Payments and Webhook Architecture¶
The payment system collects money for invoices. It integrates with payment providers via an adapter pattern, processes webhook events idempotently, and handles failure retries with exponential backoff.
Why this exists¶
Phase 4 generates invoices and tracks what tenants owe. But mark-paid was manual - someone had to call an endpoint to confirm payment. Phase 5 automates this: finalize an invoice → create a payment intent → provider charges the card → webhook confirms → invoice marked PAID → billing period advances.
Schema overview¶
Two new tables:
┌───────────────────────────┐
│ payment_attempts │
│───────────────────────────│
│ id │
│ invoice_id (FK) │
│ tenant_id (FK) │
│ provider_payment_id (UQ) │
│ provider │
│ status │
│ amount (cents) │
│ currency │
│ failure_reason │
│ provider_response (JSON)│
│ retry_of │
│ attempt_number │
│ next_retry_at │
│ created_at │
│ updated_at │
└───────────────────────────┘
┌───────────────────────────┐
│ webhook_events │
│───────────────────────────│
│ id │
│ provider_event_id (UQ) │
│ provider │
│ event_type │
│ status │
│ raw_payload (JSON)│
│ processing_error │
│ processed_at │
│ created_at │
│ updated_at │
└───────────────────────────┘
Relationships¶
- Invoice → PaymentAttempts: one-to-many. One invoice can have multiple attempts (retries after failure).
- Tenant → PaymentAttempts: one-to-many. Denormalized
tenant_idfor filtering without JOIN. - WebhookEvents: standalone. Not FK'd to anything - the provider event ID is the deduplication key.
Payment adapter pattern¶
All provider interaction goes through PaymentProviderBase, an abstract class with four methods:
abstract class PaymentProviderBase {
abstract createPaymentIntent(
amount,
currency,
metadata,
): Promise<CreatePaymentIntentResult>;
abstract getPaymentStatus(providerPaymentId): Promise<PaymentStatusResult>;
abstract refundPayment(providerPaymentId, amount?): Promise<RefundResult>;
abstract constructWebhookEvent(rawBody, signature): Promise<WebhookEvent>;
}
Two implementations:
| Adapter | When | Behavior |
|---|---|---|
FakePaymentAdapter |
PAYMENT_PROVIDER=fake |
In-memory, no external calls. Configurable success rate. |
StripePaymentAdapter |
PAYMENT_PROVIDER=stripe |
Real Stripe API. Signature validation on webhooks. |
The adapter is injected via PAYMENT_PROVIDER token. Swapping providers requires zero code changes - just change the env var.
Why an adapter and not direct Stripe calls¶
- Testing: fake adapter runs in CI without Stripe credentials.
- Provider migration: switching from Stripe to another provider means writing one new adapter class, not rewriting the entire payment flow.
- Dev experience:
pnpm db:seedcreates fake payment data without touching any external API.
Payment flow¶
Happy path: invoice finalize → paid¶
┌─────────┐ ┌──────────────┐ ┌──────────┐ ┌─────────────┐
│ Finalize │────▶│ Create │────▶│ Provider │────▶│ Webhook │
│ Invoice │ │ PaymentIntent│ │ charges │ │ received │
└─────────┘ └──────────────┘ └──────────┘ └──────┬──────┘
│
┌──────────────────────────────────────┘
▼
┌─────────────────┐ ┌──────────────┐ ┌──────────────┐
│ Dedup check │────▶│ Update │────▶│ Mark invoice │
│ (provider │ │ attempt │ │ PAID + ledger│
│ event ID) │ │ SUCCEEDED │ │ PAYMENT │
└─────────────────┘ └──────────────┘ └──────┬───────┘
│
▼
┌──────────────┐
│ Advance │
│ billing │
│ period │
└──────────────┘
- Invoice finalized →
PaymentIntentService.createPaymentIntent()fires (fire-and-forget, doesn't block the finalize response). - Adapter calls provider → gets back
providerPaymentId(e.g.,pi_abc123orfake_pi_seed_...). - A
payment_attemptsrow is created with status PENDING (or SUCCEEDED if the fake adapter auto-resolves). - Provider sends webhook →
WebhookControllerreceives it. WebhookProcessingServicevalidates signature, checks deduplication, routes to handler.PaymentSuccessHandlerupdates attempt to SUCCEEDED, marks invoice PAID, creates ledger PAYMENT, advances billing period.
Failure path: retry with exponential backoff¶
Day 0: Payment fails → attempt #0, status FAILED
Schedule retry → next_retry_at = now + 1 day
Day 1: Retry fires → attempt #1, status FAILED
Schedule retry → next_retry_at = now + 3 days
Day 4: Retry fires → attempt #2, status FAILED
Schedule retry → next_retry_at = now + 7 days
Day 11: Retry fires → attempt #3, status FAILED
Max retries (3) reached → subscription → PAST_DUE
Retry intervals: 1 day, 3 days, 7 days. After 3 retries (4 total attempts), the subscription transitions to PAST_DUE. Each retry creates a new payment_attempts row with retry_of pointing to the previous attempt and an incremented attempt_number.
Webhook processing¶
Why webhooks are hard¶
Three problems every webhook receiver must solve:
- Duplicates: Stripe retries on timeout. The same event arrives twice. Without dedup, you'd mark an invoice PAID twice and create two ledger entries.
- Out-of-order:
payment_intent.succeededmight arrive before thepayment_attemptsrow is created (race condition with the create call). - Timeouts: If processing takes too long, the provider retries. Now you have concurrent processing of the same event.
How Meterplex handles each¶
Duplicates: The provider_event_id column on webhook_events has a UNIQUE constraint. Before processing, we check if the event already exists. If it does and status is PROCESSED, skip. This is the deduplication gate.
Out-of-order: The PaymentSuccessHandler looks up the payment attempt by provider_payment_id. If the attempt doesn't exist yet (race condition), the handler logs a warning and returns - the event is marked PROCESSED to prevent retries. The payment attempt creation will handle the success state independently since the fake adapter returns the final status synchronously.
Timeouts: The webhook endpoint returns 200 immediately, then processes asynchronously. The provider sees success and doesn't retry. Processing happens in the background.
Webhook processing flow¶
POST /api/v1/webhooks/stripe
│
├─ Return 200 immediately (don't make Stripe wait)
│
└─ Async processing:
1. Validate signature (adapter.constructWebhookEvent)
2. Check dedup (webhookEvent.findUnique by providerEventId)
3. If exists + PROCESSED → skip
4. Create webhookEvent row (status: PENDING)
5. Update status → PROCESSING
6. Route by event type:
├─ payment_intent.succeeded → PaymentSuccessHandler
├─ payment_intent.payment_failed → PaymentFailureHandler
└─ other → mark SKIPPED
7. Update status → PROCESSED (or FAILED with error)
Payment status lifecycle¶
PaymentAttempt statuses:
PENDING ──────────▶ SUCCEEDED
│
└──────────────▶ FAILED ──▶ (new attempt created as retry)
│
└──▶ after max retries: subscription → PAST_DUE
WebhookEvent statuses:
PENDING ──▶ PROCESSING ──▶ PROCESSED
│
└──────────▶ FAILED
(unhandled event types) ──▶ SKIPPED
API endpoints¶
Webhooks (no auth - signature is the auth)¶
| Method | Path | Description |
|---|---|---|
| POST | /webhooks/stripe | Receive Stripe webhook events |
| POST | /webhooks/fake | Test endpoint for fake adapter |
Payment history (tenant-scoped, JWT + Tenant guard)¶
| Method | Path | Description |
|---|---|---|
| GET | /billing/payments | List payment attempts (paginated, filterable by status) |
| GET | /billing/payments/:id | Get payment attempt details with provider response |
Key decisions¶
| Decision | Why |
|---|---|
| Adapter pattern for payment providers | Swap providers without code changes. Fake adapter for dev/CI. |
| Fire-and-forget payment intent on finalize | Don't block the finalize response waiting for Stripe. |
| Return 200 before processing webhook | Stripe retries on non-2xx. Process async to avoid timeout retries. |
| Dedup by provider_event_id unique constraint | Database-level guarantee. No application-level race conditions. |
| Denormalized tenant_id on payment_attempts | Filter by tenant without joining through invoice. |
| retry_of FK chain on attempts | Full retry history traceable. Each attempt is its own record. |
| Exponential backoff (1d, 3d, 7d) | Gives transient failures time to resolve without spam. |
| PAST_DUE after max retries, not CANCELLED | Gives the tenant a chance to update payment method. |
Seed data¶
| Tenant | Invoice | Payment ID | Amount | Status |
|---|---|---|---|---|
| Acme | INV-2026-0001 | fake_pi_seed_inv20260001 | $99.00 | SUCCEEDED |
| Globex | INV-2026-0002 | fake_pi_seed_inv20260002 | $29.00 | SUCCEEDED |
| Stark | INV-2026-0003 | fake_pi_seed_inv20260003 | $4,788.00 | SUCCEEDED |
Each seed invoice has: 1 payment attempt (SUCCEEDED), 1 webhook event (PROCESSED), invoice status PAID, ledger PAYMENT entry.
Future enhancements¶
- Stripe integration testing: real Stripe test-mode keys in CI with webhook forwarding via Stripe CLI.
- Refund endpoint:
POST /billing/payments/:id/refundcreates a refund via adapter + ledger REFUND entry. - Admin webhook events API:
GET /admin/webhook-eventsfor support/debugging (WebhookEventResponseDto is pre-built). - Payment method management: store/update cards via Stripe Customer + SetupIntent.
- Dunning emails: automated emails on payment failure with retry schedule.
- SCA/3DS support: handle REQUIRES_ACTION status for Strong Customer Authentication.