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
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user