@owlmeans/server-payment 0.1.18-rc.20 → 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 (144) hide show
  1. package/README.md +1 -1
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/server-payment/SKILL.md +402 -260
  4. package/build/config.d.ts +15 -1
  5. package/build/config.d.ts.map +1 -1
  6. package/build/config.js +99 -3
  7. package/build/config.js.map +1 -1
  8. package/build/consts.d.ts +27 -0
  9. package/build/consts.d.ts.map +1 -1
  10. package/build/consts.js +27 -0
  11. package/build/consts.js.map +1 -1
  12. package/build/consumer/capture.d.ts +74 -0
  13. package/build/consumer/capture.d.ts.map +1 -0
  14. package/build/consumer/capture.js +291 -0
  15. package/build/consumer/capture.js.map +1 -0
  16. package/build/consumer/format.d.ts +27 -0
  17. package/build/consumer/format.d.ts.map +1 -0
  18. package/build/consumer/format.js +81 -0
  19. package/build/consumer/format.js.map +1 -0
  20. package/build/consumer/handlers.d.ts +28 -0
  21. package/build/consumer/handlers.d.ts.map +1 -0
  22. package/build/consumer/handlers.js +173 -0
  23. package/build/consumer/handlers.js.map +1 -0
  24. package/build/consumer/index.d.ts +7 -0
  25. package/build/consumer/index.d.ts.map +1 -0
  26. package/build/consumer/index.js +6 -0
  27. package/build/consumer/index.js.map +1 -0
  28. package/build/consumer/mail.d.ts +27 -0
  29. package/build/consumer/mail.d.ts.map +1 -0
  30. package/build/consumer/mail.js +314 -0
  31. package/build/consumer/mail.js.map +1 -0
  32. package/build/consumer/origin.d.ts +14 -0
  33. package/build/consumer/origin.d.ts.map +1 -0
  34. package/build/consumer/origin.js +47 -0
  35. package/build/consumer/origin.js.map +1 -0
  36. package/build/consumer/reconcile.d.ts +12 -0
  37. package/build/consumer/reconcile.d.ts.map +1 -0
  38. package/build/consumer/reconcile.js +317 -0
  39. package/build/consumer/reconcile.js.map +1 -0
  40. package/build/consumer/records.d.ts +78 -0
  41. package/build/consumer/records.d.ts.map +1 -0
  42. package/build/consumer/records.js +296 -0
  43. package/build/consumer/records.js.map +1 -0
  44. package/build/consumer/service.d.ts +51 -0
  45. package/build/consumer/service.d.ts.map +1 -0
  46. package/build/consumer/service.js +760 -0
  47. package/build/consumer/service.js.map +1 -0
  48. package/build/consumer/withdrawal.d.ts +57 -0
  49. package/build/consumer/withdrawal.d.ts.map +1 -0
  50. package/build/consumer/withdrawal.js +247 -0
  51. package/build/consumer/withdrawal.js.map +1 -0
  52. package/build/index.d.ts +4 -2
  53. package/build/index.d.ts.map +1 -1
  54. package/build/index.js +4 -2
  55. package/build/index.js.map +1 -1
  56. package/build/model.d.ts +6 -1
  57. package/build/model.d.ts.map +1 -1
  58. package/build/model.js +134 -4
  59. package/build/model.js.map +1 -1
  60. package/build/observer.d.ts +4 -0
  61. package/build/observer.d.ts.map +1 -1
  62. package/build/observer.js +13 -0
  63. package/build/observer.js.map +1 -1
  64. package/build/plugins/checkout-plugins.d.ts +49 -0
  65. package/build/plugins/checkout-plugins.d.ts.map +1 -0
  66. package/build/plugins/checkout-plugins.js +124 -0
  67. package/build/plugins/checkout-plugins.js.map +1 -0
  68. package/build/plugins/estimate.d.ts.map +1 -1
  69. package/build/plugins/estimate.js +41 -8
  70. package/build/plugins/estimate.js.map +1 -1
  71. package/build/plugins/events.d.ts +2 -0
  72. package/build/plugins/events.d.ts.map +1 -1
  73. package/build/plugins/events.js +126 -9
  74. package/build/plugins/events.js.map +1 -1
  75. package/build/plugins/fx.d.ts +5 -0
  76. package/build/plugins/fx.d.ts.map +1 -1
  77. package/build/plugins/fx.js +25 -0
  78. package/build/plugins/fx.js.map +1 -1
  79. package/build/plugins/portal.d.ts.map +1 -1
  80. package/build/plugins/portal.js +11 -1
  81. package/build/plugins/portal.js.map +1 -1
  82. package/build/plugins/stripe.d.ts +21 -2
  83. package/build/plugins/stripe.d.ts.map +1 -1
  84. package/build/plugins/stripe.js +397 -61
  85. package/build/plugins/stripe.js.map +1 -1
  86. package/build/resource.d.ts +8 -1
  87. package/build/resource.d.ts.map +1 -1
  88. package/build/resource.js +59 -2
  89. package/build/resource.js.map +1 -1
  90. package/build/service.d.ts +2 -1
  91. package/build/service.d.ts.map +1 -1
  92. package/build/service.js +28 -5
  93. package/build/service.js.map +1 -1
  94. package/build/subscription.d.ts +6 -0
  95. package/build/subscription.d.ts.map +1 -1
  96. package/build/subscription.js +1 -0
  97. package/build/subscription.js.map +1 -1
  98. package/build/sync.d.ts +7 -0
  99. package/build/sync.d.ts.map +1 -1
  100. package/build/sync.js +108 -15
  101. package/build/sync.js.map +1 -1
  102. package/build/types.d.ts +707 -4
  103. package/build/types.d.ts.map +1 -1
  104. package/build/utils.d.ts +22 -1
  105. package/build/utils.d.ts.map +1 -1
  106. package/build/utils.js +27 -1
  107. package/build/utils.js.map +1 -1
  108. package/package.json +14 -13
  109. package/src/config.ts +111 -7
  110. package/src/consts.ts +34 -0
  111. package/src/consumer/capture.ts +362 -0
  112. package/src/consumer/format.ts +90 -0
  113. package/src/consumer/handlers.ts +211 -0
  114. package/src/consumer/index.ts +6 -0
  115. package/src/consumer/mail.ts +368 -0
  116. package/src/consumer/origin.ts +63 -0
  117. package/src/consumer/reconcile.ts +329 -0
  118. package/src/consumer/records.ts +374 -0
  119. package/src/consumer/service.ts +868 -0
  120. package/src/consumer/withdrawal.ts +302 -0
  121. package/src/index.ts +6 -4
  122. package/src/model.ts +148 -6
  123. package/src/observer.ts +15 -2
  124. package/src/plugins/checkout-plugins.ts +155 -0
  125. package/src/plugins/estimate.ts +49 -9
  126. package/src/plugins/events.ts +135 -11
  127. package/src/plugins/fx.ts +29 -0
  128. package/src/plugins/portal.ts +11 -1
  129. package/src/plugins/stripe.ts +476 -60
  130. package/src/resource.ts +87 -4
  131. package/src/service.ts +28 -6
  132. package/src/subscription.ts +7 -0
  133. package/src/sync.ts +124 -17
  134. package/src/types.ts +756 -6
  135. package/src/utils.ts +56 -7
  136. package/tests/checkout-consumer.spec.ts +348 -0
  137. package/tests/checkout-plugins.spec.ts +164 -0
  138. package/tests/consumer-events.spec.ts +218 -0
  139. package/tests/consumer-fixtures.ts +132 -0
  140. package/tests/consumer-ops.spec.ts +351 -0
  141. package/tests/consumer-rights.integration.spec.ts +150 -0
  142. package/tests/consumer-rights.spec.ts +501 -0
  143. package/tests/context.ts +20 -2
  144. package/tests/fake-stripe.ts +188 -18
