feat: add Bankgiro number validation and formatting, update settings and invoice templates (#60)
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user