@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
@@ -0,0 +1,155 @@
1
+ import { CheckoutLimitExceeded, narrowAmountPolicy } from '@owlmeans/payment'
2
+ import type { AmountNarrowing, AmountPolicyView } from '@owlmeans/payment'
3
+ import type { Context as ApiContext } from '@owlmeans/server-api'
4
+ import { STRIPE_SESSION_TTL_MAX_SECONDS, STRIPE_SESSION_TTL_MIN_SECONDS } from '../consts.js'
5
+ import type {
6
+ CheckoutAttempt, CheckoutNarrowInput, CheckoutPlugin, CheckoutSettled,
7
+ } from '../types.js'
8
+
9
+ export interface CheckoutPluginRegistry {
10
+ use: (plugin: CheckoutPlugin) => void
11
+ list: () => readonly CheckoutPlugin[]
12
+ }
13
+
14
+ /**
15
+ * The gateway's checkout plugins, the way `ExecutionService.use` seats its own: a plugin with an
16
+ * alias already registered replaces it in place (a layer wired twice must not answer twice), one
17
+ * without an alias is appended.
18
+ */
19
+ export const makeCheckoutPluginRegistry = (): CheckoutPluginRegistry => {
20
+ const plugins: CheckoutPlugin[] = []
21
+
22
+ return {
23
+ use: plugin => {
24
+ const at = plugin.alias != null ? plugins.findIndex(entry => entry.alias === plugin.alias) : -1
25
+ if (at < 0) {
26
+ plugins.push(plugin)
27
+ } else {
28
+ plugins[at] = plugin
29
+ }
30
+ },
31
+ list: () => [...plugins],
32
+ }
33
+ }
34
+
35
+ /**
36
+ * An entity's amount policy as every plugin narrows it now — the ONE computation both
37
+ * `gateway.amountPolicy` and checkout enforcement use, so a control and a refusal cannot disagree.
38
+ * A plugin's error propagates: a narrowing that cannot be computed fails closed.
39
+ */
40
+ export const narrowAmountFor = async (
41
+ ctx: ApiContext, plugins: readonly CheckoutPlugin[], input: CheckoutNarrowInput,
42
+ ): Promise<AmountPolicyView> => {
43
+ const narrowings: AmountNarrowing[] = []
44
+ for (const plugin of plugins) {
45
+ if (plugin.narrow == null) {
46
+ continue
47
+ }
48
+ const narrowing = await plugin.narrow(ctx, input)
49
+ if (narrowing != null) {
50
+ narrowings.push(narrowing)
51
+ }
52
+ }
53
+
54
+ return narrowAmountPolicy(input.base, narrowings, {
55
+ productSku: input.productSku, ...(input.planSku != null ? { planSku: input.planSku } : {}),
56
+ })
57
+ }
58
+
59
+ /**
60
+ * Refuse an amount the narrowed view does not allow: any amount while `blocked`, else one above
61
+ * the narrowed maximum.
62
+ *
63
+ * @throws CheckoutLimitExceeded (409)
64
+ */
65
+ export const assertAmountAllowed = (view: AmountPolicyView, amountMinor: number): void => {
66
+ const limit = view.limit
67
+ if (limit == null || (!limit.blocked && amountMinor <= limit.maximumMinor)) {
68
+ return
69
+ }
70
+ throw new CheckoutLimitExceeded({
71
+ reason: limit.reason ?? 'limit', maximumMinor: limit.maximumMinor, currency: limit.currency,
72
+ ...(limit.resetsAt != null ? { resetsAt: limit.resetsAt } : {}),
73
+ })
74
+ }
75
+
76
+ /**
77
+ * The session lifetime: the smallest `sessionTtlSeconds` any plugin declares, clamped to Stripe's
78
+ * 30 minutes – 24 hours; `undefined` when none declares one (Stripe's own default).
79
+ */
80
+ export const sessionTtlOf = (plugins: readonly CheckoutPlugin[]): number | undefined => {
81
+ const declared = plugins
82
+ .map(plugin => plugin.sessionTtlSeconds)
83
+ .filter((ttl): ttl is number => typeof ttl === 'number' && Number.isFinite(ttl))
84
+ if (declared.length === 0) {
85
+ return undefined
86
+ }
87
+
88
+ return Math.min(STRIPE_SESSION_TTL_MAX_SECONDS, Math.max(STRIPE_SESSION_TTL_MIN_SECONDS, Math.floor(Math.min(...declared))))
89
+ }
90
+
91
+ /** A plugin that admitted an attempt, with the reservation it holds. */
92
+ export interface Admitted {
93
+ plugin: CheckoutPlugin
94
+ reservationId?: string
95
+ }
96
+
97
+ /**
98
+ * Tell plugins how a checkout ended. A plugin's error is logged, never raised: a hold carries its
99
+ * own TTL, and the webhook that reports the outcome must not be redelivered for it.
100
+ */
101
+ export const settleCheckout = async (
102
+ ctx: ApiContext, plugins: readonly CheckoutPlugin[], settled: CheckoutSettled,
103
+ ): Promise<void> => {
104
+ for (const plugin of plugins) {
105
+ if (plugin.settled == null) {
106
+ continue
107
+ }
108
+ try {
109
+ await plugin.settled(ctx, settled)
110
+ } catch (error) {
111
+ console.error(`[payment] checkout plugin "${plugin.alias ?? 'anonymous'}" failed to settle`, error)
112
+ }
113
+ }
114
+ }
115
+
116
+ /** Release what admitted plugins hold for a checkout that never became usable. */
117
+ export const releaseAdmitted = async (
118
+ ctx: ApiContext, admitted: readonly Admitted[], attempt: CheckoutAttempt, sessionId?: string,
119
+ ): Promise<void> => {
120
+ for (const { plugin, reservationId } of admitted) {
121
+ await settleCheckout(ctx, [plugin], {
122
+ entityId: attempt.entityId, productSku: attempt.productSku,
123
+ ...(attempt.planSku != null ? { planSku: attempt.planSku } : {}),
124
+ ...(sessionId != null ? { sessionId } : {}),
125
+ ...(reservationId != null ? { reservationId } : {}),
126
+ ...(attempt.amountMinor != null ? { amountMinor: attempt.amountMinor } : {}),
127
+ outcome: 'failed', at: new Date(),
128
+ })
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Ask every plugin to admit the attempt, in order. A veto releases what the earlier plugins
134
+ * admitted and propagates.
135
+ */
136
+ export const admitCheckout = async (
137
+ ctx: ApiContext, plugins: readonly CheckoutPlugin[], attempt: CheckoutAttempt,
138
+ ): Promise<Admitted[]> => {
139
+ const admitted: Admitted[] = []
140
+ for (const plugin of plugins) {
141
+ if (plugin.admit == null) {
142
+ admitted.push({ plugin })
143
+ continue
144
+ }
145
+ try {
146
+ const admission = await plugin.admit(ctx, attempt)
147
+ admitted.push({ plugin, ...(admission?.reservationId != null ? { reservationId: admission.reservationId } : {}) })
148
+ } catch (error) {
149
+ await releaseAdmitted(ctx, admitted.filter(entry => entry.plugin.admit != null), attempt)
150
+ throw error
151
+ }
152
+ }
153
+
154
+ return admitted
155
+ }
@@ -1,14 +1,16 @@
1
1
  import type Stripe from 'stripe'
2
2
  import {
3
- chargeAmountMinor, CheckoutPricingMode, currencyOfCountry, ProductError, ratePpmOf, TaxBehavior,
4
- TaxEstimateStatus, TaxType, UnknownProduct,
3
+ chargeAmountMinor, chargeCurrencyOf, CheckoutPricingMode, currencyOfCountry, ProductError, ratePpmOf,
4
+ regionOf, TaxBehavior, TaxEstimateStatus, TaxType, UnknownProduct,
5
5
  } from '@owlmeans/payment'
6
6
  import type { PriceEstimate, TaxEstimate, TaxRateEstimate } from '@owlmeans/payment'
7
7
  import type { Context as ApiContext } from '@owlmeans/server-api'
8
8
  import { STRIPE_FX_QUOTES_API_VERSION, STRIPE_PAYGATE_ALIAS } from '../consts.js'
9
9
  import { findProduct } from '../plan.js'
10
10
  import { isSoldThrough } from '../sync.js'
11
- import { isMissingObject, paygateCustomers, payment, stripePricingConfig } from '../utils.js'
11
+ import {
12
+ consumerRightsOf, fingerprints, isMissingObject, paygateCustomers, payment, stripePricingConfig,
13
+ } from '../utils.js'
12
14
  import type { PaymentPlan, PaymentProduct, PriceEstimateParams } from '../types.js'
13
15
  import { stripeFxRate } from './fx.js'
14
16
 
@@ -158,6 +160,30 @@ const referenceOf = (plan: PaymentPlan): { subtotalMinor: number; currency: stri
158
160
  return { subtotalMinor: Math.round(plan.price * 100), currency: (plan.currency ?? 'usd').toLowerCase() }
159
161
  }
160
162
 
163
+ /**
164
+ * The reference amount in the currency the buyer is charged in: a recurring or quantity plan's
165
+ * synced price in that currency (its default or an option — no paygate call), else the plan's own
166
+ * catalogue reference. An amount plan keeps its policy currency.
167
+ */
168
+ const chargedReferenceOf = async (
169
+ ctx: ApiContext, product: PaymentProduct, plan: PaymentPlan, chargeCurrency: string | null,
170
+ ): Promise<{ subtotalMinor: number; currency: string }> => {
171
+ const reference = referenceOf(plan)
172
+ if (chargeCurrency == null || plan.pricingMode === CheckoutPricingMode.Amount || chargeCurrency === reference.currency && plan.currencyPrices == null) {
173
+ return reference
174
+ }
175
+ const synced = (await fingerprints(ctx).bySku(product.sku))?.prices?.find(price => price.planSku === plan.sku)
176
+ if (synced == null) {
177
+ return reference
178
+ }
179
+ if (synced.currency === chargeCurrency) {
180
+ return { subtotalMinor: synced.unitAmount, currency: chargeCurrency }
181
+ }
182
+ const option = synced.options.find(entry => entry.currency === chargeCurrency)
183
+
184
+ return option != null ? { subtotalMinor: option.unitAmount, currency: chargeCurrency } : reference
185
+ }
186
+
161
187
  /** A placeholder estimate — no known tax, shown by its `status`, never by its zeroed numbers. */
162
188
  const unresolvedEstimate = (status: TaxEstimateStatus, subtotalMinor: number): TaxEstimate => ({
163
189
  status, subtotalMinor, taxMinor: 0, totalMinor: subtotalMinor, scalable: false, rates: [],
@@ -214,10 +240,13 @@ export const estimateStripePrice = async (
214
240
 
215
241
  const pricing = await payment(ctx).pricingPolicy()
216
242
  const behavior = pricing.tax.behavior ?? TaxBehavior.Exclusive
217
- const { subtotalMinor, currency } = referenceOf(plan)
243
+ const rights = await payment(ctx).consumerRightsPolicy()
244
+ // A locked billing country overrides whatever the request names: a picker shows it, locked.
245
+ const profile = rights != null ? await consumerRightsOf(ctx)?.profile(params.entityId) ?? null : null
246
+ const locked = profile?.locked === true && profile.country != null
218
247
 
219
- let country = params.country?.toUpperCase()
220
- let source: 'request' | 'customer' | undefined = country != null ? 'request' : undefined
248
+ let country = locked ? profile.country as string : params.country?.toUpperCase()
249
+ let source: 'request' | 'customer' | 'profile' | undefined = locked ? 'profile' : country != null ? 'request' : undefined
221
250
  let matchingTaxIds: Array<{ type: string; value: string }> = []
222
251
  let taxabilityOverride: 'customer_exempt' | 'reverse_charge' | undefined
223
252
 
@@ -236,8 +265,16 @@ export const estimateStripePrice = async (
236
265
  }
237
266
  }
238
267
 
268
+ const region = rights != null ? regionOf(country, rights) : null
269
+ const settlementCurrency = (await stripePricingConfig(ctx))?.settlementCurrency?.toLowerCase()
270
+ const { subtotalMinor, currency } = await chargedReferenceOf(ctx, product, plan, rights != null && rights.currencies != null
271
+ && Object.keys(rights.currencies).length > 0
272
+ ? (profile?.currency ?? chargeCurrencyOf(region, rights, settlementCurrency ?? (plan.currency ?? 'usd'))).toLowerCase()
273
+ : null)
274
+ const where = { ...(region != null ? { region } : {}), ...(locked ? { locked: true } : {}) }
275
+
239
276
  if (country == null) {
240
- return { currency, behavior, tax: unresolvedEstimate(TaxEstimateStatus.LocationRequired, subtotalMinor) }
277
+ return { currency, behavior, tax: unresolvedEstimate(TaxEstimateStatus.LocationRequired, subtotalMinor), ...where }
241
278
  }
242
279
 
243
280
  const cacheKey = JSON.stringify([
@@ -276,9 +313,12 @@ export const estimateStripePrice = async (
276
313
  ? taxEstimateOf(outcome.calculation, behavior, subtotalMinor)
277
314
  : unresolvedEstimate(TaxEstimateStatus.AtCheckout, subtotalMinor)
278
315
 
279
- const result: PriceEstimate = { country, source, currency, behavior, tax }
316
+ const result: PriceEstimate = { country, source, currency, behavior, tax, ...where }
317
+ // Adaptive Pricing — and so a local-currency line — applies only to a session charged in the
318
+ // settlement currency; a forced region currency (exact USD) is shown as it is.
319
+ const adaptive = rights?.currencies == null || currency === (settlementCurrency ?? currency)
280
320
 
281
- if (pricing.currency.estimate && pricing.currency.adaptive === true) {
321
+ if (pricing.currency.estimate && pricing.currency.adaptive === true && adaptive) {
282
322
  const localCurrency = currencyOfCountry(country)
283
323
  if (localCurrency != null && localCurrency !== currency) {
284
324
  const stripePricing = await stripePricingConfig(ctx)
@@ -1,21 +1,27 @@
1
1
  import type Stripe from 'stripe'
2
2
  import { Mutex } from 'async-mutex'
3
3
  import {
4
- CheckoutPricingMode, PaygateError, SubscriptionStatus, TERMINAL_STATUSES,
4
+ CheckoutPricingMode, ENTITLING_STATUSES, PaygateError, SubscriptionStatus, TERMINAL_STATUSES,
5
5
  } from '@owlmeans/payment'
6
6
  import type { Context as ApiContext } from '@owlmeans/server-api'
7
- import { STRIPE_PAYGATE_ALIAS } from '../consts.js'
7
+ import { GATEWAY_SERVICE, STRIPE_PAYGATE_ALIAS } from '../consts.js'
8
8
  import { findPlan, planRank } from '../plan.js'
9
9
  import { commitSubscription } from '../subscription.js'
10
10
  import type { CommitResult } from '../subscription.js'
11
11
  import {
12
- compact, dateOf, fulfillments, idOf, isDuplicateKey, isMissingObject, observer, paygateCustomers,
13
- subscriptions,
12
+ compact, dateOf, fulfillments, gateway, idOf, isDuplicateKey, isMissingObject, observer, paygateCustomers,
13
+ purchases, subscriptions,
14
14
  } from '../utils.js'
15
+ import {
16
+ capturePaymentPurchase, captureSubscriptionPurchase, completeSubscriptionPurchase, sessionEvidenceOf,
17
+ } from '../consumer/capture.js'
18
+ import { patchPurchase, purchaseIdOf } from '../consumer/records.js'
19
+ import { settleCheckout } from './checkout-plugins.js'
15
20
  import { resolvePaymentTarget, retrieveCharge } from './refunds.js'
16
21
  import type { PaymentTarget } from './refunds.js'
17
22
  import type {
18
- DisputePhase, PaymentFulfillmentRecord, PaymentSubscriptionRecord, SubscriptionRef, TopUpCompletion,
23
+ CheckoutOutcome, CheckoutPlugin, DisputePhase, PaymentFulfillmentRecord, PaymentSubscriptionRecord,
24
+ SubscriptionRef, TopUpCompletion,
19
25
  } from '../types.js'
20
26
 
21
27
  const customerMutex: Record<string, Mutex> = {}
@@ -82,6 +88,8 @@ export interface ApplyOptions {
82
88
  trialEnding?: boolean
83
89
  /** Store this status whatever the payload says (a deleted subscription is canceled). */
84
90
  forced?: SubscriptionStatus
91
+ /** The paygate client — lets the first commit read the first invoice for its purchase row. */
92
+ stripe?: Stripe
85
93
  }
86
94
 
87
95
  const secondsFloor = (at: Date): Date => new Date(Math.floor(at.getTime() / 1000) * 1000)
@@ -152,6 +160,7 @@ export const applySubscription = async (
152
160
  trialEnd: dateOf(subscription.trial_end),
153
161
  latestInvoiceId: opts.invoiceId ?? idOf(subscription.latest_invoice) ?? previous?.latestInvoiceId,
154
162
  customerId,
163
+ currency: subscription.currency?.toLowerCase() ?? previous?.currency,
155
164
  createdAt: previous?.createdAt ?? dateOf(subscription.created) ?? now,
156
165
  updatedAt: now,
157
166
  syncedAt,
@@ -160,6 +169,12 @@ export const applySubscription = async (
160
169
 
161
170
  return await commitSubscription(ctx, previous, next, {
162
171
  eventId: opts.eventId, renewal: opts.renewal, invoiceId: opts.invoiceId, trialEnding: opts.trialEnding,
172
+ // The first invoice is a purchase: its window exists before an observer grants the bundle.
173
+ beforePropagate: async (record, change) => {
174
+ if (change === 'created') {
175
+ await captureSubscriptionPurchase(ctx, opts.stripe ?? null, subscription, record)
176
+ }
177
+ },
163
178
  })
164
179
  }
165
180
 
@@ -211,7 +226,7 @@ export const resyncStripeSubscription = async (
211
226
  const subscription = await retrieveSubscription(stripe, id)
212
227
  const customerId = subscription != null ? idOf(subscription.customer) : undefined
213
228
  const result = await withCustomerLock(customerId, async () => subscription != null
214
- ? await applySubscription(ctx, subscription, { source: 'resync' })
229
+ ? await applySubscription(ctx, subscription, { source: 'resync', stripe })
215
230
  : await cancelMissing(ctx, id))
216
231
  if (result.updated) {
217
232
  updated++
@@ -267,6 +282,11 @@ const DISPUTE_PHASES: Record<string, DisputePhase> = {
267
282
 
268
283
  type EventHandler = (event: Stripe.Event) => Promise<void>
269
284
 
285
+ /** The registered checkout plugins — none in a context without a gateway service. */
286
+ const checkoutPluginsOf = (ctx: ApiContext): readonly CheckoutPlugin[] =>
287
+ (ctx as unknown as { hasService?: (alias: string) => boolean }).hasService?.(GATEWAY_SERVICE) === true
288
+ ? gateway(ctx).checkoutPlugins?.() ?? [] : []
289
+
270
290
  /**
271
291
  * The Stripe event dispatch table. `process` runs the handler of an event type and ignores every
272
292
  * other type; a throw escapes so Stripe delivers the event again.
@@ -279,6 +299,8 @@ export const createEventHandler = (ctx: ApiContext, stripe: Stripe) => {
279
299
  const data = compact({
280
300
  email: customer.email ?? undefined, name: customer.name ?? undefined,
281
301
  taxId: customer.tax_ids?.data?.[0]?.value ?? undefined,
302
+ country: customer.address?.country?.toUpperCase() ?? undefined,
303
+ currency: customer.currency?.toLowerCase() ?? undefined,
282
304
  entityId: customer.metadata?.entityId ?? existing?.entityId ?? undefined,
283
305
  profileId: customer.metadata?.profileId ?? existing?.profileId ?? undefined,
284
306
  })
@@ -303,7 +325,22 @@ export const createEventHandler = (ctx: ApiContext, stripe: Stripe) => {
303
325
  }
304
326
  }
305
327
 
328
+ const settle = async (session: Stripe.Checkout.Session, outcome: CheckoutOutcome, entityId?: string): Promise<void> => {
329
+ const metadata = session.metadata ?? {}
330
+ const owner = entityId ?? metadata.entityId
331
+ if (owner == null) return
332
+ const amount = Number(metadata.amountMinor)
333
+ await settleCheckout(ctx, checkoutPluginsOf(ctx), compact({
334
+ entityId: owner, productSku: metadata.productSku, planSku: metadata.planSku, sessionId: session.id, outcome,
335
+ amountMinor: Number.isSafeInteger(amount) && amount > 0 ? amount : undefined, at: new Date(),
336
+ }))
337
+ }
338
+
306
339
  const fulfillPayment = async (session: Stripe.Checkout.Session): Promise<void> => {
340
+ if (session.mode === 'subscription') {
341
+ await completeSubscriptionCheckout(session)
342
+ return
343
+ }
307
344
  if (session.mode !== 'payment' || session.payment_status !== 'paid') return
308
345
  const metadata = session.metadata ?? {}
309
346
  if (metadata.entityId == null || metadata.service == null || metadata.productSku == null) {
@@ -353,23 +390,90 @@ export const createEventHandler = (ctx: ApiContext, stripe: Stripe) => {
353
390
  completion = { ...base, mode: 'quantity', units }
354
391
  }
355
392
 
393
+ // The purchase — the withdrawal window and the contract — exists BEFORE the credits do; the
394
+ // billing country is locked with it.
395
+ const captured = await capturePaymentPurchase(ctx, stripe, session, compact({
396
+ netAmountMinor: completion.mode === 'amount' ? completion.amountMinor : undefined,
397
+ amountCurrency: completion.mode === 'amount' ? completion.amountCurrency : undefined,
398
+ units: completion.mode === 'quantity' ? completion.units : undefined,
399
+ }))
400
+ const evidence = sessionEvidenceOf(session)
356
401
  const identifiers = compact({
357
402
  paymentIntentId: idOf(session.payment_intent), invoiceId: idOf(session.invoice),
358
403
  })
404
+ const evidenceFields = compact({
405
+ country: evidence.country, email: evidence.email, profileId: metadata.profileId,
406
+ amountTotalMinor: session.amount_total ?? undefined, amountTaxMinor: session.total_details?.amount_tax ?? undefined,
407
+ termsAccepted: evidence.termsAccepted, purchaseId: captured?.purchase.purchaseId,
408
+ })
359
409
  if (stored == null) {
360
410
  try {
361
- stored = await ledger.create(compact({ ...base, ...record, ...identifiers, createdAt: new Date() }) as PaymentFulfillmentRecord)
411
+ stored = await ledger.create(compact({ ...base, ...record, ...identifiers, ...evidenceFields, createdAt: new Date() }) as PaymentFulfillmentRecord)
362
412
  } catch (error) {
363
413
  if (!isDuplicateKey(error)) throw error
364
414
  stored = await ledger.byExternalId(session.id, STRIPE_PAYGATE_ALIAS)
365
415
  if (stored == null || stored.fulfilledAt != null) return
366
416
  }
367
- } else if (stored.paymentIntentId == null && identifiers.paymentIntentId != null) {
368
- stored = await ledger.update({ ...stored, ...identifiers })
417
+ } else if ((stored.paymentIntentId == null && identifiers.paymentIntentId != null)
418
+ || (stored.purchaseId == null && evidenceFields.purchaseId != null)) {
419
+ stored = await ledger.update({ ...stored, ...identifiers, ...evidenceFields })
369
420
  }
370
421
  await observer(ctx).propagateTopUp(completion, ctx)
371
422
  await ledger.update({ ...stored, fulfilledAt: new Date() })
372
423
  })
424
+ await settle(session, 'paid')
425
+ }
426
+
427
+ /**
428
+ * A completed SUBSCRIPTION checkout: the subscription is applied if no webhook applied it yet
429
+ * (its first commit writes the purchase before the `created` observers), then the purchase is
430
+ * refined with the buyer's own country and totals, the billing country is locked, the evidence is
431
+ * stored on the subscription row and the confirmation mailed.
432
+ */
433
+ const completeSubscriptionCheckout = async (session: Stripe.Checkout.Session): Promise<void> => {
434
+ if (session.payment_status !== 'paid' && session.payment_status !== 'no_payment_required') return
435
+ const subscriptionId = idOf(session.subscription)
436
+ if (subscriptionId == null) return
437
+ await withCustomerLock(idOf(session.customer), async () => {
438
+ let row = await subscriptions(ctx).byExternalId(subscriptionId, STRIPE_PAYGATE_ALIAS)
439
+ let subscription: Stripe.Subscription | null = null
440
+ if (row?.propagated == null) {
441
+ subscription = await retrieveSubscription(stripe, subscriptionId)
442
+ if (subscription != null) {
443
+ row = (await applySubscription(ctx, subscription, { source: 'webhook', stripe })).record ?? row
444
+ }
445
+ }
446
+ if (row == null) {
447
+ console.warn(`[payment] completed checkout "${session.id}" names an unknown subscription "${subscriptionId}"`)
448
+ return
449
+ }
450
+ let purchase = await purchases(ctx).byPurchaseId(purchaseIdOf(subscriptionId))
451
+ if (purchase == null && ENTITLING_STATUSES.includes(row.status)) {
452
+ subscription = subscription ?? await retrieveSubscription(stripe, subscriptionId)
453
+ purchase = subscription != null
454
+ ? (await captureSubscriptionPurchase(ctx, stripe, subscription, row))?.purchase ?? null : null
455
+ }
456
+ if (purchase != null) {
457
+ purchase = await completeSubscriptionPurchase(ctx, stripe, session, purchase)
458
+ }
459
+ const evidence = sessionEvidenceOf(session)
460
+ const metadata = session.metadata ?? {}
461
+ const current = await subscriptions(ctx).byExternalId(subscriptionId, STRIPE_PAYGATE_ALIAS) ?? row
462
+ await subscriptions(ctx).update(compact({
463
+ ...current,
464
+ checkoutSessionId: session.id,
465
+ purchaseId: purchase?.purchaseId ?? current.purchaseId,
466
+ firstInvoiceId: current.firstInvoiceId ?? idOf(session.invoice),
467
+ currency: current.currency ?? (evidence.currency !== '' ? evidence.currency : undefined),
468
+ country: evidence.country ?? current.country,
469
+ email: evidence.email ?? current.email,
470
+ amountTotalMinor: evidence.totalMinor,
471
+ amountTaxMinor: evidence.taxMinor,
472
+ termsAccepted: evidence.termsAccepted ?? current.termsAccepted,
473
+ startRequestId: metadata.startRequestId ?? purchase?.startRequestId ?? current.startRequestId,
474
+ }))
475
+ })
476
+ await settle(session, 'paid')
373
477
  }
374
478
 
375
479
  const checkoutFailed = async (session: Stripe.Checkout.Session): Promise<void> => {
@@ -403,17 +507,19 @@ export const createEventHandler = (ctx: ApiContext, stripe: Stripe) => {
403
507
  entityId, kind: 'checkout' as const, externalId: session.id,
404
508
  subscriptionId: idOf(session.subscription), eventKey: `payment-failed:${session.id}:0`,
405
509
  }), ctx)
510
+ await settle(session, 'failed', entityId)
406
511
  }
407
512
 
408
513
  const purgeExpiredCheckout = async (session: Stripe.Checkout.Session): Promise<void> => {
409
514
  await fulfillments(ctx).purge({ paygate: STRIPE_PAYGATE_ALIAS, externalId: session.id, fulfilledAt: null })
515
+ await settle(session, 'expired')
410
516
  }
411
517
 
412
518
  const applyFromEvent = (opts: Partial<ApplyOptions> = {}): EventHandler => async event => {
413
519
  const subscription = event.data.object as Stripe.Subscription
414
520
  await withCustomerLock(idOf(subscription.customer), async () => {
415
521
  await applySubscription(ctx, subscription, {
416
- source: 'webhook', eventId: event.id, eventCreated: event.created, ...opts,
522
+ source: 'webhook', eventId: event.id, eventCreated: event.created, stripe, ...opts,
417
523
  })
418
524
  })
419
525
  }
@@ -429,7 +535,7 @@ export const createEventHandler = (ctx: ApiContext, stripe: Stripe) => {
429
535
  }
430
536
 
431
537
  return await withCustomerLock(idOf(subscription.customer), async () =>
432
- await applySubscription(ctx, subscription, { source: 'webhook', eventId: event.id, ...opts }))
538
+ await applySubscription(ctx, subscription, { source: 'webhook', eventId: event.id, stripe, ...opts }))
433
539
  }
434
540
 
435
541
  const invoicePaid: EventHandler = async event => {
@@ -488,6 +594,19 @@ export const createEventHandler = (ctx: ApiContext, stripe: Stripe) => {
488
594
  planSku: target.record.planSku, externalId: target.record.externalId,
489
595
  })
490
596
 
597
+ /** A refund of a purchase's payment moves its refunded total; a whole refund closes its window. */
598
+ const refundPurchase = async (target: PaymentTarget, refundedTotal: number, paid: number | undefined): Promise<void> => {
599
+ const purchaseId = purchaseIdOf(target.record.externalId)
600
+ const purchase = await purchases(ctx).byPurchaseId(purchaseId)
601
+ if (purchase == null || (target.kind === 'subscription' && purchase.invoiceId != null && purchase.invoiceId !== target.invoiceId)) {
602
+ return
603
+ }
604
+ await patchPurchase(ctx, purchaseId, compact({
605
+ refundedMinor: Math.max(purchase.refundedMinor ?? 0, refundedTotal),
606
+ refundedAt: purchase.refundedAt ?? (paid != null && refundedTotal >= paid ? new Date() : undefined),
607
+ }))
608
+ }
609
+
491
610
  const processRefund = async (refund: Stripe.Refund, charge?: Stripe.Charge | null): Promise<void> => {
492
611
  if (refund.status !== 'succeeded') return
493
612
  const target = await resolvePaymentTarget(ctx, stripe, {
@@ -507,6 +626,8 @@ export const createEventHandler = (ctx: ApiContext, stripe: Stripe) => {
507
626
  refundedAt: new Date(), chargeId: target.record.chargeId ?? refunded?.id,
508
627
  }))
509
628
  }
629
+ await refundPurchase(target, refundedTotal, paid)
630
+ const metadata = { ...(refund.metadata ?? {}) } as Record<string, string>
510
631
  await observer(ctx).propagateRefund(compact({
511
632
  ...targetFields(target),
512
633
  refundId: refund.id,
@@ -518,6 +639,9 @@ export const createEventHandler = (ctx: ApiContext, stripe: Stripe) => {
518
639
  currency: refund.currency,
519
640
  partial: paid != null ? refundedTotal < paid : false,
520
641
  eventKey: `refund:${refund.id}`,
642
+ metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
643
+ // Set by a withdrawal's own refund: its observer takes back the unused units, `onRefund` must not.
644
+ withdrawalId: metadata.withdrawalId != null && metadata.withdrawalId !== '' ? metadata.withdrawalId : undefined,
521
645
  }) as Parameters<ReturnType<typeof observer>['propagateRefund']>[0], ctx)
522
646
  }
523
647
 
package/src/plugins/fx.ts CHANGED
@@ -64,6 +64,35 @@ export interface SettlementAmount {
64
64
 
65
65
  export type StripeFxRateCache = Map<string, Promise<StripeFxRate | null>>
66
66
 
67
+ /**
68
+ * A catalogue amount in the currency the buyer is charged in: unchanged — no FX call — when the
69
+ * currencies are equal; otherwise converted at Stripe's FX reference rate, rounded up.
70
+ */
71
+ export const chargeAmount = async (
72
+ ctx: ApiContext, stripe: Stripe, sourceAmountMinor: number, sourceCurrency: string, chargeCurrency: string,
73
+ cache?: StripeFxRateCache,
74
+ ): Promise<SettlementAmount> => {
75
+ const source = sourceCurrency.toLowerCase()
76
+ const currency = chargeCurrency.toLowerCase()
77
+ if (currency === source) {
78
+ return { amountMinor: sourceAmountMinor, currency, sourceAmountMinor, sourceCurrency: source, referenceRate: 1 }
79
+ }
80
+ const apiVersion = (await stripePricingConfig(ctx))?.fxApiVersion ?? STRIPE_FX_QUOTES_API_VERSION
81
+ const key = `${source}:${currency}:${apiVersion}`
82
+ let pending = cache?.get(key)
83
+ if (pending == null) {
84
+ pending = stripeFxRate(stripe, source, currency, apiVersion)
85
+ cache?.set(key, pending)
86
+ }
87
+ const quote = await pending
88
+ if (quote == null) throw new PaygateError(`fx-rate:${source}:${currency}`)
89
+
90
+ return {
91
+ amountMinor: convertMinor(sourceAmountMinor, quote.referenceRate), currency,
92
+ sourceAmountMinor, sourceCurrency: source, referenceRate: quote.referenceRate,
93
+ }
94
+ }
95
+
67
96
  /** Translate a catalogue amount into the configured Stripe settlement currency. */
68
97
  export const settlementAmount = async (
69
98
  ctx: ApiContext, stripe: Stripe, sourceAmountMinor: number, sourceCurrency: string,
@@ -37,6 +37,8 @@ const recurringCatalog = async (ctx: ApiContext): Promise<Array<{ product: Payme
37
37
  hashable: recurring.map(plan => ({
38
38
  sku: plan.sku, price: plan.price, currency: plan.currency ?? 'usd', interval: plan.recurring?.interval,
39
39
  rank: planRank(plan), behavior,
40
+ // A changed currency option replaces the Price — the portal's price list must follow it.
41
+ ...(plan.currencyPrices != null ? { currencyPrices: plan.currencyPrices } : {}),
40
42
  })).sort((a, b) => a.sku.localeCompare(b.sku)),
41
43
  })
42
44
  }
@@ -114,9 +116,15 @@ export const ensurePortalConfiguration = async (
114
116
  const sku = portalFingerprintSku(service)
115
117
  const branding = await portalBrandingConfig(ctx)
116
118
  const catalog = await recurringCatalog(ctx)
119
+ const rights = await payment(ctx).consumerRightsPolicy()
120
+ // A locked billing country is never edited in the portal: tax follows the saved address.
121
+ const countryLock = rights?.mechanisms.countryLock === true
122
+ const regionCurrencies = Object.values(rights?.currencies ?? {}).filter(code => code != null).sort()
117
123
  const hash = createHash('sha256').update(JSON.stringify({
118
124
  service,
119
125
  deployment,
126
+ ...(countryLock ? { countryLock } : {}),
127
+ ...(regionCurrencies.length > 0 ? { regionCurrencies } : {}),
120
128
  branding: branding != null ? {
121
129
  headline: branding.headline ?? null, privacyPolicyUrl: branding.privacyPolicyUrl ?? null,
122
130
  termsOfServiceUrl: branding.termsOfServiceUrl ?? null, returnUrl: branding.returnUrl ?? null,
@@ -141,7 +149,9 @@ export const ensurePortalConfiguration = async (
141
149
  const metadata = { [STRIPE_OWNER_KEY]: STRIPE_OWNER_VALUE, service, [STRIPE_DEPLOYMENT_KEY]: deployment }
142
150
  const params: ConfigurationParams = {
143
151
  features: {
144
- customer_update: { enabled: true, allowed_updates: ['email', 'address', 'tax_id'] },
152
+ customer_update: {
153
+ enabled: true, allowed_updates: countryLock ? ['email', 'tax_id'] : ['email', 'address', 'tax_id'],
154
+ },
145
155
  invoice_history: { enabled: true },
146
156
  payment_method_update: { enabled: true },
147
157
  subscription_cancel: cancelable