Bug/resend and invoices (#1192)

* fix(invoices): anchor the PDF logo to the top-left of its header cell

The logo box is always the full 240x80pt reserved area (any larger logo is
clamped to exactly that), so objectFit: 'contain' placed the image inside it
with the default 50% 50% centering. A wide banner logo fills the width and
lands on the left margin, but a near-square logo scaled down to the 80pt
height cap is only ~117pt wide and got pushed ~60pt in from the margin, which
reads as a misaligned logo and forced companies to reshape their artwork.

Anchor the image top-left so every aspect ratio starts at the margin.

Covered by a test that renders the real PDF and reads the image placement
matrix out of the content stream, for both a wide and a near-square logo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(invoices): show the real delivery outcome in the send history

"Skickad" only meant the email provider accepted the message, so a bounced
invoice looked identical to one that arrived. Resend reports the outcome
asynchronously; that report now lands on the delivery row and drives the
history: green is reserved for a confirmed delivery, bounce/blocked reads
red, delayed and spam-marked read amber, and an accepted-but-unconfirmed
send is neutral instead of falsely green.

The report arrives on a signed webhook and may only touch the three new
provider status columns of an already sent, unredacted row: the WORM trigger
proves nothing else changed, and a lower ranked or older report can never
downgrade an observed failure. The provider reason text can quote the failing
address, so it is masked on read and cleared by the daily PII redaction job.

Timestamps also formatted in Europe/Stockholm instead of falling back to the
runtime zone, which rendered a 14:05 send as 12:05 on Vercel.

Delivery reports are per message, never per recipient: Resend sends one event
for the whole message, so splitting a send per recipient would be the only way
to get finer granularity, at the cost of CC.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(stripe): make the integration feed-only

Stripe sync now only imports balance transactions into the transactions
inbox, like any bank feed; nothing auto-books. The event/settlement sync
(lib/sync.ts, lib/payouts.ts) stays in the repo but is no longer wired to
any route or cron: the 15-min sync cron is removed from vercel.json.
Payment links on invoice send are unchanged; their payments arrive as
feed rows and are matched manually.

- /sync runs only syncStripeBalanceTransactions; response is { success,
  transactions }
- connecting via OAuth enables the nightly feed by default (toggle stays
  as opt-out)
- panel: needs-review section and plumbing removed, copy rewritten to
  transactions-first (sv + en), toast reports fetched/imported/linked
  and calls out an empty result instead of silent all-zeros

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api): return the article currency from the v1 article list

