read.markets/app/static/js/settings-import.js
Giorgio Gilestro 47dce1a1a4 compliance: flag-gate AI portfolio + cloud sync + Stripe; de-risk prompts; harden reviewer
Implements docs/read-markets-compliance-changes.md as flag-gated changes
(no deletions) so paused features stay in the tree for future re-enable.
All four flags default False so a fresh deploy is compliance-safe.

- New env flags: PORTFOLIO_AI_ENABLED, PORTFOLIO_SYNC_ENABLED,
  TICKER_UNIVERSE_AGGREGATE_ENABLED, SUBSCRIPTIONS_ENABLED.
- Gates: /api/analyze, /api/portfolio/sync*, /api/stripe/*, /pricing,
  ticker_universe writes, portfolio_analysis.analyse(). is_paid_active()
  returns True for any auth'd user when subscriptions are paused.
- Prompts (PROMPT_VERSION 10): universal _COMPLIANCE_RIDER prepended to
  every system prompt; watch list removed; price-target / close-above-below
  / trigger / forward-state-as-description rules added; SPECULATIVE
  pivoted to regime-only scenarios; daily + weekly digests tightened.
- Reviewer: deterministic regex/lexicon pre-check fail-closed under the
  Haiku call; portfolio rider gated by PORTFOLIO_AI_ENABLED; base prompt
  sharpened for forward-state and MAR forward-opinion patterns;
  ReviewerVerdict audit table; generate_with_review retry helper.
- Migration 0026: purge portfolio_sync + ticker_universe; create
  reviewer_verdicts.
- Copy: MAR cite fixed to Art 3(1)(35) + Art 20 + Del Reg 2016/958;
  portfolio reframed as browser-only viewer in disclaimer / privacy /
  terms / about / pricing / landing (en + it). TODO(legal) marker for
  lawyer sign-off on disclaimer.
- Tests: 13 lexicon + 6 reviewer compliance regressions; conftest enables
  all flags so existing 402 tests still cover their code paths.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 19:57:12 +02:00

254 lines
9.8 KiB
JavaScript

(function () {
'use strict';
// Server-side hint: did the user have paid privileges when the page
// rendered? Used to decide whether to offer the 'Import & sync' button.
// We still call CassandraSync.getStatus() at click time as the source
// of truth, but this lets us skip rendering a button we know is dead.
// Value is passed via data-paid attribute on #drop-zone.
function ready(fn) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', fn);
} else { fn(); }
}
ready(function () {
var P = window.CassandraPortfolio;
if (!P) return;
var esc = P.esc, fmt = P.fmt, signed = P.signed, cls = P.cls;
var dropZone = document.getElementById('drop-zone');
var fileInput = document.getElementById('file-input');
var browseLink = document.getElementById('browse-link');
var filenameEl = document.getElementById('dz-filename');
var previewEl = document.getElementById('import-preview');
var resultEl = document.getElementById('import-result');
if (!dropZone) return;
var IS_PAID = dropZone.dataset.paid === 'true';
// PORTFOLIO_SYNC_ENABLED gate — when off, the sync route 404s and the
// 'Import & sync to cloud' option must be hidden entirely (a button
// that errors on click is worse than no button).
var SYNC_ENABLED = dropZone.dataset.syncEnabled === 'true';
var currentPie = null; // most recently parsed pie, awaiting commit
function showError(msg) {
previewEl.hidden = true;
resultEl.className = 'result result--err';
resultEl.innerHTML =
'<div class="result__head">✕ Import failed</div>' +
'<div class="result__row">' + esc(msg) + '</div>';
resultEl.hidden = false;
}
function showSuccess(headline, sub) {
previewEl.hidden = true;
resultEl.className = 'result result--ok';
resultEl.innerHTML =
'<div class="result__head">' + esc(headline) + '</div>' +
(sub ? '<div class="result__row">' + sub + '</div>' : '') +
'<div class="result__row" style="margin-top:14px;">' +
'<a href="/">Open dashboard →</a>' +
'</div>';
resultEl.hidden = false;
}
function renderPreview(pie) {
currentPie = pie;
resultEl.hidden = true;
var t = pie.totals || {};
var rows = (pie.positions || []).map(function (p) {
var invested = (p.avg_cost != null && p.qty != null) ? p.avg_cost * p.qty : null;
return '<tr>' +
'<td class="label">' + esc(p.yahoo_ticker || p.t212_slice || '') + '</td>' +
'<td>' + esc(p.name || '') + '</td>' +
'<td class="num">' + fmt(p.qty, { maximumFractionDigits: 6 }) + '</td>' +
'<td class="num neu">' + fmt(p.avg_cost) + '</td>' +
'<td class="num">' + fmt(invested) + '</td>' +
'</tr>';
}).join('');
var warnings = (pie.warnings || []).map(function (w) {
return '<div class="result__warn">' + esc(w) + '</div>';
}).join('');
var syncBtn;
if (!SYNC_ENABLED) {
syncBtn = '';
} else if (IS_PAID) {
syncBtn = '<div class="import-choice">' +
'<button type="button" id="commit-sync">Import &amp; sync to cloud</button>' +
'<div class="settings-row__hint">' +
'Also stores an <strong>encrypted</strong> copy on the server, ' +
'restorable on any device with your PIN. Only you can decrypt ' +
'it &mdash; losing the PIN means losing the backup.' +
'</div>' +
'</div>';
} else {
syncBtn = '<div class="import-choice">' +
'<button type="button" disabled>Import &amp; sync to cloud</button>' +
'<div class="settings-row__hint">' +
'Encrypted cloud backup is available on the paid tier.' +
'</div>' +
'</div>';
}
previewEl.innerHTML =
'<div class="result result--ok" style="margin:0;">' +
'<div class="result__head">' +
'▸ Preview: <strong>' + esc(pie.pie_name || 'pie') + '</strong>' +
'</div>' +
'<div class="result__grid">' +
'<div><div class="k">Positions</div><div class="v">' + (pie.positions || []).length + '</div></div>' +
'<div><div class="k">Invested</div><div class="v">' + fmt(t.invested) + '</div></div>' +
'<div><div class="k">Value</div><div class="v">' + fmt(t.value) + '</div></div>' +
'<div><div class="k">Result</div><div class="v ' + cls(t.result) + '">' + signed(t.result) + '</div></div>' +
'</div>' +
warnings +
(rows
? '<div style="max-height:280px;overflow:auto;margin-top:12px;">' +
'<table class="dense">' +
'<thead><tr>' +
'<th>Ticker</th><th>Name</th>' +
'<th class="num">Qty</th>' +
'<th class="num">Avg</th>' +
'<th class="num">Invested</th>' +
'</tr></thead>' +
'<tbody>' + rows + '</tbody>' +
'</table>' +
'</div>'
: ''
) +
'<div class="import-actions">' +
'<div class="import-choice">' +
'<button type="button" id="commit-local">Import to this browser</button>' +
'<div class="settings-row__hint">' +
'Saved to this browser only. No server-side copy of your holdings.' +
'</div>' +
'</div>' +
syncBtn +
'<div style="flex-basis:100%;">' +
'<button type="button" id="commit-cancel" class="pf-secondary">' +
'Cancel</button>' +
'</div>' +
'</div>' +
'</div>';
previewEl.hidden = false;
document.getElementById('commit-local').addEventListener('click', commitLocal);
document.getElementById('commit-cancel').addEventListener('click', resetUploader);
var syncEl = document.getElementById('commit-sync');
if (syncEl) syncEl.addEventListener('click', commitSync);
}
function commitLocal() {
if (!currentPie) return;
P.savePie(currentPie);
showSuccess('▸ Imported to this browser.',
'Pie kept locally; no server-side copy.');
currentPie = null;
}
async function commitSync() {
if (!currentPie) return;
// Save locally first so the cloud-sync flow uses the freshly-imported
// pie (the enable-PIN modal in this same page reads from localStorage).
P.savePie(currentPie);
var S = window.CassandraSync;
if (!S) { showError('Cloud sync module not loaded.'); return; }
var status;
try { status = await S.getStatus(); }
catch (e) { showError('Could not check sync status: ' + (e.message || e)); return; }
if (!status.paid) {
showError('Cloud sync requires the paid tier.');
return;
}
if (status.exists) {
// Already enabled — try a direct push using the cached session
// key. If no key is cached (fresh browser session), this throws,
// and we fall back to the enable-PIN modal so the user can
// re-enter their PIN.
try {
await S.pushSync(currentPie, null);
showSuccess('▸ Imported and synced.',
'Encrypted copy updated on the server.');
currentPie = null;
if (window.cassandraRefreshSyncStatus) window.cassandraRefreshSyncStatus();
return;
} catch (e) {
// Fall through to modal so the user can re-auth with their PIN.
console.warn('direct push failed, falling back to PIN modal', e);
}
}
// !status.exists OR cached-key push failed → use the modal.
if (window.cassandraOpenSyncModal) {
window.cassandraOpenSyncModal({
onSuccess: function () {
showSuccess('▸ Imported and synced.',
'Cloud sync is now enabled and the pie is stored encrypted.');
currentPie = null;
},
});
} else {
showError('Cloud sync UI unavailable on this page. ' +
'Use the Cloud sync section below to enable.');
}
}
function resetUploader() {
currentPie = null;
previewEl.hidden = true;
previewEl.innerHTML = '';
resultEl.hidden = true;
filenameEl.textContent = '';
fileInput.value = '';
}
async function parseFile(file) {
filenameEl.textContent = file.name + ' (' + Math.round(file.size / 1024) + ' KB) — parsing…';
previewEl.hidden = true;
resultEl.hidden = true;
try {
var pie = await P.parseCsv(file);
renderPreview(pie);
filenameEl.textContent = file.name + ' (' + Math.round(file.size / 1024) + ' KB)';
} catch (e) {
filenameEl.textContent = file.name + ' (failed)';
showError(e.message || 'Unknown error');
}
}
browseLink.addEventListener('click', function (e) { e.preventDefault(); fileInput.click(); });
fileInput.addEventListener('change', function () {
if (fileInput.files[0]) parseFile(fileInput.files[0]);
});
['dragenter', 'dragover'].forEach(function (ev) {
dropZone.addEventListener(ev, function (e) {
e.preventDefault(); e.stopPropagation();
dropZone.classList.add('dz--over');
});
});
['dragleave', 'drop'].forEach(function (ev) {
dropZone.addEventListener(ev, function (e) {
e.preventDefault(); e.stopPropagation();
dropZone.classList.remove('dz--over');
});
});
dropZone.addEventListener('drop', function (e) {
var f = e.dataTransfer.files && e.dataTransfer.files[0];
if (f) parseFile(f);
});
dropZone.addEventListener('click', function (e) {
if (e.target.tagName !== 'A') fileInput.click();
});
});
})();