692 lines
27 KiB
JavaScript
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 });
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|