@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.
- package/package.json +34 -0
- package/src/drivers/index.ts +6 -0
- package/src/drivers/mock_driver.ts +534 -0
- package/src/drivers/omise/index.ts +56 -0
- package/src/drivers/omise/omise_config.ts +19 -0
- package/src/drivers/omise/omise_driver.ts +576 -0
- package/src/drivers/omise/omise_mappers.ts +180 -0
- package/src/drivers/omise/omise_method_spec.ts +88 -0
- package/src/drivers/omise/omise_next_action_mapper.ts +89 -0
- package/src/drivers/omise/omise_price_spec.ts +85 -0
- package/src/drivers/omise/omise_provider.ts +33 -0
- package/src/drivers/omise/omise_schedule_mapper.ts +156 -0
- package/src/drivers/omise/omise_webhook.ts +162 -0
- package/src/drivers/payment_method_helpers.ts +35 -0
- package/src/drivers/stripe/index.ts +40 -0
- package/src/drivers/stripe/mappers/stripe_mappers.ts +312 -0
- package/src/drivers/stripe/mappers/stripe_method_spec.ts +77 -0
- package/src/drivers/stripe/mappers/stripe_next_action_mapper.ts +163 -0
- package/src/drivers/stripe/stripe_config.ts +18 -0
- package/src/drivers/stripe/stripe_driver.ts +650 -0
- package/src/drivers/stripe/stripe_provider.ts +38 -0
- package/src/drivers/stripe/webhook/stripe_normalize.ts +139 -0
- package/src/drivers/unsupported.ts +20 -0
- package/src/dto/index.ts +72 -0
- package/src/dto/payment_charge.ts +158 -0
- package/src/dto/payment_checkout.ts +46 -0
- package/src/dto/payment_customer.ts +52 -0
- package/src/dto/payment_event.ts +83 -0
- package/src/dto/payment_invoice.ts +39 -0
- package/src/dto/payment_link.ts +81 -0
- package/src/dto/payment_method.ts +43 -0
- package/src/dto/payment_price.ts +47 -0
- package/src/dto/payment_product.ts +40 -0
- package/src/dto/payment_subscription.ts +71 -0
- package/src/index.ts +78 -0
- package/src/ledger/apply_payment_ledger_migration.ts +106 -0
- package/src/ledger/index.ts +13 -0
- package/src/ledger/payment_ledger.ts +260 -0
- package/src/ledger/payment_ledger_models.ts +66 -0
- package/src/ledger/schemas/payment_customer_schema.ts +34 -0
- package/src/ledger/schemas/payment_invoice_schema.ts +39 -0
- package/src/ledger/schemas/payment_subscription_schema.ts +34 -0
- package/src/payment_capabilities.ts +91 -0
- package/src/payment_driver.ts +167 -0
- package/src/payment_error.ts +159 -0
- package/src/payment_manager.ts +174 -0
- package/src/payment_provider.ts +93 -0
- package/src/tenant_metadata.ts +60 -0
- package/src/types.ts +49 -0
- package/src/webhook/index.ts +8 -0
- package/src/webhook/payment_webhook.ts +190 -0
- package/src/webhook/payment_webhook_event.ts +22 -0
- package/src/webhook/payment_webhook_event_repository.ts +65 -0
- package/src/webhook/payment_webhook_event_schema.ts +40 -0
- 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
|
+
}
|