1<!DOCTYPE html> 2<html lang="en"> 3<head> 4<meta charset="UTF-8"> 5<meta name="viewport" content="width=device-width, initial-scale=1.0"> 6<title>E2LLM â Plan & Billing</title> 7<meta name="robots" content="noindex"> 8<link rel="icon" href="/e2llm/favicon.ico"> 9<link rel="stylesheet" href="/lib/style.css"> 10<style> 11.section-tabs{display:flex;gap:.5rem;margin-bottom:1.25rem;border-bottom:1px solid var(--border);padding-bottom:0} 12.section-tab{padding:.6rem 1.1rem;border:none;background:transparent;color:var(--fg3);font-size:.85rem;font-weight:500;cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px;font-family:inherit;transition:color .15s} 13.section-tab:hover:not(.active){color:var(--fg2)} 14.section-tab.active{color:var(--fg);border-bottom-color:var(--mcp)} 15.section-panel{display:none} 16.section-panel.active{display:block} 17 18.plans-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:1rem;margin-bottom:1.5rem} 19@media(max-width:768px){.plans-grid{grid-template-columns:1fr}} 20.plan-tile{padding:1.5rem;background:var(--bg);border:1px solid var(--border);border-radius:12px;display:flex;flex-direction:column;position:relative} 21.plan-tile.current{border-color:var(--ext);box-shadow:0 0 0 2px var(--ext-glow)} 22.plan-tile-current-badge{position:absolute;top:-10px;right:1rem;background:var(--ext-deep);color:#fff;font-size:.65rem;font-weight:700;padding:.2rem .6rem;border-radius:100px;text-transform:uppercase;letter-spacing:.08em} 23.plan-tile h3{font-size:1.1rem;font-weight:700;margin-bottom:.25rem} 24.plan-tile .price{font-size:1.5rem;font-weight:700;margin:.5rem 0;letter-spacing:-.02em} 25.plan-tile .price small{font-size:.78rem;font-weight:400;color:var(--fg3)} 26.plan-tile .features{list-style:none;padding:0;margin:1rem 0;flex:1} 27.plan-tile .features li{font-size:.82rem;color:var(--fg2);padding:.3rem 0 .3rem 1.4rem;position:relative;line-height:1.5} 28.plan-tile .features li::before{content:'â';position:absolute;left:0;color:var(--mcp);font-weight:600;font-size:.75rem} 29.usage-summary{display:grid;grid-template-columns:repeat(3,1fr);gap:1rem;margin-bottom:1.25rem} 30@media(max-width:480px){.usage-summary{grid-template-columns:1fr}} 31.usage-tile{padding:1rem;background:var(--bg);border:1px solid var(--border);border-radius:10px;text-align:center} 32.usage-tile .num{font-size:1.5rem;font-weight:700;letter-spacing:-.02em} 33.usage-tile .lbl{font-size:.72rem;color:var(--fg3);text-transform:uppercase;letter-spacing:.06em;font-weight:600;margin-top:.25rem} 34 35.pm{display:flex;align-items:center;justify-content:space-between;padding:.85rem 1rem;background:var(--bg);border:1px solid var(--border);border-radius:8px;margin-bottom:.5rem} 36.pm-info{display:flex;align-items:center;gap:.85rem} 37.pm-brand{font-weight:600;font-size:.85rem} 38.pm-detail{font-size:.72rem;color:var(--fg3)} 39.pm-default{font-size:.65rem;background:var(--mcp-glow);color:var(--mcp);padding:.15rem .5rem;border-radius:100px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;margin-left:.5rem} 40 41/* Subscription status + past_due banner (#12 Part A legs 3/4). */ 42.billing-banner:not(:empty){margin-bottom:1.25rem} 43.banner-warn{display:flex;align-items:center;justify-content:space-between;gap:1rem;flex-wrap:wrap;padding:.9rem 1.1rem;background:rgba(251,191,36,.08);border:1px solid rgba(251,191,36,.35);border-radius:10px} 44.banner-warn .bw-text{font-size:.82rem;color:var(--fg2);line-height:1.5} 45.banner-warn .bw-text strong{color:var(--amber)} 46.sub-status{font-weight:600;font-size:.85rem} 47.sub-actions{display:flex;gap:.5rem;flex-wrap:wrap} 48</style> 49</head> 50<body data-state="loading"> 51<div id="app-nav"></div> 52 53<div class="page"> 54 <div class="page-header"> 55 <div class="crumb"><a href="/">Dashboard</a> / Plan & Billing</div> 56 <h1>Plan & Billing</h1> 57 <p>Manage your subscription tier, view usage, and handle payment methods and invoices.</p> 58 </div> 59 60 <!-- Leg 4 (#12): past_due dunning banner â access-preserved (HOLD-but-active), 61 populated from billing.getStatus().status by renderBillingBanner(). --> 62 <div class="billing-banner" id="billing-banner"></div> 63 64 <div class="section-tabs" role="tablist"> 65 <button type="button" class="section-tab active" data-section="plan" role="tab" aria-selected="true">Plan</button> 66 <button type="button" class="section-tab" data-section="billing" role="tab" aria-selected="false">Billing</button> 67 </div> 68 69 <!-- âââââ PLAN PANEL âââââ --> 70 <div class="section-panel active" data-panel="plan" role="tabpanel"> 71 <div class="card grid-full" style="margin-bottom:1.25rem"> 72 <div class="card-label"><span class="card-label-text">Usage this period</span></div> 73 <div class="usage-summary" id="usage-summary"> 74 <div class="usage-tile"><div class="num"><span class="skel" style="width:3em">···</span></div><div class="lbl">Used today</div></div> 75 <div class="usage-tile"><div class="num"><span class="skel" style="width:3em">···</span></div><div class="lbl">Daily limit</div></div> 76 <div class="usage-tile"><div class="num"><span class="skel" style="width:3em">···</span></div><div class="lbl">% of limit</div></div> 77 </div> 78 </div> 79 80 <div class="card grid-full"> 81 <div class="card-label"><span class="card-label-text">Available plans</span></div> 82 <div class="plans-grid" id="plans-grid"> 83 <div class="t-empty" style="grid-column:1/-1">Loadingâ¦</div> 84 </div> 85 <p class="config-hint">All plans include the same features. The difference is daily operation budget. Billing is monthly via Paddle.</p> 86 </div> 87 </div> 88 89 <!-- âââââ BILLING PANEL âââââ --> 90 <div class="section-panel" data-panel="billing" role="tabpanel"> 91 <!-- Leg 3 (#12): subscription status + self-serve manage/cancel + update-payment, 92 all Paddle-hosted links from billing.getStatus().management_urls. --> 93 <div class="card grid-full" style="margin-bottom:1.25rem"> 94 <div class="card-label"><span class="card-label-text"><span class="dot dot-grey" id="sub-dot"></span> Subscription</span></div> 95 <div id="sub-content"><div class="t-empty">Loadingâ¦</div></div> 96 </div> 97 98 <div class="card grid-full" style="margin-bottom:1.25rem"> 99 <div class="card-label">
100 <span class="card-label-text"><span class="dot dot-grey" id="pm-dot"></span> Payment methods</span> 101 <button class="btn-sm btn-primary" id="btn-add-pm" type="button" disabled>+ Add</button> 102 </div> 103 <div id="pm-content"><div class="t-empty">Loadingâ¦</div></div> 104 </div> 105 106 <div class="card grid-full"> 107 <div class="card-label"><span class="card-label-text">Transactions</span></div> 108 <div id="tx-content"><div class="t-empty">Loadingâ¦</div></div> 109 </div> 110 </div> 111 112 <div id="app-footer"></div> 113</div> 114
115<script src="/lib/config.js"></script>
vendor: 1 bytes, line 115
115
116<script src="/elastic-apm-rum.umd.min.js"></script>
vendor: 1 bytes, line 116
116
117<script src="/rum-init.js"></script>
vendor: 1 bytes, line 117
117
118<script src="/sdk/oogl-sdk.js"></script>
vendor: 1 bytes, line 118
118
119<script src="/lib/mcp-module.js"></script>
vendor: 1 bytes, line 119
119
120<script src="/lib/payment-method-state.js"></script>
vendor: 1 bytes, line 120
120
121<script src="/lib/plan-action-state.js"></script>
vendor: 1 bytes, line 121
121
122<script src="/lib/scheduled-change-state.js"></script>
vendor: 1 bytes, line 122
122
123<script src="/lib/sdk-init.js"></script>
vendor: 1 bytes, line 123
123
124<script src="/lib/shell.js"></script>
124 125<!-- Paddle Billing v2 client-side SDK (loaded from Paddle's CDN â oogl-sdk is 126 zero-dep and doesn't bundle it). The Upgrade button drives checkout through 127 sdk.billing (oogl-sdk 1.3.0): initPaddle() bootstraps Paddle at load and 128 checkout() opens the overlay (Option B â no backend round-trip for 129 new-subscriber checkout). The page still loads when no client_token is 130 configured (initPaddle is skipped; the Upgrade button then toasts). -->
131<script src="https://cdn.paddle.com/paddle/v2/paddle.js"></script>
131 132
133<script> 134(function() { 135 'use strict'; 136 var esc = window.E2LLM.shell.escapeHtml; 137 var tierLabel = window.E2LLM.shell.tierLabel; 138 var toast = window.E2LLM.shell.toast; 139 var formatSdkError = window.E2LLM.shell.formatSdkError; 140 141 // Leg 2 (#12 / oogl #73): the Pro+ ($49) bundle-agreement consent, recorded 142 // before Paddle opens. Both identifiers are settled bilaterally with gf-oogl: 143 // type = 'pro_plus_agreement' 144 // version = 'draft-2026-07' â CANONICAL (locked 2026-07-13, gf-oogl reconcile). 145 // Declared by gf-oogl as the pro_plus required_consents version at 146 // the #73 post-launch re-scope. Do NOT drift it without gf-oogl: 147 // the server compares type AND version by EXACT STRING EQUALITY and 148 // takes the version verbatim from this request, so a one-character 149 // drift here is a silent permanent 403 at checkout for that user. 150 // 151 // â THE GATE IS LIVE ON BOTH PLANES â verified 2026-07-30 by reading 152 // the live dev ConfigMap (`oogl-e2llm-manifest`, .data["manifest.json"]): 153 // pro_plus carries required_consents [{pro_plus_agreement, 154 // draft-2026-07}] on dev as well as in the prod-eun overlay. 155 // An earlier revision of this comment said the DEV manifest declared 156 // none, so the backend verify passed regardless of version on dev. 157 // That was true when written and was invalidated by manifest 158 // cf46a156; the comment was not updated. It is corrected here because 159 // it had already misled a reader into filing #32 on the premise that 160 // a version mismatch is undetectable outside production â it is not, 161 // dev fails too. (oogl-backend#90's S3 consent race fired ON DEV, 162 // which a dormant gate could not have done.) 163 // The agreement COPY below stays placeholder-until-legal-text (Alexey batch-3 + 164 // Pro+ supplement) â a later copy-swap, independent of the now-locked version. 165 // #40 â THE VERSION IS NO LONGER HARDCODED HERE. It is read per-tier from 166 // `GET /oogl/billing/plans` â `tiers[].required_consents[]`, which is the same 167 // record the backend compares against, so the two cannot drift apart. 168 // 169 // â THIS REMOVES PA FROM THE COUPLING SET. IT DOES NOT DISSOLVE THE SET. 170 // The consent version is still compared by EXACT STRING EQUALITY across: 171 // 1. the artifact 172 // 2. okd/oogl base/â¦:53 173 // 3. okd/oogl overlays/prod-eun-e2llm/â¦:104 174 // Moving some-but-not-all of those still gives every buyer a SILENT PERMANENT 175 // 403 â nothing turns red anywhere. When the version next moves it moves in one 176 // coordinated change or it does not move. The natural reading of "the constant 177 // is gone" is that the coupling is gone with it; it is not. (gf-cs, 2026-08-04.) 178 // 179 // Unblocked because the prod pin now includes 8cdbd8dd â before that, reading 180 // would have returned an absent value where a correct constant stood. 181 var livePlansRef = null; // raw /oogl/billing/plans payload; NEVER defaultPlans() 182 183 // Returns {type, version} for the tier, or null when the backend requires no 184 // consent for it. Throws when the requirement cannot be determined â callers 185 // must refuse the money action rather than guess a version. 186 function consentRequirementFor(tier) { 187 if (!livePlansRef || !livePlansRef.tiers) throw new Error('consent_requirement_unknown'); 188 var ts = livePlansRef.tiers; 189 for (var i = 0; i < ts.length; i++) { 190 if (ts[i].name !== tier) continue; 191 var rc = ts[i].required_consents; 192 if (!rc || !rc.length) return null; // backend requires none 193 return { type: rc[0].type, version: rc[0].version }; 194 } 195 // Tier absent from a payload we DID read: that is unknown, not "none". 196 throw new Error('consent_requirement_unknown'); 197 } 198 var PRO_PLUS_AGREEMENT_TEXT = 199 'Pro+ is billed at $49/month and renews automatically until cancelled. ' + 200 'You can cancel anytime from Plan & Billing â access continues to the end of the paid period. ' + 201 'By continuing you agree to the Pro+ subscription terms. ' + 202 '(Placeholder summary â the final Pro+ terms are being finalised and will replace this before launch.)'; 203 204 // The (optional) checkout opts â kept identical across the direct and 205 // consent-gated paths so both send the same customer hint. 206 function checkoutOpts() { 207 return (userRef && userRef.email) ? { customer: { email: userRef.email } } : undefined; 208 } 209 210 // Purpose-built Pro+ consent modal â deliberately NOT the shared shell
211 // confirmModal, which focuses the CANCEL button and dismisses on backdrop/Escape. 212 // For an agent-driven client those defaults made "I Agree & Continue" ambiguous 213 // and could resolve to a *silent decline* (#17 / round-4 DEF-2: modal closed but 214 // checkout never opened, no error, stayed Free). This one focuses the AFFIRMATIVE 215 // action, makes a backdrop click inert, and resolves only on an explicit button 216 // (Escape/Cancel decline explicitly). Resolves true = agree, false = decline. 217 function proPlusConsent() { 218 return new Promise(function(resolve) { 219 var bd = document.createElement('div'); 220 bd.className = 'modal-bd'; 221 bd.innerHTML = 222 '<div class="modal" role="dialog" aria-modal="true" aria-labelledby="ppc-title">' + 223 '<h2 id="ppc-title">Pro+ subscription agreement</h2>' + 224 '<p style="font-size:.85rem;color:var(--fg2);margin-bottom:1rem">' + esc(PRO_PLUS_AGREEMENT_TEXT) + '</p>' + 225 '<div class="modal-actions">' + 226 '<button class="btn-sm btn-manage js-ppc-cancel" type="button">Cancel</button>' + 227 '<button class="btn-sm btn-primary js-ppc-agree" type="button">I Agree & Continue</button>' + 228 '</div>' + 229 '</div>'; 230 function done(v) { document.removeEventListener('keydown', onKey); bd.remove(); resolve(v); } 231 function onKey(e) { if (e.key === 'Escape') done(false); } 232 // Backdrop click is intentionally inert â no silent dismiss-to-decline. 233 bd.querySelector('.js-ppc-cancel').addEventListener('click', function() { done(false); }); 234 bd.querySelector('.js-ppc-agree').addEventListener('click', function() { done(true); }); 235 document.addEventListener('keydown', onKey); 236 document.body.appendChild(bd); 237 // Focus the AFFIRMATIVE action: the user already chose to upgrade to Pro+, 238 // so an accidental Enter proceeds rather than silently declining. 239 bd.querySelector('.js-ppc-agree').focus(); 240 }); 241 } 242 243 // URL hash drives initial section, so /plan/#billing deep-links to the 244 // billing tab â keeps existing /billing/ links working after redirect. 245 function activateSection(name) { 246 if (name !== 'plan' && name !== 'billing') name = 'plan'; 247 document.querySelectorAll('.section-tab').forEach(function(t) { 248 var on = t.dataset.section === name; 249 t.classList.toggle('active', on); 250 t.setAttribute('aria-selected', on ? 'true' : 'false'); 251 }); 252 document.querySelectorAll('.section-panel').forEach(function(p) { 253 p.classList.toggle('active', p.dataset.panel === name); 254 }); 255 } 256 function sectionFromHash() { 257 var h = (window.location.hash || '').replace(/^#/, '').toLowerCase(); 258 return (h === 'billing' || h === 'plan') ? h : 'plan'; 259 } 260 document.querySelectorAll('.section-tab').forEach(function(t) { 261 t.addEventListener('click', function() { 262 var s = t.dataset.section; 263 history.replaceState(null, '', '#' + s); 264 activateSection(s); 265 }); 266 }); 267 window.addEventListener('hashchange', function() { activateSection(sectionFromHash()); }); 268 activateSection(sectionFromHash()); 269 270 // ââ Plan: usage + plans grid ââââââââââââââââââââââââââââââââââââ 271 // `used` absent is UNKNOWN, never zero (#41). The old fallback rendered 272 // `Used today: 0` â a confident, wrong number on a paid surface â whenever the 273 // field was missing. A dash says the same thing honestly, and `%` is suppressed 274 // with it: a percentage over a fabricated numerator is the same defect carried 275 // to more decimal places. 276 function renderUsage(usage) { 277 var hasUsed = usage && typeof usage.used === 'number'; 278 var used = hasUsed ? usage.used : null; 279 var limit = (usage && typeof usage.limit === 'number') ? usage.limit : 0; 280 var pct = (hasUsed && limit) ? Math.round(used / limit * 100) + '%' : 'â'; 281 document.getElementById('usage-summary').innerHTML = 282 '<div class="usage-tile"><div class="num">' + (hasUsed ? esc(String(used)) : 'â') + '</div><div class="lbl">Used today</div></div>' + 283 '<div class="usage-tile"><div class="num">' + (limit ? esc(String(limit)) : 'â') + '</div><div class="lbl">Daily limit</div></div>' + 284 '<div class="usage-tile"><div class="num">' + esc(pct) + '</div><div class="lbl">% of limit</div></div>'; 285 } 286 function renderUsagePending() { 287 document.getElementById('usage-summary').innerHTML = 288 '<div class="pending" style="grid-column:1/-1"><strong>Coming soon.</strong> Usage statistics are on their way.</div>'; 289 } 290 // Third state (#41). A 5xx / timeout / network failure is NOT "not yet built"
290: 291 // `renderUsagePending()` says the feature is coming, which is a different â and 292 // to a paying customer, a wrong â message when the service exists and broke. 293 // Only 404 and 501 set `err.pending` (lib/mcp-module.js), so everything else 294 // used to fall through to a fabricated zero. Mirrors the amber `errorHTML` 295 // treatment the Sessions page already uses for exactly this distinction. 296 function renderUsageUnavailable() { 297 document.getElementById('usage-summary').innerHTML = 298 '<div class="pending" style="grid-column:1/-1;border-color:var(--amber);color:var(--amber)">' + 299 '<strong>Couldn\'t load.</strong> Usage statistics are temporarily unavailable â refresh to retry.</div>'; 300 } 301 302 // Display fallback when listPlans is unavailable. Prices and daily caps 303 // mirror the canonical product manifest (system/config plans.tiers â 304 // $0/$19/$49, 100/250/750 ops/day); feature lines stay curated copy 305 // because the manifest's `features` are entitlement slugs, not marketing 306 // strings. Machine fields (price_id) are never read from here â see 307 // tierPriceId(). MANIFEST IS TRUTH: if these constants ever disagree 308 // with /oogl/system/config plans.tiers, the manifest wins â update here 309 // from there, never the reverse. (Build-time generation was considered 310 // and declined: this repo deliberately has no build step.) 311 // â #47 â `priority_server` and `diff_history_7d` were REMOVED here on Alexey's 312 // ruling (2026-08-05, via gf-zam): REMOVE, not display-as-not-currently-available. 313 // They are OFF SALE. Do not re-add them, and do not "restore" them from an older 314 // revision of this list â this block is hand-maintained and drifted from the 315 // manifest, which is how it came to advertise two capabilities that are not sold. 316 // 317 // It mattered more than a stale mirror because this list is not a fallback: the 318 // live payload can never reach the grid (`listPlans()` resolves an OBJECT while 319 // the guard tests `.length`, and the grid reads `p.tier` while the API emits 320 // `name`), so defaultPlans() renders for EVERY user on EVERY load. Both of those 321 // bugs must be fixed together â fixing only the guard makes every tile render as 322 // tier `undefined`. See #47. 323 function defaultPlans() { 324 return [ 325 { tier:'free', display_name:'Free', price_label:'$0 / month', daily_limit:100, features:['All features: capture, click, fill, scroll','SiFR format, full fidelity','Community support'] }, 326 { tier:'pro', display_name:'Pro', price_label:'$19 / month', daily_limit:250, features:['Everything in Free','250 ops/day','Change detection + SiFR diffs'] }, 327 { tier:'pro_plus', display_name:'Pro+', price_label:'$49 / month', daily_limit:750, features:['Everything in Pro','750 ops/day','Early access to new features'] }, 328 ]; 329 } 330 // Paddle price IDs live in the product manifest and reach the page via 331 // /oogl/system/config â ctx.appConfig.plans.tiers[].price_id (Option C). 332 // listPlans/getPlan deliberately do NOT serialize them â a second surface 333 // carrying purchase-critical IDs is drift-by-construction. The early 334 // Option-B assumption that listPlans would carry price_id is what left 335 // data-price-id empty on both upgrade buttons (QA walk T2-D2). 336 function tierPriceId(tier) { 337 var tiers = appConfigRef && appConfigRef.plans && appConfigRef.plans.tiers; 338 if (!tiers || !tiers.length) return null; 339 for (var i = 0; i < tiers.length; i++) { 340 if (tiers[i].name === tier) return tiers[i].price_id || null; 341 } 342 return null; 343 } 344 345 // Does this tier have NO Paddle price mapping in the loaded manifest? 346 // 347 // This is the exact condition under which POST /oogl/billing/upgrade cannot 348 // reach the tier: the backend resolves PaddlePriceID(tier) and, on empty, 349 // returns 400 E400-BILL-9 "no price mapping for tier: <t>" before doing 350 // anything else. The manifest price_map has "free": null on BOTH planes, so 351 // the plan grid's "Downgrade to Free" button was a deterministic 400 â 352 // an action the architecture has no route for. 353 // 354 // Leaving paid is a CANCEL, not a plan write: entitlement flips to free only 355 // when Paddle's subscription.canceled webhook lands and the backend runs 356 // downgradeToFree. So an unpriced target routes to the Paddle-hosted cancel
357 // URL instead of POSTing a tier the endpoint will refuse. 358 // 359 // Derived from the manifest (via /oogl/system/config), never from a hardcoded 360 // 'free' â same MANIFEST-IS-TRUTH rule as tierPriceId. Returns null when the 361 // manifest hasn't loaded or doesn't carry the tier, so the caller declines to 362 // classify rather than guessing: without that guard a config-load failure 363 // (defaultPlans, no price_ids anywhere) would misroute a real Pro downgrade 364 // into the cancel flow. The action classifier treats this unknown as 365 // unavailable instead. 366 function isUnpricedTier(tier) { 367 var tiers = appConfigRef && appConfigRef.plans && appConfigRef.plans.tiers; 368 if (!tiers || !tiers.length) return null; 369 for (var i = 0; i < tiers.length; i++) { 370 if (tiers[i].name === tier) return !tiers[i].price_id; 371 } 372 return null; 373 } 374 375 // Route the leave-paid action to Paddle's hosted cancel page. 376 // 377 // The URL is per-subscription and comes from getStatus().management_urls.cancel 378 // â a LIVE Paddle read, so it is absent for a free/never-subscribed tier and 379 // is also omitted when the Paddle GET fails (the backend logs and drops the 380 // block rather than failing the whole status read). Both cases get an honest 381 // pointer instead of a silent no-op. Never an in-PA cancel form: e2llm is MoR, 382 // Paddle owns the cancellation surface (same rule as payment-method capture). 383 // 384 // Destructive, so it confirms through shell.confirmModal â native confirm() is 385 // banned repo-wide (the QA substrate cannot drive it). 386 function cancelSubscription(btn, orig) { 387 var go = function(st) { 388 var url = st && st.management_urls && st.management_urls.cancel; 389 if (!url) { 390 toast('Cancellation is handled by Paddle, and no cancel link came back for your subscription just now. Open the Billing tab to manage it, or refresh and try again.', 'error'); 391 return; 392 } 393 window.E2LLM.shell.confirmModal({ 394 title: 'Cancel your subscription?', 395 message: 'You\'ll be taken to Paddle to confirm. Access continues to the end of the paid period, then your account returns to Free.', 396 confirmLabel: 'Continue to Paddle', 397 danger: true, 398 }).then(function(ok) { 399 if (!ok) return; 400 window.open(url, '_blank', 'noopener'); 401 }); 402 }; 403 // Reuse the cached status when boot already loaded it; otherwise resolve on 404 // demand rather than act on unknown sub-state (same discipline as the 405 // tier-change router above). 406 if (billingStatusLoaded) { go(billingStatusRef); return; } 407 if (!sdkRef.billing.getStatus) { go(null); return; } 408 btn.disabled = true; btn.textContent = 'Checking your planâ¦'; 409 var restore = function() { btn.disabled = false; btn.textContent = orig; }; 410 sdkRef.billing.getStatus().then(function(st) { 411 billingStatusRef = st; billingStatusLoaded = true; 412 restore(); go(st); 413 }).catch(function() { restore(); go(null); }); 414 } 415 416 function renderPlans(currentPlan, allPlans) { 417 var current = (currentPlan && currentPlan.tier) || 'free'; 418 var plans = (allPlans && allPlans.length) ? allPlans : defaultPlans(); 419 var grid = document.getElementById('plans-grid'); 420 grid.innerHTML = plans.map(function(p) { 421 var t = p.tier || 'free'; 422 var isCurrent = t === current; 423 var action = window.E2LLM.classifyPlanAction(current, t, isUnpricedTier(t)); 424 var feats = (p.features || []).map(function(f) { return '<li>' + esc(f) + '</li>'; }).join(''); 425 var actionHTML = ''; 426 if (action === 'current') { 427 actionHTML = '<button class="btn-sm btn-manage" disabled style="opacity:.6;cursor:default">Current plan</button>'; 428 } else if (action !== 'unavailable') { 429 // An unpriced DOWNGRADE target is the leave-paid case: label it as what 430 // it actually does (cancel the subscription), not as a tier switch the 431 // backend has no route for. Only reachable for a paying user â the free 432 // tile renders "Current plan" for anyone already on free. 433 var label = action === 'cancel' ? 'Cancel subscription' 434 : 'Upgrade to ' + esc(p.display_name || tierLabel(t)); 435 actionHTML = '<button class="btn-sm ' + (action === 'upgrade' ? 'btn-upgrade' : 'btn-manage') + ' js-switch" data-action="' + action + '" data-tier="' + esc(t) + '" data-price-id="' + esc(p.price_id || tierPriceId(t) || '') + '">' + label + '</button>'; 436 } 437 return '<div class="plan-tile' + (isCurrent ? ' current' : '') + '">' + 438 (isCurrent ? '<div class="plan-tile-current-badge">Current</div>' : '') + 439 '<h3>' + esc(p.display_name || tierLabel(t)) + '</h3>' + 440 '<div class="price">' + esc(p.price_label || 'â') + '</div>' + 441 '<ul class="features">' + feats + '</ul>' + 442 actionHTML + 443 '</div>'; 444 }).join('');
445 grid.querySelectorAll('.js-switch').forEach(function(btn) { 446 btn.addEventListener('click', function() { 447 var newTier = btn.dataset.tier; 448 var priceId = btn.dataset.priceId; 449 var orig = btn.textContent; 450 var action = btn.dataset.action; 451 452 if (action === 'upgrade') { 453 // Route by CURRENT-SUB STATE (double-bill fix). Routing every 454 // tier-increase through checkout() minted a FRESH MoR sub even for an 455 // existing subscriber, so Pro->Pro+ double-billed: a 2nd (Pro+) sub 456 // plus the old Pro left active/orphaned. Split: 457 // NEW subscriber (no active sub): checkout() = create the first sub 458 // via the Paddle overlay (checkout-intent SETNX -> Paddle). 459 // EXISTING paid subscriber (e.g. Pro->Pro+): upgrade() = in-place 460 // PRORATION via /oogl/billing/upgrade â no overlay, no new sub. 461 // pro_plus_agreement is recorded BEFORE either path. Priced 462 // downgrades never enter this branch: no deferred route exists. 463 // 464 // Sub-state is read from getStatus() (subscription_id + root .tier), 465 // NOT renderPlans' `current` (from getPlan): under oogl-backend#82 466 // getPlan can under-report the tier (renders FREE), and mis-classing an 467 // existing subscriber as new re-introduces the exact double-bill. 468 // getStatus is the reliable sibling. If sub-state can't be resolved, 469 // fall to checkout() â dev-oogl-backend's already-subscribed guard is 470 // the safety net for that edge. 471 var route = function(st) { 472 var isNewSub = !(st && st.subscription_id && st.status !== 'canceled'); 473 474 // The tier-change for this click, chosen by sub-state. Returns a 475 // promise so the Pro+ consent gate can chain it; both paths surface 476 // errors (the DEF-2 no-silent-path discipline). 477 var doTierChange = function() { 478 if (isNewSub) { 479 // New subscriber -> Paddle overlay via checkout() (oogl-sdk 1.5.0, 480 // async: checkout-intent SETNX round-trip -> Paddle, fail-closed). 481 if (!paddleConfigured) { 482 toast('Checkout not configured â /oogl/system/config did not return a billing.client_token and the lib/config.js fallback is empty', 'error'); 483 return Promise.resolve(); 484 } 485 if (!priceId) { 486 toast('Checkout isn\'t available right now â pricing configuration didn\'t load. Refresh and try again.', 'error'); 487 return Promise.resolve(); 488 } 489 activeCheckoutBtn = btn; activeCheckoutOrig = orig; 490 btn.disabled = true; btn.textContent = 'Opening checkoutâ¦'; 491 return sdkRef.billing.checkout(priceId, checkoutOpts()); 492 } 493 // Existing paid subscriber -> in-place proration. No Paddle overlay, 494 // no new sub. upgrade() emits billing.plan_changed -> refreshPlans + 495 // loadStatus. 496 // 497 // #21 money-guard parity: freeze the WHOLE grid, not just this 498 // button. The checkout path gets the guard for free (Paddle's 499 // overlay covers the page, and checkout_completed freezes for the 500 // webhook-lag window); this path has neither, so an un-frozen 501 // sibling tile is a live second money action. Kept frozen on 502 // success too â plan_changed -> refreshPlans re-renders the grid 503 // from server truth, which is what re-enables it; re-enabling here 504 // from stale client state would reopen the #21 repeat-purchase 505 // window. onErr unfreezes in place when nothing actually changed. 506 freezePlanActions('Upgradingâ¦'); 507 return sdkRef.billing.upgrade(newTier).then(function(res) { 508 if (res && res.checkout_url) { window.location.href = res.checkout_url; return; } 509 // #34: DO NOT claim success here. A 200 on the mor_update branch 510 // means the proration was ACCEPTED, not that it was PAID â the 511 // tier is granted later by the subscription.updated webhook, and 512 // a DECLINED proration lands as past_due with the tier applied 513 // and unpaid. Toasting "Plan updated to X" over that told the 514 // user it worked, which is worse than silence: being told it 515 // succeeded is exactly what removes any reason to check. 516 // 517 // The assertion was also REDUNDANT, which is why the fix costs no 518 // rendering code: upgrade() emits plan_changed -> refreshPlans + 519 // loadStatus, so PA re-reads server truth one tick later and 520 // loadStatus already renders past_due (banner + the #135/!136 521 // recovery states). The toast was asserting an outcome ONE TICK 522 // AHEAD OF THE READ THAT DETERMINES IT. 523 // 524 // Neutral register on purpose â the default toast is brand- 525 // emerald and reads affirmative whatever the words say. 526 toast('Applying your plan changeâ¦', 'info'); 527 }); 528 }; 529 530 var onErr = function(e) { 531 // Nothing silent (DEF-2). console.error carries the raw code so the 532 // money-flow re-run / QA can catch a recurring backend upgrade 533 // (session-creation) failure. 534 console.error('[plan] tier-change failed (' + (isNewSub ? 'checkout' : 'upgrade') + '):', e && (e.code || e.message), e); 535 resetActiveCheckoutBtn(); 536 unfreezePlanActions(); // no-op unless the upgrade() path froze the grid 537 btn.disabled = false; btn.textContent = orig; 538 var code = e && e.code; 539 if (code && code.indexOf('E401') === 0) { 540 toast('Please sign in again before subscribing.', 'error'); 541 } else if (code && code.indexOf('E422') === 0) { 542 toast('Couldn\'t record your agreement just now â please try again.', 'error'); 543 } else if (isNewSub) { 544 reportCheckoutError(e); 545 } else { 546 toast(formatSdkError(e, 'Couldn\'t change your plan just now â please try again.'), 'error'); 547 } 548 }; 549 550 // Pro+ ($49) is gated on the bundle-agreement consent BEFORE either 551 // path â pro_plus_agreement must be recorded ahead of checkout() AND 552 // upgrade(). DEF-2 robust flow preserved: purpose-built proPlusConsent 553 // modal + ONE outer .catch (nothing silent), step logs. 554 // #40: which consent (if any) this tier needs is the BACKEND's 555 // statement, read live â not a constant here and not the tier name. 556 var req; 557 try { 558 req = consentRequirementFor(newTier); 559 } catch (e) { 560 // Cannot determine the requirement. REFUSE â recording a consent 561 // with a guessed version is exactly what produces the silent 562 // permanent 403, and a blocked-but-loud buyer can retry while a 563 // wrongly-versioned one cannot. Same rule as the sub-state resolve 564 // below: never guess for a money action. 565 console.warn('[plan] consent requirement unresolved for', newTier, 'â refusing tier change'); 566 toast('Could not confirm the terms for this plan. Refresh and try again.', 'error'); 567 return; 568 } 569 if (req) { 570 proPlusConsent().then(function(agreed) { 571 if (!agreed) { 572 console.info('[plan] consent declined/dismissed â no tier change'); 573 return; 574 } 575 console.info('[plan] consent agreed â recording', req.type, req.version, '(from /oogl/billing/plans)'); 576 return sdkRef.user.recordConsent(req.type, req.version).then(function() { 577 console.info('[plan] consent recorded â ' + (isNewSub ? 'opening checkout' : 'upgrading in place')); 578 return doTierChange(); 579 }); 580 }).catch(onErr); 581 return; 582 } 583 // Backend requires no consent for this tier (e.g. Free->Pro today). 584 Promise.resolve().then(doTierChange).catch(onErr); 585 }; 586 587 // Resolve sub-state (cached from boot's loadStatus, else fetch on 588 // demand) BEFORE routing â never guess for a money action. 589 var resolveAndRoute = function() { 590 if (billingStatusLoaded) { 591 route(billingStatusRef); 592 } else if (sdkRef.billing.getStatus) { 593 btn.disabled = true; btn.textContent = 'Checking your planâ¦';
594 sdkRef.billing.getStatus().then(function(st) { 595 billingStatusRef = st; billingStatusLoaded = true; 596 btn.disabled = false; btn.textContent = orig; 597 route(st); 598 }).catch(function() { 599 btn.disabled = false; btn.textContent = orig; 600 route(null); 601 }); 602 } else { 603 route(null); 604 } 605 }; 606 // #40: the consent requirement is read from the live plans payload, so 607 // it must be resolved before routing for the same reason the sub-state 608 // is â route() decides a money action and must not guess either input. 609 // Normally already cached by refreshPlans() at load; this covers the 610 // case where that fetch failed or has not landed yet. 611 if (livePlansRef && livePlansRef.tiers) { 612 resolveAndRoute(); 613 } else if (sdkRef.billing.listPlans) { 614 btn.disabled = true; btn.textContent = 'Checking your planâ¦'; 615 sdkRef.billing.listPlans().then(function(p) { livePlansRef = p; }) 616 .catch(function() { /* leave null â consentRequirementFor throws, route refuses */ }) 617 .then(function() { btn.disabled = false; btn.textContent = orig; resolveAndRoute(); }); 618 } else { 619 resolveAndRoute(); 620 } 621 return; 622 } 623 624 // Leave-paid: the target tier has no Paddle price mapping, so 625 // /oogl/billing/upgrade cannot reach it â the backend resolves an empty 626 // price_id and returns 400 E400-BILL-9 before touching the subscription. 627 // The architecture reaches free ONLY through Paddle's cancel â 628 // subscription.canceled webhook â downgradeToFree. Route there. 629 // 630 // A click is rendered only for the two supported route classes. Priced 631 // downgrades are classified unavailable before a button exists because 632 // PA has no deferred-downgrade endpoint; /billing/upgrade immediately 633 // prorates and is therefore not a safe substitute. 634 if (action === 'cancel') { cancelSubscription(btn, orig); return; } 635 toast('This plan change is not available.', 'error'); 636 }); 637 }); 638 } 639 640 // ââ Billing: payment methods + transactions âââââââââââââââââââââ 641 function fmtMoney(amount, currency) { 642 if (amount == null) return 'â'; 643 var c = currency || 'USD'; 644 try { return new Intl.NumberFormat('en-US', { style:'currency', currency:c }).format(amount); } 645 catch(e) { return amount + ' ' + c; } 646 } 647 function fmtDate(s) { 648 if (!s) return 'â'; 649 var d = new Date(s); if (isNaN(d.getTime())) return 'â'; 650 return d.toLocaleDateString('en-US', { year:'numeric', month:'short', day:'numeric' }); 651 } 652 function pendingHTML(reason) { 653 return '<div class="pending"><strong>Coming soon.</strong> ' + esc(reason) + '</div>'; 654 } 655 656 function renderPaymentMethods(items) { 657 var c = document.getElementById('pm-content'); 658 var dot = document.getElementById('pm-dot'); 659 var state = window.E2LLM_PAYMENT_METHOD_STATE.classify(items); 660 if (state.kind === 'not_applicable') { 661 var st = billingStatusRef || {}; 662 var mu = st.management_urls || {}; 663 if (mu.update_payment) { 664 c.innerHTML = '<div class="pm"><div class="pm-info"><div>' + 665 '<div class="pm-brand">Payment method managed securely by Paddle</div>' + 666 '<div class="pm-detail">Use Paddle\'s hosted portal to update the card for this subscription.</div>' + 667 '</div></div><a class="btn-sm btn-primary" href="' + esc(mu.update_payment) + 668 '" target="_blank" rel="noopener">Update payment method</a></div>'; 669 dot.classList.remove('dot-grey'); dot.classList.add('dot-green'); 670 } else if (updatePaymentUnreachable(st)) { 671 c.innerHTML = '<div class="t-empty">We couldn\'t reach the billing provider to load your payment link. ' + RETRY_BTN + '</div>'; 672 dot.classList.remove('dot-green'); dot.classList.add('dot-grey'); 673 } else { 674 c.innerHTML = '<div class="t-empty">Payment methods are managed by Paddle. A hosted management link is not available for this subscription.</div>'; 675 dot.classList.remove('dot-green'); dot.classList.add('dot-grey'); 676 } 677 return; 678 } 679 items = state.items; 680 if (state.kind === 'empty') { 681 c.innerHTML = '<div class="t-empty">
681No payment methods on file â your card is captured securely at checkout when you subscribe.</div>'; 682 dot.classList.remove('dot-green'); dot.classList.add('dot-grey'); 683 return; 684 } 685 dot.classList.remove('dot-grey'); dot.classList.add('dot-green'); 686 c.innerHTML = items.map(function(p) { 687 var brand = (p.brand || 'card').toUpperCase(); 688 var last4 = p.last4 || 'â¢â¢â¢â¢'; 689 var exp = p.exp_month && p.exp_year ? p.exp_month + '/' + (p.exp_year % 100) : 'â'; 690 var def = p.is_default ? '<span class="pm-default">Default</span>' : ''; 691 return '<div class="pm">' + 692 '<div class="pm-info">' + 693 '<div>' + 694 '<div class="pm-brand">' + esc(brand) + ' ····' + esc(last4) + def + '</div>' + 695 '<div class="pm-detail">Expires ' + esc(exp) + (p.holder ? ' · ' + esc(p.holder) : '') + '</div>' + 696 '</div>' + 697 '</div>' + 698 '<button class="btn-sm btn-danger js-remove" data-id="' + esc(p.id) + '">Remove</button>' + 699 '</div>'; 700 }).join(''); 701 c.querySelectorAll('.js-remove').forEach(function(btn) { 702 btn.addEventListener('click', function() { 703 window.E2LLM.shell.confirmModal({ 704 title: 'Remove this payment method?', 705 message: 'Subscriptions billed to it will fail at the next renewal.', 706 confirmLabel: 'Remove', 707 danger: true, 708 }).then(function(ok) { 709 if (!ok) return; 710 sdkRef.billing.removePaymentMethod(btn.dataset.id).then(function() { 711 toast('Payment method removed'); 712 loadBilling(); 713 }).catch(function(err) { toast(formatSdkError(err, 'Remove failed'), 'error'); }); 714 }); 715 }); 716 }); 717 } 718 719 function renderTransactions(items) { 720 var c = document.getElementById('tx-content'); 721 if (!items || !items.length) { 722 c.innerHTML = '<div class="t-empty">No transactions yet.</div>'; 723 return; 724 } 725 c.innerHTML = '<table class="t"><thead><tr>' + 726 '<th>Date</th><th>Description</th><th>Status</th><th style="text-align:right">Amount</th><th class="col-actions">Invoice</th>' + 727 '</tr></thead><tbody>' + 728 items.map(function(t) { 729 var status = t.status === 'paid' ? '<span style="color:var(--mcp)">Paid</span>' : 730 t.status === 'pending' ? '<span style="color:var(--amber)">Pending</span>' : 731 t.status === 'failed' ? '<span style="color:var(--red)">Failed</span>' : esc(t.status || 'â'); 732 var inv = t.invoice_url ? '<a href="' + esc(t.invoice_url) + '" target="_blank" rel="noopener">Download</a>' : 733 t.invoice_id ? '<a href="#" class="js-invoice" data-id="' + esc(t.invoice_id) + '">View</a>' : 'â'; 734 return '<tr>' + 735 '<td>' + esc(fmtDate(t.date || t.created_at)) + '</td>' + 736 '<td>' + esc(t.description || 'â') + '</td>' + 737 '<td>' + status + '</td>' + 738 '<td style="text-align:right">' + esc(fmtMoney(t.amount, t.currency)) + '</td>' + 739 '<td class="col-actions">' + inv + '</td>' + 740 '</tr>'; 741 }).join('') + 742 '</tbody></table>'; 743 } 744 745 document.getElementById('btn-add-pm').addEventListener('click', function() { 746 // Settled architecture (oogl-backend#29): e2llm is a MoR product â 747 // there will NEVER be an in-PA card form. The token-POST behind 748 // sdk.billing.addPaymentMethod() is the direct-gateway surface 749 // (Tranzila-class products), not ours; calling it bodiless 400s 750 // (E400-BILL-3) deterministically, so no request is fired here. 751 // Initial capture happens IN the Paddle checkout overlay (the upgrade 752 // path on this page). Existing subscribers use the Paddle-hosted 753 // management_urls.update_payment redirect from billing status. Either 754 // E400-BILL-3 or E400-BILL-MOR-PM appearing here is a regression: this 755 // handler must never call the direct-gateway token-POST. 756 var st = billingStatusRef || {}; 757 var url = st.management_urls && st.management_urls.update_payment; 758 if (url) { 759 window.open(url, '_blank', 'noopener'); 760 } else if (updatePaymentUnreachable(st)) { 761 toast('Couldn\'t reach the billing provider to load your payment link â retry in a moment.', 'error'); 762 } else { 763 toast('Payment methods are added at checkout when you subscribe â a hosted management link is not available for this subscription'); 764 } 765 }); 766 767 // ââ Boot ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 768 var sdkRef = null; 769 var userRef = null; 770 var appConfigRef = null; 771 // Checkout button currently mid-flight (the Paddle overlay is modal, so at 772 // most one). The SDK billing event bus resets it on any terminal event. 773 var activeCheckoutBtn = null, activeCheckoutOrig = null; 774 var paddleConfigured = false;
775 // Last getStatus() result â the current-sub state (subscription_id + root 776 // .tier) that drives checkout-vs-upgrade routing so an existing subscriber is 777 // never mis-classed as new (which double-bills). Sourced from getStatus, NOT 778 // getPlan's tier: getPlan can under-report the tier under oogl-backend#82; 779 // getStatus is the reliable sibling. Only set on a clean load. 780 var billingStatusRef = null, billingStatusLoaded = false; 781 function resetActiveCheckoutBtn() { 782 if (activeCheckoutBtn) { 783 activeCheckoutBtn.disabled = false; 784 activeCheckoutBtn.textContent = activeCheckoutOrig; 785 activeCheckoutBtn = null; 786 } 787 } 788 // Touchpoint 1 (#10, updated for oogl-sdk 1.5.0 #61): sdk.billing.checkout() 789 // REJECTS (async) rather than send an unattributed / double-billing checkout. 790 // Map the billing error codes to guidance. E401/E422 = no attributable user; 791 // E502/E504-BILLING-INTENT-* = the fail-closed intent gate (backend /intent 792 // failed or didn't materialize a txn) â Paddle never opened, offer a retry. 793 function reportCheckoutError(e) { 794 var code = e && e.code; 795 if (code === 'E401-BILLING-NO-USER') { 796 toast('Please sign in again before upgrading.', 'error'); 797 } else if (code === 'E422-BILLING-NO-USERNAME') { 798 toast('Your session is missing account attribution â sign out and back in, then retry.', 'error'); 799 } else if (code === 'E400-BILLING-PADDLE-MISSING' || code === 'E400-BILLING-PRICE-MISSING') { 800 toast('Checkout isn\'t ready yet â refresh and try again.', 'error'); 801 } else if (code === 'E502-BILLING-INTENT-EMPTY' || code === 'E504-BILLING-INTENT-INIT') { 802 toast('Couldn\'t start checkout just now â please try again in a moment.', 'error'); 803 } else { 804 toast(formatSdkError(e, 'Could not open checkout'), 'error'); 805 } 806 } 807 function loadBilling() { 808 sdkRef.billing.getPaymentMethods().then(function(items) { 809 return sdkRef.billing.getStatus().then(function(st) { 810 billingStatusRef = st; 811 renderPaymentMethods(items); 812 document.getElementById('btn-add-pm').disabled = false; 813 }).catch(function() { 814 renderPaymentMethods(items); 815 document.getElementById('btn-add-pm').disabled = false; 816 }); 817 }).catch(function(err) { 818 var status = err && (err.httpStatus || err.status); 819 if (status === 404 || status === 501) { 820 document.getElementById('pm-content').innerHTML = pendingHTML('Payment-method management isn\'t available yet.'); 821 } else { 822 document.getElementById('pm-content').innerHTML = '<div class="t-empty">Couldn\'t load payment methods just now.</div>'; 823 } 824 }); 825 826 sdkRef.billing.getTransactions().then(renderTransactions).catch(function(err) { 827 var status = err && (err.httpStatus || err.status); 828 if (status === 404 || status === 501) { 829 document.getElementById('tx-content').innerHTML = pendingHTML('Transaction history isn\'t available yet.'); 830 } else renderTransactions([]); 831 }); 832 } 833 834 // ââ Subscription status: billing.getStatus â status display + cancel / 835 // update-payment (legs 3/4, #12) ââââââââââââââââââââââââââââââââââââââ 836 function statusPill(status) { 837 if (status === 'active') return '<span style="color:var(--mcp)">Active</span>'; 838 if (status === 'past_due') return '<span style="color:var(--amber)">Past due</span>'; 839 if (status === 'canceled') return '<span style="color:var(--fg3)">Canceled</span>'; 840 return esc(status || 'â'); 841 } 842 function renderBillingStatus(st) { 843 var c = document.getElementById('sub-content'); 844 var dot = document.getElementById('sub-dot'); 845 var mu = (st && st.management_urls) || {}; 846 var status = st && st.status; 847 var scheduled = window.E2LLM.classifyScheduledChange(st); 848 // Free / never-subscribed: no subscription_id (management_urls absent per the 849 // SDK contract). Point back to the Plan tab rather than show a dead card. 850 if (!st || !st.subscription_id || !status || st.tier === 'free') { 851 c.innerHTML = '<div class="t-empty">You\'re on the Free plan â no active paid subscription. Choose Pro or Pro+ from the Plan tab to subscribe.</div>'; 852 dot.className = 'dot dot-grey'; 853 return; 854 } 855 dot.className = 'dot ' + (status === 'active' ? 'dot-green' : status === 'past_due' ? 'dot-amber' : 'dot-grey'); 856 var detail = status === 'past_due' 857 ? 'Payment past due â your access continues during the grace peri
857od.' 858 : status === 'canceled' 859 ? 'Cancelled â access continues until the end of the paid period.' 860 : 'Your subscription is active and renews monthly.'; 861 if (scheduled.pending) { 862 var when = scheduled.effectiveAt ? ' on ' + fmtDate(scheduled.effectiveAt) : ''; 863 if (scheduled.action === 'cancel') { 864 detail = 'Cancellation scheduled' + when + ' â access continues until then.'; 865 } else if (scheduled.action === 'pause') { 866 detail = 'Subscription pause scheduled' + when + '.'; 867 } else if (scheduled.action === 'resume') { 868 detail = 'Subscription resumption scheduled' + when + '.'; 869 } else { 870 // Provider vocabulary is deliberately open. Preserve an unknown action 871 // visibly rather than treating a real pending change as no change. 872 detail = 'Pending subscription change (' + scheduled.action + ')' + when + '.'; 873 } 874 } 875 var actions = ''; 876 var unreachable = false; 877 if (mu.update_payment) { 878 actions += '<a class="btn-sm btn-primary" href="' + esc(mu.update_payment) + '" target="_blank" rel="noopener">Update payment method</a>'; 879 } else if (updatePaymentUnreachable(st)) { 880 // #135: named in _absence â the route exists, we just could not read it. 881 // Offer the retry rather than the silence that reads as "no route exists". 882 unreachable = true; 883 actions += RETRY_BTN; 884 } 885 if (mu.cancel && !(scheduled.pending && scheduled.action === 'cancel')) { 886 actions += '<a class="btn-sm btn-danger" href="' + esc(mu.cancel) + '" target="_blank" rel="noopener">Cancel subscription</a>'; 887 } 888 if (unreachable) detail += ' We couldn\'t reach the billing provider just now, so payment and cancellation links are missing â retry in a moment.'; 889 c.innerHTML = '<div class="pm">' + 890 '<div class="pm-info"><div>' + 891 '<div class="sub-status">' + esc(tierLabel(st.tier)) + ' · ' + statusPill(status) + '</div>' + 892 '<div class="pm-detail">' + detail + '</div>' + 893 '</div></div>' + 894 (actions ? '<div class="sub-actions">' + actions + '</div>' : '') + 895 '</div>'; 896 } 897 // ââ oogl-backend#135: tell "no recovery" apart from "couldn't reach Paddle" ââ 898 // 899 // `management_urls` is omitted best-effort when the live Paddle read fails, so 900 // BOTH failure states arrive as "update_payment is not here" and render 901 // identically today. A customer with a stale card then sees no route out at all 902 // â a dead end on a paid surface, which is the user-visible reason this exists. 903 // 904 // gf-sre ruled the omission axis travels with the container (e2llm-docs#43 note 905 // 23977): wherever `_absence` annotates a field, that field is ABSENT rather 906 // than `null`. So the two states differ ONLY by the annotation: 907 // absent + UNNAMED in the map -> no recovery available 908 // absent + NAMED in the map -> could not reach Paddle (transient; retry) 909 // 910 // â DO NOT reason about this by analogy with oogl-backend#153 â they are 911 // OPPOSITE and the analogy is the natural move. #153 is a CONVERSION 912 // (present-`null` -> absent) where a map-ignoring consumer LOSES a signal and 913 // bare absence asserts "not applicable", so a degraded read becomes a confident 914 // false claim. This is ADDITIVE: the field is already absent on the failure 915 // path, so the annotation takes nothing away and only adds a state. 916 // 917 // Fail-quiet by construction: with no `_absence` in the payload â every payload 918 // today â this returns null and the callers render exactly what they render now. 919 // Nothing degrades while the backend half is unshipped, and there is no 920 // sequencing constraint in either direction. 921 function absenceReason(obj, field) { 922 var a = obj && obj._absence; 923 if (!a || typeof a !== 'object') return null; 924 if (!Object.prototype.hasOwnProperty.call(a, field)) return null; 925 return a[field] || 'unavailable'; 926 } 927 // The container that would have carried update_payment is management_urls, so 928 // the annotation may sit on either level depending on which was omitted. Both 929 // are checked rather than asserting one â and the reason string is used ONLY as 930 // a discriminator, never rendered, so backend copy cannot reach the page. 931 function updatePaymentUnreachable(st) { 932 if (!st) return false;
933 return !!(absenceReason(st, 'management_urls') || 934 absenceReason(st.management_urls, 'update_payment')); 935 } 936 var RETRY_BTN = '<button class="btn-sm btn-manage js-retry-status" type="button">Retry</button>'; 937 938 // Leg 4: the page-top dunning banner. past_due = access PRESERVED 939 // (HOLD-but-active, NOT locked) â update_payment is the primary recovery CTA. 940 // Any other status clears it; never shown on a transient getStatus error. 941 function renderBillingBanner(st) { 942 var el = document.getElementById('billing-banner'); 943 var scheduled = window.E2LLM.classifyScheduledChange(st); 944 if (scheduled.pending) { 945 var when = scheduled.effectiveAt ? ' on ' + fmtDate(scheduled.effectiveAt) : ''; 946 var text = scheduled.action === 'cancel' 947 ? '<strong>Your subscription is scheduled to cancel' + when + '.</strong> Your paid access continues until then.' 948 : '<strong>A subscription change is scheduled' + when + '.</strong> Open Billing for its current status.'; 949 el.innerHTML = '<div class="banner-warn"><div class="bw-text">' + text + '</div></div>'; 950 return; 951 } 952 if (!st || st.status !== 'past_due') { el.innerHTML = ''; return; } 953 var mu = st.management_urls || {}; 954 // #135: past_due with no update_payment is the worst case on this surface â 955 // the customer must pay and has no route. Say WHICH of the two it is. 956 var unreachable = !mu.update_payment && updatePaymentUnreachable(st); 957 var cta = mu.update_payment 958 ? '<a class="btn-sm btn-primary" href="' + esc(mu.update_payment) + '" target="_blank" rel="noopener">Update payment method</a>' 959 : unreachable ? RETRY_BTN : ''; 960 var text = unreachable 961 ? '<strong>Your payment is past due.</strong> Your plan is still active during the grace period, but we couldn\'t reach the billing provider to load your payment link â retry in a moment.' 962 : '<strong>Your payment is past due.</strong> Your plan is still active during the grace period â update your payment method to avoid an interruption.'; 963 el.innerHTML = '<div class="banner-warn">' + 964 '<div class="bw-text">' + text + '</div>' + 965 cta + 966 '</div>'; 967 } 968 // Load status once; drives both the Billing-tab card and the page-top banner. 969 // A 404/501 (endpoint not live yet) degrades to a pending card + no banner. 970 function loadStatus() { 971 if (!sdkRef.billing.getStatus) { 972 document.getElementById('sub-content').innerHTML = pendingHTML('Subscription status isn\'t available yet.'); 973 return; 974 } 975 sdkRef.billing.getStatus().then(function(st) { 976 billingStatusRef = st; billingStatusLoaded = true; // cache for tier-change routing 977 renderBillingStatus(st); 978 renderBillingBanner(st); 979 }).catch(function(err) { 980 var status = err && (err.httpStatus || err.status); 981 if (status === 404 || status === 501) { 982 document.getElementById('sub-content').innerHTML = pendingHTML('Subscription status isn\'t available yet.'); 983 } else { 984 renderBillingStatus(null); // treat as no active subscription (display only) 985 } 986 // Leave billingStatusLoaded false on any error â the tier-change handler 987 // re-resolves sub-state on demand rather than route a money action on an 988 // unknown state. 989 document.getElementById('billing-banner').innerHTML = ''; // never a scary banner on a transient error 990 }); 991 } 992 993 // Re-fetch the current plan + all plans and re-render. Called on load and 994 // after a successful Paddle checkout (via the billing.plan_changed event). 995 function refreshPlans() { 996 var p1 = sdkRef.billing.getPlan().catch(function() { return null; }); 997 var p2 = sdkRef.billing.listPlans ? sdkRef.billing.listPlans().catch(function() { return null; }) : Promise.resolve(null); 998 // #40: cache the LIVE payload for the consent lookup. Deliberately separate 999 // from what renderPlans does with it â the consent version must never come 1000 // from defaultPlans(), which is a hand-maintained mirror and has drifted (#38). 1001 Promise.all([p1, p2]).then(function(rs) { livePlansRef = rs[1]; renderPlans(rs[0], rs[1]); }); 1002 } 1003 1004 // Freeze the plan-grid switch buttons after a checkout completes â the 1005 // money-exposure guard for the webhook-lag window (plan_changed then 1006 // re-renders + re-enables via refreshPlans). 1007 // 1008 // Also the guard for the in-place upgrade() path (#19): an existing-subscriber 1009 // tier change is a money action with NO Paddle overlay covering the page, so 1010 // disabling only the clicked button leaves every sibling tile one click away 1011 // from a second money action while the first is in flight. Each button's 1012 // pre-freeze label is stashed so a tier change that never completed can be 1013 // undone in place (unfreezePlanActions) instead of paying a re-render 1014 // round-trip; the success path deliberately stays frozen until refreshPlans 1015 // re-renders from server truth. 1016 function freezePlanActions(label) { 1017 document.querySelectorAll('#plans-grid .js-switch').forEach(function(b) { 1018 if (b.dataset.frozenLabel === undefined) { b.dataset.frozenLabel = b.textContent; } 1019 b.disabled = true; 1020 b.textContent = label || 'Processingâ¦'; 1021 }); 1022 } 1023 1024 // Undo freezePlanActions in place â for a tier change that FAILED, where the 1025 // grid on screen is still accurate (nothing changed server-side) and the user 1026 // needs the buttons back to retry. Never call this on success: a still-live 1027 // grid over a not-yet-refreshed plan is exactly the #21 repeat-purchase 1028 // window, which refreshPlans (via plan_changed) closes by re-rendering. 1029 // Restores ONLY what freezePlanActions stashed, so calling it on a path that 1030 // never froze (the checkout branch) touches nothing at all. 1031 function unfreezePlanActions() { 1032 document.querySelectorAll('#plans-grid .js-switch').forEach(function(b) { 1033 if (b.dataset.frozenLabel === undefined) return; 1034 b.textContent = b.dataset.frozenLabel; 1035 delete b.dataset.frozenLabel; 1036 b.disabled = false;
1037 }); 1038 } 1039 1040 window.E2LLM.ready(function(ctx) { 1041 sdkRef = ctx.sdk; 1042 userRef = ctx.user || null; 1043 appConfigRef = ctx.appConfig || null; 1044 1045 // Wire the SDK billing event bus (oogl-sdk 1.3.0) â checkout UI. The helper 1046 // bridges Paddle's lifecycle onto these channels; on completion it runs a 1047 // bounded plan reconcile that re-emits billing.plan_changed once the async 1048 // webhook write lands. 1049 sdkRef.on('billing.checkout_closed', resetActiveCheckoutBtn); 1050 sdkRef.on('billing.checkout_error', resetActiveCheckoutBtn); 1051 sdkRef.on('billing.checkout_completed', function() { 1052 resetActiveCheckoutBtn(); 1053 // Money-exposure guard: a completed checkout = a purchase in flight; freeze 1054 // the plan-grid upgrade buttons until plan_changed confirms the new tier, 1055 // so the webhook-lag window can't be repeat-purchased (a stale 1056 // still-Upgradeable grid let 7 Pro bundles buy before the plan refreshed). 1057 freezePlanActions('Upgrade processingâ¦'); 1058 }); 1059 sdkRef.on('billing.plan_changed', function() { refreshPlans(); loadStatus(); }); // touchpoint 2 (#10) + status refresh (#12) 1060 // #135 retry: delegated, because the button is re-rendered by loadStatus() 1061 // itself and a direct binding would be lost on the first re-render. 1062 document.addEventListener('click', function(e) { 1063 var b = e.target && e.target.closest && e.target.closest('.js-retry-status'); 1064 if (!b) return; 1065 b.disabled = true; b.textContent = 'Retryingâ¦'; 1066 loadStatus(); // re-renders both the card and the banner from fresh status 1067 }); 1068 1069 // Bootstrap Paddle.js via the SDK helper (Environment-before-Initialize + 1070 // token-only, owned by initPaddle). Prefer the backend /oogl/system/config 1071 // billing block; fall back to the lib/config.js placeholders for local dev. 1072 var _cfg = window.E2LLM_CONFIG || {}; 1073 var _backendPaddle = appConfigRef && appConfigRef.billing && appConfigRef.billing.gateway === 'paddle' ? appConfigRef.billing : null; 1074 var _billing = _backendPaddle || (_cfg.paddleClientToken ? { client_token: _cfg.paddleClientToken, environment: _cfg.paddleEnvironment } : null); 1075 if (_billing && _billing.client_token) { 1076 paddleConfigured = true; 1077 // If Paddle.js failed to load, initPaddle rejects here â leave 1078 // paddleConfigured true so a click surfaces the precise 1079 // E400-BILLING-PADDLE-MISSING via reportCheckoutError, not the generic 1080 // "not configured" guard (which would be misleading: config was fine). 1081 sdkRef.billing.initPaddle(_billing).catch(function(e) { 1082 console.warn('plan: Paddle init failed:', e && (e.code || e.message)); 1083 }); 1084 } 1085 1086 refreshPlans(); 1087
1088 ctx.sdk.mcp.getUsage('day').then(renderUsage).catch(function(err) { 1089 // pending (404/501) = not built yet; anything else = it exists and failed. 1090 // Never renderUsage(null) here â that is the fabricated zero (#41). 1091 if (err && err.pending) renderUsagePending(); else renderUsageUnavailable(); 1092 }); 1093 1094 loadBilling(); 1095 loadStatus(); 1096 }); 1097})(); 1098</script>
1098 1099 1100</body> 1101</html>
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.