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'. */