feat: add Bankgiro number validation and formatting, update settings and invoice templates (#60)

This commit is contained in:
Mattsson
2026-03-20 09:32:29 +01:00
committed by GitHub
parent cf77adaa0a
commit 565d956371
7 changed files with 273 additions and 2 deletions
+29 -1
View File
@@ -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<string | null>(null)
const [bankgiroError, setBankgiroError] = useState<string | null>(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
</CardDescription>
</CardHeader>
<CardContent>
<CardContent className="space-y-4">
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="bank_name">Bank</Label>
@@ -406,6 +409,31 @@ export default function SettingsPage() {
/>
</div>
</div>
<div className="max-w-xs space-y-2">
<Label htmlFor="bankgiro">Bankgiro</Label>
<Input
id="bankgiro"
name="bankgiro"
placeholder="XXX-XXXX"
defaultValue={settings?.bankgiro || ''}
onBlur={(e) => {
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 && (
<p className="text-xs text-destructive">{bankgiroError}</p>
)}
</div>
</CardContent>
</Card>
+1
View File
@@ -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(),
+154
View File
@@ -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)
})
})
+79
View File
@@ -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)
}
+8 -1
View File
@@ -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
</Text>
</View>
)}
{company.bankgiro && (
<View style={styles.paymentRow}>
<Text style={styles.paymentLabel}>Bankgiro:</Text>
<Text style={styles.paymentValue}>{company.bankgiro}</Text>
</View>
)}
{company.iban && (
<View style={styles.paymentRow}>
<Text style={styles.paymentLabel}>IBAN:</Text>
@@ -502,7 +509,7 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
</View>
<View style={styles.paymentRow}>
<Text style={styles.paymentLabel}>OCR/Referens:</Text>
<Text style={[styles.paymentValue, { fontWeight: 'bold' }]}>{invoice.invoice_number}</Text>
<Text style={[styles.paymentValue, { fontWeight: 'bold' }]}>{generateOcrReference(invoice.invoice_number)}</Text>
</View>
</View>
)}
+1
View File
@@ -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',
+1
View File
@@ -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