@owlmeans/server-payment 0.1.18-rc.3 → 0.1.18-rc.30

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 (276) hide show
  1. package/README.md +146 -25
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/server-payment/SKILL.md +593 -53
  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 +6 -0
  11. package/build/actions/resync-subscriptions.js.map +1 -0
  12. package/build/actions/resync.d.ts +6 -1
  13. package/build/actions/resync.d.ts.map +1 -1
  14. package/build/actions/resync.js +12 -4
  15. package/build/actions/resync.js.map +1 -1
  16. package/build/actions/webhook.d.ts +2 -1
  17. package/build/actions/webhook.d.ts.map +1 -1
  18. package/build/actions/webhook.js +9 -4
  19. package/build/actions/webhook.js.map +1 -1
  20. package/build/config.d.ts +60 -5
  21. package/build/config.d.ts.map +1 -1
  22. package/build/config.js +248 -5
  23. package/build/config.js.map +1 -1
  24. package/build/consts.d.ts +74 -16
  25. package/build/consts.d.ts.map +1 -1
  26. package/build/consts.js +86 -23
  27. package/build/consts.js.map +1 -1
  28. package/build/consts.local.d.ts +94 -0
  29. package/build/consts.local.d.ts.map +1 -0
  30. package/build/consts.local.js +44 -0
  31. package/build/consts.local.js.map +1 -0
  32. package/build/consumer/capture.d.ts +61 -0
  33. package/build/consumer/capture.d.ts.map +1 -0
  34. package/build/consumer/capture.js +300 -0
  35. package/build/consumer/capture.js.map +1 -0
  36. package/build/consumer/consts.local.d.ts +10 -0
  37. package/build/consumer/consts.local.d.ts.map +1 -0
  38. package/build/consumer/consts.local.js +11 -0
  39. package/build/consumer/consts.local.js.map +1 -0
  40. package/build/consumer/format.d.ts +27 -0
  41. package/build/consumer/format.d.ts.map +1 -0
  42. package/build/consumer/format.js +81 -0
  43. package/build/consumer/format.js.map +1 -0
  44. package/build/consumer/handlers.d.ts +21 -0
  45. package/build/consumer/handlers.d.ts.map +1 -0
  46. package/build/consumer/handlers.js +173 -0
  47. package/build/consumer/handlers.js.map +1 -0
  48. package/build/consumer/index.d.ts +7 -0
  49. package/build/consumer/index.d.ts.map +1 -0
  50. package/build/consumer/index.js +6 -0
  51. package/build/consumer/index.js.map +1 -0
  52. package/build/consumer/mail.d.ts +27 -0
  53. package/build/consumer/mail.d.ts.map +1 -0
  54. package/build/consumer/mail.js +314 -0
  55. package/build/consumer/mail.js.map +1 -0
  56. package/build/consumer/origin.d.ts +14 -0
  57. package/build/consumer/origin.d.ts.map +1 -0
  58. package/build/consumer/origin.js +46 -0
  59. package/build/consumer/origin.js.map +1 -0
  60. package/build/consumer/reconcile.d.ts +12 -0
  61. package/build/consumer/reconcile.d.ts.map +1 -0
  62. package/build/consumer/reconcile.js +316 -0
  63. package/build/consumer/reconcile.js.map +1 -0
  64. package/build/consumer/records.d.ts +108 -0
  65. package/build/consumer/records.d.ts.map +1 -0
  66. package/build/consumer/records.js +299 -0
  67. package/build/consumer/records.js.map +1 -0
  68. package/build/consumer/service.d.ts +44 -0
  69. package/build/consumer/service.d.ts.map +1 -0
  70. package/build/consumer/service.js +771 -0
  71. package/build/consumer/service.js.map +1 -0
  72. package/build/consumer/types.d.ts +87 -0
  73. package/build/consumer/types.d.ts.map +1 -0
  74. package/build/consumer/types.js +2 -0
  75. package/build/consumer/types.js.map +1 -0
  76. package/build/consumer/types.local.d.ts +25 -0
  77. package/build/consumer/types.local.d.ts.map +1 -0
  78. package/build/consumer/types.local.js +2 -0
  79. package/build/consumer/types.local.js.map +1 -0
  80. package/build/consumer/withdrawal.d.ts +34 -0
  81. package/build/consumer/withdrawal.d.ts.map +1 -0
  82. package/build/consumer/withdrawal.js +247 -0
  83. package/build/consumer/withdrawal.js.map +1 -0
  84. package/build/entitlement.d.ts +10 -0
  85. package/build/entitlement.d.ts.map +1 -0
  86. package/build/entitlement.js +69 -0
  87. package/build/entitlement.js.map +1 -0
  88. package/build/entrypoints.d.ts +19 -2
  89. package/build/entrypoints.d.ts.map +1 -1
  90. package/build/entrypoints.js +16 -4
  91. package/build/entrypoints.js.map +1 -1
  92. package/build/gate.d.ts +20 -6
  93. package/build/gate.d.ts.map +1 -1
  94. package/build/gate.js +59 -19
  95. package/build/gate.js.map +1 -1
  96. package/build/index.d.ts +20 -3
  97. package/build/index.d.ts.map +1 -1
  98. package/build/index.js +17 -3
  99. package/build/index.js.map +1 -1
  100. package/build/limit.d.ts +11 -0
  101. package/build/limit.d.ts.map +1 -0
  102. package/build/limit.js +48 -0
  103. package/build/limit.js.map +1 -0
  104. package/build/log.d.ts +3 -0
  105. package/build/log.d.ts.map +1 -0
  106. package/build/log.js +4 -0
  107. package/build/log.js.map +1 -0
  108. package/build/observer.d.ts +9 -0
  109. package/build/observer.d.ts.map +1 -1
  110. package/build/observer.js +38 -12
  111. package/build/observer.js.map +1 -1
  112. package/build/plan.d.ts +33 -0
  113. package/build/plan.d.ts.map +1 -0
  114. package/build/plan.js +101 -0
  115. package/build/plan.js.map +1 -0
  116. package/build/plugins/checkout-plugins.d.ts +41 -0
  117. package/build/plugins/checkout-plugins.d.ts.map +1 -0
  118. package/build/plugins/checkout-plugins.js +125 -0
  119. package/build/plugins/checkout-plugins.js.map +1 -0
  120. package/build/plugins/consts.local.d.ts +13 -0
  121. package/build/plugins/consts.local.d.ts.map +1 -0
  122. package/build/plugins/consts.local.js +22 -0
  123. package/build/plugins/consts.local.js.map +1 -0
  124. package/build/plugins/estimate.d.ts +18 -0
  125. package/build/plugins/estimate.d.ts.map +1 -0
  126. package/build/plugins/estimate.js +278 -0
  127. package/build/plugins/estimate.js.map +1 -0
  128. package/build/plugins/events.d.ts +31 -10
  129. package/build/plugins/events.d.ts.map +1 -1
  130. package/build/plugins/events.js +625 -136
  131. package/build/plugins/events.js.map +1 -1
  132. package/build/plugins/fx.d.ts +28 -0
  133. package/build/plugins/fx.d.ts.map +1 -0
  134. package/build/plugins/fx.js +83 -0
  135. package/build/plugins/fx.js.map +1 -0
  136. package/build/plugins/portal.d.ts +40 -0
  137. package/build/plugins/portal.d.ts.map +1 -0
  138. package/build/plugins/portal.js +278 -0
  139. package/build/plugins/portal.js.map +1 -0
  140. package/build/plugins/refunds.d.ts +13 -0
  141. package/build/plugins/refunds.d.ts.map +1 -0
  142. package/build/plugins/refunds.js +80 -0
  143. package/build/plugins/refunds.js.map +1 -0
  144. package/build/plugins/stripe.d.ts +44 -4
  145. package/build/plugins/stripe.d.ts.map +1 -1
  146. package/build/plugins/stripe.js +559 -97
  147. package/build/plugins/stripe.js.map +1 -1
  148. package/build/plugins/types.d.ts +82 -0
  149. package/build/plugins/types.d.ts.map +1 -0
  150. package/build/plugins/types.js +2 -0
  151. package/build/plugins/types.js.map +1 -0
  152. package/build/plugins/types.local.d.ts +76 -0
  153. package/build/plugins/types.local.d.ts.map +1 -0
  154. package/build/plugins/types.local.js +2 -0
  155. package/build/plugins/types.local.js.map +1 -0
  156. package/build/plugins/webhook-manager.d.ts +54 -0
  157. package/build/plugins/webhook-manager.d.ts.map +1 -0
  158. package/build/plugins/webhook-manager.js +242 -0
  159. package/build/plugins/webhook-manager.js.map +1 -0
  160. package/build/reconcile.d.ts +15 -0
  161. package/build/reconcile.d.ts.map +1 -0
  162. package/build/reconcile.js +89 -0
  163. package/build/reconcile.js.map +1 -0
  164. package/build/resource.d.ts +12 -1
  165. package/build/resource.d.ts.map +1 -1
  166. package/build/resource.js +98 -6
  167. package/build/resource.js.map +1 -1
  168. package/build/schemas.d.ts +15 -0
  169. package/build/schemas.d.ts.map +1 -0
  170. package/build/schemas.js +228 -0
  171. package/build/schemas.js.map +1 -0
  172. package/build/service.d.ts +42 -3
  173. package/build/service.d.ts.map +1 -1
  174. package/build/service.js +193 -19
  175. package/build/service.js.map +1 -1
  176. package/build/subscription.d.ts +27 -0
  177. package/build/subscription.d.ts.map +1 -0
  178. package/build/subscription.js +179 -0
  179. package/build/subscription.js.map +1 -0
  180. package/build/sync.d.ts +28 -1
  181. package/build/sync.d.ts.map +1 -1
  182. package/build/sync.js +208 -32
  183. package/build/sync.js.map +1 -1
  184. package/build/types.d.ts +1258 -36
  185. package/build/types.d.ts.map +1 -1
  186. package/build/types.local.d.ts +27 -0
  187. package/build/types.local.d.ts.map +1 -0
  188. package/build/types.local.js +2 -0
  189. package/build/types.local.js.map +1 -0
  190. package/build/usage.d.ts +52 -0
  191. package/build/usage.d.ts.map +1 -0
  192. package/build/usage.js +373 -0
  193. package/build/usage.js.map +1 -0
  194. package/build/utils.d.ts +70 -2
  195. package/build/utils.d.ts.map +1 -1
  196. package/build/utils.js +105 -7
  197. package/build/utils.js.map +1 -1
  198. package/package.json +21 -16
  199. package/src/actions/index.ts +1 -0
  200. package/src/actions/resync-subscriptions.ts +9 -0
  201. package/src/actions/resync.ts +11 -4
  202. package/src/actions/webhook.ts +8 -4
  203. package/src/config.ts +268 -9
  204. package/src/consts.local.ts +62 -0
  205. package/src/consts.ts +100 -36
  206. package/src/consumer/capture.ts +335 -0
  207. package/src/consumer/consts.local.ts +18 -0
  208. package/src/consumer/format.ts +90 -0
  209. package/src/consumer/handlers.ts +200 -0
  210. package/src/consumer/index.ts +6 -0
  211. package/src/consumer/mail.ts +354 -0
  212. package/src/consumer/origin.ts +61 -0
  213. package/src/consumer/reconcile.ts +326 -0
  214. package/src/consumer/records.ts +357 -0
  215. package/src/consumer/service.ts +855 -0
  216. package/src/consumer/types.local.ts +22 -0
  217. package/src/consumer/types.ts +98 -0
  218. package/src/consumer/withdrawal.ts +273 -0
  219. package/src/entitlement.ts +84 -0
  220. package/src/entrypoints.ts +17 -4
  221. package/src/gate.ts +72 -26
  222. package/src/index.ts +27 -3
  223. package/src/limit.ts +54 -0
  224. package/src/log.ts +4 -0
  225. package/src/observer.ts +44 -11
  226. package/src/plan.ts +124 -0
  227. package/src/plugins/checkout-plugins.ts +145 -0
  228. package/src/plugins/consts.local.ts +30 -0
  229. package/src/plugins/estimate.ts +319 -0
  230. package/src/plugins/events.ts +674 -121
  231. package/src/plugins/fx.ts +101 -0
  232. package/src/plugins/portal.ts +313 -0
  233. package/src/plugins/refunds.ts +93 -0
  234. package/src/plugins/stripe.ts +613 -98
  235. package/src/plugins/types.local.ts +62 -0
  236. package/src/plugins/types.ts +87 -0
  237. package/src/plugins/webhook-manager.ts +278 -0
  238. package/src/reconcile.ts +102 -0
  239. package/src/resource.ts +145 -7
  240. package/src/schemas.ts +247 -0
  241. package/src/service.ts +217 -20
  242. package/src/subscription.ts +208 -0
  243. package/src/sync.ts +240 -30
  244. package/src/types.local.ts +20 -0
  245. package/src/types.ts +1367 -32
  246. package/src/usage.ts +443 -0
  247. package/src/utils.ts +175 -14
  248. package/tests/checkout-consumer.spec.ts +367 -0
  249. package/tests/checkout-locks.spec.ts +160 -0
  250. package/tests/checkout-plugins.spec.ts +164 -0
  251. package/tests/checkout.spec.ts +204 -91
  252. package/tests/consumer-events.spec.ts +217 -0
  253. package/tests/consumer-fixtures.ts +132 -0
  254. package/tests/consumer-ops.spec.ts +351 -0
  255. package/tests/consumer-rights.integration.spec.ts +149 -0
  256. package/tests/consumer-rights.spec.ts +577 -0
  257. package/tests/context.ts +109 -0
  258. package/tests/entitlement.spec.ts +139 -0
  259. package/tests/estimate.spec.ts +274 -0
  260. package/tests/events.spec.ts +356 -0
  261. package/tests/fake-stripe.ts +975 -0
  262. package/tests/gate-handlers.spec.ts +120 -0
  263. package/tests/gate.spec.ts +62 -72
  264. package/tests/gateway-options.spec.ts +181 -0
  265. package/tests/limit-gate.spec.ts +68 -0
  266. package/tests/portal.spec.ts +200 -0
  267. package/tests/protocol.spec.ts +17 -3
  268. package/tests/sync.spec.ts +114 -0
  269. package/tests/usage.integration.spec.ts +101 -0
  270. package/tests/usage.spec.ts +171 -0
  271. package/tests/webhook-manager.spec.ts +152 -0
  272. package/build/model.d.ts +0 -6
  273. package/build/model.d.ts.map +0 -1
  274. package/build/model.js +0 -33
  275. package/build/model.js.map +0 -1
  276. package/src/model.ts +0 -37
