* fix(invoices): apply configured voucher series to payments + preview next voucher The booking engine resolves the series from default_voucher_series_per_source_type, but the global "Standardserie" dropdown wrote a separate field the engine ignored, and cash-method invoice payments (invoice_cash_payment) weren't exposed in settings — so configured series were silently dropped to "A". - Expose cash/private payment source types in the per-source-type form - Write the global default through to the map on save, keeping overrides - Resolve voucher-sequences/next by source_type (+date) to match the engine - Show the upcoming voucher (V2) in the payment dialog title - Share resolveInvoicePaymentSourceType so preview and booking can't drift Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(salary): keep AGI panel in sync with Skatteverket signing state The AGI panel mixed run-scoped generation state (agi_generated_at, agi_declarations) with period-scoped submission state (extension_data agi_submission_{period}), so the two could drift and present contradictory UI. Reconcile them: - Auto-detect a Mina Sidor BankID signature: while awaiting_signing, poll /agi/kvittenser on mount and on tab refocus so the panel flips to "signed" (hiding the signing actions) without a manual "Hamta kvittens" click. - Warn instead of offering to sign when the locked granskningsunderlag predates the run's latest AGI generation (draftIsStale) — avoids filing superseded figures. - Self-heal a stale "AGI-XML saknas" error once the run's AGI is (re)generated out-of-band (MCP/API/other tab). - Refetch the salary run on tab focus so agi_generated_at reflects out-of-band generation without a hard reload. - /agi/lasUpp now clears the cached agi_submission_{period} record, so unlocking drops the panel back to the pre-submission state instead of stranding it on a released "redo att signeras" draft. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: Implement VAT registration handling and invoice item line types - Added VAT registration check in commitCreateInvoice to set VAT rate to 0% for non-VAT registered companies. - Updated invoice creation logic to reflect 'exempt' VAT treatment and adjusted related fields accordingly. - Introduced support for free-text and blank spacer rows in invoice items by adding a new line_type field. - Enhanced invoice and credit note handling to accommodate new line types. - Added new localized messages for text rows in English and Swedish. - Created tests for salary run approval logic, ensuring bank details are validated correctly. - Implemented effective net payout calculation for salary runs, considering tax overrides. - Added SQL migrations to support new invoice item line types and accounting method awareness for linking invoices to vouchers. * feat(articles): artikelregister with revenue account + VAT rate per article Article register (non-inventory) with per-article VAT rate and optional BAS class-3 revenue-account override. Includes API routes, UI pages, MCP tools, pending-operation staging, and the activate-or-create account flow (ACCOUNTS_NOT_IN_CHART -> ActivateAccountsDialog, unknown numbers -> AddAccountDialog) reusing the journal entry UX. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bookkeeping): no-doc-required batch + bulk-missing endpoints Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(payments): supplier payment lines + cash-method invoice matching Shared payment-line proposal for supplier invoices, improved match-invoice/match-supplier-invoice flows (kontantmetoden-aware), and voucher-link support without requiring a 151x clearing entry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bookkeeping): new journal entry dialog, SIE import tweaks, misc New journal entry dialog component, journal list/page updates, invoice editor updates, SIE import adjustments, transaction ingest and api-key tweaks, pr-agent workflow update. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(invoices): implement tax reduction features and localization updates * feat(tests): add VAT registration gate to pending operations commit tests --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
242 lines
9.4 KiB
PL/PgSQL
242 lines
9.4 KiB
PL/PgSQL
-- Make link_invoice_to_voucher accounting-method aware (kontantmetoden support).
|
|
--
|
|
-- The customer-invoice voucher-link RPC (latest definition:
|
|
-- 20260615120000_link_voucher_rpcs_tenant_guard.sql) only ever matched
|
|
-- verifikat that CREDIT an AR account (151x). On kontantmetoden no 1510 is ever
|
|
-- booked — revenue is recognised at payment (debit 19xx / credit 30xx+26xx) —
|
|
-- so the candidate set was always empty and "Befintlig verifikation" was
|
|
-- unusable. (The previous out-of-scope note lived in lib/invoices/voucher-matching.ts.)
|
|
--
|
|
-- This version reads company_settings.accounting_method and branches step 3:
|
|
-- • cash → sum the bank/cash DEBIT across the voucher's 19xx lines
|
|
-- (BAS class 19 — kassa/bank, covers 1910/1920/1930/1940…)
|
|
-- • accrual → sum the AR CREDIT across the voucher's 151x lines (unchanged)
|
|
-- Everything else (tenant guard, notes cap, attribution, locking, amount/
|
|
-- currency guards, the writes) is verbatim from 20260615120000. The internal
|
|
-- v_ar_credit_total name and the LINK_VOUCHER_NO_AR_CREDIT code are retained so
|
|
-- the TS/MCP callers map unchanged; the value simply carries the cash debit on
|
|
-- kontantmetoden. Mirrors the accounting-method branch in
|
|
-- lib/invoices/voucher-matching.ts so the staging preview and the commit agree.
|
|
|
|
CREATE OR REPLACE FUNCTION public.link_invoice_to_voucher(
|
|
p_invoice_id uuid,
|
|
p_journal_entry_id uuid,
|
|
p_user_id uuid,
|
|
p_company_id uuid,
|
|
p_notes text DEFAULT NULL
|
|
)
|
|
RETURNS jsonb
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path TO 'public'
|
|
AS $$
|
|
DECLARE
|
|
v_invoice RECORD;
|
|
v_voucher RECORD;
|
|
v_ar_credit_total numeric := 0;
|
|
v_line_currency text;
|
|
v_remaining numeric;
|
|
v_payment_amount numeric;
|
|
v_new_paid numeric;
|
|
v_new_remaining numeric;
|
|
v_new_status text;
|
|
v_is_fully_paid boolean;
|
|
v_now timestamptz := now();
|
|
v_payment_id uuid;
|
|
v_jwt_role text := coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role', '');
|
|
v_acting_user uuid := p_user_id;
|
|
v_accounting_method text;
|
|
BEGIN
|
|
-- 0. Tenant guard (mirrors 20260611140000): anon/authenticated may only act
|
|
-- on their own companies; service_role / direct access bypasses.
|
|
IF v_jwt_role IN ('anon', 'authenticated') THEN
|
|
IF p_company_id NOT IN (SELECT public.user_company_ids()) THEN
|
|
RETURN jsonb_build_object('ok', false, 'code', 'LINK_VOUCHER_INVOICE_NOT_FOUND');
|
|
END IF;
|
|
-- Attribution: the JWT sub is authoritative for user-session callers —
|
|
-- p_user_id cannot point the payment row at someone else.
|
|
v_acting_user := coalesce(
|
|
(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'sub')::uuid,
|
|
p_user_id
|
|
);
|
|
END IF;
|
|
|
|
IF p_notes IS NOT NULL AND char_length(p_notes) > 2000 THEN
|
|
RETURN jsonb_build_object(
|
|
'ok', false,
|
|
'code', 'LINK_VOUCHER_NOTES_TOO_LONG',
|
|
'details', jsonb_build_object('max_length', 2000, 'length', char_length(p_notes))
|
|
);
|
|
END IF;
|
|
|
|
-- 1. Lock the invoice for the duration of this transaction. FOR UPDATE so a
|
|
-- concurrent linker has to wait until we commit (or roll back).
|
|
SELECT * INTO v_invoice
|
|
FROM public.invoices
|
|
WHERE id = p_invoice_id AND company_id = p_company_id
|
|
FOR UPDATE;
|
|
|
|
IF NOT FOUND THEN
|
|
RETURN jsonb_build_object('ok', false, 'code', 'LINK_VOUCHER_INVOICE_NOT_FOUND');
|
|
END IF;
|
|
|
|
IF v_invoice.status NOT IN ('sent', 'overdue', 'partially_paid') THEN
|
|
RETURN jsonb_build_object(
|
|
'ok', false,
|
|
'code', 'LINK_VOUCHER_INVOICE_FULLY_PAID',
|
|
'details', jsonb_build_object('status', v_invoice.status)
|
|
);
|
|
END IF;
|
|
|
|
v_remaining := COALESCE(v_invoice.remaining_amount,
|
|
v_invoice.total - COALESCE(v_invoice.paid_amount, 0));
|
|
IF v_remaining <= 0.005 THEN
|
|
RETURN jsonb_build_object('ok', false, 'code', 'LINK_VOUCHER_INVOICE_FULLY_PAID');
|
|
END IF;
|
|
|
|
-- 2. Resolve the voucher.
|
|
SELECT * INTO v_voucher
|
|
FROM public.journal_entries
|
|
WHERE id = p_journal_entry_id AND company_id = p_company_id;
|
|
|
|
IF NOT FOUND THEN
|
|
RETURN jsonb_build_object('ok', false, 'code', 'LINK_VOUCHER_VOUCHER_NOT_FOUND');
|
|
END IF;
|
|
|
|
IF v_voucher.status <> 'posted' THEN
|
|
RETURN jsonb_build_object(
|
|
'ok', false,
|
|
'code', 'LINK_VOUCHER_NOT_POSTED',
|
|
'details', jsonb_build_object('status', v_voucher.status)
|
|
);
|
|
END IF;
|
|
|
|
IF v_voucher.source_type IN ('opening_balance', 'storno') THEN
|
|
RETURN jsonb_build_object(
|
|
'ok', false,
|
|
'code', 'LINK_VOUCHER_NO_AR_CREDIT',
|
|
'details', jsonb_build_object('source_type', v_voucher.source_type)
|
|
);
|
|
END IF;
|
|
|
|
-- 3. Sum the matched amount across the voucher's lines. Branch on the
|
|
-- company's accounting method (defaults to accrual when no settings row).
|
|
SELECT cs.accounting_method INTO v_accounting_method
|
|
FROM public.company_settings cs
|
|
WHERE cs.company_id = p_company_id;
|
|
v_accounting_method := COALESCE(v_accounting_method, 'accrual');
|
|
|
|
IF v_accounting_method = 'cash' THEN
|
|
-- Kontantmetoden: the payment verifikat debits a liquid-funds account (19xx).
|
|
SELECT COALESCE(SUM(debit_amount), 0), MAX(currency)
|
|
INTO v_ar_credit_total, v_line_currency
|
|
FROM public.journal_entry_lines
|
|
WHERE journal_entry_id = p_journal_entry_id
|
|
AND account_number LIKE '19%'
|
|
AND debit_amount > 0;
|
|
ELSE
|
|
-- Faktureringsmetoden: the payment verifikat credits the AR account (151x).
|
|
SELECT COALESCE(SUM(credit_amount), 0), MAX(currency)
|
|
INTO v_ar_credit_total, v_line_currency
|
|
FROM public.journal_entry_lines
|
|
WHERE journal_entry_id = p_journal_entry_id
|
|
AND account_number LIKE '151%'
|
|
AND credit_amount > 0;
|
|
END IF;
|
|
|
|
v_ar_credit_total := ROUND(v_ar_credit_total * 100) / 100;
|
|
|
|
IF v_ar_credit_total <= 0 THEN
|
|
RETURN jsonb_build_object('ok', false, 'code', 'LINK_VOUCHER_NO_AR_CREDIT');
|
|
END IF;
|
|
|
|
IF COALESCE(v_line_currency, v_invoice.currency) IS DISTINCT FROM v_invoice.currency THEN
|
|
RETURN jsonb_build_object(
|
|
'ok', false,
|
|
'code', 'LINK_VOUCHER_CURRENCY_MISMATCH',
|
|
'details', jsonb_build_object(
|
|
'invoice_currency', v_invoice.currency,
|
|
'line_currency', v_line_currency
|
|
)
|
|
);
|
|
END IF;
|
|
|
|
IF v_ar_credit_total > v_remaining + 0.005 THEN
|
|
RETURN jsonb_build_object(
|
|
'ok', false,
|
|
'code', 'LINK_VOUCHER_AMOUNT_EXCEEDS_REMAINING',
|
|
'details', jsonb_build_object(
|
|
'ar_credit', v_ar_credit_total,
|
|
'remaining', ROUND(v_remaining * 100) / 100
|
|
)
|
|
);
|
|
END IF;
|
|
|
|
-- 4. Reject re-link of the same voucher to the same invoice. Authoritative
|
|
-- under the FOR UPDATE lock; the partial unique index
|
|
-- idx_invoice_payments_je_inv_unique stays as the last line of defence
|
|
-- for non-RPC writers.
|
|
IF EXISTS (
|
|
SELECT 1 FROM public.invoice_payments
|
|
WHERE company_id = p_company_id
|
|
AND invoice_id = p_invoice_id
|
|
AND journal_entry_id = p_journal_entry_id
|
|
) THEN
|
|
RETURN jsonb_build_object('ok', false, 'code', 'LINK_VOUCHER_ALREADY_LINKED');
|
|
END IF;
|
|
|
|
-- 5. Compute the advance.
|
|
v_payment_amount := LEAST(v_ar_credit_total, ROUND(v_remaining * 100) / 100);
|
|
v_new_remaining := GREATEST(0,
|
|
ROUND((v_remaining - v_payment_amount) * 100) / 100
|
|
);
|
|
v_new_paid := ROUND((COALESCE(v_invoice.paid_amount, 0) + v_payment_amount) * 100) / 100;
|
|
v_is_fully_paid := v_new_remaining <= 0.005;
|
|
v_new_status := CASE WHEN v_is_fully_paid THEN 'paid' ELSE 'partially_paid' END;
|
|
|
|
-- 6. Apply both writes. The RPC body is one transaction; a failure on the
|
|
-- INSERT triggers PG's own rollback of the UPDATE — no manual rollback
|
|
-- path needed.
|
|
UPDATE public.invoices
|
|
SET status = v_new_status,
|
|
paid_at = CASE WHEN v_is_fully_paid THEN v_now ELSE paid_at END,
|
|
paid_amount = v_new_paid,
|
|
remaining_amount = v_new_remaining,
|
|
updated_at = v_now
|
|
WHERE id = p_invoice_id;
|
|
|
|
INSERT INTO public.invoice_payments (
|
|
user_id, company_id, invoice_id, payment_date, amount, currency,
|
|
exchange_rate, journal_entry_id, transaction_id, notes
|
|
) VALUES (
|
|
v_acting_user, p_company_id, p_invoice_id, v_voucher.entry_date,
|
|
v_payment_amount, v_invoice.currency, v_invoice.exchange_rate,
|
|
p_journal_entry_id, NULL, p_notes
|
|
)
|
|
RETURNING id INTO v_payment_id;
|
|
|
|
RETURN jsonb_build_object(
|
|
'ok', true,
|
|
'payment_id', v_payment_id,
|
|
'invoice_status', v_new_status,
|
|
'paid_amount', v_new_paid,
|
|
'remaining_amount', v_new_remaining,
|
|
'payment_amount', v_payment_amount,
|
|
'journal_entry_id', p_journal_entry_id,
|
|
'currency', v_invoice.currency,
|
|
'payment_date', v_voucher.entry_date
|
|
);
|
|
END;
|
|
$$;
|
|
|
|
-- CREATE OR REPLACE preserves privileges, but re-apply the canonical write-RPC
|
|
-- grants explicitly (audit A5): never callable anonymously; authenticated covers
|
|
-- user sessions, service_role covers the MCP / API-key paths.
|
|
REVOKE ALL ON FUNCTION public.link_invoice_to_voucher(uuid, uuid, uuid, uuid, text) FROM PUBLIC, anon;
|
|
GRANT EXECUTE ON FUNCTION public.link_invoice_to_voucher(uuid, uuid, uuid, uuid, text) TO authenticated, service_role;
|
|
|
|
COMMENT ON FUNCTION public.link_invoice_to_voucher(uuid, uuid, uuid, uuid, text) IS
|
|
'Atomically link an existing posted verifikat as payment for a customer invoice. Locks the invoice row, validates the voucher (faktureringsmetoden: credits 151x; kontantmetoden: debits 19xx), advances paid_amount/remaining_amount/status, and inserts an invoice_payments row in one PG transaction. Returns jsonb { ok, ..., payment_id } on success or { ok: false, code, details } on guard failure.';
|
|
|
|
NOTIFY pgrst, 'reload schema';
|