"Fakturan saknar ett betalningskonto för vald valuta" read as a foreign-currency account when the invoice was in SEK and the gap was simply the company's bankgiro; the remediation line also asked for an IBAN, which SEK does not need. A Visma-migrated user marking invoices as sent hit this and went looking for a valutakonto. - describeMissingInvoicePaymentAccount(currency) in lib/invoices/payment-accounts.ts: SEK names bankgiro, plusgiro, Swish or bank account; other currencies ask for an IBAN account in that currency (USD/GBP also offer routing number / sort code + BIC). Both point at Inställningar → Fakturering. - getErrorMessage branches on INVOICE_SEND_PAYMENT_ACCOUNT_MISSING + details.currency (every dashboard route already sends it), before the English registry shortcut so both locales get the specific text. - Registry entry rewritten currency-neutral for consumers without details (API, MCP): bankgiro/plusgiro/Swish/bankkonto for SEK, IBAN otherwise; remediation no longer says IBAN for everything. - Staged-operation commit path uses the helper directly. Tests: helper per currency, client mapping sv/en and the no-details fallback. Closes #2126 Claude-Session: https://claude.ai/code/session_01WFhSQWzu5SyXB6kG5ActZc Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -1457,6 +1457,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-09-01] F2 bank-data staleness: ship freshness reads only (last_synced_at/consent_expires/error_message on gnubok_connect_bank + new GET /api/v1/.../bank-connections, scope companies:read mirroring the MCP mapping): the daily cron already syncs server-side, so visibility is what the incident lacked; an agent-triggerable sync is a product bet (EB call cost, runaway agents) and was deferred by Emil.
|
||||
[2026-09-01] Verifikationsserie in the Ny verifikation modal is a closed dropdown instead of a one-letter free-text field: a typo there silently opens a brand-new series with its own number sequence, and the letters only mean anything if everyone uses the same ones. The letters are NOT prescribed by law (BFL 5 kap. 7 § requires only unbroken systematic numbering within each series), and the incumbents disagree: Björn Lundén uses A Huvudserie, F Kundfakturor, I Inbetalningar, L Leverantörsfakturor, N Löner, U Utbetalningar, J Bokslut. We ship FORTNOX's table verbatim (A Redovisning, B Kundfakturor, C Inbetalningar från kunder, D Leverantörsfakturor, E Utbetalningar till leverantörer, F Kassa, G Avskrivning, H Periodisering, I Bokslut, J Revisor, K Lön, L Kontantfaktura, M Momsrapport), from their own Systemdokumentation, because Fortnox is the system most companies migrate here from and an imported ledger should keep its meaning. REJECTED an earlier draft that labelled A as Kundfakturor: A is the general series manual entries land in (the one point Fortnox and BL agree on, and Fortnox allows manuell kontering ONLY in A), and migration 20260526120700 ships every source_type defaulting to 'A', so every existing company's A series already holds everything. Calling it Kundfakturor would mislabel their entire history and the modal's own default. The list is closed but any letter the company already configured, or that a draft was saved with, is appended so no existing value can fall out of the picker. Also: tabbing or clicking into an untouched amount field now proposes the outstanding difference (pre-selected, so typing replaces it) when the row already has an account and the difference belongs on that side. This deliberately reverses part of the note in updateLine that said a balancing amount must never auto-fill: that note was about filling on ACCOUNT selection, which stole the amount before the user had a chance to split it. Filling on focus keeps the split case intact because the proposal is selected text, and it fixes the common moms case where the last line is just the remainder.
|
||||
[2026-09-01] Settings PUT cross-field VAT validations scoped to touched field groups (vat-completeness, 40m-monthly, periodisk sammanstallning), not fixed at onboarding: partial saves from surfaces without VAT fields (invoice bank-details dialog) were hard-blocked by pre-existing vat_registered-without-number state (Marketio Lab case). The invariant still holds on every save that touches its group; explicit null now counts as a clear instead of falling back to the stored value during validation. Onboarding-side VAT number collection left as follow-up.
|
||||
[2026-09-01] #2126 payment-account-missing wording: made the message currency-aware on the client (getErrorMessage branch on details.currency via describeMissingInvoicePaymentAccount) plus a currency-neutral rewrite of the registry entry, instead of threading messageSv/messageEn through the nine server emit sites: every dashboard caller already sends details.currency, API/MCP consumers get the clarified registry text (names bankgiro/plusgiro/Swish for SEK, IBAN otherwise), and the staged-operation commit path (bare string) calls the helper directly. The reporter saw the pre-#2088 text; #2088 added the Inställningar -> Fakturering pointer the same evening, this removes the "valutakonto" reading.
|
||||
[2026-08-24] Declined to enable Peppol access for Low-Stack Technologies (enskild firma, personnummer-based org nr): both send validation and receive registration deliberately refuse personnummer identifiers (GDPR; DIGG recommends 0088 GLN) and no GLN support exists, so enabling would only surface errors; request row left as 'requested' pending founder call.
|
||||
[2026-08-26] invoice@arcim.io email unblock (Jakob's own test account, EXECUTED on prod, never re-run): the in-app delete had already anonymized + banned tombstone 7086c4d1 at 06:48 UTC, but the tombstone keeps auth.users.email by design (2026-07-24), which blocks re-signup. Replicated the 2026-07-30 Orback pattern: email scrambled to anonymized+<uuid>@tombstone.invalid, the stale auth.sessions row deleted, AND the Google auth.identities row (sub 103263015636623485631) deleted rather than scrubbed: an OAuth identity matches on (provider, provider_id), not email, so a scrubbed email alone would have re-bound the next Google sign-in to the banned tombstone. Hard delete of auth.users was never an option: companies.created_by cascades and the 4 154 posted CashLeads Media AB entries are BFL 7 kap. 2 § retained (delete_user_account was dropped in 20260706100000 for exactly this). The 3 archived companies stay as an inert tenant with 0 members.
|
||||
[2026-08-31] Demonstrationsinspelning (record-your-work) prototyped INSIDE the app rather than as ambient screen capture: an in-app recorder captures semantic events (chosen konto, momskod, dimension per invoice) instead of pixels, which removes the OCR/masking problem, keeps client personal data out of the recording entirely, and gives a deterministic compile target. Research the same day found continuous employee screen recording is the most legally hostile design in EU workplace privacy (IMY: regular real-time monitoring "som regel inte tillåtet"; consent invalid in employment; CNIL Amazon France 32 MEUR; byrå screens also carry client data no client DPA covers), while the horizontal market (Skan AI, 63 MUSD Aug 2026) is on-prem and works-council gated. Ambient capture parked behind two founder gates: a named design-partner byrå and acceptance of AI Act Annex III provider status (deferred to 2027-12-02 by the July 2026 Digital Omnibus). Founder has said high-risk status is acceptable.
|
||||
|
||||
@@ -563,3 +563,41 @@ describe('getProviderResourceForbiddenMessage', () => {
|
||||
expect(getProviderResourceForbiddenMessage(null)).not.toMatch(/återanslut för att fortsätta/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getErrorMessage: INVOICE_SEND_PAYMENT_ACCOUNT_MISSING (#2126)', () => {
|
||||
const envelope = (currency?: unknown) => ({
|
||||
error: {
|
||||
code: 'INVOICE_SEND_PAYMENT_ACCOUNT_MISSING',
|
||||
message: getErrorEntry('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING')!.message_sv,
|
||||
message_en: getErrorEntry('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING')!.message_en,
|
||||
...(currency !== undefined ? { details: { currency } } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
it('SEK invoice: names bankgiro/plusgiro/Swish, not a currency account', () => {
|
||||
const msg = getErrorMessage(envelope('SEK'), { statusCode: 400 })
|
||||
expect(msg).toContain('bankgiro')
|
||||
expect(msg).toContain('Inställningar → Fakturering')
|
||||
expect(msg).not.toMatch(/valuta/i)
|
||||
})
|
||||
|
||||
it('EUR invoice: asks for an IBAN account for EUR', () => {
|
||||
const msg = getErrorMessage(envelope('EUR'), { statusCode: 400 })
|
||||
expect(msg).toContain('EUR')
|
||||
expect(msg).toContain('IBAN')
|
||||
})
|
||||
|
||||
it('English locale gets the currency-specific English text, not the registry fallback', () => {
|
||||
const msg = getErrorMessage(envelope('SEK'), { statusCode: 400, locale: 'en' })
|
||||
expect(msg).toContain('bankgiro')
|
||||
expect(msg).toContain('Settings → Invoicing')
|
||||
expect(msg).not.toMatch(/currency/i)
|
||||
})
|
||||
|
||||
it('without a currency in details, falls back to the registry text', () => {
|
||||
const msg = getErrorMessage(envelope(), { statusCode: 400 })
|
||||
expect(msg).toBe(getErrorEntry('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING')!.message_sv)
|
||||
const unknown = getErrorMessage(envelope('JPY'), { statusCode: 400 })
|
||||
expect(unknown).toBe(getErrorEntry('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING')!.message_sv)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,6 +20,10 @@
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
// Pure module (no next/server): safe for the client bundles this file lives in.
|
||||
import { formatDimensionValidationIssues } from '@/lib/bookkeeping/dimension-errors'
|
||||
import {
|
||||
describeMissingInvoicePaymentAccount,
|
||||
isInvoicePaymentAccountCurrency,
|
||||
} from '@/lib/invoices/payment-accounts'
|
||||
import { getErrorEntry, hasErrorEntry } from './structured-errors'
|
||||
|
||||
type ErrorContext =
|
||||
@@ -352,6 +356,20 @@ export function getErrorMessage(
|
||||
details?: unknown
|
||||
}
|
||||
|
||||
// Say what is missing for THIS invoice's currency: on a SEK invoice the
|
||||
// registry's currency-neutral text read as a foreign-currency account
|
||||
// when the gap was the company's bankgiro (#2126). Before the English
|
||||
// registry shortcut on purpose: both locales get the specific text.
|
||||
if (structured.code === 'INVOICE_SEND_PAYMENT_ACCOUNT_MISSING') {
|
||||
// Own local name on purpose: the sek-labelled-amount guard keys
|
||||
// currency reads by owner path, and `details` is also the owner of
|
||||
// the SEK-only journal totals formatted further down.
|
||||
const paymentDetails = structured.details as { currency?: unknown } | undefined
|
||||
if (isInvoicePaymentAccountCurrency(paymentDetails?.currency)) {
|
||||
return pick(describeMissingInvoicePaymentAccount(paymentDetails.currency), locale)
|
||||
}
|
||||
}
|
||||
|
||||
// For English UI, return the registry's English message for any known
|
||||
// code instead of falling through to the Swedish branches below (which
|
||||
// ignored locale: English users were shown Swedish prose). The Swedish
|
||||
|
||||
@@ -1145,10 +1145,13 @@ const INVOICE: Record<string, StructuredErrorEntry> = {
|
||||
},
|
||||
INVOICE_SEND_PAYMENT_ACCOUNT_MISSING: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Fakturan saknar ett betalningskonto för vald valuta. Lägg till kontot under Inställningar → Fakturering innan du skapar PDF-filen eller skickar fakturan.',
|
||||
message_en: 'The invoice has no payment account for its currency. Add the account under Inställningar → Fakturering (Settings → Invoicing) before generating the PDF or sending the invoice.',
|
||||
// Currency-neutral by necessity (the registry has no details). Surfaces
|
||||
// that know the invoice currency say exactly what is missing through
|
||||
// describeMissingInvoicePaymentAccount() (lib/invoices/payment-accounts.ts).
|
||||
message_sv: 'Fakturan saknar betalningsuppgifter för sin valuta: bankgiro, plusgiro, Swish eller bankkonto för SEK, IBAN för andra valutor. Lägg till dem under Inställningar → Fakturering innan du skapar PDF-filen eller skickar fakturan.',
|
||||
message_en: 'The invoice has no payment details for its currency: bankgiro, plusgiro, Swish or a bank account for SEK, an IBAN for other currencies. Add them under Inställningar → Fakturering (Settings → Invoicing) before generating the PDF or sending the invoice.',
|
||||
remediation: {
|
||||
description: 'Lägg till ett betalningskonto med IBAN för fakturans valuta under Inställningar → Fakturering.',
|
||||
description: 'Lägg till betalningsuppgifter för fakturans valuta under Inställningar → Fakturering: bankgiro, plusgiro, Swish eller bankkonto för SEK, IBAN för andra valutor.',
|
||||
},
|
||||
},
|
||||
INVOICE_SEND_VAT_NUMBER_MISSING: {
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
InvoicePaymentAccountMissingError,
|
||||
assertInvoicePaymentAccountForRender,
|
||||
companyWithInvoicePaymentAccount,
|
||||
describeMissingInvoicePaymentAccount,
|
||||
isInvoicePaymentAccountCurrency,
|
||||
hasRequiredInvoicePaymentAccount,
|
||||
hasUsableInvoicePaymentAccount,
|
||||
invoiceRequiresPaymentAccount,
|
||||
@@ -212,3 +214,42 @@ describe('invoice payment accounts', () => {
|
||||
expect(hasRequiredInvoicePaymentAccount(emptySettings, makeInvoice())).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('describeMissingInvoicePaymentAccount (#2126)', () => {
|
||||
it('SEK: names the domestic options and never talks about currency accounts', () => {
|
||||
const { sv, en } = describeMissingInvoicePaymentAccount('SEK')
|
||||
expect(sv).toContain('bankgiro')
|
||||
expect(sv).toContain('plusgiro')
|
||||
expect(sv).toContain('Swish')
|
||||
expect(sv).toContain('Inställningar → Fakturering')
|
||||
expect(sv).not.toMatch(/valuta/i)
|
||||
expect(sv).not.toMatch(/IBAN/)
|
||||
expect(en).toContain('bankgiro')
|
||||
expect(en).toContain('Settings → Invoicing')
|
||||
expect(en).not.toMatch(/currency/i)
|
||||
})
|
||||
|
||||
it('EUR: asks for an IBAN account in that currency', () => {
|
||||
const { sv, en } = describeMissingInvoicePaymentAccount('EUR')
|
||||
expect(sv).toContain('EUR')
|
||||
expect(sv).toContain('IBAN')
|
||||
expect(sv).not.toContain('routing number')
|
||||
expect(sv).toContain('Inställningar → Fakturering')
|
||||
expect(en).toContain('EUR')
|
||||
expect(en).toContain('IBAN')
|
||||
})
|
||||
|
||||
it('USD/GBP: also offers the non-IBAN routing path', () => {
|
||||
expect(describeMissingInvoicePaymentAccount('USD').sv).toContain('routing number')
|
||||
expect(describeMissingInvoicePaymentAccount('GBP').sv).toContain('sort code')
|
||||
expect(describeMissingInvoicePaymentAccount('GBP').en).toContain('sort code')
|
||||
})
|
||||
|
||||
it('isInvoicePaymentAccountCurrency guards the details field', () => {
|
||||
expect(isInvoicePaymentAccountCurrency('SEK')).toBe(true)
|
||||
expect(isInvoicePaymentAccountCurrency('NOK')).toBe(true)
|
||||
expect(isInvoicePaymentAccountCurrency('JPY')).toBe(false)
|
||||
expect(isInvoicePaymentAccountCurrency(undefined)).toBe(false)
|
||||
expect(isInvoicePaymentAccountCurrency(42)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -141,6 +141,39 @@ export function hasRequiredInvoicePaymentAccount(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What "payment account missing" means for THIS currency, in the user's
|
||||
* words. The registry entry for INVOICE_SEND_PAYMENT_ACCOUNT_MISSING has to
|
||||
* stay currency-neutral; on a SEK invoice its "betalningskonto för vald
|
||||
* valuta" read as a foreign-currency account when the gap was simply the
|
||||
* company's bankgiro (#2126). Pure: shared by the server envelopes, the
|
||||
* staged-operation commit path and the client-side error mapper.
|
||||
*/
|
||||
export function describeMissingInvoicePaymentAccount(
|
||||
currency: Currency,
|
||||
): { sv: string; en: string } {
|
||||
if (currency === 'SEK') {
|
||||
return {
|
||||
sv: 'Fakturan saknar betalningsuppgifter: företaget har inget bankgiro, plusgiro, Swish-nummer eller bankkonto att skriva på fakturan. Lägg till ett under Inställningar → Fakturering och försök igen.',
|
||||
en: 'The invoice has no payment details: the company has no bankgiro, plusgiro, Swish number or bank account to print on the invoice. Add one under Inställningar → Fakturering (Settings → Invoicing) and try again.',
|
||||
}
|
||||
}
|
||||
const routingSv = isNonIbanCurrency(currency)
|
||||
? `, eller med ${currency === 'USD' ? 'routing number' : 'sort code'}, kontonummer och BIC,`
|
||||
: ''
|
||||
const routingEn = isNonIbanCurrency(currency)
|
||||
? `, or with ${currency === 'USD' ? 'routing number' : 'sort code'}, account number and BIC,`
|
||||
: ''
|
||||
return {
|
||||
sv: `Fakturan är i ${currency}, men företaget saknar ett betalningskonto för ${currency}. Lägg till ett konto med IBAN${routingSv} för ${currency} under Inställningar → Fakturering och försök igen.`,
|
||||
en: `The invoice is in ${currency}, but the company has no ${currency} payment account. Add an account with an IBAN${routingEn} for ${currency} under Inställningar → Fakturering (Settings → Invoicing) and try again.`,
|
||||
}
|
||||
}
|
||||
|
||||
export function isInvoicePaymentAccountCurrency(value: unknown): value is Currency {
|
||||
return typeof value === 'string' && (INVOICE_PAYMENT_ACCOUNT_CURRENCIES as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
export class InvoicePaymentAccountMissingError extends Error {
|
||||
readonly code = 'INVOICE_SEND_PAYMENT_ACCOUNT_MISSING'
|
||||
readonly currency: Currency
|
||||
|
||||
@@ -125,6 +125,7 @@ import { renderToBuffer } from '@react-pdf/renderer'
|
||||
import { InvoicePDF } from '@/lib/invoices/pdf-template'
|
||||
import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
|
||||
import {
|
||||
describeMissingInvoicePaymentAccount,
|
||||
hasRequiredInvoicePaymentAccount,
|
||||
invoiceRequiresPaymentAccount,
|
||||
} from '@/lib/invoices/payment-accounts'
|
||||
@@ -2472,9 +2473,7 @@ async function commitSendInvoice(
|
||||
const paymentAccountRequired = invoiceRequiresPaymentAccount(invoice as Invoice)
|
||||
if (!hasRequiredInvoicePaymentAccount(company as CompanySettings, invoice as Invoice)) {
|
||||
return {
|
||||
error:
|
||||
getErrorEntry('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING')?.message_sv
|
||||
?? 'Betalningskonto saknas för fakturans valuta.',
|
||||
error: describeMissingInvoicePaymentAccount((invoice as Invoice).currency).sv,
|
||||
status: 400,
|
||||
}
|
||||
}
|
||||
@@ -2729,9 +2728,7 @@ async function commitMarkInvoiceSent(
|
||||
|
||||
if (!hasRequiredInvoicePaymentAccount(settings as CompanySettings, invoice as Invoice)) {
|
||||
return {
|
||||
error:
|
||||
getErrorEntry('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING')?.message_sv
|
||||
?? 'Betalningskonto saknas för fakturans valuta.',
|
||||
error: describeMissingInvoicePaymentAccount((invoice as Invoice).currency).sv,
|
||||
status: 400,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user