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
50 changes: 50 additions & 0 deletions event-sponsorship-revenue-readiness-guard/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Event Sponsorship Revenue Readiness Guard

This module is a self-contained revenue-infrastructure slice for event sponsorship packages. It determines whether a sponsorship packet is ready to invoice, needs finance review, or must be held before release.

It is intentionally aligned with the event-oriented surface of this repository (`get-event-sponsorship`, event CRM, and event operations) rather than adding another generic billing ledger.

## What It Checks

- Sponsorship contract signature and sponsor approval
- Purchase order or finance approval readiness
- Required sponsorship deliverables and sponsor signoff
- Attendee lead guarantees and make-good risk
- Sponsor category exclusivity conflicts
- Cancellation/refund exposure windows
- Proof artifacts required for audit-ready invoicing

## Decisions

- `RELEASE_INVOICE`: evidence is complete enough to invoice or release revenue.
- `REVIEW_BEFORE_RELEASE`: finance or sponsorship owner should review before release.
- `HOLD_INVOICE`: material blocker exists; invoice/revenue release should not proceed.

## Run

```bash
npm run check
npm test
npm run demo
```

The demo writes:

- `reports/event-sponsorship-readiness-report.json`
- `reports/event-sponsorship-readiness-report.md`
- `reports/event-sponsorship-readiness-summary.svg`
- `reports/demo.mp4`

## Requirement Mapping

| Issue #20 requirement | Module coverage |
| --- | --- |
| Subscription and institutional revenue controls | Sponsor package readiness and finance approval controls |
| Usage/value-aligned monetization | Lead guarantee and deliverable evidence checks before invoice release |
| Licensing/API analytics revenue discipline | Proof-artifact and approval gates for sponsor-facing revenue claims |
| Predictable recurring revenue | Prevents premature invoicing, refund exposure, and exclusivity conflicts |
| Secure payment integrations | Does not call payment processors; emits hold/release decisions before finance action |

## Safety Boundary

This module uses synthetic data only. It does not call Stripe, PayPal, banks, ERPs, CRMs, sponsor portals, payment processors, external APIs, or accounting systems. It reads no credentials and contains no real sponsor/customer data.
71 changes: 71 additions & 0 deletions event-sponsorship-revenue-readiness-guard/demo.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
const fs = require("fs");
const path = require("path");
const { evaluateSponsorshipRevenueReadiness, summarizeEvaluations } = require("./index");
const { sponsorshipPackets } = require("./sample-data");

const reportsDir = path.join(__dirname, "reports");
fs.mkdirSync(reportsDir, { recursive: true });

const evaluations = sponsorshipPackets.map(evaluateSponsorshipRevenueReadiness);
const summary = summarizeEvaluations(evaluations);

fs.writeFileSync(
path.join(reportsDir, "event-sponsorship-readiness-report.json"),
JSON.stringify({ summary, evaluations }, null, 2)
);

const markdown = [
"# Event Sponsorship Revenue Readiness Guard - Demo Report",
"",
"Synthetic reviewer demo for SCIBASE Revenue Infrastructure issue #20.",
"",
"## Summary",
"",
`- Packets evaluated: ${summary.packet_count}`,
`- Ready to invoice: ${summary.packets_ready_to_invoice}`,
`- Needs finance review: ${summary.packets_requiring_review}`,
`- Held before invoice: ${summary.packets_on_hold}`,
"",
"## Decisions",
"",
"| Packet | Sponsor | Tier | Decision | Score | Top reasons |",
"| --- | --- | --- | --- | ---: | --- |",
...evaluations.map((item) => {
const topReasons = item.reasons.slice(0, 3).map((reason) => reason.code).join(", ") || "none";
return `| ${item.packet_id} | ${item.sponsor_id} | ${item.package_tier} | ${item.decision} | ${item.readiness_score} | ${topReasons} |`;
}),
"",
"## Boundary",
"",
"- Synthetic data only.",
"- No payment processors called.",
"- No real sponsor/customer data used.",
"- No external APIs used.",
"- No credentials, bank data, Stripe, PayPal, ERP, or accounting systems touched.",
""
].join("\n");

fs.writeFileSync(path.join(reportsDir, "event-sponsorship-readiness-report.md"), markdown);

const bar = (label, value, color, y) => `
<text x="52" y="${y - 8}" fill="#111827" font-size="14" font-family="Arial">${label}</text>
<rect x="52" y="${y}" width="520" height="24" rx="6" fill="#e5e7eb"/>
<rect x="52" y="${y}" width="${Math.max(1, value * 130)}" height="24" rx="6" fill="${color}"/>
<text x="${590}" y="${y + 17}" fill="#111827" font-size="14" font-family="Arial">${value}</text>`;

