feat: per-line VAT, invoice document types, ledger-based VAT declaration, bank reconciliation, and pagination
Per-line VAT rates: - Add generatePerRateLines() to group invoice items by vat_rate with separate revenue + VAT lines per rate group (invoice-entries.ts) - Add getAvailableVatRates() and getVatTreatmentForRate() (vat-rules.ts) - PDF template shows per-line VAT column and per-rate totals for mixed-rate invoices - Invoice create/review UI supports per-line rate selection - Types: add vat_rate/vat_amount to InvoiceItem, vat_rate to CreateInvoiceItemInput Invoice document types (proforma, delivery note): - Add InvoiceDocumentType, document_type and converted_from_id to Invoice type - PDF hides prices for delivery notes, adds proforma notice - Email templates support all document types - mark-paid skips journal entries for non-invoice document types - Migration 031: invoice_document_type Accounting method support: - Add AccountingMethod type (accrual/cash) - Migration 032: add_accounting_method column to company_settings VAT declaration rewrite: - Rewrite to read directly from general ledger (26xx/3xxx account lines) instead of aggregating invoices/transactions/receipts - ACCOUNT_RUTA mapping drives momsdeklaration boxes from GL balances Bank reconciliation: - Transaction ingest now pre-fetches unlinked GL lines and attempts auto-reconciliation during import - Add transaction.reconciled event type - Add ReconciliationMethod type and reconciliation_method on Transaction - Migration 030: bank_reconciliation - New reconciliation engine, API routes, and BankReconciliationView component Pagination (fetchAllRows): - New lib/supabase/fetch-all.ts overcomes PostgREST 1000-row limit - Adopted in all report generators, SIE/SRU export, account list APIs Fiscal period validation: - New validate-period-duration.ts enforces max 18 months per BFL 3 kap. - Applied in period-service.ts and fiscal-periods API Account mapper simplification: - Remove Levenshtein/fuzzy matching, use exact account number match only Swedbank parser improvements: - Support abbreviated headers (Clnr, Bokfdag, Radnr) - Use Referens column as counterparty Chart of accounts management: - Add DELETE endpoint with system account and usage protection - PUT uses partial updates - New AccountCombobox, AddAccountDialog, EditAccountDialog, ChartOfAccountsManager Tax deadline corrections: - Rewrite inkomstdeklaration_ab using Skatteverket lookup table - Rewrite arsredovisning deadline to 7 months after FY end per ÅRL 8:3 Onboarding first fiscal year: - Add first fiscal year toggle with date pickers and 18-month validation UI terminology: - Change "okategoriserad/kategorisera" to "obokförd/bokföra" throughout Report column fix: - Fix start_date/end_date to period_start/period_end in report queries Supplier invoice input: - CreateSupplierInvoiceItemInput uses amount field (legacy quantity/unit_price kept) Misc: - SIE import uses upsert for idempotent account creation - account-descriptions.ts falls back to BAS reference data - Add invoice_default_notes to CompanySettings - Update CLAUDE.md to reflect current project state Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
f1d187c005
commit
91e2c1705a
@@ -0,0 +1,137 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import type { Invoice } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* POST /api/invoices/[id]/convert
|
||||
*
|
||||
* Converts a proforma invoice to a real invoice.
|
||||
* Copies all data, generates a real invoice number, and marks the proforma as cancelled.
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Fetch proforma with items
|
||||
const { data: proforma, error: proformaError } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, items:invoice_items(*)')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (proformaError || !proforma) {
|
||||
return NextResponse.json({ error: 'Proformafakturan hittades inte' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (proforma.document_type !== 'proforma') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Endast proformafakturor kan konverteras' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (proforma.status === 'cancelled') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Denna proformafaktura har redan makuleras' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Generate real invoice number
|
||||
const { data: invoiceNumber } = await supabase.rpc('generate_invoice_number', {
|
||||
p_user_id: user.id,
|
||||
})
|
||||
|
||||
// Create the real invoice
|
||||
const { data: invoice, error: invoiceError } = await supabase
|
||||
.from('invoices')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
customer_id: proforma.customer_id,
|
||||
invoice_number: invoiceNumber,
|
||||
invoice_date: new Date().toISOString().split('T')[0],
|
||||
due_date: proforma.due_date,
|
||||
currency: proforma.currency,
|
||||
exchange_rate: proforma.exchange_rate,
|
||||
exchange_rate_date: proforma.exchange_rate_date,
|
||||
subtotal: proforma.subtotal,
|
||||
subtotal_sek: proforma.subtotal_sek,
|
||||
vat_amount: proforma.vat_amount,
|
||||
vat_amount_sek: proforma.vat_amount_sek,
|
||||
total: proforma.total,
|
||||
total_sek: proforma.total_sek,
|
||||
vat_treatment: proforma.vat_treatment,
|
||||
vat_rate: proforma.vat_rate,
|
||||
moms_ruta: proforma.moms_ruta,
|
||||
reverse_charge_text: proforma.reverse_charge_text,
|
||||
your_reference: proforma.your_reference,
|
||||
our_reference: proforma.our_reference,
|
||||
notes: proforma.notes,
|
||||
document_type: 'invoice',
|
||||
converted_from_id: id,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (invoiceError) {
|
||||
return NextResponse.json({ error: invoiceError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Copy invoice items
|
||||
const items = (proforma.items || []).map((item: { sort_order: number; description: string; quantity: number; unit: string; unit_price: number; line_total: number }) => ({
|
||||
invoice_id: invoice.id,
|
||||
sort_order: item.sort_order,
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
unit_price: item.unit_price,
|
||||
line_total: item.line_total,
|
||||
}))
|
||||
|
||||
if (items.length > 0) {
|
||||
const { error: itemsError } = await supabase
|
||||
.from('invoice_items')
|
||||
.insert(items)
|
||||
|
||||
if (itemsError) {
|
||||
await supabase.from('invoices').delete().eq('id', invoice.id)
|
||||
return NextResponse.json({ error: itemsError.message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// Mark proforma as cancelled
|
||||
await supabase
|
||||
.from('invoices')
|
||||
.update({ status: 'cancelled' })
|
||||
.eq('id', id)
|
||||
|
||||
// Fetch complete invoice
|
||||
const { data: completeInvoice } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*), items:invoice_items(*)')
|
||||
.eq('id', invoice.id)
|
||||
.single()
|
||||
|
||||
if (completeInvoice) {
|
||||
await eventBus.emit({
|
||||
type: 'invoice.created',
|
||||
payload: { invoice: completeInvoice as Invoice, userId: user.id },
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: completeInvoice })
|
||||
}
|
||||
@@ -77,29 +77,33 @@ export async function POST(
|
||||
const accountingMethod = settings?.accounting_method || 'accrual'
|
||||
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
||||
|
||||
// Only create journal entries for real invoices (not proformas or delivery notes)
|
||||
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
|
||||
let journalEntryId: string | null = null
|
||||
|
||||
try {
|
||||
if (accountingMethod === 'accrual') {
|
||||
// Faktureringsmetoden: clear receivable (Debit 1930, Credit 1510)
|
||||
const journalEntry = await createInvoicePaymentJournalEntry(
|
||||
user.id,
|
||||
invoice as Invoice,
|
||||
paymentDate
|
||||
)
|
||||
journalEntryId = journalEntry?.id ?? null
|
||||
} else {
|
||||
// Kontantmetoden: combined revenue entry (Debit 1930, Credit 30xx, Credit 26xx)
|
||||
const journalEntry = await createInvoiceCashEntry(
|
||||
user.id,
|
||||
invoice as Invoice,
|
||||
paymentDate,
|
||||
entityType
|
||||
)
|
||||
journalEntryId = journalEntry?.id ?? null
|
||||
if (isRealInvoice) {
|
||||
try {
|
||||
if (accountingMethod === 'accrual') {
|
||||
// Faktureringsmetoden: clear receivable (Debit 1930, Credit 1510)
|
||||
const journalEntry = await createInvoicePaymentJournalEntry(
|
||||
user.id,
|
||||
invoice as Invoice,
|
||||
paymentDate
|
||||
)
|
||||
journalEntryId = journalEntry?.id ?? null
|
||||
} else {
|
||||
// Kontantmetoden: combined revenue entry (Debit 1930, Credit 30xx, Credit 26xx)
|
||||
const journalEntry = await createInvoiceCashEntry(
|
||||
user.id,
|
||||
invoice as Invoice,
|
||||
paymentDate,
|
||||
entityType
|
||||
)
|
||||
journalEntryId = journalEntry?.id ?? null
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create payment journal entry on mark-paid:', err)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create payment journal entry on mark-paid:', err)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -45,10 +45,7 @@ export async function POST(
|
||||
// Update status to sent
|
||||
const { error: updateError } = await supabase
|
||||
.from('invoices')
|
||||
.update({
|
||||
status: 'sent',
|
||||
sent_at: new Date().toISOString(),
|
||||
})
|
||||
.update({ status: 'sent' })
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
|
||||
@@ -65,9 +62,10 @@ export async function POST(
|
||||
|
||||
const accountingMethod = settings?.accounting_method || 'accrual'
|
||||
|
||||
// Faktureringsmetoden: book at send
|
||||
// Only create journal entries for real invoices (not proformas or delivery notes)
|
||||
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
|
||||
let journalEntryId: string | null = null
|
||||
if (accountingMethod === 'accrual') {
|
||||
if (isRealInvoice && accountingMethod === 'accrual') {
|
||||
try {
|
||||
const journalEntry = await createInvoiceJournalEntry(
|
||||
user.id,
|
||||
|
||||
@@ -113,11 +113,19 @@ export async function POST(
|
||||
company: company as CompanySettings
|
||||
}
|
||||
|
||||
// Determine filename
|
||||
// Determine filename based on document type
|
||||
const isCreditNote = !!invoice.credited_invoice_id
|
||||
const filename = isCreditNote
|
||||
? `kreditfaktura-${invoice.invoice_number}.pdf`
|
||||
: `faktura-${invoice.invoice_number}.pdf`
|
||||
const docType = invoice.document_type || 'invoice'
|
||||
let filename: string
|
||||
if (isCreditNote) {
|
||||
filename = `kreditfaktura-${invoice.invoice_number}.pdf`
|
||||
} else if (docType === 'proforma') {
|
||||
filename = `proformafaktura-${invoice.invoice_number}.pdf`
|
||||
} else if (docType === 'delivery_note') {
|
||||
filename = `foljesedel-${invoice.invoice_number}.pdf`
|
||||
} else {
|
||||
filename = `faktura-${invoice.invoice_number}.pdf`
|
||||
}
|
||||
|
||||
// Send email
|
||||
const result = await sendEmail({
|
||||
@@ -144,13 +152,10 @@ export async function POST(
|
||||
)
|
||||
}
|
||||
|
||||
// Update invoice status to "sent" and set sent_at timestamp
|
||||
// Update invoice status to "sent"
|
||||
const { error: updateError } = await supabase
|
||||
.from('invoices')
|
||||
.update({
|
||||
status: 'sent',
|
||||
sent_at: new Date().toISOString()
|
||||
})
|
||||
.update({ status: 'sent' })
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
|
||||
@@ -159,8 +164,9 @@ export async function POST(
|
||||
// Don't fail the request - the email was sent successfully
|
||||
}
|
||||
|
||||
// Faktureringsmetoden: create journal entry when invoice is issued
|
||||
if ((company as Record<string, unknown>).accounting_method === 'accrual' || !(company as Record<string, unknown>).accounting_method) {
|
||||
// Only create journal entries for real invoices (not proformas or delivery notes)
|
||||
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
|
||||
if (isRealInvoice && ((company as Record<string, unknown>).accounting_method === 'accrual' || !(company as Record<string, unknown>).accounting_method)) {
|
||||
try {
|
||||
const journalEntry = await createInvoiceJournalEntry(
|
||||
user.id,
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { renderToBuffer } from '@react-pdf/renderer'
|
||||
import { InvoicePDF } from '@/lib/invoice/pdf-template'
|
||||
import { getVatRules } from '@/lib/invoice/vat-rules'
|
||||
import type { Invoice, InvoiceItem, Customer, CompanySettings, InvoiceDocumentType } from '@/types'
|
||||
|
||||
/**
|
||||
* POST /api/invoices/preview-pdf
|
||||
*
|
||||
* Generates a preview PDF from form data without creating an invoice.
|
||||
* Returns the PDF as an inline blob for display in a new browser tab.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { customer_id, invoice_date, due_date, currency, items, your_reference, our_reference, notes, document_type } = body
|
||||
|
||||
if (!customer_id || !items || items.length === 0) {
|
||||
return NextResponse.json({ error: 'Kunduppgifter och rader krävs' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Fetch customer
|
||||
const { data: customer, error: customerError } = await supabase
|
||||
.from('customers')
|
||||
.select('*')
|
||||
.eq('id', customer_id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (customerError || !customer) {
|
||||
return NextResponse.json({ error: 'Kunden hittades inte' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Fetch company settings
|
||||
const { data: company, error: companyError } = await supabase
|
||||
.from('company_settings')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (companyError || !company) {
|
||||
return NextResponse.json({ error: 'Företagsinställningar saknas' }, { status: 404 })
|
||||
}
|
||||
|
||||
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated)
|
||||
|
||||
const docType: InvoiceDocumentType = document_type || 'invoice'
|
||||
const isDeliveryNote = docType === 'delivery_note'
|
||||
|
||||
// Build items with line totals
|
||||
const invoiceItems: InvoiceItem[] = items.map((item: { description: string; quantity: number; unit: string; unit_price: number; vat_rate?: number }, index: number) => ({
|
||||
id: `preview-${index}`,
|
||||
invoice_id: 'preview',
|
||||
sort_order: index,
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
unit_price: item.unit_price,
|
||||
line_total: Math.round(item.quantity * item.unit_price * 100) / 100,
|
||||
vat_rate: item.vat_rate ?? vatRules.rate,
|
||||
vat_amount: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
}))
|
||||
|
||||
const subtotal = invoiceItems.reduce((sum, item) => sum + item.line_total, 0)
|
||||
const vatAmount = isDeliveryNote ? 0 : Math.round(subtotal * (vatRules.rate / 100) * 100) / 100
|
||||
const total = isDeliveryNote ? 0 : subtotal + vatAmount
|
||||
|
||||
// Construct a temporary Invoice-like object
|
||||
const previewInvoice = {
|
||||
id: 'preview',
|
||||
user_id: user.id,
|
||||
customer_id,
|
||||
invoice_number: 'FÖRHANDSGRANSKNING',
|
||||
invoice_date: invoice_date || new Date().toISOString().split('T')[0],
|
||||
due_date: due_date || new Date().toISOString().split('T')[0],
|
||||
status: 'draft',
|
||||
currency: currency || 'SEK',
|
||||
exchange_rate: null,
|
||||
exchange_rate_date: null,
|
||||
subtotal: isDeliveryNote ? 0 : subtotal,
|
||||
subtotal_sek: null,
|
||||
vat_amount: vatAmount,
|
||||
vat_amount_sek: null,
|
||||
total,
|
||||
total_sek: null,
|
||||
vat_treatment: vatRules.treatment,
|
||||
vat_rate: isDeliveryNote ? 0 : vatRules.rate,
|
||||
moms_ruta: vatRules.momsRuta,
|
||||
your_reference: your_reference || null,
|
||||
our_reference: our_reference || null,
|
||||
notes: notes || null,
|
||||
reverse_charge_text: vatRules.reverseChargeText || null,
|
||||
credited_invoice_id: null,
|
||||
document_type: docType,
|
||||
converted_from_id: null,
|
||||
paid_at: null,
|
||||
paid_amount: null,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
} as Invoice
|
||||
|
||||
try {
|
||||
const pdfBuffer = await renderToBuffer(
|
||||
InvoicePDF({
|
||||
invoice: previewInvoice,
|
||||
customer: customer as Customer,
|
||||
items: invoiceItems,
|
||||
company: company as CompanySettings,
|
||||
})
|
||||
)
|
||||
|
||||
return new Response(new Uint8Array(pdfBuffer), {
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': 'inline; filename="forhandsvisning.pdf"',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Preview PDF generation error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Kunde inte generera PDF-förhandsgranskning' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
+77
-30
@@ -2,8 +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, Invoice, CreditNote } from '@/types'
|
||||
import { getVatRules, calculateVat, calculateTotal } from '@/lib/invoice/vat-rules'
|
||||
import type { CreateInvoiceInput, EntityType, AccountingMethod, Invoice, CreditNote, InvoiceDocumentType } from '@/types'
|
||||
import { getVatRules, calculateVat, calculateTotal, getAvailableVatRates, getVatTreatmentForRate } from '@/lib/invoice/vat-rules'
|
||||
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
|
||||
import {
|
||||
createCreditNoteJournalEntry,
|
||||
@@ -67,6 +67,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
const invoiceInput = body as CreateInvoiceInput
|
||||
const documentType: InvoiceDocumentType = body.document_type || 'invoice'
|
||||
|
||||
// Get customer for VAT calculation
|
||||
const { data: customer, error: customerError } = await supabase
|
||||
@@ -80,16 +81,37 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: 'Customer not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Calculate VAT rules
|
||||
// Calculate VAT rules (default for customer)
|
||||
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated)
|
||||
const availableRates = getAvailableVatRates(customer.customer_type, customer.vat_number_validated)
|
||||
const allowedRates = new Set(availableRates.map((r) => r.rate))
|
||||
|
||||
// Calculate subtotal from items
|
||||
// Calculate per-item VAT and subtotals
|
||||
const subtotal = invoiceInput.items.reduce((sum, item) => {
|
||||
return sum + item.quantity * item.unit_price
|
||||
}, 0)
|
||||
|
||||
const vatAmount = calculateVat(subtotal, vatRules.rate)
|
||||
const total = subtotal + vatAmount
|
||||
// Calculate VAT per item, respecting per-line vat_rate
|
||||
let vatAmount = 0
|
||||
if (documentType !== 'delivery_note') {
|
||||
for (const item of invoiceInput.items) {
|
||||
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
|
||||
// Validate rate is allowed for this customer
|
||||
if (!allowedRates.has(itemRate)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Momssats ${itemRate}% är inte tillåten för denna kundtyp` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const lineTotal = item.quantity * item.unit_price
|
||||
vatAmount += Math.round(lineTotal * itemRate / 100 * 100) / 100
|
||||
}
|
||||
}
|
||||
const total = documentType === 'delivery_note' ? 0 : subtotal + vatAmount
|
||||
|
||||
// Determine if this is a mixed-rate invoice
|
||||
const uniqueRates = new Set(invoiceInput.items.map((item) => item.vat_rate ?? vatRules.rate))
|
||||
const isMixedRate = uniqueRates.size > 1
|
||||
|
||||
// Handle currency conversion
|
||||
let exchangeRate: number | null = null
|
||||
@@ -109,10 +131,15 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Generate invoice number
|
||||
const { data: invoiceNumber } = await supabase.rpc('generate_invoice_number', {
|
||||
// Generate invoice number with appropriate prefix
|
||||
const { data: baseNumber } = await supabase.rpc('generate_invoice_number', {
|
||||
p_user_id: user.id,
|
||||
})
|
||||
const invoiceNumber = documentType === 'proforma'
|
||||
? `PF-${baseNumber}`
|
||||
: documentType === 'delivery_note'
|
||||
? `FS-${baseNumber}`
|
||||
: baseNumber
|
||||
|
||||
// Create invoice
|
||||
const { data: invoice, error: invoiceError } = await supabase
|
||||
@@ -126,19 +153,20 @@ export async function POST(request: Request) {
|
||||
currency: invoiceInput.currency,
|
||||
exchange_rate: exchangeRate,
|
||||
exchange_rate_date: exchangeRateDate,
|
||||
subtotal,
|
||||
subtotal_sek: subtotalSek,
|
||||
subtotal: documentType === 'delivery_note' ? 0 : subtotal,
|
||||
subtotal_sek: documentType === 'delivery_note' ? null : subtotalSek,
|
||||
vat_amount: vatAmount,
|
||||
vat_amount_sek: vatAmountSek,
|
||||
vat_amount_sek: documentType === 'delivery_note' ? null : vatAmountSek,
|
||||
total,
|
||||
total_sek: totalSek,
|
||||
total_sek: documentType === 'delivery_note' ? null : totalSek,
|
||||
vat_treatment: vatRules.treatment,
|
||||
vat_rate: vatRules.rate,
|
||||
vat_rate: documentType === 'delivery_note' ? 0 : (isMixedRate ? null : (uniqueRates.values().next().value ?? vatRules.rate)),
|
||||
moms_ruta: vatRules.momsRuta,
|
||||
reverse_charge_text: vatRules.reverseChargeText || null,
|
||||
your_reference: invoiceInput.your_reference,
|
||||
our_reference: invoiceInput.our_reference,
|
||||
notes: invoiceInput.notes,
|
||||
document_type: documentType,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
@@ -147,16 +175,23 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: invoiceError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Create invoice items
|
||||
const items = invoiceInput.items.map((item, index) => ({
|
||||
invoice_id: invoice.id,
|
||||
sort_order: index,
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
unit_price: item.unit_price,
|
||||
line_total: item.quantity * item.unit_price,
|
||||
}))
|
||||
// Create invoice items with per-line VAT
|
||||
const items = invoiceInput.items.map((item, index) => {
|
||||
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
|
||||
const lineTotal = item.quantity * item.unit_price
|
||||
const itemVat = documentType === 'delivery_note' ? 0 : Math.round(lineTotal * itemRate / 100 * 100) / 100
|
||||
return {
|
||||
invoice_id: invoice.id,
|
||||
sort_order: index,
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
unit_price: item.unit_price,
|
||||
line_total: lineTotal,
|
||||
vat_rate: itemRate,
|
||||
vat_amount: itemVat,
|
||||
}
|
||||
})
|
||||
|
||||
const { error: itemsError } = await supabase
|
||||
.from('invoice_items')
|
||||
@@ -175,8 +210,8 @@ export async function POST(request: Request) {
|
||||
.eq('id', invoice.id)
|
||||
.single()
|
||||
|
||||
// Emit event (no journal entry at draft — booking happens at send/payment based on accounting method)
|
||||
if (completeInvoice) {
|
||||
// Emit event only for real invoices (proformas and delivery notes are informational)
|
||||
if (completeInvoice && documentType === 'invoice') {
|
||||
await eventBus.emit({
|
||||
type: 'invoice.created',
|
||||
payload: { invoice: completeInvoice as Invoice, userId: user.id },
|
||||
@@ -204,6 +239,14 @@ async function createCreditNote(
|
||||
return NextResponse.json({ error: 'Original invoice not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Credit notes can only be created from real invoices
|
||||
if (originalInvoice.document_type && originalInvoice.document_type !== 'invoice') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Credit notes can only be created from standard invoices' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if invoice is already credited
|
||||
if (originalInvoice.status === 'credited') {
|
||||
return NextResponse.json({ error: 'Invoice has already been credited' }, { status: 400 })
|
||||
@@ -258,8 +301,8 @@ async function createCreditNote(
|
||||
return NextResponse.json({ error: creditNoteError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Create credit note items (negated from original)
|
||||
const creditNoteItems = (originalInvoice.items || []).map((item: { sort_order: number; description: string; quantity: number; unit: string; unit_price: number; line_total: number }, index: number) => ({
|
||||
// Create credit note items (negated from original, preserving per-line VAT)
|
||||
const creditNoteItems = (originalInvoice.items || []).map((item: { sort_order: number; description: string; quantity: number; unit: string; unit_price: number; line_total: number; vat_rate?: number; vat_amount?: number }) => ({
|
||||
invoice_id: creditNote.id,
|
||||
sort_order: item.sort_order,
|
||||
description: item.description,
|
||||
@@ -267,6 +310,8 @@ 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_amount: -(item.vat_amount ? Math.abs(item.vat_amount) : 0),
|
||||
}))
|
||||
|
||||
const { error: itemsError } = await supabase
|
||||
@@ -292,17 +337,19 @@ async function createCreditNote(
|
||||
.eq('id', creditNote.id)
|
||||
.single()
|
||||
|
||||
// Fetch entity type for correct account mapping
|
||||
// Fetch entity type and accounting method for correct account mapping
|
||||
const { data: creditNoteSettings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('entity_type')
|
||||
.select('entity_type, accounting_method')
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
const entityType = (creditNoteSettings?.entity_type as EntityType) || 'enskild_firma'
|
||||
const accountingMethod = (creditNoteSettings?.accounting_method as AccountingMethod) || 'accrual'
|
||||
|
||||
// Create journal entry for the credit note (non-blocking)
|
||||
if (completeCreditNote) {
|
||||
// Cash method: skip — no original invoice entry exists to reverse; deferred until refund
|
||||
if (completeCreditNote && accountingMethod === 'accrual') {
|
||||
try {
|
||||
const journalEntry = await createCreditNoteJournalEntry(
|
||||
userId,
|
||||
|
||||
Reference in New Issue
Block a user