@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,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
|
+
}
|