Payment crediting
Let PaymentService own orders and issue Primary Card credits only after paid.
const credits = new CreditsService();
const payment = new PaymentService({
providers: [stripePaymentProvider()],
resolve_topup: ({ topup_amount_minor }) => ({
credits: topup_amount_minor * 10_000,
}),
on_paid: async (record) => {
await credits.topup({
card: { kind: "primary", user_id: record.user_id },
credits: record.credits,
source: "payment",
ref: record.payment_id,
idempotency_key: `payment:${record.payment_id}`,
note: record.note,
});
},
});Users create a Payment order directly; Credits does not store pending payment topups:
await city.payment.method("stripe").invoke({
topup_amount_minor: 500,
idempotency_key: "order_123",
note: "Buy Credits",
});The user submits the intended payment amount. resolve_topup calculates and snapshots Credits on Federation, so the client never controls the conversion rate. Payment confirmation must use trusted amount, currency, and external transaction facts from a verified event or Provider API and compare them with the local order snapshot. Request metadata is not proof of payment. Tax, discount, and currency conversion semantics must be explicit; missing or mismatched facts block fulfillment and require review. Every paid event requires trusted amount, currency, and an external transaction ID; missing or mismatched facts cause a 422 failure and enter review. Stripe projects subtotal minus discount and checks the total equation. Creem uses signed order amount_paid when present, otherwise amount. Waffo converts the signed decimal amount exactly using currency precision. Dodo currently requires one-time dynamic pricing in the same currency, no discounts/PPP, and tax-inclusive pricing, then checks signed total_amount. See the Provider pages for exact fields and limits. These rules do not promise automatic support for arbitrary tax, discount, or currency-conversion schemes.
Event inbox and recovery
After signature verification and normalization, webhook handling persists the trusted event in the inbox and enqueues it before returning HTTP 200. This acknowledges receipt, not completed fulfillment. Enqueue failures return an error, while the stored event remains recoverable. Queue runs payment.events/process; replay uses the stored normalized event without re-verifying the old HTTP request signature.
The host must periodically call payment.recover_events(), or an administrator can call POST /v1/payment/events/recover, to wake due pending events and processing events with expired leases. The current Edge Worker scans every five minutes; federation.json declares deployment schedules through deployment.crons.
Transient failures retry with exponential backoff starting at 15 seconds, capped at one hour, and move to review after 20 attempts. Amount validation failures (422) enter review directly instead of retrying indefinitely.
Administrators use GET /v1/payment/events/inbox to inspect the latest 100 events, status, attempts, and diagnostic errors. After addressing the cause, call POST /v1/payment/events/replay with JSON { "event_id": "provider:event_id" }. Replay repeats order validation and idempotent fulfillment; it never bypasses amount checks. Implement on_paid(record) idempotently using the stable payment_id and entitlement snapshot.