package/src/sync.ts CHANGED
@@ -1,24 +1,91 @@
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, type PlanPriceView } from '@owlmeans/payment'
4
4
  import type { Context as ApiContext } from '@owlmeans/server-api'
5
5
  import { STRIPE_PAYGATE_ALIAS } from './consts.js'
6
- import { fingerprints, payment, stripeClient } from './utils.js'
7
- import type { PaymentPlan, PaymentProduct } from './types.js'
6
+ import { fingerprints, gatewayOf, payment, stripeClient, stripePricingConfig } from './utils.js'
7
+ import type { PaymentPlan, PaymentProduct, SyncedPrice, SyncedPriceOption } from './types.js'
8
+ import type { StripeFxRateCache } from './plugins/types.js'
9
+ import { log } from './log.js'
10
+ import type { ResolvedPlan } from './types.local.js'
11
+ import { fxHelper } from './plugins/fx.js'
12
+
13
+ /**
14
+ * The exact prices a plan is also charged in: its declared `currencyPrices`, and its catalogue
15
+ * currency when that is one of the consumer-rights region currencies but not the Price's default
16
+ * (a USD catalogue price synced as EUR keeps an exact USD option). A declared price in the default
17
+ * currency replaces the converted default amount instead.
18
+ */
19
+ const optionsOf = (
20
+ plan: PaymentPlan, currency: string, sourceUnitAmount: number, sourceCurrency: string, regionCurrencies: Set<string>,
21
+ ): { unitAmount?: number, options: SyncedPriceOption[] } => {
22
+ const options = new Map<string, number>()
23
+ if (sourceCurrency !== currency && regionCurrencies.has(sourceCurrency)) {
24
+ options.set(sourceCurrency, sourceUnitAmount)
25
+ }
26
+ let unitAmount: number | undefined
27
+ for (const [raw, amount] of Object.entries(plan.currencyPrices ?? {})) {
28
+ const code = raw.toLowerCase()
29
+ const minor = Math.round(amount * 100)
30
+ if (code === currency) {
31
+ unitAmount = minor
32
+ } else {
33
+ options.set(code, minor)
34
+ }
35
+ }
36
+
37
+ return {
38
+ ...(unitAmount != null ? { unitAmount } : {}),
39
+ options: [...options.entries()].map(([code, amount]) => ({ currency: code, unitAmount: amount }))
40
+ .sort((a, b) => a.currency.localeCompare(b.currency)),
41
+ }
42
+ }
8
43
 
