@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.
Files changed (94) hide show
  1. package/README.md +55 -0
  2. package/agent-meta/manifest.json +16 -0
  3. package/agent-meta/skills/server-payment/SKILL.md +80 -0
  4. package/build/actions/index.d.ts +3 -0
  5. package/build/actions/index.d.ts.map +1 -0
  6. package/build/actions/index.js +3 -0
  7. package/build/actions/index.js.map +1 -0
  8. package/build/actions/resync.d.ts +2 -0
  9. package/build/actions/resync.d.ts.map +1 -0
  10. package/build/actions/resync.js +11 -0
  11. package/build/actions/resync.js.map +1 -0
  12. package/build/actions/webhook.d.ts +4 -0
  13. package/build/actions/webhook.d.ts.map +1 -0
  14. package/build/actions/webhook.js +11 -0
  15. package/build/actions/webhook.js.map +1 -0
  16. package/build/config.d.ts +8 -0
  17. package/build/config.d.ts.map +1 -0
  18. package/build/config.js +46 -0
  19. package/build/config.js.map +1 -0
  20. package/build/consts.d.ts +26 -0
  21. package/build/consts.d.ts.map +1 -0
  22. package/build/consts.js +33 -0
  23. package/build/consts.js.map +1 -0
  24. package/build/entrypoints.d.ts +5 -0
  25. package/build/entrypoints.d.ts.map +1 -0
  26. package/build/entrypoints.js +10 -0
  27. package/build/entrypoints.js.map +1 -0
  28. package/build/gate.d.ts +10 -0
  29. package/build/gate.d.ts.map +1 -0
  30. package/build/gate.js +37 -0
  31. package/build/gate.js.map +1 -0
  32. package/build/index.d.ts +12 -0
  33. package/build/index.d.ts.map +1 -0
  34. package/build/index.js +11 -0
  35. package/build/index.js.map +1 -0
  36. package/build/model.d.ts +6 -0
  37. package/build/model.d.ts.map +1 -0
  38. package/build/model.js +33 -0
  39. package/build/model.js.map +1 -0
  40. package/build/observer.d.ts +4 -0
  41. package/build/observer.d.ts.map +1 -0
  42. package/build/observer.js +24 -0
  43. package/build/observer.js.map +1 -0
  44. package/build/plugins/events.d.ts +16 -0
  45. package/build/plugins/events.d.ts.map +1 -0
  46. package/build/plugins/events.js +195 -0
  47. package/build/plugins/events.js.map +1 -0
  48. package/build/plugins/stripe.d.ts +14 -0
  49. package/build/plugins/stripe.d.ts.map +1 -0
  50. package/build/plugins/stripe.js +166 -0
  51. package/build/plugins/stripe.js.map +1 -0
  52. package/build/resource.d.ts +6 -0
  53. package/build/resource.d.ts.map +1 -0
  54. package/build/resource.js +32 -0
  55. package/build/resource.js.map +1 -0
  56. package/build/service.d.ts +4 -0
  57. package/build/service.d.ts.map +1 -0
  58. package/build/service.js +35 -0
  59. package/build/service.js.map +1 -0
  60. package/build/sync.d.ts +5 -0
  61. package/build/sync.d.ts.map +1 -0
  62. package/build/sync.js +93 -0
  63. package/build/sync.js.map +1 -0
  64. package/build/types.d.ts +189 -0
  65. package/build/types.d.ts.map +1 -0
  66. package/build/types.js +2 -0
  67. package/build/types.js.map +1 -0
  68. package/build/utils.d.ts +14 -0
  69. package/build/utils.d.ts.map +1 -0
  70. package/build/utils.js +18 -0
  71. package/build/utils.js.map +1 -0
  72. package/package.json +54 -0
  73. package/src/actions/index.ts +2 -0
  74. package/src/actions/resync.ts +13 -0
  75. package/src/actions/webhook.ts +12 -0
  76. package/src/config.ts +53 -0
  77. package/src/consts.ts +47 -0
  78. package/src/entrypoints.ts +10 -0
  79. package/src/gate.ts +51 -0
  80. package/src/index.ts +13 -0
  81. package/src/model.ts +37 -0
  82. package/src/observer.ts +27 -0
  83. package/src/plugins/events.ts +181 -0
  84. package/src/plugins/stripe.ts +183 -0
  85. package/src/resource.ts +51 -0
  86. package/src/service.ts +35 -0
  87. package/src/sync.ts +97 -0
  88. package/src/types.ts +157 -0
  89. package/src/utils.ts +33 -0
  90. package/tests/checkout.spec.ts +135 -0
  91. package/tests/gate.spec.ts +97 -0
  92. package/tests/protocol.spec.ts +20 -0
  93. package/tests/tsconfig.json +5 -0
  94. 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
+ }
@@ -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
+ }