Fix/invoice delivery and payment accounts (#1116)

* fix: reconcile annual reports with final closing entries

* test: cover annual report depreciation and VAT balances

* Merge remote-tracking branch 'origin/main' into fix/usr-fdbck-ch

* fix: show exact invoice delivery details

* fix: use currency account in invoice emails

* fix: address invoice delivery review feedback

* fix: harden invoice delivery and payment accounts

* test: assert RLS-denied zero-row updates

* fix: close remaining invoice compliance gaps

* fix: harden invoice archive authorization

* fix: close invoice delivery review findings

* fix: verify delivery finalization results

* fix: cap combined invoice email recipients

* fix: close final invoice compliance findings

* fix: prevent stale payment account saves

* test: prove invoice delivery isolation

* fix: close invoice privacy review findings

* test: normalize delivery retention dates
This commit is contained in:
Mattsson
2026-07-23 09:54:02 +02:00
committed by GitHub
parent 321e684523
commit 466e55a015
83 changed files with 6619 additions and 671 deletions
+56
View File
@@ -1230,6 +1230,62 @@ describe('UpdateSettingsSchema', () => {
expect(result.success).toBe(true)
})
it('rejects more than 19 fixed invoice copy recipients in total', () => {
const result = UpdateSettingsSchema.safeParse({
invoice_email_cc_addresses: Array.from(
{ length: 10 },
(_, index) => `copy-${index}@example.test`,
),
invoice_email_bcc_addresses: Array.from(
{ length: 10 },
(_, index) => `archive-${index}@example.test`,
),
})
expect(result.success).toBe(false)
})
it('accepts empty strings when clearing nested invoice payment account fields', () => {
const result = UpdateSettingsSchema.safeParse({
invoice_payment_accounts: {
SEK: {
clearing_number: '',
account_number: '',
bankgiro: '',
plusgiro: '',
iban: '',
bic: '',
},
},
})
expect(result.success).toBe(true)
})
it('accepts null when clearing the legacy SEK bank account mirror', () => {
const result = UpdateSettingsSchema.safeParse({
bank_name: null,
clearing_number: null,
account_number: null,
bankgiro: null,
plusgiro: null,
swish: null,
iban: null,
bic: null,
})
expect(result.success).toBe(true)
})
it('accepts and normalizes a non-Swedish IBAN in the legacy SEK mirror', () => {
const result = UpdateSettingsSchema.safeParse({
iban: 'gb29 nwbk 6016 1331 9268 19',
})
expect(result.success).toBe(true)
if (result.success) expect(result.data.iban).toBe('GB29NWBK60161331926819')
})
it('accepts a positive next_arrival_number (supplier-invoice start floor)', () => {
const result = UpdateSettingsSchema.safeParse({ next_arrival_number: 248 })
expect(result.success).toBe(true)
+84 -4
View File
@@ -5,6 +5,7 @@ import { isSaneDateString } from '@/lib/utils'
import { countCalendarMonths } from '@/lib/bookkeeping/accruals/compute'
import { DimensionsBagSchema } from '@/lib/bookkeeping/dimension-resolver'
import { validateEmployeeBankAccount } from '@/lib/salary/payment/bank-account'
import { MAX_INVOICE_EMAIL_COPY_RECIPIENTS } from '@/lib/invoices/email-recipients'
import type { AuditAction } from '@/types'
// ============================================================
@@ -33,6 +34,19 @@ const accountNumber = z.string().regex(/^\d{4}$/, 'Account number must be exactl
/** Non-negative monetary amount (>= 0) */
const nonNegativeAmount = z.number().nonnegative()
const invoiceEmailAddress = z
.string()
.trim()
.email('Ange en giltig e-postadress')
.max(254, 'E-postadressen får vara max 254 tecken')
const invoiceEmailAddressList = z
.array(invoiceEmailAddress)
.max(
MAX_INVOICE_EMAIL_COPY_RECIPIENTS,
`Högst ${MAX_INVOICE_EMAIL_COPY_RECIPIENTS} kopiemottagare är tillåtna`,
)
/** BAS class-3 revenue account: exactly 4 digits starting with 3 (försäljning/intäkt). */
const revenueAccount = z
.string()
@@ -728,6 +742,20 @@ export const MarkInvoiceSentSchema = z.object({
})).min(2).optional(),
})
export const SendInvoiceSchema = MarkInvoiceSentSchema.extend({
additional_cc: invoiceEmailAddressList.optional(),
additional_bcc: invoiceEmailAddressList.optional(),
}).refine(
(data) => (
(data.additional_cc?.length ?? 0) + (data.additional_bcc?.length ?? 0)
<= MAX_INVOICE_EMAIL_COPY_RECIPIENTS
),
{
message: `Högst ${MAX_INVOICE_EMAIL_COPY_RECIPIENTS} extra kopiemottagare är tillåtna totalt`,
path: ['additional_cc'],
},
)
// ============================================================
// Customer schemas
// ============================================================
@@ -1499,6 +1527,43 @@ export const InvoiceEmailTextsSchema = z.object({
en: InvoiceEmailTextsLangSchema.optional(),
})
const InvoiceIbanSchema = z.string()
.transform((value) => value.replace(/\s/g, '').toUpperCase())
.pipe(z.string().regex(/^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/, 'Ogiltigt IBAN'))
.nullable()
.optional()
.or(z.literal(''))
const InvoicePaymentAccountSchema = z.object({
bank_name: z.string().trim().max(100).nullable().optional(),
clearing_number: z.string().regex(/^\d{4,5}$/, 'Clearingnummer måste vara 4-5 siffror').nullable().optional().or(z.literal('')),
account_number: z.string().regex(/^\d{6,12}$/, 'Kontonummer måste vara 6-12 siffror').nullable().optional().or(z.literal('')),
bankgiro: z.string().regex(/^(\d{3,4}-\d{4}|\d{7,8})$/, 'Ogiltigt bankgironummer').nullable().optional().or(z.literal('')),
plusgiro: z.string().regex(/^\d{1,7}-\d$/, 'Ogiltigt plusgironummer').nullable().optional().or(z.literal('')),
swish: z.string().transform(normaliseSwish).pipe(z.string().refine(isValidSwish, 'Ogiltigt Swish-nummer')).nullable().optional(),
iban: InvoiceIbanSchema,
bic: z.string()
.transform((value) => value.replace(/\s/g, '').toUpperCase())
.pipe(z.string().regex(/^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$/, 'Ogiltig BIC/SWIFT'))
.nullable()
.optional()
.or(z.literal('')),
})
const InvoicePaymentAccountsSchema = z
.partialRecord(CurrencySchema, InvoicePaymentAccountSchema)
.superRefine((accounts, ctx) => {
for (const [currency, account] of Object.entries(accounts)) {
if (currency !== 'SEK' && account && !account.iban) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [currency, 'iban'],
message: `IBAN krävs för betalningskonto i ${currency}`,
})
}
}
})
export const UpdateSettingsSchema = z.object({
entity_type: EntityTypeSchema.optional(),
company_name: z.string().optional(),
@@ -1536,9 +1601,9 @@ export const UpdateSettingsSchema = z.object({
preliminary_tax_monthly: z.number().nullable().optional(),
employer_registered: z.boolean().nullable().optional(),
employer_seasonal: z.boolean().optional(),
bank_name: z.string().max(100, 'Banknamn får vara max 100 tecken').optional(),
clearing_number: z.string().regex(/^\d{4,5}$/, 'Clearingnummer måste vara 4-5 siffror').optional().or(z.literal('')),
account_number: z.string().regex(/^\d{6,12}$/, 'Kontonummer måste vara 6-12 siffror').optional().or(z.literal('')),
bank_name: z.string().max(100, 'Banknamn får vara max 100 tecken').nullable().optional(),
clearing_number: z.string().regex(/^\d{4,5}$/, 'Clearingnummer måste vara 4-5 siffror').nullable().optional().or(z.literal('')),
account_number: z.string().regex(/^\d{6,12}$/, 'Kontonummer måste vara 6-12 siffror').nullable().optional().or(z.literal('')),
bankgiro: z.string().regex(/^(\d{3,4}-\d{4}|\d{7,8})$/, 'Ogiltigt bankgironummer (7-8 siffror)').nullable().optional().or(z.literal('')),
plusgiro: z.string().regex(/^\d{1,7}-\d{1}$/, 'Ogiltigt plusgironummer').nullable().optional().or(z.literal('')),
swish: z.string()
@@ -1551,8 +1616,11 @@ export const UpdateSettingsSchema = z.object({
)
.nullable()
.optional(),
iban: z.string().regex(/^SE\d{22}$/, 'Ogiltigt IBAN (SE följt av 22 siffror)').nullable().optional().or(z.literal('')),
// Legacy SEK mirror of invoice_payment_accounts.SEK. Use the same general
// IBAN validation because a SEK-denominated account need not be Swedish.
iban: InvoiceIbanSchema,
bic: z.string().regex(/^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$/, 'Ogiltig BIC/SWIFT (8 eller 11 tecken)').nullable().optional().or(z.literal('')),
invoice_payment_accounts: InvoicePaymentAccountsSchema.optional(),
accounting_method: AccountingMethodSchema.optional(),
// #967: register/send invoices without booking; booking is a separate step.
defer_invoice_booking: z.boolean().optional(),
@@ -1606,6 +1674,8 @@ export const UpdateSettingsSchema = z.object({
// all overrides. Without this entry the generic PUT would silently strip
// the field (the schema is the de-facto column whitelist).
invoice_email_texts: InvoiceEmailTextsSchema.nullable().optional(),
invoice_email_cc_addresses: invoiceEmailAddressList.nullable().optional(),
invoice_email_bcc_addresses: invoiceEmailAddressList.nullable().optional(),
// Invoice branding: colors enforced as #RRGGBB at the DB level too
// (see migration 20260526120200_invoice_branding.sql). The dedicated
// /api/settings/invoicing/branding route is the primary path; these
@@ -1661,6 +1731,16 @@ export const UpdateSettingsSchema = z.object({
// blocks changing this while open vacation-ledger rows exist.
salary_vacation_year_basis: z.enum(['calendar', 'statutory_apr_mar']).optional(),
}).refine(
(data) => (
(data.invoice_email_cc_addresses?.length ?? 0)
+ (data.invoice_email_bcc_addresses?.length ?? 0)
<= MAX_INVOICE_EMAIL_COPY_RECIPIENTS
),
{
message: `Högst ${MAX_INVOICE_EMAIL_COPY_RECIPIENTS} fasta kopiemottagare är tillåtna totalt`,
path: ['invoice_email_cc_addresses'],
},
).refine(
(data) => {
// BFL 3 kap.: Enskild firma must have fiscal year starting January
if (data.entity_type === 'enskild_firma' && data.fiscal_year_start_month !== undefined) {
+45
View File
@@ -355,6 +355,51 @@ describe('withApiV1: idempotency', () => {
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
})
it('hashes a cloned body and leaves the original readable by the handler', async () => {
mockValidate.mockResolvedValue({
userId: 'user-1',
companyId: 'company-1',
scopes: ['invoices:write'],
mode: 'live',
})
mockServiceClient.mockReturnValue(makeSupabaseStub({ company_id: 'company-1', role: 'owner' }))
mockCheckIdempotency.mockResolvedValue(null)
let observedBody: unknown
const handler = withApiV1(
'invoices.create',
async (request, ctx) => {
observedBody = await request.json()
return ok({ ok: true }, { requestId: ctx.requestId })
},
{ requireScope: 'invoices:write' },
)
const requestBody = { customer_id: 'cust-1', additional_cc: ['copy@example.test'] }
const response = await handler(
makeRequest('https://x.test/api/v1/companies/company-1/invoices', {
method: 'POST',
headers: {
Authorization: 'Bearer gnubok_sk_x',
'Idempotency-Key': 'key-body-readable',
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
}),
companyParams('company-1'),
)
expect(response.status).toBe(200)
expect(observedBody).toEqual(requestBody)
expect(mockCheckIdempotency).toHaveBeenCalledWith(
expect.anything(),
'user-1',
'company-1',
'key-body-readable',
expect.any(String),
)
})
})
describe('withApiV1: dry-run', () => {