feat(deadlines): gate F-skatt reminders on debited preliminary tax + durable dismissal (#1057)

* feat(deadlines): gate F-skatt reminders on debited preliminary tax, add durable dismissal

The f_skatt deadline was gated on the F-skatt approval flag (DB default
true), giving nearly every company 12 monthly payment reminders for a tax
Skatteverket may not have debited at all (64% of all system deadline rows,
one lifetime completion). Approval carries no recurring obligation; the
monthly duty is payment of debiterad preliminarskatt and exists only while
the debited amount is > 0 (SFL 62 kap. 4-5 par., 55 kap. 2 par.).

- Gate the f_skatt deadline on preliminary_tax_monthly > 0 (field already
  collected at onboarding, previously unread) and retitle it as a payment.
- Storforetag keep the 12th in August (January-only 17th, 62 kap. 3 par.).
- Declare the prod-only preliminary_tax_monthly column in a migration so
  installs built purely from migrations stop failing tax-settings saves.
- Add deadlines.dismissed_at: DELETE on a system deadline now soft-dismisses
  it durably (hard deletes were resurrected by the nightly backfill within
  24h); generator, backfill, and every read surface respect it.
- Prune upcoming f_skatt rows for companies with no debited amount.

Closes part of #1028.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(deadlines): include dismissed_at in DeadlineForm payload

The Deadline type gained the required dismissed_at field; the form's
submit payload literal must carry it for the Omit<Deadline, ...> shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(deadlines): make system-deadline dismissal atomic

Constrain the dismiss update to source='system' and verify a row was
actually updated: a concurrent regeneration can delete the row between
lookup and update, and the route must not report a phantom success.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-17 15:48:25 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 97907a5a5c
commit 3c0bf3f584
21 changed files with 237 additions and 40 deletions
+1
View File
@@ -105,6 +105,7 @@ export async function GET(
.from('deadlines')
.select('*')
.eq('company_id', feed.company_id)
.is('dismissed_at', null)
.gte('due_date', startStr)
.lte('due_date', endStr)
.order('due_date')
+37 -4
View File
@@ -105,19 +105,52 @@ describe('DELETE /api/deadlines/[id]', () => {
})
it('returns 404 instead of phantom success when no row matches', async () => {
auth(createCapturingSupabase([{ count: 0 }]))
// First result: the source lookup finds nothing.
auth(createCapturingSupabase([{ data: null }]))
const { status } = await parseJsonResponse(
await DELETE(createMockRequest('/x', { method: 'DELETE' }), idParams)
)
expect(status).toBe(404)
})
it('deletes the deadline', async () => {
auth(createCapturingSupabase([{ count: 1 }]))
const { status, body } = await parseJsonResponse<{ success: boolean }>(
it('hard-deletes a user-created deadline', async () => {
auth(createCapturingSupabase([
{ data: { id: 'deadline-1', source: 'user' } },
{ count: 1 },
]))
const { status, body } = await parseJsonResponse<{ success: boolean; dismissed?: boolean }>(
await DELETE(createMockRequest('/x', { method: 'DELETE' }), idParams)
)
expect(status).toBe(200)
expect(body.success).toBe(true)
expect(body.dismissed).toBeUndefined()
})
it('dismisses a system deadline instead of deleting it', async () => {
// A hard-deleted system row is recreated by the nightly backfill cron;
// the route must soft-dismiss so the opt-out is durable.
auth(createCapturingSupabase([
{ data: { id: 'deadline-1', source: 'system' } },
{ data: [{ id: 'deadline-1' }] },
]))
const { status, body } = await parseJsonResponse<{ success: boolean; dismissed?: boolean }>(
await DELETE(createMockRequest('/x', { method: 'DELETE' }), idParams)
)
expect(status).toBe(200)
expect(body.success).toBe(true)
expect(body.dismissed).toBe(true)
})
it('returns 404 when the system row vanished before the dismissal landed', async () => {
// Concurrent regeneration can delete the row between lookup and update;
// a phantom "dismissed" success would persist nothing.
auth(createCapturingSupabase([
{ data: { id: 'deadline-1', source: 'system' } },
{ data: [] },
]))
const { status } = await parseJsonResponse(
await DELETE(createMockRequest('/x', { method: 'DELETE' }), idParams)
)
expect(status).toBe(404)
})
})
+42 -1
View File
@@ -90,7 +90,13 @@ export const PUT = withRouteContext<{ params: Promise<{ id: string }> }>(
/**
* DELETE /api/deadlines/[id]
* Delete a deadline
* Delete a user deadline, or durably dismiss a system-generated one.
*
* System rows are soft-dismissed instead of hard-deleted: the nightly
* backfill cron treats a missing upcoming system row as a repair case and
* recreates it within 24 hours, so a hard delete silently undoes itself.
* A dismissed row stays in the table (hidden from every surface) and
* satisfies the generator and backfill the same way a completed row does.
*/
export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
'deadline.delete',
@@ -98,6 +104,41 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
const { id } = await params
const { supabase, companyId } = ctx
const { data: existing, error: fetchError } = await supabase
.from('deadlines')
.select('id, source')
.eq('id', id)
.eq('company_id', companyId)
.maybeSingle()
if (fetchError) {
return NextResponse.json({ error: fetchError.message }, { status: 500 })
}
if (!existing) {
return NextResponse.json({ error: 'Deadline not found' }, { status: 404 })
}
if (existing.source === 'system') {
const { data: dismissedRows, error: dismissError } = await supabase
.from('deadlines')
.update({ dismissed_at: new Date().toISOString() })
.eq('id', id)
.eq('company_id', companyId)
.eq('source', 'system')
.select('id')
if (dismissError) {
return NextResponse.json({ error: dismissError.message }, { status: 500 })
}
// The row can vanish between lookup and update (generator cleanup
// during a concurrent regeneration); report 404 rather than a
// phantom success that persisted nothing.
if (!dismissedRows || dismissedRows.length === 0) {
return NextResponse.json({ error: 'Deadline not found' }, { status: 404 })
}
return NextResponse.json({ success: true, dismissed: true })
}
const { error, count } = await supabase
.from('deadlines')
.delete({ count: 'exact' })
+3 -1
View File
@@ -22,11 +22,13 @@ export const GET = withRouteContext('deadline.list', async (request, ctx) => {
const from = searchParams.get('from')
const to = searchParams.get('to')
// Build query
// Build query. Dismissed system deadlines are an explicit opt-out and
// never listed.
let query = supabase
.from('deadlines')
.select('*, customer:customers(id, name)')
.eq('company_id', companyId)
.is('dismissed_at', null)
// Apply filters
if (status === 'pending') {