@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
package/src/usage.ts ADDED
@@ -0,0 +1,453 @@
1
+ import {
2
+ LIFETIME_WINDOW, LimitExhausted, LimitKind, LimitMisdeclared, LimitUnknown, LimitWindow, limitViewsOf,
3
+ OCCUPANCY_WINDOW, promoActive, windowBoundsOf, windowKeyOf,
4
+ } from '@owlmeans/payment'
5
+ import type { LimitDeclaration, LimitView } from '@owlmeans/payment'
6
+ import { UnsupportedArgumentError } from '@owlmeans/resource'
7
+ import type { Context as ApiContext } from '@owlmeans/server-api'
8
+ import { resolveEffectivePlan } from './plan.js'
9
+ import { isDuplicateKey, usageCounters, usageEvents } from './utils.js'
10
+ import type {
11
+ ConsumeRequest, CounterReconciliation, EffectivePlan, LimitOutcome, OccupancyOutcome,
12
+ PaymentUsageCounterRecord, PaymentUsageRecord, ReleaseRequest,
13
+ } from './types.js'
14
+
15
+ /**
16
+ * The usage ledger and its counters.
17
+ *
18
+ * `payment-usage` events are the source of truth; `payment-usage-counter` is a projection kept
19
+ * for synchronous admission. Consuming increments the counter FIRST, with one atomic conditional
20
+ * update that only matches while there is room, and appends the event after. Invariant: **the
21
+ * counter may over-count, never over-admit.** A crash between the two leaves a unit counted that no
22
+ * event spent, which `reconcileCounters` repairs from the ledger; the opposite order would let two
23
+ * concurrent requests both read "room left" and both spend it.
24
+ */
25
+
26
+ interface CounterKey { entityId: string; limitKey: string; window: string }
27
+
28
+ const DAY_WINDOW = /^(\d{4})-(\d{2})-(\d{2})$/
29
+ const MONTH_WINDOW = /^(\d{4})-(\d{2})$/
30
+
31
+ /** When a stored window renews: the first instant of the next day or month; nothing for the others. */
32
+ export const resetsAtOfWindow = (window: string): Date | undefined => {
33
+ const day = DAY_WINDOW.exec(window)
34
+ if (day != null) {
35
+ return new Date(Date.UTC(Number(day[1]), Number(day[2]) - 1, Number(day[3]) + 1))
36
+ }
37
+ const month = MONTH_WINDOW.exec(window)
38
+ if (month != null) {
39
+ return new Date(Date.UTC(Number(month[1]), Number(month[2]), 1))
40
+ }
41
+
42
+ return undefined
43
+ }
44
+
45
+ const ceilingOf = (declaration: LimitDeclaration, effective: EffectivePlan, at: Date): number =>
46
+ promoActive(declaration.promo, effective.subscription?.createdAt, at) ? declaration.limit : 0
47
+
48
+ const declarationOf = (effective: EffectivePlan, limitKey: string): LimitDeclaration => {
49
+ const declaration = effective.plan.limits?.[limitKey]
50
+ if (declaration == null) {
51
+ throw new LimitUnknown(limitKey)
52
+ }
53
+
54
+ return declaration
55
+ }
56
+
57
+ const readCounter = async (ctx: ApiContext, key: CounterKey): Promise<PaymentUsageCounterRecord | null> =>
58
+ await usageCounters(ctx).load({ entityId: key.entityId, limitKey: key.limitKey, window: key.window })
59
+
60
+ const outcomeOf = async (
61
+ ctx: ApiContext, key: CounterKey, eventKey: string, flags: { admitted: boolean, replayed: boolean },
62
+ limit?: number,
63
+ ): Promise<LimitOutcome> => {
64
+ const counter = await readCounter(ctx, key)
65
+ const used = Math.max(0, counter?.used ?? 0)
66
+ const ceiling = limit ?? counter?.limit ?? 0
67
+ const resetsAt = resetsAtOfWindow(key.window)
68
+
69
+ return {
70
+ ...flags, limitKey: key.limitKey, window: key.window, used, limit: ceiling,
71
+ remaining: Math.max(0, ceiling - used), ...(resetsAt != null ? { resetsAt } : {}), eventKey,
72
+ }
73
+ }
74
+
75
+ /** The counter increment that IS the admission decision. `null` when there is no room. */
76
+ const admit = async (
77
+ ctx: ApiContext, key: CounterKey, amount: number, limit: number, planSku: string, at: Date,
78
+ ): Promise<PaymentUsageCounterRecord | null> => {
79
+ if (amount > limit) {
80
+ return null
81
+ }
82
+ const filter = { ...key, used: { $lte: limit - amount } }
83
+ const update = {
84
+ $inc: { used: amount },
85
+ $set: { limit, planSku, updatedAt: at },
86
+ $setOnInsert: { ...key },
87
+ }
88
+ for (let attempt = 0; attempt < 2; attempt++) {
89
+ try {
90
+ const doc = await usageCounters(ctx).collection.findOneAndUpdate(
91
+ filter, update, { upsert: true, returnDocument: 'after' },
92
+ )
93
+ return doc as unknown as PaymentUsageCounterRecord | null
94
+ } catch (error) {
95
+ // A duplicate here is either a racing insert of the same counter — the retry then matches
96
+ // the inserted document — or an existing counter without room, whose upsert collides again.
97
+ if (!isDuplicateKey(error)) {
98
+ throw error
99
+ }
100
+ }
101
+ }
102
+
103
+ return null
104
+ }
105
+
106
+ /** Take `amount` back off a counter, never below zero. */
107
+ const decrement = async (ctx: ApiContext, key: CounterKey, amount: number, at: Date): Promise<void> => {
108
+ const counters = usageCounters(ctx).collection
109
+ const doc = await counters.findOneAndUpdate(
110
+ { ...key, used: { $gte: amount } }, { $inc: { used: -amount }, $set: { updatedAt: at } },
111
+ )
112
+ if (doc == null) {
113
+ await counters.updateOne({ ...key }, { $set: { used: 0, updatedAt: at } })
114
+ }
115
+ }
116
+
117
+ const isActiveConsumption = (event: PaymentUsageRecord | null): boolean =>
118
+ event != null && event.delta > 0 && event.releasedAt == null
119
+
120
+ /** @throws LimitExhausted | LimitUnknown */
121
+ export const consumeLimit = async (ctx: ApiContext, req: ConsumeRequest): Promise<LimitOutcome> => {
122
+ const amount = req.amount ?? 1
123
+ if (!Number.isSafeInteger(amount) || amount < 1) {
124
+ throw new UnsupportedArgumentError('amount')
125
+ }
126
+ const at = new Date()
127
+ const effective = await resolveEffectivePlan(ctx, req.entityId, at)
128
+ const declaration = declarationOf(effective, req.limitKey)
129
+ const limit = ceilingOf(declaration, effective, at)
130
+ const key: CounterKey = {
131
+ entityId: req.entityId, limitKey: req.limitKey, window: windowKeyOf(declaration.kind, declaration.window, at),
132
+ }
133
+ const ledger = usageEvents(ctx)
134
+
135
+ // A retry of an event already written answers from the ledger before admission is asked: at the
136
+ // ceiling, admission would refuse the very unit this key already holds. A released key is final.
137
+ const earlier = await ledger.load({ entityId: req.entityId, limitKey: req.limitKey, eventKey: req.eventKey })
138
+ if (earlier != null) {
139
+ return await outcomeOf(ctx, { ...key, window: earlier.window }, req.eventKey, {
140
+ admitted: isActiveConsumption(earlier), replayed: true,
141
+ }, earlier.window === key.window ? limit : undefined)
142
+ }
143
+
144
+ const counter = await admit(ctx, key, amount, limit, effective.plan.sku, at)
145
+ if (counter == null) {
146
+ // A concurrent consume of this very key may hold the last unit: that is a replay, not a refusal.
147
+ const concurrent = await ledger.load({ entityId: req.entityId, limitKey: req.limitKey, eventKey: req.eventKey })
148
+ if (concurrent != null) {
149
+ return await outcomeOf(ctx, { ...key, window: concurrent.window }, req.eventKey, {
150
+ admitted: isActiveConsumption(concurrent), replayed: true,
151
+ }, concurrent.window === key.window ? limit : undefined)
152
+ }
153
+ const current = await readCounter(ctx, key)
154
+ const resetsAt = declaration.kind === LimitKind.Window && declaration.window != null
155
+ ? windowBoundsOf(declaration.window, at).resetsAt : undefined
156
+ throw new LimitExhausted({ key: req.limitKey, used: current?.used ?? 0, limit, resetsAt })
157
+ }
158
+
159
+ try {
160
+ await ledger.create({
161
+ ...key, delta: amount, eventKey: req.eventKey, ref: req.ref, reason: req.reason,
162
+ planSku: effective.plan.sku, createdAt: at,
163
+ })
164
+ } catch (error) {
165
+ // Undo the admission. Should the undo itself fail, the counter over-counts — never over-admits.
166
+ await decrement(ctx, key, amount, at).catch(undo => { console.error('[payment] usage undo failed', undo) })
167
+ if (!isDuplicateKey(error)) {
168
+ throw error
169
+ }
170
+ // A concurrent consume of the same key won the event: replay its outcome.
171
+ const winner = await ledger.load({ entityId: req.entityId, limitKey: req.limitKey, eventKey: req.eventKey })
172
+ return await outcomeOf(ctx, { ...key, window: winner?.window ?? key.window }, req.eventKey, {
173
+ admitted: isActiveConsumption(winner), replayed: true,
174
+ }, limit)
175
+ }
176
+
177
+ const used = Math.max(0, counter.used)
178
+ const resetsAt = resetsAtOfWindow(key.window)
179
+
180
+ return {
181
+ admitted: true, replayed: false, limitKey: req.limitKey, window: key.window, used, limit,
182
+ remaining: Math.max(0, limit - used), ...(resetsAt != null ? { resetsAt } : {}), eventKey: req.eventKey,
183
+ }
184
+ }
185
+
186
+ /** Idempotent: the release event is appended first, the counter decremented only by that append. */
187
+ export const releaseLimit = async (ctx: ApiContext, req: ReleaseRequest): Promise<LimitOutcome> => {
188
+ const ledger = usageEvents(ctx)
189
+ const consumed = await ledger.load({ entityId: req.entityId, limitKey: req.limitKey, eventKey: req.eventKey })
190
+ if (consumed == null || consumed.delta <= 0) {
191
+ return {
192
+ admitted: false, replayed: false, limitKey: req.limitKey, window: consumed?.window ?? '', used: 0,
193
+ limit: 0, remaining: 0, eventKey: req.eventKey,
194
+ }
195
+ }
196
+ const key: CounterKey = { entityId: req.entityId, limitKey: req.limitKey, window: consumed.window }
197
+ if (consumed.releasedAt != null) {
198
+ return await outcomeOf(ctx, key, req.eventKey, { admitted: true, replayed: true })
199
+ }
200
+ const amount = Math.min(req.amount ?? consumed.delta, consumed.delta)
201
+ if (!Number.isSafeInteger(amount) || amount < 1) {
202
+ throw new UnsupportedArgumentError('amount')
203
+ }
204
+
205
+ const at = new Date()
206
+ let appended = true
207
+ try {
208
+ await ledger.create({
209
+ ...key, delta: -amount, eventKey: `release:${req.eventKey}`, ref: consumed.ref ?? undefined,
210
+ reason: 'release', planSku: consumed.planSku ?? undefined, createdAt: at,
211
+ })
212
+ } catch (error) {
213
+ if (!isDuplicateKey(error)) {
214
+ throw error
215
+ }
216
+ // An earlier attempt appended the release and decremented; only the stamp is missing.
217
+ appended = false
218
+ }
219
+ if (appended) {
220
+ await decrement(ctx, key, amount, at)
221
+ }
222
+ await ledger.update({ ...consumed, releasedAt: at })
223
+
224
+ return await outcomeOf(ctx, key, req.eventKey, { admitted: true, replayed: false })
225
+ }
226
+
227
+ /** The active consume event of a key, or `null` (never consumed, or released). */
228
+ export const consumptionOf = async (
229
+ ctx: ApiContext, entityId: string, limitKey: string, eventKey: string,
230
+ ): Promise<PaymentUsageRecord | null> => {
231
+ const event = await usageEvents(ctx).load({ entityId, limitKey, eventKey })
232
+
233
+ return event != null && isActiveConsumption(event) ? event : null
234
+ }
235
+
236
+ /**
237
+ * The newest active consume event (not released) that pays for `ref`, or `null`. For a unit held
238
+ * per record under a fresh event key each time — an occupancy acquired again after a release —
239
+ * this answers "does the record hold a unit, and under which key".
240
+ */
241
+ export const consumptionByRefOf = async (
242
+ ctx: ApiContext, entityId: string, limitKey: string, ref: string,
243
+ ): Promise<PaymentUsageRecord | null> => await usageEvents(ctx).load(
244
+ { entityId, limitKey, ref, delta: { $gt: 0 }, releasedAt: null },
245
+ { sort: [{ field: 'createdAt', order: 'desc' }] },
246
+ )
247
+
248
+ /** One limit of the effective plan, read against its current window. @throws LimitUnknown */
249
+ export const limitStateOf = async (ctx: ApiContext, entityId: string, limitKey: string): Promise<LimitView> => {
250
+ const at = new Date()
251
+ const effective = await resolveEffectivePlan(ctx, entityId, at)
252
+ const declaration = declarationOf(effective, limitKey)
253
+ const window = windowKeyOf(declaration.kind, declaration.window, at)
254
+ const counter = await readCounter(ctx, { entityId, limitKey, window })
255
+ const [view] = limitViewsOf(
256
+ { limits: { [limitKey]: declaration } },
257
+ counter != null ? [{ key: limitKey, window, used: counter.used }] : [],
258
+ effective.subscription?.createdAt, at,
259
+ )
260
+
261
+ return view
262
+ }
263
+
264
+ interface LedgerSum { entityId: string; limitKey: string; window: string; used: number }
265
+
266
+ /** Sum of every event's `delta`, per (entity, limit, window). */
267
+ export const ledgerSums = async (
268
+ ctx: ApiContext, match: Partial<CounterKey>,
269
+ ): Promise<LedgerSum[]> => {
270
+ const filter = Object.fromEntries(Object.entries(match).filter(([, value]) => value != null))
271
+ const rows = await usageEvents(ctx).collection.aggregate([
272
+ { $match: filter },
273
+ {
274
+ $group: {
275
+ _id: { entityId: '$entityId', limitKey: '$limitKey', window: '$window' },
276
+ used: { $sum: '$delta' },
277
+ },
278
+ },
279
+ ], { allowDiskUse: true }).toArray()
280
+
281
+ return rows.map(row => ({ ...(row._id as CounterKey), used: row.used as number }))
282
+ }
283
+
284
+ /** Set a counter to a value, creating it when absent. */
285
+ const setCounter = async (
286
+ ctx: ApiContext, key: CounterKey, set: Partial<PaymentUsageCounterRecord>, unset: string[] = [],
287
+ ): Promise<void> => {
288
+ const update = {
289
+ $set: set,
290
+ $setOnInsert: { ...key },
291
+ ...(unset.length > 0 ? { $unset: Object.fromEntries(unset.map(field => [field, ''])) } : {}),
292
+ }
293
+ for (let attempt = 0; attempt < 2; attempt++) {
294
+ try {
295
+ await usageCounters(ctx).collection.updateOne({ ...key }, update, { upsert: true })
296
+ return
297
+ } catch (error) {
298
+ if (!isDuplicateKey(error) || attempt > 0) {
299
+ throw error
300
+ }
301
+ }
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Bring an occupancy limit's ledger and counter to the live count. Appends at most one adjusting
307
+ * event per limit per UTC day (`reconcile:<key>:<YYYY-MM-DD>`), adjusted in place by a later run of
308
+ * the same day, so the ledger sum always equals what was last observed. Flags `overSince` while the
309
+ * count exceeds the limit. Never stops anything.
310
+ *
311
+ * @throws LimitUnknown | LimitMisdeclared
312
+ */
313
+ export const reconcileOccupancyOf = async (
314
+ ctx: ApiContext, entityId: string, limitKey: string, actual: number,
315
+ ): Promise<OccupancyOutcome> => {
316
+ if (!Number.isSafeInteger(actual) || actual < 0) {
317
+ throw new UnsupportedArgumentError('actual')
318
+ }
319
+ const at = new Date()
320
+ const effective = await resolveEffectivePlan(ctx, entityId, at)
321
+ const declaration = declarationOf(effective, limitKey)
322
+ if (declaration.kind !== LimitKind.Occupancy) {
323
+ throw new LimitMisdeclared(`${limitKey}:not-occupancy`)
324
+ }
325
+ const limit = ceilingOf(declaration, effective, at)
326
+ const key: CounterKey = { entityId, limitKey, window: OCCUPANCY_WINDOW }
327
+
328
+ const [sum] = await ledgerSums(ctx, key)
329
+ const delta = actual - (sum?.used ?? 0)
330
+ if (delta !== 0) {
331
+ const eventKey = `reconcile:${limitKey}:${windowKeyOf(LimitKind.Window, LimitWindow.Day, at)}`
332
+ try {
333
+ await usageEvents(ctx).create({
334
+ ...key, delta, eventKey, reason: 'reconcile', planSku: effective.plan.sku, createdAt: at,
335
+ })
336
+ } catch (error) {
337
+ if (!isDuplicateKey(error)) {
338
+ throw error
339
+ }
340
+ await usageEvents(ctx).collection.updateOne({ entityId, limitKey, eventKey }, { $inc: { delta } })
341
+ }
342
+ }
343
+
344
+ const counter = await readCounter(ctx, key)
345
+ const over = Math.max(0, actual - limit)
346
+ const overSince = over > 0 ? (counter?.overSince != null ? new Date(counter.overSince) : at) : undefined
347
+ await setCounter(ctx, key, {
348
+ used: actual, limit, planSku: effective.plan.sku, updatedAt: at, reconciledAt: at,
349
+ ...(overSince != null ? { overSince } : {}),
350
+ }, overSince == null ? ['overSince'] : [])
351
+
352
+ return { limitKey, used: actual, limit, over, ...(overSince != null ? { overSince } : {}) }
353
+ }
354
+
355
+ const isPastWindow = (window: string, at: Date): boolean => {
356
+ if (DAY_WINDOW.test(window)) {
357
+ return window < windowKeyOf(LimitKind.Window, LimitWindow.Day, at)
358
+ }
359
+ if (MONTH_WINDOW.test(window)) {
360
+ return window < windowKeyOf(LimitKind.Window, LimitWindow.Month, at)
361
+ }
362
+
363
+ return false
364
+ }
365
+
366
+ /**
367
+ * Recompute counters from the ledger — one entity, or every entity the ledger knows.
368
+ *
369
+ * Every (entity, limit, window) with events gets `used = max(0, sum)` and the limit of the
370
+ * entity's current plan; a counter of a past day/month with no events is deleted (lifetime and
371
+ * occupancy counters never are); a counter with no events otherwise reads `0`; and the current
372
+ * window counter of every declared window limit exists.
373
+ */
374
+ export const reconcileLedgerCounters = async (ctx: ApiContext, entityId?: string): Promise<CounterReconciliation> => {
375
+ const at = new Date()
376
+ const sums = await ledgerSums(ctx, entityId != null ? { entityId } : {})
377
+ const entities = new Set(sums.map(sum => sum.entityId))
378
+ if (entityId != null) {
379
+ entities.add(entityId)
380
+ }
381
+
382
+ let counters = 0
383
+ let repaired = 0
384
+ for (const entity of entities) {
385
+ let effective: EffectivePlan | null = null
386
+ try {
387
+ effective = await resolveEffectivePlan(ctx, entity, at)
388
+ } catch (error) {
389
+ console.warn(`[payment] reconcile: no plan for "${entity}"`, error)
390
+ }
391
+ const limitOf = (limitKey: string, fallback: number): number => {
392
+ const declaration = effective?.plan.limits?.[limitKey]
393
+ return declaration != null && effective != null ? ceilingOf(declaration, effective, at) : fallback
394
+ }
395
+
396
+ const ledger = new Map(sums.filter(sum => sum.entityId === entity)
397
+ .map(sum => [`${sum.limitKey}${sum.window}`, sum.used]))
398
+ const { items: stored } = await usageCounters(ctx).list({ entityId: entity }, { size: 0 })
399
+ const seen = new Set<string>()
400
+
401
+ for (const counter of stored) {
402
+ const id = `${counter.limitKey}${counter.window}`
403
+ seen.add(id)
404
+ const sum = ledger.get(id)
405
+ if (sum == null && isPastWindow(counter.window, at)) {
406
+ await usageCounters(ctx).collection.deleteOne({
407
+ entityId: entity, limitKey: counter.limitKey, window: counter.window,
408
+ })
409
+ continue
410
+ }
411
+ const used = Math.max(0, sum ?? 0)
412
+ const limit = limitOf(counter.limitKey, counter.limit)
413
+ counters++
414
+ if (used !== counter.used) {
415
+ repaired++
416
+ }
417
+ await setCounter(ctx, { entityId: entity, limitKey: counter.limitKey, window: counter.window }, {
418
+ used, limit, updatedAt: at, reconciledAt: at,
419
+ })
420
+ }
421
+
422
+ for (const [id, sum] of ledger) {
423
+ if (seen.has(id)) {
424
+ continue
425
+ }
426
+ const [limitKey, window] = id.split('')
427
+ counters++
428
+ repaired++
429
+ await setCounter(ctx, { entityId: entity, limitKey, window }, {
430
+ used: Math.max(0, sum), limit: limitOf(limitKey, 0), updatedAt: at, reconciledAt: at,
431
+ })
432
+ seen.add(id)
433
+ }
434
+
435
+ for (const [limitKey, declaration] of Object.entries(effective?.plan.limits ?? {})) {
436
+ if (declaration.kind !== LimitKind.Window) {
437
+ continue
438
+ }
439
+ const window = windowKeyOf(declaration.kind, declaration.window, at)
440
+ if (seen.has(`${limitKey}${window}`)) {
441
+ continue
442
+ }
443
+ counters++
444
+ await setCounter(ctx, { entityId: entity, limitKey, window }, {
445
+ used: 0, limit: limitOf(limitKey, 0), planSku: effective?.plan.sku, updatedAt: at, reconciledAt: at,
446
+ })
447
+ }
448
+ }
449
+
450
+ return { counters, repaired }
451
+ }
452
+
453
+ export { LIFETIME_WINDOW, OCCUPANCY_WINDOW }
package/src/utils.ts CHANGED
@@ -1,33 +1,150 @@
1
1
  import Stripe from 'stripe'
2
2
  import { PLUGINS } from '@owlmeans/config'
3
- import { DEFAULT_ALIAS as PAYMENT_SERVICE, SubscriptionStatus } from '@owlmeans/payment'
3
+ import { MONGO_DUPLICATE_KEY } from '@owlmeans/mongo-resource'
4
+ import { DEFAULT_ALIAS as PAYMENT_SERVICE, ENTITLING_STATUSES, WebhookSetupError } from '@owlmeans/payment'
4
5
  import type { PaymentService } from '@owlmeans/payment'
5
6
  import type { Context as ApiContext } from '@owlmeans/server-api'
6
7
  import {
7
- GATEWAY_SERVICE, PAYMENT_OBSERVER, RES_PAYGATE_CUSTOMER, RES_PAYMENT_FINGERPRINT,
8
- RES_PAYMENT_SUBSCRIPTION, STRIPE_PLUGIN_CONFIG,
8
+ CONSUMER_RIGHTS_MAIL_PLUGIN_CONFIG, CONSUMER_RIGHTS_SERVICE, ENTITLEMENT_SERVICE, GATEWAY_SERVICE,
9
+ PAYMENT_OBSERVER, RES_BILLING_PROFILE, RES_CONSUMER_CONSENT, RES_CONSUMER_DECLARATION, RES_CONSUMER_EVENT,
10
+ RES_PAYGATE_CUSTOMER, RES_PAYMENT_FINGERPRINT, RES_PAYMENT_FULFILLMENT, RES_PAYMENT_PURCHASE,
11
+ RES_PAYMENT_SUBSCRIPTION, RES_PAYMENT_USAGE, RES_PAYMENT_USAGE_COUNTER, RES_PAYMENT_WEBHOOK, STRIPE_PLUGIN_CONFIG,
12
+ STRIPE_PORTAL_PLUGIN_CONFIG, STRIPE_PRICING_PLUGIN_CONFIG,
9
13
  } from './consts.js'
10
14
  import type {
11
- CompletionObserver, FingerprintResource, GatewayService, PaygateCustomerResource,
12
- PaymentSubscriptionRecord, PaymentSubscriptionResource, StripePluginConfig,
15
+ BillingProfileResource, CompletionObserver, ConsumerConsentResource, ConsumerDeclarationResource,
16
+ ConsumerEventResource, ConsumerMailPluginConfig, ConsumerRightsService, EntitlementService, FingerprintResource,
17
+ GatewayService, PaygateCustomerResource, PaymentFulfillmentResource, PaymentSubscriptionRecord,
18
+ PaymentSubscriptionResource, PaymentUsageCounterResource, PaymentUsageResource, PaymentWebhookResource,
19
+ PortalBrandingConfig, PurchaseResource, StripePluginConfig, StripePricingPluginConfig,
13
20
  } from './types.js'
14
21
 
15
22
  export const payment = (ctx: ApiContext): PaymentService => ctx.service<PaymentService>(PAYMENT_SERVICE)
16
23
  export const gateway = (ctx: ApiContext): GatewayService => ctx.service<GatewayService>(GATEWAY_SERVICE)
17
24
  export const observer = (ctx: ApiContext): CompletionObserver => ctx.service<CompletionObserver>(PAYMENT_OBSERVER)
25
+ export const entitlements = (ctx: ApiContext): EntitlementService =>
26
+ ctx.service<EntitlementService>(ENTITLEMENT_SERVICE)
27
+
18
28
  export const paygateCustomers = (ctx: ApiContext): PaygateCustomerResource =>
19
29
  ctx.resource<PaygateCustomerResource>(RES_PAYGATE_CUSTOMER)
20
30
  export const subscriptions = (ctx: ApiContext): PaymentSubscriptionResource =>
21
31
  ctx.resource<PaymentSubscriptionResource>(RES_PAYMENT_SUBSCRIPTION)
32
+ export const fulfillments = (ctx: ApiContext): PaymentFulfillmentResource =>
33
+ ctx.resource<PaymentFulfillmentResource>(RES_PAYMENT_FULFILLMENT)
34
+ export const paymentWebhooks = (ctx: ApiContext): PaymentWebhookResource =>
35
+ ctx.resource<PaymentWebhookResource>(RES_PAYMENT_WEBHOOK)
36
+ export const usageEvents = (ctx: ApiContext): PaymentUsageResource =>
37
+ ctx.resource<PaymentUsageResource>(RES_PAYMENT_USAGE)
38
+ export const usageCounters = (ctx: ApiContext): PaymentUsageCounterResource =>
39
+ ctx.resource<PaymentUsageCounterResource>(RES_PAYMENT_USAGE_COUNTER)
22
40
  export const fingerprints = (ctx: ApiContext): FingerprintResource =>
23
41
  ctx.resource<FingerprintResource>(RES_PAYMENT_FINGERPRINT)
42
+ export const billingProfiles = (ctx: ApiContext): BillingProfileResource =>
43
+ ctx.resource<BillingProfileResource>(RES_BILLING_PROFILE)
44
+ export const purchases = (ctx: ApiContext): PurchaseResource => ctx.resource<PurchaseResource>(RES_PAYMENT_PURCHASE)
45
+ export const consumerConsents = (ctx: ApiContext): ConsumerConsentResource =>
46
+ ctx.resource<ConsumerConsentResource>(RES_CONSUMER_CONSENT)
47
+ export const consumerDeclarations = (ctx: ApiContext): ConsumerDeclarationResource =>
48
+ ctx.resource<ConsumerDeclarationResource>(RES_CONSUMER_DECLARATION)
49
+ export const consumerEvents = (ctx: ApiContext): ConsumerEventResource =>
50
+ ctx.resource<ConsumerEventResource>(RES_CONSUMER_EVENT)
51
+
52
+ /** The consumer-rights service. @throws when it is not registered */
53
+ export const consumerRights = (ctx: ApiContext, alias: string = CONSUMER_RIGHTS_SERVICE): ConsumerRightsService =>
54
+ ctx.service<ConsumerRightsService>(alias)
55
+
56
+ /** The consumer-rights service, or `null` in a process that registered none. */
57
+ export const consumerRightsOf = (ctx: ApiContext, alias: string = CONSUMER_RIGHTS_SERVICE): ConsumerRightsService | null =>
58
+ (ctx as unknown as { hasService?: (alias: string) => boolean }).hasService?.(alias) === true
59
+ ? ctx.service<ConsumerRightsService>(alias) : null
60
+
61
+ type PluginReader = { getConfigResource: (alias: string) => {
62
+ get: (id: string) => Promise<unknown>
63
+ load: (id: string) => Promise<unknown>
64
+ } }
65
+
24
66
  export const stripeConfig = async (ctx: ApiContext): Promise<StripePluginConfig> =>
25
- await (ctx as never as { getConfigResource: (alias: string) => { get: (id: string) => Promise<unknown> } })
26
- .getConfigResource(PLUGINS).get(STRIPE_PLUGIN_CONFIG) as StripePluginConfig
67
+ await (ctx as never as PluginReader).getConfigResource(PLUGINS).get(STRIPE_PLUGIN_CONFIG) as StripePluginConfig
68
+
69
+ /** The portal branding declared with `portalBranding`, or `null`. */
70
+ export const portalBrandingConfig = async (ctx: ApiContext): Promise<PortalBrandingConfig | null> =>
71
+ await (ctx as never as PluginReader).getConfigResource(PLUGINS).load(STRIPE_PORTAL_PLUGIN_CONFIG) as PortalBrandingConfig | null
72
+
73
+ /** The consumer-rights mail options declared with `declareConsumerRights`, or `null`. */
74
+ export const consumerMailConfig = async (ctx: ApiContext): Promise<ConsumerMailPluginConfig | null> =>
75
+ await (ctx as never as PluginReader).getConfigResource(PLUGINS).load(CONSUMER_RIGHTS_MAIL_PLUGIN_CONFIG) as ConsumerMailPluginConfig | null
76
+
77
+ /** The Stripe-only pricing settings declared with `declarePaymentPricing`, or `null`. */
78
+ export const stripePricingConfig = async (ctx: ApiContext): Promise<StripePricingPluginConfig | null> =>
79
+ await (ctx as never as PluginReader).getConfigResource(PLUGINS).load(STRIPE_PRICING_PLUGIN_CONFIG) as StripePricingPluginConfig | null
80
+
81
+ /**
82
+ * A Stripe client pinned to the API version the installed SDK is typed for (its default), so every
83
+ * object shape this package reads is the one the types describe.
84
+ */
27
85
  export const stripeClient = async (ctx: ApiContext): Promise<Stripe> => new Stripe((await stripeConfig(ctx)).api)
86
+
87
+ /**
88
+ * The API version a client was built with — read back from the client, never written as a literal,
89
+ * so a webhook endpoint always receives the payload shapes the client itself parses.
90
+ */
91
+ export const apiVersionOf = (stripe: Stripe): string => {
92
+ const version = (stripe as unknown as { getApiField?: (key: string) => unknown }).getApiField?.('version')
93
+ if (typeof version !== 'string' || version === '') {
94
+ throw new WebhookSetupError('api-version')
95
+ }
96
+
97
+ return version
98
+ }
99
+
100
+ export const isDuplicateKey = (error: unknown): boolean =>
101
+ (error as { code?: unknown } | null)?.code === MONGO_DUPLICATE_KEY
102
+
103
+ /** A paygate answer meaning the object does not exist (deleted, or never did). */
104
+ export const isMissingObject = (error: unknown): boolean => {
105
+ const typed = error as { code?: unknown; statusCode?: unknown; raw?: { code?: unknown } } | null
106
+ return typed?.code === 'resource_missing' || typed?.raw?.code === 'resource_missing' || typed?.statusCode === 404
107
+ }
108
+
109
+ /** A paygate object reference, expanded or not, as its id. */
110
+ export const idOf = (value: string | { id?: string } | null | undefined): string | undefined =>
111
+ value == null ? undefined : typeof value === 'string' ? value : value.id
112
+
113
+ /** Epoch seconds → `Date`. */
114
+ export const dateOf = (seconds: number | null | undefined): Date | undefined =>
115
+ seconds == null ? undefined : new Date(seconds * 1000)
116
+
117
+ /** Drop `null` and `undefined` properties — a stored record reads an absent field back as `null`. */
118
+ export const compact = <T extends object>(value: T): T =>
119
+ Object.fromEntries(Object.entries(value).filter(([, entry]) => entry != null)) as T
120
+
121
+ /** The entity's highest-ranked entitling subscription of one product. */
28
122
  export const activeSubscription = async (
29
123
  ctx: ApiContext, entityId: string, productSku: string,
30
124
  ): Promise<PaymentSubscriptionRecord | null> => await subscriptions(ctx).load({
31
- entityId, productSku, kind: 'subscription',
32
- status: [SubscriptionStatus.Active, SubscriptionStatus.Trial],
33
- })
125
+ entityId, productSku, status: [...ENTITLING_STATUSES],
126
+ }, { sort: [{ field: 'rank', order: 'desc' }, { field: 'createdAt', order: 'desc' }] })
127
+
128
+ /** A conditional single-document `$set` — `true` when the filter matched (the guard held). */
129
+ export const conditionalSet = async (
130
+ resource: { collection: unknown }, filter: Record<string, unknown>, set: Record<string, unknown>,
131
+ ): Promise<boolean> => {
132
+ const collection = resource.collection as {
133
+ updateOne: (filter: object, update: object) => Promise<{ matchedCount?: number, modifiedCount?: number }>
134
+ }
135
+ const result = await collection.updateOne(filter, { $set: set })
136
+
137
+ return (result.matchedCount ?? result.modifiedCount ?? 0) > 0
138
+ }
139
+
140
+ /** A conditional single-document delete — `true` when the filter matched (the guard held). */
141
+ export const conditionalDelete = async (resource: { collection: unknown }, filter: Record<string, unknown>): Promise<boolean> => {
142
+ const collection = resource.collection as { deleteOne: (filter: object) => Promise<{ deletedCount?: number }> }
143
+ const result = await collection.deleteOne(filter)
144
+
145
+ return (result.deletedCount ?? 0) > 0
146
+ }
147
+
148
+ /** An error's message for an audit record, never a stack. */
149
+ export const errorText = (error: unknown): string =>
150
+ (error instanceof Error ? error.message : String(error)).slice(0, 1000)