diff --git a/.compliance/dpia-invoice-delivery-history.md b/.compliance/dpia-invoice-delivery-history.md index f63768c5..727d9dc2 100644 --- a/.compliance/dpia-invoice-delivery-history.md +++ b/.compliance/dpia-invoice-delivery-history.md @@ -3,6 +3,7 @@ Classification: Confidential Date: 2026-07-22 +Updated: 2026-07-24 (provider delivery outcome) Owner: Accounted controller Status: Screening completed @@ -19,9 +20,19 @@ and legal obligations under Article 6(1)(c) and BFL 7 kap. The exact payload is needed server-side to resolve delivery disputes and retain the sent accounting document. It is not necessary in the routine browser list. The list therefore exposes only status, timestamps, masked recipient domains, -provider name, error code, and an active-company-scoped link to the archived -PDF. Subjects, bodies, full addresses, reply-to addresses, provider message IDs, -BCC recipients, filenames, and checksums are excluded. +provider name, the provider delivery outcome with its masked reason text, error +code, and an active-company-scoped link to the archived PDF. Subjects, bodies, +full addresses, reply-to addresses, provider message IDs, BCC recipients, +filenames, and checksums are excluded. + +The provider delivery outcome (`provider_status`, `provider_status_at`, +`provider_status_detail`) is received from the email provider over a signed +webhook after the send. The outcome and its timestamp are delivery metadata. +The reason text is provider-authored and routinely quotes the recipient address +that failed, so it is treated as recipient personal data: local parts are masked +before it leaves the server, the stored text is capped at 500 characters, and it +is cleared by the same daily redaction job as the rest of the delivery PII. No +open or click tracking is enabled, so no recipient behaviour is recorded. The owner/admin full statutory archive has a different legal and operational purpose from the routine list, so it intentionally does not apply the list's @@ -49,8 +60,13 @@ server-generated export. statutory exports are owner/admin-only server operations. Their exact payload exception is limited to the downloadable statutory archive purpose described above and is not reused by the routine history endpoint. - The summary function is defined in migration `20260723003000` and the route - applies domain masking again before returning its allow-listed fields. + The summary function is defined in migration `20260724160000` and the route + applies domain masking again before returning its allow-listed fields, + including inside the provider reason text. +- Forged delivery outcome: the provider webhook is Svix-signature verified + before anything is written, and the applying function is service-role only. + It matches on the provider's own message identifier, may only touch an + already sent, unredacted row, and can never downgrade an observed failure. - Forged delivery evidence: authenticated PostgREST INSERT and UPDATE access is removed. Server-only functions bind reservations and state transitions to a verified writable company member. Payload-free crashed reservations may be diff --git a/.compliance/ropa.yaml b/.compliance/ropa.yaml index 40565019..ba83ff8c 100644 --- a/.compliance/ropa.yaml +++ b/.compliance/ropa.yaml @@ -52,6 +52,8 @@ processing_activities: - exact_sent_pdf_worm_protection - metadata_only_immutable_audit_log - daily_post_retention_pii_redaction + - signed_provider_delivery_webhook_only + - masked_recipient_addresses_in_provider_reason_text - id: customer.private_identity name: Personnummer för privatkund diff --git a/DECISIONS.md b/DECISIONS.md index 672f9905..98027727 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -368,6 +368,11 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-07-24] Declined compliance-bot ask to restore internal check codes (RC_BASIS_MISSING et al) in VAT check rows (#1161): the codes are local pre-flight rule IDs, not statutory references; the Swedish messages already cite the rutor and SKV felkod (FK004), and the founder explicitly flagged the raw codes as visual noise. [2026-07-24] Declined compliance-bot ask to re-box the bolagsskattMissing warning (#1161): text-attn IS the locked house attention idiom (one ochre sentence, banners forbidden by design.md); PreviewStep keeps the inline action to the dispositions step, so salience + remediation path both remain. [2026-07-24] VAT RC checks proportional (0.5% + 1 kr tolerance) + latched stepper landing: the binary present/absent RC_BASIS_MISSING check cleared after one korrigering and hid a 38-voucher worklist behind "klart"; tolerance absorbs per-voucher basis rounding (moms/sats vs invoiced amount) without hiding a missing voucher; landing step latches once per period so a mid-work refetch cannot navigate the user off Kontrollera. +[2026-07-24] Stripe for Arcim's own books = self-connection row, not Connect OAuth: the Connect platform account IS Arcim Technology AB (acct_1TQrB0Qj4cYcnWY9), and Stripe refuses to let an account connect to itself. Verified that Stripe accepts a self-referencing Stripe-Account header as a no-op, so the whole sync path works unchanged against the platform account; only the OAuth handshake that creates the stripe_connections row is blocked. Row inserted by hand in prod (company ed461bc1, connection b77b6619) as an interim unblock; the durable fix is a gated STRIPE_PLATFORM_OWNER_COMPANY_ID self-connect path, which also unblocks self-hosted installs whose operator owns the platform key. The row must stay pinned to that one company: it grants read access to the platform account's live Stripe data. +[2026-07-24] Invoice delivery outcome stored per message on invoice_deliveries, not in a per-recipient events table: Resend reports delivered/bounced/complained per message (one event carries the whole to[] and names the failing address only in free text), so per-recipient status would require one email per recipient and would break CC. The three columns are the only mutation allowed on a sent row; the WORM trigger proves nothing else changed by subtracting them from the row image, and the provider reason text is treated as recipient PII (masked on read, cleared by the daily redaction job). +[2026-07-24] next-intl timeZone pinned to Europe/Stockholm globally (i18n/request.ts + explicit prop on NextIntlClientProvider) rather than per call site: unset, formatting falls back to the runtime zone, which is UTC on Vercel and the visitor's zone in the browser, so timestamps both lied and disagreed across hydration. +[2026-07-24] Stripe integration is feed-only (Emil's product call): "Synka nu" and the crons import balance transactions to the inbox, nothing auto-books. The event/settlement sync (lib/sync.ts, lib/payouts.ts + tests) stays in the repo dormant: not deleted, not wired to any cron or route; the 15-min sync cron was removed from vercel.json. Payment links on invoice send stay (payments arrive as feed rows, matched manually). transaction_sync_enabled defaults true at OAuth activation. Also parked leg 1 on Arcim's prod connection (last_event_created_at=2100-01-01) so the still-deployed old cron cannot auto-book payouts before this change ships; the field is dead code after deploy. Side effect: the contested reverse-charge-vs-exempt VAT question on Stripe fees no longer has an automated code path deciding it; the inbox flow (template: momsfri) owns fee booking. +[2026-07-25] /api/v1 articles list now returns `currency`: the dashboard, importer, export and MCP article surfaces all learned about a non-SEK article price (#1166, #1183, #1184), but the v1 projection still omitted it, so an API or agent caller read a EUR price with nothing marking it as EUR and would copy the number straight onto a SEK invoice line. Additive field plus a pitfall on the endpoint; no FX conversion is implied. [2026-07-25] Removed invented 6-month minimum for first räkenskapsår: BFL 3 kap 3 § sets no floor (Bolagsverket: "hur kort som helst", max 18 months); the check only existed for isFirstPeriod, exactly the case the law exempts, and blocked a customer shortening an autumn-registered first year to Dec 31. [2026-07-25] Article EUR-price support bug: root cause was the edit dialog omitting currency from initialData (form defaulted SEK and PATCHed it back) plus kr-hardcoded formatCurrency calls; export gets a Valuta column + suffix-free decimalColumn instead of extending CURRENCY_FORMAT, importer Valuta detection deferred as follow-up to keep the diff scoped. [2026-07-25] Reinstated article deactivation as an explicit PATCH active-toggle button on the detail page (support: odinaero.se) instead of reverting DELETE to soft-delete: 8a9a930f intentionally made DELETE hard-delete for unused articles, but that left invoice-referenced articles (ARTICLE_IN_USE) with no retire path; the old deactivate i18n keys were still in messages/ and are reused. @@ -376,3 +381,6 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-07-25] Editing a draft ROT/RUT invoice keeps the stored encrypted personnummer when the field is left empty and deduction lines remain (#1186): the plaintext is not client-rehydratable by design, so empty-means-keep is the only edit semantics that neither blocks the edit nor wipes the ciphertext; typed value replaces, removing all deduction lines clears. [2026-07-25] Article delete was broken globally by a phantom invoice_items.company_id filter (42703 -> ARTICLE_DELETE_FAILED) that mocked route tests cannot catch; fixed in #1188 with a source-pin test. Lesson: supabase-mock tests validate flow, never schema: any new filtered column needs a schema-level check or pg-real coverage. [2026-07-25] Popup-after-await fix uses a pre-opened tab (AGIPanel pattern) via lib/browser/deferred-tab, not an anchor-download fallback: pre-opening about:blank keeps the user gesture and works for blob and signed URLs alike; the helper severs window.opener, except the Arcim OAuth popup which keeps it for postMessage. +[2026-07-25] Declined the review suggestion to 200-ack the Resend delivery webhook when RESEND_DELIVERY_WEBHOOK_SECRET is unset; kept 503. The endpoint is only ever called because an operator pointed Resend at it, so a missing secret at that moment is a live misconfiguration: Svix retry then endpoint-disable is a visible signal, whereas a silent 200 loses every delivery outcome with only a log line. The "optional" wording in docs/WHITELABEL.md describes not wiring the webhook at all, not wiring it half way. +[2026-07-25] Reverted the settings panel-sheet redesign on bug/resend-and-invoices back to main: Emil prefers the settings UI as it stands on main. The routed sheet, the sheet/ primitives (SettingsMasterDetail, SettingsAccordion, SettingsFieldRow), the *Subsections.tsx decompositions, the cold-load sheet and the settings_sheet i18n namespace were removed; every app/(dashboard)/settings/* page, components/settings/** file and MainContainer scroll exception now matches origin/main byte for byte. Unrelated branch work (invoice delivery outcomes, Stripe feed-only, article currency/deactivation, PDF logo) is untouched. +[2026-07-25] Settings UI on bug/resend-and-invoices now comes from feat/settings-fonster-redesign (dbae8792, Jakob) instead of the panel-sheet work reverted earlier the same day: Emil chose the Fonster concept (flat hairline rows, help behind "?", sticky dirty-only save bar, 920x680 modal, switches instead of checkboxes). Applied as a patch rather than a merge because the redesign branch forks from b5e3c476 and merging would have dragged that older main in; every file applied cleanly since no settings file changed on main since that fork point. The 10 settings_payments keys the redesign still carries (needs_review_*, reason_*, sync_done_description/transactions) were deliberately NOT restored: the Stripe feed-only commit on this branch deleted both them and their call sites. diff --git a/app/(dashboard)/articles/[id]/page.tsx b/app/(dashboard)/articles/[id]/page.tsx index b52d91f0..7642f17c 100644 --- a/app/(dashboard)/articles/[id]/page.tsx +++ b/app/(dashboard)/articles/[id]/page.tsx @@ -57,8 +57,8 @@ export default function ArticleDetailPage({ const [isLoading, setIsLoading] = useState(true) const [isEditOpen, setIsEditOpen] = useState(false) const [isUpdating, setIsUpdating] = useState(false) - const [isDeleting, setIsDeleting] = useState(false) const [isTogglingActive, setIsTogglingActive] = useState(false) + const [isDeleting, setIsDeleting] = useState(false) const { dialogProps: confirmDialogProps, confirm: confirmAction } = useDestructiveConfirm() useEffect(() => { @@ -131,17 +131,21 @@ export default function ArticleDetailPage({ } } - // Soft retire/restore: the only path for articles already used on invoices, - // where hard delete is refused (ARTICLE_IN_USE) to keep invoice history. + // Soft deactivation is the answer for an article that has already been used + // on an invoice: the delete path refuses those (ARTICLE_IN_USE), while + // active=false hides it from the invoice picker, the export and the MCP + // listing without touching invoice history. Reactivation is not destructive, + // so only the deactivate direction confirms. async function handleToggleActive() { if (!article) return - const deactivating = article.active - if (deactivating) { + const nextActive = !article.active + + if (!nextActive) { const ok = await confirmAction({ title: t('deactivate_confirm_title', { name: article.name }), description: t('deactivate_confirm_description'), confirmLabel: t('deactivate_confirm_label'), - variant: 'destructive', + variant: 'warning', }) if (!ok) return } @@ -151,18 +155,19 @@ export default function ArticleDetailPage({ const response = await fetch(`/api/articles/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ active: !article.active }), + body: JSON.stringify({ active: nextActive }), }) - await throwOnStructuredError(response) + const { data } = (await throwOnStructuredError(response)) as { data: Article } + + setArticle(data) toast({ - title: deactivating ? t('deactivated_title') : t('activated_title'), + title: nextActive ? t('activated_title') : t('deactivated_title'), description: article.name, }) - fetchArticle() } catch (err) { const body = (err as { body?: unknown }).body toast({ - title: deactivating ? t('deactivate_failed_title') : t('activate_failed_title'), + title: nextActive ? t('activate_failed_title') : t('deactivate_failed_title'), description: getErrorMessage(body ?? err, { context: 'article', locale: errorLocale }), variant: 'destructive', }) @@ -264,6 +269,7 @@ export default function ArticleDetailPage({ variant="outline" size="sm" onClick={handleToggleActive} + className="min-h-10" disabled={isTogglingActive || !canWrite} title={!canWrite ? t('viewer_disabled_tooltip') : undefined} > diff --git a/app/api/articles/__tests__/id.test.ts b/app/api/articles/__tests__/id.test.ts index dcda4ab3..6c445d7f 100644 --- a/app/api/articles/__tests__/id.test.ts +++ b/app/api/articles/__tests__/id.test.ts @@ -63,6 +63,27 @@ describe('GET/PATCH/DELETE /api/articles/[id]', () => { expect(body.data.price_excl_vat).toBe(1500) }) + // The article detail page's Inaktivera/Aktivera button sends nothing but the + // flag, so an active-only body must be a valid sparse update on its own. + it('PATCH toggles active on its own without any other field', async () => { + enqueue({ data: { id: 'a1', name: 'Konsulttimme', active: false } }) + + const request = createMockRequest('/api/articles/a1', { + method: 'PATCH', + body: { active: false }, + }) + + const response = await PATCH(request, createMockRouteParams({ id: 'a1' })) + const { status, body } = await parseJsonResponse<{ data: { active: boolean } }>(response) + + expect(status).toBe(200) + expect(body.data.active).toBe(false) + // Only the articles update: no revenue-account lookup is triggered by a + // body that carries nothing but the flag. + expect(supabase.from).toHaveBeenCalledTimes(1) + expect(supabase.from).toHaveBeenCalledWith('articles') + }) + it('PATCH answers ACCOUNTS_NOT_IN_CHART for a BAS class-3 account missing from the chart', async () => { // chart_of_accounts lookup: no row, but 3999 is a known BAS class-3 // account → activatable via the activate-and-retry dialog flow. diff --git a/app/api/extensions/stripe/callback/__tests__/route.test.ts b/app/api/extensions/stripe/callback/__tests__/route.test.ts new file mode 100644 index 00000000..871ba0cb --- /dev/null +++ b/app/api/extensions/stripe/callback/__tests__/route.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { eventBus } from '@/lib/events/bus' + +// Mock dependencies: factory must not reference outer variables +const mockExchangeCodeForAccount = vi.fn() +const mockFetchAccountDisplayName = vi.fn() +vi.mock('@/extensions/general/stripe/lib/connect', () => ({ + exchangeCodeForAccount: (...args: unknown[]) => mockExchangeCodeForAccount(...args), + fetchAccountDisplayName: (...args: unknown[]) => mockFetchAccountDisplayName(...args), +})) + +const { mockFrom } = vi.hoisted(() => ({ mockFrom: vi.fn() })) + +vi.mock('@/lib/supabase/server', () => ({ + createServiceClient: vi.fn().mockResolvedValue({ from: mockFrom }), +})) + +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) + +vi.stubEnv('NEXT_PUBLIC_APP_URL', 'http://localhost:3000') + +import { GET } from '../route' + +const CONNECTION_ID = 'connection-1' +const OAUTH_STATE = 'state-token-1' + +function makeRequest(params: Record) { + const url = new URL('http://localhost:3000/api/extensions/stripe/callback') + for (const [k, v] of Object.entries(params)) { + url.searchParams.set(k, v) + } + return new Request(url.toString()) +} + +function mockChain(result: { data?: unknown; error?: unknown }) { + const chain: Record = {} + for (const m of ['select', 'eq', 'update', 'insert']) { + chain[m] = vi.fn().mockReturnValue(chain) + } + chain.single = vi + .fn() + .mockResolvedValue({ data: result.data ?? null, error: result.error ?? null }) + // For chains ending without .single() (the insert and the error-path updates) + chain.then = (resolve: (v: unknown) => void) => + resolve({ data: result.data ?? null, error: result.error ?? null }) + return chain +} + +describe('GET /api/extensions/stripe/callback', () => { + beforeEach(() => { + vi.clearAllMocks() + eventBus.clear() + mockExchangeCodeForAccount.mockResolvedValue({ + stripeAccountId: 'acct_123', + livemode: false, + }) + mockFetchAccountDisplayName.mockResolvedValue('Test Shop') + }) + + it('activates the connection and turns the transaction feed on by default', async () => { + const findChain = mockChain({ + data: { id: CONNECTION_ID, user_id: 'user-1', company_id: 'company-1' }, + }) + const replayChain = mockChain({ error: null }) + const activateChain = mockChain({ + data: { + id: CONNECTION_ID, + company_id: 'company-1', + user_id: 'user-1', + stripe_account_id: 'acct_123', + livemode: false, + }, + }) + mockFrom + .mockReturnValueOnce(findChain) + .mockReturnValueOnce(replayChain) + .mockReturnValueOnce(activateChain) + + const response = await GET(makeRequest({ code: 'ac_123', state: OAUTH_STATE })) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe( + 'http://localhost:3000/import?mode=stripe&stripe_connected=true', + ) + + // Feed-only product: a completed OAuth must leave the nightly sync armed, + // otherwise a connected account silently ingests nothing. + const activatePayload = (activateChain.update as ReturnType).mock.calls[0][0] + expect(activatePayload).toMatchObject({ + stripe_account_id: 'acct_123', + livemode: false, + display_name: 'Test Shop', + status: 'active', + oauth_state: null, + transaction_sync_enabled: true, + }) + }) + + it('redirects with an error and never activates when the state is unknown', async () => { + mockFrom.mockReturnValueOnce(mockChain({ data: null, error: { code: 'PGRST116' } })) + + const response = await GET(makeRequest({ code: 'ac_123', state: 'unknown-state' })) + + expect(response.headers.get('location')).toBe( + 'http://localhost:3000/import?mode=stripe&stripe_error=invalid_state', + ) + expect(mockExchangeCodeForAccount).not.toHaveBeenCalled() + }) + + it('redirects with an error when the authorization code was already used', async () => { + mockFrom + .mockReturnValueOnce( + mockChain({ data: { id: CONNECTION_ID, user_id: 'user-1', company_id: 'company-1' } }), + ) + .mockReturnValueOnce(mockChain({ error: { code: '23505' } })) + + const response = await GET(makeRequest({ code: 'ac_123', state: OAUTH_STATE })) + + expect(response.headers.get('location')).toBe( + 'http://localhost:3000/import?mode=stripe&stripe_error=invalid_state', + ) + expect(mockExchangeCodeForAccount).not.toHaveBeenCalled() + }) + + it('reports the conflict when the Stripe account is already connected', async () => { + mockFrom + .mockReturnValueOnce( + mockChain({ data: { id: CONNECTION_ID, user_id: 'user-1', company_id: 'company-1' } }), + ) + .mockReturnValueOnce(mockChain({ error: null })) + .mockReturnValueOnce(mockChain({ data: null, error: { code: '23505', message: 'dup' } })) + .mockReturnValueOnce(mockChain({ error: null })) + + const response = await GET(makeRequest({ code: 'ac_123', state: OAUTH_STATE })) + + expect(response.headers.get('location')).toBe( + 'http://localhost:3000/import?mode=stripe&stripe_error=account_already_connected', + ) + }) + + it('redirects without touching Stripe when parameters are missing', async () => { + const response = await GET(makeRequest({ state: OAUTH_STATE })) + + expect(response.headers.get('location')).toBe( + 'http://localhost:3000/import?mode=stripe&stripe_error=missing_parameters', + ) + expect(mockFrom).not.toHaveBeenCalled() + }) +}) diff --git a/app/api/extensions/stripe/callback/route.ts b/app/api/extensions/stripe/callback/route.ts index f7fc893a..509e0073 100644 --- a/app/api/extensions/stripe/callback/route.ts +++ b/app/api/extensions/stripe/callback/route.ts @@ -118,6 +118,10 @@ export async function GET(request: Request) { connected_at: new Date().toISOString(), error_message: null, oauth_state: null, // Clear to prevent replay + // Feed-only product: connecting Stripe means fetching its + // transactions, so the nightly feed starts on by default. The panel + // toggle remains as the opt-out. + transaction_sync_enabled: true, }) .eq('id', pendingConnection.id) .select('id, company_id, user_id, stripe_account_id, livemode') diff --git a/app/api/invoices/[id]/deliveries/__tests__/route.test.ts b/app/api/invoices/[id]/deliveries/__tests__/route.test.ts index f003513e..66745306 100644 --- a/app/api/invoices/[id]/deliveries/__tests__/route.test.ts +++ b/app/api/invoices/[id]/deliveries/__tests__/route.test.ts @@ -79,6 +79,9 @@ describe('GET /api/invoices/[id]/deliveries', () => { body_text: 'Hej! Här kommer fakturan.', provider: 'resend', provider_message_id: 'provider-1', + provider_status: 'delivered', + provider_status_at: '2026-07-22T10:30:04.000Z', + provider_status_detail: null, error_code: null, document_attachment_id: 'document-1', attachment_filename: 'faktura-f-1001.pdf', @@ -105,6 +108,9 @@ describe('GET /api/invoices/[id]/deliveries', () => { to_addresses: ['***@example.com'], cc_addresses: ['***@example.com'], provider: 'resend', + provider_status: 'delivered', + provider_status_at: '2026-07-22T10:30:04.000Z', + provider_status_detail: null, error_code: null, document_attachment_id: 'document-1', attachment_filename: 'faktura-f-1001.pdf', @@ -127,4 +133,85 @@ describe('GET /api/invoices/[id]/deliveries', () => { p_invoice_id: INVOICE_ID, }) }) + + it('masks recipient addresses quoted inside the provider reason text', async () => { + enqueue({ data: { id: INVOICE_ID }, error: null }) + enqueue({ + data: [{ + id: 'delivery-2', + channel: 'email', + status: 'sent', + to_addresses: ['customer@example.com'], + cc_addresses: [], + provider: 'resend', + provider_status: 'bounced', + provider_status_at: '2026-07-22T10:31:00.000Z', + provider_status_detail: + '550 5.1.1 : Recipient address rejected Permanent/General', + error_code: null, + document_attachment_id: 'document-1', + attachment_filename: 'faktura-f-1001.pdf', + sent_at: '2026-07-22T10:30:00.000Z', + failed_at: null, + created_at: '2026-07-22T10:29:59.000Z', + }], + error: null, + }) + + const response = await GET( + createMockRequest(`/api/invoices/${INVOICE_ID}/deliveries`), + createMockRouteParams({ id: INVOICE_ID }), + ) + const { body } = await parseJsonResponse<{ data: Array> }>(response) + + expect(body.data[0].provider_status).toBe('bounced') + expect(body.data[0].provider_status_detail).toBe( + '550 5.1.1 <***@example.com>: Recipient address rejected Permanent/General', + ) + }) + + // An ASCII allow-list stops at the first character it cannot spell and leaks + // the head of the address ("anna.bergstr" out of anna.bergström@). Each of + // these forms is a real local part a provider can quote back at us. + it.each([ + ['non-ASCII local part', 'anna.bergström@example.se avvisad', '***@example.se avvisad'], + ['quoted local part', '"anna berg"@example.com bounced', '***@example.com bounced'], + ['apostrophe in local part', "o'brien@example.se hard bounce", '***@example.se hard bounce'], + [ + 'several addresses in one reason', + 'delivered to anna@example.se but not bob@example.com', + 'delivered to ***@example.se but not ***@example.com', + ], + ])('masks the %s in the provider reason text', async (_label, detail, expected) => { + enqueue({ data: { id: INVOICE_ID }, error: null }) + enqueue({ + data: [{ + id: 'delivery-3', + channel: 'email', + status: 'sent', + to_addresses: ['customer@example.com'], + cc_addresses: [], + provider: 'resend', + provider_status: 'bounced', + provider_status_at: '2026-07-22T10:31:00.000Z', + provider_status_detail: detail, + error_code: null, + document_attachment_id: null, + attachment_filename: null, + sent_at: '2026-07-22T10:30:00.000Z', + failed_at: null, + created_at: '2026-07-22T10:29:59.000Z', + }], + error: null, + }) + + const response = await GET( + createMockRequest(`/api/invoices/${INVOICE_ID}/deliveries`), + createMockRouteParams({ id: INVOICE_ID }), + ) + const { body } = await parseJsonResponse<{ data: Array> }>(response) + + expect(body.data[0].provider_status_detail).toBe(expected) + expect(body.data[0].provider_status_detail).not.toContain('anna') + }) }) diff --git a/app/api/invoices/[id]/deliveries/route.ts b/app/api/invoices/[id]/deliveries/route.ts index 62a33edd..2e32b837 100644 --- a/app/api/invoices/[id]/deliveries/route.ts +++ b/app/api/invoices/[id]/deliveries/route.ts @@ -2,7 +2,11 @@ import { NextResponse } from 'next/server' import { z } from 'zod' import { withRouteContext } from '@/lib/api/with-route-context' import { errorResponseFromCode } from '@/lib/errors/get-structured-error' -import type { InvoiceDeliveryChannel, InvoiceDeliveryStatus } from '@/types' +import type { + InvoiceDeliveryChannel, + InvoiceDeliveryProviderStatus, + InvoiceDeliveryStatus, +} from '@/types' interface InvoiceDeliverySummaryRow { id: string @@ -11,6 +15,9 @@ interface InvoiceDeliverySummaryRow { to_addresses: string[] cc_addresses: string[] provider: string | null + provider_status: InvoiceDeliveryProviderStatus | null + provider_status_at: string | null + provider_status_detail: string | null error_code: string | null document_attachment_id: string | null attachment_filename: string | null @@ -35,8 +42,12 @@ interface MaskedInvoiceDeliverySummaryRow * addresses stay server-side. The attachment filename passes through: it is * derived from data the invoice already exposes to every company member. The * database allow-list and masking boundary is defined by - * list_invoice_delivery_summaries in migration 20260723150000; this route + * list_invoice_delivery_summaries in migration 20260724160000; this route * masks returned addresses again as defense in depth. + * + * The provider delivery outcome is message-level, never per recipient: the + * provider reports one result for the whole send, and its reason text can + * quote the failing address, so that text is masked the same way. */ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( 'invoice.deliveries.list', @@ -79,6 +90,9 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( to_addresses: delivery.to_addresses.map(maskRecipientDomain), cc_addresses: delivery.cc_addresses.map(maskRecipientDomain), provider: delivery.provider, + provider_status: delivery.provider_status, + provider_status_at: delivery.provider_status_at, + provider_status_detail: maskAddressesInText(delivery.provider_status_detail), error_code: delivery.error_code, document_attachment_id: delivery.document_attachment_id, attachment_filename: delivery.attachment_filename, @@ -94,6 +108,27 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( }, ) +/** + * Provider reason texts routinely quote the address that failed + * ("550 5.1.1 : user unknown"). Keep the diagnostic value, + * drop the local part, matching how the recipient list itself is masked. + * + * The local part is matched by exclusion, not by an allow-list of ASCII mail + * characters: an allow-list stops at the first character it does not know, so + * it leaks the head of every address it cannot spell. `anna.bergström@` would + * mask only the `m`, and a quoted local part ("anna berg"@example.com) would + * not match at all. Anything up to the delimiters that genuinely cannot sit + * inside an address (whitespace, the angle brackets and punctuation providers + * wrap addresses in) is treated as local part, so over-masking is the failure + * mode rather than a partial disclosure. + */ +const ADDRESS_LOCAL_PART = /"[^"]*"@|[^\s<>()[\],;:"@]+@/gu + +function maskAddressesInText(text: string | null): string | null { + if (!text) return null + return text.replace(ADDRESS_LOCAL_PART, '***@') +} + function maskRecipientDomain(address: string): MaskedRecipientAddress { const separator = address.lastIndexOf('@') if (separator <= 0 || separator === address.length - 1) { diff --git a/app/api/v1/companies/[companyId]/articles/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/articles/__tests__/route.test.ts index d89ef577..1bbc94da 100644 --- a/app/api/v1/companies/[companyId]/articles/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/articles/__tests__/route.test.ts @@ -34,6 +34,7 @@ const SAMPLE_ARTICLE = { type: 'tjanst', unit: 'tim', price_excl_vat: 850, + currency: 'SEK', vat_rate: 25, revenue_account: null, cost_price: null, @@ -111,6 +112,26 @@ describe('GET /api/v1/companies/:companyId/articles', () => { expect(client.eqCalls).toContainEqual(['articles', 'active', true]) }) + it('exposes the article currency so a caller can tell a non-SEK price apart', async () => { + // price_excl_vat alone is ambiguous: without currency an agent copies a EUR + // price onto a SEK invoice line with no FX conversion. + const client = makeSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + articles: { data: [{ ...SAMPLE_ARTICLE, price_excl_vat: 95, currency: 'EUR' }], error: null }, + }) + mockServiceClient.mockReturnValue(client) + + const res = await listArticles( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/articles`), + routeParams, + ) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.articles[0].currency).toBe('EUR') + expect(body.data.articles[0].price_excl_vat).toBe(95) + }) + it('includes inactive articles with ?include_inactive=true', async () => { const client = makeSupabase({ company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, diff --git a/app/api/v1/companies/[companyId]/articles/route.ts b/app/api/v1/companies/[companyId]/articles/route.ts index 703ae137..5e34aa12 100644 --- a/app/api/v1/companies/[companyId]/articles/route.ts +++ b/app/api/v1/companies/[companyId]/articles/route.ts @@ -23,6 +23,7 @@ const ArticleShape = z.object({ type: z.enum(['vara', 'tjanst']), unit: z.string(), price_excl_vat: z.number(), + currency: z.string(), vat_rate: z.number(), revenue_account: z.string().nullable(), cost_price: z.number().nullable(), @@ -36,7 +37,7 @@ const ArticleShape = z.object({ // Explicit projection: excludes user_id, company_id (internal scoping). const ARTICLE_COLUMNS = - 'id, article_number, name, name_en, type, unit, price_excl_vat, vat_rate, revenue_account, cost_price, ean, housework_type, notes, active, created_at, updated_at' + 'id, article_number, name, name_en, type, unit, price_excl_vat, currency, vat_rate, revenue_account, cost_price, ean, housework_type, notes, active, created_at, updated_at' registerEndpoint({ operation: 'articles.list', @@ -52,6 +53,7 @@ registerEndpoint({ pitfalls: [ 'Linking article_id does NOT auto-fill the invoice line: send description, unit_price, vat_rate etc. explicitly on the item (copy them from this response).', 'price_excl_vat always excludes VAT.', + 'price_excl_vat is denominated in the article\'s own currency, which is NOT always SEK. Check currency before copying the price onto an invoice line: the invoice carries a single currency for all its lines and there is no FX conversion here.', 'housework_type is an arbetstypskod hint (e.g. BYGG, STAD); the invoice line still needs deduction_type + labor_hours + work_type set explicitly for ROT/RUT.', 'Inactive articles (active=false) are hidden by default but remain linkable for historical reads.', ], @@ -67,6 +69,7 @@ registerEndpoint({ type: 'tjanst', unit: 'tim', price_excl_vat: 850, + currency: 'SEK', vat_rate: 25, revenue_account: null, cost_price: null, diff --git a/app/layout.tsx b/app/layout.tsx index 8b0166f0..8d491376 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -14,6 +14,7 @@ import { RecaptHideWidget } from "@/components/RecaptHideWidget"; import { ScrollbarReveal } from "@/components/ScrollbarReveal"; import { ensureInitialized } from "@/lib/init"; import { getBranding } from "@/lib/branding/service"; +import { APP_TIME_ZONE } from "@/i18n/config"; import "./globals.css"; // Load extensions before metadata/viewport functions read the branding service. @@ -81,7 +82,9 @@ export default async function RootLayout({ - + {/* timeZone is passed explicitly: client components must format in the + same zone the server rendered with, or timestamps shift on hydration. */} + -
- -

Bygg din bokföringsassistent

diff --git a/components/invoices/InvoiceDeliveryHistory.tsx b/components/invoices/InvoiceDeliveryHistory.tsx index dac9caa2..bdd4969e 100644 --- a/components/invoices/InvoiceDeliveryHistory.tsx +++ b/components/invoices/InvoiceDeliveryHistory.tsx @@ -6,7 +6,7 @@ import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Separator } from '@/components/ui/separator' -import type { InvoiceDelivery } from '@/types' +import type { InvoiceDelivery, InvoiceDeliveryProviderStatus } from '@/types' export type InvoiceDeliveryView = Pick< InvoiceDelivery, @@ -15,6 +15,9 @@ export type InvoiceDeliveryView = Pick< | 'to_addresses' | 'cc_addresses' | 'provider' + | 'provider_status' + | 'provider_status_at' + | 'provider_status_detail' | 'error_code' | 'document_attachment_id' | 'attachment_filename' @@ -30,12 +33,37 @@ interface InvoiceDeliveryHistoryProps { showLegacyEmptyState: boolean } -const statusVariant = { +/** + * What the row actually says happened. The send status alone stops at + * "handed to the provider", which is why an accepted-but-bounced invoice used + * to read as a plain success. When the provider has reported back, its + * outcome is what the row shows. + */ +type DeliveryOutcome = + | 'pending' + | 'sent' + | 'failed' + | 'marked_sent' + | InvoiceDeliveryProviderStatus + +function outcomeOf(delivery: InvoiceDeliveryView): DeliveryOutcome { + if (delivery.status === 'sent' && delivery.provider_status) return delivery.provider_status + return delivery.status +} + +// Green is reserved for a confirmed arrival. "Skickad" without a delivery +// report is a neutral, honest in-between state. +const outcomeVariant: Record = { pending: 'secondary', - sent: 'success', + sent: 'secondary', + delivered: 'success', + delayed: 'warning', + complained: 'warning', + bounced: 'destructive', failed: 'destructive', + suppressed: 'destructive', marked_sent: 'outline', -} as const +} export function InvoiceDeliveryHistory({ deliveries, @@ -76,6 +104,10 @@ export function InvoiceDeliveryHistory({ {deliveries.map((delivery) => { const occurredAt = delivery.sent_at || delivery.failed_at || delivery.created_at const isManual = delivery.channel === 'manual' + const outcome = outcomeOf(delivery) + const isEmailSend = !isManual && delivery.status === 'sent' + const recipientCount = + delivery.to_addresses.length + delivery.cc_addresses.length return (
@@ -91,8 +123,8 @@ export function InvoiceDeliveryHistory({ {formatTimestamp(occurredAt)} - - {t(`delivery_status_${delivery.status}`)} + + {t(`delivery_status_${outcome}`)} @@ -115,6 +147,35 @@ export function InvoiceDeliveryHistory({ )} + {isEmailSend && ( +
+
+ + {t('delivery_provider_status_label')} + + {t(`delivery_status_${outcome}`)} + {delivery.provider_status_at && ( + + {formatTimestamp(delivery.provider_status_at)} + + )} +
+

+ {t(`delivery_status_explanation_${outcome}`)} +

+ {delivery.provider_status_detail && ( +

+ {t('delivery_provider_reason_label')}: {delivery.provider_status_detail} +

+ )} + {recipientCount > 1 && delivery.provider_status && ( +

+ {t('delivery_status_whole_send_note')} +

+ )} +
+ )} + {delivery.document_attachment_id && (
diff --git a/components/settings/AccountDangerZone.tsx b/components/settings/AccountDangerZone.tsx index 02bd9a43..3fcd2253 100644 --- a/components/settings/AccountDangerZone.tsx +++ b/components/settings/AccountDangerZone.tsx @@ -20,6 +20,12 @@ import { RetentionNotice } from '@/components/ui/retention-notice' import { ExternalLink, Loader2 } from 'lucide-react' import { SupportLink } from '@/components/ui/support-link' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' +import { + SettingsDangerZone, + SettingsRow, + SettingsRowEnd, + SettingsRowNote, +} from '@/components/settings/SettingsRows' interface Blocker { id: string @@ -28,6 +34,7 @@ interface Blocker { export function AccountDangerZone() { const t = useTranslations('settings_account_danger') + const tRetention = useTranslations('retention_notice') const router = useRouter() const [email, setEmail] = useState(null) const [blockers, setBlockers] = useState([]) @@ -103,61 +110,62 @@ export function AccountDangerZone() { return ( <> -
-

- {t('heading')} -

+ + {/* Owned companies block deletion: functional state, kept visible as + rows (only the first row carries the label and the why-help). */} + {hasBlockers && + blockers.map((b, i) => ( + + {b.name} + + + + + ))} - {hasBlockers && ( -
-

{t('blockers_title')}

-

- {t('blockers_description')} -

-
    - {blockers.map((b) => ( -
  • - {b.name} - -
  • - ))} -
-
- )} - - - -

- {t('support_question')}{' '} - -

+ } + > + {tRetention('account_title')} + + + + + {error && !showDialog && ( -

{error}

+

{error}

)} -
- - -
-
+

+ + {t('support_question')}{' '} + + +

+ -

- Redovisningsregelverk -

-
- - -

- K2 är standard för mindre bolag och innebär förenklade regler. K3 krävs när - bolaget når två av tre tröskelvärden (nettoomsättning > 80 MSEK, tillgångar - > 40 MSEK, eller fler än 50 anställda). K3 ställer högre krav: kassaflödesanalys, - komponentavskrivning på materiella anläggningstillgångar och redovisning av - uppskjuten skatt på obeskattade reserver (79,4 % eget kapital / 20,6 % skuld). -

-
+ + + + {saving && Sparar…} + - + ) } diff --git a/components/settings/AgentMemoryPanel.tsx b/components/settings/AgentMemoryPanel.tsx index dba4b522..51d050c4 100644 --- a/components/settings/AgentMemoryPanel.tsx +++ b/components/settings/AgentMemoryPanel.tsx @@ -2,16 +2,24 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { Brain, Loader2, Pin, PinOff, Pencil, Plus, RotateCcw, Trash2, X } from 'lucide-react' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' -import { Textarea } from '@/components/ui/textarea' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Switch } from '@/components/ui/switch' import { EmptyState } from '@/components/ui/empty-state' import { Skeleton } from '@/components/ui/skeleton' import { useToast } from '@/components/ui/use-toast' +import { + SettingsGroup, + SettingsReveal, + SettingsRow, + SettingsRowEnd, + SettingsRowNote, + SettingsSeg, + SettingsSelect, + SettingsTextarea, +} from '@/components/settings/SettingsRows' import { useCanWrite } from '@/lib/hooks/use-can-write' -import { formatDateLong } from '@/lib/utils' +import { cn, formatDateLong } from '@/lib/utils' type Kind = 'fact' | 'preference' | 'pattern' | 'correction' type Source = 'composer' | 'user_taught' | 'agent_learned' | 'derived' @@ -53,7 +61,7 @@ const KIND_FILTER: { value: 'all' | Kind; label: string }[] = [ ] // The API returns errors either as a plain string (legacy/validation) or as -// the canonical { code, message } envelope — extract something renderable. +// the canonical { code, message } envelope; extract something renderable. function apiErrorText(error: unknown): string | undefined { if (typeof error === 'string') return error if (error && typeof error === 'object' && 'message' in error) { @@ -159,19 +167,31 @@ export function AgentMemoryPanel() { setEditingId(null) } + // The view wrapper in AssistantSettingsContent already provides the gap + // under the segmented control, so the group starts flush (pt-0). return ( - - -
- Vad min assistent kommer ihåg - - Bokföringsassistenten använder dessa anteckningar för att ge dig rätt råd. Fäst det som - alltid ska vara med, redigera fel, eller dölj det som inte längre stämmer. - -
+ + Bokföringsassistenten använder dessa anteckningar för att ge dig rätt råd. Fäst det som + alltid ska vara med, redigera fel, eller dölj det som inte längre stämmer. Upp till 30 + minnen ingår i samtal per tur. + + } + className="pt-0 first:pt-0" + > + {/* Toolbar row: kind filter + the add-memory entry point. */} +
+ {canWrite && ( )} - +
- - {showAdd && canWrite && ( -
-