PageSourceSearch

https://account.e2llm.com/plan/?tier=pro

html e2llm.com collected 2026-10-01 15:40:03 UTC 62,797 bytes, 1,101 lines download raw bytes

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 &amp; 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 &amp; Billing</div>
56    <h1>Plan &amp; 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 &amp; 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.