64ea0fef02
* refactor(transactions): add shared settlement-account resolution helper Cherry-picked from fork/worktree-starry-waddling-wirth (PR #985) commit 34d5d35 — pulling in just the new lib/bookkeeping/settlement-account.ts helper and its test, without the match-supplier-invoice route changes from that PR (those depend on 8bfc31d, not yet on main, and are out of scope here). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: Jonas Flodén <jonas@floden.nu> * fix(transactions): resolve customer-invoice payment account from cash_account_id Customer-invoice payment matching never resolved the bank leg from the matched transaction's own cash_account_id: it was unconditionally hardcoded to 1930 in buildInvoicePaymentClearingLines, createInvoicePaymentJournalEntry, and createInvoiceCashEntry, with no override parameter at all. Any bank receipt landing in a non-primary cash/bank account (a secondary SEK account, or a foreign-currency account like 1940 for EUR) was silently misbooked to 1930 -- the same class of bug PR #985 fixed on the supplier-invoice side, except unconditional there (no stale-setting trigger needed). Adds an optional paymentAccount parameter (default '1930', preserving behavior for every caller that doesn't pass one) to the three lib functions, and threads resolveSettlementAccount(cash_account_id) through every real bank-transaction-matching call site: the dashboard match-invoice route (POST + preview), its v1/MCP-facing counterpart, and the agent/MCP match_transaction_invoice commit path. Deliberately left on default 1930: mark-paid (dashboard + v1, no bank transaction in scope), fix-cash-mismatch (narrow historical repair tool for a different bug), and the agent mark_invoice_paid commit path. Brings in lib/bookkeeping/settlement-account.ts (cherry-picked from fork/worktree-starry-waddling-wirth commit 34d5d35) so this PR is mergeable independently of #985's merge order. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: Jonas Flodén <jonas@floden.nu> * test(invoice-entries): cover ROT/RUT 1513 line stays fixed under a non-default paymentAccount Compliance-bot finding on PR #987: createInvoiceCashEntry's paymentAccount override was only tested against a plain standard_25 invoice, never combined with a ROT/RUT deduction_type item. The 1513 receivable line was already correctly untouched by paymentAccount (it's never the bank leg), this just closes the test-coverage gap. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: Jonas Flodén <jonas@floden.nu> * fix(bookkeeping): abort instead of silently defaulting to 1930 when settlement-account lookup errors Same shared-helper fix as PR #985/#986: resolveSettlementAccount now throws BookkeepingDatabaseError on a genuine cash_accounts query error instead of warning and falling back to 1930. An explicit cash_account_id almost certainly resolves to a non-1930 account, so a transient failure masking it risked the same class of misbooking this whole PR series exists to fix, just via infra flakiness instead of a stale setting. No route/commit.ts changes needed: match-invoice (POST + preview) run under withRouteContext's existing catch-all, and commitPendingOperation already has identical generic bookkeeping-error handling for every other engine failure. Added regression tests for all three call sites (dashboard POST, preview, and the agent/MCP commit path) confirming the abort rather than assuming the shared infrastructure handles it silently. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: Jonas Flodén <jonas@floden.nu> * fix(v1): guard resolved settlement account against chart of accounts Closes the two remaining gaps from jakobwennberg's triage on #987 (after rebasing onto main and picking up the already-pushed resolveSettlementAccount abort-on-error fix): - Added the v1 match-invoice route-level test coverage that was missing (cash-account threading, BOOKKEEPING_DATABASE_ERROR abort, ACCOUNTS_NOT_IN_CHART), mirroring the dashboard route's existing settlement-account-resolution tests. - Added the same findUnresolvableAccounts pre-validation guard against chart_of_accounts that 32c07c4 added to #986's match-supplier-invoice route, gated on !customLines since that is the only branch here that consumes the resolved paymentAccount. Signed-off-by: Jonas Flodén Signed-off-by: Jonas Flodén <jonas@floden.nu> * test(bookkeeping): align settlement-account error assertion with #985 Use .rejects.toBeInstanceOf(BookkeepingDatabaseError) instead of toMatchObject({ constructor: ... }), matching #985's edef79d follow-up (the assertion was correct either way, but this is the more idiomatic check and now makes the shared helper's test file byte-identical across #985/#986/#987, removing the add/add merge conflict between them noted in the merge-order validation. Signed-off-by: Jonas Flodén Signed-off-by: Jonas Flodén <jonas@floden.nu> * test(invoice-payment-lines): add missing 3740 coverage for non-1930 paymentAccount CodeRabbit nitpick on #987: the test named "...does not affect the FX-diff or öresavrundning lines" only exercised the 3960 FX-diff branch, never the pure-SEK 3740 öresavrundning branch it also claimed to cover. Split into two tests: the existing one renamed to describe only its FX-diff coverage, plus a new pure-SEK sub-krona-short case with a resolved non-1930 paymentAccount asserting the 3740 line books correctly and the bank leg lands on the resolved account, not 1930. Signed-off-by: Jonas Flodén Signed-off-by: Jonas Flodén <jonas@floden.nu> * fix(ci): quote compliance-pr.yml name to fix invalid YAML The unquoted colon in `name: compliance: review (advisory)` (introduced by #890's em-dash removal, which swapped an em dash for a colon in-place) makes YAML read it as a nested mapping key, so GitHub can't parse the workflow at all - every run fails with 0 jobs scheduled. Signed-off-by: Jonas Flodén Signed-off-by: Jonas Flodén <jonas@floden.nu> * Revert "fix(ci): quote compliance-pr.yml name to fix invalid YAML" This reverts commit e7c890245d1834cd8f3c9b13a2bc3247fea7eacb. Signed-off-by: Jonas Flodén <jonas@floden.nu> --------- Signed-off-by: Jonas Flodén <jonas@floden.nu> Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>
4015 lines
153 KiB
TypeScript
4015 lines
153 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 { bulkBookMatchedInboxItems, categorizeMatchedTransaction } from '@/lib/transactions/categorize-core'
|
||
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 { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account'
|
||
import { createJournalEntry, findFiscalPeriod, reverseEntry, validateBalance } from '@/lib/bookkeeping/engine'
|
||
import { coerceDimensionsBag } from '@/lib/bookkeeping/dimension-resolver'
|
||
import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
|
||
import { runWithActor } from '@/lib/bookkeeping/actor-context-node'
|
||
import type { CommitActor } from '@/lib/bookkeeping/actor-context'
|
||
import { correctEntry } from '@/lib/core/bookkeeping/storno-service'
|
||
import { closePeriod, lockPeriod, unlockPeriod, resolvePeriodStatusForDate } 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,
|
||
createSupplierInvoiceRegistrationEntry,
|
||
} from '@/lib/bookkeeping/supplier-invoice-entries'
|
||
import { linkInvoiceToVoucher } from '@/lib/invoices/voucher-matching'
|
||
import { planInvoicePayment } from '@/lib/invoices/apply-invoice-payment'
|
||
import { findDuplicatePaymentCandidatesForInvoice } from '@/lib/invoices/duplicate-payment-candidates'
|
||
import { linkSupplierInvoiceToVoucher } from '@/lib/invoices/supplier-voucher-matching'
|
||
import { linkTransactionToJournalEntry } from '@/lib/transactions/link-journal-entry'
|
||
import { getErrorEntry } from '@/lib/errors/structured-errors'
|
||
import { parseSIEFile } from '@/lib/import/sie-parser'
|
||
import { executeSIEImport, undoSIEImport } from '@/lib/import/sie-import'
|
||
import type { AccountMapping } from '@/lib/import/types'
|
||
import { AccountsNotInChartError, isBookkeepingError, ACCOUNTS_NOT_IN_CHART } from '@/lib/bookkeeping/errors'
|
||
import { extensionRegistry } from '@/lib/extensions/registry'
|
||
import {
|
||
SkatteverketRecoverableError,
|
||
type SkatteverketCommitServices,
|
||
type SkvSubmitResult,
|
||
} from '@/lib/pending-operations/skatteverket-commit'
|
||
import { getEmailService } from '@/lib/email/service'
|
||
import { hasCapability, CAPABILITY_BLOCKED_MESSAGE_SV } from '@/lib/entitlements/has-capability'
|
||
import { PAID_OPERATION_CAPABILITY_MAP } from '@/lib/entitlements/keys'
|
||
import {
|
||
generateInvoiceEmailHtml,
|
||
generateInvoiceEmailText,
|
||
generateInvoiceEmailSubject,
|
||
} from '@/lib/email/invoice-templates'
|
||
import { uploadDocument, linkToJournalEntry } from '@/lib/core/documents/document-service'
|
||
import { renderToBuffer } from '@react-pdf/renderer'
|
||
import { InvoicePDF } from '@/lib/invoices/pdf-template'
|
||
import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
|
||
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
|
||
import { createLogger } from '@/lib/logger'
|
||
import { appendProcessingHistory } from '@/lib/processing-history/append'
|
||
import { CreateSupplierParamsSchema } from '@/lib/pending-operations/schemas/create-supplier'
|
||
import { CreateArticleParamsSchema, UpdateArticleParamsSchema } from '@/lib/pending-operations/schemas/article'
|
||
import { CreateDimensionValueParamsSchema } from '@/lib/pending-operations/schemas/dimension-value'
|
||
import { RetagLineDimensionsParamsSchema } from '@/lib/pending-operations/schemas/retag-line-dimensions'
|
||
import { BulkBookInboxSchema } from '@/lib/api/schemas'
|
||
import { ensureArticleNumber } from '@/lib/articles/ensure-article-number'
|
||
import { isValidRevenueAccount } from '@/lib/articles/validate-revenue-account'
|
||
import { z } from 'zod'
|
||
import type {
|
||
Transaction,
|
||
TransactionCategory,
|
||
EntityType,
|
||
VatTreatment,
|
||
Currency,
|
||
Invoice,
|
||
Customer,
|
||
Supplier,
|
||
Article,
|
||
SupplierInvoice,
|
||
SupplierInvoiceItem,
|
||
PendingOperation,
|
||
CompanySettings,
|
||
InvoiceItem,
|
||
AccountingMethod,
|
||
CreditNote,
|
||
CreateJournalEntryLineInput,
|
||
JournalEntrySourceType,
|
||
} 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
|
||
// Set when the commit failed because the booking posts to BAS accounts not
|
||
// active in the company chart. Recoverable: the op is left 'pending' so the
|
||
// caller can activate the accounts and retry. Lets the route rebuild the
|
||
// structured ACCOUNTS_NOT_IN_CHART envelope (code + account_numbers).
|
||
code?: string
|
||
account_numbers?: string[]
|
||
}
|
||
|
||
export interface CommitOptions {
|
||
/** Email address used as cc on send_invoice (typically the human user's email). */
|
||
userEmail?: string
|
||
/**
|
||
* commit_method recorded on any journal_entries created by this operation.
|
||
* Must match the CHECK constraint on journal_entries.commit_method
|
||
* (migration 20260618120001): 'user_accept' | 'bulk_accept' |
|
||
* 'timing_ceiling' | 'migration' | 'legacy' | 'agent' | 'api_key'.
|
||
*
|
||
* Web-UI single-approval passes 'user_accept'; bulk-approval passes
|
||
* 'bulk_accept'. MCP approvals pass the relaying credential: 'api_key'
|
||
* (gnubok-mcp bridge) or 'agent' (OAuth connector), so the immutable layer
|
||
* records that the acknowledgment was agent-relayed rather than a
|
||
* first-party human session (agent_first_vision.md §8 P0-1). Every path is
|
||
* still human-approval-gated; agent auto-commit was removed in
|
||
* 20260505190027_drop_agent_auto_commit.
|
||
*/
|
||
commitMethod?: 'user_accept' | 'bulk_accept' | 'agent' | 'api_key'
|
||
/**
|
||
* WHO is relaying this approval (api_key with the key's display name, plain
|
||
* user, agent_chat, …). Propagated to every journal-entry commit made by the
|
||
* operation via the runWithActor() AsyncLocalStorage scope (unlike
|
||
* commitMethod, which only the create_voucher executor threads explicitly)
|
||
* and stamped onto journal_entries.committed_actor_* plus the audit_log
|
||
* COMMIT row by the commit_journal_entry RPC (migration 20260619120000).
|
||
* Omitted → NULL attribution, identical to pre-attribution behaviour.
|
||
*/
|
||
actor?: CommitActor
|
||
}
|
||
|
||
// ensureFiscalPeriod moved to lib/transactions/categorize-core.ts (imported
|
||
// above) so the bulk-book-inbox path and the single-categorize path share one
|
||
// implementation.
|
||
|
||
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
|
||
// Optional audit-trail text the agent passed alongside the categorization.
|
||
// For representation bookings the agent captures deltagare + syfte and
|
||
// funnels them in here so the verifikation's description carries the
|
||
// context an external auditor needs (SKV's representationsregler).
|
||
const notes =
|
||
typeof params.notes === 'string' && params.notes.trim().length > 0
|
||
? (params.notes as string)
|
||
: undefined
|
||
// The underlag's actual VAT, staged when the document's moms differs from
|
||
// rate × belopp (e.g. dricks). Threaded into the mapping builder so the
|
||
// approved posting matches the staged preview exactly.
|
||
const vatAmount =
|
||
typeof params.vat_amount === 'number' && Number.isFinite(params.vat_amount)
|
||
? params.vat_amount
|
||
: undefined
|
||
|
||
// Booking, the duplicate guard, VAT mapping, and matched-inbox underlag
|
||
// propagation all live in the shared core (lib/transactions/categorize-core.ts)
|
||
// so the bulk-book-inbox executor and the Underlag "Bokför valda" route reuse
|
||
// exactly this logic.
|
||
return categorizeMatchedTransaction(supabase, userId, companyId, txId, {
|
||
category,
|
||
vatTreatment,
|
||
vatAmount,
|
||
notes,
|
||
allowDuplicate: params.allow_duplicate === true,
|
||
// Dimensions PR7: resolved at staging; coerce is the drift/tamper gate.
|
||
dimensions: coerceDimensionsBag(params.dimensions),
|
||
})
|
||
}
|
||
|
||
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 commitCreateArticle(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
params: Record<string, unknown>
|
||
): Promise<ExecutorResult> {
|
||
// Defense in depth: re-validate the staged params at the commit boundary so a
|
||
// tampered pending_operations row cannot inject unexpected fields (ASVS V4.5).
|
||
let validated
|
||
try {
|
||
validated = CreateArticleParamsSchema.parse(params)
|
||
} catch (err) {
|
||
if (err instanceof z.ZodError) {
|
||
const issue = err.issues[0]
|
||
return { error: `Invalid ${issue?.path?.join('.') ?? 'params'}: ${issue?.message ?? 'validation failed'}`, status: 400 }
|
||
}
|
||
throw err
|
||
}
|
||
|
||
if (validated.revenue_account) {
|
||
const ok = await isValidRevenueAccount(supabase, companyId, validated.revenue_account)
|
||
if (!ok) return { error: 'Revenue account is not an active class-3 account', status: 400 }
|
||
}
|
||
|
||
const { data, error } = await supabase
|
||
.from('articles')
|
||
.insert({
|
||
user_id: userId,
|
||
company_id: companyId,
|
||
name: validated.name,
|
||
name_en: validated.name_en ?? null,
|
||
type: validated.type,
|
||
unit: validated.unit ?? 'st',
|
||
price_excl_vat: validated.price_excl_vat,
|
||
vat_rate: validated.vat_rate,
|
||
revenue_account: validated.revenue_account ?? null,
|
||
cost_price: validated.cost_price ?? null,
|
||
ean: validated.ean ?? null,
|
||
housework_type: validated.housework_type ?? null,
|
||
notes: validated.notes ?? null,
|
||
article_number: validated.article_number ?? null,
|
||
})
|
||
.select()
|
||
.single()
|
||
|
||
if (error) return { error: error.message, status: 500 }
|
||
|
||
if (!data.article_number) {
|
||
try {
|
||
data.article_number = await ensureArticleNumber(supabase, companyId, data.id)
|
||
} catch (err) {
|
||
log.warn('article number assignment failed (staged create):', err)
|
||
}
|
||
}
|
||
|
||
await eventBus.emit({ type: 'article.created', payload: { article: data as Article, userId, companyId } })
|
||
|
||
return { data: { article_id: data.id, article_number: data.article_number } }
|
||
}
|
||
|
||
async function commitUpdateArticle(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
params: Record<string, unknown>
|
||
): Promise<ExecutorResult> {
|
||
let validated
|
||
try {
|
||
validated = UpdateArticleParamsSchema.parse(params)
|
||
} catch (err) {
|
||
if (err instanceof z.ZodError) {
|
||
const issue = err.issues[0]
|
||
return { error: `Invalid ${issue?.path?.join('.') ?? 'params'}: ${issue?.message ?? 'validation failed'}`, status: 400 }
|
||
}
|
||
throw err
|
||
}
|
||
|
||
if (validated.revenue_account) {
|
||
const ok = await isValidRevenueAccount(supabase, companyId, validated.revenue_account)
|
||
if (!ok) return { error: 'Revenue account is not an active class-3 account', status: 400 }
|
||
}
|
||
|
||
const { article_id, ...rest } = validated
|
||
const updateData: Record<string, unknown> = {}
|
||
for (const [key, value] of Object.entries(rest)) {
|
||
if (value !== undefined) updateData[key] = value
|
||
}
|
||
|
||
const { data, error } = await supabase
|
||
.from('articles')
|
||
.update(updateData)
|
||
.eq('id', article_id)
|
||
.eq('company_id', companyId)
|
||
.select()
|
||
.single()
|
||
|
||
if (error) {
|
||
if (error.code === 'PGRST116') return { error: 'Article not found', status: 404 }
|
||
return { error: error.message, status: 500 }
|
||
}
|
||
|
||
await eventBus.emit({ type: 'article.updated', payload: { article: data as Article, userId, companyId } })
|
||
|
||
return { data: { article_id: data.id } }
|
||
}
|
||
|
||
async function commitCreateSupplier(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
params: Record<string, unknown>
|
||
): Promise<ExecutorResult> {
|
||
// Defense in depth: re-validate the staged params at the commit boundary so a
|
||
// tampered pending_operations row cannot inject unexpected fields or
|
||
// malformed payment-routing data into the suppliers table (ASVS V4.5).
|
||
let validated
|
||
try {
|
||
validated = CreateSupplierParamsSchema.parse(params)
|
||
} catch (err) {
|
||
if (err instanceof z.ZodError) {
|
||
const issue = err.issues[0]
|
||
const path = issue?.path?.join('.') ?? 'params'
|
||
return { error: `Invalid ${path}: ${issue?.message ?? 'validation failed'}`, status: 400 }
|
||
}
|
||
throw err
|
||
}
|
||
|
||
const { data, error } = await supabase
|
||
.from('suppliers')
|
||
.insert({
|
||
user_id: userId,
|
||
company_id: companyId,
|
||
name: validated.name,
|
||
supplier_type: validated.supplier_type,
|
||
email: validated.email ?? null,
|
||
phone: validated.phone ?? null,
|
||
org_number: validated.org_number ?? null,
|
||
vat_number: validated.vat_number ?? null,
|
||
address_line1: validated.address_line1 ?? null,
|
||
address_line2: validated.address_line2 ?? null,
|
||
postal_code: validated.postal_code ?? null,
|
||
city: validated.city ?? null,
|
||
country: validated.country ?? 'SE',
|
||
bankgiro: validated.bankgiro ?? null,
|
||
plusgiro: validated.plusgiro ?? null,
|
||
bank_account: validated.bank_account ?? null,
|
||
iban: validated.iban ?? null,
|
||
bic: validated.bic ?? null,
|
||
default_expense_account: validated.default_expense_account ?? null,
|
||
default_payment_terms: validated.default_payment_terms,
|
||
default_currency: validated.default_currency ?? 'SEK',
|
||
notes: validated.notes ?? null,
|
||
})
|
||
.select()
|
||
.single()
|
||
|
||
if (error) return { error: error.message, status: 500 }
|
||
|
||
await eventBus.emit({ type: 'supplier.created', payload: { supplier: data as Supplier, userId, companyId } })
|
||
|
||
return { data: { supplier_id: data.id } }
|
||
}
|
||
|
||
/**
|
||
* Executor for the staged create_dimension_value operation
|
||
* (gnubok_create_dimension_value, dimensions PR3). Inserts a dimension value
|
||
* (SIE #OBJEKT) into the registry. Agents never silently mint reporting
|
||
* values: this always arrives via a human-approved pending_operation.
|
||
*
|
||
* Idempotent on duplicate code: a 23505 on (company_id, dimension_id, code)
|
||
* re-reads the existing row and reports success with already_existed=true, so
|
||
* a raced or re-committed approval never fails on "already there".
|
||
*/
|
||
async function commitCreateDimensionValue(
|
||
supabase: SupabaseClient,
|
||
_userId: string,
|
||
companyId: string,
|
||
params: Record<string, unknown>
|
||
): Promise<ExecutorResult> {
|
||
// Defense in depth: re-validate the staged params at the commit boundary so
|
||
// a tampered pending_operations row cannot inject a non-portable code or
|
||
// malformed dates into the registry (ASVS V4.5): mirrors commitCreateSupplier.
|
||
let validated
|
||
try {
|
||
validated = CreateDimensionValueParamsSchema.parse(params)
|
||
} catch (err) {
|
||
if (err instanceof z.ZodError) {
|
||
const issue = err.issues[0]
|
||
const path = issue?.path?.join('.') ?? 'params'
|
||
return { error: `Invalid ${path}: ${issue?.message ?? 'validation failed'}`, status: 400 }
|
||
}
|
||
throw err
|
||
}
|
||
|
||
// Get-or-create the system dims (1 = kostnadsställe, 6 = projekt):
|
||
// idempotent lazy seeding. Custom dims must already exist in the registry:
|
||
// agents may stage new VALUES, never new dimensions.
|
||
if (validated.sie_dim_no === 1 || validated.sie_dim_no === 6) {
|
||
const { error: ensureError } = await supabase.rpc('ensure_company_dimensions', {
|
||
p_company_id: companyId,
|
||
})
|
||
if (ensureError) {
|
||
return { error: `Kunde inte skapa systemdimensionerna: ${ensureError.message}`, status: 500 }
|
||
}
|
||
}
|
||
|
||
const { data: dimension, error: dimError } = await supabase
|
||
.from('dimensions')
|
||
.select('id, sie_dim_no, name, resets_annually')
|
||
.eq('company_id', companyId)
|
||
.eq('sie_dim_no', validated.sie_dim_no)
|
||
.maybeSingle()
|
||
|
||
if (dimError) return { error: dimError.message, status: 500 }
|
||
if (!dimension) {
|
||
return {
|
||
error:
|
||
`Okänd dimension ${validated.sie_dim_no}. Endast registrerade dimensioner kan få nya värden ` +
|
||
'(1 = kostnadsställe och 6 = projekt skapas automatiskt; övriga skapas i registret).',
|
||
status: 400,
|
||
}
|
||
}
|
||
|
||
// Value dates only make sense on accumulating dimensions (projekt-style
|
||
// ranges): mirrors POST /api/dimensions/[id]/values.
|
||
if (dimension.resets_annually && (validated.start_date || validated.end_date)) {
|
||
return {
|
||
error: `Start-/slutdatum är inte tillåtna på dimensionen "${dimension.name}" (nollställs årligen).`,
|
||
status: 400,
|
||
}
|
||
}
|
||
|
||
const { data: created, error: insertError } = await supabase
|
||
.from('dimension_values')
|
||
.insert({
|
||
company_id: companyId,
|
||
dimension_id: dimension.id,
|
||
code: validated.code,
|
||
name: validated.name,
|
||
start_date: validated.start_date ?? null,
|
||
end_date: validated.end_date ?? null,
|
||
})
|
||
.select('id, code, name, is_active')
|
||
.single()
|
||
|
||
if (insertError) {
|
||
if (insertError.code === '23505') {
|
||
// Duplicate code: treat the existing value as success (idempotency).
|
||
const { data: existing, error: existingError } = await supabase
|
||
.from('dimension_values')
|
||
.select('id, code, name, is_active')
|
||
.eq('company_id', companyId)
|
||
.eq('dimension_id', dimension.id)
|
||
.eq('code', validated.code)
|
||
.maybeSingle()
|
||
if (existingError || !existing) {
|
||
return { error: insertError.message, status: 500 }
|
||
}
|
||
return {
|
||
data: {
|
||
dimension_value_id: existing.id,
|
||
sie_dim_no: dimension.sie_dim_no,
|
||
dimension_name: dimension.name,
|
||
code: existing.code,
|
||
name: existing.name,
|
||
is_active: existing.is_active,
|
||
already_existed: true,
|
||
},
|
||
}
|
||
}
|
||
return { error: insertError.message, status: 500 }
|
||
}
|
||
|
||
return {
|
||
data: {
|
||
dimension_value_id: created.id,
|
||
sie_dim_no: dimension.sie_dim_no,
|
||
dimension_name: dimension.name,
|
||
code: created.code,
|
||
name: created.name,
|
||
is_active: created.is_active,
|
||
already_existed: false,
|
||
},
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Executor for the staged retag_line_dimensions operation
|
||
* (gnubok_tag_journal_lines, dimensions PR6). Loops the staged line_ids
|
||
* through the retag_line_dimensions RPC: the ONE audited write path for
|
||
* changing dimension tags on posted lines. The RPC enforces everything per
|
||
* line at commit time (open period, company lock date, active registry
|
||
* values, writer role, posted status) and writes an immutable
|
||
* dimension_retag_log row before touching the line.
|
||
*
|
||
* Partial-success semantics: one line failing (e.g. its period was locked
|
||
* between staging and approval) must not roll back the lines already
|
||
* retagged: each RPC call is its own transaction. Failures are collected
|
||
* and echoed (capped at 20) so the caller can re-stage just the failed set.
|
||
* Only when EVERY line fails does the operation as a whole fail.
|
||
*/
|
||
async function commitRetagLineDimensions(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
params: Record<string, unknown>
|
||
): Promise<ExecutorResult> {
|
||
// Defense in depth: re-validate the staged params at the commit boundary so
|
||
// a tampered pending_operations row cannot inject arbitrary ids or a
|
||
// malformed bag (ASVS V4.5): mirrors commitCreateDimensionValue.
|
||
let validated
|
||
try {
|
||
validated = RetagLineDimensionsParamsSchema.parse(params)
|
||
} catch (err) {
|
||
if (err instanceof z.ZodError) {
|
||
const issue = err.issues[0]
|
||
const path = issue?.path?.join('.') ?? 'params'
|
||
return { error: `Invalid ${path}: ${issue?.message ?? 'validation failed'}`, status: 400 }
|
||
}
|
||
throw err
|
||
}
|
||
|
||
let retagged = 0
|
||
let unchanged = 0
|
||
const failed: Array<{ line_id: string; error: string }> = []
|
||
|
||
for (const lineId of validated.line_ids) {
|
||
const { data, error } = await supabase.rpc('retag_line_dimensions', {
|
||
p_company_id: companyId,
|
||
p_line_id: lineId,
|
||
p_dimensions: validated.dimensions,
|
||
p_reason: validated.reason,
|
||
p_user_id: userId,
|
||
})
|
||
if (error) {
|
||
failed.push({ line_id: lineId, error: error.message })
|
||
continue
|
||
}
|
||
if ((data as { changed?: boolean } | null)?.changed) retagged++
|
||
else unchanged++
|
||
}
|
||
|
||
if (failed.length > 0 && retagged === 0 && unchanged === 0) {
|
||
return {
|
||
error: `Ingen rad kunde taggas om (${failed.length} rader misslyckades). Första felet: ${failed[0].error}`,
|
||
status: 400,
|
||
}
|
||
}
|
||
|
||
return {
|
||
data: {
|
||
retagged,
|
||
unchanged,
|
||
failed_count: failed.length,
|
||
// Echo at most 20 failures: enough to act on without bloating
|
||
// result_data on a pathological 500-line all-but-one failure.
|
||
failed: failed.slice(0, 20),
|
||
dimensions: validated.dimensions,
|
||
...(validated.filter_summary ? { filter_summary: validated.filter_summary } : {}),
|
||
},
|
||
}
|
||
}
|
||
|
||
async function commitCreateTransaction(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
params: Record<string, unknown>
|
||
): Promise<ExecutorResult> {
|
||
const date = params.date as string
|
||
const amount = Number(params.amount)
|
||
const description = (params.description as string) ?? ''
|
||
const currency = ((params.currency as string) || 'SEK') as Currency
|
||
const bankConnectionId = (params.bank_connection_id as string) || null
|
||
const externalId = (params.external_id as string) || null
|
||
|
||
if (!date || !description.trim() || !Number.isFinite(amount)) {
|
||
return { error: 'date, description, and amount are required', status: 400 }
|
||
}
|
||
|
||
const { data, error } = await supabase
|
||
.from('transactions')
|
||
.insert({
|
||
user_id: userId,
|
||
company_id: companyId,
|
||
bank_connection_id: bankConnectionId,
|
||
external_id: externalId,
|
||
date,
|
||
description: description.trim(),
|
||
amount,
|
||
currency,
|
||
import_source: 'mcp',
|
||
})
|
||
.select('id')
|
||
.single()
|
||
|
||
if (error) {
|
||
const isDuplicate = error.code === '23505'
|
||
return {
|
||
error: isDuplicate
|
||
? `A transaction with external_id "${externalId}" already exists.`
|
||
: error.message,
|
||
status: isDuplicate ? 409 : 500,
|
||
}
|
||
}
|
||
|
||
return { data: { transaction_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
|
||
article_id?: string | null; revenue_account?: string | null
|
||
line_type?: 'product' | 'text'
|
||
dimensions?: Record<string, string>
|
||
}>
|
||
// Dimensions PR7: bags were resolved against the registry at staging time
|
||
// (resolveDimensionBags in the MCP tool); coerce is the drift/tamper gate.
|
||
const defaultDimensions = coerceDimensionsBag(params.default_dimensions)
|
||
|
||
// Free-text rows carry no amounts and never book. The MCP staging tool does
|
||
// not accept line_type today, but the totals math must stay identical to
|
||
// app/api/invoices/route.ts, which excludes text rows from subtotal, VAT,
|
||
// and the mixed-rate detection.
|
||
const billableItems = items.filter((item) => item.line_type !== 'text')
|
||
|
||
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))
|
||
|
||
// VAT registration gate (mirrors app/api/invoices/route.ts). A
|
||
// non-momsregistrerad company books no output VAT: force every line to 0%
|
||
// (momsfri → treatment 'exempt'). 0% is allowed for every customer type, so
|
||
// the allowedRates guard below still passes.
|
||
const { data: vatSettings } = await supabase
|
||
.from('company_settings')
|
||
.select('vat_registered')
|
||
.eq('company_id', companyId)
|
||
.maybeSingle()
|
||
const notVatRegistered = vatSettings?.vat_registered === false
|
||
if (notVatRegistered) for (const item of items) item.vat_rate = 0
|
||
|
||
const subtotal = billableItems.reduce((sum, item) => sum + item.quantity * item.unit_price, 0)
|
||
|
||
let vatAmount = 0
|
||
for (const item of billableItems) {
|
||
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
|
||
}
|
||
|
||
// Validate any per-line revenue-account override (defense in depth: the field
|
||
// is frozen onto invoice_items and flows to generatePerRateLines()).
|
||
const overrideAccounts = Array.from(
|
||
new Set(billableItems.map((i) => i.revenue_account).filter((a): a is string => !!a)),
|
||
)
|
||
for (const acct of overrideAccounts) {
|
||
if (!(await isValidRevenueAccount(supabase, companyId, acct))) {
|
||
return { error: `Försäljningskonto ${acct} är inte ett aktivt intäktskonto (klass 3)`, status: 400 }
|
||
}
|
||
}
|
||
|
||
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(billableItems.map((item) => item.vat_rate ?? vatRules.rate))
|
||
const isMixedRate = uniqueRates.size > 1
|
||
|
||
// Validated https-only at staging time (gnubok_create_invoice); re-checked
|
||
// here so a hand-crafted pending-operation row can't smuggle a non-https
|
||
// link into customer-facing emails/PDFs. Invalid → dropped, never blocks.
|
||
const paymentLinkUrl = (() => {
|
||
const raw = typeof params.payment_link_url === 'string' ? params.payment_link_url.trim() : ''
|
||
if (!raw || raw.length > 2048) return null
|
||
try {
|
||
return new URL(raw).protocol === 'https:' ? raw : null
|
||
} catch {
|
||
return null
|
||
}
|
||
})()
|
||
|
||
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: notVatRegistered ? 'exempt' : vatRules.treatment,
|
||
vat_rate: isMixedRate ? null : (uniqueRates.values().next().value ?? vatRules.rate),
|
||
moms_ruta: notVatRegistered ? null : vatRules.momsRuta,
|
||
reverse_charge_text: notVatRegistered ? null : (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,
|
||
payment_link_url: paymentLinkUrl,
|
||
default_dimensions: defaultDimensions ?? {},
|
||
})
|
||
.select()
|
||
.single()
|
||
|
||
if (invoiceError) return { error: invoiceError.message, status: 500 }
|
||
|
||
const invoiceItems = items.map((item, index) => {
|
||
// Text rows store the description only and zero everything else. Keys must
|
||
// match the product branch exactly: PostgREST rejects a bulk insert whose
|
||
// objects have differing key sets.
|
||
if (item.line_type === 'text') {
|
||
return {
|
||
invoice_id: invoice.id,
|
||
sort_order: index,
|
||
line_type: 'text',
|
||
description: item.description ?? '',
|
||
quantity: 0,
|
||
unit: '',
|
||
unit_price: 0,
|
||
line_total: 0,
|
||
vat_rate: 0,
|
||
vat_amount: 0,
|
||
article_id: null,
|
||
revenue_account: null,
|
||
dimensions: {},
|
||
}
|
||
}
|
||
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,
|
||
line_type: 'product',
|
||
description: item.description,
|
||
quantity: item.quantity,
|
||
unit: item.unit,
|
||
unit_price: item.unit_price,
|
||
line_total: lineTotal,
|
||
vat_rate: itemRate,
|
||
vat_amount: itemVat,
|
||
// Frozen per-line override so generatePerRateLines() books to the article's
|
||
// account; null falls back to the VAT-treatment-derived account.
|
||
article_id: item.article_id ?? null,
|
||
revenue_account: item.revenue_account ?? null,
|
||
dimensions: coerceDimensionsBag(item.dimensions) ?? {},
|
||
}
|
||
})
|
||
|
||
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 }
|
||
}
|
||
|
||
// Duplicate-payment guard: parity with the web mark-paid route, which the
|
||
// agent path otherwise bypassed. If an unlinked inbound bank transaction
|
||
// already looks like this invoice's payment, booking a parallel payment
|
||
// voucher here creates exactly the orphan that later double-counts the
|
||
// receipt. Fail closed; the agent re-stages with allow_duplicate=true (after
|
||
// the user confirms) or, better, matches the transaction to the invoice
|
||
// instead. Fail-open on a detection error so it never blocks a real payment.
|
||
if (params.allow_duplicate !== true) {
|
||
const customerName = (invoice as { customer?: { name?: string } }).customer?.name
|
||
if (customerName) {
|
||
const remainingAmount =
|
||
(invoice as { remaining_amount?: number }).remaining_amount ?? invoice.total
|
||
let candidates: Awaited<ReturnType<typeof findDuplicatePaymentCandidatesForInvoice>> = []
|
||
try {
|
||
candidates = await findDuplicatePaymentCandidatesForInvoice(supabase, {
|
||
companyId,
|
||
invoice: { invoice_number: invoice.invoice_number, customer_name: customerName },
|
||
paymentAmount: remainingAmount,
|
||
paymentDate,
|
||
})
|
||
} catch (err) {
|
||
log.warn('duplicate-payment detection failed (continuing)', err)
|
||
}
|
||
if (candidates.length > 0) {
|
||
return {
|
||
error:
|
||
`Möjlig dubbelbetalning: en obokförd banktransaktion ser ut att vara betalningen för faktura ` +
|
||
`${invoice.invoice_number}. Matcha banktransaktionen mot fakturan (gnubok_match_transaction_to_invoice) ` +
|
||
`i stället för att bokföra en separat betalning. Om det verkligen rör sig om en annan betalning, ` +
|
||
`kör om med allow_duplicate=true.`,
|
||
status: 409,
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
// allow_duplicate=true bypassed the duplicate-payment guard. The decision
|
||
// to book a payment over a possible existing one must leave a durable
|
||
// behandlingshistorik record (BFNAR 2013:2 kap 8) so an auditor can see why
|
||
// the duplicate was allowed. Re-detect to capture the dismissed candidate;
|
||
// best-effort, never blocks the payment. Payload stays PII-safe
|
||
// (ids/amounts/dates only: no customer or merchant name).
|
||
const customerName = (invoice as { customer?: { name?: string } }).customer?.name
|
||
if (customerName) {
|
||
try {
|
||
const remainingAmount =
|
||
(invoice as { remaining_amount?: number }).remaining_amount ?? invoice.total
|
||
const dismissed = await findDuplicatePaymentCandidatesForInvoice(supabase, {
|
||
companyId,
|
||
invoice: { invoice_number: invoice.invoice_number, customer_name: customerName },
|
||
paymentAmount: remainingAmount,
|
||
paymentDate,
|
||
})
|
||
if (dismissed.length > 0) {
|
||
await appendProcessingHistory({
|
||
companyId,
|
||
correlationId: invoiceId,
|
||
aggregateType: 'System',
|
||
aggregateId: invoiceId,
|
||
eventType: 'InvoiceDuplicatePaymentDismissed',
|
||
payload: {
|
||
invoice_id: invoiceId,
|
||
payment_date: paymentDate,
|
||
dismissed_transaction_ids: dismissed.map((c) => c.id),
|
||
candidate_count: dismissed.length,
|
||
via: 'allow_duplicate',
|
||
},
|
||
actor: { type: 'user', id: userId },
|
||
occurredAt: new Date(),
|
||
})
|
||
}
|
||
} catch (logErr) {
|
||
log.warn('failed to record duplicate-payment-dismissal behandlingshistorik', logErr)
|
||
}
|
||
}
|
||
}
|
||
|
||
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
|
||
|
||
// Route on invoice state, not the company's current accounting_method:
|
||
// an invoice booked at send under accrual must clear 1510 here even if
|
||
// the company has since switched to kontantmetoden.
|
||
const invoiceAlreadyBooked = !!(invoice as { journal_entry_id?: string | null }).journal_entry_id
|
||
const useCashEntry = !invoiceAlreadyBooked && accountingMethod === 'cash'
|
||
|
||
// Paid/remaining/status math + overpayment guard via the shared
|
||
// planInvoicePayment helper: the single source of truth across the three
|
||
// mark-paid surfaces (this agent path, the dashboard route, and the v1 API).
|
||
// This path settles the full remaining (no custom lines), so it can never
|
||
// overpay, but routing through the helper keeps the state identical. Runs
|
||
// BEFORE the JE below so a rejected payment never burns a voucher number.
|
||
// Settle the full outstanding balance. Prefer remaining_amount; for legacy rows
|
||
// where it was never written, derive it from total − paid_amount rather than
|
||
// falling back to the full total (which would double-count a prior partial
|
||
// payment and trip the overpayment guard).
|
||
const inv = invoice as { remaining_amount?: number | null; paid_amount?: number | null }
|
||
const paymentAmount = inv.remaining_amount ?? (invoice.total - (inv.paid_amount ?? 0))
|
||
const payment = planInvoicePayment(invoice, paymentAmount)
|
||
if (!payment.ok) {
|
||
return {
|
||
error:
|
||
getErrorEntry('MATCH_AMOUNT_EXCEEDS_REMAINING')?.message_sv ??
|
||
'Betalningsbeloppet är större än fakturans återstående belopp.',
|
||
status: 400,
|
||
}
|
||
}
|
||
const { newPaidAmount, newRemaining, newStatus } = payment.plan
|
||
|
||
if (isRealInvoice) {
|
||
if (useCashEntry) {
|
||
const je = await createInvoiceCashEntry(
|
||
supabase, companyId, userId, invoice as Invoice, paymentDate, entityType, invoice.customer?.name
|
||
)
|
||
journalEntryId = je?.id ?? null
|
||
} else {
|
||
const je = await createInvoicePaymentJournalEntry(
|
||
supabase, companyId, userId, invoice as Invoice, paymentDate, undefined, invoice.customer?.name
|
||
)
|
||
journalEntryId = je?.id ?? null
|
||
}
|
||
|
||
// Fail closed: a real invoice must produce a posted payment voucher.
|
||
// Marking it paid with no journal entry orphans the receivable and
|
||
// diverges the GL from the AR sub-ledger. Nothing was posted (the helper
|
||
// returned null), so there is no voucher to cancel.
|
||
if (!journalEntryId) {
|
||
return {
|
||
error:
|
||
'Betalningen kunde inte bokföras (ingen verifikation skapades: t.ex. stängd räkenskapsperiod). ' +
|
||
'Fakturan har inte markerats som betald.',
|
||
status: 422,
|
||
}
|
||
}
|
||
}
|
||
|
||
const now = new Date().toISOString()
|
||
// CAS guard: only flip from a payable status so a concurrently-settled
|
||
// invoice no-ops here instead of double-booking the payment.
|
||
const { data: updateResult, error: updateError } = await supabase
|
||
.from('invoices')
|
||
.update({
|
||
status: newStatus,
|
||
paid_amount: newPaidAmount,
|
||
remaining_amount: newRemaining,
|
||
...(newStatus === 'paid' ? { paid_at: now } : {}),
|
||
})
|
||
.eq('id', invoiceId)
|
||
.eq('company_id', companyId)
|
||
.in('status', ['sent', 'overdue', 'partially_paid'])
|
||
.select('id')
|
||
|
||
if (updateError) {
|
||
// The payment voucher already posted but the invoice row did not flip;
|
||
// cancel the orphan so the GL doesn't diverge from the sub-ledger.
|
||
if (journalEntryId) {
|
||
await cancelOrphanedPaymentEntry(
|
||
supabase, companyId, userId, journalEntryId,
|
||
'Automatiskt makulerad: fakturauppdatering misslyckades efter bokförd betalning',
|
||
)
|
||
}
|
||
return { error: 'Failed to update invoice status', status: 500 }
|
||
}
|
||
|
||
if (!updateResult || updateResult.length === 0) {
|
||
// Race lost: the invoice was settled concurrently between our read and
|
||
// write. Cancel the orphaned payment voucher and document the gap rather
|
||
// than leaving a double booking.
|
||
if (journalEntryId) {
|
||
await cancelOrphanedPaymentEntry(
|
||
supabase, companyId, userId, journalEntryId,
|
||
'Automatiskt makulerad: dubblettbokning förhindrad av samtidighetsskydd',
|
||
)
|
||
}
|
||
return {
|
||
error: 'Invoice can only be marked as paid from a payable status (sent, overdue or partially paid)',
|
||
status: 409,
|
||
}
|
||
}
|
||
|
||
// Notify subscribers: invoice.paid fans out to registered webhooks
|
||
// (lib/webhooks/handler.ts). Best-effort: the payment is already committed,
|
||
// so an emit failure must not fail the operation. Parity with the v1 and
|
||
// dashboard mark-paid routes, which previously emitted while this path did not.
|
||
try {
|
||
await eventBus.emit({
|
||
type: 'invoice.paid',
|
||
payload: {
|
||
invoice: {
|
||
...(invoice as Invoice),
|
||
status: newStatus,
|
||
paid_amount: newPaidAmount,
|
||
remaining_amount: newRemaining,
|
||
paid_at: newStatus === 'paid' ? now : (invoice as Invoice).paid_at,
|
||
} as Invoice,
|
||
companyId,
|
||
userId,
|
||
paymentAmount,
|
||
paymentDate,
|
||
},
|
||
})
|
||
} catch (err) {
|
||
log.warn('invoice.paid emit failed', err)
|
||
}
|
||
|
||
return { data: { status: newStatus, remaining_amount: newRemaining, 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 }
|
||
// partially_paid/credited imply the invoice was already issued too: the
|
||
// status flip below would regress them to 'sent' (PR #666 review, ASVS V2.3).
|
||
if (['sent', 'paid', 'overdue', 'partially_paid', 'credited'].includes(invoice.status)) {
|
||
return { error: 'Invoice has already been sent', status: 409 }
|
||
}
|
||
// A cancelled invoice keeps its F-series number for ML 17 kap 24§ compliance
|
||
// but is not a valid faktura: sending it would silently re-activate it (the
|
||
// status flip below has no guard) and deliver a "MAKULERAD" PDF as if live.
|
||
// Mirrors the send route's guard (audit C17, this agent path lacked it).
|
||
if (invoice.status === 'cancelled') {
|
||
return {
|
||
error:
|
||
getErrorEntry('INVOICE_SEND_CANCELLED')?.message_sv ??
|
||
'Makulerade fakturor kan inte skickas. Skapa en ny faktura istället.',
|
||
status: 400,
|
||
}
|
||
}
|
||
|
||
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 }
|
||
|
||
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
|
||
}
|
||
|
||
// Preflight render: validate the PDF pipeline BEFORE consuming an F-series
|
||
// number, so a render failure can't leave a numbered-but-never-issued
|
||
// invoice (an F-series gap if the draft is later abandoned). Skipped when
|
||
// the row is already numbered (retry path): we'd render twice for no gain.
|
||
// Mirrors the send route (audit C17, this agent path assigned the number
|
||
// first and rendered unguarded).
|
||
const isFreshAllocation = !invoice.invoice_number
|
||
if (isFreshAllocation) {
|
||
try {
|
||
const preflight = await prepareInvoicePdfRender(company as CompanySettings)
|
||
await renderToBuffer(
|
||
InvoicePDF({
|
||
invoice: { ...(invoice as Invoice), invoice_number: 'F-PREVIEW' },
|
||
customer,
|
||
items,
|
||
company: preflight.company,
|
||
originalInvoiceNumber,
|
||
branding: preflight.branding,
|
||
})
|
||
)
|
||
} catch (err) {
|
||
log.error('preflight PDF render failed before invoice number assignment (agent send)', err as Error, {
|
||
companyId,
|
||
userId,
|
||
invoiceId,
|
||
})
|
||
return {
|
||
error:
|
||
getErrorEntry('INVOICE_SEND_PDF_RENDER_FAILED')?.message_sv ??
|
||
'Fakturans PDF kunde inte skapas. Kontrollera fakturarader och kunduppgifter och försök igen.',
|
||
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 }
|
||
}
|
||
|
||
// Override `status` to 'sent' on the in-memory copy. The DB flip happens
|
||
// after email delivery (line ~625); rendering with the stale 'draft' status
|
||
// would stamp the customer's PDF with "UTKAST: inte en giltig faktura".
|
||
const renderableInvoice = { ...(invoice as Invoice), status: 'sent' as const }
|
||
const { branding, company: renderCompany } = await prepareInvoicePdfRender(
|
||
company as CompanySettings,
|
||
)
|
||
const swishQrDataUrl = await buildSwishQrDataUrl(company as CompanySettings, renderableInvoice)
|
||
const pdfBuffer = await renderToBuffer(
|
||
InvoicePDF({
|
||
invoice: renderableInvoice,
|
||
customer,
|
||
items,
|
||
company: renderCompany,
|
||
originalInvoiceNumber,
|
||
branding,
|
||
swishQrDataUrl,
|
||
})
|
||
)
|
||
|
||
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.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 }
|
||
}
|
||
|
||
// Overshoot guard + paid/remaining math: shared with the dashboard and v1
|
||
// routes via planInvoicePayment. This agent/MCP path previously had NO guard,
|
||
// so a 1500 payment on a 1000 invoice was silently accepted (paid_amount >
|
||
// total, AR over-credited). Runs BEFORE the storno + JE below, so a rejected
|
||
// match leaves the transaction untouched and never burns a voucher number.
|
||
const paidAmount = transaction.amount
|
||
const payment = planInvoicePayment(invoice, paidAmount)
|
||
if (!payment.ok) {
|
||
return {
|
||
error:
|
||
getErrorEntry('MATCH_AMOUNT_EXCEEDS_REMAINING')?.message_sv ??
|
||
'Transaktionsbeloppet är större än fakturans återstående belopp.',
|
||
status: 400,
|
||
}
|
||
}
|
||
const { newPaidAmount, newRemaining, isFullyPaid, newStatus } = payment.plan
|
||
|
||
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 { 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'
|
||
|
||
// Route on invoice state, not the company's current setting. Mirror of
|
||
// the match-invoice route fix: see that handler for the full rationale.
|
||
const invoiceAlreadyBooked = !!(invoice as { journal_entry_id?: string | null }).journal_entry_id
|
||
const useCashEntry = !invoiceAlreadyBooked && accountingMethod === 'cash' && isFullyPaid
|
||
|
||
// Debit the cash account THIS transaction actually belongs to, never a
|
||
// hardcoded 1930: cash_account_id -> cash_accounts.ledger_account is the
|
||
// only source of truth for which bank/cash account a real, matched
|
||
// transaction settled into. Mirrors the match-invoice route fix.
|
||
const paymentAccount = await resolveSettlementAccount(supabase, companyId, transaction.cash_account_id, log)
|
||
|
||
let journalEntryId: string | null = null
|
||
try {
|
||
if (useCashEntry) {
|
||
const je = await createInvoiceCashEntry(
|
||
supabase, companyId, userId, invoice as Invoice, transaction.date, entityType, invoice.customer?.name,
|
||
paymentAccount,
|
||
)
|
||
journalEntryId = je?.id ?? null
|
||
} else {
|
||
const je = await createInvoicePaymentJournalEntry(
|
||
supabase, companyId, userId, invoice as Invoice, transaction.date, undefined, invoice.customer?.name, paidAmount,
|
||
paymentAccount,
|
||
)
|
||
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 } }
|
||
}
|
||
|
||
async function commitLinkInvoiceVoucher(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
params: Record<string, unknown>
|
||
): Promise<ExecutorResult> {
|
||
const invoiceId = params.invoice_id as string | undefined
|
||
const journalEntryId = params.journal_entry_id as string | undefined
|
||
const notes = (params.notes as string | undefined) ?? undefined
|
||
|
||
if (!invoiceId || !journalEntryId) {
|
||
return { error: 'invoice_id and journal_entry_id are required', status: 400 }
|
||
}
|
||
|
||
const outcome = await linkInvoiceToVoucher(supabase, userId, companyId, {
|
||
invoiceId,
|
||
journalEntryId,
|
||
notes,
|
||
})
|
||
|
||
if (!outcome.ok) {
|
||
const entry = getErrorEntry(outcome.code)
|
||
const httpStatus = entry?.httpStatus ?? 500
|
||
// 404/409 are auto-rejected by the dispatcher (the user can re-stage with
|
||
// adjusted inputs); 400 surfaces as a normal failure so the UI can
|
||
// explain what went wrong.
|
||
return {
|
||
error: entry?.message_en ?? outcome.code,
|
||
status: httpStatus,
|
||
}
|
||
}
|
||
|
||
return {
|
||
data: {
|
||
invoice_status: outcome.result.invoiceStatus,
|
||
paid_amount: outcome.result.paidAmount,
|
||
remaining_amount: outcome.result.remainingAmount,
|
||
payment_amount: outcome.result.paymentAmount,
|
||
payment_id: outcome.result.paymentId,
|
||
journal_entry_id: outcome.result.journalEntryId,
|
||
reconciled_transaction_id: outcome.result.reconciledTransactionId,
|
||
},
|
||
}
|
||
}
|
||
|
||
async function commitLinkSupplierInvoiceVoucher(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
params: Record<string, unknown>
|
||
): Promise<ExecutorResult> {
|
||
const supplierInvoiceId = params.supplier_invoice_id as string | undefined
|
||
const journalEntryId = params.journal_entry_id as string | undefined
|
||
const notes = (params.notes as string | undefined) ?? undefined
|
||
|
||
if (!supplierInvoiceId || !journalEntryId) {
|
||
return { error: 'supplier_invoice_id and journal_entry_id are required', status: 400 }
|
||
}
|
||
|
||
const outcome = await linkSupplierInvoiceToVoucher(supabase, userId, companyId, {
|
||
supplierInvoiceId,
|
||
journalEntryId,
|
||
notes,
|
||
})
|
||
|
||
if (!outcome.ok) {
|
||
const entry = getErrorEntry(outcome.code)
|
||
// 404/409 are auto-rejected by the dispatcher (the user can re-stage with
|
||
// adjusted inputs); 400 surfaces as a normal failure so the UI can explain.
|
||
return {
|
||
error: entry?.message_en ?? outcome.code,
|
||
status: entry?.httpStatus ?? 500,
|
||
}
|
||
}
|
||
|
||
return {
|
||
data: {
|
||
invoice_status: outcome.result.invoiceStatus,
|
||
paid_amount: outcome.result.paidAmount,
|
||
remaining_amount: outcome.result.remainingAmount,
|
||
payment_amount: outcome.result.paymentAmount,
|
||
payment_id: outcome.result.paymentId,
|
||
journal_entry_id: outcome.result.journalEntryId,
|
||
reconciled_transaction_id: outcome.result.reconciledTransactionId,
|
||
},
|
||
}
|
||
}
|
||
|
||
// ── 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, journal_entry_id')
|
||
.eq('id', documentId)
|
||
.eq('company_id', companyId)
|
||
.maybeSingle()
|
||
if (docError || !doc) return { error: 'Document not found', status: 404 }
|
||
|
||
// A document that already serves as underlag for a DIFFERENT verifikation
|
||
// cannot be pinned here: propagating would either corrupt that link or be
|
||
// blocked by the document-metadata immutability trigger. Same verifikation
|
||
// is fine (idempotent re-attach; propagation below becomes a no-op).
|
||
// Mirrors the REST route in app/api/transactions/[id]/attach-document.
|
||
const docJournalEntryId = (doc.journal_entry_id as string | null) ?? null
|
||
if (docJournalEntryId && docJournalEntryId !== tx.journal_entry_id) {
|
||
return {
|
||
error: 'Underlaget är redan kopplat till en annan verifikation.',
|
||
status: 409,
|
||
}
|
||
}
|
||
|
||
// 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 }
|
||
|
||
// If the attached doc came from an invoice_inbox_items row, mark that row
|
||
// as matched so the inbox UI shows "Kopplad till transaktion". Best-effort:
|
||
// a failure must not roll back the (compliant) attach. Mirrors the REST
|
||
// route in app/api/transactions/[id]/attach-document/route.ts so MCP-staged
|
||
// and REST attaches converge on the same inbox state.
|
||
//
|
||
// The Supabase client resolves with { error } rather than rejecting on
|
||
// RLS/DB errors, so we destructure rather than try/catch.
|
||
const { error: inboxLinkErr } = await supabase
|
||
.from('invoice_inbox_items')
|
||
.update({ matched_transaction_id: txId })
|
||
.eq('document_id', documentId)
|
||
.eq('company_id', companyId)
|
||
.is('matched_transaction_id', null)
|
||
.is('created_supplier_invoice_id', null)
|
||
if (inboxLinkErr) {
|
||
console.error('[commitAttach] Failed to link inbox item:', inboxLinkErr)
|
||
}
|
||
|
||
const journalEntryId = postUpdate.journal_entry_id as string | null
|
||
// Skip when the doc already points at this verifikation: the period-lock
|
||
// trigger raises on ANY journal_entry_id write (even a same-value rewrite),
|
||
// so an unconditional re-run would 500 an otherwise idempotent re-attach
|
||
// once the period locks.
|
||
if (journalEntryId && docJournalEntryId !== journalEntryId) {
|
||
const { error: linkErr } = await supabase
|
||
.from('document_attachments')
|
||
.update({ journal_entry_id: journalEntryId })
|
||
.eq('id', documentId)
|
||
.eq('company_id', companyId)
|
||
if (linkErr) {
|
||
// The enforce_period_lock trigger blocks journal_entry_id writes when
|
||
// the target entry sits in a closed/locked period. Map to 409: the
|
||
// dispatcher auto-rejects it, and a retry could never succeed until the
|
||
// period is unlocked, so "försök igen" would be a false promise.
|
||
const linkMsg = (linkErr as { message?: string }).message ?? ''
|
||
if (/locked\/closed fiscal period|Bokföringen är låst/i.test(linkMsg)) {
|
||
return {
|
||
error:
|
||
'Bilagan kopplades till transaktionen men verifikationens period är låst: den kunde inte länkas till verifikationen.',
|
||
status: 409,
|
||
}
|
||
}
|
||
// 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 commitLinkDocumentToVoucher(
|
||
supabase: SupabaseClient,
|
||
_userId: string,
|
||
companyId: string,
|
||
params: Record<string, unknown>
|
||
): Promise<ExecutorResult> {
|
||
const documentId = params.document_id as string
|
||
const journalEntryId = params.journal_entry_id as string
|
||
const journalEntryLineId = params.journal_entry_line_id as string | undefined
|
||
if (!documentId || !journalEntryId) {
|
||
return { error: 'document_id and journal_entry_id are required', status: 400 }
|
||
}
|
||
|
||
const { data: doc, error: docError } = await supabase
|
||
.from('document_attachments')
|
||
.select('id, file_name, journal_entry_id')
|
||
.eq('id', documentId)
|
||
.eq('company_id', companyId)
|
||
.maybeSingle()
|
||
if (docError || !doc) return { error: 'Document not found', status: 404 }
|
||
|
||
// WORM guard: refuse to re-link a doc already linked to a DIFFERENT posted JE.
|
||
const existingJeId = (doc.journal_entry_id as string | null) ?? null
|
||
if (existingJeId && existingJeId !== journalEntryId) {
|
||
const { data: existingJe } = await supabase
|
||
.from('journal_entries')
|
||
.select('status')
|
||
.eq('id', existingJeId)
|
||
.eq('company_id', companyId)
|
||
.maybeSingle()
|
||
if (existingJe && (existingJe as { status: string }).status === 'posted') {
|
||
return {
|
||
error:
|
||
'Bilagan är kopplad till en bokförd verifikation och kan inte länkas om. Ladda upp ett nytt dokument.',
|
||
status: 409,
|
||
}
|
||
}
|
||
}
|
||
|
||
try {
|
||
const updated = await linkToJournalEntry(
|
||
supabase,
|
||
companyId,
|
||
documentId,
|
||
journalEntryId,
|
||
journalEntryLineId,
|
||
)
|
||
return {
|
||
data: {
|
||
document_id: updated.id,
|
||
file_name: updated.file_name,
|
||
journal_entry_id: updated.journal_entry_id,
|
||
journal_entry_line_id: updated.journal_entry_line_id ?? null,
|
||
},
|
||
}
|
||
} catch (err) {
|
||
const msg = (err as Error).message ?? ''
|
||
if (/locked\/closed fiscal period|Bokföringen är låst/i.test(msg)) {
|
||
return {
|
||
error: 'Verifikationens period är låst: bilagan kan inte länkas.',
|
||
status: 409,
|
||
}
|
||
}
|
||
return { error: `Failed to link document: ${msg}`, status: 500 }
|
||
}
|
||
}
|
||
|
||
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 commitPostAnnualDepreciation(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
params: Record<string, unknown>
|
||
): Promise<ExecutorResult> {
|
||
const fiscalPeriodId = params.fiscal_period_id as string
|
||
if (!fiscalPeriodId) return { error: 'fiscal_period_id is required', status: 400 }
|
||
const assetIds = Array.isArray(params.asset_ids) ? (params.asset_ids as string[]) : undefined
|
||
|
||
try {
|
||
const { commitAnnualPostings } = await import('@/lib/bokslut/assets/depreciation-engine')
|
||
const { posted, skipped } = await commitAnnualPostings(supabase, companyId, userId, fiscalPeriodId, {
|
||
assetIds,
|
||
})
|
||
return {
|
||
data: {
|
||
posted_count: posted.length,
|
||
skipped_count: skipped.length,
|
||
posted: posted.map((p) => ({
|
||
asset_id: p.assetId,
|
||
journal_entry_id: p.entry.id,
|
||
voucher_number: p.entry.voucher_number,
|
||
schedule_id: p.scheduleId,
|
||
})),
|
||
skipped,
|
||
},
|
||
}
|
||
} catch (err) {
|
||
if (isBookkeepingError(err)) throw err
|
||
return { error: err instanceof Error ? err.message : 'Depreciation posting 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 commitCreateSupplierInvoiceFromInbox(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
params: Record<string, unknown>
|
||
): Promise<ExecutorResult> {
|
||
const inboxItemId = params.inbox_item_id as string
|
||
const supplierId = params.supplier_id as string
|
||
const documentId = (params.document_id as string | null) ?? null
|
||
const supplierInvoiceNumber = params.supplier_invoice_number as string
|
||
const invoiceDate = params.invoice_date as string
|
||
const dueDate = (params.due_date as string | null) ?? null
|
||
const currency = (params.currency as string) || 'SEK'
|
||
const vatTreatment = (params.vat_treatment as string) || 'standard_25'
|
||
const notes = (params.notes as string | null) ?? null
|
||
const rawItems = (params.items as Array<Record<string, unknown>> | undefined) ?? []
|
||
// Dimensions PR7: resolved at staging time; coerce is the drift/tamper gate.
|
||
const defaultDimensions = coerceDimensionsBag(params.default_dimensions)
|
||
|
||
if (!inboxItemId || !supplierId || !supplierInvoiceNumber || !invoiceDate || rawItems.length === 0) {
|
||
return {
|
||
error: 'inbox_item_id, supplier_id, supplier_invoice_number, invoice_date, and items are required',
|
||
status: 400,
|
||
}
|
||
}
|
||
|
||
// Reject tampered financial fields: Number(x) || 0 silently turns string
|
||
// junk and undefined into a zero-value invoice. Require a finite number on
|
||
// every monetary field, including the optional exchange_rate when present.
|
||
const finite = (raw: unknown): number | null =>
|
||
typeof raw === 'number' && Number.isFinite(raw) ? raw : null
|
||
const subtotal = finite(params.subtotal)
|
||
const vatAmount = finite(params.vat_amount)
|
||
const total = finite(params.total)
|
||
if (subtotal === null || vatAmount === null || total === null) {
|
||
return {
|
||
error: 'subtotal, vat_amount, and total must be finite numbers',
|
||
status: 400,
|
||
}
|
||
}
|
||
const exchangeRate = params.exchange_rate === null || params.exchange_rate === undefined
|
||
? null
|
||
: finite(params.exchange_rate)
|
||
if (params.exchange_rate !== null && params.exchange_rate !== undefined && exchangeRate === null) {
|
||
return { error: 'exchange_rate must be a finite number when provided', status: 400 }
|
||
}
|
||
|
||
// Idempotency: a re-fired commit (e.g. retry, double-click on the approval
|
||
// UI, racy MCP call) must not create a second leverantörsfaktura for the
|
||
// same inbox row. The DB FK on invoice_inbox_items.created_supplier_invoice_id
|
||
// is the source of truth.
|
||
const { data: inbox, error: inboxErr } = await supabase
|
||
.from('invoice_inbox_items')
|
||
.select('id, created_supplier_invoice_id, status')
|
||
.eq('id', inboxItemId)
|
||
.eq('company_id', companyId)
|
||
.single()
|
||
|
||
if (inboxErr || !inbox) return { error: 'Inbox item not found', status: 404 }
|
||
if (inbox.created_supplier_invoice_id) {
|
||
return {
|
||
data: {
|
||
supplier_invoice_id: inbox.created_supplier_invoice_id,
|
||
inbox_item_id: inboxItemId,
|
||
idempotent: true,
|
||
},
|
||
}
|
||
}
|
||
|
||
// Defense in depth: the staging-time supplier lookup may be stale by the
|
||
// time the human approves. RLS would block a cross-company supplier too,
|
||
// but a 404 here is a cleaner error than an RLS denial later.
|
||
const { data: supplier, error: supplierErr } = await supabase
|
||
.from('suppliers')
|
||
.select('id, name, supplier_type')
|
||
.eq('id', supplierId)
|
||
.eq('company_id', companyId)
|
||
.single()
|
||
|
||
if (supplierErr || !supplier) return { error: 'Supplier not found', status: 404 }
|
||
|
||
const { data: arrivalNum, error: arrivalErr } = await supabase
|
||
.rpc('get_next_arrival_number', { p_company_id: companyId })
|
||
|
||
if (arrivalErr) {
|
||
return { error: `Failed to generate arrival number: ${arrivalErr.message}`, status: 500 }
|
||
}
|
||
|
||
const reverseCharge = vatTreatment === 'reverse_charge'
|
||
const subtotalRounded = Math.round(subtotal * 100) / 100
|
||
const vatAmountRounded = Math.round(vatAmount * 100) / 100
|
||
const totalRounded = Math.round(total * 100) / 100
|
||
const subtotalSek = exchangeRate ? Math.round(subtotal * exchangeRate * 100) / 100 : null
|
||
const vatAmountSek = exchangeRate ? Math.round(vatAmount * exchangeRate * 100) / 100 : null
|
||
const totalSek = exchangeRate ? Math.round(total * exchangeRate * 100) / 100 : null
|
||
|
||
const { data: invoice, error: invoiceErr } = await supabase
|
||
.from('supplier_invoices')
|
||
.insert({
|
||
user_id: userId,
|
||
company_id: companyId,
|
||
supplier_id: supplierId,
|
||
arrival_number: arrivalNum,
|
||
supplier_invoice_number: supplierInvoiceNumber,
|
||
invoice_date: invoiceDate,
|
||
due_date: dueDate,
|
||
status: 'registered',
|
||
currency,
|
||
exchange_rate: exchangeRate,
|
||
vat_treatment: vatTreatment,
|
||
reverse_charge: reverseCharge,
|
||
paid_with_private_funds: false,
|
||
subtotal: subtotalRounded,
|
||
subtotal_sek: subtotalSek,
|
||
vat_amount: vatAmountRounded,
|
||
vat_amount_sek: vatAmountSek,
|
||
total: totalRounded,
|
||
total_sek: totalSek,
|
||
paid_amount: 0,
|
||
remaining_amount: totalRounded,
|
||
document_id: documentId,
|
||
notes,
|
||
default_dimensions: defaultDimensions ?? {},
|
||
})
|
||
.select()
|
||
.single()
|
||
|
||
if (invoiceErr || !invoice) {
|
||
const pgErr = invoiceErr as { code?: string; message?: string } | null
|
||
const isDuplicate = pgErr?.code === '23505'
|
||
if (isDuplicate) {
|
||
// Generic 409: supplier_invoice_number alone is already in the staged
|
||
// params the caller submitted; we just don't echo back the supplier's
|
||
// name or row id. The UI surface uses the supplier-side ledger, not
|
||
// this error.
|
||
log.warn('Duplicate supplier invoice number on inbox conversion', {
|
||
companyId,
|
||
supplierId,
|
||
supplierInvoiceNumber,
|
||
})
|
||
return {
|
||
error: `Leverantörsfaktura ${supplierInvoiceNumber} finns redan registrerad.`,
|
||
status: 409,
|
||
}
|
||
}
|
||
log.error('Failed to insert supplier invoice from inbox', {
|
||
companyId,
|
||
inboxItemId,
|
||
supplierId,
|
||
error: pgErr?.message ?? 'unknown',
|
||
})
|
||
return { error: 'Failed to create supplier invoice', status: 500 }
|
||
}
|
||
|
||
// RC invariant: a reverse-charge supplier invoice never shows output VAT
|
||
// from the supplier. Zero any per-line VAT that slipped through staging so
|
||
// the registration JE's 2614/2645 self-assessed leg lines up with rutor
|
||
// 20-24 / 48 instead of double-counting input VAT into 2641. Tampered
|
||
// params can't smuggle non-zero VAT into the items table.
|
||
const itemInserts = rawItems.map((item, idx) => {
|
||
const vatRate = reverseCharge ? 0 : (typeof item.vat_rate === 'number' && Number.isFinite(item.vat_rate) ? item.vat_rate : 0)
|
||
const vatAmt = reverseCharge ? 0 : (typeof item.vat_amount === 'number' && Number.isFinite(item.vat_amount) ? item.vat_amount : 0)
|
||
return {
|
||
supplier_invoice_id: invoice.id,
|
||
sort_order: idx,
|
||
description: String(item.description ?? `Position ${idx + 1}`),
|
||
quantity: typeof item.quantity === 'number' && Number.isFinite(item.quantity) ? item.quantity : 1,
|
||
unit: (item.unit as string | undefined) ?? 'st',
|
||
unit_price: typeof item.unit_price === 'number' && Number.isFinite(item.unit_price) ? item.unit_price : 0,
|
||
line_total: typeof item.line_total === 'number' && Number.isFinite(item.line_total) ? item.line_total : 0,
|
||
account_number: String(item.account_number ?? '4000'),
|
||
vat_code: null,
|
||
vat_rate: vatRate,
|
||
vat_amount: vatAmt,
|
||
// For reverse charge the buyer self-assesses VAT; carry an explicit
|
||
// statutory rate when staged, else null (engine defaults to 25%).
|
||
reverse_charge_rate: reverseCharge
|
||
? ([0.06, 0.12, 0.25].includes(Number(item.reverse_charge_rate)) ? Number(item.reverse_charge_rate) : null)
|
||
: null,
|
||
dimensions: coerceDimensionsBag(item.dimensions) ?? {},
|
||
}
|
||
})
|
||
|
||
const { error: itemsErr } = await supabase
|
||
.from('supplier_invoice_items')
|
||
.insert(itemInserts)
|
||
|
||
if (itemsErr) {
|
||
// Roll back the parent to avoid orphan supplier_invoices rows. Without
|
||
// line items the registration JE can't be built and the invoice would
|
||
// be invisible in the supplier ledger anyway.
|
||
await supabase.from('supplier_invoices').delete().eq('id', invoice.id).eq('company_id', companyId)
|
||
log.error('Failed to insert supplier invoice items, rolled back parent', {
|
||
companyId,
|
||
invoiceId: invoice.id,
|
||
error: itemsErr.message,
|
||
})
|
||
return { error: 'Failed to insert supplier invoice items', status: 500 }
|
||
}
|
||
|
||
const { data: settings } = await supabase
|
||
.from('company_settings')
|
||
.select('accounting_method')
|
||
.eq('company_id', companyId)
|
||
.single()
|
||
|
||
const accountingMethod = (settings?.accounting_method as AccountingMethod) || 'accrual'
|
||
let registrationJournalEntryId: string | null = null
|
||
|
||
if (accountingMethod === 'accrual') {
|
||
try {
|
||
const journalEntry = await createSupplierInvoiceRegistrationEntry(
|
||
supabase,
|
||
companyId,
|
||
userId,
|
||
invoice as SupplierInvoice,
|
||
itemInserts as unknown as SupplierInvoiceItem[],
|
||
supplier.supplier_type,
|
||
supplier.name,
|
||
)
|
||
|
||
if (journalEntry) {
|
||
registrationJournalEntryId = journalEntry.id
|
||
await supabase
|
||
.from('supplier_invoices')
|
||
.update({ registration_journal_entry_id: journalEntry.id })
|
||
.eq('id', invoice.id)
|
||
|
||
// Attach the OCR'd source document to the verifikat so the
|
||
// registration JE has its underlag per BFL 5 kap 6 §. Linking failure
|
||
// is non-fatal: the JE is already posted and immutable; we log and
|
||
// continue so the supplier invoice stays usable.
|
||
if (documentId) {
|
||
try {
|
||
await linkToJournalEntry(supabase, companyId, documentId, journalEntry.id)
|
||
} catch (linkErr) {
|
||
log.warn('Failed to link inbox document to registration JE', {
|
||
documentId,
|
||
journalEntryId: journalEntry.id,
|
||
error: linkErr instanceof Error ? linkErr.message : String(linkErr),
|
||
})
|
||
}
|
||
}
|
||
} else {
|
||
// createSupplierInvoiceRegistrationEntry returns null ONLY when no
|
||
// fiscal period covers invoice_date (every other failure throws into
|
||
// the catch below). Without this branch the inbox item gets linked to
|
||
// an unbooked supplier invoice: the same 2440/2641 orphan the catch
|
||
// guards against. Roll back (items first, see FK note below) and return
|
||
// an actionable error instead of silently "succeeding".
|
||
await supabase
|
||
.from('supplier_invoice_items')
|
||
.delete()
|
||
.eq('supplier_invoice_id', invoice.id)
|
||
await supabase
|
||
.from('supplier_invoices')
|
||
.delete()
|
||
.eq('id', invoice.id)
|
||
.eq('company_id', companyId)
|
||
return {
|
||
error:
|
||
'Det finns inget räkenskapsår som täcker fakturadatumet. Lägg upp räkenskapsåret först, eller ändra fakturadatumet.',
|
||
status: 400,
|
||
}
|
||
}
|
||
} catch (err) {
|
||
// Roll back: orphan supplier_invoices row without its registration JE
|
||
// understates leverantörsskuld (2440) + ingående moms (2641) on the
|
||
// momsdeklaration. Items must be deleted BEFORE the parent: the FK
|
||
// on supplier_invoice_items.supplier_invoice_id is ON DELETE NO ACTION
|
||
// (default), so a parent-first delete would be silently blocked and
|
||
// leave the doomed invoice in the supplier ledger.
|
||
await supabase
|
||
.from('supplier_invoice_items')
|
||
.delete()
|
||
.eq('supplier_invoice_id', invoice.id)
|
||
const { error: parentDeleteErr } = await supabase
|
||
.from('supplier_invoices')
|
||
.delete()
|
||
.eq('id', invoice.id)
|
||
.eq('company_id', companyId)
|
||
if (parentDeleteErr) {
|
||
// Hard inconsistency: items gone but parent stuck. Log loudly so an
|
||
// operator can clean up; this should not happen in practice.
|
||
log.error('Rollback partial: parent supplier_invoices delete failed after JE failure', {
|
||
companyId,
|
||
invoiceId: invoice.id,
|
||
parentDeleteError: parentDeleteErr.message,
|
||
originalError: err instanceof Error ? err.message : String(err),
|
||
})
|
||
}
|
||
if (isBookkeepingError(err)) throw err
|
||
log.error('Failed to create registration journal entry; supplier invoice rolled back', {
|
||
companyId,
|
||
inboxItemId,
|
||
invoiceId: invoice.id,
|
||
error: err instanceof Error ? err.message : 'unknown',
|
||
})
|
||
return {
|
||
error: 'Failed to create registration journal entry',
|
||
status: 500,
|
||
}
|
||
}
|
||
}
|
||
|
||
// Terminal state for the inbox row: created_supplier_invoice_id is the
|
||
// dedup key for next time this inbox item is touched, and it's what the UI
|
||
// and list_unmatched_documents use to drop the row out of "needs action".
|
||
// Do NOT write status here: the status CHECK only allows received|error
|
||
// (migration 20260504180000); writing 'confirmed' makes Postgres reject the
|
||
// whole UPDATE, so the link column never lands and the item stays unresolved.
|
||
const { error: linkInboxErr } = await supabase
|
||
.from('invoice_inbox_items')
|
||
.update({ created_supplier_invoice_id: invoice.id })
|
||
.eq('id', inboxItemId)
|
||
.eq('company_id', companyId)
|
||
|
||
if (linkInboxErr) {
|
||
log.warn('Failed to link inbox item to new supplier invoice (invoice still created)', {
|
||
inboxItemId,
|
||
supplierInvoiceId: invoice.id,
|
||
error: linkInboxErr.message,
|
||
})
|
||
}
|
||
|
||
try {
|
||
await eventBus.emit({
|
||
type: 'supplier_invoice.registered',
|
||
payload: { supplierInvoice: invoice as SupplierInvoice, companyId, userId },
|
||
})
|
||
} catch { /* non-blocking */ }
|
||
|
||
return {
|
||
data: {
|
||
supplier_invoice_id: invoice.id,
|
||
inbox_item_id: inboxItemId,
|
||
registration_journal_entry_id: registrationJournalEntryId,
|
||
arrival_number: arrivalNum,
|
||
},
|
||
}
|
||
}
|
||
|
||
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,
|
||
// Dimensions PR7: copy so the reversal nets against the same cells.
|
||
default_dimensions: original.default_dimensions ?? {},
|
||
})
|
||
.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,
|
||
dimensions: item.dimensions ?? {},
|
||
}))
|
||
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,
|
||
// Dimensions PR7: copy so the reversal nets against the same cells.
|
||
default_dimensions: original.default_dimensions ?? {},
|
||
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
|
||
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
|
||
dimensions?: Record<string, string>
|
||
}) => ({
|
||
invoice_id: creditNote.id,
|
||
sort_order: item.sort_order,
|
||
line_type: item.line_type ?? 'product',
|
||
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),
|
||
// Reverse to the SAME account the original credited (e.g. 3041, not the
|
||
// VAT-derived 3001) so the override account doesn't keep a dangling balance.
|
||
revenue_account: item.revenue_account ?? null,
|
||
article_id: item.article_id ?? null,
|
||
// Same reasoning for the per-item bag (dimensions PR7).
|
||
dimensions: item.dimensions ?? {},
|
||
}))
|
||
|
||
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,
|
||
// Dimensions PR7: the converted invoice books with the proforma's bag.
|
||
default_dimensions: proforma.default_dimensions ?? {},
|
||
})
|
||
.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,
|
||
line_type: item.line_type ?? 'product',
|
||
description: item.description,
|
||
quantity: item.quantity,
|
||
unit: item.unit,
|
||
unit_price: item.unit_price,
|
||
line_total: item.line_total,
|
||
// Preserve per-line VAT and any article/revenue-account override from the
|
||
// proforma so the converted invoice books exactly as the proforma showed
|
||
// (mixed rates + per-article accounts both rely on these per-line fields).
|
||
vat_rate: item.vat_rate ?? 0,
|
||
vat_amount: item.vat_amount ?? 0,
|
||
revenue_account: item.revenue_account ?? null,
|
||
article_id: item.article_id ?? null,
|
||
dimensions: item.dimensions ?? {},
|
||
}))
|
||
|
||
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
|
||
// Default true (not Boolean(...): operations staged before this param
|
||
// existed must keep the file's account names, matching the UI default).
|
||
const updateAccountNames =
|
||
params.update_account_names === undefined ? true : Boolean(params.update_account_names)
|
||
|
||
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,
|
||
updateAccountNames,
|
||
})
|
||
|
||
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 }
|
||
}
|
||
}
|
||
|
||
async function commitUndoSieImport(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
params: Record<string, unknown>,
|
||
): Promise<ExecutorResult> {
|
||
const importId = params.import_id as string
|
||
|
||
if (!importId) {
|
||
return { error: 'import_id is required', status: 400 }
|
||
}
|
||
|
||
const result = await undoSIEImport(supabase, companyId, importId, userId)
|
||
if (!result.success) {
|
||
return { error: result.error ?? 'SIE undo failed', status: 400 }
|
||
}
|
||
|
||
return {
|
||
data: {
|
||
import_id: importId,
|
||
deleted_entries: result.deletedEntries,
|
||
},
|
||
}
|
||
}
|
||
|
||
// ── Phase 4: arbitrary-line bookkeeping primitives ───────────────
|
||
|
||
/**
|
||
* Normalize raw JSON line input from pending_operations.params into the
|
||
* engine's typed line shape. Trusts shape because the MCP tool already
|
||
* validates via Zod before staging: defensive coercion only.
|
||
*/
|
||
function normalizeVoucherLines(raw: unknown): CreateJournalEntryLineInput[] {
|
||
if (!Array.isArray(raw)) return []
|
||
return raw.map((l) => {
|
||
const line = l as Record<string, unknown>
|
||
return {
|
||
account_number: String(line.account_number),
|
||
debit_amount: Number(line.debit_amount) || 0,
|
||
credit_amount: Number(line.credit_amount) || 0,
|
||
line_description: line.line_description ? String(line.line_description) : undefined,
|
||
currency: line.currency ? String(line.currency) : undefined,
|
||
amount_in_currency: line.amount_in_currency !== undefined ? Number(line.amount_in_currency) : undefined,
|
||
exchange_rate: line.exchange_rate !== undefined ? Number(line.exchange_rate) : undefined,
|
||
tax_code: line.tax_code ? String(line.tax_code) : undefined,
|
||
// Boundary-validated with the same constraints as the Zod line schema:
|
||
// staged payloads must not bypass API-layer validation (SOC 2 PI1.1).
|
||
dimensions: coerceDimensionsBag(line.dimensions),
|
||
cost_center: line.cost_center ? String(line.cost_center) : undefined,
|
||
project: line.project ? String(line.project) : undefined,
|
||
}
|
||
})
|
||
}
|
||
|
||
async function commitCreateVoucher(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
params: Record<string, unknown>,
|
||
opts: CommitOptions = {}
|
||
): Promise<ExecutorResult> {
|
||
const entryDate = params.entry_date as string
|
||
const description = params.description as string
|
||
const lines = normalizeVoucherLines(params.lines)
|
||
|
||
if (!entryDate || !description || lines.length < 2) {
|
||
return { error: 'entry_date, description, and at least two lines are required', status: 400 }
|
||
}
|
||
|
||
// Re-validate balance defensively. The MCP tool already checks before
|
||
// staging, but a tampered or hand-inserted pending_operations row would
|
||
// bypass that gate. createDraftEntry runs the same check internally: this
|
||
// is for a cleaner 400 + Swedish error before reaching the engine.
|
||
const balance = validateBalance(lines)
|
||
if (!balance.valid) {
|
||
return {
|
||
error: `Verifikationen balanserar inte: debet ${balance.totalDebit} SEK, kredit ${balance.totalCredit} SEK.`,
|
||
status: 400,
|
||
}
|
||
}
|
||
|
||
// Resolve fiscal period: prefer explicit, fall back to date lookup so the
|
||
// caller can post a voucher without first calling list_fiscal_periods.
|
||
let fiscalPeriodId = params.fiscal_period_id as string | undefined
|
||
if (!fiscalPeriodId) {
|
||
const resolved = await findFiscalPeriod(supabase, companyId, entryDate)
|
||
if (!resolved) {
|
||
return {
|
||
error: `Ingen öppen räkenskapsperiod täcker datumet ${entryDate}. Öppna en period eller välj ett annat datum.`,
|
||
status: 400,
|
||
}
|
||
}
|
||
fiscalPeriodId = resolved
|
||
}
|
||
|
||
// source_type is derived here: never trust params.source_type. The MCP tool
|
||
// stages a typed boolean (is_opening_balance), not a raw source_type string,
|
||
// so a tampered or future direct-staging path can't inject
|
||
// 'bank'/'invoice'/etc. and corrupt audit attribution. The default is
|
||
// 'manual'. We only upgrade to 'opening_balance' after independently
|
||
// re-validating the entry genuinely looks like an ingående balans: this
|
||
// matters because bank reconciliation excludes an IB from the period movement
|
||
// ONLY when source_type='opening_balance' (lib/reconciliation/bank-reconciliation.ts);
|
||
// a mislabelled 'manual' IB shows up as a phantom reconciliation difference.
|
||
let sourceType: JournalEntrySourceType = 'manual'
|
||
if (params.is_opening_balance === true) {
|
||
// Constraint 1: every line must be a balance-sheet account (BAS class 1 or
|
||
// 2). Mirrors the canonical opening-balance flow which rejects P&L accounts
|
||
// (app/api/import/opening-balance/execute/route.ts). Inlined to avoid
|
||
// coupling this executor to the SIE-import module.
|
||
const nonBalanceSheet = lines
|
||
.map((l) => l.account_number)
|
||
.filter((num) => {
|
||
const cls = parseInt(num.charAt(0), 10)
|
||
return !(cls === 1 || cls === 2)
|
||
})
|
||
if (nonBalanceSheet.length > 0) {
|
||
return {
|
||
error:
|
||
`Ingående balans får bara innehålla balanskonton (klass 1-2). ` +
|
||
`Dessa konton hör inte hemma i en IB: ${[...new Set(nonBalanceSheet)].join(', ')}. ` +
|
||
`Bokför resultatkonton som en vanlig verifikation utan is_opening_balance.`,
|
||
status: 400,
|
||
}
|
||
}
|
||
|
||
// Constraint 2: the entry must be dated on the fiscal period's first day:
|
||
// an IB opens the period (same as the canonical flow, which dates the entry
|
||
// on period.period_start). We fetch period_start here because the resolved
|
||
// fiscalPeriodId may have come from either the explicit param or a date
|
||
// lookup; either way the date must line up exactly.
|
||
const { data: period, error: periodErr } = await supabase
|
||
.from('fiscal_periods')
|
||
.select('period_start, name')
|
||
.eq('id', fiscalPeriodId)
|
||
.eq('company_id', companyId)
|
||
.maybeSingle()
|
||
if (periodErr || !period) {
|
||
return { error: 'Räkenskapsperioden hittades inte.', status: 404 }
|
||
}
|
||
if (entryDate !== period.period_start) {
|
||
return {
|
||
error:
|
||
`En ingående balans måste dateras på räkenskapsårets första dag ` +
|
||
`(${period.period_start}). Angivet datum: ${entryDate}. ` +
|
||
`Ändra datumet eller bokför som en vanlig verifikation utan is_opening_balance.`,
|
||
status: 400,
|
||
}
|
||
}
|
||
|
||
sourceType = 'opening_balance'
|
||
}
|
||
|
||
try {
|
||
const entry = await createJournalEntry(
|
||
supabase,
|
||
companyId,
|
||
userId,
|
||
{
|
||
fiscal_period_id: fiscalPeriodId,
|
||
entry_date: entryDate,
|
||
description,
|
||
source_type: sourceType,
|
||
voucher_series: (params.voucher_series as string) || undefined,
|
||
notes: (params.notes as string) || undefined,
|
||
lines,
|
||
},
|
||
// commit_method records HOW it was committed, not who staged it.
|
||
// Web routes pass 'user_accept'/'bulk_accept'; the MCP approve path
|
||
// passes 'api_key'/'agent' so agent-relayed acknowledgments are
|
||
// distinguishable in the immutable layer. The DB CHECK constraint
|
||
// rejects anything else (migrations 20260420120001, 20260618120001).
|
||
opts.commitMethod ?? 'user_accept'
|
||
)
|
||
|
||
// Optional inbox linking: set when gnubok_create_voucher is called with
|
||
// inbox_item_id (book-direct flow for kvitton). The verifikat is already
|
||
// posted and immutable; failures here are non-fatal and only affect
|
||
// discoverability (inbox row stays in "needs action" with the document
|
||
// unlinked). Logged so the user can repair via the UI if needed.
|
||
const inboxItemId = params.inbox_item_id as string | undefined
|
||
const documentId = params.document_id as string | undefined
|
||
let inboxLinked = false
|
||
if (inboxItemId) {
|
||
// Race guard: the UNIQUE constraint on
|
||
// invoice_inbox_items.created_journal_entry_id (migration 20260515090000)
|
||
// stops two inbox items from being linked to the same JE, but it does
|
||
// NOT stop two concurrent commits of different staged ops on the same
|
||
// inbox item from overwriting each other (the second UPDATE on the same
|
||
// row trivially satisfies UNIQUE). We add a `.is('created_journal_entry_id', null)`
|
||
// predicate so only the first commit succeeds; the loser sees a
|
||
// zero-rows-updated result and surfaces a structured warning. We also
|
||
// require .eq('created_supplier_invoice_id', null) so a concurrent
|
||
// create_supplier_invoice_from_inbox doesn't get clobbered either.
|
||
// Only the link column is written: the status CHECK allows received|error
|
||
// (migration 20260504180000), so writing 'confirmed' here would fail the
|
||
// whole UPDATE and silently leave the inbox item in "needs action".
|
||
const { data: updatedRows, error: linkInboxErr } = await supabase
|
||
.from('invoice_inbox_items')
|
||
.update({ created_journal_entry_id: entry.id })
|
||
.eq('id', inboxItemId)
|
||
.eq('company_id', companyId)
|
||
.is('created_journal_entry_id', null)
|
||
.is('created_supplier_invoice_id', null)
|
||
.select('id')
|
||
|
||
if (linkInboxErr) {
|
||
log.warn('Failed to link inbox item to new voucher (voucher still posted)', {
|
||
inboxItemId,
|
||
journalEntryId: entry.id,
|
||
error: linkInboxErr.message,
|
||
})
|
||
} else if (!updatedRows || updatedRows.length === 0) {
|
||
// Race: another commit already claimed this inbox item (either as a
|
||
// journal entry or supplier invoice). The verifikat is already posted
|
||
// and immutable: we leave it; an operator can rättelse via storno
|
||
// if it's a true duplicate.
|
||
log.warn('Voucher posted but inbox item was already claimed by a concurrent commit', {
|
||
inboxItemId,
|
||
journalEntryId: entry.id,
|
||
})
|
||
} else {
|
||
inboxLinked = true
|
||
}
|
||
|
||
// Only attach the OCR document when the inbox link succeeded: if a
|
||
// racing commit already owns the inbox row, the document already lives
|
||
// on its JE and re-attaching here would either fail noisily (UNIQUE on
|
||
// document_attachments.journal_entry_id, if any) or silently shift it.
|
||
if (documentId && inboxLinked) {
|
||
try {
|
||
await linkToJournalEntry(supabase, companyId, documentId, entry.id)
|
||
} catch (linkDocErr) {
|
||
log.warn('Failed to attach inbox document to new voucher', {
|
||
documentId,
|
||
journalEntryId: entry.id,
|
||
error: linkDocErr instanceof Error ? linkDocErr.message : String(linkDocErr),
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
data: {
|
||
journal_entry_id: entry.id,
|
||
voucher_number: entry.voucher_number,
|
||
voucher_series: entry.voucher_series,
|
||
fiscal_period_id: fiscalPeriodId,
|
||
...(inboxItemId ? { inbox_item_id: inboxItemId, inbox_linked: inboxLinked } : {}),
|
||
},
|
||
}
|
||
} catch (err) {
|
||
if (isBookkeepingError(err)) throw err
|
||
return { error: err instanceof Error ? err.message : 'Failed to create voucher', status: 500 }
|
||
}
|
||
}
|
||
|
||
async function commitCorrectEntry(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
params: Record<string, unknown>
|
||
): Promise<ExecutorResult> {
|
||
const entryId = params.entry_id as string
|
||
const lines = normalizeVoucherLines(params.lines)
|
||
|
||
if (!entryId || lines.length < 2) {
|
||
return { error: 'entry_id and at least two lines are required', status: 400 }
|
||
}
|
||
|
||
// Pre-flight: verify the original is posted and its period is not locked.
|
||
// Falling into correctEntry without this returns a less helpful DB error and
|
||
// half-creates the storno before rolling back; surfacing the Swedish message
|
||
// here matches the period_locked UX everywhere else in the app.
|
||
//
|
||
// Period lock check is two-layer (matches the DB triggers): per-period
|
||
// (is_closed / locked_at) AND company-wide (bookkeeping_locked_through).
|
||
// The staging tool uses resolvePeriodStatusForDate; we reuse it here so the
|
||
// commit-time gate matches the staging-time signal.
|
||
const { data: original, error: origErr } = await supabase
|
||
.from('journal_entries')
|
||
.select('id, status, entry_date, fiscal_period_id, fiscal_periods!journal_entries_fiscal_period_id_fkey!inner(is_closed, locked_at)')
|
||
.eq('id', entryId)
|
||
.eq('company_id', companyId)
|
||
.maybeSingle()
|
||
|
||
if (origErr || !original) {
|
||
return { error: 'Verifikationen hittades inte.', status: 404 }
|
||
}
|
||
if (original.status !== 'posted') {
|
||
return {
|
||
error: `Endast bokförda verifikationer kan rättas. Aktuell status: ${original.status}. Drafts redigeras direkt.`,
|
||
status: 409,
|
||
}
|
||
}
|
||
const period = original.fiscal_periods as { is_closed?: boolean; locked_at?: string | null } | { is_closed?: boolean; locked_at?: string | null }[] | null
|
||
const periodRow = Array.isArray(period) ? period[0] : period
|
||
if (periodRow?.is_closed || periodRow?.locked_at) {
|
||
return {
|
||
error: 'Räkenskapsperioden är låst. Öppna perioden eller använd omprövning för redan inlämnade momsdeklarationer.',
|
||
status: 409,
|
||
}
|
||
}
|
||
// resolvePeriodStatusForDate also covers the company-wide bookkeeping_locked_through
|
||
// gate. A DB blip here would otherwise propagate as a 500 with a raw Postgres
|
||
// message; wrap so the caller sees a clean Swedish 500 instead, consistent with
|
||
// the staging-side log-and-degrade behaviour in stagePendingOperation.
|
||
try {
|
||
const periodStatus = await resolvePeriodStatusForDate(supabase, companyId, original.entry_date)
|
||
if (periodStatus.status === 'locked' || periodStatus.status === 'closed') {
|
||
return {
|
||
error: 'Räkenskapsperioden är låst. Öppna perioden eller använd omprövning för redan inlämnade momsdeklarationer.',
|
||
status: 409,
|
||
}
|
||
}
|
||
} catch (err) {
|
||
return {
|
||
error: `Kunde inte verifiera periodstatus: ${err instanceof Error ? err.message : 'okänt fel'}`,
|
||
status: 500,
|
||
}
|
||
}
|
||
|
||
try {
|
||
// correctEntry() posts both the storno and the corrected entry into the
|
||
// SAME fiscal_period_id and entry_date as the original (see
|
||
// lib/core/bookkeeping/storno-service.ts:99,102,195,198). So a rättelse
|
||
// made in May 2026 for a December 2025 voucher correctly lands in 2025,
|
||
// keeping that period's balances consistent. The is_closed pre-flight
|
||
// above is what blocks corrections to already-locked periods.
|
||
const result = await correctEntry(supabase, companyId, userId, entryId, lines)
|
||
return {
|
||
data: {
|
||
original_entry_id: entryId,
|
||
storno_entry_id: result.reversal.id,
|
||
corrected_entry_id: result.corrected.id,
|
||
storno_voucher_number: result.reversal.voucher_number,
|
||
corrected_voucher_number: result.corrected.voucher_number,
|
||
},
|
||
}
|
||
} catch (err) {
|
||
if (isBookkeepingError(err)) throw err
|
||
return { error: err instanceof Error ? err.message : 'Failed to correct entry', status: 500 }
|
||
}
|
||
}
|
||
|
||
async function commitReverseEntry(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
params: Record<string, unknown>
|
||
): Promise<ExecutorResult> {
|
||
const entryId = params.entry_id as string
|
||
const reversalDate = typeof params.reversal_date === 'string' ? params.reversal_date : undefined
|
||
|
||
if (!entryId) {
|
||
return { error: 'entry_id is required', status: 400 }
|
||
}
|
||
|
||
// Pre-flight matches commitCorrectEntry: posted + period not closed. Surfaces
|
||
// Swedish messages before reverseEntry() throws less helpful errors. Period
|
||
// lock check is two-layer (per-period + company-wide bookkeeping_locked_through)
|
||
// via resolvePeriodStatusForDate, matching the staging-time signal.
|
||
const { data: original, error: origErr } = await supabase
|
||
.from('journal_entries')
|
||
.select('id, status, entry_date, fiscal_period_id, fiscal_periods!journal_entries_fiscal_period_id_fkey!inner(is_closed, locked_at)')
|
||
.eq('id', entryId)
|
||
.eq('company_id', companyId)
|
||
.maybeSingle()
|
||
|
||
if (origErr || !original) {
|
||
return { error: 'Verifikationen hittades inte.', status: 404 }
|
||
}
|
||
if (original.status !== 'posted') {
|
||
return {
|
||
error: `Endast bokförda verifikationer kan makuleras. Aktuell status: ${original.status}.`,
|
||
status: 409,
|
||
}
|
||
}
|
||
const period = original.fiscal_periods as { is_closed?: boolean; locked_at?: string | null } | { is_closed?: boolean; locked_at?: string | null }[] | null
|
||
const periodRow = Array.isArray(period) ? period[0] : period
|
||
if (periodRow?.is_closed || periodRow?.locked_at) {
|
||
return {
|
||
error: 'Räkenskapsperioden är låst. Öppna perioden eller använd omprövning för redan inlämnade momsdeklarationer.',
|
||
status: 409,
|
||
}
|
||
}
|
||
try {
|
||
const periodStatus = await resolvePeriodStatusForDate(supabase, companyId, original.entry_date)
|
||
if (periodStatus.status === 'locked' || periodStatus.status === 'closed') {
|
||
return {
|
||
error: 'Räkenskapsperioden är låst. Öppna perioden eller använd omprövning för redan inlämnade momsdeklarationer.',
|
||
status: 409,
|
||
}
|
||
}
|
||
} catch (err) {
|
||
return {
|
||
error: `Kunde inte verifiera periodstatus: ${err instanceof Error ? err.message : 'okänt fel'}`,
|
||
status: 500,
|
||
}
|
||
}
|
||
|
||
try {
|
||
const reversal = await reverseEntry(supabase, companyId, userId, entryId, reversalDate)
|
||
// Invariant per BFL 5 kap 5§: the storno must land in the same fiscal period
|
||
// as the original entry. reverseEntry() at lib/bookkeeping/engine.ts:492 uses
|
||
// original.fiscal_period_id, but assert it here so a future engine change that
|
||
// breaks this invariant fails fast instead of silently shifting period attribution.
|
||
if (reversal.fiscal_period_id !== original.fiscal_period_id) {
|
||
return {
|
||
error: `BFL invariant broken: storno period ${reversal.fiscal_period_id} differs from original ${original.fiscal_period_id}.`,
|
||
status: 500,
|
||
}
|
||
}
|
||
return {
|
||
data: {
|
||
original_entry_id: entryId,
|
||
reversal_entry_id: reversal.id,
|
||
reversal_voucher_number: reversal.voucher_number,
|
||
reversal_voucher_series: reversal.voucher_series,
|
||
fiscal_period_id: reversal.fiscal_period_id,
|
||
},
|
||
}
|
||
} catch (err) {
|
||
if (isBookkeepingError(err)) throw err
|
||
return { error: err instanceof Error ? err.message : 'Failed to reverse entry', status: 500 }
|
||
}
|
||
}
|
||
|
||
// ── Payroll executors ────────────────────────────────────────────
|
||
|
||
async function commitCreateSalaryRun(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
params: Record<string, unknown>
|
||
): Promise<ExecutorResult> {
|
||
const periodYear = params.period_year as number
|
||
const periodMonth = params.period_month as number
|
||
const paymentDate = params.payment_date as string
|
||
if (
|
||
!Number.isInteger(periodYear) ||
|
||
!Number.isInteger(periodMonth) ||
|
||
typeof paymentDate !== 'string'
|
||
) {
|
||
return { error: 'period_year, period_month, payment_date are required', status: 400 }
|
||
}
|
||
|
||
try {
|
||
const { createSalaryRunWithEmployees } = await import('@/lib/salary/create-run')
|
||
const { run, employeeCount } = await createSalaryRunWithEmployees(
|
||
supabase,
|
||
companyId,
|
||
userId,
|
||
{ periodYear, periodMonth, paymentDate },
|
||
)
|
||
return {
|
||
data: {
|
||
salary_run_id: (run as { id?: string }).id,
|
||
employee_count: employeeCount,
|
||
period: `${periodYear}-${String(periodMonth).padStart(2, '0')}`,
|
||
},
|
||
}
|
||
} catch (err) {
|
||
return {
|
||
error: err instanceof Error ? err.message : 'Failed to create salary run',
|
||
status: 500,
|
||
}
|
||
}
|
||
}
|
||
|
||
async function commitGenerateAgi(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
params: Record<string, unknown>
|
||
): Promise<ExecutorResult> {
|
||
const salaryRunId = params.salary_run_id as string
|
||
if (!salaryRunId) return { error: 'salary_run_id is required', status: 400 }
|
||
|
||
try {
|
||
const { generateAgiDeclaration } = await import('@/lib/salary/agi/generate-declaration')
|
||
const { randomUUID } = await import('node:crypto')
|
||
const result = await generateAgiDeclaration({
|
||
supabase,
|
||
companyId,
|
||
userId,
|
||
userEmail: null,
|
||
salaryRunId,
|
||
log: createLogger('commit/generate_agi'),
|
||
requestId: randomUUID(),
|
||
})
|
||
if (!result.ok) {
|
||
return { error: `AGI-generering misslyckades: ${result.code}`, status: 500 }
|
||
}
|
||
const period = `${result.periodYear}-${String(result.periodMonth).padStart(2, '0')}`
|
||
return {
|
||
data: {
|
||
agi_declaration_id: result.agiDeclarationId,
|
||
period,
|
||
employee_count: result.employeeCount,
|
||
is_correction: result.isCorrection,
|
||
download_url: `/api/salary/runs/${salaryRunId}/agi/xml`,
|
||
},
|
||
}
|
||
} catch (err) {
|
||
return {
|
||
error: err instanceof Error ? err.message : 'Failed to generate AGI',
|
||
status: 500,
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Skatteverket filing commit handlers (PR5) ─────────────────────
|
||
//
|
||
// Core cannot import @/extensions (CI guard), so these reach the Skatteverket
|
||
// extension only through the registry-resolved `services` channel. The service
|
||
// runs the SKV chain and returns a SkvSubmitResult (shared shape in
|
||
// ./skatteverket-commit). A recoverable failure throws SkatteverketRecoverable-
|
||
// Error, which the dispatcher catch releases back to 'pending'; a non-recoverable
|
||
// failure becomes a plain { error, status } that rejects the op.
|
||
|
||
function getSkatteverketServices(): SkatteverketCommitServices {
|
||
const services = extensionRegistry.get('skatteverket')?.services as
|
||
| Partial<SkatteverketCommitServices>
|
||
| undefined
|
||
if (!services?.commitSubmitVatDeclaration || !services?.commitSubmitAgi) {
|
||
// Extension absent or not wired. Recoverable: leave the op pending so a
|
||
// re-enable + re-approve works without re-staging.
|
||
throw new SkatteverketRecoverableError(
|
||
'Skatteverket-integrationen är inte tillgänglig.',
|
||
'EXTENSION_DISABLED',
|
||
503,
|
||
)
|
||
}
|
||
return services as SkatteverketCommitServices
|
||
}
|
||
|
||
function handleSkvSubmitResult(result: SkvSubmitResult): ExecutorResult {
|
||
if (!result.ok) {
|
||
if (result.recoverable) {
|
||
throw new SkatteverketRecoverableError(result.error, result.code, result.http_status)
|
||
}
|
||
return { error: result.error, status: result.http_status }
|
||
}
|
||
const data: Record<string, unknown> = { ...result, status: 'awaiting_signature' }
|
||
delete data.ok
|
||
return { data }
|
||
}
|
||
|
||
async function commitSubmitVatDeclaration(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
params: Record<string, unknown>,
|
||
): Promise<ExecutorResult> {
|
||
if (!params.period_type || !params.year || !params.period) {
|
||
return { error: 'period_type, year och period krävs', status: 400 }
|
||
}
|
||
const services = getSkatteverketServices()
|
||
const result = await services.commitSubmitVatDeclaration(supabase, userId, companyId, params)
|
||
return handleSkvSubmitResult(result)
|
||
}
|
||
|
||
async function commitSubmitAgi(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
params: Record<string, unknown>,
|
||
): Promise<ExecutorResult> {
|
||
if (!params.salary_run_id) {
|
||
return { error: 'salary_run_id krävs', status: 400 }
|
||
}
|
||
const services = getSkatteverketServices()
|
||
const result = await services.commitSubmitAgi(supabase, userId, companyId, params)
|
||
return handleSkvSubmitResult(result)
|
||
}
|
||
|
||
// ── Multi-tx commit handlers (PRs #603/#606/#608/#610) ────────────
|
||
//
|
||
// Both wrap their SQL RPC. The RPCs do all the heavy lifting (locking,
|
||
// balance/period checks, journal entry creation, voucher number,
|
||
// payment/junction rows, doc inheritance). The commit handlers just
|
||
// shape params, call the RPC, and translate the structured error code
|
||
// or success payload into an ExecutorResult.
|
||
|
||
async function commitMatchBatchAllocate(
|
||
supabase: SupabaseClient,
|
||
companyId: string,
|
||
params: Record<string, unknown>
|
||
): Promise<ExecutorResult> {
|
||
// Trust boundary (compliance-swarm V8.2.1, A.8.2):
|
||
// Tenant isolation is enforced authoritatively inside the SQL RPC
|
||
// `match_batch_allocate` (supabase/migrations/20260601122000_*.sql):
|
||
// - `transactions` row fetched WHERE id = p_tx_id AND company_id = p_company_id
|
||
// - `invoices` and `supplier_invoices` rows fetched WHERE id = ? AND company_id = p_company_id
|
||
// - `auth.uid()` resolves the caller; membership checked against
|
||
// `company_members.company_id = p_company_id`
|
||
// The MCP execute() handler additionally pre-checks the same IDs to
|
||
// surface clean errors before staging. This commit handler is a thin
|
||
// pass-through by design: re-querying here would triple the same
|
||
// check without adding security.
|
||
const txId = params.transaction_id as string
|
||
const allocations = params.allocations
|
||
if (!txId) return { error: 'transaction_id is required', status: 400 }
|
||
if (!Array.isArray(allocations) || allocations.length === 0) {
|
||
return { error: 'allocations is required (non-empty array)', status: 400 }
|
||
}
|
||
const { data, error } = await supabase.rpc('match_batch_allocate', {
|
||
p_tx_id: txId,
|
||
p_allocations: allocations,
|
||
p_company_id: companyId,
|
||
})
|
||
if (error) {
|
||
// Sanitised log (A.8.11, CC7.2): only error code + message, no
|
||
// payload: error.details can echo invoice IDs, amounts, etc.
|
||
log.error('match_batch_allocate RPC error', {
|
||
code: (error as { code?: string }).code,
|
||
message: error.message,
|
||
})
|
||
return { error: error.message || 'Database error', status: 500 }
|
||
}
|
||
const result = data as { ok: boolean; code?: string; details?: unknown; journal_entry_id?: string }
|
||
if (!result || !result.ok) {
|
||
return {
|
||
error: result?.code || 'match_batch_allocate failed',
|
||
status: 400,
|
||
data: result?.details as Record<string, unknown> | undefined,
|
||
}
|
||
}
|
||
// Structured audit-trail entry on success (compliance-swarm V16). Tx
|
||
// count + JE id + the source tx id only: no amounts, no
|
||
// counterparty identifiers, no descriptions. txId is included
|
||
// intentionally so the audit trail can join successful commits back
|
||
// to the source bank tx without a separate query; it's not PII on
|
||
// its own (just an internal UUID, scoped to companyId already logged).
|
||
log.info('match_batch_allocate committed', {
|
||
companyId,
|
||
operationType: 'match_batch_allocate',
|
||
journalEntryId: result.journal_entry_id,
|
||
txId,
|
||
allocationCount: allocations.length,
|
||
})
|
||
return { data: result as unknown as Record<string, unknown>, status: 200 }
|
||
}
|
||
|
||
async function commitBulkBookTransactions(
|
||
supabase: SupabaseClient,
|
||
companyId: string,
|
||
params: Record<string, unknown>
|
||
): Promise<ExecutorResult> {
|
||
// Trust boundary (compliance-swarm V8.2.1, A.8.2):
|
||
// Tenant isolation + chart-of-accounts validation are enforced
|
||
// authoritatively inside the SQL RPC `bulk_book_transactions`
|
||
// (supabase/migrations/20260602121000_*.sql):
|
||
// - All `transactions` rows fetched WHERE id = ANY(p_tx_ids) AND
|
||
// company_id = p_company_id (line ~115).
|
||
// - `journal_entries` row (link-existing branch) fetched WHERE id =
|
||
// p_existing_journal_entry_id AND company_id = p_company_id.
|
||
// - Every account_number in p_new_entry.lines validated against
|
||
// `chart_of_accounts` filtered by company_id + is_active (PR #610
|
||
// round 2 added this allowlist).
|
||
// - `auth.uid()` resolves the caller; membership checked against
|
||
// `company_members.company_id = p_company_id`.
|
||
// The MCP execute() handler additionally pre-checks tx ownership +
|
||
// JE ownership at stage time to surface clean errors. This commit
|
||
// handler is a thin pass-through by design.
|
||
const txIds = params.tx_ids
|
||
const existingJeId = (params.existing_journal_entry_id as string | null | undefined) ?? null
|
||
const newEntry = (params.new_entry as Record<string, unknown> | null | undefined) ?? null
|
||
if (!Array.isArray(txIds) || txIds.length === 0) {
|
||
return { error: 'tx_ids is required (non-empty array)', status: 400 }
|
||
}
|
||
if ((existingJeId == null) === (newEntry == null)) {
|
||
return {
|
||
error: 'Provide exactly one of existing_journal_entry_id or new_entry',
|
||
status: 400,
|
||
}
|
||
}
|
||
const { data, error } = await supabase.rpc('bulk_book_transactions', {
|
||
p_tx_ids: txIds,
|
||
p_existing_journal_entry_id: existingJeId,
|
||
p_new_entry: newEntry,
|
||
p_company_id: companyId,
|
||
})
|
||
if (error) {
|
||
// Sanitised log (A.8.11, CC7.2): only error code + message.
|
||
log.error('bulk_book_transactions RPC error', {
|
||
code: (error as { code?: string }).code,
|
||
message: error.message,
|
||
})
|
||
return { error: error.message || 'Database error', status: 500 }
|
||
}
|
||
const result = data as { ok: boolean; code?: string; details?: unknown; journal_entry_id?: string; mode?: string; linked_tx_count?: number; docs_linked?: number }
|
||
if (!result || !result.ok) {
|
||
return {
|
||
error: result?.code || 'bulk_book_transactions failed',
|
||
status: 400,
|
||
data: result?.details as Record<string, unknown> | undefined,
|
||
}
|
||
}
|
||
// Structured audit-trail entry on success (compliance-swarm V16).
|
||
log.info('bulk_book_transactions committed', {
|
||
companyId,
|
||
operationType: 'bulk_book_transactions',
|
||
journalEntryId: result.journal_entry_id,
|
||
mode: result.mode,
|
||
txCount: result.linked_tx_count,
|
||
docsLinked: result.docs_linked,
|
||
})
|
||
return { data: result as unknown as Record<string, unknown>, status: 200 }
|
||
}
|
||
|
||
/**
|
||
* Bulk-book selected Underlag (Dokumentinkorgen): Lena-driven flow. Each
|
||
* selected inbox item is booked against its matched bank transaction using one
|
||
* shared category + VAT treatment. The booking, VAT (incl. reverse charge), and
|
||
* underlag→verifikat propagation are the SAME shared core the single-item
|
||
* categorize path uses (categorizeMatchedTransaction). Items that can't be
|
||
* booked are skipped with a reason rather than failing the whole batch: the
|
||
* "Bokför valda hoppar över" contract. A per-item throw (e.g. period locked,
|
||
* accounts not in chart) is caught and recorded as a skip so one bad underlag
|
||
* never blocks the rest.
|
||
*/
|
||
async function commitBulkBookInboxItems(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
params: Record<string, unknown>
|
||
): Promise<ExecutorResult> {
|
||
const parsed = BulkBookInboxSchema.safeParse(params)
|
||
if (!parsed.success) {
|
||
return { error: `Invalid bulk_book_inbox_items params: ${parsed.error.message}`, status: 400 }
|
||
}
|
||
|
||
const { booked, skipped } = await bulkBookMatchedInboxItems(supabase, userId, companyId, parsed.data)
|
||
|
||
log.info('bulk_book_inbox_items committed', {
|
||
companyId,
|
||
operationType: 'bulk_book_inbox_items',
|
||
requested: parsed.data.item_ids.length,
|
||
bookedCount: booked.length,
|
||
skippedCount: skipped.length,
|
||
})
|
||
|
||
return {
|
||
data: {
|
||
booked_count: booked.length,
|
||
skipped_count: skipped.length,
|
||
booked,
|
||
skipped,
|
||
},
|
||
}
|
||
}
|
||
|
||
async function commitLinkTransactionJournalEntry(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
params: Record<string, unknown>
|
||
): Promise<ExecutorResult> {
|
||
const transactionId = params.transaction_id as string | undefined
|
||
const journalEntryId = params.journal_entry_id as string | undefined
|
||
const invoiceId = (params.invoice_id as string | undefined) ?? undefined
|
||
|
||
if (!transactionId || !journalEntryId) {
|
||
return { error: 'transaction_id and journal_entry_id are required', status: 400 }
|
||
}
|
||
|
||
const outcome = await linkTransactionToJournalEntry(supabase, userId, companyId, {
|
||
transactionId,
|
||
journalEntryId,
|
||
invoiceId,
|
||
})
|
||
|
||
if (!outcome.ok) {
|
||
const entry = getErrorEntry(outcome.code)
|
||
const httpStatus = entry?.httpStatus ?? 500
|
||
return {
|
||
error: entry?.message_en ?? outcome.code,
|
||
status: httpStatus,
|
||
data: outcome.details as Record<string, unknown> | undefined,
|
||
}
|
||
}
|
||
|
||
// Structured audit-trail entry on success (compliance-swarm V16, SOC 2 CC4.1).
|
||
// Mirrors commitMatchBatchAllocate / commitBulkBookTransactions: IDs only,
|
||
// no amounts or counterparty PII. invoiceId is logged as boolean to avoid
|
||
// leaking which invoices are touched while still distinguishing the two
|
||
// code paths (link-only vs link+settle).
|
||
log.info('link_transaction_journal_entry committed', {
|
||
companyId,
|
||
operationType: 'link_transaction_journal_entry',
|
||
transactionId: outcome.result.transactionId,
|
||
journalEntryId: outcome.result.journalEntryId,
|
||
settledInvoice: outcome.result.invoiceId != null,
|
||
})
|
||
|
||
return {
|
||
data: {
|
||
transaction_id: outcome.result.transactionId,
|
||
journal_entry_id: outcome.result.journalEntryId,
|
||
voucher_label: outcome.result.voucherLabel,
|
||
invoice_id: outcome.result.invoiceId,
|
||
invoice_status: outcome.result.invoiceStatus,
|
||
paid_amount: outcome.result.paidAmount,
|
||
remaining_amount: outcome.result.remainingAmount,
|
||
},
|
||
}
|
||
}
|
||
|
||
// ── 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.
|
||
*
|
||
* When opts.actor is set, the entire executor runs inside a runWithActor()
|
||
* scope so EVERY journal-entry commit the operation makes (regardless of
|
||
* which entry generator produced it) carries actor attribution into
|
||
* journal_entries.committed_actor_* and the audit_log COMMIT row via
|
||
* commitEntry() → commit_journal_entry RPC (migration 20260619120000).
|
||
*/
|
||
export async function commitPendingOperation(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
pendingOp: PendingOperation,
|
||
opts: CommitOptions = {}
|
||
): Promise<CommitResult> {
|
||
const run = () => commitPendingOperationInner(supabase, userId, companyId, pendingOp, opts)
|
||
return opts.actor ? runWithActor(opts.actor, run) : run()
|
||
}
|
||
|
||
async function commitPendingOperationInner(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
pendingOp: PendingOperation,
|
||
opts: CommitOptions = {}
|
||
): Promise<CommitResult> {
|
||
// ── Capability gate (commit-time twin of the MCP dispatch gate). The actual
|
||
// external-service call (email / Skatteverket submit) happens below, so
|
||
// this is the true paid chokepoint: it also catches an op STAGED during
|
||
// the trial then approved AFTER the grant expired, regardless of caller
|
||
// (MCP approve tool or the UI approval path). Checked BEFORE the atomic
|
||
// claim so a blocked op stays 'pending' and is re-approvable once the
|
||
// company subscribes. Self-hosted short-circuits to all-on in hasCapability.
|
||
const requiredCapability = PAID_OPERATION_CAPABILITY_MAP[pendingOp.operation_type]
|
||
if (requiredCapability && !(await hasCapability(supabase, companyId, requiredCapability))) {
|
||
return {
|
||
status: 'failed',
|
||
error: CAPABILITY_BLOCKED_MESSAGE_SV,
|
||
http_status: 403,
|
||
code: 'capability_blocked',
|
||
}
|
||
}
|
||
|
||
// ── 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_article':
|
||
result = await commitCreateArticle(supabase, userId, companyId, pendingOp.params)
|
||
break
|
||
case 'update_article':
|
||
result = await commitUpdateArticle(supabase, userId, companyId, pendingOp.params)
|
||
break
|
||
case 'create_supplier':
|
||
result = await commitCreateSupplier(supabase, userId, companyId, pendingOp.params)
|
||
break
|
||
case 'create_dimension_value':
|
||
result = await commitCreateDimensionValue(supabase, userId, companyId, pendingOp.params)
|
||
break
|
||
case 'retag_line_dimensions':
|
||
result = await commitRetagLineDimensions(supabase, userId, companyId, pendingOp.params)
|
||
break
|
||
case 'create_invoice':
|
||
result = await commitCreateInvoice(supabase, userId, companyId, pendingOp.params)
|
||
break
|
||
case 'create_transaction':
|
||
result = await commitCreateTransaction(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 'link_invoice_voucher':
|
||
result = await commitLinkInvoiceVoucher(supabase, userId, companyId, pendingOp.params)
|
||
break
|
||
case 'link_supplier_invoice_voucher':
|
||
result = await commitLinkSupplierInvoiceVoucher(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 'link_document_to_voucher':
|
||
result = await commitLinkDocumentToVoucher(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 'create_supplier_invoice_from_inbox':
|
||
result = await commitCreateSupplierInvoiceFromInbox(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
|
||
case 'undo_sie_import':
|
||
result = await commitUndoSieImport(supabase, userId, companyId, pendingOp.params)
|
||
break
|
||
case 'create_voucher':
|
||
result = await commitCreateVoucher(supabase, userId, companyId, pendingOp.params, opts)
|
||
break
|
||
case 'correct_entry':
|
||
result = await commitCorrectEntry(supabase, userId, companyId, pendingOp.params)
|
||
break
|
||
case 'reverse_entry':
|
||
result = await commitReverseEntry(supabase, userId, companyId, pendingOp.params)
|
||
break
|
||
case 'post_annual_depreciation':
|
||
result = await commitPostAnnualDepreciation(supabase, userId, companyId, pendingOp.params)
|
||
break
|
||
case 'create_salary_run':
|
||
result = await commitCreateSalaryRun(supabase, userId, companyId, pendingOp.params)
|
||
break
|
||
case 'generate_agi':
|
||
result = await commitGenerateAgi(supabase, userId, companyId, pendingOp.params)
|
||
break
|
||
case 'match_batch_allocate':
|
||
result = await commitMatchBatchAllocate(supabase, companyId, pendingOp.params)
|
||
break
|
||
case 'bulk_book_transactions':
|
||
result = await commitBulkBookTransactions(supabase, companyId, pendingOp.params)
|
||
break
|
||
case 'bulk_book_inbox_items':
|
||
result = await commitBulkBookInboxItems(supabase, userId, companyId, pendingOp.params)
|
||
break
|
||
case 'link_transaction_journal_entry':
|
||
result = await commitLinkTransactionJournalEntry(supabase, userId, companyId, pendingOp.params)
|
||
break
|
||
case 'submit_vat_declaration':
|
||
result = await commitSubmitVatDeclaration(supabase, userId, companyId, pendingOp.params)
|
||
break
|
||
case 'submit_agi':
|
||
result = await commitSubmitAgi(supabase, userId, companyId, pendingOp.params)
|
||
break
|
||
default:
|
||
return {
|
||
status: 'failed',
|
||
error: `Unknown operation type: ${pendingOp.operation_type}`,
|
||
http_status: 400,
|
||
}
|
||
}
|
||
} catch (err) {
|
||
// Accounts-not-in-chart is RECOVERABLE: the booking itself is valid; the
|
||
// company's chart just lacks the (standard BAS) accounts it posts to. Do
|
||
// NOT consume the op: release the atomic claim back to 'pending' so the
|
||
// user can activate the accounts and retry the SAME op, and surface the
|
||
// structured code + numbers so the client can offer one-click activation.
|
||
if (err instanceof AccountsNotInChartError) {
|
||
await supabase
|
||
.from('pending_operations')
|
||
.update({ status: 'pending' })
|
||
.eq('id', pendingOp.id)
|
||
return {
|
||
status: 'failed',
|
||
error: err.message,
|
||
http_status: 400,
|
||
code: ACCOUNTS_NOT_IN_CHART,
|
||
account_numbers: err.accountNumbers,
|
||
}
|
||
}
|
||
// Recoverable Skatteverket failure (extension disabled, no connection,
|
||
// rate-limited, still processing). Same contract as accounts-not-in-chart:
|
||
// release the claim back to 'pending' so the user can fix the connection/
|
||
// flag and re-approve the SAME op, and surface the structured code.
|
||
if (err instanceof SkatteverketRecoverableError) {
|
||
await supabase
|
||
.from('pending_operations')
|
||
.update({ status: 'pending' })
|
||
.eq('id', pendingOp.id)
|
||
return {
|
||
status: 'failed',
|
||
error: err.message,
|
||
http_status: err.httpStatus,
|
||
code: err.code,
|
||
}
|
||
}
|
||
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()
|
||
const { error: finalizeError } = await supabase
|
||
.from('pending_operations')
|
||
.update({
|
||
status: 'committed',
|
||
resolved_at: now,
|
||
result_data: result.data || {},
|
||
})
|
||
.eq('id', pendingOp.id)
|
||
|
||
if (finalizeError) {
|
||
// The executor's side-effects already committed (and are immutable); only
|
||
// the terminal status write failed. Without surfacing this, the row would
|
||
// sit in 'committing' indefinitely: the expire sweep only targets
|
||
// 'pending' ops, so nothing would ever reconcile it. Log loudly with the
|
||
// ids needed to finalize manually; the response still reports success
|
||
// because the actual work is done.
|
||
log.error('failed to finalize pending_operation to committed (left in committing)', finalizeError, {
|
||
pendingOperationId: pendingOp.id,
|
||
operationType: pendingOp.operation_type,
|
||
companyId,
|
||
})
|
||
}
|
||
|
||
return {
|
||
status: 'committed',
|
||
data: result.data,
|
||
}
|
||
}
|
||
|