@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,43 @@
1
+ /**
2
+ * `PaymentMethod` — normalized payment-instrument record. The
3
+ * actual card number / bank account isn't exposed — only the
4
+ * presentational fields apps render in receipts and account
5
+ * pages.
6
+ */
7
+
8
+ export type PaymentMethodKind =
9
+ | 'card'
10
+ | 'bank_account'
11
+ | 'sepa_debit'
12
+ | 'promptpay'
13
+ | 'truemoney'
14
+ | 'paypal'
15
+ | 'other'
16
+
17
+ export interface PaymentMethod {
18
+ id: string
19
+ provider: string
20
+ customerId: string | null
21
+ kind: PaymentMethodKind
22
+ /** Card brand / bank name / wallet provider. Always present. */
23
+ brand?: string
24
+ /** Last 4 digits / account suffix. */
25
+ last4?: string
26
+ expMonth?: number
27
+ expYear?: number
28
+ metadata: Record<string, string>
29
+ createdAt: Date
30
+ raw: unknown
31
+ }
32
+
33
+ export interface ListPaymentMethodsOptions {
34
+ /** Filter to one kind. */
35
+ kind?: PaymentMethodKind
36
+ cursor?: string
37
+ limit?: number
38
+ }
39
+
40
+ export interface PaginatedPaymentMethods {
41
+ data: PaymentMethod[]
42
+ nextCursor: string | null
43
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * `PaymentPrice` — normalized price (recurring or one-shot) record.
3
+ * Money is represented in the provider's minor unit (cents,
4
+ * satang, …) to avoid floating-point drift. Currencies use the
5
+ * ISO 4217 three-letter code, lowercase to match Stripe.
6
+ */
7
+
8
+ export interface PaymentPrice {
9
+ id: string
10
+ provider: string
11
+ productId: string
12
+ /** Amount in the provider's minor unit (e.g. cents). */
13
+ amount: number
14
+ currency: string
15
+ /** `'one_time'` for single charges; recurring carries `interval`. */
16
+ type: 'one_time' | 'recurring'
17
+ interval?: 'day' | 'week' | 'month' | 'year'
18
+ /** Number of `interval` units per billing period (default 1). */
19
+ intervalCount?: number
20
+ active: boolean
21
+ metadata: Record<string, string>
22
+ createdAt: Date
23
+ raw: unknown
24
+ }
25
+
26
+ export interface CreatePriceInput {
27
+ product: string
28
+ amount: number
29
+ currency: string
30
+ type?: 'one_time' | 'recurring'
31
+ interval?: 'day' | 'week' | 'month' | 'year'
32
+ intervalCount?: number
33
+ active?: boolean
34
+ metadata?: Record<string, string>
35
+ }
36
+
37
+ export interface ListPricesOptions {
38
+ product?: string
39
+ cursor?: string
40
+ limit?: number
41
+ active?: boolean
42
+ }
43
+
44
+ export interface PaginatedPrices {
45
+ data: PaymentPrice[]
46
+ nextCursor: string | null
47
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * `PaymentProduct` — normalized product (catalogue entry) record.
3
+ * Prices attach to products and carry the actual billing terms.
4
+ */
5
+
6
+ export interface PaymentProduct {
7
+ id: string
8
+ provider: string
9
+ name: string
10
+ description?: string
11
+ active: boolean
12
+ metadata: Record<string, string>
13
+ createdAt: Date
14
+ raw: unknown
15
+ }
16
+
17
+ export interface CreateProductInput {
18
+ name: string
19
+ description?: string
20
+ active?: boolean
21
+ metadata?: Record<string, string>
22
+ }
23
+
24
+ export interface UpdateProductInput {
25
+ name?: string
26
+ description?: string
27
+ active?: boolean
28
+ metadata?: Record<string, string>
29
+ }
30
+
31
+ export interface ListProductsOptions {
32
+ cursor?: string
33
+ limit?: number
34
+ active?: boolean
35
+ }
36
+
37
+ export interface PaginatedProducts {
38
+ data: PaymentProduct[]
39
+ nextCursor: string | null
40
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * `PaymentSubscription` — normalized subscription state.
3
+ *
4
+ * `status` is the framework union — drivers translate from their
5
+ * native status (`'incomplete_expired'` etc.) onto this set. Apps
6
+ * that need the precise provider value read `raw`.
7
+ */
8
+
9
+ export type SubscriptionStatus =
10
+ | 'trialing'
11
+ | 'active'
12
+ | 'past_due'
13
+ | 'canceled'
14
+ | 'paused'
15
+ | 'incomplete'
16
+
17
+ export interface PaymentSubscription {
18
+ id: string
19
+ provider: string
20
+ customerId: string
21
+ priceId: string
22
+ status: SubscriptionStatus
23
+ currentPeriodStart: Date
24
+ currentPeriodEnd: Date
25
+ cancelAt: Date | null
26
+ canceledAt: Date | null
27
+ trialStart: Date | null
28
+ trialEnd: Date | null
29
+ metadata: Record<string, string>
30
+ createdAt: Date
31
+ raw: unknown
32
+ }
33
+
34
+ export interface CreateSubscriptionInput {
35
+ customer: string
36
+ price: string
37
+ /** Days of free trial. Drivers without trial support throw `ProviderUnsupportedError`. */
38
+ trialDays?: number
39
+ metadata?: Record<string, string>
40
+ /** Optional payment-method id to charge for renewals. */
41
+ paymentMethod?: string
42
+ /** See `CreateChargeInput.idempotencyKey`. */
43
+ idempotencyKey?: string
44
+ }
45
+
46
+ export interface UpdateSubscriptionInput {
47
+ price?: string
48
+ metadata?: Record<string, string>
49
+ paymentMethod?: string
50
+ }
51
+
52
+ export interface CancelSubscriptionOptions {
53
+ /**
54
+ * `'now'` — cancel immediately, refund/credit per provider rules.
55
+ * `'period_end'` — let the current period finish, then stop renewals.
56
+ * Default: `'period_end'`.
57
+ */
58
+ at?: 'now' | 'period_end'
59
+ }
60
+
61
+ export interface ListSubscriptionsOptions {
62
+ customer?: string
63
+ status?: SubscriptionStatus
64
+ cursor?: string
65
+ limit?: number
66
+ }
67
+
68
+ export interface PaginatedSubscriptions {
69
+ data: PaymentSubscription[]
70
+ nextCursor: string | null
71
+ }
package/src/index.ts ADDED
@@ -0,0 +1,78 @@
1
+ // Public API of `@strav/payment`.
2
+ //
3
+ // V1: provider-agnostic payment abstraction — normalized DTOs +
4
+ // multi-provider routing + ledger schema + webhook dispatcher.
5
+ // Composes with `@strav/database` for the ledger tables and
6
+ // `@strav/http` for the webhook route.
7
+ //
8
+ // Drivers ship as separate adapter packages:
9
+ // `@strav/payment-stripe`, `@strav/payment-paddle`,
10
+ // `@strav/payment-omise`. The `MockDriver` in `./drivers` is
11
+ // for tests and as the reference implementation.
12
+
13
+ export type * from './dto/index.ts'
14
+ export {
15
+ extractCardToken,
16
+ MockDriver,
17
+ type MockDriverOptions,
18
+ paymentMethodKind,
19
+ unsupported,
20
+ } from './drivers/index.ts'
21
+ export {
22
+ applyPaymentLedgerMigration,
23
+ type ApplyPaymentLedgerMigrationOptions,
24
+ PaymentCustomerRow,
25
+ PaymentInvoiceRow,
26
+ PaymentLedger,
27
+ PaymentSubscriptionRow,
28
+ paymentCustomerSchema,
29
+ paymentInvoiceSchema,
30
+ paymentSubscriptionSchema,
31
+ } from './ledger/index.ts'
32
+ export type { PaymentCapability } from './payment_capabilities.ts'
33
+ export type {
34
+ ChargeOps,
35
+ CheckoutOps,
36
+ CustomerOps,
37
+ InvoiceOps,
38
+ LinkOps,
39
+ PaymentDriver,
40
+ PaymentDriverFactory,
41
+ PaymentMethodOps,
42
+ PriceOps,
43
+ ProductOps,
44
+ SubscriptionOps,
45
+ WebhookOps,
46
+ } from './payment_driver.ts'
47
+ export {
48
+ PaymentConfigError,
49
+ PaymentError,
50
+ PaymentProviderError,
51
+ ProviderUnsupportedError,
52
+ UnknownProviderError,
53
+ WebhookIdempotencyError,
54
+ WebhookSignatureError,
55
+ } from './payment_error.ts'
56
+ export {
57
+ PaymentManager,
58
+ type PaymentManagerOptions,
59
+ } from './payment_manager.ts'
60
+ export { PaymentProvider } from './payment_provider.ts'
61
+ export {
62
+ readTenantId,
63
+ TENANT_METADATA_KEY,
64
+ tenantedMetadata,
65
+ } from './tenant_metadata.ts'
66
+ export type {
67
+ LedgerConfig,
68
+ PaymentConfig,
69
+ ProviderConfig,
70
+ } from './types.ts'
71
+ export {
72
+ paymentWebhook,
73
+ type PaymentWebhookOptions,
74
+ PaymentWebhookEvent,
75
+ PaymentWebhookEventRepository,
76
+ paymentWebhookEventSchema,
77
+ PaymentWebhookRegistry,
78
+ } from './webhook/index.ts'
@@ -0,0 +1,106 @@
1
+ /**
2
+ * `applyPaymentLedgerMigration` — emit DDL for every framework-
3
+ * owned payment table in one call. Apps drop one statement into
4
+ * their migration:
5
+ *
6
+ * ```ts
7
+ * export const migration: Migration = {
8
+ * name: '20260601000000_create_payment_ledger',
9
+ * async up(db) {
10
+ * await applyPaymentLedgerMigration(db, { registry })
11
+ * },
12
+ * async down(db) {
13
+ * await db.execute(emitDropTable(paymentInvoiceSchema.name).sql)
14
+ * await db.execute(emitDropTable(paymentSubscriptionSchema.name).sql)
15
+ * await db.execute(emitDropTable(paymentCustomerSchema.name).sql)
16
+ * await db.execute(emitDropTable(paymentWebhookEventSchema.name).sql)
17
+ * },
18
+ * }
19
+ * ```
20
+ *
21
+ * The helper attaches composite unique constraints + secondary
22
+ * indexes on top of the framework-emitted table DDL. Composite
23
+ * constraints aren't expressible through the schema builder yet
24
+ * — handled here.
25
+ */
26
+
27
+ import {
28
+ emitCreateTable,
29
+ type DatabaseExecutor,
30
+ type SchemaRegistry,
31
+ } from '@strav/database'
32
+ import { paymentCustomerSchema } from './schemas/payment_customer_schema.ts'
33
+ import { paymentInvoiceSchema } from './schemas/payment_invoice_schema.ts'
34
+ import { paymentSubscriptionSchema } from './schemas/payment_subscription_schema.ts'
35
+ import { paymentWebhookEventSchema } from '../webhook/payment_webhook_event_schema.ts'
36
+
37
+ export interface ApplyPaymentLedgerMigrationOptions {
38
+ /** Required for emitCreateTable to resolve tenant FK refs. */
39
+ registry: SchemaRegistry
40
+ /**
41
+ * Skip ledger tables (customers / subscriptions / invoices).
42
+ * When false (default), the full ledger lands. When true, only
43
+ * the dedup table is created — for apps that opt out of local
44
+ * mirroring via `config.payment.ledger.enabled = false`.
45
+ */
46
+ ledgerEnabled?: boolean
47
+ }
48
+
49
+ export async function applyPaymentLedgerMigration(
50
+ db: DatabaseExecutor,
51
+ options: ApplyPaymentLedgerMigrationOptions,
52
+ ): Promise<void> {
53
+ const { registry, ledgerEnabled = true } = options
54
+
55
+ // Dedup ledger — always created, the webhook route depends on it.
56
+ await db.execute(emitCreateTable(paymentWebhookEventSchema, { registry }).sql)
57
+ await db.execute(
58
+ `CREATE UNIQUE INDEX IF NOT EXISTS "idx_payment_webhook_event_provider_event"
59
+ ON "${paymentWebhookEventSchema.name}" ("provider", "provider_event_id")`,
60
+ )
61
+ await db.execute(
62
+ `CREATE INDEX IF NOT EXISTS "idx_payment_webhook_event_type"
63
+ ON "${paymentWebhookEventSchema.name}" ("event_type")`,
64
+ )
65
+
66
+ if (!ledgerEnabled) return
67
+
68
+ await db.execute(emitCreateTable(paymentCustomerSchema, { registry }).sql)
69
+ await db.execute(
70
+ `CREATE UNIQUE INDEX IF NOT EXISTS "idx_payment_customer_provider_id"
71
+ ON "${paymentCustomerSchema.name}" ("provider", "provider_id")`,
72
+ )
73
+ await db.execute(
74
+ `CREATE INDEX IF NOT EXISTS "idx_payment_customer_email"
75
+ ON "${paymentCustomerSchema.name}" ("email")`,
76
+ )
77
+
78
+ await db.execute(emitCreateTable(paymentSubscriptionSchema, { registry }).sql)
79
+ await db.execute(
80
+ `CREATE UNIQUE INDEX IF NOT EXISTS "idx_payment_subscription_provider_id"
81
+ ON "${paymentSubscriptionSchema.name}" ("provider", "provider_id")`,
82
+ )
83
+ await db.execute(
84
+ `CREATE INDEX IF NOT EXISTS "idx_payment_subscription_customer"
85
+ ON "${paymentSubscriptionSchema.name}" ("provider", "customer_provider_id")`,
86
+ )
87
+ await db.execute(
88
+ `CREATE INDEX IF NOT EXISTS "idx_payment_subscription_status"
89
+ ON "${paymentSubscriptionSchema.name}" ("status")`,
90
+ )
91
+
92
+ await db.execute(emitCreateTable(paymentInvoiceSchema, { registry }).sql)
93
+ await db.execute(
94
+ `CREATE UNIQUE INDEX IF NOT EXISTS "idx_payment_invoice_provider_id"
95
+ ON "${paymentInvoiceSchema.name}" ("provider", "provider_id")`,
96
+ )
97
+ await db.execute(
98
+ `CREATE INDEX IF NOT EXISTS "idx_payment_invoice_customer"
99
+ ON "${paymentInvoiceSchema.name}" ("provider", "customer_provider_id")`,
100
+ )
101
+ await db.execute(
102
+ `CREATE INDEX IF NOT EXISTS "idx_payment_invoice_subscription"
103
+ ON "${paymentInvoiceSchema.name}" ("provider", "subscription_provider_id")
104
+ WHERE "subscription_provider_id" IS NOT NULL`,
105
+ )
106
+ }
@@ -0,0 +1,13 @@
1
+ export {
2
+ applyPaymentLedgerMigration,
3
+ type ApplyPaymentLedgerMigrationOptions,
4
+ } from './apply_payment_ledger_migration.ts'
5
+ export { PaymentLedger } from './payment_ledger.ts'
6
+ export {
7
+ PaymentCustomerRow,
8
+ PaymentInvoiceRow,
9
+ PaymentSubscriptionRow,
10
+ } from './payment_ledger_models.ts'
11
+ export { paymentCustomerSchema } from './schemas/payment_customer_schema.ts'
12
+ export { paymentInvoiceSchema } from './schemas/payment_invoice_schema.ts'
13
+ export { paymentSubscriptionSchema } from './schemas/payment_subscription_schema.ts'
@@ -0,0 +1,260 @@
1
+ /**
2
+ * `PaymentLedger` — applies normalized webhook events into the
3
+ * local ledger tables.
4
+ *
5
+ * Sync flow (when `config.payment.ledger.syncOnWebhook` is true):
6
+ *
7
+ * 1. Webhook handler verifies + dedups + normalizes.
8
+ * 2. Before firing user handlers, the dispatcher calls
9
+ * `ledger.applyEvent(event)` — this upserts the matching
10
+ * row(s) in `payment_customer` / `payment_subscription` /
11
+ * `payment_invoice`.
12
+ * 3. User handlers run against an already-up-to-date ledger.
13
+ *
14
+ * Why per-event upserts (not periodic full sync): webhooks are
15
+ * the source-of-truth signal for change. Polling is wasteful and
16
+ * lossy. Apps that miss a webhook (signature secret rotation,
17
+ * downtime) backfill via a `payment:resync` admin command in a
18
+ * follow-up slice.
19
+ *
20
+ * Tenancy: tables are tenanted, but webhooks arrive without
21
+ * tenant context. The ledger resolves the tenant by matching on
22
+ * `(provider, provider_id)` from `payment_customer` rows seeded
23
+ * at customer-creation time (when tenant context IS available).
24
+ * Subscriptions / invoices then inherit the customer's tenant.
25
+ *
26
+ * Events for which no `payment_customer` row exists are skipped
27
+ * with a logged warning — likely a webhook for a customer
28
+ * created outside the framework, or a missed seed.
29
+ */
30
+
31
+ // biome-ignore lint/style/useImportType: PostgresDatabase value import for @inject() metadata.
32
+ import {
33
+ PostgresDatabase,
34
+ quoteIdent,
35
+ type DatabaseExecutor,
36
+ } from '@strav/database'
37
+ import { inject, ulid } from '@strav/kernel'
38
+ import type { NormalizedWebhookEvent } from '../dto/payment_event.ts'
39
+ import { paymentCustomerSchema } from './schemas/payment_customer_schema.ts'
40
+ import { paymentInvoiceSchema } from './schemas/payment_invoice_schema.ts'
41
+ import { paymentSubscriptionSchema } from './schemas/payment_subscription_schema.ts'
42
+
43
+ @inject()
44
+ export class PaymentLedger {
45
+ // biome-ignore lint/complexity/noUselessConstructor: explicit constructor forces TS to emit `design:paramtypes` for @inject().
46
+ constructor(private readonly db: PostgresDatabase) {}
47
+
48
+ /**
49
+ * Apply a normalized event. Idempotent — re-running with the
50
+ * same event yields the same row state.
51
+ *
52
+ * `executor` is an optional database handle — the webhook
53
+ * dispatcher passes the transaction returned by
54
+ * `TenantManager.withTenant(tenantId, async (tx) => ...)` so
55
+ * the INSERTs see `current_setting('app.tenant_id')` set by
56
+ * `withTenant`'s `set_config(..., true)` (LOCAL = transaction
57
+ * scope). When omitted (direct calls outside webhook flow),
58
+ * the ledger falls back to the pooled `PostgresDatabase`, which
59
+ * is correct only when the caller has already SET the session
60
+ * setting on that connection.
61
+ *
62
+ * Implementation note for v1: customers / subscriptions /
63
+ * invoices each have a partial-payload upsert path that reads
64
+ * the structured fields off `event._fields` (drivers stamp it
65
+ * during `normalize`). When `_fields` is absent the upsert
66
+ * no-ops (apps still get the event, just no local mirror).
67
+ */
68
+ async applyEvent(
69
+ event: NormalizedWebhookEvent,
70
+ executor?: DatabaseExecutor,
71
+ ): Promise<void> {
72
+ const fields = (event as { _fields?: Record<string, unknown> })._fields
73
+ if (!fields) return
74
+ const exec = executor ?? this.db
75
+
76
+ switch (event.type) {
77
+ case 'customer.created':
78
+ case 'customer.updated':
79
+ await this.upsertCustomer(exec, event.provider, fields)
80
+ return
81
+ case 'customer.deleted':
82
+ if (event.data.customerId) {
83
+ await this.deleteCustomer(exec, event.provider, event.data.customerId)
84
+ }
85
+ return
86
+ case 'subscription.created':
87
+ case 'subscription.updated':
88
+ case 'subscription.canceled':
89
+ case 'subscription.trial_will_end':
90
+ await this.upsertSubscription(exec, event.provider, fields)
91
+ return
92
+ case 'invoice.created':
93
+ case 'invoice.paid':
94
+ case 'invoice.payment_failed':
95
+ case 'invoice.voided':
96
+ await this.upsertInvoice(exec, event.provider, fields)
97
+ return
98
+ default:
99
+ return
100
+ }
101
+ }
102
+
103
+ private async upsertCustomer(
104
+ exec: DatabaseExecutor,
105
+ provider: string,
106
+ fields: Record<string, unknown>,
107
+ ): Promise<void> {
108
+ const table = quoteIdent(paymentCustomerSchema.name)
109
+ // `tenant_id` is pulled from the session setting (`app.tenant_id`)
110
+ // set by `TenantManager.withTenant(...)` around the webhook
111
+ // dispatcher. Same pattern as `@strav/rag`'s pgvector driver.
112
+ await exec.execute(
113
+ `INSERT INTO ${table}
114
+ ("id","tenant_id","provider","provider_id","email","name","phone","metadata","created_at","updated_at")
115
+ VALUES ($1, current_setting('app.tenant_id', true), $2, $3, $4, $5, $6, $7::jsonb, $8, NOW())
116
+ ON CONFLICT ("provider","provider_id") DO UPDATE SET
117
+ "email" = EXCLUDED."email",
118
+ "name" = EXCLUDED."name",
119
+ "phone" = EXCLUDED."phone",
120
+ "metadata" = EXCLUDED."metadata",
121
+ "updated_at" = NOW()`,
122
+ [
123
+ ulid(),
124
+ provider,
125
+ str(fields.id),
126
+ str(fields.email),
127
+ nullable(fields.name),
128
+ nullable(fields.phone),
129
+ JSON.stringify(fields.metadata ?? {}),
130
+ toDate(fields.createdAt) ?? new Date(),
131
+ ],
132
+ )
133
+ }
134
+
135
+ private async deleteCustomer(
136
+ exec: DatabaseExecutor,
137
+ provider: string,
138
+ providerId: string,
139
+ ): Promise<void> {
140
+ const table = quoteIdent(paymentCustomerSchema.name)
141
+ await exec.execute(
142
+ `DELETE FROM ${table} WHERE "provider" = $1 AND "provider_id" = $2`,
143
+ [provider, providerId],
144
+ )
145
+ }
146
+
147
+ private async upsertSubscription(
148
+ exec: DatabaseExecutor,
149
+ provider: string,
150
+ fields: Record<string, unknown>,
151
+ ): Promise<void> {
152
+ const table = quoteIdent(paymentSubscriptionSchema.name)
153
+ await exec.execute(
154
+ `INSERT INTO ${table}
155
+ ("id","tenant_id","provider","provider_id","customer_provider_id","price_provider_id",
156
+ "status","current_period_start","current_period_end","cancel_at","canceled_at",
157
+ "trial_start","trial_end","metadata","created_at","updated_at")
158
+ VALUES ($1, current_setting('app.tenant_id', true), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb, $14, NOW())
159
+ ON CONFLICT ("provider","provider_id") DO UPDATE SET
160
+ "status" = EXCLUDED."status",
161
+ "current_period_start" = EXCLUDED."current_period_start",
162
+ "current_period_end" = EXCLUDED."current_period_end",
163
+ "cancel_at" = EXCLUDED."cancel_at",
164
+ "canceled_at" = EXCLUDED."canceled_at",
165
+ "trial_start" = EXCLUDED."trial_start",
166
+ "trial_end" = EXCLUDED."trial_end",
167
+ "metadata" = EXCLUDED."metadata",
168
+ "updated_at" = NOW()`,
169
+ [
170
+ ulid(),
171
+ provider,
172
+ str(fields.id),
173
+ str(fields.customerId),
174
+ str(fields.priceId),
175
+ str(fields.status),
176
+ toDate(fields.currentPeriodStart) ?? new Date(),
177
+ toDate(fields.currentPeriodEnd) ?? new Date(),
178
+ toDate(fields.cancelAt),
179
+ toDate(fields.canceledAt),
180
+ toDate(fields.trialStart),
181
+ toDate(fields.trialEnd),
182
+ JSON.stringify(fields.metadata ?? {}),
183
+ toDate(fields.createdAt) ?? new Date(),
184
+ ],
185
+ )
186
+ }
187
+
188
+ private async upsertInvoice(
189
+ exec: DatabaseExecutor,
190
+ provider: string,
191
+ fields: Record<string, unknown>,
192
+ ): Promise<void> {
193
+ const table = quoteIdent(paymentInvoiceSchema.name)
194
+ await exec.execute(
195
+ `INSERT INTO ${table}
196
+ ("id","tenant_id","provider","provider_id","customer_provider_id","subscription_provider_id",
197
+ "status","amount","amount_paid","amount_due","currency",
198
+ "hosted_url","pdf_url","due_at","paid_at","metadata","created_at","updated_at")
199
+ VALUES ($1, current_setting('app.tenant_id', true), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15::jsonb, $16, NOW())
200
+ ON CONFLICT ("provider","provider_id") DO UPDATE SET
201
+ "status" = EXCLUDED."status",
202
+ "amount" = EXCLUDED."amount",
203
+ "amount_paid" = EXCLUDED."amount_paid",
204
+ "amount_due" = EXCLUDED."amount_due",
205
+ "hosted_url" = EXCLUDED."hosted_url",
206
+ "pdf_url" = EXCLUDED."pdf_url",
207
+ "due_at" = EXCLUDED."due_at",
208
+ "paid_at" = EXCLUDED."paid_at",
209
+ "metadata" = EXCLUDED."metadata",
210
+ "updated_at" = NOW()`,
211
+ [
212
+ ulid(),
213
+ provider,
214
+ str(fields.id),
215
+ str(fields.customerId),
216
+ nullable(fields.subscriptionId),
217
+ str(fields.status),
218
+ num(fields.amount),
219
+ num(fields.amountPaid),
220
+ num(fields.amountDue),
221
+ str(fields.currency),
222
+ nullable(fields.hostedUrl),
223
+ nullable(fields.pdfUrl),
224
+ toDate(fields.dueAt),
225
+ toDate(fields.paidAt),
226
+ JSON.stringify(fields.metadata ?? {}),
227
+ toDate(fields.createdAt) ?? new Date(),
228
+ ],
229
+ )
230
+ }
231
+ }
232
+
233
+ function str(v: unknown): string {
234
+ if (typeof v !== 'string') {
235
+ throw new TypeError(`PaymentLedger: expected string field, got ${typeof v}`)
236
+ }
237
+ return v
238
+ }
239
+
240
+ function num(v: unknown): number {
241
+ if (typeof v !== 'number' || !Number.isFinite(v)) {
242
+ throw new TypeError(`PaymentLedger: expected finite number, got ${typeof v}`)
243
+ }
244
+ return v
245
+ }
246
+
247
+ function nullable(v: unknown): string | null {
248
+ if (v === null || v === undefined) return null
249
+ if (typeof v !== 'string') {
250
+ throw new TypeError(`PaymentLedger: expected string or null, got ${typeof v}`)
251
+ }
252
+ return v
253
+ }
254
+
255
+ function toDate(v: unknown): Date | null {
256
+ if (v === null || v === undefined) return null
257
+ if (v instanceof Date) return v
258
+ if (typeof v === 'string' || typeof v === 'number') return new Date(v)
259
+ return null
260
+ }