feat(webshop): generate orderunderlag and attach it to the verifikat at booking (#1899)
* feat(webshop): generate orderunderlag PDF and attach it to the verifikat at booking Booked webshop orders only carried the VAT split; the verifikat showed no product lines, customer or payment method although the sync already stores all of it in webshop_orders.line_items (#1881). - lib/webshop-orders/order-underlag.tsx: pure model builder + react-pdf template (order lines, customer, payment method, per-rate VAT summary, SEK conversion facts) + archiveWebshopOrderUnderlag, which renders and archives the PDF on the committed verifikat through uploadDocument (upload_source system, extraction none), mirroring archiveIssuedInvoicePdf. Never throws: the booking is immutable by then. - book route: archive after commitEntry; response gains underlag_archived. FX-retry now also syncs the in-memory row so the underlag shows the resolved SEK facts. - webshop_order added to NEEDS_DOC_SOURCE_TYPES and (new migration 20260825140000) to the verifikat_without_documents needs-doc list, so a failed attach or a historical booking surfaces on the saknar-underlag worklist. transactions_without_documents is deliberately unchanged. - tests: underlag model/render/archive unit tests, book-route archive and failure-isolation cases, pg test extended (per-source-type probe now covers webshop_order; explicit flagged/silenced pair). Fixes #1881 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(migrations): move webshop needs-doc migration after main's 20260825150000 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(webshop): add manually_booked fields to the underlag order fixture Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(webshop): skeptic findings on the orderunderlag (#1881) Two refutations from the skeptic pass on PR #1899, both fixed: 1. Correctness: sv-SE Intl emits U+2212 MINUS SIGN for negatives, which Helvetica/WinAnsi PDF fonts drop silently, so refund and discount amounts on the archived underlag rendered as POSITIVE. formatAmount now replaces U+2212 with an ASCII hyphen (same guard as formatPdfCurrency), is exported, and is pinned by a regression test. 2. Regression: NEEDS_DOC_SOURCE_TYPES had two hardcoded copies that missed webshop_order, so flagged rows rendered without the "Underlag saknas" chip, waiver toggle, or batch-exempt selection, and the weekly missing-underlag push cron disagreed with the badge. The constant now lives in dependency-free lib/worklist/types.ts (client-safe), is re-exported from categories.ts, and both JournalEntryList.tsx and push-notifications/notification-scheduler.ts consume it instead of their own copies. Also: "Bokfört i SEK" reworded to "Motsvarande i SEK" (compliance skeptic observation: the dialog's lines are user-editable, so the underlag must state the order's conversion, not claim a booking fact). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1231,4 +1231,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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).
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string>(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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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> = {}): 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)
|
||||
})
|
||||
})
|
||||
@@ -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<string, string> = {
|
||||
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<OrderUnderlagModel, 'isRefund' | 'orderNumber' | 'orderDate'>): 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 (
|
||||
<Document>
|
||||
<Page size="A4" style={styles.page}>
|
||||
<View style={styles.header} fixed>
|
||||
<View>
|
||||
<Text style={styles.title}>{model.title}</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Order {model.orderNumber}
|
||||
{model.storeLabel ? ` · ${model.storeLabel}` : ''} ({model.platformLabel})
|
||||
</Text>
|
||||
<Text style={styles.meta}>
|
||||
Orderdatum: {model.orderDate}
|
||||
{model.paidDate ? ` · Betald: ${model.paidDate}` : ''} · Status: {model.status}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.companyInfo}>
|
||||
{company.company_name ? (
|
||||
<Text style={styles.companyName}>{company.company_name}</Text>
|
||||
) : null}
|
||||
{company.org_number ? (
|
||||
<Text style={styles.meta}>Org.nr: {company.org_number}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.blockRow}>
|
||||
<View style={styles.block}>
|
||||
<Text style={styles.blockLabel}>Kund</Text>
|
||||
{model.customerLines.length > 0 ? (
|
||||
model.customerLines.map((line, i) => (
|
||||
<Text key={i} style={styles.blockText}>
|
||||
{line}
|
||||
</Text>
|
||||
))
|
||||
) : (
|
||||
<Text style={styles.blockMuted}>Uppgift saknas i ordern</Text>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.block}>
|
||||
<Text style={styles.blockLabel}>Betalning</Text>
|
||||
<Text style={styles.blockText}>{model.paymentMethod ?? 'Okänd betalmetod'}</Text>
|
||||
{model.gatewayReference ? (
|
||||
<Text style={styles.blockText}>Referens: {model.gatewayReference}</Text>
|
||||
) : null}
|
||||
{model.totalSek !== null ? (
|
||||
<Text style={styles.blockText}>
|
||||
Motsvarande i SEK: {formatAmount(model.totalSek)} kr
|
||||
{model.exchangeRate ? ` (kurs ${model.exchangeRate})` : ''}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={styles.sectionHeading}>Orderrader ({model.currency})</Text>
|
||||
{model.lines.length === 0 ? (
|
||||
<Text style={styles.blockMuted}>Ordern saknar radspecifikation från butiken.</Text>
|
||||
) : (
|
||||
<View>
|
||||
<View style={styles.tableHeader}>
|
||||
<Text style={[styles.colName, styles.headerCell]}>Beskrivning</Text>
|
||||
<Text style={[styles.colQty, styles.headerCell]}>Antal</Text>
|
||||
<Text style={[styles.colAmount, styles.headerCell]}>Exkl. moms</Text>
|
||||
<Text style={[styles.colAmount, styles.headerCell]}>Moms</Text>
|
||||
<Text style={[styles.colRate, styles.headerCell]}>Sats</Text>
|
||||
</View>
|
||||
{model.lines.map((line, i) => (
|
||||
<View key={i} style={styles.row} wrap={false}>
|
||||
<Text style={styles.colName}>{line.name}</Text>
|
||||
<Text style={styles.colQty}>{line.quantity}</Text>
|
||||
<Text style={styles.colAmount}>{formatAmount(line.net)}</Text>
|
||||
<Text style={styles.colAmount}>{formatAmount(line.tax)}</Text>
|
||||
<Text style={styles.colRate}>{line.vatRateLabel}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Text style={styles.sectionHeading}>Belopp per momssats ({model.currency})</Text>
|
||||
<View>
|
||||
<View style={styles.tableHeader}>
|
||||
<Text style={[styles.colName, styles.headerCell]}>Momssats</Text>
|
||||
<Text style={[styles.colAmount, styles.headerCell]}>Netto</Text>
|
||||
<Text style={[styles.colAmount, styles.headerCell]}>Moms</Text>
|
||||
<Text style={[styles.colAmount, styles.headerCell]}>Summa</Text>
|
||||
</View>
|
||||
{model.vatRows.map((row, i) => (
|
||||
<View key={i} style={styles.row} wrap={false}>
|
||||
<Text style={styles.colName}>{row.rateLabel}</Text>
|
||||
<Text style={styles.colAmount}>{formatAmount(row.net)}</Text>
|
||||
<Text style={styles.colAmount}>{formatAmount(row.tax)}</Text>
|
||||
<Text style={styles.colAmount}>{formatAmount(row.gross)}</Text>
|
||||
</View>
|
||||
))}
|
||||
<View style={styles.totalRow}>
|
||||
<Text style={[styles.colName, styles.bold]}>Totalt</Text>
|
||||
<Text style={[styles.colAmount, styles.bold]}>{formatAmount(model.totalNet)}</Text>
|
||||
<Text style={[styles.colAmount, styles.bold]}>{formatAmount(model.totalTax)}</Text>
|
||||
<Text style={[styles.colAmount, styles.bold]}>{formatAmount(model.totalGross)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.footer} fixed>
|
||||
<Text style={styles.footerText}>
|
||||
Underlag genererat ur butikens orderdata vid bokföring
|
||||
</Text>
|
||||
<Text
|
||||
style={styles.footerText}
|
||||
render={({ pageNumber, totalPages }) =>
|
||||
`Genererad ${generatedAt} · Sida ${pageNumber} av ${totalPages}`
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</Page>
|
||||
</Document>
|
||||
)
|
||||
}
|
||||
|
||||
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<ArchiveOrderUnderlagResult> {
|
||||
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 }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
@@ -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`,
|
||||
|
||||
Reference in New Issue
Block a user