@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
package/src/resource.ts CHANGED
@@ -1,18 +1,22 @@
1
1
  import type { ResourceMaker } from '@owlmeans/resource'
2
2
  import { makeMongoResource } from '@owlmeans/mongo-resource'
3
3
  import {
4
- RES_PAYGATE_CUSTOMER, RES_PAYMENT_FINGERPRINT, RES_PAYMENT_FULFILLMENT, RES_PAYMENT_SUBSCRIPTION,
4
+ RES_BILLING_PROFILE, RES_CONSUMER_CONSENT, RES_CONSUMER_DECLARATION, RES_CONSUMER_EVENT, RES_PAYGATE_CUSTOMER,
5
+ RES_PAYMENT_FINGERPRINT, RES_PAYMENT_FULFILLMENT, RES_PAYMENT_PURCHASE, RES_PAYMENT_SUBSCRIPTION,
5
6
  RES_PAYMENT_USAGE, RES_PAYMENT_USAGE_COUNTER, RES_PAYMENT_WEBHOOK,
6
7
  } from './consts.js'
7
8
  import {
8
- FingerprintSchema, PaygateCustomerSchema, PaymentFulfillmentSchema, PaymentSubscriptionSchema,
9
- PaymentUsageCounterSchema, PaymentUsageSchema, PaymentWebhookSchema,
9
+ BillingProfileSchema, ConsumerConsentSchema, ConsumerDeclarationSchema, ConsumerEventSchema, FingerprintSchema,
10
+ PaygateCustomerSchema, PaymentFulfillmentSchema, PaymentSubscriptionSchema, PaymentUsageCounterSchema,
11
+ PaymentUsageSchema, PaymentWebhookSchema, PurchaseSchema,
10
12
  } from './model.js'
11
13
  import type {
14
+ BillingProfileRecord, BillingProfileResource, ConsumerConsentRecord, ConsumerConsentResource,
15
+ ConsumerDeclarationRecord, ConsumerDeclarationResource, ConsumerEventRecord, ConsumerEventResource,
12
16
  FingerprintRecord, FingerprintResource, PaygateCustomerRecord, PaygateCustomerResource,
13
17
  PaymentFulfillmentRecord, PaymentFulfillmentResource, PaymentSubscriptionRecord,
14
18
  PaymentSubscriptionResource, PaymentUsageCounterRecord, PaymentUsageCounterResource, PaymentUsageRecord,
15
- PaymentUsageResource, PaymentWebhookRecord, PaymentWebhookResource,
19
+ PaymentUsageResource, PaymentWebhookRecord, PaymentWebhookResource, PurchaseRecord, PurchaseResource,
16
20
  } from './types.js'
17
21
 
18
22
  export const makePaygateCustomerResource: ResourceMaker<PaygateCustomerRecord, PaygateCustomerResource> = (
@@ -111,3 +115,82 @@ export const makeFingerprintResource: ResourceMaker<FingerprintRecord, Fingerpri
111
115
  resource.index('sku', { sku: 1 }, { unique: true })
112
116
  return resource
113
117
  }
