@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
@@ -1,9 +1,13 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
- import { CheckoutPricingMode, PlanDuration, PlanStatus, ProductType } from '@owlmeans/payment'
3
- import { PAYMENT_OBSERVER, RES_PAYMENT_SUBSCRIPTION } from '../src/consts.js'
2
+ import { CheckoutPricingMode, PlanDuration, PlanStatus, ProductError, ProductType, TaxBehavior } from '@owlmeans/payment'
4
3
  import { createEventHandler } from '../src/plugins/events.js'
5
- import { amountCheckoutLineItem, quantityCheckoutLineItem } from '../src/plugins/stripe.js'
6
- import type { PaymentPlan, PaymentProduct, PaymentSubscriptionRecord, TopUpCompletion } from '../src/types.js'
4
+ import { amountCheckoutLineItem, createCheckoutLink, quantityCheckoutLineItem } from '../src/plugins/stripe.js'
5
+ import type { PaymentPlan, PaymentProduct } from '../src/types.js'
6
+ import { CREDITS_PRODUCT, FREE, makeFakeContext, PLANS_PRODUCT, PRO, TEAM } from './fake-stripe.js'
7
+ import type { FakeContext } from './fake-stripe.js'
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'
7
11
 
8
12
  const product: PaymentProduct = {
9
13
  type: ProductType.Consumable, sku: 'credits', title: 'Credits', services: ['app'],
@@ -20,41 +24,20 @@ const plan: PaymentPlan = {
20
24
 
21
25
  const paid = (overrides: Record<string, unknown> = {}) => ({
22
26
  id: 'cs_amount', mode: 'payment', payment_status: 'paid', customer: 'cus_1',
23
- currency: 'usd', amount_subtotal: 1021,
27
+ currency: 'usd', amount_subtotal: 1021, payment_intent: 'pi_1', invoice: 'in_1',
24
28
  metadata: {
25
29
  pricingMode: 'amount', amountMinor: '1000', chargeAmountMinor: '1021', currency: 'usd',
26
- entityId: 'entity-1', service: 'app', productSku: 'credits', planSku: 'credits-unit',
30
+ entityId: 'entity-1', service: 'app', productSku: CREDITS_PRODUCT, planSku: 'app-credit-unit',
27
31
  },
28
32
  ...overrides,
29
33
  })
30
34
 
31
- const harness = () => {
32
- const records: PaymentSubscriptionRecord[] = []
33
- const completions: TopUpCompletion[] = []
34
- const subscriptions = {
35
- byExternalId: async (id: string) => records.find(record => record.externalId === id) ?? null,
36
- create: async (record: PaymentSubscriptionRecord) => {
37
- const created = { ...record, id: String(records.length + 1) }
38
- records.push(created)
39
- return created
40
- },
41
- save: async (record: PaymentSubscriptionRecord) => {
42
- const index = records.findIndex(item => item.id === record.id)
43
- records[index] = { ...record }
44
- return records[index]
45
- },
46
- }
47
- const observer = { propagateTopUp: async (completion: TopUpCompletion) => { completions.push(completion) } }
48
- const context = {
49
- resource: (alias: string) => alias === RES_PAYMENT_SUBSCRIPTION ? subscriptions : undefined,
50
- service: (alias: string) => alias === PAYMENT_OBSERVER ? observer : undefined,
51
- }
52
- const stripe = { checkout: { sessions: { listLineItems: async () => ({ data: [{ quantity: 7 }] }) } } }
53
- return { records, completions, context, stripe }
54
- }
35
+ const process = async (fake: FakeContext, type: string, object: unknown) =>
36
+ await createEventHandler(fake.ctx, fake.stripe).process({ type, data: { object } } as never)
37
+ const fulfillment = (fake: FakeContext) => fake.stores['payment-fulfillment'].rows
55
38
 
56
- describe('Stripe amount checkout', () => {
57
- test('uses one inline, tax-exclusive item and no reusable Price', () => {
39
+ describe('Stripe checkout', () => {
40
+ test('amount checkout uses one inline, tax-exclusive item and no reusable Price', () => {
58
41
  const result = amountCheckoutLineItem(product, plan, 1_000)
59
42
  expect(result.chargeMinor).toBe(1_021)
60
43
  expect(result.lineItem).toEqual({
@@ -64,7 +47,7 @@ describe('Stripe amount checkout', () => {
64
47
  expect(result.lineItem.adjustable_quantity).toBeUndefined()
65
48
  })
66
49
 
67
- test('keeps reusable Price and adjustable quantity for quantity checkout', () => {
50
+ test('quantity checkout keeps a reusable Price and an adjustable quantity', () => {
68
51
  expect(quantityCheckoutLineItem({ id: 'price_1' } as never, {
69
52
  minimum: 5, maximum: 500, default: 10,
70
53
  })).toEqual({
@@ -73,63 +56,181 @@ describe('Stripe amount checkout', () => {
73
56
  })
74
57
  })
75
58
 
76
- test('fulfills a paid amount without crediting the adjustment', async () => {
77
- const h = harness()
78
- await createEventHandler(h.context as never, h.stripe as never).process({
79
- type: 'checkout.session.completed', data: { object: paid() },
80
- } as never)
81
- expect(h.completions).toEqual([expect.objectContaining({ mode: 'amount', amountMinor: 1_000, chargeAmountMinor: 1_021 })])
82
- expect(h.records[0]?.fulfilledAt).toBeInstanceOf(Date)
59
+ test('a subscription checkout takes the asked plan, locale and disclosure; a free plan is never checked out', async () => {
60
+ const fake = await makeFakeContext({
61
+ stripe: { prices: [{ id: 'price_team', product: PLANS_PRODUCT, lookup_key: TEAM, active: true, recurring: { interval: 'month' } }] },
62
+ })
63
+ await createCheckoutLink(fake.ctx, fake.stripe, {
64
+ productSku: PLANS_PRODUCT, planSku: TEAM, entityId: 'entity-1', service: 'app', successUrl: 'https://app.example.com/ok',
65
+ locale: 'fr', submitText: 'Comprend des crédits prépayés obligatoires.',
66
+ })
67
+ expect(fake.state.checkoutSessions[0]).toEqual(expect.objectContaining({
68
+ mode: 'subscription', line_items: [{ price: 'price_team', quantity: 1 }],
69
+ locale: 'fr', custom_text: { submit: { message: 'Comprend des crédits prépayés obligatoires.' } },
70
+ }))
71
+ expect(fake.state.checkoutSessions[0].subscription_data.metadata).toEqual(expect.objectContaining({ entityId: 'entity-1', planSku: TEAM }))
72
+ expect(fake.state.customers.cus_1.preferred_locales).toEqual(['fr'])
73
+
74
+ await expect(createCheckoutLink(fake.ctx, fake.stripe, {
75
+ productSku: PLANS_PRODUCT, planSku: FREE, entityId: 'entity-1', service: 'app',
76
+ })).rejects.toBeInstanceOf(ProductError)
77
+ })
78
+ })
79
+
80
+ describe('Stripe checkout — pricing policy', () => {
81
+ test('an undeclared policy reproduces exactly the session hard-coded before this policy existed', async () => {
82
+ const fake = await makeFakeContext({ stripe: { prices: [proPrice] } })
83
+ await createCheckoutLink(fake.ctx, fake.stripe, {
84
+ productSku: PLANS_PRODUCT, planSku: PRO, entityId: 'entity-1', service: 'app', successUrl,
85
+ })
86
+ const session = fake.state.checkoutSessions[0]
87
+ expect(session).toEqual(expect.objectContaining({
88
+ automatic_tax: { enabled: true }, billing_address_collection: 'required',
89
+ tax_id_collection: { enabled: true }, customer_update: { address: 'auto', name: 'auto' },
90
+ }))
91
+ expect(session.adaptive_pricing).toBeUndefined()
92
+ })
93
+
94
+ test('tax.automatic and tax.collectTaxId are independent switches', async () => {
95
+ const fake = await makeFakeContext({
96
+ pricing: { tax: { automatic: false, collectTaxId: true, estimate: false }, currency: { estimate: false } },
97
+ stripe: { prices: [proPrice] },
98
+ })
99
+ await createCheckoutLink(fake.ctx, fake.stripe, {
100
+ productSku: PLANS_PRODUCT, planSku: PRO, entityId: 'entity-1', service: 'app', successUrl,
101
+ })
102
+ const session = fake.state.checkoutSessions[0]
103
+ expect(session.automatic_tax).toBeUndefined()
104
+ expect(session.billing_address_collection).toBeUndefined()
105
+ expect(session.tax_id_collection).toEqual({ enabled: true })
106
+ // only the concern that needs it contributes a `customer_update` field
107
+ expect(session.customer_update).toEqual({ name: 'auto' })
83
108
  })
84
109
 
85
- test('does not fulfill an unpaid completion; asynchronous success does', async () => {
86
- const h = harness()
87
- const handler = createEventHandler(h.context as never, h.stripe as never)
88
- await handler.process({
89
- type: 'checkout.session.completed', data: { object: paid({ payment_status: 'unpaid' }) },
90
- } as never)
91
- expect(h.completions).toHaveLength(0)
92
- await handler.process({ type: 'checkout.session.async_payment_succeeded', data: { object: paid() } } as never)
93
- expect(h.completions).toHaveLength(1)
110
+ test('adaptive_pricing appears on the session only when currency.adaptive is declared', async () => {
111
+ const fake = await makeFakeContext({
112
+ pricing: { tax: { automatic: true, collectTaxId: true, estimate: false }, currency: { adaptive: true, estimate: false } },
113
+ stripe: { prices: [proPrice] },
114
+ })
115
+ await createCheckoutLink(fake.ctx, fake.stripe, {
116
+ productSku: PLANS_PRODUCT, planSku: PRO, entityId: 'entity-1', service: 'app', successUrl,
117
+ })
118
+ expect(fake.state.checkoutSessions[0].adaptive_pricing).toEqual({ enabled: true })
94
119
  })
95
120
 
96
- test('rejects currency/subtotal metadata mismatches and is idempotent on retries', async () => {
97
- const h = harness()
98
- const handler = createEventHandler(h.context as never, h.stripe as never)
99
- await expect(handler.process({
100
- type: 'checkout.session.completed', data: { object: paid({ amount_subtotal: 1020 }) },
101
- } as never)).rejects.toThrow()
102
- await handler.process({ type: 'checkout.session.completed', data: { object: paid() } } as never)
103
- await handler.process({ type: 'checkout.session.completed', data: { object: paid() } } as never)
104
- expect(h.completions).toHaveLength(1)
121
+ test('subscription checkout can explicitly offer recurring-capable payment methods', async () => {
122
+ const fake = await makeFakeContext({
123
+ pricing: {
124
+ tax: { automatic: true, collectTaxId: true, estimate: false }, currency: { estimate: false },
125
+ stripe: { subscriptionPaymentMethodTypes: ['CARD', 'link', 'klarna'] },
126
+ },
127
+ stripe: { prices: [proPrice] },
128
+ })
129
+ await createCheckoutLink(fake.ctx, fake.stripe, {
130
+ productSku: PLANS_PRODUCT, planSku: PRO, entityId: 'entity-1', service: 'app', successUrl,
131
+ })
132
+ expect(fake.state.checkoutSessions[0].payment_method_types).toEqual(['card', 'link', 'klarna'])
105
133
  })
106
134
 
107
- test('retries fulfillment when the application observer fails', async () => {
108
- const h = harness()
109
- let attempts = 0
110
- ;(h.context.service(PAYMENT_OBSERVER) as never as { propagateTopUp: () => Promise<void> })
111
- .propagateTopUp = async () => {
112
- attempts++
113
- if (attempts === 1) throw new Error('ledger unavailable')
114
- h.completions.push({ mode: 'amount' } as never)
115
- }
116
- const handler = createEventHandler(h.context as never, h.stripe as never)
117
- const event = { type: 'checkout.session.completed', data: { object: paid() } } as never
118
- await expect(handler.process(event)).rejects.toThrow('ledger unavailable')
119
- expect(h.records[0]?.fulfilledAt).toBeUndefined()
120
- await handler.process(event)
121
- expect(attempts).toBe(2)
122
- expect(h.records[0]?.fulfilledAt).toBeInstanceOf(Date)
135
+ test('a declared behavior is set on the inline amount line item', async () => {
136
+ const fake = await makeFakeContext({
137
+ pricing: { tax: { automatic: true, collectTaxId: true, estimate: false, behavior: TaxBehavior.Inclusive }, currency: { estimate: false } },
138
+ })
139
+ await createCheckoutLink(fake.ctx, fake.stripe, {
140
+ productSku: CREDITS_PRODUCT, entityId: 'entity-1', service: 'app', amountMinor: 1_000, successUrl,
141
+ })
142
+ expect(fake.state.checkoutSessions[0].line_items[0].price_data.tax_behavior).toBe('inclusive')
143
+ })
144
+
145
+ test('an amount checkout converts the USD catalogue charge to the settlement currency', async () => {
146
+ const fake = await makeFakeContext({
147
+ pricing: {
148
+ tax: { automatic: true, collectTaxId: true, estimate: false },
149
+ currency: { adaptive: true, estimate: false }, stripe: { settlementCurrency: 'EUR' },
150
+ },
151
+ stripe: { fxRates: { usd: { exchangeRate: 0.853568, referenceRate: 0.8726 } } },
152
+ })
153
+ await createCheckoutLink(fake.ctx, fake.stripe, {
154
+ productSku: CREDITS_PRODUCT, entityId: 'entity-1', service: 'app', amountMinor: 1_000, successUrl,
155
+ })
156
+ const session = fake.state.checkoutSessions[0]
157
+ expect(session.line_items[0].price_data).toEqual(expect.objectContaining({ currency: 'eur', unit_amount: 891 }))
158
+ expect(session.metadata).toEqual(expect.objectContaining({
159
+ amountMinor: '1000', sourceChargeAmountMinor: '1021', amountCurrency: 'usd',
160
+ chargeAmountMinor: '891', currency: 'eur',
161
+ }))
162
+ expect(fake.state.rawRequests[0]).toEqual(expect.objectContaining({
163
+ params: { to_currency: 'eur', 'from_currencies[]': 'usd', lock_duration: 'none' },
164
+ }))
165
+ })
166
+ })
167
+
168
+ describe('Stripe checkout fulfillment', () => {
169
+ test('fulfills a paid amount once even when the session carries Adaptive Pricing presentment details', async () => {
170
+ const fake = await makeFakeContext()
171
+ await process(fake, 'checkout.session.completed', paid({
172
+ presentment_details: { presentment_amount: 4_613, presentment_currency: 'eur' },
173
+ }))
174
+ expect(fake.observed.topUp).toEqual([expect.objectContaining({ amountMinor: 1_000, chargeAmountMinor: 1_021 })])
175
+ })
176
+
177
+ test('fulfills a paid amount once, without crediting the adjustment, and records the payment intent', async () => {
178
+ const fake = await makeFakeContext()
179
+ await process(fake, 'checkout.session.completed', paid())
180
+ await process(fake, 'checkout.session.completed', paid())
181
+ expect(fake.observed.topUp).toEqual([expect.objectContaining({
182
+ mode: 'amount', amountMinor: 1_000, chargeAmountMinor: 1_021, externalId: 'cs_amount', entityId: 'entity-1',
183
+ })])
184
+ expect(fulfillment(fake)[0]).toEqual(expect.objectContaining({
185
+ externalId: 'cs_amount', paymentIntentId: 'pi_1', invoiceId: 'in_1', mode: 'amount',
186
+ }))
187
+ expect(fulfillment(fake)[0].fulfilledAt).toBeInstanceOf(Date)
188
+ expect(fake.stores['payment-subscription'].rows).toHaveLength(0)
189
+ })
190
+
191
+ test('fulfills the USD credit value from an EUR settlement charge', async () => {
192
+ const fake = await makeFakeContext()
193
+ await process(fake, 'checkout.session.completed', paid({
194
+ currency: 'eur', amount_subtotal: 891,
195
+ metadata: {
196
+ pricingMode: 'amount', amountMinor: '1000', sourceChargeAmountMinor: '1021', amountCurrency: 'usd',
197
+ chargeAmountMinor: '891', currency: 'eur', entityId: 'entity-1', service: 'app',
198
+ productSku: CREDITS_PRODUCT, planSku: 'app-credit-unit',
199
+ },
200
+ }))
201
+ expect(fake.observed.topUp[0]).toEqual(expect.objectContaining({
202
+ amountMinor: 1_000, sourceChargeAmountMinor: 1_021, amountCurrency: 'usd',
203
+ chargeAmountMinor: 891, currency: 'eur',
204
+ }))
205
+ expect(fulfillment(fake)[0]).toEqual(expect.objectContaining({ amountCurrency: 'usd', currency: 'eur' }))
206
+ })
207
+
208
+ test('grants nothing for an unpaid completion; the asynchronous success does', async () => {
209
+ const fake = await makeFakeContext()
210
+ await process(fake, 'checkout.session.completed', paid({ payment_status: 'unpaid' }))
211
+ expect(fake.observed.topUp).toHaveLength(0)
212
+ await process(fake, 'checkout.session.async_payment_succeeded', paid())
213
+ expect(fake.observed.topUp).toHaveLength(1)
214
+ })
215
+
216
+ test('rejects a currency or subtotal mismatch; retries after a failed observer', async () => {
217
+ const fake = await makeFakeContext()
218
+ await expect(process(fake, 'checkout.session.completed', paid({ amount_subtotal: 1020 }))).rejects.toThrow()
219
+
220
+ fake.observed.failTopUp = 1
221
+ await expect(process(fake, 'checkout.session.completed', paid())).rejects.toThrow('ledger unavailable')
222
+ expect(fulfillment(fake)[0]?.fulfilledAt).toBeUndefined()
223
+ await process(fake, 'checkout.session.completed', paid())
224
+ expect(fake.observed.topUp).toHaveLength(1)
225
+ expect(fulfillment(fake)[0].fulfilledAt).toBeInstanceOf(Date)
123
226
  })
124
227
 
125
- test('legacy sessions without pricing metadata still fulfill by quantity', async () => {
126
- const h = harness()
127
- const legacy = paid({ id: 'cs_legacy', metadata: {
128
- entityId: 'entity-1', service: 'app', productSku: 'credits', planSku: 'credits-unit',
129
- } })
130
- await createEventHandler(h.context as never, h.stripe as never).process({
131
- type: 'checkout.session.completed', data: { object: legacy },
132
- } as never)
133
- expect(h.completions[0]).toEqual(expect.objectContaining({ mode: 'quantity', units: 7 }))
228
+ test('a legacy session without pricing metadata fulfills by quantity', async () => {
229
+ const fake = await makeFakeContext()
230
+ await process(fake, 'checkout.session.completed', paid({
231
+ id: 'cs_legacy', metadata: { entityId: 'entity-1', service: 'app', productSku: CREDITS_PRODUCT, planSku: 'app-credit-unit' },
232
+ }))
233
+ expect(fake.observed.topUp[0]).toEqual(expect.objectContaining({ mode: 'quantity', units: 7 }))
234
+ expect(fulfillment(fake)[0]).toEqual(expect.objectContaining({ mode: 'quantity', units: 7 }))
134
235
  })
135
236
  })
@@ -0,0 +1,218 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { PurchaseKind, withdrawalDeadlineOf } from '@owlmeans/payment'
3
+ import {
4
+ billingProfiles, consumerEvents, fulfillments, paygateCustomers, purchases, subscriptions,
5
+ } from '../src/utils.js'
6
+ import {
7
+ buyTopUp, ENTITY, invoiceOf, makeRightsContext, paidSession, PRO, requestStart, send,
8
+ } from './consumer-fixtures.js'
9
+ import { capturePaymentPurchase } from '../src/consumer/capture.js'
10
+ import { subscriptionOf } from './fake-stripe.js'
11
+ import type { FakeContext } from './fake-stripe.js'
12
+
13
+ const proSubscription = (startRequestId?: string, overrides: Record<string, unknown> = {}) => {
14
+ const subscription = subscriptionOf({ id: 'sub_pro', customer: 'cus_1', latestInvoice: 'in_sub' }) as unknown as Record<string, any>
15
+ subscription.currency = 'eur'
16
+ subscription.items.data[0].price = { id: 'price_pro', lookup_key: PRO, unit_amount: 1_800, currency: 'eur', tax_behavior: 'exclusive' }
17
+ subscription.items.data[0].quantity = 1
18
+ subscription.metadata = {
19
+ entityId: ENTITY, planSku: PRO, productSku: 'app-plans', service: 'app', country: 'DE', language: 'de',
20
+ ...(startRequestId != null ? { startRequestId } : {}),
21
+ }
22
+ return { ...subscription, ...overrides }
23
+ }
24
+
25
+ const subscriptionSession = (overrides: Record<string, unknown> = {}) => paidSession({
26
+ id: 'cs_sub', mode: 'subscription', subscription: 'sub_pro', invoice: 'in_sub', payment_intent: null,
27
+ amount_subtotal: 1_800, amount_total: 2_142, total_details: { amount_tax: 342 },
28
+ customer_details: { address: { country: 'DE' }, email: 'owner@shop.eu', name: 'Anna', tax_ids: [] },
29
+ metadata: { pricingMode: 'quantity', planSku: PRO, productSku: 'app-plans', country: 'DE', language: 'de' },
30
+ ...overrides,
31
+ })
32
+
33
+ const withSubInvoice = (fake: FakeContext) => {
34
+ fake.state.invoices.in_sub = invoiceOf('in_sub', {
35
+ number: 'INV-SUB-1', subtotal: 1_800, tax: 342, total: 2_142, payment_intent: 'pi_sub',
36
+ lines: { object: 'list', data: [{ id: 'il_sub', amount: 1_800 }] }, customer_address: { country: 'DE' },
37
+ })
38
+ }
39
+
40
+ describe('webhook capture — one-time purchases', () => {
41
+ test('the purchase row exists before the credits are granted, with its window and evidence', async () => {
42
+ const fake = await makeRightsContext()
43
+ const seen: unknown[] = []
44
+ fake.observed.onTopUp = async () => { seen.push(await purchases(fake.ctx).load({ sessionId: 'cs_pl' })) }
45
+ const purchase = await buyTopUp(fake)
46
+
47
+ expect(seen[0]).toEqual(expect.objectContaining({ purchaseId: 'stripe:cs_pl' }))
48
+ expect(purchase).toEqual(expect.objectContaining({
49
+ purchaseId: 'stripe:cs_pl', kind: PurchaseKind.TopUp, entityId: ENTITY, country: 'PL', region: 'eu', inScope: true,
50
+ language: 'pl', currency: 'eur', amountSubtotalMinor: 919, amountTaxMinor: 211, amountTotalMinor: 1_130,
51
+ netAmountMinor: 1_000, amountCurrency: 'usd', paymentIntentId: 'pi_pl', invoiceId: 'in_pl',
52
+ invoiceNumber: 'INV-0001', invoiceLineId: 'il_pl', termsAccepted: true, ipCountry: 'PL', email: 'buyer@shop.eu',
53
+ textVersion: 'terms-v1',
54
+ }))
55
+ expect(purchase.contractRef).toMatch(/^CR-\d{6}-[2-9A-HJKMNP-Z]{6}$/)
56
+ expect(purchase.deadline).toEqual(withdrawalDeadlineOf(new Date(purchase.purchasedAt), { marginDays: 5 }))
57
+ expect((await fulfillments(fake.ctx).byExternalId('cs_pl', 'stripe'))).toEqual(expect.objectContaining({
58
+ country: 'PL', email: 'buyer@shop.eu', amountTotalMinor: 1_130, amountTaxMinor: 211, termsAccepted: true,
59
+ purchaseId: 'stripe:cs_pl',
60
+ }))
61
+ })
62
+
63
+ test('the first purchase locks the country; a later one elsewhere is only a lock-mismatch event', async () => {
64
+ const fake = await makeRightsContext()
65
+ await buyTopUp(fake)
66
+ expect(await billingProfiles(fake.ctx).byEntity(ENTITY)).toEqual(expect.objectContaining({
67
+ country: 'PL', region: 'eu', currency: 'eur', language: 'pl', source: 'checkout', ipCountry: 'PL',
68
+ email: 'buyer@shop.eu', sessionId: 'cs_pl',
69
+ }))
70
+ const second = await buyTopUp(fake, {
71
+ id: 'cs_de', invoice: 'in_de', customer_details: { address: { country: 'DE' }, email: 'buyer@shop.eu' },
72
+ metadata: { ipCountry: 'AT' },
73
+ })
74
+ expect(second.country).toBe('DE')
75
+ expect((await billingProfiles(fake.ctx).byEntity(ENTITY))?.country).toBe('PL')
76
+ const mismatch = (await consumerEvents(fake.ctx).list({ action: 'lock-mismatch' })).items
77
+ expect(mismatch).toHaveLength(1)
78
+ expect(JSON.parse(mismatch[0].detail ?? '{}')).toEqual(expect.objectContaining({ locked: 'PL', observed: 'DE', sessionId: 'cs_de' }))
79
+ })
80
+
81
+ test('the confirmation is mailed once, in the billing language, with the withdrawal information and an archive copy', async () => {
82
+ const fake = await makeRightsContext()
83
+ await buyTopUp(fake, { customer_details: { address: { country: 'PL' }, email: 'buyer@shop.eu', name: 'Jan <b>K</b>' } })
84
+ await send(fake, 'checkout.session.completed', paidSession())
85
+ const mails = fake.mails.filter(mail => mail.subject.includes('CR-'))
86
+ expect(mails.map(mail => mail.to)).toEqual(['buyer@shop.eu', 'archive@example.com'])
87
+ const [mail] = mails
88
+ expect(mail.from).toBe('billing@example.com')
89
+ expect(mail.text).toContain('Example Trading Ltd, 1 Example Street, 00-001 Example, support@example.com')
90
+ expect(mail.text).toContain('https://app.example.com/legal/withdraw')
91
+ expect(mail.text).toContain('Odstąp od umowy tutaj')
92
+ expect(mail.html).toContain('Jan &lt;b&gt;K&lt;/b&gt;')
93
+ expect(mail.html).not.toContain('<b>K</b>')
94
+ })
95
+
96
+ test('concurrent deliveries in several processes mail the confirmation once', async () => {
97
+ const fake = await makeRightsContext()
98
+ fake.state.invoices.in_pl = invoiceOf()
99
+ const session = paidSession() as never
100
+ // Not through the webhook handler, whose per-customer lock serializes one process: two processes race here.
101
+ await Promise.all([
102
+ capturePaymentPurchase(fake.ctx, fake.stripe, session), capturePaymentPurchase(fake.ctx, fake.stripe, session),
103
+ ])
104
+ await send(fake, 'checkout.session.completed', paidSession())
105
+ expect(fake.mails.filter(mail => mail.subject.includes('CR-')).map(mail => mail.to)).toEqual(['buyer@shop.eu', 'archive@example.com'])
106
+ expect(await consumerEvents(fake.ctx).count({ action: 'mail', step: 'purchase' })).toBe(1)
107
+ expect((await purchases(fake.ctx).load({ sessionId: 'cs_pl' }))?.confirmationMailAt).toBeInstanceOf(Date)
108
+ })
109
+
110
+ test('a checkout that collected no terms records no acceptance', async () => {
111
+ const fake = await makeRightsContext()
112
+ const purchase = await buyTopUp(fake, { consent: null, metadata: { termsCollected: 'false' } })
113
+ expect(purchase.termsAccepted).toBeUndefined()
114
+ expect((await fulfillments(fake.ctx).byExternalId('cs_pl', 'stripe'))?.termsAccepted).toBeUndefined()
115
+ })
116
+
117
+ test('a purchase outside the territories has no window and no confirmation', async () => {
118
+ const fake = await makeRightsContext()
119
+ const purchase = await buyTopUp(fake, {
120
+ id: 'cs_us', invoice: 'in_us', currency: 'usd', amount_subtotal: 1_021, amount_total: 1_021, total_details: { amount_tax: 0 },
121
+ customer_details: { address: { country: 'US' }, email: 'buyer@shop.us' },
122
+ metadata: { country: 'US', currency: 'usd', chargeAmountMinor: '1021', language: 'en' },
123
+ })
124
+ expect(purchase).toEqual(expect.objectContaining({ inScope: false, region: 'other', country: 'US' }))
125
+ expect(purchase.deadline).toBeUndefined()
126
+ expect(fake.mails).toHaveLength(0)
127
+ expect((await billingProfiles(fake.ctx).byEntity(ENTITY))?.currency).toBe('usd')
128
+ })
129
+
130
+ test('customer webhooks store the paygate country and currency', async () => {
131
+ const fake = await makeRightsContext()
132
+ await send(fake, 'customer.updated', { id: 'cus_9', email: 'a@shop.eu', address: { country: 'fr' }, currency: 'EUR', metadata: { entityId: ENTITY } })
133
+ expect(await paygateCustomers(fake.ctx).loadByPgId('cus_9', 'stripe')).toEqual(expect.objectContaining({ country: 'FR', currency: 'eur' }))
134
+ })
135
+ })
136
+
137
+ describe('webhook capture — subscriptions', () => {
138
+ test('the first commit writes the purchase before the created observers; the checkout refines it', async () => {
139
+ const fake = await makeRightsContext()
140
+ withSubInvoice(fake)
141
+ const startRequestId = await requestStart(fake)
142
+ const seen: unknown[] = []
143
+ fake.observed.onSubscription = async event => {
144
+ if (event.change === 'created') seen.push(await purchases(fake.ctx).byPurchaseId('stripe:sub_pro'))
145
+ }
146
+ const subscription = proSubscription(startRequestId)
147
+ fake.state.subscriptions.sub_pro = subscription as never
148
+ await send(fake, 'customer.subscription.created', subscription)
149
+
150
+ expect(seen[0]).toEqual(expect.objectContaining({
151
+ purchaseId: 'stripe:sub_pro', kind: PurchaseKind.Subscription, subscriptionId: 'sub_pro', invoiceId: 'in_sub',
152
+ invoiceNumber: 'INV-SUB-1', invoiceLineId: 'il_sub', paymentIntentId: 'pi_sub', currency: 'eur',
153
+ amountSubtotalMinor: 1_800, amountTaxMinor: 342, amountTotalMinor: 2_142, startRequestId, consentId: startRequestId,
154
+ country: 'DE', inScope: true,
155
+ }))
156
+ const stored = seen[0] as { servicesStartedAt: Date, consentedAt: Date }
157
+ expect(stored.servicesStartedAt).toBeInstanceOf(Date)
158
+ expect(stored.consentedAt).toEqual(stored.servicesStartedAt)
159
+
160
+ await send(fake, 'checkout.session.completed', subscriptionSession({ customer_details: { address: { country: 'AT' }, email: 'owner@shop.eu', name: 'Anna' } }))
161
+ expect(await purchases(fake.ctx).byPurchaseId('stripe:sub_pro')).toEqual(expect.objectContaining({
162
+ sessionId: 'cs_sub', country: 'AT', email: 'owner@shop.eu', termsAccepted: true, amountTotalMinor: 2_142,
163
+ }))
164
+ expect(await subscriptions(fake.ctx).byExternalId('sub_pro', 'stripe')).toEqual(expect.objectContaining({
165
+ checkoutSessionId: 'cs_sub', purchaseId: 'stripe:sub_pro', firstInvoiceId: 'in_sub', currency: 'eur', country: 'AT',
166
+ email: 'owner@shop.eu', amountTotalMinor: 2_142, amountTaxMinor: 342, termsAccepted: true, startRequestId,
167
+ }))
168
+ expect((await billingProfiles(fake.ctx).byEntity(ENTITY))?.country).toBe('AT')
169
+ const confirmation = fake.mails.find(mail => mail.to === 'owner@shop.eu' && mail.subject.includes('CR-'))
170
+ // The order confirmation is in the billing language; the start request is repeated verbatim, as shown (en).
171
+ expect(confirmation?.subject).toStartWith('Bestellbestätigung')
172
+ expect(confirmation?.text).toContain('I expressly request and agree that Example starts the Pro platform services')
173
+ expect(confirmation?.text).toContain('https://app.example.com/legal/cancel')
174
+ })
175
+
176
+ test('a completed subscription checkout that arrives first applies the subscription itself', async () => {
177
+ const fake = await makeRightsContext()
178
+ withSubInvoice(fake)
179
+ fake.state.subscriptions.sub_pro = proSubscription() as never
180
+ await send(fake, 'checkout.session.completed', subscriptionSession())
181
+ expect(fake.observed.subscription.map(event => event.change)).toEqual(['created'])
182
+ expect(await purchases(fake.ctx).byPurchaseId('stripe:sub_pro')).toEqual(expect.objectContaining({ sessionId: 'cs_sub', country: 'DE' }))
183
+ // Renewals are not purchases.
184
+ await send(fake, 'invoice.paid', { id: 'in_renew', object: 'invoice', subscription: 'sub_pro', customer: 'cus_1', billing_reason: 'subscription_cycle' })
185
+ expect(fake.stores['payment-purchase'].rows).toHaveLength(1)
186
+ })
187
+ })
188
+
189
+ describe('webhook capture — refunds', () => {
190
+ const charge = { id: 'ch_pl', amount: 1_130, amount_refunded: 1_130, payment_intent: 'pi_pl', invoice: null }
191
+
192
+ test('a withdrawal refund carries its id to the refund observers; a whole refund closes the window', async () => {
193
+ const fake = await makeRightsContext({ stripe: { charges: { ch_pl: charge } } })
194
+ await buyTopUp(fake)
195
+ await send(fake, 'refund.created', {
196
+ id: 're_w', object: 'refund', amount: 1_130, currency: 'eur', status: 'succeeded', payment_intent: 'pi_pl',
197
+ charge: 'ch_pl', metadata: { owlmeans: 'payment', withdrawalId: 'decl_1', purchaseId: 'stripe:cs_pl' },
198
+ })
199
+ expect(fake.observed.refund[0]).toEqual(expect.objectContaining({
200
+ withdrawalId: 'decl_1', metadata: expect.objectContaining({ withdrawalId: 'decl_1' }), partial: false,
201
+ }))
202
+ const purchase = await purchases(fake.ctx).byPurchaseId('stripe:cs_pl')
203
+ expect(purchase?.refundedMinor).toBe(1_130)
204
+ expect(purchase?.refundedAt).toBeInstanceOf(Date)
205
+ })
206
+
207
+ test('an ordinary partial refund carries no withdrawal id and keeps the window open', async () => {
208
+ const fake = await makeRightsContext({ stripe: { charges: { ch_pl: { ...charge, amount_refunded: 300 } } } })
209
+ await buyTopUp(fake)
210
+ await send(fake, 'refund.created', {
211
+ id: 're_p', object: 'refund', amount: 300, currency: 'eur', status: 'succeeded', payment_intent: 'pi_pl', charge: 'ch_pl',
212
+ })
213
+ expect(fake.observed.refund[0].withdrawalId).toBeUndefined()
214
+ const purchase = await purchases(fake.ctx).byPurchaseId('stripe:cs_pl')
215
+ expect(purchase?.refundedMinor).toBe(300)
216
+ expect(purchase?.refundedAt).toBeUndefined()
217
+ })
218
+ })