@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
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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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))
|