feat: booking template library with system templates and cross-company sharing (#235)
* feat: implement viewer role permissions for bank transaction imports and connections * feat: add booking_template_library table with 30 system templates Three-level scoping (system/team/company), RLS policies for read/write/delete, and pre-seeded templates for EU reverse charge, tax account, private transfers, salary, representation, year-end, VAT netting, and bank/finance scenarios. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add template library types, helpers, and tests - BookingTemplateLibrary/Line/Category types in types/index.ts - applyTemplate() converts template lines + amount into form lines - Category labels, scope helpers for UI display - 8 unit tests for amount calculation, VAT, rounding, and scope detection Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add booking template CRUD, export, and import API routes - GET/POST/DELETE /api/settings/booking-templates (list, create, soft-delete) - PUT /api/settings/booking-templates/[id] (update non-system templates) - GET /api/settings/booking-templates/export (JSON download) - POST /api/settings/booking-templates/import (bulk import from JSON) All routes enforce auth, write permissions, and Zod validation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add template picker UI and settings management panel - BookingTemplatePicker: dialog with search, category/entity-type filter, line preview, and amount input — integrated into JournalEntryForm - BookingTemplatesPanel: settings page with grouped templates (system/team/company), create dialog, export/import, soft-delete - Settings templates page now shows both booking and counterparty templates Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Update supabase/migrations/20260413160000_booking_template_library.sql Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
greptile-apps[bot]
parent
cd376e1cad
commit
bf36ebfd88
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { applyTemplate, getTemplateScope, TEMPLATE_CATEGORY_LABELS } from '../template-library'
|
||||
import type { BookingTemplateLibraryLine } from '@/types'
|
||||
|
||||
describe('applyTemplate', () => {
|
||||
it('creates simple two-line debit/credit entries', () => {
|
||||
const lines: BookingTemplateLibraryLine[] = [
|
||||
{ account: '1630', label: 'Skattekonto', side: 'debit', type: 'business', ratio: 1.0 },
|
||||
{ account: '1930', label: 'Företagskonto', side: 'credit', type: 'settlement', ratio: 1.0 },
|
||||
]
|
||||
const result = applyTemplate(lines, 10000)
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0]).toEqual({
|
||||
account_number: '1630',
|
||||
debit_amount: '10000.00',
|
||||
credit_amount: '',
|
||||
line_description: 'Skattekonto',
|
||||
})
|
||||
expect(result[1]).toEqual({
|
||||
account_number: '1930',
|
||||
debit_amount: '',
|
||||
credit_amount: '10000.00',
|
||||
line_description: 'Företagskonto',
|
||||
})
|
||||
})
|
||||
|
||||
it('calculates VAT correctly for reverse charge (EU purchase)', () => {
|
||||
const lines: BookingTemplateLibraryLine[] = [
|
||||
{ account: '4010', label: 'Varuinköp', side: 'debit', type: 'business', ratio: 1.0 },
|
||||
{ account: '2614', label: 'Utgående moms', side: 'credit', type: 'vat', vat_rate: 0.25 },
|
||||
{ account: '2645', label: 'Ingående moms', side: 'debit', type: 'vat', vat_rate: 0.25 },
|
||||
{ account: '1930', label: 'Företagskonto', side: 'credit', type: 'settlement', ratio: 1.0 },
|
||||
]
|
||||
// Total payment is 10000 SEK (no VAT on the payment itself for reverse charge)
|
||||
const result = applyTemplate(lines, 10000)
|
||||
expect(result).toHaveLength(4)
|
||||
// Business line = 10000 * 1.0
|
||||
expect(result[0].debit_amount).toBe('10000.00')
|
||||
// VAT = 10000 * 0.25 / (1 + 0.25) = 2000
|
||||
expect(result[1].credit_amount).toBe('2000.00')
|
||||
expect(result[2].debit_amount).toBe('2000.00')
|
||||
// Settlement = 10000
|
||||
expect(result[3].credit_amount).toBe('10000.00')
|
||||
})
|
||||
|
||||
it('handles representation with 25% input VAT', () => {
|
||||
const lines: BookingTemplateLibraryLine[] = [
|
||||
{ account: '6072', label: 'Representation', side: 'debit', type: 'business', ratio: 1.0 },
|
||||
{ account: '2641', label: 'Ingående moms', side: 'debit', type: 'vat', vat_rate: 0.25 },
|
||||
{ account: '1930', label: 'Företagskonto', side: 'credit', type: 'settlement', ratio: 1.0 },
|
||||
]
|
||||
// Total paid = 1250 (1000 + 250 VAT)
|
||||
const result = applyTemplate(lines, 1250)
|
||||
expect(result).toHaveLength(3)
|
||||
// Business = 1250 (the representation cost at full ratio)
|
||||
expect(result[0].debit_amount).toBe('1250.00')
|
||||
// VAT = 1250 * 0.25 / 1.25 = 250
|
||||
expect(result[1].debit_amount).toBe('250.00')
|
||||
// Settlement = 1250
|
||||
expect(result[2].credit_amount).toBe('1250.00')
|
||||
})
|
||||
|
||||
it('rounds monetary values to 2 decimal places', () => {
|
||||
const lines: BookingTemplateLibraryLine[] = [
|
||||
{ account: '4010', label: 'Varuinköp', side: 'debit', type: 'business', ratio: 1.0 },
|
||||
{ account: '2641', label: 'Ingående moms', side: 'debit', type: 'vat', vat_rate: 0.25 },
|
||||
{ account: '1930', label: 'Bank', side: 'credit', type: 'settlement', ratio: 1.0 },
|
||||
]
|
||||
// 333.33 should produce clean rounding
|
||||
const result = applyTemplate(lines, 333.33)
|
||||
expect(result[0].debit_amount).toBe('333.33')
|
||||
// 333.33 * 0.25 / 1.25 = 66.666 → 66.67
|
||||
expect(result[1].debit_amount).toBe('66.67')
|
||||
expect(result[2].credit_amount).toBe('333.33')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTemplateScope', () => {
|
||||
it('identifies system templates', () => {
|
||||
expect(getTemplateScope({ is_system: true, team_id: null, company_id: null })).toBe('system')
|
||||
})
|
||||
|
||||
it('identifies team templates', () => {
|
||||
expect(getTemplateScope({ is_system: false, team_id: 'team-1', company_id: null })).toBe('team')
|
||||
})
|
||||
|
||||
it('identifies company templates', () => {
|
||||
expect(getTemplateScope({ is_system: false, team_id: null, company_id: 'comp-1' })).toBe('company')
|
||||
})
|
||||
})
|
||||
|
||||
describe('TEMPLATE_CATEGORY_LABELS', () => {
|
||||
it('has labels for all categories', () => {
|
||||
expect(Object.keys(TEMPLATE_CATEGORY_LABELS)).toHaveLength(9)
|
||||
expect(TEMPLATE_CATEGORY_LABELS.eu_trade).toBe('EU-handel')
|
||||
expect(TEMPLATE_CATEGORY_LABELS.tax_account).toBe('Skattekonto')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { BookingTemplateCategory, BookingTemplateLibraryLine } from '@/types'
|
||||
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
|
||||
|
||||
/**
|
||||
* Category labels in Swedish for UI display.
|
||||
*/
|
||||
export const TEMPLATE_CATEGORY_LABELS: Record<BookingTemplateCategory, string> = {
|
||||
eu_trade: 'EU-handel',
|
||||
tax_account: 'Skattekonto',
|
||||
private_transfer: 'Egna transaktioner',
|
||||
salary: 'Lön',
|
||||
representation: 'Representation',
|
||||
year_end: 'Bokslut',
|
||||
vat: 'Moms',
|
||||
financial: 'Bank & finans',
|
||||
other: 'Övrigt',
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a template's line pattern + total amount into form lines
|
||||
* ready for the JournalEntryForm.
|
||||
*
|
||||
* The algorithm:
|
||||
* 1. VAT lines: amount = totalAmount × vat_rate / (1 + vat_rate)
|
||||
* 2. Settlement lines: amount = totalAmount (the full payment)
|
||||
* 3. Business lines: amount = totalAmount × ratio (cost/revenue net of VAT handled separately)
|
||||
*
|
||||
* For simple two-line templates (no VAT), the ratio is typically 1.0
|
||||
* on both sides and totalAmount is used directly.
|
||||
*/
|
||||
export function applyTemplate(
|
||||
lines: BookingTemplateLibraryLine[],
|
||||
totalAmount: number,
|
||||
): FormLine[] {
|
||||
const result: FormLine[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
let amount = 0
|
||||
|
||||
if (line.type === 'vat' && line.vat_rate) {
|
||||
// VAT calculated on the total inclusive amount
|
||||
amount = Math.round(totalAmount * line.vat_rate / (1 + line.vat_rate) * 100) / 100
|
||||
} else if (line.type === 'settlement') {
|
||||
amount = Math.round(totalAmount * (line.ratio ?? 1) * 100) / 100
|
||||
} else {
|
||||
// Business lines — use ratio (default 1.0)
|
||||
amount = Math.round(totalAmount * (line.ratio ?? 1) * 100) / 100
|
||||
}
|
||||
|
||||
result.push({
|
||||
account_number: line.account,
|
||||
debit_amount: line.side === 'debit' ? amount.toFixed(2) : '',
|
||||
credit_amount: line.side === 'credit' ? amount.toFixed(2) : '',
|
||||
line_description: line.label,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope label for displaying where a template comes from.
|
||||
*/
|
||||
export function getTemplateScope(template: {
|
||||
is_system: boolean
|
||||
team_id: string | null
|
||||
company_id: string | null
|
||||
}): 'system' | 'team' | 'company' {
|
||||
if (template.is_system) return 'system'
|
||||
if (template.team_id) return 'team'
|
||||
return 'company'
|
||||
}
|
||||
|
||||
export const SCOPE_LABELS: Record<ReturnType<typeof getTemplateScope>, string> = {
|
||||
system: 'Standard',
|
||||
team: 'Team',
|
||||
company: 'Företag',
|
||||
}
|
||||
Reference in New Issue
Block a user