@owlmeans/server-payment 0.1.18-rc.2 → 0.1.18-rc.21

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 (222) hide show
  1. package/README.md +109 -25
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/server-payment/SKILL.md +475 -52
  4. package/build/actions/index.d.ts +1 -0
  5. package/build/actions/index.d.ts.map +1 -1
  6. package/build/actions/index.js +1 -0
  7. package/build/actions/index.js.map +1 -1
  8. package/build/actions/resync-subscriptions.d.ts +3 -0
  9. package/build/actions/resync-subscriptions.d.ts.map +1 -0
  10. package/build/actions/resync-subscriptions.js +7 -0
  11. package/build/actions/resync-subscriptions.js.map +1 -0
  12. package/build/actions/resync.d.ts +1 -0
  13. package/build/actions/resync.d.ts.map +1 -1
  14. package/build/actions/resync.js +7 -3
  15. package/build/actions/resync.js.map +1 -1
  16. package/build/actions/webhook.d.ts.map +1 -1
  17. package/build/actions/webhook.js +6 -3
  18. package/build/actions/webhook.js.map +1 -1
  19. package/build/config.d.ts +60 -5
  20. package/build/config.d.ts.map +1 -1
  21. package/build/config.js +237 -5
  22. package/build/config.js.map +1 -1
  23. package/build/consts.d.ts +75 -3
  24. package/build/consts.d.ts.map +1 -1
  25. package/build/consts.js +94 -5
  26. package/build/consts.js.map +1 -1
  27. package/build/consumer/capture.d.ts +74 -0
  28. package/build/consumer/capture.d.ts.map +1 -0
  29. package/build/consumer/capture.js +291 -0
  30. package/build/consumer/capture.js.map +1 -0
  31. package/build/consumer/format.d.ts +27 -0
  32. package/build/consumer/format.d.ts.map +1 -0
  33. package/build/consumer/format.js +81 -0
  34. package/build/consumer/format.js.map +1 -0
  35. package/build/consumer/handlers.d.ts +28 -0
  36. package/build/consumer/handlers.d.ts.map +1 -0
  37. package/build/consumer/handlers.js +173 -0
  38. package/build/consumer/handlers.js.map +1 -0
  39. package/build/consumer/index.d.ts +7 -0
  40. package/build/consumer/index.d.ts.map +1 -0
  41. package/build/consumer/index.js +6 -0
  42. package/build/consumer/index.js.map +1 -0
  43. package/build/consumer/mail.d.ts +27 -0
  44. package/build/consumer/mail.d.ts.map +1 -0
  45. package/build/consumer/mail.js +314 -0
  46. package/build/consumer/mail.js.map +1 -0
  47. package/build/consumer/origin.d.ts +14 -0
  48. package/build/consumer/origin.d.ts.map +1 -0
  49. package/build/consumer/origin.js +47 -0
  50. package/build/consumer/origin.js.map +1 -0
  51. package/build/consumer/reconcile.d.ts +12 -0
  52. package/build/consumer/reconcile.d.ts.map +1 -0
  53. package/build/consumer/reconcile.js +317 -0
  54. package/build/consumer/reconcile.js.map +1 -0
  55. package/build/consumer/records.d.ts +78 -0
  56. package/build/consumer/records.d.ts.map +1 -0
  57. package/build/consumer/records.js +296 -0
  58. package/build/consumer/records.js.map +1 -0
  59. package/build/consumer/service.d.ts +51 -0
  60. package/build/consumer/service.d.ts.map +1 -0
  61. package/build/consumer/service.js +760 -0
  62. package/build/consumer/service.js.map +1 -0
  63. package/build/consumer/withdrawal.d.ts +57 -0
  64. package/build/consumer/withdrawal.d.ts.map +1 -0
  65. package/build/consumer/withdrawal.js +247 -0
  66. package/build/consumer/withdrawal.js.map +1 -0
  67. package/build/entitlement.d.ts +10 -0
  68. package/build/entitlement.d.ts.map +1 -0
  69. package/build/entitlement.js +69 -0
  70. package/build/entitlement.js.map +1 -0
  71. package/build/entrypoints.d.ts +1 -1
  72. package/build/entrypoints.d.ts.map +1 -1
  73. package/build/entrypoints.js +2 -1
  74. package/build/entrypoints.js.map +1 -1
  75. package/build/gate.d.ts +32 -4
  76. package/build/gate.d.ts.map +1 -1
  77. package/build/gate.js +59 -19
  78. package/build/gate.js.map +1 -1
  79. package/build/index.d.ts +18 -3
  80. package/build/index.d.ts.map +1 -1
  81. package/build/index.js +15 -3
  82. package/build/index.js.map +1 -1
  83. package/build/limit.d.ts +13 -0
  84. package/build/limit.d.ts.map +1 -0
  85. package/build/limit.js +47 -0
  86. package/build/limit.js.map +1 -0
  87. package/build/model.d.ts +10 -1
  88. package/build/model.d.ts.map +1 -1
  89. package/build/model.js +215 -17
  90. package/build/model.js.map +1 -1
  91. package/build/observer.d.ts +9 -0
  92. package/build/observer.d.ts.map +1 -1
  93. package/build/observer.js +38 -12
  94. package/build/observer.js.map +1 -1
  95. package/build/plan.d.ts +22 -0
  96. package/build/plan.d.ts.map +1 -0
  97. package/build/plan.js +72 -0
  98. package/build/plan.js.map +1 -0
  99. package/build/plugins/checkout-plugins.d.ts +49 -0
  100. package/build/plugins/checkout-plugins.d.ts.map +1 -0
  101. package/build/plugins/checkout-plugins.js +124 -0
  102. package/build/plugins/checkout-plugins.js.map +1 -0
  103. package/build/plugins/estimate.d.ts +43 -0
  104. package/build/plugins/estimate.d.ts.map +1 -0
  105. package/build/plugins/estimate.js +268 -0
  106. package/build/plugins/estimate.js.map +1 -0
  107. package/build/plugins/events.d.ts +45 -10
  108. package/build/plugins/events.d.ts.map +1 -1
  109. package/build/plugins/events.js +605 -136
  110. package/build/plugins/events.js.map +1 -1
  111. package/build/plugins/fx.d.ts +31 -0
  112. package/build/plugins/fx.d.ts.map +1 -0
  113. package/build/plugins/fx.js +81 -0
  114. package/build/plugins/fx.js.map +1 -0
  115. package/build/plugins/portal.d.ts +37 -0
  116. package/build/plugins/portal.d.ts.map +1 -0
  117. package/build/plugins/portal.js +265 -0
  118. package/build/plugins/portal.js.map +1 -0
  119. package/build/plugins/refunds.d.ts +33 -0
  120. package/build/plugins/refunds.d.ts.map +1 -0
  121. package/build/plugins/refunds.js +80 -0
  122. package/build/plugins/refunds.js.map +1 -0
  123. package/build/plugins/stripe.d.ts +36 -4
  124. package/build/plugins/stripe.d.ts.map +1 -1
  125. package/build/plugins/stripe.js +492 -97
  126. package/build/plugins/stripe.js.map +1 -1
  127. package/build/plugins/webhook-manager.d.ts +46 -0
  128. package/build/plugins/webhook-manager.d.ts.map +1 -0
  129. package/build/plugins/webhook-manager.js +231 -0
  130. package/build/plugins/webhook-manager.js.map +1 -0
  131. package/build/reconcile.d.ts +15 -0
  132. package/build/reconcile.d.ts.map +1 -0
  133. package/build/reconcile.js +88 -0
  134. package/build/reconcile.js.map +1 -0
  135. package/build/resource.d.ts +12 -1
  136. package/build/resource.d.ts.map +1 -1
  137. package/build/resource.js +97 -6
  138. package/build/resource.js.map +1 -1
  139. package/build/service.d.ts +28 -3
  140. package/build/service.d.ts.map +1 -1
  141. package/build/service.js +146 -19
  142. package/build/service.js.map +1 -1
  143. package/build/subscription.d.ts +48 -0
  144. package/build/subscription.d.ts.map +1 -0
  145. package/build/subscription.js +176 -0
  146. package/build/subscription.js.map +1 -0
  147. package/build/sync.d.ts +28 -1
  148. package/build/sync.d.ts.map +1 -1
  149. package/build/sync.js +208 -31
  150. package/build/sync.js.map +1 -1
  151. package/build/types.d.ts +1115 -36
  152. package/build/types.d.ts.map +1 -1
  153. package/build/usage.d.ts +63 -0
  154. package/build/usage.d.ts.map +1 -0
  155. package/build/usage.js +363 -0
  156. package/build/usage.js.map +1 -0
  157. package/build/utils.d.ts +52 -1
  158. package/build/utils.d.ts.map +1 -1
  159. package/build/utils.js +69 -7
  160. package/build/utils.js.map +1 -1
  161. package/package.json +17 -13
  162. package/src/actions/index.ts +1 -0
  163. package/src/actions/resync-subscriptions.ts +10 -0
  164. package/src/actions/resync.ts +6 -3
  165. package/src/actions/webhook.ts +5 -3
  166. package/src/config.ts +264 -8
  167. package/src/consts.ts +114 -6
  168. package/src/consumer/capture.ts +362 -0
  169. package/src/consumer/format.ts +90 -0
  170. package/src/consumer/handlers.ts +211 -0
  171. package/src/consumer/index.ts +6 -0
  172. package/src/consumer/mail.ts +368 -0
  173. package/src/consumer/origin.ts +63 -0
  174. package/src/consumer/reconcile.ts +329 -0
  175. package/src/consumer/records.ts +374 -0
  176. package/src/consumer/service.ts +868 -0
  177. package/src/consumer/withdrawal.ts +302 -0
  178. package/src/entitlement.ts +84 -0
  179. package/src/entrypoints.ts +2 -1
  180. package/src/gate.ts +88 -21
  181. package/src/index.ts +24 -3
  182. package/src/limit.ts +57 -0
  183. package/src/model.ts +237 -18
  184. package/src/observer.ts +44 -11
  185. package/src/plan.ts +89 -0
  186. package/src/plugins/checkout-plugins.ts +155 -0
  187. package/src/plugins/estimate.ts +339 -0
  188. package/src/plugins/events.ts +677 -121
  189. package/src/plugins/fx.ts +122 -0
  190. package/src/plugins/portal.ts +306 -0
  191. package/src/plugins/refunds.ts +108 -0
  192. package/src/plugins/stripe.ts +581 -96
  193. package/src/plugins/webhook-manager.ts +270 -0
  194. package/src/reconcile.ts +103 -0
  195. package/src/resource.ts +152 -7
  196. package/src/service.ts +174 -18
  197. package/src/subscription.ts +231 -0
  198. package/src/sync.ts +249 -29
  199. package/src/types.ts +1227 -32
  200. package/src/usage.ts +453 -0
  201. package/src/utils.ts +127 -10
  202. package/tests/checkout-consumer.spec.ts +348 -0
  203. package/tests/checkout-plugins.spec.ts +164 -0
  204. package/tests/checkout.spec.ts +184 -83
  205. package/tests/consumer-events.spec.ts +218 -0
  206. package/tests/consumer-fixtures.ts +132 -0
  207. package/tests/consumer-ops.spec.ts +351 -0
  208. package/tests/consumer-rights.integration.spec.ts +150 -0
  209. package/tests/consumer-rights.spec.ts +501 -0
  210. package/tests/context.ts +109 -0
  211. package/tests/entitlement.spec.ts +103 -0
  212. package/tests/estimate.spec.ts +240 -0
  213. package/tests/events.spec.ts +356 -0
  214. package/tests/fake-stripe.ts +972 -0
  215. package/tests/gate.spec.ts +62 -72
  216. package/tests/limit-gate.spec.ts +68 -0
  217. package/tests/portal.spec.ts +200 -0
  218. package/tests/protocol.spec.ts +23 -6
  219. package/tests/sync.spec.ts +114 -0
  220. package/tests/usage.integration.spec.ts +101 -0
  221. package/tests/usage.spec.ts +171 -0
  222. package/tests/webhook-manager.spec.ts +152 -0
