@drawbridge/drawbridge-utils 0.0.28 → 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.
@@ -0,0 +1,115 @@
1
+ // transactions.js
2
+ var insertTransaction = async ({
3
+ db,
4
+ user,
5
+ userId,
6
+ type,
7
+ category,
8
+ source,
9
+ amount,
10
+ _id,
11
+ stripeInvoiceId,
12
+ stripeEventId,
13
+ session,
14
+ ...rest
15
+ }) => {
16
+ var _a;
17
+ const beforeRaw = (_a = user == null ? void 0 : user.balance) == null ? void 0 : _a[type];
18
+ const balance = typeof beforeRaw === "number" ? { before: beforeRaw, after: beforeRaw + amount } : void 0;
19
+ await db.create({
20
+ authenticated: user,
21
+ collection: "transaction",
22
+ data: {
23
+ ..._id && { _id },
24
+ userId,
25
+ type,
26
+ category,
27
+ source,
28
+ amount,
29
+ ...balance && { balance },
30
+ ...rest,
31
+ ...stripeInvoiceId && { stripeInvoiceId },
32
+ ...stripeEventId && { stripeEventId }
33
+ },
34
+ ...session && { options: { session } }
35
+ });
36
+ };
37
+ var credit = async ({
38
+ db,
39
+ user,
40
+ userId,
41
+ amount,
42
+ type,
43
+ category,
44
+ source,
45
+ _id,
46
+ stripeInvoiceId,
47
+ stripeEventId,
48
+ session,
49
+ ...rest
50
+ }) => {
51
+ if (!Number.isInteger(amount) || amount <= 0) {
52
+ throw new Error(`credit() requires a positive integer amount, got ${amount}`);
53
+ }
54
+ if (!type) {
55
+ throw new Error('credit() requires a type (e.g., "ai")');
56
+ }
57
+ if (!source) {
58
+ throw new Error('credit() requires a source ("system" | "admin" | "user")');
59
+ }
60
+ await insertTransaction({
61
+ db,
62
+ user,
63
+ userId,
64
+ type,
65
+ category,
66
+ source,
67
+ amount,
68
+ _id,
69
+ stripeInvoiceId,
70
+ stripeEventId,
71
+ session,
72
+ ...rest
73
+ });
74
+ };
75
+ var debit = async ({
76
+ db,
77
+ user,
78
+ userId,
79
+ amount,
80
+ type,
81
+ category,
82
+ source,
83
+ stripeInvoiceId,
84
+ stripeEventId,
85
+ session,
86
+ ...rest
87
+ }) => {
88
+ if (!Number.isInteger(amount) || amount <= 0) {
89
+ throw new Error(`debit() requires a positive integer amount, got ${amount}`);
90
+ }
91
+ if (!type) {
92
+ throw new Error('debit() requires a type (e.g., "ai")');
93
+ }
94
+ if (!source) {
95
+ throw new Error('debit() requires a source ("system" | "admin" | "user")');
96
+ }
97
+ await insertTransaction({
98
+ db,
99
+ user,
100
+ userId,
101
+ type,
102
+ category,
103
+ source,
104
+ amount: -amount,
105
+ stripeInvoiceId,
106
+ stripeEventId,
107
+ session,
108
+ ...rest
109
+ });
110
+ };
111
+
112
+ export {
113
+ credit,
114
+ debit
115
+ };
@@ -19,161 +19,121 @@ 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,
28
+ user,
30
29
  userId,
30
+ type,
31
31
  category,
32
- tags,
32
+ source,
33
33
  amount,
34
- balance,
35
- provider,
34
+ _id,
36
35
  stripeInvoiceId,
