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:
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user