fix(salary): surface missing sender bankgiro/IBAN before betalfil download (#1640)

* fix(salary): surface missing sender bankgiro/IBAN before betalfil download

Users see a bankgiro under BANKUPPGIFTER in settings (Bolagsverket
snapshot, display only) while the payment-file routes read
company_settings.bankgiro, so the LB download failed with an error
that pointed at a page that looked correct. 153 companies have a
registry bankgiro but an empty settings field.

- PaymentFilePanel warns up front when the sender bankgiro (bg_lb)
  or IBAN (pain001) is missing, linking to Installningar -> Fakturering
- betalkonton form offers a one-click prefill of the bankgiro from
  companies.tic_snapshot (Luhn-validated, user still saves)
- bg-lb and skattekonto payment-file error copy now names the exact
  place to fix instead of 'foretagsinstallningar'

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(salary): harden bankgiro prefill and warning per skeptic review

- bankgiroFromTicSnapshot now requires the snapshot's orgNumber to match
  companies.org_number before suggesting anything: stale fuzzy-matched
  snapshots can hold another entity's profile, and this field becomes the
  payee account on invoices and Peppol e-invoices
- salary run page refetches settings when the URL returns from the
  intercepting settings modal, so a bankgiro/IBAN saved there clears the
  missing-sender warning instead of leaving it stale

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-17 11:15:37 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 25524e1df4
commit 1bb423b2b3
11 changed files with 310 additions and 16 deletions
+39 -12
View File
@@ -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<string[] | null>(null)
const [preferredPaymentFormat, setPreferredPaymentFormat] = useState<'bg_lb' | 'pain001'>('pain001')
const [defaultBank, setDefaultBank] = useState<string | null>(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<string | null | undefined>(undefined)
const [senderIban, setSenderIban] = useState<string | null | undefined>(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}
/>
@@ -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([
@@ -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 }
)
}
@@ -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 }
)
}