diff --git a/app/(dashboard)/invoices/[id]/page.tsx b/app/(dashboard)/invoices/[id]/page.tsx index 7db0074d..ad2ad852 100644 --- a/app/(dashboard)/invoices/[id]/page.tsx +++ b/app/(dashboard)/invoices/[id]/page.tsx @@ -32,6 +32,7 @@ import { Trash2, } from 'lucide-react' import PaymentBookingDialog from '@/components/invoices/PaymentBookingDialog' +import SendInvoiceDialog from '@/components/invoices/SendInvoiceDialog' import { Dialog, DialogContent, @@ -76,11 +77,12 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st const [originalInvoice, setOriginalInvoice] = useState(null) const [convertedFromInvoice, setConvertedFromInvoice] = useState(null) const [showPaymentDialog, setShowPaymentDialog] = useState(false) + const [showSendDialog, setShowSendDialog] = useState(false) + const [sendDialogMode, setSendDialogMode] = useState<'email' | 'manual'>('email') const [isConverting, setIsConverting] = useState(false) const [isLoading, setIsLoading] = useState(true) const [isUpdating, setIsUpdating] = useState(false) const [isDownloading, setIsDownloading] = useState(false) - const [isSendingEmail, setIsSendingEmail] = useState(false) const [showDeleteDialog, setShowDeleteDialog] = useState(false) const [isDeleting, setIsDeleting] = useState(false) @@ -224,48 +226,9 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st setIsUpdating(false) } - async function sendInvoiceEmail() { - if (!invoice) return - - // Check if customer has email - if (!invoice.customer.email) { - toast({ - title: 'E-post saknas', - description: 'Kunden saknar e-postadress. Uppdatera kunduppgifterna först.', - variant: 'destructive', - }) - return - } - - setIsSendingEmail(true) - - try { - const response = await fetch(`/api/invoices/${invoice.id}/send`, { - method: 'POST', - }) - - const data = await response.json() - - if (!response.ok) { - throw new Error(data.error || 'Kunde inte skicka fakturan') - } - - toast({ - title: 'Faktura skickad', - description: data.message, - }) - - // Refresh to get updated status - fetchInvoice() - } catch (error) { - toast({ - title: 'Kunde inte skicka faktura', - description: error instanceof Error ? error.message : 'Försök igen.', - variant: 'destructive', - }) - } - - setIsSendingEmail(false) + function openSendDialog(mode: 'email' | 'manual') { + setSendDialogMode(mode) + setShowSendDialog(true) } async function convertToInvoice() { @@ -433,16 +396,12 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st )} {invoice.status === 'draft' && !isDeliveryNote && ( customerHasEmail ? ( - ) : ( - @@ -451,7 +410,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st {isDeliveryNote && invoice.status === 'draft' && ( )} {(invoice.status === 'sent' || invoice.status === 'overdue') && isRealInvoice && ( @@ -880,21 +839,15 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st <> + ) : ( + <> + + + + )} + + + + ) +} diff --git a/components/transactions/QuickReviewDialog.tsx b/components/transactions/QuickReviewDialog.tsx index d8aa014f..f007108c 100644 --- a/components/transactions/QuickReviewDialog.tsx +++ b/components/transactions/QuickReviewDialog.tsx @@ -77,9 +77,9 @@ export default function QuickReviewDialog({ async function fetchAccounts() { try { const res = await fetch('/api/bookkeeping/accounts') - const data = await res.json() - if (data.accounts) { - setAccounts(data.accounts) + const { data } = await res.json() + if (data) { + setAccounts(data) } } catch { // Non-critical diff --git a/components/transactions/SwipeCategorizationView.tsx b/components/transactions/SwipeCategorizationView.tsx index a6c12f28..c0554141 100644 --- a/components/transactions/SwipeCategorizationView.tsx +++ b/components/transactions/SwipeCategorizationView.tsx @@ -78,9 +78,9 @@ export default function SwipeCategorizationView({ async function fetchAccounts() { try { const res = await fetch('/api/bookkeeping/accounts') - const data = await res.json() - if (data.accounts) { - setAccounts(data.accounts) + const { data } = await res.json() + if (data) { + setAccounts(data) } } catch { // Non-critical, AccountCombobox will just be empty diff --git a/components/transactions/TransactionInboxCard.tsx b/components/transactions/TransactionInboxCard.tsx index a00acf4c..12a44743 100644 --- a/components/transactions/TransactionInboxCard.tsx +++ b/components/transactions/TransactionInboxCard.tsx @@ -202,7 +202,7 @@ export default function TransactionInboxCard({ {ts.name_sv} - D: {ts.debit_account} → K: {ts.credit_account} + D: {formatAccountWithName(ts.debit_account)} → K: {formatAccountWithName(ts.credit_account)} diff --git a/lib/bookkeeping/client-account-names.ts b/lib/bookkeeping/client-account-names.ts index fbadd878..ede4cb22 100644 --- a/lib/bookkeeping/client-account-names.ts +++ b/lib/bookkeeping/client-account-names.ts @@ -40,6 +40,12 @@ const ACCOUNT_NAMES: Record = { // Cost of goods (4xxx) '4010': 'Varuinköp', + '4060': 'Varuinköp omvänd moms', + '4070': 'Varuinköp EU', + '4100': 'Inköp material/varor', + '4500': 'Övriga inköpskostnader', + '4531': 'Import-/tullkostnader', + '4600': 'Subentreprenader', // External expenses (5xxx) '5010': 'Lokalhyra', diff --git a/lib/bookkeeping/propose-send-lines.ts b/lib/bookkeeping/propose-send-lines.ts new file mode 100644 index 00000000..122a0146 --- /dev/null +++ b/lib/bookkeeping/propose-send-lines.ts @@ -0,0 +1,158 @@ +/** + * Pure function to compute proposed journal entry lines for sending an invoice. + * Used by the SendInvoiceDialog to preview the journal entry before committing. + * + * No DB or Supabase dependency — all inputs are plain data. + */ +import { resolveSekAmount } from './currency-utils' +import { getRevenueAccount, getOutputVatAccount } from './invoice-entries' +import { getVatTreatmentForRate } from '@/lib/invoices/vat-rules' +import type { FormLine } from '@/components/bookkeeping/JournalEntryForm' +import type { EntityType, InvoiceItem, VatTreatment } from '@/types' + +export interface ProposeSendLinesInput { + invoice: { + invoice_number: string + total: number + total_sek?: number | null + subtotal: number + subtotal_sek?: number | null + vat_amount: number + vat_amount_sek?: number | null + currency: string + exchange_rate?: number | null + vat_treatment: VatTreatment + items?: InvoiceItem[] + } + entityType: EntityType +} + +function toFormAmount(n: number): string { + const rounded = Math.round(n * 100) / 100 + return rounded === 0 ? '' : rounded.toString() +} + +/** + * Propose journal entry lines for an invoice send (accrual method). + * + * Debit 1510 Kundfordringar [total incl VAT] + * Credit 30xx Försäljning [subtotal per rate] + * Credit 26xx Utgående moms [VAT per rate] + */ +export function proposeSendLines(input: ProposeSendLinesInput): FormLine[] { + const { invoice, entityType } = input + const lines: FormLine[] = [] + const isForeign = invoice.currency !== 'SEK' + const desc = `Försäljning faktura ${invoice.invoice_number}` + + const toSek = (amount: number): number => { + if (!isForeign) return amount + if (invoice.exchange_rate != null && invoice.exchange_rate > 0) { + return Math.round(amount * invoice.exchange_rate * 100) / 100 + } + return amount + } + + // Build credit lines per VAT rate group + const creditLines: FormLine[] = [] + + if (invoice.items && invoice.items.length > 0) { + const hasPerLineVat = invoice.items.some((item) => item.vat_rate !== undefined && item.vat_rate !== null) + + if (!hasPerLineVat) { + // Legacy: single rate from invoice level + const revenueAccount = getRevenueAccount(invoice.vat_treatment, entityType) + const subtotal = invoice.items.reduce((sum, item) => sum + item.line_total, 0) + creditLines.push({ + account_number: revenueAccount, + debit_amount: '', + credit_amount: toFormAmount(toSek(subtotal)), + line_description: desc, + }) + + const totalVat = invoice.items.reduce((sum, item) => sum + (item.vat_amount || 0), 0) + if (totalVat > 0) { + const vatAccount = getOutputVatAccount(invoice.vat_treatment) + creditLines.push({ + account_number: vatAccount, + debit_amount: '', + credit_amount: toFormAmount(toSek(totalVat)), + line_description: 'Utgående moms', + }) + } + } else { + // Group items by vat_rate + const rateGroups = new Map() + for (const item of invoice.items) { + 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 + rateGroups.set(rate, group) + } + + for (const [rate, group] of rateGroups) { + const treatment = rate === 0 && (invoice.vat_treatment === 'reverse_charge' || invoice.vat_treatment === 'export') + ? invoice.vat_treatment + : getVatTreatmentForRate(rate) + const revenueAccount = getRevenueAccount(treatment, entityType) + + creditLines.push({ + account_number: revenueAccount, + debit_amount: '', + credit_amount: toFormAmount(Math.round(toSek(group.subtotal) * 100) / 100), + line_description: desc, + }) + + const roundedVat = Math.round(toSek(group.vatAmount) * 100) / 100 + if (roundedVat !== 0) { + const vatAccount = getOutputVatAccount(treatment) + creditLines.push({ + account_number: vatAccount, + debit_amount: '', + credit_amount: toFormAmount(roundedVat), + line_description: `Utgående moms ${rate}%`, + }) + } + } + } + } else { + // Fallback: invoice-level amounts + const revenueAccount = getRevenueAccount(invoice.vat_treatment, entityType) + const subtotalSek = resolveSekAmount(invoice.subtotal, invoice.subtotal_sek, invoice.currency, invoice.exchange_rate) + creditLines.push({ + account_number: revenueAccount, + debit_amount: '', + credit_amount: toFormAmount(subtotalSek), + line_description: desc, + }) + + if (invoice.vat_amount > 0) { + const vatSek = resolveSekAmount(invoice.vat_amount, invoice.vat_amount_sek, invoice.currency, invoice.exchange_rate) + const vatAccount = getOutputVatAccount(invoice.vat_treatment) + creditLines.push({ + account_number: vatAccount, + debit_amount: '', + credit_amount: toFormAmount(vatSek), + line_description: `Utgående moms faktura ${invoice.invoice_number}`, + }) + } + } + + // Debit: 1510 Kundfordringar — balance guarantee + const totalCredits = creditLines.reduce((sum, l) => sum + (parseFloat(l.credit_amount) || 0), 0) + const debitAmount = isForeign + ? Math.round(totalCredits * 100) / 100 + : resolveSekAmount(invoice.total, invoice.total_sek, invoice.currency, invoice.exchange_rate) + + lines.push({ + account_number: '1510', + debit_amount: toFormAmount(debitAmount), + credit_amount: '', + line_description: desc, + }) + + lines.push(...creditLines) + + return lines +}