@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
package/src/sync.ts CHANGED
@@ -1,24 +1,100 @@
1
1
  import { createHash } from 'node:crypto'
2
2
  import type Stripe from 'stripe'
3
- import { CheckoutPricingMode, ProductType } from '@owlmeans/payment'
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
- import { fingerprints, payment, stripeClient } from './utils.js'
7
- import type { PaymentPlan, PaymentProduct } from './types.js'
7
+ import { fingerprints, payment, stripeClient, stripePricingConfig } from './utils.js'
8
+ import type { PaymentPlan, PaymentProduct, SyncedPrice, SyncedPriceOption } from './types.js'
9
+ import { settlementAmount } from './plugins/fx.js'
10
+ import type { StripeFxRateCache } from './plugins/fx.js'
11
+
12
+ interface ResolvedPlan {
13
+ plan: PaymentPlan
14
+ unitAmount: number
15
+ currency: string
16
+ sourceUnitAmount: number
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
+ }
51
+ }
8
52
 
9
53
  export const planLookupKey = (product: PaymentProduct, plan: PaymentPlan): string =>
10
54
  product.type === ProductType.Consumable ? `${product.sku}-consumable` : plan.sku
11
55
 
12
- const fingerprintOf = (product: PaymentProduct, plans: PaymentPlan[]): string => createHash('sha256')
56
+ /** Whether a plan is sold through a paygate: never a free plan; otherwise its own gateways, else the product's. */
57
+ export const isSoldThrough = (product: PaymentProduct, plan: PaymentPlan, paygate: string): boolean =>
58
+ plan.free !== true && (plan.gateways ?? product.gateways ?? []).includes(paygate)
59
+
60
+ /** Every product sold through Stripe with the plans it sells there. */
61
+ export const stripePlansOf = async (ctx: ApiContext): Promise<Array<{ product: PaymentProduct, plans: PaymentPlan[] }>> => {
62
+ const products = await payment(ctx).products() as PaymentProduct[]
63
+ const result: Array<{ product: PaymentProduct, plans: PaymentPlan[] }> = []
64
+ for (const product of products) {
65
+ if (!(product.gateways ?? []).includes(STRIPE_PAYGATE_ALIAS)) {
66
+ continue
67
+ }
68
+ const plans = (await payment(ctx).allPlans(product.sku) as PaymentPlan[])
69
+ .filter(plan => isSoldThrough(product, plan, STRIPE_PAYGATE_ALIAS))
70
+ if (plans.length > 0) {
71
+ result.push({ product, plans })
72
+ }
73
+ }
74
+
75
+ return result
76
+ }
77
+
78
+ /**
79
+ * `behavior` is hashed alongside the catalogue: an undeclared policy hashes as `null`, so declaring
80
+ * or changing `tax.behavior` re-syncs every product exactly once, the same as any other catalogue
81
+ * edit.
82
+ */
83
+ const fingerprintOf = (
84
+ product: PaymentProduct, plans: ResolvedPlan[], behavior: TaxBehavior | null,
85
+ ): string => createHash('sha256')
13
86
  .update(JSON.stringify({
14
87
  sku: product.sku, type: product.type, name: product.title,
15
88
  description: product.description ?? null, taxCode: product.taxCode ?? null,
16
89
  unitLabel: product.unitLabel ?? null, services: [...(product.services ?? [])].sort(),
17
- plans: plans.map(plan => ({
18
- sku: plan.sku, price: plan.price, currency: plan.currency ?? 'usd', duration: plan.duration,
19
- recurring: plan.recurring ?? null, pricingMode: plan.pricingMode ?? null,
90
+ behavior,
91
+ plans: plans.map(({ plan, unitAmount, currency, sourceUnitAmount, sourceCurrency, options }) => ({
92
+ sku: plan.sku, price: plan.price, currency, unitAmount, sourceUnitAmount, sourceCurrency,
93
+ duration: plan.duration, recurring: plan.recurring ?? null, pricingMode: plan.pricingMode ?? null,
20
94
  amountPolicy: plan.amountPolicy ?? null, quantityPolicy: plan.quantityPolicy ?? null,
21
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 } : {}),
22
98
  })).sort((a, b) => a.sku.localeCompare(b.sku)),
23
99
  })).digest('hex')
24
100
 
@@ -38,7 +114,23 @@ const ensureStripeProduct = async (stripe: Stripe, product: PaymentProduct): Pro
38
114
  }
39
115
 
40
116
  const activePrices = async (stripe: Stripe, product: PaymentProduct): Promise<Stripe.Price[]> =>
