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