@strav/payment 1.0.0-alpha.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/package.json +34 -0
  2. package/src/drivers/index.ts +6 -0
  3. package/src/drivers/mock_driver.ts +534 -0
  4. package/src/drivers/omise/index.ts +56 -0
  5. package/src/drivers/omise/omise_config.ts +19 -0
  6. package/src/drivers/omise/omise_driver.ts +576 -0
  7. package/src/drivers/omise/omise_mappers.ts +180 -0
  8. package/src/drivers/omise/omise_method_spec.ts +88 -0
  9. package/src/drivers/omise/omise_next_action_mapper.ts +89 -0
  10. package/src/drivers/omise/omise_price_spec.ts +85 -0
  11. package/src/drivers/omise/omise_provider.ts +33 -0
  12. package/src/drivers/omise/omise_schedule_mapper.ts +156 -0
  13. package/src/drivers/omise/omise_webhook.ts +162 -0
  14. package/src/drivers/payment_method_helpers.ts +35 -0
  15. package/src/drivers/stripe/index.ts +40 -0
  16. package/src/drivers/stripe/mappers/stripe_mappers.ts +312 -0
  17. package/src/drivers/stripe/mappers/stripe_method_spec.ts +77 -0
  18. package/src/drivers/stripe/mappers/stripe_next_action_mapper.ts +163 -0
  19. package/src/drivers/stripe/stripe_config.ts +18 -0
  20. package/src/drivers/stripe/stripe_driver.ts +650 -0
  21. package/src/drivers/stripe/stripe_provider.ts +38 -0
  22. package/src/drivers/stripe/webhook/stripe_normalize.ts +139 -0
  23. package/src/drivers/unsupported.ts +20 -0
  24. package/src/dto/index.ts +72 -0
  25. package/src/dto/payment_charge.ts +158 -0
  26. package/src/dto/payment_checkout.ts +46 -0
  27. package/src/dto/payment_customer.ts +52 -0
  28. package/src/dto/payment_event.ts +83 -0
  29. package/src/dto/payment_invoice.ts +39 -0
  30. package/src/dto/payment_link.ts +81 -0
  31. package/src/dto/payment_method.ts +43 -0
  32. package/src/dto/payment_price.ts +47 -0
  33. package/src/dto/payment_product.ts +40 -0
  34. package/src/dto/payment_subscription.ts +71 -0
  35. package/src/index.ts +78 -0
  36. package/src/ledger/apply_payment_ledger_migration.ts +106 -0
  37. package/src/ledger/index.ts +13 -0
  38. package/src/ledger/payment_ledger.ts +260 -0
  39. package/src/ledger/payment_ledger_models.ts +66 -0
  40. package/src/ledger/schemas/payment_customer_schema.ts +34 -0
  41. package/src/ledger/schemas/payment_invoice_schema.ts +39 -0
  42. package/src/ledger/schemas/payment_subscription_schema.ts +34 -0
  43. package/src/payment_capabilities.ts +91 -0
  44. package/src/payment_driver.ts +167 -0
  45. package/src/payment_error.ts +159 -0
  46. package/src/payment_manager.ts +174 -0
  47. package/src/payment_provider.ts +93 -0
  48. package/src/tenant_metadata.ts +60 -0
  49. package/src/types.ts +49 -0
  50. package/src/webhook/index.ts +8 -0
  51. package/src/webhook/payment_webhook.ts +190 -0
  52. package/src/webhook/payment_webhook_event.ts +22 -0
  53. package/src/webhook/payment_webhook_event_repository.ts +65 -0
  54. package/src/webhook/payment_webhook_event_schema.ts +40 -0
  55. package/src/webhook/payment_webhook_registry.ts +65 -0
