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
+112
View File
@@ -97,6 +97,118 @@ describe('PUT /api/settings', () => {
expect(deadlineMocks.regenerate).not.toHaveBeenCalled()
})
it('updates invoice email recipients and payment accounts', async () => {
const updates = {
invoice_email_cc_addresses: ['info@example.com', 'owner@example.com'],
invoice_email_bcc_addresses: ['archive@example.com'],
invoice_payment_accounts: {
EUR: {
bank_name: 'Example Bank',
iban: 'SE0022222222222222222222',
bic: 'EXAMSESS',
},
},
}
enqueueMany([
{ data: { entity_type: 'aktiebolag', onboarding_complete: true } },
{ data: { role: 'admin' } },
{ data: { id: 's1', ...updates } },
{ data: null, count: 5 },
])
const response = await PUT(createMockRequest('/api/settings', {
method: 'PUT',
body: updates,
}), { params: Promise.resolve({}) })
const { status, body } = await parseJsonResponse<{ data: typeof updates }>(response)
expect(status).toBe(200)
expect(body.data).toMatchObject(updates)
})
it('rejects fixed invoice recipient changes from a regular member', async () => {
enqueueMany([
{ data: { entity_type: 'aktiebolag', onboarding_complete: true } },
{ data: { role: 'member' }, error: null },
])
const response = await PUT(createMockRequest('/api/settings', {
method: 'PUT',
body: { invoice_email_bcc_addresses: ['archive@example.com'] },
}), { params: Promise.resolve({}) })
const { status, body } = await parseJsonResponse<{
error: { code: string; details?: { required_roles?: string[] } }
}>(response)
expect(status).toBe(403)
expect(body.error.code).toBe('FORBIDDEN')
expect(body.error.details?.required_roles).toEqual(['owner', 'admin'])
expect(supabase.from.mock.calls.map(([table]) => table)).toEqual([
'company_settings',
'company_members',
])
})
it('rejects invoice payment instruction changes from a regular member', async () => {
enqueueMany([
{ data: { entity_type: 'aktiebolag', onboarding_complete: true } },
{ data: { role: 'member' }, error: null },
])
const response = await PUT(createMockRequest('/api/settings', {
method: 'PUT',
body: {
invoice_payment_accounts: {
SEK: { bankgiro: '123-4567' },
},
bankgiro: '123-4567',
},
}), { params: Promise.resolve({}) })
const { status, body } = await parseJsonResponse<{
error: { code: string; details?: { required_roles?: string[] } }
}>(response)
expect(status).toBe(403)
expect(body.error.code).toBe('FORBIDDEN')
expect(body.error.details?.required_roles).toEqual(['owner', 'admin'])
expect(supabase.from.mock.calls.map(([table]) => table)).toEqual([
'company_settings',
'company_members',
])
})
it('rejects invalid invoice recipients with otherwise valid payment accounts', async () => {
enqueue({ data: { entity_type: 'aktiebolag', onboarding_complete: true } })
const response = await PUT(createMockRequest('/api/settings', {
method: 'PUT',
body: {
invoice_email_cc_addresses: ['not-an-email'],
invoice_payment_accounts: {
EUR: { bank_name: 'Example Bank', iban: 'SE0022222222222222222222' },
},
},
}), { params: Promise.resolve({}) })
expect(response.status).toBe(400)
expect(supabase.from).toHaveBeenCalledTimes(1)
})
it('rejects a foreign payment account without IBAN with valid recipients', async () => {
enqueue({ data: { entity_type: 'aktiebolag', onboarding_complete: true } })
const response = await PUT(createMockRequest('/api/settings', {
method: 'PUT',
body: {
invoice_email_cc_addresses: ['billing@example.com'],
invoice_payment_accounts: { EUR: { bank_name: 'Example Bank' } },
},
}), { params: Promise.resolve({}) })
expect(response.status).toBe(400)
expect(supabase.from).toHaveBeenCalledTimes(1)
})
it('regenerates deadlines when unchanged tax settings are saved', async () => {
const settings = {
company_id: 'company-1',
+35 -1
View File
@@ -10,6 +10,7 @@ import {
import { validateBody } from '@/lib/api/validate'
import { UpdateSettingsSchema } from '@/lib/api/schemas'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
export const GET = withRouteContext(
'settings.get',
@@ -43,7 +44,7 @@ export const GET = withRouteContext(
export const PUT = withRouteContext(
'settings.update',
async (request, { supabase, companyId, log }) => {
async (request, { supabase, companyId, log, requestId, user }) => {
// Fetch current settings to check for tax-relevant changes
const { data: oldSettings } = await supabase
.from('company_settings')
@@ -55,6 +56,39 @@ export const PUT = withRouteContext(
if (!validation.success) return validation.response
const body = validation.data
const changesInvoiceEmailRecipients =
body.invoice_email_cc_addresses !== undefined
|| body.invoice_email_bcc_addresses !== undefined
const changesInvoicePaymentInstructions =
body.invoice_payment_accounts !== undefined
|| body.bank_name !== undefined
|| body.clearing_number !== undefined
|| body.account_number !== undefined
|| body.bankgiro !== undefined
|| body.plusgiro !== undefined
|| body.swish !== undefined
|| body.iban !== undefined
|| body.bic !== undefined
if (changesInvoiceEmailRecipients || changesInvoicePaymentInstructions) {
const { data: membership, error: membershipError } = await supabase
.from('company_members')
.select('role')
.eq('company_id', companyId)
.eq('user_id', user.id)
.maybeSingle()
if (membershipError) {
log.error('failed to authorize restricted invoice settings', membershipError)
return errorResponseFromCode('INTERNAL_ERROR', log, { requestId })
}
if (!membership || !['owner', 'admin'].includes(membership.role)) {
return errorResponseFromCode('FORBIDDEN', log, {
requestId,
details: { required_roles: ['owner', 'admin'] },
})
}
}
const reminderDays = [
body.reminder_days_level_1 ?? oldSettings?.reminder_days_level_1 ?? 15,
body.reminder_days_level_2 ?? oldSettings?.reminder_days_level_2 ?? 30,