Accrue.Processor.Fake (accrue v1.5.1)

Copy Markdown View Source

Deterministic in-memory Accrue.Processor adapter for tests and demos.

The Fake is Accrue's primary test surface. It implements the Accrue.Processor behaviour entirely in-process with a GenServer + struct state:

  • Deterministic ids per resource with 5-digit zero-padded counters: cus_fake_00001, sub_fake_00001, in_fake_00001, pi_fake_00001, si_fake_00001, pm_fake_00001, ch_fake_00001, re_fake_00001.
  • Test clock — all timestamps derive from an in-memory clock that starts at Accrue.Processor.Fake.State.epoch/0 and moves only via advance/2 (or advance_subscription/2 for subscription-aware clock crossing), mirroring Stripe test-clock semantics.
  • Clean resetreset/0 zeros all counters, clears state, and restores the clock to the epoch. Call in setup blocks.
  • Scripted responsesscripted_response/2 programs a one-shot return value for a named op so tests can simulate processor failures (card declined, rate limit) without mocking.
  • Subscription transitionstransition/3 moves a subscription to any status, optionally synthesizing customer.subscription.updated webhooks in-process.
  • Trial crossingadvance_subscription/2 advances the clock and, if the crossing period includes trial_end - 3d or trial_end, synthesizes customer.subscription.trial_will_end or customer.subscription.updated (status→active) events.

Startup

The Fake is a GenServer with a fixed name (__MODULE__). It is not started by Accrue.Application — tests that need it call:

setup do
  case Accrue.Processor.Fake.start_link([]) do
    {:ok, _} -> :ok
    {:error, {:already_started, _}} -> :ok
  end

  :ok = Accrue.Processor.Fake.reset()
  :ok
end

Id prefixes

Prefixes are module attributes so they are greppable and future-proof:

@customer_prefix     "cus_fake_"
@subscription_prefix "sub_fake_"
@invoice_prefix      "in_fake_"
@payment_intent_prefix "pi_fake_"
@setup_intent_prefix   "si_fake_"
@payment_method_prefix "pm_fake_"
@charge_prefix         "ch_fake_"
@refund_prefix         "re_fake_"
@event_prefix          "evt_fake_"

Summary

Functions

Returns all stored connect accounts (always platform-scoped — connected accounts are never themselves nested under another connected account).

Advances the in-memory clock by seconds seconds. Existing Phase 1 API — preserved for tests that only need to push the clock without any subscription-aware webhook synthesis.

Subscription-aware clock advance (D3-82). Advances the Fake clock by opts[:days] * 86400 + opts[:seconds] and, if stripe_id references a subscription with a trial_end, synthesizes

Returns the number of times callback has been invoked against this Fake since the last reset/0. Used by Phase 5 Plan 05 tests to count distinct processor calls through separate_charge_and_transfer.

Returns deterministic callback arguments captured since the last reset.

Returns a specification to start this module under a supervisor.

Returns the current in-memory clock value.

Returns all customers stored under scope. Scope is either a binary "acct_..." (connected account) or the :platform atom (no with_account/2 wrapper).

Returns the id prefix map for the Fake adapter (D-20). Phase 3 resource types already have counter slots and prefixes reserved here so growing the callback list never churns id shapes.

Loads pre-existing customers and subscriptions into the in-memory state, keyed by their processor id, and raises the per-resource counters so subsequently-created resources never reuse a loaded id.

