The year total (tb / tb_ex_year_end) aggregates posted AND reversed entries, so a same-year storno nets to 0. The monthly section, both the get_kpi_report_aggregates RPC and the dimension-filtered JS fallback in lib/reports/monthly-breakdown.ts, was posted-only: it dropped the reversed original but kept the storno (itself posted). 10 000 kr on 3041 in March, reversed in April, gave March 0 kr, April -10 000 kr, year 0 kr, and PR #2198 made the per-month figures visible enough to add up. Migration 20260903160000 replaces the RPC with the monthly join on tb_ex_year_end's entry set verbatim (no extra status predicate); the JS fallback filters status in ('posted','reversed') the same way. The pg-real pin ("in tb, not in monthly") is flipped and a storno case asserts sum(months) = net result. Everything else in the function is byte-identical to 20260730090000. Verified: pg-real suite against a rebuilt local supabase/postgres with every migration applied (9 tests), unit suite, lint. Closes #2201 Claude-Session: https://claude.ai/code/session_01VnConrmMCxJRQ5kfiPPWyy Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -1527,5 +1527,6 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-09-03] Old-address social identities are unlinked by a BEFORE UPDATE trigger on auth.users (migration 20260903110000), not by the /auth/callback done path: the callback never runs for a completing click from a browser without a session, and admin-side changes bypass it entirely; the trigger covers every path and keeps the email identity, password and BankID intact.
|
||||
[2026-09-03] AGI redovisningsperiod = the payout month (agiReportingPeriod on payment_date), not salary_runs.period_*: Skatteverket files per the month the pay went out (kontantprincipen), so lön i efterskott (August work paid 25 September) is declared in September. The in-period payment-date guard (dashboard PATCH, lib/salary/update-run.ts, v1 PATCH, RunHeader min/max) is lifted rather than widened: its only stated reason was that the AGI keyed on period_*, and any residual month window would bite the next efterskott variant. Existing agi_declarations rows keep their stored period (no backfill): a declaration already filed under the earned month is a real-world correction with Skatteverket, not a re-key. New AGI_PERIOD_CONFLICT (409) refuses to overwrite a live run's declaration for the same payout month, since one month's AGI must cover every payment that month and the generator cannot merge runs. Issue #2191.
|
||||
[2026-09-03] The cursor:// deeplink is its own allowlist provider (cursor_deeplink) rendered "Din egen dator" and never "Verifierad", after the skeptic, CodeRabbit and Superagent all made the same point: a custom scheme can be claimed by any local app (RFC 8252 section 8.4), so it carries loopback trust, not vendor trust, and the consent page must not say otherwise; https://www.cursor.com/... keeps the verified label. Same pass fixed the consent-page CSP for custom schemes: new URL('cursor://...').origin is the string "null", so form-action became `'self' null` and Chromium would have blocked the post-consent 303 (correctness skeptic refutation); the header now uses the scheme-source (`cursor:`) when the origin is opaque. Not done: rejecting a missing code_challenge at /authorize. A code minted without one is unexchangeable (verifyPkce against an empty challenge is always false, now pinned by a test), so it is fail-closed; making it fail earlier is a separate change touching every client.
|
||||
[2026-09-03] KPI monthly breakdown counts reversed originals (#2201): the monthly section of get_kpi_report_aggregates (new migration 20260903160000) and lib/reports/monthly-breakdown.ts now use tb_ex_year_end's entry set verbatim (posted + reversed, minus the undone year-end chain) instead of posted-only. A same-year storno then cancels inside the months as it does in the year total, so sum(months) = Nettoresultat; the reversal shows as negative revenue in its own month, which is the honest month view. The pg-real pin "in tb, not in monthly" was flipped, not worked around. Unblocks the per-month sum on Nyckeltal (#2196).
|
||||
[2026-09-03] customers.country and suppliers.country are ISO 3166-1 alpha-2 at every writer (form select, internal + v1 REST, MCP, imports, provider migration), normalised through one helper (lib/vat/country-codes.ts) that also accepts the Swedish/English names the form used to write; unknown text is a 400 on write and left as-is by the backfill (migration 20260903170000 keeps the original in country_raw for a one-UPDATE rollback, and derives the country from the VAT prefix for eu_business rows whose country was null or only the old writer default SE: on prod that is one validated row plus sixteen without a country, and without it they would flip from reverse charge to 25% on their next invoice). No CHECK constraint on the column: unmapped legacy rows would violate it, and the periodisk report already warns on those. The country-vs-type rule (swedish_business = SE, eu_business = not SE and either in the EU VAT area with a matching prefix or holding an EU-trade VAT registration such as a Swiss company with a DE number or Northern Ireland XI, non_eu_business = outside the EU) is enforced on customers only, and on update only when type, country or VAT number is part of the change so a contradictory legacy row can still change its email; individuals are free (a foreign private person is still a Swedish-VAT customer) and suppliers get normalisation without the rule, since #2025/#2028 are about sales VAT. An omitted country on create is SE for Swedish types, derived from the VAT prefix for eu_business, and a 400 for non_eu_business: guessing a non-EU country is not possible, and Sweden-by-default was the bug. vat-rules.ts takes the country as a third optional argument and refuses reverse charge only for SE (a VIES-validated number outweighs a non-EU address), and not for an unknown/unmapped country: charging Swedish VAT to a genuine German customer whose row says Deutschland (Bayern) would be the worse error.
|
||||
[2026-09-03] AGI kvittens cron: dropped the apigw_config bucket (#963) and its warn-once suppression for ACCESS_DENIED (#2226). The bucket existed because the APIGW client was known to lack the AGI hantera subscription in Utvecklarportalen; with that subscription expected in place, a gateway refusal is a regression and belongs in the ordinary error path (error level, generic 'error' status) rather than a status that hides it as a known gap. Same pass: the connector-mode gateway-refusal message names the connector operator by host instead of "kontakta supporten", because hosted is itself a Connect installation for the canary companies and "support" no longer says whose. Merging before the portal subscription is active means the 15-minute cron logs at error level per pending declaration until it is.
|
||||
|
||||
@@ -93,10 +93,16 @@ export function assembleMonthlyBreakdown(
|
||||
/**
|
||||
* Generate monthly income vs expenses breakdown for a fiscal period.
|
||||
*
|
||||
* Groups posted journal entry lines by month and account class:
|
||||
* Groups posted AND reversed journal entry lines by month and account class:
|
||||
* - Class 3 (30xx) = revenue (credit side)
|
||||
* - Class 4-7 (40xx-79xx) = expenses (debit side)
|
||||
*
|
||||
* Reversed originals are included on purpose (issue #2201): the year total
|
||||
* (tb_ex_year_end / the income statement) counts posted + reversed, so a
|
||||
* same-year storno nets to 0 there. Dropping the reversed original here but
|
||||
* keeping the storno (itself posted) made the months stop summing to
|
||||
* Nettoresultat. The same entry set on both paths keeps sum(months) = year.
|
||||
*
|
||||
* Year-end entries are excluded, including the storno/correction chain of a
|
||||
* REVERSED year-end entry (an undone bokslut). Without that the resultatavslut,
|
||||
* which posts the mirror image of every P&L account, showed the whole year's
|
||||
@@ -145,8 +151,8 @@ export async function generateMonthlyBreakdown(
|
||||
)
|
||||
).map((r) => r.id)
|
||||
|
||||
// Get all posted journal entry lines for this period with their entry dates,
|
||||
// via the two-step entry-lines fetch (see lib/bookkeeping/entry-lines.ts).
|
||||
// Get all posted and reversed journal entry lines for this period with their
|
||||
// entry dates, via the two-step entry-lines fetch (lib/bookkeeping/entry-lines.ts).
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let lines: any[]
|
||||
try {
|
||||
@@ -158,7 +164,8 @@ export async function generateMonthlyBreakdown(
|
||||
let query = q
|
||||
.eq('fiscal_period_id', fiscalPeriodId)
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'posted')
|
||||
// posted + reversed: the same set as tb_ex_ye_entries in the RPC.
|
||||
.in('status', ['posted', 'reversed'])
|
||||
.neq('source_type', 'year_end')
|
||||
if (reversedYearEndIds.length > 0) {
|
||||
const idList = `(${reversedYearEndIds.join(',')})`
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
-- get_kpi_report_aggregates: let a same-year storno cancel inside the monthly
|
||||
-- breakdown the way it already does in the year total.
|
||||
--
|
||||
-- The 'monthly' section joined tb_ex_ye_entries with an extra
|
||||
-- `e.status = 'posted'` predicate. The year total (tb / tb_ex_year_end) has no
|
||||
-- such predicate: it aggregates posted AND reversed entries, so a P&L entry
|
||||
-- that was reversed by storno nets to 0 across the original and the storno.
|
||||
-- Monthly dropped the reversed ORIGINAL but kept the storno (itself posted),
|
||||
-- so the months no longer summed to Nettoresultat: 10 000 kr on 3041 in
|
||||
-- March, reversed in April, gave March 0 kr and April -10 000 kr while the
|
||||
-- year said 0 kr (issue #2201). PR #2198 put the exact per-month figures on
|
||||
-- the Nyckeltal page, which made the gap visible to anyone adding them up.
|
||||
--
|
||||
-- Fix: the monthly section uses tb_ex_year_end's entry set verbatim (posted +
|
||||
-- reversed, minus the undone year-end chain). lib/reports/monthly-breakdown.ts
|
||||
-- (the dimension-filtered JS fallback serving the same chart) changes in the
|
||||
-- same PR so the two paths keep agreeing. Everything else in the function is
|
||||
-- byte-identical to 20260730090000.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.get_kpi_report_aggregates(
|
||||
p_company_id uuid,
|
||||
p_fiscal_period_id uuid,
|
||||
p_ob_entry_id uuid DEFAULT NULL
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
SECURITY INVOKER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
WITH period_entries AS (
|
||||
SELECT id, entry_date, status, source_type, reverses_id, correction_of_id
|
||||
FROM public.journal_entries
|
||||
WHERE company_id = p_company_id
|
||||
AND fiscal_period_id = p_fiscal_period_id
|
||||
AND status IN ('posted', 'reversed')
|
||||
),
|
||||
tb_entries AS (
|
||||
SELECT * FROM period_entries
|
||||
WHERE p_ob_entry_id IS NULL OR id <> p_ob_entry_id
|
||||
),
|
||||
ye_reversed AS (
|
||||
-- Company-wide (no period filter), mirroring the wave-1 fetch in
|
||||
-- lib/reports/trial-balance.ts: a storno in this period can reverse a
|
||||
-- year-end entry from another period.
|
||||
SELECT id
|
||||
FROM public.journal_entries
|
||||
WHERE company_id = p_company_id
|
||||
AND source_type = 'year_end'
|
||||
AND status = 'reversed'
|
||||
),
|
||||
tb_ex_ye_entries AS (
|
||||
SELECT * FROM tb_entries
|
||||
WHERE source_type IS DISTINCT FROM 'year_end'
|
||||
AND (reverses_id IS NULL
|
||||
OR reverses_id NOT IN (SELECT id FROM ye_reversed))
|
||||
AND (correction_of_id IS NULL
|
||||
OR correction_of_id NOT IN (SELECT id FROM ye_reversed))
|
||||
)
|
||||
SELECT jsonb_build_object(
|
||||
'tb', COALESCE((
|
||||
SELECT jsonb_agg(jsonb_build_object(
|
||||
'account_number', t.account_number,
|
||||
'debit', t.debit,
|
||||
'credit', t.credit
|
||||
) ORDER BY t.account_number)
|
||||
FROM (
|
||||
SELECT l.account_number,
|
||||
sum(l.debit_amount)::float8 AS debit,
|
||||
sum(l.credit_amount)::float8 AS credit
|
||||
FROM public.journal_entry_lines l
|
||||
JOIN tb_entries e ON e.id = l.journal_entry_id
|
||||
GROUP BY l.account_number
|
||||
) t
|
||||
), '[]'::jsonb),
|
||||
'tb_ex_year_end', COALESCE((
|
||||
SELECT jsonb_agg(jsonb_build_object(
|
||||
'account_number', t.account_number,
|
||||
'debit', t.debit,
|
||||
'credit', t.credit
|
||||
) ORDER BY t.account_number)
|
||||
FROM (
|
||||
SELECT l.account_number,
|
||||
sum(l.debit_amount)::float8 AS debit,
|
||||
sum(l.credit_amount)::float8 AS credit
|
||||
FROM public.journal_entry_lines l
|
||||
JOIN tb_ex_ye_entries e ON e.id = l.journal_entry_id
|
||||
GROUP BY l.account_number
|
||||
) t
|
||||
), '[]'::jsonb),
|
||||
'ob', COALESCE((
|
||||
SELECT jsonb_agg(jsonb_build_object(
|
||||
'account_number', t.account_number,
|
||||
'debit', t.debit,
|
||||
'credit', t.credit
|
||||
) ORDER BY t.account_number)
|
||||
FROM (
|
||||
-- No status filter: getOpeningBalances only checks id + company_id.
|
||||
SELECT l.account_number,
|
||||
sum(l.debit_amount)::float8 AS debit,
|
||||
sum(l.credit_amount)::float8 AS credit
|
||||
FROM public.journal_entry_lines l
|
||||
JOIN public.journal_entries e
|
||||
ON e.id = l.journal_entry_id
|
||||
AND e.id = p_ob_entry_id
|
||||
AND e.company_id = p_company_id
|
||||
GROUP BY l.account_number
|
||||
) t
|
||||
), '[]'::jsonb),
|
||||
'monthly', COALESCE((
|
||||
SELECT jsonb_agg(jsonb_build_object(
|
||||
'year', m.year,
|
||||
'month', m.month,
|
||||
'income', m.income,
|
||||
'expenses', m.expenses
|
||||
) ORDER BY m.year, m.month)
|
||||
FROM (
|
||||
SELECT EXTRACT(YEAR FROM e.entry_date)::int AS year,
|
||||
EXTRACT(MONTH FROM e.entry_date)::int AS month,
|
||||
(
|
||||
COALESCE(sum(CASE
|
||||
WHEN l.account_number ~ '^3'
|
||||
THEN l.credit_amount - l.debit_amount
|
||||
END), 0)
|
||||
+ COALESCE(sum(CASE
|
||||
WHEN l.account_number ~ '^8'
|
||||
AND l.account_number <> '8999'
|
||||
AND (l.credit_amount - l.debit_amount) >= 0
|
||||
THEN l.credit_amount - l.debit_amount
|
||||
END), 0)
|
||||
)::float8 AS income,
|
||||
(
|
||||
COALESCE(sum(CASE
|
||||
WHEN l.account_number ~ '^[4-7]'
|
||||
THEN l.debit_amount - l.credit_amount
|
||||
END), 0)
|
||||
+ COALESCE(sum(CASE
|
||||
WHEN l.account_number ~ '^8'
|
||||
AND l.account_number <> '8999'
|
||||
AND (l.credit_amount - l.debit_amount) < 0
|
||||
THEN l.debit_amount - l.credit_amount
|
||||
END), 0)
|
||||
)::float8 AS expenses
|
||||
FROM public.journal_entry_lines l
|
||||
-- The same entry set as tb_ex_year_end, with NO extra status predicate:
|
||||
-- posted AND reversed originals, minus the undone year-end chain. A
|
||||
-- reversed original and its storno then cancel within the year exactly
|
||||
-- as they do in the year total, so sum(months) = Nettoresultat. Matches
|
||||
-- lib/reports/monthly-breakdown.ts.
|
||||
JOIN tb_ex_ye_entries e
|
||||
ON e.id = l.journal_entry_id
|
||||
WHERE l.account_number ~ '^[3-8]'
|
||||
GROUP BY 1, 2
|
||||
) m
|
||||
), '[]'::jsonb)
|
||||
)
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.get_kpi_report_aggregates(uuid, uuid, uuid) FROM PUBLIC, anon;
|
||||
GRANT EXECUTE ON FUNCTION public.get_kpi_report_aggregates(uuid, uuid, uuid) TO authenticated, service_role;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -15,11 +15,14 @@
|
||||
* year-end chain), mirroring excludeYearEndClosing;
|
||||
* - ob sums the OB entry's lines with NO status filter (mirrors
|
||||
* getOpeningBalances) but is company-guarded;
|
||||
* - monthly is posted-only, classes 3-8, 8999 excluded, class 8 split
|
||||
* per line by the sign of credit - debit, and (since migration
|
||||
* 20260730090000) it shares tb_ex_year_end's entry set so the
|
||||
* resultatavslut cannot chart the whole year's revenue as negative
|
||||
* income in the fiscal-year-end month;
|
||||
* - monthly shares tb_ex_year_end's entry set VERBATIM (posted AND
|
||||
* reversed, minus the undone year-end chain; since 20260903160000, issue
|
||||
* #2201: a reversed original and its storno cancel within the year so
|
||||
* sum(months) = Nettoresultat), classes 3-8, 8999 excluded, class 8
|
||||
* split per line by the sign of credit - debit, and (since
|
||||
* 20260730090000) year-end entries stay out so the resultatavslut cannot
|
||||
* chart the whole year's revenue as negative income in the
|
||||
* fiscal-year-end month;
|
||||
* - SECURITY INVOKER: a non-member gets empty sections under RLS.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
@@ -161,7 +164,8 @@ async function seedFullScenario() {
|
||||
{ account: '1930', debit: 12500, credit: 0 },
|
||||
],
|
||||
})
|
||||
// February: expense, plus a REVERSED entry (in tb, not in monthly)
|
||||
// February: expense, plus a REVERSED entry (in tb AND in monthly: the
|
||||
// monthly section shares tb_ex_year_end's entry set, issue #2201)
|
||||
await insertJournalEntry({
|
||||
...ctx, voucherNumber: 3, entryDate: '2026-02-10',
|
||||
lines: [
|
||||
@@ -321,14 +325,17 @@ describe('get_kpi_report_aggregates RPC', () => {
|
||||
expect(foreign.ob).toEqual([])
|
||||
})
|
||||
|
||||
it('monthly is posted-only, 8999 excluded, class 8 sign-split per line', async () => {
|
||||
it('monthly counts posted and reversed originals, 8999 excluded, class 8 sign-split per line', async () => {
|
||||
const ctx = await seedFullScenario()
|
||||
const payload = await callRpc(ctx.companyId, ctx.fiscalPeriodId, ctx.obEntryId)
|
||||
|
||||
// January: revenue only (class 2 VAT line ignored).
|
||||
expect(monthOf(payload, 2026, 1)).toMatchObject({ income: 10000, expenses: 0 })
|
||||
// February: the reversed 3001 entry (700) must NOT appear.
|
||||
expect(monthOf(payload, 2026, 2)).toMatchObject({ income: 0, expenses: 3000 })
|
||||
// February: the reversed 3001 entry (700) IS counted, exactly as it is in
|
||||
// tb_ex_year_end (3001 credit 10700). Before 20260903160000 the monthly
|
||||
// section dropped reversed originals while keeping their stornos, so the
|
||||
// months stopped summing to the year total (issue #2201).
|
||||
expect(monthOf(payload, 2026, 2)).toMatchObject({ income: 700, expenses: 3000 })
|
||||
// March: 8310 credit 200 -> income; 8410 debit 500 -> expenses.
|
||||
expect(monthOf(payload, 2026, 3)).toMatchObject({ income: 200, expenses: 500 })
|
||||
// December: year_end entries ARE excluded from monthly as of migration
|
||||
@@ -345,6 +352,41 @@ describe('get_kpi_report_aggregates RPC', () => {
|
||||
expect(payload.monthly.map((m) => m.month).sort((a, b) => a - b)).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it('a same-year storno cancels within the year: sum(months) equals the tb_ex_year_end net result', async () => {
|
||||
const ctx = await seedCompany()
|
||||
// Ver 12 (March): 10 000 kr revenue on 3041, later reversed by storno in
|
||||
// April. The year total nets to 0; the months must do the same.
|
||||
const original = await insertJournalEntry({
|
||||
...ctx, voucherNumber: 12, status: 'reversed', entryDate: '2026-03-10',
|
||||
lines: [
|
||||
{ account: '3041', debit: 0, credit: 10000 },
|
||||
{ account: '1930', debit: 10000, credit: 0 },
|
||||
],
|
||||
})
|
||||
await insertJournalEntry({
|
||||
...ctx, voucherNumber: 13, sourceType: 'storno', entryDate: '2026-04-02',
|
||||
reversesId: original,
|
||||
lines: [
|
||||
{ account: '3041', debit: 10000, credit: 0 },
|
||||
{ account: '1930', debit: 0, credit: 10000 },
|
||||
],
|
||||
})
|
||||
|
||||
const payload = await callRpc(ctx.companyId, ctx.fiscalPeriodId)
|
||||
|
||||
// Year total: the original and the storno both count, netting to 0.
|
||||
expect(byAccount(payload.tb_ex_year_end).get('3041')).toMatchObject({ debit: 10000, credit: 10000 })
|
||||
// Months: March carries the revenue, April carries the reversal.
|
||||
expect(monthOf(payload, 2026, 3)).toMatchObject({ income: 10000, expenses: 0 })
|
||||
expect(monthOf(payload, 2026, 4)).toMatchObject({ income: -10000, expenses: 0 })
|
||||
const monthsNet = payload.monthly.reduce((sum, m) => sum + m.income - m.expenses, 0)
|
||||
const yearNet = payload.tb_ex_year_end
|
||||
.filter((t) => /^[3-8]/.test(t.account_number))
|
||||
.reduce((sum, t) => sum + t.credit - t.debit, 0)
|
||||
expect(monthsNet).toBe(0)
|
||||
expect(monthsNet).toBe(yearNet)
|
||||
})
|
||||
|
||||
it('scopes to the requested company and returns empty sections for an empty one', async () => {
|
||||
const a = await seedFullScenario()
|
||||
const b = await seedCompany()
|
||||
|
||||
Reference in New Issue
Block a user