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
+75 -23
View File
@@ -20,7 +20,10 @@ import { InvoiceEditorFooter } from "@/components/invoices/InvoiceEditorFooter";
import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
import { InvoiceSetupForm } from "@/components/invoices/InvoiceSetupForm";
import { InvoiceTotals } from "@/components/invoices/InvoiceTotals";
import { LineItemEditor, type EditableLineItem } from "@/components/invoices/LineItemEditor";
import {
LineItemEditor,
type EditableLineItem,
} from "@/components/invoices/LineItemEditor";
import { LoadingScreen } from "@/components/LoadingScreen";
import { Card } from "@/components/ui/Card";
import { fonts, spacing } from "@/constants/theme";
@@ -35,6 +38,7 @@ import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles";
import { api } from "@/lib/trpc";
import { calendarDateFromLocalDate } from "@beenvoice/domain/time-zone";
export default function InvoiceEditScreen() {
const { colors } = useAppTheme();
@@ -53,7 +57,9 @@ export default function InvoiceEditScreen() {
const [businessId, setBusinessId] = useState("");
const [clientId, setClientId] = useState("");
const [notes, setNotes] = useState("");
const [dueDate, setDueDate] = useState(() => new Date());
const [dueDate, setDueDate] = useState(() =>
calendarDateFromLocalDate(new Date()),
);
const [taxRate, setTaxRate] = useState("0");
const [sendReminderAt, setSendReminderAt] = useState<Date | null>(null);
const [items, setItems] = useState<EditableLineItem[]>([]);
@@ -68,7 +74,9 @@ export default function InvoiceEditScreen() {
setNotes(invoice.notes ?? "");
setDueDate(new Date(invoice.dueDate));
setTaxRate(String(invoice.taxRate));
setSendReminderAt(invoice.sendReminderAt ? new Date(invoice.sendReminderAt) : null);
setSendReminderAt(
invoice.sendReminderAt ? new Date(invoice.sendReminderAt) : null,
);
setItems(
invoice.items.map((item) => ({
id: item.id,
@@ -119,9 +127,14 @@ export default function InvoiceEditScreen() {
[clientsQuery.data],
);
const selectedClient = clientsQuery.data?.find((client) => client.id === clientId);
const selectedClient = clientsQuery.data?.find(
(client) => client.id === clientId,
);
const currency = selectedClient?.currency ?? invoice?.currency ?? "USD";
const resolvedBusinessId = resolveInvoiceBusinessId(businessId, businessesQuery.data);
const resolvedBusinessId = resolveInvoiceBusinessId(
businessId,
businessesQuery.data,
);
const subtotal = useMemo(
() =>
@@ -137,8 +150,12 @@ export default function InvoiceEditScreen() {
const taxAmount = subtotal * (parsedTaxRate / 100);
const total = subtotal + taxAmount;
const lineItemsError = isDraft ? validateLineItems(items) : null;
const taxError = isDraft && !isValidTaxRate(taxRate) ? "Tax rate must be between 0 and 100" : null;
const businessError = isDraft && !resolvedBusinessId ? "Select a business" : undefined;
const taxError =
isDraft && !isValidTaxRate(taxRate)
? "Tax rate must be between 0 and 100"
: null;
const businessError =
isDraft && !resolvedBusinessId ? "Select a business" : undefined;
const clientError = isDraft && !clientId ? "Select a client" : undefined;
const canSave = isDraft
? !lineItemsError && !taxError && !businessError && !clientError
@@ -159,13 +176,26 @@ export default function InvoiceEditScreen() {
currency,
items,
});
}, [invoice, resolvedBusinessId, clientId, dueDate, notes, parsedTaxRate, currency, items]);
}, [
invoice,
resolvedBusinessId,
clientId,
dueDate,
notes,
parsedTaxRate,
currency,
items,
]);
if (!id) {
return <LoadingScreen message="Invalid invoice" />;
}
if (invoiceQuery.isLoading || businessesQuery.isLoading || clientsQuery.isLoading) {
if (
invoiceQuery.isLoading ||
businessesQuery.isLoading ||
clientsQuery.isLoading
) {
return <LoadingScreen message="Loading invoice…" />;
}
@@ -177,14 +207,16 @@ export default function InvoiceEditScreen() {
const clientEmail = invoice.client?.email?.trim() ?? "";
function updateItem(index: number, patch: Partial<EditableLineItem>) {
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
setItems((prev) =>
prev.map((item, i) => (i === index ? { ...item, ...patch } : item)),
);
}
function addItem() {
setItems((prev) => [
...prev,
{
date: new Date(),
date: calendarDateFromLocalDate(new Date()),
description: "",
hours: "1",
rate: prev[prev.length - 1]?.rate ?? "0",
@@ -260,8 +292,13 @@ export default function InvoiceEditScreen() {
style={styles.flex}
>
<ScrollView
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : undefined}
contentContainerStyle={[
styles.container,
{ paddingBottom: scrollPadding },
]}
contentInsetAdjustmentBehavior={
Platform.OS === "ios" ? "automatic" : undefined
}
scrollIndicatorInsets={{ bottom: scrollPadding }}
keyboardShouldPersistTaps="handled"
>
@@ -316,13 +353,13 @@ export default function InvoiceEditScreen() {
<Card title="Line items">
{!isDraft ? (
<Text style={styles.lockedHint}>
Line items are locked after an invoice is sent. Mark as draft on the invoice
screen to edit entries.
Line items are locked after an invoice is sent. Mark as
draft on the invoice screen to edit entries.
</Text>
) : items.length === 0 ? (
<Text style={styles.emptyLines}>
No line items yet. Add lines here or clock time to this invoice from the
Timer tab.
No line items yet. Add lines here or clock time to this
invoice from the Timer tab.
</Text>
) : null}
{items.map((item, index) => (
@@ -334,26 +371,40 @@ export default function InvoiceEditScreen() {
isLast={index === items.length - 1}
onChange={(patch) => updateItem(index, patch)}
onRemove={() => removeItem(index)}
onDuplicate={isDraft ? () => duplicateItem(index) : undefined}
onDuplicate={
isDraft ? () => duplicateItem(index) : undefined
}
readOnly={!isDraft}
/>
))}
{isDraft ? (
<Pressable accessibilityRole="button" onPress={addItem} style={styles.addLine}>
<Pressable
accessibilityRole="button"
onPress={addItem}
style={styles.addLine}
>
<Text style={styles.addLineText}>+ Add another line</Text>
</Pressable>
) : null}
<InvoiceTotals
subtotal={formatCurrency(subtotal, currency)}
taxLabel={parsedTaxRate > 0 ? `Tax (${parsedTaxRate}%)` : undefined}
taxAmount={parsedTaxRate > 0 ? formatCurrency(taxAmount, currency) : undefined}
taxLabel={
parsedTaxRate > 0 ? `Tax (${parsedTaxRate}%)` : undefined
}
taxAmount={
parsedTaxRate > 0
? formatCurrency(taxAmount, currency)
: undefined
}
total={formatCurrency(total, currency)}
/>
</Card>
{lineItemsError ? <Text style={styles.error}>{lineItemsError}</Text> : null}
{lineItemsError ? (
<Text style={styles.error}>{lineItemsError}</Text>
) : null}
</>
)}
@@ -367,7 +418,8 @@ export default function InvoiceEditScreen() {
secondary={
status !== "paid"
? {
title: status === "draft" ? "Send invoice" : "Resend invoice",
title:
status === "draft" ? "Send invoice" : "Resend invoice",
subtitle: clientEmail
? items.length === 0
? "Add line items before sending"
+62 -20
View File
@@ -20,7 +20,10 @@ import { InvoiceEditorFooter } from "@/components/invoices/InvoiceEditorFooter";
import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
import { InvoiceSetupForm } from "@/components/invoices/InvoiceSetupForm";
import { InvoiceTotals } from "@/components/invoices/InvoiceTotals";
import { LineItemEditor, type EditableLineItem } from "@/components/invoices/LineItemEditor";
import {
LineItemEditor,
type EditableLineItem,
} from "@/components/invoices/LineItemEditor";
import { LoadingScreen } from "@/components/LoadingScreen";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
@@ -39,6 +42,7 @@ import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles";
import { api } from "@/lib/trpc";
import { calendarDateFromLocalDate } from "@beenvoice/domain/time-zone";
export default function NewInvoiceScreen() {
const styles = useThemedStyles(createNewInvoiceStyles);
@@ -53,8 +57,12 @@ export default function NewInvoiceScreen() {
const [businessId, setBusinessId] = useState("");
const [clientId, setClientId] = useState("");
const [invoiceNumber, setInvoiceNumber] = useState(generateInvoiceNumber);
const [issueDate, setIssueDate] = useState(() => new Date());
const [dueDate, setDueDate] = useState(() => defaultDueDate(new Date()));
const [issueDate, setIssueDate] = useState(() =>
calendarDateFromLocalDate(new Date()),
);
const [dueDate, setDueDate] = useState(() =>
defaultDueDate(calendarDateFromLocalDate(new Date())),
);
const [notes, setNotes] = useState("");
const [taxRate, setTaxRate] = useState("0");
const [items, setItems] = useState<EditableLineItem[]>(() =>
@@ -62,7 +70,7 @@ export default function NewInvoiceScreen() {
? []
: [
{
date: new Date(),
date: calendarDateFromLocalDate(new Date()),
description: "",
hours: "1",
rate: "0",
@@ -96,9 +104,14 @@ export default function NewInvoiceScreen() {
[clientsQuery.data],
);
const selectedClient = clientsQuery.data?.find((client) => client.id === clientId);
const selectedClient = clientsQuery.data?.find(
(client) => client.id === clientId,
);
const currency = selectedClient?.currency ?? "USD";
const resolvedBusinessId = resolveInvoiceBusinessId(businessId, businessesQuery.data);
const resolvedBusinessId = resolveInvoiceBusinessId(
businessId,
businessesQuery.data,
);
useEffect(() => {
if (!selectedClient?.defaultHourlyRate) return;
@@ -170,7 +183,9 @@ export default function NewInvoiceScreen() {
const invoiceNumberError = isRequiredString(invoiceNumber)
? undefined
: "Invoice number is required";
const taxError = isValidTaxRate(taxRate) ? undefined : "Tax rate must be between 0 and 100";
const taxError = isValidTaxRate(taxRate)
? undefined
: "Tax rate must be between 0 and 100";
const lineItemsError = validateLineItems(items);
const canCreate =
businessOptions.length > 0 &&
@@ -187,7 +202,9 @@ export default function NewInvoiceScreen() {
function updateItem(index: number, patch: Partial<EditableLineItem>) {
touch("lineItems");
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
setItems((prev) =>
prev.map((item, i) => (i === index ? { ...item, ...patch } : item)),
);
}
function addItem() {
@@ -195,7 +212,7 @@ export default function NewInvoiceScreen() {
setItems((prev) => [
...prev,
{
date: new Date(),
date: calendarDateFromLocalDate(new Date()),
description: "",
hours: "1",
rate: prev[prev.length - 1]?.rate ?? "0",
@@ -266,8 +283,13 @@ export default function NewInvoiceScreen() {
style={styles.flex}
>
<ScrollView
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : undefined}
contentContainerStyle={[
styles.container,
{ paddingBottom: scrollPadding },
]}
contentInsetAdjustmentBehavior={
Platform.OS === "ios" ? "automatic" : undefined
}
scrollIndicatorInsets={{ bottom: scrollPadding }}
keyboardShouldPersistTaps="handled"
>
@@ -287,7 +309,11 @@ export default function NewInvoiceScreen() {
: "Add a client before creating an invoice."}
</Text>
<Button
title={businessOptions.length === 0 ? "Add business" : "Add client"}
title={
businessOptions.length === 0
? "Add business"
: "Add client"
}
variant="secondary"
onPress={() =>
router.push(
@@ -303,7 +329,9 @@ export default function NewInvoiceScreen() {
businessId={businessId}
onBusinessIdChange={setBusinessId}
businessOptions={businessOptions}
businessError={visible("business") ? businessError : undefined}
businessError={
visible("business") ? businessError : undefined
}
onBusinessBlur={() => touch("business")}
clientId={clientId}
onClientIdChange={setClientId}
@@ -334,8 +362,8 @@ export default function NewInvoiceScreen() {
<Card title="Line items">
{isBlank && items.length === 0 ? (
<Text style={styles.emptyLines}>
No line items yet. Save this draft and clock time to it from the Timer tab,
or add lines here.
No line items yet. Save this draft and clock time to it from
the Timer tab, or add lines here.
</Text>
) : null}
{items.map((item, index) => (
@@ -351,27 +379,41 @@ export default function NewInvoiceScreen() {
/>
))}
<Pressable accessibilityRole="button" onPress={addItem} style={styles.addLine}>
<Pressable
accessibilityRole="button"
onPress={addItem}
style={styles.addLine}
>
<Text style={styles.addLineText}>+ Add another line</Text>
</Pressable>
<InvoiceTotals
subtotal={formatCurrency(subtotal, currency)}
taxLabel={parsedTaxRate > 0 ? `Tax (${parsedTaxRate}%)` : undefined}
taxLabel={
parsedTaxRate > 0 ? `Tax (${parsedTaxRate}%)` : undefined
}
taxAmount={
parsedTaxRate > 0 ? formatCurrency(taxAmount, currency) : undefined
parsedTaxRate > 0
? formatCurrency(taxAmount, currency)
: undefined
}
total={formatCurrency(total, currency)}
/>
</Card>
{visible("lineItems") && lineItemsError ? (
<Text selectable style={styles.error}>{lineItemsError}</Text>
<Text selectable style={styles.error}>
{lineItemsError}
</Text>
) : null}
</>
)}
{error ? <Text selectable style={styles.error}>{error}</Text> : null}
{error ? (
<Text selectable style={styles.error}>
{error}
</Text>
) : null}
<InvoiceEditorFooter
primaryTitle={isBlank ? "Create blank invoice" : "Create invoice"}
+21 -4
View File
@@ -20,7 +20,9 @@ import { DateTimeField } from "@/components/ui/DateTimeField";
import {
formatZonedDateTime,
getDefaultScheduledSendAt,
getLocalTimeZone,
DEFAULT_TIME_ZONE,
toLocalDateTimeInputValue,
zonedDateTimeToInstant,
} from "@beenvoice/domain/time-zone";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
@@ -42,7 +44,8 @@ export default function InvoiceSendScreen() {
const [scheduledAt, setScheduledAt] = useState(() =>
getDefaultScheduledSendAt(),
);
const timeZone = useMemo(() => getLocalTimeZone(), []);
const profileQuery = api.settings.getProfile.useQuery();
const timeZone = profileQuery.data?.timeZone ?? DEFAULT_TIME_ZONE;
const invoiceQuery = api.invoices.getById.useQuery(
{ id: id ?? "" },
@@ -139,7 +142,21 @@ export default function InvoiceSendScreen() {
function handleSchedule() {
if (!clientEmail || invoice.items.length === 0) return;
if (scheduledAt.getTime() < Date.now() + 60_000) {
let instant: Date;
try {
instant = zonedDateTimeToInstant(
toLocalDateTimeInputValue(scheduledAt),
timeZone,
"earlier",
);
} catch (error) {
Alert.alert(
"Choose another time",
error instanceof Error ? error.message : "Invalid local time",
);
return;
}
if (instant.getTime() < Date.now() + 60_000) {
Alert.alert(
"Choose a future time",
"The scheduled time must be at least one minute from now.",
@@ -148,7 +165,7 @@ export default function InvoiceSendScreen() {
}
scheduleInvoice.mutate({
invoiceId: invoice.id,
scheduledAt,
scheduledAt: instant,
timeZone,
customMessage: customMessage.trim() || undefined,
});
+5 -2
View File
@@ -19,6 +19,7 @@ import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency, formatDate } from "@/lib/format";
import { scanReceiptImage, type ReceiptScanResult } from "@/lib/receipt-scan";
import { api } from "@/lib/trpc";
import { calendarDateFromLocalDate } from "@beenvoice/domain/time-zone";
type ReceiptSplitDraft = Pick<
ReceiptScanResult,
@@ -37,7 +38,7 @@ export default function ExpenseDetailScreen() {
const [form, setForm] = useState<ExpenseFormState>({
description: "",
amountText: "",
date: new Date(),
date: calendarDateFromLocalDate(new Date()),
category: "",
businessId: "",
clientId: "",
@@ -165,7 +166,9 @@ export default function ExpenseDetailScreen() {
return (
<AppBackground>
<TabPage showMoreBack>
<TabScrollView header={<PageHeader title="Expense" subtitle="Expense details" />}>
<TabScrollView
header={<PageHeader title="Expense" subtitle="Expense details" />}
>
<Text style={{ color: colors.mutedForeground }}>
Expense not found
</Text>
+106 -34
View File
@@ -1,12 +1,7 @@
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import { useMemo, useState } from "react";
import {
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { ScrollView, StyleSheet, Text, View } from "react-native";
import type { AppRouter } from "beenvoice/server/api/root";
import type { inferRouterOutputs } from "@trpc/server";
@@ -26,6 +21,7 @@ import { api } from "@/lib/trpc";
import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles";
import { formatCalendarDate } from "@beenvoice/domain/time-zone";
type ExpenseFilter = "all" | "billable" | "receipts";
type Expense = inferRouterOutputs<AppRouter>["expenses"]["getAll"][number];
@@ -120,17 +116,27 @@ export default function ExpensesScreen() {
<View
style={[
styles.emptyCard,
{ borderColor: colors.border, backgroundColor: colors.cardGlass },
{
borderColor: colors.border,
backgroundColor: colors.cardGlass,
},
]}
>
<View style={[styles.emptyIcon, { backgroundColor: colors.muted }]}>
<Ionicons name="receipt-outline" size={24} color={colors.primary} />
<View
style={[styles.emptyIcon, { backgroundColor: colors.muted }]}
>
<Ionicons
name="receipt-outline"
size={24}
color={colors.primary}
/>
</View>
<Text style={[styles.emptyTitle, { color: colors.foreground }]}>
No expenses yet
</Text>
<Text style={[styles.empty, { color: colors.mutedForeground }]}>
Scan a receipt or add a manual entry when something needs to be tracked, billed, or reimbursed.
Scan a receipt or add a manual entry when something needs to be
tracked, billed, or reimbursed.
</Text>
<Button
title="Add expense"
@@ -140,9 +146,18 @@ export default function ExpensesScreen() {
) : (
<>
<View style={styles.summaryGrid}>
<SummaryTile label="Visible total" value={formatCurrency(summary.total)} />
<SummaryTile label="Billable" value={formatCurrency(summary.billable)} />
<SummaryTile label="Receipts" value={String(summary.receiptCount)} />
<SummaryTile
label="Visible total"
value={formatCurrency(summary.total)}
/>
<SummaryTile
label="Billable"
value={formatCurrency(summary.billable)}
/>
<SummaryTile
label="Receipts"
value={String(summary.receiptCount)}
/>
</View>
<ScrollView
@@ -174,14 +189,21 @@ export default function ExpensesScreen() {
) : (
groupedExpenses.map(([monthLabel, group]) => (
<View key={monthLabel} style={styles.monthGroup}>
<Text style={[styles.monthLabel, { color: colors.mutedForeground }]}>
<Text
style={[
styles.monthLabel,
{ color: colors.mutedForeground },
]}
>
{monthLabel}
</Text>
{group.map((expense) => (
<ExpenseRow
key={expense.id}
expense={expense}
onDelete={() => deleteExpense.mutate({ id: expense.id })}
onDelete={() =>
deleteExpense.mutate({ id: expense.id })
}
/>
))}
</View>
@@ -209,14 +231,23 @@ function SummaryTile({ label, value }: { label: string; value: string }) {
<Text style={[styles.summaryLabel, { color: colors.mutedForeground }]}>
{label}
</Text>
<Text style={[styles.summaryValue, { color: colors.foreground }]} numberOfLines={1}>
<Text
style={[styles.summaryValue, { color: colors.foreground }]}
numberOfLines={1}
>
{value}
</Text>
</View>
);
}
function ExpenseRow({ expense, onDelete }: { expense: Expense; onDelete: () => void }) {
function ExpenseRow({
expense,
onDelete,
}: {
expense: Expense;
onDelete: () => void;
}) {
const { colors } = useAppTheme();
const styles = useThemedStyles(createStyles);
@@ -236,7 +267,8 @@ function ExpenseRow({ expense, onDelete }: { expense: Expense; onDelete: () => v
icon: "open-outline",
color: "#fff",
backgroundColor: colors.primary,
onPress: () => router.push(`/(app)/more/expenses/${expense.id}` as never),
onPress: () =>
router.push(`/(app)/more/expenses/${expense.id}` as never),
},
{
key: "delete",
@@ -249,45 +281,77 @@ function ExpenseRow({ expense, onDelete }: { expense: Expense; onDelete: () => v
]}
>
<View style={[styles.categoryIcon, { backgroundColor: colors.muted }]}>
<Ionicons name={expenseIcon(expense.category)} size={18} color={colors.primary} />
<Ionicons
name={expenseIcon(expense.category)}
size={18}
color={colors.primary}
/>
</View>
<View style={styles.meta}>
<View style={styles.titleRow}>
<Text style={[styles.title, { color: colors.foreground }]} numberOfLines={1}>
<Text
style={[styles.title, { color: colors.foreground }]}
numberOfLines={1}
>
{expense.description}
</Text>
{expense.receiptCount ? (
<View
style={[
styles.receiptPill,
{ borderColor: colors.border, backgroundColor: colors.background },
{
borderColor: colors.border,
backgroundColor: colors.background,
},
]}
>
<Ionicons name="document-attach-outline" size={13} color={colors.primary} />
<Ionicons
name="document-attach-outline"
size={13}
color={colors.primary}
/>
<Text style={[styles.receiptPillText, { color: colors.primary }]}>
{expense.receiptCount}
</Text>
</View>
) : null}
</View>
<Text style={[styles.sub, { color: colors.mutedForeground }]} numberOfLines={1}>
<Text
style={[styles.sub, { color: colors.mutedForeground }]}
numberOfLines={1}
>
{formatDate(expense.date)}
{expense.category ? ` · ${expense.category}` : ""}
{expense.client?.name ? ` · ${expense.client.name}` : ""}
</Text>
<View style={styles.tagRow}>
{expense.billable ? (
<Text style={[styles.tag, { color: colors.primary, borderColor: colors.border }]}>
<Text
style={[
styles.tag,
{ color: colors.primary, borderColor: colors.border },
]}
>
Billable
</Text>
) : null}
{expense.reimbursable ? (
<Text style={[styles.tag, { color: colors.foreground, borderColor: colors.border }]}>
<Text
style={[
styles.tag,
{ color: colors.foreground, borderColor: colors.border },
]}
>
Reimbursable
</Text>
) : null}
{expense.taxDeductible ? (
<Text style={[styles.tag, { color: colors.success, borderColor: colors.border }]}>
<Text
style={[
styles.tag,
{ color: colors.success, borderColor: colors.border },
]}
>
Tax
</Text>
) : null}
@@ -297,7 +361,11 @@ function ExpenseRow({ expense, onDelete }: { expense: Expense; onDelete: () => v
<Text style={[styles.amount, { color: colors.foreground }]}>
{formatCurrency(expense.amount, expense.currency)}
</Text>
<Ionicons name="chevron-forward" size={16} color={colors.mutedForeground} />
<Ionicons
name="chevron-forward"
size={16}
color={colors.mutedForeground}
/>
</View>
</SwipeableRow>
);
@@ -306,8 +374,7 @@ function ExpenseRow({ expense, onDelete }: { expense: Expense; onDelete: () => v
function groupExpensesByMonth(expenses: Expense[]) {
const groups = new Map<string, Expense[]>();
for (const expense of expenses) {
const date = new Date(expense.date);
const key = date.toLocaleDateString(undefined, {
const key = formatCalendarDate(expense.date, {
month: "long",
year: "numeric",
});
@@ -320,11 +387,16 @@ function groupExpensesByMonth(expenses: Expense[]) {
function expenseIcon(category: string | null): keyof typeof Ionicons.glyphMap {
const normalized = category?.toLowerCase() ?? "";
if (normalized.includes("travel") || normalized.includes("mileage")) return "airplane-outline";
if (normalized.includes("meal") || normalized.includes("food")) return "restaurant-outline";
if (normalized.includes("software") || normalized.includes("subscription")) return "laptop-outline";
if (normalized.includes("office") || normalized.includes("supply")) return "briefcase-outline";
if (normalized.includes("phone") || normalized.includes("internet")) return "wifi-outline";
if (normalized.includes("travel") || normalized.includes("mileage"))
return "airplane-outline";
if (normalized.includes("meal") || normalized.includes("food"))
return "restaurant-outline";
if (normalized.includes("software") || normalized.includes("subscription"))
return "laptop-outline";
if (normalized.includes("office") || normalized.includes("supply"))
return "briefcase-outline";
if (normalized.includes("phone") || normalized.includes("internet"))
return "wifi-outline";
return "receipt-outline";
}
+3
View File
@@ -303,6 +303,9 @@ export default function SettingsScreen() {
Role: {profile.role}
</Text>
) : null}
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
Time zone: {profile?.timeZone ?? "America/New_York"}
</Text>
</Card>
<Card title="Accounts">
+51 -17
View File
@@ -17,36 +17,53 @@ import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
import { api } from "@/lib/trpc";
import type { AppRouter } from "beenvoice/server/api/root";
import type { inferRouterOutputs } from "@trpc/server";
import {
DEFAULT_TIME_ZONE,
getZonedDateTimeParts,
} from "@beenvoice/domain/time-zone";
type TimeEntry = inferRouterOutputs<AppRouter>["timeEntries"]["getAll"][number];
function groupByDate(entries: TimeEntry[]) {
function groupByDate(entries: TimeEntry[], timeZone: string) {
const groups = new Map<string, typeof entries>();
for (const entry of entries) {
const d = new Date(entry.startedAt);
const key = d.toLocaleDateString(undefined, {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
});
const list = groups.get(key) ?? [];
const parts = getZonedDateTimeParts(d, timeZone);
const dateKey = `${parts.year}-${String(parts.month).padStart(2, "0")}-${String(parts.day).padStart(2, "0")}`;
const list = groups.get(dateKey) ?? [];
list.push(entry);
groups.set(key, list);
groups.set(dateKey, list);
}
return Array.from(groups.entries());
return Array.from(groups.entries()).map(
([, groupedEntries]) =>
[
new Date(groupedEntries[0]!.startedAt).toLocaleDateString(undefined, {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
timeZone,
}),
groupedEntries,
] as const,
);
}
export default function TimeEntriesScreen() {
const { colors } = useAppTheme();
const [editEntryId, setEditEntryId] = useState<string | null>(null);
const entriesQuery = api.timeEntries.getAll.useQuery();
const profileQuery = api.settings.getProfile.useQuery();
const completed = useMemo(
() => (entriesQuery.data ?? []).filter((entry) => entry.endedAt),
[entriesQuery.data],
);
const grouped = useMemo(() => groupByDate(completed), [completed]);
const grouped = useMemo(
() =>
groupByDate(completed, profileQuery.data?.timeZone ?? DEFAULT_TIME_ZONE),
[completed, profileQuery.data?.timeZone],
);
if (entriesQuery.isLoading) {
return <LoadingScreen message="Loading time entries…" />;
@@ -57,7 +74,10 @@ export default function TimeEntriesScreen() {
<AppBackground>
<TabPage showMoreBack>
<View style={styles.errorBox}>
<PageHeader title="Time entries" subtitle="Completed work history" />
<PageHeader
title="Time entries"
subtitle="Completed work history"
/>
<Text style={{ color: colors.mutedForeground }}>
{formatTrpcErrorMessage(entriesQuery.error)}
</Text>
@@ -72,7 +92,10 @@ export default function TimeEntriesScreen() {
<TabPage showMoreBack>
<TabScrollView
header={
<PageHeader title="Time entries" subtitle={`${completed.length} completed entries`} />
<PageHeader
title="Time entries"
subtitle={`${completed.length} completed entries`}
/>
}
refreshControl={
<PullToRefresh
@@ -82,7 +105,9 @@ export default function TimeEntriesScreen() {
}
>
{grouped.length === 0 ? (
<Text style={{ color: colors.mutedForeground, fontFamily: fonts.body }}>
<Text
style={{ color: colors.mutedForeground, fontFamily: fonts.body }}
>
No completed entries yet. Start the timer from the Timer tab.
</Text>
) : (
@@ -104,17 +129,26 @@ export default function TimeEntriesScreen() {
>
<View style={styles.row}>
<View style={{ flex: 1, gap: 2 }}>
<Text style={[styles.title, { color: colors.foreground }]}>
<Text
style={[styles.title, { color: colors.foreground }]}
>
{formatRunningTimerLabel(entry.description)}
</Text>
<Text style={{ color: colors.mutedForeground, fontFamily: fonts.body }}>
<Text
style={{
color: colors.mutedForeground,
fontFamily: fonts.body,
}}
>
{entry.client?.name ?? "No client"}
{entry.invoice
? ` · ${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
: " · not billed"}
</Text>
</View>
<Text style={[styles.title, { color: colors.foreground }]}>
<Text
style={[styles.title, { color: colors.foreground }]}
>
{entry.hours ?? "—"}h
</Text>
</View>
+48 -19
View File
@@ -1,12 +1,18 @@
import * as Notifications from "expo-notifications";
import Constants from "expo-constants";
import { router } from "expo-router";
import { useEffect, useRef } from "react";
import { AppState, type AppStateStatus } from "react-native";
import { useEffect, useRef, useState } from "react";
import { AppState, Platform, type AppStateStatus } from "react-native";
import { syncInvoiceSendReminders } from "@/lib/invoice-send-reminders";
import {
ensureNotificationPermissions,
syncInvoiceSendReminders,
} from "@/lib/invoice-send-reminders";
import { api } from "@/lib/trpc";
function openInvoiceFromNotification(data: Record<string, unknown> | undefined) {
function openInvoiceFromNotification(
data: Record<string, unknown> | undefined,
) {
if (data?.type !== "invoice-send-reminder") return;
const invoiceId = data.invoiceId;
if (typeof invoiceId !== "string" || !invoiceId) return;
@@ -21,35 +27,58 @@ export function InvoiceReminderSync() {
{ staleTime: 60_000 },
);
const wasBackgrounded = useRef(false);
const [remotePushReady, setRemotePushReady] = useState(false);
const registerPushToken = api.notifications.registerPushToken.useMutation();
useEffect(() => {
if (Platform.OS !== "ios" && Platform.OS !== "android") return;
void (async () => {
if (!(await ensureNotificationPermissions())) return;
const projectId =
Constants.easConfig?.projectId ??
(Constants.expoConfig?.extra?.eas as { projectId?: string } | undefined)
?.projectId;
if (!projectId) return;
const { data: token } = await Notifications.getExpoPushTokenAsync({
projectId,
});
await registerPushToken.mutateAsync({ token, platform: Platform.OS });
setRemotePushReady(true);
})().catch(() => {
// Local reminders remain available when remote push registration is unavailable.
});
}, [registerPushToken]);
useEffect(() => {
if (!invoicesQuery.data) return;
void syncInvoiceSendReminders(invoicesQuery.data);
}, [invoicesQuery.data]);
void syncInvoiceSendReminders(remotePushReady ? [] : invoicesQuery.data);
}, [invoicesQuery.data, remotePushReady]);
useEffect(() => {
const subscription = AppState.addEventListener("change", (nextState: AppStateStatus) => {
if (nextState === "background" || nextState === "inactive") {
wasBackgrounded.current = true;
return;
}
const subscription = AppState.addEventListener(
"change",
(nextState: AppStateStatus) => {
if (nextState === "background" || nextState === "inactive") {
wasBackgrounded.current = true;
return;
}
if (nextState !== "active" || !wasBackgrounded.current) return;
wasBackgrounded.current = false;
void utils.invoices.getAll.invalidate({ status: "draft" });
});
if (nextState !== "active" || !wasBackgrounded.current) return;
wasBackgrounded.current = false;
void utils.invoices.getAll.invalidate({ status: "draft" });
},
);
return () => subscription.remove();
}, [utils.invoices.getAll]);
useEffect(() => {
const responseSubscription = Notifications.addNotificationResponseReceivedListener(
(response) => {
const responseSubscription =
Notifications.addNotificationResponseReceivedListener((response) => {
openInvoiceFromNotification(
response.notification.request.content.data as Record<string, unknown>,
);
},
);
});
void Notifications.getLastNotificationResponseAsync().then((response) => {
if (!response) return;
@@ -6,6 +6,7 @@ import { SelectField, type SelectOption } from "@/components/ui/SelectField";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { EXPENSE_CATEGORIES } from "@/lib/expense-categories";
import { calendarDateFromLocalDate } from "@beenvoice/domain/time-zone";
const NONE = "__none__";
@@ -37,7 +38,7 @@ export function defaultExpenseFormState(
return {
description: "",
amountText: "",
date: new Date(),
date: calendarDateFromLocalDate(new Date()),
category: "",
businessId: defaultBusinessId,
clientId: "",
@@ -6,6 +6,7 @@ import { SelectField } from "@/components/ui/SelectField";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { defaultDueDate } from "@/lib/invoice-number";
import { formatCalendarDate } from "@beenvoice/domain/time-zone";
type SelectOption = { label: string; value: string };
@@ -120,7 +121,9 @@ export function InvoiceSetupForm({
{invoiceNumberReadOnly ? (
<View style={styles.readOnlyField}>
<Text style={[styles.readOnlyLabel, { color: colors.mutedForeground }]}>
<Text
style={[styles.readOnlyLabel, { color: colors.mutedForeground }]}
>
Invoice number
</Text>
<Text style={[styles.readOnlyValue, { color: colors.foreground }]}>
@@ -141,11 +144,13 @@ export function InvoiceSetupForm({
{issueDateReadOnly ? (
<View style={styles.readOnlyField}>
<Text style={[styles.readOnlyLabel, { color: colors.mutedForeground }]}>
<Text
style={[styles.readOnlyLabel, { color: colors.mutedForeground }]}
>
Issue date
</Text>
<Text style={[styles.readOnlyValue, { color: colors.foreground }]}>
{issueDate.toLocaleDateString()}
{formatCalendarDate(issueDate)}
</Text>
</View>
) : (
@@ -160,11 +165,18 @@ export function InvoiceSetupForm({
/>
)}
<DateTimeField label="Due date" mode="date" value={dueDate} onChange={onDueDateChange} />
<DateTimeField
label="Due date"
mode="date"
value={dueDate}
onChange={onDueDateChange}
/>
{taxRateReadOnly ? (
<View style={styles.readOnlyField}>
<Text style={[styles.readOnlyLabel, { color: colors.mutedForeground }]}>
<Text
style={[styles.readOnlyLabel, { color: colors.mutedForeground }]}
>
Tax rate
</Text>
<Text style={[styles.readOnlyValue, { color: colors.foreground }]}>
@@ -186,7 +198,7 @@ export function InvoiceSetupForm({
<>
<DateTimeField
label="Remind me to send"
mode="date"
mode="datetime"
value={sendReminderAt ?? dueDate}
minimumDate={new Date()}
maximumDate={new Date(2100, 0, 1)}
+51 -12
View File
@@ -3,11 +3,22 @@ import DateTimePicker, {
type DateTimePickerEvent,
} from "@react-native-community/datetimepicker";
import { useState } from "react";
import { Modal, Platform, Pressable, StyleSheet, Text, View } from "react-native";
import {
Modal,
Platform,
Pressable,
StyleSheet,
Text,
View,
} from "react-native";
import { fonts, radii, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatDate, formatDateTime } from "@/lib/format";
import {
calendarDateFromLocalDate,
calendarDateToLocalDate,
} from "@beenvoice/domain/time-zone";
type DateTimeFieldProps = {
label: string;
@@ -31,17 +42,18 @@ export function DateTimeField({
const [draft, setDraft] = useState(value);
function openPicker() {
setDraft(value);
setDraft(mode === "date" ? calendarDateToLocalDate(value) : value);
setOpen(true);
}
function applyDate(next: Date) {
const normalized = mode === "date" ? calendarDateFromLocalDate(next) : next;
const clamped =
next.getTime() > maximumDate.getTime()
normalized.getTime() > maximumDate.getTime()
? maximumDate
: minimumDate && next.getTime() < minimumDate.getTime()
: minimumDate && normalized.getTime() < minimumDate.getTime()
? minimumDate
: next;
: normalized;
onChange(clamped);
}
@@ -60,7 +72,9 @@ export function DateTimeField({
return (
<View style={styles.wrapper}>
<Text style={[styles.label, { color: colors.mutedForeground }]}>{label}</Text>
<Text style={[styles.label, { color: colors.mutedForeground }]}>
{label}
</Text>
<Pressable
accessible
accessibilityLabel={`${label}, ${
@@ -81,28 +95,53 @@ export function DateTimeField({
<Text style={[styles.value, { color: colors.foreground }]}>
{mode === "date" ? formatDate(value) : formatDateTime(value)}
</Text>
<Ionicons name="calendar-outline" size={18} color={colors.mutedForeground} />
<Ionicons
name="calendar-outline"
size={18}
color={colors.mutedForeground}
/>
</Pressable>
{Platform.OS === "ios" ? (
<Modal visible={open} transparent animationType="slide" onRequestClose={() => setOpen(false)}>
<Modal
visible={open}
transparent
animationType="slide"
onRequestClose={() => setOpen(false)}
>
<Pressable style={styles.backdrop} onPress={() => setOpen(false)}>
<Pressable
style={[styles.sheet, { backgroundColor: colors.card }]}
onPress={(event) => event.stopPropagation()}
>
<View style={[styles.sheetHeader, { borderBottomColor: colors.border }]}>
<View
style={[
styles.sheetHeader,
{ borderBottomColor: colors.border },
]}
>
<Pressable onPress={() => setOpen(false)}>
<Text style={[styles.sheetAction, { color: colors.mutedForeground }]}>Cancel</Text>
<Text
style={[
styles.sheetAction,
{ color: colors.mutedForeground },
]}
>
Cancel
</Text>
</Pressable>
<Text style={[styles.sheetTitle, { color: colors.foreground }]}>{label}</Text>
<Text style={[styles.sheetTitle, { color: colors.foreground }]}>
{label}
</Text>
<Pressable
onPress={() => {
applyDate(draft);
setOpen(false);
}}
>
<Text style={[styles.sheetAction, { color: colors.primary }]}>Done</Text>
<Text style={[styles.sheetAction, { color: colors.primary }]}>
Done
</Text>
</Pressable>
</View>
<DateTimePicker
+4 -2
View File
@@ -1,3 +1,5 @@
import { formatCalendarDate } from "@beenvoice/domain/time-zone";
export function formatCurrency(amount: number, currency = "USD") {
return new Intl.NumberFormat("en-US", {
style: "currency",
@@ -7,7 +9,7 @@ export function formatCurrency(amount: number, currency = "USD") {
}
export function formatDate(date: Date | string) {
return new Date(date).toLocaleDateString("en-US", {
return formatCalendarDate(date, {
month: "short",
day: "numeric",
year: "numeric",
@@ -15,7 +17,7 @@ export function formatDate(date: Date | string) {
}
export function formatShortDate(date: Date | string) {
return new Date(date).toLocaleDateString("en-US", {
return formatCalendarDate(date, {
month: "short",
day: "numeric",
});
+3 -3
View File
@@ -1,3 +1,5 @@
import { addCalendarDays } from "@beenvoice/domain/time-zone";
/** Matches web invoice-form default numbering. */
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);
}
+6 -1
View File
@@ -6,11 +6,16 @@ export type InvoiceStatus = EffectiveInvoiceStatus;
export function getInvoiceStatus(invoice: {
status: string;
dueDate: Date | string;
createdBy?: { timeZone: string } | null;
}): InvoiceStatus {
if (invoice.status === "paid" || invoice.status === "draft") {
return invoice.status;
}
return getEffectiveInvoiceStatus("sent", invoice.dueDate);
return getEffectiveInvoiceStatus(
"sent",
invoice.dueDate,
invoice.createdBy?.timeZone ?? "America/New_York",
);
}
export const statusLabels: Record<InvoiceStatus, string> = {
+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);
+5
View File
@@ -11,6 +11,7 @@ import {
type BackgroundJob,
} from "../../web/src/server/jobs/queue";
import { sendScheduledInvoice } from "./send-invoice";
import { sendInvoiceReminder } from "../../web/src/server/jobs/handlers/invoice-reminder";
const workerId = `beenvoice-worker:${randomUUID()}`;
const pollMs = Number(process.env.WORKER_POLL_MS ?? 2_000);
@@ -69,6 +70,10 @@ async function handleJob(job: BackgroundJob) {
await sendScheduledInvoice(job);
return;
}
if (job.type === jobTypes.sendInvoiceReminder) {
await sendInvoiceReminder(job);
return;
}
throw new Error(`No handler registered for ${job.type}`);
}
+4 -4
View File
@@ -5,14 +5,14 @@ import { nextDueDate } from "../../web/src/server/services/recurring-invoices";
describe("recurring invoice scheduling", () => {
test("advances weekly schedules from their scheduled occurrence", () => {
const scheduledFor = new Date("2026-08-17T12:00:00.000Z");
expect(nextDueDate("weekly", scheduledFor).toISOString()).toBe(
"2026-08-24T12:00:00.000Z",
);
expect(
nextDueDate("weekly", scheduledFor, "America/New_York").toISOString(),
).toBe("2026-08-24T12:00:00.000Z");
});
test("does not mutate the source date", () => {
const scheduledFor = new Date("2026-08-17T12:00:00.000Z");
nextDueDate("monthly", scheduledFor);
nextDueDate("monthly", scheduledFor, "America/New_York");
expect(scheduledFor.toISOString()).toBe("2026-08-17T12:00:00.000Z");
});
});
+2
View File
@@ -23,6 +23,7 @@ services:
environment:
SERVICE_FQDN_APP:
NODE_ENV: production
TZ: UTC
PORT: ${APP_PORT:-3000}
AUTH_SECRET: ${AUTH_SECRET:?Set AUTH_SECRET in Coolify env}
DATABASE_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-postgres}
@@ -67,6 +68,7 @@ services:
image: ${BEENVOICE_WORKER_IMAGE:-beenvoice-worker:coolify}
environment:
NODE_ENV: production
TZ: UTC
DATABASE_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-postgres}
DB_DISABLE_SSL: "true"
AUTH_SECRET: ${AUTH_SECRET:?Set AUTH_SECRET in Coolify env}
+1
View File
@@ -5,6 +5,7 @@ services:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: ${POSTGRES_DB:-postgres}
TZ: UTC
volumes:
- beenvoice_dev_pg_data:/var/lib/postgresql/data
healthcheck:
+3
View File
@@ -20,6 +20,7 @@ services:
image: ${BEENVOICE_IMAGE:-beenvoice:local}
environment:
NODE_ENV: production
TZ: UTC
AUTH_SECRET: ${AUTH_SECRET:?Set AUTH_SECRET in .env}
DATABASE_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-postgres}
DB_DISABLE_SSL: "true"
@@ -63,6 +64,7 @@ services:
image: ${BEENVOICE_WORKER_IMAGE:-beenvoice-worker:local}
environment:
NODE_ENV: production
TZ: UTC
AUTH_SECRET: ${AUTH_SECRET:?Set AUTH_SECRET in .env}
DATABASE_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-postgres}
DB_DISABLE_SSL: "true"
@@ -85,6 +87,7 @@ services:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: ${POSTGRES_DB:-postgres}
TZ: UTC
volumes:
- beenvoice_pg_data:/var/lib/postgresql/data
healthcheck:
+33 -13
View File
@@ -4,33 +4,48 @@ export type EffectiveInvoiceStatus = StoredInvoiceStatus | "overdue";
export function getEffectiveInvoiceStatus(
storedStatus: StoredInvoiceStatus,
dueDate: Date | string,
timeZone = getLocalTimeZone(),
now = new Date(),
): EffectiveInvoiceStatus {
if (storedStatus === "paid" || storedStatus === "draft") return storedStatus;
const today = new Date();
const due = new Date(dueDate);
today.setHours(0, 0, 0, 0);
due.setHours(0, 0, 0, 0);
return due < today ? "overdue" : "sent";
return calendarDateKey(dueDate) < zonedTodayKey(now, timeZone)
? "overdue"
: "sent";
}
export function isInvoiceOverdue(
storedStatus: StoredInvoiceStatus,
dueDate: Date | string,
timeZone = getLocalTimeZone(),
): boolean {
return getEffectiveInvoiceStatus(storedStatus, dueDate) === "overdue";
return (
getEffectiveInvoiceStatus(storedStatus, dueDate, timeZone) === "overdue"
);
}
export function getDaysPastDue(
storedStatus: StoredInvoiceStatus,
dueDate: Date | string,
timeZone = getLocalTimeZone(),
now = new Date(),
): number {
if (!isInvoiceOverdue(storedStatus, dueDate)) return 0;
const today = new Date();
const due = new Date(dueDate);
today.setHours(0, 0, 0, 0);
due.setHours(0, 0, 0, 0);
return Math.max(0, Math.ceil((today.getTime() - due.getTime()) / 86_400_000));
if (
getEffectiveInvoiceStatus(storedStatus, dueDate, timeZone, now) !==
"overdue"
)
return 0;
const dueKey = calendarDateKey(dueDate);
const todayKey = zonedTodayKey(now, timeZone);
return Math.max(
0,
Math.round((Date.parse(todayKey) - Date.parse(dueKey)) / 86_400_000),
);
}
function zonedTodayKey(now: Date, timeZone: string) {
const parts = getZonedDateTimeParts(now, timeZone);
const pad = (value: number) => String(value).padStart(2, "0");
return `${parts.year}-${pad(parts.month)}-${pad(parts.day)}`;
}
export function getValidStatusTransitions(
@@ -52,3 +67,8 @@ export function isValidStatusTransition(
): boolean {
return getValidStatusTransitions(from).includes(to);
}
import {
calendarDateKey,
getLocalTimeZone,
getZonedDateTimeParts,
} from "./time-zone";
+245 -1
View File
@@ -1,4 +1,248 @@
const FALLBACK_TIME_ZONE = "UTC";
export const DEFAULT_TIME_ZONE = "America/New_York";
const FALLBACK_TIME_ZONE = DEFAULT_TIME_ZONE;
export type ZonedDateTimeDisambiguation = "earlier" | "later" | "reject";
type DateTimeParts = {
year: number;
month: number;
day: number;
hour: number;
minute: number;
second: number;
};
const WALL_TIME_FORMATTERS = new Map<string, Intl.DateTimeFormat>();
function wallTimeFormatter(timeZone: string) {
let formatter = WALL_TIME_FORMATTERS.get(timeZone);
if (!formatter) {
formatter = new Intl.DateTimeFormat("en-US-u-ca-gregory-nu-latn", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h23",
});
WALL_TIME_FORMATTERS.set(timeZone, formatter);
}
return formatter;
}
export function getZonedDateTimeParts(
value: Date | string | number,
timeZone: string,
): DateTimeParts {
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) throw new RangeError("Invalid date");
if (!isValidTimeZone(timeZone)) throw new RangeError("Invalid time zone");
const parts = Object.fromEntries(
wallTimeFormatter(timeZone)
.formatToParts(date)
.filter((part) => part.type !== "literal")
.map((part) => [part.type, Number(part.value)]),
) as Record<string, number>;
return {
year: parts.year!,
month: parts.month!,
day: parts.day!,
hour: parts.hour!,
minute: parts.minute!,
second: parts.second!,
};
}
function sameWallTime(a: DateTimeParts, b: DateTimeParts) {
return (
a.year === b.year &&
a.month === b.month &&
a.day === b.day &&
a.hour === b.hour &&
a.minute === b.minute &&
a.second === b.second
);
}
function parseLocalDateTime(value: string): DateTimeParts {
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(
value,
);
if (!match) throw new RangeError("Expected YYYY-MM-DDTHH:mm");
const parts = {
year: Number(match[1]),
month: Number(match[2]),
day: Number(match[3]),
hour: Number(match[4]),
minute: Number(match[5]),
second: Number(match[6] ?? 0),
};
const check = new Date(
Date.UTC(
parts.year,
parts.month - 1,
parts.day,
parts.hour,
parts.minute,
parts.second,
),
);
if (
check.getUTCFullYear() !== parts.year ||
check.getUTCMonth() + 1 !== parts.month ||
check.getUTCDate() !== parts.day ||
parts.hour > 23 ||
parts.minute > 59 ||
parts.second > 59
) {
throw new RangeError("Invalid local date and time");
}
return parts;
}
export function zonedDateTimeToInstant(
localDateTime: string,
timeZone: string,
disambiguation: ZonedDateTimeDisambiguation = "reject",
): Date {
if (!isValidTimeZone(timeZone)) throw new RangeError("Invalid time zone");
const desired = parseLocalDateTime(localDateTime);
const wallAsUtc = Date.UTC(
desired.year,
desired.month - 1,
desired.day,
desired.hour,
desired.minute,
desired.second,
);
let candidateMs = wallAsUtc;
for (let attempt = 0; attempt < 3; attempt++) {
const observed = getZonedDateTimeParts(candidateMs, timeZone);
const observedAsUtc = Date.UTC(
observed.year,
observed.month - 1,
observed.day,
observed.hour,
observed.minute,
observed.second,
);
candidateMs += wallAsUtc - observedAsUtc;
}
const candidates = Array.from(
{ length: 25 },
(_, index) => candidateMs + (index - 12) * 15 * 60_000,
)
.filter((value, index, all) => all.indexOf(value) === index)
.filter((value) =>
sameWallTime(getZonedDateTimeParts(value, timeZone), desired),
)
.sort((a, b) => a - b);
if (candidates.length === 0)
throw new RangeError("That local time does not exist");
if (candidates.length > 1 && disambiguation === "reject") {
throw new RangeError(
"That local time occurs twice; choose earlier or later",
);
}
return new Date(
disambiguation === "later" ? candidates.at(-1)! : candidates[0]!,
);
}
export function toZonedDateTimeInputValue(
value: Date | string | number,
timeZone: string,
): string {
const parts = getZonedDateTimeParts(value, timeZone);
const pad = (part: number) => String(part).padStart(2, "0");
return `${parts.year}-${pad(parts.month)}-${pad(parts.day)}T${pad(parts.hour)}:${pad(parts.minute)}`;
}
export function addZonedCalendarInterval(
value: Date | string | number,
schedule: "weekly" | "biweekly" | "monthly" | "quarterly" | "yearly",
timeZone: string,
): Date {
const source = getZonedDateTimeParts(value, timeZone);
const calendar = new Date(
Date.UTC(source.year, source.month - 1, source.day),
);
if (schedule === "weekly" || schedule === "biweekly") {
calendar.setUTCDate(
calendar.getUTCDate() + (schedule === "weekly" ? 7 : 14),
);
} else {
const months =
schedule === "monthly" ? 1 : schedule === "quarterly" ? 3 : 12;
const originalDay = calendar.getUTCDate();
calendar.setUTCDate(1);
calendar.setUTCMonth(calendar.getUTCMonth() + months);
const lastDay = new Date(
Date.UTC(calendar.getUTCFullYear(), calendar.getUTCMonth() + 1, 0),
).getUTCDate();
calendar.setUTCDate(Math.min(originalDay, lastDay));
}
const pad = (part: number) => String(part).padStart(2, "0");
return zonedDateTimeToInstant(
`${calendar.getUTCFullYear()}-${pad(calendar.getUTCMonth() + 1)}-${pad(calendar.getUTCDate())}T${pad(source.hour)}:${pad(source.minute)}:${pad(source.second)}`,
timeZone,
"earlier",
);
}
export function formatCalendarDate(
value: Date | string,
options: Intl.DateTimeFormatOptions = {},
): string {
const date =
value instanceof Date
? value
: new Date(`${value.slice(0, 10)}T12:00:00.000Z`);
if (Number.isNaN(date.getTime())) return "Invalid date";
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "short",
day: "numeric",
...options,
timeZone: "UTC",
}).format(date);
}
export function calendarDateKey(value: Date | string): string {
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}/.test(value))
return value.slice(0, 10);
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) throw new RangeError("Invalid date");
return date.toISOString().slice(0, 10);
}
export function calendarDateFromLocalDate(value: Date): Date {
return new Date(
Date.UTC(value.getFullYear(), value.getMonth(), value.getDate(), 12),
);
}
export function calendarDateToLocalDate(value: Date | string): Date {
const [year, month, day] = calendarDateKey(value).split("-").map(Number);
return new Date(year!, month! - 1, day!, 12);
}
export function calendarDateFromInstant(
value: Date | string | number,
timeZone: string,
): Date {
const parts = getZonedDateTimeParts(value, timeZone);
return new Date(Date.UTC(parts.year, parts.month - 1, parts.day, 12));
}
export function addCalendarDays(value: Date | string, days: number): Date {
const [year, month, day] = calendarDateKey(value).split("-").map(Number);
return new Date(Date.UTC(year!, month! - 1, day! + days, 12));
}
export function isValidTimeZone(value: string): boolean {
if (!value.trim()) return false;
+77
View File
@@ -3,14 +3,17 @@
import { describe, expect, test } from "bun:test";
import {
formatZonedDateTime,
addZonedCalendarInterval,
getDefaultScheduledSendAt,
isValidTimeZone,
toLocalDateTimeInputValue,
zonedDateTimeToInstant,
} from "../src/time-zone";
import {
EXPENSE_CATEGORIES,
formatElapsedSeconds,
getEffectiveInvoiceStatus,
getDaysPastDue,
parseReceiptText,
} from "../src";
@@ -26,6 +29,17 @@ describe("shared domain behavior", () => {
expect(getEffectiveInvoiceStatus("paid", yesterday)).toBe("paid");
});
test("counts calendar days rather than 24-hour blocks across fall DST", () => {
expect(
getDaysPastDue(
"sent",
"2026-11-01",
"America/New_York",
new Date("2026-11-02T17:00:00.000Z"),
),
).toBe(1);
});
test("formats elapsed time", () => {
expect(formatElapsedSeconds(3_661)).toBe("01:01:01");
});
@@ -56,6 +70,69 @@ describe("time-zone helpers", () => {
expect(isValidTimeZone("not/a-zone")).toBe(false);
});
test("converts Eastern wall time to the correct absolute instant", () => {
expect(
zonedDateTimeToInstant(
"2026-08-17T09:00",
"America/New_York",
).toISOString(),
).toBe("2026-08-17T13:00:00.000Z");
});
test("rejects nonexistent spring-forward wall times", () => {
expect(() =>
zonedDateTimeToInstant("2026-03-08T02:30", "America/New_York"),
).toThrow("does not exist");
});
test("disambiguates both occurrences of a fall-back wall time", () => {
expect(
zonedDateTimeToInstant(
"2026-11-01T01:30",
"America/New_York",
"earlier",
).toISOString(),
).toBe("2026-11-01T05:30:00.000Z");
expect(
zonedDateTimeToInstant(
"2026-11-01T01:30",
"America/New_York",
"later",
).toISOString(),
).toBe("2026-11-01T06:30:00.000Z");
});
test("supports half-hour DST transitions", () => {
const earlier = zonedDateTimeToInstant(
"2026-04-05T01:45",
"Australia/Lord_Howe",
"earlier",
);
const later = zonedDateTimeToInstant(
"2026-04-05T01:45",
"Australia/Lord_Howe",
"later",
);
expect(later.getTime() - earlier.getTime()).toBe(30 * 60_000);
});
test("preserves Eastern wall time across DST and clamps month end", () => {
expect(
addZonedCalendarInterval(
new Date("2026-03-01T14:00:00.000Z"),
"weekly",
"America/New_York",
).toISOString(),
).toBe("2026-03-08T13:00:00.000Z");
expect(
addZonedCalendarInterval(
new Date("2026-01-31T14:00:00.000Z"),
"monthly",
"America/New_York",
).toISOString(),
).toBe("2026-02-28T14:00:00.000Z");
});
test("rounds the default schedule to the next local hour", () => {
const result = getDefaultScheduledSendAt(new Date(2026, 7, 17, 10, 42, 19));
expect(toLocalDateTimeInputValue(result)).toBe("2026-08-17T11:00");