|
- {item.date?.toLocaleDateString() ?? "—"}
+ {item.date ? formatCalendarDate(item.date) : "—"}
|
{item.description}
|
- {item.quantity} |
+
+ {item.quantity}
+ |
{item.rate.toLocaleString("en-US", {
style: "currency",
diff --git a/apps/web/src/components/time-clock/time-entries-history.tsx b/apps/web/src/components/time-clock/time-entries-history.tsx
index 736c566..7b9f32e 100644
--- a/apps/web/src/components/time-clock/time-entries-history.tsx
+++ b/apps/web/src/components/time-clock/time-entries-history.tsx
@@ -14,6 +14,7 @@ import type { TimeEntryListItem } from "~/lib/time-entry-display";
export function TimeEntriesHistory() {
const { data: entries, isLoading } = api.timeEntries.getAll.useQuery();
+ const { data: profile } = api.settings.getProfile.useQuery();
const [editEntryId, setEditEntryId] = useState(null);
const completedEntries = useMemo(
@@ -22,8 +23,8 @@ export function TimeEntriesHistory() {
);
const grouped = useMemo(
- () => groupEntriesByDate(completedEntries),
- [completedEntries],
+ () => groupEntriesByDate(completedEntries, profile?.timeZone),
+ [completedEntries, profile?.timeZone],
);
if (isLoading) {
diff --git a/apps/web/src/components/ui/date-picker.tsx b/apps/web/src/components/ui/date-picker.tsx
index 19b7529..4a2812d 100644
--- a/apps/web/src/components/ui/date-picker.tsx
+++ b/apps/web/src/components/ui/date-picker.tsx
@@ -13,6 +13,11 @@ import {
PopoverTrigger,
} from "~/components/ui/popover";
import { cn } from "~/lib/utils";
+import {
+ calendarDateFromLocalDate,
+ calendarDateToLocalDate,
+ formatCalendarDate,
+} from "@beenvoice/domain/time-zone";
const DATE_FORMAT_OPTIONS: Intl.DateTimeFormatOptions = {
day: "2-digit",
@@ -25,7 +30,7 @@ function formatDate(date: Date | undefined) {
return "";
}
- return date.toLocaleDateString("en-US", DATE_FORMAT_OPTIONS);
+ return formatCalendarDate(date, DATE_FORMAT_OPTIONS);
}
// Longest month name in en-US long format (September 30, 2026).
@@ -54,7 +59,9 @@ export function DatePicker({
}: DatePickerProps) {
const [open, setOpen] = React.useState(false);
const [value, setValue] = React.useState(formatDate(date));
- const [month, setMonth] = React.useState(date);
+ const [month, setMonth] = React.useState(
+ date ? calendarDateToLocalDate(date) : undefined,
+ );
const sizeClasses = {
sm: "h-9 text-xs",
@@ -67,7 +74,7 @@ export function DatePicker({
React.useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Keep text input and calendar month synchronized with the controlled date prop.
setValue(formatDate(date));
- setMonth(date);
+ setMonth(date ? calendarDateToLocalDate(date) : undefined);
}, [date]);
return (
@@ -81,7 +88,7 @@ export function DatePicker({
{
- onDateChange(selectedDate);
- setValue(formatDate(selectedDate));
+ const calendarDate = selectedDate
+ ? calendarDateFromLocalDate(selectedDate)
+ : undefined;
+ onDateChange(calendarDate);
+ setValue(formatDate(calendarDate));
setOpen(false);
}}
/>
diff --git a/apps/web/src/lib/draft-invoice.ts b/apps/web/src/lib/draft-invoice.ts
index e720734..aa7548d 100644
--- a/apps/web/src/lib/draft-invoice.ts
+++ b/apps/web/src/lib/draft-invoice.ts
@@ -1,3 +1,5 @@
+import { addCalendarDays } from "@beenvoice/domain/time-zone";
+
/** Default invoice number format (matches web/mobile create forms). */
export function generateInvoiceNumber(now = new Date()): string {
const date = [
@@ -10,7 +12,5 @@ export function generateInvoiceNumber(now = new Date()): string {
}
export function defaultDueDate(issueDate: Date): Date {
- const due = new Date(issueDate);
- due.setDate(due.getDate() + 30);
- return due;
+ return addCalendarDays(issueDate, 30);
}
diff --git a/apps/web/src/lib/email-templates/invoice-email.ts b/apps/web/src/lib/email-templates/invoice-email.ts
index 161459b..4abfa55 100644
--- a/apps/web/src/lib/email-templates/invoice-email.ts
+++ b/apps/web/src/lib/email-templates/invoice-email.ts
@@ -1,11 +1,19 @@
import { getAppUrl } from "~/lib/app-url";
+import { formatCalendarDate } from "@beenvoice/domain/time-zone";
// Most email clients render fine for PNG/JPEG but are inconsistent
// with SVG (Outlook and several webmail clients strip or refuse it), so
// non-raster logos are requested through the same on-the-fly PNG
// rasterization the PDF export uses.
function resolveEmailLogoUrl(
- business: { id?: string; logoStorageKey?: string | null; logoMimeType?: string | null } | null | undefined,
+ business:
+ | {
+ id?: string;
+ logoStorageKey?: string | null;
+ logoMimeType?: string | null;
+ }
+ | null
+ | undefined,
baseUrl: string,
): string | null {
if (!business?.id || !business.logoStorageKey) return null;
@@ -57,6 +65,7 @@ interface InvoiceEmailTemplateProps {
userName?: string;
userEmail?: string;
baseUrl?: string;
+ timeZone?: string;
}
export function generateInvoiceEmailTemplate({
@@ -66,13 +75,14 @@ export function generateInvoiceEmailTemplate({
userName,
userEmail,
baseUrl = getAppUrl(),
+ timeZone = "America/New_York",
}: InvoiceEmailTemplateProps): { html: string; text: string } {
const formatDate = (date: Date) => {
- return new Intl.DateTimeFormat("en-US", {
+ return formatCalendarDate(date, {
year: "numeric",
month: "long",
day: "numeric",
- }).format(new Date(date));
+ });
};
const formatCurrency = (amount: number) => {
@@ -83,7 +93,13 @@ export function generateInvoiceEmailTemplate({
};
const getTimeOfDayGreeting = () => {
- const hour = new Date().getHours();
+ const hour = Number(
+ new Intl.DateTimeFormat("en-US", {
+ timeZone,
+ hour: "numeric",
+ hourCycle: "h23",
+ }).format(new Date()),
+ );
if (hour < 12) return "Good morning";
if (hour < 17) return "Good afternoon";
return "Good evening";
diff --git a/apps/web/src/lib/email-templates/reminder-email.ts b/apps/web/src/lib/email-templates/reminder-email.ts
index 0a6a48a..9ae0c9e 100644
--- a/apps/web/src/lib/email-templates/reminder-email.ts
+++ b/apps/web/src/lib/email-templates/reminder-email.ts
@@ -1,3 +1,8 @@
+import {
+ formatCalendarDate,
+ getEffectiveInvoiceStatus,
+} from "@beenvoice/domain";
+
interface ReminderEmailTemplateProps {
invoice: {
invoiceNumber: string;
@@ -15,6 +20,7 @@ interface ReminderEmailTemplateProps {
customMessage?: string;
userName?: string;
userEmail?: string;
+ timeZone?: string;
}
export function generateReminderEmailTemplate({
@@ -22,11 +28,18 @@ export function generateReminderEmailTemplate({
customMessage,
userName,
userEmail,
-}: ReminderEmailTemplateProps): { html: string; text: string; subject: string } {
+ timeZone = "America/New_York",
+}: ReminderEmailTemplateProps): {
+ html: string;
+ text: string;
+ subject: string;
+} {
const formatDate = (date: Date) =>
- new Intl.DateTimeFormat("en-US", { year: "numeric", month: "long", day: "numeric" }).format(
- new Date(date),
- );
+ formatCalendarDate(date, {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ });
const formatCurrency = (amount: number) =>
new Intl.NumberFormat("en-US", {
@@ -34,14 +47,14 @@ export function generateReminderEmailTemplate({
currency: invoice.currency ?? "USD",
}).format(amount);
- const senderName =
- invoice.business?.name
- ? invoice.business.nickname
- ? `${invoice.business.name} (${invoice.business.nickname})`
- : invoice.business.name
- : userName ?? "Your service provider";
+ const senderName = invoice.business?.name
+ ? invoice.business.nickname
+ ? `${invoice.business.name} (${invoice.business.nickname})`
+ : invoice.business.name
+ : (userName ?? "Your service provider");
- const isOverdue = new Date(invoice.dueDate) < new Date();
+ const isOverdue =
+ getEffectiveInvoiceStatus("sent", invoice.dueDate, timeZone) === "overdue";
const subject = `Payment Reminder: Invoice ${invoice.invoiceNumber} — ${formatCurrency(invoice.totalAmount)}`;
diff --git a/apps/web/src/lib/invoice-import.ts b/apps/web/src/lib/invoice-import.ts
index 342f119..454fa5f 100644
--- a/apps/web/src/lib/invoice-import.ts
+++ b/apps/web/src/lib/invoice-import.ts
@@ -1,3 +1,8 @@
+import {
+ addCalendarDays,
+ calendarDateFromLocalDate,
+} from "@beenvoice/domain/time-zone";
+
export type ImportFormat = "csv" | "json";
export interface ImportItem {
@@ -86,8 +91,9 @@ export function parseFlexibleDate(dateStr: string): Date | undefined {
// ISO date (YYYY-MM-DD)
const isoMatch = /^(\d{4})-(\d{2})-(\d{2})/.exec(trimmed);
if (isoMatch) {
- const d = new Date(trimmed);
- if (!isNaN(d.getTime())) return d;
+ const key = `${isoMatch[1]}-${isoMatch[2]}-${isoMatch[3]}`;
+ const d = new Date(`${key}T12:00:00.000Z`);
+ if (!isNaN(d.getTime()) && d.toISOString().slice(0, 10) === key) return d;
}
// M/DD/YY or M/DD/YYYY
@@ -98,11 +104,11 @@ export function parseFlexibleDate(dateStr: string): Date | undefined {
let year = parseInt(slashParts[2] ?? "2000", 10);
if (year < 100) year += 2000;
const d = new Date(year, month, day);
- if (!isNaN(d.getTime())) return d;
+ if (!isNaN(d.getTime())) return calendarDateFromLocalDate(d);
}
const d = new Date(trimmed);
- if (!isNaN(d.getTime())) return d;
+ if (!isNaN(d.getTime())) return calendarDateFromLocalDate(d);
return undefined;
}
@@ -128,13 +134,11 @@ function deriveIssueDate(items: ImportItem[], fallback?: Date): Date {
if (itemDates.length > 0) {
return new Date(Math.max(...itemDates.map((d) => d.getTime())));
}
- return fallback ?? new Date();
+ return fallback ?? calendarDateFromLocalDate(new Date());
}
function defaultDueDate(issueDate: Date): Date {
- const due = new Date(issueDate);
- due.setDate(due.getDate() + 30);
- return due;
+ return addCalendarDays(issueDate, 30);
}
export function parseInvoiceCSV(
@@ -262,7 +266,9 @@ function normalizeJsonInvoice(raw: JsonInvoice, index: number): ImportInvoice {
const rate = item.rate ?? 0;
if (!description || description === "Imported item") {
- errors.push(`Invoice "${name}" item ${itemIdx + 1}: description required`);
+ errors.push(
+ `Invoice "${name}" item ${itemIdx + 1}: description required`,
+ );
}
if (quantity <= 0) {
errors.push(
@@ -356,7 +362,9 @@ export function parseInvoiceJSON(jsonText: string): ImportInvoice[] {
{
name: "JSON Import",
items: [],
- errors: ['No invoices found (expected { "invoices": [...] } or an array)'],
+ errors: [
+ 'No invoices found (expected { "invoices": [...] } or an array)',
+ ],
},
];
}
diff --git a/apps/web/src/lib/invoice-status.ts b/apps/web/src/lib/invoice-status.ts
index a282748..194b43b 100644
--- a/apps/web/src/lib/invoice-status.ts
+++ b/apps/web/src/lib/invoice-status.ts
@@ -13,22 +13,25 @@ import type {
export function getEffectiveInvoiceStatus(
storedStatus: StoredInvoiceStatus,
dueDate: Date | string,
+ timeZone?: string,
): EffectiveInvoiceStatus {
- return getSharedEffectiveInvoiceStatus(storedStatus, dueDate);
+ return getSharedEffectiveInvoiceStatus(storedStatus, dueDate, timeZone);
}
export function isInvoiceOverdue(
storedStatus: StoredInvoiceStatus,
dueDate: Date | string,
+ timeZone?: string,
): boolean {
- return isSharedInvoiceOverdue(storedStatus, dueDate);
+ return isSharedInvoiceOverdue(storedStatus, dueDate, timeZone);
}
export function getDaysPastDue(
storedStatus: StoredInvoiceStatus,
dueDate: Date | string,
+ timeZone?: string,
): number {
- return getSharedDaysPastDue(storedStatus, dueDate);
+ return getSharedDaysPastDue(storedStatus, dueDate, timeZone);
}
export const statusConfig = {
diff --git a/apps/web/src/lib/pdf-export.tsx b/apps/web/src/lib/pdf-export.tsx
index 9c772ec..c9466db 100644
--- a/apps/web/src/lib/pdf-export.tsx
+++ b/apps/web/src/lib/pdf-export.tsx
@@ -9,9 +9,8 @@ import {
type Styles,
} from "@react-pdf/renderer";
import { saveAs } from "file-saver";
-import {
- isFixedLineItem,
-} from "~/lib/invoice-line-item";
+import { formatCalendarDate } from "@beenvoice/domain/time-zone";
+import { isFixedLineItem } from "~/lib/invoice-line-item";
import React from "react";
import {
type PdfFontFamily,
@@ -136,10 +135,7 @@ function resolvePDFSettings(settings?: PDFGenerationSettings) {
return { ...defaultPDFSettings, ...settings };
}
-function mapLegacyPdfFont(
- fontFamily: string,
- fonts: ResolvedPdfFonts,
-): string {
+function mapLegacyPdfFont(fontFamily: string, fonts: ResolvedPdfFonts): string {
switch (fontFamily) {
case "Helvetica-Bold":
return fonts.bold;
@@ -177,9 +173,7 @@ type PdfStyleBundle = {
styles: typeof baseStyles;
minimalStyles: typeof baseMinimalStyles;
fonts: ResolvedPdfFonts;
- getStatusStyle: (
- status: string,
- ) => Array>;
+ getStatusStyle: (status: string) => Array>;
};
const pdfStyleCache = new Map();
@@ -816,7 +810,7 @@ const formatCurrency = (amount: number, currency = "USD") => {
};
const formatDate = (date: Date) => {
- return new Date(date).toLocaleDateString("en-US", {
+ return formatCalendarDate(date, {
year: "numeric",
month: "2-digit",
day: "2-digit",
diff --git a/apps/web/src/lib/time-entry-display.ts b/apps/web/src/lib/time-entry-display.ts
index 4b6f088..9df4d2d 100644
--- a/apps/web/src/lib/time-entry-display.ts
+++ b/apps/web/src/lib/time-entry-display.ts
@@ -1,3 +1,8 @@
+import {
+ DEFAULT_TIME_ZONE,
+ getZonedDateTimeParts,
+} from "@beenvoice/domain/time-zone";
+
export function invoiceLabel(inv: {
invoicePrefix: string | null;
invoiceNumber: string;
@@ -37,12 +42,13 @@ export type TimeEntryListItem = {
export function groupEntriesByDate(
entries: T[],
+ timeZone = DEFAULT_TIME_ZONE,
): { dateKey: string; label: string; entries: T[] }[] {
const groups = new Map();
for (const entry of entries) {
- const d = new Date(entry.startedAt);
- const dateKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
+ const parts = getZonedDateTimeParts(entry.startedAt, timeZone);
+ const dateKey = `${parts.year}-${String(parts.month).padStart(2, "0")}-${String(parts.day).padStart(2, "0")}`;
const existing = groups.get(dateKey);
if (existing) {
existing.push(entry);
@@ -58,6 +64,7 @@ export function groupEntriesByDate(
year: "numeric",
month: "long",
day: "numeric",
+ timeZone,
});
return { dateKey, label, entries: groupEntries };
});
diff --git a/apps/web/src/server/api/lib/time-entry-invoice-sync.ts b/apps/web/src/server/api/lib/time-entry-invoice-sync.ts
index 31d65ca..4601ff9 100644
--- a/apps/web/src/server/api/lib/time-entry-invoice-sync.ts
+++ b/apps/web/src/server/api/lib/time-entry-invoice-sync.ts
@@ -1,7 +1,8 @@
import { and, eq } from "drizzle-orm";
import type { db } from "~/server/db";
-import { invoiceItems, invoices, timeEntries } from "~/server/db/schema";
+import { invoiceItems, invoices, timeEntries, users } from "~/server/db/schema";
import { resolveBillingDescription } from "~/lib/time-clock";
+import { calendarDateFromInstant } from "@beenvoice/domain/time-zone";
type Db = typeof db;
@@ -110,6 +111,10 @@ export async function syncLinkedInvoiceItem(
const rate = entry.rate ?? 0;
const amount = hours * rate;
const description = resolveBillingDescription(entry.description ?? "");
+ const owner = await database.query.users.findFirst({
+ where: eq(users.id, linked.invoice.createdById),
+ columns: { timeZone: true },
+ });
await database
.update(invoiceItems)
@@ -118,7 +123,10 @@ export async function syncLinkedInvoiceItem(
hours,
rate,
amount,
- date: entry.endedAt ?? entry.startedAt,
+ date: calendarDateFromInstant(
+ entry.endedAt ?? entry.startedAt,
+ owner?.timeZone ?? "America/New_York",
+ ),
})
.where(eq(invoiceItems.id, linked.id));
@@ -136,7 +144,10 @@ export async function syncLinkedInvoiceItem(
.where(eq(invoices.id, linked.invoiceId));
}
-export async function removeLinkedInvoiceItem(database: Db, timeEntryId: string) {
+export async function removeLinkedInvoiceItem(
+ database: Db,
+ timeEntryId: string,
+) {
const linked = await findLinkedInvoiceItem(database, timeEntryId);
if (!linked?.invoice) return;
@@ -190,6 +201,10 @@ export async function relinkTimeEntryToInvoice(
});
if (!invoice) return null;
+ const owner = await database.query.users.findFirst({
+ where: eq(users.id, userId),
+ columns: { timeZone: true },
+ });
return insertInvoiceLineForTimeEntry(database, {
invoice,
@@ -197,6 +212,9 @@ export async function relinkTimeEntryToInvoice(
description: resolveBillingDescription(entry.description ?? ""),
hours: entry.hours,
rate: entry.rate ?? 0,
- date: entry.endedAt,
+ date: calendarDateFromInstant(
+ entry.endedAt,
+ owner?.timeZone ?? "America/New_York",
+ ),
});
}
diff --git a/apps/web/src/server/api/root.ts b/apps/web/src/server/api/root.ts
index a478913..82cf277 100644
--- a/apps/web/src/server/api/root.ts
+++ b/apps/web/src/server/api/root.ts
@@ -11,6 +11,7 @@ import { recurringInvoicesRouter } from "~/server/api/routers/recurring-invoices
import { apiKeysRouter } from "~/server/api/routers/apiKeys";
import { timeEntriesRouter } from "~/server/api/routers/time-entries";
import { adminRouter } from "~/server/api/routers/admin";
+import { notificationsRouter } from "~/server/api/routers/notifications";
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
export const appRouter = createTRPCRouter({
@@ -27,6 +28,7 @@ export const appRouter = createTRPCRouter({
apiKeys: apiKeysRouter,
timeEntries: timeEntriesRouter,
admin: adminRouter,
+ notifications: notificationsRouter,
});
// export type definition of API
diff --git a/apps/web/src/server/api/routers/dashboard.ts b/apps/web/src/server/api/routers/dashboard.ts
index beaa5b7..72f31d2 100644
--- a/apps/web/src/server/api/routers/dashboard.ts
+++ b/apps/web/src/server/api/routers/dashboard.ts
@@ -1,7 +1,11 @@
import { and, desc, eq, gte, lt } from "drizzle-orm";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
-import { clients, invoices } from "~/server/db/schema";
+import { clients, invoices, users } from "~/server/db/schema";
+import {
+ formatCalendarDate,
+ getZonedDateTimeParts,
+} from "@beenvoice/domain/time-zone";
import type { StoredInvoiceStatus } from "~/types/invoice";
type LiteInvoice = {
@@ -12,20 +16,28 @@ type LiteInvoice = {
issueDate: Date;
};
-function buildRevenueMonthKeys(now: Date, count: number) {
+function buildRevenueMonthKeys(now: Date, count: number, timeZone: string) {
+ const current = getZonedDateTimeParts(now, timeZone);
const keys: string[] = [];
for (let i = count - 1; i >= 0; i--) {
- const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
+ const d = new Date(Date.UTC(current.year, current.month - 1 - i, 1));
keys.push(
- `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`,
+ `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`,
);
}
return keys;
}
-function aggregateDashboardMetrics(userInvoices: LiteInvoice[], now: Date) {
- const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
- const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1);
+function aggregateDashboardMetrics(
+ userInvoices: LiteInvoice[],
+ now: Date,
+ timeZone: string,
+) {
+ const current = getZonedDateTimeParts(now, timeZone);
+ const currentMonthStart = new Date(
+ Date.UTC(current.year, current.month - 1, 1),
+ );
+ const lastMonthStart = new Date(Date.UTC(current.year, current.month - 2, 1));
let totalRevenue = 0;
let pendingAmount = 0;
@@ -34,7 +46,7 @@ function aggregateDashboardMetrics(userInvoices: LiteInvoice[], now: Date) {
let lastMonthRevenue = 0;
const revenueByMonth = Object.fromEntries(
- buildRevenueMonthKeys(now, 6).map((key) => [key, 0]),
+ buildRevenueMonthKeys(now, 6, timeZone).map((key) => [key, 0]),
) as Record;
const statusTotals: Record<
@@ -58,6 +70,7 @@ function aggregateDashboardMetrics(userInvoices: LiteInvoice[], now: Date) {
const effectiveStatus = getEffectiveInvoiceStatus(
inv.status as StoredInvoiceStatus,
inv.dueDate,
+ timeZone,
);
const amount = inv.totalAmount;
const issueDate = new Date(inv.issueDate);
@@ -67,14 +80,11 @@ function aggregateDashboardMetrics(userInvoices: LiteInvoice[], now: Date) {
if (issueDate >= currentMonthStart) {
currentMonthRevenue += amount;
- } else if (
- issueDate >= lastMonthStart &&
- issueDate < currentMonthStart
- ) {
+ } else if (issueDate >= lastMonthStart && issueDate < currentMonthStart) {
lastMonthRevenue += amount;
}
- const revenueKey = `${issueDate.getFullYear()}-${String(issueDate.getMonth() + 1).padStart(2, "0")}`;
+ const revenueKey = `${issueDate.getUTCFullYear()}-${String(issueDate.getUTCMonth() + 1).padStart(2, "0")}`;
const monthRevenue = revenueByMonth[revenueKey];
if (monthRevenue !== undefined) {
revenueByMonth[revenueKey] = monthRevenue + amount;
@@ -95,7 +105,7 @@ function aggregateDashboardMetrics(userInvoices: LiteInvoice[], now: Date) {
statusTotals[effectiveStatus].count += 1;
statusTotals[effectiveStatus].value += amount;
- const monthKey = `${issueDate.getFullYear()}-${String(issueDate.getMonth() + 1).padStart(2, "0")}`;
+ const monthKey = `${issueDate.getUTCFullYear()}-${String(issueDate.getUTCMonth() + 1).padStart(2, "0")}`;
monthlyTotals[monthKey] ??= {
month: monthKey,
totalInvoices: 0,
@@ -126,7 +136,7 @@ function aggregateDashboardMetrics(userInvoices: LiteInvoice[], now: Date) {
.map(([month, revenue]) => ({
month,
revenue,
- monthLabel: new Date(month + "-01").toLocaleDateString("en-US", {
+ monthLabel: formatCalendarDate(month + "-01", {
month: "short",
year: "2-digit",
}),
@@ -143,7 +153,7 @@ function aggregateDashboardMetrics(userInvoices: LiteInvoice[], now: Date) {
.slice(-6)
.map((item) => ({
...item,
- monthLabel: new Date(item.month + "-01").toLocaleDateString("en-US", {
+ monthLabel: formatCalendarDate(item.month + "-01", {
month: "short",
year: "2-digit",
}),
@@ -167,6 +177,12 @@ export const dashboardRouter = createTRPCRouter({
getStats: protectedProcedure.query(async ({ ctx }) => {
const userId = ctx.session.user.id;
const now = new Date();
+ const user = await ctx.db.query.users.findFirst({
+ where: eq(users.id, userId),
+ columns: { timeZone: true },
+ });
+ const timeZone = user?.timeZone ?? "America/New_York";
+ const current = getZonedDateTimeParts(now, timeZone);
const [
userInvoices,
@@ -203,8 +219,14 @@ export const dashboardRouter = createTRPCRouter({
ctx.db.query.invoices.findMany({
where: and(
eq(invoices.createdById, userId),
- gte(invoices.issueDate, new Date(now.getFullYear(), now.getMonth(), 1)),
- lt(invoices.issueDate, new Date(now.getFullYear(), now.getMonth() + 1, 1)),
+ gte(
+ invoices.issueDate,
+ new Date(Date.UTC(current.year, current.month - 1, 1)),
+ ),
+ lt(
+ invoices.issueDate,
+ new Date(Date.UTC(current.year, current.month, 1)),
+ ),
),
orderBy: [
desc(invoices.issueDate),
@@ -249,7 +271,7 @@ export const dashboardRouter = createTRPCRouter({
}),
]);
- const metrics = aggregateDashboardMetrics(userInvoices, now);
+ const metrics = aggregateDashboardMetrics(userInvoices, now, timeZone);
return {
...metrics,
diff --git a/apps/web/src/server/api/routers/invoices.ts b/apps/web/src/server/api/routers/invoices.ts
index 750d918..b052ca6 100644
--- a/apps/web/src/server/api/routers/invoices.ts
+++ b/apps/web/src/server/api/routers/invoices.ts
@@ -13,6 +13,8 @@ import {
clients,
businesses,
platformSettings,
+ users,
+ backgroundJobs,
} from "~/server/db/schema";
import { TRPCError } from "@trpc/server";
import { calculateLineItemAmount } from "~/lib/invoice-line-item";
@@ -22,6 +24,7 @@ import { defaultDueDate, generateInvoiceNumber } from "~/lib/draft-invoice";
import { generateReminderEmailTemplate } from "~/lib/email-templates/reminder-email";
import type { db } from "~/server/db";
import { resolveEmailSender } from "~/server/services/email-sender";
+import { jobTypes } from "~/server/jobs/queue";
type InvoiceRouterContext = {
db: typeof db;
@@ -249,6 +252,7 @@ export const invoicesRouter = createTRPCRouter({
return await ctx.db.query.invoices.findMany({
where: and(...conditions),
with: {
+ createdBy: { columns: { timeZone: true } },
business: true,
client: true,
items: {
@@ -347,6 +351,7 @@ export const invoicesRouter = createTRPCRouter({
const currentInvoice = await ctx.db.query.invoices.findFirst({
where: eq(invoices.createdById, ctx.session.user.id),
with: {
+ createdBy: { columns: { timeZone: true } },
business: true,
client: true,
items: {
@@ -386,6 +391,7 @@ export const invoicesRouter = createTRPCRouter({
const invoice = await ctx.db.query.invoices.findFirst({
where: eq(invoices.id, input.id),
with: {
+ createdBy: { columns: { timeZone: true } },
business: true,
client: true,
items: {
@@ -452,10 +458,16 @@ export const invoicesRouter = createTRPCRouter({
);
return await ctx.db.transaction(async (tx) => {
+ const invoiceId = crypto.randomUUID();
+ const sendReminderJobId = cleanInvoiceData.sendReminderAt
+ ? crypto.randomUUID()
+ : null;
const [invoice] = await tx
.insert(invoices)
.values({
+ id: invoiceId,
...cleanInvoiceData,
+ sendReminderJobId,
totalAmount,
createdById: ctx.session.user.id,
})
@@ -479,6 +491,17 @@ export const invoicesRouter = createTRPCRouter({
);
}
+ if (sendReminderJobId && cleanInvoiceData.sendReminderAt) {
+ await tx.insert(backgroundJobs).values({
+ id: sendReminderJobId,
+ type: jobTypes.sendInvoiceReminder,
+ payload: { invoiceId, userId: ctx.session.user.id },
+ idempotencyKey: `${jobTypes.sendInvoiceReminder}:${invoiceId}:${cleanInvoiceData.sendReminderAt.toISOString()}:${sendReminderJobId}`,
+ runAt: cleanInvoiceData.sendReminderAt,
+ maxAttempts: 5,
+ });
+ }
+
return invoice;
});
} catch (error) {
@@ -561,6 +584,34 @@ export const invoicesRouter = createTRPCRouter({
}
await ctx.db.transaction(async (tx) => {
+ let sendReminderJobId = existingInvoice.sendReminderJobId;
+ if (cleanInvoiceData.sendReminderAt !== undefined) {
+ if (existingInvoice.sendReminderJobId) {
+ await tx
+ .update(backgroundJobs)
+ .set({ status: "cancelled", updatedAt: new Date() })
+ .where(
+ eq(backgroundJobs.id, existingInvoice.sendReminderJobId),
+ );
+ }
+ sendReminderJobId = cleanInvoiceData.sendReminderAt
+ ? crypto.randomUUID()
+ : null;
+ if (sendReminderJobId && cleanInvoiceData.sendReminderAt) {
+ await tx.insert(backgroundJobs).values({
+ id: sendReminderJobId,
+ type: jobTypes.sendInvoiceReminder,
+ payload: { invoiceId: id, userId: ctx.session.user.id },
+ idempotencyKey: `${jobTypes.sendInvoiceReminder}:${id}:${cleanInvoiceData.sendReminderAt.toISOString()}:${sendReminderJobId}`,
+ runAt: cleanInvoiceData.sendReminderAt,
+ maxAttempts: 5,
+ });
+ }
+ }
+ const reminderJobPatch =
+ cleanInvoiceData.sendReminderAt !== undefined
+ ? { sendReminderJobId }
+ : {};
if (items) {
const totalAmount = calculateInvoiceTotal(
items,
@@ -571,6 +622,7 @@ export const invoicesRouter = createTRPCRouter({
.update(invoices)
.set({
...cleanInvoiceData,
+ ...reminderJobPatch,
totalAmount,
updatedAt: new Date(),
})
@@ -601,6 +653,7 @@ export const invoicesRouter = createTRPCRouter({
.update(invoices)
.set({
...cleanInvoiceData,
+ ...reminderJobPatch,
updatedAt: new Date(),
})
.where(eq(invoices.id, id))
@@ -1050,6 +1103,7 @@ export const invoicesRouter = createTRPCRouter({
where: eq(invoices.publicToken, input.token),
with: {
client: true,
+ createdBy: { columns: { timeZone: true } },
// Explicit allowlist: this is a publicProcedure — never let
// secret fields (resendApiKey, resendDomain) reach an
// unauthenticated caller via the business relation.
@@ -1120,6 +1174,10 @@ export const invoicesRouter = createTRPCRouter({
ctx.session.user.name ??
"";
const userEmail = invoice.business?.email ?? ctx.session.user.email ?? "";
+ const owner = await ctx.db.query.users.findFirst({
+ where: eq(users.id, ctx.session.user.id),
+ columns: { timeZone: true },
+ });
const { html, text, subject } = generateReminderEmailTemplate({
invoice: {
@@ -1134,6 +1192,7 @@ export const invoicesRouter = createTRPCRouter({
customMessage: input.customMessage,
userName,
userEmail,
+ timeZone: owner?.timeZone ?? "America/New_York",
});
try {
diff --git a/apps/web/src/server/api/routers/notifications.ts b/apps/web/src/server/api/routers/notifications.ts
new file mode 100644
index 0000000..5d23e07
--- /dev/null
+++ b/apps/web/src/server/api/routers/notifications.ts
@@ -0,0 +1,51 @@
+import { eq } from "drizzle-orm";
+import { z } from "zod";
+
+import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
+import { pushTokens } from "~/server/db/schema";
+
+const expoPushToken = z
+ .string()
+ .regex(/^ExponentPushToken\[[^\]]+\]$|^ExpoPushToken\[[^\]]+\]$/);
+
+export const notificationsRouter = createTRPCRouter({
+ registerPushToken: protectedProcedure
+ .input(
+ z.object({
+ token: expoPushToken,
+ platform: z.enum(["ios", "android"]),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ await ctx.db
+ .insert(pushTokens)
+ .values({
+ userId: ctx.session.user.id,
+ token: input.token,
+ platform: input.platform,
+ })
+ .onConflictDoUpdate({
+ target: pushTokens.token,
+ set: {
+ userId: ctx.session.user.id,
+ platform: input.platform,
+ updatedAt: new Date(),
+ },
+ });
+ return { success: true };
+ }),
+
+ unregisterPushToken: protectedProcedure
+ .input(z.object({ token: expoPushToken }))
+ .mutation(async ({ ctx, input }) => {
+ const owned = await ctx.db.query.pushTokens.findFirst({
+ where: eq(pushTokens.token, input.token),
+ });
+ if (owned?.userId === ctx.session.user.id) {
+ await ctx.db
+ .delete(pushTokens)
+ .where(eq(pushTokens.token, input.token));
+ }
+ return { success: true };
+ }),
+});
diff --git a/apps/web/src/server/api/routers/recurring-invoices.ts b/apps/web/src/server/api/routers/recurring-invoices.ts
index 54f9e23..c0fc1bd 100644
--- a/apps/web/src/server/api/routers/recurring-invoices.ts
+++ b/apps/web/src/server/api/routers/recurring-invoices.ts
@@ -8,12 +8,20 @@ import {
businesses,
} from "~/server/db/schema";
import { TRPCError } from "@trpc/server";
+import { generateInvoiceFromRecurring } from "~/server/services/recurring-invoices";
import {
- generateInvoiceFromRecurring,
- nextDueDate,
-} from "~/server/services/recurring-invoices";
+ DEFAULT_TIME_ZONE,
+ isValidTimeZone,
+ zonedDateTimeToInstant,
+} from "@beenvoice/domain/time-zone";
-const scheduleEnum = z.enum(["weekly", "biweekly", "monthly", "quarterly", "yearly"]);
+const scheduleEnum = z.enum([
+ "weekly",
+ "biweekly",
+ "monthly",
+ "quarterly",
+ "yearly",
+]);
const recurringItemSchema = z.object({
description: z.string().min(1),
@@ -32,9 +40,27 @@ const recurringInvoiceSchema = z.object({
currency: z.string().length(3).default("USD"),
notes: z.string().optional().or(z.literal("")),
emailMessage: z.string().optional().or(z.literal("")),
+ timeZone: z.string().refine(isValidTimeZone).default(DEFAULT_TIME_ZONE),
+ nextRunLocal: z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/),
+ disambiguation: z.enum(["earlier", "later", "reject"]).default("reject"),
items: z.array(recurringItemSchema).min(1),
});
+function parseNextRun(input: z.infer) {
+ try {
+ return zonedDateTimeToInstant(
+ input.nextRunLocal,
+ input.timeZone,
+ input.disambiguation,
+ );
+ } catch (error) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: error instanceof Error ? error.message : "Invalid recurring run time",
+ });
+ }
+}
+
export const recurringInvoicesRouter = createTRPCRouter({
getAll: protectedProcedure.query(async ({ ctx }) => {
return ctx.db.query.recurringInvoices.findMany({
@@ -51,14 +77,20 @@ export const recurringInvoicesRouter = createTRPCRouter({
where: eq(clients.id, input.clientId),
});
if (client?.createdById !== ctx.session.user.id) {
- throw new TRPCError({ code: "BAD_REQUEST", message: "Client not found" });
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Client not found",
+ });
}
if (input.businessId) {
const biz = await ctx.db.query.businesses.findFirst({
where: eq(businesses.id, input.businessId),
});
if (biz?.createdById !== ctx.session.user.id) {
- throw new TRPCError({ code: "BAD_REQUEST", message: "Business not found" });
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Business not found",
+ });
}
}
@@ -75,7 +107,8 @@ export const recurringInvoicesRouter = createTRPCRouter({
currency: input.currency,
notes: input.notes ?? null,
emailMessage: input.emailMessage ?? null,
- nextDueAt: nextDueDate(input.schedule),
+ nextDueAt: parseNextRun(input),
+ timeZone: input.timeZone,
createdById: ctx.session.user.id,
})
.returning({ id: recurringInvoices.id });
@@ -117,6 +150,8 @@ export const recurringInvoicesRouter = createTRPCRouter({
currency: input.currency,
notes: input.notes ?? null,
emailMessage: input.emailMessage ?? null,
+ nextDueAt: parseNextRun(input),
+ timeZone: input.timeZone,
})
.where(eq(recurringInvoices.id, input.id));
@@ -195,11 +230,12 @@ export const recurringInvoicesRouter = createTRPCRouter({
throw new TRPCError({ code: "NOT_FOUND" });
}
- const newInvoice = await generateInvoiceFromRecurring(ctx.db, rec);
+ const now = new Date();
+ const newInvoice = await generateInvoiceFromRecurring(ctx.db, rec, now);
await ctx.db
.update(recurringInvoices)
- .set({ lastGeneratedAt: new Date(), nextDueAt: nextDueDate(rec.schedule) })
+ .set({ lastGeneratedAt: now })
.where(eq(recurringInvoices.id, input.id));
return { invoiceId: newInvoice.id };
diff --git a/apps/web/src/server/api/routers/settings.ts b/apps/web/src/server/api/routers/settings.ts
index f306cd5..af57605 100644
--- a/apps/web/src/server/api/routers/settings.ts
+++ b/apps/web/src/server/api/routers/settings.ts
@@ -41,6 +41,10 @@ import {
type ColorMode,
} from "~/lib/branding";
import { revokeUserSessions } from "~/lib/session-security";
+import {
+ DEFAULT_TIME_ZONE,
+ isValidTimeZone,
+} from "@beenvoice/domain/time-zone";
function resolveBusinessId(
refs: { businessName?: string; businessNickname?: string },
@@ -156,6 +160,7 @@ const RecurringInvoiceBackupSchema = z.object({
currency: z.string().default("USD"),
notes: z.string().optional(),
emailMessage: z.string().optional(),
+ timeZone: z.string().refine(isValidTimeZone).default(DEFAULT_TIME_ZONE),
nextDueAt: z.coerce.date(),
lastGeneratedAt: z.coerce.date().optional(),
items: z.array(RecurringInvoiceItemBackupSchema),
@@ -197,6 +202,7 @@ const BackupDataSchema = z.object({
prefersReducedMotion: z.boolean().optional(),
animationSpeedMultiplier: z.number().optional(),
theme: z.string().optional(),
+ timeZone: z.string().refine(isValidTimeZone).optional(),
onboardingCompletedAt: z.coerce.date().nullable().optional(),
}),
clients: z.array(ClientBackupSchema),
@@ -291,6 +297,7 @@ export const settingsRouter = createTRPCRouter({
email: true,
image: true,
role: true,
+ timeZone: true,
onboardingCompletedAt: true,
},
});
@@ -507,6 +514,7 @@ export const settingsRouter = createTRPCRouter({
.input(
z.object({
name: z.string().min(1, "Name is required"),
+ timeZone: z.string().refine(isValidTimeZone).default(DEFAULT_TIME_ZONE),
}),
)
.mutation(async ({ ctx, input }) => {
@@ -514,6 +522,7 @@ export const settingsRouter = createTRPCRouter({
.update(users)
.set({
name: input.name,
+ timeZone: input.timeZone,
})
.where(eq(users.id, ctx.session.user.id));
@@ -621,6 +630,7 @@ export const settingsRouter = createTRPCRouter({
prefersReducedMotion: true,
animationSpeedMultiplier: true,
theme: true,
+ timeZone: true,
onboardingCompletedAt: true,
},
});
@@ -759,6 +769,7 @@ export const settingsRouter = createTRPCRouter({
prefersReducedMotion: user?.prefersReducedMotion ?? false,
animationSpeedMultiplier: user?.animationSpeedMultiplier ?? 1,
theme: user?.theme ?? "system",
+ timeZone: user?.timeZone ?? DEFAULT_TIME_ZONE,
onboardingCompletedAt: user?.onboardingCompletedAt ?? null,
},
clients: userClients.map((client) => ({
@@ -835,6 +846,7 @@ export const settingsRouter = createTRPCRouter({
currency: recurring.currency,
notes: recurring.notes ?? undefined,
emailMessage: recurring.emailMessage ?? undefined,
+ timeZone: recurring.timeZone,
nextDueAt: recurring.nextDueAt,
lastGeneratedAt: recurring.lastGeneratedAt ?? undefined,
items: recurring.items,
@@ -1002,6 +1014,7 @@ export const settingsRouter = createTRPCRouter({
currency: recurringData.currency,
notes: recurringData.notes,
emailMessage: recurringData.emailMessage,
+ timeZone: recurringData.timeZone,
nextDueAt: recurringData.nextDueAt,
lastGeneratedAt: recurringData.lastGeneratedAt,
createdById: userId,
@@ -1110,6 +1123,9 @@ export const settingsRouter = createTRPCRouter({
...(input.user.animationSpeedMultiplier !== undefined && {
animationSpeedMultiplier: input.user.animationSpeedMultiplier,
}),
+ ...(input.user.timeZone !== undefined && {
+ timeZone: input.user.timeZone,
+ }),
...(input.user.theme !== undefined && {
theme: input.user.theme,
}),
diff --git a/apps/web/src/server/api/routers/time-entries.ts b/apps/web/src/server/api/routers/time-entries.ts
index 2eb231a..48da941 100644
--- a/apps/web/src/server/api/routers/time-entries.ts
+++ b/apps/web/src/server/api/routers/time-entries.ts
@@ -1,7 +1,13 @@
import { z } from "zod";
import { eq, and, desc, isNull, isNotNull, gte, lte } from "drizzle-orm";
import { createTRPCRouter, protectedProcedure } from "../trpc";
-import { timeEntries, clients, invoices, businesses } from "~/server/db/schema";
+import {
+ timeEntries,
+ clients,
+ invoices,
+ businesses,
+ users,
+} from "~/server/db/schema";
import { TRPCError } from "@trpc/server";
import type { db } from "~/server/db";
import {
@@ -17,6 +23,7 @@ import {
removeLinkedInvoiceItem,
syncLinkedInvoiceItem,
} from "~/server/api/lib/time-entry-invoice-sync";
+import { calendarDateFromInstant } from "@beenvoice/domain/time-zone";
type Db = typeof db;
@@ -55,20 +62,31 @@ function computeHours(startedAt: Date, endedAt: Date): number {
async function addEntryToInvoice(
database: Db,
- invoice: { id: string; invoiceNumber: string; invoicePrefix: string | null; taxRate: number; items: { amount: number; position: number }[] },
+ userId: string,
+ invoice: {
+ id: string;
+ invoiceNumber: string;
+ invoicePrefix: string | null;
+ taxRate: number;
+ items: { amount: number; position: number }[];
+ },
entryId: string,
description: string,
hours: number,
rate: number,
date: Date,
): Promise<{ id: string; invoiceNumber: string; invoicePrefix: string }> {
+ const owner = await database.query.users.findFirst({
+ where: eq(users.id, userId),
+ columns: { timeZone: true },
+ });
return insertInvoiceLineForTimeEntry(database, {
invoice,
entryId,
description,
hours,
rate,
- date,
+ date: calendarDateFromInstant(date, owner?.timeZone ?? "America/New_York"),
});
}
@@ -100,11 +118,21 @@ async function findOrCreateDraftInvoice(
if (!client) return null;
const defaultBusiness = await database.query.businesses.findFirst({
- where: and(eq(businesses.createdById, userId), eq(businesses.isDefault, true)),
+ where: and(
+ eq(businesses.createdById, userId),
+ eq(businesses.isDefault, true),
+ ),
columns: { id: true },
});
- const issueDate = new Date();
+ const owner = await database.query.users.findFirst({
+ where: eq(users.id, userId),
+ columns: { timeZone: true },
+ });
+ const issueDate = calendarDateFromInstant(
+ new Date(),
+ owner?.timeZone ?? "America/New_York",
+ );
const [created] = await database
.insert(invoices)
.values({
@@ -135,10 +163,23 @@ async function addEntryToLatestInvoice(
hours: number,
rate: number,
date: Date,
-): Promise<{ id: string; invoiceNumber: string; invoicePrefix: string } | null> {
+): Promise<{
+ id: string;
+ invoiceNumber: string;
+ invoicePrefix: string;
+} | null> {
const invoice = await findOrCreateDraftInvoice(database, userId, clientId);
if (!invoice) return null;
- return addEntryToInvoice(database, invoice, entryId, description, hours, rate, date);
+ return addEntryToInvoice(
+ database,
+ userId,
+ invoice,
+ entryId,
+ description,
+ hours,
+ rate,
+ date,
+ );
}
async function addEntryToSpecificInvoice(
@@ -150,7 +191,11 @@ async function addEntryToSpecificInvoice(
hours: number,
rate: number,
date: Date,
-): Promise<{ id: string; invoiceNumber: string; invoicePrefix: string } | null> {
+): Promise<{
+ id: string;
+ invoiceNumber: string;
+ invoicePrefix: string;
+} | null> {
const invoice = await database.query.invoices.findFirst({
where: and(
eq(invoices.id, invoiceId),
@@ -161,7 +206,16 @@ async function addEntryToSpecificInvoice(
});
if (!invoice) return null;
- return addEntryToInvoice(database, invoice, entryId, description, hours, rate, date);
+ return addEntryToInvoice(
+ database,
+ userId,
+ invoice,
+ entryId,
+ description,
+ hours,
+ rate,
+ date,
+ );
}
export const timeEntriesRouter = createTRPCRouter({
@@ -177,13 +231,19 @@ export const timeEntriesRouter = createTRPCRouter({
)
.query(async ({ ctx, input }) => {
const conditions = [eq(timeEntries.createdById, ctx.session.user.id)];
- if (input?.clientId) conditions.push(eq(timeEntries.clientId, input.clientId));
+ if (input?.clientId)
+ conditions.push(eq(timeEntries.clientId, input.clientId));
if (input?.from) conditions.push(gte(timeEntries.startedAt, input.from));
if (input?.to) conditions.push(lte(timeEntries.startedAt, input.to));
return ctx.db.query.timeEntries.findMany({
where: and(...conditions),
- with: { client: true, invoice: { columns: { id: true, invoiceNumber: true, invoicePrefix: true } } },
+ with: {
+ client: true,
+ invoice: {
+ columns: { id: true, invoiceNumber: true, invoicePrefix: true },
+ },
+ },
orderBy: [desc(timeEntries.startedAt)],
});
}),
@@ -198,7 +258,11 @@ export const timeEntriesRouter = createTRPCRouter({
),
with: { client: true },
});
- if (!entry) throw new TRPCError({ code: "NOT_FOUND", message: "Time entry not found" });
+ if (!entry)
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Time entry not found",
+ });
return entry;
}),
@@ -247,10 +311,17 @@ export const timeEntriesRouter = createTRPCRouter({
let clientRecord: { defaultHourlyRate: number | null } | null = null;
if (clientId) {
const found = await ctx.db.query.clients.findFirst({
- where: and(eq(clients.id, clientId), eq(clients.createdById, ctx.session.user.id)),
+ where: and(
+ eq(clients.id, clientId),
+ eq(clients.createdById, ctx.session.user.id),
+ ),
columns: { defaultHourlyRate: true },
});
- if (!found) throw new TRPCError({ code: "FORBIDDEN", message: "Client not found" });
+ if (!found)
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "Client not found",
+ });
clientRecord = found;
}
@@ -282,7 +353,10 @@ export const timeEntriesRouter = createTRPCRouter({
const startedAt = input.startedAt ?? new Date();
if (startedAt > new Date()) {
- throw new TRPCError({ code: "BAD_REQUEST", message: "startedAt cannot be in the future" });
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "startedAt cannot be in the future",
+ });
}
if (!clientRecord && resolvedClientId) {
@@ -337,7 +411,10 @@ export const timeEntriesRouter = createTRPCRouter({
});
if (!entry) {
- throw new TRPCError({ code: "NOT_FOUND", message: "No running timer found" });
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "No running timer found",
+ });
}
const updates: {
@@ -369,9 +446,16 @@ export const timeEntriesRouter = createTRPCRouter({
const clientId = input.clientId.trim() || null;
if (clientId) {
const found = await ctx.db.query.clients.findFirst({
- where: and(eq(clients.id, clientId), eq(clients.createdById, ctx.session.user.id)),
+ where: and(
+ eq(clients.id, clientId),
+ eq(clients.createdById, ctx.session.user.id),
+ ),
});
- if (!found) throw new TRPCError({ code: "FORBIDDEN", message: "Client not found" });
+ if (!found)
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "Client not found",
+ });
}
resolvedClientId = clientId;
updates.clientId = clientId;
@@ -427,7 +511,10 @@ export const timeEntriesRouter = createTRPCRouter({
.returning();
if (!updated) {
- throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Update failed" });
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Update failed",
+ });
}
return updated;
@@ -435,10 +522,12 @@ export const timeEntriesRouter = createTRPCRouter({
clockOut: protectedProcedure
.input(
- z.object({
- id: z.string().optional(),
- description: z.string().max(500).optional(),
- }).optional(),
+ z
+ .object({
+ id: z.string().optional(),
+ description: z.string().max(500).optional(),
+ })
+ .optional(),
)
.mutation(async ({ ctx, input }) => {
const conditions = [
@@ -452,24 +541,41 @@ export const timeEntriesRouter = createTRPCRouter({
});
if (!entry) {
- throw new TRPCError({ code: "NOT_FOUND", message: "No running timer found" });
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "No running timer found",
+ });
}
const endedAt = new Date();
const hours = computeHours(entry.startedAt, endedAt);
- const rawDescription = input?.description?.trim() ?? entry.description?.trim() ?? "";
+ const rawDescription =
+ input?.description?.trim() ?? entry.description?.trim() ?? "";
const billingDescription = resolveBillingDescription(rawDescription);
const rate = entry.rate ?? 0;
const [updated] = await ctx.db
.update(timeEntries)
- .set({ endedAt, hours, description: rawDescription, updatedAt: new Date() })
+ .set({
+ endedAt,
+ hours,
+ description: rawDescription,
+ updatedAt: new Date(),
+ })
.where(eq(timeEntries.id, entry.id))
.returning();
- if (!updated) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Clock out failed" });
+ if (!updated)
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Clock out failed",
+ });
- let linkedInvoice: { id: string; invoiceNumber: string; invoicePrefix: string } | null = null;
+ let linkedInvoice: {
+ id: string;
+ invoiceNumber: string;
+ invoicePrefix: string;
+ } | null = null;
let outcome: ClockOutOutcome = "zero_hours";
if (hours > 0) {
@@ -518,9 +624,16 @@ export const timeEntriesRouter = createTRPCRouter({
const clientId = normalizeOptionalId(input.clientId);
if (clientId) {
const client = await ctx.db.query.clients.findFirst({
- where: and(eq(clients.id, clientId), eq(clients.createdById, ctx.session.user.id)),
+ where: and(
+ eq(clients.id, clientId),
+ eq(clients.createdById, ctx.session.user.id),
+ ),
});
- if (!client) throw new TRPCError({ code: "FORBIDDEN", message: "Client not found" });
+ if (!client)
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "Client not found",
+ });
}
let hours = input.hours ?? null;
@@ -542,9 +655,17 @@ export const timeEntriesRouter = createTRPCRouter({
})
.returning();
- if (!entry) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Create failed" });
+ if (!entry)
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Create failed",
+ });
- let linkedInvoice: { id: string; invoiceNumber: string; invoicePrefix: string } | null = null;
+ let linkedInvoice: {
+ id: string;
+ invoiceNumber: string;
+ invoicePrefix: string;
+ } | null = null;
if (clientId && hours && input.endedAt) {
linkedInvoice = await addEntryToLatestInvoice(
ctx.db,
@@ -576,7 +697,11 @@ export const timeEntriesRouter = createTRPCRouter({
eq(timeEntries.createdById, ctx.session.user.id),
),
});
- if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Time entry not found" });
+ if (!existing)
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Time entry not found",
+ });
if (existing.endedAt == null) {
throw new TRPCError({
@@ -590,16 +715,28 @@ export const timeEntriesRouter = createTRPCRouter({
if (clientId) {
const client = await ctx.db.query.clients.findFirst({
- where: and(eq(clients.id, clientId), eq(clients.createdById, ctx.session.user.id)),
+ where: and(
+ eq(clients.id, clientId),
+ eq(clients.createdById, ctx.session.user.id),
+ ),
});
- if (!client) throw new TRPCError({ code: "FORBIDDEN", message: "Client not found" });
+ if (!client)
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "Client not found",
+ });
}
let hours = data.hours;
const startedAt = data.startedAt ?? existing.startedAt;
const endedAt = data.endedAt ?? existing.endedAt;
- if (endedAt && (data.startedAt !== undefined || data.endedAt !== undefined || data.hours === undefined)) {
+ if (
+ endedAt &&
+ (data.startedAt !== undefined ||
+ data.endedAt !== undefined ||
+ data.hours === undefined)
+ ) {
hours = computeHours(startedAt, endedAt);
}
@@ -619,11 +756,19 @@ export const timeEntriesRouter = createTRPCRouter({
});
if (!updated) {
- throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Update failed" });
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Update failed",
+ });
}
if (nextInvoiceId !== undefined) {
- await relinkTimeEntryToInvoice(ctx.db, ctx.session.user.id, updated, nextInvoiceId.trim() || null);
+ await relinkTimeEntryToInvoice(
+ ctx.db,
+ ctx.session.user.id,
+ updated,
+ nextInvoiceId.trim() || null,
+ );
} else {
await syncLinkedInvoiceItem(ctx.db, updated);
}
@@ -640,7 +785,11 @@ export const timeEntriesRouter = createTRPCRouter({
eq(timeEntries.createdById, ctx.session.user.id),
),
});
- if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Time entry not found" });
+ if (!existing)
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Time entry not found",
+ });
await removeLinkedInvoiceItem(ctx.db, input.id);
await ctx.db.delete(timeEntries).where(eq(timeEntries.id, input.id));
@@ -649,10 +798,12 @@ export const timeEntriesRouter = createTRPCRouter({
getSummary: protectedProcedure
.input(
- z.object({
- from: z.date().optional(),
- to: z.date().optional(),
- }).optional(),
+ z
+ .object({
+ from: z.date().optional(),
+ to: z.date().optional(),
+ })
+ .optional(),
)
.query(async ({ ctx, input }) => {
const conditions = [
diff --git a/apps/web/src/server/db/schema.ts b/apps/web/src/server/db/schema.ts
index d048fc7..4d4c2ce 100644
--- a/apps/web/src/server/db/schema.ts
+++ b/apps/web/src/server/db/schema.ts
@@ -20,21 +20,22 @@ export const users = createTable("user", (d) => ({
email: d.varchar({ length: 255 }).notNull().unique(),
emailVerified: d.boolean().default(false).notNull(),
image: d.varchar({ length: 255 }),
- createdAt: d.timestamp().notNull().defaultNow(),
+ timeZone: d.varchar({ length: 100 }).notNull().default("America/New_York"),
+ createdAt: d.timestamp({ withTimezone: true }).notNull().defaultNow(),
updatedAt: d
- .timestamp()
+ .timestamp({ withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
password: d.varchar({ length: 255 }), // Matched DB: varchar(255)
resetToken: d.varchar({ length: 255 }), // Matched DB: varchar(255)
- resetTokenExpiry: d.timestamp(),
+ resetTokenExpiry: d.timestamp({ withTimezone: true }),
// Custom fields
prefersReducedMotion: d.boolean().default(false).notNull(),
animationSpeedMultiplier: d.real().default(1).notNull(),
theme: d.varchar({ length: 20 }).default("system").notNull(),
role: d.varchar({ length: 20 }).default("user").notNull(),
- onboardingCompletedAt: d.timestamp(),
+ onboardingCompletedAt: d.timestamp({ withTimezone: true }),
}));
export const platformSettings = createTable("platform_setting", (d) => ({
@@ -49,9 +50,9 @@ export const platformSettings = createTable("platform_setting", (d) => ({
.notNull(),
pdfShowLogo: d.boolean().default(true).notNull(),
pdfShowPageNumbers: d.boolean().default(true).notNull(),
- createdAt: d.timestamp().notNull().defaultNow(),
+ createdAt: d.timestamp({ withTimezone: true }).notNull().defaultNow(),
updatedAt: d
- .timestamp()
+ .timestamp({ withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
@@ -68,6 +69,7 @@ export const usersRelations = relations(users, ({ many }) => ({
invoiceTemplates: many(invoiceTemplates),
recurringInvoices: many(recurringInvoices),
timeEntries: many(timeEntries),
+ pushTokens: many(pushTokens),
auditLogsAsActor: many(auditLog),
}));
@@ -87,7 +89,7 @@ export const auditLog = createTable(
targetType: d.varchar({ length: 50 }).notNull(),
targetId: d.varchar({ length: 255 }),
metadata: d.jsonb().$type>(),
- createdAt: d.timestamp().notNull().defaultNow(),
+ createdAt: d.timestamp({ withTimezone: true }).notNull().defaultNow(),
}),
(t) => [
index("audit_log_actor_user_id_idx").on(t.actorUserId),
@@ -119,14 +121,14 @@ export const accounts = createTable(
providerId: d.varchar({ length: 255 }).notNull(),
accessToken: d.text(),
refreshToken: d.text(),
- accessTokenExpiresAt: d.timestamp(),
- refreshTokenExpiresAt: d.timestamp(),
+ accessTokenExpiresAt: d.timestamp({ withTimezone: true }),
+ refreshTokenExpiresAt: d.timestamp({ withTimezone: true }),
scope: d.varchar({ length: 255 }),
idToken: d.text(),
password: d.text(), // Matched DB: text
- createdAt: d.timestamp().notNull().defaultNow(),
+ createdAt: d.timestamp({ withTimezone: true }).notNull().defaultNow(),
updatedAt: d
- .timestamp()
+ .timestamp({ withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
@@ -151,12 +153,12 @@ export const sessions = createTable(
.notNull()
.references(() => users.id),
token: d.varchar({ length: 255 }).notNull().unique(),
- expiresAt: d.timestamp().notNull(),
+ expiresAt: d.timestamp({ withTimezone: true }).notNull(),
ipAddress: d.text(), // Matched DB: text
userAgent: d.text(), // Matched DB: text
- createdAt: d.timestamp().notNull().defaultNow(),
+ createdAt: d.timestamp({ withTimezone: true }).notNull().defaultNow(),
updatedAt: d
- .timestamp()
+ .timestamp({ withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
@@ -183,12 +185,12 @@ export const apiKeys = createTable(
.varchar({ length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
- lastUsedAt: d.timestamp(),
- expiresAt: d.timestamp(),
- revokedAt: d.timestamp(),
- createdAt: d.timestamp().notNull().defaultNow(),
+ lastUsedAt: d.timestamp({ withTimezone: true }),
+ expiresAt: d.timestamp({ withTimezone: true }),
+ revokedAt: d.timestamp({ withTimezone: true }),
+ createdAt: d.timestamp({ withTimezone: true }).notNull().defaultNow(),
updatedAt: d
- .timestamp()
+ .timestamp({ withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
@@ -214,10 +216,10 @@ export const verificationTokens = createTable(
.$defaultFn(() => crypto.randomUUID()), // Matched DB: text
identifier: d.varchar({ length: 255 }).notNull(),
value: d.text().notNull(),
- expiresAt: d.timestamp().notNull(),
- createdAt: d.timestamp().notNull().defaultNow(),
+ expiresAt: d.timestamp({ withTimezone: true }).notNull(),
+ createdAt: d.timestamp({ withTimezone: true }).notNull().defaultNow(),
updatedAt: d
- .timestamp()
+ .timestamp({ withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
@@ -241,9 +243,9 @@ export const ssoProviders = createTable(
redirectURI: d.varchar({ length: 255 }).notNull().default(""), // Added detailed fields
oidcConfig: d.text(),
samlConfig: d.text(),
- createdAt: d.timestamp().notNull().defaultNow(),
+ createdAt: d.timestamp({ withTimezone: true }).notNull().defaultNow(),
updatedAt: d
- .timestamp()
+ .timestamp({ withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
@@ -276,10 +278,10 @@ export const clients = createTable(
.notNull()
.references(() => users.id),
createdAt: d
- .timestamp()
+ .timestamp({ withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
- updatedAt: d.timestamp().$onUpdate(() => new Date()),
+ updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()),
}),
(t) => [
index("client_created_by_idx").on(t.createdById),
@@ -331,10 +333,10 @@ export const businesses = createTable(
.notNull()
.references(() => users.id),
createdAt: d
- .timestamp()
+ .timestamp({ withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
- updatedAt: d.timestamp().$onUpdate(() => new Date()),
+ updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()),
}),
(t) => [
index("business_created_by_idx").on(t.createdById),
@@ -368,8 +370,8 @@ export const invoices = createTable(
.varchar({ length: 255 })
.notNull()
.references(() => clients.id),
- issueDate: d.timestamp().notNull(),
- dueDate: d.timestamp().notNull(),
+ issueDate: d.date({ mode: "date" }).notNull(),
+ dueDate: d.date({ mode: "date" }).notNull(),
status: d.varchar({ length: 50 }).notNull().default("draft"), // draft, sent, paid (overdue computed)
totalAmount: d.real().notNull().default(0),
taxRate: d.real().notNull().default(0.0),
@@ -381,19 +383,20 @@ export const invoices = createTable(
.notNull()
.references(() => users.id),
publicToken: d.varchar({ length: 255 }).unique(),
- publicTokenExpiresAt: d.timestamp(),
- lastReminderSentAt: d.timestamp(),
- sendReminderAt: d.timestamp(),
+ publicTokenExpiresAt: d.timestamp({ withTimezone: true }),
+ lastReminderSentAt: d.timestamp({ withTimezone: true }),
+ sendReminderAt: d.timestamp({ withTimezone: true }),
+ sendReminderJobId: d.varchar({ length: 255 }),
sentAt: d.timestamp({ withTimezone: true }),
scheduledSendAt: d.timestamp({ withTimezone: true }),
scheduledSendTimeZone: d.varchar({ length: 100 }),
scheduledSendJobId: d.varchar({ length: 255 }),
scheduledSendStatus: d.varchar({ length: 20 }), // pending | processing | completed | failed | cancelled
createdAt: d
- .timestamp()
+ .timestamp({ withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
- updatedAt: d.timestamp().$onUpdate(() => new Date()),
+ updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()),
}),
(t) => [
index("invoice_business_id_idx").on(t.businessId),
@@ -436,7 +439,7 @@ export const invoiceItems = createTable(
.varchar({ length: 255 })
.notNull()
.references(() => invoices.id, { onDelete: "cascade" }),
- date: d.timestamp().notNull(),
+ date: d.date({ mode: "date" }).notNull(),
description: d.varchar({ length: 500 }).notNull(),
hours: d.real().notNull(),
rate: d.real().notNull(),
@@ -446,7 +449,7 @@ export const invoiceItems = createTable(
.varchar({ length: 255 })
.references(() => timeEntries.id, { onDelete: "set null" }),
createdAt: d
- .timestamp()
+ .timestamp({ withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
}),
@@ -481,7 +484,7 @@ export const expenses = createTable(
invoiceId: d
.varchar({ length: 255 })
.references(() => invoices.id, { onDelete: "set null" }),
- date: d.timestamp().notNull(),
+ date: d.date({ mode: "date" }).notNull(),
description: d.varchar({ length: 500 }).notNull(),
amount: d.real().notNull(),
currency: d.varchar({ length: 3 }).default("USD").notNull(),
@@ -495,10 +498,10 @@ export const expenses = createTable(
.notNull()
.references(() => users.id),
createdAt: d
- .timestamp()
+ .timestamp({ withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
- updatedAt: d.timestamp().$onUpdate(() => new Date()),
+ updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()),
}),
(t) => [
index("expense_created_by_idx").on(t.createdById),
@@ -527,7 +530,7 @@ export const expenseReceipts = createTable(
mimeType: d.varchar({ length: 100 }).notNull(),
sizeBytes: d.integer().notNull(),
createdAt: d
- .timestamp()
+ .timestamp({ withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
}),
@@ -581,10 +584,10 @@ export const invoiceTemplates = createTable(
.notNull()
.references(() => users.id),
createdAt: d
- .timestamp()
+ .timestamp({ withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
- updatedAt: d.timestamp().$onUpdate(() => new Date()),
+ updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()),
}),
(t) => [
index("invoice_template_created_by_idx").on(t.createdById),
@@ -618,7 +621,7 @@ export const invoicePayments = createTable(
.references(() => invoices.id, { onDelete: "cascade" }),
amount: d.real().notNull(),
currency: d.varchar({ length: 3 }).default("USD").notNull(),
- date: d.timestamp().notNull(),
+ date: d.date({ mode: "date" }).notNull(),
method: d.varchar({ length: 50 }).notNull().default("other"), // cash | check | bank_transfer | credit_card | paypal | other
notes: d.varchar({ length: 500 }),
createdById: d
@@ -626,7 +629,7 @@ export const invoicePayments = createTable(
.notNull()
.references(() => users.id),
createdAt: d
- .timestamp()
+ .timestamp({ withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
}),
@@ -673,17 +676,18 @@ export const recurringInvoices = createTable(
currency: d.varchar({ length: 3 }).default("USD").notNull(),
notes: d.varchar({ length: 1000 }),
emailMessage: d.varchar({ length: 2000 }),
- nextDueAt: d.timestamp().notNull(),
- lastGeneratedAt: d.timestamp(),
+ nextDueAt: d.timestamp({ withTimezone: true }).notNull(),
+ lastGeneratedAt: d.timestamp({ withTimezone: true }),
+ timeZone: d.varchar({ length: 100 }).notNull().default("America/New_York"),
createdById: d
.varchar({ length: 255 })
.notNull()
.references(() => users.id),
createdAt: d
- .timestamp()
+ .timestamp({ withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
- updatedAt: d.timestamp().$onUpdate(() => new Date()),
+ updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()),
}),
(t) => [
index("recurring_invoice_created_by_idx").on(t.createdById),
@@ -729,7 +733,7 @@ export const recurringInvoiceItems = createTable(
rate: d.real().notNull(),
position: d.integer().notNull().default(0),
createdAt: d
- .timestamp()
+ .timestamp({ withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
}),
@@ -748,6 +752,31 @@ export const recurringInvoiceItemsRelations = relations(
}),
);
+// ─── Mobile Push Tokens ──────────────────────────────────────────────────────
+
+export const pushTokens = createTable(
+ "push_token",
+ (d) => ({
+ id: d
+ .varchar({ length: 255 })
+ .primaryKey()
+ .$defaultFn(() => crypto.randomUUID()),
+ userId: d
+ .varchar({ length: 255 })
+ .notNull()
+ .references(() => users.id, { onDelete: "cascade" }),
+ token: d.varchar({ length: 255 }).notNull().unique(),
+ platform: d.varchar({ length: 20 }).notNull(),
+ createdAt: d.timestamp({ withTimezone: true }).notNull().defaultNow(),
+ updatedAt: d.timestamp({ withTimezone: true }).notNull().defaultNow(),
+ }),
+ (t) => [index("push_token_user_id_idx").on(t.userId)],
+);
+
+export const pushTokensRelations = relations(pushTokens, ({ one }) => ({
+ user: one(users, { fields: [pushTokens.userId], references: [users.id] }),
+}));
+
// ─── Background Jobs ─────────────────────────────────────────────────────────
export const backgroundJobs = createTable(
@@ -795,8 +824,8 @@ export const timeEntries = createTable(
invoiceId: d
.varchar({ length: 255 })
.references(() => invoices.id, { onDelete: "set null" }),
- startedAt: d.timestamp().notNull(),
- endedAt: d.timestamp(), // null = currently running
+ startedAt: d.timestamp({ withTimezone: true }).notNull(),
+ endedAt: d.timestamp({ withTimezone: true }), // null = currently running
hours: d.real(), // stored when stopped
rate: d.real(),
notes: d.varchar({ length: 500 }),
@@ -805,10 +834,10 @@ export const timeEntries = createTable(
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
createdAt: d
- .timestamp()
+ .timestamp({ withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
- updatedAt: d.timestamp().$onUpdate(() => new Date()),
+ updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()),
}),
(t) => [
index("time_entry_created_by_idx").on(t.createdById),
diff --git a/apps/web/src/server/jobs/handlers/invoice-reminder.ts b/apps/web/src/server/jobs/handlers/invoice-reminder.ts
new file mode 100644
index 0000000..9793a97
--- /dev/null
+++ b/apps/web/src/server/jobs/handlers/invoice-reminder.ts
@@ -0,0 +1,71 @@
+import { and, eq } from "drizzle-orm";
+
+import { db } from "~/server/db";
+import { invoices, pushTokens } from "~/server/db/schema";
+import type { BackgroundJob } from "~/server/jobs/queue";
+
+type ExpoPushTicket = {
+ status: "ok" | "error";
+ message?: string;
+ details?: { error?: string };
+};
+
+export async function sendInvoiceReminder(job: BackgroundJob) {
+ const invoiceId = job.payload.invoiceId;
+ const userId = job.payload.userId;
+ if (typeof invoiceId !== "string" || typeof userId !== "string") {
+ throw new Error("Invalid invoice reminder payload");
+ }
+
+ const invoice = await db.query.invoices.findFirst({
+ where: and(eq(invoices.id, invoiceId), eq(invoices.createdById, userId)),
+ with: { client: { columns: { name: true } } },
+ });
+ if (invoice?.status !== "draft" || invoice.sendReminderJobId !== job.id)
+ return;
+
+ const tokens = await db.query.pushTokens.findMany({
+ where: eq(pushTokens.userId, userId),
+ });
+ if (!tokens.length) return;
+
+ const label = `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`;
+ const response = await fetch("https://exp.host/--/api/v2/push/send", {
+ method: "POST",
+ headers: {
+ Accept: "application/json",
+ "Accept-Encoding": "gzip, deflate",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(
+ tokens.map(({ token }) => ({
+ to: token,
+ title: "Time to send invoice",
+ body: `${label} for ${invoice.client?.name ?? "your client"} is ready to send.`,
+ sound: "default",
+ data: { invoiceId, type: "invoice-send-reminder" },
+ })),
+ ),
+ });
+ if (!response.ok)
+ throw new Error(`Expo push request failed (${response.status})`);
+
+ const result = (await response.json()) as { data?: ExpoPushTicket[] };
+ const tickets = result.data ?? [];
+ const invalidTokens = tokens.filter(
+ (_, index) => tickets[index]?.details?.error === "DeviceNotRegistered",
+ );
+ for (const invalid of invalidTokens) {
+ await db.delete(pushTokens).where(eq(pushTokens.id, invalid.id));
+ }
+ const retryableFailure = tickets.find(
+ (ticket) =>
+ ticket.status === "error" &&
+ ticket.details?.error !== "DeviceNotRegistered",
+ );
+ if (retryableFailure) {
+ throw new Error(
+ retryableFailure.message ?? "Expo rejected the push notification",
+ );
+ }
+}
diff --git a/apps/web/src/server/jobs/handlers/recurring-invoice.ts b/apps/web/src/server/jobs/handlers/recurring-invoice.ts
index 4fc532e..5933c72 100644
--- a/apps/web/src/server/jobs/handlers/recurring-invoice.ts
+++ b/apps/web/src/server/jobs/handlers/recurring-invoice.ts
@@ -11,11 +11,15 @@ import {
export async function generateRecurringInvoice(job: BackgroundJob) {
const recurringInvoiceId = job.payload.recurringInvoiceId;
const scheduledForValue = job.payload.scheduledFor;
- if (typeof recurringInvoiceId !== "string" || typeof scheduledForValue !== "string") {
+ if (
+ typeof recurringInvoiceId !== "string" ||
+ typeof scheduledForValue !== "string"
+ ) {
throw new Error("Invalid recurring invoice job payload");
}
const scheduledFor = new Date(scheduledForValue);
- if (Number.isNaN(scheduledFor.getTime())) throw new Error("Invalid recurring invoice job payload");
+ if (Number.isNaN(scheduledFor.getTime()))
+ throw new Error("Invalid recurring invoice job payload");
await db.transaction(async (tx) => {
const recurring = await tx.query.recurringInvoices.findFirst({
@@ -28,12 +32,16 @@ export async function generateRecurringInvoice(job: BackgroundJob) {
});
if (!recurring) return;
- await generateInvoiceFromRecurring(tx, recurring);
+ await generateInvoiceFromRecurring(tx, recurring, scheduledFor);
await tx
.update(recurringInvoices)
.set({
lastGeneratedAt: new Date(),
- nextDueAt: nextDueDate(recurring.schedule, scheduledFor),
+ nextDueAt: nextDueDate(
+ recurring.schedule,
+ scheduledFor,
+ recurring.timeZone,
+ ),
})
.where(eq(recurringInvoices.id, recurring.id));
});
diff --git a/apps/web/src/server/services/recurring-invoices.ts b/apps/web/src/server/services/recurring-invoices.ts
index 557a0eb..034fc53 100644
--- a/apps/web/src/server/services/recurring-invoices.ts
+++ b/apps/web/src/server/services/recurring-invoices.ts
@@ -4,27 +4,36 @@ import type {
recurringInvoiceItems,
recurringInvoices,
} from "~/server/db/schema";
+import {
+ addZonedCalendarInterval,
+ getZonedDateTimeParts,
+} from "@beenvoice/domain/time-zone";
-export function nextDueDate(schedule: string, from = new Date()): Date {
- const date = new Date(from);
- switch (schedule) {
- case "weekly":
- date.setDate(date.getDate() + 7);
- break;
- case "biweekly":
- date.setDate(date.getDate() + 14);
- break;
- case "monthly":
- date.setMonth(date.getMonth() + 1);
- break;
- case "quarterly":
- date.setMonth(date.getMonth() + 3);
- break;
- case "yearly":
- date.setFullYear(date.getFullYear() + 1);
- break;
+export function nextDueDate(
+ schedule: string,
+ from = new Date(),
+ timeZone = "America/New_York",
+): Date {
+ if (
+ !(
+ ["weekly", "biweekly", "monthly", "quarterly", "yearly"] as string[]
+ ).includes(schedule)
+ ) {
+ throw new RangeError("Invalid recurring schedule");
}
- return date;
+ return addZonedCalendarInterval(
+ from,
+ schedule as "weekly" | "biweekly" | "monthly" | "quarterly" | "yearly",
+ timeZone,
+ );
+}
+
+function calendarDateAt(value: Date, timeZone: string) {
+ const parts = getZonedDateTimeParts(value, timeZone);
+ const pad = (part: number) => String(part).padStart(2, "0");
+ return new Date(
+ `${parts.year}-${pad(parts.month)}-${pad(parts.day)}T00:00:00.000Z`,
+ );
}
type RecurringWithItems = typeof recurringInvoices.$inferSelect & {
@@ -34,10 +43,14 @@ type RecurringWithItems = typeof recurringInvoices.$inferSelect & {
export async function generateInvoiceFromRecurring(
db: Pick,
recurring: RecurringWithItems,
+ scheduledFor = new Date(),
): Promise<{ id: string }> {
- const now = new Date();
+ const issueDate = calendarDateAt(scheduledFor, recurring.timeZone);
const invoiceNumber = `REC-${Date.now()}`;
- const subtotal = recurring.items.reduce((sum, item) => sum + item.hours * item.rate, 0);
+ const subtotal = recurring.items.reduce(
+ (sum, item) => sum + item.hours * item.rate,
+ 0,
+ );
const taxAmount = (subtotal * recurring.taxRate) / 100;
const [newInvoice] = await db
@@ -47,8 +60,11 @@ export async function generateInvoiceFromRecurring(
invoicePrefix: recurring.invoicePrefix ?? "#",
clientId: recurring.clientId,
businessId: recurring.businessId ?? null,
- issueDate: now,
- dueDate: nextDueDate("monthly", now),
+ issueDate,
+ dueDate: calendarDateAt(
+ nextDueDate("monthly", scheduledFor, recurring.timeZone),
+ recurring.timeZone,
+ ),
status: "draft",
totalAmount: subtotal + taxAmount,
taxRate: recurring.taxRate,
@@ -65,7 +81,7 @@ export async function generateInvoiceFromRecurring(
await db.insert(invoiceItems).values(
recurring.items.map((item, index) => ({
invoiceId: newInvoice.id,
- date: now,
+ date: issueDate,
description: item.description,
hours: item.hours,
rate: item.rate,
diff --git a/apps/web/src/server/services/send-invoice-email.ts b/apps/web/src/server/services/send-invoice-email.ts
index c0efe59..c0d0838 100644
--- a/apps/web/src/server/services/send-invoice-email.ts
+++ b/apps/web/src/server/services/send-invoice-email.ts
@@ -231,6 +231,7 @@ export async function deliverInvoiceEmail(input: DeliverInvoiceEmailInput) {
userName,
userEmail,
baseUrl: input.baseUrl,
+ timeZone: invoice.createdBy.timeZone,
});
const sender = resolveEmailSender(invoice.business, userName);
diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts
index 9ab99bb..84fde2b 100644
--- a/apps/worker/src/index.ts
+++ b/apps/worker/src/index.ts
@@ -11,6 +11,7 @@ import {
type BackgroundJob,
} from "../../web/src/server/jobs/queue";
import { sendScheduledInvoice } from "./send-invoice";
+import { sendInvoiceReminder } from "../../web/src/server/jobs/handlers/invoice-reminder";
const workerId = `beenvoice-worker:${randomUUID()}`;
const pollMs = Number(process.env.WORKER_POLL_MS ?? 2_000);
@@ -69,6 +70,10 @@ async function handleJob(job: BackgroundJob) {
await sendScheduledInvoice(job);
return;
}
+ if (job.type === jobTypes.sendInvoiceReminder) {
+ await sendInvoiceReminder(job);
+ return;
+ }
throw new Error(`No handler registered for ${job.type}`);
}
diff --git a/apps/worker/tests/recurring-invoices.test.ts b/apps/worker/tests/recurring-invoices.test.ts
index 34c983f..7de6554 100644
--- a/apps/worker/tests/recurring-invoices.test.ts
+++ b/apps/worker/tests/recurring-invoices.test.ts
@@ -5,14 +5,14 @@ import { nextDueDate } from "../../web/src/server/services/recurring-invoices";
describe("recurring invoice scheduling", () => {
test("advances weekly schedules from their scheduled occurrence", () => {
const scheduledFor = new Date("2026-08-17T12:00:00.000Z");
- expect(nextDueDate("weekly", scheduledFor).toISOString()).toBe(
- "2026-08-24T12:00:00.000Z",
- );
+ expect(
+ nextDueDate("weekly", scheduledFor, "America/New_York").toISOString(),
+ ).toBe("2026-08-24T12:00:00.000Z");
});
test("does not mutate the source date", () => {
const scheduledFor = new Date("2026-08-17T12:00:00.000Z");
- nextDueDate("monthly", scheduledFor);
+ nextDueDate("monthly", scheduledFor, "America/New_York");
expect(scheduledFor.toISOString()).toBe("2026-08-17T12:00:00.000Z");
});
});
diff --git a/docker-compose.coolify.yml b/docker-compose.coolify.yml
index 0e7279f..d9fc363 100644
--- a/docker-compose.coolify.yml
+++ b/docker-compose.coolify.yml
@@ -23,6 +23,7 @@ services:
environment:
SERVICE_FQDN_APP:
NODE_ENV: production
+ TZ: UTC
PORT: ${APP_PORT:-3000}
AUTH_SECRET: ${AUTH_SECRET:?Set AUTH_SECRET in Coolify env}
DATABASE_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-postgres}
@@ -67,6 +68,7 @@ services:
image: ${BEENVOICE_WORKER_IMAGE:-beenvoice-worker:coolify}
environment:
NODE_ENV: production
+ TZ: UTC
DATABASE_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-postgres}
DB_DISABLE_SSL: "true"
AUTH_SECRET: ${AUTH_SECRET:?Set AUTH_SECRET in Coolify env}
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
index 61c3739..07fdd86 100644
--- a/docker-compose.dev.yml
+++ b/docker-compose.dev.yml
@@ -5,6 +5,7 @@ services:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: ${POSTGRES_DB:-postgres}
+ TZ: UTC
volumes:
- beenvoice_dev_pg_data:/var/lib/postgresql/data
healthcheck:
diff --git a/docker-compose.yml b/docker-compose.yml
index c70c05a..6c75282 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -20,6 +20,7 @@ services:
image: ${BEENVOICE_IMAGE:-beenvoice:local}
environment:
NODE_ENV: production
+ TZ: UTC
AUTH_SECRET: ${AUTH_SECRET:?Set AUTH_SECRET in .env}
DATABASE_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-postgres}
DB_DISABLE_SSL: "true"
@@ -63,6 +64,7 @@ services:
image: ${BEENVOICE_WORKER_IMAGE:-beenvoice-worker:local}
environment:
NODE_ENV: production
+ TZ: UTC
AUTH_SECRET: ${AUTH_SECRET:?Set AUTH_SECRET in .env}
DATABASE_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-postgres}
DB_DISABLE_SSL: "true"
@@ -85,6 +87,7 @@ services:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: ${POSTGRES_DB:-postgres}
+ TZ: UTC
volumes:
- beenvoice_pg_data:/var/lib/postgresql/data
healthcheck:
diff --git a/packages/domain/src/invoice-status.ts b/packages/domain/src/invoice-status.ts
index 74e99cc..f18bf84 100644
--- a/packages/domain/src/invoice-status.ts
+++ b/packages/domain/src/invoice-status.ts
@@ -4,33 +4,48 @@ export type EffectiveInvoiceStatus = StoredInvoiceStatus | "overdue";
export function getEffectiveInvoiceStatus(
storedStatus: StoredInvoiceStatus,
dueDate: Date | string,
+ timeZone = getLocalTimeZone(),
+ now = new Date(),
): EffectiveInvoiceStatus {
if (storedStatus === "paid" || storedStatus === "draft") return storedStatus;
-
- const today = new Date();
- const due = new Date(dueDate);
- today.setHours(0, 0, 0, 0);
- due.setHours(0, 0, 0, 0);
- return due < today ? "overdue" : "sent";
+ return calendarDateKey(dueDate) < zonedTodayKey(now, timeZone)
+ ? "overdue"
+ : "sent";
}
export function isInvoiceOverdue(
storedStatus: StoredInvoiceStatus,
dueDate: Date | string,
+ timeZone = getLocalTimeZone(),
): boolean {
- return getEffectiveInvoiceStatus(storedStatus, dueDate) === "overdue";
+ return (
+ getEffectiveInvoiceStatus(storedStatus, dueDate, timeZone) === "overdue"
+ );
}
export function getDaysPastDue(
storedStatus: StoredInvoiceStatus,
dueDate: Date | string,
+ timeZone = getLocalTimeZone(),
+ now = new Date(),
): number {
- if (!isInvoiceOverdue(storedStatus, dueDate)) return 0;
- const today = new Date();
- const due = new Date(dueDate);
- today.setHours(0, 0, 0, 0);
- due.setHours(0, 0, 0, 0);
- return Math.max(0, Math.ceil((today.getTime() - due.getTime()) / 86_400_000));
+ if (
+ getEffectiveInvoiceStatus(storedStatus, dueDate, timeZone, now) !==
+ "overdue"
+ )
+ return 0;
+ const dueKey = calendarDateKey(dueDate);
+ const todayKey = zonedTodayKey(now, timeZone);
+ return Math.max(
+ 0,
+ Math.round((Date.parse(todayKey) - Date.parse(dueKey)) / 86_400_000),
+ );
+}
+
+function zonedTodayKey(now: Date, timeZone: string) {
+ const parts = getZonedDateTimeParts(now, timeZone);
+ const pad = (value: number) => String(value).padStart(2, "0");
+ return `${parts.year}-${pad(parts.month)}-${pad(parts.day)}`;
}
export function getValidStatusTransitions(
@@ -52,3 +67,8 @@ export function isValidStatusTransition(
): boolean {
return getValidStatusTransitions(from).includes(to);
}
+import {
+ calendarDateKey,
+ getLocalTimeZone,
+ getZonedDateTimeParts,
+} from "./time-zone";
diff --git a/packages/domain/src/time-zone.ts b/packages/domain/src/time-zone.ts
index 893a251..731213d 100644
--- a/packages/domain/src/time-zone.ts
+++ b/packages/domain/src/time-zone.ts
@@ -1,4 +1,248 @@
-const FALLBACK_TIME_ZONE = "UTC";
+export const DEFAULT_TIME_ZONE = "America/New_York";
+const FALLBACK_TIME_ZONE = DEFAULT_TIME_ZONE;
+
+export type ZonedDateTimeDisambiguation = "earlier" | "later" | "reject";
+
+type DateTimeParts = {
+ year: number;
+ month: number;
+ day: number;
+ hour: number;
+ minute: number;
+ second: number;
+};
+
+const WALL_TIME_FORMATTERS = new Map();
+
+function wallTimeFormatter(timeZone: string) {
+ let formatter = WALL_TIME_FORMATTERS.get(timeZone);
+ if (!formatter) {
+ formatter = new Intl.DateTimeFormat("en-US-u-ca-gregory-nu-latn", {
+ timeZone,
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ hourCycle: "h23",
+ });
+ WALL_TIME_FORMATTERS.set(timeZone, formatter);
+ }
+ return formatter;
+}
+
+export function getZonedDateTimeParts(
+ value: Date | string | number,
+ timeZone: string,
+): DateTimeParts {
+ const date = value instanceof Date ? value : new Date(value);
+ if (Number.isNaN(date.getTime())) throw new RangeError("Invalid date");
+ if (!isValidTimeZone(timeZone)) throw new RangeError("Invalid time zone");
+ const parts = Object.fromEntries(
+ wallTimeFormatter(timeZone)
+ .formatToParts(date)
+ .filter((part) => part.type !== "literal")
+ .map((part) => [part.type, Number(part.value)]),
+ ) as Record;
+ return {
+ year: parts.year!,
+ month: parts.month!,
+ day: parts.day!,
+ hour: parts.hour!,
+ minute: parts.minute!,
+ second: parts.second!,
+ };
+}
+
+function sameWallTime(a: DateTimeParts, b: DateTimeParts) {
+ return (
+ a.year === b.year &&
+ a.month === b.month &&
+ a.day === b.day &&
+ a.hour === b.hour &&
+ a.minute === b.minute &&
+ a.second === b.second
+ );
+}
+
+function parseLocalDateTime(value: string): DateTimeParts {
+ const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(
+ value,
+ );
+ if (!match) throw new RangeError("Expected YYYY-MM-DDTHH:mm");
+ const parts = {
+ year: Number(match[1]),
+ month: Number(match[2]),
+ day: Number(match[3]),
+ hour: Number(match[4]),
+ minute: Number(match[5]),
+ second: Number(match[6] ?? 0),
+ };
+ const check = new Date(
+ Date.UTC(
+ parts.year,
+ parts.month - 1,
+ parts.day,
+ parts.hour,
+ parts.minute,
+ parts.second,
+ ),
+ );
+ if (
+ check.getUTCFullYear() !== parts.year ||
+ check.getUTCMonth() + 1 !== parts.month ||
+ check.getUTCDate() !== parts.day ||
+ parts.hour > 23 ||
+ parts.minute > 59 ||
+ parts.second > 59
+ ) {
+ throw new RangeError("Invalid local date and time");
+ }
+ return parts;
+}
+
+export function zonedDateTimeToInstant(
+ localDateTime: string,
+ timeZone: string,
+ disambiguation: ZonedDateTimeDisambiguation = "reject",
+): Date {
+ if (!isValidTimeZone(timeZone)) throw new RangeError("Invalid time zone");
+ const desired = parseLocalDateTime(localDateTime);
+ const wallAsUtc = Date.UTC(
+ desired.year,
+ desired.month - 1,
+ desired.day,
+ desired.hour,
+ desired.minute,
+ desired.second,
+ );
+
+ let candidateMs = wallAsUtc;
+ for (let attempt = 0; attempt < 3; attempt++) {
+ const observed = getZonedDateTimeParts(candidateMs, timeZone);
+ const observedAsUtc = Date.UTC(
+ observed.year,
+ observed.month - 1,
+ observed.day,
+ observed.hour,
+ observed.minute,
+ observed.second,
+ );
+ candidateMs += wallAsUtc - observedAsUtc;
+ }
+
+ const candidates = Array.from(
+ { length: 25 },
+ (_, index) => candidateMs + (index - 12) * 15 * 60_000,
+ )
+ .filter((value, index, all) => all.indexOf(value) === index)
+ .filter((value) =>
+ sameWallTime(getZonedDateTimeParts(value, timeZone), desired),
+ )
+ .sort((a, b) => a - b);
+ if (candidates.length === 0)
+ throw new RangeError("That local time does not exist");
+ if (candidates.length > 1 && disambiguation === "reject") {
+ throw new RangeError(
+ "That local time occurs twice; choose earlier or later",
+ );
+ }
+ return new Date(
+ disambiguation === "later" ? candidates.at(-1)! : candidates[0]!,
+ );
+}
+
+export function toZonedDateTimeInputValue(
+ value: Date | string | number,
+ timeZone: string,
+): string {
+ const parts = getZonedDateTimeParts(value, timeZone);
+ const pad = (part: number) => String(part).padStart(2, "0");
+ return `${parts.year}-${pad(parts.month)}-${pad(parts.day)}T${pad(parts.hour)}:${pad(parts.minute)}`;
+}
+
+export function addZonedCalendarInterval(
+ value: Date | string | number,
+ schedule: "weekly" | "biweekly" | "monthly" | "quarterly" | "yearly",
+ timeZone: string,
+): Date {
+ const source = getZonedDateTimeParts(value, timeZone);
+ const calendar = new Date(
+ Date.UTC(source.year, source.month - 1, source.day),
+ );
+ if (schedule === "weekly" || schedule === "biweekly") {
+ calendar.setUTCDate(
+ calendar.getUTCDate() + (schedule === "weekly" ? 7 : 14),
+ );
+ } else {
+ const months =
+ schedule === "monthly" ? 1 : schedule === "quarterly" ? 3 : 12;
+ const originalDay = calendar.getUTCDate();
+ calendar.setUTCDate(1);
+ calendar.setUTCMonth(calendar.getUTCMonth() + months);
+ const lastDay = new Date(
+ Date.UTC(calendar.getUTCFullYear(), calendar.getUTCMonth() + 1, 0),
+ ).getUTCDate();
+ calendar.setUTCDate(Math.min(originalDay, lastDay));
+ }
+ const pad = (part: number) => String(part).padStart(2, "0");
+ return zonedDateTimeToInstant(
+ `${calendar.getUTCFullYear()}-${pad(calendar.getUTCMonth() + 1)}-${pad(calendar.getUTCDate())}T${pad(source.hour)}:${pad(source.minute)}:${pad(source.second)}`,
+ timeZone,
+ "earlier",
+ );
+}
+
+export function formatCalendarDate(
+ value: Date | string,
+ options: Intl.DateTimeFormatOptions = {},
+): string {
+ const date =
+ value instanceof Date
+ ? value
+ : new Date(`${value.slice(0, 10)}T12:00:00.000Z`);
+ if (Number.isNaN(date.getTime())) return "Invalid date";
+ return new Intl.DateTimeFormat("en-US", {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ ...options,
+ timeZone: "UTC",
+ }).format(date);
+}
+
+export function calendarDateKey(value: Date | string): string {
+ if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}/.test(value))
+ return value.slice(0, 10);
+ const date = value instanceof Date ? value : new Date(value);
+ if (Number.isNaN(date.getTime())) throw new RangeError("Invalid date");
+ return date.toISOString().slice(0, 10);
+}
+
+export function calendarDateFromLocalDate(value: Date): Date {
+ return new Date(
+ Date.UTC(value.getFullYear(), value.getMonth(), value.getDate(), 12),
+ );
+}
+
+export function calendarDateToLocalDate(value: Date | string): Date {
+ const [year, month, day] = calendarDateKey(value).split("-").map(Number);
+ return new Date(year!, month! - 1, day!, 12);
+}
+
+export function calendarDateFromInstant(
+ value: Date | string | number,
+ timeZone: string,
+): Date {
+ const parts = getZonedDateTimeParts(value, timeZone);
+ return new Date(Date.UTC(parts.year, parts.month - 1, parts.day, 12));
+}
+
+export function addCalendarDays(value: Date | string, days: number): Date {
+ const [year, month, day] = calendarDateKey(value).split("-").map(Number);
+ return new Date(Date.UTC(year!, month! - 1, day! + days, 12));
+}
export function isValidTimeZone(value: string): boolean {
if (!value.trim()) return false;
diff --git a/packages/domain/tests/domain.test.ts b/packages/domain/tests/domain.test.ts
index b74b4b8..e2c4feb 100644
--- a/packages/domain/tests/domain.test.ts
+++ b/packages/domain/tests/domain.test.ts
@@ -3,14 +3,17 @@
import { describe, expect, test } from "bun:test";
import {
formatZonedDateTime,
+ addZonedCalendarInterval,
getDefaultScheduledSendAt,
isValidTimeZone,
toLocalDateTimeInputValue,
+ zonedDateTimeToInstant,
} from "../src/time-zone";
import {
EXPENSE_CATEGORIES,
formatElapsedSeconds,
getEffectiveInvoiceStatus,
+ getDaysPastDue,
parseReceiptText,
} from "../src";
@@ -26,6 +29,17 @@ describe("shared domain behavior", () => {
expect(getEffectiveInvoiceStatus("paid", yesterday)).toBe("paid");
});
+ test("counts calendar days rather than 24-hour blocks across fall DST", () => {
+ expect(
+ getDaysPastDue(
+ "sent",
+ "2026-11-01",
+ "America/New_York",
+ new Date("2026-11-02T17:00:00.000Z"),
+ ),
+ ).toBe(1);
+ });
+
test("formats elapsed time", () => {
expect(formatElapsedSeconds(3_661)).toBe("01:01:01");
});
@@ -56,6 +70,69 @@ describe("time-zone helpers", () => {
expect(isValidTimeZone("not/a-zone")).toBe(false);
});
+ test("converts Eastern wall time to the correct absolute instant", () => {
+ expect(
+ zonedDateTimeToInstant(
+ "2026-08-17T09:00",
+ "America/New_York",
+ ).toISOString(),
+ ).toBe("2026-08-17T13:00:00.000Z");
+ });
+
+ test("rejects nonexistent spring-forward wall times", () => {
+ expect(() =>
+ zonedDateTimeToInstant("2026-03-08T02:30", "America/New_York"),
+ ).toThrow("does not exist");
+ });
+
+ test("disambiguates both occurrences of a fall-back wall time", () => {
+ expect(
+ zonedDateTimeToInstant(
+ "2026-11-01T01:30",
+ "America/New_York",
+ "earlier",
+ ).toISOString(),
+ ).toBe("2026-11-01T05:30:00.000Z");
+ expect(
+ zonedDateTimeToInstant(
+ "2026-11-01T01:30",
+ "America/New_York",
+ "later",
+ ).toISOString(),
+ ).toBe("2026-11-01T06:30:00.000Z");
+ });
+
+ test("supports half-hour DST transitions", () => {
+ const earlier = zonedDateTimeToInstant(
+ "2026-04-05T01:45",
+ "Australia/Lord_Howe",
+ "earlier",
+ );
+ const later = zonedDateTimeToInstant(
+ "2026-04-05T01:45",
+ "Australia/Lord_Howe",
+ "later",
+ );
+ expect(later.getTime() - earlier.getTime()).toBe(30 * 60_000);
+ });
+
+ test("preserves Eastern wall time across DST and clamps month end", () => {
+ expect(
+ addZonedCalendarInterval(
+ new Date("2026-03-01T14:00:00.000Z"),
+ "weekly",
+ "America/New_York",
+ ).toISOString(),
+ ).toBe("2026-03-08T13:00:00.000Z");
+ expect(
+ addZonedCalendarInterval(
+ new Date("2026-01-31T14:00:00.000Z"),
+ "monthly",
+ "America/New_York",
+ ).toISOString(),
+ ).toBe("2026-02-28T14:00:00.000Z");
+ });
+
test("rounds the default schedule to the next local hour", () => {
const result = getDefaultScheduledSendAt(new Date(2026, 7, 17, 10, 42, 19));
expect(toLocalDateTimeInputValue(result)).toBe("2026-08-17T11:00");
|