Fix/mcp and req (#753)

* feat(api): test-mode API keys force dry-run on the v1 REST API

A key created with mode='test' (prefix gnubok_sk_test_) binds to the real
company, but the v1 wrapper forces dry_run on every write so nothing is
persisted or sent. Mutations on endpoints that can't be simulated
(dryRunSupported=false or unregistered) are refused with 403
TEST_KEY_WRITE_BLOCKED — fail-closed. Reads pass through unchanged and every
test-key response carries X-Gnubok-Mode: test. Live keys are unaffected
(mode defaults to 'live').

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

* feat(invoices): company default "Vår referens" + per-line sales-account override

Add company_settings.default_our_reference (settings form, schema, type); the
invoice editor pre-fills our_reference from it on new invoices only, never
overwriting an edited draft. Separately, add an optional per-line
försäljningskonto (class-3) override in the editor — left blank, the engine
still derives the revenue account from the VAT rate, and reverse-charge/export
lines ignore the override.

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

* feat(invoices): render a Swish payment QR on invoice PDFs

Build the Swish "Type C" QR payload offline (no Swish API call) and embed it as
a PNG in the invoice PDF payment box when Swish display is enabled, the invoice
is in SEK, and the amount is positive. Also surface the invoice number in the
payment box. Wired through every PDF render path: send, mark-sent and pdf
routes (both legacy and v1), the recurring-schedule sender, and the staged-send
commit.

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

* feat(bookkeeping): draft exclusion + correction-chain collapse on verifikationslista

Extend list_fiscal_period_entries_with_related with two opt-in params:
p_exclude_draft (keep drafts off the committed list — they get their own
surface) and p_collapse_corrections (render a correction group as the single
live correction, hiding the mechanical storno and the reversed original).
Both default false; nothing is deleted, every voucher keeps its number, and a
"show all" toggle exposes the full chain.

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

* fix(reports): link multi-year SIE periods so resultatrapport shows the prior year

SIE import now sets fiscal_periods.previous_period_id in both directions when
creating a period, so multi-year files chain correctly regardless of #RAR order.
A backfill migration repairs periods imported before this (idempotent; only
touches NULL links on first-of-month periods). generateResultatrapport falls
back to the date-adjacent prior period when the chain is still null, so the
comparison column works for legacy data too.

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

* fix(articles): hide the VAT field for non-momsregistrerade companies

The article form reads company_settings.vat_registered and, when false, hides
the moms field and forces vat_rate to 0 on submit — mirroring the invoice
editor so a non-VAT-registered company never sets a rate it can't charge.

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

* feat(import): allow file-based imports in the sandbox

Bank-file, CSV/Excel and SIE imports run entirely on uploaded data with no
external service, so they're now reachable in the sandbox. Only the API-backed
options that need live third-party credentials (PSD2 bank connection, provider
migration) stay disabled. Updates the sandbox notice copy to match.

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

* feat(bookkeeping): add edit draft functionality for journal entries

* feat(database): add default "Vår referens" column to company_settings for invoicing

* fix(tests): set SHOW_SWISH_ON_INVOICE to false in PDF template mocks

* @
fix(payments): use roundOre for Swish amount formatting

Replace naive Math.round(x*100)/100 with roundOre from @/lib/money to
satisfy the antipattern guard.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-06-18 11:49:33 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 2d6ddeafc5
commit 241959513b
54 changed files with 1634 additions and 120 deletions
@@ -7,6 +7,11 @@ import { eventBus } from '@/lib/events/bus'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { createLogger } from '@/lib/logger'
import { syncInvoiceStatusFromPaymentEntry } from '@/lib/bookkeeping/payment-sync'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { CreateJournalEntrySchema } from '@/lib/api/schemas'
import { updateDraftEntry } from '@/lib/bookkeeping/engine'
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
const logger = createLogger('journal-entries')
@@ -103,3 +108,31 @@ export async function DELETE(
return NextResponse.json({ data })
}
/**
* PATCH — edit a DRAFT verifikat in place (header + lines). Only drafts are
* editable; updateDraftEntry rejects committed entries with a 409, and the DB
* immutability trigger is the backstop. Uses withRouteContext (MFA + write gate)
* — the GET/DELETE above predate that wrapper and are intentionally left as-is.
*/
export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>(
'bookkeeping.journal_entry.update',
async (request, { supabase, companyId, user }, { params }) => {
const { id } = await params
const validation = await validateBody(request, CreateJournalEntrySchema)
if (!validation.success) return validation.response
try {
const entry = await updateDraftEntry(supabase, companyId, user.id, id, validation.data)
return NextResponse.json({ data: entry })
} catch (err) {
const typed = bookkeepingErrorResponse(err)
if (typed) return typed
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to update journal entry' },
{ status: 400 },
)
}
},
{ requireWrite: true },
)
@@ -0,0 +1,100 @@
import { randomUUID } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import { getPool } from '@/tests/pg/setup'
import { seedCompany, insertBalancedLines } from '@/tests/pg/fixtures'
// Covers the p_exclude_draft / p_collapse_corrections params added to
// list_fiscal_period_entries_with_related (migration 20260621130500).
// - exclude_draft: drafts kept off the committed list (own "Utkast" surface).
// - collapse_corrections: a correction group renders as ONE row — the live
// correction; the storno and the reversed original it replaced are hidden.
// total_count must stay in lockstep with the filtered set so pagination holds.
describe('list_fiscal_period_entries_with_related: draft + correction filters', () => {
// Insert a journal_entry directly so we can set the storno/correction link
// columns the fixtures don't expose. Posted/reversed rows get balanced lines
// so any deferred balance check is satisfied.
async function insertEntry(p: {
userId: string
companyId: string
fiscalPeriodId: string
status: 'draft' | 'posted' | 'reversed'
sourceType: string
voucherNumber: number
description: string
reversesId?: string
correctionOfId?: string
withLines?: boolean
}): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.journal_entries
(id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series,
entry_date, description, source_type, status, reverses_id, correction_of_id)
VALUES ($1,$2,$3,$4,$5,'A','2026-06-01',$6,$7,$8,$9,$10)`,
[
id,
p.userId,
p.companyId,
p.fiscalPeriodId,
p.voucherNumber,
p.description,
p.sourceType,
p.status,
p.reversesId ?? null,
p.correctionOfId ?? null,
],
)
if (p.withLines) await insertBalancedLines(id)
return id
}
async function callRpc(
companyId: string,
periodId: string,
opts: { status?: string | null; excludeDraft?: boolean; collapse?: boolean } = {},
) {
const { rows } = await getPool().query<{ entry: { id: string }; total_count: string }>(
`SELECT entry, total_count
FROM list_fiscal_period_entries_with_related(
$1, $2, true, $3, NULL, NULL, 'desc', 100, 0, $4, $5)`,
[companyId, periodId, opts.status ?? null, opts.excludeDraft ?? false, opts.collapse ?? false],
)
return rows
}
it('excludes drafts and collapses a correction group to the live correction', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const posted = await insertEntry({ userId, companyId, fiscalPeriodId, status: 'posted', sourceType: 'manual', voucherNumber: 10, withLines: true, description: 'Plain posted' })
const draft = await insertEntry({ userId, companyId, fiscalPeriodId, status: 'draft', sourceType: 'manual', voucherNumber: 0, description: 'Draft' })
// Correction group: original is reversed; storno reverses it; correction replaces it.
const original = await insertEntry({ userId, companyId, fiscalPeriodId, status: 'reversed', sourceType: 'manual', voucherNumber: 11, withLines: true, description: 'Original' })
const storno = await insertEntry({ userId, companyId, fiscalPeriodId, status: 'posted', sourceType: 'storno', voucherNumber: 12, reversesId: original, withLines: true, description: 'Storno' })
const correction = await insertEntry({ userId, companyId, fiscalPeriodId, status: 'posted', sourceType: 'correction', voucherNumber: 13, correctionOfId: original, withLines: true, description: 'Correction' })
// Default (no filters): every row shows.
const all = await callRpc(companyId, fiscalPeriodId, {})
const allIds = all.map((r) => r.entry.id)
expect(allIds).toEqual(expect.arrayContaining([posted, draft, original, storno, correction]))
expect(Number(all[0]!.total_count)).toBe(5)
// Committed list: drafts, stornos and reversed-corrected originals hidden.
const filtered = await callRpc(companyId, fiscalPeriodId, { excludeDraft: true, collapse: true })
const ids = filtered.map((r) => r.entry.id)
expect(ids).toEqual(expect.arrayContaining([posted, correction]))
expect(ids).not.toContain(draft)
expect(ids).not.toContain(storno)
expect(ids).not.toContain(original)
expect(Number(filtered[0]!.total_count)).toBe(2)
})
it('still returns drafts when status=draft is requested explicitly', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
await insertEntry({ userId, companyId, fiscalPeriodId, status: 'posted', sourceType: 'manual', voucherNumber: 10, withLines: true, description: 'Posted' })
const draft = await insertEntry({ userId, companyId, fiscalPeriodId, status: 'draft', sourceType: 'manual', voucherNumber: 0, description: 'Draft' })
// Drafts mode (status=draft). exclude_draft must NOT cancel the explicit ask.
const rows = await callRpc(companyId, fiscalPeriodId, { status: 'draft', excludeDraft: true })
expect(rows.map((r) => r.entry.id)).toEqual([draft])
})
})
@@ -24,6 +24,11 @@ export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const periodId = searchParams.get('period_id')
const status = searchParams.get('status')
// Drafts get their own surface in the UI; the committed list excludes them.
const excludeDraft = searchParams.get('exclude_draft') === 'true'
// Collapse a correction group to the live correction (hide the storno and the
// reversed original it replaced). The full chain stays reachable.
const collapseCorrections = searchParams.get('collapse_corrections') === 'true'
// Clamp pagination to bound DB work against oversized/pathological inputs
// (compliance A.8.28 / ASVS V1.2.5). The UI page-size selector offers
// 20/50/100/Alla; "Alla" sends a large limit which is capped at MAX_LIMIT.
@@ -81,6 +86,8 @@ export async function GET(request: Request) {
p_sort_date: sortDateParam,
p_limit: limit,
p_offset: offset,
p_exclude_draft: excludeDraft,
p_collapse_corrections: collapseCorrections,
})
if (error) {
@@ -134,6 +141,9 @@ export async function GET(request: Request) {
query = query.eq('status', status)
} else {
query = query.neq('status', 'cancelled')
if (excludeDraft) {
query = query.neq('status', 'draft')
}
}
if (dateFrom) {
@@ -157,6 +167,26 @@ export async function GET(request: Request) {
query = query.ilike('description', `%${escapeLikePattern(search)}%`)
}
// Collapse correction groups (voucher-sort / search path): hide the storno
// and the reversed originals a posted correction replaced, leaving the live
// correction. Pagination/count stay correct because these are query filters.
if (collapseCorrections) {
query = query.neq('source_type', 'storno')
const { data: corrections } = await supabase
.from('journal_entries')
.select('correction_of_id')
.eq('company_id', companyId)
.eq('source_type', 'correction')
.eq('status', 'posted')
.not('correction_of_id', 'is', null)
const correctedOriginalIds = Array.from(
new Set((corrections ?? []).map((r) => r.correction_of_id).filter(Boolean) as string[])
)
if (correctedOriginalIds.length > 0) {
query = query.not('id', 'in', `(${correctedOriginalIds.join(',')})`)
}
}
const { data, error, count } = await query
if (error) {
@@ -40,6 +40,7 @@ vi.mock('@react-pdf/renderer', () => ({
vi.mock('@/lib/invoices/pdf-template', () => ({
InvoicePDF: vi.fn().mockReturnValue('mock-pdf-element'),
brandingFromCompanySettings: vi.fn().mockReturnValue({}),
SHOW_SWISH_ON_INVOICE: false,
}))
import { InvoicePDF } from '@/lib/invoices/pdf-template'
+3 -1
View File
@@ -6,7 +6,7 @@ import { createSchedulesForCustomerInvoice } from '@/lib/bookkeeping/accruals/fr
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
import { ensureInitialized } from '@/lib/init'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
import { prepareInvoicePdfRender } from '@/lib/invoices/pdf-render-helpers'
import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
import { uploadDocument } from '@/lib/core/documents/document-service'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
@@ -170,6 +170,7 @@ export async function POST(
// underlag isn't stamped "UTKAST – inte en giltig faktura".
const renderableInvoice = { ...(invoice as Invoice), status: 'sent' as const }
const { branding } = prepareInvoicePdfRender(settings as CompanySettings)
const swishQrDataUrl = await buildSwishQrDataUrl(settings as CompanySettings, renderableInvoice)
const pdfBuffer = await renderToBuffer(
InvoicePDF({
invoice: renderableInvoice,
@@ -178,6 +179,7 @@ export async function POST(
company: settings as CompanySettings,
originalInvoiceNumber,
branding,
swishQrDataUrl,
})
)
+3 -1
View File
@@ -2,7 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { renderToBuffer } from '@react-pdf/renderer'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
import { prepareInvoicePdfRender } from '@/lib/invoices/pdf-render-helpers'
import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
import { requireCompanyId } from '@/lib/company/context'
import type { Invoice, InvoiceItem, Customer, CompanySettings } from '@/types'
@@ -68,6 +68,7 @@ export async function GET(
try {
// Generate PDF
const { branding } = prepareInvoicePdfRender(company as CompanySettings)
const swishQrDataUrl = await buildSwishQrDataUrl(company as CompanySettings, invoice as Invoice)
const pdfBuffer = await renderToBuffer(
InvoicePDF({
invoice: invoice as Invoice,
@@ -76,6 +77,7 @@ export async function GET(
company: company as CompanySettings,
originalInvoiceNumber,
branding,
swishQrDataUrl,
})
)
@@ -41,6 +41,7 @@ vi.mock('@react-pdf/renderer', () => ({
vi.mock('@/lib/invoices/pdf-template', () => ({
InvoicePDF: vi.fn().mockReturnValue('mock-pdf-element'),
brandingFromCompanySettings: vi.fn().mockReturnValue({}),
SHOW_SWISH_ON_INVOICE: false,
}))
import { InvoicePDF } from '@/lib/invoices/pdf-template'
+3 -1
View File
@@ -3,7 +3,7 @@ import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { renderToBuffer } from '@react-pdf/renderer'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
import { prepareInvoicePdfRender } from '@/lib/invoices/pdf-render-helpers'
import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
import { getEmailService } from '@/lib/email/service'
import {
generateInvoiceEmailHtml,
@@ -133,6 +133,7 @@ export const POST = withRouteContext(
// receives a PDF stamped "UTKAST – inte en giltig faktura".
const renderableInvoice = { ...(invoice as Invoice), status: 'sent' as const }
const { branding } = prepareInvoicePdfRender(company as CompanySettings)
const swishQrDataUrl = await buildSwishQrDataUrl(company as CompanySettings, renderableInvoice)
const pdfBuffer = await renderToBuffer(
InvoicePDF({
invoice: renderableInvoice,
@@ -141,6 +142,7 @@ export const POST = withRouteContext(
company: company as CompanySettings,
originalInvoiceNumber,
branding,
swishQrDataUrl,
}),
)
@@ -189,5 +189,40 @@ describe('POST /api/settings/api-keys', () => {
expect(payload).not.toHaveProperty('sod_acknowledged_at')
expect(payload).not.toHaveProperty('sod_acknowledged_by')
expect(payload.scopes).toEqual(['reports:read'])
// Default mode is live, bound to the active company.
expect(payload.mode).toBe('live')
expect(payload.company_id).toBe('company-1')
})
it('creates a test key bound to the active company with mode=test', async () => {
const { insertSpy } = setupFrom({
count: 0,
insertResult: {
data: {
id: 'ak-3',
key_prefix: 'gnubok_sk_test_abc',
name: 'pilot',
scopes: ['reports:read'],
mode: 'test',
created_at: '2026-06-05T10:00:00Z',
},
},
})
const res = await POST(
createMockRequest('/api/settings/api-keys', {
method: 'POST',
body: { name: 'pilot', scopes: ['reports:read'], mode: 'test' },
}),
)
const { status, body } = await parseJsonResponse<{ data: { key: string } }>(res)
expect(status).toBe(200)
// Real generateApiKey('test') runs — the returned secret carries the infix.
expect(body.data.key).toMatch(/^gnubok_sk_test_/)
const payload = insertSpy.mock.calls[0][0] as Record<string, unknown>
expect(payload.mode).toBe('test')
// Test keys are simulation-only — they bind to the active company (the v1
// wrapper forces dry-run so they never persist).
expect(payload.company_id).toBe('company-1')
})
})
+13 -4
View File
@@ -7,7 +7,7 @@ import {
} from '@/lib/auth/api-keys'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { ApiKeyScope } from '@/lib/auth/api-keys'
import type { ApiKeyMode, ApiKeyScope } from '@/lib/auth/api-keys'
/** GET /api/settings/api-keys — list the company's API keys (key value never returned). */
export const GET = withRouteContext(
@@ -15,9 +15,11 @@ export const GET = withRouteContext(
async (_request, ctx) => {
const { supabase, companyId, log, requestId } = ctx
// Both live and test keys for the active company. (Test keys are bound to the
// active company too — they're simulation-only, so they never write real data.)
const { data, error } = await supabase
.from('api_keys')
.select('id, key_prefix, name, scopes, rate_limit_rpm, last_used_at, revoked_at, created_at')
.select('id, key_prefix, name, scopes, mode, rate_limit_rpm, last_used_at, revoked_at, created_at')
.eq('company_id', companyId)
.order('created_at', { ascending: false })
@@ -44,12 +46,14 @@ export const POST = withRouteContext(
let name = 'Unnamed key'
let scopes: ApiKeyScope[] = DEFAULT_SCOPES
let acknowledgeSod = false
let mode: ApiKeyMode = 'live'
try {
const body = await request.json()
if (body.name && typeof body.name === 'string') {
name = body.name.slice(0, 100)
}
acknowledgeSod = body.acknowledge_sod === true
if (body.mode === 'test') mode = 'test'
const parsed = validateScopes(body.scopes)
if (parsed) {
scopes = parsed
@@ -63,6 +67,10 @@ export const POST = withRouteContext(
// Empty body — use defaults.
}
// Both live and test keys bind to the active company. A test key is
// simulation-only — the v1 wrapper forces dry-run on every write — so it can
// safely point at the real company without ever persisting anything.
// Segregation of duties: warn + require explicit acknowledgement (not block)
// when a single key both stages bookkeeping AND can approve it. Surfacing a
// 409 lets the UI raise an explicit confirm dialog and the agent inform the
@@ -92,7 +100,7 @@ export const POST = withRouteContext(
})
}
const { key, hash, prefix } = generateApiKey()
const { key, hash, prefix } = generateApiKey(mode)
const { data, error } = await supabase
.from('api_keys')
@@ -103,11 +111,12 @@ export const POST = withRouteContext(
key_prefix: prefix,
name,
scopes,
mode,
...(sodAcknowledgedAt
? { sod_acknowledged_at: sodAcknowledgedAt, sod_acknowledged_by: user.id }
: {}),
})
.select('id, key_prefix, name, scopes, created_at')
.select('id, key_prefix, name, scopes, mode, created_at')
.single()
if (error) {
@@ -40,6 +40,7 @@ vi.mock('@react-pdf/renderer', () => ({
vi.mock('@/lib/invoices/pdf-template', () => ({
InvoicePDF: vi.fn().mockReturnValue({}),
brandingFromCompanySettings: vi.fn().mockReturnValue({}),
SHOW_SWISH_ON_INVOICE: false,
}))
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
@@ -20,7 +20,7 @@
import { z } from 'zod'
import { renderToBuffer } from '@react-pdf/renderer'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
import { prepareInvoicePdfRender } from '@/lib/invoices/pdf-render-helpers'
import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
import { registerEndpoint } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
@@ -150,6 +150,7 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }
let pdfBuffer: Buffer
try {
const { branding } = prepareInvoicePdfRender(company as CompanySettings)
const swishQrDataUrl = await buildSwishQrDataUrl(company as CompanySettings, typed as Invoice)
pdfBuffer = await renderToBuffer(
InvoicePDF({
invoice: typed as Invoice,
@@ -158,6 +159,7 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }
company: company as CompanySettings,
originalInvoiceNumber,
branding,
swishQrDataUrl,
}),
)
} catch (err) {
@@ -72,6 +72,7 @@ vi.mock('@/lib/email/invoice-templates', () => ({
vi.mock('@/lib/invoices/pdf-template', () => ({
InvoicePDF: vi.fn().mockReturnValue({}),
brandingFromCompanySettings: vi.fn().mockReturnValue({}),
SHOW_SWISH_ON_INVOICE: false,
}))
// The sandbox guard reads company_settings.is_sandbox at the top of the
@@ -411,6 +412,40 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/send', () => {
expect(finalRenderArgs.invoice.invoice_number).toBe('2026-0043')
})
it('test-mode key forces dry-run: returns a preview, no email, no number burned', async () => {
// A test key has no ?dry_run flag, but the wrapper forces dry-run because
// the key is mode='test'. The send endpoint declares dryRunSupported, so the
// request is allowed and short-circuits to the preview.
mockValidate.mockResolvedValue({
userId: USER_ID,
companyId: COMPANY_ID,
apiKeyId: 'ak_test',
apiKeyName: 'Test key',
scopes: ['invoices:write'],
mode: 'test',
})
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: { data: DRAFT_INVOICE, error: null },
company_settings: { data: COMPANY_SETTINGS, error: null },
}),
)
const res = await sendInvoice(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/send`),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(200)
expect(res.headers.get('X-Gnubok-Mode')).toBe('test')
const body = await res.json()
expect(body.data.dry_run).toBe(true)
expect(body.data.preview.status).toBe('sent')
expect(body.data.preview.would_send_to).toBe('billing@acme.test')
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('rejects keys without invoices:write scope', async () => {
mockValidate.mockResolvedValue({
userId: USER_ID,
@@ -43,7 +43,7 @@ import { registerEndpoint } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
import { prepareInvoicePdfRender } from '@/lib/invoices/pdf-render-helpers'
import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
import { getEmailService } from '@/lib/email/service'
import {
generateInvoiceEmailHtml,
@@ -362,6 +362,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
let pdfBuffer: Buffer
try {
const { branding } = prepareInvoicePdfRender(settings)
const swishQrDataUrl = await buildSwishQrDataUrl(settings, renderableInvoice)
pdfBuffer = await renderToBuffer(
InvoicePDF({
invoice: renderableInvoice,
@@ -370,6 +371,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
company: settings,
originalInvoiceNumber,
branding,
swishQrDataUrl,
}),
)
} catch (err) {