@@ -1,44 +1,97 @@
1
- import Stripe from 'stripe'
1
+ import type Stripe from 'stripe'
2
2
  import {
3
- assertCheckoutAmount, chargeAmountMinor, CheckoutPricingMode, PaygateError, ProductError, ProductType,
3
+ assertCheckoutAmount, billingLanguageOf, BillingCountryLocked, chargeAmountMinor, chargeCurrencyOf,
4
+ CheckoutPricingMode, CONSUMER_RIGHTS_COPY_VERSION, ConsumerRightsError, consumerText, inScope, linksOf,
5
+ PaygateError, ProductError, ProductType, regionOf, TaxBehavior, WebhookSetupError,
4
6
  } from '@owlmeans/payment'
7
+ import type { BillingProfileView, ConsumerRegion, ConsumerRightsPolicy, PricingPolicy } from '@owlmeans/payment'
5
8
  import type { Context as ApiContext } from '@owlmeans/server-api'
6
9
  import { STRIPE_PAYGATE_ALIAS, STRIPE_SIGNATURE } from '../consts.js'
7
- import { paygateCustomers, payment, stripeClient, stripeConfig } from '../utils.js'
8
- import { planLookupKey } from '../sync.js'
10
+ import {
11
+ compact, consumerRightsOf, errorText, fingerprints, paygateCustomers, payment, stripePricingConfig,
12
+ } from '../utils.js'
13
+ import { isSoldThrough, planLookupKey } from '../sync.js'
14
+ import { countryName, formatMoney } from '../consumer/format.js'
15
+ import { hasPaid, recordEvent, wasUnlocked } from '../consumer/records.js'
16
+ import {
17
+ admitCheckout, assertAmountAllowed, narrowAmountFor, releaseAdmitted, sessionTtlOf,
18
+ } from './checkout-plugins.js'
19
+ import type { Admitted } from './checkout-plugins.js'
9
20
  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
