@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
@@ -1,11 +1,14 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
- import { CheckoutPricingMode, PlanDuration, PlanStatus, ProductError, ProductType } from '@owlmeans/payment'
2
+ import { CheckoutPricingMode, PlanDuration, PlanStatus, ProductError, ProductType, TaxBehavior } from '@owlmeans/payment'
3
3
  import { createEventHandler } from '../src/plugins/events.js'
4
4
  import { amountCheckoutLineItem, createCheckoutLink, quantityCheckoutLineItem } from '../src/plugins/stripe.js'
5
5
  import type { PaymentPlan, PaymentProduct } from '../src/types.js'
6
- import { CREDITS_PRODUCT, FREE, makeFakeContext, PLANS_PRODUCT, TEAM } from './fake-stripe.js'
6
+ import { CREDITS_PRODUCT, FREE, makeFakeContext, PLANS_PRODUCT, PRO, TEAM } from './fake-stripe.js'
7
7
  import type { FakeContext } from './fake-stripe.js'
8
8
 
9
+ const proPrice = { id: 'price_pro', product: PLANS_PRODUCT, lookup_key: PRO, active: true, recurring: { interval: 'month' } }
10
+ const successUrl = 'https://app.example.com/ok'
11
+
9
12
  const product: PaymentProduct = {
10
13
  type: ProductType.Consumable, sku: 'credits', title: 'Credits', services: ['app'],
11
14
  }
@@ -71,7 +74,103 @@ describe('Stripe checkout', () => {
71
74
  })
72
75
  })
73
76
 
