Files
accounted/lib/reports/catalog.ts
T
Mattsson 7d7f604e00 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>
2026-07-12 00:56:16 +02:00

367 lines
10 KiB
TypeScript

import type { EntityType } from '@/types'
/**
* Single source of truth for the reports surface.
*
* One descriptor per report drives every entry point: the report-library
* landing (`ReportLibrary`), the "Senast öppnade" recent shelf, the focused
* report route (`/reports/[slug]` via `FocusedReport`), and the command-palette
* "Visa rapport" jumps. Adding a report = adding one row here.
*
* `labelKey` / `descKey` resolve against the `reports` i18n namespace. The
* category labels reuse the existing `group_*` keys so statutory terminology is
* never re-translated.
*/
export type ReportCategory =
| 'interim'
| 'year_end'
| 'tax_vat'
| 'ledgers'
| 'reconciliation'
| 'payroll'
| 'export'
/**
* How the report is parameterised:
* - `fiscal-range`: fiscal period + an optional date sub-range (ReportDateRange)
* - `fiscal`: fiscal period only
* - `calendar`: calendar year + monthly/quarterly/yearly period (VAT family):
* the deliberate exception to "pick the fiscal year once"
* - `none`: no period parameter
*/
export type ReportParams = 'fiscal-range' | 'fiscal' | 'calendar' | 'none'
export type ReportExportFormat = 'pdf' | 'xlsx'
export interface ReportDescriptor {
/** URL slug at /reports/[slug]; also the legacy activeTab id. */
slug: string
/** i18n key in the `reports` namespace for the display name. */
labelKey: string
/** i18n key in the `reports` namespace for the one-line description. */
descKey: string
category: ReportCategory
/** When set, the report only appears for this entity type. */
entityType?: EntityType
/** When true, only shown if the company has employees. */
needsEmployees?: boolean
params: ReportParams
/** On-page export formats handled by the focused view's export menu. */
exports?: ReportExportFormat[]
/**
* External destination. When set, the library/nav links straight here instead
* of /reports/[slug] (e.g. reports that own their own route, or live elsewhere).
*/
route?: string
/**
* Hidden from the legacy desktop rail; surfaced only on the library landing.
* Used for reports that were never in the nav (KPI, payroll, archive…).
*/
libraryOnly?: boolean
/**
* Accepts the per-dimension value filter (?dim_no/&dim_code → jsonb @>).
* P&L-safe reports ONLY: statutory outputs (balance sheet, balansrapport,
* kassaflöde, årsredovisning, INK2, NE, VAT, SIE) must never carry this
* flag; a filtered filing is a wrong filing. The whitelist is pinned by
* lib/reports/__tests__/dimension-statutory-guard.test.ts.
*/
dimensions?: boolean
/** Only shown when company_settings.dimensions_enabled is true. */
needsDimensions?: boolean
/**
* Nav-promoted page that happens to render in the focused-report shell.
* Hides the report-library back link and the shell's fiscal-year selector —
* the view owns all of its period controls.
*/
standalone?: boolean
}
/** Categories shown in the legacy desktop rail, in order. */
export const NAV_CATEGORIES: ReportCategory[] = [
'interim',
'year_end',
'tax_vat',
'ledgers',
'reconciliation',
]
/** All categories shown on the library landing, in order. */
export const LIBRARY_CATEGORIES: ReportCategory[] = [
'interim',
'year_end',
'tax_vat',
'ledgers',
'reconciliation',
'payroll',
'export',
]
/** Maps a category to its existing `group_*` i18n label key. */
export const CATEGORY_LABEL_KEY: Record<ReportCategory, string> = {
interim: 'group_interim',
year_end: 'group_year_end',
tax_vat: 'group_tax_vat',
ledgers: 'group_ledgers',
reconciliation: 'group_reconciliation',
payroll: 'group_payroll',
export: 'group_export',
}
export const REPORT_CATALOG: ReportDescriptor[] = [
// --- Löpande (interim) ---
{
slug: 'resultatrapport',
labelKey: 'name_resultatrapport',
descKey: 'desc_resultatrapport',
category: 'interim',
params: 'fiscal-range',
exports: ['pdf', 'xlsx'],
dimensions: true,
},
{
// Resultat per projekt/kostnadsställe: value-as-column P&L matrix over
// one SIE dimension (Fortnox "Resultatrapport projekt").
slug: 'dimension-pnl',
labelKey: 'name_dimension_pnl',
descKey: 'desc_dimension_pnl',
category: 'interim',
params: 'fiscal-range',
exports: ['xlsx'],
needsDimensions: true,
},
{
slug: 'balansrapport',
labelKey: 'name_balansrapport',
descKey: 'desc_balansrapport',
category: 'interim',
params: 'fiscal-range',
exports: ['pdf', 'xlsx'],
},
{
slug: 'trial-balance',
labelKey: 'name_trial_balance',
descKey: 'desc_trial_balance',
category: 'interim',
params: 'fiscal',
exports: ['xlsx'],
},
{
slug: 'kpi',
labelKey: 'name_kpi',
descKey: 'desc_kpi',
category: 'interim',
params: 'fiscal',
route: '/kpi',
libraryOnly: true,
dimensions: true,
},
// --- Bokslut (year-end) ---
{
// The year-end closing wizard (dispositions, accruals, execute). Owns its
// route under /bookkeeping; surfaced here so the closing flow is reachable
// from Rapporter rather than only via the Bokföring header.
slug: 'year-end-closing',
labelKey: 'name_year_end_closing',
descKey: 'desc_year_end_closing',
category: 'year_end',
params: 'fiscal',
route: '/bookkeeping/year-end',
},
{
slug: 'income-statement',
labelKey: 'name_income_statement',
descKey: 'desc_income_statement',
category: 'year_end',
params: 'fiscal-range',
exports: ['pdf', 'xlsx'],
dimensions: true,
},
{
slug: 'balance-sheet',
labelKey: 'name_balance_sheet',
descKey: 'desc_balance_sheet',
category: 'year_end',
params: 'fiscal-range',
exports: ['pdf', 'xlsx'],
},
{
slug: 'kassaflodesanalys',
labelKey: 'name_kassaflodesanalys',
descKey: 'desc_kassaflodesanalys',
category: 'year_end',
params: 'fiscal',
route: '/reports/kassaflodesanalys',
},
{
slug: 'arsredovisning',
labelKey: 'name_arsredovisning',
descKey: 'desc_arsredovisning',
category: 'year_end',
entityType: 'aktiebolag',
params: 'fiscal',
route: '/bookkeeping/year-end/arsredovisning',
},
// --- Skatt & moms (tax & VAT) ---
{
slug: 'vat-declaration',
labelKey: 'name_vat_declaration',
descKey: 'desc_vat_declaration',
category: 'tax_vat',
params: 'calendar',
exports: ['xlsx'],
// Promoted to the Skatt & bokslut nav group — reached directly, not via
// the report library, and it manages its own period selection.
standalone: true,
},
{
slug: 'periodisk-sammanstallning',
labelKey: 'name_periodisk_sammanstallning',
descKey: 'desc_periodisk_sammanstallning',
category: 'tax_vat',
params: 'calendar',
},
{
slug: 'ne-declaration',
labelKey: 'name_ne_declaration',
descKey: 'desc_ne_declaration',
category: 'tax_vat',
entityType: 'enskild_firma',
params: 'fiscal',
},
{
slug: 'ink2-declaration',
labelKey: 'name_ink2_declaration',
descKey: 'desc_ink2_declaration',
category: 'tax_vat',
entityType: 'aktiebolag',
params: 'fiscal',
},
// --- Huvudböcker (ledgers) ---
{
slug: 'huvudbok',
labelKey: 'name_huvudbok',
descKey: 'desc_huvudbok',
category: 'ledgers',
params: 'fiscal-range',
exports: ['xlsx'],
dimensions: true,
},
{
slug: 'grundbok',
labelKey: 'name_grundbok',
descKey: 'desc_grundbok',
category: 'ledgers',
params: 'fiscal',
exports: ['xlsx'],
},
{
slug: 'kundreskontra',
labelKey: 'name_kundreskontra',
descKey: 'desc_kundreskontra',
category: 'ledgers',
params: 'fiscal',
exports: ['xlsx'],
},
{
slug: 'supplier-ledger',
labelKey: 'name_supplier_ledger',
descKey: 'desc_supplier_ledger',
category: 'ledgers',
params: 'fiscal',
exports: ['xlsx'],
},
// --- Avstämning (reconciliation) ---
{
slug: 'bank-reconciliation',
labelKey: 'name_bank_reconciliation',
descKey: 'desc_bank_reconciliation',
category: 'reconciliation',
// Period-scoped like the ledgers: the report page's räkenskapsår selector
// drives the reconciliation window (issue #751). Was 'none' (periodless),
// which left the view to host its OWN fiscal-year selector inside a
// loading-gated action bar: a render deadlock that hung the page on a
// permanent skeleton (#771).
params: 'fiscal',
},
// --- Export & arkiv: library-only ---
{
slug: 'sie-export',
labelKey: 'name_sie_export',
descKey: 'desc_sie_export',
category: 'export',
params: 'fiscal',
route: '/import?view=export#sie-export',
libraryOnly: true,
},
]
/** Reports that take a fiscal period + optional date sub-range. */
export const DATE_RANGE_SLUGS: ReadonlySet<string> = new Set(
REPORT_CATALOG.filter((r) => r.params === 'fiscal-range').map((r) => r.slug),
)
/** Reports that accept the per-dimension value filter (mounts DimensionFilter). */
export const DIMENSION_FILTER_SLUGS: ReadonlySet<string> = new Set(
REPORT_CATALOG.filter((r) => r.dimensions).map((r) => r.slug),
)
export function getReport(slug: string): ReportDescriptor | undefined {
return REPORT_CATALOG.find((r) => r.slug === slug)
}
function isVisible(
r: ReportDescriptor,
entityType?: EntityType,
hasEmployees?: boolean,
dimensionsEnabled?: boolean,
): boolean {
if (r.entityType && r.entityType !== entityType) return false
if (r.needsEmployees && !hasEmployees) return false
if (r.needsDimensions && !dimensionsEnabled) return false
return true
}
export interface ReportSection {
category: ReportCategory
labelKey: string
items: ReportDescriptor[]
}
/** Grouped reports for the legacy desktop rail (excludes library-only items). */
export function getNavSections(
entityType?: EntityType,
dimensionsEnabled?: boolean,
): ReportSection[] {
return NAV_CATEGORIES.map((category) => ({
category,
labelKey: CATEGORY_LABEL_KEY[category],
items: REPORT_CATALOG.filter(
(r) =>
r.category === category &&
!r.libraryOnly &&
isVisible(r, entityType, undefined, dimensionsEnabled),
),
})).filter((s) => s.items.length > 0)
}
/** Grouped reports for the library landing (includes everything visible). */
export function getLibrarySections(
entityType?: EntityType,
hasEmployees?: boolean,
dimensionsEnabled?: boolean,
): ReportSection[] {
return LIBRARY_CATEGORIES.map((category) => ({
category,
labelKey: CATEGORY_LABEL_KEY[category],
items: REPORT_CATALOG.filter(
(r) => r.category === category && isVisible(r, entityType, hasEmployees, dimensionsEnabled),
),
})).filter((s) => s.items.length > 0)
}