@owlmeans/server-payment 0.1.18-rc.11 → 0.1.18-rc.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/server-payment/SKILL.md +67 -7
- package/build/config.d.ts +15 -1
- package/build/config.d.ts.map +1 -1
- package/build/config.js +40 -2
- package/build/config.js.map +1 -1
- package/build/consts.d.ts +10 -0
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +10 -0
- package/build/consts.js.map +1 -1
- package/build/index.d.ts +2 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +1 -0
- package/build/index.js.map +1 -1
- package/build/model.d.ts.map +1 -1
- package/build/model.js +2 -1
- package/build/model.js.map +1 -1
- package/build/plugins/estimate.d.ts +43 -0
- package/build/plugins/estimate.d.ts.map +1 -0
- package/build/plugins/estimate.js +235 -0
- package/build/plugins/estimate.js.map +1 -0
- package/build/plugins/events.d.ts.map +1 -1
- package/build/plugins/events.js +17 -4
- package/build/plugins/events.js.map +1 -1
- package/build/plugins/fx.d.ts +26 -0
- package/build/plugins/fx.d.ts.map +1 -0
- package/build/plugins/fx.js +56 -0
- package/build/plugins/fx.js.map +1 -0
- package/build/plugins/portal.d.ts.map +1 -1
- package/build/plugins/portal.js +6 -2
- package/build/plugins/portal.js.map +1 -1
- package/build/plugins/stripe.d.ts +2 -1
- package/build/plugins/stripe.d.ts.map +1 -1
- package/build/plugins/stripe.js +51 -19
- package/build/plugins/stripe.js.map +1 -1
- package/build/service.d.ts.map +1 -1
- package/build/service.js +6 -0
- package/build/service.js.map +1 -1
- package/build/sync.d.ts +5 -0
- package/build/sync.d.ts.map +1 -1
- package/build/sync.js +80 -14
- package/build/sync.js.map +1 -1
- package/build/types.d.ts +46 -1
- package/build/types.d.ts.map +1 -1
- package/build/utils.d.ts +3 -1
- package/build/utils.d.ts.map +1 -1
- package/build/utils.js +3 -1
- package/build/utils.js.map +1 -1
- package/package.json +16 -16
- package/src/config.ts +47 -5
- package/src/consts.ts +11 -0
- package/src/index.ts +2 -0
- package/src/model.ts +2 -1
- package/src/plugins/estimate.ts +299 -0
- package/src/plugins/events.ts +17 -4
- package/src/plugins/fx.ts +93 -0
- package/src/plugins/portal.ts +6 -2
- package/src/plugins/stripe.ts +55 -19
- package/src/service.ts +6 -0
- package/src/sync.ts +101 -13
- package/src/types.ts +49 -2
- package/src/utils.ts +6 -2
- package/tests/checkout.spec.ts +118 -2
- package/tests/estimate.spec.ts +240 -0
- package/tests/events.spec.ts +13 -0
- package/tests/fake-stripe.ts +130 -6
- package/tests/portal.spec.ts +19 -2
- package/tests/sync.spec.ts +114 -0
package/tests/checkout.spec.ts
CHANGED
|
@@ -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
|
+
})
|
package/tests/events.spec.ts
CHANGED
|
@@ -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)
|
package/tests/fake-stripe.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
290
|
-
|
|
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> = {}
|
package/tests/portal.spec.ts
CHANGED
|
@@ -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({
|