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:
@@ -1019,6 +1019,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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 <tr> 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).
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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)) && (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-border p-3 text-xs">
|
||||
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
|
||||
<span className="text-muted-foreground">
|
||||
{format === 'bg_lb' ? t('missing_bankgiro_warning') : t('missing_iban_warning')}{' '}
|
||||
<Link
|
||||
href="/settings/invoicing"
|
||||
className="underline underline-offset-2 hover:text-foreground"
|
||||
>
|
||||
{t('missing_sender_link')}
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleDownload} disabled={downloading}>
|
||||
{downloading ? (
|
||||
|
||||
@@ -15,7 +15,9 @@ import {
|
||||
} from '@/components/settings/SettingsRows'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { validateBankgiroNumber, validatePlusgiroNumber } from '@/lib/bankgiro/luhn'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { bankgiroFromTicSnapshot } from '@/lib/company/snapshot-bank'
|
||||
import { formatBankgiroNumber, validateBankgiroNumber, validatePlusgiroNumber } from '@/lib/bankgiro/luhn'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import {
|
||||
INVOICE_PAYMENT_ACCOUNT_CURRENCIES,
|
||||
@@ -78,7 +80,13 @@ export function InvoicePaymentAccountsSettings({
|
||||
}: InvoicePaymentAccountsSettingsProps) {
|
||||
const t = useTranslations('settings_invoice_payment_accounts')
|
||||
const { toast } = useToast()
|
||||
const { role } = useCompany()
|
||||
const { role, company } = useCompany()
|
||||
// Bolagsverket knows most companies' bankgiro (companies.tic_snapshot), but
|
||||
// the payment files read this form's field. Offer the registry number as a
|
||||
// one-click prefill when the SEK field is empty; the user still saves. The
|
||||
// helper only suggests when the snapshot's orgNumber matches the company's
|
||||
// org_number: stale fuzzy-matched snapshots can describe another entity.
|
||||
const [snapshotBankgiro, setSnapshotBankgiro] = useState<string | null>(null)
|
||||
const legacySekAccount = useMemo(
|
||||
() => legacySekInvoicePaymentAccount({
|
||||
bank_name: settings.bank_name,
|
||||
@@ -133,6 +141,24 @@ export function InvoicePaymentAccountsSettings({
|
||||
previousServerAccountsKey.current = serverAccountsKey
|
||||
}, [serverAccounts, serverAccountsKey])
|
||||
|
||||
useEffect(() => {
|
||||
if (!company?.id) return
|
||||
const supabase = createClient()
|
||||
let cancelled = false
|
||||
supabase
|
||||
.from('companies')
|
||||
.select('tic_snapshot, org_number')
|
||||
.eq('id', company.id)
|
||||
.maybeSingle()
|
||||
.then(({ data }) => {
|
||||
if (cancelled) return
|
||||
setSnapshotBankgiro(bankgiroFromTicSnapshot(data?.tic_snapshot, data?.org_number))
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [company?.id])
|
||||
|
||||
const configuredCurrencies = useMemo(
|
||||
() => INVOICE_PAYMENT_ACCOUNT_CURRENCIES.filter((currency) => !!accounts[currency]),
|
||||
[accounts],
|
||||
@@ -397,6 +423,15 @@ export function InvoicePaymentAccountsSettings({
|
||||
onChange={(event) => updateField('bankgiro', event.target.value)}
|
||||
className="max-w-40 flex-none tabular-nums"
|
||||
/>
|
||||
{activeCurrency === 'SEK' && !value(activeAccount, 'bankgiro') && snapshotBankgiro && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateField('bankgiro', formatBankgiroNumber(snapshotBankgiro))}
|
||||
className="text-xs text-muted-foreground underline underline-offset-2 transition-colors duration-150 hover:text-foreground"
|
||||
>
|
||||
{t('bankgiro_prefill', { value: formatBankgiroNumber(snapshotBankgiro) })}
|
||||
</button>
|
||||
)}
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label={t('plusgiro_label')}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { bankgiroFromTicSnapshot } from '../snapshot-bank'
|
||||
|
||||
// 5402-9681 is a Luhn-valid synthetic bankgiro; 5402-9682 fails the check.
|
||||
const VALID_BG = '54029681'
|
||||
const INVALID_BG = '54029682'
|
||||
const ORG = '5566778899'
|
||||
|
||||
function snapshot(overrides: Record<string, unknown> = {}) {
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user