@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,348 @@
1
+ import { describe, expect, spyOn, test } from 'bun:test'
2
+ import Stripe from 'stripe'
3
+ import { BillingCountryLocked, SubscriptionStartRequired } from '@owlmeans/payment'
4
+ import { createCheckoutLink, isMissingTermsUrl } from '../src/plugins/stripe.js'
5
+ import { billingProfiles, consumerConsents, consumerEvents, consumerRights, fulfillments } from '../src/utils.js'
6
+ import type { CheckoutPlugin } from '../src/types.js'
7
+ import {
8
+ ALL_ON, CREDITS_PRODUCT, ENTITY, makeRightsContext, PLANS_PRODUCT, PRO, requestStart, rightsOf,
9
+ } from './consumer-fixtures.js'
10
+ import { TEAM } from './fake-stripe.js'
11
+ import type { FakeContext } from './fake-stripe.js'
12
+
13
+ const successUrl = 'https://app.example.com/ok'
14
+ const topUp = (fake: FakeContext, extra: Record<string, unknown> = {}, plugins: CheckoutPlugin[] = []) => createCheckoutLink(fake.ctx, fake.stripe, {
15
+ productSku: CREDITS_PRODUCT, entityId: ENTITY, service: 'app', amountMinor: 1_000, successUrl, ...extra,
16
+ }, plugins)
17
+
18
+ /** What Stripe (SDK 17) answers a terms checkbox on an account without a Dashboard terms URL. */
19
+ const TERMS_URL_MESSAGE = 'You cannot collect consent to your terms of service unless a URL is set in the Stripe '
20
+ + 'Dashboard. Update your public business details at https://dashboard.stripe.com/settings/public to set the URL '
21
+ + 'for your terms of service.'
22
+ const missingTermsUrl = () => new Stripe.errors.StripeInvalidRequestError({
23
+ type: 'invalid_request_error', message: TERMS_URL_MESSAGE, param: 'consent_collection[terms_of_service]', statusCode: 400,
24
+ })
25
+ const subscribe = (fake: FakeContext, extra: Record<string, unknown> = {}) => createCheckoutLink(fake.ctx, fake.stripe, {
26
+ productSku: PLANS_PRODUCT, planSku: PRO, entityId: ENTITY, service: 'app', successUrl, ...extra,
27
+ })
28
+ const session = (fake: FakeContext, index = 0) => fake.state.checkoutSessions[index]
29
+
30
+ /** The pro plan synced as EUR 18.00 with an exact USD 20.00 option. */
31
+ const PRO_PRICE = {
32
+ id: 'price_pro', product: PLANS_PRODUCT, lookup_key: PRO, active: true, recurring: { interval: 'month' },
33
+ currency: 'eur', unit_amount: 1_800, tax_behavior: 'exclusive',
34
+ currency_options: { eur: { unit_amount: 1_800 }, usd: { unit_amount: 2_000, tax_behavior: 'exclusive' } },
35
+ }
36
+ const TEAM_PRICE = {
37
+ id: 'price_team', product: PLANS_PRODUCT, lookup_key: TEAM, active: true, recurring: { interval: 'month' },
38
+ currency: 'eur', unit_amount: 4_500,
39
+ }
40
+
41
+ const lockTo = async (fake: FakeContext, country: string, customerCountry?: string) => {
42
+ await consumerRights(fake.ctx).lock(ENTITY, country, 'manual')
43
+ if (customerCountry !== undefined) {
44
+ fake.state.customers.cus_locked = { id: 'cus_locked', object: 'customer', address: customerCountry == null ? null : { country: customerCountry } }
45
+ const { paygateCustomers } = await import('../src/utils.js')
46
+ await paygateCustomers(fake.ctx).create({ paygate: 'stripe', externalId: 'cus_locked', entityId: ENTITY })
47
+ }
48
+ }
49
+
50
+ describe('checkout under a consumer-rights policy — currency', () => {
51
+ test('an EU buyer is charged in EUR through the FX reference rate, with Adaptive Pricing', async () => {
52
+ const fake = await makeRightsContext()
53
+ await topUp(fake, { country: 'pl', ipCountry: 'pl' })
54
+ expect(session(fake).line_items[0].price_data).toEqual(expect.objectContaining({ currency: 'eur', unit_amount: 919 }))
55
+ expect(fake.state.rawRequests).toHaveLength(1)
56
+ expect(session(fake).adaptive_pricing).toEqual({ enabled: true })
57
+ expect(session(fake).metadata).toEqual(expect.objectContaining({
58
+ currency: 'eur', chargeAmountMinor: '919', amountCurrency: 'usd', amountMinor: '1000', region: 'eu', country: 'PL',
59
+ language: 'pl', termsVersion: 'terms-v1', ipCountry: 'PL',
60
+ }))
61
+ expect(session(fake).metadata.copyVersion).toBeString()
62
+ // The declared country is preselected on the new customer's address, never over a saved one.
63
+ const customer = Object.values(fake.state.customers)[0]
64
+ expect(customer.address).toEqual({ country: 'PL' })
65
+ })
66
+
67
+ test('a buyer outside the territories is charged exact USD — no FX call, no Adaptive Pricing', async () => {
68
+ const fake = await makeRightsContext()
69
+ await topUp(fake, { country: 'US' })
70
+ expect(session(fake).line_items[0].price_data).toEqual(expect.objectContaining({ currency: 'usd', unit_amount: 1_021 }))
71
+ expect(fake.state.rawRequests).toHaveLength(0)
72
+ expect(session(fake).adaptive_pricing).toBeUndefined()
73
+ expect(session(fake).metadata).toEqual(expect.objectContaining({ currency: 'usd', chargeAmountMinor: '1021', region: 'other' }))
74
+ // No legal submit text for a buyer without the rights.
75
+ expect(session(fake).custom_text).toBeUndefined()
76
+ })
77
+
78
+ test('an in-scope top-up says what it is, in the billing language', async () => {
79
+ const fake = await makeRightsContext()
80
+ await topUp(fake, { country: 'DE' })
81
+ expect(session(fake).custom_text.submit.message).toContain('Deutschland')
82
+ expect(session(fake).custom_text.submit.message).toContain('14')
83
+ })
84
+
85
+ test('without a consumer-rights policy the legacy settlement behaviour is unchanged', async () => {
86
+ const fake = await makeRightsContext({ consumerRights: undefined })
87
+ await topUp(fake, { country: 'US' })
88
+ expect(session(fake).line_items[0].price_data).toEqual(expect.objectContaining({ currency: 'eur', unit_amount: 919 }))
89
+ expect(session(fake).metadata.region).toBeUndefined()
90
+ })
91
+ })
92
+
93
+ describe('checkout under a consumer-rights policy — the country lock', () => {
94
+ test('a locked country overrides the request; another declared country is refused', async () => {
95
+ const fake = await makeRightsContext()
96
+ await lockTo(fake, 'DE')
97
+ await expect(topUp(fake, { country: 'PL' })).rejects.toBeInstanceOf(BillingCountryLocked)
98
+ await topUp(fake)
99
+ expect(session(fake).metadata).toEqual(expect.objectContaining({ country: 'DE', currency: 'eur', language: 'de' }))
100
+ })
101
+
102
+ test('a locked customer keeps its saved address: address never, collection auto, name still auto', async () => {
103
+ const fake = await makeRightsContext()
104
+ await lockTo(fake, 'DE', 'DE')
105
+ await topUp(fake)
106
+ expect(session(fake).customer_update).toEqual({ address: 'never', name: 'auto' })
107
+ expect(session(fake).billing_address_collection).toBe('auto')
108
+ })
109
+
110
+ test('a locked customer without a saved address still lets Checkout collect one', async () => {
111
+ const fake = await makeRightsContext()
112
+ await lockTo(fake, 'DE', null as unknown as string)
113
+ await topUp(fake)
114
+ expect(session(fake).customer_update).toEqual({ address: 'auto', name: 'auto' })
115
+ expect(session(fake).billing_address_collection).toBe('required')
116
+ })
117
+
118
+ test('a saved customer address that left the locked country is refused', async () => {
119
+ const fake = await makeRightsContext()
120
+ await lockTo(fake, 'DE', 'FR')
121
+ const refusal = await topUp(fake).catch(error => error)
122
+ expect(refusal).toBeInstanceOf(BillingCountryLocked)
123
+ expect(refusal).toEqual(expect.objectContaining({ country: 'DE', requested: 'FR' }))
124
+ })
125
+
126
+ test('an organization that paid before the lock is locked lazily from its customer address', async () => {
127
+ const fake = await makeRightsContext()
128
+ const { paygateCustomers } = await import('../src/utils.js')
129
+ fake.state.customers.cus_old = { id: 'cus_old', object: 'customer', address: { country: 'US' } }
130
+ await paygateCustomers(fake.ctx).create({ paygate: 'stripe', externalId: 'cus_old', entityId: ENTITY })
131
+ await fulfillments(fake.ctx).create({
132
+ entityId: ENTITY, productSku: CREDITS_PRODUCT, service: 'app', paygate: 'stripe', externalId: 'cs_old',
133
+ mode: 'amount' as never, createdAt: new Date(), fulfilledAt: new Date(),
134
+ })
135
+ await topUp(fake)
136
+ expect(await billingProfiles(fake.ctx).byEntity(ENTITY)).toEqual(expect.objectContaining({
137
+ country: 'US', source: 'customer', currency: 'usd',
138
+ }))
139
+ expect(session(fake).metadata.currency).toBe('usd')
140
+ await expect(topUp(fake, { country: 'PL' })).rejects.toBeInstanceOf(BillingCountryLocked)
141
+ expect((await consumerEvents(fake.ctx).list({ action: 'lock' })).items).toHaveLength(1)
142
+ })
143
+ })
144
+
145
+ describe('checkout under a consumer-rights policy — terms', () => {
146
+ test('the terms checkbox is off until the policy turns it on', async () => {
147
+ const fake = await makeRightsContext()
148
+ await topUp(fake, { country: 'PL' })
149
+ expect(session(fake).consent_collection).toBeUndefined()
150
+ expect(session(fake).custom_text?.terms_of_service_acceptance).toBeUndefined()
151
+ })
152
+
153
+ test('on, every checkout requires it, with the terms text of the billing language and its links', async () => {
154
+ const fake = await makeRightsContext({ consumerRights: rightsOf({ mechanisms: { ...ALL_ON, checkoutTerms: true } }) })
155
+ await topUp(fake, { country: 'PL' })
156
+ expect(session(fake).consent_collection).toEqual({ terms_of_service: 'required' })
157
+ const text = session(fake).custom_text.terms_of_service_acceptance.message as string
158
+ expect(text).toContain('https://legal.example.com/pl/billing')
159
+ expect(text).toContain('https://legal.example.com/pl/withdrawal')
160
+ expect(text.length).toBeLessThanOrEqual(1200)
161
+
162
+ await topUp(fake, { country: 'US' })
163
+ const other = session(fake, 1).custom_text.terms_of_service_acceptance.message as string
164
+ expect(other).toContain('https://legal.example.com/en/billing')
165
+ expect(other).not.toContain('withdrawal')
166
+ })
167
+
168
+ test('the session metadata says whether the checkbox was collected', async () => {
169
+ const off = await makeRightsContext()
170
+ await topUp(off, { country: 'PL' })
171
+ expect(session(off).metadata.termsCollected).toBe('false')
172
+ const on = await makeRightsContext({ consumerRights: rightsOf({ mechanisms: { ...ALL_ON, checkoutTerms: true } }) })
173
+ await topUp(on, { country: 'PL' })
174
+ expect(session(on).metadata.termsCollected).toBe('true')
175
+ })
176
+
177
+ test('a Dashboard without a terms URL never stops a payment: the session is created again without the checkbox', async () => {
178
+ const fake = await makeRightsContext({ consumerRights: rightsOf({ mechanisms: { ...ALL_ON, checkoutTerms: true } }) })
179
+ const warned = spyOn(console, 'warn')
180
+ try {
181
+ fake.state.failures['checkout.sessions.create'] = missingTermsUrl()
182
+ const url = await topUp(fake, { country: 'PL' })
183
+ expect(url).toStartWith('https://checkout.example.test/')
184
+ expect(fake.state.calls.filter(call => call === 'checkout.sessions.create')).toHaveLength(2)
185
+ expect(session(fake).consent_collection).toBeUndefined()
186
+ expect(session(fake).custom_text.terms_of_service_acceptance).toBeUndefined()
187
+ // The legal submit text stays; only the checkbox and its text go.
188
+ expect(session(fake).custom_text.submit.message).toContain('Polska')
189
+ expect(session(fake).metadata).toEqual(expect.objectContaining({ termsCollected: 'false', termsVersion: 'terms-v1' }))
190
+
191
+ const [event] = (await consumerEvents(fake.ctx).list({ action: 'checkout-terms-fallback' })).items
192
+ expect(event).toEqual(expect.objectContaining({
193
+ recordId: ENTITY, recordKind: 'checkout', entityId: ENTITY, ok: false, externalId: expect.stringMatching(/^cs_\d+$/),
194
+ }))
195
+ expect(JSON.parse(event.detail ?? '{}')).toEqual(expect.objectContaining({
196
+ param: 'consent_collection[terms_of_service]', mode: 'amount', productSku: CREDITS_PRODUCT,
197
+ message: expect.stringContaining('terms of service'),
198
+ }))
199
+
200
+ // Once per process: a second fallback is audited again, but not warned again.
201
+ fake.state.failures['checkout.sessions.create'] = missingTermsUrl()
202
+ await topUp(fake, { country: 'PL' })
203
+ expect((await consumerEvents(fake.ctx).list({ action: 'checkout-terms-fallback' })).items).toHaveLength(2)
204
+ const operator = warned.mock.calls.filter(args => String(args[0]).includes('terms of service URL'))
205
+ expect(operator).toHaveLength(1)
206
+ expect(String(operator[0][0])).toContain('Settings → Public details')
207
+ } finally {
208
+ warned.mockRestore()
209
+ }
210
+ })
211
+
212
+ test('a subscription falls back the same way, its subscription metadata marked too', async () => {
213
+ const fake = await makeRightsContext({
214
+ consumerRights: rightsOf({ mechanisms: { ...ALL_ON, checkoutTerms: true } }), stripe: { prices: [PRO_PRICE] },
215
+ })
216
+ const startRequestId = await requestStart(fake)
217
+ fake.state.failures['checkout.sessions.create'] = missingTermsUrl()
218
+ await subscribe(fake, { startRequestId, country: 'DE' })
219
+ expect(session(fake).consent_collection).toBeUndefined()
220
+ expect(session(fake).custom_text.terms_of_service_acceptance).toBeUndefined()
221
+ expect(session(fake).custom_text.after_submit.message).toContain('https://app.example.com/legal/cancel')
222
+ expect(session(fake).metadata.termsCollected).toBe('false')
223
+ expect(session(fake).subscription_data.metadata).toEqual(expect.objectContaining({ termsCollected: 'false', startRequestId }))
224
+ })
225
+
226
+ test('the fallback keeps the admissions; a retry that fails too releases them and says why', async () => {
227
+ const fake = await makeRightsContext({ consumerRights: rightsOf({ mechanisms: { ...ALL_ON, checkoutTerms: true } }) })
228
+ const settled: unknown[] = []
229
+ const hold: CheckoutPlugin = {
230
+ alias: 'hold',
231
+ admit: async () => ({ reservationId: 'hold-1' }),
232
+ settled: async (_ctx, event) => { settled.push(event) },
233
+ }
234
+ fake.state.failures['checkout.sessions.create'] = missingTermsUrl()
235
+ await topUp(fake, { country: 'PL' }, [hold])
236
+ expect(settled).toHaveLength(0)
237
+
238
+ fake.state.failures['checkout.sessions.create'] = [missingTermsUrl(), 'rate limited']
239
+ await expect(topUp(fake, { country: 'PL' }, [hold])).rejects.toThrow('rate limited')
240
+ expect(settled).toEqual([expect.objectContaining({ outcome: 'failed', reservationId: 'hold-1' })])
241
+ const failed = (await consumerEvents(fake.ctx).list({ action: 'checkout-terms-fallback' })).items
242
+ .find(event => event.error != null)
243
+ expect(failed).toEqual(expect.objectContaining({ ok: false, error: 'rate limited' }))
244
+ expect(failed?.externalId).toBeUndefined()
245
+ })
246
+
247
+ test('any other refusal is not retried', async () => {
248
+ const fake = await makeRightsContext({ consumerRights: rightsOf({ mechanisms: { ...ALL_ON, checkoutTerms: true } }) })
249
+ fake.state.failures['checkout.sessions.create'] = 'Invalid currency: xyz'
250
+ await expect(topUp(fake, { country: 'PL' })).rejects.toThrow('Invalid currency')
251
+ expect(fake.state.calls.filter(call => call === 'checkout.sessions.create')).toHaveLength(1)
252
+ expect((await consumerEvents(fake.ctx).list({ action: 'checkout-terms-fallback' })).items).toHaveLength(0)
253
+ })
254
+
255
+ test('only the missing-terms-URL refusal is recognised', () => {
256
+ expect(isMissingTermsUrl(missingTermsUrl())).toBe(true)
257
+ // The message alone (no param), as a plain error carries it.
258
+ expect(isMissingTermsUrl(new Error(TERMS_URL_MESSAGE))).toBe(true)
259
+ // The param with a terms message.
260
+ expect(isMissingTermsUrl({ type: 'StripeInvalidRequestError', param: 'consent_collection[terms_of_service]', message: 'Terms of service consent is not available.' })).toBe(true)
261
+ expect(isMissingTermsUrl(new Stripe.errors.StripeCardError({ type: 'card_error', message: TERMS_URL_MESSAGE }))).toBe(false)
262
+ expect(isMissingTermsUrl({ type: 'StripeInvalidRequestError', param: 'line_items', message: 'Invalid line items' })).toBe(false)
263
+ expect(isMissingTermsUrl({ message: 'Please accept our terms of service' })).toBe(false)
264
+ expect(isMissingTermsUrl(null)).toBe(false)
265
+ })
266
+ })
267
+
268
+ describe('checkout under a consumer-rights policy — subscriptions', () => {
269
+ test('an in-scope (or unknown) buyer needs a fresh start request bound to the plan', async () => {
270
+ const fake = await makeRightsContext({ stripe: { prices: [PRO_PRICE, TEAM_PRICE] } })
271
+ await expect(subscribe(fake)).rejects.toBeInstanceOf(SubscriptionStartRequired)
272
+ await expect(subscribe(fake, { startRequestId: 'nope' })).rejects.toBeInstanceOf(SubscriptionStartRequired)
273
+
274
+ const startRequestId = await requestStart(fake)
275
+ await expect(createCheckoutLink(fake.ctx, fake.stripe, {
276
+ productSku: PLANS_PRODUCT, planSku: TEAM, entityId: ENTITY, service: 'app', successUrl, startRequestId,
277
+ })).rejects.toBeInstanceOf(SubscriptionStartRequired)
278
+
279
+ await subscribe(fake, { startRequestId, country: 'DE' })
280
+ expect(session(fake).metadata.startRequestId).toBe(startRequestId)
281
+ expect(session(fake).subscription_data.metadata).toEqual(expect.objectContaining({
282
+ startRequestId, entityId: ENTITY, planSku: PRO, country: 'DE', region: 'eu',
283
+ }))
284
+ })
285
+
286
+ test('an expired start request, or one of another text version, is asked again', async () => {
287
+ const fake = await makeRightsContext({ stripe: { prices: [PRO_PRICE] } })
288
+ const startRequestId = await requestStart(fake)
289
+ const stored = fake.stores['payment-consumer-consent'].rows.find(row => row.id === startRequestId)!
290
+ stored.expiresAt = new Date(Date.now() - 1_000)
291
+ await expect(subscribe(fake, { startRequestId })).rejects.toBeInstanceOf(SubscriptionStartRequired)
292
+
293
+ const fresh = await requestStart(fake)
294
+ fake.stores['payment-consumer-consent'].rows.find(row => row.id === fresh)!.textVersion = 'terms-v0'
295
+ await expect(subscribe(fake, { startRequestId: fresh })).rejects.toBeInstanceOf(SubscriptionStartRequired)
296
+ })
297
+
298
+ test('an organization locked outside the territories subscribes without one, in exact USD', async () => {
299
+ const fake = await makeRightsContext({ stripe: { prices: [PRO_PRICE] } })
300
+ await consumerRights(fake.ctx).lock(ENTITY, 'US', 'manual')
301
+ await subscribe(fake)
302
+ expect(session(fake).currency).toBe('usd')
303
+ expect(session(fake).adaptive_pricing).toBeUndefined()
304
+ expect(session(fake).custom_text.submit.message).toContain('$20.00')
305
+ })
306
+
307
+ test('an EU subscription is forced to EUR with the renewal text and the cancellation link', async () => {
308
+ const fake = await makeRightsContext({ stripe: { prices: [PRO_PRICE] } })
309
+ const startRequestId = await requestStart(fake)
310
+ await subscribe(fake, { startRequestId, country: 'DE', consumerLanguage: 'en' })
311
+ expect(session(fake).currency).toBe('eur')
312
+ expect(session(fake).adaptive_pricing).toEqual({ enabled: true })
313
+ expect(session(fake).custom_text.submit.message).toContain('€18.00')
314
+ expect(session(fake).custom_text.after_submit.message).toContain('https://app.example.com/legal/cancel')
315
+ })
316
+
317
+ test('the application submit text is a function of the charge currency and price', async () => {
318
+ const fake = await makeRightsContext({ stripe: { prices: [PRO_PRICE] } })
319
+ await consumerRights(fake.ctx).lock(ENTITY, 'US', 'manual')
320
+ const seen: unknown[] = []
321
+ await subscribe(fake, {
322
+ submitText: (context: Record<string, unknown>) => { seen.push(context); return `Pay ${String(context.unitAmountMinor)} ${String(context.currency)}` },
323
+ })
324
+ expect(seen[0]).toEqual(expect.objectContaining({ currency: 'usd', unitAmountMinor: 2_000, interval: 'month', region: 'other' }))
325
+ expect(session(fake).custom_text.submit.message).toBe('Pay 2000 usd')
326
+ })
327
+
328
+ test('a price that does not carry the charge currency leaves the currency to Stripe', async () => {
329
+ const fake = await makeRightsContext({
330
+ stripe: { prices: [{ ...PRO_PRICE, currency_options: undefined }] },
331
+ })
332
+ await consumerRights(fake.ctx).lock(ENTITY, 'US', 'manual')
333
+ await subscribe(fake)
334
+ expect(session(fake).currency).toBeUndefined()
335
+ })
336
+
337
+ test('the start request records the statement it was shown, with the plan name the application passed', async () => {
338
+ const fake = await makeRightsContext({ stripe: { prices: [PRO_PRICE] } })
339
+ const startRequestId = await requestStart(fake, { language: 'de' })
340
+ const record = await consumerConsents(fake.ctx).load(startRequestId)
341
+ expect(record).toEqual(expect.objectContaining({
342
+ kind: 'subscription-start', planSku: PRO, planName: 'Pro', language: 'de', trader: 'Example', ip: '203.0.113.7',
343
+ }))
344
+ expect(record?.text.request).toContain('Ich verlange ausdrücklich')
345
+ expect(record?.text.request).toContain('Pro')
346
+ expect(record?.expiresAt).toBeInstanceOf(Date)
347
+ })
348
+ })
@@ -0,0 +1,164 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { CheckoutLimitExceeded } from '@owlmeans/payment'
3
+ import { createCheckoutLink } from '../src/plugins/stripe.js'
4
+ import { gateway } from '../src/utils.js'
5
+ import type { CheckoutPlugin, CheckoutSettled } from '../src/types.js'
6
+ import {
7
+ CREDITS_PRODUCT, ENTITY, makeRightsContext, paidSession, send,
8
+ } from './consumer-fixtures.js'
9
+ import type { FakeContext } from './fake-stripe.js'
10
+
11
+ const successUrl = 'https://app.example.com/ok'
12
+ const topUp = (fake: FakeContext, amountMinor = 1_000) => createCheckoutLink(fake.ctx, fake.stripe, {
13
+ productSku: CREDITS_PRODUCT, entityId: ENTITY, service: 'app', amountMinor, successUrl, country: 'US',
14
+ }, gateway(fake.ctx).checkoutPlugins())
15
+
16
+ interface Journal {
17
+ admitted: unknown[]
18
+ created: unknown[]
19
+ settled: CheckoutSettled[]
20
+ }
21
+
22
+ const recording = (alias: string, journal: Journal, extra: Partial<CheckoutPlugin> = {}): CheckoutPlugin => ({
23
+ alias,
24
+ admit: async (_ctx, attempt) => { journal.admitted.push({ alias, ...attempt }); return { reservationId: `${alias}-hold` } },
25
+ created: async (_ctx, created) => { journal.created.push({ alias, ...created }) },
26
+ settled: async (_ctx, settled) => { journal.settled.push({ ...settled, alias } as CheckoutSettled) },
27
+ ...extra,
28
+ })
29
+
30
+ const journal = (): Journal => ({ admitted: [], created: [], settled: [] })
31
+
32
+ describe('checkout plugins — narrowing', () => {
33
+ test('narrowings compose to the smallest maximum; the amount policy read equals the enforcement', async () => {
34
+ const fake = await makeRightsContext()
35
+ gateway(fake.ctx).use({ alias: 'tier', narrow: async () => ({ maximumMinor: 2_500, reason: 'per-purchase' }) })
36
+ gateway(fake.ctx).use({
37
+ alias: 'window', narrow: async () => ({ maximumMinor: 4_000, reason: 'window', remainingMinor: 4_000 }),
38
+ })
39
+ const view = await gateway(fake.ctx).amountPolicy(fake.ctx, ENTITY, CREDITS_PRODUCT)
40
+ expect(view.limit).toEqual(expect.objectContaining({ maximumMinor: 2_500, reason: 'per-purchase', blocked: false, narrowed: true }))
41
+ expect(view.policy.presetsMinor).toEqual([1_000, 2_000])
42
+
43
+ await topUp(fake, 2_500)
44
+ const refusal = await topUp(fake, 2_600).catch(error => error)
45
+ expect(refusal).toBeInstanceOf(CheckoutLimitExceeded)
46
+ expect(refusal).toEqual(expect.objectContaining({ reason: 'per-purchase', maximumMinor: 2_500, currency: 'usd' }))
47
+ expect(fake.state.checkoutSessions).toHaveLength(1)
48
+ })
49
+
50
+ test('a blocked limit refuses any amount, the minimum too', async () => {
51
+ const fake = await makeRightsContext()
52
+ const resetsAt = new Date(Date.now() + 86_400_000)
53
+ gateway(fake.ctx).use({ alias: 'hold', narrow: async () => ({ maximumMinor: 0, reason: 'hold', resetsAt }) })
54
+ const view = await gateway(fake.ctx).amountPolicy(fake.ctx, ENTITY, CREDITS_PRODUCT)
55
+ expect(view.limit?.blocked).toBe(true)
56
+ const refusal = await topUp(fake, 500).catch(error => error)
57
+ expect(refusal).toBeInstanceOf(CheckoutLimitExceeded)
58
+ expect(refusal.resetsAt?.getTime()).toBe(Math.floor(resetsAt.getTime() / 1000) * 1000 === resetsAt.getTime() ? resetsAt.getTime() : refusal.resetsAt?.getTime())
59
+ })
60
+
61
+ test('a plugin registered twice under one alias replaces the first; a narrowing error fails closed', async () => {
62
+ const fake = await makeRightsContext()
63
+ gateway(fake.ctx).use({ alias: 'tier', narrow: async () => ({ maximumMinor: 600, reason: 'old' }) })
64
+ gateway(fake.ctx).use({ alias: 'tier', narrow: async () => ({ maximumMinor: 3_000, reason: 'new' }) })
65
+ expect(gateway(fake.ctx).checkoutPlugins()).toHaveLength(1)
66
+ expect((await gateway(fake.ctx).amountPolicy(fake.ctx, ENTITY, CREDITS_PRODUCT)).limit?.reason).toBe('new')
67
+
68
+ gateway(fake.ctx).use({ alias: 'broken', narrow: async () => { throw new Error('tier store down') } })
69
+ await expect(topUp(fake)).rejects.toThrow('tier store down')
70
+ expect(fake.state.checkoutSessions).toHaveLength(0)
71
+ })
72
+ })
73
+
74
+ describe('checkout plugins — admission and the session', () => {
75
+ test('admit sees the attempt; created gets the session, the amount, the reservation and the expiry', async () => {
76
+ const fake = await makeRightsContext()
77
+ const log = journal()
78
+ gateway(fake.ctx).use(recording('tier', log, { sessionTtlSeconds: 3_600 }))
79
+ const before = Math.floor(Date.now() / 1000)
80
+ await topUp(fake)
81
+ expect(log.admitted[0]).toEqual(expect.objectContaining({
82
+ alias: 'tier', entityId: ENTITY, productSku: CREDITS_PRODUCT, mode: 'amount', amountMinor: 1_000,
83
+ amountCurrency: 'usd', chargeMinor: 1_021, currency: 'usd', sessionTtlSeconds: 3_600,
84
+ }))
85
+ expect(log.created[0]).toEqual(expect.objectContaining({
86
+ alias: 'tier', sessionId: expect.stringMatching(/^cs_/), url: expect.stringContaining('https://'),
87
+ reservationId: 'tier-hold', amountMinor: 1_000,
88
+ }))
89
+ const expiresAt = fake.state.checkoutSessions[0].expires_at as number
90
+ expect(expiresAt).toBeGreaterThanOrEqual(before + 3_600)
91
+ expect(expiresAt).toBeLessThanOrEqual(before + 3_602)
92
+ })
93
+
94
+ test('the session lifetime is the smallest declared, clamped to Stripe’s 30 min – 24 h', async () => {
95
+ const fake = await makeRightsContext()
96
+ gateway(fake.ctx).use({ alias: 'short', sessionTtlSeconds: 60 })
97
+ gateway(fake.ctx).use({ alias: 'long', sessionTtlSeconds: 7_200 })
98
+ const now = Math.floor(Date.now() / 1000)
99
+ await topUp(fake)
100
+ expect(fake.state.checkoutSessions[0].expires_at - now).toBeGreaterThanOrEqual(1_800)
101
+ expect(fake.state.checkoutSessions[0].expires_at - now).toBeLessThanOrEqual(1_802)
102
+
103
+ const plain = await makeRightsContext()
104
+ await topUp(plain)
105
+ expect(plain.state.checkoutSessions[0].expires_at).toBeUndefined()
106
+ })
107
+
108
+ test('a veto releases what earlier plugins admitted and creates no session', async () => {
109
+ const fake = await makeRightsContext()
110
+ const log = journal()
111
+ gateway(fake.ctx).use(recording('first', log))
112
+ gateway(fake.ctx).use({
113
+ alias: 'veto',
114
+ admit: async () => { throw new CheckoutLimitExceeded({ reason: 'window', maximumMinor: 0, currency: 'usd' }) },
115
+ })
116
+ await expect(topUp(fake)).rejects.toBeInstanceOf(CheckoutLimitExceeded)
117
+ expect(fake.state.checkoutSessions).toHaveLength(0)
118
+ expect(log.settled).toEqual([expect.objectContaining({ alias: 'first', outcome: 'failed', reservationId: 'first-hold' })])
119
+ })
120
+
121
+ test('a created that throws expires the fresh session and releases every hold', async () => {
122
+ const fake = await makeRightsContext()
123
+ const log = journal()
124
+ gateway(fake.ctx).use(recording('first', log))
125
+ gateway(fake.ctx).use(recording('second', log, { created: async () => { throw new Error('reservation lost') } }))
126
+ await expect(topUp(fake)).rejects.toThrow('reservation lost')
127
+ expect(fake.state.expiredSessions).toHaveLength(1)
128
+ expect(log.settled.map(settled => [settled.outcome, settled.reservationId, settled.sessionId]))
129
+ .toEqual([['failed', 'first-hold', fake.state.expiredSessions[0]], ['failed', 'second-hold', fake.state.expiredSessions[0]]])
130
+ })
131
+
132
+ test('Stripe refusing the session releases the holds too', async () => {
133
+ const fake = await makeRightsContext()
134
+ const log = journal()
135
+ gateway(fake.ctx).use(recording('first', log))
136
+ fake.state.failures['checkout.sessions.create'] = 'amount_too_small'
137
+ await expect(topUp(fake)).rejects.toThrow('amount_too_small')
138
+ expect(log.settled).toEqual([expect.objectContaining({ outcome: 'failed', reservationId: 'first-hold' })])
139
+ })
140
+ })
141
+
142
+ describe('checkout plugins — settlement from the webhook', () => {
143
+ test('paid, expired and failed sessions are settled; a settling error never fails the webhook', async () => {
144
+ const fake = await makeRightsContext()
145
+ const log = journal()
146
+ gateway(fake.ctx).use(recording('tier', log))
147
+ gateway(fake.ctx).use({ alias: 'broken', settled: async () => { throw new Error('ignored') } })
148
+
149
+ await send(fake, 'checkout.session.completed', paidSession({ id: 'cs_paid' }))
150
+ await send(fake, 'checkout.session.expired', { ...paidSession({ id: 'cs_gone', payment_status: 'unpaid' }), status: 'expired' })
151
+ await send(fake, 'checkout.session.async_payment_failed', paidSession({ id: 'cs_failed', payment_status: 'unpaid' }))
152
+ expect(log.settled.map(settled => [settled.sessionId, settled.outcome])).toEqual([
153
+ ['cs_paid', 'paid'], ['cs_gone', 'expired'], ['cs_failed', 'failed'],
154
+ ])
155
+ expect(log.settled[0]).toEqual(expect.objectContaining({ entityId: ENTITY, productSku: CREDITS_PRODUCT, amountMinor: 1_000 }))
156
+ })
157
+
158
+ test('an unmanaged gateway still narrows and answers the amount policy', async () => {
159
+ const fake = await makeRightsContext()
160
+ expect(gateway(fake.ctx).managed).toBe(false)
161
+ gateway(fake.ctx).use({ alias: 'tier', narrow: async () => ({ maximumMinor: 700, reason: 'per-purchase' }) })
162
+ expect((await gateway(fake.ctx).amountPolicy(fake.ctx, ENTITY, CREDITS_PRODUCT)).policy.maximumMinor).toBe(700)
163
+ })
164
+ })