@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,180 @@
1
+ /**
2
+ * Omise ↔ normalized-DTO mappers. The Omise type surface lives in
3
+ * the SDK namespace; we use structural shapes here to keep the
4
+ * mappers tolerant of SDK version drift.
5
+ */
6
+
7
+ import type {
8
+ ChargeStatus,
9
+ PaymentCharge,
10
+ PaymentCustomer,
11
+ PaymentMethod,
12
+ } from '../../dto/index.ts'
13
+ import { omiseNextAction } from './omise_next_action_mapper.ts'
14
+
15
+ const PROVIDER = 'omise'
16
+
17
+ interface OmiseTimestamps {
18
+ created_at?: string
19
+ created?: string
20
+ }
21
+
22
+ interface OmiseCustomer extends OmiseTimestamps {
23
+ id: string
24
+ email?: string
25
+ description?: string
26
+ metadata?: Record<string, unknown>
27
+ }
28
+
29
+ interface OmiseCharge extends OmiseTimestamps {
30
+ id: string
31
+ amount: number
32
+ currency: string
33
+ status: 'failed' | 'reversed' | 'expired' | 'pending' | 'successful'
34
+ paid?: boolean
35
+ capture?: boolean
36
+ refunded?: number
37
+ refunded_amount?: number
38
+ customer?: string | OmiseCustomer | null
39
+ card?: OmiseCard | null
40
+ source?: OmiseSource | null
41
+ authorize_uri?: string | null
42
+ return_uri?: string | null
43
+ expires_at?: string
44
+ failure_code?: string | null
45
+ failure_message?: string | null
46
+ metadata?: Record<string, unknown>
47
+ }
48
+
49
+ interface OmiseSource {
50
+ id: string
51
+ type?: string
52
+ flow?: string
53
+ amount?: number
54
+ currency?: string
55
+ scannable_code?: {
56
+ type?: string
57
+ image?: { download_uri?: string }
58
+ }
59
+ references?: { expires_at?: string }
60
+ }
61
+
62
+ interface OmiseCard extends OmiseTimestamps {
63
+ id: string
64
+ brand?: string
65
+ last_digits?: string
66
+ expiration_month?: number
67
+ expiration_year?: number
68
+ customer?: string | null
69
+ }
70
+
71
+ function metadata(m: Record<string, unknown> | undefined): Record<string, string> {
72
+ if (!m) return {}
73
+ const out: Record<string, string> = {}
74
+ for (const [k, v] of Object.entries(m)) {
75
+ if (v === null || v === undefined) continue
76
+ out[k] = String(v)
77
+ }
78
+ return out
79
+ }
80
+
81
+ function createdAt(r: OmiseTimestamps): Date {
82
+ const ts = r.created_at ?? r.created
83
+ return ts ? new Date(ts) : new Date()
84
+ }
85
+
86
+ export function toPaymentCustomer(c: OmiseCustomer): PaymentCustomer {
87
+ return {
88
+ id: c.id,
89
+ provider: PROVIDER,
90
+ email: c.email ?? '',
91
+ metadata: metadata(c.metadata),
92
+ createdAt: createdAt(c),
93
+ raw: c,
94
+ }
95
+ }
96
+
97
+ const CHARGE_STATUS_MAP: Record<OmiseCharge['status'], ChargeStatus> = {
98
+ successful: 'succeeded',
99
+ pending: 'pending',
100
+ failed: 'failed',
101
+ reversed: 'refunded',
102
+ expired: 'failed',
103
+ }
104
+
105
+ export function toPaymentCharge(c: OmiseCharge): PaymentCharge {
106
+ let status: ChargeStatus = CHARGE_STATUS_MAP[c.status] ?? 'pending'
107
+ if ((c.refunded ?? 0) > 0) {
108
+ status = (c.refunded ?? 0) >= c.amount ? 'refunded' : 'partial_refunded'
109
+ }
110
+ return {
111
+ id: c.id,
112
+ provider: PROVIDER,
113
+ customerId: typeof c.customer === 'string' ? c.customer : c.customer?.id ?? null,
114
+ amount: c.amount,
115
+ currency: c.currency.toLowerCase(),
116
+ status,
117
+ paymentMethodId: c.card?.id ?? null,
118
+ failureCode: c.failure_code ?? null,
119
+ failureMessage: c.failure_message ?? null,
120
+ // Source-backed charges (PromptPay / TrueMoney / Alipay /
121
+ // GrabPay / etc.) carry the next-action shape on the
122
+ // attached source (QR image URL) or the charge itself
123
+ // (`authorize_uri`). Card-only / settled charges produce
124
+ // `null`.
125
+ nextAction: omiseNextAction(c),
126
+ metadata: metadata(c.metadata),
127
+ createdAt: createdAt(c),
128
+ raw: c,
129
+ }
130
+ }
131
+
132
+ export function toPaymentMethod(card: OmiseCard): PaymentMethod {
133
+ return {
134
+ id: card.id,
135
+ provider: PROVIDER,
136
+ customerId: card.customer ?? null,
137
+ kind: 'card',
138
+ ...(card.brand ? { brand: card.brand } : {}),
139
+ ...(card.last_digits ? { last4: card.last_digits } : {}),
140
+ ...(card.expiration_month ? { expMonth: card.expiration_month } : {}),
141
+ ...(card.expiration_year ? { expYear: card.expiration_year } : {}),
142
+ metadata: {},
143
+ createdAt: createdAt(card),
144
+ raw: card,
145
+ }
146
+ }
147
+
148
+ interface OmiseLink extends OmiseTimestamps {
149
+ id: string
150
+ amount: number
151
+ currency: string
152
+ title?: string
153
+ description?: string
154
+ used?: boolean
155
+ multiple?: boolean
156
+ payment_uri: string
157
+ metadata?: Record<string, unknown>
158
+ }
159
+
160
+ export function toPaymentLink(l: OmiseLink): import('../../dto/index.ts').PaymentLink {
161
+ return {
162
+ id: l.id,
163
+ provider: PROVIDER,
164
+ url: l.payment_uri,
165
+ amount: l.amount,
166
+ currency: l.currency.toLowerCase(),
167
+ // Omise marks a link as `used` after the first payment when
168
+ // `multiple: false`. We treat `used && !multiple` as inactive
169
+ // since the link can't take more payments.
170
+ active: !(l.used === true && l.multiple !== true),
171
+ reusable: l.multiple === true,
172
+ ...(l.title ? { title: l.title } : {}),
173
+ ...(l.description ? { description: l.description } : {}),
174
+ metadata: metadata(l.metadata),
175
+ createdAt: createdAt(l),
176
+ raw: l,
177
+ }
178
+ }
179
+
180
+ export type { OmiseCard, OmiseCharge, OmiseCustomer, OmiseLink, OmiseSource }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Build an Omise source-creation request from a `PaymentMethodSpec`.
3
+ *
4
+ * Omise's async flow is two-step: create a `source` (type +
5
+ * amount + currency + per-kind extras), then create a `charge`
6
+ * that references the source. This module owns the mapping from
7
+ * framework spec kind → Omise source `type` string + the extras
8
+ * each kind needs (e.g. `phone_number` for TrueMoney).
9
+ *
10
+ * `card` short-circuits to the existing single-step flow.
11
+ * Unsupported kinds return `'unsupported'` and the driver throws
12
+ * `ProviderUnsupportedError`.
13
+ */
14
+
15
+ import type { PaymentMethodSpec } from '../../dto/index.ts'
16
+
17
+ export interface OmiseSourceRequest {
18
+ type: string
19
+ amount: number
20
+ currency: string
21
+ /** TrueMoney: customer mobile in international format. */
22
+ phone_number?: string
23
+ /** Optional billing-display name. */
24
+ name?: string
25
+ /** Optional email for sources that ask for it. */
26
+ email?: string
27
+ }
28
+
29
+ export type OmiseMethodBuildResult =
30
+ | { kind: 'card_token' }
31
+ | { kind: 'unsupported' }
32
+ | { kind: 'source'; request: Omit<OmiseSourceRequest, 'amount' | 'currency'> }
33
+
34
+ /**
35
+ * Closed map of supported `PaymentMethodSpec.kind` values to the
36
+ * Omise source `type` strings. Driver capabilities + the create
37
+ * path both derive from this table so they stay in sync.
38
+ */
39
+ const OMISE_TYPES: Partial<Record<PaymentMethodSpec['kind'], string>> = {
40
+ promptpay: 'promptpay',
41
+ truemoney: 'truemoney',
42
+ alipay: 'alipay',
43
+ wechat_pay: 'wechat_pay',
44
+ grabpay: 'grabpay',
45
+ rabbit_linepay: 'rabbit_linepay',
46
+ }
47
+
48
+ export function buildOmiseMethodSpec(
49
+ spec: PaymentMethodSpec,
50
+ amount: number,
51
+ currency: string,
52
+ ): OmiseMethodBuildResult {
53
+ if (spec.kind === 'card') return { kind: 'card_token' }
54
+ const omiseType = OMISE_TYPES[spec.kind]
55
+ if (!omiseType) return { kind: 'unsupported' }
56
+
57
+ const request: Omit<OmiseSourceRequest, 'amount' | 'currency'> = { type: omiseType }
58
+ if (spec.kind === 'truemoney') {
59
+ request.phone_number = spec.phoneNumber
60
+ }
61
+ return { kind: 'source', request }
62
+ }
63
+
64
+ /** Flag the source's `flow` — used by the next-action mapper. */
65
+ export function omiseSourceFlowFor(kind: PaymentMethodSpec['kind']): 'offline' | 'redirect' | 'unknown' {
66
+ switch (kind) {
67
+ case 'promptpay':
68
+ return 'offline'
69
+ case 'truemoney':
70
+ case 'alipay':
71
+ case 'wechat_pay':
72
+ case 'grabpay':
73
+ case 'rabbit_linepay':
74
+ return 'redirect'
75
+ default:
76
+ return 'unknown'
77
+ }
78
+ }
79
+
80
+ export const OMISE_SUPPORTED_METHOD_KINDS: ReadonlyArray<PaymentMethodSpec['kind']> = [
81
+ 'card',
82
+ 'promptpay',
83
+ 'truemoney',
84
+ 'alipay',
85
+ 'wechat_pay',
86
+ 'grabpay',
87
+ 'rabbit_linepay',
88
+ ]
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Map an Omise source + charge pair onto `PaymentNextAction`.
3
+ *
4
+ * Omise splits the async-payment surface across two objects:
5
+ *
6
+ * - **QR-based** (PromptPay, FPS, DuitNow QR, …):
7
+ * `source.scannable_code.image.download_uri` is the PNG.
8
+ * The charge has no `authorize_uri`. Apps display the
9
+ * image; the customer pays via banking app; the webhook
10
+ * fires when settlement lands.
11
+ *
12
+ * - **Redirect-based** (TrueMoney, Alipay, GrabPay, Rabbit
13
+ * LINE Pay, WeChat Pay): `charge.authorize_uri` is the
14
+ * URL the app sends the customer to. Omise routes back to
15
+ * `return_uri` (passed when creating the charge).
16
+ *
17
+ * Omise doesn't expose the raw EMV / SGQR string the way Stripe
18
+ * does — only the rendered PNG. We mirror it into both
19
+ * `qrData` and `qrImageUrl` so apps using either field work; the
20
+ * raw image url is what they actually display.
21
+ */
22
+
23
+ import type { PaymentNextAction } from '../../dto/index.ts'
24
+
25
+ interface OmiseSourceLike {
26
+ flow?: string
27
+ scannable_code?: {
28
+ image?: { download_uri?: string }
29
+ }
30
+ references?: { expires_at?: string }
31
+ }
32
+
33
+ interface OmiseChargeLike {
34
+ authorize_uri?: string | null
35
+ expires_at?: string
36
+ source?: OmiseSourceLike | null
37
+ }
38
+
39
+ function parseDate(v: string | undefined): Date | undefined {
40
+ if (!v) return undefined
41
+ const d = new Date(v)
42
+ return Number.isNaN(d.getTime()) ? undefined : d
43
+ }
44
+
45
+ export function omiseNextAction(
46
+ charge: OmiseChargeLike,
47
+ source?: OmiseSourceLike,
48
+ ): PaymentNextAction | null {
49
+ const src = source ?? charge.source ?? undefined
50
+ const flow = src?.flow
51
+
52
+ // Redirect-based — charge.authorize_uri is the URL to send the
53
+ // customer to. Some flows (wechat_pay) carry both a QR and a
54
+ // redirect; if both are present we prefer the QR (universal for
55
+ // desktop checkout).
56
+ const qrImage = src?.scannable_code?.image?.download_uri
57
+ if (qrImage) {
58
+ const action: PaymentNextAction = {
59
+ kind: 'display_qr',
60
+ // Omise gives the rendered PNG URL, not the raw EMV string.
61
+ // Mirroring it into both slots lets either app handler work.
62
+ qrData: qrImage,
63
+ qrImageUrl: qrImage,
64
+ }
65
+ const expires = parseDate(src?.references?.expires_at ?? charge.expires_at)
66
+ if (expires) action.expiresAt = expires
67
+ return action
68
+ }
69
+
70
+ if (charge.authorize_uri) {
71
+ const action: PaymentNextAction = {
72
+ kind: 'redirect',
73
+ url: charge.authorize_uri,
74
+ }
75
+ const expires = parseDate(charge.expires_at)
76
+ if (expires) action.expiresAt = expires
77
+ return action
78
+ }
79
+
80
+ // Source flow declared but neither field surfaced — Omise hasn't
81
+ // populated the charge yet; the app waits for the webhook.
82
+ if (flow === 'offline' || flow === 'redirect') {
83
+ return { kind: 'wait' }
84
+ }
85
+
86
+ return null
87
+ }
88
+
89
+ export type { OmiseChargeLike, OmiseSourceLike }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Omise has no `prices` catalog — but the framework's
3
+ * `SubscriptionOps.create` takes a `price: string` (Stripe's model).
4
+ *
5
+ * We bridge that gap with an in-band encoded "price spec" that
6
+ * carries everything the Omise schedules API needs: amount,
7
+ * currency, recurrence period, count per period, optional
8
+ * description, optional default card.
9
+ *
10
+ * Apps build the spec with `omisePriceSpec({...})` and pass the
11
+ * result as `subscriptions.create({ price: spec, ... })`. The
12
+ * driver parses on the way in and rebuilds on the way out (so
13
+ * `PaymentSubscription.priceId` round-trips cleanly).
14
+ *
15
+ * Wire format: `omise_spec:<base64-url JSON>`. The prefix lets
16
+ * the driver detect the format and reject opaque ids that look
17
+ * like Stripe `price_…` early with a clear error.
18
+ *
19
+ * Period values match Omise's API: `'day' | 'week' | 'month'`.
20
+ */
21
+
22
+ export type OmisePeriod = 'day' | 'week' | 'month'
23
+
24
+ export interface OmisePriceSpec {
25
+ amount: number
26
+ currency: string
27
+ period: OmisePeriod
28
+ /** Every N periods between charges. Default 1. */
29
+ every?: number
30
+ description?: string
31
+ /** Default card on the customer. Apps that want to charge a specific token id pass it here. */
32
+ card?: string
33
+ }
34
+
35
+ const PREFIX = 'omise_spec:'
36
+
37
+ export function omisePriceSpec(spec: OmisePriceSpec): string {
38
+ if (!Number.isFinite(spec.amount) || spec.amount <= 0) {
39
+ throw new TypeError('omisePriceSpec: `amount` must be a positive number (in minor units).')
40
+ }
41
+ if (!spec.currency) {
42
+ throw new TypeError('omisePriceSpec: `currency` is required.')
43
+ }
44
+ if (!spec.period) {
45
+ throw new TypeError('omisePriceSpec: `period` is required (day | week | month).')
46
+ }
47
+ const payload = {
48
+ a: spec.amount,
49
+ c: spec.currency.toLowerCase(),
50
+ p: spec.period,
51
+ ...(spec.every !== undefined ? { e: spec.every } : {}),
52
+ ...(spec.description ? { d: spec.description } : {}),
53
+ ...(spec.card ? { card: spec.card } : {}),
54
+ }
55
+ return PREFIX + Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url')
56
+ }
57
+
58
+ export function parseOmisePriceSpec(value: string): OmisePriceSpec | null {
59
+ if (!value.startsWith(PREFIX)) return null
60
+ const rest = value.slice(PREFIX.length)
61
+ let parsed: { a: number; c: string; p: OmisePeriod; e?: number; d?: string; card?: string }
62
+ try {
63
+ const json = Buffer.from(rest, 'base64url').toString('utf8')
64
+ parsed = JSON.parse(json)
65
+ } catch {
66
+ return null
67
+ }
68
+ if (
69
+ typeof parsed.a !== 'number' ||
70
+ typeof parsed.c !== 'string' ||
71
+ typeof parsed.p !== 'string'
72
+ ) {
73
+ return null
74
+ }
75
+ return {
76
+ amount: parsed.a,
77
+ currency: parsed.c,
78
+ period: parsed.p,
79
+ ...(parsed.e !== undefined ? { every: parsed.e } : {}),
80
+ ...(parsed.d ? { description: parsed.d } : {}),
81
+ ...(parsed.card ? { card: parsed.card } : {}),
82
+ }
83
+ }
84
+
85
+ export const OMISE_PRICE_SPEC_PREFIX = PREFIX
@@ -0,0 +1,33 @@
1
+ /**
2
+ * `OmisePaymentProvider` — `ServiceProvider` that registers the
3
+ * Omise driver factory on the `PaymentManager`.
4
+ *
5
+ * Apps list this AFTER `PaymentProvider` in
6
+ * `bootstrap/providers.ts`. Driver instances construct lazily on
7
+ * first `payment.use(name)` call.
8
+ */
9
+
10
+ import { type Application, ServiceProvider } from '@strav/kernel'
11
+ import { PaymentManager } from '../../payment_manager.ts'
12
+ import { PaymentConfigError } from '../../payment_error.ts'
13
+ import type { OmiseProviderConfig } from './omise_config.ts'
14
+ import { OmisePaymentDriver } from './omise_driver.ts'
15
+
16
+ export class OmisePaymentProvider extends ServiceProvider {
17
+ override readonly name = 'payment-omise'
18
+ override readonly dependencies = ['payment']
19
+
20
+ override register(app: Application): void {
21
+ const manager = app.resolve(PaymentManager)
22
+ manager.extend('omise', ({ instanceName, config }) => {
23
+ const cfg = config as OmiseProviderConfig
24
+ if (!cfg.publicKey || !cfg.secretKey) {
25
+ throw new PaymentConfigError(
26
+ `OmisePaymentProvider: \`publicKey\` and \`secretKey\` are required for provider "${instanceName}".`,
27
+ { context: { instanceName } },
28
+ )
29
+ }
30
+ return new OmisePaymentDriver({ instanceName, config: cfg })
31
+ })
32
+ }
33
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Map an Omise `schedule` onto a `PaymentSubscription`.
3
+ *
4
+ * Omise + framework data models differ; we make the following
5
+ * choices:
6
+ *
7
+ * - **`status`** — Omise statuses: `running`, `active`,
8
+ * `expiring`, `expired`, `deleted`, `suspended`. We collapse
9
+ * active recurrences to `active`, expired / deleted to
10
+ * `canceled`, suspended to `paused`.
11
+ *
12
+ * - **`currentPeriodStart` / `currentPeriodEnd`** — Omise gives
13
+ * `start_date` + `end_date` + an array `next_occurrence_dates`.
14
+ * We treat the most recently passed occurrence as period
15
+ * start, and the next upcoming occurrence as period end.
16
+ *
17
+ * - **`priceId`** — synthesized via `omisePriceSpec` so the
18
+ * round-trip stays portable. Apps reading the DTO see the
19
+ * same spec they sent on `create`.
20
+ *
21
+ * - **`trialStart` / `trialEnd`** — always null. Omise schedules
22
+ * don't have a trial concept.
23
+ *
24
+ * - **`cancelAt` / `canceledAt`** — `end_date` becomes
25
+ * `cancelAt`; `ended_at` becomes `canceledAt` once the
26
+ * schedule has actually stopped.
27
+ */
28
+
29
+ import type { PaymentSubscription, SubscriptionStatus } from '../../dto/index.ts'
30
+ import { omisePriceSpec, type OmisePeriod } from './omise_price_spec.ts'
31
+
32
+ export interface OmiseScheduleCharge {
33
+ amount: number
34
+ currency: string
35
+ customer: string | { id: string }
36
+ card?: string | { id: string } | null
37
+ description?: string
38
+ metadata?: Record<string, unknown>
39
+ }
40
+
41
+ export interface OmiseSchedule {
42
+ id: string
43
+ status?: string
44
+ active?: boolean
45
+ every: number
46
+ period: string
47
+ start_date?: string
48
+ end_date?: string
49
+ start_on?: string
50
+ end_on?: string
51
+ ended_at?: string
52
+ created?: string
53
+ created_at?: string
54
+ next_occurrence_dates?: string[]
55
+ charge?: OmiseScheduleCharge
56
+ metadata?: Record<string, unknown>
57
+ }
58
+
59
+ function statusFor(s: OmiseSchedule): SubscriptionStatus {
60
+ const raw = s.status?.toLowerCase()
61
+ if (raw === 'suspended') return 'paused'
62
+ if (raw === 'expired' || raw === 'deleted') return 'canceled'
63
+ if (s.active === false) return 'canceled'
64
+ return 'active'
65
+ }
66
+
67
+ function parseDate(v: string | undefined): Date | null {
68
+ if (!v) return null
69
+ const d = new Date(v)
70
+ return Number.isNaN(d.getTime()) ? null : d
71
+ }
72
+
73
+ function metadata(m: Record<string, unknown> | undefined): Record<string, string> {
74
+ if (!m) return {}
75
+ const out: Record<string, string> = {}
76
+ for (const [k, v] of Object.entries(m)) {
77
+ if (v === null || v === undefined) continue
78
+ out[k] = String(v)
79
+ }
80
+ return out
81
+ }
82
+
83
+ function periodBoundary(s: OmiseSchedule, now = new Date()): { start: Date; end: Date } {
84
+ const nowMs = now.getTime()
85
+ const upcoming = (s.next_occurrence_dates ?? [])
86
+ .map(parseDate)
87
+ .filter((d): d is Date => d !== null)
88
+ .sort((a, b) => a.getTime() - b.getTime())
89
+ const next = upcoming.find((d) => d.getTime() >= nowMs)
90
+ const start =
91
+ upcoming.filter((d) => d.getTime() < nowMs).pop() ??
92
+ parseDate(s.start_date) ??
93
+ parseDate(s.created_at) ??
94
+ now
95
+ const end = next ?? parseDate(s.end_date) ?? start
96
+ return { start, end }
97
+ }
98
+
99
+ export function toPaymentSubscription(s: OmiseSchedule): PaymentSubscription {
100
+ const charge = s.charge
101
+ if (!charge) {
102
+ // Transfer-only schedules don't map onto subscriptions. Surface
103
+ // a meaningful row so apps can still see them, but the price spec
104
+ // can't be reconstructed.
105
+ const { start, end } = periodBoundary(s)
106
+ return {
107
+ id: s.id,
108
+ provider: 'omise',
109
+ customerId: '',
110
+ priceId: '',
111
+ status: statusFor(s),
112
+ currentPeriodStart: start,
113
+ currentPeriodEnd: end,
114
+ cancelAt: parseDate(s.end_date),
115
+ canceledAt: parseDate(s.ended_at),
116
+ trialStart: null,
117
+ trialEnd: null,
118
+ metadata: metadata(s.metadata),
119
+ createdAt: parseDate(s.created_at) ?? parseDate(s.created) ?? new Date(),
120
+ raw: s,
121
+ }
122
+ }
123
+ const customerId =
124
+ typeof charge.customer === 'string' ? charge.customer : charge.customer.id
125
+ const cardId =
126
+ typeof charge.card === 'string'
127
+ ? charge.card
128
+ : charge.card
129
+ ? charge.card.id
130
+ : undefined
131
+ const priceId = omisePriceSpec({
132
+ amount: charge.amount,
133
+ currency: charge.currency,
134
+ period: s.period as OmisePeriod,
135
+ every: s.every,
136
+ ...(charge.description ? { description: charge.description } : {}),
137
+ ...(cardId ? { card: cardId } : {}),
138
+ })
139
+ const { start, end } = periodBoundary(s)
140
+ return {
141
+ id: s.id,
142
+ provider: 'omise',
143
+ customerId,
144
+ priceId,
145
+ status: statusFor(s),
146
+ currentPeriodStart: start,
147
+ currentPeriodEnd: end,
148
+ cancelAt: parseDate(s.end_date),
149
+ canceledAt: parseDate(s.ended_at),
150
+ trialStart: null,
151
+ trialEnd: null,
152
+ metadata: metadata(s.metadata),
153
+ createdAt: parseDate(s.created_at) ?? parseDate(s.created) ?? new Date(),
154
+ raw: s,
155
+ }
156
+ }