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:
co-authored by
Claude Fable 5
parent
25524e1df4
commit
1bb423b2b3
@@ -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 }
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user