@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.
- package/README.md +109 -25
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/server-payment/SKILL.md +475 -52
- package/build/actions/index.d.ts +1 -0
- package/build/actions/index.d.ts.map +1 -1
- package/build/actions/index.js +1 -0
- package/build/actions/index.js.map +1 -1
- package/build/actions/resync-subscriptions.d.ts +3 -0
- package/build/actions/resync-subscriptions.d.ts.map +1 -0
- package/build/actions/resync-subscriptions.js +7 -0
- package/build/actions/resync-subscriptions.js.map +1 -0
- package/build/actions/resync.d.ts +1 -0
- package/build/actions/resync.d.ts.map +1 -1
- package/build/actions/resync.js +7 -3
- package/build/actions/resync.js.map +1 -1
- package/build/actions/webhook.d.ts.map +1 -1
- package/build/actions/webhook.js +6 -3
- package/build/actions/webhook.js.map +1 -1
- package/build/config.d.ts +60 -5
- package/build/config.d.ts.map +1 -1
- package/build/config.js +237 -5
- package/build/config.js.map +1 -1
- package/build/consts.d.ts +75 -3
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +94 -5
- package/build/consts.js.map +1 -1
- package/build/consumer/capture.d.ts +74 -0
- package/build/consumer/capture.d.ts.map +1 -0
- package/build/consumer/capture.js +291 -0
- package/build/consumer/capture.js.map +1 -0
- package/build/consumer/format.d.ts +27 -0
- package/build/consumer/format.d.ts.map +1 -0
- package/build/consumer/format.js +81 -0
- package/build/consumer/format.js.map +1 -0
- package/build/consumer/handlers.d.ts +28 -0
- package/build/consumer/handlers.d.ts.map +1 -0
- package/build/consumer/handlers.js +173 -0
- package/build/consumer/handlers.js.map +1 -0
- package/build/consumer/index.d.ts +7 -0
- package/build/consumer/index.d.ts.map +1 -0
- package/build/consumer/index.js +6 -0
- package/build/consumer/index.js.map +1 -0
- package/build/consumer/mail.d.ts +27 -0
- package/build/consumer/mail.d.ts.map +1 -0
- package/build/consumer/mail.js +314 -0
- package/build/consumer/mail.js.map +1 -0
- package/build/consumer/origin.d.ts +14 -0
- package/build/consumer/origin.d.ts.map +1 -0
- package/build/consumer/origin.js +47 -0
- package/build/consumer/origin.js.map +1 -0
- package/build/consumer/reconcile.d.ts +12 -0
- package/build/consumer/reconcile.d.ts.map +1 -0
- package/build/consumer/reconcile.js +317 -0
- package/build/consumer/reconcile.js.map +1 -0
- package/build/consumer/records.d.ts +78 -0
- package/build/consumer/records.d.ts.map +1 -0
- package/build/consumer/records.js +296 -0
- package/build/consumer/records.js.map +1 -0
- package/build/consumer/service.d.ts +51 -0
- package/build/consumer/service.d.ts.map +1 -0
- package/build/consumer/service.js +760 -0
- package/build/consumer/service.js.map +1 -0
- package/build/consumer/withdrawal.d.ts +57 -0
- package/build/consumer/withdrawal.d.ts.map +1 -0
- package/build/consumer/withdrawal.js +247 -0
- package/build/consumer/withdrawal.js.map +1 -0
- package/build/entitlement.d.ts +10 -0
- package/build/entitlement.d.ts.map +1 -0
- package/build/entitlement.js +69 -0
- package/build/entitlement.js.map +1 -0
- package/build/entrypoints.d.ts +1 -1
- package/build/entrypoints.d.ts.map +1 -1
- package/build/entrypoints.js +2 -1
- package/build/entrypoints.js.map +1 -1
- package/build/gate.d.ts +32 -4
- package/build/gate.d.ts.map +1 -1
- package/build/gate.js +59 -19
- package/build/gate.js.map +1 -1
- package/build/index.d.ts +18 -3
- package/build/index.d.ts.map +1 -1
- package/build/index.js +15 -3
- package/build/index.js.map +1 -1
- package/build/limit.d.ts +13 -0
- package/build/limit.d.ts.map +1 -0
- package/build/limit.js +47 -0
- package/build/limit.js.map +1 -0
- package/build/model.d.ts +10 -1
- package/build/model.d.ts.map +1 -1
- package/build/model.js +215 -17
- package/build/model.js.map +1 -1
- package/build/observer.d.ts +9 -0
- package/build/observer.d.ts.map +1 -1
- package/build/observer.js +38 -12
- package/build/observer.js.map +1 -1
- package/build/plan.d.ts +22 -0
- package/build/plan.d.ts.map +1 -0
- package/build/plan.js +72 -0
- package/build/plan.js.map +1 -0
- package/build/plugins/checkout-plugins.d.ts +49 -0
- package/build/plugins/checkout-plugins.d.ts.map +1 -0
- package/build/plugins/checkout-plugins.js +124 -0
- package/build/plugins/checkout-plugins.js.map +1 -0
- package/build/plugins/estimate.d.ts +43 -0
- package/build/plugins/estimate.d.ts.map +1 -0
- package/build/plugins/estimate.js +268 -0
- package/build/plugins/estimate.js.map +1 -0
- package/build/plugins/events.d.ts +45 -10
- package/build/plugins/events.d.ts.map +1 -1
- package/build/plugins/events.js +605 -136
- package/build/plugins/events.js.map +1 -1
- package/build/plugins/fx.d.ts +31 -0
- package/build/plugins/fx.d.ts.map +1 -0
- package/build/plugins/fx.js +81 -0
- package/build/plugins/fx.js.map +1 -0
- package/build/plugins/portal.d.ts +37 -0
- package/build/plugins/portal.d.ts.map +1 -0
- package/build/plugins/portal.js +265 -0
- package/build/plugins/portal.js.map +1 -0
- package/build/plugins/refunds.d.ts +33 -0
- package/build/plugins/refunds.d.ts.map +1 -0
- package/build/plugins/refunds.js +80 -0
- package/build/plugins/refunds.js.map +1 -0
- package/build/plugins/stripe.d.ts +36 -4
- package/build/plugins/stripe.d.ts.map +1 -1
- package/build/plugins/stripe.js +492 -97
- package/build/plugins/stripe.js.map +1 -1
- package/build/plugins/webhook-manager.d.ts +46 -0
- package/build/plugins/webhook-manager.d.ts.map +1 -0
- package/build/plugins/webhook-manager.js +231 -0
- package/build/plugins/webhook-manager.js.map +1 -0
- package/build/reconcile.d.ts +15 -0
- package/build/reconcile.d.ts.map +1 -0
- package/build/reconcile.js +88 -0
- package/build/reconcile.js.map +1 -0
- package/build/resource.d.ts +12 -1
- package/build/resource.d.ts.map +1 -1
- package/build/resource.js +97 -6
- package/build/resource.js.map +1 -1
- package/build/service.d.ts +28 -3
- package/build/service.d.ts.map +1 -1
- package/build/service.js +146 -19
- package/build/service.js.map +1 -1
- package/build/subscription.d.ts +48 -0
- package/build/subscription.d.ts.map +1 -0
- package/build/subscription.js +176 -0
- package/build/subscription.js.map +1 -0
- package/build/sync.d.ts +28 -1
- package/build/sync.d.ts.map +1 -1
- package/build/sync.js +208 -31
- package/build/sync.js.map +1 -1
- package/build/types.d.ts +1115 -36
- package/build/types.d.ts.map +1 -1
- package/build/usage.d.ts +63 -0
- package/build/usage.d.ts.map +1 -0
- package/build/usage.js +363 -0
- package/build/usage.js.map +1 -0
- package/build/utils.d.ts +52 -1
- package/build/utils.d.ts.map +1 -1
- package/build/utils.js +69 -7
- package/build/utils.js.map +1 -1
- package/package.json +17 -13
- package/src/actions/index.ts +1 -0
- package/src/actions/resync-subscriptions.ts +10 -0
- package/src/actions/resync.ts +6 -3
- package/src/actions/webhook.ts +5 -3
- package/src/config.ts +264 -8
- package/src/consts.ts +114 -6
- package/src/consumer/capture.ts +362 -0
- package/src/consumer/format.ts +90 -0
- package/src/consumer/handlers.ts +211 -0
- package/src/consumer/index.ts +6 -0
- package/src/consumer/mail.ts +368 -0
- package/src/consumer/origin.ts +63 -0
- package/src/consumer/reconcile.ts +329 -0
- package/src/consumer/records.ts +374 -0
- package/src/consumer/service.ts +868 -0
- package/src/consumer/withdrawal.ts +302 -0
- package/src/entitlement.ts +84 -0
- package/src/entrypoints.ts +2 -1
- package/src/gate.ts +88 -21
- package/src/index.ts +24 -3
- package/src/limit.ts +57 -0
- package/src/model.ts +237 -18
- package/src/observer.ts +44 -11
- package/src/plan.ts +89 -0
- package/src/plugins/checkout-plugins.ts +155 -0
- package/src/plugins/estimate.ts +339 -0
- package/src/plugins/events.ts +677 -121
- package/src/plugins/fx.ts +122 -0
- package/src/plugins/portal.ts +306 -0
- package/src/plugins/refunds.ts +108 -0
- package/src/plugins/stripe.ts +581 -96
- package/src/plugins/webhook-manager.ts +270 -0
- package/src/reconcile.ts +103 -0
- package/src/resource.ts +152 -7
- package/src/service.ts +174 -18
- package/src/subscription.ts +231 -0
- package/src/sync.ts +249 -29
- package/src/types.ts +1227 -32
- package/src/usage.ts +453 -0
- package/src/utils.ts +127 -10
- package/tests/checkout-consumer.spec.ts +348 -0
- package/tests/checkout-plugins.spec.ts +164 -0
- package/tests/checkout.spec.ts +184 -83
- package/tests/consumer-events.spec.ts +218 -0
- package/tests/consumer-fixtures.ts +132 -0
- package/tests/consumer-ops.spec.ts +351 -0
- package/tests/consumer-rights.integration.spec.ts +150 -0
- package/tests/consumer-rights.spec.ts +501 -0
- package/tests/context.ts +109 -0
- package/tests/entitlement.spec.ts +103 -0
- package/tests/estimate.spec.ts +240 -0
- package/tests/events.spec.ts +356 -0
- package/tests/fake-stripe.ts +972 -0
- package/tests/gate.spec.ts +62 -72
- package/tests/limit-gate.spec.ts +68 -0
- package/tests/portal.spec.ts +200 -0
- package/tests/protocol.spec.ts +23 -6
- package/tests/sync.spec.ts +114 -0
- package/tests/usage.integration.spec.ts +101 -0
- package/tests/usage.spec.ts +171 -0
- 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
|
+
}
|