@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,501 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import {
3
+ CancellationKind, CancellationStatus, CancellationUnavailable, LimitKind, PerformanceConsentRequired,
4
+ PlanDuration, WithdrawalStatus, WithdrawalUnavailable,
5
+ } from '@owlmeans/payment'
6
+ import {
7
+ consumerDeclarations, consumerEvents, consumerRights, paygateCustomers, purchases, subscriptions,
8
+ } from '../src/utils.js'
9
+ import type { PaymentPlanDef, PurchaseRecord } from '../src/types.js'
10
+ import {
11
+ buyTopUp, ENTITY, fixedMeter, invoiceOf, makeRightsContext, PLANS_PRODUCT, requestStart, rightsOf, send,
12
+ TEXT_VERSION,
13
+ } from './consumer-fixtures.js'
14
+ import type { RecordingMeter } from './consumer-fixtures.js'
15
+ import { future, past, subscriptionOf } from './fake-stripe.js'
16
+ import type { FakeContext, FakeContextOptions } from './fake-stripe.js'
17
+
18
+ const SPLIT = 'pro-split'
19
+ /** A plan whose price is services (by time) and included credits (by units), equal halves. */
20
+ const SPLIT_PLAN: PaymentPlanDef = {
21
+ productSku: PLANS_PRODUCT, sku: SPLIT, duration: PlanDuration.Monthly, rank: 15, price: 20, title: 'Pro split',
22
+ recurring: { interval: 'month' },
23
+ limits: { seats: { kind: LimitKind.Occupancy, limit: 3 } },
24
+ withdrawal: { components: [{ key: 'services', basis: 'time', shareMinor: 1_000 }, { key: 'credits', basis: 'units', shareMinor: 1_000 }] },
25
+ }
26
+
27
+ const withMeter = async (meter: RecordingMeter, opts: FakeContextOptions = {}) =>
28
+ await makeRightsContext({ meter, catalogue: { plans: [SPLIT_PLAN] }, ...opts })
29
+
30
+ const service = (fake: FakeContext) => consumerRights(fake.ctx)
31
+ const subject = { entityId: ENTITY, email: 'owner@shop.eu', name: 'Anna Nowak', profileId: 'profile-1' }
32
+ const events = async (fake: FakeContext, recordId: string) => (await consumerEvents(fake.ctx).list({ recordId })).items
33
+ const consent = async (fake: FakeContext, purchase: PurchaseRecord, language = 'de') =>
34
+ await service(fake).recordConsent(subject, {
35
+ purchaseIds: [purchase.purchaseId], textVersion: TEXT_VERSION, language, acknowledged: true, uiLanguage: 'en',
36
+ }, { ip: '198.51.100.1', userAgent: 'test-agent', ipCountry: 'PL' })
37
+
38
+ describe('consumer rights — performance consent', () => {
39
+ test('an open in-scope window needs consent; the view names the purchase, deadline and billing language', async () => {
40
+ const fake = await makeRightsContext()
41
+ const purchase = await buyTopUp(fake)
42
+ const view = await service(fake).consentView(ENTITY)
43
+ expect(view).toEqual(expect.objectContaining({
44
+ required: true, region: 'eu', country: 'PL', language: 'pl', trader: 'Example', textVersion: TEXT_VERSION,
45
+ }))
46
+ expect(view.purchases.map(item => item.purchaseId)).toEqual([purchase.purchaseId])
47
+ expect(view.deadline).toEqual(purchase.deadline)
48
+ expect(view.links.billingTerms).toBe('https://legal.example.com/pl/billing')
49
+
50
+ const refusal = await service(fake).assertConsent(ENTITY).catch(error => error)
51
+ expect(refusal).toBeInstanceOf(PerformanceConsentRequired)
52
+ expect((refusal.constructor as { httpStatus: number }).httpStatus).toBe(428)
53
+ expect(refusal).toEqual(expect.objectContaining({ pending: 1, deadline: purchase.deadline }))
54
+ })
55
+
56
+ test('a stale text version is asked again; a recorded consent stamps the window and is mailed verbatim', async () => {
57
+ const fake = await makeRightsContext()
58
+ const purchase = await buyTopUp(fake)
59
+ await expect(service(fake).recordConsent(subject, {
60
+ purchaseIds: [purchase.purchaseId], textVersion: 'terms-v0', language: 'de', acknowledged: true,
61
+ })).rejects.toBeInstanceOf(PerformanceConsentRequired)
62
+
63
+ const response = await consent(fake, purchase)
64
+ expect(response).toEqual(expect.objectContaining({ purchaseIds: [purchase.purchaseId], mailed: true }))
65
+ await service(fake).assertConsent(ENTITY)
66
+ expect((await purchases(fake.ctx).byPurchaseId(purchase.purchaseId))).toEqual(expect.objectContaining({
67
+ consentId: response.consentId, consentedAt: response.consentedAt,
68
+ }))
69
+ const record = fake.stores['payment-consumer-consent'].rows.find(row => row.id === response.consentId)
70
+ expect(record).toEqual(expect.objectContaining({
71
+ kind: 'performance', language: 'de', uiLanguage: 'en', trader: 'Example', ip: '198.51.100.1',
72
+ userAgent: 'test-agent', ipCountry: 'PL', email: 'owner@shop.eu',
73
+ }))
74
+ expect(record?.text.checkbox).toContain('Ich verlange ausdrücklich')
75
+ const mail = fake.mails.find(item => item.to === 'owner@shop.eu')
76
+ expect(mail?.text).toContain(record?.text.checkbox)
77
+ expect(mail?.text).toContain(purchase.contractRef)
78
+ expect(mail?.text).toContain('bis einschließlich')
79
+ expect(fake.observed.consent).toEqual([expect.objectContaining({
80
+ kind: 'performance', consentId: response.consentId, eventKey: `consent:${response.consentId}`,
81
+ })])
82
+ })
83
+
84
+ test('a consent covers only what it was shown; a purchase outside the territories never needs one', async () => {
85
+ const fake = await makeRightsContext()
86
+ const first = await buyTopUp(fake)
87
+ await buyTopUp(fake, { id: 'cs_2', invoice: 'in_2' })
88
+ await consent(fake, first)
89
+ await expect(service(fake).assertConsent(ENTITY)).rejects.toBeInstanceOf(PerformanceConsentRequired)
90
+
91
+ const other = await makeRightsContext()
92
+ await buyTopUp(other, {
93
+ id: 'cs_us', invoice: 'in_us', currency: 'usd', amount_subtotal: 1_021, amount_total: 1_021,
94
+ customer_details: { address: { country: 'US' } }, metadata: { country: 'US', currency: 'usd', chargeAmountMinor: '1021' },
95
+ })
96
+ await service(other).assertConsent(ENTITY)
97
+ expect((await service(other).consentView(ENTITY)).required).toBe(false)
98
+ })
99
+ })
100
+
101
+ describe('consumer rights — withdrawal of a top-up', () => {
102
+ test('before consent nothing is deductible: the whole payment comes back, through a credit note', async () => {
103
+ const meter = fixedMeter({ granted: 1_000, used: 400, usedAfter: 0 })
104
+ const fake = await withMeter(meter)
105
+ const purchase = await buyTopUp(fake)
106
+ const candidates = await service(fake).withdrawalCandidates(ENTITY, subject)
107
+ expect(candidates.candidates).toEqual([expect.objectContaining({
108
+ purchaseId: purchase.purchaseId, automatic: true, estimate: expect.objectContaining({ refundMinor: 1_130 }),
109
+ })])
110
+
111
+ const receipt = await service(fake).withdraw(subject, { purchaseId: purchase.purchaseId, name: 'Anna Nowak', email: 'owner@shop.eu' })
112
+ expect(receipt).toEqual(expect.objectContaining({ status: WithdrawalStatus.Refunded, refundMinor: 1_130, currency: 'eur' }))
113
+ expect(receipt.content).toEqual({ name: 'Anna Nowak', contract: purchase.contractRef, email: 'owner@shop.eu' })
114
+ expect(meter.queries[0]).toEqual(expect.objectContaining({ entityId: ENTITY }))
115
+ expect(meter.queries[0].after).toBeUndefined()
116
+
117
+ const id = receipt.declarationId
118
+ expect(fake.state.creditNotePreviews[0]).toEqual({
119
+ invoice: 'in_pl', lines: [{ type: 'invoice_line_item', invoice_line_item: 'il_pl', amount: 919 }],
120
+ })
121
+ expect(fake.state.refunds[0]).toEqual(expect.objectContaining({
122
+ amount: 1_130, payment_intent: 'pi_pl', reason: 'requested_by_customer',
123
+ metadata: { owlmeans: 'payment', withdrawalId: id, purchaseId: purchase.purchaseId },
124
+ }))
125
+ expect(fake.state.requestOptions['refunds.create']).toEqual([{ idempotencyKey: `withdrawal:${id}:refund` }])
126
+ expect(fake.state.creditNotes[0]).toEqual(expect.objectContaining({ invoice: 'in_pl', refund: fake.state.refunds[0].id }))
127
+ expect(fake.state.requestOptions['creditNotes.create']).toEqual([{ idempotencyKey: `withdrawal:${id}:credit-note` }])
128
+ expect((await purchases(fake.ctx).byPurchaseId(purchase.purchaseId))).toEqual(expect.objectContaining({ withdrawalId: id }))
129
+ expect(fake.observed.withdrawal).toEqual([expect.objectContaining({
130
+ withdrawalId: id, eventKey: `withdrawal:${id}`, status: WithdrawalStatus.Refunded, channel: 'in-app',
131
+ units: { granted: 1_000, used: 0, returned: 600 },
132
+ refund: expect.objectContaining({ amountMinor: 1_130, refundId: fake.state.refunds[0].id, creditNoteId: fake.state.creditNotes[0].id }),
133
+ })])
134
+ expect((await events(fake, id)).map(event => [event.action, event.ok])).toEqual(expect.arrayContaining([
135
+ ['computed', true], ['mail', true], ['refund', true], ['credit-note', true], ['observers', true],
136
+ ]))
137
+ // The window closed: no consent is asked for a withdrawn purchase.
138
+ await service(fake).assertConsent(ENTITY)
139
+ })
140
+
141
+ test('after consent the deduction is usage after consent, settled debt and earlier claw-backs', async () => {
142
+ const meter = fixedMeter({ granted: 1_000, used: 500, usedAfter: 250, settled: 50, clawed: 100 })
143
+ const fake = await withMeter(meter)
144
+ const purchase = await buyTopUp(fake)
145
+ await consent(fake, purchase)
146
+ fake.stores['payment-purchase'].rows.find(row => row.purchaseId === purchase.purchaseId)!.refundedMinor = 113
147
+ const receipt = await service(fake).withdraw(subject, { purchaseId: purchase.purchaseId, name: 'Anna', email: 'owner@shop.eu' })
148
+ // net: min(919 − 91, ceil(919 × 600 / 1000)) = 552; the credit note adds its tax → 679.
149
+ expect(fake.state.creditNotePreviews[0].lines[0].amount).toBe(552)
150
+ expect(fake.state.refunds[0].amount).toBe(679)
151
+ expect(receipt.refundMinor).toBe(679)
152
+ expect(meter.queries[0].after).toBeInstanceOf(Date)
153
+ expect(fake.observed.withdrawal[0].units).toEqual({ granted: 1_000, used: 400, returned: 350 })
154
+ })
155
+
156
+ test('no invoice: a plain proportional refund; a failed preview falls back to it too', async () => {
157
+ const meter = fixedMeter({ granted: 1_000, used: 0, usedAfter: 0 })
158
+ const fake = await withMeter(meter)
159
+ const plain = await buyTopUp(fake, { id: 'cs_old', invoice: null })
160
+ await service(fake).withdraw(subject, { purchaseId: plain.purchaseId, name: 'Anna', email: 'owner@shop.eu' })
161
+ expect(fake.state.calls).not.toContain('creditNotes.preview')
162
+ expect(fake.state.refunds[0].amount).toBe(1_130)
163
+
164
+ const again = await withMeter(fixedMeter({ granted: 1_000, used: 0, usedAfter: 0 }))
165
+ const purchase = await buyTopUp(again)
166
+ again.state.failures['creditNotes.preview'] = 'invoice_not_finalized'
167
+ const receipt = await service(again).withdraw(subject, { purchaseId: purchase.purchaseId, name: 'Anna', email: 'owner@shop.eu' })
168
+ expect(receipt.status).toBe(WithdrawalStatus.Refunded)
169
+ expect(again.state.refunds[0].amount).toBe(1_130)
170
+ expect(again.state.calls).not.toContain('creditNotes.create')
171
+ expect((await events(again, receipt.declarationId)).find(event => event.action === 'credit-note')).toEqual(
172
+ expect.objectContaining({ ok: false, step: 'preview' }))
173
+ })
174
+
175
+ test('a credit note refused after the refund keeps the refund; reconcile issues it, linked to that refund', async () => {
176
+ const fake = await withMeter(fixedMeter({ granted: 1_000, used: 0, usedAfter: 0 }))
177
+ const purchase = await buyTopUp(fake)
178
+ fake.state.failures['creditNotes.create'] = 'rate_limit'
179
+ const receipt = await service(fake).withdraw(subject, { purchaseId: purchase.purchaseId, name: 'Anna', email: 'owner@shop.eu' })
180
+ expect(receipt.status).toBe(WithdrawalStatus.Refunded)
181
+ expect(fake.state.creditNotes).toHaveLength(0)
182
+
183
+ const result = await service(fake).reconcile()
184
+ expect(result.retried).toBe(1)
185
+ expect(fake.state.creditNotes).toEqual([expect.objectContaining({ refund: fake.state.refunds[0].id })])
186
+ expect(fake.state.requestOptions['creditNotes.create'].at(-1)).toEqual({
187
+ idempotencyKey: `withdrawal:${receipt.declarationId}:credit-note:1`,
188
+ })
189
+ expect(fake.state.refunds).toHaveLength(1)
190
+ })
191
+
192
+ test('a failed refund is retried by reconcile under a fresh key, adopting nothing twice; observers then run', async () => {
193
+ const fake = await withMeter(fixedMeter({ granted: 1_000, used: 0, usedAfter: 0 }))
194
+ const purchase = await buyTopUp(fake)
195
+ fake.state.failures['refunds.create'] = 'card_declined'
196
+ const receipt = await service(fake).withdraw(subject, { purchaseId: purchase.purchaseId, name: 'Anna', email: 'owner@shop.eu' })
197
+ expect(receipt.status).toBe(WithdrawalStatus.Failed)
198
+ expect(fake.observed.withdrawal).toHaveLength(0)
199
+ // The window is closed although the paygate failed: the declaration was made.
200
+ expect((await purchases(fake.ctx).byPurchaseId(purchase.purchaseId))?.withdrawnAt).toBeInstanceOf(Date)
201
+
202
+ await service(fake).reconcile()
203
+ expect(fake.state.refunds).toHaveLength(1)
204
+ expect(fake.state.requestOptions['refunds.create'].at(-1)).toEqual({ idempotencyKey: `withdrawal:${receipt.declarationId}:refund:1` })
205
+ expect(fake.observed.withdrawal).toEqual([expect.objectContaining({ status: WithdrawalStatus.Refunded })])
206
+ await service(fake).reconcile()
207
+ expect(fake.state.refunds).toHaveLength(1)
208
+ expect(fake.observed.withdrawal).toHaveLength(1)
209
+ })
210
+
211
+ test('credits fully used after consent: the right has expired, nothing is offered or executed', async () => {
212
+ const fake = await withMeter(fixedMeter({ granted: 1_000, used: 1_000, usedAfter: 1_000 }))
213
+ const purchase = await buyTopUp(fake)
214
+ await consent(fake, purchase)
215
+ expect((await service(fake).withdrawalCandidates(ENTITY)).candidates).toHaveLength(0)
216
+ const refusal = await service(fake).withdraw(subject, { purchaseId: purchase.purchaseId, name: 'Anna', email: 'owner@shop.eu' })
217
+ .catch(error => error)
218
+ expect(refusal).toBeInstanceOf(WithdrawalUnavailable)
219
+ expect(refusal.reason).toBe('performed')
220
+ expect(fake.stores['payment-consumer-declaration'].rows).toHaveLength(0)
221
+ })
222
+
223
+ test('without a meter a withdrawal is recorded and left to an operator, promising the reimbursement', async () => {
224
+ const fake = await makeRightsContext()
225
+ const purchase = await buyTopUp(fake)
226
+ const receipt = await service(fake).withdraw(subject, { purchaseId: purchase.purchaseId, name: 'Anna', email: 'owner@shop.eu', language: 'en' })
227
+ expect(receipt.status).toBe(WithdrawalStatus.Review)
228
+ expect(fake.state.calls).not.toContain('refunds.create')
229
+ expect(fake.observed.withdrawal).toEqual([expect.objectContaining({ status: WithdrawalStatus.Review, units: null })])
230
+ const mail = fake.mails.find(item => item.subject.includes('withdrawal') && item.to === 'owner@shop.eu')
231
+ expect(mail?.text).toContain('not later than 14 days')
232
+ })
233
+
234
+ test('a repeated declaration answers the original receipt and executes nothing again', async () => {
235
+ const fake = await withMeter(fixedMeter({ granted: 1_000, used: 0, usedAfter: 0 }))
236
+ const purchase = await buyTopUp(fake)
237
+ const first = await service(fake).withdraw(subject, { purchaseId: purchase.purchaseId, name: 'Anna', email: 'owner@shop.eu' })
238
+ const second = await service(fake).withdraw(subject, { purchaseId: purchase.purchaseId, name: 'Anna', email: 'owner@shop.eu' })
239
+ expect(second.declarationId).toBe(first.declarationId)
240
+ expect(second.status).toBe(WithdrawalStatus.Refunded)
241
+ expect(fake.state.refunds).toHaveLength(1)
242
+ const rows = fake.stores['payment-consumer-declaration'].rows
243
+ expect(rows).toHaveLength(2)
244
+ expect(rows[1].duplicateOf).toBe(first.declarationId)
245
+ })
246
+
247
+ test('a late declaration is recorded as expired, its receipt names the last day, nothing is executed', async () => {
248
+ const fake = await withMeter(fixedMeter({ granted: 1_000, used: 0, usedAfter: 0 }))
249
+ const purchase = await buyTopUp(fake)
250
+ fake.stores['payment-purchase'].rows.find(row => row.purchaseId === purchase.purchaseId)!.deadline = new Date('2026-01-13T00:00:00.000Z')
251
+ const receipt = await service(fake).withdraw(subject, { purchaseId: purchase.purchaseId, name: 'Anna', email: 'owner@shop.eu', language: 'en' })
252
+ expect(receipt.status).toBe(WithdrawalStatus.Expired)
253
+ expect(fake.state.calls).not.toContain('refunds.create')
254
+ expect(fake.mails.find(item => item.to === 'owner@shop.eu')?.text).toContain('January 12, 2026')
255
+ expect((await purchases(fake.ctx).byPurchaseId(purchase.purchaseId))?.withdrawnAt).toBeUndefined()
256
+ })
257
+
258
+ test('in-app refusals: an unknown contract, a purchase outside the territories', async () => {
259
+ const fake = await withMeter(fixedMeter({ granted: 1_000 }))
260
+ await expect(service(fake).withdraw(subject, { purchaseId: 'stripe:cs_none', name: 'A', email: 'a@shop.eu' }))
261
+ .rejects.toBeInstanceOf(WithdrawalUnavailable)
262
+ const us = await buyTopUp(fake, {
263
+ id: 'cs_us', invoice: 'in_us', customer_details: { address: { country: 'US' } }, metadata: { country: 'US' },
264
+ })
265
+ const refusal = await service(fake).withdraw(subject, { purchaseId: us.purchaseId, name: 'A', email: 'a@shop.eu' }).catch(error => error)
266
+ expect(refusal.reason).toBe('not-in-scope')
267
+ })
268
+ })
269
+
270
+ describe('consumer rights — withdrawal of a subscription', () => {
271
+ test('services by time from the start request, credits by units; the subscription ends at once', async () => {
272
+ const meter = fixedMeter({ granted: 1_000, used: 300, usedAfter: 100 })
273
+ const fake = await withMeter(meter)
274
+ fake.state.invoices.in_sub = invoiceOf('in_sub', {
275
+ subtotal: 1_800, tax: 342, total: 2_142, payment_intent: 'pi_sub', lines: { object: 'list', data: [{ id: 'il_sub' }] },
276
+ customer_address: { country: 'DE' },
277
+ })
278
+ const startRequestId = await requestStart(fake, { planSku: SPLIT })
279
+ const subscription = subscriptionOf({
280
+ id: 'sub_split', planSku: SPLIT, latestInvoice: 'in_sub', periodStart: past(3), periodEnd: future(27),
281
+ }) as unknown as Record<string, any>
282
+ subscription.currency = 'eur'
283
+ subscription.items.data[0].price.unit_amount = 1_800
284
+ subscription.metadata = { entityId: ENTITY, planSku: SPLIT, startRequestId, country: 'DE' }
285
+ fake.state.subscriptions.sub_split = subscription as never
286
+ await send(fake, 'customer.subscription.created', subscription)
287
+ const row = fake.stores['payment-purchase'].rows.find(item => item.purchaseId === 'stripe:sub_split')!
288
+ row.servicesStartedAt = past(3)
289
+ row.consentedAt = past(3)
290
+
291
+ const receipt = await service(fake).withdraw(subject, { purchaseId: 'stripe:sub_split', name: 'Anna', email: 'owner@shop.eu' })
292
+ // net 1800 = 900 services + 900 credits; −90 (3 of 30 days) −90 (100 of 1000 units) → 1620 net → 1928 gross.
293
+ expect(fake.state.creditNotePreviews[0].lines[0].amount).toBe(1_620)
294
+ expect(receipt).toEqual(expect.objectContaining({ status: WithdrawalStatus.Refunded, refundMinor: 1_928, subscriptionCanceled: true }))
295
+ expect(fake.state.subscriptionChanges).toEqual([expect.objectContaining({
296
+ id: 'sub_split', method: 'cancel', prorate: false, invoice_now: false,
297
+ cancellation_details: { comment: `withdrawal:${receipt.declarationId}` },
298
+ })])
299
+ expect(fake.state.requestOptions['subscriptions.cancel']).toEqual([{ idempotencyKey: `withdrawal:${receipt.declarationId}:subscription-cancel` }])
300
+ expect((await subscriptions(fake.ctx).byExternalId('sub_split', 'stripe'))?.withdrawnAt).toBeInstanceOf(Date)
301
+ const estimate = (await events(fake, receipt.declarationId)).find(event => event.action === 'computed')
302
+ expect(JSON.parse(estimate?.detail ?? '{}').estimate).toEqual(expect.objectContaining({
303
+ timeDeductionMinor: 90, unitsDeductionMinor: 90, elapsedDays: 3, periodDays: 30,
304
+ }))
305
+ })
306
+ })
307
+
308
+ describe('consumer rights — the public withdrawal function', () => {
309
+ test('matched by contract reference and e-mail: executed, answered without status, amount or match', async () => {
310
+ const fake = await withMeter(fixedMeter({ granted: 1_000, used: 0, usedAfter: 0 }))
311
+ const purchase = await buyTopUp(fake)
312
+ const receipt = await service(fake).withdraw(null, {
313
+ contractRef: purchase.contractRef.toLowerCase(), name: 'Jan', email: 'BUYER@shop.eu',
314
+ })
315
+ expect(Object.keys(receipt).sort()).toEqual(['content', 'declarationId', 'mailed', 'receivedAt'])
316
+ expect(fake.state.refunds).toHaveLength(1)
317
+ expect(fake.stores['payment-consumer-declaration'].rows[0]).toEqual(expect.objectContaining({ channel: 'public', matched: true }))
318
+ })
319
+
320
+ test('a wrong e-mail matches nothing: the same answer, recorded, a receipt that says what happens on a match', async () => {
321
+ const fake = await withMeter(fixedMeter({ granted: 1_000, used: 0, usedAfter: 0 }))
322
+ const purchase = await buyTopUp(fake)
323
+ const receipt = await service(fake).withdraw(null, { contractRef: purchase.contractRef, name: 'Eve', email: 'eve@else.eu', language: 'en' })
324
+ expect(Object.keys(receipt).sort()).toEqual(['content', 'declarationId', 'mailed', 'receivedAt'])
325
+ expect(receipt.content).toEqual({ name: 'Eve', contract: purchase.contractRef, email: 'eve@else.eu' })
326
+ expect(fake.state.refunds).toHaveLength(0)
327
+ expect(fake.stores['payment-consumer-declaration'].rows[0]).toEqual(expect.objectContaining({ matched: false, status: 'received' }))
328
+ expect(fake.mails.find(item => item.to === 'eve@else.eu')?.text).toContain('If these details match one of our contracts')
329
+ })
330
+
331
+ test('an invoice number and the paygate customer e-mail match too; a reserved domain is never mailed', async () => {
332
+ const fake = await withMeter(fixedMeter({ granted: 1_000, used: 0, usedAfter: 0 }))
333
+ await paygateCustomers(fake.ctx).create({ paygate: 'stripe', externalId: 'cus_1', entityId: ENTITY, email: 'billing@buyer.test' })
334
+ await buyTopUp(fake)
335
+ const receipt = await service(fake).withdraw(null, { contractRef: 'INV-0001', name: 'Jan', email: 'billing@buyer.test' })
336
+ expect(receipt.mailed).toBe(false)
337
+ expect(fake.state.refunds).toHaveLength(1)
338
+ const skipped = (await consumerEvents(fake.ctx).list({ action: 'mail', recordId: receipt.declarationId })).items[0]
339
+ expect(skipped).toEqual(expect.objectContaining({ ok: true, skipped: true }))
340
+ expect(fake.mails.some(item => item.to.endsWith('.test'))).toBe(false)
341
+ })
342
+ })
343
+
344
+ describe('consumer rights — cancellation', () => {
345
+ const active = async (fake: FakeContext, overrides: Parameters<typeof subscriptionOf>[0] = {}) => {
346
+ const subscription = subscriptionOf({ id: 'sub_c', periodEnd: new Date('2026-10-31T10:00:00.000Z'), ...overrides })
347
+ fake.state.subscriptions.sub_c = subscription as never
348
+ await send(fake, 'customer.subscription.updated', subscription)
349
+ }
350
+ const body = { kind: CancellationKind.Ordinary, name: 'Anna', email: 'owner@shop.eu', effective: 'earliest' as const }
351
+
352
+ test('ordinary, at the earliest date: scheduled at the period end', async () => {
353
+ const fake = await makeRightsContext()
354
+ await active(fake)
355
+ const receipt = await service(fake).cancel(subject, body)
356
+ expect(receipt).toEqual(expect.objectContaining({ status: CancellationStatus.Scheduled, effectiveAt: new Date('2026-10-31T10:00:00.000Z') }))
357
+ expect(fake.state.subscriptionChanges).toEqual([expect.objectContaining({
358
+ id: 'sub_c', method: 'update', cancel_at_period_end: true, cancellation_details: { comment: `cancellation:${receipt.declarationId}` },
359
+ })])
360
+ expect(fake.observed.cancellation).toEqual([expect.objectContaining({ matched: true, status: 'scheduled', subscriptionId: 'sub_c' })])
361
+ expect(fake.mails.find(item => item.to === 'owner@shop.eu')?.subject).toContain('cancellation')
362
+ })
363
+
364
+ test('a later requested date ends at the first period boundary on or after it, without proration', async () => {
365
+ const fake = await makeRightsContext()
366
+ await active(fake)
367
+ const receipt = await service(fake).cancel(subject, { ...body, effective: 'date', date: '2026-12-10' })
368
+ expect(receipt.effectiveAt).toEqual(new Date('2026-12-31T10:00:00.000Z'))
369
+ expect(fake.state.subscriptionChanges[0]).toEqual(expect.objectContaining({
370
+ cancel_at: Math.floor(new Date('2026-12-31T10:00:00.000Z').getTime() / 1000), proration_behavior: 'none',
371
+ }))
372
+ })
373
+
374
+ test('already scheduled: nothing to change; extraordinary: recorded for an operator, the paygate untouched', async () => {
375
+ const fake = await makeRightsContext()
376
+ await active(fake, { cancelAtPeriodEnd: true })
377
+ expect((await service(fake).cancel(subject, body)).status).toBe(CancellationStatus.AlreadyScheduled)
378
+ const extraordinary = await service(fake).cancel(subject, { ...body, kind: CancellationKind.Extraordinary, reason: 'Service unusable', language: 'en' })
379
+ expect(extraordinary.status).toBe(CancellationStatus.Review)
380
+ expect(fake.state.subscriptionChanges).toHaveLength(0)
381
+ const mail = fake.mails.filter(item => item.to === 'owner@shop.eu').at(-1)
382
+ expect(mail?.text).toContain('We examine the reason you gave')
383
+ })
384
+
385
+ test('public by e-mail alone: the one organization with that paygate e-mail; the answer never says so', async () => {
386
+ const fake = await makeRightsContext()
387
+ await paygateCustomers(fake.ctx).create({ paygate: 'stripe', externalId: 'cus_1', entityId: ENTITY, email: 'Owner@Shop.eu' })
388
+ await active(fake)
389
+ const receipt = await service(fake).cancel(null, { ...body, email: 'owner@shop.eu' })
390
+ expect(Object.keys(receipt).sort()).toEqual(['content', 'declarationId', 'mailed', 'receivedAt'])
391
+ expect(fake.state.subscriptionChanges).toHaveLength(1)
392
+
393
+ const unmatched = await service(fake).cancel(null, { ...body, email: 'nobody@else.eu', language: 'en' })
394
+ expect(Object.keys(unmatched).sort()).toEqual(['content', 'declarationId', 'mailed', 'receivedAt'])
395
+ expect(fake.state.subscriptionChanges).toHaveLength(1)
396
+ expect(fake.mails.find(item => item.to === 'nobody@else.eu')?.text).toContain('If these details match one of our contracts')
397
+ })
398
+
399
+ test('in-app without a subscription: CancellationUnavailable', async () => {
400
+ const fake = await makeRightsContext()
401
+ const refusal = await service(fake).cancel(subject, body).catch(error => error)
402
+ expect(refusal).toBeInstanceOf(CancellationUnavailable)
403
+ expect(refusal.reason).toBe('no-subscription')
404
+ })
405
+ })
406
+
407
+ describe('consumer rights — records', () => {
408
+ test('a manual relock is audited; a mismatch is never a relock', async () => {
409
+ const fake = await makeRightsContext()
410
+ await buyTopUp(fake)
411
+ const relocked = await service(fake).lock(ENTITY, 'DE', 'manual', { force: true, by: 'operator', reason: 'moved' })
412
+ expect(relocked).toEqual(expect.objectContaining({ country: 'DE', locked: true, language: 'de', currency: 'eur' }))
413
+ const relock = (await consumerEvents(fake.ctx).list({ action: 'relock' })).items[0]
414
+ expect(JSON.parse(relock.detail ?? '{}')).toEqual({ from: 'PL', to: 'DE', currency: 'eur', by: 'operator', reason: 'moved' })
415
+ expect((await service(fake).lock(ENTITY, 'FR', 'checkout')).country).toBe('DE')
416
+ })
417
+
418
+ test('an operator unlock deletes the profile, keeps it on an unlock event, and the next purchase locks again', async () => {
419
+ const fake = await makeRightsContext()
420
+ await buyTopUp(fake)
421
+ const unlocked = await service(fake).unlock(ENTITY, { by: 'operator', reason: 'moved to Germany' })
422
+ expect(unlocked).toEqual(expect.objectContaining({ country: 'PL', locked: true }))
423
+ expect(await service(fake).profile(ENTITY)).toBeNull()
424
+ const [event] = (await consumerEvents(fake.ctx).list({ action: 'unlock' })).items
425
+ expect(event).toEqual(expect.objectContaining({ recordId: ENTITY, recordKind: 'profile', entityId: ENTITY, ok: true }))
426
+ expect(JSON.parse(event.detail ?? '{}')).toEqual(expect.objectContaining({
427
+ from: 'PL', by: 'operator', reason: 'moved to Germany',
428
+ profile: expect.objectContaining({ entityId: ENTITY, country: 'PL', source: 'checkout', currency: 'eur' }),
429
+ }))
430
+ expect(await service(fake).unlock(ENTITY)).toBeNull()
431
+ expect(await consumerEvents(fake.ctx).count({ action: 'unlock' })).toBe(1)
432
+
433
+ await buyTopUp(fake, { id: 'cs_de', invoice: 'in_de', customer_details: { address: { country: 'DE' }, email: 'buyer@shop.eu' } })
434
+ expect(await service(fake).profile(ENTITY)).toEqual(expect.objectContaining({ country: 'DE', locked: true }))
435
+ })
436
+
437
+ test('an unlocked organization is not locked again from its paygate customer — at checkout or in reconcile', async () => {
438
+ const fake = await makeRightsContext()
439
+ await buyTopUp(fake)
440
+ await paygateCustomers(fake.ctx).create({ paygate: 'stripe', externalId: 'cus_saved', entityId: ENTITY, country: 'PL' })
441
+ fake.state.customers.cus_saved = { id: 'cus_saved', object: 'customer', address: { country: 'PL' } }
442
+ await service(fake).unlock(ENTITY, { by: 'operator', reason: 'wrong country' })
443
+
444
+ const { createCheckoutLink } = await import('../src/plugins/stripe.js')
445
+ await createCheckoutLink(fake.ctx, fake.stripe, {
446
+ productSku: 'app-credits', entityId: ENTITY, service: 'app', amountMinor: 1_000, successUrl: 'https://app.example.com/ok', country: 'DE',
447
+ })
448
+ expect(await service(fake).profile(ENTITY)).toBeNull()
449
+ expect(fake.state.checkoutSessions.at(-1)?.metadata).toEqual(expect.objectContaining({ country: 'DE' }))
450
+ expect((await service(fake).reconcile()).locked).toBe(0)
451
+ expect(await service(fake).profile(ENTITY)).toBeNull()
452
+ })
453
+
454
+ test('purchases list their windows; an unmanaged service reads and asserts but never withdraws', async () => {
455
+ const fake = await makeRightsContext({ consumerRights: rightsOf() })
456
+ const purchase = await buyTopUp(fake)
457
+ expect(await service(fake).purchases(ENTITY, { open: true })).toEqual([expect.objectContaining({
458
+ purchaseId: purchase.purchaseId, withdrawable: true, contractRef: purchase.contractRef,
459
+ })])
460
+ const { makeConsumerRightsService } = await import('../src/consumer/service.js')
461
+ const readOnly = makeConsumerRightsService('read-only-rights', { manage: false })
462
+ fake.ctx.registerService(readOnly)
463
+ await expect(readOnly.assertConsent(ENTITY)).rejects.toBeInstanceOf(PerformanceConsentRequired)
464
+ await expect(readOnly.withdraw(subject, { purchaseId: purchase.purchaseId, name: 'A', email: 'a@shop.eu' }))
465
+ .rejects.toThrow('unmanaged')
466
+ expect(await consumerDeclarations(fake.ctx).count({})).toBe(0)
467
+ })
468
+ })
469
+
470
+ describe('consumer rights — registration order', () => {
471
+ const meter = fixedMeter({ granted: 1_000 })
472
+
473
+ for (const gatewayFirst of [false, true]) {
474
+ const order = gatewayFirst ? 'after the gateway' : 'before the gateway'
475
+
476
+ test(`appendConsumerRights ${order}: the meter and the paygate client are installed, its manage wins`, async () => {
477
+ const fake = await makeRightsContext({ gatewayFirst, meter })
478
+ expect(service(fake).usageMeter()).toBe(meter)
479
+ expect(service(fake).managed).toBe(true)
480
+ const purchase = await buyTopUp(fake)
481
+ const receipt = await service(fake).withdraw(subject, { purchaseId: purchase.purchaseId, name: 'Anna', email: 'owner@shop.eu' })
482
+ expect(receipt.status).toBe(WithdrawalStatus.Refunded)
483
+ expect(fake.state.refunds).toHaveLength(1)
484
+ })
485
+
486
+ test(`appendConsumerRights ${order} without manage: the gateway's manage applies`, async () => {
487
+ const fake = await makeRightsContext({ gatewayFirst, rights: () => ({ usage: meter }) })
488
+ expect(service(fake).managed).toBe(false)
489
+ expect(service(fake).usageMeter()).toBe(meter)
490
+ })
491
+ }
492
+
493
+ test('the service is reachable while the application is wired, before the context initializes', async () => {
494
+ const renderer = () => undefined
495
+ const fake = await makeRightsContext({
496
+ gatewayFirst: true,
497
+ wire: ctx => { consumerRights(ctx).useMailRenderer(renderer) },
498
+ })
499
+ expect(service(fake).mailRenderer()).toBe(renderer)
500
+ })
501
+ })
@@ -0,0 +1,109 @@
1
+ import { mongoGate, randomNamespace } from '@owlmeans/test-integration'
2
+ import type { IntegrationGate, MongoEnv } from '@owlmeans/test-integration'
3
+ import { AppType } from '@owlmeans/context'
4
+ import { appendMongo } from '@owlmeans/mongo'
5
+ import type { MongoDbService } from '@owlmeans/mongo-resource'
6
+ import { LimitKind, LimitWindow, PlanDuration } from '@owlmeans/payment'
7
+ import { config, makeServerContext } from '@owlmeans/server-context'
8
+ import type { ServerConfig } from '@owlmeans/server-context'
9
+ import type { Context as ApiContext } from '@owlmeans/server-api'
10
+ import type Stripe from 'stripe'
11
+ import { declareConsumerRights, declarePaymentPricing } from '../src/config.js'
12
+ import { appendConsumerRights } from '../src/consumer/service.js'
13
+ import { appendPaymentGatewayService } from '../src/service.js'
14
+ import type { Config, ConsumerRightsDef, PricingDef, UsageMeter } from '../src/types.js'
15
+ import { declareTestCatalogue, HOST, PLANS_PRODUCT, SERVICE } from './fake-stripe.js'
16
+
17
+ /**
18
+ * Category-C harness: a real server context over a real Mongo database — the payment resources with
19
+ * their validators and indexes, the entitlement service and the gates — with the gateway unmanaged
20
+ * (no Stripe). One database per spec file, dropped by that file's own teardown.
21
+ */
22
+ export const gate: IntegrationGate<MongoEnv> = mongoGate()
23
+
24
+ export const BURST_PLAN = 'burst-plan'
25
+
26
+ export interface Booted {
27
+ ctx: ApiContext
28
+ mongo: MongoDbService
29
+ }
30
+
31
+ export interface MongoSuite {
32
+ database: string
33
+ boot: () => Promise<Booted>
34
+ teardown: () => Promise<void>
35
+ }
36
+
37
+ export interface SuiteOptions {
38
+ consumerRights?: ConsumerRightsDef
39
+ pricing?: PricingDef
40
+ /** The paygate the consumer-rights service manages (a fake); without it the service is unmanaged. */
41
+ stripe?: Stripe
42
+ meter?: UsageMeter
43
+ }
44
+
45
+ export const makeSuite = (label: string, opts: SuiteOptions = {}): MongoSuite => {
46
+ const prefix = process.env.MONGO_TEST_DB_PREFIX ?? 'omt'
47
+ const database = randomNamespace(`${prefix}_${label}`)
48
+ const booted: MongoDbService[] = []
49
+
50
+ const boot = async (): Promise<Booted> => {
51
+ const parsed = new URL(gate.env.MONGO_URL as string)
52
+ const cfg = config<ServerConfig>(SERVICE, {
53
+ dbs: [{
54
+ service: 'mongo', alias: 'mongo', schema: database,
55
+ host: decodeURIComponent(parsed.hostname).replace(/^\[|\]$/g, ''),
56
+ port: parsed.port !== '' ? parseInt(parsed.port, 10) : 27017,
57
+ ...(parsed.username !== ''
58
+ ? { user: decodeURIComponent(parsed.username), secret: decodeURIComponent(parsed.password) }
59
+ : {}),
60
+ }],
61
+ services: { [SERVICE]: { service: SERVICE, type: AppType.Backend, host: HOST } },
62
+ } as Partial<ServerConfig>)
63
+ declareTestCatalogue(cfg as unknown as Config, {
64
+ plans: [{
65
+ productSku: PLANS_PRODUCT, sku: BURST_PLAN, duration: PlanDuration.Monthly, rank: 30, price: 90,
66
+ recurring: { interval: 'month' },
67
+ limits: {
68
+ burst: { kind: LimitKind.Window, window: LimitWindow.Day, limit: 5 },
69
+ seats: { kind: LimitKind.Occupancy, limit: 1 },
70
+ },
71
+ }],
72
+ })
73
+
74
+ if (opts.pricing != null) declarePaymentPricing(cfg as unknown as Config, opts.pricing)
75
+ if (opts.consumerRights != null) declareConsumerRights(cfg as unknown as Config, opts.consumerRights)
76
+
77
+ const context = makeServerContext(cfg)
78
+ appendMongo(context)
79
+ if (opts.stripe != null) {
80
+ const stripe = opts.stripe
81
+ appendConsumerRights(context as never, { manage: true, stripe: async () => stripe, ...(opts.meter != null ? { usage: opts.meter } : {}) })
82
+ }
83
+ appendPaymentGatewayService(context as never, { manage: false })
84
+ context.configure()
85
+ const mongo = context.service<MongoDbService>('mongo')
86
+ booted.push(mongo)
87
+ await context.init()
88
+
89
+ return { ctx: context as unknown as ApiContext, mongo }
90
+ }
91
+
92
+ const teardown = async (): Promise<void> => {
93
+ if (gate.skip) {
94
+ return
95
+ }
96
+ const [first] = booted
97
+ if (first != null) {
98
+ await first.db().then(async db => { await db.dropDatabase() })
99
+ .catch(error => console.error(`mongo test teardown (${database}):`, error))
100
+ }
101
+ for (const mongo of booted) {
102
+ for (const client of Object.values(mongo.clients ?? {}) as Array<{ close: () => Promise<void> }>) {
103
+ await client.close().catch(() => undefined)
104
+ }
105
+ }
106
+ }
107
+
108
+ return { database, boot, teardown }
109
+ }