diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2c5eb051..4efc65f7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -61,6 +61,9 @@ jobs: files: | max-api-* checksums-linux.txt + NOTICE + LICENSE + THIRD-PARTY-LICENSES.md env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -107,6 +110,9 @@ jobs: files: | max-api-macos-* checksums-macos.txt + NOTICE + LICENSE + THIRD-PARTY-LICENSES.md env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -155,5 +161,8 @@ jobs: files: | max-api-*.exe checksums-windows.txt + NOTICE + LICENSE + THIRD-PARTY-LICENSES.md env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/NOTICE b/NOTICE index b5a09205..13016c4a 100644 --- a/NOTICE +++ b/NOTICE @@ -1,7 +1,18 @@ max-api Notices +==== Third-Party Notices ==== + +This product includes third-party open source software. Copyright notices and +license terms for direct third-party dependencies are listed in +THIRD-PARTY-LICENSES.md. + +Redistributed modified versions must retain this complete NOTICE file, +including its third-party, attribution, and additional-term notices. + +==== GNU Affero General Public License v3.0 Notices ==== + max-api -Copyright (c) MAX-API-Next and contributors. +Copyright (c) MAX-API-Next(https://github.com/MAX-API-Next) and contributors. This project is licensed under the GNU Affero General Public License v3.0. See LICENSE for the full project license terms. @@ -24,10 +35,33 @@ https://github.com/MAX-API-Next/MAX-API Modified versions must not misrepresent the origin of the software and must mark their changes in accordance with AGPLv3 Section 7(c). +==== MAX API Temporary Commercial Permission ==== + +In addition to, and not as a replacement for, AGPLv3, a derivative project +used only by its operator automatically receives a no-fee, non-perpetual +temporary commercial permission for the MAX API additions and modifications +that the MAX API project maintainers have the right to license, without a +separate application or approval, while it keeps either of the following +clearly visible in a prominent homepage, footer, about, legal, or attribution +location: + +* a link to https://github.com/MAX-API-Next/MAX-API; or +* an acknowledgement of the MAX-API-Next community with a link to + https://github.com/MAX-API-Next. + +This temporary permission does not cover, replace, or waive AGPLv3 or any +license obligation for One API, New API, or other upstream material. It ceases +to apply if the display condition is no longer met, or if it expires, is +amended, or is terminated by a later notice published in this NOTICE, the +project README, or the official MAX-API-Next community. Rights independently +granted by AGPLv3 remain unaffected. Continued use outside this temporary +permission must comply with AGPLv3 or a separate written authorization from +the applicable rights holder. + ==== Preserved New API Upstream Notice ==== new-api -Copyright (c) QuantumNous and contributors. +Copyright (c) New API(https://github.com/QuantumNous/new-api/) contributors. This project includes software derived from New API, which is licensed under the GNU Affero General Public License v3.0. The following additional terms from @@ -49,16 +83,6 @@ https://github.com/QuantumNous/new-api Modified versions must not misrepresent the origin of the software and must mark their changes in accordance with AGPLv3 Section 7(c). -==== Third-Party Notices ==== - -This product includes third-party open source software. Copyright notices and -license terms for direct third-party dependencies are listed in -THIRD-PARTY-LICENSES.md. - -Apache-2.0 upstream NOTICE entries identified for direct dependencies are -reproduced below. Preserve this file with Docker images, standalone binaries, -frontend bundles, and Electron desktop installers distributed to users. - ==== Apache-2.0 Notices ==== AWS SDK for Go diff --git a/README.en.md b/README.en.md index 257918cd..b31b6c39 100644 --- a/README.en.md +++ b/README.en.md @@ -326,7 +326,11 @@ Multi-node deployments must share `SESSION_SECRET` while using a different `NODE ## 🤝 Legal notes and derivative use -If you create or distribute a derivative version, read [NOTICE](./NOTICE) and [LICENSE](./LICENSE) in full and preserve the legal notices, attributions, original-project link, and change marking required there. +If you create or distribute a derivative version, read [NOTICE](./NOTICE) and [LICENSE](./LICENSE) in full and preserve the legal notices, attributions, original-project link, and change marking required there in a prominent location. + +In addition to the default AGPLv3 license, a derivative project used only by its operator automatically receives, without a separate application or approval, the no-fee, non-perpetual temporary commercial permission described in [NOTICE](./NOTICE) for MAX API additions and modifications that the project maintainers have the right to license, while it continuously keeps either the [MAX-API-Next/MAX-API](https://github.com/MAX-API-Next/MAX-API) project link or an acknowledgement of the [MAX-API-Next](https://github.com/MAX-API-Next) community clearly visible in a prominent homepage, footer, About, legal, or attribution location. + +This temporary permission does not cover, replace, or waive AGPLv3 or any license obligation for One API, New API, or other upstream material. It ceases to apply if the display condition is no longer met, or if it expires, is amended, or is terminated through NOTICE, the README, or an official MAX-API-Next community notice. Rights independently granted by AGPLv3 remain unaffected; continued use outside the temporary permission must comply with AGPLv3 or a separate written authorization from the applicable rights holder. See [NOTICE](./NOTICE) for the complete terms. ## 📜 License diff --git a/README.fr.md b/README.fr.md index 6b9d983c..9a938556 100644 --- a/README.fr.md +++ b/README.fr.md @@ -326,7 +326,11 @@ Les déploiements multi-nœuds doivent partager `SESSION_SECRET`, tout en utilis ## 🤝 Mentions légales et développements dérivés -Si vous créez ou distribuez une version dérivée, lisez intégralement [NOTICE](./NOTICE) et [LICENSE](./LICENSE), puis conservez les mentions légales, attributions, lien vers le projet d'origine et marquage des modifications qui y sont requis. +Si vous créez ou distribuez une version dérivée, lisez intégralement [NOTICE](./NOTICE) et [LICENSE](./LICENSE), puis conservez à un emplacement bien visible les mentions légales, attributions, lien vers le projet d'origine et marquage des modifications qui y sont requis. + +En complément de la licence AGPLv3 par défaut, un projet dérivé réservé à l'usage de son opérateur bénéficie automatiquement, sans demande ni approbation distincte, de l'autorisation commerciale temporaire, gratuite et non permanente décrite dans [NOTICE](./NOTICE) pour les ajouts et modifications de MAX API que les mainteneurs du projet ont le droit de concéder, tant qu'il affiche clairement soit le lien du projet [MAX-API-Next/MAX-API](https://github.com/MAX-API-Next/MAX-API), soit une mention de la communauté [MAX-API-Next](https://github.com/MAX-API-Next), à un emplacement bien visible de la page d'accueil, du pied de page, de la page À propos, des mentions légales ou des attributions. + +Cette autorisation temporaire ne couvre, ne remplace ni ne supprime les obligations de l'AGPLv3, de One API, de New API ou de tout autre composant amont. Elle cesse de s'appliquer si la condition d'affichage n'est plus respectée, ou si elle expire, est modifiée ou prend fin par une mention publiée dans NOTICE, le README ou la communauté officielle MAX-API-Next. Les droits accordés indépendamment par l'AGPLv3 restent inchangés ; toute utilisation poursuivie hors de cette autorisation temporaire doit respecter l'AGPLv3 ou faire l'objet d'une autorisation écrite distincte du titulaire de droits concerné. Consultez [NOTICE](./NOTICE) pour les conditions complètes. ## 📜 Licence diff --git a/README.ja.md b/README.ja.md index 1cc33b7d..265d18f7 100644 --- a/README.ja.md +++ b/README.ja.md @@ -326,7 +326,11 @@ docker compose up -d ## 🤝 法的事項と二次開発 -二次開発版を作成または配布する場合は、[NOTICE](./NOTICE) と [LICENSE](./LICENSE) を全文確認し、そこに定められた法的表示、帰属、原プロジェクトへのリンク、変更表示を保持してください。 +二次開発版を作成または配布する場合は、[NOTICE](./NOTICE) と [LICENSE](./LICENSE) を全文確認し、そこに定められた法的表示、帰属、原プロジェクトへのリンク、変更表示を目立つ場所に保持してください。 + +既定の AGPLv3 ライセンスに加え、運営者自身のみが使用する二次開発プロジェクトは、プロジェクトの保守者が許諾する権利を有する MAX API の追加・変更部分について、[NOTICE](./NOTICE) に定める無料かつ非永久の一時的商用許諾を、別途の申請や承認なく自動的に受けられます。適用中は、ホームページ、フッター、「概要」、法的表示、または帰属表示の目立つ場所に、[MAX-API-Next/MAX-API](https://github.com/MAX-API-Next/MAX-API) のプロジェクトリンク、または [MAX-API-Next](https://github.com/MAX-API-Next) コミュニティへの謝辞のいずれかを明確に表示し続ける必要があります。 + +この一時的許諾は、AGPLv3、One API、New API、その他の上流素材に関するライセンス義務を対象とせず、置き換えも免除もしません。表示条件を満たさなくなった場合、または NOTICE、README、MAX-API-Next 公式コミュニティの告知により期限切れ、変更、終了となった場合は適用されなくなります。AGPLv3 から独立して付与される権利には影響せず、一時的許諾の範囲外で利用を継続する場合は AGPLv3 を遵守するか、該当する権利者から別途書面による許諾を得る必要があります。完全な条件は [NOTICE](./NOTICE) を参照してください。 ## 📜 ライセンス diff --git a/README.md b/README.md index c3ed5306..b9f07b2d 100644 --- a/README.md +++ b/README.md @@ -328,7 +328,11 @@ docker compose up -d ## 🤝 法律说明与二次开发 -如果你基于本项目进行二次开发或分发,请先完整阅读 [NOTICE](./NOTICE) 和 [LICENSE](./LICENSE),并按其中要求保留法律声明、归属信息、原项目链接和修改标记。 +如果你基于本项目进行二次开发或分发,请先完整阅读 [NOTICE](./NOTICE) 和 [LICENSE](./LICENSE),并按其中要求在项目明显位置保留法律声明、归属信息、原项目链接和修改标记。 + +除 AGPLv3 默认授权外,当前阶段,仅供自身使用的二次开发项目如持续在项目主页、页脚、“关于”、法律声明或其他明显位置清晰展示 [MAX-API-Next/MAX-API](https://github.com/MAX-API-Next/MAX-API) 项目链接,或对 [MAX-API-Next](https://github.com/MAX-API-Next) 社区的致谢,即自动免费获得 MAX API 项目方对其有权授权的新增与修改部分所提供的非永久临时商用授权,无需另行申请或审批。 + +该临时授权不覆盖、替代或免除 AGPLv3、One API、New API 或其他上游许可义务;展示条件不再满足,或项目方通过 NOTICE、README 或 MAX-API-Next 官方社区公告其到期、调整或终止时,该临时授权即不再适用。AGPLv3 独立授予的权利不受影响;超出临时授权继续使用时,须遵守 AGPLv3 或另行取得适用权利人的书面授权。完整条件以 [NOTICE](./NOTICE) 为准。 ## 📜 许可证 diff --git a/README.zh_CN.md b/README.zh_CN.md index c3ed5306..b9f07b2d 100644 --- a/README.zh_CN.md +++ b/README.zh_CN.md @@ -328,7 +328,11 @@ docker compose up -d ## 🤝 法律说明与二次开发 -如果你基于本项目进行二次开发或分发,请先完整阅读 [NOTICE](./NOTICE) 和 [LICENSE](./LICENSE),并按其中要求保留法律声明、归属信息、原项目链接和修改标记。 +如果你基于本项目进行二次开发或分发,请先完整阅读 [NOTICE](./NOTICE) 和 [LICENSE](./LICENSE),并按其中要求在项目明显位置保留法律声明、归属信息、原项目链接和修改标记。 + +除 AGPLv3 默认授权外,当前阶段,仅供自身使用的二次开发项目如持续在项目主页、页脚、“关于”、法律声明或其他明显位置清晰展示 [MAX-API-Next/MAX-API](https://github.com/MAX-API-Next/MAX-API) 项目链接,或对 [MAX-API-Next](https://github.com/MAX-API-Next) 社区的致谢,即自动免费获得 MAX API 项目方对其有权授权的新增与修改部分所提供的非永久临时商用授权,无需另行申请或审批。 + +该临时授权不覆盖、替代或免除 AGPLv3、One API、New API 或其他上游许可义务;展示条件不再满足,或项目方通过 NOTICE、README 或 MAX-API-Next 官方社区公告其到期、调整或终止时,该临时授权即不再适用。AGPLv3 独立授予的权利不受影响;超出临时授权继续使用时,须遵守 AGPLv3 或另行取得适用权利人的书面授权。完整条件以 [NOTICE](./NOTICE) 为准。 ## 📜 许可证 diff --git a/README.zh_TW.md b/README.zh_TW.md index 4ca3eb21..f40ad184 100644 --- a/README.zh_TW.md +++ b/README.zh_TW.md @@ -328,7 +328,11 @@ docker compose up -d ## 🤝 法律說明與二次開發 -如果你基於本專案進行二次開發或散布,請先完整閱讀 [NOTICE](./NOTICE) 和 [LICENSE](./LICENSE),並依其中要求保留法律聲明、歸屬資訊、原專案連結和修改標記。 +如果你基於本專案進行二次開發或散布,請先完整閱讀 [NOTICE](./NOTICE) 和 [LICENSE](./LICENSE),並依其中要求在專案明顯位置保留法律聲明、歸屬資訊、原專案連結和修改標記。 + +除 AGPLv3 預設授權外,現階段,僅供自身使用的二次開發專案如持續在專案首頁、頁尾、「關於」、法律聲明或其他明顯位置清楚展示 [MAX-API-Next/MAX-API](https://github.com/MAX-API-Next/MAX-API) 專案連結,或對 [MAX-API-Next](https://github.com/MAX-API-Next) 社群的致謝,即自動免費取得 MAX API 專案方對其有權授權的新增與修改部分所提供的非永久臨時商用授權,無需另行申請或審批。 + +此臨時授權不涵蓋、取代或免除 AGPLv3、One API、New API 或其他上游授權義務;展示條件不再符合,或專案方透過 NOTICE、README 或 MAX-API-Next 官方社群公告其到期、調整或終止時,此臨時授權即不再適用。AGPLv3 獨立授予的權利不受影響;超出臨時授權繼續使用時,須遵守 AGPLv3 或另行取得適用權利人的書面授權。完整條件以 [NOTICE](./NOTICE) 為準。 ## 📜 許可證 diff --git a/controller/audit.go b/controller/audit.go index 99a91cef..176a6699 100644 --- a/controller/audit.go +++ b/controller/audit.go @@ -34,6 +34,7 @@ var auditContentTemplates = map[string]string{ "billing.reconciliation_blocking_policy_update": "Updated billing reconciliation default user-blocking policy to ${block_user_by_default}", "billing.reconciliation_review": "Reviewed billing settlement ${settlement_id} (block user: ${block_user})", "billing.reconciliation_review_batch": "Reviewed and closed ${count} billing reconciliation alerts", + "billing.manual_task_settlement_complete": "Completed manual task billing settlement ${settlement_id} with exact quota ${actual_quota}", "channel.create": "Created channel ${name} (type ${type}, count ${count})", "channel.update": "Updated channel ${name} (ID: ${id})", diff --git a/controller/smart_ops_alert.go b/controller/smart_ops_alert.go index e57e8d03..e82338f8 100644 --- a/controller/smart_ops_alert.go +++ b/controller/smart_ops_alert.go @@ -62,6 +62,12 @@ type billingSettlementBatchReviewRequest struct { Items []model.BillingSettlementReviewTarget `json:"items"` } +type manualTaskBillingCompletionRequest struct { + Revision int64 `json:"revision"` + ActualQuota *int64 `json:"actual_quota"` + Note string `json:"note"` +} + // ReviewBillingSettlements closes a bounded set of current reconciliation // alerts after the administrator acknowledges them. func ReviewBillingSettlements(c *gin.Context) { @@ -84,6 +90,9 @@ func ReviewBillingSettlements(c *gin.Context) { case errors.Is(err, model.ErrBillingSettlementReviewConflict): status = http.StatusConflict message = "one or more billing settlement alerts changed; refresh and review again" + case errors.Is(err, model.ErrBillingSettlementCompletionRequired): + status = http.StatusConflict + message = "one or more task settlements require an exact final quota and cannot be batch closed" } if status == http.StatusInternalServerError { common.SysError(fmt.Sprintf("failed to review billing settlements: %v", err)) @@ -139,6 +148,9 @@ func ReviewBillingSettlement(c *gin.Context) { case errors.Is(err, model.ErrBillingSettlementReviewConflict): status = http.StatusConflict message = "billing settlement is no longer pending manual reconciliation" + case errors.Is(err, model.ErrBillingSettlementCompletionRequired): + status = http.StatusConflict + message = "task settlement requires an exact final quota before it can be closed" } if status == http.StatusInternalServerError { common.SysError(fmt.Sprintf("failed to review billing settlement %d: %v", id, err)) @@ -161,8 +173,71 @@ func ReviewBillingSettlement(c *gin.Context) { }) } +// CompleteManualTaskBillingSettlement applies a root administrator's exact +// final quota to a reconciliation-only task settlement. Zero is a valid and +// explicit financial result; omission is rejected. +func CompleteManualTaskBillingSettlement(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil || id <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "invalid billing settlement id"}) + return + } + var request manualTaskBillingCompletionRequest + if err := common.DecodeJson(c.Request.Body, &request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "invalid manual task billing completion request"}) + return + } + result, err := service.CompleteManualTaskBillingSettlement( + id, + request.Revision, + c.GetInt("id"), + request.ActualQuota, + request.Note, + ) + if err != nil { + status := http.StatusInternalServerError + message := "failed to complete manual task billing settlement" + switch { + case errors.Is(err, service.ErrInvalidBillingSettlementReconciliationReview): + status = http.StatusBadRequest + message = err.Error() + case errors.Is(err, model.ErrBillingSettlementReviewConflict), + errors.Is(err, model.ErrBillingSettlementOperationConflict), + errors.Is(err, model.ErrBillingSettlementTaskConflict), + errors.Is(err, model.ErrBillingSettlementManualReview): + status = http.StatusConflict + message = "manual task billing settlement could not be applied safely; refresh and reconcile the current record" + case errors.Is(err, model.ErrTokenQuotaInsufficient): + status = http.StatusConflict + message = "the token quota mirror is inconsistent; repair the token record before completing this settlement" + case errors.Is(err, model.ErrSubscriptionRefundClamped): + status = http.StatusConflict + message = "the subscription usage mirror is lower than the refund; repair the subscription record before completing this settlement" + case errors.Is(err, model.ErrSubscriptionSettlementUnbound), + errors.Is(err, model.ErrSubscriptionSettlementPeriodChanged): + status = http.StatusConflict + message = "the subscription reservation is unbound or its period changed; escalate this record for manual reconciliation" + } + if status == http.StatusInternalServerError { + common.SysError(fmt.Sprintf("failed to complete manual task billing settlement %d: %v", id, err)) + } + c.JSON(status, gin.H{"success": false, "message": message}) + return + } + recordManageAudit(c, "billing.manual_task_settlement_complete", map[string]interface{}{ + "settlement_id": result.SettlementID, + "task_id": result.TaskID, + "target_user_id": result.UserID, + "operation_key": result.OperationKey, + "actual_quota": result.ActualQuota, + "applied_funding_delta": result.AppliedFundingDelta, + "already_applied": result.AlreadyApplied, + }) + c.JSON(http.StatusOK, gin.H{"success": true, "data": result}) +} + // GetBillingSettlementReconciliation exposes bounded, read-only evidence for -// unresolved positive final settlements. It does not retry or mutate records. +// open billing reconciliation records. It does not retry or mutate records. func GetBillingSettlementReconciliation(c *gin.Context) { limit, err := parseIntQuery(c, "limit") if err != nil { diff --git a/controller/smart_ops_alert_test.go b/controller/smart_ops_alert_test.go index 3758ad82..efc623cd 100644 --- a/controller/smart_ops_alert_test.go +++ b/controller/smart_ops_alert_test.go @@ -7,6 +7,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" "github.com/MAX-API-Next/MAX-API/common" "github.com/MAX-API-Next/MAX-API/model" @@ -27,6 +28,346 @@ func TestBillingSettlementMutationRequestsPreserveExplicitFalse(t *testing.T) { require.NoError(t, common.Unmarshal([]byte(`{"block_user":false,"note":"verified"}`), &review)) require.NotNil(t, review.BlockUser) assert.False(t, *review.BlockUser) + + var completion manualTaskBillingCompletionRequest + require.NoError(t, common.Unmarshal([]byte(`{"revision":1,"actual_quota":0,"note":"verified"}`), &completion)) + require.NotNil(t, completion.ActualQuota) + assert.Zero(t, *completion.ActualQuota) + + var omittedCompletion manualTaskBillingCompletionRequest + require.NoError(t, common.Unmarshal([]byte(`{"revision":1,"note":"verified"}`), &omittedCompletion)) + assert.Nil(t, omittedCompletion.ActualQuota) +} + +func TestCompleteManualTaskBillingSettlementAuditsExactCompletion(t *testing.T) { + gin.SetMode(gin.TestMode) + oldDB := model.DB + oldLogDB := model.LOG_DB + oldRedisEnabled := common.RedisEnabled + oldMemoryCacheEnabled := common.MemoryCacheEnabled + oldLogConsumeEnabled := common.LogConsumeEnabled + common.RedisEnabled = false + common.MemoryCacheEnabled = false + common.LogConsumeEnabled = false + + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_")) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate( + &model.User{}, + &model.Task{}, + &model.BillingSettlement{}, + &model.CacheInvalidationTask{}, + &model.Log{}, + )) + model.DB = db + model.LOG_DB = db + t.Cleanup(func() { + if sqlDB, dbErr := db.DB(); dbErr == nil { + _ = sqlDB.Close() + } + model.DB = oldDB + model.LOG_DB = oldLogDB + common.RedisEnabled = oldRedisEnabled + common.MemoryCacheEnabled = oldMemoryCacheEnabled + common.LogConsumeEnabled = oldLogConsumeEnabled + }) + + administrator := model.User{ + Id: 7051, + Username: "manual-task-billing-root", + AffCode: "manual-task-billing-root-aff", + Role: common.RoleRootUser, + Status: common.UserStatusEnabled, + Group: "default", + } + owner := model.User{ + Id: 7052, + Username: "manual-task-billing-owner", + AffCode: "manual-task-billing-owner-aff", + Role: common.RoleCommonUser, + Status: common.UserStatusEnabled, + Group: "default", + Quota: 900, + } + require.NoError(t, db.Create(&administrator).Error) + require.NoError(t, db.Create(&owner).Error) + task := model.Task{ + TaskID: "controller-manual-task-completion", + UserId: owner.Id, + Group: "default", + Quota: 100, + Status: model.TaskStatus(model.TaskStatusInProgress), + CreatedAt: 1, + UpdatedAt: 1, + PrivateData: model.TaskPrivateData{ + BillingSource: "wallet", + BillingContext: &model.TaskBillingContext{ + OriginModelName: "manual-task-model", + }, + }, + } + task.SetData(map[string]any{"provider_status": "complete"}) + require.NoError(t, db.Create(&task).Error) + settlement := model.BillingSettlement{ + OperationKey: model.BillingTaskFinalizeOperationKey(task.ID), + Source: model.BillingSettlementSourceWallet, + UserID: owner.Id, + TaskID: task.ID, + TaskQuota: 100, + TaskQuotaTarget: 100, + Status: model.BillingSettlementStatusManual, + LastError: "provider usage requires an exact quota", + CreatedAt: 1, + UpdatedAt: 1, + Revision: 1, + } + require.NoError(t, db.Create(&settlement).Error) + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest( + http.MethodPost, + fmt.Sprintf("/api/smart-ops/billing-settlements/%d/complete-task", settlement.ID), + bytes.NewBufferString(`{"revision":1,"actual_quota":40,"note":"Verified provider evidence and exact usage."}`), + ) + ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", settlement.ID)}} + ctx.Set("id", administrator.Id) + ctx.Set("username", administrator.Username) + ctx.Set("role", administrator.Role) + + CompleteManualTaskBillingSettlement(ctx) + + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) + assert.Contains(t, recorder.Body.String(), `"actual_quota":40`) + var storedOwner model.User + require.NoError(t, db.First(&storedOwner, owner.Id).Error) + assert.EqualValues(t, 960, storedOwner.Quota) + var storedTask model.Task + require.NoError(t, db.First(&storedTask, task.ID).Error) + assert.Equal(t, 40, storedTask.Quota) + var storedSettlement model.BillingSettlement + require.NoError(t, db.First(&storedSettlement, settlement.ID).Error) + assert.Equal(t, model.BillingSettlementStatusApplied, storedSettlement.Status) + assert.Equal(t, administrator.Id, storedSettlement.ReconciliationReviewedBy) + + var audit model.Log + require.NoError(t, db.Where("type = ?", model.LogTypeManage).Order("id DESC").First(&audit).Error) + assert.Equal(t, administrator.Id, audit.UserId) + assert.Equal(t, administrator.Username, audit.Username) + var other struct { + Op struct { + Action string `json:"action"` + Params map[string]interface{} `json:"params"` + } `json:"op"` + } + require.NoError(t, common.UnmarshalJsonStr(audit.Other, &other)) + assert.Equal(t, "billing.manual_task_settlement_complete", other.Op.Action) + assert.EqualValues(t, settlement.ID, other.Op.Params["settlement_id"]) + assert.EqualValues(t, task.ID, other.Op.Params["task_id"]) + assert.EqualValues(t, owner.Id, other.Op.Params["target_user_id"]) + assert.EqualValues(t, 40, other.Op.Params["actual_quota"]) + assert.Equal(t, model.BillingTaskManualCompletionOperationKey(task.ID), other.Op.Params["operation_key"]) +} + +func TestCompleteManualTaskBillingSettlementMapsTokenRefundConflict(t *testing.T) { + gin.SetMode(gin.TestMode) + oldDB := model.DB + oldLogDB := model.LOG_DB + oldRedisEnabled := common.RedisEnabled + oldMemoryCacheEnabled := common.MemoryCacheEnabled + oldLogConsumeEnabled := common.LogConsumeEnabled + common.RedisEnabled = false + common.MemoryCacheEnabled = false + common.LogConsumeEnabled = false + + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_")) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate( + &model.User{}, + &model.Token{}, + &model.Task{}, + &model.BillingSettlement{}, + &model.CacheInvalidationTask{}, + &model.Log{}, + )) + model.DB = db + model.LOG_DB = db + t.Cleanup(func() { + if sqlDB, dbErr := db.DB(); dbErr == nil { + _ = sqlDB.Close() + } + model.DB = oldDB + model.LOG_DB = oldLogDB + common.RedisEnabled = oldRedisEnabled + common.MemoryCacheEnabled = oldMemoryCacheEnabled + common.LogConsumeEnabled = oldLogConsumeEnabled + }) + + administrator := model.User{ + Id: 7061, Username: "manual-task-token-root", AffCode: "manual-task-token-root-aff", + Role: common.RoleRootUser, Status: common.UserStatusEnabled, Group: "default", + } + owner := model.User{ + Id: 7062, Username: "manual-task-token-owner", AffCode: "manual-task-token-owner-aff", + Role: common.RoleCommonUser, Status: common.UserStatusEnabled, Group: "default", Quota: 900, + } + token := model.Token{ + Id: 7063, UserId: owner.Id, Key: "manual-task-token-conflict", Status: common.TokenStatusEnabled, + Name: "manual-task-token-conflict", RemainQuota: 100, UsedQuota: 10, Group: "default", + } + require.NoError(t, db.Create(&administrator).Error) + require.NoError(t, db.Create(&owner).Error) + require.NoError(t, db.Create(&token).Error) + task := model.Task{ + TaskID: "controller-manual-task-token-conflict", UserId: owner.Id, Group: "default", Quota: 100, + Status: model.TaskStatus(model.TaskStatusInProgress), CreatedAt: 1, UpdatedAt: 1, + PrivateData: model.TaskPrivateData{ + TokenId: token.Id, BillingSource: "wallet", + BillingContext: &model.TaskBillingContext{OriginModelName: "manual-task-model"}, + }, + } + task.SetData(map[string]any{"provider_status": "complete"}) + require.NoError(t, db.Create(&task).Error) + settlement := model.BillingSettlement{ + OperationKey: model.BillingTaskFinalizeOperationKey(task.ID), Source: model.BillingSettlementSourceWallet, + UserID: owner.Id, TokenID: token.Id, TaskID: task.ID, TaskQuota: 100, TaskQuotaTarget: 100, + Status: model.BillingSettlementStatusManual, LastError: "provider usage requires an exact quota", + CreatedAt: 1, UpdatedAt: 1, Revision: 1, + } + require.NoError(t, db.Create(&settlement).Error) + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest( + http.MethodPost, + fmt.Sprintf("/api/smart-ops/billing-settlements/%d/complete-task", settlement.ID), + bytes.NewBufferString(`{"revision":1,"actual_quota":40,"note":"Verified provider evidence and exact usage."}`), + ) + ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", settlement.ID)}} + ctx.Set("id", administrator.Id) + ctx.Set("username", administrator.Username) + ctx.Set("role", administrator.Role) + + CompleteManualTaskBillingSettlement(ctx) + + require.Equal(t, http.StatusConflict, recorder.Code, recorder.Body.String()) + assert.Contains(t, recorder.Body.String(), "token quota mirror") + var storedOwner model.User + require.NoError(t, db.First(&storedOwner, owner.Id).Error) + assert.EqualValues(t, 900, storedOwner.Quota) + var storedToken model.Token + require.NoError(t, db.First(&storedToken, token.Id).Error) + assert.EqualValues(t, 100, storedToken.RemainQuota) + assert.EqualValues(t, 10, storedToken.UsedQuota) +} + +func TestCompleteManualTaskBillingSettlementMapsClampedSubscriptionRefund(t *testing.T) { + gin.SetMode(gin.TestMode) + oldDB := model.DB + oldLogDB := model.LOG_DB + oldRedisEnabled := common.RedisEnabled + oldMemoryCacheEnabled := common.MemoryCacheEnabled + oldLogConsumeEnabled := common.LogConsumeEnabled + common.RedisEnabled = false + common.MemoryCacheEnabled = false + common.LogConsumeEnabled = false + + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_")) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate( + &model.User{}, + &model.Token{}, + &model.Task{}, + &model.UserSubscription{}, + &model.SubscriptionPreConsumeRecord{}, + &model.BillingSettlement{}, + &model.CacheInvalidationTask{}, + &model.Log{}, + )) + model.DB = db + model.LOG_DB = db + t.Cleanup(func() { + if sqlDB, dbErr := db.DB(); dbErr == nil { + _ = sqlDB.Close() + } + model.DB = oldDB + model.LOG_DB = oldLogDB + common.RedisEnabled = oldRedisEnabled + common.MemoryCacheEnabled = oldMemoryCacheEnabled + common.LogConsumeEnabled = oldLogConsumeEnabled + }) + + administrator := model.User{ + Id: 7071, Username: "manual-task-subscription-root", AffCode: "manual-task-subscription-root-aff", + Role: common.RoleRootUser, Status: common.UserStatusEnabled, Group: "default", + } + owner := model.User{ + Id: 7072, Username: "manual-task-subscription-owner", AffCode: "manual-task-subscription-owner-aff", + Role: common.RoleCommonUser, Status: common.UserStatusEnabled, Group: "default", + } + token := model.Token{ + Id: 7073, UserId: owner.Id, Key: "manual-task-subscription-token", Status: common.TokenStatusEnabled, + Name: "manual-task-subscription-token", RemainQuota: 100, UsedQuota: 100, Group: "default", + } + subscription := model.UserSubscription{ + Id: 7074, UserId: owner.Id, AmountTotal: 1000, AmountUsed: 50, + Status: "active", StartTime: 1, EndTime: time.Now().Add(24 * time.Hour).Unix(), + } + require.NoError(t, db.Create(&administrator).Error) + require.NoError(t, db.Create(&owner).Error) + require.NoError(t, db.Create(&token).Error) + require.NoError(t, db.Create(&subscription).Error) + task := model.Task{ + TaskID: "controller-manual-task-subscription-clamp", UserId: owner.Id, Group: "default", Quota: 100, + Status: model.TaskStatus(model.TaskStatusInProgress), CreatedAt: 1, UpdatedAt: 1, + PrivateData: model.TaskPrivateData{ + TokenId: token.Id, SubscriptionId: subscription.Id, BillingSource: "subscription", + BillingRequestId: "controller-manual-task-subscription-clamp-preconsume", + BillingContext: &model.TaskBillingContext{OriginModelName: "manual-task-model"}, + }, + } + task.SetData(map[string]any{"provider_status": "complete"}) + require.NoError(t, db.Create(&task).Error) + require.NoError(t, db.Create(&model.SubscriptionPreConsumeRecord{ + RequestId: task.PrivateData.BillingRequestId, UserId: owner.Id, TokenId: token.Id, + UserSubscriptionId: subscription.Id, PreConsumed: 100, Status: "consumed", + }).Error) + settlement := model.BillingSettlement{ + OperationKey: model.BillingTaskFinalizeOperationKey(task.ID), Source: model.BillingSettlementSourceSubscription, + UserID: owner.Id, TokenID: token.Id, SubscriptionID: subscription.Id, + SubscriptionPreConsumeRequestID: task.PrivateData.BillingRequestId, + TaskID: task.ID, TaskQuota: 100, TaskQuotaTarget: 100, + Status: model.BillingSettlementStatusManual, LastError: "provider usage requires an exact quota", + CreatedAt: 1, UpdatedAt: 1, Revision: 1, + } + require.NoError(t, db.Create(&settlement).Error) + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest( + http.MethodPost, + fmt.Sprintf("/api/smart-ops/billing-settlements/%d/complete-task", settlement.ID), + bytes.NewBufferString(`{"revision":1,"actual_quota":0,"note":"Verified provider evidence for a full refund."}`), + ) + ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", settlement.ID)}} + ctx.Set("id", administrator.Id) + ctx.Set("username", administrator.Username) + ctx.Set("role", administrator.Role) + + CompleteManualTaskBillingSettlement(ctx) + + require.Equal(t, http.StatusConflict, recorder.Code, recorder.Body.String()) + assert.Contains(t, recorder.Body.String(), "subscription usage mirror") + var storedSubscription model.UserSubscription + require.NoError(t, db.First(&storedSubscription, subscription.Id).Error) + assert.EqualValues(t, 50, storedSubscription.AmountUsed) + var storedToken model.Token + require.NoError(t, db.First(&storedToken, token.Id).Error) + assert.EqualValues(t, 100, storedToken.RemainQuota) + assert.EqualValues(t, 100, storedToken.UsedQuota) } func TestReviewBillingSettlementAuditRecordsActingAdministrator(t *testing.T) { diff --git a/model/billing_settlement.go b/model/billing_settlement.go index b9146e69..4757623d 100644 --- a/model/billing_settlement.go +++ b/model/billing_settlement.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "sort" + "strings" "sync" "time" @@ -29,6 +30,8 @@ const ( const billingRequestOperationPrefix = "request:" const billingRequestFinalizeSuffix = ":finalize" +const billingTaskOperationPrefix = "task:" +const billingTaskManualCompletionSuffix = ":manual-completion" const billingSettlementBacklogSampleInterval = 30 * time.Second @@ -38,6 +41,8 @@ var ( ErrBillingSettlementOperationConflict = errors.New("billing settlement operation conflict") ErrBillingSettlementRecordNotDurable = errors.New("billing settlement record was not durably created") ErrBillingSettlementReviewConflict = errors.New("billing settlement is no longer reviewable") + ErrBillingSettlementCompletionRequired = errors.New("manual task settlement requires financial completion") + ErrSubscriptionRefundClamped = errors.New("subscription refund was clamped") ErrSubscriptionSettlementUnbound = errors.New("subscription settlement is not bound to its pre-consume request") ErrSubscriptionSettlementPeriodChanged = errors.New("subscription settlement crossed a quota reset period") ) @@ -174,8 +179,8 @@ type BillingSettlementInput struct { } // BillingSettlementBacklogStats is a read-only operational projection of open -// positive-final-settlement alerts. User admission is evaluated separately by -// the global and per-record blocking policy. +// reconciliation alerts. User admission is evaluated separately and remains +// limited to unresolved positive request-finalize funding. type BillingSettlementBacklogStats struct { Count int64 `gorm:"column:record_count"` OldestCreatedAt int64 `gorm:"column:oldest_created_at"` @@ -194,6 +199,9 @@ type BillingSettlementReconciliationItem struct { SubscriptionID int `json:"subscription_id"` TokenID int `json:"token_id"` TaskID int64 `json:"task_id"` + TaskQuota int64 `json:"task_quota"` + TaskQuotaTarget int64 `json:"task_quota_target"` + RequiresManualCompletion bool `json:"requires_manual_completion"` FundingDelta int64 `json:"funding_delta"` AppliedFundingDelta int64 `json:"applied_funding_delta"` TokenDelta int64 `json:"token_delta"` @@ -241,13 +249,32 @@ func unresolvedPositiveFinalizeSettlementScope(db *gorm.DB) *gorm.DB { Where("operation_key LIKE ?", BillingRequestFinalizeOperationKey("%")) } -func openPositiveFinalizeSettlementAlertScope(db *gorm.DB) *gorm.DB { - return unresolvedPositiveFinalizeSettlementScope(db). - Where("(reconciliation_reviewed_at = ? OR user_blocking_override = ?)", 0, true) +func unresolvedBillingReconciliationScope(db *gorm.DB) *gorm.DB { + return db.Model(&BillingSettlement{}). + Where("status IN ?", []string{BillingSettlementStatusPending, BillingSettlementStatusManual}). + Where( + "(funding_delta > ? AND operation_key LIKE ?) OR (task_id > ? AND operation_key LIKE ?)", + 0, + BillingRequestFinalizeOperationKey("%"), + 0, + billingTaskOperationPrefix+"%"+billingRequestFinalizeSuffix, + ) +} + +func openBillingReconciliationAlertScope(db *gorm.DB) *gorm.DB { + return unresolvedBillingReconciliationScope(db). + Where( + "reconciliation_reviewed_at = ? OR (funding_delta > ? AND operation_key LIKE ? AND user_blocking_override = ?)", + 0, + 0, + BillingRequestFinalizeOperationKey("%"), + true, + ) } func blockingPositiveFinalizeSettlementScope(db *gorm.DB, blockUserByDefault bool) *gorm.DB { - scope := openPositiveFinalizeSettlementAlertScope(db) + scope := unresolvedPositiveFinalizeSettlementScope(db). + Where("(reconciliation_reviewed_at = ? OR user_blocking_override = ?)", 0, true) if blockUserByDefault { return scope.Where("(user_blocking_override IS NULL OR user_blocking_override = ?)", true) } @@ -335,7 +362,7 @@ func ProcessPendingBillingSettlementsOnce() { func observeBillingSettlementBacklog(observedAt time.Time) { stats, err := GetUnresolvedPositiveFinalizeSettlementStats() if err != nil { - common.SysLog(fmt.Sprintf("failed to query unresolved positive final billing settlements: %s", err.Error())) + common.SysLog(fmt.Sprintf("failed to query open billing reconciliation settlements: %s", err.Error())) return } billingSettlementBacklogObserverState.RLock() @@ -346,23 +373,22 @@ func observeBillingSettlementBacklog(observedAt time.Time) { } } -// GetUnresolvedPositiveFinalizeSettlementStats returns the count and oldest -// creation time for open positive request-finalize alerts. Reviewed rows -// remain durable financial records and leave this operational projection -// unless an administrator explicitly keeps the affected user blocked or new -// failure evidence reopens them. +// GetUnresolvedPositiveFinalizeSettlementStats keeps its historical name for +// API compatibility. It returns all open reconciliation alerts: unresolved +// positive request-finalize funding and reconciliation-only task-finalize +// records. Only the former can block new paid requests. func GetUnresolvedPositiveFinalizeSettlementStats() (BillingSettlementBacklogStats, error) { if DB == nil { return BillingSettlementBacklogStats{}, errors.New("database is not initialized") } - return getOpenPositiveFinalizeSettlementAlertStatsDB(DB) + return getOpenBillingReconciliationAlertStatsDB(DB) } -// GetUnresolvedPositiveFinalizeSettlements returns bounded, read-only evidence -// for open operator alerts. Reviewed rows remain durable financial records and -// are excluded after closure unless an administrator explicitly keeps the -// affected user blocked. This function never retries, updates, deletes, or -// otherwise mutates a settlement record or any financial balance. +// GetUnresolvedPositiveFinalizeSettlements keeps its historical name for API +// compatibility and returns bounded, read-only evidence for every open +// reconciliation alert. Reviewed task-finalize records leave the projection; +// reviewed positive request-finalize records remain only when explicitly kept +// blocking. This function never mutates financial state. func GetUnresolvedPositiveFinalizeSettlements(limit int) (BillingSettlementReconciliationData, error) { if DB == nil { return BillingSettlementReconciliationData{}, errors.New("database is not initialized") @@ -387,19 +413,19 @@ func GetUnresolvedPositiveFinalizeSettlements(limit int) (BillingSettlementRecon }) } err := DB.Transaction(func(tx *gorm.DB) error { - stats, err := getOpenPositiveFinalizeSettlementAlertStatsDB(tx) + stats, err := getOpenBillingReconciliationAlertStatsDB(tx) if err != nil { return err } data.TotalCount = stats.Count data.OldestCreatedAt = stats.OldestCreatedAt - if err := openPositiveFinalizeSettlementAlertScope(tx). + if err := openBillingReconciliationAlertScope(tx). Where("status = ?", BillingSettlementStatusPending). Count(&data.PendingCount).Error; err != nil { return err } - if err := openPositiveFinalizeSettlementAlertScope(tx). + if err := openBillingReconciliationAlertScope(tx). Where("status = ?", BillingSettlementStatusManual). Count(&data.ManualCount).Error; err != nil { return err @@ -417,9 +443,9 @@ func GetUnresolvedPositiveFinalizeSettlements(limit int) (BillingSettlementRecon } items := make([]BillingSettlementReconciliationItem, 0, limit+1) - if err := openPositiveFinalizeSettlementAlertScope(tx). + if err := openBillingReconciliationAlertScope(tx). Select( - "id", "revision", "operation_key", "status", "source", "user_id", "subscription_id", "token_id", "task_id", + "id", "revision", "operation_key", "status", "source", "user_id", "subscription_id", "token_id", "task_id", "task_quota", "task_quota_target", "funding_delta", "applied_funding_delta", "token_delta", "applied_token_delta", "attempts", "last_error", "next_attempt", "created_at", "updated_at", "reconciliation_reviewed_at", "reconciliation_reviewed_by", "reconciliation_review_note", "user_blocking_override", @@ -442,10 +468,15 @@ func GetUnresolvedPositiveFinalizeSettlements(limit int) (BillingSettlementRecon items[index].ReconciliationReviewNote = common.SanitizePersistedLogContent( common.MaskSensitiveInfo(items[index].ReconciliationReviewNote), ) - items[index].RecordBlocksUser = billingSettlementBlocksUser( - items[index].UserBlockingOverride, - data.BlockUserByDefault, - ) + items[index].RequiresManualCompletion = billingSettlementRequiresManualTaskCompletion(BillingSettlement{ + Status: items[index].Status, TaskID: items[index].TaskID, OperationKey: items[index].OperationKey, + FundingDelta: items[index].FundingDelta, TokenDelta: items[index].TokenDelta, + TaskQuota: items[index].TaskQuota, TaskQuotaTarget: items[index].TaskQuotaTarget, + }) + items[index].RecordBlocksUser = items[index].FundingDelta > 0 && + strings.HasPrefix(items[index].OperationKey, billingRequestOperationPrefix) && + strings.HasSuffix(items[index].OperationKey, billingRequestFinalizeSuffix) && + billingSettlementBlocksUser(items[index].UserBlockingOverride, data.BlockUserByDefault) if items[index].UserID > 0 { displayedUserIDs = append(displayedUserIDs, items[index].UserID) } @@ -472,9 +503,9 @@ func GetUnresolvedPositiveFinalizeSettlements(limit int) (BillingSettlementRecon return data, err } -func getOpenPositiveFinalizeSettlementAlertStatsDB(db *gorm.DB) (BillingSettlementBacklogStats, error) { +func getOpenBillingReconciliationAlertStatsDB(db *gorm.DB) (BillingSettlementBacklogStats, error) { var stats BillingSettlementBacklogStats - err := openPositiveFinalizeSettlementAlertScope(db). + err := openBillingReconciliationAlertScope(db). Select("COUNT(*) AS record_count, COALESCE(MIN(created_at), 0) AS oldest_created_at"). Scan(&stats).Error return stats, err @@ -487,6 +518,15 @@ func billingSettlementBlocksUser(override *bool, blockUserByDefault bool) bool { return blockUserByDefault } +func billingSettlementRequiresManualTaskCompletion(record BillingSettlement) bool { + return record.Status == BillingSettlementStatusManual && + record.TaskID > 0 && + record.OperationKey == BillingTaskFinalizeOperationKey(record.TaskID) && + record.FundingDelta == 0 && + record.TokenDelta == 0 && + record.TaskQuotaTarget == record.TaskQuota +} + // HasUnresolvedPositiveFinalizeSettlement reports whether a user has an // upstream-accepted request whose positive final settlement has not completed. // New paid requests must stop before they can consume more service against the @@ -560,7 +600,23 @@ func ReviewBillingSettlements(targets []BillingSettlementReviewTarget, reviewerI reviewedAt := time.Now() updatedIDs := make([]int64, 0, len(sortedTargets)) for _, target := range sortedTargets { - result := openPositiveFinalizeSettlementAlertScope(tx). + var current BillingSettlement + if err := withRowLock(openBillingReconciliationAlertScope(tx)). + Select("id", "revision", "status", "task_id", "operation_key", "funding_delta", "token_delta", "task_quota", "task_quota_target"). + Where("id = ? AND revision = ?", target.ID, target.Revision). + First(¤t).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrBillingSettlementReviewConflict + } + return err + } + if billingSettlementRequiresManualTaskCompletion(current) { + return ErrBillingSettlementCompletionRequired + } + if billingSettlementIsManualTaskCompletion(current.OperationKey, current.TaskID) { + return ErrBillingSettlementCompletionRequired + } + result := openBillingReconciliationAlertScope(tx). Where("id = ? AND revision = ?", target.ID, target.Revision). UpdateColumns(billingSettlementReviewUpdates(reviewerID, false, "", reviewedAt)) if result.Error != nil { @@ -625,7 +681,14 @@ func ReviewBillingSettlement(id int64, reviewerID int, blockUser bool, note stri var record BillingSettlement if err := DB.Select( "id", + "operation_key", + "status", "user_id", + "task_id", + "funding_delta", + "token_delta", + "task_quota", + "task_quota_target", "revision", "reconciliation_reviewed_at", "reconciliation_reviewed_by", @@ -637,10 +700,16 @@ func ReviewBillingSettlement(id int64, reviewerID int, blockUser bool, note stri } return BillingSettlement{}, err } + if billingSettlementRequiresManualTaskCompletion(record) { + return BillingSettlement{}, ErrBillingSettlementCompletionRequired + } + if billingSettlementIsManualTaskCompletion(record.OperationKey, record.TaskID) { + return BillingSettlement{}, ErrBillingSettlementCompletionRequired + } reviewedAt := time.Now() result := billingSettlementReviewSnapshotScope( - unresolvedPositiveFinalizeSettlementScope(DB), + unresolvedBillingReconciliationScope(DB), record, ). UpdateColumns(billingSettlementReviewUpdates(reviewerID, blockUser, note, reviewedAt)) @@ -649,7 +718,7 @@ func ReviewBillingSettlement(id int64, reviewerID int, blockUser bool, note stri } if result.RowsAffected != 1 { var matching BillingSettlement - err := unresolvedPositiveFinalizeSettlementScope(DB). + err := unresolvedBillingReconciliationScope(DB). Where("id = ?", id). First(&matching).Error if err != nil { @@ -673,6 +742,293 @@ func BillingRequestFinalizeOperationKey(requestID string) string { return billingRequestOperationPrefix + requestID + billingRequestFinalizeSuffix } +func BillingTaskFinalizeOperationKey(taskID int64) string { + return fmt.Sprintf("%s%d%s", billingTaskOperationPrefix, taskID, billingRequestFinalizeSuffix) +} + +func BillingTaskManualCompletionOperationKey(taskID int64) string { + return fmt.Sprintf("%s%d%s%s", billingTaskOperationPrefix, taskID, billingTaskManualCompletionSuffix, billingRequestFinalizeSuffix) +} + +func billingSettlementIsManualTaskCompletion(operationKey string, taskID int64) bool { + return taskID > 0 && operationKey == BillingTaskManualCompletionOperationKey(taskID) +} + +// EnsureManualTaskBillingCompletion records the administrator's exact approval +// together with the independently keyed child settlement before any balance +// mutation. A process restart may then let the normal runner apply the already +// approved operation without losing who authorized it or which evidence note +// was supplied. +func EnsureManualTaskBillingCompletion(input BillingSettlementInput, reviewerID int, note string) (BillingSettlement, error) { + if DB == nil { + return BillingSettlement{}, errors.New("database is not initialized") + } + if reviewerID <= 0 || strings.TrimSpace(note) == "" || !billingSettlementIsManualTaskCompletion(input.OperationKey, input.TaskID) { + return BillingSettlement{}, ErrBillingSettlementReviewConflict + } + var approved BillingSettlement + err := DB.Transaction(func(tx *gorm.DB) error { + record, _, err := ensureBillingSettlementRecordDB(tx, input) + if err != nil { + if !errors.Is(err, ErrBillingSettlementManualReview) { + return err + } + reapproved, reapproveErr := reapproveManualTaskBillingCompletionDB(tx, input, reviewerID, note) + if reapproveErr != nil { + return reapproveErr + } + approved = reapproved + return nil + } + if record.ReconciliationReviewedAt > 0 || record.ReconciliationReviewedBy > 0 || record.ReconciliationReviewNote != "" { + if record.ReconciliationReviewedAt <= 0 || record.ReconciliationReviewedBy != reviewerID || record.ReconciliationReviewNote != note { + return ErrBillingSettlementReviewConflict + } + approved = record + return nil + } + now := time.Now().Unix() + result := tx.Model(&BillingSettlement{}). + Where( + "id = ? AND reconciliation_reviewed_at = ? AND reconciliation_reviewed_by = ?", + record.ID, + 0, + 0, + ). + Where("reconciliation_review_note = ? OR reconciliation_review_note IS NULL", ""). + UpdateColumns(map[string]interface{}{ + "reconciliation_reviewed_at": now, + "reconciliation_reviewed_by": reviewerID, + "reconciliation_review_note": note, + "user_blocking_override": false, + }) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return ErrBillingSettlementReviewConflict + } + return tx.First(&approved, record.ID).Error + }) + return approved, err +} + +func reapproveManualTaskBillingCompletionDB(tx *gorm.DB, input BillingSettlementInput, reviewerID int, note string) (BillingSettlement, error) { + var record BillingSettlement + if err := withRowLock(tx).Where("operation_key = ?", input.OperationKey).First(&record).Error; err != nil { + return BillingSettlement{}, err + } + if !billingSettlementIsManualTaskCompletion(record.OperationKey, record.TaskID) || + record.Status != BillingSettlementStatusManual || + record.ReconciliationReviewedAt <= 0 || + record.ReconciliationReviewedBy != reviewerID || + record.ReconciliationReviewNote != note { + return BillingSettlement{}, ErrBillingSettlementReviewConflict + } + if err := validateBillingSettlement(record, input); err != nil { + return BillingSettlement{}, err + } + + now := time.Now().Unix() + result := tx.Model(&BillingSettlement{}). + Where( + "id = ? AND revision = ? AND status = ? AND reconciliation_reviewed_at = ? AND reconciliation_reviewed_by = ? AND reconciliation_review_note = ?", + record.ID, + record.Revision, + BillingSettlementStatusManual, + record.ReconciliationReviewedAt, + reviewerID, + note, + ). + Updates(map[string]interface{}{ + "status": BillingSettlementStatusPending, + "next_attempt": now, + "updated_at": now, + "revision": gorm.Expr("revision + ?", 1), + }) + if result.Error != nil { + return BillingSettlement{}, result.Error + } + if result.RowsAffected != 1 { + return BillingSettlement{}, ErrBillingSettlementReviewConflict + } + if err := tx.First(&record, record.ID).Error; err != nil { + return BillingSettlement{}, err + } + return record, nil +} + +// GetManualTaskBillingSettlement loads the immutable task-finalize evidence +// used by the audited administrator completion flow. The original revision is +// accepted after resolution so an HTTP retry can replay the child settlement +// without changing the first administrator's audit disposition. +func GetManualTaskBillingSettlement(id int64, expectedRevision int64) (BillingSettlement, bool, error) { + if DB == nil { + return BillingSettlement{}, false, errors.New("database is not initialized") + } + if id <= 0 || expectedRevision <= 0 { + return BillingSettlement{}, false, ErrBillingSettlementReviewConflict + } + var record BillingSettlement + if err := DB.First(&record, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return BillingSettlement{}, false, ErrBillingSettlementReviewConflict + } + return BillingSettlement{}, false, err + } + if record.TaskID <= 0 || record.OperationKey != BillingTaskFinalizeOperationKey(record.TaskID) { + return BillingSettlement{}, false, ErrBillingSettlementReviewConflict + } + switch record.Status { + case BillingSettlementStatusManual: + if record.Revision != expectedRevision { + return BillingSettlement{}, false, ErrBillingSettlementReviewConflict + } + return record, false, nil + case BillingSettlementStatusApplied: + if record.Revision != expectedRevision+1 || record.ReconciliationReviewedAt <= 0 || record.ReconciliationReviewedBy <= 0 { + return BillingSettlement{}, false, ErrBillingSettlementReviewConflict + } + var completion BillingSettlement + if err := DB.Where("operation_key = ?", BillingTaskManualCompletionOperationKey(record.TaskID)).First(&completion).Error; err != nil { + return BillingSettlement{}, false, ErrBillingSettlementReviewConflict + } + if err := validateManualTaskBillingCompletionPair(record, completion, record.ReconciliationReviewedBy, record.ReconciliationReviewNote); err != nil { + return BillingSettlement{}, false, err + } + return record, true, nil + default: + return BillingSettlement{}, false, ErrBillingSettlementReviewConflict + } +} + +// GetManualTaskBillingCompletion returns the independently keyed financial +// operation, when present. Callers use it to distinguish an untouched task +// reservation from a child settlement that already committed before a retry. +func GetManualTaskBillingCompletion(taskID int64) (BillingSettlement, bool, error) { + if DB == nil { + return BillingSettlement{}, false, errors.New("database is not initialized") + } + if taskID <= 0 { + return BillingSettlement{}, false, ErrBillingSettlementReviewConflict + } + var record BillingSettlement + result := DB.Where("operation_key = ?", BillingTaskManualCompletionOperationKey(taskID)).Limit(1).Find(&record) + if result.Error != nil { + return BillingSettlement{}, false, result.Error + } + return record, result.RowsAffected == 1, nil +} + +func validateManualTaskBillingCompletionPair(original BillingSettlement, completion BillingSettlement, reviewerID int, note string) error { + if original.TaskID <= 0 || original.OperationKey != BillingTaskFinalizeOperationKey(original.TaskID) || + completion.OperationKey != BillingTaskManualCompletionOperationKey(original.TaskID) || + completion.Status != BillingSettlementStatusApplied || + completion.Source != original.Source || + completion.UserID != original.UserID || + completion.SubscriptionID != original.SubscriptionID || + completion.TokenID != original.TokenID || + completion.TaskID != original.TaskID || + completion.TaskQuota != original.TaskQuota || + completion.TaskQuotaTarget < 0 || + completion.TaskQuotaTarget > original.TaskQuota || + completion.FundingDelta != completion.TaskQuotaTarget-completion.TaskQuota || + completion.AppliedFundingDelta != completion.FundingDelta || + completion.SubscriptionPreConsumeRequestID != original.SubscriptionPreConsumeRequestID || + completion.ReconciliationReviewedAt <= 0 || + completion.ReconciliationReviewedBy != reviewerID || + completion.ReconciliationReviewNote != note { + return ErrBillingSettlementReviewConflict + } + return nil +} + +// ResolveManualTaskBillingSettlement closes the original manual lifecycle only +// after its independently keyed child settlement has reached applied. A lost +// response or process interruption can safely replay this compare-and-swap. +func ResolveManualTaskBillingSettlement(id int64, expectedRevision int64, reviewerID int, note string) (BillingSettlement, bool, error) { + if DB == nil { + return BillingSettlement{}, false, errors.New("database is not initialized") + } + if id <= 0 || expectedRevision <= 0 || reviewerID <= 0 || strings.TrimSpace(note) == "" { + return BillingSettlement{}, false, ErrBillingSettlementReviewConflict + } + var resolved BillingSettlement + alreadyResolved := false + err := DB.Transaction(func(tx *gorm.DB) error { + var current BillingSettlement + if err := withRowLock(tx).Where("id = ?", id).First(¤t).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrBillingSettlementReviewConflict + } + return err + } + if current.TaskID <= 0 || current.OperationKey != BillingTaskFinalizeOperationKey(current.TaskID) { + return ErrBillingSettlementReviewConflict + } + + var completion BillingSettlement + if err := withRowLock(tx). + Where("operation_key = ?", BillingTaskManualCompletionOperationKey(current.TaskID)). + First(&completion).Error; err != nil { + return ErrBillingSettlementReviewConflict + } + if err := validateManualTaskBillingCompletionPair(current, completion, reviewerID, note); err != nil { + return err + } + + switch current.Status { + case BillingSettlementStatusApplied: + if current.Revision != expectedRevision+1 || + current.ReconciliationReviewedAt <= 0 || + current.ReconciliationReviewedBy != reviewerID || + current.ReconciliationReviewNote != note { + return ErrBillingSettlementReviewConflict + } + resolved = current + alreadyResolved = true + return nil + case BillingSettlementStatusManual: + if current.Revision != expectedRevision { + return ErrBillingSettlementReviewConflict + } + default: + return ErrBillingSettlementReviewConflict + } + + now := time.Now().Unix() + result := tx.Model(&BillingSettlement{}). + Where( + "id = ? AND revision = ? AND status = ? AND operation_key = ?", + id, + expectedRevision, + BillingSettlementStatusManual, + current.OperationKey, + ). + Updates(map[string]interface{}{ + "status": BillingSettlementStatusApplied, + "reconciliation_reviewed_at": now, + "reconciliation_reviewed_by": reviewerID, + "reconciliation_review_note": note, + "user_blocking_override": false, + "next_attempt": 0, + "updated_at": now, + "revision": gorm.Expr("revision + ?", 1), + }) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return ErrBillingSettlementReviewConflict + } + return tx.First(&resolved, id).Error + }) + if err != nil { + return BillingSettlement{}, false, err + } + return resolved, alreadyResolved, nil +} + // GetBillingSettlementStatus returns the durable funding state for one stable // operation key. Effect replay is intentionally separate: once funding is // applied, an effect-only pending state must not block async task polling. @@ -789,6 +1145,14 @@ func ApplyBillingSettlementOnce(input BillingSettlementInput) (appliedFundingDel if applyErr != nil { return applyErr } + if billingSettlementIsManualTaskCompletion(input.OperationKey, input.TaskID) && appliedFundingDelta != input.FundingDelta { + return permanentBillingSettlement(fmt.Errorf( + "%w: exact funding delta required requested=%d applied=%d", + ErrBillingSettlementOperationConflict, + input.FundingDelta, + appliedFundingDelta, + )) + } if input.Source == BillingSettlementSourceSubscription && input.TokenDelta == input.FundingDelta { effectiveTokenDelta = appliedFundingDelta } @@ -857,6 +1221,124 @@ func ApplyBillingSettlementOnce(input BillingSettlementInput) (appliedFundingDel return appliedFundingDelta, alreadyApplied, nil } +// PromoteManualTaskBillingSettlement reopens only the narrow class of H3 task +// settlements that were held because provider usage was incomplete. The +// replacement input must keep the original task/funding identity and may only +// reduce the frozen reservation. The transition is durable before the caller +// applies funding, so a process interruption leaves a replayable pending +// record rather than an ambiguous manual mutation. The boolean reports whether +// the settlement is already applied or is ready for idempotent application; +// this keeps a retry after interruption from stranding an existing pending +// record. +func PromoteManualTaskBillingSettlement(input BillingSettlementInput, reasonPrefix, recoveryNote string) (bool, error) { + if DB == nil { + return false, errors.New("database is not initialized") + } + if input.TaskID <= 0 || input.OperationKey != BillingTaskFinalizeOperationKey(input.TaskID) || input.Effect == nil { + return false, permanentBillingSettlement(ErrBillingSettlementOperationConflict) + } + if input.TaskQuota < 0 || input.TaskQuotaTarget < 0 || input.TaskQuotaTarget > input.TaskQuota || + input.FundingDelta != input.TaskQuotaTarget-input.TaskQuota { + return false, permanentBillingSettlement(ErrBillingSettlementOperationConflict) + } + if input.TokenID <= 0 && input.TokenDelta != 0 { + return false, permanentBillingSettlement(ErrBillingSettlementOperationConflict) + } + if input.TokenID > 0 && input.TokenDelta != input.FundingDelta { + return false, permanentBillingSettlement(ErrBillingSettlementOperationConflict) + } + if strings.TrimSpace(reasonPrefix) == "" || strings.TrimSpace(recoveryNote) == "" { + return false, permanentBillingSettlement(ErrBillingSettlementOperationConflict) + } + if err := validateBillingSettlementInput(input); err != nil { + return false, err + } + effectPayload, err := billingSettlementEffectPayload(input.Effect) + if err != nil { + return false, fmt.Errorf("%w: %v", ErrBillingSettlementRecordNotDurable, err) + } + recoveryNote = common.SanitizePersistedLogContent(common.MaskSensitiveInfo(recoveryNote)) + + ready := false + err = DB.Transaction(func(tx *gorm.DB) error { + var record BillingSettlement + if err := withRowLock(tx).Where("operation_key = ?", input.OperationKey).First(&record).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return permanentBillingSettlement(ErrBillingSettlementOperationConflict) + } + return err + } + if record.Status == BillingSettlementStatusApplied || record.Status == BillingSettlementStatusPending { + if err := validateBillingSettlement(record, input); err != nil { + return err + } + ready = true + return nil + } + if record.Status != BillingSettlementStatusManual { + return permanentBillingSettlement(fmt.Errorf("%w: unexpected status %q", ErrBillingSettlementOperationConflict, record.Status)) + } + if !strings.HasPrefix(record.LastError, reasonPrefix) || + record.Source != input.Source || + record.UserID != input.UserID || + record.SubscriptionID != input.SubscriptionID || + record.TokenID != input.TokenID || + record.TaskID != input.TaskID || + record.TaskQuota != input.TaskQuota || + record.TaskQuotaTarget != record.TaskQuota || + record.FundingDelta != 0 || record.TokenDelta != 0 || + record.SubscriptionPreConsumeRequestID != input.SubscriptionPreConsumeRequestID || + (record.FinalizeSubscriptionPreConsume && !input.FinalizeSubscriptionPreConsume) || + (record.AllowMissingToken && !input.AllowMissingToken) || + record.ManualOnFailure != input.ManualOnFailure || + record.EffectPayload != "" { + return permanentBillingSettlement(ErrBillingSettlementOperationConflict) + } + now := time.Now().Unix() + originalReason := common.SanitizePersistedLogContent(common.MaskSensitiveInfo(record.LastError)) + auditNote := fmt.Sprintf("%s; original manual reason: %s", recoveryNote, originalReason) + if record.ReconciliationReviewedBy > 0 || record.ReconciliationReviewNote != "" { + auditNote = fmt.Sprintf("%s; prior reviewer=%d note=%s", auditNote, record.ReconciliationReviewedBy, + common.SanitizePersistedLogContent(common.MaskSensitiveInfo(record.ReconciliationReviewNote))) + } + updates := map[string]interface{}{ + "funding_delta": input.FundingDelta, + "token_delta": input.TokenDelta, + "task_quota_target": input.TaskQuotaTarget, + "finalize_subscription_pre_consume": input.FinalizeSubscriptionPreConsume, + "allow_missing_token": input.AllowMissingToken, + "effect_payload": effectPayload, + "effect_status": effectStatusAfterSettlement(effectPayload), + "status": BillingSettlementStatusPending, + "last_error": recoveryNote, + "next_attempt": now, + "updated_at": now, + "revision": gorm.Expr("revision + ?", 1), + "reconciliation_reviewed_at": 0, + "reconciliation_reviewed_by": 0, + "reconciliation_review_note": auditNote, + "user_blocking_override": nil, + "applied_funding_delta": 0, + "applied_token_delta": 0, + } + result := tx.Model(&BillingSettlement{}). + Where("id = ? AND revision = ? AND status = ?", record.ID, record.Revision, BillingSettlementStatusManual). + Updates(updates) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return ErrBillingSettlementOperationConflict + } + ready = true + return nil + }) + if err != nil { + return false, err + } + return ready, nil +} + // ResolveBillingPreConsumeSource returns the funding source already selected by // a request's durable pre-consume record. A request must never move between a // wallet settlement and a subscription pre-consume on replay. @@ -1014,6 +1496,59 @@ func ensureBillingSettlementRecordDB(db *gorm.DB, input BillingSettlementInput) return record, record.Status == "" || record.Status == BillingSettlementStatusApplied, nil } +func ensureManualBillingSettlementRecordDB(db *gorm.DB, input BillingSettlementInput, reason string) (BillingSettlement, error) { + if db == nil { + return BillingSettlement{}, fmt.Errorf("%w: database is not initialized", ErrBillingSettlementRecordNotDurable) + } + if strings.TrimSpace(input.OperationKey) == "" { + return BillingSettlement{}, fmt.Errorf("%w: billing settlement operation key is required", ErrBillingSettlementRecordNotDurable) + } + if input.FundingDelta != 0 || input.TokenDelta != 0 || input.TaskQuotaTarget != input.TaskQuota || input.Effect != nil { + return BillingSettlement{}, permanentBillingSettlement(fmt.Errorf("%w: manual settlement must preserve all balances and task quota", ErrBillingSettlementOperationConflict)) + } + if err := validateBillingSettlementInput(input); err != nil { + return BillingSettlement{}, err + } + reason = common.SanitizePersistedLogContent(common.MaskSensitiveInfo(reason)) + if strings.TrimSpace(reason) == "" { + return BillingSettlement{}, permanentBillingSettlement(fmt.Errorf("%w: manual settlement reason is required", ErrBillingSettlementOperationConflict)) + } + now := time.Now().Unix() + record := BillingSettlement{ + OperationKey: input.OperationKey, Source: input.Source, UserID: input.UserID, + SubscriptionID: input.SubscriptionID, TokenID: input.TokenID, + FundingDelta: input.FundingDelta, TokenDelta: input.TokenDelta, + TaskID: input.TaskID, TaskQuota: input.TaskQuota, TaskQuotaTarget: input.TaskQuotaTarget, + SubscriptionPreConsumeRequestID: input.SubscriptionPreConsumeRequestID, + AllowMissingToken: input.AllowMissingToken, + FinalizeSubscriptionPreConsume: input.FinalizeSubscriptionPreConsume, + ManualOnFailure: input.ManualOnFailure, + Status: BillingSettlementStatusManual, + LastError: reason, + NextAttempt: 0, + CreatedAt: now, + UpdatedAt: now, + Revision: 1, + } + result := db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "operation_key"}}, + DoNothing: true, + }).Create(&record) + if result.Error != nil { + return BillingSettlement{}, fmt.Errorf("%w: %v", ErrBillingSettlementRecordNotDurable, result.Error) + } + if err := db.Where("operation_key = ?", input.OperationKey).First(&record).Error; err != nil { + return BillingSettlement{}, err + } + if err := validateBillingSettlement(record, input); err != nil { + return BillingSettlement{}, err + } + if record.Status != BillingSettlementStatusManual || record.LastError != reason { + return BillingSettlement{}, permanentBillingSettlement(fmt.Errorf("%w: manual settlement identity conflict: %s", ErrBillingSettlementOperationConflict, input.OperationKey)) + } + return record, nil +} + func billingSettlementEffectPayload(effect *BillingSettlementEffect) (string, error) { if effect == nil { return "", nil @@ -1088,7 +1623,11 @@ func markBillingSettlementFailure(operationKey string, cause error) { if cause != nil { lastError = cause.Error() } - resetReview := lastError != record.LastError || status != record.Status + // Manual task-completion children persist the approving administrator and + // evidence note before any financial mutation. Retrying or escalating that + // already-authorized operation must never erase its durable authorization. + resetReview := (lastError != record.LastError || status != record.Status) && + !billingSettlementIsManualTaskCompletion(record.OperationKey, record.TaskID) if err := DB.Model(&BillingSettlement{}). Where("id = ? AND status = ?", record.ID, BillingSettlementStatusPending). Updates(billingSettlementFailureUpdates(attempts, lastError, status, nextAttempt, time.Now().Unix(), resetReview)).Error; err != nil { @@ -1495,6 +2034,13 @@ func applySubscriptionDeltaTx(tx *gorm.DB, input BillingSettlementInput) (int64, if err := validateSubscriptionPreConsumePeriod(record, sub); err != nil { return 0, permanentBillingSettlement(err) } + // A zero-delta finalize is still a durable lifecycle operation, but it must + // not issue a same-value subscription UPDATE. MySQL may report zero affected + // rows for that UPDATE while SQLite reports a matched row, which would turn a + // valid replay into a database-dependent conflict. + if input.FundingDelta == 0 { + return 0, nil + } if input.FinalizeSubscriptionPreConsume { if input.FundingDelta >= 0 || -input.FundingDelta < record.PreConsumed { return 0, permanentBillingSettlement(fmt.Errorf("subscription refund does not cover pre-consumed amount: request=%s", requestID)) @@ -1509,7 +2055,7 @@ func applySubscriptionDeltaTx(tx *gorm.DB, input BillingSettlementInput) (int64, } if input.FinalizeSubscriptionPreConsume { if applied != input.FundingDelta { - return 0, permanentBillingSettlement(fmt.Errorf("subscription refund was clamped: request=%s requested=%d applied=%d", requestID, input.FundingDelta, applied)) + return 0, permanentBillingSettlement(fmt.Errorf("%w: request=%s requested=%d applied=%d", ErrSubscriptionRefundClamped, requestID, input.FundingDelta, applied)) } result := tx.Model(&SubscriptionPreConsumeRecord{}). Where("id = ? AND status = ?", record.Id, "consumed"). diff --git a/model/billing_settlement_regression_test.go b/model/billing_settlement_regression_test.go index e6abb931..d2bfb01a 100644 --- a/model/billing_settlement_regression_test.go +++ b/model/billing_settlement_regression_test.go @@ -264,6 +264,113 @@ func TestBillingSettlementBlockingPolicyAndReviewAreIndependentFromFinancialStat assert.True(t, sharedBlockItem.BlocksUser) } +func TestTaskManualFinalizeIsVisibleForReconciliationWithoutBlockingAdmission(t *testing.T) { + setupUserUpdateTestState(t) + setBillingReconciliationBlockDefaultForTest(t, true) + + now := time.Now().Unix() + record := BillingSettlement{ + OperationKey: BillingTaskFinalizeOperationKey(9801), + Source: BillingSettlementSourceWallet, + UserID: 9802, + TaskID: 9801, + TaskQuota: 800, + TaskQuotaTarget: 800, + Status: BillingSettlementStatusManual, + LastError: "H3 terminal usage requires manual reconciliation", + CreatedAt: now - 30, + UpdatedAt: now, + Revision: 1, + } + require.NoError(t, DB.Create(&record).Error) + + stats, err := GetUnresolvedPositiveFinalizeSettlementStats() + require.NoError(t, err) + assert.EqualValues(t, 1, stats.Count) + assert.Equal(t, record.CreatedAt, stats.OldestCreatedAt) + + reconciliation, err := GetUnresolvedPositiveFinalizeSettlements(100) + require.NoError(t, err) + assert.EqualValues(t, 1, reconciliation.TotalCount) + assert.Zero(t, reconciliation.PendingCount) + assert.EqualValues(t, 1, reconciliation.ManualCount) + require.Len(t, reconciliation.Items, 1) + assert.Equal(t, record.ID, reconciliation.Items[0].ID) + assert.False(t, reconciliation.Items[0].RecordBlocksUser) + assert.False(t, reconciliation.Items[0].BlocksUser) + + blocked, err := HasUnresolvedPositiveFinalizeSettlement(record.UserID) + require.NoError(t, err) + assert.False(t, blocked, "a bounded-actual task manual review must not be treated as unpaid positive funding") + + _, err = ReviewBillingSettlement(record.ID, 9803, true, "Verified the frozen task usage evidence") + require.ErrorIs(t, err, ErrBillingSettlementCompletionRequired) + var unchanged BillingSettlement + require.NoError(t, DB.First(&unchanged, record.ID).Error) + assert.Zero(t, unchanged.ReconciliationReviewedAt) + assert.Equal(t, BillingSettlementStatusManual, unchanged.Status) + assert.EqualValues(t, 800, unchanged.TaskQuota) + assert.EqualValues(t, 800, unchanged.TaskQuotaTarget) + + stats, err = GetUnresolvedPositiveFinalizeSettlementStats() + require.NoError(t, err) + assert.EqualValues(t, 1, stats.Count, "manual task-finalize evidence must remain visible until financial completion") +} + +func TestTaskPendingFinalizeIsVisibleForReconciliationWithoutBlockingAdmission(t *testing.T) { + setupUserUpdateTestState(t) + setBillingReconciliationBlockDefaultForTest(t, true) + + now := time.Now().Unix() + record := BillingSettlement{ + OperationKey: BillingTaskFinalizeOperationKey(9811), + Source: BillingSettlementSourceWallet, + UserID: 9812, + TaskID: 9811, + FundingDelta: -100, + TokenDelta: -100, + TaskQuota: 900, + TaskQuotaTarget: 800, + Status: BillingSettlementStatusPending, + LastError: "transient task finalize database error", + NextAttempt: now + 60, + CreatedAt: now - 30, + UpdatedAt: now, + Revision: 2, + } + require.NoError(t, DB.Create(&record).Error) + + stats, err := GetUnresolvedPositiveFinalizeSettlementStats() + require.NoError(t, err) + assert.EqualValues(t, 1, stats.Count) + assert.Equal(t, record.CreatedAt, stats.OldestCreatedAt) + + reconciliation, err := GetUnresolvedPositiveFinalizeSettlements(100) + require.NoError(t, err) + assert.EqualValues(t, 1, reconciliation.TotalCount) + assert.EqualValues(t, 1, reconciliation.PendingCount) + assert.Zero(t, reconciliation.ManualCount) + require.Len(t, reconciliation.Items, 1) + assert.Equal(t, record.ID, reconciliation.Items[0].ID) + assert.False(t, reconciliation.Items[0].RecordBlocksUser) + assert.False(t, reconciliation.Items[0].BlocksUser) + + blocked, err := HasUnresolvedPositiveFinalizeSettlement(record.UserID) + require.NoError(t, err) + assert.False(t, blocked, "a pending bounded-actual task refund must not block paid-request admission") + + reviewed, err := ReviewBillingSettlement(record.ID, 9813, true, "Verified the pending task settlement evidence") + require.NoError(t, err) + assert.Equal(t, BillingSettlementStatusPending, reviewed.Status) + assert.Equal(t, record.Revision, reviewed.Revision) + assert.Equal(t, record.UpdatedAt, reviewed.UpdatedAt) + assert.EqualValues(t, -100, reviewed.FundingDelta) + + stats, err = GetUnresolvedPositiveFinalizeSettlementStats() + require.NoError(t, err) + assert.Zero(t, stats.Count, "reviewed task-pending evidence must leave the active alert projection") +} + func TestBillingSettlementReconciliationProjectsOnlyOpenAlerts(t *testing.T) { setupUserUpdateTestState(t) setBillingReconciliationBlockDefaultForTest(t, false) @@ -338,7 +445,7 @@ func TestReviewBillingSettlementsClosesSelectionAtomically(t *testing.T) { require.NoError(t, err) require.Len(t, reviewed, 2) - assert.Equal(t, 1, queryCount, "reviewed settlements must be read back in one query") + assert.Equal(t, len(targets)+1, queryCount, "review must lock each settlement in ID order and read the completed batch back once") assert.Equal(t, records[1].ID, targets[0].ID, "sorting must not mutate the caller's target order") assert.Equal(t, records[0].ID, targets[1].ID) for index := range reviewed { @@ -363,6 +470,321 @@ func TestReviewBillingSettlementsClosesSelectionAtomically(t *testing.T) { assert.Empty(t, reconciliation.Items) } +func TestReviewBillingSettlementsRefusesToHideManualTaskFinalization(t *testing.T) { + setupUserUpdateTestState(t) + + now := time.Now().Unix() + reviewable := BillingSettlement{ + OperationKey: "request:review-before-manual-task:finalize", + Source: BillingSettlementSourceWallet, + UserID: 9800, + FundingDelta: 10, + TokenDelta: 10, + Status: BillingSettlementStatusPending, + CreatedAt: now, + UpdatedAt: now, + Revision: 1, + } + require.NoError(t, DB.Create(&reviewable).Error) + record := BillingSettlement{ + OperationKey: BillingTaskFinalizeOperationKey(9801), + Source: BillingSettlementSourceWallet, + UserID: 9802, + TaskID: 9801, + TaskQuota: 100, + TaskQuotaTarget: 100, + Status: BillingSettlementStatusManual, + LastError: "provider usage requires reconciliation", + CreatedAt: now, + UpdatedAt: now, + Revision: 1, + } + require.NoError(t, DB.Create(&record).Error) + + _, err := ReviewBillingSettlements([]BillingSettlementReviewTarget{ + {ID: reviewable.ID, Revision: reviewable.Revision}, + {ID: record.ID, Revision: record.Revision}, + }, 7009) + + require.ErrorIs(t, err, ErrBillingSettlementCompletionRequired) + var storedReviewable BillingSettlement + require.NoError(t, DB.First(&storedReviewable, reviewable.ID).Error) + assert.Zero(t, storedReviewable.ReconciliationReviewedAt) + var stored BillingSettlement + require.NoError(t, DB.First(&stored, record.ID).Error) + assert.Zero(t, stored.ReconciliationReviewedAt) + assert.Equal(t, BillingSettlementStatusManual, stored.Status) +} + +func TestReviewBillingSettlementRefusesToHideManualTaskFinalization(t *testing.T) { + setupUserUpdateTestState(t) + + now := time.Now().Unix() + record := BillingSettlement{ + OperationKey: BillingTaskFinalizeOperationKey(9811), + Source: BillingSettlementSourceWallet, + UserID: 9812, + TaskID: 9811, + TaskQuota: 100, + TaskQuotaTarget: 100, + Status: BillingSettlementStatusManual, + LastError: "provider usage requires reconciliation", + CreatedAt: now, + UpdatedAt: now, + Revision: 1, + } + require.NoError(t, DB.Create(&record).Error) + + _, err := ReviewBillingSettlement(record.ID, 7010, false, "Reviewed but not settled") + + require.ErrorIs(t, err, ErrBillingSettlementCompletionRequired) + var stored BillingSettlement + require.NoError(t, DB.First(&stored, record.ID).Error) + assert.Zero(t, stored.ReconciliationReviewedAt) + assert.Equal(t, BillingSettlementStatusManual, stored.Status) +} + +func TestReviewBillingSettlementsRefusesToOverwriteManualTaskCompletion(t *testing.T) { + setupUserUpdateTestState(t) + + now := time.Now().Unix() + reviewable := BillingSettlement{ + OperationKey: "request:review-before-manual-completion:finalize", + Source: BillingSettlementSourceWallet, + UserID: 9819, + FundingDelta: 10, + TokenDelta: 10, + Status: BillingSettlementStatusPending, + CreatedAt: now, + UpdatedAt: now, + Revision: 1, + } + record := BillingSettlement{ + OperationKey: BillingTaskManualCompletionOperationKey(9820), + Source: BillingSettlementSourceWallet, + UserID: 9821, + TaskID: 9820, + TaskQuota: 100, + TaskQuotaTarget: 40, + FundingDelta: -60, + TokenDelta: -60, + Status: BillingSettlementStatusManual, + LastError: "manual completion requires repaired account mirrors", + CreatedAt: now, + UpdatedAt: now, + Revision: 2, + } + require.NoError(t, DB.Create(&reviewable).Error) + require.NoError(t, DB.Create(&record).Error) + + _, err := ReviewBillingSettlements([]BillingSettlementReviewTarget{ + {ID: reviewable.ID, Revision: reviewable.Revision}, + {ID: record.ID, Revision: record.Revision}, + }, 7011) + + require.ErrorIs(t, err, ErrBillingSettlementCompletionRequired) + var storedReviewable BillingSettlement + require.NoError(t, DB.First(&storedReviewable, reviewable.ID).Error) + assert.Zero(t, storedReviewable.ReconciliationReviewedAt) + var stored BillingSettlement + require.NoError(t, DB.First(&stored, record.ID).Error) + assert.Zero(t, stored.ReconciliationReviewedAt) + assert.Equal(t, BillingSettlementStatusManual, stored.Status) +} + +func TestReviewBillingSettlementRefusesToOverwriteManualTaskCompletion(t *testing.T) { + setupUserUpdateTestState(t) + + now := time.Now().Unix() + record := BillingSettlement{ + OperationKey: BillingTaskManualCompletionOperationKey(9822), + Source: BillingSettlementSourceWallet, + UserID: 9823, + TaskID: 9822, + TaskQuota: 100, + TaskQuotaTarget: 40, + FundingDelta: -60, + TokenDelta: -60, + Status: BillingSettlementStatusManual, + LastError: "manual completion requires repaired account mirrors", + ReconciliationReviewedAt: now, + ReconciliationReviewedBy: 7012, + ReconciliationReviewNote: "Original provider evidence and approval.", + CreatedAt: now, + UpdatedAt: now, + Revision: 2, + } + require.NoError(t, DB.Create(&record).Error) + + _, err := ReviewBillingSettlement(record.ID, 7013, false, "Attempted replacement approval.") + + require.ErrorIs(t, err, ErrBillingSettlementCompletionRequired) + var stored BillingSettlement + require.NoError(t, DB.First(&stored, record.ID).Error) + assert.Equal(t, record.ReconciliationReviewedAt, stored.ReconciliationReviewedAt) + assert.Equal(t, record.ReconciliationReviewedBy, stored.ReconciliationReviewedBy) + assert.Equal(t, record.ReconciliationReviewNote, stored.ReconciliationReviewNote) +} + +func TestReviewBillingSettlementAllowsFundedManualTaskFinalize(t *testing.T) { + setupUserUpdateTestState(t) + + now := time.Now().Unix() + records := []BillingSettlement{ + { + OperationKey: BillingTaskFinalizeOperationKey(9831), + Source: BillingSettlementSourceWallet, + UserID: 9832, + TaskID: 9831, + TaskQuota: 100, + TaskQuotaTarget: 40, + FundingDelta: -60, + TokenDelta: -60, + Status: BillingSettlementStatusManual, + LastError: "task quota mirror conflict", + CreatedAt: now, + UpdatedAt: now, + Revision: 1, + }, + { + OperationKey: BillingTaskFinalizeOperationKey(9833), + Source: BillingSettlementSourceWallet, + UserID: 9834, + TaskID: 9833, + TaskQuota: 100, + TaskQuotaTarget: 40, + FundingDelta: -60, + TokenDelta: -60, + Status: BillingSettlementStatusManual, + LastError: "task quota mirror conflict", + CreatedAt: now, + UpdatedAt: now, + Revision: 1, + }, + } + require.NoError(t, DB.Create(&records).Error) + + reconciliation, err := GetUnresolvedPositiveFinalizeSettlements(100) + require.NoError(t, err) + require.Len(t, reconciliation.Items, 2) + for _, item := range reconciliation.Items { + assert.False(t, item.RequiresManualCompletion, "funded manual task-finalize records are not zero-delta completion markers") + } + + reviewed, err := ReviewBillingSettlement(records[0].ID, 9835, false, "Reviewed funded task settlement") + require.NoError(t, err) + assert.Equal(t, BillingSettlementStatusManual, reviewed.Status) + + reviewedBatch, err := ReviewBillingSettlements([]BillingSettlementReviewTarget{{ID: records[1].ID, Revision: records[1].Revision}}, 9836) + require.NoError(t, err) + require.Len(t, reviewedBatch, 1) + assert.Equal(t, BillingSettlementStatusManual, reviewedBatch[0].Status) +} + +func TestResolveManualTaskBillingSettlementRequiresMatchingAppliedChild(t *testing.T) { + setupUserUpdateTestState(t) + + now := time.Now().Unix() + original := BillingSettlement{ + OperationKey: BillingTaskFinalizeOperationKey(9821), + Source: BillingSettlementSourceWallet, + UserID: 9822, + TokenID: 9823, + TaskID: 9821, + TaskQuota: 100, + TaskQuotaTarget: 100, + SubscriptionPreConsumeRequestID: "manual-child-binding", + Status: BillingSettlementStatusManual, + LastError: "provider evidence needs reconciliation", + CreatedAt: now, + UpdatedAt: now, + Revision: 1, + } + require.NoError(t, DB.Create(&original).Error) + + const reviewerID = 7021 + const note = "Verified provider artifacts and exact final quota." + _, _, err := ResolveManualTaskBillingSettlement(original.ID, original.Revision, reviewerID, note) + require.ErrorIs(t, err, ErrBillingSettlementReviewConflict) + + child := BillingSettlement{ + OperationKey: BillingTaskManualCompletionOperationKey(original.TaskID), + Source: original.Source, + UserID: original.UserID, + TokenID: original.TokenID, + FundingDelta: -60, + TokenDelta: -60, + TaskID: original.TaskID, + TaskQuota: original.TaskQuota, + TaskQuotaTarget: 40, + SubscriptionPreConsumeRequestID: original.SubscriptionPreConsumeRequestID, + Status: BillingSettlementStatusPending, + ReconciliationReviewedAt: now, + ReconciliationReviewedBy: reviewerID, + ReconciliationReviewNote: note, + CreatedAt: now, + UpdatedAt: now, + Revision: 1, + } + require.NoError(t, DB.Create(&child).Error) + _, _, err = ResolveManualTaskBillingSettlement(original.ID, original.Revision, reviewerID, note) + require.ErrorIs(t, err, ErrBillingSettlementReviewConflict) + + require.NoError(t, DB.Model(&child).Updates(map[string]interface{}{ + "status": BillingSettlementStatusApplied, + "applied_funding_delta": -59, + "revision": 2, + }).Error) + _, _, err = ResolveManualTaskBillingSettlement(original.ID, original.Revision, reviewerID, note) + require.ErrorIs(t, err, ErrBillingSettlementReviewConflict) + + require.NoError(t, DB.Model(&child).Update("applied_funding_delta", -60).Error) + resolved, alreadyResolved, err := ResolveManualTaskBillingSettlement(original.ID, original.Revision, reviewerID, note) + require.NoError(t, err) + assert.False(t, alreadyResolved) + assert.Equal(t, BillingSettlementStatusApplied, resolved.Status) + assert.EqualValues(t, original.Revision+1, resolved.Revision) + + replayed, alreadyResolved, err := ResolveManualTaskBillingSettlement(original.ID, original.Revision, reviewerID, note) + require.NoError(t, err) + assert.True(t, alreadyResolved) + assert.Equal(t, resolved.ID, replayed.ID) +} + +func TestManualTaskCompletionFailurePreservesAdministratorAuthorization(t *testing.T) { + setupUserUpdateTestState(t) + + now := time.Now().Unix() + record := BillingSettlement{ + OperationKey: BillingTaskManualCompletionOperationKey(9831), + Source: BillingSettlementSourceWallet, + UserID: 9832, + TaskID: 9831, + TaskQuota: 100, + TaskQuotaTarget: 40, + FundingDelta: -60, + TokenDelta: -60, + Status: BillingSettlementStatusPending, + NextAttempt: now, + ReconciliationReviewedAt: now, + ReconciliationReviewedBy: 7031, + ReconciliationReviewNote: "Verified provider evidence before applying the refund.", + CreatedAt: now, + UpdatedAt: now, + Revision: 1, + } + require.NoError(t, DB.Create(&record).Error) + + markBillingSettlementFailure(record.OperationKey, errors.New("temporary database failure")) + + var stored BillingSettlement + require.NoError(t, DB.First(&stored, record.ID).Error) + assert.Equal(t, BillingSettlementStatusPending, stored.Status) + assert.Equal(t, record.ReconciliationReviewedAt, stored.ReconciliationReviewedAt) + assert.Equal(t, record.ReconciliationReviewedBy, stored.ReconciliationReviewedBy) + assert.Equal(t, record.ReconciliationReviewNote, stored.ReconciliationReviewNote) +} + func TestReviewBillingSettlementsRejectsStaleBatchWithoutPartialClose(t *testing.T) { setupUserUpdateTestState(t) @@ -1644,6 +2066,37 @@ func TestTimedOutTaskCursorRemainsGroupedWithStatusPredicate(t *testing.T) { require.Equal(t, "cursor-unfinished", got[0].TaskID) } +func TestTimedOutTaskCursorExcludesTaskFinalizeOwnedTasksBeforeLimit(t *testing.T) { + truncateTables(t) + const submitTime = int64(100) + tasks := []Task{ + {ID: 410, TaskID: "cursor-finalize-pending", Status: TaskStatusInProgress, SubmitTime: submitTime}, + {ID: 411, TaskID: "cursor-finalize-manual", Status: TaskStatusInProgress, SubmitTime: submitTime}, + {ID: 412, TaskID: "cursor-finalize-applied", Status: TaskStatusInProgress, SubmitTime: submitTime}, + {ID: 413, TaskID: "cursor-finalize-empty-applied", Status: TaskStatusInProgress, SubmitTime: submitTime}, + {ID: 414, TaskID: "cursor-finalize-unknown", Status: TaskStatusInProgress, SubmitTime: submitTime}, + {ID: 415, TaskID: "cursor-actionable", Status: TaskStatusInProgress, SubmitTime: submitTime}, + } + require.NoError(t, DB.Create(&tasks).Error) + settlements := []BillingSettlement{ + {OperationKey: BillingTaskFinalizeOperationKey(410), Source: BillingSettlementSourceWallet, TaskID: 410, Status: BillingSettlementStatusPending}, + {OperationKey: BillingTaskFinalizeOperationKey(411), Source: BillingSettlementSourceWallet, TaskID: 411, Status: BillingSettlementStatusManual}, + {OperationKey: BillingTaskFinalizeOperationKey(412), Source: BillingSettlementSourceWallet, TaskID: 412, Status: BillingSettlementStatusApplied}, + {OperationKey: BillingTaskFinalizeOperationKey(413), Source: BillingSettlementSourceWallet, TaskID: 413, Status: BillingSettlementStatusApplied}, + {OperationKey: BillingTaskFinalizeOperationKey(414), Source: BillingSettlementSourceWallet, TaskID: 414, Status: "unexpected"}, + } + require.NoError(t, DB.Create(&settlements).Error) + require.NoError(t, DB.Model(&BillingSettlement{}). + Where("operation_key = ?", BillingTaskFinalizeOperationKey(413)). + UpdateColumn("status", "").Error) + + got, err := GetTimedOutUnfinishedTasksAfter(200, submitTime, 409, 2) + require.NoError(t, err) + require.Len(t, got, 2) + require.Equal(t, "cursor-finalize-unknown", got[0].TaskID) + require.Equal(t, "cursor-actionable", got[1].TaskID) +} + func TestTimedOutTaskCursorReturnsQueryError(t *testing.T) { failingDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) require.NoError(t, err) diff --git a/model/pricing.go b/model/pricing.go index 628b1424..6df55af7 100644 --- a/model/pricing.go +++ b/model/pricing.go @@ -2,8 +2,9 @@ package model import ( "fmt" + "math" + "strconv" "strings" - "sync" "time" @@ -41,16 +42,21 @@ type Pricing struct { } type TaskRateCardPricing struct { - RuleKey string `json:"rule_key,omitempty"` - Vendor string `json:"vendor,omitempty"` - Unit string `json:"unit,omitempty"` - QuantityField string `json:"quantity_field,omitempty"` - DefaultQuantity float64 `json:"default_quantity,omitempty"` - Strict bool `json:"strict"` - Defaults map[string]string `json:"defaults,omitempty"` - MinUnitPrice float64 `json:"min_unit_price"` - MaxUnitPrice float64 `json:"max_unit_price"` - Rows []TaskRateCardPricingRow `json:"rows"` + RuleKey string `json:"rule_key,omitempty"` + Vendor string `json:"vendor,omitempty"` + BillingType string `json:"billing_type,omitempty"` + SchemaVersion int `json:"schema_version,omitempty"` + Mode string `json:"mode,omitempty"` + Currency string `json:"currency,omitempty"` + Unit string `json:"unit,omitempty"` + QuantityField string `json:"quantity_field,omitempty"` + DefaultQuantity float64 `json:"default_quantity,omitempty"` + Strict bool `json:"strict"` + Defaults map[string]string `json:"defaults,omitempty"` + MinUnitPrice float64 `json:"min_unit_price"` + MaxUnitPrice float64 `json:"max_unit_price"` + Rows []TaskRateCardPricingRow `json:"rows"` + Components []TaskRateCardPricingComponent `json:"components,omitempty"` } type TaskRateCardPricingRow struct { @@ -59,6 +65,16 @@ type TaskRateCardPricingRow struct { UnitPrice float64 `json:"unit_price"` } +type TaskRateCardPricingComponent struct { + Key string `json:"key"` + Variant string `json:"variant,omitempty"` + Unit string `json:"unit"` + UnitPrice string `json:"unit_price"` + FreeQuantity *int64 `json:"free_quantity,omitempty"` + MinQuantity *int64 `json:"min_quantity,omitempty"` + MaxQuantity *int64 `json:"max_quantity,omitempty"` +} + type PricingVendor struct { ID int `json:"id"` Name string `json:"name"` @@ -452,9 +468,24 @@ func updatePricing() { } func newTaskRateCardPricing(ruleKey string, card *task_billing_setting.RateCard) *TaskRateCardPricing { - if card == nil || card.BillingType != "" { + if card == nil { return nil } + if card.BillingType == task_billing_setting.MinimaxBillingType { + if structured := newStructuredTaskRateCardPricing(ruleKey, card); structured != nil { + return structured + } + // A malformed structured card should not erase a valid legacy price + // payload that may still be present in persisted settings. + if len(card.Rows) == 0 { + return nil + } + } + if card.BillingType != "" && len(card.Rows) == 0 { + return nil + } + // Unknown structured types must not hide legacy row pricing. This keeps + // model pricing visible while the billing type is not yet supported. rows := make([]TaskRateCardPricingRow, len(card.Rows)) var minUnitPrice float64 var maxUnitPrice float64 @@ -485,6 +516,113 @@ func newTaskRateCardPricing(ruleKey string, card *task_billing_setting.RateCard) } } +func newStructuredTaskRateCardPricing(ruleKey string, card *task_billing_setting.RateCard) *TaskRateCardPricing { + if card == nil || card.BillingType != task_billing_setting.MinimaxBillingType { + return nil + } + data, err := common.Marshal(card.BillingConfig) + if err != nil { + return nil + } + var config task_billing_setting.H3BillingConfig + if err := common.Unmarshal(data, &config); err != nil { + return nil + } + + resolutions := []string{"768P", "2K"} + components := make([]TaskRateCardPricingComponent, 0, 6) + var minUnitPrice float64 + var maxUnitPrice float64 + for i, resolution := range resolutions { + priceText := config.OutputUnitPrice[resolution] + price, ok := parseTaskRateCardDisplayPrice(priceText) + if !ok { + return nil + } + if i == 0 || price < minUnitPrice { + minUnitPrice = price + } + if i == 0 || price > maxUnitPrice { + maxUnitPrice = price + } + components = append(components, TaskRateCardPricingComponent{ + Key: "output_video", + Variant: resolution, + Unit: "second", + UnitPrice: priceText, + MinQuantity: pricingInt64Pointer(task_billing_setting.H3MinOutputDurationSeconds), + MaxQuantity: pricingInt64Pointer(task_billing_setting.H3MaxOutputDurationSeconds), + }) + } + for _, resolution := range resolutions { + priceText := config.InputVideoUnitPrice[resolution] + if _, ok := parseTaskRateCardDisplayPrice(priceText); !ok { + return nil + } + components = append(components, TaskRateCardPricingComponent{ + Key: "input_video", + Variant: resolution, + Unit: "second", + UnitPrice: priceText, + MaxQuantity: pricingQuantityLimit(config.InputVideoMaxSeconds), + }) + } + if _, ok := parseTaskRateCardDisplayPrice(config.InputImageExtraUnitPrice); !ok { + return nil + } + components = append(components, TaskRateCardPricingComponent{ + Key: "input_image", + Unit: "image", + UnitPrice: config.InputImageExtraUnitPrice, + FreeQuantity: pricingInt64Pointer(config.InputImageFreeCount), + MaxQuantity: pricingInt64Pointer(task_billing_setting.H3MaxInputImageCount), + }) + if _, ok := parseTaskRateCardDisplayPrice(config.InputAudioUnitPrice); !ok { + return nil + } + components = append(components, TaskRateCardPricingComponent{ + Key: "input_audio", + Unit: "second", + UnitPrice: config.InputAudioUnitPrice, + MaxQuantity: pricingInt64Pointer(task_billing_setting.H3MaxInputMediaDurationSeconds), + }) + + return &TaskRateCardPricing{ + RuleKey: ruleKey, + Vendor: card.Vendor, + BillingType: card.BillingType, + SchemaVersion: config.SchemaVersion, + Mode: config.Mode, + Currency: config.Currency, + Unit: "second", + QuantityField: "output_duration", + Strict: true, + MinUnitPrice: minUnitPrice, + MaxUnitPrice: maxUnitPrice, + Rows: make([]TaskRateCardPricingRow, 0), + Components: components, + } +} + +func parseTaskRateCardDisplayPrice(value string) (float64, bool) { + price, err := strconv.ParseFloat(strings.TrimSpace(value), 64) + if err != nil || math.IsNaN(price) || math.IsInf(price, 0) || price < 0 { + return 0, false + } + return price, true +} + +func pricingInt64Pointer(value int64) *int64 { + return &value +} + +func pricingQuantityLimit(value int64) *int64 { + if value <= 0 { + return nil + } + return pricingInt64Pointer(value) +} + func clonePricingStringMap(src map[string]string) map[string]string { if len(src) == 0 { return nil diff --git a/model/pricing_test.go b/model/pricing_test.go index be8f5212..1fb48d59 100644 --- a/model/pricing_test.go +++ b/model/pricing_test.go @@ -1,6 +1,7 @@ package model import ( + "strconv" "testing" "github.com/MAX-API-Next/MAX-API/constant" @@ -42,11 +43,116 @@ func TestGetPricingEndpointTypesForAdvancedCustomAbilityUsesConfiguredRoutes(t * }, endpointTypes) } -func TestNewTaskRateCardPricingOmitsStructuredBillingCards(t *testing.T) { +func TestNewTaskRateCardPricingExposesStructuredMinimaxBilling(t *testing.T) { pricing := newTaskRateCardPricing("minimax/minimax-h3", &task_billing_setting.RateCard{ Vendor: "minimax", BillingType: task_billing_setting.MinimaxBillingType, + BillingConfig: map[string]any{ + "schema_version": 1, + "mode": "bounded_actual", + "currency": "USD", + "output_unit_price": map[string]any{"768P": "0.08", "2K": "0.13"}, + "input_video_unit_price": map[string]any{"768P": "0.08", "2K": "0.13"}, + "input_video_max_seconds": float64(15), + "input_image_free_count": float64(5), + "input_image_extra_unit_price": "0.04", + "input_audio_unit_price": "0", + }, + }) + + require.NotNil(t, pricing) + require.Equal(t, task_billing_setting.MinimaxBillingType, pricing.BillingType) + require.Equal(t, "USD", pricing.Currency) + require.Equal(t, "second", pricing.Unit) + require.Equal(t, "output_duration", pricing.QuantityField) + require.True(t, pricing.Strict) + require.Equal(t, 0.08, pricing.MinUnitPrice) + require.Equal(t, 0.13, pricing.MaxUnitPrice) + require.Empty(t, pricing.Rows) + require.Len(t, pricing.Components, 6) + require.Equal(t, TaskRateCardPricingComponent{ + Key: "output_video", + Variant: "768P", + Unit: "second", + UnitPrice: "0.08", + MinQuantity: int64Pointer(4), + MaxQuantity: int64Pointer(15), + }, pricing.Components[0]) + require.Equal(t, TaskRateCardPricingComponent{ + Key: "input_image", + Unit: "image", + UnitPrice: "0.04", + FreeQuantity: int64Pointer(5), + MaxQuantity: int64Pointer(9), + }, pricing.Components[4]) +} + +func TestNewTaskRateCardPricingOmitsNonPositiveInputVideoLimit(t *testing.T) { + for _, limit := range []int64{0, -1} { + t.Run(strconv.FormatInt(limit, 10), func(t *testing.T) { + pricing := newTaskRateCardPricing("minimax/minimax-h3", &task_billing_setting.RateCard{ + Vendor: "minimax", + BillingType: task_billing_setting.MinimaxBillingType, + BillingConfig: map[string]any{ + "schema_version": 1, + "mode": "bounded_actual", + "currency": "USD", + "output_unit_price": map[string]any{"768P": "0.08", "2K": "0.13"}, + "input_video_unit_price": map[string]any{"768P": "0.08", "2K": "0.13"}, + "input_video_max_seconds": limit, + "input_image_extra_unit_price": "0.04", + "input_audio_unit_price": "0", + }, + }) + + require.NotNil(t, pricing) + require.Len(t, pricing.Components, 6) + require.Nil(t, pricing.Components[2].MaxQuantity) + require.Nil(t, pricing.Components[3].MaxQuantity) + }) + } +} + +func TestNewTaskRateCardPricingFallsBackToRowsForUnknownBillingType(t *testing.T) { + pricing := newTaskRateCardPricing("vendor/custom", &task_billing_setting.RateCard{ + Vendor: "vendor", + BillingType: "future_billing_type", + Rows: []task_billing_setting.RateCardRow{ + {ID: "base", Match: map[string]string{"quality": "standard"}, UnitPrice: 0.42}, + }, }) - require.Nil(t, pricing) + require.NotNil(t, pricing) + require.Empty(t, pricing.BillingType) + require.Len(t, pricing.Rows, 1) + require.Equal(t, "base", pricing.Rows[0].ID) + require.Equal(t, 0.42, pricing.Rows[0].UnitPrice) +} + +func TestNewTaskRateCardPricingFallsBackToRowsWhenStructuredDisplayIsInvalid(t *testing.T) { + pricing := newTaskRateCardPricing("minimax/minimax-h3", &task_billing_setting.RateCard{ + Vendor: "minimax", + BillingType: task_billing_setting.MinimaxBillingType, + BillingConfig: map[string]any{ + "output_unit_price": map[string]any{"768P": "invalid"}, + }, + Rows: []task_billing_setting.RateCardRow{ + {ID: "legacy", Match: map[string]string{"resolution": "768P"}, UnitPrice: 0.25}, + }, + }) + + require.NotNil(t, pricing) + require.Empty(t, pricing.BillingType) + require.Len(t, pricing.Rows, 1) + require.Equal(t, "legacy", pricing.Rows[0].ID) +} + +func TestNewTaskRateCardPricingOmitsUnsupportedCardWithoutRows(t *testing.T) { + require.Nil(t, newTaskRateCardPricing("vendor/custom", &task_billing_setting.RateCard{ + BillingType: "future_billing_type", + })) +} + +func int64Pointer(value int64) *int64 { + return &value } diff --git a/model/task.go b/model/task.go index dafdac7f..12629645 100644 --- a/model/task.go +++ b/model/task.go @@ -139,6 +139,13 @@ type TaskPrivateData struct { TokenId int `json:"token_id,omitempty"` // 令牌 ID,用于令牌额度退款 NodeName string `json:"node_name,omitempty"` // 发起任务的节点名,轮询结算阶段据此归属日志而非最后查询节点 BillingContext *TaskBillingContext `json:"billing_context,omitempty"` // 计费参数快照(用于轮询阶段重新计算) + // Pending terminal evidence is persisted before task-finalize funding is + // applied, so a crash cannot strand the task in a non-terminal state. + PendingTerminalStatus TaskStatus `json:"pending_terminal_status,omitempty"` + PendingTerminalProgress string `json:"pending_terminal_progress,omitempty"` + PendingTerminalFinishTime int64 `json:"pending_terminal_finish_time,omitempty"` + PendingTerminalReason string `json:"pending_terminal_reason,omitempty"` + PendingTerminalResultURL string `json:"pending_terminal_result_url,omitempty"` } // TaskBillingContext 记录任务提交时的计费参数,以便轮询阶段可以重新计算额度。 @@ -149,6 +156,7 @@ type TaskBillingContext struct { OtherRatios map[string]float64 `json:"other_ratios,omitempty"` // 附加倍率(时长、分辨率等) TaskBilling *types.TaskBillingResult `json:"task_billing,omitempty"` TaskBillingPlan *types.TaskBillingPlan `json:"task_billing_plan,omitempty"` + TaskUsage *types.TaskUsage `json:"task_usage,omitempty"` OriginModelName string `json:"origin_model_name,omitempty"` // 模型名称,必须为OriginModelName PerCallBilling bool `json:"per_call_billing,omitempty"` // 按次计费:跳过轮询阶段的差额结算 DeltaSettlementDisabled *bool `json:"delta_settlement_disabled,omitempty"` // 渠道关闭完成态差额结算时按提交快照跳过 @@ -354,12 +362,48 @@ func GetTimedOutUnfinishedTasks(cutoffUnix int64, limit int) []*Task { return tasks } +func GetAppliedTaskFinalizeRecoveryCandidates(cutoffUnix int64, limit int) ([]*Task, error) { + var tasks []*Task + if limit <= 0 { + limit = 100 + } + finalize := DB.Model(&BillingSettlement{}).Select("1"). + Where("operation_key = "+taskFinalizeOperationKeySQL(common.UsingSQLite)). + Where("status = ?", BillingSettlementStatusApplied) + err := DB.Model(&Task{}). + Where("status NOT IN ?", []TaskStatus{TaskStatusFailure, TaskStatusSuccess}). + Where("submit_time < ?", cutoffUnix). + Where("EXISTS (?)", finalize). + Order("submit_time ASC, id ASC").Limit(limit).Find(&tasks).Error + return tasks, err +} + // GetTimedOutUnfinishedTasksAfter returns timed-out non-terminal tasks after // the supplied (submit_time, id) cursor in stable ascending order. func GetTimedOutUnfinishedTasksAfter(cutoffUnix int64, afterSubmitTime int64, afterID int64, limit int) ([]*Task, error) { var tasks []*Task - query := DB.Where("status NOT IN ?", []string{TaskStatusFailure, TaskStatusSuccess}). - Where("submit_time < ?", cutoffUnix) + err := timedOutUnfinishedTasksQuery(DB, common.UsingSQLite, cutoffUnix, afterSubmitTime, afterID, limit). + Find(&tasks).Error + if err != nil { + return nil, err + } + return tasks, nil +} + +func timedOutUnfinishedTasksQuery(db *gorm.DB, usingSQLite bool, cutoffUnix int64, afterSubmitTime int64, afterID int64, limit int) *gorm.DB { + taskFinalizeOwned := db.Model(&BillingSettlement{}). + Select("1"). + Where("operation_key = "+taskFinalizeOperationKeySQL(usingSQLite)). + Where("status IN ?", []string{ + "", + BillingSettlementStatusPending, + BillingSettlementStatusManual, + BillingSettlementStatusApplied, + }) + query := db.Model(&Task{}). + Where("status NOT IN ?", []TaskStatus{TaskStatusFailure, TaskStatusSuccess}). + Where("submit_time < ?", cutoffUnix). + Where("NOT EXISTS (?)", taskFinalizeOwned) if afterID > 0 { query = query.Where( "(submit_time > ? OR (submit_time = ? AND id > ?))", @@ -368,13 +412,14 @@ func GetTimedOutUnfinishedTasksAfter(cutoffUnix int64, afterSubmitTime int64, af afterID, ) } - err := query.Order("submit_time ASC, id ASC"). - Limit(limit). - Find(&tasks).Error - if err != nil { - return nil, err + return query.Order("submit_time ASC, id ASC").Limit(limit) +} + +func taskFinalizeOperationKeySQL(usingSQLite bool) string { + if usingSQLite { + return fmt.Sprintf("'%s' || CAST(tasks.id AS TEXT) || '%s'", billingTaskOperationPrefix, billingRequestFinalizeSuffix) } - return tasks, nil + return fmt.Sprintf("CONCAT('%s', tasks.id, '%s')", billingTaskOperationPrefix, billingRequestFinalizeSuffix) } func GetAllUnFinishSyncTasks(limit int) []*Task { @@ -442,6 +487,20 @@ func GetByTaskId(userId int, taskId string) (*Task, bool, error) { return task, exist, err } +func GetTaskByID(id int64) (*Task, error) { + if DB == nil { + return nil, errors.New("database is not initialized") + } + if id <= 0 { + return nil, gorm.ErrRecordNotFound + } + var task Task + if err := DB.First(&task, id).Error; err != nil { + return nil, err + } + return &task, nil +} + func GetByTaskIds(userId int, taskIds []any) ([]*Task, error) { if len(taskIds) == 0 { return nil, nil @@ -559,6 +618,46 @@ func (t *Task) UpdateWithStatus(fromStatus TaskStatus) (bool, error) { return result.RowsAffected > 0, nil } +// UpdateWithStatusAndPendingTerminalEvidence durably records terminal provider +// evidence while keeping the task in fromStatus. The status and updated_at +// guards prevent a stale poller from publishing evidence for another lifecycle. +func (t *Task) UpdateWithStatusAndPendingTerminalEvidence(fromStatus TaskStatus, expectedUpdatedAt int64) (bool, error) { + if t == nil || t.ID <= 0 { + return false, errors.New("persisted task is required") + } + if t.Status != fromStatus { + return false, errors.New("terminal evidence must keep the task non-terminal") + } + if expectedUpdatedAt < 0 { + return false, errors.New("task updated_at snapshot is invalid") + } + updatedAt := t.UpdatedAt + if updatedAt <= expectedUpdatedAt { + if expectedUpdatedAt == 1<<63-1 { + return false, errors.New("task updated_at cannot advance") + } + updatedAt = expectedUpdatedAt + 1 + } + updates := map[string]interface{}{ + "private_data": t.PrivateData, + "updated_at": updatedAt, + } + if t.Data != nil || t.includeDataInUpdate { + updates["data"] = t.Data + } + result := DB.Model(&Task{}). + Where("id = ? AND status = ? AND updated_at = ?", t.ID, fromStatus, expectedUpdatedAt). + Updates(updates) + if result.Error != nil { + return false, result.Error + } + if result.RowsAffected == 0 { + return false, nil + } + t.UpdatedAt = updatedAt + return true, nil +} + var errTaskStatusCASLost = errors.New("task status compare-and-swap lost") // UpdateWithStatusAndSettlement commits a terminal task transition and its @@ -594,6 +693,124 @@ func (t *Task) UpdateWithStatusAndSettlement(fromStatus TaskStatus, input Billin return err == nil, err } +// UpdateWithStatusAndSettlementIntent atomically freezes terminal billing +// evidence and its durable settlement intent while deliberately keeping the +// provider task non-terminal. Funding may then be applied safely; only an +// applied funding state is allowed to advance the provider status to a +// terminal value. +func (t *Task) UpdateWithStatusAndSettlementIntent(fromStatus TaskStatus, expectedUpdatedAt int64, input BillingSettlementInput) (bool, error) { + if t == nil || t.ID <= 0 { + return false, errors.New("persisted task is required") + } + if t.Status != fromStatus { + return false, errors.New("settlement intent must keep the task non-terminal") + } + if expectedUpdatedAt < 0 { + return false, errors.New("task updated_at snapshot is invalid") + } + if input.TaskID != t.ID { + return false, fmt.Errorf("billing settlement task identity mismatch: task=%d input=%d", t.ID, input.TaskID) + } + if err := validateBillingSettlementInput(input); err != nil { + return false, err + } + updatedAt := t.UpdatedAt + if updatedAt <= expectedUpdatedAt { + if expectedUpdatedAt == 1<<63-1 { + return false, errors.New("task updated_at cannot advance") + } + updatedAt = expectedUpdatedAt + 1 + } + + err := DB.Transaction(func(tx *gorm.DB) error { + if _, _, err := ensureBillingSettlementRecordDB(tx, input); err != nil { + return err + } + updates := map[string]interface{}{ + "private_data": t.PrivateData, + "updated_at": updatedAt, + } + if t.Data != nil || t.includeDataInUpdate { + updates["data"] = t.Data + } + result := tx.Model(&Task{}). + Where("id = ? AND status = ? AND updated_at = ?", t.ID, fromStatus, expectedUpdatedAt). + Updates(updates) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return errTaskStatusCASLost + } + return nil + }) + if errors.Is(err, errTaskStatusCASLost) { + return false, nil + } + if err != nil { + return false, err + } + t.UpdatedAt = updatedAt + return true, nil +} + +// UpdateWithStatusAndManualSettlement atomically records a reconciliation-only +// finalize intent while keeping the provider task non-terminal and pollable. +// The status plus updated_at guard prevents concurrent pollers from publishing +// different terminal evidence for the same task lifecycle. +func (t *Task) UpdateWithStatusAndManualSettlement(fromStatus TaskStatus, expectedUpdatedAt int64, input BillingSettlementInput, reason string) (bool, error) { + if t == nil || t.ID <= 0 { + return false, errors.New("persisted task is required") + } + if t.Status != fromStatus { + return false, errors.New("manual settlement must keep the task non-terminal") + } + if expectedUpdatedAt < 0 { + return false, errors.New("task updated_at snapshot is invalid") + } + if input.TaskID != t.ID { + return false, fmt.Errorf("billing settlement task identity mismatch: task=%d input=%d", t.ID, input.TaskID) + } + updatedAt := t.UpdatedAt + if updatedAt <= expectedUpdatedAt { + if expectedUpdatedAt == 1<<63-1 { + return false, errors.New("task updated_at cannot advance") + } + updatedAt = expectedUpdatedAt + 1 + } + + err := DB.Transaction(func(tx *gorm.DB) error { + if _, err := ensureManualBillingSettlementRecordDB(tx, input, reason); err != nil { + return err + } + updates := map[string]interface{}{ + "private_data": t.PrivateData, + "updated_at": updatedAt, + } + if t.Data != nil || t.includeDataInUpdate { + updates["data"] = t.Data + } + result := tx.Model(&Task{}). + Where("id = ? AND status = ? AND updated_at = ?", t.ID, fromStatus, expectedUpdatedAt). + Updates(updates) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return errTaskStatusCASLost + } + return nil + }) + if errors.Is(err, errTaskStatusCASLost) { + return false, nil + } + if err != nil { + return false, err + } + t.UpdatedAt = updatedAt + return true, nil +} + // UpdateWithSettlementIntent persists upstream task identity and the request's // final billing intent in one transaction before the HTTP success response is released. func (t *Task) UpdateWithSettlementIntent(input *BillingSettlementInput) error { diff --git a/model/task_manual_settlement_test.go b/model/task_manual_settlement_test.go new file mode 100644 index 00000000..01d67cdd --- /dev/null +++ b/model/task_manual_settlement_test.go @@ -0,0 +1,262 @@ +package model + +import ( + "errors" + "testing" + "time" + + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func TestUpdateWithStatusAndPendingTerminalEvidenceUsesCAS(t *testing.T) { + truncateTables(t) + task := &Task{ + TaskID: "pending-terminal-evidence", + Status: TaskStatusInProgress, + Progress: "50%", + PrivateData: TaskPrivateData{ + PendingTerminalStatus: TaskStatusSuccess, + PendingTerminalProgress: "100%", + PendingTerminalFinishTime: time.Now().Unix(), + PendingTerminalResultURL: "https://cdn.example.com/pending.mp4", + }, + } + insertTask(t, task) + expectedUpdatedAt := task.UpdatedAt + + won, err := task.UpdateWithStatusAndPendingTerminalEvidence(TaskStatusInProgress, expectedUpdatedAt) + require.NoError(t, err) + require.True(t, won) + + var stored Task + require.NoError(t, DB.First(&stored, task.ID).Error) + require.Equal(t, TaskStatus(TaskStatusInProgress), stored.Status) + require.Equal(t, expectedUpdatedAt+1, stored.UpdatedAt) + require.Equal(t, TaskStatus(TaskStatusSuccess), stored.PrivateData.PendingTerminalStatus) + require.Equal(t, "https://cdn.example.com/pending.mp4", stored.PrivateData.PendingTerminalResultURL) + + stale := stored + stale.UpdatedAt = expectedUpdatedAt + won, err = stale.UpdateWithStatusAndPendingTerminalEvidence(TaskStatusInProgress, expectedUpdatedAt) + require.NoError(t, err) + require.False(t, won) + var afterCASLoss Task + require.NoError(t, DB.First(&afterCASLoss, task.ID).Error) + require.Equal(t, TaskStatus(TaskStatusInProgress), afterCASLoss.Status) + require.Equal(t, TaskStatus(TaskStatusSuccess), afterCASLoss.PrivateData.PendingTerminalStatus) +} + +func TestUpdateWithStatusAndPendingTerminalEvidencePropagatesError(t *testing.T) { + truncateTables(t) + task := &Task{ + TaskID: "pending-terminal-evidence-error", + Status: TaskStatusInProgress, + } + insertTask(t, task) + expectedUpdatedAt := task.UpdatedAt + task.PrivateData.PendingTerminalStatus = TaskStatusSuccess + task.PrivateData.PendingTerminalProgress = "100%" + task.PrivateData.PendingTerminalResultURL = "https://cdn.example.com/should-not-persist.mp4" + + callbackName := "test:pending-terminal-evidence-update-error" + forcedErr := errors.New("forced pending terminal evidence update error") + require.NoError(t, DB.Callback().Update().Before("gorm:update").Register(callbackName, func(tx *gorm.DB) { + _ = tx.AddError(forcedErr) + })) + t.Cleanup(func() { _ = DB.Callback().Update().Remove(callbackName) }) + + won, err := task.UpdateWithStatusAndPendingTerminalEvidence(TaskStatusInProgress, expectedUpdatedAt) + require.ErrorIs(t, err, forcedErr) + require.False(t, won) + require.Equal(t, expectedUpdatedAt, task.UpdatedAt) + var stored Task + require.NoError(t, DB.First(&stored, task.ID).Error) + require.Equal(t, TaskStatus(TaskStatusInProgress), stored.Status) + require.Empty(t, stored.PrivateData.PendingTerminalStatus) + require.Empty(t, stored.PrivateData.PendingTerminalResultURL) +} + +func TestUpdateWithStatusAndManualSettlementKeepsTaskNonTerminal(t *testing.T) { + truncateTables(t) + task := &Task{ + TaskID: "manual-h3", + Status: TaskStatusInProgress, + Quota: 800, + Progress: "35%", + StartTime: 11, + FinishTime: 0, + FailReason: "", + } + task.SetData(map[string]any{"phase": "running"}) + insertTask(t, task) + expectedUpdatedAt := task.UpdatedAt + task.PrivateData.BillingContext = &TaskBillingContext{ + OriginModelName: "MiniMax-H3", + PerCallBilling: true, + } + task.SetData(map[string]any{"phase": "provider-terminal-evidence"}) + task.Progress = "100%" + task.StartTime = 22 + task.FinishTime = 33 + task.FailReason = "must remain reconciliation-only" + task.UpdatedAt++ + operationKey := BillingTaskFinalizeOperationKey(task.ID) + + won, err := task.UpdateWithStatusAndManualSettlement(TaskStatusInProgress, expectedUpdatedAt, BillingSettlementInput{ + OperationKey: operationKey, Source: BillingSettlementSourceWallet, + UserID: 1, TaskID: task.ID, TaskQuota: 800, TaskQuotaTarget: 800, + }, "H3 terminal usage is missing") + + require.NoError(t, err) + require.True(t, won) + var stored Task + require.NoError(t, DB.First(&stored, task.ID).Error) + require.Equal(t, TaskStatus(TaskStatusInProgress), stored.Status) + require.Equal(t, 800, stored.Quota) + require.Equal(t, expectedUpdatedAt+1, task.UpdatedAt) + require.Equal(t, task.UpdatedAt, stored.UpdatedAt) + require.Equal(t, "35%", stored.Progress) + require.EqualValues(t, 11, stored.StartTime) + require.Zero(t, stored.FinishTime) + require.Empty(t, stored.FailReason) + var storedData map[string]any + require.NoError(t, stored.GetData(&storedData)) + require.Equal(t, "provider-terminal-evidence", storedData["phase"]) + require.NotNil(t, stored.PrivateData.BillingContext) + require.Equal(t, "MiniMax-H3", stored.PrivateData.BillingContext.OriginModelName) + require.True(t, stored.PrivateData.BillingContext.PerCallBilling) + var settlement BillingSettlement + require.NoError(t, DB.Where("operation_key = ?", operationKey).First(&settlement).Error) + require.Equal(t, BillingSettlementStatusManual, settlement.Status) + require.Zero(t, settlement.FundingDelta) + require.Contains(t, settlement.LastError, "usage is missing") +} + +func TestUpdateWithStatusAndManualSettlementCASLossLeavesNoIntent(t *testing.T) { + truncateTables(t) + task := &Task{TaskID: "manual-h3-cas-loss", Status: TaskStatusQueued, Quota: 800} + insertTask(t, task) + operationKey := BillingTaskFinalizeOperationKey(task.ID) + expectedUpdatedAt := task.UpdatedAt + task.Status = TaskStatusInProgress + + won, err := task.UpdateWithStatusAndManualSettlement(TaskStatusInProgress, expectedUpdatedAt, BillingSettlementInput{ + OperationKey: operationKey, Source: BillingSettlementSourceWallet, + UserID: 1, TaskID: task.ID, TaskQuota: 800, TaskQuotaTarget: 800, + }, "H3 terminal usage is missing") + + require.NoError(t, err) + require.False(t, won) + require.Equal(t, expectedUpdatedAt, task.UpdatedAt) + var count int64 + require.NoError(t, DB.Model(&BillingSettlement{}).Where("operation_key = ?", operationKey).Count(&count).Error) + require.Zero(t, count) +} + +func TestUpdateWithStatusAndSettlementIntentKeepsTaskNonTerminal(t *testing.T) { + truncateTables(t) + task := &Task{TaskID: "pending-h3", Status: TaskStatusInProgress, Quota: 800} + insertTask(t, task) + expectedUpdatedAt := task.UpdatedAt + task.PrivateData.BillingContext = &TaskBillingContext{} + task.UpdatedAt++ + operationKey := BillingTaskFinalizeOperationKey(task.ID) + + won, err := task.UpdateWithStatusAndSettlementIntent(TaskStatusInProgress, expectedUpdatedAt, BillingSettlementInput{ + OperationKey: operationKey, Source: BillingSettlementSourceWallet, + UserID: 1, TaskID: task.ID, TaskQuota: 800, TaskQuotaTarget: 500, + FundingDelta: -300, + }) + + require.NoError(t, err) + require.True(t, won) + var stored Task + require.NoError(t, DB.First(&stored, task.ID).Error) + require.Equal(t, TaskStatus(TaskStatusInProgress), stored.Status) + require.Equal(t, 800, stored.Quota) + require.Equal(t, expectedUpdatedAt+1, task.UpdatedAt) + require.Equal(t, task.UpdatedAt, stored.UpdatedAt) + require.NotNil(t, stored.PrivateData.BillingContext) + var settlement BillingSettlement + require.NoError(t, DB.Where("operation_key = ?", operationKey).First(&settlement).Error) + require.Equal(t, BillingSettlementStatusPending, settlement.Status) + require.EqualValues(t, -300, settlement.FundingDelta) +} + +func TestUpdateWithStatusAndSettlementIntentCASLossLeavesNoIntent(t *testing.T) { + truncateTables(t) + task := &Task{TaskID: "pending-h3-cas-loss", Status: TaskStatusQueued, Quota: 800} + insertTask(t, task) + operationKey := BillingTaskFinalizeOperationKey(task.ID) + expectedUpdatedAt := task.UpdatedAt + task.Status = TaskStatusInProgress + + won, err := task.UpdateWithStatusAndSettlementIntent(TaskStatusInProgress, expectedUpdatedAt, BillingSettlementInput{ + OperationKey: operationKey, Source: BillingSettlementSourceWallet, + UserID: 1, TaskID: task.ID, TaskQuota: 800, TaskQuotaTarget: 500, + FundingDelta: -300, + }) + + require.NoError(t, err) + require.False(t, won) + require.Equal(t, expectedUpdatedAt, task.UpdatedAt) + var count int64 + require.NoError(t, DB.Model(&BillingSettlement{}).Where("operation_key = ?", operationKey).Count(&count).Error) + require.Zero(t, count) +} + +func TestTaskSettlementIntentErrorsKeepCallerTimestamp(t *testing.T) { + tests := []struct { + name string + call func(*Task, int64, BillingSettlementInput) (bool, error) + }{ + { + name: "pending settlement intent", + call: func(task *Task, expectedUpdatedAt int64, input BillingSettlementInput) (bool, error) { + return task.UpdateWithStatusAndSettlementIntent(TaskStatusInProgress, expectedUpdatedAt, input) + }, + }, + { + name: "manual settlement intent", + call: func(task *Task, expectedUpdatedAt int64, input BillingSettlementInput) (bool, error) { + input.TaskQuotaTarget = input.TaskQuota + input.FundingDelta = 0 + return task.UpdateWithStatusAndManualSettlement(TaskStatusInProgress, expectedUpdatedAt, input, "provider usage is missing") + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + truncateTables(t) + task := &Task{TaskID: "settlement-intent-error", Status: TaskStatusInProgress, Quota: 800} + insertTask(t, task) + expectedUpdatedAt := task.UpdatedAt + operationKey := BillingTaskFinalizeOperationKey(task.ID) + + callbackName := "test:settlement-intent-error-" + test.name + forcedErr := errors.New("forced settlement create error") + require.NoError(t, DB.Callback().Create().Before("gorm:create").Register(callbackName, func(tx *gorm.DB) { + _ = tx.AddError(forcedErr) + })) + t.Cleanup(func() { _ = DB.Callback().Create().Remove(callbackName) }) + + won, err := test.call(task, expectedUpdatedAt, BillingSettlementInput{ + OperationKey: operationKey, Source: BillingSettlementSourceWallet, + UserID: 1, TaskID: task.ID, TaskQuota: 800, TaskQuotaTarget: 500, + FundingDelta: -300, + }) + require.ErrorContains(t, err, forcedErr.Error()) + require.False(t, won) + require.Equal(t, expectedUpdatedAt, task.UpdatedAt) + + var stored Task + require.NoError(t, DB.First(&stored, task.ID).Error) + require.Equal(t, expectedUpdatedAt, stored.UpdatedAt) + var count int64 + require.NoError(t, DB.Model(&BillingSettlement{}).Where("operation_key = ?", operationKey).Count(&count).Error) + require.Zero(t, count) + }) + } +} diff --git a/model/task_timeout_query_test.go b/model/task_timeout_query_test.go new file mode 100644 index 00000000..c748785f --- /dev/null +++ b/model/task_timeout_query_test.go @@ -0,0 +1,78 @@ +package model + +import ( + "database/sql" + "strings" + "testing" + + "github.com/glebarez/sqlite" + _ "github.com/go-sql-driver/mysql" + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/stretchr/testify/require" + gormmysql "gorm.io/driver/mysql" + gormpostgres "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +func TestTimedOutTaskQueryUsesPortableTaskFinalizeExclusion(t *testing.T) { + tests := []struct { + name string + open func(*testing.T) *gorm.DB + usingSQLite bool + keySQL string + }{ + { + name: "sqlite", + open: func(t *testing.T) *gorm.DB { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{DryRun: true}) + require.NoError(t, err) + return db + }, + usingSQLite: true, + keySQL: "'TASK:' || CAST(TASKS.ID AS TEXT) || ':FINALIZE'", + }, + { + name: "mysql", + open: func(t *testing.T) *gorm.DB { + conn, err := sql.Open("mysql", "") + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + db, err := gorm.Open(gormmysql.New(gormmysql.Config{Conn: conn, SkipInitializeWithVersion: true}), &gorm.Config{ + DryRun: true, DisableAutomaticPing: true, + }) + require.NoError(t, err) + return db + }, + keySQL: "CONCAT('TASK:', TASKS.ID, ':FINALIZE')", + }, + { + name: "postgres", + open: func(t *testing.T) *gorm.DB { + conn, err := sql.Open("pgx/v5", "") + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + db, err := gorm.Open(gormpostgres.New(gormpostgres.Config{Conn: conn}), &gorm.Config{ + DryRun: true, DisableAutomaticPing: true, + }) + require.NoError(t, err) + return db + }, + keySQL: "CONCAT('TASK:', TASKS.ID, ':FINALIZE')", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db := tt.open(t) + statement := db.ToSQL(func(tx *gorm.DB) *gorm.DB { + var tasks []*Task + return timedOutUnfinishedTasksQuery(tx, tt.usingSQLite, 200, 100, 400, 2).Find(&tasks) + }) + upper := strings.ToUpper(statement) + require.Contains(t, upper, "NOT EXISTS (SELECT 1 FROM") + require.Contains(t, upper, tt.keySQL) + require.Contains(t, upper, "ORDER BY SUBMIT_TIME ASC, ID ASC") + require.Contains(t, upper, "LIMIT 2") + }) + } +} diff --git a/relay/channel/task/hailuo/adaptor.go b/relay/channel/task/hailuo/adaptor.go index 98ed90f9..9999f614 100644 --- a/relay/channel/task/hailuo/adaptor.go +++ b/relay/channel/task/hailuo/adaptor.go @@ -2,6 +2,7 @@ package hailuo import ( "bytes" + "encoding/json" "fmt" "io" "net/http" @@ -33,6 +34,8 @@ type TaskAdaptor struct { baseURL string } +var _ channel.TaskUsageProvider = (*TaskAdaptor)(nil) + func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) { a.ChannelType = info.ChannelType a.baseURL = info.ChannelBaseUrl @@ -307,6 +310,9 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e if result, handled, err := parseH3TaskResult(respBody); handled { return result, err } + if result, handled, err := parseGenericMiniMaxTaskResult(respBody); handled { + return result, err + } resTask := QueryTaskResponse{} if err := common.Unmarshal(respBody, &resTask); err != nil { @@ -358,14 +364,38 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e } func (a *TaskAdaptor) ExtractTaskUsage(respBody []byte) (*types.TaskUsage, error) { + return a.extractTaskUsage(respBody, 0) +} + +func (a *TaskAdaptor) extractTaskUsage(respBody []byte, unwrapDepth int) (*types.TaskUsage, error) { + if unwrapDepth < taskcommon.MaxWrappedTaskUnwrapDepth { + if nested := taskcommon.WrappedTaskProviderPayload(respBody); nested != nil { + return a.extractTaskUsage(nested, unwrapDepth+1) + } + } response, handled, err := parseH3Response(respBody) if err != nil { return nil, err } - if !handled || response.Task == nil { - return nil, nil + if handled { + if response.Task == nil { + return nil, nil + } + return normalizeH3Usage(response.Task.Usage), nil + } + + // Some MiniMax-compatible gateways expose the H3 usage facts in a generic + // video response ({"id", "status", "data", "usage"}) instead of the + // native {"task": ...} envelope. Keep the same strict H3 normalizer so + // seconds, image counts, explicit zeroes, and consistency checks are not + // weakened by the alternate envelope. + var genericResponse struct { + Usage json.RawMessage `json:"usage,omitempty"` + } + if err := common.Unmarshal(respBody, &genericResponse); err != nil { + return nil, err } - return normalizeH3Usage(response.Task.Usage), nil + return parseGenericMiniMaxUsage(genericResponse.Usage), nil } func (a *TaskAdaptor) ConvertToOpenAIVideo(originTask *model.Task) ([]byte, error) { diff --git a/relay/channel/task/hailuo/adaptor_test.go b/relay/channel/task/hailuo/adaptor_test.go index 10edd26f..fe03c2bf 100644 --- a/relay/channel/task/hailuo/adaptor_test.go +++ b/relay/channel/task/hailuo/adaptor_test.go @@ -12,11 +12,13 @@ import ( "github.com/MAX-API-Next/MAX-API/constant" "github.com/MAX-API-Next/MAX-API/dto" "github.com/MAX-API-Next/MAX-API/model" + "github.com/MAX-API-Next/MAX-API/relay/channel/task/taskcommon" relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" "github.com/MAX-API-Next/MAX-API/relay/helper" "github.com/MAX-API-Next/MAX-API/service" "github.com/MAX-API-Next/MAX-API/types" "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -476,6 +478,277 @@ func TestParseH3TaskResultCarriesVideoAndAudioUsageSeparately(t *testing.T) { require.Equal(t, types.TaskUsageSourceProviderResponse, result.Usage.Source) } +func TestExtractTaskUsageReadsGenericMiniMaxVideoResponse(t *testing.T) { + usage, err := (&TaskAdaptor{}).ExtractTaskUsage([]byte(`{ + "created": 1788853061, + "data": [{"url": "https://cdn.example.com/result.mp4"}], + "id": "439499419230570", + "object": "video.generation", + "status": "completed", + "usage": { + "completion_tokens": 162745, + "input_image_count": 1, + "input_seconds": 0, + "output_seconds": 5, + "prompt_tokens": 13020, + "total_seconds": 5, + "total_tokens": 175765 + } + }`)) + + require.NoError(t, err) + require.NotNil(t, usage) + assert.Equal(t, types.TaskUsageCompletenessComplete, usage.Completeness) + require.NotNil(t, usage.InputVideoDurationMs) + assert.Equal(t, int64(0), *usage.InputVideoDurationMs) + require.NotNil(t, usage.OutputDurationMs) + assert.Equal(t, int64(5000), *usage.OutputDurationMs) + require.NotNil(t, usage.InputImageCount) + assert.Equal(t, int64(1), *usage.InputImageCount) +} + +func TestExtractTaskUsageIgnoresNonObjectGenericUsage(t *testing.T) { + for _, value := range []string{`null`, `[]`, `"none"`, `123`} { + t.Run(value, func(t *testing.T) { + usage, err := (&TaskAdaptor{}).ExtractTaskUsage([]byte(`{ + "id":"439499419230570", + "object":"video.generation", + "status":"completed", + "data":[{"url":"https://cdn.example.com/result.mp4"}], + "usage":` + value + ` + }`)) + + require.NoError(t, err) + assert.Nil(t, usage) + }) + } +} + +func TestExtractTaskUsageRejectsMalformedGenericPayload(t *testing.T) { + usage, err := (&TaskAdaptor{}).ExtractTaskUsage([]byte(`{"usage":`)) + + require.Error(t, err) + assert.Nil(t, usage) +} + +func TestExtractTaskUsageReadsWrappedGenericMiniMaxVideoResponse(t *testing.T) { + usage, err := (&TaskAdaptor{}).ExtractTaskUsage([]byte(`{ + "code": "success", + "data": { + "status": "IN_PROGRESS", + "data": { + "id": "439499419230570", + "object": "video.generation", + "status": "completed", + "data": [{"url": "https://cdn.example.com/result.mp4"}], + "usage": { + "input_image_count": 1, + "input_seconds": 0, + "output_seconds": 5, + "total_seconds": 5 + } + } + } + }`)) + + require.NoError(t, err) + require.NotNil(t, usage) + assert.Equal(t, types.TaskUsageCompletenessComplete, usage.Completeness) + require.NotNil(t, usage.InputImageCount) + assert.Equal(t, int64(1), *usage.InputImageCount) + require.NotNil(t, usage.OutputDurationMs) + assert.Equal(t, int64(5000), *usage.OutputDurationMs) +} + +func TestExtractTaskUsageBoundsWrappedProviderPayloadDepth(t *testing.T) { + buildPayload := func(depth int) []byte { + payload := map[string]any{ + "id": "439499419230570", + "object": "video.generation", + "status": "completed", + "data": []any{map[string]any{"url": "https://cdn.example.com/result.mp4"}}, + "usage": map[string]any{ + "input_image_count": 1, + "input_seconds": 0, + "output_seconds": 5, + "total_seconds": 5, + }, + } + for index := 0; index < depth; index++ { + payload = map[string]any{ + "code": "success", + "id": "wrapper-task", + "status": "IN_PROGRESS", + "data": map[string]any{"data": payload}, + } + } + body, err := common.Marshal(payload) + require.NoError(t, err) + return body + } + + withinLimit, err := (&TaskAdaptor{}).ExtractTaskUsage(buildPayload(taskcommon.MaxWrappedTaskUnwrapDepth)) + require.NoError(t, err) + require.NotNil(t, withinLimit) + require.NotNil(t, withinLimit.OutputDurationMs) + assert.Equal(t, int64(5000), *withinLimit.OutputDurationMs) + + beyondLimit, err := (&TaskAdaptor{}).ExtractTaskUsage(buildPayload(taskcommon.MaxWrappedTaskUnwrapDepth + 1)) + require.NoError(t, err) + assert.Nil(t, beyondLimit) +} + +func TestParseTaskResultReadsGenericMiniMaxVideoResponse(t *testing.T) { + result, err := (&TaskAdaptor{}).ParseTaskResult([]byte(`{ + "created": 1788853061, + "data": [{"url": "https://cdn.example.com/result.mp4"}], + "id": "439499419230570", + "object": "video.generation", + "status": "completed", + "usage": { + "input_image_count": 1, + "input_seconds": 0, + "output_seconds": 5, + "total_seconds": 5 + } + }`)) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, string(model.TaskStatusSuccess), result.Status) + assert.Equal(t, "439499419230570", result.TaskID) + assert.Equal(t, "https://cdn.example.com/result.mp4", result.Url) + assert.Equal(t, types.TaskUsageCompletenessComplete, result.Usage.Completeness) +} + +func TestParseTaskResultDoesNotLetNonObjectUsageHideGenericTerminalState(t *testing.T) { + result, err := (&TaskAdaptor{}).ParseTaskResult([]byte(`{ + "id":"439499419230570", + "object":"video.generation", + "status":"completed", + "data":[{"url":"https://cdn.example.com/result.mp4"}], + "usage":[] + }`)) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, string(model.TaskStatusSuccess), result.Status) + assert.Equal(t, "https://cdn.example.com/result.mp4", result.Url) + assert.Nil(t, result.Usage) +} + +func TestParseTaskResultReadsGenericMiniMaxFailureReason(t *testing.T) { + withMessage, err := (&TaskAdaptor{}).ParseTaskResult([]byte(`{ + "id":"439499419230570", + "object":"video.generation", + "status":"failed", + "error":{"message":"provider rejected the prompt"} + }`)) + require.NoError(t, err) + require.Equal(t, string(model.TaskStatusFailure), withMessage.Status) + assert.Equal(t, "provider rejected the prompt", withMessage.Reason) + assert.Equal(t, "100%", withMessage.Progress) + + withoutMessage, err := (&TaskAdaptor{}).ParseTaskResult([]byte(`{ + "id":"439499419230570", + "object":"video.generation", + "status":"failed" + }`)) + require.NoError(t, err) + require.Equal(t, string(model.TaskStatusFailure), withoutMessage.Status) + assert.Equal(t, "MiniMax task failed", withoutMessage.Reason) +} + +func TestParseTaskResultTreatsGenericMiniMaxErrorAsTerminalFailure(t *testing.T) { + result, err := (&TaskAdaptor{}).ParseTaskResult([]byte(`{ + "id":"439499419230570", + "error":{"type":"bad_request_error","message":"provider rejected the prompt","code":"503","http_code":"400"} + }`)) + require.NoError(t, err) + require.Equal(t, string(model.TaskStatusFailure), result.Status) + require.Equal(t, "100%", result.Progress) + require.Equal(t, 400, result.Code) + require.Equal(t, "provider rejected the prompt", result.Reason) + + result, err = (&TaskAdaptor{}).ParseTaskResult([]byte(`{ + "id":"439499419230570", + "object":"video.generation", + "status":"unexpected", + "error":{} + }`)) + require.NoError(t, err) + require.Equal(t, string(model.TaskStatusFailure), result.Status) + require.Equal(t, "MiniMax task failed", result.Reason) +} + +func TestParseTaskResultKeepsGenericMiniMaxTransientErrorsRetryable(t *testing.T) { + tests := []struct { + name string + body string + }{ + { + name: "rate limit from http code", + body: `{"id":"task-429","error":{"type":"rate_limit_error","message":"retry later","http_code":"429"}}`, + }, + { + name: "service unavailable", + body: `{"id":"task-503","error":{"type":"server_error","message":"temporarily unavailable","http_code":503}}`, + }, + { + name: "request timeout from fallback code", + body: `{"id":"task-408","error":{"type":"timeout_error","message":"query timed out","code":"408"}}`, + }, + { + name: "legacy MiniMax rate limit", + body: `{"id":"task-1002","error":{"type":"rate_limit_error","message":"retry later","code":1002}}`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result, err := (&TaskAdaptor{}).ParseTaskResult([]byte(test.body)) + require.ErrorContains(t, err, "temporary query error") + require.Nil(t, result) + }) + } +} + +func TestParseTaskResultTreatsPermanentMiniMaxCodesAsTerminalFailure(t *testing.T) { + tests := []struct { + name string + body string + code int + }{ + { + name: "generic authentication failure", + body: `{"id":"task-1004","error":{"message":"invalid key","code":1004}}`, + code: StatusAuthFailed, + }, + { + name: "official sensitive input", + body: `{"error":{"message":"sensitive input","code":1026}}`, + code: StatusSensitive, + }, + { + name: "generic parameter error", + body: `{"id":"task-2013","error":{"message":"invalid parameters","code":2013}}`, + code: StatusParamError, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result, err := (&TaskAdaptor{}).ParseTaskResult([]byte(test.body)) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, string(model.TaskStatusFailure), result.Status) + assert.Equal(t, "100%", result.Progress) + assert.Equal(t, test.code, result.Code) + }) + } +} + func TestParseH3UsagePreservesExplicitZeroAndMissingFields(t *testing.T) { result, err := (&TaskAdaptor{}).ParseTaskResult([]byte(`{ "task": { diff --git a/relay/channel/task/hailuo/h3.go b/relay/channel/task/hailuo/h3.go index 84d133d8..d275cc62 100644 --- a/relay/channel/task/hailuo/h3.go +++ b/relay/channel/task/hailuo/h3.go @@ -512,7 +512,8 @@ func parseH3Response(body []byte) (*H3QueryResponse, bool, error) { if err := common.Unmarshal(body, &response); err != nil { return nil, false, err } - return &response, response.Task != nil || response.Error != nil, nil + return &response, response.Task != nil || + (response.Error != nil && strings.TrimSpace(response.ID) == ""), nil } func parseH3TaskResult(body []byte) (*relaycommon.TaskInfo, bool, error) { @@ -521,12 +522,12 @@ func parseH3TaskResult(body []byte) (*relaycommon.TaskInfo, bool, error) { return nil, handled, err } if response.Error != nil { - code := h3APIErrorCode(response.Error) + code := miniMaxAPIErrorCode(response.Error.HTTPCode, response.Error.Code) message := strings.TrimSpace(response.Error.Message) if message == "" { message = "H3 query failed" } - if code == httpStatusRequestTimeout || code == httpStatusTooManyRequests || code >= 500 { + if isMiniMaxTemporaryQueryErrorCode(code) { return nil, true, fmt.Errorf("H3 temporary query error: %s", message) } return &relaycommon.TaskInfo{Code: code, Status: model.TaskStatusFailure, Progress: "100%", Reason: message}, true, nil @@ -568,14 +569,112 @@ func parseH3TaskResult(body []byte) (*relaycommon.TaskInfo, bool, error) { return result, true, nil } +func parseGenericMiniMaxTaskResult(body []byte) (*relaycommon.TaskInfo, bool, error) { + var response GenericMiniMaxVideoResponse + if err := common.Unmarshal(body, &response); err != nil { + return nil, false, err + } + // The root-level shape is used by compatible gateways. Requiring an id and + // either the documented object marker or a status/data field prevents a + // legacy Hailuo response from being treated as a MiniMax result. + if response.Error == nil && (strings.TrimSpace(response.ID) == "" || + (strings.TrimSpace(response.Object) != "video.generation" && + (strings.TrimSpace(response.Status) == "" || response.Data == nil))) { + return nil, false, nil + } + + result := &relaycommon.TaskInfo{ + TaskID: response.ID, + Code: 0, + Usage: parseGenericMiniMaxUsage(response.Usage), + } + if len(response.Data) > 0 { + asset := response.Data[0] + result.Url = firstNonEmptyString(asset.URL, asset.VideoURL, asset.OutputURL) + } + status := strings.ToLower(strings.TrimSpace(response.Status)) + if response.Error != nil { + code := miniMaxAPIErrorCode(response.Error.HTTPCode, response.Error.Code) + message := strings.TrimSpace(response.Error.Message) + if message == "" { + message = "MiniMax task failed" + } + if isMiniMaxTemporaryQueryErrorCode(code) { + return nil, true, fmt.Errorf("MiniMax temporary query error: %s", message) + } + result.Code = code + result.Status = model.TaskStatusFailure + result.Progress = "100%" + result.Reason = message + return result, true, nil + } + switch status { + case "queued", "pending", "submitted", "created": + result.Status = model.TaskStatusQueued + result.Progress = "30%" + case "running", "processing", "in_progress": + result.Status = model.TaskStatusInProgress + result.Progress = "50%" + case "succeeded", "success", "completed", "complete": + result.Progress = "100%" + if result.Url == "" { + result.Status = model.TaskStatusInProgress + result.Progress = "90%" + } else { + result.Status = model.TaskStatusSuccess + } + case "failed", "failure", "error", "cancelled", "canceled": + result.Status = model.TaskStatusFailure + result.Progress = "100%" + if result.Reason == "" { + result.Reason = "MiniMax task failed" + } + default: + result.Status = model.TaskStatusInProgress + result.Progress = "30%" + } + return result, true, nil +} + +func firstNonEmptyString(values ...string) string { + for _, value := range values { + if value = strings.TrimSpace(value); value != "" { + return value + } + } + return "" +} + +func miniMaxAPIErrorCode(httpCode any, code any) int { + if parsed := h3CodeNumber(httpCode); parsed != 0 { + return parsed + } + return h3CodeNumber(code) +} + func h3APIErrorCode(apiError *H3APIError) int { if apiError == nil { return 0 } - if code := h3CodeNumber(apiError.HTTPCode); code != 0 { - return code + return miniMaxAPIErrorCode(apiError.HTTPCode, apiError.Code) +} + +func isMiniMaxTemporaryQueryErrorCode(code int) bool { + return code == StatusRateLimit || + code == httpStatusRequestTimeout || + code == httpStatusTooManyRequests || + (code >= 500 && code < 600) +} + +func parseGenericMiniMaxUsage(raw json.RawMessage) *types.TaskUsage { + if common.GetJsonType(raw) != "object" { + return nil + } + var usage H3Usage + if err := common.Unmarshal(raw, &usage); err != nil { + return nil } - return h3CodeNumber(apiError.Code) + return normalizeH3Usage(&usage) } func normalizeH3Usage(usage *H3Usage) *types.TaskUsage { diff --git a/relay/channel/task/hailuo/models.go b/relay/channel/task/hailuo/models.go index 8426e26e..9b13f706 100644 --- a/relay/channel/task/hailuo/models.go +++ b/relay/channel/task/hailuo/models.go @@ -68,6 +68,7 @@ type QueryTaskResponse struct { } type H3QueryResponse struct { + ID string `json:"id,omitempty"` Task *H3Task `json:"task,omitempty"` Error *H3APIError `json:"error,omitempty"` } @@ -108,6 +109,32 @@ type H3Usage struct { InputAudioSeconds json.RawMessage `json:"input_audio_seconds"` } +// GenericMiniMaxVideoResponse is used by compatible gateways that expose the +// MiniMax H3 result at the response root instead of under the official task +// envelope. Keep this shape deliberately narrow so legacy Hailuo responses do +// not get reclassified accidentally. +type GenericMiniMaxVideoResponse struct { + ID string `json:"id"` + Object string `json:"object"` + Status string `json:"status"` + Data []GenericMiniMaxVideoAsset `json:"data"` + Usage json.RawMessage `json:"usage,omitempty"` + Error *GenericMiniMaxVideoError `json:"error,omitempty"` +} + +type GenericMiniMaxVideoAsset struct { + URL string `json:"url,omitempty"` + VideoURL string `json:"video_url,omitempty"` + OutputURL string `json:"output_url,omitempty"` +} + +type GenericMiniMaxVideoError struct { + Type string `json:"type,omitempty"` + Message string `json:"message,omitempty"` + Code any `json:"code,omitempty"` + HTTPCode any `json:"http_code,omitempty"` +} + type ErrorInfo struct { StatusCode int `json:"status_code"` StatusMsg string `json:"status_msg"` diff --git a/relay/channel/task/taskcommon/path_config_test.go b/relay/channel/task/taskcommon/path_config_test.go index ce7a7e1e..11561b15 100644 --- a/relay/channel/task/taskcommon/path_config_test.go +++ b/relay/channel/task/taskcommon/path_config_test.go @@ -2,6 +2,7 @@ package taskcommon import ( "bytes" + "fmt" "io" "net/http/httptest" "testing" @@ -293,6 +294,306 @@ func TestParseConfiguredTaskResultPromotesResultURLToSuccess(t *testing.T) { assert.Equal(t, "https://example.com/video.mp4", result.Url) } +func TestParseConfiguredTaskResultSupportsMiniMaxCompatibleRootFields(t *testing.T) { + settings := dto.ChannelOtherSettings{ + TaskProtocol: TaskProtocolGenericVideo, + // These are common legacy guesses when the configured response is + // copied from the local task wrapper rather than the upstream body. + TaskProtocolConfig: &dto.TaskProtocolConfig{ + TaskIDPath: "data.id", + StatusPath: "data.status", + ResultURLPaths: []string{"data.data.0.url"}, + }, + } + body := []byte(`{ + "id": "439499419230570", + "object": "video.generation", + "status": "completed", + "data": [{"url": "https://cdn.example.com/minimax.mp4"}], + "usage": {"output_seconds": 5, "input_image_count": 1} + }`) + + result, parsed, err := ParseConfiguredTaskResult(body, settings) + + require.NoError(t, err) + require.True(t, parsed) + require.NotNil(t, result) + assert.Equal(t, "439499419230570", result.TaskID) + assert.Equal(t, string(model.TaskStatusSuccess), result.Status) + assert.Equal(t, "https://cdn.example.com/minimax.mp4", result.Url) +} + +func TestParseConfiguredTaskResultUnwrapsMaxSuccessEnvelope(t *testing.T) { + settings := dto.ChannelOtherSettings{ + TaskProtocol: TaskProtocolGenericVideo, + TaskProtocolConfig: &dto.TaskProtocolConfig{ + TaskIDPath: "data.id", + StatusPath: "data.status", + ResultURLPaths: []string{"data.data.0.url"}, + }, + } + body := []byte(`{ + "code": "success", + "data": { + "id": 10, + "task_id": "task_public", + "status": "IN_PROGRESS", + "data": { + "id": "439499419230570", + "object": "video.generation", + "status": "completed", + "data": [{"url": "https://cdn.example.com/wrapped.mp4"}] + } + } + }`) + + result, parsed, err := ParseConfiguredTaskResult(body, settings) + + require.NoError(t, err) + require.True(t, parsed) + require.NotNil(t, result) + assert.Equal(t, "439499419230570", result.TaskID) + assert.Equal(t, string(model.TaskStatusSuccess), result.Status) + assert.Equal(t, "https://cdn.example.com/wrapped.mp4", result.Url) +} + +func TestParseConfiguredTaskResultBoundsNestedSuccessEnvelopes(t *testing.T) { + settings := dto.ChannelOtherSettings{ + TaskProtocol: TaskProtocolGenericVideo, + TaskProtocolConfig: &dto.TaskProtocolConfig{ + TaskIDPath: "id", + StatusPath: "status", + ResultURLPaths: []string{"data.0.url"}, + }, + } + payload := map[string]any{ + "id": "provider-task", + "object": "video.generation", + "status": "completed", + "data": []any{map[string]any{"url": "https://cdn.example.com/deep.mp4"}}, + } + for depth := 0; depth < MaxWrappedTaskUnwrapDepth+2; depth++ { + payload = map[string]any{ + "code": "success", + "id": fmt.Sprintf("wrapper-%d", depth), + "status": "IN_PROGRESS", + "data": map[string]any{"data": payload}, + } + } + body, err := common.Marshal(payload) + require.NoError(t, err) + + result, parsed, err := ParseConfiguredTaskResult(body, settings) + + require.NoError(t, err) + require.True(t, parsed) + require.NotNil(t, result) + assert.NotEqual(t, "provider-task", result.TaskID) + assert.Equal(t, string(model.TaskStatusInProgress), result.Status) + assert.Empty(t, result.Url) +} + +func TestParseConfiguredTaskResultDoesNotUseObjectlessMiniMaxFallback(t *testing.T) { + settings := dto.ChannelOtherSettings{ + TaskProtocol: TaskProtocolGenericVideo, + TaskProtocolConfig: &dto.TaskProtocolConfig{ + TaskIDPath: "id", + StatusPath: "status", + ResultURLPaths: []string{"result.url"}, + StatusMap: map[string]string{"completed": "SUCCESS"}, + }, + } + body := []byte(`{ + "id": "provider-task-2", + "status": "completed", + "data": [{"url": "https://cdn.example.com/unrelated.mp4"}] + }`) + + result, parsed, err := ParseConfiguredTaskResult(body, settings) + + require.NoError(t, err) + require.True(t, parsed) + require.NotNil(t, result) + assert.Equal(t, "provider-task-2", result.TaskID) + assert.Equal(t, string(model.TaskStatusSuccess), result.Status) + assert.Empty(t, result.Url) +} + +func TestParseConfiguredTaskResultKeepsMiniMaxCompatibleRootPendingWithoutURL(t *testing.T) { + settings := dto.ChannelOtherSettings{ + TaskProtocol: TaskProtocolGenericVideo, + TaskProtocolConfig: &dto.TaskProtocolConfig{ + TaskIDPath: "data.id", + StatusPath: "data.status", + ResultURLPaths: []string{"data.data.0.url"}, + }, + } + body := []byte(`{ + "id": "439499419230570", + "object": "video.generation", + "status": "completed", + "data": [] + }`) + + result, parsed, err := ParseConfiguredTaskResult(body, settings) + + require.NoError(t, err) + require.True(t, parsed) + require.NotNil(t, result) + assert.Equal(t, "439499419230570", result.TaskID) + assert.Equal(t, string(model.TaskStatusInProgress), result.Status) + assert.Equal(t, "50%", result.Progress) + assert.Empty(t, result.Url) +} + +func TestParseConfiguredTaskResultDoesNotUseMiniMaxFallbackForOtherEnvelope(t *testing.T) { + settings := dto.ChannelOtherSettings{ + TaskProtocol: TaskProtocolGenericVideo, + TaskProtocolConfig: &dto.TaskProtocolConfig{ + TaskIDPath: "id", + StatusPath: "status", + ResultURLPaths: []string{"result.url"}, + }, + } + body := []byte(`{ + "id": "provider-task-1", + "object": "media.task", + "status": "in_progress", + "data": [{"url": "https://cdn.example.com/should-not-be-used.mp4"}] + }`) + + result, parsed, err := ParseConfiguredTaskResult(body, settings) + + require.NoError(t, err) + require.True(t, parsed) + require.NotNil(t, result) + assert.Equal(t, "provider-task-1", result.TaskID) + assert.Equal(t, string(model.TaskStatusInProgress), result.Status) + assert.Empty(t, result.Url) +} + +func TestParseConfiguredTaskResultDoesNotUseRootIDFallbackForNonMiniMax(t *testing.T) { + settings := dto.ChannelOtherSettings{TaskProtocol: TaskProtocolGenericVideo} + body := []byte(`{ + "id": "provider-task-root", + "object": "media.task", + "status": "in_progress" + }`) + + result, parsed, err := ParseConfiguredTaskResult(body, settings) + + require.NoError(t, err) + require.True(t, parsed) + require.NotNil(t, result) + assert.Empty(t, result.TaskID) + assert.Equal(t, string(model.TaskStatusInProgress), result.Status) +} + +func TestParseConfiguredTaskResultDoesNotUseRootStatusFallbackForNonMiniMax(t *testing.T) { + settings := dto.ChannelOtherSettings{ + TaskProtocol: TaskProtocolGenericVideo, + TaskProtocolConfig: &dto.TaskProtocolConfig{ + TaskIDPath: "id", + StatusPath: "task.status", + }, + } + body := []byte(`{ + "object": "media.task", + "id": "provider-task-root", + "status": "completed" + }`) + + result, parsed, err := ParseConfiguredTaskResult(body, settings) + + require.NoError(t, err) + require.True(t, parsed) + require.NotNil(t, result) + assert.Equal(t, "provider-task-root", result.TaskID) + assert.Equal(t, string(model.TaskStatusInProgress), result.Status) +} + +func TestParseConfiguredTaskResultLeavesErrorOnlyResponseForAdaptor(t *testing.T) { + settings := dto.ChannelOtherSettings{TaskProtocol: TaskProtocolGenericVideo} + result, parsed, err := ParseConfiguredTaskResult([]byte(`{ + "error": {"message": "temporary upstream failure"} + }`), settings) + + require.NoError(t, err) + assert.False(t, parsed) + assert.Nil(t, result) +} + +func TestParseConfiguredTaskResultReadsGenericFailureReason(t *testing.T) { + settings := dto.ChannelOtherSettings{ + TaskProtocol: TaskProtocolGenericVideo, + TaskProtocolConfig: &dto.TaskProtocolConfig{TaskIDPath: "id"}, + } + body := []byte(`{ + "id": "439499419230570", + "status": "failed", + "error": {"message": "provider rejected the prompt"} + }`) + + result, parsed, err := ParseConfiguredTaskResult(body, settings) + + require.NoError(t, err) + require.True(t, parsed) + require.NotNil(t, result) + assert.Equal(t, "439499419230570", result.TaskID) + assert.Equal(t, string(model.TaskStatusFailure), result.Status) + assert.Equal(t, "provider rejected the prompt", result.Reason) +} + +func TestParseConfiguredTaskResultSupportsOfficialMiniMaxTaskEnvelope(t *testing.T) { + settings := dto.ChannelOtherSettings{ + TaskProtocol: TaskProtocolGenericVideo, + // Keep legacy paths to prove the official envelope is a compatibility + // fallback rather than a requirement to reconfigure the channel. + TaskProtocolConfig: &dto.TaskProtocolConfig{ + TaskIDPath: "data.id", + StatusPath: "data.status", + ResultURLPaths: []string{"data.data.0.url"}, + }, + } + body := []byte(`{ + "task": { + "id": "424010985738629", + "model": "MiniMax-H3", + "status": "succeeded", + "content": {"url": "https://cdn.example.com/h3.mp4"} + } + }`) + + result, parsed, err := ParseConfiguredTaskResult(body, settings) + + require.NoError(t, err) + require.True(t, parsed) + require.NotNil(t, result) + assert.Equal(t, "424010985738629", result.TaskID) + assert.Equal(t, string(model.TaskStatusSuccess), result.Status) + assert.Equal(t, "https://cdn.example.com/h3.mp4", result.Url) +} + +func TestParseConfiguredTaskResultKeepsOfficialMiniMaxTaskPendingWithoutURL(t *testing.T) { + settings := dto.ChannelOtherSettings{TaskProtocol: TaskProtocolGenericVideo} + body := []byte(`{ + "task": { + "id": "424010985738629", + "model": "MiniMax-H3", + "status": "succeeded" + } + }`) + + result, parsed, err := ParseConfiguredTaskResult(body, settings) + + require.NoError(t, err) + require.True(t, parsed) + require.NotNil(t, result) + assert.Equal(t, string(model.TaskStatusInProgress), result.Status) + assert.Equal(t, "50%", result.Progress) + assert.Empty(t, result.Url) +} + func TestParseConfiguredTaskResultReadsOpenAIVideoMetadataURL(t *testing.T) { settings := dto.ChannelOtherSettings{ TaskProtocol: TaskProtocolGenericVideo, diff --git a/relay/channel/task/taskcommon/protocol_config.go b/relay/channel/task/taskcommon/protocol_config.go index e9c971e1..c644db18 100644 --- a/relay/channel/task/taskcommon/protocol_config.go +++ b/relay/channel/task/taskcommon/protocol_config.go @@ -181,15 +181,91 @@ func TryHandleConfiguredSubmitResponse(c *gin.Context, responseBody []byte, info } func ParseConfiguredTaskResult(respBody []byte, settings dto.ChannelOtherSettings) (*relaycommon.TaskInfo, bool, error) { + return parseConfiguredTaskResult(respBody, settings, 0) +} + +// MaxWrappedTaskUnwrapDepth bounds successful relay-envelope unwrapping so +// task result and provider usage parsing apply the same resource limit. +const MaxWrappedTaskUnwrapDepth = 4 + +func parseConfiguredTaskResult(respBody []byte, settings dto.ChannelOtherSettings, unwrapDepth int) (*relaycommon.TaskInfo, bool, error) { if !UseConfiguredTaskProtocol(settings) { return nil, false, nil } cfg := EffectiveTaskProtocolConfig(settings) + // Relay gateways may wrap the provider result in a successful MAX envelope + // ({"code":"success","data":{...}}). Parse the nested provider object + // first so the wrapper's local IN_PROGRESS status cannot hide a terminal + // upstream result. Configurations copied from the wrapper commonly prefix + // paths with data.; strip that prefix for this explicit nested candidate. + if unwrapDepth < MaxWrappedTaskUnwrapDepth { + if nested := WrappedTaskProviderPayload(respBody); nested != nil { + nestedCfg := cfg + nestedCfg.TaskIDPath = stripWrappedTaskPath(cfg.TaskIDPath) + nestedCfg.StatusPath = stripWrappedTaskPath(cfg.StatusPath) + nestedCfg.ProgressPath = stripWrappedTaskPath(cfg.ProgressPath) + nestedCfg.ErrorMessagePath = stripWrappedTaskPath(cfg.ErrorMessagePath) + nestedCfg.CreatedAtPath = stripWrappedTaskPath(cfg.CreatedAtPath) + nestedCfg.UpdatedAtPath = stripWrappedTaskPath(cfg.UpdatedAtPath) + nestedCfg.ResultURLPaths = make([]string, 0, len(cfg.ResultURLPaths)) + for _, path := range cfg.ResultURLPaths { + nestedCfg.ResultURLPaths = append(nestedCfg.ResultURLPaths, stripWrappedTaskPath(path)) + } + nestedSettings := settings + nestedSettings.TaskProtocolConfig = &nestedCfg + result, ok, err := parseConfiguredTaskResult(nested, nestedSettings, unwrapDepth+1) + if err != nil || ok { + return result, ok, err + } + } + } + // The official MiniMax H3 query response nests the task facts under + // `task`. Keep these envelope markers before compatibility fallbacks so + // unrelated providers cannot be mistaken for MiniMax responses. + officialTaskEnvelope := gjson.GetBytes(respBody, "task").IsObject() + // MiniMax-compatible gateways return the task facts at the root with an + // array-valued `data` field and the `video.generation` object marker. Keep + // this compatibility fallback explicit so unrelated providers cannot be + // mistaken for MiniMax responses. + rootObject := strings.TrimSpace(StringFromGJSONPath(respBody, "object")) + rootMiniMaxEnvelope := !officialTaskEnvelope && + StringFromGJSONPath(respBody, "id") != "" && + strings.EqualFold(rootObject, "video.generation") && + gjson.GetBytes(respBody, "data").IsArray() taskID := StringFromGJSONPath(respBody, cfg.TaskIDPath) + if taskID == "" && rootMiniMaxEnvelope { + // MiniMax-compatible gateways may return the task identifier at the + // response root even when a legacy nested path was configured. + taskID = StringFromGJSONPath(respBody, "id") + } + if taskID == "" && officialTaskEnvelope { + taskID = StringFromGJSONPath(respBody, "task.id") + } statusRaw := StringFromGJSONPath(respBody, cfg.StatusPath) + if statusRaw == "" && (officialTaskEnvelope || rootMiniMaxEnvelope) { + statusRaw = StringFromGJSONPath(respBody, "status") + } + if statusRaw == "" && officialTaskEnvelope { + statusRaw = StringFromGJSONPath(respBody, "task.status") + } progressRaw := StringFromGJSONPath(respBody, cfg.ProgressPath) resultURL := ExtractConfiguredResultURL(respBody, cfg.ResultURLPaths) + if resultURL == "" && (officialTaskEnvelope || rootMiniMaxEnvelope) { + // The generic MiniMax video response uses data[0].url. Keep this + // compatibility fallback narrow so an explicitly configured provider + // path remains authoritative whenever it yields a value. + resultURL = ExtractConfiguredResultURL(respBody, []string{"data.0.url", "data.0.video_url", "data.0.output_url"}) + } + if resultURL == "" && officialTaskEnvelope { + resultURL = ExtractConfiguredResultURL(respBody, []string{"task.content.url", "task.content.video_url", "task.content.output_url"}) + } reason := StringFromGJSONPath(respBody, cfg.ErrorMessagePath) + if reason == "" && (taskID != "" || statusRaw != "") { + reason = StringFromGJSONPath(respBody, "error.message") + } + if reason == "" && officialTaskEnvelope { + reason = StringFromGJSONPath(respBody, "task.error.message") + } if taskID == "" && statusRaw == "" && progressRaw == "" && resultURL == "" && reason == "" { return nil, false, nil @@ -199,6 +275,13 @@ func ParseConfiguredTaskResult(respBody []byte, settings dto.ChannelOtherSetting if statusRaw == "" { status = inferConfiguredTaskStatus(resultURL, reason) } + // MiniMax only has a retrievable result once `content.url` is present. + // Do not let a generic status map turn an intermediate succeeded envelope + // into a billable terminal task before the artifact is available. + if (officialTaskEnvelope || rootMiniMaxEnvelope) && + status == string(model.TaskStatusSuccess) && resultURL == "" && reason == "" { + status = string(model.TaskStatusInProgress) + } if resultURL != "" && status != string(model.TaskStatusFailure) { status = string(model.TaskStatusSuccess) } @@ -218,6 +301,32 @@ func ParseConfiguredTaskResult(respBody []byte, settings dto.ChannelOtherSetting }, true, nil } +// WrappedTaskProviderPayload returns an explicit provider object from the +// successful MAX relay envelope used by some task gateways. It is deliberately +// narrow to avoid treating arbitrary `data.data` responses as this contract. +func WrappedTaskProviderPayload(respBody []byte) []byte { + if !strings.EqualFold(strings.TrimSpace(StringFromGJSONPath(respBody, "code")), "success") { + return nil + } + nested := gjson.GetBytes(respBody, "data.data") + if !nested.Exists() || !nested.IsObject() { + return nil + } + if StringFromGJSONPath([]byte(nested.Raw), "id") == "" || + StringFromGJSONPath([]byte(nested.Raw), "status") == "" { + return nil + } + return []byte(nested.Raw) +} + +func stripWrappedTaskPath(path string) string { + path = strings.TrimSpace(path) + if strings.HasPrefix(path, "data.") { + return strings.TrimPrefix(path, "data.") + } + return path +} + func ConvertConfiguredTaskToOpenAIVideo(originTask *model.Task) ([]byte, bool, error) { cfg, ok := TaskProtocolConfigFromTask(originTask) if !ok { @@ -232,6 +341,9 @@ func ConvertConfiguredTaskToOpenAIVideo(originTask *model.Task) ([]byte, bool, e if urlValue == "" { urlValue = ExtractConfiguredResultURL(originTask.Data, cfg.ResultURLPaths) } + if urlValue == "" && gjson.GetBytes(originTask.Data, "task").IsObject() { + urlValue = ExtractConfiguredResultURL(originTask.Data, []string{"task.content.url", "task.content.video_url", "task.content.output_url"}) + } openAIVideo.SetMetadata("url", urlValue) openAIVideo.CreatedAt = firstTimestamp(originTask.CreatedAt, TimestampFromGJSONPath(originTask.Data, cfg.CreatedAtPath)) openAIVideo.CompletedAt = firstTimestamp(originTask.UpdatedAt, TimestampFromGJSONPath(originTask.Data, cfg.UpdatedAtPath)) diff --git a/relay/helper/price.go b/relay/helper/price.go index 7c20e28a..b501ba23 100644 --- a/relay/helper/price.go +++ b/relay/helper/price.go @@ -237,6 +237,17 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens // ModelPriceHelperPerCall 按次/按量计费的 PriceHelper (MJ、Task) func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (types.PriceData, error) { + return modelPriceHelperPerCall(c, info, false) +} + +// ModelPriceHelperPerCallWithPlanCapability allows the selected task adaptor +// to opt into structured task-plan pricing. Other callers remain on the +// ordinary fixed-price or ratio path. +func ModelPriceHelperPerCallWithPlanCapability(c *gin.Context, info *relaycommon.RelayInfo, taskPlanCapable bool) (types.PriceData, error) { + return modelPriceHelperPerCall(c, info, taskPlanCapable) +} + +func modelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo, taskPlanCapable bool) (types.PriceData, error) { if info == nil { return types.PriceData{}, fmt.Errorf("relay info is nil") } @@ -253,6 +264,17 @@ func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (types upstreamModelName = info.UpstreamModelName } rateCardPriced := task_billing_setting.HasRateCard(info.OriginModelName, upstreamModelName) + effectiveTaskModel := strings.TrimSpace(info.OriginModelName) + if strings.TrimSpace(upstreamModelName) != "" { + effectiveTaskModel = strings.TrimSpace(upstreamModelName) + } + taskPlanPriced := taskPlanCapable && task_billing_setting.HasH3BillingPlanForModel(effectiveTaskModel) + if taskPlanPriced { + // A structured task plan owns H3 pricing. Ignore a stale legacy card + // match so it cannot turn the request into per-call billing or a second + // generic task estimate. + rateCardPriced = false + } if !success { defaultPrice, ok := ratio_setting.GetDefaultModelPriceMap()[info.OriginModelName] @@ -262,6 +284,11 @@ func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (types } else if rateCardPriced { modelPrice = 0 usePrice = true + } else if taskPlanPriced { + // Structured task plans own the actual estimate and reservation. This + // helper only supplies the selected group ratio and must not require or + // synthesize a legacy model price. + modelPrice = 0 } else if taskModelRequiresConfiguredPrice(info.OriginModelName) { return types.PriceData{}, modelPriceNotConfiguredError(info.OriginModelName, info.UserId) } else { @@ -282,7 +309,12 @@ func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (types var err error freeModel := false - if usePrice { + if taskPlanPriced { + quota = 0 + if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume && groupRatioInfo.GroupRatio == 0 { + freeModel = true + } + } else if usePrice { quota, err = common.QuotaFromFloatStrict(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio) if err != nil { return types.PriceData{}, err @@ -311,7 +343,7 @@ func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (types // Keep the model price in Quota for final settlement. The configured // minimum belongs only to the reservation made before the request. preConsumedQuota := quota - if !freeModel && groupRatioInfo.GroupRatio > 0 { + if !taskPlanPriced && !freeModel && groupRatioInfo.GroupRatio > 0 { preConsumedQuota, err = ApplyPreConsumedQuotaFloor(quota, true) if err != nil { return types.PriceData{}, err @@ -319,13 +351,14 @@ func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (types } priceData := types.PriceData{ - FreeModel: freeModel, - ModelPrice: modelPrice, - ModelRatio: modelRatio, - UsePrice: usePrice, - Quota: quota, - QuotaToPreConsume: preConsumedQuota, - GroupRatioInfo: groupRatioInfo, + FreeModel: freeModel, + ModelPrice: modelPrice, + ModelRatio: modelRatio, + UsePrice: usePrice, + Quota: quota, + QuotaToPreConsume: preConsumedQuota, + TaskBillingPlanRequired: taskPlanPriced, + GroupRatioInfo: groupRatioInfo, } return priceData, nil } diff --git a/relay/helper/price_test.go b/relay/helper/price_test.go index 4a0b239c..f9c2bcdc 100644 --- a/relay/helper/price_test.go +++ b/relay/helper/price_test.go @@ -20,6 +20,38 @@ import ( "github.com/stretchr/testify/require" ) +func withTaskPricingFixture(t *testing.T, rateCards, h3Profiles string) { + t.Helper() + originalSelfUseMode := operation_setting.SelfUseModeEnabled + originalModelPrice := ratio_setting.ModelPrice2JSONString() + originalModelRatio := ratio_setting.ModelRatio2JSONString() + originalGroupRatio := ratio_setting.GroupRatio2JSONString() + originalRateCards, err := common.Marshal(task_billing_setting.GetRateCardsCopy()) + require.NoError(t, err) + originalProfiles, err := common.Marshal(task_billing_setting.GetH3BillingProfilesCopy()) + require.NoError(t, err) + t.Cleanup(func() { + operation_setting.SelfUseModeEnabled = originalSelfUseMode + require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(originalModelPrice)) + require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(originalModelRatio)) + require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(originalGroupRatio)) + require.NoError(t, config.GlobalConfig.LoadFromDB(map[string]string{ + "task_billing_setting.rate_cards": string(originalRateCards), + "task_billing_setting.h3_profiles": string(originalProfiles), + })) + }) + + operation_setting.SelfUseModeEnabled = false + require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(`{}`)) + require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(`{}`)) + require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(`{"default":1}`)) + settings := map[string]string{"task_billing_setting.rate_cards": rateCards} + if h3Profiles != "" { + settings["task_billing_setting.h3_profiles"] = h3Profiles + } + require.NoError(t, config.GlobalConfig.LoadFromDB(settings)) +} + func TestModelPriceHelperTieredUsesPreloadedRequestInput(t *testing.T) { gin.SetMode(gin.TestMode) originalPreConsumedQuota := common.PreConsumedQuota @@ -132,31 +164,54 @@ func TestModelPriceHelperPerCallRejectsUnpricedMJSunoTaskModels(t *testing.T) { } } -func TestModelPriceHelperPerCallDoesNotTreatStructuredMinimaxCardAsLegacyPrice(t *testing.T) { +func TestModelPriceHelperPerCallDefersStructuredMinimaxPricingToTaskPlan(t *testing.T) { gin.SetMode(gin.TestMode) - originalSelfUseMode := operation_setting.SelfUseModeEnabled - originalModelPrice := ratio_setting.ModelPrice2JSONString() - originalModelRatio := ratio_setting.ModelRatio2JSONString() - originalGroupRatio := ratio_setting.GroupRatio2JSONString() - originalRateCards, err := common.Marshal(task_billing_setting.GetRateCardsCopy()) + withTaskPricingFixture(t, `{"minimax/minimax-h3":{"vendor":"minimax","billing_type":"minimax","billing_config":{"schema_version":1,"mode":"bounded_actual","currency":"USD","output_unit_price":{"768P":"0.08","2K":"0.13"},"input_video_unit_price":{"768P":"0.08","2K":"0.13"},"input_video_max_seconds":15,"input_image_free_count":5,"input_image_extra_unit_price":"0.04","input_audio_unit_price":"0"}}}`, "") + + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + info := &relaycommon.RelayInfo{ + OriginModelName: "MiniMax-H3", + UserGroup: "default", + UsingGroup: "default", + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: "MiniMax-H3", + }, + } + + priceData, err := ModelPriceHelperPerCallWithPlanCapability(ctx, info, true) + require.NoError(t, err) - t.Cleanup(func() { - operation_setting.SelfUseModeEnabled = originalSelfUseMode - require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(originalModelPrice)) - require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(originalModelRatio)) - require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(originalGroupRatio)) - require.NoError(t, config.GlobalConfig.LoadFromDB(map[string]string{ - "task_billing_setting.rate_cards": string(originalRateCards), - })) - }) + require.Zero(t, priceData.Quota) + require.Zero(t, priceData.QuotaToPreConsume) + require.False(t, priceData.FreeModel) + require.False(t, priceData.UsePrice) + require.True(t, priceData.TaskBillingPlanRequired) + require.EqualValues(t, 1, priceData.GroupRatioInfo.GroupRatio) +} - operation_setting.SelfUseModeEnabled = false - require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(`{}`)) - require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(`{}`)) - require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(`{"default":1}`)) - require.NoError(t, config.GlobalConfig.LoadFromDB(map[string]string{ - "task_billing_setting.rate_cards": `{"minimax/minimax-h3":{"vendor":"minimax","billing_type":"minimax","billing_config":{"schema_version":1,"mode":"bounded_actual","currency":"USD","output_unit_price":{"768P":"0.08","2K":"0.13"},"input_video_unit_price":{"768P":"0.08","2K":"0.13"},"input_video_max_seconds":15,"input_image_free_count":5,"input_image_extra_unit_price":"0.04","input_audio_unit_price":"0"}}}`, - })) +func TestModelPriceHelperPerCallIgnoresLegacyRateCardWhenStructuredPlanIsActive(t *testing.T) { + gin.SetMode(gin.TestMode) + withTaskPricingFixture(t, `{"MiniMax-H3":{"vendor":"legacy","unit":"call","default_quantity":1,"rows":[{"id":"legacy-call","unit_price":1}],"billing_type":""},"minimax/minimax-h3":{"vendor":"minimax","billing_type":"minimax","billing_config":{"schema_version":1,"mode":"bounded_actual","currency":"USD","output_unit_price":{"768P":"0.08","2K":"0.13"},"input_video_unit_price":{"768P":"0.08","2K":"0.13"},"input_video_max_seconds":15,"input_image_free_count":5,"input_image_extra_unit_price":"0.04","input_audio_unit_price":"0"}}}`, "") + + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + info := &relaycommon.RelayInfo{ + OriginModelName: "MiniMax-H3", + UserGroup: "default", + UsingGroup: "default", + ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "MiniMax-H3"}, + } + + priceData, err := ModelPriceHelperPerCallWithPlanCapability(ctx, info, true) + + require.NoError(t, err) + require.True(t, priceData.TaskBillingPlanRequired) + require.False(t, priceData.UsePrice) + require.Zero(t, priceData.Quota) +} + +func TestModelPriceHelperPerCallDoesNotDeferH3PricingWithoutPlanCapability(t *testing.T) { + gin.SetMode(gin.TestMode) + withTaskPricingFixture(t, `{"minimax/minimax-h3":{"vendor":"minimax","billing_type":"minimax","billing_config":{"schema_version":1,"mode":"bounded_actual","currency":"USD","output_unit_price":{"768P":"0.08","2K":"0.13"},"input_video_unit_price":{"768P":"0.08","2K":"0.13"},"input_video_max_seconds":15,"input_image_free_count":5,"input_image_extra_unit_price":"0.04","input_audio_unit_price":"0"}}}`, "") ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) info := &relaycommon.RelayInfo{ @@ -168,11 +223,55 @@ func TestModelPriceHelperPerCallDoesNotTreatStructuredMinimaxCardAsLegacyPrice(t }, } - priceData, err := ModelPriceHelperPerCall(ctx, info) + _, err := ModelPriceHelperPerCall(ctx, info) require.Error(t, err) require.Contains(t, err.Error(), "not been priced") - require.Zero(t, priceData) +} + +func TestModelPriceHelperPerCallDoesNotUseH3PlanAfterMappingToLegacyModel(t *testing.T) { + gin.SetMode(gin.TestMode) + withTaskPricingFixture(t, `{"minimax/minimax-h3":{"vendor":"minimax","billing_type":"minimax","billing_config":{"schema_version":1,"mode":"bounded_actual","currency":"USD","output_unit_price":{"768P":"0.08","2K":"0.13"},"input_video_unit_price":{"768P":"0.08","2K":"0.13"},"input_video_max_seconds":15,"input_image_free_count":5,"input_image_extra_unit_price":"0.04","input_audio_unit_price":"0"}}}`, "") + + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + info := &relaycommon.RelayInfo{ + OriginModelName: "MiniMax-H3", + UserGroup: "default", + UsingGroup: "default", + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: "MiniMax-Hailuo-2.3", + }, + } + + priceData, err := ModelPriceHelperPerCallWithPlanCapability(ctx, info, true) + + require.Error(t, err) + require.Contains(t, err.Error(), "not been priced") + require.False(t, priceData.TaskBillingPlanRequired) +} + +func TestModelPriceHelperPerCallDefersLegacyH3ProfilePricingToTaskPlan(t *testing.T) { + gin.SetMode(gin.TestMode) + const legacyH3ProfilesFixture = `{"minimax_h3_v2":{"schema_version":1,"mode":"bounded_actual","currency":"USD","output_unit_price":{"768P":"0.08","2K":"0.13"},"input_video_unit_price":{"768P":"0.08","2K":"0.13"},"input_video_max_seconds":15,"input_image_free_count":5,"input_image_extra_unit_price":"0.04","input_audio_unit_price":"0"}}` + withTaskPricingFixture(t, `{}`, legacyH3ProfilesFixture) + + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + info := &relaycommon.RelayInfo{ + OriginModelName: "MiniMax-H3", + UserGroup: "default", + UsingGroup: "default", + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: "MiniMax-H3", + }, + } + + priceData, err := ModelPriceHelperPerCallWithPlanCapability(ctx, info, true) + + require.NoError(t, err) + require.Zero(t, priceData.Quota) + require.Zero(t, priceData.QuotaToPreConsume) + require.False(t, priceData.UsePrice) + require.True(t, priceData.TaskBillingPlanRequired) } func TestModelPriceHelperPerCallUsesDefaultTaskPrice(t *testing.T) { @@ -208,6 +307,7 @@ func TestModelPriceHelperPerCallUsesDefaultTaskPrice(t *testing.T) { require.Equal(t, float64(1), priceData.GroupRatioInfo.GroupRatio) require.Equal(t, 0.1, priceData.ModelPrice) require.Equal(t, 100, priceData.Quota) + require.False(t, priceData.TaskBillingPlanRequired) } func TestModelPriceHelperPerCallSeparatesFinalAndPreConsumedQuota(t *testing.T) { diff --git a/relay/relay_task.go b/relay/relay_task.go index f5db731f..ae61fa53 100644 --- a/relay/relay_task.go +++ b/relay/relay_task.go @@ -19,6 +19,7 @@ import ( relayconstant "github.com/MAX-API-Next/MAX-API/relay/constant" "github.com/MAX-API-Next/MAX-API/relay/helper" "github.com/MAX-API-Next/MAX-API/service" + "github.com/MAX-API-Next/MAX-API/setting/task_billing_setting" "github.com/MAX-API-Next/MAX-API/types" "github.com/gin-gonic/gin" ) @@ -53,6 +54,7 @@ func populateTaskBillingMetadata(task *model.Task, info *relaycommon.RelayInfo) task.PrivateData.SubscriptionId = info.SubscriptionId task.PrivateData.TokenId = info.TokenId task.PrivateData.NodeName = common.NodeName + structuredTaskPlan := info.TaskBillingPlan != nil task.PrivateData.BillingContext = &model.TaskBillingContext{ ModelPrice: info.PriceData.ModelPrice, GroupRatio: info.PriceData.GroupRatioInfo.GroupRatio, @@ -61,7 +63,7 @@ func populateTaskBillingMetadata(task *model.Task, info *relaycommon.RelayInfo) TaskBilling: info.TaskBilling, TaskBillingPlan: types.CloneTaskBillingPlan(info.TaskBillingPlan), OriginModelName: info.OriginModelName, - PerCallBilling: common.StringsContains(constant.TaskPricePatches, info.OriginModelName) || info.PriceData.UsePrice || info.TaskBilling != nil, + PerCallBilling: !structuredTaskPlan && (common.StringsContains(constant.TaskPricePatches, info.OriginModelName) || info.PriceData.UsePrice || info.TaskBilling != nil), DeltaSettlementDisabled: common.GetPointer(deltaSettlementDisabled), } task.Action = info.Action @@ -302,15 +304,20 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitRe // 5. 价格计算:基础模型价格 info.OriginModelName = modelName - priceData, err := helper.ModelPriceHelperPerCall(c, info) + _, taskPlanCapable := adaptor.(channel.TaskBillingPlanProvider) + priceData, err := helper.ModelPriceHelperPerCallWithPlanCapability(c, info, taskPlanCapable) if err != nil { return nil, service.TaskErrorWrapper(err, "model_price_error", http.StatusBadRequest) } info.PriceData = priceData - // H3 uses a bounded multi-component plan. During H3-02 this is a shadow - // snapshot only; legacy submission settlement remains unchanged. - captureShadowTaskBillingPlan(c, info, adaptor) + // Structured task plans become the formal price source only after the + // adaptor has normalized the final request body. A plan error must stop the + // request before upstream admission rather than silently falling back to a + // different legacy price. + if err := captureTaskBillingPlan(c, info, adaptor); err != nil { + return nil, service.TaskErrorWrapperLocal(err, "task_billing_plan_error", http.StatusBadRequest) + } // 6. Prefer a parameterized rate card when the adaptor can normalize the // request. Legacy task models continue to use OtherRatios. @@ -345,16 +352,14 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitRe } } - // Keep the raw estimate for final settlement. The configured value is an - // admission reservation only and must not become the task's final charge. - taskEstimateQuota := info.PriceData.Quota - preConsumedQuota := taskEstimateQuota - if !info.PriceData.FreeModel && info.PriceData.GroupRatioInfo.GroupRatio > 0 { - preConsumedQuota, err = helper.ApplyPreConsumedQuotaFloor(taskEstimateQuota, true) - if err != nil { - return nil, service.TaskErrorWrapper(err, "pre_consume_quota_error", http.StatusBadRequest) - } + // Keep estimate and reservation separate. Bounded-actual plans reserve their + // own proven upper bound; the site-wide floor is applied to that complete + // reservation exactly once and is never added as a fee. + taskEstimateQuota, preConsumedQuota, taskPlanActive, err := resolveTaskBillingQuotas(info) + if err != nil { + return nil, service.TaskErrorWrapperLocal(err, "task_billing_plan_error", http.StatusBadRequest) } + info.PriceData.Quota = taskEstimateQuota info.PriceData.QuotaToPreConsume = preConsumedQuota // 7. 预扣费。重试可能因路由/分组变化得到更高的目标额度,必须通过 @@ -409,7 +414,11 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitRe // 11. 提交后计费调整:让适配器根据上游实际返回调整 OtherRatios finalQuota := taskEstimateQuota - if !taskBillingOverride { + if taskPlanActive { + // Submission finalization closes the reservation lifecycle only. Actual + // H3 consumption is projected once canonical terminal usage is available. + finalQuota = actualReservedQuota + } else if !taskBillingOverride { if adjustedRatios := adaptor.AdjustBillingOnSubmit(info, taskData); len(adjustedRatios) > 0 { // 基于调整后的 ratios 重新计算 quota finalQuota = recalcQuotaFromRatios(info, taskEstimateQuota, adjustedRatios) @@ -440,21 +449,53 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitRe }, nil } -func captureShadowTaskBillingPlan(c *gin.Context, info *relaycommon.RelayInfo, adaptor channel.TaskAdaptor) { +func captureTaskBillingPlan(c *gin.Context, info *relaycommon.RelayInfo, adaptor channel.TaskAdaptor) error { if info == nil { - return + return nil } info.TaskBillingPlan = nil planner, ok := adaptor.(channel.TaskBillingPlanProvider) if !ok { - return + if info.PriceData.TaskBillingPlanRequired { + return errors.New("required task billing plan provider is unavailable") + } + return nil } plan, err := planner.BuildTaskBillingPlan(c, info) if err != nil { - common.SysLog("task_billing_plan_error: " + common.SanitizePersistedLogContent(common.MaskSensitiveInfo(err.Error()))) - return + return err + } + if info.PriceData.TaskBillingPlanRequired && plan == nil { + return errors.New("required task billing plan was not produced") } info.TaskBillingPlan = plan + return nil +} + +func resolveTaskBillingQuotas(info *relaycommon.RelayInfo) (estimateQuota int, reservationQuota int, usesPlan bool, err error) { + if info == nil { + return 0, 0, false, errors.New("task relay metadata is unavailable") + } + estimateQuota = info.PriceData.Quota + reservationQuota = estimateQuota + if plan := info.TaskBillingPlan; plan != nil { + if validateErr := task_billing_setting.ValidateH3BillingPlanSnapshot(plan); validateErr != nil { + return 0, 0, true, validateErr + } + estimateQuota = plan.EstimateQuota + reservationQuota = plan.ReserveQuota + usesPlan = true + } + if estimateQuota < 0 || reservationQuota < 0 { + return 0, 0, usesPlan, errors.New("task billing quota cannot be negative") + } + if !info.PriceData.FreeModel && info.PriceData.GroupRatioInfo.GroupRatio > 0 { + reservationQuota, err = helper.ApplyPreConsumedQuotaFloor(reservationQuota, true) + if err != nil { + return 0, 0, usesPlan, err + } + } + return estimateQuota, reservationQuota, usesPlan, nil } func setTaskOtherRatioHeaders(header http.Header, otherRatios map[string]float64) { @@ -482,6 +523,13 @@ func mapUpstreamTaskError(c *gin.Context, taskErr *dto.TaskError) *dto.TaskError } func estimateTaskBilling(c *gin.Context, info *relaycommon.RelayInfo, adaptor channel.TaskAdaptor, platform constant.TaskPlatform) (*types.TaskBillingResult, error) { + // A structured task plan is the sole pricing source for its provider. Do not + // retain a legacy rate-card result alongside it, or later settlement/logging + // could observe two competing billing identities for one task. + if info != nil && info.TaskBillingPlan != nil { + info.TaskBilling = nil + return nil, nil + } if estimator, ok := adaptor.(taskBillingEstimator); ok { taskBilling, err := estimator.EstimateTaskBilling(c, info) if err != nil || taskBilling != nil { diff --git a/relay/relay_task_test.go b/relay/relay_task_test.go index cf049c19..ac300300 100644 --- a/relay/relay_task_test.go +++ b/relay/relay_task_test.go @@ -5,6 +5,7 @@ import ( "io" "net/http" "net/http/httptest" + "strconv" "strings" "testing" @@ -12,6 +13,7 @@ import ( "github.com/MAX-API-Next/MAX-API/constant" "github.com/MAX-API-Next/MAX-API/dto" "github.com/MAX-API-Next/MAX-API/model" + "github.com/MAX-API-Next/MAX-API/relay/channel" "github.com/MAX-API-Next/MAX-API/relay/channel/task/doubao" "github.com/MAX-API-Next/MAX-API/relay/channel/task/hailuo" relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" @@ -29,6 +31,18 @@ type recordingTaskReservationBilling struct { settleTo []int } +func TestTaskBillingPlanCapabilityFollowsSelectedAdaptor(t *testing.T) { + unsupported := GetTaskAdaptor(constant.TaskPlatform(strconv.Itoa(constant.ChannelTypeDoubaoVideo))) + require.NotNil(t, unsupported) + _, ok := unsupported.(channel.TaskBillingPlanProvider) + require.False(t, ok) + + supported := GetTaskAdaptor(constant.TaskPlatform(strconv.Itoa(constant.ChannelTypeMiniMax))) + require.NotNil(t, supported) + _, ok = supported.(channel.TaskBillingPlanProvider) + require.True(t, ok) +} + func TestPopulateTaskBillingMetadataPersistsH3PlanSnapshot(t *testing.T) { plan := &types.TaskBillingPlan{ Source: "minimax_hailuo", @@ -63,6 +77,22 @@ func TestPopulateTaskBillingMetadataPersistsH3PlanSnapshot(t *testing.T) { require.Equal(t, plan, restored.BillingContext.TaskBillingPlan) } +func TestPopulateTaskBillingMetadataDoesNotMarkStructuredPlanAsPerCall(t *testing.T) { + info := &relaycommon.RelayInfo{ + OriginModelName: "MiniMax-H3", + TaskRelayInfo: &relaycommon.TaskRelayInfo{}, + TaskBillingPlan: &types.TaskBillingPlan{RuleKey: "minimax_h3"}, + PriceData: types.PriceData{UsePrice: true}, + TaskBilling: &types.TaskBillingResult{RuleKey: "legacy-card"}, + } + task := &model.Task{} + + populateTaskBillingMetadata(task, info) + + require.NotNil(t, task.PrivateData.BillingContext) + assert.False(t, task.PrivateData.BillingContext.PerCallBilling) +} + func (b *recordingTaskReservationBilling) Settle(actualQuota int) error { b.settleTo = append(b.settleTo, actualQuota) b.preConsumed = actualQuota @@ -130,6 +160,56 @@ func TestReserveTaskQuotaReturnsZeroForUnreservedFreeTask(t *testing.T) { assert.Nil(t, info.Billing) } +func TestResolveTaskBillingQuotasUsesH3ReserveAndAppliesFloorOnce(t *testing.T) { + originalFloor := common.PreConsumedQuota + common.PreConsumedQuota = 1700 + t.Cleanup(func() { common.PreConsumedQuota = originalFloor }) + withRelayTaskQuotaPerUnit(t, 1000) + plan, err := task_billing_setting.BuildH3BillingPlan(task_billing_setting.H3BillingInput{ + Resolution: "768P", OutputDurationSeconds: 5, InputVideoCount: 1, + }, 1) + require.NoError(t, err) + info := &relaycommon.RelayInfo{ + TaskBillingPlan: plan, + PriceData: types.PriceData{ + Quota: 1, + FreeModel: false, + GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 1}, + }, + } + + estimate, reserve, usesPlan, err := resolveTaskBillingQuotas(info) + + require.NoError(t, err) + require.True(t, usesPlan) + require.Equal(t, plan.EstimateQuota, estimate) + require.Equal(t, 1700, reserve) +} + +func TestResolveTaskBillingQuotasRejectsTamperedH3SnapshotBeforeReadingTotals(t *testing.T) { + withRelayTaskQuotaPerUnit(t, 1000) + plan, err := task_billing_setting.BuildH3BillingPlan(task_billing_setting.H3BillingInput{ + Resolution: "768P", OutputDurationSeconds: 5, + }, 1) + require.NoError(t, err) + plan.EstimateQuota++ + info := &relaycommon.RelayInfo{ + TaskBillingPlan: plan, + PriceData: types.PriceData{ + FreeModel: false, + GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 1}, + }, + } + + estimate, reserve, usesPlan, err := resolveTaskBillingQuotas(info) + + require.Error(t, err) + require.Contains(t, err.Error(), "frozen totals do not match") + require.True(t, usesPlan) + require.Zero(t, estimate) + require.Zero(t, reserve) +} + func withRelayTaskRateCards(t *testing.T, cards map[string]task_billing_setting.RateCard) { t.Helper() original := task_billing_setting.GetRateCardsCopy() @@ -244,7 +324,7 @@ func TestPrepareTaskSubmitRequestBodyMakesH3PlanUseFinalRequestFacts(t *testing. require.Equal(t, "2K", plan.Resolution) } -func TestCaptureShadowTaskBillingPlanFailureDoesNotBlockSubmission(t *testing.T) { +func TestCaptureTaskBillingPlanFailureBlocksUnsafeFallback(t *testing.T) { c, _ := gin.CreateTestContext(httptest.NewRecorder()) previous := &types.TaskBillingPlan{RuleKey: "stale"} info := &relaycommon.RelayInfo{ @@ -255,8 +335,9 @@ func TestCaptureShadowTaskBillingPlanFailureDoesNotBlockSubmission(t *testing.T) }, } - captureShadowTaskBillingPlan(c, info, &hailuo.TaskAdaptor{}) + err := captureTaskBillingPlan(c, info, &hailuo.TaskAdaptor{}) + require.Error(t, err) require.Nil(t, info.TaskBillingPlan) } @@ -281,7 +362,7 @@ func TestRelayTaskSubmitMapsModelBeforeContentOnlyValidation(t *testing.T) { require.Contains(t, taskErr.Message, "prompt is required") } -func TestCaptureShadowTaskBillingPlanSuccessKeepsSnapshot(t *testing.T) { +func TestCaptureTaskBillingPlanSuccessKeepsSnapshot(t *testing.T) { duration := 5 c, _ := gin.CreateTestContext(httptest.NewRecorder()) c.Set("task_request", relaycommon.TaskSubmitReq{ @@ -295,15 +376,41 @@ func TestCaptureShadowTaskBillingPlanSuccessKeepsSnapshot(t *testing.T) { ChannelMeta: &relaycommon.ChannelMeta{ UpstreamModelName: hailuo.H3Model, }, - PriceData: types.PriceData{GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 1}}, + PriceData: types.PriceData{ + TaskBillingPlanRequired: true, + GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 1}, + }, } - captureShadowTaskBillingPlan(c, info, &hailuo.TaskAdaptor{}) + require.NoError(t, captureTaskBillingPlan(c, info, &hailuo.TaskAdaptor{})) require.NotNil(t, info.TaskBillingPlan) require.EqualValues(t, 5, info.TaskBillingPlan.RequestedOutputDurationSeconds) } +func TestCaptureTaskBillingPlanRejectsMissingRequiredPlan(t *testing.T) { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + info := &relaycommon.RelayInfo{ + OriginModelName: hailuo.H3Model, + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: "MiniMax-Hailuo-2.3", + }, + PriceData: types.PriceData{ + TaskBillingPlanRequired: true, + GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 1}, + }, + } + + err := captureTaskBillingPlan(c, info, &hailuo.TaskAdaptor{}) + + require.ErrorContains(t, err, "required task billing plan was not produced") + require.Nil(t, info.TaskBillingPlan) + + info.PriceData.TaskBillingPlanRequired = false + require.NoError(t, captureTaskBillingPlan(c, info, &hailuo.TaskAdaptor{})) + require.Nil(t, info.TaskBillingPlan) +} + func TestPrepareTaskSubmitRequestBodyMakesMultipartParamOverrideVisibleToBilling(t *testing.T) { gin.SetMode(gin.TestMode) c, _ := gin.CreateTestContext(httptest.NewRecorder()) @@ -403,6 +510,46 @@ func TestEstimateTaskBillingFallsBackToGenericRateCard(t *testing.T) { assert.Equal(t, 3000, got.Quota) } +func TestEstimateTaskBillingDoesNotMixLegacyRateCardWithStructuredPlan(t *testing.T) { + withRelayTaskQuotaPerUnit(t, 1000) + withRelayTaskRateCards(t, map[string]task_billing_setting.RateCard{ + "MiniMax-H3": { + Vendor: "legacy", + Unit: "call", + DefaultQuantity: 1, + Rows: []task_billing_setting.RateCardRow{{ + ID: "legacy-call", + UnitPrice: 1, + }}, + }, + }) + + plan, err := task_billing_setting.BuildH3BillingPlan(task_billing_setting.H3BillingInput{ + Resolution: "768P", OutputDurationSeconds: 5, + }, 1) + require.NoError(t, err) + + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + duration := 5 + info := &relaycommon.RelayInfo{ + OriginModelName: hailuo.H3Model, + ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: hailuo.H3Model}, + TaskRelayInfo: &relaycommon.TaskRelayInfo{Action: constant.TaskActionGenerate}, + TaskBillingPlan: plan, + TaskBilling: &types.TaskBillingResult{RuleKey: "legacy-call"}, + } + relaycommon.StoreTaskRequest(c, info, constant.TaskActionGenerate, relaycommon.TaskSubmitReq{ + Model: hailuo.H3Model, + Duration: &duration, + }) + + got, err := estimateTaskBilling(c, info, &hailuo.TaskAdaptor{}, constant.TaskPlatform("minimax")) + + require.NoError(t, err) + assert.Nil(t, got) + assert.Nil(t, info.TaskBilling) +} + func TestRecalcQuotaFromRatiosUsesRawEstimateBeforePreConsumeFloor(t *testing.T) { const estimatedQuota = 200 info := &relaycommon.RelayInfo{ diff --git a/router/api-router.go b/router/api-router.go index 4d333fb6..9800e1e8 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -248,6 +248,7 @@ func SetApiRouter(router *gin.Engine) { smartOpsRoute.PUT("/billing-settlements/blocking-policy", middleware.RootAuth(), middleware.CriticalRateLimit(), middleware.UserCriticalRateLimit(), middleware.DisableCache(), anonymousRequestBodyLimit, controller.UpdateBillingSettlementBlockingPolicy) smartOpsRoute.POST("/billing-settlements/reviews", middleware.CriticalRateLimit(), middleware.UserCriticalRateLimit(), middleware.DisableCache(), anonymousRequestBodyLimit, controller.ReviewBillingSettlements) smartOpsRoute.POST("/billing-settlements/:id/review", middleware.CriticalRateLimit(), middleware.UserCriticalRateLimit(), middleware.DisableCache(), anonymousRequestBodyLimit, controller.ReviewBillingSettlement) + smartOpsRoute.POST("/billing-settlements/:id/complete-task", middleware.RootAuth(), middleware.CriticalRateLimit(), middleware.UserCriticalRateLimit(), middleware.DisableCache(), anonymousRequestBodyLimit, controller.CompleteManualTaskBillingSettlement) smartOpsRoute.GET("/channel-performance", controller.GetChannelPerformance) smartOpsRoute.GET("/channel-performance/detail", controller.GetChannelPerformanceDetail) smartOpsRoute.GET("/model-performance", controller.GetModelPerformance) diff --git a/router/api_router_test.go b/router/api_router_test.go index f504d8a0..24dcdc14 100644 --- a/router/api_router_test.go +++ b/router/api_router_test.go @@ -198,7 +198,7 @@ func TestBillingSettlementBlockingPolicyRequiresRootRole(t *testing.T) { _ = sqlDB.Close() } }) - require.NoError(t, db.AutoMigrate(&model.User{}, &model.Option{}, &model.Log{})) + require.NoError(t, db.AutoMigrate(&model.User{}, &model.Option{}, &model.Log{}, &model.BillingSettlement{})) model.DB = db model.LOG_DB = db @@ -285,6 +285,29 @@ func TestBillingSettlementBlockingPolicyRequiresRootRole(t *testing.T) { require.Equal(t, http.StatusOK, rootResponse.Code) require.Contains(t, rootResponse.Body.String(), `"success":true`) require.False(t, billing_reconciliation_setting.BlockUserByDefault()) + + completeTask := func(cookies []*http.Cookie) *httptest.ResponseRecorder { + request := httptest.NewRequest( + http.MethodPost, + "/api/smart-ops/billing-settlements/1/complete-task", + strings.NewReader(`{"revision":1,"actual_quota":0,"note":"verified provider evidence"}`), + ) + request.Header.Set("Content-Type", "application/json") + for _, sessionCookie := range cookies { + request.AddCookie(sessionCookie) + } + recorder := httptest.NewRecorder() + engine.ServeHTTP(recorder, request) + return recorder + } + + adminCompletion := completeTask(login(admin.Id)) + require.Equal(t, http.StatusOK, adminCompletion.Code) + require.Contains(t, adminCompletion.Body.String(), `"success":false`) + + rootCompletion := completeTask(login(root.Id)) + require.Equal(t, http.StatusConflict, rootCompletion.Code) + require.Contains(t, rootCompletion.Body.String(), `"success":false`) } func TestUniversalVerifyRateLimitFollowsUserAcrossIPs(t *testing.T) { diff --git a/service/billing_session.go b/service/billing_session.go index a3367660..f4084f89 100644 --- a/service/billing_session.go +++ b/service/billing_session.go @@ -109,7 +109,7 @@ func updateBillingSettlementBacklogAlert(stats model.BillingSettlementBacklogSta Threshold: 0, ObservedAt: observedAt, Message: fmt.Sprintf( - "尚未核对的正向最终计费告警已全部关闭,此前共 %d 条。财务结算记录仍可能保持 pending/manual,请继续在对账列表中处理。", + "尚未核对的最终计费或任务用量告警已全部关闭,此前共 %d 条。财务结算记录仍可能保持 pending/manual,请继续在对账列表中处理。", previousCount, ), } @@ -149,7 +149,7 @@ func updateBillingSettlementBacklogAlert(stats model.BillingSettlementBacklogSta Threshold: oldestAge.Seconds(), ObservedAt: observedAt, Message: fmt.Sprintf( - "存在 %d 条尚未核对的正向最终计费对账,最早已等待 %s。是否阻止相关用户的新付费请求由管理员配置和单条核对策略决定,请尽快处理 pending/manual 记录。", + "存在 %d 条尚未核对的最终计费或任务用量对账,最早已等待 %s。只有未解决的正向资金差额可能阻止相关用户的新付费请求,请尽快处理 pending/manual 记录。", stats.Count, oldestAge.Round(time.Second), ), diff --git a/service/manual_task_settlement_test.go b/service/manual_task_settlement_test.go new file mode 100644 index 00000000..c8d7397e --- /dev/null +++ b/service/manual_task_settlement_test.go @@ -0,0 +1,431 @@ +package service + +import ( + "errors" + "testing" + "time" + + "github.com/MAX-API-Next/MAX-API/model" + "github.com/MAX-API-Next/MAX-API/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func createManualTaskFinalizeSettlement(t *testing.T, task *model.Task, reason string) model.BillingSettlement { + t.Helper() + now := time.Now().Unix() + settlement := model.BillingSettlement{ + OperationKey: model.BillingTaskFinalizeOperationKey(task.ID), + Source: model.BillingSettlementSourceWallet, + UserID: task.UserId, + SubscriptionID: task.PrivateData.SubscriptionId, + TokenID: task.PrivateData.TokenId, + TaskID: task.ID, + TaskQuota: int64(task.Quota), + TaskQuotaTarget: int64(task.Quota), + SubscriptionPreConsumeRequestID: task.PrivateData.BillingRequestId, + Status: model.BillingSettlementStatusManual, + LastError: reason, + CreatedAt: now, + UpdatedAt: now, + Revision: 1, + } + if taskIsSubscription(task) { + settlement.Source = model.BillingSettlementSourceSubscription + } + require.NoError(t, model.DB.Create(&settlement).Error) + return settlement +} + +func TestCompleteManualTaskBillingSettlementRefundsWalletAndReplays(t *testing.T) { + truncate(t) + const userID, tokenID, channelID = 801, 802, 803 + seedUser(t, userID, 900) + seedToken(t, tokenID, userID, "manual-completion-wallet", 100) + seedChannel(t, channelID) + task := makeTask(userID, channelID, 100, tokenID, BillingSourceWallet, 0) + persistTask(t, task) + manual := createManualTaskFinalizeSettlement(t, task, "provider usage was incomplete") + actualQuota := int64(40) + + result, err := CompleteManualTaskBillingSettlement( + manual.ID, + manual.Revision, + 9001, + &actualQuota, + "Verified provider artifacts and calculated the exact final quota.", + ) + + require.NoError(t, err) + assert.False(t, result.AlreadyApplied) + assert.EqualValues(t, -60, result.AppliedFundingDelta) + assert.Equal(t, model.BillingTaskManualCompletionOperationKey(task.ID), result.OperationKey) + assert.EqualValues(t, 960, getUserQuota(t, userID)) + assert.Equal(t, 160, getTokenRemainQuota(t, tokenID)) + assert.Equal(t, 40, getTokenUsedQuota(t, tokenID)) + var storedTask model.Task + require.NoError(t, model.DB.First(&storedTask, task.ID).Error) + assert.Equal(t, 40, storedTask.Quota) + pending, applied, stateErr := taskTerminalSettlementState(&storedTask, true) + require.NoError(t, stateErr) + assert.False(t, pending) + assert.True(t, applied) + var storedManual model.BillingSettlement + require.NoError(t, model.DB.First(&storedManual, manual.ID).Error) + assert.Equal(t, model.BillingSettlementStatusApplied, storedManual.Status) + assert.Equal(t, "provider usage was incomplete", storedManual.LastError) + assert.Equal(t, 9001, storedManual.ReconciliationReviewedBy) + assert.Contains(t, storedManual.ReconciliationReviewNote, "Verified provider artifacts") + assert.EqualValues(t, manual.Revision+1, storedManual.Revision) + assert.EqualValues(t, 1, countLogs(t)) + + replayed, err := CompleteManualTaskBillingSettlement( + manual.ID, + manual.Revision, + 9001, + &actualQuota, + "Verified provider artifacts and calculated the exact final quota.", + ) + require.NoError(t, err) + assert.True(t, replayed.AlreadyApplied) + assert.EqualValues(t, -60, replayed.AppliedFundingDelta) + assert.EqualValues(t, 960, getUserQuota(t, userID)) + assert.Equal(t, 160, getTokenRemainQuota(t, tokenID)) + assert.EqualValues(t, 1, countLogs(t)) + + differentQuota := int64(30) + _, err = CompleteManualTaskBillingSettlement( + manual.ID, + manual.Revision, + 9001, + &differentQuota, + "Verified provider artifacts and calculated a different final quota.", + ) + require.Error(t, err) + assert.EqualValues(t, 960, getUserQuota(t, userID)) + assert.Equal(t, 160, getTokenRemainQuota(t, tokenID)) + assert.EqualValues(t, 1, countLogs(t)) +} + +func TestCompleteManualTaskBillingSettlementPreservesExplicitZero(t *testing.T) { + truncate(t) + const userID, tokenID, channelID = 811, 812, 813 + seedUser(t, userID, 900) + seedToken(t, tokenID, userID, "manual-completion-zero", 100) + seedChannel(t, channelID) + task := makeTask(userID, channelID, 100, tokenID, BillingSourceWallet, 0) + persistTask(t, task) + manual := createManualTaskFinalizeSettlement(t, task, "provider confirmed no billable output") + actualQuota := int64(0) + + _, err := CompleteManualTaskBillingSettlement( + manual.ID, + manual.Revision, + 9011, + &actualQuota, + "Verified that the provider produced no billable output.", + ) + + require.NoError(t, err) + assert.EqualValues(t, 1000, getUserQuota(t, userID)) + assert.Equal(t, 200, getTokenRemainQuota(t, tokenID)) + assert.Zero(t, getTokenUsedQuota(t, tokenID)) + var storedTask model.Task + require.NoError(t, model.DB.First(&storedTask, task.ID).Error) + assert.Zero(t, storedTask.Quota) + var user model.User + require.NoError(t, model.DB.First(&user, userID).Error) + assert.Zero(t, user.UsedQuota) + assert.EqualValues(t, 1, user.RequestCount) + assert.EqualValues(t, 1, countLogs(t)) +} + +func TestCompleteManualTaskBillingSettlementRefundsSubscriptionWithDeletedToken(t *testing.T) { + truncate(t) + const userID, tokenID, channelID, subscriptionID = 821, 822, 823, 824 + seedUser(t, userID, 0) + seedToken(t, tokenID, userID, "manual-completion-subscription", 100) + seedChannel(t, channelID) + seedSubscription(t, subscriptionID, userID, 1000, 100) + task := makeTask(userID, channelID, 100, tokenID, BillingSourceSubscription, subscriptionID) + persistTask(t, task) + manual := createManualTaskFinalizeSettlement(t, task, "provider confirmed an empty result") + require.NoError(t, model.DB.Delete(&model.Token{}, tokenID).Error) + actualQuota := int64(0) + + _, err := CompleteManualTaskBillingSettlement( + manual.ID, + manual.Revision, + 9021, + &actualQuota, + "Verified the empty provider result and approved a full refund.", + ) + + require.NoError(t, err) + assert.Zero(t, getSubscriptionUsed(t, subscriptionID)) + var preConsume model.SubscriptionPreConsumeRecord + require.NoError(t, model.DB.Where("request_id = ?", task.PrivateData.BillingRequestId).First(&preConsume).Error) + assert.Equal(t, "refunded", preConsume.Status) + var storedTask model.Task + require.NoError(t, model.DB.First(&storedTask, task.ID).Error) + assert.Zero(t, storedTask.Quota) + assert.EqualValues(t, 1, countLogs(t)) +} + +func TestCompleteManualTaskBillingSettlementRecoversAfterChildWasApplied(t *testing.T) { + truncate(t) + const userID, tokenID, channelID = 831, 832, 833 + seedUser(t, userID, 900) + seedToken(t, tokenID, userID, "manual-completion-recovery", 100) + seedChannel(t, channelID) + task := makeTask(userID, channelID, 100, tokenID, BillingSourceWallet, 0) + persistTask(t, task) + manual := createManualTaskFinalizeSettlement(t, task, "provider usage needed manual recovery") + actualQuota := int64(40) + completionTask := *task + completionTask.Quota = int(manual.TaskQuota) + input := buildTaskExactFinalSettlementInput( + &completionTask, + int(actualQuota), + types.CloneTaskUsage(task.PrivateData.BillingContext.TaskUsage), + "Administrator manual task usage settlement: recovery fixture", + ) + require.NotNil(t, input) + input.OperationKey = model.BillingTaskManualCompletionOperationKey(task.ID) + _, err := model.EnsureManualTaskBillingCompletion(*input, 9031, "Recovered the already-applied child settlement after restart.") + require.NoError(t, err) + _, _, err = model.ApplyBillingSettlementOnce(*input) + require.NoError(t, err) + + var unresolved model.BillingSettlement + require.NoError(t, model.DB.First(&unresolved, manual.ID).Error) + assert.Equal(t, model.BillingSettlementStatusManual, unresolved.Status) + assert.EqualValues(t, 960, getUserQuota(t, userID)) + + result, err := CompleteManualTaskBillingSettlement( + manual.ID, + manual.Revision, + 9031, + &actualQuota, + "Recovered the already-applied child settlement after restart.", + ) + + require.NoError(t, err) + assert.True(t, result.AlreadyApplied) + assert.EqualValues(t, 960, getUserQuota(t, userID)) + assert.Equal(t, 160, getTokenRemainQuota(t, tokenID)) + require.NoError(t, model.DB.First(&unresolved, manual.ID).Error) + assert.Equal(t, model.BillingSettlementStatusApplied, unresolved.Status) + assert.EqualValues(t, manual.Revision+1, unresolved.Revision) + require.NoError(t, model.ProcessBillingSettlementEffect(input.OperationKey)) + require.NoError(t, model.ProcessBillingSettlementEffect(input.OperationKey)) + assert.EqualValues(t, 1, countLogs(t)) + + pending, applied, stateErr := taskTerminalSettlementState(task, true) + require.NoError(t, stateErr) + assert.False(t, pending) + assert.True(t, applied) +} + +func TestCompleteManualTaskBillingSettlementValidatesExplicitAmount(t *testing.T) { + truncate(t) + const userID = 841 + seedUser(t, userID, 900) + task := makeTask(userID, 0, 100, 0, BillingSourceWallet, 0) + persistTask(t, task) + manual := createManualTaskFinalizeSettlement(t, task, "provider usage needed manual review") + + _, err := CompleteManualTaskBillingSettlement(manual.ID, manual.Revision, 9041, nil, "Verified provider evidence.") + require.Error(t, err) + aboveReservation := int64(101) + _, err = CompleteManualTaskBillingSettlement(manual.ID, manual.Revision, 9041, &aboveReservation, "Verified provider evidence.") + require.Error(t, err) + negative := int64(-1) + _, err = CompleteManualTaskBillingSettlement(manual.ID, manual.Revision, 9041, &negative, "Verified provider evidence.") + require.Error(t, err) + assert.EqualValues(t, 900, getUserQuota(t, userID)) + var childCount int64 + require.NoError(t, model.DB.Model(&model.BillingSettlement{}). + Where("operation_key = ?", model.BillingTaskManualCompletionOperationKey(task.ID)). + Count(&childCount).Error) + assert.Zero(t, childCount) +} + +func TestCompleteManualTaskBillingSettlementRejectsTaskIdentityDrift(t *testing.T) { + truncate(t) + const userID, tokenID, channelID = 851, 852, 853 + seedUser(t, userID, 900) + seedToken(t, tokenID, userID, "manual-completion-identity", 100) + seedChannel(t, channelID) + task := makeTask(userID, channelID, 100, tokenID, BillingSourceWallet, 0) + persistTask(t, task) + manual := createManualTaskFinalizeSettlement(t, task, "provider usage needed manual review") + require.NoError(t, model.DB.Model(&model.Task{}).Where("id = ?", task.ID).Update("user_id", userID+1).Error) + actualQuota := int64(40) + + _, err := CompleteManualTaskBillingSettlement( + manual.ID, + manual.Revision, + 9051, + &actualQuota, + "Verified provider evidence and exact usage.", + ) + + require.ErrorIs(t, err, model.ErrBillingSettlementReviewConflict) + assert.EqualValues(t, 900, getUserQuota(t, userID)) + assert.Equal(t, 100, getTokenRemainQuota(t, tokenID)) + var childCount int64 + require.NoError(t, model.DB.Model(&model.BillingSettlement{}). + Where("operation_key = ?", model.BillingTaskManualCompletionOperationKey(task.ID)). + Count(&childCount).Error) + assert.Zero(t, childCount) +} + +func TestCompleteManualTaskBillingSettlementRejectsClampedSubscriptionRefund(t *testing.T) { + truncate(t) + const userID, tokenID, channelID, subscriptionID = 861, 862, 863, 864 + seedUser(t, userID, 0) + seedToken(t, tokenID, userID, "manual-completion-clamped-subscription", 100) + seedChannel(t, channelID) + seedSubscription(t, subscriptionID, userID, 1000, 50) + task := makeTask(userID, channelID, 100, tokenID, BillingSourceSubscription, subscriptionID) + persistTask(t, task) + manual := createManualTaskFinalizeSettlement(t, task, "provider confirmed no billable usage") + actualQuota := int64(0) + + _, err := CompleteManualTaskBillingSettlement( + manual.ID, + manual.Revision, + 9061, + &actualQuota, + "Verified provider evidence for a full refund.", + ) + + require.ErrorIs(t, err, model.ErrSubscriptionRefundClamped) + assert.EqualValues(t, 50, getSubscriptionUsed(t, subscriptionID)) + assert.Equal(t, 100, getTokenRemainQuota(t, tokenID)) + var storedManual model.BillingSettlement + require.NoError(t, model.DB.First(&storedManual, manual.ID).Error) + assert.Equal(t, model.BillingSettlementStatusManual, storedManual.Status) + var child model.BillingSettlement + require.NoError(t, model.DB.Where("operation_key = ?", model.BillingTaskManualCompletionOperationKey(task.ID)).First(&child).Error) + assert.Equal(t, model.BillingSettlementStatusManual, child.Status) + assert.Equal(t, 9061, child.ReconciliationReviewedBy) + assert.Equal(t, "Verified provider evidence for a full refund.", child.ReconciliationReviewNote) +} + +func TestCompleteManualTaskBillingSettlementReapprovesManualChildAfterRecovery(t *testing.T) { + truncate(t) + const userID, tokenID, channelID, subscriptionID = 871, 872, 873, 874 + seedUser(t, userID, 0) + seedToken(t, tokenID, userID, "manual-completion-reapproval", 100) + seedChannel(t, channelID) + seedSubscription(t, subscriptionID, userID, 1000, 50) + task := makeTask(userID, channelID, 100, tokenID, BillingSourceSubscription, subscriptionID) + persistTask(t, task) + manual := createManualTaskFinalizeSettlement(t, task, "provider confirmed no billable usage") + actualQuota := int64(0) + note := "Verified provider evidence for a full refund." + + _, err := CompleteManualTaskBillingSettlement(manual.ID, manual.Revision, 9071, &actualQuota, note) + require.Error(t, err) + + var failedChild model.BillingSettlement + require.NoError(t, model.DB.Where("operation_key = ?", model.BillingTaskManualCompletionOperationKey(task.ID)).First(&failedChild).Error) + require.Equal(t, model.BillingSettlementStatusManual, failedChild.Status) + require.Equal(t, 9071, failedChild.ReconciliationReviewedBy) + require.Equal(t, note, failedChild.ReconciliationReviewNote) + failedRevision := failedChild.Revision + + // An operator repairs the inconsistent subscription mirror before + // re-approving the exact same immutable child operation. + require.NoError(t, model.DB.Model(&model.UserSubscription{}). + Where("id = ?", subscriptionID). + Update("amount_used", 100).Error) + + _, err = CompleteManualTaskBillingSettlement( + manual.ID, + manual.Revision, + 9072, + &actualQuota, + "A different administrator attempted to replace the original approval.", + ) + require.ErrorIs(t, err, model.ErrBillingSettlementReviewConflict) + + result, err := CompleteManualTaskBillingSettlement(manual.ID, manual.Revision, 9071, &actualQuota, note) + require.NoError(t, err) + assert.False(t, result.AlreadyApplied) + assert.EqualValues(t, -100, result.AppliedFundingDelta) + assert.Zero(t, getSubscriptionUsed(t, subscriptionID)) + assert.Equal(t, 200, getTokenRemainQuota(t, tokenID)) + assert.Zero(t, getTokenUsedQuota(t, tokenID)) + assert.EqualValues(t, 1, countLogs(t)) + + var recoveredChild model.BillingSettlement + require.NoError(t, model.DB.First(&recoveredChild, failedChild.ID).Error) + assert.Equal(t, model.BillingSettlementStatusApplied, recoveredChild.Status) + assert.Greater(t, recoveredChild.Revision, failedRevision) + assert.Equal(t, 9071, recoveredChild.ReconciliationReviewedBy) + assert.Equal(t, note, recoveredChild.ReconciliationReviewNote) + var recoveredParent model.BillingSettlement + require.NoError(t, model.DB.First(&recoveredParent, manual.ID).Error) + assert.Equal(t, model.BillingSettlementStatusApplied, recoveredParent.Status) +} + +func TestCompleteManualTaskBillingSettlementPropagatesFinalTaskReadFailure(t *testing.T) { + truncate(t) + const userID, tokenID, channelID = 881, 882, 883 + seedUser(t, userID, 900) + seedToken(t, tokenID, userID, "manual-completion-final-read", 100) + seedChannel(t, channelID) + task := makeTask(userID, channelID, 100, tokenID, BillingSourceWallet, 0) + persistTask(t, task) + manual := createManualTaskFinalizeSettlement(t, task, "provider usage needed manual verification") + actualQuota := int64(40) + finalReadErr := errors.New("final task read unavailable") + childSettlementApplied := false + beforeCallbackName := "test:manual-completion-final-task-read-before" + require.NoError(t, model.DB.Callback().Query().Before("gorm:query").Register(beforeCallbackName, func(tx *gorm.DB) { + if tx.Statement != nil && tx.Statement.Table == "tasks" && childSettlementApplied { + _ = tx.AddError(finalReadErr) + childSettlementApplied = false + } + })) + updateCallbackName := "test:manual-completion-final-task-read-update" + require.NoError(t, model.DB.Callback().Update().After("gorm:update").Register(updateCallbackName, func(tx *gorm.DB) { + if tx.Statement == nil || tx.Statement.Schema == nil || tx.Statement.Schema.Name != "BillingSettlement" { + return + } + fields, ok := tx.Statement.Dest.(map[string]interface{}) + if status, statusOK := fields["status"].(string); ok && statusOK && status == model.BillingSettlementStatusApplied { + childSettlementApplied = true + } + })) + t.Cleanup(func() { + require.NoError(t, model.DB.Callback().Query().Remove(beforeCallbackName)) + require.NoError(t, model.DB.Callback().Update().Remove(updateCallbackName)) + }) + + _, err := CompleteManualTaskBillingSettlement( + manual.ID, + manual.Revision, + 9081, + &actualQuota, + "Verified provider evidence and exact usage.", + ) + + require.ErrorIs(t, err, finalReadErr) + require.False(t, errors.Is(err, model.ErrBillingSettlementTaskConflict)) + assert.EqualValues(t, 960, getUserQuota(t, userID)) + assert.Equal(t, 160, getTokenRemainQuota(t, tokenID)) + var storedTask model.Task + require.NoError(t, model.DB.First(&storedTask, task.ID).Error) + assert.Equal(t, 40, storedTask.Quota) + var child model.BillingSettlement + require.NoError(t, model.DB.Where("operation_key = ?", model.BillingTaskManualCompletionOperationKey(task.ID)).First(&child).Error) + assert.Equal(t, model.BillingSettlementStatusApplied, child.Status) + var parent model.BillingSettlement + require.NoError(t, model.DB.First(&parent, manual.ID).Error) + assert.Equal(t, model.BillingSettlementStatusManual, parent.Status) +} diff --git a/service/smart_ops_alert.go b/service/smart_ops_alert.go index 64451ae2..46622b3b 100644 --- a/service/smart_ops_alert.go +++ b/service/smart_ops_alert.go @@ -15,6 +15,8 @@ import ( "github.com/MAX-API-Next/MAX-API/dto" "github.com/MAX-API-Next/MAX-API/model" "github.com/MAX-API-Next/MAX-API/setting/billing_reconciliation_setting" + "github.com/MAX-API-Next/MAX-API/types" + "gorm.io/gorm" ) var ErrInvalidBillingSettlementReconciliationQuery = errors.New("invalid billing settlement reconciliation query") @@ -417,6 +419,155 @@ func ReviewBillingSettlements(targets []model.BillingSettlementReviewTarget, rev return records, nil } +// ManualTaskBillingCompletionResult describes the durable child settlement +// that completed a reconciliation-only task finalization. The operation key +// can be used to trace the idempotent balance mutation and its effect replay. +type ManualTaskBillingCompletionResult struct { + SettlementID int64 `json:"settlement_id"` + TaskID int64 `json:"task_id"` + UserID int `json:"user_id"` + OperationKey string `json:"operation_key"` + ActualQuota int64 `json:"actual_quota"` + AppliedFundingDelta int64 `json:"applied_funding_delta"` + AlreadyApplied bool `json:"already_applied"` +} + +// CompleteManualTaskBillingSettlement applies an administrator-approved exact +// final task quota. It never charges above the frozen reservation: the only +// allowed balance mutation is an exact refund (including an explicit zero-cost +// result) or a zero delta. Approval is persisted on the child operation before +// the existing idempotent settlement transaction may mutate any balance. +func CompleteManualTaskBillingSettlement( + id int64, + expectedRevision int64, + reviewerID int, + actualQuota *int64, + note string, +) (ManualTaskBillingCompletionResult, error) { + if id <= 0 || expectedRevision <= 0 || reviewerID <= 0 || actualQuota == nil { + return ManualTaskBillingCompletionResult{}, ErrInvalidBillingSettlementReconciliationReview + } + note = strings.TrimSpace(note) + if err := validateBillingSettlementReviewNote(note); err != nil { + return ManualTaskBillingCompletionResult{}, err + } + note = strings.TrimSpace(common.SanitizePersistedLogContent(common.MaskSensitiveInfo(note))) + if err := validateBillingSettlementReviewNote(note); err != nil { + return ManualTaskBillingCompletionResult{}, err + } + + original, originalAlreadyResolved, err := model.GetManualTaskBillingSettlement(id, expectedRevision) + if err != nil { + return ManualTaskBillingCompletionResult{}, err + } + if originalAlreadyResolved && + (original.ReconciliationReviewedBy != reviewerID || original.ReconciliationReviewNote != note) { + return ManualTaskBillingCompletionResult{}, model.ErrBillingSettlementReviewConflict + } + if *actualQuota < 0 || *actualQuota > original.TaskQuota || + original.TaskQuota < 0 || original.TaskQuotaTarget != original.TaskQuota || + original.FundingDelta != 0 || original.TokenDelta != 0 { + return ManualTaskBillingCompletionResult{}, ErrInvalidBillingSettlementReconciliationReview + } + if int64(int(original.TaskQuota)) != original.TaskQuota || int64(int(*actualQuota)) != *actualQuota { + return ManualTaskBillingCompletionResult{}, ErrInvalidBillingSettlementReconciliationReview + } + + task, err := model.GetTaskByID(original.TaskID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ManualTaskBillingCompletionResult{}, model.ErrBillingSettlementReviewConflict + } + return ManualTaskBillingCompletionResult{}, err + } + expectedSource := model.BillingSettlementSourceWallet + if taskIsSubscription(task) { + expectedSource = model.BillingSettlementSourceSubscription + } + if original.Source != expectedSource || + original.UserID != task.UserId || + original.SubscriptionID != task.PrivateData.SubscriptionId || + original.TokenID != task.PrivateData.TokenId || + original.SubscriptionPreConsumeRequestID != task.PrivateData.BillingRequestId { + return ManualTaskBillingCompletionResult{}, model.ErrBillingSettlementReviewConflict + } + + existingChild, childFound, err := model.GetManualTaskBillingCompletion(task.ID) + if err != nil { + return ManualTaskBillingCompletionResult{}, err + } + childWasApplied := childFound && existingChild.Status == model.BillingSettlementStatusApplied + if childFound && existingChild.TaskQuotaTarget != *actualQuota { + return ManualTaskBillingCompletionResult{}, model.ErrBillingSettlementReviewConflict + } + if int64(task.Quota) != original.TaskQuota && + (int64(task.Quota) != *actualQuota || !childWasApplied) { + return ManualTaskBillingCompletionResult{}, model.ErrBillingSettlementReviewConflict + } + + completionTask := *task + completionTask.Quota = int(original.TaskQuota) + var usage *types.TaskUsage + if completionTask.PrivateData.BillingContext != nil { + usage = types.CloneTaskUsage(completionTask.PrivateData.BillingContext.TaskUsage) + } + input := buildTaskExactFinalSettlementInput( + &completionTask, + int(*actualQuota), + usage, + "Administrator-approved manual task usage settlement", + ) + if input == nil { + return ManualTaskBillingCompletionResult{}, model.ErrBillingSettlementReviewConflict + } + input.OperationKey = model.BillingTaskManualCompletionOperationKey(task.ID) + + if _, err := model.EnsureManualTaskBillingCompletion(*input, reviewerID, note); err != nil { + return ManualTaskBillingCompletionResult{}, err + } + appliedFundingDelta, childAlreadyApplied, err := model.ApplyBillingSettlementOnce(*input) + if err != nil { + return ManualTaskBillingCompletionResult{}, err + } + if appliedFundingDelta != input.FundingDelta { + return ManualTaskBillingCompletionResult{}, model.ErrBillingSettlementOperationConflict + } + settledTask, err := model.GetTaskByID(task.ID) + if err != nil { + return ManualTaskBillingCompletionResult{}, err + } + if int64(settledTask.Quota) != *actualQuota { + return ManualTaskBillingCompletionResult{}, model.ErrBillingSettlementTaskConflict + } + if _, originalResolvedByReplay, err := model.ResolveManualTaskBillingSettlement( + id, + expectedRevision, + reviewerID, + note, + ); err != nil { + return ManualTaskBillingCompletionResult{}, err + } else { + originalAlreadyResolved = originalAlreadyResolved || originalResolvedByReplay + } + + // Funding and task quota are already durable. Effect replay is independently + // idempotent and may remain pending without hiding the completed financial + // state or blocking the provider-driven terminal task transition. + if effectErr := model.ProcessBillingSettlementEffect(input.OperationKey); effectErr != nil { + common.SysLog(fmt.Sprintf("manual task billing settlement effect remains pending: operation=%s error=%v", input.OperationKey, effectErr)) + } + refreshBillingSettlementBacklogAfterReview() + return ManualTaskBillingCompletionResult{ + SettlementID: id, + TaskID: task.ID, + UserID: original.UserID, + OperationKey: input.OperationKey, + ActualQuota: *actualQuota, + AppliedFundingDelta: appliedFundingDelta, + AlreadyApplied: originalAlreadyResolved || childWasApplied || childAlreadyApplied, + }, nil +} + type smartOpsAlertRecipient struct { ID int Email string diff --git a/service/task_billing.go b/service/task_billing.go index 06299ed6..93a40b87 100644 --- a/service/task_billing.go +++ b/service/task_billing.go @@ -8,10 +8,13 @@ import ( "github.com/MAX-API-Next/MAX-API/common" "github.com/MAX-API-Next/MAX-API/constant" + "github.com/MAX-API-Next/MAX-API/dto" "github.com/MAX-API-Next/MAX-API/logger" "github.com/MAX-API-Next/MAX-API/model" relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" "github.com/MAX-API-Next/MAX-API/setting/ratio_setting" + "github.com/MAX-API-Next/MAX-API/setting/task_billing_setting" + "github.com/MAX-API-Next/MAX-API/types" "github.com/gin-gonic/gin" ) @@ -66,6 +69,9 @@ func BuildTaskSubmissionSettlementEffect(c *gin.Context, info *relaycommon.Relay if info == nil { return nil } + if taskSubmissionUsageIsDeferred(info) { + return nil + } logContent, other := buildTaskConsumptionLogData(c, info) useTimeSeconds := 0 if !info.StartTime.IsZero() { @@ -93,6 +99,9 @@ func BuildTaskSubmissionSettlementEffect(c *gin.Context, info *relaycommon.Relay // LogTaskConsumption records task usage for legacy/custom funding paths that // do not have a durable BillingSettlement operation to carry the effect. func LogTaskConsumption(c *gin.Context, info *relaycommon.RelayInfo) { + if info == nil || taskSubmissionUsageIsDeferred(info) { + return + } tokenName := "" if c != nil { tokenName = c.GetString("token_name") @@ -138,6 +147,12 @@ func taskBillingOther(task *model.Task) map[string]interface{} { if bc.TaskBilling != nil { other["task_billing"] = bc.TaskBilling } + if bc.TaskBillingPlan != nil { + other["task_billing_plan"] = taskBillingPlanLogMetadata(bc.TaskBillingPlan) + } + if bc.TaskUsage != nil { + other["task_usage"] = taskUsageLogMetadata(bc.TaskUsage) + } } props := task.Properties if props.UpstreamModelName != "" && props.UpstreamModelName != props.OriginModelName { @@ -147,6 +162,50 @@ func taskBillingOther(task *model.Task) map[string]interface{} { return other } +func taskBillingPlanLogMetadata(plan *types.TaskBillingPlan) map[string]interface{} { + if plan == nil { + return nil + } + return map[string]interface{}{ + "source": plan.Source, + "rule_key": plan.RuleKey, + "reserve_quota": plan.ReserveQuota, + } +} + +func taskUsageLogMetadata(usage *types.TaskUsage) map[string]interface{} { + if usage == nil { + return nil + } + metadata := map[string]interface{}{ + "source": usage.Source, + "completeness": usage.Completeness, + } + if usage.OutputDurationMs != nil { + metadata["output_duration_ms"] = *usage.OutputDurationMs + } + if usage.InputVideoDurationMs != nil { + metadata["input_video_duration_ms"] = *usage.InputVideoDurationMs + } + if usage.InputAudioDurationMs != nil { + metadata["input_audio_duration_ms"] = *usage.InputAudioDurationMs + } + if usage.InputImageCount != nil { + metadata["input_image_count"] = *usage.InputImageCount + } + if usage.InputVideoCount != nil { + metadata["input_video_count"] = *usage.InputVideoCount + } + if usage.InputAudioCount != nil { + metadata["input_audio_count"] = *usage.InputAudioCount + } + return metadata +} + +func taskSubmissionUsageIsDeferred(info *relaycommon.RelayInfo) bool { + return info != nil && info.TaskBillingPlan != nil +} + // taskModelName 从 BillingContext 或 Properties 中获取模型名称。 func taskModelName(task *model.Task) string { if bc := task.PrivateData.BillingContext; bc != nil && bc.OriginModelName != "" { @@ -180,10 +239,33 @@ func taskFinalSettlementPending(task *model.Task) (bool, error) { // provider transition that would make the task terminal. Callers keep their // own error reporting and skip/return behavior. func taskTerminalSettlementPending(task *model.Task, providerTerminal bool) (bool, error) { + pending, _, err := taskTerminalSettlementState(task, providerTerminal) + return pending, err +} + +// taskTerminalSettlementState separates a task-finalize funding result from +// its effect replay. Applied funding allows the provider-driven terminal +// transition even when the non-financial effect is still pending. +func taskTerminalSettlementState(task *model.Task, providerTerminal bool) (pending bool, taskFinalizeApplied bool, err error) { if !providerTerminal { - return false, nil + return false, false, nil + } + pending, err = taskFinalSettlementPending(task) + if err != nil || pending || task == nil || task.ID <= 0 { + return pending, false, err + } + status, found, err := model.GetBillingSettlementStatus(model.BillingTaskFinalizeOperationKey(task.ID)) + if err != nil || !found { + return false, false, err + } + switch status { + case "", model.BillingSettlementStatusApplied: + return false, true, nil + case model.BillingSettlementStatusPending, model.BillingSettlementStatusManual: + return true, false, nil + default: + return true, false, fmt.Errorf("unknown task terminal settlement status %q", status) } - return taskFinalSettlementPending(task) } func BuildTaskRefundSettlementInput(task *model.Task, reason string) *model.BillingSettlementInput { @@ -246,7 +328,7 @@ func buildTaskFinalSettlementInput(task *model.Task, actualQuota int, reason str attachQuotaSaturationToOther(other, clamp) } return &model.BillingSettlementInput{ - OperationKey: fmt.Sprintf("task:%d:finalize", task.ID), + OperationKey: model.BillingTaskFinalizeOperationKey(task.ID), Source: source, UserID: task.UserId, SubscriptionID: task.PrivateData.SubscriptionId, @@ -267,6 +349,161 @@ func buildTaskFinalSettlementInput(task *model.Task, actualQuota int, reason str } } +type taskTerminalBillingDecision struct { + Settlement *model.BillingSettlementInput + ManualReason string + Usage *types.TaskUsage + UsesPlan bool +} + +func prepareTaskTerminalBillingDecision( + ctx context.Context, + adaptor TaskPollingAdaptor, + task *model.Task, + taskResult *relaycommon.TaskInfo, + channelType int, + channelSettings ...dto.ChannelOtherSettings, +) taskTerminalBillingDecision { + plan := taskBillingPlan(task) + if plan == nil { + if taskResult != nil && taskResult.Status == string(model.TaskStatusFailure) { + return taskTerminalBillingDecision{Settlement: BuildTaskRefundSettlementInput(task, taskResult.Reason)} + } + return taskTerminalBillingDecision{ + Settlement: prepareTaskCompletionSettlement(ctx, adaptor, task, taskResult, channelType, channelSettings...), + } + } + + usage := frozenTaskUsage(task, taskResult) + decision := taskTerminalBillingDecision{ + Settlement: buildTaskManualSettlementInput(task), + Usage: usage, + UsesPlan: true, + } + if task == nil || task.ID <= 0 || task.Quota < 0 { + decision.ManualReason = "H3 terminal billing task snapshot is invalid" + return decision + } + if plan.ReserveQuota < 0 || task.Quota < plan.ReserveQuota { + decision.ManualReason = "H3 terminal billing reservation snapshot conflicts with the frozen plan" + return decision + } + if err := task_billing_setting.ValidateH3BillingPlanSnapshot(plan); err != nil { + decision.ManualReason = "H3 terminal billing frozen totals require manual reconciliation: " + err.Error() + return decision + } + quote, err := task_billing_setting.QuoteH3Final(plan, usage) + if err != nil { + decision.ManualReason = "H3 terminal usage requires manual reconciliation: " + err.Error() + return decision + } + if quote.Quota > task.Quota { + decision.ManualReason = "H3 terminal quota exceeds the actual task reservation" + return decision + } + status := "unknown" + if taskResult != nil && strings.TrimSpace(taskResult.Status) != "" { + status = strings.ToLower(strings.TrimSpace(taskResult.Status)) + } + decision.Settlement = buildTaskExactFinalSettlementInput(task, quote.Quota, usage, "H3 actual usage settlement (provider status: "+status+")") + return decision +} + +func taskBillingPlan(task *model.Task) *types.TaskBillingPlan { + if task == nil || task.PrivateData.BillingContext == nil { + return nil + } + return task.PrivateData.BillingContext.TaskBillingPlan +} + +func frozenTaskUsage(task *model.Task, taskResult *relaycommon.TaskInfo) *types.TaskUsage { + if task != nil && task.PrivateData.BillingContext != nil && task.PrivateData.BillingContext.TaskUsage != nil { + frozen := task.PrivateData.BillingContext.TaskUsage + // A terminal poll may initially lack usage and create a manual hold while + // a later provider poll returns the complete authoritative facts. Missing + // evidence is not a financial value to freeze; replace only that state with + // a complete provider result. Partial, invalid, ambiguous, and complete + // snapshots remain immutable and continue to require reconciliation. + if frozen.Completeness == types.TaskUsageCompletenessMissing && + taskResult != nil && taskResult.Usage != nil && + taskResult.Usage.Source == types.TaskUsageSourceProviderResponse && + taskResult.Usage.Completeness == types.TaskUsageCompletenessComplete { + return types.CloneTaskUsage(taskResult.Usage) + } + return types.CloneTaskUsage(frozen) + } + if taskResult != nil && taskResult.Usage != nil { + return types.CloneTaskUsage(taskResult.Usage) + } + return &types.TaskUsage{ + Source: types.TaskUsageSourceProviderResponse, + Completeness: types.TaskUsageCompletenessMissing, + } +} + +func buildTaskManualSettlementInput(task *model.Task) *model.BillingSettlementInput { + if task == nil || task.ID <= 0 { + return nil + } + source := model.BillingSettlementSourceWallet + if taskIsSubscription(task) { + source = model.BillingSettlementSourceSubscription + } + return &model.BillingSettlementInput{ + OperationKey: model.BillingTaskFinalizeOperationKey(task.ID), + Source: source, + UserID: task.UserId, + SubscriptionID: task.PrivateData.SubscriptionId, + TokenID: task.PrivateData.TokenId, + TaskID: task.ID, + TaskQuota: int64(task.Quota), + TaskQuotaTarget: int64(task.Quota), + SubscriptionPreConsumeRequestID: task.PrivateData.BillingRequestId, + } +} + +func buildTaskExactFinalSettlementInput(task *model.Task, actualQuota int, usage *types.TaskUsage, reason string) *model.BillingSettlementInput { + if task == nil || task.ID <= 0 || actualQuota < 0 { + return nil + } + reason = common.SanitizePersistedLogContent(reason) + quotaDelta := actualQuota - task.Quota + source := model.BillingSettlementSourceWallet + if taskIsSubscription(task) { + source = model.BillingSettlementSourceSubscription + } + tokenDelta := int64(quotaDelta) + if task.PrivateData.TokenId <= 0 { + tokenDelta = 0 + } + other := taskBillingOther(task) + other["task_id"] = task.TaskID + other["pre_consumed_quota"] = task.Quota + other["actual_quota"] = actualQuota + other["task_usage"] = taskUsageLogMetadata(usage) + return &model.BillingSettlementInput{ + OperationKey: model.BillingTaskFinalizeOperationKey(task.ID), + Source: source, + UserID: task.UserId, + SubscriptionID: task.PrivateData.SubscriptionId, + TokenID: task.PrivateData.TokenId, + FundingDelta: int64(quotaDelta), + TokenDelta: tokenDelta, + TaskID: task.ID, + TaskQuota: int64(task.Quota), + TaskQuotaTarget: int64(actualQuota), + SubscriptionPreConsumeRequestID: task.PrivateData.BillingRequestId, + FinalizeSubscriptionPreConsume: source == model.BillingSettlementSourceSubscription && actualQuota == 0 && quotaDelta < 0, + AllowMissingToken: quotaDelta < 0, + Effect: &model.BillingSettlementEffect{ + LogType: model.LogTypeConsume, Content: reason, ChannelID: task.ChannelId, + ModelName: taskModelName(task), TokenID: task.PrivateData.TokenId, + Group: task.Group, Other: other, NodeName: task.PrivateData.NodeName, + UpdateUsage: true, Quota: int64(actualQuota), QuotaIsActual: true, + }, + } +} + func applyTaskBillingSettlement(ctx context.Context, task *model.Task, input *model.BillingSettlementInput) bool { if input == nil { return true diff --git a/service/task_billing_h3_test.go b/service/task_billing_h3_test.go new file mode 100644 index 00000000..ca0f76f8 --- /dev/null +++ b/service/task_billing_h3_test.go @@ -0,0 +1,995 @@ +package service + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/constant" + "github.com/MAX-API-Next/MAX-API/model" + relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" + "github.com/MAX-API-Next/MAX-API/setting/task_billing_setting" + "github.com/MAX-API-Next/MAX-API/types" + "github.com/stretchr/testify/require" +) + +type h3TerminalPollingAdaptor struct { + result *relaycommon.TaskInfo + parseErr error +} + +func (a *h3TerminalPollingAdaptor) Init(*relaycommon.RelayInfo) {} + +func (a *h3TerminalPollingAdaptor) FetchTask(string, string, map[string]any, string) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"task":{"id":"provider-h3"}}`)), + }, nil +} + +func (a *h3TerminalPollingAdaptor) ParseTaskResult([]byte) (*relaycommon.TaskInfo, error) { + if a.parseErr != nil { + return nil, a.parseErr + } + return a.result, nil +} + +func (a *h3TerminalPollingAdaptor) AdjustBillingOnComplete(*model.Task, *relaycommon.TaskInfo) int { + panic("legacy completion billing must not run for a frozen H3 plan") +} + +func h3UsageInt64(value int64) *int64 { + return &value +} + +func buildServiceH3Plan(t *testing.T, input task_billing_setting.H3BillingInput) *types.TaskBillingPlan { + t.Helper() + originalQuotaPerUnit := common.QuotaPerUnit + common.QuotaPerUnit = 1000 + t.Cleanup(func() { common.QuotaPerUnit = originalQuotaPerUnit }) + plan, err := task_billing_setting.BuildH3BillingPlan(input, 1) + require.NoError(t, err) + return plan +} + +func makeServiceH3Task(t *testing.T, quota int, plan *types.TaskBillingPlan) *model.Task { + t.Helper() + task := makeTask(901, 902, quota, 903, BillingSourceWallet, 0) + task.ID = 904 + task.TaskID = "task_h3_terminal" + task.Properties.OriginModelName = constant.TaskModelMiniMaxH3 + task.Properties.UpstreamModelName = constant.TaskModelMiniMaxH3 + task.PrivateData.UpstreamTaskID = "provider-h3" + task.PrivateData.BillingRequestId = "request-h3-terminal" + task.PrivateData.BillingContext.TaskBillingPlan = types.CloneTaskBillingPlan(plan) + return task +} + +func TestH3TerminalDecisionUsesFrozenPlanForNegativeAndZeroDelta(t *testing.T) { + plan := buildServiceH3Plan(t, task_billing_setting.H3BillingInput{ + Resolution: "768P", OutputDurationSeconds: 5, InputVideoCount: 1, + }) + task := makeServiceH3Task(t, plan.ReserveQuota, plan) + usage := &types.TaskUsage{ + OutputDurationMs: h3UsageInt64(5_000), + InputVideoDurationMs: h3UsageInt64(5_000), + InputImageCount: h3UsageInt64(0), + Source: types.TaskUsageSourceProviderResponse, + Completeness: types.TaskUsageCompletenessComplete, + } + + decision := prepareTaskTerminalBillingDecision( + context.Background(), nil, task, + &relaycommon.TaskInfo{Status: string(model.TaskStatusSuccess), Usage: usage}, + constant.ChannelTypeMiniMax, + ) + + require.True(t, decision.UsesPlan) + require.Empty(t, decision.ManualReason) + require.NotNil(t, decision.Settlement) + require.EqualValues(t, -800, decision.Settlement.FundingDelta) + require.EqualValues(t, 800, decision.Settlement.TaskQuotaTarget) + require.NotNil(t, decision.Settlement.Effect) + require.True(t, decision.Settlement.Effect.UpdateUsage) + require.True(t, decision.Settlement.Effect.QuotaIsActual) + require.EqualValues(t, 800, decision.Settlement.Effect.Quota) + require.Equal(t, usage, decision.Usage) + require.NotSame(t, usage, decision.Usage) + require.NotSame(t, usage.OutputDurationMs, decision.Usage.OutputDurationMs) + require.NotSame(t, usage.InputImageCount, decision.Usage.InputImageCount) + + zeroDeltaTask := makeServiceH3Task(t, plan.ReserveQuota, plan) + reserveUsage := types.CloneTaskUsage(usage) + reserveUsage.InputVideoDurationMs = h3UsageInt64(15_000) + zeroDeltaDecision := prepareTaskTerminalBillingDecision( + context.Background(), nil, zeroDeltaTask, + &relaycommon.TaskInfo{Status: string(model.TaskStatusFailure), Usage: reserveUsage}, + constant.ChannelTypeMiniMax, + ) + require.True(t, zeroDeltaDecision.UsesPlan) + require.Empty(t, zeroDeltaDecision.ManualReason) + require.NotNil(t, zeroDeltaDecision.Settlement) + require.Zero(t, zeroDeltaDecision.Settlement.FundingDelta) + require.EqualValues(t, plan.ReserveQuota, zeroDeltaDecision.Settlement.TaskQuotaTarget) +} + +func TestH3TerminalDecisionPreservesExplicitZeroFinalQuota(t *testing.T) { + plan := buildServiceH3Plan(t, task_billing_setting.H3BillingInput{ + Resolution: "768P", OutputDurationSeconds: 5, + }) + for i := range plan.Components { + plan.Components[i].UnitPrice = "0" + } + plan.EstimateQuota = 0 + plan.ReserveQuota = 0 + task := makeServiceH3Task(t, 100, plan) + usage := &types.TaskUsage{ + OutputDurationMs: h3UsageInt64(5_000), + InputImageCount: h3UsageInt64(0), + Source: types.TaskUsageSourceProviderResponse, + Completeness: types.TaskUsageCompletenessComplete, + } + + decision := prepareTaskTerminalBillingDecision( + context.Background(), nil, task, + &relaycommon.TaskInfo{Status: string(model.TaskStatusSuccess), Usage: usage}, + constant.ChannelTypeMiniMax, + ) + + require.True(t, decision.UsesPlan) + require.Empty(t, decision.ManualReason) + require.NotNil(t, decision.Settlement) + require.EqualValues(t, -100, decision.Settlement.FundingDelta) + require.Zero(t, decision.Settlement.TaskQuotaTarget) + require.NotNil(t, decision.Settlement.Effect) + require.True(t, decision.Settlement.Effect.QuotaIsActual) + require.Zero(t, decision.Settlement.Effect.Quota) +} + +func TestH3TerminalDecisionRoutesUnsafeUsageToManual(t *testing.T) { + plan := buildServiceH3Plan(t, task_billing_setting.H3BillingInput{ + Resolution: "768P", OutputDurationSeconds: 5, InputVideoCount: 1, + }) + tests := []struct { + name string + usage *types.TaskUsage + }{ + {name: "missing", usage: nil}, + {name: "partial", usage: &types.TaskUsage{Completeness: types.TaskUsageCompletenessPartial}}, + {name: "invalid", usage: &types.TaskUsage{Completeness: types.TaskUsageCompletenessInvalid}}, + {name: "ambiguous", usage: &types.TaskUsage{Completeness: types.TaskUsageCompletenessAmbiguous}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + task := makeServiceH3Task(t, plan.ReserveQuota, plan) + decision := prepareTaskTerminalBillingDecision( + context.Background(), nil, task, + &relaycommon.TaskInfo{Status: string(model.TaskStatusFailure), Usage: test.usage}, + constant.ChannelTypeMiniMax, + ) + require.True(t, decision.UsesPlan) + require.NotEmpty(t, decision.ManualReason) + require.NotNil(t, decision.Settlement) + require.Zero(t, decision.Settlement.FundingDelta) + require.EqualValues(t, task.Quota, decision.Settlement.TaskQuotaTarget) + require.Nil(t, decision.Settlement.Effect) + }) + } +} + +func TestH3TerminalDecisionRoutesChangedFrozenTotalsToManual(t *testing.T) { + plan := buildServiceH3Plan(t, task_billing_setting.H3BillingInput{ + Resolution: "768P", OutputDurationSeconds: 5, InputVideoCount: 1, + }) + plan.EstimateQuota++ + task := makeServiceH3Task(t, plan.ReserveQuota, plan) + usage := &types.TaskUsage{ + OutputDurationMs: h3UsageInt64(5_000), + InputVideoDurationMs: h3UsageInt64(5_000), + InputImageCount: h3UsageInt64(0), + Source: types.TaskUsageSourceProviderResponse, + Completeness: types.TaskUsageCompletenessComplete, + } + + decision := prepareTaskTerminalBillingDecision( + context.Background(), nil, task, + &relaycommon.TaskInfo{Status: string(model.TaskStatusSuccess), Usage: usage}, + constant.ChannelTypeMiniMax, + ) + + require.True(t, decision.UsesPlan) + require.Contains(t, decision.ManualReason, "frozen totals") + require.NotNil(t, decision.Settlement) + require.Zero(t, decision.Settlement.FundingDelta) +} + +func TestH3SubmissionProjectionIsDeferredUntilTerminalUsage(t *testing.T) { + truncate(t) + seedUser(t, 901, 1000) + seedChannel(t, 902) + plan := buildServiceH3Plan(t, task_billing_setting.H3BillingInput{ + Resolution: "768P", OutputDurationSeconds: 5, + }) + info := &relaycommon.RelayInfo{ + UserId: 901, + OriginModelName: constant.TaskModelMiniMaxH3, + UsingGroup: "default", + TaskBillingPlan: plan, + ChannelMeta: &relaycommon.ChannelMeta{ChannelId: 902}, + TaskRelayInfo: &relaycommon.TaskRelayInfo{}, + } + + require.Nil(t, BuildTaskSubmissionSettlementEffect(nil, info, plan.ReserveQuota)) + LogTaskConsumption(nil, info) + require.Zero(t, countLogs(t)) + var user model.User + require.NoError(t, model.DB.First(&user, 901).Error) + require.Zero(t, user.UsedQuota) + require.Zero(t, user.RequestCount) +} + +func TestUpdateVideoSingleTaskKeepsManualH3TaskPollable(t *testing.T) { + truncate(t) + plan := buildServiceH3Plan(t, task_billing_setting.H3BillingInput{ + Resolution: "768P", OutputDurationSeconds: 5, + }) + task := makeServiceH3Task(t, plan.ReserveQuota, plan) + task.Status = model.TaskStatusInProgress + task.UpdatedAt = time.Now().Unix() + persistTask(t, task) + ch := &model.Channel{Id: task.ChannelId, Type: constant.ChannelTypeMiniMax, Key: "test"} + adaptor := &h3TerminalPollingAdaptor{result: &relaycommon.TaskInfo{ + Status: string(model.TaskStatusFailure), Reason: "provider failed", + Usage: &types.TaskUsage{Completeness: types.TaskUsageCompletenessMissing}, + }} + + require.NoError(t, updateVideoSingleTask(context.Background(), adaptor, ch, task.GetUpstreamTaskID(), map[string]*model.Task{ + task.GetUpstreamTaskID(): task, + })) + + var stored model.Task + require.NoError(t, model.DB.First(&stored, task.ID).Error) + require.Equal(t, model.TaskStatus(model.TaskStatusInProgress), stored.Status) + require.Equal(t, plan.ReserveQuota, stored.Quota) + require.NotNil(t, stored.PrivateData.BillingContext.TaskUsage) + require.Equal(t, types.TaskUsageCompletenessMissing, stored.PrivateData.BillingContext.TaskUsage.Completeness) + var settlement model.BillingSettlement + require.NoError(t, model.DB.Where("operation_key = ?", model.BillingTaskFinalizeOperationKey(task.ID)).First(&settlement).Error) + require.Equal(t, model.BillingSettlementStatusManual, settlement.Status) + require.Zero(t, settlement.FundingDelta) + + firstUpdatedAt := stored.UpdatedAt + require.NoError(t, updateVideoSingleTask(context.Background(), adaptor, ch, stored.GetUpstreamTaskID(), map[string]*model.Task{ + stored.GetUpstreamTaskID(): &stored, + })) + require.NoError(t, model.DB.First(&stored, task.ID).Error) + require.Equal(t, firstUpdatedAt, stored.UpdatedAt) + var settlementCount int64 + require.NoError(t, model.DB.Model(&model.BillingSettlement{}). + Where("operation_key = ?", model.BillingTaskFinalizeOperationKey(task.ID)). + Count(&settlementCount).Error) + require.EqualValues(t, 1, settlementCount) +} + +func TestUpdateVideoSingleTaskLeavesBillingUntouchedOnTransientQueryError(t *testing.T) { + truncate(t) + seedUser(t, 901, 900) + seedToken(t, 903, 901, "h3-transient-query", 900) + seedChannel(t, 902) + plan := buildServiceH3Plan(t, task_billing_setting.H3BillingInput{ + Resolution: "768P", OutputDurationSeconds: 5, + }) + task := makeServiceH3Task(t, plan.ReserveQuota, plan) + task.Status = model.TaskStatusInProgress + persistTask(t, task) + + var before model.Task + require.NoError(t, model.DB.First(&before, task.ID).Error) + adaptor := &h3TerminalPollingAdaptor{parseErr: errors.New("MiniMax temporary query error: retry later")} + ch := &model.Channel{Id: task.ChannelId, Type: constant.ChannelTypeMiniMax, Key: "test"} + + err := updateVideoSingleTask(context.Background(), adaptor, ch, task.GetUpstreamTaskID(), map[string]*model.Task{ + task.GetUpstreamTaskID(): task, + }) + require.ErrorContains(t, err, "temporary query error") + + var stored model.Task + require.NoError(t, model.DB.First(&stored, task.ID).Error) + require.Equal(t, before.Status, stored.Status) + require.Equal(t, before.Progress, stored.Progress) + require.Equal(t, before.Quota, stored.Quota) + require.Equal(t, before.UpdatedAt, stored.UpdatedAt) + require.EqualValues(t, 900, getUserQuota(t, task.UserId)) + require.Equal(t, 900, getTokenRemainQuota(t, task.PrivateData.TokenId)) + require.Zero(t, countLogs(t)) + + var settlementCount int64 + require.NoError(t, model.DB.Model(&model.BillingSettlement{}). + Where("operation_key IN ?", []string{ + model.BillingTaskFinalizeOperationKey(task.ID), + fmt.Sprintf("task:%d:refund", task.ID), + }).Count(&settlementCount).Error) + require.Zero(t, settlementCount) +} + +func TestUpdateVideoSingleTaskRejectsNilParsedResultWithoutBillingMutation(t *testing.T) { + truncate(t) + seedUser(t, 901, 900) + seedToken(t, 903, 901, "h3-nil-query-result", 900) + seedChannel(t, 902) + plan := buildServiceH3Plan(t, task_billing_setting.H3BillingInput{ + Resolution: "768P", OutputDurationSeconds: 5, + }) + task := makeServiceH3Task(t, plan.ReserveQuota, plan) + task.Status = model.TaskStatusInProgress + persistTask(t, task) + + var before model.Task + require.NoError(t, model.DB.First(&before, task.ID).Error) + adaptor := &h3TerminalPollingAdaptor{} + ch := &model.Channel{Id: task.ChannelId, Type: constant.ChannelTypeMiniMax, Key: "test"} + + err := updateVideoSingleTask(context.Background(), adaptor, ch, task.GetUpstreamTaskID(), map[string]*model.Task{ + task.GetUpstreamTaskID(): task, + }) + require.ErrorContains(t, err, "parseTaskResult returned no result") + + var stored model.Task + require.NoError(t, model.DB.First(&stored, task.ID).Error) + require.Equal(t, before.Status, stored.Status) + require.Equal(t, before.Progress, stored.Progress) + require.Equal(t, before.Quota, stored.Quota) + require.Equal(t, before.UpdatedAt, stored.UpdatedAt) + require.EqualValues(t, 900, getUserQuota(t, task.UserId)) + require.Equal(t, 900, getTokenRemainQuota(t, task.PrivateData.TokenId)) + require.Zero(t, countLogs(t)) + + var settlementCount int64 + require.NoError(t, model.DB.Model(&model.BillingSettlement{}). + Where("operation_key IN ?", []string{ + model.BillingTaskFinalizeOperationKey(task.ID), + fmt.Sprintf("task:%d:refund", task.ID), + }).Count(&settlementCount).Error) + require.Zero(t, settlementCount) +} + +func TestUpdateVideoSingleTaskRecoversManualH3WhenLaterPollHasCompleteUsage(t *testing.T) { + truncate(t) + seedUser(t, 901, 900) + seedToken(t, 903, 901, "h3-manual-recovery", 900) + seedChannel(t, 902) + plan := buildServiceH3Plan(t, task_billing_setting.H3BillingInput{ + Resolution: "768P", OutputDurationSeconds: 5, InputImageCount: 1, + }) + task := makeServiceH3Task(t, plan.ReserveQuota, plan) + task.Status = model.TaskStatusInProgress + persistTask(t, task) + missingDecision := prepareTaskTerminalBillingDecision(context.Background(), nil, task, &relaycommon.TaskInfo{ + Status: string(model.TaskStatusSuccess), + Usage: &types.TaskUsage{Completeness: types.TaskUsageCompletenessMissing}, + }, constant.ChannelTypeMiniMax) + require.NotEmpty(t, missingDecision.ManualReason) + won, err := persistTaskManualBillingDecision(task, task.Status, task.UpdatedAt, missingDecision) + require.NoError(t, err) + require.True(t, won) + + completeUsage := &types.TaskUsage{ + OutputDurationMs: h3UsageInt64(5_000), + InputImageCount: h3UsageInt64(1), + Source: types.TaskUsageSourceProviderResponse, + Completeness: types.TaskUsageCompletenessComplete, + } + finalQuote, err := task_billing_setting.QuoteH3Final(plan, completeUsage) + require.NoError(t, err) + adaptor := &h3TerminalPollingAdaptor{result: &relaycommon.TaskInfo{ + Status: string(model.TaskStatusSuccess), + Usage: completeUsage, + }} + ch := &model.Channel{Id: task.ChannelId, Type: constant.ChannelTypeMiniMax, Key: "test"} + + require.NoError(t, updateVideoSingleTask(context.Background(), adaptor, ch, task.GetUpstreamTaskID(), map[string]*model.Task{ + task.GetUpstreamTaskID(): task, + })) + + var stored model.Task + require.NoError(t, model.DB.First(&stored, task.ID).Error) + require.Equal(t, model.TaskStatus(model.TaskStatusSuccess), stored.Status) + require.EqualValues(t, finalQuote.Quota, stored.Quota) + var settlement model.BillingSettlement + require.NoError(t, model.DB.Where("operation_key = ?", model.BillingTaskFinalizeOperationKey(task.ID)).First(&settlement).Error) + require.Equal(t, model.BillingSettlementStatusApplied, settlement.Status) + require.EqualValues(t, finalQuote.Quota-plan.ReserveQuota, settlement.FundingDelta) + require.Contains(t, settlement.ReconciliationReviewNote, "original manual reason") + require.Contains(t, settlement.ReconciliationReviewNote, "H3 terminal usage requires manual reconciliation") + require.EqualValues(t, 1, countLogs(t)) + // A repeated provider poll must replay the same settlement without a + // second balance mutation or receipt. + require.NoError(t, updateVideoSingleTask(context.Background(), adaptor, ch, stored.GetUpstreamTaskID(), map[string]*model.Task{ + stored.GetUpstreamTaskID(): &stored, + })) + require.EqualValues(t, 1, countLogs(t)) + require.Equal(t, model.TaskStatus(model.TaskStatusSuccess), stored.Status) +} + +func TestRecoverManualH3PersistsTerminalEvidenceBeforeFunding(t *testing.T) { + truncate(t) + seedUser(t, 901, 900) + seedChannel(t, 902) + plan := buildServiceH3Plan(t, task_billing_setting.H3BillingInput{ + Resolution: "768P", OutputDurationSeconds: 5, InputImageCount: 1, + }) + task := makeServiceH3Task(t, plan.ReserveQuota, plan) + persistTask(t, task) + + missingDecision := prepareTaskTerminalBillingDecision(context.Background(), nil, task, &relaycommon.TaskInfo{ + Status: string(model.TaskStatusSuccess), + Usage: &types.TaskUsage{Completeness: types.TaskUsageCompletenessMissing}, + }, constant.ChannelTypeMiniMax) + require.NotEmpty(t, missingDecision.ManualReason) + won, err := persistTaskManualBillingDecision(task, task.Status, task.UpdatedAt, missingDecision) + require.NoError(t, err) + require.True(t, won) + + completeUsage := &types.TaskUsage{ + OutputDurationMs: h3UsageInt64(5_000), + InputImageCount: h3UsageInt64(1), + Source: types.TaskUsageSourceProviderResponse, + Completeness: types.TaskUsageCompletenessComplete, + } + decision := prepareTaskTerminalBillingDecision(context.Background(), nil, task, &relaycommon.TaskInfo{ + Status: string(model.TaskStatusSuccess), Usage: completeUsage, + }, constant.ChannelTypeMiniMax) + require.Empty(t, decision.ManualReason) + providerResult := &relaycommon.TaskInfo{ + Status: string(model.TaskStatusSuccess), + Url: "https://cdn.example.com/manual-recovered.mp4", + Usage: completeUsage, + } + expectedUpdatedAt := task.UpdatedAt + recovered, err := recoverManualTaskBillingSettlement( + context.Background(), task, decision, providerResult, model.TaskStatusInProgress, expectedUpdatedAt, + ) + require.NoError(t, err) + require.True(t, recovered) + + var stored model.Task + require.NoError(t, model.DB.First(&stored, task.ID).Error) + require.Equal(t, model.TaskStatus(model.TaskStatusInProgress), stored.Status) + require.Equal(t, model.TaskStatus(model.TaskStatusSuccess), stored.PrivateData.PendingTerminalStatus) + require.Equal(t, "https://cdn.example.com/manual-recovered.mp4", stored.PrivateData.PendingTerminalResultURL) + require.NotNil(t, stored.PrivateData.BillingContext.TaskUsage) + require.Equal(t, types.TaskUsageCompletenessComplete, stored.PrivateData.BillingContext.TaskUsage.Completeness) + + var settlement model.BillingSettlement + require.NoError(t, model.DB.Where("operation_key = ?", model.BillingTaskFinalizeOperationKey(task.ID)).First(&settlement).Error) + require.Equal(t, model.BillingSettlementStatusApplied, settlement.Status) + require.EqualValues(t, decision.Settlement.TaskQuotaTarget, stored.Quota) + +} + +func TestUpdateVideoSingleTaskAppliesAlreadyPromotedManualH3Settlement(t *testing.T) { + truncate(t) + seedUser(t, 901, 900) + seedToken(t, 903, 901, "h3-promoted-recovery", 900) + seedChannel(t, 902) + plan := buildServiceH3Plan(t, task_billing_setting.H3BillingInput{ + Resolution: "768P", OutputDurationSeconds: 5, InputImageCount: 1, + }) + task := makeServiceH3Task(t, plan.ReserveQuota, plan) + task.Status = model.TaskStatusInProgress + persistTask(t, task) + + missingDecision := prepareTaskTerminalBillingDecision(context.Background(), nil, task, &relaycommon.TaskInfo{ + Status: string(model.TaskStatusSuccess), + Usage: &types.TaskUsage{Completeness: types.TaskUsageCompletenessMissing}, + }, constant.ChannelTypeMiniMax) + require.NotEmpty(t, missingDecision.ManualReason) + won, err := persistTaskManualBillingDecision(task, task.Status, task.UpdatedAt, missingDecision) + require.NoError(t, err) + require.True(t, won) + + completeUsage := &types.TaskUsage{ + OutputDurationMs: h3UsageInt64(5_000), + InputImageCount: h3UsageInt64(1), + Source: types.TaskUsageSourceProviderResponse, + Completeness: types.TaskUsageCompletenessComplete, + } + providerResult := &relaycommon.TaskInfo{ + Status: string(model.TaskStatusSuccess), Url: "https://cdn.example.com/promoted-recovery.mp4", Usage: completeUsage, + } + decision := prepareTaskTerminalBillingDecision( + context.Background(), nil, task, providerResult, constant.ChannelTypeMiniMax, + ) + require.Empty(t, decision.ManualReason) + require.NotNil(t, decision.Settlement) + + candidate := *task + billingContext := *task.PrivateData.BillingContext + candidate.PrivateData.BillingContext = &billingContext + candidate.PrivateData.BillingContext.TaskUsage = types.CloneTaskUsage(completeUsage) + persistPendingTaskTerminalEvidence(&candidate, providerResult, time.Now().Unix()) + won, err = candidate.UpdateWithStatusAndPendingTerminalEvidence(task.Status, task.UpdatedAt) + require.NoError(t, err) + require.True(t, won) + *task = candidate + + ready, err := model.PromoteManualTaskBillingSettlement( + *decision.Settlement, + "H3 terminal usage requires manual reconciliation:", + "H3 task settlement automatically recovered after a complete provider usage response", + ) + require.NoError(t, err) + require.True(t, ready) + status, found, err := model.GetBillingSettlementStatus(model.BillingTaskFinalizeOperationKey(task.ID)) + require.NoError(t, err) + require.True(t, found) + require.Equal(t, model.BillingSettlementStatusPending, status) + + beforeUserQuota := getUserQuota(t, task.UserId) + beforeTokenQuota := getTokenRemainQuota(t, task.PrivateData.TokenId) + adaptor := &h3TerminalPollingAdaptor{result: providerResult} + ch := &model.Channel{Id: task.ChannelId, Type: constant.ChannelTypeMiniMax, Key: "test"} + require.NoError(t, updateVideoSingleTask(context.Background(), adaptor, ch, task.GetUpstreamTaskID(), map[string]*model.Task{ + task.GetUpstreamTaskID(): task, + })) + + var stored model.Task + require.NoError(t, model.DB.First(&stored, task.ID).Error) + require.Equal(t, model.TaskStatus(model.TaskStatusSuccess), stored.Status) + require.EqualValues(t, decision.Settlement.TaskQuotaTarget, stored.Quota) + require.EqualValues(t, beforeUserQuota-decision.Settlement.FundingDelta, getUserQuota(t, task.UserId)) + require.EqualValues(t, beforeTokenQuota-int(decision.Settlement.TokenDelta), getTokenRemainQuota(t, task.PrivateData.TokenId)) + require.EqualValues(t, 1, countLogs(t)) + var logOther map[string]interface{} + require.NoError(t, common.UnmarshalJsonStr(getLastLog(t).Other, &logOther)) + planMetadata, ok := logOther["task_billing_plan"].(map[string]interface{}) + require.True(t, ok) + require.NotContains(t, planMetadata, "config_hash") + require.NotContains(t, planMetadata, "group_ratio") + require.NotContains(t, planMetadata, "quota_per_unit") + require.NotContains(t, planMetadata, "components") + usageMetadata, ok := logOther["task_usage"].(map[string]interface{}) + require.True(t, ok) + require.EqualValues(t, 5_000, usageMetadata["output_duration_ms"]) + require.EqualValues(t, 1, usageMetadata["input_image_count"]) + require.Equal(t, types.TaskUsageCompletenessComplete, usageMetadata["completeness"]) + var settlement model.BillingSettlement + require.NoError(t, model.DB.Where("operation_key = ?", model.BillingTaskFinalizeOperationKey(task.ID)).First(&settlement).Error) + require.Equal(t, model.BillingSettlementStatusApplied, settlement.Status) + + userQuotaAfterFirstPoll := getUserQuota(t, task.UserId) + tokenQuotaAfterFirstPoll := getTokenRemainQuota(t, task.PrivateData.TokenId) + require.NoError(t, updateVideoSingleTask(context.Background(), adaptor, ch, stored.GetUpstreamTaskID(), map[string]*model.Task{ + stored.GetUpstreamTaskID(): &stored, + })) + require.EqualValues(t, userQuotaAfterFirstPoll, getUserQuota(t, task.UserId)) + require.EqualValues(t, tokenQuotaAfterFirstPoll, getTokenRemainQuota(t, task.PrivateData.TokenId)) + require.EqualValues(t, 1, countLogs(t)) +} + +func TestUpdateVideoSingleTaskRecoversManualH3SubscriptionFullRefund(t *testing.T) { + truncate(t) + const userID, tokenID, channelID, subscriptionID = 905, 906, 907, 908 + seedUser(t, userID, 0) + seedToken(t, tokenID, userID, "h3-manual-subscription-recovery", 100) + seedChannel(t, channelID) + seedSubscription(t, subscriptionID, userID, 10_000, 5_000) + plan := buildServiceH3Plan(t, task_billing_setting.H3BillingInput{Resolution: "768P", OutputDurationSeconds: 5}) + for i := range plan.Components { + plan.Components[i].UnitPrice = "0" + } + plan.EstimateQuota = 0 + plan.ReserveQuota = 0 + task := makeServiceH3Task(t, 100, plan) + task.UserId = userID + task.ChannelId = channelID + task.PrivateData.TokenId = tokenID + task.PrivateData.BillingSource = BillingSourceSubscription + task.PrivateData.SubscriptionId = subscriptionID + task.PrivateData.BillingRequestId = "" + task.Status = model.TaskStatusInProgress + persistTask(t, task) + + missingDecision := prepareTaskTerminalBillingDecision(context.Background(), nil, task, &relaycommon.TaskInfo{ + Status: string(model.TaskStatusSuccess), Usage: &types.TaskUsage{Completeness: types.TaskUsageCompletenessMissing}, + }, constant.ChannelTypeMiniMax) + require.NotEmpty(t, missingDecision.ManualReason) + won, err := persistTaskManualBillingDecision(task, task.Status, task.UpdatedAt, missingDecision) + require.NoError(t, err) + require.True(t, won) + + completeUsage := &types.TaskUsage{ + OutputDurationMs: h3UsageInt64(5_000), + InputImageCount: h3UsageInt64(0), + Source: types.TaskUsageSourceProviderResponse, + Completeness: types.TaskUsageCompletenessComplete, + } + decision := prepareTaskTerminalBillingDecision(context.Background(), nil, task, &relaycommon.TaskInfo{ + Status: string(model.TaskStatusSuccess), Usage: completeUsage, + }, constant.ChannelTypeMiniMax) + require.Empty(t, decision.ManualReason) + require.NotNil(t, decision.Settlement) + require.EqualValues(t, -100, decision.Settlement.FundingDelta) + require.True(t, decision.Settlement.FinalizeSubscriptionPreConsume) + require.True(t, decision.Settlement.AllowMissingToken) + + adaptor := &h3TerminalPollingAdaptor{result: &relaycommon.TaskInfo{ + Status: string(model.TaskStatusSuccess), Usage: completeUsage, + }} + ch := &model.Channel{Id: channelID, Type: constant.ChannelTypeMiniMax, Key: "test"} + require.NoError(t, updateVideoSingleTask(context.Background(), adaptor, ch, task.GetUpstreamTaskID(), map[string]*model.Task{ + task.GetUpstreamTaskID(): task, + })) + + var stored model.Task + require.NoError(t, model.DB.First(&stored, task.ID).Error) + require.Equal(t, model.TaskStatus(model.TaskStatusSuccess), stored.Status) + require.Zero(t, stored.Quota) + require.EqualValues(t, 4_900, getSubscriptionUsed(t, subscriptionID)) + require.EqualValues(t, 200, getTokenRemainQuota(t, tokenID)) + require.EqualValues(t, 0, getTokenUsedQuota(t, tokenID)) + require.EqualValues(t, 1, countLogs(t)) + var preConsume model.SubscriptionPreConsumeRecord + require.NoError(t, model.DB.Where("request_id = ?", stored.PrivateData.BillingRequestId).First(&preConsume).Error) + require.Equal(t, "refunded", preConsume.Status) + + require.NoError(t, updateVideoSingleTask(context.Background(), adaptor, ch, stored.GetUpstreamTaskID(), map[string]*model.Task{ + stored.GetUpstreamTaskID(): &stored, + })) + require.EqualValues(t, 4_900, getSubscriptionUsed(t, subscriptionID)) + require.EqualValues(t, 200, getTokenRemainQuota(t, tokenID)) + require.EqualValues(t, 1, countLogs(t)) +} + +func TestUpdateVideoSingleTaskDoesNotRecoverManualH3BeforeSubmissionSettlement(t *testing.T) { + truncate(t) + seedUser(t, 901, 900) + seedToken(t, 903, 901, "h3-manual-submission-pending", 900) + seedChannel(t, 902) + plan := buildServiceH3Plan(t, task_billing_setting.H3BillingInput{Resolution: "768P", OutputDurationSeconds: 5, InputImageCount: 1}) + task := makeServiceH3Task(t, plan.ReserveQuota, plan) + persistTask(t, task) + requestKey := model.BillingRequestFinalizeOperationKey(task.PrivateData.BillingRequestId) + require.NoError(t, model.DB.Create(&model.BillingSettlement{ + OperationKey: requestKey, Source: model.BillingSettlementSourceWallet, UserID: task.UserId, + Status: model.BillingSettlementStatusPending, CreatedAt: time.Now().Unix(), UpdatedAt: time.Now().Unix(), Revision: 1, + }).Error) + missingDecision := prepareTaskTerminalBillingDecision(context.Background(), nil, task, &relaycommon.TaskInfo{ + Status: string(model.TaskStatusSuccess), Usage: &types.TaskUsage{Completeness: types.TaskUsageCompletenessMissing}, + }, constant.ChannelTypeMiniMax) + _, err := persistTaskManualBillingDecision(task, task.Status, task.UpdatedAt, missingDecision) + require.NoError(t, err) + + completeUsage := &types.TaskUsage{OutputDurationMs: h3UsageInt64(5_000), InputImageCount: h3UsageInt64(1), Source: types.TaskUsageSourceProviderResponse, Completeness: types.TaskUsageCompletenessComplete} + adaptor := &h3TerminalPollingAdaptor{result: &relaycommon.TaskInfo{Status: string(model.TaskStatusSuccess), Usage: completeUsage}} + ch := &model.Channel{Id: task.ChannelId, Type: constant.ChannelTypeMiniMax, Key: "test"} + require.NoError(t, updateVideoSingleTask(context.Background(), adaptor, ch, task.GetUpstreamTaskID(), map[string]*model.Task{task.GetUpstreamTaskID(): task})) + + var stored model.Task + require.NoError(t, model.DB.First(&stored, task.ID).Error) + require.Equal(t, model.TaskStatus(model.TaskStatusInProgress), stored.Status) + var finalSettlement model.BillingSettlement + require.NoError(t, model.DB.Where("operation_key = ?", model.BillingTaskFinalizeOperationKey(task.ID)).First(&finalSettlement).Error) + require.Equal(t, model.BillingSettlementStatusManual, finalSettlement.Status) +} + +func TestUpdateVideoSingleTaskKeepsH3TaskPollableWhenSubscriptionPeriodChanged(t *testing.T) { + truncate(t) + const userID, tokenID, channelID, subscriptionID = 911, 912, 913, 914 + seedUser(t, userID, 0) + seedToken(t, tokenID, userID, "h3-period-change", 5_000) + seedChannel(t, channelID) + seedSubscription(t, subscriptionID, userID, 10_000, 5_000) + plan := buildServiceH3Plan(t, task_billing_setting.H3BillingInput{ + Resolution: "768P", OutputDurationSeconds: 5, InputVideoCount: 1, + }) + task := makeServiceH3Task(t, plan.ReserveQuota, plan) + task.UserId = userID + task.ChannelId = channelID + task.PrivateData.TokenId = tokenID + task.PrivateData.BillingSource = BillingSourceSubscription + task.PrivateData.SubscriptionId = subscriptionID + task.PrivateData.BillingRequestId = "" + task.Status = model.TaskStatusInProgress + persistTask(t, task) + require.NoError(t, model.DB.Model(&model.UserSubscription{}). + Where("id = ?", subscriptionID). + UpdateColumn("last_reset_time", 99).Error) + + usage := &types.TaskUsage{ + OutputDurationMs: h3UsageInt64(5_000), + InputVideoDurationMs: h3UsageInt64(5_000), + InputImageCount: h3UsageInt64(0), + Source: types.TaskUsageSourceProviderResponse, + Completeness: types.TaskUsageCompletenessComplete, + } + ch := &model.Channel{Id: channelID, Type: constant.ChannelTypeMiniMax, Key: "test"} + adaptor := &h3TerminalPollingAdaptor{result: &relaycommon.TaskInfo{ + Status: string(model.TaskStatusSuccess), Usage: usage, + }} + + require.NoError(t, updateVideoSingleTask(context.Background(), adaptor, ch, task.GetUpstreamTaskID(), map[string]*model.Task{ + task.GetUpstreamTaskID(): task, + })) + + var stored model.Task + require.NoError(t, model.DB.First(&stored, task.ID).Error) + require.Equal(t, model.TaskStatus(model.TaskStatusInProgress), stored.Status) + require.Equal(t, plan.ReserveQuota, stored.Quota) + require.NotNil(t, stored.PrivateData.BillingContext.TaskUsage) + var settlement model.BillingSettlement + require.NoError(t, model.DB.Where("operation_key = ?", model.BillingTaskFinalizeOperationKey(task.ID)).First(&settlement).Error) + require.Equal(t, model.BillingSettlementStatusManual, settlement.Status) + require.ErrorContains(t, fmt.Errorf("%s", settlement.LastError), "crossed a quota reset period") + require.Zero(t, countLogs(t)) +} + +func TestUpdateVideoSingleTaskSettlesH3BeforeTerminalTransition(t *testing.T) { + truncate(t) + const userID, tokenID, channelID = 951, 952, 953 + seedUser(t, userID, 900) + seedToken(t, tokenID, userID, "h3-terminal-order", 900) + seedChannel(t, channelID) + plan := buildServiceH3Plan(t, task_billing_setting.H3BillingInput{ + Resolution: "768P", OutputDurationSeconds: 5, InputVideoCount: 1, + }) + task := makeServiceH3Task(t, plan.ReserveQuota, plan) + task.UserId = userID + task.ChannelId = channelID + task.PrivateData.TokenId = tokenID + task.Status = model.TaskStatusInProgress + persistTask(t, task) + usage := &types.TaskUsage{ + OutputDurationMs: h3UsageInt64(5_000), InputVideoDurationMs: h3UsageInt64(5_000), + InputImageCount: h3UsageInt64(0), Source: types.TaskUsageSourceProviderResponse, + Completeness: types.TaskUsageCompletenessComplete, + } + ch := &model.Channel{Id: channelID, Type: constant.ChannelTypeMiniMax, Key: "test"} + adaptor := &h3TerminalPollingAdaptor{result: &relaycommon.TaskInfo{ + Status: string(model.TaskStatusSuccess), Usage: usage, + }} + + require.NoError(t, updateVideoSingleTask(context.Background(), adaptor, ch, task.GetUpstreamTaskID(), map[string]*model.Task{ + task.GetUpstreamTaskID(): task, + })) + + var stored model.Task + require.NoError(t, model.DB.First(&stored, task.ID).Error) + require.Equal(t, model.TaskStatus(model.TaskStatusSuccess), stored.Status) + require.EqualValues(t, 800, stored.Quota) + require.EqualValues(t, 1_700, getUserQuota(t, userID)) + require.Equal(t, 1_700, getTokenRemainQuota(t, tokenID)) + var user model.User + require.NoError(t, model.DB.First(&user, userID).Error) + require.EqualValues(t, 800, user.UsedQuota) + require.EqualValues(t, 1, user.RequestCount) + require.EqualValues(t, 1, countLogs(t)) + var settlement model.BillingSettlement + require.NoError(t, model.DB.Where("operation_key = ?", model.BillingTaskFinalizeOperationKey(task.ID)).First(&settlement).Error) + require.Equal(t, model.BillingSettlementStatusApplied, settlement.Status) + require.Equal(t, model.BillingSettlementEffectApplied, settlement.EffectStatus) +} + +func TestUpdateVideoSingleTaskRecoversAppliedH3FundingWithPendingEffect(t *testing.T) { + truncate(t) + const userID, tokenID, channelID = 961, 962, 963 + seedUser(t, userID, 900) + seedToken(t, tokenID, userID, "h3-effect-recovery", 900) + seedChannel(t, channelID) + plan := buildServiceH3Plan(t, task_billing_setting.H3BillingInput{ + Resolution: "768P", OutputDurationSeconds: 5, InputVideoCount: 1, + }) + task := makeServiceH3Task(t, plan.ReserveQuota, plan) + task.UserId = userID + task.ChannelId = channelID + task.PrivateData.TokenId = tokenID + task.Status = model.TaskStatusInProgress + persistTask(t, task) + usage := &types.TaskUsage{ + OutputDurationMs: h3UsageInt64(5_000), InputVideoDurationMs: h3UsageInt64(5_000), + InputImageCount: h3UsageInt64(0), Source: types.TaskUsageSourceProviderResponse, + Completeness: types.TaskUsageCompletenessComplete, + } + decision := prepareTaskTerminalBillingDecision(context.Background(), nil, task, &relaycommon.TaskInfo{ + Status: string(model.TaskStatusSuccess), Usage: usage, + }, constant.ChannelTypeMiniMax) + require.NotNil(t, decision.Settlement) + task.PrivateData.BillingContext.TaskUsage = types.CloneTaskUsage(usage) + won, err := task.UpdateWithStatusAndSettlementIntent(task.Status, task.UpdatedAt, *decision.Settlement) + require.NoError(t, err) + require.True(t, won) + _, _, err = model.ApplyBillingSettlementOnce(*decision.Settlement) + require.NoError(t, err) + + var stored model.Task + require.NoError(t, model.DB.First(&stored, task.ID).Error) + require.Equal(t, model.TaskStatus(model.TaskStatusInProgress), stored.Status) + require.EqualValues(t, 800, stored.Quota) + require.Zero(t, countLogs(t)) + var user model.User + require.NoError(t, model.DB.First(&user, userID).Error) + require.Zero(t, user.UsedQuota) + require.Zero(t, user.RequestCount) + var settlement model.BillingSettlement + require.NoError(t, model.DB.Where("operation_key = ?", model.BillingTaskFinalizeOperationKey(task.ID)).First(&settlement).Error) + require.Equal(t, model.BillingSettlementStatusApplied, settlement.Status) + require.Equal(t, model.BillingSettlementEffectPending, settlement.EffectStatus) + + ch := &model.Channel{Id: channelID, Type: constant.ChannelTypeMiniMax, Key: "test"} + adaptor := &h3TerminalPollingAdaptor{result: &relaycommon.TaskInfo{ + Status: string(model.TaskStatusSuccess), Usage: usage, + }} + require.NoError(t, updateVideoSingleTask(context.Background(), adaptor, ch, stored.GetUpstreamTaskID(), map[string]*model.Task{ + stored.GetUpstreamTaskID(): &stored, + })) + require.NoError(t, model.DB.First(&stored, task.ID).Error) + require.Equal(t, model.TaskStatus(model.TaskStatusSuccess), stored.Status) + require.EqualValues(t, 800, stored.Quota) + require.Zero(t, countLogs(t)) + + require.NoError(t, model.ProcessBillingSettlementEffect(decision.Settlement.OperationKey)) + require.NoError(t, model.ProcessBillingSettlementEffect(decision.Settlement.OperationKey)) + require.EqualValues(t, 1, countLogs(t)) + require.NoError(t, model.DB.First(&user, userID).Error) + require.EqualValues(t, 800, user.UsedQuota) + require.EqualValues(t, 1, user.RequestCount) + require.EqualValues(t, 1_700, getUserQuota(t, userID)) + require.Equal(t, 1_700, getTokenRemainQuota(t, tokenID)) +} + +func TestH3ZeroFinalSettlementRefundsAndProjectsReceiptExactlyOnce(t *testing.T) { + truncate(t) + seedUser(t, 901, 900) + seedToken(t, 903, 901, "h3-zero-final", 900) + seedChannel(t, 902) + plan := buildServiceH3Plan(t, task_billing_setting.H3BillingInput{ + Resolution: "768P", OutputDurationSeconds: 5, + }) + for i := range plan.Components { + plan.Components[i].UnitPrice = "0" + } + plan.EstimateQuota = 0 + plan.ReserveQuota = 0 + task := makeServiceH3Task(t, 100, plan) + task.Status = model.TaskStatusSuccess + persistTask(t, task) + usage := &types.TaskUsage{ + OutputDurationMs: h3UsageInt64(5_000), InputImageCount: h3UsageInt64(0), + Source: types.TaskUsageSourceProviderResponse, Completeness: types.TaskUsageCompletenessComplete, + } + decision := prepareTaskTerminalBillingDecision(context.Background(), nil, task, &relaycommon.TaskInfo{ + Status: string(model.TaskStatusSuccess), Usage: usage, + }, constant.ChannelTypeMiniMax) + require.NotNil(t, decision.Settlement) + + require.True(t, ApplyTaskBillingSettlement(context.Background(), task, decision.Settlement)) + require.True(t, ApplyTaskBillingSettlement(context.Background(), task, decision.Settlement)) + require.EqualValues(t, 1000, getUserQuota(t, 901)) + require.EqualValues(t, 1000, getTokenRemainQuota(t, 903)) + require.Zero(t, task.Quota) + var user model.User + require.NoError(t, model.DB.First(&user, 901).Error) + require.Zero(t, user.UsedQuota) + require.EqualValues(t, 1, user.RequestCount) + require.EqualValues(t, 1, countLogs(t)) + log := getLastLog(t) + require.NotNil(t, log) + require.Equal(t, model.LogTypeConsume, log.Type) + require.Zero(t, log.Quota) +} + +func TestH3SettlementRefundsWalletWhenTokenWasDeleted(t *testing.T) { + truncate(t) + const userID, tokenID, channelID = 921, 922, 923 + seedUser(t, userID, 900) + seedToken(t, tokenID, userID, "h3-deleted-token", 900) + seedChannel(t, channelID) + plan := buildServiceH3Plan(t, task_billing_setting.H3BillingInput{ + Resolution: "768P", OutputDurationSeconds: 5, InputVideoCount: 1, + }) + task := makeServiceH3Task(t, plan.ReserveQuota, plan) + task.UserId = userID + task.ChannelId = channelID + task.PrivateData.TokenId = tokenID + task.Status = model.TaskStatusSuccess + persistTask(t, task) + require.NoError(t, model.DB.Delete(&model.Token{}, tokenID).Error) + usage := &types.TaskUsage{ + OutputDurationMs: h3UsageInt64(5_000), InputVideoDurationMs: h3UsageInt64(5_000), + InputImageCount: h3UsageInt64(0), Source: types.TaskUsageSourceProviderResponse, + Completeness: types.TaskUsageCompletenessComplete, + } + decision := prepareTaskTerminalBillingDecision(context.Background(), nil, task, &relaycommon.TaskInfo{ + Status: string(model.TaskStatusSuccess), Usage: usage, + }, constant.ChannelTypeMiniMax) + require.NotNil(t, decision.Settlement) + + require.True(t, ApplyTaskBillingSettlement(context.Background(), task, decision.Settlement)) + require.True(t, ApplyTaskBillingSettlement(context.Background(), task, decision.Settlement)) + require.EqualValues(t, 1_700, getUserQuota(t, userID)) + require.EqualValues(t, 800, task.Quota) + require.EqualValues(t, 1, countLogs(t)) +} + +func TestH3SettlementRefundsUnlimitedTokenExactlyOnce(t *testing.T) { + truncate(t) + const userID, tokenID, channelID = 931, 932, 933 + seedUser(t, userID, 900) + seedToken(t, tokenID, userID, "h3-unlimited-token", 900) + require.NoError(t, model.DB.Model(&model.Token{}).Where("id = ?", tokenID).Update("unlimited_quota", true).Error) + seedChannel(t, channelID) + plan := buildServiceH3Plan(t, task_billing_setting.H3BillingInput{ + Resolution: "768P", OutputDurationSeconds: 5, InputVideoCount: 1, + }) + task := makeServiceH3Task(t, plan.ReserveQuota, plan) + task.UserId = userID + task.ChannelId = channelID + task.PrivateData.TokenId = tokenID + task.Status = model.TaskStatusSuccess + persistTask(t, task) + usage := &types.TaskUsage{ + OutputDurationMs: h3UsageInt64(5_000), InputVideoDurationMs: h3UsageInt64(5_000), + InputImageCount: h3UsageInt64(0), Source: types.TaskUsageSourceProviderResponse, + Completeness: types.TaskUsageCompletenessComplete, + } + decision := prepareTaskTerminalBillingDecision(context.Background(), nil, task, &relaycommon.TaskInfo{ + Status: string(model.TaskStatusSuccess), Usage: usage, + }, constant.ChannelTypeMiniMax) + require.NotNil(t, decision.Settlement) + + require.True(t, ApplyTaskBillingSettlement(context.Background(), task, decision.Settlement)) + require.True(t, ApplyTaskBillingSettlement(context.Background(), task, decision.Settlement)) + require.EqualValues(t, 1_700, getUserQuota(t, userID)) + require.Equal(t, 1_700, getTokenRemainQuota(t, tokenID)) + require.Equal(t, 100, getTokenUsedQuota(t, tokenID)) + require.EqualValues(t, 1, countLogs(t)) +} + +func TestH3SubscriptionZeroDeltaFinalizesWithoutTouchingSubscriptionPeriod(t *testing.T) { + truncate(t) + const userID, tokenID, channelID, subscriptionID = 941, 942, 943, 944 + seedUser(t, userID, 0) + seedToken(t, tokenID, userID, "h3-subscription-zero-delta", 5_000) + seedChannel(t, channelID) + seedSubscription(t, subscriptionID, userID, 10_000, 5_000) + plan := buildServiceH3Plan(t, task_billing_setting.H3BillingInput{ + Resolution: "768P", OutputDurationSeconds: 5, InputVideoCount: 1, + }) + task := makeServiceH3Task(t, plan.ReserveQuota, plan) + task.UserId = userID + task.ChannelId = channelID + task.PrivateData.TokenId = tokenID + task.PrivateData.BillingSource = BillingSourceSubscription + task.PrivateData.SubscriptionId = subscriptionID + task.PrivateData.BillingRequestId = "" + task.Status = model.TaskStatusSuccess + persistTask(t, task) + const stableUpdatedAt int64 = 123 + require.NoError(t, model.DB.Model(&model.UserSubscription{}). + Where("id = ?", subscriptionID). + UpdateColumn("updated_at", stableUpdatedAt).Error) + usage := &types.TaskUsage{ + OutputDurationMs: h3UsageInt64(5_000), InputVideoDurationMs: h3UsageInt64(15_000), + InputImageCount: h3UsageInt64(0), Source: types.TaskUsageSourceProviderResponse, + Completeness: types.TaskUsageCompletenessComplete, + } + decision := prepareTaskTerminalBillingDecision(context.Background(), nil, task, &relaycommon.TaskInfo{ + Status: string(model.TaskStatusSuccess), Usage: usage, + }, constant.ChannelTypeMiniMax) + require.NotNil(t, decision.Settlement) + require.Zero(t, decision.Settlement.FundingDelta) + + require.True(t, ApplyTaskBillingSettlement(context.Background(), task, decision.Settlement)) + require.True(t, ApplyTaskBillingSettlement(context.Background(), task, decision.Settlement)) + var subscription model.UserSubscription + require.NoError(t, model.DB.First(&subscription, subscriptionID).Error) + require.EqualValues(t, 5_000, subscription.AmountUsed) + require.Equal(t, stableUpdatedAt, subscription.UpdatedAt) + require.EqualValues(t, 1, countLogs(t)) +} diff --git a/service/task_billing_test.go b/service/task_billing_test.go index c05e76ce..6335a51c 100644 --- a/service/task_billing_test.go +++ b/service/task_billing_test.go @@ -15,6 +15,7 @@ import ( "github.com/MAX-API-Next/MAX-API/dto" "github.com/MAX-API-Next/MAX-API/model" relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" + "github.com/MAX-API-Next/MAX-API/types" "github.com/gin-gonic/gin" "github.com/glebarez/sqlite" "github.com/stretchr/testify/assert" @@ -108,6 +109,100 @@ func TestBuildTaskSubmissionSettlementEffectCarriesRequestMetadata(t *testing.T) assert.True(t, effect.IsStream) } +func TestBuildTaskExactFinalSettlementUsesPreConsumedQuotaMetadata(t *testing.T) { + task := makeTask(901, 902, 100, 0, BillingSourceWallet, 0) + task.ID = 903 + task.TaskID = "task-exact-finalize" + + input := buildTaskExactFinalSettlementInput(task, 80, nil, "exact settlement") + + require.NotNil(t, input) + require.NotNil(t, input.Effect) + assert.Equal(t, 100, input.Effect.Other["pre_consumed_quota"]) + assert.NotContains(t, input.Effect.Other, "reserved_quota") +} + +func TestTaskBillingOtherRestrictsStructuredReconciliationMetadata(t *testing.T) { + zero := int64(0) + task := &model.Task{ + PrivateData: model.TaskPrivateData{ + BillingContext: &model.TaskBillingContext{ + TaskBillingPlan: &types.TaskBillingPlan{ + Source: "minimax_h3_v2", + RuleKey: "minimax_h3_v2", + ConfigHash: "must-not-reach-user-logs", + GroupRatio: 1.25, + QuotaPerUnit: 500000, + ReserveQuota: 800, + Components: []types.TaskBillingPlanComponent{{ + Key: "output_video", UnitPrice: "9.99", ReservedQuantity: 5, + }}, + }, + TaskUsage: &types.TaskUsage{ + OutputDurationMs: &zero, InputVideoDurationMs: &zero, InputAudioDurationMs: &zero, + InputImageCount: &zero, InputVideoCount: &zero, InputAudioCount: &zero, + Source: types.TaskUsageSourceProviderResponse, Completeness: types.TaskUsageCompletenessComplete, + }, + }, + }, + } + + other := taskBillingOther(task) + planMetadata, ok := other["task_billing_plan"].(map[string]interface{}) + require.True(t, ok) + require.Len(t, planMetadata, 3) + require.Equal(t, "minimax_h3_v2", planMetadata["source"]) + require.Equal(t, "minimax_h3_v2", planMetadata["rule_key"]) + require.EqualValues(t, 800, planMetadata["reserve_quota"]) + require.NotContains(t, planMetadata, "config_hash") + require.NotContains(t, planMetadata, "group_ratio") + require.NotContains(t, planMetadata, "quota_per_unit") + require.NotContains(t, planMetadata, "components") + + usageMetadata, ok := other["task_usage"].(map[string]interface{}) + require.True(t, ok) + require.Len(t, usageMetadata, 8) + for _, key := range []string{ + "output_duration_ms", "input_video_duration_ms", "input_audio_duration_ms", + "input_image_count", "input_video_count", "input_audio_count", + } { + require.Contains(t, usageMetadata, key) + require.EqualValues(t, 0, usageMetadata[key]) + } + require.Equal(t, types.TaskUsageSourceProviderResponse, usageMetadata["source"]) + require.Equal(t, types.TaskUsageCompletenessComplete, usageMetadata["completeness"]) +} + +func TestBuildTaskExactFinalSettlementUsesAllowlistedUsageMetadata(t *testing.T) { + task := makeTask(901, 902, 100, 0, BillingSourceWallet, 0) + task.ID = 903 + task.PrivateData.BillingContext.TaskBillingPlan = &types.TaskBillingPlan{ + RuleKey: "minimax_h3_v2", ReserveQuota: 100, ConfigHash: "must-not-reach-user-logs", + } + task.PrivateData.BillingContext.TaskUsage = &types.TaskUsage{ + Completeness: types.TaskUsageCompletenessPartial, + } + zero := int64(0) + usage := &types.TaskUsage{ + OutputDurationMs: &zero, + InputImageCount: &zero, + Source: types.TaskUsageSourceProviderResponse, + Completeness: types.TaskUsageCompletenessComplete, + } + + input := buildTaskExactFinalSettlementInput(task, 80, usage, "exact settlement") + + require.NotNil(t, input) + require.NotNil(t, input.Effect) + usageMetadata, ok := input.Effect.Other["task_usage"].(map[string]interface{}) + require.True(t, ok) + require.EqualValues(t, 0, usageMetadata["output_duration_ms"]) + require.EqualValues(t, 0, usageMetadata["input_image_count"]) + require.Equal(t, types.TaskUsageSourceProviderResponse, usageMetadata["source"]) + require.Equal(t, types.TaskUsageCompletenessComplete, usageMetadata["completeness"]) + require.NotContains(t, usageMetadata, "unit_price") +} + func TestSweepTimedOutUnconfirmedSubmitRequiresReviewWithoutRefund(t *testing.T) { truncate(t) resetTimedOutTaskSweepCursorForTest(t) @@ -307,6 +402,115 @@ func TestSweepTimedOutTasksBoundsScansAndContinuesFromCursor(t *testing.T) { assert.EqualValues(t, model.TaskStatusFailure, reloaded.Status) } +func TestSweepTimedOutTasksDoesNotSpendBudgetOnTaskFinalizeOwnedTasks(t *testing.T) { + truncate(t) + resetTimedOutTaskSweepCursorForTest(t) + originalTimeout := constant.TaskTimeoutMinutes + originalScanBudget := timedOutTaskScanBudget + constant.TaskTimeoutMinutes = 1 + timedOutTaskScanBudget = 1 + t.Cleanup(func() { + constant.TaskTimeoutMinutes = originalTimeout + timedOutTaskScanBudget = originalScanBudget + }) + + now := time.Now().Unix() + submitTime := legacyTaskRefundCutoff - 2 + protected := []model.Task{ + {TaskID: "timeout-finalize-pending", Status: model.TaskStatusSubmitted, SubmitTime: submitTime}, + {TaskID: "timeout-finalize-manual", Status: model.TaskStatusSubmitted, SubmitTime: submitTime}, + {TaskID: "timeout-finalize-applied", Status: model.TaskStatusSubmitted, SubmitTime: submitTime}, + } + require.NoError(t, model.DB.Create(&protected).Error) + statuses := []string{ + model.BillingSettlementStatusPending, + model.BillingSettlementStatusManual, + model.BillingSettlementStatusApplied, + } + for i := range protected { + require.NoError(t, model.DB.Create(&model.BillingSettlement{ + OperationKey: model.BillingTaskFinalizeOperationKey(protected[i].ID), + Source: model.BillingSettlementSourceWallet, + TaskID: protected[i].ID, + Status: statuses[i], + CreatedAt: now, + UpdatedAt: now, + Revision: 1, + }).Error) + } + actionable := model.Task{ + TaskID: "timeout-after-finalize-backlog", + Status: model.TaskStatusSubmitted, + SubmitTime: submitTime + 1, + } + require.NoError(t, model.DB.Create(&actionable).Error) + + sweepTimedOutTasks(context.Background()) + + var reloaded model.Task + require.NoError(t, model.DB.First(&reloaded, actionable.ID).Error) + assert.EqualValues(t, model.TaskStatusFailure, reloaded.Status) + for i := range protected { + reloaded = model.Task{} + require.NoError(t, model.DB.First(&reloaded, protected[i].ID).Error) + if statuses[i] == model.BillingSettlementStatusApplied { + assert.EqualValues(t, model.TaskStatusFailure, reloaded.Status) + assert.Contains(t, reloaded.FailReason, "manual reconciliation") + } else { + assert.EqualValues(t, model.TaskStatusSubmitted, reloaded.Status) + } + } +} + +func TestSweepTimedOutTasksRecoversAppliedFinalizeTerminalEvidence(t *testing.T) { + truncate(t) + resetTimedOutTaskSweepCursorForTest(t) + originalTimeout := constant.TaskTimeoutMinutes + constant.TaskTimeoutMinutes = 1 + t.Cleanup(func() { constant.TaskTimeoutMinutes = originalTimeout }) + + task := &model.Task{ + TaskID: "task_applied_finalize_recovery", + Status: model.TaskStatusInProgress, + Quota: 40, + SubmitTime: time.Now().Add(-2 * time.Minute).Unix(), + PrivateData: model.TaskPrivateData{ + PendingTerminalStatus: model.TaskStatusSuccess, + PendingTerminalProgress: "100%", + PendingTerminalFinishTime: time.Now().Unix(), + PendingTerminalResultURL: "https://cdn.example.com/recovered.mp4", + }, + } + require.NoError(t, model.DB.Create(task).Error) + now := time.Now().Unix() + require.NoError(t, model.DB.Create(&model.BillingSettlement{ + OperationKey: model.BillingTaskFinalizeOperationKey(task.ID), + TaskID: task.ID, + TaskQuota: 40, + TaskQuotaTarget: 40, + Status: model.BillingSettlementStatusApplied, + AppliedFundingDelta: 0, + CreatedAt: now, + UpdatedAt: now, + Revision: 1, + }).Error) + candidates, err := model.GetAppliedTaskFinalizeRecoveryCandidates(time.Now().Unix(), 10) + require.NoError(t, err) + require.Len(t, candidates, 1) + + sweepTimedOutTasks(context.Background()) + + var reloaded model.Task + require.NoError(t, model.DB.First(&reloaded, task.ID).Error) + assert.EqualValues(t, model.TaskStatusSuccess, reloaded.Status) + assert.Equal(t, "100%", reloaded.Progress) + assert.Equal(t, "https://cdn.example.com/recovered.mp4", reloaded.PrivateData.ResultURL) + assert.Equal(t, 40, reloaded.Quota) + var settlementCount int64 + require.NoError(t, model.DB.Model(&model.BillingSettlement{}).Where("task_id = ?", task.ID).Count(&settlementCount).Error) + assert.EqualValues(t, 1, settlementCount) +} + func resetTimedOutTaskSweepCursorForTest(t *testing.T) { t.Helper() reset := func() { diff --git a/service/task_polling.go b/service/task_polling.go index 7349a71b..2ae0edd3 100644 --- a/service/task_polling.go +++ b/service/task_polling.go @@ -18,6 +18,7 @@ import ( "github.com/MAX-API-Next/MAX-API/model" "github.com/MAX-API-Next/MAX-API/relay/channel/task/taskcommon" relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" + "github.com/MAX-API-Next/MAX-API/types" "github.com/samber/lo" ) @@ -32,6 +33,14 @@ type TaskPollingAdaptor interface { AdjustBillingOnComplete(task *model.Task, taskResult *relaycommon.TaskInfo) int } +// taskUsageProvider is an optional provider-specific usage extractor. It is +// deliberately local to service so the polling package does not import the +// relay/channel host package and create an import cycle. Usage facts remain +// evidence only; billing is still owned by the existing settlement path. +type taskUsageProvider interface { + ExtractTaskUsage(responseBody []byte) (*types.TaskUsage, error) +} + // GetTaskAdaptorFunc 由 main 包注入,用于获取指定平台的任务适配器。 // 打破 service -> relay -> relay/channel -> service 的循环依赖。 var GetTaskAdaptorFunc func(platform constant.TaskPlatform) TaskPollingAdaptor @@ -47,6 +56,14 @@ var timedOutTaskSweepCursor struct { afterID int64 } +type appliedTaskRecoveryOutcome uint8 + +const ( + appliedTaskRecoveryNoEvidence appliedTaskRecoveryOutcome = iota + appliedTaskRecoveryUpdated + appliedTaskRecoveryCASLost +) + // sweepTimedOutTasks 在主轮询之前独立清理超时任务。 // 每次最多转换 100 条、扫描 2000 条,剩余的从游标位置在下个周期继续处理。 // 使用 per-task CAS (UpdateWithStatus) 防止覆盖被正常轮询已推进的任务。 @@ -64,6 +81,36 @@ func sweepTimedOutTasks(ctx context.Context) { remaining := timedOutTaskTransitionBudget scanBudget := timedOutTaskScanBudget + if recovered, err := model.GetAppliedTaskFinalizeRecoveryCandidates(cutoff, remaining); err != nil { + logger.LogError(ctx, fmt.Sprintf("sweepTimedOutTasks applied-finalize recovery query error: %v", err)) + } else { + for _, task := range recovered { + if remaining <= 0 { + break + } + recoveryOutcome, recoveryErr := recoverAppliedTaskTerminalEvidence(task) + if recoveryErr != nil { + logger.LogError(ctx, fmt.Sprintf("sweepTimedOutTasks applied-finalize terminal recovery error for task %s: %v", task.TaskID, recoveryErr)) + continue + } + if recoveryOutcome == appliedTaskRecoveryCASLost { + continue + } + if recoveryOutcome == appliedTaskRecoveryNoEvidence { + recoveryOutcome, recoveryErr = markAppliedTaskEvidenceMissing(task, now) + if recoveryErr != nil { + logger.LogError(ctx, fmt.Sprintf("sweepTimedOutTasks applied-finalize evidence disposition error for task %s: %v", task.TaskID, recoveryErr)) + continue + } + if recoveryOutcome == appliedTaskRecoveryCASLost { + continue + } + } + if recoveryOutcome == appliedTaskRecoveryUpdated { + remaining-- + } + } + } afterSubmitTime := timedOutTaskSweepCursor.afterSubmitTime afterID := timedOutTaskSweepCursor.afterID for remaining > 0 && scanBudget > 0 { @@ -88,17 +135,60 @@ func sweepTimedOutTasks(ctx context.Context) { afterSubmitTime = task.SubmitTime afterID = task.ID scanBudget-- - settlementPending, settlementErr := taskTerminalSettlementPending(task, true) + settlementPending, taskFinalizeApplied, settlementErr := taskTerminalSettlementState(task, true) if settlementErr != nil { logger.LogError(ctx, fmt.Sprintf("sweepTimedOutTasks settlement lookup error for task %s: %v", task.TaskID, settlementErr)) continue } - if settlementPending { - continue + if settlementPending || taskFinalizeApplied { + // Pending/manual funding remains operator/retry owned. Applied + // funding with durable terminal evidence only needs a status update; + // legacy applied rows without evidence retain the historical timeout + // path so they cannot be stranded. + if taskFinalizeApplied { + latestTask, reloadErr := model.GetTaskByID(task.ID) + if reloadErr != nil { + logger.LogError(ctx, fmt.Sprintf("sweepTimedOutTasks applied-finalize task reload error for task %s: %v", task.TaskID, reloadErr)) + continue + } + if latestTask.Status == model.TaskStatusFailure || latestTask.Status == model.TaskStatusSuccess { + continue + } + recoveryOutcome, recoveryErr := recoverAppliedTaskTerminalEvidence(latestTask) + if recoveryErr != nil { + logger.LogError(ctx, fmt.Sprintf("sweepTimedOutTasks applied-finalize terminal recovery error for task %s: %v", task.TaskID, recoveryErr)) + continue + } + if recoveryOutcome == appliedTaskRecoveryNoEvidence { + recoveryOutcome, recoveryErr = markAppliedTaskEvidenceMissing(latestTask, now) + if recoveryErr != nil { + logger.LogError(ctx, fmt.Sprintf("sweepTimedOutTasks applied-finalize evidence disposition error for task %s: %v", task.TaskID, recoveryErr)) + continue + } + } + if recoveryOutcome == appliedTaskRecoveryUpdated { + remaining-- + } + continue + } else { + continue + } } remaining-- isLegacy := task.SubmitTime > 0 && task.SubmitTime < legacyTaskRefundCutoff isUnconfirmedSubmit := task.PrivateData.AwaitingUpstreamID + terminalDecision := prepareTaskTerminalBillingDecision(ctx, nil, task, &relaycommon.TaskInfo{ + Status: string(model.TaskStatusFailure), Reason: reason, + }, 0) + if terminalDecision.UsesPlan && terminalDecision.ManualReason != "" { + won, err := persistTaskManualBillingDecision(task, task.Status, task.UpdatedAt, terminalDecision) + if err != nil { + logger.LogError(ctx, fmt.Sprintf("sweepTimedOutTasks manual settlement error for task %s: %v", task.TaskID, err)) + } else if !won { + logger.LogInfo(ctx, fmt.Sprintf("sweepTimedOutTasks: task %s manual settlement CAS lost", task.TaskID)) + } + continue + } oldStatus := task.Status task.Status = model.TaskStatusFailure @@ -114,7 +204,11 @@ func sweepTimedOutTasks(ctx context.Context) { var settlement *model.BillingSettlementInput if !isLegacy && !isUnconfirmedSubmit { - settlement = BuildTaskRefundSettlementInput(task, reason) + if terminalDecision.UsesPlan { + settlement = terminalDecision.Settlement + } else { + settlement = BuildTaskRefundSettlementInput(task, reason) + } } var won bool var err error @@ -451,25 +545,37 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * logger.LogDebug(ctx, "updateVideoSingleTask response bytes: %d", len(responseBody)) snap := task.Snapshot() + expectedUpdatedAt := task.UpdatedAt taskResult := &relaycommon.TaskInfo{} - // try parse as MAX API response format - var responseItems dto.TaskResponse[model.Task] - if err = common.Unmarshal(responseBody, &responseItems); err == nil && responseItems.IsSuccess() { - logger.LogDebug(ctx, "updateVideoSingleTask parsed as compatible response format: %+v", responseItems) - t := responseItems.Data - taskResult.TaskID = t.TaskID - taskResult.Status = string(t.Status) - taskResult.Url = t.GetResultURL() - taskResult.Progress = t.Progress - taskResult.Reason = t.FailReason - task.Data = t.Data - } else if configuredResult, ok, parseErr := taskcommon.ParseConfiguredTaskResult(responseBody, ch.GetOtherSettings()); parseErr != nil { + // Configured task protocols take precedence over the MAX envelope. Relay + // gateways may return code=success while their nested data.data object is + // the actual provider result; consuming the outer wrapper first would keep + // the task in the gateway's local IN_PROGRESS state forever. + if configuredResult, ok, parseErr := taskcommon.ParseConfiguredTaskResult(responseBody, ch.GetOtherSettings()); parseErr != nil { return fmt.Errorf("parse configured task result failed for task %s: %w", taskId, parseErr) } else if ok { taskResult = configuredResult - } else if taskResult, err = adaptor.ParseTaskResult(responseBody); err != nil { - return fmt.Errorf("parseTaskResult failed for task %s: %w", taskId, err) + } else { + // try parse as MAX API response format + var responseItems dto.TaskResponse[model.Task] + if err = common.Unmarshal(responseBody, &responseItems); err == nil && responseItems.IsSuccess() { + logger.LogDebug(ctx, "updateVideoSingleTask parsed as compatible response format: %+v", responseItems) + t := responseItems.Data + taskResult.TaskID = t.TaskID + taskResult.Status = string(t.Status) + taskResult.Url = t.GetResultURL() + taskResult.Progress = t.Progress + taskResult.Reason = t.FailReason + task.Data = t.Data + } else if taskResult, err = adaptor.ParseTaskResult(responseBody); err != nil { + return fmt.Errorf("parseTaskResult failed for task %s: %w", taskId, err) + } else if taskResult == nil { + return fmt.Errorf("parseTaskResult returned no result for task %s", taskId) + } + } + if err := applyTaskUsageFacts(adaptor, responseBody, taskResult); err != nil { + return fmt.Errorf("extract task usage failed for task %s: %w", taskId, err) } task.Data = redactVideoResponseBody(responseBody) @@ -499,12 +605,60 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * } } providerTerminal := taskResult.Status == string(model.TaskStatusSuccess) || taskResult.Status == string(model.TaskStatusFailure) - pending, settlementErr := taskTerminalSettlementPending(task, providerTerminal) + pending, taskFinalizeApplied, settlementErr := taskTerminalSettlementState(task, providerTerminal) if settlementErr != nil { return fmt.Errorf("load task submission settlement for task %s: %w", task.TaskID, settlementErr) } if pending { - return nil + // A task-finalize manual record created by incomplete H3 usage may be + // safely reopened when a later provider poll supplies complete, + // validated usage. Other manual reasons remain operator-gated. + if providerTerminal { + recoveryDecision := prepareTaskTerminalBillingDecision(ctx, adaptor, task, taskResult, ch.Type, ch.GetOtherSettings()) + recovered, recoveryErr := recoverManualTaskBillingSettlement(ctx, task, recoveryDecision, taskResult, snap.Status, expectedUpdatedAt) + if recoveryErr != nil { + return fmt.Errorf("recover manual task settlement for task %s: %w", task.TaskID, recoveryErr) + } + if recovered { + pending, taskFinalizeApplied, settlementErr = taskTerminalSettlementState(task, providerTerminal) + if settlementErr != nil { + return fmt.Errorf("reload recovered task settlement for task %s: %w", task.TaskID, settlementErr) + } + } + } + if pending { + return nil + } + } + terminalDecision := taskTerminalBillingDecision{} + if providerTerminal && !taskFinalizeApplied { + terminalDecision = prepareTaskTerminalBillingDecision(ctx, adaptor, task, taskResult, ch.Type, ch.GetOtherSettings()) + if terminalDecision.UsesPlan && terminalDecision.ManualReason != "" { + won, manualErr := persistTaskManualBillingDecision(task, snap.Status, expectedUpdatedAt, terminalDecision) + if manualErr != nil { + return fmt.Errorf("persist manual task settlement for task %s: %w", task.TaskID, manualErr) + } + if !won { + logger.LogWarn(ctx, fmt.Sprintf("Task %s manual settlement CAS lost", task.TaskID)) + } + return nil + } + if terminalDecision.UsesPlan && task.PrivateData.BillingContext != nil { + task.PrivateData.BillingContext.TaskUsage = types.CloneTaskUsage(terminalDecision.Usage) + persistPendingTaskTerminalEvidence(task, taskResult, time.Now().Unix()) + won, intentErr := task.UpdateWithStatusAndSettlementIntent(snap.Status, expectedUpdatedAt, *terminalDecision.Settlement) + if intentErr != nil { + return fmt.Errorf("persist task settlement intent for task %s: %w", task.TaskID, intentErr) + } + if !won { + logger.LogWarn(ctx, fmt.Sprintf("Task %s settlement intent CAS lost", task.TaskID)) + return nil + } + if !applyTaskBillingSettlement(ctx, task, terminalDecision.Settlement) { + return nil + } + taskFinalizeApplied = true + } } shouldRefund := false @@ -562,7 +716,14 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * var settlement *model.BillingSettlementInput transitionWon := false if isDone && snap.Status != task.Status { - if shouldSettle { + if taskFinalizeApplied { + clearPendingTaskTerminalEvidence(task) + } + if taskFinalizeApplied { + settlement = nil + } else if terminalDecision.UsesPlan { + return fmt.Errorf("task %s terminal settlement was not applied", task.TaskID) + } else if shouldSettle { settlement = prepareTaskCompletionSettlement(ctx, adaptor, task, taskResult, ch.Type, ch.GetOtherSettings()) } else if shouldRefund { settlement = BuildTaskRefundSettlementInput(task, task.FailReason) @@ -601,6 +762,179 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * return nil } +func persistPendingTaskTerminalEvidence(task *model.Task, result *relaycommon.TaskInfo, now int64) { + if task == nil || result == nil { + return + } + task.PrivateData.PendingTerminalStatus = model.TaskStatus(result.Status) + task.PrivateData.PendingTerminalProgress = result.Progress + task.PrivateData.PendingTerminalFinishTime = now + task.PrivateData.PendingTerminalReason = common.SanitizePersistedLogContent(result.Reason) + task.PrivateData.PendingTerminalResultURL = result.Url + task.IncludePrivateDataInUpdate() +} + +func recoverAppliedTaskTerminalEvidence(task *model.Task) (appliedTaskRecoveryOutcome, error) { + if task == nil || task.ID <= 0 { + return appliedTaskRecoveryNoEvidence, errors.New("applied-finalize task is required") + } + pending := task.PrivateData.PendingTerminalStatus + if pending != model.TaskStatusSuccess && pending != model.TaskStatusFailure { + return appliedTaskRecoveryNoEvidence, nil + } + // Stage all changes on a copy. A lost CAS must leave the caller's snapshot + // untouched so it cannot be mistaken for missing evidence and overwritten. + candidate := *task + oldStatus := candidate.Status + candidate.Status = pending + candidate.Progress = candidate.PrivateData.PendingTerminalProgress + if candidate.Progress == "" { + candidate.Progress = taskcommon.ProgressComplete + } + candidate.FinishTime = candidate.PrivateData.PendingTerminalFinishTime + if candidate.FinishTime == 0 { + candidate.FinishTime = time.Now().Unix() + } + candidate.FailReason = candidate.PrivateData.PendingTerminalReason + if pending == model.TaskStatusSuccess && candidate.PrivateData.PendingTerminalResultURL != "" { + candidate.PrivateData.ResultURL = candidate.PrivateData.PendingTerminalResultURL + } + clearPendingTaskTerminalEvidence(&candidate) + won, err := candidate.UpdateWithStatus(oldStatus) + if err != nil { + return appliedTaskRecoveryNoEvidence, err + } + if !won { + return appliedTaskRecoveryCASLost, nil + } + *task = candidate + return appliedTaskRecoveryUpdated, nil +} + +func clearPendingTaskTerminalEvidence(task *model.Task) { + if task == nil { + return + } + task.PrivateData.PendingTerminalStatus = "" + task.PrivateData.PendingTerminalProgress = "" + task.PrivateData.PendingTerminalFinishTime = 0 + task.PrivateData.PendingTerminalReason = "" + task.PrivateData.PendingTerminalResultURL = "" + task.IncludePrivateDataInUpdate() +} + +func markAppliedTaskEvidenceMissing(task *model.Task, now int64) (appliedTaskRecoveryOutcome, error) { + if task == nil || task.ID <= 0 { + return appliedTaskRecoveryNoEvidence, errors.New("applied-finalize task is required") + } + // Use the same copy-and-CAS discipline as terminal recovery. This preserves + // a concurrently published terminal state when the CAS is lost. + candidate := *task + oldStatus := candidate.Status + candidate.Status = model.TaskStatusFailure + candidate.Progress = taskcommon.ProgressComplete + candidate.FinishTime = now + candidate.FailReason = "task settlement was already applied but terminal provider evidence is unavailable; manual reconciliation required" + clearPendingTaskTerminalEvidence(&candidate) + won, err := candidate.UpdateWithStatus(oldStatus) + if err != nil { + return appliedTaskRecoveryNoEvidence, err + } + if !won { + return appliedTaskRecoveryCASLost, nil + } + *task = candidate + return appliedTaskRecoveryUpdated, nil +} + +func applyTaskUsageFacts(adaptor TaskPollingAdaptor, responseBody []byte, taskResult *relaycommon.TaskInfo) error { + if adaptor == nil || taskResult == nil { + return nil + } + provider, ok := adaptor.(taskUsageProvider) + if !ok { + return nil + } + usage, err := provider.ExtractTaskUsage(responseBody) + if err != nil { + return err + } + if usage != nil { + taskResult.Usage = usage + } + return nil +} + +func recoverManualTaskBillingSettlement( + ctx context.Context, + task *model.Task, + decision taskTerminalBillingDecision, + providerResult *relaycommon.TaskInfo, + fromStatus model.TaskStatus, + expectedUpdatedAt int64, +) (bool, error) { + if task == nil || task.ID <= 0 || !decision.UsesPlan || decision.ManualReason != "" || decision.Settlement == nil { + return false, nil + } + if providerResult == nil || (providerResult.Status != string(model.TaskStatusSuccess) && providerResult.Status != string(model.TaskStatusFailure)) { + return false, errors.New("terminal provider result is required for manual settlement recovery") + } + if submissionPending, err := taskFinalSettlementPending(task); err != nil || submissionPending { + return false, err + } + status, found, err := model.GetBillingSettlementStatus(model.BillingTaskFinalizeOperationKey(task.ID)) + if err != nil || !found || (status != model.BillingSettlementStatusManual && status != model.BillingSettlementStatusPending) { + return false, err + } + // Persist the provider terminal facts before promoting the manual settlement + // to a funding operation. A crash after promotion must remain recoverable as + // the provider's terminal state, not be misclassified as missing evidence. + candidate := *task + if task.PrivateData.BillingContext != nil { + billingContext := *task.PrivateData.BillingContext + candidate.PrivateData.BillingContext = &billingContext + candidate.PrivateData.BillingContext.TaskUsage = types.CloneTaskUsage(decision.Usage) + } + persistPendingTaskTerminalEvidence(&candidate, providerResult, time.Now().Unix()) + won, err := candidate.UpdateWithStatusAndPendingTerminalEvidence(fromStatus, expectedUpdatedAt) + if err != nil { + return false, fmt.Errorf("persist manual task terminal evidence: %w", err) + } + if !won { + return false, nil + } + *task = candidate + const h3UsageManualReasonPrefix = "H3 terminal usage requires manual reconciliation:" + recoveryNote := "H3 task settlement automatically recovered after a complete provider usage response" + ready, err := model.PromoteManualTaskBillingSettlement(*decision.Settlement, h3UsageManualReasonPrefix, recoveryNote) + if errors.Is(err, model.ErrBillingSettlementOperationConflict) { + // A different manual reason or a concurrent/permanent recovery failure + // must remain operator-gated; do not turn that durable state into a + // polling error or publish a terminal task status. + return false, nil + } + if err != nil || !ready { + return false, err + } + if !applyTaskBillingSettlement(ctx, task, decision.Settlement) { + return false, nil + } + return true, nil +} + +func persistTaskManualBillingDecision(task *model.Task, fromStatus model.TaskStatus, expectedUpdatedAt int64, decision taskTerminalBillingDecision) (bool, error) { + if task == nil || !decision.UsesPlan || decision.ManualReason == "" || decision.Settlement == nil { + return false, errors.New("complete manual task billing decision is required") + } + if task.PrivateData.BillingContext == nil { + return false, errors.New("task billing context is required") + } + task.Status = fromStatus + task.PrivateData.BillingContext.TaskUsage = types.CloneTaskUsage(decision.Usage) + task.UpdatedAt = time.Now().Unix() + return task.UpdateWithStatusAndManualSettlement(fromStatus, expectedUpdatedAt, *decision.Settlement, decision.ManualReason) +} + func shouldApplyTaskResultProgress(taskResult *relaycommon.TaskInfo) bool { if taskResult == nil || strings.TrimSpace(taskResult.Progress) == "" { return false diff --git a/service/task_polling_test.go b/service/task_polling_test.go index a718e234..e64831b3 100644 --- a/service/task_polling_test.go +++ b/service/task_polling_test.go @@ -3,18 +3,22 @@ package service import ( "bytes" "context" + "errors" "io" "net/http" "strings" "testing" + "time" "github.com/MAX-API-Next/MAX-API/common" "github.com/MAX-API-Next/MAX-API/constant" "github.com/MAX-API-Next/MAX-API/dto" "github.com/MAX-API-Next/MAX-API/model" relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" + "github.com/MAX-API-Next/MAX-API/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gorm.io/gorm" ) type sunoPollingResponseAdaptor struct { @@ -38,6 +42,181 @@ func (a *sunoPollingResponseAdaptor) AdjustBillingOnComplete(*model.Task, *relay return 0 } +type usagePollingResponseAdaptor struct { + usage *types.TaskUsage + usageErr error +} + +type configuredWrapperPollingAdaptor struct{} + +func (a *configuredWrapperPollingAdaptor) Init(*relaycommon.RelayInfo) {} + +func (a *configuredWrapperPollingAdaptor) FetchTask(string, string, map[string]any, string) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{ + "code":"success", + "data":{ + "id":10, + "status":"IN_PROGRESS", + "data":{ + "id":"439499419230570", + "object":"video.generation", + "status":"completed", + "data":[{"url":"https://cdn.example.com/polled.mp4"}] + } + } + }`)), + }, nil +} + +func (a *configuredWrapperPollingAdaptor) ParseTaskResult([]byte) (*relaycommon.TaskInfo, error) { + return nil, errors.New("configured parser should handle wrapper response") +} + +func (a *configuredWrapperPollingAdaptor) AdjustBillingOnComplete(*model.Task, *relaycommon.TaskInfo) int { + return 0 +} + +func (a *usagePollingResponseAdaptor) Init(*relaycommon.RelayInfo) {} + +func (a *usagePollingResponseAdaptor) FetchTask(string, string, map[string]any, string) (*http.Response, error) { + return nil, nil +} + +func (a *usagePollingResponseAdaptor) ParseTaskResult([]byte) (*relaycommon.TaskInfo, error) { + return &relaycommon.TaskInfo{Status: string(model.TaskStatusSuccess)}, nil +} + +func (a *usagePollingResponseAdaptor) AdjustBillingOnComplete(*model.Task, *relaycommon.TaskInfo) int { + return 0 +} + +func (a *usagePollingResponseAdaptor) ExtractTaskUsage([]byte) (*types.TaskUsage, error) { + return a.usage, a.usageErr +} + +func TestApplyTaskUsageFactsAttachesProviderEvidence(t *testing.T) { + taskResult := &relaycommon.TaskInfo{} + usage := &types.TaskUsage{ + Source: types.TaskUsageSourceProviderResponse, + Completeness: types.TaskUsageCompletenessComplete, + } + + require.NoError(t, applyTaskUsageFacts(&usagePollingResponseAdaptor{usage: usage}, []byte(`{}`), taskResult)) + assert.Same(t, usage, taskResult.Usage) +} + +func TestApplyTaskUsageFactsFailsClosedOnProviderError(t *testing.T) { + taskResult := &relaycommon.TaskInfo{} + providerErr := errors.New("ambiguous usage response") + + err := applyTaskUsageFacts(&usagePollingResponseAdaptor{usageErr: providerErr}, []byte(`{}`), taskResult) + + require.ErrorIs(t, err, providerErr) + assert.Nil(t, taskResult.Usage) +} + +func TestUpdateVideoSingleTaskUsesConfiguredParserForWrappedProviderResult(t *testing.T) { + truncate(t) + baseURL := "https://upstream.example.com" + task := &model.Task{ + TaskID: "task_wrapped_polling", + Status: model.TaskStatusInProgress, + Progress: "50%", + SubmitTime: time.Now().Unix(), + UpdatedAt: time.Now().Unix(), + PrivateData: model.TaskPrivateData{}, + } + require.NoError(t, model.DB.Create(task).Error) + channel := &model.Channel{ + Id: 8101, + Key: "sk-test", + BaseURL: &baseURL, + OtherSettings: `{"task_protocol":"generic_video_task","task_protocol_config":{"task_id_path":"data.id","status_path":"data.status","result_url_paths":["data.data.0.url"]}}`, + } + task.ChannelId = channel.Id + require.NoError(t, model.DB.Create(channel).Error) + + err := updateVideoSingleTask(context.Background(), &configuredWrapperPollingAdaptor{}, channel, task.TaskID, map[string]*model.Task{task.TaskID: task}) + + require.NoError(t, err) + var reloaded model.Task + require.NoError(t, model.DB.First(&reloaded, task.ID).Error) + assert.EqualValues(t, model.TaskStatusSuccess, reloaded.Status) + assert.Equal(t, "https://cdn.example.com/polled.mp4", reloaded.PrivateData.ResultURL) +} + +func TestAppliedTaskRecoveryPreservesSnapshotOnCASLoss(t *testing.T) { + truncate(t) + task := &model.Task{ + TaskID: "task_recovery_cas_loss", + Status: model.TaskStatusInProgress, + Progress: "50%", + PrivateData: model.TaskPrivateData{ + PendingTerminalStatus: model.TaskStatusSuccess, + PendingTerminalProgress: "100%", + PendingTerminalResultURL: "https://cdn.example.com/cas-loss.mp4", + }, + } + require.NoError(t, model.DB.Create(task).Error) + require.NoError(t, model.DB.Model(&model.Task{}).Where("id = ?", task.ID).Update("status", model.TaskStatusSuccess).Error) + + outcome, err := recoverAppliedTaskTerminalEvidence(task) + require.NoError(t, err) + assert.Equal(t, appliedTaskRecoveryCASLost, outcome) + assert.EqualValues(t, model.TaskStatusInProgress, task.Status) + assert.EqualValues(t, model.TaskStatusSuccess, task.PrivateData.PendingTerminalStatus) + assert.Equal(t, "https://cdn.example.com/cas-loss.mp4", task.PrivateData.PendingTerminalResultURL) + + task = &model.Task{ + TaskID: "task_missing_evidence_cas_loss", + Status: model.TaskStatusInProgress, + } + require.NoError(t, model.DB.Create(task).Error) + require.NoError(t, model.DB.Model(&model.Task{}).Where("id = ?", task.ID).Update("status", model.TaskStatusSuccess).Error) + + outcome, err = markAppliedTaskEvidenceMissing(task, time.Now().Unix()) + require.NoError(t, err) + assert.Equal(t, appliedTaskRecoveryCASLost, outcome) + assert.EqualValues(t, model.TaskStatusInProgress, task.Status) +} + +func TestAppliedTaskRecoveryPropagatesUpdateError(t *testing.T) { + truncate(t) + task := &model.Task{ + TaskID: "task_recovery_update_error", + Status: model.TaskStatusInProgress, + PrivateData: model.TaskPrivateData{ + PendingTerminalStatus: model.TaskStatusSuccess, + }, + } + require.NoError(t, model.DB.Create(task).Error) + + callbackName := "test:task-recovery-update-error" + forcedErr := errors.New("forced task recovery update error") + require.NoError(t, model.DB.Callback().Update().Before("gorm:update").Register(callbackName, func(tx *gorm.DB) { + _ = tx.AddError(forcedErr) + })) + t.Cleanup(func() { _ = model.DB.Callback().Update().Remove(callbackName) }) + + outcome, err := recoverAppliedTaskTerminalEvidence(task) + assert.ErrorIs(t, err, forcedErr) + assert.Equal(t, appliedTaskRecoveryNoEvidence, outcome) + assert.EqualValues(t, model.TaskStatusInProgress, task.Status) + assert.EqualValues(t, model.TaskStatusSuccess, task.PrivateData.PendingTerminalStatus) + + missingEvidenceTask := &model.Task{ + TaskID: "task_missing_evidence_update_error", + Status: model.TaskStatusInProgress, + } + require.NoError(t, model.DB.Create(missingEvidenceTask).Error) + outcome, err = markAppliedTaskEvidenceMissing(missingEvidenceTask, time.Now().Unix()) + assert.ErrorIs(t, err, forcedErr) + assert.Equal(t, appliedTaskRecoveryNoEvidence, outcome) + assert.EqualValues(t, model.TaskStatusInProgress, missingEvidenceTask.Status) +} + func TestUpdateVideoTasksLeavesChargedTasksPendingWhenChannelCacheFails(t *testing.T) { truncate(t) diff --git a/setting/task_billing_setting/h3.go b/setting/task_billing_setting/h3.go index 6c2d0a04..96052bde 100644 --- a/setting/task_billing_setting/h3.go +++ b/setting/task_billing_setting/h3.go @@ -165,6 +165,20 @@ func GetH3BillingProfileCopy(key string) (*H3BillingConfig, bool) { return &profile, true } +// HasH3BillingPlanForModel mirrors the Hailuo adaptor's effective-model gate. +// A structured rate card or the legacy H3 profile can own pricing only when +// the final upstream model is actually MiniMax-H3. +func HasH3BillingPlanForModel(model string) bool { + if !isMinimaxH3Model(model) { + return false + } + if card, _ := findMinimaxRateCard(model); card != nil { + return true + } + _, ok := GetH3BillingProfileCopy(H3BillingProfileKey) + return ok +} + func ValidateH3ProfilesJSON(raw string) error { if strings.TrimSpace(raw) == "" { return fmt.Errorf("H3 billing profiles cannot be empty") @@ -400,6 +414,28 @@ func QuoteH3Reserve(plan *types.TaskBillingPlan) (*H3BillingQuote, error) { return quoteH3Plan(plan, types.TaskBillingPlanStageReserve, plan.RequestedOutputDurationSeconds, videoSeconds, 0, plan.InputImageCount) } +// ValidateH3BillingPlanSnapshot proves that the persisted summary totals still +// match the immutable component snapshot. Completion must fail closed when a +// stored estimate or reservation has drifted from the prices and quantities +// that originally produced it. +func ValidateH3BillingPlanSnapshot(plan *types.TaskBillingPlan) error { + estimate, err := QuoteH3Estimate(plan) + if err != nil { + return err + } + reserve, err := QuoteH3Reserve(plan) + if err != nil { + return err + } + if estimate.Quota != plan.EstimateQuota || reserve.Quota != plan.ReserveQuota { + return fmt.Errorf("H3 billing plan frozen totals do not match its component snapshot") + } + if reserve.Quota < estimate.Quota { + return fmt.Errorf("H3 billing plan reservation is below its estimate") + } + return nil +} + func QuoteH3Final(plan *types.TaskBillingPlan, usage *types.TaskUsage) (*H3BillingQuote, error) { if plan == nil || usage == nil { return nil, fmt.Errorf("H3 billing plan and usage are required") diff --git a/types/price_data.go b/types/price_data.go index 065369ab..90da8cc1 100644 --- a/types/price_data.go +++ b/types/price_data.go @@ -31,7 +31,10 @@ type PriceData struct { UsePrice bool Quota int // 按次计费的最终额度(MJ / Task) QuotaToPreConsume int // 预消耗额度(真实估算额度应用预扣下限后) - GroupRatioInfo GroupRatioInfo + // TaskBillingPlanRequired records that this pricing result deliberately + // deferred its estimate and reservation to a structured task plan. + TaskBillingPlanRequired bool + GroupRatioInfo GroupRatioInfo } func (p *PriceData) AddOtherRatio(key string, ratio float64) { diff --git a/types/task_usage.go b/types/task_usage.go index b29786cb..c77637a0 100644 --- a/types/task_usage.go +++ b/types/task_usage.go @@ -28,3 +28,25 @@ type TaskUsage struct { Source string `json:"source,omitempty"` Completeness string `json:"completeness,omitempty"` } + +func CloneTaskUsage(usage *TaskUsage) *TaskUsage { + if usage == nil { + return nil + } + cloned := *usage + cloned.OutputDurationMs = cloneInt64Pointer(usage.OutputDurationMs) + cloned.InputVideoDurationMs = cloneInt64Pointer(usage.InputVideoDurationMs) + cloned.InputAudioDurationMs = cloneInt64Pointer(usage.InputAudioDurationMs) + cloned.InputImageCount = cloneInt64Pointer(usage.InputImageCount) + cloned.InputVideoCount = cloneInt64Pointer(usage.InputVideoCount) + cloned.InputAudioCount = cloneInt64Pointer(usage.InputAudioCount) + return &cloned +} + +func cloneInt64Pointer(value *int64) *int64 { + if value == nil { + return nil + } + cloned := *value + return &cloned +} diff --git a/web/default/src/features/pricing/components/model-details.runtime.test.tsx b/web/default/src/features/pricing/components/model-details.runtime.test.tsx new file mode 100644 index 00000000..0671b48c --- /dev/null +++ b/web/default/src/features/pricing/components/model-details.runtime.test.tsx @@ -0,0 +1,164 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createReactTestEnvironment } from '@/test/react' +import assert from 'node:assert/strict' +import { after, before, describe, test } from 'node:test' +import type { PricingModel } from '../types' +import { ModelDetailsContent } from './model-details' + +const testEnv = createReactTestEnvironment() + +before(() => testEnv.setup()) + +after(() => testEnv.teardown()) + +describe('ModelDetailsContent structured task pricing', () => { + function createModel(inputVideoMaxQuantity?: number): PricingModel { + return { + id: 1, + model_name: 'MiniMax-H3', + quota_type: 1, + model_ratio: 0, + completion_ratio: 0, + model_price: 0, + enable_groups: ['default'], + task_rate_card: { + rule_key: 'minimax/minimax-h3', + vendor: 'minimax', + billing_type: 'minimax', + currency: 'USD', + unit: 'second', + quantity_field: 'output_duration', + strict: true, + min_unit_price: 0.08, + max_unit_price: 0.13, + rows: [], + components: [ + { + key: 'output_video', + variant: '768P', + unit: 'second', + unit_price: '0.08', + min_quantity: 4, + max_quantity: 15, + }, + { + key: 'output_video', + variant: '2K', + unit: 'second', + unit_price: '0.13', + min_quantity: 4, + max_quantity: 15, + }, + { + key: 'input_video', + variant: '768P', + unit: 'second', + unit_price: '0.08', + max_quantity: inputVideoMaxQuantity, + }, + { + key: 'input_video', + variant: '2K', + unit: 'second', + unit_price: '0.13', + max_quantity: inputVideoMaxQuantity, + }, + { + key: 'input_image', + unit: 'image', + unit_price: '0.04', + free_quantity: 5, + max_quantity: 9, + }, + { + key: 'input_audio', + unit: 'second', + unit_price: '0', + max_quantity: 15, + }, + { + key: 'experimental_component', + variant: 'custom', + unit: 'second', + unit_price: '0.3', + }, + ], + }, + } as PricingModel + } + + async function renderModel(model: PricingModel): Promise<{ + queryClient: QueryClient + view: Awaited> + }> { + const queryClient = new QueryClient() + const view = await testEnv.render( + + + + ) + return { queryClient, view } + } + + test('shows every MiniMax billing component without falling back to model price', async () => { + const { queryClient, view } = await renderModel(createModel(15)) + + try { + const content = view.container.textContent || '' + assert.match(content, /Output video \/ 768P/) + assert.match(content, /Output video \/ 2K/) + assert.match(content, /Input video \/ 768P/) + assert.match(content, /Input video \/ 2K/) + assert.match(content, /Extra input image price/) + assert.match(content, /Input audio price/) + assert.match(content, /Other pricing component/) + assert.match(content, /Free input images/) + assert.match(content, /\/ images/) + assert.doesNotMatch(content, /\/ image(?!s)/) + assert.doesNotMatch(content, /Per request/) + } finally { + await view.unmount() + queryClient.clear() + } + }) + + test('does not append seconds when input video cap is absent', async () => { + const { queryClient, view } = await renderModel(createModel()) + + try { + const content = view.container.textContent || '' + assert.match(content, /Input video reservation cap-/) + assert.doesNotMatch(content, /- seconds/) + } finally { + await view.unmount() + queryClient.clear() + } + }) +}) diff --git a/web/default/src/features/pricing/components/model-details.tsx b/web/default/src/features/pricing/components/model-details.tsx index f3592164..f22e68c3 100644 --- a/web/default/src/features/pricing/components/model-details.tsx +++ b/web/default/src/features/pricing/components/model-details.tsx @@ -73,6 +73,10 @@ import { formatTaskRateCardRange, formatTaskRateCardUnitPrice, } from '../lib/price' +import { + getTaskRateCardComponentLabelKey, + hasStructuredTaskRateCard, +} from '../lib/task-rate-card' import type { Modality, ModelCapability, @@ -423,6 +427,67 @@ function PriceSection(props: { if (props.model.task_rate_card) { const card = props.model.task_rate_card + const structuredComponents = card.components || [] + if (hasStructuredTaskRateCard(card)) { + const inputVideo = structuredComponents.find( + (component) => component.key === 'input_video' + ) + const inputImage = structuredComponents.find( + (component) => component.key === 'input_image' + ) + return ( +
+ {t('Base Price')} +
+
+
+ {t('Price per second')} +
+
+ {formatTaskRateCardRange( + props.model, + props.showRechargePrice, + props.priceRate, + props.usdExchangeRate, + 1 + )} + + / {t(card.unit || 'second')} + +
+
+
+
+ {t('Input video reservation cap')} +
+
+ {inputVideo?.max_quantity == null ? ( + '-' + ) : ( + <> + {inputVideo.max_quantity}{' '} + + {t('seconds')} + + + )} +
+
+
+
+ {t('Free input images')} +
+
+ {inputImage?.free_quantity ?? 0}{' '} + + {t('images')} + +
+
+
+
+ ) + } return (
{t('Base Price')} @@ -634,6 +699,86 @@ function TaskRateCardBreakdown(props: { const card = props.model.task_rate_card if (!card) return null + const structuredComponents = card.components || [] + if (hasStructuredTaskRateCard(card)) { + const thClass = + 'text-muted-foreground py-2 text-[10px] font-medium tracking-wider uppercase' + + return ( +
+ {t('Parameter pricing rules')} +
+
+ {t('Rule')}: + + {card.rule_key || props.model.model_name} + +
+
+ {t('Vendor')}: + {card.vendor || props.model.vendor_name || '-'} +
+
+ {t('Currency')}: + {card.currency || '-'} +
+
+ {t('Schema')}: + + {card.schema_version ?? '-'} + {card.mode ? ` / ${card.mode}` : ''} + +
+
+
+ + + + + {t('Pricing component')} + + + {t('Unit price')} + + + + + {structuredComponents.map((component, index) => ( + + + {t( + getTaskRateCardComponentLabelKey(component) ?? + 'Other pricing component' + )} + + + {formatTaskRateCardUnitPrice( + Number(component.unit_price), + props.showRechargePrice, + props.priceRate, + props.usdExchangeRate, + 1 + )} + + /{' '} + {t( + component.unit === 'image' + ? 'images' + : component.unit || 'unit' + )} + + + + ))} + +
+
+
+ ) + } + const defaults = Object.entries(card.defaults || {}).sort(([a], [b]) => a.localeCompare(b) ) diff --git a/web/default/src/features/pricing/lib/task-rate-card.test.ts b/web/default/src/features/pricing/lib/task-rate-card.test.ts new file mode 100644 index 00000000..9c7b07ad --- /dev/null +++ b/web/default/src/features/pricing/lib/task-rate-card.test.ts @@ -0,0 +1,132 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import assert from 'node:assert/strict' +import { describe, test } from 'node:test' +import type { + TaskRateCardPricing, + TaskRateCardPricingComponent, +} from '../types' +import { + getTaskRateCardComponentLabelKey, + hasStructuredTaskRateCard, +} from './task-rate-card' + +describe('task rate card helpers', () => { + test('recognizes only cards with a billing type and components', () => { + const baseCard: TaskRateCardPricing = { + billing_type: 'minimax', + min_unit_price: 0, + max_unit_price: 0, + rows: [], + } + + assert.equal(hasStructuredTaskRateCard(null), false) + assert.equal(hasStructuredTaskRateCard(undefined), false) + assert.equal(hasStructuredTaskRateCard(baseCard), false) + assert.equal( + hasStructuredTaskRateCard({ ...baseCard, billing_type: undefined }), + false + ) + assert.equal( + hasStructuredTaskRateCard({ ...baseCard, components: [] }), + false + ) + assert.equal( + hasStructuredTaskRateCard({ + ...baseCard, + components: [{ key: 'input_image', unit: 'image', unit_price: '0.04' }], + }), + true + ) + }) + + test('maps every supported component key and variant to its label key', () => { + const cases: Array<[TaskRateCardPricingComponent, string]> = [ + [ + { + key: 'output_video', + variant: '768P', + unit: 'second', + unit_price: '0.08', + }, + 'Output video / 768P', + ], + [ + { + key: 'output_video', + variant: '2K', + unit: 'second', + unit_price: '0.13', + }, + 'Output video / 2K', + ], + [ + { + key: 'input_video', + variant: '768P', + unit: 'second', + unit_price: '0.08', + }, + 'Input video / 768P', + ], + [ + { + key: 'input_video', + variant: '2K', + unit: 'second', + unit_price: '0.13', + }, + 'Input video / 2K', + ], + [ + { key: 'input_image', unit: 'image', unit_price: '0.04' }, + 'Extra input image price', + ], + [ + { key: 'input_audio', unit: 'second', unit_price: '0.01' }, + 'Input audio price', + ], + ] + + for (const [component, expectedLabel] of cases) { + assert.equal(getTaskRateCardComponentLabelKey(component), expectedLabel) + } + }) + + test('returns null for unsupported key and variant combinations', () => { + const unknownComponents: TaskRateCardPricingComponent[] = [ + { + key: 'output_video', + variant: '4K', + unit: 'second', + unit_price: '0.2', + }, + { + key: 'input_video', + unit: 'second', + unit_price: '0.2', + }, + { key: 'custom', variant: 'custom', unit: 'second', unit_price: '0.2' }, + ] + + for (const component of unknownComponents) { + assert.equal(getTaskRateCardComponentLabelKey(component), null) + } + }) +}) diff --git a/web/default/src/features/pricing/lib/task-rate-card.ts b/web/default/src/features/pricing/lib/task-rate-card.ts new file mode 100644 index 00000000..031886b6 --- /dev/null +++ b/web/default/src/features/pricing/lib/task-rate-card.ts @@ -0,0 +1,52 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import type { + TaskRateCardPricing, + TaskRateCardPricingComponent, +} from '../types' + +export function hasStructuredTaskRateCard( + card: TaskRateCardPricing | null | undefined +): boolean { + return Boolean(card?.billing_type && (card.components?.length ?? 0) > 0) +} + +export function getTaskRateCardComponentLabelKey( + component: TaskRateCardPricingComponent +): string | null { + if (component.key === 'output_video' && component.variant === '768P') { + return 'Output video / 768P' + } + if (component.key === 'output_video' && component.variant === '2K') { + return 'Output video / 2K' + } + if (component.key === 'input_video' && component.variant === '768P') { + return 'Input video / 768P' + } + if (component.key === 'input_video' && component.variant === '2K') { + return 'Input video / 2K' + } + if (component.key === 'input_image') { + return 'Extra input image price' + } + if (component.key === 'input_audio') { + return 'Input audio price' + } + return null +} diff --git a/web/default/src/features/pricing/types.ts b/web/default/src/features/pricing/types.ts index b55e24dc..edb6cd8f 100644 --- a/web/default/src/features/pricing/types.ts +++ b/web/default/src/features/pricing/types.ts @@ -35,9 +35,23 @@ export type TaskRateCardPricingRow = { unit_price: number } +export type TaskRateCardPricingComponent = { + key: string + variant?: string + unit: string + unit_price: string + free_quantity?: number + min_quantity?: number + max_quantity?: number +} + export type TaskRateCardPricing = { rule_key?: string vendor?: string + billing_type?: string + schema_version?: number + mode?: string + currency?: string unit?: string quantity_field?: string default_quantity?: number @@ -46,6 +60,7 @@ export type TaskRateCardPricing = { min_unit_price: number max_unit_price: number rows: TaskRateCardPricingRow[] + components?: TaskRateCardPricingComponent[] } export type PricingModel = { diff --git a/web/default/src/features/smart-ops/alerts.test.tsx b/web/default/src/features/smart-ops/alerts.test.tsx index 89646d4f..b3194ed3 100644 --- a/web/default/src/features/smart-ops/alerts.test.tsx +++ b/web/default/src/features/smart-ops/alerts.test.tsx @@ -16,6 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ +import { useMemo, useState, type ReactElement } from 'react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { createReactTestEnvironment } from '@/test/react' import { waitFor, within } from '@testing-library/react' @@ -23,6 +24,7 @@ import assert from 'node:assert/strict' import { after, describe, test } from 'node:test' import { useAuthStore } from '@/stores/auth-store' import { api } from '@/lib/api' +import { completeManualTaskBillingSettlement } from './api' import type { BillingSettlementReconciliationData } from './types' const LOAD_ERROR_KEY = 'We could not load active alerts.' @@ -39,6 +41,8 @@ const testEnv = createReactTestEnvironment({ await testEnv.setup() const { ActiveAlerts } = await import('./alerts') +const { BillingSettlementEvidence } = + await import('./components/billing-settlement-evidence') after(() => testEnv.teardown()) @@ -67,7 +71,104 @@ function emptyReconciliationData(): BillingSettlementReconciliationData { } } +function ManualSettlementEvidenceHarness(): ReactElement { + const [failed, setFailed] = useState(false) + const data = useMemo( + () => ({ + ...emptyReconciliationData(), + total_count: 1, + manual_count: 1, + open_alert_count: 1, + items: [ + { + id: 701, + revision: 4, + operation_key: 'task:9701:finalize', + status: 'manual', + source: 'wallet', + user_id: 53, + subscription_id: 0, + token_id: 54, + task_id: 9701, + task_quota: 100, + task_quota_target: 100, + requires_manual_completion: true, + funding_delta: 0, + applied_funding_delta: 0, + token_delta: 0, + applied_token_delta: 0, + attempts: 0, + last_error: 'provider usage needs verification', + next_attempt: 0, + created_at: 1786032545, + updated_at: 1786032545, + reconciliation_reviewed_at: 0, + reconciliation_reviewed_by: 0, + reconciliation_review_note: '', + user_blocking_override: null, + record_blocks_user: false, + blocks_user: false, + }, + ], + }), + [] + ) + const error = useMemo(() => new Error('temporary reconciliation failure'), []) + + return ( + <> + + undefined} + /> + + ) +} + describe('SmartOps active alerts', () => { + test('posts the exact manual task settlement contract', async (): Promise => { + const originalPost = api.post + const writes: Array<{ url: string; data: unknown; config: unknown }> = [] + api.post = (async ( + url: string, + data: unknown, + config: unknown + ): Promise => { + writes.push({ url: String(url), data, config }) + return { data: { success: true, data: { actual_quota: 0 } } } + }) as typeof api.post + + try { + const response = await completeManualTaskBillingSettlement(93, { + revision: 2, + actual_quota: 0, + note: 'Verified provider evidence and exact usage.', + }) + + assert.equal(response.success, true) + assert.deepEqual(writes, [ + { + url: '/api/smart-ops/billing-settlements/93/complete-task', + data: { + revision: 2, + actual_quota: 0, + note: 'Verified provider evidence and exact usage.', + }, + config: { skipBusinessError: true, skipErrorHandler: true }, + }, + ]) + } finally { + api.post = originalPost + } + }) + test('polls the administrator alert endpoint and renders active host pressure', async (): Promise => { const originalGet = api.get const urls: string[] = [] @@ -195,6 +296,9 @@ describe('SmartOps active alerts', () => { subscription_id: 0, token_id: 84, task_id: 0, + task_quota: 0, + task_quota_target: 0, + requires_manual_completion: false, funding_delta: 2500, applied_funding_delta: 0, token_delta: 2500, @@ -360,6 +464,86 @@ describe('SmartOps active alerts', () => { } }) + test('keeps the manual settlement dialog mounted when reconciliation fails', async (): Promise => { + const htmlElementPrototype = window.HTMLElement + .prototype as typeof window.HTMLElement.prototype & { + attachEvent?: (name: string, listener: EventListener) => void + detachEvent?: (name: string, listener: EventListener) => void + } + // React's async rendering path probes the legacy IE event API in this test + // environment; emulate it on the shared prototype and remove it below. + htmlElementPrototype.attachEvent = function (name, listener) { + this.addEventListener(name.replace(/^on/, ''), listener) + } + htmlElementPrototype.detachEvent = function (name, listener) { + this.removeEventListener(name.replace(/^on/, ''), listener) + } + const queryClient = createQueryClient() + const view = await testEnv.render( + + + + ) + + try { + await waitFor(() => { + assert.ok( + within(view.container).getByRole('button', { + name: 'Complete billing', + }) + ) + }) + const causeErrorButton = view.container.querySelector( + 'button' + ) as HTMLButtonElement + assert.equal(causeErrorButton.textContent, 'Cause reconciliation error') + await view.click( + within(view.container).getByRole('button', { + name: 'Complete billing', + }) + ) + + const dialog = await waitFor(() => { + const currentDialog = document.body.querySelector( + '[role="dialog"]' + ) as HTMLElement + assert.ok(currentDialog) + return currentDialog + }) + assert.ok(dialog.querySelector('#manual-task-actual-quota')) + + await view.click(causeErrorButton) + await waitFor(() => { + assert.ok( + (view.container.textContent ?? '').includes( + 'We could not load billing reconciliation details.' + ) + ) + }) + + const currentDialog = document.body.querySelector( + '[role="dialog"]' + ) as HTMLElement + assert.ok(currentDialog) + assert.equal(currentDialog, dialog) + assert.ok( + (currentDialog.textContent ?? '').includes( + 'This reconciliation record changed while the dialog was open.' + ) + ) + const submitButton = currentDialog.querySelector( + 'button[type="submit"]' + ) as HTMLButtonElement + assert.ok(submitButton) + assert.equal(submitButton.hasAttribute('disabled'), true) + } finally { + delete htmlElementPrototype.attachEvent + delete htmlElementPrototype.detachEvent + queryClient.clear() + await view.unmount() + } + }) + test('batch closes selected active reconciliation alerts without requesting notes', async (): Promise => { const originalUser = useAuthStore.getState().auth.user useAuthStore.getState().auth.setUser({ @@ -398,6 +582,9 @@ describe('SmartOps active alerts', () => { subscription_id: 0, token_id: 0, task_id: 0, + task_quota: 0, + task_quota_target: 0, + requires_manual_completion: false, funding_delta: 100, applied_funding_delta: 0, token_delta: 100, @@ -424,6 +611,9 @@ describe('SmartOps active alerts', () => { subscription_id: 0, token_id: 0, task_id: 0, + task_quota: 0, + task_quota_target: 0, + requires_manual_completion: false, funding_delta: 200, applied_funding_delta: 0, token_delta: 200, @@ -546,6 +736,172 @@ describe('SmartOps active alerts', () => { } }) + test('separates manual task completion from ordinary batch review', async (): Promise => { + const originalUser = useAuthStore.getState().auth.user + useAuthStore.getState().auth.setUser({ + id: 1, + username: 'root', + role: 100, + }) + const originalGet = api.get + const htmlElementPrototype = window.HTMLElement + .prototype as typeof window.HTMLElement.prototype & { + attachEvent?: (name: string, listener: EventListener) => void + detachEvent?: (name: string, listener: EventListener) => void + } + // React's async rendering path probes the legacy IE event API in this test + // environment; emulate it on the shared prototype and remove it below. + htmlElementPrototype.attachEvent = function (name, listener) { + this.addEventListener(name.replace(/^on/, ''), listener) + } + htmlElementPrototype.detachEvent = function (name, listener) { + this.removeEventListener(name.replace(/^on/, ''), listener) + } + const manualRevision = 2 + const manualCompletionRequired = true + let includeManualItem = true + api.get = (async (url: string): Promise => { + return { + data: + url === '/api/smart-ops/billing-settlements' + ? { + success: true, + data: { + ...emptyReconciliationData(), + total_count: includeManualItem ? 1 : 0, + manual_count: includeManualItem ? 1 : 0, + open_alert_count: includeManualItem ? 1 : 0, + items: includeManualItem + ? [ + { + id: 93, + revision: manualRevision, + operation_key: 'task:7001:finalize', + status: 'manual', + source: 'wallet', + user_id: 53, + subscription_id: 0, + token_id: 54, + task_id: 7001, + task_quota: 100, + task_quota_target: 100, + requires_manual_completion: manualCompletionRequired, + funding_delta: 0, + applied_funding_delta: 0, + token_delta: 0, + applied_token_delta: 0, + attempts: 0, + last_error: 'provider usage needs verification', + next_attempt: 0, + created_at: 1786032545, + updated_at: 1786032545, + reconciliation_reviewed_at: 0, + reconciliation_reviewed_by: 0, + reconciliation_review_note: '', + user_blocking_override: null, + record_blocks_user: false, + blocks_user: false, + }, + ] + : [], + }, + } + : { success: true, data: [] }, + } + }) as typeof api.get + const queryClient = createQueryClient() + const view = await testEnv.render( + + + + ) + + try { + let completeButton: HTMLElement | undefined + await waitFor(() => { + const screen = within(view.container) + completeButton = screen.getByRole('button', { + name: 'Complete billing', + }) + assert.equal( + screen + .getByRole('checkbox', { + name: 'Select billing reconciliation alert 93', + }) + .hasAttribute('data-disabled'), + true + ) + assert.equal( + screen + .getByRole('button', { + name: 'Review and close selected (0)', + }) + .hasAttribute('disabled'), + true + ) + }) + assert.ok(completeButton) + await view.click(completeButton) + + const dialog = await waitFor(() => + within(document.body).getByRole('dialog') + ) + const dialogScreen = within(dialog) + const quotaInput = dialogScreen.getByLabelText('Exact final quota') + const noteInput = dialogScreen.getByLabelText('Audit note') + const submitButton = dialogScreen.getByRole('button', { + name: 'Apply exact settlement', + }) + assert.equal(submitButton.hasAttribute('disabled'), true) + assert.equal((quotaInput as HTMLInputElement).max, '100') + assert.equal((quotaInput as HTMLInputElement).min, '0') + assert.equal((noteInput as HTMLTextAreaElement).maxLength, 1000) + assert.ok( + (dialog.textContent ?? '').includes( + 'This workflow cannot add a charge above the original reservation.' + ) + ) + + includeManualItem = false + await queryClient.invalidateQueries({ + queryKey: ['smart-ops', 'billing-settlement-reconciliation'], + }) + + const staleDialog = await waitFor(() => { + const currentDialog = within(document.body).getByRole('dialog') + assert.ok( + (currentDialog.textContent ?? '').includes( + 'This reconciliation record changed while the dialog was open.' + ) + ) + return currentDialog + }) + const staleDialogScreen = within(staleDialog) + assert.equal(staleDialog, dialog) + assert.equal( + staleDialog.querySelector('#manual-task-actual-quota'), + quotaInput + ) + assert.equal( + staleDialog.querySelector('#manual-task-audit-note'), + noteInput + ) + assert.equal( + staleDialogScreen + .getByRole('button', { name: 'Apply exact settlement' }) + .hasAttribute('disabled'), + true + ) + } finally { + api.get = originalGet + delete htmlElementPrototype.attachEvent + delete htmlElementPrototype.detachEvent + await view.unmount() + queryClient.clear() + useAuthStore.getState().auth.setUser(originalUser) + } + }) + test('clears a selected alert when refresh changes its financial revision', async (): Promise => { const originalGet = api.get const originalPost = api.post @@ -574,6 +930,9 @@ describe('SmartOps active alerts', () => { subscription_id: 0, token_id: 0, task_id: 0, + task_quota: 0, + task_quota_target: 0, + requires_manual_completion: false, funding_delta: 100, applied_funding_delta: 0, token_delta: 100, diff --git a/web/default/src/features/smart-ops/alerts.tsx b/web/default/src/features/smart-ops/alerts.tsx index d92b669d..bc4b8fb2 100644 --- a/web/default/src/features/smart-ops/alerts.tsx +++ b/web/default/src/features/smart-ops/alerts.tsx @@ -202,6 +202,7 @@ export function ActiveAlerts(): ReactElement { const { t } = useTranslation() const userRole = useAuthStore((state) => state.auth.user?.role) const canUpdateBlockingPolicy = userRole === ROLE.SUPER_ADMIN + const canCompleteManualTask = userRole === ROLE.SUPER_ADMIN const loadErrorMessage = t('We could not load active alerts.') const reconciliationErrorMessage = t( 'We could not load billing reconciliation details.' @@ -309,6 +310,7 @@ export function ActiveAlerts(): ReactElement { onRetry={() => void alertsQuery.refetch()} /> { + const response = await api.post( + `/api/smart-ops/billing-settlements/${settlementId}/complete-task`, + request, + { skipBusinessError: true, skipErrorHandler: true } + ) + return response.data +} + export async function getChannelPerformance( query: ChannelPerformanceQuery ): Promise { diff --git a/web/default/src/features/smart-ops/components/billing-settlement-evidence.tsx b/web/default/src/features/smart-ops/components/billing-settlement-evidence.tsx index 2bbfefee..add20cee 100644 --- a/web/default/src/features/smart-ops/components/billing-settlement-evidence.tsx +++ b/web/default/src/features/smart-ops/components/billing-settlement-evidence.tsx @@ -35,6 +35,7 @@ import { import { Skeleton } from '@/components/ui/skeleton' import { Switch } from '@/components/ui/switch' import { + completeManualTaskBillingSettlement, reviewBillingSettlements, updateBillingSettlementBlockingPolicy, } from '../api' @@ -46,11 +47,14 @@ import { } from '../lib/query-keys' import type { BillingSettlementReconciliationData, + BillingSettlementReconciliationItem, BillingSettlementReviewTarget, } from '../types' import { BillingSettlementTable } from './billing-settlement-table' +import { ManualTaskSettlementDialog } from './manual-task-settlement-dialog' interface BillingSettlementEvidenceProps { + canCompleteManualTask: boolean canUpdateBlockingPolicy: boolean data?: BillingSettlementReconciliationData error: Error | null @@ -65,7 +69,23 @@ export function BillingSettlementEvidence( const [selectedTargets, setSelectedTargets] = useState< Map >(() => new Map()) + const [manualTaskItem, setManualTaskItem] = + useState(null) const reconciliationItems = props.data?.items + const currentManualTaskItem = useMemo(() => { + if (!manualTaskItem) return null + return ( + reconciliationItems?.find((item) => item.id === manualTaskItem.id) ?? null + ) + }, [manualTaskItem, reconciliationItems]) + const manualTaskItemStale = Boolean( + manualTaskItem && + (props.error || + !props.data || + !currentManualTaskItem || + currentManualTaskItem.revision !== manualTaskItem.revision || + !currentManualTaskItem.requires_manual_completion) + ) const { activeSelectedTargets, activeSelectedTargetMap } = useMemo(() => { const currentRevisions = new Map( reconciliationItems?.map((item) => [item.id, item.revision]) ?? [] @@ -169,23 +189,84 @@ export function BillingSettlementEvidence( } } + const manualTaskCompletionMutation = useMutation({ + mutationKey: ['smart-ops', 'manual-task-billing-completion'], + mutationFn: async ({ + item, + actualQuota, + note, + }: { + item: BillingSettlementReconciliationItem + actualQuota: number + note: string + }): Promise => { + const response = await completeManualTaskBillingSettlement(item.id, { + revision: item.revision, + actual_quota: actualQuota, + note, + }) + if (!response.success) { + throw new Error( + response.message || t('Failed to complete manual task billing.') + ) + } + }, + onSuccess: () => { + setManualTaskItem(null) + toast.success(t('Manual task billing completed.')) + }, + onSettled: async () => { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: SMART_OPS_ACTIVE_ALERTS_QUERY_KEY, + }), + queryClient.invalidateQueries({ + queryKey: SMART_OPS_BILLING_RECONCILIATION_QUERY_KEY, + }), + ]) + }, + onError: (error) => { + const message = mutationErrorMessage( + error, + t('Failed to complete manual task billing.') + ) + handleServerError(error, { + fallback: message, + }) + }, + }) + + const manualTaskDialog = manualTaskItem ? ( + { + if (!open) setManualTaskItem(null) + }} + onSubmit={(item, actualQuota, note) => + manualTaskCompletionMutation.mutate({ item, actualQuota, note }) + } + /> + ) : null + const replaceSelectedTargets = ( targets: Map ): void => { setSelectedTargets(targets) } + let content: ReactElement if (props.loading) { - return ( + content = (
) - } - - if (props.error) { - return ( + } else if (props.error) { + content = ( ) - } - - if (!props.data) { - return <> - } - - return ( -
-
-
-

- {t('Open billing reconciliation alerts')} -

-

- {t( - 'Select one or more alerts and close them with one click. The underlying financial settlement record remains available for safe retry and audit.' - )} -

-
-
- - {formatLocalizedCount( - props.data.open_alert_count, - i18n.language, - t, - 'Open alert: {{count}}', - 'Open alerts: {{count}}' - )} - - - {t('Open pending settlements: {{count}}', { - count: formatCount(props.data.pending_count, i18n.language), - })} - - - {t('Open manual settlements: {{count}}', { - count: formatCount(props.data.manual_count, i18n.language), - })} - - - {formatLocalizedCount( - props.data.blocked_user_count, - i18n.language, - t, - 'Blocked user: {{count}}', - 'Blocked users: {{count}}' - )} - + } else if (!props.data) { + content = <> + } else { + content = ( +
+
+
+

+ {t('Open billing reconciliation alerts')} +

+

+ {t( + 'Batch-close ordinary alerts after review. Task-finalization alerts require a root administrator to enter the exact provider-backed quota before they can close.' + )} +

+
+
+ + {formatLocalizedCount( + props.data.open_alert_count, + i18n.language, + t, + 'Open alert: {{count}}', + 'Open alerts: {{count}}' + )} + + + {t('Open pending settlements: {{count}}', { + count: formatCount(props.data.pending_count, i18n.language), + })} + + + {t('Open manual settlements: {{count}}', { + count: formatCount(props.data.manual_count, i18n.language), + })} + + + {formatLocalizedCount( + props.data.blocked_user_count, + i18n.language, + t, + 'Blocked user: {{count}}', + 'Blocked users: {{count}}' + )} + +
-
- - - {t('Block affected users by default')} - - {t( - 'When enabled, new paid requests remain blocked while any unresolved positive final settlement record still blocks the user. Allowing one reviewed record does not override other blocking records.' - )} - - {!props.canUpdateBlockingPolicy && ( + + + {t('Block affected users by default')} {t( - 'Only root administrators can change the default blocking policy.' + 'When enabled, new paid requests remain blocked while any unresolved positive final settlement record still blocks the user. Allowing one reviewed record does not override other blocking records.' )} - )} - - - - - {props.data.items.length === 0 ? ( - - - ) : ( -
-
- - {t('Selected alerts: {{count}}', { - count: formatCount(activeSelectedTargets.length, i18n.language), - })} - - -
- + -
- )} +
- {props.data.truncated && ( - - - {t( - 'Showing the oldest {{count}} alerts; the summary covers all {{total}} open alerts.', - { - count: formatCount(props.data.items.length, i18n.language), - total: formatCount(props.data.total_count, i18n.language), + {props.data.items.length === 0 ? ( + + + ) : ( +
+
+ + {t('Selected alerts: {{count}}', { + count: formatCount( + activeSelectedTargets.length, + i18n.language + ), + })} + + +
+ - - )} -

- {t('Generated at {{time}}', { - time: formatTimestampToDate(props.data.generated_at), - })} -

-
+ onSelectedTargetsChange={replaceSelectedTargets} + onReviewTargets={reviewTargets} + onCompleteManualTask={setManualTaskItem} + /> + + )} + + {props.data.truncated && ( + + + {t( + 'Showing the oldest {{count}} alerts; the summary covers all {{total}} open alerts.', + { + count: formatCount(props.data.items.length, i18n.language), + total: formatCount(props.data.total_count, i18n.language), + } + )} + + + )} +

+ {t('Generated at {{time}}', { + time: formatTimestampToDate(props.data.generated_at), + })} +

+
+ ) + } + + return ( + <> + {content} + {manualTaskDialog} + ) } diff --git a/web/default/src/features/smart-ops/components/billing-settlement-table.tsx b/web/default/src/features/smart-ops/components/billing-settlement-table.tsx index 7ad5c40a..8cfbb64a 100644 --- a/web/default/src/features/smart-ops/components/billing-settlement-table.tsx +++ b/web/default/src/features/smart-ops/components/billing-settlement-table.tsx @@ -64,20 +64,75 @@ function settlementReferences( return references } +interface BillingSettlementActionsCellProps { + item: BillingSettlementReconciliationItem + reviewPending: boolean + canCompleteManualTask: boolean + onReviewTargets: (targets: BillingSettlementReviewTarget[]) => void + onCompleteManualTask: (item: BillingSettlementReconciliationItem) => void +} + +function BillingSettlementActionsCell( + props: BillingSettlementActionsCellProps +): ReactElement { + const { t } = useTranslation() + + if (props.item.requires_manual_completion) { + return ( +
+ + {!props.canCompleteManualTask && ( + + {t('Root administrator required')} + + )} +
+ ) + } + + return ( + + ) +} + interface BillingSettlementTableProps { items: BillingSettlementReconciliationItem[] + canCompleteManualTask: boolean selectedTargets: ReadonlyMap reviewPending: boolean onSelectedTargetsChange: ( targets: Map ) => void onReviewTargets: (targets: BillingSettlementReviewTarget[]) => void + onCompleteManualTask: (item: BillingSettlementReconciliationItem) => void } export function BillingSettlementTable( props: BillingSettlementTableProps ): ReactElement { const { t, i18n } = useTranslation() + const hasReviewableItems = props.items.some( + (item) => !item.requires_manual_completion + ) const { allSelected, someSelected, isSelected, toggleAll, toggleItem } = useBillingSettlementSelection({ items: props.items, @@ -95,7 +150,7 @@ export function BillingSettlementTable( checked={allSelected} indeterminate={someSelected && !allSelected} onCheckedChange={(checked) => toggleAll(checked === true)} - disabled={props.reviewPending} + disabled={props.reviewPending || !hasReviewableItems} aria-label={t('Select all billing reconciliation alerts')} /> @@ -126,7 +181,9 @@ export function BillingSettlementTable( onCheckedChange={(checked) => toggleItem(item, checked === true) } - disabled={props.reviewPending} + disabled={ + props.reviewPending || item.requires_manual_completion + } aria-label={t( 'Select billing reconciliation alert {{id}}', { @@ -150,6 +207,11 @@ export function BillingSettlementTable( {item.status === 'manual' ? t('Manual') : t('Pending')} {t('Open alert')} + {item.requires_manual_completion && ( + + {t('Exact quota required')} + + )} @@ -223,19 +285,13 @@ export function BillingSettlementTable( )} - + ) diff --git a/web/default/src/features/smart-ops/components/manual-task-settlement-dialog.tsx b/web/default/src/features/smart-ops/components/manual-task-settlement-dialog.tsx new file mode 100644 index 00000000..d1740d6a --- /dev/null +++ b/web/default/src/features/smart-ops/components/manual-task-settlement-dialog.tsx @@ -0,0 +1,228 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import { type ReactElement } from 'react' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { Loader2 } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { formatQuota } from '@/lib/format' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + Field, + FieldDescription, + FieldError, + FieldGroup, + FieldLabel, +} from '@/components/ui/field' +import { Form, FormField } from '@/components/ui/form' +import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' +import { + getManualTaskSettlementSchema, + type ManualTaskSettlementFormValues, +} from '../lib/manual-task-settlement' +import type { BillingSettlementReconciliationItem } from '../types' + +interface ManualTaskSettlementDialogProps { + item: BillingSettlementReconciliationItem | null + pending: boolean + stale: boolean + onOpenChange: (open: boolean) => void + onSubmit: ( + item: BillingSettlementReconciliationItem, + actualQuota: number, + note: string + ) => void +} + +export function ManualTaskSettlementDialog( + props: ManualTaskSettlementDialogProps +): ReactElement { + const { t } = useTranslation() + const item = props.item + const form = useForm({ + resolver: zodResolver( + getManualTaskSettlementSchema(t, props.item?.task_quota ?? 0) + ), + defaultValues: { actualQuota: '', note: '' }, + mode: 'onChange', + }) + const canSubmit = + Boolean(props.item) && !props.stale && form.formState.isValid + + const handleSubmit = form.handleSubmit((values) => { + if (props.item && !props.stale) { + props.onSubmit(props.item, Number(values.actualQuota), values.note) + } + }) + + return ( + { + if (!props.pending) props.onOpenChange(open) + }} + > + + + {t('Complete manual task billing')} + + {t( + 'Enter the exact provider-backed final quota. This action may refund the unused reservation and then unlock the task terminal state.' + )} + + + + {item && ( + <> + + + {t('Task #{{id}} · reserved {{quota}}', { + id: item.task_id, + quota: formatQuota(item.task_quota), + })} + + + {t( + 'Only an exact quota from verified provider evidence is allowed. This workflow cannot add a charge above the original reservation.' + )} + + + + {props.stale && ( + + + {t( + 'This reconciliation record changed while the dialog was open.' + )} + + + {t( + 'Close this dialog and reopen the latest record before submitting.' + )} + + + )} + +
+ + + ( + + + {t('Exact final quota')} + + + + {t('Allowed range: 0 to {{quota}}.', { + quota: formatQuota(item.task_quota), + })} + + {fieldState.error && ( + {fieldState.error.message} + )} + + )} + /> + + ( + + + {t('Audit note')} + +