feat: wire Zod validation into API routes, improve types and components
- Add 8 new Zod schemas (UpdateCustomer, UpdateSupplier, UpdateSupplierInvoice, UpdateAccount, BankUnlink, RunReconciliation, CorrectJournalEntry, EvaluateMappingRules) and wire validateBody() into 24 JSON-body API routes - Remove redundant manual validation checks replaced by Zod - Add comprehensive schema tests (222 tests) - Improve type definitions in types/index.ts with expanded interfaces - Refactor extension types (push-notifications, receipt-ocr) for cleaner imports - Update transaction components (BatchCategorySelector, SwipeCategorizationView, QuickReviewDialog, VatTreatmentSelect) and invoice inbox workspace - Add invoice-inbox utilities and type decoupling tests - Fix NE-bilaga, SRU export, and invoice PDF template type usage - Update CLAUDE.md with expanded architecture documentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
cfc4cb6849
commit
acb85edf4a
@@ -23,7 +23,9 @@
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(findstr:*)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)"
|
||||
"Bash(git commit:*)",
|
||||
"WebFetch(domain:raw.githubusercontent.com)",
|
||||
"WebFetch(domain:support.fortnox.se)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ erp-base is a Swedish-focused accounting SaaS for sole traders (enskild firma) a
|
||||
|
||||
**Tech stack**: Next.js 16 (App Router), React 19, TypeScript (strict), Supabase (PostgreSQL + RLS + magic link auth), Tailwind CSS 4 + shadcn/ui, Vercel hosting.
|
||||
|
||||
**Integrations**: Enable Banking (PSD2), Anthropic SDK, OpenAI (embeddings), Resend (email), web-push (VAPID).
|
||||
**Integrations**: Enable Banking (PSD2), Anthropic SDK, LangChain, OpenAI (embeddings), Resend (email), web-push (VAPID).
|
||||
|
||||
**Path alias**: `@/*` maps to the project root (tsconfig.json).
|
||||
|
||||
@@ -34,7 +34,8 @@ app/
|
||||
(onboarding)/ 6-step setup wizard
|
||||
(dashboard)/ Authenticated routes (invoices, customers, transactions,
|
||||
bookkeeping, reports, suppliers, supplier-invoices,
|
||||
receipts, settings, calendar, help, import)
|
||||
receipts, settings, calendar, help, import,
|
||||
extensions, e/[sector]/[slug])
|
||||
(public)/ Public invoice action links (no auth)
|
||||
api/ API routes organized by domain
|
||||
|
||||
@@ -43,20 +44,47 @@ components/
|
||||
bookkeeping/ Chart of accounts manager, account combobox, add/edit dialogs
|
||||
calendar/ Calendar views, deadline cards, payment summary, todo widgets
|
||||
chat/ ChatWidget, ChatPanel, ChatInput, ChatMessage
|
||||
customers/ CustomerForm
|
||||
dashboard/ DashboardContent, DashboardNav, FSkattWarningCard
|
||||
extensions/ Extension marketplace UI (ExtensionCard, SectorCard,
|
||||
ExtensionToggleButton, workspace components,
|
||||
per-sector subdirectories)
|
||||
import/ Bank file import workflow components
|
||||
invoices/ InvoiceReviewContent
|
||||
onboarding/ NewUserChecklist, setup step components
|
||||
reports/ Report views (including BankReconciliationView)
|
||||
settings/ CalendarFeedSettings
|
||||
[feature]/ Feature-organized components (invoices, suppliers, import, etc.)
|
||||
suppliers/ Supplier-related components
|
||||
transactions/ Transaction list, categorization, booking components
|
||||
|
||||
extensions/ First-party extension implementations
|
||||
ai-categorization/ AI-powered transaction categorization
|
||||
ai-chat/ Claude-based chat assistant
|
||||
enable-banking/ PSD2 bank integration via Enable Banking (JWT auth, sync)
|
||||
ne-bilaga/ NE tax form attachment generation
|
||||
push-notifications/ Web push notification system
|
||||
receipt-ocr/ Receipt image OCR processing
|
||||
sru-export/ SRU file export
|
||||
example-logger/ Minimal reference extension (not loaded by default)
|
||||
extensions/ Sector-based extension hierarchy
|
||||
general/ General-purpose extensions (all businesses)
|
||||
ai-categorization/ AI-powered transaction categorization
|
||||
ai-chat/ Claude-based chat assistant (LangChain RAG)
|
||||
enable-banking/ PSD2 bank integration (opt-in, commented out in loader)
|
||||
example-logger/ Minimal reference extension (not loaded)
|
||||
invoice-inbox/ Supplier invoice intake via email/upload with AI extraction
|
||||
push-notifications/ Web push notification system
|
||||
receipt-ocr/ Receipt image OCR processing (includes components/pages)
|
||||
restaurant/ Restaurant & cafe sector
|
||||
food-cost/ Food cost percentage calculator
|
||||
earnings-per-liter/ Revenue per liter of alcohol
|
||||
pos-import/ POS Z-report import
|
||||
tip-tracking/ Tip tracking per shift/employee
|
||||
construction/ Construction & trades sector
|
||||
rot-calculator/ ROT tax deduction calculator
|
||||
project-cost/ Project cost tracking
|
||||
hotel/ Hotel & lodging sector
|
||||
revpar/ Revenue Per Available Room
|
||||
occupancy/ Occupancy rate tracking
|
||||
tech/ IT & consulting sector
|
||||
billable-hours/ Billable hours & utilization rate
|
||||
project-billing/ Project billing analysis
|
||||
ecommerce/ E-commerce sector
|
||||
shopify-import/ Shopify order import
|
||||
multichannel-revenue/ Multi-channel revenue analysis
|
||||
ne-bilaga/ NE tax form attachment generation (top-level)
|
||||
sru-export/ SRU file export (top-level)
|
||||
|
||||
lib/
|
||||
bookkeeping/ Core journal entry engine and all entry generators
|
||||
@@ -70,23 +98,34 @@ lib/
|
||||
bas-reference.ts BAS account catalog (~180 accounts with metadata, SRU codes)
|
||||
account-descriptions.ts Human-readable account name lookup
|
||||
validate-period-duration.ts Fiscal period duration validation (BFL 3 kap.)
|
||||
handlers/ Booking handler functions (supplier-invoice-handler.ts)
|
||||
core/
|
||||
bookkeeping/ Period service, storno reversal, year-end closing
|
||||
documents/ Document archive (upload, versioning, SHA-256 integrity)
|
||||
audit/ Audit trail service
|
||||
tax/ Tax code service
|
||||
calendar/ Calendar and scheduling utilities
|
||||
calendar/ Calendar utilities, ICS feed generation
|
||||
currency/ Riksbanken exchange rates
|
||||
customers/ Customer management helpers
|
||||
deadlines/ Tax deadline tracking
|
||||
email/ Email service (Resend integration)
|
||||
deadlines/ Tax deadline tracking, status engine
|
||||
email/ Email service (Resend), invoice/reminder templates
|
||||
events/ Event bus (bus.ts, types.ts)
|
||||
extensions/ Extension registry, loader, types
|
||||
import/ SIE and bank file parser
|
||||
extensions/ Extension system
|
||||
loader.ts FIRST_PARTY_EXTENSIONS array, static imports
|
||||
registry.ts Runtime extension registry
|
||||
types.ts Extension, Sector, ExtensionDefinition, toggle types
|
||||
sectors.ts Sector & extension metadata registry (pure data)
|
||||
hooks.ts React hooks for extension state
|
||||
context-factory.ts Extension context builder
|
||||
toggle-check.ts Extension enable/disable logic
|
||||
validation.ts Extension data validation
|
||||
workspace-registry.tsx Extension workspace component registry
|
||||
icon-resolver.tsx Dynamic icon lookup for extensions
|
||||
use-account-totals.ts Hook for account balance queries
|
||||
use-extension-data.ts Hook for extension-specific data
|
||||
import/ SIE parser, SIE import orchestrator, bank file parser
|
||||
bank-file/ Bank file parser with format modules
|
||||
formats/ camt053, generic-csv, handelsbanken, nordea, seb, swedbank
|
||||
invoice/ VAT rules, invoice matching (vat-rules.ts, invoice-matching.ts)
|
||||
invoices/ Invoice business logic (reminder-processor)
|
||||
invoices/ VAT rules, invoice matching, PDF template, reminder processor
|
||||
reconciliation/ Bank reconciliation engine (4-pass matching algorithm)
|
||||
reports/ Financial reports (trial-balance, income-statement,
|
||||
balance-sheet, vat-declaration, sie-export,
|
||||
@@ -94,18 +133,20 @@ lib/
|
||||
general-ledger, journal-register,
|
||||
ar-ledger, ar-reconciliation, monthly-breakdown)
|
||||
supabase/ Client setup (client.ts = browser, server.ts = server/admin,
|
||||
fetch-all.ts = pagination helper for large queries)
|
||||
tax/ Tax calculations, deadlines, Swedish holidays
|
||||
transactions/ Transaction processing helpers
|
||||
fetch-all.ts = pagination helper, middleware.ts)
|
||||
tax/ Tax calculations, deadlines, Swedish holidays, expense warnings
|
||||
transactions/ Transaction processing, category suggestions
|
||||
init.ts Extension loader (idempotent, called by API routes)
|
||||
utils.ts Shared utility functions
|
||||
|
||||
types/index.ts Canonical type definitions (120+ types, single source of truth)
|
||||
types/index.ts Canonical type definitions (single source of truth)
|
||||
types/chat.ts Chat-specific type definitions
|
||||
tests/helpers.ts Mock factories and fixture builders
|
||||
supabase/migrations/ SQL migration files
|
||||
scripts/ Utility scripts (clear-user-data.sql)
|
||||
dev_docs/ Extensive project documentation (PRD, architecture, BAS guide, etc.)
|
||||
scripts/ Utility scripts (clear-user-data.sql, copy-extensions.mjs,
|
||||
move-extensions.js, setup-phase8.js)
|
||||
dev_docs/ Project documentation (BAS account guides, gap analysis,
|
||||
Enable Banking docs, Bokio reference screenshots)
|
||||
```
|
||||
|
||||
### Key Relationships
|
||||
@@ -114,6 +155,7 @@ dev_docs/ Extensive project documentation (PRD, architecture, BA
|
||||
- **API routes** that emit events must call `ensureInitialized()` (from `lib/init.ts`) at module level to load extensions.
|
||||
- **Event bus** (`lib/events/bus.ts`) is a module-level singleton. Core services emit, extensions subscribe.
|
||||
- **Supabase clients**: browser (`lib/supabase/client.ts`), server with user cookies (`createClient()` from `lib/supabase/server.ts`), and service role (`createServiceClient()`).
|
||||
- **Extension sector system**: Extensions are organized by business sector (`lib/extensions/sectors.ts`). Users can browse/toggle extensions via the marketplace UI (`app/(dashboard)/extensions/`). Sector-specific extension workspaces are rendered at `app/(dashboard)/e/[sector]/[slug]/`.
|
||||
|
||||
---
|
||||
|
||||
@@ -163,7 +205,7 @@ The bookkeeping engine (`lib/bookkeeping/engine.ts`) is the most critical system
|
||||
|
||||
### Per-Line VAT
|
||||
|
||||
Invoice items support individual `vat_rate` values, enabling mixed-rate invoices. The helper `generatePerRateLines()` in `invoice-entries.ts` groups items by VAT rate and creates separate revenue + VAT account lines per rate group. Available rates depend on customer type — use `getAvailableVatRates(customerType, vatNumberValidated)` from `lib/invoice/vat-rules.ts`.
|
||||
Invoice items support individual `vat_rate` values, enabling mixed-rate invoices. The helper `generatePerRateLines()` in `invoice-entries.ts` groups items by VAT rate and creates separate revenue + VAT account lines per rate group. Available rates depend on customer type — use `getAvailableVatRates(customerType, vatNumberValidated)` from `lib/invoices/vat-rules.ts`.
|
||||
|
||||
### Bank Reconciliation
|
||||
|
||||
@@ -199,14 +241,39 @@ These rules exist for legal compliance and are enforced by database triggers. **
|
||||
|
||||
## Extension Development
|
||||
|
||||
Extensions are first-party plugins in the `/extensions/` directory, loaded statically at startup.
|
||||
Extensions are first-party plugins organized by business sector in the `/extensions/` directory, loaded statically at startup.
|
||||
|
||||
### Sector System
|
||||
|
||||
Extensions are grouped into sectors defined in `lib/extensions/sectors.ts`. Each sector targets a specific industry (restaurant, construction, hotel, tech, ecommerce) or serves all businesses (general). The sector registry provides metadata used by the extension marketplace UI.
|
||||
|
||||
Key types (from `lib/extensions/types.ts`):
|
||||
- `SectorSlug` — `'general' | 'restaurant' | 'construction' | 'hotel' | 'tech' | 'ecommerce'`
|
||||
- `ExtensionDefinition` — Marketplace metadata (slug, name, sector, category, icon, dataPattern, description)
|
||||
- `ExtensionCategory` — `'import' | 'operations' | 'reports' | 'accounting'`
|
||||
- `ExtensionDataPattern` — `'core' | 'manual' | 'both'` (how extension accesses data)
|
||||
- `ExtensionToggle` — Per-user enable/disable state for extensions
|
||||
|
||||
### Creating a New Extension
|
||||
|
||||
1. Create `extensions/<name>/index.ts`
|
||||
1. Create `extensions/<sector>/<name>/index.ts`
|
||||
2. Export an object implementing the `Extension` interface from `lib/extensions/types.ts`
|
||||
3. Add a static import to the `FIRST_PARTY_EXTENSIONS` array in `lib/extensions/loader.ts`
|
||||
4. Extensions **cannot** use dynamic imports (Next.js bundling constraint)
|
||||
4. Add metadata to the appropriate sector in `lib/extensions/sectors.ts`
|
||||
5. Extensions **cannot** use dynamic imports (Next.js bundling constraint)
|
||||
|
||||
### Currently Loaded Extensions (FIRST_PARTY_EXTENSIONS)
|
||||
|
||||
```
|
||||
receiptOcrExtension @/extensions/general/receipt-ocr
|
||||
aiCategorizationExtension @/extensions/general/ai-categorization
|
||||
pushNotificationsExtension @/extensions/general/push-notifications
|
||||
sruExportExtension @/extensions/sru-export
|
||||
neBilagaExtension @/extensions/ne-bilaga
|
||||
aiChatExtension @/extensions/general/ai-chat
|
||||
invoiceInboxExtension @/extensions/general/invoice-inbox
|
||||
# enableBankingExtension @/extensions/general/enable-banking (commented out, opt-in)
|
||||
```
|
||||
|
||||
### Extension Interface
|
||||
|
||||
@@ -235,7 +302,7 @@ interface Extension {
|
||||
|
||||
### Minimal Example
|
||||
|
||||
See `extensions/example-logger/index.ts`:
|
||||
See `extensions/general/example-logger/index.ts`:
|
||||
|
||||
```typescript
|
||||
import type { Extension } from '@/lib/extensions/types'
|
||||
@@ -273,6 +340,7 @@ All defined in `lib/events/types.ts`:
|
||||
| `credit_note.created` | `{ creditNote, userId }` |
|
||||
| `transaction.synced` | `{ transactions[], userId }` |
|
||||
| `transaction.categorized` | `{ transaction, account, taxCode, userId }` |
|
||||
| `transaction.reconciled` | `{ transaction, journalEntryId, method, userId }` |
|
||||
| `bank.statement_received` | `{ statement, userId }` |
|
||||
| `bank.payment_notification` | `{ notification, userId }` |
|
||||
| `period.locked` | `{ period, userId }` |
|
||||
@@ -282,7 +350,9 @@ All defined in `lib/events/types.ts`:
|
||||
| `receipt.extracted` | `{ receipt, documentId, confidence, userId }` |
|
||||
| `receipt.matched` | `{ receipt, transaction, confidence, autoMatched, userId }` |
|
||||
| `receipt.confirmed` | `{ receipt, businessTotal, privateTotal, userId }` |
|
||||
| `transaction.reconciled` | `{ transaction, journalEntryId, method, userId }` |
|
||||
| `supplier_invoice.received` | `{ inboxItem, userId }` |
|
||||
| `supplier_invoice.extracted` | `{ inboxItem, confidence, userId }` |
|
||||
| `supplier_invoice.confirmed` | `{ inboxItem, supplierInvoice, userId }` |
|
||||
| `audit.security_event` | `{ event, userId }` |
|
||||
|
||||
### Event Bus Behavior
|
||||
@@ -325,6 +395,13 @@ mockResult({ data: makeTransaction(), error: null })
|
||||
- `makeJournalEntryLine()` — Line with account number, zero amounts
|
||||
- `makeDocumentAttachment()` — Document with hash, storage path
|
||||
- `makeTaxCode()` — TaxCode with default output VAT 25%
|
||||
- `makeInvoice()` — Invoice with default customer, amounts, dates
|
||||
- `makeCustomer()` — Customer with default name, address
|
||||
- `makeSupplier()` — Supplier with default details
|
||||
- `makeSupplierInvoice()` — Supplier invoice with default amounts
|
||||
- `makeCompanySettings()` — Company settings with defaults
|
||||
- `makeInvoiceInboxItem()` — Invoice inbox item for supplier invoice intake
|
||||
- `makeExtensionToggle()` — Extension toggle state
|
||||
|
||||
### Patterns
|
||||
|
||||
@@ -363,11 +440,11 @@ mockResult({ data: makeTransaction(), error: null })
|
||||
|
||||
### Location
|
||||
|
||||
`supabase/migrations/` — currently 32 files numbered `20240101000001` through `20240101000032`.
|
||||
`supabase/migrations/` — currently 39 files numbered `20240101000001` through `20240101000038` (note: two files share number `000033`).
|
||||
|
||||
### Naming Convention
|
||||
|
||||
`YYYYMMDD00NNNN_descriptive_name.sql` — next migration: `20240101000033_*.sql`
|
||||
`YYYYMMDD00NNNN_descriptive_name.sql` — next migration: `20240101000039_*.sql`
|
||||
|
||||
### Migration Rules
|
||||
|
||||
@@ -406,6 +483,13 @@ mockResult({ data: makeTransaction(), error: null })
|
||||
- **Migration 030 (`bank_reconciliation`)** — Adds `reconciliation_method` column to `transactions` (CHECK constraint for method types), indexes for unmatched transaction lookup, and RPC `get_unlinked_1930_lines()` for finding unreconciled GL lines.
|
||||
- **Migration 031 (`invoice_document_type`)** — Adds `document_type` column to `invoices` (CHECK: invoice/proforma/delivery_note, default 'invoice') and `converted_from_id` FK for tracking proforma-to-invoice conversions.
|
||||
- **Migration 032 (`add_accounting_method`)** — Adds `accounting_method` column to `company_settings` (CHECK: accrual/cash, default 'accrual') to support kontantmetoden vs faktureringsmetoden.
|
||||
- **Migration 033 (`ai_chat_schema`)** — AI chat conversation and message storage.
|
||||
- **Migration 033 (`invoice_inbox`)** — Invoice inbox table for supplier invoice intake. (Note: shares number with ai_chat_schema.)
|
||||
- **Migration 034 (`fix_extension_data_trigger`)** — Fixes extension data trigger.
|
||||
- **Migration 035 (`fix_push_notifications`)** — Push notifications schema fix.
|
||||
- **Migration 036 (`fix_enable_banking`)** — Enable Banking schema fix.
|
||||
- **Migration 037 (`extension_toggles`)** — Extension toggle table for per-user enable/disable.
|
||||
- **Migration 038 (`fix_match_documents_search_path`)** — Fixes search path for document matching function.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -515,7 +515,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
{(() => {
|
||||
const vatByRate = new Map<number, number>()
|
||||
for (const item of invoice.items) {
|
||||
const rate = item.vat_rate ?? 25
|
||||
const rate = item.vat_rate ?? 0
|
||||
const lineVat = Math.round(item.line_total * (rate / 100) * 100) / 100
|
||||
vatByRate.set(rate, (vatByRate.get(rate) || 0) + lineVat)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ import TransactionHistoryList from '@/components/transactions/TransactionHistory
|
||||
import InboxZeroState from '@/components/transactions/InboxZeroState'
|
||||
import InvoiceMatchDialog from '@/components/transactions/InvoiceMatchDialog'
|
||||
import TransactionBookingDialog from '@/components/transactions/TransactionBookingDialog'
|
||||
import QuickReviewDialog from '@/components/transactions/QuickReviewDialog'
|
||||
import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from '@/components/transactions/transaction-types'
|
||||
import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from '@/lib/bookkeeping/category-mapping'
|
||||
import type { TransactionWithInvoice, ViewMode, CategorizeHandler } from '@/components/transactions/transaction-types'
|
||||
import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, VatTreatment } from '@/types'
|
||||
import type { SuggestedCategory } from '@/lib/transactions/category-suggestions'
|
||||
@@ -48,6 +51,15 @@ export default function TransactionsPage() {
|
||||
const [bookingDialogOpen, setBookingDialogOpen] = useState(false)
|
||||
const [bookingDialogTransaction, setBookingDialogTransaction] = useState<TransactionWithInvoice | null>(null)
|
||||
|
||||
// Quick review dialog (suggestion review before booking)
|
||||
const [quickReviewOpen, setQuickReviewOpen] = useState(false)
|
||||
const [quickReviewTransaction, setQuickReviewTransaction] = useState<TransactionWithInvoice | null>(null)
|
||||
const [quickReviewCategory, setQuickReviewCategory] = useState<TransactionCategory | null>(null)
|
||||
const [quickReviewLabel, setQuickReviewLabel] = useState('')
|
||||
|
||||
// Entity type for tooltip context
|
||||
const [entityType, setEntityType] = useState<string>('enskild_firma')
|
||||
|
||||
// Set of transaction IDs that are animating out (just categorized)
|
||||
const [exitingIds, setExitingIds] = useState<Set<string>>(new Set())
|
||||
|
||||
@@ -128,6 +140,22 @@ export default function TransactionsPage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => { fetchTransactions() }, [])
|
||||
|
||||
// Fetch entity type for tooltip context
|
||||
useEffect(() => {
|
||||
async function fetchEntityType() {
|
||||
try {
|
||||
const res = await fetch('/api/settings')
|
||||
const data = await res.json()
|
||||
if (data?.entity_type) {
|
||||
setEntityType(data.entity_type)
|
||||
}
|
||||
} catch {
|
||||
// Non-critical, defaults to enskild_firma
|
||||
}
|
||||
}
|
||||
fetchEntityType()
|
||||
}, [])
|
||||
|
||||
// Auto-fetch suggestions when transactions load
|
||||
useEffect(() => {
|
||||
const uncatIds = transactions
|
||||
@@ -416,6 +444,29 @@ export default function TransactionsPage() {
|
||||
setBookingDialogOpen(true)
|
||||
}
|
||||
|
||||
function handleOpenQuickReview(transaction: TransactionWithInvoice, suggestion: SuggestedCategory) {
|
||||
const allCategories = [...EXPENSE_CATEGORIES, ...INCOME_CATEGORIES]
|
||||
const label = allCategories.find((c) => c.value === suggestion.category)?.label || suggestion.label
|
||||
setQuickReviewTransaction(transaction)
|
||||
setQuickReviewCategory(suggestion.category)
|
||||
setQuickReviewLabel(label)
|
||||
setQuickReviewOpen(true)
|
||||
}
|
||||
|
||||
async function handleQuickReviewConfirm(
|
||||
id: string,
|
||||
category: TransactionCategory,
|
||||
vatTreatment: VatTreatment | undefined,
|
||||
accountOverride: string | undefined
|
||||
) {
|
||||
const success = await handleCategorize(id, true, category, vatTreatment, accountOverride)
|
||||
if (success) {
|
||||
setQuickReviewOpen(false)
|
||||
setQuickReviewTransaction(null)
|
||||
setQuickReviewCategory(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Swipe view
|
||||
if (showSwipeView && uncategorizedTransactions.length > 0) {
|
||||
return (
|
||||
@@ -478,10 +529,12 @@ export default function TransactionsPage() {
|
||||
processingId={processingId}
|
||||
isBatchMode={isBatchMode}
|
||||
isSelected={selectedIds.has(transaction.id)}
|
||||
entityType={entityType}
|
||||
onCategorize={handleCategorize}
|
||||
onMarkPrivate={handleMarkPrivate}
|
||||
onOpenMatchDialog={openMatchDialog}
|
||||
onOpenCategoryDialog={openCategoryDialog}
|
||||
onOpenQuickReview={handleOpenQuickReview}
|
||||
onToggleSelect={toggleBatchSelect}
|
||||
/>
|
||||
))}
|
||||
@@ -537,6 +590,18 @@ export default function TransactionsPage() {
|
||||
onBooked={handleTransactionBooked}
|
||||
/>
|
||||
|
||||
<QuickReviewDialog
|
||||
key={quickReviewTransaction?.id ?? '' + String(quickReviewCategory)}
|
||||
open={quickReviewOpen}
|
||||
onOpenChange={setQuickReviewOpen}
|
||||
transaction={quickReviewTransaction}
|
||||
category={quickReviewCategory}
|
||||
categoryLabel={quickReviewLabel}
|
||||
defaultAccount={quickReviewCategory ? getDefaultAccountForCategory(quickReviewCategory) : ''}
|
||||
defaultVat={quickReviewCategory ? (getDefaultVatTreatmentForCategory(quickReviewCategory) ?? 'none') : 'none'}
|
||||
onConfirm={handleQuickReviewConfirm}
|
||||
/>
|
||||
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { UpdateAccountSchema } from '@/lib/api/schemas'
|
||||
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
@@ -70,19 +72,13 @@ export async function PUT(
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
|
||||
// Build update object with only provided fields
|
||||
const updates: Record<string, unknown> = {}
|
||||
if (body.account_name !== undefined) updates.account_name = body.account_name
|
||||
if (body.is_active !== undefined) updates.is_active = body.is_active
|
||||
if (body.description !== undefined) updates.description = body.description
|
||||
if (body.default_vat_code !== undefined) updates.default_vat_code = body.default_vat_code
|
||||
if (body.sru_code !== undefined) updates.sru_code = body.sru_code
|
||||
const validation = await validateBody(request, UpdateAccountSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.update(updates)
|
||||
.update(body)
|
||||
.eq('user_id', user.id)
|
||||
.eq('account_number', number)
|
||||
.select()
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { CreateAccountSchema } from '@/lib/api/schemas'
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
@@ -47,7 +49,9 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const validation = await validateBody(request, CreateAccountSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { CreateFiscalPeriodInput } from '@/types'
|
||||
import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-duration'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { CreateFiscalPeriodSchema } from '@/lib/api/schemas'
|
||||
|
||||
export async function GET() {
|
||||
const supabase = await createClient()
|
||||
@@ -32,7 +33,9 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json() as CreateFiscalPeriodInput
|
||||
const validation = await validateBody(request, CreateFiscalPeriodSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
|
||||
// Validate period duration (max 18 months per BFL 3 kap.)
|
||||
const durationError = validatePeriodDuration(body.period_start, body.period_end)
|
||||
|
||||
@@ -57,7 +57,7 @@ describe('POST /api/bookkeeping/journal-entries/[id]/correct', () => {
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toBe('Lines are required')
|
||||
expect(body.error).toBe('Validation failed')
|
||||
})
|
||||
|
||||
it('returns 400 when lines array is empty', async () => {
|
||||
@@ -69,7 +69,7 @@ describe('POST /api/bookkeeping/journal-entries/[id]/correct', () => {
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toBe('Lines are required')
|
||||
expect(body.error).toBe('Validation failed')
|
||||
})
|
||||
|
||||
it('returns reversal and corrected entries on success', async () => {
|
||||
|
||||
@@ -2,7 +2,8 @@ import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { correctEntry } from '@/lib/core/bookkeeping/storno-service'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import type { CreateJournalEntryLineInput } from '@/types'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { CorrectJournalEntrySchema } from '@/lib/api/schemas'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
@@ -18,16 +19,9 @@ export async function POST(
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
let body: { lines: CreateJournalEntryLineInput[] }
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!body.lines || !Array.isArray(body.lines) || body.lines.length === 0) {
|
||||
return NextResponse.json({ error: 'Lines are required' }, { status: 400 })
|
||||
}
|
||||
const validation = await validateBody(request, CorrectJournalEntrySchema)
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
|
||||
try {
|
||||
const result = await correctEntry(user.id, id, body.lines)
|
||||
|
||||
@@ -88,6 +88,8 @@ describe('GET /api/bookkeeping/journal-entries', () => {
|
||||
})
|
||||
})
|
||||
|
||||
const VALID_UUID = '550e8400-e29b-41d4-a716-446655440000'
|
||||
|
||||
describe('POST /api/bookkeeping/journal-entries', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
@@ -116,7 +118,7 @@ describe('POST /api/bookkeeping/journal-entries', () => {
|
||||
mockCreateJournalEntry.mockResolvedValue(entry)
|
||||
|
||||
const input = {
|
||||
fiscal_period_id: 'period-1',
|
||||
fiscal_period_id: VALID_UUID,
|
||||
entry_date: '2024-06-15',
|
||||
description: 'Test entry',
|
||||
source_type: 'manual',
|
||||
@@ -144,11 +146,14 @@ describe('POST /api/bookkeeping/journal-entries', () => {
|
||||
const request = createMockRequest('/api/bookkeeping/journal-entries', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
fiscal_period_id: 'period-1',
|
||||
fiscal_period_id: VALID_UUID,
|
||||
entry_date: '2024-06-15',
|
||||
description: 'Bad entry',
|
||||
source_type: 'manual',
|
||||
lines: [{ account_number: '1930', debit_amount: 1000, credit_amount: 0 }],
|
||||
lines: [
|
||||
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 500 },
|
||||
],
|
||||
},
|
||||
})
|
||||
const response = await POST(request)
|
||||
|
||||
@@ -2,7 +2,8 @@ import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createJournalEntry } from '@/lib/bookkeeping/engine'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import type { CreateJournalEntryInput } from '@/types'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { CreateJournalEntrySchema } from '@/lib/api/schemas'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
@@ -63,7 +64,9 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json() as CreateJournalEntryInput
|
||||
const validation = await validateBody(request, CreateJournalEntrySchema)
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
|
||||
try {
|
||||
const entry = await createJournalEntry(user.id, body)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { evaluateMappingRules } from '@/lib/bookkeeping/mapping-engine'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { EvaluateMappingRulesSchema } from '@/lib/api/schemas'
|
||||
import type { Transaction } from '@/types'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
@@ -11,12 +13,14 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const validation = await validateBody(request, EvaluateMappingRulesSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
|
||||
// Accept either a transaction ID or raw transaction data
|
||||
let transaction: Transaction
|
||||
|
||||
if (body.transaction_id) {
|
||||
if ('transaction_id' in body) {
|
||||
const { data, error } = await supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
@@ -30,7 +34,7 @@ export async function POST(request: Request) {
|
||||
|
||||
transaction = data as Transaction
|
||||
} else {
|
||||
transaction = body as Transaction
|
||||
transaction = body as unknown as Transaction
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { CreateMappingRuleSchema } from '@/lib/api/schemas'
|
||||
|
||||
export async function GET() {
|
||||
const supabase = await createClient()
|
||||
@@ -31,7 +33,9 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const result = await validateBody(request, CreateMappingRuleSchema)
|
||||
if (!result.success) return result.response
|
||||
const body = result.data
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('mapping_rules')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { CreateCustomerInput } from '@/types'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { UpdateCustomerSchema } from '@/lib/api/schemas'
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
@@ -62,7 +63,9 @@ export async function PATCH(
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: Partial<CreateCustomerInput> = await request.json()
|
||||
const result = await validateBody(request, UpdateCustomerSchema)
|
||||
if (!result.success) return result.response
|
||||
const body = result.data
|
||||
|
||||
const updateData: Record<string, unknown> = {}
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@ import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import type { CreateCustomerInput, Customer } from '@/types'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { CreateCustomerSchema } from '@/lib/api/schemas'
|
||||
import type { Customer } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
@@ -37,7 +39,9 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: CreateCustomerInput = await request.json()
|
||||
const result = await validateBody(request, CreateCustomerSchema)
|
||||
if (!result.success) return result.response
|
||||
const body = result.data
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('customers')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { CreateDeadlineInput } from '@/types'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { CreateDeadlineSchema } from '@/lib/api/schemas'
|
||||
|
||||
/**
|
||||
* GET /api/deadlines
|
||||
@@ -78,12 +79,9 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: CreateDeadlineInput = await request.json()
|
||||
|
||||
// Validate required fields
|
||||
if (!body.title || !body.due_date || !body.deadline_type) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 })
|
||||
}
|
||||
const validation = await validateBody(request, CreateDeadlineSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
|
||||
// Insert the deadline
|
||||
const { data, error } = await supabase
|
||||
|
||||
@@ -111,6 +111,9 @@ describe('GET /api/invoices', () => {
|
||||
})
|
||||
})
|
||||
|
||||
const VALID_UUID = '550e8400-e29b-41d4-a716-446655440000'
|
||||
const VALID_UUID_2 = '550e8400-e29b-41d4-a716-446655440001'
|
||||
|
||||
describe('POST /api/invoices (create invoice)', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
@@ -126,7 +129,7 @@ describe('POST /api/invoices (create invoice)', () => {
|
||||
|
||||
const request = createMockRequest('/api/invoices', {
|
||||
method: 'POST',
|
||||
body: { customer_id: 'cust-1', items: [] },
|
||||
body: { customer_id: VALID_UUID, items: [] },
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse(response)
|
||||
@@ -141,7 +144,7 @@ describe('POST /api/invoices (create invoice)', () => {
|
||||
const request = createMockRequest('/api/invoices', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
customer_id: 'cust-999',
|
||||
customer_id: VALID_UUID_2,
|
||||
invoice_date: '2024-06-15',
|
||||
due_date: '2024-07-15',
|
||||
currency: 'SEK',
|
||||
@@ -156,7 +159,7 @@ describe('POST /api/invoices (create invoice)', () => {
|
||||
})
|
||||
|
||||
it('creates invoice with items and emits event', async () => {
|
||||
const customer = makeCustomer({ id: 'cust-1' })
|
||||
const customer = makeCustomer({ id: VALID_UUID })
|
||||
const createdInvoice = makeInvoice({ id: 'inv-1' })
|
||||
|
||||
mockGetVatRules.mockReturnValue({
|
||||
@@ -189,7 +192,7 @@ describe('POST /api/invoices (create invoice)', () => {
|
||||
const request = createMockRequest('/api/invoices', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
customer_id: 'cust-1',
|
||||
customer_id: VALID_UUID,
|
||||
invoice_date: '2024-06-15',
|
||||
due_date: '2024-07-15',
|
||||
currency: 'SEK',
|
||||
@@ -207,7 +210,7 @@ describe('POST /api/invoices (create invoice)', () => {
|
||||
})
|
||||
|
||||
it('rolls back invoice when items insertion fails', async () => {
|
||||
const customer = makeCustomer({ id: 'cust-1' })
|
||||
const customer = makeCustomer({ id: VALID_UUID })
|
||||
const createdInvoice = makeInvoice({ id: 'inv-1' })
|
||||
|
||||
mockGetVatRules.mockReturnValue({
|
||||
@@ -235,7 +238,7 @@ describe('POST /api/invoices (create invoice)', () => {
|
||||
const request = createMockRequest('/api/invoices', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
customer_id: 'cust-1',
|
||||
customer_id: VALID_UUID,
|
||||
invoice_date: '2024-06-15',
|
||||
due_date: '2024-07-15',
|
||||
currency: 'SEK',
|
||||
@@ -265,7 +268,7 @@ describe('POST /api/invoices (create credit note)', () => {
|
||||
|
||||
const request = createMockRequest('/api/invoices', {
|
||||
method: 'POST',
|
||||
body: { credited_invoice_id: 'inv-999' },
|
||||
body: { credited_invoice_id: VALID_UUID_2 },
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
@@ -275,12 +278,12 @@ describe('POST /api/invoices (create credit note)', () => {
|
||||
})
|
||||
|
||||
it('returns 400 when invoice is already credited', async () => {
|
||||
const original = makeInvoice({ id: 'inv-1', status: 'credited' })
|
||||
const original = makeInvoice({ id: VALID_UUID, status: 'credited' })
|
||||
enqueue({ data: original, error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices', {
|
||||
method: 'POST',
|
||||
body: { credited_invoice_id: 'inv-1' },
|
||||
body: { credited_invoice_id: VALID_UUID },
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
@@ -290,12 +293,12 @@ describe('POST /api/invoices (create credit note)', () => {
|
||||
})
|
||||
|
||||
it('returns 400 when invoice is in draft status', async () => {
|
||||
const original = makeInvoice({ id: 'inv-1', status: 'draft' })
|
||||
const original = makeInvoice({ id: VALID_UUID, status: 'draft' })
|
||||
enqueue({ data: original, error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices', {
|
||||
method: 'POST',
|
||||
body: { credited_invoice_id: 'inv-1' },
|
||||
body: { credited_invoice_id: VALID_UUID },
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
@@ -319,7 +322,7 @@ describe('POST /api/invoices (create credit note)', () => {
|
||||
},
|
||||
]
|
||||
const original = makeInvoice({
|
||||
id: 'inv-1',
|
||||
id: VALID_UUID,
|
||||
status: 'sent',
|
||||
subtotal: 10000,
|
||||
vat_amount: 2500,
|
||||
@@ -328,7 +331,7 @@ describe('POST /api/invoices (create credit note)', () => {
|
||||
})
|
||||
const creditNote = makeInvoice({
|
||||
id: 'cn-1',
|
||||
credited_invoice_id: 'inv-1',
|
||||
credited_invoice_id: VALID_UUID,
|
||||
subtotal: -10000,
|
||||
vat_amount: -2500,
|
||||
total: -12500,
|
||||
@@ -356,7 +359,7 @@ describe('POST /api/invoices (create credit note)', () => {
|
||||
|
||||
const request = createMockRequest('/api/invoices', {
|
||||
method: 'POST',
|
||||
body: { credited_invoice_id: 'inv-1' },
|
||||
body: { credited_invoice_id: VALID_UUID },
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ data: unknown }>(response)
|
||||
@@ -370,7 +373,7 @@ describe('POST /api/invoices (create credit note)', () => {
|
||||
|
||||
it('rolls back credit note when items insertion fails', async () => {
|
||||
const original = makeInvoice({
|
||||
id: 'inv-1',
|
||||
id: VALID_UUID,
|
||||
status: 'sent',
|
||||
items: [
|
||||
{
|
||||
@@ -397,7 +400,7 @@ describe('POST /api/invoices (create credit note)', () => {
|
||||
|
||||
const request = createMockRequest('/api/invoices', {
|
||||
method: 'POST',
|
||||
body: { credited_invoice_id: 'inv-1' },
|
||||
body: { credited_invoice_id: VALID_UUID },
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
+41
-15
@@ -2,7 +2,8 @@ import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import type { CreateInvoiceInput, EntityType, AccountingMethod, Invoice, CreditNote, InvoiceDocumentType } from '@/types'
|
||||
import { CreateInvoiceSchema, CreateCreditNoteSchema } from '@/lib/api/schemas'
|
||||
import type { EntityType, AccountingMethod, Invoice, CreditNote, InvoiceDocumentType } from '@/types'
|
||||
import { getVatRules, calculateVat, calculateTotal, getAvailableVatRates, getVatTreatmentForRate } from '@/lib/invoices/vat-rules'
|
||||
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
|
||||
import {
|
||||
@@ -11,11 +12,6 @@ import {
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
interface CreateCreditNoteInput {
|
||||
credited_invoice_id: string
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
@@ -59,15 +55,45 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
|
||||
// Check if this is a credit note creation request
|
||||
if (body.credited_invoice_id) {
|
||||
return createCreditNote(supabase, user.id, body as CreateCreditNoteInput)
|
||||
let rawBody: unknown
|
||||
try {
|
||||
rawBody = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid JSON in request body', type: 'validation_error' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const invoiceInput = body as CreateInvoiceInput
|
||||
const documentType: InvoiceDocumentType = body.document_type || 'invoice'
|
||||
// Check if this is a credit note creation request
|
||||
if (typeof rawBody === 'object' && rawBody !== null && 'credited_invoice_id' in rawBody) {
|
||||
const parsed = CreateCreditNoteSchema.safeParse(rawBody)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Validation failed',
|
||||
type: 'validation_error',
|
||||
errors: parsed.error.issues.map((i) => ({ field: i.path.join('.'), message: i.message, code: i.code })),
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
return createCreditNote(supabase, user.id, parsed.data)
|
||||
}
|
||||
|
||||
const parsed = CreateInvoiceSchema.safeParse(rawBody)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Validation failed',
|
||||
type: 'validation_error',
|
||||
errors: parsed.error.issues.map((i) => ({ field: i.path.join('.'), message: i.message, code: i.code })),
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
const invoiceInput = parsed.data
|
||||
const documentType: InvoiceDocumentType = invoiceInput.document_type || 'invoice'
|
||||
|
||||
// Get customer for VAT calculation
|
||||
const { data: customer, error: customerError } = await supabase
|
||||
@@ -225,7 +251,7 @@ export async function POST(request: Request) {
|
||||
async function createCreditNote(
|
||||
supabase: Awaited<ReturnType<typeof createClient>>,
|
||||
userId: string,
|
||||
input: CreateCreditNoteInput
|
||||
input: { credited_invoice_id: string; reason?: string }
|
||||
) {
|
||||
// Fetch the original invoice with items
|
||||
const { data: originalInvoice, error: originalError } = await supabase
|
||||
@@ -310,7 +336,7 @@ async function createCreditNote(
|
||||
unit: item.unit,
|
||||
unit_price: item.unit_price,
|
||||
line_total: -Math.abs(item.line_total),
|
||||
vat_rate: item.vat_rate ?? 25,
|
||||
vat_rate: item.vat_rate ?? 0,
|
||||
vat_amount: -(item.vat_amount ? Math.abs(item.vat_amount) : 0),
|
||||
}))
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { manualLink } from '@/lib/reconciliation/bank-reconciliation'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { BankLinkSchema } from '@/lib/api/schemas'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
@@ -13,15 +15,9 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { transaction_id, journal_entry_id } = body
|
||||
|
||||
if (!transaction_id || !journal_entry_id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'transaction_id and journal_entry_id are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const validation = await validateBody(request, BankLinkSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const { transaction_id, journal_entry_id } = validation.data
|
||||
|
||||
const result = await manualLink(supabase, user.id, transaction_id, journal_entry_id)
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { runReconciliation } from '@/lib/reconciliation/bank-reconciliation'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { RunReconciliationSchema } from '@/lib/api/schemas'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
@@ -13,8 +15,9 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { date_from, date_to, dry_run } = body
|
||||
const validation = await validateBody(request, RunReconciliationSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const { date_from, date_to, dry_run } = validation.data
|
||||
|
||||
const result = await runReconciliation(supabase, user.id, {
|
||||
dateFrom: date_from,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { unlinkReconciliation } from '@/lib/reconciliation/bank-reconciliation'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { BankUnlinkSchema } from '@/lib/api/schemas'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
@@ -10,15 +12,9 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { transaction_id } = body
|
||||
|
||||
if (!transaction_id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'transaction_id is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const validation = await validateBody(request, BankUnlinkSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const { transaction_id } = validation.data
|
||||
|
||||
const result = await unlinkReconciliation(supabase, user.id, transaction_id)
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { didTaxFieldsChange, regenerateTaxDeadlinesForUser } from '@/lib/tax/deadline-generator'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { UpdateSettingsSchema } from '@/lib/api/schemas'
|
||||
|
||||
export async function GET() {
|
||||
const supabase = await createClient()
|
||||
@@ -40,7 +42,9 @@ export async function PUT(request: Request) {
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
const body = await request.json()
|
||||
const validation = await validateBody(request, UpdateSettingsSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
|
||||
// Validate: enskild firma must use calendar year (BFL 3 kap.)
|
||||
const effectiveEntityType = body.entity_type || oldSettings?.entity_type
|
||||
|
||||
@@ -4,15 +4,10 @@ import {
|
||||
createSupplierInvoicePaymentEntry,
|
||||
createSupplierInvoiceCashEntry,
|
||||
} from '@/lib/bookkeeping/supplier-invoice-entries'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { MarkSupplierInvoicePaidSchema } from '@/lib/api/schemas'
|
||||
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
|
||||
|
||||
interface MarkPaidRequest {
|
||||
amount?: number
|
||||
payment_date?: string
|
||||
exchange_rate_difference?: number
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
@@ -26,7 +21,9 @@ export async function POST(
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: MarkPaidRequest = await request.json()
|
||||
const validation = await validateBody(request, MarkSupplierInvoicePaidSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
|
||||
// Fetch invoice with supplier and items
|
||||
const { data: invoice, error: fetchError } = await supabase
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { UpdateSupplierInvoiceSchema } from '@/lib/api/schemas'
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
@@ -60,18 +62,13 @@ export async function PUT(
|
||||
)
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const validation = await validateBody(request, UpdateSupplierInvoiceSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.update({
|
||||
supplier_invoice_number: body.supplier_invoice_number,
|
||||
invoice_date: body.invoice_date,
|
||||
due_date: body.due_date,
|
||||
delivery_date: body.delivery_date,
|
||||
payment_reference: body.payment_reference,
|
||||
notes: body.notes,
|
||||
})
|
||||
.update(body)
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.select()
|
||||
|
||||
@@ -98,6 +98,9 @@ describe('GET /api/supplier-invoices', () => {
|
||||
})
|
||||
})
|
||||
|
||||
const VALID_UUID = '550e8400-e29b-41d4-a716-446655440000'
|
||||
const VALID_UUID_2 = '550e8400-e29b-41d4-a716-446655440001'
|
||||
|
||||
describe('POST /api/supplier-invoices', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
@@ -112,7 +115,7 @@ describe('POST /api/supplier-invoices', () => {
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices', {
|
||||
method: 'POST',
|
||||
body: { supplier_id: 'sup-1', items: [] },
|
||||
body: { supplier_id: VALID_UUID, items: [] },
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse(response)
|
||||
@@ -127,7 +130,7 @@ describe('POST /api/supplier-invoices', () => {
|
||||
const request = createMockRequest('/api/supplier-invoices', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
supplier_id: 'sup-999',
|
||||
supplier_id: VALID_UUID_2,
|
||||
supplier_invoice_number: 'LF-001',
|
||||
invoice_date: '2024-06-01',
|
||||
due_date: '2024-07-01',
|
||||
@@ -142,7 +145,7 @@ describe('POST /api/supplier-invoices', () => {
|
||||
})
|
||||
|
||||
it('creates supplier invoice with items and arrival number', async () => {
|
||||
const supplier = makeSupplier({ id: 'sup-1' })
|
||||
const supplier = makeSupplier({ id: VALID_UUID })
|
||||
const createdInvoice = makeSupplierInvoice({ id: 'si-1' })
|
||||
|
||||
// Fetch supplier
|
||||
@@ -163,7 +166,7 @@ describe('POST /api/supplier-invoices', () => {
|
||||
const request = createMockRequest('/api/supplier-invoices', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
supplier_id: 'sup-1',
|
||||
supplier_id: VALID_UUID,
|
||||
supplier_invoice_number: 'LF-001',
|
||||
invoice_date: '2024-06-01',
|
||||
due_date: '2024-07-01',
|
||||
@@ -190,7 +193,7 @@ describe('POST /api/supplier-invoices', () => {
|
||||
})
|
||||
|
||||
it('skips registration entry for cash method', async () => {
|
||||
const supplier = makeSupplier({ id: 'sup-1' })
|
||||
const supplier = makeSupplier({ id: VALID_UUID })
|
||||
const createdInvoice = makeSupplierInvoice({ id: 'si-1' })
|
||||
|
||||
enqueue({ data: supplier, error: null })
|
||||
@@ -202,7 +205,7 @@ describe('POST /api/supplier-invoices', () => {
|
||||
const request = createMockRequest('/api/supplier-invoices', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
supplier_id: 'sup-1',
|
||||
supplier_id: VALID_UUID,
|
||||
supplier_invoice_number: 'LF-002',
|
||||
invoice_date: '2024-06-01',
|
||||
due_date: '2024-07-01',
|
||||
@@ -220,7 +223,7 @@ describe('POST /api/supplier-invoices', () => {
|
||||
})
|
||||
|
||||
it('rolls back on items insertion failure', async () => {
|
||||
const supplier = makeSupplier({ id: 'sup-1' })
|
||||
const supplier = makeSupplier({ id: VALID_UUID })
|
||||
const createdInvoice = makeSupplierInvoice({ id: 'si-1' })
|
||||
|
||||
enqueue({ data: supplier, error: null })
|
||||
@@ -234,7 +237,7 @@ describe('POST /api/supplier-invoices', () => {
|
||||
const request = createMockRequest('/api/supplier-invoices', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
supplier_id: 'sup-1',
|
||||
supplier_id: VALID_UUID,
|
||||
supplier_invoice_number: 'LF-003',
|
||||
invoice_date: '2024-06-01',
|
||||
due_date: '2024-07-01',
|
||||
|
||||
@@ -2,7 +2,9 @@ import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import type { CreateSupplierInvoiceInput, SupplierInvoice, SupplierInvoiceItem } from '@/types'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { CreateSupplierInvoiceSchema } from '@/lib/api/schemas'
|
||||
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
@@ -49,7 +51,9 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: CreateSupplierInvoiceInput = await request.json()
|
||||
const validation = await validateBody(request, CreateSupplierInvoiceSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
|
||||
// Validate supplier exists and belongs to user
|
||||
const { data: supplier, error: supplierError } = await supabase
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { CreateSupplierInput } from '@/types'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { UpdateSupplierSchema } from '@/lib/api/schemas'
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
@@ -66,7 +67,9 @@ export async function PUT(
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: Partial<CreateSupplierInput> = await request.json()
|
||||
const result = await validateBody(request, UpdateSupplierSchema)
|
||||
if (!result.success) return result.response
|
||||
const body = result.data
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('suppliers')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { CreateSupplierInput, Supplier } from '@/types'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { CreateSupplierSchema } from '@/lib/api/schemas'
|
||||
|
||||
export async function GET() {
|
||||
const supabase = await createClient()
|
||||
@@ -33,7 +34,9 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: CreateSupplierInput = await request.json()
|
||||
const result = await validateBody(request, CreateSupplierSchema)
|
||||
if (!result.success) return result.response
|
||||
const body = result.data
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('suppliers')
|
||||
|
||||
@@ -25,10 +25,12 @@ vi.mock('@/lib/bookkeeping/engine', () => ({
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
const VALID_UUID = '550e8400-e29b-41d4-a716-446655440000'
|
||||
|
||||
describe('POST /api/transactions/[id]/book', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
const validBody = {
|
||||
fiscal_period_id: 'period-1',
|
||||
fiscal_period_id: VALID_UUID,
|
||||
entry_date: '2025-01-15',
|
||||
description: 'Test booking',
|
||||
lines: [
|
||||
@@ -61,13 +63,13 @@ describe('POST /api/transactions/[id]/book', () => {
|
||||
it('returns 400 when missing required fields', async () => {
|
||||
const request = createMockRequest('/api/transactions/tx-1/book', {
|
||||
method: 'POST',
|
||||
body: { fiscal_period_id: 'period-1' },
|
||||
body: { fiscal_period_id: VALID_UUID },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toContain('Missing required fields')
|
||||
expect(body.error).toBe('Validation failed')
|
||||
})
|
||||
|
||||
it('returns 404 when transaction not found', async () => {
|
||||
@@ -154,7 +156,7 @@ describe('POST /api/transactions/[id]/book', () => {
|
||||
expect(body.data.id).toBe('je-new')
|
||||
|
||||
expect(mockCreateJournalEntry).toHaveBeenCalledWith('user-1', {
|
||||
fiscal_period_id: 'period-1',
|
||||
fiscal_period_id: VALID_UUID,
|
||||
entry_date: '2025-01-15',
|
||||
description: 'Test booking',
|
||||
source_type: 'bank_transaction',
|
||||
|
||||
@@ -3,17 +3,12 @@ import { NextResponse } from 'next/server'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { createJournalEntry } from '@/lib/bookkeeping/engine'
|
||||
import type { CreateJournalEntryLineInput, Transaction } from '@/types'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { BookTransactionSchema } from '@/lib/api/schemas'
|
||||
import type { Transaction } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
interface BookRequest {
|
||||
fiscal_period_id: string
|
||||
entry_date: string
|
||||
description: string
|
||||
lines: CreateJournalEntryLineInput[]
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
@@ -27,15 +22,9 @@ export async function POST(
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: BookRequest = await request.json()
|
||||
const { fiscal_period_id, entry_date, description, lines } = body
|
||||
|
||||
if (!fiscal_period_id || !entry_date || !description || !lines?.length) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields: fiscal_period_id, entry_date, description, lines' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const validation = await validateBody(request, BookTransactionSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const { fiscal_period_id, entry_date, description, lines } = validation.data
|
||||
|
||||
// Fetch transaction (validates ownership)
|
||||
const { data: transaction, error: fetchError } = await supabase
|
||||
|
||||
@@ -5,17 +5,12 @@ import { ensureInitialized } from '@/lib/init'
|
||||
import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping'
|
||||
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
|
||||
import { saveUserMappingRule } from '@/lib/bookkeeping/mapping-engine'
|
||||
import type { Transaction, TransactionCategory, EntityType, VatTreatment } from '@/types'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { CategorizeTransactionSchema } from '@/lib/api/schemas'
|
||||
import type { Transaction, TransactionCategory, EntityType } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
interface CategorizeRequest {
|
||||
is_business: boolean
|
||||
category?: TransactionCategory
|
||||
vat_treatment?: VatTreatment
|
||||
account_override?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a fiscal period exists for the given date, create one if needed
|
||||
*/
|
||||
@@ -98,8 +93,10 @@ export async function POST(
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
const body: CategorizeRequest = await request.json()
|
||||
// Parse and validate request body
|
||||
const validation = await validateBody(request, CategorizeTransactionSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
const { is_business, category } = body
|
||||
|
||||
// Fetch the transaction (validates ownership)
|
||||
|
||||
@@ -25,6 +25,9 @@ vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
const VALID_UUID = '550e8400-e29b-41d4-a716-446655440000'
|
||||
const VALID_UUID_2 = '550e8400-e29b-41d4-a716-446655440001'
|
||||
|
||||
describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
@@ -39,7 +42,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
|
||||
method: 'POST',
|
||||
body: { invoice_id: 'inv-1' },
|
||||
body: { invoice_id: VALID_UUID },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse(response)
|
||||
@@ -57,7 +60,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toBe('invoice_id is required')
|
||||
expect(body.error).toBe('Validation failed')
|
||||
})
|
||||
|
||||
it('returns 404 when transaction not found', async () => {
|
||||
@@ -65,7 +68,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-999/match-invoice', {
|
||||
method: 'POST',
|
||||
body: { invoice_id: 'inv-1' },
|
||||
body: { invoice_id: VALID_UUID },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-999' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
@@ -80,7 +83,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
|
||||
method: 'POST',
|
||||
body: { invoice_id: 'inv-1' },
|
||||
body: { invoice_id: VALID_UUID },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
@@ -95,7 +98,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
|
||||
method: 'POST',
|
||||
body: { invoice_id: 'inv-1' },
|
||||
body: { invoice_id: VALID_UUID },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
@@ -111,7 +114,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
|
||||
method: 'POST',
|
||||
body: { invoice_id: 'inv-999' },
|
||||
body: { invoice_id: VALID_UUID_2 },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
@@ -122,13 +125,13 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
|
||||
it('returns 400 when invoice is not in unpaid state', async () => {
|
||||
const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null })
|
||||
const invoice = makeInvoice({ id: 'inv-1', status: 'paid' })
|
||||
const invoice = makeInvoice({ id: VALID_UUID, status: 'paid' })
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: invoice, error: null })
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
|
||||
method: 'POST',
|
||||
body: { invoice_id: 'inv-1' },
|
||||
body: { invoice_id: VALID_UUID },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
@@ -141,7 +144,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null, date: '2024-06-15' })
|
||||
const customer = makeCustomer()
|
||||
const invoice = makeInvoice({
|
||||
id: 'inv-1',
|
||||
id: VALID_UUID,
|
||||
status: 'sent',
|
||||
total: 12500,
|
||||
subtotal: 10000,
|
||||
@@ -166,7 +169,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
|
||||
method: 'POST',
|
||||
body: { invoice_id: 'inv-1' },
|
||||
body: { invoice_id: VALID_UUID },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
@@ -185,14 +188,14 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
// Verify accrual payment entry was called
|
||||
expect(mockCreateInvoicePaymentJournalEntry).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
expect.objectContaining({ id: 'inv-1' }),
|
||||
expect.objectContaining({ id: VALID_UUID }),
|
||||
'2024-06-15'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns success with journal_entry_error when journal entry fails (non-blocking)', async () => {
|
||||
const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null, date: '2024-06-15' })
|
||||
const invoice = makeInvoice({ id: 'inv-1', status: 'sent', total: 12500 })
|
||||
const invoice = makeInvoice({ id: VALID_UUID, status: 'sent', total: 12500 })
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: invoice, error: null })
|
||||
@@ -207,7 +210,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
|
||||
method: 'POST',
|
||||
body: { invoice_id: 'inv-1' },
|
||||
body: { invoice_id: VALID_UUID },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
|
||||
@@ -4,12 +4,10 @@ import {
|
||||
createInvoicePaymentJournalEntry,
|
||||
createInvoiceCashEntry,
|
||||
} from '@/lib/bookkeeping/invoice-entries'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { MatchInvoiceSchema } from '@/lib/api/schemas'
|
||||
import type { EntityType, Invoice } from '@/types'
|
||||
|
||||
interface MatchInvoiceRequest {
|
||||
invoice_id: string
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/transactions/[id]/match-invoice
|
||||
*
|
||||
@@ -34,13 +32,10 @@ export async function POST(
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
const body: MatchInvoiceRequest = await request.json()
|
||||
const { invoice_id } = body
|
||||
|
||||
if (!invoice_id) {
|
||||
return NextResponse.json({ error: 'invoice_id is required' }, { status: 400 })
|
||||
}
|
||||
// Parse and validate request body
|
||||
const validation = await validateBody(request, MatchInvoiceSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const { invoice_id } = validation.data
|
||||
|
||||
// Fetch the transaction (validates ownership)
|
||||
const { data: transaction, error: fetchTxError } = await supabase
|
||||
|
||||
@@ -5,12 +5,10 @@ import {
|
||||
createSupplierInvoicePaymentEntry,
|
||||
createSupplierInvoiceCashEntry,
|
||||
} from '@/lib/bookkeeping/supplier-invoice-entries'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { MatchSupplierInvoiceSchema } from '@/lib/api/schemas'
|
||||
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
|
||||
|
||||
interface MatchRequest {
|
||||
supplier_invoice_id: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a fiscal period exists for the given date
|
||||
*/
|
||||
@@ -64,12 +62,9 @@ export async function POST(
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: MatchRequest = await request.json()
|
||||
const { supplier_invoice_id } = body
|
||||
|
||||
if (!supplier_invoice_id) {
|
||||
return NextResponse.json({ error: 'supplier_invoice_id is required' }, { status: 400 })
|
||||
}
|
||||
const validation = await validateBody(request, MatchSupplierInvoiceSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const { supplier_invoice_id } = validation.data
|
||||
|
||||
// Fetch the transaction
|
||||
const { data: transaction, error: fetchTxError } = await supabase
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
|
||||
import type { InvoiceInboxItem, Supplier, InboxItemStatus } from '@/types'
|
||||
import type { InvoiceInboxSettings } from '@/extensions/general/invoice-inbox/types'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Settings, Inbox, CheckCircle2, AlertTriangle } from 'lucide-react'
|
||||
import InboxItemCard from '@/components/extensions/general/invoice-inbox/InboxItemCard'
|
||||
import InboxUploadZone from '@/components/extensions/general/invoice-inbox/InboxUploadZone'
|
||||
import InboxDetailDialog from '@/components/extensions/general/invoice-inbox/InboxDetailDialog'
|
||||
import InboxSettingsDialog from '@/components/extensions/general/invoice-inbox/InboxSettingsDialog'
|
||||
|
||||
type TabValue = 'all' | InboxItemStatus
|
||||
|
||||
const TABS: { value: TabValue; label: string }[] = [
|
||||
{ value: 'all', label: 'Alla' },
|
||||
{ value: 'pending', label: 'Ny' },
|
||||
{ value: 'ready', label: 'Klar' },
|
||||
{ value: 'confirmed', label: 'Bekräftad' },
|
||||
{ value: 'rejected', label: 'Avvisad' },
|
||||
{ value: 'error', label: 'Fel' },
|
||||
]
|
||||
|
||||
const DEFAULT_SETTINGS: InvoiceInboxSettings = {
|
||||
autoProcessEnabled: true,
|
||||
autoMatchSupplierEnabled: true,
|
||||
supplierMatchThreshold: 0.7,
|
||||
inboxEmail: null,
|
||||
}
|
||||
|
||||
export default function InvoiceInboxWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
const [items, setItems] = useState<InvoiceInboxItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState<TabValue>('all')
|
||||
const [selectedItem, setSelectedItem] = useState<InvoiceInboxItem | null>(null)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [settings, setSettings] = useState<InvoiceInboxSettings>(DEFAULT_SETTINGS)
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [suppliers, setSuppliers] = useState<Supplier[]>([])
|
||||
|
||||
const fetchItems = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/extensions/invoice-inbox/inbox')
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
setItems(data ?? [])
|
||||
}
|
||||
} catch {
|
||||
// Silently fail — user sees empty state
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchSettings = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/extensions/invoice-inbox/settings')
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
if (data) setSettings(data)
|
||||
}
|
||||
} catch {
|
||||
// Use defaults
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchSuppliers = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/suppliers')
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
setSuppliers(data ?? [])
|
||||
}
|
||||
} catch {
|
||||
// ok
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchItems()
|
||||
fetchSettings()
|
||||
fetchSuppliers()
|
||||
}, [fetchItems, fetchSettings, fetchSuppliers])
|
||||
|
||||
function handleUploadComplete(newItem: InvoiceInboxItem) {
|
||||
setItems((prev) => [newItem, ...prev])
|
||||
// Poll for processing completion
|
||||
pollItem(newItem.id)
|
||||
}
|
||||
|
||||
async function pollItem(itemId: string) {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await new Promise((r) => setTimeout(r, 3000))
|
||||
try {
|
||||
const res = await fetch(`/api/extensions/invoice-inbox/inbox/${itemId}`)
|
||||
if (!res.ok) continue
|
||||
const { data } = await res.json()
|
||||
if (data && data.status !== 'processing') {
|
||||
setItems((prev) =>
|
||||
prev.map((it) => (it.id === itemId ? data : it))
|
||||
)
|
||||
// Also update the detail dialog if it's open for this item
|
||||
setSelectedItem((current) =>
|
||||
current?.id === itemId ? data : current
|
||||
)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// continue polling
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConfirm(itemId: string, supplierId?: string) {
|
||||
const body: Record<string, string> = {}
|
||||
if (supplierId) body.supplier_id = supplierId
|
||||
|
||||
const res = await fetch(`/api/extensions/invoice-inbox/inbox/${itemId}/confirm`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setItems((prev) =>
|
||||
prev.map((it) => (it.id === itemId ? { ...it, status: 'confirmed' as const } : it))
|
||||
)
|
||||
setSelectedItem(null)
|
||||
fetchSuppliers() // New supplier may have been created
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReject(itemId: string) {
|
||||
const res = await fetch(`/api/extensions/invoice-inbox/inbox/${itemId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setItems((prev) =>
|
||||
prev.map((it) => (it.id === itemId ? { ...it, status: 'rejected' as const } : it))
|
||||
)
|
||||
setSelectedItem(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReprocess(itemId: string) {
|
||||
setItems((prev) =>
|
||||
prev.map((it) => (it.id === itemId ? { ...it, status: 'processing' as const } : it))
|
||||
)
|
||||
setSelectedItem(null)
|
||||
|
||||
const res = await fetch(`/api/extensions/invoice-inbox/inbox/${itemId}/process`, {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
if (data) {
|
||||
setItems((prev) => prev.map((it) => (it.id === itemId ? data : it)))
|
||||
}
|
||||
} else {
|
||||
// Refetch in case of error update
|
||||
fetchItems()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveSettings(updated: InvoiceInboxSettings) {
|
||||
const res = await fetch('/api/extensions/invoice-inbox/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updated),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
if (data) setSettings(data)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredItems =
|
||||
activeTab === 'all'
|
||||
? items
|
||||
: items.filter((it) => it.status === activeTab)
|
||||
|
||||
const totalCount = items.length
|
||||
const readyCount = items.filter((it) => it.status === 'ready').length
|
||||
const errorCount = items.filter((it) => it.status === 'error').length
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Fakturainkorgen"
|
||||
description="Ladda upp leverantörsfakturor och låt AI extrahera data automatiskt"
|
||||
action={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* KPI cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Inbox className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-medium">{totalCount}</p>
|
||||
<p className="text-xs text-muted-foreground">Totalt</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-warning/15">
|
||||
<CheckCircle2 className="h-5 w-5 text-warning-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-medium">{readyCount}</p>
|
||||
<p className="text-xs text-muted-foreground">Att granska</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-destructive/10">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-medium">{errorCount}</p>
|
||||
<p className="text-xs text-muted-foreground">Fel</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Upload zone */}
|
||||
<InboxUploadZone
|
||||
onUploadComplete={handleUploadComplete}
|
||||
isUploading={isUploading}
|
||||
setIsUploading={setIsUploading}
|
||||
/>
|
||||
|
||||
{/* Tabs + item list */}
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as TabValue)}>
|
||||
<TabsList>
|
||||
{TABS.map((tab) => (
|
||||
<TabsTrigger key={tab.value} value={tab.value}>
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{TABS.map((tab) => (
|
||||
<TabsContent key={tab.value} value={tab.value}>
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-20 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : filteredItems.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Inbox className="h-10 w-10 text-muted-foreground/40 mb-3" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{activeTab === 'all'
|
||||
? 'Inga fakturor ännu. Ladda upp en faktura ovan.'
|
||||
: 'Inga fakturor med denna status.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filteredItems.map((item) => (
|
||||
<InboxItemCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
onClick={() => setSelectedItem(item)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
{/* Detail dialog */}
|
||||
<InboxDetailDialog
|
||||
item={selectedItem}
|
||||
open={selectedItem != null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSelectedItem(null)
|
||||
}}
|
||||
onConfirm={handleConfirm}
|
||||
onReject={handleReject}
|
||||
onReprocess={handleReprocess}
|
||||
suppliers={suppliers}
|
||||
/>
|
||||
|
||||
{/* Settings dialog */}
|
||||
<InboxSettingsDialog
|
||||
open={settingsOpen}
|
||||
onOpenChange={setSettingsOpen}
|
||||
settings={settings}
|
||||
onSave={handleSaveSettings}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { InvoiceInboxItem, Supplier } from '@/types'
|
||||
import type { InvoiceExtractionResult } from '@/extensions/general/invoice-inbox/types'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import {
|
||||
getStatusLabel,
|
||||
getStatusVariant,
|
||||
getConfidenceLabel,
|
||||
} from '@/lib/extensions/invoice-inbox-utils'
|
||||
import { Loader2, RefreshCw, Check, X } from 'lucide-react'
|
||||
|
||||
interface InboxDetailDialogProps {
|
||||
item: InvoiceInboxItem | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onConfirm: (itemId: string, supplierId?: string) => Promise<void>
|
||||
onReject: (itemId: string) => Promise<void>
|
||||
onReprocess: (itemId: string) => Promise<void>
|
||||
suppliers: Supplier[]
|
||||
}
|
||||
|
||||
function formatSEK(amount: number | null): string {
|
||||
if (amount == null) return '-'
|
||||
return new Intl.NumberFormat('sv-SE', {
|
||||
style: 'currency',
|
||||
currency: 'SEK',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
export default function InboxDetailDialog({
|
||||
item,
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
onReject,
|
||||
onReprocess,
|
||||
suppliers,
|
||||
}: InboxDetailDialogProps) {
|
||||
const [loading, setLoading] = useState<'confirm' | 'reject' | 'reprocess' | null>(null)
|
||||
const [selectedSupplierId, setSelectedSupplierId] = useState<string | undefined>(undefined)
|
||||
|
||||
if (!item) return null
|
||||
|
||||
const extraction = item.extracted_data as unknown as InvoiceExtractionResult | null
|
||||
const confidence = getConfidenceLabel(item.confidence)
|
||||
const confidenceVariant = confidence.variant as 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning'
|
||||
const statusVariant = getStatusVariant(item.status) as 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning'
|
||||
|
||||
const matchedSupplierName = (item.supplier as { name?: string } | undefined)?.name
|
||||
const supplierId = selectedSupplierId ?? item.matched_supplier_id ?? undefined
|
||||
|
||||
const canConfirm = item.status === 'ready' && extraction != null
|
||||
const canReprocess = item.status !== 'confirmed'
|
||||
const canReject = item.status !== 'confirmed' && item.status !== 'rejected'
|
||||
|
||||
async function handleAction(action: 'confirm' | 'reject' | 'reprocess') {
|
||||
setLoading(action)
|
||||
try {
|
||||
if (action === 'confirm') {
|
||||
await onConfirm(item!.id, supplierId)
|
||||
} else if (action === 'reject') {
|
||||
await onReject(item!.id)
|
||||
} else {
|
||||
await onReprocess(item!.id)
|
||||
}
|
||||
} finally {
|
||||
setLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<DialogTitle>Granska faktura</DialogTitle>
|
||||
<Badge variant={statusVariant}>
|
||||
{getStatusLabel(item.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Confidence */}
|
||||
{item.confidence != null && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">AI-konfidens</span>
|
||||
<Badge variant={confidenceVariant}>{confidence.label} ({Math.round(item.confidence * 100)}%)</Badge>
|
||||
</div>
|
||||
<Progress value={item.confidence * 100} className="h-1.5" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.error_message && (
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{item.error_message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{extraction && (
|
||||
<>
|
||||
<Separator />
|
||||
|
||||
{/* Supplier info */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">Leverantör</h4>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Namn</span>
|
||||
<p className="font-medium">{extraction.supplier.name ?? '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Org.nr</span>
|
||||
<p className="font-medium">{extraction.supplier.orgNumber ?? '-'}</p>
|
||||
</div>
|
||||
{extraction.supplier.bankgiro && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Bankgiro</span>
|
||||
<p className="font-medium">{extraction.supplier.bankgiro}</p>
|
||||
</div>
|
||||
)}
|
||||
{extraction.supplier.plusgiro && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Plusgiro</span>
|
||||
<p className="font-medium">{extraction.supplier.plusgiro}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Supplier match override */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm text-muted-foreground">
|
||||
Matchad leverantör
|
||||
{matchedSupplierName && (
|
||||
<span className="ml-1 text-xs">
|
||||
(auto: {matchedSupplierName})
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<Select
|
||||
value={supplierId ?? '__new__'}
|
||||
onValueChange={(v) => setSelectedSupplierId(v === '__new__' ? undefined : v)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Skapa ny leverantör" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__new__">Skapa ny leverantör</SelectItem>
|
||||
{suppliers.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
{s.org_number ? ` (${s.org_number})` : ''}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Invoice details */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">Fakturadetaljer</h4>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Fakturanummer</span>
|
||||
<p className="font-medium">{extraction.invoice.invoiceNumber ?? '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Valuta</span>
|
||||
<p className="font-medium">{extraction.invoice.currency}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Fakturadatum</span>
|
||||
<p className="font-medium">{extraction.invoice.invoiceDate ?? '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Förfallodatum</span>
|
||||
<p className="font-medium">{extraction.invoice.dueDate ?? '-'}</p>
|
||||
</div>
|
||||
{extraction.invoice.paymentReference && (
|
||||
<div className="col-span-2">
|
||||
<span className="text-muted-foreground">Betalningsreferens</span>
|
||||
<p className="font-medium font-mono">{extraction.invoice.paymentReference}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Line items */}
|
||||
{extraction.lineItems.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">Rader</h4>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Beskrivning</TableHead>
|
||||
<TableHead className="text-right w-16">Antal</TableHead>
|
||||
<TableHead className="text-right w-24">À-pris</TableHead>
|
||||
<TableHead className="text-right w-24">Belopp</TableHead>
|
||||
<TableHead className="text-right w-16">Moms</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{extraction.lineItems.map((line, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell className="text-sm">{line.description}</TableCell>
|
||||
<TableCell className="text-right text-sm">{line.quantity}</TableCell>
|
||||
<TableCell className="text-right text-sm">
|
||||
{line.unitPrice != null ? formatSEK(line.unitPrice) : '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm font-medium">
|
||||
{formatSEK(line.lineTotal)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm">
|
||||
{line.vatRate != null ? `${line.vatRate}%` : '-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Totals */}
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Netto</span>
|
||||
<span>{formatSEK(extraction.totals.subtotal)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Moms</span>
|
||||
<span>{formatSEK(extraction.totals.vatAmount)}</span>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex justify-between font-medium text-base">
|
||||
<span>Totalt</span>
|
||||
<span>{formatSEK(extraction.totals.total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
{canReject && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleAction('reject')}
|
||||
disabled={loading !== null}
|
||||
>
|
||||
{loading === 'reject' ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Avvisa
|
||||
</Button>
|
||||
)}
|
||||
{canReprocess && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleAction('reprocess')}
|
||||
disabled={loading !== null}
|
||||
>
|
||||
{loading === 'reprocess' ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Bearbeta igen
|
||||
</Button>
|
||||
)}
|
||||
{canConfirm && (
|
||||
<Button
|
||||
onClick={() => handleAction('confirm')}
|
||||
disabled={loading !== null}
|
||||
>
|
||||
{loading === 'confirm' ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Bekräfta
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
'use client'
|
||||
|
||||
import type { InvoiceInboxItem } from '@/types'
|
||||
import type { InvoiceExtractionResult } from '@/extensions/general/invoice-inbox/types'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
getStatusLabel,
|
||||
getStatusVariant,
|
||||
getConfidenceLabel,
|
||||
formatExtractionSummary,
|
||||
} from '@/lib/extensions/invoice-inbox-utils'
|
||||
import { Mail, Upload, FileText } from 'lucide-react'
|
||||
|
||||
interface InboxItemCardProps {
|
||||
item: InvoiceInboxItem
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateStr: string): string {
|
||||
const now = new Date()
|
||||
const date = new Date(dateStr)
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMin = Math.floor(diffMs / 60000)
|
||||
if (diffMin < 1) return 'Just nu'
|
||||
if (diffMin < 60) return `${diffMin} min sedan`
|
||||
const diffH = Math.floor(diffMin / 60)
|
||||
if (diffH < 24) return `${diffH} tim sedan`
|
||||
const diffD = Math.floor(diffH / 24)
|
||||
if (diffD === 1) return 'Igår'
|
||||
return `${diffD} dagar sedan`
|
||||
}
|
||||
|
||||
function formatSEK(amount: number): string {
|
||||
return new Intl.NumberFormat('sv-SE', {
|
||||
style: 'currency',
|
||||
currency: 'SEK',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
export default function InboxItemCard({ item, onClick }: InboxItemCardProps) {
|
||||
const extraction = item.extracted_data as unknown as InvoiceExtractionResult | null
|
||||
const summary = formatExtractionSummary(extraction)
|
||||
const confidence = getConfidenceLabel(item.confidence)
|
||||
const statusVariant = getStatusVariant(item.status) as 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning'
|
||||
const confidenceVariant = confidence.variant as 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning'
|
||||
|
||||
const fileName = (item.document as { file_name?: string } | undefined)?.file_name ?? 'Okänd fil'
|
||||
const supplierName = (item.supplier as { name?: string } | undefined)?.name ?? summary.supplierName
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="cursor-pointer transition-colors hover:bg-accent/50"
|
||||
onClick={onClick}
|
||||
>
|
||||
<CardContent className="flex items-center gap-4 p-4">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||
{item.source === 'email' ? (
|
||||
<Mail className="h-5 w-5 text-muted-foreground" />
|
||||
) : (
|
||||
<Upload className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm font-medium truncate">{fileName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
{supplierName ? (
|
||||
<span className="text-sm text-muted-foreground truncate">{supplierName}</span>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground/60 italic">Okänd leverantör</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-1 shrink-0">
|
||||
{summary.total > 0 && (
|
||||
<span className="text-sm font-medium">{formatSEK(summary.total)}</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{item.confidence != null && (
|
||||
<Badge variant={confidenceVariant} className="text-[10px] px-1.5 py-0">
|
||||
{confidence.label}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant={statusVariant}>
|
||||
{getStatusLabel(item.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{formatRelativeTime(item.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { InvoiceInboxSettings } from '@/extensions/general/invoice-inbox/types'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Loader2, Copy, Check } from 'lucide-react'
|
||||
|
||||
interface InboxSettingsDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
settings: InvoiceInboxSettings
|
||||
onSave: (settings: InvoiceInboxSettings) => Promise<void>
|
||||
}
|
||||
|
||||
export default function InboxSettingsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
settings,
|
||||
onSave,
|
||||
}: InboxSettingsDialogProps) {
|
||||
const [local, setLocal] = useState<InvoiceInboxSettings>(settings)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
// Reset local state when dialog opens with new settings
|
||||
function handleOpenChange(isOpen: boolean) {
|
||||
if (isOpen) {
|
||||
setLocal(settings)
|
||||
}
|
||||
onOpenChange(isOpen)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true)
|
||||
try {
|
||||
await onSave(local)
|
||||
onOpenChange(false)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleCopyEmail() {
|
||||
if (local.inboxEmail) {
|
||||
navigator.clipboard.writeText(local.inboxEmail)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Inställningar</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Automatisk bearbetning</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Analysera fakturor automatiskt vid uppladdning
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={local.autoProcessEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setLocal((prev) => ({ ...prev, autoProcessEnabled: checked }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Automatisk leverantörsmatchning</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Matcha extraherade uppgifter mot befintliga leverantörer
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={local.autoMatchSupplierEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setLocal((prev) => ({ ...prev, autoMatchSupplierEnabled: checked }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Matchningströskel</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Lägsta konfidens för automatisk leverantörsmatchning (0-1)
|
||||
</p>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={local.supplierMatchThreshold}
|
||||
onChange={(e) =>
|
||||
setLocal((prev) => ({
|
||||
...prev,
|
||||
supplierMatchThreshold: Math.min(1, Math.max(0, parseFloat(e.target.value) || 0)),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{local.inboxEmail && (
|
||||
<div className="space-y-2">
|
||||
<Label>Inkorg-e-post</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Vidarebefodra fakturor till denna adress
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={local.inboxEmail}
|
||||
readOnly
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleCopyEmail}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 text-success" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
|
||||
Spara
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import type { InvoiceInboxItem } from '@/types'
|
||||
import { Upload, Loader2, FileUp } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface InboxUploadZoneProps {
|
||||
onUploadComplete: (item: InvoiceInboxItem) => void
|
||||
isUploading: boolean
|
||||
setIsUploading: (v: boolean) => void
|
||||
}
|
||||
|
||||
const ACCEPTED_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp']
|
||||
const MAX_SIZE = 10 * 1024 * 1024 // 10 MB
|
||||
|
||||
export default function InboxUploadZone({
|
||||
onUploadComplete,
|
||||
isUploading,
|
||||
setIsUploading,
|
||||
}: InboxUploadZoneProps) {
|
||||
const [isDragOver, setIsDragOver] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const uploadFile = useCallback(
|
||||
async (file: File) => {
|
||||
setError(null)
|
||||
|
||||
if (!ACCEPTED_TYPES.includes(file.type)) {
|
||||
setError('Filtypen stöds inte. Välj PDF, JPEG, PNG eller WebP.')
|
||||
return
|
||||
}
|
||||
|
||||
if (file.size > MAX_SIZE) {
|
||||
setError('Filen är för stor. Max 10 MB.')
|
||||
return
|
||||
}
|
||||
|
||||
setIsUploading(true)
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const res = await fetch('/api/extensions/invoice-inbox/inbox', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ error: 'Uppladdning misslyckades' }))
|
||||
setError(body.error ?? 'Uppladdning misslyckades')
|
||||
return
|
||||
}
|
||||
|
||||
const { data } = await res.json()
|
||||
onUploadComplete(data)
|
||||
} catch {
|
||||
setError('Nätverksfel vid uppladdning')
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
}
|
||||
},
|
||||
[onUploadComplete, setIsUploading]
|
||||
)
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragOver(false)
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file) uploadFile(file)
|
||||
},
|
||||
[uploadFile]
|
||||
)
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragOver(true)
|
||||
}, [])
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragOver(false)
|
||||
}, [])
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) uploadFile(file)
|
||||
// Reset so same file can be re-selected
|
||||
e.target.value = ''
|
||||
},
|
||||
[uploadFile]
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onClick={() => !isUploading && inputRef.current?.click()}
|
||||
className={cn(
|
||||
'relative flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-6 transition-colors cursor-pointer',
|
||||
isDragOver
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-primary/50 hover:bg-accent/30',
|
||||
isUploading && 'pointer-events-none opacity-60'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".pdf,.jpg,.jpeg,.png,.webp"
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
|
||||
{isUploading ? (
|
||||
<>
|
||||
<Loader2 className="h-8 w-8 text-primary animate-spin mb-2" />
|
||||
<p className="text-sm text-muted-foreground">Laddar upp och analyserar...</p>
|
||||
</>
|
||||
) : isDragOver ? (
|
||||
<>
|
||||
<FileUp className="h-8 w-8 text-primary mb-2" />
|
||||
<p className="text-sm font-medium text-primary">Släpp filen här</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="h-8 w-8 text-muted-foreground/60 mb-2" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Dra och släpp en faktura, eller{' '}
|
||||
<span className="font-medium text-primary">välj fil</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/60 mt-1">
|
||||
PDF, JPEG, PNG eller WebP (max 10 MB)
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-destructive mt-2">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -54,7 +54,7 @@ export function InvoiceReviewContent({
|
||||
// Calculate per-rate VAT breakdown
|
||||
const vatByRate = new Map<number, number>()
|
||||
for (const item of items) {
|
||||
const rate = item.vat_rate ?? 25
|
||||
const rate = item.vat_rate ?? 0
|
||||
const lineTotal = item.quantity * item.unit_price
|
||||
const lineVat = Math.round(lineTotal * rate / 100 * 100) / 100
|
||||
vatByRate.set(rate, (vatByRate.get(rate) || 0) + lineVat)
|
||||
@@ -112,7 +112,7 @@ export function InvoiceReviewContent({
|
||||
<td className="py-2 text-center">{item.unit}</td>
|
||||
<td className="py-2 text-right">{formatCurrency(item.unit_price, currency)}</td>
|
||||
{showVatColumn && (
|
||||
<td className="py-2 text-right">{item.vat_rate ?? 25}%</td>
|
||||
<td className="py-2 text-right">{item.vat_rate ?? 0}%</td>
|
||||
)}
|
||||
<td className="py-2 text-right">
|
||||
{formatCurrency(item.quantity * item.unit_price, currency)}
|
||||
|
||||
@@ -4,13 +4,12 @@ import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { EXPENSE_CATEGORIES, INCOME_CATEGORIES, VAT_TREATMENT_OPTIONS } from './transaction-types'
|
||||
import VatTreatmentSelect from './VatTreatmentSelect'
|
||||
import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from './transaction-types'
|
||||
import type { TransactionCategory, VatTreatment } from '@/types'
|
||||
|
||||
const expenseCategories = EXPENSE_CATEGORIES
|
||||
const incomeCategories = INCOME_CATEGORIES
|
||||
const vatTreatmentOptions = VAT_TREATMENT_OPTIONS
|
||||
|
||||
interface BatchCategorySelectorProps {
|
||||
open: boolean
|
||||
@@ -62,21 +61,10 @@ export default function BatchCategorySelector({
|
||||
<div className="space-y-4 py-2">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-muted-foreground mb-2">Momsbehandling</h4>
|
||||
<Select
|
||||
<VatTreatmentSelect
|
||||
value={vatTreatment}
|
||||
onValueChange={(v) => setVatTreatment(v as VatTreatment | 'none')}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{vatTreatmentOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
onValueChange={setVatTreatment}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-muted-foreground mb-2">Kostnader</h4>
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { ArrowUpRight, ArrowDownRight } from 'lucide-react'
|
||||
import { EXPENSE_CATEGORIES, INCOME_CATEGORIES, VAT_TREATMENT_OPTIONS } from './transaction-types'
|
||||
import VatTreatmentSelect from './VatTreatmentSelect'
|
||||
import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from './transaction-types'
|
||||
import type { TransactionWithInvoice } from './transaction-types'
|
||||
import type { TransactionCategory, VatTreatment } from '@/types'
|
||||
|
||||
@@ -80,21 +80,10 @@ export default function CategoryExpandedDialog({
|
||||
{/* VAT treatment selector */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-muted-foreground mb-2">Momsbehandling</h4>
|
||||
<Select
|
||||
<VatTreatmentSelect
|
||||
value={vatTreatment}
|
||||
onValueChange={(v) => setVatTreatment(v as VatTreatment | 'none')}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{VAT_TREATMENT_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
onValueChange={setVatTreatment}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category grid */}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { ArrowUpRight, ArrowDownRight, Check } from 'lucide-react'
|
||||
import { getDefaultAccountForCategory } from '@/lib/bookkeeping/category-mapping'
|
||||
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
|
||||
import VatTreatmentSelect from './VatTreatmentSelect'
|
||||
import type { TransactionWithInvoice } from './transaction-types'
|
||||
import type { TransactionCategory, VatTreatment, BASAccount } from '@/types'
|
||||
|
||||
interface QuickReviewDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
transaction: TransactionWithInvoice | null
|
||||
category: TransactionCategory | null
|
||||
categoryLabel: string
|
||||
defaultAccount: string
|
||||
defaultVat: VatTreatment | 'none'
|
||||
onConfirm: (
|
||||
id: string,
|
||||
category: TransactionCategory,
|
||||
vatTreatment: VatTreatment | undefined,
|
||||
accountOverride: string | undefined
|
||||
) => Promise<void>
|
||||
}
|
||||
|
||||
export default function QuickReviewDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
transaction,
|
||||
category,
|
||||
categoryLabel,
|
||||
defaultAccount,
|
||||
defaultVat,
|
||||
onConfirm,
|
||||
}: QuickReviewDialogProps) {
|
||||
const [accountOverride, setAccountOverride] = useState(defaultAccount)
|
||||
const [vatTreatment, setVatTreatment] = useState<VatTreatment | 'none'>(defaultVat)
|
||||
const [accounts, setAccounts] = useState<BASAccount[]>([])
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Handle account changes — clear VAT for liability/equity accounts (class 2)
|
||||
const handleAccountChange = useCallback((account: string) => {
|
||||
setAccountOverride(account)
|
||||
if (account.startsWith('2')) {
|
||||
setVatTreatment('none')
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Fetch accounts on mount
|
||||
useEffect(() => {
|
||||
async function fetchAccounts() {
|
||||
try {
|
||||
const res = await fetch('/api/bookkeeping/accounts')
|
||||
const data = await res.json()
|
||||
if (data.accounts) {
|
||||
setAccounts(data.accounts)
|
||||
}
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
}
|
||||
fetchAccounts()
|
||||
}, [])
|
||||
|
||||
if (!transaction || !category) return null
|
||||
|
||||
const isIncome = transaction.amount > 0
|
||||
const isLiabilityAccount = accountOverride.startsWith('2')
|
||||
|
||||
async function handleConfirm() {
|
||||
if (!category || !transaction) return
|
||||
|
||||
setIsProcessing(true)
|
||||
setError(null)
|
||||
try {
|
||||
const resolvedVat = vatTreatment === 'none' ? undefined : vatTreatment
|
||||
const catDefault = getDefaultAccountForCategory(category)
|
||||
const override = accountOverride && accountOverride !== catDefault
|
||||
? accountOverride
|
||||
: undefined
|
||||
|
||||
await onConfirm(transaction.id, category, resolvedVat, override)
|
||||
} catch {
|
||||
setError('Ett fel uppstod vid bokföring.')
|
||||
setIsProcessing(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={isProcessing ? undefined : onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Granska bokföring</DialogTitle>
|
||||
<DialogDescription>
|
||||
Kontrollera konto och moms innan du bokför
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Transaction summary */}
|
||||
<div className="flex items-center gap-3 rounded-lg border p-3">
|
||||
<div
|
||||
className={`h-9 w-9 rounded-full flex items-center justify-center flex-shrink-0 ${
|
||||
isIncome
|
||||
? 'bg-success/10 text-success'
|
||||
: 'bg-destructive/10 text-destructive'
|
||||
}`}
|
||||
>
|
||||
{isIncome ? (
|
||||
<ArrowUpRight className="h-4 w-4" />
|
||||
) : (
|
||||
<ArrowDownRight className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm truncate">{transaction.description}</p>
|
||||
<p className="text-xs text-muted-foreground">{formatDate(transaction.date)}</p>
|
||||
</div>
|
||||
<p className={`font-medium text-sm flex-shrink-0 ${isIncome ? 'text-success' : ''}`}>
|
||||
{isIncome ? '+' : ''}
|
||||
{formatCurrency(transaction.amount, transaction.currency)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Category (read-only) */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Kategori</label>
|
||||
<div className="mt-1">
|
||||
<Badge variant="outline" className="text-sm py-1 px-3">{categoryLabel}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Account */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Konto</label>
|
||||
<div className="mt-1">
|
||||
<AccountCombobox
|
||||
value={accountOverride}
|
||||
accounts={accounts}
|
||||
onChange={handleAccountChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* VAT treatment */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Momsbehandling</label>
|
||||
<div className="mt-1">
|
||||
<VatTreatmentSelect
|
||||
value={isLiabilityAccount ? 'none' : vatTreatment}
|
||||
onValueChange={setVatTreatment}
|
||||
disabled={isLiabilityAccount}
|
||||
/>
|
||||
{isLiabilityAccount && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Ingen moms för skuld-/eget kapital-konton
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={handleConfirm}
|
||||
disabled={isProcessing || !accountOverride}
|
||||
>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
{isProcessing ? 'Bokför...' : 'Bokför'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { motion, useMotionValue, useTransform, AnimatePresence, type PanInfo } f
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import VatTreatmentSelect from './VatTreatmentSelect'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { checkExpenseWarnings } from '@/lib/tax/expense-warnings'
|
||||
import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from '@/lib/bookkeeping/category-mapping'
|
||||
@@ -14,7 +14,7 @@ import { X, ArrowLeft, ArrowRight, Building, AlertTriangle, Check, FileText, Lin
|
||||
import type { TransactionCategory, VatTreatment, BASAccount } from '@/types'
|
||||
import type { SuggestedCategory } from '@/lib/transactions/category-suggestions'
|
||||
import type { TransactionWithInvoice, CategorizeHandler, MatchInvoiceHandler } from './transaction-types'
|
||||
import { EXPENSE_CATEGORIES, INCOME_CATEGORIES, VAT_TREATMENT_OPTIONS } from './transaction-types'
|
||||
import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from './transaction-types'
|
||||
|
||||
interface SwipeCategorizationViewProps {
|
||||
transactions: TransactionWithInvoice[]
|
||||
@@ -24,7 +24,6 @@ interface SwipeCategorizationViewProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const vatTreatmentOptions = VAT_TREATMENT_OPTIONS
|
||||
const expenseCategories = EXPENSE_CATEGORIES
|
||||
const incomeCategories = INCOME_CATEGORIES
|
||||
|
||||
@@ -346,22 +345,11 @@ export default function SwipeCategorizationView({
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Momsbehandling</label>
|
||||
<div className="mt-1">
|
||||
<Select
|
||||
<VatTreatmentSelect
|
||||
value={isLiabilityAccount ? 'none' : vatTreatment}
|
||||
onValueChange={(v) => setVatTreatment(v as VatTreatment | 'none')}
|
||||
onValueChange={setVatTreatment}
|
||||
disabled={isLiabilityAccount}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{vatTreatmentOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
/>
|
||||
{isLiabilityAccount && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Ingen moms för skuld-/eget kapital-konton
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Badge } from '@/components/ui/badge'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { ArrowUpRight, ArrowDownRight, FileText, Loader2 } from 'lucide-react'
|
||||
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/info-tooltip'
|
||||
import type { TransactionWithInvoice, CategorizeHandler } from './transaction-types'
|
||||
import type { SuggestedCategory } from '@/lib/transactions/category-suggestions'
|
||||
|
||||
@@ -16,10 +17,12 @@ interface TransactionInboxCardProps {
|
||||
processingId: string | null
|
||||
isBatchMode: boolean
|
||||
isSelected: boolean
|
||||
entityType?: string
|
||||
onCategorize: CategorizeHandler
|
||||
onMarkPrivate: (id: string) => void
|
||||
onOpenMatchDialog: (transaction: TransactionWithInvoice) => void
|
||||
onOpenCategoryDialog: (transaction: TransactionWithInvoice) => void
|
||||
onOpenQuickReview?: (transaction: TransactionWithInvoice, suggestion: SuggestedCategory) => void
|
||||
onToggleSelect: (id: string) => void
|
||||
onAnimationComplete?: (id: string) => void
|
||||
}
|
||||
@@ -30,10 +33,12 @@ export default function TransactionInboxCard({
|
||||
processingId,
|
||||
isBatchMode,
|
||||
isSelected,
|
||||
entityType = 'enskild_firma',
|
||||
onCategorize,
|
||||
onMarkPrivate,
|
||||
onOpenMatchDialog,
|
||||
onOpenCategoryDialog,
|
||||
onOpenQuickReview,
|
||||
onToggleSelect,
|
||||
onAnimationComplete,
|
||||
}: TransactionInboxCardProps) {
|
||||
@@ -45,8 +50,12 @@ export default function TransactionInboxCard({
|
||||
const isUncategorized = transaction.is_business === null && !transaction.journal_entry_id
|
||||
const showCheckbox = isBatchMode && isUncategorized
|
||||
|
||||
async function handleSuggestionClick(suggestion: SuggestedCategory) {
|
||||
await onCategorize(transaction.id, true, suggestion.category)
|
||||
function handleSuggestionClick(suggestion: SuggestedCategory) {
|
||||
if (onOpenQuickReview) {
|
||||
onOpenQuickReview(transaction, suggestion)
|
||||
} else {
|
||||
onCategorize(transaction.id, true, suggestion.category)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -168,15 +177,28 @@ export default function TransactionInboxCard({
|
||||
)}
|
||||
|
||||
{/* Private button */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 text-xs text-muted-foreground"
|
||||
onClick={() => onMarkPrivate(transaction.id)}
|
||||
disabled={isProcessing || isDisabled}
|
||||
>
|
||||
Privat
|
||||
</Button>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 text-xs text-muted-foreground"
|
||||
onClick={() => onMarkPrivate(transaction.id)}
|
||||
disabled={isProcessing || isDisabled}
|
||||
>
|
||||
Privat
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-[260px]">
|
||||
<p className="text-sm leading-relaxed">
|
||||
{entityType === 'aktiebolag'
|
||||
? 'Privat utgift med företagets kort \u2014 bokförs som skuld till ägaren (konto 2893)'
|
||||
: 'Privat uttag \u2014 bokförs mot konto 2013 (Övriga egna uttag)'}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
{/* Open category dialog */}
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
'use client'
|
||||
|
||||
import * as SelectPrimitive from '@radix-ui/react-select'
|
||||
import { Check } from 'lucide-react'
|
||||
import { Select, SelectContent, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { VAT_TREATMENT_OPTIONS } from './transaction-types'
|
||||
import type { VatTreatment } from '@/types'
|
||||
|
||||
interface VatTreatmentSelectProps {
|
||||
value: VatTreatment | 'none'
|
||||
onValueChange: (value: VatTreatment | 'none') => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export default function VatTreatmentSelect({
|
||||
value,
|
||||
onValueChange,
|
||||
disabled,
|
||||
}: VatTreatmentSelectProps) {
|
||||
return (
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={(v) => onValueChange(v as VatTreatment | 'none')}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{VAT_TREATMENT_OPTIONS.map((opt) => (
|
||||
<SelectPrimitive.Item
|
||||
key={opt.value}
|
||||
value={opt.value}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-default select-none items-start rounded-md py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-secondary focus:text-secondary-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50'
|
||||
)}
|
||||
>
|
||||
<span className="absolute left-2 top-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4 text-primary" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<div>
|
||||
<SelectPrimitive.ItemText>{opt.label}</SelectPrimitive.ItemText>
|
||||
{opt.description && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 font-normal">
|
||||
{opt.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</SelectPrimitive.Item>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
@@ -50,12 +50,18 @@ export const INCOME_CATEGORIES: CategoryOption[] = [
|
||||
{ value: 'income_other', label: 'Övrigt' },
|
||||
]
|
||||
|
||||
export const VAT_TREATMENT_OPTIONS: { value: VatTreatment | 'none'; label: string }[] = [
|
||||
export interface VatTreatmentOption {
|
||||
value: VatTreatment | 'none'
|
||||
label: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export const VAT_TREATMENT_OPTIONS: VatTreatmentOption[] = [
|
||||
{ value: 'standard_25', label: 'Moms 25%' },
|
||||
{ value: 'reduced_12', label: 'Moms 12%' },
|
||||
{ value: 'reduced_6', label: 'Moms 6%' },
|
||||
{ value: 'reverse_charge', label: 'Omvänd skattskyldighet' },
|
||||
{ value: 'export', label: 'Export' },
|
||||
{ value: 'exempt', label: 'Momsfri' },
|
||||
{ value: 'none', label: 'Ingen moms' },
|
||||
{ value: 'reduced_12', label: 'Moms 12%', description: 'Livsmedel, hotell, camping' },
|
||||
{ value: 'reduced_6', label: 'Moms 6%', description: 'Böcker, tidningar, kollektivtrafik' },
|
||||
{ value: 'reverse_charge', label: 'Omvänd skattskyldighet', description: 'Köparen redovisar momsen (EU-tjänster m.m.)' },
|
||||
{ value: 'export', label: 'Export', description: 'Försäljning utanför EU (behåller avdragsrätt)' },
|
||||
{ value: 'exempt', label: 'Momsfri', description: 'Undantaget enligt ML (vård, utbildning, finans)' },
|
||||
{ value: 'none', label: 'Ingen moms', description: 'Ej momspliktigt (t.ex. lön, privata uttag)' },
|
||||
]
|
||||
|
||||
@@ -1,53 +1,2 @@
|
||||
// Push subscription for Web Push API
|
||||
export interface PushSubscription {
|
||||
id: string
|
||||
user_id: string
|
||||
endpoint: string
|
||||
p256dh: string
|
||||
auth: string
|
||||
user_agent: string | null
|
||||
is_active: boolean
|
||||
last_used_at: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// Notification settings per user
|
||||
export interface NotificationSettings {
|
||||
id: string
|
||||
user_id: string
|
||||
tax_deadlines_enabled: boolean
|
||||
invoice_reminders_enabled: boolean
|
||||
quiet_start: string // time format "HH:MM"
|
||||
quiet_end: string // time format "HH:MM"
|
||||
email_enabled: boolean
|
||||
push_enabled: boolean
|
||||
period_locked_enabled: boolean
|
||||
period_year_closed_enabled: boolean
|
||||
invoice_sent_enabled: boolean
|
||||
receipt_extracted_enabled: boolean
|
||||
receipt_matched_enabled: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// Notification type for logging
|
||||
export type NotificationType =
|
||||
| 'tax_deadline'
|
||||
| 'invoice_due'
|
||||
| 'invoice_overdue'
|
||||
| 'period_locked'
|
||||
| 'period_year_closed'
|
||||
| 'receipt_extracted'
|
||||
| 'receipt_matched'
|
||||
| 'invoice_sent'
|
||||
|
||||
// Notification log entry
|
||||
export interface NotificationLog {
|
||||
id: string
|
||||
user_id: string
|
||||
notification_type: NotificationType
|
||||
reference_id: string
|
||||
days_before: number
|
||||
sent_at: string
|
||||
delivery_status: 'sent' | 'delivered' | 'failed'
|
||||
}
|
||||
// Re-export from canonical source — kept for backwards compatibility with extension-internal imports
|
||||
export type { PushSubscription, NotificationSettings, NotificationType, NotificationLog } from '@/types'
|
||||
|
||||
@@ -1,171 +1,15 @@
|
||||
import type { TransactionCategory, Transaction } from '@/types'
|
||||
|
||||
// Receipt extraction status
|
||||
export type ReceiptStatus = 'pending' | 'processing' | 'extracted' | 'confirmed' | 'error'
|
||||
|
||||
// Receipt record
|
||||
export interface Receipt {
|
||||
id: string
|
||||
user_id: string
|
||||
|
||||
// Image storage
|
||||
image_url: string
|
||||
image_thumbnail_url: string | null
|
||||
|
||||
// Extraction status
|
||||
status: ReceiptStatus
|
||||
extraction_confidence: number | null
|
||||
|
||||
// Extracted header data
|
||||
merchant_name: string | null
|
||||
merchant_org_number: string | null
|
||||
merchant_vat_number: string | null
|
||||
receipt_date: string | null
|
||||
receipt_time: string | null
|
||||
total_amount: number | null
|
||||
currency: string
|
||||
vat_amount: number | null
|
||||
|
||||
// Special flags
|
||||
is_restaurant: boolean
|
||||
is_systembolaget: boolean
|
||||
is_foreign_merchant: boolean
|
||||
|
||||
// Restaurant representation data
|
||||
representation_persons: number | null
|
||||
representation_purpose: string | null
|
||||
|
||||
// Transaction matching
|
||||
matched_transaction_id: string | null
|
||||
match_confidence: number | null
|
||||
|
||||
// Raw extraction data
|
||||
raw_extraction: ReceiptExtractionResult | null
|
||||
|
||||
created_at: string
|
||||
updated_at: string
|
||||
|
||||
// Relations (populated when fetched)
|
||||
line_items?: ReceiptLineItem[]
|
||||
matched_transaction?: Transaction
|
||||
}
|
||||
|
||||
// Receipt line item record
|
||||
export interface ReceiptLineItem {
|
||||
id: string
|
||||
receipt_id: string
|
||||
|
||||
// Extracted data
|
||||
description: string
|
||||
quantity: number
|
||||
unit_price: number | null
|
||||
line_total: number
|
||||
vat_rate: number | null
|
||||
vat_amount: number | null
|
||||
|
||||
// Classification
|
||||
is_business: boolean | null
|
||||
category: TransactionCategory | null
|
||||
bas_account: string | null
|
||||
|
||||
// Confidence
|
||||
extraction_confidence: number | null
|
||||
suggested_category: string | null
|
||||
|
||||
sort_order: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// AI extraction result from Claude Vision
|
||||
export interface ReceiptExtractionResult {
|
||||
merchant: {
|
||||
name: string | null
|
||||
orgNumber: string | null
|
||||
vatNumber: string | null
|
||||
isForeign: boolean
|
||||
}
|
||||
receipt: {
|
||||
date: string | null
|
||||
time: string | null
|
||||
currency: string
|
||||
}
|
||||
lineItems: ExtractedLineItem[]
|
||||
totals: {
|
||||
subtotal: number | null
|
||||
vatAmount: number | null
|
||||
total: number | null
|
||||
}
|
||||
flags: {
|
||||
isRestaurant: boolean
|
||||
isSystembolaget: boolean
|
||||
isForeignMerchant: boolean
|
||||
}
|
||||
confidence: number
|
||||
}
|
||||
|
||||
// Extracted line item from AI
|
||||
export interface ExtractedLineItem {
|
||||
description: string
|
||||
quantity: number
|
||||
unitPrice: number | null
|
||||
lineTotal: number
|
||||
vatRate: number | null
|
||||
suggestedCategory: string | null
|
||||
confidence?: number
|
||||
}
|
||||
|
||||
// Match candidate for receipt-to-transaction matching
|
||||
export interface ReceiptMatchCandidate {
|
||||
transaction: Transaction
|
||||
confidence: number
|
||||
matchReasons: string[]
|
||||
dateVariance: number
|
||||
amountVariance: number
|
||||
}
|
||||
|
||||
// Input for creating a receipt
|
||||
export interface CreateReceiptInput {
|
||||
image_url: string
|
||||
image_thumbnail_url?: string
|
||||
}
|
||||
|
||||
// Input for confirming receipt line items
|
||||
export interface ConfirmReceiptInput {
|
||||
line_items: ConfirmLineItemInput[]
|
||||
matched_transaction_id?: string
|
||||
representation_persons?: number
|
||||
representation_purpose?: string
|
||||
}
|
||||
|
||||
export interface ConfirmLineItemInput {
|
||||
id: string
|
||||
is_business: boolean
|
||||
category?: TransactionCategory
|
||||
bas_account?: string
|
||||
}
|
||||
|
||||
// Receipt queue summary
|
||||
export interface ReceiptQueueSummary {
|
||||
unmatched_receipts_count: number
|
||||
unmatched_transactions_count: number
|
||||
pending_review_count: number
|
||||
streak_count: number
|
||||
}
|
||||
|
||||
// Camera quality feedback
|
||||
export interface CameraQualityFeedback {
|
||||
lightingOk: boolean
|
||||
distanceOk: boolean
|
||||
focusOk: boolean
|
||||
readyToCapture: boolean
|
||||
message?: string
|
||||
}
|
||||
|
||||
// Swedish labels for receipt status
|
||||
export const RECEIPT_STATUS_LABELS: Record<ReceiptStatus, string> = {
|
||||
pending: 'Väntar',
|
||||
processing: 'Analyserar',
|
||||
extracted: 'Extraherat',
|
||||
confirmed: 'Bekräftat',
|
||||
error: 'Fel'
|
||||
}
|
||||
// Re-export from canonical source — kept for backwards compatibility with extension-internal imports
|
||||
export type {
|
||||
ReceiptStatus,
|
||||
Receipt,
|
||||
ReceiptLineItem,
|
||||
ReceiptExtractionResult,
|
||||
ExtractedLineItem,
|
||||
ReceiptMatchCandidate,
|
||||
CreateReceiptInput,
|
||||
ConfirmReceiptInput,
|
||||
ConfirmLineItemInput,
|
||||
ReceiptQueueSummary,
|
||||
CameraQualityFeedback,
|
||||
} from '@/types'
|
||||
export { RECEIPT_STATUS_LABELS } from '@/types'
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Download, AlertCircle } from 'lucide-react'
|
||||
import { AccountNumber } from '@/components/ui/account-number'
|
||||
import type { NEDeclaration } from '@/types'
|
||||
import type { NEDeclaration } from '@/extensions/ne-bilaga/types'
|
||||
|
||||
function formatAmount(amount: number): string {
|
||||
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { NE_ACCOUNT_MAPPINGS } from '../ne-engine'
|
||||
import type { NEAccountMapping } from '@/types'
|
||||
import type { NEAccountMapping } from '@/extensions/ne-bilaga/types'
|
||||
|
||||
/**
|
||||
* Helper to check if an account falls into a specific ruta
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import type {
|
||||
NEDeclaration,
|
||||
NEDeclarationRutor,
|
||||
NEAccountMapping,
|
||||
FiscalPeriod,
|
||||
JournalEntry,
|
||||
JournalEntryLine,
|
||||
} from '@/types'
|
||||
import type {
|
||||
NEDeclaration,
|
||||
NEDeclarationRutor,
|
||||
NEAccountMapping,
|
||||
} from './types'
|
||||
|
||||
/**
|
||||
* NE-bilaga (Enskild Firma / Sole Proprietorship Declaration)
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Download, AlertCircle } from 'lucide-react'
|
||||
import { AccountNumber } from '@/components/ui/account-number'
|
||||
import type { SRUExportResult, SRUCoverageStats } from '@/types'
|
||||
import type { SRUExportResult, SRUCoverageStats } from '@/extensions/sru-export/types'
|
||||
|
||||
function formatAmount(amount: number): string {
|
||||
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { NEDeclaration, SRUFile, SRURecord } from '@/types'
|
||||
import type { NEDeclaration, SRUFile, SRURecord } from '@/extensions/ne-bilaga/types'
|
||||
|
||||
/**
|
||||
* SRU File Generator
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SRUFile, SRURecord } from '@/types'
|
||||
import type { SRUFile, SRURecord } from '@/extensions/ne-bilaga/types'
|
||||
import { sruFileToString, validateSRUFile } from './lib/sru-generator'
|
||||
import type { SRUBalance } from './sru-engine'
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,245 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
import { validateBody, validateQuery } from '../validate'
|
||||
|
||||
// ============================================================
|
||||
// Helpers
|
||||
// ============================================================
|
||||
|
||||
function createJsonRequest(body: unknown): Request {
|
||||
return new Request('http://localhost/api/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
function createMalformedRequest(): Request {
|
||||
return new Request('http://localhost/api/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: 'not valid json {{{',
|
||||
})
|
||||
}
|
||||
|
||||
function createRequestWithQuery(params: Record<string, string>): Request {
|
||||
const url = new URL('http://localhost/api/test')
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
url.searchParams.set(key, value)
|
||||
}
|
||||
return new Request(url.toString())
|
||||
}
|
||||
|
||||
const TestSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
age: z.number().int().positive(),
|
||||
email: z.string().email().optional(),
|
||||
})
|
||||
|
||||
const QuerySchema = z.object({
|
||||
page: z.coerce.number().int().positive().default(1),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(50),
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// validateBody
|
||||
// ============================================================
|
||||
|
||||
describe('validateBody', () => {
|
||||
it('returns success with parsed data on valid input', async () => {
|
||||
const request = createJsonRequest({ name: 'Alice', age: 30 })
|
||||
const result = await validateBody(request, TestSchema)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data).toEqual({ name: 'Alice', age: 30 })
|
||||
}
|
||||
})
|
||||
|
||||
it('returns success with optional fields', async () => {
|
||||
const request = createJsonRequest({ name: 'Bob', age: 25, email: 'bob@test.com' })
|
||||
const result = await validateBody(request, TestSchema)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.email).toBe('bob@test.com')
|
||||
}
|
||||
})
|
||||
|
||||
it('returns failure response on invalid body', async () => {
|
||||
const request = createJsonRequest({ name: '', age: -5 })
|
||||
const result = await validateBody(request, TestSchema)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
const body = await result.response.json()
|
||||
expect(result.response.status).toBe(400)
|
||||
expect(body.error).toBe('Validation failed')
|
||||
expect(body.type).toBe('validation_error')
|
||||
expect(body.errors).toBeInstanceOf(Array)
|
||||
expect(body.errors.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('returns field paths in error details', async () => {
|
||||
const request = createJsonRequest({ name: 'Alice', age: 'not-a-number' })
|
||||
const result = await validateBody(request, TestSchema)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
const body = await result.response.json()
|
||||
const ageError = body.errors.find((e: { field: string }) => e.field === 'age')
|
||||
expect(ageError).toBeDefined()
|
||||
expect(ageError.code).toBeDefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('returns error for malformed JSON', async () => {
|
||||
const request = createMalformedRequest()
|
||||
const result = await validateBody(request, TestSchema)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
const body = await result.response.json()
|
||||
expect(result.response.status).toBe(400)
|
||||
expect(body.error).toBe('Invalid JSON in request body')
|
||||
expect(body.type).toBe('validation_error')
|
||||
}
|
||||
})
|
||||
|
||||
it('reports all validation errors, not just the first', async () => {
|
||||
// Missing name and age
|
||||
const request = createJsonRequest({})
|
||||
const result = await validateBody(request, TestSchema)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
const body = await result.response.json()
|
||||
expect(body.errors.length).toBeGreaterThanOrEqual(2)
|
||||
}
|
||||
})
|
||||
|
||||
it('strips unknown fields (Zod default behavior)', async () => {
|
||||
const request = createJsonRequest({ name: 'Alice', age: 30, secret: 'hidden' })
|
||||
const result = await validateBody(request, TestSchema)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect((result.data as Record<string, unknown>).secret).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects invalid email format', async () => {
|
||||
const request = createJsonRequest({ name: 'Alice', age: 30, email: 'not-email' })
|
||||
const result = await validateBody(request, TestSchema)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// validateQuery
|
||||
// ============================================================
|
||||
|
||||
describe('validateQuery', () => {
|
||||
it('returns success with parsed query params', () => {
|
||||
const request = createRequestWithQuery({ page: '3', limit: '25' })
|
||||
const result = validateQuery(request, QuerySchema)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.page).toBe(3)
|
||||
expect(result.data.limit).toBe(25)
|
||||
}
|
||||
})
|
||||
|
||||
it('applies defaults for missing params', () => {
|
||||
const request = createRequestWithQuery({})
|
||||
const result = validateQuery(request, QuerySchema)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.page).toBe(1)
|
||||
expect(result.data.limit).toBe(50)
|
||||
}
|
||||
})
|
||||
|
||||
it('coerces string values to numbers', () => {
|
||||
const request = createRequestWithQuery({ page: '10' })
|
||||
const result = validateQuery(request, QuerySchema)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(typeof result.data.page).toBe('number')
|
||||
}
|
||||
})
|
||||
|
||||
it('returns failure for invalid query params', () => {
|
||||
const request = createRequestWithQuery({ page: '0', limit: '200' })
|
||||
const result = validateQuery(request, QuerySchema)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
const body = result.response as unknown as { status: number }
|
||||
expect(result.response.status).toBe(400)
|
||||
}
|
||||
})
|
||||
|
||||
it('includes error details in response', () => {
|
||||
const request = createRequestWithQuery({ limit: 'abc' })
|
||||
const result = validateQuery(request, QuerySchema)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
// The response is a NextResponse — we verify it's a 400
|
||||
expect(result.response.status).toBe(400)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// Integration: validateBody with domain schemas
|
||||
// ============================================================
|
||||
|
||||
describe('validateBody with domain schemas', () => {
|
||||
// Demonstrates using validateBody with the actual schemas from schemas.ts
|
||||
// This pattern is what API routes should use
|
||||
|
||||
const InvoiceSchema = z.object({
|
||||
customer_id: z.string().uuid(),
|
||||
invoice_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
items: z.array(z.object({
|
||||
description: z.string().min(1),
|
||||
amount: z.number().positive(),
|
||||
})).min(1),
|
||||
})
|
||||
|
||||
it('validates a well-formed invoice request', async () => {
|
||||
const request = createJsonRequest({
|
||||
customer_id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
invoice_date: '2025-03-15',
|
||||
items: [{ description: 'Service', amount: 1000 }],
|
||||
})
|
||||
|
||||
const result = await validateBody(request, InvoiceSchema)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('catches nested array validation errors', async () => {
|
||||
const request = createJsonRequest({
|
||||
customer_id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
invoice_date: '2025-03-15',
|
||||
items: [{ description: '', amount: -1 }],
|
||||
})
|
||||
|
||||
const result = await validateBody(request, InvoiceSchema)
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
const body = await result.response.json()
|
||||
// Should catch both description and amount errors
|
||||
expect(body.errors.length).toBeGreaterThanOrEqual(2)
|
||||
const fields = body.errors.map((e: { field: string }) => e.field)
|
||||
expect(fields.some((f: string) => f.includes('items'))).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,499 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
// ============================================================
|
||||
// Shared primitives
|
||||
// ============================================================
|
||||
|
||||
/** UUID v4 string */
|
||||
const uuid = z.string().uuid()
|
||||
|
||||
/** ISO date string (YYYY-MM-DD) */
|
||||
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Expected YYYY-MM-DD date format')
|
||||
|
||||
/** BAS account number — always a string of 4 digits */
|
||||
const accountNumber = z.string().regex(/^\d{4}$/, 'Account number must be exactly 4 digits')
|
||||
|
||||
/** Positive monetary amount (> 0) */
|
||||
const positiveAmount = z.number().positive()
|
||||
|
||||
/** Non-negative monetary amount (>= 0) */
|
||||
const nonNegativeAmount = z.number().nonnegative()
|
||||
|
||||
/** Time string (HH:MM or HH:MM:SS) */
|
||||
const timeString = z.string().regex(/^\d{2}:\d{2}(:\d{2})?$/, 'Expected HH:MM or HH:MM:SS time format')
|
||||
|
||||
// ============================================================
|
||||
// Enum schemas (matching types/index.ts)
|
||||
// ============================================================
|
||||
|
||||
export const EntityTypeSchema = z.enum(['enskild_firma', 'aktiebolag'])
|
||||
|
||||
export const CustomerTypeSchema = z.enum([
|
||||
'individual',
|
||||
'swedish_business',
|
||||
'eu_business',
|
||||
'non_eu_business',
|
||||
])
|
||||
|
||||
export const SupplierTypeSchema = z.enum([
|
||||
'swedish_business',
|
||||
'eu_business',
|
||||
'non_eu_business',
|
||||
])
|
||||
|
||||
export const InvoiceStatusSchema = z.enum([
|
||||
'draft', 'sent', 'paid', 'overdue', 'cancelled', 'credited',
|
||||
])
|
||||
|
||||
export const InvoiceDocumentTypeSchema = z.enum([
|
||||
'invoice', 'proforma', 'delivery_note',
|
||||
])
|
||||
|
||||
export const SupplierInvoiceStatusSchema = z.enum([
|
||||
'registered', 'approved', 'paid', 'partially_paid', 'overdue', 'disputed', 'credited',
|
||||
])
|
||||
|
||||
export const VatTreatmentSchema = z.enum([
|
||||
'standard_25', 'reduced_12', 'reduced_6', 'reverse_charge', 'export', 'exempt',
|
||||
])
|
||||
|
||||
export const AccountingMethodSchema = z.enum(['accrual', 'cash'])
|
||||
|
||||
export const CurrencySchema = z.enum(['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK'])
|
||||
|
||||
export const TransactionCategorySchema = z.enum([
|
||||
'income_services',
|
||||
'income_products',
|
||||
'income_other',
|
||||
'expense_equipment',
|
||||
'expense_software',
|
||||
'expense_travel',
|
||||
'expense_office',
|
||||
'expense_marketing',
|
||||
'expense_professional_services',
|
||||
'expense_education',
|
||||
'expense_bank_fees',
|
||||
'expense_card_fees',
|
||||
'expense_currency_exchange',
|
||||
'expense_other',
|
||||
'private',
|
||||
'uncategorized',
|
||||
])
|
||||
|
||||
export const JournalEntrySourceTypeSchema = z.enum([
|
||||
'manual',
|
||||
'bank_transaction',
|
||||
'invoice_created',
|
||||
'invoice_paid',
|
||||
'invoice_cash_payment',
|
||||
'credit_note',
|
||||
'salary_payment',
|
||||
'opening_balance',
|
||||
'year_end',
|
||||
'storno',
|
||||
'correction',
|
||||
'import',
|
||||
'system',
|
||||
'supplier_invoice_registered',
|
||||
'supplier_invoice_paid',
|
||||
'supplier_invoice_cash_payment',
|
||||
'supplier_credit_note',
|
||||
])
|
||||
|
||||
export const AccountTypeSchema = z.enum([
|
||||
'asset', 'equity', 'liability', 'revenue', 'expense',
|
||||
])
|
||||
|
||||
export const NormalBalanceSchema = z.enum(['debit', 'credit'])
|
||||
|
||||
export const MappingRuleTypeSchema = z.enum([
|
||||
'mcc_code', 'merchant_name', 'description_pattern', 'amount_threshold', 'combined',
|
||||
])
|
||||
|
||||
export const RiskLevelSchema = z.enum(['NONE', 'LOW', 'MEDIUM', 'HIGH', 'VERY_HIGH'])
|
||||
|
||||
export const DeadlineTypeSchema = z.enum([
|
||||
'delivery', 'invoicing', 'report', 'tax', 'other',
|
||||
])
|
||||
|
||||
export const DeadlinePrioritySchema = z.enum(['critical', 'important', 'normal'])
|
||||
|
||||
export const TaxDeadlineTypeSchema = z.enum([
|
||||
'moms_monthly',
|
||||
'moms_quarterly',
|
||||
'moms_yearly',
|
||||
'f_skatt',
|
||||
'arbetsgivardeklaration',
|
||||
'inkomstdeklaration_ef',
|
||||
'inkomstdeklaration_ab',
|
||||
'arsredovisning',
|
||||
'periodisk_sammanstallning',
|
||||
'bokslut',
|
||||
])
|
||||
|
||||
export const DeadlineSourceSchema = z.enum(['system', 'user'])
|
||||
|
||||
export const MomsPeriodSchema = z.enum(['monthly', 'quarterly', 'yearly'])
|
||||
|
||||
export const DocumentUploadSourceSchema = z.enum([
|
||||
'camera', 'file_upload', 'email', 'e_invoice', 'scan', 'api', 'system',
|
||||
])
|
||||
|
||||
// ============================================================
|
||||
// Invoice schemas
|
||||
// ============================================================
|
||||
|
||||
export const CreateInvoiceItemSchema = z.object({
|
||||
description: z.string().min(1, 'Item description is required'),
|
||||
quantity: z.number().positive('Quantity must be positive'),
|
||||
unit: z.string().min(1, 'Unit is required'),
|
||||
unit_price: z.number(),
|
||||
vat_rate: z.number().min(0).max(1).optional(),
|
||||
})
|
||||
|
||||
export const CreateInvoiceSchema = z.object({
|
||||
customer_id: uuid,
|
||||
invoice_date: isoDate,
|
||||
due_date: isoDate,
|
||||
currency: CurrencySchema,
|
||||
document_type: InvoiceDocumentTypeSchema.optional(),
|
||||
your_reference: z.string().optional(),
|
||||
our_reference: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
items: z.array(CreateInvoiceItemSchema).min(1, 'At least one item is required'),
|
||||
})
|
||||
|
||||
export const CreateCreditNoteSchema = z.object({
|
||||
credited_invoice_id: uuid,
|
||||
reason: z.string().optional(),
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// Customer schemas
|
||||
// ============================================================
|
||||
|
||||
export const CreateCustomerSchema = z.object({
|
||||
name: z.string().min(1, 'Customer name is required'),
|
||||
customer_type: CustomerTypeSchema,
|
||||
email: z.string().email('Invalid email address').optional(),
|
||||
phone: z.string().optional(),
|
||||
address_line1: z.string().optional(),
|
||||
address_line2: z.string().optional(),
|
||||
postal_code: z.string().optional(),
|
||||
city: z.string().optional(),
|
||||
country: z.string().optional(),
|
||||
org_number: z.string().optional(),
|
||||
vat_number: z.string().optional(),
|
||||
default_payment_terms: z.number().int().positive().optional(),
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
|
||||
export const UpdateCustomerSchema = CreateCustomerSchema.partial()
|
||||
|
||||
// ============================================================
|
||||
// Supplier schemas
|
||||
// ============================================================
|
||||
|
||||
export const CreateSupplierSchema = z.object({
|
||||
name: z.string().min(1, 'Supplier name is required'),
|
||||
supplier_type: SupplierTypeSchema,
|
||||
email: z.string().email('Invalid email address').optional(),
|
||||
phone: z.string().optional(),
|
||||
address_line1: z.string().optional(),
|
||||
address_line2: z.string().optional(),
|
||||
postal_code: z.string().optional(),
|
||||
city: z.string().optional(),
|
||||
country: z.string().optional(),
|
||||
org_number: z.string().optional(),
|
||||
vat_number: z.string().optional(),
|
||||
bankgiro: z.string().optional(),
|
||||
plusgiro: z.string().optional(),
|
||||
bank_account: z.string().optional(),
|
||||
iban: z.string().optional(),
|
||||
bic: z.string().optional(),
|
||||
default_expense_account: accountNumber.optional(),
|
||||
default_payment_terms: z.number().int().positive().optional(),
|
||||
default_currency: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
|
||||
export const UpdateSupplierSchema = CreateSupplierSchema.partial()
|
||||
|
||||
// ============================================================
|
||||
// Supplier invoice schemas
|
||||
// ============================================================
|
||||
|
||||
export const CreateSupplierInvoiceItemSchema = z.object({
|
||||
description: z.string().min(1, 'Item description is required'),
|
||||
amount: z.number().optional(),
|
||||
account_number: accountNumber,
|
||||
vat_rate: z.number().min(0).max(1).optional(),
|
||||
vat_code: z.string().optional(),
|
||||
quantity: z.number().optional(),
|
||||
unit: z.string().optional(),
|
||||
unit_price: z.number().optional(),
|
||||
})
|
||||
|
||||
export const CreateSupplierInvoiceSchema = z.object({
|
||||
supplier_id: uuid,
|
||||
supplier_invoice_number: z.string().min(1, 'Supplier invoice number is required'),
|
||||
invoice_date: isoDate,
|
||||
due_date: isoDate,
|
||||
delivery_date: isoDate.optional(),
|
||||
currency: z.string().optional(),
|
||||
exchange_rate: z.number().positive().optional(),
|
||||
vat_treatment: VatTreatmentSchema.optional(),
|
||||
reverse_charge: z.boolean().optional(),
|
||||
payment_reference: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
items: z.array(CreateSupplierInvoiceItemSchema).min(1, 'At least one item is required'),
|
||||
})
|
||||
|
||||
export const MarkSupplierInvoicePaidSchema = z.object({
|
||||
amount: z.number().positive().optional(),
|
||||
payment_date: isoDate.optional(),
|
||||
exchange_rate_difference: z.number().optional(),
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
|
||||
export const UpdateSupplierInvoiceSchema = z.object({
|
||||
supplier_invoice_number: z.string().min(1).optional(),
|
||||
invoice_date: isoDate.optional(),
|
||||
due_date: isoDate.optional(),
|
||||
delivery_date: isoDate.optional(),
|
||||
payment_reference: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// Journal entry schemas
|
||||
// ============================================================
|
||||
|
||||
export const CreateJournalEntryLineSchema = z.object({
|
||||
account_number: accountNumber,
|
||||
debit_amount: nonNegativeAmount.default(0),
|
||||
credit_amount: nonNegativeAmount.default(0),
|
||||
line_description: z.string().optional(),
|
||||
currency: z.string().optional(),
|
||||
amount_in_currency: z.number().optional(),
|
||||
exchange_rate: z.number().positive().optional(),
|
||||
tax_code: z.string().optional(),
|
||||
cost_center: z.string().optional(),
|
||||
project: z.string().optional(),
|
||||
})
|
||||
|
||||
export const CreateJournalEntrySchema = z.object({
|
||||
fiscal_period_id: uuid,
|
||||
entry_date: isoDate,
|
||||
description: z.string().min(1, 'Description is required'),
|
||||
source_type: JournalEntrySourceTypeSchema.default('manual'),
|
||||
source_id: z.string().optional(),
|
||||
voucher_series: z.string().optional(),
|
||||
lines: z.array(CreateJournalEntryLineSchema).min(2, 'At least two lines are required for double-entry'),
|
||||
})
|
||||
|
||||
export const CorrectJournalEntrySchema = z.object({
|
||||
lines: z.array(CreateJournalEntryLineSchema).min(2, 'At least two lines are required for double-entry'),
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// Transaction schemas
|
||||
// ============================================================
|
||||
|
||||
export const CategorizeTransactionSchema = z.object({
|
||||
is_business: z.boolean(),
|
||||
category: TransactionCategorySchema.optional(),
|
||||
vat_treatment: VatTreatmentSchema.optional(),
|
||||
account_override: accountNumber.optional(),
|
||||
})
|
||||
|
||||
export const BookTransactionSchema = z.object({
|
||||
fiscal_period_id: uuid,
|
||||
entry_date: isoDate,
|
||||
description: z.string().min(1, 'Description is required'),
|
||||
lines: z.array(CreateJournalEntryLineSchema).min(1, 'At least one line is required'),
|
||||
})
|
||||
|
||||
export const MatchInvoiceSchema = z.object({
|
||||
invoice_id: uuid,
|
||||
})
|
||||
|
||||
export const MatchSupplierInvoiceSchema = z.object({
|
||||
supplier_invoice_id: uuid,
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// Settings schemas
|
||||
// ============================================================
|
||||
|
||||
export const UpdateSettingsSchema = z.object({
|
||||
entity_type: EntityTypeSchema.optional(),
|
||||
company_name: z.string().optional(),
|
||||
org_number: z.string().optional(),
|
||||
address_line1: z.string().optional(),
|
||||
address_line2: z.string().optional(),
|
||||
postal_code: z.string().optional(),
|
||||
city: z.string().optional(),
|
||||
country: z.string().optional(),
|
||||
f_skatt: z.boolean().optional(),
|
||||
vat_registered: z.boolean().optional(),
|
||||
vat_number: z.string().optional(),
|
||||
moms_period: MomsPeriodSchema.nullable().optional(),
|
||||
fiscal_year_start_month: z.number().int().min(1).max(12).optional(),
|
||||
preliminary_tax_monthly: z.number().nullable().optional(),
|
||||
bank_name: z.string().optional(),
|
||||
clearing_number: z.string().optional(),
|
||||
account_number: z.string().optional(),
|
||||
iban: z.string().optional(),
|
||||
bic: z.string().optional(),
|
||||
accounting_method: AccountingMethodSchema.optional(),
|
||||
invoice_prefix: z.string().optional(),
|
||||
next_invoice_number: z.number().int().positive().optional(),
|
||||
invoice_default_days: z.number().int().positive().optional(),
|
||||
invoice_default_notes: z.string().optional(),
|
||||
email: z.string().email().optional(),
|
||||
pays_salaries: z.boolean().optional(),
|
||||
sector_slug: z.string().nullable().optional(),
|
||||
}).refine(
|
||||
(data) => {
|
||||
// BFL 3 kap.: Enskild firma must have fiscal year starting January
|
||||
if (data.entity_type === 'enskild_firma' && data.fiscal_year_start_month !== undefined) {
|
||||
return data.fiscal_year_start_month === 1
|
||||
}
|
||||
return true
|
||||
},
|
||||
{
|
||||
message: 'Enskild firma must have fiscal year starting in January (BFL 3 kap.)',
|
||||
path: ['fiscal_year_start_month'],
|
||||
}
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// Fiscal period schemas
|
||||
// ============================================================
|
||||
|
||||
export const CreateFiscalPeriodSchema = z.object({
|
||||
name: z.string().min(1, 'Period name is required'),
|
||||
period_start: isoDate,
|
||||
period_end: isoDate,
|
||||
}).refine(
|
||||
(data) => data.period_start < data.period_end,
|
||||
{
|
||||
message: 'Period start must be before period end',
|
||||
path: ['period_end'],
|
||||
}
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// Mapping rule schemas
|
||||
// ============================================================
|
||||
|
||||
export const CreateMappingRuleSchema = z.object({
|
||||
rule_name: z.string().min(1, 'Rule name is required'),
|
||||
rule_type: MappingRuleTypeSchema,
|
||||
priority: z.number().int().min(0).optional(),
|
||||
mcc_codes: z.array(z.string()).optional(),
|
||||
merchant_pattern: z.string().optional(),
|
||||
description_pattern: z.string().optional(),
|
||||
amount_min: z.number().optional(),
|
||||
amount_max: z.number().optional(),
|
||||
debit_account: accountNumber,
|
||||
credit_account: accountNumber,
|
||||
vat_treatment: z.string().optional(),
|
||||
risk_level: RiskLevelSchema.optional(),
|
||||
default_private: z.boolean().optional(),
|
||||
requires_review: z.boolean().optional(),
|
||||
confidence_score: z.number().min(0).max(1).optional(),
|
||||
})
|
||||
|
||||
export const EvaluateMappingRulesSchema = z.union([
|
||||
z.object({ transaction_id: uuid }),
|
||||
z.object({
|
||||
description: z.string().optional(),
|
||||
amount: z.number(),
|
||||
}).passthrough(),
|
||||
])
|
||||
|
||||
// ============================================================
|
||||
// Deadline schemas
|
||||
// ============================================================
|
||||
|
||||
export const CreateDeadlineSchema = z.object({
|
||||
title: z.string().min(1, 'Title is required'),
|
||||
due_date: isoDate,
|
||||
due_time: timeString.optional(),
|
||||
deadline_type: DeadlineTypeSchema,
|
||||
priority: DeadlinePrioritySchema.optional(),
|
||||
customer_id: uuid.optional(),
|
||||
notes: z.string().optional(),
|
||||
tax_deadline_type: TaxDeadlineTypeSchema.optional(),
|
||||
tax_period: z.string().optional(),
|
||||
source: DeadlineSourceSchema.optional(),
|
||||
linked_report_type: z.string().optional(),
|
||||
linked_report_period: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// Account schemas
|
||||
// ============================================================
|
||||
|
||||
export const CreateAccountSchema = z.object({
|
||||
account_number: accountNumber,
|
||||
account_name: z.string().min(1, 'Account name is required'),
|
||||
account_type: AccountTypeSchema,
|
||||
normal_balance: NormalBalanceSchema,
|
||||
plan_type: z.enum(['k1', 'full_bas']).optional(),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
|
||||
export const UpdateAccountSchema = z.object({
|
||||
account_name: z.string().min(1).optional(),
|
||||
is_active: z.boolean().optional(),
|
||||
description: z.string().nullable().optional(),
|
||||
default_vat_code: z.string().nullable().optional(),
|
||||
sru_code: z.string().nullable().optional(),
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// Bank reconciliation schemas
|
||||
// ============================================================
|
||||
|
||||
export const BankLinkSchema = z.object({
|
||||
transaction_id: uuid,
|
||||
journal_entry_id: uuid,
|
||||
})
|
||||
|
||||
export const BankUnlinkSchema = z.object({
|
||||
transaction_id: uuid,
|
||||
})
|
||||
|
||||
export const RunReconciliationSchema = z.object({
|
||||
date_from: isoDate.optional(),
|
||||
date_to: isoDate.optional(),
|
||||
dry_run: z.boolean().optional(),
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// Report query schemas
|
||||
// ============================================================
|
||||
|
||||
export const VatDeclarationQuerySchema = z.object({
|
||||
periodType: z.enum(['monthly', 'quarterly', 'yearly']),
|
||||
year: z.coerce.number().int().min(2000).max(2100),
|
||||
period: z.coerce.number().int().min(1).max(12),
|
||||
})
|
||||
|
||||
export const ReportPeriodQuerySchema = z.object({
|
||||
fiscal_period_id: uuid.optional(),
|
||||
year: z.coerce.number().int().min(2000).max(2100).optional(),
|
||||
month: z.coerce.number().int().min(1).max(12).optional(),
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// Pagination schemas
|
||||
// ============================================================
|
||||
|
||||
export const PaginationQuerySchema = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(100).default(50),
|
||||
offset: z.coerce.number().int().nonnegative().default(0),
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
import { z } from 'zod'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
export interface ValidationSuccess<T> {
|
||||
success: true
|
||||
data: T
|
||||
}
|
||||
|
||||
export interface ValidationFailure {
|
||||
success: false
|
||||
response: NextResponse
|
||||
}
|
||||
|
||||
export type ValidationResult<T> = ValidationSuccess<T> | ValidationFailure
|
||||
|
||||
/**
|
||||
* Validate a request body against a Zod schema.
|
||||
*
|
||||
* Returns `{ success: true, data }` on valid input, or
|
||||
* `{ success: false, response }` with a 400 NextResponse on failure.
|
||||
*
|
||||
* Usage in an API route:
|
||||
* ```ts
|
||||
* const result = await validateBody(request, CreateInvoiceSchema)
|
||||
* if (!result.success) return result.response
|
||||
* const { data } = result
|
||||
* ```
|
||||
*/
|
||||
export async function validateBody<T>(
|
||||
request: Request,
|
||||
schema: z.ZodType<T>,
|
||||
): Promise<ValidationResult<T>> {
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
response: NextResponse.json(
|
||||
{
|
||||
error: 'Invalid JSON in request body',
|
||||
type: 'validation_error',
|
||||
},
|
||||
{ status: 400 },
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
const result = schema.safeParse(body)
|
||||
|
||||
if (!result.success) {
|
||||
const errors = result.error.issues.map((issue) => ({
|
||||
field: issue.path.join('.'),
|
||||
message: issue.message,
|
||||
code: issue.code,
|
||||
}))
|
||||
|
||||
return {
|
||||
success: false,
|
||||
response: NextResponse.json(
|
||||
{
|
||||
error: 'Validation failed',
|
||||
type: 'validation_error',
|
||||
errors,
|
||||
},
|
||||
{ status: 400 },
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, data: result.data }
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate query parameters (from URL searchParams) against a Zod schema.
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* const params = validateQuery(request, VatDeclarationQuerySchema)
|
||||
* if (!params.success) return params.response
|
||||
* const { data } = params
|
||||
* ```
|
||||
*/
|
||||
export function validateQuery<T>(
|
||||
request: Request,
|
||||
schema: z.ZodType<T>,
|
||||
): ValidationResult<T> {
|
||||
const url = new URL(request.url)
|
||||
const raw = Object.fromEntries(url.searchParams.entries())
|
||||
|
||||
const result = schema.safeParse(raw)
|
||||
|
||||
if (!result.success) {
|
||||
const errors = result.error.issues.map((issue) => ({
|
||||
field: issue.path.join('.'),
|
||||
message: issue.message,
|
||||
code: issue.code,
|
||||
}))
|
||||
|
||||
return {
|
||||
success: false,
|
||||
response: NextResponse.json(
|
||||
{
|
||||
error: 'Invalid query parameters',
|
||||
type: 'validation_error',
|
||||
errors,
|
||||
},
|
||||
{ status: 400 },
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, data: result.data }
|
||||
}
|
||||
@@ -52,7 +52,7 @@ function generatePerRateLines(
|
||||
// Group items by vat_rate
|
||||
const rateGroups = new Map<number, { subtotal: number; vatAmount: number }>()
|
||||
for (const item of items) {
|
||||
const rate = item.vat_rate ?? 25
|
||||
const rate = item.vat_rate ?? 0
|
||||
const group = rateGroups.get(rate) || { subtotal: 0, vatAmount: 0 }
|
||||
group.subtotal += item.line_total
|
||||
group.vatAmount += item.vat_amount || 0
|
||||
|
||||
+3
-2
@@ -2,9 +2,10 @@ import { Resend } from 'resend'
|
||||
|
||||
// Default sender configuration
|
||||
// Using a fixed From address with dynamic Reply-To
|
||||
// From: "Företagsnamn via ERP Base" <fakturor@erp-base.se>
|
||||
// From: "Företagsnamn via ERP Base" <your-from@example.com>
|
||||
// Reply-To: user's company email from company_settings
|
||||
const DEFAULT_FROM_EMAIL = 'fakturor@arcim.io'
|
||||
// Set RESEND_FROM_EMAIL in .env.local to your verified Resend domain
|
||||
const DEFAULT_FROM_EMAIL = process.env.RESEND_FROM_EMAIL || 'noreply@localhost'
|
||||
|
||||
// Lazy initialization to avoid errors at build time
|
||||
let resendClient: Resend | null = null
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
getStatusLabel,
|
||||
getStatusVariant,
|
||||
getConfidenceLabel,
|
||||
formatExtractionSummary,
|
||||
} from '../invoice-inbox-utils'
|
||||
import type { InboxItemStatus } from '@/types'
|
||||
import type { InvoiceExtractionResult } from '@/extensions/general/invoice-inbox/types'
|
||||
|
||||
describe('getStatusLabel', () => {
|
||||
const cases: [InboxItemStatus, string][] = [
|
||||
['pending', 'Väntar'],
|
||||
['processing', 'Bearbetar'],
|
||||
['ready', 'Klar'],
|
||||
['confirmed', 'Bekräftad'],
|
||||
['rejected', 'Avvisad'],
|
||||
['error', 'Fel'],
|
||||
]
|
||||
|
||||
it.each(cases)('returns "%s" → "%s"', (status, expected) => {
|
||||
expect(getStatusLabel(status)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getStatusVariant', () => {
|
||||
const cases: [InboxItemStatus, string][] = [
|
||||
['pending', 'secondary'],
|
||||
['processing', 'default'],
|
||||
['ready', 'warning'],
|
||||
['confirmed', 'success'],
|
||||
['rejected', 'destructive'],
|
||||
['error', 'destructive'],
|
||||
]
|
||||
|
||||
it.each(cases)('returns "%s" → "%s"', (status, expected) => {
|
||||
expect(getStatusVariant(status)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getConfidenceLabel', () => {
|
||||
it('returns unknown for null', () => {
|
||||
expect(getConfidenceLabel(null)).toEqual({ label: 'Okänd', variant: 'outline' })
|
||||
})
|
||||
|
||||
it('returns high for >= 0.9', () => {
|
||||
expect(getConfidenceLabel(0.95)).toEqual({ label: 'Hög', variant: 'success' })
|
||||
expect(getConfidenceLabel(0.9)).toEqual({ label: 'Hög', variant: 'success' })
|
||||
})
|
||||
|
||||
it('returns medium for 0.7-0.89', () => {
|
||||
expect(getConfidenceLabel(0.85)).toEqual({ label: 'Medium', variant: 'warning' })
|
||||
expect(getConfidenceLabel(0.7)).toEqual({ label: 'Medium', variant: 'warning' })
|
||||
})
|
||||
|
||||
it('returns low for < 0.7', () => {
|
||||
expect(getConfidenceLabel(0.5)).toEqual({ label: 'Låg', variant: 'destructive' })
|
||||
expect(getConfidenceLabel(0.0)).toEqual({ label: 'Låg', variant: 'destructive' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatExtractionSummary', () => {
|
||||
it('handles null data', () => {
|
||||
expect(formatExtractionSummary(null)).toEqual({
|
||||
supplierName: '',
|
||||
total: 0,
|
||||
lineCount: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('handles undefined data', () => {
|
||||
expect(formatExtractionSummary(undefined)).toEqual({
|
||||
supplierName: '',
|
||||
total: 0,
|
||||
lineCount: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('extracts summary from complete data', () => {
|
||||
const data: InvoiceExtractionResult = {
|
||||
supplier: {
|
||||
name: 'Acme AB',
|
||||
orgNumber: '556123-4567',
|
||||
vatNumber: null,
|
||||
address: null,
|
||||
bankgiro: null,
|
||||
plusgiro: null,
|
||||
},
|
||||
invoice: {
|
||||
invoiceNumber: 'INV-001',
|
||||
invoiceDate: '2025-01-15',
|
||||
dueDate: '2025-02-15',
|
||||
paymentReference: null,
|
||||
currency: 'SEK',
|
||||
},
|
||||
lineItems: [
|
||||
{ description: 'Item 1', quantity: 1, unitPrice: 100, lineTotal: 100, vatRate: 25, accountSuggestion: null },
|
||||
{ description: 'Item 2', quantity: 2, unitPrice: 50, lineTotal: 100, vatRate: 25, accountSuggestion: null },
|
||||
],
|
||||
totals: { subtotal: 200, vatAmount: 50, total: 250 },
|
||||
vatBreakdown: [{ rate: 25, base: 200, amount: 50 }],
|
||||
confidence: 0.92,
|
||||
}
|
||||
|
||||
expect(formatExtractionSummary(data)).toEqual({
|
||||
supplierName: 'Acme AB',
|
||||
total: 250,
|
||||
lineCount: 2,
|
||||
})
|
||||
})
|
||||
|
||||
it('handles null supplier name', () => {
|
||||
const data: InvoiceExtractionResult = {
|
||||
supplier: { name: null, orgNumber: null, vatNumber: null, address: null, bankgiro: null, plusgiro: null },
|
||||
invoice: { invoiceNumber: null, invoiceDate: null, dueDate: null, paymentReference: null, currency: 'SEK' },
|
||||
lineItems: [],
|
||||
totals: { subtotal: null, vatAmount: null, total: null },
|
||||
vatBreakdown: [],
|
||||
confidence: 0.5,
|
||||
}
|
||||
|
||||
expect(formatExtractionSummary(data)).toEqual({
|
||||
supplierName: '',
|
||||
total: 0,
|
||||
lineCount: 0,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { readFileSync } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
|
||||
describe('type decoupling', () => {
|
||||
it('types/index.ts must not import from @/extensions/', () => {
|
||||
const typesPath = resolve(__dirname, '../../../types/index.ts')
|
||||
const content = readFileSync(typesPath, 'utf-8')
|
||||
|
||||
const extensionImports = content
|
||||
.split('\n')
|
||||
.filter((line) => /from\s+['"]@\/extensions\//.test(line))
|
||||
|
||||
expect(extensionImports).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { InboxItemStatus } from '@/types'
|
||||
import type { InvoiceExtractionResult } from '@/extensions/general/invoice-inbox/types'
|
||||
|
||||
const STATUS_LABELS: Record<InboxItemStatus, string> = {
|
||||
pending: 'Väntar',
|
||||
processing: 'Bearbetar',
|
||||
ready: 'Klar',
|
||||
confirmed: 'Bekräftad',
|
||||
rejected: 'Avvisad',
|
||||
error: 'Fel',
|
||||
}
|
||||
|
||||
export function getStatusLabel(status: InboxItemStatus): string {
|
||||
return STATUS_LABELS[status] ?? status
|
||||
}
|
||||
|
||||
const STATUS_VARIANTS: Record<InboxItemStatus, string> = {
|
||||
pending: 'secondary',
|
||||
processing: 'default',
|
||||
ready: 'warning',
|
||||
confirmed: 'success',
|
||||
rejected: 'destructive',
|
||||
error: 'destructive',
|
||||
}
|
||||
|
||||
export function getStatusVariant(status: InboxItemStatus): string {
|
||||
return STATUS_VARIANTS[status] ?? 'outline'
|
||||
}
|
||||
|
||||
export function getConfidenceLabel(confidence: number | null): { label: string; variant: string } {
|
||||
if (confidence == null) {
|
||||
return { label: 'Okänd', variant: 'outline' }
|
||||
}
|
||||
if (confidence >= 0.9) {
|
||||
return { label: 'Hög', variant: 'success' }
|
||||
}
|
||||
if (confidence >= 0.7) {
|
||||
return { label: 'Medium', variant: 'warning' }
|
||||
}
|
||||
return { label: 'Låg', variant: 'destructive' }
|
||||
}
|
||||
|
||||
export function formatExtractionSummary(
|
||||
data: InvoiceExtractionResult | null | undefined
|
||||
): { supplierName: string; total: number; lineCount: number } {
|
||||
if (!data) {
|
||||
return { supplierName: '', total: 0, lineCount: 0 }
|
||||
}
|
||||
return {
|
||||
supplierName: data.supplier?.name ?? '',
|
||||
total: data.totals?.total ?? 0,
|
||||
lineCount: data.lineItems?.length ?? 0,
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ const WORKSPACES: Record<WorkspaceKey, ComponentType<WorkspaceComponentProps>> =
|
||||
'general/ai-categorization': dynamic(() => import('@/components/extensions/general/AiCategorizationWorkspace')),
|
||||
'general/ai-chat': dynamic(() => import('@/components/extensions/general/AiChatWorkspace')),
|
||||
'general/push-notifications': dynamic(() => import('@/components/extensions/general/PushNotificationsWorkspace')),
|
||||
'general/invoice-inbox': dynamic(() => import('@/components/extensions/general/InvoiceInboxWorkspace')),
|
||||
'general/enable-banking': dynamic(() => import('@/components/extensions/general/EnableBankingWorkspace')),
|
||||
// Restaurant
|
||||
'restaurant/food-cost': dynamic(() => import('@/components/extensions/restaurant/FoodCostWorkspace')),
|
||||
|
||||
@@ -289,7 +289,7 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
|
||||
const vatByRate = new Map<number, { base: number; vat: number }>()
|
||||
if (hasPerLineVat) {
|
||||
for (const item of items) {
|
||||
const rate = item.vat_rate ?? 25
|
||||
const rate = item.vat_rate ?? 0
|
||||
const group = vatByRate.get(rate) || { base: 0, vat: 0 }
|
||||
group.base += Math.abs(item.line_total)
|
||||
group.vat += Math.abs(item.vat_amount || 0)
|
||||
@@ -411,7 +411,7 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
|
||||
<Text style={styles.colPrice}>{formatCurrency(item.unit_price, invoice.currency)}</Text>
|
||||
)}
|
||||
{!isDeliveryNote && showVatColumn && (
|
||||
<Text style={styles.colVat}>{item.vat_rate ?? 25}%</Text>
|
||||
<Text style={styles.colVat}>{item.vat_rate ?? 0}%</Text>
|
||||
)}
|
||||
{!isDeliveryNote && (
|
||||
<Text style={styles.colTotal}>{formatCurrency(item.line_total, invoice.currency)}</Text>
|
||||
@@ -440,7 +440,7 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
|
||||
))
|
||||
) : (
|
||||
<View style={styles.totalRow}>
|
||||
<Text style={styles.totalLabel}>Moms ({invoice.vat_rate ?? (vatByRate.size === 1 ? vatByRate.keys().next().value : 25)}%):</Text>
|
||||
<Text style={styles.totalLabel}>Moms ({invoice.vat_rate ?? (vatByRate.size === 1 ? vatByRate.keys().next().value : 0)}%):</Text>
|
||||
<Text style={styles.totalValue}>{formatCurrency(invoice.vat_amount, invoice.currency)}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -162,7 +162,7 @@ export function getVatTreatmentLabel(treatment: VatTreatment): string {
|
||||
export function getVatSummaryFromItems(
|
||||
items: { vat_rate?: number | null }[]
|
||||
): { label: string; treatment: VatTreatment | null; rate: number | null; isMixed: boolean } {
|
||||
const rates = new Set(items.map((item) => item.vat_rate ?? 25))
|
||||
const rates = new Set(items.map((item) => item.vat_rate ?? 0))
|
||||
|
||||
if (rates.size === 1) {
|
||||
const rate = rates.values().next().value!
|
||||
|
||||
+2
-1
@@ -1,7 +1,8 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
allowedDevOrigins: ["nonpedagogically-sphinxlike-dalila.ngrok-free.dev"],
|
||||
// Add your ngrok/tunnel domain here for local dev, e.g.:
|
||||
// allowedDevOrigins: ["your-subdomain.ngrok-free.dev"],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
+226
-16
@@ -1175,9 +1175,62 @@ export interface CreateDeadlineInput {
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Push Notification Types (canonical source: extensions/push-notifications/types.ts)
|
||||
// Push Notification Types
|
||||
// ============================================================
|
||||
export type { PushSubscription, NotificationSettings, NotificationType, NotificationLog } from '@/extensions/general/push-notifications/types'
|
||||
|
||||
// Push subscription for Web Push API
|
||||
export interface PushSubscription {
|
||||
id: string
|
||||
user_id: string
|
||||
endpoint: string
|
||||
p256dh: string
|
||||
auth: string
|
||||
user_agent: string | null
|
||||
is_active: boolean
|
||||
last_used_at: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// Notification settings per user
|
||||
export interface NotificationSettings {
|
||||
id: string
|
||||
user_id: string
|
||||
tax_deadlines_enabled: boolean
|
||||
invoice_reminders_enabled: boolean
|
||||
quiet_start: string // time format "HH:MM"
|
||||
quiet_end: string // time format "HH:MM"
|
||||
email_enabled: boolean
|
||||
push_enabled: boolean
|
||||
period_locked_enabled: boolean
|
||||
period_year_closed_enabled: boolean
|
||||
invoice_sent_enabled: boolean
|
||||
receipt_extracted_enabled: boolean
|
||||
receipt_matched_enabled: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// Notification type for logging
|
||||
export type NotificationType =
|
||||
| 'tax_deadline'
|
||||
| 'invoice_due'
|
||||
| 'invoice_overdue'
|
||||
| 'period_locked'
|
||||
| 'period_year_closed'
|
||||
| 'receipt_extracted'
|
||||
| 'receipt_matched'
|
||||
| 'invoice_sent'
|
||||
|
||||
// Notification log entry
|
||||
export interface NotificationLog {
|
||||
id: string
|
||||
user_id: string
|
||||
notification_type: NotificationType
|
||||
reference_id: string
|
||||
days_before: number
|
||||
sent_at: string
|
||||
delivery_status: 'sent' | 'delivered' | 'failed'
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Calendar Feed Types (ICS)
|
||||
@@ -1301,10 +1354,178 @@ export interface InvoiceInboxItem {
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Receipt Types (canonical source: extensions/receipt-ocr/types.ts)
|
||||
// Receipt Types
|
||||
// ============================================================
|
||||
export type { ReceiptStatus, Receipt, ReceiptLineItem, ReceiptExtractionResult, ExtractedLineItem, ReceiptMatchCandidate, CreateReceiptInput, ConfirmReceiptInput, ConfirmLineItemInput, ReceiptQueueSummary, CameraQualityFeedback } from '@/extensions/general/receipt-ocr/types'
|
||||
export { RECEIPT_STATUS_LABELS } from '@/extensions/general/receipt-ocr/types'
|
||||
|
||||
// Receipt extraction status
|
||||
export type ReceiptStatus = 'pending' | 'processing' | 'extracted' | 'confirmed' | 'error'
|
||||
|
||||
// Receipt record
|
||||
export interface Receipt {
|
||||
id: string
|
||||
user_id: string
|
||||
|
||||
// Image storage
|
||||
image_url: string
|
||||
image_thumbnail_url: string | null
|
||||
|
||||
// Extraction status
|
||||
status: ReceiptStatus
|
||||
extraction_confidence: number | null
|
||||
|
||||
// Extracted header data
|
||||
merchant_name: string | null
|
||||
merchant_org_number: string | null
|
||||
merchant_vat_number: string | null
|
||||
receipt_date: string | null
|
||||
receipt_time: string | null
|
||||
total_amount: number | null
|
||||
currency: string
|
||||
vat_amount: number | null
|
||||
|
||||
// Special flags
|
||||
is_restaurant: boolean
|
||||
is_systembolaget: boolean
|
||||
is_foreign_merchant: boolean
|
||||
|
||||
// Restaurant representation data
|
||||
representation_persons: number | null
|
||||
representation_purpose: string | null
|
||||
|
||||
// Transaction matching
|
||||
matched_transaction_id: string | null
|
||||
match_confidence: number | null
|
||||
|
||||
// Raw extraction data
|
||||
raw_extraction: ReceiptExtractionResult | null
|
||||
|
||||
created_at: string
|
||||
updated_at: string
|
||||
|
||||
// Relations (populated when fetched)
|
||||
line_items?: ReceiptLineItem[]
|
||||
matched_transaction?: Transaction
|
||||
}
|
||||
|
||||
// Receipt line item record
|
||||
export interface ReceiptLineItem {
|
||||
id: string
|
||||
receipt_id: string
|
||||
|
||||
// Extracted data
|
||||
description: string
|
||||
quantity: number
|
||||
unit_price: number | null
|
||||
line_total: number
|
||||
vat_rate: number | null
|
||||
vat_amount: number | null
|
||||
|
||||
// Classification
|
||||
is_business: boolean | null
|
||||
category: TransactionCategory | null
|
||||
bas_account: string | null
|
||||
|
||||
// Confidence
|
||||
extraction_confidence: number | null
|
||||
suggested_category: string | null
|
||||
|
||||
sort_order: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// AI extraction result from Claude Vision
|
||||
export interface ReceiptExtractionResult {
|
||||
merchant: {
|
||||
name: string | null
|
||||
orgNumber: string | null
|
||||
vatNumber: string | null
|
||||
isForeign: boolean
|
||||
}
|
||||
receipt: {
|
||||
date: string | null
|
||||
time: string | null
|
||||
currency: string
|
||||
}
|
||||
lineItems: ExtractedLineItem[]
|
||||
totals: {
|
||||
subtotal: number | null
|
||||
vatAmount: number | null
|
||||
total: number | null
|
||||
}
|
||||
flags: {
|
||||
isRestaurant: boolean
|
||||
isSystembolaget: boolean
|
||||
isForeignMerchant: boolean
|
||||
}
|
||||
confidence: number
|
||||
}
|
||||
|
||||
// Extracted line item from AI
|
||||
export interface ExtractedLineItem {
|
||||
description: string
|
||||
quantity: number
|
||||
unitPrice: number | null
|
||||
lineTotal: number
|
||||
vatRate: number | null
|
||||
suggestedCategory: string | null
|
||||
confidence?: number
|
||||
}
|
||||
|
||||
// Match candidate for receipt-to-transaction matching
|
||||
export interface ReceiptMatchCandidate {
|
||||
transaction: Transaction
|
||||
confidence: number
|
||||
matchReasons: string[]
|
||||
dateVariance: number
|
||||
amountVariance: number
|
||||
}
|
||||
|
||||
// Input for creating a receipt
|
||||
export interface CreateReceiptInput {
|
||||
image_url: string
|
||||
image_thumbnail_url?: string
|
||||
}
|
||||
|
||||
// Input for confirming receipt line items
|
||||
export interface ConfirmReceiptInput {
|
||||
line_items: ConfirmLineItemInput[]
|
||||
matched_transaction_id?: string
|
||||
representation_persons?: number
|
||||
representation_purpose?: string
|
||||
}
|
||||
|
||||
export interface ConfirmLineItemInput {
|
||||
id: string
|
||||
is_business: boolean
|
||||
category?: TransactionCategory
|
||||
bas_account?: string
|
||||
}
|
||||
|
||||
// Receipt queue summary
|
||||
export interface ReceiptQueueSummary {
|
||||
unmatched_receipts_count: number
|
||||
unmatched_transactions_count: number
|
||||
pending_review_count: number
|
||||
streak_count: number
|
||||
}
|
||||
|
||||
// Camera quality feedback
|
||||
export interface CameraQualityFeedback {
|
||||
lightingOk: boolean
|
||||
distanceOk: boolean
|
||||
focusOk: boolean
|
||||
readyToCapture: boolean
|
||||
message?: string
|
||||
}
|
||||
|
||||
// Swedish labels for receipt status
|
||||
export const RECEIPT_STATUS_LABELS: Record<ReceiptStatus, string> = {
|
||||
pending: 'Väntar',
|
||||
processing: 'Analyserar',
|
||||
extracted: 'Extraherat',
|
||||
confirmed: 'Bekräftat',
|
||||
error: 'Fel'
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// VAT Declaration Types (Momsdeklaration)
|
||||
@@ -1377,17 +1598,6 @@ export interface VatDeclarationRequest {
|
||||
period: number
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// NE Declaration Types (canonical source: extensions/ne-bilaga/types.ts)
|
||||
// ============================================================
|
||||
export type { NEDeclarationRutor, NEAccountMapping, NEDeclaration, SRURecord, SRUFile } from '@/extensions/ne-bilaga/types'
|
||||
export { NE_RUTA_LABELS } from '@/extensions/ne-bilaga/types'
|
||||
|
||||
// ============================================================
|
||||
// SRU Export Types (canonical source: extensions/sru-export/types.ts)
|
||||
// ============================================================
|
||||
export type { SRUExportResult, SRUCoverageStats } from '@/extensions/sru-export/types'
|
||||
|
||||
// Labels for VAT rutor
|
||||
export const VAT_RUTA_LABELS: Record<keyof VatDeclarationRutor, string> = {
|
||||
ruta05: 'Utgående moms 25%',
|
||||
|
||||
Reference in New Issue
Block a user