fix: keep legacy prices and address review findings

Subscriptions sold at a replaced price lost their plan, because a
subscription was matched only against the configured price. Resolve
prices through the configured price, STRIPE_LEGACY_PRICES, then a
registry that now also records subscription prices and every configured
price at startup. Store the price on subscription grants.

Review fixes:
- Recover a Never expire add-on found by reconciliation before expiring.
- Open Checkout for the subscription cycle the hosting option shows.
- Report conference hosting only while the package payment stands.
- Keep automatic updates on a paid-slot repository while its owner subscribes.
- Apply the repository expiration limit only to repositories.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Thomas DurieuxandClaude Opus 5.5 committed 2026-11-12 17:13:19 +00:00
1 parent 4340a9fe09
commit 0f78182c58
13 files changed
+198 -24

No files matched your search

+11 -1
View File
@@ -53,6 +53,8 @@ STRIPE_REPO_PACK_PRICE=<one-time EUR price ID, Paper pack>
STRIPE_CONFERENCE_SMALL_PRICE=<one-time EUR price ID>
STRIPE_CONFERENCE_PRICE=<one-time EUR price ID>
STRIPE_CONFERENCE_LARGE_PRICE=<one-time EUR price ID>
# Optional: retired price IDs still used by existing purchases
STRIPE_LEGACY_PRICES=price_old_annual:author_annual
BILLING_AUTOMATIC_TAX=false
```
@@ -75,7 +77,15 @@ PAID_CONFERENCE_LARGE_REPOSITORIES=400
PAID_CONFERENCE_LARGE_STORAGE=214748364800
```
Changing these values changes the allowance of existing plans too. Keep existing Price IDs configured for as long as subscriptions using them remain active. Plan and conference review-window changes are intentionally locked in this first version.
Changing these values changes the allowance of existing plans too. Plan and conference review-window changes are intentionally locked in this first version.
### Changing a price
Existing subscriptions and purchases keep the Stripe price they were sold at. The application maps a price to its plan through the configured price, then `STRIPE_LEGACY_PRICES`, then a registry of prices it has seen: every configured price is recorded at startup, and every price is recorded when a checkout is created. To replace a price:
1. Deploy this version while the old price is still configured, so startup records it.
2. Add the old price to `STRIPE_LEGACY_PRICES` (for example `price_old_annual:author_annual`). This is explicit and also covers prices the registry never saw.
3. Set the new price ID and restart. New checkouts use the new price; existing subscribers keep theirs, including on renewal and cancellation webhooks.
Configure the Stripe customer portal for payment-method updates, invoice downloads and cancellation. Disable arbitrary subscription product switches. Configure a snapshot webhook at `/api/billing/webhook` using API version `2026-10-28.endive`, matching the installed Stripe SDK, for:
+1 -1
View File
@@ -1,6 +1,6 @@
{
"core.min.js": "core.d23af1c652.min.js",
"vendor.min.js": "vendor.51a6ce08f9.min.js",
"vendor.min.js": "vendor.d5b4d04371.min.js",
"mermaid.min.js": "mermaid.a7356d5ed1.min.js",
"all.min.css": "all.8c4e8ecba6.min.css",
"markdown.min.js": "markdown.6c8dfb7b1d.min.js",
+7 -5
View File
@@ -1531,7 +1531,7 @@ export const anonymizeController = function (state, http, html, params, location
};
state.hostingPlan = "";
state.hostingSaving = false;
state.selectedHostingPlan = () => state.authorPlans().find(p => p.id === state.hostingPlan) || state.authorPlans()[0];
state.selectedHostingPlan = () => state.authorPlans().find(p => p.id === state.hostingPlan) || state.hostingSubscriptionPlan?.();
const loadHostingQuota = () => http.get("/api/user/quota").then(res => { state.hostingQuota = res.data; });
setupBilling(state, http, async () => {
await loadHostingQuota();
@@ -1589,7 +1589,7 @@ export const anonymizeController = function (state, http, html, params, location
state.hostingChoice = () => {
const oneTime = state.hostingOneTimePlans();
if (oneTime.some(p => p.id === state.hostingPlan) || state.authorPlans().some(p => p.id === state.hostingPlan)) return state.hostingPlan;
return oneTime[0]?.id || state.authorPlans()[0]?.id || "";
return oneTime[0]?.id || state.hostingSubscriptionPlan()?.id || "";
};
state.checkoutRepository = async (addon = "") => {
if (state.hostingSaving || state.billingBusy || !state.billing?.csrf || state.sourceLoading) return;
@@ -1603,7 +1603,8 @@ export const anonymizeController = function (state, http, html, params, location
checkoutDraftId = saved.data.id;
checkoutDraftSnapshot = serialized;
}
const plan = state.authorPlans().find(p => p.id === state.hostingPlan) || state.authorPlans()[0];
// The subscription option shows one cycle; Checkout must open that same cycle.
const plan = state.authorPlans().find(p => p.id === state.hostingPlan) || state.hostingSubscriptionPlan();
if (!addon && !plan) throw new Error("No author plan available");
await state.checkout(addon || plan.id, addon ? state.repoId : "", { draftId: checkoutDraftId });
} catch { state.billingError = "Your draft could not be saved for checkout. Your form is unchanged. Please try again."; }
@@ -1708,7 +1709,8 @@ export const anonymizeController = function (state, http, html, params, location
const now = new Date();
let maximum = new Date(now);
maximum.setFullYear(maximum.getFullYear() + 1);
if (state.billing?.enabled && !state.hasAuthorHosting()) {
// Gists and pull requests keep their one-year limit; billing only limits repositories.
if (state.billing?.enabled && state.detectedType === "repo" && !state.hasAuthorHosting()) {
maximum = new Date(now);
maximum.setMonth(maximum.getMonth() + (state.billing.freeExpirationMonths || 5));
for (const grant of state.repositoryGrants("repo_extend")) if (new Date(grant.until) > maximum) maximum = new Date(grant.until);
@@ -1723,7 +1725,7 @@ export const anonymizeController = function (state, http, html, params, location
state.maxExpirationDate = toLocalDateString(maximum);
};
updateExpirationLimit();
state.watch(() => [state.billing, state.repoId, state.conference_data, state.isUpdate, state._savedExpirationDate, state.options?.expirationMode], updateExpirationLimit, true, false);
state.watch(() => [state.billing, state.repoId, state.conference_data, state.isUpdate, state._savedExpirationDate, state.options?.expirationMode, state.detectedType], updateExpirationLimit, true, false);
state.anonymize_readme = "";
state.readme = "";
state.html_readme = "";
+2 -2
View File
@@ -1,4 +1,4 @@
"use strict";(()=>{var ya=Object.defineProperty;var ba=e=>{throw TypeError(e)};var ap=(e,t,o)=>t in e?ya(e,t,{enumerable:!0,configurable:!0,writable:!0,value:o}):e[t]=o;var lp=(e,t)=>{for(var o in t)ya(e,o,{get:t[o],enumerable:!0})};var We=(e,t,o)=>ap(e,typeof t!="symbol"?t+"":t,o),Ui=(e,t,o)=>t.has(e)||ba("Cannot "+o);var se=(e,t,o)=>(Ui(e,t,"read from private field"),o?o.call(e):t.get(e)),Ie=(e,t,o)=>t.has(e)?ba("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(e):t.set(e,o),qe=(e,t,o,i)=>(Ui(e,t,"write to private field"),i?i.call(e,o):t.set(e,o),o),Me=(e,t,o)=>(Ui(e,t,"access private method"),o);var wa=(e,t,o,i)=>({set _(s){qe(e,t,s,o)},get _(){return se(e,t,i)}});var ge=["abap","abc","actionscript","ada","alda","apache_conf","apex","applescript","aql","asciidoc","asl","assembly_x86","autohotkey","batchfile","c9search","c_cpp","cirru","clojure","cobol","coffee","coldfusion","crystal","csharp","csound_document","csound_orchestra","csound_score","csp","css","curly","d","dart","diff","django","dockerfile","dot","drools","edifact","eiffel","ejs","elixir","elm","erlang","forth","fortran","fsharp","fsl","ftl","gcode","gherkin","gitignore","glsl","gobstones","golang","graphqlschema","groovy","haml","handlebars","haskell","haskell_cabal","haxe","hjson","html","html_elixir","html_ruby","ini","io","jack","jade","java","javascript","json","json5","jsoniq","jsp","jssm","jsx","julia","kotlin","latex","less","liquid","lisp","livescript","logiql","logtalk","lsl","lua","luapage","lucene","makefile","markdown","mask","matlab","maze","mediawiki","mel","mixal","mushcode","mysql","nginx","nim","nix","nsis","nunjucks","objectivec","ocaml","pascal","perl","perl6","pgsql","php","php_laravel_blade","pig","plain_text","powershell","praat","prisma","prolog","properties","protobuf","puppet","python","qml","r","razor","rdoc","red","redshift","rhtml","rst","ruby","rust","sass","scad","scala","scheme","scss","sh","sjs","slim","smarty","snippets","soy_template","space","sparql","sql","sqlserver","stylus","svg","swift","tcl","terraform","tex","text","textile","toml","tsx","turtle","twig","typescript","vala","vbscript","velocity","verilog","vhdl","visualforce","wollok","xml","xquery","yaml","zeek"];var ve={markdown:"/script/markdown.6c8dfb7b1d.min.js",org:"/script/org.f61f262915.min.js",notebook:"/script/notebook.c3144c18ae.min.js",pdf:"/script/pdf.4d9cc6ff49.min.js",editor:"/script/editor.4f142d3adc.min.js"};function ka(e){let t=e.location.hash.slice(1);if(e.history.replaceState(null,"","/review-link"),t.length>2048)return null;let o=new URLSearchParams(t);if([...o.keys()].length!==3||[...o.keys()].some(a=>!["clientId","intentId","token"].includes(a)))return null;let i=o.get("clientId"),s=o.get("intentId"),r=o.get("token");return!/^[a-f0-9]{32}$/.test(i||"")||!/^[a-f0-9]{32}$/.test(s||"")||!/^[a-f0-9]{64}$/.test(r||"")?null:{contract:"4open.artifacts/1",clientId:i,intentId:s,token:r}}function Ca(e,t){let{http:o,promises:i,window:s}=t,r=t.takeReviewHandoff(),a=r?{clientId:r.clientId,intentId:r.intentId}:null,u,p,v,h,g,m=!0,k,T=i.defer(),w=()=>[...s.crypto.getRandomValues(new Uint8Array(16))].map(q=>q.toString(16).padStart(2,"0")).join("");Object.assign(e,{repositoryId:"",handoffEnabled:!1,busy:!1,preview:null,acceptAccess:!1,acceptRetention:!1,uncertain:!1,saved:!1,expired:!1,error:r?"":"Open a new artifact link from your submission. This link is missing or invalid."});let b=()=>m&&e.user?.username===g;function E(){m=!1,r=u=p=void 0,T.resolve(),s.clearTimeout(k)}e.on("dispose",E),e.on("routeLeave",E),e.watch(()=>e.user?.username,q=>{!g&&q?g=q:g&&q!==g&&(E(),e.preview=null,e.repositoryId="",e.busy=!1,e.saved=!1,e.handoffEnabled=!1,e.error="Your account changed. Open a new artifact link from your submission.")});let N=()=>({timeout:T.promise,headers:{"Content-Type":"application/json","X-Review-CSRF":p}}),O=q=>q?.status===404?"Artifact linking is not available on this server.":q?.status===401?"Sign in, then open a new artifact link from your submission.":q?.status===403?"This account cannot approve this repository or this link.":"The request could not be completed. You can retry from this page.";e.loadPreview=async()=>{if(!m||!r||e.busy||e.uncertain||e.saved||!e.user?.username)return;g=e.user.username;let q=e.repositoryId.trim();if(!/^[A-Za-z0-9_-]{3,128}$/.test(q)){e.error="Enter the anonymous repository ID from its URL.";return}e.busy=!0,e.error="",e.preview=null,u=void 0,e.acceptAccess=e.acceptRetention=!1,e.expired=!1,s.clearTimeout(k),h||(h=w());try{let M=(await o.get("/api/review-consent/csrf",{timeout:T.promise})).data;if(!b())return;if(p=M.csrf,e.handoffEnabled=M.handoffEnabled===!0,!/^[a-f0-9]{64}$/.test(p||""))throw new Error("invalid_csrf");let G=(await o.post("/api/review-consent/preview",{repositoryId:q,intent:{...r,requestId:h}},N())).data;if(!b())return;if(!G||typeof G.ticket!="string"||!G.ticket||G.ticket.length>8192||typeof G.repositoryName!="string"||!["restricted-review","anonymous-link"].iLine truncated
"use strict";(()=>{var ya=Object.defineProperty;var ba=e=>{throw TypeError(e)};var ap=(e,t,o)=>t in e?ya(e,t,{enumerable:!0,configurable:!0,writable:!0,value:o}):e[t]=o;var lp=(e,t)=>{for(var o in t)ya(e,o,{get:t[o],enumerable:!0})};var We=(e,t,o)=>ap(e,typeof t!="symbol"?t+"":t,o),Ui=(e,t,o)=>t.has(e)||ba("Cannot "+o);var se=(e,t,o)=>(Ui(e,t,"read from private field"),o?o.call(e):t.get(e)),Ie=(e,t,o)=>t.has(e)?ba("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(e):t.set(e,o),qe=(e,t,o,i)=>(Ui(e,t,"write to private field"),i?i.call(e,o):t.set(e,o),o),Me=(e,t,o)=>(Ui(e,t,"access private method"),o);var wa=(e,t,o,i)=>({set _(s){qe(e,t,s,o)},get _(){return se(e,t,i)}});var ge=["abap","abc","actionscript","ada","alda","apache_conf","apex","applescript","aql","asciidoc","asl","assembly_x86","autohotkey","batchfile","c9search","c_cpp","cirru","clojure","cobol","coffee","coldfusion","crystal","csharp","csound_document","csound_orchestra","csound_score","csp","css","curly","d","dart","diff","django","dockerfile","dot","drools","edifact","eiffel","ejs","elixir","elm","erlang","forth","fortran","fsharp","fsl","ftl","gcode","gherkin","gitignore","glsl","gobstones","golang","graphqlschema","groovy","haml","handlebars","haskell","haskell_cabal","haxe","hjson","html","html_elixir","html_ruby","ini","io","jack","jade","java","javascript","json","json5","jsoniq","jsp","jssm","jsx","julia","kotlin","latex","less","liquid","lisp","livescript","logiql","logtalk","lsl","lua","luapage","lucene","makefile","markdown","mask","matlab","maze","mediawiki","mel","mixal","mushcode","mysql","nginx","nim","nix","nsis","nunjucks","objectivec","ocaml","pascal","perl","perl6","pgsql","php","php_laravel_blade","pig","plain_text","powershell","praat","prisma","prolog","properties","protobuf","puppet","python","qml","r","razor","rdoc","red","redshift","rhtml","rst","ruby","rust","sass","scad","scala","scheme","scss","sh","sjs","slim","smarty","snippets","soy_template","space","sparql","sql","sqlserver","stylus","svg","swift","tcl","terraform","tex","text","textile","toml","tsx","turtle","twig","typescript","vala","vbscript","velocity","verilog","vhdl","visualforce","wollok","xml","xquery","yaml","zeek"];var ve={markdown:"/script/markdown.6c8dfb7b1d.min.js",notebook:"/script/notebook.c3144c18ae.min.js",org:"/script/org.f61f262915.min.js",editor:"/script/editor.4f142d3adc.min.js",pdf:"/script/pdf.4d9cc6ff49.min.js"};function ka(e){let t=e.location.hash.slice(1);if(e.history.replaceState(null,"","/review-link"),t.length>2048)return null;let o=new URLSearchParams(t);if([...o.keys()].length!==3||[...o.keys()].some(a=>!["clientId","intentId","token"].includes(a)))return null;let i=o.get("clientId"),s=o.get("intentId"),r=o.get("token");return!/^[a-f0-9]{32}$/.test(i||"")||!/^[a-f0-9]{32}$/.test(s||"")||!/^[a-f0-9]{64}$/.test(r||"")?null:{contract:"4open.artifacts/1",clientId:i,intentId:s,token:r}}function Ca(e,t){let{http:o,promises:i,window:s}=t,r=t.takeReviewHandoff(),a=r?{clientId:r.clientId,intentId:r.intentId}:null,u,p,v,h,g,m=!0,k,T=i.defer(),w=()=>[...s.crypto.getRandomValues(new Uint8Array(16))].map(q=>q.toString(16).padStart(2,"0")).join("");Object.assign(e,{repositoryId:"",handoffEnabled:!1,busy:!1,preview:null,acceptAccess:!1,acceptRetention:!1,uncertain:!1,saved:!1,expired:!1,error:r?"":"Open a new artifact link from your submission. This link is missing or invalid."});let b=()=>m&&e.user?.username===g;function E(){m=!1,r=u=p=void 0,T.resolve(),s.clearTimeout(k)}e.on("dispose",E),e.on("routeLeave",E),e.watch(()=>e.user?.username,q=>{!g&&q?g=q:g&&q!==g&&(E(),e.preview=null,e.repositoryId="",e.busy=!1,e.saved=!1,e.handoffEnabled=!1,e.error="Your account changed. Open a new artifact link from your submission.")});let N=()=>({timeout:T.promise,headers:{"Content-Type":"application/json","X-Review-CSRF":p}}),O=q=>q?.status===404?"Artifact linking is not available on this server.":q?.status===401?"Sign in, then open a new artifact link from your submission.":q?.status===403?"This account cannot approve this repository or this link.":"The request could not be completed. You can retry from this page.";e.loadPreview=async()=>{if(!m||!r||e.busy||e.uncertain||e.saved||!e.user?.username)return;g=e.user.username;let q=e.repositoryId.trim();if(!/^[A-Za-z0-9_-]{3,128}$/.test(q)){e.error="Enter the anonymous repository ID from its URL.";return}e.busy=!0,e.error="",e.preview=null,u=void 0,e.acceptAccess=e.acceptRetention=!1,e.expired=!1,s.clearTimeout(k),h||(h=w());try{let M=(await o.get("/api/review-consent/csrf",{timeout:T.promise})).data;if(!b())return;if(p=M.csrf,e.handoffEnabled=M.handoffEnabled===!0,!/^[a-f0-9]{64}$/.test(p||""))throw new Error("invalid_csrf");let G=(await o.post("/api/review-consent/preview",{repositoryId:q,intent:{...r,requestId:h}},N())).data;if(!b())return;if(!G||typeof G.ticket!="string"||!G.ticket||G.ticket.length>8192||typeof G.repositoryName!="string"||!["restricted-review","anonymous-link"].iLine truncated
`))),e[rn]=ko(s);let r=i||s.props&&s.props.type==="number";Nn(e,t?"change":"input",a=>{a.target.composing||e[rn](Nr(e.value,o,r))}),(o||r)&&Nn(e,"change",()=>{e.value=Nr(e.value,o,r)}),t||(Nn(e,"compositionstart",ph),Nn(e,"compositionend",ld),Nn(e,"change",ld))},mounted(e,{value:t,modifiers:{trim:o,number:i}}){let s=t??"",r=e[pi];delete e[pi],r!==void 0&&(e.type==="text"||e.type==="textarea")&&e.value!==r?e[rn](Nr(e.value,o,i)):e.value=s},beforeUpdate(e,{value:t,oldValue:o,modifiers:{lazy:i,trim:s,number:r}},a){if(e[rn]=ko(a),e.composing)return;let u=(r||e.type==="number")&&!/^0\d/.test(e.value)?fo(e.value):e.value,p=t??"";if(u===p)return;let v=e.getRootNode();(v instanceof Document||v instanceof ShadowRoot)&&v.activeElement===e&&e.type!=="range"&&(i&&t===o||s&&e.value.trim()===p)||(e.value=p)}},Dr={deep:!0,created(e,t,o){e[rn]=ko(o),Nn(e,"change",()=>{let i=e._modelValue,s=ms(e),r=e.checked,a=e[rn];if(Se(i)){let u=Fs(i,s),p=u!==-1;if(r&&!p)a(i.concat(s));else if(!r&&p){let v=[...i];v.splice(u,1),a(v)}}else if(Ut(i)){let u=new Set(i);r?u.add(s):u.delete(s),a(u)}else a(hd(e,r))})},mounted:dd,beforeUpdate(e,t,o){e[rn]=ko(o),dd(e,t,o)}};function dd(e,{value:t,oldValue:o},i){e._modelValue=t;let s;if(Se(t))s=Fs(t,i.props.value)>-1;else if(Ut(t))s=t.has(i.props.value);else{if(t===o)return;s=Ft(t,hd(e,!0))}e.checked!==s&&(e.checked=s)}var gn={deep:!0,created(e,{value:t,modifiers:{number:o}},i){e._modelValue=t,Nn(e,"change",()=>{let s=Array.prototype.filter.call(e.options,p=>p.selected).map(p=>o?fo(ms(p)):ms(p)),r=e.multiple,a=r?Ut(e._modelValue)?new Set(s):s:s[0],u=e._pendingValue=[r,r?Se(a)?s.slice():s:a];try{e[rn](a)}finally{nt(()=>{e._pendingValue===u&&(e._pendingValue=void 0)})}}),e[rn]=ko(i)},mounted(e,{value:t}){ud(e,t)},beforeUpdate(e,{value:t},o){e._modelValue=t,e[rn]=ko(o)},updated(e,{value:t}){let o=e._pendingValue;e._pendingValue=void 0,(!o||o[0]!==e.multiple||!fh(t,o[1],o[0]))&&ud(e,t)}};function fh(e,t,o){if(!o)return Ft(e,t);if(Se(e))return Ft(e,t);if(Ut(e)){if(e.size!==t.length)return!1;for(let i of t)if(!e.has(i))return!1;return!0}return!1}function ud(e,t){let o=e.multiple,i=Se(t);if(!(o&&!i&&!Ut(t))){for(let s=0,r=e.options.length;s<r;s++){let a=e.options[s],u=ms(a);if(o)if(i){let p=typeof u;p==="string"||p==="number"?a.selected=t.some(v=>String(v)===String(u)):a.selected=Fs(t,u)>-1}else a.selected=t.has(u);else if(Ft(ms(a),t)){e.selectedIndex!==s&&(e.selectedIndex=s);return}}!o&&e.selectedIndex!==-1&&(e.selectedIndex=-1)}}function ms(e){return"_value"in e?e._value:e.value}function hd(e,t){let o=t?"_trueValue":"_falseValue";return o in e?e[o]:t}var hh=["ctrl","shift","alt","meta"],mh={stop:e=>e.stopPropagation(),prevent:e=>e.preventDefault(),self:e=>e.target!==e.currentTarget,ctrl:e=>!e.ctrlKey,shift:e=>!e.shiftKey,alt:e=>!e.altKey,meta:e=>!e.metaKey,left:e=>"button"in e&&e.button!==0,middle:e=>"button"in e&&e.button!==1,right:e=>"button"in e&&e.button!==2,exact:(e,t)=>hh.some(o=>e[`${o}Key`]&&!t.includes(o))},ce=(e,t)=>{if(!e)return e;let o=e._withMods||(e._withMods={}),i=t.join(".");return o[i]||(o[i]=((s,...r)=>{for(let a=0;a<t.length;a++){let u=mh[t[a]];if(u&&u(s,t))return}return e(s,...r)}))},gh={esc:"escape",space:" ",up:"arrow-up",left:"arrow-left",right:"arrow-right",down:"arrow-down",delete:"backspace"},At=(e,t)=>{let o=e._withKeys||(e._withKeys={}),i=t.join(".");return o[i]||(o[i]=(s=>{if(!("key"in s))return;let r=Xt(s.key);if(t.some(a=>a===r||gh[a]===r))return e(s)}))},vh=it({patchProp:dh},Kf),cd;function yh(){return cd||(cd=jl(vh))}var md=((...e)=>{let t=yh().createApp(...e),{mount:o}=t;return t.mount=i=>{let s=wh(i);if(!s)return;let r=t._component;!Te(r)&&!r.render&&!r.template&&(r.template=s.innerHTML),s.nodeType===1&&(s.textContent="");let a=o(s,!1,bh(s));return s instanceof Element&&(s.removeAttribute("v-cloak"),s.setAttribute("data-v-app","")),a},t});function bh(e){if(e instanceof SVGElement)return"svg";if(typeof MathMLElement=="function"&&e instanceof MathMLElement)return"mathml"}function wh(e){return xe(e)?document.querySelector(e):e}var Xn=typeof document<"u";function vd(e){return typeof e=="object"||"displayName"in e||"props"in e||"__vccOpts"in e}function kh(e){return e.__esModule||e[Symbol.toStringTag]==="Module"||e.default&&vd(e.default)}var je=Object.assign;function hi(e,t){let o={};for(let i in t){let s=t[i];o[i]=Lt(s)?s.map(e):e(s)}return o}var Eo=()=>{},Lt=Array.isArray;function Pr(e,t){let o={};for(let i in e)o[i]=i in t?t[i]:e[i];return o}var yd=/#/g,Ch=/&/g,Eh=/\//g,Sh=/=/g,Nh=/\?/g,bd=/\+/g,Rh=/%5B/g,Dh=/%5D/g,wd=/%5E/g,Ah=/%60/g,kd=/%7B/g,Th=/%7C/g,Cd=/%7D/g,Ph=/%20/g;function _r(e){return e==null?"":encodeURI(""+e).replace(Th,"|").replace(Rh,"[").replace(Dh,"]")}function Ed(e){return _r(e).replace(kd,"{").replace(Cd,"}").replace(wd,"^")}function Ar(e){return _r(e).replace(bd,"%2B").replace(Ph,"+").replace(yd,"%23").replace(Ch,"%26").replace(Ah,"`").replace(kd,"{").replace(Cd,"}").replace(wd,"^")}function _h(e){return Ar(e).replace(Sh,"%3D")}function Oh(e){return _Line truncated
${JSON.stringify(e)}${t?`
while being at
@@ -11,7 +11,7 @@ while being at
`).map(k=>k.trim()).filter(k=>k.length>0),options:e.options};e.saving=!0,e.error=null,t.post("/api/user/default",m).then(()=>{u.accept(g),e.saving=!1,e.message=e.unsavedChanges?null:"Saved",v&&i.cancel(v),v=i(()=>{e.message=null},2500)},k=>{e.saving=!1;let T=k&&k.data&&k.data.error,w="Unable to save your defaults. Please try again.";Promise.all([o("ERRORS."+T),o("ERRORS.unknown_error")]).then(([b,E])=>{e.error=T&&b!==E?b:w},()=>{e.error=w})})},e.deleteAccount=()=>{e.deletingAccount||!e.confirmDeleteAccount||e.deleteUsername!==e.user?.username||(e.deletingAccount=!0,t.delete("/api/user").then(()=>{window.location.href="/"},()=>{e.deletingAccount=!1,e.deleteError="Unable to delete the account. Please try again."}))}},Gu=function(e,t,o){e.repoId=null,e.repoUrl=null,e.claim=()=>{t.post("/api/repo/claim",{repoId:e.repoId,repoUrl:e.repoUrl}).then(i=>{o.url("/dashboard")},i=>{e.error=i.data,e.claimForm.repoUrl.setValidity("not_found",!1),e.claimForm.repoId.setValidity("not_found",!1)})}},Wu=function(e,t,o,i,s){e.user&&!e.user.status&&o.url("/dashboard"),e.watch("user.status",()=>{e.user&&!e.user.status&&o.url("/dashboard")}),e.features=[{key:"anonymize",num:"01",eyebrow:"Anonymize",title:"Double-anonymous,",accent:"your rules.",text:"Choose what reviewers may see: links, images, PDFs, notebooks, GitHub Pages. Add your own terms, with regex if you need it, and pick the expiration date.",cta:"Start an anonymization",href:"/anonymize",url:"anonymous.4open.science/anonymize",img:"/imgs/anonymize.png",alt:"The anonymize form: source repository, terms to redact, options and a live README preview"},{key:"review",num:"02",eyebrow:"Review",title:"Reviewers browse",accent:"the real thing.",text:"Highlighted source code, rendered PDFs, images, and notebooks, in a familiar file explorer. GitHub Pages is also supported.",cta:"Open the example",href:"https://anonymous.4open.science/r/840c8c57-3c32-451e-bf12-0e20be300389/",target:"_self",url:"anonymous.4open.science/r/840c8c57-\u2026",img:"/imgs/explorer.png",alt:"The repository explorer with a file tree and a rendered README"},{key:"manage",num:"03",eyebrow:"Manage",title:"One dashboard,",accent:"until the decision.",text:"Monitor views, edit configuration, remove or update your repository. Program chairs can group submissions under a conference with one shared expiry.",cta:"Open the dashboard",href:"/dashboard",needsUser:!0,url:"anonymous.4open.science/dashboard",img:"/imgs/dashboard.png",alt:"The dashboard listing anonymized repositories with status, views and expiry"}],e.feature=e.features[0].key,e.selectFeature=function(u){e.feature=u},e.featureHref=function(u){return u.needsUser&&!e.user?"/signin":u.href},e.featureTarget=function(u){return u.needsUser&&!e.user?"_self":u.target||void 0},e.featureKeydown=function(u,p){let v={ArrowDown:1,ArrowRight:1,ArrowUp:-1,ArrowLeft:-1,Home:"first",End:"last"}[u.key];if(v===void 0)return;u.preventDefault();let h=e.features.length,g=v==="first"?0:v==="last"?h-1:(p+v+h)%h;e.feature=e.features[g].key,s(()=>{let m=i.document.getElementById("feature-tab-"+e.feature);m&&m.focus()})},e.demoText=`# DeepLearnUtils
Developed by Jane Smith at MIT CSAIL.
See Smith et al., "Attention That Works," 2026.
Contact: jane.smith@mit.edu`,e.demoTerms="Jane Smith, Smith, MIT CSAIL, jane.smith@mit.edu",e.demoParts=function(){let u=(e.demoTerms||"").split(/[,\n]/).map(m=>m.trim()).filter(Boolean).sort((m,k)=>k.length-m.length),p=m=>m.replace(/[.*+?^${}()|[\]\\]/g,"\\$&");if(!u.length)return[{text:e.demoText}];let v=new RegExp("("+u.map(p).join("|")+")","gi"),h={},g=0;return(e.demoText||"").split(v).filter(m=>m!=="").map(m=>{let k=u.find(T=>T.toLowerCase()===m.toLowerCase());return k?(h[k.toLowerCase()]||(h[k.toLowerCase()]=++g),{text:String(h[k.toLowerCase()]),redacted:!0,original:m}):{text:m}})},e.demoCount=()=>e.demoParts().filter(u=>u.redacted).length,e.cards=[{key:"repositories",total:0,label:"repositories anonymized"},{key:"users",total:0,label:"researchers"},{key:"pageViews",total:0,label:"page views"},{key:"pullRequests",total:0,label:"pull requests"}];function r(){t.get("/api/stat/").then(u=>{e.stat=u.data,e.cards[0].total=u.data.nbRepositories,e.cards[1].total=u.data.nbUsers,e.cards[2].total=u.data.nbPageViews,e.cards[3].total=u.data.nbPullRequests}).catch(()=>{})}r();function a(u){let p={series:u,bars:[],viewW:100,deltaToday:0,pctChange:0,pctAbs:0,isUp:!0};if(!u||u.length<2)return p;let v=new Array(u.length-1);for(let w=1;w<u.length;w++)v[w-1]=u[w]-u[w-1];let h=v.length,g=Math.max.apply(null,v),m=Math.min.apply(null,v),k=Math.min(0,m),T=g-k||1;p.viewW=h*2,p.bars=new Array(h);for(let w=0;w<h;w++){let b=(v[w]-k)/T,E=Math.max(1.5,b*34);p.bars[w]={x:(w*2+.25).toFixed(2),y:(36-E).toFixed(2),w:"1.5",h:E.toFixed(2)}}if(p.deltaToday=v[h-1],h>=2){let w=v[h-2];w&&(p.pctChange=(p.deltaToday-w)/w*100)}return p.pctAbs=Math.round(Math.abs(p.pctChange)),p.isUp=p.pctChange>=0,p}e.history={repositories:a([]),users:a([]),pageViews:a([]),pullRequests:a([])},t.get("/api/stat/history?days=60").then(u=>{let p=u.data||[];e.history={repositories:a(p.map(v=>v.nbRepositories||0)),users:a(p.map(v=>v.nbUsers||0)),pageViews:a(p.map(v=>v.nbPageViews||0)),pullRequests:a(p.map(v=>v.nbPullRequests||0))}}).catch(()=>{})},Ku=function(e,t,o,i,s,r){let a=Tt();e.on("routeLeave",function(){$('[data-toggle="tooltip"]').tooltip("dispose")}),e.watch("user.status",()=>{e.user==null&&o.url("/")}),e.user==null&&o.url("/"),a.timeout(()=>{$('[data-toggle="tooltip"]').tooltip()},250),e.quotaExpanded=!1,e.closeQuota=()=>{e.quotaExpanded=!1,nt(()=>document.querySelector(".dashboard-quota > summary")?.focus())},bn()(document,"click",D=>{e.quotaExpanded&&!D.target.closest?.(".dashboard-quota")&&(e.quotaExpanded=!1)}),e.filtersExpanded=!1,e.projectView="all",e.selectedProject=null,e.projectName="",e.nameSaving=!1,e.nameError="",e.nameMessage="",e.needsAttention=D=>D.status==="error"||D._broken||p(D.status)==="progress"&&D._stale,e.attentionCount=()=>e.dashboardTotals?.attention??e.items.filter(e.needsAttention).length,e.hasProjects=()=>(e.dashboardTotals?.total??e.items.length)>0,e.setProjectView=D=>{e.projectView=D,D==="attention"&&(e.filters.status.error=!0,e.filters.status.progress=!0)},e.projectDetailsId=D=>"project-details-"+encodeURIComponent(D._type+":"+(D._id||D._source||"unknown")),e.showProjectDetails=async D=>{e.selectedProject=D,e.projectName=D.projectName||"",e.nameError="",e.nameMessage="",await nt();let X=document.getElementById(e.projectDetailsId(D));X?.focus(),X?.scrollIntoView?.({block:"nearest"})},e.closeProjectDetails=D=>{e.selectedProject=null,nt(()=>document.querySelector(`[data-project-actions="${encodeURIComponent(D._type+":"+D._id)}"]`)?.focus())},e.saveProjectName=async D=>{if(e.nameSaving||!D._id)return;let X=e.projectName.trim();if(X.length>100){e.nameError="Use 100 characters or fewer.";return}e.nameSaving=!0,e.nameError="",e.nameMessage="";try{let le=await t.post("/api/user/project-name",{type:D._type,id:D._id,name:X});D.projectName=le.data.name,D._label=D.projectName||D._fallbackLabel,e.selectedProject===D&&(e.nameMessage=X?"Project name saved.":"Default project name restored."),N||(e.dashboardCursor=null,await q(),e.selectedProject===D&&(e.selectedProject=e.items.find(Ee=>Ee._type===D._type&&Ee._id===D._id)||null))}catch{e.selectedProject===D&&(e.nameError="The project name could not be saved. Try again.")}finally{e.nameSaving=!1}},e.copyingId=null,e.copyLink=async D=>{if(!D._viewUrl||e.copyingId)return;let X=new URL(D._viewUrl,s.location.origin).href;e.copyingId=D._type+":"+D._id;try{await Bu(X),e.addToast({title:"Link copied",body:"The anonymous link is ready to share."})}catch{e.addToast({title:"Copy this anonymous link",body:X})}finally{e.copyingId=null}},e.items=[],e.search="",e.loading=!0,e.statusKeyLabels={ready:"Ready",progress:"In progress",error:"Error",expired:"Expired",removed:"Removed"};let u=["queue","download","downloaded","preparing","anonymizing"];function p(D){return D==="ready"||D==="error"?D:D==="expired"||D==="expiring"?"expired":D==="removed"||D==="removing"?"removed":(u.indexOf(D)>-1,"progress")}let v=7200*1e3,h="dashboard.filterPrefs",g={typeFilter:"all",filters:{status:{ready:!0,progress:!0,error:!0,e Line truncated
Contact: jane.smith@mit.edu`,e.demoTerms="Jane Smith, Smith, MIT CSAIL, jane.smith@mit.edu",e.demoParts=function(){let u=(e.demoTerms||"").split(/[,\n]/).map(m=>m.trim()).filter(Boolean).sort((m,k)=>k.length-m.length),p=m=>m.replace(/[.*+?^${}()|[\]\\]/g,"\\$&");if(!u.length)return[{text:e.demoText}];let v=new RegExp("("+u.map(p).join("|")+")","gi"),h={},g=0;return(e.demoText||"").split(v).filter(m=>m!=="").map(m=>{let k=u.find(T=>T.toLowerCase()===m.toLowerCase());return k?(h[k.toLowerCase()]||(h[k.toLowerCase()]=++g),{text:String(h[k.toLowerCase()]),redacted:!0,original:m}):{text:m}})},e.demoCount=()=>e.demoParts().filter(u=>u.redacted).length,e.cards=[{key:"repositories",total:0,label:"repositories anonymized"},{key:"users",total:0,label:"researchers"},{key:"pageViews",total:0,label:"page views"},{key:"pullRequests",total:0,label:"pull requests"}];function r(){t.get("/api/stat/").then(u=>{e.stat=u.data,e.cards[0].total=u.data.nbRepositories,e.cards[1].total=u.data.nbUsers,e.cards[2].total=u.data.nbPageViews,e.cards[3].total=u.data.nbPullRequests}).catch(()=>{})}r();function a(u){let p={series:u,bars:[],viewW:100,deltaToday:0,pctChange:0,pctAbs:0,isUp:!0};if(!u||u.length<2)return p;let v=new Array(u.length-1);for(let w=1;w<u.length;w++)v[w-1]=u[w]-u[w-1];let h=v.length,g=Math.max.apply(null,v),m=Math.min.apply(null,v),k=Math.min(0,m),T=g-k||1;p.viewW=h*2,p.bars=new Array(h);for(let w=0;w<h;w++){let b=(v[w]-k)/T,E=Math.max(1.5,b*34);p.bars[w]={x:(w*2+.25).toFixed(2),y:(36-E).toFixed(2),w:"1.5",h:E.toFixed(2)}}if(p.deltaToday=v[h-1],h>=2){let w=v[h-2];w&&(p.pctChange=(p.deltaToday-w)/w*100)}return p.pctAbs=Math.round(Math.abs(p.pctChange)),p.isUp=p.pctChange>=0,p}e.history={repositories:a([]),users:a([]),pageViews:a([]),pullRequests:a([])},t.get("/api/stat/history?days=60").then(u=>{let p=u.data||[];e.history={repositories:a(p.map(v=>v.nbRepositories||0)),users:a(p.map(v=>v.nbUsers||0)),pageViews:a(p.map(v=>v.nbPageViews||0)),pullRequests:a(p.map(v=>v.nbPullRequests||0))}}).catch(()=>{})},Ku=function(e,t,o,i,s,r){let a=Tt();e.on("routeLeave",function(){$('[data-toggle="tooltip"]').tooltip("dispose")}),e.watch("user.status",()=>{e.user==null&&o.url("/")}),e.user==null&&o.url("/"),a.timeout(()=>{$('[data-toggle="tooltip"]').tooltip()},250),e.quotaExpanded=!1,e.closeQuota=()=>{e.quotaExpanded=!1,nt(()=>document.querySelector(".dashboard-quota > summary")?.focus())},bn()(document,"click",D=>{e.quotaExpanded&&!D.target.closest?.(".dashboard-quota")&&(e.quotaExpanded=!1)}),e.filtersExpanded=!1,e.projectView="all",e.selectedProject=null,e.projectName="",e.nameSaving=!1,e.nameError="",e.nameMessage="",e.needsAttention=D=>D.status==="error"||D._broken||p(D.status)==="progress"&&D._stale,e.attentionCount=()=>e.dashboardTotals?.attention??e.items.filter(e.needsAttention).length,e.hasProjects=()=>(e.dashboardTotals?.total??e.items.length)>0,e.setProjectView=D=>{e.projectView=D,D==="attention"&&(e.filters.status.error=!0,e.filters.status.progress=!0)},e.projectDetailsId=D=>"project-details-"+encodeURIComponent(D._type+":"+(D._id||D._source||"unknown")),e.showProjectDetails=async D=>{e.selectedProject=D,e.projectName=D.projectName||"",e.nameError="",e.nameMessage="",await nt();let X=document.getElementById(e.projectDetailsId(D));X?.focus(),X?.scrollIntoView?.({block:"nearest"})},e.closeProjectDetails=D=>{e.selectedProject=null,nt(()=>document.querySelector(`[data-project-actions="${encodeURIComponent(D._type+":"+D._id)}"]`)?.focus())},e.saveProjectName=async D=>{if(e.nameSaving||!D._id)return;let X=e.projectName.trim();if(X.length>100){e.nameError="Use 100 characters or fewer.";return}e.nameSaving=!0,e.nameError="",e.nameMessage="";try{let le=await t.post("/api/user/project-name",{type:D._type,id:D._id,name:X});D.projectName=le.data.name,D._label=D.projectName||D._fallbackLabel,e.selectedProject===D&&(e.nameMessage=X?"Project name saved.":"Default project name restored."),N||(e.dashboardCursor=null,await q(),e.selectedProject===D&&(e.selectedProject=e.items.find(Ee=>Ee._type===D._type&&Ee._id===D._id)||null))}catch{e.selectedProject===D&&(e.nameError="The project name could not be saved. Try again.")}finally{e.nameSaving=!1}},e.copyingId=null,e.copyLink=async D=>{if(!D._viewUrl||e.copyingId)return;let X=new URL(D._viewUrl,s.location.origin).href;e.copyingId=D._type+":"+D._id;try{await Bu(X),e.addToast({title:"Link copied",body:"The anonymous link is ready to share."})}catch{e.addToast({title:"Copy this anonymous link",body:X})}finally{e.copyingId=null}},e.items=[],e.search="",e.loading=!0,e.statusKeyLabels={ready:"Ready",progress:"In progress",error:"Error",expired:"Expired",removed:"Removed"};let u=["queue","download","downloaded","preparing","anonymizing"];function p(D){return D==="ready"||D==="error"?D:D==="expired"||D==="expiring"?"expired":D==="removed"||D==="removing"?"removed":(u.indexOf(D)>-1,"progress")}let v=7200*1e3,h="dashboard.filterPrefs",g={typeFilter:"all",filters:{status:{ready:!0,progress:!0,error:!0,e Line truncated
`)),e.options=Object.assign({},e.options,F.options),e.options.expirationDate=F.options&&F.options.expirationDate?new Date(F.options.expirationDate):G(),!i.repoId&&!i.pullRequestId&&!i.gistId&&!e.anonymize?.terms?.dirty&&(e.terms=e.defaultTerms),S&&S()})}function ae(S,_,F){e.anonymize&&e.anonymize[S]&&e.anonymize[S].setValidity(_,F)}function he(S){try{let _=parseGithubUrl(S);if(_&&_.owner&&_.repo)return _.owner+"/"+_.repo}catch{}return null}e.accessRepairAllowed=!1,e.repairingGitHubAccess=!1,e.accessRepairMessage="",e.repairGitHubAccess=async()=>{if(!e.accessRepairAllowed||e.repairingGitHubAccess||!e.githubConnections?.appEnabled||!i.repoId||e.role!=="owner"||e.githubConnection!=="github-app"||he(e.sourceUrl)!==e._originalFullName)return;e.repairingGitHubAccess=!0,e.accessRepairMessage="";let S=m();try{let _=await t.get("/github/connections");if(e.githubConnections=_.data,!_.data.appEnabled)return;let F=await t.post("/github/connections/migrate",{type:"repository",id:i.repoId,connection:"github-app"},{headers:{"X-CSRF-Token":_.data.csrf}});if(!g(S))return;e.error=null,F.data.fullName&&(e._originalFullName=F.data.fullName,e.source.fullName=F.data.fullName,e.sourceUrl="https://github.com/"+F.data.fullName),e.accessRepairMessage="GitHub access repaired. Your anonymous URL and published commit are unchanged.",await e.urlSelected(!0)}catch{g(S)&&(e.accessRepairMessage="Unable to repair GitHub access. Check the App's repository permissions in Manage access, then retry. Your published snapshot has not been replaced.")}finally{e.repairingGitHubAccess=!1,k(S)}},e.reconnectingSource=!1,e.reconnectRepositoryId=void 0,e.reconnectSource=async()=>{if(confirm("Reconnect to the repository currently at this GitHub URL? Review its branch and commit before saving. Saving will replace the published snapshot.")){e.reconnectingSource=!0,e.reconnectRepositoryId=void 0,e.source.commit="",e.source.branch="",e.readme="",e.branches=[];try{await Re()}catch{}}};function Ce(){return e.reconnectingSource||!e.isUpdate||!e._originalRepositoryID?void 0:he(e.sourceUrl)===e._originalFullName?e._originalRepositoryID:void 0}async function Pe(){e._preservingDraft=!0;try{await e.urlSelected(!0)}finally{e._preservingDraft=!1}}async function Ne(){let S=new URLSearchParams(window.location.search).get("billingDraft");if(S){b.restore();try{let F=(await t.get("/api/billing/draft/"+encodeURIComponent(S))).data;if(F.path!==s.path())throw new Error("Wrong task");E=F.values.conference||null;for(let Q of["sourceUrl","terms","repoId","source","options","conference","githubConnection"])Object.prototype.hasOwnProperty.call(F.values,Q)&&(e[Q]=F.values[Q]);return e.options?.expirationDate&&(e.options.expirationDate=new Date(e.options.expirationDate)),N=F.id,O=JSON.stringify(b.checkpoint()),e.draftRestored=!0,e.hostingReview=!0,e.sourceUrl&&await Pe(),s.replace?.(s.path()),!0}catch{e.billingError="The checkout draft could not be restored. Retry by reloading this page; any local draft is still available."}}let _;try{_=JSON.parse(sessionStorage.getItem("github-access-draft")||"null")}catch{return!1}if(!_||_.path!==s.path()||Date.now()-_.savedAt>=30*6e4)return b.restore()?(e.sourceUrl&&await Pe(),!0):!1;b.restore();for(let F of["sourceUrl","terms","repoId","pullRequestId","gistId","source","options","conference","githubConnection"])Object.prototype.hasOwnProperty.call(_.draft,F)&&(e[F]=_.draft[F]);return e.options.expirationDate&&(e.options.expirationDate=new Date(e.options.expirationDate)),await Pe(),sessionStorage.removeItem("github-access-draft"),!0}Ee(()=>{!i.repoId&&!i.pullRequestId&&!i.gistId&&a(Ne,0),i.repoId&&i.repoId!=""&&(e.isUpdate=!0,e.detectedType="repo",e.repoId=i.repoId,t.get("/api/repo/"+e.repoId).then(async S=>{e.accessRepairAllowed=!["removed","archived","preparing","queue","download","removing","expiring"].includes(S.data.status),e.githubConnection=S.data.connection||"oauth",e.sourceUrl="https://github.com/"+S.data.source.fullName,e._originalFullName=S.data.source.fullName,e.terms=S.data.options.terms.filter(_=>_).join(`
`),e.source=S.data.source,e.role=S.data.role||"owner",e._existingNever=S.data.options.expirationMode==="never",e._savedExpirationDate=S.data.options.expirationDate?new Date(S.data.options.expirationDate):null,e.coauthors=S.data.coauthors||[],e._originalBranch=S.data.source.branch,e.options=Object.assign({},e.options,S.data.options),e.conference=S.data.conference,e._originalConference=S.data.conference,e.repositoryID=S.data.source.repositoryID,e._originalRepositoryID=S.data.source.repositoryID,S.data.options.expirationDate&&(e.options.expirationDate=new Date(S.data.options.expirationDate)),T=w(parseGithubUrl(e.sourceUrl)),!await Ne()&&(await Promise.all([Re(),Qe()]),j())},()=>{s.url("/404")})),i.pullRequestId&&i.pullRequestId!=""&&(e.isUpdate=!0,e.detectedType="pr",e.pullRequestId=i.pullRequestId,t.get("/api/pr/"+e.pullRequestId).then(async S=>{if(e.githubConnection=S.data.connection||"oauth",e.sourceUrl="https://github.com/"+S.data.source.repositoryFullName+"/pull/"+S.data.source.pullRequestId,e.terms=S.data.options.terms.filter(_=>_).join(`
`),e.source=S.data.source,e.options=Object.assign({},e.options,S.data.options),e.conference=S.data.conference,e._originalConference=S.data.conference,S.data.options.expirationDate&&(e.options.expirationDate=new Date(S.data.options.expirationDate)),T=w(parseGithubUrl(e.sourceUrl)),!await Ne())try{await ee()}catch{}},()=>{s.url("/404")})),i.gistId&&i.gistId!=""&&(e.isUpdate=!0,e.detectedType="gist",e.gistId=i.gistId,t.get("/api/gist/"+e.gistId).then(async S=>{e.sourceUrl="https://gist.github.com/"+S.data.source.gistId,e.terms=S.data.options.terms.filter(_=>_).join(`
+6
View File
@@ -14,6 +14,7 @@ interface Config {
STRIPE_CONFERENCE_PRICE: string;
STRIPE_CONFERENCE_SMALL_PRICE: string;
STRIPE_CONFERENCE_LARGE_PRICE: string;
STRIPE_LEGACY_PRICES: string;
STRIPE_REPO_EXTEND_PRICE: string;
STRIPE_REPO_NEVER_PRICE: string;
STRIPE_REPO_SLOT_PRICE: string;
@@ -125,6 +126,8 @@ const config: Config = {
STRIPE_CONFERENCE_PRICE: "",
STRIPE_CONFERENCE_SMALL_PRICE: "",
STRIPE_CONFERENCE_LARGE_PRICE: "",
// Retired price IDs that existing purchases still use: "price_old:author_annual,price_x:repo_pack".
STRIPE_LEGACY_PRICES: "",
STRIPE_REPO_EXTEND_PRICE: "",
STRIPE_REPO_NEVER_PRICE: "",
STRIPE_REPO_SLOT_PRICE: "",
@@ -296,4 +299,7 @@ if (config.BILLING_ENABLED) {
if (!Number.isSafeInteger(config[key]) || config[key] <= 0) throw new Error(`${key} must be a positive integer`);
}
if (!config.STRIPE_SECRET_KEY || !config.STRIPE_WEBHOOK_SECRET) throw new Error("Billing requires STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET");
for (const entry of config.STRIPE_LEGACY_PRICES.split(",").map(e => e.trim()).filter(Boolean)) {
if (!/^price_[A-Za-z0-9]+:[a-z_]+$/.test(entry)) throw new Error(`Invalid STRIPE_LEGACY_PRICES entry: ${entry}`);
}
}
+3 -3
View File
@@ -1,6 +1,6 @@
import { REPOSITORY_ID_COLLATION } from "./repository-id";
import { allowance, reserveRepository } from "./billing/quotas";
import { MANUAL_REFRESH_PLANS } from "./billing/config";
import { reserveRepository } from "./billing/quotas";
import { hasAutomaticUpdates } from "./billing/expiration";
import { FILE_TREE_BATCH_SIZE, FILE_LISTING_LIMIT, deleteRowsInBatches } from "./file-tree";
import { SourceBase, sourceStorageSize } from "./source/Source";
import { notifyOwnerAccessProblem, withOwnerAccessRecovery } from "./owner-notifications";
@@ -725,7 +725,7 @@ export default class Repository {
// Reviewer visits must not trigger an unaffordable refresh after paid access ends.
// Free managed repositories can still be refreshed explicitly by their owner.
if (config.BILLING_ENABLED && this.model.billingManaged && !opt?.force &&
MANUAL_REFRESH_PLANS.includes((await allowance(String(this.model.owner), this.repoId, this.model.conference)).plan)) return;
!(await hasAutomaticUpdates(String(this.model.owner), this.repoId, this.model.conference))) return;
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
if (
+12
View File
@@ -30,6 +30,18 @@ export function billingPlans() {
{ id: "conference_large" as PlanId, name: "Large conference", priceId: config.STRIPE_CONFERENCE_LARGE_PRICE, recurring: false, capacity: true, repositories: config.PAID_CONFERENCE_LARGE_REPOSITORIES, storage: config.PAID_CONFERENCE_LARGE_STORAGE, description: "For a large venue with several tracks. A review window of up to 180 days. No automatic renewal." },
];
}
// Retired Stripe prices keep their plan, so existing subscriptions and
// purchases stay recognized after a price ID is replaced.
export function legacyPricePlans() {
const plans = new Map<string, PlanId>();
const ids = new Set(billingPlans().map(p => p.id as string));
for (const entry of config.STRIPE_LEGACY_PRICES.split(",").map(e => e.trim()).filter(Boolean)) {
const [price, plan] = entry.split(":").map(part => part.trim());
if (!price || !plan || !ids.has(plan)) throw new Error(`Invalid STRIPE_LEGACY_PRICES entry: ${entry}`);
plans.set(price, plan as PlanId);
}
return plans;
}
export function billingError(code: string, status = 400) { return new AnonymousError(code, { httpStatus: status }); }
export function billingOrigin() {
const url = new URL(config.BILLING_ORIGIN);
+15 -3
View File
@@ -2,8 +2,8 @@ import { reconcileBillingAccount } from "./reconciliation";
import { createLogger } from "../logger";
import config from "../../config";
import { BillingGrant } from "./models";
import { billingError } from "./config";
import { usableGrant } from "./quotas";
import { billingError, MANUAL_REFRESH_PLANS } from "./config";
import { allowance, usableGrant } from "./quotas";
import Repo from "../model/anonymizedRepositories/anonymizedRepositories.model";
import ConferenceModel from "../model/conference/conferences.model";
@@ -19,6 +19,13 @@ async function hasRepositoryGrant(owner: string, repoId: string, planId: string,
return grants.filter(g => usableGrant(g, now));
}
// Automatic updates come from the plan that funds the repository, or from the
// owner's author subscription even when a paid slot funds its storage.
export async function hasAutomaticUpdates(owner: string, repoId: string, conference?: string, now = new Date()) {
if (!MANUAL_REFRESH_PLANS.includes((await allowance(owner, repoId, conference)).plan)) return true;
return (await authorGrants(owner)).some(g => usableGrant(g, now));
}
// This is an author benefit, independent of which scope pays the storage quota.
// A repository's own Never expire add-on also qualifies.
export async function neverExpirationDeadline(owner: string, existing?: Date, repoId?: string) {
@@ -81,7 +88,7 @@ export async function reconcileNeverExpiration(now = new Date(), reconcile = rec
}
const previous = repo.billingNeverExpiresUntil!;
// Roll the add-on deadline forward weekly rather than on every run.
const rolling = addons.has(`${owner} repo:${repo.repoId}`) && previous.getTime() < now.getTime() + NEVER_EXPIRATION_GRACE_MS - ROLLING_REFRESH_MS
let rolling = addons.has(`${owner} repo:${repo.repoId}`) && previous.getTime() < now.getTime() + NEVER_EXPIRATION_GRACE_MS - ROLLING_REFRESH_MS
? now.getTime() + NEVER_EXPIRATION_GRACE_MS : 0;
let deadline = new Date(Math.max(previous.getTime(), deadlines.get(owner)!, rolling));
if (deadline <= now) {
@@ -92,7 +99,12 @@ export async function reconcileNeverExpiration(now = new Date(), reconcile = rec
verified.add(owner);
const fresh = await authorGrants(owner);
deadlines.set(owner, Math.max(0, ...fresh.filter(g => !g.revoked && g.until).map(g => g.until!.getTime() + NEVER_EXPIRATION_GRACE_MS)));
// Reconciliation can recover a missed Never expire purchase too.
for (const grant of await BillingGrant.find({ owner, planId: "repo_never", revoked: false, until: { $gt: now } }).select("owner scope status until revoked").lean()) {
if (usableGrant(grant, now)) addons.add(`${grant.owner} ${grant.scope}`);
}
}
if (addons.has(`${owner} repo:${repo.repoId}`)) rolling = now.getTime() + NEVER_EXPIRATION_GRACE_MS;
deadline = new Date(Math.max(previous.getTime(), deadlines.get(owner)!, rolling));
} catch {
deferred.add(owner);
+2
View File
@@ -3,11 +3,13 @@ import config from "../../config";
import { BillingEvent, BillingAccount } from "./models";
import { processBillingInbox, billingHealth, billingFailure } from "./events";
import { reconcileBillingAccounts } from "./reconciliation";
import { rememberConfiguredPrices } from "./stripe";
import { createLogger } from "../logger";
const logger = createLogger("billing");
export async function startBillingMaintenance() {
if (!config.BILLING_ENABLED) return;
await Promise.all([BillingEvent.createIndexes(), BillingAccount.createIndexes()]);
await rememberConfiguredPrices();
let eventsRunning = false;
let reconcileRunning = false;
const events = async () => {
+23 -8
View File
@@ -3,7 +3,7 @@ import { randomUUID } from "crypto";
import config from "../../config";
import { BillingAccount, BillingGrant, BillingLock, BillingRevocation, BillingPrice, BillingQuota } from "./models";
import { REPOSITORY_ID_COLLATION } from "../repository-id";
import { billingError, billingOrigin, billingPlans, requireBilling, REPOSITORY_ADDONS, REPO_EXTEND_MS, REPO_NEVER_UNTIL, PACK_PLANS, isConferencePlan } from "./config";
import { billingError, billingOrigin, billingPlans, legacyPricePlans, requireBilling, REPOSITORY_ADDONS, REPO_EXTEND_MS, REPO_NEVER_UNTIL, PACK_PLANS, isConferencePlan } from "./config";
import { usableGrant } from "./quotas";
import ConferenceModel from "../model/conference/conferences.model";
import RepositoryModel from "../model/anonymizedRepositories/anonymizedRepositories.model";
@@ -21,6 +21,22 @@ export async function withBillingLock<T>(id: string, work: () => Promise<T>): Pr
try { return await work(); }
finally { clearInterval(heartbeat); await BillingLock.deleteOne({ _id: id, token }); }
}
// The plan a Stripe price pays for: the configured price, then a declared
// legacy price, then a price recorded when it was configured or sold.
export async function planForPrice(priceId: string | undefined, recurring: boolean) {
if (!priceId) return undefined;
const plans = billingPlans().filter(p => p.recurring === recurring);
const configured = plans.find(p => p.priceId === priceId);
if (configured) return configured;
const planId = legacyPricePlans().get(priceId) || (await BillingPrice.findById(priceId).lean())?.planId;
return plans.find(p => p.id === planId);
}
// Record every configured price before it can be replaced, so a later
// configuration change still recognizes subscriptions sold at that price.
export async function rememberConfiguredPrices() {
const prices = [...billingPlans().filter(p => p.priceId).map(p => [p.priceId, p.id] as const), ...legacyPricePlans()];
for (const [priceId, planId] of prices) await BillingPrice.updateOne({ _id: priceId }, { $setOnInsert: { planId } }, { upsert: true });
}
export async function syncSubscription(id: string, stripe = stripeClient()) {
// Compare-and-swap prevents a slow, earlier fetch from overwriting a newer one.
for (let retry = 0; retry < 4; retry++) {
@@ -31,7 +47,7 @@ export async function syncSubscription(id: string, stripe = stripeClient()) {
const account = await BillingAccount.findById(owner).lean();
if (!account || account.customerId !== objectId(sub.customer)) throw billingError("billing_customer_mismatch");
const priceId = sub.items.data[0]?.price.id;
const plan = billingPlans().find(p => p.recurring && p.priceId === priceId);
const plan = await planForPrice(priceId, true);
const invoice = sub.latest_invoice as Stripe.Invoice | null;
const paid = !!plan && sub.items.data.length === 1 && sub.status === "active" && invoice?.status === "paid";
const periodEnd = sub.items.data[0]?.current_period_end;
@@ -47,7 +63,7 @@ export async function syncSubscription(id: string, stripe = stripeClient()) {
}
}
}
const values = { owner, scope: `user:${owner}`, planId: plan?.id || "unknown", customerId: account.customerId,
const values = { owner, scope: `user:${owner}`, planId: plan?.id || "unknown", customerId: account.customerId, priceId,
subscriptionId: id, status: sub.status, cancelAtPeriodEnd: sub.cancel_at_period_end || !!sub.cancel_at,
until: paidThrough, ...(sub.start_date ? { purchasedAt: new Date(sub.start_date * 1000) } : {}),
};
@@ -78,7 +94,7 @@ export async function fulfillCheckout(id: string, stripe = stripeClient()) {
&& previousGrant?.planId === planId && previousGrant?.scope === previousScope
&& (!previousGrant.priceId || previousGrant.priceId === priceId);
if (!plan || !priceId || session.line_items?.data.length !== 1 || session.line_items.data[0].quantity !== 1 ||
!(priceId === plan.priceId || historicalPrice?.planId === planId || acceptedGrant)) throw billingError("billing_invalid_purchase");
!(priceId === plan.priceId || historicalPrice?.planId === planId || legacyPricePlans().get(priceId) === planId || acceptedGrant)) throw billingError("billing_invalid_purchase");
await BillingPrice.updateOne({ _id: priceId }, { $setOnInsert: { planId } }, { upsert: true });
if (session.payment_status !== "paid" || session.status !== "complete") return;
const intent = session.payment_intent as Stripe.PaymentIntent | null;
@@ -265,10 +281,9 @@ export async function createCheckout(owner: string, planId: string, target = "",
account.checkout.params = params;
await account.save();
}
if (!plan.recurring) {
const acceptedPrice = (account.checkout.params as Stripe.Checkout.SessionCreateParams).line_items?.[0]?.price;
if (acceptedPrice) await BillingPrice.updateOne({ _id: acceptedPrice }, { $setOnInsert: { planId } }, { upsert: true });
}
// Subscriptions too: their price must stay recognizable after a price change.
const acceptedPrice = (account.checkout.params as Stripe.Checkout.SessionCreateParams).line_items?.[0]?.price;
if (acceptedPrice) await BillingPrice.updateOne({ _id: acceptedPrice }, { $setOnInsert: { planId } }, { upsert: true });
const session = await stripe.checkout.sessions.create(account.checkout.params as Stripe.Checkout.SessionCreateParams, { idempotencyKey: account.checkout.key });
account.checkout.id = session.id;
await account.save();
+5 -1
View File
@@ -1,5 +1,7 @@
import config from "../../config";
import { billingError, billingPlans, isConferencePlan } from "../../core/billing/config";
import { BillingGrant } from "../../core/billing/models";
import { usableGrant } from "../../core/billing/quotas";
import * as express from "express";
import AnonymousError from "../../core/AnonymousError";
import Conference from "../../core/Conference";
@@ -241,7 +243,9 @@ router.get(
const conference = new Conference(data);
// Authors see whether the conference pays for their repository's hosting.
const now = new Date();
const hosting = isConferencePlan(data.plan?.planID) && data.status === "ready" && data.startDate <= now && data.endDate > now;
// Mirror admission: a refunded or disputed package no longer hosts anything.
const hosting = config.BILLING_ENABLED && isConferencePlan(data.plan?.planID) && data.status === "ready" && data.startDate <= now && data.endDate > now
&& (await BillingGrant.find({ scope: `conference:${data.conferenceID}`, revoked: false, until: { $gt: now } }).select("status until revoked").lean()).some(g => usableGrant(g, now));
try {
isOwnerOrAdmin(conference.ownerIDs, user);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
+83
View File
@@ -26,6 +26,7 @@ async function rejects(promise, message) {
beforeEach(async () => {
Object.assign(config, { BILLING_ENABLED: true, FREE_AUTHOR_REPOSITORIES: 3, FREE_AUTHOR_STORAGE: 100,
PAID_AUTHOR_STORAGE: 1000, PAID_AUTHOR_REPOSITORIES: 20, STRIPE_AUTHOR_MONTHLY_PRICE: "price_monthly", STRIPE_AUTHOR_PASS_PRICE: "price_pass", STRIPE_CONFERENCE_PRICE: "price_conf", STRIPE_CONFERENCE_SMALL_PRICE: "price_conf_small", STRIPE_CONFERENCE_LARGE_PRICE: "price_conf_large", PAID_CONFERENCE_SMALL_REPOSITORIES: 25, STRIPE_REPO_EXTEND_PRICE: "", STRIPE_REPO_NEVER_PRICE: "", STRIPE_REPO_SLOT_PRICE: "", STRIPE_REPO_PACK_PRICE: "" });
Object.assign(config, { STRIPE_AUTHOR_ANNUAL_PRICE: "price_annual", STRIPE_LEGACY_PRICES: "" });
await Promise.all([BillingPrice, BillingAccount, BillingGrant, BillingQuota, BillingLock, BillingEvent, BillingRevocation, BillingDraft, Repo, Conference].map(m => m.deleteMany({})));
await BillingAccount.create({ _id: owner, customerId: "cus_owner" });
});
@@ -464,6 +465,88 @@ async function rejects(promise, message) {
expect(limits.repositories).to.equal(25);
await assertConferenceAdmission(owner, "repo", "WS");
});
it("keeps legacy and new annual subscriptions after the annual price is replaced", async () => {
const { rememberConfiguredPrices } = require("../src/core/billing/stripe");
// Deployed before the switch: the old annual price is recorded at startup.
await rememberConfiguredPrices();
const annual = (id, price) => subscription({ id, items: { data: [{ price: { id: price }, current_period_end: Math.floor(future().getTime() / 1000) }] } });
let legacy = annual("sub_owner", "price_annual");
const stripe = { subscriptions: { retrieve: async id => id === "sub_owner" ? legacy : annual(id, "price_annual_new") } };
config.STRIPE_AUTHOR_ANNUAL_PRICE = "price_annual_new";
await syncSubscription("sub_owner", stripe);
let grant = await BillingGrant.findById("sub_owner");
expect(grant.planId).to.equal("author_annual");
expect(grant.priceId).to.equal("price_annual");
expect((await allowance(owner)).plan).to.equal("author_annual");
// Webhook updates (renewal, cancellation) keep resolving the legacy price.
legacy = { ...legacy, cancel_at_period_end: true };
await processBillingEvent({ type: "customer.subscription.updated", data: { object: { id: "sub_owner" } } }, stripe);
grant = await BillingGrant.findById("sub_owner");
expect(grant.planId).to.equal("author_annual");
expect(grant.cancelAtPeriodEnd).to.equal(true);
// New subscriptions use the new price.
await processBillingEvent({ type: "customer.subscription.created", data: { object: { id: "sub_new" } } }, stripe);
expect((await BillingGrant.findById("sub_new")).planId).to.equal("author_annual");
});
it("recognizes a declared legacy price without a recorded one", async () => {
config.STRIPE_AUTHOR_ANNUAL_PRICE = "price_annual_new";
const stripe = { subscriptions: { retrieve: async () => subscription({ items: { data: [{ price: { id: "price_annual_old" }, current_period_end: Math.floor(future().getTime() / 1000) }] } }) } };
await syncSubscription("sub_owner", stripe);
expect((await BillingGrant.findById("sub_owner")).planId).to.equal("unknown");
config.STRIPE_LEGACY_PRICES = "price_annual_old:author_annual";
await syncSubscription("sub_owner", stripe);
expect((await BillingGrant.findById("sub_owner")).planId).to.equal("author_annual");
expect((await allowance(owner)).plan).to.equal("author_annual");
});
it("keeps historical one-time purchases after their price is replaced", async () => {
const value = session(); value.id = "cs_old_pass";
await fulfillCheckout("cs_old_pass", { checkout: { sessions: { retrieve: async () => value } } });
expect((await allowance(owner, "repo")).plan).to.equal("author_pass");
config.STRIPE_AUTHOR_PASS_PRICE = "price_pass_new";
// A redelivered webhook for the old sale still fulfills and keeps the grant.
await fulfillCheckout("cs_old_pass", { checkout: { sessions: { retrieve: async () => value } } });
expect((await BillingGrant.findById("cs_old_pass")).revoked).to.not.equal(true);
expect((await allowance(owner, "repo")).plan).to.equal("author_pass");
// A sale at an old price never recorded is accepted once it is declared.
const other = session(); other.id = "cs_older"; other.metadata.target = "other"; other.line_items.data[0].price.id = "price_pass_older";
await rejects(fulfillCheckout("cs_older", { checkout: { sessions: { retrieve: async () => other } } }), "billing_invalid_purchase");
config.STRIPE_LEGACY_PRICES = "price_pass_older:author_pass";
await fulfillCheckout("cs_older", { checkout: { sessions: { retrieve: async () => other } } });
expect((await allowance(owner, "other")).plan).to.equal("author_pass");
});
it("keeps a Never expire link online when reconciliation recovers its purchase", async () => {
const { reconcileNeverExpiration, NEVER_EXPIRATION_GRACE_MS } = require("../src/core/billing/expiration");
const now = new Date();
await Repo.create({ repoId: "recovered", owner, status: "ready", options: { expirationMode: "never" }, billingNeverExpiresUntil: new Date(now.getTime() - 1000) });
await reconcileNeverExpiration(now, async () => {
await BillingGrant.create({ _id: "never-recovered", owner, scope: "repo:recovered", status: "paid", until: new Date("9999-12-31T00:00:00Z"), planId: "repo_never" });
});
const repo = await Repo.findOne({ repoId: "recovered" });
expect(repo.options.expirationMode).to.equal("never");
expect(repo.billingNeverExpiresUntil.getTime()).to.equal(now.getTime() + NEVER_EXPIRATION_GRACE_MS);
});
it("keeps automatic updates for a paid-slot repository while its owner subscribes", async () => {
const { hasAutomaticUpdates } = require("../src/core/billing/expiration");
await BillingGrant.create({ _id: "slot", owner, scope: "repo:slotted", status: "paid", until: new Date("9999-12-31T00:00:00Z"), planId: "repo_slot" });
expect(await hasAutomaticUpdates(owner, "slotted")).to.equal(false);
await BillingGrant.create({ _id: "author", owner, scope: `user:${owner}`, status: "active", until: future(), planId: "author_monthly" });
expect(await hasAutomaticUpdates(owner, "slotted")).to.equal(true);
});
it("only reports conference hosting while its package payment stands", async () => {
const utils = require("../src/server/routes/route-utils");
const router = require("../src/server/routes/conference").default;
const get = router.stack.find(layer => layer.route?.path === "/:conferenceID" && layer.route.methods.get).route.stack[0].handle;
const originalUser = utils.getUser;
utils.getUser = async () => ({ model: { id: "someone-else" }, isAdmin: false });
try {
await Conference.create({ conferenceID: "PAID", owners: [owner], status: "ready", startDate: new Date(Date.now() - 1000), endDate: future(), plan: { planID: "conference" } });
await BillingGrant.create({ _id: "conf-paid", owner, scope: "conference:PAID", status: "paid", until: future(), planId: "conference" });
const hosting = async () => { let body; await get({ params: { conferenceID: "PAID" } }, { json: value => { body = value; } }); return body.hosting; };
expect(await hosting()).to.equal(true);
await BillingGrant.updateOne({ _id: "conf-paid" }, { $set: { revoked: true } });
expect(await hosting()).to.equal(false);
} finally { utils.getUser = originalUser; }
});
it("prevents two simultaneous checkout sessions and reuses an open session", async () => {
let creates = 0;
const stripe = { prices: { retrieve: async () => ({ active: true, currency: "eur", unit_amount: 500, recurring: { interval: "month", interval_count: 1 } }) }, subscriptions: { list: async () => ({ data: [] }) }, checkout: { sessions: {
+28
View File
@@ -594,6 +594,34 @@ describe("Vue 3 UI", function () {
});
}
it("opens Checkout for the subscription cycle the hosting option shows", async () => {
const billing = { ...taskBilling, plans: [...taskBilling.plans,
{ id: "author_annual", amount: 4900, currency: "eur", interval: "year", repositories: 20, storage: 10737418240, description: "Renews yearly." }] };
ui = await browser("/anonymize", { ...repositorySource, "/api/billing/": billing });
await delay(20);
const state = ui.window.document.querySelector("#sourceUrl")._field.binding.state; state.detectedType = "repo";
state.hostingQuotaBlocked = true;
expect(state.hostingOneTimePlans()).to.deep.equal([]);
expect(state.hostingSubscriptionPlan().id).to.equal("author_annual");
expect(state.hostingChoice()).to.equal("author_annual");
expect(state.selectedHostingPlan().id).to.equal("author_annual");
});
it("leaves gist and pull request expiration dates outside the repository billing limit", async () => {
ui = await browser("/anonymize", { ...repositorySource, "/api/billing/": taskBilling });
await delay(20);
const state = ui.window.document.querySelector("#sourceUrl")._field.binding.state;
const later = new Date(); later.setMonth(later.getMonth() + 10);
state.detectedType = "gist";
state.options.expirationDate = new ui.window.Date(later.getTime());
await delay(20);
expect(state.options.expirationDate.getTime()).to.equal(later.getTime());
expect(new Date(state.maxExpirationDate).getTime()).to.be.greaterThan(later.getTime());
state.detectedType = "repo";
await delay(20);
expect(state.options.expirationDate.getTime()).to.be.lessThan(later.getTime());
});
it("keeps free, conference and coauthor work out of author checkout", async () => {
ui = await browser("/anonymize", { ...repositorySource, "/api/billing/": taskBilling });
const state = ui.window.document.querySelector("#sourceUrl")._field.binding.state; state.detectedType = "repo";