diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index 7d03f83..548f4f3 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -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( diff --git a/src/lib/server/recurring-bills/cycle.ts b/src/lib/server/recurring-bills/cycle.ts new file mode 100644 index 0000000..e9451dd --- /dev/null +++ b/src/lib/server/recurring-bills/cycle.ts @@ -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}`; +}