@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.
Files changed (69) hide show
  1. package/README.md +1 -1
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/server-payment/SKILL.md +67 -7
  4. package/build/config.d.ts +15 -1
  5. package/build/config.d.ts.map +1 -1
  6. package/build/config.js +40 -2
  7. package/build/config.js.map +1 -1
  8. package/build/consts.d.ts +10 -0
  9. package/build/consts.d.ts.map +1 -1
  10. package/build/consts.js +10 -0
  11. package/build/consts.js.map +1 -1
  12. package/build/index.d.ts +2 -0
  13. package/build/index.d.ts.map +1 -1
  14. package/build/index.js +1 -0
  15. package/build/index.js.map +1 -1
  16. package/build/model.d.ts.map +1 -1
  17. package/build/model.js +2 -1
  18. package/build/model.js.map +1 -1
  19. package/build/plugins/estimate.d.ts +43 -0
  20. package/build/plugins/estimate.d.ts.map +1 -0
  21. package/build/plugins/estimate.js +235 -0
  22. package/build/plugins/estimate.js.map +1 -0
  23. package/build/plugins/events.d.ts.map +1 -1
  24. package/build/plugins/events.js +17 -4
  25. package/build/plugins/events.js.map +1 -1
  26. package/build/plugins/fx.d.ts +26 -0
  27. package/build/plugins/fx.d.ts.map +1 -0
  28. package/build/plugins/fx.js +56 -0
  29. package/build/plugins/fx.js.map +1 -0
  30. package/build/plugins/portal.d.ts.map +1 -1
  31. package/build/plugins/portal.js +6 -2
  32. package/build/plugins/portal.js.map +1 -1
  33. package/build/plugins/stripe.d.ts +2 -1
  34. package/build/plugins/stripe.d.ts.map +1 -1
  35. package/build/plugins/stripe.js +51 -19
  36. package/build/plugins/stripe.js.map +1 -1
  37. package/build/service.d.ts.map +1 -1
  38. package/build/service.js +6 -0
  39. package/build/service.js.map +1 -1
  40. package/build/sync.d.ts +5 -0
  41. package/build/sync.d.ts.map +1 -1
  42. package/build/sync.js +80 -14
  43. package/build/sync.js.map +1 -1
  44. package/build/types.d.ts +46 -1
  45. package/build/types.d.ts.map +1 -1
  46. package/build/utils.d.ts +3 -1
  47. package/build/utils.d.ts.map +1 -1
  48. package/build/utils.js +3 -1
  49. package/build/utils.js.map +1 -1
  50. package/package.json +16 -16
  51. package/src/config.ts +47 -5
  52. package/src/consts.ts +11 -0
  53. package/src/index.ts +2 -0
  54. package/src/model.ts +2 -1
  55. package/src/plugins/estimate.ts +299 -0
  56. package/src/plugins/events.ts +17 -4
  57. package/src/plugins/fx.ts +93 -0
  58. package/src/plugins/portal.ts +6 -2
  59. package/src/plugins/stripe.ts +55 -19
  60. package/src/service.ts +6 -0
  61. package/src/sync.ts +101 -13
  62. package/src/types.ts +49 -2
  63. package/src/utils.ts +6 -2
  64. package/tests/checkout.spec.ts +118 -2
  65. package/tests/estimate.spec.ts +240 -0
  66. package/tests/events.spec.ts +13 -0
  67. package/tests/fake-stripe.ts +130 -6
  68. package/tests/portal.spec.ts +19 -2
  69. 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
+ }
@@ -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
  }
@@ -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
- const taxOptions = (promotions: boolean): Partial<Stripe.Checkout.SessionCreateParams> => ({
15
- automatic_tax: { enabled: true },
16
- billing_address_collection: 'required',
17
- tax_id_collection: { enabled: true },
18
- customer_update: { address: 'auto', name: 'auto' },
19
- allow_promotion_codes: promotions,
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: 'exclusive',
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 { lineItem, chargeMinor, currency } = amountCheckoutLineItem(product, plan, params.amountMinor)
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
- ...taxOptions(false),
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
- chargeAmountMinor: String(chargeMinor),
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 policy = plan.quantityPolicy ?? {
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, policy)],
175
+ line_items: [quantityCheckoutLineItem(price, quantityPolicy)],
144
176
  invoice_creation: { enabled: true },
145
- ...taxOptions(true),
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
- ...taxOptions(true),
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
- const fingerprintOf = (product: PaymentProduct, plans: PaymentPlan[]): string => createHash('sha256')
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
- plans: plans.map(plan => ({
40
- sku: plan.sku, price: plan.price, currency: plan.currency ?? 'usd', duration: plan.duration,
41
- recurring: plan.recurring ?? null, pricingMode: plan.pricingMode ?? null,
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
- const ensureStripePrice = async (stripe: Stripe, product: PaymentProduct, plan: PaymentPlan): Promise<void> => {
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 match = existing.find(price => price.lookup_key === lookupKey && price.unit_amount === unitAmount
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 (match != null) return
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 hash = fingerprintOf(product, plans)
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 plan of plans) await ensureStripePrice(stripe, product, plan)
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, Product, ProductPlan, ProductType, QuantityCheckoutPolicy,
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.