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