@drawbridge/drawbridge-utils 0.0.29 → 0.0.31

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.
@@ -1,56 +1,63 @@
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,
31
+ user,
30
32
  userId,
33
+ type,
31
34
  category,
32
- tags,
35
+ source,
33
36
  amount,
34
- balance,
35
- provider,
37
+ _id,
36
38
  stripeInvoiceId,
37
39
  stripeEventId,
38
- session
40
+ session,
41
+ ...rest
39
42
  }) => {
40
43
 
44
+ const beforeRaw = user?.balance?.[ type ];
45
+ const balance = typeof beforeRaw === 'number'
46
+ ? { before : beforeRaw, after : beforeRaw + amount }
47
+ : undefined;
48
+
41
49
  await db.create({
42
- authenticated,
50
+ authenticated : user,
43
51
  collection : 'transaction',
44
52
  data : {
53
+ ...( _id && { _id }),
45
54
  userId,
46
- billable : {
47
- item : 'ai'
48
- },
55
+ type,
49
56
  category,
50
- tags,
57
+ source,
51
58
  amount,
52
- balance,
53
- ...( provider && { provider }),
59
+ ...( balance && { balance }),
60
+ ...rest,
54
61
  ...( stripeInvoiceId && { stripeInvoiceId }),
55
62
  ...( stripeEventId && { stripeEventId })
56
63
  },
@@ -59,20 +66,23 @@ const recordTransaction = async ({
59
66
 
60
67
  };
61
68
 
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.
69
+ // Additive: record a credit. The stream handler does the $inc separately.
70
+ // Optional `_id` lets callers pre-generate the transaction's ObjectId the
71
+ // top-up route stamps it onto the Stripe invoice metadata so Stripe-side and
72
+ // Mongo-side IDs match for cross-system reconciliation.
66
73
  const credit = async ({
67
74
  db,
68
- authenticated,
75
+ user,
69
76
  userId,
70
77
  amount,
78
+ type,
71
79
  category,
72
- tags = [],
80
+ source,
81
+ _id,
73
82
  stripeInvoiceId,
74
83
  stripeEventId,
75
- session
84
+ session,
85
+ ...rest
76
86
  }) => {
77
87
 
78
88
  if( !Number.isInteger( amount ) || amount <= 0 ){
@@ -81,83 +91,58 @@ const credit = async ({
81
91
 
82
92
  }
83
93
 
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
- });
94
+ if( !type ){
97
95
 
98
- const after = result?.value?.balance?.ai ?? result?.balance?.ai;
96
+ throw new Error( 'credit() requires a type (e.g., "ai")' );
99
97
 
100
- if( typeof after !== 'number' ){
98
+ }
101
99
 
102
- throw new Error( `credit() could not read updated balance for user ${ userId }` );
100
+ if( !source ){
103
101
 
104
- }
102
+ throw new Error( 'credit() requires a source ("system" | "admin" | "user")' );
105
103
 
106
- const before = after - amount;
104
+ }
107
105
 
108
- await recordTransaction({
106
+ await insertTransaction({
109
107
  db,
110
- authenticated,
108
+ user,
111
109
  userId,
110
+ type,
112
111
  category,
113
- tags,
112
+ source,
114
113
  amount,
115
- balance : {
116
- before,
117
- after
118
- },
114
+ _id,
119
115
  stripeInvoiceId,
120
116
  stripeEventId,
121
- session
117
+ session,
118
+ ...rest
122
119
  });
123
120
 
124
- return {
125
- balance : {
126
- before,
127
- after
128
- }
129
- };
130
-
131
121
  };
132
122
 
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
-
123
+ // Subtractive: record a debit (negative amount). The stream handler's $inc
124
+ // is guarded against negative balance if the resulting balance would be
125
+ // less than zero, the $inc is skipped and the row stays as audit-only.
126
+ // Callers (e.g., ai.js) pre-compute `amount` from their domain-specific
127
+ // pricing.
128
+ //
129
+ // Note: there is no synchronous gate here. The atomic gate-and-decrement
130
+ // pattern moves to the AI middleware (`balance.<type> >= MAX_REQUEST_CENTS`)
131
+ // plus the stream-side $inc guard. Concurrent debits with low balance can
132
+ // race past the middleware check but the stream guard prevents the actual
133
+ // balance from going negative.
150
134
  const debit = async ({
151
135
  db,
152
- authenticated,
136
+ user,
153
137
  userId,
154
138
  amount,
139
+ type,
155
140
  category,
156
- tags = [],
157
- provider,
141
+ source,
158
142
  stripeInvoiceId,
159
143
  stripeEventId,
160
- session
144
+ session,
145
+ ...rest
161
146
  }) => {
162
147
 
163
148
  if( !Number.isInteger( amount ) || amount <= 0 ){
@@ -166,58 +151,32 @@ const debit = async ({
166
151
 
167
152
  }
168
153
 
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
- });
154
+ if( !type ){
155
+
156
+ throw new Error( 'debit() requires a type (e.g., "ai")' );
185
157
 
186
- const after = result?.value?.balance?.ai ?? result?.balance?.ai;
158
+ }
187
159
 
188
- if( typeof after !== 'number' ){
160
+ if( !source ){
189
161
 
190
- throw new InsufficientCreditsError();
162
+ throw new Error( 'debit() requires a source ("system" | "admin" | "user")' );
191
163
 
192
164
  }
193
165
 
194
- const before = after + amount;
195
-
196
- await recordTransaction({
166
+ await insertTransaction({
197
167
  db,
198
- authenticated,
168
+ user,
199
169
  userId,
170
+ type,
200
171
  category,
201
- tags,
172
+ source,
202
173
  amount : -amount,
203
- balance : {
204
- before,
205
- after
206
- },
207
- provider,
208
174
  stripeInvoiceId,
209
175
  stripeEventId,
210
- session
176
+ session,
177
+ ...rest
211
178
  });
212
179
 
213
- return {
214
- amount : -amount,
215
- balance : {
216
- before,
217
- after
218
- }
219
- };
220
-
221
180
  };
222
181
 
223
- export { InsufficientCreditsError, credit, debit };
182
+ export { credit, debit };
@@ -1,10 +1,8 @@
1
1
  import {
2
- InsufficientCreditsError,
3
2
  credit,
4
3
  debit
5
- } from "./chunk-H735KDYS.js";
4
+ } from "./chunk-6S43OFA7.js";
6
5
  export {
7
- InsufficientCreditsError,
8
6
  credit,
9
7
  debit
10
8
  };
package/package.json CHANGED
@@ -103,5 +103,5 @@
103
103
  "build": "tsup && npm publish"
104
104
  },
105
105
  "types": "dist/index.d.ts",
106
- "version": "0.0.29"
106
+ "version": "0.0.31"
107
107
  }
@@ -1,162 +0,0 @@
1
- // transactions.js
2
- var recordTransaction = async ({
3
- db,
4
- authenticated,
5
- userId,
6
- category,
7
- tags,
8
- amount,
9
- balance,
10
- provider,
11
- stripeInvoiceId,
12
- stripeEventId,
13
- session
14
- }) => {
15
- await db.create({
16
- authenticated,
17
- collection: "transaction",
18
- data: {
19
- userId,
20
- billable: {
21
- item: "ai"
22
- },
23
- category,
24
- tags,
25
- amount,
26
- balance,
27
- ...provider && { provider },
28
- ...stripeInvoiceId && { stripeInvoiceId },
29
- ...stripeEventId && { stripeEventId }
30
- },
31
- ...session && { options: { session } }
32
- });
33
- };
34
- var credit = async ({
35
- db,
36
- authenticated,
37
- userId,
38
- amount,
39
- category,
40
- tags = [],
41
- stripeInvoiceId,
42
- stripeEventId,
43
- session
44
- }) => {
45
- var _a, _b, _c;
46
- if (!Number.isInteger(amount) || amount <= 0) {
47
- throw new Error(`credit() requires a positive integer amount, got ${amount}`);
48
- }
49
- const result = await db.update({
50
- authenticated,
51
- collection: "user",
52
- query: {
53
- id: userId
54
- },
55
- data: {
56
- $inc: {
57
- "balance.ai": amount
58
- }
59
- },
60
- ...session && { options: { session } }
61
- });
62
- 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);
63
- if (typeof after !== "number") {
64
- throw new Error(`credit() could not read updated balance for user ${userId}`);
65
- }
66
- const before = after - amount;
67
- await recordTransaction({
68
- db,
69
- authenticated,
70
- userId,
71
- category,
72
- tags,
73
- amount,
74
- balance: {
75
- before,
76
- after
77
- },
78
- stripeInvoiceId,
79
- stripeEventId,
80
- session
81
- });
82
- return {
83
- balance: {
84
- before,
85
- after
86
- }
87
- };
88
- };
89
- var InsufficientCreditsError = class extends Error {
90
- constructor(message = "Insufficient AI credits") {
91
- super(message);
92
- this.name = "InsufficientCreditsError";
93
- this.status = 402;
94
- }
95
- };
96
- var debit = async ({
97
- db,
98
- authenticated,
99
- userId,
100
- amount,
101
- category,
102
- tags = [],
103
- provider,
104
- stripeInvoiceId,
105
- stripeEventId,
106
- session
107
- }) => {
108
- var _a, _b, _c;
109
- if (!Number.isInteger(amount) || amount <= 0) {
110
- throw new Error(`debit() requires a positive integer amount, got ${amount}`);
111
- }
112
- const result = await db.update({
113
- authenticated,
114
- collection: "user",
115
- query: {
116
- id: userId,
117
- "balance.ai": {
118
- $gte: amount
119
- }
120
- },
121
- data: {
122
- $inc: {
123
- "balance.ai": -amount
124
- }
125
- },
126
- ...session && { options: { session } }
127
- });
128
- 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);
129
- if (typeof after !== "number") {
130
- throw new InsufficientCreditsError();
131
- }
132
- const before = after + amount;
133
- await recordTransaction({
134
- db,
135
- authenticated,
136
- userId,
137
- category,
138
- tags,
139
- amount: -amount,
140
- balance: {
141
- before,
142
- after
143
- },
144
- provider,
145
- stripeInvoiceId,
146
- stripeEventId,
147
- session
148
- });
149
- return {
150
- amount: -amount,
151
- balance: {
152
- before,
153
- after
154
- }
155
- };
156
- };
157
-
158
- export {
159
- credit,
160
- InsufficientCreditsError,
161
- debit
162
- };