77
+ describe('Stripe checkout — pricing policy', () => {
78
+ test('an undeclared policy reproduces exactly the session hard-coded before this policy existed', async () => {
79
+ const fake = await makeFakeContext({ stripe: { prices: [proPrice] } })
80
+ await createCheckoutLink(fake.ctx, fake.stripe, {
81
+ productSku: PLANS_PRODUCT, planSku: PRO, entityId: 'entity-1', service: 'app', successUrl,
82
+ })
83
+ const session = fake.state.checkoutSessions[0]
84
+ expect(session).toEqual(expect.objectContaining({
85
+ automatic_tax: { enabled: true }, billing_address_collection: 'required',
86
+ tax_id_collection: { enabled: true }, customer_update: { address: 'auto', name: 'auto' },
87
+ }))
88
+ expect(session.adaptive_pricing).toBeUndefined()
89
+ })
90
+
91
+ test('tax.automatic and tax.collectTaxId are independent switches', async () => {
92
+ const fake = await makeFakeContext({
93
+ pricing: { tax: { automatic: false, collectTaxId: true, estimate: false }, currency: { estimate: false } },
94
+ stripe: { prices: [proPrice] },
95
+ })
96
+ await createCheckoutLink(fake.ctx, fake.stripe, {
97
+ productSku: PLANS_PRODUCT, planSku: PRO, entityId: 'entity-1', service: 'app', successUrl,
98
+ })
99
+ const session = fake.state.checkoutSessions[0]
100
+ expect(session.automatic_tax).toBeUndefined()
101
+ expect(session.billing_address_collection).toBeUndefined()
102
+ expect(session.tax_id_collection).toEqual({ enabled: true })
103
+ // only the concern that needs it contributes a `customer_update` field
104
+ expect(session.customer_update).toEqual({ name: 'auto' })
105
+ })
106
+
107
+ test('adaptive_pricing appears on the session only when currency.adaptive is declared', async () => {
108
+ const fake = await makeFakeContext({
109
+ pricing: { tax: { automatic: true, collectTaxId: true, estimate: false }, currency: { adaptive: true, estimate: false } },
110
+ stripe: { prices: [proPrice] },
111
+ })
112
+ await createCheckoutLink(fake.ctx, fake.stripe, {
113
+ productSku: PLANS_PRODUCT, planSku: PRO, entityId: 'entity-1', service: 'app', successUrl,
114
+ })
115
+ expect(fake.state.checkoutSessions[0].adaptive_pricing).toEqual({ enabled: true })
116
+ })
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
+
132
+ test('a declared behavior is set on the inline amount line item', async () => {
133
+ const fake = await makeFakeContext({
134
+ pricing: { tax: { automatic: true, collectTaxId: true, estimate: false, behavior: TaxBehavior.Inclusive }, currency: { estimate: false } },
135
+ })
136
+ await createCheckoutLink(fake.ctx, fake.stripe, {
137
+ productSku: CREDITS_PRODUCT, entityId: 'entity-1', service: 'app', amountMinor: 1_000, successUrl,
138
+ })
139
+ expect(fake.state.checkoutSessions[0].line_items[0].price_data.tax_behavior).toBe('inclusive')
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
+ })
163
+ })
164
+
74
165
  describe('Stripe checkout fulfillment', () => {
166
+ test('fulfills a paid amount once even when the session carries Adaptive Pricing presentment details', async () => {
167
+ const fake = await makeFakeContext()
168
+ await process(fake, 'checkout.session.completed', paid({
169
+ presentment_details: { presentment_amount: 4_613, presentment_currency: 'eur' },
170
+ }))
171
+ expect(fake.observed.topUp).toEqual([expect.objectContaining({ amountMinor: 1_000, chargeAmountMinor: 1_021 })])
172
+ })
173
+
75
174
  test('fulfills a paid amount once, without crediting the adjustment, and records the payment intent', async () => {
76
175
  const fake = await makeFakeContext()
77
176
  await process(fake, 'checkout.session.completed', paid())
@@ -86,6 +185,23 @@ describe('Stripe checkout fulfillment', () => {
86
185
  expect(fake.stores['payment-subscription'].rows).toHaveLength(0)
87
186
  })
88
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
+
89
205
  test('grants nothing for an unpaid completion; the asynchronous success does', async () => {
90
206
  const fake = await makeFakeContext()
91
207
  await process(fake, 'checkout.session.completed', paid({ payment_status: 'unpaid' }))
@@ -0,0 +1,240 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { PaygateError, TaxBehavior, TaxEstimateStatus, TaxType } from '@owlmeans/payment'
3
+ import { makeEstimateCache, estimateStripePrice } from '../src/plugins/estimate.js'
4
+ import { gateway, paygateCustomers } from '../src/utils.js'
5
+ import { CREDITS_PRODUCT, makeFakeContext, PLANS_PRODUCT, PRO } from './fake-stripe.js'
6
+ import type { FakeContext } from './fake-stripe.js'
7
+
8
+ const withCustomer = async (
9
+ fake: FakeContext, entityId: string, country: string, taxIds: Array<{ type: string, value: string, country: string }> = [],
10
+ taxExempt: string = 'none',
11
+ ) => {
12
+ await paygateCustomers(fake.ctx).create({ paygate: 'stripe', externalId: `cus_${entityId}`, entityId })
13
+ fake.state.customers[`cus_${entityId}`] = {
14
+ id: `cus_${entityId}`, object: 'customer', address: { country }, tax_exempt: taxExempt,
15
+ tax_ids: { object: 'list', data: taxIds },
16
+ }
17
+ }
18
+
19
+ const estimatingPolicy = { tax: { automatic: true, collectTaxId: true, estimate: true }, currency: { adaptive: true, estimate: true } }
20
+
21
+ describe('@owlmeans/server-payment — Stripe Tax price estimate', () => {
22
+ test('taxes the reference amount of a recurring plan at a given country, half-cent rounded up', async () => {
23
+ const fake = await makeFakeContext({
24
+ pricing: estimatingPolicy, stripe: { taxRates: { PL: [{ type: 'vat', percentage: '23' }] } },
25
+ })
26
+ const cache = makeEstimateCache()
27
+ const result = await estimateStripePrice(fake.ctx, fake.stripe, {
28
+ entityId: 'entity-1', productSku: PLANS_PRODUCT, planSku: PRO, country: 'PL',
29
+ }, cache)
30
+ expect(result).toEqual(expect.objectContaining({
31
+ country: 'PL', source: 'request', currency: 'usd', behavior: TaxBehavior.Exclusive,
32
+ }))
33
+ expect(result.tax).toEqual({
34
+ status: TaxEstimateStatus.Taxed, subtotalMinor: 2_000, taxMinor: 460, totalMinor: 2_460,
35
+ scalable: true, rates: [{ type: TaxType.Vat, percentage: '23', ratePpm: 230_000, country: 'PL' }],
36
+ })
37
+ })
38
+
39
+ test('taxes an amount-priced consumable at its default preset, gross-up included', async () => {
40
+ const fake = await makeFakeContext({
41
+ pricing: estimatingPolicy, stripe: { taxRates: { PL: [{ type: 'vat', percentage: '23' }] } },
42
+ })
43
+ const cache = makeEstimateCache()
44
+ const result = await estimateStripePrice(fake.ctx, fake.stripe, {
45
+ entityId: 'entity-1', productSku: CREDITS_PRODUCT, country: 'PL',
46
+ }, cache)
47
+ // the credit unit's default preset is 1_000, grossed up to 1_021 by its own amount policy
48
+ expect(result.tax.subtotalMinor).toBe(1_021)
49
+ expect(result.tax.taxMinor).toBe(235)
50
+ expect(result.tax.totalMinor).toBe(1_256)
51
+ })
52
+
53
+ test('no request country and no paygate customer: `location-required`, zero Stripe calls', async () => {
54
+ const fake = await makeFakeContext({ pricing: estimatingPolicy })
55
+ const cache = makeEstimateCache()
56
+ const result = await estimateStripePrice(fake.ctx, fake.stripe, {
57
+ entityId: 'unknown-entity', productSku: PLANS_PRODUCT, planSku: PRO,
58
+ }, cache)
59
+ expect(result.tax.status).toBe(TaxEstimateStatus.LocationRequired)
60
+ expect(fake.state.calls).not.toContain('tax.calculations.create')
61
+ })
62
+
63
+ test("falls back to the entity's paygate customer address, tagged `source: 'customer'`", async () => {
64
+ const fake = await makeFakeContext({
65
+ pricing: estimatingPolicy, stripe: { taxRates: { DE: [{ type: 'vat', percentage: '19' }] } },
66
+ })
67
+ await withCustomer(fake, 'entity-1', 'DE')
68
+ const result = await estimateStripePrice(fake.ctx, fake.stripe, {
69
+ entityId: 'entity-1', productSku: PLANS_PRODUCT, planSku: PRO,
70
+ }, makeEstimateCache())
71
+ expect(result.country).toBe('DE')
72
+ expect(result.source).toBe('customer')
73
+ expect(result.tax.status).toBe(TaxEstimateStatus.Taxed)
74
+ })
75
+
76
+ test('a saved tax id for a DIFFERENT country than the resolved one is never sent', async () => {
77
+ const fake = await makeFakeContext({
78
+ pricing: estimatingPolicy, stripe: { taxRates: { PL: [{ type: 'vat', percentage: '23' }] } },
79
+ })
80
+ await withCustomer(fake, 'entity-1', 'PL', [{ type: 'eu_vat', value: 'DE123456789', country: 'DE' }])
81
+ const result = await estimateStripePrice(fake.ctx, fake.stripe, {
82
+ entityId: 'entity-1', productSku: PLANS_PRODUCT, planSku: PRO,
83
+ }, makeEstimateCache())
84
+ // taxed, not reverse-charged — the DE tax id never reached the calculation
85
+ expect(result.tax.status).toBe(TaxEstimateStatus.Taxed)
86
+ expect(fake.state.taxCalculations[0].customer_details.tax_ids).toBeUndefined()
87
+ })
88
+
89
+ test('a matching-country tax id triggers reverse charge, with zero tax', async () => {
90
+ const fake = await makeFakeContext({
91
+ pricing: estimatingPolicy, stripe: { taxRates: { DE: [{ type: 'vat', percentage: '19' }] } },
92
+ })
93
+ await withCustomer(fake, 'entity-1', 'DE', [{ type: 'eu_vat', value: 'DE123456789', country: 'DE' }])
94
+ const result = await estimateStripePrice(fake.ctx, fake.stripe, {
95
+ entityId: 'entity-1', productSku: PLANS_PRODUCT, planSku: PRO,
96
+ }, makeEstimateCache())
97
+ expect(result.tax.status).toBe(TaxEstimateStatus.ReverseCharge)
98
+ expect(result.tax.taxMinor).toBe(0)
99
+ expect(result.tax.totalMinor).toBe(2_000)
100
+ })
101
+
102
+ test('an unconfigured country (no registration) reads `none`', async () => {
103
+ const fake = await makeFakeContext({ pricing: estimatingPolicy })
104
+ const result = await estimateStripePrice(fake.ctx, fake.stripe, {
105
+ entityId: 'entity-1', productSku: PLANS_PRODUCT, planSku: PRO, country: 'FR',
106
+ }, makeEstimateCache())
107
+ expect(result.tax.status).toBe(TaxEstimateStatus.None)
108
+ expect(result.tax.taxMinor).toBe(0)
109
+ })
110
+
111
+ test('a jurisdiction Stripe Tax does not cover reads `at-checkout`', async () => {
112
+ const fake = await makeFakeContext({ pricing: estimatingPolicy, stripe: { taxUnsupportedCountries: ['CU'] } })
113
+ const result = await estimateStripePrice(fake.ctx, fake.stripe, {
114
+ entityId: 'entity-1', productSku: PLANS_PRODUCT, planSku: PRO, country: 'CU',
115
+ }, makeEstimateCache())
116
+ expect(result.tax.status).toBe(TaxEstimateStatus.AtCheckout)
117
+ })
118
+
119
+ test('a Stripe invalid-request error (e.g. US missing a postal code) reads `at-checkout`, and the outcome is cached', async () => {
120
+ const fake = await makeFakeContext({ pricing: estimatingPolicy, stripe: { taxInvalidCountries: ['US'] } })
121
+ const cache = makeEstimateCache()
122
+ const first = await estimateStripePrice(fake.ctx, fake.stripe, {
123
+ entityId: 'entity-1', productSku: PLANS_PRODUCT, planSku: PRO, country: 'US',
124
+ }, cache)
125
+ expect(first.tax.status).toBe(TaxEstimateStatus.AtCheckout)
126
+ const callsAfterFirst = fake.state.calls.filter(name => name === 'tax.calculations.create').length
127
+ await estimateStripePrice(fake.ctx, fake.stripe, {
128
+ entityId: 'entity-1', productSku: PLANS_PRODUCT, planSku: PRO, country: 'US',
129
+ }, cache)
130
+ expect(fake.state.calls.filter(name => name === 'tax.calculations.create')).toHaveLength(callsAfterFirst)
131
+ })
132
+
133
+ test('an identical request within the cache TTL makes exactly one Stripe Tax call; a different entity with its own tax id makes another', async () => {
134
+ const fake = await makeFakeContext({ pricing: estimatingPolicy, stripe: { taxRates: { PL: [{ type: 'vat', percentage: '23' }] } } })
135
+ const cache = makeEstimateCache()
136
+ const params = { entityId: 'entity-1', productSku: PLANS_PRODUCT, planSku: PRO, country: 'PL' }
137
+ await estimateStripePrice(fake.ctx, fake.stripe, params, cache)
138
+ await estimateStripePrice(fake.ctx, fake.stripe, params, cache)
139
+ expect(fake.state.taxCalculations).toHaveLength(1)
140
+
141
+ await withCustomer(fake, 'entity-2', 'PL', [{ type: 'eu_vat', value: 'PL123456789', country: 'PL' }])
142
+ await estimateStripePrice(fake.ctx, fake.stripe, { ...params, entityId: 'entity-2' }, cache)
143
+ expect(fake.state.taxCalculations).toHaveLength(2)
144
+ })
145
+
146
+ test('never caches a rate-limit or connection error', async () => {
147
+ let fail = true
148
+ const fake = await makeFakeContext({ pricing: estimatingPolicy, stripe: { taxRates: { PL: [{ type: 'vat', percentage: '23' }] } } })
149
+ const cache = makeEstimateCache()
150
+ const original = (fake.stripe as never as { tax: { calculations: { create: (...args: unknown[]) => unknown } } })
151
+ .tax.calculations.create
152
+ ;(fake.stripe as never as { tax: { calculations: { create: (...args: unknown[]) => unknown } } })
153
+ .tax.calculations.create = async (...args: unknown[]) => {
154
+ if (fail) { fail = false; throw new Error('connection reset') }
155
+ return await original(...args)
156
+ }
157
+ const params = { entityId: 'entity-1', productSku: PLANS_PRODUCT, planSku: PRO, country: 'PL' }
158
+ await expect(estimateStripePrice(fake.ctx, fake.stripe, params, cache)).rejects.toThrow('connection reset')
159
+ const result = await estimateStripePrice(fake.ctx, fake.stripe, params, cache)
160
+ expect(result.tax.status).toBe(TaxEstimateStatus.Taxed)
161
+ })
162
+
163
+ test('carries a local-currency conversion from the FX Quotes API, requested to_currency=usd from_currencies=[local]', async () => {
164
+ const fake = await makeFakeContext({
165
+ pricing: estimatingPolicy,
166
+ stripe: { taxRates: { PL: [{ type: 'vat', percentage: '23' }] }, fxRates: { pln: { exchangeRate: 0.25, fxFeeRate: 0.02 } } },
167
+ })
168
+ const result = await estimateStripePrice(fake.ctx, fake.stripe, {
169
+ entityId: 'entity-1', productSku: PLANS_PRODUCT, planSku: PRO, country: 'PL',
170
+ }, makeEstimateCache())
171
+ expect(result.local).toEqual({ currency: 'pln', exchangeRate: 0.25, fxFeeRate: 0.02 })
172
+ expect(fake.state.rawRequests[0]).toEqual(expect.objectContaining({
173
+ method: 'POST', path: '/v1/fx_quotes', params: { to_currency: 'usd', 'from_currencies[]': 'pln', lock_duration: 'none' },
174
+ }))
175
+ })
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
+
199
+ test('omits `local` when the FX Quotes call fails, without failing the estimate', async () => {
200
+ const fake = await makeFakeContext({
201
+ pricing: estimatingPolicy, stripe: { taxRates: { PL: [{ type: 'vat', percentage: '23' }] }, fxUnavailable: true },
202
+ })
203
+ const result = await estimateStripePrice(fake.ctx, fake.stripe, {
204
+ entityId: 'entity-1', productSku: PLANS_PRODUCT, planSku: PRO, country: 'PL',
205
+ }, makeEstimateCache())
206
+ expect(result.tax.status).toBe(TaxEstimateStatus.Taxed)
207
+ expect(result.local).toBeUndefined()
208
+ })
209
+
210
+ test('omits `local` for a US billing country (same currency as the integration currency), with no FX call', async () => {
211
+ const fake = await makeFakeContext({ pricing: estimatingPolicy })
212
+ const result = await estimateStripePrice(fake.ctx, fake.stripe, {
213
+ entityId: 'entity-1', productSku: PLANS_PRODUCT, planSku: PRO, country: 'US',
214
+ }, makeEstimateCache())
215
+ expect(result.local).toBeUndefined()
216
+ expect(fake.state.rawRequests).toHaveLength(0)
217
+ })
218
+
219
+ test('`currency.estimate: false` never computes a local total, even with `currency.adaptive: true`', async () => {
220
+ const fake = await makeFakeContext({
221
+ pricing: {
222
+ tax: { automatic: true, collectTaxId: true, estimate: true },
223
+ currency: { adaptive: true, estimate: false },
224
+ },
225
+ stripe: { taxRates: { PL: [{ type: 'vat', percentage: '23' }] }, fxRates: { pln: { exchangeRate: 0.25 } } },
226
+ })
227
+ const result = await estimateStripePrice(fake.ctx, fake.stripe, {
228
+ entityId: 'entity-1', productSku: PLANS_PRODUCT, planSku: PRO, country: 'PL',
229
+ }, makeEstimateCache())
230
+ expect(result.local).toBeUndefined()
231
+ expect(fake.state.rawRequests).toHaveLength(0)
232
+ })
233
+
234
+ test('an unmanaged gateway throws PaygateError before ever reaching Stripe', async () => {
235
+ const fake = await makeFakeContext({ pricing: estimatingPolicy })
236
+ await expect(gateway(fake.ctx).estimatePrice(fake.ctx, {
237
+ entityId: 'entity-1', productSku: PLANS_PRODUCT, planSku: PRO,
238
+ })).rejects.toBeInstanceOf(PaygateError)
239
+ })
240
+ })
@@ -127,6 +127,19 @@ describe('@owlmeans/server-payment — subscription events', () => {
127
127
  expect(changes(fake)).toEqual(['created'])
128
128
  })
129
129
 
130
+ test("resolves the plan from the price's `metadata.sku` when a replaced price carries no lookup key", async () => {
131
+ const fake = await makeFakeContext()
132
+ const replaced = {
133
+ ...subscriptionOf(),
134
+ items: {
135
+ object: 'list', has_more: false, url: '',
136
+ data: [{ id: 'si_1', price: { id: 'price_new', lookup_key: null, metadata: { sku: PRO } } }],
137
+ },
138
+ } as never
139
+ await send(fake, 'customer.subscription.updated', replaced)
140
+ expect(rows(fake)[0]).toEqual(expect.objectContaining({ planSku: PRO, priceId: 'price_new' }))
141
+ })
142
+
130
143
  test('plan changes and cancel scheduling are classified with stable keys', async () => {
131
144
  const fake = await makeFakeContext()
132
145
  await subscribe(fake)
@@ -11,7 +11,7 @@ import type { Criteria, ListOptions, ResourceRecord } from '@owlmeans/resource'
11
11
  import type { MongoResource } from '@owlmeans/mongo-resource'
12
12
  import type { Context as ApiContext } from '@owlmeans/server-api'
13
13
  import {
14
- declarePaymentPlan, declarePaymentProduct, portalBranding, stripeSecrets,
14
+ declarePaymentPlan, declarePaymentPricing, declarePaymentProduct, portalBranding, stripeSecrets,
15
15
  } from '../src/config.js'
16
16
  import {
17
17
  RES_PAYGATE_CUSTOMER, RES_PAYMENT_FINGERPRINT, RES_PAYMENT_FULFILLMENT, RES_PAYMENT_SUBSCRIPTION,
@@ -24,7 +24,7 @@ import {
24
24
  import { appendPaymentGatewayService } from '../src/service.js'
25
25
  import { observer } from '../src/utils.js'
26
26
  import type {
27
- Config, DisputeEvent, PaymentFailedEvent, PaymentPlanDef, PortalBrandingDef, RefundEvent,
27
+ Config, DisputeEvent, PaymentFailedEvent, PaymentPlanDef, PortalBrandingDef, PricingDef, RefundEvent,
28
28
  SubscriptionEvent, TopUpCompletion,
29
29
  } from '../src/types.js'
30
30
 
@@ -139,6 +139,21 @@ export interface FakeStripeState {
139
139
  customers: Record<string, Rec>
140
140
  lineItemQuantity: number
141
141
  seq: number
142
+ /** Every `tax.calculations.create` params, in call order. */
143
+ taxCalculations: Rec[]
144
+ /** Flat-percentage rates a country charges (uppercase alpha-2). Absent/empty: `not_collecting`. */
145
+ taxRates: Record<string, Array<{ type: string, percentage: string }>>
146
+ /** Countries whose calculation throws `customer_tax_location_invalid` (simulates a missing ZIP). */
147
+ taxInvalidCountries: string[]
148
+ /** Countries Stripe Tax does not cover: a `not_supported`, zero-tax breakdown row. */
149
+ taxUnsupportedCountries: string[]
150
+ taxSettings: Rec
151
+ /** Every `rawRequest` call, in order: `{ method, path, params }`. */
152
+ rawRequests: Rec[]
153
+ /** FX Quotes rates by lowercase source currency. */
154
+ fxRates: Record<string, { exchangeRate: number, baseRate?: number, referenceRate?: number, fxFeeRate?: number }>
155
+ /** Every `rawRequest` to `/v1/fx_quotes` throws. */
156
+ fxUnavailable: boolean
142
157
  }
143
158
 
144
159
  /** The API version the installed SDK defaults to, read from a real client. */
@@ -149,6 +164,11 @@ export const missing = (what: string): Error => Object.assign(new Error(`No such
149
164
  type: 'StripeInvalidRequestError', code: 'resource_missing', statusCode: 404,
150
165
  })
151
166
 
167
+ /** A Stripe `invalid_request_error`, duck-typed the same way `missing` fakes `resource_missing`. */
168
+ export const invalidRequest = (code: string): Error => Object.assign(new Error(code), {
169
+ type: 'StripeInvalidRequestError', code, statusCode: 400,
170
+ })
171
+
152
172
  const page = (items: Rec[], params: Rec = {}) => {
153
173
  let start = 0
154
174
  if (params.starting_after != null) {
@@ -164,7 +184,11 @@ export const makeFakeStripe = (initial: Partial<FakeStripeState> = {}): { stripe
164
184
  const state: FakeStripeState = {
165
185
  apiVersion: SDK_API_VERSION, calls: [], webhookEndpoints: [], portalConfigurations: [], portalSessions: [],
166
186
  checkoutSessions: [], subscriptions: {}, invoices: {}, charges: {}, refunds: [], prices: [], products: {},
167
- customers: {}, lineItemQuantity: 7, seq: 0, ...initial,
187
+ customers: {}, lineItemQuantity: 7, seq: 0,
188
+ taxCalculations: [], taxRates: {}, taxInvalidCountries: [], taxUnsupportedCountries: [],
189
+ taxSettings: { defaults: { tax_behavior: null, tax_code: null }, head_office: null, status: 'active', status_details: {} },
190
+ rawRequests: [], fxRates: {}, fxUnavailable: false,
191
+ ...initial,
168
192
  }
169
193
  const next = (prefix: string): string => `${prefix}_${++state.seq}`
170
194
  const call = (name: string): void => { state.calls.push(name) }
@@ -280,14 +304,23 @@ export const makeFakeStripe = (initial: Partial<FakeStripeState> = {}): { stripe
280
304
  },
281
305
  create: async (params: Rec) => {
282
306
  call('prices.create')
283
- const price = { id: next('price'), active: true, ...structuredClone(params) }
307
+ // Stripe's own default: a price created with no `tax_behavior` is `unspecified`.
308
+ const price = { id: next('price'), active: true, tax_behavior: 'unspecified', ...structuredClone(params) }
284
309
  state.prices.push(price)
285
310
  return structuredClone(price)
286
311
  },
287
312
  update: async (id: string, params: Rec) => {
288
313
  call('prices.update')
289
- Object.assign(find(state.prices, id, 'price'), params)
290
- return structuredClone(find(state.prices, id, 'price'))
314
+ const price = find(state.prices, id, 'price')
315
+ if (
316
+ params.tax_behavior != null && price.tax_behavior != null && price.tax_behavior !== 'unspecified'
317
+ && price.tax_behavior !== params.tax_behavior
318
+ ) {
319
+ // Real Stripe: once a price's `tax_behavior` is `exclusive` or `inclusive`, it cannot change.
320
+ throw invalidRequest('parameter_invalid_empty')
321
+ }
322
+ Object.assign(price, params)
323
+ return structuredClone(price)
291
324
  },
292
325
  },
293
326
  products: {
@@ -333,6 +366,93 @@ export const makeFakeStripe = (initial: Partial<FakeStripeState> = {}): { stripe
333
366
  },
334
367
  },
335
368
  },
369
+ tax: {
370
+ calculations: {
371
+ /**
372
+ * A flat-percentage tax model, entirely driven by `state.taxRates` / `taxInvalidCountries`
373
+ * / `taxUnsupportedCountries` — real enough to exercise the status mapping and the
374
+ * scalability rule, never a stand-in for Stripe's actual jurisdiction logic.
375
+ */
376
+ create: async (params: Rec) => {
377
+ call('tax.calculations.create')
378
+ state.taxCalculations.push(structuredClone(params))
379
+ const country = params.customer_details?.address?.country as string | undefined
380
+ if (country != null && state.taxInvalidCountries.includes(country)) {
381
+ throw invalidRequest('customer_tax_location_invalid')
382
+ }
383
+ const lineItem = params.line_items[0]
384
+ const amount: number = lineItem.amount
385
+ const inclusive = lineItem.tax_behavior === 'inclusive'
386
+ const reverseCharge = (params.customer_details?.tax_ids ?? []).length > 0
387
+ const rates = reverseCharge ? [] : (country != null ? state.taxRates[country] ?? [] : [])
388
+ const unsupported = !reverseCharge && rates.length === 0
389
+ && country != null && state.taxUnsupportedCountries.includes(country)
390
+
391
+ const breakdown = reverseCharge
392
+ ? [{
393
+ amount: 0, inclusive, taxable_amount: amount, taxability_reason: 'reverse_charge',
394
+ tax_rate_details: { country, state: null, percentage_decimal: '0', tax_type: 'vat' },
395
+ }]
396
+ : unsupported
397
+ ? [{
398
+ amount: 0, inclusive, taxable_amount: amount, taxability_reason: 'not_supported',
399
+ tax_rate_details: null,
400
+ }]
401
+ : rates.length === 0
402
+ ? [{
403
+ amount: 0, inclusive, taxable_amount: amount, taxability_reason: 'not_collecting',
404
+ tax_rate_details: { country, state: null, percentage_decimal: '0', tax_type: 'vat' },
405
+ }]
406
+ : rates.map(rate => ({
407
+ amount: Math.round(amount * Number(rate.percentage) / 100), inclusive, taxable_amount: amount,
408
+ taxability_reason: 'standard_rated',
409
+ tax_rate_details: { country, state: null, percentage_decimal: rate.percentage, tax_type: rate.type },
410
+ }))
411
+ const taxTotal = breakdown.reduce((sum, row) => sum + row.amount, 0)
412
+
413
+ return {
414
+ id: next('taxcalc'), object: 'tax.calculation', currency: params.currency,
415
+ amount_total: inclusive ? amount : amount + taxTotal,
416
+ tax_amount_exclusive: inclusive ? 0 : taxTotal,
417
+ tax_amount_inclusive: inclusive ? taxTotal : 0,
418
+ tax_breakdown: breakdown,
419
+ }
420
+ },
421
+ },
422
+ settings: {
423
+ retrieve: async () => {
424
+ call('tax.settings.retrieve')
425
+ return structuredClone(state.taxSettings)
426
+ },
427
+ },
428
+ },
429
+ rawRequest: async (method: string, path: string, params: Rec = {}) => {
430
+ call('rawRequest')
431
+ state.rawRequests.push({ method, path, params: structuredClone(params) })
432
+ if (path !== '/v1/fx_quotes') {
433
+ throw new Error(`fake-stripe: unhandled rawRequest path "${path}"`)
434
+ }
435
+ if (state.fxUnavailable) {
436
+ throw new Error('fx_quotes unavailable')
437
+ }
438
+ const local = params['from_currencies[]'] as string
439
+ const rate = state.fxRates[local]
440
+ if (rate == null) {
441
+ return { rates: {} }
442
+ }
443
+ return {
444
+ rates: {
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
+ },
453
+ },
454
+ }
455
+ },
336
456
  webhooks: {
337
457
  /** Accepts the signature `sig:<secret>` over any body. */
338
458
  constructEventAsync: async (raw: string | Buffer, signature: string, secret: string) => {
@@ -582,6 +702,7 @@ export interface FakeContextOptions {
582
702
  /** The configured webhook override secret. */
583
703
  webhookSecret?: string
584
704
  portal?: PortalBrandingDef
705
+ pricing?: PricingDef
585
706
  stripe?: Partial<FakeStripeState>
586
707
  }
587
708
 
@@ -623,6 +744,9 @@ export const makeFakeContext = async (opts: FakeContextOptions = {}): Promise<Fa
623
744
  if (opts.portal != null) {
624
745
  portalBranding(cfg, opts.portal)
625
746
  }
747
+ if (opts.pricing != null) {
748
+ declarePaymentPricing(cfg, opts.pricing)
749
+ }
626
750
 
627
751
  const ctx = makeServerContext(cfg as unknown as ServerConfig) as unknown as ApiContext
628
752
  const stores: Record<string, MemoryStore> = {}
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
- import { PortalFlow, PortalUnavailable, ProductType, PlanDuration, SubscriptionStatus } from '@owlmeans/payment'
3
- import { declarePaymentPlan, declarePaymentProduct } from '../src/config.js'
2
+ import { PortalFlow, PortalUnavailable, ProductType, PlanDuration, SubscriptionStatus, TaxBehavior } from '@owlmeans/payment'
3
+ import { declarePaymentPlan, declarePaymentPricing, declarePaymentProduct } from '../src/config.js'
4
4
  import { createPortalLink, ensurePortalConfiguration } from '../src/plugins/portal.js'
5
5
  import { fingerprints, paygateCustomers, subscriptions } from '../src/utils.js'
6
6
  import { makeFakeContext, PLANS_PRODUCT, PRO, SERVICE, TEAM } from './fake-stripe.js'
@@ -73,6 +73,23 @@ describe('@owlmeans/server-payment — portal configuration', () => {
73
73
  ])
74
74
  })
75
75
 
76
+ test('a declared tax behavior is hashed: changing it re-hashes and updates the configuration', async () => {
77
+ const fake = await withPortal({
78
+ pricing: { tax: { automatic: true, collectTaxId: true, estimate: false, behavior: TaxBehavior.Exclusive }, currency: { estimate: false } },
79
+ })
80
+ const id = await ensurePortalConfiguration(fake.ctx, fake.stripe)
81
+ fake.state.calls.length = 0
82
+ expect(await ensurePortalConfiguration(fake.ctx, fake.stripe)).toBe(id)
83
+ expect(fake.state.calls).toEqual([])
84
+
85
+ declarePaymentPricing(fake.ctx.cfg as never, {
86
+ tax: { automatic: true, collectTaxId: true, estimate: false, behavior: TaxBehavior.Inclusive },
87
+ currency: { estimate: false },
88
+ })
89
+ expect(await ensurePortalConfiguration(fake.ctx, fake.stripe)).toBe(id)
90
+ expect(fake.state.calls).toContain('billingPortal.configurations.update')
91
+ })
92
+
76
93
  test('two deployments on one Stripe account keep separate configurations, even when both rows name one', async () => {
77
94
  const shared = { owlmeans: 'payment', service: SERVICE }
78
95
  const stage = await withPortal({