Add/stripe invoice link (#998)

* feat(supplier-invoices): show registered invoices under "Att betala" with inline approve

Registered supplier invoices are already booked as debt (2440) but were
hidden from the "Att betala" tab until approved, which confused users.
The tab now shows registered invoices too, marked "Ej godkand" with a
compact inline approve button. Approval remains the gate for payment,
not visibility; status model and approve API untouched.

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

* feat(reports): add date range filter to huvudbok (kontoanalys)

Mounts the existing ReportDateRange control on /reports/huvudbok so the
ledger can be narrowed to any date range within the fiscal year, matching
Fortnox kontoanalys. Lines before the range roll into each account's
opening balance so running balances stay correct at the range start;
lines after the range are dropped. Applies to the XLSX export too.

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

* feat(invoices): add optional payment link on invoices (paste-link MVP)

The user pastes a payment link created in their PSP dashboard (e.g. a
Stripe Payment Link) onto an invoice. The recipient gets a "Betala
online" button in the invoice email and a QR code + clickable link in
the PDF payment box. No PSP integration server-side: this is the
demand probe; a future Stripe Connect integration would auto-fill the
same column.

- invoices.payment_link_url (migration 20260709090000), https-only +
  2048-char cap enforced in CreateInvoiceSchema; empty string
  normalises to undefined and build-invoice-write always writes a
  concrete value so clearing the field on a draft edit NULLs the column
- editor field (real invoices only) with one-link-per-invoice hint;
  strings in sv+en (messages landed via e0e11066)
- email button (customer.language, hidden for credit notes/proforma/
  delivery notes, URL escaped for the href attribute) + URL in the
  plain-text part
- PDF QR + link row following the Swish QR pattern; wired into send,
  download and preview routes
- derived documents (credit note, proforma convert, recurring) do NOT
  copy the link: it encodes one amount for one specific invoice
- MCP gnubok_create_invoice accepts payment_link_url (validated at
  staging and re-checked in the commit executor); v1 API exposes the
  column; tools/list token ceiling bumped 45K -> 45.5K (ledger entry
  in payload-size.bench.test.ts, headroom was <10 tokens)

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

* fix(invoices): show oresavrundning on editor/form totals, supplier list and invoice email

The rounding logic (getDisplayTotal) was correct but only applied on the
PDF, invoice list/detail and review dialog. The invoice editor summary,
the supplier invoice form totals and the supplier invoice list showed the
raw ore total right next to the toggle, and the invoice email said
"Att betala" with the unrounded invoice.total while the attached PDF
showed the rounded amount (and the email also ignored the ROT/RUT
deduction).

Extract the PDF's Att betala block into getAmountToPay
(lib/invoices/rounding.ts) and point PDF + email at it so they cannot
drift; behavior-identical refactor for the PDF. Booked amounts stay
ore-exact; display-only as designed.

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

* test(reports): adapt huvudbok date-range tests to the two-step entry-lines fetch

The date-range tests (0969168f) mocked the old single-query shape with the
parent entry embedded on each line; main's refactor (fetchEntryLines)
queries journal_entries first and reattaches. Queue entry rows like the
other tests so the merge of the two features is actually exercised.

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

* fix(invoices): fetch full invoice projection in v1 send so ROT/RUT deduction and payment link reach the PDF and email

The v1 send route's hand-rolled column list omitted deduction_total,
deduction_personnummer_last4, payment_link_url and the item-level
ROT/RUT fields, so invoices sent via the public API overstated
'Att betala' and dropped the deduction box. Reuse the shared
INVOICE_FULL_COLUMNS/INVOICE_ITEM_FULL_COLUMNS so the send row can
never drift from the GET shape again.

Also harden the supplier-invoice inline approve: a thrown fetch left
the button stuck spinning; failures now refetch the true server state.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-07-12 00:56:16 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 0ef5593388
commit 7d7f604e00
35 changed files with 945 additions and 56 deletions
+57 -3
View File
@@ -23,6 +23,7 @@ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useToast } from '@/components/ui/use-toast'
import { formatCurrency } from '@/lib/utils'
import { getVatRules, getAvailableVatRates } from '@/lib/invoices/vat-rules'
import { getAmountToPay } from '@/lib/invoices/rounding'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
import { Loader2, Plus, Trash2, ArrowLeft, Send, Eye, Landmark, Lock, AlertTriangle, MoreVertical, CalendarClock, Tags } from 'lucide-react'
import {
@@ -196,6 +197,22 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
your_reference: z.string().optional(),
our_reference: z.string().optional(),
notes: z.string().optional(),
// Optional online payment link (pasted from e.g. the Stripe dashboard).
// https-only: mirrors the server-side CreateInvoiceSchema gate.
payment_link_url: z
.string()
.optional()
.refine(
(v) => {
if (!v || !v.trim()) return true
try {
return new URL(v).protocol === 'https:'
} catch {
return false
}
},
{ message: t('validation_payment_link_https') },
),
// Self-billing received (mottagen självfaktura). Present in the form for
// both modes; required only in self_billed mode: enforced in onSubmit.
external_invoice_number: z.string().optional(),
@@ -297,6 +314,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
your_reference: initial.your_reference ?? '',
our_reference: initial.our_reference ?? '',
notes: initial.notes ?? '',
payment_link_url: initial.payment_link_url ?? '',
external_invoice_number: '',
self_billing_agreement_ref: '',
received_date: '',
@@ -329,6 +347,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
due_date: '',
currency: 'SEK',
document_type: 'invoice' as InvoiceDocumentType,
payment_link_url: '',
external_invoice_number: '',
self_billing_agreement_ref: '',
received_date: '',
@@ -715,7 +734,14 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
const deductionTotal = Math.round((deductionByKind.rot + deductionByKind.rut) * 100) / 100
const hasAnyDeduction = deductionTotal > 0
const hasAnyRotLine = isInvoiceDoc && watchItems.some((i) => i.deduction_type === 'rot')
const toPay = Math.round((total - deductionTotal) * 100) / 100
// Öresavrundning live preview: same helper as the PDF/email, so the summary
// shows exactly what the customer will see. Display-only; the saved invoice
// keeps the exact öre.
const { rounding: displayRounding, toPay: displayedToPay } = getAmountToPay(
{ total, currency: watchCurrency, ore_rounding: oreRounding, deduction_total: deductionTotal },
null,
)
// Periodisering per rad: kräver faktureringsmetoden och en riktig faktura.
// EU-/exportkunder bokas på 3308/3305 (omvänd skattskyldighet/export) och
@@ -1184,6 +1210,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
your_reference: pendingData.your_reference,
our_reference: pendingData.our_reference,
notes: pendingData.notes,
payment_link_url: pendingData.payment_link_url,
invoice_number: numberPreview,
}),
})
@@ -2083,6 +2110,27 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
/>
</div>
{/* Online payment link (manual MVP): pasted per invoice from
the user's PSP dashboard. Only real invoices: proformas
and delivery notes carry no payment request. */}
{watchDocumentType === 'invoice' && (
<div className="space-y-2">
<Label htmlFor="payment_link_url">{t('payment_link_label')}</Label>
<Input
id="payment_link_url"
type="url"
inputMode="url"
placeholder={t('payment_link_placeholder')}
{...register('payment_link_url')}
/>
{errors.payment_link_url ? (
<p className="text-sm text-destructive">{errors.payment_link_url.message}</p>
) : (
<p className="text-xs text-muted-foreground">{t('payment_link_hint')}</p>
)}
</div>
)}
{/* Invoice-level default dims (kostnadsställe/projekt):
written to every generated journal line; per-item bags
(row ⋮ menu) merge on top. Renders only when dimensions
@@ -2143,6 +2191,12 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
<span>{formatCurrency(0, watchCurrency)}</span>
</div>
)}
{displayRounding.applies && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{t('ore_rounding_label')}</span>
<span className="tabular-nums">{formatCurrency(displayRounding.roundingDelta, watchCurrency)}</span>
</div>
)}
{hasAnyDeduction && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{t('deduction_summary_label')}</span>
@@ -2152,7 +2206,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
<Separator />
<div className="flex justify-between font-bold text-lg">
<span>{hasAnyDeduction ? t('to_pay_label') : t('total_label')}</span>
<span>{formatCurrency(hasAnyDeduction ? toPay : total, watchCurrency)}</span>
<span>{formatCurrency(displayedToPay, watchCurrency)}</span>
</div>
{hasAnyDeduction && (
<div className="flex justify-between text-xs text-muted-foreground">
@@ -2226,7 +2280,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
{hasAnyDeduction ? t('to_pay_label') : t('total_label')}
</p>
<p className="text-lg font-bold tabular-nums">
{formatCurrency(hasAnyDeduction ? toPay : total, watchCurrency)}
{formatCurrency(displayedToPay, watchCurrency)}
</p>
</div>
<div className="flex items-center gap-2">
+1 -1
View File
@@ -189,7 +189,7 @@ function FocusedView({
case 'ink2-declaration':
return isAktiebolag ? <INK2DeclarationView periodId={periodId} /> : null
case 'huvudbok':
return <GeneralLedgerView periodId={periodId} initialAccountFilter={accountFilter} dimensionFilter={dimensionFilter} />
return <GeneralLedgerView periodId={periodId} initialAccountFilter={accountFilter} dimensionFilter={dimensionFilter} dateRange={dateRange} />
case 'grundbok':
return <JournalRegisterView periodId={periodId} />
case 'kundreskontra':
+10 -4
View File
@@ -2290,7 +2290,11 @@ interface GeneralLedgerData {
period: { start: string; end: string }
}
export function GeneralLedgerView({ periodId, initialAccountFilter, dimensionFilter = null }: { periodId: string; initialAccountFilter: string | null; dimensionFilter?: DimensionFilterValue | null }) {
// Stable default: an inline `= {}` would change identity every render and
// re-trigger the fetch effect for callers that omit the prop.
const EMPTY_DATE_RANGE: DateRangeValue = {}
export function GeneralLedgerView({ periodId, initialAccountFilter, dimensionFilter = null, dateRange = EMPTY_DATE_RANGE }: { periodId: string; initialAccountFilter: string | null; dimensionFilter?: DimensionFilterValue | null; dateRange?: DateRangeValue }) {
const [data, setData] = useState<GeneralLedgerData | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
@@ -2306,6 +2310,8 @@ export function GeneralLedgerView({ periodId, initialAccountFilter, dimensionFil
const params = new URLSearchParams({ period_id: periodId })
if (from) params.set('account_from', from)
if (to) params.set('account_to', to)
if (dateRange.fromDate) params.set('from_date', dateRange.fromDate)
if (dateRange.toDate) params.set('to_date', dateRange.toDate)
if (dimensionFilter) {
params.set('dim_no', dimensionFilter.dimNo)
params.set('dim_code', dimensionFilter.code)
@@ -2322,7 +2328,7 @@ export function GeneralLedgerView({ periodId, initialAccountFilter, dimensionFil
} finally {
setLoading(false)
}
}, [periodId, accountFrom, accountTo, dimensionFilter])
}, [periodId, accountFrom, accountTo, dimensionFilter, dateRange])
// When initialAccountFilter changes (drill-down from another report), apply it
useEffect(() => {
@@ -2333,7 +2339,7 @@ export function GeneralLedgerView({ periodId, initialAccountFilter, dimensionFil
} else {
fetchData()
}
}, [periodId, initialAccountFilter, dimensionFilter])
}, [periodId, initialAccountFilter, dimensionFilter, dateRange])
if (loading) {
return (
@@ -2368,7 +2374,7 @@ export function GeneralLedgerView({ periodId, initialAccountFilter, dimensionFil
return (
<div className="space-y-4">
<ReportExportMenu items={[{ format: 'xlsx', href: `/api/reports/general-ledger/xlsx?${reportQuery(periodId, undefined, dimensionFilter)}` }]} />
<ReportExportMenu items={[{ format: 'xlsx', href: `/api/reports/general-ledger/xlsx?${reportQuery(periodId, dateRange, dimensionFilter)}` }]} />
{/* Account range filter */}
<Card>
<CardContent className="pt-6">
@@ -22,6 +22,7 @@ import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import { getAccountDescription } from '@/lib/bookkeeping/account-descriptions'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { cn, formatCurrency } from '@/lib/utils'
import { getDisplayTotal } from '@/lib/invoices/rounding'
import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import BankTransactionPicker from '@/components/transactions/BankTransactionPicker'
@@ -860,6 +861,13 @@ export default function NewSupplierInvoiceForm({
const payableVat = watchedReverseCharge ? 0 : totalVat
const total = Math.round((subtotal + payableVat) * 100) / 100
// Öresavrundning live preview: same helper as the detail page. Display-only;
// the registered amount and the booked verifikat keep the exact öre.
const displayRounding = getDisplayTotal(
{ total, currency: watchedCurrency || 'SEK', ore_rounding: oreRounding },
{ ore_rounding: false },
)
// Show the AI-suggested supplier card when we have an inbox item, the AI
// surfaced a supplier name, and we couldn't match it to an existing record.
const showAISupplierHint =
@@ -1982,9 +1990,15 @@ export default function NewSupplierInvoiceForm({
</span>
<span className="tabular-nums sm:w-32 text-right">{formatCurrency(totalVat, watchedCurrency)}</span>
</div>
{displayRounding.applies && (
<div className="flex justify-between sm:justify-end sm:gap-8">
<span className="text-muted-foreground">{t('ore_rounding_label')}</span>
<span className="tabular-nums sm:w-32 text-right">{formatCurrency(displayRounding.roundingDelta, watchedCurrency)}</span>
</div>
)}
<div className="flex justify-between sm:justify-end sm:gap-8 font-bold text-lg">
<span>{t('total_label')}</span>
<span className="tabular-nums sm:w-32 text-right">{formatCurrency(total, watchedCurrency)}</span>
<span className="tabular-nums sm:w-32 text-right">{formatCurrency(displayRounding.displayed, watchedCurrency)}</span>
</div>
{/* Öresavrundning: display-only rounding of the displayed total to
whole kronor (SEK only). The registered amount and the booked