@@ -0,0 +1,174 @@
1
+ /**
2
+ * `PaymentManager` — the facade apps use for payment workflows.
3
+ *
4
+ * Three concept clusters:
5
+ *
6
+ * - **Drivers.** Apps declare providers in
7
+ * `config.payment.providers`. The manager constructs each
8
+ * driver lazily on first `use(name)` + memoizes. Custom
9
+ * drivers register via `manager.extend(name, factory)`.
10
+ *
11
+ * - **Resource namespaces.** `customers`, `subscriptions`,
12
+ * `products`, `prices`, `paymentMethods`, `charges`,
13
+ * `invoices`, `checkout` — each accessor routes to the
14
+ * active driver's matching `*Ops` group. The default driver
15
+ * handles unqualified calls; `payment.use('asia').charges`
16
+ * routes elsewhere.
17
+ *
18
+ * - **Webhooks.** `manager.onWebhookEvent(type, handler)` /
19
+ * `manager.onWebhookEvent(type, filter, handler)` registers
20
+ * a normalized-event handler. The `paymentWebhook()` route
21
+ * dispatches into the registry after dedup + normalize.
22
+ *
23
+ * Multitenancy: tenanted ledger tables rely on
24
+ * `app.tenant_id` session settings, the same RLS pattern as
25
+ * `@strav/database`. Apps that wrap calls in
26
+ * `tenants.withTenant(...)` get per-tenant ledger isolation for
27
+ * free.
28
+ */
29
+
30
+ import type {
31
+ ChargeOps,
32
+ CheckoutOps,
33
+ CustomerOps,
34
+ InvoiceOps,
35
+ LinkOps,
36
+ PaymentDriver,
37
+ PaymentDriverFactory,
38
+ PaymentMethodOps,
39
+ PriceOps,
40
+ ProductOps,
41
+ SubscriptionOps,
42
+ WebhookOps,
43
+ } from './payment_driver.ts'
44
+ import {
45
+ PaymentConfigError,
46
+ UnknownProviderError,
47
+ } from './payment_error.ts'
48
+ import type {
49
+ PaymentEventType,
50
+ WebhookHandler,
51
+ WebhookHandlerFilter,
52
+ } from './dto/payment_event.ts'
53
+ import { PaymentWebhookRegistry } from './webhook/payment_webhook_registry.ts'
54
+ import type { TenantManager } from '@strav/database'
55
+ import type { PaymentLedger } from './ledger/payment_ledger.ts'
56
+ import type { PaymentConfig, ProviderConfig } from './types.ts'
57
+
58
+ export interface PaymentManagerOptions {
59
+ config: PaymentConfig
60
+ /** Optional ledger — when omitted, `applyEvent` during webhook dispatch no-ops. */
61
+ ledger?: PaymentLedger
62
+ /**
63
+ * Optional tenancy bridge. When set, the webhook dispatcher
64
+ * wraps ledger writes + user handlers in
65
+ * `tenantManager.withTenant(event.tenantId, ...)`. Events
66
+ * arrive without a `tenantId` skip the wrapper (and the
67
+ * ledger write, if ledger sync is on).
68
+ */
69
+ tenantManager?: TenantManager
70
+ }
71
+
72
+ export class PaymentManager {
73
+ readonly config: PaymentConfig
74
+ readonly webhookRegistry = new PaymentWebhookRegistry()
75
+ readonly ledger: PaymentLedger | undefined
76
+ readonly tenantManager: TenantManager | undefined
77
+
78
+ private readonly drivers = new Map<string, PaymentDriver>()
79
+ private readonly extensions = new Map<string, PaymentDriverFactory>()
80
+
81
+ constructor(options: PaymentManagerOptions) {
82
+ const { config } = options
83
+ if (!config.providers[config.default]) {
84
+ throw new PaymentConfigError(
85
+ `PaymentManager: default provider "${config.default}" is not configured.`,
86
+ {
87
+ context: {
88
+ default: config.default,
89
+ available: Object.keys(config.providers),
90
+ },
91
+ },
92
+ )
93
+ }
94
+ this.config = config
95
+ this.ledger = options.ledger
96
+ this.tenantManager = options.tenantManager
97
+ }
98
+
99
+ // ─── Driver routing ───────────────────────────────────────────────────
100
+
101
+ /** Resolve a driver by app-chosen instance name (or the default when omitted). */
102
+ use(name?: string): PaymentDriver {
103
+ const key = name ?? this.config.default
104
+ const cached = this.drivers.get(key)
105
+ if (cached) return cached
106
+
107
+ const cfg = this.config.providers[key]
108
+ if (!cfg) {
109
+ throw new UnknownProviderError(key, Object.keys(this.config.providers))
110
+ }
111
+ const ext = this.extensions.get(cfg.driver)
112
+ if (!ext) {
113
+ throw new PaymentConfigError(
114
+ `PaymentManager: unknown driver "${cfg.driver}" for provider "${key}". Register it via \`manager.extend("${cfg.driver}", factory)\` or install the matching adapter package.`,
115
+ { context: { driver: cfg.driver, available: [...this.extensions.keys()] } },
116
+ )
117
+ }
118
+ const driver = ext({ instanceName: key, config: cfg as ProviderConfig & { driver: string } })
119
+ this.drivers.set(key, driver)
120
+ return driver
121
+ }
122
+
123
+ /**
124
+ * Register a driver factory. Adapter packages
125
+ * (`@strav/payment-stripe`, …) call this from their
126
+ * ServiceProvider's `register()` step. Custom adapters
127
+ * register the same way.
128
+ */
129
+ extend(driverName: string, factory: PaymentDriverFactory): void {
130
+ this.extensions.set(driverName, factory)
131
+ }
132
+
133
+ /** Hand-wire a driver instance under an app-chosen name (tests / one-offs). */
134
+ useDriver(instanceName: string, driver: PaymentDriver): void {
135
+ this.drivers.set(instanceName, driver)
136
+ }
137
+
138
+ // ─── Resource namespaces (route to the default driver) ───────────────
139
+
140
+ get customers(): CustomerOps { return this.use().customers }
141
+ get products(): ProductOps { return this.use().products }
142
+ get prices(): PriceOps { return this.use().prices }
143
+ get subscriptions(): SubscriptionOps { return this.use().subscriptions }
144
+ get paymentMethods(): PaymentMethodOps { return this.use().paymentMethods }
145
+ get charges(): ChargeOps { return this.use().charges }
146
+ get invoices(): InvoiceOps { return this.use().invoices }
147
+ get checkout(): CheckoutOps { return this.use().checkout }
148
+ get links(): LinkOps { return this.use().links }
149
+ get webhook(): WebhookOps { return this.use().webhook }
150
+
151
+ // ─── Webhook handler registration ─────────────────────────────────────
152
+
153
+ onWebhookEvent(type: PaymentEventType, handler: WebhookHandler): void
154
+ onWebhookEvent(
155
+ type: PaymentEventType,
156
+ filter: WebhookHandlerFilter,
157
+ handler: WebhookHandler,
158
+ ): void
159
+ onWebhookEvent(
160
+ type: PaymentEventType,
161
+ filterOrHandler: WebhookHandlerFilter | WebhookHandler,
162
+ maybeHandler?: WebhookHandler,
163
+ ): void {
164
+ if (typeof filterOrHandler === 'function') {
165
+ this.webhookRegistry.on(type, filterOrHandler)
166
+ } else {
167
+ this.webhookRegistry.on(type, filterOrHandler, maybeHandler!)
168
+ }
169
+ }
170
+
171
+ clearWebhookHandlers(): void {
172
+ this.webhookRegistry.clear()
173
+ }
174
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * `PaymentProvider` — `ServiceProvider` that wires
3
+ * `PaymentManager`, `PaymentWebhookEventRepository`, and
4
+ * (when ledger is enabled) `PaymentLedger` into the container
5
+ * from `config.payment`.
6
+ *
7
+ * Adapter packages register their drivers separately via their
8
+ * own ServiceProvider (e.g. `StripePaymentProvider`) in
9
+ * `register()` BEFORE this provider's `boot()` runs. The order
10
+ * is enforced by listing the adapter providers AFTER
11
+ * `PaymentProvider` in the app's `bootstrap/providers.ts` —
12
+ * `register()` runs in declaration order, then `boot()` runs in
13
+ * the same order. Adapter `register()` calls
14
+ * `manager.extend(driver, factory)`; this provider's `boot()`
15
+ * eagerly resolves each configured instance, surfacing config
16
+ * errors at boot.
17
+ *
18
+ * Alternatively, an adapter's `boot()` can do the eager resolve
19
+ * itself. Either pattern is supported.
20
+ */
21
+
22
+ // biome-ignore lint/style/useImportType: PostgresDatabase + TenantManager value imports for the container's binding factory.
23
+ import { PostgresDatabase, TenantManager } from '@strav/database'
24
+ import {
25
+ type Application,
26
+ ConfigRepository,
27
+ ServiceProvider,
28
+ } from '@strav/kernel'
29
+ import { PaymentLedger } from './ledger/payment_ledger.ts'
30
+ import { PaymentConfigError } from './payment_error.ts'
31
+ import { PaymentManager } from './payment_manager.ts'
32
+ import type { PaymentConfig } from './types.ts'
33
+ import { PaymentWebhookEventRepository } from './webhook/payment_webhook_event_repository.ts'
34
+ // biome-ignore lint/style/useImportType: EventBus value import for the container's binding factory.
35
+ import { EventBus } from '@strav/kernel'
36
+
37
+ export class PaymentProvider extends ServiceProvider {
38
+ override readonly name = 'payment'
39
+ override readonly dependencies = ['config', 'database']
40
+
41
+ override register(app: Application): void {
42
+ app.singleton(PaymentManager, (c) => {
43
+ const raw = c.resolve(ConfigRepository).get('payment') as PaymentConfig | undefined
44
+ if (!raw) {
45
+ throw new PaymentConfigError(
46
+ 'PaymentProvider: `config.payment` is missing. Add `config/payment.ts` with at least one provider.',
47
+ )
48
+ }
49
+ if (!raw.providers || Object.keys(raw.providers).length === 0) {
50
+ throw new PaymentConfigError(
51
+ 'PaymentProvider: `config.payment.providers` is empty. Configure at least one provider.',
52
+ )
53
+ }
54
+
55
+ const ledgerEnabled = raw.ledger?.enabled ?? true
56
+ const ledger = ledgerEnabled ? new PaymentLedger(c.resolve(PostgresDatabase)) : undefined
57
+
58
+ // TenantManager is optional — apps that don't use the
59
+ // multi-tenant database surface can still use payment.
60
+ // When present, the webhook dispatcher uses it to scope
61
+ // ledger writes + user handlers per-event.
62
+ let tenantManager: TenantManager | undefined
63
+ try {
64
+ tenantManager = c.resolve(TenantManager)
65
+ } catch {
66
+ tenantManager = undefined
67
+ }
68
+
69
+ return new PaymentManager({
70
+ config: raw,
71
+ ...(ledger ? { ledger } : {}),
72
+ ...(tenantManager ? { tenantManager } : {}),
73
+ })
74
+ })
75
+
76
+ app.singleton(
77
+ PaymentWebhookEventRepository,
78
+ (c) =>
79
+ new PaymentWebhookEventRepository({
80
+ db: c.resolve(PostgresDatabase),
81
+ events: c.resolve(EventBus),
82
+ }),
83
+ )
84
+ }
85
+
86
+ override boot(app: Application): void {
87
+ // Force-resolve so config errors surface at boot. Driver
88
+ // instances are constructed lazily on first `use()` — adapter
89
+ // ServiceProviders register the factories during `register()`,
90
+ // which by this point has already run.
91
+ app.resolve(PaymentManager)
92
+ }
93
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Conventional metadata key that carries the Strav tenant id on
3
+ * every create-call that should produce tenant-scoped webhooks.
4
+ *
5
+ * Multi-tenant apps stamp this on every `payment.customers.create`,
6
+ * `payment.charges.create`, `payment.subscriptions.create`, etc.
7
+ * Providers echo metadata back on every webhook delivery, so the
8
+ * framework's `paymentWebhook()` dispatcher reads it back and
9
+ * wraps ledger writes + user handlers in
10
+ * `TenantManager.withTenant(...)`.
11
+ *
12
+ * Why a framework-namespaced key (not just `tenant_id`): some
13
+ * apps use generic `tenant_id` for their own purposes (Stripe
14
+ * Connect tenancy, partner account routing, …). `strav_tenant_id`
15
+ * is reserved.
16
+ *
17
+ * Key length: provider metadata limits — Stripe ≤ 40 chars, Omise
18
+ * keys ≤ 255 chars. Our prefix `strav_tenant_id` is 15 chars, well
19
+ * within both. Values are app-supplied tenant ids (typically
20
+ * ULIDs = 26 chars).
21
+ */
22
+
23
+ export const TENANT_METADATA_KEY = 'strav_tenant_id'
24
+
25
+ /**
26
+ * Build a metadata bag that stamps the tenant id alongside
27
+ * any caller-supplied keys. Use on every create call that should
28
+ * produce tenant-scoped webhook events.
29
+ *
30
+ * ```ts
31
+ * await payment.customers.create({
32
+ * email: user.email,
33
+ * metadata: tenantedMetadata(user.tenant_id, { source: 'signup' }),
34
+ * })
35
+ * ```
36
+ *
37
+ * If `extra` already contains `strav_tenant_id`, the explicit
38
+ * argument wins — apps that want to override (rare; testing
39
+ * fixtures) can do so.
40
+ */
41
+ export function tenantedMetadata(
42
+ tenantId: string,
43
+ extra: Record<string, string> = {},
44
+ ): Record<string, string> {
45
+ return { ...extra, [TENANT_METADATA_KEY]: tenantId }
46
+ }
47
+
48
+ /**
49
+ * Extract a Strav tenant id from a provider's metadata bag. Used
50
+ * by driver `normalize` functions when reading webhook events.
51
+ * Returns `undefined` when the metadata is missing or the tenant
52
+ * key isn't set.
53
+ */
54
+ export function readTenantId(
55
+ metadata: Record<string, unknown> | null | undefined,
56
+ ): string | undefined {
57
+ if (!metadata) return undefined
58
+ const v = metadata[TENANT_METADATA_KEY]
59
+ return typeof v === 'string' && v.length > 0 ? v : undefined
60
+ }
package/src/types.ts ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * `@strav/payment` — runtime config shapes.
3
+ *
4
+ * `PaymentConfig` is the `config.payment` shape apps declare in
5
+ * `config/payment.ts`. Multi-provider by default: a `providers`
6
+ * map keyed by app-chosen name, each with a `driver` discriminator
7
+ * the framework resolves to a concrete `PaymentDriver`. Apps that
8
+ * want a single provider just register one entry.
9
+ *
10
+ * `ProviderConfig` is intentionally free-form (`[key: string]:
11
+ * unknown`) so each adapter package owns its own config shape
12
+ * (`StripeConfig`, `PaddleConfig`, `OmiseConfig`) without forcing
13
+ * the core to know every vendor field.
14
+ */
15
+
16
+ export interface PaymentConfig {
17
+ /** Key into `providers`. The default routing target for unqualified `payment.*` calls. */
18
+ default: string
19
+ providers: Record<string, ProviderConfig>
20
+ ledger?: LedgerConfig
21
+ }
22
+
23
+ export interface ProviderConfig {
24
+ /**
25
+ * Driver identifier — must match a built-in (`'stripe'`,
26
+ * `'paddle'`, `'omise'`) or a name registered via
27
+ * `manager.extend(name, factory)`.
28
+ */
29
+ driver: string
30
+ /** Driver-specific fields — see each adapter's `*Config`. */
31
+ [key: string]: unknown
32
+ }
33
+
34
+ export interface LedgerConfig {
35
+ /**
36
+ * Mirror provider state (customers / subscriptions / invoices)
37
+ * into the local ledger tables. Default `true`. When false,
38
+ * only the webhook dedup table is used; apps own their own
39
+ * ledger.
40
+ */
41
+ enabled?: boolean
42
+ /**
43
+ * When `enabled`, upsert into the ledger on every webhook
44
+ * delivery (before user handlers fire). Default `true`. Apps
45
+ * that prefer eventual consistency via a background job set
46
+ * this to `false` and run their own sync.
47
+ */
48
+ syncOnWebhook?: boolean
49
+ }
@@ -0,0 +1,8 @@
1
+ export { PaymentWebhookEvent } from './payment_webhook_event.ts'
2
+ export { PaymentWebhookEventRepository } from './payment_webhook_event_repository.ts'
3
+ export { paymentWebhookEventSchema } from './payment_webhook_event_schema.ts'
4
+ export {
5
+ paymentWebhook,
6
+ type PaymentWebhookOptions,
7
+ } from './payment_webhook.ts'
8
+ export { PaymentWebhookRegistry } from './payment_webhook_registry.ts'
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Provider-agnostic webhook route handler.
3
+ *
4
+ * Mount once per app:
5
+ *
6
+ * router.post('/webhooks/:provider', paymentWebhook())
7
+ *
8
+ * Per request flow:
9
+ *
10
+ * 1. Read the `:provider` route param — picks the driver
11
+ * instance to verify against.
12
+ * 2. Read the raw body. Signature is computed over the bytes,
13
+ * so JSON-parsing first invalidates verification.
14
+ * 3. Resolve the signature header — drivers carry their own
15
+ * header name (`stripe-signature`, `paddle-signature`,
16
+ * `x-omise-signature`); the handler reads them all.
17
+ * 4. `driver.webhook.verify(rawBody, signature)` — returns the
18
+ * provider-native event. 400 + retry on failure.
19
+ * 5. Idempotency claim against
20
+ * `payment_webhook_event(provider, provider_event_id)`. The
21
+ * first delivery wins; replays return 200 `{ duplicate: true }`.
22
+ * 6. `driver.webhook.normalize(event)` — maps onto a
23
+ * `NormalizedWebhookEvent`. `null` means the event isn't on
24
+ * the framework's closed union; the dedup row stays, but no
25
+ * user handler fires.
26
+ * 7. Dispatch matching handlers from `PaymentWebhookRegistry`.
27
+ * Handlers run in registration order. Throwing leaves
28
+ * `processed_at` NULL so dashboards can surface stuck
29
+ * events; the route returns 500 so the provider retries.
30
+ * 8. Mark `processed_at` on success.
31
+ */
32
+
33
+ import type { HttpContext } from '@strav/http'
34
+ import type { NormalizedWebhookEvent, WebhookHandlerContext } from '../dto/payment_event.ts'
35
+ import { PaymentManager } from '../payment_manager.ts'
36
+ import { UnknownProviderError, WebhookSignatureError } from '../payment_error.ts'
37
+ import { PaymentWebhookEventRepository } from './payment_webhook_event_repository.ts'
38
+
39
+ export interface PaymentWebhookOptions {
40
+ /** Override the global ledger-sync flag for this route. */
41
+ syncLedger?: boolean
42
+ }
43
+
44
+ const SIGNATURE_HEADERS = [
45
+ 'stripe-signature',
46
+ 'paddle-signature',
47
+ 'x-omise-signature',
48
+ 'webhook-signature',
49
+ ]
50
+
51
+ export function paymentWebhook(
52
+ options: PaymentWebhookOptions = {},
53
+ ): (ctx: HttpContext) => Promise<Response> {
54
+ return async (ctx: HttpContext): Promise<Response> => {
55
+ const providerName = ctx.request.params['provider']
56
+ if (!providerName) {
57
+ return ctx.response.json(
58
+ { error: 'Missing :provider route param. Mount as `/webhooks/:provider`.' },
59
+ { status: 400 },
60
+ )
61
+ }
62
+
63
+ const manager = ctx.container.resolve(PaymentManager)
64
+ let driver
65
+ try {
66
+ driver = manager.use(providerName)
67
+ } catch (cause) {
68
+ if (cause instanceof UnknownProviderError) {
69
+ return ctx.response.json({ error: cause.message }, { status: 404 })
70
+ }
71
+ throw cause
72
+ }
73
+
74
+ const signature = findSignatureHeader(ctx.request.headers)
75
+ if (!signature) {
76
+ return ctx.response.json(
77
+ { error: 'Missing provider signature header.' },
78
+ { status: 400 },
79
+ )
80
+ }
81
+
82
+ const rawBody = await ctx.request.raw.text()
83
+
84
+ let nativeEvent: unknown
85
+ try {
86
+ nativeEvent = await driver.webhook.verify(rawBody, signature)
87
+ } catch (cause) {
88
+ if (cause instanceof WebhookSignatureError) {
89
+ return ctx.response.json({ error: cause.message }, { status: 400 })
90
+ }
91
+ throw cause
92
+ }
93
+
94
+ const normalized = driver.webhook.normalize(nativeEvent)
95
+ const eventIdForDedup = normalized?.id ?? extractEventId(nativeEvent)
96
+ const eventTypeForDedup = normalized?.type ?? extractEventType(nativeEvent) ?? 'unknown'
97
+
98
+ if (!eventIdForDedup) {
99
+ return ctx.response.json(
100
+ { error: 'Provider event is missing an id; cannot dedup.' },
101
+ { status: 400 },
102
+ )
103
+ }
104
+
105
+ const repo = ctx.container.resolve(PaymentWebhookEventRepository)
106
+ const claimed = await repo.claim(providerName, eventIdForDedup, eventTypeForDedup)
107
+ if (!claimed) {
108
+ return ctx.response.json({ received: true, duplicate: true })
109
+ }
110
+
111
+ if (normalized) {
112
+ // The route picks the driver instance by `:provider`. Apps
113
+ // register handlers against the same instance name they
114
+ // configured (`payment.use('asia') ↔ /webhooks/asia`), so
115
+ // we override the normalized event's `provider` field with
116
+ // the instance name. Drivers fill `provider` with their
117
+ // own name (`'stripe'`, `'omise'`) but instance-name routing
118
+ // is what the dispatcher honours.
119
+ const routed: NormalizedWebhookEvent = { ...normalized, provider: providerName }
120
+ const syncLedger = options.syncLedger ?? manager.config.ledger?.syncOnWebhook ?? true
121
+
122
+ // Tenant routing — when the event carries a `tenantId`
123
+ // (drivers read `metadata.strav_tenant_id`) AND a
124
+ // TenantManager is wired, scope ledger writes + user
125
+ // handlers via `withTenant`. The `tx` argument is the
126
+ // executor on which `withTenant` set `app.tenant_id`
127
+ // (LOCAL = transaction scope); ledger writes MUST use it
128
+ // so `current_setting('app.tenant_id')` resolves.
129
+ //
130
+ // Events without a `tenantId` skip the ledger write (the
131
+ // tables are tenanted; writing without scope would violate
132
+ // RLS / NOT NULL) but still dispatch to user handlers so
133
+ // apps can reconcile manually.
134
+ if (routed.tenantId && manager.tenantManager) {
135
+ await manager.tenantManager.withTenant(routed.tenantId, async (tx) => {
136
+ if (syncLedger && manager.ledger) {
137
+ await manager.ledger.applyEvent(routed, tx)
138
+ }
139
+ await dispatch(manager, routed)
140
+ })
141
+ } else {
142
+ await dispatch(manager, routed)
143
+ }
144
+ }
145
+
146
+ await repo.markProcessed(providerName, eventIdForDedup)
147
+ return ctx.response.json({ received: true, duplicate: false })
148
+ }
149
+ }
150
+
151
+ async function dispatch(
152
+ manager: PaymentManager,
153
+ event: NormalizedWebhookEvent,
154
+ ): Promise<void> {
155
+ const handlers = manager.webhookRegistry.resolve(event.type, event.provider)
156
+ if (handlers.length === 0) return
157
+ const ctx: WebhookHandlerContext = {
158
+ event,
159
+ eventId: event.id,
160
+ eventType: event.type,
161
+ provider: event.provider,
162
+ ...(event.tenantId ? { tenantId: event.tenantId } : {}),
163
+ raw: event.raw,
164
+ }
165
+ for (const handler of handlers) {
166
+ await handler(ctx)
167
+ }
168
+ }
169
+
170
+ function findSignatureHeader(headers: Headers): string | null {
171
+ for (const name of SIGNATURE_HEADERS) {
172
+ const value = headers.get(name)
173
+ if (value) return value
174
+ }
175
+ return null
176
+ }
177
+
178
+ function extractEventId(event: unknown): string | null {
179
+ if (event && typeof event === 'object' && 'id' in event && typeof (event as { id: unknown }).id === 'string') {
180
+ return (event as { id: string }).id
181
+ }
182
+ return null
183
+ }
184
+
185
+ function extractEventType(event: unknown): string | null {
186
+ if (event && typeof event === 'object' && 'type' in event && typeof (event as { type: unknown }).type === 'string') {
187
+ return (event as { type: string }).type
188
+ }
189
+ return null
190
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * `PaymentWebhookEvent` — typed row of the dedup ledger.
3
+ *
4
+ * Apps rarely touch this directly. Operator dashboards use it to
5
+ * list recent events, surface processing latencies, or flag
6
+ * stuck deliveries (rows where `processed_at` is still NULL after
7
+ * dispatch should have completed).
8
+ */
9
+
10
+ import { Model } from '@strav/database'
11
+ import { paymentWebhookEventSchema } from './payment_webhook_event_schema.ts'
12
+
13
+ export class PaymentWebhookEvent extends Model {
14
+ static override readonly schema = paymentWebhookEventSchema
15
+
16
+ id!: string
17
+ provider!: string
18
+ provider_event_id!: string
19
+ event_type!: string
20
+ received_at!: Date
21
+ processed_at!: Date | null
22
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * `PaymentWebhookEventRepository` — data access for the webhook
3
+ * dedup ledger.
4
+ *
5
+ * Two custom helpers on top of the generic CRUD surface:
6
+ *
7
+ * - `claim(provider, eventId, type)` — atomic INSERT ... ON
8
+ * CONFLICT DO NOTHING RETURNING id. Returns the row on win,
9
+ * `null` when another delivery already recorded the
10
+ * `(provider, provider_event_id)` pair.
11
+ *
12
+ * - `markProcessed(provider, eventId)` — bumps `processed_at`
13
+ * to NOW(). Observability-only; not part of the dedup
14
+ * decision. Rows with `received_at` set but `processed_at`
15
+ * NULL surface stuck deliveries (handler threw / process
16
+ * crashed mid-dispatch).
17
+ */
18
+
19
+ import { quoteIdent, Repository } from '@strav/database'
20
+ import { ulid } from '@strav/kernel'
21
+ import { PaymentWebhookEvent } from './payment_webhook_event.ts'
22
+ import { paymentWebhookEventSchema } from './payment_webhook_event_schema.ts'
23
+
24
+ export class PaymentWebhookEventRepository extends Repository<PaymentWebhookEvent> {
25
+ static override readonly schema = paymentWebhookEventSchema
26
+ static override readonly model = PaymentWebhookEvent
27
+
28
+ /**
29
+ * Atomically record receipt of an event. Returns the inserted
30
+ * row when this call won the race, `null` when the
31
+ * `(provider, provider_event_id)` pair was already recorded.
32
+ */
33
+ async claim(
34
+ provider: string,
35
+ providerEventId: string,
36
+ eventType: string,
37
+ ): Promise<PaymentWebhookEvent | null> {
38
+ const table = quoteIdent(paymentWebhookEventSchema.name)
39
+ const sql = `
40
+ INSERT INTO ${table}
41
+ ("id", "provider", "provider_event_id", "event_type", "received_at")
42
+ VALUES ($1, $2, $3, $4, NOW())
43
+ ON CONFLICT ("provider", "provider_event_id") DO NOTHING
44
+ RETURNING *
45
+ `
46
+ const rows = await this.db.query<Record<string, unknown>>(sql, [
47
+ ulid(),
48
+ provider,
49
+ providerEventId,
50
+ eventType,
51
+ ])
52
+ if (rows.length === 0) return null
53
+ return this.hydrate(rows[0]!)
54
+ }
55
+
56
+ /** Bump `processed_at` to NOW(). No-op when the row doesn't exist. */
57
+ async markProcessed(provider: string, providerEventId: string): Promise<void> {
58
+ const table = quoteIdent(paymentWebhookEventSchema.name)
59
+ await this.db.execute(
60
+ `UPDATE ${table} SET "processed_at" = NOW()
61
+ WHERE "provider" = $1 AND "provider_event_id" = $2`,
62
+ [provider, providerEventId],
63
+ )
64
+ }
65
+ }