Make scheduling and dates timezone-safe

This commit is contained in:
2026-08-17 18:15:39 -04:00
parent 1853eaa963
commit 70c08054fb
63 changed files with 2515 additions and 779 deletions
+124
View File
@@ -0,0 +1,124 @@
ALTER TABLE "beenvoice_user" ADD COLUMN IF NOT EXISTS "timeZone" varchar(100) DEFAULT 'America/New_York' NOT NULL;
--> statement-breakpoint
ALTER TABLE "beenvoice_recurring_invoice" ADD COLUMN IF NOT EXISTS "timeZone" varchar(100) DEFAULT 'America/New_York' NOT NULL;
--> statement-breakpoint
ALTER TABLE "beenvoice_invoice" ADD COLUMN IF NOT EXISTS "sendReminderJobId" varchar(255);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "beenvoice_push_token" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"userId" varchar(255) NOT NULL REFERENCES "beenvoice_user"("id") ON DELETE cascade,
"token" varchar(255) NOT NULL UNIQUE,
"platform" varchar(20) NOT NULL,
"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
"updatedAt" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "push_token_user_id_idx" ON "beenvoice_push_token" USING btree ("userId");
--> statement-breakpoint
ALTER TABLE "beenvoice_invoice" ALTER COLUMN "issueDate" TYPE date USING "issueDate"::date;
--> statement-breakpoint
ALTER TABLE "beenvoice_invoice" ALTER COLUMN "dueDate" TYPE date USING "dueDate"::date;
--> statement-breakpoint
ALTER TABLE "beenvoice_invoice_item" ALTER COLUMN "date" TYPE date USING "date"::date;
--> statement-breakpoint
ALTER TABLE "beenvoice_expense" ALTER COLUMN "date" TYPE date USING "date"::date;
--> statement-breakpoint
ALTER TABLE "beenvoice_invoice_payment" ALTER COLUMN "date" TYPE date USING "date"::date;
--> statement-breakpoint
ALTER TABLE "beenvoice_user" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_user" ALTER COLUMN "updatedAt" TYPE timestamp with time zone USING "updatedAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_user" ALTER COLUMN "resetTokenExpiry" TYPE timestamp with time zone USING "resetTokenExpiry" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_user" ALTER COLUMN "onboardingCompletedAt" TYPE timestamp with time zone USING "onboardingCompletedAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_platform_setting" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_platform_setting" ALTER COLUMN "updatedAt" TYPE timestamp with time zone USING "updatedAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_audit_log" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_account" ALTER COLUMN "accessTokenExpiresAt" TYPE timestamp with time zone USING "accessTokenExpiresAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_account" ALTER COLUMN "refreshTokenExpiresAt" TYPE timestamp with time zone USING "refreshTokenExpiresAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_account" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_account" ALTER COLUMN "updatedAt" TYPE timestamp with time zone USING "updatedAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_session" ALTER COLUMN "expiresAt" TYPE timestamp with time zone USING "expiresAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_session" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_session" ALTER COLUMN "updatedAt" TYPE timestamp with time zone USING "updatedAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_api_key" ALTER COLUMN "lastUsedAt" TYPE timestamp with time zone USING "lastUsedAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_api_key" ALTER COLUMN "expiresAt" TYPE timestamp with time zone USING "expiresAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_api_key" ALTER COLUMN "revokedAt" TYPE timestamp with time zone USING "revokedAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_api_key" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_api_key" ALTER COLUMN "updatedAt" TYPE timestamp with time zone USING "updatedAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_verification_token" ALTER COLUMN "expiresAt" TYPE timestamp with time zone USING "expiresAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_verification_token" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_verification_token" ALTER COLUMN "updatedAt" TYPE timestamp with time zone USING "updatedAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_sso_provider" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_sso_provider" ALTER COLUMN "updatedAt" TYPE timestamp with time zone USING "updatedAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_client" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_client" ALTER COLUMN "updatedAt" TYPE timestamp with time zone USING "updatedAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_business" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_business" ALTER COLUMN "updatedAt" TYPE timestamp with time zone USING "updatedAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_invoice" ALTER COLUMN "publicTokenExpiresAt" TYPE timestamp with time zone USING "publicTokenExpiresAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_invoice" ALTER COLUMN "lastReminderSentAt" TYPE timestamp with time zone USING "lastReminderSentAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_invoice" ALTER COLUMN "sendReminderAt" TYPE timestamp with time zone USING "sendReminderAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_invoice" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_invoice" ALTER COLUMN "updatedAt" TYPE timestamp with time zone USING "updatedAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_invoice_item" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_expense" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_expense" ALTER COLUMN "updatedAt" TYPE timestamp with time zone USING "updatedAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_expense_receipt" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_invoice_template" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_invoice_template" ALTER COLUMN "updatedAt" TYPE timestamp with time zone USING "updatedAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_invoice_payment" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_recurring_invoice" ALTER COLUMN "nextDueAt" TYPE timestamp with time zone USING "nextDueAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_recurring_invoice" ALTER COLUMN "lastGeneratedAt" TYPE timestamp with time zone USING "lastGeneratedAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_recurring_invoice" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_recurring_invoice" ALTER COLUMN "updatedAt" TYPE timestamp with time zone USING "updatedAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_recurring_invoice_item" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_time_entry" ALTER COLUMN "startedAt" TYPE timestamp with time zone USING "startedAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_time_entry" ALTER COLUMN "endedAt" TYPE timestamp with time zone USING "endedAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_time_entry" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_time_entry" ALTER COLUMN "updatedAt" TYPE timestamp with time zone USING "updatedAt" AT TIME ZONE 'UTC';
+7
View File
@@ -218,6 +218,13 @@
"when": 1786946793000,
"tag": "0030_scheduled_invoice_sends",
"breakpoints": true
},
{
"idx": 31,
"version": "7",
"when": 1786950000000,
"tag": "0031_timezone_safety",
"breakpoints": true
}
]
}
+50 -20
View File
@@ -14,6 +14,7 @@ type ToolResult = {
type McpCaller = ReturnType<typeof createCaller>;
const dateString = z.string().min(1);
const calendarDateString = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
const emptyableString = z.string().optional().or(z.literal(""));
const invoiceStatus = z.enum(["draft", "sent", "paid"]);
const paymentMethod = z.enum([
@@ -26,7 +27,7 @@ const paymentMethod = z.enum([
]);
const invoiceItemSchema = z.object({
date: dateString,
date: calendarDateString,
description: z.string().min(1),
hours: z.number().min(0),
rate: z.number().min(0),
@@ -68,8 +69,8 @@ const invoiceCreateSchema = z.object({
invoicePrefix: z.string().optional(),
businessId: emptyableString,
clientId: z.string().min(1),
issueDate: dateString,
dueDate: dateString,
issueDate: calendarDateString,
dueDate: calendarDateString,
status: invoiceStatus.default("draft"),
notes: emptyableString,
emailMessage: emptyableString,
@@ -83,7 +84,7 @@ const invoiceUpdateSchema = invoiceCreateSchema.partial().extend({
});
const expenseCreateSchema = z.object({
date: dateString,
date: calendarDateString,
description: z.string().min(1),
amount: z.number().min(0),
currency: z.string().length(3).default("USD"),
@@ -118,6 +119,9 @@ const recurringCreateSchema = 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().default("America/New_York"),
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),
});
@@ -151,7 +155,7 @@ const jsonSchemas = {
properties: {
invoiceId: { type: "string" },
amount: { type: "number", exclusiveMinimum: 0 },
date: { type: "string", format: "date-time" },
date: { type: "string", format: "date" },
method: {
type: "string",
enum: [
@@ -193,11 +197,17 @@ const jsonSchemas = {
invoicePrefix: { type: "string" },
businessId: { type: "string" },
clientId: { type: "string", minLength: 1 },
issueDate: { type: "string", format: "date-time" },
dueDate: { type: "string", format: "date-time" },
issueDate: { type: "string", format: "date" },
dueDate: { type: "string", format: "date" },
status: { type: "string", enum: ["draft", "sent", "paid"] },
notes: { type: "string" },
emailMessage: { type: "string" },
timeZone: { type: "string", description: "IANA time zone" },
nextRunLocal: {
type: "string",
description: "First/next wall time as YYYY-MM-DDTHH:mm in timeZone",
},
disambiguation: { type: "string", enum: ["earlier", "later", "reject"] },
taxRate: { type: "number", minimum: 0, maximum: 100 },
currency: { type: "string", minLength: 3, maxLength: 3 },
items: {
@@ -206,7 +216,7 @@ const jsonSchemas = {
items: {
type: "object",
properties: {
date: { type: "string", format: "date-time" },
date: { type: "string", format: "date" },
description: { type: "string", minLength: 1 },
hours: { type: "number", minimum: 0 },
rate: { type: "number", minimum: 0 },
@@ -243,7 +253,7 @@ const jsonSchemas = {
expenseCreate: {
type: "object",
properties: {
date: { type: "string", format: "date-time" },
date: { type: "string", format: "date" },
description: { type: "string", minLength: 1 },
amount: { type: "number", minimum: 0 },
currency: { type: "string", minLength: 3, maxLength: 3 },
@@ -314,7 +324,7 @@ const jsonSchemas = {
},
},
},
required: ["name", "clientId", "schedule", "items"],
required: ["name", "clientId", "schedule", "nextRunLocal", "items"],
additionalProperties: false,
},
invoiceSend: {
@@ -413,10 +423,30 @@ function parseDate(value: string, fieldName: string) {
return date;
}
function parseCalendarDate(value: string, fieldName: string) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `${fieldName} must use YYYY-MM-DD`,
});
}
const date = new Date(`${value}T12:00:00.000Z`);
if (
Number.isNaN(date.getTime()) ||
date.toISOString().slice(0, 10) !== value
) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `${fieldName} is not a valid date`,
});
}
return date;
}
function parseInvoiceItems(items: z.infer<typeof invoiceItemSchema>[]) {
return items.map((item) => ({
...item,
date: parseDate(item.date, "item.date"),
date: parseCalendarDate(item.date, "item.date"),
}));
}
@@ -464,8 +494,8 @@ const tools = {
handler: async (input, caller) =>
caller.invoices.create({
...input,
issueDate: parseDate(input.issueDate, "issueDate"),
dueDate: parseDate(input.dueDate, "dueDate"),
issueDate: parseCalendarDate(input.issueDate, "issueDate"),
dueDate: parseCalendarDate(input.dueDate, "dueDate"),
items: parseInvoiceItems(input.items),
}),
}),
@@ -484,10 +514,10 @@ const tools = {
caller.invoices.update({
...input,
issueDate: input.issueDate
? parseDate(input.issueDate, "issueDate")
? parseCalendarDate(input.issueDate, "issueDate")
: undefined,
dueDate: input.dueDate
? parseDate(input.dueDate, "dueDate")
? parseCalendarDate(input.dueDate, "dueDate")
: undefined,
items: input.items ? parseInvoiceItems(input.items) : undefined,
}),
@@ -516,14 +546,14 @@ const tools = {
schema: z.object({
invoiceId: z.string(),
amount: z.number().positive(),
date: dateString,
date: calendarDateString,
method: paymentMethod.default("other"),
notes: z.string().max(500).optional(),
}),
handler: async (input, caller) =>
caller.payments.create({
...input,
date: parseDate(input.date, "date"),
date: parseCalendarDate(input.date, "date"),
}),
}),
payments_delete: defineTool({
@@ -829,7 +859,7 @@ const tools = {
handler: async (input, caller) =>
caller.expenses.create({
...input,
date: parseDate(input.date, "date"),
date: parseCalendarDate(input.date, "date"),
}),
}),
expenses_update: defineTool({
@@ -847,7 +877,7 @@ const tools = {
handler: async (input, caller) =>
caller.expenses.update({
...input,
date: input.date ? parseDate(input.date, "date") : undefined,
date: input.date ? parseCalendarDate(input.date, "date") : undefined,
}),
}),
expenses_delete: defineTool({
@@ -876,7 +906,7 @@ const tools = {
description: "Update a recurring invoice template. Replaces all items.",
inputSchema: {
...jsonSchemas.recurringCreate,
required: ["id", "name", "clientId", "schedule", "items"],
required: ["id", "name", "clientId", "schedule", "nextRunLocal", "items"],
properties: {
id: { type: "string" },
...jsonSchemas.recurringCreate.properties,
@@ -23,6 +23,7 @@ import {
} from "lucide-react";
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
import type { StoredInvoiceStatus } from "~/types/invoice";
import { formatCalendarDate } from "@beenvoice/domain/time-zone";
interface ClientDetailPageProps {
params: Promise<{ id: string }>;
@@ -34,17 +35,19 @@ export default async function ClientDetailPage({
const { id } = await params;
const client = await api.clients.getById({ id });
const profile = await api.settings.getProfile();
const timeZone = profile?.timeZone ?? "America/New_York";
if (!client) {
notFound();
}
const formatDate = (date: Date) => {
return new Intl.DateTimeFormat("en-US", {
return formatCalendarDate(date, {
year: "numeric",
month: "long",
day: "numeric",
}).format(date);
});
};
const formatCurrency = (amount: number) => {
@@ -249,16 +252,19 @@ export default async function ClientDetailPage({
getEffectiveInvoiceStatus(
invoice.status as StoredInvoiceStatus,
invoice.dueDate,
timeZone,
) === "paid"
? "default"
: getEffectiveInvoiceStatus(
invoice.status as StoredInvoiceStatus,
invoice.dueDate,
timeZone,
) === "sent"
? "secondary"
: getEffectiveInvoiceStatus(
invoice.status as StoredInvoiceStatus,
invoice.dueDate,
timeZone,
) === "overdue"
? "destructive"
: "outline"
@@ -268,6 +274,7 @@ export default async function ClientDetailPage({
{getEffectiveInvoiceStatus(
invoice.status as StoredInvoiceStatus,
invoice.dueDate,
timeZone,
)}
</Badge>
</div>
+11 -4
View File
@@ -44,6 +44,10 @@ import {
} from "lucide-react";
import { formatCurrency, SUPPORTED_CURRENCIES } from "~/lib/currency";
import { EXPENSE_CATEGORIES } from "~/lib/expense-categories";
import {
calendarDateFromLocalDate,
formatCalendarDate,
} from "@beenvoice/domain/time-zone";
import {
DropdownMenu,
DropdownMenuContent,
@@ -66,7 +70,7 @@ interface ExpenseFormData {
}
const defaultForm: ExpenseFormData = {
date: new Date(),
date: calendarDateFromLocalDate(new Date()),
description: "",
amount: 0,
currency: "USD",
@@ -473,11 +477,11 @@ export default function ExpensesPage() {
)}
</div>
<p className="text-muted-foreground mt-0.5 text-xs">
{new Intl.DateTimeFormat("en-US", {
{formatCalendarDate(expense.date, {
month: "short",
day: "numeric",
year: "numeric",
}).format(new Date(expense.date))}
})}
{expense.business ? ` · ${expense.business.name}` : ""}
{expense.client ? ` · ${expense.client.name}` : ""}
</p>
@@ -690,7 +694,10 @@ export default function ExpensesPage() {
<DatePicker
date={form.date}
onDateChange={(d) =>
setForm((p) => ({ ...p, date: d ?? new Date() }))
setForm((p) => ({
...p,
date: d ?? calendarDateFromLocalDate(new Date()),
}))
}
className="w-full"
/>
@@ -2,17 +2,15 @@
import type { ColumnDef } from "@tanstack/react-table";
import { DataTable } from "~/components/data/data-table";
import {
formatLineItemDetail,
isFixedLineItem,
} from "~/lib/invoice-line-item";
import { formatLineItemDetail, isFixedLineItem } from "~/lib/invoice-line-item";
import { formatCalendarDate } from "@beenvoice/domain/time-zone";
const formatDate = (date: Date) => {
return new Intl.DateTimeFormat("en-US", {
return formatCalendarDate(date, {
year: "numeric",
month: "short",
day: "numeric",
}).format(new Date(date));
});
};
const formatCurrency = (amount: number) => {
@@ -20,7 +20,14 @@ import {
Trash2,
User,
} from "lucide-react";
import { formatZonedDateTime } from "@beenvoice/domain/time-zone";
import {
DEFAULT_TIME_ZONE,
calendarDateFromLocalDate,
formatCalendarDate,
formatZonedDateTime,
toZonedDateTimeInputValue,
zonedDateTimeToInstant,
} from "@beenvoice/domain/time-zone";
import Link from "next/link";
import {
notFound,
@@ -65,7 +72,6 @@ import { Separator } from "~/components/ui/separator";
import { Textarea } from "~/components/ui/textarea";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { DatePicker } from "~/components/ui/date-picker";
import {
getEffectiveInvoiceStatus,
isInvoiceOverdue,
@@ -110,6 +116,8 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
const { data: invoice, isLoading } = api.invoices.getById.useQuery({
id: invoiceId,
});
const { data: profile } = api.settings.getProfile.useQuery();
const timeZone = profile?.timeZone ?? DEFAULT_TIME_ZONE;
const { data: payments, isLoading: paymentsLoading } =
api.payments.getByInvoice.useQuery({ invoiceId });
const utils = api.useUtils();
@@ -201,11 +209,11 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
if (!invoice) notFound();
const formatDate = (date: Date) =>
new Intl.DateTimeFormat("en-US", {
formatCalendarDate(date, {
year: "numeric",
month: "short",
day: "numeric",
}).format(new Date(date));
});
const formatCurrency = (amount: number, currency = invoice.currency) =>
new Intl.NumberFormat("en-US", { style: "currency", currency }).format(
@@ -221,8 +229,9 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
const effectiveStatus = getEffectiveInvoiceStatus(
storedStatus,
invoice.dueDate,
timeZone,
);
const isOverdue = isInvoiceOverdue(storedStatus, invoice.dueDate);
const isOverdue = isInvoiceOverdue(storedStatus, invoice.dueDate, timeZone);
const canSendReminder =
effectiveStatus === "sent" || effectiveStatus === "overdue";
@@ -246,7 +255,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
createPayment.mutate({
invoiceId,
amount,
date: new Date(),
date: calendarDateFromLocalDate(new Date()),
method: paymentMethod as Parameters<
typeof createPayment.mutate
>[0]["method"],
@@ -694,7 +703,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
key={`${invoiceId}-${invoice.sendReminderAt?.toISOString() ?? "none"}`}
invoiceId={invoiceId}
savedReminderAt={invoice.sendReminderAt}
formatDate={formatDate}
timeZone={timeZone}
isSaving={updateInvoice.isPending}
onSave={(sendReminderAt) =>
updateInvoice.mutate({
@@ -991,28 +1000,30 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
function SendReminderEditor({
invoiceId,
savedReminderAt,
formatDate,
timeZone,
isSaving,
onSave,
onClear,
}: {
invoiceId: string;
savedReminderAt: Date | null | undefined;
formatDate: (date: Date) => string;
timeZone: string;
isSaving: boolean;
onSave: (sendReminderAt: Date | null) => void;
onClear: () => void;
}) {
const [sendReminderAt, setSendReminderAt] = useState<Date | undefined>(() =>
savedReminderAt ? new Date(savedReminderAt) : undefined,
const [sendReminderAt, setSendReminderAt] = useState(() =>
savedReminderAt ? toZonedDateTimeInputValue(savedReminderAt, timeZone) : "",
);
return (
<div className="space-y-2 rounded-lg border p-3">
<Label htmlFor={`send-reminder-at-${invoiceId}`}>Remind me to send</Label>
<DatePicker
date={sendReminderAt}
onDateChange={setSendReminderAt}
<Input
id={`send-reminder-at-${invoiceId}`}
type="datetime-local"
value={sendReminderAt}
onChange={(event) => setSendReminderAt(event.target.value)}
className="w-full"
/>
<div className="flex gap-2">
@@ -1020,7 +1031,21 @@ function SendReminderEditor({
variant="outline"
size="sm"
className="flex-1"
onClick={() => onSave(sendReminderAt ?? null)}
onClick={() => {
try {
onSave(
sendReminderAt
? zonedDateTimeToInstant(sendReminderAt, timeZone, "earlier")
: null,
);
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Invalid reminder time",
);
}
}}
disabled={isSaving}
>
Save reminder
@@ -1030,7 +1055,7 @@ function SendReminderEditor({
variant="ghost"
size="sm"
onClick={() => {
setSendReminderAt(undefined);
setSendReminderAt("");
onClear();
}}
>
@@ -1042,7 +1067,7 @@ function SendReminderEditor({
<p className="text-muted-foreground text-xs">
{new Date(savedReminderAt) <= new Date()
? "Reminder is due — time to send this invoice."
: `Scheduled for ${formatDate(savedReminderAt)}`}
: `Scheduled for ${formatZonedDateTime(savedReminderAt, timeZone)}`}
</p>
) : null}
</div>
@@ -12,9 +12,17 @@ import { Input } from "~/components/ui/input";
import {
formatZonedDateTime,
getDefaultScheduledSendAt,
getLocalTimeZone,
toLocalDateTimeInputValue,
DEFAULT_TIME_ZONE,
toZonedDateTimeInputValue,
zonedDateTimeToInstant,
} from "@beenvoice/domain/time-zone";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import {
Dialog,
DialogContent,
@@ -114,6 +122,9 @@ export default function SendEmailPage() {
const [showScheduleDialog, setShowScheduleDialog] = useState(false);
const [scheduledAt, setScheduledAt] = useState("");
const [minimumScheduledAt, setMinimumScheduledAt] = useState("");
const [scheduleDisambiguation, setScheduleDisambiguation] = useState<
"earlier" | "later"
>("earlier");
const [retryCount, setRetryCount] = useState(0);
// Email content state
@@ -128,10 +139,11 @@ export default function SendEmailPage() {
api.invoices.getById.useQuery({
id: invoiceId,
});
const { data: profile } = api.settings.getProfile.useQuery();
// Get utils for cache invalidation
const utils = api.useUtils();
const timeZone = useMemo(() => getLocalTimeZone(), []);
const timeZone = profile?.timeZone ?? DEFAULT_TIME_ZONE;
// Email sending mutation
const sendEmailMutation = api.email.sendInvoice.useMutation({
@@ -330,7 +342,20 @@ export default function SendEmailPage() {
};
const confirmScheduleEmail = async () => {
const sendAt = new Date(scheduledAt);
let sendAt: Date;
try {
sendAt = zonedDateTimeToInstant(
scheduledAt,
timeZone,
scheduleDisambiguation,
);
} catch (error) {
toast.error("Choose a valid local send time", {
description:
error instanceof Error ? error.message : "Invalid date and time",
});
return;
}
if (
Number.isNaN(sendAt.getTime()) ||
sendAt.getTime() < Date.now() + 60_000
@@ -340,13 +365,6 @@ export default function SendEmailPage() {
});
return;
}
if (toLocalDateTimeInputValue(sendAt) !== scheduledAt) {
toast.error("That local time does not exist", {
description:
"Choose another time. The selected value falls inside a daylight-saving clock change.",
});
return;
}
try {
await scheduleEmailMutation.mutateAsync({
invoiceId,
@@ -685,10 +703,13 @@ export default function SendEmailPage() {
<Button
onClick={() => {
setMinimumScheduledAt(
toLocalDateTimeInputValue(new Date(Date.now() + 60_000)),
toZonedDateTimeInputValue(
new Date(Date.now() + 60_000),
timeZone,
),
);
setScheduledAt(
toLocalDateTimeInputValue(getDefaultScheduledSendAt()),
toZonedDateTimeInputValue(getDefaultScheduledSendAt(), timeZone),
);
setShowScheduleDialog(true);
}}
@@ -800,6 +821,23 @@ export default function SendEmailPage() {
instant, so daylight saving changes and other devices will not
shift this send.
</p>
<div className="space-y-2">
<Label>Repeated DST hour</Label>
<Select
value={scheduleDisambiguation}
onValueChange={(value) =>
setScheduleDisambiguation(value as "earlier" | "later")
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="earlier">First occurrence</SelectItem>
<SelectItem value="later">Second occurrence</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button
@@ -38,6 +38,7 @@ import { toast } from "sonner";
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
import { formatCurrency } from "~/lib/currency";
import type { StoredInvoiceStatus } from "~/types/invoice";
import { formatCalendarDate } from "@beenvoice/domain/time-zone";
interface Invoice {
id: string;
@@ -81,22 +82,27 @@ interface Invoice {
interface InvoicesDataTableProps {
invoices: Invoice[];
timeZone: string;
}
const getStatusType = (invoice: Invoice): StatusType =>
const getStatusType = (invoice: Invoice, timeZone: string): StatusType =>
getEffectiveInvoiceStatus(
invoice.status as StoredInvoiceStatus,
invoice.dueDate,
timeZone,
);
const formatDate = (date: Date) =>
new Intl.DateTimeFormat("en-US", {
formatCalendarDate(date, {
month: "short",
day: "2-digit",
year: "numeric",
}).format(new Date(date));
});
export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
export function InvoicesDataTable({
invoices,
timeZone,
}: InvoicesDataTableProps) {
const router = useRouter();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [invoiceToDelete, setInvoiceToDelete] = useState<Invoice | null>(null);
@@ -183,7 +189,7 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
</p>
<div className="mt-1 flex items-center gap-2 sm:hidden">
<StatusBadge
status={getStatusType(invoice)}
status={getStatusType(invoice, timeZone)}
className="text-xs"
/>
<span className="text-foreground text-xs font-semibold">
@@ -218,14 +224,16 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
),
cell: ({ row }) => (
<StatusBadge
status={getStatusType(row.original)}
status={getStatusType(row.original, timeZone)}
className={
getStatusType(row.original) === "sent" ? "status-pending" : ""
getStatusType(row.original, timeZone) === "sent"
? "status-pending"
: ""
}
/>
),
filterFn: (row, _id, value: string[]) =>
value.includes(getStatusType(row.original)),
value.includes(getStatusType(row.original, timeZone)),
meta: {
headerClassName: "hidden sm:table-cell",
cellClassName: "hidden sm:table-cell",
+7 -1
View File
@@ -11,8 +11,14 @@ import { DataTableSkeleton } from "~/components/data/data-table";
// Invoices Table Component
async function InvoicesTable() {
const invoices = await api.invoices.getAll();
const profile = await api.settings.getProfile();
return <InvoicesDataTable invoices={invoices} />;
return (
<InvoicesDataTable
invoices={invoices}
timeZone={profile?.timeZone ?? "America/New_York"}
/>
);
}
export default async function InvoicesPage() {
@@ -39,6 +39,12 @@ import {
} from "~/components/ui/select";
import { Textarea } from "~/components/ui/textarea";
import { api } from "~/trpc/react";
import {
DEFAULT_TIME_ZONE,
formatZonedDateTime,
getDefaultScheduledSendAt,
toZonedDateTimeInputValue,
} from "@beenvoice/domain/time-zone";
const SCHEDULES = [
{ value: "weekly", label: "Weekly" },
@@ -66,10 +72,13 @@ interface RecurringFormState {
currency: string;
notes: string;
emailMessage: string;
timeZone: string;
nextRunLocal: string;
disambiguation: "earlier" | "later" | "reject";
items: RecurringItemInput[];
}
const defaultForm = (): RecurringFormState => ({
const defaultForm = (timeZone = DEFAULT_TIME_ZONE): RecurringFormState => ({
name: "",
clientId: "",
businessId: "",
@@ -79,15 +88,17 @@ const defaultForm = (): RecurringFormState => ({
currency: "USD",
notes: "",
emailMessage: "",
timeZone,
nextRunLocal: toZonedDateTimeInputValue(
getDefaultScheduledSendAt(),
timeZone,
),
disambiguation: "reject",
items: [{ description: "", hours: 0, rate: 0 }],
});
function formatDate(date: Date) {
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "short",
day: "numeric",
}).format(new Date(date));
function formatDate(date: Date, timeZone: string) {
return formatZonedDateTime(date, timeZone);
}
function scheduleLabel(s: string) {
@@ -106,19 +117,28 @@ function RecurringForm({
businesses: { id: string; name: string }[];
}) {
const addItem = () =>
setForm((f) => ({ ...f, items: [...f.items, { description: "", hours: 0, rate: 0 }] }));
setForm((f) => ({
...f,
items: [...f.items, { description: "", hours: 0, rate: 0 }],
}));
const removeItem = (idx: number) =>
setForm((f) => ({ ...f, items: f.items.filter((_, i) => i !== idx) }));
const updateItem = (idx: number, field: keyof RecurringItemInput, value: string | number) =>
const updateItem = (
idx: number,
field: keyof RecurringItemInput,
value: string | number,
) =>
setForm((f) => ({
...f,
items: f.items.map((item, i) => (i === idx ? { ...item, [field]: value } : item)),
items: f.items.map((item, i) =>
i === idx ? { ...item, [field]: value } : item,
),
}));
return (
<div className="space-y-4 max-h-[60vh] overflow-y-auto pr-1">
<div className="max-h-[60vh] space-y-4 overflow-y-auto pr-1">
<div className="space-y-1.5">
<Label>Template name</Label>
<Input
@@ -173,7 +193,9 @@ function RecurringForm({
<Label>Schedule</Label>
<Select
value={form.schedule}
onValueChange={(v) => setForm((f) => ({ ...f, schedule: v as Schedule }))}
onValueChange={(v) =>
setForm((f) => ({ ...f, schedule: v as Schedule }))
}
>
<SelectTrigger>
<SelectValue />
@@ -193,11 +215,65 @@ function RecurringForm({
maxLength={3}
placeholder="USD"
value={form.currency}
onChange={(e) => setForm((f) => ({ ...f, currency: e.target.value.toUpperCase() }))}
onChange={(e) =>
setForm((f) => ({ ...f, currency: e.target.value.toUpperCase() }))
}
/>
</div>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="recurring-next-run">First/next run</Label>
<Input
id="recurring-next-run"
type="datetime-local"
value={form.nextRunLocal}
onChange={(event) =>
setForm((current) => ({
...current,
nextRunLocal: event.target.value,
}))
}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="recurring-time-zone">Time zone</Label>
<Input
id="recurring-time-zone"
value={form.timeZone}
onChange={(event) =>
setForm((current) => ({
...current,
timeZone: event.target.value,
}))
}
placeholder="America/New_York"
/>
</div>
</div>
<div className="space-y-1.5">
<Label>Repeated DST hour</Label>
<Select
value={form.disambiguation}
onValueChange={(value) =>
setForm((current) => ({
...current,
disambiguation: value as RecurringFormState["disambiguation"],
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="reject">Reject ambiguous time</SelectItem>
<SelectItem value="earlier">First occurrence</SelectItem>
<SelectItem value="later">Second occurrence</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Tax rate (%)</Label>
<NumberInput
@@ -226,7 +302,7 @@ function RecurringForm({
type="button"
size="sm"
variant="ghost"
className="text-destructive h-8 w-8 p-0 shrink-0"
className="text-destructive h-8 w-8 shrink-0 p-0"
onClick={() => removeItem(idx)}
>
<Trash2 className="h-3.5 w-3.5" />
@@ -281,7 +357,9 @@ export default function RecurringInvoicesPage() {
const [deleteId, setDeleteId] = useState<string | null>(null);
const [form, setForm] = useState<RecurringFormState>(defaultForm());
const { data: recurring, isLoading } = api.recurringInvoices.getAll.useQuery();
const { data: recurring, isLoading } =
api.recurringInvoices.getAll.useQuery();
const { data: profile } = api.settings.getProfile.useQuery();
const { data: clients = [] } = api.clients.getAll.useQuery();
const { data: businesses = [] } = api.businesses.getAll.useQuery();
const utils = api.useUtils();
@@ -289,27 +367,47 @@ export default function RecurringInvoicesPage() {
const invalidate = () => void utils.recurringInvoices.getAll.invalidate();
const create = api.recurringInvoices.create.useMutation({
onSuccess: () => { toast.success("Recurring invoice created"); setCreateOpen(false); setForm(defaultForm()); invalidate(); },
onSuccess: () => {
toast.success("Recurring invoice created");
setCreateOpen(false);
setForm(defaultForm());
invalidate();
},
onError: (e) => toast.error(e.message ?? "Failed to create"),
});
const update = api.recurringInvoices.update.useMutation({
onSuccess: () => { toast.success("Updated"); setEditId(null); setForm(defaultForm()); invalidate(); },
onSuccess: () => {
toast.success("Updated");
setEditId(null);
setForm(defaultForm());
invalidate();
},
onError: (e) => toast.error(e.message ?? "Failed to update"),
});
const pause = api.recurringInvoices.pause.useMutation({
onSuccess: () => { toast.success("Paused"); invalidate(); },
onSuccess: () => {
toast.success("Paused");
invalidate();
},
onError: (e) => toast.error(e.message),
});
const resume = api.recurringInvoices.resume.useMutation({
onSuccess: () => { toast.success("Resumed"); invalidate(); },
onSuccess: () => {
toast.success("Resumed");
invalidate();
},
onError: (e) => toast.error(e.message),
});
const del = api.recurringInvoices.delete.useMutation({
onSuccess: () => { toast.success("Deleted"); setDeleteId(null); invalidate(); },
onSuccess: () => {
toast.success("Deleted");
setDeleteId(null);
invalidate();
},
onError: (e) => toast.error(e.message),
});
@@ -333,6 +431,9 @@ export default function RecurringInvoicesPage() {
currency: rec.currency,
notes: rec.notes ?? "",
emailMessage: rec.emailMessage ?? "",
timeZone: rec.timeZone,
nextRunLocal: toZonedDateTimeInputValue(rec.nextDueAt, rec.timeZone),
disambiguation: "reject",
items: rec.items.map((i) => ({
description: i.description,
hours: i.hours,
@@ -365,7 +466,12 @@ export default function RecurringInvoicesPage() {
title="Recurring Invoices"
description="Schedule automatic invoice generation"
>
<Button onClick={() => { setForm(defaultForm()); setCreateOpen(true); }}>
<Button
onClick={() => {
setForm(defaultForm(profile?.timeZone));
setCreateOpen(true);
}}
>
<Plus className="mr-2 h-4 w-4" />
New recurring
</Button>
@@ -383,7 +489,12 @@ export default function RecurringInvoicesPage() {
title="Create your first recurring invoice"
description="Automatically generate draft invoices on a schedule you choose."
action={
<Button onClick={() => { setForm(defaultForm()); setCreateOpen(true); }}>
<Button
onClick={() => {
setForm(defaultForm(profile?.timeZone));
setCreateOpen(true);
}}
>
<Plus className="mr-2 h-4 w-4" />
Create recurring invoice
</Button>
@@ -400,7 +511,11 @@ export default function RecurringInvoicesPage() {
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<p className="font-semibold">{rec.name}</p>
<Badge variant={rec.status === "active" ? "default" : "secondary"}>
<Badge
variant={
rec.status === "active" ? "default" : "secondary"
}
>
{rec.status}
</Badge>
</div>
@@ -408,14 +523,18 @@ export default function RecurringInvoicesPage() {
{rec.client.name} · {scheduleLabel(rec.schedule)}
</p>
<p className="text-muted-foreground text-xs">
Next: {formatDate(rec.nextDueAt)}
Next: {formatDate(rec.nextDueAt, rec.timeZone)}
{rec.lastGeneratedAt && (
<> · Last generated: {formatDate(rec.lastGeneratedAt)}</>
<>
{" "}
· Last generated:{" "}
{formatDate(rec.lastGeneratedAt, rec.timeZone)}
</>
)}
</p>
</div>
<div className="flex flex-wrap gap-2 shrink-0">
<div className="flex shrink-0 flex-wrap gap-2">
<Button
size="sm"
variant="outline"
@@ -473,14 +592,21 @@ export default function RecurringInvoicesPage() {
<Dialog
open={createOpen || editId !== null}
onOpenChange={(open) => {
if (!open) { setCreateOpen(false); setEditId(null); setForm(defaultForm()); }
if (!open) {
setCreateOpen(false);
setEditId(null);
setForm(defaultForm());
}
}}
>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{editId ? "Edit recurring invoice" : "New recurring invoice"}</DialogTitle>
<DialogTitle>
{editId ? "Edit recurring invoice" : "New recurring invoice"}
</DialogTitle>
<DialogDescription>
Configure the template. Invoices will be generated as drafts on the selected schedule.
Configure the template. Invoices will be generated as drafts on
the selected schedule.
</DialogDescription>
</DialogHeader>
<RecurringForm
@@ -492,17 +618,30 @@ export default function RecurringInvoicesPage() {
<DialogFooter>
<Button
variant="outline"
onClick={() => { setCreateOpen(false); setEditId(null); setForm(defaultForm()); }}
onClick={() => {
setCreateOpen(false);
setEditId(null);
setForm(defaultForm());
}}
>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={isSubmitting || !form.name || !form.clientId}>
<Button
onClick={handleSubmit}
disabled={isSubmitting || !form.name || !form.clientId}
>
{isSubmitting ? (
<><Loader2 className="mr-2 h-4 w-4 animate-spin" /> Saving</>
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Saving
</>
) : editId ? (
<><Check className="mr-2 h-4 w-4" /> Save changes</>
<>
<Check className="mr-2 h-4 w-4" /> Save changes
</>
) : (
<><Plus className="mr-2 h-4 w-4" /> Create</>
<>
<Plus className="mr-2 h-4 w-4" /> Create
</>
)}
</Button>
</DialogFooter>
@@ -510,12 +649,18 @@ export default function RecurringInvoicesPage() {
</Dialog>
{/* Delete Confirmation */}
<Dialog open={deleteId !== null} onOpenChange={(open) => { if (!open) setDeleteId(null); }}>
<Dialog
open={deleteId !== null}
onOpenChange={(open) => {
if (!open) setDeleteId(null);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete recurring invoice</DialogTitle>
<DialogDescription>
This will stop automatic generation. Already-generated invoices are not affected.
This will stop automatic generation. Already-generated invoices
are not affected.
</DialogDescription>
</DialogHeader>
<DialogFooter>
+35 -17
View File
@@ -3,7 +3,10 @@
import { useMemo, useState } from "react";
import { api } from "~/trpc/react";
import { DashboardPageHeader } from "~/components/layout/page-header";
import { DashboardPage, dashboardStatGridClass } from "~/components/layout/dashboard-page";
import {
DashboardPage,
dashboardStatGridClass,
} from "~/components/layout/dashboard-page";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { StatusBadge } from "~/components/data/status-badge";
import { Button } from "~/components/ui/button";
@@ -24,6 +27,10 @@ import {
import { formatCurrency } from "~/lib/currency";
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
import type { StoredInvoiceStatus } from "~/types/invoice";
import {
formatCalendarDate,
getZonedDateTimeParts,
} from "@beenvoice/domain/time-zone";
import {
AreaChart,
Area,
@@ -63,7 +70,9 @@ export default function ReportsPage() {
const isLoading = invoicesLoading || expensesLoading;
const currentYear = new Date().getFullYear();
const { data: profile } = api.settings.getProfile.useQuery();
const reportTimeZone = profile?.timeZone ?? "America/New_York";
const currentYear = getZonedDateTimeParts(new Date(), reportTimeZone).year;
const [taxYear, setTaxYear] = useState(String(currentYear));
const filteredInvoices = useMemo(() => {
@@ -76,10 +85,11 @@ export default function ReportsPage() {
if (!filteredInvoices.length) return null;
const now = new Date();
const current = getZonedDateTimeParts(now, reportTimeZone);
const monthMap: Record<string, number> = {};
for (let i = 11; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
const d = new Date(Date.UTC(current.year, current.month - 1 - i, 1));
const key = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`;
monthMap[key] = 0;
}
@@ -91,10 +101,11 @@ export default function ReportsPage() {
const status = getEffectiveInvoiceStatus(
inv.status as StoredInvoiceStatus,
inv.dueDate,
reportTimeZone,
);
if (status === "paid") {
totalRevenue += inv.totalAmount;
const key = `${new Date(inv.issueDate).getFullYear()}-${String(new Date(inv.issueDate).getMonth() + 1).padStart(2, "0")}`;
const key = `${new Date(inv.issueDate).getUTCFullYear()}-${String(new Date(inv.issueDate).getUTCMonth() + 1).padStart(2, "0")}`;
if (monthMap[key] !== undefined) monthMap[key] += inv.totalAmount;
} else if (status === "sent" || status === "overdue") {
totalPending += inv.totalAmount;
@@ -103,7 +114,7 @@ export default function ReportsPage() {
}
const revenueByMonth = Object.entries(monthMap).map(([month, revenue]) => ({
month: new Date(month + "-01").toLocaleDateString("en-US", {
month: formatCalendarDate(month + "-01", {
month: "short",
year: "2-digit",
}),
@@ -115,6 +126,7 @@ export default function ReportsPage() {
const status = getEffectiveInvoiceStatus(
inv.status as StoredInvoiceStatus,
inv.dueDate,
reportTimeZone,
);
if (status === "paid" && inv.client) {
const id = inv.client.id;
@@ -139,6 +151,7 @@ export default function ReportsPage() {
const s = getEffectiveInvoiceStatus(
inv.status as StoredInvoiceStatus,
inv.dueDate,
reportTimeZone,
);
statusCount[s] = (statusCount[s] ?? 0) + 1;
}
@@ -151,7 +164,7 @@ export default function ReportsPage() {
totalHours,
statusCount,
};
}, [filteredInvoices]);
}, [filteredInvoices, reportTimeZone]);
// Tax summary for selected year
const taxData = useMemo(() => {
@@ -161,13 +174,14 @@ export default function ReportsPage() {
const status = getEffectiveInvoiceStatus(
inv.status as StoredInvoiceStatus,
inv.dueDate,
reportTimeZone,
);
return (
status === "paid" && new Date(inv.issueDate).getFullYear() === year
status === "paid" && new Date(inv.issueDate).getUTCFullYear() === year
);
});
const yearExpenses = expenses.filter(
(exp) => new Date(exp.date).getFullYear() === year,
(exp) => new Date(exp.date).getUTCFullYear() === year,
);
const getSubtotal = (inv: (typeof yearInvoices)[number]) => {
@@ -211,10 +225,12 @@ export default function ReportsPage() {
return {
label: `Q${q}`,
income: yearInvoices
.filter((inv) => qMonths.includes(new Date(inv.issueDate).getMonth()))
.filter((inv) =>
qMonths.includes(new Date(inv.issueDate).getUTCMonth()),
)
.reduce((s, inv) => s + getSubtotal(inv), 0),
expenses: yearExpenses
.filter((exp) => qMonths.includes(new Date(exp.date).getMonth()))
.filter((exp) => qMonths.includes(new Date(exp.date).getUTCMonth()))
.reduce((s, exp) => s + exp.amount, 0),
};
});
@@ -233,13 +249,13 @@ export default function ReportsPage() {
yearInvoices,
yearExpenses,
};
}, [filteredInvoices, expenses, taxYear]);
}, [filteredInvoices, expenses, taxYear, reportTimeZone]);
const availableYears = useMemo(() => {
const years = new Set<number>([currentYear, currentYear - 1]);
for (const inv of filteredInvoices)
years.add(new Date(inv.issueDate).getFullYear());
for (const exp of expenses) years.add(new Date(exp.date).getFullYear());
years.add(new Date(inv.issueDate).getUTCFullYear());
for (const exp of expenses) years.add(new Date(exp.date).getUTCFullYear());
return Array.from(years).sort((a, b) => b - a);
}, [filteredInvoices, expenses, currentYear]);
@@ -251,6 +267,7 @@ export default function ReportsPage() {
getEffectiveInvoiceStatus(
i.status as StoredInvoiceStatus,
i.dueDate,
reportTimeZone,
) === "paid",
).length || 1)
: 0;
@@ -272,7 +289,7 @@ export default function ReportsPage() {
const invoiceSubtotal = subtotal > 0 ? subtotal : fallbackSubtotal;
const taxAmt = inv.totalAmount - invoiceSubtotal;
return [
new Date(inv.issueDate).toLocaleDateString("en-US"),
formatCalendarDate(inv.issueDate),
inv.invoiceNumber,
`"${inv.client?.name ?? ""}"`,
invoiceSubtotal.toFixed(2),
@@ -287,7 +304,7 @@ export default function ReportsPage() {
"Date,Description,Category,Amount,Currency,Billable,Reimbursable,Tax Deductible",
...taxData.yearExpenses.map((exp) =>
[
new Date(exp.date).toLocaleDateString("en-US"),
formatCalendarDate(exp.date),
`"${exp.description}"`,
`"${exp.category ?? ""}"`,
exp.amount.toFixed(2),
@@ -634,7 +651,7 @@ export default function ReportsPage() {
<div>
<p className="font-medium">{inv.client?.name ?? "—"}</p>
<p className="text-muted-foreground text-xs">
{new Date(inv.issueDate).toLocaleDateString("en-US", {
{formatCalendarDate(inv.issueDate, {
month: "short",
day: "numeric",
year: "numeric",
@@ -647,6 +664,7 @@ export default function ReportsPage() {
getEffectiveInvoiceStatus(
inv.status as StoredInvoiceStatus,
inv.dueDate,
reportTimeZone,
) as never
}
/>
@@ -92,6 +92,7 @@ import type { PdfFontFamily, PdfTemplate } from "~/lib/appearance";
import { pdfFontFamilyOptions } from "~/lib/pdf-fonts";
import { ApiAccessSettings } from "./api-access-settings";
import { ImportPageHeaderActions } from "./invoice-import/import-page-header-actions";
import { DEFAULT_TIME_ZONE } from "@beenvoice/domain/time-zone";
const InvoiceImportPage = dynamic(
() =>
@@ -147,6 +148,7 @@ export function SettingsContent({
const { data: session } = useAuthSession();
const [name, setName] = useState("");
const [timeZone, setTimeZone] = useState(DEFAULT_TIME_ZONE);
const [nameInitialized, setNameInitialized] = useState(false);
const [deleteConfirmText, setDeleteConfirmText] = useState("");
const [importData, setImportData] = useState("");
@@ -309,7 +311,7 @@ export function SettingsContent({
toast.error("Please enter your name");
return;
}
updateProfileMutation.mutate({ name: name.trim() });
updateProfileMutation.mutate({ name: name.trim(), timeZone });
};
const handleChangePassword = (e: React.FormEvent) => {
@@ -423,8 +425,15 @@ export function SettingsContent({
if (nameInitialized || !profileFetched) return;
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync async profile data into an editable form field.
setName(profile?.name ?? session?.user?.name ?? "");
setTimeZone(profile?.timeZone ?? DEFAULT_TIME_ZONE);
setNameInitialized(true);
}, [profile?.name, profileFetched, session?.user?.name, nameInitialized]);
}, [
profile?.name,
profile?.timeZone,
profileFetched,
session?.user?.name,
nameInitialized,
]);
// (Removed direct DOM mutation; provider handles applying preferences globally)
@@ -497,6 +506,19 @@ export function SettingsContent({
Email address cannot be changed
</p>
</div>
<div className="space-y-2">
<Label htmlFor="time-zone">Time zone</Label>
<Input
id="time-zone"
value={timeZone}
onChange={(event) => setTimeZone(event.target.value)}
placeholder="America/New_York"
/>
<p className="text-muted-foreground text-sm">
IANA time zone used for recurring schedules, reminders, and
reports.
</p>
</div>
<Button
type="submit"
disabled={updateProfileMutation.isPending}
+95 -35
View File
@@ -9,29 +9,52 @@ import { api } from "~/trpc/react";
import { generateInvoicePDF } from "~/lib/pdf-export";
import { formatLineItemDetail } from "~/lib/invoice-line-item";
import { toast } from "sonner";
import {
formatCalendarDate,
getEffectiveInvoiceStatus,
} from "@beenvoice/domain";
function formatDate(date: Date) {
return new Intl.DateTimeFormat("en-US", {
return formatCalendarDate(date, {
year: "numeric",
month: "long",
day: "numeric",
}).format(new Date(date));
});
}
function formatCurrency(amount: number, currency = "USD") {
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(amount);
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(
amount,
);
}
function StatusPill({ status, dueDate }: { status: string; dueDate: Date }) {
const overdue = status === "sent" && new Date(dueDate) < new Date();
const label = overdue ? "Overdue" : status.charAt(0).toUpperCase() + status.slice(1);
function StatusPill({
status,
dueDate,
timeZone,
}: {
status: string;
dueDate: Date;
timeZone: string;
}) {
const overdue =
getEffectiveInvoiceStatus(
status as "draft" | "sent" | "paid",
dueDate,
timeZone,
) === "overdue";
const label = overdue
? "Overdue"
: status.charAt(0).toUpperCase() + status.slice(1);
const cls = overdue
? "bg-red-50 text-red-700 border-red-200"
: status === "paid"
? "bg-green-50 text-green-700 border-green-200"
: "bg-yellow-50 text-yellow-700 border-yellow-200";
? "bg-green-50 text-green-700 border-green-200"
: "bg-yellow-50 text-yellow-700 border-yellow-200";
return (
<span className={`inline-flex items-center rounded-full border px-3 py-0.5 text-xs font-semibold ${cls}`}>
<span
className={`inline-flex items-center rounded-full border px-3 py-0.5 text-xs font-semibold ${cls}`}
>
{label}
</span>
);
@@ -40,7 +63,11 @@ function StatusPill({ status, dueDate }: { status: string; dueDate: Date }) {
function PublicInvoiceView({ token }: { token: string }) {
const [downloading, setDownloading] = useState(false);
const { data: invoice, isLoading, error } = api.invoices.getByPublicToken.useQuery({ token });
const {
data: invoice,
isLoading,
error,
} = api.invoices.getByPublicToken.useQuery({ token });
const handleDownload = async () => {
if (!invoice || downloading) return;
@@ -79,7 +106,9 @@ function PublicInvoiceView({ token }: { token: string }) {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-3 text-center">
<p className="text-2xl font-bold text-gray-800">Invoice not found</p>
<p className="text-sm text-gray-500">This link may have expired or been revoked.</p>
<p className="text-sm text-gray-500">
This link may have expired or been revoked.
</p>
</div>
);
}
@@ -96,7 +125,7 @@ function PublicInvoiceView({ token }: { token: string }) {
const hideName = hasLogo && Boolean(invoice.business?.hideNameWithLogo);
return (
<div className="min-h-screen bg-gray-50 py-10 px-4">
<div className="min-h-screen bg-gray-50 px-4 py-10">
<div className="mx-auto max-w-2xl">
{/* Card */}
<div className="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm">
@@ -114,31 +143,46 @@ function PublicInvoiceView({ token }: { token: string }) {
)}
<div className="min-w-0">
{!hideName && (
<p className="truncate text-lg font-bold text-white">{senderName ?? "Invoice"}</p>
<p className="truncate text-lg font-bold text-white">
{senderName ?? "Invoice"}
</p>
)}
{invoice.business?.email && (
<p className="mt-0.5 truncate text-sm text-gray-400">{invoice.business.email}</p>
<p className="mt-0.5 truncate text-sm text-gray-400">
{invoice.business.email}
</p>
)}
</div>
</div>
{/* Body */}
<div className="px-8 py-6 space-y-6">
<div className="space-y-6 px-8 py-6">
{/* Invoice meta */}
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<p className="text-2xl font-bold text-gray-900">{invoice.invoiceNumber}</p>
<p className="text-2xl font-bold text-gray-900">
{invoice.invoiceNumber}
</p>
<p className="mt-1 text-sm text-gray-500">
Issued {formatDate(invoice.issueDate)} · Due {formatDate(invoice.dueDate)}
Issued {formatDate(invoice.issueDate)} · Due{" "}
{formatDate(invoice.dueDate)}
</p>
</div>
<StatusPill status={invoice.status} dueDate={invoice.dueDate} />
<StatusPill
status={invoice.status}
dueDate={invoice.dueDate}
timeZone={invoice.createdBy.timeZone}
/>
</div>
{/* Bill to */}
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-gray-400 mb-1">Bill to</p>
<p className="font-semibold text-gray-900">{invoice.client.name}</p>
<p className="mb-1 text-xs font-semibold tracking-wider text-gray-400 uppercase">
Bill to
</p>
<p className="font-semibold text-gray-900">
{invoice.client.name}
</p>
{invoice.client.email && (
<p className="text-sm text-gray-500">{invoice.client.email}</p>
)}
@@ -149,18 +193,21 @@ function PublicInvoiceView({ token }: { token: string }) {
{/* Line items */}
<div className="space-y-3">
{invoice.items.map((item) => (
<div key={item.id} className="flex justify-between gap-4 text-sm">
<div className="flex-1 min-w-0">
<p className="font-medium text-gray-900 break-words">{item.description}</p>
<div
key={item.id}
className="flex justify-between gap-4 text-sm"
>
<div className="min-w-0 flex-1">
<p className="font-medium break-words text-gray-900">
{item.description}
</p>
<p className="text-gray-500">
{formatLineItemDetail(
item.hours,
item.rate,
(amount) => formatCurrency(amount, invoice.currency ?? "USD"),
{formatLineItemDetail(item.hours, item.rate, (amount) =>
formatCurrency(amount, invoice.currency ?? "USD"),
)}
</p>
</div>
<p className="font-semibold text-gray-900 shrink-0">
<p className="shrink-0 font-semibold text-gray-900">
{formatCurrency(item.amount, invoice.currency ?? "USD")}
</p>
</div>
@@ -173,15 +220,19 @@ function PublicInvoiceView({ token }: { token: string }) {
<div className="space-y-2 text-sm">
<div className="flex justify-between text-gray-500">
<span>Subtotal</span>
<span>{formatCurrency(subtotal, invoice.currency ?? "USD")}</span>
<span>
{formatCurrency(subtotal, invoice.currency ?? "USD")}
</span>
</div>
{invoice.taxRate > 0 && (
<div className="flex justify-between text-gray-500">
<span>Tax ({invoice.taxRate}%)</span>
<span>{formatCurrency(taxAmount, invoice.currency ?? "USD")}</span>
<span>
{formatCurrency(taxAmount, invoice.currency ?? "USD")}
</span>
</div>
)}
<div className="flex justify-between text-base font-bold text-gray-900 pt-1">
<div className="flex justify-between pt-1 text-base font-bold text-gray-900">
<span>Total</span>
<span>{formatCurrency(total, invoice.currency ?? "USD")}</span>
</div>
@@ -192,8 +243,12 @@ function PublicInvoiceView({ token }: { token: string }) {
<>
<Separator />
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-gray-400 mb-1">Notes</p>
<p className="text-sm text-gray-700 whitespace-pre-wrap">{invoice.notes}</p>
<p className="mb-1 text-xs font-semibold tracking-wider text-gray-400 uppercase">
Notes
</p>
<p className="text-sm whitespace-pre-wrap text-gray-700">
{invoice.notes}
</p>
</div>
</>
)}
@@ -206,9 +261,14 @@ function PublicInvoiceView({ token }: { token: string }) {
className="w-full"
>
{downloading ? (
<><Loader2 className="mr-2 h-4 w-4 animate-spin" /> Generating PDF</>
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Generating
PDF
</>
) : (
<><Download className="mr-2 h-4 w-4" /> Download PDF</>
<>
<Download className="mr-2 h-4 w-4" /> Download PDF
</>
)}
</Button>
</div>
@@ -7,6 +7,7 @@ import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Skeleton } from "~/components/ui/skeleton";
import { api } from "~/trpc/react";
import { formatCalendarDate } from "@beenvoice/domain/time-zone";
export function CurrentOpenInvoiceCard() {
const { data: currentInvoice, isLoading } =
@@ -20,10 +21,10 @@ export function CurrentOpenInvoiceCard() {
};
const formatDate = (date: Date) => {
return new Intl.DateTimeFormat("en-US", {
return formatCalendarDate(date, {
month: "short",
day: "numeric",
}).format(new Date(date));
});
};
if (isLoading) {
@@ -32,6 +32,7 @@ import {
Plus,
User,
} from "lucide-react";
import { formatCalendarDate } from "@beenvoice/domain/time-zone";
export function InvoiceList() {
const [searchTerm, setSearchTerm] = useState("");
@@ -72,7 +73,7 @@ export function InvoiceList() {
};
const formatDate = (date: Date) => {
return new Date(date).toLocaleDateString();
return formatCalendarDate(date);
};
const formatCurrency = (amount: number) => {
@@ -24,6 +24,10 @@ import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { NumberInput } from "~/components/ui/number-input";
import {
calendarDateFromLocalDate,
calendarDateToLocalDate,
} from "@beenvoice/domain/time-zone";
import {
Plus,
Trash2,
@@ -77,7 +81,7 @@ export function InvoiceCalendarView({
return items
.map((item, index) => ({ item, index }))
.filter((wrapper) => {
const itemDate = new Date(wrapper.item.date);
const itemDate = calendarDateToLocalDate(wrapper.item.date);
return isSameDay(itemDate, date);
});
}, [items, date]);
@@ -88,7 +92,7 @@ export function InvoiceCalendarView({
return items
.map((item, index) => ({ item, index }))
.filter((wrapper) => {
const itemDate = new Date(wrapper.item.date);
const itemDate = calendarDateToLocalDate(wrapper.item.date);
return isSameDay(itemDate, targetDate);
});
},
@@ -103,7 +107,7 @@ export function InvoiceCalendarView({
const handleAddNewItem = () => {
if (date) {
onAddItem(date);
onAddItem(calendarDateFromLocalDate(date));
}
};
@@ -407,7 +411,11 @@ export function InvoiceCalendarView({
</p>
</div>
{!readOnly ? (
<Button onClick={handleAddNewItem} className="mt-2" size="lg">
<Button
onClick={handleAddNewItem}
className="mt-2"
size="lg"
>
<Plus className="mr-2 h-4 w-4" />
Log Time
</Button>
@@ -494,7 +502,11 @@ export function InvoiceCalendarView({
Total
</span>
<span className="text-primary text-lg font-bold">
${calculateLineItemAmount(item.hours, item.rate).toFixed(2)}
$
{calculateLineItemAmount(
item.hours,
item.rate,
).toFixed(2)}
</span>
</div>
</div>
+256 -232
View File
@@ -42,7 +42,8 @@ import {
Mail,
} from "lucide-react";
import { SUPPORTED_CURRENCIES } from "~/lib/currency";
import { generateInvoiceNumber } from "~/lib/draft-invoice";
import { defaultDueDate, generateInvoiceNumber } from "~/lib/draft-invoice";
import { calendarDateFromLocalDate } from "@beenvoice/domain/time-zone";
import { Textarea } from "~/components/ui/textarea";
import {
DropdownMenu,
@@ -108,13 +109,14 @@ function plainTextToHtml(value: string) {
}
function createDefaultInvoiceFormData(): InvoiceFormData {
const today = calendarDateFromLocalDate(new Date());
return {
invoiceNumber: generateInvoiceNumber(),
invoicePrefix: "#",
businessId: "",
clientId: "",
issueDate: new Date(),
dueDate: new Date(),
issueDate: today,
dueDate: defaultDueDate(today),
status: "draft",
notes: "",
emailMessage: "",
@@ -124,7 +126,7 @@ function createDefaultInvoiceFormData(): InvoiceFormData {
items: [
{
id: crypto.randomUUID(),
date: new Date(),
date: today,
description: "",
hours: 1,
rate: 0,
@@ -209,7 +211,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
: [
{
id: crypto.randomUUID(),
date: new Date(),
date: calendarDateFromLocalDate(new Date()),
description: "",
hours: 1,
rate: 0,
@@ -275,10 +277,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
items: formData.items.map((item) => ({
date: item.date,
description: item.description || "Service",
hours: item.hours,
rate: item.rate,
amount: calculateLineItemAmount(item.hours, item.rate),
})),
hours: item.hours,
rate: item.rate,
amount: calculateLineItemAmount(item.hours, item.rate),
})),
}),
[formData],
);
@@ -320,7 +322,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
...prev.items,
{
id: crypto.randomUUID(),
date: new Date(),
date: calendarDateFromLocalDate(new Date()),
description: parsed.description,
hours: parsed.hours ?? 1,
rate: parsed.rate ?? prev.defaultHourlyRate ?? 0,
@@ -350,7 +352,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
items: prev.items.map((item, i) => {
if (i !== idx) return item;
if (field === "billingType" && (value === "hourly" || value === "fixed")) {
if (
field === "billingType" &&
(value === "hourly" || value === "fixed")
) {
const next = applyBillingTypeChange(value, item);
return {
...item,
@@ -401,7 +406,9 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
return;
}
const itemsToSave = formData.items.filter((item) => item.description?.trim());
const itemsToSave = formData.items.filter((item) =>
item.description?.trim(),
);
let invalidItemIndex = -1;
for (let i = 0; i < formData.items.length; i++) {
@@ -515,7 +522,11 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
</Button>
</DashboardPageHeader>
<PageTabs value={activeTab} className="w-full" onValueChange={setActiveTab}>
<PageTabs
value={activeTab}
className="w-full"
onValueChange={setActiveTab}
>
<PageTabsList>
<PageTabsTrigger value="details">Details</PageTabsTrigger>
<PageTabsTrigger value="items">Items</PageTabsTrigger>
@@ -526,248 +537,256 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
{/* DETAILS TAB */}
<PageTabsContent value="details">
<div className={cn(pageTabsGridClass, "lg:grid-cols-2")}>
<Card className="h-full">
<CardHeader>
<CardTitle className="flex gap-2 text-base">
<User className="h-4 w-4" /> Client Details
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Client</Label>
<Select
value={formData.clientId}
onValueChange={(v) => {
updateField("clientId", v);
const selectedClient = clients?.find((c) => c.id === v);
const currentBusiness = businesses?.find(
(b) => b.id === formData.businessId,
);
const clientRate = getDefaultHourlyRate(selectedClient);
const businessRate =
getDefaultHourlyRate(currentBusiness);
updateField(
"defaultHourlyRate",
clientRate ?? businessRate ?? 0,
);
// Auto-fill currency from client
if (
selectedClient &&
"currency" in selectedClient &&
selectedClient.currency
) {
updateField("currency", selectedClient.currency);
}
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select Client" />
</SelectTrigger>
<SelectContent>
{clients?.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Business</Label>
<Select
value={formData.businessId}
onValueChange={(v) => updateField("businessId", v)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select Business" />
</SelectTrigger>
<SelectContent>
{businesses?.map((b) => (
<SelectItem key={b.id} value={b.id}>
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
<Card className="h-full">
<CardHeader>
<CardTitle className="flex gap-2 text-base">
<Tag className="h-4 w-4" /> Invoice Settings
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4">
<Card className="h-full">
<CardHeader>
<CardTitle className="flex gap-2 text-base">
<User className="h-4 w-4" /> Client Details
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Issue Date</Label>
<DatePicker
date={formData.issueDate}
onDateChange={(d) =>
updateField("issueDate", d ?? new Date())
}
className="w-full"
/>
</div>
<div className="space-y-2">
<Label>Due Date</Label>
<DatePicker
date={formData.dueDate}
onDateChange={(d) =>
updateField("dueDate", d ?? new Date())
}
className="w-full"
/>
</div>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[96px_1fr] sm:gap-4">
<div className="space-y-2">
<Label>Prefix</Label>
<Input
value={formData.invoicePrefix}
onChange={(e) =>
updateField("invoicePrefix", e.target.value)
}
placeholder="#"
className="w-full"
/>
</div>
<div className="space-y-2">
<Label>Invoice Number</Label>
<Input
value={formData.invoiceNumber}
onChange={(e) =>
updateField("invoiceNumber", e.target.value)
}
placeholder="INV-20260428-000001"
className="w-full font-mono"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Tax Rate</Label>
<NumberInput
value={formData.taxRate}
onChange={(v) => updateField("taxRate", v)}
suffix="%"
className="w-full"
/>
</div>
<div className="space-y-2">
<Label>Hourly Rate</Label>
<NumberInput
value={formData.defaultHourlyRate ?? 0}
onChange={(v) => updateField("defaultHourlyRate", v)}
prefix="$"
disabled={!formData.clientId}
className="w-full"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Status</Label>
<Label>Client</Label>
<Select
value={formData.status}
onValueChange={(v: "draft" | "sent" | "paid") =>
updateField("status", v)
}
value={formData.clientId}
onValueChange={(v) => {
updateField("clientId", v);
const selectedClient = clients?.find((c) => c.id === v);
const currentBusiness = businesses?.find(
(b) => b.id === formData.businessId,
);
const clientRate = getDefaultHourlyRate(selectedClient);
const businessRate =
getDefaultHourlyRate(currentBusiness);
updateField(
"defaultHourlyRate",
clientRate ?? businessRate ?? 0,
);
// Auto-fill currency from client
if (
selectedClient &&
"currency" in selectedClient &&
selectedClient.currency
) {
updateField("currency", selectedClient.currency);
}
}}
>
<SelectTrigger className="w-full">
<SelectValue />
<SelectValue placeholder="Select Client" />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
{clients?.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Currency</Label>
<Label>Business</Label>
<Select
value={formData.currency}
onValueChange={(v) => updateField("currency", v)}
value={formData.businessId}
onValueChange={(v) => updateField("businessId", v)}
>
<SelectTrigger className="w-full">
<SelectValue />
<SelectValue placeholder="Select Business" />
</SelectTrigger>
<SelectContent>
{SUPPORTED_CURRENCIES.map((c) => (
<SelectItem key={c.code} value={c.code}>
{c.code}
{businesses?.map((b) => (
<SelectItem key={b.id} value={b.id}>
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
</CardContent>
</Card>
<Card className="h-fit">
<CardHeader>
<CardTitle className="flex items-center justify-between gap-2 text-base">
<span className="flex items-center gap-2">
<Mail className="h-4 w-4" /> Email Message
</span>
</CardTitle>
</CardHeader>
<CardContent>
<Textarea
value={formData.emailMessage}
onChange={(e) => updateField("emailMessage", e.target.value)}
placeholder="Add a note that appears only in the email body..."
className="min-h-[140px]"
/>
</CardContent>
</Card>
<Card className="h-full">
<CardHeader>
<CardTitle className="flex gap-2 text-base">
<Tag className="h-4 w-4" /> Invoice Settings
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4">
<div className="space-y-2">
<Label>Issue Date</Label>
<DatePicker
date={formData.issueDate}
onDateChange={(d) =>
updateField(
"issueDate",
d ?? calendarDateFromLocalDate(new Date()),
)
}
className="w-full"
/>
</div>
<div className="space-y-2">
<Label>Due Date</Label>
<DatePicker
date={formData.dueDate}
onDateChange={(d) =>
updateField(
"dueDate",
d ?? calendarDateFromLocalDate(new Date()),
)
}
className="w-full"
/>
</div>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[96px_1fr] sm:gap-4">
<div className="space-y-2">
<Label>Prefix</Label>
<Input
value={formData.invoicePrefix}
onChange={(e) =>
updateField("invoicePrefix", e.target.value)
}
placeholder="#"
className="w-full"
/>
</div>
<div className="space-y-2">
<Label>Invoice Number</Label>
<Input
value={formData.invoiceNumber}
onChange={(e) =>
updateField("invoiceNumber", e.target.value)
}
placeholder="INV-20260428-000001"
className="w-full font-mono"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Tax Rate</Label>
<NumberInput
value={formData.taxRate}
onChange={(v) => updateField("taxRate", v)}
suffix="%"
className="w-full"
/>
</div>
<div className="space-y-2">
<Label>Hourly Rate</Label>
<NumberInput
value={formData.defaultHourlyRate ?? 0}
onChange={(v) => updateField("defaultHourlyRate", v)}
prefix="$"
disabled={!formData.clientId}
className="w-full"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Status</Label>
<Select
value={formData.status}
onValueChange={(v: "draft" | "sent" | "paid") =>
updateField("status", v)
}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Currency</Label>
<Select
value={formData.currency}
onValueChange={(v) => updateField("currency", v)}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SUPPORTED_CURRENCIES.map((c) => (
<SelectItem key={c.code} value={c.code}>
{c.code}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
<Card className="h-fit">
<CardHeader>
<CardTitle className="flex items-center justify-between gap-2 text-base">
<span className="flex items-center gap-2">
<FileText className="h-4 w-4" /> Invoice Notes
</span>
{noteTemplates && noteTemplates.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-7 gap-1 text-xs"
>
Use template <ChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
{noteTemplates.map((t) => (
<DropdownMenuItem
key={t.id}
onClick={() => updateField("notes", t.content)}
<Card className="h-fit">
<CardHeader>
<CardTitle className="flex items-center justify-between gap-2 text-base">
<span className="flex items-center gap-2">
<Mail className="h-4 w-4" /> Email Message
</span>
</CardTitle>
</CardHeader>
<CardContent>
<Textarea
value={formData.emailMessage}
onChange={(e) =>
updateField("emailMessage", e.target.value)
}
placeholder="Add a note that appears only in the email body..."
className="min-h-[140px]"
/>
</CardContent>
</Card>
<Card className="h-fit">
<CardHeader>
<CardTitle className="flex items-center justify-between gap-2 text-base">
<span className="flex items-center gap-2">
<FileText className="h-4 w-4" /> Invoice Notes
</span>
{noteTemplates && noteTemplates.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-7 gap-1 text-xs"
>
{t.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</CardTitle>
</CardHeader>
<CardContent>
<Textarea
value={formData.notes}
onChange={(e) => updateField("notes", e.target.value)}
placeholder="Add notes, payment terms, or other information for the invoice/PDF..."
className="min-h-[140px]"
/>
</CardContent>
</Card>
Use template <ChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
{noteTemplates.map((t) => (
<DropdownMenuItem
key={t.id}
onClick={() => updateField("notes", t.content)}
>
{t.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</CardTitle>
</CardHeader>
<CardContent>
<Textarea
value={formData.notes}
onChange={(e) => updateField("notes", e.target.value)}
placeholder="Add notes, payment terms, or other information for the invoice/PDF..."
className="min-h-[140px]"
/>
</CardContent>
</Card>
</div>
</PageTabsContent>
@@ -818,7 +837,9 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
onRemoveItem={removeItem}
onUpdateItem={updateItem}
onAddItemWithValues={addItemWithValues}
invoiceId={invoiceId && invoiceId !== "new" ? invoiceId : undefined}
invoiceId={
invoiceId && invoiceId !== "new" ? invoiceId : undefined
}
clientId={formData.clientId || undefined}
defaultRate={formData.items[0]?.rate}
readOnly={formData.status !== "draft"}
@@ -925,7 +946,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
description: item.description,
hours: item.hours,
rate: item.rate,
amount: calculateLineItemAmount(item.hours, item.rate),
amount: calculateLineItemAmount(
item.hours,
item.rate,
),
})),
}}
/>
@@ -51,6 +51,10 @@ import {
} from "~/lib/invoice-import";
import { cn } from "~/lib/utils";
import { api } from "~/trpc/react";
import {
addCalendarDays,
formatCalendarDate,
} from "@beenvoice/domain/time-zone";
interface StagedInvoice extends ImportInvoice {
id: string;
@@ -173,9 +177,10 @@ export function InvoiceImportPage() {
if (inv.id !== id) return inv;
const updated = { ...inv, ...updates };
if (updates.issueDate !== undefined && !updates.dueDate) {
const due = new Date(updated.issueDate ?? new Date());
due.setDate(due.getDate() + 30);
updated.dueDate = due;
updated.dueDate = addCalendarDays(
updated.issueDate ?? new Date(),
30,
);
}
return updated;
}),
@@ -628,12 +633,14 @@ export function InvoiceImportPage() {
{previewInvoice.items.map((item, idx) => (
<tr key={idx} className="border-border border-b">
<td className="p-2 text-sm whitespace-nowrap">
{item.date?.toLocaleDateString() ?? "—"}
{item.date ? formatCalendarDate(item.date) : "—"}
</td>
<td className="max-w-xs truncate p-2 text-sm">
{item.description}
</td>
<td className="p-2 text-right text-sm">{item.quantity}</td>
<td className="p-2 text-right text-sm">
{item.quantity}
</td>
<td className="p-2 text-right text-sm">
{item.rate.toLocaleString("en-US", {
style: "currency",
@@ -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<string | null>(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) {
+19 -8
View File
@@ -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 | undefined>(date);
const [month, setMonth] = React.useState<Date | undefined>(
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({
<span
aria-hidden
className={cn(
"invisible block whitespace-nowrap px-3 pr-10",
"invisible block px-3 pr-10 whitespace-nowrap",
sizeClasses[size],
inputClassName,
)}
@@ -102,7 +109,8 @@ export function DatePicker({
setValue(e.target.value);
const parsedDate = parseDate(e.target.value);
if (parsedDate) {
onDateChange(parsedDate);
const calendarDate = calendarDateFromLocalDate(parsedDate);
onDateChange(calendarDate);
setMonth(parsedDate);
}
}}
@@ -130,13 +138,16 @@ export function DatePicker({
>
<Calendar
mode="single"
selected={date}
selected={date ? calendarDateToLocalDate(date) : undefined}
captionLayout="dropdown"
month={month}
onMonthChange={setMonth}
onSelect={(selectedDate) => {
onDateChange(selectedDate);
setValue(formatDate(selectedDate));
const calendarDate = selectedDate
? calendarDateFromLocalDate(selectedDate)
: undefined;
onDateChange(calendarDate);
setValue(formatDate(calendarDate));
setOpen(false);
}}
/>
+3 -3
View File
@@ -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);
}
@@ -1,11 +1,19 @@
import { getAppUrl } from "~/lib/app-url";
import { formatCalendarDate } from "@beenvoice/domain/time-zone";
// Most email clients render <img src> 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";
@@ -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)}`;
+18 -10
View File
@@ -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)',
],
},
];
}
+6 -3
View File
@@ -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 = {
+5 -11
View File
@@ -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<Record<string, string | number>>;
getStatusStyle: (status: string) => Array<Record<string, string | number>>;
};
const pdfStyleCache = new Map<string, PdfStyleBundle>();
@@ -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",
+9 -2
View File
@@ -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<T extends { startedAt: Date }>(
entries: T[],
timeZone = DEFAULT_TIME_ZONE,
): { dateKey: string; label: string; entries: T[] }[] {
const groups = new Map<string, T[]>();
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<T extends { startedAt: Date }>(
year: "numeric",
month: "long",
day: "numeric",
timeZone,
});
return { dateKey, label, entries: groupEntries };
});
@@ -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",
),
});
}
+2
View File
@@ -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
+41 -19
View File
@@ -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<string, number>;
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,
@@ -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 {
@@ -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 };
}),
});
@@ -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<typeof recurringInvoiceSchema>) {
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 };
@@ -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,
}),
+194 -43
View File
@@ -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 = [
+83 -54
View File
@@ -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<Record<string, unknown>>(),
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),
@@ -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",
);
}
}
@@ -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));
});
@@ -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<typeof DbType, "insert">,
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,
@@ -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);