Files
accounted/components/invoices/InvoiceReviewContent.tsx
T
MattssonandClaude Fable 5 f9ea9c0082 Add/pdf and templates (#705)
* fix(invoices): apply configured voucher series to payments + preview next voucher

The booking engine resolves the series from
default_voucher_series_per_source_type, but the global "Standardserie"
dropdown wrote a separate field the engine ignored, and cash-method invoice
payments (invoice_cash_payment) weren't exposed in settings — so configured
series were silently dropped to "A".

- Expose cash/private payment source types in the per-source-type form
- Write the global default through to the map on save, keeping overrides
- Resolve voucher-sequences/next by source_type (+date) to match the engine
- Show the upcoming voucher (V2) in the payment dialog title
- Share resolveInvoicePaymentSourceType so preview and booking can't drift

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(salary): keep AGI panel in sync with Skatteverket signing state

The AGI panel mixed run-scoped generation state (agi_generated_at,
agi_declarations) with period-scoped submission state (extension_data
agi_submission_{period}), so the two could drift and present
contradictory UI. Reconcile them:

- Auto-detect a Mina Sidor BankID signature: while awaiting_signing,
  poll /agi/kvittenser on mount and on tab refocus so the panel flips
  to "signed" (hiding the signing actions) without a manual
  "Hamta kvittens" click.
- Warn instead of offering to sign when the locked granskningsunderlag
  predates the run's latest AGI generation (draftIsStale) — avoids
  filing superseded figures.
- Self-heal a stale "AGI-XML saknas" error once the run's AGI is
  (re)generated out-of-band (MCP/API/other tab).
- Refetch the salary run on tab focus so agi_generated_at reflects
  out-of-band generation without a hard reload.
- /agi/lasUpp now clears the cached agi_submission_{period} record, so
  unlocking drops the panel back to the pre-submission state instead of
  stranding it on a released "redo att signeras" draft.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: Implement VAT registration handling and invoice item line types

- Added VAT registration check in commitCreateInvoice to set VAT rate to 0% for non-VAT registered companies.
- Updated invoice creation logic to reflect 'exempt' VAT treatment and adjusted related fields accordingly.
- Introduced support for free-text and blank spacer rows in invoice items by adding a new line_type field.
- Enhanced invoice and credit note handling to accommodate new line types.
- Added new localized messages for text rows in English and Swedish.
- Created tests for salary run approval logic, ensuring bank details are validated correctly.
- Implemented effective net payout calculation for salary runs, considering tax overrides.
- Added SQL migrations to support new invoice item line types and accounting method awareness for linking invoices to vouchers.

* feat(articles): artikelregister with revenue account + VAT rate per article

Article register (non-inventory) with per-article VAT rate and optional
BAS class-3 revenue-account override. Includes API routes, UI pages,
MCP tools, pending-operation staging, and the activate-or-create
account flow (ACCOUNTS_NOT_IN_CHART -> ActivateAccountsDialog,
unknown numbers -> AddAccountDialog) reusing the journal entry UX.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(bookkeeping): no-doc-required batch + bulk-missing endpoints

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(payments): supplier payment lines + cash-method invoice matching

Shared payment-line proposal for supplier invoices, improved
match-invoice/match-supplier-invoice flows (kontantmetoden-aware),
and voucher-link support without requiring a 151x clearing entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(bookkeeping): new journal entry dialog, SIE import tweaks, misc

New journal entry dialog component, journal list/page updates,
invoice editor updates, SIE import adjustments, transaction ingest
and api-key tweaks, pr-agent workflow update.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(invoices): implement tax reduction features and localization updates

* feat(tests): add VAT registration gate to pending operations commit tests

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 13:52:24 +02:00

235 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useTranslations } from 'next-intl'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { formatCurrency } from '@/lib/utils'
import { getDisplayTotal } from '@/lib/invoices/rounding'
import type { Customer, Currency } from '@/types'
interface ReviewItem {
description: string
quantity: number
unit: string
unit_price: number
vat_rate?: number
/** 'text' rows are free-text/blank lines — description only, no amounts. */
line_type?: 'product' | 'text'
}
interface InvoiceReviewContentProps {
customer: Customer
invoiceDate: string
dueDate: string
currency: Currency
items: ReviewItem[]
subtotal: number
vatAmount: number
total: number
yourReference?: string
ourReference?: string
notes?: string
/** The invoice number that will be assigned on confirm. Null when unknown
* (e.g. delivery notes use a different sequence) or unfetched. */
numberPreview?: string | null
/** Mirrors `company_settings.ore_rounding`. Defaults to true to match `getDisplayTotal`. */
oreRounding?: boolean
/** Mirrors `company_settings.vat_registered`. When false and the invoice carries
* no VAT, the moms row is suppressed to match the PDF (pdf-template.tsx:876). */
vatRegistered?: boolean
}
export function InvoiceReviewContent({
customer,
invoiceDate,
dueDate,
currency,
items,
subtotal,
vatAmount,
total,
yourReference,
ourReference,
notes,
numberPreview,
oreRounding,
vatRegistered,
}: InvoiceReviewContentProps) {
const t = useTranslations('invoice_review')
const rounding = getDisplayTotal({ total, currency }, { ore_rounding: oreRounding ?? true })
const customerTypeLabel: Record<string, string> = {
individual: t('customer_type_individual'),
swedish_business: t('customer_type_swedish_business'),
eu_business: t('customer_type_eu_business'),
non_eu_business: t('customer_type_non_eu_business'),
}
// Calculate per-rate VAT breakdown (free-text rows carry no amounts).
const vatByRate = new Map<number, number>()
for (const item of items) {
if (item.line_type === 'text') continue
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)
}
const showVatColumn = vatByRate.size > 1
return (
<div className="space-y-4">
{numberPreview && (
<div className="text-sm text-muted-foreground">
{t('assigned_number_prefix')}{' '}
<span className="font-medium tabular-nums text-foreground">{numberPreview}</span>
</div>
)}
{/* Customer info */}
<div className="bg-muted rounded-lg p-3 sm:p-4 flex flex-col sm:flex-row sm:items-center gap-2 sm:justify-between">
<div className="min-w-0">
<p className="font-medium text-base truncate">{customer.name}</p>
<p className="text-sm text-muted-foreground truncate">{customer.email}</p>
</div>
<Badge variant="outline" className="self-start sm:self-auto shrink-0">
{customerTypeLabel[customer.customer_type] || customer.customer_type}
</Badge>
</div>
{/* Dates */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">{t('invoice_date')}</span>
<p className="font-medium">{invoiceDate}</p>
</div>
<div>
<span className="text-muted-foreground">{t('due_date')}</span>
<p className="font-medium">{dueDate}</p>
</div>
</div>
{/* Line items — table on desktop, cards on mobile */}
<div className="hidden sm:block">
<table className="w-full text-sm">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b text-left">
<th className="py-2">{t('th_description')}</th>
<th className="py-2 w-16 text-right">{t('th_quantity')}</th>
<th className="py-2 w-16 text-center">{t('th_unit')}</th>
<th className="py-2 w-24 text-right">{t('th_unit_price')}</th>
{showVatColumn && <th className="py-2 w-16 text-right">{t('th_vat')}</th>}
<th className="py-2 w-28 text-right">{t('th_amount')}</th>
</tr>
</thead>
<tbody>
{items.map((item, index) =>
item.line_type === 'text' ? (
<tr key={index} className="border-b last:border-0">
<td className="py-2 text-muted-foreground" colSpan={showVatColumn ? 6 : 5}>
{item.description || ' '}
</td>
</tr>
) : (
<tr key={index} className="border-b last:border-0">
<td className="py-2">{item.description}</td>
<td className="py-2 text-right">{item.quantity}</td>
<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 ?? 0}%</td>
)}
<td className="py-2 text-right">
{formatCurrency(item.quantity * item.unit_price, currency)}
</td>
</tr>
)
)}
</tbody>
</table>
</div>
<div className="sm:hidden space-y-2">
{items.map((item, index) =>
item.line_type === 'text' ? (
<p key={index} className="text-sm text-muted-foreground px-1">{item.description || ' '}</p>
) : (
<div key={index} className="border rounded-lg p-3 text-sm space-y-1.5">
<p className="font-medium">{item.description}</p>
<div className="flex items-center justify-between text-muted-foreground">
<span>{item.quantity} {item.unit} × {formatCurrency(item.unit_price, currency)}</span>
{showVatColumn && <span className="text-xs">{t('mobile_vat_suffix', { rate: item.vat_rate ?? 0 })}</span>}
</div>
<p className="text-right font-medium">
{formatCurrency(item.quantity * item.unit_price, currency)}
</p>
</div>
)
)}
</div>
{/* Totals */}
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">{t('subtotal')}</span>
<span>{formatCurrency(subtotal, currency)}</span>
</div>
{Array.from(vatByRate.entries())
.filter(([, vat]) => vat > 0)
.sort(([a], [b]) => b - a)
.map(([rate, vat]) => (
<div key={rate} className="flex justify-between">
<span className="text-muted-foreground">{t('vat_at_rate', { rate })}</span>
<span>{formatCurrency(vat, currency)}</span>
</div>
))}
{Array.from(vatByRate.values()).every((vat) => vat === 0) && !(vatRegistered === false && vatAmount === 0) && (
<div className="flex justify-between">
<span className="text-muted-foreground">{t('vat_label')}</span>
<span>{formatCurrency(0, currency)}</span>
</div>
)}
{rounding.applies && (
<div className="flex justify-between">
<span className="text-muted-foreground">{t('ore_rounding')}</span>
<span>{formatCurrency(rounding.roundingDelta, currency)}</span>
</div>
)}
<Separator />
<div className="flex justify-between font-bold text-xl sm:text-2xl">
<span>{t('total')}</span>
<span>{formatCurrency(rounding.displayed, currency)}</span>
</div>
</div>
{/* References/notes */}
{(yourReference || ourReference || notes) && (
<div className="border-t pt-3 space-y-2 text-sm text-muted-foreground">
{yourReference && (
<div>
<span>{t('your_reference')}</span>
<div className="flex flex-wrap gap-1 mt-1">
{yourReference.split(',').map((ref, i) => (
<Badge key={i} variant="secondary" className="text-xs font-normal">
{ref.trim()}
</Badge>
))}
</div>
</div>
)}
{ourReference && (
<div>
<span>{t('our_reference')}</span>
<div className="flex flex-wrap gap-1 mt-1">
{ourReference.split(',').map((ref, i) => (
<Badge key={i} variant="secondary" className="text-xs font-normal">
{ref.trim()}
</Badge>
))}
</div>
</div>
)}
{notes && <p>{t('notes_prefix', { notes })}</p>}
</div>
)}
</div>
)
}