Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions subscription-cancellation-effective-date-guard/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Subscription Cancellation Effective-Date Guard

This is a focused Revenue Infrastructure slice for issue #20. It evaluates
subscription cancellation packets before entitlements, AI compute credits,
analytics licenses, and renewal invoices are changed.

The guard uses synthetic data only and has no external service dependencies.

## Checks

- Cancellation requests submitted after the contractual notice window.
- Cancellation effective dates that do not match the contract end date.
- Renewal invoices that are still scheduled after cancellation.
- Refund or service-credit obligations that have no finance review.
- AI compute credits that would remain usable after the wind-down date.
- Analytics-license exports that continue past the cancellation cutoff.
- Institutional purchase-order exceptions that require manual handling.

## Local Verification

```bash
node subscription-cancellation-effective-date-guard/test.js
node subscription-cancellation-effective-date-guard/demo.js
```

Demo artifacts are written to
`subscription-cancellation-effective-date-guard/reports/`.

## Issue #20 Mapping

This targets the subscription billing, AI compute billing, and analytics
licensing layers in issue #20. It is distinct from renewal notice, trial abuse,
dunning, downgrade, payment authorization, account transfer, collections,
receipt privacy, quote approval, sanctions, and analytics seat roster slices.
153 changes: 153 additions & 0 deletions subscription-cancellation-effective-date-guard/cancellationGuard.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
const MS_PER_DAY = 24 * 60 * 60 * 1000;

function parseDate(value) {
const time = Date.parse(value);
if (Number.isNaN(time)) {
throw new Error(`Invalid date: ${value}`);
}
return new Date(time);
}

function daysBetween(start, end) {
return Math.ceil((parseDate(end) - parseDate(start)) / MS_PER_DAY);
}

function addFinding(findings, severity, id, message, action, evidence = {}) {
findings.push({ severity, id, message, action, evidence });
}

function evaluateCancellationPacket(packet) {
const findings = [];
const noticeDays = daysBetween(packet.requestedAt, packet.contractEndDate);

if (noticeDays < packet.requiredNoticeDays) {
addFinding(
findings,
"hold",
"late-cancellation-notice",
`Cancellation notice provides ${noticeDays} days, below the required ${packet.requiredNoticeDays} days.`,
"Route to finance/legal for late-notice approval before suppressing renewal revenue.",
{ noticeDays, requiredNoticeDays: packet.requiredNoticeDays }
);
}

if (packet.effectiveDate !== packet.contractEndDate && !packet.earlyTerminationApprovalId) {
addFinding(
findings,
"block",
"unapproved-effective-date",
"Cancellation effective date differs from the contract end date without early-termination approval.",
"Attach early-termination approval or align the effective date with the contract end date.",
{ effectiveDate: packet.effectiveDate, contractEndDate: packet.contractEndDate }
);
}

for (const invoice of packet.renewalInvoices || []) {
if (invoice.status === "scheduled" && Date.parse(invoice.issueDate) >= Date.parse(packet.effectiveDate)) {
addFinding(
findings,
"block",
`scheduled-renewal-invoice:${invoice.id}`,
`Renewal invoice ${invoice.id} is still scheduled after cancellation takes effect.`,
"Cancel or hold the renewal invoice before finalizing the cancellation.",
invoice
);
}
}

if (packet.refundDueCents > 0 && !packet.financeReviewId) {
addFinding(
findings,
"hold",
"refund-without-review",
"A refund or service credit is due but no finance review is attached.",
"Create a finance review before issuing credit notes or refund payments.",
{ refundDueCents: packet.refundDueCents }
);
}

for (const creditLot of packet.computeCreditLots || []) {
if (creditLot.remainingUnits > 0 && Date.parse(creditLot.expiresAt) > Date.parse(packet.computeWindDownDate)) {
addFinding(
findings,
"hold",
`compute-credit-overhang:${creditLot.id}`,
`Compute credit lot ${creditLot.id} remains usable after the wind-down date.`,
"Expire, refund, or convert the remaining compute credits before cancellation close.",
creditLot
);
}
}

for (const exportGrant of packet.analyticsExportGrants || []) {
if (Date.parse(exportGrant.accessUntil) > Date.parse(packet.analyticsCutoffDate)) {
addFinding(
findings,
"block",
`analytics-export-overhang:${exportGrant.id}`,
`Analytics export grant ${exportGrant.id} outlives the cancellation cutoff.`,
"Revoke or shorten export access before confirming cancellation.",
exportGrant
);
}
}

if (packet.institutionalPoException && !packet.poCloseoutId) {
addFinding(
findings,
"hold",
"po-closeout-missing",
"Institutional purchase-order exception is present without a closeout record.",
"Attach PO closeout evidence before marking the account cancelled.",
{ institutionalPoException: true }
);
}

const blockers = findings.filter((finding) => finding.severity === "block").length;
const holds = findings.filter((finding) => finding.severity === "hold").length;
const warnings = findings.filter((finding) => finding.severity === "warn").length;

return {
accountId: packet.accountId,
cancellationId: packet.cancellationId,
decision: blockers > 0 ? "block-cancellation-close" : holds > 0 ? "hold-for-finance-review" : "ready-to-close",
summary: { blockers, holds, warnings, findings: findings.length },
findings
};
}

