@owlmeans/server-payment 0.1.18-rc.0
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/README.md +55 -0
- package/agent-meta/manifest.json +16 -0
- package/agent-meta/skills/server-payment/SKILL.md +80 -0
- package/build/actions/index.d.ts +3 -0
- package/build/actions/index.d.ts.map +1 -0
- package/build/actions/index.js +3 -0
- package/build/actions/index.js.map +1 -0
- package/build/actions/resync.d.ts +2 -0
- package/build/actions/resync.d.ts.map +1 -0
- package/build/actions/resync.js +11 -0
- package/build/actions/resync.js.map +1 -0
- package/build/actions/webhook.d.ts +4 -0
- package/build/actions/webhook.d.ts.map +1 -0
- package/build/actions/webhook.js +11 -0
- package/build/actions/webhook.js.map +1 -0
- package/build/config.d.ts +8 -0
- package/build/config.d.ts.map +1 -0
- package/build/config.js +46 -0
- package/build/config.js.map +1 -0
- package/build/consts.d.ts +26 -0
- package/build/consts.d.ts.map +1 -0
- package/build/consts.js +33 -0
- package/build/consts.js.map +1 -0
- package/build/entrypoints.d.ts +5 -0
- package/build/entrypoints.d.ts.map +1 -0
- package/build/entrypoints.js +10 -0
- package/build/entrypoints.js.map +1 -0
- package/build/gate.d.ts +10 -0
- package/build/gate.d.ts.map +1 -0
- package/build/gate.js +37 -0
- package/build/gate.js.map +1 -0
- package/build/index.d.ts +12 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +11 -0
- package/build/index.js.map +1 -0
- package/build/model.d.ts +6 -0
- package/build/model.d.ts.map +1 -0
- package/build/model.js +33 -0
- package/build/model.js.map +1 -0
- package/build/observer.d.ts +4 -0
- package/build/observer.d.ts.map +1 -0
- package/build/observer.js +24 -0
- package/build/observer.js.map +1 -0
- package/build/plugins/events.d.ts +16 -0
- package/build/plugins/events.d.ts.map +1 -0
- package/build/plugins/events.js +195 -0
- package/build/plugins/events.js.map +1 -0
- package/build/plugins/stripe.d.ts +14 -0
- package/build/plugins/stripe.d.ts.map +1 -0
- package/build/plugins/stripe.js +166 -0
- package/build/plugins/stripe.js.map +1 -0
- package/build/resource.d.ts +6 -0
- package/build/resource.d.ts.map +1 -0
- package/build/resource.js +32 -0
- package/build/resource.js.map +1 -0
- package/build/service.d.ts +4 -0
- package/build/service.d.ts.map +1 -0
- package/build/service.js +35 -0
- package/build/service.js.map +1 -0
- package/build/sync.d.ts +5 -0
- package/build/sync.d.ts.map +1 -0
- package/build/sync.js +93 -0
- package/build/sync.js.map +1 -0
- package/build/types.d.ts +189 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +2 -0
- package/build/types.js.map +1 -0
- package/build/utils.d.ts +14 -0
- package/build/utils.d.ts.map +1 -0
- package/build/utils.js +18 -0
- package/build/utils.js.map +1 -0
- package/package.json +54 -0
- package/src/actions/index.ts +2 -0
- package/src/actions/resync.ts +13 -0
- package/src/actions/webhook.ts +12 -0
- package/src/config.ts +53 -0
- package/src/consts.ts +47 -0
- package/src/entrypoints.ts +10 -0
- package/src/gate.ts +51 -0
- package/src/index.ts +13 -0
- package/src/model.ts +37 -0
- package/src/observer.ts +27 -0
- package/src/plugins/events.ts +181 -0
- package/src/plugins/stripe.ts +183 -0
- package/src/resource.ts +51 -0
- package/src/service.ts +35 -0
- package/src/sync.ts +97 -0
- package/src/types.ts +157 -0
- package/src/utils.ts +33 -0
- package/tests/checkout.spec.ts +135 -0
- package/tests/gate.spec.ts +97 -0
- package/tests/protocol.spec.ts +20 -0
- package/tests/tsconfig.json +5 -0
- package/tsconfig.json +11 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import type Stripe from 'stripe'
|
|
2
|
+
import { Mutex } from 'async-mutex'
|
|
3
|
+
import {
|
|
4
|
+
CheckoutPricingMode, PaygateError, PlanDuration, SubscriptionStatus,
|
|
5
|
+
} from '@owlmeans/payment'
|
|
6
|
+
import type { Context as ApiContext } from '@owlmeans/server-api'
|
|
7
|
+
import { STRIPE_PAYGATE_ALIAS } from '../consts.js'
|
|
8
|
+
import { observer, payment, paygateCustomers, subscriptions } from '../utils.js'
|
|
9
|
+
import type { PaymentPlan, PaymentSubscriptionRecord, TopUpCompletion } from '../types.js'
|
|
10
|
+
|
|
11
|
+
const customerMutex: Record<string, Mutex> = {}
|
|
12
|
+
const lockOn = async (customerId: string) => {
|
|
13
|
+
customerMutex[customerId] = customerMutex[customerId] ?? new Mutex()
|
|
14
|
+
return customerMutex[customerId].acquire()
|
|
15
|
+
}
|
|
16
|
+
const unlockOn = (customerId: string) => {
|
|
17
|
+
const mutex = customerMutex[customerId]
|
|
18
|
+
if (mutex != null) {
|
|
19
|
+
mutex.release()
|
|
20
|
+
if (!mutex.isLocked()) delete customerMutex[customerId]
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const customerIdOf = (value: string | { id?: string } | null): string =>
|
|
24
|
+
typeof value === 'string' ? value : (value?.id ?? '')
|
|
25
|
+
const mapStatus = (status: Stripe.Subscription.Status): SubscriptionStatus => {
|
|
26
|
+
switch (status) {
|
|
27
|
+
case 'active': return SubscriptionStatus.Active
|
|
28
|
+
case 'trialing': return SubscriptionStatus.Trial
|
|
29
|
+
case 'past_due':
|
|
30
|
+
case 'unpaid': return SubscriptionStatus.Expired
|
|
31
|
+
case 'canceled': return SubscriptionStatus.Canceled
|
|
32
|
+
case 'incomplete':
|
|
33
|
+
case 'incomplete_expired':
|
|
34
|
+
case 'paused': return SubscriptionStatus.Suspended
|
|
35
|
+
default: return SubscriptionStatus.Created
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const integerMetadata = (metadata: Stripe.Metadata, key: string): number => {
|
|
39
|
+
const raw = metadata[key]
|
|
40
|
+
const value = raw == null || raw.trim() === '' ? Number.NaN : Number(raw)
|
|
41
|
+
if (!Number.isSafeInteger(value)) throw new PaygateError(`metadata:${key}`)
|
|
42
|
+
return value
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const createEventHandler = (context: ApiContext, stripe: Stripe) => {
|
|
46
|
+
const fulfillPayment = async (session: Stripe.Checkout.Session): Promise<void> => {
|
|
47
|
+
if (session.mode !== 'payment' || session.payment_status !== 'paid') return
|
|
48
|
+
const metadata = session.metadata ?? {}
|
|
49
|
+
if (metadata.entityId == null || metadata.service == null || metadata.productSku == null) {
|
|
50
|
+
throw new PaygateError('metadata:owner')
|
|
51
|
+
}
|
|
52
|
+
const customerId = customerIdOf(session.customer)
|
|
53
|
+
await lockOn(customerId)
|
|
54
|
+
try {
|
|
55
|
+
let stored = await subscriptions(context).byExternalId(session.id, STRIPE_PAYGATE_ALIAS)
|
|
56
|
+
if (stored?.fulfilledAt != null) return
|
|
57
|
+
|
|
58
|
+
const amountMode = metadata.pricingMode === CheckoutPricingMode.Amount
|
|
59
|
+
let completion: TopUpCompletion
|
|
60
|
+
let record: PaymentSubscriptionRecord
|
|
61
|
+
if (amountMode) {
|
|
62
|
+
const amountMinor = integerMetadata(metadata, 'amountMinor')
|
|
63
|
+
const chargedMinor = integerMetadata(metadata, 'chargeAmountMinor')
|
|
64
|
+
const currency = metadata.currency?.toLowerCase()
|
|
65
|
+
if (currency == null || session.currency?.toLowerCase() !== currency
|
|
66
|
+
|| session.amount_subtotal !== chargedMinor || amountMinor <= 0 || chargedMinor < amountMinor) {
|
|
67
|
+
throw new PaygateError('amount-mismatch')
|
|
68
|
+
}
|
|
69
|
+
record = {
|
|
70
|
+
sku: metadata.planSku ?? metadata.productSku, productSku: metadata.productSku,
|
|
71
|
+
entityId: metadata.entityId, service: metadata.service, paygate: STRIPE_PAYGATE_ALIAS,
|
|
72
|
+
externalId: session.id, status: SubscriptionStatus.Consumable, kind: 'consumable',
|
|
73
|
+
pricingMode: CheckoutPricingMode.Amount, amountMinor, chargeAmountMinor: chargedMinor,
|
|
74
|
+
currency, createdAt: new Date(),
|
|
75
|
+
}
|
|
76
|
+
completion = {
|
|
77
|
+
mode: 'amount', entityId: metadata.entityId, amountMinor,
|
|
78
|
+
chargeAmountMinor: chargedMinor, currency, productSku: metadata.productSku,
|
|
79
|
+
planSku: metadata.planSku, service: metadata.service,
|
|
80
|
+
paygate: STRIPE_PAYGATE_ALIAS, externalId: session.id,
|
|
81
|
+
}
|
|
82
|
+
} else {
|
|
83
|
+
if (metadata.currency != null && session.currency?.toLowerCase() !== metadata.currency.toLowerCase()) {
|
|
84
|
+
throw new PaygateError('currency-mismatch')
|
|
85
|
+
}
|
|
86
|
+
const lineItems = await stripe.checkout.sessions.listLineItems(session.id, { limit: 100 })
|
|
87
|
+
const units = lineItems.data.reduce((sum, line) => sum + (line.quantity ?? 0), 0)
|
|
88
|
+
if (units <= 0) throw new PaygateError('units')
|
|
89
|
+
record = {
|
|
90
|
+
sku: metadata.planSku ?? metadata.productSku, productSku: metadata.productSku,
|
|
91
|
+
entityId: metadata.entityId, service: metadata.service, paygate: STRIPE_PAYGATE_ALIAS,
|
|
92
|
+
externalId: session.id, status: SubscriptionStatus.Consumable, kind: 'consumable', units,
|
|
93
|
+
pricingMode: CheckoutPricingMode.Quantity,
|
|
94
|
+
capabilities: [{ scope: PlanDuration.Consumable, permissions: { units } }],
|
|
95
|
+
createdAt: new Date(),
|
|
96
|
+
}
|
|
97
|
+
completion = {
|
|
98
|
+
mode: 'quantity', entityId: metadata.entityId, units, productSku: metadata.productSku,
|
|
99
|
+
planSku: metadata.planSku, service: metadata.service,
|
|
100
|
+
paygate: STRIPE_PAYGATE_ALIAS, externalId: session.id,
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (stored == null) stored = await subscriptions(context).create(record)
|
|
104
|
+
await observer(context).propagateTopUp(completion, context)
|
|
105
|
+
await subscriptions(context).save({ ...stored, fulfilledAt: new Date() })
|
|
106
|
+
} finally {
|
|
107
|
+
unlockOn(customerId)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const handler = {
|
|
112
|
+
process: async (event: Stripe.Event) => {
|
|
113
|
+
if (event.type in handler) await (handler as never as Record<string, (event: Stripe.Event) => Promise<void>>)[event.type](event)
|
|
114
|
+
},
|
|
115
|
+
'customer.created': async (event: Stripe.CustomerCreatedEvent) => handler._upsertCustomer(event.data.object),
|
|
116
|
+
'customer.updated': async (event: Stripe.CustomerUpdatedEvent) => handler._upsertCustomer(event.data.object),
|
|
117
|
+
'checkout.session.completed': async (event: Stripe.CheckoutSessionCompletedEvent) => fulfillPayment(event.data.object),
|
|
118
|
+
'checkout.session.async_payment_succeeded': async (event: Stripe.CheckoutSessionAsyncPaymentSucceededEvent) =>
|
|
119
|
+
fulfillPayment(event.data.object),
|
|
120
|
+
'customer.subscription.created': async (event: Stripe.CustomerSubscriptionCreatedEvent) => handler._syncSubscription(event.data.object),
|
|
121
|
+
'customer.subscription.updated': async (event: Stripe.CustomerSubscriptionUpdatedEvent) => handler._syncSubscription(event.data.object),
|
|
122
|
+
'customer.subscription.deleted': async (event: Stripe.CustomerSubscriptionDeletedEvent) =>
|
|
123
|
+
handler._syncSubscription(event.data.object, SubscriptionStatus.Canceled),
|
|
124
|
+
|
|
125
|
+
_upsertCustomer: async (customer: Stripe.Customer) => {
|
|
126
|
+
const resource = paygateCustomers(context)
|
|
127
|
+
await lockOn(customer.id)
|
|
128
|
+
try {
|
|
129
|
+
const existing = await resource.loadByPgId(customer.id, STRIPE_PAYGATE_ALIAS)
|
|
130
|
+
const data = {
|
|
131
|
+
email: customer.email ?? undefined, name: customer.name ?? undefined,
|
|
132
|
+
taxId: customer.tax_ids?.data?.[0]?.value ?? undefined,
|
|
133
|
+
entityId: customer.metadata?.entityId ?? existing?.entityId,
|
|
134
|
+
profileId: customer.metadata?.profileId ?? existing?.profileId,
|
|
135
|
+
}
|
|
136
|
+
if (existing == null) await resource.create({ paygate: STRIPE_PAYGATE_ALIAS, externalId: customer.id, ...data })
|
|
137
|
+
else { Object.assign(existing, data); await resource.save(existing) }
|
|
138
|
+
} finally { unlockOn(customer.id) }
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
_syncSubscription: async (pgSubscription: Stripe.Subscription, forced?: SubscriptionStatus) => {
|
|
142
|
+
const metadata = pgSubscription.metadata ?? {}
|
|
143
|
+
const customerId = customerIdOf(pgSubscription.customer)
|
|
144
|
+
await lockOn(customerId)
|
|
145
|
+
try {
|
|
146
|
+
const status = forced ?? mapStatus(pgSubscription.status)
|
|
147
|
+
for (const item of pgSubscription.items.data) {
|
|
148
|
+
const planSku = item.price.lookup_key ?? metadata.planSku
|
|
149
|
+
const entityId = metadata.entityId
|
|
150
|
+
if (planSku == null || entityId == null) continue
|
|
151
|
+
let plan: PaymentPlan | null = null
|
|
152
|
+
try { plan = await payment(context).plan(planSku) as PaymentPlan } catch { /* archived plan */ }
|
|
153
|
+
const productSku = plan?.productSku ?? metadata.productSku ?? planSku
|
|
154
|
+
const service = metadata.service ?? productSku
|
|
155
|
+
let record = await subscriptions(context).byExternalId(item.id, STRIPE_PAYGATE_ALIAS)
|
|
156
|
+
const isNew = record?.initialPropagatedAt == null
|
|
157
|
+
const values: Partial<PaymentSubscriptionRecord> = {
|
|
158
|
+
sku: planSku, productSku, entityId, service, paygate: STRIPE_PAYGATE_ALIAS,
|
|
159
|
+
externalId: item.id, status, kind: 'subscription',
|
|
160
|
+
...(plan?.capabilities != null && { capabilities: plan.capabilities }),
|
|
161
|
+
...(plan?.limits != null && { limits: plan.limits }),
|
|
162
|
+
endsAt: (pgSubscription as never as { current_period_end?: number }).current_period_end != null
|
|
163
|
+
? new Date((pgSubscription as never as { current_period_end: number }).current_period_end * 1000) : undefined,
|
|
164
|
+
canceledAt: status === SubscriptionStatus.Canceled ? new Date() : undefined,
|
|
165
|
+
}
|
|
166
|
+
if (record == null) record = await subscriptions(context).create({ createdAt: new Date(), ...values } as PaymentSubscriptionRecord)
|
|
167
|
+
else { Object.assign(record, values); await subscriptions(context).save(record) }
|
|
168
|
+
await observer(context).propagateSubscription({
|
|
169
|
+
entityId, status, productSku, planSku, service, paygate: STRIPE_PAYGATE_ALIAS,
|
|
170
|
+
externalId: item.id, isNew, capabilities: plan?.capabilities, limits: plan?.limits,
|
|
171
|
+
}, context)
|
|
172
|
+
if (isNew) {
|
|
173
|
+
record.initialPropagatedAt = new Date()
|
|
174
|
+
await subscriptions(context).save(record)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
} finally { unlockOn(customerId) }
|
|
178
|
+
},
|
|
179
|
+
}
|
|
180
|
+
return handler
|
|
181
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import Stripe from 'stripe'
|
|
2
|
+
import {
|
|
3
|
+
assertCheckoutAmount, chargeAmountMinor, CheckoutPricingMode, PaygateError, ProductError, ProductType,
|
|
4
|
+
} from '@owlmeans/payment'
|
|
5
|
+
import type { Context as ApiContext } from '@owlmeans/server-api'
|
|
6
|
+
import { STRIPE_PAYGATE_ALIAS, STRIPE_SIGNATURE } from '../consts.js'
|
|
7
|
+
import { paygateCustomers, payment, stripeClient, stripeConfig } from '../utils.js'
|
|
8
|
+
import { planLookupKey } from '../sync.js'
|
|
9
|
+
import { createEventHandler } from './events.js'
|
|
10
|
+
import type { CreateLinkParams, PaymentPlan, PaymentPlugin, PaymentProduct } from '../types.js'
|
|
11
|
+
|
|
12
|
+
const taxOptions = (promotions: boolean): Partial<Stripe.Checkout.SessionCreateParams> => ({
|
|
13
|
+
automatic_tax: { enabled: true },
|
|
14
|
+
billing_address_collection: 'required',
|
|
15
|
+
tax_id_collection: { enabled: true },
|
|
16
|
+
customer_update: { address: 'auto', name: 'auto' },
|
|
17
|
+
allow_promotion_codes: promotions,
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
const ensureStripeCustomer = async (
|
|
21
|
+
ctx: ApiContext, stripe: Stripe, params: CreateLinkParams,
|
|
22
|
+
): Promise<Stripe.Customer> => {
|
|
23
|
+
const resource = paygateCustomers(ctx)
|
|
24
|
+
const existing = await resource.byEntity(params.entityId, STRIPE_PAYGATE_ALIAS)
|
|
25
|
+
if (existing != null) {
|
|
26
|
+
const retrieved = await stripe.customers.retrieve(existing.externalId)
|
|
27
|
+
if (!(retrieved as Stripe.DeletedCustomer).deleted) return retrieved as Stripe.Customer
|
|
28
|
+
}
|
|
29
|
+
const created = await stripe.customers.create({
|
|
30
|
+
metadata: {
|
|
31
|
+
entityId: params.entityId, ...(params.profileId && { profileId: params.profileId }),
|
|
32
|
+
service: params.service,
|
|
33
|
+
},
|
|
34
|
+
})
|
|
35
|
+
if (existing != null) {
|
|
36
|
+
Object.assign(existing, { externalId: created.id })
|
|
37
|
+
await resource.save(existing)
|
|
38
|
+
} else {
|
|
39
|
+
await resource.create({
|
|
40
|
+
paygate: STRIPE_PAYGATE_ALIAS, externalId: created.id, entityId: params.entityId,
|
|
41
|
+
profileId: params.profileId,
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
return created
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const findPrice = async (stripe: Stripe, productSku: string, lookupKey: string): Promise<Stripe.Price> => {
|
|
48
|
+
const prices = await stripe.prices.list({ product: productSku, active: true, lookup_keys: [lookupKey] })
|
|
49
|
+
if (prices.data.length === 0) throw new ProductError(`price:${lookupKey}`)
|
|
50
|
+
return prices.data[0]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const sharedSession = (
|
|
54
|
+
customer: Stripe.Customer, params: CreateLinkParams, product: PaymentProduct,
|
|
55
|
+
plan: PaymentPlan, metadata: Record<string, string>,
|
|
56
|
+
): Pick<Stripe.Checkout.SessionCreateParams, 'customer' | 'success_url' | 'cancel_url' | 'metadata'> => ({
|
|
57
|
+
customer: customer.id,
|
|
58
|
+
success_url: params.successUrl,
|
|
59
|
+
cancel_url: params.cancelUrl ?? params.successUrl,
|
|
60
|
+
metadata: {
|
|
61
|
+
pricingMode: plan.pricingMode ?? CheckoutPricingMode.Quantity,
|
|
62
|
+
currency: (plan.currency ?? 'usd').toLowerCase(),
|
|
63
|
+
entityId: params.entityId,
|
|
64
|
+
...(params.profileId && { profileId: params.profileId }),
|
|
65
|
+
service: params.service,
|
|
66
|
+
productSku: product.sku,
|
|
67
|
+
planSku: plan.sku,
|
|
68
|
+
...metadata,
|
|
69
|
+
},
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
export const amountCheckoutLineItem = (
|
|
73
|
+
product: PaymentProduct, plan: PaymentPlan, amountMinor: number,
|
|
74
|
+
): { lineItem: Stripe.Checkout.SessionCreateParams.LineItem; chargeMinor: number; currency: string } => {
|
|
75
|
+
if (plan.amountPolicy == null) throw new ProductError(`amount-policy:${plan.sku}`)
|
|
76
|
+
assertCheckoutAmount(plan.amountPolicy, amountMinor)
|
|
77
|
+
const chargeMinor = chargeAmountMinor(amountMinor, plan.amountPolicy)
|
|
78
|
+
const currency = plan.amountPolicy.currency.toLowerCase()
|
|
79
|
+
return {
|
|
80
|
+
lineItem: {
|
|
81
|
+
price_data: {
|
|
82
|
+
product: product.sku, currency, unit_amount: chargeMinor, tax_behavior: 'exclusive',
|
|
83
|
+
},
|
|
84
|
+
quantity: 1,
|
|
85
|
+
},
|
|
86
|
+
chargeMinor,
|
|
87
|
+
currency,
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const quantityCheckoutLineItem = (
|
|
92
|
+
price: Stripe.Price, policy: { minimum: number; maximum: number; default: number },
|
|
93
|
+
): Stripe.Checkout.SessionCreateParams.LineItem => ({
|
|
94
|
+
price: price.id,
|
|
95
|
+
adjustable_quantity: { enabled: true, minimum: policy.minimum, maximum: policy.maximum },
|
|
96
|
+
quantity: policy.default,
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
export const stripePlugin: PaymentPlugin = {
|
|
100
|
+
createLink: async (ctx, params) => {
|
|
101
|
+
const stripe = await stripeClient(ctx)
|
|
102
|
+
const product = await payment(ctx).product(params.productSku) as PaymentProduct
|
|
103
|
+
const plans = await payment(ctx).allPlans(product.sku) as PaymentPlan[]
|
|
104
|
+
const customer = await ensureStripeCustomer(ctx, stripe, params)
|
|
105
|
+
|
|
106
|
+
if (product.type === ProductType.Consumable) {
|
|
107
|
+
const plan = plans.find(item => item.sku === params.planSku) ?? plans[0]
|
|
108
|
+
if (plan == null) throw new ProductError('plan')
|
|
109
|
+
|
|
110
|
+
if (plan.pricingMode === CheckoutPricingMode.Amount) {
|
|
111
|
+
if (params.amountMinor == null) throw new ProductError('amount')
|
|
112
|
+
const { lineItem, chargeMinor, currency } = amountCheckoutLineItem(product, plan, params.amountMinor)
|
|
113
|
+
const session = await stripe.checkout.sessions.create({
|
|
114
|
+
mode: 'payment', line_items: [lineItem], invoice_creation: { enabled: true },
|
|
115
|
+
...taxOptions(false),
|
|
116
|
+
...sharedSession(customer, params, product, plan, {
|
|
117
|
+
pricingMode: CheckoutPricingMode.Amount,
|
|
118
|
+
currency,
|
|
119
|
+
amountMinor: String(params.amountMinor),
|
|
120
|
+
chargeAmountMinor: String(chargeMinor),
|
|
121
|
+
}),
|
|
122
|
+
})
|
|
123
|
+
if (session.url == null) throw new PaygateError('session')
|
|
124
|
+
return session.url
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const price = await findPrice(stripe, product.sku, planLookupKey(product, plan))
|
|
128
|
+
const policy = plan.quantityPolicy ?? {
|
|
129
|
+
minimum: plan.minQuantity ?? plan.limits?.['consumable:units']?.limit ?? 1,
|
|
130
|
+
maximum: plan.maxQuantity ?? Math.max(100_000, plan.minQuantity ?? 1),
|
|
131
|
+
default: plan.defaultQuantity ?? plan.minQuantity ?? 1,
|
|
132
|
+
}
|
|
133
|
+
const session = await stripe.checkout.sessions.create({
|
|
134
|
+
mode: 'payment',
|
|
135
|
+
line_items: [quantityCheckoutLineItem(price, policy)],
|
|
136
|
+
invoice_creation: { enabled: true },
|
|
137
|
+
...taxOptions(true),
|
|
138
|
+
...sharedSession(customer, params, product, plan, { pricingMode: CheckoutPricingMode.Quantity }),
|
|
139
|
+
})
|
|
140
|
+
if (session.url == null) throw new PaygateError('session')
|
|
141
|
+
return session.url
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const plan = plans.find(item => item.sku === params.planSku)
|
|
145
|
+
?? plans.find(item => item.recurring != null) ?? plans[0]
|
|
146
|
+
if (plan == null) throw new ProductError('plan')
|
|
147
|
+
const price = await findPrice(stripe, product.sku, planLookupKey(product, plan))
|
|
148
|
+
const session = await stripe.checkout.sessions.create({
|
|
149
|
+
mode: 'subscription', line_items: [{ price: price.id, quantity: 1 }],
|
|
150
|
+
subscription_data: {
|
|
151
|
+
metadata: {
|
|
152
|
+
pricingMode: CheckoutPricingMode.Quantity, entityId: params.entityId,
|
|
153
|
+
service: params.service, productSku: product.sku, planSku: plan.sku,
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
...taxOptions(true),
|
|
157
|
+
...sharedSession(customer, params, product, plan, {}),
|
|
158
|
+
})
|
|
159
|
+
if (session.url == null) throw new PaygateError('session')
|
|
160
|
+
return session.url
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
handleWebhook: async (request, ctx) => {
|
|
164
|
+
const config = await stripeConfig(ctx)
|
|
165
|
+
const stripe = await stripeClient(ctx)
|
|
166
|
+
const typed = request as {
|
|
167
|
+
original?: { rawBody?: string | Buffer }; rawBody?: string | Buffer
|
|
168
|
+
headers: Record<string, string | string[] | undefined>
|
|
169
|
+
}
|
|
170
|
+
const rawBody = typed.original?.rawBody ?? typed.rawBody
|
|
171
|
+
const signature = typed.headers[STRIPE_SIGNATURE.toLowerCase()]
|
|
172
|
+
if (rawBody == null || typeof signature !== 'string') throw new PaygateError('signature')
|
|
173
|
+
const event = await stripe.webhooks.constructEventAsync(rawBody, signature, config.webhook)
|
|
174
|
+
await createEventHandler(ctx, stripe).process(event)
|
|
175
|
+
},
|
|
176
|
+
|
|
177
|
+
manageSubscription: async (ctx, entityId, returnUrl) => {
|
|
178
|
+
const stripe = await stripeClient(ctx)
|
|
179
|
+
const customer = await paygateCustomers(ctx).byEntity(entityId, STRIPE_PAYGATE_ALIAS)
|
|
180
|
+
if (customer == null) throw new PaygateError('customer')
|
|
181
|
+
return (await stripe.billingPortal.sessions.create({ customer: customer.externalId, return_url: returnUrl })).url
|
|
182
|
+
},
|
|
183
|
+
}
|
package/src/resource.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { ResourceMaker } from '@owlmeans/resource'
|
|
2
|
+
import { makeMongoResource } from '@owlmeans/mongo-resource'
|
|
3
|
+
import { RES_PAYGATE_CUSTOMER, RES_PAYMENT_FINGERPRINT, RES_PAYMENT_SUBSCRIPTION } from './consts.js'
|
|
4
|
+
import { FingerprintSchema, PaygateCustomerSchema, PaymentSubscriptionSchema } from './model.js'
|
|
5
|
+
import type {
|
|
6
|
+
FingerprintRecord, FingerprintResource, PaygateCustomerRecord, PaygateCustomerResource,
|
|
7
|
+
PaymentSubscriptionRecord, PaymentSubscriptionResource,
|
|
8
|
+
} from './types.js'
|
|
9
|
+
|
|
10
|
+
export const makePaygateCustomerResource: ResourceMaker<PaygateCustomerRecord, PaygateCustomerResource> = (
|
|
11
|
+
dbAlias, serviceAlias,
|
|
12
|
+
) => {
|
|
13
|
+
const resource = makeMongoResource<PaygateCustomerRecord, PaygateCustomerResource>(
|
|
14
|
+
RES_PAYGATE_CUSTOMER, dbAlias, serviceAlias,
|
|
15
|
+
)
|
|
16
|
+
resource.loadByPgId = async (externalId, paygate) => await resource.load({ externalId, paygate })
|
|
17
|
+
resource.byEntity = async (entityId, paygate) => await resource.load({ entityId, paygate })
|
|
18
|
+
resource.schema = PaygateCustomerSchema
|
|
19
|
+
resource.index('entity', { paygate: 1, entityId: 1 })
|
|
20
|
+
resource.index('profile', { paygate: 1, profileId: 1 })
|
|
21
|
+
resource.index('external', { paygate: 1, externalId: 1 }, { unique: true })
|
|
22
|
+
return resource
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const makeSubscriptionResource: ResourceMaker<PaymentSubscriptionRecord, PaymentSubscriptionResource> = (
|
|
26
|
+
dbAlias, serviceAlias,
|
|
27
|
+
) => {
|
|
28
|
+
const resource = makeMongoResource<PaymentSubscriptionRecord, PaymentSubscriptionResource>(
|
|
29
|
+
RES_PAYMENT_SUBSCRIPTION, dbAlias, serviceAlias,
|
|
30
|
+
)
|
|
31
|
+
resource.byExternalId = async (externalId, paygate) => await resource.load({ externalId, paygate })
|
|
32
|
+
resource.schema = PaymentSubscriptionSchema
|
|
33
|
+
resource.index('entity', { entityId: 1 })
|
|
34
|
+
resource.index('entitySku', { entityId: 1, sku: 1 })
|
|
35
|
+
resource.index('external', { externalId: 1, paygate: 1 }, { unique: true })
|
|
36
|
+
resource.index('status', { status: 1 })
|
|
37
|
+
return resource
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const makeFingerprintResource: ResourceMaker<FingerprintRecord, FingerprintResource> = (
|
|
41
|
+
dbAlias, serviceAlias,
|
|
42
|
+
) => {
|
|
43
|
+
const resource = makeMongoResource<FingerprintRecord, FingerprintResource>(
|
|
44
|
+
RES_PAYMENT_FINGERPRINT, dbAlias, serviceAlias,
|
|
45
|
+
)
|
|
46
|
+
resource.bySku = async sku => await resource.load({ sku })
|
|
47
|
+
resource.clear = async () => { await resource.purge({ sku: { $exists: true } }) }
|
|
48
|
+
resource.schema = FingerprintSchema
|
|
49
|
+
resource.index('sku', { sku: 1 }, { unique: true })
|
|
50
|
+
return resource
|
|
51
|
+
}
|
package/src/service.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { createService } from '@owlmeans/context'
|
|
2
|
+
import { DEFAULT_ALIAS as PAYMENT_SERVICE, ENTITLEMENT_GATE, appendPaymentService } from '@owlmeans/payment'
|
|
3
|
+
import type { Context as ApiContext } from '@owlmeans/server-api'
|
|
4
|
+
import { GATEWAY_SERVICE, RES_PAYGATE_CUSTOMER, RES_PAYMENT_FINGERPRINT, RES_PAYMENT_SUBSCRIPTION } from './consts.js'
|
|
5
|
+
import { makeEntitlementGate } from './gate.js'
|
|
6
|
+
import { appendCompletionObserver } from './observer.js'
|
|
7
|
+
import { stripePlugin } from './plugins/stripe.js'
|
|
8
|
+
import { makeFingerprintResource, makePaygateCustomerResource, makeSubscriptionResource } from './resource.js'
|
|
9
|
+
import { initialize } from './sync.js'
|
|
10
|
+
import type { Config, Context, GatewayService, PaymentResourceOptions } from './types.js'
|
|
11
|
+
|
|
12
|
+
export const makeGatewayService = (alias: string = GATEWAY_SERVICE): GatewayService => {
|
|
13
|
+
const service = createService<GatewayService>(alias, {
|
|
14
|
+
createLink: async (ctx, params) => stripePlugin.createLink(ctx, params),
|
|
15
|
+
manageSubscription: async (ctx, entityId, returnUrl) => stripePlugin.manageSubscription(ctx, entityId, returnUrl),
|
|
16
|
+
}, service => async () => {
|
|
17
|
+
service.initialized = true
|
|
18
|
+
const ctx = service.assertCtx() as ApiContext
|
|
19
|
+
ctx.waitForInitialized().then(() => initialize(ctx).catch(console.error))
|
|
20
|
+
})
|
|
21
|
+
return service
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const appendPaymentGatewayService = <C extends Config, T extends Context<C>>(
|
|
25
|
+
ctx: T, opts?: PaymentResourceOptions,
|
|
26
|
+
): T => {
|
|
27
|
+
if (!ctx.hasResource(RES_PAYGATE_CUSTOMER)) ctx.registerResource(makePaygateCustomerResource(opts?.dbAlias, opts?.serviceAlias))
|
|
28
|
+
if (!ctx.hasResource(RES_PAYMENT_SUBSCRIPTION)) ctx.registerResource(makeSubscriptionResource(opts?.dbAlias, opts?.serviceAlias))
|
|
29
|
+
if (!ctx.hasResource(RES_PAYMENT_FINGERPRINT)) ctx.registerResource(makeFingerprintResource(opts?.dbAlias, opts?.serviceAlias))
|
|
30
|
+
if (!ctx.hasService(PAYMENT_SERVICE)) appendPaymentService(ctx as never)
|
|
31
|
+
appendCompletionObserver(ctx)
|
|
32
|
+
ctx.registerService(makeGatewayService())
|
|
33
|
+
if (!ctx.hasService(ENTITLEMENT_GATE)) ctx.registerService(makeEntitlementGate())
|
|
34
|
+
return ctx
|
|
35
|
+
}
|
package/src/sync.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import type Stripe from 'stripe'
|
|
3
|
+
import { CheckoutPricingMode, ProductType } from '@owlmeans/payment'
|
|
4
|
+
import type { Context as ApiContext } from '@owlmeans/server-api'
|
|
5
|
+
import { STRIPE_PAYGATE_ALIAS } from './consts.js'
|
|
6
|
+
import { fingerprints, payment, stripeClient } from './utils.js'
|
|
7
|
+
import type { PaymentPlan, PaymentProduct } from './types.js'
|
|
8
|
+
|
|
9
|
+
export const planLookupKey = (product: PaymentProduct, plan: PaymentPlan): string =>
|
|
10
|
+
product.type === ProductType.Consumable ? `${product.sku}-consumable` : plan.sku
|
|
11
|
+
|
|
12
|
+
const fingerprintOf = (product: PaymentProduct, plans: PaymentPlan[]): string => createHash('sha256')
|
|
13
|
+
.update(JSON.stringify({
|
|
14
|
+
sku: product.sku, type: product.type, name: product.title,
|
|
15
|
+
description: product.description ?? null, taxCode: product.taxCode ?? null,
|
|
16
|
+
unitLabel: product.unitLabel ?? null, services: [...(product.services ?? [])].sort(),
|
|
17
|
+
plans: plans.map(plan => ({
|
|
18
|
+
sku: plan.sku, price: plan.price, currency: plan.currency ?? 'usd', duration: plan.duration,
|
|
19
|
+
recurring: plan.recurring ?? null, pricingMode: plan.pricingMode ?? null,
|
|
20
|
+
amountPolicy: plan.amountPolicy ?? null, quantityPolicy: plan.quantityPolicy ?? null,
|
|
21
|
+
lookup: planLookupKey(product, plan),
|
|
22
|
+
})).sort((a, b) => a.sku.localeCompare(b.sku)),
|
|
23
|
+
})).digest('hex')
|
|
24
|
+
|
|
25
|
+
const ensureStripeProduct = async (stripe: Stripe, product: PaymentProduct): Promise<Stripe.Product> => {
|
|
26
|
+
const params: Stripe.ProductCreateParams = {
|
|
27
|
+
name: product.title, description: product.description || undefined, tax_code: product.taxCode,
|
|
28
|
+
active: true,
|
|
29
|
+
metadata: { sku: product.sku, ...(product.services && { services: product.services.join(',') }) },
|
|
30
|
+
}
|
|
31
|
+
if (product.type === ProductType.Consumable && product.unitLabel != null) params.unit_label = product.unitLabel
|
|
32
|
+
try {
|
|
33
|
+
await stripe.products.retrieve(product.sku)
|
|
34
|
+
return await stripe.products.update(product.sku, params)
|
|
35
|
+
} catch {
|
|
36
|
+
return await stripe.products.create({ id: product.sku, ...params })
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const activePrices = async (stripe: Stripe, product: PaymentProduct): Promise<Stripe.Price[]> =>
|
|
41
|
+
(await stripe.prices.list({ product: product.sku, active: true, limit: 100 })).data
|
|
42
|
+
|
|
43
|
+
const deactivateAmountPrice = async (stripe: Stripe, product: PaymentProduct, plan: PaymentPlan): Promise<void> => {
|
|
44
|
+
const lookup = planLookupKey(product, plan)
|
|
45
|
+
for (const price of (await activePrices(stripe, product)).filter(item => item.lookup_key === lookup)) {
|
|
46
|
+
await stripe.prices.update(price.id, { active: false })
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const ensureStripePrice = async (stripe: Stripe, product: PaymentProduct, plan: PaymentPlan): Promise<void> => {
|
|
51
|
+
if (plan.pricingMode === CheckoutPricingMode.Amount) {
|
|
52
|
+
await deactivateAmountPrice(stripe, product, plan)
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
const lookupKey = planLookupKey(product, plan)
|
|
56
|
+
const unitAmount = Math.round(plan.price * 100)
|
|
57
|
+
const currency = plan.currency ?? 'usd'
|
|
58
|
+
const recurring = plan.recurring != null
|
|
59
|
+
? { interval: plan.recurring.interval } as Stripe.PriceCreateParams.Recurring : undefined
|
|
60
|
+
const existing = await activePrices(stripe, product)
|
|
61
|
+
const match = existing.find(price => price.lookup_key === lookupKey && price.unit_amount === unitAmount
|
|
62
|
+
&& price.currency === currency
|
|
63
|
+
&& ((price.recurring?.interval ?? null) === (recurring?.interval ?? null)))
|
|
64
|
+
if (match != null) return
|
|
65
|
+
for (const price of existing.filter(item => item.lookup_key === lookupKey)) {
|
|
66
|
+
await stripe.prices.update(price.id, { active: false })
|
|
67
|
+
}
|
|
68
|
+
await stripe.prices.create({
|
|
69
|
+
product: product.sku, currency, unit_amount: unitAmount, lookup_key: lookupKey,
|
|
70
|
+
transfer_lookup_key: true, nickname: plan.sku,
|
|
71
|
+
...(recurring != null ? { recurring } : { billing_scheme: 'per_unit' }),
|
|
72
|
+
metadata: { sku: plan.sku, ...(product.services && { services: product.services.join(',') }) },
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const initialize = async (ctx: ApiContext): Promise<void> => {
|
|
77
|
+
const all = await payment(ctx).products() as PaymentProduct[]
|
|
78
|
+
const products = all.filter(product => (product.gateways ?? []).includes(STRIPE_PAYGATE_ALIAS))
|
|
79
|
+
if (products.length === 0) return
|
|
80
|
+
const stripe = await stripeClient(ctx)
|
|
81
|
+
const fpRes = fingerprints(ctx)
|
|
82
|
+
for (const product of products) {
|
|
83
|
+
const plans = await payment(ctx).allPlans(product.sku) as PaymentPlan[]
|
|
84
|
+
const hash = fingerprintOf(product, plans)
|
|
85
|
+
const stored = await fpRes.bySku(product.sku)
|
|
86
|
+
if (stored != null && stored.hash === hash) continue
|
|
87
|
+
const stripeProduct = await ensureStripeProduct(stripe, product)
|
|
88
|
+
for (const plan of plans) await ensureStripePrice(stripe, product, plan)
|
|
89
|
+
if (stored != null) {
|
|
90
|
+
Object.assign(stored, { hash, productId: stripeProduct.id, updatedAt: new Date() })
|
|
91
|
+
await fpRes.save(stored)
|
|
92
|
+
} else {
|
|
93
|
+
await fpRes.create({ sku: product.sku, hash, productId: stripeProduct.id, updatedAt: new Date() })
|
|
94
|
+
}
|
|
95
|
+
console.info(`[payment] synced product '${product.sku}' to Stripe (${plans.length} plan(s))`)
|
|
96
|
+
}
|
|
97
|
+
}
|