@@ -6,6 +6,7 @@ import { recordUsage } from '@/lib/billing/core/usage-log'
66import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing'
77import { checkInternalApiKey } from '@/lib/copilot/request/http'
88import { isBillingEnabled } from '@/lib/core/config/feature-flags'
9+ import { type AtomicClaimResult , billingIdempotency } from '@/lib/core/idempotency/service'
910import { generateRequestId } from '@/lib/core/utils/request'
1011
1112const logger = createLogger ( 'BillingUpdateCostAPI' )
@@ -19,6 +20,7 @@ const UpdateCostSchema = z.object({
1920 source : z
2021 . enum ( [ 'copilot' , 'workspace-chat' , 'mcp_copilot' , 'mothership_block' ] )
2122 . default ( 'copilot' ) ,
23+ idempotencyKey : z . string ( ) . min ( 1 ) . optional ( ) ,
2224} )
2325
2426/**
@@ -28,6 +30,8 @@ const UpdateCostSchema = z.object({
2830export async function POST ( req : NextRequest ) {
2931 const requestId = generateRequestId ( )
3032 const startTime = Date . now ( )
33+ let claim : AtomicClaimResult | null = null
34+ let usageCommitted = false
3135
3236 try {
3337 logger . info ( `[${ requestId } ] Update cost request started` )
@@ -75,9 +79,30 @@ export async function POST(req: NextRequest) {
7579 )
7680 }
7781
78- const { userId, cost, model, inputTokens, outputTokens, source } = validation . data
82+ const { userId, cost, model, inputTokens, outputTokens, source, idempotencyKey } =
83+ validation . data
7984 const isMcp = source === 'mcp_copilot'
8085
86+ claim = idempotencyKey
87+ ? await billingIdempotency . atomicallyClaim ( 'update-cost' , idempotencyKey )
88+ : null
89+
90+ if ( claim && ! claim . claimed ) {
91+ logger . warn ( `[${ requestId } ] Duplicate billing update rejected` , {
92+ idempotencyKey,
93+ userId,
94+ source,
95+ } )
96+ return NextResponse . json (
97+ {
98+ success : false ,
99+ error : 'Duplicate request: idempotency key already processed' ,
100+ requestId,
101+ } ,
102+ { status : 409 }
103+ )
104+ }
105+
81106 logger . info ( `[${ requestId } ] Processing cost update` , {
82107 userId,
83108 cost,
@@ -113,6 +138,7 @@ export async function POST(req: NextRequest) {
113138 ] ,
114139 additionalStats,
115140 } )
141+ usageCommitted = true
116142
117143 logger . info ( `[${ requestId } ] Recorded usage` , {
118144 userId,
@@ -149,6 +175,22 @@ export async function POST(req: NextRequest) {
149175 duration,
150176 } )
151177
178+ if ( claim ?. claimed && ! usageCommitted ) {
179+ await billingIdempotency
180+ . release ( claim . normalizedKey , claim . storageMethod )
181+ . catch ( ( releaseErr ) => {
182+ logger . warn ( `[${ requestId } ] Failed to release idempotency claim` , {
183+ error : releaseErr instanceof Error ? releaseErr . message : String ( releaseErr ) ,
184+ normalizedKey : claim ?. normalizedKey ,
185+ } )
186+ } )
187+ } else if ( claim ?. claimed && usageCommitted ) {
188+ logger . warn (
189+ `[${ requestId } ] Error occurred after usage committed; retaining idempotency claim to prevent double-billing` ,
190+ { normalizedKey : claim . normalizedKey }
191+ )
192+ }
193+
152194 return NextResponse . json (
153195 {
154196 success : false ,
0 commit comments