From 7d7f604e00fecd436ff79dc926bf326098cc0c69 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Sun, 12 Jul 2026 00:56:16 +0200 Subject: [PATCH] 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 * 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 * 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 * 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 * 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 * 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 --------- Co-authored-by: Claude Fable 5 --- DECISIONS.md | 3 + app/(dashboard)/supplier-invoices/page.tsx | 65 +++++++- app/api/invoices/[id]/pdf/route.ts | 4 +- app/api/invoices/[id]/send/route.ts | 4 +- app/api/invoices/preview-pdf/route.ts | 18 ++- .../general-ledger/__tests__/route.test.ts | 142 ++++++++++++++++++ app/api/reports/general-ledger/route.ts | 20 +++ app/api/reports/general-ledger/xlsx/route.ts | 20 +++ .../[companyId]/invoices/[id]/send/route.ts | 16 +- components/invoices/InvoiceEditor.tsx | 60 +++++++- components/reports/FocusedReport.tsx | 2 +- components/reports/views/index.tsx | 14 +- .../NewSupplierInvoiceForm.tsx | 16 +- .../__tests__/payload-size.bench.test.ts | 7 +- extensions/general/mcp-server/server.ts | 20 +++ lib/api/__tests__/schemas.test.ts | 32 ++++ lib/api/schemas.ts | 22 +++ lib/api/v1/invoice-columns.ts | 2 +- lib/email/__tests__/invoice-templates.test.ts | 92 ++++++++++++ lib/email/invoice-templates.ts | 23 ++- .../__tests__/build-invoice-write.test.ts | 28 ++++ .../__tests__/pdf-render-helpers.test.ts | 30 +++- lib/invoices/__tests__/rounding.test.ts | 45 +++++- lib/invoices/build-invoice-write.ts | 6 + lib/invoices/pdf-render-helpers.ts | 24 +++ lib/invoices/pdf-template.tsx | 41 +++-- lib/invoices/rounding.ts | 35 +++++ lib/pending-operations/commit.ts | 14 ++ lib/reports/__tests__/general-ledger.test.ts | 92 ++++++++++++ lib/reports/catalog.ts | 2 +- lib/reports/general-ledger.ts | 55 ++++++- messages/en.json | 11 +- messages/sv.json | 11 +- .../20260709090000_invoice_payment_link.sql | 16 ++ types/index.ts | 9 ++ 35 files changed, 945 insertions(+), 56 deletions(-) create mode 100644 app/api/reports/general-ledger/__tests__/route.test.ts create mode 100644 supabase/migrations/20260709090000_invoice_payment_link.sql diff --git a/DECISIONS.md b/DECISIONS.md index 5c0495a3..8bd3ef7c 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -49,6 +49,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-07-08] Bedrock prod outage + Docker build failure both root-caused to dependabot #884 (a1fad319, 2026-07-06) bumping @anthropic-ai/bedrock-sdk 0.29.1->0.32.0. Runtime: 0.32.0 streaming returns an empty event stream ("request ended without sending any chunks", no HTTP status) - proven NOT a creds/region issue (prod diagnostic logged AKIA key + eu-west-1). Two prior sessions mis-diagnosed it as an AWS_* env collision and shipped/reverted #937 (BEDROCK_AWS_* rename) with no effect. "Works locally, fails on prod/CI" because local node_modules was stale at 0.29.1 while prod/Docker build fresh from the lockfile (0.32.0). Fix: pin back to ^0.29.1 + regenerate lockfile. FOLLOW-UP: add a dependabot ignore/exact-pin so it does not re-bump to 0.32.x and re-break both. [2026-07-08] One reconciliation PR adopts 3 prod-orphaned migrations (20260707113729 enrichment + 20260708120000/130000 ledger-stats RPCs) plus their pg-tests/fixtures onto main, instead of waiting on #927+#935 to merge: prod ledger was 3 versions ahead of the repo, leaving the default Supabase branch MIGRATIONS_FAILED and blocking every preview branch from being created. SQL committed byte-identical under the exact apply-time versions -> no-op on prod (idempotent), clean on fresh replays, and a no-op on #927/#935's next rebase. Carries #935's DB layer only (migrations + pg-tests + fixtures), not its UI/lib/i18n. Root anti-pattern: all three applied to prod via MCP apply_migration without committing the file (CLAUDE.md "never leave the remote DB ahead of the repo"). [2026-07-08] Pinned @anthropic-ai/bedrock-sdk to exact 0.29.1 (dependabot #884 auto-bumped it to 0.32.0, which broke Bedrock streaming in prod: empty stream / "request ended without sending any chunks"). Guarded three ways against accidental re-bump: exact pin in package.json, dependabot ignore, and a pinned-dep check in scripts/checks/no-new-antipatterns.mjs (check:guards). Unpin only once 0.32.x streaming is verified against Bedrock. +[2026-07-09] Invoice online payment ships as a manual paste-link MVP (invoices.payment_link_url + email button + PDF QR/link) instead of a full Stripe Connect integration: a day of work as a demand probe vs a week for Connect (OAuth onboarding, pay page, webhook auto-booking to 1686). Same column/UI is the upgrade path: Connect would auto-fill payment_link_url later, so nothing is throwaway. Field is PSP-agnostic ("Betalningslänk", any https URL) since the effort is identical and it also covers PayPal/Zettle. Derived documents (credit note, proforma convert, recurring) deliberately do NOT copy the link: a pasted link encodes one amount for one invoice. MCP tools/list token ceiling bumped 45K->45.5K (headroom was <10 tokens; ledger entry in payload-size.bench.test.ts). [2026-07-09] Issue #916 (disconnect orphans ledger accounts): release claims by demoting cash_accounts rows to manual (bank_connection_id = null), never deleting: transactions.cash_account_id and ledger history reference the rows, and upsertFromPsd2 promotes a manual holder in place on reconnect so the bank lands back on its original BAS slot. Orphans predating the fix self-heal via a revoked-status filter in the allocator + collision guard (not data repair). When a promote collides with a duplicate row for the same connection+uid (callback mirrored onto an overflow slot pre-fix), the duplicate is deleted only if it has zero linked transactions, otherwise demoted: preserves FK links while freeing the slot. Picker-save rejections now render inline in the picker instead of routing to the sync-progress modal, whose parent-unmount-on-close made every save outcome invisible. [2026-07-09] #917 fix scoped to the current-year suggestion: "Sedan räkenskapsårets början" now resolves from the fiscal_periods row containing today, but the "Föregående räkenskapsårets start" custom option still derives from the recurring fiscal_year_start_month: the issue only covers the current-year date and a first-year company has no previous period row to resolve against. [2026-07-09] Issue #919 (duplicate guard should steer to matching): the match action lives INSIDE DuplicateBookingDialog (fetch to /api/reconciliation/bank/link + account resolution via /api/cash-accounts + resolveAccount, exactly the MatchVoucherDialog path) rather than in each call site or a new endpoint: both call sites (transactions page runCategorize + TransactionBookingDialog/JournalEntryForm) share one implementation and pass only the transaction context + an onMatched callback mirroring onLinked. Match is primary ONLY for ledger-only candidates (transaction_id null, the SIE-import case); sibling-transaction candidates keep "Bokför ändå" primary since N:1 matching is the edge case. No lib change: the candidate already carries the transaction_id discriminator, covered by existing tests. @@ -58,6 +59,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-07-09] common.delete changed "Radera" to "Ta bort": grep proved the key has zero live call sites (every delete dialog uses feature-namespace keys), so this only affects future uses; convention going forward is Ta bort = detach/remove, Radera = irreversible destruction (kept in AccountDangerZone/CompanyDangerZone keys). [2026-07-09] InvoiceEditor customer-card description kept only for the self-billing branch (issuer_card_description adds real info: who issues the invoice); the plain-invoice branch dropped its description as a title paraphrase per design.md forbidden patterns. [2026-07-09] SalaryCalendar absence-type rainbow palette (red/amber/emerald/blue/indigo pills) left as-is in the UI consistency pass: those colors encode absence categories (data), not status chrome, and swapping them for the 3 semantic tokens would collapse 5 distinguishable categories; needs a proper categorical-palette decision instead of a mechanical fix. +[2026-07-10] Oresavrundning "fungerar inte" (support): kept the display-only design (booked verifikat stays ore-exact, 3740 absorption at bank match untouched) and fixed the surfaces that ignored it: invoice editor summary + mobile bar, supplier invoice form totals, supplier invoice list Belopp column, and the invoice EMAIL (Att betala used raw invoice.total while the attached PDF rounded; also ignored ROT/RUT deduction). Extracted the PDF's Att betala block to getAmountToPay (lib/invoices/rounding.ts) and pointed PDF + email at it so they cannot drift; behavior-identical refactor verified against HEAD. Supplier list rounds only the total column; "kvar att betala" stays ore-exact (actual outstanding debt), matching the detail page. Deferred (pre-existing, found in review): v1 API send route's invoice projection omits deduction_total, so ROT/RUT invoices sent via the public API already render PDF+email without the deduction; needs its own fix. [2026-07-10] Momsverifikat from momsrapport (#980): the proposal clears each 26xx account at exact öre but books the 2650/1650 net at the FILED whole-krona amount (buildFiledAmounts, öretal faller bort) with the gap on 3740, so redovisningskontot always matches the skattekonto movement; and vat_settlement entries are excluded from the VAT report projection (web calculateVatDeclaration + MCP computeVatReport) because a pure-projection report would otherwise read zero (and Skatteverket submission would file zeros) the moment the settlement is booked. [2026-07-10] VatBookingCard hard-disables "Skapa verifikat" while a POSTED vat_settlement exists in the period (CodeRabbit finding, accepted over the initial warn-but-allow): the proposal is not delta-aware (it re-clears the FULL period), so booking twice corrupts 26xx balances; the sanctioned redo path is annullera (storno restores the balances and re-enables the button). Already-booked detection is by source_type + entry_date within the period, so redating the entry outside the period escapes the gate: accepted v1 limitation. Card copy is hardcoded Swedish per the file's existing momsdeklaration convention (i18n.md). [2026-07-11] Momsrapport after settlement (#984): extended the VAT-report exclusion from tag-only to shape-based. Any entry touching both a declaration account (ACCOUNT_RUTA) and a settlement net account (2650/1650) is treated as a momsredovisning and excluded from the projection (web calculateVatDeclaration + MCP computeVatReport), covering manual momsomforingar booked before #980 shipped, SIE-imported settlements, and stornos of a settlement (which would otherwise double the rutor after annullera, a latent bug in the #983 tag-only filter). Opening-balance entries are exempt from the shape rule: carried-in 26xx balances are unsettled VAT that belongs in the next declaration. Shaped POSTED entries also gate the "Skapa verifikat" button via existing_entries (the proposal re-clears the full period, so booking over a manual settlement would corrupt 26xx); stornos never gate, or annullera could not re-enable booking. Rejected the frozen-snapshot alternative the issue suggested: pure projection heals historical periods retroactively (a snapshot would not exist for them) and needs no migration. @@ -69,3 +71,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-07-11] Momsdeklaration UI overhaul: deleted VatCompositionChart (donut mixed utgående/omvänd/ingående moms as slices of one pie, answering no filing question) and reduced the VAT ReportExportMenu to xlsx-only (XML/PDF are filing artifacts, now owned solely by the "Lämna in" card): both are one-commit reverts if vetoed. [2026-07-11] Hoisted local VAT checks + RC-gap worklist out of SkatteverketPanel into ungated VatChecksCard: the panel's paywall/not-connected early-returns hid compliance errors from exactly the users who file manually. [2026-07-11] NE/INK2 amounts display in whole kronor (matches filed SRU values per SFL); momsdeklaration keeps öre (reconciles against ledger and settlement verifikat). Numbered h2 section headers instead of a stepper component on the VAT page: same sequencing legibility, a tenth of the diff. +[2026-07-12] Compliance-review triage on the payment-link PR: finding 1 (email pay button on kreditfaktura) verified FALSE: invoice-templates.ts derives isCreditNote from credited_invoice_id and hidePayment already gates both HTML and text builders; no change. Finding 2 was the real deferred v1 gap but misfiled against invoice-columns.ts (which already carries deduction_total): the actual hole was the v1 send route's hand-rolled fetch projection, now replaced with the shared INVOICE_FULL_COLUMNS/INVOICE_ITEM_FULL_COLUMNS so PDF/email inputs cannot drift from the GET shape again (closes the [2026-07-10] deferred ROT/RUT send fix; also gives v1 sends the pay button + deduction box). Finding 3 accepted as a robustness fix only: the non-ok path already reflected true server state, but a thrown fetch left the Godkann spinner stuck; approve handler now try/catch/finally with a server refetch on failure. diff --git a/app/(dashboard)/supplier-invoices/page.tsx b/app/(dashboard)/supplier-invoices/page.tsx index 6053f2ef..85d0e100 100644 --- a/app/(dashboard)/supplier-invoices/page.tsx +++ b/app/(dashboard)/supplier-invoices/page.tsx @@ -15,7 +15,10 @@ import Link from 'next/link' import { PageHeader } from '@/components/ui/page-header' import NewSupplierInvoiceDialog from '@/components/supplier-invoices/NewSupplierInvoiceDialog' import { useCanWrite } from '@/lib/hooks/use-can-write' +import { useToast } from '@/components/ui/use-toast' +import { getErrorMessage } from '@/lib/errors/get-error-message' import { formatCurrency, formatDate } from '@/lib/utils' +import { getDisplayTotal } from '@/lib/invoices/rounding' import type { SupplierInvoice } from '@/types' const STATUS_VARIANTS: Record = { @@ -43,11 +46,13 @@ const STATUS_LABEL_KEYS: Record = { export default function SupplierInvoicesPage() { const t = useTranslations('supplier_invoices') const { canWrite } = useCanWrite() + const { toast } = useToast() const router = useRouter() const searchParams = useSearchParams() const [invoices, setInvoices] = useState<(SupplierInvoice & { supplier?: { id: string; name: string } })[]>([]) const [isLoading, setIsLoading] = useState(true) const [activeTab, setActiveTab] = useState('all') + const [approvingId, setApprovingId] = useState(null) // The "Registrera leverantörsfaktura" modal is driven by the URL (?new=1, // optionally with inbox_item_id for the invoice-inbox conversion flow) so @@ -88,16 +93,41 @@ export default function SupplierInvoicesPage() { fetchInvoices() } + // "Att betala" is the full payment queue: registered invoices are already + // booked as debt (2440), so they belong here too. Approval stays the gate + // for paying, not for visibility; unapproved rows get an inline approve. const filteredInvoices = invoices.filter((inv) => { switch (activeTab) { case 'registered': return inv.status === 'registered' case 'approved': return inv.status === 'approved' - case 'to_pay': return inv.status === 'approved' || inv.status === 'overdue' + case 'to_pay': return inv.status === 'registered' || inv.status === 'approved' || inv.status === 'overdue' case 'paid': return inv.status === 'paid' default: return true } }) + async function handleApprove(id: string) { + setApprovingId(id) + try { + const res = await fetch(`/api/supplier-invoices/${id}/approve`, { method: 'POST' }) + const result = await res.json() + if (!res.ok) { + toast({ title: t('approve_failed_title'), description: getErrorMessage(result, { context: 'supplier_invoice' }), variant: 'destructive' }) + // Re-sync from the server: an operator about to pay must see the + // invoice's true approval state, not an optimistic guess. + fetchInvoices() + } else { + toast({ title: t('approved_title'), description: t('approved_description') }) + setInvoices((prev) => prev.map((inv) => (inv.id === id ? { ...inv, status: 'approved' as const } : inv))) + } + } catch { + toast({ title: t('approve_failed_title'), description: getErrorMessage(null, { context: 'supplier_invoice' }), variant: 'destructive' }) + fetchInvoices() + } finally { + setApprovingId(null) + } + } + return (
{formatDate(inv.invoice_date)} {formatDate(inv.due_date)} - {formatCurrency(inv.total, inv.currency)} + {/* Belopp rounds like the detail page when the invoice's + öresavrundning flag is on; "kvar att betala" stays + öre-exact (it is the actual outstanding debt). */} + + {formatCurrency(getDisplayTotal( + { total: inv.total, currency: inv.currency, ore_rounding: inv.ore_rounding }, + { ore_rounding: false }, + ).displayed, inv.currency)} + {formatCurrency(inv.remaining_amount, inv.currency)} - - {STATUS_LABEL_KEYS[inv.status] ? t(STATUS_LABEL_KEYS[inv.status]) : inv.status} - + {activeTab === 'to_pay' && inv.status === 'registered' ? ( +
+ {t('not_approved')} + {!inv.is_credit_note && canWrite && ( + + )} +
+ ) : ( + + {STATUS_LABEL_KEYS[inv.status] ? t(STATUS_LABEL_KEYS[inv.status]) : inv.status} + + )}
))} diff --git a/app/api/invoices/[id]/pdf/route.ts b/app/api/invoices/[id]/pdf/route.ts index e4f7f519..05f47dd0 100644 --- a/app/api/invoices/[id]/pdf/route.ts +++ b/app/api/invoices/[id]/pdf/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from 'next/server' import { renderToBuffer } from '@react-pdf/renderer' import { withRouteContext } from '@/lib/api/with-route-context' import { InvoicePDF } from '@/lib/invoices/pdf-template' -import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf-render-helpers' +import { prepareInvoicePdfRender, buildSwishQrDataUrl, buildPaymentLinkQrDataUrl } from '@/lib/invoices/pdf-render-helpers' import type { Invoice, InvoiceItem, Customer, CompanySettings } from '@/types' export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( @@ -60,6 +60,7 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( company as CompanySettings, ) const swishQrDataUrl = await buildSwishQrDataUrl(company as CompanySettings, invoice as Invoice) + const paymentLinkQrDataUrl = await buildPaymentLinkQrDataUrl(invoice as Invoice) const pdfBuffer = await renderToBuffer( InvoicePDF({ invoice: invoice as Invoice, @@ -69,6 +70,7 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( originalInvoiceNumber, branding, swishQrDataUrl, + paymentLinkQrDataUrl, }) ) diff --git a/app/api/invoices/[id]/send/route.ts b/app/api/invoices/[id]/send/route.ts index 1e8de440..9c60787f 100644 --- a/app/api/invoices/[id]/send/route.ts +++ b/app/api/invoices/[id]/send/route.ts @@ -3,7 +3,7 @@ import { eventBus } from '@/lib/events' import { ensureInitialized } from '@/lib/init' import { renderToBuffer } from '@react-pdf/renderer' import { InvoicePDF } from '@/lib/invoices/pdf-template' -import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf-render-helpers' +import { prepareInvoicePdfRender, buildSwishQrDataUrl, buildPaymentLinkQrDataUrl } from '@/lib/invoices/pdf-render-helpers' import { getEmailService } from '@/lib/email/service' import { generateInvoiceEmailHtml, @@ -141,6 +141,7 @@ export const POST = withRouteContext( company as CompanySettings, ) const swishQrDataUrl = await buildSwishQrDataUrl(company as CompanySettings, renderableInvoice) + const paymentLinkQrDataUrl = await buildPaymentLinkQrDataUrl(renderableInvoice) const pdfBuffer = await renderToBuffer( InvoicePDF({ invoice: renderableInvoice, @@ -150,6 +151,7 @@ export const POST = withRouteContext( originalInvoiceNumber, branding, swishQrDataUrl, + paymentLinkQrDataUrl, }), ) diff --git a/app/api/invoices/preview-pdf/route.ts b/app/api/invoices/preview-pdf/route.ts index 068da7ea..3a8c4731 100644 --- a/app/api/invoices/preview-pdf/route.ts +++ b/app/api/invoices/preview-pdf/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from 'next/server' import { renderToBuffer } from '@react-pdf/renderer' import { withRouteContext } from '@/lib/api/with-route-context' import { InvoicePDF } from '@/lib/invoices/pdf-template' -import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf-render-helpers' +import { prepareInvoicePdfRender, buildSwishQrDataUrl, buildPaymentLinkQrDataUrl } from '@/lib/invoices/pdf-render-helpers' import { getVatRules } from '@/lib/invoices/vat-rules' import type { Invoice, InvoiceItem, Customer, CompanySettings, InvoiceDocumentType } from '@/types' @@ -14,7 +14,18 @@ import type { Invoice, InvoiceItem, Customer, CompanySettings, InvoiceDocumentTy */ export const POST = withRouteContext('invoice.preview_pdf', async (request, { supabase, user, companyId }) => { const body = await request.json() - const { customer_id, invoice_date, due_date, delivery_date, currency, items, your_reference, our_reference, notes, document_type, invoice_number } = body + const { customer_id, invoice_date, due_date, delivery_date, currency, items, your_reference, our_reference, notes, document_type, invoice_number, payment_link_url } = body + + // Preview-only https gate, mirroring CreateInvoiceSchema: the value is + // rendered as a clickable link + QR in the preview PDF. + const previewPaymentLink = (() => { + if (typeof payment_link_url !== 'string' || !payment_link_url.trim()) return null + try { + return new URL(payment_link_url).protocol === 'https:' ? payment_link_url.trim() : null + } catch { + return null + } + })() if (!items || items.length === 0) { return NextResponse.json({ error: 'Rader krävs' }, { status: 400 }) @@ -156,6 +167,7 @@ export const POST = withRouteContext('invoice.preview_pdf', async (request, { su your_reference: your_reference || null, our_reference: our_reference || null, notes: notes || null, + payment_link_url: previewPaymentLink, reverse_charge_text: vatRules.reverseChargeText || null, credited_invoice_id: null, document_type: docType, @@ -171,6 +183,7 @@ export const POST = withRouteContext('invoice.preview_pdf', async (request, { su company as CompanySettings, ) const swishQrDataUrl = await buildSwishQrDataUrl(company as CompanySettings, previewInvoice) + const paymentLinkQrDataUrl = await buildPaymentLinkQrDataUrl(previewInvoice) const pdfBuffer = await renderToBuffer( InvoicePDF({ invoice: previewInvoice, @@ -180,6 +193,7 @@ export const POST = withRouteContext('invoice.preview_pdf', async (request, { su isPreview: true, branding, swishQrDataUrl, + paymentLinkQrDataUrl, }) ) diff --git a/app/api/reports/general-ledger/__tests__/route.test.ts b/app/api/reports/general-ledger/__tests__/route.test.ts new file mode 100644 index 00000000..e917a5b6 --- /dev/null +++ b/app/api/reports/general-ledger/__tests__/route.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const { supabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +vi.mock('@/lib/reports/general-ledger', () => ({ + generateGeneralLedger: vi.fn(), +})) + +import { generateGeneralLedger } from '@/lib/reports/general-ledger' +import { GET } from '../route' + +const mockGenerate = vi.mocked(generateGeneralLedger) + +function authed() { + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null }) +} + +function unauthed() { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) +} + +const PERIOD = { period_start: '2026-01-01', period_end: '2026-12-31' } + +// Next.js 16 static-route second arg +const noParams = { params: Promise.resolve({}) } + +const EMPTY_REPORT = { + accounts: [], + period: { start: '2026-01-01', end: '2026-12-31' }, +} + +beforeEach(() => { + vi.clearAllMocks() + reset() + authed() +}) + +describe('GET /api/reports/general-ledger', () => { + it('returns 401 when not authenticated', async () => { + unauthed() + const res = await GET(createMockRequest('/api/reports/general-ledger'), noParams) + expect(res.status).toBe(401) + }) + + it('returns 400 when period_id is missing', async () => { + const res = await GET(createMockRequest('/api/reports/general-ledger'), noParams) + expect(res.status).toBe(400) + }) + + it('returns 400 for a malformed from_date', async () => { + enqueue({ data: PERIOD }) // fiscal_periods + const res = await GET( + createMockRequest('/api/reports/general-ledger', { + searchParams: { period_id: 'period-1', from_date: '2026-6-1' }, + }), + noParams + ) + const { status } = await parseJsonResponse(res) + expect(status).toBe(400) + expect(mockGenerate).not.toHaveBeenCalled() + }) + + it('returns 400 when the range falls outside the fiscal period', async () => { + enqueue({ data: PERIOD }) // fiscal_periods + const res = await GET( + createMockRequest('/api/reports/general-ledger', { + searchParams: { period_id: 'period-1', from_date: '2025-06-01' }, + }), + noParams + ) + const { status } = await parseJsonResponse(res) + expect(status).toBe(400) + expect(mockGenerate).not.toHaveBeenCalled() + }) + + it('passes the validated date range through to the generator', async () => { + enqueue({ data: PERIOD }) // fiscal_periods + mockGenerate.mockResolvedValue({ + ...EMPTY_REPORT, + period: { start: '2026-06-01', end: '2026-06-30' }, + }) + + const res = await GET( + createMockRequest('/api/reports/general-ledger', { + searchParams: { + period_id: 'period-1', + from_date: '2026-06-01', + to_date: '2026-06-30', + }, + }), + noParams + ) + const { status, body } = await parseJsonResponse<{ data: typeof EMPTY_REPORT }>(res) + expect(status).toBe(200) + expect(body.data.period).toEqual({ start: '2026-06-01', end: '2026-06-30' }) + expect(mockGenerate).toHaveBeenCalledWith( + supabase, + 'company-1', + 'period-1', + undefined, + undefined, + expect.objectContaining({ fromDate: '2026-06-01', toDate: '2026-06-30' }) + ) + }) + + it('omits the range when no date params are sent (full period)', async () => { + enqueue({ data: PERIOD }) // fiscal_periods + mockGenerate.mockResolvedValue(EMPTY_REPORT) + + const res = await GET( + createMockRequest('/api/reports/general-ledger', { + searchParams: { period_id: 'period-1' }, + }), + noParams + ) + expect(res.status).toBe(200) + expect(mockGenerate).toHaveBeenCalledWith( + supabase, + 'company-1', + 'period-1', + undefined, + undefined, + expect.objectContaining({ fromDate: undefined, toDate: undefined }) + ) + }) +}) diff --git a/app/api/reports/general-ledger/route.ts b/app/api/reports/general-ledger/route.ts index 5ee3b918..0888dfd5 100644 --- a/app/api/reports/general-ledger/route.ts +++ b/app/api/reports/general-ledger/route.ts @@ -3,6 +3,7 @@ import { generateGeneralLedger } from '@/lib/reports/general-ledger' import { withRouteContext } from '@/lib/api/with-route-context' import { errorResponseFromCode } from '@/lib/errors/get-structured-error' import { parseDimensionFilterParams } from '@/lib/reports/dimension-filter' +import { parseReportDateRange, type DateRange } from '@/lib/reports/date-range' export const GET = withRouteContext( 'report.general_ledger', @@ -23,9 +24,28 @@ export const GET = withRouteContext( return NextResponse.json({ error: dimFilter.error }, { status: 400 }) } + // Validate the optional date sub-range against the fiscal period bounds. + const { data: period } = await supabase + .from('fiscal_periods') + .select('period_start, period_end') + .eq('id', periodId) + .eq('company_id', companyId!) + .single() + + let range: DateRange = {} + if (period) { + const parsed = parseReportDateRange(searchParams, period) + if (!parsed.ok) { + return NextResponse.json({ error: parsed.error }, { status: 400 }) + } + range = parsed.range + } + try { const data = await generateGeneralLedger(supabase, companyId!, periodId, accountFrom, accountTo, { dimensions: dimFilter.dimensions, + fromDate: range.fromDate, + toDate: range.toDate, }) return NextResponse.json({ data }) } catch (err) { diff --git a/app/api/reports/general-ledger/xlsx/route.ts b/app/api/reports/general-ledger/xlsx/route.ts index 53e47822..370a4259 100644 --- a/app/api/reports/general-ledger/xlsx/route.ts +++ b/app/api/reports/general-ledger/xlsx/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server' import { generateGeneralLedger } from '@/lib/reports/general-ledger' import { withRouteContext } from '@/lib/api/with-route-context' import { parseDimensionFilterParams, dimensionFilterDisclosure, dimensionFilterFileSuffix } from '@/lib/reports/dimension-filter' +import { parseReportDateRange, type DateRange } from '@/lib/reports/date-range' import { reportToWorkbook, textColumn, @@ -50,9 +51,28 @@ export const GET = withRouteContext('report.general_ledger.xlsx', async (request return NextResponse.json({ error: dimFilter.error }, { status: 400 }) } + // Validate the optional date sub-range against the fiscal period bounds. + const { data: period } = await supabase + .from('fiscal_periods') + .select('period_start, period_end') + .eq('id', periodId) + .eq('company_id', companyId) + .single() + + let range: DateRange = {} + if (period) { + const parsed = parseReportDateRange(searchParams, period) + if (!parsed.ok) { + return NextResponse.json({ error: parsed.error }, { status: 400 }) + } + range = parsed.range + } + try { const report = await generateGeneralLedger(supabase, companyId, periodId, accountFrom, accountTo, { dimensions: dimFilter.dimensions, + fromDate: range.fromDate, + toDate: range.toDate, }) // Flatten accounts + their lines into a single sheet. Each account contributes diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts index e774d421..f463c518 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts @@ -57,14 +57,9 @@ import { eventBus } from '@/lib/events' import { guardSandbox } from '@/lib/sandbox/guard' import { requireCapability } from '@/lib/entitlements/has-capability' import { CAPABILITY } from '@/lib/entitlements/keys' +import { INVOICE_FULL_COLUMNS, INVOICE_ITEM_FULL_COLUMNS } from '@/lib/api/v1/invoice-columns' import type { CompanySettings, Customer, EntityType, Invoice, InvoiceItem } from '@/types' -// default_dimensions must stay in this projection: the fetched row feeds -// createInvoiceJournalEntry, which reads the bag off the row — dropping the -// column here silently untags the revenue JE lines. -const INVOICE_SEND_RESPONSE_COLUMNS = - 'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, notes, reverse_charge_text, credited_invoice_id, document_type, converted_from_id, paid_at, paid_amount, remaining_amount, default_dimensions, created_at, updated_at' - const InvoiceSendResponse = z.object({ id: z.string().uuid(), invoice_number: z.string(), @@ -158,11 +153,16 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }) } - // Fetch invoice + customer + items. + // Fetch invoice + customer + items. Uses the shared full projections so + // the row feeding the PDF/email/journal entry cannot drift from what GET + // returns: an earlier hand-rolled list here silently dropped + // deduction_total, which made v1-sent ROT/RUT invoices overstate + // "Att betala" (default_dimensions must also stay: createInvoiceJournalEntry + // reads the bag off this row). const { data: invoice, error: fetchErr } = await ctx.supabase .from('invoices') .select( - `${INVOICE_SEND_RESPONSE_COLUMNS}, customer:customers(id, name, customer_number, email, customer_type, country, address_line1, address_line2, postal_code, city, vat_number), items:invoice_items(id, sort_order, description, quantity, unit, unit_price, line_total, vat_rate, vat_amount, revenue_account, dimensions)`, + `${INVOICE_FULL_COLUMNS}, customer:customers(id, name, customer_number, email, customer_type, country, address_line1, address_line2, postal_code, city, vat_number), items:invoice_items(${INVOICE_ITEM_FULL_COLUMNS})`, ) .eq('company_id', ctx.companyId!) .eq('id', invoiceId) diff --git a/components/invoices/InvoiceEditor.tsx b/components/invoices/InvoiceEditor.tsx index 02b0dd9b..30a0ca1a 100644 --- a/components/invoices/InvoiceEditor.tsx +++ b/components/invoices/InvoiceEditor.tsx @@ -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 />
+ {/* 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' && ( +
+ + + {errors.payment_link_url ? ( +

{errors.payment_link_url.message}

+ ) : ( +

{t('payment_link_hint')}

+ )} +
+ )} + {/* 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 {formatCurrency(0, watchCurrency)} )} + {displayRounding.applies && ( +
+ {t('ore_rounding_label')} + {formatCurrency(displayRounding.roundingDelta, watchCurrency)} +
+ )} {hasAnyDeduction && (
{t('deduction_summary_label')} @@ -2152,7 +2206,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
{hasAnyDeduction ? t('to_pay_label') : t('total_label')} - {formatCurrency(hasAnyDeduction ? toPay : total, watchCurrency)} + {formatCurrency(displayedToPay, watchCurrency)}
{hasAnyDeduction && (
@@ -2226,7 +2280,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat {hasAnyDeduction ? t('to_pay_label') : t('total_label')}

- {formatCurrency(hasAnyDeduction ? toPay : total, watchCurrency)} + {formatCurrency(displayedToPay, watchCurrency)}

diff --git a/components/reports/FocusedReport.tsx b/components/reports/FocusedReport.tsx index 496be184..4657e07e 100644 --- a/components/reports/FocusedReport.tsx +++ b/components/reports/FocusedReport.tsx @@ -189,7 +189,7 @@ function FocusedView({ case 'ink2-declaration': return isAktiebolag ? : null case 'huvudbok': - return + return case 'grundbok': return case 'kundreskontra': diff --git a/components/reports/views/index.tsx b/components/reports/views/index.tsx index fb56ab86..0238c7c0 100644 --- a/components/reports/views/index.tsx +++ b/components/reports/views/index.tsx @@ -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(null) const [loading, setLoading] = useState(false) const [error, setError] = useState(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 (
- + {/* Account range filter */} diff --git a/components/supplier-invoices/NewSupplierInvoiceForm.tsx b/components/supplier-invoices/NewSupplierInvoiceForm.tsx index c5fda84c..70da9aff 100644 --- a/components/supplier-invoices/NewSupplierInvoiceForm.tsx +++ b/components/supplier-invoices/NewSupplierInvoiceForm.tsx @@ -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({ {formatCurrency(totalVat, watchedCurrency)}
+ {displayRounding.applies && ( +
+ {t('ore_rounding_label')} + {formatCurrency(displayRounding.roundingDelta, watchedCurrency)} +
+ )}
{t('total_label')} - {formatCurrency(total, watchedCurrency)} + {formatCurrency(displayRounding.displayed, watchedCurrency)}
{/* Öresavrundning: display-only rounding of the displayed total to whole kronor (SEK only). The registered amount and the booked diff --git a/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts b/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts index 7c033acb..3a64f27b 100644 --- a/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts +++ b/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts @@ -92,9 +92,14 @@ describe('tools/list payload size guard', () => { // eligible/blocked per-invoice output). Each side alone was under the // ceiling; the combination crossed it by ~220. Descriptions are at // their trimmed floor per the entries above. + // * 45K → 45.5K when payment_link_url landed on gnubok_create_invoice + // (manual payment-link MVP): one optional string property with an + // already-minimal ~24-token description. Headroom before the change was + // under 10 tokens, so even this smallest possible addition crossed; + // other descriptions are at their trimmed floor per the entries above. // Long-term answer to growth is leaning harder on gnubok_search_tools: if this // fires again, prefer trimming descriptions or making a tool opt-in via search // before bumping further. - expect(approxTokens).toBeLessThan(45_000) + expect(approxTokens).toBeLessThan(45_500) }) }) diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index cbbe8669..63100421 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -3618,6 +3618,10 @@ export const tools: McpTool[] = [ our_reference: { type: 'string' }, your_reference: { type: 'string' }, notes: { type: 'string' }, + payment_link_url: { + type: 'string', + description: 'Optional https pay link for THIS invoice (e.g. Stripe); rendered in the invoice email and PDF.', + }, }, required: ['customer_id', 'items'], }, @@ -3667,6 +3671,21 @@ export const tools: McpTool[] = [ return bag && Object.keys(bag).length > 0 ? { ...rest, dimensions: bag } : rest }) + // Same https-only gate as the web API (CreateInvoiceSchema): the link is + // rendered in customer-facing emails/PDFs under the company's name. + const paymentLinkUrl = (args.payment_link_url as string | undefined)?.trim() || null + if (paymentLinkUrl) { + let isHttps = false + try { + isHttps = new URL(paymentLinkUrl).protocol === 'https:' + } catch { + isHttps = false + } + if (!isHttps || paymentLinkUrl.length > 2048) { + throw new Error('payment_link_url must be a valid https URL (max 2048 chars).') + } + } + const today = new Date().toISOString().split('T')[0] const currency = ((args.currency as string) || 'SEK') as Currency const invoiceDate = (args.invoice_date as string) || today @@ -3727,6 +3746,7 @@ export const tools: McpTool[] = [ our_reference: (args.our_reference as string) || null, your_reference: (args.your_reference as string) || null, notes: (args.notes as string) || null, + payment_link_url: paymentLinkUrl, }, { customer_name: customer.name, diff --git a/lib/api/__tests__/schemas.test.ts b/lib/api/__tests__/schemas.test.ts index 6e33f86c..320c484c 100644 --- a/lib/api/__tests__/schemas.test.ts +++ b/lib/api/__tests__/schemas.test.ts @@ -291,6 +291,38 @@ describe('CreateInvoiceSchema', () => { expect(result.success).toBe(true) }) + it('payment_link_url accepts a valid https URL', () => { + const result = CreateInvoiceSchema.safeParse(validInvoice({ + payment_link_url: 'https://buy.stripe.com/test_abc123', + })) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.payment_link_url).toBe('https://buy.stripe.com/test_abc123') + } + }) + + it('payment_link_url normalises empty string to undefined (form always sends the field)', () => { + const result = CreateInvoiceSchema.safeParse(validInvoice({ payment_link_url: '' })) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.payment_link_url).toBeUndefined() + } + }) + + it('payment_link_url rejects non-https and malformed values', () => { + const bad = [ + 'http://buy.stripe.com/abc', // plaintext link in a customer email + 'javascript:alert(1)', + 'not a url', + `https://pay.example.se/${'a'.repeat(2049)}`, // over the 2048 cap + ] + for (const value of bad) { + expect( + CreateInvoiceSchema.safeParse(validInvoice({ payment_link_url: value })).success, + ).toBe(false) + } + }) + it('accepts invoice with per-line VAT rates', () => { const result = CreateInvoiceSchema.safeParse(validInvoice({ items: [ diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 97980ec1..6611ab13 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -362,6 +362,28 @@ export const CreateInvoiceSchema = z.object({ your_reference: z.string().optional(), our_reference: z.string().optional(), notes: z.string().optional(), + // Optional online payment link (manual MVP): the user pastes a link created + // in their PSP dashboard (e.g. a Stripe Payment Link). https-only because the + // URL is rendered in customer-facing emails/PDFs under the company's name. + // The invoice form always sends the field ('' when empty), so empty string + // normalises to undefined like external_invoice_number above; build-invoice- + // write maps undefined to NULL so clearing the field on a draft edit works. + payment_link_url: z + .union([ + z + .string() + .max(2048) + .refine((v) => { + try { + return new URL(v).protocol === 'https:' + } catch { + return false + } + }, 'Ogiltig betalningslänk (måste vara en https-adress)'), + z.literal(''), + ]) + .transform((v) => v || undefined) + .optional(), // ROT/RUT claim info. The personnummer is plaintext on the wire and gets // encrypted server-side before it ever hits the DB (see encryptPersonnummer // in lib/salary/personnummer.ts). `deduction_housing_designation` is the diff --git a/lib/api/v1/invoice-columns.ts b/lib/api/v1/invoice-columns.ts index 46dd346a..0ad97ba2 100644 --- a/lib/api/v1/invoice-columns.ts +++ b/lib/api/v1/invoice-columns.ts @@ -12,7 +12,7 @@ */ export const INVOICE_FULL_COLUMNS = - 'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, notes, reverse_charge_text, credited_invoice_id, document_type, converted_from_id, paid_at, paid_amount, remaining_amount, default_dimensions, deduction_total, deduction_personnummer_last4, created_at, updated_at' + 'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, notes, payment_link_url, reverse_charge_text, credited_invoice_id, document_type, converted_from_id, paid_at, paid_amount, remaining_amount, default_dimensions, deduction_total, deduction_personnummer_last4, created_at, updated_at' export const INVOICE_ITEM_FULL_COLUMNS = 'id, sort_order, line_type, description, quantity, unit, unit_price, line_total, vat_rate, vat_amount, article_id, revenue_account, deduction_type, deduction_amount, labor_hours, work_type, housing_designation, apartment_number, brf_org_number, dimensions, created_at' diff --git a/lib/email/__tests__/invoice-templates.test.ts b/lib/email/__tests__/invoice-templates.test.ts index f92f46fe..73077f7b 100644 --- a/lib/email/__tests__/invoice-templates.test.ts +++ b/lib/email/__tests__/invoice-templates.test.ts @@ -357,4 +357,96 @@ describe('invoice email templates', () => { .toBe('F\u00f6ljesedel 1045 fr\u00e5n Acme AB') }) }) + + describe('payment link (payment_link_url)', () => { + const svCustomer = makeCustomer({ name: 'Erik Andersson', email: 'erik@example.se', language: 'sv' }) + const linkUrl = 'https://buy.stripe.com/test_abc123' + + it('renders a pay-online button in HTML and the URL in plain text when set', () => { + const linked = makeInvoice({ invoice_number: '1042', payment_link_url: linkUrl }) + const html = generateInvoiceEmailHtml({ invoice: linked, customer: svCustomer, company }) + expect(html).toContain(`href="${linkUrl}"`) + expect(html).toContain('Betala online') + const text = generateInvoiceEmailText({ invoice: linked, customer: svCustomer, company }) + expect(text).toContain(`Betala online: ${linkUrl}`) + }) + + it('uses the English label for English customers', () => { + const enCustomer = makeCustomer({ name: 'Jane Doe', email: 'jane@example.com', language: 'en' }) + const linked = makeInvoice({ invoice_number: '1042', payment_link_url: linkUrl }) + const html = generateInvoiceEmailHtml({ invoice: linked, customer: enCustomer, company }) + expect(html).toContain('Pay online') + expect(html).not.toContain('Betala online') + }) + + it('omits the button when no link is set', () => { + const html = generateInvoiceEmailHtml({ invoice, customer: svCustomer, company }) + expect(html).not.toContain('Betala online') + const text = generateInvoiceEmailText({ invoice, customer: svCustomer, company }) + expect(text).not.toContain('Betala online') + }) + + it('hides the button on credit notes even if a link is present on the row', () => { + const creditNote = makeInvoice({ + invoice_number: '1043', + credited_invoice_id: 'inv-orig', + total: -5000, + payment_link_url: linkUrl, + }) + const html = generateInvoiceEmailHtml({ invoice: creditNote, customer: svCustomer, company }) + expect(html).not.toContain('Betala online') + }) + + it('escapes quote characters in the URL for the href attribute', () => { + const sneaky = 'https://pay.example.se/x?a="onmouseover=alert(1)' + const linked = makeInvoice({ invoice_number: '1042', payment_link_url: sneaky }) + const html = generateInvoiceEmailHtml({ invoice: linked, customer: svCustomer, company }) + expect(html).not.toContain('a="onmouseover') + expect(html).toContain('"onmouseover=alert(1)') + }) + }) + + describe('öresavrundning: "Att betala" matches the PDF', () => { + const svCustomer = makeCustomer({ name: 'Erik Andersson', email: 'erik@example.se', language: 'sv' }) + + it('rounds the SEK total to whole kronor when rounding is on (company default)', () => { + const oreInvoice = makeInvoice({ invoice_number: '1042', total: 1234.56 }) + const html = generateInvoiceEmailHtml({ invoice: oreInvoice, customer: svCustomer, company }) + expect(html).toMatch(/1[\s ]235,00 SEK/) + expect(html).not.toContain('234,56') + const text = generateInvoiceEmailText({ invoice: oreInvoice, customer: svCustomer, company }) + expect(text).toMatch(/Att betala: 1[\s ]235,00 SEK/) + }) + + it('keeps the exact öre when the per-invoice flag turns rounding off', () => { + const exactInvoice = makeInvoice({ invoice_number: '1042', total: 1234.56, ore_rounding: false }) + const html = generateInvoiceEmailHtml({ invoice: exactInvoice, customer: svCustomer, company }) + expect(html).toMatch(/1[\s ]234,56 SEK/) + expect(html).not.toMatch(/1[\s ]235,00 SEK/) + }) + + it('does not round non-SEK invoices', () => { + const eurInvoice = makeInvoice({ invoice_number: '1042', currency: 'EUR', total: 1234.56 }) + const text = generateInvoiceEmailText({ invoice: eurInvoice, customer: svCustomer, company }) + expect(text).toMatch(/1[\s ]234,56 EUR/) + }) + + it('subtracts the ROT/RUT deduction so the email states what the customer owes', () => { + const rotInvoice = makeInvoice({ invoice_number: '1042', total: 1234.56, deduction_total: 500 }) + const html = generateInvoiceEmailHtml({ invoice: rotInvoice, customer: svCustomer, company }) + expect(html).toContain('735,00 SEK') + const text = generateInvoiceEmailText({ invoice: rotInvoice, customer: svCustomer, company }) + expect(text).toContain('Att betala: 735,00 SEK') + }) + + it('uses the rounded amount for the {belopp} placeholder', () => { + const withBelopp = makeCompanySettings({ + company_name: 'Acme AB', + invoice_email_texts: { sv: { body: 'Summa: {belopp}' } }, + }) + const oreInvoice = makeInvoice({ invoice_number: '1042', total: 1234.56 }) + const text = generateInvoiceEmailText({ invoice: oreInvoice, customer: svCustomer, company: withBelopp }) + expect(text).toMatch(/Summa: 1[\s ]235,00 SEK/) + }) + }) }) diff --git a/lib/email/invoice-templates.ts b/lib/email/invoice-templates.ts index ddb09e44..4441b4b7 100644 --- a/lib/email/invoice-templates.ts +++ b/lib/email/invoice-templates.ts @@ -1,6 +1,7 @@ import type { Invoice, Customer, CompanySettings, InvoiceDocumentType } from '@/types' import { formatDate, getCompanyDisplayName, getCompanyPrimaryName } from '@/lib/utils' -import { applyPlaceholders, sanitizeSubjectLine, userTextToHtml } from './user-text' +import { getAmountToPay } from '@/lib/invoices/rounding' +import { applyPlaceholders, escapeHtml, sanitizeSubjectLine, userTextToHtml } from './user-text' type EmailLang = 'sv' | 'en' @@ -23,6 +24,7 @@ const LABELS = { bodyCreditNote: 'Bifogat hittar du en kreditfaktura som korrigerar en tidigare faktura.', bodyInvoice: 'Tack för ditt förtroende! Bifogat hittar du din faktura.', toPay: 'Att betala:', + payOnline: 'Betala online', paymentHeading: 'Betalningsinformation', bank: 'Bank:', account: 'Kontonummer:', @@ -51,6 +53,7 @@ const LABELS = { bodyCreditNote: 'Attached you will find a credit note that corrects an earlier invoice.', bodyInvoice: 'Thank you for your business. Attached you will find your invoice.', toPay: 'Total due:', + payOnline: 'Pay online', paymentHeading: 'Payment information', bank: 'Bank:', account: 'Account number:', @@ -150,7 +153,7 @@ function buildPlaceholderValues(data: InvoiceEmailData, lang: EmailLang): Record förnamn: fullName ? fullName.split(' ')[0] : '', företag: getCompanyPrimaryName(company), förfallodatum: formatDate(invoice.due_date), - belopp: formatCurrencyForCustomer(invoice.total, invoice.currency, lang), + belopp: formatCurrencyForCustomer(getAmountToPay(invoice, company).toPay, invoice.currency, lang), } } @@ -267,12 +270,23 @@ export function generateInvoiceEmailHtml(data: InvoiceEmailData): string { ${L.toPay} - ${formatCurrencyForCustomer(invoice.total, invoice.currency, lang)} + ${formatCurrencyForCustomer(getAmountToPay(invoice, company).toPay, invoice.currency, lang)}
+ + ${!hidePayment && invoice.payment_link_url ? ` + + ` : ''} + ${!hidePayment ? `
@@ -364,11 +378,12 @@ export function generateInvoiceEmailText(data: InvoiceEmailData): string { text += `${L.documentNumber(documentType)} ${invoice.invoice_number}\n` text += `${L.documentDate(documentType)} ${formatDate(invoice.invoice_date)}\n` text += `${L.dueDate} ${formatDate(invoice.due_date)}\n` - text += `${L.toPay} ${formatCurrencyForCustomer(invoice.total, invoice.currency, lang)}\n` + text += `${L.toPay} ${formatCurrencyForCustomer(getAmountToPay(invoice, company).toPay, invoice.currency, lang)}\n` text += `---\n\n` if (!hidePayment) { text += `${L.paymentHeading}:\n` + if (invoice.payment_link_url) text += `${L.payOnline}: ${invoice.payment_link_url}\n` if (company.bank_name) text += `${L.bank} ${company.bank_name}\n` if (company.clearing_number && company.account_number) { text += `${L.account} ${company.clearing_number}-${company.account_number}\n` diff --git a/lib/invoices/__tests__/build-invoice-write.test.ts b/lib/invoices/__tests__/build-invoice-write.test.ts index f387cf47..a6bf9eff 100644 --- a/lib/invoices/__tests__/build-invoice-write.test.ts +++ b/lib/invoices/__tests__/build-invoice-write.test.ts @@ -58,6 +58,34 @@ describe('buildInvoiceWriteData', () => { }) }) + it('maps payment_link_url to a concrete trimmed value, null when absent', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { vat_registered: true }, error: null }) + + const customer = makeCustomer({ customer_type: 'swedish_business' }) + const withLink = await call(enqueue, supabase as unknown as SupabaseClient, customer, { + ...baseHeader, + payment_link_url: ' https://buy.stripe.com/test_abc123 ', + items: [{ description: 'Konsult', quantity: 1, unit: 'tim', unit_price: 1000, vat_rate: 25 }], + }) + expect(withLink.ok).toBe(true) + if (!withLink.ok) return + expect(withLink.invoiceFields.payment_link_url).toBe('https://buy.stripe.com/test_abc123') + + // Absent input must still produce an explicit null (not undefined): + // supabase-js drops undefined keys, and a draft edit that cleared the + // field relies on the NULL actually being written. + const { supabase: supabase2, enqueue: enqueue2 } = createQueuedMockSupabase() + enqueue2({ data: { vat_registered: true }, error: null }) + const withoutLink = await call(enqueue2, supabase2 as unknown as SupabaseClient, customer, { + ...baseHeader, + items: [{ description: 'Konsult', quantity: 1, unit: 'tim', unit_price: 1000, vat_rate: 25 }], + }) + expect(withoutLink.ok).toBe(true) + if (!withoutLink.ok) return + expect(withoutLink.invoiceFields.payment_link_url).toBeNull() + }) + it('handles a mixed-rate invoice (vat_rate becomes null on the header)', async () => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { vat_registered: true }, error: null }) diff --git a/lib/invoices/__tests__/pdf-render-helpers.test.ts b/lib/invoices/__tests__/pdf-render-helpers.test.ts index 85102d13..a1cc3a3d 100644 --- a/lib/invoices/__tests__/pdf-render-helpers.test.ts +++ b/lib/invoices/__tests__/pdf-render-helpers.test.ts @@ -13,8 +13,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import sharp from 'sharp' -import { prepareInvoicePdfRender } from '@/lib/invoices/pdf-render-helpers' -import { makeCompanySettings } from '@/tests/helpers' +import { prepareInvoicePdfRender, buildPaymentLinkQrDataUrl } from '@/lib/invoices/pdf-render-helpers' +import { makeCompanySettings, makeInvoice } from '@/tests/helpers' const PNG_DATA_URL_PREFIX = 'data:image/png;base64,' @@ -177,3 +177,29 @@ describe('prepareInvoicePdfRender: logo resolution (issue #772)', () => { expect(branding.primaryColor).toBe('#c2410c') }) }) + +describe('buildPaymentLinkQrDataUrl', () => { + it('encodes the payment link as a PNG QR data URL for a real invoice', async () => { + const invoice = makeInvoice({ payment_link_url: 'https://buy.stripe.com/test_abc123' }) + const qr = await buildPaymentLinkQrDataUrl(invoice) + expect(qr).toMatch(new RegExp(`^${PNG_DATA_URL_PREFIX}`)) + }) + + it('returns null when the invoice has no payment link', async () => { + expect(await buildPaymentLinkQrDataUrl(makeInvoice())).toBeNull() + expect(await buildPaymentLinkQrDataUrl(makeInvoice({ payment_link_url: ' ' }))).toBeNull() + }) + + it('returns null for non-payable documents (proforma, delivery note, credit note)', async () => { + const url = 'https://buy.stripe.com/test_abc123' + expect( + await buildPaymentLinkQrDataUrl(makeInvoice({ payment_link_url: url, document_type: 'proforma' })), + ).toBeNull() + expect( + await buildPaymentLinkQrDataUrl(makeInvoice({ payment_link_url: url, document_type: 'delivery_note' })), + ).toBeNull() + expect( + await buildPaymentLinkQrDataUrl(makeInvoice({ payment_link_url: url, credited_invoice_id: 'inv-orig' })), + ).toBeNull() + }) +}) diff --git a/lib/invoices/__tests__/rounding.test.ts b/lib/invoices/__tests__/rounding.test.ts index 3584bb4b..66a7acea 100644 --- a/lib/invoices/__tests__/rounding.test.ts +++ b/lib/invoices/__tests__/rounding.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { getDisplayTotal } from '@/lib/invoices/rounding' +import { getAmountToPay, getDisplayTotal } from '@/lib/invoices/rounding' const inv = (total: number, currency: 'SEK' | 'EUR' = 'SEK') => ({ total, currency }) const co = (ore_rounding: boolean) => ({ ore_rounding }) @@ -69,3 +69,46 @@ describe('getDisplayTotal', () => { expect(r.displayed).toBe(99.99) }) }) + +describe('getAmountToPay', () => { + it('equals the rounded display total when there is no deduction', () => { + const r = getAmountToPay(inv(1234.56), co(true)) + expect(r.toPay).toBe(1235) + expect(r.deductionApplies).toBe(false) + expect(r.rounding.applies).toBe(true) + expect(r.rounding.roundingDelta).toBe(0.44) + }) + + it('subtracts the ROT/RUT deduction from the ROUNDED total', () => { + const r = getAmountToPay({ ...inv(1234.56), deduction_total: 500 }, co(true)) + expect(r.deductionApplies).toBe(true) + expect(r.toPay).toBe(735) + }) + + it('subtracts the deduction from the raw total when rounding is off', () => { + const r = getAmountToPay({ ...inv(1234.56), deduction_total: 500 }, co(false)) + expect(r.rounding.applies).toBe(false) + expect(r.toPay).toBe(734.56) + }) + + it('keeps öre precision in the deduction subtraction', () => { + // 1235 - 166.67 must not pick up float noise. + const r = getAmountToPay({ ...inv(1234.56), deduction_total: 166.67 }, co(true)) + expect(r.toPay).toBe(1068.33) + }) + + it('ignores the deduction on credit notes (fakturamodellen does not apply)', () => { + const r = getAmountToPay( + { ...inv(-1234.56), deduction_total: 500, credited_invoice_id: 'inv-1' }, + co(true), + ) + expect(r.deductionApplies).toBe(false) + expect(r.toPay).toBe(-1235) + }) + + it('does not round non-SEK invoices but still applies the deduction', () => { + const r = getAmountToPay({ ...inv(1234.56, 'EUR'), deduction_total: 100 }, co(true)) + expect(r.rounding.applies).toBe(false) + expect(r.toPay).toBe(1134.56) + }) +}) diff --git a/lib/invoices/build-invoice-write.ts b/lib/invoices/build-invoice-write.ts index 8238a8f9..fe37e704 100644 --- a/lib/invoices/build-invoice-write.ts +++ b/lib/invoices/build-invoice-write.ts @@ -68,6 +68,8 @@ export interface InvoiceWriteInput { your_reference?: string our_reference?: string notes?: string + /** Optional https payment link (schema-validated). Omitted/empty → null. */ + payment_link_url?: string /** Per-invoice öresavrundning override (display-only). Omitted → null (inherit company setting). */ ore_rounding?: boolean deduction_personnummer?: string @@ -105,6 +107,7 @@ export type InvoiceWriteFields = { your_reference: string | null | undefined our_reference: string | null | undefined notes: string | null | undefined + payment_link_url: string | null ore_rounding: boolean | null document_type: InvoiceDocumentType deduction_total: number @@ -365,6 +368,9 @@ export async function buildInvoiceWriteData(params: { your_reference: input.your_reference, our_reference: input.our_reference, notes: input.notes, + // Always a concrete value (never undefined) so a draft edit that cleared + // the field actually NULLs the column: supabase-js drops undefined keys. + payment_link_url: input.payment_link_url?.trim() || null, // Display-only öresavrundning override; null inherits company_settings.ore_rounding. ore_rounding: input.ore_rounding ?? null, document_type: documentType, diff --git a/lib/invoices/pdf-render-helpers.ts b/lib/invoices/pdf-render-helpers.ts index cd6ce548..814803a9 100644 --- a/lib/invoices/pdf-render-helpers.ts +++ b/lib/invoices/pdf-render-helpers.ts @@ -27,6 +27,7 @@ import { getDisplayTotal } from '@/lib/invoices/rounding' import { createLogger } from '@/lib/logger' const log = createLogger('invoice.swish-qr') +const paymentLinkLog = createLogger('invoice.payment-link-qr') export interface InvoicePdfRenderExtras { branding: InvoiceBranding @@ -163,6 +164,29 @@ export async function prepareInvoicePdfRender( * `swishQrDataUrl` prop; the template gates rendering on the same payment box * that already shows the Swish number. */ +/** + * Build the payment-link QR for an invoice as a PNG data URL, or null when the + * invoice carries no payment_link_url or it isn't a payable document (credit + * notes, proformas and delivery notes show no payment box). The URL was + * https-validated at write time (lib/api/schemas.ts); the QR simply encodes it + * locally with the `qrcode` lib: no call to any payment provider. + */ +export async function buildPaymentLinkQrDataUrl(invoice: Invoice): Promise { + const url = invoice.payment_link_url?.trim() + if (!url) return null + const docType = invoice.document_type || 'invoice' + if (docType !== 'invoice' || invoice.credited_invoice_id) return null + try { + return await QRCode.toDataURL(url, { margin: 1, width: 240, errorCorrectionLevel: 'M' }) + } catch (err) { + paymentLinkLog.warn('payment link QR generation failed', { + invoiceId: invoice.id, + error: err instanceof Error ? err.message : String(err), + }) + return null + } +} + export async function buildSwishQrDataUrl( company: CompanySettings, invoice: Invoice, diff --git a/lib/invoices/pdf-template.tsx b/lib/invoices/pdf-template.tsx index acd4e2b0..5dc645bc 100644 --- a/lib/invoices/pdf-template.tsx +++ b/lib/invoices/pdf-template.tsx @@ -4,11 +4,12 @@ import { Text, View, Image, + Link, StyleSheet, } from '@react-pdf/renderer' import type { Invoice, InvoiceItem, Customer, CompanySettings, InvoiceDocumentType } from '@/types' import { generateOcrReference } from '@/lib/bankgiro/luhn' -import { getDisplayTotal } from '@/lib/invoices/rounding' +import { getAmountToPay } from '@/lib/invoices/rounding' type PdfLang = 'sv' | 'en' @@ -86,6 +87,8 @@ const LABELS = { paymentReference: 'Betalningsreferens:', invoiceNumber: 'Fakturanummer:', swishQrCaption: 'Skanna för att betala med Swish', + payOnline: 'Betala online:', + paymentLinkQrCaption: 'Skanna för att betala online', // Footer orgNoLong: 'Org.nr:', vatRegNo: 'Momsreg.nr:', @@ -152,6 +155,8 @@ const LABELS = { paymentReference: 'Payment reference:', invoiceNumber: 'Invoice number:', swishQrCaption: 'Scan to pay with Swish', + payOnline: 'Pay online:', + paymentLinkQrCaption: 'Scan to pay online', orgNoLong: 'Reg. no.:', vatRegNo: 'VAT reg. no.:', // Statutory Swedish phrase: kept verbatim in both locales. Peppol SE-R-005 @@ -636,9 +641,12 @@ interface InvoicePDFProps { /** Pre-rendered Swish payment QR (PNG data URL). Built offline in * pdf-render-helpers; null/omitted renders no QR. */ swishQrDataUrl?: string | null + /** Pre-rendered payment-link QR (PNG data URL) for invoice.payment_link_url. + * Built offline in pdf-render-helpers; null/omitted renders no QR. */ + paymentLinkQrDataUrl?: string | null } -export function InvoicePDF({ invoice, customer, items, company, originalInvoiceNumber, isPreview, language, branding, swishQrDataUrl }: InvoicePDFProps) { +export function InvoicePDF({ invoice, customer, items, company, originalInvoiceNumber, isPreview, language, branding, swishQrDataUrl, paymentLinkQrDataUrl }: InvoicePDFProps) { const lang: PdfLang = language ?? customer.language ?? 'sv' const L = LABELS[lang] // Build the stylesheet per-render so each invoice picks up its company's @@ -915,14 +923,10 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN ) )} {(() => { - const rounding = getDisplayTotal(invoice, company) - // ROT/RUT-avdrag reduces "Att betala": the customer only owes - // (total - deduction); the rest is reclaimed from Skatteverket - // via fakturamodellen. The rule does not apply to credit notes. - const showDeduction = !isCreditNote && (invoice.deduction_total ?? 0) > 0 - const grandTotal = showDeduction - ? Math.round((rounding.displayed - (invoice.deduction_total ?? 0)) * 100) / 100 - : rounding.displayed + // Shared with the invoice email (lib/email/invoice-templates.ts) + // so the mail and the PDF always state the same "Att betala". + const { rounding, deductionApplies: showDeduction, toPay: grandTotal } = + getAmountToPay(invoice, company) return ( <> {rounding.applies && ( @@ -1033,6 +1037,16 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN {!isCreditNote && !isProforma && !isDeliveryNote && ( {L.paymentHeading} + {invoice.payment_link_url && ( + + {L.payOnline} + + {invoice.payment_link_url.length > 60 + ? `${invoice.payment_link_url.slice(0, 57)}...` + : invoice.payment_link_url} + + + )} {company.bank_name && ( {L.bank} @@ -1099,6 +1113,13 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN {L.swishQrCaption} )} + {/* Payment-link QR: shifts left when the Swish QR occupies the corner. */} + {paymentLinkQrDataUrl && ( + + + {L.paymentLinkQrCaption} + + )} )} diff --git a/lib/invoices/rounding.ts b/lib/invoices/rounding.ts index a662ba76..6d40b5d9 100644 --- a/lib/invoices/rounding.ts +++ b/lib/invoices/rounding.ts @@ -55,3 +55,38 @@ export function getDisplayTotal( applies: true, } } + +type AmountToPayShape = InvoiceTotalShape & { + /** ROT/RUT deduction (fakturamodellen). Reduces what the customer owes. */ + deduction_total?: number | null + /** Set on credit notes; the deduction rule does not apply to those. */ + credited_invoice_id?: string | null +} + +export interface AmountToPay { + /** The öresavrundning outcome on the invoice total (before any deduction). */ + rounding: DisplayTotal + /** True when a ROT/RUT deduction reduces the amount to pay. */ + deductionApplies: boolean + /** Customer-facing "Att betala": rounded total minus any ROT/RUT deduction. */ + toPay: number +} + +/** + * Customer-facing "Att betala" for an invoice: öresavrundning via + * getDisplayTotal, then the ROT/RUT deduction (the customer only owes + * total - deduction; the rest is reclaimed from Skatteverket via + * fakturamodellen). Extracted from the PDF totals block so the invoice email + * shows the exact same amount as the attached PDF and the two cannot drift. + */ +export function getAmountToPay( + invoice: AmountToPayShape, + company: CompanyRoundingShape | null | undefined, +): AmountToPay { + const rounding = getDisplayTotal(invoice, company) + const deductionApplies = !invoice.credited_invoice_id && (invoice.deduction_total ?? 0) > 0 + const toPay = deductionApplies + ? Math.round((rounding.displayed - (invoice.deduction_total ?? 0)) * 100) / 100 + : rounding.displayed + return { rounding, deductionApplies, toPay } +} diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index 914d6f1a..e50d2bc3 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -775,6 +775,19 @@ async function commitCreateInvoice( const uniqueRates = new Set(billableItems.map((item) => item.vat_rate ?? vatRules.rate)) const isMixedRate = uniqueRates.size > 1 + // Validated https-only at staging time (gnubok_create_invoice); re-checked + // here so a hand-crafted pending-operation row can't smuggle a non-https + // link into customer-facing emails/PDFs. Invalid → dropped, never blocks. + const paymentLinkUrl = (() => { + const raw = typeof params.payment_link_url === 'string' ? params.payment_link_url.trim() : '' + if (!raw || raw.length > 2048) return null + try { + return new URL(raw).protocol === 'https:' ? raw : null + } catch { + return null + } + })() + const { data: invoice, error: invoiceError } = await supabase .from('invoices') .insert({ @@ -800,6 +813,7 @@ async function commitCreateInvoice( our_reference: (params.our_reference as string) || null, your_reference: (params.your_reference as string) || null, notes: (params.notes as string) || null, + payment_link_url: paymentLinkUrl, default_dimensions: defaultDimensions ?? {}, }) .select() diff --git a/lib/reports/__tests__/general-ledger.test.ts b/lib/reports/__tests__/general-ledger.test.ts index 521fd31c..fb86755e 100644 --- a/lib/reports/__tests__/general-ledger.test.ts +++ b/lib/reports/__tests__/general-ledger.test.ts @@ -317,6 +317,98 @@ describe('generateGeneralLedger', () => { expect(acc.lines[2].description).toBe('Second') }) + it('rolls lines before fromDate into the opening balance and drops lines after toDate', async () => { + mockResults = { + fiscal_periods: [ + { data: { period_start: '2026-01-01', period_end: '2026-12-31', opening_balance_entry_id: null }, error: null }, + ], + 'rpc:compute_prior_opening_balances': [ + { + data: [{ account_number: '1930', debit: 10000, credit: 0 }], + error: null, + }, + ], + journal_entries: [ + { + data: [ + { id: 'e1', entry_date: '2026-03-15', voucher_number: 1, voucher_series: 'A', description: 'Pre-range', source_type: 'manual' }, + { id: 'e2', entry_date: '2026-06-10', voucher_number: 2, voucher_series: 'A', description: 'In range', source_type: 'manual' }, + { id: 'e3', entry_date: '2026-09-01', voucher_number: 3, voucher_series: 'A', description: 'Post-range', source_type: 'manual' }, + ], + error: null, + }, + ], + journal_entry_lines: [ + { + data: [ + // Before the range: rolls into opening + { account_number: '1930', debit_amount: 0, credit_amount: 2000, journal_entry_id: 'e1' }, + // Inside the range: shown + { account_number: '1930', debit_amount: 500, credit_amount: 0, journal_entry_id: 'e2' }, + // After the range: dropped entirely + { account_number: '1930', debit_amount: 0, credit_amount: 300, journal_entry_id: 'e3' }, + ], + error: null, + }, + ], + chart_of_accounts: [ + { data: [{ account_number: '1930', account_name: 'Företagskonto' }], error: null }, + ], + } + + const report = await generateGeneralLedger(supabase, 'company-1', 'period-1', undefined, undefined, { + fromDate: '2026-06-01', + toDate: '2026-06-30', + }) + + const acc = report.accounts.find((a) => a.account_number === '1930')! + // Opening at range start = period IB 10000 - pre-range 2000 + expect(acc.opening_balance).toBe(8000) + expect(acc.lines).toHaveLength(1) + expect(acc.lines[0].description).toBe('In range') + expect(acc.lines[0].balance).toBe(8500) + // Closing = opening + in-range movement only; post-range line excluded + expect(acc.closing_balance).toBe(8500) + expect(report.period).toEqual({ start: '2026-06-01', end: '2026-06-30' }) + }) + + it('keeps an account visible when all its lines fall before the range', async () => { + mockResults = { + fiscal_periods: [ + { data: { period_start: '2026-01-01', period_end: '2026-12-31', opening_balance_entry_id: null }, error: null }, + ], + journal_entries: [ + { + data: [ + { id: 'e1', entry_date: '2026-02-01', voucher_number: 1, voucher_series: 'A', description: 'Hyra feb', source_type: 'manual' }, + ], + error: null, + }, + ], + journal_entry_lines: [ + { + data: [ + { account_number: '5010', debit_amount: 4000, credit_amount: 0, journal_entry_id: 'e1' }, + ], + error: null, + }, + ], + chart_of_accounts: [ + { data: [{ account_number: '5010', account_name: 'Lokalhyra' }], error: null }, + ], + } + + const report = await generateGeneralLedger(supabase, 'company-1', 'period-1', undefined, undefined, { + fromDate: '2026-06-01', + toDate: '2026-06-30', + }) + + const acc = report.accounts.find((a) => a.account_number === '5010')! + expect(acc.opening_balance).toBe(4000) + expect(acc.lines).toHaveLength(0) + expect(acc.closing_balance).toBe(4000) + }) + it('uses Math.round for monetary precision', async () => { mockResults = { fiscal_periods: [ diff --git a/lib/reports/catalog.ts b/lib/reports/catalog.ts index c7f113a8..054596b5 100644 --- a/lib/reports/catalog.ts +++ b/lib/reports/catalog.ts @@ -246,7 +246,7 @@ export const REPORT_CATALOG: ReportDescriptor[] = [ labelKey: 'name_huvudbok', descKey: 'desc_huvudbok', category: 'ledgers', - params: 'fiscal', + params: 'fiscal-range', exports: ['xlsx'], dimensions: true, }, diff --git a/lib/reports/general-ledger.ts b/lib/reports/general-ledger.ts index 41c8a4fa..ae08f121 100644 --- a/lib/reports/general-ledger.ts +++ b/lib/reports/general-ledger.ts @@ -1,5 +1,6 @@ import type { SupabaseClient } from '@supabase/supabase-js' import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { roundOre } from '@/lib/money' import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines' import { getOpeningBalances } from './opening-balances' @@ -59,6 +60,13 @@ export async function generateGeneralLedger( /** SIE dim → code filter ({"6":"P001"}). Opening balances are dropped * when set: they are company-wide and cannot be dimension-scoped. */ dimensions?: Record + /** Inclusive date sub-range within the fiscal period (kontoanalys). + * Lines before fromDate roll into each account's opening balance so + * the running balance at the range start matches the full-year ledger; + * lines after toDate are dropped. Callers validate the bounds + * (parseReportDateRange). */ + fromDate?: string + toDate?: string } ): Promise { const dimensionFilter = @@ -135,7 +143,13 @@ export async function generateGeneralLedger( }) if (rawLines.length === 0 && openingBalances.size === 0) { - return { accounts: [], period: { start: period.period_start, end: period.period_end } } + return { + accounts: [], + period: { + start: options?.fromDate ?? period.period_start, + end: options?.toDate ?? period.period_end, + }, + } } // Fetch account names @@ -153,12 +167,25 @@ export async function generateGeneralLedger( accountNameMap.set(acc.account_number, acc.account_name) } - // Group lines by account + // Group lines by account. Lines before fromDate accumulate per account so + // they can roll into the opening balance below; lines after toDate drop. + const fromDate = options?.fromDate + const toDate = options?.toDate const accountLines = new Map() + const preRangeMovements = new Map() for (const line of rawLines) { const entry = line.journal_entries const accNum = line.account_number + const debit = Math.round((Number(line.debit_amount) || 0) * 100) / 100 + const credit = Math.round((Number(line.credit_amount) || 0) * 100) / 100 + + if (toDate && entry.entry_date > toDate) continue + if (fromDate && entry.entry_date < fromDate) { + preRangeMovements.set(accNum, (preRangeMovements.get(accNum) || 0) + debit - credit) + continue + } + if (!accountLines.has(accNum)) { accountLines.set(accNum, []) } @@ -172,15 +199,24 @@ export async function generateGeneralLedger( journal_entry_id: line.journal_entry_id, description: entry.description || '', source_type: entry.source_type || '', - debit: Math.round((Number(line.debit_amount) || 0) * 100) / 100, - credit: Math.round((Number(line.credit_amount) || 0) * 100) / 100, + debit, + credit, balance: 0, // computed below ...(hasDims ? { dimensions: line.dimensions as Record } : {}), }) } - // Include accounts that have opening balance but no period lines - for (const [accNum, balance] of openingBalances) { + // Opening balance at the range start: period IB plus movements before + // fromDate. Under a dimension filter the IB map is empty (company-wide IB + // cannot be dimension-scoped) but pre-range movements are dimension-scoped + // by the query, so they still roll in. + const effectiveOpening = new Map(openingBalances) + for (const [accNum, movement] of preRangeMovements) { + effectiveOpening.set(accNum, roundOre((effectiveOpening.get(accNum) || 0) + movement)) + } + + // Include accounts that carry a balance into the range but have no lines in it + for (const [accNum, balance] of effectiveOpening) { if (!accountLines.has(accNum) && Math.abs(balance) > 0.005) { accountLines.set(accNum, []) } @@ -201,7 +237,7 @@ export async function generateGeneralLedger( return a.voucher_number - b.voucher_number }) - const opening = Math.round((openingBalances.get(accNum) || 0) * 100) / 100 + const opening = Math.round((effectiveOpening.get(accNum) || 0) * 100) / 100 let runningBalance = opening for (const line of accLines) { @@ -228,6 +264,9 @@ export async function generateGeneralLedger( return { accounts: result, - period: { start: period.period_start, end: period.period_end }, + period: { + start: fromDate ?? period.period_start, + end: toDate ?? period.period_end, + }, } } diff --git a/messages/en.json b/messages/en.json index ebff3c2e..6c92ceef 100644 --- a/messages/en.json +++ b/messages/en.json @@ -433,7 +433,12 @@ "status_overdue": "Overdue", "status_disputed": "Disputed", "status_credited": "Credited", - "status_reversed": "Reversed" + "status_reversed": "Reversed", + "not_approved": "Not approved", + "approve": "Approve", + "approve_failed_title": "Approval failed", + "approved_title": "Approved", + "approved_description": "The invoice has been approved" }, "purchase_orders": { "title": "Purchase orders", @@ -2358,6 +2363,10 @@ "your_reference_placeholder": "Customer contact person", "our_reference_label": "Our reference", "our_reference_placeholder": "Your name", + "payment_link_label": "Payment link (optional)", + "payment_link_placeholder": "https://buy.stripe.com/…", + "payment_link_hint": "Paste a payment link created for this specific invoice (e.g. in Stripe). Make sure the amount matches and preferably limit the link to a single payment. The link is shown as a button in the invoice email and as a QR code on the PDF.", + "validation_payment_link_https": "The payment link must be an https address", "summary_card_title": "Summary", "subtotal_label": "Subtotal", "net_at_rate": "Net {rate}%", diff --git a/messages/sv.json b/messages/sv.json index 24662736..73f6a064 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -433,7 +433,12 @@ "status_overdue": "Förfallen", "status_disputed": "Tvist", "status_credited": "Krediterad", - "status_reversed": "Makulerad" + "status_reversed": "Makulerad", + "not_approved": "Ej godkänd", + "approve": "Godkänn", + "approve_failed_title": "Godkännande misslyckades", + "approved_title": "Godkänd", + "approved_description": "Fakturan har godkänts" }, "purchase_orders": { "title": "Inköpsorder", @@ -2358,6 +2363,10 @@ "your_reference_placeholder": "Kontaktperson hos kund", "our_reference_label": "Vår referens", "our_reference_placeholder": "Ditt namn", + "payment_link_label": "Betalningslänk (valfritt)", + "payment_link_placeholder": "https://buy.stripe.com/…", + "payment_link_hint": "Klistra in en betalningslänk skapad för just denna faktura (t.ex. i Stripe). Kontrollera att beloppet stämmer och begränsa gärna länken till en betalning. Länken visas som en knapp i fakturamejlet och som QR-kod på PDF:en.", + "validation_payment_link_https": "Betalningslänken måste vara en https-adress", "summary_card_title": "Summering", "subtotal_label": "Delsumma", "net_at_rate": "Netto {rate}%", diff --git a/supabase/migrations/20260709090000_invoice_payment_link.sql b/supabase/migrations/20260709090000_invoice_payment_link.sql new file mode 100644 index 00000000..c1705f80 --- /dev/null +++ b/supabase/migrations/20260709090000_invoice_payment_link.sql @@ -0,0 +1,16 @@ +-- Optional online payment link on customer invoices (manual MVP). +-- +-- The user creates a payment link in their PSP dashboard (e.g. a Stripe +-- Payment Link), pastes it onto the invoice, and it renders as a +-- "Betala online" button in the invoice email and as a QR code + link on +-- the PDF. There is no PSP integration server-side: the column is a plain +-- URL. Validation (https-only, length cap) lives in the API schema +-- (lib/api/schemas.ts). Derived documents (credit notes, proforma +-- conversions, recurring-schedule invoices) intentionally do NOT copy this +-- column: a pasted link encodes one specific amount for one specific +-- invoice, and carrying it over would silently point at the wrong amount. +alter table public.invoices + add column if not exists payment_link_url text; + +comment on column public.invoices.payment_link_url is + 'Optional https link where the customer can pay this invoice online (pasted by the user, e.g. a Stripe Payment Link). Rendered in the invoice email and on the PDF. Never copied to derived documents.'; diff --git a/types/index.ts b/types/index.ts index 5ca3a275..de745e1f 100644 --- a/types/index.ts +++ b/types/index.ts @@ -871,6 +871,13 @@ export interface Invoice { your_reference: string | null our_reference: string | null + // Optional online payment link (pasted by the user, e.g. a Stripe Payment + // Link). Rendered as a "Betala online" button in the invoice email and as a + // QR code + link on the PDF. Never copied to derived documents (credit + // notes, conversions, recurring invoices). Optional in TS for pre-migration + // fixtures. + payment_link_url?: string | null + // Notes notes: string | null @@ -1215,6 +1222,8 @@ export interface CreateInvoiceInput { your_reference?: string our_reference?: string notes?: string + /** Optional https link where the customer can pay online (e.g. a Stripe Payment Link). */ + payment_link_url?: string /** Plaintext personnummer: encrypted server-side before storage. */ deduction_personnummer?: string /** Fastighetsbeteckning. Required when any item carries deduction_type === 'rot'. */