From 565d9563710676a42f0ae6b7289ac827460765bc Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Fri, 20 Mar 2026 09:32:29 +0100 Subject: [PATCH] feat: add Bankgiro number validation and formatting, update settings and invoice templates (#60) --- app/(dashboard)/settings/page.tsx | 30 +++++- lib/api/schemas.ts | 1 + lib/bankgiro/__tests__/luhn.test.ts | 154 ++++++++++++++++++++++++++++ lib/bankgiro/luhn.ts | 79 ++++++++++++++ lib/invoices/pdf-template.tsx | 9 +- tests/helpers.ts | 1 + types/index.ts | 1 + 7 files changed, 273 insertions(+), 2 deletions(-) create mode 100644 lib/bankgiro/__tests__/luhn.test.ts create mode 100644 lib/bankgiro/luhn.ts diff --git a/app/(dashboard)/settings/page.tsx b/app/(dashboard)/settings/page.tsx index 53779af7..23dbed2d 100644 --- a/app/(dashboard)/settings/page.tsx +++ b/app/(dashboard)/settings/page.tsx @@ -33,6 +33,7 @@ import { } from 'lucide-react' import { useTheme } from 'next-themes' import type { CompanySettings } from '@/types' +import { validateBankgiroNumber, formatBankgiroNumber } from '@/lib/bankgiro/luhn' import { CalendarFeedSettings } from '@/components/settings/CalendarFeedSettings' import { getSettingsPanel } from '@/lib/extensions/settings-panel-registry' import { SecuritySettings } from '@/components/settings/SecuritySettings' @@ -52,6 +53,7 @@ export default function SettingsPage() { const hasBankingExtension = ENABLED_EXTENSION_IDS.has('enable-banking') const hasCalendarExtension = ENABLED_EXTENSION_IDS.has('calendar') const [bankConnectionError, setBankConnectionError] = useState(null) + const [bankgiroError, setBankgiroError] = useState(null) const [showDeleteDialog, setShowDeleteDialog] = useState(false) const [deleteConfirmText, setDeleteConfirmText] = useState('') const [isDeleting, setIsDeleting] = useState(false) @@ -158,6 +160,7 @@ export default function SettingsPage() { bank_name: formData.get('bank_name') as string, clearing_number: formData.get('clearing_number') as string, account_number: formData.get('account_number') as string, + bankgiro: (formData.get('bankgiro') as string) || null, preliminary_tax_monthly: parseFloat(formData.get('preliminary_tax_monthly') as string) || null, invoice_prefix: formData.get('invoice_prefix') as string || null, next_invoice_number: parseInt(formData.get('next_invoice_number') as string) || 1, @@ -379,7 +382,7 @@ export default function SettingsPage() { Betalningsuppgifter som visas på dina fakturor - +
@@ -406,6 +409,31 @@ export default function SettingsPage() { />
+
+ + { + const val = e.target.value.trim() + if (!val) { + setBankgiroError(null) + return + } + if (validateBankgiroNumber(val)) { + e.target.value = formatBankgiroNumber(val) + setBankgiroError(null) + } else { + setBankgiroError('Ogiltigt bankgironummer (7-8 siffror med kontrollsiffra)') + } + }} + /> + {bankgiroError && ( +

{bankgiroError}

+ )} +
diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index d504d3d5..8644e682 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -372,6 +372,7 @@ export const UpdateSettingsSchema = z.object({ bank_name: z.string().optional(), clearing_number: z.string().optional(), account_number: z.string().optional(), + bankgiro: z.string().nullable().optional(), iban: z.string().optional(), bic: z.string().optional(), accounting_method: AccountingMethodSchema.optional(), diff --git a/lib/bankgiro/__tests__/luhn.test.ts b/lib/bankgiro/__tests__/luhn.test.ts new file mode 100644 index 00000000..a2c99e3c --- /dev/null +++ b/lib/bankgiro/__tests__/luhn.test.ts @@ -0,0 +1,154 @@ +import { + luhnCheckDigit, + luhnValidate, + validateBankgiroNumber, + formatBankgiroNumber, + generateOcrReference, + validateOcrReference, +} from '../luhn' + +// -- Luhn core -- + +describe('luhnCheckDigit', () => { + it('calculates check digit for Bankgiro 991-2346', () => { + expect(luhnCheckDigit('991234')).toBe(6) + }) + + it('calculates check digit for Bankgiro 5555-5551', () => { + expect(luhnCheckDigit('5555555')).toBe(1) + }) + + it('returns 0 when sum is already a multiple of 10', () => { + // 0: weight 2, product 0. Sum=0 → check=0 + expect(luhnCheckDigit('0')).toBe(0) + }) +}) + +describe('luhnValidate', () => { + it('validates correct numbers', () => { + expect(luhnValidate('9912346')).toBe(true) + expect(luhnValidate('55555551')).toBe(true) + }) + + it('rejects incorrect check digits', () => { + expect(luhnValidate('9912345')).toBe(false) + expect(luhnValidate('55555552')).toBe(false) + }) + + it('rejects single-digit input', () => { + expect(luhnValidate('5')).toBe(false) + }) +}) + +// -- Bankgiro -- + +describe('validateBankgiroNumber', () => { + it('validates 7-digit bankgiro with hyphen', () => { + expect(validateBankgiroNumber('991-2346')).toBe(true) + }) + + it('validates 8-digit bankgiro with hyphen', () => { + expect(validateBankgiroNumber('5555-5551')).toBe(true) + }) + + it('validates raw digits without hyphen', () => { + expect(validateBankgiroNumber('9912346')).toBe(true) + expect(validateBankgiroNumber('55555551')).toBe(true) + }) + + it('rejects wrong check digit', () => { + expect(validateBankgiroNumber('991-2345')).toBe(false) + }) + + it('rejects wrong length', () => { + expect(validateBankgiroNumber('12345')).toBe(false) + expect(validateBankgiroNumber('123456789')).toBe(false) + }) + + it('rejects non-numeric input', () => { + expect(validateBankgiroNumber('abc-defg')).toBe(false) + }) + + it('handles spaces', () => { + expect(validateBankgiroNumber('991 2346')).toBe(true) + }) +}) + +describe('formatBankgiroNumber', () => { + it('formats 7-digit as XXX-XXXX', () => { + expect(formatBankgiroNumber('9912346')).toBe('991-2346') + }) + + it('formats 8-digit as XXXX-XXXX', () => { + expect(formatBankgiroNumber('55555551')).toBe('5555-5551') + }) + + it('handles already-formatted input', () => { + expect(formatBankgiroNumber('991-2346')).toBe('991-2346') + }) + + it('returns input unchanged for invalid lengths', () => { + expect(formatBankgiroNumber('12345')).toBe('12345') + }) +}) + +// -- OCR reference -- + +describe('generateOcrReference', () => { + it('appends correct check digit to numeric invoice number', () => { + const ocr = generateOcrReference('12345') + // 12345 → check digit 5 → '123455' + expect(ocr).toBe('123455') + expect(validateOcrReference(ocr)).toBe(true) + }) + + it('strips non-numeric characters from invoice number', () => { + const ocr = generateOcrReference('INV-2024-001') + // digits: 2024001 + expect(ocr).toBe(generateOcrReference('2024001')) + expect(validateOcrReference(ocr)).toBe(true) + }) + + it('handles pure-numeric invoice numbers', () => { + const ocr = generateOcrReference('20240001') + expect(validateOcrReference(ocr)).toBe(true) + expect(ocr.length).toBe(9) + }) + + it('returns original if no digits found', () => { + expect(generateOcrReference('ABC')).toBe('ABC') + }) + + it('returns original if digits exceed 24 characters', () => { + const long = '1'.repeat(25) + expect(generateOcrReference(long)).toBe(long) + }) + + it('generates valid OCR for single-digit invoice number', () => { + const ocr = generateOcrReference('7') + expect(ocr.length).toBe(2) + expect(validateOcrReference(ocr)).toBe(true) + }) +}) + +describe('validateOcrReference', () => { + it('validates correct OCR', () => { + expect(validateOcrReference('123455')).toBe(true) + }) + + it('rejects non-numeric', () => { + expect(validateOcrReference('12345a')).toBe(false) + }) + + it('rejects too short', () => { + expect(validateOcrReference('5')).toBe(false) + }) + + it('rejects too long (>25 digits)', () => { + expect(validateOcrReference('1'.repeat(26))).toBe(false) + }) + + it('rejects incorrect check digit', () => { + expect(validateOcrReference('123459')).toBe(false) + }) +}) diff --git a/lib/bankgiro/luhn.ts b/lib/bankgiro/luhn.ts new file mode 100644 index 00000000..e1177e4c --- /dev/null +++ b/lib/bankgiro/luhn.ts @@ -0,0 +1,79 @@ +/** + * Luhn (modulus 10) check digit calculation and validation utilities. + * Used for Swedish Bankgiro numbers and OCR payment references. + * + * Algorithm source: Bankgirot "Beräkning av kontrollsiffra 10-modulen" + */ + +/** + * Calculate the Luhn check digit for a string of digits. + * Weights alternate 2,1 starting from the rightmost digit. + */ +export function luhnCheckDigit(digits: string): number { + let sum = 0 + for (let i = digits.length - 1; i >= 0; i--) { + const posFromRight = digits.length - 1 - i + const weight = posFromRight % 2 === 0 ? 2 : 1 + let product = parseInt(digits[i], 10) * weight + if (product > 9) product -= 9 + sum += product + } + return (10 - (sum % 10)) % 10 +} + +/** + * Validate that the last digit of a number string is a correct Luhn check digit. + */ +export function luhnValidate(number: string): boolean { + if (number.length < 2) return false + const payload = number.slice(0, -1) + const checkDigit = parseInt(number[number.length - 1], 10) + return luhnCheckDigit(payload) === checkDigit +} + +// -- Bankgiro -- + +/** + * Validate a Swedish Bankgiro number (7-8 digits, Luhn check digit). + * Accepts formats: "XXX-XXXX", "XXXX-XXXX", or raw digits. + */ +export function validateBankgiroNumber(input: string): boolean { + const digits = input.replace(/[-\s]/g, '') + if (!/^\d+$/.test(digits)) return false + if (digits.length !== 7 && digits.length !== 8) return false + return luhnValidate(digits) +} + +/** + * Format a Bankgiro number with the standard hyphen placement. + * 7 digits → XXX-XXXX, 8 digits → XXXX-XXXX. + */ +export function formatBankgiroNumber(input: string): string { + const digits = input.replace(/[-\s]/g, '') + if (digits.length === 7) return digits.slice(0, 3) + '-' + digits.slice(3) + if (digits.length === 8) return digits.slice(0, 4) + '-' + digits.slice(4) + return input +} + +// -- OCR reference -- + +/** + * Generate a Swedish OCR reference from an invoice number. + * Strips non-numeric characters and appends a Luhn check digit. + * Result is 2-25 digits. + */ +export function generateOcrReference(invoiceNumber: string): string { + const digits = invoiceNumber.replace(/\D/g, '') + if (digits.length === 0 || digits.length > 24) return invoiceNumber + const checkDigit = luhnCheckDigit(digits) + return digits + checkDigit.toString() +} + +/** + * Validate a Swedish OCR reference number (2-25 digits, Luhn check digit). + */ +export function validateOcrReference(ocr: string): boolean { + if (!/^\d+$/.test(ocr)) return false + if (ocr.length < 2 || ocr.length > 25) return false + return luhnValidate(ocr) +} diff --git a/lib/invoices/pdf-template.tsx b/lib/invoices/pdf-template.tsx index 91fdd4d8..9cdfd5c2 100644 --- a/lib/invoices/pdf-template.tsx +++ b/lib/invoices/pdf-template.tsx @@ -6,6 +6,7 @@ import { StyleSheet, } from '@react-pdf/renderer' import type { Invoice, InvoiceItem, Customer, CompanySettings, InvoiceDocumentType } from '@/types' +import { generateOcrReference } from '@/lib/bankgiro/luhn' // Create styles const styles = StyleSheet.create({ @@ -484,6 +485,12 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN )} + {company.bankgiro && ( + + Bankgiro: + {company.bankgiro} + + )} {company.iban && ( IBAN: @@ -502,7 +509,7 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN OCR/Referens: - {invoice.invoice_number} + {generateOcrReference(invoice.invoice_number)} )} diff --git a/tests/helpers.ts b/tests/helpers.ts index 87713164..4dc98fff 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -437,6 +437,7 @@ export function makeCompanySettings( bank_name: null, clearing_number: null, account_number: null, + bankgiro: null, iban: null, bic: null, accounting_method: 'accrual', diff --git a/types/index.ts b/types/index.ts index 9a4ff2d3..aef3fc96 100644 --- a/types/index.ts +++ b/types/index.ts @@ -118,6 +118,7 @@ export interface CompanySettings { bank_name: string | null clearing_number: string | null account_number: string | null + bankgiro: string | null iban: string | null bic: string | null