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