#!/usr/bin/env python3 # RoyaltyWaterfall - deterministic period royalty allocation engine. # Exact rational arithmetic (fractions.Fraction); integer-cent conservation via # largest-remainder settlement. Pure standard library; runs in CPython and Pyodide. # No network, no input execution. SPEC.json = royaltywaterfall/v1. import sys, os, io, json, csv, hashlib, zipfile, math, tempfile, html, copy from fractions import Fraction SCHEMA = 'royaltywaterfall/v1' VERSION = '1.0.0' MAX_BYTES = 5000000 MAX_ITEMS = 100000 MAX_NODES = 1000 NODE_TYPES = ('fee', 'recoup', 'tier', 'share') class ValidationError(ValueError): pass class UnknownError(Exception): pass def _is_bool(x): return isinstance(x, bool) def _req_int(x, field): if _is_bool(x) or not isinstance(x, int) or abs(x)>10**15: raise ValidationError(field + ' must be an integer minor-unit value') return x def _frac(x, field): if _is_bool(x): raise ValidationError(field + ' must not be a boolean') if isinstance(x, int): return Fraction(_req_int(x,field)) if isinstance(x, str): if len(x)>64: raise ValidationError(field+' rational length limit') try: result=Fraction(x) if abs(result.numerator)>10**15 or result.denominator>10**12: raise ValidationError(field+' rational magnitude limit') return result except (ValueError, ZeroDivisionError): raise ValidationError(field + " is not a valid rational ('a/b' or decimal string)") raise ValidationError(field + ' must be a rational string; floats are rejected as ambiguous') def _reject_const(s): raise ValidationError('non-finite number ' + str(s) + ' is not allowed') def _validate_structure(payload): if not isinstance(payload, dict): raise ValidationError('payload must be a JSON object') if payload.get('schema') != SCHEMA: raise ValidationError("schema must be '" + SCHEMA + "'") currency = payload.get('currency') precision={'USD':2,'EUR':2,'GBP':2,'CAD':2,'AUD':2,'NZD':2,'CHF':2,'JPY':0,'KWD':3} if currency not in precision: raise ValidationError('unsupported currency precision; supported: '+','.join(precision)) if 'currency_precision' in payload and (type(payload['currency_precision']) is not int or payload['currency_precision']!=precision[currency]): raise ValidationError('currency_precision mismatch') period = payload.get('period') if not isinstance(period, str) or not period: raise ValidationError('period must be a non-empty string') receipts = payload.get('receipts') if not isinstance(receipts, list): raise ValidationError('receipts must be a list') if len(receipts) > MAX_ITEMS: raise ValidationError('receipts exceed size bound') ob = payload.get('opening_balances', {}) if not isinstance(ob, dict): raise ValidationError('opening_balances must be an object') for k, v in ob.items(): if not isinstance(k, str) or not k: raise ValidationError('opening_balances keys must be non-empty strings') b = _req_int(v, 'opening_balances[' + str(k) + ']') if b < 0: raise ValidationError('opening_balances must be >= 0') waterfall = payload.get('waterfall') if not isinstance(waterfall, list) or not waterfall: raise ValidationError('waterfall must be a non-empty list of nodes') if len(waterfall) > MAX_NODES: raise ValidationError('waterfall exceeds node bound') seen = set() for i, r in enumerate(receipts): if not isinstance(r, dict): raise ValidationError('receipts[' + str(i) + '] must be an object') eid = r.get('event_id') if not isinstance(eid, str) or not eid: raise ValidationError('receipts[' + str(i) + '].event_id must be a non-empty string') if eid in seen: raise ValidationError('duplicate event_id ' + eid) seen.add(eid) src = r.get('source') if not isinstance(src, str) or not src: raise ValidationError('receipts[' + str(i) + '].source must be a non-empty string') amt = _req_int(r.get('amount_minor'), 'receipts[' + str(i) + '].amount_minor') if amt < 0: raise ValidationError('amount_minor must be >= 0 after referenced reversal replay') cur = r.get('currency', currency) if cur != currency: raise ValidationError('mixed currency not supported in one run; run currency-separated') _validate_nodes(waterfall) node_ids = set() for i, n in enumerate(waterfall): if not isinstance(n, dict): raise ValidationError('waterfall[' + str(i) + '] must be an object') nid = n.get('id') if not isinstance(nid, str) or not nid: raise ValidationError('waterfall[' + str(i) + '].id must be a non-empty string') if nid in node_ids: raise ValidationError('duplicate node id ' + nid) node_ids.add(nid) nt = n.get('type') if nt not in NODE_TYPES: raise ValidationError('waterfall[' + str(i) + '].type must be one of ' + str(NODE_TYPES)) basis = n.get('basis', 'remaining' if nt != 'tier' else 'gross') if basis not in ('gross', 'remaining'): raise ValidationError(nid + '.basis must be gross or remaining') if nt == 'fee': if not isinstance(n.get('to'), str) or not n.get('to'): raise ValidationError(nid + '.to recipient required') rate = _frac(n.get('rate'), nid + '.rate') if rate < 0 or rate > 1: raise ValidationError(nid + '.rate must be within [0,1]') elif nt == 'recoup': if not isinstance(n.get('to'), str) or not n.get('to'): raise ValidationError(nid + '.to recipient required') if not isinstance(n.get('pool'), str) or not n.get('pool'): raise ValidationError(nid + '.pool required') sh = _frac(n.get('share', 1), nid + '.share') if sh < 0 or sh > 1: raise ValidationError(nid + '.share must be within [0,1]') elif nt == 'tier': if not isinstance(n.get('to'), str) or not n.get('to'): raise ValidationError(nid + '.to recipient required') th = n.get('thresholds') if not isinstance(th, list) or not th: raise ValidationError(nid + '.thresholds must be a non-empty list') prev = None for j, b in enumerate(th): if not isinstance(b, dict): raise ValidationError(nid + '.thresholds[' + str(j) + '] must be an object') up = b.get('upto', None) if up is not None: up = _req_int(up, nid + '.thresholds upto') if up <= 0: raise ValidationError(nid + ' tier upto must be > 0') if prev is not None and up <= prev: raise ValidationError(nid + ' tier thresholds must be strictly ascending') prev = up else: if j != len(th) - 1: raise ValidationError(nid + ' only the last tier band may be open (upto null)') r = _frac(b.get('rate'), nid + '.thresholds rate') if r < 0 or r > 1: raise ValidationError(nid + ' tier rate must be within [0,1]') elif nt == 'share': sp = n.get('splits') if not isinstance(sp, list) or not sp: raise ValidationError(nid + '.splits must be a non-empty list') total = Fraction(0) for j, s in enumerate(sp): if not isinstance(s, dict): raise ValidationError(nid + '.splits[' + str(j) + '] must be an object') if not isinstance(s.get('to'), str) or not s.get('to'): raise ValidationError(nid + '.splits[' + str(j) + '].to required') sv = _frac(s.get('share'), nid + '.splits share') if sv < 0: raise ValidationError(nid + ' split share must be >= 0') total += sv if total > 1: raise ValidationError(nid + ' share splits exceed 1 (sum=' + str(total) + ')') return currency, period, receipts, waterfall, ob def _tier_amount(basis_pot, thresholds): amt = Fraction(0) lower = Fraction(0) for b in thresholds: up = b.get('upto', None) rate = _frac(b.get('rate'), 'tier.rate') upper = basis_pot if up is None else min(basis_pot, Fraction(up)) portion = upper - lower if portion > 0: amt += portion * rate if up is None: break lower = Fraction(up) if basis_pot <= up: break return amt def _evaluate(currency, period, receipts, waterfall, ob): if not receipts: raise UnknownError('no receipts supplied for the period; allocation is undefined (missing observation, not zero)') pot = sum(int(r['amount_minor']) for r in receipts) gross = Fraction(pot) remaining = gross accounts = {} trace = [] outstanding = {k: Fraction(int(v)) for k, v in ob.items()} opening = dict(outstanding) def add(acct, amt): accounts[acct] = accounts.get(acct, Fraction(0)) + amt step = 0 for n in waterfall: step += 1 nt = n['type'] nid = n['id'] basis = n.get('basis', 'remaining' if nt != 'tier' else 'gross') basis_pot = gross if basis == 'gross' else remaining before = remaining if nt == 'fee': amt = _frac(n['rate'], nid) * basis_pot if amt > remaining: raise ValidationError(nid+' claims more than remaining pot; gross-basis overlap is refused') add(n['to'], amt) remaining -= amt target = n['to'] elif nt == 'recoup': pool = n['pool'] if pool not in outstanding: raise UnknownError("outstanding recoupment balance for pool '" + pool + "' not supplied; cannot recoup without the opening balance") avail = _frac(n.get('share', 1), nid) * basis_pot if avail > remaining: avail = remaining # Recoupment settles whole minor units at this explicit boundary. taken = min(Fraction(avail.numerator//avail.denominator), outstanding[pool]) add(n['to'], taken) remaining -= taken outstanding[pool] -= taken amt = taken target = n['to'] elif nt == 'tier': amt = _tier_amount(basis_pot, n['thresholds']) if n.get('mode','marginal')=='marginal' else basis_pot*next(_frac(b['rate'],nid) for b in n['thresholds'] if b.get('upto') is None or basis_pot<=b['upto']) if amt > remaining: raise ValidationError(nid+' claims more than remaining pot; gross-basis overlap is refused') add(n['to'], amt) remaining -= amt target = n['to'] else: amt = Fraction(0) parts = [] for s in n['splits']: sv = _frac(s['share'], nid) * basis_pot add(s['to'], sv) amt += sv parts.append(s['to'] + '=' + str(sv)) if amt > remaining: raise ValidationError(nid+' claims more than remaining pot; gross-basis overlap is refused') remaining -= amt target = 'split:' + ';'.join(parts) trace.append({'step': step, 'node_id': nid, 'node_type': nt, 'target': target, 'amount_exact': str(amt), 'pot_before_exact': str(before), 'pot_after_exact': str(remaining)}) unallocated = remaining return accounts, trace, opening, outstanding, unallocated, pot def _round_conserve(accounts, unallocated, total_int): keys = sorted(accounts.keys()) buckets = [(k, accounts[k]) for k in keys] + [('__unallocated__', unallocated)] floors = [] total_floor = 0 for k, v in buckets: f = v.numerator // v.denominator floors.append([k, f, v - f]) total_floor += f if any(v<0 for _,v in buckets) or sum(v for _,v in buckets)!=total_int: raise ValidationError('conservation invariant violated before settlement') deficit = total_int - total_floor if not 0<=deficit 0: findings.append({'severity': 'info', 'code': 'residual', 'message': 'residual ' + str(unalloc_int) + ' minor units unallocated; review terminal share node'}) for pool in sorted(outstanding.keys()): if outstanding[pool] > 0: findings.append({'severity': 'info', 'code': 'carryforward', 'message': 'pool ' + pool + ' carries ' + str(outstanding[pool]) + ' minor units of unrecouped balance to next period'}) summary = {'currency': currency, 'period': period, 'total_receipts_minor': pot, 'total_allocated_minor': total_alloc, 'unallocated_minor': unalloc_int, 'recipient_count': len(recip_ids), 'node_count': len(waterfall)} return {'status': 'PASS', 'summary': summary, 'tables': tables, 'findings': findings, 'provenance': _prov(payload)} def _neutralize(v): if isinstance(v, str) and v.lstrip(' \t\r\n')[:1] in ('=', '+', '-', '@'): return "'" + v return v def _rows_to_csv(rows): if not rows: return '' out = io.StringIO() fields = list(rows[0].keys()) w = csv.writer(out, lineterminator='\n') w.writerow(fields) for r in rows: w.writerow([_neutralize(r.get(k, '')) for k in fields]) return out.getvalue() README_TXT = '\n'.join([ 'RoyaltyWaterfall ' + VERSION + ' - offline royalty allocation kit', 'Schema: ' + SCHEMA, 'Scope: allocates separate project/currency/period pots through a typed, acyclic', 'priority waterfall (fee, recoup, tier, share) using exact rational arithmetic, then', 'conserves cents with largest-remainder settlement (stable recipient order).', 'Outputs are DRAFTS for customer review. This program does not read contracts, decide', 'rights, issue invoices, send statements or move money.', 'Files: report.json (full result), statements.csv (per-recipient minor units),', 'waterfall-trace.csv (exact per-node amounts), unallocated.csv, carryforward.csv', '(next-period opening balances), input.json (your exact payload), manifest.json', '(SHA256 of every file). Rerun: python3 product.py input.json OUTPUT_DIR', ]) def offline_page(name,title,status,href,action,content): template = """{title}

