Phoenix billing patterns example

Copy Markdown View Source

The complete application is in examples/phoenix_checkout. It is one runnable Phoenix lab with ten isolated vertical slices, one SQLite database, one verified webhook endpoint, and one supervision tree. Keeping the patterns together avoids nine copied project skeletons while preserving a named workflow for each case.

Pattern 1 is the original live test-mode checkout. Checkout-based patterns 2, 3, 5, 6, and 9 can also launch real test-mode sessions when you supply Dodo product IDs; every page retains a safe local action. Integration entry points live in DodoStore.Workflows.

Run it

cd examples/phoenix_checkout
mix setup

export DODO_PAYMENTS_API_KEY="your Dodo test-mode key"
export DODO_PAYMENTS_WEBHOOK_SECRETS="whsec_your_endpoint_secret"
mix run scripts/create_product.exs
mix phx.server

Open http://localhost:4000 for one-time checkout and http://localhost:4000/patterns for all patterns. Setup creates the ignored SQLite database and creates or reuses the sample USD 19 product. Use DODO_PRODUCT_ID to supply an existing product.

Pattern map

#PatternExample APICentral invariant
1One-time checkoutWorkflows.checkout/6Persist the expected order, then fulfill once from a verified payment webhook.
2SaaS subscriptionWorkflows.checkout/7Reconcile current state after lifecycle events.
3Mixed cart/add-onsWorkflows.checkout/6Track each line item independently.
4Usage billingWorkflows.record_usage/4Use a deterministic event ID and durable outbox.
5Prepaid creditsWorkflows.authorize_prepaid_usage/4Grant on a matched payment and authorize locally.
6On-demand chargeMandate-only checkout + Workflows.queue_on_demand_charge/3Require a reconciled active on_demand mandate and persist charge intent before dispatch.
7Refund/disputeWorkflows.queue_refund/3Idempotent command plus state reconciliation.
8Multiple metersWorkflows.record_multiple_meter_usage/3One stable event ID per meter and operation.
9Subscription + usage creditsWorkflows.authorize_hybrid_usage/7Subscription grants access; local allowance gates use.
10Hard usage limitsWorkflows.authorize_limited_use/6Reserve once by action ID before protected work.

Dodo product configuration determines whether a cart item is one-time, recurring, metered, or grants credits. The same server-side checkout API accepts the appropriate product IDs. For example, pattern 3 passes both the recurring base product and one-time setup product in product_cart.

Webhooks, ordering, and jobs

POST /webhooks/dodo preserves the exact raw JSON, verifies the Standard Webhooks signature, and commits the webhook ID to webhook_inbox under a unique constraint before acknowledging it. A supervised inbox processor can then restart safely because the work survives process and node restarts.

Lifecycle event order is not trusted. Subscription, refund, and dispute deliveries schedule a current-resource retrieval instead of applying a timestamp or arrival-order transition. This prevents a delayed older event from reverting a newer local state.

Usage, charge, refund, and reconciliation work is stored in billing_outbox. The included dispatcher shows the exact SDK calls. Production applications should run that dispatcher through Oban or another durable job system. An uncertain charge/refund is removed from ordinary retries and placed in reconciliation_required rather than blindly repeating a possibly completed mutation.

Pattern 10 uses a unique action-ID reservation and an atomic conditional database update, so a retry does not consume the allowance twice. Dodo metering reports usage for billing, but a remote asynchronous API is not an authorization lock. ETS is also not sufficient: it loses state on restart and does not coordinate multiple nodes. SQLite keeps the lab dependency-free; production should use the application's primary transactional database.

Configuration

VariableDefaultPurpose
DODO_PAYMENTS_API_KEYrequired for callsMerchant test/live key.
DODO_PAYMENTS_ENVIRONMENTtestTest or live API selection.
DODO_PAYMENTS_WEBHOOK_SECRETSnoneComma-separated active webhook secrets.
DODO_PRODUCT_IDgenerated IDExisting pattern-1 product.
DATABASE_PATH/data/dodo_store.db in productionPersistent SQLite file.
PHX_HOSTrequired in productionPublic HTTPS host.
SECRET_KEY_BASErequired in productionPhoenix endpoint secret.

The inbox processor is supervised by DodoStore.Supervisor. A role dedicated to serving HTTP can opt out:

config :dodo_store, :inbox_processor, enabled: false

This is different from unsupervised spawn/1: supervision provides restart semantics, while the database provides durability. The SDK's opt-in telemetry exporter has its own package supervisor; application-owned billing jobs remain the application's responsibility.

Tests and production work

cd examples/phoenix_checkout
mix test

Tests are network-free and cover all ten patterns, duplicate webhook delivery, signature failure, reversed lifecycle events, deterministic meter IDs, durable commands, and hard-cap denial.

Before production, adapt the small example domain tables to your own ledger, add authentication and account ownership checks, dispatch and monitor the outbox with a durable job runner, and define compensation for reserved usage when the protected operation fails. The app-local README contains concrete API examples, setup-lock recovery, CDN/SRI guidance, and a fuller handoff checklist.