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
parent 0ef5593388
commit 7d7f604e00
35 changed files with 945 additions and 56 deletions
+3
View File
@@ -49,6 +49,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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] <decision>: <why>`. 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] <decision>: <why>`. 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.
+60 -5
View File
@@ -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<string, 'default' | 'secondary' | 'success' | 'warning' | 'destructive'> = {
@@ -43,11 +46,13 @@ const STATUS_LABEL_KEYS: Record<string, string> = {
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<string | null>(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 (
<div className="space-y-8">
<PageHeader
@@ -195,12 +225,37 @@ export default function SupplierInvoicesPage() {
</TableCell>
<TableCell className="tabular-nums">{formatDate(inv.invoice_date)}</TableCell>
<TableCell className="tabular-nums">{formatDate(inv.due_date)}</TableCell>
<TableCell className="text-right tabular-nums">{formatCurrency(inv.total, inv.currency)}</TableCell>
{/* 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). */}
<TableCell className="text-right tabular-nums">
{formatCurrency(getDisplayTotal(
{ total: inv.total, currency: inv.currency, ore_rounding: inv.ore_rounding },
{ ore_rounding: false },
).displayed, inv.currency)}
</TableCell>
<TableCell className="text-right tabular-nums">{formatCurrency(inv.remaining_amount, inv.currency)}</TableCell>
<TableCell>
<Badge variant={STATUS_VARIANTS[inv.status] || 'secondary'}>
{STATUS_LABEL_KEYS[inv.status] ? t(STATUS_LABEL_KEYS[inv.status]) : inv.status}
</Badge>
{activeTab === 'to_pay' && inv.status === 'registered' ? (
<div className="flex items-center gap-2">
<Badge variant="warning" className="whitespace-nowrap">{t('not_approved')}</Badge>
{!inv.is_credit_note && canWrite && (
<Button
size="sm"
variant="outline"
className="h-7 px-2 text-xs"
onClick={() => handleApprove(inv.id)}
disabled={approvingId !== null}
>
{t('approve')}
</Button>
)}
</div>
) : (
<Badge variant={STATUS_VARIANTS[inv.status] || 'secondary'}>
{STATUS_LABEL_KEYS[inv.status] ? t(STATUS_LABEL_KEYS[inv.status]) : inv.status}
</Badge>
)}
</TableCell>
</TableRow>
))}
+3 -1
View File
@@ -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,
})
)
+3 -1
View File
@@ -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,
}),
)
+16 -2
View File
@@ -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,
})
)
@@ -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 })
)
})
})
+20
View File
@@ -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) {
@@ -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
@@ -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)
+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
@@ -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)
})
})
+20
View File
@@ -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,
+32
View File
@@ -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: [
+22
View File
@@ -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
+1 -1
View File
@@ -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'
@@ -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('&quot;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/)
})
})
})
+19 -4
View File
@@ -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 {
<tr>
<td style="padding: 8px 0; font-size: 18px; font-weight: 600;">${L.toPay}</td>
<td style="padding: 8px 0; text-align: right; font-size: 18px; font-weight: 600; color: ${isCreditNote ? '#059669' : primaryColor};">
${formatCurrencyForCustomer(invoice.total, invoice.currency, lang)}
${formatCurrencyForCustomer(getAmountToPay(invoice, company).toPay, invoice.currency, lang)}
</td>
</tr>
</table>
</div>
<!-- Pay-online button: only when the user pasted a payment link on this
invoice. The URL is schema-validated (https-only) but still escaped
for the attribute context: a URL may legally contain quotes. -->
${!hidePayment && invoice.payment_link_url ? `
<div style="margin-bottom: 30px; text-align: center;">
<a href="${escapeHtml(invoice.payment_link_url)}" style="display: inline-block; background: ${primaryColor}; color: #ffffff; text-decoration: none; padding: 12px 32px; border-radius: 6px; font-size: 16px; font-weight: 600;">
${L.payOnline}
</a>
</div>
` : ''}
<!-- Payment Details -->
${!hidePayment ? `
<div style="margin-bottom: 30px;">
@@ -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`
@@ -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 })
@@ -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()
})
})
+44 -1
View File
@@ -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)
})
})
+6
View File
@@ -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,
+24
View File
@@ -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<string | null> {
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,
+31 -10
View File
@@ -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 && (
<View style={styles.paymentSection}>
<Text style={styles.paymentTitle}>{L.paymentHeading}</Text>
{invoice.payment_link_url && (
<View style={styles.paymentRow}>
<Text style={styles.paymentLabel}>{L.payOnline}</Text>
<Link src={invoice.payment_link_url} style={styles.paymentValue}>
{invoice.payment_link_url.length > 60
? `${invoice.payment_link_url.slice(0, 57)}...`
: invoice.payment_link_url}
</Link>
</View>
)}
{company.bank_name && (
<View style={styles.paymentRow}>
<Text style={styles.paymentLabel}>{L.bank}</Text>
@@ -1099,6 +1113,13 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
<Text style={[styles.paymentLabel, { width: 'auto', marginTop: 2, textAlign: 'center' }]}>{L.swishQrCaption}</Text>
</View>
)}
{/* Payment-link QR: shifts left when the Swish QR occupies the corner. */}
{paymentLinkQrDataUrl && (
<View style={{ position: 'absolute', top: 15, right: swishQrDataUrl ? 125 : 15, width: 96, alignItems: 'center' }}>
<Image src={paymentLinkQrDataUrl} style={{ width: 96, height: 96 }} />
<Text style={[styles.paymentLabel, { width: 'auto', marginTop: 2, textAlign: 'center' }]}>{L.paymentLinkQrCaption}</Text>
</View>
)}
</View>
)}
+35
View File
@@ -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 }
}
+14
View File
@@ -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()
@@ -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: [
+1 -1
View File
@@ -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,
},
+47 -8
View File
@@ -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<string, string>
/** 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<GeneralLedgerReport> {
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<string, GeneralLedgerLine[]>()
const preRangeMovements = new Map<string, number>()
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<string, string> } : {}),
})
}
// 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<string, number>(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,
},
}
}
+10 -1
View File
@@ -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}%",
+10 -1
View File
@@ -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}%",
@@ -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.';
+9
View File
@@ -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'. */