From 0ef4b4f876999a404a3905517dae7927b55d8070 Mon Sep 17 00:00:00 2001 From: modelsbridgeaicom-ship-it <290653746+modelsbridgeaicom-ship-it@users.noreply.github.com> Date: Sat, 6 Jun 2026 01:29:05 +0800 Subject: [PATCH] Add compute budget billing guard --- README.md | 4 + revenue-compute-budget-guard/README.md | 51 ++++ .../artifacts/demo-output.json | 122 ++++++++ .../artifacts/demo-report.md | 53 ++++ revenue-compute-budget-guard/budgetGuard.js | 267 ++++++++++++++++++ revenue-compute-budget-guard/demo.js | 30 ++ revenue-compute-budget-guard/sample-data.json | 61 ++++ revenue-compute-budget-guard/test.js | 61 ++++ 8 files changed, 649 insertions(+) create mode 100644 revenue-compute-budget-guard/README.md create mode 100644 revenue-compute-budget-guard/artifacts/demo-output.json create mode 100644 revenue-compute-budget-guard/artifacts/demo-report.md create mode 100644 revenue-compute-budget-guard/budgetGuard.js create mode 100644 revenue-compute-budget-guard/demo.js create mode 100644 revenue-compute-budget-guard/sample-data.json create mode 100644 revenue-compute-budget-guard/test.js diff --git a/README.md b/README.md index d338cf68..99914c97 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,6 @@ # deepevents.ai deepevents.ai main codebase + +## Revenue Infrastructure Guards + +- [Revenue Compute Budget Guard](./revenue-compute-budget-guard/) - validates AI compute spend thresholds, top-up coverage, customer notice freshness, and invoice release readiness. diff --git a/revenue-compute-budget-guard/README.md b/revenue-compute-budget-guard/README.md new file mode 100644 index 00000000..89912ea3 --- /dev/null +++ b/revenue-compute-budget-guard/README.md @@ -0,0 +1,51 @@ +# Revenue Compute Budget Guard + +This module adds an audit-ready guard for the AI compute billing stream in +SCIBASE revenue infrastructure. It checks whether metered compute usage can be +released to billing before customer budget notices, top-up coverage, and hard +cap controls are satisfied. + +The guard is intentionally dependency-free and uses synthetic sample data only. +It does not connect to payment processors, external APIs, customer accounts, or +private billing data. + +## What It Covers + +- Account-level compute spend from billable usage events +- Approved budget plus prepaid top-up balance +- Warning and critical threshold decisions +- Freshness of customer budget notices +- Missing finance/account-owner notification recipients +- Hard-cap billing hold enforcement +- Draft invoice amount mismatches +- Reviewer-ready JSON and Markdown evidence output + +## Decisions + +| Decision | Meaning | +| --- | --- | +| `CLEAR_TO_BILL` | Spend is within approved controls and invoice amount matches the ledger. | +| `SEND_NOTICE_BEFORE_BILLING` | Billing can proceed only after warning/projection notice issues are remediated. | +| `HOLD_COMPUTE_BILLING` | Critical overage, missing budget, or hard-cap control gaps block invoice release. | + +## Run Locally + +```bash +node revenue-compute-budget-guard/test.js +node revenue-compute-budget-guard/demo.js +``` + +The demo writes: + +- `revenue-compute-budget-guard/artifacts/demo-output.json` +- `revenue-compute-budget-guard/artifacts/demo-report.md` + +## Requirement Map + +| Issue #20 revenue infrastructure requirement | Implementation | +| --- | --- | +| Usage-based pricing for heavy backend processing tasks | `usageEvents` are metered into billable compute spend. | +| Transparent quotas and real-time usage meters | Budget, spend ratio, projected period-end spend, and allowed invoice amount are returned per account. | +| Bundle tokens or allow top-ups | `topUpBalanceCents` is included in the approved spend limit and overage decision. | +| Institutional billing governance | Draft invoice mismatch and hard-cap hold findings create reviewer-ready finance remediation evidence. | +| No live payment or credential handling | The module is dependency-free and runs only on synthetic JSON sample data. | diff --git a/revenue-compute-budget-guard/artifacts/demo-output.json b/revenue-compute-budget-guard/artifacts/demo-output.json new file mode 100644 index 00000000..da8ea5b3 --- /dev/null +++ b/revenue-compute-budget-guard/artifacts/demo-output.json @@ -0,0 +1,122 @@ +{ + "generatedAt": "2026-06-06T00:00:00Z", + "portfolioId": "revenue-infra-demo", + "accounts": [ + { + "accountId": "lab-clear-001", + "plan": "Research Lab Pro", + "decision": "CLEAR_TO_BILL", + "spendCents": 78000, + "budgetCents": 700000, + "creditCents": 0, + "allowedInvoiceCents": 78000, + "spendRatio": 0.111, + "projectedSpendCents": 468000, + "projectedRatio": 0.669, + "findings": [] + }, + { + "accountId": "lab-warning-002", + "plan": "Institutional AI Compute", + "decision": "SEND_NOTICE_BEFORE_BILLING", + "spendCents": 79800, + "budgetCents": 100000, + "creditCents": 25000, + "allowedInvoiceCents": 79800, + "spendRatio": 0.798, + "projectedSpendCents": 478800, + "projectedRatio": 4.788, + "findings": [ + { + "severity": "high", + "code": "BUDGET_THRESHOLD_NOTICE_STALE", + "message": "Budget threshold was crossed without a fresh customer notice.", + "remediation": "Send or refresh the budget threshold notice before billing the overage.", + "evidence": { + "threshold": 0.75, + "latestNoticeAt": "2026-06-02T09:00:00Z", + "noticeFreshnessHours": 48 + } + } + ] + }, + { + "accountId": "lab-hold-003", + "plan": "Enterprise Compute", + "decision": "HOLD_COMPUTE_BILLING", + "spendCents": 105500, + "budgetCents": 80000, + "creditCents": 10000, + "allowedInvoiceCents": 90000, + "spendRatio": 1.319, + "projectedSpendCents": 633000, + "projectedRatio": 7.912, + "findings": [ + { + "severity": "high", + "code": "BUDGET_NOTICE_CONTACTS_MISSING", + "message": "Budget threshold was crossed, but the account does not have enough notice recipients.", + "remediation": "Add finance or account-owner recipients before releasing the compute invoice.", + "evidence": { + "contacts": [], + "minimumContacts": 1 + } + }, + { + "severity": "high", + "code": "BUDGET_THRESHOLD_NOTICE_STALE", + "message": "Budget threshold was crossed without a fresh customer notice.", + "remediation": "Send or refresh the budget threshold notice before billing the overage.", + "evidence": { + "threshold": 0.75, + "latestNoticeAt": null, + "noticeFreshnessHours": 48 + } + }, + { + "severity": "critical", + "code": "HARD_CAP_WITHOUT_BILLING_HOLD", + "message": "Compute usage reached the hard-cap threshold but billing hold controls are not enabled.", + "remediation": "Enable billing hold or record an explicit finance override before invoice release.", + "evidence": { + "spendCents": 105500, + "budgetCents": 80000, + "criticalThreshold": 1 + } + }, + { + "severity": "critical", + "code": "UNCOVERED_COMPUTE_OVERAGE", + "message": "Compute spend exceeds the approved budget plus prepaid top-up balance.", + "remediation": "Hold the invoice, request top-up authorization, or cap the billable amount to the approved limit.", + "evidence": { + "spendCents": 105500, + "approvedLimitCents": 90000, + "uncoveredCents": 15500 + } + }, + { + "severity": "high", + "code": "COMPUTE_INVOICE_AMOUNT_MISMATCH", + "message": "Draft compute invoice does not match the approved billable amount.", + "remediation": "Regenerate the invoice from the metered usage ledger and approved budget/top-up limit.", + "evidence": { + "invoiceId": "draft-hold-003", + "invoiceAmountCents": 105500, + "allowedInvoiceCents": 90000 + } + } + ] + } + ], + "totals": { + "spendCents": 263300, + "approvedLimitCents": 915000, + "allowedInvoiceCents": 247800 + }, + "decisionCounts": { + "CLEAR_TO_BILL": 1, + "SEND_NOTICE_BEFORE_BILLING": 1, + "HOLD_COMPUTE_BILLING": 1 + } +} diff --git a/revenue-compute-budget-guard/artifacts/demo-report.md b/revenue-compute-budget-guard/artifacts/demo-report.md new file mode 100644 index 00000000..e434634d --- /dev/null +++ b/revenue-compute-budget-guard/artifacts/demo-report.md @@ -0,0 +1,53 @@ +# Compute Budget Guard Report + +Generated: 2026-06-06T00:00:00Z +Portfolio: revenue-infra-demo + +## Totals + +- Metered compute spend: $2633.00 +- Approved budget plus top-ups: $9150.00 +- Approved invoiceable amount: $2478.00 + +## Account Decisions + +### lab-clear-001 + +- Decision: CLEAR_TO_BILL +- Plan: Research Lab Pro +- Spend: $780.00 of $7000.00 budget +- Top-up credit: $0.00 +- Projected spend: $4680.00 + +No findings. + +### lab-warning-002 + +- Decision: SEND_NOTICE_BEFORE_BILLING +- Plan: Institutional AI Compute +- Spend: $798.00 of $1000.00 budget +- Top-up credit: $250.00 +- Projected spend: $4788.00 + +- [HIGH] BUDGET_THRESHOLD_NOTICE_STALE: Budget threshold was crossed without a fresh customer notice. + - Remediation: Send or refresh the budget threshold notice before billing the overage. + +### lab-hold-003 + +- Decision: HOLD_COMPUTE_BILLING +- Plan: Enterprise Compute +- Spend: $1055.00 of $800.00 budget +- Top-up credit: $100.00 +- Projected spend: $6330.00 + +- [HIGH] BUDGET_NOTICE_CONTACTS_MISSING: Budget threshold was crossed, but the account does not have enough notice recipients. + - Remediation: Add finance or account-owner recipients before releasing the compute invoice. +- [HIGH] BUDGET_THRESHOLD_NOTICE_STALE: Budget threshold was crossed without a fresh customer notice. + - Remediation: Send or refresh the budget threshold notice before billing the overage. +- [CRITICAL] HARD_CAP_WITHOUT_BILLING_HOLD: Compute usage reached the hard-cap threshold but billing hold controls are not enabled. + - Remediation: Enable billing hold or record an explicit finance override before invoice release. +- [CRITICAL] UNCOVERED_COMPUTE_OVERAGE: Compute spend exceeds the approved budget plus prepaid top-up balance. + - Remediation: Hold the invoice, request top-up authorization, or cap the billable amount to the approved limit. +- [HIGH] COMPUTE_INVOICE_AMOUNT_MISMATCH: Draft compute invoice does not match the approved billable amount. + - Remediation: Regenerate the invoice from the metered usage ledger and approved budget/top-up limit. + diff --git a/revenue-compute-budget-guard/budgetGuard.js b/revenue-compute-budget-guard/budgetGuard.js new file mode 100644 index 00000000..eacb31e1 --- /dev/null +++ b/revenue-compute-budget-guard/budgetGuard.js @@ -0,0 +1,267 @@ +const DEFAULT_POLICY = { + warningThreshold: 0.75, + criticalThreshold: 1, + noticeFreshnessHours: 48, + minimumContacts: 1, +}; + +function cents(value) { + return Math.round(Number(value || 0)); +} + +function money(value) { + return `$${(cents(value) / 100).toFixed(2)}`; +} + +function hoursBetween(later, earlier) { + return (new Date(later).getTime() - new Date(earlier).getTime()) / 36e5; +} + +function sumUsage(events) { + return events + .filter((event) => event.billable !== false) + .reduce((total, event) => { + return total + Number(event.units || 0) * cents(event.unitPriceCents); + }, 0); +} + +function latestNoticeForThreshold(notices, threshold) { + return notices + .filter((notice) => Number(notice.threshold) >= threshold) + .sort((a, b) => new Date(b.sentAt).getTime() - new Date(a.sentAt).getTime())[0]; +} + +function projectSpend(spendCents, periodStart, periodEnd, now) { + const elapsedHours = Math.max(hoursBetween(now, periodStart), 1); + const totalHours = Math.max(hoursBetween(periodEnd, periodStart), elapsedHours); + return Math.round((spendCents / elapsedHours) * totalHours); +} + +function evaluateAccount(account, options = {}) { + const policy = { + ...DEFAULT_POLICY, + ...(account.policy || {}), + ...(options.policy || {}), + }; + const now = options.now || new Date().toISOString(); + const budgetCents = cents(account.budgetCents); + const creditCents = cents(account.topUpBalanceCents); + const spendCents = cents(sumUsage(account.usageEvents || [])); + const approvedLimitCents = budgetCents + creditCents; + const allowedInvoiceCents = Math.min(spendCents, approvedLimitCents); + const spendRatio = budgetCents > 0 ? spendCents / budgetCents : Infinity; + const projectedSpendCents = projectSpend(spendCents, account.periodStart, account.periodEnd, now); + const projectedRatio = budgetCents > 0 ? projectedSpendCents / budgetCents : Infinity; + const findings = []; + + function addFinding(severity, code, message, remediation, evidence = {}) { + findings.push({ + severity, + code, + message, + remediation, + evidence, + }); + } + + if (budgetCents <= 0 && spendCents > 0) { + addFinding( + "critical", + "MISSING_APPROVED_BUDGET", + "Billable compute usage exists without an approved account budget.", + "Hold compute billing until finance attaches an approved budget or top-up authorization.", + { spendCents } + ); + } + + if (spendRatio >= policy.warningThreshold) { + const notice = latestNoticeForThreshold(account.thresholdNotices || [], policy.warningThreshold); + const noticeIsFresh = + notice && hoursBetween(now, notice.sentAt) <= Number(policy.noticeFreshnessHours); + + if ((account.notificationContacts || []).length < policy.minimumContacts) { + addFinding( + "high", + "BUDGET_NOTICE_CONTACTS_MISSING", + "Budget threshold was crossed, but the account does not have enough notice recipients.", + "Add finance or account-owner recipients before releasing the compute invoice.", + { + contacts: account.notificationContacts || [], + minimumContacts: policy.minimumContacts, + } + ); + } + + if (!noticeIsFresh) { + addFinding( + "high", + "BUDGET_THRESHOLD_NOTICE_STALE", + "Budget threshold was crossed without a fresh customer notice.", + "Send or refresh the budget threshold notice before billing the overage.", + { + threshold: policy.warningThreshold, + latestNoticeAt: notice ? notice.sentAt : null, + noticeFreshnessHours: policy.noticeFreshnessHours, + } + ); + } + } + + if (projectedRatio >= policy.warningThreshold && spendRatio < policy.warningThreshold) { + addFinding( + "medium", + "PROJECTED_BUDGET_WARNING", + "Projected period-end compute spend will cross the warning threshold.", + "Queue a proactive budget warning so the customer can top up or reduce usage before invoice release.", + { + projectedSpendCents, + projectedRatio: Number(projectedRatio.toFixed(3)), + } + ); + } + + if (spendRatio >= policy.criticalThreshold && account.billingHoldEnabled !== true) { + addFinding( + "critical", + "HARD_CAP_WITHOUT_BILLING_HOLD", + "Compute usage reached the hard-cap threshold but billing hold controls are not enabled.", + "Enable billing hold or record an explicit finance override before invoice release.", + { + spendCents, + budgetCents, + criticalThreshold: policy.criticalThreshold, + } + ); + } + + if (spendCents > approvedLimitCents) { + addFinding( + "critical", + "UNCOVERED_COMPUTE_OVERAGE", + "Compute spend exceeds the approved budget plus prepaid top-up balance.", + "Hold the invoice, request top-up authorization, or cap the billable amount to the approved limit.", + { + spendCents, + approvedLimitCents, + uncoveredCents: spendCents - approvedLimitCents, + } + ); + } + + const draftInvoice = (account.invoices || []).find((invoice) => + ["draft", "pending"].includes(invoice.status) + ); + if (draftInvoice && cents(draftInvoice.amountCents) !== allowedInvoiceCents) { + addFinding( + "high", + "COMPUTE_INVOICE_AMOUNT_MISMATCH", + "Draft compute invoice does not match the approved billable amount.", + "Regenerate the invoice from the metered usage ledger and approved budget/top-up limit.", + { + invoiceId: draftInvoice.invoiceId, + invoiceAmountCents: cents(draftInvoice.amountCents), + allowedInvoiceCents, + } + ); + } + + const decision = findings.some((finding) => finding.severity === "critical") + ? "HOLD_COMPUTE_BILLING" + : findings.length > 0 + ? "SEND_NOTICE_BEFORE_BILLING" + : "CLEAR_TO_BILL"; + + return { + accountId: account.accountId, + plan: account.plan, + decision, + spendCents, + budgetCents, + creditCents, + allowedInvoiceCents, + spendRatio: budgetCents > 0 ? Number(spendRatio.toFixed(3)) : null, + projectedSpendCents, + projectedRatio: budgetCents > 0 ? Number(projectedRatio.toFixed(3)) : null, + findings, + }; +} + +function evaluatePortfolio(portfolio, options = {}) { + const accounts = portfolio.accounts.map((account) => evaluateAccount(account, options)); + const decisionCounts = accounts.reduce((counts, account) => { + counts[account.decision] = (counts[account.decision] || 0) + 1; + return counts; + }, {}); + + return { + generatedAt: options.now || new Date().toISOString(), + portfolioId: portfolio.portfolioId, + accounts, + totals: { + spendCents: accounts.reduce((total, account) => total + account.spendCents, 0), + approvedLimitCents: accounts.reduce( + (total, account) => total + account.budgetCents + account.creditCents, + 0 + ), + allowedInvoiceCents: accounts.reduce( + (total, account) => total + account.allowedInvoiceCents, + 0 + ), + }, + decisionCounts, + }; +} + +function renderMarkdownReport(assessment) { + const lines = [ + `# Compute Budget Guard Report`, + "", + `Generated: ${assessment.generatedAt}`, + `Portfolio: ${assessment.portfolioId}`, + "", + `## Totals`, + "", + `- Metered compute spend: ${money(assessment.totals.spendCents)}`, + `- Approved budget plus top-ups: ${money(assessment.totals.approvedLimitCents)}`, + `- Approved invoiceable amount: ${money(assessment.totals.allowedInvoiceCents)}`, + "", + `## Account Decisions`, + "", + ]; + + for (const account of assessment.accounts) { + lines.push( + `### ${account.accountId}`, + "", + `- Decision: ${account.decision}`, + `- Plan: ${account.plan}`, + `- Spend: ${money(account.spendCents)} of ${money(account.budgetCents)} budget`, + `- Top-up credit: ${money(account.creditCents)}`, + `- Projected spend: ${money(account.projectedSpendCents)}`, + "" + ); + + if (account.findings.length === 0) { + lines.push(`No findings.`, ""); + continue; + } + + for (const finding of account.findings) { + lines.push( + `- [${finding.severity.toUpperCase()}] ${finding.code}: ${finding.message}`, + ` - Remediation: ${finding.remediation}` + ); + } + lines.push(""); + } + + return `${lines.join("\n")}\n`; +} + +module.exports = { + DEFAULT_POLICY, + evaluateAccount, + evaluatePortfolio, + renderMarkdownReport, + money, +}; diff --git a/revenue-compute-budget-guard/demo.js b/revenue-compute-budget-guard/demo.js new file mode 100644 index 00000000..6fe3b742 --- /dev/null +++ b/revenue-compute-budget-guard/demo.js @@ -0,0 +1,30 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const sample = require("./sample-data.json"); +const { evaluatePortfolio, renderMarkdownReport } = require("./budgetGuard"); + +const now = "2026-06-06T00:00:00Z"; +const assessment = evaluatePortfolio(sample, { now }); +const outDir = path.join(__dirname, "artifacts"); + +fs.mkdirSync(outDir, { recursive: true }); +fs.writeFileSync( + path.join(outDir, "demo-output.json"), + `${JSON.stringify(assessment, null, 2)}\n` +); +fs.writeFileSync(path.join(outDir, "demo-report.md"), renderMarkdownReport(assessment)); + +console.log("Compute budget guard demo"); +console.log(`Portfolio: ${assessment.portfolioId}`); +console.log(`Generated: ${assessment.generatedAt}`); +console.log(""); +for (const account of assessment.accounts) { + console.log(`${account.accountId}: ${account.decision}`); + for (const finding of account.findings) { + console.log(` - ${finding.severity.toUpperCase()} ${finding.code}`); + } +} +console.log(""); +console.log("Artifacts written:"); +console.log(`- ${path.join("artifacts", "demo-output.json")}`); +console.log(`- ${path.join("artifacts", "demo-report.md")}`); diff --git a/revenue-compute-budget-guard/sample-data.json b/revenue-compute-budget-guard/sample-data.json new file mode 100644 index 00000000..173f4eaf --- /dev/null +++ b/revenue-compute-budget-guard/sample-data.json @@ -0,0 +1,61 @@ +{ + "portfolioId": "revenue-infra-demo", + "accounts": [ + { + "accountId": "lab-clear-001", + "plan": "Research Lab Pro", + "periodStart": "2026-06-01T00:00:00Z", + "periodEnd": "2026-06-30T23:59:59Z", + "budgetCents": 700000, + "topUpBalanceCents": 0, + "notificationContacts": ["finance@lab-clear.example"], + "billingHoldEnabled": true, + "usageEvents": [ + { "jobId": "job-001", "service": "literature-review", "units": 1200, "unitPriceCents": 35 }, + { "jobId": "job-002", "service": "repro-check", "units": 900, "unitPriceCents": 40 } + ], + "thresholdNotices": [], + "invoices": [ + { "invoiceId": "draft-clear-001", "status": "draft", "amountCents": 78000 } + ] + }, + { + "accountId": "lab-warning-002", + "plan": "Institutional AI Compute", + "periodStart": "2026-06-01T00:00:00Z", + "periodEnd": "2026-06-30T23:59:59Z", + "budgetCents": 100000, + "topUpBalanceCents": 25000, + "notificationContacts": ["ops@lab-warning.example"], + "billingHoldEnabled": true, + "usageEvents": [ + { "jobId": "job-101", "service": "model-inference", "units": 1800, "unitPriceCents": 35 }, + { "jobId": "job-102", "service": "dataset-validation", "units": 420, "unitPriceCents": 40 } + ], + "thresholdNotices": [ + { "threshold": 0.75, "sentAt": "2026-06-02T09:00:00Z", "channel": "email", "recipientCount": 1 } + ], + "invoices": [ + { "invoiceId": "draft-warning-002", "status": "draft", "amountCents": 79800 } + ] + }, + { + "accountId": "lab-hold-003", + "plan": "Enterprise Compute", + "periodStart": "2026-06-01T00:00:00Z", + "periodEnd": "2026-06-30T23:59:59Z", + "budgetCents": 80000, + "topUpBalanceCents": 10000, + "notificationContacts": [], + "billingHoldEnabled": false, + "usageEvents": [ + { "jobId": "job-201", "service": "model-training", "units": 2200, "unitPriceCents": 40 }, + { "jobId": "job-202", "service": "batch-inference", "units": 350, "unitPriceCents": 50 } + ], + "thresholdNotices": [], + "invoices": [ + { "invoiceId": "draft-hold-003", "status": "draft", "amountCents": 105500 } + ] + } + ] +} diff --git a/revenue-compute-budget-guard/test.js b/revenue-compute-budget-guard/test.js new file mode 100644 index 00000000..76eca990 --- /dev/null +++ b/revenue-compute-budget-guard/test.js @@ -0,0 +1,61 @@ +const assert = require("node:assert/strict"); +const sample = require("./sample-data.json"); +const { evaluateAccount, evaluatePortfolio, renderMarkdownReport } = require("./budgetGuard"); + +const now = "2026-06-06T00:00:00Z"; + +const clearAccount = evaluateAccount(sample.accounts[0], { now }); +assert.equal(clearAccount.decision, "CLEAR_TO_BILL"); +assert.equal(clearAccount.findings.length, 0); +assert.equal(clearAccount.allowedInvoiceCents, 78000); + +const warningAccount = evaluateAccount(sample.accounts[1], { now }); +assert.equal(warningAccount.decision, "SEND_NOTICE_BEFORE_BILLING"); +assert.equal( + warningAccount.findings.some((finding) => finding.code === "BUDGET_THRESHOLD_NOTICE_STALE"), + true +); +assert.equal(warningAccount.allowedInvoiceCents, 79800); + +const holdAccount = evaluateAccount(sample.accounts[2], { now }); +assert.equal(holdAccount.decision, "HOLD_COMPUTE_BILLING"); +assert.equal( + holdAccount.findings.some((finding) => finding.code === "UNCOVERED_COMPUTE_OVERAGE"), + true +); +assert.equal( + holdAccount.findings.some((finding) => finding.code === "HARD_CAP_WITHOUT_BILLING_HOLD"), + true +); +assert.equal(holdAccount.allowedInvoiceCents, 90000); + +const projectedAccount = evaluateAccount( + { + ...sample.accounts[0], + accountId: "projected-warning", + budgetCents: 300000, + invoices: [{ invoiceId: "draft-projected", status: "draft", amountCents: 78000 }], + }, + { now } +); +assert.equal(projectedAccount.decision, "SEND_NOTICE_BEFORE_BILLING"); +assert.equal( + projectedAccount.findings.some((finding) => finding.code === "PROJECTED_BUDGET_WARNING"), + true +); + +const portfolio = evaluatePortfolio(sample, { now }); +assert.deepEqual(portfolio.decisionCounts, { + CLEAR_TO_BILL: 1, + SEND_NOTICE_BEFORE_BILLING: 1, + HOLD_COMPUTE_BILLING: 1, +}); +assert.equal(portfolio.totals.spendCents, 263300); +assert.equal(portfolio.totals.allowedInvoiceCents, 247800); + +const report = renderMarkdownReport(portfolio); +assert.match(report, /Compute Budget Guard Report/); +assert.match(report, /lab-hold-003/); +assert.match(report, /HOLD_COMPUTE_BILLING/); + +console.log("revenue-compute-budget-guard tests passed");