@@ -1,17 +1,38 @@
1
1
  import type Stripe from 'stripe'
2
2
  import {
3
- assertCheckoutAmount, chargeAmountMinor, CheckoutPricingMode, PaygateError, ProductError, ProductType,
4
- TaxBehavior, WebhookSetupError,
3
+ assertCheckoutAmount, billingLanguageOf, BillingCountryLocked, chargeAmountMinor, chargeCurrencyOf,
4
+ CheckoutPricingMode, CONSUMER_RIGHTS_COPY_VERSION, ConsumerRightsError, consumerText, inScope, linksOf,
5
+ PaygateError, ProductError, ProductType, regionOf, TaxBehavior, WebhookSetupError,
5
6
  } from '@owlmeans/payment'
6
- import type { PricingPolicy } from '@owlmeans/payment'
7
+ import type { BillingProfileView, ConsumerRegion, ConsumerRightsPolicy, PricingPolicy } from '@owlmeans/payment'
7
8
  import type { Context as ApiContext } from '@owlmeans/server-api'
8
9
  import { STRIPE_PAYGATE_ALIAS, STRIPE_SIGNATURE } from '../consts.js'
9
- import { paygateCustomers, payment, stripePricingConfig } from '../utils.js'
10
+ import {
11
+ compact, consumerRightsOf, errorText, fingerprints, paygateCustomers, payment, stripePricingConfig,
12
+ } from '../utils.js'
10
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'
11
20
  import { createEventHandler } from './events.js'
