import { todayInTz, resolveTz, wallClockToUtc } from '@shared/timezone'; import { famMeta } from './fam'; import { pbAdmin } from '$lib/server/pocketbase'; function resolveServerTz(tz?: string): string { return resolveTz(tz || 'auto'); } // A `once` goal is finished the moment its reward is approved — complete the // config here (not just in evaluateFam, which only runs on chore toggles) so // the child dashboard stops offering it even before the next toggle. // Config write via superuser: bonus_configs updates are parent-only. // Outstanding (unclaimed/requested) rewards are untouched — they stay visible // and collectible; only the approval moment completes the goal. async function completeOnceConfig(famId: string, reward: any) { if (!reward?.bonusConfigId) return; try { const cfg: any = await pbAdmin.getOne('bonus_configs', reward.bonusConfigId); if (!cfg || cfg.famId !== famId) return; if (cfg.occurrence !== 'once' || cfg.status === 'completed') return; await pbAdmin.update('bonus_configs', cfg.id, { status: 'completed', completedAt: new Date().toISOString().slice(0, 10), completedBy: reward.memberId || 'admin' }); } catch {} } // Payday-gated rewards (claimable 'payday', e.g. pocket money) unlock on the // settle DATE at the fam's configured payday TIME — not midnight. Anything // else (claimable 'immediate'/unset, e.g. manual triggers) is always claimable. export function assertPaydayUnlocked(reward: any, tz?: string, paydayTime?: string) { if (!reward || reward.claimable !== 'payday' || !reward.settleDate) return; const resolvedTz = resolveServerTz(tz); const settle = (reward.settleDate as string).slice(0, 10); const today = todayInTz(resolvedTz); if (today < settle) { throw new Error(`This bonus pays out on payday (${settle}) — hang tight!`); } if (today === settle && paydayTime && /^\d{2}:\d{2}$/.test(paydayTime)) { const unlock = wallClockToUtc(settle, paydayTime, resolvedTz); if (Date.now() < unlock) { throw new Error(`This bonus unlocks at ${paydayTime} on payday — hang tight!`); } } } // Member claim → status 'requested' (pending parent approval). export async function claim(pb: any, famId: string, id: string) { const now = new Date().toISOString(); const { tz, paydayTime } = await famMeta(pb, famId); const found = await pb .collection('rewards') .getFullList({ filter: `famId = '${famId}' && id = '${id}'` }); const reward = found?.[0]; if (!reward) throw new Error('Reward not found'); assertPaydayUnlocked(reward, tz, paydayTime); return pb.collection('rewards').update(id, { status: 'requested', requestedAt: now }); } // Admin approval → status 'claimed'. export async function approve(pb: any, famId: string, id: string) { const record = await pb.collection('rewards').update(id, { status: 'claimed', claimedAt: new Date().toISOString() }); await completeOnceConfig(famId, record); return record; } export async function requestAll(pb: any, famId: string, memberId: string) { const now = new Date().toISOString(); const { tz, paydayTime } = await famMeta(pb, famId); const rewards = await pb.collection('rewards').getFullList({ filter: `famId = '${famId}' && memberId = '${memberId}' && status = 'unclaimed'` }); let count = 0; for (const r of rewards) { try { assertPaydayUnlocked(r, tz, paydayTime); } catch { continue; } await pb.collection('rewards').update(r.id, { status: 'requested', requestedAt: now }); count++; } return { count }; } export async function issueAll(pb: any, famId: string, memberId: string) { if (!memberId) throw new Error('memberId required'); const now = new Date().toISOString(); const rewards = await pb.collection('rewards').getFullList({ filter: `famId = '${famId}' && memberId = '${memberId}' && rewardType = 'cash' && (status = 'unclaimed' || status = 'requested')` }); let count = 0; for (const r of rewards) { const record = await pb.collection('rewards').update(r.id, { status: 'claimed', claimedAt: now }); await completeOnceConfig(famId, record); count++; } return { count }; }