cd64c0e3fb
* feat(skatteverket): production-ready momsdeklaration submission
Brings the Skatteverket extension up to a state where it can ship moms
declaration submission to Vercel production. Verified end-to-end against
SKV's Komplett testtjänst — all 8 momsdeklaration operations tested
(kontrollera, spara/hämta/radera utkast, lås/lås upp, hämta inlämnade,
hämta beslutade) plus signing-link return.
Bundles three coherent changes:
1. Skatteverket extension (the main work)
- extensions.config.json: enable `skatteverket`, drop `invoice-inbox`
and `ai-agent` (those were enabled in config but lacked AWS env vars
in prod, so they loaded but failed at runtime)
- lib/reports/vat-declaration.ts: extend ACCOUNT_RUTA to populate
Ruta 06 (uttag 3401–3403), Ruta 20–24 (reverse-charge bases from
4xxx cost accounts), Ruta 50 (import 4545–4547), and Ruta 42
(3404/3994/3980); delete the supplier-type heuristic that made
Ruta 20 and Ruta 23 always 0
- extensions/general/skatteverket/lib/token-store.ts: work around
three real prod schema-drift issues — wrong column on read/delete
(was `company_id`, schema only has `user_id`), missing
UNIQUE(user_id) constraint that makes UPSERT fail (switched to
DELETE+INSERT), missing RLS policies (switched to service-role
client). Refresh path now reuses existing row's company_id when
none is passed.
- extensions/general/skatteverket/index.ts: 9 sites switched from
ctx.companyId to ctx.userId for the token-store key; pass
companyId from the OAuth callback
- extensions/general/skatteverket/types.ts + components/reports/
SkatteverketPanel.tsx: align field names with v1.0.24 RAML
(signeringsLank/kontrollResultat/resultat/kod/status/beskrivning).
Without this, the signing link never displayed.
- SkatteverketPanel: add Lås upp + Radera utkast + Hämta utkast +
Hämta beslut buttons so the full lifecycle is reachable from the UI
- lib/reports/__tests__/vat-declaration.test.ts: rewritten to match
the refactored calculator; new fixtures for cost-account-based
reverse charge (Ruta 20/21/22/23/24), Ruta 50 import, Ruta 06
uttag, Ruta 42 expansion; SKV §4.1.1.4 cross-field contract checks
- supabase/migrations/20260428120000_skatteverket_tokens_user_id_unique.sql:
idempotently adds the missing UNIQUE(user_id) constraint
- scripts/*: dev-only helpers used during the prod-of-test
verification (create test company, seed VAT data, inspect token
state, etc.)
2. Journal-entries cancelled-status filter
- app/api/bookkeeping/journal-entries/route.ts: when no status filter
is supplied, exclude `cancelled` entries by default
- supabase/migrations/20260428153500_journal_entries_with_related_exclude_statuses.sql
3. Swedish e-invoicing skill (reference docs only — no runtime code)
- .claude/skills/swedish-e-invoicing/
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(skatteverket): address PR review findings
- panel: handleFetchDraft read `result.data?.last` (typo) — switched to
`result.data?.locked` to match the field defined in
SkatteverketUtkastResponse and the v1.0.24 RAML. The "(låst)" suffix on
the success message would silently never appear before this fix.
- api-client: getValidToken had no concurrency guard, so two parallel
SKV requests from the same user could both call /token with the same
refresh_token. SKV rotates the refresh_token on first use, so the
second call would 401 with REFRESH_EXHAUSTED-adjacent failures. With
the new 6-button UI on SkatteverketPanel, rapid clicks made this a
realistic trigger. Added an in-process Promise map keyed on userId
that coalesces concurrent refresh attempts; cross-process races are
mitigated by re-reading tokens inside the critical section before
calling refreshAccessToken (if another process refreshed already, we
use the newer token instead of burning the old refresh_token).
- migration 20260428120000: dedup query used `created_at < max(...)`,
which failed to remove duplicates inserted in the same second. The
subsequent ALTER TABLE … ADD CONSTRAINT would then abort. Switched
to ctid (Postgres physical row identifier) to break timestamp ties.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(skatteverket): throw on token-store SELECT error before destructive DELETE
The company_id pre-read in storeTokens used destructuring that discarded
the error field. If the service-role SELECT failed for any reason (network
blip, overloaded DB, transient permissions issue), `existing` became null,
`resolvedCompanyId` stayed undefined, and execution fell through to the
DELETE. The old row got deleted successfully, then the INSERT omitted
company_id and failed with the NOT NULL constraint violation — leaving
the user with no token row at all and forcing a fresh BankID handshake.
Now we capture the SELECT error and throw before the DELETE runs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
111 lines
3.8 KiB
PL/PgSQL
111 lines
3.8 KiB
PL/PgSQL
-- Hide cancelled journal entries from the default /bookkeeping list view by
|
|
-- filtering them out inside list_fiscal_period_entries_with_related unless
|
|
-- the caller explicitly asks for status='cancelled'.
|
|
--
|
|
-- Why: prior data-cleanup operations (e.g. a one-off SIE re-import for a
|
|
-- specific tenant) left ~1,900 cancelled entries in journal_entries for
|
|
-- one company. The list view rendered them indistinguishably from posted
|
|
-- entries, so the user perceived them as duplicates of the new postings.
|
|
-- Hiding cancelled by default matches the convention already used by
|
|
-- trial-balance / balance-sheet / income-statement reads (which use
|
|
-- .in('status', ['posted', 'reversed'])).
|
|
--
|
|
-- Implementation: pure body change via CREATE OR REPLACE — no signature
|
|
-- change, so old API callers continue to work and immediately benefit from
|
|
-- the new behavior the moment this migration applies. No deploy-order risk.
|
|
|
|
CREATE OR REPLACE FUNCTION public.list_fiscal_period_entries_with_related(
|
|
p_company_id uuid,
|
|
p_period_id uuid,
|
|
p_include_related boolean DEFAULT true,
|
|
p_status text DEFAULT NULL,
|
|
p_date_from date DEFAULT NULL,
|
|
p_date_to date DEFAULT NULL,
|
|
p_sort_date text DEFAULT 'desc',
|
|
p_limit int DEFAULT 50,
|
|
p_offset int DEFAULT 0
|
|
)
|
|
RETURNS TABLE (
|
|
entry jsonb,
|
|
total_count bigint
|
|
)
|
|
LANGUAGE sql
|
|
STABLE
|
|
SECURITY INVOKER
|
|
SET search_path = public, pg_temp
|
|
AS $$
|
|
WITH period AS (
|
|
SELECT period_start, period_end
|
|
FROM public.fiscal_periods
|
|
WHERE id = p_period_id AND company_id = p_company_id
|
|
),
|
|
matching AS (
|
|
SELECT je.*
|
|
FROM public.journal_entries je
|
|
CROSS JOIN period p
|
|
WHERE je.company_id = p_company_id
|
|
AND (
|
|
je.fiscal_period_id = p_period_id
|
|
OR (
|
|
p_include_related
|
|
AND je.source_type IN ('invoice_paid','invoice_cash_payment','credit_note')
|
|
AND EXISTS (
|
|
SELECT 1 FROM public.invoices i
|
|
WHERE i.id = je.source_id
|
|
AND i.company_id = p_company_id
|
|
AND i.invoice_date BETWEEN p.period_start AND p.period_end
|
|
)
|
|
)
|
|
OR (
|
|
p_include_related
|
|
AND je.source_type IN ('supplier_invoice_paid','supplier_invoice_cash_payment','supplier_credit_note')
|
|
AND EXISTS (
|
|
SELECT 1 FROM public.supplier_invoices si
|
|
WHERE si.id = je.source_id
|
|
AND si.company_id = p_company_id
|
|
AND si.invoice_date BETWEEN p.period_start AND p.period_end
|
|
)
|
|
)
|
|
)
|
|
AND (p_status IS NULL OR je.status = p_status)
|
|
-- Hide cancelled by default; show them only when caller asks explicitly.
|
|
AND (je.status <> 'cancelled' OR p_status = 'cancelled')
|
|
AND (p_date_from IS NULL OR je.entry_date >= p_date_from)
|
|
AND (p_date_to IS NULL OR je.entry_date <= p_date_to)
|
|
),
|
|
matching_with_total AS (
|
|
SELECT m.*, COUNT(*) OVER () AS total
|
|
FROM matching m
|
|
),
|
|
paged AS (
|
|
SELECT *
|
|
FROM matching_with_total
|
|
ORDER BY
|
|
CASE WHEN p_sort_date = 'asc' THEN entry_date END ASC NULLS LAST,
|
|
CASE WHEN p_sort_date = 'desc' THEN entry_date END DESC NULLS LAST,
|
|
voucher_series,
|
|
voucher_number
|
|
LIMIT p_limit OFFSET p_offset
|
|
)
|
|
SELECT
|
|
(to_jsonb(p.*) - 'total')
|
|
|| jsonb_build_object(
|
|
'lines', COALESCE(
|
|
(SELECT jsonb_agg(to_jsonb(l.*) ORDER BY l.sort_order)
|
|
FROM public.journal_entry_lines l
|
|
WHERE l.journal_entry_id = p.id),
|
|
'[]'::jsonb
|
|
),
|
|
'out_of_period', (p.fiscal_period_id IS DISTINCT FROM p_period_id)
|
|
) AS entry,
|
|
p.total AS total_count
|
|
FROM paged p
|
|
ORDER BY
|
|
CASE WHEN p_sort_date = 'asc' THEN p.entry_date END ASC NULLS LAST,
|
|
CASE WHEN p_sort_date = 'desc' THEN p.entry_date END DESC NULLS LAST,
|
|
p.voucher_series,
|
|
p.voucher_number;
|
|
$$;
|
|
|
|
NOTIFY pgrst, 'reload schema';
|