@drawbridge/drawbridge-utils 0.0.65 → 0.0.67

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.
@@ -0,0 +1,373 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
5
+ var __export = (target, all) => {
6
+ for (var name in all)
7
+ __defProp(target, name, { get: all[name], enumerable: true });
8
+ };
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") {
11
+ for (let key of __getOwnPropNames(from))
12
+ if (!__hasOwnProp.call(to, key) && key !== except)
13
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
14
+ }
15
+ return to;
16
+ };
17
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
18
+
19
+ // lib/billing.js
20
+ var billing_exports = {};
21
+ __export(billing_exports, {
22
+ MARKUP: () => MARKUP,
23
+ action: () => action,
24
+ ai: () => ai,
25
+ grant: () => grant,
26
+ scrape: () => scrape
27
+ });
28
+ module.exports = __toCommonJS(billing_exports);
29
+
30
+ // lib/transactions.js
31
+ var import_drawbridge_telemetry = require("@drawbridge/drawbridge-telemetry");
32
+ var insertTransaction = async ({
33
+ db,
34
+ user,
35
+ type,
36
+ category,
37
+ source,
38
+ amount,
39
+ _id,
40
+ stripeInvoiceId,
41
+ stripeEventId,
42
+ session,
43
+ ...rest
44
+ }) => {
45
+ var _a;
46
+ const beforeRaw = (_a = user == null ? void 0 : user.balance) == null ? void 0 : _a[type];
47
+ const balance = typeof beforeRaw === "number" ? { before: beforeRaw, after: beforeRaw + amount } : void 0;
48
+ const trace = (0, import_drawbridge_telemetry.currentTraceId)();
49
+ await db.create({
50
+ authenticated: user,
51
+ collection: "transaction",
52
+ data: {
53
+ ..._id && { _id },
54
+ user: user == null ? void 0 : user.id,
55
+ type,
56
+ category,
57
+ source,
58
+ amount,
59
+ ...balance && { balance },
60
+ ...trace && { trace },
61
+ ...rest,
62
+ ...stripeInvoiceId && { stripeInvoiceId },
63
+ ...stripeEventId && { stripeEventId }
64
+ },
65
+ ...session && { options: { session } }
66
+ });
67
+ };
68
+ var credit = async ({
69
+ db,
70
+ user,
71
+ amount,
72
+ type,
73
+ category,
74
+ source,
75
+ _id,
76
+ stripeInvoiceId,
77
+ stripeEventId,
78
+ session,
79
+ ...rest
80
+ }) => {
81
+ if (!Number.isInteger(amount) || amount <= 0) {
82
+ throw new Error(`credit() requires a positive integer amount, got ${amount}`);
83
+ }
84
+ if (!type) {
85
+ throw new Error('credit() requires a type (e.g., "ai")');
86
+ }
87
+ if (!source) {
88
+ throw new Error('credit() requires a source ("system" | "admin" | "user")');
89
+ }
90
+ await insertTransaction({
91
+ db,
92
+ user,
93
+ type,
94
+ category,
95
+ source,
96
+ amount,
97
+ _id,
98
+ stripeInvoiceId,
99
+ stripeEventId,
100
+ session,
101
+ ...rest
102
+ });
103
+ };
104
+ var debit = async ({
105
+ db,
106
+ user,
107
+ amount,
108
+ type,
109
+ category,
110
+ source,
111
+ stripeInvoiceId,
112
+ stripeEventId,
113
+ session,
114
+ ...rest
115
+ }) => {
116
+ if (!Number.isInteger(amount) || amount <= 0) {
117
+ throw new Error(`debit() requires a positive integer amount, got ${amount}`);
118
+ }
119
+ if (!type) {
120
+ throw new Error('debit() requires a type (e.g., "ai")');
121
+ }
122
+ if (!source) {
123
+ throw new Error('debit() requires a source ("system" | "admin" | "user")');
124
+ }
125
+ await insertTransaction({
126
+ db,
127
+ user,
128
+ type,
129
+ category,
130
+ source,
131
+ amount: -amount,
132
+ stripeInvoiceId,
133
+ stripeEventId,
134
+ session,
135
+ ...rest
136
+ });
137
+ };
138
+
139
+ // lib/usage.js
140
+ var incrementUsageTotals = async ({ controller, usageId, $inc, session }) => {
141
+ if (!usageId || !$inc) return;
142
+ const safeInc = Object.fromEntries(
143
+ Object.entries($inc).filter(([, value]) => {
144
+ const number = Number(value);
145
+ return Number.isFinite(number) && number !== 0;
146
+ })
147
+ );
148
+ if (Object.keys(safeInc).length === 0) return;
149
+ return controller.update({
150
+ collection: "usage",
151
+ data: { $inc: safeInc },
152
+ options: {
153
+ bypassDocumentValidation: true,
154
+ ...session && { session }
155
+ },
156
+ query: { id: usageId }
157
+ });
158
+ };
159
+ var isDuplicateKey = (error) => (error == null ? void 0 : error.code) === 11e3 || (error == null ? void 0 : error.code) === "11000" || /E11000|duplicate key/i.test((error == null ? void 0 : error.message) || "");
160
+ var recordAction = async ({ controller, organization, usageId, type, units = 1, key, meta = {}, traceId, session }) => {
161
+ if (!controller || !organization || !usageId || !type || !key) return;
162
+ const amount = Number(units);
163
+ if (!Number.isFinite(amount) || amount === 0) return;
164
+ let inserted = false;
165
+ try {
166
+ const { lastErrorObject } = await controller.update({
167
+ collection: "action",
168
+ data: {
169
+ $setOnInsert: {
170
+ organization,
171
+ usage: usageId,
172
+ type,
173
+ units: amount,
174
+ key,
175
+ meta,
176
+ ...traceId && { traceId }
177
+ }
178
+ },
179
+ options: {
180
+ bypassDocumentValidation: true,
181
+ includeResultMetadata: true,
182
+ upsert: true,
183
+ ...session && { session }
184
+ },
185
+ query: {
186
+ key,
187
+ usage: usageId
188
+ }
189
+ });
190
+ inserted = Boolean(lastErrorObject == null ? void 0 : lastErrorObject.upserted);
191
+ } catch (error) {
192
+ if (isDuplicateKey(error) && !session) return;
193
+ throw error;
194
+ }
195
+ if (inserted) {
196
+ await incrementUsageTotals({ controller, usageId, $inc: { "totals.actions": amount }, session });
197
+ }
198
+ };
199
+
200
+ // lib/billing.js
201
+ var import_drawbridge_telemetry2 = require("@drawbridge/drawbridge-telemetry");
202
+ var logger = (0, import_drawbridge_telemetry2.createLogger)();
203
+ var MARKUP = 1.3;
204
+ var cost = {
205
+ "gemini-2.5-flash": {
206
+ cached: 3,
207
+ input: 30,
208
+ output: 250
209
+ },
210
+ "gemini-2.5-flash-image": {
211
+ cached: 3,
212
+ input: 30,
213
+ output: 3e3
214
+ }
215
+ };
216
+ var toolCost = {
217
+ search: 3.5
218
+ };
219
+ var toolPricing = Object.fromEntries(
220
+ Object.entries(toolCost).map(([tool, value]) => [
221
+ tool,
222
+ Math.ceil(value * MARKUP)
223
+ ])
224
+ );
225
+ var pricing = Object.fromEntries(
226
+ Object.entries(cost).map(([model, rates]) => [
227
+ model,
228
+ {
229
+ cached: Math.round(rates.cached * MARKUP),
230
+ input: Math.round(rates.input * MARKUP),
231
+ output: Math.round(rates.output * MARKUP)
232
+ }
233
+ ])
234
+ );
235
+ var sumCents = (rates, tokens) => Math.ceil(
236
+ (Number((tokens == null ? void 0 : tokens.cached) || 0) * rates.cached + Number((tokens == null ? void 0 : tokens.input) || 0) * rates.input + (Number((tokens == null ? void 0 : tokens.output) || 0) + Number((tokens == null ? void 0 : tokens.thinking) || 0)) * rates.output) / 1e6
237
+ );
238
+ var sumToolCents = (table, tools) => Object.entries(tools || {}).reduce(
239
+ (total, [name, used]) => total + (used && table[name] ? table[name] : 0),
240
+ 0
241
+ );
242
+ var priceForRequest = (model, usage, tools) => {
243
+ const rates = pricing[model];
244
+ if (!rates) {
245
+ throw new Error(`Unknown model for pricing: ${model}`);
246
+ }
247
+ return sumCents(rates, usage) + Math.ceil(sumToolCents(toolPricing, tools));
248
+ };
249
+ var costForRequest = (model, usage, tools) => {
250
+ const rates = cost[model];
251
+ if (!rates) {
252
+ throw new Error(`Unknown model for cost: ${model}`);
253
+ }
254
+ return sumCents(rates, usage) + Math.ceil(sumToolCents(toolCost, tools));
255
+ };
256
+ var billRequest = async ({ db, user, model, usage, tools }) => {
257
+ const gross = priceForRequest(model, usage, tools);
258
+ const cogs = costForRequest(model, usage, tools);
259
+ await debit({
260
+ db,
261
+ user,
262
+ amount: gross,
263
+ type: "ai",
264
+ category: "usage",
265
+ source: "user",
266
+ ai: {
267
+ name: "google",
268
+ model,
269
+ tokens: usage,
270
+ tools,
271
+ rates: {
272
+ wholesale: cost[model],
273
+ retail: pricing[model]
274
+ },
275
+ totals: {
276
+ cost: cogs,
277
+ net: gross - cogs,
278
+ gross
279
+ }
280
+ }
281
+ });
282
+ };
283
+ var ai = {
284
+ // Retail cents for a request (what the user pays).
285
+ price: priceForRequest,
286
+ // Wholesale cents for a request (what we pay the provider).
287
+ cost: costForRequest,
288
+ // Deferred bill() the caller fires on success, so the debit lands only when
289
+ // the request succeeds and bundles into that request's Sentry trace. No user
290
+ // → a no-op.
291
+ bill: ({ db, user, model, usage, tools }) => (user == null ? void 0 : user.id) ? () => billRequest({ db, user, model, usage, tools }) : () => Promise.resolve()
292
+ };
293
+ var STANDARD_RATE_PER_GB = 8;
294
+ var PREMIUM_RATE_PER_GB = 11;
295
+ var PREMIUM_HOSTS = /* @__PURE__ */ new Set();
296
+ var LOCAL_FLAT_CENTS = 1;
297
+ var REUSE_FLAT_CENTS = 1;
298
+ var ratePerGB = (url) => {
299
+ try {
300
+ return PREMIUM_HOSTS.has(new URL(url).hostname) ? PREMIUM_RATE_PER_GB : STANDARD_RATE_PER_GB;
301
+ } catch {
302
+ return STANDARD_RATE_PER_GB;
303
+ }
304
+ };
305
+ var scrapeFee = ({ provider, bytes, url, cold }) => {
306
+ if (!cold) return REUSE_FLAT_CENTS;
307
+ if (provider === "brightdata") {
308
+ return Math.ceil((bytes || 0) / 1e9 * ratePerGB(url) * 100 * MARKUP);
309
+ }
310
+ return LOCAL_FLAT_CENTS;
311
+ };
312
+ var scrape = {
313
+ // Cents for a scrape given its provider/bytes/cold basis.
314
+ fee: scrapeFee,
315
+ // Deferred bill(scrapeId) the scrape store's resolve() hands the generation
316
+ // flow. Atomically claims the cold (first-time) fee via the `charged` flag so
317
+ // a BrightData scrape's metered cost is recovered ONCE; later reuses pay the
318
+ // flat rate. Best-effort — a billing hiccup never fails generation.
319
+ bill: ({ controller, user, page, url }) => async (scrapeId) => {
320
+ var _a;
321
+ if (!(user == null ? void 0 : user.id) || !scrapeId) return;
322
+ try {
323
+ const claim = await controller.update({
324
+ collection: "scrape",
325
+ data: { $set: { charged: true } },
326
+ options: { bypassDocumentValidation: true },
327
+ query: { id: scrapeId, charged: { $ne: true } }
328
+ });
329
+ const cents = scrapeFee({
330
+ provider: (_a = page == null ? void 0 : page.provider) == null ? void 0 : _a.name,
331
+ bytes: page == null ? void 0 : page.bytes,
332
+ url: (page == null ? void 0 : page.url) || url,
333
+ cold: Boolean(claim == null ? void 0 : claim.value)
334
+ });
335
+ if (cents > 0) await debit({ db: controller, user, amount: cents, type: "ai", category: "usage", source: "user" });
336
+ } catch (error) {
337
+ logger.error(error, { extra: { action: "generation.scrape.bill", scrapeId } });
338
+ }
339
+ }
340
+ };
341
+ var grant = {
342
+ // System-issued credit (signup welcome, migration catch-up). Pass `session`
343
+ // to enroll the credit in the caller's transaction.
344
+ bonus: ({ db, user, amount, session }) => credit({ db, user, amount, type: "ai", category: "promotional", source: "system", ...session && { session } }),
345
+ // Admin-issued credit (manual grant). `user` is the recipient, not the admin.
346
+ admin: ({ db, user, amount }) => credit({ db, user, amount, type: "ai", category: "promotional", source: "admin" })
347
+ };
348
+ var action = {
349
+ // Append a billable action (idempotent on { usage, key }) + bump the
350
+ // usage.totals.actions display cache. The proven recordAction primitive.
351
+ record: recordAction,
352
+ // Authoritative per-period total to bill — sum(units) over the period's ledger
353
+ // rows. The Stripe meter emit (in the Stripe/sync layer) reports this number.
354
+ sum: async ({ controller, usageId }) => {
355
+ if (!controller || !usageId) return 0;
356
+ const [row] = await controller.aggregate({
357
+ collection: "action",
358
+ pipeline: [
359
+ { $match: { usage: usageId } },
360
+ { $group: { _id: null, units: { $sum: "$units" } } }
361
+ ]
362
+ }) || [];
363
+ return (row == null ? void 0 : row.units) || 0;
364
+ }
365
+ };
366
+ // Annotate the CommonJS export names for ESM import in node:
367
+ 0 && (module.exports = {
368
+ MARKUP,
369
+ action,
370
+ ai,
371
+ grant,
372
+ scrape
373
+ });
@@ -0,0 +1,300 @@
1
+ import { credit, debit } from './transactions.cjs';
2
+ import { recordAction } from './usage.cjs';
3
+ import { createLogger } from '@drawbridge/drawbridge-telemetry';
4
+
5
+ // Billing — the single source of truth for what Drawbridge charges, and the
6
+ // single public surface for moving money. Namespaced by what's billed, each noun
7
+ // exposing the pure fee math and a deferred bill()/credit:
8
+ //
9
+ // billing.ai { price, cost, bill } AI requests → credit rail (debit)
10
+ // billing.scrape { fee, bill } scrape fees → credit rail (debit)
11
+ // billing.grant { bonus, admin } promo credits → credit rail (credit)
12
+ // billing.action { record, sum } usage actions → Stripe meter rail
13
+ //
14
+ // Sits on top of transactions.js (the generic credit/debit money rail) and
15
+ // usage.js (the action-ledger + usage-totals primitives). Shopify order fees are
16
+ // NOT billed here — they meter through @drawbridge/shopify's recordUsage and stay
17
+ // Shopify-only by design. The Stripe meter EMIT for actions also stays in the
18
+ // Stripe layer (utils must not depend on Stripe); billing.action.sum gives that
19
+ // layer the authoritative number to report.
20
+
21
+
22
+ const logger = createLogger();
23
+
24
+ // Markup multiplier applied to provider cost to produce the retail price.
25
+ // 1.3 = 30% margin. Change one number, all pricing updates. Shared across rails
26
+ // (AI tokens + the metered scrape fee).
27
+ const MARKUP = 1.3;
28
+
29
+ // ─── AI (credit rail) ─────────────────────────────────────────────────────────
30
+
31
+ // Raw per-model provider rates in cents per 1,000,000 tokens — the wholesale
32
+ // cost. Sync from LiteLLM. Never apply markup here; `pricing` derives retail.
33
+ const cost = {
34
+ 'gemini-2.5-flash' : {
35
+ cached : 3,
36
+ input : 30,
37
+ output : 250
38
+ },
39
+ 'gemini-2.5-flash-image' : {
40
+ cached : 3,
41
+ input : 30,
42
+ output : 3000
43
+ }
44
+ };
45
+
46
+ // Flat per-request fees Google charges when specific tools fire. Search
47
+ // grounding is $35/1k requests → 3.5 cents/request, billed only when the tool
48
+ // actually ran. Add more as Google introduces them.
49
+ const toolCost = {
50
+ search : 3.5
51
+ };
52
+
53
+ const toolPricing = Object.fromEntries(
54
+ Object.entries( toolCost ).map( ( [ tool, value ] ) => [
55
+ tool,
56
+ Math.ceil( value * MARKUP )
57
+ ] )
58
+ );
59
+
60
+ // Derived retail pricing in cents per 1,000,000 tokens — what users pay.
61
+ const pricing = Object.fromEntries(
62
+ Object.entries( cost ).map( ( [ model, rates ] ) => [
63
+ model,
64
+ {
65
+ cached : Math.round( rates.cached * MARKUP ),
66
+ input : Math.round( rates.input * MARKUP ),
67
+ output : Math.round( rates.output * MARKUP )
68
+ }
69
+ ] )
70
+ );
71
+
72
+ // Thinking tokens (Gemini 2.5 internal reasoning) bill at the output rate, so
73
+ // they roll into the same multiplier even though they're tracked separately.
74
+ const sumCents = ( rates, tokens ) => Math.ceil(
75
+ (
76
+ Number( tokens?.cached || 0 ) * rates.cached +
77
+ Number( tokens?.input || 0 ) * rates.input +
78
+ (
79
+ Number( tokens?.output || 0 ) +
80
+ Number( tokens?.thinking || 0 )
81
+ ) * rates.output
82
+ ) / 1_000_000
83
+ );
84
+
85
+ // Sum the per-request flat fees for the tools that actually fired.
86
+ const sumToolCents = ( table, tools ) => Object.entries( tools || {} ).reduce(
87
+ ( total, [ name, used ] ) => total + ( used && table[ name ] ? table[ name ] : 0 ),
88
+ 0
89
+ );
90
+
91
+ // What we charge the user, rounded up so we never under-bill. Throws on an
92
+ // unknown model so a typo doesn't silently fall back to a wrong price.
93
+ const priceForRequest = ( model, usage, tools ) => {
94
+
95
+ const rates = pricing[ model ];
96
+
97
+ if( !rates ){
98
+
99
+ throw new Error( `Unknown model for pricing: ${ model }` );
100
+
101
+ }
102
+
103
+ return sumCents( rates, usage ) + Math.ceil( sumToolCents( toolPricing, tools ) );
104
+
105
+ };
106
+
107
+ // What we pay the provider. Recorded on each transaction row so margin is
108
+ // computable per request without backfilling from git history.
109
+ const costForRequest = ( model, usage, tools ) => {
110
+
111
+ const rates = cost[ model ];
112
+
113
+ if( !rates ){
114
+
115
+ throw new Error( `Unknown model for cost: ${ model }` );
116
+
117
+ }
118
+
119
+ return sumCents( rates, usage ) + Math.ceil( sumToolCents( toolCost, tools ) );
120
+
121
+ };
122
+
123
+ // Compute gross + cost from response usage and atomically debit the user.
124
+ // Records the full income-statement line on the transaction row's `ai` sub-doc:
125
+ // wholesale + retail per-1M rate snapshots, plus rolled-up cost, net, and gross.
126
+ const billRequest = async ({ db, user, model, usage, tools }) => {
127
+
128
+ const gross = priceForRequest( model, usage, tools );
129
+ const cogs = costForRequest( model, usage, tools );
130
+
131
+ await debit({
132
+ db,
133
+ user,
134
+ amount : gross,
135
+ type : 'ai',
136
+ category : 'usage',
137
+ source : 'user',
138
+ ai : {
139
+ name : 'google',
140
+ model,
141
+ tokens : usage,
142
+ tools,
143
+ rates : {
144
+ wholesale : cost[ model ],
145
+ retail : pricing[ model ]
146
+ },
147
+ totals : {
148
+ cost : cogs,
149
+ net : gross - cogs,
150
+ gross
151
+ }
152
+ }
153
+ });
154
+
155
+ };
156
+
157
+ const ai = {
158
+
159
+ // Retail cents for a request (what the user pays).
160
+ price : priceForRequest,
161
+
162
+ // Wholesale cents for a request (what we pay the provider).
163
+ cost : costForRequest,
164
+
165
+ // Deferred bill() the caller fires on success, so the debit lands only when
166
+ // the request succeeds and bundles into that request's Sentry trace. No user
167
+ // → a no-op.
168
+ bill : ({ db, user, model, usage, tools }) =>
169
+ user?.id
170
+ ? () => billRequest({ db, user, model, usage, tools })
171
+ : () => Promise.resolve()
172
+
173
+ };
174
+
175
+ // ─── Scrape (credit rail) ──────────────────────────────────────────────────────
176
+
177
+ // BrightData (the Scraping Browser fallback) bills by GB, so its fee is METERED
178
+ // to the traffic measured at scrape time; the 30% margin matches MARKUP above. A
179
+ // cold local render and a cache reuse have no external cost, so they're small
180
+ // flat fees (set to 0 to make either free). Tune against real BrightData invoices.
181
+ const STANDARD_RATE_PER_GB = 8;
182
+ const PREMIUM_RATE_PER_GB = 11;
183
+ const PREMIUM_HOSTS = new Set();
184
+ const LOCAL_FLAT_CENTS = 1;
185
+ const REUSE_FLAT_CENTS = 1;
186
+
187
+ const ratePerGB = ( url ) => {
188
+
189
+ try { return PREMIUM_HOSTS.has( new URL( url ).hostname ) ? PREMIUM_RATE_PER_GB : STANDARD_RATE_PER_GB; }
190
+ catch { return STANDARD_RATE_PER_GB; }
191
+
192
+ };
193
+
194
+ // Cents to charge. `cold` = the first generation off this scrape (charge the real
195
+ // cost: metered for BrightData, a flat for a local render); otherwise it's a reuse
196
+ // (flat). Rounded up so we never under-bill a fractional cent.
197
+ const scrapeFee = ({ provider, bytes, url, cold }) => {
198
+
199
+ if( ! cold ) return REUSE_FLAT_CENTS;
200
+
201
+ if( provider === 'brightdata' ){
202
+
203
+ return Math.ceil( ( ( bytes || 0 ) / 1e9 ) * ratePerGB( url ) * 100 * MARKUP );
204
+
205
+ }
206
+
207
+ return LOCAL_FLAT_CENTS;
208
+
209
+ };
210
+
211
+ const scrape = {
212
+
213
+ // Cents for a scrape given its provider/bytes/cold basis.
214
+ fee : scrapeFee,
215
+
216
+ // Deferred bill(scrapeId) the scrape store's resolve() hands the generation
217
+ // flow. Atomically claims the cold (first-time) fee via the `charged` flag so
218
+ // a BrightData scrape's metered cost is recovered ONCE; later reuses pay the
219
+ // flat rate. Best-effort — a billing hiccup never fails generation.
220
+ bill : ({ controller, user, page, url }) => async ( scrapeId ) => {
221
+
222
+ if( ! user?.id || ! scrapeId ) return;
223
+
224
+ try {
225
+
226
+ const claim = await controller.update({
227
+ collection : 'scrape',
228
+ data : { $set : { charged : true } },
229
+ options : { bypassDocumentValidation : true },
230
+ query : { id : scrapeId, charged : { $ne : true } }
231
+ });
232
+
233
+ const cents = scrapeFee({
234
+ provider : page?.provider?.name,
235
+ bytes : page?.bytes,
236
+ url : page?.url || url,
237
+ cold : Boolean( claim?.value )
238
+ });
239
+
240
+ if( cents > 0 ) await debit({ db : controller, user, amount : cents, type : 'ai', category : 'usage', source : 'user' });
241
+
242
+ } catch ( error ) {
243
+
244
+ logger.error( error, { extra : { action : 'generation.scrape.bill', scrapeId } } );
245
+
246
+ }
247
+
248
+ }
249
+
250
+ };
251
+
252
+ // ─── Grants (credit rail) ──────────────────────────────────────────────────────
253
+
254
+ // Promotional credits to a user's AI balance. `source` captures who acted; the
255
+ // stream listener on the transaction collection does the balance.ai $inc.
256
+ const grant = {
257
+
258
+ // System-issued credit (signup welcome, migration catch-up). Pass `session`
259
+ // to enroll the credit in the caller's transaction.
260
+ bonus : ({ db, user, amount, session }) =>
261
+ credit({ db, user, amount, type : 'ai', category : 'promotional', source : 'system', ...( session && { session }) }),
262
+
263
+ // Admin-issued credit (manual grant). `user` is the recipient, not the admin.
264
+ admin : ({ db, user, amount }) =>
265
+ credit({ db, user, amount, type : 'ai', category : 'promotional', source : 'admin' })
266
+
267
+ };
268
+
269
+ // ─── Actions (usage meter rail) ────────────────────────────────────────────────
270
+
271
+ // The metered-usage rail (distinct from the AI credit rail above): an append-only
272
+ // `action` ledger keyed on organization/period, summed at changeover and reported
273
+ // to the Stripe meter. See the action-ledger design.
274
+ const action = {
275
+
276
+ // Append a billable action (idempotent on { usage, key }) + bump the
277
+ // usage.totals.actions display cache. The proven recordAction primitive.
278
+ record : recordAction,
279
+
280
+ // Authoritative per-period total to bill — sum(units) over the period's ledger
281
+ // rows. The Stripe meter emit (in the Stripe/sync layer) reports this number.
282
+ sum : async ({ controller, usageId }) => {
283
+
284
+ if( ! controller || ! usageId ) return 0;
285
+
286
+ const [ row ] = await controller.aggregate({
287
+ collection : 'action',
288
+ pipeline : [
289
+ { $match : { usage : usageId } },
290
+ { $group : { _id : null, units : { $sum : '$units' } } }
291
+ ]
292
+ }) || [];
293
+
294
+ return row?.units || 0;
295
+
296
+ }
297
+
298
+ };
299
+
300
+ export { MARKUP, action, ai, grant, scrape };