@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,101 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
2
+ import { LimitExhausted, SubscriptionStatus } from '@owlmeans/payment'
3
+ import { createEventHandler } from '../src/plugins/events.js'
4
+ import { ensureWebhookEndpoint } from '../src/plugins/webhook-manager.js'
5
+ import {
6
+ entitlements, fulfillments, gateway, paymentWebhooks, subscriptions, usageCounters, usageEvents,
7
+ } from '../src/utils.js'
8
+ import { BURST_PLAN, gate, makeSuite } from './context.js'
9
+ import type { Booted } from './context.js'
10
+ import { CREDITS_PRODUCT, eventOf, makeFakeStripe, PRO, subscriptionOf, TEAM } from './fake-stripe.js'
11
+
12
+ const suite = makeSuite('payusage')
13
+ const it = gate.skip ? test.skip : test
14
+
15
+ describe('@owlmeans/server-payment — usage ledger on Mongo', () => {
16
+ if (gate.skip) {
17
+ test.skip(gate.reason ?? 'mongo gate closed', () => {})
18
+ return
19
+ }
20
+
21
+ let booted: Booted
22
+ beforeAll(async () => { booted = await suite.boot() }, 60_000)
23
+ afterAll(async () => { await suite.teardown() }, 60_000)
24
+
25
+ it('admits exactly the limit out of 20 concurrent consumes', async () => {
26
+ const { ctx } = booted
27
+ await gateway(ctx).grantInternalPlan(ctx, 'burst-1', BURST_PLAN, { force: true })
28
+ const results = await Promise.allSettled(Array.from({ length: 20 }, async (_, index) =>
29
+ await entitlements(ctx).consume({ entityId: 'burst-1', limitKey: 'burst', eventKey: `burst:${index}` })))
30
+
31
+ expect(results.filter(result => result.status === 'fulfilled')).toHaveLength(5)
32
+ const refused = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
33
+ expect(refused).toHaveLength(15)
34
+ expect(refused.every(result => result.reason instanceof LimitExhausted)).toBe(true)
35
+ expect(await usageEvents(ctx).count({ entityId: 'burst-1', limitKey: 'burst' })).toBe(5)
36
+ expect((await usageCounters(ctx).load({ entityId: 'burst-1', limitKey: 'burst' }))?.used).toBe(5)
37
+ }, 60_000)
38
+
39
+ it('admits a concurrently repeated event key once', async () => {
40
+ const { ctx } = booted
41
+ await gateway(ctx).grantInternalPlan(ctx, 'dup-1', TEAM, { force: true })
42
+ const outcomes = await Promise.all(Array.from({ length: 10 }, async () =>
43
+ await entitlements(ctx).consume({ entityId: 'dup-1', limitKey: 'exports', eventKey: 'export:same', ref: 'same' })))
44
+
45
+ expect(outcomes.every(outcome => outcome.admitted)).toBe(true)
46
+ expect(outcomes.filter(outcome => !outcome.replayed)).toHaveLength(1)
47
+ expect(await usageEvents(ctx).count({ entityId: 'dup-1', limitKey: 'exports' })).toBe(1)
48
+ expect((await usageCounters(ctx).load({ entityId: 'dup-1', limitKey: 'exports' }))?.used).toBe(1)
49
+ expect((await entitlements(ctx).consumptionByRef('dup-1', 'exports', 'same'))?.eventKey).toBe('export:same')
50
+ }, 60_000)
51
+
52
+ it('recomputes counters to the ledger sum and reconciles occupancy', async () => {
53
+ const { ctx } = booted
54
+ await gateway(ctx).grantInternalPlan(ctx, 'rec-1', PRO, { force: true })
55
+ const service = entitlements(ctx)
56
+ for (const key of ['a', 'b', 'c']) {
57
+ await service.consume({ entityId: 'rec-1', limitKey: 'reports', eventKey: `report:${key}` })
58
+ }
59
+ await service.release({ entityId: 'rec-1', limitKey: 'reports', eventKey: 'report:b' })
60
+ await usageCounters(ctx).collection.updateOne({ entityId: 'rec-1', limitKey: 'reports' }, { $set: { used: 7 } })
61
+
62
+ const result = await service.reconcileCounters('rec-1')
63
+ expect(result.repaired).toBeGreaterThanOrEqual(1)
64
+ const { items } = await usageEvents(ctx).list({ entityId: 'rec-1', limitKey: 'reports' }, { size: 0 })
65
+ const sum = items.reduce((total, event) => total + event.delta, 0)
66
+ expect(sum).toBe(2)
67
+ expect((await usageCounters(ctx).load({ entityId: 'rec-1', limitKey: 'reports' }))?.used).toBe(sum)
68
+
69
+ const over = await service.reconcileOccupancy('rec-1', 'seats', 4)
70
+ expect(over.overSince).toBeInstanceOf(Date)
71
+ const within = await service.reconcileOccupancy('rec-1', 'seats', 2)
72
+ expect(within).toEqual({ limitKey: 'seats', used: 2, limit: 2, over: 0 })
73
+ expect((await usageCounters(ctx).load({ entityId: 'rec-1', limitKey: 'seats' }))?.overSince ?? null).toBeNull()
74
+ expect(await usageEvents(ctx).count({ entityId: 'rec-1', limitKey: 'seats' })).toBe(1)
75
+ }, 60_000)
76
+
77
+ it('stores subscriptions, fulfillments and webhook endpoints through their collection validators', async () => {
78
+ const { ctx } = booted
79
+ const { stripe } = makeFakeStripe()
80
+ const handler = createEventHandler(ctx, stripe)
81
+ await handler.process(eventOf('customer.subscription.updated', subscriptionOf({ entityId: 'sub-1' })))
82
+ await handler.process(eventOf('customer.subscription.updated', subscriptionOf({ entityId: 'sub-1', cancelAtPeriodEnd: true })))
83
+ await handler.process(eventOf('checkout.session.completed', {
84
+ id: 'cs_int', mode: 'payment', payment_status: 'paid', customer: 'cus_1', currency: 'usd', amount_subtotal: 1021,
85
+ payment_intent: 'pi_int',
86
+ metadata: {
87
+ pricingMode: 'amount', amountMinor: '1000', chargeAmountMinor: '1021', currency: 'usd',
88
+ entityId: 'sub-1', service: 'app', productSku: CREDITS_PRODUCT,
89
+ },
90
+ }))
91
+ await ensureWebhookEndpoint(ctx, stripe)
92
+
93
+ const row = await subscriptions(ctx).byExternalId('sub_1', 'stripe')
94
+ expect(row).toEqual(expect.objectContaining({ status: SubscriptionStatus.Active, cancelAtPeriodEnd: true, rank: 10 }))
95
+ expect(row?.propagated?.cancelAtPeriodEnd).toBe(true)
96
+ expect(row?.createdAt).toBeInstanceOf(Date)
97
+ expect((await entitlements(ctx).entitlements('sub-1')).plan.sku).toBe(PRO)
98
+ expect((await fulfillments(ctx).byExternalId('cs_int', 'stripe'))?.fulfilledAt).toBeInstanceOf(Date)
99
+ expect((await paymentWebhooks(ctx).load({ service: 'app' }))?.secret).toStartWith('whsec_')
100
+ }, 60_000)
101
+ })
@@ -0,0 +1,171 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import {
3
+ LimitExhausted, LimitKind, LimitMisdeclared, LimitUnknown, LimitWindow, windowBoundsOf, windowKeyOf,
4
+ } from '@owlmeans/payment'
5
+ import { UnsupportedArgumentError } from '@owlmeans/resource'
6
+ import { entitlements, gateway } from '../src/utils.js'
7
+ import { makeFakeContext, past, PRO } from './fake-stripe.js'
8
+ import type { FakeContext } from './fake-stripe.js'
9
+
10
+ const ledger = (fake: FakeContext) => fake.stores['payment-usage'].rows
11
+ const counters = (fake: FakeContext) => fake.stores['payment-usage-counter'].rows
12
+
13
+ const pro = async (fake: FakeContext, entityId = 'entity-1'): Promise<void> => {
14
+ await gateway(fake.ctx).grantInternalPlan(fake.ctx, entityId, PRO, { force: true })
15
+ }
16
+
17
+ describe('@owlmeans/server-payment — consume', () => {
18
+ test('admits up to the ceiling, then refuses without writing an event', async () => {
19
+ const fake = await makeFakeContext()
20
+ const service = entitlements(fake.ctx)
21
+ const first = await service.consume({ entityId: 'entity-1', limitKey: 'exports', eventKey: 'export:a', ref: 'a' })
22
+ expect(first).toEqual(expect.objectContaining({
23
+ admitted: true, replayed: false, used: 1, limit: 1, remaining: 0,
24
+ window: windowKeyOf(LimitKind.Window, LimitWindow.Month),
25
+ }))
26
+
27
+ const refusal = await service.consume({ entityId: 'entity-1', limitKey: 'exports', eventKey: 'export:b' })
28
+ .catch(error => error)
29
+ expect(refusal).toBeInstanceOf(LimitExhausted)
30
+ expect(refusal).toEqual(expect.objectContaining({
31
+ limitKey: 'exports', used: 1, limit: 1, resetsAt: windowBoundsOf(LimitWindow.Month).resetsAt,
32
+ }))
33
+ expect(ledger(fake)).toHaveLength(1)
34
+ expect(counters(fake)[0].used).toBe(1)
35
+ })
36
+
37
+ test('replays an event key — at the ceiling too — without counting it twice', async () => {
38
+ const fake = await makeFakeContext()
39
+ const service = entitlements(fake.ctx)
40
+ await service.consume({ entityId: 'entity-1', limitKey: 'exports', eventKey: 'export:a' })
41
+ const replay = await service.consume({ entityId: 'entity-1', limitKey: 'exports', eventKey: 'export:a' })
42
+
43
+ expect(replay).toEqual(expect.objectContaining({ admitted: true, replayed: true, used: 1 }))
44
+ expect(ledger(fake)).toHaveLength(1)
45
+ expect(counters(fake)[0].used).toBe(1)
46
+ expect(await service.consumption('entity-1', 'exports', 'export:a')).toEqual(expect.objectContaining({ delta: 1 }))
47
+ expect(await service.consumption('entity-1', 'exports', 'export:unknown')).toBeNull()
48
+ })
49
+
50
+ test('a lapsed promo refuses; a grandfathered subscription keeps the allowance', async () => {
51
+ const fake = await makeFakeContext({ catalogue: { reportsUntil: past(1) } })
52
+ await pro(fake, 'late')
53
+ await expect(entitlements(fake.ctx).consume({ entityId: 'late', limitKey: 'reports', eventKey: 'r:1' }))
54
+ .rejects.toBeInstanceOf(LimitExhausted)
55
+
56
+ await pro(fake, 'early')
57
+ const row = fake.stores['payment-subscription'].rows.find(item => item.entityId === 'early')!
58
+ row.createdAt = past(5)
59
+ expect((await entitlements(fake.ctx).consume({ entityId: 'early', limitKey: 'reports', eventKey: 'r:1' })).limit).toBe(4)
60
+ })
61
+
62
+ test('an undeclared key is LimitUnknown; a non-positive amount is refused', async () => {
63
+ const fake = await makeFakeContext()
64
+ await expect(entitlements(fake.ctx).consume({ entityId: 'entity-1', limitKey: 'nope', eventKey: 'n:1' }))
65
+ .rejects.toBeInstanceOf(LimitUnknown)
66
+ await expect(entitlements(fake.ctx).consume({ entityId: 'entity-1', limitKey: 'exports', eventKey: 'n:1', amount: 0 }))
67
+ .rejects.toBeInstanceOf(UnsupportedArgumentError)
68
+ })
69
+ })
70
+
71
+ describe('@owlmeans/server-payment — release', () => {
72
+ test('frees the unit once; a second release replays; the consumption is gone', async () => {
73
+ const fake = await makeFakeContext()
74
+ await pro(fake)
75
+ const service = entitlements(fake.ctx)
76
+ await service.consume({ entityId: 'entity-1', limitKey: 'reports', eventKey: 'report:1', ref: '1' })
77
+
78
+ expect(await service.release({ entityId: 'entity-1', limitKey: 'reports', eventKey: 'report:1' }))
79
+ .toEqual(expect.objectContaining({ admitted: true, replayed: false, used: 0 }))
80
+ expect(await service.release({ entityId: 'entity-1', limitKey: 'reports', eventKey: 'report:1' }))
81
+ .toEqual(expect.objectContaining({ admitted: true, replayed: true, used: 0 }))
82
+ expect(ledger(fake).map(event => event.delta)).toEqual([1, -1])
83
+ expect(await service.consumption('entity-1', 'reports', 'report:1')).toBeNull()
84
+ expect(await service.release({ entityId: 'entity-1', limitKey: 'reports', eventKey: 'report:never' }))
85
+ .toEqual(expect.objectContaining({ admitted: false, replayed: false }))
86
+ })
87
+
88
+ test('consumptionByRef finds the unit a record holds under a fresh key per cycle', async () => {
89
+ const fake = await makeFakeContext()
90
+ await pro(fake)
91
+ const service = entitlements(fake.ctx)
92
+ expect(await service.consumptionByRef('entity-1', 'seats', 'record-1')).toBeNull()
93
+
94
+ await service.consume({ entityId: 'entity-1', limitKey: 'seats', eventKey: 'seats:record-1:1', ref: 'record-1' })
95
+ expect(await service.consumptionByRef('entity-1', 'seats', 'record-1'))
96
+ .toEqual(expect.objectContaining({ eventKey: 'seats:record-1:1', delta: 1 }))
97
+
98
+ await Bun.sleep(3)
99
+ await service.consume({ entityId: 'entity-1', limitKey: 'seats', eventKey: 'seats:record-1:2', ref: 'record-1' })
100
+ expect((await service.consumptionByRef('entity-1', 'seats', 'record-1'))?.eventKey).toBe('seats:record-1:2')
101
+ expect(await service.consumptionByRef('entity-1', 'seats', 'record-2')).toBeNull()
102
+
103
+ await service.release({ entityId: 'entity-1', limitKey: 'seats', eventKey: 'seats:record-1:2' })
104
+ expect((await service.consumptionByRef('entity-1', 'seats', 'record-1'))?.eventKey).toBe('seats:record-1:1')
105
+ await service.release({ entityId: 'entity-1', limitKey: 'seats', eventKey: 'seats:record-1:1' })
106
+ expect(await service.consumptionByRef('entity-1', 'seats', 'record-1')).toBeNull()
107
+ })
108
+
109
+ test('never takes a counter below zero', async () => {
110
+ const fake = await makeFakeContext()
111
+ await pro(fake)
112
+ const service = entitlements(fake.ctx)
113
+ await service.consume({ entityId: 'entity-1', limitKey: 'reports', eventKey: 'report:1' })
114
+ counters(fake)[0].used = 0
115
+
116
+ expect((await service.release({ entityId: 'entity-1', limitKey: 'reports', eventKey: 'report:1' })).used).toBe(0)
117
+ expect(counters(fake)[0].used).toBe(0)
118
+ })
119
+ })
120
+
121
+ describe('@owlmeans/server-payment — reconciliation', () => {
122
+ test('reconcileCounters repairs drift from the ledger, drops empty past windows and creates the current one', async () => {
123
+ const fake = await makeFakeContext()
124
+ await pro(fake)
125
+ const service = entitlements(fake.ctx)
126
+ await service.consume({ entityId: 'entity-1', limitKey: 'exports', eventKey: 'export:1' })
127
+ await service.consume({ entityId: 'entity-1', limitKey: 'exports', eventKey: 'export:2' })
128
+ counters(fake)[0].used = 3
129
+ counters(fake).push({ entityId: 'entity-1', limitKey: 'exports', window: '2001-01-01', used: 2, limit: 3, updatedAt: past(9000) })
130
+ counters(fake).push({ entityId: 'entity-1', limitKey: 'reports', window: 'lifetime', used: 2, limit: 4, updatedAt: past(1) })
131
+
132
+ const result = await service.reconcileCounters('entity-1')
133
+ expect(result.repaired).toBe(2)
134
+ const today = windowKeyOf(LimitKind.Window, LimitWindow.Day)
135
+ expect(counters(fake).find(counter => counter.window === today)?.used).toBe(2)
136
+ expect(counters(fake).some(counter => counter.window === '2001-01-01')).toBe(false)
137
+ expect(counters(fake).find(counter => counter.window === 'lifetime')?.used).toBe(0)
138
+
139
+ const fresh = await makeFakeContext()
140
+ await entitlements(fresh.ctx).reconcileCounters('entity-9')
141
+ expect(counters(fresh)).toEqual([expect.objectContaining({
142
+ entityId: 'entity-9', limitKey: 'exports', window: windowKeyOf(LimitKind.Window, LimitWindow.Month), used: 0,
143
+ })])
144
+ })
145
+
146
+ test('reconcileOccupancy writes one adjusting event a day and flags an entity over its limit', async () => {
147
+ const fake = await makeFakeContext()
148
+ await pro(fake)
149
+ const service = entitlements(fake.ctx)
150
+ await service.consume({ entityId: 'entity-1', limitKey: 'seats', eventKey: 'seat:1' })
151
+
152
+ const over = await service.reconcileOccupancy('entity-1', 'seats', 3)
153
+ expect(over).toEqual(expect.objectContaining({ used: 3, limit: 2, over: 1 }))
154
+ expect(over.overSince).toBeInstanceOf(Date)
155
+ const again = await service.reconcileOccupancy('entity-1', 'seats', 3)
156
+ expect(again.overSince?.getTime()).toBe(over.overSince?.getTime())
157
+
158
+ const within = await service.reconcileOccupancy('entity-1', 'seats', 1)
159
+ expect(within).toEqual({ limitKey: 'seats', used: 1, limit: 2, over: 0 })
160
+ const adjustments = ledger(fake).filter(event => event.eventKey.startsWith('reconcile:seats:'))
161
+ expect(adjustments).toHaveLength(1)
162
+ expect(ledger(fake).reduce((sum, event) => sum + event.delta, 0)).toBe(1)
163
+ expect(counters(fake).find(counter => counter.limitKey === 'seats')?.overSince).toBeUndefined()
164
+ })
165
+
166
+ test('reconcileOccupancy refuses a limit that is not an occupancy', async () => {
167
+ const fake = await makeFakeContext()
168
+ await expect(entitlements(fake.ctx).reconcileOccupancy('entity-1', 'exports', 1))
169
+ .rejects.toBeInstanceOf(LimitMisdeclared)
170
+ })
171
+ })
@@ -0,0 +1,152 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { WebhookSetupError } from '@owlmeans/payment'
3
+ import { STRIPE_SIGNATURE, WEBHOOK_EVENTS } from '../src/consts.js'
4
+ import { handleStripeWebhook } from '../src/plugins/stripe.js'
5
+ import {
6
+ ensureWebhookEndpoint, stripeWebhookSecret, webhookUrlOf,
7
+ } from '../src/plugins/webhook-manager.js'
8
+ import { HOST, makeFakeContext, SDK_API_VERSION, SERVICE } from './fake-stripe.js'
9
+
10
+ const URL = 'https://api.example.com/payment-gate/webhook/stripe'
11
+ const ours = { owlmeans: 'payment', service: SERVICE }
12
+
13
+ describe('@owlmeans/server-payment — webhook endpoint management', () => {
14
+ test('skips a URL Stripe cannot deliver to without calling Stripe', async () => {
15
+ for (const host of ['localhost', '127.0.0.1', 'api', 'payments.local']) {
16
+ const fake = await makeFakeContext({ host })
17
+ expect(await ensureWebhookEndpoint(fake.ctx, fake.stripe)).toBeNull()
18
+ expect(fake.state.calls).toEqual([])
19
+ }
20
+ const plain = await makeFakeContext()
21
+ ;(plain.ctx.cfg as { security?: { unsecure?: boolean } }).security = { unsecure: true }
22
+ expect(await ensureWebhookEndpoint(plain.ctx, plain.stripe)).toBeNull()
23
+ expect(plain.state.calls).toEqual([])
24
+ })
25
+
26
+ test('creates the endpoint on the client API version, persists its secret, makes no call while nothing changed, and a forced run recreates it once deleted from outside', async () => {
27
+ const fake = await makeFakeContext()
28
+ expect(webhookUrlOf(fake.ctx)).toBe(URL)
29
+
30
+ const row = await ensureWebhookEndpoint(fake.ctx, fake.stripe)
31
+ expect(fake.state.calls).toEqual(['webhookEndpoints.list', 'webhookEndpoints.create'])
32
+ const [endpoint] = fake.state.webhookEndpoints
33
+ expect(endpoint).toEqual(expect.objectContaining({
34
+ url: URL, api_version: SDK_API_VERSION, enabled_events: [...WEBHOOK_EVENTS].sort(), metadata: ours,
35
+ description: `owlmeans:${SERVICE}`,
36
+ }))
37
+ expect(row).toEqual(expect.objectContaining({
38
+ externalId: endpoint.id, secret: endpoint.secret, apiVersion: SDK_API_VERSION, url: URL,
39
+ }))
40
+
41
+ fake.state.calls.length = 0
42
+ await ensureWebhookEndpoint(fake.ctx, fake.stripe)
43
+ expect(fake.state.calls).toEqual([])
44
+
45
+ fake.state.webhookEndpoints = []
46
+ await ensureWebhookEndpoint(fake.ctx, fake.stripe)
47
+ expect(fake.state.calls).toEqual([])
48
+
49
+ const healed = await ensureWebhookEndpoint(fake.ctx, fake.stripe, { force: true })
50
+ expect(fake.state.calls).toEqual(['webhookEndpoints.retrieve', 'webhookEndpoints.list', 'webhookEndpoints.create'])
51
+ const [recreated] = fake.state.webhookEndpoints
52
+ expect(recreated).toEqual(expect.objectContaining({ id: healed?.externalId, url: URL, metadata: ours }))
53
+ expect(fake.stores['payment-webhook'].rows).toEqual([
54
+ expect.objectContaining({ url: URL, externalId: recreated.id, secret: recreated.secret }),
55
+ ])
56
+ })
57
+
58
+ test('updates changed events in place, and recreates the endpoint for a new API version', async () => {
59
+ const fake = await makeFakeContext()
60
+ await ensureWebhookEndpoint(fake.ctx, fake.stripe)
61
+ const stored = fake.stores['payment-webhook'].rows[0]
62
+ const firstSecret = stored.secret
63
+ stored.hash = 'events-of-an-older-release'
64
+ fake.state.calls.length = 0
65
+
66
+ await ensureWebhookEndpoint(fake.ctx, fake.stripe)
67
+ expect(fake.state.calls).toEqual(['webhookEndpoints.update'])
68
+ expect(fake.stores['payment-webhook'].rows[0].secret).toBe(firstSecret)
69
+
70
+ fake.state.apiVersion = '2099-01-01.future'
71
+ fake.state.calls.length = 0
72
+ await ensureWebhookEndpoint(fake.ctx, fake.stripe)
73
+ expect(fake.state.calls).toEqual(['webhookEndpoints.del', 'webhookEndpoints.list', 'webhookEndpoints.create'])
74
+ expect(fake.state.webhookEndpoints).toHaveLength(1)
75
+ expect(fake.state.webhookEndpoints[0].api_version).toBe('2099-01-01.future')
76
+ expect(fake.stores['payment-webhook'].rows).toHaveLength(1)
77
+ expect(fake.stores['payment-webhook'].rows[0].secret).not.toBe(firstSecret)
78
+ })
79
+
80
+ test('leaves another deployment\'s endpoint in a shared account alone, and replaces an untracked one at its own URL', async () => {
81
+ const STAGE_URL = 'https://stage.example.com/payment-gate/webhook/stripe'
82
+ const STAGING_URL = 'https://staging.example.com/payment-gate/webhook/stripe'
83
+ const stage = await makeFakeContext({
84
+ host: 'stage.example.com',
85
+ stripe: {
86
+ webhookEndpoints: [{ id: 'we_manual', url: STAGING_URL, metadata: {}, enabled_events: ['*'], secret: 'whsec_manual' }],
87
+ },
88
+ })
89
+ const staging = await makeFakeContext({ host: 'staging.example.com' })
90
+
91
+ const stageRow = await ensureWebhookEndpoint(stage.ctx, stage.stripe)
92
+ const stagingRow = await ensureWebhookEndpoint(staging.ctx, stage.stripe)
93
+ expect(stage.state.webhookEndpoints.map(({ id, url, metadata }) => ({ id, url, metadata }))).toEqual([
94
+ { id: stageRow?.externalId, url: STAGE_URL, metadata: ours },
95
+ { id: stagingRow?.externalId, url: STAGING_URL, metadata: ours },
96
+ ])
97
+
98
+ stage.state.calls.length = 0
99
+ expect(await ensureWebhookEndpoint(stage.ctx, stage.stripe, { force: true }))
100
+ .toEqual(expect.objectContaining({ externalId: stageRow?.externalId, secret: stageRow?.secret }))
101
+ expect(stage.state.calls).toEqual(['webhookEndpoints.retrieve', 'webhookEndpoints.update'])
102
+ })
103
+
104
+ test('after a URL change, drops the endpoints its own former rows name and removes those rows', async () => {
105
+ const fake = await makeFakeContext({ host: 'old.example.com' })
106
+ const former = await ensureWebhookEndpoint(fake.ctx, fake.stripe)
107
+ fake.stores['payment-webhook'].rows.push({
108
+ id: 'older-row', paygate: 'stripe', service: SERVICE, url: 'https://older.example.com/payment-gate/webhook/stripe',
109
+ externalId: 'we_gone', secret: 'whsec_gone', apiVersion: SDK_API_VERSION, events: [], hash: 'x', createdAt: new Date(),
110
+ })
111
+ ;(fake.ctx.cfg.services?.[SERVICE] as { host: string }).host = HOST
112
+ fake.state.calls.length = 0
113
+
114
+ const moved = await ensureWebhookEndpoint(fake.ctx, fake.stripe)
115
+ expect(moved).toEqual(expect.objectContaining({ url: URL }))
116
+ expect(fake.state.calls).toEqual([
117
+ 'webhookEndpoints.list', 'webhookEndpoints.create', 'webhookEndpoints.del', 'webhookEndpoints.del',
118
+ ])
119
+ expect(fake.state.webhookEndpoints.map(endpoint => endpoint.id)).toEqual([moved?.externalId])
120
+ expect(moved?.externalId).not.toBe(former?.externalId)
121
+ expect(fake.stores['payment-webhook'].rows).toEqual([expect.objectContaining({ url: URL, externalId: moved?.externalId })])
122
+ })
123
+ })
124
+
125
+ describe('@owlmeans/server-payment — webhook secret', () => {
126
+ test('resolves the configured secret first, then the stored one, else fails setup', async () => {
127
+ const bare = await makeFakeContext()
128
+ await expect(stripeWebhookSecret(bare.ctx)).rejects.toBeInstanceOf(WebhookSetupError)
129
+
130
+ await ensureWebhookEndpoint(bare.ctx, bare.stripe)
131
+ expect(await stripeWebhookSecret(bare.ctx)).toBe(bare.state.webhookEndpoints[0].secret)
132
+
133
+ const configured = await makeFakeContext({ webhookSecret: 'whsec_file' })
134
+ await ensureWebhookEndpoint(configured.ctx, configured.stripe)
135
+ expect(await stripeWebhookSecret(configured.ctx)).toBe('whsec_file')
136
+ })
137
+
138
+ test('verifies a delivery with either secret, and refuses a signature neither verifies', async () => {
139
+ const fake = await makeFakeContext({ webhookSecret: 'whsec_file' })
140
+ await ensureWebhookEndpoint(fake.ctx, fake.stripe)
141
+ const stored = fake.state.webhookEndpoints[0].secret
142
+ const body = JSON.stringify({ id: 'evt_1', type: 'customer.created', data: { object: { id: 'cus_9', metadata: { entityId: 'e9' } } } })
143
+ const deliver = async (signature: string) => await handleStripeWebhook(fake.ctx, fake.stripe, {
144
+ rawBody: body, headers: { [STRIPE_SIGNATURE.toLowerCase()]: signature },
145
+ })
146
+
147
+ await deliver(`sig:${stored}`)
148
+ await deliver('sig:whsec_file')
149
+ expect(fake.stores['payment-paygate-customer'].rows).toHaveLength(1)
150
+ await expect(deliver('sig:whsec_forged')).rejects.toThrow('signature')
151
+ })
152
+ })