37
- stripeEventId
36
+ stripeEventId,
37
+ session,
38
+ ...rest
38
39
  }) => {
40
+ var _a;
41
+ const beforeRaw = (_a = user == null ? void 0 : user.balance) == null ? void 0 : _a[type];
42
+ const balance = typeof beforeRaw === "number" ? { before: beforeRaw, after: beforeRaw + amount } : void 0;
39
43
  await db.create({
40
- authenticated,
41
- collection: "transactions",
44
+ authenticated: user,
45
+ collection: "transaction",
42
46
  data: {
47
+ ..._id && { _id },
43
48
  userId,
44
- billable: {
45
- item: "ai"
46
- },
49
+ type,
47
50
  category,
48
- tags,
51
+ source,
49
52
  amount,
50
- balance,
51
- ...provider && { provider },
53
+ ...balance && { balance },
54
+ ...rest,
52
55
  ...stripeInvoiceId && { stripeInvoiceId },
53
56
  ...stripeEventId && { stripeEventId }
54
- }
57
+ },
58
+ ...session && { options: { session } }
55
59
  });
56
60
  };
57
61
  var credit = async ({
58
62
  db,
59
- authenticated,
63
+ user,
60
64
  userId,
61
65
  amount,
66
+ type,
62
67
  category,
63
- tags = [],
68
+ source,
69
+ _id,
64
70
  stripeInvoiceId,
65
- stripeEventId
71
+ stripeEventId,
72
+ session,
73
+ ...rest
66
74
  }) => {
67
- var _a, _b, _c;
68
75
  if (!Number.isInteger(amount) || amount <= 0) {
69
76
  throw new Error(`credit() requires a positive integer amount, got ${amount}`);
70
77
  }
71
- const result = await db.update({
72
- authenticated,
73
- collection: "users",
74
- query: {
75
- id: userId
76
- },
77
- data: {
78
- $inc: {
79
- "balance.ai": amount
80
- }
81
- }
82
- });
83
- 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);
84
- if (typeof after !== "number") {
85
- throw new Error(`credit() could not read updated balance for user ${userId}`);
78
+ if (!type) {
79
+ throw new Error('credit() requires a type (e.g., "ai")');
86
80
  }
87
- const before = after - amount;
88
- await recordTransaction({
81
+ if (!source) {
82
+ throw new Error('credit() requires a source ("system" | "admin" | "user")');
83
+ }
84
+ await insertTransaction({
89
85
  db,
90
- authenticated,
86
+ user,
91
87
  userId,
88
+ type,
92
89
  category,
93
- tags,
90
+ source,
94
91
  amount,
95
- balance: {
96
- before,
97
- after
98
- },
92
+ _id,
99
93
  stripeInvoiceId,
100
- stripeEventId
94
+ stripeEventId,
95
+ session,
96
+ ...rest
101
97
  });
102
- return {
103
- balance: {
104
- before,
105
- after
106
- }
107
- };
108
- };
109
- var InsufficientCreditsError = class extends Error {
110
- constructor(message = "Insufficient AI credits") {
111
- super(message);
112
- this.name = "InsufficientCreditsError";
113
- this.status = 402;
114
- }
115
98
  };
116
99
  var debit = async ({
117
100
  db,
118
- authenticated,
101
+ user,
119
102
  userId,
120
103
  amount,
104
+ type,
121
105
  category,
122
- tags = [],
123
- provider,
106
+ source,
124
107
  stripeInvoiceId,
125
- stripeEventId
108
+ stripeEventId,
109
+ session,
110
+ ...rest
126
111
  }) => {
127
- var _a, _b, _c;
128
112
  if (!Number.isInteger(amount) || amount <= 0) {
129
113
  throw new Error(`debit() requires a positive integer amount, got ${amount}`);
130
114
  }
131
- const result = await db.update({
132
- authenticated,
133
- collection: "users",
134
- query: {
135
- id: userId,
136
- "balance.ai": {
137
- $gte: amount
138
- }
139
- },
140
- data: {
141
- $inc: {
142
- "balance.ai": -amount
143
- }
144
- }
145
- });
146
- 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);
147
- if (typeof after !== "number") {
148
- throw new InsufficientCreditsError();
115
+ if (!type) {
116
+ throw new Error('debit() requires a type (e.g., "ai")');
149
117
  }
150
- const before = after + amount;
151
- await recordTransaction({
118
+ if (!source) {
119
+ throw new Error('debit() requires a source ("system" | "admin" | "user")');
120
+ }
121
+ await insertTransaction({
152
122
  db,
153
- authenticated,
123
+ user,
154
124
  userId,
125
+ type,
155
126
  category,
156
- tags,
127
+ source,
157
128
  amount: -amount,
158
- balance: {
159
- before,
160
- after
161
- },
162
- provider,
163
129
  stripeInvoiceId,
164
- stripeEventId
130
+ stripeEventId,
131
+ session,
132
+ ...rest
165
133
  });
166
- return {
167
- amount: -amount,
168
- balance: {
169
- before,
170
- after
171
- }
172
- };
173
134
  };
174
135
  // Annotate the CommonJS export names for ESM import in node:
175
136
  0 && (module.exports = {
176
- InsufficientCreditsError,
177
137
  credit,
178
138
  debit
179
139
  });
@@ -1,68 +1,88 @@
1
- // Generic ledger primitivesauthoritative balance for user.balance.ai lives
2
- // in Mongo, and every change to that balance is recorded in the `transactions`
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.
17
-
18
- // Internal: write a transactions row. The user.balance.ai update happens in the
19
- // callers (credit/debit) so we can keep the atomic gate-and-decrement contract.
20
- const recordTransaction = async ({
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.
21
+ //
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 ({
21
30
  db,
22
- authenticated,
31
+ user,
23
32
  userId,
33
+ type,
24
34
  category,
25
- tags,
35
+ source,
26
36
  amount,
27
- balance,
28
- provider,
37
+ _id,
29
38
  stripeInvoiceId,
30
- stripeEventId
39
+ stripeEventId,
40
+ session,
41
+ ...rest
31
42
  }) => {
32
43
 
44
+ const beforeRaw = user?.balance?.[ type ];
45
+ const balance = typeof beforeRaw === 'number'
46
+ ? { before : beforeRaw, after : beforeRaw + amount }
47
+ : undefined;
48
+
33
49
  await db.create({
34
- authenticated,
35
- collection : 'transactions',
50
+ authenticated : user,
51
+ collection : 'transaction',
36
52
  data : {
53
+ ...( _id && { _id }),
37
54
  userId,
38
- billable : {
39
- item : 'ai'
40
- },
55
+ type,
41
56
  category,
42
- tags,
57
+ source,
43
58
  amount,
44
- balance,
45
- ...( provider && { provider }),
59
+ ...( balance && { balance }),
60
+ ...rest,
46
61
  ...( stripeInvoiceId && { stripeInvoiceId }),
47
62
  ...( stripeEventId && { stripeEventId })
48
- }
63
+ },
64
+ ...( session && { options : { session } })
49
65
  });
50
66
 
51
67
  };
52
68
 
53
- // Additive: grant credits to a user. Used by welcome flow, top-up webhook,
54
- // admin promo, refunds. If stripeEventId is provided, the unique sparse index
55
- // on the transactions collection makes the insert idempotent against webhook
56
- // 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.
57
73
  const credit = async ({
58
74
  db,
59
- authenticated,
75
+ user,
60
76
  userId,
61
77
  amount,
78
+ type,
62
79
  category,
63
- tags = [],
80
+ source,
81
+ _id,
64
82
  stripeInvoiceId,
65
- stripeEventId
83
+ stripeEventId,
84
+ session,
85
+ ...rest
66
86
  }) => {
67
87
 
68
88
  if( !Number.isInteger( amount ) || amount <= 0 ){
@@ -71,80 +91,58 @@ const credit = async ({
71
91
 
72
92
  }
73
93
 
74
- const result = await db.update({
75
- authenticated,
76
- collection : 'users',
77
- query : {
78
- id : userId
79
- },
80
- data : {
81
- $inc : {
82
- 'balance.ai' : amount
83
- }
84
- }
85
- });
94
+ if( !type ){
86
95
 
87
- const after = result?.value?.balance?.ai ?? result?.balance?.ai;
96
+ throw new Error( 'credit() requires a type (e.g., "ai")' );
88
97
 
89
- if( typeof after !== 'number' ){
98
+ }
90
99
 
91
- throw new Error( `credit() could not read updated balance for user ${ userId }` );
100
+ if( !source ){
92
101
 
93
- }
102
+ throw new Error( 'credit() requires a source ("system" | "admin" | "user")' );
94
103
 
95
- const before = after - amount;
104
+ }
96
105
 
97
- await recordTransaction({
106
+ await insertTransaction({
98
107
  db,
99
- authenticated,
108
+ user,
100
109
  userId,
110
+ type,
101
111
  category,
102
- tags,
112
+ source,
103
113
  amount,
104
- balance : {
105
- before,
106
- after
107
- },
114
+ _id,
108
115
  stripeInvoiceId,
109
- stripeEventId
116
+ stripeEventId,
117
+ session,
118
+ ...rest
110
119
  });
111
120
 
112
- return {
113
- balance : {
114
- before,
115
- after
116
- }
117
- };
118
-
119
121
  };
120
122
 
121
- // Subtractive: atomic gate-and-decrement against the user's balance. The
122
- // $gte filter ensures concurrent calls can't drive balance negative — exactly
123
- // one of N parallel requests with overlapping cost will succeed; the rest
124
- // throw InsufficientCreditsError. Callers (e.g., ai.js) pre-compute `amount`
125
- // from their domain-specific pricing.
126
- class InsufficientCreditsError extends Error {
127
-
128
- constructor( message = 'Insufficient AI credits' ){
129
-
130
- super( message );
131
- this.name = 'InsufficientCreditsError';
132
- this.status = 402;
133
-
134
- }
135
-
136
- }
137
-
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.
138
134
  const debit = async ({
139
135
  db,
140
- authenticated,
136
+ user,
141
137
  userId,
142
138
  amount,
139
+ type,
143
140
  category,
144
- tags = [],
145
- provider,
141
+ source,
146
142
  stripeInvoiceId,
147
- stripeEventId
143
+ stripeEventId,
144
+ session,
145
+ ...rest
148
146
  }) => {
149
147
 
150
148
  if( !Number.isInteger( amount ) || amount <= 0 ){
@@ -153,56 +151,32 @@ const debit = async ({
153
151
 
154
152
  }
155
153
 
156
- const result = await db.update({
157
- authenticated,
158
- collection : 'users',
159
- query : {
160
- id : userId,
161
- 'balance.ai' : {
162
- $gte : amount
163
- }
164
- },
165
- data : {
166
- $inc : {
167
- 'balance.ai' : -amount
168
- }
169
- }
170
- });
154
+ if( !type ){
155
+
156
+ throw new Error( 'debit() requires a type (e.g., "ai")' );
171
157
 
172
- const after = result?.value?.balance?.ai ?? result?.balance?.ai;
158
+ }
173
159
 
174
- if( typeof after !== 'number' ){
160
+ if( !source ){
175
161
 
176
- throw new InsufficientCreditsError();
162
+ throw new Error( 'debit() requires a source ("system" | "admin" | "user")' );
177
163
 
178
164
  }
179
165
 
180
- const before = after + amount;
181
-
182
- await recordTransaction({
166
+ await insertTransaction({
183
167
  db,
184
- authenticated,
168
+ user,
185
169
  userId,
170
+ type,
186
171
  category,
187
- tags,
172
+ source,
188
173
  amount : -amount,
189
- balance : {
190
- before,
191
- after
192
- },
193
- provider,
194
174
  stripeInvoiceId,
195
- stripeEventId
175
+ stripeEventId,
176
+ session,
177
+ ...rest
196
178
  });
197
179
 
198
- return {
199
- amount : -amount,
200
- balance : {
201
- before,
202
- after
203
- }
204
- };
205
-
206
180
  };
207
181
 
208
- export { InsufficientCreditsError, credit, debit };
182
+ export { credit, debit };