@owlmeans/server-payment 0.1.18-rc.2 → 0.1.18-rc.21

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 (222) hide show
  1. package/README.md +109 -25
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/server-payment/SKILL.md +475 -52
  4. package/build/actions/index.d.ts +1 -0
  5. package/build/actions/index.d.ts.map +1 -1
  6. package/build/actions/index.js +1 -0
  7. package/build/actions/index.js.map +1 -1
  8. package/build/actions/resync-subscriptions.d.ts +3 -0
  9. package/build/actions/resync-subscriptions.d.ts.map +1 -0
  10. package/build/actions/resync-subscriptions.js +7 -0
  11. package/build/actions/resync-subscriptions.js.map +1 -0
  12. package/build/actions/resync.d.ts +1 -0
  13. package/build/actions/resync.d.ts.map +1 -1
  14. package/build/actions/resync.js +7 -3
  15. package/build/actions/resync.js.map +1 -1
  16. package/build/actions/webhook.d.ts.map +1 -1
  17. package/build/actions/webhook.js +6 -3
  18. package/build/actions/webhook.js.map +1 -1
  19. package/build/config.d.ts +60 -5
  20. package/build/config.d.ts.map +1 -1
  21. package/build/config.js +237 -5
  22. package/build/config.js.map +1 -1
  23. package/build/consts.d.ts +75 -3
  24. package/build/consts.d.ts.map +1 -1
  25. package/build/consts.js +94 -5
  26. package/build/consts.js.map +1 -1
  27. package/build/consumer/capture.d.ts +74 -0
  28. package/build/consumer/capture.d.ts.map +1 -0
  29. package/build/consumer/capture.js +291 -0
  30. package/build/consumer/capture.js.map +1 -0
  31. package/build/consumer/format.d.ts +27 -0
  32. package/build/consumer/format.d.ts.map +1 -0
  33. package/build/consumer/format.js +81 -0
  34. package/build/consumer/format.js.map +1 -0
  35. package/build/consumer/handlers.d.ts +28 -0
  36. package/build/consumer/handlers.d.ts.map +1 -0
  37. package/build/consumer/handlers.js +173 -0
  38. package/build/consumer/handlers.js.map +1 -0
  39. package/build/consumer/index.d.ts +7 -0
  40. package/build/consumer/index.d.ts.map +1 -0
  41. package/build/consumer/index.js +6 -0
  42. package/build/consumer/index.js.map +1 -0
  43. package/build/consumer/mail.d.ts +27 -0
  44. package/build/consumer/mail.d.ts.map +1 -0
  45. package/build/consumer/mail.js +314 -0
  46. package/build/consumer/mail.js.map +1 -0
  47. package/build/consumer/origin.d.ts +14 -0
  48. package/build/consumer/origin.d.ts.map +1 -0
  49. package/build/consumer/origin.js +47 -0
  50. package/build/consumer/origin.js.map +1 -0
  51. package/build/consumer/reconcile.d.ts +12 -0
  52. package/build/consumer/reconcile.d.ts.map +1 -0
  53. package/build/consumer/reconcile.js +317 -0
  54. package/build/consumer/reconcile.js.map +1 -0
  55. package/build/consumer/records.d.ts +78 -0
  56. package/build/consumer/records.d.ts.map +1 -0
  57. package/build/consumer/records.js +296 -0
  58. package/build/consumer/records.js.map +1 -0
  59. package/build/consumer/service.d.ts +51 -0
  60. package/build/consumer/service.d.ts.map +1 -0
  61. package/build/consumer/service.js +760 -0
  62. package/build/consumer/service.js.map +1 -0
  63. package/build/consumer/withdrawal.d.ts +57 -0
  64. package/build/consumer/withdrawal.d.ts.map +1 -0
  65. package/build/consumer/withdrawal.js +247 -0
  66. package/build/consumer/withdrawal.js.map +1 -0
  67. package/build/entitlement.d.ts +10 -0
  68. package/build/entitlement.d.ts.map +1 -0
  69. package/build/entitlement.js +69 -0
  70. package/build/entitlement.js.map +1 -0
  71. package/build/entrypoints.d.ts +1 -1
  72. package/build/entrypoints.d.ts.map +1 -1
  73. package/build/entrypoints.js +2 -1
  74. package/build/entrypoints.js.map +1 -1
  75. package/build/gate.d.ts +32 -4
  76. package/build/gate.d.ts.map +1 -1
  77. package/build/gate.js +59 -19
  78. package/build/gate.js.map +1 -1
  79. package/build/index.d.ts +18 -3
  80. package/build/index.d.ts.map +1 -1
  81. package/build/index.js +15 -3
  82. package/build/index.js.map +1 -1
  83. package/build/limit.d.ts +13 -0
  84. package/build/limit.d.ts.map +1 -0
  85. package/build/limit.js +47 -0
  86. package/build/limit.js.map +1 -0
  87. package/build/model.d.ts +10 -1
  88. package/build/model.d.ts.map +1 -1
  89. package/build/model.js +215 -17
  90. package/build/model.js.map +1 -1
  91. package/build/observer.d.ts +9 -0
  92. package/build/observer.d.ts.map +1 -1
  93. package/build/observer.js +38 -12
  94. package/build/observer.js.map +1 -1
  95. package/build/plan.d.ts +22 -0
  96. package/build/plan.d.ts.map +1 -0
  97. package/build/plan.js +72 -0
  98. package/build/plan.js.map +1 -0
  99. package/build/plugins/checkout-plugins.d.ts +49 -0
  100. package/build/plugins/checkout-plugins.d.ts.map +1 -0
  101. package/build/plugins/checkout-plugins.js +124 -0
  102. package/build/plugins/checkout-plugins.js.map +1 -0
  103. package/build/plugins/estimate.d.ts +43 -0
  104. package/build/plugins/estimate.d.ts.map +1 -0
  105. package/build/plugins/estimate.js +268 -0
  106. package/build/plugins/estimate.js.map +1 -0
  107. package/build/plugins/events.d.ts +45 -10
  108. package/build/plugins/events.d.ts.map +1 -1
  109. package/build/plugins/events.js +605 -136
  110. package/build/plugins/events.js.map +1 -1
  111. package/build/plugins/fx.d.ts +31 -0
  112. package/build/plugins/fx.d.ts.map +1 -0
  113. package/build/plugins/fx.js +81 -0
  114. package/build/plugins/fx.js.map +1 -0
  115. package/build/plugins/portal.d.ts +37 -0
  116. package/build/plugins/portal.d.ts.map +1 -0
  117. package/build/plugins/portal.js +265 -0
  118. package/build/plugins/portal.js.map +1 -0
  119. package/build/plugins/refunds.d.ts +33 -0
  120. package/build/plugins/refunds.d.ts.map +1 -0
  121. package/build/plugins/refunds.js +80 -0
  122. package/build/plugins/refunds.js.map +1 -0
  123. package/build/plugins/stripe.d.ts +36 -4
  124. package/build/plugins/stripe.d.ts.map +1 -1
  125. package/build/plugins/stripe.js +492 -97
  126. package/build/plugins/stripe.js.map +1 -1
  127. package/build/plugins/webhook-manager.d.ts +46 -0
  128. package/build/plugins/webhook-manager.d.ts.map +1 -0
  129. package/build/plugins/webhook-manager.js +231 -0
  130. package/build/plugins/webhook-manager.js.map +1 -0
  131. package/build/reconcile.d.ts +15 -0
  132. package/build/reconcile.d.ts.map +1 -0
  133. package/build/reconcile.js +88 -0
  134. package/build/reconcile.js.map +1 -0
  135. package/build/resource.d.ts +12 -1
  136. package/build/resource.d.ts.map +1 -1
  137. package/build/resource.js +97 -6
  138. package/build/resource.js.map +1 -1
  139. package/build/service.d.ts +28 -3
  140. package/build/service.d.ts.map +1 -1
  141. package/build/service.js +146 -19
  142. package/build/service.js.map +1 -1
  143. package/build/subscription.d.ts +48 -0
  144. package/build/subscription.d.ts.map +1 -0
  145. package/build/subscription.js +176 -0
  146. package/build/subscription.js.map +1 -0
  147. package/build/sync.d.ts +28 -1
  148. package/build/sync.d.ts.map +1 -1
  149. package/build/sync.js +208 -31
  150. package/build/sync.js.map +1 -1
  151. package/build/types.d.ts +1115 -36
  152. package/build/types.d.ts.map +1 -1
  153. package/build/usage.d.ts +63 -0
  154. package/build/usage.d.ts.map +1 -0
  155. package/build/usage.js +363 -0
  156. package/build/usage.js.map +1 -0
  157. package/build/utils.d.ts +52 -1
  158. package/build/utils.d.ts.map +1 -1
  159. package/build/utils.js +69 -7
  160. package/build/utils.js.map +1 -1
  161. package/package.json +17 -13
  162. package/src/actions/index.ts +1 -0
  163. package/src/actions/resync-subscriptions.ts +10 -0
  164. package/src/actions/resync.ts +6 -3
  165. package/src/actions/webhook.ts +5 -3
  166. package/src/config.ts +264 -8
  167. package/src/consts.ts +114 -6
  168. package/src/consumer/capture.ts +362 -0
  169. package/src/consumer/format.ts +90 -0
  170. package/src/consumer/handlers.ts +211 -0
  171. package/src/consumer/index.ts +6 -0
  172. package/src/consumer/mail.ts +368 -0
  173. package/src/consumer/origin.ts +63 -0
  174. package/src/consumer/reconcile.ts +329 -0
  175. package/src/consumer/records.ts +374 -0
  176. package/src/consumer/service.ts +868 -0
  177. package/src/consumer/withdrawal.ts +302 -0
  178. package/src/entitlement.ts +84 -0
  179. package/src/entrypoints.ts +2 -1
  180. package/src/gate.ts +88 -21
  181. package/src/index.ts +24 -3
  182. package/src/limit.ts +57 -0
  183. package/src/model.ts +237 -18
  184. package/src/observer.ts +44 -11
  185. package/src/plan.ts +89 -0
  186. package/src/plugins/checkout-plugins.ts +155 -0
  187. package/src/plugins/estimate.ts +339 -0
  188. package/src/plugins/events.ts +677 -121
  189. package/src/plugins/fx.ts +122 -0
  190. package/src/plugins/portal.ts +306 -0
  191. package/src/plugins/refunds.ts +108 -0
  192. package/src/plugins/stripe.ts +581 -96
  193. package/src/plugins/webhook-manager.ts +270 -0
  194. package/src/reconcile.ts +103 -0
  195. package/src/resource.ts +152 -7
  196. package/src/service.ts +174 -18
  197. package/src/subscription.ts +231 -0
  198. package/src/sync.ts +249 -29
  199. package/src/types.ts +1227 -32
  200. package/src/usage.ts +453 -0
  201. package/src/utils.ts +127 -10
  202. package/tests/checkout-consumer.spec.ts +348 -0
  203. package/tests/checkout-plugins.spec.ts +164 -0
  204. package/tests/checkout.spec.ts +184 -83
  205. package/tests/consumer-events.spec.ts +218 -0
  206. package/tests/consumer-fixtures.ts +132 -0
  207. package/tests/consumer-ops.spec.ts +351 -0
  208. package/tests/consumer-rights.integration.spec.ts +150 -0
  209. package/tests/consumer-rights.spec.ts +501 -0
  210. package/tests/context.ts +109 -0
  211. package/tests/entitlement.spec.ts +103 -0
  212. package/tests/estimate.spec.ts +240 -0
  213. package/tests/events.spec.ts +356 -0
  214. package/tests/fake-stripe.ts +972 -0
  215. package/tests/gate.spec.ts +62 -72
  216. package/tests/limit-gate.spec.ts +68 -0
  217. package/tests/portal.spec.ts +200 -0
  218. package/tests/protocol.spec.ts +23 -6
  219. package/tests/sync.spec.ts +114 -0
  220. package/tests/usage.integration.spec.ts +101 -0
  221. package/tests/usage.spec.ts +171 -0
  222. package/tests/webhook-manager.spec.ts +152 -0