function toMarkdown(result) {
const rows = result.findings.map((finding) => `| ${finding.severity} | ${finding.message} | ${finding.action} |`);
return [
"# Cancellation Effective-Date Guard Report",
"",
`Account: ${result.accountId}`,
`Cancellation: ${result.cancellationId}`,
`Decision: ${result.decision}`,
"",
"| Severity | Finding | Action |",
"| --- | --- | --- |",
...(rows.length ? rows : ["| ok | No issues found. | Close cancellation packet. |"]),
""
].join("\n");
}

function toSvg(result) {
const color = result.decision === "ready-to-close" ? "#0f7b45" : result.decision === "hold-for-finance-review" ? "#9a6700" : "#b42318";
return [
`<svg xmlns="http://www.w3.org/2000/svg" width="760" height="220" viewBox="0 0 760 220" role="img" aria-label="Cancellation guard summary">`,
`<rect width="760" height="220" fill="#f8fafc"/>`,
`<rect x="30" y="28" width="700" height="164" rx="8" fill="#ffffff" stroke="#d0d7de"/>`,
`<text x="54" y="72" font-family="Arial, sans-serif" font-size="24" font-weight="700" fill="#111827">Cancellation Effective-Date Guard</text>`,
`<text x="54" y="112" font-family="Arial, sans-serif" font-size="18" fill="${color}">Decision: ${result.decision}</text>`,
`<text x="54" y="146" font-family="Arial, sans-serif" font-size="16" fill="#374151">Blockers: ${result.summary.blockers} | Holds: ${result.summary.holds} | Warnings: ${result.summary.warnings}</text>`,
`<text x="54" y="174" font-family="Arial, sans-serif" font-size="14" fill="#6b7280">Account ${result.accountId} / ${result.cancellationId}</text>`,
`</svg>`
].join("\n");
}

module.exports = {
evaluateCancellationPacket,
toMarkdown,
toSvg
};
16 changes: 16 additions & 0 deletions subscription-cancellation-effective-date-guard/demo.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
const fs = require("fs");
const path = require("path");
const { evaluateCancellationPacket, toMarkdown, toSvg } = require("./cancellationGuard");
const samplePacket = require("./samplePacket");

const result = evaluateCancellationPacket(samplePacket);
const reportDir = path.join(__dirname, "reports");

fs.mkdirSync(reportDir, { recursive: true });
fs.writeFileSync(path.join(reportDir, "cancellation-packet.json"), `${JSON.stringify(result, null, 2)}\n`);
fs.writeFileSync(path.join(reportDir, "cancellation-report.md"), toMarkdown(result));
fs.writeFileSync(path.join(reportDir, "summary.svg"), toSvg(result));