9
44
  export const planLookupKey = (product: PaymentProduct, plan: PaymentPlan): string =>
10
45
  product.type === ProductType.Consumable ? `${product.sku}-consumable` : plan.sku
11
46
 
12
- const fingerprintOf = (product: PaymentProduct, plans: PaymentPlan[]): string => createHash('sha256')
47
+ /** Whether a plan is sold through a paygate: never a free plan; otherwise its own gateways, else the product's. */
48
+ export const isSoldThrough = (product: PaymentProduct, plan: PaymentPlan, paygate: string): boolean =>
49
+ plan.free !== true && (plan.gateways ?? product.gateways ?? []).includes(paygate)
50
+
51
+ /** Every product sold through Stripe with the plans it sells there. */
52
+ export const stripePlansOf = async (ctx: ApiContext): Promise<Array<{ product: PaymentProduct, plans: PaymentPlan[] }>> => {
53
+ const products = await payment(ctx).products() as PaymentProduct[]
54
+ const result: Array<{ product: PaymentProduct, plans: PaymentPlan[] }> = []
55
+ for (const product of products) {
56
+ if (!(product.gateways ?? []).includes(STRIPE_PAYGATE_ALIAS)) {
57
+ continue
58
+ }
59
+ const plans = (await payment(ctx).allPlans(product.sku) as PaymentPlan[])
60
+ .filter(plan => isSoldThrough(product, plan, STRIPE_PAYGATE_ALIAS))
61
+ if (plans.length > 0) {
62
+ result.push({ product, plans })
63
+ }
64
+ }
65
+
66
+ return result
67
+ }
68
+
69
+ /**
70
+ * `behavior` is hashed alongside the catalogue: an undeclared policy hashes as `null`, so declaring
71
+ * or changing `tax.behavior` re-syncs every product exactly once, the same as any other catalogue
72
+ * edit.
73
+ */
74
+ const fingerprintOf = (
75
+ product: PaymentProduct, plans: ResolvedPlan[], behavior: TaxBehavior | null,
76
+ ): string => createHash('sha256')
13
77
  .update(JSON.stringify({
14
78
  sku: product.sku, type: product.type, name: product.title,
15
79
  description: product.description ?? null, taxCode: product.taxCode ?? null,
16
80
  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,
81
+ behavior,
82
+ plans: plans.map(({ plan, unitAmount, currency, sourceUnitAmount, sourceCurrency, options }) => ({
83
+ sku: plan.sku, price: plan.price, currency, unitAmount, sourceUnitAmount, sourceCurrency,
84
+ duration: plan.duration, recurring: plan.recurring ?? null, pricingMode: plan.pricingMode ?? null,
20
85
  amountPolicy: plan.amountPolicy ?? null, quantityPolicy: plan.quantityPolicy ?? null,
21
86
  lookup: planLookupKey(product, plan),
87
+ // Only when present, so a catalogue without options keeps the fingerprint it always had.
88
+ ...(options.length > 0 ? { options } : {}),
22
89
  })).sort((a, b) => a.sku.localeCompare(b.sku)),
23
90
  })).digest('hex')
