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