- })
21
+ import { chargeAmount, settlementAmount } from './fx.js'
22
+ import { stripeWebhookSecrets } from './webhook-manager.js'
23
+ import type {
24
+ CheckoutAttempt, CheckoutPlugin, CheckoutTextContext, CreateLinkParams, PaymentPlan, PaymentProduct,
25
+ } from '../types.js'
26
+
27
+ /** Stripe's limit on every `custom_text` message. */
28
+ const CUSTOM_TEXT_MAX = 1200
29
+
30
+ interface CheckoutOptionsFlags {
31
+ /** The billing country is locked and the saved customer address carries it: never overwrite it. */
32
+ locked?: boolean
33
+ /** Adaptive Pricing is allowed on this session (the charge currency is the settlement currency). */
34
+ adaptive?: boolean
35
+ }
36
+
37
+ /**
38
+ * A Checkout Session's tax and currency options, entirely driven by the declared `PricingPolicy` —
39
+ * an undeclared one (`DEFAULT_PRICING_POLICY`) reproduces exactly what every session hard-coded
40
+ * before this policy existed: automatic tax and tax-id collection on, no Adaptive Pricing.
41
+ *
42
+ * `customer_update.address` lets automatic tax use the billing address Checkout just collected
43
+ * rather than only a previously saved one; `customer_update.name` lets tax-id collection save the
44
+ * business name it collects. Each is included only for the concern that needs it. Under a country
45
+ * lock the saved address is kept (`address: 'never'`, collection `'auto'`), so tax follows the
46
+ * locked country and Checkout cannot move it.
47
+ */
48
+ const checkoutOptions = (
49
+ policy: PricingPolicy, promotions: boolean, flags: CheckoutOptionsFlags = {},
50
+ ): Partial<Stripe.Checkout.SessionCreateParams> => {
51
+ const customerUpdate: Stripe.Checkout.SessionCreateParams.CustomerUpdate = {}
52
+ if (policy.tax.automatic) customerUpdate.address = flags.locked === true ? 'never' : 'auto'
53
+ if (policy.tax.collectTaxId) customerUpdate.name = 'auto'
54
+
55
+ return {
56
+ ...(policy.tax.automatic
57
+ ? { automatic_tax: { enabled: true }, billing_address_collection: flags.locked === true ? 'auto' as const : 'required' as const }
58
+ : {}),
59
+ ...(policy.tax.collectTaxId ? { tax_id_collection: { enabled: true } } : {}),
60
+ ...(Object.keys(customerUpdate).length > 0 ? { customer_update: customerUpdate } : {}),
61
+ ...(policy.currency.adaptive === true && flags.adaptive !== false ? { adaptive_pricing: { enabled: true } } : {}),
62
+ allow_promotion_codes: promotions,
63
+ }
64
+ }
19
65
 
