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:
co-authored by
Claude Fable 5
parent
97907a5a5c
commit
3c0bf3f584
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user