From 90686d74b7f0fd18b919bbbddea7f2fffe728715 Mon Sep 17 00:00:00 2001 From: Seowoo Han Date: Wed, 20 May 2026 13:05:36 +0900 Subject: [PATCH] Add partner royalty settlement guard --- README.md | 4 + partner-royalty-settlement-guard/README.md | 45 +++ .../acceptance-notes.md | 23 ++ partner-royalty-settlement-guard/demo.js | 100 ++++++ partner-royalty-settlement-guard/demo.mp4 | Bin 0 -> 47489 bytes partner-royalty-settlement-guard/demo.svg | 32 ++ partner-royalty-settlement-guard/index.js | 288 ++++++++++++++++++ .../requirements-map.md | 19 ++ partner-royalty-settlement-guard/test.js | 110 +++++++ 9 files changed, 621 insertions(+) create mode 100644 partner-royalty-settlement-guard/README.md create mode 100644 partner-royalty-settlement-guard/acceptance-notes.md create mode 100644 partner-royalty-settlement-guard/demo.js create mode 100644 partner-royalty-settlement-guard/demo.mp4 create mode 100644 partner-royalty-settlement-guard/demo.svg create mode 100644 partner-royalty-settlement-guard/index.js create mode 100644 partner-royalty-settlement-guard/requirements-map.md create mode 100644 partner-royalty-settlement-guard/test.js diff --git a/README.md b/README.md index d338cf68..0f91fed8 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,6 @@ # deepevents.ai deepevents.ai main codebase + +## Revenue Infrastructure Slices + +- [Partner Royalty Settlement Guard](partner-royalty-settlement-guard/README.md) - audit-ready settlement checks for data-licensing and white-label analytics revenue shares. diff --git a/partner-royalty-settlement-guard/README.md b/partner-royalty-settlement-guard/README.md new file mode 100644 index 00000000..8a45b310 --- /dev/null +++ b/partner-royalty-settlement-guard/README.md @@ -0,0 +1,45 @@ +# Partner Royalty Settlement Guard + +Self-contained Revenue Infrastructure slice for issue #20. It focuses on a narrow gap that is different from the existing subscription, metering, tax, dispute, SLA, procurement, pricing experiment, renewal, margin, and privacy-gate modules: audit-ready settlement of data-licensing and white-label analytics revenue shares. + +The guard evaluates each license invoice before money is released to dataset contributors, reseller partners, and the platform. + +## What It Does + +- Calculates platform fees, reseller fees, contributor royalty pools, reserves, and payable lines. +- Blocks duplicate invoices, expired agreements, unauthorized use cases, missing consent attestations, excessive reseller fees, and broken royalty split percentages. +- Holds otherwise-valid settlements when anonymized subject counts are below the configured aggregation floor or payout lines are below the minimum payout threshold. +- Produces a deterministic SHA-256 audit digest that finance can archive with the invoice packet. +- Returns reviewer-ready actions for settlement, hold, and blocker states. + +## Files + +- `index.js` - dependency-free settlement evaluator. +- `test.js` - Node assertions covering ready, held, blocked, duplicate, and validation paths. +- `demo.js` - terminal demo over a synthetic license batch. +- `requirements-map.md` - explicit mapping to issue #20 requirements. +- `acceptance-notes.md` - verification notes and limitations. +- `demo.svg` / `demo.mp4` - short visual demo artifacts for bounty review. + +## Run + +```bash +node partner-royalty-settlement-guard/test.js +node partner-royalty-settlement-guard/demo.js +``` + +## Example Output + +```text +Partner Royalty Settlement Guard Demo +===================================== +settlements: 3 +ready: 1, held: 1, blocked: 1 +ready payouts: $925.00 +held payouts: $232.56 +blocked gross: $640.00 +``` + +## Design Notes + +The module is synthetic-data-only and has no network, payment, Stripe, PayPal, bank, or credential integration. It is intended to be the policy and audit layer that would run before a real payout provider is called. diff --git a/partner-royalty-settlement-guard/acceptance-notes.md b/partner-royalty-settlement-guard/acceptance-notes.md new file mode 100644 index 00000000..7532d5da --- /dev/null +++ b/partner-royalty-settlement-guard/acceptance-notes.md @@ -0,0 +1,23 @@ +# Acceptance Notes + +## Validation + +Run from the repository root: + +```bash +node partner-royalty-settlement-guard/test.js +node partner-royalty-settlement-guard/demo.js +ffprobe -v error -show_entries format=duration,size -show_entries stream=codec_name,width,height -of default=noprint_wrappers=1 partner-royalty-settlement-guard/demo.mp4 +``` + +## Expected Review Signals + +- Ready settlement releases payout lines and archives the digest. +- Low anonymized subject count becomes a hold, not a blocker, so finance can aggregate more usage. +- Duplicate invoice, expired agreement, missing consent, unauthorized use case, and split mismatch block release. +- Audit digest remains stable for identical input. +- All calculations use integer cents. + +## Scope Boundary + +This is an audit and policy module. It intentionally stops before live payout-provider integration so the demo remains credential-free and safe to run in CI or during review. diff --git a/partner-royalty-settlement-guard/demo.js b/partner-royalty-settlement-guard/demo.js new file mode 100644 index 00000000..916a00b9 --- /dev/null +++ b/partner-royalty-settlement-guard/demo.js @@ -0,0 +1,100 @@ +"use strict"; + +const { evaluateSettlement, money } = require("./index"); + +const sampleBatch = { + agreements: [ + { + id: "agr-national-knowledge-graph", + datasetId: "dataset-citation-network-2026q2", + status: "active", + endsAt: "2027-03-31", + platformFeeRate: 0.18, + resellerFeeRate: 0.08, + allowedUseCases: ["policy-planning", "meta-research"], + contributorRoyaltySplits: [ + { contributorId: "lab-north", share: 0.45 }, + { contributorId: "lab-south", share: 0.35 }, + { contributorId: "data-trust", share: 0.2 }, + ], + }, + { + id: "agr-white-label-pilot", + datasetId: "dataset-method-trends", + status: "active", + endsAt: "2026-09-30", + platformFeeRate: 0.2, + resellerFeeRate: 0.12, + allowedUseCases: ["white-label-dashboard"], + contributorRoyaltySplits: [ + { contributorId: "methods-lab", share: 0.5 }, + { contributorId: "instrument-core", share: 0.5 }, + ], + }, + { + id: "agr-expired-consortium", + datasetId: "dataset-private-notes", + status: "active", + endsAt: "2026-01-31", + allowedUseCases: ["market-intelligence"], + contributorRoyaltySplits: [{ contributorId: "legacy-lab", share: 1 }], + }, + ], + licenseEvents: [ + { + id: "lic-001", + invoiceId: "INV-2026-05-101", + agreementId: "agr-national-knowledge-graph", + customerId: "nih-policy-office", + datasetId: "dataset-citation-network-2026q2", + useCase: "policy-planning", + grossCents: 125000, + subjectCount: 480, + consentAttestation: true, + }, + { + id: "lic-002", + invoiceId: "INV-2026-05-102", + agreementId: "agr-white-label-pilot", + customerId: "research-dashboard-reseller", + datasetId: "dataset-method-trends", + useCase: "white-label-dashboard", + grossCents: 36000, + subjectCount: 18, + consentAttestation: true, + }, + { + id: "lic-003", + invoiceId: "INV-2026-05-101", + agreementId: "agr-expired-consortium", + customerId: "market-intel-firm", + datasetId: "dataset-private-notes", + useCase: "market-intelligence", + grossCents: 64000, + subjectCount: 72, + consentAttestation: false, + }, + ], +}; + +const report = evaluateSettlement(sampleBatch); + +console.log("Partner Royalty Settlement Guard Demo"); +console.log("====================================="); +console.log(`settlements: ${report.settlementCount}`); +console.log(`ready: ${report.totals.readyCount}, held: ${report.totals.heldCount}, blocked: ${report.totals.blockedCount}`); +console.log(`ready payouts: ${money(report.totals.readyPayoutCents)}`); +console.log(`held payouts: ${money(report.totals.heldPayoutCents)}`); +console.log(`blocked gross: ${money(report.totals.blockedGrossCents)}`); +console.log(`audit digest: ${report.auditDigest.slice(0, 16)}...`); + +for (const settlement of report.settlements) { + console.log(""); + console.log(`${settlement.eventId} ${settlement.status.toUpperCase()} ${money(settlement.grossCents)} ${settlement.invoiceId}`); + for (const finding of settlement.findings) { + console.log(`- ${finding.severity}: ${finding.code} - ${finding.message}`); + } + for (const action of settlement.recommendedActions) { + console.log(` action: ${action}`); + } +} diff --git a/partner-royalty-settlement-guard/demo.mp4 b/partner-royalty-settlement-guard/demo.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..9c484b6d7f53f5762578253374abd1f066ff12cd GIT binary patch literal 47489 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℞$b&+bmKB^nA^Pvt>9fNTH3X zPoN7tpPvia^)5kcWiLk8V=TxATfYsi{TXzMLb0b_rmdv4vTY%A9%vHrY+Fd?-Oo7~e0T!}Y7#I3;) z`lHTjx^ycM3347u&lP#EmV;qa2#9*Fr0TC>-hKPPvF5||a5Jw0&t4s?18$FR?f+8D zL*-Ol5to0mJ$({|#>L$${rH9Z^1Jj*pM-p}_*Zy(nL_eyn?dK%mcF(mtEVz?g069- z(%GbeLbp7jlSQDmZ*+0nmgoOTtwsZmZ*tu-kV8;Fb5px@R5^|!4^x#T#q19j)nIfW zv;IrV@%In3K>nTNHY=%{M`o=VYtk?V-XmIIO zn7;&D3=Njr(4>&h^=047n3l~_51H=KC!63-IYIGiJ7c{e{{Fe6276u_j|1+q$9d?P z6HG*_25#_B-ChgF4x5KSWAwueLl>P>Ll`Ms{370+BEQ?;#!!Zc$uiF?6xCh2p^fd; zE5HZtO9^ayWfsMR{%GZ3n@<5RtbqC8XOA&1VlK2ggw6^bxHDRKO)b2sXZdzkMto5# z1cC%ZyRj@7i>2CdN!W;-rk~~%1aI?Ils+b!X=}u?l31OMrl9{##TYM*UJCYlQG^t( z^T8dvxcYvvmq{VBpSe9;Azmuc{BJ4Rwd9Cj;EQ_z9)SSwTn?D=ZW-hR1ksJf?DBaQ;6?kMpymq&1Noz;c}TMbV$(O^s`bzboy<1qeaJrc`{% z%|GCf29D)`_18zFtsP~d!2cflnVLpuCK&i_*}BAxjei<^l<}6$PaavS|F;EN#vP)n zLatybiUP1)tbEe0nzRGfp!-?^+FHDSb6n*SQ;QARa>_JW5kf2gDy4XAa0z-jM44RfX5bEJ_lfN+uYk zZ#$Qan?%z_@>H_T8Azodk$`S2m;HU3ufGcN-6?O=6YokPS4cY+ik#173gF}pB6A8$|MmZaUbCW!cjtMc;&I(SHoKkBio3gT7vYFhsW=NAvDgd5AyImpvxIIU3?jqjBC4LfbZ(!kR|8D|_NYGKz6F!9@xbp+mWoBV10B)^qcR zTQzn0Ws2=!C^e1$z5T*)@6jehb;hMKk2RTkLxgd&Ml%6RjC{i1XP(d~>^>zVO3qVE z5mj;~jR2?@0apDyd1o!6dF?LwULCG>TN(?ipj9W0*MrfVkEIyo?=VoDGI3u;O~5S4 ziugxoE^~Jat7a>?x9PbNdK@lK#HY@r!ucBydzo{|)~W2d7iyRd7upd8u&Ht9@<0_z ztANP$xac&|*ulU%UlhzYC@ehUWO5r45mVT;mpaq4s<@O0Tt%YX+k_;wd&PHzdZ!t+3+ha*65B9@B<1Z`;;u* zormmGwTA4$5$WBkNScR%X8wm#1EDLAKVkWeUR@`1<|_i0r+-C}Z_h-DkS!1v=NcK# z{qi8);Qd&-N~g2G+*uIqn`p3yB&VG7-3^o1L5;gYQ6`V(;;6%MWu_P{rC`fy$^HPq zu50Fv>oac#$0%T}F$)Tkkfo5_oL8O(C7Z(q9jG8B-%UB~Oiu@Bw4= zm^9sZ7gx4nlm%9K{U?^sfIA|@MK zAL~&q`kU*kljSlyhmCR5tC;aDI!uLsE9i5Ui5Ij$KaaE-%Z$P;f8=1}u(eHE2Zu`f z?zrP%6G4@;`s0C#3sg1ySH&Zq*oOsU)hcq{E!)L1AG6Ji@=yQfqX*3m&hJKm6}k##Ar=fI(zT*& zRoRw9SFi7ZJda@a<(E)At2wz8?j(7MgTGMCD1#txVYpx*^(i$iSD#5+szrhPn;pys z-)oRX)B0<{yFB-5O-q97st}u^m1$nU;Q<5CGs`Mf5vhVKz!kC<~oR{_Tf*iFWrH zwIz%6!4HMHT<4mFg46&4THbJK=buGh)CPRjJy9up!(4$C&INh%SLJfcju1e|Jt~a8 z%^ziTM=xQ*LB@H)ni;5xU+4}`r4njGFU#jnsNp`sv*~TS6Z1PiqsV7b`?|n=1kpd< zC`6?$t*^cJ@E1&PXAZU3&OeU?2vh?E?uWzsy}O6?LlU!cQDea7fM}Z=WTdTv2>CA7j?JmzZs9$ZhT^GZH=HyqKlh0BV>qbc5 zWoo+#W)>Y34F+tN`0iZ$n9rTXe^`~PXEQ?*)2%-AbiJl9h^CUQgI&e?JRSNVDEL<)nQR4M?s4b=a%&@ks!?KIqrjanCJFlghLrwy9HuowqZG2GO>RIa$dzZ5k&2g6 zTd_?28m7M%n&*o%4%6pyF`)SGi5 zF!N--m<9q!WCyRKX|z5+`(ep0AKH9eA1q(a(DPQ|(oB23?SZhyU#*>e+?+m==`-bH zqYLYCcIY%nn#Xp_+`M2*EX0Z0 zWpbyBA=hKhf#=6U5U4rW30mvI6&AcRT`Q`@;9K5BXd2A3p*wcnhyA8+-kF`wUr3&a;I3;T{!7AGFbKa2RUzHP`SHfA3|dvY6?vC- z-V0bLOw|HeJDz$g>c}bf$ej_M%!R}eHldfBtt~j?)6h9_p7vx-xZ~u)x)1{Sg5Tnn zlZY8szj9kYp^OMlIUk!{4r5I7lnkc1`h9L?^Md}=wbHxhRZxoiHTCcEJ(<-qU6%;> z_n={UqKI1oyj|CFL%=CQy zbxq~~WY`_ua2yJ`(+yT!Xe|Bjb=x1SxaH>zt znXNqNrdk1wS&V?(GSS7U38MT7o4~YQXBn-$kwY7;HnIlOXG9$E=pAgm-mPP(vXj4E zmS!KdO*2%_!v(`CIR-oym16xq*_wi7Pgeim!WsOPg;`(`-d49G@l$cx#N3Fu^qZs< zDu#06;2_hiM<9Tit%J`fn5_tXtx3&A_O~&DoGA)5ZS~BE~6sEmO>8w}b&uza`!ObVZu6M!9)e8Z`ViEDEMgQloIWmdv^eAoXpZ1)pK z?(GX)lYg*a~vGjHMK@ijgxtk-H4{nH z)_LQx|7)dW<{E3kIM8sh?1Dvg4c?}vs;7kz6Ere-HvS{cxS*_nEd`s46%17Vme#}V zD2Yk;k@Ggn2H8cOxgU)?85`LS7|%eqjG$4sZa`@vK<_YsKeCe|{6}>;_z&)ii8u1} z`$4ED%v$P$6z+RQpXQ?5hK!hPNU2Kf?o>%o%u{yj4EB7Ejz1^^3Nlz%{>^JKxeB^^ zZd1>UtWyQ;(&KszqyD$K55fdzd`)?mEGy9Nhn?ZDI0edV*_U?V#b_$?{rhIw8=Bvf zvx%WSywW$eny)09uw-6=M%*)X$$awQS7C!26;|Oos}DicO9$-q*^(3Db$K4Pa@$k2 zb|1&&`m-z)r0xsq4cOuK&~y- zu4%V;6yJr-b&KPLXG9e(y`Lu|R5{pafaBT=uWI9K)f!j*m%BH&aC>(PkM%sCE)km%uYk#hFKkxANOvd2zeOSguP2zN zCgoKUbdHU1n=|I`qmm9Ya~8uCi*MTwSx$ZpR4%O4aP;VwwicYwDe*ajeEe9HfV@DyEDq_yPL4hTk+2YIuH?`c zYdzf&8JiMsjT=1=fMrt_dT|wfR2V$&ll7kW@T3BskML^F|0aPP4Z)y1nv*a$VEnQzGtAtgOvd z<_}W#tdE%g7&c$v@Tr`8@ZSjlSFRfXJAj@Ic zc^%)5h*bEis;@)(HFYd|Dzzv6KIbdhLh7-FSN28&`;Wr1Tu@8%e{_2&TFx_bT%9Zt5`r4$% zug@gjkvbYzIwLNOU6cho+$05&jUatnV1q^lM5YFGDsF8@xbLQ2Dtvi@S*y;De7sTz z&{3q+rLai+BoM3gcAhXo-%eyh2d&{4we zkTk)Zb@EH=MwD^kJ3$1Er8jwNwB~SdJN-Nd^tY7;DUCYK6a(Nq+wHNv_W^p8vR|5k zs5HxIuLH2BaH}tHA;kwox%M<$3d~3$U|nNVxb$?>n!p7W>)r(A z7kZ?>4K5j@qyVns_IlG!*0ve=a9D!XJINQ>LTe9pMxy7n5Nj++`KS+7-f!)^I-{GB z`9a@Qz%2el9qdXlgs%LMMZO0n=8QO0%tawBOEyA{@7-uGp~t~S$!cDmMM^RP}KJ}m{IE}EQaZvcEHA|bA*2^>1c@=vI(kF zYFuq+fVVRh^cO%mwm@PR{;5vyQLa3*sSxlfc~}a(opi<>Z%*z7f#nWtE7mIFD+pI; z?tLAMtTYIaW6Gnx#|alS3LF zG}bz(_I4{W%5FYOJgbJa_V3y_84loB55je;*(WoB2U-)EfU%7nJRIJshHOTB?e@RT z#ET=|n=whOZ#C;tyjey~Ee(kd<2J3ufAJKZ{Y&bO_?*_+S^B3}jykzD1m*O7xe4Jk zY}D}o{yMg;Qsjf;02#XbV2ey>r75%z1^8OTC!UBfs46u3V{K zPiQ(?mhO|&8Hn^HP(~(6$q*ULB$_OM8FzY}=D{s@K%?aLHrZ#`MG@+~V~FI(28O_- zYIr7!3Ub5%5ykldb<6(r5JU>}-iIcZ6IVN(&BN;iFT~p+KyL09fj_1Q-fd-4pkF$5 z>V`MX2c6*Xr+3v210F7O@JF4T^eu>ArKT0#_dDuX%FKgkDPS0P=}$!xyoqt5$bCDa zltq8PJ7&-?4M%|S(KwLz?JL0ct3KoEHfW{xTWJlt;?M2JZa_aLw`Eq%WxEdzvwFAH zpz#RjCVz1`L5^fwheD_Dr}gB*X;?D)6SeHP%^Qq#Ug^R*52oi&ENSNc*q!i$`P_is zm}5C;**=k$Gn8iGvYF3TJH7Nnj3K!ku8TnrpMTruIy zqpZQ4xuz*__c1`5jVO zcDtV-n6NcgqL4SCQ(#Z)Z0fIsD2|ET-%DjZy2-Vs%rTYO=DSz}Jd!oA;Dp@TMftX~ z)AlwySemlXW8<~}6*HisZ{Cpz!Eq^cJTh>ueU7fYLt`^*CI+(X3^CUtsm?z3#{(p5 z-TJhtWvKwc zKmF^V+T4Dja^n0lUq5Che|T;QLk0ZvCt>-e6S{vCJpz*(S6>hsK+?PylQ)R)$5Gpv zFBf9?Tvx1MH~9rXTps||gXtZ`{C*OHvx1ve(7*XXy@~%y2jSoCCSWq;wK#H-VBz3k z^MG@NTP$_ztt&UwIjMB&6C*1op1&ZO97h@>=kwdkJ6tA-a{>xhIYmeP(0!gu^bw*E$5FAW9U0Wvg5qVW(XWN6l%*B=d%R+1_2{bu*_`72(i{v(um&vMCRD5cOQatXD;IMibGuiELwV&-U1#w_*}&7E4Mko zA0K!~W-a>hJEHY4>mKz&KX{z2`B-zhS#L28W_|S2!teDA;sz{(s;1X>af2ZjHx3N%Fo4-Y|i&R!22d*N0-nCPg( z%>^4;SeP7m4bNlw16nS0y3g_1arx2W4O9Q!e7dM~meLE@$ac=oS4>~vUrhyA9F<3b zB1u)GuhE%+auOd1v(8*G=QuC@;mmh;rc85x#?r@Sz|L`#k$=?-5`m{mf1LM*ETYZ7 zO8+i{_WQ&q5WSFL-Zo2m<7Mf+Fpv;t85NLWS~1R#6A9XU(Hqd#N^9#(g;egkw{IxV z8*%S1*6O$kA73UjkIYf?Kh${r{Ce1w z?osXUWVA*FY>n*tx`rK%Kn(F#e{X;`AjMzE{BrOHm>>HUZ_uB3g(6Wu6Xo}wvGZk# zUsv#JPhZ+9D?P0MC4JVkzjNgV8=G2{R-B3O?pWp)fK+Fdf@JJ}RWd2oyH8dTecPgf^4C;4B;vs`u;c&{5nIom)9ayqg51NU9<{^cX10*%28{YH%WeAw`?o47yq*}C~z(| zTxU}JWaXXVq~TBb6EPYfCe7-|5b`3j0%AJ`+#$vs-W~C_D-OejEEf@`R@Pv2f=+oG zGU7@TZwz@YnoBP=U8(X*!!?!c>&C|W!mQ}(F4PRuU00&JMTbT%7Hq745SJpd-BVLB z!Z#7spDF=02y%^x>hj3_-~0IOsQGG#EM}LVJe{|xz~uclDYDan)#bGngP5btA@js- z4qaIRUwKDqXLi6{hcQba0w|o`2y7eF@m~dg{N5BkJ~4sS@>Tj5Pt`eo?NAARcth(b z2XNGd6U;g*44Qj{X~f6Ksqzgvg%%LV;i+`vw320{|ePV^SL11fNpJ*<_?s4%gUSYDz(- z$a3L{7N0hg3`Z_;AOKb10009300RI30{{R6007;z%W>|%NJ7G%(`yF+3^X_rrPPeu zW(BTPHrh(UOdrNCd1ovTy?XxHq^KOexFV$)JICG-Kf}_sn1Z#bj<)7pd#WvzX&4CA z^@1X?Fx2;4&#!HFoHWHpHx;PVBL->RBRE-IZ3Z^6qTJsg>BR5;0oc(18ONc)C)DW5 z-N-{{{CVqve@YPls$nW6>o!8`dpb+(mJ6tl^5FgV)B3vOMcx+5`*keVO32o`2?r0w z>eC*Pvee6)f%+)F4atu-4mh>nS7sY2SU*t8LXqXXK)$IsgwBPY+A!+9nrBJhmQ|siUD2BmwMjstIrH%0jnrh zp^7C(S@sVXqCe`2+$MAAL^|CaPG(UgiXL$TYn2hxOzCUtCotS*tPI3(Pl18z1M=@9 zB}Mr05`Vb)f^DK+wR~*1FKagfH3)6Ro0_hmUH|6;VOgi2(d*1<N=7pF@9cj&~I(&j~RWOqdDT^_S>=R zE=Da;@??MbArdIjasYABbsP+p+#9X7FRvcZq`AHfmMy3>|eNU zqIdo3;LiB|oJ8{KQUXZ3)@u-8qZ-b5%n&8Aa-1GrB1go|7nHIL!O?k=_r=Nxg_?6^ z2U+uTXXqUs=Tq;_CPDk6%8U8{x^tUO$0ImN3ZO3r3ia}b(|sY~@KU17UO_8=*{F`y zk}Krb%rrGqOl*jM{wv*~v{DX_Wlx`4k^cQrT>H2D-GlZFPX=}vJfx-~hbx4~j}oq~ zES6t7kNd1y1`mZU3j%Je*_w8U;J49T?hPjLT+RtwD_5dNerhQS2)Tqjz~%|i?yU?& zjT|T6(tIIhy^t^{1upjMbD~h-K+hxD%n&k!`&vMafU^`MU-I%xLbIX>9s=Mn9m1jOYn zu}C`41a^c^=clm}TA{{Q529V9)n{hGH6=l}vo8ka2-19Xb72iZ6Y)yNBDCB*?Oc_! z?`QI+0)ftdpfo!#fc-W&n#sDz9oZ`_s^8?QR=rG~^}gU2PbGB2Kwg5NwbpE|M6Hk= z2mLMwu|)~zH9yOc9PuPS{QhiAEUCiL(ySG!swRlOp&z*)DP08J_`W^zZyEJ_oGl;D z5MAsEEi5DKL5u?Zi7b(j_QlvZ==#DtainoWXsL3$X2WM3bZz@X zk_27q`8=6V;>CIg!)6Pdq$eiE{J;4T%?H7HR!Zs2QMan+QXVT_vAAWW1$b%@8iKU} zTFt4%sFuck;42OO%!rP!HJPE@f5A&EO%PhzK{B0c1*6WGH>G2C-_w}!SwA$KOO^?H zXLBc-(?;J(ord=MqH-YuMD3k>I8^B$z|SBiqiL+jx+N49LnN1NXfQ4%xnH(&UkpR! zR#}Yfge1x(8O^w)@k4I8v?5wsmy{$_BPpVc5=kg#4>Q~DZ-3iwXZCsadG?R?w$SRtQ)|y6# zOe|1J?m8A6VYtw$nxAPrvE8iE+_Q+QjQURZ(@x9G^JcD;xE@h`nPgXq(*~=pnCdx$ zkC~LGLD6j%-szS;q_G3$;tuY3VWWjxt^1sN`?1PCk7Q3hHsUcw%*sfK23|as6k;gO z$bd&j9#qFy{(!2i`MIW;*YsS^WT}}!y6R2h%vS8AA-W{)do%TbH97pV!|kb;rPc~r zO7fddyYbPBI_GhxtGaii7-fFgKs&sq-PqR-PE3z3a^yLA5p)@`t>5{;g;Vrv-^_&% zxz&!=J`|A};JN*LAnoD7rmuo^einFj^N@sm=}rUb$D^;ZUG(H{k05sR9slK}N0gvm6+NzG{GoA)dKPmR;P(A@R*3ed)F1-9k&bQ*$Ge zv^1-zzPtMV(T9+`Oire&x0Cc*7Sz;uq{`B|-6Xc-r}+Uzgk|wJdDr0QszL_3y!YI2 zE0yb;wWxbpB`AG=BRzS%#_FN(n52Xiz5U9gwxh}4J~`=Rrd)DTEga>(caMW?QR-Z> zYk{#B{OR#Vc$}PLYkhRCz&0gHzUqKZOehJ38UOXHTQgD2EWOi3xL zWG_|=w`l(qzKD{&6nqrLe}Rs|;Na~9g^YXZ1sPp&<4tWI zHl3r#@QVH7MLeZOnjiAZS(l$fsz09a>;(W=2R2KAVA#0yp9}!dHAn15tO_S5)Rm8o zCVEbEeYi&7F<-i;UbL&Y_(>SE(A%80AfBLCwkAJxcz_(3J^by9G*Wze+MZJ|?M9N5 zhN)9DQ@veSFd{dNanMgP$bqlI%i`{h2>-9@-@GM><-4EC>jJl=yZGeHB`aydrUcO* zvvY^A9~99wvr%Df4*BGf40~UT+QpG$GC35=I7%eYBBMFC$!>l7 zc9cRy%zh(&xb_2W_ajvjg}K?QE*4moWcn`vfNWQ{+5>VxVC2368rD)R@{Saibi9Qy zotgFg_DlqtEElCJJ$cTtX6U;FlhUF#8E0}c^}HV6x!Tfj#&2|jhZZvJWLT1 zJ-+L%(UCG+Lc>QjwSL${o9;!%(}sT0iEi-*z5Ou__3tWbEbNibEekH7cPg|1n-g;q zwT%!Tw@MBjiwj~p7H$dCeXwDngN84Wsg}*}KS>O*bzGyCsTA%NMlnKqT%q~L2WTp3 z%&a$Myrdg=2?$9~iNp1%sqWy`9JsJomK7<5fdbgaI*IFAE)jKb61~WFchU}CL!NXM zQh0kwcC(MuBir)`s^kl8P|`xz003~nQVfogm;iwD)+nB=OqIvxzVypR7-0xcW2I8R z2LMD9p!f6Y55pv%2|$c+rGsGW3}jXZyF2&1n4w6~76vDS%^PR04hd|#tOBp1#=mKl z1pt0_5@_f(O;K z&N^{aSiEiaP{@>Bu-da75p6NxG?1lydI$gBOcF(0N7bt3i!cF@13RN0nt^-u}#1_vVZ-`bc7B# zQyE4nSYg%qw9anqW^Ixq`_Vj3WCCpdG#&xKgtW7lZwd#o#?w}qbcL$?7&lv(;J;_D zybY{e?rt^Q17%1Alxld1;9XccI3A77DgmJgzd`-_?wPu41oLjYERX#4z{r9#c3>$T zIMH8OvWq?4dg{jcasZf(-9rFCnJnziLGq;>1Er-988WppJ$3mTfF!RtB?)AWXK+KZ z6`S?;&q;m*SRoPLzCuAo{yhq+3a1n-c~Dikqag3#j6#h?Vf{BLf;gpM$$}cpCB;$B zDA=;d;Ff~8q9N33PAS;3sO6HPo-+!zEFN=9!TMcL=Q*Wd%i<%K6hoX*uw+4z;g*6W z3krri3W`2=6j=(KQLtn|G2oVhZKGJ8g#&BLk_Fp?aA<8o8@230{>0k;xs3w7#^1BF z|ESLZDZbz{{z$==1>`RPfCG(!{Kfx9A%D~;$X_VKAb;`KaV==0Ab-JmWDfa@zs!`N z{DPZYj|$}%9B337$}c#uQBZyXK>5YzBGm6r#G(8Gj0T~46epGzsz-5PX`y-)7nT;v zFF4kyf3}AK)uXtI%prdP`HSBLQDB9Q9Y;~2{sl*MT6X0h>R*5hHU3+XK3}qXtWf^~ zTwTCH<)5ulP`~^CXcQ~gqeA^|jx-ADcZ0p8!^|bx4_~xVY8$~d1mJ3xe|x{%o4G_m zeUak&a&-)(_*eG3w_vlp+5K(@=92dAFH!K&Sr86AgmIgD%lCLD!Fk}qqRSjFCHMv6hPEsK literal 0 HcmV?d00001 diff --git a/partner-royalty-settlement-guard/demo.svg b/partner-royalty-settlement-guard/demo.svg new file mode 100644 index 00000000..e7a04e1b --- /dev/null +++ b/partner-royalty-settlement-guard/demo.svg @@ -0,0 +1,32 @@ + + + + Partner Royalty Settlement Guard + Revenue Infrastructure slice for data-licensing and white-label analytics payouts + + + + READY + $925.00 + release payout lines + + + + + HELD + $232.56 + aggregation floor + + + + + BLOCKED + $640.00 + duplicate + consent + + + + Audit controls + duplicate invoice protection - split checks - consent gates - reserve recommendations - stable SHA-256 digest + Validation: node test.js | node demo.js | ffprobe demo.mp4 + diff --git a/partner-royalty-settlement-guard/index.js b/partner-royalty-settlement-guard/index.js new file mode 100644 index 00000000..fd84b0a4 --- /dev/null +++ b/partner-royalty-settlement-guard/index.js @@ -0,0 +1,288 @@ +"use strict"; + +const crypto = require("crypto"); + +const DEFAULT_POLICY = Object.freeze({ + asOf: "2026-05-20", + platformFeeRate: 0.18, + reserveRate: 0.05, + minimumPayoutCents: 5000, + minimumAggregationSubjects: 25, + maximumResellerFeeRate: 0.3, +}); + +function evaluateSettlement(input, policyOverrides = {}) { + const policy = { ...DEFAULT_POLICY, ...policyOverrides }; + const agreements = new Map((input.agreements || []).map((agreement) => [agreement.id, agreement])); + const seenInvoices = new Set(); + const settlements = []; + + for (const event of input.licenseEvents || []) { + const agreement = agreements.get(event.agreementId); + const findings = []; + + if (!agreement) { + findings.push(blocker("missing_agreement", `No agreement found for ${event.agreementId}`)); + settlements.push(buildBlockedSettlement(event, findings)); + continue; + } + + const grossCents = assertCents(event.grossCents, event.id, "grossCents"); + const platformFeeRate = rateOrDefault(agreement.platformFeeRate, policy.platformFeeRate); + const resellerFeeRate = rateOrDefault(event.resellerFeeRate, agreement.resellerFeeRate || 0); + const contributorSplits = agreement.contributorRoyaltySplits || []; + + if (seenInvoices.has(event.invoiceId)) { + findings.push(blocker("duplicate_invoice", `Invoice ${event.invoiceId} already appeared in this settlement batch`)); + } + seenInvoices.add(event.invoiceId); + + if (agreement.status !== "active") { + findings.push(blocker("inactive_agreement", `Agreement ${agreement.id} has status ${agreement.status || "unknown"}`)); + } + + if (agreement.endsAt && agreement.endsAt < policy.asOf) { + findings.push(blocker("expired_agreement", `Agreement expired on ${agreement.endsAt}`)); + } + + if (event.datasetId !== agreement.datasetId) { + findings.push(blocker("dataset_mismatch", `Event dataset ${event.datasetId} does not match agreement dataset ${agreement.datasetId}`)); + } + + if (!event.consentAttestation) { + findings.push(blocker("missing_consent", "Consent attestation is required before licensing revenue can be settled")); + } + + if ((event.subjectCount || 0) < policy.minimumAggregationSubjects) { + findings.push( + hold( + "aggregation_floor", + `Only ${event.subjectCount || 0} subjects are represented; minimum is ${policy.minimumAggregationSubjects}` + ) + ); + } + + if (agreement.allowedUseCases && !agreement.allowedUseCases.includes(event.useCase)) { + findings.push(blocker("use_case_not_allowed", `${event.useCase} is not allowed by agreement ${agreement.id}`)); + } + + if (resellerFeeRate > policy.maximumResellerFeeRate) { + findings.push( + blocker( + "reseller_fee_cap", + `Reseller fee ${percent(resellerFeeRate)} exceeds cap ${percent(policy.maximumResellerFeeRate)}` + ) + ); + } + + const splitTotal = roundRate(contributorSplits.reduce((sum, split) => sum + Number(split.share || 0), 0)); + if (contributorSplits.length === 0) { + findings.push(blocker("missing_contributor_splits", "At least one contributor split is required")); + } else if (splitTotal !== 1) { + findings.push(blocker("split_mismatch", `Contributor splits total ${percent(splitTotal)}, expected 100%`)); + } + + const platformFeeCents = roundCents(grossCents * platformFeeRate); + const resellerFeeCents = roundCents(grossCents * resellerFeeRate); + const royaltyPoolCents = Math.max(0, grossCents - platformFeeCents - resellerFeeCents); + const reserveCents = findings.some((finding) => finding.severity !== "info") + ? roundCents(royaltyPoolCents * policy.reserveRate) + : 0; + const distributableCents = royaltyPoolCents - reserveCents; + + const payoutLines = contributorSplits.map((split) => { + const payableCents = roundCents(distributableCents * Number(split.share || 0)); + const needsHold = payableCents > 0 && payableCents < policy.minimumPayoutCents; + if (needsHold) { + findings.push( + hold( + "minimum_payout", + `${split.contributorId} has ${money(payableCents)}, below minimum payout ${money(policy.minimumPayoutCents)}` + ) + ); + } + return { + contributorId: split.contributorId, + share: roundRate(Number(split.share || 0)), + payableCents, + status: needsHold ? "held" : "payable", + }; + }); + + const status = settlementStatus(findings); + settlements.push({ + eventId: event.id, + invoiceId: event.invoiceId, + agreementId: agreement.id, + customerId: event.customerId, + datasetId: event.datasetId, + useCase: event.useCase, + status, + grossCents, + platformFeeCents, + resellerFeeCents, + royaltyPoolCents, + reserveCents, + distributableCents: status === "blocked" ? 0 : distributableCents, + payoutLines: status === "blocked" ? payoutLines.map((line) => ({ ...line, status: "blocked" })) : payoutLines, + findings, + recommendedActions: recommendedActions(status, findings), + }); + } + + const totals = summarize(settlements); + const report = { + asOf: policy.asOf, + policy, + settlementCount: settlements.length, + totals, + settlements, + }; + + return { + ...report, + auditDigest: digest(report), + }; +} + +function buildBlockedSettlement(event, findings) { + return { + eventId: event.id, + invoiceId: event.invoiceId, + agreementId: event.agreementId, + customerId: event.customerId, + datasetId: event.datasetId, + useCase: event.useCase, + status: "blocked", + grossCents: assertCents(event.grossCents || 0, event.id, "grossCents"), + platformFeeCents: 0, + resellerFeeCents: 0, + royaltyPoolCents: 0, + reserveCents: 0, + distributableCents: 0, + payoutLines: [], + findings, + recommendedActions: recommendedActions("blocked", findings), + }; +} + +function settlementStatus(findings) { + if (findings.some((finding) => finding.severity === "blocker")) return "blocked"; + if (findings.some((finding) => finding.severity === "hold")) return "held"; + return "ready"; +} + +function summarize(settlements) { + const totals = { + grossCents: 0, + platformFeeCents: 0, + resellerFeeCents: 0, + reserveCents: 0, + readyPayoutCents: 0, + heldPayoutCents: 0, + blockedGrossCents: 0, + readyCount: 0, + heldCount: 0, + blockedCount: 0, + }; + + for (const settlement of settlements) { + totals.grossCents += settlement.grossCents; + totals.platformFeeCents += settlement.platformFeeCents; + totals.resellerFeeCents += settlement.resellerFeeCents; + totals.reserveCents += settlement.reserveCents; + + if (settlement.status === "ready") { + totals.readyCount += 1; + totals.readyPayoutCents += settlement.payoutLines.reduce((sum, line) => sum + line.payableCents, 0); + } else if (settlement.status === "held") { + totals.heldCount += 1; + totals.heldPayoutCents += settlement.payoutLines.reduce((sum, line) => sum + line.payableCents, 0); + } else { + totals.blockedCount += 1; + totals.blockedGrossCents += settlement.grossCents; + } + } + + return totals; +} + +function recommendedActions(status, findings) { + if (status === "ready") { + return ["Release payout lines", "Archive the audit digest with the invoice packet"]; + } + + const actions = new Set(); + for (const finding of findings) { + if (finding.code === "missing_consent") actions.add("Collect consent attestation before settlement"); + if (finding.code === "aggregation_floor") actions.add("Hold settlement until additional anonymized usage is aggregated"); + if (finding.code === "duplicate_invoice") actions.add("Reconcile duplicate invoice before payout"); + if (finding.code === "expired_agreement") actions.add("Renew or replace the agreement before recognizing revenue"); + if (finding.code === "split_mismatch") actions.add("Correct royalty split percentages to total 100%"); + if (finding.code === "minimum_payout") actions.add("Accrue small royalty lines until the payout threshold is reached"); + if (finding.code === "use_case_not_allowed") actions.add("Obtain written approval for the requested license use case"); + if (finding.code === "reseller_fee_cap") actions.add("Cap or approve reseller fee before settlement"); + } + return Array.from(actions); +} + +function blocker(code, message) { + return { severity: "blocker", code, message }; +} + +function hold(code, message) { + return { severity: "hold", code, message }; +} + +function assertCents(value, eventId, field) { + if (!Number.isInteger(value) || value < 0) { + throw new Error(`${eventId || "settlement"} ${field} must be a non-negative integer number of cents`); + } + return value; +} + +function rateOrDefault(value, fallback) { + if (value === undefined || value === null) return fallback; + const numeric = Number(value); + if (!Number.isFinite(numeric) || numeric < 0) { + throw new Error(`Invalid rate: ${value}`); + } + return numeric; +} + +function roundCents(value) { + return Math.round(value); +} + +function roundRate(value) { + return Math.round(value * 10000) / 10000; +} + +function percent(value) { + return `${(value * 100).toFixed(2)}%`; +} + +function money(cents) { + return `$${(cents / 100).toFixed(2)}`; +} + +function digest(value) { + return crypto.createHash("sha256").update(canonicalJson(value)).digest("hex"); +} + +function canonicalJson(value) { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + if (value && typeof value === "object") { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +module.exports = { + DEFAULT_POLICY, + evaluateSettlement, + money, +}; diff --git a/partner-royalty-settlement-guard/requirements-map.md b/partner-royalty-settlement-guard/requirements-map.md new file mode 100644 index 00000000..972f6e1a --- /dev/null +++ b/partner-royalty-settlement-guard/requirements-map.md @@ -0,0 +1,19 @@ +# Requirement Map + +Issue #20 asks for modular revenue infrastructure spanning subscription billing, AI compute billing, and licensing APIs and analytics. This slice targets the licensing and analytics revenue stream after a license invoice exists but before payout settlement. + +| Issue requirement | Coverage in this module | +| --- | --- | +| Data licensing models for institutional customers and government partners | Models partner/customer license events against agreement metadata and allowed use cases. | +| Licensing APIs and analytics | Treats white-label analytics and policy-planning exports as license events with dataset IDs, customers, use cases, and invoice IDs. | +| Monetize structured, anonymized usage and research metadata | Enforces a minimum aggregation subject floor and consent attestation before revenue can be settled. | +| Predictable recurring revenue and high margins | Calculates platform fees, reseller fees, contributor pools, reserves, and held payouts in cents. | +| Secure payment integrations and institutional invoicing | Produces pre-payment settlement decisions and audit-ready recommended actions without touching live payment rails. | +| Volume/consortium pricing and partner channels | Supports reseller fee rates, contributor royalty splits, payout thresholds, and duplicate invoice protection. | +| Compliance and audit readiness | Emits deterministic audit digests, blocker/hold findings, and finance actions for each settlement. | + +## Non-Goals + +- Does not integrate with Stripe, PayPal, banks, or Algora. +- Does not store real customer, contributor, or payment data. +- Does not duplicate existing broad subscription billing, dispute, tax, SLA, renewal, pricing experiment, procurement, margin, or privacy-safe licensing gate modules. diff --git a/partner-royalty-settlement-guard/test.js b/partner-royalty-settlement-guard/test.js new file mode 100644 index 00000000..252da063 --- /dev/null +++ b/partner-royalty-settlement-guard/test.js @@ -0,0 +1,110 @@ +"use strict"; + +const assert = require("assert/strict"); +const { evaluateSettlement } = require("./index"); + +function baseAgreement(overrides = {}) { + return { + id: "agreement-a", + datasetId: "dataset-a", + status: "active", + endsAt: "2026-12-31", + platformFeeRate: 0.18, + resellerFeeRate: 0.08, + allowedUseCases: ["policy-planning"], + contributorRoyaltySplits: [ + { contributorId: "lab-a", share: 0.6 }, + { contributorId: "lab-b", share: 0.4 }, + ], + ...overrides, + }; +} + +function baseEvent(overrides = {}) { + return { + id: "event-a", + invoiceId: "INV-A", + agreementId: "agreement-a", + customerId: "customer-a", + datasetId: "dataset-a", + useCase: "policy-planning", + grossCents: 100000, + subjectCount: 80, + consentAttestation: true, + ...overrides, + }; +} + +{ + const report = evaluateSettlement({ + agreements: [baseAgreement()], + licenseEvents: [baseEvent()], + }); + + assert.equal(report.settlements[0].status, "ready"); + assert.equal(report.totals.readyCount, 1); + assert.equal(report.totals.readyPayoutCents, 74000); + assert.equal(report.settlements[0].payoutLines[0].payableCents, 44400); + assert.match(report.auditDigest, /^[a-f0-9]{64}$/); +} + +{ + const first = evaluateSettlement({ + agreements: [baseAgreement()], + licenseEvents: [baseEvent()], + }); + const second = evaluateSettlement({ + agreements: [baseAgreement()], + licenseEvents: [baseEvent()], + }); + assert.equal(first.auditDigest, second.auditDigest); +} + +{ + const report = evaluateSettlement({ + agreements: [baseAgreement()], + licenseEvents: [baseEvent({ subjectCount: 8 })], + }); + + assert.equal(report.settlements[0].status, "held"); + assert.equal(report.totals.heldCount, 1); + assert.equal(report.settlements[0].reserveCents, 3700); + assert.ok(report.settlements[0].findings.some((finding) => finding.code === "aggregation_floor")); +} + +{ + const report = evaluateSettlement({ + agreements: [baseAgreement({ contributorRoyaltySplits: [{ contributorId: "lab-a", share: 0.7 }] })], + licenseEvents: [baseEvent()], + }); + + assert.equal(report.settlements[0].status, "blocked"); + assert.ok(report.settlements[0].findings.some((finding) => finding.code === "split_mismatch")); +} + +{ + const report = evaluateSettlement({ + agreements: [baseAgreement()], + licenseEvents: [ + baseEvent({ id: "event-a", invoiceId: "INV-DUP" }), + baseEvent({ id: "event-b", invoiceId: "INV-DUP", grossCents: 22000 }), + ], + }); + + assert.equal(report.settlements[0].status, "ready"); + assert.equal(report.settlements[1].status, "blocked"); + assert.ok(report.settlements[1].findings.some((finding) => finding.code === "duplicate_invoice")); +} + +{ + assert.throws( + () => + evaluateSettlement({ + agreements: [baseAgreement()], + licenseEvents: [baseEvent({ grossCents: 10.5 })], + }), + /grossCents must be a non-negative integer/ + ); +} + +console.log("partner-royalty-settlement-guard tests passed");