Returns the Fake-stored meter events for the given customer (by processor_id) in insertion order. Test helper only — the Fake never exposes meter events through the behaviour (Stripe doesn't either).

Alias of current_time/0 — the canonical name used by Accrue.Clock when the runtime env is :test (D3-86). Kept as a thin wrapper so callers don't have to remember to pass a server argument, and so the grep pattern Fake.now is stable across the codebase.

Seeds active entitlements for a customer processor id.

Returns the completed create result held by a provider idempotency key.

Resets all counters, stored resources, scripts, and the clock.

Full reset like reset/0, but preserves Connect account rows and the :connect_account counter.

Pre-programs a one-shot return value for the named op. The next call to that op consumes the scripted response; subsequent calls fall back to the default in-memory behaviour.

Starts the Fake processor with a fixed name.

Overrides one behaviour callback with a custom function for the lifetime of the GenServer (until reset/0). Intended for per-test stubbing.

Returns all stored transfers filtered by scope. Transfers are always platform-scoped (the platform is the party initiating the transfer), but the filter parameter is accepted for API symmetry with the other *_on/1 helpers.

Transitions a stored subscription to new_status. By default synthesizes a customer.subscription.updated event in-process; pass synthesize_webhooks: false to skip.

Removes a callback override without resetting deterministic resources.

Functions

accounts()

@spec accounts() :: [map()]

Returns all stored connect accounts (always platform-scoped — connected accounts are never themselves nested under another connected account).

advance(server \\ __MODULE__, seconds)

@spec advance(GenServer.server(), integer()) :: :ok

Advances the in-memory clock by seconds seconds. Existing Phase 1 API — preserved for tests that only need to push the clock without any subscription-aware webhook synthesis.

Accepts an optional server argument for tests that explicitly name the GenServer.

advance_subscription(stripe_id, opts)

@spec advance_subscription(
  String.t() | nil,
  keyword()
) :: :ok

Subscription-aware clock advance (D3-82). Advances the Fake clock by opts[:days] * 86400 + opts[:seconds] and, if stripe_id references a subscription with a trial_end, synthesizes:

  • customer.subscription.trial_will_end when crossing trial_end - 3d
  • customer.subscription.updated (with status: :active) when crossing trial_end

Pass synthesize_webhooks: false to skip the in-process event dispatch (useful for tests that only care about the state side effects).

call_count(callback)

@spec call_count(atom()) :: non_neg_integer()

Returns the number of times callback has been invoked against this Fake since the last reset/0. Used by Phase 5 Plan 05 tests to count distinct processor calls through separate_charge_and_transfer.

calls()

@spec calls() :: [{atom(), [term()]}]

Returns deterministic callback arguments captured since the last reset.

charges_on(scope)

@spec charges_on(String.t() | :platform) :: [map()]

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

current_time(server \\ __MODULE__)

@spec current_time(GenServer.server()) :: DateTime.t()

Returns the current in-memory clock value.

customers_on(scope)

@spec customers_on(String.t() | :platform) :: [map()]

Returns all customers stored under scope. Scope is either a binary "acct_..." (connected account) or the :platform atom (no with_account/2 wrapper).

id_prefixes()

@spec id_prefixes() :: %{required(atom()) => String.t()}

Returns the id prefix map for the Fake adapter (D-20). Phase 3 resource types already have counter slots and prefixes reserved here so growing the callback list never churns id shapes.

load_fixtures(fixtures)

@spec load_fixtures(map()) :: :ok

Loads pre-existing customers and subscriptions into the in-memory state, keyed by their processor id, and raises the per-resource counters so subsequently-created resources never reuse a loaded id.

This exists so a host demo/dev app can rehydrate the Fake from durable storage on boot: the seeds that created these rows ran in a separate BEAM node, so the in-memory Fake state was lost while the DB rows (and their processor_ids) survived. Loaded records are built with the same shape as create_customer/create_subscription, so every downstream update_*/cancel_*/retrieve_* handler resolves them identically to natively-created resources.

fixtures is a map with optional keys:

  • :customers — list of %{id:, name:, email:, metadata:}
  • :subscriptions — list of %{id:, customer_id:, item_id:, price_id:, product_id:, quantity:, status:, metadata:, current_period_start:, current_period_end:}
  • :counters — map of resource => non_neg_integer; each named counter is raised to at least the given value (a floor), so new ids skip past loaded ones. Callers pass the max numeric suffix seen among <prefix>NNNNN ids.

Idempotent: reloading the same fixtures yields the same state.

meter_events_for(stripe_customer_id)

@spec meter_events_for(Accrue.Billing.Customer.t() | String.t()) :: [map()]

Returns the Fake-stored meter events for the given customer (by processor_id) in insertion order. Test helper only — the Fake never exposes meter events through the behaviour (Stripe doesn't either).

now()

@spec now() :: DateTime.t()

Alias of current_time/0 — the canonical name used by Accrue.Clock when the runtime env is :test (D3-86). Kept as a thin wrapper so callers don't have to remember to pass a server argument, and so the grep pattern Fake.now is stable across the codebase.

put_entitlements(customer_id, entitlements)

(since 1.5.0)
@spec put_entitlements(String.t(), [map()]) :: :ok

Seeds active entitlements for a customer processor id.

Test helper for the advisory Stripe-native sync path.

reconcile_create_subscription(idempotency_key)

@spec reconcile_create_subscription(String.t()) :: {:ok, map()} | {:error, :not_found}

Returns the completed create result held by a provider idempotency key.

reset()

@spec reset() :: :ok

Resets all counters, stored resources, scripts, and the clock.

reset_preserve_connect()

@spec reset_preserve_connect() :: :ok

Full reset like reset/0, but preserves Connect account rows and the :connect_account counter.

Accrue.BillingCase uses this in setup/1 so async billing tests do not wipe in-memory Connect state while Accrue.ConnectCase (or other modules) are mid-flight on the shared named Fake GenServer.

scripted_response(op, result)

@spec scripted_response(atom(), {:ok, map()} | {:error, Exception.t()}) :: :ok

Pre-programs a one-shot return value for the named op. The next call to that op consumes the scripted response; subsequent calls fall back to the default in-memory behaviour.

Fake.scripted_response(:create_subscription, {:error, %Accrue.CardError{...}})

start_link(opts)

@spec start_link(keyword()) :: GenServer.on_start()

Starts the Fake processor with a fixed name.

stub(callback, fun)

@spec stub(atom(), (... -> term())) :: :ok

Overrides one behaviour callback with a custom function for the lifetime of the GenServer (until reset/0). Intended for per-test stubbing.

subscriptions_on(scope)

@spec subscriptions_on(String.t() | :platform) :: [map()]

transfers_on(scope)

@spec transfers_on(String.t() | :platform) :: [map()]

Returns all stored transfers filtered by scope. Transfers are always platform-scoped (the platform is the party initiating the transfer), but the filter parameter is accepted for API symmetry with the other *_on/1 helpers.

transition(stripe_id, new_status, opts \\ [])

@spec transition(String.t(), atom(), keyword()) ::
  {:ok, map()} | {:error, Accrue.APIError.t()}

Transitions a stored subscription to new_status. By default synthesizes a customer.subscription.updated event in-process; pass synthesize_webhooks: false to skip.

unstub(callback)

@spec unstub(atom()) :: :ok

Removes a callback override without resetting deterministic resources.