@@ -4,15 +4,14 @@ import { toError } from '@sim/utils/errors'
44import { type NextRequest , NextResponse } from 'next/server'
55import { billingUpdateCostContract } from '@/lib/api/contracts/subscription'
66import { parseRequest } from '@/lib/api/server'
7- import { recordUsage } from '@/lib/billing/core/usage-log'
7+ import { recordCumulativeUsage , recordUsage } from '@/lib/billing/core/usage-log'
88import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing'
99import { BillingRouteOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1'
1010import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
1111import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1'
1212import { checkInternalApiKey } from '@/lib/copilot/request/http'
1313import { withIncomingGoSpan } from '@/lib/copilot/request/otel'
1414import { isBillingEnabled } from '@/lib/core/config/feature-flags'
15- import { type AtomicClaimResult , billingIdempotency } from '@/lib/core/idempotency/service'
1615import { generateRequestId } from '@/lib/core/utils/request'
1716import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1817
@@ -42,8 +41,6 @@ export const POST = withRouteHandler((req: NextRequest) =>
4241async function updateCostInner ( req : NextRequest , span : Span ) : Promise < NextResponse > {
4342 const requestId = generateRequestId ( )
4443 const startTime = Date . now ( )
45- let claim : AtomicClaimResult | null = null
46- let usageCommitted = false
4744
4845 try {
4946 logger . info ( `[${ requestId } ] Update cost request started` )
@@ -125,63 +122,92 @@ async function updateCostInner(req: NextRequest, span: Span): Promise<NextRespon
125122 ...( idempotencyKey ? { [ TraceAttr . BillingIdempotencyKey ] : idempotencyKey } : { } ) ,
126123 } )
127124
128- claim = idempotencyKey
129- ? await billingIdempotency . atomicallyClaim ( 'update-cost' , idempotencyKey )
130- : null
125+ logger . info ( `[${ requestId } ] Processing cost update` , {
126+ userId,
127+ cost,
128+ model,
129+ source,
130+ } )
131131
132- if ( claim && ! claim . claimed ) {
133- logger . warn ( `[${ requestId } ] Duplicate billing update rejected` , {
134- idempotencyKey,
132+ // Go sends the request's CUMULATIVE cost, possibly more than once (a
133+ // mid-loop provider-error flush, then the recovered terminal flush, plus
134+ // abort-race duplicates). Record it as a monotonic top-up: one ledger row
135+ // per request holds the MAX cumulative and we bill only the delta, so
136+ // partial + complete flushes converge to the true total exactly once — no
137+ // under-billing on recovery, no over-billing on duplicates. When there is
138+ // no idempotency key (shouldn't happen for real requests) we fall back to a
139+ // plain append so cost is never silently dropped.
140+ let billed = true
141+ if ( idempotencyKey ) {
142+ const result = await recordCumulativeUsage ( {
143+ userId,
144+ workspaceId,
145+ source,
146+ model,
147+ cost,
148+ eventKey : `update-cost:${ idempotencyKey } ` ,
149+ metadata : { inputTokens, outputTokens } ,
150+ } )
151+ billed = result . billed
152+ logger . info ( `[${ requestId } ] Cumulative cost top-up` , {
135153 userId,
136154 source,
155+ cumulativeCost : cost ,
156+ billedDelta : result . delta ,
157+ newTotal : result . total ,
158+ billed : result . billed ,
159+ } )
160+ } else {
161+ await recordUsage ( {
162+ userId,
163+ workspaceId,
164+ entries : [
165+ {
166+ category : 'model' ,
167+ source,
168+ description : model ,
169+ cost,
170+ sourceReference : requestId ,
171+ metadata : { inputTokens, outputTokens } ,
172+ } ,
173+ ] ,
174+ } )
175+ logger . info ( `[${ requestId } ] Recorded usage (no idempotency key)` , {
176+ userId,
177+ addedCost : cost ,
178+ source,
179+ } )
180+ }
181+
182+ const duration = Date . now ( ) - startTime
183+
184+ // Same-or-lower cumulative than already recorded: nothing new to bill. Tell
185+ // the caller via 409 (its existing "duplicate" outcome) without re-running
186+ // overage billing.
187+ if ( ! billed ) {
188+ logger . info ( `[${ requestId } ] Duplicate/non-increasing cumulative cost; no new charge` , {
189+ idempotencyKey,
190+ userId,
191+ cost,
137192 } )
138193 span . setAttribute ( TraceAttr . BillingOutcome , BillingRouteOutcome . DuplicateIdempotencyKey )
139194 span . setAttribute ( TraceAttr . HttpStatusCode , 409 )
195+ span . setAttribute ( TraceAttr . BillingDurationMs , duration )
140196 return NextResponse . json (
141197 {
142198 success : false ,
143- error : 'Duplicate request: idempotency key already processed ' ,
199+ error : 'Duplicate request: cumulative cost already recorded ' ,
144200 requestId,
145201 } ,
146202 { status : 409 }
147203 )
148204 }
149205
150- logger . info ( `[${ requestId } ] Processing cost update` , {
151- userId,
152- cost,
153- model,
154- source,
155- } )
156-
157- await recordUsage ( {
158- userId,
159- workspaceId,
160- entries : [
161- {
162- category : 'model' ,
163- source,
164- description : model ,
165- cost,
166- eventKey : idempotencyKey ? `update-cost:${ idempotencyKey } ` : undefined ,
167- sourceReference : idempotencyKey ? `update-cost:${ idempotencyKey } ` : requestId ,
168- metadata : { inputTokens, outputTokens } ,
169- } ,
170- ] ,
171- } )
172- usageCommitted = true
173-
174- logger . info ( `[${ requestId } ] Recorded usage` , {
175- userId,
176- addedCost : cost ,
177- source,
178- } )
179-
180- // Check if user has hit overage threshold and bill incrementally
206+ // Check if user has hit overage threshold and bill incrementally. Reads the
207+ // (now topped-up) ledger total and is idempotent against billedOverage, so
208+ // it is safe to run on every flush that records new cost.
181209 await checkAndBillOverageThreshold ( userId )
182210
183- const duration = Date . now ( ) - startTime
184-
185211 logger . info ( `[${ requestId } ] Cost update completed successfully` , {
186212 userId,
187213 duration,
@@ -209,22 +235,9 @@ async function updateCostInner(req: NextRequest, span: Span): Promise<NextRespon
209235 duration,
210236 } )
211237
212- if ( claim ?. claimed && ! usageCommitted ) {
213- await billingIdempotency
214- . release ( claim . normalizedKey , claim . storageMethod )
215- . catch ( ( releaseErr ) => {
216- logger . warn ( `[${ requestId } ] Failed to release idempotency claim` , {
217- error : toError ( releaseErr ) . message ,
218- normalizedKey : claim ?. normalizedKey ,
219- } )
220- } )
221- } else if ( claim ?. claimed && usageCommitted ) {
222- logger . warn (
223- `[${ requestId } ] Error occurred after usage committed; retaining idempotency claim to prevent double-billing` ,
224- { normalizedKey : claim . normalizedKey }
225- )
226- }
227-
238+ // The cumulative top-up runs in a single transaction (and a plain append is
239+ // a single insert), so a failure here leaves nothing partially committed —
240+ // a retry re-evaluates the max idempotently. No claim to release.
228241 span . setAttribute ( TraceAttr . BillingOutcome , BillingRouteOutcome . InternalError )
229242 span . setAttribute ( TraceAttr . HttpStatusCode , 500 )
230243 span . setAttribute ( TraceAttr . BillingDurationMs , duration )
0 commit comments