@@ -24,10 +24,129 @@ import postgres from 'postgres'
2424import { eq , and , asc , desc , ne , or , gt , isNull , sql } from 'drizzle-orm'
2525import { union } from 'drizzle-orm/pg-core'
2626import * as schema from '@codebuff/internal/db/schema'
27- import { consumeFromOrderedGrants } from '../balance-calculator'
2827
2928import type { Logger } from '@codebuff/common/types/contracts/logger'
3029
30+ // Inlined from balance-calculator.ts to avoid importing db (which has side effects)
31+ // that would try to connect with env.DATABASE_URL before our test URL is set
32+ interface CreditConsumptionResult {
33+ consumed : number
34+ fromPurchased : number
35+ }
36+
37+ // Minimal type for database connection that works with both db and tx
38+ type TestDbConn = ReturnType < typeof drizzle < typeof schema > >
39+
40+ async function updateGrantBalance ( params : {
41+ userId : string
42+ grant : typeof schema . creditLedger . $inferSelect
43+ consumed : number
44+ newBalance : number
45+ tx : TestDbConn
46+ logger : Logger
47+ } ) {
48+ const { grant, newBalance, tx } = params
49+ await tx
50+ . update ( schema . creditLedger )
51+ . set ( { balance : newBalance } )
52+ . where ( eq ( schema . creditLedger . operation_id , grant . operation_id ) )
53+ }
54+
55+ async function consumeFromOrderedGrants ( params : {
56+ userId : string
57+ creditsToConsume : number
58+ grants : ( typeof schema . creditLedger . $inferSelect ) [ ]
59+ tx : TestDbConn
60+ logger : Logger
61+ } ) : Promise < CreditConsumptionResult > {
62+ const { userId, creditsToConsume, grants, tx, logger } = params
63+
64+ let remainingToConsume = creditsToConsume
65+ let consumed = 0
66+ let fromPurchased = 0
67+
68+ // First pass: try to repay any debt
69+ for ( const grant of grants ) {
70+ if ( grant . balance < 0 && remainingToConsume > 0 ) {
71+ const debtAmount = Math . abs ( grant . balance )
72+ const repayAmount = Math . min ( debtAmount , remainingToConsume )
73+ const newBalance = grant . balance + repayAmount
74+ remainingToConsume -= repayAmount
75+ consumed += repayAmount
76+
77+ await updateGrantBalance ( {
78+ userId,
79+ grant,
80+ consumed : - repayAmount ,
81+ newBalance,
82+ tx,
83+ logger,
84+ } )
85+
86+ logger . debug (
87+ { userId, grantId : grant . operation_id , repayAmount, newBalance } ,
88+ 'Repaid debt in grant' ,
89+ )
90+ }
91+ }
92+
93+ // Second pass: consume from positive balances
94+ for ( const grant of grants ) {
95+ if ( remainingToConsume <= 0 ) break
96+ if ( grant . balance <= 0 ) continue
97+
98+ const consumeFromThisGrant = Math . min ( remainingToConsume , grant . balance )
99+ const newBalance = grant . balance - consumeFromThisGrant
100+ remainingToConsume -= consumeFromThisGrant
101+ consumed += consumeFromThisGrant
102+
103+ // Track consumption from purchased credits
104+ if ( grant . type === 'purchase' ) {
105+ fromPurchased += consumeFromThisGrant
106+ }
107+
108+ await updateGrantBalance ( {
109+ userId,
110+ grant,
111+ consumed : consumeFromThisGrant ,
112+ newBalance,
113+ tx,
114+ logger,
115+ } )
116+ }
117+
118+ // If we still have remaining to consume and no grants left, create debt in the last grant
119+ if ( remainingToConsume > 0 && grants . length > 0 ) {
120+ const lastGrant = grants [ grants . length - 1 ]
121+
122+ if ( lastGrant . balance <= 0 ) {
123+ const newBalance = lastGrant . balance - remainingToConsume
124+ await updateGrantBalance ( {
125+ userId,
126+ grant : lastGrant ,
127+ consumed : remainingToConsume ,
128+ newBalance,
129+ tx,
130+ logger,
131+ } )
132+ consumed += remainingToConsume
133+
134+ logger . warn (
135+ {
136+ userId,
137+ grantId : lastGrant . operation_id ,
138+ requested : remainingToConsume ,
139+ consumed : remainingToConsume ,
140+ newDebt : Math . abs ( newBalance ) ,
141+ } ,
142+ 'Created new debt in grant' ,
143+ )
144+ }
145+ }
146+
147+ return { consumed, fromPurchased }
148+ }
149+
31150// Test logger that silently discards all logs
32151const testLogger : Logger = {
33152 debug : ( ) => { } ,
@@ -546,7 +665,7 @@ describe('Balance Calculator - Integration Tests (Real DB)', () => {
546665 userId : TEST_USER_ID ,
547666 creditsToConsume : 70 ,
548667 grants,
549- tx : db as any ,
668+ tx : db ,
550669 logger : testLogger ,
551670 } )
552671
@@ -596,7 +715,7 @@ describe('Balance Calculator - Integration Tests (Real DB)', () => {
596715 userId : TEST_USER_ID ,
597716 creditsToConsume : 100 ,
598717 grants,
599- tx : db as any ,
718+ tx : db ,
600719 logger : testLogger ,
601720 } )
602721
@@ -654,7 +773,7 @@ describe('Balance Calculator - Integration Tests (Real DB)', () => {
654773 userId : TEST_USER_ID ,
655774 creditsToConsume : 60 ,
656775 grants,
657- tx : db as any ,
776+ tx : db ,
658777 logger : testLogger ,
659778 } )
660779
@@ -713,7 +832,7 @@ describe('Balance Calculator - Integration Tests (Real DB)', () => {
713832 userId : TEST_USER_ID ,
714833 creditsToConsume : 80 ,
715834 grants,
716- tx : db as any ,
835+ tx : db ,
717836 logger : testLogger ,
718837 } )
719838
@@ -771,7 +890,7 @@ describe('Balance Calculator - Integration Tests (Real DB)', () => {
771890 userId : TEST_USER_ID ,
772891 creditsToConsume : 50 ,
773892 grants,
774- tx : db as any ,
893+ tx : db ,
775894 logger : testLogger ,
776895 } )
777896
0 commit comments