diff --git a/DECISIONS.md b/DECISIONS.md index 8b7eee9e..48fb8058 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1019,6 +1019,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-15] Confirmed intentional (Swedish-review note): with override=true and an unresolvable filename, the attach endpoint links a document to any same-company, same-declared-year, posted verifikat, migrated or not. This mirrors /api/documents/[id]/link, which imposes no filename check at all, so it introduces no new capability class; tenant, year and period-lock enforcement always apply. [2026-08-15] BankID tabs bind to a random non-secret `flowId` signed into the shared flow cookie and sent as a request header after start or explicit resume: mode pinning alone cannot distinguish two same-mode tabs, so an older tab could otherwise silently follow, cancel, or complete a newer person's identification after `/start` replaced the origin-wide cookie. This supersedes the 2026-08-15 decision that deliberately skipped mode matching on active polls. [2026-08-15] Did not apply BankID migration `20260815120000` to Supabase staging during PR #1625 follow-through: read-only reconciliation found 14 staging-only and 99 branch-only migration versions, so applying on top of that divergent ledger would violate the no-orphan rule. Production is reconciled with zero remote-only versions and exactly this PR migration local-only; hosted pg-real validates the migration until staging is reconciled. +[2026-08-17] Betalfil missing-bankgiro UX: advisory warning in PaymentFilePanel (download stays enabled, route stays the authority) + click-to-prefill from tic_snapshot instead of auto-seeding company_settings.bankgiro: sender payment data must be user-confirmed, and the snapshot is unvalidated registry JSON. [2026-08-16] /transactions FyPicker double-fetch fixed by gating the initial fetch on FyPicker's existing onReady (fires after its restore onChange) instead of the analysis doc's literal "read the persisted period synchronously in initial state": localStorage only holds the period ID, not the FiscalPeriod bounds, so a synchronous read would suppress FyPicker's restore (value !== null) and leave the fetch permanently unscoped while the chip claimed a year. Same outcome (one scoped fetch per mount, background refetch on period change) without a stale-bounds cache or new FyPicker API. [2026-08-16] Row exit animation for dry-table rows collapses via td padding/line-height/font-size transitions plus a numeric max-height (.row-collapsible) on the fixed-height cell spans, not grid-template-rows 0fr (the AttGoraSection pattern): table cells cannot host the grid wrapper without restructuring every td, and max-height needs a numeric rest value because auto/none does not interpolate. prefers-reduced-motion hides the exiting row instantly (display: none) while the 350ms timer does the state cleanup. [2026-08-16] Restyled QuickReviewDialog's inbox-picker trigger to the same full-width dropzone-footer row as TransactionBookingDialog even though it did not share the orphan-button layout: both surfaces come from #1620 and should present the same underlag affordance; the alternative (leaving a small outline button in one dialog and a footer row in the other) would split the visual language of one control. Presentation only, disabled-while-booking kept (PR #1628). diff --git a/app/(dashboard)/salary/runs/[id]/page.tsx b/app/(dashboard)/salary/runs/[id]/page.tsx index 5e94fa47..08160433 100644 --- a/app/(dashboard)/salary/runs/[id]/page.tsx +++ b/app/(dashboard)/salary/runs/[id]/page.tsx @@ -1,7 +1,7 @@ 'use client' -import { use, useEffect, useState } from 'react' -import { useRouter } from 'next/navigation' +import { use, useEffect, useRef, useState } from 'react' +import { usePathname, useRouter } from 'next/navigation' import { useTranslations } from 'next-intl' import { Button } from '@/components/ui/button' import { Card, CardContent } from '@/components/ui/card' @@ -47,6 +47,7 @@ import type { EmployeeMasked, SalaryRunEmployee } from '@/types' export default function SalaryRunPage({ params }: { params: Promise<{ id: string }> }) { const { id } = use(params) const router = useRouter() + const pathname = usePathname() const { toast } = useToast() const { canWrite } = useCanWrite() const t = useTranslations('salary_run') @@ -62,6 +63,10 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string const [approveOverride, setApproveOverride] = useState(null) const [preferredPaymentFormat, setPreferredPaymentFormat] = useState<'bg_lb' | 'pain001'>('pain001') const [defaultBank, setDefaultBank] = useState(null) + // undefined = settings not loaded yet, null = confirmed missing. The panel + // only warns on null, so a failed settings fetch never shows a false alarm. + const [senderBankgiro, setSenderBankgiro] = useState(undefined) + const [senderIban, setSenderIban] = useState(undefined) // Gates the default-dimensions chips on the employee rows: same // company_settings.dimensions_enabled UI gate as the voucher form. const [dimensionsEnabled, setDimensionsEnabled] = useState(false) @@ -104,33 +109,53 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string } } + async function loadSettings() { + const settingsRes = await fetch('/api/settings') + if (!settingsRes.ok) return + const { data } = await settingsRes.json() + if (data?.preferred_payment_format === 'pain001' || data?.preferred_payment_format === 'bg_lb') { + setPreferredPaymentFormat(data.preferred_payment_format) + } + setDefaultBank(typeof data?.salary_default_bank === 'string' ? data.salary_default_bank : null) + setSenderBankgiro( + typeof data?.bankgiro === 'string' && data.bankgiro.trim() ? data.bankgiro : null, + ) + setSenderIban(typeof data?.iban === 'string' && data.iban.trim() ? data.iban : null) + setDimensionsEnabled(data?.dimensions_enabled === true) + } + useEffect(() => { async function load() { // Employees and settings don't depend on the run - load all three in // parallel instead of serially. - const [, empRes, settingsRes] = await Promise.all([ + const [, empRes] = await Promise.all([ loadRun(), fetch('/api/salary/employees'), - fetch('/api/settings'), + loadSettings(), ]) if (empRes.ok) { const { data } = await empRes.json() setAvailableEmployees(data || []) } - if (settingsRes.ok) { - const { data } = await settingsRes.json() - if (data?.preferred_payment_format === 'pain001' || data?.preferred_payment_format === 'bg_lb') { - setPreferredPaymentFormat(data.preferred_payment_format) - } - setDefaultBank(typeof data?.salary_default_bank === 'string' ? data.salary_default_bank : null) - setDimensionsEnabled(data?.dimensions_enabled === true) - } setLoading(false) } load() // eslint-disable-next-line react-hooks/exhaustive-deps }, [id]) + // The payment-file warning links to settings, which opens as an intercepting + // modal over this still-mounted page. When the URL returns here after that + // detour, refetch settings so a bankgiro/IBAN saved in the modal clears the + // warning instead of leaving it asserting a stale "file cannot be created". + const pathnameSeen = useRef(false) + useEffect(() => { + if (!pathnameSeen.current) { + pathnameSeen.current = true + return + } + if (pathname === `/salary/runs/${id}`) loadSettings() + }, [pathname, id]) + // Refetch when the tab regains focus. AGI can be generated out-of-band (via // the MCP server, the public API, or another browser tab) and this page // would otherwise keep showing a stale "AGI-fil har inte genererats ännu" @@ -821,6 +846,8 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string paymentFileGeneratedAt={run.payment_file_generated_at} defaultFormat={preferredPaymentFormat} defaultBank={defaultBank} + senderBankgiro={senderBankgiro} + senderIban={senderIban} readOnly={!canWrite} onDownloaded={loadRun} /> diff --git a/app/api/salary/runs/[id]/payment/bg-lb/__tests__/route.test.ts b/app/api/salary/runs/[id]/payment/bg-lb/__tests__/route.test.ts index 7d384bda..64fab5bb 100644 --- a/app/api/salary/runs/[id]/payment/bg-lb/__tests__/route.test.ts +++ b/app/api/salary/runs/[id]/payment/bg-lb/__tests__/route.test.ts @@ -72,6 +72,26 @@ describe('GET /api/salary/runs/[id]/payment/bg-lb', () => { expect(response.status).toBe(403) }) + it('returns 400 pointing at the invoicing settings when bankgiro is empty', async () => { + const { enqueueMany } = authed() + enqueueMany([ + { data: { id: 'run-1', status: 'approved', period_year: 2026, period_month: 3, payment_date: '2026-03-25' } }, + { data: { name: 'Bolaget AB' } }, // companies + { data: { company_name: 'Bolaget AB', bankgiro: null } }, // company_settings + ]) + + const response = await GET( + createMockRequest('/api/salary/runs/run-1/payment/bg-lb'), + createMockRouteParams({ id: 'run-1' }), + ) + + expect(response.status).toBe(400) + const body = await response.json() + // The message must name where the setting lives: the settings overview + // shows a registry bankgiro this route does not read. + expect(body.error).toContain('Inställningar → Fakturering') + }) + it('generates a Bankgirot LB file for an approved run', async () => { const { enqueueMany } = authed() enqueueMany([ diff --git a/app/api/salary/runs/[id]/payment/bg-lb/route.ts b/app/api/salary/runs/[id]/payment/bg-lb/route.ts index 8369545c..b01347f9 100644 --- a/app/api/salary/runs/[id]/payment/bg-lb/route.ts +++ b/app/api/salary/runs/[id]/payment/bg-lb/route.ts @@ -58,7 +58,9 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( if (!settings?.bankgiro) { return NextResponse.json( - { error: 'Bankgironummer saknas i företagsinställningar. Krävs för Bankgirot LB-fil.' }, + // The settings overview shows a bankgiro from the Bolagsverket snapshot, + // which is display data only; point at the field this route reads. + { error: 'Företagets bankgironummer är inte ifyllt. Fyll i det under Inställningar → Fakturering för att skapa Bankgirot LB-fil.' }, { status: 400 } ) } diff --git a/app/api/skatteverket/tax-payments/[period]/payment-file/route.ts b/app/api/skatteverket/tax-payments/[period]/payment-file/route.ts index 52f536ba..3ffca45b 100644 --- a/app/api/skatteverket/tax-payments/[period]/payment-file/route.ts +++ b/app/api/skatteverket/tax-payments/[period]/payment-file/route.ts @@ -91,7 +91,9 @@ export const GET = withRouteContext<{ params: Promise<{ period: string }> }>( if (!settings?.bankgiro) { return NextResponse.json( - { error: 'Bankgironummer saknas i företagsinställningar.' }, + // Same wording as the salary LB route: the settings overview shows a + // registry bankgiro that this route does not read. + { error: 'Företagets bankgironummer är inte ifyllt. Fyll i det under Inställningar → Fakturering för att skapa betalfilen.' }, { status: 400 } ) } diff --git a/components/salary/PaymentFilePanel.tsx b/components/salary/PaymentFilePanel.tsx index e669e742..ddb89232 100644 --- a/components/salary/PaymentFilePanel.tsx +++ b/components/salary/PaymentFilePanel.tsx @@ -22,6 +22,15 @@ interface PaymentFilePanelProps { defaultFormat: PaymentFormat /** company_settings.salary_default_bank: sorts and auto-expands the matching bank's instructions. */ defaultBank?: string | null + /** + * company_settings.bankgiro / iban: the sender account each format requires. + * null means missing as of the latest settings fetch (warn up front, the + * download would 400); undefined means unknown (settings not loaded), so no + * warning is shown. The caller must refetch after detours that can fix the + * setting (the warning links into the settings modal over this page). + */ + senderBankgiro?: string | null + senderIban?: string | null readOnly?: boolean onDownloaded?: () => void } @@ -49,6 +58,8 @@ export function PaymentFilePanel({ paymentFileGeneratedAt, defaultFormat, defaultBank, + senderBankgiro, + senderIban, readOnly, onDownloaded, }: PaymentFilePanelProps) { @@ -175,6 +186,26 @@ export function PaymentFilePanel({ )} + {/* The sender account lives in company_settings, not in the + Bolagsverket snapshot shown on the settings overview: users see + a bankgiro there and reasonably believe it is configured. Say + the precondition here, before the download 400s on it. */} + {((format === 'bg_lb' && senderBankgiro === null) || + (format === 'pain001' && senderIban === null)) && ( +
+ + + {format === 'bg_lb' ? t('missing_bankgiro_warning') : t('missing_iban_warning')}{' '} + + {t('missing_sender_link')} + + +
+ )} +
+ )} = {}) { + return { + orgNumber: ORG, + bankAccounts: [{ type: 'bankgiro', accountNumber: VALID_BG }], + ...overrides, + } +} + +describe('bankgiroFromTicSnapshot', () => { + it('returns null for null, undefined and non-object snapshots', () => { + expect(bankgiroFromTicSnapshot(null, ORG)).toBeNull() + expect(bankgiroFromTicSnapshot(undefined, ORG)).toBeNull() + expect(bankgiroFromTicSnapshot('a string', ORG)).toBeNull() + }) + + it('returns null when bankAccounts is missing or not an array', () => { + expect(bankgiroFromTicSnapshot(snapshot({ bankAccounts: undefined }), ORG)).toBeNull() + expect(bankgiroFromTicSnapshot(snapshot({ bankAccounts: 'nope' }), ORG)).toBeNull() + expect(bankgiroFromTicSnapshot(snapshot({ bankAccounts: null }), ORG)).toBeNull() + }) + + it('returns null when only non-bankgiro accounts exist', () => { + expect( + bankgiroFromTicSnapshot( + snapshot({ bankAccounts: [{ type: 'plusgiro', accountNumber: '1234567' }] }), + ORG, + ), + ).toBeNull() + }) + + it('returns the digits of a valid bankgiro account', () => { + expect(bankgiroFromTicSnapshot(snapshot(), ORG)).toBe(VALID_BG) + }) + + it('strips hyphens and spaces from the registry value', () => { + expect( + bankgiroFromTicSnapshot( + snapshot({ bankAccounts: [{ type: 'bankgiro', accountNumber: '5402-9681' }] }), + ORG, + ), + ).toBe(VALID_BG) + }) + + it('skips bankgiro entries that fail the Luhn check', () => { + expect( + bankgiroFromTicSnapshot( + snapshot({ bankAccounts: [{ type: 'bankgiro', accountNumber: INVALID_BG }] }), + ORG, + ), + ).toBeNull() + }) + + it('skips malformed entries and finds a later valid one', () => { + expect( + bankgiroFromTicSnapshot( + snapshot({ + bankAccounts: [ + null, + { type: 'bankgiro' }, + { type: 'bankgiro', accountNumber: 12345 }, + { type: 'bankgiro', accountNumber: VALID_BG }, + ], + }), + ORG, + ), + ).toBe(VALID_BG) + }) + + describe('snapshot identity guard', () => { + it('returns null when the snapshot describes a different org', () => { + expect(bankgiroFromTicSnapshot(snapshot({ orgNumber: '5511223344' }), ORG)).toBeNull() + }) + + it('returns null when the snapshot has no orgNumber to prove identity', () => { + expect(bankgiroFromTicSnapshot(snapshot({ orgNumber: undefined }), ORG)).toBeNull() + expect(bankgiroFromTicSnapshot(snapshot({ orgNumber: 5566778899 }), ORG)).toBeNull() + }) + + it('returns null when the company org number is missing or empty', () => { + expect(bankgiroFromTicSnapshot(snapshot(), null)).toBeNull() + expect(bankgiroFromTicSnapshot(snapshot(), undefined)).toBeNull() + expect(bankgiroFromTicSnapshot(snapshot(), '')).toBeNull() + }) + + it('matches org numbers regardless of hyphenation', () => { + expect(bankgiroFromTicSnapshot(snapshot({ orgNumber: '556677-8899' }), ORG)).toBe(VALID_BG) + expect(bankgiroFromTicSnapshot(snapshot(), '556677-8899')).toBe(VALID_BG) + }) + + it('matches a 12-digit personnummer against its 10-digit form', () => { + expect( + bankgiroFromTicSnapshot(snapshot({ orgNumber: '198012311234' }), '801231-1234'), + ).toBe(VALID_BG) + expect( + bankgiroFromTicSnapshot(snapshot({ orgNumber: '8012311234' }), '19801231-1234'), + ).toBe(VALID_BG) + }) + + it('does not match on partial digit overlap', () => { + expect(bankgiroFromTicSnapshot(snapshot({ orgNumber: '66778899' }), ORG)).toBeNull() + }) + }) +}) diff --git a/lib/company/snapshot-bank.ts b/lib/company/snapshot-bank.ts new file mode 100644 index 00000000..8504d3a1 --- /dev/null +++ b/lib/company/snapshot-bank.ts @@ -0,0 +1,57 @@ +import { validateBankgiroNumber } from '@/lib/bankgiro/luhn' + +/** + * Extract the company's bankgiro number from the cached TIC company snapshot + * (companies.tic_snapshot). The snapshot's BANKUPPGIFTER rows are registry + * display data from Bolagsverket and are never read by the payment-file + * generators; those read company_settings.bankgiro. This helper bridges the + * two as a suggestion only: the user still confirms and saves the value. + * + * The snapshot must prove it describes THIS company: older snapshots were + * fetched via fuzzy search and can hold a different entity's whole profile + * (see lib/company/tic-refresh.ts), and this field ends up as the payee + * account on invoices. A suggestion is only returned when the snapshot's + * orgNumber matches the company's org_number; no match, no suggestion. + * + * Returns the raw digits (no hyphen) of the first bankgiro-typed account that + * passes the Luhn check, or null. The snapshot is unvalidated registry JSON, + * so every level is checked defensively. + */ +export function bankgiroFromTicSnapshot( + snapshot: unknown, + companyOrgNumber: string | null | undefined, +): string | null { + if (!snapshot || typeof snapshot !== 'object') return null + + const snapshotOrg = (snapshot as { orgNumber?: unknown }).orgNumber + if (typeof snapshotOrg !== 'string' || !orgNumbersMatch(snapshotOrg, companyOrgNumber)) { + return null + } + + const accounts = (snapshot as { bankAccounts?: unknown }).bankAccounts + if (!Array.isArray(accounts)) return null + for (const entry of accounts) { + if (!entry || typeof entry !== 'object') continue + const { type, accountNumber } = entry as { type?: unknown; accountNumber?: unknown } + if (type !== 'bankgiro' || typeof accountNumber !== 'string') continue + const digits = accountNumber.replace(/[-\s]/g, '') + if (validateBankgiroNumber(digits)) return digits + } + return null +} + +/** + * Digits-only identity compare. Org numbers appear both with and without the + * hyphen, and enskild firma personnummer both as 10 and century-prefixed 12 + * digits; a 12-vs-10 pair matches on the trailing 10 digits. + */ +function orgNumbersMatch(a: string, b: string | null | undefined): boolean { + if (!b) return false + const da = a.replace(/\D/g, '') + const db = b.replace(/\D/g, '') + if (!da || !db) return false + if (da === db) return true + if (da.length === 12 && db.length === 10) return da.slice(2) === db + if (da.length === 10 && db.length === 12) return da === db.slice(2) + return false +} diff --git a/messages/en.json b/messages/en.json index 95ac02f2..916e16bf 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1933,6 +1933,9 @@ "pain001_agreement_warning": "pain.001 is normally delivered through a file transfer agreement or a bankgiro connection, not by uploading it in your online bank. Check that your bank accepts the file that way well before the pay date.", "sunset_warning": "Swedish banks are retiring Bankgirot Lön during 2026 — Swedbank stops accepting LB salary files on 1 August 2026, other banks during the autumn. Switch to pain.001 in good time.", "sunset_link": "Change in payroll settings", + "missing_bankgiro_warning": "The company bankgiro number is not filled in, so the LB file cannot be generated. The bankgiro shown under Company details comes from Bolagsverket and is not used automatically.", + "missing_iban_warning": "The company IBAN is not filled in, so the pain.001 file cannot be generated.", + "missing_sender_link": "Enter it under Settings → Invoicing", "download": "Download payment file", "download_failed_title": "Payment file could not be generated", "download_failed_fallback": "Could not generate payment file", @@ -2169,6 +2172,7 @@ "clearing_label": "Clearing number", "account_number_label": "Account number", "bankgiro_label": "Bankgiro", + "bankgiro_prefill": "Use the number from Bolagsverket: {value}", "plusgiro_label": "Plusgiro", "swish_label": "Swish", "iban_label": "IBAN", diff --git a/messages/sv.json b/messages/sv.json index 1a72d2e8..97dd3c09 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -1933,6 +1933,9 @@ "pain001_agreement_warning": "pain.001 skickas normalt via filkommunikationsavtal eller bankgirokoppling, inte genom att laddas upp i internetbanken. Kontrollera att din bank tar emot filen den vägen i god tid före utbetalningsdagen.", "sunset_warning": "Bankerna avvecklar Bankgirot Lön under 2026 — Swedbank slutar ta emot LB-lönefiler 1 augusti 2026, övriga banker under hösten. Byt till pain.001 i god tid.", "sunset_link": "Ändra i löneinställningarna", + "missing_bankgiro_warning": "Företagets bankgironummer är inte ifyllt, så LB-filen kan inte skapas. Bankgirot som visas under Bolagsuppgifter är hämtat från Bolagsverket och används inte automatiskt.", + "missing_iban_warning": "Företagets IBAN är inte ifyllt, så pain.001-filen kan inte skapas.", + "missing_sender_link": "Ange under Inställningar → Fakturering", "download": "Ladda ner betalfil", "download_failed_title": "Betalfil kunde inte genereras", "download_failed_fallback": "Kunde inte generera betalfil", @@ -2169,6 +2172,7 @@ "clearing_label": "Clearingnummer", "account_number_label": "Kontonummer", "bankgiro_label": "Bankgiro", + "bankgiro_prefill": "Hämta från Bolagsverket: {value}", "plusgiro_label": "Plusgiro", "swish_label": "Swish", "iban_label": "IBAN",