E2E #11: the account picker's fiscal-year default sent a 365-day request for a 405-day-old fiscal year; Swedbank answered by TERMINATING the session: zero transactions, connection expired, no error surfaced (initial_sync_requested_from 2025-08-26, returned min/max null). 90 days worked. - Default lookback mode is now 'fast' (90 days), labeled rekommenderas on a first connect; the fiscal-year option stays but carries an inline 'vissa banker avbryter kopplingen' note when its span exceeds 90 days, and the long-range helper names the real failure mode + the SIE/CSV path for older history. - SIE drop card: stage + arm in ONE click (three clicks was one too many): after the verdict, the single button reads 'Bokför: oåterkalleligt (BFL 5 kap 5 §)' and the deliberate click commits with confirmed=true. - Skill: never re-ask an answered question; when a connect card rendered, do not paste the URL as text too. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
324 lines
14 KiB
TypeScript
324 lines
14 KiB
TypeScript
import type { UiWidget } from './types'
|
||
|
||
/**
|
||
* SIE Drop Widget: MCP Apps inline HTML rendered by gnubok_create_sie_upload.
|
||
* The user drags their .se/.sie export onto the card; the widget reads the
|
||
* EXACT bytes itself (FileReader), computes sha256, and passes them through
|
||
* `tools/call` as file_content_base64: no model in the byte path, so a
|
||
* 100 KB+ file imports without token-by-token reproduction risk. Flow:
|
||
* drop → gnubok_sie_preflight (verdict shown in the card) → user clicks
|
||
* Importera → gnubok_import_sie with the preflight's mappings (stages for
|
||
* approval as always). The tool's upload_url stays available as a fallback
|
||
* for hosts without the widget.
|
||
*/
|
||
|
||
export const SIE_DROP_HTML = `<!DOCTYPE html>
|
||
<html lang="sv">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>SIE-import - Accounted</title>
|
||
<style>
|
||
:root {
|
||
--bg: #fafafa;
|
||
--surface: #ffffff;
|
||
--border: rgba(0,0,0,0.1);
|
||
--text: #1a1a1a;
|
||
--text-muted: #6b6b6b;
|
||
--success: #5a7a5a;
|
||
--success-bg: rgba(90,122,90,0.08);
|
||
--error: #b35a3a;
|
||
--error-bg: rgba(179,90,58,0.08);
|
||
--accent: #1a1a1a;
|
||
--accent-text: #ffffff;
|
||
--drop-bg: rgba(0,0,0,0.03);
|
||
--drop-active: rgba(90,122,90,0.12);
|
||
}
|
||
.dark {
|
||
--bg: #161616;
|
||
--surface: #1e1e1e;
|
||
--border: rgba(255,255,255,0.1);
|
||
--text: #e5e5e5;
|
||
--text-muted: #999;
|
||
--success: #7aab7a;
|
||
--success-bg: rgba(122,171,122,0.1);
|
||
--error: #d4816a;
|
||
--error-bg: rgba(212,129,106,0.1);
|
||
--accent: #e5e5e5;
|
||
--accent-text: #161616;
|
||
--drop-bg: rgba(255,255,255,0.03);
|
||
--drop-active: rgba(122,171,122,0.12);
|
||
}
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
body {
|
||
font-family: system-ui, -apple-system, sans-serif;
|
||
background: var(--bg); color: var(--text);
|
||
font-size: 13px; line-height: 1.5; padding: 12px;
|
||
}
|
||
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 16px; max-width: 520px; }
|
||
h1 { font-size: 15px; font-weight: 600; margin-bottom: 4px; }
|
||
.lede { color: var(--text-muted); margin-bottom: 12px; }
|
||
.drop {
|
||
border: 2px dashed var(--border); border-radius: 8px;
|
||
background: var(--drop-bg); padding: 28px 16px; text-align: center;
|
||
color: var(--text-muted); cursor: pointer; transition: background 150ms, border-color 150ms;
|
||
}
|
||
.drop.active { background: var(--drop-active); border-color: var(--success); }
|
||
.hidden { display: none; }
|
||
.report { border-top: 1px solid var(--border); margin-top: 12px; padding-top: 12px; }
|
||
.row { display: flex; justify-content: space-between; gap: 12px; padding: 2px 0; }
|
||
.row .k { color: var(--text-muted); }
|
||
.row .v { font-variant-numeric: tabular-nums; text-align: right; }
|
||
.status { display: inline-block; font-size: 12px; font-weight: 500; border-radius: 999px; padding: 2px 10px; margin-bottom: 8px; }
|
||
.status.ok { color: var(--success); background: var(--success-bg); }
|
||
.status.bad { color: var(--error); background: var(--error-bg); }
|
||
.warnings { margin-top: 8px; color: var(--error); font-size: 12px; }
|
||
.actions { display: flex; gap: 8px; margin-top: 12px; }
|
||
button {
|
||
font: inherit; font-weight: 500; cursor: pointer; border-radius: 6px;
|
||
padding: 8px 14px; border: 1px solid var(--border);
|
||
background: var(--surface); color: var(--text);
|
||
}
|
||
button.primary { background: var(--accent); color: var(--accent-text); border-color: var(--accent); }
|
||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||
button:focus-visible { outline: 2px solid var(--success); outline-offset: 2px; }
|
||
.note { margin-top: 10px; color: var(--text-muted); font-size: 12px; }
|
||
input[type="file"] { display: none; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="card">
|
||
<h1>Importera bokföring (SIE)</h1>
|
||
<p class="lede">Släpp SIE-filen här så kontrolleras den innan något bokförs.</p>
|
||
<div class="drop" id="drop">Släpp .se/.sie-filen här, eller klicka för att välja</div>
|
||
<input type="file" id="picker" accept=".se,.sie,.si" />
|
||
<div class="report hidden" id="report"></div>
|
||
<div class="actions hidden" id="actions">
|
||
<button class="primary" id="import">Importera</button>
|
||
</div>
|
||
<p class="note hidden" id="note"></p>
|
||
</div>
|
||
|
||
<script>
|
||
(function() {
|
||
// ── MCP Apps Bridge ──
|
||
let rpcId = 1;
|
||
const pending = new Map();
|
||
let fileState = null; // { name, base64, sha256, preflight }
|
||
|
||
function sendRequest(method, params) {
|
||
const id = rpcId++;
|
||
return new Promise((resolve, reject) => {
|
||
pending.set(id, { resolve, reject });
|
||
window.parent.postMessage({ jsonrpc: '2.0', id, method, params }, '*');
|
||
});
|
||
}
|
||
function sendNotification(method, params) {
|
||
window.parent.postMessage({ jsonrpc: '2.0', method, params }, '*');
|
||
}
|
||
function callTool(name, args) {
|
||
return sendRequest('tools/call', { name: name, arguments: args });
|
||
}
|
||
window.addEventListener('message', function(e) {
|
||
const msg = e.data;
|
||
if (!msg || msg.jsonrpc !== '2.0') return;
|
||
if (msg.id != null && pending.has(msg.id)) {
|
||
const { resolve, reject } = pending.get(msg.id);
|
||
pending.delete(msg.id);
|
||
if (msg.error) reject(msg.error); else resolve(msg.result);
|
||
return;
|
||
}
|
||
if (msg.method === 'ui/notifications/host-context-changed') applyTheme(msg.params);
|
||
});
|
||
function applyTheme(ctx) {
|
||
if (!ctx) return;
|
||
if (ctx.theme === 'dark') document.documentElement.classList.add('dark');
|
||
else document.documentElement.classList.remove('dark');
|
||
}
|
||
sendRequest('ui/initialize', { name: 'gnubok-sie-drop', version: '1.0.0' })
|
||
.then(function(res) {
|
||
if (res && res.hostContext) applyTheme(res.hostContext);
|
||
sendNotification('ui/notifications/initialized');
|
||
})
|
||
.catch(function() { sendNotification('ui/notifications/initialized'); });
|
||
|
||
// ── Helpers ──
|
||
function el(id) { return document.getElementById(id); }
|
||
function show(id) { el(id).classList.remove('hidden'); }
|
||
function hide(id) { el(id).classList.add('hidden'); }
|
||
function note(text) { el('note').textContent = text; show('note'); }
|
||
|
||
function toBase64(buffer) {
|
||
const u8 = new Uint8Array(buffer);
|
||
let s = '';
|
||
const CHUNK = 0x8000;
|
||
for (let i = 0; i < u8.length; i += CHUNK) {
|
||
s += String.fromCharCode.apply(null, u8.subarray(i, i + CHUNK));
|
||
}
|
||
return btoa(s);
|
||
}
|
||
function toHex(buffer) {
|
||
return Array.from(new Uint8Array(buffer)).map(function(b) { return b.toString(16).padStart(2, '0'); }).join('');
|
||
}
|
||
function parseResult(res) {
|
||
if (res && res.structuredContent) return res.structuredContent;
|
||
try { return JSON.parse(res.content[0].text); } catch (e) { return null; }
|
||
}
|
||
|
||
// ── Drop handling ──
|
||
const drop = el('drop');
|
||
drop.addEventListener('click', function() { el('picker').click(); });
|
||
el('picker').addEventListener('change', function(e) {
|
||
if (e.target.files && e.target.files[0]) handleFile(e.target.files[0]);
|
||
});
|
||
;['dragover', 'dragenter'].forEach(function(ev) {
|
||
drop.addEventListener(ev, function(e) { e.preventDefault(); drop.classList.add('active'); });
|
||
});
|
||
;['dragleave', 'drop'].forEach(function(ev) {
|
||
drop.addEventListener(ev, function(e) { e.preventDefault(); drop.classList.remove('active'); });
|
||
});
|
||
drop.addEventListener('drop', function(e) {
|
||
const file = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
|
||
if (file) handleFile(file);
|
||
});
|
||
|
||
function handleFile(file) {
|
||
const lower = file.name.toLowerCase();
|
||
if (!lower.endsWith('.se') && !lower.endsWith('.sie') && !lower.endsWith('.si')) {
|
||
note('Filen måste vara en SIE-export (.se eller .sie).');
|
||
return;
|
||
}
|
||
drop.textContent = 'Kontrollerar ' + file.name + '…';
|
||
const reader = new FileReader();
|
||
reader.onload = function() {
|
||
const buffer = reader.result;
|
||
crypto.subtle.digest('SHA-256', buffer).then(function(hash) {
|
||
const base64 = toBase64(buffer);
|
||
const sha256 = toHex(hash);
|
||
fileState = { name: file.name, base64: base64, sha256: sha256, preflight: null };
|
||
sendNotification('ui/updateContext', {
|
||
content: 'Användaren släppte ' + file.name + ' på importkortet; preflight körs med exakta bytes (sha256 ' + sha256.slice(0, 12) + '…).'
|
||
});
|
||
callTool('gnubok_sie_preflight', {
|
||
filename: file.name,
|
||
file_content_base64: base64,
|
||
sha256: sha256
|
||
}).then(function(res) {
|
||
const sc = parseResult(res);
|
||
if (!sc) { drop.textContent = 'Kunde inte läsa svaret. Försök igen.'; return; }
|
||
fileState.preflight = sc;
|
||
renderReport(sc);
|
||
}).catch(function(err) {
|
||
drop.textContent = 'Preflight misslyckades: ' + (err && err.message ? err.message : 'okänt fel');
|
||
});
|
||
}).catch(function() {
|
||
note('Kunde inte beräkna filens kontrollsumma i denna miljö. Ladda upp via app.accounted.se/import?mode=sie i stället.');
|
||
});
|
||
};
|
||
reader.readAsArrayBuffer(file);
|
||
}
|
||
|
||
function renderReport(sc) {
|
||
drop.textContent = fileState.name;
|
||
const ok = sc.verdict === 'ok' || sc.verdict === 'ok_with_warnings';
|
||
const f = sc.file || {};
|
||
const rows = [
|
||
['Företag', (f.company_name || '?') + ' (' + (f.org_number || '?') + ')'],
|
||
['Räkenskapsår', ((f.fiscal_year || {}).start || '?') + ' – ' + ((f.fiscal_year || {}).end || '?')],
|
||
['Verifikat', String(f.voucher_count != null ? f.voucher_count : '?')],
|
||
['Konton', String(f.account_count != null ? f.account_count : '?')],
|
||
];
|
||
let html = '<span class="status ' + (ok ? 'ok' : 'bad') + '">' +
|
||
(sc.verdict === 'ok' ? 'Ser korrekt ut'
|
||
: sc.verdict === 'ok_with_warnings' ? 'OK med anmärkningar'
|
||
: sc.verdict === 'duplicate' ? 'Redan importerad'
|
||
: 'Ogiltig fil') + '</span>';
|
||
rows.forEach(function(r) {
|
||
html += '<div class="row"><span class="k">' + r[0] + '</span><span class="v">' + r[1] + '</span></div>';
|
||
});
|
||
const warnings = ((sc.validation || {}).warnings || []).concat((sc.validation || {}).errors || []);
|
||
if (warnings.length > 0) {
|
||
html += '<div class="warnings">' + warnings.slice(0, 3).map(function(w) { return '• ' + w; }).join('<br>') + '</div>';
|
||
}
|
||
el('report').innerHTML = html;
|
||
show('report');
|
||
if (ok) { show('actions'); el('import').disabled = false; }
|
||
else hide('actions');
|
||
}
|
||
|
||
// After staging, the same button becomes the approval: first click arms
|
||
// the BFL acknowledgment, second click commits (confirmed=true for the
|
||
// high-risk import). The human click IS the acknowledgment, exactly like
|
||
// the pending-operations widget. Keeping approval in the card closes the
|
||
// loop that previously stranded the staged import until the user prodded
|
||
// the agent ("kolla igen", E2E #10).
|
||
let stagedOp = null; // { id, risk, armed }
|
||
|
||
el('import').addEventListener('click', function() {
|
||
if (stagedOp) { approveStaged(); return; }
|
||
if (!fileState || !fileState.preflight) return;
|
||
el('import').disabled = true;
|
||
el('import').textContent = 'Importerar…';
|
||
callTool('gnubok_import_sie', {
|
||
filename: fileState.name,
|
||
file_content_base64: fileState.base64,
|
||
sha256: fileState.sha256,
|
||
mappings: fileState.preflight.mappings || [],
|
||
create_fiscal_period: true,
|
||
import_opening_balances: true,
|
||
import_transactions: true
|
||
}).then(function(res) {
|
||
const sc = parseResult(res);
|
||
if (sc && sc.operation_id) {
|
||
// Stage + arm in ONE step: the next click is already the deliberate
|
||
// BFL acknowledgment (three clicks was one too many, E2E #11). The
|
||
// irreversibility text on the button IS the surfacing.
|
||
stagedOp = { id: sc.operation_id, risk: sc.risk_level || 'high', armed: true };
|
||
el('import').disabled = false;
|
||
el('import').textContent = 'Bokför: oåterkalleligt (BFL 5 kap 5 §)';
|
||
note('Importen är förberedd. Bokförda verifikat kan inte raderas, endast rättas med storno. Klicka för att bokföra, eller säg till i chatten.');
|
||
} else {
|
||
el('import').textContent = 'Import förberedd';
|
||
note('Importen är förberedd och väntar på godkännande i chatten eller i Accounted.');
|
||
}
|
||
sendNotification('ui/updateContext', {
|
||
content: 'SIE-importen av ' + fileState.name + ' är stagead' + (sc && sc.operation_id ? ' (operation ' + sc.operation_id + ')' : '') + ' och väntar på godkännande i importkortet.'
|
||
});
|
||
}).catch(function(err) {
|
||
el('import').disabled = false;
|
||
el('import').textContent = 'Importera';
|
||
note('Import misslyckades: ' + (err && err.message ? err.message : 'okänt fel'));
|
||
});
|
||
});
|
||
|
||
function approveStaged() {
|
||
el('import').disabled = true;
|
||
el('import').textContent = 'Bokför…';
|
||
const args = { operation_id: stagedOp.id };
|
||
if (stagedOp.risk === 'high') args.confirmed = true;
|
||
callTool('gnubok_approve_pending_operation', args).then(function() {
|
||
el('import').textContent = 'Bokfört';
|
||
note('Importen är godkänd och bokförd. Fortsätt i chatten: verifiera med råbalansen och förklara eventuella verifikatluckor.');
|
||
sendNotification('ui/updateContext', {
|
||
content: 'SIE-importen är GODKÄND och bokförd via importkortet (operation ' + stagedOp.id + '). Nästa steg: gnubok_get_trial_balance för verifiering och gnubok_explain_voucher_gap för eventuella luckor.'
|
||
});
|
||
}).catch(function(err) {
|
||
el('import').disabled = false;
|
||
el('import').textContent = 'Bokför: oåterkalleligt (BFL 5 kap 5 §)';
|
||
note('Godkännandet misslyckades: ' + (err && err.message ? err.message : 'okänt fel'));
|
||
});
|
||
}
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|
||
`
|
||
|
||
export const sieDropWidget: UiWidget = {
|
||
uri: 'ui://sie-drop/app.html',
|
||
name: 'SIE Import Drop',
|
||
description:
|
||
'Drag-and-drop SIE import card: reads the exact file bytes, runs gnubok_sie_preflight, and stages gnubok_import_sie on click. Rendered by gnubok_create_sie_upload.',
|
||
html: SIE_DROP_HTML,
|
||
}
|