@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.
@@ -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 { lineItem, chargeMinor, currency } = amountCheckoutLineItem(
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
- chargeAmountMinor: String(chargeMinor),
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: PaymentPlan[], behavior: TaxBehavior | null,
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: plan.currency ?? 'usd', duration: plan.duration,
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, plan: PaymentPlan,
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 hash = fingerprintOf(product, plans, behavior)
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 plan of plans) await ensureStripePrice(stripe, product, plan, behavior, migrateUnspecifiedPrices)
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
@@ -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' }))
@@ -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 },
@@ -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 (lowercase) local currency: USD per one unit of it. */
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]: { exchange_rate: rate.exchangeRate, rate_details: { fx_fee_rate: rate.fxFeeRate ?? 0.02 } },
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
  },
@@ -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
  })