@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,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Omise webhook signature verification + event normalization.
|
|
3
|
+
*
|
|
4
|
+
* Omise signs webhook deliveries with HMAC SHA-256 over the raw
|
|
5
|
+
* body using the webhook secret from the Dashboard. The signature
|
|
6
|
+
* is sent in `X-Omise-Signature` as a hex digest. The SDK doesn't
|
|
7
|
+
* bundle a verifier — we implement it here.
|
|
8
|
+
*
|
|
9
|
+
* Event shape: `{ id, object: 'event', key, created_at, data }`
|
|
10
|
+
* where `key` is the event name (`'charge.complete'`,
|
|
11
|
+
* `'customer.create'`, …). We map the common keys onto the
|
|
12
|
+
* framework's `PaymentEventType` union.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { createHmac, timingSafeEqual } from 'node:crypto'
|
|
16
|
+
import { WebhookSignatureError } from '../../payment_error.ts'
|
|
17
|
+
import type {
|
|
18
|
+
NormalizedWebhookEvent,
|
|
19
|
+
PaymentEventType,
|
|
20
|
+
} from '../../dto/payment_event.ts'
|
|
21
|
+
import { readTenantId } from '../../tenant_metadata.ts'
|
|
22
|
+
import {
|
|
23
|
+
toPaymentCharge,
|
|
24
|
+
toPaymentCustomer,
|
|
25
|
+
type OmiseCharge,
|
|
26
|
+
type OmiseCustomer,
|
|
27
|
+
} from './omise_mappers.ts'
|
|
28
|
+
import {
|
|
29
|
+
toPaymentSubscription as toPaymentSubscriptionFromSchedule,
|
|
30
|
+
type OmiseSchedule,
|
|
31
|
+
} from './omise_schedule_mapper.ts'
|
|
32
|
+
|
|
33
|
+
interface OmiseEvent {
|
|
34
|
+
id: string
|
|
35
|
+
object: 'event'
|
|
36
|
+
key: string
|
|
37
|
+
created_at?: string
|
|
38
|
+
created?: string
|
|
39
|
+
data: { object?: unknown; [k: string]: unknown }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const TYPE_MAP: Record<string, PaymentEventType> = {
|
|
43
|
+
'customer.create': 'customer.created',
|
|
44
|
+
'customer.update': 'customer.updated',
|
|
45
|
+
'customer.destroy': 'customer.deleted',
|
|
46
|
+
'charge.create': 'charge.succeeded',
|
|
47
|
+
'charge.complete': 'charge.succeeded',
|
|
48
|
+
'charge.update': 'charge.succeeded',
|
|
49
|
+
'charge.capture': 'charge.succeeded',
|
|
50
|
+
'charge.expire': 'charge.failed',
|
|
51
|
+
'refund.create': 'charge.refunded',
|
|
52
|
+
'schedule.create': 'subscription.created',
|
|
53
|
+
'schedule.update': 'subscription.updated',
|
|
54
|
+
'schedule.destroy': 'subscription.canceled',
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Verify an Omise webhook signature against the raw body using
|
|
59
|
+
* the configured secret. Throws `WebhookSignatureError` on
|
|
60
|
+
* mismatch or missing secret. Returns the parsed event payload
|
|
61
|
+
* on success.
|
|
62
|
+
*/
|
|
63
|
+
export async function omiseVerify(
|
|
64
|
+
rawBody: string,
|
|
65
|
+
signature: string,
|
|
66
|
+
webhookSecret: string | undefined,
|
|
67
|
+
): Promise<OmiseEvent> {
|
|
68
|
+
if (!webhookSecret) {
|
|
69
|
+
throw new WebhookSignatureError(
|
|
70
|
+
'OmisePaymentDriver.webhook.verify: `webhookSecret` is not set on the provider config.',
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
const expected = createHmac('sha256', webhookSecret).update(rawBody).digest('hex')
|
|
74
|
+
const provided = signature.trim()
|
|
75
|
+
// Use Uint8Array views to dodge the Buffer<ArrayBufferLike>
|
|
76
|
+
// mismatch with `timingSafeEqual`'s `ArrayBufferView`-typed
|
|
77
|
+
// params. Hex strings of unequal length are always rejected.
|
|
78
|
+
const expectedBuf = new Uint8Array(Buffer.from(expected, 'hex'))
|
|
79
|
+
const providedBuf = new Uint8Array(Buffer.from(provided, 'hex'))
|
|
80
|
+
if (
|
|
81
|
+
expectedBuf.length === 0 ||
|
|
82
|
+
expectedBuf.length !== providedBuf.length ||
|
|
83
|
+
!timingSafeEqual(expectedBuf, providedBuf)
|
|
84
|
+
) {
|
|
85
|
+
throw new WebhookSignatureError(
|
|
86
|
+
'OmisePaymentDriver.webhook.verify: signature mismatch.',
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
return JSON.parse(rawBody) as OmiseEvent
|
|
91
|
+
} catch (cause) {
|
|
92
|
+
throw new WebhookSignatureError(
|
|
93
|
+
'OmisePaymentDriver.webhook.verify: body is not valid JSON.',
|
|
94
|
+
{ cause },
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function omiseNormalize(event: OmiseEvent): NormalizedWebhookEvent | null {
|
|
100
|
+
const type = TYPE_MAP[event.key]
|
|
101
|
+
if (!type) return null
|
|
102
|
+
const data: NormalizedWebhookEvent['data'] = {}
|
|
103
|
+
let fields: Record<string, unknown> | undefined
|
|
104
|
+
const object = event.data.object
|
|
105
|
+
|
|
106
|
+
switch (type) {
|
|
107
|
+
case 'customer.created':
|
|
108
|
+
case 'customer.updated':
|
|
109
|
+
if (object && typeof object === 'object') {
|
|
110
|
+
const dto = toPaymentCustomer(object as OmiseCustomer)
|
|
111
|
+
data.customerId = dto.id
|
|
112
|
+
fields = { ...dto }
|
|
113
|
+
}
|
|
114
|
+
break
|
|
115
|
+
case 'customer.deleted':
|
|
116
|
+
if (object && typeof object === 'object' && 'id' in (object as { id?: string })) {
|
|
117
|
+
data.customerId = (object as { id: string }).id
|
|
118
|
+
}
|
|
119
|
+
break
|
|
120
|
+
case 'charge.succeeded':
|
|
121
|
+
case 'charge.failed':
|
|
122
|
+
case 'charge.refunded':
|
|
123
|
+
if (object && typeof object === 'object') {
|
|
124
|
+
const dto = toPaymentCharge(object as OmiseCharge)
|
|
125
|
+
data.chargeId = dto.id
|
|
126
|
+
if (dto.customerId) data.customerId = dto.customerId
|
|
127
|
+
}
|
|
128
|
+
break
|
|
129
|
+
case 'subscription.created':
|
|
130
|
+
case 'subscription.updated':
|
|
131
|
+
case 'subscription.canceled':
|
|
132
|
+
if (object && typeof object === 'object') {
|
|
133
|
+
const dto = toPaymentSubscriptionFromSchedule(object as OmiseSchedule)
|
|
134
|
+
data.subscriptionId = dto.id
|
|
135
|
+
if (dto.customerId) data.customerId = dto.customerId
|
|
136
|
+
fields = { ...dto }
|
|
137
|
+
}
|
|
138
|
+
break
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Same convention as Stripe: read `strav_tenant_id` off the
|
|
142
|
+
// event's underlying resource metadata. Omise echoes metadata
|
|
143
|
+
// verbatim on every event the framework cares about.
|
|
144
|
+
const resourceMeta =
|
|
145
|
+
(object as { metadata?: Record<string, unknown> } | undefined)?.metadata ?? null
|
|
146
|
+
const tenantId = readTenantId(resourceMeta)
|
|
147
|
+
|
|
148
|
+
const normalized: NormalizedWebhookEvent = {
|
|
149
|
+
id: event.id,
|
|
150
|
+
type,
|
|
151
|
+
provider: 'omise',
|
|
152
|
+
raw: event,
|
|
153
|
+
data,
|
|
154
|
+
...(tenantId ? { tenantId } : {}),
|
|
155
|
+
}
|
|
156
|
+
if (fields) {
|
|
157
|
+
;(normalized as { _fields?: unknown })._fields = fields
|
|
158
|
+
}
|
|
159
|
+
return normalized
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export type { OmiseEvent }
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for handling `CreateChargeInput.paymentMethod` —
|
|
3
|
+
* `string | PaymentMethodSpec`.
|
|
4
|
+
*
|
|
5
|
+
* `extractCardToken` collapses both back-compat shapes (raw
|
|
6
|
+
* tokenized id string, `{ kind: 'card', token }` spec) onto a
|
|
7
|
+
* single token id. Specs of any other kind are not card flows;
|
|
8
|
+
* the helper signals that with `null`. Drivers then decide
|
|
9
|
+
* whether to route into their async-method pipeline (slices
|
|
10
|
+
* 7.2 / 7.3) or throw `ProviderUnsupportedError`.
|
|
11
|
+
*
|
|
12
|
+
* Drivers that don't yet support async methods can:
|
|
13
|
+
* const token = extractCardToken(input.paymentMethod)
|
|
14
|
+
* if (input.paymentMethod && !token) throw new ProviderUnsupportedError(...)
|
|
15
|
+
* // …pass `token` as before
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { PaymentMethodSpec } from '../dto/payment_charge.ts'
|
|
19
|
+
|
|
20
|
+
export function extractCardToken(
|
|
21
|
+
pm: string | PaymentMethodSpec | undefined,
|
|
22
|
+
): string | null {
|
|
23
|
+
if (pm === undefined) return null
|
|
24
|
+
if (typeof pm === 'string') return pm
|
|
25
|
+
if (pm.kind === 'card') return pm.token
|
|
26
|
+
return null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function paymentMethodKind(
|
|
30
|
+
pm: string | PaymentMethodSpec | undefined,
|
|
31
|
+
): PaymentMethodSpec['kind'] | 'unspecified' {
|
|
32
|
+
if (pm === undefined) return 'unspecified'
|
|
33
|
+
if (typeof pm === 'string') return 'card'
|
|
34
|
+
return pm.kind
|
|
35
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Public API of `@strav/payment/stripe`.
|
|
2
|
+
//
|
|
3
|
+
// Subpath barrel for the Stripe driver. Apps import from here to
|
|
4
|
+
// register the adapter:
|
|
5
|
+
//
|
|
6
|
+
// ```ts
|
|
7
|
+
// import { StripePaymentProvider } from '@strav/payment/stripe'
|
|
8
|
+
//
|
|
9
|
+
// export default [PaymentProvider, StripePaymentProvider, ...]
|
|
10
|
+
// ```
|
|
11
|
+
//
|
|
12
|
+
// `StripePaymentDriver` + mapper exports are advanced — used by
|
|
13
|
+
// tests and by apps that hand-wire a driver instance via
|
|
14
|
+
// `manager.useDriver(name, driver)`.
|
|
15
|
+
|
|
16
|
+
export {
|
|
17
|
+
toPaymentCharge,
|
|
18
|
+
toPaymentCheckoutSession,
|
|
19
|
+
toPaymentCustomer,
|
|
20
|
+
toPaymentInvoice,
|
|
21
|
+
toPaymentLink,
|
|
22
|
+
toPaymentMethod,
|
|
23
|
+
toPaymentPrice,
|
|
24
|
+
toPaymentProduct,
|
|
25
|
+
toPaymentSubscription,
|
|
26
|
+
} from './mappers/stripe_mappers.ts'
|
|
27
|
+
export {
|
|
28
|
+
buildStripeMethodWiring,
|
|
29
|
+
STRIPE_SUPPORTED_METHOD_KINDS,
|
|
30
|
+
type StripeMethodBuildResult,
|
|
31
|
+
type StripeMethodWiring,
|
|
32
|
+
} from './mappers/stripe_method_spec.ts'
|
|
33
|
+
export { stripeNextAction } from './mappers/stripe_next_action_mapper.ts'
|
|
34
|
+
export type { StripeProviderConfig } from './stripe_config.ts'
|
|
35
|
+
export {
|
|
36
|
+
StripePaymentDriver,
|
|
37
|
+
type StripeDriverOptions,
|
|
38
|
+
} from './stripe_driver.ts'
|
|
39
|
+
export { StripePaymentProvider } from './stripe_provider.ts'
|
|
40
|
+
export { stripeNormalize } from './webhook/stripe_normalize.ts'
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stripe ↔ normalized-DTO mappers. One function per resource;
|
|
3
|
+
* each converts a `Stripe.<X>` SDK object into the framework's
|
|
4
|
+
* `Payment<X>` DTO with the native object on `.raw`.
|
|
5
|
+
*
|
|
6
|
+
* Field name conventions:
|
|
7
|
+
* - Stripe timestamps are unix seconds; we multiply by 1000.
|
|
8
|
+
* - Stripe metadata is `Record<string, string>` directly.
|
|
9
|
+
* - Missing-from-Stripe-but-required-by-DTO falls back to
|
|
10
|
+
* sensible defaults (empty string, `{}`); never invent ids.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type Stripe from 'stripe'
|
|
14
|
+
import type {
|
|
15
|
+
ChargeStatus,
|
|
16
|
+
InvoiceStatus,
|
|
17
|
+
PaymentCharge,
|
|
18
|
+
PaymentCheckoutSession,
|
|
19
|
+
PaymentCustomer,
|
|
20
|
+
PaymentInvoice,
|
|
21
|
+
PaymentMethod,
|
|
22
|
+
PaymentPrice,
|
|
23
|
+
PaymentProduct,
|
|
24
|
+
PaymentSubscription,
|
|
25
|
+
SubscriptionStatus,
|
|
26
|
+
} from '../../../dto/index.ts'
|
|
27
|
+
|
|
28
|
+
const PROVIDER = 'stripe'
|
|
29
|
+
|
|
30
|
+
function toDate(unix: number | null | undefined): Date | null {
|
|
31
|
+
if (unix === null || unix === undefined) return null
|
|
32
|
+
return new Date(unix * 1000)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function metadata(m: Stripe.Metadata | null | undefined): Record<string, string> {
|
|
36
|
+
if (!m) return {}
|
|
37
|
+
const out: Record<string, string> = {}
|
|
38
|
+
for (const [k, v] of Object.entries(m)) {
|
|
39
|
+
if (v === null) continue
|
|
40
|
+
out[k] = String(v)
|
|
41
|
+
}
|
|
42
|
+
return out
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function toPaymentCustomer(c: Stripe.Customer): PaymentCustomer {
|
|
46
|
+
return {
|
|
47
|
+
id: c.id,
|
|
48
|
+
provider: PROVIDER,
|
|
49
|
+
email: c.email ?? '',
|
|
50
|
+
...(c.name ? { name: c.name } : {}),
|
|
51
|
+
...(c.phone ? { phone: c.phone } : {}),
|
|
52
|
+
metadata: metadata(c.metadata),
|
|
53
|
+
createdAt: new Date(c.created * 1000),
|
|
54
|
+
raw: c,
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function toPaymentProduct(p: Stripe.Product): PaymentProduct {
|
|
59
|
+
return {
|
|
60
|
+
id: p.id,
|
|
61
|
+
provider: PROVIDER,
|
|
62
|
+
name: p.name,
|
|
63
|
+
...(p.description ? { description: p.description } : {}),
|
|
64
|
+
active: p.active,
|
|
65
|
+
metadata: metadata(p.metadata),
|
|
66
|
+
createdAt: new Date(p.created * 1000),
|
|
67
|
+
raw: p,
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function toPaymentPrice(p: Stripe.Price): PaymentPrice {
|
|
72
|
+
return {
|
|
73
|
+
id: p.id,
|
|
74
|
+
provider: PROVIDER,
|
|
75
|
+
productId: typeof p.product === 'string' ? p.product : (p.product as { id: string }).id,
|
|
76
|
+
amount: p.unit_amount ?? 0,
|
|
77
|
+
currency: p.currency,
|
|
78
|
+
type: p.type === 'recurring' ? 'recurring' : 'one_time',
|
|
79
|
+
...(p.recurring?.interval
|
|
80
|
+
? { interval: p.recurring.interval as PaymentPrice['interval'] }
|
|
81
|
+
: {}),
|
|
82
|
+
...(p.recurring?.interval_count
|
|
83
|
+
? { intervalCount: p.recurring.interval_count }
|
|
84
|
+
: {}),
|
|
85
|
+
active: p.active,
|
|
86
|
+
metadata: metadata(p.metadata),
|
|
87
|
+
createdAt: new Date(p.created * 1000),
|
|
88
|
+
raw: p,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const SUBSCRIPTION_STATUS_MAP: Record<Stripe.Subscription.Status, SubscriptionStatus> = {
|
|
93
|
+
active: 'active',
|
|
94
|
+
trialing: 'trialing',
|
|
95
|
+
past_due: 'past_due',
|
|
96
|
+
canceled: 'canceled',
|
|
97
|
+
unpaid: 'past_due',
|
|
98
|
+
incomplete: 'incomplete',
|
|
99
|
+
incomplete_expired: 'canceled',
|
|
100
|
+
paused: 'paused',
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function toPaymentSubscription(s: Stripe.Subscription): PaymentSubscription {
|
|
104
|
+
// Stripe sometimes nests the actual price on items.data[0].price.
|
|
105
|
+
const firstItem = s.items.data[0]
|
|
106
|
+
const priceId = firstItem
|
|
107
|
+
? typeof firstItem.price === 'string'
|
|
108
|
+
? firstItem.price
|
|
109
|
+
: firstItem.price.id
|
|
110
|
+
: ''
|
|
111
|
+
// `current_period_start/end` moved to the item level in recent API
|
|
112
|
+
// versions; fall back across both shapes.
|
|
113
|
+
const sAny = s as unknown as { current_period_start?: number; current_period_end?: number }
|
|
114
|
+
const itemAny = firstItem as unknown as
|
|
115
|
+
| { current_period_start?: number; current_period_end?: number }
|
|
116
|
+
| undefined
|
|
117
|
+
const periodStart = sAny.current_period_start ?? itemAny?.current_period_start ?? s.start_date
|
|
118
|
+
const periodEnd = sAny.current_period_end ?? itemAny?.current_period_end ?? s.start_date
|
|
119
|
+
return {
|
|
120
|
+
id: s.id,
|
|
121
|
+
provider: PROVIDER,
|
|
122
|
+
customerId: typeof s.customer === 'string' ? s.customer : (s.customer as { id: string }).id,
|
|
123
|
+
priceId,
|
|
124
|
+
status: SUBSCRIPTION_STATUS_MAP[s.status] ?? 'active',
|
|
125
|
+
currentPeriodStart: new Date(periodStart * 1000),
|
|
126
|
+
currentPeriodEnd: new Date(periodEnd * 1000),
|
|
127
|
+
cancelAt: toDate(s.cancel_at),
|
|
128
|
+
canceledAt: toDate(s.canceled_at),
|
|
129
|
+
trialStart: toDate(s.trial_start),
|
|
130
|
+
trialEnd: toDate(s.trial_end),
|
|
131
|
+
metadata: metadata(s.metadata),
|
|
132
|
+
createdAt: new Date(s.created * 1000),
|
|
133
|
+
raw: s,
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function paymentMethodKind(kind: string): PaymentMethod['kind'] {
|
|
138
|
+
switch (kind) {
|
|
139
|
+
case 'card':
|
|
140
|
+
return 'card'
|
|
141
|
+
case 'us_bank_account':
|
|
142
|
+
case 'bank_account':
|
|
143
|
+
return 'bank_account'
|
|
144
|
+
case 'sepa_debit':
|
|
145
|
+
return 'sepa_debit'
|
|
146
|
+
case 'paypal':
|
|
147
|
+
return 'paypal'
|
|
148
|
+
default:
|
|
149
|
+
return 'other'
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function toPaymentMethod(pm: Stripe.PaymentMethod): PaymentMethod {
|
|
154
|
+
const card = pm.card
|
|
155
|
+
return {
|
|
156
|
+
id: pm.id,
|
|
157
|
+
provider: PROVIDER,
|
|
158
|
+
customerId:
|
|
159
|
+
typeof pm.customer === 'string'
|
|
160
|
+
? pm.customer
|
|
161
|
+
: pm.customer
|
|
162
|
+
? (pm.customer as { id: string }).id
|
|
163
|
+
: null,
|
|
164
|
+
kind: paymentMethodKind(pm.type),
|
|
165
|
+
...(card?.brand ? { brand: card.brand } : {}),
|
|
166
|
+
...(card?.last4 ? { last4: card.last4 } : {}),
|
|
167
|
+
...(card?.exp_month ? { expMonth: card.exp_month } : {}),
|
|
168
|
+
...(card?.exp_year ? { expYear: card.exp_year } : {}),
|
|
169
|
+
metadata: metadata(pm.metadata),
|
|
170
|
+
createdAt: new Date(pm.created * 1000),
|
|
171
|
+
raw: pm,
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const CHARGE_STATUS_MAP: Record<Stripe.Charge.Status, ChargeStatus> = {
|
|
176
|
+
succeeded: 'succeeded',
|
|
177
|
+
pending: 'pending',
|
|
178
|
+
failed: 'failed',
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function toPaymentCharge(c: Stripe.Charge): PaymentCharge {
|
|
182
|
+
const status: ChargeStatus = c.refunded
|
|
183
|
+
? c.amount_refunded === c.amount
|
|
184
|
+
? 'refunded'
|
|
185
|
+
: 'partial_refunded'
|
|
186
|
+
: (CHARGE_STATUS_MAP[c.status] ?? 'pending')
|
|
187
|
+
return {
|
|
188
|
+
id: c.id,
|
|
189
|
+
provider: PROVIDER,
|
|
190
|
+
customerId: typeof c.customer === 'string' ? c.customer : (c.customer as { id: string } | null)?.id ?? null,
|
|
191
|
+
amount: c.amount,
|
|
192
|
+
currency: c.currency,
|
|
193
|
+
status,
|
|
194
|
+
paymentMethodId:
|
|
195
|
+
typeof c.payment_method === 'string'
|
|
196
|
+
? c.payment_method
|
|
197
|
+
: (c.payment_method as { id: string } | null)?.id ?? null,
|
|
198
|
+
failureCode: c.failure_code,
|
|
199
|
+
failureMessage: c.failure_message,
|
|
200
|
+
// Settled charges don't carry a next-action. The intent-level
|
|
201
|
+
// mapper used by `charges.create` populates `nextAction` from
|
|
202
|
+
// `PaymentIntent.next_action` when the charge is still in
|
|
203
|
+
// `requires_action`; that lands in slice 7.2.
|
|
204
|
+
nextAction: null,
|
|
205
|
+
metadata: metadata(c.metadata),
|
|
206
|
+
createdAt: new Date(c.created * 1000),
|
|
207
|
+
raw: c,
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const INVOICE_STATUS_MAP: Record<NonNullable<Stripe.Invoice.Status>, InvoiceStatus> = {
|
|
212
|
+
draft: 'draft',
|
|
213
|
+
open: 'open',
|
|
214
|
+
paid: 'paid',
|
|
215
|
+
uncollectible: 'uncollectible',
|
|
216
|
+
void: 'void',
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function toPaymentInvoice(i: Stripe.Invoice): PaymentInvoice {
|
|
220
|
+
// Stripe invoices carry the subscription reference on a nested
|
|
221
|
+
// line-item property; fall back to a top-level field on older
|
|
222
|
+
// shapes.
|
|
223
|
+
const iAny = i as unknown as {
|
|
224
|
+
subscription?: string | { id: string } | null
|
|
225
|
+
customer?: string | { id: string } | null
|
|
226
|
+
hosted_invoice_url?: string | null
|
|
227
|
+
invoice_pdf?: string | null
|
|
228
|
+
}
|
|
229
|
+
const subId =
|
|
230
|
+
typeof iAny.subscription === 'string'
|
|
231
|
+
? iAny.subscription
|
|
232
|
+
: iAny.subscription
|
|
233
|
+
? (iAny.subscription as { id: string }).id
|
|
234
|
+
: null
|
|
235
|
+
return {
|
|
236
|
+
id: i.id ?? '',
|
|
237
|
+
provider: PROVIDER,
|
|
238
|
+
customerId:
|
|
239
|
+
typeof iAny.customer === 'string'
|
|
240
|
+
? iAny.customer
|
|
241
|
+
: iAny.customer
|
|
242
|
+
? (iAny.customer as { id: string }).id
|
|
243
|
+
: '',
|
|
244
|
+
subscriptionId: subId,
|
|
245
|
+
status: i.status ? (INVOICE_STATUS_MAP[i.status] ?? 'open') : 'draft',
|
|
246
|
+
amount: i.amount_due,
|
|
247
|
+
amountPaid: i.amount_paid,
|
|
248
|
+
amountDue: i.amount_remaining ?? i.amount_due,
|
|
249
|
+
currency: i.currency,
|
|
250
|
+
hostedUrl: iAny.hosted_invoice_url ?? null,
|
|
251
|
+
pdfUrl: iAny.invoice_pdf ?? null,
|
|
252
|
+
dueAt: toDate(i.due_date),
|
|
253
|
+
paidAt: i.status === 'paid' ? toDate(i.status_transitions?.paid_at) : null,
|
|
254
|
+
metadata: metadata(i.metadata),
|
|
255
|
+
createdAt: new Date(i.created * 1000),
|
|
256
|
+
raw: i,
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function toPaymentLink(l: Stripe.PaymentLink): import('../../../dto/index.ts').PaymentLink {
|
|
261
|
+
// Stripe Payment Links carry their amount/currency on the
|
|
262
|
+
// attached line_items.data[0].price — when the SDK doesn't
|
|
263
|
+
// expand it, those fields are `null` on our DTO and apps
|
|
264
|
+
// resolve the price separately. The link itself doesn't
|
|
265
|
+
// expose a top-level amount.
|
|
266
|
+
return {
|
|
267
|
+
id: l.id,
|
|
268
|
+
provider: PROVIDER,
|
|
269
|
+
url: l.url,
|
|
270
|
+
amount: null,
|
|
271
|
+
currency: null,
|
|
272
|
+
active: l.active,
|
|
273
|
+
reusable: true, // Stripe links are reusable by default; single-use is rare.
|
|
274
|
+
metadata: metadata(l.metadata),
|
|
275
|
+
createdAt: new Date(0), // PaymentLink.created not on Stripe.PaymentLink — apps that need it read from the dashboard.
|
|
276
|
+
raw: l,
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function toPaymentCheckoutSession(
|
|
281
|
+
s: Stripe.Checkout.Session,
|
|
282
|
+
): PaymentCheckoutSession {
|
|
283
|
+
return {
|
|
284
|
+
id: s.id,
|
|
285
|
+
provider: PROVIDER,
|
|
286
|
+
mode: s.mode === 'subscription' ? 'subscription' : s.mode === 'setup' ? 'setup' : 'payment',
|
|
287
|
+
status: s.status === 'complete' ? 'complete' : s.status === 'expired' ? 'expired' : 'open',
|
|
288
|
+
url: s.url ?? '',
|
|
289
|
+
customerId:
|
|
290
|
+
typeof s.customer === 'string'
|
|
291
|
+
? s.customer
|
|
292
|
+
: s.customer
|
|
293
|
+
? (s.customer as { id: string }).id
|
|
294
|
+
: null,
|
|
295
|
+
paymentIntentId:
|
|
296
|
+
typeof s.payment_intent === 'string'
|
|
297
|
+
? s.payment_intent
|
|
298
|
+
: s.payment_intent
|
|
299
|
+
? (s.payment_intent as { id: string }).id
|
|
300
|
+
: null,
|
|
301
|
+
subscriptionId:
|
|
302
|
+
typeof s.subscription === 'string'
|
|
303
|
+
? s.subscription
|
|
304
|
+
: s.subscription
|
|
305
|
+
? (s.subscription as { id: string }).id
|
|
306
|
+
: null,
|
|
307
|
+
expiresAt: toDate(s.expires_at),
|
|
308
|
+
metadata: metadata(s.metadata),
|
|
309
|
+
createdAt: new Date(s.created * 1000),
|
|
310
|
+
raw: s,
|
|
311
|
+
}
|
|
312
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a Stripe `payment_method_data` (plus matching
|
|
3
|
+
* `payment_method_options` extras) from a `PaymentMethodSpec`.
|
|
4
|
+
*
|
|
5
|
+
* Returns `null` when the spec is a card token (the caller passes
|
|
6
|
+
* `payment_method: <token>` directly), or `undefined` when the
|
|
7
|
+
* driver should reject the kind via `ProviderUnsupportedError`.
|
|
8
|
+
*
|
|
9
|
+
* Stripe supports a different set of methods than Omise — this
|
|
10
|
+
* function is the single place where the mapping lives so the
|
|
11
|
+
* capability set + the create-call wiring stay in sync.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type Stripe from 'stripe'
|
|
15
|
+
import type { PaymentMethodSpec } from '../../../dto/index.ts'
|
|
16
|
+
|
|
17
|
+
export interface StripeMethodWiring {
|
|
18
|
+
payment_method_data: Stripe.PaymentIntentCreateParams.PaymentMethodData
|
|
19
|
+
payment_method_options?: Stripe.PaymentIntentCreateParams.PaymentMethodOptions
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type StripeMethodBuildResult =
|
|
23
|
+
| { kind: 'card_token' }
|
|
24
|
+
| { kind: 'unsupported' }
|
|
25
|
+
| { kind: 'wired'; wiring: StripeMethodWiring }
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Closed set of `PaymentMethodSpec.kind` values Stripe accepts.
|
|
29
|
+
* `card` is handled separately (caller passes the token id);
|
|
30
|
+
* everything else routes through `payment_method_data.type`.
|
|
31
|
+
*/
|
|
32
|
+
const STRIPE_TYPES: Partial<Record<PaymentMethodSpec['kind'], string>> = {
|
|
33
|
+
promptpay: 'promptpay',
|
|
34
|
+
paynow: 'paynow',
|
|
35
|
+
alipay: 'alipay',
|
|
36
|
+
wechat_pay: 'wechat_pay',
|
|
37
|
+
grabpay: 'grabpay',
|
|
38
|
+
kakaopay: 'kakao_pay',
|
|
39
|
+
konbini: 'konbini',
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function buildStripeMethodWiring(
|
|
43
|
+
spec: PaymentMethodSpec,
|
|
44
|
+
): StripeMethodBuildResult {
|
|
45
|
+
if (spec.kind === 'card') return { kind: 'card_token' }
|
|
46
|
+
const stripeType = STRIPE_TYPES[spec.kind]
|
|
47
|
+
if (!stripeType) return { kind: 'unsupported' }
|
|
48
|
+
|
|
49
|
+
const data = { type: stripeType } as Stripe.PaymentIntentCreateParams.PaymentMethodData
|
|
50
|
+
|
|
51
|
+
const wiring: StripeMethodWiring = { payment_method_data: data }
|
|
52
|
+
|
|
53
|
+
// Per-kind extras. WeChat needs a client hint; Konbini takes
|
|
54
|
+
// a confirmation_number setting via payment_method_options.
|
|
55
|
+
if (spec.kind === 'wechat_pay') {
|
|
56
|
+
wiring.payment_method_options = {
|
|
57
|
+
wechat_pay: { client: 'web' },
|
|
58
|
+
} as Stripe.PaymentIntentCreateParams.PaymentMethodOptions
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { kind: 'wired', wiring }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The `PaymentCapability` flag corresponding to a spec kind. Used
|
|
66
|
+
* by the driver to declare its supported set + by tests.
|
|
67
|
+
*/
|
|
68
|
+
export const STRIPE_SUPPORTED_METHOD_KINDS: ReadonlyArray<PaymentMethodSpec['kind']> = [
|
|
69
|
+
'card',
|
|
70
|
+
'promptpay',
|
|
71
|
+
'paynow',
|
|
72
|
+
'alipay',
|
|
73
|
+
'wechat_pay',
|
|
74
|
+
'grabpay',
|
|
75
|
+
'kakaopay',
|
|
76
|
+
'konbini',
|
|
77
|
+
]
|