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:
Mattsson
2026-04-14 09:42:14 +02:00
committed by GitHub
co-authored by Claude Opus 4.6 greptile-apps[bot]
parent cd376e1cad
commit bf36ebfd88
12 changed files with 1685 additions and 19 deletions
@@ -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')
})
})
+78
View File
@@ -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',
}