* feat(invoice-inbox): pin documents to bank transactions + MCP tools Adds a first-class flow for attaching unmatched inbox documents to bank transactions, separate from the existing supplier-invoice convert path: - new transactions.document_id FK → document_attachments (ON DELETE SET NULL) - POST/DELETE /api/transactions/[id]/attach-document - categorize route propagates the link to journal_entry_id on commit - three new MCP tools: gnubok_list_unmatched_documents, gnubok_get_document_content (5-min signed URL), gnubok_attach_document_to_transaction (staged via pending_operations) - InvoiceInboxWorkspace gains a "Koppla till transaktion" picker dialog ranked by amount-match, plus a "Bilaga" badge in SwipeCategorizationView - regex extraction unchanged; supplier-invoice convert flow unchanged Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(invoice-inbox): address PR #397 review findings - categorize: destructure { error } from the document-link update so Supabase-level failures are logged instead of silently dropped (BFL 5 kap 6 § receipt-on-verifikation contract). - list_unmatched_documents: emit next_cursor whenever the inbox query may have more rows, not only when the post-filter slice was full; switch to composite (created_at, id) cursor to avoid same-second collisions. - DELETE /attach-document: return 404 when the tx isn't in the company; return 409 when the linked document already has journal_entry_id set (räkenskapsinformation immutability). - risk tier: attach_document_to_transaction medium (was low) — link becomes part of verifikation underlag once categorize propagates it. - pg-real test: stop reusing $2 across uuid + text-concat contexts (Postgres couldn't deduce the parameter type). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(migrations): break duplicate version 20260505120000 (Supabase Preview) Two migrations on main share filename version 20260505120000: - 20260505120000_api_keys_refresh_token.sql (PR #392) - 20260505120000_drop_agent_auto_commit.sql (PR #394) The schema_migrations primary key is (version), so any fresh DB doing `supabase db push` over both files conflicts on the second insert. This is why every PR with a migration since #394 has had Supabase Preview either fail or skip. Renaming _drop_agent_auto_commit to 20260505190027 — that matches the timestamp recorded in prod schema_migrations from when apply_migration was called for it, so future `db push` against prod sees the file as already- applied (no re-run). The migration body is fully idempotent (IF EXISTS on every drop) so a re-run would be a no-op anyway. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(invoice-inbox): address PR #397 round-2 compliance review Two BFL gaps the compliance bot flagged on the round-1 fix commit: 1. commitAttachDocumentToTransaction silently broke verifikation→underlag if the transaction was categorized between staging and approval. Now reads transactions.journal_entry_id at commit time and, if non-null, also writes document_attachments.journal_entry_id in the same commit so BFL 5 kap 6 § is satisfied regardless of order. 2. Application-layer DELETE check was racy (SELECT then UPDATE) and the FK ON DELETE SET NULL path could null transactions.document_id even for a document that is räkenskapsinformation. Added a BEFORE UPDATE OF document_id trigger on transactions that raises check_violation when the previously-attached document has document_attachments.journal_entry_id set. The app-layer guard stays for friendly Swedish messaging; the trigger is the DB-level safety net. pg-real test extended to cover both directions of the trigger (block detach + block swap) and the happy-path detach when there's no JE link yet. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(invoice-inbox): address PR #397 round-3 compliance review Four findings from the round-2 update of the compliance bot. The first three are genuine compliance gaps; the fourth (preview metadata distinguishing pre- vs post-categorization overwrites) is a UX nicety left for follow-up. 1. transactions.document_id FK switched from ON DELETE SET NULL to RESTRICT (migration 20260506100000). Removes the "trigger ordering" concern: a doc that's pinned to any tx now cannot be deleted at all without explicit detach first. Belt-and-braces with block_document_deletion. 2. commitAttachDocumentToTransaction now does: - pre-check that mirrors the DELETE route's 409 when the existing pinned doc is räkenskapsinformation, so the same Swedish message is returned in both paths; - UPDATE…RETURNING journal_entry_id so the propagation decision uses the post-update state, closing the read-then-write race with concurrent categorize. Either ordering of attach-then-categorize or categorize-then-attach now lands at the same correct final state. 3. Both DELETE /attach-document and the MCP commit path catch the trigger's check_violation (SQLSTATE 23514) and translate to 409 with the Swedish underlag message. The trigger remains the DB-level safety net; the app layer is responsible only for friendly UX. pg-real test rewritten for ON DELETE RESTRICT (blocks deletion of pinned doc; detach-then-delete works). Unit coverage added for commitAttach: 404, two distinct 409 paths (pre-check + trigger-translation), happy-path uncategorized, and propagation when tx was categorized between staging and commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(invoice-inbox): address PR #397 round-4 compliance review Three of five round-3 findings actioned: 1. commitAttachDocumentToTransaction: surface propagation failure rather than logging-and-continuing. If document_attachments.journal_entry_id can't be set after the transaction has been categorized, the op fails (status 500) with a Swedish message instructing retry. Retry is idempotent — same document_id on the tx, same propagate target. 2. Replace check_violation (23514) matching with a stable "BFL_DOCUMENT_IMMUTABILITY:" message prefix. The trigger now uses default P0001 + tagged message; both the route handler and the executor match on the prefix instead of the generic SQLSTATE. Future unrelated CHECK constraints on transactions can no longer accidentally surface as the räkenskapsinformation message. 3. gnubok_list_unmatched_documents now returns invoice currency alongside amount so an agent can FX-normalise before comparing to transactions.amount. Description updated to make the requirement explicit. Mirrored in the UI: AttachToTransactionDialog ranks same-currency rows by amount distance and pushes cross-currency rows to the bottom of the list. Skipped: - Two-migration window for FK action change is acknowledged as resolved by the bot; deploy-atomicity is an ops concern, not code. - Period-lock check in attach/detach: realistic compliance concern is already covered by the existing immutability trigger (post-categorize) and by the engine's period-lock enforcement (categorize itself). A dedicated period check on pre-categorize attach would only guard against pinning a doc to a tx in a closed period — defensible defense-in-depth, but no active BFL violation. Left for a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(invoice-inbox): address PR #397 round-5 compliance review Three of four findings actioned. The fourth (block_document_deletion verification) is already covered by 20240101000017_enforcement_triggers.sql which raises when document_attachments.journal_entry_id IS NOT NULL on a posted/reversed entry — confirmed via grep, no code change needed. 1. categorize/route.ts: propagation no longer fires-and-forgets. If document_attachments.journal_entry_id can't be set after the JE has been committed, the response now carries a document_link_warning field with a Swedish retry message. The JE is already committed so we can't roll back, but the client can no longer mistake a partial attach for a clean categorize. 2. Rättelse audit trail (BFL 5 kap 5 §): both the REST POST handler and the MCP commit executor now append a TransactionDocumentReplaced event to processing_history whenever a non-null document_id is overwritten, with previous_document_id and new_document_id in the payload. Best-effort — logging failure must not roll back the (compliant) attach. The previous doc id is also returned in the response so callers see what was displaced. 3. MCP staging preview now exposes the existing doc's identity (existing_document_id, existing_document_file_name) plus an explicit existing_document_is_rakenskapsinformation flag, so a human approver sees "replaces X.pdf with Y.pdf" rather than just a will_overwrite_existing boolean. Mirrors BFL 5 kap 5 § informed-rättelse intent. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(invoice-inbox): close trigger SELECT race (PR #397 round-6) The enforce_transactions_document_immutability trigger SELECTed document_attachments.journal_entry_id without a row lock. A concurrent UPDATE setting journal_entry_id on that row could commit between the trigger's SELECT and its RAISE, letting a detach slip through against a document that just became räkenskapsinformation. Add FOR SHARE to the SELECT inside the trigger. A concurrent journal_entry_id write blocks on our share lock until our transaction commits, so either we observe the propagation and raise, or we run first and the propagation observes our committed detach (which is fine because journal_entry_id was still null at that point). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(invoice-inbox): bidirectional immutability + richer staging preview (PR #397 round-7) Two of six round-6 findings actioned. The other four are recurring architectural recommendations (atomic audit-log writes, background reconciliation jobs, migration consolidation, anti-join via materialized view) that are properly scoped as follow-up work. 1. document_attachments side of the immutability link (BFL 5 kap 6 § works in both directions). New trigger enforce_document_journal_entry_immutability blocks UPDATE OF journal_entry_id when going from non-null to NULL or to a different uuid. The original null→uuid path (initial propagation in the categorize / commitAttach flows) still works. Migration 20260506130000. 2. gnubok_attach_document_to_transaction staging preview now joins on invoice_inbox_items.extracted_data and surfaces vendor/amount/currency/ invoice_date alongside the existing doc filename/mime metadata. Gives the human approver the same hints the agent saw before choosing the attachment. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1727 lines
61 KiB
TypeScript
1727 lines
61 KiB
TypeScript
/**
|
|
* Unified entry point for executing a pending_operation.
|
|
*
|
|
* Used by:
|
|
* - The web UI commit route (app/api/pending-operations/[id]/commit/route.ts)
|
|
* when a human clicks "Approve"
|
|
* - The MCP server (extensions/general/mcp-server/server.ts) when a trusted
|
|
* agent stages a low-risk op that the company has opted in to auto-commit
|
|
*
|
|
* Both paths converge here so the same audit trail, event emission, error
|
|
* handling, and status transition logic apply.
|
|
*
|
|
* The executor functions previously lived in the commit route. They are kept
|
|
* private to this module — call `commitPendingOperation()` to invoke them.
|
|
*/
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { eventBus } from '@/lib/events'
|
|
import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping'
|
|
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
|
|
import { upsertCounterpartyTemplate } from '@/lib/bookkeeping/counterparty-templates'
|
|
import { getVatRules, getAvailableVatRates } from '@/lib/invoices/vat-rules'
|
|
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
|
|
import { validateVatNumber } from '@/lib/vat/vies-client'
|
|
import {
|
|
createInvoicePaymentJournalEntry,
|
|
createInvoiceCashEntry,
|
|
createInvoiceJournalEntry,
|
|
createCreditNoteJournalEntry,
|
|
} from '@/lib/bookkeeping/invoice-entries'
|
|
import { reverseEntry } from '@/lib/bookkeeping/engine'
|
|
import { closePeriod, lockPeriod, unlockPeriod } from '@/lib/core/bookkeeping/period-service'
|
|
import {
|
|
executeYearEndClosing,
|
|
generateOpeningBalances,
|
|
} from '@/lib/core/bookkeeping/year-end-service'
|
|
import { executeCurrencyRevaluation } from '@/lib/bookkeeping/currency-revaluation'
|
|
import { createSupplierCreditNoteEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
|
|
import { parseSIEFile } from '@/lib/import/sie-parser'
|
|
import { executeSIEImport } from '@/lib/import/sie-import'
|
|
import type { AccountMapping } from '@/lib/import/types'
|
|
import { AccountsNotInChartError, isBookkeepingError } from '@/lib/bookkeeping/errors'
|
|
import { getEmailService } from '@/lib/email/service'
|
|
import {
|
|
generateInvoiceEmailHtml,
|
|
generateInvoiceEmailText,
|
|
generateInvoiceEmailSubject,
|
|
} from '@/lib/email/invoice-templates'
|
|
import { uploadDocument } from '@/lib/core/documents/document-service'
|
|
import { renderToBuffer } from '@react-pdf/renderer'
|
|
import { InvoicePDF } from '@/lib/invoices/pdf-template'
|
|
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
|
|
import { createLogger } from '@/lib/logger'
|
|
import { appendProcessingHistory } from '@/lib/processing-history/append'
|
|
import type {
|
|
Transaction,
|
|
TransactionCategory,
|
|
EntityType,
|
|
VatTreatment,
|
|
Currency,
|
|
Invoice,
|
|
Customer,
|
|
PendingOperation,
|
|
CompanySettings,
|
|
InvoiceItem,
|
|
AccountingMethod,
|
|
CreditNote,
|
|
} from '@/types'
|
|
|
|
const log = createLogger('pending-operations/commit')
|
|
|
|
export interface CommitResult {
|
|
status: 'committed' | 'rejected' | 'failed'
|
|
data?: Record<string, unknown>
|
|
error?: string
|
|
http_status?: number
|
|
auto_rejected?: boolean
|
|
}
|
|
|
|
export interface CommitOptions {
|
|
/** Email address used as cc on send_invoice (typically the human user's email). */
|
|
userEmail?: string
|
|
}
|
|
|
|
// ── Helper: ensure fiscal period covers the date ──────────────────
|
|
|
|
async function ensureFiscalPeriod(
|
|
supabase: SupabaseClient,
|
|
userId: string,
|
|
companyId: string,
|
|
date: string,
|
|
fiscalYearStartMonth: number = 1
|
|
): Promise<boolean> {
|
|
const { data: existing } = await supabase
|
|
.from('fiscal_periods')
|
|
.select('id')
|
|
.eq('company_id', companyId)
|
|
.lte('period_start', date)
|
|
.gte('period_end', date)
|
|
.eq('is_closed', false)
|
|
.limit(1)
|
|
|
|
if (existing && existing.length > 0) return true
|
|
|
|
const txDate = new Date(date)
|
|
const txMonth = txDate.getMonth() + 1
|
|
const txYear = txDate.getFullYear()
|
|
|
|
let periodStartYear: number
|
|
if (fiscalYearStartMonth === 1) {
|
|
periodStartYear = txYear
|
|
} else if (txMonth >= fiscalYearStartMonth) {
|
|
periodStartYear = txYear
|
|
} else {
|
|
periodStartYear = txYear - 1
|
|
}
|
|
|
|
const startMonth = String(fiscalYearStartMonth).padStart(2, '0')
|
|
const periodStart = `${periodStartYear}-${startMonth}-01`
|
|
|
|
const endYear = fiscalYearStartMonth === 1 ? periodStartYear : periodStartYear + 1
|
|
const endMonth = fiscalYearStartMonth === 1 ? 12 : fiscalYearStartMonth - 1
|
|
const lastDay = new Date(endYear, endMonth, 0).getDate()
|
|
const periodEnd = `${endYear}-${String(endMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`
|
|
|
|
const periodName = fiscalYearStartMonth === 1
|
|
? `Räkenskapsår ${periodStartYear}`
|
|
: `Räkenskapsår ${periodStartYear}/${endYear}`
|
|
|
|
const { error } = await supabase
|
|
.from('fiscal_periods')
|
|
.upsert({
|
|
user_id: userId,
|
|
company_id: companyId,
|
|
name: periodName,
|
|
period_start: periodStart,
|
|
period_end: periodEnd,
|
|
}, { onConflict: 'user_id,period_start,period_end' })
|
|
|
|
if (error) {
|
|
log.error('Failed to create fiscal period:', error)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
async function recordSkippedInvoiceJournalEntry(
|
|
invoiceId: string,
|
|
companyId: string,
|
|
userId: string,
|
|
operation: 'send_invoice' | 'mark_invoice_sent',
|
|
err: unknown
|
|
): Promise<void> {
|
|
try {
|
|
const reasonCode = err instanceof AccountsNotInChartError
|
|
? 'accounts_not_in_chart'
|
|
: 'journal_entry_error'
|
|
const accountNumbers = err instanceof AccountsNotInChartError ? err.accountNumbers : undefined
|
|
await appendProcessingHistory({
|
|
companyId,
|
|
correlationId: invoiceId,
|
|
aggregateType: 'System',
|
|
aggregateId: invoiceId,
|
|
eventType: 'InvoiceJournalEntrySkipped',
|
|
payload: {
|
|
invoice_id: invoiceId,
|
|
operation,
|
|
reason_code: reasonCode,
|
|
...(accountNumbers ? { account_numbers: accountNumbers } : {}),
|
|
},
|
|
actor: { type: 'user', id: userId },
|
|
occurredAt: new Date(),
|
|
})
|
|
} catch (historyErr) {
|
|
log.warn('Failed to append InvoiceJournalEntrySkipped to processing_history', historyErr)
|
|
}
|
|
}
|
|
|
|
// ── Executors ────────────────────────────────────────────────────
|
|
|
|
type ExecutorResult = { data?: Record<string, unknown>; error?: string; status?: number }
|
|
|
|
async function commitCategorizeTransaction(
|
|
supabase: SupabaseClient,
|
|
userId: string,
|
|
companyId: string,
|
|
params: Record<string, unknown>
|
|
): Promise<ExecutorResult> {
|
|
const txId = params.transaction_id as string
|
|
const category = params.category as TransactionCategory
|
|
const vatTreatment = params.vat_treatment as VatTreatment | undefined
|
|
|
|
const { data: transaction, error: fetchError } = await supabase
|
|
.from('transactions').select('*').eq('id', txId).eq('company_id', companyId).single()
|
|
|
|
if (fetchError || !transaction) {
|
|
return { error: 'Transaction not found — it may have been deleted.', status: 404 }
|
|
}
|
|
if (transaction.journal_entry_id) {
|
|
return { error: 'Transaction already has a journal entry — it was categorized in the meantime.', status: 409 }
|
|
}
|
|
|
|
const isBusiness = category !== 'private'
|
|
|
|
const { data: settings } = await supabase
|
|
.from('company_settings').select('entity_type, fiscal_year_start_month').eq('company_id', companyId).single()
|
|
|
|
const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
|
const fiscalYearStartMonth = settings?.fiscal_year_start_month ?? 1
|
|
|
|
const mappingResult = buildMappingResultFromCategory(
|
|
category, transaction as Transaction, isBusiness, entityType, vatTreatment
|
|
)
|
|
|
|
if (!mappingResult.debit_account || !mappingResult.credit_account) {
|
|
return { error: `No account mapping for category "${category}" with entity type "${entityType}".`, status: 400 }
|
|
}
|
|
|
|
await ensureFiscalPeriod(supabase, userId, companyId, transaction.date, fiscalYearStartMonth)
|
|
|
|
let journalEntryId: string | null = null
|
|
try {
|
|
const journalEntry = await createTransactionJournalEntry(
|
|
supabase, companyId, userId, transaction as Transaction, mappingResult
|
|
)
|
|
if (journalEntry) journalEntryId = journalEntry.id
|
|
} catch (err) {
|
|
if (isBookkeepingError(err)) throw err
|
|
log.error('Failed to create journal entry:', err)
|
|
return { error: err instanceof Error ? err.message : 'Failed to create journal entry', status: 500 }
|
|
}
|
|
|
|
const { error: updateError } = await supabase
|
|
.from('transactions')
|
|
.update({ is_business: isBusiness, category, journal_entry_id: journalEntryId })
|
|
.eq('id', txId)
|
|
|
|
if (updateError) {
|
|
log.error('Failed to update transaction:', updateError)
|
|
return { error: 'Failed to update transaction', status: 500 }
|
|
}
|
|
|
|
try {
|
|
await upsertCounterpartyTemplate(
|
|
supabase, userId, transaction as Transaction, mappingResult, 'user_approved'
|
|
)
|
|
} catch { /* non-critical */ }
|
|
|
|
await eventBus.emit({
|
|
type: 'transaction.categorized',
|
|
payload: {
|
|
transaction: transaction as Transaction,
|
|
account: mappingResult.debit_account,
|
|
taxCode: mappingResult.vat_lines[0]?.account_number || '',
|
|
userId,
|
|
companyId,
|
|
},
|
|
})
|
|
|
|
return { data: { journal_entry_id: journalEntryId, category } }
|
|
}
|
|
|
|
async function commitCreateCustomer(
|
|
supabase: SupabaseClient,
|
|
userId: string,
|
|
companyId: string,
|
|
params: Record<string, unknown>
|
|
): Promise<ExecutorResult> {
|
|
const { data, error } = await supabase
|
|
.from('customers')
|
|
.insert({
|
|
user_id: userId,
|
|
company_id: companyId,
|
|
name: params.name as string,
|
|
customer_type: params.customer_type as string,
|
|
email: (params.email as string) || null,
|
|
org_number: (params.org_number as string) || null,
|
|
vat_number: (params.vat_number as string) || null,
|
|
default_payment_terms: (params.payment_terms as number) || 30,
|
|
address_line1: (params.address as string) || null,
|
|
postal_code: (params.postal_code as string) || null,
|
|
city: (params.city as string) || null,
|
|
country: (params.country as string) || 'Sweden',
|
|
})
|
|
.select()
|
|
.single()
|
|
|
|
if (error) return { error: error.message, status: 500 }
|
|
|
|
if (params.customer_type === 'eu_business' && params.vat_number) {
|
|
try {
|
|
const vatResult = await validateVatNumber(params.vat_number as string)
|
|
if (vatResult.valid) {
|
|
await supabase
|
|
.from('customers')
|
|
.update({ vat_number_validated: true, vat_number_validated_at: new Date().toISOString() })
|
|
.eq('id', data.id)
|
|
.eq('company_id', companyId)
|
|
}
|
|
} catch (err) {
|
|
log.warn('Auto-VIES validation failed:', err)
|
|
}
|
|
}
|
|
|
|
await eventBus.emit({ type: 'customer.created', payload: { customer: data as Customer, userId, companyId } })
|
|
|
|
return { data: { customer_id: data.id } }
|
|
}
|
|
|
|
async function commitCreateInvoice(
|
|
supabase: SupabaseClient,
|
|
userId: string,
|
|
companyId: string,
|
|
params: Record<string, unknown>
|
|
): Promise<ExecutorResult> {
|
|
const customerId = params.customer_id as string
|
|
const items = params.items as Array<{
|
|
description: string; quantity: number; unit: string; unit_price: number; vat_rate?: number
|
|
}>
|
|
|
|
const { data: customer, error: customerError } = await supabase
|
|
.from('customers').select('*').eq('id', customerId).eq('company_id', companyId).single()
|
|
|
|
if (customerError || !customer) {
|
|
return { error: 'Customer not found — they may have been deleted.', status: 404 }
|
|
}
|
|
|
|
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated)
|
|
const availableRates = getAvailableVatRates(customer.customer_type, customer.vat_number_validated)
|
|
const allowedRates = new Set(availableRates.map((r) => r.rate))
|
|
|
|
const subtotal = items.reduce((sum, item) => sum + item.quantity * item.unit_price, 0)
|
|
|
|
let vatAmount = 0
|
|
for (const item of items) {
|
|
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
|
|
if (!allowedRates.has(itemRate)) {
|
|
return { error: `Momssats ${itemRate}% är inte tillåten för denna kundtyp`, status: 400 }
|
|
}
|
|
const lineTotal = item.quantity * item.unit_price
|
|
vatAmount += Math.round(lineTotal * itemRate / 100 * 100) / 100
|
|
}
|
|
|
|
const total = subtotal + vatAmount
|
|
const currency = ((params.currency as string) || 'SEK') as Currency
|
|
|
|
let exchangeRate: number | null = null
|
|
let exchangeRateDate: string | null = null
|
|
let subtotalSek: number | null = null
|
|
let vatAmountSek: number | null = null
|
|
let totalSek: number | null = null
|
|
|
|
if (currency !== 'SEK') {
|
|
const rateData = await fetchExchangeRate(currency)
|
|
if (rateData) {
|
|
exchangeRate = rateData.rate
|
|
exchangeRateDate = rateData.date
|
|
subtotalSek = convertToSEK(subtotal, exchangeRate)
|
|
vatAmountSek = convertToSEK(vatAmount, exchangeRate)
|
|
totalSek = convertToSEK(total, exchangeRate)
|
|
}
|
|
}
|
|
|
|
const uniqueRates = new Set(items.map((item) => item.vat_rate ?? vatRules.rate))
|
|
const isMixedRate = uniqueRates.size > 1
|
|
|
|
const { data: invoice, error: invoiceError } = await supabase
|
|
.from('invoices')
|
|
.insert({
|
|
user_id: userId,
|
|
company_id: companyId,
|
|
customer_id: customerId,
|
|
invoice_number: null,
|
|
invoice_date: (params.invoice_date as string) || new Date().toISOString().split('T')[0],
|
|
due_date: (params.due_date as string) || null,
|
|
currency,
|
|
exchange_rate: exchangeRate,
|
|
exchange_rate_date: exchangeRateDate,
|
|
subtotal,
|
|
subtotal_sek: subtotalSek,
|
|
vat_amount: vatAmount,
|
|
vat_amount_sek: vatAmountSek,
|
|
total,
|
|
total_sek: totalSek,
|
|
vat_treatment: vatRules.treatment,
|
|
vat_rate: isMixedRate ? null : (uniqueRates.values().next().value ?? vatRules.rate),
|
|
moms_ruta: vatRules.momsRuta,
|
|
reverse_charge_text: vatRules.reverseChargeText || null,
|
|
our_reference: (params.our_reference as string) || null,
|
|
your_reference: (params.your_reference as string) || null,
|
|
notes: (params.notes as string) || null,
|
|
})
|
|
.select()
|
|
.single()
|
|
|
|
if (invoiceError) return { error: invoiceError.message, status: 500 }
|
|
|
|
const invoiceItems = items.map((item, index) => {
|
|
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
|
|
const lineTotal = item.quantity * item.unit_price
|
|
const itemVat = Math.round(lineTotal * itemRate / 100 * 100) / 100
|
|
return {
|
|
invoice_id: invoice.id,
|
|
sort_order: index,
|
|
description: item.description,
|
|
quantity: item.quantity,
|
|
unit: item.unit,
|
|
unit_price: item.unit_price,
|
|
line_total: lineTotal,
|
|
vat_rate: itemRate,
|
|
vat_amount: itemVat,
|
|
}
|
|
})
|
|
|
|
const { error: itemsError } = await supabase.from('invoice_items').insert(invoiceItems)
|
|
|
|
if (itemsError) {
|
|
await supabase.from('invoices').delete().eq('id', invoice.id)
|
|
return { error: itemsError.message, status: 500 }
|
|
}
|
|
|
|
const { data: completeInvoice } = await supabase
|
|
.from('invoices')
|
|
.select('*, customer:customers(*), items:invoice_items(*)')
|
|
.eq('id', invoice.id)
|
|
.single()
|
|
|
|
if (completeInvoice) {
|
|
await eventBus.emit({
|
|
type: 'invoice.created',
|
|
payload: { invoice: completeInvoice as Invoice, userId, companyId },
|
|
})
|
|
}
|
|
|
|
return { data: { invoice_id: invoice.id, invoice_number: invoice.invoice_number } }
|
|
}
|
|
|
|
async function commitMarkInvoicePaid(
|
|
supabase: SupabaseClient,
|
|
userId: string,
|
|
companyId: string,
|
|
params: Record<string, unknown>
|
|
): Promise<ExecutorResult> {
|
|
const invoiceId = params.invoice_id as string
|
|
const paymentDate = (params.payment_date as string) || new Date().toISOString().split('T')[0]
|
|
|
|
const { data: invoice, error: invoiceError } = await supabase
|
|
.from('invoices')
|
|
.select('*, customer:customers(*), items:invoice_items(*)')
|
|
.eq('id', invoiceId)
|
|
.eq('company_id', companyId)
|
|
.single()
|
|
|
|
if (invoiceError || !invoice) return { error: 'Invoice not found', status: 404 }
|
|
if (invoice.status !== 'sent' && invoice.status !== 'overdue') {
|
|
return { error: 'Invoice can only be marked as paid when status is "sent" or "overdue"', status: 409 }
|
|
}
|
|
|
|
const { data: settings } = await supabase
|
|
.from('company_settings').select('accounting_method, entity_type').eq('company_id', companyId).single()
|
|
|
|
const accountingMethod = settings?.accounting_method || 'accrual'
|
|
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
|
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
|
|
let journalEntryId: string | null = null
|
|
|
|
if (isRealInvoice) {
|
|
if (accountingMethod === 'accrual') {
|
|
const je = await createInvoicePaymentJournalEntry(
|
|
supabase, companyId, userId, invoice as Invoice, paymentDate, undefined, invoice.customer?.name
|
|
)
|
|
journalEntryId = je?.id ?? null
|
|
} else {
|
|
const je = await createInvoiceCashEntry(
|
|
supabase, companyId, userId, invoice as Invoice, paymentDate, entityType, invoice.customer?.name
|
|
)
|
|
journalEntryId = je?.id ?? null
|
|
}
|
|
}
|
|
|
|
const now = new Date().toISOString()
|
|
const { error: updateError } = await supabase
|
|
.from('invoices')
|
|
.update({ status: 'paid', paid_at: now, paid_amount: invoice.total })
|
|
.eq('id', invoiceId)
|
|
.eq('company_id', companyId)
|
|
|
|
if (updateError) return { error: 'Failed to update invoice status', status: 500 }
|
|
|
|
return { data: { status: 'paid', journal_entry_id: journalEntryId } }
|
|
}
|
|
|
|
async function commitSendInvoice(
|
|
supabase: SupabaseClient,
|
|
userId: string,
|
|
companyId: string,
|
|
params: Record<string, unknown>,
|
|
userEmail?: string
|
|
): Promise<ExecutorResult> {
|
|
const invoiceId = params.invoice_id as string
|
|
|
|
const emailService = getEmailService()
|
|
if (!emailService.isConfigured()) {
|
|
return { error: 'Email service not configured', status: 500 }
|
|
}
|
|
|
|
const { data: invoice, error: invoiceError } = await supabase
|
|
.from('invoices')
|
|
.select('*, customer:customers(*), items:invoice_items(*)')
|
|
.eq('id', invoiceId)
|
|
.eq('company_id', companyId)
|
|
.single()
|
|
|
|
if (invoiceError || !invoice) return { error: 'Invoice not found', status: 404 }
|
|
if (invoice.status === 'sent' || invoice.status === 'paid' || invoice.status === 'overdue') {
|
|
return { error: 'Invoice has already been sent', status: 409 }
|
|
}
|
|
|
|
const customer = invoice.customer as Customer
|
|
if (!customer.email) return { error: 'Customer has no email address', status: 400 }
|
|
|
|
const { data: company, error: companyError } = await supabase
|
|
.from('company_settings').select('*').eq('company_id', companyId).single()
|
|
|
|
if (companyError || !company) return { error: 'Company settings missing', status: 500 }
|
|
|
|
try {
|
|
await ensureInvoiceNumber(supabase, companyId, invoice as Invoice)
|
|
} catch (err) {
|
|
return { error: `Failed to assign invoice number: ${err instanceof Error ? err.message : 'unknown'}`, status: 500 }
|
|
}
|
|
|
|
const items = (invoice.items as InvoiceItem[]).sort(
|
|
(a: InvoiceItem, b: InvoiceItem) => a.sort_order - b.sort_order
|
|
)
|
|
|
|
let originalInvoiceNumber: string | undefined
|
|
if (invoice.credited_invoice_id) {
|
|
const { data: orig } = await supabase
|
|
.from('invoices').select('invoice_number').eq('id', invoice.credited_invoice_id).single()
|
|
if (orig) originalInvoiceNumber = orig.invoice_number
|
|
}
|
|
|
|
const pdfBuffer = await renderToBuffer(
|
|
InvoicePDF({
|
|
invoice: invoice as Invoice,
|
|
customer,
|
|
items,
|
|
company: company as CompanySettings,
|
|
originalInvoiceNumber,
|
|
})
|
|
)
|
|
|
|
const isCreditNote = !!invoice.credited_invoice_id
|
|
const docType = invoice.document_type || 'invoice'
|
|
let filename: string
|
|
if (isCreditNote) filename = `kreditfaktura-${invoice.invoice_number}.pdf`
|
|
else if (docType === 'proforma') filename = `proformafaktura-${invoice.invoice_number}.pdf`
|
|
else if (docType === 'delivery_note') filename = `foljesedel-${invoice.invoice_number}.pdf`
|
|
else filename = `faktura-${invoice.invoice_number}.pdf`
|
|
|
|
const ccAddress = company.email || userEmail
|
|
const emailData = { invoice: invoice as Invoice, customer, company: company as CompanySettings }
|
|
const result = await emailService.sendEmail({
|
|
to: customer.email,
|
|
cc: ccAddress,
|
|
subject: generateInvoiceEmailSubject(emailData),
|
|
html: generateInvoiceEmailHtml(emailData),
|
|
text: generateInvoiceEmailText(emailData),
|
|
replyTo: company.email || undefined,
|
|
fromName: company.trade_name || company.company_name,
|
|
attachments: [{ filename, content: pdfBuffer, contentType: 'application/pdf' }],
|
|
})
|
|
|
|
if (!result.success) return { error: `Failed to send email: ${result.error}`, status: 500 }
|
|
|
|
await supabase.from('invoices').update({ status: 'sent' }).eq('id', invoiceId).eq('company_id', companyId)
|
|
|
|
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
|
|
let createdJournalEntryId: string | undefined
|
|
if (isRealInvoice && (company.accounting_method === 'accrual' || !company.accounting_method)) {
|
|
try {
|
|
const je = await createInvoiceJournalEntry(
|
|
supabase, companyId, userId, invoice as Invoice, (company as CompanySettings).entity_type
|
|
)
|
|
if (je) {
|
|
createdJournalEntryId = je.id
|
|
await supabase.from('invoices').update({ journal_entry_id: je.id }).eq('id', invoiceId)
|
|
}
|
|
} catch (err) {
|
|
await recordSkippedInvoiceJournalEntry(invoiceId, companyId, userId, 'send_invoice', err)
|
|
}
|
|
}
|
|
|
|
if (isRealInvoice) {
|
|
try {
|
|
const pdfArrayBuffer = new Uint8Array(pdfBuffer).buffer as ArrayBuffer
|
|
await uploadDocument(supabase, userId, companyId, {
|
|
name: filename, buffer: pdfArrayBuffer, type: 'application/pdf',
|
|
}, { upload_source: 'system', journal_entry_id: createdJournalEntryId })
|
|
} catch { /* non-blocking */ }
|
|
}
|
|
|
|
await eventBus.emit({ type: 'invoice.sent', payload: { invoice: invoice as Invoice, userId, companyId } })
|
|
|
|
return { data: { message: `Invoice ${invoice.invoice_number} sent to ${customer.email}` } }
|
|
}
|
|
|
|
async function commitMarkInvoiceSent(
|
|
supabase: SupabaseClient,
|
|
userId: string,
|
|
companyId: string,
|
|
params: Record<string, unknown>
|
|
): Promise<ExecutorResult> {
|
|
const invoiceId = params.invoice_id as string
|
|
|
|
const { data: invoice, error: invoiceError } = await supabase
|
|
.from('invoices')
|
|
.select('*, customer:customers(*), items:invoice_items(*)')
|
|
.eq('id', invoiceId)
|
|
.eq('company_id', companyId)
|
|
.single()
|
|
|
|
if (invoiceError || !invoice) return { error: 'Invoice not found', status: 404 }
|
|
if (invoice.status !== 'draft') return { error: 'Only draft invoices can be marked as sent', status: 409 }
|
|
|
|
try {
|
|
await ensureInvoiceNumber(supabase, companyId, invoice as Invoice)
|
|
} catch (err) {
|
|
return { error: `Failed to assign invoice number: ${err instanceof Error ? err.message : 'unknown'}`, status: 500 }
|
|
}
|
|
|
|
const { error: updateError } = await supabase
|
|
.from('invoices').update({ status: 'sent' }).eq('id', invoiceId).eq('company_id', companyId)
|
|
|
|
if (updateError) return { error: 'Failed to update invoice status', status: 500 }
|
|
|
|
const { data: settings } = await supabase
|
|
.from('company_settings').select('accounting_method, entity_type').eq('company_id', companyId).single()
|
|
|
|
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
|
|
let journalEntryId: string | null = null
|
|
|
|
if (isRealInvoice && (settings?.accounting_method === 'accrual' || !settings?.accounting_method)) {
|
|
try {
|
|
const je = await createInvoiceJournalEntry(
|
|
supabase, companyId, userId, invoice as Invoice,
|
|
(settings?.entity_type as EntityType) || 'enskild_firma',
|
|
invoice.customer?.name
|
|
)
|
|
if (je) {
|
|
journalEntryId = je.id
|
|
await supabase.from('invoices').update({ journal_entry_id: je.id }).eq('id', invoiceId)
|
|
}
|
|
} catch (err) {
|
|
await recordSkippedInvoiceJournalEntry(invoiceId, companyId, userId, 'mark_invoice_sent', err)
|
|
}
|
|
}
|
|
|
|
return { data: { status: 'sent', journal_entry_id: journalEntryId } }
|
|
}
|
|
|
|
async function commitMatchTransactionInvoice(
|
|
supabase: SupabaseClient,
|
|
userId: string,
|
|
companyId: string,
|
|
params: Record<string, unknown>
|
|
): Promise<ExecutorResult> {
|
|
const transactionId = params.transaction_id as string
|
|
const invoiceId = params.invoice_id as string
|
|
|
|
const { data: transaction, error: txError } = await supabase
|
|
.from('transactions').select('*').eq('id', transactionId).eq('company_id', companyId).single()
|
|
|
|
if (txError || !transaction) return { error: 'Transaction not found', status: 404 }
|
|
if (transaction.amount <= 0) return { error: 'Only income transactions can be matched', status: 400 }
|
|
if (transaction.invoice_id) return { error: 'Transaction already linked to an invoice', status: 409 }
|
|
|
|
const { data: invoice, error: invError } = await supabase
|
|
.from('invoices')
|
|
.select('*, customer:customers(*), items:invoice_items(*)')
|
|
.eq('id', invoiceId)
|
|
.eq('company_id', companyId)
|
|
.single()
|
|
|
|
if (invError || !invoice) return { error: 'Invoice not found', status: 404 }
|
|
if (!['sent', 'overdue', 'partially_paid'].includes(invoice.status)) {
|
|
return { error: 'Invoice is not in a matchable state', status: 409 }
|
|
}
|
|
|
|
if (transaction.journal_entry_id) {
|
|
await reverseEntry(supabase, companyId, userId, transaction.journal_entry_id)
|
|
await supabase.from('transactions').update({ journal_entry_id: null }).eq('id', transactionId)
|
|
}
|
|
|
|
const now = new Date().toISOString()
|
|
const paidAmount = transaction.amount
|
|
const newPaidAmount = Math.round(((invoice.paid_amount || 0) + paidAmount) * 100) / 100
|
|
const currentRemaining = invoice.remaining_amount ?? (invoice.total - (invoice.paid_amount || 0))
|
|
const newRemaining = Math.max(0, Math.round((currentRemaining - paidAmount) * 100) / 100)
|
|
const isFullyPaid = newRemaining <= 0
|
|
const newStatus = isFullyPaid ? 'paid' : 'partially_paid'
|
|
|
|
const { data: settings } = await supabase
|
|
.from('company_settings').select('accounting_method, entity_type').eq('company_id', companyId).single()
|
|
|
|
const accountingMethod = settings?.accounting_method || 'accrual'
|
|
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
|
|
|
let journalEntryId: string | null = null
|
|
try {
|
|
if (accountingMethod === 'cash' && isFullyPaid) {
|
|
const je = await createInvoiceCashEntry(
|
|
supabase, companyId, userId, invoice as Invoice, transaction.date, entityType, invoice.customer?.name
|
|
)
|
|
journalEntryId = je?.id ?? null
|
|
} else {
|
|
const je = await createInvoicePaymentJournalEntry(
|
|
supabase, companyId, userId, invoice as Invoice, transaction.date, undefined, invoice.customer?.name, paidAmount
|
|
)
|
|
journalEntryId = je?.id ?? null
|
|
}
|
|
} catch (err) {
|
|
if (isBookkeepingError(err)) throw err
|
|
log.error('Failed to create match journal entry:', err)
|
|
}
|
|
|
|
const { data: updatedRows, error: updateInvError } = await supabase
|
|
.from('invoices')
|
|
.update({
|
|
status: newStatus,
|
|
paid_at: isFullyPaid ? now : null,
|
|
paid_amount: newPaidAmount,
|
|
remaining_amount: newRemaining,
|
|
})
|
|
.eq('id', invoiceId)
|
|
.in('status', ['sent', 'overdue', 'partially_paid'])
|
|
.select('id')
|
|
|
|
if (updateInvError) return { error: 'Failed to update invoice status', status: 500 }
|
|
if (!updatedRows || updatedRows.length === 0) {
|
|
return { error: 'Invoice has already been fully paid or is no longer matchable', status: 409 }
|
|
}
|
|
|
|
const paymentNotes = (accountingMethod === 'cash' && !isFullyPaid)
|
|
? 'Kontantmetoden: intäkt bokförs vid slutbetalning' : null
|
|
|
|
await supabase.from('invoice_payments').insert({
|
|
user_id: userId,
|
|
company_id: companyId,
|
|
invoice_id: invoiceId,
|
|
payment_date: transaction.date,
|
|
amount: paidAmount,
|
|
currency: invoice.currency,
|
|
exchange_rate: invoice.exchange_rate,
|
|
journal_entry_id: journalEntryId,
|
|
transaction_id: transactionId,
|
|
notes: paymentNotes,
|
|
})
|
|
|
|
await supabase
|
|
.from('transactions')
|
|
.update({
|
|
invoice_id: invoiceId,
|
|
potential_invoice_id: null,
|
|
journal_entry_id: journalEntryId,
|
|
is_business: true,
|
|
category: 'income_services',
|
|
})
|
|
.eq('id', transactionId)
|
|
|
|
try {
|
|
await eventBus.emit({
|
|
type: 'invoice.match_confirmed',
|
|
payload: { invoice: invoice as Invoice, transaction: transaction as Transaction, userId, companyId },
|
|
})
|
|
} catch { /* non-critical */ }
|
|
|
|
return { data: { invoice_status: newStatus, paid_amount: newPaidAmount, journal_entry_id: journalEntryId } }
|
|
}
|
|
|
|
// ── Stream 1 Phase 1 + follow-up executors ───────────────────────
|
|
|
|
async function commitClosePeriod(
|
|
supabase: SupabaseClient,
|
|
userId: string,
|
|
companyId: string,
|
|
params: Record<string, unknown>
|
|
): Promise<ExecutorResult> {
|
|
const id = params.fiscal_period_id as string
|
|
if (!id) return { error: 'fiscal_period_id is required', status: 400 }
|
|
try {
|
|
const period = await closePeriod(supabase, companyId, userId, id)
|
|
return { data: { period_id: period.id, closed_at: period.closed_at } }
|
|
} catch (err) {
|
|
return { error: err instanceof Error ? err.message : 'Close failed', status: 400 }
|
|
}
|
|
}
|
|
|
|
async function commitLockPeriod(
|
|
supabase: SupabaseClient,
|
|
userId: string,
|
|
companyId: string,
|
|
params: Record<string, unknown>
|
|
): Promise<ExecutorResult> {
|
|
const id = params.fiscal_period_id as string
|
|
if (!id) return { error: 'fiscal_period_id is required', status: 400 }
|
|
try {
|
|
const period = await lockPeriod(supabase, companyId, userId, id)
|
|
return { data: { period_id: period.id, locked_at: period.locked_at } }
|
|
} catch (err) {
|
|
return { error: err instanceof Error ? err.message : 'Lock failed', status: 400 }
|
|
}
|
|
}
|
|
|
|
async function commitUnlockPeriod(
|
|
supabase: SupabaseClient,
|
|
userId: string,
|
|
companyId: string,
|
|
params: Record<string, unknown>
|
|
): Promise<ExecutorResult> {
|
|
const id = params.fiscal_period_id as string
|
|
if (!id) return { error: 'fiscal_period_id is required', status: 400 }
|
|
try {
|
|
const period = await unlockPeriod(supabase, companyId, userId, id)
|
|
return { data: { period_id: period.id, locked_at: period.locked_at } }
|
|
} catch (err) {
|
|
return { error: err instanceof Error ? err.message : 'Unlock failed', status: 400 }
|
|
}
|
|
}
|
|
|
|
async function commitUncategorizeTransaction(
|
|
supabase: SupabaseClient,
|
|
userId: string,
|
|
companyId: string,
|
|
params: Record<string, unknown>
|
|
): Promise<ExecutorResult> {
|
|
const txId = params.transaction_id as string
|
|
const journalEntryId = params.journal_entry_id as string
|
|
if (!txId || !journalEntryId) return { error: 'transaction_id and journal_entry_id are required', status: 400 }
|
|
|
|
try {
|
|
await reverseEntry(supabase, companyId, userId, journalEntryId)
|
|
} catch (err) {
|
|
if (isBookkeepingError(err)) throw err
|
|
return { error: err instanceof Error ? err.message : 'Reversal failed', status: 500 }
|
|
}
|
|
|
|
const { error: updateError } = await supabase
|
|
.from('transactions')
|
|
.update({ is_business: null, category: null, journal_entry_id: null })
|
|
.eq('id', txId)
|
|
.eq('company_id', companyId)
|
|
|
|
if (updateError) return { error: 'Failed to reset transaction', status: 500 }
|
|
|
|
return { data: { transaction_id: txId, reversed_journal_entry_id: journalEntryId } }
|
|
}
|
|
|
|
async function commitAttachDocumentToTransaction(
|
|
supabase: SupabaseClient,
|
|
userId: string,
|
|
companyId: string,
|
|
params: Record<string, unknown>
|
|
): Promise<ExecutorResult> {
|
|
const txId = params.transaction_id as string
|
|
const documentId = params.document_id as string
|
|
if (!txId || !documentId) {
|
|
return { error: 'transaction_id and document_id are required', status: 400 }
|
|
}
|
|
|
|
const { data: tx, error: txError } = await supabase
|
|
.from('transactions')
|
|
.select('id, document_id, journal_entry_id')
|
|
.eq('id', txId)
|
|
.eq('company_id', companyId)
|
|
.maybeSingle()
|
|
if (txError || !tx) return { error: 'Transaction not found', status: 404 }
|
|
|
|
const previousDocumentId = (tx.document_id as string | null) ?? null
|
|
|
|
// Pre-check: if the tx already has a doc and that doc is räkenskapsinformation,
|
|
// mirror the DELETE-route 409 instead of letting the DB trigger raise a
|
|
// raw check_violation. Same compliance message in both places.
|
|
if (tx.document_id && tx.document_id !== documentId) {
|
|
const { data: existing } = await supabase
|
|
.from('document_attachments')
|
|
.select('journal_entry_id')
|
|
.eq('id', tx.document_id)
|
|
.eq('company_id', companyId)
|
|
.maybeSingle()
|
|
if (existing?.journal_entry_id) {
|
|
return {
|
|
error:
|
|
'Bilagan är kopplad till en bokförd verifikation och kan inte ersättas. Storno verifikationen först.',
|
|
status: 409,
|
|
}
|
|
}
|
|
}
|
|
|
|
const { data: doc, error: docError } = await supabase
|
|
.from('document_attachments')
|
|
.select('id')
|
|
.eq('id', documentId)
|
|
.eq('company_id', companyId)
|
|
.maybeSingle()
|
|
if (docError || !doc) return { error: 'Document not found', status: 404 }
|
|
|
|
// Race-free read of journal_entry_id: use UPDATE ... RETURNING so the value
|
|
// we propagate against reflects any concurrent categorize that committed
|
|
// before our UPDATE acquired the row lock. Reading the post-update state
|
|
// (rather than the pre-staging state) is what makes the
|
|
// attach-then-categorize and categorize-then-attach orderings produce the
|
|
// same final state — both end with document_attachments.journal_entry_id
|
|
// set to the tx's journal_entry_id. (BFL 5 kap 6 § verifikation underlag.)
|
|
const { data: postUpdate, error: updateError } = await supabase
|
|
.from('transactions')
|
|
.update({ document_id: documentId })
|
|
.eq('id', txId)
|
|
.eq('company_id', companyId)
|
|
.select('journal_entry_id')
|
|
.maybeSingle()
|
|
|
|
if (updateError) {
|
|
// The DB-level immutability trigger raises P0001 with a stable
|
|
// BFL_DOCUMENT_IMMUTABILITY: prefix when the previous doc is already
|
|
// räkenskapsinformation. Match on the prefix (not the generic SQLSTATE)
|
|
// so unrelated future exceptions don't get translated.
|
|
const errMsg = (updateError as { message?: string }).message ?? ''
|
|
if (errMsg.includes('BFL_DOCUMENT_IMMUTABILITY')) {
|
|
return {
|
|
error:
|
|
'Bilagan är kopplad till en bokförd verifikation och kan inte ersättas. Storno verifikationen först.',
|
|
status: 409,
|
|
}
|
|
}
|
|
return { error: 'Failed to attach document', status: 500 }
|
|
}
|
|
if (!postUpdate) return { error: 'Transaction not found', status: 404 }
|
|
|
|
const journalEntryId = postUpdate.journal_entry_id as string | null
|
|
if (journalEntryId) {
|
|
const { error: linkErr } = await supabase
|
|
.from('document_attachments')
|
|
.update({ journal_entry_id: journalEntryId })
|
|
.eq('id', documentId)
|
|
.eq('company_id', companyId)
|
|
if (linkErr) {
|
|
// Surface the propagation failure rather than logging-and-continuing.
|
|
// BFL 5 kap 6 § requires the verifikation to reference its underlag, so
|
|
// a "succeeded" attach that left document_attachments.journal_entry_id
|
|
// null would be a silent compliance gap. Failing here marks the op
|
|
// failed; a retry is idempotent (same documentId on tx, same propagate
|
|
// target) and will replay the document_attachments UPDATE.
|
|
console.error('[commitAttach] Failed to propagate to journal entry:', linkErr)
|
|
return {
|
|
error:
|
|
'Bilagan kopplades till transaktionen men kunde inte länkas till verifikationen. Försök igen — operationen är idempotent.',
|
|
status: 500,
|
|
}
|
|
}
|
|
}
|
|
|
|
// Rättelse audit trail (BFL 5 kap 5 §): if we replaced a non-null doc, log
|
|
// the swap to processing_history so the original is traceable. Best-effort —
|
|
// a logging failure must not roll back the (compliant) attach.
|
|
if (previousDocumentId && previousDocumentId !== documentId) {
|
|
try {
|
|
await appendProcessingHistory({
|
|
companyId,
|
|
correlationId: txId,
|
|
aggregateType: 'BankTransaction',
|
|
aggregateId: txId,
|
|
eventType: 'TransactionDocumentReplaced',
|
|
payload: {
|
|
transaction_id: txId,
|
|
previous_document_id: previousDocumentId,
|
|
new_document_id: documentId,
|
|
journal_entry_id: journalEntryId,
|
|
},
|
|
actor: { type: 'user', id: userId },
|
|
occurredAt: new Date(),
|
|
})
|
|
} catch (logErr) {
|
|
console.error('[commitAttach] Failed to append rättelse event:', logErr)
|
|
}
|
|
}
|
|
|
|
return {
|
|
data: {
|
|
transaction_id: txId,
|
|
document_id: documentId,
|
|
previous_document_id: previousDocumentId,
|
|
journal_entry_id: journalEntryId,
|
|
},
|
|
}
|
|
}
|
|
|
|
async function commitRunYearEnd(
|
|
supabase: SupabaseClient,
|
|
userId: string,
|
|
companyId: string,
|
|
params: Record<string, unknown>
|
|
): Promise<ExecutorResult> {
|
|
const id = params.fiscal_period_id as string
|
|
if (!id) return { error: 'fiscal_period_id is required', status: 400 }
|
|
|
|
try {
|
|
const result = await executeYearEndClosing(supabase, companyId, userId, id)
|
|
return {
|
|
data: {
|
|
closing_entry_id: result.closingEntry?.id ?? null,
|
|
next_period_id: result.nextPeriod?.id ?? null,
|
|
opening_balance_entry_id: result.openingBalanceEntry?.id ?? null,
|
|
},
|
|
}
|
|
} catch (err) {
|
|
if (isBookkeepingError(err)) throw err
|
|
return { error: err instanceof Error ? err.message : 'Year-end failed', status: 400 }
|
|
}
|
|
}
|
|
|
|
async function commitSetOpeningBalances(
|
|
supabase: SupabaseClient,
|
|
userId: string,
|
|
companyId: string,
|
|
params: Record<string, unknown>
|
|
): Promise<ExecutorResult> {
|
|
const closedId = params.closed_period_id as string
|
|
const nextId = params.next_period_id as string
|
|
if (!closedId || !nextId) return { error: 'closed_period_id and next_period_id are required', status: 400 }
|
|
|
|
try {
|
|
const entry = await generateOpeningBalances(supabase, companyId, userId, closedId, nextId)
|
|
return { data: { opening_balance_entry_id: entry.id } }
|
|
} catch (err) {
|
|
if (isBookkeepingError(err)) throw err
|
|
return { error: err instanceof Error ? err.message : 'Opening balances failed', status: 400 }
|
|
}
|
|
}
|
|
|
|
async function commitRunCurrencyRevaluation(
|
|
supabase: SupabaseClient,
|
|
userId: string,
|
|
companyId: string,
|
|
params: Record<string, unknown>
|
|
): Promise<ExecutorResult> {
|
|
const id = params.fiscal_period_id as string
|
|
const closingDate = params.closing_date as string
|
|
if (!id || !closingDate) return { error: 'fiscal_period_id and closing_date are required', status: 400 }
|
|
|
|
try {
|
|
const result = await executeCurrencyRevaluation(supabase, companyId, closingDate, id, userId)
|
|
return {
|
|
data: result
|
|
? { entry_id: result.entry.id, items_revalued: result.preview.items.length }
|
|
: { entry_id: null, items_revalued: 0, message: 'No foreign-currency items to revalue' },
|
|
}
|
|
} catch (err) {
|
|
if (isBookkeepingError(err)) throw err
|
|
return { error: err instanceof Error ? err.message : 'Revaluation failed', status: 400 }
|
|
}
|
|
}
|
|
|
|
async function commitExplainVoucherGap(
|
|
supabase: SupabaseClient,
|
|
userId: string,
|
|
companyId: string,
|
|
params: Record<string, unknown>
|
|
): Promise<ExecutorResult> {
|
|
const fiscalPeriodId = params.fiscal_period_id as string
|
|
const voucherSeries = params.voucher_series as string
|
|
const gapStart = Number(params.gap_start)
|
|
const gapEnd = Number(params.gap_end)
|
|
const explanation = params.explanation as string
|
|
if (!fiscalPeriodId || !voucherSeries || !gapStart || !gapEnd || !explanation?.trim()) {
|
|
return { error: 'fiscal_period_id, voucher_series, gap_start, gap_end, and explanation are required', status: 400 }
|
|
}
|
|
|
|
const { data, error } = await supabase
|
|
.from('voucher_gap_explanations')
|
|
.insert({
|
|
user_id: userId,
|
|
company_id: companyId,
|
|
fiscal_period_id: fiscalPeriodId,
|
|
voucher_series: voucherSeries,
|
|
gap_start: gapStart,
|
|
gap_end: gapEnd,
|
|
explanation: explanation.trim(),
|
|
})
|
|
.select('id')
|
|
.single()
|
|
|
|
if (error) return { error: error.message, status: 500 }
|
|
return { data: { explanation_id: data.id } }
|
|
}
|
|
|
|
async function commitApproveSupplierInvoice(
|
|
supabase: SupabaseClient,
|
|
userId: string,
|
|
companyId: string,
|
|
params: Record<string, unknown>
|
|
): Promise<ExecutorResult> {
|
|
const id = params.supplier_invoice_id as string
|
|
if (!id) return { error: 'supplier_invoice_id is required', status: 400 }
|
|
|
|
const { data: invoice } = await supabase
|
|
.from('supplier_invoices').select('*').eq('id', id).eq('company_id', companyId).single()
|
|
|
|
if (!invoice) return { error: 'Supplier invoice not found', status: 404 }
|
|
if (invoice.status !== 'registered') {
|
|
return { error: 'Kan bara godkänna registrerade fakturor', status: 400 }
|
|
}
|
|
|
|
const { data, error } = await supabase
|
|
.from('supplier_invoices')
|
|
.update({ status: 'approved' })
|
|
.eq('id', id)
|
|
.eq('company_id', companyId)
|
|
.select()
|
|
.single()
|
|
|
|
if (error) return { error: error.message, status: 500 }
|
|
|
|
try {
|
|
await eventBus.emit({
|
|
type: 'supplier_invoice.approved',
|
|
payload: { supplierInvoice: data, companyId, userId },
|
|
})
|
|
} catch { /* non-blocking */ }
|
|
|
|
return { data: { supplier_invoice_id: id, status: 'approved' } }
|
|
}
|
|
|
|
async function commitCreditSupplierInvoice(
|
|
supabase: SupabaseClient,
|
|
userId: string,
|
|
companyId: string,
|
|
params: Record<string, unknown>
|
|
): Promise<ExecutorResult> {
|
|
const id = params.supplier_invoice_id as string
|
|
if (!id) return { error: 'supplier_invoice_id is required', status: 400 }
|
|
|
|
const { data: original, error: fetchError } = await supabase
|
|
.from('supplier_invoices')
|
|
.select('*, supplier:suppliers(*), items:supplier_invoice_items(*)')
|
|
.eq('id', id)
|
|
.eq('company_id', companyId)
|
|
.single()
|
|
|
|
if (fetchError || !original) return { error: 'Supplier invoice not found', status: 404 }
|
|
if (original.status === 'credited') return { error: 'Fakturan har redan krediterats', status: 409 }
|
|
|
|
const { data: arrivalNum } = await supabase.rpc('get_next_arrival_number', { p_company_id: companyId })
|
|
|
|
const { data: creditNote, error: creditError } = await supabase
|
|
.from('supplier_invoices')
|
|
.insert({
|
|
user_id: userId,
|
|
company_id: companyId,
|
|
supplier_id: original.supplier_id,
|
|
arrival_number: arrivalNum,
|
|
supplier_invoice_number: `KREDIT-${original.supplier_invoice_number}`,
|
|
invoice_date: new Date().toISOString().split('T')[0],
|
|
due_date: new Date().toISOString().split('T')[0],
|
|
status: 'registered',
|
|
currency: original.currency,
|
|
exchange_rate: original.exchange_rate,
|
|
vat_treatment: original.vat_treatment,
|
|
reverse_charge: original.reverse_charge,
|
|
subtotal: original.subtotal,
|
|
subtotal_sek: original.subtotal_sek,
|
|
vat_amount: original.vat_amount,
|
|
vat_amount_sek: original.vat_amount_sek,
|
|
total: original.total,
|
|
total_sek: original.total_sek,
|
|
remaining_amount: 0,
|
|
is_credit_note: true,
|
|
credited_invoice_id: id,
|
|
})
|
|
.select()
|
|
.single()
|
|
|
|
if (creditError || !creditNote) return { error: creditError?.message ?? 'Failed to create credit note', status: 500 }
|
|
|
|
const creditItems = (original.items ?? []).map((item: Record<string, unknown>) => ({
|
|
supplier_invoice_id: creditNote.id,
|
|
sort_order: item.sort_order,
|
|
description: item.description,
|
|
quantity: item.quantity,
|
|
unit: item.unit,
|
|
unit_price: item.unit_price,
|
|
line_total: item.line_total,
|
|
account_number: item.account_number,
|
|
vat_code: item.vat_code,
|
|
vat_rate: item.vat_rate,
|
|
vat_amount: item.vat_amount,
|
|
}))
|
|
await supabase.from('supplier_invoice_items').insert(creditItems)
|
|
|
|
const { data: settings } = await supabase
|
|
.from('company_settings').select('accounting_method').eq('company_id', companyId).single()
|
|
const accountingMethod = settings?.accounting_method || 'accrual'
|
|
|
|
let journalEntryId: string | null = null
|
|
if (accountingMethod === 'accrual') {
|
|
try {
|
|
const je = await createSupplierCreditNoteEntry(
|
|
supabase,
|
|
companyId,
|
|
userId,
|
|
creditNote,
|
|
creditItems as never,
|
|
original.supplier?.supplier_type || 'swedish_business',
|
|
original.supplier?.name
|
|
)
|
|
if (je) {
|
|
journalEntryId = je.id
|
|
await supabase
|
|
.from('supplier_invoices')
|
|
.update({ registration_journal_entry_id: je.id })
|
|
.eq('id', creditNote.id)
|
|
}
|
|
} catch (err) {
|
|
await supabase.from('supplier_invoices').delete().eq('id', creditNote.id).eq('company_id', companyId)
|
|
if (isBookkeepingError(err)) throw err
|
|
return { error: err instanceof Error ? err.message : 'Failed to book credit note', status: 500 }
|
|
}
|
|
}
|
|
|
|
const newRemaining = Math.max(0, original.remaining_amount - original.total)
|
|
const newStatus = newRemaining <= 0 ? 'credited' : original.status
|
|
|
|
await supabase
|
|
.from('supplier_invoices')
|
|
.update({ status: newStatus, remaining_amount: newRemaining })
|
|
.eq('id', id)
|
|
|
|
try {
|
|
await eventBus.emit({
|
|
type: 'supplier_invoice.credited',
|
|
payload: { supplierInvoice: original, creditNote, companyId, userId },
|
|
})
|
|
} catch { /* non-blocking */ }
|
|
|
|
return { data: { credit_note_id: creditNote.id, journal_entry_id: journalEntryId } }
|
|
}
|
|
|
|
async function commitCreditInvoice(
|
|
supabase: SupabaseClient,
|
|
userId: string,
|
|
companyId: string,
|
|
params: Record<string, unknown>
|
|
): Promise<ExecutorResult> {
|
|
const id = params.invoice_id as string
|
|
const reason = params.reason as string | undefined
|
|
if (!id) return { error: 'invoice_id is required', status: 400 }
|
|
|
|
const { data: original, error: fetchError } = await supabase
|
|
.from('invoices')
|
|
.select('*, items:invoice_items(*)')
|
|
.eq('id', id)
|
|
.eq('company_id', companyId)
|
|
.single()
|
|
|
|
if (fetchError || !original) return { error: 'Original invoice not found', status: 404 }
|
|
if (original.document_type && original.document_type !== 'invoice') {
|
|
return { error: 'Credit notes can only be created from standard invoices', status: 400 }
|
|
}
|
|
if (original.status === 'credited') return { error: 'Invoice has already been credited', status: 409 }
|
|
if (!['sent', 'paid', 'overdue'].includes(original.status)) {
|
|
return { error: 'Only sent, paid, or overdue invoices can be credited', status: 400 }
|
|
}
|
|
|
|
const today = new Date().toISOString().split('T')[0]
|
|
const creditNoteNumber = `KR-${original.invoice_number}`
|
|
|
|
const { data: creditNote, error: creditNoteError } = await supabase
|
|
.from('invoices')
|
|
.insert({
|
|
user_id: userId,
|
|
company_id: companyId,
|
|
customer_id: original.customer_id,
|
|
invoice_number: creditNoteNumber,
|
|
invoice_date: today,
|
|
due_date: today,
|
|
delivery_date: original.delivery_date ?? null,
|
|
currency: original.currency,
|
|
exchange_rate: original.exchange_rate,
|
|
exchange_rate_date: original.exchange_rate_date,
|
|
subtotal: -Math.abs(original.subtotal),
|
|
subtotal_sek: original.subtotal_sek != null ? -Math.abs(original.subtotal_sek) : null,
|
|
vat_amount: -Math.abs(original.vat_amount),
|
|
vat_amount_sek: original.vat_amount_sek != null ? -Math.abs(original.vat_amount_sek) : null,
|
|
total: -Math.abs(original.total),
|
|
total_sek: original.total_sek != null ? -Math.abs(original.total_sek) : null,
|
|
vat_treatment: original.vat_treatment,
|
|
vat_rate: original.vat_rate,
|
|
moms_ruta: original.moms_ruta,
|
|
reverse_charge_text: original.reverse_charge_text,
|
|
your_reference: original.your_reference,
|
|
our_reference: original.our_reference,
|
|
notes: reason || `Krediterar faktura ${original.invoice_number}`,
|
|
credited_invoice_id: id,
|
|
status: 'sent',
|
|
})
|
|
.select()
|
|
.single()
|
|
|
|
if (creditNoteError || !creditNote) {
|
|
return { error: creditNoteError?.message ?? 'Failed to create credit note', status: 500 }
|
|
}
|
|
|
|
const creditItems = (original.items || []).map((item: {
|
|
sort_order: number
|
|
description: string
|
|
quantity: number
|
|
unit: string
|
|
unit_price: number
|
|
line_total: number
|
|
vat_rate?: number
|
|
vat_amount?: number
|
|
}) => ({
|
|
invoice_id: creditNote.id,
|
|
sort_order: item.sort_order,
|
|
description: item.description,
|
|
quantity: -Math.abs(item.quantity),
|
|
unit: item.unit,
|
|
unit_price: item.unit_price,
|
|
line_total: -Math.abs(item.line_total),
|
|
vat_rate: item.vat_rate ?? 0,
|
|
vat_amount: -(item.vat_amount ? Math.abs(item.vat_amount) : 0),
|
|
}))
|
|
|
|
const { error: itemsError } = await supabase
|
|
.from('invoice_items')
|
|
.insert(creditItems)
|
|
|
|
if (itemsError) {
|
|
await supabase.from('invoices').delete().eq('id', creditNote.id)
|
|
return { error: itemsError.message, status: 500 }
|
|
}
|
|
|
|
await supabase.from('invoices').update({ status: 'credited' }).eq('id', id)
|
|
|
|
const { data: completeCreditNote } = await supabase
|
|
.from('invoices')
|
|
.select('*, customer:customers(*), items:invoice_items(*)')
|
|
.eq('id', creditNote.id)
|
|
.single()
|
|
|
|
const { data: settings } = await supabase
|
|
.from('company_settings')
|
|
.select('entity_type, accounting_method')
|
|
.eq('company_id', companyId)
|
|
.single()
|
|
|
|
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
|
const accountingMethod = (settings?.accounting_method as AccountingMethod) || 'accrual'
|
|
|
|
// Resolve the original verifikation reference so the credit-note JE can
|
|
// point back to the corrected entry per BFL 5 kap. 5 §. We tolerate
|
|
// missing-JE on the original (legacy data) — the description simply omits
|
|
// the voucher reference and keeps the invoice-number reference.
|
|
let originalVoucherRef: string | undefined
|
|
if (original.journal_entry_id) {
|
|
const { data: origJe } = await supabase
|
|
.from('journal_entries')
|
|
.select('voucher_series, voucher_number')
|
|
.eq('id', original.journal_entry_id)
|
|
.eq('company_id', companyId)
|
|
.maybeSingle()
|
|
if (origJe?.voucher_series && origJe?.voucher_number != null) {
|
|
originalVoucherRef = `${origJe.voucher_series}-${origJe.voucher_number}`
|
|
}
|
|
}
|
|
|
|
let journalEntryId: string | null = null
|
|
if (completeCreditNote && accountingMethod === 'accrual') {
|
|
try {
|
|
const journalEntry = await createCreditNoteJournalEntry(
|
|
supabase,
|
|
companyId,
|
|
userId,
|
|
completeCreditNote as Invoice,
|
|
entityType,
|
|
completeCreditNote.customer?.name,
|
|
originalVoucherRef
|
|
)
|
|
if (journalEntry) {
|
|
journalEntryId = journalEntry.id
|
|
await supabase
|
|
.from('invoices')
|
|
.update({ journal_entry_id: journalEntry.id })
|
|
.eq('id', creditNote.id)
|
|
}
|
|
} catch (err) {
|
|
if (isBookkeepingError(err)) throw err
|
|
log.error('Failed to create credit note journal entry:', err)
|
|
}
|
|
|
|
try {
|
|
await eventBus.emit({
|
|
type: 'credit_note.created',
|
|
payload: { creditNote: completeCreditNote as CreditNote, companyId, userId },
|
|
})
|
|
} catch { /* non-blocking */ }
|
|
}
|
|
|
|
return { data: { credit_note_id: creditNote.id, journal_entry_id: journalEntryId } }
|
|
}
|
|
|
|
async function commitConvertInvoice(
|
|
supabase: SupabaseClient,
|
|
userId: string,
|
|
companyId: string,
|
|
params: Record<string, unknown>
|
|
): Promise<ExecutorResult> {
|
|
const id = params.invoice_id as string
|
|
if (!id) return { error: 'invoice_id is required', status: 400 }
|
|
|
|
const { data: proforma, error: proformaError } = await supabase
|
|
.from('invoices').select('*, items:invoice_items(*)').eq('id', id).eq('company_id', companyId).single()
|
|
|
|
if (proformaError || !proforma) return { error: 'Proformafakturan hittades inte', status: 404 }
|
|
if (proforma.document_type !== 'proforma') {
|
|
return { error: 'Endast proformafakturor kan konverteras', status: 400 }
|
|
}
|
|
if (proforma.status === 'cancelled') {
|
|
return { error: 'Denna proformafaktura har redan makuleras', status: 409 }
|
|
}
|
|
|
|
const { data: invoice, error: invoiceError } = await supabase
|
|
.from('invoices')
|
|
.insert({
|
|
user_id: userId,
|
|
company_id: companyId,
|
|
customer_id: proforma.customer_id,
|
|
invoice_number: null,
|
|
invoice_date: new Date().toISOString().split('T')[0],
|
|
due_date: proforma.due_date,
|
|
currency: proforma.currency,
|
|
exchange_rate: proforma.exchange_rate,
|
|
exchange_rate_date: proforma.exchange_rate_date,
|
|
subtotal: proforma.subtotal,
|
|
subtotal_sek: proforma.subtotal_sek,
|
|
vat_amount: proforma.vat_amount,
|
|
vat_amount_sek: proforma.vat_amount_sek,
|
|
total: proforma.total,
|
|
total_sek: proforma.total_sek,
|
|
vat_treatment: proforma.vat_treatment,
|
|
vat_rate: proforma.vat_rate,
|
|
moms_ruta: proforma.moms_ruta,
|
|
reverse_charge_text: proforma.reverse_charge_text,
|
|
your_reference: proforma.your_reference,
|
|
our_reference: proforma.our_reference,
|
|
notes: proforma.notes,
|
|
document_type: 'invoice',
|
|
converted_from_id: id,
|
|
})
|
|
.select()
|
|
.single()
|
|
|
|
if (invoiceError) return { error: invoiceError.message, status: 500 }
|
|
|
|
try {
|
|
await ensureInvoiceNumber(supabase, companyId, invoice as Invoice)
|
|
} catch (err) {
|
|
await supabase.from('invoices').delete().eq('id', invoice.id)
|
|
return { error: err instanceof Error ? err.message : 'Failed to assign invoice number', status: 500 }
|
|
}
|
|
|
|
const items = (proforma.items ?? []).map((item: Record<string, unknown>) => ({
|
|
invoice_id: invoice.id,
|
|
sort_order: item.sort_order,
|
|
description: item.description,
|
|
quantity: item.quantity,
|
|
unit: item.unit,
|
|
unit_price: item.unit_price,
|
|
line_total: item.line_total,
|
|
}))
|
|
|
|
if (items.length > 0) {
|
|
const { error: itemsError } = await supabase.from('invoice_items').insert(items)
|
|
if (itemsError) {
|
|
await supabase.from('invoices').delete().eq('id', invoice.id)
|
|
return { error: itemsError.message, status: 500 }
|
|
}
|
|
}
|
|
|
|
await supabase.from('invoices').update({ status: 'cancelled' }).eq('id', id)
|
|
|
|
return { data: { invoice_id: invoice.id, invoice_number: invoice.invoice_number } }
|
|
}
|
|
|
|
async function commitImportSie(
|
|
supabase: SupabaseClient,
|
|
userId: string,
|
|
companyId: string,
|
|
params: Record<string, unknown>
|
|
): Promise<ExecutorResult> {
|
|
const fileContent = params.file_content as string
|
|
const filename = params.filename as string
|
|
const mappings = params.mappings as AccountMapping[] | undefined
|
|
const createFiscalPeriod = Boolean(params.create_fiscal_period)
|
|
const importOpeningBalances = Boolean(params.import_opening_balances)
|
|
const importTransactions = Boolean(params.import_transactions)
|
|
const voucherSeries = params.voucher_series as string | undefined
|
|
|
|
if (!fileContent || !filename || !Array.isArray(mappings)) {
|
|
return { error: 'file_content, filename, and mappings are required', status: 400 }
|
|
}
|
|
|
|
let parsed
|
|
try {
|
|
parsed = parseSIEFile(fileContent)
|
|
} catch (err) {
|
|
return { error: err instanceof Error ? err.message : 'Failed to parse SIE file', status: 400 }
|
|
}
|
|
|
|
try {
|
|
const result = await executeSIEImport(supabase, companyId, userId, parsed, mappings, {
|
|
filename,
|
|
fileContent,
|
|
createFiscalPeriod,
|
|
importOpeningBalances,
|
|
importTransactions,
|
|
voucherSeries,
|
|
})
|
|
|
|
if (!result.success) {
|
|
return { error: result.errors.join('; ') || 'SIE import failed', status: 400 }
|
|
}
|
|
|
|
return {
|
|
data: {
|
|
import_id: result.importId,
|
|
fiscal_period_id: result.fiscalPeriodId,
|
|
opening_balance_entry_id: result.openingBalanceEntryId,
|
|
journal_entries_created: result.journalEntriesCreated,
|
|
warnings: result.warnings,
|
|
},
|
|
}
|
|
} catch (err) {
|
|
if (isBookkeepingError(err)) throw err
|
|
return { error: err instanceof Error ? err.message : 'SIE import failed', status: 500 }
|
|
}
|
|
}
|
|
|
|
// ── Public dispatcher ────────────────────────────────────────────
|
|
|
|
/**
|
|
* Execute a pending_operation by type, update its status row, and return a
|
|
* normalized CommitResult.
|
|
*
|
|
* Used by both the human-approval route and the auto-commit path. Status row
|
|
* transitions are applied here so the two callers stay consistent.
|
|
*/
|
|
export async function commitPendingOperation(
|
|
supabase: SupabaseClient,
|
|
userId: string,
|
|
companyId: string,
|
|
pendingOp: PendingOperation,
|
|
opts: CommitOptions = {}
|
|
): Promise<CommitResult> {
|
|
// ── Atomic claim: flip status pending → committing in a single conditional
|
|
// update. If 0 rows are affected, another caller (auto-commit ↔ human
|
|
// approval, or two parallel approvals) already claimed this op and we
|
|
// must not run side-effects. Without this, both callers can pass the
|
|
// in-memory status check and double-book journal entries, send duplicate
|
|
// emails, etc.
|
|
const { data: claimed, error: claimError } = await supabase
|
|
.from('pending_operations')
|
|
.update({ status: 'committing' })
|
|
.eq('id', pendingOp.id)
|
|
.eq('status', 'pending')
|
|
.select('id')
|
|
.maybeSingle()
|
|
|
|
if (claimError) {
|
|
log.error('Failed to claim pending_operation:', claimError)
|
|
return { status: 'failed', error: 'Failed to claim operation', http_status: 500 }
|
|
}
|
|
if (!claimed) {
|
|
return {
|
|
status: 'failed',
|
|
error: 'Operation already claimed or resolved by another caller',
|
|
http_status: 409,
|
|
}
|
|
}
|
|
|
|
let result: ExecutorResult
|
|
try {
|
|
switch (pendingOp.operation_type) {
|
|
case 'categorize_transaction':
|
|
result = await commitCategorizeTransaction(supabase, userId, companyId, pendingOp.params)
|
|
break
|
|
case 'create_customer':
|
|
result = await commitCreateCustomer(supabase, userId, companyId, pendingOp.params)
|
|
break
|
|
case 'create_invoice':
|
|
result = await commitCreateInvoice(supabase, userId, companyId, pendingOp.params)
|
|
break
|
|
case 'mark_invoice_paid':
|
|
result = await commitMarkInvoicePaid(supabase, userId, companyId, pendingOp.params)
|
|
break
|
|
case 'send_invoice':
|
|
result = await commitSendInvoice(supabase, userId, companyId, pendingOp.params, opts.userEmail)
|
|
break
|
|
case 'mark_invoice_sent':
|
|
result = await commitMarkInvoiceSent(supabase, userId, companyId, pendingOp.params)
|
|
break
|
|
case 'match_transaction_invoice':
|
|
result = await commitMatchTransactionInvoice(supabase, userId, companyId, pendingOp.params)
|
|
break
|
|
case 'close_period':
|
|
result = await commitClosePeriod(supabase, userId, companyId, pendingOp.params)
|
|
break
|
|
case 'lock_period':
|
|
result = await commitLockPeriod(supabase, userId, companyId, pendingOp.params)
|
|
break
|
|
case 'unlock_period':
|
|
result = await commitUnlockPeriod(supabase, userId, companyId, pendingOp.params)
|
|
break
|
|
case 'uncategorize_transaction':
|
|
result = await commitUncategorizeTransaction(supabase, userId, companyId, pendingOp.params)
|
|
break
|
|
case 'attach_document_to_transaction':
|
|
result = await commitAttachDocumentToTransaction(supabase, userId, companyId, pendingOp.params)
|
|
break
|
|
case 'run_year_end':
|
|
result = await commitRunYearEnd(supabase, userId, companyId, pendingOp.params)
|
|
break
|
|
case 'set_opening_balances':
|
|
result = await commitSetOpeningBalances(supabase, userId, companyId, pendingOp.params)
|
|
break
|
|
case 'run_currency_revaluation':
|
|
result = await commitRunCurrencyRevaluation(supabase, userId, companyId, pendingOp.params)
|
|
break
|
|
case 'explain_voucher_gap':
|
|
result = await commitExplainVoucherGap(supabase, userId, companyId, pendingOp.params)
|
|
break
|
|
case 'approve_supplier_invoice':
|
|
result = await commitApproveSupplierInvoice(supabase, userId, companyId, pendingOp.params)
|
|
break
|
|
case 'credit_supplier_invoice':
|
|
result = await commitCreditSupplierInvoice(supabase, userId, companyId, pendingOp.params)
|
|
break
|
|
case 'convert_invoice':
|
|
result = await commitConvertInvoice(supabase, userId, companyId, pendingOp.params)
|
|
break
|
|
case 'credit_invoice':
|
|
result = await commitCreditInvoice(supabase, userId, companyId, pendingOp.params)
|
|
break
|
|
case 'import_sie':
|
|
result = await commitImportSie(supabase, userId, companyId, pendingOp.params)
|
|
break
|
|
default:
|
|
return {
|
|
status: 'failed',
|
|
error: `Unknown operation type: ${pendingOp.operation_type}`,
|
|
http_status: 400,
|
|
}
|
|
}
|
|
} catch (err) {
|
|
const isBkErr = isBookkeepingError(err)
|
|
const message = err instanceof Error ? err.message : (isBkErr ? 'Bookkeeping error' : 'Executor failed')
|
|
// Release the claim by transitioning to 'rejected' so the row never gets
|
|
// stuck in 'committing'. The error text is persisted in result_data for
|
|
// audit/debug.
|
|
await supabase
|
|
.from('pending_operations')
|
|
.update({
|
|
status: 'rejected',
|
|
resolved_at: new Date().toISOString(),
|
|
result_data: { error: message, threw: true },
|
|
})
|
|
.eq('id', pendingOp.id)
|
|
return {
|
|
status: 'failed',
|
|
error: message,
|
|
http_status: isBkErr ? 400 : 500,
|
|
}
|
|
}
|
|
|
|
if (result.error) {
|
|
const isAutoReject = result.status === 404 || result.status === 409
|
|
await supabase
|
|
.from('pending_operations')
|
|
.update({
|
|
status: 'rejected',
|
|
resolved_at: new Date().toISOString(),
|
|
result_data: isAutoReject
|
|
? { auto_rejected: true, reason: result.error }
|
|
: { error: result.error, http_status: result.status },
|
|
})
|
|
.eq('id', pendingOp.id)
|
|
if (isAutoReject) {
|
|
return {
|
|
status: 'rejected',
|
|
auto_rejected: true,
|
|
error: result.error,
|
|
http_status: result.status,
|
|
}
|
|
}
|
|
return {
|
|
status: 'failed',
|
|
error: result.error,
|
|
http_status: result.status ?? 500,
|
|
}
|
|
}
|
|
|
|
const now = new Date().toISOString()
|
|
await supabase
|
|
.from('pending_operations')
|
|
.update({
|
|
status: 'committed',
|
|
resolved_at: now,
|
|
result_data: result.data || {},
|
|
})
|
|
.eq('id', pendingOp.id)
|
|
|
|
return {
|
|
status: 'committed',
|
|
data: result.data,
|
|
}
|
|
}
|