118
+
119
+ export const makeBillingProfileResource: ResourceMaker<BillingProfileRecord, BillingProfileResource> = (
120
+ dbAlias, serviceAlias,
121
+ ) => {
122
+ const resource = makeMongoResource<BillingProfileRecord, BillingProfileResource>(
123
+ RES_BILLING_PROFILE, dbAlias, serviceAlias,
124
+ )
125
+ resource.byEntity = async entityId => await resource.load({ entityId })
126
+ resource.schema = BillingProfileSchema
127
+ // One profile per organization: the unique index is what makes the first lock the only one.
128
+ resource.index('entity', { entityId: 1 }, { unique: true })
129
+ resource.index('customer', { paygate: 1, customerId: 1 }, { sparse: true })
130
+ return resource
131
+ }
132
+
133
+ export const makePurchaseResource: ResourceMaker<PurchaseRecord, PurchaseResource> = (dbAlias, serviceAlias) => {
134
+ const resource = makeMongoResource<PurchaseRecord, PurchaseResource>(RES_PAYMENT_PURCHASE, dbAlias, serviceAlias)
135
+ resource.byPurchaseId = async purchaseId => await resource.load({ purchaseId })
136
+ resource.schema = PurchaseSchema
137
+ resource.index('purchase', { purchaseId: 1 }, { unique: true })
138
+ resource.index('contract', { contractRef: 1 }, { unique: true })
139
+ resource.index('entityDeadline', { entityId: 1, deadline: -1 })
140
+ resource.index('entityPurchased', { entityId: 1, purchasedAt: -1 })
141
+ // Single-field on purpose: a compound sparse index still indexes rows missing only `sessionId`
142
+ // (subscription purchases before their checkout is captured), and unique would collide on them.
143
+ resource.index('session', { sessionId: 1 }, { unique: true, sparse: true })
144
+ resource.index('subscription', { paygate: 1, subscriptionId: 1 }, { sparse: true })
145
+ resource.index('invoice', { invoiceId: 1 }, { sparse: true })
146
+ resource.index('invoiceNumber', { invoiceNumber: 1 }, { sparse: true })
147
+ resource.index('paymentIntent', { paygate: 1, paymentIntentId: 1 }, { sparse: true })
148
+ return resource
149
+ }
150
+
151
+ export const makeConsumerConsentResource: ResourceMaker<ConsumerConsentRecord, ConsumerConsentResource> = (
152
+ dbAlias, serviceAlias,
153
+ ) => {
154
+ const resource = makeMongoResource<ConsumerConsentRecord, ConsumerConsentResource>(
155
+ RES_CONSUMER_CONSENT, dbAlias, serviceAlias,
156
+ )
157
+ resource.schema = ConsumerConsentSchema
158
+ resource.index('entityDecided', { entityId: 1, decidedAt: -1 })
159
+ resource.index('kindPlan', { kind: 1, entityId: 1, planSku: 1, decidedAt: -1 })
160
+ return resource
161
+ }
162
+
163
+ export const makeConsumerDeclarationResource: ResourceMaker<ConsumerDeclarationRecord, ConsumerDeclarationResource> = (
164
+ dbAlias, serviceAlias,
165
+ ) => {
166
+ const resource = makeMongoResource<ConsumerDeclarationRecord, ConsumerDeclarationResource>(
167
+ RES_CONSUMER_DECLARATION, dbAlias, serviceAlias,
168
+ )
169
+ resource.schema = ConsumerDeclarationSchema
170
+ resource.index('kindReceived', { kind: 1, receivedAt: -1 })
171
+ resource.index('entityReceived', { entityId: 1, receivedAt: -1 }, { sparse: true })
172
+ resource.index('purchase', { purchaseId: 1 }, { sparse: true })
173
+ return resource
174
+ }
175
+
176
+ export const makeConsumerEventResource: ResourceMaker<ConsumerEventRecord, ConsumerEventResource> = (
177
+ dbAlias, serviceAlias,
178
+ ) => {
179
+ const resource = makeMongoResource<ConsumerEventRecord, ConsumerEventResource>(
180
+ RES_CONSUMER_EVENT, dbAlias, serviceAlias,
181
+ )
182
+ resource.schema = ConsumerEventSchema
183
+ resource.index('record', { recordId: 1, at: 1 })
184
+ resource.index('entityAt', { entityId: 1, at: -1 }, { sparse: true })
185
+ resource.index('actionOk', { action: 1, ok: 1, at: 1 })
186
+ return resource
187
+ }
188
+
189
+ /** The consumer-rights resources, alias → maker — registered by the gateway, each only when absent. */
190
+ export const CONSUMER_RIGHTS_RESOURCE_MAKERS = [
191
+ [RES_BILLING_PROFILE, makeBillingProfileResource],
192
+ [RES_PAYMENT_PURCHASE, makePurchaseResource],
193
+ [RES_CONSUMER_CONSENT, makeConsumerConsentResource],
194
+ [RES_CONSUMER_DECLARATION, makeConsumerDeclarationResource],
195
+ [RES_CONSUMER_EVENT, makeConsumerEventResource],
196
+ ] as const
package/src/service.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  ENTITLEMENT_SERVICE, GATEWAY_SERVICE, RES_PAYGATE_CUSTOMER, RES_PAYMENT_FINGERPRINT, RES_PAYMENT_FULFILLMENT,
11
11
  RES_PAYMENT_SUBSCRIPTION, RES_PAYMENT_USAGE, RES_PAYMENT_USAGE_COUNTER, RES_PAYMENT_WEBHOOK,
12
12
  } from './consts.js'
