Add recurring bills schema and cycle math helper

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-16 15:13:38 +07:00
parent 70bb5954a0
commit bd87cd09f5
2 changed files with 157 additions and 1 deletions
+63 -1
View File
@@ -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( export const companyAddresses = pgTable(
'company_addresses', 'company_addresses',
{ {
@@ -1037,7 +1092,14 @@ export const companyLogEventEnum = pgEnum('company_log_event', [
'account_deleted', 'account_deleted',
'account_transaction_added', 'account_transaction_added',
'account_transfer_posted', '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( export const companyLog = pgTable(
+94
View File
@@ -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}`;
}