@drawbridge/drawbridge-utils 0.0.29 → 0.0.32

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.
@@ -19,37 +19,38 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
19
19
  // transactions.js
20
20
  var transactions_exports = {};
21
21
  __export(transactions_exports, {
22
- InsufficientCreditsError: () => InsufficientCreditsError,
23
22
  credit: () => credit,
24
23
  debit: () => debit
25
24
  });
26
25
  module.exports = __toCommonJS(transactions_exports);
27
- var recordTransaction = async ({
26
+ var insertTransaction = async ({
28
27
  db,
29
- authenticated,
30
- userId,
28
+ user,
29
+ type,
31
30
  category,
32
- tags,
31
+ source,
33
32
  amount,
34
- balance,
35
- provider,
33
+ _id,
36
34
  stripeInvoiceId,
37
35
  stripeEventId,
38
- session
36
+ session,
37
+ ...rest
39
38
  }) => {
39
+ var _a;
40
+ const beforeRaw = (_a = user == null ? void 0 : user.balance) == null ? void 0 : _a[type];
41
+ const balance = typeof beforeRaw === "number" ? { before: beforeRaw, after: beforeRaw + amount } : void 0;
40
42
  await db.create({
41
- authenticated,
43
+ authenticated: user,
42
44
  collection: "transaction",
43
45
  data: {
44
- userId,
45
- billable: {
46
- item: "ai"
47
- },
46
+ ..._id && { _id },
47
+ userId: user == null ? void 0 : user.id,
48
+ type,
48
49
  category,
49
- tags,
50
+ source,
50
51
  amount,
51
- balance,
52
- ...provider && { provider },
52
+ ...balance && { balance },
53
+ ...rest,
53
54
  ...stripeInvoiceId && { stripeInvoiceId },
54
55
  ...stripeEventId && { stripeEventId }
55
56
  },
@@ -58,130 +59,76 @@ var recordTransaction = async ({
58
59
  };
59
60
  var credit = async ({
60
61
  db,
61
- authenticated,
62
- userId,
62
+ user,
63
63
  amount,
64
+ type,
64
65
  category,
65
- tags = [],
66
+ source,
67
+ _id,
66
68
  stripeInvoiceId,
67
69
  stripeEventId,
68
- session
70
+ session,
71
+ ...rest
69
72
  }) => {
70
- var _a, _b, _c;
71
73
  if (!Number.isInteger(amount) || amount <= 0) {
72
74
  throw new Error(`credit() requires a positive integer amount, got ${amount}`);
73
75
  }
74
- const result = await db.update({
75
- authenticated,
76
- collection: "user",
77
- query: {
78
- id: userId
79
- },
80
- data: {
81
- $inc: {
82
- "balance.ai": amount
83
- }
84
- },
85
- ...session && { options: { session } }
86
- });
87
- const after = ((_b = (_a = result == null ? void 0 : result.value) == null ? void 0 : _a.balance) == null ? void 0 : _b.ai) ?? ((_c = result == null ? void 0 : result.balance) == null ? void 0 : _c.ai);
88
- if (typeof after !== "number") {
89
- throw new Error(`credit() could not read updated balance for user ${userId}`);
76
+ if (!type) {
77
+ throw new Error('credit() requires a type (e.g., "ai")');
90
78
  }
91
- const before = after - amount;
92
- await recordTransaction({
79
+ if (!source) {
80
+ throw new Error('credit() requires a source ("system" | "admin" | "user")');
81
+ }
82
+ await insertTransaction({
93
83
  db,
94
- authenticated,
95
- userId,
84
+ user,
85
+ type,
96
86
  category,
97
- tags,
87
+ source,
98
88
  amount,
99
- balance: {
100
- before,
101
- after
102
- },
89
+ _id,
103
90
  stripeInvoiceId,
104
91
  stripeEventId,
105
- session
92
+ session,
93
+ ...rest
106
94
  });
107
- return {
108
- balance: {
109
- before,
110
- after
111
- }
112
- };
113
- };
114
- var InsufficientCreditsError = class extends Error {
115
- constructor(message = "Insufficient AI credits") {
116
- super(message);
117
- this.name = "InsufficientCreditsError";
118
- this.status = 402;
119
- }
120
95
  };
121
96
  var debit = async ({
122
97
  db,
123
- authenticated,
124
- userId,
98
+ user,
125
99
  amount,
100
+ type,
126
101
  category,
127
- tags = [],
128
- provider,
102
+ source,
129
103
  stripeInvoiceId,
130
104
  stripeEventId,
131
- session
105
+ session,
106
+ ...rest
132
107
  }) => {
133
- var _a, _b, _c;
134
108
  if (!Number.isInteger(amount) || amount <= 0) {
135
109
  throw new Error(`debit() requires a positive integer amount, got ${amount}`);
136
110
  }
137
- const result = await db.update({
138
- authenticated,
139
- collection: "user",
140
- query: {
141
- id: userId,
142
- "balance.ai": {
143
- $gte: amount
144
- }
145
- },
146
- data: {
147
- $inc: {
148
- "balance.ai": -amount
149
- }
150
- },
151
- ...session && { options: { session } }
152
- });
153
- const after = ((_b = (_a = result == null ? void 0 : result.value) == null ? void 0 : _a.balance) == null ? void 0 : _b.ai) ?? ((_c = result == null ? void 0 : result.balance) == null ? void 0 : _c.ai);
154
- if (typeof after !== "number") {
155
- throw new InsufficientCreditsError();
111
+ if (!type) {
112
+ throw new Error('debit() requires a type (e.g., "ai")');
113
+ }
114
+ if (!source) {
115
+ throw new Error('debit() requires a source ("system" | "admin" | "user")');
156
116
  }
157
- const before = after + amount;
158
- await recordTransaction({
117
+ await insertTransaction({
159
118
  db,
160
- authenticated,
161
- userId,
119
+ user,
120
+ type,
162
121
  category,
163
- tags,
122
+ source,
164
123
  amount: -amount,
165
- balance: {
166
- before,
167
- after
168
- },
169
- provider,
170
124
  stripeInvoiceId,
171
125
  stripeEventId,
172
- session
126
+ session,
127
+ ...rest
173
128
  });
174
- return {
175
- amount: -amount,
176
- balance: {
177
- before,
178
- after
179
- }
180
- };
181
129
  };
182
130
  // Annotate the CommonJS export names for ESM import in node:
183
131
  0 && (module.exports = {
184
- InsufficientCreditsError,
185
132
  credit,
186
133
  debit
187
134
  });
@@ -1,56 +1,62 @@
1
- // Generic ledger primitivesauthoritative balance for user.balance.ai lives
2
- // in Mongo, and every change to that balance is recorded in the `transaction`
3
- // collection. Domain-specific helpers (e.g., LLM pricing in ./ai.js) call into
4
- // credit/debit with pre-computed amounts.
1
+ // Ledger transaction insert helpers no balance writes happen here.
2
+ //
3
+ // User-balance updates are the exclusive job of drawbridge-sync's
4
+ // stream/transaction.js change-stream listener: every insert into the
5
+ // `transaction` collection triggers an atomic $inc on user.balance.<type>
6
+ // (guarded against going negative). This decouples the API surface from
7
+ // the balance arithmetic and makes the `transaction` collection the single
8
+ // source of truth for both audit history and balance derivation.
5
9
  //
6
10
  // import { credit, debit } from '@drawbridge/drawbridge-utils/transactions';
7
11
  //
8
- // await credit({ db, authenticated, userId, amount: 500,
9
- // category: 'promotional', tags: [ 'welcome' ] });
12
+ // await credit({ db, user, amount: 500,
13
+ // type: 'ai', category: 'promotional', source: 'system' });
10
14
  //
11
- // await debit({ db, authenticated, userId, amount: 9,
12
- // category: 'usage', tags: [ 'google', 'gemini-2.5-flash' ],
13
- // provider: { name, model, tokens, snapshot } });
15
+ // await debit({ db, user, amount: 6,
16
+ // type: 'ai', category: 'usage', source: 'user',
17
+ // ai: { name, model, tokens, rates, totals } });
14
18
  //
15
- // `db` is the wrapper returned by @drawbridge/mongodb — credit/debit duck-type
16
- // against its `.create`, `.update` interface so this module has no hard dep.
19
+ // `db` is the wrapper returned by @drawbridge/mongodb — credit/debit
20
+ // duck-type against its `.create` interface so this module has no hard dep.
17
21
  //
18
- // Both helpers accept an optional `session` so the caller can enroll them in
19
- // a larger Mongo transaction. When session is omitted, the balance update +
20
- // transaction insert run as two separate atomic ops (acceptable: balance is
21
- // always correct, worst case is a missing ledger row on a Mongo blip between
22
- // the two writes).
23
-
24
- // Internal: write a transaction row. The user.balance.ai update happens in
25
- // the callers (credit/debit) so we can keep the atomic gate-and-decrement
26
- // contract.
27
- const recordTransaction = async ({
22
+ // `user` is the user the transaction is being recorded against. The helper
23
+ // reads `user.balance.<type>` to stamp `balance: { before, after }` on the
24
+ // row as an audit snapshot of what the actor saw at action time — useful
25
+ // for support / dispute resolution. When `balance.<type>` isn't on the user
26
+ // (fresh signup, webhook-initiated row), the snapshot is omitted, which is
27
+ // the honest representation of "no prior view to record".
28
+
29
+ const insertTransaction = async ({
28
30
  db,
29
- authenticated,
30
- userId,
31
+ user,
32
+ type,
31
33
  category,
32
- tags,
34
+ source,
33
35
  amount,
34
- balance,
35
- provider,
36
+ _id,
36
37
  stripeInvoiceId,
37
38
  stripeEventId,
38
- session
39
+ session,
40
+ ...rest
39
41
  }) => {
40
42
 
43
+ const beforeRaw = user?.balance?.[ type ];
44
+ const balance = typeof beforeRaw === 'number'
45
+ ? { before : beforeRaw, after : beforeRaw + amount }
46
+ : undefined;
47
+
41
48
  await db.create({
42
- authenticated,
49
+ authenticated : user,
43
50
  collection : 'transaction',
44
51
  data : {
45
- userId,
46
- billable : {
47
- item : 'ai'
48
- },
52
+ ...( _id && { _id }),
53
+ userId : user?.id,
54
+ type,
49
55
  category,
50
- tags,
56
+ source,
51
57
  amount,
52
- balance,
53
- ...( provider && { provider }),
58
+ ...( balance && { balance }),
59
+ ...rest,
54
60
  ...( stripeInvoiceId && { stripeInvoiceId }),
55
61
  ...( stripeEventId && { stripeEventId })
56
62
  },
@@ -59,20 +65,22 @@ const recordTransaction = async ({
59
65
 
60
66
  };
61
67
 
62
- // Additive: grant credits to a user. Used by welcome flow, top-up webhook,
63
- // admin promo, refunds. If stripeEventId is provided, the unique sparse index
64
- // on the transaction collection makes the insert idempotent against webhook
65
- // retries second call no-ops at the Mongo layer.
68
+ // Additive: record a credit. The stream handler does the $inc separately.
69
+ // Optional `_id` lets callers pre-generate the transaction's ObjectId the
70
+ // top-up route stamps it onto the Stripe invoice metadata so Stripe-side and
71
+ // Mongo-side IDs match for cross-system reconciliation.
66
72
  const credit = async ({
67
73
  db,
68
- authenticated,
69
- userId,
74
+ user,
70
75
  amount,
76
+ type,
71
77
  category,
72
- tags = [],
78
+ source,
79
+ _id,
73
80
  stripeInvoiceId,
74
81
  stripeEventId,
75
- session
82
+ session,
83
+ ...rest
76
84
  }) => {
77
85
 
78
86
  if( !Number.isInteger( amount ) || amount <= 0 ){
@@ -81,83 +89,56 @@ const credit = async ({
81
89
 
82
90
  }
83
91
 
84
- const result = await db.update({
85
- authenticated,
86
- collection : 'user',
87
- query : {
88
- id : userId
89
- },
90
- data : {
91
- $inc : {
92
- 'balance.ai' : amount
93
- }
94
- },
95
- ...( session && { options : { session } })
96
- });
92
+ if( !type ){
97
93
 
98
- const after = result?.value?.balance?.ai ?? result?.balance?.ai;
94
+ throw new Error( 'credit() requires a type (e.g., "ai")' );
99
95
 
100
- if( typeof after !== 'number' ){
96
+ }
101
97
 
102
- throw new Error( `credit() could not read updated balance for user ${ userId }` );
98
+ if( !source ){
103
99
 
104
- }
100
+ throw new Error( 'credit() requires a source ("system" | "admin" | "user")' );
105
101
 
106
- const before = after - amount;
102
+ }
107
103
 
108
- await recordTransaction({
104
+ await insertTransaction({
109
105
  db,
110
- authenticated,
111
- userId,
106
+ user,
107
+ type,
112
108
  category,
113
- tags,
109
+ source,
114
110
  amount,
115
- balance : {
116
- before,
117
- after
118
- },
111
+ _id,
119
112
  stripeInvoiceId,
120
113
  stripeEventId,
121
- session
114
+ session,
115
+ ...rest
122
116
  });
123
117
 
124
- return {
125
- balance : {
126
- before,
127
- after
128
- }
129
- };
130
-
131
118
  };
132
119
 
133
- // Subtractive: atomic gate-and-decrement against the user's balance. The
134
- // $gte filter ensures concurrent calls can't drive balance negative — exactly
135
- // one of N parallel requests with overlapping cost will succeed; the rest
136
- // throw InsufficientCreditsError. Callers (e.g., ai.js) pre-compute `amount`
137
- // from their domain-specific pricing.
138
- class InsufficientCreditsError extends Error {
139
-
140
- constructor( message = 'Insufficient AI credits' ){
141
-
142
- super( message );
143
- this.name = 'InsufficientCreditsError';
144
- this.status = 402;
145
-
146
- }
147
-
148
- }
149
-
120
+ // Subtractive: record a debit (negative amount). The stream handler's $inc
121
+ // is guarded against negative balance if the resulting balance would be
122
+ // less than zero, the $inc is skipped and the row stays as audit-only.
123
+ // Callers (e.g., ai.js) pre-compute `amount` from their domain-specific
124
+ // pricing.
125
+ //
126
+ // Note: there is no synchronous gate here. The atomic gate-and-decrement
127
+ // pattern moves to the AI middleware (`balance.<type> >= MAX_REQUEST_CENTS`)
128
+ // plus the stream-side $inc guard. Concurrent debits with low balance can
129
+ // race past the middleware check but the stream guard prevents the actual
130
+ // balance from going negative.
150
131
  const debit = async ({
151
132
  db,
152
- authenticated,
153
- userId,
133
+ user,
154
134
  amount,
135
+ type,
155
136
  category,
156
- tags = [],
157
- provider,
137
+ source,
158
138
  stripeInvoiceId,
159
139
  stripeEventId,
160
- session
140
+ session,
141
+ ...rest
161
142
  }) => {
162
143
 
163
144
  if( !Number.isInteger( amount ) || amount <= 0 ){
@@ -166,58 +147,31 @@ const debit = async ({
166
147
 
167
148
  }
168
149
 
169
- const result = await db.update({
170
- authenticated,
171
- collection : 'user',
172
- query : {
173
- id : userId,
174
- 'balance.ai' : {
175
- $gte : amount
176
- }
177
- },
178
- data : {
179
- $inc : {
180
- 'balance.ai' : -amount
181
- }
182
- },
183
- ...( session && { options : { session } })
184
- });
150
+ if( !type ){
151
+
152
+ throw new Error( 'debit() requires a type (e.g., "ai")' );
185
153
 
186
- const after = result?.value?.balance?.ai ?? result?.balance?.ai;
154
+ }
187
155
 
188
- if( typeof after !== 'number' ){
156
+ if( !source ){
189
157
 
190
- throw new InsufficientCreditsError();
158
+ throw new Error( 'debit() requires a source ("system" | "admin" | "user")' );
191
159
 
192
160
  }
193
161
 
194
- const before = after + amount;
195
-
196
- await recordTransaction({
162
+ await insertTransaction({
197
163
  db,
198
- authenticated,
199
- userId,
164
+ user,
165
+ type,
200
166
  category,
201
- tags,
167
+ source,
202
168
  amount : -amount,
203
- balance : {
204
- before,
205
- after
206
- },
207
- provider,
208
169
  stripeInvoiceId,
209
170
  stripeEventId,
210
- session
171
+ session,
172
+ ...rest
211
173
  });
212
174
 
213
- return {
214
- amount : -amount,
215
- balance : {
216
- before,
217
- after
218
- }
219
- };
220
-
221
175
  };
222
176
 
223
- export { InsufficientCreditsError, credit, debit };
177
+ export { credit, debit };