@@ -1268,6 +1268,78 @@ func (h *Handler) failExchange(ctx context.Context, id, reason string) (any, err
12681268 return map [string ]any {"status" : "failed" , "reason" : reason }, nil
12691269}
12701270
1271+ // retryCompleteWithPayment calls CompleteRIExchangeWithPayment up to
1272+ // maxLedgerAttempts times, logging retries. Returns a non-nil error if all
1273+ // attempts fail. Extracted from executeApprovedExchange to reduce its
1274+ // cyclomatic complexity (H4 fix).
1275+ func (h * Handler ) retryCompleteWithPayment (ctx context.Context , id , exchangeID , acceptedPaymentDue string ) error {
1276+ const maxAttempts = 3
1277+ var err error
1278+ for attempt := 1 ; attempt <= maxAttempts ; attempt ++ {
1279+ err = h .config .CompleteRIExchangeWithPayment (ctx , id , exchangeID , acceptedPaymentDue )
1280+ if err == nil {
1281+ return nil
1282+ }
1283+ if attempt < maxAttempts {
1284+ logging .Warnf ("ledger write retry %d/%d for exchange %s after money moved: %v" ,
1285+ attempt , maxAttempts , id , err )
1286+ }
1287+ }
1288+ return err
1289+ }
1290+
1291+ // handlerChooseEffectiveCap returns the smaller of perExchangeCap and daily
1292+ // headroom (dailyCap - dailySpent), bounding Execute's MaxPaymentDueUSD so a
1293+ // fresh re-quote cannot exceed the remaining daily budget (H2 fix).
1294+ func handlerChooseEffectiveCap (dailyCap , dailySpent , perExchangeCap * big.Rat ) * big.Rat {
1295+ remaining := new (big.Rat ).Sub (dailyCap , dailySpent )
1296+ if remaining .Cmp (perExchangeCap ) < 0 {
1297+ return remaining
1298+ }
1299+ return perExchangeCap
1300+ }
1301+
1302+ // handlerAcceptedAmount extracts the confirmed payment amount from a fresh
1303+ // Execute quote, falling back to fallback when freshQ is nil or empty (H3 fix).
1304+ func handlerAcceptedAmount (freshQ * exchange.ExchangeQuoteSummary , fallback string ) string {
1305+ if freshQ == nil {
1306+ return fallback
1307+ }
1308+ if freshQ .PaymentDueUSDStr != "" {
1309+ return freshQ .PaymentDueUSDStr
1310+ }
1311+ // Zero-cost exchange: PaymentDueRaw was empty (AWS returned nil) so
1312+ // PaymentDueUSDStr is also empty. Use "0" to avoid a NULL payment_due in
1313+ // the DB that would silently distort GetRIExchangeDailySpend's SUM.
1314+ return "0"
1315+ }
1316+
1317+ // checkCapsAndComputeHeadroom validates the spending-cap configuration, runs the
1318+ // daily-cap check, and computes the effective MaxPaymentDueUSD that Execute must
1319+ // not exceed (H2: remaining daily headroom vs per-exchange cap, whichever is
1320+ // smaller). Returns a non-empty reason string on any failure so the caller can
1321+ // forward it to failExchange.
1322+ func checkCapsAndComputeHeadroom (dailySpendStr , paymentDue string , cfg * config.GlobalConfig ) (effectiveCap * big.Rat , reason string ) {
1323+ if cfg .RIExchangeMaxDailyUSD == 0 {
1324+ return nil , "daily spending cap is not configured (RIExchangeMaxDailyUSD is 0)"
1325+ }
1326+ if reason := checkDailyCap (dailySpendStr , paymentDue , cfg .RIExchangeMaxDailyUSD ); reason != "" {
1327+ return nil , reason
1328+ }
1329+ if cfg .RIExchangeMaxPerExchangeUSD == 0 {
1330+ return nil , "per-exchange spending cap is not configured (RIExchangeMaxPerExchangeUSD is 0)"
1331+ }
1332+ // checkDailyCap already verified dailySpendStr is parseable; a second failure
1333+ // is an internal error - fail closed to avoid executing with wrong headroom.
1334+ dailySpent , err := exchange .ParseDecimalRat (dailySpendStr )
1335+ if err != nil || dailySpent == nil {
1336+ return nil , fmt .Sprintf ("daily spend re-parse failed (internal error): %v" , err )
1337+ }
1338+ dailyCap := new (big.Rat ).SetFloat64 (cfg .RIExchangeMaxDailyUSD )
1339+ perExchangeCap := new (big.Rat ).SetFloat64 (cfg .RIExchangeMaxPerExchangeUSD )
1340+ return handlerChooseEffectiveCap (dailyCap , dailySpent , perExchangeCap ), ""
1341+ }
1342+
12711343// executeApprovedExchange checks caps and executes the exchange after approval.
12721344func (h * Handler ) executeApprovedExchange (ctx context.Context , id string , record * config.RIExchangeRecord ) (any , error ) {
12731345 dailySpendStr , err := h .config .GetRIExchangeDailySpend (ctx , time .Now ())
@@ -1280,13 +1352,6 @@ func (h *Handler) executeApprovedExchange(ctx context.Context, id string, record
12801352 return h .failExchange (ctx , id , "config load failed" )
12811353 }
12821354
1283- if globalCfg .RIExchangeMaxDailyUSD == 0 {
1284- return h .failExchange (ctx , id , "daily spending cap is not configured (RIExchangeMaxDailyUSD is 0)" )
1285- }
1286- if reason := checkDailyCap (dailySpendStr , record .PaymentDue , globalCfg .RIExchangeMaxDailyUSD ); reason != "" {
1287- return h .failExchange (ctx , id , reason )
1288- }
1289-
12901355 region := record .Region
12911356 if region == "" {
12921357 // Region is a required field captured at record-creation time.
@@ -1295,31 +1360,46 @@ func (h *Handler) executeApprovedExchange(ctx context.Context, id string, record
12951360 return h .failExchange (ctx , id , "exchange record has no region; cannot execute safely" )
12961361 }
12971362
1298- if globalCfg .RIExchangeMaxPerExchangeUSD == 0 {
1299- return h .failExchange (ctx , id , "per-exchange spending cap is not configured (RIExchangeMaxPerExchangeUSD is 0)" )
1363+ effectiveCap , reason := checkCapsAndComputeHeadroom (dailySpendStr , record .PaymentDue , globalCfg )
1364+ if reason != "" {
1365+ return h .failExchange (ctx , id , reason )
13001366 }
13011367
1302- perExchangeCap := new (big.Rat ).SetFloat64 (globalCfg .RIExchangeMaxPerExchangeUSD )
1303- exchangeID , _ , execErr := exchange .ExecuteExchange (ctx , exchange.ExchangeExecuteRequest {
1368+ execFn := exchange .ExecuteExchange
1369+ if h .executeExchangeFn != nil {
1370+ execFn = h .executeExchangeFn
1371+ }
1372+ exchangeID , freshQ , execErr := execFn (ctx , exchange.ExchangeExecuteRequest {
13041373 Region : region ,
13051374 ReservedIDs : record .SourceRIIDs ,
13061375 TargetOfferingID : record .TargetOfferingID ,
13071376 TargetCount : int32 (record .TargetCount ), // #nosec G115 -- RI quantity stored from validated API request; AWS limits RI counts well below math.MaxInt32
1308- MaxPaymentDueUSD : perExchangeCap ,
1377+ MaxPaymentDueUSD : effectiveCap ,
13091378 })
13101379 if execErr != nil {
13111380 return h .failExchange (ctx , id , execErr .Error ())
13121381 }
13131382
1314- if err := h .config .CompleteRIExchange (ctx , id , exchangeID ); err != nil {
1315- logging .Errorf ("failed to mark exchange %s as completed: %v" , id , err )
1383+ // H3: persist the amount AWS actually accepted, not the stale pre-execution
1384+ // quote stored in record.PaymentDue.
1385+ acceptedPaymentDue := handlerAcceptedAmount (freshQ , record .PaymentDue )
1386+
1387+ // H4: retry the ledger write via retryCompleteWithPayment; persistent
1388+ // failure is returned as an error (HTTP 500) so the caller knows money
1389+ // moved but the record was not updated.
1390+ if completeErr := h .retryCompleteWithPayment (ctx , id , exchangeID , acceptedPaymentDue ); completeErr != nil {
1391+ logging .Errorf ("all ledger write attempts failed for exchange %s after money moved: %v" ,
1392+ id , completeErr )
1393+ return nil , fmt .Errorf ("exchange executed (id=%s) but ledger update failed: %w" ,
1394+ exchangeID , completeErr )
13161395 }
13171396
13181397 return map [string ]any {"status" : "completed" , "exchange_id" : exchangeID }, nil
13191398}
13201399
13211400// checkDailyCap verifies the exchange payment won't exceed the daily spending cap.
1322- // Returns an empty string if within cap, or a reason string if exceeded.
1401+ // Returns an empty string if within cap, or a reason string if exceeded or if
1402+ // either input cannot be parsed (fail closed on parse errors).
13231403func checkDailyCap (dailySpendStr , paymentDueStr string , maxDailyUSD float64 ) string {
13241404 dailyCap := new (big.Rat ).SetFloat64 (maxDailyUSD )
13251405 dailySpent , err := exchange .ParseDecimalRat (dailySpendStr )
@@ -1331,8 +1411,11 @@ func checkDailyCap(dailySpendStr, paymentDueStr string, maxDailyUSD float64) str
13311411 }
13321412 paymentDue , err := exchange .ParseDecimalRat (paymentDueStr )
13331413 if err != nil || paymentDue == nil {
1334- logging .Warnf ("checkDailyCap: failed to parse payment due string %q: %v; treating as $0" , paymentDueStr , err )
1335- paymentDue = new (big.Rat )
1414+ // H1 fix: fail closed on an unparseable payment-due string instead of
1415+ // treating it as $0. An unparseable value means we cannot determine the
1416+ // true cost of this exchange, so proceeding risks exceeding the cap.
1417+ logging .Warnf ("checkDailyCap: failed to parse payment due string %q: %v; blocking exchange to avoid cap bypass" , paymentDueStr , err )
1418+ return fmt .Sprintf ("daily spend check failed: could not parse payment due value %q" , paymentDueStr )
13361419 }
13371420
13381421 newTotal := new (big.Rat ).Add (dailySpent , paymentDue )
0 commit comments