@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.
@@ -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 };
@@ -1,10 +1,8 @@
1
1
  import {
2
- InsufficientCreditsError,
3
2
  credit,
4
3
  debit
5
- } from "./chunk-H735KDYS.js";
4
+ } from "./chunk-RW2FJVIV.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.32"
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
- };