{name} · local computation

{title}

{status}

Scope
Supported inputs only
Review
Draft observed output
{action}
{content}
""" return template.format(name=html.escape(name),title=html.escape(title),status=html.escape(status),href=html.escape(href,quote=True),action=html.escape(action),content=content) SHARED_CSS = '/* ==========================================================================\n Dated change feeds — aligned to the ustechautomations.com design system.\n Tokens, type scale, radius and accent are lifted from the live main site.\n Take them from assets/WebsiteStyleProvider-*.css, NOT assets/root-*.css: the\n root sheet is the app\'s palette and the website overrides it. Reading the\n wrong one is how these pages ended up with a near-black button where the\n marketing site has a blue one.\n\n Look only. Prices, sample rows, event ids and honesty lines live in the\n HTML and are frozen: never restyle a fact out of existence.\n Each family page sets data-family on . Nothing in this file reads it:\n there is one accent and every family shares it. The attribute is kept for\n the page\'s own scripts and for anyone grepping the built site by family.\n ========================================================================== */\n\n@font-face {\n font-family: "Satoshi";\n src: url("https://ustechautomations.com/fonts/Satoshi-Variable.woff2") format("woff2-variations"),\n url("https://ustechautomations.com/fonts/Satoshi-Variable.woff2") format("woff2");\n font-weight: 300 900;\n font-display: swap;\n font-style: normal;\n}\n\n:root {\n /* type — same stack the main site resolves --font-sans to */\n --sans: "Satoshi", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;\n --serif: "PT Serif", ui-serif, Georgia, "Times New Roman", serif;\n --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;\n\n /* main-site tokens, light */\n --background: 220 10% 99%;\n --foreground: 220 20% 10%;\n --card: 0 0% 100%;\n --card-foreground: 220 20% 10%;\n --secondary: 220 12% 97%;\n --muted: 220 12% 97%;\n --muted-foreground: 220 10% 42%;\n --border: 220 13% 94%;\n --primary: 207 100% 50%;\n --primary-foreground: 0 0% 100%;\n --primary-hover: 207 100% 44%;\n --accent-blue: 207 100% 50%;\n --accent-emerald: 149 80% 90%;\n --accent-emerald-fg: 161 94% 30%;\n --accent-amber: 48 96% 89%;\n --accent-amber-fg: 26 90% 37%;\n --radius: .5rem;\n --shadow-color: 220 15% 60%;\n\n /* resolved */\n --bg: hsl(var(--background));\n --surface: hsl(var(--card));\n --surface-2: hsl(var(--secondary));\n --fg: hsl(var(--foreground));\n --muted-fg: hsl(var(--muted-foreground));\n --faint: hsl(var(--muted-foreground) / .78);\n --line: hsl(var(--border));\n --line-strong: hsl(var(--border) / 1.6);\n --accent: hsl(var(--accent-blue));\n --shadow-sm: 0 1px 2px hsl(var(--shadow-color) / .08);\n --shadow-md: 0 4px 16px -4px hsl(var(--shadow-color) / .16);\n}\n\n@media (prefers-color-scheme: dark) {\n :root:not([data-theme="light"]) {\n --background: 220 15% 8%;\n --foreground: 220 15% 96%;\n --card: 220 12% 10%;\n --card-foreground: 220 15% 96%;\n --secondary: 220 10% 16%;\n --muted: 220 10% 16%;\n --muted-foreground: 220 8% 62%;\n --border: 220 10% 18%;\n --primary: 207 100% 50%;\n --primary-foreground: 0 0% 100%;\n --primary-hover: 207 100% 44%;\n --accent-blue: 207 100% 60%;\n --accent-emerald: 164 86% 16%;\n --accent-emerald-fg: 158 64% 52%;\n --accent-amber: 22 78% 26%;\n --accent-amber-fg: 45.9 96.7% 64.5%;\n --shadow-color: 220 20% 4%;\n }\n}\n:root[data-theme="dark"] {\n --background: 220 15% 8%;\n --foreground: 220 15% 96%;\n --card: 220 12% 10%;\n --card-foreground: 220 15% 96%;\n --secondary: 220 10% 16%;\n --muted: 220 10% 16%;\n --muted-foreground: 220 8% 62%;\n --border: 220 10% 18%;\n --primary: 207 100% 50%;\n --primary-foreground: 0 0% 100%;\n --primary-hover: 207 100% 44%;\n --accent-blue: 207 100% 60%;\n --accent-emerald: 164 86% 16%;\n --accent-emerald-fg: 158 64% 52%;\n --accent-amber: 22 78% 26%;\n --accent-amber-fg: 45.9 96.7% 64.5%;\n --shadow-color: 220 20% 4%;\n}\n\n*, *::before, *::after { box-sizing: border-box; }\n\nhtml { -webkit-text-size-adjust: 100%; background: hsl(var(--background)); }\n\nbody {\n margin: 0;\n background: var(--bg);\n color: var(--fg);\n font-family: var(--sans);\n font-size: 16px;\n font-weight: 500;\n line-height: 1.65;\n -webkit-font-smoothing: antialiased;\n text-rendering: optimizeLegibility;\n}\n\n.wrap { width: 100%; max-width: 1280px; margin: 0 auto; padding: 0 1.5rem; }\n@media (min-width: 768px) { .wrap { padding: 0 2rem; } }\n\na { color: var(--accent); text-decoration: none; }\na:hover { text-decoration: underline; }\n\n.skip {\n position: absolute; left: -9999px; top: 0;\n background: var(--surface); color: var(--fg);\n padding: .75rem 1rem; border: 1px solid var(--line);\n border-radius: var(--radius); z-index: 100;\n}\n.skip:focus { left: 1rem; top: 1rem; }\n\n/* ---------- masthead: mirrors the main-site sticky header ---------- */\n.masthead {\n position: sticky; top: 0; z-index: 40;\n width: 100%;\n border-bottom: 1px solid var(--line);\n background: hsl(var(--background) / .8);\n backdrop-filter: blur(8px);\n -webkit-backdrop-filter: blur(8px);\n}\n.masthead .wrap {\n display: flex; align-items: center; gap: 1.25rem;\n min-height: 64px; flex-wrap: wrap;\n}\n@media (min-width: 1024px) { .masthead .wrap { flex-wrap: nowrap; } }\n.wordmark {\n display: inline-flex; align-items: center; gap: .5rem;\n font-weight: 900; font-size: 1rem; letter-spacing: normal;\n text-transform: uppercase;\n color: var(--fg); text-decoration: none; white-space: nowrap;\n}\n@media (min-width: 640px) { .wordmark { font-size: 1.25rem; letter-spacing: .025em; } }\n.usta-logo { width: 24px; height: 24px; display: block; flex: none; }\n.wordmark:hover { text-decoration: none; opacity: .8; }\n.wordmark span { font-weight: 500; color: var(--muted-fg); }\n\n.mast-nav { display: none; gap: 2rem; margin-left: 2rem; align-items: center; flex: 1; }\n/* main site keeps the links beside the wordmark and the CTA hard right */\n.mast-cta { margin-left: auto; }\n@media (min-width: 1024px) { .mast-nav { display: flex; } }\n.mast-nav a {\n font-size: .875rem; font-weight: 500; color: var(--fg); text-decoration: none;\n}\n.mast-nav a:hover { color: hsl(var(--primary)); text-decoration: none; }\n.mast-cta {\n display: inline-flex; align-items: center;\n background: hsl(var(--primary)); color: hsl(var(--primary-foreground));\n font-size: .875rem; font-weight: 600;\n padding: .5rem 1rem; border-radius: var(--radius);\n text-decoration: none; white-space: nowrap;\n}\n.mast-cta:hover { background: hsl(var(--primary-hover)); text-decoration: none; }\n\n.crumbs {\n width: 100%; margin: 0 0 .75rem; font-size: .8125rem; color: var(--muted-fg);\n}\n.crumbbar { border-bottom: 1px solid var(--line); background: hsl(var(--background)); }\n.crumbbar .crumbs {\n width: auto; margin: 0; padding: .625rem 0;\n white-space: nowrap; overflow-x: auto;\n}\n.crumbs a { color: var(--muted-fg); }\n.crumbs .sep { padding: 0 .5rem; opacity: .5; }\n\n/* ---------- hero ---------- */\n.hero { padding: 3.5rem 0 2rem; }\n.hero h1 {\n font-size: 3rem;\n line-height: 1.15; letter-spacing: -.025em; font-weight: 500;\n margin: 0 0 1rem; max-width: 20ch;\n}\n@media (min-width: 768px) { .hero h1 { font-size: 3.75rem; } }\n@media (min-width: 1024px) { .hero h1 { font-size: 4.5rem; letter-spacing: -.03em; } }\n.eyebrow {\n display: flex; align-items: center; flex-wrap: wrap; gap: .5rem;\n margin: 0 0 1rem;\n font-size: .8125rem; font-weight: 600; letter-spacing: .04em; text-transform: uppercase;\n color: var(--muted-fg);\n}\n.dot { width: 3px; height: 3px; border-radius: 999px; background: currentColor; opacity: .55; display: inline-block; }\n.lede {\n font-size: clamp(1.0625rem, 1rem + .35vw, 1.25rem);\n line-height: 1.6; color: var(--muted-fg); max-width: 68ch; margin: 0 0 2rem;\n}\n.lede strong { color: var(--fg); font-weight: 600; }\n\n/* ---------- fact rail ---------- */\n.rail {\n display: grid; gap: 1px; margin: 0;\n grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));\n background: var(--line);\n border: 1px solid var(--line);\n border-radius: var(--radius);\n overflow: hidden;\n}\n.rail > div { background: var(--surface); padding: 1rem 1.25rem; }\n.rail dt {\n font-size: .75rem; font-weight: 600; letter-spacing: .05em; text-transform: uppercase;\n color: var(--muted-fg); margin: 0 0 .375rem;\n}\n.rail dd { margin: 0; font-size: .9375rem; font-weight: 500; }\n.rail dd.price, .amount {\n font-size: 1.25rem; font-weight: 700; letter-spacing: -.02em;\n font-variant-numeric: tabular-nums;\n}\n\n/* ---------- main ---------- */\nmain { padding: 1rem 0 4rem; }\nmain section { margin: 0 0 2.75rem; max-width: 76ch; }\n/* Evidence tables use the full 1280px container the main site\'s sections use,\n while the prose around them keeps its reading measure. */\nmain section:has(.evidence) { max-width: none; }\nmain section:has(.evidence) > p,\nmain section:has(.evidence) > h2,\nmain section:has(.evidence) > h3 { max-width: 76ch; }\n/* Main-site heading character (weight/tracking/leading are its h1-h6 rule).\n Sizes step one rung down its ladder: a feed page\'s h2 is a subsection of a\n record, not a marketing section header, so it takes the site\'s h3 numbers. */\nh2 {\n font-size: 1.875rem; font-weight: 500; letter-spacing: -.025em; line-height: 1.15;\n margin: 0 0 .875rem;\n}\nh3 {\n font-size: 1.5rem; font-weight: 500; letter-spacing: -.025em; line-height: 1.15;\n margin: 0 0 .5rem;\n}\n@media (min-width: 768px) {\n h2 { font-size: 2.25rem; }\n h3 { font-size: 1.875rem; }\n}\np { margin: 0 0 1rem; line-height: 1.625; }\nmain p { color: hsl(var(--foreground) / .86); }\nstrong { font-weight: 650; color: var(--fg); }\n\n/* ---------- pills ---------- */\n.pill {\n display: inline-flex; align-items: center; gap: .375rem;\n font-size: .75rem; font-weight: 650; letter-spacing: .01em;\n padding: .25rem .625rem; border-radius: 999px;\n border: 1px solid transparent; white-space: nowrap;\n}\n.pill-hold {\n background: hsl(var(--accent-amber)); color: hsl(var(--accent-amber-fg));\n border-color: hsl(var(--accent-amber-fg) / .22);\n}\n.pill-ready {\n background: hsl(var(--accent-emerald)); color: hsl(var(--accent-emerald-fg));\n border-color: hsl(var(--accent-emerald-fg) / .22);\n}\n\n/* ---------- honesty panel ---------- */\n.honest {\n border: 1px solid hsl(var(--accent-amber-fg) / .3);\n background: hsl(var(--accent-amber) / .5);\n border-radius: var(--radius);\n padding: 1.125rem 1.25rem;\n}\n.honest p:last-child { margin-bottom: 0; }\n.honest strong { color: hsl(var(--accent-amber-fg)); }\n\n/* The same disclosure, sitting in a hero rather than in the body. Five family\n pages used this class with nothing defined for it, so their "not for sale\n yet" line rendered as ordinary body text while the identical sentence on\n dc-buildout got a panel. One shop, one look. */\n.hero-note {\n border: 1px solid hsl(var(--accent-amber-fg) / .3);\n background: hsl(var(--accent-amber) / .5);\n border-radius: var(--radius);\n padding: 1rem 1.125rem;\n margin: 0 0 1.25rem;\n max-width: 62ch;\n}\n.hero-note strong { color: hsl(var(--accent-amber-fg)); }\n\n/* ---------- spec list ---------- */\n.spec { list-style: none; margin: 0 0 1rem; padding: 0; display: grid; gap: .75rem; }\n.spec li {\n display: grid; gap: .125rem;\n border-left: 2px solid var(--line-strong);\n padding: .125rem 0 .125rem 1rem;\n}\n.spec li strong { font-weight: 650; }\n.sub { color: var(--muted-fg); font-size: .9375rem; }\n\n/* ---------- cards (hub) ---------- */\n.cards { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); }\n.hub-groups { display: grid; gap: 2.5rem; }\n.group { max-width: none; }\n\n/* The list of a feed\'s own pages, printed on the feed\'s page. Columns, not one\n long line: twenty-seven states stacked vertically is a wall, and a reader\n scanning for one name reads across a column far faster than down a list.\n break-inside keeps a name from being split over a column boundary. */\n.slice-index { margin-top: 2.75rem; border-top: 1px solid var(--line); padding-top: 1.75rem; }\n.slice-index h2 { margin-bottom: .9rem; }\n.slice-list { columns: 3 190px; column-gap: 1.75rem; list-style: none; padding: 0; margin: 0 0 .9rem; }\n.slice-list li { break-inside: avoid; margin: 0 0 .5rem; }\n.slice-list a { text-decoration: none; }\n.slice-list a:hover { text-decoration: underline; }\n.card {\n display: block; background: var(--surface);\n border: 1px solid var(--line); border-radius: var(--radius);\n padding: 1.25rem; color: inherit; text-decoration: none;\n box-shadow: var(--shadow-sm);\n transition: box-shadow .15s ease, border-color .15s ease, transform .15s ease;\n}\na.card:hover {\n text-decoration: none; border-color: hsl(var(--accent-blue) / .5);\n box-shadow: var(--shadow-md); transform: translateY(-1px);\n}\n.card h3 { margin: 0 0 .375rem; }\n.card .who { color: var(--muted-fg); font-size: .875rem; margin: 0 0 .875rem; }\n.card .meta { display: flex; align-items: center; gap: .625rem; flex-wrap: wrap; font-size: .8125rem; color: var(--muted-fg); }\n\n/* ---------- evidence / sample panels ---------- */\n.evidence {\n border: 1px solid var(--line); border-radius: var(--radius);\n overflow: hidden; background: var(--surface); box-shadow: var(--shadow-sm);\n}\n.evidence-head {\n display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap;\n padding: .75rem 1.125rem; background: var(--surface-2);\n border-bottom: 1px solid var(--line);\n font-size: .8125rem; font-weight: 600; color: var(--muted-fg);\n}\n.evidence-body { padding: 1.125rem; }\n.blank { color: var(--muted-fg); font-style: italic; }\n.scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; }\n\ntable { width: 100%; border-collapse: collapse; font-size: .875rem; }\nth, td { text-align: left; padding: .625rem .75rem; border-bottom: 1px solid var(--line); }\nth {\n font-size: .6875rem; font-weight: 650; letter-spacing: .05em; text-transform: uppercase;\n color: var(--muted-fg);\n}\ntd.num, .num { font-variant-numeric: tabular-nums; }\ntbody tr:last-child td { border-bottom: 0; }\n\n.stamp, .seal, .fact, .change, .moved {\n font-family: var(--mono); font-size: .8125rem;\n}\nh1 > .seal, h1 > .stamp, h2 > .seal, h2 > .stamp, h3 > .seal, h3 > .stamp {\n display: block; margin-top: .375rem;\n font-weight: 400; letter-spacing: 0; color: var(--muted-fg);\n font-size: .8125rem; line-height: 1.5;\n}\n.seal { color: var(--muted-fg); word-break: break-all; }\n.stamp { color: var(--muted-fg); }\n.note { font-size: .875rem; color: var(--muted-fg); }\n\n.steps { display: grid; gap: 1rem; padding: 0; margin: 0 0 1rem; list-style: none; counter-reset: step; }\n.steps li { display: grid; grid-template-columns: auto 1fr; gap: .875rem; align-items: start; }\n\n/* ---------- contact ---------- */\n.contact {\n border: 1px solid var(--line); border-radius: var(--radius);\n background: var(--surface); padding: 1.75rem; box-shadow: var(--shadow-sm);\n max-width: none;\n}\n.contact h2 { margin-top: 0; }\n.mail {\n display: inline-flex; align-items: center;\n background: hsl(var(--primary)); color: hsl(var(--primary-foreground));\n font-weight: 600; font-size: .9375rem;\n padding: .625rem 1.25rem; border-radius: var(--radius); text-decoration: none;\n}\n.mail:hover { background: hsl(var(--primary-hover)); text-decoration: none; }\n.mail-note { margin: .875rem 0 0; font-size: .8125rem; color: var(--muted-fg); }\n\n/* ---------- footer: mirrors the main-site footer ---------- */\nfooter.site {\n border-top: 1px solid var(--line);\n background: var(--bg);\n padding: 4rem 0 2rem;\n font-size: .875rem;\n}\n@media (min-width: 768px) { footer.site { padding: 5rem 0 2rem; } }\n.foot-grid {\n display: grid; gap: 3rem; margin-bottom: 3rem;\n grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));\n}\n.foot-brand { grid-column: 1 / -1; }\n@media (min-width: 1024px) { .foot-brand { grid-column: span 2; max-width: 320px; } }\n.foot-brand .wordmark {\n font-size: 1.125rem; font-weight: 600; letter-spacing: -.025em; text-transform: none;\n}\n.foot-brand p { color: var(--muted-fg); margin: .75rem 0 1rem; }\n.foot-col h4 {\n font-size: 1.875rem; font-weight: 500; letter-spacing: -.025em; line-height: 1.15;\n color: var(--fg); margin: 0 0 1rem;\n}\n@media (min-width: 768px) { .foot-col h4 { font-size: 2.25rem; } }\n.foot-col ul { list-style: none; margin: 0; padding: 0; display: grid; gap: .75rem; }\n.foot-col a { color: var(--muted-fg); }\n.foot-col a:hover { color: hsl(var(--primary)); }\n.foot-bottom {\n border-top: 1px solid var(--line); padding-top: 1.5rem;\n display: grid; gap: .75rem; color: var(--muted-fg);\n}\nfooter.site p { color: var(--muted-fg); }\nfooter.site .addr { font-size: .8125rem; }\n.foot-honest { max-width: 76ch; }\n\n/* ---------- offer: the pay path ----------\n A page shows btn-buy only when catalog.json carries a checkout that was\n fetched and found working. btn-ghost is the email fallback. Both are the\n same shape so a page does not look broken when the pay link is missing. */\n.hero-cta {\n display: flex; flex-wrap: wrap; align-items: center; gap: .875rem;\n margin: 1.5rem 0 0;\n}\n.btn {\n display: inline-flex; align-items: center; justify-content: center;\n font-weight: 650; font-size: .9375rem; line-height: 1;\n padding: .75rem 1.375rem; border-radius: var(--radius);\n text-decoration: none; border: 1px solid transparent;\n transition: background .15s ease, border-color .15s ease;\n}\n.btn:hover { text-decoration: none; }\n.btn-buy {\n background: hsl(var(--primary)); color: hsl(var(--primary-foreground));\n box-shadow: 0 1px 2px hsl(var(--foreground) / .08);\n}\n.btn-buy:hover { background: hsl(var(--primary-hover)); }\n.btn-ghost {\n background: transparent; color: hsl(var(--primary));\n border-color: hsl(var(--primary) / .35);\n}\n.btn-ghost:hover { background: hsl(var(--primary) / .06); border-color: hsl(var(--primary) / .55); }\n.btn-lg { padding: .9375rem 1.75rem; font-size: 1rem; }\n.btn-note { font-size: .8125rem; color: var(--muted-fg); }\n.contact.buy .buy-price {\n margin: 0 0 1.125rem; font-size: 1.125rem; color: var(--fg);\n}\n.contact.buy .buy-price strong { font-size: 1.5rem; letter-spacing: -.01em; }\n.contact.buy .btn-lg { margin-bottom: .25rem; }\n@media (max-width: 34rem) {\n .hero-cta { flex-direction: column; align-items: flex-start; }\n .btn { width: 100%; }\n}\n' def bundle(payload): result = run(payload) if result['status'] == 'FAIL': msgs = '; '.join(f['message'] for f in result['findings']) raise ValidationError('cannot bundle invalid input: ' + msgs) files = {} files['report.json'] = json.dumps(result, indent=2, sort_keys=True, allow_nan=False).encode('utf-8') files['input.json'] = json.dumps(payload, indent=2, sort_keys=True, allow_nan=False).encode('utf-8') files['README.txt'] = README_TXT.encode('utf-8') for fname, rows in result['tables'].items(): files[fname] = _rows_to_csv(rows).encode('utf-8') files['styles.css']=SHARED_CSS.encode() files['rules.json']=json.dumps({k:v for k,v in payload.items() if k!='receipts'},sort_keys=True,allow_nan=False).encode() files['inputs-manifest.json']=json.dumps({'schema':SCHEMA,'input_sha256':hashlib.sha256(files['input.json']).hexdigest(),'scope':'exact input.json bytes in this package'},sort_keys=True).encode() files['period-balances.json']=json.dumps(result['tables'].get('period-balances.csv',[]),sort_keys=True,allow_nan=False).encode() grouped={} for row in result['tables'].get('statements.csv',[]): grouped.setdefault(row['recipient_id'],[]).append(row) links=[] for rid,rows in sorted(grouped.items()): opaque=hashlib.sha256(rid.encode()).hexdigest() stem='statements/'+opaque files[stem+'.csv']=_rows_to_csv(rows).encode() cells=''.join(''+''.join(''+html.escape(str(row.get(k,'')))+'' for k in ('project_id','period','currency','allocated_minor'))+'' for row in rows) content='

Draft allocations

No invoice, tax calculation, rights decision, or payment action.

'+cells+'
Integer minor units; currency remains separate
ProjectPeriodCurrencyAllocated
' page=offline_page('RoyaltyWaterfall','Draft statement: '+rid,result['status']+' · Supplied rules require customer review.',opaque+'.csv','Download draft statement',content) page=page.replace('href="styles.css"','href="../styles.css"').replace('href="index.html"','href="../index.html"') files[stem+'.html']=page.encode() links.append('
  • '+html.escape(rid)+'
  • ') files['index.html']=offline_page('RoyaltyWaterfall','Draft royalty allocations',result['status']+' · Exact arithmetic on buyer-supplied rules.','statements.csv','Download draft allocations','

    Recipient statements

    Full reversals restate prior periods and replay balances; they do not transfer or recover money. Period boundaries determine rounding. Incomplete periods withhold final balances.

    ').encode() manifest = {'schema': SCHEMA, 'version': VERSION, 'status': result['status'], 'files': {name: hashlib.sha256(data).hexdigest() for name, data in sorted(files.items())}} files['manifest.json'] = json.dumps(manifest, indent=2, sort_keys=True).encode('utf-8') buf = io.BytesIO() with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as z: for name in sorted(files): info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0)) info.compress_type = zipfile.ZIP_DEFLATED info.external_attr = 0o644 << 16 z.writestr(info, files[name]) return buf.getvalue() def run_json(text): payload = _strict(text) return json.dumps(run(payload), allow_nan=False, sort_keys=True) def demo(): return { 'schema': SCHEMA, 'currency': 'USD', 'period': '2026-Q1', 'opening_balances': {'advance': 150000}, 'receipts': [ {'event_id': 'r1', 'source': 'streaming', 'amount_minor': 800000}, {'event_id': 'r2', 'source': 'download', 'amount_minor': 200000}], 'waterfall': [ {'id': 'n1', 'type': 'fee', 'to': 'distributor', 'rate': '15/100', 'basis': 'gross'}, {'id': 'n2', 'type': 'recoup', 'to': 'label', 'pool': 'advance', 'share': '1'}, {'id': 'n3', 'type': 'share', 'splits': [ {'to': 'artist', 'share': '1/2'}, {'to': 'label', 'share': '1/2'}]}], 'note': 'synthetic demo payload'} def _atomic_write(path, data): fd,tmp=tempfile.mkstemp(prefix='.royalty-',dir=os.path.dirname(path)) try: with os.fdopen(fd,'wb') as f: f.write(data); f.flush(); os.fsync(f.fileno()) os.replace(tmp,path) finally: if os.path.exists(tmp): os.unlink(tmp) def main(argv): if len(argv) != 3: sys.stderr.write('usage: product.py INPUT.json OUTPUT_DIR\n') return 2 inp, outdir = argv[1], argv[2] try: with open(inp, 'rb') as f: raw = f.read(MAX_BYTES+1) if len(raw) > MAX_BYTES: raise ValidationError('input exceeds size bound') payload = _strict(raw.decode('utf-8')) except (ValidationError, ValueError, UnicodeDecodeError, OSError, RecursionError) as e: os.makedirs(outdir, exist_ok=True) stale=os.path.join(outdir,'result.zip') if os.path.lexists(stale): os.unlink(stale) rep = {'status': 'FAIL', 'summary': {}, 'tables': {}, 'findings': [{'severity': 'error', 'code': 'input', 'message': str(e)}], 'provenance': {}} _atomic_write(os.path.join(outdir, 'report.json'), json.dumps(rep, indent=2, sort_keys=True).encode('utf-8')) sys.stdout.write('FAIL\n') return 2 result = run(payload) os.makedirs(outdir, exist_ok=True) _atomic_write(os.path.join(outdir, 'report.json'), json.dumps(result, indent=2, sort_keys=True, allow_nan=False).encode('utf-8')) status = result['status'] if status == 'PASS': _atomic_write(os.path.join(outdir, 'result.zip'), bundle(payload)) sys.stdout.write('PASS\n') return 0 if status == 'UNKNOWN': _atomic_write(os.path.join(outdir, 'result.zip'), bundle(payload)) sys.stdout.write('UNKNOWN\n') return 3 stale=os.path.join(outdir,'result.zip') if os.path.lexists(stale): os.unlink(stale) sys.stdout.write('FAIL\n') return 2 # Native continuation of saved Opus engine. Merged into product.py for one-file Pyodide. def _tree(value,depth=0): if depth>24: raise ValidationError('nesting limit') if isinstance(value,float) and not math.isfinite(value): raise ValidationError('nonfinite number') if isinstance(value,str) and len(value)>MAX_BYTES: raise ValidationError('string size limit') if isinstance(value,(dict,list)): if len(value)>MAX_ITEMS: raise ValidationError('collection size limit') if isinstance(value,dict) and any(not isinstance(k,str) for k in value): raise ValidationError('object keys must be strings') for v in (value.values() if isinstance(value,dict) else value): _tree(v,depth+1) elif isinstance(value,int) and not isinstance(value,bool): _req_int(value,'number') def _strict(text): if not isinstance(text,str) or len(text.encode())>MAX_BYTES: raise ValidationError('JSON size limit') def pairs(items): d={} for k,v in items: if k in d: raise ValidationError('duplicate JSON key '+k) d[k]=v return d try: result=json.loads(text,object_pairs_hook=pairs,parse_constant=_reject_const) except (ValueError,RecursionError) as e: raise ValidationError(str(e)) _tree(result); return result def _validate_nodes(nodes): if not isinstance(nodes,list) or not nodes or len(nodes)>MAX_NODES: raise ValidationError('bounded nonempty waterfall required') ids=[n.get('id') if isinstance(n,dict) else None for n in nodes] if any(not isinstance(x,str) or not x for x in ids) or len(set(ids))!=len(ids): raise ValidationError('unique nonempty node IDs required') edges={}; owners=set(); previous=-1 for i,n in enumerate(nodes): allowed={'id','type','basis','priority','after','to'}|{'fee':{'rate'},'recoup':{'pool','share'},'tier':{'mode','thresholds'},'share':{'splits'}}.get(n.get('type'),set()) if set(n)-allowed: raise ValidationError('unsupported node fields '+','.join(sorted(set(n)-allowed))) after=n.get('after',[]) if not isinstance(after,list) or any(not isinstance(a,str) or a not in ids for a in after): raise ValidationError('after must reference existing node IDs') edges[n['id']]=after priority=_req_int(n.get('priority',i),'priority') if priority<=previous: raise ValidationError('node priorities must match strictly increasing array order') previous=priority recipients=[n.get('to')] if n.get('type')!='share' else [a.get('to') if isinstance(a,dict) else None for a in n.get('splits',[])] if '__unallocated__' in recipients: raise ValidationError('reserved recipient ID __unallocated__') if n.get('type')=='recoup': pool=n.get('pool') if not isinstance(pool,str): raise ValidationError('pool must be string') if pool in owners: raise ValidationError('recoupment pool has multiple owner nodes') owners.add(pool) if n.get('type')=='tier': if n.get('mode','marginal') not in ('marginal','whole_pot'): raise ValidationError('explicit tier mode must be marginal or whole_pot') th=n.get('thresholds') if not isinstance(th,list) or not th or not isinstance(th[-1],dict) or th[-1].get('upto','missing') is not None: raise ValidationError('final tier must explicitly end at null') if n.get('type')=='share': if not isinstance(n.get('splits'),list) or len(n['splits'])>MAX_NODES: raise ValidationError('bounded split list required') if any(not isinstance(x,dict) or set(x)!={'to','share'} for x in n['splits']): raise ValidationError('split exact fields to/share required') colors={} def visit(node): if colors.get(node)==1: raise ValidationError('waterfall graph cycle') if colors.get(node)==2: return colors[node]=1 for dep in edges[node]: visit(dep) colors[node]=2 for node in ids: visit(node) for node,deps in edges.items(): if any(ids.index(dep)>=ids.index(node) for dep in deps): raise ValidationError('dependencies must precede the node in priority order') def _prepared_receipts(periods,project,currency,allowed_sources=None): seen={}; first_period={}; canonical=[]; events=[] if allowed_sources is not None and (not isinstance(allowed_sources,list) or not allowed_sources or any(not isinstance(x,str) or not x for x in allowed_sources)): raise ValidationError('allowed_sources must be a nonempty string list') for i,period in enumerate(periods): rr=period.get('receipts') if not isinstance(rr,list): raise ValidationError('receipts list required for each period') rows=[] for raw in rr: if not isinstance(raw,dict): raise ValidationError('receipt must be object') if set(raw)-{'event_id','source','amount_minor','currency','period','project_id','reverses_event_id'}: raise ValidationError('unsupported receipt fields') r=copy.deepcopy(raw); eid=r.get('event_id') if not isinstance(eid,str) or not eid: raise ValidationError('receipt event_id required') r.setdefault('source','receipts'); r.setdefault('currency',currency); r.setdefault('period',period['period']); r.setdefault('project_id',project) if r['currency']!=currency or r['period']!=period['period'] or r['project_id']!=project: raise ValidationError('receipt currency/period/project scope mismatch') if allowed_sources is not None and r['source'] not in allowed_sources: raise ValidationError('receipt source is not allowed') _req_int(r.get('amount_minor'),'amount_minor') if eid in seen: if r!=seen[eid]: raise ValidationError('conflicting duplicate event_id '+eid) continue seen[eid]=r; first_period[eid]=i; rows.append(r); events.append(r) canonical.append(rows) reversed_ids=set(); reversal_rows=[] for r in events: amt=r['amount_minor']; ref=r.get('reverses_event_id') if amt<0: if not isinstance(ref,str) or ref not in seen: raise ValidationError('negative adjustment needs existing referenced event') original=seen[ref] if ref==r['event_id'] or original['amount_minor']<=0 or original.get('reverses_event_id') or amt!=-original['amount_minor']: raise ValidationError('only exact full receipt reversals supported') if first_period[ref]>first_period[r['event_id']] or ref in reversed_ids: raise ValidationError('reversal is before original or already reversed') reversed_ids.add(ref); reversal_rows.append({'reversal_event_id':r['event_id'],'original_event_id':ref,'original_period':original['period'],'reversal_period':r['period'],'amount_minor':amt,'policy':'restate original period and replay all later opening balances'}) elif ref is not None: raise ValidationError('reversal must be negative') out=[] for rows in canonical: transformed=[] for r in rows: rr=copy.deepcopy(r) # Keep observed, fully reversed zero rows: zero is observed here, not absence. if rr['event_id'] in reversed_ids or rr['amount_minor']<0: rr['amount_minor']=0 rr.pop('reverses_event_id',None); transformed.append(rr) out.append(transformed) return out,reversal_rows def _failure(payload,status,message): return {'status':status,'summary':{},'tables':{},'findings':[{'severity':'unknown' if status=='UNKNOWN' else 'error','code':'missing_observation' if status=='UNKNOWN' else 'validation','message':str(message)}],'provenance':_prov(payload)} def _costs(period,opening,project,currency,seen): balances=copy.deepcopy(opening); costs=period.get('costs',[]) if not isinstance(costs,list): raise ValidationError('costs must be a list') for c in costs: if not isinstance(c,dict) or set(c)-{'cost_id','project_id','period','currency','amount_minor','recoupment_pool'}: raise ValidationError('cost schema') cid=c.get('cost_id'); pool=c.get('recoupment_pool') if not isinstance(cid,str) or not cid or not isinstance(pool,str): raise ValidationError('cost_id and recoupment_pool required') if c.get('project_id',project)!=project or c.get('period',period['period'])!=period['period'] or c.get('currency',currency)!=currency: raise ValidationError('cost scope mismatch') if cid in seen: if seen[cid]!=c: raise ValidationError('conflicting duplicate cost_id') continue seen[cid]=c amount=_req_int(c.get('amount_minor'),'cost amount_minor') if amount<0: raise ValidationError('negative costs unsupported') if pool not in balances: raise UnknownError('cost pool needs explicit opening balance: '+pool) balances[pool]=_req_int(balances[pool]+amount,'cost-adjusted balance') return balances def run(payload): try: _tree(payload) if not isinstance(payload,dict): raise ValidationError('payload must be object') if len(json.dumps(payload,allow_nan=False))>MAX_BYTES: raise ValidationError('combined input size limit') if payload.get('schema')!=SCHEMA: raise ValidationError('schema must be '+SCHEMA) is_batch='projects' in payload if is_batch: if set(payload)-{'schema','projects','note'}: raise ValidationError('batch top-level fields schema/projects/note only') projects=payload['projects'] if not isinstance(projects,list) or not 1<=len(projects)<=100: raise ValidationError('batch requires 1..100 projects') else: allowed={'schema','currency','currency_precision','project_id','period','opening_balances','receipts','costs','waterfall','allowed_sources','note','rounding_policy','residual_policy'} if set(payload)-allowed: raise ValidationError('unsupported period fields '+','.join(sorted(set(payload)-allowed))) projects=[{k:v for k,v in payload.items() if k not in ('schema','receipts','costs','period','note')}] projects[0].setdefault('project_id','default'); projects[0]['periods']=[{'period':payload.get('period'),'receipts':payload.get('receipts'),'costs':payload.get('costs',[])}] all_tables={}; all_findings=[]; runs=[]; project_ids=set(); status='PASS'; total_rows=0 single=None for project in projects: if not isinstance(project,dict): raise ValidationError('project must be object') if set(project)-{'project_id','currency','currency_precision','opening_balances','waterfall','periods','allowed_sources','rounding_policy','residual_policy'}: raise ValidationError('unsupported project fields') pid=project.get('project_id'); currency=project.get('currency') if not isinstance(pid,str) or not pid or pid in project_ids: raise ValidationError('unique nonempty project_id required') project_ids.add(pid) if project.get('rounding_policy','largest_remainder_recipient_id')!='largest_remainder_recipient_id' or project.get('residual_policy','unallocated')!='unallocated': raise ValidationError('supported policies: largest_remainder_recipient_id / unallocated') periods=project.get('periods') if not isinstance(periods,list) or not 1<=len(periods)<=1000: raise ValidationError('1..1000 explicit ordered periods required') period_names=[] for period in periods: if not isinstance(period,dict) or set(period)-{'period','receipts','costs'}: raise ValidationError('period schema') name=period.get('period') if not isinstance(name,str) or not name or name in period_names: raise ValidationError('unique nonempty period names required; array is chronology') period_names.append(name); total_rows+=len(period.get('receipts',[])) if isinstance(period.get('receipts'),list) else 0 if total_rows>MAX_ITEMS: raise ValidationError('combined receipt count limit') normalized,reversals=_prepared_receipts(periods,pid,currency,project.get('allowed_sources')) balances=project.get('opening_balances',{}); cost_seen={}; missing_prior=False for i,period in enumerate(periods): q={'schema':SCHEMA,'project_id':pid,'currency':currency,'period':period['period'],'receipts':normalized[i],'waterfall':project.get('waterfall'),'opening_balances':balances} if 'currency_precision' in project: q['currency_precision']=project['currency_precision'] if missing_prior: result=_failure(q,'UNKNOWN','prior period did not establish accepted closing balances') else: q['opening_balances']=_costs(period,balances,pid,currency,cost_seen) result=_run_period(q) if result['status']=='FAIL': return result if result['status']=='UNKNOWN': status='UNKNOWN'; missing_prior=True else: balances={row['pool']:row['closing_minor'] for row in result['tables'].get('carryforward.csv',[])} or q['opening_balances'] result['provenance'].update({'project_id':pid,'policies':{'chronology':'period array order','rounding':'largest remainder, recipient ID ascending','recoupment':'floor fractional availability to whole minor units at each recoup node','residual':'unallocated','tier_default':'marginal','reversal':'full referenced receipt restatement and entire history replay'},'opening_balances':q['opening_balances']}) for name,rows in result['tables'].items(): all_tables.setdefault(name,[]).extend(dict(row,project_id=pid,period=period['period']) for row in rows) all_tables.setdefault('period-balances.csv',[]).append({'project_id':pid,'period':period['period'],'currency':currency,'status':result['status'],'receipts_minor':result['summary'].get('total_receipts_minor'),'allocated_minor':result['summary'].get('total_allocated_minor'),'unallocated_minor':result['summary'].get('unallocated_minor'),'closing_balances':json.dumps(balances,sort_keys=True) if result['status']=='PASS' else None}) all_findings.extend(dict(f,project_id=pid,period=period['period']) for f in result['findings']); runs.append({'project_id':pid,'period':period['period'],'currency':currency,'status':result['status'],'summary':result['summary']}); single=result if reversals: all_tables.setdefault('reversals.csv',[]).extend(dict(r,project_id=pid,currency=currency) for r in reversals) if not is_batch: if single['status']=='PASS': single['tables']['period-balances.csv']=all_tables['period-balances.csv'] if 'reversals.csv' in all_tables: single['tables']['reversals.csv']=all_tables['reversals.csv'] return single return {'status':status,'summary':{'project_count':len(projects),'period_count':len(runs),'periods':runs,'money_totals':'currency-separated; not aggregated across currencies'},'tables':all_tables,'findings':all_findings,'provenance':{'schema':SCHEMA,'version':VERSION,'engine':'RoyaltyWaterfall','policies':{'chronology':'explicit project period-array order','reversal':'restate original and replay all later periods','rounding':'per period; exact rational then largest remainder by recipient ID','recoupment':'whole minor-unit floor at recoup node','residual':'unallocated'}}} except UnknownError as e: return _failure(payload,'UNKNOWN',e) except (ValidationError,ValueError,TypeError,KeyError,IndexError,OverflowError,RecursionError) as e: return _failure(payload,'FAIL',e) def files_to_payload(files): if not isinstance(files,dict) or not files or len(files)>4: raise ValidationError('file set must be a bounded object') for name,entry in files.items(): if not isinstance(name,str) or not name or name in ('.','..') or '/' in name or '\\' in name or '\x00' in name: raise ValidationError('filenames must be basenames') if not isinstance(entry,dict) or set(entry)!={'encoding','content'} or entry['encoding']!='utf8' or not isinstance(entry['content'],str): raise ValidationError('UTF8 file wrapper required') if len(entry['content'].encode())>MAX_BYTES: raise ValidationError('file size limit') files=copy.deepcopy(files) if 'revenue.csv' in files: if 'receipts.csv' in files: raise ValidationError('revenue.csv and receipts.csv are ambiguous together') files['receipts.csv']=files.pop('revenue.csv') if 'input.json' in files: if len(files)!=1: raise ValidationError('input.json must be the sole file') return _strict(files['input.json']['content']) if set(files)-{'rules.json','receipts.csv','costs.csv','opening-state.json'} or not {'rules.json','receipts.csv'}<=set(files): raise ValidationError('rules.json + receipts.csv required; optional costs.csv/opening-state.json') rules=_strict(files['rules.json']['content']) if not isinstance(rules,dict): raise ValidationError('rules must be object') mappings=rules.pop('csv_mappings',{}) if not isinstance(mappings,dict) or set(mappings)-{'receipts.csv','costs.csv'}: raise ValidationError('csv_mappings supported file keys only') def rows(name,headers): reader=csv.DictReader(io.StringIO(files[name]['content'],newline='')) mapping=mappings.get(name) if mapping is not None: if not isinstance(mapping,dict) or set(mapping)!=set(headers) or any(not isinstance(x,str) for x in mapping.values()) or len(set(mapping.values()))!=len(headers): raise ValidationError('mapping must map each canonical header to one unique input header') if reader.fieldnames!=[mapping[k] for k in headers]: raise ValidationError('mapped CSV header/order mismatch') elif name=='receipts.csv' and reader.fieldnames==headers[:5]: headers=headers[:5] elif reader.fieldnames!=headers: raise ValidationError(name+' requires exact unique header order: '+','.join(headers)) out=[] for row in reader: if mapping is not None: row={k:row.get(v) for k,v in mapping.items()} if None not in row else row if len(out)>=MAX_ITEMS or None in row or any(v is None for v in row.values()): raise ValidationError('CSV row shape/limit') s=row['amount_minor'] if not s or not s.lstrip('-').isdigit() or s.startswith('+'): raise ValidationError('amount_minor must be integer text') row['amount_minor']=_req_int(int(s),'amount_minor') if row.get('reverses_event_id')=='': row.pop('reverses_event_id',None) out.append(row) return out receipts=rows('receipts.csv',['event_id','project_id','period','currency','amount_minor','source','reverses_event_id']) costs=rows('costs.csv',['cost_id','project_id','period','currency','amount_minor','recoupment_pool']) if 'costs.csv' in files else [] if 'receipts' in rules: raise ValidationError('rules must not include receipts alongside CSV') if 'projects' in rules: used=set(); used_cost=set() for project in rules['projects']: if 'opening-state.json' in files: raise ValidationError('batch opening balances belong to each project, not separate ambiguous state') for period in project.get('periods',[]): if 'receipts' in period or 'costs' in period: raise ValidationError('batch rules must not duplicate CSV events') period['receipts']=[r for i,r in enumerate(receipts) if r['project_id']==project['project_id'] and r['period']==period['period'] and not used.add(i)] period['costs']=[r for i,r in enumerate(costs) if r['project_id']==project['project_id'] and r['period']==period['period'] and not used_cost.add(i)] if len(used)!=len(receipts) or len(used_cost)!=len(costs): raise ValidationError('CSV contains unknown project/period') else: rules['receipts']=receipts; rules['costs']=costs if 'opening-state.json' in files: if 'opening_balances' in rules: raise ValidationError('opening state supplied twice') rules['opening_balances']=_strict(files['opening-state.json']['content']) return rules if __name__ == '__main__': sys.exit(main(sys.argv))