console.log(`decision=${result.decision}`);
console.log(`findings=${result.findings.length}`);
console.log(`reports=${reportDir}`);
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
{
"accountId": "inst-lab-204",
"cancellationId": "can-2026-05-alpha",
"decision": "block-cancellation-close",
"summary": {
"blockers": 3,
"holds": 4,
"warnings": 0,
"findings": 7
},
"findings": [
{
"severity": "hold",
"id": "late-cancellation-notice",
"message": "Cancellation notice provides 17 days, below the required 30 days.",
"action": "Route to finance/legal for late-notice approval before suppressing renewal revenue.",
"evidence": {
"noticeDays": 17,
"requiredNoticeDays": 30
}
},
{
"severity": "block",
"id": "unapproved-effective-date",
"message": "Cancellation effective date differs from the contract end date without early-termination approval.",
"action": "Attach early-termination approval or align the effective date with the contract end date.",
"evidence": {
"effectiveDate": "2026-05-31",
"contractEndDate": "2026-06-01"
}
},
{
"severity": "block",
"id": "scheduled-renewal-invoice:inv-renew-991",
"message": "Renewal invoice inv-renew-991 is still scheduled after cancellation takes effect.",
"action": "Cancel or hold the renewal invoice before finalizing the cancellation.",
"evidence": {
"id": "inv-renew-991",
"issueDate": "2026-06-01",
"status": "scheduled",
"amountCents": 4800000
}
},
{
"severity": "hold",
"id": "refund-without-review",
"message": "A refund or service credit is due but no finance review is attached.",
"action": "Create a finance review before issuing credit notes or refund payments.",
"evidence": {
"refundDueCents": 125000
}
},
{
"severity": "hold",
"id": "compute-credit-overhang:cc-ai-771",
"message": "Compute credit lot cc-ai-771 remains usable after the wind-down date.",
"action": "Expire, refund, or convert the remaining compute credits before cancellation close.",
"evidence": {
"id": "cc-ai-771",
"remainingUnits": 430,
"expiresAt": "2026-07-31"
}
},
{
"severity": "block",
"id": "analytics-export-overhang:lic-export-44",
"message": "Analytics export grant lic-export-44 outlives the cancellation cutoff.",
"action": "Revoke or shorten export access before confirming cancellation.",
"evidence": {
"id": "lic-export-44",
"dataset": "citation-network",
"accessUntil": "2026-06-30"
}
},
{
"severity": "hold",
"id": "po-closeout-missing",
"message": "Institutional purchase-order exception is present without a closeout record.",
"action": "Attach PO closeout evidence before marking the account cancelled.",
"evidence": {
"institutionalPoException": true
}
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Cancellation Effective-Date Guard Report

Account: inst-lab-204
Cancellation: can-2026-05-alpha
Decision: block-cancellation-close

| Severity | Finding | Action |
| --- | --- | --- |
| hold | Cancellation notice provides 17 days, below the required 30 days. | Route to finance/legal for late-notice approval before suppressing renewal revenue. |
| block | Cancellation effective date differs from the contract end date without early-termination approval. | Attach early-termination approval or align the effective date with the contract end date. |
| block | Renewal invoice inv-renew-991 is still scheduled after cancellation takes effect. | Cancel or hold the renewal invoice before finalizing the cancellation. |
| hold | A refund or service credit is due but no finance review is attached. | Create a finance review before issuing credit notes or refund payments. |
| hold | Compute credit lot cc-ai-771 remains usable after the wind-down date. | Expire, refund, or convert the remaining compute credits before cancellation close. |
| block | Analytics export grant lic-export-44 outlives the cancellation cutoff. | Revoke or shorten export access before confirming cancellation. |
| hold | Institutional purchase-order exception is present without a closeout record. | Attach PO closeout evidence before marking the account cancelled. |
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
26 changes: 26 additions & 0 deletions subscription-cancellation-effective-date-guard/samplePacket.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
module.exports = {
accountId: "inst-lab-204",
cancellationId: "can-2026-05-alpha",
requestedAt: "2026-05-15",
contractEndDate: "2026-06-01",
effectiveDate: "2026-05-31",
requiredNoticeDays: 30,
earlyTerminationApprovalId: "",
refundDueCents: 125000,
financeReviewId: "",
computeWindDownDate: "2026-06-07",
analyticsCutoffDate: "2026-06-01",
institutionalPoException: true,
poCloseoutId: "",
renewalInvoices: [
{ id: "inv-renew-991", issueDate: "2026-06-01", status: "scheduled", amountCents: 4800000 },
{ id: "inv-final-990", issueDate: "2026-05-20", status: "issued", amountCents: 1200000 }
],
computeCreditLots: [
{ id: "cc-ai-771", remainingUnits: 430, expiresAt: "2026-07-31" },
{ id: "cc-ai-772", remainingUnits: 0, expiresAt: "2026-06-01" }
],
analyticsExportGrants: [
{ id: "lic-export-44", dataset: "citation-network", accessUntil: "2026-06-30" }
]
};
35 changes: 35 additions & 0 deletions subscription-cancellation-effective-date-guard/test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
const assert = require("assert");
const { evaluateCancellationPacket } = require("./cancellationGuard");
const samplePacket = require("./samplePacket");

const result = evaluateCancellationPacket(samplePacket);

assert.equal(result.decision, "block-cancellation-close");
assert.ok(result.findings.some((finding) => finding.id === "late-cancellation-notice"));
assert.ok(result.findings.some((finding) => finding.id === "unapproved-effective-date"));
assert.ok(result.findings.some((finding) => finding.id === "scheduled-renewal-invoice:inv-renew-991"));
assert.ok(result.findings.some((finding) => finding.id === "refund-without-review"));
assert.ok(result.findings.some((finding) => finding.id === "compute-credit-overhang:cc-ai-771"));
assert.ok(result.findings.some((finding) => finding.id === "analytics-export-overhang:lic-export-44"));
assert.ok(result.findings.some((finding) => finding.id === "po-closeout-missing"));

const cleanResult = evaluateCancellationPacket({
accountId: "lab-clean",
cancellationId: "can-clean",
requestedAt: "2026-04-01",
contractEndDate: "2026-06-01",
effectiveDate: "2026-06-01",
requiredNoticeDays: 30,
refundDueCents: 0,
computeWindDownDate: "2026-06-07",
analyticsCutoffDate: "2026-06-01",
institutionalPoException: false,
renewalInvoices: [{ id: "inv-old", issueDate: "2026-05-01", status: "issued", amountCents: 50000 }],
computeCreditLots: [{ id: "cc-zero", remainingUnits: 0, expiresAt: "2026-07-01" }],
analyticsExportGrants: [{ id: "lic-ok", accessUntil: "2026-06-01" }]
});

assert.equal(cleanResult.decision, "ready-to-close");
assert.equal(cleanResult.findings.length, 0);

console.log("cancellation effective-date guard tests passed");