@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
@@ -0,0 +1,339 @@
1
+ import type Stripe from 'stripe'
2
+ import {
3
+ chargeAmountMinor, chargeCurrencyOf, CheckoutPricingMode, currencyOfCountry, ProductError, ratePpmOf,
4
+ regionOf, TaxBehavior, TaxEstimateStatus, TaxType, UnknownProduct,
5
+ } from '@owlmeans/payment'
6
+ import type { PriceEstimate, TaxEstimate, TaxRateEstimate } from '@owlmeans/payment'
7
+ import type { Context as ApiContext } from '@owlmeans/server-api'
8
+ import { STRIPE_FX_QUOTES_API_VERSION, STRIPE_PAYGATE_ALIAS } from '../consts.js'
9
+ import { findProduct } from '../plan.js'
10
+ import { isSoldThrough } from '../sync.js'
11
+ import {
12
+ consumerRightsOf, fingerprints, isMissingObject, paygateCustomers, payment, stripePricingConfig,
13
+ } from '../utils.js'
14
+ import type { PaymentPlan, PaymentProduct, PriceEstimateParams } from '../types.js'
15
+ import { stripeFxRate } from './fx.js'
16
+
17
+ // -------------------------------------------------------------------------------------------
18
+ // Cache — one instance per gateway service, never module-level (a test builds many contexts in
19
+ // one process, and a module-level cache would make "no Stripe call" assertions order-dependent).
20
+ // -------------------------------------------------------------------------------------------
21
+
22
+ interface CacheEntry<T> { value: T; expiresAt: number }
23
+
24
+ export interface EstimateCache {
25
+ taxTtlMs: number
26
+ fxTtlMs: number
27
+ customerTtlMs: number
28
+ tax: Map<string, CacheEntry<TaxOutcome>>
29
+ fx: Map<string, CacheEntry<FxOutcome>>
30
+ customer: Map<string, CacheEntry<Stripe.Customer | null>>
31
+ inflight: Map<string, Promise<unknown>>
32
+ }
33
+
34
+ /** @param taxTtlMs default 24h; @param fxTtlMs default 1h; @param customerTtlMs default 5min. */
35
+ export const makeEstimateCache = (
36
+ taxTtlMs: number = 24 * 60 * 60 * 1_000, fxTtlMs: number = 60 * 60 * 1_000,
37
+ customerTtlMs: number = 5 * 60 * 1_000,
38
+ ): EstimateCache => ({
39
+ taxTtlMs, fxTtlMs, customerTtlMs, tax: new Map(), fx: new Map(), customer: new Map(), inflight: new Map(),
40
+ })
41
+
42
+ /**
43
+ * A keyed value cached for `ttlMs`, with in-flight requests shared across concurrent callers. Only
44
+ * a settled (resolved) outcome is cached — a thrown error (a rate limit, a dropped connection) never
45
+ * is, so the next call simply tries again.
46
+ */
47
+ const cached = async <T>(
48
+ store: Map<string, CacheEntry<T>>, inflight: Map<string, Promise<unknown>>,
49
+ key: string, ttlMs: number, factory: () => Promise<T>,
50
+ ): Promise<T> => {
51
+ const now = Date.now()
52
+ const hit = store.get(key)
53
+ if (hit != null && hit.expiresAt > now) {
54
+ return hit.value
55
+ }
56
+ const pending = inflight.get(key) as Promise<T> | undefined
57
+ if (pending != null) {
58
+ return pending
59
+ }
60
+ const promise = factory().then(value => {
61
+ store.set(key, { value, expiresAt: Date.now() + ttlMs })
62
+ inflight.delete(key)
63
+ return value
64
+ }).catch(error => {
65
+ inflight.delete(key)
66
+ throw error
67
+ })
68
+ inflight.set(key, promise)
69
+ return promise
70
+ }
71
+
72
+ // -------------------------------------------------------------------------------------------
73
+ // Customer lookup — the fallback country and the tax ids a request-given country still checks.
74
+ // -------------------------------------------------------------------------------------------
75
+
76
+ const cachedCustomer = async (
77
+ ctx: ApiContext, stripe: Stripe, entityId: string, cache: EstimateCache,
78
+ ): Promise<Stripe.Customer | null> => cached(cache.customer, cache.inflight, `customer:${entityId}`, cache.customerTtlMs, async () => {
79
+ const record = await paygateCustomers(ctx).byEntity(entityId, STRIPE_PAYGATE_ALIAS)
80
+ if (record == null || record.deletedAt != null) {
81
+ return null
82
+ }
83
+ try {
84
+ const retrieved = await stripe.customers.retrieve(record.externalId, { expand: ['tax_ids'] })
85
+ return (retrieved as Stripe.DeletedCustomer).deleted ? null : retrieved as Stripe.Customer
86
+ } catch (error) {
87
+ if (isMissingObject(error)) return null
88
+ throw error
89
+ }
90
+ })
91
+
92
+ // -------------------------------------------------------------------------------------------
93
+ // Tax
94
+ // -------------------------------------------------------------------------------------------
95
+
96
+ type TaxOutcome = { ok: true; calculation: Stripe.Tax.Calculation } | { ok: false; code?: string }
97
+
98
+ /** A duck-typed check, matching `isMissingObject` above: the installed SDK's own error classes. */
99
+ const isInvalidRequest = (error: unknown): error is { code?: string } =>
100
+ (error as { type?: unknown } | null)?.type === 'StripeInvalidRequestError'
101
+
102
+ const NON_SCALABLE_REASONS = new Set([
103
+ 'portion_product_exempt', 'portion_reduced_rated', 'portion_standard_rated', 'taxable_basis_reduced',
104
+ 'proportionally_rated',
105
+ ])
106
+
107
+ /** A linear percentage rate scales with the amount; a flat fee, a partial exemption, or several inclusive rates do not. */
108
+ const scalableOf = (breakdown: Stripe.Tax.Calculation.TaxBreakdown[], behavior: TaxBehavior): boolean => {
109
+ if (breakdown.some(row => row.tax_rate_details?.rate_type === 'flat_amount')) return false
110
+ if (breakdown.some(row => NON_SCALABLE_REASONS.has(row.taxability_reason))) return false
111
+ if (behavior === TaxBehavior.Inclusive && breakdown.length > 1) return false
112
+ return true
113
+ }
114
+
115
+ const statusOf = (breakdown: Stripe.Tax.Calculation.TaxBreakdown[], taxMinor: number): TaxEstimateStatus => {
116
+ if (breakdown.some(row => row.taxability_reason === 'reverse_charge')) return TaxEstimateStatus.ReverseCharge
117
+ if (taxMinor > 0) return TaxEstimateStatus.Taxed
118
+ if (breakdown.some(row => row.taxability_reason === 'not_supported')) return TaxEstimateStatus.AtCheckout
119
+ return TaxEstimateStatus.None
120
+ }
121
+
122
+ const TAX_TYPE_MAP: Readonly<Record<string, TaxType>> = Object.freeze({
123
+ vat: TaxType.Vat, gst: TaxType.Gst, sales_tax: TaxType.SalesTax,
124
+ })
125
+
126
+ const taxTypeOf = (raw: string | null | undefined): TaxType => raw != null && raw in TAX_TYPE_MAP ? TAX_TYPE_MAP[raw] : TaxType.Tax
127
+
128
+ const ratesOf = (breakdown: Stripe.Tax.Calculation.TaxBreakdown[]): TaxRateEstimate[] => breakdown
129
+ .filter((row): row is Stripe.Tax.Calculation.TaxBreakdown & {
130
+ tax_rate_details: NonNullable<Stripe.Tax.Calculation.TaxBreakdown['tax_rate_details']>
131
+ } => row.tax_rate_details != null)
132
+ .map(row => ({
133
+ type: taxTypeOf(row.tax_rate_details.tax_type),
134
+ percentage: row.tax_rate_details.percentage_decimal,
135
+ ratePpm: ratePpmOf(row.tax_rate_details.percentage_decimal),
136
+ ...(row.tax_rate_details.country != null ? { country: row.tax_rate_details.country } : {}),
137
+ ...(row.tax_rate_details.state != null ? { state: row.tax_rate_details.state } : {}),
138
+ }))
139
+
140
+ const taxEstimateOf = (calculation: Stripe.Tax.Calculation, behavior: TaxBehavior, subtotalMinor: number): TaxEstimate => {
141
+ const breakdown = calculation.tax_breakdown ?? []
142
+ const taxMinor = calculation.tax_amount_exclusive + calculation.tax_amount_inclusive
143
+ const status = statusOf(breakdown, taxMinor)
144
+
145
+ return {
146
+ status, subtotalMinor, taxMinor, totalMinor: calculation.amount_total,
147
+ scalable: scalableOf(breakdown, behavior), rates: ratesOf(breakdown),
148
+ }
149
+ }
150
+
151
+ /** The reference amount and currency of the estimate: an amount plan's default preset, else the plan's own price. */
152
+ const referenceOf = (plan: PaymentPlan): { subtotalMinor: number; currency: string } => {
153
+ if (plan.pricingMode === CheckoutPricingMode.Amount) {
154
+ if (plan.amountPolicy == null) throw new ProductError(`amount-policy:${plan.sku}`)
155
+ return {
156
+ subtotalMinor: chargeAmountMinor(plan.amountPolicy.defaultMinor, plan.amountPolicy),
157
+ currency: plan.amountPolicy.currency.toLowerCase(),
158
+ }
159
+ }
160
+ return { subtotalMinor: Math.round(plan.price * 100), currency: (plan.currency ?? 'usd').toLowerCase() }
161
+ }
162
+
163
+ /**
164
+ * The reference amount in the currency the buyer is charged in: a recurring or quantity plan's
165
+ * synced price in that currency (its default or an option — no paygate call), else the plan's own
166
+ * catalogue reference. An amount plan keeps its policy currency.
167
+ */
168
+ const chargedReferenceOf = async (
169
+ ctx: ApiContext, product: PaymentProduct, plan: PaymentPlan, chargeCurrency: string | null,
170
+ ): Promise<{ subtotalMinor: number; currency: string }> => {
171
+ const reference = referenceOf(plan)
172
+ if (chargeCurrency == null || plan.pricingMode === CheckoutPricingMode.Amount || chargeCurrency === reference.currency && plan.currencyPrices == null) {
173
+ return reference
174
+ }
175
+ const synced = (await fingerprints(ctx).bySku(product.sku))?.prices?.find(price => price.planSku === plan.sku)
176
+ if (synced == null) {
177
+ return reference
178
+ }
179
+ if (synced.currency === chargeCurrency) {
180
+ return { subtotalMinor: synced.unitAmount, currency: chargeCurrency }
181
+ }
182
+ const option = synced.options.find(entry => entry.currency === chargeCurrency)
183
+
184
+ return option != null ? { subtotalMinor: option.unitAmount, currency: chargeCurrency } : reference
185
+ }
186
+
187
+ /** A placeholder estimate — no known tax, shown by its `status`, never by its zeroed numbers. */
188
+ const unresolvedEstimate = (status: TaxEstimateStatus, subtotalMinor: number): TaxEstimate => ({
189
+ status, subtotalMinor, taxMinor: 0, totalMinor: subtotalMinor, scalable: false, rates: [],
190
+ })
191
+
192
+ // -------------------------------------------------------------------------------------------
193
+ // Currency (Stripe FX Quotes, a PREVIEW endpoint — see `STRIPE_FX_QUOTES_API_VERSION`)
194
+ // -------------------------------------------------------------------------------------------
195
+
196
+ type FxOutcome = { currency: string; exchangeRate: number; fxFeeRate?: number } | null
197
+
198
+ const fetchFxRate = async (
199
+ stripe: Stripe, currency: string, localCurrency: string, apiVersion: string,
200
+ settlementCurrency: string = currency,
201
+ ): Promise<FxOutcome> => {
202
+ const settlement = settlementCurrency.toLowerCase()
203
+ const sourceRate = settlement === currency
204
+ ? { referenceRate: 1 }
205
+ : await stripeFxRate(stripe, currency, settlement, apiVersion)
206
+ if (sourceRate == null) return null
207
+ if (localCurrency === settlement) {
208
+ return { currency: localCurrency, exchangeRate: 1 / sourceRate.referenceRate }
209
+ }
210
+ const localRate = await stripeFxRate(stripe, localCurrency, settlement, apiVersion)
211
+ if (localRate == null) return null
212
+ return {
213
+ currency: localCurrency, exchangeRate: localRate.exchangeRate / sourceRate.referenceRate,
214
+ ...(localRate.fxFeeRate != null ? { fxFeeRate: localRate.fxFeeRate } : {}),
215
+ }
216
+ }
217
+
218
+ // -------------------------------------------------------------------------------------------
219
+
220
+ /**
221
+ * A Stripe Tax estimate (and, when the pricing policy asks for it, a local-currency conversion) for
222
+ * one product/plan, at a billing country the request names or the entity's paygate customer does.
223
+ *
224
+ * Costs one Tax Calculation API call ($0.05) per distinct (currency, country, amount, tax code,
225
+ * behavior, matching tax ids) within `cache`'s TTL, and one free FX Quotes call per distinct
226
+ * (currency, local currency) — never more, and never for a rate-limit or connection error, which is
227
+ * never cached.
228
+ */
229
+ export const estimateStripePrice = async (
230
+ ctx: ApiContext, stripe: Stripe, params: PriceEstimateParams, cache: EstimateCache,
231
+ ): Promise<PriceEstimate> => {
232
+ const product = await findProduct(ctx, params.productSku) as PaymentProduct | null
233
+ if (product == null) throw new UnknownProduct(params.productSku)
234
+ const plans = (await payment(ctx).allPlans(product.sku) as PaymentPlan[])
235
+ .filter(plan => isSoldThrough(product, plan, STRIPE_PAYGATE_ALIAS))
236
+ const plan = params.planSku != null
237
+ ? plans.find(item => item.sku === params.planSku)
238
+ : plans.find(item => item.recurring != null) ?? plans[0]
239
+ if (plan == null) throw new ProductError(params.planSku != null ? `plan:${params.planSku}` : 'plan')
240
+
241
+ const pricing = await payment(ctx).pricingPolicy()
242
+ const behavior = pricing.tax.behavior ?? TaxBehavior.Exclusive
243
+ const rights = await payment(ctx).consumerRightsPolicy()
244
+ // A locked billing country overrides whatever the request names: a picker shows it, locked.
245
+ const profile = rights != null ? await consumerRightsOf(ctx)?.profile(params.entityId) ?? null : null
246
+ const locked = profile?.locked === true && profile.country != null
247
+
248
+ let country = locked ? profile.country as string : params.country?.toUpperCase()
249
+ let source: 'request' | 'customer' | 'profile' | undefined = locked ? 'profile' : country != null ? 'request' : undefined
250
+ let matchingTaxIds: Array<{ type: string; value: string }> = []
251
+ let taxabilityOverride: 'customer_exempt' | 'reverse_charge' | undefined
252
+
253
+ const customer = await cachedCustomer(ctx, stripe, params.entityId, cache)
254
+ if (customer != null) {
255
+ if (country == null && customer.address?.country != null) {
256
+ country = customer.address.country
257
+ source = 'customer'
258
+ }
259
+ if (country != null) {
260
+ matchingTaxIds = (customer.tax_ids?.data ?? [])
261
+ .filter(taxId => taxId.country === country)
262
+ .map(taxId => ({ type: taxId.type, value: taxId.value }))
263
+ if (customer.tax_exempt === 'exempt') taxabilityOverride = 'customer_exempt'
264
+ else if (customer.tax_exempt === 'reverse') taxabilityOverride = 'reverse_charge'
265
+ }
266
+ }
267
+
268
+ const region = rights != null ? regionOf(country, rights) : null
269
+ const settlementCurrency = (await stripePricingConfig(ctx))?.settlementCurrency?.toLowerCase()
270
+ const { subtotalMinor, currency } = await chargedReferenceOf(ctx, product, plan, rights != null && rights.currencies != null
271
+ && Object.keys(rights.currencies).length > 0
272
+ ? (profile?.currency ?? chargeCurrencyOf(region, rights, settlementCurrency ?? (plan.currency ?? 'usd'))).toLowerCase()
273
+ : null)
274
+ const where = { ...(region != null ? { region } : {}), ...(locked ? { locked: true } : {}) }
275
+
276
+ if (country == null) {
277
+ return { currency, behavior, tax: unresolvedEstimate(TaxEstimateStatus.LocationRequired, subtotalMinor), ...where }
278
+ }
279
+
280
+ const cacheKey = JSON.stringify([
281
+ currency, country, subtotalMinor, product.taxCode ?? null, behavior,
282
+ matchingTaxIds.map(id => `${id.type}:${id.value}`).sort(), taxabilityOverride ?? null,
283
+ ])
284
+ const outcome = await cached(cache.tax, cache.inflight, `tax:${cacheKey}`, cache.taxTtlMs, async (): Promise<TaxOutcome> => {
285
+ try {
286
+ const calculation = await stripe.tax.calculations.create({
287
+ currency,
288
+ line_items: [{
289
+ amount: subtotalMinor, reference: plan.sku, tax_behavior: behavior,
290
+ ...(product.taxCode != null ? { tax_code: product.taxCode } : {}),
291
+ }],
292
+ customer_details: {
293
+ address: { country },
294
+ address_source: 'billing',
295
+ // Cast: `Stripe.TaxId.type` (the customer's saved tax id) and the Tax Calculation
296
+ // parameter's own tax-id-type enum are independently generated from Stripe's OpenAPI
297
+ // spec and can drift apart in this pinned SDK; every value here still came from a real
298
+ // Stripe `TaxId` record, so it is valid at the API even where the two types disagree.
299
+ ...(matchingTaxIds.length > 0
300
+ ? { tax_ids: matchingTaxIds as Stripe.Tax.CalculationCreateParams.CustomerDetails['tax_ids'] }
301
+ : {}),
302
+ ...(taxabilityOverride != null ? { taxability_override: taxabilityOverride } : {}),
303
+ },
304
+ })
305
+ return { ok: true, calculation }
306
+ } catch (error) {
307
+ if (isInvalidRequest(error)) return { ok: false, code: error.code }
308
+ throw error
309
+ }
310
+ })
311
+
312
+ const tax = outcome.ok
313
+ ? taxEstimateOf(outcome.calculation, behavior, subtotalMinor)
314
+ : unresolvedEstimate(TaxEstimateStatus.AtCheckout, subtotalMinor)
315
+
316
+ const result: PriceEstimate = { country, source, currency, behavior, tax, ...where }
317
+ // Adaptive Pricing — and so a local-currency line — applies only to a session charged in the
318
+ // settlement currency; a forced region currency (exact USD) is shown as it is.
319
+ const adaptive = rights?.currencies == null || currency === (settlementCurrency ?? currency)
320
+
321
+ if (pricing.currency.estimate && pricing.currency.adaptive === true && adaptive) {
322
+ const localCurrency = currencyOfCountry(country)
323
+ if (localCurrency != null && localCurrency !== currency) {
324
+ const stripePricing = await stripePricingConfig(ctx)
325
+ const apiVersion = stripePricing?.fxApiVersion ?? STRIPE_FX_QUOTES_API_VERSION
326
+ const settlementCurrency = stripePricing?.settlementCurrency?.toLowerCase() ?? currency
327
+ // A preview endpoint: unreachable or erroring never fails the estimate, it only drops `local`.
328
+ const fx = await cached(
329
+ cache.fx, cache.inflight, `fx:${currency}:${settlementCurrency}:${localCurrency}:${apiVersion}`, cache.fxTtlMs,
330
+ () => fetchFxRate(stripe, currency, localCurrency, apiVersion, settlementCurrency),
331
+ ).catch(() => null)
332
+ if (fx != null) {
333
+ result.local = { currency: fx.currency, exchangeRate: fx.exchangeRate, ...(fx.fxFeeRate != null ? { fxFeeRate: fx.fxFeeRate } : {}) }
334
+ }
335
+ }
336
+ }
337
+
338
+ return result
339
+ }