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