feat(api): Phase 3 — transactions + reconciliation vertical (#464)
* feat(api): Phase 3 — transactions + reconciliation vertical
Closes out Phase 3 of the plan in one PR. After this, a 3rd-party agent
can fully manage a company's transaction ledger via the public API:
import bank data, walk the queue, categorize (manual / template /
counterparty / account-override), match payments to customer + supplier
invoices, reverse mistakes, and auto-reconcile the bank against the GL.
ENDPOINTS (12)
Reads:
GET /transactions — cursor list, filters
GET /transactions/{id} — detail
GET /accounts — BAS chart, class filter
GET /fiscal-periods — räkenskapsår list
Writes (single tx, idempotent + scoped):
POST /transactions/{id}/categorize — dry-run, CAS race guard
POST /transactions/{id}/uncategorize — dry-run, storno + reset
POST /transactions/{id}/match-invoice — storno conflicting JE,
payment JE, link
POST /transactions/{id}/match-supplier-invoice — incl. FX diff handling
Writes (bulk, partial-success + all_or_nothing:true → 501):
POST /transactions/ingest — up to 500 items
(CSV + custom feeds)
POST /transactions/batch-categorize — up to 100 items
Reconciliation:
POST /reconciliation/bank/run — dry-run, applies matches
GET /reconciliation/bank/status — health snapshot
All write surfaces mirror the dashboard's internal route compliance
behavior exactly — same engine functions, same Prong-B SI-match
suggestion intercept on categorize, same FX-diff handling on supplier-
invoice match, same optimistic-lock interlock on invoice status update.
No new bookkeeping primitives — every route delegates to the existing
`lib/bookkeeping/*` engine, `lib/transactions/ingest.ts`, and
`lib/reconciliation/bank-reconciliation.ts`.
SCOPES + ERRORS
Adds 12 entries to lib/auth/scopes.ts under transactions:read|write +
reports:read (accounts, fiscal-periods follow the same convention as
MCP tools). Adds 4 new error codes: TX_UNCATEGORIZE_NOT_BOOKED,
TX_UNCATEGORIZE_JE_NOT_POSTED, TX_INGEST_INSERT_FAILED,
TX_BATCH_CATEGORIZE_EMPTY.
TESTS
32 new integration cases across 5 suites:
- transactions list / detail (4)
- accounts + fiscal-periods (4)
- categorize / uncategorize / match-invoice / match-supplier-invoice (9)
- ingest + batch-categorize (7)
- reconciliation run + status (5)
plus shared happy-path and edge cases (no-income, already-linked,
malformed body, scope rejection, dry-run shape).
Full suite green: 3270 passing (234 files). Build + lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): address PR #464 review — Phase 3 hardening
Greptile P1 — cursor pagination broken in GET /transactions.
encodeDefaultCursor was passed the YYYY-MM-DD `date` field, but
decodeDefaultCursor's strict ISO 8601 timestamp regex rejected it,
so every cursor decoded as null and the endpoint always returned the
first page. Switched the cursor anchor to `created_at` (real ISO
timestamp, total-orderable, unique within the company at the row
insertion grain) and updated the sort to (created_at DESC, id ASC).
The `date` column remains in every row + filterable via ?date_from /
?date_to. Updated the registry description to reflect the change.
Greptile P1 — JE soft-fall in match-invoice + match-supplier-invoice.
When the payment journal entry creation threw (any non-
AccountsNotInChartError), the catch block recorded the error string
but execution CONTINUED, marking the invoice paid + inserting a
payment row + linking the transaction with no GL entry. The dashboard
internal route soft-fails here intentionally and surfaces a banner so
the user can re-book; for the v1 surface a partial state is strictly
worse than a clean failure to retry. Both routes now return:
- INVOICE_PAID_BOOK_FAILED (match-invoice)
- MATCH_SI_RECORD_PAYMENT_FAILED (match-supplier-invoice)
before any state mutation. Removed `journal_entry_error` from both
response schemas — strict mode means it can never be set on a 200.
Greptile P1 — `overdue` supplier invoices fail the optimistic lock.
The early status guard accepted `overdue` as matchable, but the
downstream `.in('status', ['registered', 'approved', 'partially_paid'])`
excluded it, returning MATCH_SI_NOT_OPEN for a legitimately payable
invoice. Added `overdue` to the optimistic-lock list.
Greptile P1 + Swedish-compliance — CAS-race orphan cancellation.
Direct `.update({ status: 'cancelled' })` on the orphaned JE was
silently blocked by enforce_journal_entry_immutability (the engine
writes JEs as posted) and the `voucher_gap_explanations` row claimed
the entry was cancelled when it wasn't. BFL 5 kap 5 § requires
corrections via a reversing entry. Both /transactions/{id}/categorize
and /transactions/batch-categorize now call `reverseEntry()` on the
orphan; the storno pair keeps the verifikationsnummer series unbroken
so the gap-explanation insert is no longer needed.
Greptile P2 + Swedish-compliance — hardcoded category on match-invoice.
The dashboard internal route writes `category: 'income_services'` for
every matched invoice payment, overwriting any prior categorization
with a wrong BAS classification for goods sales / rental income.
Fixed by preserving the existing transaction.category if set, only
defaulting to `income_services` when the row had never been
categorized before.
Compliance Swarm V2.4 — reconciliation date range guard.
Added a 366-day cap on date_from / date_to via Zod refine. Longer
reconciliations should be paged.
Greptile P2 — dry-run dedup limitation.
Added a pitfall note documenting that the ingest dry-run only checks
external_id-based dedup; content-based dedup (date+amount against
already-booked rows) only runs in the live pipeline.
Swedish-compliance — BFL chapter typo on fiscal-periods registry.
"BFL 6 kap" → "BFL 5 kap 2 §" (the löpande bokföring deadline).
Deferred (with rationale documented):
- OWASP V8.2.1 cross-tenant via path: false positive — wrapper sets
ctx.companyId from the URL after membership check (recurring across
swarm runs).
- OWASP V4.5 select('*') on transactions/invoices: same as Phase 2 —
those rows feed engine functions that need the full shape.
- OWASP V2.3 multi-write atomicity (match endpoints): would need a
Postgres RPC; separate refactor.
- Swedish-compliance kontantmetoden partial-payment status: same
semantics as the dashboard internal route; engine-level decision
out of v1's scope.
- Greptile P3 `reversible: false` on uncategorize: technically
correct (the storno itself isn't reversible via this verb).
Tests + build green: 3270 passing, lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(import): distinguish network errors in the SIE upload step
Adds a dedicated 'network' errorType so the SIE import wizard surfaces
"Uppladdningen misslyckades" with a connectivity-focused remediation
instead of the generic 'parse' fallback (which suggested checking the
SIE file format — wrong direction when the issue is actually offline /
flaky upload).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): address PR #464 swedish-compliance re-run findings
The Swedish-compliance bot edited its existing comment in place after
the prior fix push (so created_at filtering missed the re-run). The
re-run flagged 6 new substantive findings against the post-fix code.
Fix 1 — Orphan storno failure leaves an unresolved immutability gap
(categorize + batch-categorize).
When reverseEntry() on the CAS-race orphan fails, the orphan stays
posted and untraceable. BFL 5 kap 5 § requires every correction be
traceable. Both paths now insert a voucher_gap_explanations row in
the catch branch flagging "automatisk storno misslyckades — manuell
reconciliation krävs", so the orphan is logged at the audit-trail
level rather than only in app logs.
Fix 2 — Period-lock pre-check (categorize + batch-categorize).
enforce_period_lock and enforce_company_lock_date triggers block JE
inserts on locked/closed periods, but Supabase surfaces those as a
generic 500. Added a new lib/api/v1/check-period-lock.ts helper that
performs the same check the trigger would (company-wide lock date,
is_closed, locked_at), and both routes now return a structured
PERIOD_LOCKED response (existing error code, 400) with reason +
fiscal_period_id details before the engine call. Note: this is an
ergonomics check (TOCTOU window between check and insert) — the
trigger remains authoritative.
Fix 3 — Ingest dry-run now performs content-based dedup too.
The earlier doc-only note was a compliance miss: an integrator
relying on dry-run to confirm uniqueness could ingest duplicate
affärshändelser, violating BFL 5 kap. The dry-run now runs BOTH
external_id dedup AND content-based (date+amount-against-booked)
dedup over the request's date range — same query the live pipeline
uses. Pitfall doc updated accordingly.
Fix 4 — fiscal-periods response now carries duration_days +
exceeds_18_months computed fields.
An automated client (year-end wizard, audit tool) can spot a
non-compliant period sequence (BFL 3 kap, 18-month cap) without
re-implementing date arithmetic. 549-day cap (18 calendar months)
is used to keep the comparison deterministic across leap years.
First-year exceptions still require human judgment; the boolean is
a flag, not a verdict.
Deferred (with rationale documented in commit, not retried):
- uncategorize storno memo: reverseEntry() doesn't accept a reason
parameter today and the JE-level back-reference exists already
via reversed_by_id / reverses_id. Engine signature change is
out of v1's scope.
- VAT integrity check on partial payment in match-invoice: the
behavior is fully delegated to createInvoicePaymentJournalEntry.
The bot itself recommends auditing against the engine; that is
an engine-layer concern and the dashboard internal route uses
the same path.
- 366-day reconciliation window (advisory): no statutory basis;
operational guard.
- match-supplier-invoice FX path against ML 8 kap 21–23 §
(advisory): engine-layer concern.
Tests + build green: 3270 passing, lint clean. Touched-suite tests
(transactions, fiscal-periods, accounts, reconciliation) re-run; the
fiscal-periods test asserts the new derived fields.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): PR #464 round-3 review fixes (re-run after period-lock + dedup)
Both compliance bots edited their existing comments in place after the
prior fix push. New findings against the post-fix code:
Fix — VAT account suppression too broad on account_override.
categorize/route.ts dropped vat_lines for ANY class-2 override, but
BAS class 2 includes the 26xx VAT clearing accounts themselves. Result:
a user override TO a VAT account silently lost the auto-VAT line.
Tightened to `account_class === 2 && !account_override.startsWith('26')`.
The override-to-2440-leverantörsskulder case is unchanged (correctly
drops auto-VAT); the override-to-2611-utgående-moms case now keeps the
VAT line.
Fix — fiscal-periods 18-month cap uses calendar arithmetic.
EIGHTEEN_MONTHS_DAYS = 549 was a generous approximation (18 calendar
months span 540–549 days). Replaced with proper month-anchor math:
start_date + 18 months computed via setUTCMonth-style year/month
rollover, then `period_end > anchor` is the violation. Manual day-
arithmetic on the year part avoids JS's clamp-overflow on Aug-31-style
start dates. duration_days helper preserved for the response field.
Fix — match-invoice no longer hardcodes 'income_services'.
When the transaction has no prior category, the route now leaves the
field UNTOUCHED in the UPDATE (existing default 'uncategorized' or
whatever was there persists). The response surfaces null for the
uncategorized case so a caller can detect "needs human classification"
without inspecting the DB. The auto-default to income_services was
flowing into BAS 3001/3041/3530 selection mismatches and INK2R/SRU
mis-reporting for goods/rental flows. Existing-category transactions
still propagate their value.
Doc — accounts.ts BAS 5/6 description tightened.
Was "5=other costs, 6=other costs" — both true but flatten distinct
subgroups. Now spells out 5xxx (rents/supplies/services) and 6xxx
(marketing/professional/IT) under övriga externa kostnader, with a
pointer to the canonical BAS chart.
Deferred (with rationale documented):
- voucher_gap_explanations in SIE export coverage: verification ask;
SIE export audit is a separate task, not this PR's scope.
- Dry-run dedup parity with full live pipeline: my dedup matches the
live pipeline's primary checks (external_id + content date+amount
against booked rows). Achieving exact parity would need refactoring
lib/transactions/ingest.ts to expose a shared dedup helper.
- FX sign convention in match-supplier-invoice: identical to the
dashboard internal route; if the engine sign convention is wrong
both surfaces are wrong. Engine-layer audit, not v1 surface.
- OWASP V8.2.1 cross-tenant via path: recurring false positive — the
wrapper sets ctx.companyId from the URL only AFTER company_members
membership check.
- V2.3 multi-write atomicity in match endpoints: would need a Postgres
RPC; separate refactor.
- check-period-lock TOCTOU on no_fiscal_period (advisory note): the
engine's ensureFiscalPeriod helper creates an open period; if the
transaction date sits in a historical gap, the engine creates the
period unlocked. The trigger remains the authoritative gate.
Tests + build green: 3270 passing, lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): PR #464 round-4 review fixes (compliance bot re-run)
The compliance swarm went from 20 → 10 findings after round-3, but the
swedish-compliance bot caught 5 issues my fixes introduced or didn't
fully cover.
Fix — VAT account suppression narrowed to BAS 2610–2649.
My round-3 fix exempted any account starting with '26' from VAT-line
suppression, but BAS 26xx includes 2650 (momsredovisningskonto) and
2690 (diverse), neither of which is a moms-line account. Auto-VAT
posted against 2650 would double-post on the moms reconciliation
account. Tightened the exception to the 2610–2649 range (utgående
+ ingående moms accounts only).
Fix — exceedsEighteenMonths month-end overflow.
My round-3 manual month math still passed `startD` raw to Date.UTC,
which clamps Aug 31 + 18 months to Mar 3, making the cap LATER than
the BFL 3 kap 1 § ceiling (false negative). Now clamps `startD` to
the last valid day of the target month using `Date.UTC(year, m+1, 0)`.
Fix — ingest dry-run dedup float-key normalization.
Built the content-dedup set from `${tx.date}|${tx.amount}` where
amount is a JS number stringified directly — `-349.5` from JSON vs
`-349.50` from a Postgres numeric round-trip miss-match. Normalized
both sides to .toFixed(2). SIE imports commonly carry trailing-zero
precision, so this would have caused the dry-run to under-report
duplicates (a BFL 5 kap löpande-bokföring concern: an integrator
trusting the dry-run could double-book affärshändelser).
Fix — CAS-race voucher_series fallback no longer files under 'A'.
Both categorize and batch-categorize used `voucher_series || 'A'`
for the voucher_gap_explanations row. If the orphan JE had no series,
the gap would be indexed under series 'A' and missed by any series-
specific audit query (BFL 5 kap 6 §). Now skips the gap row entirely
when no series is set — the error log already captures the orphan
for human reconciliation; filing under the wrong key is strictly
worse than not filing.
Fix — match-invoice rejects kontantmetoden partial payments.
Under kontantmetoden, utgående moms must be reported per actual
receipt (ML 13 kap 8 §). The cash-method-partial branch was falling
through to createInvoicePaymentJournalEntry (the accrual 1510/1930
clearing path), which doesn't model the per-installment moms event.
Rather than silently over-report moms, refuse with a VALIDATION_ERROR
pointing the caller to either wait for the full payment or switch to
faktureringsmetoden. Full cash-method payments still flow through
createInvoiceCashEntry (the correct kontantmetod path).
Deferred (with rationale):
- `uncategorize` resets journal_entry_id to null: dashboard parity;
the JE-side back-reference (reversed_by_id / reverses_id) preserves
the audit pair. Adding a separate reversal_journal_entry_id column
on transactions is a schema change out of v1 scope.
- OWASP V8.2.1 cross-tenant: recurring false positive.
- OWASP V2.2 inline Zod filter schemas: structural consistency
decision — kept in-route to match other v1 endpoints; a future
refactor can centralize when it justifies the cost.
- OWASP V16 add userId/companyId to storno-failure log: txLog
already carries both via ctx.log.child; not changing call-site
syntax for compliance theatre.
- Engine-layer FX sign convention in match-supplier-invoice
(advisory): identical to dashboard internal route.
Tests + build green: 3270 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): match-supplier-invoice storno conflicting JE before booking
The match-invoice route stornoes any conflicting auto-categorization JE
before posting the payment entry; match-supplier-invoice was missing
the symmetric guard. If a transaction was previously auto-categorized
(e.g. expense_office with a 5460/1930 entry), matching it to a supplier
invoice would post a second 2440/1930 entry while leaving the original
posted — two verifikationer for one affärshändelse, a BFL 5 kap 6 §
integrity violation. Storno-before-match now applies in both routes,
with the same fail-closed semantics (storno failure aborts before any
state change).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
da39eb2d43
commit
a9c98da243
@@ -336,7 +336,7 @@ function SIEImportWizard() {
|
||||
const [step, setStep] = useState<ImportWizardStep>('upload')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [errorType, setErrorType] = useState<'duplicate' | 'duplicate_period' | 'validation' | 'parse' | undefined>()
|
||||
const [errorType, setErrorType] = useState<'duplicate' | 'duplicate_period' | 'validation' | 'parse' | 'network' | undefined>()
|
||||
const [validationErrors, setValidationErrors] = useState<string[]>([])
|
||||
const [validationWarnings, setValidationWarnings] = useState<string[]>([])
|
||||
const [duplicateImportId, setDuplicateImportId] = useState<string | null>(null)
|
||||
@@ -450,9 +450,9 @@ function SIEImportWizard() {
|
||||
const message = isNetworkError
|
||||
? 'Kunde inte nå servern. Kontrollera din internetanslutning och försök igen.'
|
||||
: err instanceof Error ? err.message : 'Ett oväntat fel uppstod.'
|
||||
setErrorType('parse')
|
||||
setErrorType(isNetworkError ? 'network' : 'parse')
|
||||
setError(message)
|
||||
toast({ title: 'Anslutningsfel', description: message, variant: 'destructive' })
|
||||
toast({ title: isNetworkError ? 'Anslutningsfel' : 'Ett fel uppstod', description: message, variant: 'destructive' })
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Integration tests for GET /api/v1/companies/:companyId/accounts and
|
||||
* GET .../fiscal-periods.
|
||||
*/
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
beforeAll(() => {
|
||||
if (process.env.NODE_ENV !== 'test') throw new Error('NODE_ENV=test required')
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
|
||||
})
|
||||
|
||||
vi.mock('@/lib/auth/api-keys', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
|
||||
return { ...actual, validateApiKey: vi.fn(), createServiceClientNoCookies: vi.fn() }
|
||||
})
|
||||
vi.mock('@supabase/supabase-js', async () => {
|
||||
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
|
||||
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
|
||||
})
|
||||
|
||||
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import { GET as listAccounts } from '../route'
|
||||
import { GET as listPeriods } from '../../fiscal-periods/route'
|
||||
|
||||
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
|
||||
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
|
||||
|
||||
type MockResult = { data?: unknown; error?: unknown }
|
||||
function makeFlexibleSupabase(byTable: Record<string, MockResult | MockResult[]>) {
|
||||
const queues = new Map<string, MockResult[]>()
|
||||
for (const [t, val] of Object.entries(byTable)) {
|
||||
queues.set(t, Array.isArray(val) ? [...val] : [val])
|
||||
}
|
||||
const buildChain = (table: string): unknown => {
|
||||
const handler: ProxyHandler<object> = {
|
||||
get(_target, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) => {
|
||||
const q = queues.get(table)
|
||||
const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null })
|
||||
resolve(next)
|
||||
}
|
||||
}
|
||||
return (..._args: unknown[]) => buildChain(table)
|
||||
},
|
||||
}
|
||||
return new Proxy({}, handler)
|
||||
}
|
||||
return { from: vi.fn((table: string) => buildChain(table)) }
|
||||
}
|
||||
|
||||
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
|
||||
function makeRequest(url: string): Request {
|
||||
return new Request(url, {
|
||||
method: 'GET',
|
||||
headers: { Authorization: 'Bearer test-fixture-not-a-real-key' },
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: 'user-1',
|
||||
companyId: COMPANY_ID,
|
||||
apiKeyId: 'ak_1',
|
||||
scopes: ['reports:read'],
|
||||
mode: 'live',
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/v1/companies/:companyId/accounts', () => {
|
||||
it('returns active accounts by default', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
chart_of_accounts: {
|
||||
data: [
|
||||
{
|
||||
account_number: '1930',
|
||||
account_name: 'Företagskonto',
|
||||
account_class: 1,
|
||||
account_group: '19',
|
||||
account_type: 'asset',
|
||||
normal_balance: 'debit',
|
||||
is_system_account: true,
|
||||
is_active: true,
|
||||
description: null,
|
||||
default_vat_code: null,
|
||||
sru_code: null,
|
||||
sort_order: 1930,
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
)
|
||||
const res = await listAccounts(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/accounts`),
|
||||
{ params: Promise.resolve({ companyId: COMPANY_ID }) },
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.accounts).toHaveLength(1)
|
||||
expect(body.data.accounts[0].account_number).toBe('1930')
|
||||
})
|
||||
|
||||
it('rejects invalid class filter', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await listAccounts(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/accounts?class=9`),
|
||||
{ params: Promise.resolve({ companyId: COMPANY_ID }) },
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects keys without reports:read scope', async () => {
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: 'user-1',
|
||||
companyId: COMPANY_ID,
|
||||
scopes: ['invoices:read'],
|
||||
mode: 'live',
|
||||
})
|
||||
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
|
||||
const res = await listAccounts(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/accounts`),
|
||||
{ params: Promise.resolve({ companyId: COMPANY_ID }) },
|
||||
)
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/v1/companies/:companyId/fiscal-periods', () => {
|
||||
it('returns fiscal periods sorted by period_start desc', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
fiscal_periods: {
|
||||
data: [
|
||||
{
|
||||
id: 'fp-1',
|
||||
name: 'Räkenskapsår 2026',
|
||||
period_start: '2026-01-01',
|
||||
period_end: '2026-12-31',
|
||||
is_closed: false,
|
||||
closed_at: null,
|
||||
locked_at: null,
|
||||
previous_period_id: null,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
)
|
||||
const res = await listPeriods(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/fiscal-periods`),
|
||||
{ params: Promise.resolve({ companyId: COMPANY_ID }) },
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.fiscal_periods).toHaveLength(1)
|
||||
// Phase 3 review fix: derived BFL 3 kap fields appear on every row.
|
||||
expect(body.data.fiscal_periods[0].duration_days).toBe(365) // 2026-01-01 → 2026-12-31
|
||||
expect(body.data.fiscal_periods[0].exceeds_18_months).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* GET /api/v1/companies/{companyId}/accounts
|
||||
*
|
||||
* List chart-of-accounts entries (BAS chart). Filter by ?class=1..8
|
||||
* (BAS account class) and ?active=false (include archived). Sorted by
|
||||
* sort_order — agents can render the BAS hierarchy directly from this.
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import { ok } from '@/lib/api/v1/response'
|
||||
import { registerEndpoint } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
|
||||
const Account = z.object({
|
||||
account_number: z.string(),
|
||||
account_name: z.string(),
|
||||
account_class: z.number().int().min(1).max(8),
|
||||
account_group: z.string(),
|
||||
account_type: z.string(),
|
||||
normal_balance: z.string(),
|
||||
is_system_account: z.boolean(),
|
||||
is_active: z.boolean(),
|
||||
description: z.string().nullable(),
|
||||
default_vat_code: z.string().nullable(),
|
||||
sru_code: z.string().nullable(),
|
||||
sort_order: z.number().int(),
|
||||
})
|
||||
|
||||
const AccountsResponse = z.object({ accounts: z.array(Account) })
|
||||
|
||||
const ACCOUNT_COLUMNS =
|
||||
'account_number, account_name, account_class, account_group, account_type, ' +
|
||||
'normal_balance, is_system_account, is_active, description, default_vat_code, ' +
|
||||
'sru_code, sort_order'
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'accounts.list',
|
||||
method: 'GET',
|
||||
path: '/api/v1/companies/:companyId/accounts',
|
||||
summary: 'List chart-of-accounts entries (BAS chart).',
|
||||
description:
|
||||
'Returns every account in the company\'s chart of accounts, ordered by sort_order (the BAS canonical sequence). Filter by ?class=<1..8> (BAS account class — 1=assets, 2=equity/liabilities, 3=revenue, 4=cost of goods sold, 5=övriga externa kostnader (rents, supplies, services), 6=övriga externa kostnader (marketing, professional services, IT), 7=labour, 8=financial). Note: BAS 5xxx and 6xxx are both övriga externa kostnader but cover distinct subgroups — see the BAS chart for the canonical mapping. Pass ?active=false to include archived accounts.',
|
||||
useWhen:
|
||||
'You need account numbers and names to render verifikation tables, build a custom report, or look up the canonical BAS label for an account.',
|
||||
doNotUseFor:
|
||||
'Fetching balances — use the trial-balance report. Creating new accounts — this endpoint is read-only in v1 (use the dashboard).',
|
||||
pitfalls: [
|
||||
'account_number is a STRING — "1930", not 1930. The leading character can be 0 in non-BAS plans.',
|
||||
'is_system_account=true means the account was seeded by gnubok and cannot be archived or renamed.',
|
||||
'Default filter excludes archived accounts; pass ?active=false to include them.',
|
||||
],
|
||||
example: {
|
||||
response: {
|
||||
data: [
|
||||
{
|
||||
account_number: '1930',
|
||||
account_name: 'Företagskonto',
|
||||
account_class: 1,
|
||||
account_type: 'asset',
|
||||
normal_balance: 'debit',
|
||||
is_active: true,
|
||||
},
|
||||
],
|
||||
meta: { request_id: 'req_…', api_version: '2026-05-12' },
|
||||
},
|
||||
},
|
||||
scope: 'reports:read',
|
||||
risk: 'low',
|
||||
idempotent: true,
|
||||
reversible: false,
|
||||
dryRunSupported: false,
|
||||
response: { success: AccountsResponse },
|
||||
})
|
||||
|
||||
export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
'accounts.list',
|
||||
async (request, ctx) => {
|
||||
const url = new URL(request.url)
|
||||
const Filters = z.object({
|
||||
class: z
|
||||
.string()
|
||||
.regex(/^[1-8]$/)
|
||||
.optional(),
|
||||
active: z.enum(['true', 'false']).optional(),
|
||||
})
|
||||
const parsed = Filters.safeParse({
|
||||
class: url.searchParams.get('class') ?? undefined,
|
||||
active: url.searchParams.get('active') ?? undefined,
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
issues: parsed.error.issues.map((i) => ({
|
||||
field: i.path.join('.'),
|
||||
message: i.message,
|
||||
})),
|
||||
},
|
||||
})
|
||||
}
|
||||
const f = parsed.data
|
||||
const activeOnly = f.active !== 'false'
|
||||
|
||||
let query = ctx.supabase
|
||||
.from('chart_of_accounts')
|
||||
.select(ACCOUNT_COLUMNS)
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.order('sort_order', { ascending: true })
|
||||
|
||||
if (activeOnly) query = query.eq('is_active', true)
|
||||
if (f.class) query = query.eq('account_class', parseInt(f.class, 10))
|
||||
|
||||
const { data, error } = await query
|
||||
if (error) return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId })
|
||||
return ok({ accounts: data ?? [] }, { requestId: ctx.requestId })
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* GET /api/v1/companies/{companyId}/fiscal-periods
|
||||
*
|
||||
* List fiscal periods (räkenskapsår) for the company. Ordered newest first.
|
||||
* Read-only in v1 — period creation, locking and closing land in Phase 4.
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import { ok } from '@/lib/api/v1/response'
|
||||
import { registerEndpoint } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse } from '@/lib/api/v1/errors'
|
||||
|
||||
const FiscalPeriod = z.object({
|
||||
id: z.string().uuid(),
|
||||
name: z.string(),
|
||||
period_start: z.string(),
|
||||
period_end: z.string(),
|
||||
is_closed: z.boolean(),
|
||||
closed_at: z.string().nullable(),
|
||||
locked_at: z.string().nullable(),
|
||||
previous_period_id: z.string().uuid().nullable(),
|
||||
created_at: z.string(),
|
||||
// Computed BFL-compliance flags. Persisted nowhere; derived per response.
|
||||
duration_days: z.number().int(),
|
||||
exceeds_18_months: z.boolean(),
|
||||
})
|
||||
|
||||
const FiscalPeriodsResponse = z.object({ fiscal_periods: z.array(FiscalPeriod) })
|
||||
|
||||
const FISCAL_PERIOD_COLUMNS =
|
||||
'id, name, period_start, period_end, is_closed, closed_at, locked_at, ' +
|
||||
'previous_period_id, created_at'
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'fiscal-periods.list',
|
||||
method: 'GET',
|
||||
path: '/api/v1/companies/:companyId/fiscal-periods',
|
||||
summary: 'List fiscal periods (räkenskapsår).',
|
||||
description:
|
||||
'Returns every fiscal period for the company ordered by period_start DESC. is_closed=true means bokslut has been signed; locked_at non-null means writes are blocked at the DB-trigger level.',
|
||||
useWhen:
|
||||
'You need to find the active period before booking, build a year-selector UI, or audit the period-lock history.',
|
||||
doNotUseFor:
|
||||
'Creating, locking, or closing periods — those land in Phase 4 (`POST /fiscal-periods/{id}/lock`, `:close`, `:year-end`). Use the dashboard or wait for Phase 4.',
|
||||
pitfalls: [
|
||||
'previous_period_id chains the bokslut continuity (BFNAR 2013:2). A null value on a non-first period is a data-quality red flag.',
|
||||
'A period can be locked but not closed (löpande bokföring of the new year while bokslut work continues on the prior year — see BFL 5 kap 2 § for the löpande bokföring deadline).',
|
||||
'BFL 3 kap caps a single fiscal period at 18 months. First-year exceptions are allowed.',
|
||||
],
|
||||
example: {
|
||||
response: {
|
||||
data: [
|
||||
{
|
||||
id: 'fp_2026',
|
||||
name: 'Räkenskapsår 2026',
|
||||
period_start: '2026-01-01',
|
||||
period_end: '2026-12-31',
|
||||
is_closed: false,
|
||||
locked_at: null,
|
||||
},
|
||||
],
|
||||
meta: { request_id: 'req_…', api_version: '2026-05-12' },
|
||||
},
|
||||
},
|
||||
scope: 'reports:read',
|
||||
risk: 'low',
|
||||
idempotent: true,
|
||||
reversible: false,
|
||||
dryRunSupported: false,
|
||||
response: { success: FiscalPeriodsResponse },
|
||||
})
|
||||
|
||||
/**
|
||||
* BFL 3 kap 1 § caps a räkenskapsår at 18 calendar months. "Calendar months"
|
||||
* matters here: 18 months can span 540–549 days depending on which 31-day
|
||||
* months and leap days fall in the window, so a fixed day count is either
|
||||
* too generous (false negatives) or too strict (false positives near month
|
||||
* boundaries). Use proper calendar arithmetic — the period end's anchor day
|
||||
* 18 months after the period start.
|
||||
*/
|
||||
function exceedsEighteenMonths(periodStart: string, periodEnd: string): boolean {
|
||||
// ISO date strings — UTC parse to avoid host-tz shifts.
|
||||
const start = new Date(periodStart + 'T00:00:00Z')
|
||||
const end = new Date(periodEnd + 'T00:00:00Z')
|
||||
const startY = start.getUTCFullYear()
|
||||
const startM = start.getUTCMonth() // 0-indexed
|
||||
const startD = start.getUTCDate()
|
||||
const targetY = startY + Math.floor((startM + 18) / 12)
|
||||
const targetM = (startM + 18) % 12
|
||||
// Clamp the day to the last valid day of the target month. Without this,
|
||||
// start=2024-08-31 + 18 months → Date.UTC(2026, 1, 31) rolls into March 3,
|
||||
// making the cap later than the BFL 3 kap 1 § ceiling and causing false
|
||||
// negatives near month-end starts. Date.UTC(year, month, 0) returns the
|
||||
// last day of the prior month, so passing targetM+1 with day=0 gives us
|
||||
// the last day of targetM.
|
||||
const lastDayOfTargetM = new Date(Date.UTC(targetY, targetM + 1, 0)).getUTCDate()
|
||||
const cappedDay = Math.min(startD, lastDayOfTargetM)
|
||||
const cap = new Date(Date.UTC(targetY, targetM, cappedDay))
|
||||
return end.getTime() > cap.getTime()
|
||||
}
|
||||
|
||||
function durationDays(periodStart: string, periodEnd: string): number {
|
||||
const start = new Date(periodStart + 'T00:00:00Z')
|
||||
const end = new Date(periodEnd + 'T00:00:00Z')
|
||||
return Math.round((end.getTime() - start.getTime()) / (24 * 60 * 60 * 1000)) + 1
|
||||
}
|
||||
|
||||
export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
'fiscal-periods.list',
|
||||
async (_request, ctx) => {
|
||||
const { data, error } = await ctx.supabase
|
||||
.from('fiscal_periods')
|
||||
.select(FISCAL_PERIOD_COLUMNS)
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.order('period_start', { ascending: false })
|
||||
|
||||
if (error) return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId })
|
||||
|
||||
// Derive BFL 3 kap compliance flags so an automated client (year-end
|
||||
// wizard, audit tool) can spot non-compliant period sequences without
|
||||
// re-implementing date arithmetic.
|
||||
type Row = { period_start: string; period_end: string } & Record<string, unknown>
|
||||
const rows = (data ?? []) as unknown as Row[]
|
||||
const enriched = rows.map((p) => ({
|
||||
...p,
|
||||
duration_days: durationDays(p.period_start, p.period_end),
|
||||
exceeds_18_months: exceedsEighteenMonths(p.period_start, p.period_end),
|
||||
}))
|
||||
|
||||
return ok({ fiscal_periods: enriched }, { requestId: ctx.requestId })
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Integration tests for POST .../reconciliation/bank/run and
|
||||
* GET .../reconciliation/bank/status.
|
||||
*/
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
beforeAll(() => {
|
||||
if (process.env.NODE_ENV !== 'test') throw new Error('NODE_ENV=test required')
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
|
||||
})
|
||||
|
||||
vi.mock('@/lib/auth/api-keys', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
|
||||
return { ...actual, validateApiKey: vi.fn(), createServiceClientNoCookies: vi.fn() }
|
||||
})
|
||||
vi.mock('@supabase/supabase-js', async () => {
|
||||
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
|
||||
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
|
||||
})
|
||||
|
||||
const { runRecMock, statusMock } = vi.hoisted(() => ({
|
||||
runRecMock: vi.fn().mockResolvedValue({
|
||||
matches: [
|
||||
{
|
||||
transaction: {
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
date: '2026-05-12',
|
||||
description: 'Test',
|
||||
amount: -100,
|
||||
},
|
||||
glLine: {
|
||||
journal_entry_id: '22222222-2222-4222-8222-222222222222',
|
||||
voucher_number: 42,
|
||||
voucher_series: 'A',
|
||||
entry_date: '2026-05-12',
|
||||
entry_description: 'Voucher 42',
|
||||
},
|
||||
method: 'amount_date',
|
||||
confidence: 0.95,
|
||||
},
|
||||
],
|
||||
applied: 1,
|
||||
errors: [],
|
||||
}),
|
||||
statusMock: vi.fn().mockResolvedValue({
|
||||
matched_transactions: 100,
|
||||
unmatched_transactions: 5,
|
||||
unmatched_gl_lines: 2,
|
||||
total_unmatched_amount: 1500,
|
||||
bank_balance: 50000,
|
||||
gl_balance: 48500,
|
||||
difference: 1500,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/reconciliation/bank-reconciliation', () => ({
|
||||
runReconciliation: runRecMock,
|
||||
getReconciliationStatus: statusMock,
|
||||
}))
|
||||
|
||||
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import { POST as runPOST } from '../run/route'
|
||||
import { GET as statusGET } from '../status/route'
|
||||
|
||||
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
|
||||
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
|
||||
|
||||
type MockResult = { data?: unknown; error?: unknown }
|
||||
function makeFlexibleSupabase(byTable: Record<string, MockResult | MockResult[]>) {
|
||||
const queues = new Map<string, MockResult[]>()
|
||||
for (const [t, val] of Object.entries(byTable)) {
|
||||
queues.set(t, Array.isArray(val) ? [...val] : [val])
|
||||
}
|
||||
const buildChain = (table: string): unknown => {
|
||||
const handler: ProxyHandler<object> = {
|
||||
get(_target, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) => {
|
||||
const q = queues.get(table)
|
||||
const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null })
|
||||
resolve(next)
|
||||
}
|
||||
}
|
||||
return (..._args: unknown[]) => buildChain(table)
|
||||
},
|
||||
}
|
||||
return new Proxy({}, handler)
|
||||
}
|
||||
return { from: vi.fn((table: string) => buildChain(table)) }
|
||||
}
|
||||
|
||||
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
|
||||
function postRequest(url: string, body: unknown): Request {
|
||||
return new Request(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer test-fixture-not-a-real-key',
|
||||
'Content-Type': 'application/json',
|
||||
'Idempotency-Key': 'idem1234-aaaa-4abc-8def-1234567890ab',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
function getRequest(url: string): Request {
|
||||
return new Request(url, {
|
||||
method: 'GET',
|
||||
headers: { Authorization: 'Bearer test-fixture-not-a-real-key' },
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('POST /reconciliation/bank/run', () => {
|
||||
beforeEach(() => {
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: 'user-1',
|
||||
companyId: COMPANY_ID,
|
||||
apiKeyId: 'ak_1',
|
||||
scopes: ['transactions:write'],
|
||||
mode: 'live',
|
||||
})
|
||||
})
|
||||
|
||||
it('runs the matcher and applies results', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await runPOST(
|
||||
postRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/reconciliation/bank/run`, {
|
||||
date_from: '2026-05-01',
|
||||
date_to: '2026-05-31',
|
||||
}),
|
||||
{ params: Promise.resolve({ companyId: COMPANY_ID }) },
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.matches).toHaveLength(1)
|
||||
expect(body.data.applied).toBe(1)
|
||||
expect(runRecMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
COMPANY_ID,
|
||||
'user-1',
|
||||
expect.objectContaining({ dryRun: false }),
|
||||
)
|
||||
})
|
||||
|
||||
it('dry-run passes dryRun: true into the matcher', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await runPOST(
|
||||
postRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/reconciliation/bank/run?dry_run=true`,
|
||||
{},
|
||||
),
|
||||
{ params: Promise.resolve({ companyId: COMPANY_ID }) },
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('X-Dry-Run')).toBe('true')
|
||||
expect(runRecMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
COMPANY_ID,
|
||||
'user-1',
|
||||
expect.objectContaining({ dryRun: true }),
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects keys without transactions:write scope', async () => {
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: 'user-1',
|
||||
companyId: COMPANY_ID,
|
||||
scopes: ['transactions:read'],
|
||||
mode: 'live',
|
||||
})
|
||||
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
|
||||
const res = await runPOST(
|
||||
postRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/reconciliation/bank/run`,
|
||||
{},
|
||||
),
|
||||
{ params: Promise.resolve({ companyId: COMPANY_ID }) },
|
||||
)
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /reconciliation/bank/status', () => {
|
||||
beforeEach(() => {
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: 'user-1',
|
||||
companyId: COMPANY_ID,
|
||||
apiKeyId: 'ak_1',
|
||||
scopes: ['transactions:read'],
|
||||
mode: 'live',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns the status snapshot', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await statusGET(
|
||||
getRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/reconciliation/bank/status`),
|
||||
{ params: Promise.resolve({ companyId: COMPANY_ID }) },
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.matched_transactions).toBe(100)
|
||||
expect(body.data.unmatched_transactions).toBe(5)
|
||||
})
|
||||
|
||||
it('rejects invalid date filter', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await statusGET(
|
||||
getRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/reconciliation/bank/status?date_from=invalid`,
|
||||
),
|
||||
{ params: Promise.resolve({ companyId: COMPANY_ID }) },
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* POST /api/v1/companies/{companyId}/reconciliation/bank/run
|
||||
*
|
||||
* Run the bank-reconciliation pipeline: look for bank-side transactions and
|
||||
* GL-side journal lines that pair up by amount + date proximity, then apply
|
||||
* the matches (set `transactions.journal_entry_id` for confirmed pairs).
|
||||
*
|
||||
* Dry-run returns the proposed matches without applying any of them — the
|
||||
* canonical way to preview a reconciliation before letting it write to the
|
||||
* ledger. Idempotent via mandatory Idempotency-Key.
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import { ok } from '@/lib/api/v1/response'
|
||||
import { dryRunPreview } from '@/lib/api/v1/dry-run'
|
||||
import { registerEndpoint } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import { runReconciliation } from '@/lib/reconciliation/bank-reconciliation'
|
||||
|
||||
const RunRequest = z
|
||||
.object({
|
||||
date_from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
date_to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
})
|
||||
// Bound the window so a key with no explicit range can't trigger an
|
||||
// unbounded join across years. 366 days covers a full räkenskapsår + a
|
||||
// leap day; longer reconciliations should be paged.
|
||||
.refine(
|
||||
(d) => {
|
||||
if (!d.date_from || !d.date_to) return true
|
||||
const ms = new Date(d.date_to).getTime() - new Date(d.date_from).getTime()
|
||||
return ms >= 0 && ms <= 366 * 24 * 60 * 60 * 1000
|
||||
},
|
||||
{ message: 'date range must be ≤ 366 days; page longer reconciliations.' },
|
||||
)
|
||||
|
||||
const MatchOut = z.object({
|
||||
transaction_id: z.string().uuid(),
|
||||
transaction_date: z.string(),
|
||||
transaction_description: z.string().nullable(),
|
||||
transaction_amount: z.number(),
|
||||
journal_entry_id: z.string().uuid(),
|
||||
voucher_number: z.number().int().nullable(),
|
||||
voucher_series: z.string().nullable(),
|
||||
entry_date: z.string(),
|
||||
entry_description: z.string().nullable(),
|
||||
method: z.string(),
|
||||
confidence: z.number(),
|
||||
})
|
||||
|
||||
const RunResponse = z.object({
|
||||
matches: z.array(MatchOut),
|
||||
applied: z.number().int(),
|
||||
errors: z.array(z.string()),
|
||||
})
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'reconciliation.bank.run',
|
||||
method: 'POST',
|
||||
path: '/api/v1/companies/:companyId/reconciliation/bank/run',
|
||||
summary: 'Run the bank-reconciliation matcher.',
|
||||
description:
|
||||
'Walks all unbooked bank transactions in the requested date range and pairs them with open GL lines (1930-side) by amount + date proximity. Applies confirmed matches by setting transactions.journal_entry_id (the GL row already exists). Dry-runnable.',
|
||||
useWhen:
|
||||
'You want to auto-match outstanding bank transactions against existing journal entries — typically as the closing step of a sync. Dry-run first to inspect proposed matches.',
|
||||
doNotUseFor:
|
||||
'Creating new journal entries — this only links bank transactions to existing GL lines. Matching to invoices — use `:match-invoice` or `:match-supplier-invoice` for explicit invoice payments.',
|
||||
pitfalls: [
|
||||
'date_from / date_to default to the company\'s full bank history if omitted. Specify a window for predictable performance.',
|
||||
'Idempotency-Key is mandatory.',
|
||||
'matches.confidence is between 0 and 1; the matcher only applies matches above the internal threshold (currently ~0.85).',
|
||||
],
|
||||
example: {
|
||||
request: { date_from: '2026-05-01', date_to: '2026-05-31' },
|
||||
response: {
|
||||
data: { matches: [], applied: 0, errors: [] },
|
||||
meta: { request_id: 'req_…', api_version: '2026-05-12' },
|
||||
},
|
||||
},
|
||||
scope: 'transactions:write',
|
||||
risk: 'medium',
|
||||
idempotent: true,
|
||||
reversible: false,
|
||||
dryRunSupported: true,
|
||||
request: { body: RunRequest },
|
||||
response: { success: RunResponse },
|
||||
})
|
||||
|
||||
export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
'reconciliation.bank.run',
|
||||
async (request, ctx) => {
|
||||
let rawBody: unknown
|
||||
try {
|
||||
rawBody = await request.json()
|
||||
} catch {
|
||||
// Body is optional — an empty body is fine.
|
||||
rawBody = {}
|
||||
}
|
||||
const parsed = RunRequest.safeParse(rawBody)
|
||||
if (!parsed.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
issues: parsed.error.issues.map((i) => ({
|
||||
field: i.path.join('.'),
|
||||
message: i.message,
|
||||
})),
|
||||
},
|
||||
})
|
||||
}
|
||||
const body = parsed.data
|
||||
|
||||
let result
|
||||
try {
|
||||
result = await runReconciliation(ctx.supabase, ctx.companyId!, ctx.userId, {
|
||||
dateFrom: body.date_from,
|
||||
dateTo: body.date_to,
|
||||
dryRun: ctx.dryRun,
|
||||
})
|
||||
} catch (err) {
|
||||
ctx.log.error('reconciliation.bank.run: pipeline failed', err as Error)
|
||||
return v1ErrorResponse(err, ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
|
||||
const matches = result.matches.map((m) => ({
|
||||
transaction_id: m.transaction.id,
|
||||
transaction_date: m.transaction.date,
|
||||
transaction_description: m.transaction.description ?? null,
|
||||
transaction_amount: m.transaction.amount,
|
||||
journal_entry_id: m.glLine.journal_entry_id,
|
||||
voucher_number: m.glLine.voucher_number ?? null,
|
||||
voucher_series: m.glLine.voucher_series ?? null,
|
||||
entry_date: m.glLine.entry_date,
|
||||
entry_description: m.glLine.entry_description ?? null,
|
||||
method: m.method,
|
||||
confidence: m.confidence,
|
||||
}))
|
||||
|
||||
const payload = {
|
||||
matches,
|
||||
applied: result.applied,
|
||||
errors: result.errors,
|
||||
}
|
||||
|
||||
if (ctx.dryRun) {
|
||||
return dryRunPreview(payload, { requestId: ctx.requestId, log: ctx.log })
|
||||
}
|
||||
return ok(payload, { requestId: ctx.requestId })
|
||||
},
|
||||
{ requireIdempotencyKey: true },
|
||||
)
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* GET /api/v1/companies/{companyId}/reconciliation/bank/status
|
||||
*
|
||||
* Snapshot of bank reconciliation health: counts of matched / unmatched
|
||||
* transactions and GL lines for the requested window. Read-only, no
|
||||
* dry-run, no idempotency.
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import { ok } from '@/lib/api/v1/response'
|
||||
import { registerEndpoint } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import { getReconciliationStatus } from '@/lib/reconciliation/bank-reconciliation'
|
||||
|
||||
const StatusResponse = z.object({
|
||||
matched_transactions: z.number().int(),
|
||||
unmatched_transactions: z.number().int(),
|
||||
unmatched_gl_lines: z.number().int(),
|
||||
total_unmatched_amount: z.number(),
|
||||
bank_balance: z.number(),
|
||||
gl_balance: z.number(),
|
||||
difference: z.number(),
|
||||
})
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'reconciliation.bank.status',
|
||||
method: 'GET',
|
||||
path: '/api/v1/companies/:companyId/reconciliation/bank/status',
|
||||
summary: 'Bank-reconciliation health snapshot.',
|
||||
description:
|
||||
'Returns matched / unmatched counts and the balance delta between the bank ledger and the GL for the requested window. Optional ?date_from / ?date_to (default: company history).',
|
||||
useWhen:
|
||||
'You\'re building a dashboard widget, an audit report, or a pre-close check that needs to know how many bank transactions are still unbooked.',
|
||||
doNotUseFor:
|
||||
'Running the matcher — that\'s POST `/reconciliation/bank/run`. Per-transaction detail — use the transaction list with `?status=unbooked`.',
|
||||
pitfalls: [
|
||||
'A non-zero difference is normal between sync runs (uncleared cheques, in-flight transfers). Investigate only if it persists across reconciliations.',
|
||||
'total_unmatched_amount is the absolute sum — positive even when the unmatched rows include both credits and debits.',
|
||||
],
|
||||
example: {
|
||||
response: {
|
||||
data: {
|
||||
matched_transactions: 142,
|
||||
unmatched_transactions: 3,
|
||||
unmatched_gl_lines: 2,
|
||||
total_unmatched_amount: 1850.0,
|
||||
bank_balance: 50000,
|
||||
gl_balance: 48150,
|
||||
difference: 1850,
|
||||
},
|
||||
meta: { request_id: 'req_…', api_version: '2026-05-12' },
|
||||
},
|
||||
},
|
||||
scope: 'transactions:read',
|
||||
risk: 'low',
|
||||
idempotent: true,
|
||||
reversible: false,
|
||||
dryRunSupported: false,
|
||||
response: { success: StatusResponse },
|
||||
})
|
||||
|
||||
export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
'reconciliation.bank.status',
|
||||
async (request, ctx) => {
|
||||
const url = new URL(request.url)
|
||||
const Filters = z.object({
|
||||
date_from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
date_to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
})
|
||||
const parsed = Filters.safeParse({
|
||||
date_from: url.searchParams.get('date_from') ?? undefined,
|
||||
date_to: url.searchParams.get('date_to') ?? undefined,
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
issues: parsed.error.issues.map((i) => ({
|
||||
field: i.path.join('.'),
|
||||
message: i.message,
|
||||
})),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await getReconciliationStatus(
|
||||
ctx.supabase,
|
||||
ctx.companyId!,
|
||||
parsed.data.date_from,
|
||||
parsed.data.date_to,
|
||||
)
|
||||
return ok(status, { requestId: ctx.requestId })
|
||||
} catch (err) {
|
||||
return v1ErrorResponse(err, ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,418 @@
|
||||
/**
|
||||
* Integration tests for the single-transaction write verbs:
|
||||
* POST :id/categorize
|
||||
* POST :id/uncategorize
|
||||
* POST :id/match-invoice
|
||||
* POST :id/match-supplier-invoice
|
||||
*
|
||||
* Each test stubs the bookkeeping engine (createTransactionJournalEntry,
|
||||
* createInvoicePaymentJournalEntry, reverseEntry, etc.) so the test asserts
|
||||
* the route's orchestration — wiring of params + scope + error codes —
|
||||
* rather than reimplementing the engine.
|
||||
*/
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
beforeAll(() => {
|
||||
if (process.env.NODE_ENV !== 'test') throw new Error('NODE_ENV=test required')
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
|
||||
})
|
||||
|
||||
vi.mock('@/lib/auth/api-keys', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
|
||||
return { ...actual, validateApiKey: vi.fn(), createServiceClientNoCookies: vi.fn() }
|
||||
})
|
||||
vi.mock('@supabase/supabase-js', async () => {
|
||||
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
|
||||
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
|
||||
})
|
||||
|
||||
// Engine stubs — happy-path returns reusable across cases.
|
||||
const { createTxJE, reverseEntryMock, createInvPmtJE, createInvCashJE, createSupplierInvPmtJE } = vi.hoisted(() => ({
|
||||
createTxJE: vi.fn().mockResolvedValue({ id: 'je-fresh' }),
|
||||
reverseEntryMock: vi.fn().mockResolvedValue(undefined),
|
||||
createInvPmtJE: vi.fn().mockResolvedValue({ id: 'je-invpmt' }),
|
||||
createInvCashJE: vi.fn().mockResolvedValue({ id: 'je-invcash' }),
|
||||
createSupplierInvPmtJE: vi.fn().mockResolvedValue({ id: 'je-sipmt' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/bookkeeping/transaction-entries', () => ({
|
||||
createTransactionJournalEntry: createTxJE,
|
||||
}))
|
||||
vi.mock('@/lib/bookkeeping/engine', () => ({
|
||||
reverseEntry: reverseEntryMock,
|
||||
}))
|
||||
vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
|
||||
createInvoicePaymentJournalEntry: createInvPmtJE,
|
||||
createInvoiceCashEntry: createInvCashJE,
|
||||
}))
|
||||
vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({
|
||||
createSupplierInvoicePaymentEntry: createSupplierInvPmtJE,
|
||||
createSupplierInvoiceCashEntry: vi.fn().mockResolvedValue({ id: 'je-sicash' }),
|
||||
}))
|
||||
vi.mock('@/lib/invoices/match-log', () => ({
|
||||
logMatchEvent: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/bookkeeping/mapping-engine', () => ({
|
||||
saveUserMappingRule: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
vi.mock('@/lib/bookkeeping/counterparty-templates', () => ({
|
||||
upsertCounterpartyTemplate: vi.fn().mockResolvedValue(undefined),
|
||||
buildMappingResultFromCounterpartyTemplate: vi.fn(),
|
||||
}))
|
||||
// category mapping is real — provides the debit/credit account guarantees.
|
||||
|
||||
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import { POST as categorizePOST } from '../categorize/route'
|
||||
import { POST as uncategorizePOST } from '../uncategorize/route'
|
||||
import { POST as matchInvoicePOST } from '../match-invoice/route'
|
||||
import { POST as matchSIPOST } from '../match-supplier-invoice/route'
|
||||
|
||||
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
|
||||
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
|
||||
|
||||
type MockResult = { data?: unknown; error?: unknown }
|
||||
function makeFlexibleSupabase(byTable: Record<string, MockResult | MockResult[]>) {
|
||||
const queues = new Map<string, MockResult[]>()
|
||||
for (const [t, val] of Object.entries(byTable)) {
|
||||
queues.set(t, Array.isArray(val) ? [...val] : [val])
|
||||
}
|
||||
const buildChain = (table: string): unknown => {
|
||||
const handler: ProxyHandler<object> = {
|
||||
get(_target, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) => {
|
||||
const q = queues.get(table)
|
||||
const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null })
|
||||
resolve(next)
|
||||
}
|
||||
}
|
||||
return (..._args: unknown[]) => buildChain(table)
|
||||
},
|
||||
}
|
||||
return new Proxy({}, handler)
|
||||
}
|
||||
return { from: vi.fn((table: string) => buildChain(table)) }
|
||||
}
|
||||
|
||||
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
const TX_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
|
||||
const INV_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'
|
||||
const SI_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'
|
||||
const JE_ID = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'
|
||||
|
||||
function makeRequest(url: string, body: unknown): Request {
|
||||
return new Request(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer test-fixture-not-a-real-key',
|
||||
'Content-Type': 'application/json',
|
||||
'Idempotency-Key': 'idem1234-aaaa-4abc-8def-1234567890ab',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
function txParams(id: string) {
|
||||
return { params: Promise.resolve({ companyId: COMPANY_ID, id }) }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: 'user-1',
|
||||
companyId: COMPANY_ID,
|
||||
apiKeyId: 'ak_1',
|
||||
scopes: ['transactions:write'],
|
||||
mode: 'live',
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST :id/categorize', () => {
|
||||
it('categorizes a fresh business transaction and creates the JE', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
transactions: [
|
||||
{
|
||||
data: {
|
||||
id: TX_ID,
|
||||
company_id: COMPANY_ID,
|
||||
date: '2026-05-12',
|
||||
amount: -349.5,
|
||||
currency: 'SEK',
|
||||
merchant_name: 'ICA',
|
||||
journal_entry_id: null,
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
{ data: [{ id: TX_ID }], error: null }, // CAS update select
|
||||
],
|
||||
company_settings: {
|
||||
data: { entity_type: 'enskild_firma' },
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await categorizePOST(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/categorize`,
|
||||
{ is_business: true, category: 'expense_office' },
|
||||
),
|
||||
txParams(TX_ID),
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.journal_entry_created).toBe(true)
|
||||
expect(body.data.category).toBe('expense_office')
|
||||
expect(createTxJE).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('dry-run returns mapping preview without creating a JE', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
transactions: {
|
||||
data: {
|
||||
id: TX_ID,
|
||||
company_id: COMPANY_ID,
|
||||
date: '2026-05-12',
|
||||
amount: -349.5,
|
||||
currency: 'SEK',
|
||||
merchant_name: 'ICA',
|
||||
journal_entry_id: null,
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
company_settings: { data: { entity_type: 'enskild_firma' }, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await categorizePOST(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/categorize?dry_run=true`,
|
||||
{ is_business: true, category: 'expense_office' },
|
||||
),
|
||||
txParams(TX_ID),
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('X-Dry-Run')).toBe('true')
|
||||
expect(createTxJE).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects unknown transaction id with TX_CATEGORIZE_TX_NOT_FOUND', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
transactions: { data: null, error: { code: 'PGRST116' } },
|
||||
}),
|
||||
)
|
||||
const res = await categorizePOST(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/categorize`,
|
||||
{ is_business: true, category: 'expense_office' },
|
||||
),
|
||||
txParams(TX_ID),
|
||||
)
|
||||
expect(res.status).toBe(404)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('TX_CATEGORIZE_TX_NOT_FOUND')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST :id/uncategorize', () => {
|
||||
it('storno + reset on a booked transaction', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
transactions: { data: { id: TX_ID, journal_entry_id: JE_ID }, error: null },
|
||||
journal_entries: { data: { id: JE_ID, status: 'posted' }, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await uncategorizePOST(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/uncategorize`,
|
||||
{},
|
||||
),
|
||||
txParams(TX_ID),
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(reverseEntryMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('returns TX_UNCATEGORIZE_NOT_BOOKED when JE missing', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
transactions: { data: { id: TX_ID, journal_entry_id: null }, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await uncategorizePOST(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/uncategorize`,
|
||||
{},
|
||||
),
|
||||
txParams(TX_ID),
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('TX_UNCATEGORIZE_NOT_BOOKED')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST :id/match-invoice', () => {
|
||||
it('matches a positive transaction to an open invoice', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
transactions: {
|
||||
data: {
|
||||
id: TX_ID,
|
||||
amount: 12500,
|
||||
date: '2026-05-12',
|
||||
currency: 'SEK',
|
||||
invoice_id: null,
|
||||
journal_entry_id: null,
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
invoices: [
|
||||
{
|
||||
data: {
|
||||
id: INV_ID,
|
||||
status: 'sent',
|
||||
document_type: 'invoice',
|
||||
total: 12500,
|
||||
paid_amount: 0,
|
||||
remaining_amount: 12500,
|
||||
currency: 'SEK',
|
||||
exchange_rate: null,
|
||||
customer: { name: 'Acme' },
|
||||
items: [],
|
||||
journal_entry_id: null,
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
{ data: [{ id: INV_ID }], error: null }, // status update select
|
||||
],
|
||||
company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null },
|
||||
invoice_payments: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await matchInvoicePOST(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/match-invoice`,
|
||||
{ invoice_id: INV_ID },
|
||||
),
|
||||
txParams(TX_ID),
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.invoice_status).toBe('paid')
|
||||
expect(body.data.journal_entry_id).toBe('je-invpmt')
|
||||
})
|
||||
|
||||
it('rejects negative transaction with MATCH_INVOICE_NOT_INCOME', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
transactions: { data: { id: TX_ID, amount: -100, invoice_id: null }, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await matchInvoicePOST(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/match-invoice`,
|
||||
{ invoice_id: INV_ID },
|
||||
),
|
||||
txParams(TX_ID),
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
expect((await res.json()).error.code).toBe('MATCH_INVOICE_NOT_INCOME')
|
||||
})
|
||||
|
||||
it('rejects already-linked transaction', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
transactions: { data: { id: TX_ID, amount: 100, invoice_id: 'other-id' }, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await matchInvoicePOST(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/match-invoice`,
|
||||
{ invoice_id: INV_ID },
|
||||
),
|
||||
txParams(TX_ID),
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
expect((await res.json()).error.code).toBe('MATCH_INVOICE_TX_ALREADY_LINKED')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST :id/match-supplier-invoice', () => {
|
||||
it('matches a negative transaction to an open supplier invoice', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
transactions: {
|
||||
data: {
|
||||
id: TX_ID,
|
||||
amount: -5000,
|
||||
date: '2026-05-12',
|
||||
currency: 'SEK',
|
||||
supplier_invoice_id: null,
|
||||
journal_entry_id: null,
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
supplier_invoices: [
|
||||
{
|
||||
data: {
|
||||
id: SI_ID,
|
||||
status: 'approved',
|
||||
total: 5000,
|
||||
paid_amount: 0,
|
||||
remaining_amount: 5000,
|
||||
currency: 'SEK',
|
||||
exchange_rate: null,
|
||||
supplier: { name: 'Acme', supplier_type: 'swedish_business' },
|
||||
items: [],
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
{ data: [{ id: SI_ID }], error: null },
|
||||
],
|
||||
company_settings: { data: { accounting_method: 'accrual' }, error: null },
|
||||
supplier_invoice_payments: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await matchSIPOST(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/match-supplier-invoice`,
|
||||
{ supplier_invoice_id: SI_ID },
|
||||
),
|
||||
txParams(TX_ID),
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.invoice_status).toBe('paid')
|
||||
})
|
||||
|
||||
it('rejects positive transaction with MATCH_SI_NOT_EXPENSE', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
transactions: { data: { id: TX_ID, amount: 100, supplier_invoice_id: null }, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await matchSIPOST(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/match-supplier-invoice`,
|
||||
{ supplier_invoice_id: SI_ID },
|
||||
),
|
||||
txParams(TX_ID),
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
expect((await res.json()).error.code).toBe('MATCH_SI_NOT_EXPENSE')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,477 @@
|
||||
/**
|
||||
* POST /api/v1/companies/{companyId}/transactions/{id}/categorize
|
||||
*
|
||||
* Categorize a transaction and create the corresponding journal entry. This
|
||||
* is a thin v1 surface over the same orchestration the internal dashboard
|
||||
* route uses — same mapping engine, same booking templates, same SI-match
|
||||
* suggestion intercept, same CAS race guard.
|
||||
*
|
||||
* Already-categorized fast path: if the transaction already has a journal
|
||||
* entry, only the is_business / category flags are updated. The JE is left
|
||||
* intact (it's immutable post-commit per BFL 5 kap 6 §).
|
||||
*
|
||||
* Dry-runnable: returns the resolved mapping (debit/credit + VAT lines)
|
||||
* without inserting the journal entry or mutating the transaction.
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import { ok } from '@/lib/api/v1/response'
|
||||
import { dryRunPreview } from '@/lib/api/v1/dry-run'
|
||||
import { registerEndpoint } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import { checkPeriodLock } from '@/lib/api/v1/check-period-lock'
|
||||
import { CategorizeTransactionSchema } from '@/lib/api/schemas'
|
||||
import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping'
|
||||
import {
|
||||
getTemplateById,
|
||||
buildMappingResultFromTemplate,
|
||||
validateTemplateForEntity,
|
||||
} from '@/lib/bookkeeping/booking-templates'
|
||||
import {
|
||||
upsertCounterpartyTemplate,
|
||||
buildMappingResultFromCounterpartyTemplate,
|
||||
} from '@/lib/bookkeeping/counterparty-templates'
|
||||
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
|
||||
import { reverseEntry } from '@/lib/bookkeeping/engine'
|
||||
import { saveUserMappingRule } from '@/lib/bookkeeping/mapping-engine'
|
||||
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import type {
|
||||
CategorizationTemplate,
|
||||
EntityType,
|
||||
Transaction,
|
||||
TransactionCategory,
|
||||
} from '@/types'
|
||||
|
||||
const CategorizeResponse = z.object({
|
||||
success: z.boolean(),
|
||||
journal_entry_created: z.boolean(),
|
||||
journal_entry_id: z.string().uuid().nullable(),
|
||||
journal_entry_error: z.string().nullable(),
|
||||
document_link_warning: z.string().nullable().optional(),
|
||||
category: z.string(),
|
||||
already_had_journal_entry: z.boolean().optional(),
|
||||
})
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'transactions.categorize',
|
||||
method: 'POST',
|
||||
path: '/api/v1/companies/:companyId/transactions/:id/categorize',
|
||||
summary: 'Categorize a transaction and create the journal entry.',
|
||||
description:
|
||||
'Resolves the BAS account mapping for the transaction (via category, booking template, or counterparty template), creates the corresponding verifikation, and updates the transaction with is_business / category / journal_entry_id. Idempotent on (transaction, key). Dry-runnable.',
|
||||
useWhen:
|
||||
'You\'re categorizing a bank transaction. Pass `is_business: true` plus either `category`, `template_id` (booking template), `counterparty_template_id`, or `account_override`. For private transactions, `is_business: false` is enough.',
|
||||
doNotUseFor:
|
||||
'Matching a payment to an invoice — use `:match-invoice` or `:match-supplier-invoice`, which storno any conflicting JE first. Uncategorizing — `:uncategorize`.',
|
||||
pitfalls: [
|
||||
'A bank payment that looks like an invoice payment will be flagged via TX_CATEGORIZE_SUGGEST_SI_MATCH — pass `confirm_no_match: true` to override and force-categorize as direct expense (e.g. when the supplier invoice was already booked).',
|
||||
'Already-categorized fast path: if the transaction already has a journal_entry_id, only flags get updated. The JE is immutable post-commit.',
|
||||
'account_override must exist in the chart of accounts; an unknown account returns TX_CATEGORIZE_INVALID_ACCOUNT.',
|
||||
],
|
||||
example: {
|
||||
request: { is_business: true, category: 'expense_office' },
|
||||
response: {
|
||||
data: {
|
||||
success: true,
|
||||
journal_entry_created: true,
|
||||
journal_entry_id: 'je_…',
|
||||
category: 'expense_office',
|
||||
},
|
||||
meta: { request_id: 'req_…', api_version: '2026-05-12' },
|
||||
},
|
||||
},
|
||||
scope: 'transactions:write',
|
||||
risk: 'medium',
|
||||
idempotent: true,
|
||||
reversible: true,
|
||||
dryRunSupported: true,
|
||||
request: { body: CategorizeTransactionSchema },
|
||||
response: { success: CategorizeResponse },
|
||||
})
|
||||
|
||||
export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
|
||||
'transactions.categorize',
|
||||
async (request, ctx, params) => {
|
||||
const { id } = await params.params
|
||||
const idParse = z.string().uuid().safeParse(id)
|
||||
if (!idParse.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: 'id', message: 'Transaction id must be a UUID.' },
|
||||
})
|
||||
}
|
||||
const txId = idParse.data
|
||||
|
||||
let rawBody: unknown
|
||||
try {
|
||||
rawBody = await request.json()
|
||||
} catch {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: 'body', message: 'Body is not valid JSON.' },
|
||||
})
|
||||
}
|
||||
const parsed = CategorizeTransactionSchema.safeParse(rawBody)
|
||||
if (!parsed.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
issues: parsed.error.issues.map((i) => ({
|
||||
field: i.path.join('.'),
|
||||
message: i.message,
|
||||
})),
|
||||
},
|
||||
})
|
||||
}
|
||||
const body = parsed.data
|
||||
const { is_business, category } = body
|
||||
|
||||
const { data: transaction, error: fetchErr } = await ctx.supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('id', txId)
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.single()
|
||||
|
||||
if (fetchErr || !transaction) {
|
||||
return v1ErrorResponseFromCode('TX_CATEGORIZE_TX_NOT_FOUND', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
})
|
||||
}
|
||||
|
||||
const txLog = ctx.log.child({ transactionId: txId })
|
||||
|
||||
// Already-categorized fast path: just flip flags. Skip on dry-run so the
|
||||
// caller can preview the full mapping that would be applied to a fresh tx.
|
||||
if (transaction.journal_entry_id && !ctx.dryRun) {
|
||||
const finalCat: TransactionCategory = is_business
|
||||
? category || 'uncategorized'
|
||||
: 'private'
|
||||
const { error: updateErr } = await ctx.supabase
|
||||
.from('transactions')
|
||||
.update({ is_business, category: finalCat })
|
||||
.eq('id', txId)
|
||||
.eq('company_id', ctx.companyId!)
|
||||
if (updateErr) return v1ErrorResponse(updateErr, txLog, { requestId: ctx.requestId })
|
||||
return ok(
|
||||
{
|
||||
success: true,
|
||||
journal_entry_created: false,
|
||||
journal_entry_id: transaction.journal_entry_id as string,
|
||||
journal_entry_error: null,
|
||||
category: finalCat,
|
||||
already_had_journal_entry: true,
|
||||
},
|
||||
{ requestId: ctx.requestId },
|
||||
)
|
||||
}
|
||||
|
||||
const { data: settings } = await ctx.supabase
|
||||
.from('company_settings')
|
||||
.select('entity_type')
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.single()
|
||||
const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
||||
|
||||
// Resolve final category and mapping result. Mirrors the internal route.
|
||||
let finalCategory: TransactionCategory
|
||||
if (body.template_id) {
|
||||
const template = getTemplateById(body.template_id)
|
||||
if (!template) {
|
||||
return v1ErrorResponseFromCode('TX_CATEGORIZE_INVALID_TEMPLATE', txLog, {
|
||||
requestId: ctx.requestId,
|
||||
details: { templateId: body.template_id, reason: 'unknown_template' },
|
||||
})
|
||||
}
|
||||
const valid = validateTemplateForEntity(template, entityType)
|
||||
if (!valid.valid) {
|
||||
return v1ErrorResponseFromCode('TX_CATEGORIZE_INVALID_TEMPLATE', txLog, {
|
||||
requestId: ctx.requestId,
|
||||
details: { templateId: body.template_id, reason: valid.error },
|
||||
})
|
||||
}
|
||||
finalCategory = is_business ? template.fallback_category : 'private'
|
||||
} else {
|
||||
finalCategory = is_business ? category || 'uncategorized' : 'private'
|
||||
}
|
||||
|
||||
let mappingResult
|
||||
if (body.counterparty_template_id && is_business) {
|
||||
const { data: cpTemplate } = await ctx.supabase
|
||||
.from('categorization_templates')
|
||||
.select('*')
|
||||
.eq('id', body.counterparty_template_id)
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.eq('is_active', true)
|
||||
.maybeSingle()
|
||||
if (!cpTemplate) {
|
||||
return v1ErrorResponseFromCode('NOT_FOUND', txLog, {
|
||||
requestId: ctx.requestId,
|
||||
details: { resource: 'counterparty_template' },
|
||||
})
|
||||
}
|
||||
const match = {
|
||||
template: cpTemplate as CategorizationTemplate,
|
||||
matchMethod: 'exact_alias' as const,
|
||||
confidence: Number(cpTemplate.confidence),
|
||||
}
|
||||
mappingResult = buildMappingResultFromCounterpartyTemplate(
|
||||
match,
|
||||
transaction as Transaction,
|
||||
entityType,
|
||||
)
|
||||
} else if (body.template_id) {
|
||||
const template = getTemplateById(body.template_id)!
|
||||
mappingResult = buildMappingResultFromTemplate(
|
||||
template,
|
||||
transaction as Transaction,
|
||||
entityType,
|
||||
)
|
||||
} else {
|
||||
mappingResult = buildMappingResultFromCategory(
|
||||
finalCategory,
|
||||
transaction as Transaction,
|
||||
is_business,
|
||||
entityType,
|
||||
body.vat_treatment,
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
is_business &&
|
||||
body.account_override &&
|
||||
!body.template_id &&
|
||||
!body.counterparty_template_id
|
||||
) {
|
||||
const { data: accountExists } = await ctx.supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('account_number, account_class')
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.eq('account_number', body.account_override)
|
||||
.single()
|
||||
if (!accountExists) {
|
||||
return v1ErrorResponseFromCode('TX_CATEGORIZE_INVALID_ACCOUNT', txLog, {
|
||||
requestId: ctx.requestId,
|
||||
details: { accountNumber: body.account_override },
|
||||
})
|
||||
}
|
||||
if (transaction.amount < 0) mappingResult.debit_account = body.account_override
|
||||
else mappingResult.credit_account = body.account_override
|
||||
// Drop auto-VAT lines when the override targets a balance-sheet
|
||||
// (class 2) account — but NOT when it targets a moms-line account
|
||||
// directly. BAS class 2 covers both equity/liabilities (where VAT
|
||||
// shouldn't be auto-posted) and the specific VAT accounts themselves
|
||||
// (2611/2621/2631 utgående moms, 2641/2645 ingående moms, etc.).
|
||||
// Narrow the exception to the 2610–2649 range — 2650
|
||||
// (momsredovisningskonto) and 2690 (diverse) are class-2 but NOT
|
||||
// moms-line accounts, so writing the auto-VAT pair there would
|
||||
// double-post on the momsredovisningskonto.
|
||||
const overrideNum = parseInt(body.account_override, 10)
|
||||
const isMomsLineAccount = overrideNum >= 2610 && overrideNum <= 2649
|
||||
if (accountExists.account_class === 2 && !isMomsLineAccount) {
|
||||
mappingResult.vat_lines = []
|
||||
}
|
||||
}
|
||||
|
||||
if (!mappingResult.debit_account || !mappingResult.credit_account) {
|
||||
return v1ErrorResponseFromCode('TX_CATEGORIZE_INVALID_MAPPING', txLog, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
debitAccount: mappingResult.debit_account,
|
||||
creditAccount: mappingResult.credit_account,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Dry-run stops here — caller sees the resolved mapping without burning
|
||||
// a voucher number or mutating any state.
|
||||
if (ctx.dryRun) {
|
||||
return dryRunPreview(
|
||||
{
|
||||
category: finalCategory,
|
||||
mapping: {
|
||||
debit_account: mappingResult.debit_account,
|
||||
credit_account: mappingResult.credit_account,
|
||||
vat_lines: mappingResult.vat_lines,
|
||||
all_lines_complete: mappingResult.all_lines_complete ?? false,
|
||||
},
|
||||
would_create_journal_entry: !transaction.journal_entry_id,
|
||||
already_had_journal_entry: !!transaction.journal_entry_id,
|
||||
},
|
||||
{ requestId: ctx.requestId, log: ctx.log },
|
||||
)
|
||||
}
|
||||
|
||||
// Period-lock pre-check. enforce_period_lock + enforce_company_lock_date
|
||||
// triggers will block the JE insert anyway, but they surface as a generic
|
||||
// 500. Catch the locked-period case here and return a structured
|
||||
// PERIOD_LOCKED response so callers see actionable error semantics.
|
||||
const periodLock = await checkPeriodLock(
|
||||
ctx.supabase,
|
||||
ctx.companyId!,
|
||||
transaction.date,
|
||||
)
|
||||
if (periodLock.locked) {
|
||||
return v1ErrorResponseFromCode('PERIOD_LOCKED', txLog, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
transaction_date: transaction.date,
|
||||
reason: periodLock.reason,
|
||||
fiscal_period_id: periodLock.fiscal_period_id,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Live path: create the journal entry. The internal route runs a
|
||||
// duplicate-payment guard (Prong B) here that surfaces SI-match
|
||||
// suggestions; we preserve that behavior so v1 and the dashboard
|
||||
// diverge on neither booking outcomes nor compliance.
|
||||
let journalEntryId: string | null = null
|
||||
let journalEntryError: string | null = null
|
||||
try {
|
||||
const journalEntry = await createTransactionJournalEntry(
|
||||
ctx.supabase,
|
||||
ctx.companyId!,
|
||||
ctx.userId,
|
||||
transaction as Transaction,
|
||||
mappingResult,
|
||||
)
|
||||
if (journalEntry) journalEntryId = journalEntry.id
|
||||
} catch (err) {
|
||||
txLog.error('transactions.categorize: journal entry creation failed', err as Error)
|
||||
if (isBookkeepingError(err)) {
|
||||
journalEntryError = getErrorMessage(err, { context: 'transaction' })
|
||||
} else {
|
||||
journalEntryError = err instanceof Error ? err.message : 'Unknown error'
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort: save mapping rule + upsert counterparty template. These
|
||||
// are user-experience polish (faster future categorization) and never
|
||||
// fail the request.
|
||||
if (is_business && transaction.merchant_name) {
|
||||
try {
|
||||
await saveUserMappingRule(
|
||||
ctx.supabase,
|
||||
ctx.companyId!,
|
||||
transaction.merchant_name,
|
||||
mappingResult.debit_account,
|
||||
mappingResult.credit_account,
|
||||
!is_business,
|
||||
body.user_description,
|
||||
body.template_id,
|
||||
)
|
||||
} catch (err) {
|
||||
txLog.warn('save mapping rule failed (non-critical)', err as Error)
|
||||
}
|
||||
}
|
||||
try {
|
||||
await upsertCounterpartyTemplate(
|
||||
ctx.supabase,
|
||||
ctx.userId,
|
||||
transaction as Transaction,
|
||||
mappingResult,
|
||||
'user_approved',
|
||||
)
|
||||
} catch (err) {
|
||||
txLog.warn('counterparty template upsert failed (non-critical)', err as Error)
|
||||
}
|
||||
|
||||
// CAS guard: another request must not have categorized this transaction
|
||||
// between fetch and write.
|
||||
const { data: updateResult, error: updateErr } = await ctx.supabase
|
||||
.from('transactions')
|
||||
.update({
|
||||
is_business,
|
||||
category: finalCategory,
|
||||
journal_entry_id: journalEntryId,
|
||||
})
|
||||
.eq('id', txId)
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.is('journal_entry_id', null)
|
||||
.select('id')
|
||||
|
||||
if (updateErr) return v1ErrorResponse(updateErr, txLog, { requestId: ctx.requestId })
|
||||
|
||||
if ((!updateResult || updateResult.length === 0) && journalEntryId) {
|
||||
// Lost the race. The orphan JE was created with status='posted' by the
|
||||
// engine, so the immutability trigger blocks a direct status flip to
|
||||
// 'cancelled'. BFL 5 kap 5 § requires corrections via a reversing
|
||||
// entry (storno) — issue one. The pair (orphan + storno) keeps the
|
||||
// verifikationsnummer series unbroken; no voucher_gap_explanations row
|
||||
// is needed because there's no gap.
|
||||
try {
|
||||
await reverseEntry(ctx.supabase, ctx.companyId!, ctx.userId, journalEntryId)
|
||||
} catch (revErr) {
|
||||
// Storno failure on the orphan is rare but creates an unreconcilable
|
||||
// ledger state (posted JE with no reversal). BFL 5 kap 5 § requires
|
||||
// every correction be traceable. Document the gap explicitly so a
|
||||
// human can reconcile manually rather than losing the trail to logs.
|
||||
txLog.error('TX_CATEGORIZE_RACE: failed to storno orphaned JE', revErr as Error, {
|
||||
orphanJournalEntryId: journalEntryId,
|
||||
})
|
||||
try {
|
||||
const { data: orphan } = await ctx.supabase
|
||||
.from('journal_entries')
|
||||
.select('fiscal_period_id, voucher_series, voucher_number')
|
||||
.eq('id', journalEntryId)
|
||||
.single()
|
||||
if (orphan && orphan.voucher_series) {
|
||||
// Skip the gap row when the engine didn't tag a series on the
|
||||
// orphan. Filing under a fallback series (previously 'A') would
|
||||
// index the gap explanation under the wrong key, hiding it from
|
||||
// series-specific audit queries (BFL 5 kap 6 §). A missing series
|
||||
// is logged above already; a human will reconcile via that trail.
|
||||
await ctx.supabase.from('voucher_gap_explanations').insert({
|
||||
company_id: ctx.companyId!,
|
||||
fiscal_period_id: orphan.fiscal_period_id,
|
||||
voucher_series: orphan.voucher_series,
|
||||
gap_number: orphan.voucher_number,
|
||||
explanation:
|
||||
'CAS-race orphan; automatisk storno misslyckades. Manuell reconciliation krävs.',
|
||||
created_by: ctx.userId,
|
||||
})
|
||||
}
|
||||
} catch (gapErr) {
|
||||
txLog.error(
|
||||
'TX_CATEGORIZE_RACE: failed to log voucher_gap_explanations after storno failure',
|
||||
gapErr as Error,
|
||||
{ orphanJournalEntryId: journalEntryId },
|
||||
)
|
||||
}
|
||||
}
|
||||
return v1ErrorResponseFromCode('TX_CATEGORIZE_RACE', txLog, {
|
||||
requestId: ctx.requestId,
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
await eventBus.emit({
|
||||
type: 'transaction.categorized',
|
||||
payload: {
|
||||
transaction: transaction as Transaction,
|
||||
account: mappingResult.debit_account,
|
||||
taxCode: mappingResult.vat_lines[0]?.account_number || '',
|
||||
userId: ctx.userId,
|
||||
companyId: ctx.companyId!,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
txLog.warn('transaction.categorized emit failed (non-critical)', err as Error)
|
||||
}
|
||||
|
||||
return ok(
|
||||
{
|
||||
success: true,
|
||||
journal_entry_created: !!journalEntryId,
|
||||
journal_entry_id: journalEntryId,
|
||||
journal_entry_error: journalEntryError,
|
||||
category: finalCategory,
|
||||
},
|
||||
{ requestId: ctx.requestId },
|
||||
)
|
||||
},
|
||||
{ requireIdempotencyKey: true },
|
||||
)
|
||||
@@ -0,0 +1,454 @@
|
||||
/**
|
||||
* POST /api/v1/companies/{companyId}/transactions/{id}/match-invoice
|
||||
*
|
||||
* Match a positive (income) transaction to an open customer invoice. The
|
||||
* full flow:
|
||||
* 1. Storno any conflicting auto-categorization JE.
|
||||
* 2. Create the payment journal entry (1930 debit / 1510 credit under
|
||||
* accrual; cash-method path delegates to createInvoiceCashEntry).
|
||||
* 3. Re-attach the invoice PDF to the new payment JE (BFL 7 kap underlag).
|
||||
* 4. Update invoice status (paid / partially_paid) with optimistic lock.
|
||||
* 5. Insert invoice_payments row; link transaction to invoice.
|
||||
*
|
||||
* Mirrors the internal route's failure ordering exactly. Idempotent on
|
||||
* (transaction, key). NOT dry-runnable — the multi-row interlock makes a
|
||||
* meaningful preview infeasible without staging the JE for real, and dry-
|
||||
* run is reserved for endpoints where the caller benefits from a fully
|
||||
* resolved preview before commit. Skip the flag here; document it.
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import { ok } from '@/lib/api/v1/response'
|
||||
import { registerEndpoint } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import { MatchInvoiceSchema } from '@/lib/api/schemas'
|
||||
import {
|
||||
createInvoicePaymentJournalEntry,
|
||||
createInvoiceCashEntry,
|
||||
} from '@/lib/bookkeeping/invoice-entries'
|
||||
import { reverseEntry } from '@/lib/bookkeeping/engine'
|
||||
import { AccountsNotInChartError, isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { logMatchEvent } from '@/lib/invoices/match-log'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import type { EntityType, Invoice, Transaction } from '@/types'
|
||||
|
||||
const MatchInvoiceResponse = z.object({
|
||||
success: z.boolean(),
|
||||
invoice_status: z.string(),
|
||||
paid_at: z.string().nullable(),
|
||||
paid_amount: z.number(),
|
||||
remaining_amount: z.number(),
|
||||
journal_entry_id: z.string().uuid().nullable(),
|
||||
// Preserved from the prior :categorize call (or whatever the existing
|
||||
// value was). Returns null when the transaction had never been
|
||||
// categorized — the v1 surface no longer guesses 'income_services'
|
||||
// for unmatched-revenue rows because the wrong default flows into
|
||||
// BAS 3001/3041/3530 selection and INK2R/SRU reporting.
|
||||
category: z.string().nullable(),
|
||||
})
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'transactions.match-invoice',
|
||||
method: 'POST',
|
||||
path: '/api/v1/companies/:companyId/transactions/:id/match-invoice',
|
||||
summary: 'Match a positive bank transaction to a customer invoice.',
|
||||
description:
|
||||
'Confirms an invoice match for a transaction. Storno any conflicting auto-categorization JE, create the payment journal entry, update the invoice status (paid / partially_paid), insert into invoice_payments, and link the transaction. Idempotent.',
|
||||
useWhen:
|
||||
'You have a bank receipt and a known open invoice it pays. The transaction must be positive (income) and unlinked.',
|
||||
doNotUseFor:
|
||||
'Categorizing a transaction without an invoice — use `:categorize`. Matching to a supplier invoice — use `:match-supplier-invoice`. Bulk auto-match — use `POST /reconciliation/bank/run`.',
|
||||
pitfalls: [
|
||||
'Proforma + delivery notes are rejected (MATCH_INVOICE_NOT_INVOICE_TYPE) — only document_type=\'invoice\' can be matched.',
|
||||
'Transaction must be positive (amount > 0) — negative transactions return MATCH_INVOICE_NOT_INCOME.',
|
||||
'Invoice must be in sent / overdue / partially_paid status — paid or draft invoices return MATCH_INVOICE_NOT_OPEN.',
|
||||
'Idempotency-Key is mandatory.',
|
||||
],
|
||||
example: {
|
||||
request: { invoice_id: 'inv_…' },
|
||||
response: {
|
||||
data: {
|
||||
success: true,
|
||||
invoice_status: 'paid',
|
||||
paid_amount: 12500,
|
||||
remaining_amount: 0,
|
||||
journal_entry_id: 'je_…',
|
||||
category: null,
|
||||
},
|
||||
meta: { request_id: 'req_…', api_version: '2026-05-12' },
|
||||
},
|
||||
},
|
||||
scope: 'transactions:write',
|
||||
risk: 'high',
|
||||
idempotent: true,
|
||||
reversible: false,
|
||||
dryRunSupported: false,
|
||||
request: { body: MatchInvoiceSchema },
|
||||
response: { success: MatchInvoiceResponse },
|
||||
})
|
||||
|
||||
export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
|
||||
'transactions.match-invoice',
|
||||
async (request, ctx, params) => {
|
||||
const { id } = await params.params
|
||||
const idParse = z.string().uuid().safeParse(id)
|
||||
if (!idParse.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: 'id', message: 'Transaction id must be a UUID.' },
|
||||
})
|
||||
}
|
||||
const txId = idParse.data
|
||||
|
||||
let rawBody: unknown
|
||||
try {
|
||||
rawBody = await request.json()
|
||||
} catch {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: 'body', message: 'Body is not valid JSON.' },
|
||||
})
|
||||
}
|
||||
const parsed = MatchInvoiceSchema.safeParse(rawBody)
|
||||
if (!parsed.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
issues: parsed.error.issues.map((i) => ({
|
||||
field: i.path.join('.'),
|
||||
message: i.message,
|
||||
})),
|
||||
},
|
||||
})
|
||||
}
|
||||
const { invoice_id } = parsed.data
|
||||
const txLog = ctx.log.child({ transactionId: txId, invoiceId: invoice_id })
|
||||
|
||||
const { data: transaction, error: fetchTxErr } = await ctx.supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('id', txId)
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.single()
|
||||
if (fetchTxErr || !transaction) {
|
||||
return v1ErrorResponseFromCode('TX_CATEGORIZE_TX_NOT_FOUND', txLog, {
|
||||
requestId: ctx.requestId,
|
||||
})
|
||||
}
|
||||
// Preserve any prior category (e.g. income_products for goods sales).
|
||||
// Only fall back to the generic 'income_services' default if the
|
||||
// transaction has never been categorized — Greptile + Swedish-compliance
|
||||
// flagged the dashboard's hardcode-on-write as a wrong BAS classification
|
||||
// for goods/rental income flows.
|
||||
const existingTxCategory = (transaction as { category?: string | null }).category ?? null
|
||||
if (transaction.amount <= 0) {
|
||||
return v1ErrorResponseFromCode('MATCH_INVOICE_NOT_INCOME', txLog, {
|
||||
requestId: ctx.requestId,
|
||||
details: { amount: transaction.amount },
|
||||
})
|
||||
}
|
||||
if (transaction.invoice_id) {
|
||||
return v1ErrorResponseFromCode('MATCH_INVOICE_TX_ALREADY_LINKED', txLog, {
|
||||
requestId: ctx.requestId,
|
||||
details: { existingInvoiceId: transaction.invoice_id },
|
||||
})
|
||||
}
|
||||
|
||||
const { data: invoice, error: fetchInvErr } = await ctx.supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*), items:invoice_items(*)')
|
||||
.eq('id', invoice_id)
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.single()
|
||||
if (fetchInvErr || !invoice) {
|
||||
return v1ErrorResponseFromCode('MATCH_INVOICE_NOT_FOUND', txLog, {
|
||||
requestId: ctx.requestId,
|
||||
})
|
||||
}
|
||||
const docType = (invoice as { document_type?: string }).document_type ?? 'invoice'
|
||||
if (docType !== 'invoice') {
|
||||
return v1ErrorResponseFromCode('MATCH_INVOICE_NOT_INVOICE_TYPE', txLog, {
|
||||
requestId: ctx.requestId,
|
||||
details: { documentType: docType },
|
||||
})
|
||||
}
|
||||
if (
|
||||
invoice.status !== 'sent' &&
|
||||
invoice.status !== 'overdue' &&
|
||||
invoice.status !== 'partially_paid'
|
||||
) {
|
||||
return v1ErrorResponseFromCode('MATCH_INVOICE_NOT_OPEN', txLog, {
|
||||
requestId: ctx.requestId,
|
||||
details: { currentStatus: invoice.status },
|
||||
})
|
||||
}
|
||||
|
||||
if (transaction.journal_entry_id) {
|
||||
try {
|
||||
await reverseEntry(ctx.supabase, ctx.companyId!, ctx.userId, transaction.journal_entry_id)
|
||||
const { error: clearErr } = await ctx.supabase
|
||||
.from('transactions')
|
||||
.update({ journal_entry_id: null })
|
||||
.eq('id', txId)
|
||||
.eq('company_id', ctx.companyId!)
|
||||
if (clearErr) {
|
||||
txLog.warn('failed to clear journal_entry_id after storno', clearErr)
|
||||
}
|
||||
logMatchEvent(ctx.supabase, ctx.userId, txId, 'storno_conflict_resolved', {
|
||||
invoiceId: invoice_id,
|
||||
previousState: { journal_entry_id: transaction.journal_entry_id },
|
||||
newState: { journal_entry_id: null },
|
||||
})
|
||||
} catch (err) {
|
||||
txLog.error('storno failed', err as Error)
|
||||
return v1ErrorResponse(err, txLog, { requestId: ctx.requestId })
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const paidAmount = transaction.amount
|
||||
const newPaidAmount =
|
||||
Math.round(((invoice.paid_amount || 0) + paidAmount) * 100) / 100
|
||||
const currentRemaining =
|
||||
invoice.remaining_amount ?? invoice.total - (invoice.paid_amount || 0)
|
||||
const newRemaining = Math.max(
|
||||
0,
|
||||
Math.round((currentRemaining - paidAmount) * 100) / 100,
|
||||
)
|
||||
const isFullyPaid = newRemaining <= 0
|
||||
const newStatus = isFullyPaid ? 'paid' : 'partially_paid'
|
||||
|
||||
const { data: settings } = await ctx.supabase
|
||||
.from('company_settings')
|
||||
.select('accounting_method, entity_type')
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.single()
|
||||
const accountingMethod = settings?.accounting_method || 'accrual'
|
||||
const entityType: EntityType =
|
||||
(settings?.entity_type as EntityType) || 'enskild_firma'
|
||||
|
||||
// Reject cash-method partial payments. Under kontantmetoden, utgående
|
||||
// moms must be reported in the period of actual receipt (ML 13 kap 8 §);
|
||||
// the partial-payment branch below uses createInvoicePaymentJournalEntry
|
||||
// (the accrual-style 1510/1930 clearing entry), which doesn't model the
|
||||
// per-installment moms event. Rather than silently over-report moms,
|
||||
// refuse the operation and document the constraint. Full payments
|
||||
// (isFullyPaid=true) flow through createInvoiceCashEntry which IS the
|
||||
// correct kontantmetod path.
|
||||
if (accountingMethod === 'cash' && !isFullyPaid) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', txLog, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
field: 'accounting_method',
|
||||
message:
|
||||
'Kontantmetoden does not support partial-payment matching via this endpoint. ' +
|
||||
'Match the full payment when received, or switch to accrual (faktureringsmetoden).',
|
||||
accounting_method: 'cash',
|
||||
payment_amount: paidAmount,
|
||||
invoice_total: invoice.total,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Strict-mode for the public API: if the payment JE can't be created we
|
||||
// ABORT before touching invoice / payment / transaction state. The
|
||||
// dashboard's internal route soft-fails here and surfaces a banner so
|
||||
// the user can re-book manually; the v1 caller is an automation with
|
||||
// no UI, so a partial state (invoice marked paid, GL has no entry) is
|
||||
// strictly worse than a clean failure to retry.
|
||||
let journalEntryId: string | null = null
|
||||
try {
|
||||
if (accountingMethod === 'cash' && isFullyPaid) {
|
||||
const je = await createInvoiceCashEntry(
|
||||
ctx.supabase,
|
||||
ctx.companyId!,
|
||||
ctx.userId,
|
||||
invoice as Invoice,
|
||||
transaction.date,
|
||||
entityType,
|
||||
invoice.customer?.name,
|
||||
)
|
||||
journalEntryId = je?.id ?? null
|
||||
} else {
|
||||
const je = await createInvoicePaymentJournalEntry(
|
||||
ctx.supabase,
|
||||
ctx.companyId!,
|
||||
ctx.userId,
|
||||
invoice as Invoice,
|
||||
transaction.date,
|
||||
undefined,
|
||||
invoice.customer?.name,
|
||||
paidAmount,
|
||||
)
|
||||
journalEntryId = je?.id ?? null
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof AccountsNotInChartError) {
|
||||
return v1ErrorResponse(err, txLog, { requestId: ctx.requestId })
|
||||
}
|
||||
txLog.error('match-invoice: payment JE creation failed — aborting before state mutation', err as Error)
|
||||
const message = isBookkeepingError(err)
|
||||
? getErrorMessage(err, { context: 'invoice' })
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: 'Unknown error'
|
||||
return v1ErrorResponseFromCode('INVOICE_PAID_BOOK_FAILED', txLog, {
|
||||
requestId: ctx.requestId,
|
||||
details: { reason: message },
|
||||
})
|
||||
}
|
||||
|
||||
// Re-attach invoice PDF to the payment JE (BFL 7 kap underlag).
|
||||
if (journalEntryId && invoice.journal_entry_id) {
|
||||
try {
|
||||
const { data: invoiceDoc } = await ctx.supabase
|
||||
.from('document_attachments')
|
||||
.select('storage_path, file_name, file_size_bytes, mime_type, sha256_hash')
|
||||
.eq('journal_entry_id', invoice.journal_entry_id)
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.eq('is_current_version', true)
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
if (invoiceDoc) {
|
||||
const { error: attachErr } = await ctx.supabase
|
||||
.from('document_attachments')
|
||||
.insert({
|
||||
user_id: ctx.userId,
|
||||
company_id: ctx.companyId!,
|
||||
uploaded_by: ctx.userId,
|
||||
upload_source: 'system',
|
||||
storage_path: invoiceDoc.storage_path,
|
||||
file_name: invoiceDoc.file_name,
|
||||
file_size_bytes: invoiceDoc.file_size_bytes,
|
||||
mime_type: invoiceDoc.mime_type,
|
||||
sha256_hash: invoiceDoc.sha256_hash,
|
||||
journal_entry_id: journalEntryId,
|
||||
})
|
||||
if (attachErr) {
|
||||
txLog.warn('failed to attach invoice PDF to payment JE', {
|
||||
attachError: attachErr.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
txLog.warn('attach invoice PDF threw', err as Error)
|
||||
}
|
||||
}
|
||||
|
||||
const { data: updatedRows, error: updateInvErr } = await ctx.supabase
|
||||
.from('invoices')
|
||||
.update({
|
||||
status: newStatus,
|
||||
paid_at: isFullyPaid ? now : null,
|
||||
paid_amount: newPaidAmount,
|
||||
remaining_amount: newRemaining,
|
||||
})
|
||||
.eq('id', invoice_id)
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.in('status', ['sent', 'overdue', 'partially_paid'])
|
||||
.select('id')
|
||||
if (updateInvErr) return v1ErrorResponse(updateInvErr, txLog, { requestId: ctx.requestId })
|
||||
if (!updatedRows || updatedRows.length === 0) {
|
||||
return v1ErrorResponseFromCode('MATCH_INVOICE_ALREADY_PAID', txLog, {
|
||||
requestId: ctx.requestId,
|
||||
})
|
||||
}
|
||||
|
||||
const paymentNotes =
|
||||
accountingMethod === 'cash' && !isFullyPaid
|
||||
? 'Kontantmetoden: intäkt bokförs vid slutbetalning'
|
||||
: null
|
||||
|
||||
const { error: paymentInsertErr } = await ctx.supabase
|
||||
.from('invoice_payments')
|
||||
.insert({
|
||||
user_id: ctx.userId,
|
||||
company_id: ctx.companyId!,
|
||||
invoice_id,
|
||||
payment_date: transaction.date,
|
||||
amount: paidAmount,
|
||||
currency: invoice.currency,
|
||||
exchange_rate: invoice.exchange_rate,
|
||||
journal_entry_id: journalEntryId,
|
||||
transaction_id: txId,
|
||||
notes: paymentNotes,
|
||||
})
|
||||
if (paymentInsertErr) {
|
||||
if (paymentInsertErr.code === '23505') {
|
||||
return v1ErrorResponseFromCode('MATCH_INVOICE_DUPLICATE_PAYMENT', txLog, {
|
||||
requestId: ctx.requestId,
|
||||
})
|
||||
}
|
||||
txLog.error('failed to record payment', paymentInsertErr)
|
||||
return v1ErrorResponseFromCode('MATCH_INVOICE_RECORD_PAYMENT_FAILED', txLog, {
|
||||
requestId: ctx.requestId,
|
||||
})
|
||||
}
|
||||
|
||||
// When the tx already has a category (set by a prior :categorize call,
|
||||
// could be income_products / rental / etc.), preserve it. When there is
|
||||
// none, leave the column UNTOUCHED — the existing default ('uncategorized')
|
||||
// persists. Writing a hardcoded 'income_services' here was the source of
|
||||
// a known mis-classification for goods/rental flows (BAS 3001/3041/3530
|
||||
// distinct accounts → wrong INK2R field → wrong SRU).
|
||||
const txUpdate: Record<string, unknown> = {
|
||||
invoice_id,
|
||||
potential_invoice_id: null,
|
||||
journal_entry_id: journalEntryId,
|
||||
is_business: true,
|
||||
}
|
||||
if (existingTxCategory) txUpdate.category = existingTxCategory
|
||||
|
||||
const { error: updateTxErr } = await ctx.supabase
|
||||
.from('transactions')
|
||||
.update(txUpdate)
|
||||
.eq('id', txId)
|
||||
.eq('company_id', ctx.companyId!)
|
||||
if (updateTxErr) {
|
||||
txLog.error('failed to link transaction to invoice', updateTxErr)
|
||||
return v1ErrorResponseFromCode('MATCH_INVOICE_LINK_TX_FAILED', txLog, {
|
||||
requestId: ctx.requestId,
|
||||
})
|
||||
}
|
||||
|
||||
logMatchEvent(ctx.supabase, ctx.userId, txId, 'matched', {
|
||||
invoiceId: invoice_id,
|
||||
matchConfidence: 1.0,
|
||||
matchMethod: 'manual_confirm',
|
||||
newState: {
|
||||
status: newStatus,
|
||||
paid_amount: newPaidAmount,
|
||||
remaining_amount: newRemaining,
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
eventBus.emit({
|
||||
type: 'invoice.match_confirmed',
|
||||
payload: {
|
||||
invoice: invoice as Invoice,
|
||||
transaction: transaction as Transaction,
|
||||
userId: ctx.userId,
|
||||
companyId: ctx.companyId!,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
txLog.warn('event emit failed (non-critical)', err as Error)
|
||||
}
|
||||
|
||||
return ok(
|
||||
{
|
||||
success: true,
|
||||
invoice_status: newStatus,
|
||||
paid_at: isFullyPaid ? now : null,
|
||||
paid_amount: newPaidAmount,
|
||||
remaining_amount: newRemaining,
|
||||
journal_entry_id: journalEntryId,
|
||||
category: existingTxCategory,
|
||||
},
|
||||
{ requestId: ctx.requestId },
|
||||
)
|
||||
},
|
||||
{ requireIdempotencyKey: true },
|
||||
)
|
||||
@@ -0,0 +1,368 @@
|
||||
/**
|
||||
* POST /api/v1/companies/{companyId}/transactions/{id}/match-supplier-invoice
|
||||
*
|
||||
* Match a negative (expense) bank transaction to an open supplier invoice.
|
||||
* Mirrors the dashboard's internal route: same FX-difference handling,
|
||||
* same cash-method-FX rejection, same optimistic-lock interlock.
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import { ok } from '@/lib/api/v1/response'
|
||||
import { registerEndpoint } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import { MatchSupplierInvoiceSchema } from '@/lib/api/schemas'
|
||||
import {
|
||||
createSupplierInvoicePaymentEntry,
|
||||
createSupplierInvoiceCashEntry,
|
||||
} from '@/lib/bookkeeping/supplier-invoice-entries'
|
||||
import { reverseEntry } from '@/lib/bookkeeping/engine'
|
||||
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { logMatchEvent } from '@/lib/invoices/match-log'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import type { SupplierInvoice, SupplierInvoiceItem, Transaction } from '@/types'
|
||||
|
||||
const MatchSIResponse = z.object({
|
||||
success: z.boolean(),
|
||||
invoice_status: z.string(),
|
||||
paid_amount: z.number(),
|
||||
remaining_amount: z.number(),
|
||||
journal_entry_id: z.string().uuid().nullable(),
|
||||
})
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'transactions.match-supplier-invoice',
|
||||
method: 'POST',
|
||||
path: '/api/v1/companies/:companyId/transactions/:id/match-supplier-invoice',
|
||||
summary: 'Match a negative bank transaction to a supplier invoice.',
|
||||
description:
|
||||
'Confirms a supplier invoice payment match. Creates the payment journal entry (accrual: 2440 debit / 1930 credit; cash-method: collapsed registration+payment), updates supplier_invoices, inserts a supplier_invoice_payments row, and links the transaction. Handles FX differences for cross-currency payments (7960 gain / 3960 loss).',
|
||||
useWhen:
|
||||
'You have a bank payment and a known open supplier invoice. The transaction must be negative (expense) and unlinked.',
|
||||
doNotUseFor:
|
||||
'Categorizing a direct supplier expense without an invoice — use `:categorize`. Matching to a customer invoice — use `:match-invoice`. Bulk auto-match — `POST /reconciliation/bank/run`.',
|
||||
pitfalls: [
|
||||
'Cash-method companies cannot match across currencies (MATCH_SI_CASH_FX_UNSUPPORTED) — switch to accrual or book FX manually.',
|
||||
'Transaction must be negative (amount < 0). Positive returns MATCH_SI_NOT_EXPENSE.',
|
||||
'Supplier invoice must NOT be paid/credited already. paid/credited returns MATCH_SI_ALREADY_PAID; registered/approved/partially_paid/overdue are matchable.',
|
||||
'Idempotency-Key is mandatory.',
|
||||
],
|
||||
example: {
|
||||
request: { supplier_invoice_id: 'si_…' },
|
||||
response: {
|
||||
data: {
|
||||
success: true,
|
||||
invoice_status: 'paid',
|
||||
paid_amount: 5000,
|
||||
remaining_amount: 0,
|
||||
journal_entry_id: 'je_…',
|
||||
},
|
||||
meta: { request_id: 'req_…', api_version: '2026-05-12' },
|
||||
},
|
||||
},
|
||||
scope: 'transactions:write',
|
||||
risk: 'high',
|
||||
idempotent: true,
|
||||
reversible: false,
|
||||
dryRunSupported: false,
|
||||
request: { body: MatchSupplierInvoiceSchema },
|
||||
response: { success: MatchSIResponse },
|
||||
})
|
||||
|
||||
export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
|
||||
'transactions.match-supplier-invoice',
|
||||
async (request, ctx, params) => {
|
||||
const { id } = await params.params
|
||||
const idParse = z.string().uuid().safeParse(id)
|
||||
if (!idParse.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: 'id', message: 'Transaction id must be a UUID.' },
|
||||
})
|
||||
}
|
||||
const txId = idParse.data
|
||||
|
||||
let rawBody: unknown
|
||||
try {
|
||||
rawBody = await request.json()
|
||||
} catch {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: 'body', message: 'Body is not valid JSON.' },
|
||||
})
|
||||
}
|
||||
const parsed = MatchSupplierInvoiceSchema.safeParse(rawBody)
|
||||
if (!parsed.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
issues: parsed.error.issues.map((i) => ({
|
||||
field: i.path.join('.'),
|
||||
message: i.message,
|
||||
})),
|
||||
},
|
||||
})
|
||||
}
|
||||
const { supplier_invoice_id } = parsed.data
|
||||
const txLog = ctx.log.child({ transactionId: txId, supplierInvoiceId: supplier_invoice_id })
|
||||
|
||||
const { data: transaction, error: fetchTxErr } = await ctx.supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('id', txId)
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.single()
|
||||
if (fetchTxErr || !transaction) {
|
||||
return v1ErrorResponseFromCode('TX_CATEGORIZE_TX_NOT_FOUND', txLog, {
|
||||
requestId: ctx.requestId,
|
||||
})
|
||||
}
|
||||
if (transaction.amount >= 0) {
|
||||
return v1ErrorResponseFromCode('MATCH_SI_NOT_EXPENSE', txLog, {
|
||||
requestId: ctx.requestId,
|
||||
details: { amount: transaction.amount },
|
||||
})
|
||||
}
|
||||
if (transaction.supplier_invoice_id) {
|
||||
return v1ErrorResponseFromCode('MATCH_SI_TX_ALREADY_LINKED', txLog, {
|
||||
requestId: ctx.requestId,
|
||||
details: { existingSupplierInvoiceId: transaction.supplier_invoice_id },
|
||||
})
|
||||
}
|
||||
|
||||
const { data: invoice, error: fetchInvErr } = await ctx.supabase
|
||||
.from('supplier_invoices')
|
||||
.select('*, supplier:suppliers(*), items:supplier_invoice_items(*)')
|
||||
.eq('id', supplier_invoice_id)
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.single()
|
||||
if (fetchInvErr || !invoice) {
|
||||
return v1ErrorResponseFromCode('MATCH_SI_NOT_FOUND', txLog, {
|
||||
requestId: ctx.requestId,
|
||||
})
|
||||
}
|
||||
if (invoice.status === 'paid' || invoice.status === 'credited') {
|
||||
return v1ErrorResponseFromCode('MATCH_SI_ALREADY_PAID', txLog, {
|
||||
requestId: ctx.requestId,
|
||||
details: { currentStatus: invoice.status },
|
||||
})
|
||||
}
|
||||
|
||||
// Storno any conflicting auto-categorization JE before booking the
|
||||
// payment. Mirrors the match-invoice path. Without this, an earlier
|
||||
// :categorize of the same transaction (e.g. as expense_office with a
|
||||
// 5460/1930 entry) would leave its JE posted alongside the new
|
||||
// 2440/1930 supplier-invoice payment entry — two verifikationer for
|
||||
// one affärshändelse violates BFL 5 kap 6 §. If storno fails, abort
|
||||
// before any further state change.
|
||||
if (transaction.journal_entry_id) {
|
||||
try {
|
||||
await reverseEntry(
|
||||
ctx.supabase,
|
||||
ctx.companyId!,
|
||||
ctx.userId,
|
||||
transaction.journal_entry_id,
|
||||
)
|
||||
const { error: clearErr } = await ctx.supabase
|
||||
.from('transactions')
|
||||
.update({ journal_entry_id: null })
|
||||
.eq('id', txId)
|
||||
.eq('company_id', ctx.companyId!)
|
||||
if (clearErr) {
|
||||
txLog.warn('failed to clear journal_entry_id after storno', clearErr)
|
||||
}
|
||||
} catch (err) {
|
||||
txLog.error('match-supplier-invoice: storno of conflicting JE failed', err as Error, {
|
||||
conflictingJournalEntryId: transaction.journal_entry_id,
|
||||
})
|
||||
return v1ErrorResponse(err, txLog, { requestId: ctx.requestId })
|
||||
}
|
||||
}
|
||||
|
||||
const txAmountAbs = Math.abs(transaction.amount)
|
||||
const paymentAmountInvoiceCurrency =
|
||||
transaction.currency === invoice.currency ? txAmountAbs : invoice.remaining_amount
|
||||
const actualBankSek =
|
||||
transaction.currency === 'SEK'
|
||||
? txAmountAbs
|
||||
: transaction.amount_sek != null
|
||||
? Math.abs(transaction.amount_sek)
|
||||
: txAmountAbs
|
||||
const invoiceFxRate = invoice.exchange_rate ?? null
|
||||
const originalBookedSek =
|
||||
invoice.currency === 'SEK'
|
||||
? paymentAmountInvoiceCurrency
|
||||
: invoiceFxRate && invoiceFxRate > 0
|
||||
? Math.round(paymentAmountInvoiceCurrency * invoiceFxRate * 100) / 100
|
||||
: actualBankSek
|
||||
const exchangeRateDifference =
|
||||
Math.round((originalBookedSek - actualBankSek) * 100) / 100
|
||||
const paymentAmountSek =
|
||||
exchangeRateDifference !== 0 ? originalBookedSek : actualBankSek
|
||||
|
||||
const now = new Date().toISOString()
|
||||
|
||||
const { data: settings } = await ctx.supabase
|
||||
.from('company_settings')
|
||||
.select('accounting_method')
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.single()
|
||||
const accountingMethod = settings?.accounting_method || 'accrual'
|
||||
|
||||
if (accountingMethod === 'cash' && exchangeRateDifference !== 0) {
|
||||
return v1ErrorResponseFromCode('MATCH_SI_CASH_FX_UNSUPPORTED', txLog, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
exchangeRateDifference,
|
||||
invoiceCurrency: invoice.currency,
|
||||
transactionCurrency: transaction.currency,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Strict-mode for the public API: abort before mutating state if the
|
||||
// payment JE can't be created. See the parallel comment in match-invoice.
|
||||
let journalEntryId: string | null = null
|
||||
try {
|
||||
if (accountingMethod === 'cash') {
|
||||
const je = await createSupplierInvoiceCashEntry(
|
||||
ctx.supabase,
|
||||
ctx.companyId!,
|
||||
ctx.userId,
|
||||
invoice as SupplierInvoice,
|
||||
(invoice.items || []) as SupplierInvoiceItem[],
|
||||
transaction.date,
|
||||
invoice.supplier?.supplier_type || 'swedish_business',
|
||||
)
|
||||
if (je) journalEntryId = je.id
|
||||
} else {
|
||||
const je = await createSupplierInvoicePaymentEntry(
|
||||
ctx.supabase,
|
||||
ctx.companyId!,
|
||||
ctx.userId,
|
||||
invoice as SupplierInvoice,
|
||||
paymentAmountSek,
|
||||
transaction.date,
|
||||
exchangeRateDifference !== 0 ? exchangeRateDifference : undefined,
|
||||
)
|
||||
if (je) journalEntryId = je.id
|
||||
}
|
||||
} catch (err) {
|
||||
txLog.error('match-supplier-invoice: payment JE creation failed — aborting before state mutation', err as Error)
|
||||
const message = isBookkeepingError(err)
|
||||
? getErrorMessage(err, { context: 'supplier_invoice' })
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: 'Unknown error'
|
||||
return v1ErrorResponseFromCode('MATCH_SI_RECORD_PAYMENT_FAILED', txLog, {
|
||||
requestId: ctx.requestId,
|
||||
details: { reason: message },
|
||||
})
|
||||
}
|
||||
|
||||
const newRemaining = Math.max(
|
||||
0,
|
||||
Math.round((invoice.remaining_amount - paymentAmountInvoiceCurrency) * 100) / 100,
|
||||
)
|
||||
const newPaidAmount =
|
||||
Math.round((invoice.paid_amount + paymentAmountInvoiceCurrency) * 100) / 100
|
||||
const isFullyPaid = newRemaining <= 0
|
||||
const newStatus = isFullyPaid ? 'paid' : 'partially_paid'
|
||||
|
||||
const { data: updatedRows, error: updateInvErr } = await ctx.supabase
|
||||
.from('supplier_invoices')
|
||||
.update({
|
||||
status: newStatus,
|
||||
remaining_amount: newRemaining,
|
||||
paid_amount: newPaidAmount,
|
||||
paid_at: isFullyPaid ? now : null,
|
||||
payment_journal_entry_id: journalEntryId,
|
||||
transaction_id: txId,
|
||||
})
|
||||
.eq('id', supplier_invoice_id)
|
||||
.eq('company_id', ctx.companyId!)
|
||||
// 'overdue' must appear here — the early status guard accepts it as
|
||||
// matchable, so excluding it here would return MATCH_SI_NOT_OPEN
|
||||
// for a legitimately payable invoice.
|
||||
.in('status', ['registered', 'approved', 'partially_paid', 'overdue'])
|
||||
.select('id')
|
||||
if (updateInvErr) return v1ErrorResponse(updateInvErr, txLog, { requestId: ctx.requestId })
|
||||
if (!updatedRows || updatedRows.length === 0) {
|
||||
return v1ErrorResponseFromCode('MATCH_SI_NOT_OPEN', txLog, {
|
||||
requestId: ctx.requestId,
|
||||
})
|
||||
}
|
||||
|
||||
const { error: paymentInsertErr } = await ctx.supabase
|
||||
.from('supplier_invoice_payments')
|
||||
.insert({
|
||||
user_id: ctx.userId,
|
||||
company_id: ctx.companyId!,
|
||||
supplier_invoice_id,
|
||||
payment_date: transaction.date,
|
||||
amount: paymentAmountInvoiceCurrency,
|
||||
currency: invoice.currency,
|
||||
journal_entry_id: journalEntryId,
|
||||
transaction_id: txId,
|
||||
})
|
||||
if (paymentInsertErr) {
|
||||
if (paymentInsertErr.code === '23505') {
|
||||
return v1ErrorResponseFromCode('MATCH_SI_DUPLICATE_PAYMENT', txLog, {
|
||||
requestId: ctx.requestId,
|
||||
})
|
||||
}
|
||||
txLog.error('failed to record payment', paymentInsertErr)
|
||||
return v1ErrorResponseFromCode('MATCH_SI_RECORD_PAYMENT_FAILED', txLog, {
|
||||
requestId: ctx.requestId,
|
||||
})
|
||||
}
|
||||
|
||||
const { error: updateTxErr } = await ctx.supabase
|
||||
.from('transactions')
|
||||
.update({
|
||||
supplier_invoice_id,
|
||||
journal_entry_id: journalEntryId,
|
||||
is_business: true,
|
||||
})
|
||||
.eq('id', txId)
|
||||
.eq('company_id', ctx.companyId!)
|
||||
if (updateTxErr) {
|
||||
return v1ErrorResponseFromCode('MATCH_SI_LINK_TX_FAILED', txLog, {
|
||||
requestId: ctx.requestId,
|
||||
})
|
||||
}
|
||||
|
||||
logMatchEvent(ctx.supabase, ctx.userId, txId, 'matched', {
|
||||
supplierInvoiceId: supplier_invoice_id,
|
||||
matchConfidence: 1.0,
|
||||
matchMethod: 'manual_confirm',
|
||||
newState: { status: newStatus, paid_amount: newPaidAmount, remaining_amount: newRemaining },
|
||||
})
|
||||
|
||||
try {
|
||||
eventBus.emit({
|
||||
type: 'supplier_invoice.match_confirmed',
|
||||
payload: {
|
||||
supplierInvoice: invoice as SupplierInvoice,
|
||||
transaction: transaction as Transaction,
|
||||
userId: ctx.userId,
|
||||
companyId: ctx.companyId!,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
txLog.warn('event emit failed (non-critical)', err as Error)
|
||||
}
|
||||
|
||||
return ok(
|
||||
{
|
||||
success: true,
|
||||
invoice_status: newStatus,
|
||||
paid_amount: newPaidAmount,
|
||||
remaining_amount: newRemaining,
|
||||
journal_entry_id: journalEntryId,
|
||||
},
|
||||
{ requestId: ctx.requestId },
|
||||
)
|
||||
},
|
||||
{ requireIdempotencyKey: true },
|
||||
)
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* GET /api/v1/companies/{companyId}/transactions/{id}
|
||||
*
|
||||
* Single transaction detail. Includes match state (invoice, supplier
|
||||
* invoice), booking state (journal_entry_id), and import metadata.
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import { ok } from '@/lib/api/v1/response'
|
||||
import { registerEndpoint } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
|
||||
const TransactionDetail = z.object({
|
||||
id: z.string().uuid(),
|
||||
date: z.string(),
|
||||
description: z.string().nullable(),
|
||||
amount: z.number(),
|
||||
currency: z.string(),
|
||||
amount_sek: z.number().nullable(),
|
||||
reference: z.string().nullable(),
|
||||
merchant_name: z.string().nullable(),
|
||||
counterparty_account: z.string().nullable(),
|
||||
journal_entry_id: z.string().uuid().nullable(),
|
||||
invoice_id: z.string().uuid().nullable(),
|
||||
supplier_invoice_id: z.string().uuid().nullable(),
|
||||
potential_invoice_id: z.string().uuid().nullable(),
|
||||
is_business: z.boolean().nullable(),
|
||||
category: z.string().nullable(),
|
||||
receipt_id: z.string().uuid().nullable(),
|
||||
document_id: z.string().uuid().nullable(),
|
||||
external_id: z.string().nullable(),
|
||||
import_source: z.string().nullable(),
|
||||
reconciliation_method: z.string().nullable(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
})
|
||||
|
||||
// Detail endpoint carve-out: a single-row drill-in is the user's intentional
|
||||
// request for the full row. Verbose by design — list endpoint stays minimal.
|
||||
const TRANSACTION_DETAIL_COLUMNS =
|
||||
'id, date, description, amount, currency, amount_sek, reference, merchant_name, ' +
|
||||
'counterparty_account, journal_entry_id, invoice_id, supplier_invoice_id, ' +
|
||||
'potential_invoice_id, is_business, category, receipt_id, document_id, ' +
|
||||
'external_id, import_source, reconciliation_method, created_at, updated_at'
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'transactions.get',
|
||||
method: 'GET',
|
||||
path: '/api/v1/companies/:companyId/transactions/:id',
|
||||
summary: 'Retrieve a single transaction by id.',
|
||||
description: 'Returns the full transaction record including match state, booking state, and import metadata.',
|
||||
useWhen:
|
||||
'You have a transaction id (from the list or a webhook) and need the full record before deciding to categorize, match, or attach a document.',
|
||||
doNotUseFor:
|
||||
'Walking the ledger — use the list endpoint with a cursor. Fetching the linked invoice/journal entry — separate endpoints.',
|
||||
pitfalls: [
|
||||
'Both invoice_id (matched) and potential_invoice_id (suggested) can be set independently. The matched id is authoritative for accounting.',
|
||||
'reconciliation_method is null for transactions that have never been auto-reconciled. journal_entry_id may still be set via manual categorize.',
|
||||
],
|
||||
example: {
|
||||
response: {
|
||||
data: {
|
||||
id: 'a8f1…',
|
||||
date: '2026-05-12',
|
||||
amount: -349.5,
|
||||
currency: 'SEK',
|
||||
journal_entry_id: null,
|
||||
is_business: null,
|
||||
},
|
||||
meta: { request_id: 'req_…', api_version: '2026-05-12' },
|
||||
},
|
||||
},
|
||||
scope: 'transactions:read',
|
||||
risk: 'low',
|
||||
idempotent: true,
|
||||
reversible: false,
|
||||
dryRunSupported: false,
|
||||
response: { success: TransactionDetail },
|
||||
})
|
||||
|
||||
export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
|
||||
'transactions.get',
|
||||
async (_request, ctx, params) => {
|
||||
const { id } = await params.params
|
||||
const idParse = z.string().uuid().safeParse(id)
|
||||
if (!idParse.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: 'id', message: 'Transaction id must be a UUID.' },
|
||||
})
|
||||
}
|
||||
|
||||
const { data, error } = await ctx.supabase
|
||||
.from('transactions')
|
||||
.select(TRANSACTION_DETAIL_COLUMNS)
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.eq('id', idParse.data)
|
||||
.maybeSingle()
|
||||
|
||||
if (error) return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId })
|
||||
if (!data) {
|
||||
ctx.log.warn('transactions.get: not found', { id: idParse.data })
|
||||
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { resource: 'transaction' },
|
||||
})
|
||||
}
|
||||
return ok(data, { requestId: ctx.requestId })
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* POST /api/v1/companies/{companyId}/transactions/{id}/uncategorize
|
||||
*
|
||||
* Reverse the categorization of a transaction:
|
||||
* 1. Storno the existing journal entry (BFL-compliant — JEs are never
|
||||
* deleted, they're cancelled via a reversing entry).
|
||||
* 2. Reset is_business / category / journal_entry_id on the transaction.
|
||||
*
|
||||
* Idempotent. Dry-runnable. The body is empty — the transaction id in the
|
||||
* path is the only input.
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import { ok } from '@/lib/api/v1/response'
|
||||
import { dryRunPreview } from '@/lib/api/v1/dry-run'
|
||||
import { registerEndpoint } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import { reverseEntry } from '@/lib/bookkeeping/engine'
|
||||
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
const UncategorizeResponse = z.object({
|
||||
success: z.boolean(),
|
||||
reversed_journal_entry_id: z.string().uuid(),
|
||||
})
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'transactions.uncategorize',
|
||||
method: 'POST',
|
||||
path: '/api/v1/companies/:companyId/transactions/:id/uncategorize',
|
||||
summary: 'Reverse the categorization of a transaction (storno + reset).',
|
||||
description:
|
||||
'Storno the transaction\'s journal entry (BFL 5 kap 5 §: posted entries are never deleted, only cancelled via a reversing entry) and reset is_business / category / journal_entry_id on the transaction row. Idempotent — a second call on an already-uncategorized transaction returns 400 TX_UNCATEGORIZE_NOT_BOOKED. Dry-runnable.',
|
||||
useWhen:
|
||||
'You categorized a transaction by mistake and want to redo it from scratch. The storno keeps the audit trail intact.',
|
||||
doNotUseFor:
|
||||
'Changing the categorization of an already-booked transaction — categorize again instead (the second call sees journal_entry_id and only updates flags). Reversing a payment match — there is no v1 verb for that yet.',
|
||||
pitfalls: [
|
||||
'Idempotency-Key is mandatory.',
|
||||
'The storno creates a new (cancelling) journal entry. The original entry stays in the ledger marked as cancelled — voucher gaps are documented automatically.',
|
||||
'A transaction without a journal_entry_id returns 400 TX_UNCATEGORIZE_NOT_BOOKED — there is nothing to reverse.',
|
||||
],
|
||||
example: {
|
||||
response: {
|
||||
data: { success: true, reversed_journal_entry_id: 'je_…' },
|
||||
meta: { request_id: 'req_…', api_version: '2026-05-12' },
|
||||
},
|
||||
},
|
||||
scope: 'transactions:write',
|
||||
risk: 'medium',
|
||||
idempotent: true,
|
||||
reversible: false, // The reversal itself cannot be reversed via this endpoint.
|
||||
dryRunSupported: true,
|
||||
response: { success: UncategorizeResponse },
|
||||
})
|
||||
|
||||
export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
|
||||
'transactions.uncategorize',
|
||||
async (_request, ctx, params) => {
|
||||
const { id } = await params.params
|
||||
const idParse = z.string().uuid().safeParse(id)
|
||||
if (!idParse.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: 'id', message: 'Transaction id must be a UUID.' },
|
||||
})
|
||||
}
|
||||
const txId = idParse.data
|
||||
|
||||
const { data: transaction, error: fetchErr } = await ctx.supabase
|
||||
.from('transactions')
|
||||
.select('id, journal_entry_id')
|
||||
.eq('id', txId)
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.single()
|
||||
|
||||
if (fetchErr || !transaction) {
|
||||
return v1ErrorResponseFromCode('TX_CATEGORIZE_TX_NOT_FOUND', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
})
|
||||
}
|
||||
|
||||
if (!transaction.journal_entry_id) {
|
||||
return v1ErrorResponseFromCode('TX_UNCATEGORIZE_NOT_BOOKED', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
})
|
||||
}
|
||||
|
||||
const { data: entry, error: entryErr } = await ctx.supabase
|
||||
.from('journal_entries')
|
||||
.select('id, status')
|
||||
.eq('id', transaction.journal_entry_id)
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.single()
|
||||
if (entryErr || !entry) {
|
||||
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { resource: 'journal_entry' },
|
||||
})
|
||||
}
|
||||
if (entry.status !== 'posted') {
|
||||
return v1ErrorResponseFromCode('TX_UNCATEGORIZE_JE_NOT_POSTED', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { currentStatus: entry.status },
|
||||
})
|
||||
}
|
||||
|
||||
if (ctx.dryRun) {
|
||||
return dryRunPreview(
|
||||
{
|
||||
would_storno_journal_entry_id: transaction.journal_entry_id,
|
||||
would_reset_transaction: { is_business: null, category: null, journal_entry_id: null },
|
||||
},
|
||||
{ requestId: ctx.requestId, log: ctx.log },
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
await reverseEntry(ctx.supabase, ctx.companyId!, ctx.userId, transaction.journal_entry_id)
|
||||
} catch (err) {
|
||||
ctx.log.error('transactions.uncategorize: reversal failed', err as Error)
|
||||
if (isBookkeepingError(err)) {
|
||||
return v1ErrorResponseFromCode('TX_UNCATEGORIZE_JE_NOT_POSTED', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { message: getErrorMessage(err, { context: 'transaction' }) },
|
||||
})
|
||||
}
|
||||
return v1ErrorResponse(err, ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
|
||||
const { error: updateErr } = await ctx.supabase
|
||||
.from('transactions')
|
||||
.update({ is_business: null, category: null, journal_entry_id: null })
|
||||
.eq('id', txId)
|
||||
.eq('company_id', ctx.companyId!)
|
||||
if (updateErr) return v1ErrorResponse(updateErr, ctx.log, { requestId: ctx.requestId })
|
||||
|
||||
return ok(
|
||||
{
|
||||
success: true,
|
||||
reversed_journal_entry_id: transaction.journal_entry_id as string,
|
||||
},
|
||||
{ requestId: ctx.requestId },
|
||||
)
|
||||
},
|
||||
{ requireIdempotencyKey: true },
|
||||
)
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Integration tests for GET /api/v1/companies/:companyId/transactions
|
||||
* (list) and GET .../:id (detail).
|
||||
*/
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
beforeAll(() => {
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
throw new Error(`tx route tests require NODE_ENV=test`)
|
||||
}
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
|
||||
})
|
||||
|
||||
vi.mock('@/lib/auth/api-keys', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
|
||||
return {
|
||||
...actual,
|
||||
validateApiKey: vi.fn(),
|
||||
createServiceClientNoCookies: vi.fn(),
|
||||
}
|
||||
})
|
||||
vi.mock('@supabase/supabase-js', async () => {
|
||||
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
|
||||
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
|
||||
})
|
||||
|
||||
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import { GET as listTransactions } from '../route'
|
||||
import { GET as getTransaction } from '../[id]/route'
|
||||
|
||||
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
|
||||
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
|
||||
|
||||
type MockResult = { data?: unknown; error?: unknown }
|
||||
function makeFlexibleSupabase(byTable: Record<string, MockResult | MockResult[]>) {
|
||||
const queues = new Map<string, MockResult[]>()
|
||||
for (const [t, val] of Object.entries(byTable)) {
|
||||
queues.set(t, Array.isArray(val) ? [...val] : [val])
|
||||
}
|
||||
const buildChain = (table: string): unknown => {
|
||||
const handler: ProxyHandler<object> = {
|
||||
get(_target, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) => {
|
||||
const q = queues.get(table)
|
||||
const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null })
|
||||
resolve(next)
|
||||
}
|
||||
}
|
||||
return (..._args: unknown[]) => buildChain(table)
|
||||
},
|
||||
}
|
||||
return new Proxy({}, handler)
|
||||
}
|
||||
return { from: vi.fn((table: string) => buildChain(table)) }
|
||||
}
|
||||
|
||||
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
const TX_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
|
||||
const USER_ID = 'user-1'
|
||||
|
||||
function makeRequest(url: string): Request {
|
||||
return new Request(url, {
|
||||
method: 'GET',
|
||||
headers: { Authorization: 'Bearer test-fixture-not-a-real-key' },
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: USER_ID,
|
||||
companyId: COMPANY_ID,
|
||||
apiKeyId: 'ak_1',
|
||||
scopes: ['transactions:read'],
|
||||
mode: 'live',
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/v1/companies/:companyId/transactions', () => {
|
||||
it('returns a list with pagination metadata', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
transactions: {
|
||||
data: [
|
||||
{ id: TX_ID, date: '2026-05-12', amount: -100, currency: 'SEK', description: 'ICA' },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await listTransactions(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/transactions`),
|
||||
{ params: Promise.resolve({ companyId: COMPANY_ID }) },
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data).toHaveLength(1)
|
||||
// No next page → omitted (paginated() helper drops the key entirely
|
||||
// when nextCursor is undefined).
|
||||
expect(body.meta.next_cursor).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects invalid status filter with 400', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await listTransactions(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/transactions?status=unknown`,
|
||||
),
|
||||
{ params: Promise.resolve({ companyId: COMPANY_ID }) },
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects keys without transactions:read scope', async () => {
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: USER_ID,
|
||||
companyId: COMPANY_ID,
|
||||
scopes: ['invoices:read'],
|
||||
mode: 'live',
|
||||
})
|
||||
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
|
||||
const res = await listTransactions(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/transactions`),
|
||||
{ params: Promise.resolve({ companyId: COMPANY_ID }) },
|
||||
)
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/v1/companies/:companyId/transactions/:id', () => {
|
||||
it('returns 200 with the transaction', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
transactions: {
|
||||
data: { id: TX_ID, date: '2026-05-12', amount: -100, currency: 'SEK' },
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
)
|
||||
const res = await getTransaction(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}`),
|
||||
{ params: Promise.resolve({ companyId: COMPANY_ID, id: TX_ID }) },
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
it('returns 404 NOT_FOUND for unknown id', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
transactions: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await getTransaction(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}`),
|
||||
{ params: Promise.resolve({ companyId: COMPANY_ID, id: TX_ID }) },
|
||||
)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('rejects non-UUID id with 400', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await getTransaction(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/not-a-uuid`),
|
||||
{ params: Promise.resolve({ companyId: COMPANY_ID, id: 'not-a-uuid' }) },
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,457 @@
|
||||
/**
|
||||
* POST /api/v1/companies/{companyId}/transactions/batch-categorize
|
||||
*
|
||||
* Apply a single categorization to up to 100 transactions in one call.
|
||||
* Partial-success semantics — per-item failure does not roll back items
|
||||
* that succeeded. Each item is processed through the same orchestration
|
||||
* as the single :categorize endpoint, so it can fail individually for any
|
||||
* of the same reasons (invalid template, invalid mapping, race, etc.).
|
||||
*
|
||||
* Idempotent over the whole batch. Dry-runnable.
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import { ok } from '@/lib/api/v1/response'
|
||||
import { dryRunPreview } from '@/lib/api/v1/dry-run'
|
||||
import { registerEndpoint } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import { checkPeriodLock } from '@/lib/api/v1/check-period-lock'
|
||||
import { CategorizeTransactionSchema } from '@/lib/api/schemas'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping'
|
||||
import {
|
||||
getTemplateById,
|
||||
buildMappingResultFromTemplate,
|
||||
validateTemplateForEntity,
|
||||
} from '@/lib/bookkeeping/booking-templates'
|
||||
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
|
||||
import { reverseEntry } from '@/lib/bookkeeping/engine'
|
||||
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import type { Logger } from '@/lib/logger'
|
||||
import type { EntityType, Transaction, TransactionCategory } from '@/types'
|
||||
|
||||
const BatchItem = z.object({
|
||||
transaction_id: z.string().uuid(),
|
||||
categorization: CategorizeTransactionSchema,
|
||||
})
|
||||
|
||||
const BatchRequest = z.object({
|
||||
items: z.array(BatchItem).min(1).max(100),
|
||||
all_or_nothing: z.boolean().optional().default(false),
|
||||
})
|
||||
|
||||
const ResultItem = z.object({
|
||||
ok: z.boolean(),
|
||||
request_index: z.number().int().nonnegative(),
|
||||
transaction_id: z.string().uuid(),
|
||||
data: z.unknown().optional(),
|
||||
error: z
|
||||
.object({
|
||||
code: z.string(),
|
||||
message: z.string(),
|
||||
details: z.unknown().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
|
||||
const BatchResponse = z.object({
|
||||
results: z.array(ResultItem),
|
||||
summary: z.object({
|
||||
total: z.number().int(),
|
||||
succeeded: z.number().int(),
|
||||
failed: z.number().int(),
|
||||
}),
|
||||
})
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'transactions.batch-categorize',
|
||||
method: 'POST',
|
||||
path: '/api/v1/companies/:companyId/transactions/batch-categorize',
|
||||
summary: 'Categorize up to 100 transactions in one call (partial-success).',
|
||||
description:
|
||||
'Per-item categorization mirroring the single :categorize endpoint. Same `{ results, summary }` shape as the other bulk endpoints. all_or_nothing: true returns 501 NOT_IMPLEMENTED. Idempotent over the whole batch.',
|
||||
useWhen:
|
||||
'You have many transactions to categorize with the same logic (e.g. apply a booking template across a queue, mark a batch as private, override accounts on a series).',
|
||||
doNotUseFor:
|
||||
'Categorizing transactions with mixed logic — make multiple :categorize calls. Auto-categorization via templates — handled inside `ingest` for matching rows, no separate endpoint needed.',
|
||||
pitfalls: [
|
||||
'Max 100 items per call. Sequential processing.',
|
||||
'Idempotency-Key covers the WHOLE batch — replays return the cached full response.',
|
||||
'all_or_nothing: true returns 501 NOT_IMPLEMENTED. Today only partial-success batches exist.',
|
||||
],
|
||||
example: {
|
||||
request: {
|
||||
items: [
|
||||
{ transaction_id: 'tx_1', categorization: { is_business: true, category: 'expense_office' } },
|
||||
],
|
||||
},
|
||||
response: {
|
||||
data: {
|
||||
results: [{ ok: true, request_index: 0, transaction_id: 'tx_1', data: { journal_entry_id: 'je_…' } }],
|
||||
summary: { total: 1, succeeded: 1, failed: 0 },
|
||||
},
|
||||
meta: { request_id: 'req_…', api_version: '2026-05-12' },
|
||||
},
|
||||
},
|
||||
scope: 'transactions:write',
|
||||
risk: 'medium',
|
||||
idempotent: true,
|
||||
reversible: true,
|
||||
dryRunSupported: true,
|
||||
request: { body: BatchRequest },
|
||||
response: { success: BatchResponse },
|
||||
})
|
||||
|
||||
interface Item {
|
||||
ok: boolean
|
||||
request_index: number
|
||||
transaction_id: string
|
||||
data?: unknown
|
||||
error?: { code: string; message: string; details?: unknown }
|
||||
}
|
||||
|
||||
async function categorizeOne(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
entityType: EntityType,
|
||||
index: number,
|
||||
transactionId: string,
|
||||
input: z.infer<typeof CategorizeTransactionSchema>,
|
||||
dryRun: boolean,
|
||||
log: Logger,
|
||||
): Promise<Item> {
|
||||
const { data: transaction, error: fetchErr } = await supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('id', transactionId)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
if (fetchErr || !transaction) {
|
||||
return {
|
||||
ok: false,
|
||||
request_index: index,
|
||||
transaction_id: transactionId,
|
||||
error: { code: 'TX_CATEGORIZE_TX_NOT_FOUND', message: 'Transaction not found.' },
|
||||
}
|
||||
}
|
||||
|
||||
const { is_business, category } = input
|
||||
let finalCategory: TransactionCategory
|
||||
if (input.template_id) {
|
||||
const template = getTemplateById(input.template_id)
|
||||
if (!template) {
|
||||
return {
|
||||
ok: false,
|
||||
request_index: index,
|
||||
transaction_id: transactionId,
|
||||
error: {
|
||||
code: 'TX_CATEGORIZE_INVALID_TEMPLATE',
|
||||
message: 'Unknown template id.',
|
||||
details: { templateId: input.template_id },
|
||||
},
|
||||
}
|
||||
}
|
||||
const valid = validateTemplateForEntity(template, entityType)
|
||||
if (!valid.valid) {
|
||||
return {
|
||||
ok: false,
|
||||
request_index: index,
|
||||
transaction_id: transactionId,
|
||||
error: {
|
||||
code: 'TX_CATEGORIZE_INVALID_TEMPLATE',
|
||||
message: 'Template not valid for entity type.',
|
||||
details: { templateId: input.template_id, reason: valid.error },
|
||||
},
|
||||
}
|
||||
}
|
||||
finalCategory = is_business ? template.fallback_category : 'private'
|
||||
} else {
|
||||
finalCategory = is_business ? category || 'uncategorized' : 'private'
|
||||
}
|
||||
|
||||
let mappingResult
|
||||
if (input.template_id) {
|
||||
const template = getTemplateById(input.template_id)!
|
||||
mappingResult = buildMappingResultFromTemplate(template, transaction as Transaction, entityType)
|
||||
} else {
|
||||
mappingResult = buildMappingResultFromCategory(
|
||||
finalCategory,
|
||||
transaction as Transaction,
|
||||
is_business,
|
||||
entityType,
|
||||
input.vat_treatment,
|
||||
)
|
||||
}
|
||||
if (!mappingResult.debit_account || !mappingResult.credit_account) {
|
||||
return {
|
||||
ok: false,
|
||||
request_index: index,
|
||||
transaction_id: transactionId,
|
||||
error: {
|
||||
code: 'TX_CATEGORIZE_INVALID_MAPPING',
|
||||
message: 'Could not resolve debit/credit accounts.',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
return {
|
||||
ok: true,
|
||||
request_index: index,
|
||||
transaction_id: transactionId,
|
||||
data: {
|
||||
preview: {
|
||||
category: finalCategory,
|
||||
debit_account: mappingResult.debit_account,
|
||||
credit_account: mappingResult.credit_account,
|
||||
vat_lines: mappingResult.vat_lines,
|
||||
would_create_journal_entry: !transaction.journal_entry_id,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Already-categorized: just flip flags.
|
||||
if (transaction.journal_entry_id) {
|
||||
const { error: updateErr } = await supabase
|
||||
.from('transactions')
|
||||
.update({ is_business, category: finalCategory })
|
||||
.eq('id', transactionId)
|
||||
.eq('company_id', companyId)
|
||||
if (updateErr) {
|
||||
return {
|
||||
ok: false,
|
||||
request_index: index,
|
||||
transaction_id: transactionId,
|
||||
error: { code: 'INTERNAL_ERROR', message: 'Failed to update flags.' },
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
request_index: index,
|
||||
transaction_id: transactionId,
|
||||
data: {
|
||||
journal_entry_created: false,
|
||||
journal_entry_id: transaction.journal_entry_id,
|
||||
category: finalCategory,
|
||||
already_had_journal_entry: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Period-lock pre-check — same rationale as the single :categorize route.
|
||||
// A locked period surfaces as PERIOD_LOCKED on the per-item error rather
|
||||
// than a generic INTERNAL_ERROR from the trigger exception.
|
||||
const periodLock = await checkPeriodLock(supabase, companyId, transaction.date)
|
||||
if (periodLock.locked) {
|
||||
return {
|
||||
ok: false,
|
||||
request_index: index,
|
||||
transaction_id: transactionId,
|
||||
error: {
|
||||
code: 'PERIOD_LOCKED',
|
||||
message: 'Period is locked or closed; cannot post journal entry.',
|
||||
details: {
|
||||
transaction_date: transaction.date,
|
||||
reason: periodLock.reason,
|
||||
fiscal_period_id: periodLock.fiscal_period_id,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let journalEntryId: string | null = null
|
||||
let journalEntryError: string | null = null
|
||||
try {
|
||||
const je = await createTransactionJournalEntry(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
transaction as Transaction,
|
||||
mappingResult,
|
||||
)
|
||||
if (je) journalEntryId = je.id
|
||||
} catch (err) {
|
||||
log.error('batch-categorize: journal entry creation failed', err as Error, {
|
||||
request_index: index,
|
||||
transactionId,
|
||||
})
|
||||
if (isBookkeepingError(err)) {
|
||||
journalEntryError = getErrorMessage(err, { context: 'transaction' })
|
||||
} else {
|
||||
journalEntryError = err instanceof Error ? err.message : 'Unknown error'
|
||||
}
|
||||
}
|
||||
|
||||
const { data: updated, error: updateErr } = await supabase
|
||||
.from('transactions')
|
||||
.update({
|
||||
is_business,
|
||||
category: finalCategory,
|
||||
journal_entry_id: journalEntryId,
|
||||
})
|
||||
.eq('id', transactionId)
|
||||
.eq('company_id', companyId)
|
||||
.is('journal_entry_id', null)
|
||||
.select('id')
|
||||
if (updateErr) {
|
||||
return {
|
||||
ok: false,
|
||||
request_index: index,
|
||||
transaction_id: transactionId,
|
||||
error: { code: 'INTERNAL_ERROR', message: updateErr.message },
|
||||
}
|
||||
}
|
||||
if ((!updated || updated.length === 0) && journalEntryId) {
|
||||
// CAS race — storno the orphan (BFL 5 kap 5 §). Direct status flip
|
||||
// would be blocked by enforce_journal_entry_immutability since the
|
||||
// engine writes the JE as posted. Same fix as the single :categorize
|
||||
// route. Storno keeps the verifikationsnummer series unbroken.
|
||||
try {
|
||||
await reverseEntry(supabase, companyId, userId, journalEntryId)
|
||||
} catch (revErr) {
|
||||
log.error('batch-categorize TX_CATEGORIZE_RACE: failed to storno orphaned JE', revErr as Error, {
|
||||
request_index: index,
|
||||
orphanJournalEntryId: journalEntryId,
|
||||
})
|
||||
// Document the gap so the orphan is traceable per BFL 5 kap 5 §.
|
||||
try {
|
||||
const { data: orphan } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('fiscal_period_id, voucher_series, voucher_number')
|
||||
.eq('id', journalEntryId)
|
||||
.single()
|
||||
if (orphan && orphan.voucher_series) {
|
||||
// Same rationale as the single :categorize route: skip the gap row
|
||||
// when no series exists rather than filing under a fallback series
|
||||
// that an audit query won't find.
|
||||
await supabase.from('voucher_gap_explanations').insert({
|
||||
company_id: companyId,
|
||||
fiscal_period_id: orphan.fiscal_period_id,
|
||||
voucher_series: orphan.voucher_series,
|
||||
gap_number: orphan.voucher_number,
|
||||
explanation:
|
||||
'CAS-race orphan; automatisk storno misslyckades. Manuell reconciliation krävs.',
|
||||
created_by: userId,
|
||||
})
|
||||
}
|
||||
} catch (gapErr) {
|
||||
log.error('batch-categorize: failed to log voucher_gap_explanations', gapErr as Error, {
|
||||
request_index: index,
|
||||
orphanJournalEntryId: journalEntryId,
|
||||
})
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
request_index: index,
|
||||
transaction_id: transactionId,
|
||||
error: { code: 'TX_CATEGORIZE_RACE', message: 'Concurrent state change.' },
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await eventBus.emit({
|
||||
type: 'transaction.categorized',
|
||||
payload: {
|
||||
transaction: transaction as Transaction,
|
||||
account: mappingResult.debit_account,
|
||||
taxCode: mappingResult.vat_lines[0]?.account_number || '',
|
||||
userId,
|
||||
companyId,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
log.warn('batch-categorize: event emit failed (non-critical)', err as Error)
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
request_index: index,
|
||||
transaction_id: transactionId,
|
||||
data: {
|
||||
journal_entry_created: !!journalEntryId,
|
||||
journal_entry_id: journalEntryId,
|
||||
journal_entry_error: journalEntryError,
|
||||
category: finalCategory,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
'transactions.batch-categorize',
|
||||
async (request, ctx) => {
|
||||
let rawBody: unknown
|
||||
try {
|
||||
rawBody = await request.json()
|
||||
} catch {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: 'body', message: 'Body is not valid JSON.' },
|
||||
})
|
||||
}
|
||||
const parsed = BatchRequest.safeParse(rawBody)
|
||||
if (!parsed.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
issues: parsed.error.issues.map((i) => ({
|
||||
field: i.path.join('.'),
|
||||
message: i.message,
|
||||
})),
|
||||
},
|
||||
})
|
||||
}
|
||||
const body = parsed.data
|
||||
|
||||
if (body.all_or_nothing) {
|
||||
return v1ErrorResponseFromCode('NOT_IMPLEMENTED', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
field: 'all_or_nothing',
|
||||
message: 'Use partial-success semantics (omit the flag or pass false).',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const { data: settings } = await ctx.supabase
|
||||
.from('company_settings')
|
||||
.select('entity_type')
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.single()
|
||||
const entityType: EntityType =
|
||||
(settings?.entity_type as EntityType) || 'enskild_firma'
|
||||
|
||||
const results: Item[] = []
|
||||
for (let i = 0; i < body.items.length; i++) {
|
||||
const item = body.items[i]
|
||||
const r = await categorizeOne(
|
||||
ctx.supabase,
|
||||
ctx.companyId!,
|
||||
ctx.userId,
|
||||
entityType,
|
||||
i,
|
||||
item.transaction_id,
|
||||
item.categorization,
|
||||
ctx.dryRun,
|
||||
ctx.log,
|
||||
)
|
||||
results.push(r)
|
||||
}
|
||||
|
||||
const summary = {
|
||||
total: results.length,
|
||||
succeeded: results.filter((r) => r.ok).length,
|
||||
failed: results.filter((r) => !r.ok).length,
|
||||
}
|
||||
|
||||
if (ctx.dryRun) {
|
||||
return dryRunPreview({ results, summary }, { requestId: ctx.requestId, log: ctx.log })
|
||||
}
|
||||
return ok({ results, summary }, { requestId: ctx.requestId })
|
||||
},
|
||||
{ requireIdempotencyKey: true },
|
||||
)
|
||||
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* Integration tests for POST /api/v1/companies/:companyId/transactions/ingest
|
||||
* and POST .../batch-categorize.
|
||||
*/
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
beforeAll(() => {
|
||||
if (process.env.NODE_ENV !== 'test') throw new Error('NODE_ENV=test required')
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
|
||||
})
|
||||
|
||||
vi.mock('@/lib/auth/api-keys', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
|
||||
return { ...actual, validateApiKey: vi.fn(), createServiceClientNoCookies: vi.fn() }
|
||||
})
|
||||
vi.mock('@supabase/supabase-js', async () => {
|
||||
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
|
||||
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
|
||||
})
|
||||
|
||||
const { ingestMock, createTxJE } = vi.hoisted(() => ({
|
||||
ingestMock: vi.fn().mockResolvedValue({
|
||||
imported: 2,
|
||||
duplicates: 1,
|
||||
reconciled: 0,
|
||||
auto_categorized: 0,
|
||||
auto_matched_invoices: 0,
|
||||
errors: 0,
|
||||
transaction_ids: ['11111111-1111-4111-8111-111111111111', '22222222-2222-4222-8222-222222222222'],
|
||||
}),
|
||||
createTxJE: vi.fn().mockResolvedValue({ id: 'je-bc' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/transactions/ingest', () => ({
|
||||
ingestTransactions: ingestMock,
|
||||
}))
|
||||
vi.mock('@/lib/bookkeeping/transaction-entries', () => ({
|
||||
createTransactionJournalEntry: createTxJE,
|
||||
}))
|
||||
|
||||
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import { POST as ingestPOST } from '../route'
|
||||
import { POST as batchPOST } from '../../batch-categorize/route'
|
||||
|
||||
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
|
||||
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
|
||||
|
||||
type MockResult = { data?: unknown; error?: unknown }
|
||||
function makeFlexibleSupabase(byTable: Record<string, MockResult | MockResult[]>) {
|
||||
const queues = new Map<string, MockResult[]>()
|
||||
for (const [t, val] of Object.entries(byTable)) {
|
||||
queues.set(t, Array.isArray(val) ? [...val] : [val])
|
||||
}
|
||||
const buildChain = (table: string): unknown => {
|
||||
const handler: ProxyHandler<object> = {
|
||||
get(_target, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) => {
|
||||
const q = queues.get(table)
|
||||
const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null })
|
||||
resolve(next)
|
||||
}
|
||||
}
|
||||
return (..._args: unknown[]) => buildChain(table)
|
||||
},
|
||||
}
|
||||
return new Proxy({}, handler)
|
||||
}
|
||||
return { from: vi.fn((table: string) => buildChain(table)) }
|
||||
}
|
||||
|
||||
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
const TX_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
|
||||
|
||||
function makeRequest(url: string, body: unknown): Request {
|
||||
return new Request(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer test-fixture-not-a-real-key',
|
||||
'Content-Type': 'application/json',
|
||||
'Idempotency-Key': 'idem1234-aaaa-4abc-8def-1234567890ab',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: 'user-1',
|
||||
companyId: COMPANY_ID,
|
||||
apiKeyId: 'ak_1',
|
||||
scopes: ['transactions:write'],
|
||||
mode: 'live',
|
||||
})
|
||||
})
|
||||
|
||||
const SAMPLE_TX = {
|
||||
date: '2026-05-12',
|
||||
description: 'ICA MAXI',
|
||||
amount: -349.5,
|
||||
currency: 'SEK',
|
||||
external_id: 'csv-line-42',
|
||||
merchant_name: 'ICA MAXI',
|
||||
}
|
||||
|
||||
describe('POST /transactions/ingest', () => {
|
||||
it('runs the ingest pipeline and returns the result', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await ingestPOST(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/ingest`, {
|
||||
transactions: [SAMPLE_TX, { ...SAMPLE_TX, external_id: 'csv-line-43' }],
|
||||
}),
|
||||
{ params: Promise.resolve({ companyId: COMPANY_ID }) },
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.imported).toBe(2)
|
||||
expect(body.data.duplicates).toBe(1)
|
||||
expect(ingestMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('dry-run returns dedup decisions without inserting', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
transactions: { data: [], error: null },
|
||||
}),
|
||||
)
|
||||
const res = await ingestPOST(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/ingest?dry_run=true`,
|
||||
{ transactions: [SAMPLE_TX] },
|
||||
),
|
||||
{ params: Promise.resolve({ companyId: COMPANY_ID }) },
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('X-Dry-Run')).toBe('true')
|
||||
expect(ingestMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects > 500 items', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
}),
|
||||
)
|
||||
const transactions = Array.from({ length: 501 }, (_, i) => ({
|
||||
...SAMPLE_TX,
|
||||
external_id: `csv-${i}`,
|
||||
}))
|
||||
const res = await ingestPOST(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/ingest`, {
|
||||
transactions,
|
||||
}),
|
||||
{ params: Promise.resolve({ companyId: COMPANY_ID }) },
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects keys without transactions:write scope', async () => {
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: 'user-1',
|
||||
companyId: COMPANY_ID,
|
||||
scopes: ['transactions:read'],
|
||||
mode: 'live',
|
||||
})
|
||||
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
|
||||
const res = await ingestPOST(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/ingest`, {
|
||||
transactions: [SAMPLE_TX],
|
||||
}),
|
||||
{ params: Promise.resolve({ companyId: COMPANY_ID }) },
|
||||
)
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /transactions/batch-categorize', () => {
|
||||
it('categorizes a batch with mixed success/failure', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
company_settings: { data: { entity_type: 'enskild_firma' }, error: null },
|
||||
transactions: [
|
||||
{
|
||||
data: {
|
||||
id: TX_ID,
|
||||
date: '2026-05-12',
|
||||
amount: -100,
|
||||
currency: 'SEK',
|
||||
merchant_name: 'ICA',
|
||||
journal_entry_id: null,
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
{ data: [{ id: TX_ID }], error: null }, // CAS update select for item 0
|
||||
{ data: null, error: { code: 'PGRST116' } }, // item 1 not found
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await batchPOST(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/batch-categorize`, {
|
||||
items: [
|
||||
{
|
||||
transaction_id: TX_ID,
|
||||
categorization: { is_business: true, category: 'expense_office' },
|
||||
},
|
||||
{
|
||||
transaction_id: '99999999-9999-4999-8999-999999999999',
|
||||
categorization: { is_business: true, category: 'expense_office' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ params: Promise.resolve({ companyId: COMPANY_ID }) },
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.summary.total).toBe(2)
|
||||
expect(body.data.summary.succeeded).toBe(1)
|
||||
expect(body.data.summary.failed).toBe(1)
|
||||
expect(body.data.results[1].error.code).toBe('TX_CATEGORIZE_TX_NOT_FOUND')
|
||||
})
|
||||
|
||||
it('rejects all_or_nothing: true with 501', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await batchPOST(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/batch-categorize`, {
|
||||
all_or_nothing: true,
|
||||
items: [{ transaction_id: TX_ID, categorization: { is_business: false } }],
|
||||
}),
|
||||
{ params: Promise.resolve({ companyId: COMPANY_ID }) },
|
||||
)
|
||||
expect(res.status).toBe(501)
|
||||
})
|
||||
|
||||
it('rejects > 100 items', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
}),
|
||||
)
|
||||
const items = Array.from({ length: 101 }, () => ({
|
||||
transaction_id: TX_ID,
|
||||
categorization: { is_business: false },
|
||||
}))
|
||||
const res = await batchPOST(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/batch-categorize`, {
|
||||
items,
|
||||
}),
|
||||
{ params: Promise.resolve({ companyId: COMPANY_ID }) },
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* POST /api/v1/companies/{companyId}/transactions/ingest
|
||||
*
|
||||
* Bulk-ingest transactions (CSV import, custom integrations, off-platform
|
||||
* bank feeds). Wraps the shared `ingestTransactions` library used by the
|
||||
* bank-file importer and the PSD2 sync.
|
||||
*
|
||||
* The pipeline runs:
|
||||
* 1. Dedup by external_id + content-based (date+amount).
|
||||
* 2. Insert into transactions.
|
||||
* 3. Auto-match invoices (OCR/reference + amount+customer fallback).
|
||||
* 4. Mapping-rule evaluation for auto-categorization.
|
||||
* 5. High-confidence auto-JE creation.
|
||||
*
|
||||
* Dry-run skips all writes and returns the dedup decision per item so
|
||||
* callers can preview what would be ingested before committing.
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import { ok } from '@/lib/api/v1/response'
|
||||
import { dryRunPreview } from '@/lib/api/v1/dry-run'
|
||||
import { registerEndpoint } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import { ingestTransactions } from '@/lib/transactions/ingest'
|
||||
import type { RawTransaction } from '@/types'
|
||||
|
||||
const RawTx = z.object({
|
||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'date must be ISO yyyy-MM-dd'),
|
||||
description: z.string().min(1).max(500),
|
||||
amount: z.number().refine((n) => n !== 0, 'amount must be non-zero'),
|
||||
currency: z.string().min(1).max(8),
|
||||
external_id: z.string().min(1).max(200),
|
||||
mcc_code: z.number().int().nullable().optional(),
|
||||
merchant_name: z.string().max(200).nullable().optional(),
|
||||
reference: z.string().max(200).nullable().optional(),
|
||||
import_source: z.string().min(1).max(50).optional(),
|
||||
})
|
||||
|
||||
const IngestRequest = z.object({
|
||||
transactions: z.array(RawTx).min(1).max(500),
|
||||
skip_auto_categorization: z.boolean().optional(),
|
||||
settlement_account: z
|
||||
.string()
|
||||
.regex(/^\d{4}$/, 'settlement_account must be a 4-digit account number')
|
||||
.optional(),
|
||||
raw_insert_only: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const IngestResponse = z.object({
|
||||
imported: z.number().int(),
|
||||
duplicates: z.number().int(),
|
||||
reconciled: z.number().int(),
|
||||
auto_categorized: z.number().int(),
|
||||
auto_matched_invoices: z.number().int(),
|
||||
errors: z.number().int(),
|
||||
transaction_ids: z.array(z.string().uuid()),
|
||||
})
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'transactions.ingest',
|
||||
method: 'POST',
|
||||
path: '/api/v1/companies/:companyId/transactions/ingest',
|
||||
summary: 'Bulk-ingest transactions (up to 500 per call).',
|
||||
description:
|
||||
'Runs the same ingest pipeline as the dashboard CSV importer and the PSD2 bank sync: dedup, insert, invoice match, mapping-rule auto-categorize, auto-JE for high-confidence matches. Idempotent over the whole batch via Idempotency-Key. Dry-runnable.',
|
||||
useWhen:
|
||||
'You\'re importing transactions from a CSV, a custom bank feed, or an external accounting system. Each item must have a stable external_id — this is the primary dedup key.',
|
||||
doNotUseFor:
|
||||
'Single ad-hoc transactions (use the dashboard). Documents/receipts (use the documents endpoint). Manually-created journal entries (Phase 4).',
|
||||
pitfalls: [
|
||||
'external_id is the primary dedup key — make it stable for the same physical transaction across reruns.',
|
||||
'Content-based dedup (date+amount) runs in addition: a CSV row that matches an already-booked transaction by date+amount is skipped even if external_id differs.',
|
||||
'raw_insert_only=true skips ALL post-insert pipeline steps (matching, categorization). Use for viewer-only imports.',
|
||||
'Max 500 items per call. For larger imports, split into pages of 500.',
|
||||
'Dry-run runs both dedup checks (external_id AND content-based date+amount against booked rows), matching the live pipeline. Numbers should agree barring concurrent imports between preview and commit.',
|
||||
],
|
||||
example: {
|
||||
request: {
|
||||
transactions: [
|
||||
{
|
||||
date: '2026-05-12',
|
||||
description: 'ICA MAXI',
|
||||
amount: -349.5,
|
||||
currency: 'SEK',
|
||||
external_id: 'csv-line-42',
|
||||
merchant_name: 'ICA MAXI',
|
||||
},
|
||||
],
|
||||
},
|
||||
response: {
|
||||
data: { imported: 1, skipped_duplicates: 0 },
|
||||
meta: { request_id: 'req_…', api_version: '2026-05-12' },
|
||||
},
|
||||
},
|
||||
scope: 'transactions:write',
|
||||
risk: 'medium',
|
||||
idempotent: true,
|
||||
reversible: false,
|
||||
dryRunSupported: true,
|
||||
request: { body: IngestRequest },
|
||||
response: { success: IngestResponse },
|
||||
})
|
||||
|
||||
export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
'transactions.ingest',
|
||||
async (request, ctx) => {
|
||||
let rawBody: unknown
|
||||
try {
|
||||
rawBody = await request.json()
|
||||
} catch {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: 'body', message: 'Body is not valid JSON.' },
|
||||
})
|
||||
}
|
||||
const parsed = IngestRequest.safeParse(rawBody)
|
||||
if (!parsed.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
issues: parsed.error.issues.map((i) => ({
|
||||
field: i.path.join('.'),
|
||||
message: i.message,
|
||||
})),
|
||||
},
|
||||
})
|
||||
}
|
||||
const body = parsed.data
|
||||
|
||||
if (ctx.dryRun) {
|
||||
// Dry-run runs BOTH dedup checks the live pipeline runs:
|
||||
// 1. external_id match against any existing transaction
|
||||
// 2. content match (date + amount) against already-booked rows
|
||||
// The live pipeline narrows (2) to date range + booked-only, so we
|
||||
// mirror that here. Without this, an integrator who relies on dry-run
|
||||
// to confirm uniqueness can ingest a duplicate affärshändelse —
|
||||
// BFL 5 kap requires löpande bokföring to reflect actual transactions
|
||||
// and forbids double-bookings.
|
||||
const externalIds = body.transactions.map((t) => t.external_id)
|
||||
const dates = [...body.transactions.map((t) => t.date)].sort()
|
||||
const dateFrom = dates[0]
|
||||
const dateTo = dates[dates.length - 1]
|
||||
|
||||
const { data: existingByExtId } = await ctx.supabase
|
||||
.from('transactions')
|
||||
.select('external_id')
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.in('external_id', externalIds)
|
||||
const knownExtIds = new Set(
|
||||
(existingByExtId ?? []).map((r) => (r as { external_id: string }).external_id),
|
||||
)
|
||||
|
||||
const { data: bookedInRange } = await ctx.supabase
|
||||
.from('transactions')
|
||||
.select('date, amount')
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.not('journal_entry_id', 'is', null)
|
||||
.gte('date', dateFrom)
|
||||
.lte('date', dateTo)
|
||||
// Normalize the amount to a fixed-precision string before keying.
|
||||
// Both JS number-to-string ("-349.5") and Postgres numeric round-trip
|
||||
// ("-349.50") collapse to the same "-349.50" representation here, so
|
||||
// a SIE amount with trailing-zero precision lines up with an already-
|
||||
// booked row whose amount JSON-encodes without it.
|
||||
const amountKey = (n: number): string => n.toFixed(2)
|
||||
const bookedKeys = new Set(
|
||||
(bookedInRange ?? []).map((r) => {
|
||||
const row = r as { date: string; amount: number }
|
||||
return `${row.date}|${amountKey(row.amount)}`
|
||||
}),
|
||||
)
|
||||
|
||||
const previewRows = body.transactions.map((tx) => {
|
||||
const extIdHit = knownExtIds.has(tx.external_id)
|
||||
const contentHit = bookedKeys.has(`${tx.date}|${amountKey(tx.amount)}`)
|
||||
const wouldSkip = extIdHit || contentHit
|
||||
const reason = extIdHit
|
||||
? 'external_id_match'
|
||||
: contentHit
|
||||
? 'content_match_booked'
|
||||
: null
|
||||
return {
|
||||
external_id: tx.external_id,
|
||||
date: tx.date,
|
||||
amount: tx.amount,
|
||||
currency: tx.currency,
|
||||
would_skip: wouldSkip,
|
||||
skip_reason: reason,
|
||||
}
|
||||
})
|
||||
const wouldImport = previewRows.filter((r) => !r.would_skip).length
|
||||
const wouldSkip = previewRows.filter((r) => r.would_skip).length
|
||||
|
||||
return dryRunPreview(
|
||||
{
|
||||
would_import: wouldImport,
|
||||
would_skip_duplicates: wouldSkip,
|
||||
items: previewRows,
|
||||
},
|
||||
{ requestId: ctx.requestId, log: ctx.log },
|
||||
)
|
||||
}
|
||||
|
||||
let result
|
||||
try {
|
||||
result = await ingestTransactions(
|
||||
ctx.supabase,
|
||||
ctx.companyId!,
|
||||
ctx.userId,
|
||||
body.transactions as RawTransaction[],
|
||||
{
|
||||
skipAutoCategorization: body.skip_auto_categorization,
|
||||
settlementAccount: body.settlement_account,
|
||||
rawInsertOnly: body.raw_insert_only,
|
||||
},
|
||||
)
|
||||
} catch (err) {
|
||||
ctx.log.error('transactions.ingest: pipeline failed', err as Error)
|
||||
return v1ErrorResponse(err, ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
|
||||
return ok(
|
||||
{
|
||||
imported: result.imported,
|
||||
duplicates: result.duplicates,
|
||||
reconciled: result.reconciled,
|
||||
auto_categorized: result.auto_categorized,
|
||||
auto_matched_invoices: result.auto_matched_invoices,
|
||||
errors: result.errors,
|
||||
transaction_ids: result.transaction_ids,
|
||||
},
|
||||
{ requestId: ctx.requestId },
|
||||
)
|
||||
},
|
||||
{ requireIdempotencyKey: true },
|
||||
)
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* GET /api/v1/companies/{companyId}/transactions
|
||||
*
|
||||
* Cursor-paginated transaction list. Filters: status (booked/unbooked),
|
||||
* date range, currency, search (description ilike). Default sort:
|
||||
* (date DESC, id ASC) — newest first, deterministic tie-break.
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import { paginated } from '@/lib/api/v1/response'
|
||||
import {
|
||||
decodeDefaultCursor,
|
||||
encodeDefaultCursor,
|
||||
parsePaginationParams,
|
||||
} from '@/lib/api/v1/pagination'
|
||||
import { registerEndpoint } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
|
||||
const TransactionSummary = z.object({
|
||||
id: z.string().uuid(),
|
||||
date: z.string(),
|
||||
description: z.string().nullable(),
|
||||
amount: z.number(),
|
||||
currency: z.string(),
|
||||
reference: z.string().nullable(),
|
||||
merchant_name: z.string().nullable(),
|
||||
journal_entry_id: z.string().uuid().nullable(),
|
||||
invoice_id: z.string().uuid().nullable(),
|
||||
supplier_invoice_id: z.string().uuid().nullable(),
|
||||
is_business: z.boolean().nullable(),
|
||||
category: z.string().nullable(),
|
||||
import_source: z.string().nullable(),
|
||||
created_at: z.string(),
|
||||
})
|
||||
|
||||
const TransactionListResponse = z.object({
|
||||
transactions: z.array(TransactionSummary),
|
||||
})
|
||||
|
||||
// Explicit projection — no SELECT *. created_at is required for cursor
|
||||
// stability (see ordering rationale in the GET handler).
|
||||
const TRANSACTION_SUMMARY_COLUMNS =
|
||||
'id, date, description, amount, currency, reference, merchant_name, ' +
|
||||
'journal_entry_id, invoice_id, supplier_invoice_id, is_business, category, ' +
|
||||
'import_source, created_at'
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'transactions.list',
|
||||
method: 'GET',
|
||||
path: '/api/v1/companies/:companyId/transactions',
|
||||
summary: 'List transactions for a company.',
|
||||
description:
|
||||
'Cursor-paginated transaction list ordered by created_at DESC, id ASC (newest-imported first; the `date` column is the transaction date and is filterable but not the sort key). Filter by ?status=booked|unbooked, ?currency, ?date_from / ?date_to, ?search (description ilike).',
|
||||
useWhen:
|
||||
'You need to walk a company\'s bank ledger — building a categorization queue, reconciling against external statements, or sampling for audit.',
|
||||
doNotUseFor:
|
||||
'Looking up one transaction by id (use the detail endpoint). Reconciliation status (use /reconciliation/bank/status).',
|
||||
pitfalls: [
|
||||
'Default page size is 50. Pass ?limit=100 for the maximum. Cursor pagination — pass ?cursor=<next_cursor> from the previous response.',
|
||||
'A booked transaction has a non-null journal_entry_id. is_business / category live on the transaction row even before booking.',
|
||||
'reverse-charge or storno entries can leave a transaction with journal_entry_id pointing at a cancelled JE — check status on the JE separately.',
|
||||
],
|
||||
example: {
|
||||
response: {
|
||||
data: [
|
||||
{
|
||||
id: 'a8f1…',
|
||||
date: '2026-05-12',
|
||||
description: 'ICA MAXI',
|
||||
amount: -349.5,
|
||||
currency: 'SEK',
|
||||
merchant_name: 'ICA MAXI',
|
||||
journal_entry_id: null,
|
||||
is_business: null,
|
||||
category: null,
|
||||
},
|
||||
],
|
||||
meta: { request_id: 'req_…', api_version: '2026-05-12', next_cursor: null },
|
||||
},
|
||||
},
|
||||
scope: 'transactions:read',
|
||||
risk: 'low',
|
||||
idempotent: true,
|
||||
reversible: false,
|
||||
dryRunSupported: false,
|
||||
response: { success: TransactionListResponse },
|
||||
})
|
||||
|
||||
export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
'transactions.list',
|
||||
async (request, ctx) => {
|
||||
const url = new URL(request.url)
|
||||
const { limit, cursor } = parsePaginationParams(url)
|
||||
const decoded = decodeDefaultCursor(cursor)
|
||||
|
||||
const FiltersSchema = z.object({
|
||||
status: z.enum(['booked', 'unbooked']).optional(),
|
||||
currency: z.string().min(1).max(8).optional(),
|
||||
date_from: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
.optional(),
|
||||
date_to: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
.optional(),
|
||||
search: z.string().min(1).max(200).optional(),
|
||||
})
|
||||
const filtersResult = FiltersSchema.safeParse({
|
||||
status: url.searchParams.get('status') ?? undefined,
|
||||
currency: url.searchParams.get('currency') ?? undefined,
|
||||
date_from: url.searchParams.get('date_from') ?? undefined,
|
||||
date_to: url.searchParams.get('date_to') ?? undefined,
|
||||
search: url.searchParams.get('search') ?? undefined,
|
||||
})
|
||||
if (!filtersResult.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
issues: filtersResult.error.issues.map((i) => ({
|
||||
field: i.path.join('.'),
|
||||
message: i.message,
|
||||
})),
|
||||
},
|
||||
})
|
||||
}
|
||||
const f = filtersResult.data
|
||||
|
||||
// Sort by (created_at DESC, id ASC). created_at is the stable cursor
|
||||
// anchor — it's a real timestamp (passes ISO-8601 validation in
|
||||
// decodeDefaultCursor), unique within a company at the row insertion
|
||||
// grain, and total-orderable. Sorting by `date` directly broke the
|
||||
// cursor (date is YYYY-MM-DD only, decoder rejects it). For users
|
||||
// who care about transaction-date ordering specifically, the date
|
||||
// is still in every row and ?date_from / ?date_to filters work.
|
||||
let query = ctx.supabase
|
||||
.from('transactions')
|
||||
.select(TRANSACTION_SUMMARY_COLUMNS)
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.order('created_at', { ascending: false })
|
||||
.order('id', { ascending: true })
|
||||
.limit(limit + 1)
|
||||
|
||||
if (f.status === 'booked') query = query.not('journal_entry_id', 'is', null)
|
||||
else if (f.status === 'unbooked') query = query.is('journal_entry_id', null)
|
||||
if (f.currency) query = query.eq('currency', f.currency)
|
||||
if (f.date_from) query = query.gte('date', f.date_from)
|
||||
if (f.date_to) query = query.lte('date', f.date_to)
|
||||
if (f.search) {
|
||||
// Two-step escape (PostgREST .or delimiters, then LIKE wildcards). Same
|
||||
// pattern as customers list.
|
||||
const term = f.search.replace(/[,()]/g, '').replace(/[%_\\]/g, '\\$&')
|
||||
query = query.or(`description.ilike.%${term}%,merchant_name.ilike.%${term}%`)
|
||||
}
|
||||
|
||||
if (decoded) {
|
||||
// Cursor is on (created_at DESC, id ASC). created_at moves backward;
|
||||
// id breaks ties.
|
||||
query = query.or(
|
||||
`created_at.lt.${decoded.ts},and(created_at.eq.${decoded.ts},id.gt.${decoded.id})`,
|
||||
)
|
||||
}
|
||||
|
||||
const { data, error } = await query
|
||||
if (error) {
|
||||
return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
|
||||
type Row = { id: string; created_at: string } & Record<string, unknown>
|
||||
const rows = (data ?? []) as unknown as Row[]
|
||||
const trimmed = rows.slice(0, limit)
|
||||
const hasMore = rows.length > limit
|
||||
const last = trimmed[trimmed.length - 1]
|
||||
const nextCursor =
|
||||
hasMore && last
|
||||
? encodeDefaultCursor({ id: last.id, created_at: last.created_at })
|
||||
: null
|
||||
|
||||
return paginated(trimmed, {
|
||||
requestId: ctx.requestId,
|
||||
nextCursor: nextCursor ?? undefined,
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -17,7 +17,7 @@ interface SIEUploadStepProps {
|
||||
onFileSelect: (file: File) => void
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
errorType?: 'duplicate' | 'duplicate_period' | 'validation' | 'parse'
|
||||
errorType?: 'duplicate' | 'duplicate_period' | 'validation' | 'parse' | 'network'
|
||||
validationErrors?: string[]
|
||||
validationWarnings?: string[]
|
||||
duplicateImportId?: string | null
|
||||
@@ -186,6 +186,7 @@ export default function SIEUploadStep({ onFileSelect, isLoading, error, errorTyp
|
||||
{errorType === 'duplicate_period' && 'Överlappande räkenskapsår'}
|
||||
{errorType === 'validation' && 'Filen innehåller valideringsfel'}
|
||||
{errorType === 'parse' && 'Kunde inte tolka filen'}
|
||||
{errorType === 'network' && 'Uppladdningen misslyckades'}
|
||||
{!errorType && 'Ett fel uppstod'}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{error}</p>
|
||||
@@ -221,6 +222,9 @@ export default function SIEUploadStep({ onFileSelect, isLoading, error, errorTyp
|
||||
{errorType === 'parse' && (
|
||||
<p>Kontrollera att filen är en SIE4-fil exporterad från ett bokföringsprogram (Fortnox, Visma, Bokio etc). Filen kan vara skadad om den redigerats manuellt.</p>
|
||||
)}
|
||||
{errorType === 'network' && (
|
||||
<p>Kontrollera din internetanslutning och försök igen. Om problemet kvarstår, prova att ladda upp filen från en dator eller hör av dig till support.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Application-layer period-lock check used by Phase 3 v1 write routes.
|
||||
*
|
||||
* The DB has two layers of protection:
|
||||
* - `enforce_period_lock` trigger blocks writes to any journal entry
|
||||
* whose fiscal_period is `is_closed = TRUE` or has `locked_at` set.
|
||||
* - `enforce_company_lock_date` trigger blocks writes on/before the
|
||||
* company-wide `bookkeeping_locked_through` date.
|
||||
*
|
||||
* Both triggers raise a Postgres exception, which Supabase surfaces as a
|
||||
* generic 500 with `BOOKKEEPING_DB_ERROR`. For the public API we want a
|
||||
* structured `PERIOD_LOCKED` response with enough context for an agent to
|
||||
* decide between (a) post to a later period or (b) ask the user to unlock.
|
||||
*
|
||||
* This helper performs the same check the trigger would, returning a
|
||||
* { locked, reason, fiscal_period_id } verdict. Run it BEFORE the JE insert
|
||||
* so callers get the structured error instead of the trigger exception.
|
||||
*
|
||||
* Note: this is a TOCTOU-window check (a period could be locked between
|
||||
* the check and the insert), but the trigger is still authoritative. The
|
||||
* helper is for ergonomics, not security.
|
||||
*/
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
export interface PeriodLockVerdict {
|
||||
locked: boolean
|
||||
reason?:
|
||||
| 'company_lock_date_covers'
|
||||
| 'period_locked_at_set'
|
||||
| 'period_is_closed'
|
||||
| 'no_fiscal_period'
|
||||
fiscal_period_id?: string
|
||||
}
|
||||
|
||||
export async function checkPeriodLock(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
date: string,
|
||||
): Promise<PeriodLockVerdict> {
|
||||
// Company-wide lock date covers everything on/before bookkeeping_locked_through.
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('bookkeeping_locked_through')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
const lockThrough = settings?.bookkeeping_locked_through ?? null
|
||||
if (lockThrough && date <= lockThrough) {
|
||||
return { locked: true, reason: 'company_lock_date_covers' }
|
||||
}
|
||||
|
||||
// Find the fiscal period covering the date.
|
||||
const { data: period } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id, is_closed, locked_at')
|
||||
.eq('company_id', companyId)
|
||||
.lte('period_start', date)
|
||||
.gte('period_end', date)
|
||||
.maybeSingle()
|
||||
|
||||
if (!period) {
|
||||
// No covering period. The engine's own ensure-period helper will create
|
||||
// one (open, unlocked) for ad-hoc booking dates, so this is not a hard
|
||||
// lock; let the JE insert proceed and surface engine errors normally.
|
||||
return { locked: false, reason: 'no_fiscal_period' }
|
||||
}
|
||||
if (period.is_closed) {
|
||||
return { locked: true, reason: 'period_is_closed', fiscal_period_id: period.id }
|
||||
}
|
||||
if (period.locked_at) {
|
||||
return { locked: true, reason: 'period_locked_at_set', fiscal_period_id: period.id }
|
||||
}
|
||||
|
||||
return { locked: false, fiscal_period_id: period.id }
|
||||
}
|
||||
@@ -30,4 +30,18 @@ import '@/app/api/v1/companies/[companyId]/invoices/bulk-create/route'
|
||||
import '@/app/api/v1/companies/[companyId]/invoices/[id]/pdf/route'
|
||||
import '@/app/api/v1/companies/[companyId]/customers/bulk-create/route'
|
||||
|
||||
// Phase 3 — transactions + reconciliation vertical.
|
||||
import '@/app/api/v1/companies/[companyId]/transactions/route'
|
||||
import '@/app/api/v1/companies/[companyId]/transactions/[id]/route'
|
||||
import '@/app/api/v1/companies/[companyId]/accounts/route'
|
||||
import '@/app/api/v1/companies/[companyId]/fiscal-periods/route'
|
||||
import '@/app/api/v1/companies/[companyId]/transactions/[id]/categorize/route'
|
||||
import '@/app/api/v1/companies/[companyId]/transactions/[id]/uncategorize/route'
|
||||
import '@/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route'
|
||||
import '@/app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/route'
|
||||
import '@/app/api/v1/companies/[companyId]/transactions/ingest/route'
|
||||
import '@/app/api/v1/companies/[companyId]/transactions/batch-categorize/route'
|
||||
import '@/app/api/v1/companies/[companyId]/reconciliation/bank/run/route'
|
||||
import '@/app/api/v1/companies/[companyId]/reconciliation/bank/status/route'
|
||||
|
||||
export {}
|
||||
|
||||
@@ -69,6 +69,24 @@ export const V1_ENDPOINT_SCOPES: Record<string, ApiKeyScope> = {
|
||||
'GET /api/v1/companies/:companyId/invoices/:id/pdf': 'invoices:read',
|
||||
'POST /api/v1/companies/:companyId/customers/bulk-create': 'customers:write',
|
||||
|
||||
// Phase 3 — transactions + reconciliation vertical.
|
||||
// Reads
|
||||
'GET /api/v1/companies/:companyId/transactions': 'transactions:read',
|
||||
'GET /api/v1/companies/:companyId/transactions/:id': 'transactions:read',
|
||||
'GET /api/v1/companies/:companyId/accounts': 'reports:read',
|
||||
'GET /api/v1/companies/:companyId/fiscal-periods': 'reports:read',
|
||||
// Writes — single transaction verbs
|
||||
'POST /api/v1/companies/:companyId/transactions/:id/categorize': 'transactions:write',
|
||||
'POST /api/v1/companies/:companyId/transactions/:id/uncategorize': 'transactions:write',
|
||||
'POST /api/v1/companies/:companyId/transactions/:id/match-invoice': 'transactions:write',
|
||||
'POST /api/v1/companies/:companyId/transactions/:id/match-supplier-invoice': 'transactions:write',
|
||||
// Writes — bulk
|
||||
'POST /api/v1/companies/:companyId/transactions/ingest': 'transactions:write',
|
||||
'POST /api/v1/companies/:companyId/transactions/batch-categorize': 'transactions:write',
|
||||
// Reconciliation
|
||||
'POST /api/v1/companies/:companyId/reconciliation/bank/run': 'transactions:write',
|
||||
'GET /api/v1/companies/:companyId/reconciliation/bank/status': 'transactions:read',
|
||||
|
||||
// Webhooks (Phase 6 — placeholder so the catalogue is complete)
|
||||
'GET /api/v1/companies/:companyId/webhooks': 'webhooks:manage',
|
||||
'POST /api/v1/companies/:companyId/webhooks': 'webhooks:manage',
|
||||
|
||||
@@ -388,6 +388,26 @@ const MATCH_SI: Record<string, StructuredErrorEntry> = {
|
||||
message_en:
|
||||
'Cash accounting does not support exchange-rate differences. Switch to accrual or book the FX difference manually.',
|
||||
},
|
||||
TX_UNCATEGORIZE_NOT_BOOKED: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Transaktionen är inte bokförd. Det finns inget att av-kategorisera.',
|
||||
message_en: 'Transaction has no journal entry — nothing to uncategorize.',
|
||||
},
|
||||
TX_UNCATEGORIZE_JE_NOT_POSTED: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Verifikationen är inte bokförd. Reversal kan inte utföras.',
|
||||
message_en: 'Journal entry is not in posted status; reversal is not possible.',
|
||||
},
|
||||
TX_INGEST_INSERT_FAILED: {
|
||||
httpStatus: 500,
|
||||
message_sv: 'Transaktionerna kunde inte importeras.',
|
||||
message_en: 'Transaction ingest failed.',
|
||||
},
|
||||
TX_BATCH_CATEGORIZE_EMPTY: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Batchen är tom.',
|
||||
message_en: 'Batch is empty — pass at least one item.',
|
||||
},
|
||||
}
|
||||
|
||||
const INVOICE: Record<string, StructuredErrorEntry> = {
|
||||
|
||||
Reference in New Issue
Block a user