Per-line VAT rates: - Add generatePerRateLines() to group invoice items by vat_rate with separate revenue + VAT lines per rate group (invoice-entries.ts) - Add getAvailableVatRates() and getVatTreatmentForRate() (vat-rules.ts) - PDF template shows per-line VAT column and per-rate totals for mixed-rate invoices - Invoice create/review UI supports per-line rate selection - Types: add vat_rate/vat_amount to InvoiceItem, vat_rate to CreateInvoiceItemInput Invoice document types (proforma, delivery note): - Add InvoiceDocumentType, document_type and converted_from_id to Invoice type - PDF hides prices for delivery notes, adds proforma notice - Email templates support all document types - mark-paid skips journal entries for non-invoice document types - Migration 031: invoice_document_type Accounting method support: - Add AccountingMethod type (accrual/cash) - Migration 032: add_accounting_method column to company_settings VAT declaration rewrite: - Rewrite to read directly from general ledger (26xx/3xxx account lines) instead of aggregating invoices/transactions/receipts - ACCOUNT_RUTA mapping drives momsdeklaration boxes from GL balances Bank reconciliation: - Transaction ingest now pre-fetches unlinked GL lines and attempts auto-reconciliation during import - Add transaction.reconciled event type - Add ReconciliationMethod type and reconciliation_method on Transaction - Migration 030: bank_reconciliation - New reconciliation engine, API routes, and BankReconciliationView component Pagination (fetchAllRows): - New lib/supabase/fetch-all.ts overcomes PostgREST 1000-row limit - Adopted in all report generators, SIE/SRU export, account list APIs Fiscal period validation: - New validate-period-duration.ts enforces max 18 months per BFL 3 kap. - Applied in period-service.ts and fiscal-periods API Account mapper simplification: - Remove Levenshtein/fuzzy matching, use exact account number match only Swedbank parser improvements: - Support abbreviated headers (Clnr, Bokfdag, Radnr) - Use Referens column as counterparty Chart of accounts management: - Add DELETE endpoint with system account and usage protection - PUT uses partial updates - New AccountCombobox, AddAccountDialog, EditAccountDialog, ChartOfAccountsManager Tax deadline corrections: - Rewrite inkomstdeklaration_ab using Skatteverket lookup table - Rewrite arsredovisning deadline to 7 months after FY end per ÅRL 8:3 Onboarding first fiscal year: - Add first fiscal year toggle with date pickers and 18-month validation UI terminology: - Change "okategoriserad/kategorisera" to "obokförd/bokföra" throughout Report column fix: - Fix start_date/end_date to period_start/period_end in report queries Supplier invoice input: - CreateSupplierInvoiceItemInput uses amount field (legacy quantity/unit_price kept) Misc: - SIE import uses upsert for idempotent account creation - account-descriptions.ts falls back to BAS reference data - Add invoice_default_notes to CompanySettings - Update CLAUDE.md to reflect current project state Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
243 lines
6.5 KiB
TypeScript
243 lines
6.5 KiB
TypeScript
import { createClient } from '@/lib/supabase/server'
|
|
import { eventBus } from '@/lib/events'
|
|
import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-duration'
|
|
import type { FiscalPeriod, PeriodStatus } from '@/types'
|
|
|
|
/**
|
|
* Lock a fiscal period — prevents new journal entries from being posted.
|
|
* Requires: period exists, belongs to user, not already locked/closed.
|
|
*/
|
|
export async function lockPeriod(
|
|
userId: string,
|
|
fiscalPeriodId: string
|
|
): Promise<FiscalPeriod> {
|
|
const supabase = await createClient()
|
|
|
|
// Fetch period
|
|
const { data: period, error: fetchError } = await supabase
|
|
.from('fiscal_periods')
|
|
.select('*')
|
|
.eq('id', fiscalPeriodId)
|
|
.eq('user_id', userId)
|
|
.single()
|
|
|
|
if (fetchError || !period) {
|
|
throw new Error('Fiscal period not found')
|
|
}
|
|
|
|
if (period.is_closed) {
|
|
throw new Error('Period is already closed')
|
|
}
|
|
|
|
if (period.locked_at) {
|
|
throw new Error('Period is already locked')
|
|
}
|
|
|
|
const { data: updated, error: updateError } = await supabase
|
|
.from('fiscal_periods')
|
|
.update({ locked_at: new Date().toISOString() })
|
|
.eq('id', fiscalPeriodId)
|
|
.eq('user_id', userId)
|
|
.select()
|
|
.single()
|
|
|
|
if (updateError || !updated) {
|
|
throw new Error(`Failed to lock period: ${updateError?.message}`)
|
|
}
|
|
|
|
const result = updated as FiscalPeriod
|
|
|
|
await eventBus.emit({
|
|
type: 'period.locked',
|
|
payload: { period: result, userId },
|
|
})
|
|
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Close a fiscal period — marks it as permanently closed.
|
|
* Requires: period is locked AND closing_entry_id is set (year-end must run first).
|
|
*/
|
|
export async function closePeriod(
|
|
userId: string,
|
|
fiscalPeriodId: string
|
|
): Promise<FiscalPeriod> {
|
|
const supabase = await createClient()
|
|
|
|
const { data: period, error: fetchError } = await supabase
|
|
.from('fiscal_periods')
|
|
.select('*')
|
|
.eq('id', fiscalPeriodId)
|
|
.eq('user_id', userId)
|
|
.single()
|
|
|
|
if (fetchError || !period) {
|
|
throw new Error('Fiscal period not found')
|
|
}
|
|
|
|
if (period.is_closed) {
|
|
throw new Error('Period is already closed')
|
|
}
|
|
|
|
if (!period.locked_at) {
|
|
throw new Error('Period must be locked before closing')
|
|
}
|
|
|
|
if (!period.closing_entry_id) {
|
|
throw new Error('Year-end closing must be executed before closing the period')
|
|
}
|
|
|
|
const { data: updated, error: updateError } = await supabase
|
|
.from('fiscal_periods')
|
|
.update({
|
|
is_closed: true,
|
|
closed_at: new Date().toISOString(),
|
|
})
|
|
.eq('id', fiscalPeriodId)
|
|
.eq('user_id', userId)
|
|
.select()
|
|
.single()
|
|
|
|
if (updateError || !updated) {
|
|
throw new Error(`Failed to close period: ${updateError?.message}`)
|
|
}
|
|
|
|
return updated as FiscalPeriod
|
|
}
|
|
|
|
/**
|
|
* Create the next fiscal period following the current one.
|
|
* Computes dates based on the current period's length (handles brutet räkenskapsår).
|
|
* Sets previous_period_id for chain validation.
|
|
*/
|
|
export async function createNextPeriod(
|
|
userId: string,
|
|
currentPeriodId: string
|
|
): Promise<FiscalPeriod> {
|
|
const supabase = await createClient()
|
|
|
|
const { data: current, error: fetchError } = await supabase
|
|
.from('fiscal_periods')
|
|
.select('*')
|
|
.eq('id', currentPeriodId)
|
|
.eq('user_id', userId)
|
|
.single()
|
|
|
|
if (fetchError || !current) {
|
|
throw new Error('Current fiscal period not found')
|
|
}
|
|
|
|
// Check if next period already exists
|
|
const nextStart = new Date(current.period_end)
|
|
nextStart.setDate(nextStart.getDate() + 1)
|
|
|
|
const { data: existing } = await supabase
|
|
.from('fiscal_periods')
|
|
.select('id')
|
|
.eq('user_id', userId)
|
|
.eq('period_start', nextStart.toISOString().split('T')[0])
|
|
.maybeSingle()
|
|
|
|
if (existing) {
|
|
throw new Error('Next fiscal period already exists')
|
|
}
|
|
|
|
// Compute period length from current period to handle broken fiscal years
|
|
const currentStart = new Date(current.period_start)
|
|
const currentEnd = new Date(current.period_end)
|
|
|
|
// Calculate months difference
|
|
const monthsDiff =
|
|
(currentEnd.getFullYear() - currentStart.getFullYear()) * 12 +
|
|
(currentEnd.getMonth() - currentStart.getMonth())
|
|
|
|
// Next period end: add same number of months from next start, then go to end of that month
|
|
const nextEnd = new Date(nextStart)
|
|
nextEnd.setMonth(nextEnd.getMonth() + monthsDiff)
|
|
// Go to end of the month
|
|
nextEnd.setMonth(nextEnd.getMonth() + 1)
|
|
nextEnd.setDate(0)
|
|
|
|
const nextStartStr = nextStart.toISOString().split('T')[0]
|
|
const nextEndStr = nextEnd.toISOString().split('T')[0]
|
|
|
|
// Validate period duration (max 18 months per BFL 3 kap.)
|
|
const durationError = validatePeriodDuration(nextStartStr, nextEndStr)
|
|
if (durationError) {
|
|
throw new Error(durationError)
|
|
}
|
|
|
|
// Generate name: e.g. "FY 2025" or "FY 2025/2026"
|
|
const startYear = nextStart.getFullYear()
|
|
const endYear = nextEnd.getFullYear()
|
|
const name = startYear === endYear ? `FY ${startYear}` : `FY ${startYear}/${endYear}`
|
|
|
|
const { data: newPeriod, error: insertError } = await supabase
|
|
.from('fiscal_periods')
|
|
.insert({
|
|
user_id: userId,
|
|
name,
|
|
period_start: nextStartStr,
|
|
period_end: nextEndStr,
|
|
previous_period_id: currentPeriodId,
|
|
})
|
|
.select()
|
|
.single()
|
|
|
|
if (insertError || !newPeriod) {
|
|
throw new Error(`Failed to create next period: ${insertError?.message}`)
|
|
}
|
|
|
|
return newPeriod as FiscalPeriod
|
|
}
|
|
|
|
/**
|
|
* Get status summary for a fiscal period.
|
|
*/
|
|
export async function getPeriodStatus(
|
|
userId: string,
|
|
fiscalPeriodId: string
|
|
): Promise<PeriodStatus> {
|
|
const supabase = await createClient()
|
|
|
|
const { data: period, error: fetchError } = await supabase
|
|
.from('fiscal_periods')
|
|
.select('*')
|
|
.eq('id', fiscalPeriodId)
|
|
.eq('user_id', userId)
|
|
.single()
|
|
|
|
if (fetchError || !period) {
|
|
throw new Error('Fiscal period not found')
|
|
}
|
|
|
|
// Count draft entries in this period
|
|
const { count: draftCount } = await supabase
|
|
.from('journal_entries')
|
|
.select('id', { count: 'exact', head: true })
|
|
.eq('user_id', userId)
|
|
.eq('fiscal_period_id', fiscalPeriodId)
|
|
.eq('status', 'draft')
|
|
|
|
// Check if next period exists
|
|
const nextStart = new Date(period.period_end)
|
|
nextStart.setDate(nextStart.getDate() + 1)
|
|
|
|
const { data: nextPeriod } = await supabase
|
|
.from('fiscal_periods')
|
|
.select('id')
|
|
.eq('user_id', userId)
|
|
.eq('previous_period_id', fiscalPeriodId)
|
|
.maybeSingle()
|
|
|
|
return {
|
|
is_locked: !!period.locked_at,
|
|
is_closed: period.is_closed,
|
|
has_closing_entry: !!period.closing_entry_id,
|
|
has_opening_balances: period.opening_balances_set,
|
|
draft_count: draftCount ?? 0,
|
|
next_period_exists: !!nextPeriod,
|
|
}
|
|
}
|