Add a complete prepaid credit system with Stripe payment integration to a Phoenix 1.8 app.
What Gets Created
- Credit Balances — Per-account balance with optimistic locking
- Credit Transactions — Immutable append-only ledger (deductions, additions, adjustments)
- Usage Events — Per-operation tracking with cost, cache status, metadata
- Billing — Credit pack configuration, Stripe PaymentIntent creation, customer lifecycle
- Stripe Webhook — Signature verification, event routing, idempotent credit fulfillment
- PgFlow Job — Async credit fulfillment with deduplication
- LiveView Pages — Purchase page with Stripe Payment Element, balance/history page
- JS Hook — Stripe.js lazy-loading and Payment Element mounting
Prerequisites
Verify before starting:
- Phoenix 1.8 app with
phx.gen.authcompleted - Multi-tenant account model exists (accounts table with users)
- DaisyUI installed for UI components
- PgFlow configured for background jobs (see bootstrap-phoenix /
/pgflow bootstrap) - Nebulex or similar cache configured (optional — skip caching if absent)
App Name Detection
Detect the app module name from mix.exs (e.g., MyApp) and the OTP app name (e.g., my_app). Replace all template references accordingly. Also detect:
- The accounts schema module and table name
- The repo module
- The endpoint module
- The web module prefix
- Whether a cache module exists
Phase 0: Discovery — Check What Already Exists
CRITICAL: Before generating anything, audit the target app.
Discovery Checklist
-
Dependencies — Check
mix.exsforstripity_stripeandpgflow- If
stripity_stripemissing: add to deps and tell user to runmix deps.get - If
pgflowmissing: add{:pgflow, github: "agoodway/pgflow", branch: "main"}and warn the user to run/pgflow bootstrap(or follow bootstrap-phoenix Phase 7) before continuing
- If
-
Migrations — Search
priv/repo/migrations/for credit-related tablesGlob("priv/repo/migrations/*credit*")andGrep("credit_balances|credit_transactions|usage_events", path: "priv/repo/migrations/")
-
Schemas — Search for existing credit schemas
Grep("schema \"credit_balances\""),Grep("schema \"credit_transactions\""),Grep("schema \"usage_events\"")
-
Context — Search for existing credit context
Grep("def deduct\\|def add\\|def get_balance\\|def compute_cost")
-
Billing modules — Search for Stripe integration
Grep("PaymentIntent\\|StripeCustomer\\|CreditPurchaseWorker")
-
Webhook controller — Search for existing Stripe webhook
Grep("stripe_webhook\\|StripeWebhook")
-
LiveViews — Search for existing credit LiveViews
Glob("lib/**/credit_live/**")orGrep("CreditLive")
-
JS Hook — Search for Stripe payment hook
Grep("StripePaymentElement", path: "assets/")
-
Accounts schema — Check if
stripe_customer_idfield existsGrep("stripe_customer_id", path: "lib/")
-
Cache module — Check for Nebulex or similar
Grep("Nebulex\\|use Nebulex")orGrep("defmodule.*Cache")
Discovery Report
Present findings and ask for confirmation before proceeding.
Phase 1: Gather Configuration
Ask the user:
-
Account association — What schema do credits belong to? Default:
accounts(the multi-tenant parent) -
Credit packs — What packs to offer? Default: Starter (500 credits / $5), Pro (2000 / $20), Scale (10000 / $100)
-
Operation costs — What operations consume credits and how much? Example:
%{verify_email: 2, verify_phone: 2, api_call: 1}The user must define at least one operation. -
Default balance — Free credits for new accounts? Default: 25
-
Route paths — Where to mount the credit pages? Default:
/dashboard/credits(purchase at/dashboard/credits/purchase) -
Usage events — Track per-operation usage with API key association? Default: yes if API keys exist, no otherwise
Phase 2: Implementation
Execute in dependency order, skipping existing components.
Step 1: Dependencies
Add to mix.exs if missing:
{:stripity_stripe, "~> 3.2"}
Step 2: Environment Configuration
Read references/config.md
Add config entries for credits and Stripe.
Step 3: Data Layer
Create migrations and schemas for:
credit_balances— per-account balance with optimistic lockingcredit_transactions— immutable transaction logusage_events— per-operation usage tracking (if enabled)stripe_customer_idfield on accounts table- Idempotency index on credit_transactions for payment deduplication
Step 4: Credit Context
Read references/credits-context.md
Create the credits context module with:
compute_cost/2— config-driven cost registry with add-on supportget_balance/1— cached balance lookuphas_sufficient_credits?/2— balance checkdeduct/3— atomic deduction with optimistic lock retryadd/3— atomic addition with FOR UPDATE lockcreate_balance/2— initialize balance for new accountsget_usage/2— aggregated usage stats for date rangelist_transactions/2— paginated transaction historylist_usage_events/2— paginated usage eventsrecord_usage_event/1— standalone event recording
Step 5: Billing Modules
Create:
Billing— credit pack configuration helpersBilling.Payments— Stripe PaymentIntent creationBilling.StripeCustomers— lazy Stripe customer creation and persistenceBilling.CreditPurchaseWorker— PgFlow job for idempotent credit fulfillment- Register the job in
:my_app, PgFlowjobs:and compile withmix pgflow.gen.job_migration MyApp.Billing.CreditPurchaseWorker
Step 6: Stripe Webhook
Create:
StripeWebhookBodyReaderplug — preserves raw body for signature verificationStripeWebhookController— verifies signature, routes events, enqueues jobs- Router entries: webhook pipeline + route
Step 7: LiveView Pages
Create:
CreditLive.Purchase— pack selection + Stripe Payment Element checkoutCreditLive.Index— balance display, transaction history, usage events with tabs/pagination- Router entries for both pages
Step 8: JavaScript Hook
Add StripePaymentElement hook to assets/js/app.js:
- Lazy-loads Stripe.js from CDN
- Mounts Payment Element on
stripe:client_secretevent - Handles payment confirmation with return URL
- Cleanup on destroy
Step 9: Integration
- Hook into account creation to auto-create credit balance (call
Credits.create_balance/1) - Add credit balance to dashboard assigns if a dashboard layout exists
- Run
mix ecto.migrate - Run
mix testto verify
Key Design Patterns
Optimistic Locking
CreditBalance uses lock_version to prevent concurrent deduction races. Deductions retry once on StaleEntryError.
Atomic Operations
Ecto.Multi ensures balance update + transaction log + usage event all succeed or all fail.
Idempotent Payments
A unique partial index on credit_transactions prevents double-crediting if the Stripe webhook fires multiple times:
CREATE UNIQUE INDEX ON credit_transactions (account_id, (metadata->>'stripe_payment_intent_id'))
WHERE type = 'addition' AND metadata ? 'stripe_payment_intent_id'
Lazy Stripe Customer
Stripe customer IDs are created on-demand during the first payment and persisted on the account.
Raw Body Preservation
Stripe webhook signature verification requires the raw request body. A custom BodyReader plug stores it on conn.assigns before JSON parsing.
Cache Invalidation
Credit balances are cached (30s TTL) for read performance and invalidated after every deduction/addition.
Security Considerations
- Stripe webhook secret verified on every request
- Raw body preserved for signature verification (not re-serialized JSON)
- Payment metadata validated before job enqueue
- Credit operations use database-level locking (not application-level)
- Idempotency index prevents financial double-crediting
- Stripe publishable key validated before showing payment form