How BabySea built atomic credit settlement on Stripe and Postgres
Date: May 2, 2026
By: Randy Aries Saputra
AI generation billing is not checkout. It is settlement.
That distinction matters because generative media does not behave like a normal SaaS seat, a normal API call, or a normal one-time purchase. A customer can start an image or video generation, the accepted cost must be reserved immediately, the provider can take seconds or minutes to finish, the webhook can arrive late or twice, and the process can crash between reservation and terminal settlement. Weeks later, the underlying Stripe payment may need a separate refund or dispute workflow outside the credit ledger.
If the billing system treats that lifecycle as a simple payment event, it eventually leaks money or user trust.
At BabySea, Stripe moves money. PostgreSQL enforces settlement integrity. The application coordinates the flow, but it does not hold the invariant. That boundary is the core of our credit system and the reason we open-sourced the reusable pattern as ledger-fortress.
Why AI billing is a settlement problem
Stripe is excellent at the payment side of the system: subscriptions, checkout, invoices, disputes, refunds, webhook delivery, and reconciliation around real money movement. But an AI generation platform still needs a separate operating ledger for spendable credits because customer spend happens asynchronously after the Stripe payment.
The hard part is not adding credits after an invoice is paid. The hard part is preserving correctness across every state transition after that.
Stripe payment succeeds
-> credits granted
-> generation cost is computed from accepted inputs
-> generation reserves credits
-> provider runs async workload
-> generation succeeds, fails, times out, or is canceled
-> charge or refund records the terminal outcome
-> Stripe refund or dispute policy stays outside the ledger packageEach arrow is a failure boundary. If any boundary relies on a best-effort application check, the system is already fragile.
The payment processor should not be asked to understand every generation state. The generation system should not be asked to act like a bank. The ledger is the contract between them.
Stripe moves money, Postgres enforces credits
The architecture is intentionally boring.
Stripe
-> invoice.paid
-> checkout.session.completed
-> checkout.session.async_payment_succeeded
Postgres
-> plans
-> credits
-> credit_ledger
-> credit_alert_settings
-> credit_alert_log
Application
-> maps Stripe customers to accounts
-> starts generation only after reserve succeeds
-> calls add, reserve, charge, or refund
-> handles Stripe refunds and disputes outside the credit ledgerStripe is the source of truth for external payment events. Postgres is the source of truth for internal spendable balance and settlement history. The application is deliberately thin at the invariant boundary.
That separation matters because Stripe webhooks are asynchronous by design. They can be retried. They can arrive after local state has moved forward. They can describe a payment that affects credits the customer has already spent. The database must make every replay and every ordering safe.
The two ledgers
There are really two ledgers in this system.
The first is Stripe's ledger of money movement: invoices, checkout sessions, refunds, and disputes.
The second is the credit ledger pattern we package in ledger-fortress: reserves, charges, refunds, and additive grants.
They are connected, but they are not the same thing.
The mistake is to collapse payment state and workload state into one abstraction. Payments settle money. Workloads settle credits.
In ledger-fortress, the core storage shape is small:
CREATE TABLE credits (
account_id uuid PRIMARY KEY,
tokens numeric(10, 3) NOT NULL DEFAULT 0 CHECK (tokens >= 0),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE credit_ledger (
id uuid PRIMARY KEY,
account_id uuid NOT NULL,
type text NOT NULL CHECK (type IN ('reserve', 'charge', 'refund', 'add')),
amount numeric(10, 3) NOT NULL CHECK (amount > 0),
balance_after numeric(10, 3) NOT NULL,
generation_id text,
model text,
description text,
created_at timestamptz NOT NULL DEFAULT now()
);credits is the current spendable balance. credit_ledger is the immutable audit trail. The balance can be recomputed and inspected from the ledger, but runtime execution reads the current balance because generation gating has to be fast.
The hot path is one atomic update
The most important query in the system is not a Stripe call. It is the reservation.
UPDATE credits
SET tokens = tokens - p_tokens,
updated_at = now()
WHERE account_id = p_account_id
AND tokens >= p_tokens
RETURNING tokens;That one statement answers the only question the generation path needs to ask before dispatching work: can this account afford this generation right now?
There is no separate read. There is no application lock. There is no distributed transaction. If two requests race, PostgreSQL serializes the updates. The loser sees the new balance and fails if there is not enough left.
The generation starts after the database has already made the affordability decision atomically.
The lifecycle then becomes explicit:
reserve_credits
-> deduct balance and write reserve ledger entry
charge_credits
-> confirm successful generation, log-only because reserve already deducted
refund_credits
-> return reserved credits when generation fails or is canceled
add_credits
-> grant paid Stripe invoice or checkout credits additivelyThis is the pattern we use for async generation: compute the accepted cost before reserve, deduct before spending provider capacity, then confirm or refund after the workload reaches a terminal state.
Idempotency belongs in the database
Webhook retries are not exceptional. They are part of the system. The ledger has to treat them as normal traffic.
ledger-fortress enforces idempotency with unique partial indexes, not handler memory:
CREATE UNIQUE INDEX idx_credit_ledger_charge_idempotent
ON credit_ledger (generation_id) WHERE type = 'charge';
CREATE UNIQUE INDEX idx_credit_ledger_refund_idempotent
ON credit_ledger (generation_id) WHERE type = 'refund';
CREATE UNIQUE INDEX idx_credit_ledger_add_idempotent
ON credit_ledger (account_id, description) WHERE type = 'add';
CREATE UNIQUE INDEX idx_credit_ledger_reserve_idempotent
ON credit_ledger (generation_id)
WHERE type = 'reserve' AND generation_id IS NOT NULL;This means duplicate success callbacks, duplicate failure callbacks, duplicate Stripe invoice webhooks, duplicate credit pack webhooks, and duplicate reservation retries all collapse into safe no-ops.
The application can retry. Stripe can retry. Cron can retry. The database decides whether a state transition already happened.
Turning Stripe payments into credits
The Stripe side of the system is narrow by design.
The webhook route starts by verifying Stripe's signature against the raw request body. Parsed JSON is not enough because signature verification depends on the exact bytes Stripe sent.
const payload = await request.text();
const signature = request.headers.get('stripe-signature');
const event = verifyStripeSignature(
stripe,
payload,
signature,
process.env.STRIPE_WEBHOOK_SECRET!,
);After verification, invoice.paid grants subscription credits, while checkout.session.completed or checkout.session.async_payment_succeeded grants paid one-time credit packs. By default, both paths convert cents to credits and call add_credits with an idempotency key derived from the Stripe object.
const credits = invoice.amount_paid / 100;
const idempotencyKey = `invoice:${invoice.id}`;
await fortress.addCredits({
accountId,
amount: credits,
description: idempotencyKey,
idempotencyKey,
});For credit packs, BabySea production keys the grant from its internal order ID. The standalone OSS helper uses the Stripe payment intent when present, otherwise the checkout session ID, because those IDs are available in a portable Stripe integration:
const idempotencyKey = `order:${paymentIntentId ?? session.id}`;That small convention is what makes Stripe retries harmless. If the same invoice or checkout completion is delivered twice, the second insert hits the add idempotency index and does not grant credits again.
It also gives credits rollover semantics. A renewal adds to the existing balance. It never resets the balance.
previous balance: 3.500
invoice.paid: +29.000
new balance: 32.500That detail is critical for usage-based products with credit packs. A customer who buys a one-time pack should not lose it on subscription renewal.
Stale checkout sessions are a real edge case
Stripe Checkout sessions can outlive the state that created them. A customer can start a credit pack checkout, cancel or lose the subscription that made them eligible, and then complete the checkout later.
The production pattern is to guard credit pack redemption at webhook time, not only at session creation time.
if (hasActiveSubscription) {
const active = await hasActiveSubscription(accountId);
if (!active) {
return { handled: true, action: 'skipped_no_subscription' };
}
}The payment event is still observed. Credits are not granted unless the account is still eligible. That turns a subtle stale-session problem into an explicit operational case.
Generation settlement: reserve, then confirm
The generation path is separate from the payment path.
Before dispatching an async generation, the application computes and reserves the accepted cost:
const acceptedCost = priceGeneration(input);
const reserved = await fortress.reserve({
accountId,
generationId,
amount: acceptedCost,
model,
});
if (!reserved) {
return { error: 'insufficient_credits' };
}
await runGeneration();When the workload succeeds, the system calls charge. Because the reservation already deducted the balance, charge is a confirmation event.
await fortress.charge({
accountId,
generationId,
amount: acceptedCost,
model,
});When the workload fails or is canceled, the system calls refund.
await fortress.refund({
accountId,
generationId,
amount: acceptedCost,
model,
});This split keeps the critical path tight. The request path does one balance mutation. The async path records the terminal outcome.
Out-of-order callbacks are where billing systems break
The most dangerous failures happen when two truthful events arrive in the wrong order.
Example: a timeout path refunds a reservation, then a late success callback arrives. If charge is log-only and does not notice the prior refund, the customer gets the output for free.
So charge_credits checks for an existing refund under lock. If a refund already returned credits, charge re-deducts the reserved amount before logging success. If the balance cannot cover the late correction, it returns FALSE instead of marking the generation charged, leaving the application to retry, pause the account, or escalate the case.
The opposite ordering matters too. If a generation has already been charged, a late refund path must not return credits.
IF EXISTS (
SELECT 1 FROM credit_ledger
WHERE generation_id = p_generation_id
AND type = 'charge'
) THEN
RETURN FALSE;
END IF;That guard prevents the deadly sequence: reserve, success webhook, charge, crash recovery, refund, free output.
Out-of-order events are not bugs in an async system. They are part of the environment the ledger must be designed to survive.
Pricing boundary: compute before reserve
Generative media is often priced from user-selected parameters such as model, duration, output count, resolution, and audio mode. In the current BabySea credit implementation and the current ledger-fortress OSS surface, that cost is computed before reserve_credits runs.
That boundary keeps terminal settlement simple: reserve, charge, and refund all use the same accepted amount for the generation.
const acceptedCost = priceGeneration({
model: 'video-5s',
durationSeconds: 5,
resolution: '720p',
});
await fortress.reserve({
accountId,
generationId,
amount: acceptedCost,
model: 'video-5s',
});
await fortress.charge({
accountId,
generationId,
amount: acceptedCost,
model: 'video-5s',
});ledger-fortress deliberately does not ship settle_credits, true-up ledger entries, negative balances, or uncollectible debt tracking. If a product needs a different terminal pricing policy, it should be implemented as an explicit extension and tested separately from this BabySea-derived OSS surface.
That is the important accounting property in the current package: the workload settles through explicit charge or refund transitions using the reserved amount, and never depends on silently adjusting the cost after reserve.
Stripe refunds and disputes stay outside the package
When a Stripe payment is refunded or disputed, the credits granted by that payment may already be gone. The policy response is product-specific: support workflow, account review, refund handling, dispute evidence, or a custom extension.
The current BabySea credit implementation does not automatically convert charge.refunded or charge.dispute.created into credit deductions, so the current ledger-fortress OSS package does not ship clawback_credits, debt entries, or dispute handlers.
switch (event.type) {
case 'invoice.paid':
return grantSubscriptionCredits(event);
case 'checkout.session.completed':
case 'checkout.session.async_payment_succeeded':
return grantCreditPackCredits(event);
default:
return { handled: false };
}The boundary is intentional. ledger-fortress handles the credit grants and generation settlement invariants that BabySea actually runs. Refund and dispute deductions remain outside the public package unless they are implemented and validated as a separate flow.
Crash recovery closes the ghost-reservation hole
The slowest leak in async billing is the ghost reservation: credits are reserved, the generation is dispatched, the process dies, and no terminal callback is ever recorded.
ledger-fortress treats crash recovery as a replayable settlement path.
SELECT * FROM find_orphaned_reservations(5, 100);That function finds reservations older than a safety window with no matching charge or refund settlement. The recovery worker calls refund_credits for each orphan. Because refund_credits no-ops if a charge or refund already exists, recovery can overlap safely with late provider callbacks.
cron finds orphan
-> refund_credits
-> if already charged, no-op
-> if already refunded, no-op
-> otherwise return creditsThis is another place where correctness belongs in the ledger rather than the scheduler. The cron can miss a cycle. It can run twice. It can overlap with webhooks. The database still decides the valid terminal state.
Alerts should not block settlement
Low-balance alerts are useful, but they should never be on the critical path for generation. In ledger-fortress, alert checks are intentionally fire-and-forget.
void fortress.checkAlerts(accountId);Each threshold fires once per descent and resets when balance recovers. The alert check runs outside the generation gating path, and the alert system can fail without blocking reservation, charge, refund, or Stripe reconciliation.
That boundary keeps billing correctness separate from notification delivery.
Security: the ledger cannot be a public table
Credit tables are not application cache. They are financial state. The open-source migrations enforce that posture.
ledger-fortress enables row-level security on every table, revokes anonymous and authenticated table access, and runs mutating operations through SECURITY DEFINER functions with a locked search_path.
ALTER TABLE credits ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON credits FROM anon;
REVOKE ALL ON credits FROM authenticated;
ALTER FUNCTION reserve_credits(uuid, numeric, text, text)
SECURITY DEFINER
SET search_path = pg_catalog, public;The public contract is not direct table mutation. The public contract is the set of functions that preserve the invariants.
Why this became OSS
Every AI generation company eventually rebuilds this system.
They start with Stripe Checkout. Then they add credits. Then they add async generation. Then they discover duplicate webhooks, out-of-order callbacks, stale checkout sessions, crash recovery, low-balance notifications, audit requirements, and the need to keep refund/dispute policy separate from the credit ledger invariant.
By the time it works, the system is no longer checkout. It is a settlement engine.
That is why we open-sourced ledger-fortress under Apache 2.0. The repository packages the generalized pattern: PostgreSQL migrations, a TypeScript SDK, a core Python SDK, Stripe webhook helpers, JSON Schemas, crash recovery, credit alerts, and local examples.
The pattern should be reusable. The customer data, payment history, workload mix, fraud signals, and production operating graph remain BabySea's.
The boundary is deliberate. We are not publishing BabySea's customer ledger. We are publishing the shape of a settlement system that should not need to be rediscovered by every team building async AI infrastructure.
The broader point
As AI products move from demos to production, billing moves from payment collection to workload settlement. The systems that survive will treat money movement, credit movement, workload state, and reconciliation as separate but connected contracts.
Stripe is the right primitive for external payments. Postgres is the right primitive for local settlement invariants. The application should coordinate, not improvise.
That is the lesson behind ledger-fortress: every generation settles, every retry is safe, every grant, reserve, charge, and refund is auditable, and the balance never depends on the hope that callbacks arrive once, in order, at the perfect time.