@owlmeans/server-payment 0.1.18-rc.11 → 0.1.18-rc.13
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 +1 -1
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/server-payment/SKILL.md +67 -7
- package/build/config.d.ts +15 -1
- package/build/config.d.ts.map +1 -1
- package/build/config.js +40 -2
- package/build/config.js.map +1 -1
- package/build/consts.d.ts +10 -0
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +10 -0
- package/build/consts.js.map +1 -1
- package/build/index.d.ts +2 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +1 -0
- package/build/index.js.map +1 -1
- package/build/model.d.ts.map +1 -1
- package/build/model.js +2 -1
- package/build/model.js.map +1 -1
- package/build/plugins/estimate.d.ts +43 -0
- package/build/plugins/estimate.d.ts.map +1 -0
- package/build/plugins/estimate.js +235 -0
- package/build/plugins/estimate.js.map +1 -0
- package/build/plugins/events.d.ts.map +1 -1
- package/build/plugins/events.js +17 -4
- package/build/plugins/events.js.map +1 -1
- package/build/plugins/fx.d.ts +26 -0
- package/build/plugins/fx.d.ts.map +1 -0
- package/build/plugins/fx.js +56 -0
- package/build/plugins/fx.js.map +1 -0
- package/build/plugins/portal.d.ts.map +1 -1
- package/build/plugins/portal.js +6 -2
- package/build/plugins/portal.js.map +1 -1
- package/build/plugins/stripe.d.ts +2 -1
- package/build/plugins/stripe.d.ts.map +1 -1
- package/build/plugins/stripe.js +51 -19
- package/build/plugins/stripe.js.map +1 -1
- package/build/service.d.ts.map +1 -1
- package/build/service.js +6 -0
- package/build/service.js.map +1 -1
- package/build/sync.d.ts +5 -0
- package/build/sync.d.ts.map +1 -1
- package/build/sync.js +80 -14
- package/build/sync.js.map +1 -1
- package/build/types.d.ts +46 -1
- package/build/types.d.ts.map +1 -1
- package/build/utils.d.ts +3 -1
- package/build/utils.d.ts.map +1 -1
- package/build/utils.js +3 -1
- package/build/utils.js.map +1 -1
- package/package.json +16 -16
- package/src/config.ts +47 -5
- package/src/consts.ts +11 -0
- package/src/index.ts +2 -0
- package/src/model.ts +2 -1
- package/src/plugins/estimate.ts +299 -0
- package/src/plugins/events.ts +17 -4
- package/src/plugins/fx.ts +93 -0
- package/src/plugins/portal.ts +6 -2
- package/src/plugins/stripe.ts +55 -19
- package/src/service.ts +6 -0
- package/src/sync.ts +101 -13
- package/src/types.ts +49 -2
- package/src/utils.ts +6 -2
- package/tests/checkout.spec.ts +118 -2
- package/tests/estimate.spec.ts +240 -0
- package/tests/events.spec.ts +13 -0
- package/tests/fake-stripe.ts +130 -6
- package/tests/portal.spec.ts +19 -2
- package/tests/sync.spec.ts +114 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type Stripe from 'stripe'
|
|
2
|
+
import { PaygateError } from '@owlmeans/payment'
|
|
3
|
+
import type { Context as ApiContext } from '@owlmeans/server-api'
|
|
4
|
+
import { STRIPE_FX_QUOTES_API_VERSION } from '../consts.js'
|
|
5
|
+
import { stripePricingConfig } from '../utils.js'
|
|
6
|
+
|
|
7
|
+
interface FxQuoteResponse {
|
|
8
|
+
rates?: Record<string, {
|
|
9
|
+
exchange_rate?: number
|
|
10
|
+
rate_details?: { base_rate?: number; fx_fee_rate?: number; reference_rate?: number }
|
|
11
|
+
}>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface StripeFxRate {
|
|
15
|
+
fromCurrency: string
|
|
16
|
+
toCurrency: string
|
|
17
|
+
/** Stripe's fee-inclusive conversion rate. */
|
|
18
|
+
exchangeRate: number
|
|
19
|
+
/** Market/reference rate used to translate catalogue value into settlement value. */
|
|
20
|
+
referenceRate: number
|
|
21
|
+
fxFeeRate?: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** One unlocked Stripe FX quote. `toCurrency` units per one `fromCurrency` unit. */
|
|
25
|
+
export const stripeFxRate = async (
|
|
26
|
+
stripe: Stripe, fromCurrency: string, toCurrency: string, apiVersion: string,
|
|
27
|
+
): Promise<StripeFxRate | null> => {
|
|
28
|
+
const from = fromCurrency.toLowerCase()
|
|
29
|
+
const to = toCurrency.toLowerCase()
|
|
30
|
+
if (from === to) return { fromCurrency: from, toCurrency: to, exchangeRate: 1, referenceRate: 1 }
|
|
31
|
+
const response = await stripe.rawRequest(
|
|
32
|
+
'POST', '/v1/fx_quotes',
|
|
33
|
+
{ to_currency: to, 'from_currencies[]': from, lock_duration: 'none' },
|
|
34
|
+
{ apiVersion },
|
|
35
|
+
) as Stripe.Response<FxQuoteResponse>
|
|
36
|
+
const rate = response.rates?.[from]
|
|
37
|
+
if (rate?.exchange_rate == null) return null
|
|
38
|
+
return {
|
|
39
|
+
fromCurrency: from,
|
|
40
|
+
toCurrency: to,
|
|
41
|
+
exchangeRate: rate.exchange_rate,
|
|
42
|
+
referenceRate: rate.rate_details?.reference_rate ?? rate.rate_details?.base_rate ?? rate.exchange_rate,
|
|
43
|
+
...(rate.rate_details?.fx_fee_rate != null ? { fxFeeRate: rate.rate_details.fx_fee_rate } : {}),
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Convert whole source minor units at the reference rate, rounding up so value is never lost. */
|
|
48
|
+
export const convertMinor = (amountMinor: number, rate: number): number => {
|
|
49
|
+
const converted = Math.ceil(amountMinor * rate)
|
|
50
|
+
if (!Number.isSafeInteger(amountMinor) || amountMinor < 0 || !Number.isFinite(rate) || rate <= 0
|
|
51
|
+
|| !Number.isSafeInteger(converted)) {
|
|
52
|
+
throw new PaygateError('fx-amount')
|
|
53
|
+
}
|
|
54
|
+
return converted
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface SettlementAmount {
|
|
58
|
+
amountMinor: number
|
|
59
|
+
currency: string
|
|
60
|
+
sourceAmountMinor: number
|
|
61
|
+
sourceCurrency: string
|
|
62
|
+
referenceRate: number
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type StripeFxRateCache = Map<string, Promise<StripeFxRate | null>>
|
|
66
|
+
|
|
67
|
+
/** Translate a catalogue amount into the configured Stripe settlement currency. */
|
|
68
|
+
export const settlementAmount = async (
|
|
69
|
+
ctx: ApiContext, stripe: Stripe, sourceAmountMinor: number, sourceCurrency: string,
|
|
70
|
+
cache?: StripeFxRateCache,
|
|
71
|
+
): Promise<SettlementAmount> => {
|
|
72
|
+
const source = sourceCurrency.toLowerCase()
|
|
73
|
+
const pricing = await stripePricingConfig(ctx)
|
|
74
|
+
const currency = pricing?.settlementCurrency?.toLowerCase() ?? source
|
|
75
|
+
if (currency === source) {
|
|
76
|
+
return {
|
|
77
|
+
amountMinor: sourceAmountMinor, currency, sourceAmountMinor, sourceCurrency: source, referenceRate: 1,
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const apiVersion = pricing?.fxApiVersion ?? STRIPE_FX_QUOTES_API_VERSION
|
|
81
|
+
const key = `${source}:${currency}:${apiVersion}`
|
|
82
|
+
let pending = cache?.get(key)
|
|
83
|
+
if (pending == null) {
|
|
84
|
+
pending = stripeFxRate(stripe, source, currency, apiVersion)
|
|
85
|
+
cache?.set(key, pending)
|
|
86
|
+
}
|
|
87
|
+
const quote = await pending
|
|
88
|
+
if (quote == null) throw new PaygateError(`fx-rate:${source}:${currency}`)
|
|
89
|
+
return {
|
|
90
|
+
amountMinor: convertMinor(sourceAmountMinor, quote.referenceRate), currency,
|
|
91
|
+
sourceAmountMinor, sourceCurrency: source, referenceRate: quote.referenceRate,
|
|
92
|
+
}
|
|
93
|
+
}
|
package/src/plugins/portal.ts
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
import { findPlan, findProduct, planRank } from '../plan.js'
|
|
11
11
|
import { planLookupKey, stripePlansOf } from '../sync.js'
|
|
12
12
|
import {
|
|
13
|
-
fingerprints, isMissingObject, paygateCustomers, portalBrandingConfig, subscriptions,
|
|
13
|
+
fingerprints, isMissingObject, paygateCustomers, payment, portalBrandingConfig, subscriptions,
|
|
14
14
|
} from '../utils.js'
|
|
15
15
|
import { webhookUrlOf } from './webhook-manager.js'
|
|
16
16
|
import type { PaymentProduct, PaymentSubscriptionRecord, PortalLinkOptions } from '../types.js'
|
|
@@ -21,6 +21,10 @@ export const portalFingerprintSku = (service: string): string => `${FINGERPRINT_
|
|
|
21
21
|
|
|
22
22
|
/** The recurring plans sold through Stripe, per product — what the portal may switch between. */
|
|
23
23
|
const recurringCatalog = async (ctx: ApiContext): Promise<Array<{ product: PaymentProduct, lookupKeys: string[], hashable: unknown[] }>> => {
|
|
24
|
+
// The declared tax behavior decides which Stripe price id `sync.ts` keeps for a plan (an
|
|
25
|
+
// in-place `unspecified` update keeps it, an opposite behavior replaces it) — hashed here too,
|
|
26
|
+
// so a behavior change refreshes this configuration's `products[].prices` to the new ids.
|
|
27
|
+
const behavior = (await payment(ctx).pricingPolicy()).tax.behavior ?? null
|
|
24
28
|
const result: Array<{ product: PaymentProduct, lookupKeys: string[], hashable: unknown[] }> = []
|
|
25
29
|
for (const { product, plans } of await stripePlansOf(ctx)) {
|
|
26
30
|
const recurring = plans.filter(plan => plan.recurring != null)
|
|
@@ -32,7 +36,7 @@ const recurringCatalog = async (ctx: ApiContext): Promise<Array<{ product: Payme
|
|
|
32
36
|
lookupKeys: recurring.map(plan => planLookupKey(product, plan)),
|
|
33
37
|
hashable: recurring.map(plan => ({
|
|
34
38
|
sku: plan.sku, price: plan.price, currency: plan.currency ?? 'usd', interval: plan.recurring?.interval,
|
|
35
|
-
rank: planRank(plan),
|
|
39
|
+
rank: planRank(plan), behavior,
|
|
36
40
|
})).sort((a, b) => a.sku.localeCompare(b.sku)),
|
|
37
41
|
})
|
|
38
42
|
}
|
package/src/plugins/stripe.ts
CHANGED
|
@@ -1,23 +1,42 @@
|
|
|
1
1
|
import type Stripe from 'stripe'
|
|
2
2
|
import {
|
|
3
3
|
assertCheckoutAmount, chargeAmountMinor, CheckoutPricingMode, PaygateError, ProductError, ProductType,
|
|
4
|
-
WebhookSetupError,
|
|
4
|
+
TaxBehavior, WebhookSetupError,
|
|
5
5
|
} from '@owlmeans/payment'
|
|
6
|
+
import type { PricingPolicy } from '@owlmeans/payment'
|
|
6
7
|
import type { Context as ApiContext } from '@owlmeans/server-api'
|
|
7
8
|
import { STRIPE_PAYGATE_ALIAS, STRIPE_SIGNATURE } from '../consts.js'
|
|
8
|
-
import { paygateCustomers, payment } from '../utils.js'
|
|
9
|
+
import { paygateCustomers, payment, stripePricingConfig } from '../utils.js'
|
|
9
10
|
import { isSoldThrough, planLookupKey } from '../sync.js'
|
|
10
11
|
import { createEventHandler } from './events.js'
|
|
12
|
+
import { settlementAmount } from './fx.js'
|
|
11
13
|
import { stripeWebhookSecrets } from './webhook-manager.js'
|
|
12
14
|
import type { CreateLinkParams, PaymentPlan, PaymentProduct } from '../types.js'
|
|
13
15
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
/**
|
|
17
|
+
* A Checkout Session's tax and currency options, entirely driven by the declared `PricingPolicy` —
|
|
18
|
+
* an undeclared one (`DEFAULT_PRICING_POLICY`) reproduces exactly what every session hard-coded
|
|
19
|
+
* before this policy existed: automatic tax and tax-id collection on, no Adaptive Pricing.
|
|
20
|
+
*
|
|
21
|
+
* `customer_update.address` lets automatic tax use the billing address Checkout just collected
|
|
22
|
+
* rather than only a previously saved one; `customer_update.name` lets tax-id collection save the
|
|
23
|
+
* business name it collects. Each is included only for the concern that needs it.
|
|
24
|
+
*/
|
|
25
|
+
const checkoutOptions = (policy: PricingPolicy, promotions: boolean): Partial<Stripe.Checkout.SessionCreateParams> => {
|
|
26
|
+
const customerUpdate: Stripe.Checkout.SessionCreateParams.CustomerUpdate = {}
|
|
27
|
+
if (policy.tax.automatic) customerUpdate.address = 'auto'
|
|
28
|
+
if (policy.tax.collectTaxId) customerUpdate.name = 'auto'
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
...(policy.tax.automatic
|
|
32
|
+
? { automatic_tax: { enabled: true }, billing_address_collection: 'required' as const }
|
|
33
|
+
: {}),
|
|
34
|
+
...(policy.tax.collectTaxId ? { tax_id_collection: { enabled: true } } : {}),
|
|
35
|
+
...(Object.keys(customerUpdate).length > 0 ? { customer_update: customerUpdate } : {}),
|
|
36
|
+
...(policy.currency.adaptive === true ? { adaptive_pricing: { enabled: true } } : {}),
|
|
37
|
+
allow_promotion_codes: promotions,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
21
40
|
|
|
22
41
|
const ensureStripeCustomer = async (
|
|
23
42
|
ctx: ApiContext, stripe: Stripe, params: CreateLinkParams,
|
|
@@ -72,7 +91,7 @@ const sharedSession = (
|
|
|
72
91
|
})
|
|
73
92
|
|
|
74
93
|
export const amountCheckoutLineItem = (
|
|
75
|
-
product: PaymentProduct, plan: PaymentPlan, amountMinor: number,
|
|
94
|
+
product: PaymentProduct, plan: PaymentPlan, amountMinor: number, behavior: TaxBehavior = TaxBehavior.Exclusive,
|
|
76
95
|
): { lineItem: Stripe.Checkout.SessionCreateParams.LineItem; chargeMinor: number; currency: string } => {
|
|
77
96
|
if (plan.amountPolicy == null) throw new ProductError(`amount-policy:${plan.sku}`)
|
|
78
97
|
assertCheckoutAmount(plan.amountPolicy, amountMinor)
|
|
@@ -81,7 +100,7 @@ export const amountCheckoutLineItem = (
|
|
|
81
100
|
return {
|
|
82
101
|
lineItem: {
|
|
83
102
|
price_data: {
|
|
84
|
-
product: product.sku, currency, unit_amount: chargeMinor, tax_behavior:
|
|
103
|
+
product: product.sku, currency, unit_amount: chargeMinor, tax_behavior: behavior,
|
|
85
104
|
},
|
|
86
105
|
quantity: 1,
|
|
87
106
|
},
|
|
@@ -110,6 +129,7 @@ export const createCheckoutLink = async (ctx: ApiContext, stripe: Stripe, params
|
|
|
110
129
|
throw new ProductError(`plan:${params.planSku}`)
|
|
111
130
|
}
|
|
112
131
|
const customer = await ensureStripeCustomer(ctx, stripe, params)
|
|
132
|
+
const pricing = await payment(ctx).pricingPolicy()
|
|
113
133
|
|
|
114
134
|
if (product.type === ProductType.Consumable) {
|
|
115
135
|
const plan = plans.find(item => item.sku === params.planSku) ?? plans[0]
|
|
@@ -117,15 +137,27 @@ export const createCheckoutLink = async (ctx: ApiContext, stripe: Stripe, params
|
|
|
117
137
|
|
|
118
138
|
if (plan.pricingMode === CheckoutPricingMode.Amount) {
|
|
119
139
|
if (params.amountMinor == null) throw new ProductError('amount')
|
|
120
|
-
const {
|
|
140
|
+
const { chargeMinor: sourceChargeMinor, currency: amountCurrency } = amountCheckoutLineItem(
|
|
141
|
+
product, plan, params.amountMinor, pricing.tax.behavior,
|
|
142
|
+
)
|
|
143
|
+
const settled = await settlementAmount(ctx, stripe, sourceChargeMinor, amountCurrency)
|
|
144
|
+
const lineItem: Stripe.Checkout.SessionCreateParams.LineItem = {
|
|
145
|
+
price_data: {
|
|
146
|
+
product: product.sku, currency: settled.currency, unit_amount: settled.amountMinor,
|
|
147
|
+
tax_behavior: pricing.tax.behavior,
|
|
148
|
+
},
|
|
149
|
+
quantity: 1,
|
|
150
|
+
}
|
|
121
151
|
const session = await stripe.checkout.sessions.create({
|
|
122
152
|
mode: 'payment', line_items: [lineItem], invoice_creation: { enabled: true },
|
|
123
|
-
...
|
|
153
|
+
...checkoutOptions(pricing, false),
|
|
124
154
|
...sharedSession(customer, params, product, plan, {
|
|
125
155
|
pricingMode: CheckoutPricingMode.Amount,
|
|
126
|
-
currency,
|
|
156
|
+
currency: settled.currency,
|
|
157
|
+
amountCurrency,
|
|
127
158
|
amountMinor: String(params.amountMinor),
|
|
128
|
-
|
|
159
|
+
sourceChargeAmountMinor: String(sourceChargeMinor),
|
|
160
|
+
chargeAmountMinor: String(settled.amountMinor),
|
|
129
161
|
}),
|
|
130
162
|
})
|
|
131
163
|
if (session.url == null) throw new PaygateError('session')
|
|
@@ -133,16 +165,16 @@ export const createCheckoutLink = async (ctx: ApiContext, stripe: Stripe, params
|
|
|
133
165
|
}
|
|
134
166
|
|
|
135
167
|
const price = await findPrice(stripe, product.sku, planLookupKey(product, plan))
|
|
136
|
-
const
|
|
168
|
+
const quantityPolicy = plan.quantityPolicy ?? {
|
|
137
169
|
minimum: plan.minQuantity ?? 1,
|
|
138
170
|
maximum: plan.maxQuantity ?? Math.max(100_000, plan.minQuantity ?? 1),
|
|
139
171
|
default: plan.defaultQuantity ?? plan.minQuantity ?? 1,
|
|
140
172
|
}
|
|
141
173
|
const session = await stripe.checkout.sessions.create({
|
|
142
174
|
mode: 'payment',
|
|
143
|
-
line_items: [quantityCheckoutLineItem(price,
|
|
175
|
+
line_items: [quantityCheckoutLineItem(price, quantityPolicy)],
|
|
144
176
|
invoice_creation: { enabled: true },
|
|
145
|
-
...
|
|
177
|
+
...checkoutOptions(pricing, true),
|
|
146
178
|
...sharedSession(customer, params, product, plan, { pricingMode: CheckoutPricingMode.Quantity }),
|
|
147
179
|
})
|
|
148
180
|
if (session.url == null) throw new PaygateError('session')
|
|
@@ -153,15 +185,19 @@ export const createCheckoutLink = async (ctx: ApiContext, stripe: Stripe, params
|
|
|
153
185
|
?? plans.find(item => item.recurring != null) ?? plans[0]
|
|
154
186
|
if (plan == null) throw new ProductError('plan')
|
|
155
187
|
const price = await findPrice(stripe, product.sku, planLookupKey(product, plan))
|
|
188
|
+
const subscriptionPaymentMethodTypes = (
|
|
189
|
+
await stripePricingConfig(ctx)
|
|
190
|
+
)?.subscriptionPaymentMethodTypes as Stripe.Checkout.SessionCreateParams.PaymentMethodType[] | undefined
|
|
156
191
|
const session = await stripe.checkout.sessions.create({
|
|
157
192
|
mode: 'subscription', line_items: [{ price: price.id, quantity: 1 }],
|
|
193
|
+
...(subscriptionPaymentMethodTypes != null ? { payment_method_types: subscriptionPaymentMethodTypes } : {}),
|
|
158
194
|
subscription_data: {
|
|
159
195
|
metadata: {
|
|
160
196
|
pricingMode: CheckoutPricingMode.Quantity, entityId: params.entityId,
|
|
161
197
|
service: params.service, productSku: product.sku, planSku: plan.sku,
|
|
162
198
|
},
|
|
163
199
|
},
|
|
164
|
-
...
|
|
200
|
+
...checkoutOptions(pricing, true),
|
|
165
201
|
...sharedSession(customer, params, product, plan, {}),
|
|
166
202
|
})
|
|
167
203
|
if (session.url == null) throw new PaygateError('session')
|
package/src/service.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { makeLimitGate } from './limit.js'
|
|
|
16
16
|
import { appendCompletionObserver } from './observer.js'
|
|
17
17
|
import { findPlan, findProduct, planRank } from './plan.js'
|
|
18
18
|
import { resyncStripeSubscription, resyncStripeSubscriptions } from './plugins/events.js'
|
|
19
|
+
import { makeEstimateCache, estimateStripePrice } from './plugins/estimate.js'
|
|
19
20
|
import { createPortalLink, ensurePortalConfiguration } from './plugins/portal.js'
|
|
20
21
|
import { createCheckoutLink } from './plugins/stripe.js'
|
|
21
22
|
import { ensureWebhookEndpoint } from './plugins/webhook-manager.js'
|
|
@@ -103,6 +104,9 @@ export const makeGatewayService = (
|
|
|
103
104
|
alias: string = GATEWAY_SERVICE, opts: Pick<PaymentGatewayOptions, 'manage'> = {},
|
|
104
105
|
): GatewayService => {
|
|
105
106
|
const managed = opts.manage !== false
|
|
107
|
+
// One estimate cache per gateway SERVICE instance, never module-level: several service
|
|
108
|
+
// instances (several tests, several deployments in one process) must never share hits.
|
|
109
|
+
const estimateCache = makeEstimateCache()
|
|
106
110
|
const service = createService<GatewayService>(alias, {
|
|
107
111
|
managed,
|
|
108
112
|
createLink: async (ctx, params) => managed
|
|
@@ -114,6 +118,8 @@ export const makeGatewayService = (
|
|
|
114
118
|
? await resyncStripeSubscription(ctx, await stripeClient(ctx), ref) : unmanaged(),
|
|
115
119
|
resyncAll: async ctx => managed
|
|
116
120
|
? await resyncStripeSubscriptions(ctx, await stripeClient(ctx)) : unmanaged(),
|
|
121
|
+
estimatePrice: async (ctx, params) => managed
|
|
122
|
+
? await estimateStripePrice(ctx, await stripeClient(ctx), params, estimateCache) : unmanaged(),
|
|
117
123
|
}, service => async () => {
|
|
118
124
|
const ctx = service.assertCtx() as unknown as ApiContext
|
|
119
125
|
assertPlanDeclarations(ctx.cfg)
|
package/src/sync.ts
CHANGED
|
@@ -1,10 +1,20 @@
|
|
|
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
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'
|
|
6
|
+
import { fingerprints, payment, stripeClient, stripePricingConfig } from './utils.js'
|
|
7
7
|
import type { PaymentPlan, PaymentProduct } from './types.js'
|
|
8
|
+
import { settlementAmount } from './plugins/fx.js'
|
|
9
|
+
import type { StripeFxRateCache } from './plugins/fx.js'
|
|
10
|
+
|
|
11
|
+
interface ResolvedPlan {
|
|
12
|
+
plan: PaymentPlan
|
|
13
|
+
unitAmount: number
|
|
14
|
+
currency: string
|
|
15
|
+
sourceUnitAmount: number
|
|
16
|
+
sourceCurrency: string
|
|
17
|
+
}
|
|
8
18
|
|
|
9
19
|
export const planLookupKey = (product: PaymentProduct, plan: PaymentPlan): string =>
|
|
10
20
|
product.type === ProductType.Consumable ? `${product.sku}-consumable` : plan.sku
|
|
@@ -31,14 +41,22 @@ export const stripePlansOf = async (ctx: ApiContext): Promise<Array<{ product: P
|
|
|
31
41
|
return result
|
|
32
42
|
}
|
|
33
43
|
|
|
34
|
-
|
|
44
|
+
/**
|
|
45
|
+
* `behavior` is hashed alongside the catalogue: an undeclared policy hashes as `null`, so declaring
|
|
46
|
+
* or changing `tax.behavior` re-syncs every product exactly once, the same as any other catalogue
|
|
47
|
+
* edit.
|
|
48
|
+
*/
|
|
49
|
+
const fingerprintOf = (
|
|
50
|
+
product: PaymentProduct, plans: ResolvedPlan[], behavior: TaxBehavior | null,
|
|
51
|
+
): string => createHash('sha256')
|
|
35
52
|
.update(JSON.stringify({
|
|
36
53
|
sku: product.sku, type: product.type, name: product.title,
|
|
37
54
|
description: product.description ?? null, taxCode: product.taxCode ?? null,
|
|
38
55
|
unitLabel: product.unitLabel ?? null, services: [...(product.services ?? [])].sort(),
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
56
|
+
behavior,
|
|
57
|
+
plans: plans.map(({ plan, unitAmount, currency, sourceUnitAmount, sourceCurrency }) => ({
|
|
58
|
+
sku: plan.sku, price: plan.price, currency, unitAmount, sourceUnitAmount, sourceCurrency,
|
|
59
|
+
duration: plan.duration, recurring: plan.recurring ?? null, pricingMode: plan.pricingMode ?? null,
|
|
42
60
|
amountPolicy: plan.amountPolicy ?? null, quantityPolicy: plan.quantityPolicy ?? null,
|
|
43
61
|
lookup: planLookupKey(product, plan),
|
|
44
62
|
})).sort((a, b) => a.sku.localeCompare(b.sku)),
|
|
@@ -69,21 +87,71 @@ const deactivateAmountPrice = async (stripe: Stripe, product: PaymentProduct, pl
|
|
|
69
87
|
}
|
|
70
88
|
}
|
|
71
89
|
|
|
72
|
-
|
|
90
|
+
/** Whether `price` already carries the OPPOSITE of `behavior` — never `unspecified`, which is not a conflict. */
|
|
91
|
+
const opposesBehavior = (price: Stripe.Price, behavior: TaxBehavior | null): boolean =>
|
|
92
|
+
behavior != null && price.tax_behavior !== 'unspecified' && price.tax_behavior !== behavior
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The behavior the Stripe account's own Tax Settings resolve to for `currency`, or `null` when no
|
|
96
|
+
* default is configured yet (nothing established to disrupt). `inferred_by_currency` follows
|
|
97
|
+
* Stripe's own rule: exclusive for USD/CAD, inclusive otherwise.
|
|
98
|
+
*/
|
|
99
|
+
const accountDefaultBehavior = async (stripe: Stripe, currency: string): Promise<TaxBehavior | null> => {
|
|
100
|
+
const { defaults } = await stripe.tax.settings.retrieve()
|
|
101
|
+
if (defaults.tax_behavior === 'inferred_by_currency') {
|
|
102
|
+
return ['usd', 'cad'].includes(currency.toLowerCase()) ? TaxBehavior.Exclusive : TaxBehavior.Inclusive
|
|
103
|
+
}
|
|
104
|
+
if (defaults.tax_behavior === 'exclusive') return TaxBehavior.Exclusive
|
|
105
|
+
if (defaults.tax_behavior === 'inclusive') return TaxBehavior.Inclusive
|
|
106
|
+
return null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Give `price` the declared `behavior` while it is still `unspecified` (the only state Stripe lets
|
|
111
|
+
* an existing price's `tax_behavior` be set from). Skipped, with a `console.error`, when the
|
|
112
|
+
* account's own default resolves to the opposite behavior — applying ours would then change what an
|
|
113
|
+
* existing renewal actually charges — unless `migrateUnspecifiedPrices` opts into that migration.
|
|
114
|
+
*/
|
|
115
|
+
const applyUnspecifiedBehavior = async (
|
|
116
|
+
stripe: Stripe, price: Stripe.Price, lookupKey: string, behavior: TaxBehavior, migrateUnspecifiedPrices: boolean,
|
|
117
|
+
): Promise<void> => {
|
|
118
|
+
if (!migrateUnspecifiedPrices) {
|
|
119
|
+
const resolved = await accountDefaultBehavior(stripe, price.currency)
|
|
120
|
+
if (resolved != null && resolved !== behavior) {
|
|
121
|
+
console.error(
|
|
122
|
+
`[payment] price '${lookupKey}' left 'unspecified': the Stripe account's default tax `
|
|
123
|
+
+ `behavior for ${price.currency.toUpperCase()} is '${resolved}', not the declared `
|
|
124
|
+
+ `'${behavior}' — applying it would change existing renewal amounts. Set `
|
|
125
|
+
+ '`stripe.migrateUnspecifiedPrices` to override.',
|
|
126
|
+
)
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
await stripe.prices.update(price.id, { tax_behavior: behavior })
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const ensureStripePrice = async (
|
|
134
|
+
stripe: Stripe, product: PaymentProduct, resolved: ResolvedPlan,
|
|
135
|
+
behavior: TaxBehavior | null, migrateUnspecifiedPrices: boolean,
|
|
136
|
+
): Promise<void> => {
|
|
137
|
+
const { plan, unitAmount, currency } = resolved
|
|
73
138
|
if (plan.pricingMode === CheckoutPricingMode.Amount) {
|
|
74
139
|
await deactivateAmountPrice(stripe, product, plan)
|
|
75
140
|
return
|
|
76
141
|
}
|
|
77
142
|
const lookupKey = planLookupKey(product, plan)
|
|
78
|
-
const unitAmount = Math.round(plan.price * 100)
|
|
79
|
-
const currency = plan.currency ?? 'usd'
|
|
80
143
|
const recurring = plan.recurring != null
|
|
81
144
|
? { interval: plan.recurring.interval } as Stripe.PriceCreateParams.Recurring : undefined
|
|
82
145
|
const existing = await activePrices(stripe, product)
|
|
83
|
-
const
|
|
146
|
+
const candidate = existing.find(price => price.lookup_key === lookupKey && price.unit_amount === unitAmount
|
|
84
147
|
&& price.currency === currency
|
|
85
148
|
&& ((price.recurring?.interval ?? null) === (recurring?.interval ?? null)))
|
|
86
|
-
if (
|
|
149
|
+
if (candidate != null && !opposesBehavior(candidate, behavior)) {
|
|
150
|
+
if (behavior != null && candidate.tax_behavior === 'unspecified') {
|
|
151
|
+
await applyUnspecifiedBehavior(stripe, candidate, lookupKey, behavior, migrateUnspecifiedPrices)
|
|
152
|
+
}
|
|
153
|
+
return
|
|
154
|
+
}
|
|
87
155
|
for (const price of existing.filter(item => item.lookup_key === lookupKey)) {
|
|
88
156
|
await stripe.prices.update(price.id, { active: false })
|
|
89
157
|
}
|
|
@@ -91,6 +159,7 @@ const ensureStripePrice = async (stripe: Stripe, product: PaymentProduct, plan:
|
|
|
91
159
|
product: product.sku, currency, unit_amount: unitAmount, lookup_key: lookupKey,
|
|
92
160
|
transfer_lookup_key: true, nickname: plan.sku,
|
|
93
161
|
...(recurring != null ? { recurring } : { billing_scheme: 'per_unit' }),
|
|
162
|
+
...(behavior != null ? { tax_behavior: behavior } : {}),
|
|
94
163
|
metadata: { sku: plan.sku, ...(product.services && { services: product.services.join(',') }) },
|
|
95
164
|
})
|
|
96
165
|
}
|
|
@@ -99,15 +168,34 @@ const ensureStripePrice = async (stripe: Stripe, product: PaymentProduct, plan:
|
|
|
99
168
|
* Synchronize every product sold through Stripe, and its Stripe-sold plans, to Stripe products and
|
|
100
169
|
* prices. A product whose declaration fingerprint is unchanged makes no paygate call. Free plans
|
|
101
170
|
* and plans sold through no Stripe gateway are never synchronized.
|
|
171
|
+
*
|
|
172
|
+
* The declared `PricingPolicy.tax.behavior` (absent by default, so a price's `tax_behavior` stays
|
|
173
|
+
* whatever it already was) is applied to a matching price only while it is `unspecified` — Stripe
|
|
174
|
+
* forbids changing a price once set to `exclusive` or `inclusive` — and to a fresh one on creation.
|
|
175
|
+
* A price carrying the OPPOSITE behavior is deactivated and replaced, same as any other mismatch.
|
|
102
176
|
*/
|
|
103
177
|
export const syncStripeProducts = async (ctx: ApiContext, stripe: Stripe): Promise<void> => {
|
|
104
178
|
const fpRes = fingerprints(ctx)
|
|
179
|
+
const behavior = (await payment(ctx).pricingPolicy()).tax.behavior ?? null
|
|
180
|
+
const migrateUnspecifiedPrices = (await stripePricingConfig(ctx))?.migrateUnspecifiedPrices ?? false
|
|
181
|
+
const fxRates: StripeFxRateCache = new Map()
|
|
105
182
|
for (const { product, plans } of await stripePlansOf(ctx)) {
|
|
106
|
-
const
|
|
183
|
+
const resolvedPlans: ResolvedPlan[] = []
|
|
184
|
+
for (const plan of plans) {
|
|
185
|
+
const sourceUnitAmount = Math.round(plan.price * 100)
|
|
186
|
+
const sourceCurrency = (plan.currency ?? 'usd').toLowerCase()
|
|
187
|
+
const settled = plan.pricingMode === CheckoutPricingMode.Amount
|
|
188
|
+
? { amountMinor: sourceUnitAmount, currency: sourceCurrency }
|
|
189
|
+
: await settlementAmount(ctx, stripe, sourceUnitAmount, sourceCurrency, fxRates)
|
|
190
|
+
resolvedPlans.push({ plan, unitAmount: settled.amountMinor, currency: settled.currency, sourceUnitAmount, sourceCurrency })
|
|
191
|
+
}
|
|
192
|
+
const hash = fingerprintOf(product, resolvedPlans, behavior)
|
|
107
193
|
const stored = await fpRes.bySku(product.sku)
|
|
108
194
|
if (stored != null && stored.hash === hash) continue
|
|
109
195
|
const stripeProduct = await ensureStripeProduct(stripe, product)
|
|
110
|
-
for (const
|
|
196
|
+
for (const resolved of resolvedPlans) {
|
|
197
|
+
await ensureStripePrice(stripe, product, resolved, behavior, migrateUnspecifiedPrices)
|
|
198
|
+
}
|
|
111
199
|
if (stored != null) {
|
|
112
200
|
await fpRes.update({ ...stored, hash, productId: stripeProduct.id, updatedAt: new Date() })
|
|
113
201
|
} else {
|
package/src/types.ts
CHANGED
|
@@ -6,8 +6,8 @@ import type { PermissionSet } from '@owlmeans/auth'
|
|
|
6
6
|
import type { Config as ApiConfig, Context as ApiContext } from '@owlmeans/server-api'
|
|
7
7
|
import type {
|
|
8
8
|
AmountCheckoutPolicy, CheckoutPricingMode, EntitlementView, LimitDeclaration, LimitView,
|
|
9
|
-
PlanCapability, PlanDuration, PortalFlow,
|
|
10
|
-
SubscriptionStatus,
|
|
9
|
+
PlanCapability, PlanDuration, PortalFlow, PriceEstimate, PricingPolicy, Product, ProductPlan,
|
|
10
|
+
ProductType, QuantityCheckoutPolicy, SubscriptionStatus,
|
|
11
11
|
} from '@owlmeans/payment'
|
|
12
12
|
|
|
13
13
|
export interface Config extends ApiConfig {}
|
|
@@ -76,6 +76,32 @@ export interface PaymentPlanDef {
|
|
|
76
76
|
export interface StripeSecretsDef { api: string; webhook?: string }
|
|
77
77
|
export interface StripePluginConfig extends PluginConfig { api: string; webhook?: string }
|
|
78
78
|
|
|
79
|
+
/** Stripe-only pricing settings — never a browser-visible field (see `PricingPolicy` for those). */
|
|
80
|
+
export interface StripePricingDef {
|
|
81
|
+
/** Overrides `STRIPE_FX_QUOTES_API_VERSION`, when Stripe moves or renames the preview. */
|
|
82
|
+
fxApiVersion?: string
|
|
83
|
+
/**
|
|
84
|
+
* Stripe settlement currency used when a catalogue price is declared in another currency.
|
|
85
|
+
* Recurring prices are converted during product sync; amount checkout is converted per session.
|
|
86
|
+
*/
|
|
87
|
+
settlementCurrency?: string
|
|
88
|
+
/** Explicit Stripe payment methods for subscription Checkout; absent keeps Stripe's dynamic selection. */
|
|
89
|
+
subscriptionPaymentMethodTypes?: string[]
|
|
90
|
+
/**
|
|
91
|
+
* Let a matching `unspecified` price take the declared `tax.behavior` even when the Stripe
|
|
92
|
+
* account's own tax-settings default resolves to the opposite one — which changes what an
|
|
93
|
+
* existing subscriber is charged at their next renewal. Absent/`false`: such a price is left
|
|
94
|
+
* `unspecified` and a `console.error` explains why.
|
|
95
|
+
*/
|
|
96
|
+
migrateUnspecifiedPrices?: boolean
|
|
97
|
+
}
|
|
98
|
+
export interface StripePricingPluginConfig extends PluginConfig, StripePricingDef {}
|
|
99
|
+
|
|
100
|
+
/** `declarePaymentPricing`'s argument: the browser-safe `PricingPolicy` plus Stripe-only settings. */
|
|
101
|
+
export interface PricingDef extends PricingPolicy {
|
|
102
|
+
stripe?: StripePricingDef
|
|
103
|
+
}
|
|
104
|
+
|
|
79
105
|
/** What the managed customer portal configuration shows. */
|
|
80
106
|
export interface PortalBrandingDef {
|
|
81
107
|
headline?: string
|
|
@@ -109,6 +135,16 @@ export interface PortalLinkOptions {
|
|
|
109
135
|
returnUrl: string
|
|
110
136
|
}
|
|
111
137
|
|
|
138
|
+
export interface PriceEstimateParams {
|
|
139
|
+
productSku: string
|
|
140
|
+
/** Stable database key resolved by the application before this in-process call. */
|
|
141
|
+
entityId: string
|
|
142
|
+
/** Absent: the product's own reference plan (an amount-priced consumable). */
|
|
143
|
+
planSku?: string
|
|
144
|
+
/** ISO 3166-1 alpha-2. Absent: taken from the entity's paygate customer address. */
|
|
145
|
+
country?: string
|
|
146
|
+
}
|
|
147
|
+
|
|
112
148
|
export interface GrantInternalPlanOptions {
|
|
113
149
|
/** Required to grant a plan that is not `free`. */
|
|
114
150
|
force?: boolean
|
|
@@ -127,6 +163,8 @@ export interface GatewayService extends InitializedService {
|
|
|
127
163
|
managed: boolean
|
|
128
164
|
createLink: (ctx: ApiContext, params: CreateLinkParams) => Promise<string>
|
|
129
165
|
portalLink: (ctx: ApiContext, entityId: string, opts: PortalLinkOptions) => Promise<string>
|
|
166
|
+
/** @throws PaygateError('unmanaged') when `managed` is `false`. */
|
|
167
|
+
estimatePrice: (ctx: ApiContext, params: PriceEstimateParams) => Promise<PriceEstimate>
|
|
130
168
|
grantInternalPlan: (
|
|
131
169
|
ctx: ApiContext, entityId: string, planSku: string, opts?: GrantInternalPlanOptions,
|
|
132
170
|
) => Promise<PaymentSubscriptionRecord>
|
|
@@ -167,8 +205,15 @@ export interface QuantityTopUpCompletion extends TopUpBase {
|
|
|
167
205
|
}
|
|
168
206
|
export interface AmountTopUpCompletion extends TopUpBase {
|
|
169
207
|
mode: 'amount'
|
|
208
|
+
/** Net value in the amount policy's catalogue currency. */
|
|
170
209
|
amountMinor: number
|
|
210
|
+
/** Grossed-up value in the amount policy's catalogue currency, before settlement conversion. */
|
|
211
|
+
sourceChargeAmountMinor: number
|
|
212
|
+
/** The amount policy's catalogue currency. */
|
|
213
|
+
amountCurrency: string
|
|
214
|
+
/** Pre-tax subtotal actually charged by Stripe, in `currency`. */
|
|
171
215
|
chargeAmountMinor: number
|
|
216
|
+
/** Stripe integration/settlement currency. */
|
|
172
217
|
currency: string
|
|
173
218
|
}
|
|
174
219
|
export type TopUpCompletion = QuantityTopUpCompletion | AmountTopUpCompletion
|
|
@@ -369,6 +414,8 @@ export interface PaymentFulfillmentRecord extends ResourceRecord {
|
|
|
369
414
|
mode: CheckoutPricingMode
|
|
370
415
|
units?: number
|
|
371
416
|
amountMinor?: number
|
|
417
|
+
sourceChargeAmountMinor?: number
|
|
418
|
+
amountCurrency?: string
|
|
372
419
|
chargeAmountMinor?: number
|
|
373
420
|
currency?: string
|
|
374
421
|
createdAt: Date
|
package/src/utils.ts
CHANGED
|
@@ -7,13 +7,13 @@ import type { Context as ApiContext } from '@owlmeans/server-api'
|
|
|
7
7
|
import {
|
|
8
8
|
ENTITLEMENT_SERVICE, GATEWAY_SERVICE, PAYMENT_OBSERVER, RES_PAYGATE_CUSTOMER, RES_PAYMENT_FINGERPRINT,
|
|
9
9
|
RES_PAYMENT_FULFILLMENT, RES_PAYMENT_SUBSCRIPTION, RES_PAYMENT_USAGE, RES_PAYMENT_USAGE_COUNTER,
|
|
10
|
-
RES_PAYMENT_WEBHOOK, STRIPE_PLUGIN_CONFIG, STRIPE_PORTAL_PLUGIN_CONFIG,
|
|
10
|
+
RES_PAYMENT_WEBHOOK, STRIPE_PLUGIN_CONFIG, STRIPE_PORTAL_PLUGIN_CONFIG, STRIPE_PRICING_PLUGIN_CONFIG,
|
|
11
11
|
} from './consts.js'
|
|
12
12
|
import type {
|
|
13
13
|
CompletionObserver, EntitlementService, FingerprintResource, GatewayService, PaygateCustomerResource,
|
|
14
14
|
PaymentFulfillmentResource, PaymentSubscriptionRecord, PaymentSubscriptionResource,
|
|
15
15
|
PaymentUsageCounterResource, PaymentUsageResource, PaymentWebhookResource, PortalBrandingConfig,
|
|
16
|
-
StripePluginConfig,
|
|
16
|
+
StripePluginConfig, StripePricingPluginConfig,
|
|
17
17
|
} from './types.js'
|
|
18
18
|
|
|
19
19
|
export const payment = (ctx: ApiContext): PaymentService => ctx.service<PaymentService>(PAYMENT_SERVICE)
|
|
@@ -49,6 +49,10 @@ export const stripeConfig = async (ctx: ApiContext): Promise<StripePluginConfig>
|
|
|
49
49
|
export const portalBrandingConfig = async (ctx: ApiContext): Promise<PortalBrandingConfig | null> =>
|
|
50
50
|
await (ctx as never as PluginReader).getConfigResource(PLUGINS).load(STRIPE_PORTAL_PLUGIN_CONFIG) as PortalBrandingConfig | null
|
|
51
51
|
|
|
52
|
+
/** The Stripe-only pricing settings declared with `declarePaymentPricing`, or `null`. */
|
|
53
|
+
export const stripePricingConfig = async (ctx: ApiContext): Promise<StripePricingPluginConfig | null> =>
|
|
54
|
+
await (ctx as never as PluginReader).getConfigResource(PLUGINS).load(STRIPE_PRICING_PLUGIN_CONFIG) as StripePricingPluginConfig | null
|
|
55
|
+
|
|
52
56
|
/**
|
|
53
57
|
* A Stripe client pinned to the API version the installed SDK is typed for (its default), so every
|
|
54
58
|
* object shape this package reads is the one the types describe.
|