const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="900" height="520" viewBox="0 0 900 520">
<rect width="900" height="520" fill="#f8fafc"/>
<text x="52" y="72" fill="#0f172a" font-size="30" font-weight="700" font-family="Arial">Event Sponsorship Revenue Readiness</text>
<text x="52" y="106" fill="#475569" font-size="16" font-family="Arial">Synthetic guard demo for invoice release, finance review, and hold decisions.</text>
${bar("RELEASE_INVOICE", summary.decision_counts.RELEASE_INVOICE, "#16a34a", 160)}
${bar("REVIEW_BEFORE_RELEASE", summary.decision_counts.REVIEW_BEFORE_RELEASE, "#f59e0b", 220)}
${bar("HOLD_INVOICE", summary.decision_counts.HOLD_INVOICE, "#dc2626", 280)}
<rect x="52" y="360" width="796" height="96" rx="10" fill="#ffffff" stroke="#cbd5e1"/>
<text x="80" y="394" fill="#111827" font-size="16" font-weight="700" font-family="Arial">Boundary</text>
<text x="80" y="424" fill="#475569" font-size="14" font-family="Arial">Synthetic data only · no payment processors · no external APIs · no credentials</text>
<text x="80" y="448" fill="#475569" font-size="14" font-family="Arial">Designed to make event sponsorship revenue release auditable before invoice actions.</text>
</svg>`;

fs.writeFileSync(path.join(reportsDir, "event-sponsorship-readiness-summary.svg"), svg.replace(/[ \t]+$/gm, ""));

console.log(JSON.stringify({ summary, report_dir: reportsDir }, null, 2));
265 changes: 265 additions & 0 deletions event-sponsorship-revenue-readiness-guard/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
const crypto = require("crypto");

const DECISIONS = Object.freeze({
RELEASE: "RELEASE_INVOICE",
REVIEW: "REVIEW_BEFORE_RELEASE",
HOLD: "HOLD_INVOICE"
});

const SEVERITY_WEIGHT = Object.freeze({
info: 0,
review: 1,
hold: 3
});

function stableStringify(value) {
if (Array.isArray(value)) {
return `[${value.map(stableStringify).join(",")}]`;
}
if (value && typeof value === "object") {
return `{${Object.keys(value)
.sort()
.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
.join(",")}}`;
}
return JSON.stringify(value);
}

function stableHash(value) {
return crypto
.createHash("sha256")
.update(stableStringify(value))
.digest("hex");
}

function reason(code, severity, message, remediation) {
return { code, severity, message, remediation };
}

function missingRequiredDeliverables(packet) {
return (packet.deliverables || []).filter((item) => item.required && !item.completed);
}

function unsignedRequiredDeliverables(packet) {
return (packet.deliverables || []).filter(
(item) => item.required && item.completed && !item.sponsorApproved
);
}

function missingEvidence(packet) {
return (packet.evidence || []).filter((item) => !item.present);
}

function evaluateSponsorshipRevenueReadiness(packet) {
const reasons = [];
const financeActions = [];
const contract = packet.contract || {};
const leadGuarantee = packet.leadGuarantee || {};
const exclusivity = packet.exclusivity || {};
const missingDeliverables = missingRequiredDeliverables(packet);
const unsignedDeliverables = unsignedRequiredDeliverables(packet);
const evidenceGaps = missingEvidence(packet);
const delivered = Number(leadGuarantee.qualifiedDelivered || 0);
const promised = Number(leadGuarantee.promised || 0);
const minimumAcceptable = Number(leadGuarantee.minimumAcceptable || 0);
const leadShortfall = Math.max(0, minimumAcceptable - delivered);

if (!contract.signed) {
reasons.push(
reason(
"SPONSOR_CONTRACT_UNSIGNED",
"hold",
"Sponsorship contract is not signed.",
"Collect signed sponsorship agreement before invoicing or releasing revenue."
)
);
financeActions.push("hold invoice until contract is signed");
}

if (!contract.purchaseOrderApproved) {
reasons.push(
reason(
"PURCHASE_ORDER_NOT_APPROVED",
contract.signed ? "review" : "hold",
"Purchase order or finance approval is missing.",
"Attach approved PO or finance approval before invoice release."
)
);
financeActions.push("request PO or finance approval evidence");
}

if (!contract.sponsorApproval) {
reasons.push(
reason(
"SPONSOR_APPROVAL_MISSING",
"hold",
"Sponsor has not approved the final package state.",
"Obtain sponsor signoff for package scope before recognizing revenue readiness."
)
);
financeActions.push("collect sponsor package approval");
}

if (!contract.cancellationWindowClosed || Number(contract.refundExposureUsd || 0) > 0) {
reasons.push(
reason(
"REFUND_WINDOW_OR_EXPOSURE_OPEN",
"hold",
"Cancellation or refund exposure is still open.",
"Defer invoice release or record finance review until refund exposure is cleared."
)
);
financeActions.push("defer release until cancellation/refund exposure closes");
}

if ((exclusivity.conflicts || []).length > 0) {
reasons.push(
reason(
"SPONSOR_EXCLUSIVITY_CONFLICT",
"hold",
"Sponsor category exclusivity conflicts with another sponsor.",
"Resolve category conflict or amend sponsorship package before release."
)
);
financeActions.push("route exclusivity conflict to sponsorship owner");
}

for (const deliverable of missingDeliverables) {
reasons.push(
reason(
"REQUIRED_DELIVERABLE_INCOMPLETE",
"hold",
`Required deliverable is incomplete: ${deliverable.label}.`,
"Complete the deliverable or reduce the invoiceable package scope."
)
);
}

for (const deliverable of unsignedDeliverables) {
reasons.push(
reason(
"DELIVERABLE_SIGNOFF_MISSING",
"review",
`Required deliverable lacks sponsor signoff: ${deliverable.label}.`,
"Collect sponsor signoff or mark the line item for manual finance review."
)
);
}

if (leadShortfall > 0) {
const severity = delivered < promised * 0.75 ? "hold" : "review";
reasons.push(
reason(
"ATTENDEE_LEAD_GUARANTEE_SHORTFALL",
severity,
`Qualified leads delivered (${delivered}) are below the acceptable floor (${minimumAcceptable}).`,
"Deliver remaining qualified leads, apply make-good credit, or reduce invoice amount."
)
);
financeActions.push("calculate make-good credit or revised invoice amount");
}

for (const gap of evidenceGaps) {
reasons.push(
reason(
"PROOF_ARTIFACT_MISSING",
"review",
`Proof artifact is missing: ${gap.type}.`,
"Attach proof artifact before revenue packet is marked audit-ready."
)
);
}

const highestWeight = reasons.reduce(
(weight, item) => Math.max(weight, SEVERITY_WEIGHT[item.severity] || 0),
0
);
const decision =
highestWeight >= SEVERITY_WEIGHT.hold
? DECISIONS.HOLD
: highestWeight >= SEVERITY_WEIGHT.review
? DECISIONS.REVIEW
: DECISIONS.RELEASE;

const completedRequired = (packet.deliverables || []).filter(
(item) => item.required && item.completed && item.sponsorApproved
).length;
const totalRequired = (packet.deliverables || []).filter((item) => item.required).length;
const deliverableReadiness = totalRequired === 0 ? 100 : Math.round((completedRequired / totalRequired) * 100);
const leadReadiness =
minimumAcceptable === 0 ? 100 : Math.min(100, Math.round((delivered / minimumAcceptable) * 100));
const evidenceReadiness =
(packet.evidence || []).length === 0
? 0
: Math.round((((packet.evidence || []).length - evidenceGaps.length) / (packet.evidence || []).length) * 100);

const readinessScore = Math.max(
0,
Math.min(
100,
Math.round(
deliverableReadiness * 0.35 +
leadReadiness * 0.25 +
evidenceReadiness * 0.2 +
(contract.signed ? 10 : 0) +
(contract.purchaseOrderApproved ? 10 : 0)
)
)
);

return {
schema_version: "event_sponsorship_revenue_readiness_guard_v1",
packet_id: packet.id,
event_id: packet.eventId,
sponsor_id: packet.sponsorId,
package_tier: packet.packageTier,
invoice_amount_usd: packet.invoiceAmountUsd,
decision,
readiness_score: readinessScore,
reason_count: reasons.length,
reasons,
finance_actions: [...new Set(financeActions)],
metrics: {
deliverable_readiness_percent: deliverableReadiness,
lead_readiness_percent: leadReadiness,
evidence_readiness_percent: evidenceReadiness,
lead_shortfall: leadShortfall,
exclusivity_conflict_count: (exclusivity.conflicts || []).length,
refund_exposure_usd: Number(contract.refundExposureUsd || 0)
},
audit_packet: {
synthetic_data_only: true,
external_apis_used: false,
payment_processors_called: false,
private_customer_data_used: false,
packet_sha256: stableHash(packet)
}
};
}

function summarizeEvaluations(evaluations) {
const counts = evaluations.reduce(
(acc, item) => {
acc[item.decision] = (acc[item.decision] || 0) + 1;
return acc;
},
{ [DECISIONS.RELEASE]: 0, [DECISIONS.REVIEW]: 0, [DECISIONS.HOLD]: 0 }
);

return {
schema_version: "event_sponsorship_revenue_readiness_summary_v1",
packet_count: evaluations.length,
decision_counts: counts,
packets_ready_to_invoice: counts[DECISIONS.RELEASE],
packets_requiring_review: counts[DECISIONS.REVIEW],
packets_on_hold: counts[DECISIONS.HOLD],
audit_note:
"Synthetic event sponsorship packets only; no payment processors, bank data, credentials, or external APIs used."
};
}

module.exports = {
DECISIONS,
evaluateSponsorshipRevenueReadiness,
summarizeEvaluations
};
Loading