Add/bokslut (#718)
* feat(arcim-migration): Briox provider with SIE-over-API import - Briox auth via account ID + application token (no app-level credentials); both tokens rotate on refresh and are persisted - New sie-fetcher pulls the general ledger as SIE through the provider API for Fortnox, Briox and Bjorn Lunden - Wizard stops on a failed SIE import and surfaces the real errors instead of proceeding to the misleading migrate-guard message - PROVIDER_SIE_ONLY_FORTNOX renamed to PROVIDER_SIE_NOT_SUPPORTED; new PROVIDER_TOKEN_INVALID for rejected provider credentials Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bookkeeping): per-line accruals (periodisering) on invoices and supplier invoices Defer revenue/costs per invoice line to 29xx/17xx interim accounts with automatic monthly dissolution (nightly cron + catch-up at registration), schedule cancellation on credit, year-end auto-detect exclusion for already-scheduled invoices, invoice-inbox service-period extraction for prefill, and an MCP tool to list schedules. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bokslut): iXBRL arsredovisning generation and Bolagsverket digital filing Generate the annual report as iXBRL from a generated taxonomy registry (K2 element lists, taxonomy:generate/check scripts + CI guard), expose it via the fiscal-period API, and add the bolagsverket extension for digital submission to eget utrymme with webhook-driven status tracking (submissions table + pg tests, lifecycle events, year-end wizard UI). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(mcp): raise origin-guard test timeout to 20s The dynamic import pulls in the full server module; the parse alone flirts with the 5s default under full-suite parallel load. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add new scripts and documentation for K2 AB taxonomy generation and validation - Introduced `generate-taxonomy-registry.ts` to automate the generation of the iXBRL taxonomy concept registry from official element lists and tuple models. - Added `validate-ixbrl.mjs` for validating generated iXBRL reports against the official taxonomy package using Arelle. - Included new documentation files: - `k2-ab-arsredovisning-elementlista-2024-09-12_rev20250312_sv.xlsx` - `tuple-innehallsmodell-arsredovisning-k2-2024-09-12.xlsx` - `taxonomi-paket-2024-09-12_rev20250312.zip` * Add tests for bookkeeping accruals dissolution and supplier invoices - Implement tests for the POST /api/bookkeeping/accruals/[id]/dissolve route, covering success and error scenarios. - Add tests for the DELETE /api/supplier-invoices/[id] route, including authentication checks and validation of invoice deletion conditions. - Introduce tests for the Arcim migration provider client, ensuring token handling and error classification. - Create tests for the Bolagsverket extension, validating submission role enforcement and environment settings. - Add Zod schemas for Bolagsverket response payloads to ensure proper validation. - Implement tests for MCP server's list accrual schedules, confirming registration and scope mapping. - Add consistency tests for IXBRL document generation, ensuring duplicate facts and XML escaping are handled correctly. - Introduce typed domain errors for accrual schedules to improve error handling in the service. - Add tests for resolving consent with Briox token refresh concurrency, ensuring proper token management and error handling. * fix(tests): update payload size guard comments to reflect recent changes in tool descriptions and ceiling adjustments * fix(gitattributes): mark generated JSON files in bokslut taxonomy as linguist-generated * feat(migrations): add backfill for invoices.journal_entry_id and fallback for next_voucher_number user_id * feat(bokslut): enhance compliance and financial processing features with new submission details and security measures --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
8e8b63a200
commit
db8983ba9e
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { renderToBuffer } from '@react-pdf/renderer'
|
||||
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
|
||||
import { createSchedulesForCustomerInvoice } from '@/lib/bookkeeping/accruals/from-invoices'
|
||||
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { InvoicePDF } from '@/lib/invoices/pdf-template'
|
||||
@@ -105,6 +106,25 @@ export async function POST(
|
||||
)
|
||||
if (journalEntry) {
|
||||
journalEntryId = journalEntry.id
|
||||
|
||||
// Periodiserade lines: create schedules + catch-up dissolutions now
|
||||
// that the revenue entry exists. Failures are logged, never fatal —
|
||||
// the verifikat is committed.
|
||||
const accrual = await createSchedulesForCustomerInvoice(
|
||||
supabase,
|
||||
companyId,
|
||||
user.id,
|
||||
invoice as Invoice,
|
||||
(invoice.items as InvoiceItem[] | null) ?? [],
|
||||
journalEntry.id,
|
||||
(settings?.entity_type as EntityType) || 'enskild_firma',
|
||||
)
|
||||
if (accrual.failed > 0) {
|
||||
log.error('accrual schedule creation failed on mark-sent', {
|
||||
failed: accrual.failed,
|
||||
})
|
||||
}
|
||||
|
||||
const { error: linkError } = await supabase
|
||||
.from('invoices')
|
||||
.update({ journal_entry_id: journalEntry.id })
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
generateInvoiceEmailSubject,
|
||||
} from '@/lib/email/invoice-templates'
|
||||
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
|
||||
import { createSchedulesForCustomerInvoice } from '@/lib/bookkeeping/accruals/from-invoices'
|
||||
import { uploadDocument } from '@/lib/core/documents/document-service'
|
||||
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
@@ -226,6 +227,25 @@ export const POST = withRouteContext(
|
||||
.from('invoices')
|
||||
.update({ journal_entry_id: journalEntry.id })
|
||||
.eq('id', id)
|
||||
|
||||
// Periodiserade lines: create their schedules + catch-up
|
||||
// dissolutions now that the revenue entry exists. Failures degrade
|
||||
// to PARTIAL — the entry is committed and must not be rolled back.
|
||||
const accrual = await createSchedulesForCustomerInvoice(
|
||||
supabase,
|
||||
companyId!,
|
||||
user.id,
|
||||
invoice as Invoice,
|
||||
items,
|
||||
journalEntry.id,
|
||||
(company as CompanySettings).entity_type,
|
||||
)
|
||||
if (accrual.failed > 0) {
|
||||
partialFailures.push({
|
||||
step: 'accrual_schedules',
|
||||
reason: `${accrual.failed} periodisering(ar) kunde inte skapas`,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
opLog.error('failed to create invoice journal entry on send', err as Error)
|
||||
|
||||
+103
-2
@@ -7,6 +7,8 @@ import type { EntityType, AccountingMethod, Invoice, CreditNote, InvoiceDocument
|
||||
import { getVatRules, getAvailableVatRates } from '@/lib/invoices/vat-rules'
|
||||
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
|
||||
import { createCreditNoteJournalEntry } from '@/lib/bookkeeping/invoice-entries'
|
||||
import { cancelSchedulesForSource } from '@/lib/bookkeeping/accruals/service'
|
||||
import { DEFAULT_DEFERRED_REVENUE_ACCOUNT } from '@/lib/bookkeeping/accruals/account-suggestions'
|
||||
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
|
||||
import {
|
||||
computeDeduction,
|
||||
@@ -139,6 +141,39 @@ export const POST = withRouteContext(
|
||||
for (const item of invoiceInput.items) item.vat_rate = 0
|
||||
}
|
||||
|
||||
// Periodisering guards. The line schema already validates the period
|
||||
// shape; here we gate the flows where deferral has no meaning: cash
|
||||
// method (recognition at payment), reverse charge/export (3308/3305 must
|
||||
// reflect the full sale for ruta 39/40), and non-invoice document types.
|
||||
const hasAccrualItems = invoiceInput.items.some(
|
||||
(item) => item.accrual_period_start && item.accrual_period_end,
|
||||
)
|
||||
if (hasAccrualItems) {
|
||||
if (documentType !== 'invoice') {
|
||||
return errorResponseFromCode('INVOICE_CREATE_ACCRUAL_INVALID', log, {
|
||||
requestId,
|
||||
details: { reason: 'document_type', documentType },
|
||||
})
|
||||
}
|
||||
if (vatRules.treatment === 'reverse_charge' || vatRules.treatment === 'export') {
|
||||
return errorResponseFromCode('INVOICE_CREATE_ACCRUAL_INVALID', log, {
|
||||
requestId,
|
||||
details: { reason: 'vat_treatment', vatTreatment: vatRules.treatment },
|
||||
})
|
||||
}
|
||||
const { data: methodSettings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('accounting_method')
|
||||
.eq('company_id', companyId!)
|
||||
.maybeSingle()
|
||||
if ((methodSettings?.accounting_method || 'accrual') !== 'accrual') {
|
||||
return errorResponseFromCode('INVOICE_CREATE_ACCRUAL_INVALID', log, {
|
||||
requestId,
|
||||
details: { reason: 'accounting_method' },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Free-text rows carry no amounts and are excluded from totals + VAT.
|
||||
const subtotal = invoiceInput.items.reduce(
|
||||
(sum, item) => (item.line_type === 'text' ? sum : sum + item.quantity * item.unit_price),
|
||||
@@ -353,6 +388,9 @@ export const POST = withRouteContext(
|
||||
work_type: null,
|
||||
housing_designation: null,
|
||||
apartment_number: null,
|
||||
accrual_period_start: null,
|
||||
accrual_period_end: null,
|
||||
accrual_balance_account: null,
|
||||
}
|
||||
}
|
||||
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
|
||||
@@ -392,6 +430,22 @@ export const POST = withRouteContext(
|
||||
work_type: documentType === 'invoice' ? (item.work_type ?? null) : null,
|
||||
housing_designation: documentType === 'invoice' ? (item.housing_designation ?? null) : null,
|
||||
apartment_number: documentType === 'invoice' ? (item.apartment_number ?? null) : null,
|
||||
// Periodisering (förutbetald intäkt): frozen onto the line. The
|
||||
// schedule itself is created when the invoice is sent/booked. ROT/RUT
|
||||
// lines never defer (schema-enforced); the guard above already
|
||||
// restricted this to real invoices under faktureringsmetoden.
|
||||
accrual_period_start:
|
||||
documentType === 'invoice' && !deductionType
|
||||
? (item.accrual_period_start ?? null)
|
||||
: null,
|
||||
accrual_period_end:
|
||||
documentType === 'invoice' && !deductionType
|
||||
? (item.accrual_period_end ?? null)
|
||||
: null,
|
||||
accrual_balance_account:
|
||||
documentType === 'invoice' && !deductionType && item.accrual_period_start && item.accrual_period_end
|
||||
? (item.accrual_balance_account ?? DEFAULT_DEFERRED_REVENUE_ACCOUNT)
|
||||
: null,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -490,6 +544,10 @@ async function createCreditNote(
|
||||
log: Logger,
|
||||
requestId: string,
|
||||
) {
|
||||
// Non-blocking issues (e.g. partial accrual cancellation) surfaced to the
|
||||
// caller alongside the created credit note.
|
||||
const warnings: Array<{ code: string; message: string }> = []
|
||||
|
||||
const { data: originalInvoice, error: originalError } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, items:invoice_items(*)')
|
||||
@@ -561,7 +619,7 @@ async function createCreditNote(
|
||||
})
|
||||
}
|
||||
|
||||
const creditNoteItems = (originalInvoice.items || []).map((item: { sort_order: number; line_type?: 'product' | 'text'; description: string; quantity: number; unit: string; unit_price: number; line_total: number; vat_rate?: number; vat_amount?: number; revenue_account?: string | null; article_id?: string | null }) => ({
|
||||
const creditNoteItems = (originalInvoice.items || []).map((item: { sort_order: number; line_type?: 'product' | 'text'; description: string; quantity: number; unit: string; unit_price: number; line_total: number; vat_rate?: number; vat_amount?: number; revenue_account?: string | null; article_id?: string | null; accrual_period_start?: string | null; accrual_period_end?: string | null; accrual_balance_account?: string | null }) => ({
|
||||
invoice_id: creditNote.id,
|
||||
sort_order: item.sort_order,
|
||||
line_type: item.line_type ?? 'product',
|
||||
@@ -578,6 +636,14 @@ async function createCreditNote(
|
||||
// balance. article_id is preserved for the usage history.
|
||||
revenue_account: item.revenue_account ?? null,
|
||||
article_id: item.article_id ?? null,
|
||||
// Same reasoning for periodiserade lines: the credit-note verifikat must
|
||||
// reverse against the 29xx interim account the original credited, not the
|
||||
// revenue account. generatePerRateLines reads these fields to substitute.
|
||||
// No schedule is ever created for a credit note (only send/mark-sent
|
||||
// create schedules); the original's schedule is cancelled below.
|
||||
accrual_period_start: item.accrual_period_start ?? null,
|
||||
accrual_period_end: item.accrual_period_end ?? null,
|
||||
accrual_balance_account: item.accrual_balance_account ?? null,
|
||||
}))
|
||||
|
||||
const { error: itemsError } = await supabase.from('invoice_items').insert(creditNoteItems)
|
||||
@@ -638,11 +704,46 @@ async function createCreditNote(
|
||||
// Non-blocking — credit note still exists.
|
||||
}
|
||||
|
||||
// Periodisering interplay: cancel remaining months and storno posted
|
||||
// dissolutions so origin + dissolutions + stornos + credit net to zero on
|
||||
// both 29xx and 3xxx. Best-effort — never blocks the credit itself, but
|
||||
// partial reversals are surfaced as a response warning so the user knows
|
||||
// the schedule stayed active.
|
||||
try {
|
||||
const cancelResult = await cancelSchedulesForSource(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
{ invoiceId: input.credited_invoice_id },
|
||||
{ reversalDate: creditNote.invoice_date },
|
||||
)
|
||||
if (cancelResult.failedReversals > 0) {
|
||||
warnings.push({
|
||||
code: 'ACCRUAL_CANCEL_PARTIAL',
|
||||
message:
|
||||
'Fakturan krediterades, men en eller flera periodiseringsverifikat ' +
|
||||
'kunde inte vändas. Periodiseringen är fortfarande aktiv — ' +
|
||||
'kontrollera under Bokföring → Periodiseringar.',
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('failed to cancel accrual schedules for credited invoice', err as Error)
|
||||
warnings.push({
|
||||
code: 'ACCRUAL_CANCEL_PARTIAL',
|
||||
message:
|
||||
'Fakturan krediterades, men periodiseringarna kunde inte avslutas. ' +
|
||||
'Kontrollera under Bokföring → Periodiseringar.',
|
||||
})
|
||||
}
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'credit_note.created',
|
||||
payload: { creditNote: completeCreditNote as CreditNote, companyId, userId },
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: completeCreditNote })
|
||||
return NextResponse.json({
|
||||
data: completeCreditNote,
|
||||
...(warnings.length > 0 ? { warnings } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user