feat(mcp): approve the SIE import in the drop card + post-import efterkontroll in the skill (#1963)

E2E #10 (the first fully successful drop-card run) left the staged
import stranded until the user prodded the agent, and the agent then
improvised an excellent post-import audit (skattekonto reconciliation,
missing 1630, over-stated payroll liabilities). Codify both:

1. The drop card now carries the approval: after staging, the button
   becomes 'Godkänn bokföringen' with a two-click BFL confirmation
   (confirmed=true armed on the deliberate second click, exactly the
   pending-operations widget pattern), then 'Bokfört' + a ui/updateContext
   pointing the agent at trial balance and voucher-gap follow-ups.

2. Skill: when the user writes after a card was shown, the FIRST call is
   list_pending_operations (an empty ledger does not mean the file never
   arrived); new Step 4b 'efterkontroll' codifies the audit pass
   (trial balance vs SIE, skattekonto vs 1630 with 8423/8314/6992 for
   ränta/avgifter, auto-created bank account names, underlag coverage,
   voucher gaps); memory-first extended with memory-back (save orgnr,
   bank, fiscal year, moms period after creation so the next
   conversation needs zero questions).

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-26 21:57:45 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 84e6b1eb17
commit 53c4d0d194
3 changed files with 91 additions and 14 deletions
@@ -18,6 +18,15 @@ describe('SIE drop widget', () => {
expect(widget?.html).toContain("addEventListener('drop'")
})
it('carries the approval in-card: two-click BFL confirm on the staged import', () => {
const html = widget!.html
expect(html).toContain("callTool('gnubok_approve_pending_operation'")
// High-risk approve arms confirmed=true only on the second, deliberate
// click: the human acknowledgment, never a default.
expect(html).toContain('args.confirmed = true')
expect(html).toContain('BFL 5 kap 5')
})
it('passes exact bytes through tools/call with a sha256, never retyped or fetched', () => {
const html = widget!.html
expect(html).toContain("callTool('gnubok_sie_preflight'")
@@ -23,9 +23,13 @@ tables, no cross-checks the user did not ask about (bolagsstämma dates,
EU-moms edge cases, K-regelverk). The user can always ask for depth; the
flow must never make them scroll past it.
**Memory first.** Before asking the opening questions, check what you
already know about the user (memory, earlier conversation): orgnr, company
name, bank, previous system. Ask only for what is genuinely missing.
**Memory first, memory back.** Before asking the opening questions, check
what you already know about the user (memory, earlier conversation):
orgnr, company name, bank, previous system. Ask only for what is
genuinely missing. AFTER the company is created, save the durable facts
to memory (orgnr, company name, bank, fiscal year, moms period, previous
system, that Accounted is set up): the user's next conversation should
need zero of these questions.
## When to use
@@ -128,14 +132,15 @@ reaches far enough back anyway.
\`sha256\`. NEVER reproduce a large file token by token: unhashed
oversized inline content is refused because a mid-verifikat truncation
imports silently incomplete bookkeeping.
3. Summarize the preflight in a few lines: source system, fiscal years,
verifikat count, balance status, org-number match, the one warning that
matters. On the user's go-ahead: \`gnubok_import_sie\` with the same
source (\`upload_id\` or content) and the preflight's \`mappings\`. It
stages for approval; after commit verify with
\`gnubok_get_trial_balance\`, and explain any skipped voucher numbers
with \`gnubok_explain_voucher_gap\` (BFNAR 2013:2; an unexplained gap
blocks year-end).
3. THE CARD ACTS WITHOUT YOU SEEING IT: it stages the import and can
approve it too. When the user writes after a card was shown, your
FIRST call is \`gnubok_list_pending_operations\`: an empty ledger does
NOT mean the file never arrived; the import may be staged (approve it
on the user's word) or already booked. Without the card: summarize the
preflight in a few lines and stage \`gnubok_import_sie\` with the
preflight's \`mappings\`. After commit: \`gnubok_get_trial_balance\`
and \`gnubok_explain_voucher_gap\` for any skipped numbers (BFNAR
2013:2; unexplained gaps block year-end).
4. Multiple fiscal years = multiple files: import oldest first so IB/UB
chains. The web wizard at \`/import?mode=sie\` is the fallback when no
upload path works; Fortnox users can also run the full API migration
@@ -157,6 +162,28 @@ turn; on claude.ai/Desktop both render connect cards with buttons.
When the user says they are done (or comes back), re-call
\`gnubok_connect_bank\` to verify \`connected\`, then go DIRECTLY to step 5.
## Step 4b: after the import: efterkontroll (this is where trust is won)
Run a short audit pass as soon as history + bank are in, and fix findings
through the normal staged flow, a few lines per finding:
- \`gnubok_get_trial_balance\`: does the book balance and match the SIE?
- Skattekonto vs 1630: if Skatteverket is connected, reconcile the
skattekonto events against the ledger. Common finds: paid payroll taxes
still standing as liabilities on 2710/2731, the 1630 account missing
entirely, unbooked ränta/avgifter (kostnadsränta 8423, skattefri
intäktsränta 8314, ej avdragsgill förseningsavgift 6992: never the
ordinary cost accounts, or the year-end tax computation goes wrong).
- Auto-created bank accounts (1930/1931/1935) named after the company:
suggest proper names.
- Underlag coverage: verifikat over ~5 000 kr without documents (BFL 5
kap 6 §): list them, offer the receipt-matcher flow.
- Voucher gaps: explain each with \`gnubok_explain_voucher_gap\`.
Present findings as a short numbered list with amounts, fix in priority
order on the user's go-ahead, and re-verify the reconciled balances match
external truth (skattekonto saldo, bank balance) to the krona.
## Step 5: first bookkeeping, immediately
Call \`gnubok_list_uncategorized_transactions\` as soon as the bank is
@@ -246,7 +246,16 @@ export const SIE_DROP_HTML = `<!DOCTYPE html>
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…';
@@ -260,10 +269,17 @@ export const SIE_DROP_HTML = `<!DOCTYPE html>
import_transactions: true
}).then(function(res) {
const sc = parseResult(res);
el('import').textContent = 'Import förberedd';
note('Importen är förberedd och väntar på godkännande: säg till i chatten eller godkänn i Accounted, så bokförs verifikationerna.');
if (sc && sc.operation_id) {
stagedOp = { id: sc.operation_id, risk: sc.risk_level || 'high', armed: false };
el('import').disabled = false;
el('import').textContent = 'Godkänn bokföringen';
note('Importen är förberedd. Godkänn här i kortet så bokförs verifikationerna, 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.'
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;
@@ -271,6 +287,31 @@ export const SIE_DROP_HTML = `<!DOCTYPE html>
note('Import misslyckades: ' + (err && err.message ? err.message : 'okänt fel'));
});
});
function approveStaged() {
if (stagedOp.risk === 'high' && !stagedOp.armed) {
stagedOp.armed = true;
el('import').textContent = 'Bekräfta: oåterkalleligt (BFL 5 kap 5 §)';
note('Bokförda verifikat kan inte raderas, endast rättas med storno. Klicka igen för att bokföra.');
return;
}
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;
stagedOp.armed = false;
el('import').textContent = 'Godkänn bokföringen';
note('Godkännandet misslyckades: ' + (err && err.message ? err.message : 'okänt fel'));
});
}
})();
</script>
</body>