Compare commits
5 Commits
77c5d72e43
...
b43924f527
| Author | SHA1 | Date | |
|---|---|---|---|
| b43924f527 | |||
| b611207d25 | |||
| bd87cd09f5 | |||
| 70bb5954a0 | |||
| c1a575241f |
@@ -1,5 +1,8 @@
|
||||
import { redirect, type Handle } from '@sveltejs/kit';
|
||||
import { validateSession, setSessionCookie } from '$lib/server/auth/index.js';
|
||||
import { startScheduler } from '$lib/server/recurring-bills/scheduler.js';
|
||||
|
||||
startScheduler();
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
// Redirect implicit /favicon.ico requests to our SVG to avoid 404 noise
|
||||
|
||||
@@ -944,6 +944,61 @@ export const companyAccountTransactions = pgTable(
|
||||
]
|
||||
);
|
||||
|
||||
// ── Recurring Bills ────────────────────────────────────
|
||||
|
||||
export const recurringBillCycleEnum = pgEnum('recurring_bill_cycle', [
|
||||
'weekly',
|
||||
'monthly',
|
||||
'quarterly',
|
||||
'yearly'
|
||||
]);
|
||||
|
||||
export const recurringBillStatusEnum = pgEnum('recurring_bill_status', [
|
||||
'active',
|
||||
'paused',
|
||||
'ended'
|
||||
]);
|
||||
|
||||
export const recurringBills = pgTable(
|
||||
'recurring_bills',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
companyId: uuid('company_id')
|
||||
.notNull()
|
||||
.references(() => companies.id, { onDelete: 'cascade' }),
|
||||
projectId: uuid('project_id')
|
||||
.notNull()
|
||||
.references(() => projects.id, { onDelete: 'restrict' }),
|
||||
accountId: uuid('account_id')
|
||||
.notNull()
|
||||
.references(() => companyAccounts.id, { onDelete: 'restrict' }),
|
||||
categoryId: uuid('category_id').references(() => categories.id, { onDelete: 'set null' }),
|
||||
partyId: uuid('party_id').references(() => parties.id, { onDelete: 'set null' }),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
cycle: recurringBillCycleEnum('cycle').notNull(),
|
||||
defaultAmount: numeric('default_amount', { precision: 15, scale: 2 }).notNull(),
|
||||
nextCycleAmount: numeric('next_cycle_amount', { precision: 15, scale: 2 }),
|
||||
currency: text('currency').notNull().default('THB'),
|
||||
dayOfCycle: integer('day_of_cycle'),
|
||||
startDate: date('start_date').notNull(),
|
||||
endDate: date('end_date'),
|
||||
nextDueDate: date('next_due_date').notNull(),
|
||||
lastPostedDate: date('last_posted_date'),
|
||||
status: recurringBillStatusEnum('status').notNull().default('active'),
|
||||
pausedAt: timestamp('paused_at', { withTimezone: true }),
|
||||
skipNext: boolean('skip_next').notNull().default(false),
|
||||
createdBy: text('created_by').references(() => users.id, { onDelete: 'set null' }),
|
||||
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
|
||||
},
|
||||
(table) => [
|
||||
index('recurring_bills_company_next_due_idx').on(table.companyId, table.nextDueDate),
|
||||
index('recurring_bills_company_status_idx').on(table.companyId, table.status)
|
||||
]
|
||||
);
|
||||
|
||||
export const companyAddresses = pgTable(
|
||||
'company_addresses',
|
||||
{
|
||||
@@ -1037,7 +1092,14 @@ export const companyLogEventEnum = pgEnum('company_log_event', [
|
||||
'account_deleted',
|
||||
'account_transaction_added',
|
||||
'account_transfer_posted',
|
||||
'account_reconciled'
|
||||
'account_reconciled',
|
||||
'recurring_bill_created',
|
||||
'recurring_bill_updated',
|
||||
'recurring_bill_deleted',
|
||||
'recurring_bill_paused',
|
||||
'recurring_bill_resumed',
|
||||
'recurring_bill_skipped',
|
||||
'recurring_bill_posted'
|
||||
]);
|
||||
|
||||
export const companyLog = pgTable(
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
export type Cycle = 'weekly' | 'monthly' | 'quarterly' | 'yearly';
|
||||
|
||||
function daysInMonthUTC(year: number, monthZeroBased: number): number {
|
||||
return new Date(Date.UTC(year, monthZeroBased + 1, 0)).getUTCDate();
|
||||
}
|
||||
|
||||
function toUtcMidnight(date: Date): Date {
|
||||
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
||||
}
|
||||
|
||||
function clampDay(year: number, monthZeroBased: number, day: number): number {
|
||||
const max = daysInMonthUTC(year, monthZeroBased);
|
||||
return Math.min(Math.max(day, 1), max);
|
||||
}
|
||||
|
||||
// Monthly day=31: Jan 31 → Feb 28 → Mar 31 (we never advance based on the previous result alone)
|
||||
// Yearly Feb 29 (leap) → Feb 28 next year → Feb 29 in next leap year
|
||||
// Quarterly day=31 starting Jan 31 → Apr 30, Jul 31, Oct 31
|
||||
// This clamps to month length without losing the "original" day intent across cycles.
|
||||
function stepForward(date: Date, cycle: Cycle, targetDay: number | null): Date {
|
||||
const y = date.getUTCFullYear();
|
||||
const m = date.getUTCMonth();
|
||||
const d = date.getUTCDate();
|
||||
|
||||
if (cycle === 'weekly') {
|
||||
return new Date(Date.UTC(y, m, d + 7));
|
||||
}
|
||||
|
||||
const monthDelta = cycle === 'monthly' ? 1 : cycle === 'quarterly' ? 3 : 12;
|
||||
const nextMonth = m + monthDelta;
|
||||
const nextYear = y + Math.floor(nextMonth / 12);
|
||||
const normalisedMonth = ((nextMonth % 12) + 12) % 12;
|
||||
const intendedDay = targetDay ?? d;
|
||||
const clampedDay = clampDay(nextYear, normalisedMonth, intendedDay);
|
||||
return new Date(Date.UTC(nextYear, normalisedMonth, clampedDay));
|
||||
}
|
||||
|
||||
export function addCycle(date: Date, cycle: Cycle, targetDay: number | null = null): Date {
|
||||
return stepForward(toUtcMidnight(date), cycle, targetDay);
|
||||
}
|
||||
|
||||
function alignWeekly(startDate: Date, dayOfCycle: number | null): Date {
|
||||
if (dayOfCycle === null) return toUtcMidnight(startDate);
|
||||
const base = toUtcMidnight(startDate);
|
||||
const baseDay = base.getUTCDay();
|
||||
const target = Math.max(0, Math.min(6, dayOfCycle));
|
||||
const delta = (target - baseDay + 7) % 7;
|
||||
return new Date(Date.UTC(base.getUTCFullYear(), base.getUTCMonth(), base.getUTCDate() + delta));
|
||||
}
|
||||
|
||||
function alignMonthLike(startDate: Date, dayOfCycle: number | null): Date {
|
||||
const base = toUtcMidnight(startDate);
|
||||
if (dayOfCycle === null) return base;
|
||||
const y = base.getUTCFullYear();
|
||||
const m = base.getUTCMonth();
|
||||
const clamped = clampDay(y, m, dayOfCycle);
|
||||
const aligned = new Date(Date.UTC(y, m, clamped));
|
||||
if (aligned.getTime() < base.getTime()) {
|
||||
const nextMonth = m + 1;
|
||||
const nextYear = y + Math.floor(nextMonth / 12);
|
||||
const normalisedMonth = ((nextMonth % 12) + 12) % 12;
|
||||
return new Date(Date.UTC(nextYear, normalisedMonth, clampDay(nextYear, normalisedMonth, dayOfCycle)));
|
||||
}
|
||||
return aligned;
|
||||
}
|
||||
|
||||
export function computeNextDueDate(
|
||||
startDate: string,
|
||||
cycle: Cycle,
|
||||
dayOfCycle: number | null,
|
||||
from?: Date
|
||||
): Date {
|
||||
const start = new Date(`${startDate}T00:00:00Z`);
|
||||
if (Number.isNaN(start.getTime())) {
|
||||
throw new Error(`Invalid startDate: ${startDate}`);
|
||||
}
|
||||
const fromUtc = from ? toUtcMidnight(from) : toUtcMidnight(new Date());
|
||||
|
||||
let candidate =
|
||||
cycle === 'weekly' ? alignWeekly(start, dayOfCycle) : alignMonthLike(start, dayOfCycle);
|
||||
|
||||
while (candidate.getTime() < fromUtc.getTime()) {
|
||||
candidate = stepForward(candidate, cycle, dayOfCycle);
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
export function toIsoDate(date: Date): string {
|
||||
const y = date.getUTCFullYear();
|
||||
const m = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getUTCDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { expenses, recurringBills } from '$lib/server/db/schema.js';
|
||||
import { postExpenseTransaction } from '$lib/server/accounts/ledger.js';
|
||||
import { logCompanyEvent } from '$lib/server/audit.js';
|
||||
import { addCycle, toIsoDate, type Cycle } from './cycle.js';
|
||||
import { and, eq, isNull, lte } from 'drizzle-orm';
|
||||
|
||||
export interface PostResult {
|
||||
postedCount: number;
|
||||
skippedCount: number;
|
||||
errors: Array<{ billId: string; error: string }>;
|
||||
}
|
||||
|
||||
type BillRow = typeof recurringBills.$inferSelect;
|
||||
|
||||
function fromIso(iso: string): Date {
|
||||
return new Date(`${iso}T00:00:00Z`);
|
||||
}
|
||||
|
||||
function advanceDate(iso: string, cycle: Cycle, dayOfCycle: number | null): string {
|
||||
return toIsoDate(addCycle(fromIso(iso), cycle, dayOfCycle));
|
||||
}
|
||||
|
||||
async function processBill(
|
||||
bill: BillRow,
|
||||
nowDate: Date
|
||||
): Promise<{ posted: number; skipped: number }> {
|
||||
const nowIso = toIsoDate(nowDate);
|
||||
let posted = 0;
|
||||
let skipped = 0;
|
||||
let nextDueDate = bill.nextDueDate;
|
||||
let skipNext = bill.skipNext;
|
||||
let currentOverride: string | null = bill.nextCycleAmount;
|
||||
|
||||
while (nextDueDate <= nowIso) {
|
||||
if (bill.endDate && nextDueDate > bill.endDate) break;
|
||||
|
||||
if (skipNext) {
|
||||
const advancedIso = advanceDate(nextDueDate, bill.cycle, bill.dayOfCycle);
|
||||
await db
|
||||
.update(recurringBills)
|
||||
.set({
|
||||
skipNext: false,
|
||||
nextDueDate: advancedIso,
|
||||
updatedAt: nowDate
|
||||
})
|
||||
.where(eq(recurringBills.id, bill.id));
|
||||
await logCompanyEvent(
|
||||
bill.companyId,
|
||||
bill.createdBy,
|
||||
'recurring_bill_skipped',
|
||||
`Skipped ${nextDueDate} cycle for "${bill.name}"`,
|
||||
{ billId: bill.id, skippedDate: nextDueDate }
|
||||
);
|
||||
skipNext = false;
|
||||
nextDueDate = advancedIso;
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!bill.createdBy) {
|
||||
throw new Error('Bill has no createdBy (user was deleted); cannot post expense');
|
||||
}
|
||||
const createdBy = bill.createdBy;
|
||||
const amountStr = currentOverride ?? bill.defaultAmount;
|
||||
const postedDate = nextDueDate;
|
||||
const advancedIso = advanceDate(postedDate, bill.cycle, bill.dayOfCycle);
|
||||
const willBeEnded = bill.endDate !== null && advancedIso > bill.endDate;
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
const [exp] = await tx
|
||||
.insert(expenses)
|
||||
.values({
|
||||
projectId: bill.projectId,
|
||||
accountId: bill.accountId,
|
||||
categoryId: bill.categoryId,
|
||||
partyId: bill.partyId,
|
||||
submittedBy: createdBy,
|
||||
approvedBy: createdBy,
|
||||
title: bill.name,
|
||||
description: bill.description,
|
||||
amount: amountStr,
|
||||
currency: bill.currency,
|
||||
expenseDate: postedDate,
|
||||
status: 'approved',
|
||||
reviewedAt: nowDate
|
||||
})
|
||||
.returning({ id: expenses.id });
|
||||
|
||||
await postExpenseTransaction(exp.id, bill.accountId, createdBy, tx);
|
||||
|
||||
await tx
|
||||
.update(recurringBills)
|
||||
.set({
|
||||
lastPostedDate: postedDate,
|
||||
nextDueDate: advancedIso,
|
||||
nextCycleAmount: null,
|
||||
status: willBeEnded ? 'ended' : 'active',
|
||||
updatedAt: nowDate
|
||||
})
|
||||
.where(eq(recurringBills.id, bill.id));
|
||||
|
||||
await logCompanyEvent(
|
||||
bill.companyId,
|
||||
createdBy,
|
||||
'recurring_bill_posted',
|
||||
`Posted ${amountStr} ${bill.currency} for "${bill.name}" (${postedDate})`,
|
||||
{ billId: bill.id, expenseId: exp.id, amount: amountStr, postedFor: postedDate }
|
||||
);
|
||||
});
|
||||
|
||||
currentOverride = null;
|
||||
nextDueDate = advancedIso;
|
||||
posted++;
|
||||
|
||||
if (willBeEnded) break;
|
||||
}
|
||||
|
||||
return { posted, skipped };
|
||||
}
|
||||
|
||||
export async function postBillsDue(
|
||||
companyId?: string,
|
||||
now?: Date
|
||||
): Promise<PostResult> {
|
||||
const nowDate = now ?? new Date();
|
||||
const nowIso = toIsoDate(nowDate);
|
||||
|
||||
const scopeFilter = companyId ? eq(recurringBills.companyId, companyId) : undefined;
|
||||
|
||||
const dueBills = await db
|
||||
.select()
|
||||
.from(recurringBills)
|
||||
.where(
|
||||
and(
|
||||
eq(recurringBills.status, 'active'),
|
||||
isNull(recurringBills.pausedAt),
|
||||
isNull(recurringBills.deletedAt),
|
||||
lte(recurringBills.nextDueDate, nowIso),
|
||||
...(scopeFilter ? [scopeFilter] : [])
|
||||
)
|
||||
);
|
||||
|
||||
let postedCount = 0;
|
||||
let skippedCount = 0;
|
||||
const errors: Array<{ billId: string; error: string }> = [];
|
||||
|
||||
for (const bill of dueBills) {
|
||||
try {
|
||||
const r = await processBill(bill, nowDate);
|
||||
postedCount += r.posted;
|
||||
skippedCount += r.skipped;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
errors.push({ billId: bill.id, error: msg });
|
||||
console.error(`[recurring-bills] failed to post bill ${bill.id}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
return { postedCount, skippedCount, errors };
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { postBillsDue } from './poster.js';
|
||||
|
||||
const INTERVAL_MS = 15 * 60 * 1000;
|
||||
const GUARD_KEY = '__b4lRecurringBillsScheduler';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type GlobalWithGuard = typeof globalThis & { [GUARD_KEY]?: NodeJS.Timeout };
|
||||
|
||||
export function startScheduler(): void {
|
||||
const g = globalThis as GlobalWithGuard;
|
||||
if (g[GUARD_KEY]) return;
|
||||
|
||||
g[GUARD_KEY] = setInterval(async () => {
|
||||
try {
|
||||
const result = await postBillsDue();
|
||||
if (result.postedCount > 0 || result.errors.length > 0 || result.skippedCount > 0) {
|
||||
console.log('[scheduler] recurring bills tick:', result);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[scheduler] recurring bills tick error:', err);
|
||||
}
|
||||
}, INTERVAL_MS);
|
||||
|
||||
console.log('[scheduler] recurring bills started (interval: 15min)');
|
||||
}
|
||||
@@ -36,6 +36,7 @@
|
||||
...(data.companyRoles.some((r) => r === 'admin' || r === 'manager' || r === 'accountant')
|
||||
? [
|
||||
{ href: `/companies/${data.company.id}/accounts`, label: 'Accounts' },
|
||||
{ href: `/companies/${data.company.id}/bills`, label: 'Bills' },
|
||||
{ href: `/companies/${data.company.id}/profile`, label: 'Profile' },
|
||||
{ href: `/companies/${data.company.id}/documents`, label: 'Documents' }
|
||||
]
|
||||
|
||||
@@ -207,44 +207,29 @@ export const load: PageServerLoad = async ({ locals, params, parent, url }) => {
|
||||
|
||||
const showArchived = url.searchParams.get('archived') === '1';
|
||||
|
||||
const accountsWithBalance = await db
|
||||
.select({
|
||||
id: companyAccounts.id,
|
||||
accountType: companyAccounts.accountType,
|
||||
name: companyAccounts.name,
|
||||
currency: companyAccounts.currency,
|
||||
isActive: companyAccounts.isActive,
|
||||
isArchived: companyAccounts.isArchived,
|
||||
notes: companyAccounts.notes,
|
||||
sortOrder: companyAccounts.sortOrder,
|
||||
bankName: companyAccounts.bankName,
|
||||
accountNumber: companyAccounts.accountNumber,
|
||||
branch: companyAccounts.branch,
|
||||
swiftBic: companyAccounts.swiftBic,
|
||||
iban: companyAccounts.iban,
|
||||
accountHolderName: companyAccounts.accountHolderName,
|
||||
cardBrand: companyAccounts.cardBrand,
|
||||
last4: companyAccounts.last4,
|
||||
cardholderName: companyAccounts.cardholderName,
|
||||
expiryMonth: companyAccounts.expiryMonth,
|
||||
expiryYear: companyAccounts.expiryYear,
|
||||
creditLimit: companyAccounts.creditLimit,
|
||||
statementCloseDay: companyAccounts.statementCloseDay,
|
||||
paymentDueDay: companyAccounts.paymentDueDay,
|
||||
externalAccountId: companyAccounts.externalAccountId,
|
||||
createdAt: companyAccounts.createdAt,
|
||||
balance: sql<string>`coalesce((
|
||||
select sum(${companyAccountTransactions.amount})
|
||||
from ${companyAccountTransactions}
|
||||
where ${companyAccountTransactions.accountId} = ${companyAccounts.id}
|
||||
), '0')::text`
|
||||
})
|
||||
const accountsRaw = await db
|
||||
.select()
|
||||
.from(companyAccounts)
|
||||
.where(
|
||||
and(eq(companyAccounts.companyId, params.companyId), isNull(companyAccounts.deletedAt))
|
||||
)
|
||||
.orderBy(asc(companyAccounts.isArchived), asc(companyAccounts.sortOrder), asc(companyAccounts.name));
|
||||
|
||||
const balanceRows = await db
|
||||
.select({
|
||||
accountId: companyAccountTransactions.accountId,
|
||||
balance: sql<string>`coalesce(sum(${companyAccountTransactions.amount}), '0')::text`
|
||||
})
|
||||
.from(companyAccountTransactions)
|
||||
.where(eq(companyAccountTransactions.companyId, params.companyId))
|
||||
.groupBy(companyAccountTransactions.accountId);
|
||||
|
||||
const balanceMap = new Map(balanceRows.map((r) => [r.accountId, r.balance]));
|
||||
const accountsWithBalance = accountsRaw.map((a) => ({
|
||||
...a,
|
||||
balance: balanceMap.get(a.id) ?? '0'
|
||||
}));
|
||||
|
||||
const visibleAccounts = showArchived
|
||||
? accountsWithBalance
|
||||
: accountsWithBalance.filter((a) => !a.isArchived);
|
||||
|
||||
@@ -715,19 +715,21 @@
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{#each data.accounts as acct (acct.id)}
|
||||
<div
|
||||
class="flex flex-col gap-2 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800 {acct.isArchived
|
||||
class="group relative flex flex-col gap-2 rounded-lg border border-gray-200 bg-white p-4 transition-colors hover:border-blue-400 hover:shadow-sm dark:border-gray-700 dark:bg-gray-800 dark:hover:border-blue-500 {acct.isArchived
|
||||
? 'opacity-60'
|
||||
: ''}"
|
||||
>
|
||||
<a
|
||||
href={`/companies/${data.company.id}/accounts/${acct.id}`}
|
||||
aria-label={`Open ${acct.name}`}
|
||||
class="absolute inset-0 rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
>
|
||||
<span class="sr-only">Open {acct.name}</span>
|
||||
</a>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="truncate text-sm font-semibold text-gray-900 dark:text-white">
|
||||
<a
|
||||
href={`/companies/${data.company.id}/accounts/${acct.id}`}
|
||||
class="hover:text-blue-600 dark:hover:text-blue-400"
|
||||
>
|
||||
{acct.name}
|
||||
</a>
|
||||
<h3 class="truncate text-sm font-semibold text-gray-900 group-hover:text-blue-600 dark:text-white dark:group-hover:text-blue-400">
|
||||
{acct.name}
|
||||
</h3>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-1.5">
|
||||
<span
|
||||
@@ -783,7 +785,7 @@
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="mt-auto flex flex-wrap justify-end gap-2 border-t border-gray-100 pt-2 dark:border-gray-700"
|
||||
class="relative z-10 mt-auto flex flex-wrap justify-end gap-2 border-t border-gray-100 pt-2 dark:border-gray-700"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -835,7 +837,7 @@
|
||||
await update({ reset: false });
|
||||
confirmDeleteId = null;
|
||||
}}
|
||||
class="mt-2 rounded-md bg-red-50 p-2 text-xs dark:bg-red-900/30"
|
||||
class="relative z-10 mt-2 rounded-md bg-red-50 p-2 text-xs dark:bg-red-900/30"
|
||||
>
|
||||
<input type="hidden" name="id" value={acct.id} />
|
||||
<p class="mb-2 text-red-700 dark:text-red-300">
|
||||
@@ -867,7 +869,7 @@
|
||||
await update({ reset: false });
|
||||
if (result.type === 'success') editingId = null;
|
||||
}}
|
||||
class="mt-2 rounded-md bg-gray-50 p-3 dark:bg-gray-700/50"
|
||||
class="relative z-10 mt-2 rounded-md bg-gray-50 p-3 dark:bg-gray-700/50"
|
||||
>
|
||||
<input type="hidden" name="id" value={acct.id} />
|
||||
<input type="hidden" name="accountType" value={acct.accountType} />
|
||||
|
||||
@@ -0,0 +1,550 @@
|
||||
import { error, fail } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import {
|
||||
recurringBills,
|
||||
companyAccounts,
|
||||
projects,
|
||||
categories,
|
||||
parties
|
||||
} from '$lib/server/db/schema.js';
|
||||
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
|
||||
import { logCompanyEvent } from '$lib/server/audit.js';
|
||||
import { postBillsDue } from '$lib/server/recurring-bills/poster.js';
|
||||
import { computeNextDueDate, toIsoDate, type Cycle } from '$lib/server/recurring-bills/cycle.js';
|
||||
import { and, asc, eq, isNull } from 'drizzle-orm';
|
||||
|
||||
const CYCLES = ['weekly', 'monthly', 'quarterly', 'yearly'] as const;
|
||||
|
||||
function trimOrNull(v: FormDataEntryValue | null): string | null {
|
||||
const s = v?.toString().trim();
|
||||
return s ? s : null;
|
||||
}
|
||||
|
||||
function parseCycle(v: FormDataEntryValue | null): Cycle | null {
|
||||
const s = v?.toString();
|
||||
if (!s) return null;
|
||||
return (CYCLES as readonly string[]).includes(s) ? (s as Cycle) : null;
|
||||
}
|
||||
|
||||
function parseAmount(v: FormDataEntryValue | null): string | null {
|
||||
const s = trimOrNull(v);
|
||||
if (!s) return null;
|
||||
const n = Number(s);
|
||||
if (!Number.isFinite(n) || n < 0) return null;
|
||||
return n.toFixed(2);
|
||||
}
|
||||
|
||||
function parseInt0(v: FormDataEntryValue | null): number | null {
|
||||
const s = trimOrNull(v);
|
||||
if (!s) return null;
|
||||
const n = Number(s);
|
||||
if (!Number.isInteger(n)) return null;
|
||||
return n;
|
||||
}
|
||||
|
||||
function parseIsoDate(v: FormDataEntryValue | null): string | null {
|
||||
const s = trimOrNull(v);
|
||||
if (!s) return null;
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return null;
|
||||
const d = new Date(`${s}T00:00:00Z`);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return s;
|
||||
}
|
||||
|
||||
type BillFormFields = {
|
||||
name: string;
|
||||
amount: string;
|
||||
cycle: Cycle;
|
||||
accountId: string;
|
||||
projectId: string;
|
||||
categoryId: string | null;
|
||||
partyId: string | null;
|
||||
description: string | null;
|
||||
currency: string;
|
||||
startDate: string;
|
||||
endDate: string | null;
|
||||
dayOfCycle: number | null;
|
||||
};
|
||||
|
||||
function extractFields(fd: FormData): BillFormFields | string {
|
||||
const name = trimOrNull(fd.get('name'));
|
||||
const amount = parseAmount(fd.get('amount'));
|
||||
const cycle = parseCycle(fd.get('cycle'));
|
||||
const accountId = trimOrNull(fd.get('accountId'));
|
||||
const projectId = trimOrNull(fd.get('projectId'));
|
||||
const startDate = parseIsoDate(fd.get('startDate'));
|
||||
|
||||
if (!name) return 'Name is required';
|
||||
if (!amount) return 'Valid amount is required';
|
||||
if (!cycle) return 'Invalid cycle';
|
||||
if (!accountId) return 'Account is required';
|
||||
if (!projectId) return 'Project is required';
|
||||
if (!startDate) return 'Valid start date is required';
|
||||
|
||||
const currency = trimOrNull(fd.get('currency')) ?? 'THB';
|
||||
const endDate = parseIsoDate(fd.get('endDate'));
|
||||
const endDateRaw = trimOrNull(fd.get('endDate'));
|
||||
if (endDateRaw && !endDate) return 'Invalid end date';
|
||||
|
||||
const dayOfCycle = parseInt0(fd.get('dayOfCycle'));
|
||||
if (fd.get('dayOfCycle') && dayOfCycle === null) return 'Invalid day of cycle';
|
||||
if (dayOfCycle !== null) {
|
||||
if (cycle === 'weekly' && (dayOfCycle < 0 || dayOfCycle > 6)) {
|
||||
return 'Weekly day must be 0 (Sun) – 6 (Sat)';
|
||||
}
|
||||
if (cycle !== 'weekly' && (dayOfCycle < 1 || dayOfCycle > 31)) {
|
||||
return 'Day of cycle must be 1 – 31';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
amount,
|
||||
cycle,
|
||||
accountId,
|
||||
projectId,
|
||||
categoryId: trimOrNull(fd.get('categoryId')),
|
||||
partyId: trimOrNull(fd.get('partyId')),
|
||||
description: trimOrNull(fd.get('description')),
|
||||
currency,
|
||||
startDate,
|
||||
endDate,
|
||||
dayOfCycle
|
||||
};
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, params, parent }) => {
|
||||
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
|
||||
await parent();
|
||||
|
||||
const [billRows, accountRows, projectRows, categoryRows, partyRows] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: recurringBills.id,
|
||||
name: recurringBills.name,
|
||||
description: recurringBills.description,
|
||||
cycle: recurringBills.cycle,
|
||||
defaultAmount: recurringBills.defaultAmount,
|
||||
nextCycleAmount: recurringBills.nextCycleAmount,
|
||||
currency: recurringBills.currency,
|
||||
dayOfCycle: recurringBills.dayOfCycle,
|
||||
startDate: recurringBills.startDate,
|
||||
endDate: recurringBills.endDate,
|
||||
nextDueDate: recurringBills.nextDueDate,
|
||||
lastPostedDate: recurringBills.lastPostedDate,
|
||||
status: recurringBills.status,
|
||||
pausedAt: recurringBills.pausedAt,
|
||||
skipNext: recurringBills.skipNext,
|
||||
accountId: recurringBills.accountId,
|
||||
accountName: companyAccounts.name,
|
||||
projectId: recurringBills.projectId,
|
||||
projectName: projects.name,
|
||||
categoryId: recurringBills.categoryId,
|
||||
categoryName: categories.name,
|
||||
partyId: recurringBills.partyId,
|
||||
partyName: parties.name,
|
||||
createdAt: recurringBills.createdAt,
|
||||
updatedAt: recurringBills.updatedAt
|
||||
})
|
||||
.from(recurringBills)
|
||||
.leftJoin(companyAccounts, eq(recurringBills.accountId, companyAccounts.id))
|
||||
.leftJoin(projects, eq(recurringBills.projectId, projects.id))
|
||||
.leftJoin(categories, eq(recurringBills.categoryId, categories.id))
|
||||
.leftJoin(parties, eq(recurringBills.partyId, parties.id))
|
||||
.where(
|
||||
and(eq(recurringBills.companyId, params.companyId), isNull(recurringBills.deletedAt))
|
||||
)
|
||||
.orderBy(asc(recurringBills.status), asc(recurringBills.nextDueDate)),
|
||||
|
||||
db
|
||||
.select({
|
||||
id: companyAccounts.id,
|
||||
name: companyAccounts.name,
|
||||
currency: companyAccounts.currency,
|
||||
accountType: companyAccounts.accountType
|
||||
})
|
||||
.from(companyAccounts)
|
||||
.where(
|
||||
and(
|
||||
eq(companyAccounts.companyId, params.companyId),
|
||||
isNull(companyAccounts.deletedAt),
|
||||
eq(companyAccounts.isArchived, false)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(companyAccounts.name)),
|
||||
|
||||
db
|
||||
.select({ id: projects.id, name: projects.name })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.companyId, params.companyId), eq(projects.isActive, true)))
|
||||
.orderBy(asc(projects.name)),
|
||||
|
||||
db
|
||||
.select({ id: categories.id, name: categories.name })
|
||||
.from(categories)
|
||||
.where(eq(categories.companyId, params.companyId))
|
||||
.orderBy(asc(categories.name)),
|
||||
|
||||
db
|
||||
.select({ id: parties.id, name: parties.name })
|
||||
.from(parties)
|
||||
.where(and(eq(parties.companyId, params.companyId), isNull(parties.deletedAt)))
|
||||
.orderBy(asc(parties.name))
|
||||
]);
|
||||
|
||||
return {
|
||||
bills: billRows,
|
||||
accounts: accountRows,
|
||||
projects: projectRows,
|
||||
categories: categoryRows,
|
||||
parties: partyRows
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
createBill: async ({ request, locals, params }) => {
|
||||
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
|
||||
'admin',
|
||||
'manager',
|
||||
'accountant'
|
||||
]);
|
||||
const fd = await request.formData();
|
||||
const parsed = extractFields(fd);
|
||||
if (typeof parsed === 'string') {
|
||||
return fail(400, { action: 'createBill', error: parsed });
|
||||
}
|
||||
|
||||
// Verify account + project belong to this company
|
||||
const [acct] = await db
|
||||
.select({ id: companyAccounts.id })
|
||||
.from(companyAccounts)
|
||||
.where(
|
||||
and(
|
||||
eq(companyAccounts.id, parsed.accountId),
|
||||
eq(companyAccounts.companyId, params.companyId),
|
||||
isNull(companyAccounts.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (!acct) return fail(400, { action: 'createBill', error: 'Account not found' });
|
||||
|
||||
const [proj] = await db
|
||||
.select({ id: projects.id })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.id, parsed.projectId), eq(projects.companyId, params.companyId)))
|
||||
.limit(1);
|
||||
if (!proj) return fail(400, { action: 'createBill', error: 'Project not found' });
|
||||
|
||||
const dayOfCycle =
|
||||
parsed.dayOfCycle ??
|
||||
(parsed.cycle === 'weekly'
|
||||
? new Date(`${parsed.startDate}T00:00:00Z`).getUTCDay()
|
||||
: new Date(`${parsed.startDate}T00:00:00Z`).getUTCDate());
|
||||
|
||||
const nextDueDate = toIsoDate(
|
||||
computeNextDueDate(
|
||||
parsed.startDate,
|
||||
parsed.cycle,
|
||||
dayOfCycle,
|
||||
new Date(`${parsed.startDate}T00:00:00Z`)
|
||||
)
|
||||
);
|
||||
|
||||
const [inserted] = await db
|
||||
.insert(recurringBills)
|
||||
.values({
|
||||
companyId: params.companyId,
|
||||
projectId: parsed.projectId,
|
||||
accountId: parsed.accountId,
|
||||
categoryId: parsed.categoryId,
|
||||
partyId: parsed.partyId,
|
||||
name: parsed.name,
|
||||
description: parsed.description,
|
||||
cycle: parsed.cycle,
|
||||
defaultAmount: parsed.amount,
|
||||
currency: parsed.currency,
|
||||
dayOfCycle,
|
||||
startDate: parsed.startDate,
|
||||
endDate: parsed.endDate,
|
||||
nextDueDate,
|
||||
status: 'active',
|
||||
createdBy: user.id
|
||||
})
|
||||
.returning({ id: recurringBills.id });
|
||||
|
||||
await logCompanyEvent(
|
||||
params.companyId,
|
||||
user.id,
|
||||
'recurring_bill_created',
|
||||
`Recurring bill "${parsed.name}" created (${parsed.cycle}, ${parsed.amount} ${parsed.currency})`,
|
||||
{ billId: inserted.id, cycle: parsed.cycle, amount: parsed.amount }
|
||||
);
|
||||
|
||||
return { success: true, action: 'createBill' };
|
||||
},
|
||||
|
||||
updateBill: async ({ request, locals, params }) => {
|
||||
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
|
||||
'admin',
|
||||
'manager',
|
||||
'accountant'
|
||||
]);
|
||||
const fd = await request.formData();
|
||||
const id = trimOrNull(fd.get('id'));
|
||||
if (!id) return fail(400, { action: 'updateBill', error: 'Bill id is required' });
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(recurringBills)
|
||||
.where(
|
||||
and(
|
||||
eq(recurringBills.id, id),
|
||||
eq(recurringBills.companyId, params.companyId),
|
||||
isNull(recurringBills.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (!existing) error(404, 'Bill not found');
|
||||
|
||||
const parsed = extractFields(fd);
|
||||
if (typeof parsed === 'string') {
|
||||
return fail(400, { action: 'updateBill', error: parsed });
|
||||
}
|
||||
|
||||
const dayOfCycle =
|
||||
parsed.dayOfCycle ??
|
||||
(parsed.cycle === 'weekly'
|
||||
? new Date(`${parsed.startDate}T00:00:00Z`).getUTCDay()
|
||||
: new Date(`${parsed.startDate}T00:00:00Z`).getUTCDate());
|
||||
|
||||
const scheduleChanged =
|
||||
existing.startDate !== parsed.startDate ||
|
||||
existing.cycle !== parsed.cycle ||
|
||||
existing.dayOfCycle !== dayOfCycle;
|
||||
|
||||
const nextDueDate =
|
||||
existing.lastPostedDate === null && scheduleChanged
|
||||
? toIsoDate(
|
||||
computeNextDueDate(
|
||||
parsed.startDate,
|
||||
parsed.cycle,
|
||||
dayOfCycle,
|
||||
new Date(`${parsed.startDate}T00:00:00Z`)
|
||||
)
|
||||
)
|
||||
: existing.nextDueDate;
|
||||
|
||||
await db
|
||||
.update(recurringBills)
|
||||
.set({
|
||||
projectId: parsed.projectId,
|
||||
accountId: parsed.accountId,
|
||||
categoryId: parsed.categoryId,
|
||||
partyId: parsed.partyId,
|
||||
name: parsed.name,
|
||||
description: parsed.description,
|
||||
cycle: parsed.cycle,
|
||||
defaultAmount: parsed.amount,
|
||||
currency: parsed.currency,
|
||||
dayOfCycle,
|
||||
startDate: parsed.startDate,
|
||||
endDate: parsed.endDate,
|
||||
nextDueDate,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(recurringBills.id, id));
|
||||
|
||||
await logCompanyEvent(
|
||||
params.companyId,
|
||||
user.id,
|
||||
'recurring_bill_updated',
|
||||
`Recurring bill "${parsed.name}" updated`,
|
||||
{ billId: id }
|
||||
);
|
||||
|
||||
return { success: true, action: 'updateBill' };
|
||||
},
|
||||
|
||||
deleteBill: async ({ request, locals, params }) => {
|
||||
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
|
||||
'admin',
|
||||
'manager',
|
||||
'accountant'
|
||||
]);
|
||||
const fd = await request.formData();
|
||||
const id = trimOrNull(fd.get('id'));
|
||||
if (!id) return fail(400, { action: 'deleteBill', error: 'Bill id is required' });
|
||||
|
||||
const [existing] = await db
|
||||
.select({ id: recurringBills.id, name: recurringBills.name })
|
||||
.from(recurringBills)
|
||||
.where(
|
||||
and(
|
||||
eq(recurringBills.id, id),
|
||||
eq(recurringBills.companyId, params.companyId),
|
||||
isNull(recurringBills.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (!existing) error(404, 'Bill not found');
|
||||
|
||||
await db
|
||||
.update(recurringBills)
|
||||
.set({ deletedAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(recurringBills.id, id));
|
||||
|
||||
await logCompanyEvent(
|
||||
params.companyId,
|
||||
user.id,
|
||||
'recurring_bill_deleted',
|
||||
`Recurring bill "${existing.name}" deleted`,
|
||||
{ billId: id }
|
||||
);
|
||||
|
||||
return { success: true, action: 'deleteBill' };
|
||||
},
|
||||
|
||||
pauseBill: async ({ request, locals, params }) => {
|
||||
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
|
||||
'admin',
|
||||
'manager',
|
||||
'accountant'
|
||||
]);
|
||||
const fd = await request.formData();
|
||||
const id = trimOrNull(fd.get('id'));
|
||||
if (!id) return fail(400, { action: 'pauseBill', error: 'Bill id is required' });
|
||||
|
||||
const result = await db
|
||||
.update(recurringBills)
|
||||
.set({ status: 'paused', pausedAt: new Date(), updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(recurringBills.id, id),
|
||||
eq(recurringBills.companyId, params.companyId),
|
||||
isNull(recurringBills.deletedAt)
|
||||
)
|
||||
)
|
||||
.returning({ name: recurringBills.name });
|
||||
|
||||
if (result.length === 0) error(404, 'Bill not found');
|
||||
|
||||
await logCompanyEvent(
|
||||
params.companyId,
|
||||
user.id,
|
||||
'recurring_bill_paused',
|
||||
`Recurring bill "${result[0].name}" paused`,
|
||||
{ billId: id }
|
||||
);
|
||||
|
||||
return { success: true, action: 'pauseBill' };
|
||||
},
|
||||
|
||||
resumeBill: async ({ request, locals, params }) => {
|
||||
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
|
||||
'admin',
|
||||
'manager',
|
||||
'accountant'
|
||||
]);
|
||||
const fd = await request.formData();
|
||||
const id = trimOrNull(fd.get('id'));
|
||||
if (!id) return fail(400, { action: 'resumeBill', error: 'Bill id is required' });
|
||||
|
||||
const result = await db
|
||||
.update(recurringBills)
|
||||
.set({ status: 'active', pausedAt: null, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(recurringBills.id, id),
|
||||
eq(recurringBills.companyId, params.companyId),
|
||||
isNull(recurringBills.deletedAt)
|
||||
)
|
||||
)
|
||||
.returning({ name: recurringBills.name });
|
||||
|
||||
if (result.length === 0) error(404, 'Bill not found');
|
||||
|
||||
await logCompanyEvent(
|
||||
params.companyId,
|
||||
user.id,
|
||||
'recurring_bill_resumed',
|
||||
`Recurring bill "${result[0].name}" resumed`,
|
||||
{ billId: id }
|
||||
);
|
||||
|
||||
return { success: true, action: 'resumeBill' };
|
||||
},
|
||||
|
||||
skipNextBill: async ({ request, locals, params }) => {
|
||||
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
|
||||
'admin',
|
||||
'manager',
|
||||
'accountant'
|
||||
]);
|
||||
const fd = await request.formData();
|
||||
const id = trimOrNull(fd.get('id'));
|
||||
if (!id) return fail(400, { action: 'skipNextBill', error: 'Bill id is required' });
|
||||
|
||||
const result = await db
|
||||
.update(recurringBills)
|
||||
.set({ skipNext: true, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(recurringBills.id, id),
|
||||
eq(recurringBills.companyId, params.companyId),
|
||||
isNull(recurringBills.deletedAt)
|
||||
)
|
||||
)
|
||||
.returning({ name: recurringBills.name, nextDueDate: recurringBills.nextDueDate });
|
||||
|
||||
if (result.length === 0) error(404, 'Bill not found');
|
||||
|
||||
await logCompanyEvent(
|
||||
params.companyId,
|
||||
user.id,
|
||||
'recurring_bill_skipped',
|
||||
`Next cycle (${result[0].nextDueDate}) of "${result[0].name}" will be skipped`,
|
||||
{ billId: id, intent: true }
|
||||
);
|
||||
|
||||
return { success: true, action: 'skipNextBill' };
|
||||
},
|
||||
|
||||
setNextCycleAmount: async ({ request, locals, params }) => {
|
||||
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
|
||||
const fd = await request.formData();
|
||||
const id = trimOrNull(fd.get('id'));
|
||||
if (!id) return fail(400, { action: 'setNextCycleAmount', error: 'Bill id is required' });
|
||||
|
||||
const amountRaw = fd.get('amount');
|
||||
const cleared = amountRaw === null || amountRaw === '';
|
||||
const amount = cleared ? null : parseAmount(amountRaw);
|
||||
if (!cleared && !amount) return fail(400, { action: 'setNextCycleAmount', error: 'Valid amount required' });
|
||||
|
||||
await db
|
||||
.update(recurringBills)
|
||||
.set({ nextCycleAmount: amount, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(recurringBills.id, id),
|
||||
eq(recurringBills.companyId, params.companyId),
|
||||
isNull(recurringBills.deletedAt)
|
||||
)
|
||||
);
|
||||
|
||||
return { success: true, action: 'setNextCycleAmount' };
|
||||
},
|
||||
|
||||
runBillsNow: async ({ locals, params }) => {
|
||||
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
|
||||
const result = await postBillsDue(params.companyId, new Date());
|
||||
return {
|
||||
success: true,
|
||||
action: 'runBillsNow',
|
||||
postedCount: result.postedCount,
|
||||
skippedCount: result.skippedCount,
|
||||
errorCount: result.errors.length,
|
||||
errors: result.errors.map((e) => `${e.billId}: ${e.error}`)
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,572 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
import { formatDate } from '$lib/utils/date.js';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
type StatusFilter = 'all' | 'active' | 'paused' | 'ended';
|
||||
let statusFilter = $state<StatusFilter>('all');
|
||||
let showAddForm = $state(false);
|
||||
let editingBillId = $state<string | null>(null);
|
||||
let confirmDeleteId = $state<string | null>(null);
|
||||
|
||||
const CYCLE_LABELS: Record<string, string> = {
|
||||
weekly: 'Weekly',
|
||||
monthly: 'Monthly',
|
||||
quarterly: 'Quarterly',
|
||||
yearly: 'Yearly'
|
||||
};
|
||||
|
||||
const STATUS_BADGE: Record<string, string> = {
|
||||
active: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
|
||||
paused: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
|
||||
ended: 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||||
};
|
||||
|
||||
const todayIso = new Date().toISOString().slice(0, 10);
|
||||
|
||||
const filteredBills = $derived(
|
||||
statusFilter === 'all' ? data.bills : data.bills.filter((b) => b.status === statusFilter)
|
||||
);
|
||||
|
||||
function isOverdue(bill: (typeof data.bills)[number]): boolean {
|
||||
return bill.status === 'active' && bill.nextDueDate < todayIso;
|
||||
}
|
||||
|
||||
function formatAmount(value: string, currency: string): string {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return value;
|
||||
return `${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${currency}`;
|
||||
}
|
||||
|
||||
function startEdit(id: string) {
|
||||
editingBillId = id;
|
||||
confirmDeleteId = null;
|
||||
showAddForm = false;
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
showAddForm = !showAddForm;
|
||||
editingBillId = null;
|
||||
confirmDeleteId = null;
|
||||
}
|
||||
|
||||
const inputCls =
|
||||
'w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white';
|
||||
const labelCls = 'mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Bills - {data.company.name}</title>
|
||||
</svelte:head>
|
||||
|
||||
{#snippet billForm(
|
||||
action: string,
|
||||
values: {
|
||||
name?: string;
|
||||
amount?: string;
|
||||
cycle?: string;
|
||||
accountId?: string;
|
||||
projectId?: string;
|
||||
categoryId?: string;
|
||||
partyId?: string;
|
||||
description?: string;
|
||||
currency?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
dayOfCycle?: string;
|
||||
} = {},
|
||||
billId?: string
|
||||
)}
|
||||
<form
|
||||
method="POST"
|
||||
{action}
|
||||
use:enhance={() => async ({ result, update, formElement }) => {
|
||||
await update({ reset: false });
|
||||
if (result.type === 'success') {
|
||||
if (!billId) {
|
||||
showAddForm = false;
|
||||
formElement.reset();
|
||||
} else {
|
||||
editingBillId = null;
|
||||
}
|
||||
}
|
||||
}}
|
||||
class="mt-4 grid grid-cols-1 gap-3 rounded-md bg-gray-50 p-4 dark:bg-gray-700/50 md:grid-cols-2"
|
||||
>
|
||||
{#if billId}
|
||||
<input type="hidden" name="id" value={billId} />
|
||||
{/if}
|
||||
<div class="md:col-span-2">
|
||||
<label class={labelCls} for="bill-name-{billId ?? 'new'}">Name <span class="text-red-500">*</span></label>
|
||||
<input
|
||||
id="bill-name-{billId ?? 'new'}"
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
value={values.name ?? ''}
|
||||
class={inputCls}
|
||||
placeholder="e.g. Office rent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelCls} for="bill-amount-{billId ?? 'new'}">Amount <span class="text-red-500">*</span></label>
|
||||
<input
|
||||
id="bill-amount-{billId ?? 'new'}"
|
||||
name="amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
required
|
||||
value={values.amount ?? ''}
|
||||
class={inputCls}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelCls} for="bill-currency-{billId ?? 'new'}">Currency</label>
|
||||
<input
|
||||
id="bill-currency-{billId ?? 'new'}"
|
||||
name="currency"
|
||||
type="text"
|
||||
value={values.currency ?? 'THB'}
|
||||
class={inputCls}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelCls} for="bill-cycle-{billId ?? 'new'}">Cycle <span class="text-red-500">*</span></label>
|
||||
<select id="bill-cycle-{billId ?? 'new'}" name="cycle" required value={values.cycle ?? 'monthly'} class={inputCls}>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="quarterly">Quarterly</option>
|
||||
<option value="yearly">Yearly</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelCls} for="bill-dayOfCycle-{billId ?? 'new'}">Day of cycle</label>
|
||||
<input
|
||||
id="bill-dayOfCycle-{billId ?? 'new'}"
|
||||
name="dayOfCycle"
|
||||
type="number"
|
||||
value={values.dayOfCycle ?? ''}
|
||||
class={inputCls}
|
||||
placeholder="0–6 weekly · 1–31 monthly/qtr/yr"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelCls} for="bill-account-{billId ?? 'new'}">Account <span class="text-red-500">*</span></label>
|
||||
<select
|
||||
id="bill-account-{billId ?? 'new'}"
|
||||
name="accountId"
|
||||
required
|
||||
value={values.accountId ?? ''}
|
||||
class={inputCls}
|
||||
>
|
||||
<option value="" disabled>Select an account</option>
|
||||
{#each data.accounts as acct (acct.id)}
|
||||
<option value={acct.id}>{acct.name} ({acct.currency})</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelCls} for="bill-project-{billId ?? 'new'}">Project <span class="text-red-500">*</span></label>
|
||||
<select
|
||||
id="bill-project-{billId ?? 'new'}"
|
||||
name="projectId"
|
||||
required
|
||||
value={values.projectId ?? ''}
|
||||
class={inputCls}
|
||||
>
|
||||
<option value="" disabled>Select a project</option>
|
||||
{#each data.projects as proj (proj.id)}
|
||||
<option value={proj.id}>{proj.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelCls} for="bill-category-{billId ?? 'new'}">Category</label>
|
||||
<select
|
||||
id="bill-category-{billId ?? 'new'}"
|
||||
name="categoryId"
|
||||
value={values.categoryId ?? ''}
|
||||
class={inputCls}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{#each data.categories as cat (cat.id)}
|
||||
<option value={cat.id}>{cat.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelCls} for="bill-party-{billId ?? 'new'}">Vendor / Party</label>
|
||||
<select
|
||||
id="bill-party-{billId ?? 'new'}"
|
||||
name="partyId"
|
||||
value={values.partyId ?? ''}
|
||||
class={inputCls}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{#each data.parties as p (p.id)}
|
||||
<option value={p.id}>{p.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelCls} for="bill-start-{billId ?? 'new'}">Start date <span class="text-red-500">*</span></label>
|
||||
<input
|
||||
id="bill-start-{billId ?? 'new'}"
|
||||
name="startDate"
|
||||
type="date"
|
||||
required
|
||||
value={values.startDate ?? todayIso}
|
||||
class={inputCls}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelCls} for="bill-end-{billId ?? 'new'}">End date</label>
|
||||
<input
|
||||
id="bill-end-{billId ?? 'new'}"
|
||||
name="endDate"
|
||||
type="date"
|
||||
value={values.endDate ?? ''}
|
||||
class={inputCls}
|
||||
/>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class={labelCls} for="bill-desc-{billId ?? 'new'}">Description</label>
|
||||
<textarea
|
||||
id="bill-desc-{billId ?? 'new'}"
|
||||
name="description"
|
||||
rows="2"
|
||||
class={inputCls}>{values.description ?? ''}</textarea
|
||||
>
|
||||
</div>
|
||||
<div class="md:col-span-2 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
if (billId) editingBillId = null;
|
||||
else showAddForm = false;
|
||||
}}
|
||||
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-600"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
{billId ? 'Save' : 'Create Bill'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/snippet}
|
||||
|
||||
<div class="space-y-6">
|
||||
<header class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Recurring Bills</h1>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Auto-post rent, utilities, SaaS, and other recurring expenses on their due date. Scheduler
|
||||
runs every 15 minutes; use "Run Now" to trigger an immediate pass.
|
||||
</p>
|
||||
</div>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/runBillsNow"
|
||||
use:enhance={() => async ({ update }) => {
|
||||
await update({ reset: false });
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||
>
|
||||
Run Now
|
||||
</button>
|
||||
</form>
|
||||
</header>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
||||
{form.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if form?.action === 'runBillsNow'}
|
||||
<div
|
||||
class="rounded-md border border-gray-200 bg-white p-3 text-sm dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<p class="font-medium text-gray-900 dark:text-white">
|
||||
Posted: {form.postedCount} · Skipped: {form.skippedCount} · Errors: {form.errorCount}
|
||||
</p>
|
||||
{#if form.errors && form.errors.length > 0}
|
||||
<ul class="mt-2 list-inside list-disc text-xs text-red-600 dark:text-red-400">
|
||||
{#each form.errors as err (err)}
|
||||
<li>{err}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<section
|
||||
class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="font-semibold text-gray-900 dark:text-white">Add Bill</h2>
|
||||
<button
|
||||
type="button"
|
||||
onclick={openAdd}
|
||||
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
{showAddForm ? 'Cancel' : '+ New Bill'}
|
||||
</button>
|
||||
</div>
|
||||
{#if showAddForm}
|
||||
{@render billForm('?/createBill', { currency: 'THB', startDate: todayIso, cycle: 'monthly' })}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each ['all', 'active', 'paused', 'ended'] as s (s)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (statusFilter = s as StatusFilter)}
|
||||
class="rounded-full px-3 py-1 text-xs font-medium {statusFilter === s
|
||||
? 'bg-gray-900 text-white dark:bg-white dark:text-gray-900'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'}"
|
||||
>
|
||||
{s.charAt(0).toUpperCase() + s.slice(1)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if filteredBills.length === 0}
|
||||
<div
|
||||
class="rounded-lg border border-dashed border-gray-300 bg-white p-10 text-center dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
No {statusFilter === 'all' ? '' : statusFilter} bills yet.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700/50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Name</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Amount</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Cycle</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Account</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Next Due</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Status</th>
|
||||
<th class="px-4 py-3 text-right font-semibold text-gray-700 dark:text-gray-300">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{#each filteredBills as bill (bill.id)}
|
||||
<tr class="align-top">
|
||||
<td class="px-4 py-3">
|
||||
<div class="font-medium text-gray-900 dark:text-white">{bill.name}</div>
|
||||
{#if bill.projectName}
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Project: {bill.projectName}
|
||||
</div>
|
||||
{/if}
|
||||
{#if bill.partyName}
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Vendor: {bill.partyName}
|
||||
</div>
|
||||
{/if}
|
||||
{#if bill.status === 'paused' && bill.pausedAt}
|
||||
<div class="text-xs text-amber-600 dark:text-amber-400">
|
||||
Paused since {formatDate(bill.pausedAt)}
|
||||
</div>
|
||||
{/if}
|
||||
{#if bill.skipNext}
|
||||
<div class="text-xs text-blue-600 dark:text-blue-400">Next cycle will be skipped</div>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-900 dark:text-white">
|
||||
{formatAmount(bill.defaultAmount, bill.currency)}
|
||||
{#if bill.nextCycleAmount}
|
||||
<div class="text-xs text-blue-600 dark:text-blue-400">
|
||||
Override: {formatAmount(bill.nextCycleAmount, bill.currency)}
|
||||
</div>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-600 dark:text-gray-300">
|
||||
{CYCLE_LABELS[bill.cycle] ?? bill.cycle}
|
||||
{#if bill.dayOfCycle !== null}
|
||||
<div class="text-xs text-gray-400">day {bill.dayOfCycle}</div>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-600 dark:text-gray-300">
|
||||
{bill.accountName ?? '—'}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-900 dark:text-white">
|
||||
<span class={isOverdue(bill) ? 'font-medium text-red-600 dark:text-red-400' : ''}>
|
||||
{bill.nextDueDate}
|
||||
</span>
|
||||
{#if isOverdue(bill)}
|
||||
<div class="text-xs text-red-500">Overdue</div>
|
||||
{/if}
|
||||
{#if bill.lastPostedDate}
|
||||
<div class="text-xs text-gray-400">
|
||||
Last: {bill.lastPostedDate}
|
||||
</div>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-medium {STATUS_BADGE[bill.status] ??
|
||||
'bg-gray-100 text-gray-700'}"
|
||||
>
|
||||
{bill.status}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right text-xs">
|
||||
<div class="flex flex-wrap justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => startEdit(bill.id)}
|
||||
class="font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
{#if bill.status === 'active'}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/pauseBill"
|
||||
use:enhance={() => async ({ update }) => await update({ reset: false })}
|
||||
>
|
||||
<input type="hidden" name="id" value={bill.id} />
|
||||
<button type="submit" class="font-medium text-amber-600 hover:text-amber-700 dark:text-amber-400">
|
||||
Pause
|
||||
</button>
|
||||
</form>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/skipNextBill"
|
||||
use:enhance={() => async ({ update }) => await update({ reset: false })}
|
||||
>
|
||||
<input type="hidden" name="id" value={bill.id} />
|
||||
<button type="submit" class="font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400">
|
||||
Skip next
|
||||
</button>
|
||||
</form>
|
||||
{:else if bill.status === 'paused'}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/resumeBill"
|
||||
use:enhance={() => async ({ update }) => await update({ reset: false })}
|
||||
>
|
||||
<input type="hidden" name="id" value={bill.id} />
|
||||
<button type="submit" class="font-medium text-emerald-600 hover:text-emerald-700 dark:text-emerald-400">
|
||||
Resume
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (confirmDeleteId = confirmDeleteId === bill.id ? null : bill.id)}
|
||||
class="font-medium text-red-600 hover:text-red-700 dark:text-red-400"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{#if confirmDeleteId === bill.id}
|
||||
<tr>
|
||||
<td colspan="7" class="bg-red-50 px-4 py-3 dark:bg-red-900/20">
|
||||
<form
|
||||
method="POST"
|
||||
action="?/deleteBill"
|
||||
use:enhance={() => async ({ update }) => {
|
||||
await update({ reset: false });
|
||||
confirmDeleteId = null;
|
||||
}}
|
||||
class="flex items-center justify-between gap-3 text-xs"
|
||||
>
|
||||
<input type="hidden" name="id" value={bill.id} />
|
||||
<p class="text-red-700 dark:text-red-300">
|
||||
Delete "{bill.name}"? This soft-deletes the bill; already-posted expenses remain.
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (confirmDeleteId = null)}
|
||||
class="rounded border border-gray-300 bg-white px-2 py-1 text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded bg-red-600 px-2 py-1 font-medium text-white hover:bg-red-700"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{#if editingBillId === bill.id}
|
||||
<tr>
|
||||
<td colspan="7" class="bg-gray-50 px-4 py-3 dark:bg-gray-700/30">
|
||||
{@render billForm(
|
||||
'?/updateBill',
|
||||
{
|
||||
name: bill.name,
|
||||
amount: bill.defaultAmount,
|
||||
cycle: bill.cycle,
|
||||
accountId: bill.accountId,
|
||||
projectId: bill.projectId,
|
||||
categoryId: bill.categoryId ?? '',
|
||||
partyId: bill.partyId ?? '',
|
||||
description: bill.description ?? '',
|
||||
currency: bill.currency,
|
||||
startDate: bill.startDate,
|
||||
endDate: bill.endDate ?? '',
|
||||
dayOfCycle: bill.dayOfCycle?.toString() ?? ''
|
||||
},
|
||||
bill.id
|
||||
)}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/setNextCycleAmount"
|
||||
use:enhance={() => async ({ update }) => await update({ reset: false })}
|
||||
class="mt-3 flex flex-wrap items-end gap-2 rounded-md bg-white p-3 text-xs dark:bg-gray-800"
|
||||
>
|
||||
<input type="hidden" name="id" value={bill.id} />
|
||||
<div>
|
||||
<label class={labelCls} for="override-{bill.id}">Next-cycle amount override</label>
|
||||
<input
|
||||
id="override-{bill.id}"
|
||||
name="amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={bill.nextCycleAmount ?? ''}
|
||||
class={inputCls}
|
||||
placeholder="Leave blank to clear"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-gray-700 px-3 py-2 font-medium text-white hover:bg-gray-800 dark:bg-gray-600"
|
||||
>
|
||||
Save override
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user