@owlmeans/server-payment 0.1.18-rc.12 → 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 +27 -7
- package/build/config.d.ts.map +1 -1
- package/build/config.js +16 -2
- package/build/config.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.map +1 -1
- package/build/plugins/estimate.js +18 -8
- package/build/plugins/estimate.js.map +1 -1
- package/build/plugins/events.d.ts.map +1 -1
- package/build/plugins/events.js +14 -3
- 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/stripe.d.ts.map +1 -1
- package/build/plugins/stripe.js +17 -4
- package/build/plugins/stripe.js.map +1 -1
- package/build/sync.d.ts.map +1 -1
- package/build/sync.js +20 -9
- package/build/sync.js.map +1 -1
- package/build/types.d.ts +16 -0
- package/build/types.d.ts.map +1 -1
- package/package.json +16 -16
- package/src/config.ts +16 -2
- package/src/model.ts +2 -1
- package/src/plugins/estimate.ts +18 -17
- package/src/plugins/events.ts +14 -3
- package/src/plugins/fx.ts +93 -0
- package/src/plugins/stripe.ts +19 -4
- package/src/sync.ts +30 -9
- package/src/types.ts +16 -0
- package/tests/checkout.spec.ts +53 -0
- package/tests/estimate.spec.ts +22 -0
- package/tests/fake-stripe.ts +10 -3
- package/tests/sync.spec.ts +26 -0
package/src/plugins/stripe.ts
CHANGED
|
@@ -6,9 +6,10 @@ import {
|
|
|
6
6
|
import type { PricingPolicy } from '@owlmeans/payment'
|
|
7
7
|
import type { Context as ApiContext } from '@owlmeans/server-api'
|
|
8
8
|
import { STRIPE_PAYGATE_ALIAS, STRIPE_SIGNATURE } from '../consts.js'
|
|
9
|
-
import { paygateCustomers, payment } from '../utils.js'
|
|
9
|
+
import { paygateCustomers, payment, stripePricingConfig } from '../utils.js'
|
|
10
10
|
import { isSoldThrough, planLookupKey } from '../sync.js'
|
|
11
11
|
import { createEventHandler } from './events.js'
|
|
12
|
+
import { settlementAmount } from './fx.js'
|
|
12
13
|
import { stripeWebhookSecrets } from './webhook-manager.js'
|
|
13
14
|
import type { CreateLinkParams, PaymentPlan, PaymentProduct } from '../types.js'
|
|
14
15
|
|
|
@@ -136,17 +137,27 @@ export const createCheckoutLink = async (ctx: ApiContext, stripe: Stripe, params
|
|
|
136
137
|
|
|
137
138
|
if (plan.pricingMode === CheckoutPricingMode.Amount) {
|
|
138
139
|
if (params.amountMinor == null) throw new ProductError('amount')
|
|
139
|
-
const {
|
|
140
|
+
const { chargeMinor: sourceChargeMinor, currency: amountCurrency } = amountCheckoutLineItem(
|
|
140
141
|
product, plan, params.amountMinor, pricing.tax.behavior,
|
|
141
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
|
+
}
|
|
142
151
|
const session = await stripe.checkout.sessions.create({
|
|
143
152
|
mode: 'payment', line_items: [lineItem], invoice_creation: { enabled: true },
|
|
144
153
|
...checkoutOptions(pricing, false),
|
|
145
154
|
...sharedSession(customer, params, product, plan, {
|
|
146
155
|
pricingMode: CheckoutPricingMode.Amount,
|
|
147
|
-
currency,
|
|
156
|
+
currency: settled.currency,
|
|
157
|
+
amountCurrency,
|
|
148
158
|
amountMinor: String(params.amountMinor),
|
|
149
|
-
|
|
159
|
+
sourceChargeAmountMinor: String(sourceChargeMinor),
|
|
160
|
+
chargeAmountMinor: String(settled.amountMinor),
|
|
150
161
|
}),
|
|
151
162
|
})
|
|
152
163
|
if (session.url == null) throw new PaygateError('session')
|
|
@@ -174,8 +185,12 @@ export const createCheckoutLink = async (ctx: ApiContext, stripe: Stripe, params
|
|
|
174
185
|
?? plans.find(item => item.recurring != null) ?? plans[0]
|
|
175
186
|
if (plan == null) throw new ProductError('plan')
|
|
176
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
|
|
177
191
|
const session = await stripe.checkout.sessions.create({
|
|
178
192
|
mode: 'subscription', line_items: [{ price: price.id, quantity: 1 }],
|
|
193
|
+
...(subscriptionPaymentMethodTypes != null ? { payment_method_types: subscriptionPaymentMethodTypes } : {}),
|
|
179
194
|
subscription_data: {
|
|
180
195
|
metadata: {
|
|
181
196
|
pricingMode: CheckoutPricingMode.Quantity, entityId: params.entityId,
|
package/src/sync.ts
CHANGED
|
@@ -5,6 +5,16 @@ import type { Context as ApiContext } from '@owlmeans/server-api'
|
|
|
5
5
|
import { STRIPE_PAYGATE_ALIAS } from './consts.js'
|
|
6
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
|
|
@@ -37,16 +47,16 @@ export const stripePlansOf = async (ctx: ApiContext): Promise<Array<{ product: P
|
|
|
37
47
|
* edit.
|
|
38
48
|
*/
|
|
39
49
|
const fingerprintOf = (
|
|
40
|
-
product: PaymentProduct, plans:
|
|
50
|
+
product: PaymentProduct, plans: ResolvedPlan[], behavior: TaxBehavior | null,
|
|
41
51
|
): string => createHash('sha256')
|
|
42
52
|
.update(JSON.stringify({
|
|
43
53
|
sku: product.sku, type: product.type, name: product.title,
|
|
44
54
|
description: product.description ?? null, taxCode: product.taxCode ?? null,
|
|
45
55
|
unitLabel: product.unitLabel ?? null, services: [...(product.services ?? [])].sort(),
|
|
46
56
|
behavior,
|
|
47
|
-
plans: plans.map(plan => ({
|
|
48
|
-
sku: plan.sku, price: plan.price, currency
|
|
49
|
-
recurring: plan.recurring ?? null, pricingMode: plan.pricingMode ?? null,
|
|
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,
|
|
50
60
|
amountPolicy: plan.amountPolicy ?? null, quantityPolicy: plan.quantityPolicy ?? null,
|
|
51
61
|
lookup: planLookupKey(product, plan),
|
|
52
62
|
})).sort((a, b) => a.sku.localeCompare(b.sku)),
|
|
@@ -121,16 +131,15 @@ const applyUnspecifiedBehavior = async (
|
|
|
121
131
|
}
|
|
122
132
|
|
|
123
133
|
const ensureStripePrice = async (
|
|
124
|
-
stripe: Stripe, product: PaymentProduct,
|
|
134
|
+
stripe: Stripe, product: PaymentProduct, resolved: ResolvedPlan,
|
|
125
135
|
behavior: TaxBehavior | null, migrateUnspecifiedPrices: boolean,
|
|
126
136
|
): Promise<void> => {
|
|
137
|
+
const { plan, unitAmount, currency } = resolved
|
|
127
138
|
if (plan.pricingMode === CheckoutPricingMode.Amount) {
|
|
128
139
|
await deactivateAmountPrice(stripe, product, plan)
|
|
129
140
|
return
|
|
130
141
|
}
|
|
131
142
|
const lookupKey = planLookupKey(product, plan)
|
|
132
|
-
const unitAmount = Math.round(plan.price * 100)
|
|
133
|
-
const currency = plan.currency ?? 'usd'
|
|
134
143
|
const recurring = plan.recurring != null
|
|
135
144
|
? { interval: plan.recurring.interval } as Stripe.PriceCreateParams.Recurring : undefined
|
|
136
145
|
const existing = await activePrices(stripe, product)
|
|
@@ -169,12 +178,24 @@ export const syncStripeProducts = async (ctx: ApiContext, stripe: Stripe): Promi
|
|
|
169
178
|
const fpRes = fingerprints(ctx)
|
|
170
179
|
const behavior = (await payment(ctx).pricingPolicy()).tax.behavior ?? null
|
|
171
180
|
const migrateUnspecifiedPrices = (await stripePricingConfig(ctx))?.migrateUnspecifiedPrices ?? false
|
|
181
|
+
const fxRates: StripeFxRateCache = new Map()
|
|
172
182
|
for (const { product, plans } of await stripePlansOf(ctx)) {
|
|
173
|
-
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)
|
|
174
193
|
const stored = await fpRes.bySku(product.sku)
|
|
175
194
|
if (stored != null && stored.hash === hash) continue
|
|
176
195
|
const stripeProduct = await ensureStripeProduct(stripe, product)
|
|
177
|
-
for (const
|
|
196
|
+
for (const resolved of resolvedPlans) {
|
|
197
|
+
await ensureStripePrice(stripe, product, resolved, behavior, migrateUnspecifiedPrices)
|
|
198
|
+
}
|
|
178
199
|
if (stored != null) {
|
|
179
200
|
await fpRes.update({ ...stored, hash, productId: stripeProduct.id, updatedAt: new Date() })
|
|
180
201
|
} else {
|
package/src/types.ts
CHANGED
|
@@ -80,6 +80,13 @@ export interface StripePluginConfig extends PluginConfig { api: string; webhook?
|
|
|
80
80
|
export interface StripePricingDef {
|
|
81
81
|
/** Overrides `STRIPE_FX_QUOTES_API_VERSION`, when Stripe moves or renames the preview. */
|
|
82
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[]
|
|
83
90
|
/**
|
|
84
91
|
* Let a matching `unspecified` price take the declared `tax.behavior` even when the Stripe
|
|
85
92
|
* account's own tax-settings default resolves to the opposite one — which changes what an
|
|
@@ -198,8 +205,15 @@ export interface QuantityTopUpCompletion extends TopUpBase {
|
|
|
198
205
|
}
|
|
199
206
|
export interface AmountTopUpCompletion extends TopUpBase {
|
|
200
207
|
mode: 'amount'
|
|
208
|
+
/** Net value in the amount policy's catalogue currency. */
|
|
201
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`. */
|
|
202
215
|
chargeAmountMinor: number
|
|
216
|
+
/** Stripe integration/settlement currency. */
|
|
203
217
|
currency: string
|
|
204
218
|
}
|
|
205
219
|
export type TopUpCompletion = QuantityTopUpCompletion | AmountTopUpCompletion
|
|
@@ -400,6 +414,8 @@ export interface PaymentFulfillmentRecord extends ResourceRecord {
|
|
|
400
414
|
mode: CheckoutPricingMode
|
|
401
415
|
units?: number
|
|
402
416
|
amountMinor?: number
|
|
417
|
+
sourceChargeAmountMinor?: number
|
|
418
|
+
amountCurrency?: string
|
|
403
419
|
chargeAmountMinor?: number
|
|
404
420
|
currency?: string
|
|
405
421
|
createdAt: Date
|
package/tests/checkout.spec.ts
CHANGED
|
@@ -115,6 +115,20 @@ describe('Stripe checkout — pricing policy', () => {
|
|
|
115
115
|
expect(fake.state.checkoutSessions[0].adaptive_pricing).toEqual({ enabled: true })
|
|
116
116
|
})
|
|
117
117
|
|
|
118
|
+
test('subscription checkout can explicitly offer recurring-capable payment methods', async () => {
|
|
119
|
+
const fake = await makeFakeContext({
|
|
120
|
+
pricing: {
|
|
121
|
+
tax: { automatic: true, collectTaxId: true, estimate: false }, currency: { estimate: false },
|
|
122
|
+
stripe: { subscriptionPaymentMethodTypes: ['CARD', 'link', 'klarna'] },
|
|
123
|
+
},
|
|
124
|
+
stripe: { prices: [proPrice] },
|
|
125
|
+
})
|
|
126
|
+
await createCheckoutLink(fake.ctx, fake.stripe, {
|
|
127
|
+
productSku: PLANS_PRODUCT, planSku: PRO, entityId: 'entity-1', service: 'app', successUrl,
|
|
128
|
+
})
|
|
129
|
+
expect(fake.state.checkoutSessions[0].payment_method_types).toEqual(['card', 'link', 'klarna'])
|
|
130
|
+
})
|
|
131
|
+
|
|
118
132
|
test('a declared behavior is set on the inline amount line item', async () => {
|
|
119
133
|
const fake = await makeFakeContext({
|
|
120
134
|
pricing: { tax: { automatic: true, collectTaxId: true, estimate: false, behavior: TaxBehavior.Inclusive }, currency: { estimate: false } },
|
|
@@ -124,6 +138,28 @@ describe('Stripe checkout — pricing policy', () => {
|
|
|
124
138
|
})
|
|
125
139
|
expect(fake.state.checkoutSessions[0].line_items[0].price_data.tax_behavior).toBe('inclusive')
|
|
126
140
|
})
|
|
141
|
+
|
|
142
|
+
test('an amount checkout converts the USD catalogue charge to the settlement currency', async () => {
|
|
143
|
+
const fake = await makeFakeContext({
|
|
144
|
+
pricing: {
|
|
145
|
+
tax: { automatic: true, collectTaxId: true, estimate: false },
|
|
146
|
+
currency: { adaptive: true, estimate: false }, stripe: { settlementCurrency: 'EUR' },
|
|
147
|
+
},
|
|
148
|
+
stripe: { fxRates: { usd: { exchangeRate: 0.853568, referenceRate: 0.8726 } } },
|
|
149
|
+
})
|
|
150
|
+
await createCheckoutLink(fake.ctx, fake.stripe, {
|
|
151
|
+
productSku: CREDITS_PRODUCT, entityId: 'entity-1', service: 'app', amountMinor: 1_000, successUrl,
|
|
152
|
+
})
|
|
153
|
+
const session = fake.state.checkoutSessions[0]
|
|
154
|
+
expect(session.line_items[0].price_data).toEqual(expect.objectContaining({ currency: 'eur', unit_amount: 891 }))
|
|
155
|
+
expect(session.metadata).toEqual(expect.objectContaining({
|
|
156
|
+
amountMinor: '1000', sourceChargeAmountMinor: '1021', amountCurrency: 'usd',
|
|
157
|
+
chargeAmountMinor: '891', currency: 'eur',
|
|
158
|
+
}))
|
|
159
|
+
expect(fake.state.rawRequests[0]).toEqual(expect.objectContaining({
|
|
160
|
+
params: { to_currency: 'eur', 'from_currencies[]': 'usd', lock_duration: 'none' },
|
|
161
|
+
}))
|
|
162
|
+
})
|
|
127
163
|
})
|
|
128
164
|
|
|
129
165
|
describe('Stripe checkout fulfillment', () => {
|
|
@@ -149,6 +185,23 @@ describe('Stripe checkout fulfillment', () => {
|
|
|
149
185
|
expect(fake.stores['payment-subscription'].rows).toHaveLength(0)
|
|
150
186
|
})
|
|
151
187
|
|
|
188
|
+
test('fulfills the USD credit value from an EUR settlement charge', async () => {
|
|
189
|
+
const fake = await makeFakeContext()
|
|
190
|
+
await process(fake, 'checkout.session.completed', paid({
|
|
191
|
+
currency: 'eur', amount_subtotal: 891,
|
|
192
|
+
metadata: {
|
|
193
|
+
pricingMode: 'amount', amountMinor: '1000', sourceChargeAmountMinor: '1021', amountCurrency: 'usd',
|
|
194
|
+
chargeAmountMinor: '891', currency: 'eur', entityId: 'entity-1', service: 'app',
|
|
195
|
+
productSku: CREDITS_PRODUCT, planSku: 'app-credit-unit',
|
|
196
|
+
},
|
|
197
|
+
}))
|
|
198
|
+
expect(fake.observed.topUp[0]).toEqual(expect.objectContaining({
|
|
199
|
+
amountMinor: 1_000, sourceChargeAmountMinor: 1_021, amountCurrency: 'usd',
|
|
200
|
+
chargeAmountMinor: 891, currency: 'eur',
|
|
201
|
+
}))
|
|
202
|
+
expect(fulfillment(fake)[0]).toEqual(expect.objectContaining({ amountCurrency: 'usd', currency: 'eur' }))
|
|
203
|
+
})
|
|
204
|
+
|
|
152
205
|
test('grants nothing for an unpaid completion; the asynchronous success does', async () => {
|
|
153
206
|
const fake = await makeFakeContext()
|
|
154
207
|
await process(fake, 'checkout.session.completed', paid({ payment_status: 'unpaid' }))
|
package/tests/estimate.spec.ts
CHANGED
|
@@ -174,6 +174,28 @@ describe('@owlmeans/server-payment — Stripe Tax price estimate', () => {
|
|
|
174
174
|
}))
|
|
175
175
|
})
|
|
176
176
|
|
|
177
|
+
test('estimates the same USD to EUR to local chain used by settlement and Adaptive Pricing', async () => {
|
|
178
|
+
const fake = await makeFakeContext({
|
|
179
|
+
pricing: { ...estimatingPolicy, stripe: { settlementCurrency: 'eur' } },
|
|
180
|
+
stripe: {
|
|
181
|
+
taxRates: { PL: [{ type: 'vat', percentage: '23' }] },
|
|
182
|
+
fxRates: {
|
|
183
|
+
usd: { exchangeRate: 0.853568, referenceRate: 0.8726 },
|
|
184
|
+
pln: { exchangeRate: 0.22, referenceRate: 0.224, fxFeeRate: 0.02 },
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
})
|
|
188
|
+
const result = await estimateStripePrice(fake.ctx, fake.stripe, {
|
|
189
|
+
entityId: 'entity-1', productSku: PLANS_PRODUCT, planSku: PRO, country: 'PL',
|
|
190
|
+
}, makeEstimateCache())
|
|
191
|
+
expect(result.local?.currency).toBe('pln')
|
|
192
|
+
expect(result.local?.exchangeRate).toBeCloseTo(0.22 / 0.8726)
|
|
193
|
+
expect(fake.state.rawRequests.map(request => request.params)).toEqual([
|
|
194
|
+
{ to_currency: 'eur', 'from_currencies[]': 'usd', lock_duration: 'none' },
|
|
195
|
+
{ to_currency: 'eur', 'from_currencies[]': 'pln', lock_duration: 'none' },
|
|
196
|
+
])
|
|
197
|
+
})
|
|
198
|
+
|
|
177
199
|
test('omits `local` when the FX Quotes call fails, without failing the estimate', async () => {
|
|
178
200
|
const fake = await makeFakeContext({
|
|
179
201
|
pricing: estimatingPolicy, stripe: { taxRates: { PL: [{ type: 'vat', percentage: '23' }] }, fxUnavailable: true },
|
package/tests/fake-stripe.ts
CHANGED
|
@@ -150,8 +150,8 @@ export interface FakeStripeState {
|
|
|
150
150
|
taxSettings: Rec
|
|
151
151
|
/** Every `rawRequest` call, in order: `{ method, path, params }`. */
|
|
152
152
|
rawRequests: Rec[]
|
|
153
|
-
/** FX Quotes rates by
|
|
154
|
-
fxRates: Record<string, { exchangeRate: number, fxFeeRate?: number }>
|
|
153
|
+
/** FX Quotes rates by lowercase source currency. */
|
|
154
|
+
fxRates: Record<string, { exchangeRate: number, baseRate?: number, referenceRate?: number, fxFeeRate?: number }>
|
|
155
155
|
/** Every `rawRequest` to `/v1/fx_quotes` throws. */
|
|
156
156
|
fxUnavailable: boolean
|
|
157
157
|
}
|
|
@@ -442,7 +442,14 @@ export const makeFakeStripe = (initial: Partial<FakeStripeState> = {}): { stripe
|
|
|
442
442
|
}
|
|
443
443
|
return {
|
|
444
444
|
rates: {
|
|
445
|
-
[local]: {
|
|
445
|
+
[local]: {
|
|
446
|
+
exchange_rate: rate.exchangeRate,
|
|
447
|
+
rate_details: {
|
|
448
|
+
base_rate: rate.baseRate ?? rate.exchangeRate,
|
|
449
|
+
reference_rate: rate.referenceRate ?? rate.baseRate ?? rate.exchangeRate,
|
|
450
|
+
fx_fee_rate: rate.fxFeeRate ?? 0.02,
|
|
451
|
+
},
|
|
452
|
+
},
|
|
446
453
|
},
|
|
447
454
|
}
|
|
448
455
|
},
|
package/tests/sync.spec.ts
CHANGED
|
@@ -85,4 +85,30 @@ describe('@owlmeans/server-payment — price tax_behavior sync', () => {
|
|
|
85
85
|
expect(fresh?.id).not.toBe('price_pro_existing')
|
|
86
86
|
expect(fresh).toEqual(expect.objectContaining({ tax_behavior: 'exclusive', lookup_key: PRO }))
|
|
87
87
|
})
|
|
88
|
+
|
|
89
|
+
test('recurring USD catalogue prices are synchronized as EUR settlement prices', async () => {
|
|
90
|
+
const fake = await withProProduct({
|
|
91
|
+
pricing: { ...exclusive, stripe: { settlementCurrency: 'eur' } },
|
|
92
|
+
stripe: { fxRates: { usd: { exchangeRate: 0.853568, referenceRate: 0.8726 } } },
|
|
93
|
+
})
|
|
94
|
+
await syncStripeProducts(fake.ctx, fake.stripe)
|
|
95
|
+
expect(proPrice(fake)).toEqual(expect.objectContaining({ currency: 'eur', unit_amount: 1_746 }))
|
|
96
|
+
expect(fake.state.rawRequests).toContainEqual(expect.objectContaining({
|
|
97
|
+
params: { to_currency: 'eur', 'from_currencies[]': 'usd', lock_duration: 'none' },
|
|
98
|
+
}))
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('a changed reference rate replaces a recurring settlement price on the next sync', async () => {
|
|
102
|
+
const fake = await withProProduct({
|
|
103
|
+
pricing: { ...exclusive, stripe: { settlementCurrency: 'eur' } },
|
|
104
|
+
stripe: { fxRates: { usd: { exchangeRate: 0.853568, referenceRate: 0.8726 } } },
|
|
105
|
+
})
|
|
106
|
+
await syncStripeProducts(fake.ctx, fake.stripe)
|
|
107
|
+
const first = proPrice(fake)
|
|
108
|
+
fake.state.fxRates.usd = { exchangeRate: 0.88, referenceRate: 0.9 }
|
|
109
|
+
await syncStripeProducts(fake.ctx, fake.stripe)
|
|
110
|
+
expect(first?.active).toBe(false)
|
|
111
|
+
expect(proPrice(fake)).toEqual(expect.objectContaining({ currency: 'eur', unit_amount: 1_800 }))
|
|
112
|
+
expect(proPrice(fake)?.id).not.toBe(first?.id)
|
|
113
|
+
})
|
|
88
114
|
})
|