13
+ import { registerConsumerRights } from './consumer/service.js'
13
14
  import { makeEntitlementService } from './entitlement.js'
14
15
  import { makeCapabilityGate } from './gate.js'
15
16
  import { makeLimitGate } from './limit.js'
@@ -18,17 +19,18 @@ import { findPlan, findProduct, planRank } from './plan.js'
18
19
  import { resyncStripeSubscription, resyncStripeSubscriptions } from './plugins/events.js'
19
20
  import { makeEstimateCache, estimateStripePrice } from './plugins/estimate.js'
20
21
  import { createPortalLink, ensurePortalConfiguration } from './plugins/portal.js'
21
- import { createCheckoutLink } from './plugins/stripe.js'
22
+ import { makeCheckoutPluginRegistry, narrowAmountFor } from './plugins/checkout-plugins.js'
23
+ import { consumablePlanOf, createCheckoutLink } from './plugins/stripe.js'
22
24
  import { ensureWebhookEndpoint } from './plugins/webhook-manager.js'
23
25
  import {
24
26
  makeFingerprintResource, makeFulfillmentResource, makePaygateCustomerResource, makeSubscriptionResource,
25
27
  makeUsageCounterResource, makeUsageResource, makeWebhookResource,
26
28
  } from './resource.js'
27
29
  import { commitSubscription } from './subscription.js'
28
- import { syncStripeProducts } from './sync.js'
29
- import { stripeClient, subscriptions } from './utils.js'
30
+ import { syncedPlanPrices, syncStripeProducts } from './sync.js'
31
+ import { consumerRightsOf, stripeClient, subscriptions } from './utils.js'
30
32
  import type {
31
- Config, Context, GatewayService, GrantInternalPlanOptions, PaymentGatewayOptions,
33
+ Config, Context, GatewayService, GrantInternalPlanOptions, PaymentGatewayOptions, PaymentPlan,
32
34
  PaymentSubscriptionRecord,
33
35
  } from './types.js'
34
36
 
