/** * Canonical registry of structured error codes used by both REST routes and * the MCP server. * * Each entry defines: * - httpStatus: status returned by errorResponse() for this code * - message_sv: Swedish user-facing message (consumed by toast) * - message_en: English message for agents and developer logs * - remediation: optional pointer to a fix (tool/resource/description) * * Adding a new code = add a row here. The error-code-matrix in * `.claude/plans/for-all-of-those-mutable-sunset.md` lists the codes per * operation; keep that document and this file in sync. * * Codes follow `__` naming. Stable forever once * shipped — agents pattern-match on them. */ export interface StructuredErrorRemediation { description: string tool?: string args?: Record resource?: string } export interface StructuredErrorEntry { httpStatus: number message_sv: string message_en: string remediation?: StructuredErrorRemediation } // ───────────────────────────────────────────────────────────────── // Generic / cross-cutting codes // ───────────────────────────────────────────────────────────────── const GENERIC: Record = { UNKNOWN_ERROR: { httpStatus: 500, message_sv: 'Något gick fel. Försök igen.', message_en: 'An unexpected error occurred.', }, INTERNAL_ERROR: { httpStatus: 500, message_sv: 'Ett oväntat serverfel uppstod. Försök igen senare.', message_en: 'Internal server error.', }, VALIDATION_ERROR: { httpStatus: 400, message_sv: 'Förfrågan innehåller ogiltiga uppgifter.', message_en: 'Validation error.', }, UNAUTHORIZED: { httpStatus: 401, message_sv: 'Din session har gått ut. Logga in igen.', message_en: 'Authentication required.', }, MFA_REQUIRED: { httpStatus: 403, message_sv: 'Tvåstegsverifiering krävs för att utföra åtgärden.', message_en: 'MFA verification required.', }, FORBIDDEN: { httpStatus: 403, message_sv: 'Du har inte behörighet att utföra denna åtgärd.', message_en: 'Insufficient permissions.', }, NOT_FOUND: { httpStatus: 404, message_sv: 'Resursen kunde inte hittas.', message_en: 'Resource not found.', }, CONFLICT: { httpStatus: 409, message_sv: 'En konflikt uppstod. Ladda om sidan och försök igen.', message_en: 'Conflict.', }, RATE_LIMITED: { httpStatus: 429, message_sv: 'För många förfrågningar. Vänta en stund och försök igen.', message_en: 'Rate limit exceeded.', }, NOT_IMPLEMENTED: { httpStatus: 501, message_sv: 'Funktionen är inte implementerad ännu.', message_en: 'This feature is accepted by the schema but not yet implemented.', }, COMPANY_CONTEXT_MISSING: { httpStatus: 400, message_sv: 'Ingen aktiv företagskontext. Välj ett företag och försök igen.', message_en: 'No active company context resolved for the request.', }, IDEMPOTENCY_KEY_REUSE: { httpStatus: 409, message_sv: 'Idempotensnyckeln har redan använts med en annan begäran.', message_en: 'Idempotency key was previously used with a different request body.', remediation: { description: 'Use a fresh UUID for a new operation, or send the original request body to replay.', }, }, INSUFFICIENT_SCOPE: { httpStatus: 403, message_sv: 'API-nyckeln saknar behörighet för denna åtgärd.', message_en: 'The current API key does not have the required scope.', remediation: { description: 'Mint a new key with the missing scope or grant it through the API key settings.', resource: 'gnubok://capabilities', }, }, } // ───────────────────────────────────────────────────────────────── // Bookkeeping engine codes (already used by lib/bookkeeping/errors.ts) // ───────────────────────────────────────────────────────────────── const BOOKKEEPING: Record = { ACCOUNTS_NOT_IN_CHART: { httpStatus: 400, message_sv: 'Konton saknas i kontoplanen.', message_en: 'One or more BAS accounts are not active in the chart of accounts.', remediation: { description: 'Activate the missing accounts via bookkeeping settings, or use a different category.', resource: 'gnubok://chart-of-accounts', }, }, JOURNAL_ENTRY_NOT_BALANCED: { httpStatus: 400, message_sv: 'Verifikationen balanserar inte.', message_en: 'Debits and credits do not match.', remediation: { description: 'Recalculate the lines so totals are equal before retrying.', }, }, FISCAL_PERIOD_NOT_FOUND: { httpStatus: 404, message_sv: 'Räkenskapsperioden kunde inte hittas.', message_en: 'No fiscal period covers the entry date.', remediation: { description: 'Create or extend the relevant fiscal period before retrying.', resource: 'gnubok://period/active', }, }, ENTRY_DATE_OUTSIDE_FISCAL_PERIOD: { httpStatus: 400, message_sv: 'Datumet ligger utanför det valda räkenskapsåret.', message_en: 'Entry date is outside the active fiscal period.', remediation: { description: 'Use a date inside an open period or create one that covers it.', resource: 'gnubok://period/active', }, }, JOURNAL_ENTRY_NOT_FOUND: { httpStatus: 404, message_sv: 'Verifikationen kunde inte hittas.', message_en: 'Journal entry not found.', }, CANNOT_REVERSE_NON_POSTED: { httpStatus: 400, message_sv: 'Endast bokförda verifikationer kan stornas.', message_en: 'Only posted entries can be reversed.', }, CANNOT_CORRECT_NON_POSTED: { httpStatus: 400, message_sv: 'Endast bokförda verifikationer kan rättas.', message_en: 'Only posted entries can be corrected.', }, ENTRY_ALREADY_REVERSED: { httpStatus: 409, message_sv: 'Verifikationen har redan stornats av en annan användare. Ladda om sidan och försök igen.', message_en: 'Entry was already reversed by a concurrent operation.', }, CURRENCY_REVALUATION_ALREADY_EXISTS: { httpStatus: 409, message_sv: 'En valutaomvärdering finns redan för denna period.', message_en: 'Currency revaluation already exists for this period.', }, INVALID_MAPPING_RESULT: { httpStatus: 400, message_sv: 'Kontering saknas för transaktionen. Kontrollera bokföringsreglerna.', message_en: 'Mapping rules produced an invalid debit/credit account pair.', }, BOOKKEEPING_DATABASE_ERROR: { httpStatus: 500, message_sv: 'Verifikationen kunde inte sparas. Försök igen.', message_en: 'Bookkeeping database operation failed.', }, PERIOD_LOCKED: { httpStatus: 400, message_sv: 'Bokföringen är låst för denna period.', message_en: 'Period is locked or closed; entries cannot be added.', }, PERIOD_NOT_LOCKED: { httpStatus: 400, message_sv: 'Perioden måste först låsas innan den kan stängas.', message_en: 'Period must be locked before it can be closed.', remediation: { description: 'Call gnubok_lock_period before closing.', tool: 'gnubok_lock_period', }, }, PERIOD_HAS_UNBOOKED_TRANSACTIONS: { httpStatus: 400, message_sv: 'Perioden innehåller okategoriserade affärstransaktioner. Bokför eller markera dem som privata innan låsning.', message_en: 'The period contains uncategorized business transactions.', remediation: { description: 'Categorize or mark uncategorized transactions before locking.', tool: 'gnubok_list_uncategorized_transactions', }, }, YEAR_END_NOT_RUN: { httpStatus: 400, message_sv: 'Bokslutsåtgärder måste utföras innan perioden kan stängas.', message_en: 'Year-end closing must be executed before the period can be closed.', }, TRANSACTION_ALREADY_CATEGORIZED: { httpStatus: 409, message_sv: 'Transaktionen är redan bokförd. Ångra kategoriseringen om du vill ändra den.', message_en: 'The transaction already has a journal entry.', remediation: { description: 'Use gnubok_uncategorize_transaction first if you need to recategorize.', tool: 'gnubok_uncategorize_transaction', }, }, INVOICE_ALREADY_SENT: { httpStatus: 409, message_sv: 'Fakturan har redan skickats eller betalats.', message_en: 'The invoice is already sent or paid.', }, } // ───────────────────────────────────────────────────────────────── // Wave 1: invoicing & transactions // ───────────────────────────────────────────────────────────────── const TRANSACTIONS: Record = { TX_CATEGORIZE_TX_NOT_FOUND: { httpStatus: 404, message_sv: 'Transaktionen kunde inte hittas.', message_en: 'Transaction not found.', }, TX_CATEGORIZE_INVALID_ACCOUNT: { httpStatus: 400, message_sv: 'Det valda kontot finns inte i kontoplanen.', message_en: 'The supplied account does not exist in the chart of accounts.', remediation: { description: 'Activate the account in the chart of accounts or pick a different one.', resource: 'gnubok://chart-of-accounts', }, }, TX_CATEGORIZE_INVALID_TEMPLATE: { httpStatus: 400, message_sv: 'Bokföringsmallen är ogiltig eller passar inte din bolagsform.', message_en: 'The supplied booking template is invalid or does not match the entity type.', }, TX_CATEGORIZE_INVALID_MAPPING: { httpStatus: 400, message_sv: 'Konteringen saknar debet- eller kreditkonto.', message_en: 'Mapping result is missing a debit or credit account.', }, TX_CATEGORIZE_RACE: { httpStatus: 409, message_sv: 'Transaktionen kategoriserades av en annan förfrågan. Ladda om och försök igen.', message_en: 'Transaction was already categorized by another request.', }, TX_CATEGORIZE_SUGGEST_SI_MATCH: { httpStatus: 409, message_sv: 'Det finns en öppen leverantörsfaktura från samma leverantör med samma belopp. Matcha mot fakturan istället för att bokföra direkt på leverantörsskuldskontot — annars skapas en dubblerad verifikation som måste stornas (BFL 5 kap 5 §).', message_en: 'An open supplier invoice from the same supplier matches this amount. Suggest matching to the invoice instead of a plain 244x categorization to avoid producing a duplicate verifikation (BFL 5 kap 5 §).', remediation: { description: 'Match the transaction via POST /api/transactions/{id}/match-supplier-invoice, or resend with confirm_no_match: true to keep the plain 244x categorization.', }, }, TX_CATEGORIZE_SUGGEST_CI_MATCH: { httpStatus: 409, message_sv: 'Det finns en obetald kundfaktura från samma kund med samma belopp. Matcha mot fakturan istället för att bokföra direkt mot kundfordringskontot — annars skapas en dubblerad verifikation som måste stornas (BFL 5 kap 5 §).', message_en: 'An unpaid customer invoice from the same customer matches this amount. Suggest matching to the invoice instead of a plain 151x categorization to avoid producing a duplicate verifikation (BFL 5 kap 5 §).', remediation: { description: 'Match the transaction via POST /api/transactions/{id}/match-invoice, or resend with confirm_no_match: true to keep the plain 151x categorization.', }, }, TX_UNCATEGORIZE_NO_LINKED_ENTRY: { httpStatus: 400, message_sv: 'Transaktionen har ingen kopplad verifikation att stornera.', message_en: 'Transaction has no linked journal entry to reverse.', }, TX_EXCHANGE_RATE_UNAVAILABLE: { httpStatus: 502, message_sv: 'Kunde inte hämta växelkursen från Riksbanken. Försök igen om en stund — verifikationen måste bokföras i SEK.', message_en: 'Could not fetch the exchange rate from Riksbanken. The verifikation must be posted in SEK.', }, } const MATCH_INVOICE: Record = { MATCH_INVOICE_NOT_FOUND: { httpStatus: 404, message_sv: 'Fakturan kunde inte hittas.', message_en: 'Invoice not found.', }, MATCH_INVOICE_NOT_INCOME: { httpStatus: 400, message_sv: 'Endast intäktstransaktioner kan matchas mot kundfakturor.', message_en: 'Only income transactions can be matched to customer invoices.', }, MATCH_INVOICE_TX_ALREADY_LINKED: { httpStatus: 400, message_sv: 'Transaktionen är redan kopplad till en faktura.', message_en: 'Transaction is already linked to an invoice.', }, MATCH_INVOICE_NOT_OPEN: { httpStatus: 400, message_sv: 'Fakturan är inte i ett obetalt läge och kan inte matchas.', message_en: 'Invoice is not in an unpaid state.', }, MATCH_INVOICE_NOT_INVOICE_TYPE: { httpStatus: 400, message_sv: 'Endast fakturor kan matchas mot en transaktion. Proforma och följesedel saknar momsskyldighet.', message_en: 'Only invoices may be matched to a transaction; proforma and delivery notes have no VAT obligation.', }, MATCH_INVOICE_ALREADY_PAID: { httpStatus: 409, message_sv: 'Fakturan har redan slutbetalats av en annan förfrågan.', message_en: 'Invoice has already been fully paid or is no longer matchable.', }, MATCH_INVOICE_DUPLICATE_PAYMENT: { httpStatus: 409, message_sv: 'Den här transaktionen är redan matchad mot fakturan.', message_en: 'This transaction is already matched to this invoice.', }, MATCH_INVOICE_RECORD_PAYMENT_FAILED: { httpStatus: 500, message_sv: 'Kunde inte registrera fakturabetalningen.', message_en: 'Failed to record invoice payment.', }, MATCH_INVOICE_LINK_TX_FAILED: { httpStatus: 500, message_sv: 'Kunde inte koppla transaktionen till fakturan.', message_en: 'Failed to link transaction to invoice.', }, MATCH_INVOICE_PARTIAL: { httpStatus: 200, message_sv: 'Matchningen registrerades men verifikationen kunde inte skapas.', message_en: 'Match recorded but the journal entry could not be created.', }, } const MATCH_SI: Record = { MATCH_SI_NOT_FOUND: { httpStatus: 404, message_sv: 'Leverantörsfakturan kunde inte hittas.', message_en: 'Supplier invoice not found.', }, MATCH_SI_NOT_EXPENSE: { httpStatus: 400, message_sv: 'Endast utgiftstransaktioner kan matchas mot leverantörsfakturor.', message_en: 'Only expense transactions can be matched to supplier invoices.', }, MATCH_SI_TX_ALREADY_LINKED: { httpStatus: 400, message_sv: 'Transaktionen är redan kopplad till en leverantörsfaktura.', message_en: 'Transaction is already linked to a supplier invoice.', }, MATCH_SI_ALREADY_PAID: { httpStatus: 400, message_sv: 'Leverantörsfakturan är redan betald eller krediterad.', message_en: 'Supplier invoice is already paid or credited.', }, MATCH_SI_NOT_OPEN: { httpStatus: 409, message_sv: 'Leverantörsfakturan har redan slutbetalats av en annan förfrågan.', message_en: 'Supplier invoice has already been fully paid or is no longer matchable.', }, MATCH_SI_DUPLICATE_PAYMENT: { httpStatus: 409, message_sv: 'Den här transaktionen är redan matchad mot leverantörsfakturan.', message_en: 'This transaction is already matched to this supplier invoice.', }, MATCH_SI_RECORD_PAYMENT_FAILED: { httpStatus: 500, message_sv: 'Kunde inte registrera leverantörsfakturabetalningen.', message_en: 'Failed to record supplier invoice payment.', }, MATCH_SI_LINK_TX_FAILED: { httpStatus: 500, message_sv: 'Kunde inte koppla transaktionen till leverantörsfakturan.', message_en: 'Failed to link transaction to supplier invoice.', }, MATCH_SI_CASH_FX_UNSUPPORTED: { httpStatus: 400, message_sv: 'Kontantmetoden stödjer inte valutakursdifferenser. Byt till löpande bokföring eller bokför valutakursdifferensen manuellt.', message_en: 'Cash accounting does not support exchange-rate differences. Switch to accrual or book the FX difference manually.', }, TX_UNCATEGORIZE_NOT_BOOKED: { httpStatus: 400, message_sv: 'Transaktionen är inte bokförd. Det finns inget att av-kategorisera.', message_en: 'Transaction has no journal entry — nothing to uncategorize.', }, TX_UNCATEGORIZE_JE_NOT_POSTED: { httpStatus: 400, message_sv: 'Verifikationen är inte bokförd. Reversal kan inte utföras.', message_en: 'Journal entry is not in posted status; reversal is not possible.', }, TX_INGEST_INSERT_FAILED: { httpStatus: 500, message_sv: 'Transaktionerna kunde inte importeras.', message_en: 'Transaction ingest failed.', }, TX_BATCH_CATEGORIZE_EMPTY: { httpStatus: 400, message_sv: 'Batchen är tom.', message_en: 'Batch is empty — pass at least one item.', }, } const INVOICE: Record = { INVOICE_CUSTOMER_NOT_FOUND: { httpStatus: 404, message_sv: 'Kunden kunde inte hittas.', message_en: 'Customer not found.', }, INVOICE_CREATE_VAT_RULE_VIOLATION: { httpStatus: 400, message_sv: 'Momssatsen är inte tillåten för denna kundtyp.', message_en: 'The VAT rate is not allowed for this customer type.', }, INVOICE_CREATE_INSERT_FAILED: { httpStatus: 500, message_sv: 'Fakturan kunde inte sparas.', message_en: 'Invoice insert failed.', }, INVOICE_CREATE_ITEMS_FAILED: { httpStatus: 500, message_sv: 'Fakturaraderna kunde inte sparas.', message_en: 'Invoice items insert failed.', }, INVOICE_CREATE_NUMBER_ASSIGN_FAILED: { httpStatus: 500, message_sv: 'Kunde inte tilldela fakturanummer vid skapande.', message_en: 'Failed to assign invoice number on create.', }, INVOICE_CREDIT_ORIGINAL_NOT_FOUND: { httpStatus: 404, message_sv: 'Ursprungsfakturan kunde inte hittas.', message_en: 'Original invoice not found.', }, INVOICE_CREDIT_NOT_INVOICE: { httpStatus: 400, message_sv: 'Kreditfakturor kan endast skapas från riktiga fakturor.', message_en: 'Credit notes can only be created from standard invoices.', }, INVOICE_CREDIT_ALREADY_CREDITED: { httpStatus: 400, message_sv: 'Fakturan har redan krediterats.', message_en: 'Invoice has already been credited.', }, INVOICE_CREDIT_NOT_SENT: { httpStatus: 400, message_sv: 'Endast skickade, betalda eller förfallna fakturor kan krediteras.', message_en: 'Only sent, paid, or overdue invoices can be credited.', }, INVOICE_SEND_EMAIL_NOT_CONFIGURED: { httpStatus: 503, message_sv: 'E-posttjänsten är inte konfigurerad. Kontrollera att RESEND_API_KEY och RESEND_FROM_EMAIL är satta.', message_en: 'Email service is not configured.', remediation: { description: 'Set RESEND_API_KEY and RESEND_FROM_EMAIL in the deployment environment.', }, }, INVOICE_SEND_NO_CUSTOMER_EMAIL: { httpStatus: 400, message_sv: 'Kunden saknar e-postadress. Uppdatera kunduppgifterna först.', message_en: 'Customer has no email address.', remediation: { description: 'Add an email address on the customer record before sending.' }, }, INVOICE_SEND_COMPANY_SETTINGS_MISSING: { httpStatus: 404, message_sv: 'Företagsinställningar saknas.', message_en: 'Company settings are missing.', }, INVOICE_SEND_NUMBER_ASSIGN_FAILED: { httpStatus: 500, message_sv: 'Kunde inte tilldela fakturanummer.', message_en: 'Failed to assign invoice number on send.', }, INVOICE_SEND_PROVIDER_FAILED: { httpStatus: 502, message_sv: 'E-postleverantören kunde inte skicka meddelandet.', message_en: 'The email provider could not deliver the message.', }, INVOICE_SEND_PDF_RENDER_FAILED: { httpStatus: 500, message_sv: 'Fakturans PDF kunde inte skapas. Kontrollera fakturarader och kunduppgifter och försök igen.', message_en: 'Failed to render invoice PDF before send; no invoice number was consumed.', }, INVOICE_PDF_RENDER_FAILED: { httpStatus: 500, message_sv: 'Fakturans PDF kunde inte skapas.', message_en: 'Invoice PDF rendering failed.', }, INVOICE_SEND_PARTIAL: { httpStatus: 200, message_sv: 'Fakturan skickades men en efterföljande åtgärd misslyckades (verifikation eller PDF-bilaga).', message_en: 'Invoice was sent but a follow-up step (journal entry or PDF) failed.', }, INVOICE_SEND_CANCELLED: { httpStatus: 400, message_sv: 'Makulerade fakturor kan inte skickas. Skapa en ny faktura istället.', message_en: 'Cancelled invoices cannot be sent; create a new invoice instead.', }, INVOICE_PAID_NOT_FOUND: { httpStatus: 404, message_sv: 'Fakturan kunde inte hittas.', message_en: 'Invoice not found.', }, INVOICE_PAID_NOT_PAYABLE: { httpStatus: 400, message_sv: 'Fakturan kan inte markeras som betald i nuvarande status.', message_en: 'Invoice is not in a payable status.', }, INVOICE_PAID_LINES_UNBALANCED: { httpStatus: 400, message_sv: 'Verifikationsraderna är inte balanserade (debet ≠ kredit).', message_en: 'Custom journal lines do not balance.', }, INVOICE_PAID_NO_FISCAL_PERIOD: { httpStatus: 400, message_sv: 'Ingen öppen räkenskapsperiod för betalningsdatumet.', message_en: 'No open fiscal period covers the payment date.', }, INVOICE_PAID_RACE: { httpStatus: 409, message_sv: 'Fakturan har redan betalats av en annan förfrågan.', message_en: 'Invoice was already paid by another request.', }, INVOICE_PAID_BOOK_FAILED: { httpStatus: 500, message_sv: 'Kunde inte bokföra betalningen.', message_en: 'Failed to create payment journal entry.', }, INVOICE_PAID_LIKELY_DUPLICATE: { httpStatus: 409, message_sv: 'Det finns redan en obokförd inkommande banktransaktion som kan vara denna betalning. Länka den istället, eller markera som betald ändå om du är säker.', message_en: 'A likely-matching unlinked inbound bank transaction was found for this customer. Suggest linking it instead of creating a new payment entry.', remediation: { description: 'Match the candidate transaction via POST /api/transactions/{id}/match-invoice, or resend mark-paid with force: true to create the payment entry anyway. When using the v1 endpoint, the force retry requires a fresh Idempotency-Key (the original key is bound to the body hash).', }, }, INVOICE_DELETE_NOT_DRAFT: { httpStatus: 400, message_sv: 'Endast utkast kan tas bort. Bokförda fakturor måste krediteras istället.', message_en: 'Only draft invoices can be deleted; non-drafts must be credited.', remediation: { description: 'Issue a credit note instead of deleting a posted invoice.', }, }, INVOICE_UPDATE_NOT_DRAFT: { httpStatus: 409, message_sv: 'Endast utkast kan ändras. Bokförda fakturor är oföränderliga — utfärda en kreditfaktura istället.', message_en: 'Only draft invoices can be updated. Issued invoices are immutable — issue a credit note instead.', remediation: { description: 'Issue a credit note via POST /invoices/{id}:credit and create a fresh invoice with the corrected details.', }, }, INVOICE_CANCEL_RACE: { httpStatus: 409, message_sv: 'Fakturan ändrades samtidigt och kunde inte makuleras. Ladda om och försök igen.', message_en: 'Invoice was modified concurrently and could not be cancelled. Reload and retry.', }, } const SUPPLIER_INVOICE: Record = { SI_NOT_FOUND: { httpStatus: 404, message_sv: 'Leverantörsfakturan kunde inte hittas.', message_en: 'Supplier invoice not found.', }, SI_APPROVE_NOT_REGISTERED: { httpStatus: 400, message_sv: 'Endast registrerade fakturor kan godkännas.', message_en: 'Only invoices in registered status can be approved.', }, SI_APPROVE_UPDATE_FAILED: { httpStatus: 500, message_sv: 'Kunde inte godkänna leverantörsfakturan.', message_en: 'Failed to update supplier invoice status to approved.', }, } // ───────────────────────────────────────────────────────────────── // Wave 2: periods, year-end, reports // ───────────────────────────────────────────────────────────────── const PERIOD: Record = { PERIOD_NOT_FOUND: { httpStatus: 404, message_sv: 'Räkenskapsperioden kunde inte hittas.', message_en: 'Fiscal period not found.', }, PERIOD_LOCK_FAILED: { httpStatus: 400, message_sv: 'Perioden kunde inte låsas.', message_en: 'Failed to lock period.', }, PERIOD_LOCK_HAS_DRAFTS: { httpStatus: 400, message_sv: 'Perioden innehåller verifikationsutkast som måste bokföras eller raderas innan låsning.', message_en: 'Period contains draft journal entries.', }, PERIOD_LOCK_ALREADY_LOCKED: { httpStatus: 409, message_sv: 'Perioden är redan låst.', message_en: 'Period is already locked.', }, } const YEAR_END: Record = { YEAR_END_PREVIEW_FAILED: { httpStatus: 400, message_sv: 'Bokslutsförhandsgranskningen misslyckades.', message_en: 'Failed to preview year-end closing.', }, YEAR_END_FAILED: { httpStatus: 400, message_sv: 'Bokslutet kunde inte verkställas.', message_en: 'Failed to execute year-end closing.', }, YEAR_END_PRIOR_PERIOD_OPEN: { httpStatus: 400, message_sv: 'En tidigare period är fortfarande öppen. Stäng den först.', message_en: 'A prior fiscal period is still open.', }, YEAR_END_UNBALANCED_TRIAL: { httpStatus: 400, message_sv: 'Resultaträkningens debet och kredit balanserar inte. Granska verifikationerna innan bokslut.', message_en: 'Trial balance does not balance.', }, } const OPENING_BAL: Record = { OPENING_BAL_PERIOD_NOT_FOUND: { httpStatus: 404, message_sv: 'Räkenskapsperioden kunde inte hittas.', message_en: 'Fiscal period not found.', }, } const FX: Record = { FX_PERIOD_NOT_FOUND: { httpStatus: 404, message_sv: 'Räkenskapsperioden kunde inte hittas.', message_en: 'Fiscal period not found.', }, FX_PERIOD_CLOSED: { httpStatus: 400, message_sv: 'Perioden är redan stängd. Valutaomvärdering kan inte köras.', message_en: 'Period is already closed; currency revaluation cannot be run.', }, FX_FAILED: { httpStatus: 400, message_sv: 'Valutaomvärderingen misslyckades.', message_en: 'Currency revaluation failed.', }, } const REPORT: Record = { REPORT_PERIOD_REQUIRED: { httpStatus: 400, message_sv: 'period_id krävs.', message_en: 'period_id query parameter is required.', }, REPORT_GENERATION_FAILED: { httpStatus: 500, message_sv: 'Rapporten kunde inte genereras.', message_en: 'Failed to generate the report.', }, } const VAT_REPORT: Record = { VAT_REPORT_MISSING_PARAMS: { httpStatus: 400, message_sv: 'periodType, year och period krävs.', message_en: 'periodType, year and period query parameters are required.', }, VAT_REPORT_INVALID_PERIOD_TYPE: { httpStatus: 400, message_sv: 'periodType måste vara monthly, quarterly eller yearly.', message_en: 'periodType must be one of monthly, quarterly, yearly.', }, VAT_REPORT_INVALID_YEAR: { httpStatus: 400, message_sv: 'year måste vara ett giltigt årtal mellan 2000 och 2100.', message_en: 'year must be a number between 2000 and 2100.', }, VAT_REPORT_INVALID_PERIOD: { httpStatus: 400, message_sv: 'period är ogiltig för vald periodtyp.', message_en: 'period is invalid for the chosen period type.', }, VAT_REPORT_GENERATION_FAILED: { httpStatus: 500, message_sv: 'Momsdeklarationen kunde inte beräknas.', message_en: 'Failed to calculate VAT declaration.', }, } const PS_REPORT: Record = { PS_REPORT_MISSING_PARAMS: { httpStatus: 400, message_sv: 'periodType, year och period krävs.', message_en: 'periodType, year and period query parameters are required.', }, PS_REPORT_INVALID_PERIOD_TYPE: { httpStatus: 400, message_sv: 'periodType måste vara monthly eller quarterly.', message_en: 'periodType must be monthly or quarterly.', }, PS_REPORT_INVALID_YEAR: { httpStatus: 400, message_sv: 'year måste vara ett giltigt årtal mellan 2000 och 2100.', message_en: 'year must be a number between 2000 and 2100.', }, PS_REPORT_INVALID_PERIOD: { httpStatus: 400, message_sv: 'period är ogiltig för vald periodtyp.', message_en: 'period is invalid for the chosen period type.', }, PS_REPORT_GENERATION_FAILED: { httpStatus: 500, message_sv: 'Periodisk sammanställning kunde inte beräknas.', message_en: 'Failed to generate periodisk sammanställning.', }, PS_REPORT_CSV_BLOCKED_BY_ERRORS: { httpStatus: 400, message_sv: 'CSV kan inte laddas ner. Åtgärda blockerande fel först.', message_en: 'CSV download blocked by validation errors. Fix them first.', }, PS_REPORT_MISSING_FILER_INFO: { httpStatus: 400, message_sv: 'Kontaktuppgifter saknas. Fyll i namn, telefon och e-post under Inställningar.', message_en: 'Tax contact information is missing on company_settings.', }, } const SIE_EXPORT: Record = { SIE_EXPORT_COMPANY_NOT_FOUND: { httpStatus: 404, message_sv: 'Företagsinställningar saknas — SIE-exporten kan inte skapas.', message_en: 'Company settings missing; SIE export cannot be generated.', }, SIE_EXPORT_FAILED: { httpStatus: 500, message_sv: 'SIE-exporten misslyckades.', message_en: 'Failed to generate SIE export.', }, } const TAX_DECL: Record = { TAX_DECL_GENERATION_FAILED: { httpStatus: 500, message_sv: 'Skattedeklarationen kunde inte genereras.', message_en: 'Failed to generate tax declaration.', }, } // ───────────────────────────────────────────────────────────────── // Wave 3: imports (SIE, bank-file, opening-balance) // ───────────────────────────────────────────────────────────────── const SIE_IMPORT: Record = { SIE_PARSE_NO_FILE: { httpStatus: 400, message_sv: 'Ingen fil bifogad i förfrågan.', message_en: 'No file attached to the request.', }, SIE_PARSE_INVALID_TYPE: { httpStatus: 400, message_sv: 'Filtypen stöds inte. Ladda upp en fil med ändelsen .sie eller .se.', message_en: 'Unsupported file type; upload a .sie or .se file.', }, SIE_PARSE_FILE_TOO_LARGE: { httpStatus: 400, message_sv: 'Filen är för stor. Maxstorlek är 50 MB.', message_en: 'File exceeds the 50 MB size limit.', }, SIE_PARSE_EMPTY: { httpStatus: 400, message_sv: 'Filen är tom (0 bytes). Kontrollera exporten från bokföringsprogrammet.', message_en: 'File is empty.', }, SIE_PARSE_FAILED: { httpStatus: 500, message_sv: 'Kunde inte tolka SIE-filen. Filen kan vara skadad eller i ett format som inte stöds.', message_en: 'Failed to parse the SIE file.', }, SIE_PARSE_VALIDATION_FAILED: { httpStatus: 400, message_sv: 'SIE-filen innehåller valideringsfel som måste åtgärdas innan import.', message_en: 'SIE file failed validation.', }, SIE_DUPLICATE_FILE: { httpStatus: 409, message_sv: 'Den här filen har redan importerats.', message_en: 'File has already been imported.', }, SIE_DUPLICATE_PERIOD: { httpStatus: 409, message_sv: 'En SIE-import för ett överlappande räkenskapsår finns redan.', message_en: 'An SIE import for an overlapping fiscal period already exists.', }, SIE_IMPORT_UNMAPPED_ACCOUNTS: { httpStatus: 400, message_sv: 'Vissa konton saknar mappning. Gå tillbaka till kontomappningssteget och koppla alla konton.', message_en: 'One or more accounts have no mapping target.', remediation: { description: 'Map every source account to a BAS account before importing.' }, }, SIE_IMPORT_ACCOUNT_ACTIVATION_FAILED: { httpStatus: 500, message_sv: 'Kunde inte aktivera konton i kontoplanen. Kontrollera att kontona inte redan finns med andra inställningar.', message_en: 'Failed to activate mapped accounts in the chart of accounts.', }, SIE_IMPORT_FAILED: { httpStatus: 400, message_sv: 'Importen slutfördes med fel. Se detaljerna nedan.', message_en: 'SIE import completed with errors.', }, SIE_IMPORT_UNEXPECTED: { httpStatus: 500, message_sv: 'Importen avbröts oväntat. Ingen data har sparats.', message_en: 'Unexpected error during SIE import; no data was committed.', }, SIE_REPLACE_FAILED: { httpStatus: 400, message_sv: 'SIE-importen kunde inte ersättas.', message_en: 'Failed to replace SIE import.', }, } const BANK_FILE: Record = { BANK_FILE_NO_FILE: { httpStatus: 400, message_sv: 'Ingen fil bifogad i förfrågan.', message_en: 'No file attached to the request.', }, BANK_FILE_TOO_LARGE: { httpStatus: 400, message_sv: 'Filen är för stor. Maxstorlek är 10 MB.', message_en: 'File exceeds the 10 MB size limit.', }, BANK_FILE_DUPLICATE: { httpStatus: 409, message_sv: 'Den här filen har redan importerats.', message_en: 'Bank file has already been imported.', }, BANK_FILE_PARSE_FAILED: { httpStatus: 500, message_sv: 'Kunde inte tolka bankfilen.', message_en: 'Failed to parse the bank file.', }, BANK_FILE_NO_TRANSACTIONS: { httpStatus: 400, message_sv: 'Bankfilen innehåller inga transaktioner att importera.', message_en: 'No transactions to import.', }, BANK_FILE_IMPORT_RECORD_FAILED: { httpStatus: 500, message_sv: 'Kunde inte skapa importpost.', message_en: 'Failed to create the bank file import record.', }, BANK_FILE_EXECUTE_FAILED: { httpStatus: 500, message_sv: 'Bankfilsimporten misslyckades.', message_en: 'Bank file import failed.', }, } const OPENING_BALANCE_IMPORT: Record = { OB_NO_FILE: { httpStatus: 400, message_sv: 'Ingen fil bifogad.', message_en: 'No file attached.', }, OB_FILE_TOO_LARGE: { httpStatus: 400, message_sv: 'Filen är för stor. Maxstorlek är 10 MB.', message_en: 'File exceeds the 10 MB size limit.', }, OB_INVALID_FORMAT: { httpStatus: 400, message_sv: 'Filformatet stöds inte. Tillåtna format: .xlsx, .xls, .csv, .ods.', message_en: 'Unsupported file format.', }, OB_INVALID_COLUMN_OVERRIDES: { httpStatus: 400, message_sv: 'Ogiltig kolumnmappning.', message_en: 'Invalid column overrides JSON.', }, OB_PARSE_FAILED: { httpStatus: 500, message_sv: 'Kunde inte tolka filen.', message_en: 'Failed to parse the opening balance file.', }, OB_PERIOD_NOT_FOUND: { httpStatus: 404, message_sv: 'Räkenskapsperioden hittades inte.', message_en: 'Fiscal period not found.', }, OB_PERIOD_CLOSED: { httpStatus: 400, message_sv: 'Räkenskapsperioden är stängd.', message_en: 'Fiscal period is closed.', }, OB_PERIOD_LOCKED: { httpStatus: 400, message_sv: 'Räkenskapsperioden är låst.', message_en: 'Fiscal period is locked.', }, OB_PERIOD_ALREADY_HAS_BALANCES: { httpStatus: 409, message_sv: 'Räkenskapsperioden har redan ingående balanser.', message_en: 'Fiscal period already has opening balances set.', }, OB_TOO_FEW_LINES: { httpStatus: 400, message_sv: 'Minst två rader med belopp krävs.', message_en: 'At least two lines with amounts are required.', }, OB_PNL_ACCOUNT: { httpStatus: 400, message_sv: 'Resultatkonton (klass 3-8) kan inte användas i ingående balanser.', message_en: 'Profit & loss accounts (class 3-8) are not allowed in opening balances.', }, OB_UNBALANCED: { httpStatus: 400, message_sv: 'Debet och kredit balanserar inte.', message_en: 'Opening balance debits and credits do not match.', }, OB_ACCOUNT_ACTIVATION_FAILED: { httpStatus: 500, message_sv: 'Kunde inte aktivera konton i kontoplanen.', message_en: 'Failed to activate accounts in the chart of accounts.', }, OB_EXECUTE_FAILED: { httpStatus: 500, message_sv: 'Importen misslyckades.', message_en: 'Opening balance import failed.', }, } const REGISTER_IMPORT: Record = { REG_IMPORT_NO_FILE: { httpStatus: 400, message_sv: 'Ingen fil bifogad.', message_en: 'No file attached.', }, REG_IMPORT_FILE_TOO_LARGE: { httpStatus: 400, message_sv: 'Filen är för stor. Maxstorlek är 10 MB.', message_en: 'File exceeds the 10 MB size limit.', }, REG_IMPORT_INVALID_FORMAT: { httpStatus: 400, message_sv: 'Filformatet stöds inte. Tillåtna format: .xlsx, .xls, .csv, .ods.', message_en: 'Unsupported file format.', }, REG_IMPORT_INVALID_COLUMN_OVERRIDES: { httpStatus: 400, message_sv: 'Ogiltig kolumnmappning.', message_en: 'Invalid column overrides JSON.', }, REG_IMPORT_PARSE_FAILED: { httpStatus: 500, message_sv: 'Kunde inte tolka filen.', message_en: 'Failed to parse the register file.', }, REG_IMPORT_NO_ROWS: { httpStatus: 400, message_sv: 'Inga giltiga rader hittades i filen.', message_en: 'No valid rows found in the file.', }, REG_IMPORT_EXECUTE_FAILED: { httpStatus: 500, message_sv: 'Importen misslyckades.', message_en: 'Register import failed.', }, } // ───────────────────────────────────────────────────────────────── // Wave 3 tail: provider migration extension codes // ───────────────────────────────────────────────────────────────── const PROVIDER_MIGRATION: Record = { PROVIDER_INVALID: { httpStatus: 400, message_sv: 'Okänd leverantör.', message_en: 'Unknown provider.', }, PROVIDER_CONSENT_NOT_READY: { httpStatus: 400, message_sv: 'Anslutningen är inte klar. Slutför inloggningen först.', message_en: 'Provider consent is not ready; finish authentication first.', }, PROVIDER_CONSENT_NOT_FOUND: { httpStatus: 404, message_sv: 'Anslutningen kunde inte hittas.', message_en: 'Provider consent not found.', }, PROVIDER_CONNECT_FAILED: { httpStatus: 500, message_sv: 'Kunde inte starta anslutningen till leverantören.', message_en: 'Failed to start provider connection flow.', }, PROVIDER_TOKEN_REQUIRED: { httpStatus: 400, message_sv: 'API-token krävs för den här leverantören.', message_en: 'apiToken is required for this provider.', }, PROVIDER_COMPANY_ID_REQUIRED: { httpStatus: 400, message_sv: 'companyId krävs för den här leverantören.', message_en: 'companyId is required for this provider.', }, PROVIDER_TOKEN_SUBMIT_FAILED: { httpStatus: 500, message_sv: 'Tokensubmissionen misslyckades.', message_en: 'Failed to submit provider token.', }, PROVIDER_PREVIEW_FAILED: { httpStatus: 500, message_sv: 'Förhandsgranskningen från leverantören misslyckades.', message_en: 'Provider preview failed.', }, PROVIDER_SIE_FETCH_FAILED: { httpStatus: 502, message_sv: 'Kunde inte hämta SIE-data från leverantören.', message_en: 'Failed to fetch SIE data from the provider.', }, PROVIDER_SIE_NO_YEARS: { httpStatus: 404, message_sv: 'Inga räkenskapsår 2024–2026 hittades hos leverantören.', message_en: 'No fiscal years available for 2024–2026.', }, PROVIDER_SIE_ONLY_FORTNOX: { httpStatus: 400, message_sv: 'SIE-export stöds för närvarande endast för Fortnox.', message_en: 'SIE export is currently only supported for Fortnox.', }, PROVIDER_MIGRATE_FAILED: { httpStatus: 500, message_sv: 'Migrationen från leverantören misslyckades.', message_en: 'Provider migration failed.', }, PROVIDER_DISCONNECT_FAILED: { httpStatus: 500, message_sv: 'Frånkoppling från leverantören misslyckades.', message_en: 'Provider disconnect failed.', }, PROVIDER_ACCEPT_FAILED: { httpStatus: 500, message_sv: 'Kunde inte slutföra anslutningen.', message_en: 'Failed to accept consent.', }, PROVIDER_STATUS_FAILED: { httpStatus: 500, message_sv: 'Kunde inte hämta status från leverantören.', message_en: 'Failed to fetch provider status.', }, } // ───────────────────────────────────────────────────────────────── // Wave 4: documents, masters, salary, company, API keys // ───────────────────────────────────────────────────────────────── const DOCUMENT: Record = { DOC_UPLOAD_NO_FILE: { httpStatus: 400, message_sv: 'Ingen fil bifogad.', message_en: 'No file attached.', }, DOC_UPLOAD_TOO_LARGE: { httpStatus: 400, message_sv: 'Filen är för stor.', message_en: 'Uploaded file exceeds the size limit.', }, DOC_UPLOAD_UNSUPPORTED_TYPE: { httpStatus: 400, message_sv: 'Filtypen stöds inte.', message_en: 'Unsupported file type.', }, DOC_UPLOAD_STORAGE_FAILED: { httpStatus: 500, message_sv: 'Filen kunde inte sparas.', message_en: 'Document storage failed.', }, DOC_DOWNLOAD_FAILED: { httpStatus: 500, message_sv: 'Det gick inte att skapa nedladdningslänken.', message_en: 'Failed to create signed download URL.', }, DOC_NOT_FOUND: { httpStatus: 404, message_sv: 'Dokumentet kunde inte hittas.', message_en: 'Document not found.', }, DOC_LINK_ENTRY_NOT_FOUND: { httpStatus: 404, message_sv: 'Verifikationen kunde inte hittas.', message_en: 'Journal entry not found.', }, DOC_LINK_ALREADY_LINKED: { httpStatus: 409, message_sv: 'Dokumentet är redan kopplat till en verifikation.', message_en: 'Document is already linked to a journal entry.', }, DOC_LINK_FAILED: { httpStatus: 500, message_sv: 'Kopplingen misslyckades.', message_en: 'Failed to link document to journal entry.', }, } const CUSTOMER: Record = { CUSTOMER_NOT_FOUND: { httpStatus: 404, message_sv: 'Kunden kunde inte hittas.', message_en: 'Customer not found.', }, CUSTOMER_DUPLICATE_ORG_NUMBER: { httpStatus: 409, message_sv: 'En kund med samma organisationsnummer finns redan.', message_en: 'A customer with that organisation number already exists.', }, CUSTOMER_CREATE_FAILED: { httpStatus: 500, message_sv: 'Kunden kunde inte skapas.', message_en: 'Failed to create customer.', }, CUSTOMER_UPDATE_FAILED: { httpStatus: 500, message_sv: 'Kunden kunde inte uppdateras.', message_en: 'Failed to update customer.', }, CUSTOMER_DELETE_FAILED: { httpStatus: 500, message_sv: 'Kunden kunde inte tas bort.', message_en: 'Failed to delete customer.', }, CUSTOMER_HAS_INVOICES: { httpStatus: 409, message_sv: 'Kunden har fakturor och kan inte tas bort.', message_en: 'Customer cannot be deleted while invoices reference it.', }, } const SUPPLIER: Record = { SUPPLIER_NOT_FOUND: { httpStatus: 404, message_sv: 'Leverantören kunde inte hittas.', message_en: 'Supplier not found.', }, SUPPLIER_DUPLICATE_ORG_NUMBER: { httpStatus: 409, message_sv: 'En leverantör med samma organisationsnummer finns redan.', message_en: 'A supplier with that organisation number already exists.', }, SUPPLIER_CREATE_FAILED: { httpStatus: 500, message_sv: 'Leverantören kunde inte skapas.', message_en: 'Failed to create supplier.', }, SUPPLIER_UPDATE_FAILED: { httpStatus: 500, message_sv: 'Leverantören kunde inte uppdateras.', message_en: 'Failed to update supplier.', }, SUPPLIER_DELETE_FAILED: { httpStatus: 500, message_sv: 'Leverantören kunde inte tas bort.', message_en: 'Failed to delete supplier.', }, // v1 archive refusal — leverantörsfakturor pointing at this supplier still // need its name/address for BFL 7 kap audit. Issue credit notes first. SUPPLIER_HAS_INVOICES: { httpStatus: 409, message_sv: 'Leverantören kan inte arkiveras eftersom det finns öppna leverantörsfakturor som refererar till den.', message_en: 'Supplier cannot be archived while open supplier invoices reference it.', remediation: { description: 'Close (credit / mark paid) every open supplier invoice before archiving the supplier. The dashboard exposes the same blocker.', }, }, // v1 strict-mode: update / delete only allowed on `registered` SIs (the // SI analogue of `draft`). Mirrors the dashboard internal route. SI_NOT_DRAFT: { httpStatus: 400, message_sv: 'Leverantörsfakturan är inte längre i status "registrerad" och kan därför inte uppdateras eller tas bort.', message_en: 'Supplier invoice is not in `registered` status and cannot be updated or deleted.', }, } const SUPPLIER_INVOICE_WAVE4: Record = { SI_CREATE_DUPLICATE_INVOICE_NUMBER: { httpStatus: 409, message_sv: 'En leverantörsfaktura med samma nummer finns redan.', message_en: 'A supplier invoice with that number already exists.', }, SI_CREATE_FAILED: { httpStatus: 500, message_sv: 'Leverantörsfakturan kunde inte skapas.', message_en: 'Failed to create supplier invoice.', }, SI_CREATE_INVALID_INPUT: { httpStatus: 400, message_sv: 'Ogiltig kombination av fakturafält. Kontrollera formuläret och försök igen.', message_en: 'Invalid combination of supplier invoice fields.', }, SI_PAID_ALREADY: { httpStatus: 409, message_sv: 'Leverantörsfakturan är redan betald eller krediterad.', message_en: 'Supplier invoice is already paid or credited.', }, SI_PAID_NOT_PAYABLE: { httpStatus: 400, message_sv: 'Leverantörsfakturan kan inte markeras som betald i nuvarande status.', message_en: 'Supplier invoice is not in a payable state.', }, SI_PAID_PERIOD_LOCKED: { httpStatus: 400, message_sv: 'Bokföringen är låst. Betalningen kan inte registreras.', message_en: 'Bookkeeping is locked; payment cannot be recorded.', }, SI_PAID_FAILED: { httpStatus: 500, message_sv: 'Kunde inte registrera betalningen.', message_en: 'Failed to record supplier invoice payment.', }, SI_PAID_LIKELY_DUPLICATE: { httpStatus: 409, message_sv: 'Det finns redan en obokförd banktransaktion som kan vara denna betalning. Länka den istället, eller markera som betald ändå om du är säker.', message_en: 'A likely-matching unlinked bank transaction was found for this supplier. Suggest linking it instead of creating a new payment entry.', remediation: { description: 'Match the candidate transaction via POST /api/transactions/{id}/match-supplier-invoice, or resend mark-paid with force: true to create the payment entry anyway.', }, }, SI_CREDIT_ALREADY_CREDITED: { httpStatus: 409, message_sv: 'Leverantörsfakturan har redan krediterats.', message_en: 'Supplier invoice has already been credited.', }, SI_CREDIT_PERIOD_LOCKED: { httpStatus: 400, message_sv: 'Bokföringen är låst. Krediteringen kan inte skapas.', message_en: 'Bookkeeping is locked; credit note cannot be created.', }, SI_CREDIT_FAILED: { httpStatus: 500, message_sv: 'Kunde inte kreditera leverantörsfakturan.', message_en: 'Failed to credit supplier invoice.', }, } const SALARY: Record = { SALARY_RUN_NOT_FOUND: { httpStatus: 404, message_sv: 'Lönekörningen kunde inte hittas.', message_en: 'Salary run not found.', }, SALARY_RUN_NO_EMPLOYEES: { httpStatus: 400, message_sv: 'Inga aktiva anställda finns i företaget.', message_en: 'No active employees in the company.', }, SALARY_RUN_TAX_TABLE_MISSING: { httpStatus: 400, message_sv: 'Skattetabellen saknas för perioden. Importera skattetabellen först.', message_en: 'Tax table is missing for the period.', }, SALARY_RUN_PERIOD_LOCKED: { httpStatus: 400, message_sv: 'Lönekörningen kan inte göras i en låst period.', message_en: 'Salary run cannot be processed in a locked period.', }, SALARY_RUN_NOT_CALCULATED: { httpStatus: 400, message_sv: 'Lönekörningen måste beräknas innan bokföring.', message_en: 'Salary run must be calculated before booking.', }, SALARY_RUN_CREATE_FAILED: { httpStatus: 500, message_sv: 'Lönekörningen kunde inte skapas.', message_en: 'Failed to create salary run.', }, SALARY_RUN_CALCULATE_FAILED: { httpStatus: 500, message_sv: 'Lönekörningen kunde inte beräknas.', message_en: 'Failed to calculate salary run.', }, SALARY_RUN_BOOK_FAILED: { httpStatus: 500, message_sv: 'Lönekörningen kunde inte bokföras.', message_en: 'Failed to book salary run.', }, AGI_NO_SALARY_RUN: { httpStatus: 400, message_sv: 'Det finns ingen lönekörning för perioden.', message_en: 'No salary run exists for the period.', }, AGI_FSKATT_VERIFICATION_FAILED: { httpStatus: 400, message_sv: 'F-skattekontrollen misslyckades. Kontrollera leverantörens F-skatt.', message_en: 'F-skatt verification failed.', }, AGI_GENERATION_FAILED: { httpStatus: 500, message_sv: 'AGI-deklarationen kunde inte genereras.', message_en: 'Failed to generate AGI declaration.', }, // Phase 5 PR-1 — v1 REST surface error codes. EMPLOYEE_NOT_FOUND: { httpStatus: 404, message_sv: 'Den anställda kunde inte hittas.', message_en: 'Employee not found.', }, EMPLOYEE_DUPLICATE_PERSONNUMMER: { httpStatus: 409, message_sv: 'En anställd med samma personnummer finns redan.', message_en: 'An employee with that personnummer already exists.', }, SALARY_RUN_DUPLICATE_PERIOD: { httpStatus: 409, message_sv: 'En lönekörning för perioden finns redan.', message_en: 'A salary run for that period already exists.', }, SALARY_RUN_PATCH_NOT_DRAFT: { httpStatus: 400, message_sv: 'Endast utkast (draft) kan uppdateras.', message_en: 'Only draft salary runs can be patched.', }, SALARY_RUN_DELETE_NOT_DRAFT: { httpStatus: 400, message_sv: 'Endast utkast (draft) kan raderas.', message_en: 'Only draft salary runs can be deleted.', }, SALARY_RUN_CALCULATE_NOT_DRAFT: { httpStatus: 400, message_sv: 'Lönekörningen måste vara i status draft för beräkning.', message_en: 'Salary run must be in draft status to calculate.', }, SALARY_RUN_APPROVE_NOT_REVIEW: { httpStatus: 400, message_sv: 'Lönekörningen måste vara i status review för godkännande.', message_en: 'Salary run must be in review status to approve.', }, SALARY_RUN_APPROVE_VALIDATION_FAILED: { httpStatus: 400, message_sv: 'Valideringsfel — korrigera innan godkännande.', message_en: 'Validation failed — fix issues before approving.', }, SALARY_RUN_MARK_PAID_NOT_APPROVED: { httpStatus: 400, message_sv: 'Lönekörningen måste vara godkänd för att markeras som betald.', message_en: 'Salary run must be approved before it can be marked paid.', }, SALARY_RUN_BOOK_NOT_PAID: { httpStatus: 400, message_sv: 'Lönekörningen måste vara markerad som betald för bokföring.', message_en: 'Salary run must be marked paid before booking.', }, AGI_GENERATE_NOT_BOOKABLE: { httpStatus: 400, message_sv: 'AGI kan endast genereras för lönekörningar i status review, approved, paid, booked eller corrected.', message_en: 'AGI can only be generated for salary runs in review, approved, paid, booked, or corrected status.', }, AGI_INCOMPLETE_DATA: { httpStatus: 400, message_sv: 'AGI-data ofullständig — kontrollera att företaget har organisationsnummer, kontaktnamn, telefon och e-post.', message_en: 'AGI data is incomplete — verify the company has org number, contact name, phone, and email.', }, COMPANY_NOT_FOUND: { httpStatus: 404, message_sv: 'Företaget kunde inte hittas.', message_en: 'Company not found.', }, // Phase 5 PR-1 carry-over: distinct error code for the salary-run DELETE // FK-null guard so an operator seeing this in logs knows a journal entry // is at risk, not just a status race. SALARY_RUN_DELETE_HAS_JOURNAL_ENTRY: { httpStatus: 400, message_sv: 'Lönekörningen är kopplad till en verifikation och kan inte raderas (BFL 5 kap räkenskapsinformation).', message_en: 'Salary run is linked to a journal entry and cannot be deleted (BFL 5 kap räkenskapsinformation).', }, // Phase 5 PR-3 — additional import error codes. SIE_IMPORT_DUPLICATE: { httpStatus: 409, message_sv: 'Den här SIE-filen har redan importerats.', message_en: 'This SIE file has already been imported.', }, BANK_IMPORT_FAILED: { httpStatus: 500, message_sv: 'Bankfilsimporten misslyckades.', message_en: 'Bank file import failed.', }, BANK_FILE_FORMAT_UNKNOWN: { httpStatus: 400, message_sv: 'Bankfilens format kunde inte identifieras.', message_en: 'Bank file format could not be identified.', }, BANK_IMPORT_DUPLICATE_OTHER_COMPANY: { httpStatus: 409, message_sv: 'Den här filen har redan importerats för ett annat företag av samma användare.', message_en: 'This file has already been imported into another company by this user.', }, } const COMPANY: Record = { COMPANY_CREATE_DUPLICATE_ORG_NUMBER: { httpStatus: 409, message_sv: 'Ett företag med samma organisationsnummer finns redan.', message_en: 'A company with that organisation number already exists.', }, COMPANY_CREATE_BAS_SEED_FAILED: { httpStatus: 500, message_sv: 'Kontoplanen kunde inte skapas. Försök igen.', message_en: 'Failed to seed the chart of accounts.', }, COMPANY_CREATE_FAILED: { httpStatus: 500, message_sv: 'Företaget kunde inte skapas.', message_en: 'Failed to create company.', }, } const API_KEY: Record = { API_KEY_SCOPE_INVALID: { httpStatus: 400, message_sv: 'En eller flera scopes är ogiltiga.', message_en: 'One or more requested scopes are invalid.', }, API_KEY_QUOTA_EXCEEDED: { httpStatus: 429, message_sv: 'Du har nått maxgränsen för antal API-nycklar.', message_en: 'API key quota exceeded.', }, API_KEY_CREATE_FAILED: { httpStatus: 500, message_sv: 'API-nyckeln kunde inte skapas.', message_en: 'Failed to create API key.', }, API_KEY_REVOKE_FAILED: { httpStatus: 500, message_sv: 'API-nyckeln kunde inte återkallas.', message_en: 'Failed to revoke API key.', }, API_KEY_NOT_FOUND: { httpStatus: 404, message_sv: 'API-nyckeln kunde inte hittas.', message_en: 'API key not found.', }, } // ───────────────────────────────────────────────────────────────── // Provider connection / external HTTP codes // ───────────────────────────────────────────────────────────────── const PROVIDER: Record = { PROVIDER_AUTH_EXPIRED: { httpStatus: 401, message_sv: 'Anslutningen till leverantören har gått ut. Återanslut för att fortsätta.', message_en: 'Provider authentication expired or refresh failed.', }, PROVIDER_RATE_LIMITED: { httpStatus: 429, message_sv: 'Leverantören begränsar antalet anrop just nu. Vänta en stund och försök igen.', message_en: 'Provider rate limit exceeded.', }, PROVIDER_UNREACHABLE: { httpStatus: 502, message_sv: 'Leverantörens tjänst är inte tillgänglig just nu. Försök igen om en stund.', message_en: 'Provider service is unreachable (network/DNS error).', }, PROVIDER_UPSTREAM_ERROR: { httpStatus: 502, message_sv: 'Leverantören svarade med ett fel. Försök igen om en stund.', message_en: 'Provider returned an upstream 5xx error.', }, } // ───────────────────────────────────────────────────────────────── // Combined registry // ───────────────────────────────────────────────────────────────── const REGISTRY: Record = { ...GENERIC, ...BOOKKEEPING, ...TRANSACTIONS, ...MATCH_INVOICE, ...MATCH_SI, ...INVOICE, ...SUPPLIER_INVOICE, ...PERIOD, ...YEAR_END, ...OPENING_BAL, ...FX, ...REPORT, ...VAT_REPORT, ...PS_REPORT, ...SIE_EXPORT, ...TAX_DECL, ...SIE_IMPORT, ...BANK_FILE, ...OPENING_BALANCE_IMPORT, ...REGISTER_IMPORT, ...PROVIDER_MIGRATION, ...DOCUMENT, ...CUSTOMER, ...SUPPLIER, ...SUPPLIER_INVOICE_WAVE4, ...SALARY, ...COMPANY, ...API_KEY, ...PROVIDER, } export function getErrorEntry(code: string): StructuredErrorEntry | undefined { return REGISTRY[code] } export function hasErrorEntry(code: string): boolean { return code in REGISTRY } /** * Test-only: returns all registered codes. Used by the unit test that asserts * the matrix in the plan file stays in sync with this registry. */ export function listErrorCodes(): string[] { return Object.keys(REGISTRY) }