@@ -0,0 +1,103 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { INTERNAL_PAYGATE, PlanRequired, SubscriptionStatus } from '@owlmeans/payment'
3
+ import { entitlements, subscriptions } from '../src/utils.js'
4
+ import type { PaymentSubscriptionRecord } from '../src/types.js'
5
+ import {
6
+ CAP_PREVIEW, CAP_WHITELABEL, FREE, future, makeFakeContext, past, PLANS_PRODUCT, PRO, TEAM,
7
+ } from './fake-stripe.js'
8
+ import type { FakeContext } from './fake-stripe.js'
9
+
10
+ const seed = async (
11
+ fake: FakeContext, planSku: string, overrides: Partial<PaymentSubscriptionRecord> = {},
12
+ ): Promise<PaymentSubscriptionRecord> => await subscriptions(fake.ctx).create({
13
+ entityId: 'entity-1', planSku, productSku: PLANS_PRODUCT, service: 'app', paygate: 'stripe',
14
+ externalId: `sub_${planSku}_${Math.random().toString(16).slice(2, 8)}`, status: SubscriptionStatus.Active,
15
+ rank: planSku === TEAM ? 20 : planSku === PRO ? 10 : 0, createdAt: past(5), ...overrides,
16
+ })
17
+
18
+ describe('@owlmeans/server-payment — effective plan', () => {
19
+ test('the highest-ranked entitling subscription wins, the newest on a tie', async () => {
20
+ const fake = await makeFakeContext()
21
+ await seed(fake, FREE, { paygate: INTERNAL_PAYGATE, externalId: 'free:entity-1' })
22
+ await seed(fake, PRO, { status: SubscriptionStatus.PastDue })
23
+ await seed(fake, TEAM, { status: SubscriptionStatus.Canceled })
24
+ const older = await seed(fake, PRO, { createdAt: past(20) })
25
+
26
+ const effective = await entitlements(fake.ctx).effectivePlan('entity-1')
27
+ expect(effective.plan.sku).toBe(PRO)
28
+ expect(effective.subscription?.status).toBe(SubscriptionStatus.PastDue)
29
+ expect(effective.subscription?.externalId).not.toBe(older.externalId)
30
+ expect(effective.fallback?.sku).toBe(FREE)
31
+ })
32
+
33
+ test('falls back to the free plan past suspended rows, expired internal grants and unknown plans', async () => {
34
+ const fake = await makeFakeContext()
35
+ await seed(fake, TEAM, { status: SubscriptionStatus.Suspended })
36
+ await seed(fake, TEAM, { paygate: INTERNAL_PAYGATE, periodEnd: past(1) })
37
+ await seed(fake, 'retired-plan', { rank: 99 })
38
+
39
+ const effective = await entitlements(fake.ctx).effectivePlan('entity-1')
40
+ expect(effective.plan.sku).toBe(FREE)
41
+ expect(effective.subscription).toBeNull()
42
+
43
+ await seed(fake, PRO, { paygate: INTERNAL_PAYGATE, periodEnd: future(1) })
44
+ expect((await entitlements(fake.ctx).effectivePlan('entity-1')).plan.sku).toBe(PRO)
45
+ })
46
+
47
+ test('without a free plan and without a subscription it is a PlanRequired fault', async () => {
48
+ const fake = await makeFakeContext({ catalogue: { free: false } })
49
+ await expect(entitlements(fake.ctx).effectivePlan('entity-1')).rejects.toBeInstanceOf(PlanRequired)
50
+ })
51
+ })
52
+
53
+ describe('@owlmeans/server-payment — the entitlement view', () => {
54
+ test('describes the plan behind it: subscribed-at, past-due and the fallback', async () => {
55
+ const fake = await makeFakeContext()
56
+ const row = await seed(fake, PRO, {
57
+ status: SubscriptionStatus.PastDue, periodEnd: future(10), cancelAtPeriodEnd: true,
58
+ })
59
+
60
+ const view = await entitlements(fake.ctx).entitlements('entity-1')
61
+ expect(view.plan).toEqual(expect.objectContaining({
62
+ sku: PRO, rank: 10, free: false, status: SubscriptionStatus.PastDue, paygate: 'stripe',
63
+ subscriptionId: row.externalId, pastDue: true, fallbackSku: FREE, cancelAtPeriodEnd: true,
64
+ }))
65
+ expect(view.plan.subscribedAt?.getTime()).toBe(row.createdAt.getTime())
66
+
67
+ const free = await entitlements(fake.ctx).entitlements('entity-2')
68
+ expect(free.plan).toEqual(expect.objectContaining({ sku: FREE, free: true, paygate: INTERNAL_PAYGATE }))
69
+ expect(free.plan.fallbackSku).toBeUndefined()
70
+ })
71
+
72
+ test('reads usage from the current window counter only', async () => {
73
+ const fake = await makeFakeContext()
74
+ await seed(fake, PRO)
75
+ await entitlements(fake.ctx).consume({ entityId: 'entity-1', limitKey: 'exports', eventKey: 'export:1' })
76
+ fake.stores['payment-usage-counter'].rows.push({
77
+ entityId: 'entity-1', limitKey: 'exports', window: '2001-01-01', used: 3, limit: 3, updatedAt: past(9000),
78
+ })
79
+
80
+ const exports = (await entitlements(fake.ctx).entitlements('entity-1')).limits.find(limit => limit.key === 'exports')
81
+ expect(exports).toEqual(expect.objectContaining({ used: 1, limit: 3, remaining: 2 }))
82
+ })
83
+
84
+ test('grandfathers a promo for a subscription created before it ended, and lapses it otherwise', async () => {
85
+ const fake = await makeFakeContext({ catalogue: { reportsUntil: past(2), previewUntil: past(2) } })
86
+ await seed(fake, PRO, { entityId: 'early', createdAt: past(10) })
87
+ await seed(fake, PRO, { entityId: 'late', createdAt: past(1) })
88
+
89
+ const early = await entitlements(fake.ctx).entitlements('early')
90
+ const late = await entitlements(fake.ctx).entitlements('late')
91
+ expect(early.limits.find(limit => limit.key === 'reports')).toEqual(expect.objectContaining({
92
+ limit: 4, promo: expect.objectContaining({ active: true, grandfathered: true }),
93
+ }))
94
+ expect(late.limits.find(limit => limit.key === 'reports')).toEqual(expect.objectContaining({
95
+ limit: 0, promo: expect.objectContaining({ active: false, grandfathered: false }),
96
+ }))
97
+
98
+ // The free plan's preview promo does not grandfather: lapsed for everyone.
99
+ expect(await entitlements(fake.ctx).hasCapability('nobody', CAP_PREVIEW)).toBe(false)
100
+ expect(await entitlements(fake.ctx).hasCapability('early', CAP_WHITELABEL)).toBe(true)
101
+ expect(await entitlements(fake.ctx).hasCapability('nobody', CAP_WHITELABEL)).toBe(false)
102
+ })
103
+ })
@@ -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
+ })