Inbox UX overhaul + cross-currency supplier-invoice fixes (#444)
* feat(kpi): expense mix and top suppliers charts Replace the single monthly-trend chart with two additional compact visuals on /kpi: expense composition donut (BAS class 4-7) and top suppliers bar (supplier_invoices sum_sek over the fiscal period). KPIReport gains expenseComposition and topSuppliers fields, computed from the trial balance and supplier_invoices rows already fetched in the API. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(nav): swap Deadlines sidebar slot for Dokumentinkorg Sidebar main-menu slot now points to the invoice-inbox extension. The /deadlines page stays accessible via dashboard widgets and direct links — only the prominent nav entry changes. Most users open gnubok to act on incoming documents, not to read tax deadlines. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(supplier-invoices): cross-currency totals, FX residual, review SEK display Five fixes around foreign-currency supplier invoices: - Form layout: move Valuta / Växelkurs / Reverse charge from collapsed "Övrigt" into a visible row above the line-item table. Auto-fetch the Riksbanken rate when switching to a non-SEK currency; never clobber a user-typed rate; clear it when switching back to SEK. - Form submit: reset() the form on successful submit so the useUnsavedChanges hook detaches its beforeunload listener before the router.push, killing the "Are you sure you want to leave?" prompt that fired during Turbopack-mediated navigations. - BankTransactionPicker: drop the strict currency filter that hid every SEK transaction when the invoice was in EUR/USD. Cross-currency rows fall to the bottom with an "Annan valuta" hint instead of producing a meaningless numeric diff. - match-supplier-invoice route: when the bank transaction currency differs from the invoice currency, compute the FX diff against the AP-booked SEK and pass it to createSupplierInvoicePaymentEntry so 7960/3960 catches the residual instead of leaving a permanent stub on 2440. Fix also covers the "EUR transaction paying a SEK invoice" case that the first iteration missed. - Review dialog: buildJournalPreview now multiplies amounts by the exchange rate so the "Verifikation som bokförs" table shows the actual SEK numbers that hit the DB, not the EUR magnitudes labelled with no unit. Header gains an "(i SEK)" hint when foreign currency. Test coverage for the FX residual path covers SEK-SEK (no diff), SEK-into-EUR-invoice (loss), SEK-into-EUR-invoice (gain), foreign-tx- into-SEK-invoice, and the no-rate fallback. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(inbox): rate limits, multi-file UX, onboarding, retry, supplier autolink Big workspace pass on /e/general/invoice-inbox. Highlights: Backend - New table inbox_rate_counters + RPC check_and_increment_inbox_quota. Postgres-backed (no Upstash dep) per-company limit: 30/min, 500/day. Applied at /upload, /inbound, and /items/:id/retry-extraction. - POST /items/:id/retry-extraction — re-runs the deterministic extractor on a stored document when the previous attempt errored. - POST /items/:id/match-supplier — links a freshly-created supplier back to the inbox item so the next action prefills correctly. - POST /api/transactions/create-from-document — creates an uncategorized manual transaction from an inbox item for the "I have a receipt, no bank transaction" case. The user categorizes through the normal flow. - /inbound caps email at 20 attachments/email; truncated count goes to processing_history as AttachmentsTruncated. Rate-limit drops emit RateLimitedDropped and return 200 so Resend doesn't retry. - attach-document side effect: when the document came from an inbox item, the inbox row's matched_transaction_id is updated so the UI can flip it to "Kopplad till transaktion" without a round-trip. New migration: re-introduces matched_transaction_id on invoice_inbox_items as a plain FK (the AI metadata that the previous migration stripped doesn't come back). Workspace UI - Onboarding card replaces the thin empty-state with a 3-step checkmark guide (Aktivera adress → Ladda upp → Matcha eller bokför). Auto-hides when all three steps are done; localStorage-backed dismiss. Beta badge + link to gnubok.se/priser. - Responsive layout: 3-pane at lg, 2-pane at md, master-detail toggle on phone (list xor detail with a back button). - Filter pills (Alla / Behöver åtgärd / Bearbetade / Fel) + search input above the list — client-side over the existing items list. - Multi-file upload queue with "Laddar X av N…" progress counter on the button. Sequential to avoid hammering pdfjs. Selection stays put during a batch (only single-file drops auto-jump the detail pane). - Bulk select + delete with sticky action bar. Items linked to a supplier invoice are skipped with a count toast. - Retry button in the FieldsRail error branch. - "Skapa transaktion från underlag" CTA in the match dialog when no unmatched bank transactions exist. Prefills date/amount/description from the extracted data; user picks the sign. - "Skapa leverantör" inline CTA when the extractor caught a supplier name with no match against existing suppliers. POSTs /api/suppliers with the extracted fields, then auto-links via /items/:id/match-supplier. - Matched-state CTA renamed to "Bokför transaktionen" with link to /transactions?highlight=<id> so the categorize panel auto-opens. Tests - lib/rate-limits/__tests__/inbox.test.ts — RPC wrapper happy/error/scope - app/api/transactions/create-from-document/__tests__/route.test.ts — auth, validation, 404/409/200/500, inbox-link failure tolerated - extensions/general/invoice-inbox/__tests__/retry-extraction.test.ts — auth, rate limit, 404, 409, 400 no-doc, success, extraction failure - attach-document tests extend coverage to the new inbox-link side effect (both success and best-effort failure paths) - inbound-webhook test mocks the rate-limit module so the queued-mock sequence in each existing test doesn't have to know about it CLAUDE.md gains a row for lib/rate-limits/ so the new helper is discoverable. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(transactions): paperclip indicator and highlight-row param Close the feedback loop after a user attaches a receipt to a transaction from the inbox: the row in /transactions now shows a paperclip icon when transaction.document_id is set, with a click handler that fetches a signed download URL and opens the document in a new tab. Works for both uncategorized and history views. When the inbox sends a user to /transactions?highlight=<id>, the page now scrolls that row into view and auto-opens the categorize panel if the transaction is still uncategorized. Behind a double-rAF so the row DOM exists when scrollIntoView fires. QuickReviewDialog no longer prompts to upload underlag when the transaction already has a doc attached (which it does after the inbox match flow). Shows "Underlag bifogat — Visa" instead, opening the existing doc in a new tab. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(pr-444): address review feedback (Greptile + compliance bots) Migration rules - New migration 20260512092423: adds updated_at trigger on inbox_rate_counters (CLAUDE.md rule 2) and explicit USING (false) RLS policies for the four DML verbs to make the SECURITY DEFINER-only intent explicit (rule 1). - New pg-real test inbox-rate-limit.pg.test.ts covering happy path, minute-cap rejection, day-cap rejection, per-company isolation, and the updated_at trigger firing. CLAUDE.md mandates *.pg.test.ts for every new RPC because mocks pass on broken PL/pgSQL. Bugs - Stale exchange rate on currency switch (Greptile P1) — userTouchedRateRef was scoped per session, not per currency. Switching EUR (with a hand-edited rate) → USD kept the EUR rate. Now tracks the last fetched currency in a ref and resets the touched flag on currency change while still honoring manual edits within a single currency. - topSuppliersResult.error silently swallowed (Greptile P2) — failed queries used to render an empty chart matching the no-data state. Logged now. - Currency from extracted_data not validated (GDPR Art.25(2), OWASP V4.5, Swedish compliance bot) — extracted PDF currency was inserted into transactions.currency without sanitisation. Allowlisted against the six supported ISO 4217 codes; coerce to SEK otherwise. - Idempotency gap on create-from-document (OWASP V2.3) — two concurrent POSTs with the same inbox_item_id could each pass the matched_transaction_id IS NULL read and insert duplicate transactions. UPDATE now includes .is('matched_transaction_id', null) as an optimistic-lock release and returns 409 with an orphan-transaction rollback when the predicate doesn't match. - FX residual on cash-method match path (Swedish compliance bot) — createSupplierInvoiceCashEntry has no exchange_rate_difference path, so a cross-currency match would silently leave a 1930 reconciliation gap. Added a guard that returns MATCH_SI_CASH_FX_UNSUPPORTED (400) before the JE is created. Users on cash method can switch to accrual or book the FX diff manually. Design system - gap-y-1.5 / gap-1.5 in KPIExpenseMixChart — replaced with gap-y-2 / gap-2 (CLAUDE.md design tokens; 2.5/1.5/5/hardcoded pixels are forbidden spacing values). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(migrations): rename to match applied versions The mcp__plugin_supabase_supabase__apply_migration tool stamps its own timestamp when it applies a migration to the live project, so the version recorded in supabase_migrations.schema_migrations differs from my local generation-time filenames. Renaming the local files so a production CD run sees the migrations as already-applied (matching versions) instead of trying to re-apply them — which would fail for the trigger/RLS migration (CREATE TRIGGER and CREATE POLICY don't support IF NOT EXISTS). Follows the pattern from d854efcd ("chore(migration): rename to match applied version"). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(create-from-document): scope orphan rollback DELETE by company_id Defence in depth on the inbox-link race rollback. newTx.id is a fresh UUID from a company-scoped insert two statements above, so the existing single-key DELETE is already safe, but adding .eq('company_id', companyId) makes the cross-company invariant explicit on every write — addresses the OWASP ASVS V2.3 finding from the compliance swarm on PR #444. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(nav): mark Dokumentinkorg with Beta badge Same signal we use for Löner and Anställda — the inbox flow (AI extraction, supplier autolink, manual transaction creation) is in end-to-end customer testing. 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
8f15f98687
commit
17c67fece0
@@ -273,6 +273,7 @@ export async function POST(request: Request) {
|
||||
| `bankgiro/` | Luhn checksum validation |
|
||||
| `calendar/` | ICS generator, calendar utilities |
|
||||
| `errors/` | Swedish error message mapping (Zod → Postgres → HTTP → fallback) |
|
||||
| `rate-limits/` | Per-company Postgres-backed rate limiter (`checkInboxUploadRateLimit`) — used by inbox upload + email-inbound + retry-extraction. Calls `check_and_increment_inbox_quota` RPC; fails open on infra error. |
|
||||
| `hooks/` | React hooks (e.g., `use-unsaved-changes`, `use-can-write`) |
|
||||
| `logger.ts` | Structured logger with module prefixes, env-aware filtering |
|
||||
| `support.ts` | Server-side support recipient email (used by `/api/support/contact`) |
|
||||
|
||||
@@ -6,6 +6,8 @@ import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { FiscalYearSelector } from '@/components/common/FiscalYearSelector'
|
||||
import { KPIHeroCards } from '@/components/kpi/KPIHeroCards'
|
||||
import { KPITrendChart } from '@/components/kpi/KPITrendChart'
|
||||
import { KPIExpenseMixChart } from '@/components/kpi/KPIExpenseMixChart'
|
||||
import { KPITopSuppliersChart } from '@/components/kpi/KPITopSuppliersChart'
|
||||
import { KPISettingsDialog } from '@/components/kpi/KPISettingsDialog'
|
||||
import { getDefaultPreferences } from '@/lib/reports/kpi-definitions'
|
||||
import type { KPIReport, KPIPreferences } from '@/types'
|
||||
@@ -107,6 +109,10 @@ export default function KpiPage() {
|
||||
<>
|
||||
<KPIHeroCards report={report} preferences={preferences} />
|
||||
{report.months.length > 0 && <KPITrendChart months={report.months} />}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<KPIExpenseMixChart composition={report.expenseComposition} />
|
||||
<KPITopSuppliersChart suppliers={report.topSuppliers} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -133,6 +139,16 @@ function LoadingSkeleton() {
|
||||
<Skeleton className="h-56" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{[1, 2].map((i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="p-6 space-y-3">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-40" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { SupplierInvoiceReviewContent } from '@/components/suppliers/SupplierInv
|
||||
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
|
||||
import { getAccountDescription } from '@/lib/bookkeeping/account-descriptions'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { cn, formatCurrency } from '@/lib/utils'
|
||||
import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import BankTransactionPicker from '@/components/transactions/BankTransactionPicker'
|
||||
@@ -106,6 +107,7 @@ export default function NewSupplierInvoicePage() {
|
||||
const { toast } = useToast()
|
||||
|
||||
const [suppliers, setSuppliers] = useState<Supplier[]>([])
|
||||
const [suppliersLoaded, setSuppliersLoaded] = useState(false)
|
||||
const [accounts, setAccounts] = useState<BASAccount[]>([])
|
||||
const [entityType, setEntityType] = useState<EntityType>('enskild_firma')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
@@ -174,8 +176,11 @@ export default function NewSupplierInvoicePage() {
|
||||
|
||||
// One-shot: load inbox item and prefill form. Runs after suppliers are
|
||||
// loaded so we can resolve matched_supplier_id to a real picker value.
|
||||
// Gate on `suppliersLoaded`, not `suppliers.length > 0` — otherwise the
|
||||
// effect never fires for users who haven't booked a supplier yet and
|
||||
// the "Laddar uppgifter från inkorgen…" spinner sticks forever.
|
||||
useEffect(() => {
|
||||
if (!inboxItemId || hasPrefilled || suppliers.length === 0) return
|
||||
if (!inboxItemId || hasPrefilled || !suppliersLoaded) return
|
||||
let cancelled = false
|
||||
|
||||
;(async () => {
|
||||
@@ -266,7 +271,7 @@ export default function NewSupplierInvoicePage() {
|
||||
cancelled = true
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [inboxItemId, suppliers])
|
||||
}, [inboxItemId, suppliersLoaded, suppliers])
|
||||
|
||||
// Auto-fill due date and defaults when supplier is selected — but never
|
||||
// overwrite a value the AI already filled in for us.
|
||||
@@ -298,6 +303,50 @@ export default function NewSupplierInvoicePage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [watchedSupplierId, suppliers])
|
||||
|
||||
// Auto-fetch Riksbanken exchange rate when currency switches to non-SEK and
|
||||
// the user hasn't typed a custom rate yet. Re-fetches when the invoice
|
||||
// date changes too. Never overwrites a user-entered rate.
|
||||
const watchedInvoiceDate = watch('invoice_date')
|
||||
// The "user has manually edited the rate" flag is scoped *per currency*.
|
||||
// Switching from EUR (rate 11.8 edited by hand) to USD must re-fetch — the
|
||||
// EUR rate is meaningless for a USD invoice. Tracking last-fetched currency
|
||||
// lets us reset the touched flag on a currency switch while still honoring
|
||||
// a manual edit when only the invoice date changes within the same currency.
|
||||
const userTouchedRateRef = useRef(false)
|
||||
const lastFxCurrencyRef = useRef<string | null>(null)
|
||||
useEffect(() => {
|
||||
if (watchedCurrency === 'SEK') {
|
||||
setValue('exchange_rate', '')
|
||||
userTouchedRateRef.current = false
|
||||
lastFxCurrencyRef.current = null
|
||||
return
|
||||
}
|
||||
if (lastFxCurrencyRef.current !== watchedCurrency) {
|
||||
// Currency switched — drop the previous currency's manual-edit flag.
|
||||
userTouchedRateRef.current = false
|
||||
lastFxCurrencyRef.current = watchedCurrency
|
||||
}
|
||||
if (userTouchedRateRef.current) return
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const url = `/api/currency/rate?currency=${watchedCurrency}${
|
||||
watchedInvoiceDate ? `&date=${watchedInvoiceDate}` : ''
|
||||
}`
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) return
|
||||
const { data } = await res.json()
|
||||
if (cancelled || !data?.rate) return
|
||||
// Don't clobber a value the user typed while we were fetching.
|
||||
if (userTouchedRateRef.current) return
|
||||
setValue('exchange_rate', String(Math.round(data.rate * 10000) / 10000))
|
||||
} catch {
|
||||
// Non-critical — user can type the rate manually.
|
||||
}
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, [watchedCurrency, watchedInvoiceDate, setValue])
|
||||
|
||||
// Auto-select newly created supplier once it shows up in the list
|
||||
useEffect(() => {
|
||||
if (pendingSupplierSelect && suppliers.find((s) => s.id === pendingSupplierSelect)) {
|
||||
@@ -307,9 +356,13 @@ export default function NewSupplierInvoicePage() {
|
||||
}, [suppliers, pendingSupplierSelect, setValue])
|
||||
|
||||
async function fetchSuppliers() {
|
||||
const res = await fetch('/api/suppliers')
|
||||
const { data } = await res.json()
|
||||
setSuppliers(data || [])
|
||||
try {
|
||||
const res = await fetch('/api/suppliers')
|
||||
const { data } = await res.json()
|
||||
setSuppliers(data || [])
|
||||
} finally {
|
||||
setSuppliersLoaded(true)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAccounts() {
|
||||
@@ -546,6 +599,9 @@ export default function NewSupplierInvoicePage() {
|
||||
|
||||
// Auto-approve for EF
|
||||
const approveRes = await fetch(`/api/supplier-invoices/${result.data.id}/approve`, { method: 'POST' })
|
||||
// Clear dirty state so useUnsavedChanges doesn't fire the
|
||||
// beforeunload prompt while we navigate away on a successful submit.
|
||||
reset(data)
|
||||
if (!approveRes.ok) {
|
||||
toast({
|
||||
title: 'Varning',
|
||||
@@ -572,6 +628,8 @@ export default function NewSupplierInvoicePage() {
|
||||
const invoiceId = result.data.id
|
||||
const arrivalNumber = result.data.arrival_number
|
||||
setShowReview(false)
|
||||
// Clear dirty state — see comment in handleDirectSubmit.
|
||||
reset(pendingData)
|
||||
|
||||
if (pendingTransactionId) {
|
||||
const matchRes = await fetch(`/api/transactions/${pendingTransactionId}/match-supplier-invoice`, {
|
||||
@@ -663,6 +721,7 @@ export default function NewSupplierInvoicePage() {
|
||||
title: 'Kreditering ångrad och faktura registrerad',
|
||||
description: `Ankomstnummer: ${result.data.arrival_number}`,
|
||||
})
|
||||
reset(pendingData)
|
||||
router.push(`/supplier-invoices/${result.data.id}`)
|
||||
return
|
||||
}
|
||||
@@ -739,6 +798,7 @@ export default function NewSupplierInvoicePage() {
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
reset(pendingData)
|
||||
router.push(`/supplier-invoices/${invoiceId}`)
|
||||
}
|
||||
|
||||
@@ -880,6 +940,75 @@ export default function NewSupplierInvoicePage() {
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Valuta & moms — kept inline with the line items because they
|
||||
drive how each row is interpreted. Hidden defaults (SEK +
|
||||
normal moms) collapse to nothing so most users don't see this. */}
|
||||
<div className="mb-5 pb-5 border-b grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Valuta</Label>
|
||||
<Controller
|
||||
name="currency"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="SEK">SEK</SelectItem>
|
||||
<SelectItem value="EUR">EUR</SelectItem>
|
||||
<SelectItem value="USD">USD</SelectItem>
|
||||
<SelectItem value="GBP">GBP</SelectItem>
|
||||
<SelectItem value="NOK">NOK</SelectItem>
|
||||
<SelectItem value="DKK">DKK</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{watchedCurrency !== 'SEK' && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">
|
||||
Växelkurs <span className="text-muted-foreground">(till SEK)</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.0001"
|
||||
inputMode="decimal"
|
||||
placeholder="Hämtas från Riksbanken"
|
||||
className="h-9 text-right tabular-nums"
|
||||
{...register('exchange_rate', {
|
||||
onChange: () => { userTouchedRateRef.current = true },
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2',
|
||||
watchedCurrency === 'SEK' ? 'sm:col-span-2' : ''
|
||||
)}
|
||||
>
|
||||
<Controller
|
||||
name="reverse_charge"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
id="reverse_charge"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Label htmlFor="reverse_charge" className="text-xs cursor-pointer">
|
||||
Omvänd skattskyldighet
|
||||
<span className="block text-[11px] text-muted-foreground font-normal mt-0.5">
|
||||
Köp inom EU eller byggtjänster — momsen redovisas av köparen.
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop table */}
|
||||
<div className="hidden sm:block">
|
||||
<table className="w-full text-sm">
|
||||
@@ -1032,7 +1161,7 @@ export default function NewSupplierInvoicePage() {
|
||||
</div>
|
||||
<div className="flex justify-between items-center pt-1 border-t">
|
||||
<span className="text-xs text-muted-foreground">Moms</span>
|
||||
<span className="font-mono text-sm">{formatAmount(itemTotals[index]?.vatAmount || 0)} kr</span>
|
||||
<span className="font-mono text-sm">{formatCurrency(itemTotals[index]?.vatAmount || 0, watchedCurrency)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -1064,15 +1193,15 @@ export default function NewSupplierInvoicePage() {
|
||||
<div className="mt-4 pt-4 border-t space-y-2">
|
||||
<div className="flex justify-between sm:justify-end sm:gap-8">
|
||||
<span className="text-muted-foreground">Netto (exkl. moms)</span>
|
||||
<span className="font-mono sm:w-32 text-right">{formatAmount(subtotal)} kr</span>
|
||||
<span className="font-mono sm:w-32 text-right">{formatCurrency(subtotal, watchedCurrency)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between sm:justify-end sm:gap-8">
|
||||
<span className="text-muted-foreground">Moms</span>
|
||||
<span className="font-mono sm:w-32 text-right">{formatAmount(totalVat)} kr</span>
|
||||
<span className="font-mono sm:w-32 text-right">{formatCurrency(totalVat, watchedCurrency)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between sm:justify-end sm:gap-8 font-bold text-lg">
|
||||
<span>Totalt</span>
|
||||
<span className="font-mono sm:w-32 text-right">{formatAmount(total)} kr</span>
|
||||
<span className="font-mono sm:w-32 text-right">{formatCurrency(total, watchedCurrency)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -1091,50 +1220,10 @@ export default function NewSupplierInvoicePage() {
|
||||
</CardHeader>
|
||||
{advancedOpen && (
|
||||
<CardContent className="space-y-4 pt-0">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Valuta</Label>
|
||||
<Controller
|
||||
name="currency"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="SEK">SEK</SelectItem>
|
||||
<SelectItem value="EUR">EUR</SelectItem>
|
||||
<SelectItem value="USD">USD</SelectItem>
|
||||
<SelectItem value="GBP">GBP</SelectItem>
|
||||
<SelectItem value="NOK">NOK</SelectItem>
|
||||
<SelectItem value="DKK">DKK</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{watchedCurrency !== 'SEK' && (
|
||||
<div className="space-y-2">
|
||||
<Label>Växelkurs</Label>
|
||||
<Input type="number" step="0.0001" inputMode="decimal" placeholder="1,0000" className="text-right tabular-nums" {...register('exchange_rate')} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Leveransdatum (ML krav)</Label>
|
||||
<Input type="date" {...register('delivery_date')} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Controller
|
||||
name="reverse_charge"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Checkbox checked={field.value} onCheckedChange={field.onChange} />
|
||||
)}
|
||||
/>
|
||||
<Label>Omvänd skattskyldighet (reverse charge)</Label>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Anteckningar</Label>
|
||||
<Textarea placeholder="Interna anteckningar om denna faktura..." {...register('notes')} />
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { AnimatePresence } from 'framer-motion'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -123,6 +124,11 @@ export default function TransactionsPage() {
|
||||
const { toast } = useToast()
|
||||
const { dialogProps: deleteDialogProps, confirm: confirmDelete } = useDestructiveConfirm()
|
||||
const supabase = createClient()
|
||||
const searchParams = useSearchParams()
|
||||
const highlightId = searchParams.get('highlight')
|
||||
// Tracks the last highlight target we acted on so re-renders don't re-trigger
|
||||
// the auto-open every time the user closes the categorize panel.
|
||||
const handledHighlightRef = useRef<string | null>(null)
|
||||
|
||||
// Computed lists
|
||||
const uncategorizedTransactions = transactions
|
||||
@@ -293,6 +299,35 @@ export default function TransactionsPage() {
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
// Auto-open categorize panel when arriving via /transactions?highlight=<id>
|
||||
// (used by the inbox "Bokför transaktionen" link). Runs once per distinct
|
||||
// highlight id so closing the panel doesn't re-trigger it.
|
||||
useEffect(() => {
|
||||
if (!highlightId) return
|
||||
if (handledHighlightRef.current === highlightId) return
|
||||
if (transactions.length === 0) return
|
||||
const tx = transactions.find((t) => t.id === highlightId)
|
||||
if (!tx) return
|
||||
handledHighlightRef.current = highlightId
|
||||
|
||||
// Defer the scroll until React has committed the list to the DOM.
|
||||
// Without rAF the data-tx-id node may not exist yet when this fires
|
||||
// immediately after fetchTransactions resolves.
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const el = document.querySelector(`[data-tx-id="${tx.id}"]`)
|
||||
if (el && 'scrollIntoView' in el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
if (tx.is_business === null && !tx.journal_entry_id) {
|
||||
setTemplatePickerTransaction(tx)
|
||||
setTemplatePickerOpen(true)
|
||||
}
|
||||
}, [highlightId, transactions])
|
||||
|
||||
// Auto-fetch suggestions when transactions load
|
||||
useEffect(() => {
|
||||
const uncatIds = transactions
|
||||
|
||||
@@ -51,19 +51,32 @@ export async function GET(request: Request) {
|
||||
(prefsData?.value as Partial<KPIPreferences>) ?? {}
|
||||
)
|
||||
|
||||
const [incomeStatement, trialBalanceResult, arLedger, monthlyBreakdown, paidInvoicesResult] =
|
||||
await Promise.all([
|
||||
generateIncomeStatement(supabase, companyId, periodId),
|
||||
generateTrialBalance(supabase, companyId, periodId),
|
||||
generateARLedger(supabase, companyId),
|
||||
generateMonthlyBreakdown(supabase, companyId, periodId),
|
||||
supabase
|
||||
.from('invoices')
|
||||
.select('invoice_date, paid_at')
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'paid')
|
||||
.not('paid_at', 'is', null),
|
||||
])
|
||||
const [
|
||||
incomeStatement,
|
||||
trialBalanceResult,
|
||||
arLedger,
|
||||
monthlyBreakdown,
|
||||
paidInvoicesResult,
|
||||
topSuppliersResult,
|
||||
] = await Promise.all([
|
||||
generateIncomeStatement(supabase, companyId, periodId),
|
||||
generateTrialBalance(supabase, companyId, periodId),
|
||||
generateARLedger(supabase, companyId),
|
||||
generateMonthlyBreakdown(supabase, companyId, periodId),
|
||||
supabase
|
||||
.from('invoices')
|
||||
.select('invoice_date, paid_at')
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'paid')
|
||||
.not('paid_at', 'is', null),
|
||||
supabase
|
||||
.from('supplier_invoices')
|
||||
.select('supplier_id, total_sek, total, supplier:suppliers(id, name)')
|
||||
.eq('company_id', companyId)
|
||||
.gte('invoice_date', period.period_start)
|
||||
.lte('invoice_date', period.period_end)
|
||||
.neq('status', 'credited'),
|
||||
])
|
||||
|
||||
// Cash position — use account overrides if set
|
||||
const cashOverrides = preferences.accountOverrides['cashPosition']
|
||||
@@ -108,6 +121,59 @@ export async function GET(request: Request) {
|
||||
paid_at: inv.paid_at as string,
|
||||
}))
|
||||
|
||||
// Expense composition by BAS class (4-7). Expense accounts have a debit
|
||||
// normal balance, so amount = closing_debit - closing_credit. Negative
|
||||
// values (rare reclassifications) are clamped to 0 so the donut renders
|
||||
// sensibly.
|
||||
const expenseComposition = trialBalanceResult.rows.reduce(
|
||||
(acc, r) => {
|
||||
if (r.account_class < 4 || r.account_class > 7) return acc
|
||||
const amount = r.closing_debit - r.closing_credit
|
||||
if (amount <= 0) return acc
|
||||
if (r.account_class === 4) acc.class4 += amount
|
||||
else if (r.account_class === 5) acc.class5 += amount
|
||||
else if (r.account_class === 6) acc.class6 += amount
|
||||
else if (r.account_class === 7) acc.class7 += amount
|
||||
return acc
|
||||
},
|
||||
{ class4: 0, class5: 0, class6: 0, class7: 0 }
|
||||
)
|
||||
|
||||
// Top suppliers by spend within the fiscal period. Sum total_sek to avoid
|
||||
// mixing currencies. Drop FX invoices without a SEK conversion (total_sek
|
||||
// null) — they would otherwise inflate a supplier's total with raw
|
||||
// foreign-currency amounts.
|
||||
type SupplierInvoiceRow = {
|
||||
supplier_id: string | null
|
||||
total_sek: number | null
|
||||
total: number | null
|
||||
supplier: { id: string; name: string } | { id: string; name: string }[] | null
|
||||
}
|
||||
if (topSuppliersResult.error) {
|
||||
// Surface the failure rather than silently rendering an empty chart that
|
||||
// matches the legitimate "no supplier invoices" empty state.
|
||||
console.error('[kpi] topSuppliersResult error:', topSuppliersResult.error)
|
||||
}
|
||||
const supplierTotals = new Map<string, { name: string; total: number }>()
|
||||
for (const row of (topSuppliersResult.data ?? []) as SupplierInvoiceRow[]) {
|
||||
if (!row.supplier_id) continue
|
||||
const supplier = Array.isArray(row.supplier) ? row.supplier[0] : row.supplier
|
||||
if (!supplier?.name) continue
|
||||
const amount = row.total_sek ?? null
|
||||
if (amount == null) continue
|
||||
const existing = supplierTotals.get(row.supplier_id)
|
||||
if (existing) existing.total += amount
|
||||
else supplierTotals.set(row.supplier_id, { name: supplier.name, total: amount })
|
||||
}
|
||||
const topSuppliers = Array.from(supplierTotals.entries())
|
||||
.map(([supplier_id, v]) => ({
|
||||
supplier_id,
|
||||
supplier_name: v.name,
|
||||
total: Math.round(v.total * 100) / 100,
|
||||
}))
|
||||
.sort((a, b) => b.total - a.total)
|
||||
.slice(0, 7)
|
||||
|
||||
const report: KPIReport = {
|
||||
netResult: incomeStatement.net_result,
|
||||
cashPosition,
|
||||
@@ -122,6 +188,13 @@ export async function GET(request: Request) {
|
||||
periodComplete: period.is_closed,
|
||||
months: monthlyBreakdown.months,
|
||||
period: { start: period.period_start, end: period.period_end },
|
||||
expenseComposition: {
|
||||
class4: Math.round(expenseComposition.class4 * 100) / 100,
|
||||
class5: Math.round(expenseComposition.class5 * 100) / 100,
|
||||
class6: Math.round(expenseComposition.class6 * 100) / 100,
|
||||
class7: Math.round(expenseComposition.class7 * 100) / 100,
|
||||
},
|
||||
topSuppliers,
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: report })
|
||||
|
||||
@@ -82,7 +82,8 @@ describe('POST /api/transactions/[id]/attach-document', () => {
|
||||
it('attaches when both rows exist', async () => {
|
||||
enqueue({ data: { id: 'tx-1' }, error: null }) // tx fetch
|
||||
enqueue({ data: { id: 'doc-1' }, error: null }) // doc fetch
|
||||
enqueue({ data: null, error: null }) // update
|
||||
enqueue({ data: null, error: null }) // transactions update
|
||||
enqueue({ data: null, error: null }) // inbox-link best-effort update
|
||||
const res = await POST(
|
||||
makeReq({ document_id: '11111111-1111-4111-8111-111111111111' }),
|
||||
createMockRouteParams({ id: 'tx-1' }),
|
||||
@@ -92,6 +93,41 @@ describe('POST /api/transactions/[id]/attach-document', () => {
|
||||
expect(body.data.transaction_id).toBe('tx-1')
|
||||
expect(body.data.document_id).toBe('11111111-1111-4111-8111-111111111111')
|
||||
})
|
||||
|
||||
it('attempts to update invoice_inbox_items.matched_transaction_id after successful attach', async () => {
|
||||
// The side effect lets the inbox UI flip an item from "needs action" to
|
||||
// "Kopplad till transaktion" without an extra round-trip.
|
||||
enqueue({ data: { id: 'tx-1' }, error: null }) // tx fetch
|
||||
enqueue({ data: { id: 'doc-1' }, error: null }) // doc fetch
|
||||
enqueue({ data: null, error: null }) // transactions update
|
||||
enqueue({ data: null, error: null }) // inbox-link update
|
||||
|
||||
await POST(
|
||||
makeReq({ document_id: '11111111-1111-4111-8111-111111111111' }),
|
||||
createMockRouteParams({ id: 'tx-1' }),
|
||||
)
|
||||
// Verify the inbox_items table was touched.
|
||||
const fromCalls = mockSupabase.from.mock.calls.map((c) => c[0])
|
||||
expect(fromCalls).toContain('invoice_inbox_items')
|
||||
})
|
||||
|
||||
it('tolerates a failing inbox-link update — the document attach is the primary effect', async () => {
|
||||
enqueue({ data: { id: 'tx-1' }, error: null }) // tx fetch
|
||||
enqueue({ data: { id: 'doc-1' }, error: null }) // doc fetch
|
||||
enqueue({ data: null, error: null }) // transactions update
|
||||
enqueue({ data: null, error: { message: 'rls denied' } }) // inbox-link fails
|
||||
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const res = await POST(
|
||||
makeReq({ document_id: '11111111-1111-4111-8111-111111111111' }),
|
||||
createMockRouteParams({ id: 'tx-1' }),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ data: { transaction_id: string } }>(res)
|
||||
// Side-effect failure must not roll back the (compliant) document attach.
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.transaction_id).toBe('tx-1')
|
||||
spy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /api/transactions/[id]/attach-document', () => {
|
||||
|
||||
@@ -83,6 +83,22 @@ export async function POST(
|
||||
return NextResponse.json({ error: 'Failed to attach document' }, { status: 500 })
|
||||
}
|
||||
|
||||
// If this document came from an invoice_inbox_items row, mark that row
|
||||
// as matched so the inbox UI can show it as "Kopplad" + link back to the
|
||||
// transaction. Best-effort: a failure here must not roll back the
|
||||
// (compliant) document attach.
|
||||
try {
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({ matched_transaction_id: transactionId })
|
||||
.eq('document_id', document_id)
|
||||
.eq('company_id', companyId)
|
||||
.is('matched_transaction_id', null)
|
||||
.is('created_supplier_invoice_id', null)
|
||||
} catch (linkErr) {
|
||||
console.error('[attach-document] Failed to link inbox item:', linkErr)
|
||||
}
|
||||
|
||||
// Rättelse audit trail (BFL 5 kap 5 §): record swaps where a non-null doc
|
||||
// was replaced. Best-effort — a logging failure must not roll back the
|
||||
// (compliant) attach.
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
createQueuedMockSupabase,
|
||||
createMockRouteParams,
|
||||
parseJsonResponse,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({
|
||||
ensureInitialized: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/invoices/match-log', () => ({
|
||||
logMatchEvent: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/events/bus', () => ({
|
||||
eventBus: { emit: vi.fn() },
|
||||
}))
|
||||
|
||||
const mockCreatePaymentEntry = vi.fn()
|
||||
const mockCreateCashEntry = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({
|
||||
createSupplierInvoicePaymentEntry: (...args: unknown[]) => mockCreatePaymentEntry(...args),
|
||||
createSupplierInvoiceCashEntry: (...args: unknown[]) => mockCreateCashEntry(...args),
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
mockCreatePaymentEntry.mockResolvedValue({ id: 'je-1' })
|
||||
mockCreateCashEntry.mockResolvedValue({ id: 'je-1' })
|
||||
})
|
||||
|
||||
const TX_UUID = '11111111-1111-4111-8111-111111111111'
|
||||
const SI_UUID = '22222222-2222-4222-8222-222222222222'
|
||||
|
||||
function makeReq() {
|
||||
return new Request(`http://localhost/api/transactions/${TX_UUID}/match-supplier-invoice`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ supplier_invoice_id: SI_UUID }),
|
||||
})
|
||||
}
|
||||
|
||||
function enqueueHappyPath(opts: {
|
||||
transaction: { amount: number; currency: string; amount_sek?: number | null }
|
||||
invoice: {
|
||||
currency: string
|
||||
exchange_rate?: number | null
|
||||
remaining_amount?: number
|
||||
paid_amount?: number
|
||||
}
|
||||
}) {
|
||||
// 1. transactions fetch
|
||||
enqueue({
|
||||
data: {
|
||||
id: TX_UUID,
|
||||
company_id: 'company-1',
|
||||
amount: opts.transaction.amount,
|
||||
currency: opts.transaction.currency,
|
||||
amount_sek: opts.transaction.amount_sek ?? null,
|
||||
supplier_invoice_id: null,
|
||||
date: '2026-05-12',
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
// 2. supplier_invoices fetch
|
||||
enqueue({
|
||||
data: {
|
||||
id: SI_UUID,
|
||||
currency: opts.invoice.currency,
|
||||
exchange_rate: opts.invoice.exchange_rate ?? null,
|
||||
status: 'registered',
|
||||
remaining_amount: opts.invoice.remaining_amount ?? 225,
|
||||
paid_amount: opts.invoice.paid_amount ?? 0,
|
||||
supplier: { supplier_type: 'eu_business' },
|
||||
items: [],
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
// 3. company_settings fetch
|
||||
enqueue({ data: { accounting_method: 'accrual' }, error: null })
|
||||
// 4. supplier_invoices update (CAS)
|
||||
enqueue({ data: [{ id: SI_UUID }], error: null })
|
||||
// 5. supplier_invoice_payments insert
|
||||
enqueue({ data: null, error: null })
|
||||
// 6. transactions update (link)
|
||||
enqueue({ data: null, error: null })
|
||||
}
|
||||
|
||||
describe('POST /api/transactions/[id]/match-supplier-invoice — FX residual', () => {
|
||||
it('passes no exchangeRateDifference for a SEK transaction paying a SEK invoice', async () => {
|
||||
enqueueHappyPath({
|
||||
transaction: { amount: -2390, currency: 'SEK' },
|
||||
invoice: { currency: 'SEK', remaining_amount: 2390 },
|
||||
})
|
||||
await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
|
||||
expect(mockCreatePaymentEntry).toHaveBeenCalledTimes(1)
|
||||
const args = mockCreatePaymentEntry.mock.calls[0]
|
||||
// (supabase, companyId, userId, invoice, paymentAmountSek, paymentDate, exchangeRateDifference?)
|
||||
expect(args[4]).toBe(2390) // paymentAmountSek = actual bank SEK
|
||||
expect(args[6]).toBeUndefined() // no FX diff
|
||||
})
|
||||
|
||||
it('computes a loss when the SEK paid exceeds the AP booked SEK (EUR invoice)', async () => {
|
||||
// Invoice: 225 EUR @ rate 10.6254 → AP booked at 2390.72 SEK.
|
||||
// Bank: paid 2400 SEK out of a SEK account.
|
||||
// → diff = 2390.72 − 2400 = −9.28 (loss, debit 7960).
|
||||
enqueueHappyPath({
|
||||
transaction: { amount: -2400, currency: 'SEK' },
|
||||
invoice: { currency: 'EUR', exchange_rate: 10.6254, remaining_amount: 225 },
|
||||
})
|
||||
await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
|
||||
const args = mockCreatePaymentEntry.mock.calls[0]
|
||||
expect(args[4]).toBeCloseTo(2390.72, 2) // paymentAmountSek = originalBookedSek
|
||||
expect(args[6]).toBeCloseTo(-9.28, 2) // exchangeRateDifference (loss)
|
||||
})
|
||||
|
||||
it('computes a gain when the SEK paid is less than the AP booked SEK', async () => {
|
||||
// Invoice: 100 EUR @ rate 11 → AP booked at 1100 SEK.
|
||||
// Bank: paid 1080 SEK (rate had dipped) → diff = 1100 − 1080 = +20 (gain → credit 3960).
|
||||
enqueueHappyPath({
|
||||
transaction: { amount: -1080, currency: 'SEK' },
|
||||
invoice: { currency: 'EUR', exchange_rate: 11, remaining_amount: 100 },
|
||||
})
|
||||
await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
|
||||
const args = mockCreatePaymentEntry.mock.calls[0]
|
||||
expect(args[4]).toBeCloseTo(1100, 2)
|
||||
expect(args[6]).toBeCloseTo(20, 2)
|
||||
})
|
||||
|
||||
it('uses transaction.amount_sek for a foreign-currency bank transaction', async () => {
|
||||
// Reverse case: SEK invoice for 1000 kr, paid from a EUR card that
|
||||
// showed amount_sek = 1063 (rate had moved).
|
||||
// → diff = 1000 − 1063 = −63 (loss).
|
||||
enqueueHappyPath({
|
||||
transaction: { amount: -100, currency: 'EUR', amount_sek: -1063 },
|
||||
invoice: { currency: 'SEK', remaining_amount: 1000 },
|
||||
})
|
||||
await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
|
||||
const args = mockCreatePaymentEntry.mock.calls[0]
|
||||
// SEK invoice: originalBookedSek = remaining = 1000, FX diff = 1000 - 1063 = -63
|
||||
expect(args[4]).toBe(1000)
|
||||
expect(args[6]).toBeCloseTo(-63, 2)
|
||||
})
|
||||
|
||||
it('falls back to bank SEK when the invoice has no exchange_rate on file', async () => {
|
||||
// Foreign-currency invoice but exchange_rate is null on the row.
|
||||
// Without a rate we can't compute the AP-booked SEK precisely, so we
|
||||
// pass the actual bank SEK and skip the FX diff.
|
||||
enqueueHappyPath({
|
||||
transaction: { amount: -239, currency: 'SEK' },
|
||||
invoice: { currency: 'USD', exchange_rate: null, remaining_amount: 25 },
|
||||
})
|
||||
await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
|
||||
const args = mockCreatePaymentEntry.mock.calls[0]
|
||||
expect(args[4]).toBe(239)
|
||||
expect(args[6]).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/transactions/[id]/match-supplier-invoice — non-FX paths', () => {
|
||||
it('returns 200 with the expected body shape on the happy path', async () => {
|
||||
enqueueHappyPath({
|
||||
transaction: { amount: -1000, currency: 'SEK' },
|
||||
invoice: { currency: 'SEK', remaining_amount: 1000 },
|
||||
})
|
||||
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
success: boolean
|
||||
paid_amount: number
|
||||
remaining_amount: number
|
||||
}>(res)
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.paid_amount).toBe(1000)
|
||||
expect(body.remaining_amount).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -79,7 +79,59 @@ export const POST = withRouteContext(
|
||||
})
|
||||
}
|
||||
|
||||
const paymentAmount = Math.abs(transaction.amount)
|
||||
const txAmountAbs = Math.abs(transaction.amount)
|
||||
|
||||
// Amount in the *invoice's* currency — used to update
|
||||
// supplier_invoices.paid_amount/remaining_amount and the
|
||||
// supplier_invoice_payments row (whose `currency` is the invoice's).
|
||||
// When the bank transaction is in a different currency from the
|
||||
// invoice (e.g. paying a USD invoice from a SEK account) we treat the
|
||||
// match as a full payment of whatever remains, rather than storing the
|
||||
// SEK number with the invoice's currency suffix — which would render
|
||||
// as "Betalt 239 USD" on a 25 USD invoice.
|
||||
const paymentAmountInvoiceCurrency =
|
||||
transaction.currency === invoice.currency
|
||||
? txAmountAbs
|
||||
: invoice.remaining_amount
|
||||
|
||||
// Actual SEK leaving the bank — what really moved out of 1930. For a
|
||||
// SEK transaction this is just the absolute amount; for a foreign-
|
||||
// currency transaction we use the SEK conversion stored at import.
|
||||
const actualBankSek =
|
||||
transaction.currency === 'SEK'
|
||||
? txAmountAbs
|
||||
: (transaction.amount_sek != null
|
||||
? Math.abs(transaction.amount_sek)
|
||||
: txAmountAbs)
|
||||
|
||||
// SEK value that's actually sitting on 2440 for this payment portion:
|
||||
// - SEK invoice: face value = paymentAmountInvoiceCurrency
|
||||
// - Non-SEK invoice w/ exchange_rate: portion × rate
|
||||
// - Non-SEK invoice w/o exchange_rate: can't compute precisely; fall
|
||||
// back to actualBankSek (no FX diff, plain SEK booking)
|
||||
// FX diff hits 7960/3960 so 2440 clears cleanly instead of leaving a
|
||||
// residual. Triggered whenever bank-paid SEK differs from booked SEK —
|
||||
// happens for any currency mismatch (SEK→EUR, EUR→SEK, EUR→USD), not
|
||||
// just non-SEK invoices.
|
||||
const invoiceFxRate = invoice.exchange_rate ?? null
|
||||
const originalBookedSek =
|
||||
invoice.currency === 'SEK'
|
||||
? paymentAmountInvoiceCurrency
|
||||
: invoiceFxRate && invoiceFxRate > 0
|
||||
? Math.round(paymentAmountInvoiceCurrency * invoiceFxRate * 100) / 100
|
||||
: actualBankSek
|
||||
|
||||
// Positive = gain (AP credited at more SEK than the bank actually paid).
|
||||
// Negative = loss (bank paid more SEK than the AP we owed).
|
||||
const exchangeRateDifference =
|
||||
Math.round((originalBookedSek - actualBankSek) * 100) / 100
|
||||
|
||||
// `paymentAmountSek` is what we pass to the payment-entry builder. In
|
||||
// the FX branch (non-zero exchangeRateDifference) it represents the
|
||||
// ORIGINAL booked SEK on 2440; the builder then computes actualSekPaid
|
||||
// as paymentAmountSek - exchangeRateDifference internally.
|
||||
const paymentAmountSek = exchangeRateDifference !== 0 ? originalBookedSek : actualBankSek
|
||||
|
||||
const now = new Date().toISOString()
|
||||
|
||||
const { data: settings } = await supabase
|
||||
@@ -90,6 +142,23 @@ export const POST = withRouteContext(
|
||||
|
||||
const accountingMethod = settings?.accounting_method || 'accrual'
|
||||
|
||||
// Cash method (kontantmetoden) collapses registration + payment into a
|
||||
// single entry that credits 1930 at sum(expenses_SEK). It has no
|
||||
// exchange_rate_difference path — if the actual bank SEK differs from
|
||||
// the invoice's booked SEK, the 1930 credit won't match the bank
|
||||
// transaction and we'd silently leave a reconciliation gap. Block the
|
||||
// combination and ask the user to switch to accrual or do a manual JE.
|
||||
if (accountingMethod === 'cash' && exchangeRateDifference !== 0) {
|
||||
return errorResponseFromCode('MATCH_SI_CASH_FX_UNSUPPORTED', txLog, {
|
||||
requestId,
|
||||
details: {
|
||||
exchangeRateDifference,
|
||||
invoiceCurrency: invoice.currency,
|
||||
transactionCurrency: transaction.currency,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
let journalEntryId: string | null = null
|
||||
let journalEntryError: string | null = null
|
||||
|
||||
@@ -105,7 +174,8 @@ export const POST = withRouteContext(
|
||||
} else {
|
||||
const journalEntry = await createSupplierInvoicePaymentEntry(
|
||||
supabase, companyId, user.id, invoice as SupplierInvoice,
|
||||
paymentAmount, transaction.date,
|
||||
paymentAmountSek, transaction.date,
|
||||
exchangeRateDifference !== 0 ? exchangeRateDifference : undefined,
|
||||
)
|
||||
if (journalEntry) journalEntryId = journalEntry.id
|
||||
}
|
||||
@@ -120,8 +190,8 @@ export const POST = withRouteContext(
|
||||
}
|
||||
}
|
||||
|
||||
const newRemaining = Math.max(0, Math.round((invoice.remaining_amount - paymentAmount) * 100) / 100)
|
||||
const newPaidAmount = Math.round((invoice.paid_amount + paymentAmount) * 100) / 100
|
||||
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'
|
||||
|
||||
@@ -155,7 +225,7 @@ export const POST = withRouteContext(
|
||||
company_id: companyId,
|
||||
supplier_invoice_id,
|
||||
payment_date: transaction.date,
|
||||
amount: paymentAmount,
|
||||
amount: paymentAmountInvoiceCurrency,
|
||||
currency: invoice.currency,
|
||||
journal_entry_id: journalEntryId,
|
||||
transaction_id: transactionId,
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { parseJsonResponse, createQueuedMockSupabase } from '@/tests/helpers'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({
|
||||
ensureInitialized: vi.fn(),
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
})
|
||||
|
||||
const VALID_UUID = '11111111-1111-4111-8111-111111111111'
|
||||
|
||||
function makeReq(body: unknown) {
|
||||
return new Request('http://localhost/api/transactions/create-from-document', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
function validBody(overrides: Partial<{
|
||||
inbox_item_id: string
|
||||
amount: number
|
||||
transaction_date: string
|
||||
description: string
|
||||
}> = {}) {
|
||||
return {
|
||||
inbox_item_id: VALID_UUID,
|
||||
amount: -100,
|
||||
transaction_date: '2026-05-12',
|
||||
description: 'Test supplier · INV-001',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('POST /api/transactions/create-from-document', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
|
||||
const res = await POST(makeReq(validBody()))
|
||||
const { status, body } = await parseJsonResponse(res)
|
||||
expect(status).toBe(401)
|
||||
expect(body).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('returns 400 when the body is invalid', async () => {
|
||||
const res = await POST(makeReq({ inbox_item_id: 'not-a-uuid', amount: 0 }))
|
||||
const { status } = await parseJsonResponse(res)
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 400 when amount is zero (schema refine)', async () => {
|
||||
const res = await POST(makeReq(validBody({ amount: 0 })))
|
||||
const { status } = await parseJsonResponse(res)
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 404 when the inbox item is not in the user company', async () => {
|
||||
enqueue({ data: null, error: null }) // inbox item lookup misses
|
||||
const res = await POST(makeReq(validBody()))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(res)
|
||||
expect(status).toBe(404)
|
||||
expect(body.error).toBe('Inbox item not found')
|
||||
})
|
||||
|
||||
it('returns 409 when the inbox item is already matched to a transaction', async () => {
|
||||
enqueue({
|
||||
data: {
|
||||
id: VALID_UUID,
|
||||
document_id: 'doc-1',
|
||||
matched_transaction_id: 'tx-existing',
|
||||
created_supplier_invoice_id: null,
|
||||
extracted_data: null,
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
const res = await POST(makeReq(validBody()))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(res)
|
||||
expect(status).toBe(409)
|
||||
expect(body.error).toMatch(/redan kopplad/)
|
||||
})
|
||||
|
||||
it('returns 409 when the inbox item is already booked as a supplier invoice', async () => {
|
||||
enqueue({
|
||||
data: {
|
||||
id: VALID_UUID,
|
||||
document_id: 'doc-1',
|
||||
matched_transaction_id: null,
|
||||
created_supplier_invoice_id: 'si-existing',
|
||||
extracted_data: null,
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
const res = await POST(makeReq(validBody()))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(res)
|
||||
expect(status).toBe(409)
|
||||
expect(body.error).toMatch(/redan bokförd/)
|
||||
})
|
||||
|
||||
it('creates the transaction and links the inbox item on the happy path', async () => {
|
||||
enqueue({
|
||||
data: {
|
||||
id: VALID_UUID,
|
||||
document_id: 'doc-1',
|
||||
matched_transaction_id: null,
|
||||
created_supplier_invoice_id: null,
|
||||
extracted_data: { invoice: { currency: 'EUR' } },
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: { id: 'new-tx-1' }, error: null }) // insert
|
||||
enqueue({ data: [{ id: VALID_UUID }], error: null }) // inbox update — one row affected
|
||||
|
||||
const res = await POST(makeReq(validBody()))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { transaction_id: string; inbox_item_id: string; document_id: string }
|
||||
}>(res)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.transaction_id).toBe('new-tx-1')
|
||||
expect(body.data.document_id).toBe('doc-1')
|
||||
})
|
||||
|
||||
it('returns 409 and rolls back the orphan when a concurrent request linked first', async () => {
|
||||
// Race scenario: both requests pass the matched_transaction_id IS NULL
|
||||
// read; both insert their own transaction. The losing UPDATE matches
|
||||
// zero rows because the .is('matched_transaction_id', null) predicate
|
||||
// no longer holds. We delete the orphan and 409.
|
||||
enqueue({
|
||||
data: {
|
||||
id: VALID_UUID,
|
||||
document_id: 'doc-1',
|
||||
matched_transaction_id: null,
|
||||
created_supplier_invoice_id: null,
|
||||
extracted_data: null,
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: { id: 'orphan-tx' }, error: null }) // insert succeeds
|
||||
enqueue({ data: [], error: null }) // inbox update affects zero rows — lost the race
|
||||
enqueue({ data: null, error: null }) // rollback delete of the orphan
|
||||
|
||||
const res = await POST(makeReq(validBody()))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(res)
|
||||
expect(status).toBe(409)
|
||||
expect(body.error).toMatch(/parallell begäran/)
|
||||
})
|
||||
|
||||
it('coerces an unrecognised extracted currency to SEK before insert', async () => {
|
||||
// Defense-in-depth: the deterministic extractor can still emit garbage
|
||||
// for malformed PDFs. We must not let arbitrary strings reach the
|
||||
// transactions.currency column.
|
||||
enqueue({
|
||||
data: {
|
||||
id: VALID_UUID,
|
||||
document_id: null,
|
||||
matched_transaction_id: null,
|
||||
created_supplier_invoice_id: null,
|
||||
extracted_data: { invoice: { currency: 'XYZ' } },
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: { id: 'new-tx-3' }, error: null })
|
||||
enqueue({ data: [{ id: VALID_UUID }], error: null })
|
||||
|
||||
const res = await POST(makeReq(validBody()))
|
||||
const { status } = await parseJsonResponse(res)
|
||||
expect(status).toBe(200)
|
||||
})
|
||||
|
||||
it('returns 500 when the transaction insert fails', async () => {
|
||||
enqueue({
|
||||
data: {
|
||||
id: VALID_UUID,
|
||||
document_id: null,
|
||||
matched_transaction_id: null,
|
||||
created_supplier_invoice_id: null,
|
||||
extracted_data: null,
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: null, error: { message: 'db down' } }) // insert fails
|
||||
// Silence the console.error
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const res = await POST(makeReq(validBody()))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(res)
|
||||
expect(status).toBe(500)
|
||||
expect(body.error).toMatch(/Kunde inte skapa transaktion/)
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('tolerates a failed inbox-link update — transaction exists, surface inbox_link_failed', async () => {
|
||||
enqueue({
|
||||
data: {
|
||||
id: VALID_UUID,
|
||||
document_id: 'doc-1',
|
||||
matched_transaction_id: null,
|
||||
created_supplier_invoice_id: null,
|
||||
extracted_data: null,
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: { id: 'new-tx-2' }, error: null }) // insert ok
|
||||
enqueue({ data: null, error: { message: 'rls' } }) // link update fails
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const res = await POST(makeReq(validBody()))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { transaction_id: string; inbox_link_failed?: boolean }
|
||||
}>(res)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.transaction_id).toBe('new-tx-2')
|
||||
expect(body.data.inbox_link_failed).toBe(true)
|
||||
spy.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { CreateTransactionFromDocumentSchema } from '@/lib/api/schemas'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* POST /api/transactions/create-from-document
|
||||
*
|
||||
* Creates an uncategorized manual bank transaction prefilled from an
|
||||
* invoice_inbox_items row, then attaches the inbox item's document to it
|
||||
* and links the inbox item to the new transaction. The user categorizes
|
||||
* the new transaction through the normal /transactions flow (which routes
|
||||
* through the bookkeeping engine and respects period locks, etc.).
|
||||
*
|
||||
* Use case: receipt in the inbox has no matching bank transaction
|
||||
* (cash purchase, personal-card expense, missed sync).
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
const validation = await validateBody(request, CreateTransactionFromDocumentSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const { inbox_item_id, amount, transaction_date, description } = validation.data
|
||||
|
||||
const { data: item, error: itemError } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('id, document_id, matched_transaction_id, created_supplier_invoice_id, extracted_data')
|
||||
.eq('id', inbox_item_id)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
|
||||
if (itemError || !item) {
|
||||
return NextResponse.json({ error: 'Inbox item not found' }, { status: 404 })
|
||||
}
|
||||
if (item.matched_transaction_id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Inkorgsposten är redan kopplad till en transaktion.' },
|
||||
{ status: 409 },
|
||||
)
|
||||
}
|
||||
if (item.created_supplier_invoice_id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Inkorgsposten är redan bokförd som leverantörsfaktura.' },
|
||||
{ status: 409 },
|
||||
)
|
||||
}
|
||||
|
||||
// Allowlist the currency — extracted_data.invoice.currency comes from the
|
||||
// (deterministic, but still untrusted) PDF extractor, so an arbitrary
|
||||
// string like "XYZ" or '"SEK\'"' could otherwise be persisted directly to
|
||||
// the transactions table and break later formatCurrency / journal-entry
|
||||
// bookings (BFL 5 kap 6 §). Coerce anything outside the supported set
|
||||
// to SEK; the user can change it manually on the transaction.
|
||||
const ALLOWED_CURRENCIES = new Set(['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK'])
|
||||
const extractedCurrency = (
|
||||
item.extracted_data as { invoice?: { currency?: string } } | null
|
||||
)?.invoice?.currency
|
||||
const currency =
|
||||
extractedCurrency && ALLOWED_CURRENCIES.has(extractedCurrency)
|
||||
? extractedCurrency
|
||||
: 'SEK'
|
||||
|
||||
const { data: newTx, error: insertError } = await supabase
|
||||
.from('transactions')
|
||||
.insert({
|
||||
company_id: companyId,
|
||||
user_id: user.id,
|
||||
date: transaction_date,
|
||||
description,
|
||||
amount,
|
||||
currency,
|
||||
category: 'uncategorized',
|
||||
is_business: null,
|
||||
import_source: 'manual',
|
||||
document_id: item.document_id,
|
||||
})
|
||||
.select('id')
|
||||
.single()
|
||||
|
||||
if (insertError || !newTx) {
|
||||
console.error('[create-from-document] Failed to insert transaction:', insertError)
|
||||
return NextResponse.json({ error: 'Kunde inte skapa transaktion.' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Concurrency guard: the .is('matched_transaction_id', null) predicate +
|
||||
// the rows-affected check turn this into an optimistic-lock release. If
|
||||
// two requests with the same inbox_item_id race past the earlier
|
||||
// matched_transaction_id check, only the first UPDATE will match a row
|
||||
// here. The loser's transaction insert is then an orphan we proactively
|
||||
// delete so the user doesn't get a duplicate uncategorized row.
|
||||
const { data: linked, error: linkError } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({ matched_transaction_id: newTx.id })
|
||||
.eq('id', inbox_item_id)
|
||||
.eq('company_id', companyId)
|
||||
.is('matched_transaction_id', null)
|
||||
.select('id')
|
||||
|
||||
if (linkError) {
|
||||
console.error('[create-from-document] Failed to link inbox item:', linkError)
|
||||
// Transaction was created; surface a 200 with a warning so the user can
|
||||
// still find it under Transaktioner — the inbox-link orphan is recoverable.
|
||||
return NextResponse.json({
|
||||
data: { transaction_id: newTx.id, inbox_link_failed: true },
|
||||
})
|
||||
}
|
||||
|
||||
if (!linked || linked.length === 0) {
|
||||
// Lost a race — another concurrent request linked the inbox item first.
|
||||
// Roll back our newly-created transaction (only safe because we own it
|
||||
// and it has no journal_entry_id yet) and return 409 so the client can
|
||||
// refetch and reuse the winning transaction instead of creating a dupe.
|
||||
// Re-assert company_id on the delete (defence in depth — newTx.id is a
|
||||
// fresh UUID from a company-scoped insert above, but scoping the rollback
|
||||
// makes the invariant explicit).
|
||||
await supabase.from('transactions').delete().eq('id', newTx.id).eq('company_id', companyId)
|
||||
return NextResponse.json(
|
||||
{ error: 'Inkorgsposten kopplades av en parallell begäran. Försök igen.' },
|
||||
{ status: 409 },
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: { transaction_id: newTx.id, inbox_item_id, document_id: item.document_id },
|
||||
})
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
Settings,
|
||||
LogOut,
|
||||
Upload,
|
||||
Calendar,
|
||||
Inbox,
|
||||
Menu,
|
||||
X,
|
||||
HelpCircle,
|
||||
@@ -67,7 +67,7 @@ interface NavItem {
|
||||
const navItems: NavItem[] = [
|
||||
{ href: '/', label: 'Översikt', icon: LayoutDashboard, group: 'main' },
|
||||
{ href: '/kpi', label: 'Nyckeltal', icon: TrendingUp, group: 'main' },
|
||||
{ href: '/deadlines', label: 'Deadlines', icon: Calendar, group: 'main' },
|
||||
{ href: '/e/general/invoice-inbox', label: 'Dokumentinkorg', icon: Inbox, group: 'main', betaBadge: true },
|
||||
// AR — Accounts Receivable
|
||||
{ href: '/invoices', label: 'Fakturor', icon: Receipt, group: 'försäljning' },
|
||||
{ href: '/customers', label: 'Kunder', icon: Users, group: 'försäljning' },
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,109 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
|
||||
interface KPIExpenseMixChartProps {
|
||||
composition: {
|
||||
class4: number
|
||||
class5: number
|
||||
class6: number
|
||||
class7: number
|
||||
}
|
||||
}
|
||||
|
||||
const SEGMENT_COLORS = [
|
||||
'hsl(var(--chart-1))',
|
||||
'hsl(var(--chart-3))',
|
||||
'hsl(var(--chart-2))',
|
||||
'hsl(var(--chart-4))',
|
||||
]
|
||||
|
||||
export function KPIExpenseMixChart({ composition }: KPIExpenseMixChartProps) {
|
||||
const { class4, class5, class6, class7 } = composition
|
||||
const chartData = useMemo(
|
||||
() =>
|
||||
[
|
||||
{ name: 'Varor (klass 4)', value: class4 },
|
||||
{ name: 'Drift & lokaler (klass 5)', value: class5 },
|
||||
{ name: 'Övriga externa (klass 6)', value: class6 },
|
||||
{ name: 'Personal (klass 7)', value: class7 },
|
||||
].filter((s) => s.value > 0),
|
||||
[class4, class5, class6, class7]
|
||||
)
|
||||
|
||||
const total = class4 + class5 + class6 + class7
|
||||
const totalCompact =
|
||||
new Intl.NumberFormat('sv-SE', {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
}).format(total) + ' kr'
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">Kostnader per klass</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{chartData.length === 0 ? (
|
||||
<div className="flex h-[240px] items-center justify-center text-sm text-muted-foreground">
|
||||
Inga bokförda kostnader ännu
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative flex flex-col items-center">
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={chartData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={58}
|
||||
outerRadius={84}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
>
|
||||
{chartData.map((_, index) => (
|
||||
<Cell key={index} fill={SEGMENT_COLORS[index % SEGMENT_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
formatter={(value) => [formatCurrency(Number(value)), '']}
|
||||
contentStyle={{
|
||||
fontSize: '12px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
}}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="pointer-events-none absolute left-0 right-0 top-0 h-[180px] flex flex-col items-center justify-center">
|
||||
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
Totalt
|
||||
</span>
|
||||
<span
|
||||
className="font-display text-lg font-medium tabular-nums"
|
||||
title={formatCurrency(total)}
|
||||
>
|
||||
{totalCompact}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap justify-center gap-x-4 gap-y-2 text-[11px] text-muted-foreground">
|
||||
{chartData.map((seg, i) => (
|
||||
<div key={seg.name} className="flex items-center gap-2">
|
||||
<span
|
||||
className="h-2 w-2 rounded-[2px]"
|
||||
style={{ backgroundColor: SEGMENT_COLORS[i % SEGMENT_COLORS.length] }}
|
||||
/>
|
||||
<span>{seg.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
} from 'recharts'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
|
||||
interface KPITopSuppliersChartProps {
|
||||
suppliers: { supplier_id: string; supplier_name: string; total: number }[]
|
||||
}
|
||||
|
||||
const BAR_COLOR = 'hsl(var(--chart-1))'
|
||||
|
||||
export function KPITopSuppliersChart({ suppliers }: KPITopSuppliersChartProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">Största leverantörer</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{suppliers.length === 0 ? (
|
||||
<div className="flex h-[200px] items-center justify-center text-center text-sm text-muted-foreground px-4">
|
||||
Inga registrerade leverantörsfakturor under perioden
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={Math.max(160, suppliers.length * 32)}>
|
||||
<BarChart
|
||||
data={suppliers}
|
||||
layout="vertical"
|
||||
margin={{ top: 5, right: 16, left: 0, bottom: 5 }}
|
||||
>
|
||||
<XAxis
|
||||
type="number"
|
||||
tickFormatter={(v) =>
|
||||
new Intl.NumberFormat('sv-SE', { notation: 'compact' }).format(v)
|
||||
}
|
||||
tick={{ fontSize: 11 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="supplier_name"
|
||||
tick={{ fontSize: 11 }}
|
||||
width={120}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
interval={0}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value) => [formatCurrency(Number(value)), 'Spend']}
|
||||
contentStyle={{
|
||||
fontSize: '12px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
}}
|
||||
cursor={{ fill: 'hsl(var(--muted) / 0.4)' }}
|
||||
/>
|
||||
<Bar dataKey="total" radius={[0, 4, 4, 0]}>
|
||||
{suppliers.map((s) => (
|
||||
<Cell key={s.supplier_id} fill={BAR_COLOR} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -52,15 +52,21 @@ function buildJournalPreview(
|
||||
totalVat: number,
|
||||
total: number,
|
||||
reverseCharge: boolean,
|
||||
supplierType?: string,
|
||||
supplierType: string | undefined,
|
||||
// FX multiplier applied to every amount. 1 when the invoice is in SEK or
|
||||
// when no rate is set. Matches what the backend writes — items go through
|
||||
// resolveSekAmount(item.line_total, null, currency, exchange_rate), so the
|
||||
// saved verifikation is always in SEK, never in invoice currency.
|
||||
fxRate: number,
|
||||
): JournalPreviewLine[] {
|
||||
const lines: JournalPreviewLine[] = []
|
||||
const toSek = (n: number) => Math.round(n * fxRate * 100) / 100
|
||||
|
||||
// Aggregate expense amounts by account number
|
||||
// Aggregate expense amounts by account number (in SEK)
|
||||
const expenseByAccount = new Map<string, number>()
|
||||
for (const item of items) {
|
||||
const current = expenseByAccount.get(item.account_number) || 0
|
||||
expenseByAccount.set(item.account_number, current + Math.round(item.amount * 100) / 100)
|
||||
expenseByAccount.set(item.account_number, current + toSek(item.amount))
|
||||
}
|
||||
|
||||
// Debit: Expense accounts
|
||||
@@ -68,7 +74,7 @@ function buildJournalPreview(
|
||||
lines.push({
|
||||
account_number: accountNumber,
|
||||
description: accountNumber,
|
||||
debit: Math.round(amount * 100) / 100,
|
||||
debit: amount,
|
||||
credit: 0,
|
||||
})
|
||||
}
|
||||
@@ -82,7 +88,7 @@ function buildJournalPreview(
|
||||
for (const item of items) {
|
||||
if (item.vat_rate > 0) {
|
||||
const current = vatByRate.get(item.vat_rate) || 0
|
||||
vatByRate.set(item.vat_rate, current + Math.round(item.amount * 100) / 100)
|
||||
vatByRate.set(item.vat_rate, current + toSek(item.amount))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,14 +114,14 @@ function buildJournalPreview(
|
||||
account_number: '2440',
|
||||
description: 'Leverantörsskulder',
|
||||
debit: 0,
|
||||
credit: Math.round(subtotal * 100) / 100,
|
||||
credit: toSek(subtotal),
|
||||
})
|
||||
} else {
|
||||
if (totalVat > 0) {
|
||||
lines.push({
|
||||
account_number: '2641',
|
||||
description: 'Ingående moms',
|
||||
debit: Math.round(totalVat * 100) / 100,
|
||||
debit: toSek(totalVat),
|
||||
credit: 0,
|
||||
})
|
||||
}
|
||||
@@ -124,7 +130,7 @@ function buildJournalPreview(
|
||||
account_number: '2440',
|
||||
description: 'Leverantörsskulder',
|
||||
debit: 0,
|
||||
credit: Math.round(total * 100) / 100,
|
||||
credit: toSek(total),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -156,9 +162,12 @@ export function SupplierInvoiceReviewContent({
|
||||
totalVat,
|
||||
total,
|
||||
}: SupplierInvoiceReviewContentProps) {
|
||||
const journalLines = buildJournalPreview(items, subtotal, totalVat, total, reverseCharge, supplier.supplier_type)
|
||||
const parsedRate = exchangeRate ? parseFloat(exchangeRate) : NaN
|
||||
const fxRate = currency !== 'SEK' && Number.isFinite(parsedRate) && parsedRate > 0 ? parsedRate : 1
|
||||
const journalLines = buildJournalPreview(items, subtotal, totalVat, total, reverseCharge, supplier.supplier_type, fxRate)
|
||||
const totalDebit = journalLines.reduce((sum, l) => sum + l.debit, 0)
|
||||
const totalCredit = journalLines.reduce((sum, l) => sum + l.credit, 0)
|
||||
const showingSek = fxRate !== 1
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -274,7 +283,12 @@ export function SupplierInvoiceReviewContent({
|
||||
|
||||
{/* Verifikation preview */}
|
||||
<div className="bg-muted/50 border rounded-lg p-3 sm:p-4 space-y-2">
|
||||
<p className="text-sm font-semibold text-muted-foreground">Verifikation som bokförs</p>
|
||||
<p className="text-sm font-semibold text-muted-foreground">
|
||||
Verifikation som bokförs
|
||||
{showingSek && (
|
||||
<span className="ml-1.5 font-normal text-xs">(i SEK)</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="hidden sm:block">
|
||||
<table className="w-full text-sm font-mono">
|
||||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||||
|
||||
@@ -58,7 +58,13 @@ export default function BankTransactionPicker({
|
||||
|
||||
;(async () => {
|
||||
setIsLoading(true)
|
||||
const query = supabase
|
||||
// No strict currency filter — the bank transaction that pays a
|
||||
// foreign-currency invoice is almost always in the company's domestic
|
||||
// currency (e.g. SEK bank account paying a USD invoice). The user
|
||||
// would see "no matches" if we filtered to the invoice currency.
|
||||
// Cross-currency amount diff is hidden in the row below so the user
|
||||
// isn't shown a misleading "Diff X kr" against a USD target.
|
||||
const { data, error } = await supabase
|
||||
.from('transactions')
|
||||
.select('id, date, description, amount, currency')
|
||||
.eq('company_id', company.id)
|
||||
@@ -69,10 +75,6 @@ export default function BankTransactionPicker({
|
||||
.order('date', { ascending: false })
|
||||
.limit(200)
|
||||
|
||||
const { data, error } = targetCurrency
|
||||
? await query.eq('currency', targetCurrency)
|
||||
: await query
|
||||
|
||||
if (!cancelled) {
|
||||
if (!error) setTransactions(data || [])
|
||||
setIsLoading(false)
|
||||
@@ -82,20 +84,26 @@ export default function BankTransactionPicker({
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [open, company, targetCurrency])
|
||||
}, [open, company])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const term = searchTerm.trim().toLowerCase()
|
||||
const matches = transactions.filter((t) =>
|
||||
term === '' ? true : (t.description || '').toLowerCase().includes(term),
|
||||
)
|
||||
// Rank by closeness to target (treat target as the absolute outflow)
|
||||
// Rank by amount proximity only when currencies match — comparing a USD
|
||||
// target to a SEK transaction numerically would produce a meaningless
|
||||
// ranking. Cross-currency rows fall back to date-desc order.
|
||||
return matches.sort((a, b) => {
|
||||
const aSame = a.currency === targetCurrency
|
||||
const bSame = b.currency === targetCurrency
|
||||
if (aSame !== bSame) return aSame ? -1 : 1
|
||||
if (!aSame) return 0
|
||||
const da = Math.abs(Math.abs(a.amount) - targetAmount)
|
||||
const db = Math.abs(Math.abs(b.amount) - targetAmount)
|
||||
return da - db
|
||||
})
|
||||
}, [transactions, searchTerm, targetAmount])
|
||||
}, [transactions, searchTerm, targetAmount, targetCurrency])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -133,9 +141,10 @@ export default function BankTransactionPicker({
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((tx) => {
|
||||
const sameCurrency = tx.currency === targetCurrency
|
||||
const absAmount = Math.abs(tx.amount)
|
||||
const diff = Math.abs(absAmount - targetAmount)
|
||||
const isExact = diff < 0.01
|
||||
const diff = sameCurrency ? Math.abs(absAmount - targetAmount) : null
|
||||
const isExact = diff != null && diff < 0.01
|
||||
return (
|
||||
<button
|
||||
key={tx.id}
|
||||
@@ -154,10 +163,14 @@ export default function BankTransactionPicker({
|
||||
</p>
|
||||
{isExact ? (
|
||||
<p className="text-xs text-success">Belopp matchar</p>
|
||||
) : targetAmount > 0 ? (
|
||||
) : diff != null && targetAmount > 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Diff {formatCurrency(diff, tx.currency)}
|
||||
</p>
|
||||
) : !sameCurrency ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Annan valuta
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -65,6 +65,27 @@ export default function QuickReviewDialog({
|
||||
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
|
||||
const [showUploadZone, setShowUploadZone] = useState(false)
|
||||
const [showVatDropdown, setShowVatDropdown] = useState(false)
|
||||
const [isOpeningDoc, setIsOpeningDoc] = useState(false)
|
||||
|
||||
const preAttachedDocumentId = transaction?.document_id ?? null
|
||||
|
||||
const handleOpenAttachedDoc = useCallback(async () => {
|
||||
if (!preAttachedDocumentId || isOpeningDoc) return
|
||||
setIsOpeningDoc(true)
|
||||
try {
|
||||
const res = await fetch(`/api/documents/${preAttachedDocumentId}`)
|
||||
if (!res.ok) {
|
||||
toast({ title: 'Kunde inte öppna underlaget', variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
const { data } = await res.json()
|
||||
if (data?.download_url) {
|
||||
window.open(data.download_url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
} finally {
|
||||
setIsOpeningDoc(false)
|
||||
}
|
||||
}, [preAttachedDocumentId, isOpeningDoc, toast])
|
||||
|
||||
// Handle account changes — clear VAT for liability/equity accounts (class 2)
|
||||
const handleAccountChange = useCallback((account: string) => {
|
||||
@@ -292,38 +313,59 @@ export default function QuickReviewDialog({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Document upload */}
|
||||
<div className="rounded-lg border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowUploadZone(!showUploadZone)}
|
||||
className="flex items-center justify-between w-full px-3 py-2.5 text-sm hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Paperclip className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">Underlag</span>
|
||||
{uploadedFiles.filter((f) => f.status === 'uploaded').length > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{uploadedFiles.filter((f) => f.status === 'uploaded').length} bifogade
|
||||
</span>
|
||||
{/* Document — either show the doc the inbox attached pre-categorize,
|
||||
or let the user upload one if none is attached yet. */}
|
||||
{preAttachedDocumentId ? (
|
||||
<div className="rounded-lg border flex items-center justify-between px-3 py-2.5 text-sm">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Paperclip className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="font-medium">Underlag bifogat</span>
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
från dokumentinkorgen
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenAttachedDoc}
|
||||
disabled={isOpeningDoc}
|
||||
className="text-xs text-primary hover:underline shrink-0"
|
||||
>
|
||||
{isOpeningDoc ? 'Öppnar…' : 'Visa'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowUploadZone(!showUploadZone)}
|
||||
className="flex items-center justify-between w-full px-3 py-2.5 text-sm hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Paperclip className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">Underlag</span>
|
||||
{uploadedFiles.filter((f) => f.status === 'uploaded').length > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{uploadedFiles.filter((f) => f.status === 'uploaded').length} bifogade
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{showUploadZone ? (
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
{showUploadZone ? (
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
{showUploadZone && (
|
||||
<div className="px-3 pb-3">
|
||||
<DocumentUploadZone
|
||||
files={uploadedFiles}
|
||||
onFilesChange={setUploadedFiles}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
{showUploadZone && (
|
||||
<div className="px-3 pb-3">
|
||||
<DocumentUploadZone
|
||||
files={uploadedFiles}
|
||||
onFilesChange={setUploadedFiles}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Paperclip, Loader2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
|
||||
interface Props {
|
||||
documentId: string | null | undefined
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact "this transaction has an attached document" indicator.
|
||||
* Clicking fetches a signed download URL and opens the document in a new tab —
|
||||
* lets the user verify the attached receipt without first having to book the
|
||||
* transaction (which is when the doc gets linked to a journal entry).
|
||||
*/
|
||||
export function TransactionAttachmentIndicator({ documentId, className }: Props) {
|
||||
const { toast } = useToast()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
if (!documentId) return null
|
||||
|
||||
const handleOpen = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
if (isLoading) return
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/documents/${documentId}`)
|
||||
if (!res.ok) {
|
||||
toast({ title: 'Kunde inte hämta underlaget', variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
const { data } = await res.json()
|
||||
if (data?.download_url) {
|
||||
window.open(data.download_url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpen}
|
||||
title="Underlag bifogat — klicka för att öppna"
|
||||
aria-label="Öppna bifogat underlag"
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center h-5 w-5 rounded text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors shrink-0',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Paperclip className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { Input } from '@/components/ui/input'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { getCategoryDisplayName } from '@/lib/tax/expense-warnings'
|
||||
import { Search, ArrowUpRight, ArrowDownRight, ArrowLeftRight, Check, Link2, FileText, Loader2 } from 'lucide-react'
|
||||
import { TransactionAttachmentIndicator } from './TransactionAttachmentIndicator'
|
||||
import type { TransactionWithInvoice, HistoryFilter } from './transaction-types'
|
||||
|
||||
interface TransactionHistoryListProps {
|
||||
@@ -83,7 +84,11 @@ export default function TransactionHistoryList({
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filtered.map((transaction) => (
|
||||
<Card key={transaction.id} className="hover:border-primary/50 transition-colors">
|
||||
<Card
|
||||
key={transaction.id}
|
||||
data-tx-id={transaction.id}
|
||||
className="hover:border-primary/50 transition-colors"
|
||||
>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -101,7 +106,10 @@ export default function TransactionHistoryList({
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{transaction.description}</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="font-medium">{transaction.description}</p>
|
||||
<TransactionAttachmentIndicator documentId={transaction.document_id} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{formatDate(transaction.date)}</span>
|
||||
{transaction.is_business !== null &&
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/comp
|
||||
import { getAccountName, formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
|
||||
import { getTemplateById } from '@/lib/bookkeeping/booking-templates'
|
||||
import { isCounterpartyTemplateId } from '@/lib/bookkeeping/counterparty-templates'
|
||||
import { TransactionAttachmentIndicator } from './TransactionAttachmentIndicator'
|
||||
import type { TransactionWithInvoice, CategorizeHandler } from './transaction-types'
|
||||
import type { SuggestedCategory, SuggestedTemplate } from '@/lib/transactions/category-suggestions'
|
||||
|
||||
@@ -83,6 +84,7 @@ export default function TransactionInboxCard({
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
data-tx-id={transaction.id}
|
||||
className={cn(
|
||||
'transition-colors',
|
||||
hasInvoiceMatch || hasSupplierInvoiceMatch ? 'border-primary/50' : 'border-warning/50',
|
||||
@@ -114,7 +116,10 @@ export default function TransactionInboxCard({
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium truncate">{transaction.description}</p>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<p className="font-medium truncate">{transaction.description}</p>
|
||||
<TransactionAttachmentIndicator documentId={transaction.document_id} />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{formatDate(transaction.date)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,12 @@ vi.mock('@supabase/supabase-js', () => ({
|
||||
createClient: vi.fn(),
|
||||
}))
|
||||
|
||||
// Rate limiter is a thin RPC wrapper; bypass it so the queued-mock sequence
|
||||
// in each test doesn't have to account for the extra Supabase call.
|
||||
vi.mock('@/lib/rate-limits/inbox', () => ({
|
||||
checkInboxUploadRateLimit: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
import { verifyInboundWebhook, fetchReceivingEmail, fetchInboundAttachment } from '@/extensions/general/invoice-inbox/lib/resend-inbound'
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox'
|
||||
import {
|
||||
createQueuedMockSupabase,
|
||||
createMockRequest,
|
||||
parseJsonResponse,
|
||||
} from '@/tests/helpers'
|
||||
import type { ExtensionContext } from '@/lib/extensions/types'
|
||||
|
||||
vi.mock('@/extensions/general/invoice-inbox/lib/extract-invoice-fields', () => ({
|
||||
extractInvoiceFields: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/rate-limits/inbox', () => ({
|
||||
checkInboxUploadRateLimit: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
import { extractInvoiceFields } from '@/extensions/general/invoice-inbox/lib/extract-invoice-fields'
|
||||
import { checkInboxUploadRateLimit } from '@/lib/rate-limits/inbox'
|
||||
|
||||
function findRoute(method: string, path: string) {
|
||||
return invoiceInboxExtension.apiRoutes!.find(
|
||||
(r) => r.method === method && r.path === path,
|
||||
)!
|
||||
}
|
||||
|
||||
const retryRoute = findRoute('POST', '/items/:id/retry-extraction')
|
||||
|
||||
function buildCtx(supabase: unknown): ExtensionContext {
|
||||
return {
|
||||
userId: 'user-1',
|
||||
companyId: 'company-1',
|
||||
extensionId: 'invoice-inbox',
|
||||
supabase: supabase as ExtensionContext['supabase'],
|
||||
emit: vi.fn(),
|
||||
settings: { get: vi.fn(), set: vi.fn() },
|
||||
storage: { from: vi.fn() } as unknown as ExtensionContext['storage'],
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as unknown as ExtensionContext['log'],
|
||||
services: {},
|
||||
} as ExtensionContext
|
||||
}
|
||||
|
||||
function makeReq() {
|
||||
return createMockRequest('/items/item-1/retry-extraction', {
|
||||
method: 'POST',
|
||||
searchParams: { _id: 'item-1' },
|
||||
})
|
||||
}
|
||||
|
||||
const EXTRACTION_SUCCESS = {
|
||||
data: {
|
||||
supplier: { name: 'Acme AB', orgNumber: null, vatNumber: null, address: null, bankgiro: null, plusgiro: null },
|
||||
invoice: { invoiceNumber: 'F-1', invoiceDate: '2026-05-01', dueDate: null, paymentReference: null, currency: 'SEK' },
|
||||
lineItems: [],
|
||||
totals: { subtotal: 100, vatAmount: 25, total: 125 },
|
||||
vatBreakdown: [],
|
||||
confidence: 1,
|
||||
},
|
||||
rawText: 'mock',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(checkInboxUploadRateLimit).mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
describe('POST /items/:id/retry-extraction', () => {
|
||||
it('returns 401 when no context', async () => {
|
||||
const res = await retryRoute.handler(makeReq(), undefined)
|
||||
const { status } = await parseJsonResponse(res)
|
||||
expect(status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 429 when the per-company rate limit is exceeded', async () => {
|
||||
vi.mocked(checkInboxUploadRateLimit).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
scope: 'minute',
|
||||
retryAfterSec: 30,
|
||||
})
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
const res = await retryRoute.handler(makeReq(), buildCtx(supabase))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(res)
|
||||
expect(status).toBe(429)
|
||||
expect(body.error).toMatch(/för många/i)
|
||||
expect(res.headers.get('Retry-After')).toBe('30')
|
||||
})
|
||||
|
||||
it('returns 404 when the inbox item is missing', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: null, error: null }) // item lookup
|
||||
const res = await retryRoute.handler(makeReq(), buildCtx(supabase))
|
||||
const { status } = await parseJsonResponse(res)
|
||||
expect(status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns 409 when already booked as supplier invoice', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({
|
||||
data: { id: 'item-1', document_id: 'doc-1', correlation_id: null, created_supplier_invoice_id: 'si-1' },
|
||||
error: null,
|
||||
})
|
||||
const res = await retryRoute.handler(makeReq(), buildCtx(supabase))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(res)
|
||||
expect(status).toBe(409)
|
||||
expect(body.error).toMatch(/redan bokfört/i)
|
||||
})
|
||||
|
||||
it('returns 400 when the item has no attached document', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({
|
||||
data: { id: 'item-1', document_id: null, correlation_id: null, created_supplier_invoice_id: null },
|
||||
error: null,
|
||||
})
|
||||
const res = await retryRoute.handler(makeReq(), buildCtx(supabase))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(res)
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toMatch(/Ingen bilaga/i)
|
||||
})
|
||||
|
||||
it('returns 200 with re-extracted data on the happy path', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({
|
||||
data: { id: 'item-1', document_id: 'doc-1', correlation_id: null, created_supplier_invoice_id: null },
|
||||
error: null,
|
||||
})
|
||||
enqueue({
|
||||
data: { storage_path: 'path/to.pdf', mime_type: 'application/pdf', file_name: 'invoice.pdf' },
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: null, error: null }) // inbox update on success
|
||||
|
||||
supabase.storage.from = vi.fn().mockReturnValue({
|
||||
download: vi.fn().mockResolvedValue({
|
||||
data: new Blob([new Uint8Array([1, 2, 3])], { type: 'application/pdf' }),
|
||||
error: null,
|
||||
}),
|
||||
})
|
||||
|
||||
vi.mocked(extractInvoiceFields).mockResolvedValueOnce(EXTRACTION_SUCCESS as never)
|
||||
|
||||
const res = await retryRoute.handler(makeReq(), buildCtx(supabase))
|
||||
const { status, body } = await parseJsonResponse<{ data: { extracted_data: { totals: { total: number } } } }>(res)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.extracted_data.totals.total).toBe(125)
|
||||
})
|
||||
|
||||
it('marks the item as error and returns 500 when extraction throws', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({
|
||||
data: { id: 'item-1', document_id: 'doc-1', correlation_id: null, created_supplier_invoice_id: null },
|
||||
error: null,
|
||||
})
|
||||
enqueue({
|
||||
data: { storage_path: 'path/to.pdf', mime_type: 'application/pdf', file_name: 'invoice.pdf' },
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: null, error: null }) // error-state update
|
||||
|
||||
supabase.storage.from = vi.fn().mockReturnValue({
|
||||
download: vi.fn().mockResolvedValue({
|
||||
data: new Blob([new Uint8Array([1])], { type: 'application/pdf' }),
|
||||
error: null,
|
||||
}),
|
||||
})
|
||||
|
||||
vi.mocked(extractInvoiceFields).mockRejectedValueOnce(new Error('pdfjs blew up'))
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const res = await retryRoute.handler(makeReq(), buildCtx(supabase))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(res)
|
||||
expect(status).toBe(500)
|
||||
expect(body.error).toBe('pdfjs blew up')
|
||||
spy.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -20,9 +20,11 @@ import {
|
||||
import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
|
||||
import { CreateSupplierInvoiceSchema } from '@/lib/api/schemas'
|
||||
import { appendProcessingHistory } from '@/lib/processing-history/append'
|
||||
import { checkInboxUploadRateLimit } from '@/lib/rate-limits/inbox'
|
||||
import type { InvoiceExtractionResult, InvoiceInboxItem, SupplierInvoice, SupplierInvoiceItem } from '@/types'
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024
|
||||
const MAX_ATTACHMENTS_PER_EMAIL = 20
|
||||
|
||||
// Partial-update schema for the /items/:id/fields PATCH route. Only the
|
||||
// scalar fields the UI exposes for inline editing — line items and
|
||||
@@ -251,6 +253,23 @@ export const invoiceInboxExtension: Extension = {
|
||||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
// Per-company rate limit (30/min, 500/day). Defense against script
|
||||
// floods and compromised sessions; never hit by real users in normal
|
||||
// monthly receipt-clearing.
|
||||
const limit = await checkInboxUploadRateLimit(ctx.supabase, ctx.companyId)
|
||||
if (!limit.ok) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
limit.scope === 'minute'
|
||||
? 'För många uppladdningar på kort tid. Försök igen om en stund.'
|
||||
: 'Dagsgränsen för uppladdningar är nådd. Försök igen imorgon.',
|
||||
retry_after: limit.retryAfterSec,
|
||||
},
|
||||
{ status: 429, headers: { 'Retry-After': String(limit.retryAfterSec ?? 60) } },
|
||||
)
|
||||
}
|
||||
|
||||
const formData = await request.formData()
|
||||
const file = formData.get('file') as File | null
|
||||
|
||||
@@ -547,6 +566,179 @@ export const invoiceInboxExtension: Extension = {
|
||||
},
|
||||
},
|
||||
|
||||
// ── Match a supplier to an inbox item ───────────────────
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/items/:id/match-supplier',
|
||||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const url = new URL(request.url)
|
||||
const id = url.searchParams.get('_id')
|
||||
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 })
|
||||
|
||||
let body: { supplier_id?: string }
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
|
||||
}
|
||||
if (!body.supplier_id || typeof body.supplier_id !== 'string') {
|
||||
return NextResponse.json({ error: 'supplier_id required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Confirm supplier exists in this company before linking.
|
||||
const { data: supplier } = await ctx.supabase
|
||||
.from('suppliers')
|
||||
.select('id')
|
||||
.eq('id', body.supplier_id)
|
||||
.eq('company_id', ctx.companyId)
|
||||
.maybeSingle()
|
||||
if (!supplier) {
|
||||
return NextResponse.json({ error: 'Supplier not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const { error: updateError } = await ctx.supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({ matched_supplier_id: body.supplier_id })
|
||||
.eq('id', id)
|
||||
.eq('company_id', ctx.companyId)
|
||||
|
||||
if (updateError) {
|
||||
return NextResponse.json({ error: updateError.message }, { status: 500 })
|
||||
}
|
||||
return NextResponse.json({ data: { id, matched_supplier_id: body.supplier_id } })
|
||||
},
|
||||
},
|
||||
|
||||
// ── Retry extraction on a stored document ──────────────
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/items/:id/retry-extraction',
|
||||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
// Retry runs pdfjs extraction synchronously and is CPU-heavy; counts
|
||||
// against the same per-company quota as a fresh upload so an
|
||||
// attacker can't burn server CPU by repeatedly re-extracting one doc.
|
||||
const limit = await checkInboxUploadRateLimit(ctx.supabase, ctx.companyId)
|
||||
if (!limit.ok) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
limit.scope === 'minute'
|
||||
? 'För många tolkningsförsök på kort tid. Försök igen om en stund.'
|
||||
: 'Dagsgränsen för tolkningar är nådd. Försök igen imorgon.',
|
||||
retry_after: limit.retryAfterSec,
|
||||
},
|
||||
{ status: 429, headers: { 'Retry-After': String(limit.retryAfterSec ?? 60) } },
|
||||
)
|
||||
}
|
||||
|
||||
const url = new URL(request.url)
|
||||
const id = url.searchParams.get('_id')
|
||||
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 })
|
||||
|
||||
const { data: item } = await ctx.supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('id, document_id, correlation_id, created_supplier_invoice_id')
|
||||
.eq('id', id)
|
||||
.eq('company_id', ctx.companyId)
|
||||
.maybeSingle()
|
||||
|
||||
if (!item) return NextResponse.json({ error: 'Inbox item not found' }, { status: 404 })
|
||||
if (item.created_supplier_invoice_id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Redan bokfört — kan inte köra om tolkningen.' },
|
||||
{ status: 409 },
|
||||
)
|
||||
}
|
||||
if (!item.document_id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Ingen bilaga att tolka om.' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const { data: doc } = await ctx.supabase
|
||||
.from('document_attachments')
|
||||
.select('storage_path, mime_type, file_name')
|
||||
.eq('id', item.document_id)
|
||||
.eq('company_id', ctx.companyId)
|
||||
.maybeSingle()
|
||||
|
||||
if (!doc) {
|
||||
return NextResponse.json({ error: 'Bilagan kunde inte hittas.' }, { status: 404 })
|
||||
}
|
||||
|
||||
const { data: blob, error: dlError } = await ctx.supabase.storage
|
||||
.from('documents')
|
||||
.download(doc.storage_path)
|
||||
|
||||
if (dlError || !blob) {
|
||||
console.error('[invoice-inbox/retry-extraction] download failed:', dlError)
|
||||
return NextResponse.json(
|
||||
{ error: 'Kunde inte ladda ner bilagan.' },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = Buffer.from(await blob.arrayBuffer())
|
||||
const { data: extracted } = await extractInvoiceFields({
|
||||
buffer,
|
||||
mimeType: doc.mime_type,
|
||||
fileName: doc.file_name,
|
||||
})
|
||||
|
||||
const { error: updateError } = await ctx.supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({
|
||||
status: 'received',
|
||||
error_message: null,
|
||||
extracted_data: extracted as unknown as Record<string, unknown>,
|
||||
})
|
||||
.eq('id', id)
|
||||
.eq('company_id', ctx.companyId)
|
||||
|
||||
if (updateError) {
|
||||
return NextResponse.json({ error: updateError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
if (item.correlation_id) {
|
||||
try {
|
||||
await appendProcessingHistory({
|
||||
companyId: ctx.companyId,
|
||||
correlationId: item.correlation_id,
|
||||
aggregateType: 'Document',
|
||||
aggregateId: item.document_id,
|
||||
eventType: 'DocumentExtractionRetried',
|
||||
payload: {
|
||||
inbox_item_id: id,
|
||||
document_id: item.document_id,
|
||||
},
|
||||
actor: { type: 'user', id: ctx.userId },
|
||||
occurredAt: new Date(),
|
||||
})
|
||||
} catch (logErr) {
|
||||
console.error('[invoice-inbox/retry-extraction] history append failed:', logErr)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { extracted_data: extracted } })
|
||||
} catch (error) {
|
||||
console.error('[invoice-inbox/retry-extraction] extraction failed:', error)
|
||||
const message = error instanceof Error ? error.message : 'Tolkning misslyckades'
|
||||
await ctx.supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({ status: 'error', error_message: message })
|
||||
.eq('id', id)
|
||||
.eq('company_id', ctx.companyId)
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// ── Get this company's inbox address ────────────────────
|
||||
{
|
||||
method: 'GET',
|
||||
@@ -692,7 +884,64 @@ export const invoiceInboxExtension: Extension = {
|
||||
}
|
||||
|
||||
const bodyText = fullEmail.text ?? null
|
||||
const attachments = fullEmail.attachments ?? []
|
||||
const rawAttachments = fullEmail.attachments ?? []
|
||||
|
||||
// Per-company rate limit (30/min, 500/day). Same Postgres-backed
|
||||
// RPC as /upload. Acknowledge + drop on cap — returning 429 to
|
||||
// Resend would just consume more budget via their retry.
|
||||
const limit = await checkInboxUploadRateLimit(serviceSupabase, inbox.company_id)
|
||||
if (!limit.ok) {
|
||||
try {
|
||||
await appendProcessingHistory({
|
||||
companyId: inbox.company_id,
|
||||
correlationId: email_id,
|
||||
aggregateType: 'System',
|
||||
aggregateId: email_id,
|
||||
eventType: 'RateLimitedDropped',
|
||||
payload: {
|
||||
scope: limit.scope,
|
||||
retry_after_sec: limit.retryAfterSec,
|
||||
attachment_count: rawAttachments.length,
|
||||
from,
|
||||
subject,
|
||||
},
|
||||
actor: { type: 'system', id: 'resend-inbound' },
|
||||
occurredAt: new Date(),
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[invoice-inbox/inbound] RateLimitedDropped append failed:', err)
|
||||
}
|
||||
return NextResponse.json({ data: { processed: 0, reason: 'rate_limited' } })
|
||||
}
|
||||
|
||||
// Per-email attachment cap. 20 covers any legitimate batched
|
||||
// supplier email; an attacker stuffing 500 PDFs into one message
|
||||
// gets truncated and a single history event records the drop.
|
||||
const totalAttachments = rawAttachments.length
|
||||
const attachments = rawAttachments.slice(0, MAX_ATTACHMENTS_PER_EMAIL)
|
||||
const truncatedCount = totalAttachments - attachments.length
|
||||
if (truncatedCount > 0) {
|
||||
try {
|
||||
await appendProcessingHistory({
|
||||
companyId: inbox.company_id,
|
||||
correlationId: email_id,
|
||||
aggregateType: 'System',
|
||||
aggregateId: email_id,
|
||||
eventType: 'AttachmentsTruncated',
|
||||
payload: {
|
||||
total: totalAttachments,
|
||||
processed: attachments.length,
|
||||
dropped: truncatedCount,
|
||||
from,
|
||||
subject,
|
||||
},
|
||||
actor: { type: 'system', id: 'resend-inbound' },
|
||||
occurredAt: new Date(),
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[invoice-inbox/inbound] AttachmentsTruncated append failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
if (attachments.length === 0) {
|
||||
await serviceSupabase.from('invoice_inbox_items').insert({
|
||||
|
||||
@@ -340,6 +340,13 @@ export const MatchInvoiceSchema = z.object({
|
||||
invoice_id: uuid,
|
||||
})
|
||||
|
||||
export const CreateTransactionFromDocumentSchema = z.object({
|
||||
inbox_item_id: uuid,
|
||||
amount: z.number().refine((n) => n !== 0, 'Amount must be non-zero'),
|
||||
transaction_date: isoDate,
|
||||
description: z.string().min(1).max(500),
|
||||
})
|
||||
|
||||
export const MatchSupplierInvoiceSchema = z.object({
|
||||
supplier_invoice_id: uuid,
|
||||
})
|
||||
|
||||
@@ -365,6 +365,13 @@ const MATCH_SI: Record<string, StructuredErrorEntry> = {
|
||||
message_sv: 'Kunde inte koppla transaktionen till leverantörsfakturan.',
|
||||
message_en: 'Failed to link transaction to supplier invoice.',
|
||||
},
|
||||
MATCH_SI_CASH_FX_UNSUPPORTED: {
|
||||
httpStatus: 400,
|
||||
message_sv:
|
||||
'Kontantmetoden stödjer inte valutakursdifferenser. Byt till löpande bokföring eller bokför valutakursdifferensen manuellt.',
|
||||
message_en:
|
||||
'Cash accounting does not support exchange-rate differences. Switch to accrual or book the FX difference manually.',
|
||||
},
|
||||
}
|
||||
|
||||
const INVOICE: Record<string, StructuredErrorEntry> = {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { checkInboxUploadRateLimit } from '../inbox'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
function makeSupabase(rpcResult: { data?: unknown; error?: unknown }) {
|
||||
return {
|
||||
rpc: vi.fn().mockResolvedValue(rpcResult),
|
||||
} as unknown as SupabaseClient
|
||||
}
|
||||
|
||||
describe('checkInboxUploadRateLimit', () => {
|
||||
it('returns ok=true when the RPC says ok', async () => {
|
||||
const supabase = makeSupabase({ data: { ok: true }, error: null })
|
||||
const result = await checkInboxUploadRateLimit(supabase, 'company-1')
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.scope).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns ok=false with minute scope and retry_after when minute window hit', async () => {
|
||||
const supabase = makeSupabase({
|
||||
data: { ok: false, scope: 'minute', retry_after_sec: 42 },
|
||||
error: null,
|
||||
})
|
||||
const result = await checkInboxUploadRateLimit(supabase, 'company-1')
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.scope).toBe('minute')
|
||||
expect(result.retryAfterSec).toBe(42)
|
||||
})
|
||||
|
||||
it('returns ok=false with day scope when day window hit', async () => {
|
||||
const supabase = makeSupabase({
|
||||
data: { ok: false, scope: 'day', retry_after_sec: 3600 },
|
||||
error: null,
|
||||
})
|
||||
const result = await checkInboxUploadRateLimit(supabase, 'company-1')
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.scope).toBe('day')
|
||||
expect(result.retryAfterSec).toBe(3600)
|
||||
})
|
||||
|
||||
it('fails open (ok=true) when the RPC errors — better to accept than 500 a real user', async () => {
|
||||
const supabase = makeSupabase({ data: null, error: { message: 'boom' } })
|
||||
// Silence the console.error the helper emits on infra error.
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const result = await checkInboxUploadRateLimit(supabase, 'company-1')
|
||||
expect(result.ok).toBe(true)
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('passes the configured minute and day caps to the RPC', async () => {
|
||||
const supabase = makeSupabase({ data: { ok: true }, error: null })
|
||||
await checkInboxUploadRateLimit(supabase, 'company-xyz')
|
||||
expect(supabase.rpc).toHaveBeenCalledWith('check_and_increment_inbox_quota', {
|
||||
p_company_id: 'company-xyz',
|
||||
p_minute_max: 30,
|
||||
p_day_max: 500,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
/**
|
||||
* Per-company rate limiter for document-inbox ingestion. Backed by the
|
||||
* `check_and_increment_inbox_quota` Postgres RPC (atomic check + increment),
|
||||
* not Upstash — keeps the limiter on the same shared distributed store the
|
||||
* rest of the app already hits, and works without extra env vars on Vercel
|
||||
* and Docker self-hosters alike.
|
||||
*
|
||||
* Both windows are per-company:
|
||||
* - MINUTE_MAX: a real user could only ever hit this with a script or by
|
||||
* holding the upload button. The defense is against burst floods.
|
||||
* - DAY_MAX: the backstop against slow drip abuse. A legitimate end-of-
|
||||
* month batch is well under this number.
|
||||
*/
|
||||
export interface InboxLimitResult {
|
||||
ok: boolean
|
||||
retryAfterSec?: number
|
||||
scope?: 'minute' | 'day'
|
||||
}
|
||||
|
||||
const MINUTE_MAX = 30
|
||||
const DAY_MAX = 500
|
||||
|
||||
export async function checkInboxUploadRateLimit(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
): Promise<InboxLimitResult> {
|
||||
const { data, error } = await supabase.rpc('check_and_increment_inbox_quota', {
|
||||
p_company_id: companyId,
|
||||
p_minute_max: MINUTE_MAX,
|
||||
p_day_max: DAY_MAX,
|
||||
})
|
||||
if (error) {
|
||||
// Fail open on infra error. The limiter is defense-in-depth — per-file
|
||||
// size + MIME checks still apply on the upload route. Better to accept
|
||||
// an upload than 500 a real user because Postgres blipped.
|
||||
console.error('[inbox-rate-limit] RPC failed:', error)
|
||||
return { ok: true }
|
||||
}
|
||||
// RPC returns jsonb_build_object payload. The JS client decodes as object.
|
||||
const result = (data ?? { ok: true }) as {
|
||||
ok: boolean
|
||||
scope?: 'minute' | 'day'
|
||||
retry_after_sec?: number
|
||||
}
|
||||
return {
|
||||
ok: result.ok,
|
||||
scope: result.scope,
|
||||
retryAfterSec: result.retry_after_sec,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Re-add matched_transaction_id on invoice_inbox_items.
|
||||
--
|
||||
-- The 20260504180000 migration removed this column along with all the AI
|
||||
-- metadata (match_confidence / match_method / match_reasoning) because the
|
||||
-- AI subsystem was retired. But the back-reference itself isn't an AI
|
||||
-- artifact — it's how the inbox UI knows an item has been linked to a
|
||||
-- bank transaction (symmetric with `created_supplier_invoice_id` which
|
||||
-- already marks supplier-invoice processing).
|
||||
--
|
||||
-- The "Koppla till transaktion" and "Skapa transaktion från underlag"
|
||||
-- flows both need this column so the inbox can show a "Kopplad" badge
|
||||
-- and link back to the matched transaction.
|
||||
|
||||
ALTER TABLE public.invoice_inbox_items
|
||||
ADD COLUMN IF NOT EXISTS matched_transaction_id uuid
|
||||
REFERENCES public.transactions(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_inbox_items_matched_transaction
|
||||
ON public.invoice_inbox_items(company_id, matched_transaction_id)
|
||||
WHERE matched_transaction_id IS NOT NULL;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,79 @@
|
||||
-- Per-company minute + day counters for inbox ingestion (manual upload + email
|
||||
-- inbound). Backstops against:
|
||||
-- 1) A logged-in user (or compromised session) flooding /upload.
|
||||
-- 2) An attacker who discovered a company's inbox address and mails in
|
||||
-- hundreds of large attachments.
|
||||
--
|
||||
-- Atomic check-and-increment under INSERT…ON CONFLICT row locking — same
|
||||
-- shape as validate_and_increment_api_key. No Upstash dependency.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.inbox_rate_counters (
|
||||
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
|
||||
window_kind text NOT NULL CHECK (window_kind IN ('minute','day')),
|
||||
window_key text NOT NULL,
|
||||
count integer NOT NULL DEFAULT 0,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (company_id, window_kind, window_key)
|
||||
);
|
||||
|
||||
ALTER TABLE public.inbox_rate_counters ENABLE ROW LEVEL SECURITY;
|
||||
-- No user-facing policies — only SECURITY DEFINER fn writes this.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.check_and_increment_inbox_quota(
|
||||
p_company_id uuid,
|
||||
p_minute_max integer,
|
||||
p_day_max integer
|
||||
) RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_minute_key text := to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI');
|
||||
v_day_key text := to_char(now() AT TIME ZONE 'Europe/Stockholm', 'YYYY-MM-DD');
|
||||
v_minute_count integer;
|
||||
v_day_count integer;
|
||||
BEGIN
|
||||
-- 1) Minute window — slide upsert + increment, then check.
|
||||
INSERT INTO public.inbox_rate_counters (company_id, window_kind, window_key, count)
|
||||
VALUES (p_company_id, 'minute', v_minute_key, 1)
|
||||
ON CONFLICT (company_id, window_kind, window_key)
|
||||
DO UPDATE SET count = inbox_rate_counters.count + 1, updated_at = now()
|
||||
RETURNING count INTO v_minute_count;
|
||||
|
||||
IF v_minute_count > p_minute_max THEN
|
||||
UPDATE public.inbox_rate_counters
|
||||
SET count = count - 1
|
||||
WHERE company_id = p_company_id
|
||||
AND window_kind = 'minute'
|
||||
AND window_key = v_minute_key;
|
||||
RETURN jsonb_build_object('ok', false, 'scope', 'minute', 'retry_after_sec', 60);
|
||||
END IF;
|
||||
|
||||
-- 2) Day window — only checked when minute passed.
|
||||
INSERT INTO public.inbox_rate_counters (company_id, window_kind, window_key, count)
|
||||
VALUES (p_company_id, 'day', v_day_key, 1)
|
||||
ON CONFLICT (company_id, window_kind, window_key)
|
||||
DO UPDATE SET count = inbox_rate_counters.count + 1, updated_at = now()
|
||||
RETURNING count INTO v_day_count;
|
||||
|
||||
IF v_day_count > p_day_max THEN
|
||||
-- Roll both counters back: the request didn't go through.
|
||||
UPDATE public.inbox_rate_counters
|
||||
SET count = count - 1
|
||||
WHERE company_id = p_company_id
|
||||
AND window_kind = 'day'
|
||||
AND window_key = v_day_key;
|
||||
UPDATE public.inbox_rate_counters
|
||||
SET count = count - 1
|
||||
WHERE company_id = p_company_id
|
||||
AND window_kind = 'minute'
|
||||
AND window_key = v_minute_key;
|
||||
RETURN jsonb_build_object('ok', false, 'scope', 'day', 'retry_after_sec', 3600);
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object('ok', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,40 @@
|
||||
-- Address review feedback on the inbox_rate_counters table:
|
||||
--
|
||||
-- 1) Add the standard updated_at trigger so any future write path that
|
||||
-- bypasses the SECURITY DEFINER RPC keeps the column semantically
|
||||
-- correct (matches every other table in the schema — CLAUDE.md
|
||||
-- migration rule 2).
|
||||
--
|
||||
-- 2) Make the "no user access" intent explicit instead of implicit.
|
||||
-- RLS is enabled on the table but had no policies. The default behavior
|
||||
-- when RLS is on without policies is to deny everything except the
|
||||
-- table owner / SECURITY DEFINER functions — which is exactly what we
|
||||
-- want — but a future admin tool or diagnostic query running under
|
||||
-- the authenticated role will silently get zero rows, which is hard
|
||||
-- to debug. The explicit USING (false) policies below state the
|
||||
-- intent and surface no-access situations more clearly when running
|
||||
-- EXPLAIN or auditing the schema (CLAUDE.md migration rule 1).
|
||||
-- Writes still go exclusively through check_and_increment_inbox_quota
|
||||
-- (SECURITY DEFINER), which bypasses RLS by design.
|
||||
|
||||
CREATE TRIGGER update_inbox_rate_counters_updated_at
|
||||
BEFORE UPDATE ON public.inbox_rate_counters
|
||||
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||
|
||||
CREATE POLICY inbox_rate_counters_no_select
|
||||
ON public.inbox_rate_counters FOR SELECT
|
||||
USING (false);
|
||||
|
||||
CREATE POLICY inbox_rate_counters_no_insert
|
||||
ON public.inbox_rate_counters FOR INSERT
|
||||
WITH CHECK (false);
|
||||
|
||||
CREATE POLICY inbox_rate_counters_no_update
|
||||
ON public.inbox_rate_counters FOR UPDATE
|
||||
USING (false);
|
||||
|
||||
CREATE POLICY inbox_rate_counters_no_delete
|
||||
ON public.inbox_rate_counters FOR DELETE
|
||||
USING (false);
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,162 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { seedCompany } from '@/tests/pg/fixtures'
|
||||
import { getPool } from '@/tests/pg/setup'
|
||||
|
||||
/**
|
||||
* Smoke for check_and_increment_inbox_quota (migration 20260512083644).
|
||||
*
|
||||
* Locks in:
|
||||
* - Two atomic upsert windows (minute + day) with serializable counter increment.
|
||||
* - Successful calls return ok=true.
|
||||
* - Minute-cap rejection returns ok=false + scope='minute' + retry_after_sec.
|
||||
* - Day-cap rejection returns ok=false + scope='day'.
|
||||
* - Decrement-on-rejection: counter is rolled back when the call is denied,
|
||||
* so a failed attempt doesn't permanently consume budget. Both minute and
|
||||
* day rejections must roll back the minute counter; day-cap rejection
|
||||
* must also roll back the day counter it just incremented.
|
||||
*
|
||||
* Mock-based tests can't catch a PL/pgSQL syntax error, a wrong column
|
||||
* reference, or an INSERT…ON CONFLICT predicate that targets the wrong index.
|
||||
* This test exercises the RPC against a real Postgres so a broken function
|
||||
* fails loudly at the DB layer instead of passing the unit suite and failing
|
||||
* in production.
|
||||
*/
|
||||
|
||||
interface QuotaResult {
|
||||
ok: boolean
|
||||
scope?: 'minute' | 'day'
|
||||
retry_after_sec?: number
|
||||
}
|
||||
|
||||
async function callQuota(
|
||||
companyId: string,
|
||||
minuteMax: number,
|
||||
dayMax: number,
|
||||
): Promise<QuotaResult> {
|
||||
const res = await getPool().query<{ result: QuotaResult }>(
|
||||
`SELECT public.check_and_increment_inbox_quota($1::uuid, $2::int, $3::int) AS result`,
|
||||
[companyId, minuteMax, dayMax],
|
||||
)
|
||||
return res.rows[0]!.result
|
||||
}
|
||||
|
||||
async function getCounters(
|
||||
companyId: string,
|
||||
): Promise<{ minute: number; day: number }> {
|
||||
const res = await getPool().query<{
|
||||
window_kind: 'minute' | 'day'
|
||||
count: number
|
||||
}>(
|
||||
`SELECT window_kind, count
|
||||
FROM public.inbox_rate_counters
|
||||
WHERE company_id = $1`,
|
||||
[companyId],
|
||||
)
|
||||
let minute = 0
|
||||
let day = 0
|
||||
for (const row of res.rows) {
|
||||
if (row.window_kind === 'minute') minute = row.count
|
||||
if (row.window_kind === 'day') day = row.count
|
||||
}
|
||||
return { minute, day }
|
||||
}
|
||||
|
||||
describe('check_and_increment_inbox_quota.pg', () => {
|
||||
it('returns ok=true and increments both windows on a successful call', async () => {
|
||||
const { companyId } = await seedCompany()
|
||||
|
||||
const result = await callQuota(companyId, 30, 500)
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.scope).toBeUndefined()
|
||||
|
||||
const counters = await getCounters(companyId)
|
||||
expect(counters.minute).toBe(1)
|
||||
expect(counters.day).toBe(1)
|
||||
})
|
||||
|
||||
it('increments to the cap on the boundary call, then rejects the next one', async () => {
|
||||
const { companyId } = await seedCompany()
|
||||
const MINUTE_MAX = 3
|
||||
|
||||
// Three successful calls take us to count=3 (exactly at the cap).
|
||||
for (let i = 0; i < MINUTE_MAX; i++) {
|
||||
const r = await callQuota(companyId, MINUTE_MAX, 1000)
|
||||
expect(r.ok).toBe(true)
|
||||
}
|
||||
expect((await getCounters(companyId)).minute).toBe(MINUTE_MAX)
|
||||
|
||||
// The 4th call exceeds the cap.
|
||||
const denied = await callQuota(companyId, MINUTE_MAX, 1000)
|
||||
expect(denied.ok).toBe(false)
|
||||
expect(denied.scope).toBe('minute')
|
||||
expect(denied.retry_after_sec).toBe(60)
|
||||
|
||||
// Critical: the rejected call must NOT have permanently consumed budget.
|
||||
// Counter stays at MINUTE_MAX after the rollback inside the RPC.
|
||||
expect((await getCounters(companyId)).minute).toBe(MINUTE_MAX)
|
||||
})
|
||||
|
||||
it('rejects with scope=day when the minute cap is generous but the day cap is hit', async () => {
|
||||
const { companyId } = await seedCompany()
|
||||
const DAY_MAX = 2
|
||||
|
||||
for (let i = 0; i < DAY_MAX; i++) {
|
||||
const r = await callQuota(companyId, 1000, DAY_MAX)
|
||||
expect(r.ok).toBe(true)
|
||||
}
|
||||
const after = await getCounters(companyId)
|
||||
expect(after.minute).toBe(DAY_MAX)
|
||||
expect(after.day).toBe(DAY_MAX)
|
||||
|
||||
const denied = await callQuota(companyId, 1000, DAY_MAX)
|
||||
expect(denied.ok).toBe(false)
|
||||
expect(denied.scope).toBe('day')
|
||||
expect(denied.retry_after_sec).toBe(3600)
|
||||
|
||||
// Both counters must roll back: the day cap was checked AFTER the minute
|
||||
// counter was incremented for this call, so both increments are undone.
|
||||
const final = await getCounters(companyId)
|
||||
expect(final.minute).toBe(DAY_MAX)
|
||||
expect(final.day).toBe(DAY_MAX)
|
||||
})
|
||||
|
||||
it('isolates counters per company', async () => {
|
||||
const { companyId: companyA } = await seedCompany()
|
||||
const { companyId: companyB } = await seedCompany()
|
||||
|
||||
await callQuota(companyA, 30, 500)
|
||||
await callQuota(companyA, 30, 500)
|
||||
await callQuota(companyB, 30, 500)
|
||||
|
||||
const a = await getCounters(companyA)
|
||||
const b = await getCounters(companyB)
|
||||
expect(a.minute).toBe(2)
|
||||
expect(b.minute).toBe(1)
|
||||
})
|
||||
|
||||
it('updated_at trigger fires on counter updates', async () => {
|
||||
const { companyId } = await seedCompany()
|
||||
|
||||
await callQuota(companyId, 30, 500)
|
||||
const first = await getPool().query<{ updated_at: Date }>(
|
||||
`SELECT updated_at FROM public.inbox_rate_counters
|
||||
WHERE company_id = $1 AND window_kind = 'minute'`,
|
||||
[companyId],
|
||||
)
|
||||
|
||||
// Trigger an UPDATE path (second call hits ON CONFLICT DO UPDATE).
|
||||
// Sleep so the timestamp delta is observable.
|
||||
await new Promise((r) => setTimeout(r, 10))
|
||||
await callQuota(companyId, 30, 500)
|
||||
|
||||
const second = await getPool().query<{ updated_at: Date }>(
|
||||
`SELECT updated_at FROM public.inbox_rate_counters
|
||||
WHERE company_id = $1 AND window_kind = 'minute'`,
|
||||
[companyId],
|
||||
)
|
||||
|
||||
expect(second.rows[0]!.updated_at.getTime()).toBeGreaterThan(
|
||||
first.rows[0]!.updated_at.getTime(),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -2456,6 +2456,13 @@ export interface KPIReport {
|
||||
periodComplete: boolean // whether selected period is closed/complete
|
||||
months: { label: string; income: number; expenses: number; net: number }[]
|
||||
period: { start: string; end: string }
|
||||
expenseComposition: {
|
||||
class4: number
|
||||
class5: number
|
||||
class6: number
|
||||
class7: number
|
||||
}
|
||||
topSuppliers: { supplier_id: string; supplier_name: string; total: number }[]
|
||||
}
|
||||
|
||||
export interface KPIPreferences {
|
||||
|
||||
Reference in New Issue
Block a user