20
66
  const ensureStripeCustomer = async (
21
67
  ctx: ApiContext, stripe: Stripe, params: CreateLinkParams,
22
68
  ): Promise<Stripe.Customer> => {
23
69
  const resource = paygateCustomers(ctx)
24
70
  const existing = await resource.byEntity(params.entityId, STRIPE_PAYGATE_ALIAS)
25
- if (existing != null) {
71
+ if (existing != null && existing.deletedAt == null) {
26
72
  const retrieved = await stripe.customers.retrieve(existing.externalId)
27
- if (!(retrieved as Stripe.DeletedCustomer).deleted) return retrieved as Stripe.Customer
73
+ if (!(retrieved as Stripe.DeletedCustomer).deleted) {
74
+ const customer = retrieved as Stripe.Customer
75
+ if (params.locale != null && customer.preferred_locales?.[0] !== params.locale) {
76
+ return await stripe.customers.update(customer.id, { preferred_locales: [params.locale] })
77
+ }
78
+ return customer
79
+ }
28
80
  }
29
81
  const created = await stripe.customers.create({
30
82
  metadata: {
31
83
  entityId: params.entityId, ...(params.profileId && { profileId: params.profileId }),
32
84
  service: params.service,
33
85
  },
86
+ ...(params.locale != null ? { preferred_locales: [params.locale] } : {}),
34
87
  })
35
88
  if (existing != null) {
36
- Object.assign(existing, { externalId: created.id })
37
- await resource.save(existing)
89
+ const { deletedAt: _deleted, ...kept } = existing
90
+ await resource.update({ ...kept, externalId: created.id })
38
91
  } else {
39
92
  await resource.create({
40
93
  paygate: STRIPE_PAYGATE_ALIAS, externalId: created.id, entityId: params.entityId,
41
- profileId: params.profileId,
94
+ ...(params.profileId != null ? { profileId: params.profileId } : {}),
42
95
  })
43
96
  }
44
97
  return created
@@ -53,10 +106,11 @@ const findPrice = async (stripe: Stripe, productSku: string, lookupKey: string):
53
106
  const sharedSession = (
54
107
  customer: Stripe.Customer, params: CreateLinkParams, product: PaymentProduct,
55
108
  plan: PaymentPlan, metadata: Record<string, string>,
56
- ): Pick<Stripe.Checkout.SessionCreateParams, 'customer' | 'success_url' | 'cancel_url' | 'metadata'> => ({
109
+ ): Pick<Stripe.Checkout.SessionCreateParams, 'customer' | 'success_url' | 'cancel_url' | 'metadata' | 'locale'> => ({
57
110
  customer: customer.id,
58
111
  success_url: params.successUrl,
59
112
  cancel_url: params.cancelUrl ?? params.successUrl,
113
+ ...(params.locale != null ? { locale: params.locale as Stripe.Checkout.SessionCreateParams.Locale } : {}),
60
114
  metadata: {
61
115
  pricingMode: plan.pricingMode ?? CheckoutPricingMode.Quantity,
62
116
  currency: (plan.currency ?? 'usd').toLowerCase(),
@@ -70,7 +124,7 @@ const sharedSession = (
70
124
  })
71
125
 
72
126
  export const amountCheckoutLineItem = (
73
- product: PaymentProduct, plan: PaymentPlan, amountMinor: number,
127
+ product: PaymentProduct, plan: PaymentPlan, amountMinor: number, behavior: TaxBehavior = TaxBehavior.Exclusive,
74
128
  ): { lineItem: Stripe.Checkout.SessionCreateParams.LineItem; chargeMinor: number; currency: string } => {
75
129
  if (plan.amountPolicy == null) throw new ProductError(`amount-policy:${plan.sku}`)
76
130
  assertCheckoutAmount(plan.amountPolicy, amountMinor)
@@ -79,7 +133,7 @@ export const amountCheckoutLineItem = (
79
133
  return {
80
134
  lineItem: {
81
135
  price_data: {
82
- product: product.sku, currency, unit_amount: chargeMinor, tax_behavior: 'exclusive',
136
+ product: product.sku, currency, unit_amount: chargeMinor, tax_behavior: behavior,
83
137
  },
84
138
  quantity: 1,
85
139
  },
@@ -96,88 +150,519 @@ export const quantityCheckoutLineItem = (
96
150
  quantity: policy.default,
97
151
  })
98
152
 
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
- }
153
+ /** The plan an amount/quantity checkout of a consumable product sells: the named one, else the first. */
154
+ export const consumablePlanOf = async (
155
+ ctx: ApiContext, productSku: string, planSku?: string,
156
+ ): Promise<{ product: PaymentProduct, plan: PaymentPlan }> => {
157
+ const product = await payment(ctx).product(productSku) as PaymentProduct
158
+ const plans = (await payment(ctx).allPlans(product.sku) as PaymentPlan[])
159
+ .filter(plan => isSoldThrough(product, plan, STRIPE_PAYGATE_ALIAS))
160
+ if (planSku != null && !plans.some(plan => plan.sku === planSku)) {
161
+ throw new ProductError(`plan:${planSku}`)
162
+ }
163
+ const plan = plans.find(item => item.sku === planSku) ?? plans[0]
164
+ if (plan == null) throw new ProductError('plan')
165
+
166
+ return { product, plan }
167
+ }
168
+
169
+ /** What the consumer-rights policy makes of this checkout's buyer. */
170
+ interface BuyerContext {
171
+ policy: ConsumerRightsPolicy | null
172
+ profile: BillingProfileView | null
173
+ country?: string
174
+ region: ConsumerRegion | null
175
+ inScope: boolean
176
+ language: string
177
+ /** `null`: the policy names no region currencies — the legacy settlement behaviour. */
178
+ chargeCurrency: string | null
179
+ /** A locked profile whose country the saved customer address carries. */
180
+ addressLocked: boolean
181
+ }
182
+
183
+ /**
184
+ * The buyer as the consumer-rights policy sees it — a locked profile overrides the declared
185
+ * country (a different one is `BillingCountryLocked`), an organization that already paid before
186
+ * its country was locked is locked lazily from its paygate customer's address, and a locked
187
+ * customer address that no longer carries the locked country refuses (the operator relocks).
188
+ */
189
+ const buyerOf = async (
190
+ ctx: ApiContext, stripe: Stripe, params: CreateLinkParams, catalogueCurrency: string,
191
+ ): Promise<{ buyer: BuyerContext, customer: Stripe.Customer }> => {
192
+ const policy = await payment(ctx).consumerRightsPolicy()
193
+ const rights = policy != null ? consumerRightsOf(ctx) : null
194
+ let profile = rights != null ? await rights.profile(params.entityId) : null
195
+ const declared = params.country != null && params.country.trim() !== '' ? params.country.trim().toUpperCase() : undefined
196
+ if (profile?.locked === true && declared != null && declared !== profile.country) {
197
+ throw new BillingCountryLocked({ country: profile.country as string, requested: declared })
198
+ }
199
+ let customer = await ensureStripeCustomer(ctx, stripe, params)
200
+ const customerCountry = customer.address?.country?.toUpperCase() ?? undefined
201
+ if (rights != null && policy?.mechanisms.countryLock === true && profile == null && customerCountry != null
202
+ && await hasPaid(ctx, params.entityId) && !await wasUnlocked(ctx, params.entityId)) {
203
+ // An organization that paid before countries were locked: its saved address is its country.
204
+ // One an operator unlocked is locked again by its next completed purchase instead.
205
+ profile = await rights.lock(params.entityId, customerCountry, 'customer', { customerId: customer.id })
206
+ }
207
+ if (policy != null && profile == null && declared != null && customerCountry == null) {
208
+ // Preselect the declared country on Checkout's address form; never over a saved address.
209
+ customer = await stripe.customers.update(customer.id, { address: { country: declared } })
210
+ }
211
+ if (profile?.locked === true && declared != null && declared !== profile.country) {
212
+ throw new BillingCountryLocked({ country: profile.country as string, requested: declared })
213
+ }
214
+ if (profile?.locked === true && customerCountry != null && customerCountry !== profile.country) {
215
+ throw new BillingCountryLocked({ country: profile.country as string, requested: customerCountry })
216
+ }
217
+ const country = profile?.country ?? declared
218
+ const region = policy != null ? regionOf(country, policy) : null
219
+ const settlement = (await stripePricingConfig(ctx))?.settlementCurrency?.toLowerCase()
220
+ const regional = policy?.currencies != null && Object.keys(policy.currencies).length > 0
221
+
222
+ return {
223
+ customer,
224
+ buyer: {
225
+ policy,
226
+ profile,
227
+ ...(country != null ? { country } : {}),
228
+ region,
229
+ inScope: policy != null ? inScope(region, country, policy) : false,
230
+ language: params.consumerLanguage ?? profile?.language ?? billingLanguageOf(country, policy),
231
+ chargeCurrency: regional
232
+ ? (profile?.currency ?? chargeCurrencyOf(region, policy, settlement ?? catalogueCurrency)).toLowerCase()
233
+ : null,
234
+ addressLocked: profile?.locked === true && customerCountry != null,
235
+ },
236
+ }
237
+ }
238
+
239
+ /**
240
+ * The consumer-rights metadata every session (and its subscription) carries. `termsCollected`
241
+ * says whether Checkout asked for the terms checkbox — `'false'` also after the fallback of a
242
+ * Dashboard without a terms URL.
243
+ */
244
+ const consumerMetadata = (buyer: BuyerContext, params: CreateLinkParams, startRequestId?: string): Record<string, string> =>
245
+ buyer.policy == null ? {} : compact({
246
+ region: buyer.region ?? undefined,
247
+ country: buyer.country,
248
+ language: buyer.language,
249
+ termsVersion: buyer.policy.textVersion,
250
+ copyVersion: CONSUMER_RIGHTS_COPY_VERSION,
251
+ termsCollected: String(buyer.policy.mechanisms.checkoutTerms === true),
252
+ ipCountry: params.ipCountry?.toUpperCase(),
253
+ startRequestId,
254
+ }) as Record<string, string>
255
+
256
+ const limited = (message: string, field: string): string => {
257
+ if (message.length > CUSTOM_TEXT_MAX) {
258
+ throw new ConsumerRightsError(`copy:length:${field}`)
259
+ }
260
+
261
+ return message
262
+ }
263
+
264
+ /** The terms checkbox and its text, when the policy asks for them on every checkout. */
265
+ const termsOptions = (buyer: BuyerContext): Partial<Stripe.Checkout.SessionCreateParams> & { terms?: string } => {
266
+ const policy = buyer.policy
267
+ if (policy?.mechanisms.checkoutTerms !== true) {
268
+ return {}
269
+ }
270
+ const links = linksOf(policy, buyer.language)
271
+ const withInformation = buyer.inScope && links.withdrawalInformation != null
272
+ const message = limited(consumerText(buyer.language, `checkout.terms-acceptance.${withInformation ? 'in-scope' : 'other'}`, {
273
+ billingTerms: links.billingTerms, ...(withInformation ? { withdrawalInformation: links.withdrawalInformation as string } : {}),
274
+ }), 'terms')
275
+
276
+ return { consent_collection: { terms_of_service: 'required' }, terms: message }
277
+ }
278
+
279
+ const textContextOf = (buyer: BuyerContext, currency: string, extra: Partial<CheckoutTextContext> = {}): CheckoutTextContext => compact({
280
+ language: buyer.language, currency, region: buyer.region, country: buyer.country, ...extra,
281
+ }) as CheckoutTextContext
282
+
283
+ const submitTextOf = (params: CreateLinkParams, context: CheckoutTextContext): string | undefined => {
284
+ if (params.submitText == null) {
285
+ return undefined
286
+ }
287
+ const message = typeof params.submitText === 'function' ? params.submitText(context) : params.submitText
288
+
289
+ return message.trim() === '' ? undefined : message
290
+ }
291
+
292
+ /** Whether the synced reusable price of a plan can be charged in `currency` (its default or an option). */
293
+ const priceCarries = async (
294
+ ctx: ApiContext, product: PaymentProduct, plan: PaymentPlan, price: Stripe.Price, currency: string,
295
+ ): Promise<{ carries: boolean, unitAmount?: number }> => {
296
+ if (price.currency === currency) {
297
+ return { carries: true, ...(price.unit_amount != null ? { unitAmount: price.unit_amount } : {}) }
298
+ }
299
+ const listed = (price as unknown as { currency_options?: Record<string, { unit_amount?: number | null }> }).currency_options?.[currency]
300
+ if (listed != null) {
301
+ return { carries: true, ...(listed.unit_amount != null ? { unitAmount: listed.unit_amount } : {}) }
302
+ }
303
+ const synced = (await fingerprints(ctx).bySku(product.sku))?.prices?.find(entry => entry.planSku === plan.sku && entry.priceId === price.id)
304
+ const option = synced?.options.find(entry => entry.currency === currency)
305
+
306
+ return option != null ? { carries: true, unitAmount: option.unitAmount } : { carries: false }
307
+ }
308
+
309
+ interface StripeErrorShape {
310
+ type?: string
311
+ rawType?: string
312
+ code?: string
313
+ param?: string
314
+ message?: string
315
+ raw?: { type?: string, code?: string, param?: string, message?: string }
316
+ }
317
+
318
+ /**
319
+ * Stripe refuses `consent_collection.terms_of_service` while the account has no terms-of-service URL
320
+ * in its Dashboard (Settings → Public details): an `invalid_request_error` on the param
321
+ * `consent_collection[terms_of_service]` saying "You cannot collect consent to your terms of service
322
+ * unless a URL is set in the Stripe Dashboard …". Matched on the param with a terms message, or on
323
+ * the message alone; any other error type never matches.
324
+ */
325
+ export const isMissingTermsUrl = (error: unknown): boolean => {
326
+ if (error == null || typeof error !== 'object') {
327
+ return false
328
+ }
329
+ const typed = error as StripeErrorShape
330
+ const kind = typed.rawType ?? typed.raw?.type ?? typed.type
331
+ if (kind != null && kind !== 'invalid_request_error' && kind !== 'StripeInvalidRequestError') {
332
+ return false
333
+ }
334
+ const param = (typed.param ?? typed.raw?.param ?? '').replace(/\s/g, '')
335
+ const message = typed.message ?? typed.raw?.message ?? ''
336
+ const aboutTerms = /terms of service/i.test(message)
337
+ const onTermsParam = /^consent_collection(\[|\.)terms_of_service\]?$/.test(param)
338
+
339
+ return (onTermsParam && aboutTerms) || (aboutTerms && /\burl\b/i.test(message) && /dashboard/i.test(message))
340
+ }
126
341
 
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,
342
+ /** The same session without the terms checkbox and its text — and saying so in its metadata. */
343
+ const withoutTerms = (params: Stripe.Checkout.SessionCreateParams): Stripe.Checkout.SessionCreateParams => {
344
+ const { consent_collection: consent, custom_text: customText, ...rest } = params
345
+ const { terms_of_service: _terms, ...otherConsent } = consent ?? {}
346
+ const { terms_of_service_acceptance: _acceptance, ...otherText } = customText ?? {}
347
+ const marked = (metadata: Stripe.MetadataParam | undefined): Stripe.MetadataParam | undefined =>
348
+ metadata != null ? { ...metadata, termsCollected: 'false' } : metadata
349
+
350
+ return {
351
+ ...rest,
352
+ ...(Object.keys(otherConsent).length > 0 ? { consent_collection: otherConsent } : {}),
353
+ ...(Object.keys(otherText).length > 0 ? { custom_text: otherText } : {}),
354
+ metadata: marked(rest.metadata) ?? { termsCollected: 'false' },
355
+ ...(rest.subscription_data != null
356
+ ? { subscription_data: { ...rest.subscription_data, metadata: marked(rest.subscription_data.metadata) ?? { termsCollected: 'false' } } }
357
+ : {}),
358
+ }
359
+ }
360
+
361
+ /** One console warning per context — a process builds one. */
362
+ const warnedTermsFallback = new WeakSet<object>()
363
+
364
+ /**
365
+ * A Dashboard without a terms-of-service URL must never stop a payment: the session is created
366
+ * once more without the checkbox, an operator is told (once), and the degraded checkout is audited
367
+ * as a `checkout-terms-fallback` event of the organization (`externalId` = the session).
368
+ */
369
+ const createWithoutTerms = async (
370
+ ctx: ApiContext, stripe: Stripe, attempt: CheckoutAttempt, params: Stripe.Checkout.SessionCreateParams, refusal: unknown,
371
+ ): Promise<Stripe.Checkout.Session> => {
372
+ const stripeMessage = errorText(refusal)
373
+ if (!warnedTermsFallback.has(ctx)) {
374
+ warnedTermsFallback.add(ctx)
375
+ console.warn('[payment] Stripe refused the terms-of-service checkbox: this account has no terms of service URL. '
376
+ + 'Checkouts continue WITHOUT the checkbox (metadata termsCollected=false). Operator: set the Billing Terms URL '
377
+ + 'in the Stripe Dashboard → Settings → Public details (test and live mode alike).')
378
+ }
379
+ const typed = refusal as StripeErrorShape
380
+ const detail = JSON.stringify(compact({
381
+ message: stripeMessage, code: typed.code ?? typed.raw?.code, param: typed.param ?? typed.raw?.param,
382
+ mode: attempt.mode, productSku: attempt.productSku, planSku: attempt.planSku,
383
+ }))
384
+ try {
385
+ const session = await stripe.checkout.sessions.create(withoutTerms(params))
386
+ await recordEvent(ctx, {
387
+ recordId: attempt.entityId, recordKind: 'checkout', entityId: attempt.entityId, action: 'checkout-terms-fallback',
388
+ ok: false, externalId: session.id, detail,
389
+ })
390
+
391
+ return session
392
+ } catch (error) {
393
+ await recordEvent(ctx, {
394
+ recordId: attempt.entityId, recordKind: 'checkout', entityId: attempt.entityId, action: 'checkout-terms-fallback',
395
+ ok: false, detail, error: errorText(error),
396
+ })
397
+ throw error
398
+ }
399
+ }
400
+
401
+ /**
402
+ * Create the session, then tell the plugins it exists. A plugin's `created` that throws expires
403
+ * the fresh session and releases every admission before the error propagates. A session Stripe
404
+ * refuses for the terms checkbox alone (no terms URL in the Dashboard) is created once more
405
+ * without it — under the same admissions.
406
+ */
407
+ const createSession = async (
408
+ ctx: ApiContext, stripe: Stripe, plugins: readonly CheckoutPlugin[], attempt: CheckoutAttempt,
409
+ params: Stripe.Checkout.SessionCreateParams,
410
+ ): Promise<string> => {
411
+ const admitted: Admitted[] = await admitCheckout(ctx, plugins, attempt)
412
+ const holders = admitted.filter(entry => entry.plugin.admit != null)
413
+ let session: Stripe.Checkout.Session
414
+ try {
415
+ try {
416
+ session = await stripe.checkout.sessions.create(params)
417
+ } catch (error) {
418
+ if (params.consent_collection?.terms_of_service !== 'required' || !isMissingTermsUrl(error)) {
419
+ throw error
132
420
  }
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
421
+ session = await createWithoutTerms(ctx, stripe, attempt, params, error)
142
422
  }
423
+ } catch (error) {
424
+ await releaseAdmitted(ctx, holders, attempt)
425
+ throw error
426
+ }
427
+ if (session.url == null) {
428
+ await releaseAdmitted(ctx, holders, attempt, session.id)
429
+ throw new PaygateError('session')
430
+ }
431
+ for (const { plugin, reservationId } of admitted) {
432
+ if (plugin.created == null) {
433
+ continue
434
+ }
435
+ try {
436
+ await plugin.created(ctx, { ...attempt, sessionId: session.id, url: session.url, ...(reservationId != null ? { reservationId } : {}) })
437
+ } catch (error) {
438
+ try {
439
+ await stripe.checkout.sessions.expire(session.id)
440
+ } catch (expireError) {
441
+ console.error(`[payment] could not expire checkout "${session.id}" after a plugin refused it`, expireError)
442
+ }
443
+ await releaseAdmitted(ctx, holders, attempt, session.id)
444
+ throw error
445
+ }
446
+ }
447
+
448
+ return session.url
449
+ }
450
+
451
+ /**
452
+ * A Stripe Checkout URL: an amount or quantity purchase of a consumable product, or a subscription
453
+ * to `planSku` (else the product's first recurring plan). A free plan is never checked out.
454
+ *
455
+ * With a consumer-rights policy: a locked billing country overrides the declared one (another is
456
+ * `BillingCountryLocked`); the charge currency is the profile's, else the region's; an amount
457
+ * checkout is narrowed by the checkout plugins (`CheckoutLimitExceeded`) and charged without FX
458
+ * when its policy currency is the charge currency; a subscription needs a fresh start request
459
+ * bound to its plan; the terms checkbox and the legal submit texts come from the copy.
460
+ */
461
+ export const createCheckoutLink = async (
462
+ ctx: ApiContext, stripe: Stripe, params: CreateLinkParams, plugins: readonly CheckoutPlugin[] = [],
463
+ ): Promise<string> => {
464
+ const product = await payment(ctx).product(params.productSku) as PaymentProduct
465
+ const plans = (await payment(ctx).allPlans(product.sku) as PaymentPlan[])
466
+ .filter(plan => isSoldThrough(product, plan, STRIPE_PAYGATE_ALIAS))
467
+ if (params.planSku != null && !plans.some(plan => plan.sku === params.planSku)) {
468
+ throw new ProductError(`plan:${params.planSku}`)
469
+ }
470
+ const consumable = product.type === ProductType.Consumable
471
+ const plan = consumable
472
+ ? plans.find(item => item.sku === params.planSku) ?? plans[0]
473
+ : plans.find(item => item.sku === params.planSku) ?? plans.find(item => item.recurring != null) ?? plans[0]
474
+ if (plan == null) throw new ProductError('plan')
475
+ const catalogueCurrency = (plan.amountPolicy?.currency ?? plan.currency ?? 'usd').toLowerCase()
476
+ const { buyer, customer } = await buyerOf(ctx, stripe, params, catalogueCurrency)
477
+ const pricing = await payment(ctx).pricingPolicy()
478
+ const settlement = (await stripePricingConfig(ctx))?.settlementCurrency?.toLowerCase()
479
+ const ttl = sessionTtlOf(plugins)
480
+ const now = new Date()
481
+ const expiresAt = ttl != null ? new Date((Math.floor(now.getTime() / 1000) + ttl) * 1000) : undefined
482
+ const expiry = expiresAt != null ? { expires_at: Math.floor(expiresAt.getTime() / 1000) } : {}
483
+ const { terms, ...termsParams } = termsOptions(buyer)
484
+ const base = {
485
+ entityId: params.entityId, productSku: product.sku, planSku: plan.sku, at: now,
486
+ ...(ttl != null ? { sessionTtlSeconds: ttl } : {}), ...(expiresAt != null ? { expiresAt } : {}),
487
+ }
143
488
 
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
- },
489
+ if (consumable && plan.pricingMode === CheckoutPricingMode.Amount) {
490
+ if (params.amountMinor == null) throw new ProductError('amount')
491
+ const { chargeMinor: sourceChargeMinor, currency: amountCurrency } = amountCheckoutLineItem(
492
+ product, plan, params.amountMinor, pricing.tax.behavior,
493
+ )
494
+ const view = await narrowAmountFor(ctx, plugins, {
495
+ entityId: params.entityId, productSku: product.sku, planSku: plan.sku,
496
+ base: plan.amountPolicy as NonNullable<PaymentPlan['amountPolicy']>, at: now,
497
+ })
498
+ assertAmountAllowed(view, params.amountMinor)
499
+ const charged = buyer.chargeCurrency != null
500
+ ? await chargeAmount(ctx, stripe, sourceChargeMinor, amountCurrency, buyer.chargeCurrency)
501
+ : await settlementAmount(ctx, stripe, sourceChargeMinor, amountCurrency)
502
+ const adaptive = buyer.chargeCurrency == null || charged.currency === (settlement ?? amountCurrency)
503
+ const context = textContextOf(buyer, charged.currency, { unitAmountMinor: charged.amountMinor })
504
+ const submit = submitTextOf(params, context)
505
+ ?? (buyer.policy != null && buyer.inScope && buyer.country != null
506
+ ? consumerText(buyer.language, 'checkout.top-up', {
507
+ product: product.title, country: countryName(buyer.country, buyer.language),
508
+ })
509
+ : undefined)
510
+ const customText = compact({
511
+ ...(submit != null ? { submit: { message: limited(submit, 'submit') } } : {}),
512
+ ...(terms != null ? { terms_of_service_acceptance: { message: terms } } : {}),
513
+ })
514
+ const lineItem: Stripe.Checkout.SessionCreateParams.LineItem = {
515
+ price_data: {
516
+ product: product.sku, currency: charged.currency, unit_amount: charged.amountMinor,
517
+ tax_behavior: pricing.tax.behavior,
155
518
  },
156
- ...taxOptions(true),
157
- ...sharedSession(customer, params, product, plan, {}),
519
+ quantity: 1,
520
+ }
521
+
522
+ return await createSession(ctx, stripe, plugins, {
523
+ ...base, mode: 'amount', amountMinor: params.amountMinor, amountCurrency, chargeMinor: charged.amountMinor,
524
+ currency: charged.currency,
525
+ }, {
526
+ mode: 'payment', line_items: [lineItem], invoice_creation: { enabled: true },
527
+ ...checkoutOptions(pricing, false, { locked: buyer.addressLocked, adaptive }),
528
+ ...termsParams,
529
+ ...(Object.keys(customText).length > 0 ? { custom_text: customText } : {}),
530
+ ...expiry,
531
+ ...sharedSession(customer, params, product, plan, {
532
+ pricingMode: CheckoutPricingMode.Amount,
533
+ currency: charged.currency,
534
+ amountCurrency,
535
+ amountMinor: String(params.amountMinor),
536
+ sourceChargeAmountMinor: String(sourceChargeMinor),
537
+ chargeAmountMinor: String(charged.amountMinor),
538
+ ...consumerMetadata(buyer, params),
539
+ }),
158
540
  })
159
- if (session.url == null) throw new PaygateError('session')
160
- return session.url
161
- },
541
+ }
162
542
 
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>
543
+ const price = await findPrice(stripe, product.sku, planLookupKey(product, plan))
544
+ const forced = buyer.chargeCurrency != null
545
+ ? await priceCarries(ctx, product, plan, price, buyer.chargeCurrency) : { carries: false }
546
+ if (buyer.chargeCurrency != null && !forced.carries) {
547
+ console.warn(`[payment] plan "${plan.sku}" has no ${buyer.chargeCurrency.toUpperCase()} price; Stripe picks the currency`)
548
+ }
549
+ const currency = forced.carries ? buyer.chargeCurrency as string : price.currency
550
+ const adaptive = buyer.chargeCurrency == null || currency === (settlement ?? price.currency)
551
+ // Forced only when the synced price carries it: a session currency the price lacks is refused.
552
+ const currencyParam = forced.carries ? { currency } : {}
553
+
554
+ if (consumable) {
555
+ const quantityPolicy = plan.quantityPolicy ?? {
556
+ minimum: plan.minQuantity ?? 1,
557
+ maximum: plan.maxQuantity ?? Math.max(100_000, plan.minQuantity ?? 1),
558
+ default: plan.defaultQuantity ?? plan.minQuantity ?? 1,
169
559
  }
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
- },
560
+ const submit = submitTextOf(params, textContextOf(buyer, currency, compact({ unitAmountMinor: forced.unitAmount ?? price.unit_amount ?? undefined })))
561
+ const customText = compact({
562
+ ...(submit != null ? { submit: { message: limited(submit, 'submit') } } : {}),
563
+ ...(terms != null ? { terms_of_service_acceptance: { message: terms } } : {}),
564
+ })
176
565
 
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
- },
566
+ return await createSession(ctx, stripe, plugins, { ...base, mode: 'quantity', currency }, {
567
+ mode: 'payment',
568
+ line_items: [quantityCheckoutLineItem(price, quantityPolicy)],
569
+ invoice_creation: { enabled: true },
570
+ ...currencyParam,
571
+ ...checkoutOptions(pricing, true, { locked: buyer.addressLocked, adaptive }),
572
+ ...termsParams,
573
+ ...(Object.keys(customText).length > 0 ? { custom_text: customText } : {}),
574
+ ...expiry,
575
+ ...sharedSession(customer, params, product, plan, {
576
+ pricingMode: CheckoutPricingMode.Quantity,
577
+ ...(forced.carries ? { currency } : {}),
578
+ ...consumerMetadata(buyer, params),
579
+ }),
580
+ })
581
+ }
582
+
583
+ // A subscription starts services at once: in scope (or unknown and protected) it needs the
584
+ // consumer's express start request, fresh and bound to this organization and plan.
585
+ const start = buyer.policy != null ? await consumerRightsOf(ctx)?.assertStartRequest(params.entityId, plan.sku, params.startRequestId) ?? null : null
586
+ const subscriptionPaymentMethodTypes = (
587
+ await stripePricingConfig(ctx)
588
+ )?.subscriptionPaymentMethodTypes as Stripe.Checkout.SessionCreateParams.PaymentMethodType[] | undefined
589
+ const unitAmount = forced.unitAmount ?? price.unit_amount ?? undefined
590
+ const interval = plan.recurring?.interval
591
+ const context = textContextOf(buyer, currency, compact({ unitAmountMinor: unitAmount, interval }))
592
+ const renewal = buyer.policy != null && unitAmount != null && interval != null
593
+ ? consumerText(buyer.language, `checkout.renewal.${interval}`, {
594
+ price: consumerText(buyer.language, `checkout.price.${pricing.tax.behavior === TaxBehavior.Inclusive ? 'inclusive' : 'exclusive'}`, {
595
+ amount: formatMoney(unitAmount, currency, buyer.language),
596
+ }),
597
+ })
598
+ : undefined
599
+ const submit = submitTextOf(params, context) ?? renewal
600
+ const links = buyer.policy != null ? linksOf(buyer.policy, buyer.language) : null
601
+ const afterSubmit = buyer.policy?.mechanisms.cancellation === true && links?.cancellation != null
602
+ ? consumerText(buyer.language, 'checkout.renewal.after-submit', { cancelUrl: links.cancellation })
603
+ : undefined
604
+ const customText = compact({
605
+ ...(submit != null ? { submit: { message: limited(submit, 'submit') } } : {}),
606
+ ...(afterSubmit != null ? { after_submit: { message: limited(afterSubmit, 'after-submit') } } : {}),
607
+ ...(terms != null ? { terms_of_service_acceptance: { message: terms } } : {}),
608
+ })
609
+ const startRequestId = start?.id ?? undefined
610
+ const metadata = consumerMetadata(buyer, params, startRequestId)
611
+
612
+ return await createSession(ctx, stripe, plugins, { ...base, mode: 'subscription', currency }, {
613
+ mode: 'subscription', line_items: [{ price: price.id, quantity: 1 }],
614
+ ...currencyParam,
615
+ ...(subscriptionPaymentMethodTypes != null ? { payment_method_types: subscriptionPaymentMethodTypes } : {}),
616
+ ...(Object.keys(customText).length > 0 ? { custom_text: customText } : {}),
617
+ subscription_data: {
618
+ metadata: {
619
+ pricingMode: CheckoutPricingMode.Quantity, entityId: params.entityId,
620
+ service: params.service, productSku: product.sku, planSku: plan.sku,
621
+ ...(params.profileId != null ? { profileId: params.profileId } : {}),
622
+ ...metadata,
623
+ },
624
+ },
625
+ ...checkoutOptions(pricing, true, { locked: buyer.addressLocked, adaptive }),
626
+ ...termsParams,
627
+ ...expiry,
628
+ ...sharedSession(customer, params, product, plan, metadata),
629
+ })
630
+ }
631
+
632
+ interface WebhookRequest {
633
+ original?: { rawBody?: string | Buffer }
634
+ rawBody?: string | Buffer
635
+ headers: Record<string, string | string[] | undefined>
636
+ }
637
+
638
+ /**
639
+ * Verify a Stripe webhook against the configured override secret, then the managed endpoint's
640
+ * stored one, and dispatch it.
641
+ *
642
+ * @throws PaygateError('signature') | WebhookSetupError('secret')
643
+ */
644
+ export const handleStripeWebhook = async (ctx: ApiContext, stripe: Stripe, request: unknown): Promise<void> => {
645
+ const typed = request as WebhookRequest
646
+ const rawBody = typed.original?.rawBody ?? typed.rawBody
647
+ const signature = typed.headers[STRIPE_SIGNATURE.toLowerCase()]
648
+ if (rawBody == null || typeof signature !== 'string') throw new PaygateError('signature')
649
+
650
+ const secrets = await stripeWebhookSecrets(ctx)
651
+ if (secrets.length === 0) {
652
+ throw new WebhookSetupError('secret')
653
+ }
654
+ let event: Stripe.Event | null = null
655
+ for (const secret of secrets) {
656
+ try {
657
+ event = await stripe.webhooks.constructEventAsync(rawBody, signature, secret)
658
+ break
659
+ } catch {
660
+ // Try the next secret; a signature none of them verifies is refused below.
661
+ }
662
+ }
663
+ if (event == null) {
664
+ throw new PaygateError('signature')
665
+ }
666
+
667
+ await createEventHandler(ctx, stripe).process(event)
183
668
  }