diff --git a/pricing-experiment-guard/README.md b/pricing-experiment-guard/README.md new file mode 100644 index 0000000..42528be --- /dev/null +++ b/pricing-experiment-guard/README.md @@ -0,0 +1,38 @@ +# Pricing Experiment Guard + +This self-contained revenue infrastructure module checks whether pricing and packaging experiments are safe to launch. It is designed for finance, growth, and product teams that need audit-ready controls before changing SaaS prices, entitlements, or AI-compute bundles. + +## What It Checks + +- A measurable hypothesis and unchanged control cohort +- Gross-margin headroom against a finance-approved floor +- Entitlement removals without grandfathering +- Variant exposure limits and rollback readiness +- Invoice copy review, customer notice windows, and tax review +- Finance/legal approvals and revenue-recognition notes + +## Run It + +```bash +node pricing-experiment-guard/test.js +node pricing-experiment-guard/demo.js +``` + +The module uses only Node.js standard library APIs. + +## Public API + +```js +const { evaluatePricingExperiment } = require("./index.js"); + +const result = evaluatePricingExperiment(experiment); +console.log(result.summary.launchDecision); +console.log(result.findings); +console.log(result.financePacket.requiredActions); +``` + +The guard returns a launch decision of `approve`, `needs-remediation`, or `block`, plus finance-owner remediation steps and audit questions. + +## Demo + +The included `demo.gif` shows a risky enterprise AI-compute price lift being blocked because it lacks a hypothesis/control, breaches margin floors, removes entitlements, exceeds rollout limits, and has incomplete billing/audit controls. diff --git a/pricing-experiment-guard/demo.gif b/pricing-experiment-guard/demo.gif new file mode 100644 index 0000000..d11a649 Binary files /dev/null and b/pricing-experiment-guard/demo.gif differ diff --git a/pricing-experiment-guard/demo.js b/pricing-experiment-guard/demo.js new file mode 100644 index 0000000..bbdbb62 --- /dev/null +++ b/pricing-experiment-guard/demo.js @@ -0,0 +1,56 @@ +const { evaluatePricingExperiment } = require("./index.js"); + +const experiment = { + title: "Enterprise AI Compute Price Lift", + hypothesis: "", + cohorts: [ + { + name: "new-pricing", + exposurePercent: 42, + priceMultiplier: 1.35, + entitlementsRemoved: ["priority-support", "included-compute-pack"], + grandfathering: false + } + ], + economics: { + grossMarginFloorPercent: 55, + projectedGrossMarginPercent: 41 + }, + rollout: { + maxExposurePercent: 25, + rollbackMetric: "", + rollbackThreshold: null, + owner: "revenue-ops" + }, + billing: { + invoiceCopyReviewed: false, + customerNoticeDays: 3, + taxTreatmentReviewed: false + }, + audit: { + approvalRecords: [], + revenueRecognitionNotes: "" + } +}; + +const result = evaluatePricingExperiment(experiment); + +console.log(`Pricing experiment guard: ${result.summary.experimentTitle}`); +console.log(`Decision: ${result.summary.launchDecision}`); +console.log(`Finance readiness: ${result.summary.financeReadiness}`); +console.log(`Findings: ${result.summary.findingCount}`); +console.log(""); + +for (const finding of result.findings) { + console.log(`[${finding.severity.toUpperCase()}] ${finding.title}`); + for (const evidence of finding.evidence) { + console.log(` - ${evidence}`); + } + console.log(` Remediation: ${finding.remediation}`); + console.log(""); +} + +console.log("Finance actions:"); +for (const action of result.financePacket.requiredActions) { + console.log(`- ${action}`); +} diff --git a/pricing-experiment-guard/index.js b/pricing-experiment-guard/index.js new file mode 100644 index 0000000..e45635f --- /dev/null +++ b/pricing-experiment-guard/index.js @@ -0,0 +1,253 @@ +const crypto = require("node:crypto"); + +function evaluatePricingExperiment(experiment) { + const normalized = normalizeExperiment(experiment); + const findings = [ + ...findMissingHypothesisOrControl(normalized), + ...findMarginFloorBreach(normalized), + ...findEntitlementRegressions(normalized), + ...findRollbackGaps(normalized), + ...findBillingNoticeGaps(normalized), + ...findAuditGaps(normalized) + ]; + + const severityCounts = countSeverities(findings); + const summary = { + experimentTitle: normalized.title, + findingCount: findings.length, + severityCounts, + launchDecision: decideLaunch(severityCounts), + financeReadiness: scoreFinanceReadiness(findings) + }; + + return { + summary, + findings, + financePacket: buildFinancePacket(summary, findings) + }; +} + +function normalizeExperiment(experiment) { + if (!experiment || typeof experiment !== "object") { + throw new TypeError("evaluatePricingExperiment expects an experiment object"); + } + + return { + title: experiment.title || "Untitled pricing experiment", + hypothesis: experiment.hypothesis || "", + cohorts: Array.isArray(experiment.cohorts) ? experiment.cohorts : [], + economics: experiment.economics || {}, + rollout: experiment.rollout || {}, + billing: experiment.billing || {}, + audit: experiment.audit || {} + }; +} + +function findMissingHypothesisOrControl(experiment) { + const evidence = []; + if (!experiment.hypothesis.trim()) evidence.push("experiment hypothesis is missing"); + if (!experiment.cohorts.some((cohort) => Number(cohort.priceMultiplier) === 1)) { + evidence.push("control cohort with unchanged pricing is missing"); + } + + if (evidence.length === 0) return []; + + return [ + finding({ + type: "missing_hypothesis_or_control", + severity: "critical", + title: "Pricing experiment lacks a measurable hypothesis or control cohort", + evidence, + remediation: + "Define the business hypothesis and add a control cohort before exposing customers to changed prices." + }) + ]; +} + +function findMarginFloorBreach(experiment) { + const floor = Number(experiment.economics.grossMarginFloorPercent); + const projected = Number(experiment.economics.projectedGrossMarginPercent); + + if (!Number.isFinite(floor) || !Number.isFinite(projected) || projected >= floor) { + return []; + } + + return [ + finding({ + type: "margin_floor_breach", + severity: "high", + title: "Projected gross margin falls below the approved floor", + evidence: [`projected margin ${projected}% is below the ${floor}% floor`], + remediation: + "Reprice the cohort, reduce compute cost, or lower exposure until projected gross margin clears the finance floor." + }) + ]; +} + +function findEntitlementRegressions(experiment) { + const regressions = []; + + for (const cohort of experiment.cohorts) { + const removed = Array.isArray(cohort.entitlementsRemoved) ? cohort.entitlementsRemoved : []; + if (removed.length > 0 && !cohort.grandfathering) { + regressions.push( + `${cohort.name || "unnamed cohort"} removes ${removed.join(", ")} without grandfathering` + ); + } + } + + if (regressions.length === 0) return []; + + return [ + finding({ + type: "entitlement_regression", + severity: "high", + title: "Pricing change removes customer entitlements without protection", + evidence: regressions, + remediation: + "Grandfather existing customers, add customer-facing entitlement notices, or remove entitlement regressions from the experiment." + }) + ]; +} + +function findRollbackGaps(experiment) { + const evidence = []; + const maxExposure = Number(experiment.rollout.maxExposurePercent); + + for (const cohort of experiment.cohorts) { + const exposure = Number(cohort.exposurePercent); + const changesPrice = Number(cohort.priceMultiplier) !== 1; + if (changesPrice && Number.isFinite(exposure) && Number.isFinite(maxExposure) && exposure > maxExposure) { + evidence.push( + `${cohort.name || "unnamed cohort"} exposure ${exposure}% exceeds max ${maxExposure}%` + ); + } + } + + if (!experiment.rollout.rollbackMetric) evidence.push("rollback metric is missing"); + if (!experiment.rollout.rollbackThreshold) evidence.push("rollback threshold is missing"); + if (!experiment.rollout.owner) evidence.push("rollout owner is missing"); + + if (evidence.length === 0) return []; + + return [ + finding({ + type: "rollback_gap", + severity: "medium", + title: "Rollout controls are not ready for a pricing experiment", + evidence, + remediation: + "Set cohort exposure limits, rollback metrics, rollback thresholds, and a named owner before launch." + }) + ]; +} + +function findBillingNoticeGaps(experiment) { + const evidence = []; + const noticeDays = Number(experiment.billing.customerNoticeDays); + + if (!experiment.billing.invoiceCopyReviewed) evidence.push("invoice copy has not been reviewed"); + if (!Number.isFinite(noticeDays) || noticeDays < 14) { + evidence.push(`customer notice window is ${Number.isFinite(noticeDays) ? noticeDays : "missing"} days`); + } + if (!experiment.billing.taxTreatmentReviewed) evidence.push("tax treatment review is missing"); + + if (evidence.length === 0) return []; + + return [ + finding({ + type: "billing_notice_gap", + severity: "medium", + title: "Billing and customer notice controls are incomplete", + evidence, + remediation: + "Review invoice copy, confirm tax treatment, and provide an adequate customer notice window before billing changes go live." + }) + ]; +} + +function findAuditGaps(experiment) { + const evidence = []; + const approvals = Array.isArray(experiment.audit.approvalRecords) + ? experiment.audit.approvalRecords + : []; + + if (approvals.length === 0) evidence.push("finance/legal approval records are missing"); + if (!experiment.audit.revenueRecognitionNotes) { + evidence.push("revenue recognition notes are missing"); + } + + if (evidence.length === 0) return []; + + return [ + finding({ + type: "audit_packet_gap", + severity: "medium", + title: "Finance audit packet is incomplete", + evidence, + remediation: + "Attach finance/legal approvals and revenue-recognition notes before the experiment is approved." + }) + ]; +} + +function buildFinancePacket(summary, findings) { + if (findings.length === 0) { + return { + requiredActions: ["Proceed with monitored launch."], + ownerQuestions: [ + "Are finance and support teams ready to watch refund, churn, and gross-margin triggers?" + ], + auditSummary: `${summary.experimentTitle} is ready for monitored launch.` + }; + } + + return { + requiredActions: findings.map((finding) => finding.remediation), + ownerQuestions: [ + "Is there a control cohort and a measurable revenue hypothesis?", + "Can finance confirm the projected margin stays above the approved floor?", + "Can billing roll back invoices and entitlements without customer harm?" + ], + auditSummary: `${summary.experimentTitle} needs ${findings.length} remediation action(s) before launch.` + }; +} + +function finding(input) { + return { + id: `${input.type}-${hash(input.evidence.join("|")).slice(0, 8)}`, + type: input.type, + severity: input.severity, + title: input.title, + evidence: input.evidence, + remediation: input.remediation + }; +} + +function countSeverities(findings) { + const counts = { critical: 0, high: 0, medium: 0 }; + for (const finding of findings) counts[finding.severity] += 1; + return counts; +} + +function decideLaunch(counts) { + if (counts.critical > 0 || counts.high > 0) return "block"; + if (counts.medium > 0) return "needs-remediation"; + return "approve"; +} + +function scoreFinanceReadiness(findings) { + if (findings.some((finding) => finding.severity === "critical" || finding.severity === "high")) { + return "low"; + } + if (findings.length > 0) return "medium"; + return "high"; +} + +function hash(value) { + return crypto.createHash("sha256").update(String(value)).digest("hex"); +} + +module.exports = { + evaluatePricingExperiment +}; diff --git a/pricing-experiment-guard/requirement-map.md b/pricing-experiment-guard/requirement-map.md new file mode 100644 index 0000000..4b13258 --- /dev/null +++ b/pricing-experiment-guard/requirement-map.md @@ -0,0 +1,30 @@ +# Requirement Map + +Issue: SCIBASE-AI/SCIBASE.AI#20, Revenue Infrastructure. + +## Tiered Subscription Billing + +- Validates pricing changes against unchanged control cohorts. +- Detects entitlement removals without grandfathering so plan changes do not silently break customer expectations. +- Checks invoice copy and customer notice readiness before billing changes go live. + +## AI Compute Billing + +- Enforces projected gross-margin floors for compute-heavy plan changes. +- Supports safe AI-compute bundle experiments through exposure limits and rollback triggers. + +## Licensing APIs And Analytics + +- Produces an audit packet that finance and analytics teams can attach to pricing-experiment reviews. +- Captures owner questions for margin, billing, and rollback readiness before revenue experiments affect customers. + +## Safety And Scope + +- Uses synthetic sample data only. +- Requires no payment provider, external service, credential, or network access. +- Keeps the implementation isolated under `pricing-experiment-guard/`. + +## Verification + +- `node pricing-experiment-guard/test.js` +- `node pricing-experiment-guard/demo.js` diff --git a/pricing-experiment-guard/test.js b/pricing-experiment-guard/test.js new file mode 100644 index 0000000..d70bc54 --- /dev/null +++ b/pricing-experiment-guard/test.js @@ -0,0 +1,119 @@ +const assert = require("node:assert/strict"); +const { evaluatePricingExperiment } = require("./index.js"); + +function test(name, fn) { + try { + fn(); + console.log(`ok - ${name}`); + } catch (error) { + console.error(`not ok - ${name}`); + throw error; + } +} + +const riskyExperiment = { + title: "Enterprise AI Compute Price Lift", + hypothesis: "", + cohorts: [ + { + name: "new-pricing", + exposurePercent: 42, + priceMultiplier: 1.35, + entitlementsRemoved: ["priority-support", "included-compute-pack"], + grandfathering: false + } + ], + economics: { + grossMarginFloorPercent: 55, + projectedGrossMarginPercent: 41 + }, + rollout: { + maxExposurePercent: 25, + rollbackMetric: "", + rollbackThreshold: null, + owner: "revenue-ops" + }, + billing: { + invoiceCopyReviewed: false, + customerNoticeDays: 3, + taxTreatmentReviewed: false + }, + audit: { + approvalRecords: [], + revenueRecognitionNotes: "" + } +}; + +const safeExperiment = { + title: "Pro Annual Discount Copy Test", + hypothesis: "Annual discount positioning improves conversion without lowering margin.", + cohorts: [ + { + name: "control", + exposurePercent: 50, + priceMultiplier: 1, + entitlementsRemoved: [], + grandfathering: true + }, + { + name: "variant", + exposurePercent: 10, + priceMultiplier: 0.95, + entitlementsRemoved: [], + grandfathering: true + } + ], + economics: { + grossMarginFloorPercent: 60, + projectedGrossMarginPercent: 74 + }, + rollout: { + maxExposurePercent: 20, + rollbackMetric: "gross margin below 60% or refund requests above 2%", + rollbackThreshold: "2 consecutive days", + owner: "growth-finance" + }, + billing: { + invoiceCopyReviewed: true, + customerNoticeDays: 30, + taxTreatmentReviewed: true + }, + audit: { + approvalRecords: ["finance-approval-2026-05-18", "legal-review-2026-05-18"], + revenueRecognitionNotes: "Discount is applied prospectively and deferred revenue remains unchanged." + } +}; + +test("blocks risky pricing experiments with missing controls, margin breach, entitlement regressions, and weak rollback", () => { + const result = evaluatePricingExperiment(riskyExperiment); + const types = result.findings.map((finding) => finding.type); + + assert.equal(result.summary.launchDecision, "block"); + assert.ok(types.includes("missing_hypothesis_or_control")); + assert.ok(types.includes("margin_floor_breach")); + assert.ok(types.includes("entitlement_regression")); + assert.ok(types.includes("rollback_gap")); + assert.ok(types.includes("billing_notice_gap")); + assert.ok(result.financePacket.requiredActions.some((action) => action.includes("control cohort"))); +}); + +test("approves a bounded experiment with controls, margin headroom, notice, and audit evidence", () => { + const result = evaluatePricingExperiment(safeExperiment); + + assert.equal(result.summary.launchDecision, "approve"); + assert.equal(result.summary.findingCount, 0); + assert.equal(result.summary.financeReadiness, "high"); + assert.deepEqual(result.findings, []); + assert.ok(result.financePacket.requiredActions.includes("Proceed with monitored launch.")); +}); + +test("emits audit-ready findings with owner-facing remediation", () => { + const result = evaluatePricingExperiment(riskyExperiment); + + for (const finding of result.findings) { + assert.ok(finding.id); + assert.ok(["critical", "high", "medium"].includes(finding.severity)); + assert.ok(finding.evidence.length > 0); + assert.ok(finding.remediation.length > 10); + } +});