The dashboard, importer, export and MCP article surfaces all learned to
carry a non-SEK article price (#1166, #1183, #1184), but the v1
projection still omitted currency. An API or agent caller therefore read
price_excl_vat with nothing marking it as EUR and would copy the number
straight onto a SEK invoice line, at a nine-to-one error.

Adds currency to the projection, the response shape and the example, plus
a pitfall stating the price is not always SEK and that this endpoint does
no FX conversion.

Additive field only; no migration (articles.currency already exists).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(settings): replace the settings modal with a routed panel sheet

Settings now renders as a sheet that fills the main panel, sliding up over the
page the user came from and back down on close, with the sidebar and frame left
visible and usable. Behind it sits one shared master-detail surface: underline
search across every section and subsection, the grouped section rail, and the
active section as a direct-editing accordion. All 11 sections are decomposed
into subsections, and the legacy *SettingsContent components compose the same
pieces so the stacked and accordion layouts cannot drift.

The sheet is the only presentation, on every entry path. The intercepting route
handles in-app navigation and closes by popping the history entry, landing back
on the page underneath. @settingsModal/default.tsx handles cold loads (refresh,
deep link, new tab), where interception never fires; nothing is mounted
underneath there, so it closes to the dashboard. Both branch on one shared
predicate, isSheetSection, together with the settings layout, which must render
nothing for those sections or the surface would stack twice behind the sheet
and run every section's fetches twice.

Closing is deliberate rather than incidental: the X, Esc, or navigating away.
The dialog is non-modal so the sidebar's account popover and company switcher
keep working with settings up, and an outside click no longer dismisses it.
Sections land fully collapsed, and the scroll position of the page behind
survives opening and closing the sheet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: enhance article management and settings UI

- Add PATCH test for toggling article active state without other fields.
- Remove unused MessageCircle icon from DashboardContent.
- Refactor AccountingFrameworkForm to use SettingsFieldRow for better help text display.
- Update CompanyInfoForm, DimensionsToggle, and various settings forms to replace description with help text.
- Remove redundant headings and intros in several settings components to streamline UI.
- Improve help text for various settings in English and Swedish translations.
- Update structured error messages for better clarity on article deletion.

* refactor(ArticleDetailPage): remove unused imports and duplicate state variable

* fix(settings): own deep-linked settings routes by route list, not nav visibility

Review fixes from the settings panel sheet work:
* isSheetSection reads the full settings route list so a hidden-but-deep-linked
  section (assistant before BankID, banking in sandbox, api without MCP) is
  claimed by the sheet instead of rendering the legacy shell around an empty panel
* keep 503 on the Resend delivery webhook when the signing secret is unset, with
  a test pinning the behaviour
* stripe callback route test coverage

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: update salary, tax, and templates settings components

- Refactored SalarySettingsContent to use a form wrapper and improved payment settings UI.
- Enhanced TaxSettingsContent with new signals for EU sales, KU obligations, and ROT/RUT deductions.
- Updated TemplatesSettingsContent to remove legacy comments and improve readability.
- Simplified navigation items by removing unnecessary constants and directly using hrefs.
- Cleaned up translation files by removing deprecated keys and adding new descriptions for clarity.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-07-25 22:56:17 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent f07a34c51b
commit d54b43f80f
89 changed files with 6399 additions and 4682 deletions
+4 -2
View File
@@ -1839,8 +1839,10 @@ const ARTICLE: Record<string, StructuredErrorEntry> = {
},
ARTICLE_IN_USE: {
httpStatus: 409,
message_sv: 'Artikeln har använts på en faktura och kan därför inte tas bort.',
message_en: 'The article has been used on an invoice and cannot be deleted.',
message_sv:
'Artikeln har använts på en faktura och kan därför inte tas bort. Inaktivera den i stället om du inte vill kunna välja den på nya fakturor.',
message_en:
'The article has been used on an invoice and cannot be deleted. Deactivate it instead if you no longer want it selectable on new invoices.',
},
ARTICLE_REVENUE_ACCOUNT_INVALID: {
httpStatus: 400,
@@ -114,6 +114,30 @@ async function insertPendingEmailDelivery(params: {
return deliveryId
}
/**
* A delivery that has already been handed to the provider: this is the only
* state a provider delivery report can attach to, and the provider message id
* is the key the report is matched on.
*/
async function insertSentEmailDelivery(params: {
userId: string
companyId: string
invoiceId: string
documentId: string
retentionExpiresAt?: string
}): Promise<{ deliveryId: string; providerMessageId: string }> {
const deliveryId = await insertPendingEmailDelivery(params)
const providerMessageId = `provider-${randomUUID()}`
await getPool().query(
`UPDATE public.invoice_deliveries
SET status = 'sent', provider = 'resend',
provider_message_id = $2, sent_at = now()
WHERE id = $1`,
[deliveryId, providerMessageId],
)
return { deliveryId, providerMessageId }
}
describe('invoice_deliveries.pg: immutable delivery evidence', () => {
it('allows only a pending to terminal transition and then locks the row', async () => {
const { userId, companyId } = await seedCompany()
@@ -727,3 +751,257 @@ describe('invoice_deliveries.pg: immutable delivery evidence', () => {
])
})
})
describe('invoice_deliveries.pg: provider delivery outcome', () => {
it('records the provider outcome on a sent email and keeps the rest locked', async () => {
const { userId, companyId } = await seedCompany()
const invoiceId = await insertInvoice(userId, companyId)
const documentId = await insertDocument(userId, companyId)
const { deliveryId, providerMessageId } = await insertSentEmailDelivery({
userId,
companyId,
invoiceId,
documentId,
})
await withServiceRoleContext(userId, async (client) => {
const applied = await client.query<{ id: string | null }>(
`SELECT public.apply_invoice_delivery_provider_status(
'resend', $1, 'bounced', '2026-07-24T08:00:00Z'::timestamptz, $2
)::text AS id`,
[providerMessageId, '550 5.1.1 <customer@example.com>: Recipient address rejected'],
)
expect(applied.rows[0].id).toBe(deliveryId)
const row = await client.query<{
provider_status: string
provider_status_detail: string
body_text: string
subject: string
}>(
`SELECT provider_status, provider_status_detail, body_text, subject
FROM public.invoice_deliveries
WHERE id = $1`,
[deliveryId],
)
expect(row.rows[0]).toMatchObject({
provider_status: 'bounced',
provider_status_detail: '550 5.1.1 <customer@example.com>: Recipient address rejected',
body_text: 'Exact plain text',
subject: 'Faktura F-1001',
})
})
})
it('never downgrades an observed failure on a late or repeated report', async () => {
const { userId, companyId } = await seedCompany()
const invoiceId = await insertInvoice(userId, companyId)
const documentId = await insertDocument(userId, companyId)
const { deliveryId, providerMessageId } = await insertSentEmailDelivery({
userId,
companyId,
invoiceId,
documentId,
})
await withServiceRoleContext(userId, async (client) => {
const apply = (status: string, occurredAt: string) =>
client.query(
`SELECT public.apply_invoice_delivery_provider_status(
'resend', $1, $2, $3::timestamptz, NULL
)`,
[providerMessageId, status, occurredAt],
)
const currentStatus = async () => {
const row = await client.query<{ provider_status: string }>(
`SELECT provider_status FROM public.invoice_deliveries WHERE id = $1`,
[deliveryId],
)
return row.rows[0].provider_status
}
await apply('delayed', '2026-07-24T08:00:00Z')
expect(await currentStatus()).toBe('delayed')
await apply('delivered', '2026-07-24T08:01:00Z')
expect(await currentStatus()).toBe('delivered')
await apply('bounced', '2026-07-24T08:02:00Z')
expect(await currentStatus()).toBe('bounced')
// Retried and out-of-order events must not undo the bounce.
await apply('delayed', '2026-07-24T08:03:00Z')
await apply('delivered', '2026-07-24T08:04:00Z')
expect(await currentStatus()).toBe('bounced')
})
})
it('ignores reports for messages that are not tracked invoice deliveries', async () => {
const { userId } = await seedCompany()
await withServiceRoleContext(userId, async (client) => {
const applied = await client.query<{ id: string | null }>(
`SELECT public.apply_invoice_delivery_provider_status(
'resend', 'payslip-message-id', 'delivered', now(), NULL
)::text AS id`,
)
expect(applied.rows[0].id).toBeNull()
})
})
it('rejects an unsupported outcome and non-service callers', async () => {
const { userId, companyId } = await seedCompany()
const memberId = await insertAuthUser()
await insertCompanyMember({ companyId, userId: memberId, role: 'member' })
await expect(
withServiceRoleContext(userId, (client) => client.query(
`SELECT public.apply_invoice_delivery_provider_status(
'resend', 'msg-1', 'opened', now(), NULL
)`,
)),
).rejects.toThrow(/unsupported invoice delivery provider status/i)
await expect(
withUserContext(memberId, (client) => client.query(
`SELECT public.apply_invoice_delivery_provider_status(
'resend', 'msg-1', 'delivered', now(), NULL
)`,
)),
).rejects.toThrow(/permission denied/i)
})
it('blocks a provider outcome from smuggling in other changes', async () => {
const { userId, companyId } = await seedCompany()
const invoiceId = await insertInvoice(userId, companyId)
const documentId = await insertDocument(userId, companyId)
const { deliveryId } = await insertSentEmailDelivery({
userId,
companyId,
invoiceId,
documentId,
})
await expect(
getPool().query(
`UPDATE public.invoice_deliveries
SET provider_status = 'delivered', provider_status_at = now(),
subject = 'tampered'
WHERE id = $1`,
[deliveryId],
),
).rejects.toThrow(/terminal invoice delivery.*immutable/i)
await getPool().query(
`UPDATE public.invoice_deliveries
SET provider_status = 'bounced', provider_status_at = now()
WHERE id = $1`,
[deliveryId],
)
await expect(
getPool().query(
`UPDATE public.invoice_deliveries
SET provider_status = NULL, provider_status_at = NULL,
provider_status_detail = NULL
WHERE id = $1`,
[deliveryId],
),
).rejects.toThrow(/terminal invoice delivery.*immutable/i)
})
it('refuses a provider outcome before the send is terminal', async () => {
const { userId, companyId } = await seedCompany()
const invoiceId = await insertInvoice(userId, companyId)
const documentId = await insertDocument(userId, companyId)
const deliveryId = await insertPendingEmailDelivery({
userId,
companyId,
invoiceId,
documentId,
})
await expect(
getPool().query(
`UPDATE public.invoice_deliveries
SET status = 'sent', sent_at = now(),
provider_status = 'delivered', provider_status_at = now()
WHERE id = $1`,
[deliveryId],
),
).rejects.toThrow(/invoice delivery payload is immutable/i)
})
it('redacts the provider reason text with the rest of the expired PII', async () => {
const { userId, companyId } = await seedCompany()
const invoiceId = await insertInvoice(userId, companyId)
const documentId = await insertDocument(userId, companyId)
const { deliveryId } = await insertSentEmailDelivery({
userId,
companyId,
invoiceId,
documentId,
retentionExpiresAt: '2000-01-01',
})
await getPool().query(
`UPDATE public.invoice_deliveries
SET provider_status = 'bounced', provider_status_at = now(),
provider_status_detail = '550 5.1.1 <customer@example.com> rejected'
WHERE id = $1`,
[deliveryId],
)
await getPool().query(`SELECT public.redact_expired_invoice_delivery_pii()`)
const delivery = await getPool().query<{
provider_status: string
provider_status_detail: string | null
pii_redacted_at: string | null
}>(
`SELECT provider_status, provider_status_detail, pii_redacted_at
FROM public.invoice_deliveries
WHERE id = $1`,
[deliveryId],
)
expect(delivery.rows[0].provider_status).toBe('bounced')
expect(delivery.rows[0].provider_status_detail).toBeNull()
expect(delivery.rows[0].pii_redacted_at).toBeTruthy()
})
it('masks recipient addresses quoted in the reason text of a summary', async () => {
const { userId, companyId } = await seedCompany()
const memberId = await insertAuthUser()
await insertCompanyMember({ companyId, userId: memberId, role: 'member' })
const invoiceId = await insertInvoice(userId, companyId)
const documentId = await insertDocument(userId, companyId)
const { deliveryId } = await insertSentEmailDelivery({
userId,
companyId,
invoiceId,
documentId,
})
await getPool().query(
`UPDATE public.invoice_deliveries
SET provider_status = 'bounced', provider_status_at = now(),
provider_status_detail = '550 5.1.1 <customer@example.com> unknown'
WHERE id = $1`,
[deliveryId],
)
const summary = await withUserContext(memberId, (client) => client.query<{
id: string
provider_status: string
provider_status_detail: string
}>(
`SELECT id::text, provider_status, provider_status_detail
FROM public.list_invoice_delivery_summaries($1, $2)`,
[companyId, invoiceId],
))
expect(summary.rows[0]).toEqual({
id: deliveryId,
provider_status: 'bounced',
provider_status_detail: '550 5.1.1 <***@example.com> unknown',
})
})
})
@@ -0,0 +1,127 @@
/**
* Where the company logo lands in the invoice header.
*
* The logo box is always drawn at the full 240x80pt reserved area (any logo
* larger than that gets clamped to it), so the image itself is positioned
* *inside* that box by objectFit/objectPosition. With the default centering,
* a near-square logo scaled down to fit 80pt of height ends up indented by
* half the leftover width, which reads as "the logo is not aligned with the
* left margin" while a wide banner logo looks fine. The template therefore
* anchors the image top-left, so every aspect ratio starts at the margin.
*
* This test renders the real PDF and reads the image placement matrix out of
* the content stream, so it fails if the anchoring regresses.
*/
import { inflateSync } from 'node:zlib'
import { describe, expect, it } from 'vitest'
import React from 'react'
import { renderToBuffer } from '@react-pdf/renderer'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
import { makeCompanySettings, makeCustomer, makeInvoice } from '@/tests/helpers'
import type { InvoiceItem } from '@/types'
// The page uses a 40pt left margin; a left-anchored logo starts exactly there.
const PAGE_MARGIN_PT = 40
async function makeLogoDataUrl(width: number, height: number): Promise<string> {
const { default: sharp } = await import('sharp')
const png = await sharp({
create: { width, height, channels: 3, background: { r: 20, g: 80, b: 160 } },
})
.png()
.toBuffer()
return `data:image/png;base64,${png.toString('base64')}`
}
/**
* Pull the placement of the first drawn image out of a rendered PDF.
*
* pdfkit emits `<w> 0 0 <-h> <x> <y> cm` followed by `/<label> Do` for every
* image, where x is relative to the enclosing translations. The logo box sits
* at the page margin via a plain `1 0 0 1 <tx> <ty> cm`, so the absolute left
* edge of the drawn image is that translation plus the matrix offset.
*/
function firstImagePlacement(pdf: Buffer): { x: number; width: number } {
const raw = pdf.toString('latin1')
const streams: string[] = []
const re = /stream\r?\n/g
let match: RegExpExecArray | null
while ((match = re.exec(raw)) !== null) {
const start = match.index + match[0].length
const end = raw.indexOf('endstream', start)
if (end === -1) continue
const bytes = Buffer.from(raw.slice(start, end), 'latin1')
try {
streams.push(inflateSync(bytes).toString('latin1'))
} catch {
streams.push(bytes.toString('latin1'))
}
}
const placement = /(-?[\d.]+) 0 0 (-?[\d.]+) (-?[\d.]+) (-?[\d.]+) cm\s*\/\w+ Do/
for (const stream of streams) {
const hit = placement.exec(stream)
if (!hit) continue
let translated = 0
const translate = /1 0 0 1 (-?[\d.]+) (-?[\d.]+) cm/g
let step: RegExpExecArray | null
while ((step = translate.exec(stream)) !== null && step.index < hit.index) {
translated += Number(step[1])
}
return { width: Number(hit[1]), x: translated + Number(hit[3]) }
}
throw new Error('no image draw found in the rendered PDF')
}
async function renderWithLogo(logoWidth: number, logoHeight: number): Promise<Buffer> {
const company = makeCompanySettings({
logo_url: await makeLogoDataUrl(logoWidth, logoHeight),
invoice_show_logo: true,
})
const invoice = makeInvoice({ status: 'sent', invoice_number: '2026-0001' })
const items: InvoiceItem[] = [
{
id: 'item-1',
invoice_id: invoice.id,
sort_order: 0,
line_type: 'product',
description: 'Consulting',
quantity: 1,
unit: 'st',
unit_price: 1000,
line_total: 1000,
vat_rate: 25,
vat_amount: 250,
created_at: '2026-01-15T00:00:00Z',
},
]
return renderToBuffer(
React.createElement(InvoicePDF, {
invoice,
customer: makeCustomer(),
items,
company,
}),
)
}
describe('invoice PDF logo placement', () => {
it('starts a wide banner logo at the left margin', async () => {
const placement = firstImagePlacement(await renderWithLogo(600, 160))
expect(placement.x).toBeCloseTo(PAGE_MARGIN_PT, 1)
}, 30_000)
it('starts a near-square logo at the left margin too', async () => {
// Scaled to the 80pt height cap this logo is only ~117pt wide, so it used
// to be centred in the 240pt box and printed ~60pt in from the margin.
const placement = firstImagePlacement(await renderWithLogo(1500, 1024))
expect(placement.width).toBeLessThan(200)
expect(placement.x).toBeCloseTo(PAGE_MARGIN_PT, 1)
}, 30_000)
})
+10
View File
@@ -754,6 +754,16 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
marginBottom: 6,
alignSelf: 'flex-start',
objectFit: 'contain',
// Any logo bigger than the reserved area is clamped to the
// full 240x80pt box, so the box never hugs the image and the
// image is placed *inside* it. Anchor it top-left: with the
// default centering, a near-square logo scaled down to the
// 80pt height cap is only ~117pt wide and gets pushed ~60pt
// in from the left margin, while a wide banner logo fills the
// width and looks correctly aligned. Left-anchoring makes
// every aspect ratio start at the margin instead, so a
// company doesn't have to reshape its logo to fit the layout.
objectPosition: 'left top',
}}
/>
)}