Files
boc/period-workflow-patched.mjs
T
Bernt bae705aa97 ARCHITECTURE: NFC roadmap, edge AI, audit logging
- Add NFC ePassport roadmap (ICAO 9303, eIDAS)
- Add TensorFlow.js edge face detection (BlazeFace)
- Add structured audit logger (GDPR-compliant)
- Risk scoring support

Part of KYC Apple Native UX v1.1.0
2026-06-29 16:24:48 +00:00

692 lines
27 KiB
JavaScript

/**
* period-workflow.mjs — AAMOS Ledger Period Close Workflow
* ─────────────────────────────────────────────────────────────────────────────
* Implementerar komplett workflow-state machine för periodhantering:
*
* open ──submit-review──► review ──approve──► approved ──close──► closed
* ▲ │
* └─reject─┘
* approved/review ──reopen──► open
*
* Exporterar: register(app, pool, buildCtx, writeAudit, hermes)
* ─────────────────────────────────────────────────────────────────────────────
*/
import { createHash } from 'crypto';
import { createRequire } from 'module';
import { readFileSync } from 'fs';
const _require = createRequire(import.meta.url);
const _jwt = _require('jsonwebtoken');
// RS256 private key for internal system-to-system JWT signing
let _ledgerPrivKey = null;
try { _ledgerPrivKey = readFileSync('/opt/amos/data/keys/jwt-private.pem', 'utf8'); } catch {}
const _JWT_SECRET = process.env.AMOS_JWT_SECRET || process.env.JWT_SECRET || 'y8Lf__vnWpImX2lxyPo3R0WYFEgCrtmQ3r8FcCwKjAWTNsLBKhaqIrP5oGAdt31E';
// ── Private helpers ───────────────────────────────────────────────────────────
/**
* Hämtar en period-rad med tenant-isolation.
* @returns {object|null}
*/
async function fetchPeriod(client, tenant_id, fiscal_year, period) {
const { rows } = await client.query(
`SELECT * FROM ledger_periods
WHERE tenant_id=$1 AND fiscal_year=$2 AND period=$3`,
[tenant_id, fiscal_year, period]
);
return rows[0] || null;
}
/**
* Beräknar trial balance för perioden (baserat på posted entries).
* @returns {{ accounts: Array, totalDebit: number, totalCredit: number, balanced: boolean }}
*/
async function buildTrialBalance(client, tenant_id, period) {
const { rows } = await client.query(
`SELECT
l.account_number,
COALESCE(SUM(l.debit), 0) AS total_debit,
COALESCE(SUM(l.credit), 0) AS total_credit
FROM ledger_journal_lines l
JOIN ledger_journal_entries e ON e.id = l.entry_id
WHERE e.tenant_id=$1 AND e.period=$2 AND e.status='posted'
GROUP BY l.account_number
ORDER BY l.account_number`,
[tenant_id, period]
);
const totalDebit = rows.reduce((s, r) => s + parseFloat(r.total_debit), 0);
const totalCredit = rows.reduce((s, r) => s + parseFloat(r.total_credit), 0);
return {
accounts: rows,
totalDebit,
totalCredit,
balanced: Math.abs(totalDebit - totalCredit) < 0.01,
};
}
/**
* Härleder fiscal_year från body-fält eller period-strängen (t.ex. '2026-01' → 2026).
*/
function getFiscalYear(body, period) {
return body.fiscal_year ? parseInt(body.fiscal_year) : parseInt(period.split('-')[0]);
}
/**
* Beräknar nettointäkt (SEK) från BAS intäktskonton 3xxx för perioden.
* Intäktskonton krediteras vid intäkt → net_revenue = summa kredit - summa debet.
* @returns {{ net_revenue_sek: number, snapshot: Array }}
*/
async function calcNetRevenue(client, tenant_id, period) {
const { rows } = await client.query(
`SELECT
l.account_number,
COALESCE(SUM(l.credit), 0) AS total_credit,
COALESCE(SUM(l.debit), 0) AS total_debit
FROM ledger_journal_lines l
JOIN ledger_journal_entries e ON e.id = l.entry_id
WHERE e.tenant_id=$1
AND e.period=$2
AND e.status='posted'
AND l.account_number::text LIKE '3%'
GROUP BY l.account_number
ORDER BY l.account_number`,
[tenant_id, period]
);
const net_revenue_sek = rows.reduce(
(s, r) => s + (parseFloat(r.total_credit) - parseFloat(r.total_debit)),
0
);
return { net_revenue_sek, snapshot: rows };
}
/**
* Skickar revenue-sync till billing-tjänsten (best-effort, blockerar ej stängning).
* POST http://localhost:3100/api/ouroboros/billing/revenue-sync/:org_id
*/
async function callRevenueSyncBilling({ tenant_id, fiscal_year, net_revenue_sek, sie4_hash, snapshot_data }) {
const url = `http://localhost:3100/api/ouroboros/billing/revenue-sync/${encodeURIComponent(tenant_id)}`;
const body = JSON.stringify({ fiscal_year, net_revenue_sek, sie4_hash, snapshot_data });
// Generera internt system-JWT för billing-anropet
let systemToken;
try {
const signKey = _ledgerPrivKey || _JWT_SECRET;
const signOpts = _ledgerPrivKey
? { algorithm: 'RS256', expiresIn: '5m' }
: { algorithm: 'HS256', expiresIn: '5m' };
systemToken = _jwt.sign(
{ sub: 'aamos-ledger', org_id: tenant_id, roles: ['admin'], iss: 'wavult', source: 'ouroboros-system' },
signKey,
signOpts
);
} catch (signErr) {
console.warn('[period-workflow] JWT signing failed for revenue-sync:', signErr.message);
systemToken = null;
}
const authHeader = systemToken ? { 'Authorization': `Bearer ${systemToken}` } : {};
try {
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-internal-source': 'ouroboros-system',
'x-tenant-id': tenant_id,
...authHeader,
},
body,
signal: AbortSignal.timeout(10_000),
});
const text = await res.text();
let json;
try { json = JSON.parse(text); } catch { json = { raw: text }; }
return { ok: res.ok, status: res.status, body: json };
} catch (err) {
// Nätverksfel eller timeout — logga men blockera ej
return { ok: false, error: err.message };
}
}
// ── Module export ─────────────────────────────────────────────────────────────
export function register(app, pool, buildCtx, writeAudit, hermes) {
// ════════════════════════════════════════════════════════════════════════════
// POST /api/ledger/periods/:period/open
// Skapar/öppnar en period (idempotent om redan öppen)
// Body: { fiscal_year }
// ════════════════════════════════════════════════════════════════════════════
app.post('/api/ledger/periods/:period/open', async (req, res) => {
const { period } = req.params;
const ctx = buildCtx(req, 'period', period);
const fiscal_year = getFiscalYear(req.body, period);
const client = await pool.connect();
try {
await client.query('BEGIN');
// Insert med ON CONFLICT DO NOTHING — idempotent om perioden redan är öppen
const { rows: inserted } = await client.query(
`INSERT INTO ledger_periods
(tenant_id, fiscal_year, period, status, opened_at, trace_id, metadata)
VALUES ($1, $2, $3, 'open', NOW(), $4, $5)
ON CONFLICT (tenant_id, fiscal_year, period) DO NOTHING
RETURNING *`,
[ctx.tenant_id, fiscal_year, period, ctx.trace_id, JSON.stringify({})]
);
const isNew = inserted.length > 0;
const periodRow = isNew
? inserted[0]
: await fetchPeriod(client, ctx.tenant_id, fiscal_year, period);
if (!periodRow) {
throw new Error('Period kunde inte skapas — okänt fel');
}
// Om raden redan finns med annan status → 409
if (periodRow.status !== 'open') {
await client.query('ROLLBACK');
return res.status(409).json({
ok: false,
error: `Period har redan status '${periodRow.status}'. Använd reopen-endpointen för att återgå till open.`,
});
}
// Skriv audit endast för faktisk skapelse (inte idempotent re-fetch)
if (isNew) {
await writeAudit(ctx, 'period_opened', null, periodRow, client);
}
await client.query('COMMIT');
if (isNew) {
await hermes.emit('finance.period.opened', ctx, { period, fiscal_year });
}
res.status(isNew ? 201 : 200).json({
ok: true,
period: periodRow,
transition: 'created→open',
});
} catch (e) {
await client.query('ROLLBACK');
res.status(400).json({ ok: false, error: e.message });
} finally {
client.release();
}
});
// ════════════════════════════════════════════════════════════════════════════
// POST /api/ledger/periods/:period/submit-review
// Transition: open → review
// Kontroller: inga draft-entries, trial balance i balans, status='open'
// ════════════════════════════════════════════════════════════════════════════
app.post('/api/ledger/periods/:period/submit-review', async (req, res) => {
const { period } = req.params;
const ctx = buildCtx(req, 'period', period);
const fiscal_year = getFiscalYear(req.body, period);
const client = await pool.connect();
try {
await client.query('BEGIN');
const periodRow = await fetchPeriod(client, ctx.tenant_id, fiscal_year, period);
if (!periodRow) {
return res.status(404).json({ ok: false, error: 'Period hittades inte' });
}
if (periodRow.status !== 'open') {
await client.query('ROLLBACK');
return res.status(409).json({
ok: false,
error: `Transition open→review kräver status='open', nuvarande status är '${periodRow.status}'`,
});
}
// Kontroll 1: inga draft-verifikationer
const { rows: draftCheck } = await client.query(
`SELECT COUNT(*) AS cnt
FROM ledger_journal_entries
WHERE tenant_id=$1 AND period=$2 AND status='draft'`,
[ctx.tenant_id, period]
);
if (parseInt(draftCheck[0].cnt) > 0) {
await client.query('ROLLBACK');
return res.status(422).json({
ok: false,
error: `${draftCheck[0].cnt} draft-verifikation(er) finns kvar — kontera dem innan granskningsstart`,
});
}
// Kontroll 2: trial balance måste vara i balans
const tb = await buildTrialBalance(client, ctx.tenant_id, period);
if (!tb.balanced) {
await client.query('ROLLBACK');
return res.status(422).json({
ok: false,
error: `Saldobalansen är inte i balans: debet ${tb.totalDebit.toFixed(2)} ≠ kredit ${tb.totalCredit.toFixed(2)}`,
});
}
// Spara checklist-snapshot i metadata
const checklist = {
checks: { no_drafts: true, balanced: true },
submitted_at: new Date().toISOString(),
submitted_by: ctx.user_id,
};
const newMetadata = { ...(periodRow.metadata || {}), workflow: checklist };
const { rows: updated } = await client.query(
`UPDATE ledger_periods
SET status='review', metadata=$1
WHERE tenant_id=$2 AND fiscal_year=$3 AND period=$4
RETURNING *`,
[JSON.stringify(newMetadata), ctx.tenant_id, fiscal_year, period]
);
await writeAudit(ctx, 'period_submitted_review', periodRow, updated[0], client);
await client.query('COMMIT');
await hermes.emit('finance.period.review_submitted', ctx, {
period,
fiscal_year,
submitted_by: ctx.user_id,
checks: checklist.checks,
});
res.json({ ok: true, period: updated[0], transition: 'open→review' });
} catch (e) {
await client.query('ROLLBACK');
res.status(500).json({ ok: false, error: e.message });
} finally {
client.release();
}
});
// ════════════════════════════════════════════════════════════════════════════
// POST /api/ledger/periods/:period/approve
// Transition: review → approved
// Spara trial balance snapshot vid godkännande
// ════════════════════════════════════════════════════════════════════════════
app.post('/api/ledger/periods/:period/approve', async (req, res) => {
const { period } = req.params;
const ctx = buildCtx(req, 'period', period);
const fiscal_year = getFiscalYear(req.body, period);
const client = await pool.connect();
try {
await client.query('BEGIN');
const periodRow = await fetchPeriod(client, ctx.tenant_id, fiscal_year, period);
if (!periodRow) {
return res.status(404).json({ ok: false, error: 'Period hittades inte' });
}
if (periodRow.status !== 'review') {
await client.query('ROLLBACK');
return res.status(409).json({
ok: false,
error: `Transition review→approved kräver status='review', nuvarande status är '${periodRow.status}'`,
});
}
// Snapshot av trial balance vid godkännande
const tb = await buildTrialBalance(client, ctx.tenant_id, period);
const newMetadata = {
...(periodRow.metadata || {}),
approval: {
approved_at: new Date().toISOString(),
approved_by: ctx.user_id,
trial_balance_snapshot: tb.accounts,
},
};
const { rows: updated } = await client.query(
`UPDATE ledger_periods
SET status='approved', trial_balance=$1, metadata=$2
WHERE tenant_id=$3 AND fiscal_year=$4 AND period=$5
RETURNING *`,
[JSON.stringify(tb.accounts), JSON.stringify(newMetadata),
ctx.tenant_id, fiscal_year, period]
);
await writeAudit(ctx, 'period_approved', periodRow, updated[0], client);
await client.query('COMMIT');
await hermes.emit('finance.period.approved', ctx, {
period,
fiscal_year,
approved_by: ctx.user_id,
});
res.json({ ok: true, period: updated[0], transition: 'review→approved' });
} catch (e) {
await client.query('ROLLBACK');
res.status(500).json({ ok: false, error: e.message });
} finally {
client.release();
}
});
// ════════════════════════════════════════════════════════════════════════════
// POST /api/ledger/periods/:period/reject
// Transition: review → open
// Body: { reason } — krävs
// ════════════════════════════════════════════════════════════════════════════
app.post('/api/ledger/periods/:period/reject', async (req, res) => {
const { period } = req.params;
const ctx = buildCtx(req, 'period', period);
const fiscal_year = getFiscalYear(req.body, period);
const { reason } = req.body;
if (!reason || !String(reason).trim()) {
return res.status(400).json({ ok: false, error: 'reason krävs för avvisning' });
}
const client = await pool.connect();
try {
await client.query('BEGIN');
const periodRow = await fetchPeriod(client, ctx.tenant_id, fiscal_year, period);
if (!periodRow) {
return res.status(404).json({ ok: false, error: 'Period hittades inte' });
}
if (periodRow.status !== 'review') {
await client.query('ROLLBACK');
return res.status(409).json({
ok: false,
error: `Transition review→open kräver status='review', nuvarande status är '${periodRow.status}'`,
});
}
const newMetadata = {
...(periodRow.metadata || {}),
rejection: {
rejected_at: new Date().toISOString(),
rejected_by: ctx.user_id,
reason: String(reason).trim(),
},
};
const { rows: updated } = await client.query(
`UPDATE ledger_periods
SET status='open', metadata=$1
WHERE tenant_id=$2 AND fiscal_year=$3 AND period=$4
RETURNING *`,
[JSON.stringify(newMetadata), ctx.tenant_id, fiscal_year, period]
);
await writeAudit(ctx, 'period_rejected', periodRow, updated[0], client);
await client.query('COMMIT');
await hermes.emit('finance.period.rejected', ctx, {
period,
fiscal_year,
rejected_by: ctx.user_id,
reason: String(reason).trim(),
});
res.json({ ok: true, period: updated[0], transition: 'review→open' });
} catch (e) {
await client.query('ROLLBACK');
res.status(500).json({ ok: false, error: e.message });
} finally {
client.release();
}
});
// ════════════════════════════════════════════════════════════════════════════
// POST /api/ledger/periods/:period/close
// Transition: approved → closed
// Kräver status='approved' — returnerar samma format som original-endpointen
// Revenue-sync hook: anropar billing-tjänsten med intäktsdata efter stängning
// ════════════════════════════════════════════════════════════════════════════
app.post('/api/ledger/periods/:period/close', async (req, res) => {
const { period } = req.params;
const ctx = buildCtx(req, 'period', period);
const fiscal_year = getFiscalYear(req.body, period);
const client = await pool.connect();
try {
await client.query('BEGIN');
const periodRow = await fetchPeriod(client, ctx.tenant_id, fiscal_year, period);
// Perioden måste existera och ha status='approved'
if (!periodRow || periodRow.status !== 'approved') {
await client.query('ROLLBACK');
const currentStatus = periodRow ? `'${periodRow.status}'` : 'inte skapad';
return res.status(409).json({
ok: false,
error: `Periodstängning kräver status='approved', nuvarande status är ${currentStatus}`,
});
}
// Belt-and-suspenders: inga draft-verifikationer ska finnas
const { rows: draftCheck } = await client.query(
`SELECT COUNT(*) AS cnt
FROM ledger_journal_entries
WHERE tenant_id=$1 AND period=$2 AND status='draft'`,
[ctx.tenant_id, period]
);
if (parseInt(draftCheck[0].cnt) > 0) {
await client.query('ROLLBACK');
return res.status(422).json({
ok: false,
error: `${draftCheck[0].cnt} utkast finns kvar — kontera dem innan periodstängning`,
});
}
// Snapshot av trial balance vid stängning
const tb = await buildTrialBalance(client, ctx.tenant_id, period);
// ── Revenue-sync: beräkna nettointäkt från 3xxx-konton ────────────────
const { net_revenue_sek, snapshot: revenueSnapshot } = await calcNetRevenue(
client, ctx.tenant_id, period
);
// Generera sie4_hash som SHA-256 av snapshot-data (deterministisk)
const sie4_hash = createHash('sha256')
.update(JSON.stringify({ tenant_id: ctx.tenant_id, fiscal_year, period, accounts: tb.accounts }))
.digest('hex');
const { rows: updated } = await client.query(
`UPDATE ledger_periods
SET status='closed', closed_at=NOW(), closed_by=$1, trial_balance=$2
WHERE tenant_id=$3 AND fiscal_year=$4 AND period=$5
RETURNING *`,
[ctx.user_id, JSON.stringify(tb.accounts),
ctx.tenant_id, fiscal_year, period]
);
await writeAudit(ctx, 'period_closed', periodRow, updated[0], client);
await client.query('COMMIT');
await hermes.emit('finance.period.closed', ctx, {
period,
fiscal_year,
closed_by: ctx.user_id,
entry_count: tb.accounts.length,
net_revenue_sek,
});
// ── Revenue-sync hook (best-effort, utanför transaktion) ───────────────
// Anropas efter commit så att periodstängningen inte rullas tillbaka vid fel.
const syncResult = await callRevenueSyncBilling({
tenant_id: ctx.tenant_id,
fiscal_year,
net_revenue_sek,
sie4_hash,
snapshot_data: revenueSnapshot,
});
if (!syncResult.ok) {
console.warn(
`[period-workflow] revenue-sync VARNING: tenant=${ctx.tenant_id} period=${period}`,
syncResult.error || syncResult.body
);
} else {
console.log(
`[period-workflow] revenue-sync OK: tenant=${ctx.tenant_id} period=${period}`,
`net_revenue_sek=${net_revenue_sek.toFixed(2)}`
);
}
// Samma response-format som befintlig close-endpoint + sync-info
res.json({
ok: true,
period: updated[0],
revenue_sync: {
called: true,
ok: syncResult.ok,
net_revenue_sek,
sie4_hash,
billing_status: syncResult.status ?? null,
billing_body: syncResult.body ?? syncResult.error ?? null,
},
});
} catch (e) {
await client.query('ROLLBACK');
res.status(400).json({ ok: false, error: e.message });
} finally {
client.release();
}
});
// ════════════════════════════════════════════════════════════════════════════
// POST /api/ledger/periods/:period/reopen
// Transition: approved/review → open
// Body: { reason } — krävs. Ej tillåtet om status='closed'
// ════════════════════════════════════════════════════════════════════════════
app.post('/api/ledger/periods/:period/reopen', async (req, res) => {
const { period } = req.params;
const ctx = buildCtx(req, 'period', period);
const fiscal_year = getFiscalYear(req.body, period);
const { reason } = req.body;
if (!reason || !String(reason).trim()) {
return res.status(400).json({ ok: false, error: 'reason krävs för återöppning' });
}
const client = await pool.connect();
try {
await client.query('BEGIN');
const periodRow = await fetchPeriod(client, ctx.tenant_id, fiscal_year, period);
if (!periodRow) {
return res.status(404).json({ ok: false, error: 'Period hittades inte' });
}
// Stängda perioder får aldrig återöppnas
if (periodRow.status === 'closed') {
await client.query('ROLLBACK');
return res.status(409).json({
ok: false,
error: 'Stängd period kan inte återöppnas',
});
}
// Måste vara approved eller review
if (!['approved', 'review'].includes(periodRow.status)) {
await client.query('ROLLBACK');
return res.status(409).json({
ok: false,
error: `Återöppning kräver status='approved' eller 'review', nuvarande status är '${periodRow.status}'`,
});
}
const fromStatus = periodRow.status;
const newMetadata = {
...(periodRow.metadata || {}),
reopen: {
reopened_at: new Date().toISOString(),
reopened_by: ctx.user_id,
from_status: fromStatus,
reason: String(reason).trim(),
},
};
const { rows: updated } = await client.query(
`UPDATE ledger_periods
SET status='open', metadata=$1
WHERE tenant_id=$2 AND fiscal_year=$3 AND period=$4
RETURNING *`,
[JSON.stringify(newMetadata), ctx.tenant_id, fiscal_year, period]
);
await writeAudit(ctx, 'period_reopened', periodRow, updated[0], client);
await client.query('COMMIT');
await hermes.emit('finance.period.reopened', ctx, {
period,
fiscal_year,
reopened_by: ctx.user_id,
from_status: fromStatus,
reason: String(reason).trim(),
});
res.json({ ok: true, period: updated[0], transition: `${fromStatus}→open` });
} catch (e) {
await client.query('ROLLBACK');
res.status(500).json({ ok: false, error: e.message });
} finally {
client.release();
}
});
// ════════════════════════════════════════════════════════════════════════════
// GET /api/ledger/periods/:period/status
// Returnerar period + workflow history från audit log + checklist status
// Query: ?fiscal_year=2026 (valfritt, härleds annars från period-strängen)
// ════════════════════════════════════════════════════════════════════════════
app.get('/api/ledger/periods/:period/status', async (req, res) => {
const { period } = req.params;
const tenant_id = req.headers['x-tenant-id'] || req.query.tenant_id || 'wavult-group';
const fiscal_year = req.query.fiscal_year
? parseInt(req.query.fiscal_year)
: parseInt(period.split('-')[0]);
try {
const { rows: periodRows } = await pool.query(
`SELECT * FROM ledger_periods
WHERE tenant_id=$1 AND fiscal_year=$2 AND period=$3`,
[tenant_id, fiscal_year, period]
);
if (!periodRows.length) {
return res.status(404).json({ ok: false, error: 'Period hittades inte' });
}
const periodRecord = periodRows[0];
// Audit log för denna period (entity_id = period-strängen, entity_type = 'period')
const { rows: auditRows } = await pool.query(
`SELECT action, user_id, decision_source, ts AS created_at, before_state, after_state
FROM ledger_audit_log
WHERE tenant_id=$1 AND entity_type='period' AND entity_id=$2
ORDER BY ts ASC`,
[tenant_id, period]
);
// Checklist-status från metadata (satt av submit-review)
const workflow = (periodRecord.metadata || {}).workflow || {};
const checklist = {
no_drafts: workflow.checks?.no_drafts || false,
balanced: workflow.checks?.balanced || false,
submitted_at: workflow.submitted_at || null,
submitted_by: workflow.submitted_by || null,
};
res.json({
ok: true,
period: periodRecord,
workflow_history: auditRows,
checklist,
});
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
}