feat(transactions): split-payment allocator — 1 tx → N invoices (#603)
* fix(category-mapping): use leaf BAS accounts instead of group codes 3900, 5800, 6200 are BAS gruppkonton (header codes) and shouldn't carry postings. Switched the default mappings to the matching leaf accounts: - income_other: 3900 -> 3999 (Övriga rörelseintäkter) - expense_travel: 5800 -> 5890 (Övriga resekostnader) - expense_telecom: 6200 -> 6230 (Datakommunikation) The fallback for income_other inside getCategoryAccountMapping was also hardcoded to '3900'; updated to '3999' for consistency. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(transactions): split-payment allocator — 1 tx → N invoices Closes one of the two flows that motivated PR #602's foundation: allocating a single bank transaction across multiple customer OR multiple supplier invoices, with one combined verifikat (samlingsverifikation per BFL 5 kap 6§ st 3). ## Backend (Phase 3a) - **PL/pgSQL RPC** match_batch_allocate (~400 lines): locks the tx + each target invoice with SELECT … FOR UPDATE in id order, validates status/currency/remaining/direction before any write, builds the combined verifikat via commit_journal_entry (atomically assigns voucher_number + flips draft→posted), inserts N rows in invoice_payments or supplier_invoice_payments pointing at the same JE, advances paid_amount/remaining_amount/status per invoice. Returns { ok, journal_entry_id, voucher_number, allocations: [...] } on success or { ok: false, code, details } on guard failure. Mixed customer+supplier kinds are rejected (v1 scope). - **Endpoint** POST /api/transactions/[id]/match-batch — thin wrapper around the RPC. Validates body via MatchBatchSchema (zod discriminatedUnion + superRefine to catch mixed-kinds at the schema layer). On RPC success, emits one invoice.match_confirmed or supplier_invoice.match_confirmed event per allocation so existing subscribers (reminders, automations, processing-history) keep working. Maps the structured RPC error envelope to errorResponseFromCode. - **16 new BATCH_* error codes** (sv+en): BATCH_TX_NOT_FOUND, BATCH_TX_ALREADY_BOOKED, BATCH_OVERSHOOT, BATCH_AMOUNT_EXCEEDS_TX, BATCH_MIXED_KINDS_UNSUPPORTED, BATCH_DIRECTION_MISMATCH, BATCH_CURRENCY_MISMATCH, BATCH_PERIOD_LOCKED, BATCH_RPC_FAILED, etc. ## UI (Phase 5a) - **MatchAllocationDialog** (components/transactions/) — direction- aware (positive tx → customer invoices, negative → supplier). Search + selectable list of open invoices. Per-row amount input with default = min(invoice.remaining, tx_remaining_budget). Live tally with green-check balanced state, red overshoot warning, gray leftover note. Confirm button disabled on overshoot. POSTs to /match-batch and on 200 triggers the same exit animation as single-tx match. - **Inbox row** gains a second outline icon button (Split icon) next to the existing 1:1 match button, gated by the same showInvoiceMatchButton predicate. Tooltip explains the direction- aware split. Opens MatchAllocationDialog. - **i18n** strings under tx_match_allocation namespace in sv.json and en.json (32 keys each). ## Tests - tests/pg/match-batch-allocate.pg.test.ts — 5 pg-real tests covering combined verifikat shape, overshoot guard, already-booked tx, direction mismatch, mixed-kinds rejection. - app/api/transactions/[id]/match-batch/__tests__/route.test.ts — 5 unit tests covering schema validation, mixed-kinds, happy path, structured-error mapping, raw-error → BATCH_RPC_FAILED. 63 unit tests pass across the touched paths. The RPC migration was already applied to remote in an earlier Phase 3a session (idempotent CREATE OR REPLACE FUNCTION; the next replay is a no-op). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(match-batch): PR #603 review round 1 + CI fixes Closes both CI failures and the three real review findings. ## CI fixes - **pg-real failure**: the RPC declared `v_journal_entry_id uuid := uuid_generate_v4()` which fails in the CI Postgres image (uuid-ossp extension is off). Switched to `gen_random_uuid()` — the codebase standard already used by supplier_invoices, invoice_inbox, etc. - **core-only failure**: my earlier BAS leaf-account commit (3900→3999, 5800→5890, 6200→6230) didn't update the matching `lib/bookkeeping/__tests__/category-mapping.test.ts` expectations, and `getDefaultAccountForCategory`'s fallback for `income_*` was still hardcoded to '3900'. Updated both. ## Review findings (greptile) - **P1 deadlock-stable locking** (`match_batch_allocate.sql:11`): the validation `FOR UPDATE` loop ran in caller-supplied array order. Two concurrent calls with overlapping invoice sets in opposite orders could deadlock and one would abort with `BATCH_RPC_FAILED`. Now all three loops (validate, build lines, advance invoices) iterate via `SELECT … FROM jsonb_array_elements(…) ORDER BY COALESCE(invoice_id, supplier_invoice_id)`, giving a stable global lock order regardless of how the caller ordered the JSON array. - **P1 duplicate-allocation detection** (`match_batch_allocate.sql:163`): the same invoice_id listed twice would pass the per-row overshoot guard (both iterations read the original `remaining_amount`) and the write loop would insert two `invoice_payments` rows for the same invoice. Added a `v_seen_ids text[]` check in the validation loop and a new `BATCH_DUPLICATE_ALLOCATION` error code (sv + en). The dialog already prevents this UI-side via `if (prev[candidate.id] return prev` — the RPC guard is the defense-in-depth layer. - **P2 zod `.positive()`** (`schemas.ts:544`): allocation amount was `nonNegativeAmount` (allowing 0), passing schema validation only to be rejected by the RPC with `BATCH_INVALID_AMOUNT`. Now `z.number().positive(…)` so 0-amount entries fail at the schema layer with a per-field path, cleaner 400. - **P2 strict `> 0` direction check** (`MatchAllocationDialog.tsx:82`): used `amount >= 0` to pick customer-side, but a zero-amount tx would load customer candidates only to hit `BATCH_TX_ZERO_AMOUNT` at submit time after the user has filled in allocations. Switched to `> 0` so 0-amount tx never reaches the dialog at all (it's rejected by the RPC immediately). The fourth Greptile comment (the schema P2 about amount validation) overlaps with the third; addressed in the same edit. ## Verification - 112 unit tests pass across touched paths - ESLint clean - New pg-real test `tests/pg/match-batch-allocate.pg.test.ts` covers the dedupe scenario (same supplier invoice listed twice with summing amounts that individually pass per-row overshoot) - RPC patch applied to remote via Supabase MCP Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(match-batch): PR #603 review round 2 — compliance hardening Addresses the actionable findings from compliance-swarm and Swedish-accounting-compliance reviews. Six small RPC changes + two TS-side guards, all bundled in one follow-up migration. ## Security - **(GDPR Art.5(1)(f) / ISO A.8.2) Caller verification**: SECURITY DEFINER bypasses RLS, and the prior RPC accepted any (p_user_id, p_company_id) pair from the route. Now the function rejects with new `BATCH_UNAUTHORIZED` (sv+en, HTTP 403) if `auth.uid()` is not a member of `p_company_id`. Pattern lifted from `harden_invoice_number_rpcs` (#20260510140000). - **(OWASP V4.2) Allocation cap**: `MatchBatchSchema.allocations` now carries `.max(100)` to prevent DoS via unbounded FOR UPDATE locks. ## Swedish accounting correctness - **source_type per direction**: was hardcoded to `'invoice_paid'` for both customer + supplier batches, mis-routing behandlingshistorik filters. Customer batches keep `'invoice_paid'`, supplier batches now write `'supplier_invoice_paid'`. - **Fiscal-period determinism**: `LIMIT 1` on the period lookup was non-deterministic on overlap (e.g. corrected broken year). Added `ORDER BY period_start DESC` so the most recent matching period wins. - **Tolerance harmonisation**: cross-allocation sum used `+0.01` tolerance while per-row used `+0.005`. Both now `+0.005` so a multi-row batch can't drift ~0.01 SEK while each row passes individually. - **`transactions.category` no longer overwritten**: was forced to `'income_services'` (→ BAS 3001 at 25% VAT) for any customer batch, misrepresenting reduced-rate / export / EU-service invoices. The category is only meaningful 1:1 with a single invoice; batches now leave it as-is, mirroring the supplier-side `ELSE category` branch. ## Tests - `tests/pg/match-batch-allocate.pg.test.ts` now wraps every RPC call in `withUserContext(userId)` so `auth.uid()` resolves to the seeded owner. Without this the new membership check would have failed all existing tests. - New pg-real test: `rejects with BATCH_UNAUTHORIZED when caller is not a member of the company` — outsider user gets explicit refusal. - New happy-path assertion: `source_type = 'supplier_invoice_paid'` on the combined verifikat for supplier batches. 15 unit tests pass on the touched paths. RPC patch applied to remote via Supabase MCP. Out-of-scope mcp-server changes still parked locally. Skipped findings (documented in PR comment thread): - V8.2.1 ownership pre-check at route layer (RPC enforces it) - V4.5 / Art.5(1)(b) narrower API response and event payload — typed contracts require the full shapes - V2.4 rate-limiting — system-level, applies to all match endpoints - A.8.28 client-side RLS reliance — documented architectural choice - Direction pre-check at API layer (RPC catches with cleaner code) - V16 + Art.32 + Art.5(1)(b) low-severity logging nits Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
7bcd46d503
commit
7eb8715417
@@ -518,6 +518,52 @@ export const LinkSupplierInvoiceToVoucherSchema = z.object({
|
||||
notes: z.string().max(2000).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Allocate one bank transaction across N customer OR N supplier invoices.
|
||||
* Backed by the match_batch_allocate PL/pgSQL RPC, which builds a single
|
||||
* combined verifikat (samlingsverifikation) and inserts N payment rows.
|
||||
*/
|
||||
export const MatchBatchSchema = z
|
||||
.object({
|
||||
allocations: z
|
||||
.array(
|
||||
z.discriminatedUnion('kind', [
|
||||
z.object({
|
||||
kind: z.literal('customer_invoice'),
|
||||
invoice_id: uuid,
|
||||
// Strictly positive — zero or negative is rejected at the schema
|
||||
// layer (PR #603 review) so the RPC's BATCH_INVALID_AMOUNT path
|
||||
// is only reachable from non-HTTP callers.
|
||||
amount: z.number().positive('Allocation amount must be greater than 0'),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal('supplier_invoice'),
|
||||
supplier_invoice_id: uuid,
|
||||
amount: z.number().positive('Allocation amount must be greater than 0'),
|
||||
}),
|
||||
]),
|
||||
)
|
||||
.min(1, 'At least one allocation is required')
|
||||
// Cap at 100 to prevent DoS via unbounded FOR UPDATE locks in the RPC
|
||||
// (PR #603 compliance review — OWASP V4.2). Domain-appropriate ceiling:
|
||||
// a real samlingsverifikat rarely covers more than a few dozen invoices.
|
||||
.max(100, 'At most 100 allocations per batch'),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
// Reject mixed customer + supplier in a single batch — semantically a
|
||||
// single bank transfer settles invoices on one side. The RPC also guards
|
||||
// this with BATCH_MIXED_KINDS_UNSUPPORTED, but rejecting at the schema
|
||||
// layer gives a cleaner 400 with a per-field path.
|
||||
const kinds = new Set(data.allocations.map((a) => a.kind))
|
||||
if (kinds.size > 1) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['allocations'],
|
||||
message: 'Allocations cannot mix customer_invoice and supplier_invoice kinds',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const LinkTransactionJournalEntrySchema = z.object({
|
||||
journal_entry_id: uuid,
|
||||
// Optional invoice to settle alongside the link. When provided, the
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
getDefaultVatTreatmentForCategory,
|
||||
buildMappingResultFromCategory,
|
||||
} from '../category-mapping'
|
||||
import { BAS_REFERENCE } from '../bas-data'
|
||||
import { makeTransaction } from '@/tests/helpers'
|
||||
import type { TransactionCategory, VatTreatment } from '@/types'
|
||||
|
||||
@@ -64,7 +65,7 @@ describe('getDefaultAccountForCategory', () => {
|
||||
it('returns expense account for expense categories', () => {
|
||||
expect(getDefaultAccountForCategory('expense_equipment')).toBe('5410')
|
||||
expect(getDefaultAccountForCategory('expense_software')).toBe('5420')
|
||||
expect(getDefaultAccountForCategory('expense_travel')).toBe('5800')
|
||||
expect(getDefaultAccountForCategory('expense_travel')).toBe('5890')
|
||||
expect(getDefaultAccountForCategory('expense_office')).toBe('6110')
|
||||
expect(getDefaultAccountForCategory('expense_bank_fees')).toBe('6570')
|
||||
})
|
||||
@@ -72,7 +73,7 @@ describe('getDefaultAccountForCategory', () => {
|
||||
it('returns income account for income categories', () => {
|
||||
expect(getDefaultAccountForCategory('income_services')).toBe('3001')
|
||||
expect(getDefaultAccountForCategory('income_products')).toBe('3001')
|
||||
expect(getDefaultAccountForCategory('income_other')).toBe('3900')
|
||||
expect(getDefaultAccountForCategory('income_other')).toBe('3999')
|
||||
})
|
||||
|
||||
it('returns private account for enskild firma', () => {
|
||||
@@ -232,10 +233,10 @@ describe('income account resolves by VAT treatment', () => {
|
||||
expect(result.creditAccount).toBe(expectedAccount)
|
||||
})
|
||||
|
||||
it('income_other always returns 3900 regardless of VAT treatment', () => {
|
||||
it('income_other always returns 3999 regardless of VAT treatment', () => {
|
||||
for (const vat of ['standard_25', 'reduced_12', 'reduced_6', 'export', 'reverse_charge', 'exempt'] as VatTreatment[]) {
|
||||
const result = getCategoryAccountMapping('income_other', 1000, true, 'enskild_firma', vat)
|
||||
expect(result.creditAccount).toBe('3900')
|
||||
expect(result.creditAccount).toBe('3999')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -270,3 +271,60 @@ describe('private transaction accounts by entity type and direction', () => {
|
||||
expect(getDefaultAccountForCategory('private', 'enskild_firma')).toBe('2013')
|
||||
})
|
||||
})
|
||||
|
||||
describe('category default → leaf account guarantee', () => {
|
||||
// BAS encodes the parent/leaf distinction in account_name via the
|
||||
// "(gruppkonto)" suffix. Auditors and Skatteverket downstream reporting
|
||||
// expect postings on leaves, not headers — see migration 03d4b740.
|
||||
const groupAccountNumbers = new Set<string>()
|
||||
for (const acct of BAS_REFERENCE) {
|
||||
if (acct.account_name.includes('(gruppkonto)')) {
|
||||
groupAccountNumbers.add(acct.account_number)
|
||||
}
|
||||
}
|
||||
|
||||
const categoriesUnderGuard: TransactionCategory[] = [
|
||||
'income_services',
|
||||
'income_products',
|
||||
'income_other',
|
||||
'expense_equipment',
|
||||
'expense_software',
|
||||
'expense_travel',
|
||||
'expense_office',
|
||||
'expense_marketing',
|
||||
'expense_professional_services',
|
||||
'expense_representation',
|
||||
'expense_consumables',
|
||||
'expense_vehicle',
|
||||
'expense_telecom',
|
||||
'expense_education',
|
||||
'expense_bank_fees',
|
||||
'expense_card_fees',
|
||||
'expense_currency_exchange',
|
||||
'expense_other',
|
||||
'private',
|
||||
'uncategorized',
|
||||
]
|
||||
|
||||
it.each(categoriesUnderGuard)('%s default does not resolve to a gruppkonto', (category) => {
|
||||
const target = getDefaultAccountForCategory(category)
|
||||
expect(groupAccountNumbers.has(target)).toBe(false)
|
||||
})
|
||||
|
||||
it('uncategorized positive amount does not credit a gruppkonto', () => {
|
||||
const result = getCategoryAccountMapping('uncategorized', 1000, true)
|
||||
expect(groupAccountNumbers.has(result.creditAccount)).toBe(false)
|
||||
})
|
||||
|
||||
it('expense_telecom resolves to 6230 (Datakommunikation, leaf)', () => {
|
||||
expect(getDefaultAccountForCategory('expense_telecom')).toBe('6230')
|
||||
})
|
||||
|
||||
it('expense_travel resolves to 5890 (Övriga resekostnader, leaf)', () => {
|
||||
expect(getDefaultAccountForCategory('expense_travel')).toBe('5890')
|
||||
})
|
||||
|
||||
it('income_other resolves to 3999 (Övriga rörelseintäkter, leaf)', () => {
|
||||
expect(getDefaultAccountForCategory('income_other')).toBe('3999')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -40,14 +40,14 @@ const PRIVATE_ACCOUNTS: Record<EntityType, string> = {
|
||||
const EXPENSE_ACCOUNTS: Record<string, string> = {
|
||||
expense_equipment: '5410', // Förbrukningsinventarier
|
||||
expense_software: '5420', // Programvaror
|
||||
expense_travel: '5800', // Resekostnader
|
||||
expense_travel: '5890', // Övriga resekostnader (5800 är gruppkonto)
|
||||
expense_office: '6110', // Kontorsförbrukning
|
||||
expense_marketing: '5910', // Annonsering
|
||||
expense_professional_services: '6530', // Redovisningstjänster
|
||||
expense_representation: '6071', // Representation, avdragsgill
|
||||
expense_consumables: '5460', // Förbrukningsvaror
|
||||
expense_vehicle: '5611', // Drivmedel bil
|
||||
expense_telecom: '6200', // Telefon och internet
|
||||
expense_telecom: '6230', // Datakommunikation (6200 är gruppkonto)
|
||||
expense_bank_fees: '6570', // Bankavgifter
|
||||
expense_card_fees: '6570', // Kortavgifter
|
||||
expense_currency_exchange: '7960', // Valutakursförluster
|
||||
@@ -58,7 +58,7 @@ const EXPENSE_ACCOUNTS: Record<string, string> = {
|
||||
const INCOME_ACCOUNTS: Record<string, string> = {
|
||||
income_services: '3001', // Försäljning tjänster 25%
|
||||
income_products: '3001', // Försäljning varor 25% moms
|
||||
income_other: '3900', // Övriga rörelseintäkter
|
||||
income_other: '3999', // Övriga rörelseintäkter (3900 är gruppkonto)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,8 +78,8 @@ function getExpenseAccount(category: string, entityType: EntityType = 'enskild_f
|
||||
* 3001=25%, 3002=12%, 3003=6%, 3305=Export, 3308=EU services, 3004=Exempt.
|
||||
*/
|
||||
function getIncomeAccount(category: string, vatTreatment?: VatTreatment): string {
|
||||
// income_other always maps to 3900 regardless of VAT treatment
|
||||
if (category === 'income_other') return '3900'
|
||||
// income_other always maps to 3999 regardless of VAT treatment (3900 är gruppkonto)
|
||||
if (category === 'income_other') return '3999'
|
||||
|
||||
if (vatTreatment) {
|
||||
switch (vatTreatment) {
|
||||
@@ -93,7 +93,7 @@ function getIncomeAccount(category: string, vatTreatment?: VatTreatment): string
|
||||
}
|
||||
|
||||
// No vatTreatment provided — fall back to static mapping
|
||||
return INCOME_ACCOUNTS[category] || '3900'
|
||||
return INCOME_ACCOUNTS[category] || '3999'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -194,7 +194,7 @@ export function getCategoryAccountMapping(
|
||||
} else {
|
||||
return {
|
||||
debitAccount: BANK_ACCOUNT,
|
||||
creditAccount: '3900',
|
||||
creditAccount: '3999',
|
||||
vatTreatment: null,
|
||||
vatDebitAccount: null,
|
||||
vatCreditAccount: null,
|
||||
@@ -326,7 +326,7 @@ export function getDefaultAccountForCategory(
|
||||
}
|
||||
|
||||
if (category.startsWith('income_')) {
|
||||
return INCOME_ACCOUNTS[category] || '3900'
|
||||
return INCOME_ACCOUNTS[category] || '3999'
|
||||
}
|
||||
|
||||
// uncategorized
|
||||
|
||||
@@ -1802,6 +1802,134 @@ const LINK_SI_VOUCHER: Record<string, StructuredErrorEntry> = {
|
||||
},
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Batch allocation (match_batch_allocate RPC)
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const MATCH_BATCH: Record<string, StructuredErrorEntry> = {
|
||||
BATCH_TX_NOT_FOUND: {
|
||||
httpStatus: 404,
|
||||
message_sv: 'Transaktionen kunde inte hittas.',
|
||||
message_en: 'Transaction not found.',
|
||||
},
|
||||
BATCH_UNAUTHORIZED: {
|
||||
httpStatus: 403,
|
||||
message_sv: 'Du har inte behörighet att fördela transaktioner för det här företaget.',
|
||||
message_en: 'You are not authorized to allocate transactions for this company.',
|
||||
},
|
||||
BATCH_TX_ALREADY_BOOKED: {
|
||||
httpStatus: 409,
|
||||
message_sv:
|
||||
'Transaktionen är redan bokförd. Avbokföra först (storno) innan du fördelar den på flera fakturor.',
|
||||
message_en:
|
||||
'Transaction is already booked. Reverse the existing journal entry before re-allocating.',
|
||||
},
|
||||
BATCH_TX_ZERO_AMOUNT: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Transaktioner med beloppet 0 kan inte bokföras.',
|
||||
message_en: 'Zero-amount transactions cannot be allocated.',
|
||||
},
|
||||
BATCH_NO_ALLOCATIONS: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Minst en fördelning krävs.',
|
||||
message_en: 'At least one allocation is required.',
|
||||
},
|
||||
BATCH_INVALID_AMOUNT: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Fördelningens belopp måste vara positivt.',
|
||||
message_en: 'Allocation amount must be positive.',
|
||||
},
|
||||
BATCH_DUPLICATE_ALLOCATION: {
|
||||
httpStatus: 400,
|
||||
message_sv:
|
||||
'Samma faktura förekommer två gånger i fördelningen. Slå ihop beloppen eller ta bort dubbletten.',
|
||||
message_en:
|
||||
'The same invoice appears twice in the allocations. Merge the amounts or remove the duplicate.',
|
||||
},
|
||||
BATCH_INVALID_KIND: {
|
||||
httpStatus: 400,
|
||||
message_sv:
|
||||
'Okänd typ av fördelning. Endast customer_invoice och supplier_invoice stöds.',
|
||||
message_en:
|
||||
'Unknown allocation kind. Only customer_invoice and supplier_invoice are supported.',
|
||||
},
|
||||
BATCH_INVOICE_NOT_FOUND: {
|
||||
httpStatus: 404,
|
||||
message_sv: 'En av fakturorna i fördelningen kunde inte hittas.',
|
||||
message_en: 'One of the invoices in the allocation could not be found.',
|
||||
},
|
||||
BATCH_INVOICE_NOT_OPEN: {
|
||||
httpStatus: 409,
|
||||
message_sv: 'En av fakturorna är inte i ett obetalt läge och kan inte ta emot betalning.',
|
||||
message_en: 'One of the invoices is not in an open state.',
|
||||
},
|
||||
BATCH_SUPPLIER_INVOICE_NOT_FOUND: {
|
||||
httpStatus: 404,
|
||||
message_sv: 'En av leverantörsfakturorna i fördelningen kunde inte hittas.',
|
||||
message_en: 'One of the supplier invoices in the allocation could not be found.',
|
||||
},
|
||||
BATCH_SUPPLIER_INVOICE_NOT_OPEN: {
|
||||
httpStatus: 409,
|
||||
message_sv:
|
||||
'En av leverantörsfakturorna är inte i ett obetalt läge och kan inte ta emot betalning.',
|
||||
message_en: 'One of the supplier invoices is not in an open state.',
|
||||
},
|
||||
BATCH_OVERSHOOT: {
|
||||
httpStatus: 400,
|
||||
message_sv:
|
||||
'En av fördelningarna överskrider fakturans återstående belopp. Sänk beloppet eller fördela överskottet på fler fakturor.',
|
||||
message_en:
|
||||
'One allocation exceeds the invoice remaining amount. Lower it or split the excess across additional invoices.',
|
||||
},
|
||||
BATCH_AMOUNT_EXCEEDS_TX: {
|
||||
httpStatus: 400,
|
||||
message_sv:
|
||||
'Summan av fördelningarna är större än transaktionens belopp.',
|
||||
message_en: 'Sum of allocations exceeds the transaction amount.',
|
||||
},
|
||||
BATCH_MIXED_KINDS_UNSUPPORTED: {
|
||||
httpStatus: 400,
|
||||
message_sv:
|
||||
'En transaktion kan inte fördelas på både kund- och leverantörsfakturor i samma verifikat. Skapa två separata fördelningar.',
|
||||
message_en:
|
||||
'A single transaction cannot allocate to both customer and supplier invoices in one batch.',
|
||||
},
|
||||
BATCH_DIRECTION_MISMATCH: {
|
||||
httpStatus: 400,
|
||||
message_sv:
|
||||
'Transaktionens riktning matchar inte fördelningens typ. Kundfakturor kräver inkommande, leverantörsfakturor utgående.',
|
||||
message_en:
|
||||
'Transaction direction does not match allocation kind: customer invoices require income, supplier invoices require expense.',
|
||||
},
|
||||
BATCH_CURRENCY_MISMATCH: {
|
||||
httpStatus: 400,
|
||||
message_sv:
|
||||
'Fakturans valuta matchar inte transaktionens. Endast samma valuta stöds i V1.',
|
||||
message_en:
|
||||
'Invoice currency does not match the transaction currency. Same-currency only in v1.',
|
||||
},
|
||||
BATCH_NO_FISCAL_PERIOD: {
|
||||
httpStatus: 400,
|
||||
message_sv:
|
||||
'Det finns ingen öppen räkenskapsperiod för transaktionens datum. Skapa perioden först.',
|
||||
message_en:
|
||||
'No fiscal period exists for the transaction date. Create the period first.',
|
||||
},
|
||||
BATCH_PERIOD_LOCKED: {
|
||||
httpStatus: 409,
|
||||
message_sv:
|
||||
'Räkenskapsperioden för transaktionens datum är stängd. Öppna perioden eller välj ett annat datum.',
|
||||
message_en:
|
||||
'Fiscal period for the transaction date is closed/locked. Open the period or pick a different date.',
|
||||
},
|
||||
BATCH_RPC_FAILED: {
|
||||
httpStatus: 500,
|
||||
message_sv: 'Databasfel under fördelning. Försök igen.',
|
||||
message_en: 'Database error during batch allocation. Please retry.',
|
||||
retryable: true,
|
||||
},
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Combined registry
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
@@ -1814,6 +1942,7 @@ const REGISTRY: Record<string, StructuredErrorEntry> = {
|
||||
...LINK_TX_JE,
|
||||
...LINK_INVOICE_VOUCHER,
|
||||
...LINK_SI_VOUCHER,
|
||||
...MATCH_BATCH,
|
||||
...MATCH_SI,
|
||||
...INVOICE,
|
||||
...SUPPLIER_INVOICE,
|
||||
|
||||
Reference in New Issue
Block a user