24
91
 
@@ -38,7 +105,23 @@ const ensureStripeProduct = async (stripe: Stripe, product: PaymentProduct): Pro
38
105
  }
39
106
 
40
107
  const activePrices = async (stripe: Stripe, product: PaymentProduct): Promise<Stripe.Price[]> =>
41
- (await stripe.prices.list({ product: product.sku, active: true, limit: 100 })).data
108
+ (await stripe.prices.list({ product: product.sku, active: true, limit: 100, expand: ['data.currency_options'] })).data
109
+
110
+ /** A price's `currency_options` besides its default currency (read only once expanded). */
111
+ const priceOptionsOf = (price: Stripe.Price): Record<string, { unit_amount?: number | null, tax_behavior?: string | null }> =>
112
+ Object.fromEntries(Object.entries(price.currency_options ?? {}).filter(([code]) => code !== price.currency))
113
+
114
+ /** Whether a price carries exactly these options (and, with a declared behavior, with that behavior). */
115
+ const optionsMatch = (price: Stripe.Price, options: SyncedPriceOption[], behavior: TaxBehavior | null): boolean => {
116
+ const current = priceOptionsOf(price)
117
+ if (Object.keys(current).length !== options.length) {
118
+ return false
119
+ }
120
+
121
+ return options.every(option => current[option.currency]?.unit_amount === option.unitAmount
122
+ && (behavior == null || current[option.currency]?.tax_behavior == null
123
+ || current[option.currency]?.tax_behavior === 'unspecified' || current[option.currency]?.tax_behavior === behavior))
124
+ }
42
125
 