12
- import { settlementAmount } from './fx.js'
21
+ import { chargeAmount, settlementAmount } from './fx.js'
13
22
  import { stripeWebhookSecrets } from './webhook-manager.js'
14
- import type { CreateLinkParams, PaymentPlan, PaymentProduct } from '../types.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
+ }
15
36
 
16
37
  /**
17
38
  * A Checkout Session's tax and currency options, entirely driven by the declared `PricingPolicy` —
@@ -20,20 +41,24 @@ import type { CreateLinkParams, PaymentPlan, PaymentProduct } from '../types.js'
20
41
  *
21
42
  * `customer_update.address` lets automatic tax use the billing address Checkout just collected
22
43
  * rather than only a previously saved one; `customer_update.name` lets tax-id collection save the
23
- * business name it collects. Each is included only for the concern that needs it.
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.
24
47
  */
25
- const checkoutOptions = (policy: PricingPolicy, promotions: boolean): Partial<Stripe.Checkout.SessionCreateParams> => {
48
+ const checkoutOptions = (
49
+ policy: PricingPolicy, promotions: boolean, flags: CheckoutOptionsFlags = {},
50
+ ): Partial<Stripe.Checkout.SessionCreateParams> => {
26
51
  const customerUpdate: Stripe.Checkout.SessionCreateParams.CustomerUpdate = {}
27
- if (policy.tax.automatic) customerUpdate.address = 'auto'
52
+ if (policy.tax.automatic) customerUpdate.address = flags.locked === true ? 'never' : 'auto'
28
53
  if (policy.tax.collectTaxId) customerUpdate.name = 'auto'
29
54
 
30
55
  return {
31
56
  ...(policy.tax.automatic
32
- ? { automatic_tax: { enabled: true }, billing_address_collection: 'required' as const }
57
+ ? { automatic_tax: { enabled: true }, billing_address_collection: flags.locked === true ? 'auto' as const : 'required' as const }
33
58
  : {}),
34
59
  ...(policy.tax.collectTaxId ? { tax_id_collection: { enabled: true } } : {}),
35
60
  ...(Object.keys(customerUpdate).length > 0 ? { customer_update: customerUpdate } : {}),
36
- ...(policy.currency.adaptive === true ? { adaptive_pricing: { enabled: true } } : {}),
61
+ ...(policy.currency.adaptive === true && flags.adaptive !== false ? { adaptive_pricing: { enabled: true } } : {}),
37
62
  allow_promotion_codes: promotions,
38
63
  }
39
64
  }
@@ -125,92 +150,483 @@ export const quantityCheckoutLineItem = (
125
150
  quantity: policy.default,
126
151
  })
127
152
 
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
+ }
341
+
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
420
+ }
421
+ session = await createWithoutTerms(ctx, stripe, attempt, params, error)
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
+
128
451
  /**
129
452
  * A Stripe Checkout URL: an amount or quantity purchase of a consumable product, or a subscription
130
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.
131
460
  */
132
- export const createCheckoutLink = async (ctx: ApiContext, stripe: Stripe, params: CreateLinkParams): Promise<string> => {
461
+ export const createCheckoutLink = async (
462
+ ctx: ApiContext, stripe: Stripe, params: CreateLinkParams, plugins: readonly CheckoutPlugin[] = [],
463
+ ): Promise<string> => {
133
464
  const product = await payment(ctx).product(params.productSku) as PaymentProduct
134
465
  const plans = (await payment(ctx).allPlans(product.sku) as PaymentPlan[])
135
466
  .filter(plan => isSoldThrough(product, plan, STRIPE_PAYGATE_ALIAS))
136
467
  if (params.planSku != null && !plans.some(plan => plan.sku === params.planSku)) {
137
468
  throw new ProductError(`plan:${params.planSku}`)
138
469
  }
139
- const customer = await ensureStripeCustomer(ctx, stripe, params)
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)
140
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
+ }
141
488
 
