diff --git a/revenue-trial-pricing-guard/README.md b/revenue-trial-pricing-guard/README.md new file mode 100644 index 00000000..689d563d --- /dev/null +++ b/revenue-trial-pricing-guard/README.md @@ -0,0 +1,42 @@ +# Revenue Trial Pricing Guard + +This module is a focused Revenue Infrastructure slice for issue #20. It validates free trials, coupons, annual/volume discounts, and consortium pricing before subscription invoices are released. + +The guard is intentionally dependency-free and synthetic-data-only. It does not call Stripe, PayPal, invoice providers, wallets, external APIs, or private billing systems. + +## What It Checks + +- Reused free trials on the same billing identity +- Trial overruns beyond the configured trial window +- Missing conversion evidence before trial-to-paid billing +- Expired coupons +- Coupon-to-plan mismatches +- Consortium discounts used by accounts that fail institution, domain, or minimum-seat requirements +- Non-stackable coupons combined on one invoice +- Annual, volume, coupon, and consortium discounts exceeding the configured discount cap + +## Reviewer Commands + +```bash +node revenue-trial-pricing-guard/test.js +node revenue-trial-pricing-guard/demo.js +``` + +The demo writes reviewer artifacts to `revenue-trial-pricing-guard/artifacts/`: + +- `demo-output.json` +- `reviewer-report.md` +- `discount-risk-map.svg` +- `trial-pricing-guard-demo.mp4` + +## Issue #20 Mapping + +- Tiered subscription billing: evaluates plan-specific coupon eligibility, billing cycles, volume discounts, and consortium pricing. +- Free trials and coupons: blocks trial reuse, expired discounts, missing conversion evidence, and unsafe coupon stacking. +- Institutional pricing: verifies consortium eligibility using synthetic institution IDs, seat thresholds, and email domains. +- Invoice safety: emits deterministic `RELEASE_INVOICE`, `REVIEW_BEFORE_RELEASE`, or `HOLD_INVOICE` decisions before invoice release. +- Privacy and safety: uses synthetic fixtures only, with no real customers, secrets, credentials, payment processors, or external services. + +## Why This Is Distinct + +Existing same-issue slices cover broad billing ledgers, compute metering, tax/VAT, invoice delivery, quota rollover, compute replay, collections, analytics-seat licensing, and data-product freshness. This guard focuses narrowly on discount integrity for trials, coupons, and consortium pricing before invoices leave the platform. diff --git a/revenue-trial-pricing-guard/artifacts/demo-output.json b/revenue-trial-pricing-guard/artifacts/demo-output.json new file mode 100644 index 00000000..74efd5dd --- /dev/null +++ b/revenue-trial-pricing-guard/artifacts/demo-output.json @@ -0,0 +1,198 @@ +{ + "summary": { + "invoiceDate": "2026-06-04", + "accountCount": 5, + "releaseCount": 1, + "reviewCount": 0, + "holdCount": 4, + "totalAtRiskCents": 4437850, + "totalNetInvoiceCents": 5875796, + "totalAtRiskDollars": 44378.5, + "totalNetInvoiceDollars": 58757.96 + }, + "accounts": [ + { + "accountId": "acct-clean-lab", + "accountName": "Clean Lab Renewal", + "plan": "lab_group", + "baseCents": 758400, + "discountCents": 75840, + "netInvoiceCents": 682560, + "effectiveDiscountPercent": 0.1, + "action": "RELEASE_INVOICE", + "findings": [], + "discountEvents": [ + { + "type": "annual", + "id": "annual_cycle", + "amountCents": 75840 + } + ], + "atRiskCents": 0 + }, + { + "accountId": "acct-repeat-trial", + "accountName": "Repeat Trial Startup", + "plan": "individual_pro", + "baseCents": 5800, + "discountCents": 1160, + "netInvoiceCents": 4640, + "effectiveDiscountPercent": 0.2, + "action": "HOLD_INVOICE", + "findings": [ + { + "severity": "block", + "code": "trial_reuse", + "message": "Free trial was reused after a prior trial on the same billing identity.", + "atRiskCents": 5800 + }, + { + "severity": "block", + "code": "trial_overrun", + "message": "Trial age is 36 days, above the 30-day policy.", + "atRiskCents": 5800 + }, + { + "severity": "review", + "code": "missing_conversion_evidence", + "message": "Trial account has no conversion evidence before invoice release.", + "atRiskCents": 1450 + }, + { + "severity": "review", + "code": "coupon_missing_conversion_evidence", + "message": "Coupon trial_conversion_20 requires conversion evidence before billing.", + "atRiskCents": 580 + } + ], + "discountEvents": [ + { + "type": "coupon", + "id": "trial_conversion_20", + "amountCents": 1160 + } + ], + "atRiskCents": 13630 + }, + { + "accountId": "acct-expired-coupon", + "accountName": "Expired Promo Lab", + "plan": "lab_group", + "baseCents": 94800, + "discountCents": 21804, + "netInvoiceCents": 72996, + "effectiveDiscountPercent": 0.23, + "action": "HOLD_INVOICE", + "findings": [ + { + "severity": "block", + "code": "expired_coupon", + "message": "Coupon spring_promo_15 expired on 2026-05-15.", + "atRiskCents": 14220 + } + ], + "discountEvents": [ + { + "type": "coupon", + "id": "spring_promo_15", + "amountCents": 14220 + }, + { + "type": "volume", + "id": "seat_volume", + "amountCents": 7584 + } + ], + "atRiskCents": 14220 + }, + { + "accountId": "acct-consortium-mismatch", + "accountName": "Ineligible Consortium Invoice", + "plan": "institutional", + "baseCents": 3528000, + "discountCents": 1587600, + "netInvoiceCents": 1940400, + "effectiveDiscountPercent": 0.45, + "action": "HOLD_INVOICE", + "findings": [ + { + "severity": "block", + "code": "consortium_ineligible", + "message": "Coupon consortium_research_30 requires consortium eligibility that this account does not satisfy.", + "atRiskCents": 1058400 + }, + { + "severity": "review", + "code": "discount_cap_exceeded", + "message": "Effective discount 45% exceeds cap 40%.", + "atRiskCents": 176400 + } + ], + "discountEvents": [ + { + "type": "coupon", + "id": "consortium_research_30", + "amountCents": 1058400 + }, + { + "type": "annual", + "id": "annual_cycle", + "amountCents": 352800 + }, + { + "type": "volume", + "id": "seat_volume", + "amountCents": 176400 + } + ], + "atRiskCents": 1234800 + }, + { + "accountId": "acct-stack-conflict", + "accountName": "Stacked Discount Consortium", + "plan": "institutional", + "baseCents": 7056000, + "discountCents": 3880800, + "netInvoiceCents": 3175200, + "effectiveDiscountPercent": 0.55, + "action": "HOLD_INVOICE", + "findings": [ + { + "severity": "block", + "code": "non_stackable_coupon_combo", + "message": "One or more non-stackable coupons were combined on the same invoice.", + "atRiskCents": 2116800 + }, + { + "severity": "review", + "code": "discount_cap_exceeded", + "message": "Effective discount 55% exceeds cap 40%.", + "atRiskCents": 1058400 + } + ], + "discountEvents": [ + { + "type": "coupon", + "id": "consortium_research_30", + "amountCents": 2116800 + }, + { + "type": "coupon", + "id": "accessibility_credit_10", + "amountCents": 705600 + }, + { + "type": "annual", + "id": "annual_cycle", + "amountCents": 705600 + }, + { + "type": "volume", + "id": "seat_volume", + "amountCents": 352800 + } + ], + "atRiskCents": 3175200 + } + ] +} diff --git a/revenue-trial-pricing-guard/artifacts/discount-risk-map.svg b/revenue-trial-pricing-guard/artifacts/discount-risk-map.svg new file mode 100644 index 00000000..c5dc5390 --- /dev/null +++ b/revenue-trial-pricing-guard/artifacts/discount-risk-map.svg @@ -0,0 +1,25 @@ + + +Revenue discount risk map +Blocked invoices: 4 | Review: 0 | Total at risk: $44378.50 +acct-clean-lab + + +$0.00 at risk +acct-repeat-trial + + +$136.30 at risk +acct-expired-coupon + + +$142.20 at risk +acct-consortium-mismatch + + +$12348.00 at risk +acct-stack-conflict + + +$31752.00 at risk + \ No newline at end of file diff --git a/revenue-trial-pricing-guard/artifacts/reviewer-report.md b/revenue-trial-pricing-guard/artifacts/reviewer-report.md new file mode 100644 index 00000000..e4dda65f --- /dev/null +++ b/revenue-trial-pricing-guard/artifacts/reviewer-report.md @@ -0,0 +1,30 @@ +# Trial/Coupon/Consortium Pricing Guard Report + +Invoice date: 2026-06-04 + +## Portfolio Summary + +- Accounts evaluated: 5 +- Released invoices: 1 +- Review holds: 0 +- Blocked invoices: 4 +- Net invoice amount: $58757.96 +- Discount risk detected: $44378.50 + +## Account Decisions + +| Account | Action | Net invoice | At risk | Finding codes | +| --- | --- | ---: | ---: | --- | +| acct-clean-lab | RELEASE_INVOICE | $6825.60 | $0.00 | none | +| acct-repeat-trial | HOLD_INVOICE | $46.40 | $136.30 | trial_reuse, trial_overrun, missing_conversion_evidence, coupon_missing_conversion_evidence | +| acct-expired-coupon | HOLD_INVOICE | $729.96 | $142.20 | expired_coupon | +| acct-consortium-mismatch | HOLD_INVOICE | $19404.00 | $12348.00 | consortium_ineligible, discount_cap_exceeded | +| acct-stack-conflict | HOLD_INVOICE | $31752.00 | $31752.00 | non_stackable_coupon_combo, discount_cap_exceeded | + +## Revenue Infrastructure Mapping + +- Tiered subscription billing: checks plan-level coupon eligibility, billing cycle discounts, and seat-volume conflicts. +- Free trials and coupons: blocks reused trials, expired coupons, missing trial-conversion evidence, and non-stackable coupon combinations. +- Consortium pricing: verifies institution/domain/seat eligibility before discount release. +- Invoice safety: emits deterministic RELEASE_INVOICE, REVIEW_BEFORE_RELEASE, or HOLD_INVOICE actions before billing leaves the platform. +- Privacy: uses synthetic fixtures only; no payment processor calls, customer identifiers, credentials, or external APIs. diff --git a/revenue-trial-pricing-guard/artifacts/trial-pricing-guard-demo.mp4 b/revenue-trial-pricing-guard/artifacts/trial-pricing-guard-demo.mp4 new file mode 100644 index 00000000..9c25513f Binary files /dev/null and b/revenue-trial-pricing-guard/artifacts/trial-pricing-guard-demo.mp4 differ diff --git a/revenue-trial-pricing-guard/demo.js b/revenue-trial-pricing-guard/demo.js new file mode 100644 index 00000000..97a544d4 --- /dev/null +++ b/revenue-trial-pricing-guard/demo.js @@ -0,0 +1,81 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const scenarios = require("./fixtures/scenarios.json"); +const { centsToDollars, evaluatePortfolio } = require("./guard"); + +const outputDir = path.join(__dirname, "artifacts"); +fs.mkdirSync(outputDir, { recursive: true }); + +const result = evaluatePortfolio(scenarios); +const jsonPath = path.join(outputDir, "demo-output.json"); +fs.writeFileSync(jsonPath, `${JSON.stringify(result, null, 2)}\n`); + +const rows = result.accounts + .map((account) => { + const findings = account.findings.map((finding) => finding.code).join(", ") || "none"; + return `| ${account.accountId} | ${account.action} | $${centsToDollars(account.netInvoiceCents).toFixed(2)} | $${centsToDollars(account.atRiskCents).toFixed(2)} | ${findings} |`; + }) + .join("\n"); + +const report = [ + "# Trial/Coupon/Consortium Pricing Guard Report", + "", + `Invoice date: ${result.summary.invoiceDate}`, + "", + "## Portfolio Summary", + "", + `- Accounts evaluated: ${result.summary.accountCount}`, + `- Released invoices: ${result.summary.releaseCount}`, + `- Review holds: ${result.summary.reviewCount}`, + `- Blocked invoices: ${result.summary.holdCount}`, + `- Net invoice amount: $${result.summary.totalNetInvoiceDollars.toFixed(2)}`, + `- Discount risk detected: $${result.summary.totalAtRiskDollars.toFixed(2)}`, + "", + "## Account Decisions", + "", + "| Account | Action | Net invoice | At risk | Finding codes |", + "| --- | --- | ---: | ---: | --- |", + rows, + "", + "## Revenue Infrastructure Mapping", + "", + "- Tiered subscription billing: checks plan-level coupon eligibility, billing cycle discounts, and seat-volume conflicts.", + "- Free trials and coupons: blocks reused trials, expired coupons, missing trial-conversion evidence, and non-stackable coupon combinations.", + "- Consortium pricing: verifies institution/domain/seat eligibility before discount release.", + "- Invoice safety: emits deterministic RELEASE_INVOICE, REVIEW_BEFORE_RELEASE, or HOLD_INVOICE actions before billing leaves the platform.", + "- Privacy: uses synthetic fixtures only; no payment processor calls, customer identifiers, credentials, or external APIs.", + "" +].join("\n"); + +fs.writeFileSync(path.join(outputDir, "reviewer-report.md"), report); + +const barWidth = 620; +const barHeight = 34; +const maxRisk = Math.max(...result.accounts.map((account) => account.atRiskCents), 1); +const bars = result.accounts.map((account, index) => { + const width = Math.round((account.atRiskCents / maxRisk) * barWidth); + const y = 80 + index * 58; + const color = account.action === "RELEASE_INVOICE" ? "#16a34a" : account.action === "REVIEW_BEFORE_RELEASE" ? "#f59e0b" : "#dc2626"; + return [ + `${account.accountId}`, + ``, + ``, + `$${centsToDollars(account.atRiskCents).toFixed(2)} at risk` + ].join("\n"); +}).join("\n"); + +const svg = [ + ``, + '', + 'Revenue discount risk map', + `Blocked invoices: ${result.summary.holdCount} | Review: ${result.summary.reviewCount} | Total at risk: $${result.summary.totalAtRiskDollars.toFixed(2)}`, + bars, + "" +].join("\n"); + +fs.writeFileSync(path.join(outputDir, "discount-risk-map.svg"), svg); + +console.log(`Wrote ${jsonPath}`); +console.log(report); diff --git a/revenue-trial-pricing-guard/fixtures/scenarios.json b/revenue-trial-pricing-guard/fixtures/scenarios.json new file mode 100644 index 00000000..9e9fb185 --- /dev/null +++ b/revenue-trial-pricing-guard/fixtures/scenarios.json @@ -0,0 +1,133 @@ +{ + "invoiceDate": "2026-06-04", + "maxTrialDays": 30, + "maxDiscountPercent": 0.4, + "plans": { + "individual_pro": { + "monthlyCents": 2900 + }, + "lab_group": { + "monthlyCents": 7900 + }, + "institutional": { + "monthlyCents": 21000 + } + }, + "consortia": { + "northeast_research": { + "minimumSeats": 20, + "allowedEmailDomains": ["northlab.edu", "metascience.org"], + "allowedInstitutionIds": ["inst-north-01", "inst-meta-04"] + } + }, + "coupons": { + "trial_conversion_20": { + "type": "percent", + "percent": 0.2, + "expiresAt": "2026-12-31", + "stackable": false, + "allowedPlanIds": ["individual_pro", "lab_group"], + "requiresConversionEvidence": true + }, + "spring_promo_15": { + "type": "percent", + "percent": 0.15, + "expiresAt": "2026-05-15", + "stackable": false, + "allowedPlanIds": ["individual_pro", "lab_group"] + }, + "consortium_research_30": { + "type": "percent", + "percent": 0.3, + "expiresAt": "2026-12-31", + "stackable": false, + "allowedPlanIds": ["institutional"], + "consortiumId": "northeast_research" + }, + "accessibility_credit_10": { + "type": "percent", + "percent": 0.1, + "expiresAt": "2026-10-01", + "stackable": true, + "allowedPlanIds": ["individual_pro", "lab_group", "institutional"] + } + }, + "accounts": [ + { + "id": "acct-clean-lab", + "name": "Clean Lab Renewal", + "plan": "lab_group", + "seats": 8, + "billingCycle": "annual", + "institutionId": "lab-clean-01", + "emailDomain": "cleanlab.edu", + "trialActive": false, + "priorTrials": 0, + "conversionEvidence": "signed-order-447", + "coupons": [], + "annualDiscountPercent": 0.1, + "volumeDiscountPercent": 0 + }, + { + "id": "acct-repeat-trial", + "name": "Repeat Trial Startup", + "plan": "individual_pro", + "seats": 2, + "billingCycle": "monthly", + "institutionId": "startup-02", + "emailDomain": "example.org", + "trialActive": true, + "trialStartedAt": "2026-04-29", + "priorTrials": 1, + "conversionEvidence": null, + "coupons": ["trial_conversion_20"], + "annualDiscountPercent": 0, + "volumeDiscountPercent": 0 + }, + { + "id": "acct-expired-coupon", + "name": "Expired Promo Lab", + "plan": "lab_group", + "seats": 12, + "billingCycle": "monthly", + "institutionId": "lab-east-02", + "emailDomain": "eastlab.edu", + "trialActive": false, + "priorTrials": 0, + "conversionEvidence": "purchase-order-310", + "coupons": ["spring_promo_15"], + "annualDiscountPercent": 0, + "volumeDiscountPercent": 0.08 + }, + { + "id": "acct-consortium-mismatch", + "name": "Ineligible Consortium Invoice", + "plan": "institutional", + "seats": 14, + "billingCycle": "annual", + "institutionId": "inst-west-09", + "emailDomain": "westlab.edu", + "trialActive": false, + "priorTrials": 0, + "conversionEvidence": "signed-msa-118", + "coupons": ["consortium_research_30"], + "annualDiscountPercent": 0.1, + "volumeDiscountPercent": 0.05 + }, + { + "id": "acct-stack-conflict", + "name": "Stacked Discount Consortium", + "plan": "institutional", + "seats": 28, + "billingCycle": "annual", + "institutionId": "inst-north-01", + "emailDomain": "northlab.edu", + "trialActive": false, + "priorTrials": 0, + "conversionEvidence": "signed-consortium-2026", + "coupons": ["consortium_research_30", "accessibility_credit_10"], + "annualDiscountPercent": 0.1, + "volumeDiscountPercent": 0.05 + } + ] +} diff --git a/revenue-trial-pricing-guard/guard.js b/revenue-trial-pricing-guard/guard.js new file mode 100644 index 00000000..93fd3368 --- /dev/null +++ b/revenue-trial-pricing-guard/guard.js @@ -0,0 +1,241 @@ +"use strict"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +function parseDate(value) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + throw new Error(`Invalid date: ${value}`); + } + return date; +} + +function centsToDollars(cents) { + return Number((cents / 100).toFixed(2)); +} + +function addFinding(findings, severity, code, message, atRiskCents = 0) { + findings.push({ severity, code, message, atRiskCents }); +} + +function getPlan(plans, planId) { + const plan = plans[planId]; + if (!plan) { + throw new Error(`Unknown plan: ${planId}`); + } + return plan; +} + +function getCoupon(coupons, couponId) { + const coupon = coupons[couponId]; + if (!coupon) { + throw new Error(`Unknown coupon: ${couponId}`); + } + return coupon; +} + +function accountBasePriceCents(plans, account) { + const plan = getPlan(plans, account.plan); + const seatCount = Math.max(1, account.seats || 1); + const monthlyBase = plan.monthlyCents * seatCount; + if (account.billingCycle === "annual") { + return monthlyBase * 12; + } + return monthlyBase; +} + +function couponDiscountCents(baseCents, coupon) { + if (coupon.type === "percent") { + return Math.round(baseCents * coupon.percent); + } + if (coupon.type === "fixed") { + return Math.min(baseCents, coupon.amountCents); + } + throw new Error(`Unsupported coupon type: ${coupon.type}`); +} + +function isAfter(date, compareTo) { + return parseDate(date).getTime() > parseDate(compareTo).getTime(); +} + +function trialAgeDays(account, invoiceDate) { + if (!account.trialStartedAt) return 0; + return Math.floor((parseDate(invoiceDate).getTime() - parseDate(account.trialStartedAt).getTime()) / DAY_MS); +} + +function qualifiesForConsortium(account, consortium) { + if (!consortium) return false; + const domainOk = consortium.allowedEmailDomains.includes(account.emailDomain); + const seatsOk = (account.seats || 0) >= consortium.minimumSeats; + const institutionOk = !consortium.allowedInstitutionIds || consortium.allowedInstitutionIds.includes(account.institutionId); + return domainOk && seatsOk && institutionOk; +} + +function evaluateAccount(account, config) { + const findings = []; + const baseCents = accountBasePriceCents(config.plans, account); + const discountEvents = []; + const appliedCouponIds = account.coupons || []; + let discountCents = 0; + + if (account.trialActive) { + const age = trialAgeDays(account, config.invoiceDate); + if (account.priorTrials > 0) { + addFinding( + findings, + "block", + "trial_reuse", + "Free trial was reused after a prior trial on the same billing identity.", + baseCents + ); + } + if (age > config.maxTrialDays) { + addFinding( + findings, + "block", + "trial_overrun", + `Trial age is ${age} days, above the ${config.maxTrialDays}-day policy.`, + baseCents + ); + } + if (!account.conversionEvidence) { + addFinding( + findings, + "review", + "missing_conversion_evidence", + "Trial account has no conversion evidence before invoice release.", + Math.round(baseCents * 0.25) + ); + } + } + + for (const couponId of appliedCouponIds) { + const coupon = getCoupon(config.coupons, couponId); + if (coupon.expiresAt && isAfter(config.invoiceDate, coupon.expiresAt)) { + addFinding( + findings, + "block", + "expired_coupon", + `Coupon ${couponId} expired on ${coupon.expiresAt}.`, + couponDiscountCents(baseCents, coupon) + ); + } + if (coupon.allowedPlanIds && !coupon.allowedPlanIds.includes(account.plan)) { + addFinding( + findings, + "block", + "coupon_plan_mismatch", + `Coupon ${couponId} is not valid for plan ${account.plan}.`, + couponDiscountCents(baseCents, coupon) + ); + } + if (coupon.consortiumId) { + const consortium = config.consortia[coupon.consortiumId]; + if (!qualifiesForConsortium(account, consortium)) { + addFinding( + findings, + "block", + "consortium_ineligible", + `Coupon ${couponId} requires consortium eligibility that this account does not satisfy.`, + couponDiscountCents(baseCents, coupon) + ); + } + } + if (coupon.requiresConversionEvidence && !account.conversionEvidence) { + addFinding( + findings, + "review", + "coupon_missing_conversion_evidence", + `Coupon ${couponId} requires conversion evidence before billing.`, + Math.round(couponDiscountCents(baseCents, coupon) * 0.5) + ); + } + discountCents += couponDiscountCents(baseCents, coupon); + discountEvents.push({ type: "coupon", id: couponId, amountCents: couponDiscountCents(baseCents, coupon) }); + } + + const nonStackableCoupons = appliedCouponIds + .map((couponId) => getCoupon(config.coupons, couponId)) + .filter((coupon) => coupon.stackable === false); + + if (appliedCouponIds.length > 1 && nonStackableCoupons.length > 0) { + addFinding( + findings, + "block", + "non_stackable_coupon_combo", + "One or more non-stackable coupons were combined on the same invoice.", + Math.round(discountCents * 0.75) + ); + } + + if (account.billingCycle === "annual" && account.annualDiscountPercent) { + const annualDiscount = Math.round(baseCents * account.annualDiscountPercent); + discountCents += annualDiscount; + discountEvents.push({ type: "annual", id: "annual_cycle", amountCents: annualDiscount }); + } + + if (account.volumeDiscountPercent) { + const volumeDiscount = Math.round(baseCents * account.volumeDiscountPercent); + discountCents += volumeDiscount; + discountEvents.push({ type: "volume", id: "seat_volume", amountCents: volumeDiscount }); + } + + const effectiveDiscountPercent = baseCents === 0 ? 0 : discountCents / baseCents; + if (effectiveDiscountPercent > config.maxDiscountPercent) { + addFinding( + findings, + "review", + "discount_cap_exceeded", + `Effective discount ${Math.round(effectiveDiscountPercent * 100)}% exceeds cap ${Math.round(config.maxDiscountPercent * 100)}%.`, + discountCents - Math.round(baseCents * config.maxDiscountPercent) + ); + } + + const blockCount = findings.filter((finding) => finding.severity === "block").length; + const reviewCount = findings.filter((finding) => finding.severity === "review").length; + const action = blockCount > 0 ? "HOLD_INVOICE" : reviewCount > 0 ? "REVIEW_BEFORE_RELEASE" : "RELEASE_INVOICE"; + const atRiskCents = findings.reduce((total, finding) => total + finding.atRiskCents, 0); + const netInvoiceCents = Math.max(0, baseCents - Math.min(discountCents, baseCents)); + + return { + accountId: account.id, + accountName: account.name, + plan: account.plan, + baseCents, + discountCents: Math.min(discountCents, baseCents), + netInvoiceCents, + effectiveDiscountPercent: Number(effectiveDiscountPercent.toFixed(4)), + action, + findings, + discountEvents, + atRiskCents + }; +} + +function evaluatePortfolio(config) { + const accounts = config.accounts.map((account) => evaluateAccount(account, config)); + const summary = { + invoiceDate: config.invoiceDate, + accountCount: accounts.length, + releaseCount: accounts.filter((account) => account.action === "RELEASE_INVOICE").length, + reviewCount: accounts.filter((account) => account.action === "REVIEW_BEFORE_RELEASE").length, + holdCount: accounts.filter((account) => account.action === "HOLD_INVOICE").length, + totalAtRiskCents: accounts.reduce((total, account) => total + account.atRiskCents, 0), + totalNetInvoiceCents: accounts.reduce((total, account) => total + account.netInvoiceCents, 0) + }; + + return { + summary: { + ...summary, + totalAtRiskDollars: centsToDollars(summary.totalAtRiskCents), + totalNetInvoiceDollars: centsToDollars(summary.totalNetInvoiceCents) + }, + accounts + }; +} + +module.exports = { + evaluateAccount, + evaluatePortfolio, + centsToDollars +}; diff --git a/revenue-trial-pricing-guard/test.js b/revenue-trial-pricing-guard/test.js new file mode 100644 index 00000000..15198d08 --- /dev/null +++ b/revenue-trial-pricing-guard/test.js @@ -0,0 +1,48 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const scenarios = require("./fixtures/scenarios.json"); +const { evaluateAccount, evaluatePortfolio } = require("./guard"); + +function byId(id) { + return scenarios.accounts.find((account) => account.id === id); +} + +function codes(result) { + return result.findings.map((finding) => finding.code); +} + +const clean = evaluateAccount(byId("acct-clean-lab"), scenarios); +assert.equal(clean.action, "RELEASE_INVOICE"); +assert.equal(clean.findings.length, 0); +assert.equal(clean.netInvoiceCents, 682560); + +const repeatTrial = evaluateAccount(byId("acct-repeat-trial"), scenarios); +assert.equal(repeatTrial.action, "HOLD_INVOICE"); +assert.ok(codes(repeatTrial).includes("trial_reuse")); +assert.ok(codes(repeatTrial).includes("trial_overrun")); +assert.ok(codes(repeatTrial).includes("missing_conversion_evidence")); +assert.ok(codes(repeatTrial).includes("coupon_missing_conversion_evidence")); + +const expiredCoupon = evaluateAccount(byId("acct-expired-coupon"), scenarios); +assert.equal(expiredCoupon.action, "HOLD_INVOICE"); +assert.ok(codes(expiredCoupon).includes("expired_coupon")); + +const consortiumMismatch = evaluateAccount(byId("acct-consortium-mismatch"), scenarios); +assert.equal(consortiumMismatch.action, "HOLD_INVOICE"); +assert.ok(codes(consortiumMismatch).includes("consortium_ineligible")); +assert.ok(codes(consortiumMismatch).includes("discount_cap_exceeded")); + +const stacked = evaluateAccount(byId("acct-stack-conflict"), scenarios); +assert.equal(stacked.action, "HOLD_INVOICE"); +assert.ok(codes(stacked).includes("non_stackable_coupon_combo")); +assert.ok(codes(stacked).includes("discount_cap_exceeded")); + +const portfolio = evaluatePortfolio(scenarios); +assert.equal(portfolio.summary.accountCount, 5); +assert.equal(portfolio.summary.releaseCount, 1); +assert.equal(portfolio.summary.holdCount, 4); +assert.equal(portfolio.summary.reviewCount, 0); +assert.ok(portfolio.summary.totalAtRiskCents > 0); + +console.log("revenue trial pricing guard tests passed");