41
- (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
+ }
42
134
 
43
135
  const deactivateAmountPrice = async (stripe: Stripe, product: PaymentProduct, plan: PaymentPlan): Promise<void> => {
44
136
  const lookup = planLookupKey(product, plan)
@@ -47,51 +139,179 @@ const deactivateAmountPrice = async (stripe: Stripe, product: PaymentProduct, pl
47
139
  }
48
140
  }
49
141
 
50
- const ensureStripePrice = async (stripe: Stripe, product: PaymentProduct, plan: PaymentPlan): Promise<void> => {
142
+ /** Whether `price` already carries the OPPOSITE of `behavior` — never `unspecified`, which is not a conflict. */
143
+ const opposesBehavior = (price: Stripe.Price, behavior: TaxBehavior | null): boolean =>
144
+ behavior != null && price.tax_behavior !== 'unspecified' && price.tax_behavior !== behavior
145
+
146
+ /**
147
+ * The behavior the Stripe account's own Tax Settings resolve to for `currency`, or `null` when no
148
+ * default is configured yet (nothing established to disrupt). `inferred_by_currency` follows
149
+ * Stripe's own rule: exclusive for USD/CAD, inclusive otherwise.
150
+ */
151
+ const accountDefaultBehavior = async (stripe: Stripe, currency: string): Promise<TaxBehavior | null> => {
152
+ const { defaults } = await stripe.tax.settings.retrieve()
153
+ if (defaults.tax_behavior === 'inferred_by_currency') {
154
+ return ['usd', 'cad'].includes(currency.toLowerCase()) ? TaxBehavior.Exclusive : TaxBehavior.Inclusive
155
+ }
156
+ if (defaults.tax_behavior === 'exclusive') return TaxBehavior.Exclusive
157
+ if (defaults.tax_behavior === 'inclusive') return TaxBehavior.Inclusive
158
+ return null
159
+ }
160
+
161
+ /**
162
+ * Give `price` the declared `behavior` while it is still `unspecified` (the only state Stripe lets
163
+ * an existing price's `tax_behavior` be set from). Skipped, with a `console.error`, when the
164
+ * account's own default resolves to the opposite behavior — applying ours would then change what an
165
+ * existing renewal actually charges — unless `migrateUnspecifiedPrices` opts into that migration.
166
+ */
167
+ const applyUnspecifiedBehavior = async (
168
+ stripe: Stripe, price: Stripe.Price, lookupKey: string, behavior: TaxBehavior, migrateUnspecifiedPrices: boolean,
169
+ ): Promise<void> => {
170
+ if (!migrateUnspecifiedPrices) {
171
+ const resolved = await accountDefaultBehavior(stripe, price.currency)
172
+ if (resolved != null && resolved !== behavior) {
173
+ console.error(
174
+ `[payment] price '${lookupKey}' left 'unspecified': the Stripe account's default tax `
175
+ + `behavior for ${price.currency.toUpperCase()} is '${resolved}', not the declared `
176
+ + `'${behavior}' — applying it would change existing renewal amounts. Set `
177
+ + '`stripe.migrateUnspecifiedPrices` to override.',
178
+ )
179
+ return
180
+ }
181
+ }
182
+ await stripe.prices.update(price.id, { tax_behavior: behavior })
183
+ }
184
+
185
+ const ensureStripePrice = async (
186
+ stripe: Stripe, product: PaymentProduct, resolved: ResolvedPlan,
187
+ behavior: TaxBehavior | null, migrateUnspecifiedPrices: boolean,
188
+ ): Promise<Stripe.Price | null> => {
189
+ const { plan, unitAmount, currency, options } = resolved
51
190
  if (plan.pricingMode === CheckoutPricingMode.Amount) {
52
191
  await deactivateAmountPrice(stripe, product, plan)
53
- return
192
+ return null
54
193
  }
55
194
  const lookupKey = planLookupKey(product, plan)
56
- const unitAmount = Math.round(plan.price * 100)
57
- const currency = plan.currency ?? 'usd'
58
195
  const recurring = plan.recurring != null
59
196
  ? { interval: plan.recurring.interval } as Stripe.PriceCreateParams.Recurring : undefined
60
197
  const existing = await activePrices(stripe, product)
61
- const match = existing.find(price => price.lookup_key === lookupKey && price.unit_amount === unitAmount
198
+ const candidate = existing.find(price => price.lookup_key === lookupKey && price.unit_amount === unitAmount
62
199
  && price.currency === currency
63
- && ((price.recurring?.interval ?? null) === (recurring?.interval ?? null)))
64
- if (match != null) return
200
+ && ((price.recurring?.interval ?? null) === (recurring?.interval ?? null))
201
+ && optionsMatch(price, options, behavior))
202
+ if (candidate != null && !opposesBehavior(candidate, behavior)) {
203
+ if (behavior != null && candidate.tax_behavior === 'unspecified') {
204
+ await applyUnspecifiedBehavior(stripe, candidate, lookupKey, behavior, migrateUnspecifiedPrices)
205
+ }
206
+ return candidate
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.
65
210
  for (const price of existing.filter(item => item.lookup_key === lookupKey)) {
66
211
  await stripe.prices.update(price.id, { active: false })
67
212
  }
68
- await stripe.prices.create({
213
+
214
+ return await stripe.prices.create({
69
215
  product: product.sku, currency, unit_amount: unitAmount, lookup_key: lookupKey,
70
216
  transfer_lookup_key: true, nickname: plan.sku,
71
217
  ...(recurring != null ? { recurring } : { billing_scheme: 'per_unit' }),
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
+ } : {}),
72
224
  metadata: { sku: plan.sku, ...(product.services && { services: product.services.join(',') }) },
73
225
  })
74
226
  }
75
227
 
76
- export const initialize = async (ctx: ApiContext): Promise<void> => {
77
- const all = await payment(ctx).products() as PaymentProduct[]
78
- const products = all.filter(product => (product.gateways ?? []).includes(STRIPE_PAYGATE_ALIAS))
79
- if (products.length === 0) return
80
- const stripe = await stripeClient(ctx)
228
+ /**
229
+ * Synchronize every product sold through Stripe, and its Stripe-sold plans, to Stripe products and
230
+ * prices. A product whose declaration fingerprint is unchanged makes no paygate call. Free plans
231
+ * and plans sold through no Stripe gateway are never synchronized.
232
+ *
233
+ * The declared `PricingPolicy.tax.behavior` (absent by default, so a price's `tax_behavior` stays
234
+ * whatever it already was) is applied to a matching price only while it is `unspecified` — Stripe
235
+ * forbids changing a price once set to `exclusive` or `inclusive` — and to a fresh one on creation.
236
+ * A price carrying the OPPOSITE behavior is deactivated and replaced, same as any other mismatch.
237
+ */
238
+ export const syncStripeProducts = async (ctx: ApiContext, stripe: Stripe): Promise<void> => {
81
239
  const fpRes = fingerprints(ctx)
82
- for (const product of products) {
83
- const plans = await payment(ctx).allPlans(product.sku) as PaymentPlan[]
84
- const hash = fingerprintOf(product, plans)
240
+ const behavior = (await payment(ctx).pricingPolicy()).tax.behavior ?? null
241
+ const migrateUnspecifiedPrices = (await stripePricingConfig(ctx))?.migrateUnspecifiedPrices ?? false
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()))
246
+ for (const { product, plans } of await stripePlansOf(ctx)) {
247
+ const resolvedPlans: ResolvedPlan[] = []
248
+ for (const plan of plans) {
249
+ const sourceUnitAmount = Math.round(plan.price * 100)
250
+ const sourceCurrency = (plan.currency ?? 'usd').toLowerCase()
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
+ })
261
+ }
262
+ const hash = fingerprintOf(product, resolvedPlans, behavior)
85
263
  const stored = await fpRes.bySku(product.sku)
86
- 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
87
266
  const stripeProduct = await ensureStripeProduct(stripe, product)
88
- for (const plan of plans) await ensureStripePrice(stripe, product, plan)
267
+ const prices: SyncedPrice[] = []
268
+ const syncedAt = new Date()
269
+ for (const resolved of resolvedPlans) {
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
+ }
280
+ }
89
281
  if (stored != null) {
90
- Object.assign(stored, { hash, productId: stripeProduct.id, updatedAt: new Date() })
91
- await fpRes.save(stored)
282
+ await fpRes.update({ ...stored, hash, productId: stripeProduct.id, prices, updatedAt: new Date() })
92
283
  } else {
93
- 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() })
94
285
  }
95
286
  console.info(`[payment] synced product '${product.sku}' to Stripe (${plans.length} plan(s))`)
96
287
  }
97
288
  }
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
+
315
+ /** `syncStripeProducts` with this context's own Stripe client. */
316
+ export const syncPaymentProducts = async (ctx: ApiContext): Promise<void> =>
317
+ await syncStripeProducts(ctx, await stripeClient(ctx))