* refactor: update VAT handling logic for non-registered sellers and improve related comments * chore: gate automated email flows behind 503 responses Disables user-facing access to invoice payment reminders and salary payslip email sending. Underlying lib code (reminder-processor, PDF templates, notification_settings) is preserved for easy re-enable. - Invoice reminders cron route returns 503; settings UI section removed. - Payslip send route returns 503; original implementation kept as _sendPayslipsImpl for future re-enable. - Push notifications were already extension-disabled, no change needed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: remove Recapt feedback widget Strips the third-party Recapt SDK and its floating feedback bubble from the app. The in-app contact form keeps working via the existing email channel (/api/support/contact). Drops the Recapt entries from the CSP and the subprocessor list in the privacy policy. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat: reject meaningless rättelser in correctEntry Guard against zero-economic-effect corrections in the storno engine: - Reject when proposed lines net to zero on every account (e.g. 1930 debit 100 / 1930 credit 100), which would erase the original posting without representing any affärshändelse (BFL 5 kap. 5 §). - Reject when proposed lines are an exact multiset match of the original entry — a rättelse must actually change something. New MeaninglessCorrectionError wired through bookkeepingErrorResponse (HTTP 400) and the Swedish error translator. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat: add date-range picker to resultat- and balansrapport Adds optional from/to date filtering to the four operational financial reports (resultatrapport, balansrapport, income-statement, balance-sheet) so users can view a month, quarter, or custom range inside a fiscal year without leaving the report. Defaults to YTD; "Hela året" preserves the prior full-period behaviour (URL-identical, cache-stable). - trial-balance engine accepts optional fromDate/toDate, rolling prior in-period activity into IB and clamping period activity to the window - 12 API routes accept and validate from_date/to_date query params - ReportDateRange chip picker persists preset per company, only renders on the four relevant tabs - FiscalYearSelector now emits the period object so the range picker has bounds without an extra fetch - PDF/XLSX filenames reflect the chosen range - Resultatrapport drops the prior-year column when narrowed (full-year vs partial-year would mislead) - 11 new tests (engine + parser); all existing report tests pass Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat: add support for marking journal entries as "no document required" - Introduced a new sidecar table `journal_entry_no_doc_required` to track entries that do not require separate documentation (e.g., bank fees, interest). - Implemented API routes for creating and deleting exemptions, including validation and authorization checks. - Added a toggle component in the UI to allow users to mark entries as exempt, with an optional reason. - Updated relevant tests to cover the new functionality, including RLS checks and cascading deletes. - Enhanced existing schemas and types to accommodate the new `vat_amount` field for supplier invoice items. * fix: address PR review findings on no-doc-required + VAT changes - pg-real cascade test wraps DELETE in gnubok.allow_delete='true' txn so the immutability trigger bypass fires (mirrors delete_last_voucher RPC). - Clamp supplier-invoice item vat_amount to <= line_total * vat_rate via Zod refinement (with 1-öre rounding tolerance) so the manual override can't inflate the 2641 debit beyond the statutory ceiling. - groupVatByRate falls back to line_total * rate when stored vat_amount is 0 with a positive rate, so legacy/import paths leaving the column at its NOT NULL DEFAULT 0 don't silently understate ruta 48. - ReportDateRange todayIso() and preset endpoints use local date components instead of toISOString() (UTC) — fixes the midnight-to-02:00 off-by-one that truncated a day from YTD / this-month / this-quarter for Swedish users. - NoDocRequiredToggle restores the previous reason on failed POST/DELETE so the rolled-back toggle state stays consistent with the rendered reason. - Document the company-scoped (not user-scoped) DELETE authorization policy on the no-document-required route. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
389 lines
14 KiB
TypeScript
389 lines
14 KiB
TypeScript
'use client'
|
||
|
||
import { useTranslations } from 'next-intl'
|
||
import { Badge } from '@/components/ui/badge'
|
||
import { Separator } from '@/components/ui/separator'
|
||
import { AccountNumber } from '@/components/ui/account-number'
|
||
import { formatCurrency } from '@/lib/utils'
|
||
import type { Supplier } from '@/types'
|
||
|
||
interface ReviewLineItem {
|
||
description: string
|
||
amount: number
|
||
account_number: string
|
||
vat_rate: number
|
||
// When set, the user typed the deductible VAT explicitly (manual override).
|
||
// Used for bilförmån 50%, representation tak, FX-rundningar etc.
|
||
vat_amount?: number
|
||
}
|
||
|
||
interface SupplierInvoiceReviewContentProps {
|
||
supplier: Supplier
|
||
invoiceNumber: string
|
||
invoiceDate: string
|
||
dueDate: string
|
||
deliveryDate?: string
|
||
currency: string
|
||
exchangeRate?: string
|
||
reverseCharge: boolean
|
||
paymentReference?: string
|
||
items: ReviewLineItem[]
|
||
subtotal: number
|
||
totalVat: number
|
||
total: number
|
||
}
|
||
|
||
function formatAmount(amount: number): string {
|
||
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||
}
|
||
|
||
interface JournalPreviewLine {
|
||
account_number: string
|
||
description: string
|
||
debit: number
|
||
credit: number
|
||
}
|
||
|
||
function getOutputVatAccount(rate: number): string {
|
||
if (rate === 0.12) return '2624'
|
||
if (rate === 0.06) return '2634'
|
||
return '2614'
|
||
}
|
||
|
||
function buildJournalPreview(
|
||
items: ReviewLineItem[],
|
||
subtotal: number,
|
||
totalVat: number,
|
||
total: number,
|
||
reverseCharge: boolean,
|
||
supplierType: string | undefined,
|
||
// FX multiplier applied to every amount. 1 when the invoice is in SEK or
|
||
// when no rate is set. Matches what the backend writes — items go through
|
||
// resolveSekAmount(item.line_total, null, currency, exchange_rate), so the
|
||
// saved verifikation is always in SEK, never in invoice currency.
|
||
fxRate: number,
|
||
): JournalPreviewLine[] {
|
||
const lines: JournalPreviewLine[] = []
|
||
const toSek = (n: number) => Math.round(n * fxRate * 100) / 100
|
||
|
||
// Aggregate expense amounts by account number (in SEK)
|
||
const expenseByAccount = new Map<string, number>()
|
||
for (const item of items) {
|
||
const current = expenseByAccount.get(item.account_number) || 0
|
||
expenseByAccount.set(item.account_number, current + toSek(item.amount))
|
||
}
|
||
|
||
// Debit: Expense accounts
|
||
for (const [accountNumber, amount] of expenseByAccount) {
|
||
lines.push({
|
||
account_number: accountNumber,
|
||
description: accountNumber,
|
||
debit: amount,
|
||
credit: 0,
|
||
})
|
||
}
|
||
|
||
// Per-line effective VAT — manual override wins over computed amount × rate.
|
||
// The engine reads stored vat_amount; the preview must reflect the same.
|
||
const itemVat = (item: ReviewLineItem) =>
|
||
item.vat_amount != null
|
||
? Math.round(item.vat_amount * 100) / 100
|
||
: Math.round(item.amount * item.vat_rate * 100) / 100
|
||
|
||
if (reverseCharge) {
|
||
// Reverse charge: fiktiv moms is always statutory base × rate, regardless
|
||
// of any manual override on the items themselves (matches engine).
|
||
const isDomesticRC = supplierType === 'swedish_business'
|
||
const inputAccount = isDomesticRC ? '2647' : '2645'
|
||
|
||
const baseByRate = new Map<number, number>()
|
||
for (const item of items) {
|
||
if (item.vat_rate > 0) {
|
||
const current = baseByRate.get(item.vat_rate) || 0
|
||
baseByRate.set(item.vat_rate, current + toSek(item.amount))
|
||
}
|
||
}
|
||
|
||
for (const [rate, netAmount] of baseByRate) {
|
||
const fiktivVat = Math.round(netAmount * rate * 100) / 100
|
||
const outputAccount = getOutputVatAccount(rate)
|
||
lines.push({
|
||
account_number: inputAccount,
|
||
description: inputAccount,
|
||
debit: fiktivVat,
|
||
credit: 0,
|
||
})
|
||
lines.push({
|
||
account_number: outputAccount,
|
||
description: outputAccount,
|
||
debit: 0,
|
||
credit: fiktivVat,
|
||
})
|
||
}
|
||
|
||
// Credit: 2440 at subtotal (no real VAT for reverse charge)
|
||
lines.push({
|
||
account_number: '2440',
|
||
description: 'Leverantörsskulder',
|
||
debit: 0,
|
||
credit: toSek(subtotal),
|
||
})
|
||
} else {
|
||
if (totalVat > 0) {
|
||
// Sum per-rate using effective (manual-or-computed) VAT, so the preview
|
||
// matches what groupVatByRate will write to 2641 server-side.
|
||
const vatByRate = new Map<number, number>()
|
||
for (const item of items) {
|
||
const v = itemVat(item)
|
||
if (v > 0) {
|
||
vatByRate.set(item.vat_rate, (vatByRate.get(item.vat_rate) || 0) + v)
|
||
}
|
||
}
|
||
for (const [, vat] of vatByRate) {
|
||
lines.push({
|
||
account_number: '2641',
|
||
description: 'Ingående moms',
|
||
debit: toSek(vat),
|
||
credit: 0,
|
||
})
|
||
}
|
||
}
|
||
// Credit: 2440 at total incl. VAT
|
||
lines.push({
|
||
account_number: '2440',
|
||
description: 'Leverantörsskulder',
|
||
debit: 0,
|
||
credit: toSek(total),
|
||
})
|
||
}
|
||
|
||
return lines
|
||
}
|
||
|
||
export function SupplierInvoiceReviewContent({
|
||
supplier,
|
||
invoiceNumber,
|
||
invoiceDate,
|
||
dueDate,
|
||
deliveryDate,
|
||
currency,
|
||
exchangeRate,
|
||
reverseCharge,
|
||
paymentReference,
|
||
items,
|
||
subtotal,
|
||
totalVat,
|
||
total,
|
||
}: SupplierInvoiceReviewContentProps) {
|
||
const t = useTranslations('supplier_invoice_editor')
|
||
const parsedRate = exchangeRate ? parseFloat(exchangeRate) : NaN
|
||
const fxRate = currency !== 'SEK' && Number.isFinite(parsedRate) && parsedRate > 0 ? parsedRate : 1
|
||
const journalLines = buildJournalPreview(items, subtotal, totalVat, total, reverseCharge, supplier.supplier_type, fxRate)
|
||
const totalDebit = journalLines.reduce((sum, l) => sum + l.debit, 0)
|
||
const totalCredit = journalLines.reduce((sum, l) => sum + l.credit, 0)
|
||
const showingSek = fxRate !== 1
|
||
|
||
const ACCOUNT_LABELS: Record<string, string> = {
|
||
'2440': t('account_2440'),
|
||
'2641': t('account_2641'),
|
||
'2645': t('account_2645'),
|
||
'2647': t('account_2647'),
|
||
'2614': t('account_2614'),
|
||
'2624': t('account_2624'),
|
||
'2634': t('account_2634'),
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{/* Supplier 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">{supplier.name}</p>
|
||
<p className="text-sm text-muted-foreground">{t('review_invoice_number_label', { number: invoiceNumber })}</p>
|
||
</div>
|
||
<div className="flex flex-wrap gap-1.5 sm:gap-2 shrink-0">
|
||
{reverseCharge && (
|
||
<Badge variant="outline" className="border-orange-300 text-orange-700 dark:text-orange-400">
|
||
{t('reverse_charge_badge')}
|
||
</Badge>
|
||
)}
|
||
{currency !== 'SEK' && (
|
||
<Badge variant="outline" className="text-sm">
|
||
{currency}
|
||
{exchangeRate && t('review_currency_rate_suffix', { rate: exchangeRate })}
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Dates */}
|
||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 sm:gap-4 text-sm">
|
||
<div>
|
||
<span className="text-muted-foreground">{t('invoice_date_label')}</span>
|
||
<p className="font-medium">{invoiceDate}</p>
|
||
</div>
|
||
<div>
|
||
<span className="text-muted-foreground">{t('due_date_label')}</span>
|
||
<p className="font-medium">{dueDate}</p>
|
||
</div>
|
||
{deliveryDate && (
|
||
<div>
|
||
<span className="text-muted-foreground">{t('delivery_date_label')}</span>
|
||
<p className="font-medium">{deliveryDate}</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 w-20">{t('col_account')}</th>
|
||
<th className="py-2">{t('col_description')}</th>
|
||
<th className="py-2 w-28 text-right">{t('col_amount')}</th>
|
||
<th className="py-2 w-16 text-right">{t('col_vat_rate')}</th>
|
||
<th className="py-2 w-24 text-right">{t('col_vat')}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{items.map((item, index) => {
|
||
const vatAmount = item.vat_amount != null
|
||
? Math.round(item.vat_amount * 100) / 100
|
||
: Math.round(item.amount * item.vat_rate * 100) / 100
|
||
return (
|
||
<tr key={index} className="border-b last:border-0">
|
||
<td className="py-2">
|
||
<AccountNumber number={item.account_number} size="sm" />
|
||
</td>
|
||
<td className="py-2">{item.description}</td>
|
||
<td className="py-2 text-right font-mono">{formatAmount(item.amount)}</td>
|
||
<td className="py-2 text-right">{Math.round(item.vat_rate * 100)}%</td>
|
||
<td className="py-2 text-right font-mono">{formatAmount(vatAmount)}</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div className="sm:hidden space-y-2">
|
||
{items.map((item, index) => {
|
||
const vatAmount = item.vat_amount != null
|
||
? Math.round(item.vat_amount * 100) / 100
|
||
: Math.round(item.amount * item.vat_rate * 100) / 100
|
||
return (
|
||
<div key={index} className="border rounded-lg p-3 text-sm space-y-1.5">
|
||
<div className="flex items-center justify-between">
|
||
<p className="font-medium">{item.description}</p>
|
||
<AccountNumber number={item.account_number} size="sm" />
|
||
</div>
|
||
<div className="flex items-center justify-between text-muted-foreground">
|
||
<span>{formatAmount(item.amount)} kr</span>
|
||
<span className="text-xs">{t('review_vat_inline', { rate: Math.round(item.vat_rate * 100), amount: formatAmount(vatAmount) })}</span>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{/* Totals */}
|
||
<div className="space-y-2 text-sm">
|
||
<div className="flex justify-between">
|
||
<span className="text-muted-foreground">{t('net_excl_vat')}</span>
|
||
<span>{formatCurrency(subtotal, currency)}</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span className="text-muted-foreground">{t('vat_label_short')}</span>
|
||
<span>{formatCurrency(totalVat, currency)}</span>
|
||
</div>
|
||
<Separator />
|
||
<div className="flex justify-between font-bold text-xl sm:text-2xl">
|
||
<span>{t('total_label')}</span>
|
||
<span>{formatCurrency(total, currency)}</span>
|
||
</div>
|
||
{currency !== 'SEK' && exchangeRate && (
|
||
<div className="flex justify-between text-muted-foreground">
|
||
<span>{t('review_sek_amount_at_rate', { rate: exchangeRate })}</span>
|
||
<span>{formatCurrency(total * parseFloat(exchangeRate))}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Verifikation preview */}
|
||
<div className="bg-muted/50 border rounded-lg p-3 sm:p-4 space-y-2">
|
||
<p className="text-sm font-semibold text-muted-foreground">
|
||
{t('review_voucher_preview_title')}
|
||
{showingSek && (
|
||
<span className="ml-1.5 font-normal text-xs">{t('review_voucher_in_sek_suffix')}</span>
|
||
)}
|
||
</p>
|
||
<div className="hidden sm:block">
|
||
<table className="w-full text-sm font-mono">
|
||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||
<tr className="text-left">
|
||
<th className="pb-1 w-16">{t('col_account')}</th>
|
||
<th className="pb-1">{t('col_description')}</th>
|
||
<th className="pb-1 w-24 text-right">{t('col_debit')}</th>
|
||
<th className="pb-1 w-24 text-right">{t('col_credit')}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{journalLines.map((line, index) => (
|
||
<tr key={index} className="border-b border-dashed border-muted-foreground/20 last:border-0">
|
||
<td className="py-1">
|
||
<AccountNumber number={line.account_number} size="sm" />
|
||
</td>
|
||
<td className="py-1 text-xs">
|
||
{ACCOUNT_LABELS[line.account_number] || line.description}
|
||
</td>
|
||
<td className="py-1 text-right">
|
||
{line.debit > 0 ? formatAmount(line.debit) : ''}
|
||
</td>
|
||
<td className="py-1 text-right">
|
||
{line.credit > 0 ? formatAmount(line.credit) : ''}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
<tfoot>
|
||
<tr className="border-t font-semibold">
|
||
<td className="pt-1" colSpan={2}>{t('sum_label')}</td>
|
||
<td className="pt-1 text-right">{formatAmount(totalDebit)}</td>
|
||
<td className="pt-1 text-right">{formatAmount(totalCredit)}</td>
|
||
</tr>
|
||
</tfoot>
|
||
</table>
|
||
</div>
|
||
<div className="sm:hidden space-y-1.5 text-sm">
|
||
{journalLines.map((line, index) => (
|
||
<div key={index} className="flex items-center justify-between py-1 border-b border-dashed border-muted-foreground/20 last:border-0">
|
||
<div className="min-w-0">
|
||
<div className="flex items-center gap-1.5">
|
||
<AccountNumber number={line.account_number} size="sm" />
|
||
<span className="text-xs text-muted-foreground truncate">
|
||
{ACCOUNT_LABELS[line.account_number] || line.description}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<span className="font-mono text-xs shrink-0 ml-2">
|
||
{line.debit > 0 ? t('debit_short', { amount: formatAmount(line.debit) }) : t('credit_short', { amount: formatAmount(line.credit) })}
|
||
</span>
|
||
</div>
|
||
))}
|
||
<div className="flex justify-between pt-1 border-t font-semibold text-xs font-mono">
|
||
<span>{t('sum_label')}</span>
|
||
<span>{t('debit_credit_short', { debit: formatAmount(totalDebit), credit: formatAmount(totalCredit) })}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Payment reference */}
|
||
{paymentReference && (
|
||
<div className="border-t pt-3 text-sm text-muted-foreground">
|
||
<p>{t('review_payment_reference_inline', { reference: paymentReference })}</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|