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:
@@ -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(
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user