@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.
- package/README.md +109 -25
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/server-payment/SKILL.md +475 -52
- package/build/actions/index.d.ts +1 -0
- package/build/actions/index.d.ts.map +1 -1
- package/build/actions/index.js +1 -0
- package/build/actions/index.js.map +1 -1
- package/build/actions/resync-subscriptions.d.ts +3 -0
- package/build/actions/resync-subscriptions.d.ts.map +1 -0
- package/build/actions/resync-subscriptions.js +7 -0
- package/build/actions/resync-subscriptions.js.map +1 -0
- package/build/actions/resync.d.ts +1 -0
- package/build/actions/resync.d.ts.map +1 -1
- package/build/actions/resync.js +7 -3
- package/build/actions/resync.js.map +1 -1
- package/build/actions/webhook.d.ts.map +1 -1
- package/build/actions/webhook.js +6 -3
- package/build/actions/webhook.js.map +1 -1
- package/build/config.d.ts +60 -5
- package/build/config.d.ts.map +1 -1
- package/build/config.js +237 -5
- package/build/config.js.map +1 -1
- package/build/consts.d.ts +75 -3
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +94 -5
- package/build/consts.js.map +1 -1
- package/build/consumer/capture.d.ts +74 -0
- package/build/consumer/capture.d.ts.map +1 -0
- package/build/consumer/capture.js +291 -0
- package/build/consumer/capture.js.map +1 -0
- package/build/consumer/format.d.ts +27 -0
- package/build/consumer/format.d.ts.map +1 -0
- package/build/consumer/format.js +81 -0
- package/build/consumer/format.js.map +1 -0
- package/build/consumer/handlers.d.ts +28 -0
- package/build/consumer/handlers.d.ts.map +1 -0
- package/build/consumer/handlers.js +173 -0
- package/build/consumer/handlers.js.map +1 -0
- package/build/consumer/index.d.ts +7 -0
- package/build/consumer/index.d.ts.map +1 -0
- package/build/consumer/index.js +6 -0
- package/build/consumer/index.js.map +1 -0
- package/build/consumer/mail.d.ts +27 -0
- package/build/consumer/mail.d.ts.map +1 -0
- package/build/consumer/mail.js +314 -0
- package/build/consumer/mail.js.map +1 -0
- package/build/consumer/origin.d.ts +14 -0
- package/build/consumer/origin.d.ts.map +1 -0
- package/build/consumer/origin.js +47 -0
- package/build/consumer/origin.js.map +1 -0
- package/build/consumer/reconcile.d.ts +12 -0
- package/build/consumer/reconcile.d.ts.map +1 -0
- package/build/consumer/reconcile.js +317 -0
- package/build/consumer/reconcile.js.map +1 -0
- package/build/consumer/records.d.ts +78 -0
- package/build/consumer/records.d.ts.map +1 -0
- package/build/consumer/records.js +296 -0
- package/build/consumer/records.js.map +1 -0
- package/build/consumer/service.d.ts +51 -0
- package/build/consumer/service.d.ts.map +1 -0
- package/build/consumer/service.js +760 -0
- package/build/consumer/service.js.map +1 -0
- package/build/consumer/withdrawal.d.ts +57 -0
- package/build/consumer/withdrawal.d.ts.map +1 -0
- package/build/consumer/withdrawal.js +247 -0
- package/build/consumer/withdrawal.js.map +1 -0
- package/build/entitlement.d.ts +10 -0
- package/build/entitlement.d.ts.map +1 -0
- package/build/entitlement.js +69 -0
- package/build/entitlement.js.map +1 -0
- package/build/entrypoints.d.ts +1 -1
- package/build/entrypoints.d.ts.map +1 -1
- package/build/entrypoints.js +2 -1
- package/build/entrypoints.js.map +1 -1
- package/build/gate.d.ts +32 -4
- package/build/gate.d.ts.map +1 -1
- package/build/gate.js +59 -19
- package/build/gate.js.map +1 -1
- package/build/index.d.ts +18 -3
- package/build/index.d.ts.map +1 -1
- package/build/index.js +15 -3
- package/build/index.js.map +1 -1
- package/build/limit.d.ts +13 -0
- package/build/limit.d.ts.map +1 -0
- package/build/limit.js +47 -0
- package/build/limit.js.map +1 -0
- package/build/model.d.ts +10 -1
- package/build/model.d.ts.map +1 -1
- package/build/model.js +215 -17
- package/build/model.js.map +1 -1
- package/build/observer.d.ts +9 -0
- package/build/observer.d.ts.map +1 -1
- package/build/observer.js +38 -12
- package/build/observer.js.map +1 -1
- package/build/plan.d.ts +22 -0
- package/build/plan.d.ts.map +1 -0
- package/build/plan.js +72 -0
- package/build/plan.js.map +1 -0
- package/build/plugins/checkout-plugins.d.ts +49 -0
- package/build/plugins/checkout-plugins.d.ts.map +1 -0
- package/build/plugins/checkout-plugins.js +124 -0
- package/build/plugins/checkout-plugins.js.map +1 -0
- package/build/plugins/estimate.d.ts +43 -0
- package/build/plugins/estimate.d.ts.map +1 -0
- package/build/plugins/estimate.js +268 -0
- package/build/plugins/estimate.js.map +1 -0
- package/build/plugins/events.d.ts +45 -10
- package/build/plugins/events.d.ts.map +1 -1
- package/build/plugins/events.js +605 -136
- package/build/plugins/events.js.map +1 -1
- package/build/plugins/fx.d.ts +31 -0
- package/build/plugins/fx.d.ts.map +1 -0
- package/build/plugins/fx.js +81 -0
- package/build/plugins/fx.js.map +1 -0
- package/build/plugins/portal.d.ts +37 -0
- package/build/plugins/portal.d.ts.map +1 -0
- package/build/plugins/portal.js +265 -0
- package/build/plugins/portal.js.map +1 -0
- package/build/plugins/refunds.d.ts +33 -0
- package/build/plugins/refunds.d.ts.map +1 -0
- package/build/plugins/refunds.js +80 -0
- package/build/plugins/refunds.js.map +1 -0
- package/build/plugins/stripe.d.ts +36 -4
- package/build/plugins/stripe.d.ts.map +1 -1
- package/build/plugins/stripe.js +492 -97
- package/build/plugins/stripe.js.map +1 -1
- package/build/plugins/webhook-manager.d.ts +46 -0
- package/build/plugins/webhook-manager.d.ts.map +1 -0
- package/build/plugins/webhook-manager.js +231 -0
- package/build/plugins/webhook-manager.js.map +1 -0
- package/build/reconcile.d.ts +15 -0
- package/build/reconcile.d.ts.map +1 -0
- package/build/reconcile.js +88 -0
- package/build/reconcile.js.map +1 -0
- package/build/resource.d.ts +12 -1
- package/build/resource.d.ts.map +1 -1
- package/build/resource.js +97 -6
- package/build/resource.js.map +1 -1
- package/build/service.d.ts +28 -3
- package/build/service.d.ts.map +1 -1
- package/build/service.js +146 -19
- package/build/service.js.map +1 -1
- package/build/subscription.d.ts +48 -0
- package/build/subscription.d.ts.map +1 -0
- package/build/subscription.js +176 -0
- package/build/subscription.js.map +1 -0
- package/build/sync.d.ts +28 -1
- package/build/sync.d.ts.map +1 -1
- package/build/sync.js +208 -31
- package/build/sync.js.map +1 -1
- package/build/types.d.ts +1115 -36
- package/build/types.d.ts.map +1 -1
- package/build/usage.d.ts +63 -0
- package/build/usage.d.ts.map +1 -0
- package/build/usage.js +363 -0
- package/build/usage.js.map +1 -0
- package/build/utils.d.ts +52 -1
- package/build/utils.d.ts.map +1 -1
- package/build/utils.js +69 -7
- package/build/utils.js.map +1 -1
- package/package.json +17 -13
- package/src/actions/index.ts +1 -0
- package/src/actions/resync-subscriptions.ts +10 -0
- package/src/actions/resync.ts +6 -3
- package/src/actions/webhook.ts +5 -3
- package/src/config.ts +264 -8
- package/src/consts.ts +114 -6
- package/src/consumer/capture.ts +362 -0
- package/src/consumer/format.ts +90 -0
- package/src/consumer/handlers.ts +211 -0
- package/src/consumer/index.ts +6 -0
- package/src/consumer/mail.ts +368 -0
- package/src/consumer/origin.ts +63 -0
- package/src/consumer/reconcile.ts +329 -0
- package/src/consumer/records.ts +374 -0
- package/src/consumer/service.ts +868 -0
- package/src/consumer/withdrawal.ts +302 -0
- package/src/entitlement.ts +84 -0
- package/src/entrypoints.ts +2 -1
- package/src/gate.ts +88 -21
- package/src/index.ts +24 -3
- package/src/limit.ts +57 -0
- package/src/model.ts +237 -18
- package/src/observer.ts +44 -11
- package/src/plan.ts +89 -0
- package/src/plugins/checkout-plugins.ts +155 -0
- package/src/plugins/estimate.ts +339 -0
- package/src/plugins/events.ts +677 -121
- package/src/plugins/fx.ts +122 -0
- package/src/plugins/portal.ts +306 -0
- package/src/plugins/refunds.ts +108 -0
- package/src/plugins/stripe.ts +581 -96
- package/src/plugins/webhook-manager.ts +270 -0
- package/src/reconcile.ts +103 -0
- package/src/resource.ts +152 -7
- package/src/service.ts +174 -18
- package/src/subscription.ts +231 -0
- package/src/sync.ts +249 -29
- package/src/types.ts +1227 -32
- package/src/usage.ts +453 -0
- package/src/utils.ts +127 -10
- package/tests/checkout-consumer.spec.ts +348 -0
- package/tests/checkout-plugins.spec.ts +164 -0
- package/tests/checkout.spec.ts +184 -83
- package/tests/consumer-events.spec.ts +218 -0
- package/tests/consumer-fixtures.ts +132 -0
- package/tests/consumer-ops.spec.ts +351 -0
- package/tests/consumer-rights.integration.spec.ts +150 -0
- package/tests/consumer-rights.spec.ts +501 -0
- package/tests/context.ts +109 -0
- package/tests/entitlement.spec.ts +103 -0
- package/tests/estimate.spec.ts +240 -0
- package/tests/events.spec.ts +356 -0
- package/tests/fake-stripe.ts +972 -0
- package/tests/gate.spec.ts +62 -72
- package/tests/limit-gate.spec.ts +68 -0
- package/tests/portal.spec.ts +200 -0
- package/tests/protocol.spec.ts +23 -6
- package/tests/sync.spec.ts +114 -0
- package/tests/usage.integration.spec.ts +101 -0
- package/tests/usage.spec.ts +171 -0
- 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 {
|
|
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
|
-
|
|
8
|
-
|
|
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
|
-
|
|
12
|
-
|
|
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
|
|
26
|
-
|
|
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,
|
|
32
|
-
|
|
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)
|