feat(expenses): utlägg as an answer to "Vem betalade?" in Underlag, not a page (#2317)

An out-of-pocket purchase differs from any other receipt only in the
credit account, so the Underlag pane now asks one question for an
unmatched underlag (Företaget / Jag, privat / En anställd / Ingen ännu)
and books a privately paid receipt in place through POST
/api/expense-claims, replacing the "Andra sätt att bokföra" dropdown and
the deep link into the two-step wizard. The verifikat editor stays
reachable below as the escape hatch (BFL 5 kap 6-7 §).

The person owed surfaces in Att göra under a new Betala band, one row per
person (lib/worklist expense_payout, counted in the total and exposed to
agents through the attention resource). The Utlägg nav row is gated on
existing claims, the same hybrid gate as Körjournal, since the entry
point for a new utlägg is now the Underlag pane.


Claude-Session: https://claude.ai/code/session_01P8YsvPqjfGxGZUkGeBVUWQ

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-05 15:57:14 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5.1
parent bff44e5757
commit cbe5580886
17 changed files with 866 additions and 86 deletions
+38 -7
View File
@@ -23,22 +23,49 @@ function makeSupabase(
}
describe('getDashboardNavFlags', () => {
it('reads both flags from the RPC row and never touches the tables', async () => {
it('reads both flags from the RPC row and only probes expense_claims beside it', async () => {
const { supabase, from, rpc } = makeSupabase({ data: [{ has_webshop: true, has_mileage_trips: false }] })
expect(await getDashboardNavFlags(supabase, 'c1')).toEqual({ hasWebshop: true, hasMileageTrips: false })
expect(await getDashboardNavFlags(supabase, 'c1')).toEqual({
hasWebshop: true,
hasMileageTrips: false,
hasExpenseClaims: false,
})
expect(rpc).toHaveBeenCalledWith('get_dashboard_nav_flags', { p_company_id: 'c1' })
expect(from).not.toHaveBeenCalled()
// The Utlägg row is gated on existing claims (not part of the RPC): one
// limit-1 probe in the same wave, never the webshop/mileage tables.
expect(from.mock.calls.map((c) => c[0])).toEqual(['expense_claims'])
})
it('accepts a single-object payload and treats null flags as false', async () => {
const { supabase } = makeSupabase({ data: { has_webshop: null, has_mileage_trips: true } })
expect(await getDashboardNavFlags(supabase, 'c1')).toEqual({ hasWebshop: false, hasMileageTrips: true })
expect(await getDashboardNavFlags(supabase, 'c1')).toEqual({
hasWebshop: false,
hasMileageTrips: true,
hasExpenseClaims: false,
})
})
it('shows the Utlägg row once a claim exists', async () => {
const { supabase } = makeSupabase(
{ data: [{ has_webshop: false, has_mileage_trips: false }] },
{ expense_claims: [{ id: 'ec1' }] },
)
expect(await getDashboardNavFlags(supabase, 'c1')).toEqual({
hasWebshop: false,
hasMileageTrips: false,
hasExpenseClaims: true,
})
})
it.each(['PGRST202', '42883', '42501'])('falls back to the four probes when the RPC is unavailable (%s)', async (code) => {
const { supabase, from } = makeSupabase({ error: { code } }, { webshop_orders: [{ id: 'o1' }] })
expect(await getDashboardNavFlags(supabase, 'c1')).toEqual({ hasWebshop: true, hasMileageTrips: false })
expect(await getDashboardNavFlags(supabase, 'c1')).toEqual({
hasWebshop: true,
hasMileageTrips: false,
hasExpenseClaims: false,
})
expect(from.mock.calls.map((c) => c[0]).sort()).toEqual([
'expense_claims',
'mileage_trips',
'shopify_connections',
'webshop_orders',
@@ -48,7 +75,11 @@ describe('getDashboardNavFlags', () => {
it('degrades to hidden rows on any other error instead of probing', async () => {
const { supabase, from } = makeSupabase({ error: { code: '57014', message: 'timeout' } })
expect(await getDashboardNavFlags(supabase, 'c1')).toEqual({ hasWebshop: false, hasMileageTrips: false })
expect(from).not.toHaveBeenCalled()
expect(await getDashboardNavFlags(supabase, 'c1')).toEqual({
hasWebshop: false,
hasMileageTrips: false,
hasExpenseClaims: false,
})
expect(from.mock.calls.map((c) => c[0])).toEqual(['expense_claims'])
})
})
+28 -4
View File
@@ -5,6 +5,13 @@ export interface DashboardNavFlags {
hasWebshop: boolean
/** Existing mileage trips (created via UI, API or MCP). */
hasMileageTrips: boolean
/**
* Existing expense claims (utlägg). Gates the Utlägg nav row the same way
* trips gate Körjournal: the entry point for a new utlägg is the Underlag
* pane ("Vem betalade?"), so the page only earns a rail row once there is
* something on it (a person to pay out).
*/
hasExpenseClaims: boolean
}
const FALLBACK_CODES = new Set(['PGRST202', '42883', '42501'])
@@ -24,7 +31,13 @@ export async function getDashboardNavFlags(
supabase: SupabaseClient,
companyId: string,
): Promise<DashboardNavFlags> {
const rpc = await supabase.rpc('get_dashboard_nav_flags', { p_company_id: companyId })
// The expense probe runs beside the RPC rather than inside it: extending
// get_dashboard_nav_flags would need a migration for one limit-1 read, and
// the two waves overlap so the layout pays no extra round trip.
const [rpc, expenseClaims] = await Promise.all([
supabase.rpc('get_dashboard_nav_flags', { p_company_id: companyId }),
probeExpenseClaims(supabase, companyId),
])
if (!rpc.error) {
const row = (Array.isArray(rpc.data) ? rpc.data[0] : rpc.data) as
| { has_webshop?: boolean | null; has_mileage_trips?: boolean | null }
@@ -33,19 +46,30 @@ export async function getDashboardNavFlags(
return {
hasWebshop: row?.has_webshop === true,
hasMileageTrips: row?.has_mileage_trips === true,
hasExpenseClaims: expenseClaims,
}
}
if (!FALLBACK_CODES.has(rpc.error.code ?? '')) {
return { hasWebshop: false, hasMileageTrips: false }
return { hasWebshop: false, hasMileageTrips: false, hasExpenseClaims: expenseClaims }
}
return getDashboardNavFlagsViaProbes(supabase, companyId)
return { ...(await getDashboardNavFlagsViaProbes(supabase, companyId)), hasExpenseClaims: expenseClaims }
}
async function probeExpenseClaims(supabase: SupabaseClient, companyId: string): Promise<boolean> {
const { data, error } = await supabase
.from('expense_claims')
.select('id')
.eq('company_id', companyId)
.limit(1)
// A failed probe hides the row; the page and API work regardless.
return !error && (data?.length ?? 0) > 0
}
/** The pre-RPC implementation, kept verbatim as the fallback. */
export async function getDashboardNavFlagsViaProbes(
supabase: SupabaseClient,
companyId: string,
): Promise<DashboardNavFlags> {
): Promise<Omit<DashboardNavFlags, 'hasExpenseClaims'>> {
const [woo, shopify, orders, trips] = await Promise.all([
supabase.from('woocommerce_connections').select('id').eq('company_id', companyId).eq('status', 'active').limit(1),
supabase.from('shopify_connections').select('id').eq('company_id', companyId).eq('status', 'active').limit(1),