fix(invoices): force 0% VAT on recurring and bulk-created invoices when the company is not VAT registered (#1838)
Issue #1719: moms lands on an invoice even though momskrysset (company_settings.vat_registered) is off. The web and v1 create/update routes, the MCP commit, and the webshop route all zero every line via buildInvoiceWriteData, but two paths insert invoices directly and never consult vat_registered: 1. executeRecurringSchedule (cron + run-now): the schedule dialog defaults template lines to 25%, stores vat_rate with no gate, and the spawn falls back to the customer default (25% for Swedish customers) for null-rate lines. The generated invoice carried 25% output VAT and could be auto-emailed to the customer and booked against 2611. 2. POST /api/v1/.../invoices/bulk-create: same fallback, same direct insert. Both now mirror buildInvoiceWriteData: when vat_registered is false, every line is forced to 0% at spawn/create time, and the header lands as treatment 'exempt' with moms_ruta and reverse_charge_text null. Self-billed received invoices deliberately keep their stated VAT: the counterparty issued that document, and the books must mirror it (ML 16 kap 23 §). Credit notes keep mirroring the invoice they credit. Claude-Session: https://claude.ai/code/session_01SyDuePXxUFowaPBKpAv8SF Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
64119d30bc
commit
c93a97bb4e
@@ -331,6 +331,49 @@ describe('POST /api/v1/companies/:companyId/invoices/bulk-create', () => {
|
||||
expect(insertedInvoice).toBe(false)
|
||||
})
|
||||
|
||||
it('forces every line to 0% when the company is not VAT registered (issue #1719)', async () => {
|
||||
// Momskrysset off (company_settings.vat_registered = false): the invoice
|
||||
// must come out momsfri whether the payload sends an explicit rate or
|
||||
// relies on the customer-default fallback (25% for a Swedish customer).
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
customers: { data: VALID_CUSTOMER, error: null },
|
||||
company_settings: { data: { vat_registered: false }, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await bulkCreate(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/bulk-create?dry_run=true`, {
|
||||
invoices: [
|
||||
{
|
||||
customer_id: CUSTOMER_ID,
|
||||
invoice_date: '2026-05-12',
|
||||
due_date: '2026-06-11',
|
||||
currency: 'SEK',
|
||||
items: [
|
||||
{ ...SAMPLE_ITEM('Explicit 25'), vat_rate: 25 },
|
||||
SAMPLE_ITEM('Fallback rate'),
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.preview.summary.succeeded).toBe(1)
|
||||
const preview = body.data.preview.results[0].data.preview
|
||||
expect(preview.subtotal).toBe(2000)
|
||||
expect(preview.vat_amount).toBe(0)
|
||||
expect(preview.total).toBe(2000)
|
||||
for (const item of preview.items) {
|
||||
expect(item.vat_rate).toBe(0)
|
||||
expect(item.vat_amount).toBe(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects all_or_nothing: true with 501 NOT_IMPLEMENTED', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
|
||||
@@ -194,6 +194,21 @@ async function createOneInvoice(
|
||||
)
|
||||
const allowedRates = new Set(permittedRates.map((r) => r.rate))
|
||||
|
||||
// VAT registration gate, mirroring buildInvoiceWriteData (issue #1719): a
|
||||
// non-momsregistrerad company books no output VAT, so every line is forced
|
||||
// to 0% (momsfri) server-side no matter what the batch payload carries,
|
||||
// explicitly or via the customer-default fallback below. 0% is a permitted
|
||||
// rate for every customer type, so the allowedRates gate still passes.
|
||||
const { data: vatSettings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('vat_registered')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
const notVatRegistered = vatSettings?.vat_registered === false
|
||||
if (notVatRegistered && documentType !== 'delivery_note') {
|
||||
for (const item of input.items) item.vat_rate = 0
|
||||
}
|
||||
|
||||
const subtotal = input.items.reduce((sum, item) => sum + item.quantity * item.unit_price, 0)
|
||||
let vatAmount = 0
|
||||
if (documentType !== 'delivery_note') {
|
||||
@@ -315,10 +330,13 @@ async function createOneInvoice(
|
||||
total,
|
||||
total_sek: documentType === 'delivery_note' ? null : totalSek,
|
||||
remaining_amount: documentType === 'invoice' ? total : 0,
|
||||
vat_treatment: vatRules.treatment,
|
||||
// Header VAT fields mirror buildInvoiceWriteData: a not-VAT-registered
|
||||
// company stamps the sale as momsfri (treatment 'exempt', no ruta, no
|
||||
// reverse-charge notation); every line rate is already zeroed above.
|
||||
vat_treatment: notVatRegistered ? 'exempt' : vatRules.treatment,
|
||||
vat_rate: headerVatRate,
|
||||
moms_ruta: vatRules.momsRuta,
|
||||
reverse_charge_text: vatRules.reverseChargeText || null,
|
||||
moms_ruta: notVatRegistered ? null : vatRules.momsRuta,
|
||||
reverse_charge_text: notVatRegistered ? null : (vatRules.reverseChargeText || null),
|
||||
your_reference: input.your_reference,
|
||||
our_reference: input.our_reference,
|
||||
notes: input.notes,
|
||||
|
||||
Reference in New Issue
Block a user