@@ -107,10 +109,22 @@ export const makeGatewayService = (
107
109
  // One estimate cache per gateway SERVICE instance, never module-level: several service
108
110
  // instances (several tests, several deployments in one process) must never share hits.
109
111
  const estimateCache = makeEstimateCache()
112
+ // Checkout plugins are seated per gateway instance, like the estimate cache.
113
+ const plugins = makeCheckoutPluginRegistry()
110
114
  const service = createService<GatewayService>(alias, {
111
115
  managed,
112
116
  createLink: async (ctx, params) => managed
113
- ? await createCheckoutLink(ctx, await stripeClient(ctx), params) : unmanaged(),
117
+ ? await createCheckoutLink(ctx, await stripeClient(ctx), params, plugins.list()) : unmanaged(),
118
+ use: plugin => { plugins.use(plugin) },
119
+ checkoutPlugins: () => plugins.list(),
120
+ amountPolicy: async (ctx, entityId, productSku, planSku) => {
121
+ const { plan } = await consumablePlanOf(ctx, productSku, planSku)
122
+ if (plan.amountPolicy == null) throw new ProductError(`amount-policy:${plan.sku}`)
123
+ return await narrowAmountFor(ctx, plugins.list(), {
124
+ entityId, productSku, planSku: (plan as PaymentPlan).sku, base: plan.amountPolicy, at: new Date(),
125
+ })
126
+ },
127
+ planPrices: async (ctx, productSku) => await syncedPlanPrices(ctx, productSku),
114
128
  portalLink: async (ctx, entityId, link) => managed
115
129
  ? await createPortalLink(ctx, await stripeClient(ctx), entityId, link) : unmanaged(),
116
130
  grantInternalPlan: async (ctx, entityId, planSku, grant) => await grantInternalPlan(ctx, entityId, planSku, grant),
@@ -124,6 +138,9 @@ export const makeGatewayService = (
124
138
  const ctx = service.assertCtx() as unknown as ApiContext
125
139
  assertPlanDeclarations(ctx.cfg)
126
140
  service.initialized = true
141
+ // The consumer-rights service is lazy (reachable while the application is wired): initialize it
142
+ // with the gateway, so its boot checks run at boot.
143
+ consumerRightsOf(ctx)
127
144
  if (managed) {
128
145
  void ctx.waitForInitialized().then(async () => {
129
146
  await bootstrapStripe(ctx, await stripeClient(ctx))
@@ -136,7 +153,8 @@ export const makeGatewayService = (
136
153
 
137
154
  /**
138
155
  * Register the payment resources, the catalogue service, the completion observer, the gateway, both
139
- * gate services and the entitlement service — each only when not registered yet.
156
+ * gate services, the entitlement service and the consumer-rights records and service — each only
157
+ * when not registered yet.
140
158
  *
141
159
  * `manage: false` registers the same surface for a process that reads entitlements but never talks
142
160
  * to Stripe.
@@ -164,6 +182,10 @@ export const appendPaymentGatewayService = <C extends Config, T extends Context<
164
182
  if (!ctx.hasService(ENTITLEMENT_GATE)) ctx.registerService(makeCapabilityGate())
165
183
  if (!ctx.hasService(LIMIT_GATE)) ctx.registerService(makeLimitGate())
166
184
  if (!ctx.hasService(ENTITLEMENT_SERVICE)) ctx.registerService(makeEntitlementService())
185
+ // The consumer-rights records and service, with this gateway's `manage` unless the application
186
+ // gave its own (before or after this call): the webhook writes purchases and locks, and an
187
+ // unmanaged process still reads and asserts consent.
188
+ registerConsumerRights(ctx, { manage: opts?.manage, dbAlias: opts?.dbAlias, serviceAlias: opts?.serviceAlias }, 'gateway')
167
189
 
168
190
  return ctx
169
191
  }
@@ -14,6 +14,12 @@ export interface CommitOptions {
14
14
  invoiceId?: string
15
15
  /** The paygate announced the trial ends soon. */
16
16
  trialEnding?: boolean
17
+ /**
18
+ * Runs after the new state is written and BEFORE observers hear the change — what must exist
19
+ * before an observer grants anything (a subscription's purchase row). A throw propagates: the
20
+ * observers are not told and the paygate retries.
21
+ */
22
+ beforePropagate?: (record: PaymentSubscriptionRecord, change: SubscriptionChange) => Promise<void>
17
23
  }
18
24
 
19
25
  export interface CommitResult {
@@ -205,6 +211,7 @@ export const commitSubscription = async (
205
211
  }
206
212
 
207
213
  const stored = await write(next)
214
+ await opts.beforePropagate?.(stored, change)
208
215
  await observer(ctx).propagateSubscription({
209
216
  change,
210
217
  previous: prior != null && previous != null ? await snapshotOf(ctx, previous, prior) : null,
package/src/sync.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import { createHash } from 'node:crypto'
2
2
  import type Stripe from 'stripe'
3
3
  import { CheckoutPricingMode, ProductType, TaxBehavior } from '@owlmeans/payment'
4
+ import type { PlanPriceView } from '@owlmeans/payment'
4
5
  import type { Context as ApiContext } from '@owlmeans/server-api'
5
6
  import { STRIPE_PAYGATE_ALIAS } from './consts.js'
6
7
  import { fingerprints, payment, stripeClient, stripePricingConfig } from './utils.js'
7
- import type { PaymentPlan, PaymentProduct } from './types.js'
8
+ import type { PaymentPlan, PaymentProduct, SyncedPrice, SyncedPriceOption } from './types.js'
8
9
  import { settlementAmount } from './plugins/fx.js'
9
10
  import type { StripeFxRateCache } from './plugins/fx.js'
10
11
 
@@ -14,6 +15,39 @@ interface ResolvedPlan {
14
15
  currency: string
15
16
  sourceUnitAmount: number
16
17
  sourceCurrency: string
18
+ /** `currency_options` besides `currency`, sorted by currency. */
19
+ options: SyncedPriceOption[]
20
+ }
21
+
22
+ /**
23
+ * The exact prices a plan is also charged in: its declared `currencyPrices`, and its catalogue
24
+ * currency when that is one of the consumer-rights region currencies but not the Price's default
25
+ * (a USD catalogue price synced as EUR keeps an exact USD option). A declared price in the default
26
+ * currency replaces the converted default amount instead.
27
+ */
28
+ const optionsOf = (
29
+ plan: PaymentPlan, currency: string, sourceUnitAmount: number, sourceCurrency: string, regionCurrencies: Set<string>,
30
+ ): { unitAmount?: number, options: SyncedPriceOption[] } => {
31
+ const options = new Map<string, number>()
32
+ if (sourceCurrency !== currency && regionCurrencies.has(sourceCurrency)) {
33
+ options.set(sourceCurrency, sourceUnitAmount)
34
+ }
35
+ let unitAmount: number | undefined
36
+ for (const [raw, amount] of Object.entries(plan.currencyPrices ?? {})) {
37
+ const code = raw.toLowerCase()
38
+ const minor = Math.round(amount * 100)
39
+ if (code === currency) {
40
+ unitAmount = minor
41
+ } else {
42
+ options.set(code, minor)
43
+ }
44
+ }
45
+
46
+ return {
47
+ ...(unitAmount != null ? { unitAmount } : {}),
48
+ options: [...options.entries()].map(([code, amount]) => ({ currency: code, unitAmount: amount }))
49
+ .sort((a, b) => a.currency.localeCompare(b.currency)),
50
+ }
17
51
  }
18
52
 
19
53
  export const planLookupKey = (product: PaymentProduct, plan: PaymentPlan): string =>
@@ -54,11 +88,13 @@ const fingerprintOf = (
54
88
  description: product.description ?? null, taxCode: product.taxCode ?? null,
55
89
  unitLabel: product.unitLabel ?? null, services: [...(product.services ?? [])].sort(),
56
90
  behavior,
57
- plans: plans.map(({ plan, unitAmount, currency, sourceUnitAmount, sourceCurrency }) => ({
91
+ plans: plans.map(({ plan, unitAmount, currency, sourceUnitAmount, sourceCurrency, options }) => ({
58
92
  sku: plan.sku, price: plan.price, currency, unitAmount, sourceUnitAmount, sourceCurrency,
59
93
  duration: plan.duration, recurring: plan.recurring ?? null, pricingMode: plan.pricingMode ?? null,
60
94
  amountPolicy: plan.amountPolicy ?? null, quantityPolicy: plan.quantityPolicy ?? null,
61
95
  lookup: planLookupKey(product, plan),
96
+ // Only when present, so a catalogue without options keeps the fingerprint it always had.
97
+ ...(options.length > 0 ? { options } : {}),
62
98
  })).sort((a, b) => a.sku.localeCompare(b.sku)),
63
99
  })).digest('hex')
64
100
 
@@ -78,7 +114,23 @@ const ensureStripeProduct = async (stripe: Stripe, product: PaymentProduct): Pro
78
114
  }
79
115
 
80
116
  const activePrices = async (stripe: Stripe, product: PaymentProduct): Promise<Stripe.Price[]> =>
81
- (await stripe.prices.list({ product: product.sku, active: true, limit: 100 })).data
117
+ (await stripe.prices.list({ product: product.sku, active: true, limit: 100, expand: ['data.currency_options'] })).data
118
+
119
+ /** A price's `currency_options` besides its default currency (read only once expanded). */
120
+ const priceOptionsOf = (price: Stripe.Price): Record<string, { unit_amount?: number | null, tax_behavior?: string | null }> =>
121
+ Object.fromEntries(Object.entries(price.currency_options ?? {}).filter(([code]) => code !== price.currency))
122
+
123
+ /** Whether a price carries exactly these options (and, with a declared behavior, with that behavior). */
124
+ const optionsMatch = (price: Stripe.Price, options: SyncedPriceOption[], behavior: TaxBehavior | null): boolean => {
125
+ const current = priceOptionsOf(price)
126
+ if (Object.keys(current).length !== options.length) {
127
+ return false
128
+ }
129
+
130
+ return options.every(option => current[option.currency]?.unit_amount === option.unitAmount
131
+ && (behavior == null || current[option.currency]?.tax_behavior == null
132
+ || current[option.currency]?.tax_behavior === 'unspecified' || current[option.currency]?.tax_behavior === behavior))
133
+ }
82
134
 
83
135
  const deactivateAmountPrice = async (stripe: Stripe, product: PaymentProduct, plan: PaymentPlan): Promise<void> => {
84
136
  const lookup = planLookupKey(product, plan)
@@ -133,11 +185,11 @@ const applyUnspecifiedBehavior = async (
133
185
  const ensureStripePrice = async (
134
186
  stripe: Stripe, product: PaymentProduct, resolved: ResolvedPlan,
135
187
  behavior: TaxBehavior | null, migrateUnspecifiedPrices: boolean,
136
- ): Promise<void> => {
137
- const { plan, unitAmount, currency } = resolved
188
+ ): Promise<Stripe.Price | null> => {
189
+ const { plan, unitAmount, currency, options } = resolved
138
190
  if (plan.pricingMode === CheckoutPricingMode.Amount) {
139
191
  await deactivateAmountPrice(stripe, product, plan)
140
- return
192
+ return null
141
193
  }
142
194
  const lookupKey = planLookupKey(product, plan)
143
195
  const recurring = plan.recurring != null
@@ -145,21 +197,30 @@ const ensureStripePrice = async (
145
197
  const existing = await activePrices(stripe, product)
146
198
  const candidate = existing.find(price => price.lookup_key === lookupKey && price.unit_amount === unitAmount
147
199
  && price.currency === currency
148
- && ((price.recurring?.interval ?? null) === (recurring?.interval ?? null)))
200
+ && ((price.recurring?.interval ?? null) === (recurring?.interval ?? null))
201
+ && optionsMatch(price, options, behavior))
149
202
  if (candidate != null && !opposesBehavior(candidate, behavior)) {
150
203
  if (behavior != null && candidate.tax_behavior === 'unspecified') {
151
204
  await applyUnspecifiedBehavior(stripe, candidate, lookupKey, behavior, migrateUnspecifiedPrices)
152
205
  }
153
- return
206
+ return candidate
154
207
  }
208
+ // A changed option replaces the Price like any other change — options are never edited in place,
209
+ // so a subscriber keeps exactly the Price (and currency amounts) they accepted.
155
210
  for (const price of existing.filter(item => item.lookup_key === lookupKey)) {
156
211
  await stripe.prices.update(price.id, { active: false })
157
212
  }
158
- await stripe.prices.create({
213
+
214
+ return await stripe.prices.create({
159
215
  product: product.sku, currency, unit_amount: unitAmount, lookup_key: lookupKey,
160
216
  transfer_lookup_key: true, nickname: plan.sku,
161
217
  ...(recurring != null ? { recurring } : { billing_scheme: 'per_unit' }),
162
218
  ...(behavior != null ? { tax_behavior: behavior } : {}),
219
+ ...(options.length > 0 ? {
220
+ currency_options: Object.fromEntries(options.map(option => [option.currency, {
221
+ unit_amount: option.unitAmount, ...(behavior != null ? { tax_behavior: behavior } : {}),
222
+ }])),
223
+ } : {}),
163
224
  metadata: { sku: plan.sku, ...(product.services && { services: product.services.join(',') }) },
164
225
  })
165
226
  }
@@ -179,32 +240,78 @@ export const syncStripeProducts = async (ctx: ApiContext, stripe: Stripe): Promi
179
240
  const behavior = (await payment(ctx).pricingPolicy()).tax.behavior ?? null
180
241
  const migrateUnspecifiedPrices = (await stripePricingConfig(ctx))?.migrateUnspecifiedPrices ?? false
181
242
  const fxRates: StripeFxRateCache = new Map()
243
+ const rights = await payment(ctx).consumerRightsPolicy()
244
+ const regionCurrencies = new Set(Object.values(rights?.currencies ?? {})
245
+ .filter((code): code is string => typeof code === 'string').map(code => code.toLowerCase()))
182
246
  for (const { product, plans } of await stripePlansOf(ctx)) {
183
247
  const resolvedPlans: ResolvedPlan[] = []
184
248
  for (const plan of plans) {
185
249
  const sourceUnitAmount = Math.round(plan.price * 100)
186
250
  const sourceCurrency = (plan.currency ?? 'usd').toLowerCase()
187
- const settled = plan.pricingMode === CheckoutPricingMode.Amount
188
- ? { amountMinor: sourceUnitAmount, currency: sourceCurrency }
189
- : await settlementAmount(ctx, stripe, sourceUnitAmount, sourceCurrency, fxRates)
190
- resolvedPlans.push({ plan, unitAmount: settled.amountMinor, currency: settled.currency, sourceUnitAmount, sourceCurrency })
251
+ if (plan.pricingMode === CheckoutPricingMode.Amount) {
252
+ resolvedPlans.push({ plan, unitAmount: sourceUnitAmount, currency: sourceCurrency, sourceUnitAmount, sourceCurrency, options: [] })
253
+ continue
254
+ }
255
+ const settled = await settlementAmount(ctx, stripe, sourceUnitAmount, sourceCurrency, fxRates)
256
+ const { unitAmount, options } = optionsOf(plan, settled.currency, sourceUnitAmount, sourceCurrency, regionCurrencies)
257
+ resolvedPlans.push({
258
+ plan, unitAmount: unitAmount ?? settled.amountMinor, currency: settled.currency, sourceUnitAmount, sourceCurrency,
259
+ options,
260
+ })
191
261
  }
192
262
  const hash = fingerprintOf(product, resolvedPlans, behavior)
193
263
  const stored = await fpRes.bySku(product.sku)
194
- if (stored != null && stored.hash === hash) continue
264
+ // A row from before prices were persisted syncs once more, so `planPrices` can read it.
265
+ if (stored != null && stored.hash === hash && stored.prices != null) continue
195
266
  const stripeProduct = await ensureStripeProduct(stripe, product)
267
+ const prices: SyncedPrice[] = []
268
+ const syncedAt = new Date()
196
269
  for (const resolved of resolvedPlans) {
197
- await ensureStripePrice(stripe, product, resolved, behavior, migrateUnspecifiedPrices)
270
+ const price = await ensureStripePrice(stripe, product, resolved, behavior, migrateUnspecifiedPrices)
271
+ if (price != null) {
272
+ prices.push({
273
+ planSku: resolved.plan.sku, priceId: price.id, lookupKey: planLookupKey(product, resolved.plan),
274
+ currency: resolved.currency, unitAmount: resolved.unitAmount, options: resolved.options,
275
+ ...(behavior != null ? { taxBehavior: behavior } : price.tax_behavior != null ? { taxBehavior: price.tax_behavior } : {}),
276
+ ...(resolved.plan.recurring != null ? { interval: resolved.plan.recurring.interval } : {}),
277
+ sourceUnitAmount: resolved.sourceUnitAmount, sourceCurrency: resolved.sourceCurrency, syncedAt,
278
+ })
279
+ }
198
280
  }
199
281
  if (stored != null) {
200
- await fpRes.update({ ...stored, hash, productId: stripeProduct.id, updatedAt: new Date() })
282
+ await fpRes.update({ ...stored, hash, productId: stripeProduct.id, prices, updatedAt: new Date() })
201
283
  } else {
202
- await fpRes.create({ sku: product.sku, hash, productId: stripeProduct.id, updatedAt: new Date() })
284
+ await fpRes.create({ sku: product.sku, hash, productId: stripeProduct.id, prices, updatedAt: new Date() })
203
285
  }
204
286
  console.info(`[payment] synced product '${product.sku}' to Stripe (${plans.length} plan(s))`)
205
287
  }
206
288
  }
207
289
 
290
+ /**
291
+ * The prices a product's plans are charged at, per currency, as the last sync stored them — no
292
+ * paygate call, so it works in an unmanaged process. One entry for each Price's default currency
293
+ * (`default: true`) and one per currency option.
294
+ */
295
+ export const syncedPlanPrices = async (ctx: ApiContext, productSku: string): Promise<PlanPriceView[]> => {
296
+ const row = await fingerprints(ctx).bySku(productSku)
297
+ const behaviorOf = (value: string | undefined): TaxBehavior | undefined =>
298
+ value === TaxBehavior.Exclusive || value === TaxBehavior.Inclusive ? value : undefined
299
+ const views: PlanPriceView[] = []
300
+ for (const price of row?.prices ?? []) {
301
+ const taxBehavior = behaviorOf(price.taxBehavior ?? undefined)
302
+ const shared = {
303
+ planSku: price.planSku, ...(taxBehavior != null ? { taxBehavior } : {}),
304
+ ...(price.interval != null ? { interval: price.interval } : {}),
305
+ }
306
+ views.push({ ...shared, currency: price.currency, unitAmountMinor: price.unitAmount, default: true })
307
+ for (const option of price.options ?? []) {
308
+ views.push({ ...shared, currency: option.currency, unitAmountMinor: option.unitAmount, default: false })
309
+ }
310
+ }
311
+
312
+ return views
313
+ }
314
+
208
315
  /** `syncStripeProducts` with this context's own Stripe client. */
209
316
  export const syncPaymentProducts = async (ctx: ApiContext): Promise<void> =>
210
317
  await syncStripeProducts(ctx, await stripeClient(ctx))