142
- if (product.type === ProductType.Consumable) {
143
- const plan = plans.find(item => item.sku === params.planSku) ?? plans[0]
144
- if (plan == null) throw new ProductError('plan')
145
-
146
- if (plan.pricingMode === CheckoutPricingMode.Amount) {
147
- if (params.amountMinor == null) throw new ProductError('amount')
148
- const { chargeMinor: sourceChargeMinor, currency: amountCurrency } = amountCheckoutLineItem(
149
- product, plan, params.amountMinor, pricing.tax.behavior,
150
- )
151
- const settled = await settlementAmount(ctx, stripe, sourceChargeMinor, amountCurrency)
152
- const lineItem: Stripe.Checkout.SessionCreateParams.LineItem = {
153
- price_data: {
154
- product: product.sku, currency: settled.currency, unit_amount: settled.amountMinor,
155
- tax_behavior: pricing.tax.behavior,
156
- },
157
- quantity: 1,
158
- }
159
- const session = await stripe.checkout.sessions.create({
160
- mode: 'payment', line_items: [lineItem], invoice_creation: { enabled: true },
161
- ...checkoutOptions(pricing, false),
162
- ...sharedSession(customer, params, product, plan, {
163
- pricingMode: CheckoutPricingMode.Amount,
164
- currency: settled.currency,
165
- amountCurrency,
166
- amountMinor: String(params.amountMinor),
167
- sourceChargeAmountMinor: String(sourceChargeMinor),
168
- chargeAmountMinor: String(settled.amountMinor),
169
- }),
170
- })
171
- if (session.url == null) throw new PaygateError('session')
172
- return session.url
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,
518
+ },
519
+ quantity: 1,
173
520
  }
174
521
 
175
- const price = await findPrice(stripe, product.sku, planLookupKey(product, plan))
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
+ }),
540
+ })
541
+ }
542
+
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) {
176
555
  const quantityPolicy = plan.quantityPolicy ?? {
177
556
  minimum: plan.minQuantity ?? 1,
178
557
  maximum: plan.maxQuantity ?? Math.max(100_000, plan.minQuantity ?? 1),
179
558
  default: plan.defaultQuantity ?? plan.minQuantity ?? 1,
180
559
  }
181
- const session = await stripe.checkout.sessions.create({
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
+ })
565
+
566
+ return await createSession(ctx, stripe, plugins, { ...base, mode: 'quantity', currency }, {
182
567
  mode: 'payment',
183
568
  line_items: [quantityCheckoutLineItem(price, quantityPolicy)],
184
569
  invoice_creation: { enabled: true },
185
- ...checkoutOptions(pricing, true),
186
- ...sharedSession(customer, params, product, plan, { pricingMode: CheckoutPricingMode.Quantity }),
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
+ }),
187
580
  })
188
- if (session.url == null) throw new PaygateError('session')
189
- return session.url
190
581
  }
191
582
 
192
- const plan = plans.find(item => item.sku === params.planSku)
193
- ?? plans.find(item => item.recurring != null) ?? plans[0]
194
- if (plan == null) throw new ProductError('plan')
195
- const price = await findPrice(stripe, product.sku, planLookupKey(product, plan))
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
196
586
  const subscriptionPaymentMethodTypes = (
197
587
  await stripePricingConfig(ctx)
198
588
  )?.subscriptionPaymentMethodTypes as Stripe.Checkout.SessionCreateParams.PaymentMethodType[] | undefined
199
- const session = await stripe.checkout.sessions.create({
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 }, {
200
613
  mode: 'subscription', line_items: [{ price: price.id, quantity: 1 }],
614
+ ...currencyParam,
201
615
  ...(subscriptionPaymentMethodTypes != null ? { payment_method_types: subscriptionPaymentMethodTypes } : {}),
202
- ...(params.submitText != null ? { custom_text: { submit: { message: params.submitText } } } : {}),
616
+ ...(Object.keys(customText).length > 0 ? { custom_text: customText } : {}),
203
617
  subscription_data: {
204
618
  metadata: {
205
619
  pricingMode: CheckoutPricingMode.Quantity, entityId: params.entityId,
206
620
  service: params.service, productSku: product.sku, planSku: plan.sku,
621
+ ...(params.profileId != null ? { profileId: params.profileId } : {}),
622
+ ...metadata,
207
623
  },
208
624
  },
209
- ...checkoutOptions(pricing, true),
210
- ...sharedSession(customer, params, product, plan, {}),
625
+ ...checkoutOptions(pricing, true, { locked: buyer.addressLocked, adaptive }),
626
+ ...termsParams,
627
+ ...expiry,
628
+ ...sharedSession(customer, params, product, plan, metadata),
211
629
  })
212
- if (session.url == null) throw new PaygateError('session')
213
- return session.url
214
630
  }
215
631
 
216
632
  interface WebhookRequest {