43
126
  const deactivateAmountPrice = async (stripe: Stripe, product: PaymentProduct, plan: PaymentPlan): Promise<void> => {
44
127
  const lookup = planLookupKey(product, plan)
@@ -47,51 +130,178 @@ const deactivateAmountPrice = async (stripe: Stripe, product: PaymentProduct, pl
47
130
  }
48
131
  }
49
132
 
50
- const ensureStripePrice = async (stripe: Stripe, product: PaymentProduct, plan: PaymentPlan): Promise<void> => {
133
+ /** Whether `price` already carries the OPPOSITE of `behavior` — never `unspecified`, which is not a conflict. */
134
+ const opposesBehavior = (price: Stripe.Price, behavior: TaxBehavior | null): boolean =>
135
+ behavior != null && price.tax_behavior !== 'unspecified' && price.tax_behavior !== behavior
136
+
137
+ /**
138
+ * The behavior the Stripe account's own Tax Settings resolve to for `currency`, or `null` when no
139
+ * default is configured yet (nothing established to disrupt). `inferred_by_currency` follows
140
+ * Stripe's own rule: exclusive for USD/CAD, inclusive otherwise.
141
+ */
142
+ const accountDefaultBehavior = async (stripe: Stripe, currency: string): Promise<TaxBehavior | null> => {
143
+ const { defaults } = await stripe.tax.settings.retrieve()
144
+ if (defaults.tax_behavior === 'inferred_by_currency') {
145
+ return ['usd', 'cad'].includes(currency.toLowerCase()) ? TaxBehavior.Exclusive : TaxBehavior.Inclusive
146
+ }
147
+ if (defaults.tax_behavior === 'exclusive') return TaxBehavior.Exclusive
148
+ if (defaults.tax_behavior === 'inclusive') return TaxBehavior.Inclusive
149
+ return null
150
+ }
151
+
152
+ /**
153
+ * Give `price` the declared `behavior` while it is still `unspecified` (the only state Stripe lets
154
+ * an existing price's `tax_behavior` be set from). Skipped, with a `log.error`, when the
155
+ * account's own default resolves to the opposite behavior — applying ours would then change what an
156
+ * existing renewal actually charges — unless `migrateUnspecifiedPrices` opts into that migration.
157
+ */
158
+ const applyUnspecifiedBehavior = async (
159
+ stripe: Stripe, price: Stripe.Price, lookupKey: string, behavior: TaxBehavior, migrateUnspecifiedPrices: boolean,
160
+ ): Promise<void> => {
161
+ if (!migrateUnspecifiedPrices) {
162
+ const resolved = await accountDefaultBehavior(stripe, price.currency)
163
+ if (resolved != null && resolved !== behavior) {
164
+ log.error(
165
+ 'Price left unspecified: the Stripe account\'s default tax behavior differs from the declared one — '
166
+ + 'applying it would change existing renewal amounts; set `stripe.migrateUnspecifiedPrices` to override',
167
+ { lookupKey, currency: price.currency, accountBehavior: resolved, declaredBehavior: behavior },
168
+ )
169
+ return
170
+ }
171
+ }
172
+ await stripe.prices.update(price.id, { tax_behavior: behavior })
173
+ }
174
+
175
+ const ensureStripePrice = async (
176
+ stripe: Stripe, product: PaymentProduct, resolved: ResolvedPlan,
177
+ behavior: TaxBehavior | null, migrateUnspecifiedPrices: boolean,
178
+ ): Promise<Stripe.Price | null> => {
179
+ const { plan, unitAmount, currency, options } = resolved
51
180
  if (plan.pricingMode === CheckoutPricingMode.Amount) {
52
181
  await deactivateAmountPrice(stripe, product, plan)
53
- return
182
+ return null
54
183
  }
55
184
  const lookupKey = planLookupKey(product, plan)
56
- const unitAmount = Math.round(plan.price * 100)
57
- const currency = plan.currency ?? 'usd'
58
185
  const recurring = plan.recurring != null
59
186
  ? { interval: plan.recurring.interval } as Stripe.PriceCreateParams.Recurring : undefined
60
187
  const existing = await activePrices(stripe, product)
61
- const match = existing.find(price => price.lookup_key === lookupKey && price.unit_amount === unitAmount
188
+ const candidate = existing.find(price => price.lookup_key === lookupKey && price.unit_amount === unitAmount
62
189
  && price.currency === currency
63
- && ((price.recurring?.interval ?? null) === (recurring?.interval ?? null)))
64
- if (match != null) return
190
+ && ((price.recurring?.interval ?? null) === (recurring?.interval ?? null))
191
+ && optionsMatch(price, options, behavior))
192
+ if (candidate != null && !opposesBehavior(candidate, behavior)) {
193
+ if (behavior != null && candidate.tax_behavior === 'unspecified') {
194
+ await applyUnspecifiedBehavior(stripe, candidate, lookupKey, behavior, migrateUnspecifiedPrices)
195
+ }
196
+ return candidate
197
+ }
198
+ // A changed option replaces the Price like any other change — options are never edited in place,
199
+ // so a subscriber keeps exactly the Price (and currency amounts) they accepted.
65
200
  for (const price of existing.filter(item => item.lookup_key === lookupKey)) {
66
201
  await stripe.prices.update(price.id, { active: false })
67
202
  }
68
- await stripe.prices.create({
203
+
204
+ return await stripe.prices.create({
69
205
  product: product.sku, currency, unit_amount: unitAmount, lookup_key: lookupKey,
70
206
  transfer_lookup_key: true, nickname: plan.sku,
71
207
  ...(recurring != null ? { recurring } : { billing_scheme: 'per_unit' }),
208
+ ...(behavior != null ? { tax_behavior: behavior } : {}),
209
+ ...(options.length > 0 ? {
210
+ currency_options: Object.fromEntries(options.map(option => [option.currency, {
211
+ unit_amount: option.unitAmount, ...(behavior != null ? { tax_behavior: behavior } : {}),
212
+ }])),
213
+ } : {}),
72
214
  metadata: { sku: plan.sku, ...(product.services && { services: product.services.join(',') }) },
73
215
  })
74
216
  }
75
217
 
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)
218
+ /**
219
+ * Synchronize every product sold through Stripe, and its Stripe-sold plans, to Stripe products and
220
+ * prices. A product whose declaration fingerprint is unchanged makes no paygate call. Free plans
221
+ * and plans sold through no Stripe gateway are never synchronized.
222
+ *
223
+ * The declared `PricingPolicy.tax.behavior` (absent by default, so a price's `tax_behavior` stays
224
+ * whatever it already was) is applied to a matching price only while it is `unspecified` — Stripe
225
+ * forbids changing a price once set to `exclusive` or `inclusive` — and to a fresh one on creation.
226
+ * A price carrying the OPPOSITE behavior is deactivated and replaced, same as any other mismatch.
227
+ */
228
+ export const syncStripeProducts = async (ctx: ApiContext, stripe: Stripe): Promise<void> => {
81
229
  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)
230
+ const behavior = (await payment(ctx).pricingPolicy()).tax.behavior ?? null
231
+ const migrateUnspecifiedPrices = (await stripePricingConfig(ctx))?.migrateUnspecifiedPrices ?? false
232
+ const fxRates: StripeFxRateCache = new Map()
233
+ const rights = await payment(ctx).consumerRightsPolicy()
234
+ const regionCurrencies = new Set(Object.values(rights?.currencies ?? {})
235
+ .filter((code): code is string => typeof code === 'string').map(code => code.toLowerCase()))
236
+ for (const { product, plans } of await stripePlansOf(ctx)) {
237
+ const resolvedPlans: ResolvedPlan[] = []
238
+ for (const plan of plans) {
239
+ const sourceUnitAmount = Math.round(plan.price * 100)
240
+ const sourceCurrency = (plan.currency ?? 'usd').toLowerCase()
241
+ if (plan.pricingMode === CheckoutPricingMode.Amount) {
242
+ resolvedPlans.push({ plan, unitAmount: sourceUnitAmount, currency: sourceCurrency, sourceUnitAmount, sourceCurrency, options: [] })
243
+ continue
244
+ }
245
+ const settled = await fxHelper.settlementAmount(ctx, stripe, sourceUnitAmount, sourceCurrency, fxRates)
246
+ const { unitAmount, options } = optionsOf(plan, settled.currency, sourceUnitAmount, sourceCurrency, regionCurrencies)
247
+ resolvedPlans.push({
248
+ plan, unitAmount: unitAmount ?? settled.amountMinor, currency: settled.currency, sourceUnitAmount, sourceCurrency,
249
+ options,
250
+ })
251
+ }
252
+ const hash = fingerprintOf(product, resolvedPlans, behavior)
85
253
  const stored = await fpRes.bySku(product.sku)
86
- if (stored != null && stored.hash === hash) continue
254
+ // A row from before prices were persisted syncs once more, so `planPrices` can read it.
255
+ if (stored != null && stored.hash === hash && stored.prices != null) continue
87
256
  const stripeProduct = await ensureStripeProduct(stripe, product)
88
- for (const plan of plans) await ensureStripePrice(stripe, product, plan)
257
+ const prices: SyncedPrice[] = []
258
+ const syncedAt = new Date()
259
+ for (const resolved of resolvedPlans) {
260
+ const price = await ensureStripePrice(stripe, product, resolved, behavior, migrateUnspecifiedPrices)
261
+ if (price != null) {
262
+ prices.push({
263
+ planSku: resolved.plan.sku, priceId: price.id, lookupKey: planLookupKey(product, resolved.plan),
264
+ currency: resolved.currency, unitAmount: resolved.unitAmount, options: resolved.options,
265
+ ...(behavior != null ? { taxBehavior: behavior } : price.tax_behavior != null ? { taxBehavior: price.tax_behavior } : {}),
266
+ ...(resolved.plan.recurring != null ? { interval: resolved.plan.recurring.interval } : {}),
267
+ sourceUnitAmount: resolved.sourceUnitAmount, sourceCurrency: resolved.sourceCurrency, syncedAt,
268
+ })
269
+ }
270
+ }
89
271
  if (stored != null) {
90
- Object.assign(stored, { hash, productId: stripeProduct.id, updatedAt: new Date() })
91
- await fpRes.save(stored)
272
+ await fpRes.update({ ...stored, hash, productId: stripeProduct.id, prices, updatedAt: new Date() })
92
273
  } else {
93
- await fpRes.create({ sku: product.sku, hash, productId: stripeProduct.id, updatedAt: new Date() })
274
+ await fpRes.create({ sku: product.sku, hash, productId: stripeProduct.id, prices, updatedAt: new Date() })
275
+ }
276
+ log.info('Product synced to Stripe', { sku: product.sku, plans: plans.length }, { event: 'payment.product.synced' })
277
+ }
278
+ }
279
+
280
+ /**
281
+ * The prices a product's plans are charged at, per currency, as the last sync stored them — no
282
+ * paygate call, so it works in an unmanaged process. One entry for each Price's default currency
283
+ * (`default: true`) and one per currency option.
284
+ */
285
+ export const syncedPlanPrices = async (ctx: ApiContext, productSku: string): Promise<PlanPriceView[]> => {
286
+ const row = await fingerprints(ctx).bySku(productSku)
287
+ const behaviorOf = (value: string | undefined): TaxBehavior | undefined =>
288
+ value === TaxBehavior.Exclusive || value === TaxBehavior.Inclusive ? value : undefined
289
+ const views: PlanPriceView[] = []
290
+ for (const price of row?.prices ?? []) {
291
+ const taxBehavior = behaviorOf(price.taxBehavior ?? undefined)
292
+ const shared = {
293
+ planSku: price.planSku, ...(taxBehavior != null ? { taxBehavior } : {}),
294
+ ...(price.interval != null ? { interval: price.interval } : {}),
295
+ }
296
+ views.push({ ...shared, currency: price.currency, unitAmountMinor: price.unitAmount, default: true })
297
+ for (const option of price.options ?? []) {
298
+ views.push({ ...shared, currency: option.currency, unitAmountMinor: option.unitAmount, default: false })
94
299
  }
95
- console.info(`[payment] synced product '${product.sku}' to Stripe (${plans.length} plan(s))`)
96
300
  }
301
+
302
+ return views
97
303
  }
304
+
305
+ /** `syncStripeProducts` with this context's own Stripe client (the gateway's, else the configured secret). */
306
+ export const syncPaymentProducts = async (ctx: ApiContext): Promise<void> =>
307
+ await syncStripeProducts(ctx, await (gatewayOf(ctx)?.stripe ?? stripeClient)(ctx))
@@ -0,0 +1,20 @@
1
+ import type { PaymentPlan, SyncedPriceOption } from './types.js'
2
+
3
+ export type Distinct = { collection: { distinct: (field: string, filter: object) => Promise<unknown[]> } }
4
+
5
+ export interface ResolvedPlan {
6
+ plan: PaymentPlan
7
+ unitAmount: number
8
+ currency: string
9
+ sourceUnitAmount: number
10
+ sourceCurrency: string
11
+ /** `currency_options` besides `currency`, sorted by currency. */
12
+ options: SyncedPriceOption[]
13
+ }
14
+
15
+ export interface CounterKey { entityId: string; limitKey: string; window: string }
16
+
17
+ export type PluginReader = { getConfigResource: (alias: string) => {
18
+ get: (id: string) => Promise<unknown>
19
+ load: (id: string) => Promise<unknown>
20
+ } }