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 @@
+
\ 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 = [
+ `"
+].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");