diff --git a/DECISIONS.md b/DECISIONS.md index 3310dad8..48bcdfc0 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1231,4 +1231,5 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-25] reset_fiscal_year skeptic hardening (#1897): filed ROT/RUT begaran and cross-year rattelse/storno chains BLOCK the reset (new snapshot guards) instead of being silently unlinked or crashing on the RI UPDATE; other SET NULL links (invoices, payments, transactions) unlink by design with dialog disclosure, since they are re-bookable and the year's external reliance states all refuse. Full verifikat content is archived in company-scoped RESET_SNAPSHOT audit rows before deletion: the trigger's line-level audit rows carry no company_id and header rows carry no amounts, which would otherwise destroy rakenskapsinformation (BFL 7 kap). [2026-08-24] The OAuth AS metadata advertises client_id_metadata_document_supported (CIMD, #1814 PR 4) without fetching or validating the client's metadata document: authorize/token never keyed anything on client_id, the redirect_uri allowlist is the trust boundary, and CIMD only changes what Claude/Codex send as client_id (an HTTPS URL instead of a DCR-minted UUID). Fetching the document would add a network dependency to every consent for no gain in this design. DCR stays for ChatGPT. [2026-08-25] CIMD is NOT advertised after all (reverses the 2026-08-24 entry; CodeRabbit on #1866): the spec expects an AS that advertises client_id_metadata_document_supported to fetch the document and match redirect_uri exactly against it, and our authorize endpoint only checks the global allowlist. Advertising would claim a check we skip. Add the flag together with an SSRF-safe cached CIMD fetch + exact redirect matching (localhost port-agnostic for Claude Code/Codex); DCR is free for us (stateless register), so nothing is lost meanwhile. +[2026-08-25] Webshop orderunderlag (#1881) is a generated PDF via the existing @react-pdf/renderer + uploadDocument path (same mechanism as archiveIssuedInvoicePdf), not an HTML document: no new dependency, WORM-archive viewers already render PDFs, and magic-byte validation has no HTML arm. Only the verifikat_without_documents RPC gains 'webshop_order' in its needs-doc list; transactions_without_documents stays unchanged because webshop_order entries never hang on a transactions row (the legacy-feed cross-lock guarantees it), so the strict-subset invariant holds without touching it. [2026-08-25] Proposal line-pattern settlement leg now takes the counterparty template's learned legacy pair (credit for expense, debit for income, mirror-swapped, || 1930), passed raw from QuickReviewDialog: two skeptics refuted the 1930 default (engine books e.g. 2440 from SIE-learned patterns; preview/prefill showed 1930). Declined CodeRabbit's two suggestions on #1894 deliberately: the 3740 rounding line keeps the engine's business-side placement for BOTH diff signs (parity contract; the engine's negative-diff imbalance cannot reach the ledger, commit_journal_entry rejects it; engine-side sign fix is a separate issue) and the naiveOreRound baseline stays raised to 622 (engineRound is a documented parity exception, not drift). diff --git a/app/api/webshop-orders/[id]/book/route.ts b/app/api/webshop-orders/[id]/book/route.ts index fe5d86d8..5b0df737 100644 --- a/app/api/webshop-orders/[id]/book/route.ts +++ b/app/api/webshop-orders/[id]/book/route.ts @@ -9,6 +9,7 @@ import { errorResponseFromCode } from '@/lib/errors/get-structured-error' import { getErrorMessage } from '@/lib/errors/get-error-message' import { fetchExchangeRate } from '@/lib/currency/riksbanken' import { ensureWebshopPrefillAccounts } from '@/lib/webshop-orders/ensure-accounts' +import { archiveWebshopOrderUnderlag } from '@/lib/webshop-orders/order-underlag' import { roundOre } from '@/lib/money' import type { Currency, WebshopOrder } from '@/types' @@ -131,6 +132,12 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( .eq('id', id) .eq('company_id', companyId) resolved = !fxError + if (resolved) { + // Keep the in-memory row in sync: the underlag renders the SEK + // conversion facts from it after commit. + order.total_sek = totalSek + order.exchange_rate = rate.rate + } } } catch (err) { log.warn('booking-time FX retry failed', err as Error) @@ -249,11 +256,26 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( // No extra event here: commitEntry() already emits // journal_entry.committed from inside the engine. + // Archive the orderunderlag (lines, customer, payment method) on the + // committed verifikat (#1881). Never fatal: the booking is immutable at + // this point, and a verifikat left without underlag surfaces on the + // "saknar underlag" worklist (webshop_order is a needs-doc source type), + // where the user can attach a document by hand. + const underlag = await archiveWebshopOrderUnderlag({ + supabase, + companyId, + userId: user.id, + order, + journalEntryId: journalEntry?.id ?? draft.id, + log, + }) + return NextResponse.json({ data: journalEntry, // commitEntry's post-commit fetch can theoretically return no row; // the entry still exists under draft.id. journal_entry_id: journalEntry?.id ?? draft.id, + underlag_archived: underlag.ok, success: true, }) }, diff --git a/app/api/webshop-orders/__tests__/book.test.ts b/app/api/webshop-orders/__tests__/book.test.ts index 7cf6265c..60040643 100644 --- a/app/api/webshop-orders/__tests__/book.test.ts +++ b/app/api/webshop-orders/__tests__/book.test.ts @@ -50,6 +50,14 @@ vi.mock('@/lib/webshop-orders/ensure-accounts', () => ({ ensureWebshopPrefillAccounts: (...args: unknown[]) => mockEnsureAccounts(...args), })) +// Underlag rendering/archiving behaviour lives in +// lib/webshop-orders/__tests__/order-underlag.test.ts; here we only assert +// when the route archives and that a failure never breaks the booking. +const mockArchiveUnderlag = vi.fn() +vi.mock('@/lib/webshop-orders/order-underlag', () => ({ + archiveWebshopOrderUnderlag: (...args: unknown[]) => mockArchiveUnderlag(...args), +})) + import { POST } from '../[id]/book/route' const PERIOD_UUID = '550e8400-e29b-41d4-a716-446655440000' @@ -112,6 +120,7 @@ describe('POST /api/webshop-orders/[id]/book', () => { requireWriteMock.mockResolvedValue({ ok: true }) mockCreateDraftEntry.mockResolvedValue(makeJournalEntry({ id: 'draft-1', status: 'draft' })) mockCommitEntry.mockResolvedValue(makeJournalEntry({ id: 'je-1' })) + mockArchiveUnderlag.mockResolvedValue({ ok: true, documentId: 'doc-1' }) }) it('returns 401 when not authenticated', async () => { @@ -289,6 +298,56 @@ describe('POST /api/webshop-orders/[id]/book', () => { ) }) + it('archives the orderunderlag on the committed verifikat (#1881)', async () => { + enqueue({ data: makeOrderRow() }) // fetch + enqueue({ data: [{ id: 'order-1' }] }) // claim + const { status, body } = await parseJsonResponse<{ underlag_archived: boolean }>( + await postBook(), + ) + expect(status).toBe(200) + expect(body.underlag_archived).toBe(true) + expect(mockArchiveUnderlag).toHaveBeenCalledTimes(1) + expect(mockArchiveUnderlag).toHaveBeenCalledWith( + expect.objectContaining({ + companyId: 'company-1', + userId: 'user-1', + journalEntryId: 'je-1', + order: expect.objectContaining({ id: 'order-1' }), + }), + ) + // Only after the commit: an underlag must never anchor to a draft that + // could still be cancelled. + expect(mockCommitEntry.mock.invocationCallOrder[0]).toBeLessThan( + mockArchiveUnderlag.mock.invocationCallOrder[0], + ) + }) + + it('a failed underlag archive never breaks the booking', async () => { + mockArchiveUnderlag.mockResolvedValueOnce({ ok: false, documentId: null }) + enqueue({ data: makeOrderRow() }) // fetch + enqueue({ data: [{ id: 'order-1' }] }) // claim + const { status, body } = await parseJsonResponse<{ + journal_entry_id: string + underlag_archived: boolean + success: boolean + }>(await postBook()) + expect(status).toBe(200) + expect(body.success).toBe(true) + expect(body.journal_entry_id).toBe('je-1') + expect(body.underlag_archived).toBe(false) + }) + + it('does not archive an underlag when the commit fails', async () => { + mockCommitEntry.mockRejectedValueOnce(new Error('period locked')) + enqueue({ data: makeOrderRow() }) // fetch + enqueue({ data: [{ id: 'order-1' }] }) // claim + enqueue({ data: null }) // unlink + enqueue({ data: null }) // cancel draft + const { status } = await parseJsonResponse(await postBook()) + expect(status).toBeGreaterThanOrEqual(400) + expect(mockArchiveUnderlag).not.toHaveBeenCalled() + }) + it('returns 409 and cancels the draft when another request wins the claim', async () => { enqueue({ data: makeOrderRow() }) // fetch (sees unbooked) enqueue({ data: [] }) // claim matched ZERO rows: raced diff --git a/components/bookkeeping/JournalEntryList.tsx b/components/bookkeeping/JournalEntryList.tsx index 2597620a..501bfd84 100644 --- a/components/bookkeeping/JournalEntryList.tsx +++ b/components/bookkeeping/JournalEntryList.tsx @@ -60,16 +60,14 @@ import { useCanWrite } from '@/lib/hooks/use-can-write' import { getErrorMessage } from '@/lib/errors/get-error-message' import { useCompanyOptional } from '@/contexts/CompanyContext' import { listContextKey, writeListContext } from '@/lib/navigation/list-context' +import { NEEDS_DOC_SOURCE_TYPES } from '@/lib/worklist/types' import type { FiscalPeriod, JournalEntry, JournalEntryLine } from '@/types' -const NEEDS_ATTACHMENT = new Set([ - 'manual', - 'bank_transaction', - 'supplier_invoice_registered', - 'supplier_invoice_paid', - 'supplier_invoice_cash_payment', - 'import', -]) +// Shared source of truth (lib/worklist/types.ts) so the per-row chip and +// waiver UI can never drift from the worklist count and the SQL predicate +// (skeptic finding on #1881: a hardcoded copy here missed webshop_order, +// leaving flagged rows without chip or waiver toggle). +const NEEDS_ATTACHMENT = new Set(NEEDS_DOC_SOURCE_TYPES) // Column-header sorting (support feedback: "filtrera/sortera alla rubriker"). // The sort order is a priority-ordered STACK of keys (max 3): the second key diff --git a/extensions/general/push-notifications/notification-scheduler.ts b/extensions/general/push-notifications/notification-scheduler.ts index 063f7e4d..67e2a9c0 100644 --- a/extensions/general/push-notifications/notification-scheduler.ts +++ b/extensions/general/push-notifications/notification-scheduler.ts @@ -12,6 +12,7 @@ import type { SupabaseClient } from '@supabase/supabase-js' import type { NotificationType } from '@/types' import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { NEEDS_DOC_SOURCE_TYPES } from '@/lib/worklist/types' import { sendNotificationToUser, readNotificationSettings } from './notification-sender' import { createTaxDeadlinePayload, @@ -214,16 +215,12 @@ export async function sendInvoiceNotifications( } /** - * Source types that require supporting documents (underlag). + * Source types that require supporting documents (underlag). Shared source + * of truth (lib/worklist/types.ts) so this cron can never disagree with the + * worklist badge (skeptic finding on #1881: a hardcoded copy here missed + * webshop_order). */ -const NEEDS_ATTACHMENT_SOURCE_TYPES = [ - 'manual', - 'bank_transaction', - 'supplier_invoice_registered', - 'supplier_invoice_paid', - 'supplier_invoice_cash_payment', - 'import', -] +const NEEDS_ATTACHMENT_SOURCE_TYPES = [...NEEDS_DOC_SOURCE_TYPES] /** * Send missing underlag notifications. diff --git a/lib/webshop-orders/__tests__/order-underlag.test.ts b/lib/webshop-orders/__tests__/order-underlag.test.ts new file mode 100644 index 00000000..be9dd37b --- /dev/null +++ b/lib/webshop-orders/__tests__/order-underlag.test.ts @@ -0,0 +1,309 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { SupabaseClient } from '@supabase/supabase-js' +import { createMockSupabase } from '@/tests/helpers' +import type { WebshopOrder } from '@/types' +import type { Logger } from '@/lib/logger' + +const mockUploadDocument = vi.fn() +vi.mock('@/lib/core/documents/document-service', () => ({ + uploadDocument: (...args: unknown[]) => mockUploadDocument(...args), +})) + +import { + buildOrderUnderlagModel, + orderUnderlagFilename, + archiveWebshopOrderUnderlag, + formatAmount, +} from '../order-underlag' + +function makeOrder(overrides: Partial = {}): WebshopOrder { + return { + id: 'order-1', + company_id: 'company-1', + user_id: 'user-1', + platform: 'woocommerce', + store_scope: 'butik.example.se', + store_label: 'Butiken', + connection_id: null, + row_type: 'order', + parent_order_id: null, + external_id: 'woo_butik.example.se_order_1001', + platform_order_id: '1001', + order_number: '1001', + status: 'processing', + is_paid: true, + order_date: '2026-08-01', + paid_date: '2026-08-01', + currency: 'SEK', + total: 500, + total_tax: 100, + total_sek: 500, + exchange_rate: 1, + vat_breakdown: [{ rate: 25, net: 400, tax: 100 }], + line_items: [ + { name: 'Kaffekopp', quantity: 2, total: 300, total_tax: 75, vat_rate: 25 }, + { name: 'Frakt', quantity: 1, total: 100, total_tax: 25, vat_rate: 25 }, + ], + customer_name: 'Anna Andersson', + customer_company: null, + customer_email: 'anna@example.se', + customer_orgnr: null, + customer_country: 'SE', + payment_method: 'swish', + payment_method_title: 'Swish', + gateway_reference: 'SW-123', + refunded_total: 0, + journal_entry_id: null, + invoice_id: null, + legacy_transaction_id: null, + manually_booked_at: null, + manually_booked_by: null, + manually_booked_journal_entry_id: null, + remote_changed_after_freeze: false, + created_at: '2026-08-01T00:00:00Z', + updated_at: '2026-08-01T00:00:00Z', + ...overrides, + } +} + +const log = { + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), +} as unknown as Logger + +describe('buildOrderUnderlagModel', () => { + it('maps line items, customer, payment method and per-rate totals', () => { + const model = buildOrderUnderlagModel(makeOrder()) + expect(model.title).toBe('Orderunderlag') + expect(model.orderNumber).toBe('1001') + expect(model.platformLabel).toBe('WooCommerce') + expect(model.storeLabel).toBe('Butiken') + expect(model.lines).toEqual([ + { name: 'Kaffekopp', quantity: 2, net: 300, tax: 75, vatRateLabel: '25%' }, + { name: 'Frakt', quantity: 1, net: 100, tax: 25, vatRateLabel: '25%' }, + ]) + expect(model.customerLines).toEqual(['Anna Andersson', 'anna@example.se', 'Land: SE']) + expect(model.paymentMethod).toBe('Swish') + expect(model.gatewayReference).toBe('SW-123') + expect(model.vatRows).toEqual([{ rateLabel: '25%', net: 400, tax: 100, gross: 500 }]) + expect(model.totalNet).toBe(400) + expect(model.totalTax).toBe(100) + expect(model.totalGross).toBe(500) + // SEK order: no conversion facts. + expect(model.totalSek).toBeNull() + expect(model.exchangeRate).toBeNull() + }) + + it('keeps every rate on a multi-rate order', () => { + const model = buildOrderUnderlagModel( + makeOrder({ + total: 456, + total_tax: 68, + vat_breakdown: [ + { rate: 25, net: 200, tax: 50 }, + { rate: 12, net: 150, tax: 18 }, + { rate: 0, net: 38, tax: 0 }, + ], + }), + ) + expect(model.vatRows).toEqual([ + { rateLabel: '25%', net: 200, tax: 50, gross: 250 }, + { rateLabel: '12%', net: 150, tax: 18, gross: 168 }, + { rateLabel: '0%', net: 38, tax: 0, gross: 38 }, + ]) + expect(model.totalNet).toBe(388) + }) + + it('rounds öre amounts and never emits float drift', () => { + const model = buildOrderUnderlagModel( + makeOrder({ + total: 100.3, + total_tax: 20.06, + vat_breakdown: [{ rate: 25, net: 80.239999999, tax: 20.060000001 }], + line_items: [ + { name: 'Vara', quantity: 3, total: 80.239999999, total_tax: 20.060000001, vat_rate: 25 }, + ], + }), + ) + expect(model.lines[0].net).toBe(80.24) + expect(model.lines[0].tax).toBe(20.06) + expect(model.vatRows[0]).toEqual({ rateLabel: '25%', net: 80.24, tax: 20.06, gross: 100.3 }) + expect(model.totalNet).toBe(80.24) + }) + + it('handles a missing customer entirely', () => { + const model = buildOrderUnderlagModel( + makeOrder({ + customer_name: null, + customer_company: null, + customer_email: null, + customer_country: null, + }), + ) + expect(model.customerLines).toEqual([]) + }) + + it('puts the company name before the contact person', () => { + const model = buildOrderUnderlagModel( + makeOrder({ customer_company: 'Kund AB', customer_name: 'Anna Andersson' }), + ) + expect(model.customerLines[0]).toBe('Kund AB') + expect(model.customerLines[1]).toBe('Anna Andersson') + }) + + it('negates the stored positive breakdown magnitudes on refund rows', () => { + const model = buildOrderUnderlagModel( + makeOrder({ + row_type: 'refund', + total: -500, + total_tax: -100, + vat_breakdown: [{ rate: 25, net: 400, tax: 100 }], + line_items: [], + }), + ) + expect(model.title).toBe('Orderunderlag: återbetalning') + expect(model.isRefund).toBe(true) + expect(model.vatRows).toEqual([{ rateLabel: '25%', net: -400, tax: -100, gross: -500 }]) + expect(model.totalGross).toBe(-500) + expect(model.totalNet).toBe(-400) + }) + + it('keeps signed buckets as stored on order rows (discount bucket)', () => { + const model = buildOrderUnderlagModel( + makeOrder({ + total: 375, + total_tax: 75, + vat_breakdown: [ + { rate: 25, net: 400, tax: 100 }, + { rate: 0, net: -100, tax: -25 }, + ], + }), + ) + expect(model.vatRows[1]).toEqual({ rateLabel: '0%', net: -100, tax: -25, gross: -125 }) + }) + + it('falls back to the inferred single bucket when the breakdown is empty', () => { + const model = buildOrderUnderlagModel( + makeOrder({ vat_breakdown: [], total: 125, total_tax: 25 }), + ) + expect(model.vatRows).toEqual([{ rateLabel: '25%', net: 100, tax: 25, gross: 125 }]) + }) + + it('labels an unresolved line rate with a dash', () => { + const model = buildOrderUnderlagModel( + makeOrder({ + line_items: [{ name: 'Vara', quantity: 1, total: 100, total_tax: 0, vat_rate: null }], + }), + ) + expect(model.lines[0].vatRateLabel).toBe('-') + }) + + it('carries the SEK conversion facts on non-SEK orders', () => { + const model = buildOrderUnderlagModel( + makeOrder({ currency: 'EUR', total: 50, total_tax: 10, total_sek: 561.5, exchange_rate: 11.23 }), + ) + expect(model.currency).toBe('EUR') + expect(model.totalSek).toBe(561.5) + expect(model.exchangeRate).toBe(11.23) + }) +}) + +describe('formatAmount', () => { + it('renders negatives with an ASCII hyphen, never U+2212 (WinAnsi PDF fonts drop it)', () => { + const formatted = formatAmount(-500) + // sv-SE Intl emits U+2212 MINUS SIGN; Helvetica/WinAnsi has no glyph for + // it, so an unguarded refund amount would silently render as positive in + // the archived underlag (skeptic finding). + expect(formatted.includes(String.fromCharCode(0x2212))).toBe(false) + expect(formatted.startsWith('-')).toBe(true) + expect(formatted.endsWith('500,00')).toBe(true) + }) + + it('formats two decimals with a Swedish decimal comma', () => { + expect(formatAmount(80.24).endsWith('80,24')).toBe(true) + expect(formatAmount(0)).toBe('0,00') + }) +}) + +describe('orderUnderlagFilename', () => { + it('names order and refund underlag distinctly', () => { + expect( + orderUnderlagFilename({ isRefund: false, orderNumber: '1001', orderDate: '2026-08-01' }), + ).toBe('Orderunderlag_1001_2026-08-01.pdf') + expect( + orderUnderlagFilename({ isRefund: true, orderNumber: '1001', orderDate: '2026-08-05' }), + ).toBe('Orderunderlag_aterbetalning_1001_2026-08-05.pdf') + }) +}) + +describe('archiveWebshopOrderUnderlag', () => { + const { supabase: supabaseMock, mockResult } = createMockSupabase() + const supabase = supabaseMock as unknown as SupabaseClient + + beforeEach(() => { + vi.clearAllMocks() + mockResult({ data: { company_name: 'Testbolag AB', org_number: '556677-8899' } }) + mockUploadDocument.mockResolvedValue({ id: 'doc-1' }) + }) + + it('renders a PDF and archives it anchored to the verifikat', async () => { + const result = await archiveWebshopOrderUnderlag({ + supabase, + companyId: 'company-1', + userId: 'user-1', + order: makeOrder(), + journalEntryId: 'je-1', + log, + }) + expect(result).toEqual({ ok: true, documentId: 'doc-1' }) + expect(mockUploadDocument).toHaveBeenCalledTimes(1) + const [, userId, companyId, file, metadata] = mockUploadDocument.mock.calls[0] + expect(userId).toBe('user-1') + expect(companyId).toBe('company-1') + expect(file.name).toBe('Orderunderlag_1001_2026-08-01.pdf') + expect(file.type).toBe('application/pdf') + // Real render: the buffer must actually be a PDF. + const head = Buffer.from(file.buffer as ArrayBuffer).subarray(0, 5).toString('utf8') + expect(head).toBe('%PDF-') + expect(metadata).toMatchObject({ + upload_source: 'system', + journal_entry_id: 'je-1', + extractionOwner: 'none', + }) + }) + + it('returns ok=false and logs instead of throwing when the archive fails', async () => { + mockUploadDocument.mockRejectedValueOnce(new Error('storage down')) + const result = await archiveWebshopOrderUnderlag({ + supabase, + companyId: 'company-1', + userId: 'user-1', + order: makeOrder(), + journalEntryId: 'je-1', + log, + }) + expect(result).toEqual({ ok: false, documentId: null }) + expect(log.error).toHaveBeenCalled() + }) + + it('renders without customer data and without company settings', async () => { + mockResult({ data: null }) + const result = await archiveWebshopOrderUnderlag({ + supabase, + companyId: 'company-1', + userId: 'user-1', + order: makeOrder({ + customer_name: null, + customer_company: null, + customer_email: null, + customer_country: null, + line_items: [], + }), + journalEntryId: 'je-1', + log, + }) + expect(result.ok).toBe(true) + }) +}) diff --git a/lib/webshop-orders/order-underlag.tsx b/lib/webshop-orders/order-underlag.tsx new file mode 100644 index 00000000..f1bc1967 --- /dev/null +++ b/lib/webshop-orders/order-underlag.tsx @@ -0,0 +1,532 @@ +import { Document, Page, Text, View, StyleSheet, renderToBuffer } from '@react-pdf/renderer' +import type { SupabaseClient } from '@supabase/supabase-js' +import { roundOre } from '@/lib/money' +import { uploadDocument } from '@/lib/core/documents/document-service' +import { fallbackVatBreakdown } from '@/lib/webshop-orders/booking-lines' +import type { Logger } from '@/lib/logger' +import type { WebshopOrder } from '@/types' + +/** + * Orderunderlag for a booked webshop order (issue #1881). + * + * The booking verifikat only carries the VAT split (buildOrderBookingLines), + * so without this document the archived affärshändelse loses everything the + * sync already knows: product lines, customer, payment method. BFL 5 kap 7 § + * requires the verifikation to rest on underlag that shows what the + * affärshändelse was; this module renders that underlag from the stored + * webshop_orders row and archives it on the verifikat through the same WORM + * document path the issued-invoice PDF uses (archiveIssuedInvoicePdf). + * + * Swedish-only on purpose: underlag is räkenskapsinformation, the same + * stays-Swedish surface class as invoice PDFs and SIE exports. + */ + +/** Everything the PDF renders, precomputed so it is testable without react-pdf. */ +export interface OrderUnderlagModel { + title: string + isRefund: boolean + orderNumber: string + platformLabel: string + storeLabel: string | null + orderDate: string + paidDate: string | null + status: string + /** Company/person lines shown under "Kund"; empty when the store sent none. */ + customerLines: string[] + paymentMethod: string | null + gatewayReference: string | null + currency: string + lines: Array<{ + name: string + quantity: number + /** Net (excl. VAT), in the order's currency, signed as stored. */ + net: number + tax: number + /** '25%' | '12%' | '6%' | '0%' | '-' (unresolved rate). */ + vatRateLabel: string + }> + /** Per-rate summary; signed like the row (negative on refunds). */ + vatRows: Array<{ rateLabel: string; net: number; tax: number; gross: number }> + totalNet: number + totalTax: number + totalGross: number + /** SEK conversion facts for non-SEK orders; null when SEK or unresolved. */ + totalSek: number | null + exchangeRate: number | null +} + +const PLATFORM_LABELS: Record = { + woocommerce: 'WooCommerce', + shopify: 'Shopify', +} + +function vatRateLabel(rate: number | null): string { + if (rate === null) return '-' + return `${rate}%` +} + +/** + * Build the render model from a stored order row. Pure; all money through + * roundOre. Refund rows keep their negative signs so the underlag reads like + * the money movement it documents. + */ +export function buildOrderUnderlagModel( + order: Pick< + WebshopOrder, + | 'row_type' + | 'platform' + | 'store_label' + | 'store_scope' + | 'order_number' + | 'status' + | 'order_date' + | 'paid_date' + | 'currency' + | 'total' + | 'total_tax' + | 'total_sek' + | 'exchange_rate' + | 'vat_breakdown' + | 'line_items' + | 'customer_name' + | 'customer_company' + | 'customer_email' + | 'customer_country' + | 'payment_method' + | 'payment_method_title' + | 'gateway_reference' + >, +): OrderUnderlagModel { + const isRefund = order.row_type === 'refund' + const currency = order.currency.toUpperCase() + const isSek = currency === 'SEK' + + const lines = (order.line_items ?? []).map((item) => ({ + name: item.name, + quantity: item.quantity, + net: roundOre(item.total), + tax: roundOre(item.total_tax), + vatRateLabel: vatRateLabel(item.vat_rate), + })) + + // Refund rows store the breakdown as positive magnitudes (direction lives + // in row_type, mirroring booking-lines); re-apply the sign so the summary + // matches the negative totals. Order rows keep the buckets as stored: + // they are SIGNED there (a discount bucket carries a negative net). Fall + // back to the inferred single bucket exactly like the booking prefill when + // the sync could not build one. + const breakdown = + order.vat_breakdown.length > 0 + ? order.vat_breakdown + : fallbackVatBreakdown(order.total, order.total_tax) + const vatRows = breakdown.map((bucket) => { + const net = roundOre(isRefund ? -Math.abs(bucket.net) : bucket.net) + const tax = roundOre(isRefund ? -Math.abs(bucket.tax) : bucket.tax) + return { + rateLabel: vatRateLabel(bucket.rate), + net, + tax, + gross: roundOre(net + tax), + } + }) + + const totalGross = roundOre(order.total) + const totalTax = roundOre(order.total_tax) + + const customerLines = [ + order.customer_company, + order.customer_name, + order.customer_email, + order.customer_country ? `Land: ${order.customer_country.toUpperCase()}` : null, + ].filter((line): line is string => !!line) + + return { + title: isRefund ? 'Orderunderlag: återbetalning' : 'Orderunderlag', + isRefund, + orderNumber: order.order_number, + platformLabel: PLATFORM_LABELS[order.platform] ?? order.platform, + storeLabel: order.store_label || order.store_scope || null, + orderDate: order.order_date, + paidDate: order.paid_date, + status: order.status, + customerLines, + paymentMethod: order.payment_method_title || order.payment_method || null, + gatewayReference: order.gateway_reference, + currency, + lines, + vatRows, + totalNet: roundOre(totalGross - totalTax), + totalTax, + totalGross, + totalSek: isSek ? null : order.total_sek !== null ? roundOre(order.total_sek) : null, + exchangeRate: isSek ? null : order.exchange_rate, + } +} + +/** Sanitized-enough name; uploadDocument sanitizes the storage key itself. */ +export function orderUnderlagFilename(model: Pick): string { + const kind = model.isRefund ? 'Orderunderlag_aterbetalning' : 'Orderunderlag' + return `${kind}_${model.orderNumber}_${model.orderDate}.pdf` +} + +const styles = StyleSheet.create({ + page: { + paddingTop: 40, + paddingHorizontal: 40, + paddingBottom: 60, + fontSize: 9, + fontFamily: 'Helvetica', + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + marginBottom: 20, + paddingBottom: 12, + borderBottomWidth: 1, + borderBottomColor: '#d4d4d4', + }, + title: { + fontSize: 18, + fontWeight: 'bold', + color: '#1a1a1a', + marginBottom: 4, + }, + subtitle: { + fontSize: 10, + color: '#333', + marginBottom: 2, + }, + meta: { + fontSize: 9, + color: '#666', + }, + companyInfo: { + textAlign: 'right', + }, + companyName: { + fontSize: 11, + fontWeight: 'bold', + marginBottom: 2, + }, + block: { + marginBottom: 12, + }, + blockRow: { + flexDirection: 'row', + gap: 32, + marginBottom: 14, + }, + blockLabel: { + fontSize: 7.5, + fontWeight: 'bold', + color: '#555', + textTransform: 'uppercase', + marginBottom: 3, + }, + blockText: { + fontSize: 9, + color: '#1a1a1a', + marginBottom: 1, + }, + blockMuted: { + fontSize: 9, + color: '#888', + fontStyle: 'italic', + }, + sectionHeading: { + fontSize: 11, + fontWeight: 'bold', + color: '#1a1a1a', + marginTop: 8, + marginBottom: 5, + paddingBottom: 3, + borderBottomWidth: 1, + borderBottomColor: '#1a1a1a', + }, + tableHeader: { + flexDirection: 'row', + paddingVertical: 3, + borderBottomWidth: 0.8, + borderBottomColor: '#999', + }, + headerCell: { + fontSize: 7.5, + fontWeight: 'bold', + color: '#555', + textTransform: 'uppercase', + }, + row: { + flexDirection: 'row', + paddingVertical: 3, + borderBottomWidth: 0.4, + borderBottomColor: '#e4e4e4', + }, + totalRow: { + flexDirection: 'row', + paddingVertical: 4, + marginTop: 2, + borderTopWidth: 1, + borderTopColor: '#1a1a1a', + }, + colName: { + flex: 1, + paddingRight: 8, + color: '#1a1a1a', + }, + colQty: { + width: 40, + textAlign: 'right', + fontFamily: 'Courier', + }, + colAmount: { + width: 70, + textAlign: 'right', + fontFamily: 'Courier', + color: '#1a1a1a', + }, + colRate: { + width: 50, + textAlign: 'right', + color: '#666', + }, + bold: { + fontWeight: 'bold', + }, + footer: { + position: 'absolute', + bottom: 24, + left: 40, + right: 40, + borderTopWidth: 0.5, + borderTopColor: '#d4d4d4', + paddingTop: 6, + flexDirection: 'row', + justifyContent: 'space-between', + }, + footerText: { + fontSize: 8, + color: '#888', + }, +}) + +// U+2212 MINUS SIGN, which sv-SE Intl emits for negatives. Standard PDF +// fonts (Helvetica/WinAnsi) have no glyph for it and drop it silently, so a +// refund would render as a POSITIVE amount in the archived underlag (skeptic +// finding; same guard as formatPdfCurrency in lib/invoices/pdf-template). +// Built via fromCharCode so no escape sequence can mangle in transit. +const UNICODE_MINUS = String.fromCharCode(0x2212) + +/** Exported for tests: the sign guard must never regress. */ +export function formatAmount(amount: number): string { + return new Intl.NumberFormat('sv-SE', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }) + .format(amount) + .replaceAll(UNICODE_MINUS, '-') +} + +export interface OrderUnderlagCompany { + company_name?: string | null + org_number?: string | null +} + +export function WebshopOrderUnderlagPDF({ + model, + company, + generatedAt, +}: { + model: OrderUnderlagModel + company: OrderUnderlagCompany + generatedAt: string +}) { + return ( + + + + + {model.title} + + Order {model.orderNumber} + {model.storeLabel ? ` · ${model.storeLabel}` : ''} ({model.platformLabel}) + + + Orderdatum: {model.orderDate} + {model.paidDate ? ` · Betald: ${model.paidDate}` : ''} · Status: {model.status} + + + + {company.company_name ? ( + {company.company_name} + ) : null} + {company.org_number ? ( + Org.nr: {company.org_number} + ) : null} + + + + + + Kund + {model.customerLines.length > 0 ? ( + model.customerLines.map((line, i) => ( + + {line} + + )) + ) : ( + Uppgift saknas i ordern + )} + + + Betalning + {model.paymentMethod ?? 'Okänd betalmetod'} + {model.gatewayReference ? ( + Referens: {model.gatewayReference} + ) : null} + {model.totalSek !== null ? ( + + Motsvarande i SEK: {formatAmount(model.totalSek)} kr + {model.exchangeRate ? ` (kurs ${model.exchangeRate})` : ''} + + ) : null} + + + + Orderrader ({model.currency}) + {model.lines.length === 0 ? ( + Ordern saknar radspecifikation från butiken. + ) : ( + + + Beskrivning + Antal + Exkl. moms + Moms + Sats + + {model.lines.map((line, i) => ( + + {line.name} + {line.quantity} + {formatAmount(line.net)} + {formatAmount(line.tax)} + {line.vatRateLabel} + + ))} + + )} + + Belopp per momssats ({model.currency}) + + + Momssats + Netto + Moms + Summa + + {model.vatRows.map((row, i) => ( + + {row.rateLabel} + {formatAmount(row.net)} + {formatAmount(row.tax)} + {formatAmount(row.gross)} + + ))} + + Totalt + {formatAmount(model.totalNet)} + {formatAmount(model.totalTax)} + {formatAmount(model.totalGross)} + + + + + + Underlag genererat ur butikens orderdata vid bokföring + + + `Genererad ${generatedAt} · Sida ${pageNumber} av ${totalPages}` + } + /> + + + + ) +} + +export interface ArchiveOrderUnderlagResult { + ok: boolean + documentId: string | null +} + +/** + * Render the orderunderlag and archive it on the just-committed verifikat. + * Mirrors archiveIssuedInvoicePdf: never throws, the booking is already + * committed and immutable, so a failure here is logged and surfaced to the + * caller as ok=false. The verifikat then stays on the "saknar underlag" + * worklist (webshop_order is a needs-doc source type), which is exactly the + * recovery mechanism: the user attaches an underlag by hand. + */ +export async function archiveWebshopOrderUnderlag(args: { + supabase: SupabaseClient + companyId: string + userId: string + order: WebshopOrder + journalEntryId: string + log: Logger +}): Promise { + const { supabase, companyId, userId, order, journalEntryId, log } = args + try { + const model = buildOrderUnderlagModel(order) + + // Header context only; the underlag is valid without it. + let company: OrderUnderlagCompany = {} + try { + const { data } = await supabase + .from('company_settings') + .select('company_name, org_number') + .eq('company_id', companyId) + .maybeSingle() + company = (data as OrderUnderlagCompany | null) ?? {} + } catch { + company = {} + } + + const pdfBuffer = await renderToBuffer( + WebshopOrderUnderlagPDF({ + model, + company, + generatedAt: new Date().toISOString().split('T')[0], + }), + ) + const pdfArrayBuffer = new Uint8Array(pdfBuffer).buffer as ArrayBuffer + + const document = await uploadDocument( + supabase, + userId, + companyId, + { + name: orderUnderlagFilename(model), + buffer: pdfArrayBuffer, + type: 'application/pdf', + }, + { + upload_source: 'system', + journal_entry_id: journalEntryId, + // Self-generated from structured data: nothing to extract. + extractionOwner: 'none', + }, + ) + return { ok: true, documentId: document.id } + } catch (err) { + log.error('failed to archive webshop order underlag', err as Error, { + orderId: order.id, + journalEntryId, + }) + return { ok: false, documentId: null } + } +} diff --git a/lib/worklist/categories.ts b/lib/worklist/categories.ts index 741a4a78..1bf9db5b 100644 --- a/lib/worklist/categories.ts +++ b/lib/worklist/categories.ts @@ -16,21 +16,11 @@ import { } from '@/lib/invoices/matchable-statuses' import type { SuggestedMatch } from './types' -const log = createLogger('worklist') +// Canonical home is lib/worklist/types.ts (dependency-free, client-safe); +// re-exported here so existing server-side imports keep working. +export { NEEDS_DOC_SOURCE_TYPES } from './types' -/** - * Journal-entry source types that require underlag (BFL 5 kap 7§). Source - * types representing system-generated entries (VAT settlement, year-end, - * currency revaluation, …) are exempt by omission. - */ -export const NEEDS_DOC_SOURCE_TYPES = [ - 'manual', - 'bank_transaction', - 'supplier_invoice_registered', - 'supplier_invoice_paid', - 'supplier_invoice_cash_payment', - 'import', -] as const +const log = createLogger('worklist') /** * Upper bound on the unconsumed-inbox scan in countInboxDocuments. An inbox diff --git a/lib/worklist/types.ts b/lib/worklist/types.ts index 4d9884d2..eb2c707f 100644 --- a/lib/worklist/types.ts +++ b/lib/worklist/types.ts @@ -117,3 +117,27 @@ export interface SuggestedMatch { counterparty_name: string | null candidate_total: number | null } + +/** + * Journal-entry source types that require underlag (BFL 5 kap 7 §). Source + * types representing system-generated entries (VAT settlement, year-end, + * currency revaluation, ...) are exempt by omission. + * + * Single source of truth for EVERY TS surface (worklist counts, journal-list + * chip/waiver UI, no-doc-required batch route, push notifications); the SQL + * mirror lives in the verifikat_without_documents RPC, pinned by + * tests/pg/document-surfaces-unification.pg.test.ts. Lives here (not in + * categories.ts) because this module is dependency-free and safe to import + * from client components. + */ +export const NEEDS_DOC_SOURCE_TYPES = [ + 'manual', + 'bank_transaction', + 'supplier_invoice_registered', + 'supplier_invoice_paid', + 'supplier_invoice_cash_payment', + 'import', + // Webshop order bookings rest on the generated orderunderlag (#1881); an + // entry whose underlag failed to attach must surface here. + 'webshop_order', +] as const diff --git a/supabase/migrations/20260825160000_verifikat_needs_doc_webshop_order.sql b/supabase/migrations/20260825160000_verifikat_needs_doc_webshop_order.sql new file mode 100644 index 00000000..19c271fa --- /dev/null +++ b/supabase/migrations/20260825160000_verifikat_needs_doc_webshop_order.sql @@ -0,0 +1,154 @@ +-- Add 'webshop_order' to the needs-doc source-type list of +-- verifikat_without_documents (#1881). +-- +-- Webshop order bookings now archive a generated orderunderlag (line items, +-- customer, payment method) on the verifikat at booking time. The source type +-- therefore belongs in the needs-doc list: a webshop_order verifikat without a +-- current-version document (historical bookings from before #1881, or a failed +-- underlag attach) must surface on the "saknar underlag" worklist instead of +-- silently passing as documented. +-- +-- Only the verifikat surface changes. transactions_without_documents stays +-- as-is: webshop_order entries never hang on a transactions row (the legacy +-- feed cross-lock guarantees a feed row and an order row are never both +-- booked), so the transactions surface cannot contain them and remains a +-- strict subset of this one. +-- +-- Body identical to 20260724090000 except for the added source type. Keep the +-- needs-doc list in lockstep with NEEDS_DOC_SOURCE_TYPES +-- (lib/worklist/categories.ts); pinned by +-- tests/pg/document-surfaces-unification.pg.test.ts. +-- +-- pg-test: tests/pg/document-surfaces-unification.pg.test.ts + +CREATE OR REPLACE FUNCTION public.verifikat_without_documents( + p_company_id uuid, + p_since date DEFAULT NULL, + p_min_amount numeric DEFAULT 0, + p_limit integer DEFAULT 20, + p_offset integer DEFAULT 0 +) +RETURNS jsonb +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +SET search_path TO 'public' +AS $$ +DECLARE + v_jwt_role text := coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role', ''); + v_limit integer := least(greatest(coalesce(p_limit, 20), 1), 100); + v_offset integer := greatest(coalesce(p_offset, 0), 0); + v_min numeric := greatest(coalesce(p_min_amount, 0), 0); + v_result jsonb; +BEGIN + IF v_jwt_role IN ('anon', 'authenticated') THEN + IF p_company_id IS NULL OR NOT EXISTS ( + SELECT 1 FROM public.user_company_ids() AS c(id) WHERE c.id = p_company_id + ) THEN + RETURN jsonb_build_object('ok', false, 'code', 'VERIFIKAT_WITHOUT_DOCUMENTS_FORBIDDEN'); + END IF; + END IF; + + WITH candidates AS ( + SELECT + je.id, + je.voucher_series, + je.voucher_number, + je.entry_date, + je.description, + je.source_type, + round(coalesce(sum(l.debit_amount), 0), 2) AS gross_amount + FROM journal_entries je + LEFT JOIN journal_entry_lines l ON l.journal_entry_id = je.id + WHERE je.company_id = p_company_id + AND je.status = 'posted' + -- Only source types whose affärshändelse requires an underlag. + -- Mirrors NEEDS_DOC_SOURCE_TYPES (lib/worklist/categories.ts). + AND je.source_type IN ( + 'manual', + 'bank_transaction', + 'supplier_invoice_registered', + 'supplier_invoice_paid', + 'supplier_invoice_cash_payment', + 'import', + 'webshop_order' + ) + -- Superseded document versions do not satisfy BFL underlag. + AND NOT EXISTS ( + SELECT 1 FROM document_attachments d + WHERE d.journal_entry_id = je.id AND d.is_current_version = true + ) + -- Explicitly waived (e.g. internal transfers): user decided no + -- underlag is required; do not resurface to agents. + AND NOT EXISTS ( + SELECT 1 FROM journal_entry_no_doc_required x + WHERE x.journal_entry_id = je.id + ) + -- BFL 5 kap 7 §: hänvisning till underlag. An entry booked from a + -- supplier invoice whose source document is retained is covered by + -- that document even though the doc row hangs on the invoice's other + -- verifikat (registration vs payment). The doc must be ANCHORED + -- (journal_entry_id set): only anchored docs sit behind the WORM + -- deletion guards, so an unanchored doc cannot legally back a posted + -- verifikat and must keep the warning alive. + AND NOT EXISTS ( + SELECT 1 + FROM supplier_invoices si + JOIN document_attachments sd ON sd.id = si.document_id + WHERE si.company_id = p_company_id + AND sd.journal_entry_id IS NOT NULL + AND (si.registration_journal_entry_id = je.id + OR si.payment_journal_entry_id = je.id) + ) + -- Partial payments link through supplier_invoice_payments instead of + -- supplier_invoices.payment_journal_entry_id. + AND NOT EXISTS ( + SELECT 1 + FROM supplier_invoice_payments sip + JOIN supplier_invoices sip_si ON sip_si.id = sip.supplier_invoice_id + JOIN document_attachments sipd ON sipd.id = sip_si.document_id + WHERE sip.journal_entry_id = je.id + AND sip_si.company_id = p_company_id + AND sipd.journal_entry_id IS NOT NULL + ) + AND (p_since IS NULL OR je.entry_date >= p_since) + GROUP BY je.id + HAVING round(coalesce(sum(l.debit_amount), 0), 2) >= v_min + ), + total AS ( + SELECT count(*) AS n FROM candidates + ), + page AS ( + SELECT * FROM candidates + ORDER BY entry_date DESC, voucher_number DESC, id DESC + LIMIT v_limit OFFSET v_offset + ) + SELECT jsonb_build_object( + 'ok', true, + 'total_count', (SELECT n FROM total), + 'verifikat', coalesce( + (SELECT jsonb_agg( + jsonb_build_object( + 'journal_entry_id', p.id, + 'voucher_series', p.voucher_series, + 'voucher_number', p.voucher_number, + 'entry_date', p.entry_date, + 'description', p.description, + 'source_type', p.source_type, + 'gross_amount', p.gross_amount + ) + ORDER BY p.entry_date DESC, p.voucher_number DESC, p.id DESC + ) FROM page p), + '[]'::jsonb + ) + ) + INTO v_result; + + RETURN v_result; +END; +$$; + +REVOKE ALL ON FUNCTION public.verifikat_without_documents(uuid, date, numeric, integer, integer) FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION public.verifikat_without_documents(uuid, date, numeric, integer, integer) TO authenticated, service_role; + +NOTIFY pgrst, 'reload schema'; diff --git a/tests/pg/document-surfaces-unification.pg.test.ts b/tests/pg/document-surfaces-unification.pg.test.ts index 5f010f73..13f21dd1 100644 --- a/tests/pg/document-surfaces-unification.pg.test.ts +++ b/tests/pg/document-surfaces-unification.pg.test.ts @@ -354,6 +354,39 @@ describe('document surfaces unification', () => { expect((res.verifikat ?? []).map((v) => v.journal_entry_id).sort()).toEqual(expected.sort()) }) + it('webshop_order: flagged without underlag, silenced by the archived orderunderlag (#1881)', async () => { + // The book route archives a generated orderunderlag on the verifikat; a + // historical booking (or a failed attach) has no doc and must surface. + const s = await seedCompany() + const mkWebshopJe = (n: number) => + insertPostedJournalEntry({ + userId: s.userId, + companyId: s.companyId, + fiscalPeriodId: s.fiscalPeriodId, + voucherNumber: n, + entryDate: '2026-06-15', + description: `webshop order ${n}`, + sourceType: 'webshop_order', + lines: [ + { accountNumber: '1930', debitAmount: 100 * n, creditAmount: 0 }, + { accountNumber: '3001', debitAmount: 0, creditAmount: 100 * n }, + ], + }) + const jeWithUnderlag = await mkWebshopJe(1) + const jeWithoutUnderlag = await mkWebshopJe(2) + await attachDocument({ + userId: s.userId, + companyId: s.companyId, + journalEntryId: jeWithUnderlag, + }) + + const res = await verifikatSurface(s.companyId) + expect(res.ok).toBe(true) + const ids = (res.verifikat ?? []).map((v) => v.journal_entry_id) + expect(ids).toContain(jeWithoutUnderlag) + expect(ids).not.toContain(jeWithUnderlag) + }) + it('tenant guard on the transactions surface (NULL + foreign company)', async () => { const { rows } = await getPool().query<{ r: TransactionsResult }>( `SELECT public.transactions_without_documents(NULL, NULL, 20, 0) AS r`,