@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,66 @@
1
+ /**
2
+ * Ledger model classes — typed row shapes the ledger repositories
3
+ * hydrate into. Apps query these for "show me this tenant's
4
+ * billing state" UIs.
5
+ */
6
+
7
+ import { Model } from '@strav/database'
8
+ import { paymentCustomerSchema } from './schemas/payment_customer_schema.ts'
9
+ import { paymentInvoiceSchema } from './schemas/payment_invoice_schema.ts'
10
+ import { paymentSubscriptionSchema } from './schemas/payment_subscription_schema.ts'
11
+
12
+ export class PaymentCustomerRow extends Model {
13
+ static override readonly schema = paymentCustomerSchema
14
+
15
+ id!: string
16
+ provider!: string
17
+ provider_id!: string
18
+ email!: string
19
+ name!: string | null
20
+ phone!: string | null
21
+ metadata!: Record<string, string>
22
+ created_at!: Date
23
+ updated_at!: Date
24
+ }
25
+
26
+ export class PaymentSubscriptionRow extends Model {
27
+ static override readonly schema = paymentSubscriptionSchema
28
+
29
+ id!: string
30
+ provider!: string
31
+ provider_id!: string
32
+ customer_provider_id!: string
33
+ price_provider_id!: string
34
+ status!: string
35
+ current_period_start!: Date
36
+ current_period_end!: Date
37
+ cancel_at!: Date | null
38
+ canceled_at!: Date | null
39
+ trial_start!: Date | null
40
+ trial_end!: Date | null
41
+ metadata!: Record<string, string>
42
+ created_at!: Date
43
+ updated_at!: Date
44
+ }
45
+
46
+ export class PaymentInvoiceRow extends Model {
47
+ static override readonly schema = paymentInvoiceSchema
48
+
49
+ id!: string
50
+ provider!: string
51
+ provider_id!: string
52
+ customer_provider_id!: string
53
+ subscription_provider_id!: string | null
54
+ status!: string
55
+ amount!: number
56
+ amount_paid!: number
57
+ amount_due!: number
58
+ currency!: string
59
+ hosted_url!: string | null
60
+ pdf_url!: string | null
61
+ due_at!: Date | null
62
+ paid_at!: Date | null
63
+ metadata!: Record<string, string>
64
+ created_at!: Date
65
+ updated_at!: Date
66
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * `paymentCustomerSchema` — local mirror of provider customers.
3
+ *
4
+ * Tenanted via RLS — apps that wrap calls in
5
+ * `tenants.withTenant(...)` get per-tenant isolation. The
6
+ * framework upserts into this table from webhook deliveries when
7
+ * `config.payment.ledger.syncOnWebhook` is true; apps read from
8
+ * it instead of round-tripping to the provider for the common
9
+ * "show me this user's billing info" UI.
10
+ *
11
+ * `provider` + `provider_id` together are the natural key; the
12
+ * composite unique constraint is added by
13
+ * `applyPaymentLedgerMigration` (the schema builder only exposes
14
+ * per-column `.unique()`).
15
+ */
16
+
17
+ import { Archetype, defineSchema } from '@strav/database'
18
+
19
+ export const paymentCustomerSchema = defineSchema(
20
+ 'payment_customer',
21
+ Archetype.Entity,
22
+ (t) => {
23
+ t.id()
24
+ t.string('provider').max(64).notNull()
25
+ t.string('provider_id').max(255).notNull()
26
+ t.string('email').max(320).notNull()
27
+ t.string('name').max(255).nullable()
28
+ t.string('phone').max(64).nullable()
29
+ t.json('metadata').notNull().default({})
30
+ t.timestamp('created_at').notNull()
31
+ t.timestamp('updated_at').notNull()
32
+ },
33
+ { tenanted: true },
34
+ )
@@ -0,0 +1,39 @@
1
+ /**
2
+ * `paymentInvoiceSchema` — local mirror of provider invoices.
3
+ * Tenanted via RLS.
4
+ *
5
+ * Apps query this for "last 12 invoices for this tenant" without
6
+ * paginating the provider's invoice listing on every render.
7
+ */
8
+
9
+ import { Archetype, defineSchema } from '@strav/database'
10
+
11
+ export const paymentInvoiceSchema = defineSchema(
12
+ 'payment_invoice',
13
+ Archetype.Entity,
14
+ (t) => {
15
+ t.id()
16
+ t.string('provider').max(64).notNull()
17
+ t.string('provider_id').max(255).notNull()
18
+ t.string('customer_provider_id').max(255).notNull()
19
+ t.string('subscription_provider_id').max(255).nullable()
20
+ t.string('status').max(32).notNull()
21
+ // Amounts are in the provider's minor unit (cents/satang).
22
+ // `integer` is int32 — caps each line at ~$21M USD which is
23
+ // well above the per-invoice ceiling for normal apps. Apps
24
+ // that bill nation-state contracts swap this for a decimal
25
+ // column in a follow-up migration.
26
+ t.integer('amount').notNull()
27
+ t.integer('amount_paid').notNull()
28
+ t.integer('amount_due').notNull()
29
+ t.string('currency').max(8).notNull()
30
+ t.string('hosted_url').max(1024).nullable()
31
+ t.string('pdf_url').max(1024).nullable()
32
+ t.timestamp('due_at').nullable()
33
+ t.timestamp('paid_at').nullable()
34
+ t.json('metadata').notNull().default({})
35
+ t.timestamp('created_at').notNull()
36
+ t.timestamp('updated_at').notNull()
37
+ },
38
+ { tenanted: true },
39
+ )
@@ -0,0 +1,34 @@
1
+ /**
2
+ * `paymentSubscriptionSchema` — local mirror of provider
3
+ * subscriptions. Tenanted via RLS.
4
+ *
5
+ * Apps query this table for "active subscriptions for tenant X"
6
+ * without paying a network round-trip to the provider on every
7
+ * dashboard render. Mirror rows are upserted on webhook delivery
8
+ * (when ledger sync is on).
9
+ */
10
+
11
+ import { Archetype, defineSchema } from '@strav/database'
12
+
13
+ export const paymentSubscriptionSchema = defineSchema(
14
+ 'payment_subscription',
15
+ Archetype.Entity,
16
+ (t) => {
17
+ t.id()
18
+ t.string('provider').max(64).notNull()
19
+ t.string('provider_id').max(255).notNull()
20
+ t.string('customer_provider_id').max(255).notNull()
21
+ t.string('price_provider_id').max(255).notNull()
22
+ t.string('status').max(32).notNull()
23
+ t.timestamp('current_period_start').notNull()
24
+ t.timestamp('current_period_end').notNull()
25
+ t.timestamp('cancel_at').nullable()
26
+ t.timestamp('canceled_at').nullable()
27
+ t.timestamp('trial_start').nullable()
28
+ t.timestamp('trial_end').nullable()
29
+ t.json('metadata').notNull().default({})
30
+ t.timestamp('created_at').notNull()
31
+ t.timestamp('updated_at').notNull()
32
+ },
33
+ { tenanted: true },
34
+ )
@@ -0,0 +1,91 @@
1
+ /**
2
+ * `PaymentCapability` — granular feature flags every driver
3
+ * declares in `driver.capabilities`. Apps that build provider-
4
+ * neutral flows check capability before calling:
5
+ *
6
+ * if (payment.use('omise').capabilities.has('checkout')) { ... }
7
+ *
8
+ * Drivers omit a capability when they can't fulfil it
9
+ * faithfully — partial / surprising implementations are worse
10
+ * than `ProviderUnsupportedError`. Apps reach `.raw` when they
11
+ * need provider-specific behaviour that doesn't map to a
12
+ * capability.
13
+ *
14
+ * Capability granularity is intentionally fine-grained (one per
15
+ * non-trivial *Ops method, not one per *Ops group) so e.g.
16
+ * Paddle can support `subscriptions.create` but not
17
+ * `subscriptions.changePlan` without losing the rest.
18
+ */
19
+
20
+ export type PaymentCapability =
21
+ // customers
22
+ | 'customers.create'
23
+ | 'customers.update'
24
+ | 'customers.retrieve'
25
+ | 'customers.list'
26
+ | 'customers.delete'
27
+ // products + prices
28
+ | 'products.create'
29
+ | 'products.update'
30
+ | 'products.list'
31
+ | 'prices.create'
32
+ | 'prices.list'
33
+ // subscriptions
34
+ | 'subscriptions.create'
35
+ | 'subscriptions.retrieve'
36
+ | 'subscriptions.update'
37
+ | 'subscriptions.cancel'
38
+ | 'subscriptions.changePlan'
39
+ | 'subscriptions.trials'
40
+ // payment methods
41
+ | 'paymentMethods.attach'
42
+ | 'paymentMethods.detach'
43
+ | 'paymentMethods.list'
44
+ // charges
45
+ | 'charges.create'
46
+ | 'charges.refund'
47
+ | 'charges.capture'
48
+ // charges — payment-method specs the driver accepts as input.
49
+ // Fine-grained so apps can build method pickers that only show
50
+ // what the routed driver can take.
51
+ | 'charges.method.card'
52
+ | 'charges.method.promptpay'
53
+ | 'charges.method.paynow'
54
+ | 'charges.method.fps'
55
+ | 'charges.method.truemoney'
56
+ | 'charges.method.alipay'
57
+ | 'charges.method.wechat_pay'
58
+ | 'charges.method.grabpay'
59
+ | 'charges.method.kakaopay'
60
+ | 'charges.method.rabbit_linepay'
61
+ | 'charges.method.konbini'
62
+ // charges — next-action kinds the driver can emit. Apps that
63
+ // host their own QR / redirect UI check these to know what
64
+ // shapes they need to handle.
65
+ | 'charges.nextAction.display_qr'
66
+ | 'charges.nextAction.redirect'
67
+ | 'charges.nextAction.authorize'
68
+ | 'charges.nextAction.voucher'
69
+ | 'charges.nextAction.wait'
70
+ // invoices
71
+ | 'invoices.list'
72
+ | 'invoices.retrieve'
73
+ | 'invoices.finalize'
74
+ | 'invoices.void'
75
+ // checkout (hosted)
76
+ | 'checkout.create'
77
+ | 'checkout.retrieve'
78
+ // payment links — shareable hosted pay URLs.
79
+ | 'links.create'
80
+ | 'links.deactivate'
81
+ // Driver enforces server-side idempotency when `idempotencyKey`
82
+ // is set on supported create-style inputs. When NOT declared,
83
+ // the driver silently ignores the field — apps that need
84
+ // guaranteed dedup build it app-side (claim the key in a DB
85
+ // table before calling). Single flag covers every supported
86
+ // method (charges, refunds, subscriptions, links, checkout,
87
+ // customers).
88
+ | 'idempotency'
89
+ // webhooks
90
+ | 'webhook.verify'
91
+ | 'webhook.normalize'
@@ -0,0 +1,167 @@
1
+ /**
2
+ * `PaymentDriver` — the driver contract every adapter implements.
3
+ *
4
+ * One `PaymentDriver` represents a configured provider instance
5
+ * (`config.payment.providers['stripe']`). The manager holds one
6
+ * driver per configured name and routes resource calls into it.
7
+ *
8
+ * Methods drivers don't support throw `ProviderUnsupportedError`
9
+ * synchronously. The driver's `capabilities` set declares the
10
+ * supported method names — apps that branch on capability avoid
11
+ * the throw by checking first.
12
+ */
13
+
14
+ import type {
15
+ CancelSubscriptionOptions,
16
+ CreateChargeInput,
17
+ CreateCheckoutInput,
18
+ CreateCustomerInput,
19
+ CreatePaymentLinkInput,
20
+ CreatePriceInput,
21
+ CreateProductInput,
22
+ CreateRefundInput,
23
+ CreateSubscriptionInput,
24
+ ListCustomersOptions,
25
+ ListInvoicesOptions,
26
+ ListPaymentLinksOptions,
27
+ ListPaymentMethodsOptions,
28
+ ListPricesOptions,
29
+ ListProductsOptions,
30
+ ListSubscriptionsOptions,
31
+ NormalizedWebhookEvent,
32
+ PaginatedCustomers,
33
+ PaginatedInvoices,
34
+ PaginatedPaymentLinks,
35
+ PaginatedPaymentMethods,
36
+ PaginatedPrices,
37
+ PaginatedProducts,
38
+ PaginatedSubscriptions,
39
+ PaymentCharge,
40
+ PaymentCheckoutSession,
41
+ PaymentCustomer,
42
+ PaymentInvoice,
43
+ PaymentLink,
44
+ PaymentMethod,
45
+ PaymentPrice,
46
+ PaymentProduct,
47
+ PaymentRefund,
48
+ PaymentSubscription,
49
+ UpdateCustomerInput,
50
+ UpdateSubscriptionInput,
51
+ } from './dto/index.ts'
52
+ import type { PaymentCapability } from './payment_capabilities.ts'
53
+
54
+ export interface CustomerOps {
55
+ create(input: CreateCustomerInput): Promise<PaymentCustomer>
56
+ retrieve(id: string): Promise<PaymentCustomer>
57
+ update(id: string, input: UpdateCustomerInput): Promise<PaymentCustomer>
58
+ list(options?: ListCustomersOptions): Promise<PaginatedCustomers>
59
+ delete(id: string): Promise<void>
60
+ }
61
+
62
+ export interface ProductOps {
63
+ create(input: CreateProductInput): Promise<PaymentProduct>
64
+ retrieve(id: string): Promise<PaymentProduct>
65
+ update(id: string, input: Partial<CreateProductInput>): Promise<PaymentProduct>
66
+ list(options?: ListProductsOptions): Promise<PaginatedProducts>
67
+ }
68
+
69
+ export interface PriceOps {
70
+ create(input: CreatePriceInput): Promise<PaymentPrice>
71
+ retrieve(id: string): Promise<PaymentPrice>
72
+ list(options?: ListPricesOptions): Promise<PaginatedPrices>
73
+ }
74
+
75
+ export interface SubscriptionOps {
76
+ create(input: CreateSubscriptionInput): Promise<PaymentSubscription>
77
+ retrieve(id: string): Promise<PaymentSubscription>
78
+ update(id: string, input: UpdateSubscriptionInput): Promise<PaymentSubscription>
79
+ cancel(id: string, options?: CancelSubscriptionOptions): Promise<PaymentSubscription>
80
+ list(options?: ListSubscriptionsOptions): Promise<PaginatedSubscriptions>
81
+ }
82
+
83
+ export interface PaymentMethodOps {
84
+ /** Attach a payment method (typically a tokenized card) to a customer. */
85
+ attach(paymentMethodId: string, customerId: string): Promise<PaymentMethod>
86
+ /**
87
+ * Detach a payment method. `customerId` is optional for providers
88
+ * that can look up the customer from the payment method itself
89
+ * (Stripe), required for providers that store cards under a
90
+ * customer scope (Omise). Drivers throw `ProviderUnsupportedError`
91
+ * when they need `customerId` and the caller omits it.
92
+ */
93
+ detach(paymentMethodId: string, customerId?: string): Promise<PaymentMethod>
94
+ list(customerId: string, options?: ListPaymentMethodsOptions): Promise<PaginatedPaymentMethods>
95
+ }
96
+
97
+ export interface ChargeOps {
98
+ create(input: CreateChargeInput): Promise<PaymentCharge>
99
+ retrieve(id: string): Promise<PaymentCharge>
100
+ capture(id: string, options?: { amount?: number }): Promise<PaymentCharge>
101
+ refund(input: CreateRefundInput): Promise<PaymentRefund>
102
+ }
103
+
104
+ export interface InvoiceOps {
105
+ retrieve(id: string): Promise<PaymentInvoice>
106
+ list(options?: ListInvoicesOptions): Promise<PaginatedInvoices>
107
+ finalize(id: string): Promise<PaymentInvoice>
108
+ void(id: string): Promise<PaymentInvoice>
109
+ }
110
+
111
+ export interface CheckoutOps {
112
+ create(input: CreateCheckoutInput): Promise<PaymentCheckoutSession>
113
+ retrieve(id: string): Promise<PaymentCheckoutSession>
114
+ }
115
+
116
+ export interface LinkOps {
117
+ create(input: CreatePaymentLinkInput): Promise<PaymentLink>
118
+ retrieve(id: string): Promise<PaymentLink>
119
+ list(options?: ListPaymentLinksOptions): Promise<PaginatedPaymentLinks>
120
+ /** Stop accepting new payments via this link. Throws `ProviderUnsupportedError` on drivers that can't (Omise). */
121
+ deactivate(id: string): Promise<PaymentLink>
122
+ }
123
+
124
+ export interface WebhookOps {
125
+ /**
126
+ * Verify the provider signature against the raw body. Returns
127
+ * the parsed provider-native event. Throws
128
+ * `WebhookSignatureError` on failure.
129
+ */
130
+ verify(rawBody: string, signature: string): Promise<unknown>
131
+ /**
132
+ * Map a provider-native event onto the framework's
133
+ * `NormalizedWebhookEvent`. Drivers translate the closed
134
+ * union of types they support; events outside the union map
135
+ * to `null` and the dispatcher skips user handlers (but still
136
+ * records the dedup row).
137
+ */
138
+ normalize(event: unknown): NormalizedWebhookEvent | null
139
+ }
140
+
141
+ export interface PaymentDriver {
142
+ /** Driver identifier — matches the `driver:` discriminator in `ProviderConfig`. */
143
+ readonly name: string
144
+ /** App-chosen instance name (`config.payment.providers[name]`). */
145
+ readonly instanceName: string
146
+ /** Declared feature set. Apps check this to branch around `ProviderUnsupportedError`. */
147
+ readonly capabilities: ReadonlySet<PaymentCapability>
148
+
149
+ readonly customers: CustomerOps
150
+ readonly products: ProductOps
151
+ readonly prices: PriceOps
152
+ readonly subscriptions: SubscriptionOps
153
+ readonly paymentMethods: PaymentMethodOps
154
+ readonly charges: ChargeOps
155
+ readonly invoices: InvoiceOps
156
+ readonly checkout: CheckoutOps
157
+ readonly links: LinkOps
158
+ readonly webhook: WebhookOps
159
+ }
160
+
161
+ /** Factory the manager invokes for each configured provider. */
162
+ export type PaymentDriverFactory = (config: {
163
+ /** App-chosen instance name (`'stripe'`, `'asia'`, …). */
164
+ instanceName: string
165
+ /** Provider-config object with `driver:` + driver-specific fields. */
166
+ config: Record<string, unknown> & { driver: string }
167
+ }) => PaymentDriver
@@ -0,0 +1,159 @@
1
+ /**
2
+ * `PaymentError` hierarchy — typed wrappers for failures across
3
+ * the payment stack. Driver-native exceptions (Stripe API errors,
4
+ * Paddle rate limits, Omise card declines) are preserved on
5
+ * `.cause` so apps can still `instanceof` the vendor class for
6
+ * retry / recovery logic; the wrapping just gives the framework a
7
+ * consistent `StravError` to render through the standard
8
+ * exception handler.
9
+ *
10
+ * Concrete subclasses:
11
+ *
12
+ * - `PaymentConfigError` — `config.payment` missing required
13
+ * fields. Thrown at boot from `PaymentProvider`.
14
+ *
15
+ * - `ProviderUnsupportedError` — a driver doesn't implement the
16
+ * requested operation (e.g., `omise.checkout.create` when
17
+ * hosted checkout isn't available). Thrown synchronously from
18
+ * the *Ops call so apps fail fast rather than after a network
19
+ * round-trip.
20
+ *
21
+ * - `UnknownProviderError` — `payment.use('x')` for a name not
22
+ * configured. 400 — apps usually have a config bug.
23
+ *
24
+ * - `WebhookSignatureError` — provider signature header missing
25
+ * or doesn't verify. Webhook route returns 400; the provider
26
+ * retries per its backoff schedule.
27
+ *
28
+ * - `WebhookIdempotencyError` — malformed / missing event id.
29
+ * 400 — apps that hit this usually have a non-provider payload
30
+ * reaching the route.
31
+ *
32
+ * - `PaymentProviderError` — generic wrapper around a vendor
33
+ * exception that doesn't map to a specific subclass.
34
+ * Preserves `.cause`; default status 502 (upstream failure).
35
+ */
36
+
37
+ import { StravError } from '@strav/kernel'
38
+
39
+ export class PaymentError extends StravError {
40
+ constructor(
41
+ message: string,
42
+ options: {
43
+ code?: string
44
+ status?: number
45
+ context?: Record<string, unknown>
46
+ cause?: unknown
47
+ } = {},
48
+ ) {
49
+ super(
50
+ message,
51
+ { code: options.code ?? 'payment.error', status: options.status ?? 500 },
52
+ {
53
+ ...(options.context ? { context: options.context } : {}),
54
+ ...(options.cause !== undefined ? { cause: options.cause } : {}),
55
+ },
56
+ )
57
+ }
58
+ }
59
+
60
+ export class PaymentConfigError extends PaymentError {
61
+ constructor(message: string, options: { context?: Record<string, unknown> } = {}) {
62
+ super(message, {
63
+ code: 'payment.config',
64
+ status: 500,
65
+ ...(options.context ? { context: options.context } : {}),
66
+ })
67
+ }
68
+ }
69
+
70
+ export class UnknownProviderError extends PaymentError {
71
+ constructor(name: string, available: readonly string[]) {
72
+ super(
73
+ `Payment provider "${name}" is not configured. Available: ${available.join(', ') || '<none>'}.`,
74
+ {
75
+ code: 'payment.unknown_provider',
76
+ status: 400,
77
+ context: { requested: name, available },
78
+ },
79
+ )
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Thrown when a driver doesn't implement the requested operation.
85
+ * The driver's `capabilities` set declares what it can do; calls
86
+ * to unsupported operations throw this synchronously so apps fail
87
+ * fast rather than after the network round-trip.
88
+ */
89
+ export class ProviderUnsupportedError extends PaymentError {
90
+ constructor(provider: string, operation: string, options: { reason?: string } = {}) {
91
+ const trailer = options.reason ? ` ${options.reason}` : ''
92
+ super(
93
+ `Payment provider "${provider}" does not support "${operation}".${trailer}`,
94
+ {
95
+ code: 'payment.provider_unsupported',
96
+ status: 400,
97
+ context: { provider, operation, ...(options.reason ? { reason: options.reason } : {}) },
98
+ },
99
+ )
100
+ }
101
+ }
102
+
103
+ export class WebhookSignatureError extends PaymentError {
104
+ constructor(
105
+ message: string,
106
+ options: { context?: Record<string, unknown>; cause?: unknown } = {},
107
+ ) {
108
+ super(message, {
109
+ code: 'payment.webhook_signature',
110
+ status: 400,
111
+ ...(options.context ? { context: options.context } : {}),
112
+ ...(options.cause !== undefined ? { cause: options.cause } : {}),
113
+ })
114
+ }
115
+ }
116
+
117
+ export class WebhookIdempotencyError extends PaymentError {
118
+ constructor(
119
+ message: string,
120
+ options: { context?: Record<string, unknown>; cause?: unknown } = {},
121
+ ) {
122
+ super(message, {
123
+ code: 'payment.webhook_idempotency',
124
+ status: 400,
125
+ ...(options.context ? { context: options.context } : {}),
126
+ ...(options.cause !== undefined ? { cause: options.cause } : {}),
127
+ })
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Generic wrapper around a vendor exception. Drivers throw this
133
+ * for failures that don't map to a more specific subclass —
134
+ * declined cards, rate limits, etc. The original vendor error is
135
+ * preserved on `.cause`.
136
+ */
137
+ export class PaymentProviderError extends PaymentError {
138
+ constructor(
139
+ message: string,
140
+ options: {
141
+ provider: string
142
+ operation: string
143
+ context?: Record<string, unknown>
144
+ cause?: unknown
145
+ status?: number
146
+ },
147
+ ) {
148
+ super(message, {
149
+ code: 'payment.provider_error',
150
+ status: options.status ?? 502,
151
+ context: {
152
+ provider: options.provider,
153
+ operation: options.operation,
154
+ ...(options.context ?? {}),
155
+ },
156
+ ...(options.cause !== undefined ? { cause: options.cause } : {}),
157
+ })
158
+ }
159
+ }