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 { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
import { InvoiceSetupForm } from "@/components/invoices/InvoiceSetupForm"; import { InvoiceSetupForm } from "@/components/invoices/InvoiceSetupForm";
import { InvoiceTotals } from "@/components/invoices/InvoiceTotals"; 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 { LoadingScreen } from "@/components/LoadingScreen";
import { Card } from "@/components/ui/Card"; import { Card } from "@/components/ui/Card";
import { fonts, spacing } from "@/constants/theme"; 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 type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles"; import { useThemedStyles } from "@/lib/use-themed-styles";
import { api } from "@/lib/trpc"; import { api } from "@/lib/trpc";
import { calendarDateFromLocalDate } from "@beenvoice/domain/time-zone";
export default function InvoiceEditScreen() { export default function InvoiceEditScreen() {
const { colors } = useAppTheme(); const { colors } = useAppTheme();
@@ -53,7 +57,9 @@ export default function InvoiceEditScreen() {
const [businessId, setBusinessId] = useState(""); const [businessId, setBusinessId] = useState("");
const [clientId, setClientId] = useState(""); const [clientId, setClientId] = useState("");
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
const [dueDate, setDueDate] = useState(() => new Date()); const [dueDate, setDueDate] = useState(() =>
calendarDateFromLocalDate(new Date()),
);
const [taxRate, setTaxRate] = useState("0"); const [taxRate, setTaxRate] = useState("0");
const [sendReminderAt, setSendReminderAt] = useState<Date | null>(null); const [sendReminderAt, setSendReminderAt] = useState<Date | null>(null);
const [items, setItems] = useState<EditableLineItem[]>([]); const [items, setItems] = useState<EditableLineItem[]>([]);
@@ -68,7 +74,9 @@ export default function InvoiceEditScreen() {
setNotes(invoice.notes ?? ""); setNotes(invoice.notes ?? "");
setDueDate(new Date(invoice.dueDate)); setDueDate(new Date(invoice.dueDate));
setTaxRate(String(invoice.taxRate)); setTaxRate(String(invoice.taxRate));
setSendReminderAt(invoice.sendReminderAt ? new Date(invoice.sendReminderAt) : null); setSendReminderAt(
invoice.sendReminderAt ? new Date(invoice.sendReminderAt) : null,
);
setItems( setItems(
invoice.items.map((item) => ({ invoice.items.map((item) => ({
id: item.id, id: item.id,
@@ -119,9 +127,14 @@ export default function InvoiceEditScreen() {
[clientsQuery.data], [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 currency = selectedClient?.currency ?? invoice?.currency ?? "USD";
const resolvedBusinessId = resolveInvoiceBusinessId(businessId, businessesQuery.data); const resolvedBusinessId = resolveInvoiceBusinessId(
businessId,
businessesQuery.data,
);
const subtotal = useMemo( const subtotal = useMemo(
() => () =>
@@ -137,8 +150,12 @@ export default function InvoiceEditScreen() {
const taxAmount = subtotal * (parsedTaxRate / 100); const taxAmount = subtotal * (parsedTaxRate / 100);
const total = subtotal + taxAmount; const total = subtotal + taxAmount;
const lineItemsError = isDraft ? validateLineItems(items) : null; const lineItemsError = isDraft ? validateLineItems(items) : null;
const taxError = isDraft && !isValidTaxRate(taxRate) ? "Tax rate must be between 0 and 100" : null; const taxError =
const businessError = isDraft && !resolvedBusinessId ? "Select a business" : undefined; 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 clientError = isDraft && !clientId ? "Select a client" : undefined;
const canSave = isDraft const canSave = isDraft
? !lineItemsError && !taxError && !businessError && !clientError ? !lineItemsError && !taxError && !businessError && !clientError
@@ -159,13 +176,26 @@ export default function InvoiceEditScreen() {
currency, currency,
items, items,
}); });
}, [invoice, resolvedBusinessId, clientId, dueDate, notes, parsedTaxRate, currency, items]); }, [
invoice,
resolvedBusinessId,
clientId,
dueDate,
notes,
parsedTaxRate,
currency,
items,
]);
if (!id) { if (!id) {
return <LoadingScreen message="Invalid invoice" />; return <LoadingScreen message="Invalid invoice" />;
} }
if (invoiceQuery.isLoading || businessesQuery.isLoading || clientsQuery.isLoading) { if (
invoiceQuery.isLoading ||
businessesQuery.isLoading ||
clientsQuery.isLoading
) {
return <LoadingScreen message="Loading invoice…" />; return <LoadingScreen message="Loading invoice…" />;
} }
@@ -177,14 +207,16 @@ export default function InvoiceEditScreen() {
const clientEmail = invoice.client?.email?.trim() ?? ""; const clientEmail = invoice.client?.email?.trim() ?? "";
function updateItem(index: number, patch: Partial<EditableLineItem>) { 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() { function addItem() {
setItems((prev) => [ setItems((prev) => [
...prev, ...prev,
{ {
date: new Date(), date: calendarDateFromLocalDate(new Date()),
description: "", description: "",
hours: "1", hours: "1",
rate: prev[prev.length - 1]?.rate ?? "0", rate: prev[prev.length - 1]?.rate ?? "0",
@@ -260,8 +292,13 @@ export default function InvoiceEditScreen() {
style={styles.flex} style={styles.flex}
> >
<ScrollView <ScrollView
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]} contentContainerStyle={[
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : undefined} styles.container,
{ paddingBottom: scrollPadding },
]}
contentInsetAdjustmentBehavior={
Platform.OS === "ios" ? "automatic" : undefined
}
scrollIndicatorInsets={{ bottom: scrollPadding }} scrollIndicatorInsets={{ bottom: scrollPadding }}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
> >
@@ -316,13 +353,13 @@ export default function InvoiceEditScreen() {
<Card title="Line items"> <Card title="Line items">
{!isDraft ? ( {!isDraft ? (
<Text style={styles.lockedHint}> <Text style={styles.lockedHint}>
Line items are locked after an invoice is sent. Mark as draft on the invoice Line items are locked after an invoice is sent. Mark as
screen to edit entries. draft on the invoice screen to edit entries.
</Text> </Text>
) : items.length === 0 ? ( ) : items.length === 0 ? (
<Text style={styles.emptyLines}> <Text style={styles.emptyLines}>
No line items yet. Add lines here or clock time to this invoice from the No line items yet. Add lines here or clock time to this
Timer tab. invoice from the Timer tab.
</Text> </Text>
) : null} ) : null}
{items.map((item, index) => ( {items.map((item, index) => (
@@ -334,26 +371,40 @@ export default function InvoiceEditScreen() {
isLast={index === items.length - 1} isLast={index === items.length - 1}
onChange={(patch) => updateItem(index, patch)} onChange={(patch) => updateItem(index, patch)}
onRemove={() => removeItem(index)} onRemove={() => removeItem(index)}
onDuplicate={isDraft ? () => duplicateItem(index) : undefined} onDuplicate={
isDraft ? () => duplicateItem(index) : undefined
}
readOnly={!isDraft} readOnly={!isDraft}
/> />
))} ))}
{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> <Text style={styles.addLineText}>+ Add another line</Text>
</Pressable> </Pressable>
) : null} ) : null}
<InvoiceTotals <InvoiceTotals
subtotal={formatCurrency(subtotal, currency)} subtotal={formatCurrency(subtotal, currency)}
taxLabel={parsedTaxRate > 0 ? `Tax (${parsedTaxRate}%)` : undefined} taxLabel={
taxAmount={parsedTaxRate > 0 ? formatCurrency(taxAmount, currency) : undefined} parsedTaxRate > 0 ? `Tax (${parsedTaxRate}%)` : undefined
}
taxAmount={
parsedTaxRate > 0
? formatCurrency(taxAmount, currency)
: undefined
}
total={formatCurrency(total, currency)} total={formatCurrency(total, currency)}
/> />
</Card> </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={ secondary={
status !== "paid" status !== "paid"
? { ? {
title: status === "draft" ? "Send invoice" : "Resend invoice", title:
status === "draft" ? "Send invoice" : "Resend invoice",
subtitle: clientEmail subtitle: clientEmail
? items.length === 0 ? items.length === 0
? "Add line items before sending" ? "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 { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
import { InvoiceSetupForm } from "@/components/invoices/InvoiceSetupForm"; import { InvoiceSetupForm } from "@/components/invoices/InvoiceSetupForm";
import { InvoiceTotals } from "@/components/invoices/InvoiceTotals"; 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 { LoadingScreen } from "@/components/LoadingScreen";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card"; 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 type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles"; import { useThemedStyles } from "@/lib/use-themed-styles";
import { api } from "@/lib/trpc"; import { api } from "@/lib/trpc";
import { calendarDateFromLocalDate } from "@beenvoice/domain/time-zone";
export default function NewInvoiceScreen() { export default function NewInvoiceScreen() {
const styles = useThemedStyles(createNewInvoiceStyles); const styles = useThemedStyles(createNewInvoiceStyles);
@@ -53,8 +57,12 @@ export default function NewInvoiceScreen() {
const [businessId, setBusinessId] = useState(""); const [businessId, setBusinessId] = useState("");
const [clientId, setClientId] = useState(""); const [clientId, setClientId] = useState("");
const [invoiceNumber, setInvoiceNumber] = useState(generateInvoiceNumber); const [invoiceNumber, setInvoiceNumber] = useState(generateInvoiceNumber);
const [issueDate, setIssueDate] = useState(() => new Date()); const [issueDate, setIssueDate] = useState(() =>
const [dueDate, setDueDate] = useState(() => defaultDueDate(new Date())); calendarDateFromLocalDate(new Date()),
);
const [dueDate, setDueDate] = useState(() =>
defaultDueDate(calendarDateFromLocalDate(new Date())),
);
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
const [taxRate, setTaxRate] = useState("0"); const [taxRate, setTaxRate] = useState("0");
const [items, setItems] = useState<EditableLineItem[]>(() => const [items, setItems] = useState<EditableLineItem[]>(() =>
@@ -62,7 +70,7 @@ export default function NewInvoiceScreen() {
? [] ? []
: [ : [
{ {
date: new Date(), date: calendarDateFromLocalDate(new Date()),
description: "", description: "",
hours: "1", hours: "1",
rate: "0", rate: "0",
@@ -96,9 +104,14 @@ export default function NewInvoiceScreen() {
[clientsQuery.data], [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 currency = selectedClient?.currency ?? "USD";
const resolvedBusinessId = resolveInvoiceBusinessId(businessId, businessesQuery.data); const resolvedBusinessId = resolveInvoiceBusinessId(
businessId,
businessesQuery.data,
);
useEffect(() => { useEffect(() => {
if (!selectedClient?.defaultHourlyRate) return; if (!selectedClient?.defaultHourlyRate) return;
@@ -170,7 +183,9 @@ export default function NewInvoiceScreen() {
const invoiceNumberError = isRequiredString(invoiceNumber) const invoiceNumberError = isRequiredString(invoiceNumber)
? undefined ? undefined
: "Invoice number is required"; : "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 lineItemsError = validateLineItems(items);
const canCreate = const canCreate =
businessOptions.length > 0 && businessOptions.length > 0 &&
@@ -187,7 +202,9 @@ export default function NewInvoiceScreen() {
function updateItem(index: number, patch: Partial<EditableLineItem>) { function updateItem(index: number, patch: Partial<EditableLineItem>) {
touch("lineItems"); 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() { function addItem() {
@@ -195,7 +212,7 @@ export default function NewInvoiceScreen() {
setItems((prev) => [ setItems((prev) => [
...prev, ...prev,
{ {
date: new Date(), date: calendarDateFromLocalDate(new Date()),
description: "", description: "",
hours: "1", hours: "1",
rate: prev[prev.length - 1]?.rate ?? "0", rate: prev[prev.length - 1]?.rate ?? "0",
@@ -266,8 +283,13 @@ export default function NewInvoiceScreen() {
style={styles.flex} style={styles.flex}
> >
<ScrollView <ScrollView
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]} contentContainerStyle={[
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : undefined} styles.container,
{ paddingBottom: scrollPadding },
]}
contentInsetAdjustmentBehavior={
Platform.OS === "ios" ? "automatic" : undefined
}
scrollIndicatorInsets={{ bottom: scrollPadding }} scrollIndicatorInsets={{ bottom: scrollPadding }}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
> >
@@ -287,7 +309,11 @@ export default function NewInvoiceScreen() {
: "Add a client before creating an invoice."} : "Add a client before creating an invoice."}
</Text> </Text>
<Button <Button
title={businessOptions.length === 0 ? "Add business" : "Add client"} title={
businessOptions.length === 0
? "Add business"
: "Add client"
}
variant="secondary" variant="secondary"
onPress={() => onPress={() =>
router.push( router.push(
@@ -303,7 +329,9 @@ export default function NewInvoiceScreen() {
businessId={businessId} businessId={businessId}
onBusinessIdChange={setBusinessId} onBusinessIdChange={setBusinessId}
businessOptions={businessOptions} businessOptions={businessOptions}
businessError={visible("business") ? businessError : undefined} businessError={
visible("business") ? businessError : undefined
}
onBusinessBlur={() => touch("business")} onBusinessBlur={() => touch("business")}
clientId={clientId} clientId={clientId}
onClientIdChange={setClientId} onClientIdChange={setClientId}
@@ -334,8 +362,8 @@ export default function NewInvoiceScreen() {
<Card title="Line items"> <Card title="Line items">
{isBlank && items.length === 0 ? ( {isBlank && items.length === 0 ? (
<Text style={styles.emptyLines}> <Text style={styles.emptyLines}>
No line items yet. Save this draft and clock time to it from the Timer tab, No line items yet. Save this draft and clock time to it from
or add lines here. the Timer tab, or add lines here.
</Text> </Text>
) : null} ) : null}
{items.map((item, index) => ( {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> <Text style={styles.addLineText}>+ Add another line</Text>
</Pressable> </Pressable>
<InvoiceTotals <InvoiceTotals
subtotal={formatCurrency(subtotal, currency)} subtotal={formatCurrency(subtotal, currency)}
taxLabel={parsedTaxRate > 0 ? `Tax (${parsedTaxRate}%)` : undefined} taxLabel={
parsedTaxRate > 0 ? `Tax (${parsedTaxRate}%)` : undefined
}
taxAmount={ taxAmount={
parsedTaxRate > 0 ? formatCurrency(taxAmount, currency) : undefined parsedTaxRate > 0
? formatCurrency(taxAmount, currency)
: undefined
} }
total={formatCurrency(total, currency)} total={formatCurrency(total, currency)}
/> />
</Card> </Card>
{visible("lineItems") && lineItemsError ? ( {visible("lineItems") && lineItemsError ? (
<Text selectable style={styles.error}>{lineItemsError}</Text> <Text selectable style={styles.error}>
{lineItemsError}
</Text>
) : null} ) : null}
</> </>
)} )}
{error ? <Text selectable style={styles.error}>{error}</Text> : null} {error ? (
<Text selectable style={styles.error}>
{error}
</Text>
) : null}
<InvoiceEditorFooter <InvoiceEditorFooter
primaryTitle={isBlank ? "Create blank invoice" : "Create invoice"} primaryTitle={isBlank ? "Create blank invoice" : "Create invoice"}
+21 -4
View File
@@ -20,7 +20,9 @@ import { DateTimeField } from "@/components/ui/DateTimeField";
import { import {
formatZonedDateTime, formatZonedDateTime,
getDefaultScheduledSendAt, getDefaultScheduledSendAt,
getLocalTimeZone, DEFAULT_TIME_ZONE,
toLocalDateTimeInputValue,
zonedDateTimeToInstant,
} from "@beenvoice/domain/time-zone"; } from "@beenvoice/domain/time-zone";
import { fonts, spacing } from "@/constants/theme"; import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext"; import { useAppTheme } from "@/contexts/ThemeContext";
@@ -42,7 +44,8 @@ export default function InvoiceSendScreen() {
const [scheduledAt, setScheduledAt] = useState(() => const [scheduledAt, setScheduledAt] = useState(() =>
getDefaultScheduledSendAt(), 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( const invoiceQuery = api.invoices.getById.useQuery(
{ id: id ?? "" }, { id: id ?? "" },
@@ -139,7 +142,21 @@ export default function InvoiceSendScreen() {
function handleSchedule() { function handleSchedule() {
if (!clientEmail || invoice.items.length === 0) return; 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( Alert.alert(
"Choose a future time", "Choose a future time",
"The scheduled time must be at least one minute from now.", "The scheduled time must be at least one minute from now.",
@@ -148,7 +165,7 @@ export default function InvoiceSendScreen() {
} }
scheduleInvoice.mutate({ scheduleInvoice.mutate({
invoiceId: invoice.id, invoiceId: invoice.id,
scheduledAt, scheduledAt: instant,
timeZone, timeZone,
customMessage: customMessage.trim() || undefined, customMessage: customMessage.trim() || undefined,
}); });
+5 -2
View File
@@ -19,6 +19,7 @@ import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency, formatDate } from "@/lib/format"; import { formatCurrency, formatDate } from "@/lib/format";
import { scanReceiptImage, type ReceiptScanResult } from "@/lib/receipt-scan"; import { scanReceiptImage, type ReceiptScanResult } from "@/lib/receipt-scan";
import { api } from "@/lib/trpc"; import { api } from "@/lib/trpc";
import { calendarDateFromLocalDate } from "@beenvoice/domain/time-zone";
type ReceiptSplitDraft = Pick< type ReceiptSplitDraft = Pick<
ReceiptScanResult, ReceiptScanResult,
@@ -37,7 +38,7 @@ export default function ExpenseDetailScreen() {
const [form, setForm] = useState<ExpenseFormState>({ const [form, setForm] = useState<ExpenseFormState>({
description: "", description: "",
amountText: "", amountText: "",
date: new Date(), date: calendarDateFromLocalDate(new Date()),
category: "", category: "",
businessId: "", businessId: "",
clientId: "", clientId: "",
@@ -165,7 +166,9 @@ export default function ExpenseDetailScreen() {
return ( return (
<AppBackground> <AppBackground>
<TabPage showMoreBack> <TabPage showMoreBack>
<TabScrollView header={<PageHeader title="Expense" subtitle="Expense details" />}> <TabScrollView
header={<PageHeader title="Expense" subtitle="Expense details" />}
>
<Text style={{ color: colors.mutedForeground }}> <Text style={{ color: colors.mutedForeground }}>
Expense not found Expense not found
</Text> </Text>
+106 -34
View File
@@ -1,12 +1,7 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router"; import { router } from "expo-router";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { import { ScrollView, StyleSheet, Text, View } from "react-native";
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import type { AppRouter } from "beenvoice/server/api/root"; import type { AppRouter } from "beenvoice/server/api/root";
import type { inferRouterOutputs } from "@trpc/server"; import type { inferRouterOutputs } from "@trpc/server";
@@ -26,6 +21,7 @@ import { api } from "@/lib/trpc";
import { formatTrpcErrorMessage } from "@/lib/trpc-errors"; import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
import type { ThemeColors } from "@/lib/theme-palette"; import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles"; import { useThemedStyles } from "@/lib/use-themed-styles";
import { formatCalendarDate } from "@beenvoice/domain/time-zone";
type ExpenseFilter = "all" | "billable" | "receipts"; type ExpenseFilter = "all" | "billable" | "receipts";
type Expense = inferRouterOutputs<AppRouter>["expenses"]["getAll"][number]; type Expense = inferRouterOutputs<AppRouter>["expenses"]["getAll"][number];
@@ -120,17 +116,27 @@ export default function ExpensesScreen() {
<View <View
style={[ style={[
styles.emptyCard, styles.emptyCard,
{ borderColor: colors.border, backgroundColor: colors.cardGlass }, {
borderColor: colors.border,
backgroundColor: colors.cardGlass,
},
]} ]}
> >
<View style={[styles.emptyIcon, { backgroundColor: colors.muted }]}> <View
<Ionicons name="receipt-outline" size={24} color={colors.primary} /> style={[styles.emptyIcon, { backgroundColor: colors.muted }]}
>
<Ionicons
name="receipt-outline"
size={24}
color={colors.primary}
/>
</View> </View>
<Text style={[styles.emptyTitle, { color: colors.foreground }]}> <Text style={[styles.emptyTitle, { color: colors.foreground }]}>
No expenses yet No expenses yet
</Text> </Text>
<Text style={[styles.empty, { color: colors.mutedForeground }]}> <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> </Text>
<Button <Button
title="Add expense" title="Add expense"
@@ -140,9 +146,18 @@ export default function ExpensesScreen() {
) : ( ) : (
<> <>
<View style={styles.summaryGrid}> <View style={styles.summaryGrid}>
<SummaryTile label="Visible total" value={formatCurrency(summary.total)} /> <SummaryTile
<SummaryTile label="Billable" value={formatCurrency(summary.billable)} /> label="Visible total"
<SummaryTile label="Receipts" value={String(summary.receiptCount)} /> value={formatCurrency(summary.total)}
/>
<SummaryTile
label="Billable"
value={formatCurrency(summary.billable)}
/>
<SummaryTile
label="Receipts"
value={String(summary.receiptCount)}
/>
</View> </View>
<ScrollView <ScrollView
@@ -174,14 +189,21 @@ export default function ExpensesScreen() {
) : ( ) : (
groupedExpenses.map(([monthLabel, group]) => ( groupedExpenses.map(([monthLabel, group]) => (
<View key={monthLabel} style={styles.monthGroup}> <View key={monthLabel} style={styles.monthGroup}>
<Text style={[styles.monthLabel, { color: colors.mutedForeground }]}> <Text
style={[
styles.monthLabel,
{ color: colors.mutedForeground },
]}
>
{monthLabel} {monthLabel}
</Text> </Text>
{group.map((expense) => ( {group.map((expense) => (
<ExpenseRow <ExpenseRow
key={expense.id} key={expense.id}
expense={expense} expense={expense}
onDelete={() => deleteExpense.mutate({ id: expense.id })} onDelete={() =>
deleteExpense.mutate({ id: expense.id })
}
/> />
))} ))}
</View> </View>
@@ -209,14 +231,23 @@ function SummaryTile({ label, value }: { label: string; value: string }) {
<Text style={[styles.summaryLabel, { color: colors.mutedForeground }]}> <Text style={[styles.summaryLabel, { color: colors.mutedForeground }]}>
{label} {label}
</Text> </Text>
<Text style={[styles.summaryValue, { color: colors.foreground }]} numberOfLines={1}> <Text
style={[styles.summaryValue, { color: colors.foreground }]}
numberOfLines={1}
>
{value} {value}
</Text> </Text>
</View> </View>
); );
} }
function ExpenseRow({ expense, onDelete }: { expense: Expense; onDelete: () => void }) { function ExpenseRow({
expense,
onDelete,
}: {
expense: Expense;
onDelete: () => void;
}) {
const { colors } = useAppTheme(); const { colors } = useAppTheme();
const styles = useThemedStyles(createStyles); const styles = useThemedStyles(createStyles);
@@ -236,7 +267,8 @@ function ExpenseRow({ expense, onDelete }: { expense: Expense; onDelete: () => v
icon: "open-outline", icon: "open-outline",
color: "#fff", color: "#fff",
backgroundColor: colors.primary, 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", key: "delete",
@@ -249,45 +281,77 @@ function ExpenseRow({ expense, onDelete }: { expense: Expense; onDelete: () => v
]} ]}
> >
<View style={[styles.categoryIcon, { backgroundColor: colors.muted }]}> <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>
<View style={styles.meta}> <View style={styles.meta}>
<View style={styles.titleRow}> <View style={styles.titleRow}>
<Text style={[styles.title, { color: colors.foreground }]} numberOfLines={1}> <Text
style={[styles.title, { color: colors.foreground }]}
numberOfLines={1}
>
{expense.description} {expense.description}
</Text> </Text>
{expense.receiptCount ? ( {expense.receiptCount ? (
<View <View
style={[ style={[
styles.receiptPill, 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 }]}> <Text style={[styles.receiptPillText, { color: colors.primary }]}>
{expense.receiptCount} {expense.receiptCount}
</Text> </Text>
</View> </View>
) : null} ) : null}
</View> </View>
<Text style={[styles.sub, { color: colors.mutedForeground }]} numberOfLines={1}> <Text
style={[styles.sub, { color: colors.mutedForeground }]}
numberOfLines={1}
>
{formatDate(expense.date)} {formatDate(expense.date)}
{expense.category ? ` · ${expense.category}` : ""} {expense.category ? ` · ${expense.category}` : ""}
{expense.client?.name ? ` · ${expense.client.name}` : ""} {expense.client?.name ? ` · ${expense.client.name}` : ""}
</Text> </Text>
<View style={styles.tagRow}> <View style={styles.tagRow}>
{expense.billable ? ( {expense.billable ? (
<Text style={[styles.tag, { color: colors.primary, borderColor: colors.border }]}> <Text
style={[
styles.tag,
{ color: colors.primary, borderColor: colors.border },
]}
>
Billable Billable
</Text> </Text>
) : null} ) : null}
{expense.reimbursable ? ( {expense.reimbursable ? (
<Text style={[styles.tag, { color: colors.foreground, borderColor: colors.border }]}> <Text
style={[
styles.tag,
{ color: colors.foreground, borderColor: colors.border },
]}
>
Reimbursable Reimbursable
</Text> </Text>
) : null} ) : null}
{expense.taxDeductible ? ( {expense.taxDeductible ? (
<Text style={[styles.tag, { color: colors.success, borderColor: colors.border }]}> <Text
style={[
styles.tag,
{ color: colors.success, borderColor: colors.border },
]}
>
Tax Tax
</Text> </Text>
) : null} ) : null}
@@ -297,7 +361,11 @@ function ExpenseRow({ expense, onDelete }: { expense: Expense; onDelete: () => v
<Text style={[styles.amount, { color: colors.foreground }]}> <Text style={[styles.amount, { color: colors.foreground }]}>
{formatCurrency(expense.amount, expense.currency)} {formatCurrency(expense.amount, expense.currency)}
</Text> </Text>
<Ionicons name="chevron-forward" size={16} color={colors.mutedForeground} /> <Ionicons
name="chevron-forward"
size={16}
color={colors.mutedForeground}
/>
</View> </View>
</SwipeableRow> </SwipeableRow>
); );
@@ -306,8 +374,7 @@ function ExpenseRow({ expense, onDelete }: { expense: Expense; onDelete: () => v
function groupExpensesByMonth(expenses: Expense[]) { function groupExpensesByMonth(expenses: Expense[]) {
const groups = new Map<string, Expense[]>(); const groups = new Map<string, Expense[]>();
for (const expense of expenses) { for (const expense of expenses) {
const date = new Date(expense.date); const key = formatCalendarDate(expense.date, {
const key = date.toLocaleDateString(undefined, {
month: "long", month: "long",
year: "numeric", year: "numeric",
}); });
@@ -320,11 +387,16 @@ function groupExpensesByMonth(expenses: Expense[]) {
function expenseIcon(category: string | null): keyof typeof Ionicons.glyphMap { function expenseIcon(category: string | null): keyof typeof Ionicons.glyphMap {
const normalized = category?.toLowerCase() ?? ""; const normalized = category?.toLowerCase() ?? "";
if (normalized.includes("travel") || normalized.includes("mileage")) return "airplane-outline"; if (normalized.includes("travel") || normalized.includes("mileage"))
if (normalized.includes("meal") || normalized.includes("food")) return "restaurant-outline"; return "airplane-outline";
if (normalized.includes("software") || normalized.includes("subscription")) return "laptop-outline"; if (normalized.includes("meal") || normalized.includes("food"))
if (normalized.includes("office") || normalized.includes("supply")) return "briefcase-outline"; return "restaurant-outline";
if (normalized.includes("phone") || normalized.includes("internet")) return "wifi-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"; return "receipt-outline";
} }
+3
View File
@@ -303,6 +303,9 @@ export default function SettingsScreen() {
Role: {profile.role} Role: {profile.role}
</Text> </Text>
) : null} ) : null}
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
Time zone: {profile?.timeZone ?? "America/New_York"}
</Text>
</Card> </Card>
<Card title="Accounts"> <Card title="Accounts">
+49 -15
View File
@@ -17,36 +17,53 @@ import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
import { api } from "@/lib/trpc"; import { api } from "@/lib/trpc";
import type { AppRouter } from "beenvoice/server/api/root"; import type { AppRouter } from "beenvoice/server/api/root";
import type { inferRouterOutputs } from "@trpc/server"; import type { inferRouterOutputs } from "@trpc/server";
import {
DEFAULT_TIME_ZONE,
getZonedDateTimeParts,
} from "@beenvoice/domain/time-zone";
type TimeEntry = inferRouterOutputs<AppRouter>["timeEntries"]["getAll"][number]; type TimeEntry = inferRouterOutputs<AppRouter>["timeEntries"]["getAll"][number];
function groupByDate(entries: TimeEntry[]) { function groupByDate(entries: TimeEntry[], timeZone: string) {
const groups = new Map<string, typeof entries>(); const groups = new Map<string, typeof entries>();
for (const entry of entries) { for (const entry of entries) {
const d = new Date(entry.startedAt); const d = new Date(entry.startedAt);
const key = d.toLocaleDateString(undefined, { 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(dateKey, list);
}
return Array.from(groups.entries()).map(
([, groupedEntries]) =>
[
new Date(groupedEntries[0]!.startedAt).toLocaleDateString(undefined, {
weekday: "long", weekday: "long",
month: "long", month: "long",
day: "numeric", day: "numeric",
year: "numeric", year: "numeric",
}); timeZone,
const list = groups.get(key) ?? []; }),
list.push(entry); groupedEntries,
groups.set(key, list); ] as const,
} );
return Array.from(groups.entries());
} }
export default function TimeEntriesScreen() { export default function TimeEntriesScreen() {
const { colors } = useAppTheme(); const { colors } = useAppTheme();
const [editEntryId, setEditEntryId] = useState<string | null>(null); const [editEntryId, setEditEntryId] = useState<string | null>(null);
const entriesQuery = api.timeEntries.getAll.useQuery(); const entriesQuery = api.timeEntries.getAll.useQuery();
const profileQuery = api.settings.getProfile.useQuery();
const completed = useMemo( const completed = useMemo(
() => (entriesQuery.data ?? []).filter((entry) => entry.endedAt), () => (entriesQuery.data ?? []).filter((entry) => entry.endedAt),
[entriesQuery.data], [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) { if (entriesQuery.isLoading) {
return <LoadingScreen message="Loading time entries…" />; return <LoadingScreen message="Loading time entries…" />;
@@ -57,7 +74,10 @@ export default function TimeEntriesScreen() {
<AppBackground> <AppBackground>
<TabPage showMoreBack> <TabPage showMoreBack>
<View style={styles.errorBox}> <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 }}> <Text style={{ color: colors.mutedForeground }}>
{formatTrpcErrorMessage(entriesQuery.error)} {formatTrpcErrorMessage(entriesQuery.error)}
</Text> </Text>
@@ -72,7 +92,10 @@ export default function TimeEntriesScreen() {
<TabPage showMoreBack> <TabPage showMoreBack>
<TabScrollView <TabScrollView
header={ header={
<PageHeader title="Time entries" subtitle={`${completed.length} completed entries`} /> <PageHeader
title="Time entries"
subtitle={`${completed.length} completed entries`}
/>
} }
refreshControl={ refreshControl={
<PullToRefresh <PullToRefresh
@@ -82,7 +105,9 @@ export default function TimeEntriesScreen() {
} }
> >
{grouped.length === 0 ? ( {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. No completed entries yet. Start the timer from the Timer tab.
</Text> </Text>
) : ( ) : (
@@ -104,17 +129,26 @@ export default function TimeEntriesScreen() {
> >
<View style={styles.row}> <View style={styles.row}>
<View style={{ flex: 1, gap: 2 }}> <View style={{ flex: 1, gap: 2 }}>
<Text style={[styles.title, { color: colors.foreground }]}> <Text
style={[styles.title, { color: colors.foreground }]}
>
{formatRunningTimerLabel(entry.description)} {formatRunningTimerLabel(entry.description)}
</Text> </Text>
<Text style={{ color: colors.mutedForeground, fontFamily: fonts.body }}> <Text
style={{
color: colors.mutedForeground,
fontFamily: fonts.body,
}}
>
{entry.client?.name ?? "No client"} {entry.client?.name ?? "No client"}
{entry.invoice {entry.invoice
? ` · ${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}` ? ` · ${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
: " · not billed"} : " · not billed"}
</Text> </Text>
</View> </View>
<Text style={[styles.title, { color: colors.foreground }]}> <Text
style={[styles.title, { color: colors.foreground }]}
>
{entry.hours ?? "—"}h {entry.hours ?? "—"}h
</Text> </Text>
</View> </View>
+41 -12
View File
@@ -1,12 +1,18 @@
import * as Notifications from "expo-notifications"; import * as Notifications from "expo-notifications";
import Constants from "expo-constants";
import { router } from "expo-router"; import { router } from "expo-router";
import { useEffect, useRef } from "react"; import { useEffect, useRef, useState } from "react";
import { AppState, type AppStateStatus } from "react-native"; 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"; 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; if (data?.type !== "invoice-send-reminder") return;
const invoiceId = data.invoiceId; const invoiceId = data.invoiceId;
if (typeof invoiceId !== "string" || !invoiceId) return; if (typeof invoiceId !== "string" || !invoiceId) return;
@@ -21,14 +27,37 @@ export function InvoiceReminderSync() {
{ staleTime: 60_000 }, { staleTime: 60_000 },
); );
const wasBackgrounded = useRef(false); 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(() => { useEffect(() => {
if (!invoicesQuery.data) return; if (!invoicesQuery.data) return;
void syncInvoiceSendReminders(invoicesQuery.data); void syncInvoiceSendReminders(remotePushReady ? [] : invoicesQuery.data);
}, [invoicesQuery.data]); }, [invoicesQuery.data, remotePushReady]);
useEffect(() => { useEffect(() => {
const subscription = AppState.addEventListener("change", (nextState: AppStateStatus) => { const subscription = AppState.addEventListener(
"change",
(nextState: AppStateStatus) => {
if (nextState === "background" || nextState === "inactive") { if (nextState === "background" || nextState === "inactive") {
wasBackgrounded.current = true; wasBackgrounded.current = true;
return; return;
@@ -37,19 +66,19 @@ export function InvoiceReminderSync() {
if (nextState !== "active" || !wasBackgrounded.current) return; if (nextState !== "active" || !wasBackgrounded.current) return;
wasBackgrounded.current = false; wasBackgrounded.current = false;
void utils.invoices.getAll.invalidate({ status: "draft" }); void utils.invoices.getAll.invalidate({ status: "draft" });
}); },
);
return () => subscription.remove(); return () => subscription.remove();
}, [utils.invoices.getAll]); }, [utils.invoices.getAll]);
useEffect(() => { useEffect(() => {
const responseSubscription = Notifications.addNotificationResponseReceivedListener( const responseSubscription =
(response) => { Notifications.addNotificationResponseReceivedListener((response) => {
openInvoiceFromNotification( openInvoiceFromNotification(
response.notification.request.content.data as Record<string, unknown>, response.notification.request.content.data as Record<string, unknown>,
); );
}, });
);
void Notifications.getLastNotificationResponseAsync().then((response) => { void Notifications.getLastNotificationResponseAsync().then((response) => {
if (!response) return; if (!response) return;
@@ -6,6 +6,7 @@ import { SelectField, type SelectOption } from "@/components/ui/SelectField";
import { fonts, spacing } from "@/constants/theme"; import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext"; import { useAppTheme } from "@/contexts/ThemeContext";
import { EXPENSE_CATEGORIES } from "@/lib/expense-categories"; import { EXPENSE_CATEGORIES } from "@/lib/expense-categories";
import { calendarDateFromLocalDate } from "@beenvoice/domain/time-zone";
const NONE = "__none__"; const NONE = "__none__";
@@ -37,7 +38,7 @@ export function defaultExpenseFormState(
return { return {
description: "", description: "",
amountText: "", amountText: "",
date: new Date(), date: calendarDateFromLocalDate(new Date()),
category: "", category: "",
businessId: defaultBusinessId, businessId: defaultBusinessId,
clientId: "", clientId: "",
@@ -6,6 +6,7 @@ import { SelectField } from "@/components/ui/SelectField";
import { fonts, spacing } from "@/constants/theme"; import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext"; import { useAppTheme } from "@/contexts/ThemeContext";
import { defaultDueDate } from "@/lib/invoice-number"; import { defaultDueDate } from "@/lib/invoice-number";
import { formatCalendarDate } from "@beenvoice/domain/time-zone";
type SelectOption = { label: string; value: string }; type SelectOption = { label: string; value: string };
@@ -120,7 +121,9 @@ export function InvoiceSetupForm({
{invoiceNumberReadOnly ? ( {invoiceNumberReadOnly ? (
<View style={styles.readOnlyField}> <View style={styles.readOnlyField}>
<Text style={[styles.readOnlyLabel, { color: colors.mutedForeground }]}> <Text
style={[styles.readOnlyLabel, { color: colors.mutedForeground }]}
>
Invoice number Invoice number
</Text> </Text>
<Text style={[styles.readOnlyValue, { color: colors.foreground }]}> <Text style={[styles.readOnlyValue, { color: colors.foreground }]}>
@@ -141,11 +144,13 @@ export function InvoiceSetupForm({
{issueDateReadOnly ? ( {issueDateReadOnly ? (
<View style={styles.readOnlyField}> <View style={styles.readOnlyField}>
<Text style={[styles.readOnlyLabel, { color: colors.mutedForeground }]}> <Text
style={[styles.readOnlyLabel, { color: colors.mutedForeground }]}
>
Issue date Issue date
</Text> </Text>
<Text style={[styles.readOnlyValue, { color: colors.foreground }]}> <Text style={[styles.readOnlyValue, { color: colors.foreground }]}>
{issueDate.toLocaleDateString()} {formatCalendarDate(issueDate)}
</Text> </Text>
</View> </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 ? ( {taxRateReadOnly ? (
<View style={styles.readOnlyField}> <View style={styles.readOnlyField}>
<Text style={[styles.readOnlyLabel, { color: colors.mutedForeground }]}> <Text
style={[styles.readOnlyLabel, { color: colors.mutedForeground }]}
>
Tax rate Tax rate
</Text> </Text>
<Text style={[styles.readOnlyValue, { color: colors.foreground }]}> <Text style={[styles.readOnlyValue, { color: colors.foreground }]}>
@@ -186,7 +198,7 @@ export function InvoiceSetupForm({
<> <>
<DateTimeField <DateTimeField
label="Remind me to send" label="Remind me to send"
mode="date" mode="datetime"
value={sendReminderAt ?? dueDate} value={sendReminderAt ?? dueDate}
minimumDate={new Date()} minimumDate={new Date()}
maximumDate={new Date(2100, 0, 1)} maximumDate={new Date(2100, 0, 1)}
+51 -12
View File
@@ -3,11 +3,22 @@ import DateTimePicker, {
type DateTimePickerEvent, type DateTimePickerEvent,
} from "@react-native-community/datetimepicker"; } from "@react-native-community/datetimepicker";
import { useState } from "react"; 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 { fonts, radii, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext"; import { useAppTheme } from "@/contexts/ThemeContext";
import { formatDate, formatDateTime } from "@/lib/format"; import { formatDate, formatDateTime } from "@/lib/format";
import {
calendarDateFromLocalDate,
calendarDateToLocalDate,
} from "@beenvoice/domain/time-zone";
type DateTimeFieldProps = { type DateTimeFieldProps = {
label: string; label: string;
@@ -31,17 +42,18 @@ export function DateTimeField({
const [draft, setDraft] = useState(value); const [draft, setDraft] = useState(value);
function openPicker() { function openPicker() {
setDraft(value); setDraft(mode === "date" ? calendarDateToLocalDate(value) : value);
setOpen(true); setOpen(true);
} }
function applyDate(next: Date) { function applyDate(next: Date) {
const normalized = mode === "date" ? calendarDateFromLocalDate(next) : next;
const clamped = const clamped =
next.getTime() > maximumDate.getTime() normalized.getTime() > maximumDate.getTime()
? maximumDate ? maximumDate
: minimumDate && next.getTime() < minimumDate.getTime() : minimumDate && normalized.getTime() < minimumDate.getTime()
? minimumDate ? minimumDate
: next; : normalized;
onChange(clamped); onChange(clamped);
} }
@@ -60,7 +72,9 @@ export function DateTimeField({
return ( return (
<View style={styles.wrapper}> <View style={styles.wrapper}>
<Text style={[styles.label, { color: colors.mutedForeground }]}>{label}</Text> <Text style={[styles.label, { color: colors.mutedForeground }]}>
{label}
</Text>
<Pressable <Pressable
accessible accessible
accessibilityLabel={`${label}, ${ accessibilityLabel={`${label}, ${
@@ -81,28 +95,53 @@ export function DateTimeField({
<Text style={[styles.value, { color: colors.foreground }]}> <Text style={[styles.value, { color: colors.foreground }]}>
{mode === "date" ? formatDate(value) : formatDateTime(value)} {mode === "date" ? formatDate(value) : formatDateTime(value)}
</Text> </Text>
<Ionicons name="calendar-outline" size={18} color={colors.mutedForeground} /> <Ionicons
name="calendar-outline"
size={18}
color={colors.mutedForeground}
/>
</Pressable> </Pressable>
{Platform.OS === "ios" ? ( {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.backdrop} onPress={() => setOpen(false)}>
<Pressable <Pressable
style={[styles.sheet, { backgroundColor: colors.card }]} style={[styles.sheet, { backgroundColor: colors.card }]}
onPress={(event) => event.stopPropagation()} onPress={(event) => event.stopPropagation()}
> >
<View style={[styles.sheetHeader, { borderBottomColor: colors.border }]}> <View
style={[
styles.sheetHeader,
{ borderBottomColor: colors.border },
]}
>
<Pressable onPress={() => setOpen(false)}> <Pressable onPress={() => setOpen(false)}>
<Text style={[styles.sheetAction, { color: colors.mutedForeground }]}>Cancel</Text> <Text
style={[
styles.sheetAction,
{ color: colors.mutedForeground },
]}
>
Cancel
</Text>
</Pressable> </Pressable>
<Text style={[styles.sheetTitle, { color: colors.foreground }]}>{label}</Text> <Text style={[styles.sheetTitle, { color: colors.foreground }]}>
{label}
</Text>
<Pressable <Pressable
onPress={() => { onPress={() => {
applyDate(draft); applyDate(draft);
setOpen(false); setOpen(false);
}} }}
> >
<Text style={[styles.sheetAction, { color: colors.primary }]}>Done</Text> <Text style={[styles.sheetAction, { color: colors.primary }]}>
Done
</Text>
</Pressable> </Pressable>
</View> </View>
<DateTimePicker <DateTimePicker
+4 -2
View File
@@ -1,3 +1,5 @@
import { formatCalendarDate } from "@beenvoice/domain/time-zone";
export function formatCurrency(amount: number, currency = "USD") { export function formatCurrency(amount: number, currency = "USD") {
return new Intl.NumberFormat("en-US", { return new Intl.NumberFormat("en-US", {
style: "currency", style: "currency",
@@ -7,7 +9,7 @@ export function formatCurrency(amount: number, currency = "USD") {
} }
export function formatDate(date: Date | string) { export function formatDate(date: Date | string) {
return new Date(date).toLocaleDateString("en-US", { return formatCalendarDate(date, {
month: "short", month: "short",
day: "numeric", day: "numeric",
year: "numeric", year: "numeric",
@@ -15,7 +17,7 @@ export function formatDate(date: Date | string) {
} }
export function formatShortDate(date: Date | string) { export function formatShortDate(date: Date | string) {
return new Date(date).toLocaleDateString("en-US", { return formatCalendarDate(date, {
month: "short", month: "short",
day: "numeric", day: "numeric",
}); });
+3 -3
View File
@@ -1,3 +1,5 @@
import { addCalendarDays } from "@beenvoice/domain/time-zone";
/** Matches web invoice-form default numbering. */ /** Matches web invoice-form default numbering. */
export function generateInvoiceNumber(now = new Date()): string { export function generateInvoiceNumber(now = new Date()): string {
const date = [ const date = [
@@ -10,7 +12,5 @@ export function generateInvoiceNumber(now = new Date()): string {
} }
export function defaultDueDate(issueDate: Date): Date { export function defaultDueDate(issueDate: Date): Date {
const due = new Date(issueDate); return addCalendarDays(issueDate, 30);
due.setDate(due.getDate() + 30);
return due;
} }
+6 -1
View File
@@ -6,11 +6,16 @@ export type InvoiceStatus = EffectiveInvoiceStatus;
export function getInvoiceStatus(invoice: { export function getInvoiceStatus(invoice: {
status: string; status: string;
dueDate: Date | string; dueDate: Date | string;
createdBy?: { timeZone: string } | null;
}): InvoiceStatus { }): InvoiceStatus {
if (invoice.status === "paid" || invoice.status === "draft") { if (invoice.status === "paid" || invoice.status === "draft") {
return invoice.status; 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> = { 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, "when": 1786946793000,
"tag": "0030_scheduled_invoice_sends", "tag": "0030_scheduled_invoice_sends",
"breakpoints": true "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>; type McpCaller = ReturnType<typeof createCaller>;
const dateString = z.string().min(1); 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 emptyableString = z.string().optional().or(z.literal(""));
const invoiceStatus = z.enum(["draft", "sent", "paid"]); const invoiceStatus = z.enum(["draft", "sent", "paid"]);
const paymentMethod = z.enum([ const paymentMethod = z.enum([
@@ -26,7 +27,7 @@ const paymentMethod = z.enum([
]); ]);
const invoiceItemSchema = z.object({ const invoiceItemSchema = z.object({
date: dateString, date: calendarDateString,
description: z.string().min(1), description: z.string().min(1),
hours: z.number().min(0), hours: z.number().min(0),
rate: z.number().min(0), rate: z.number().min(0),
@@ -68,8 +69,8 @@ const invoiceCreateSchema = z.object({
invoicePrefix: z.string().optional(), invoicePrefix: z.string().optional(),
businessId: emptyableString, businessId: emptyableString,
clientId: z.string().min(1), clientId: z.string().min(1),
issueDate: dateString, issueDate: calendarDateString,
dueDate: dateString, dueDate: calendarDateString,
status: invoiceStatus.default("draft"), status: invoiceStatus.default("draft"),
notes: emptyableString, notes: emptyableString,
emailMessage: emptyableString, emailMessage: emptyableString,
@@ -83,7 +84,7 @@ const invoiceUpdateSchema = invoiceCreateSchema.partial().extend({
}); });
const expenseCreateSchema = z.object({ const expenseCreateSchema = z.object({
date: dateString, date: calendarDateString,
description: z.string().min(1), description: z.string().min(1),
amount: z.number().min(0), amount: z.number().min(0),
currency: z.string().length(3).default("USD"), currency: z.string().length(3).default("USD"),
@@ -118,6 +119,9 @@ const recurringCreateSchema = z.object({
currency: z.string().length(3).default("USD"), currency: z.string().length(3).default("USD"),
notes: z.string().optional().or(z.literal("")), notes: z.string().optional().or(z.literal("")),
emailMessage: 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), items: z.array(recurringItemSchema).min(1),
}); });
@@ -151,7 +155,7 @@ const jsonSchemas = {
properties: { properties: {
invoiceId: { type: "string" }, invoiceId: { type: "string" },
amount: { type: "number", exclusiveMinimum: 0 }, amount: { type: "number", exclusiveMinimum: 0 },
date: { type: "string", format: "date-time" }, date: { type: "string", format: "date" },
method: { method: {
type: "string", type: "string",
enum: [ enum: [
@@ -193,11 +197,17 @@ const jsonSchemas = {
invoicePrefix: { type: "string" }, invoicePrefix: { type: "string" },
businessId: { type: "string" }, businessId: { type: "string" },
clientId: { type: "string", minLength: 1 }, clientId: { type: "string", minLength: 1 },
issueDate: { type: "string", format: "date-time" }, issueDate: { type: "string", format: "date" },
dueDate: { type: "string", format: "date-time" }, dueDate: { type: "string", format: "date" },
status: { type: "string", enum: ["draft", "sent", "paid"] }, status: { type: "string", enum: ["draft", "sent", "paid"] },
notes: { type: "string" }, notes: { type: "string" },
emailMessage: { 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 }, taxRate: { type: "number", minimum: 0, maximum: 100 },
currency: { type: "string", minLength: 3, maxLength: 3 }, currency: { type: "string", minLength: 3, maxLength: 3 },
items: { items: {
@@ -206,7 +216,7 @@ const jsonSchemas = {
items: { items: {
type: "object", type: "object",
properties: { properties: {
date: { type: "string", format: "date-time" }, date: { type: "string", format: "date" },
description: { type: "string", minLength: 1 }, description: { type: "string", minLength: 1 },
hours: { type: "number", minimum: 0 }, hours: { type: "number", minimum: 0 },
rate: { type: "number", minimum: 0 }, rate: { type: "number", minimum: 0 },
@@ -243,7 +253,7 @@ const jsonSchemas = {
expenseCreate: { expenseCreate: {
type: "object", type: "object",
properties: { properties: {
date: { type: "string", format: "date-time" }, date: { type: "string", format: "date" },
description: { type: "string", minLength: 1 }, description: { type: "string", minLength: 1 },
amount: { type: "number", minimum: 0 }, amount: { type: "number", minimum: 0 },
currency: { type: "string", minLength: 3, maxLength: 3 }, 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, additionalProperties: false,
}, },
invoiceSend: { invoiceSend: {
@@ -413,10 +423,30 @@ function parseDate(value: string, fieldName: string) {
return date; 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>[]) { function parseInvoiceItems(items: z.infer<typeof invoiceItemSchema>[]) {
return items.map((item) => ({ return items.map((item) => ({
...item, ...item,
date: parseDate(item.date, "item.date"), date: parseCalendarDate(item.date, "item.date"),
})); }));
} }
@@ -464,8 +494,8 @@ const tools = {
handler: async (input, caller) => handler: async (input, caller) =>
caller.invoices.create({ caller.invoices.create({
...input, ...input,
issueDate: parseDate(input.issueDate, "issueDate"), issueDate: parseCalendarDate(input.issueDate, "issueDate"),
dueDate: parseDate(input.dueDate, "dueDate"), dueDate: parseCalendarDate(input.dueDate, "dueDate"),
items: parseInvoiceItems(input.items), items: parseInvoiceItems(input.items),
}), }),
}), }),
@@ -484,10 +514,10 @@ const tools = {
caller.invoices.update({ caller.invoices.update({
...input, ...input,
issueDate: input.issueDate issueDate: input.issueDate
? parseDate(input.issueDate, "issueDate") ? parseCalendarDate(input.issueDate, "issueDate")
: undefined, : undefined,
dueDate: input.dueDate dueDate: input.dueDate
? parseDate(input.dueDate, "dueDate") ? parseCalendarDate(input.dueDate, "dueDate")
: undefined, : undefined,
items: input.items ? parseInvoiceItems(input.items) : undefined, items: input.items ? parseInvoiceItems(input.items) : undefined,
}), }),
@@ -516,14 +546,14 @@ const tools = {
schema: z.object({ schema: z.object({
invoiceId: z.string(), invoiceId: z.string(),
amount: z.number().positive(), amount: z.number().positive(),
date: dateString, date: calendarDateString,
method: paymentMethod.default("other"), method: paymentMethod.default("other"),
notes: z.string().max(500).optional(), notes: z.string().max(500).optional(),
}), }),
handler: async (input, caller) => handler: async (input, caller) =>
caller.payments.create({ caller.payments.create({
...input, ...input,
date: parseDate(input.date, "date"), date: parseCalendarDate(input.date, "date"),
}), }),
}), }),
payments_delete: defineTool({ payments_delete: defineTool({
@@ -829,7 +859,7 @@ const tools = {
handler: async (input, caller) => handler: async (input, caller) =>
caller.expenses.create({ caller.expenses.create({
...input, ...input,
date: parseDate(input.date, "date"), date: parseCalendarDate(input.date, "date"),
}), }),
}), }),
expenses_update: defineTool({ expenses_update: defineTool({
@@ -847,7 +877,7 @@ const tools = {
handler: async (input, caller) => handler: async (input, caller) =>
caller.expenses.update({ caller.expenses.update({
...input, ...input,
date: input.date ? parseDate(input.date, "date") : undefined, date: input.date ? parseCalendarDate(input.date, "date") : undefined,
}), }),
}), }),
expenses_delete: defineTool({ expenses_delete: defineTool({
@@ -876,7 +906,7 @@ const tools = {
description: "Update a recurring invoice template. Replaces all items.", description: "Update a recurring invoice template. Replaces all items.",
inputSchema: { inputSchema: {
...jsonSchemas.recurringCreate, ...jsonSchemas.recurringCreate,
required: ["id", "name", "clientId", "schedule", "items"], required: ["id", "name", "clientId", "schedule", "nextRunLocal", "items"],
properties: { properties: {
id: { type: "string" }, id: { type: "string" },
...jsonSchemas.recurringCreate.properties, ...jsonSchemas.recurringCreate.properties,
@@ -23,6 +23,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status"; import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
import type { StoredInvoiceStatus } from "~/types/invoice"; import type { StoredInvoiceStatus } from "~/types/invoice";
import { formatCalendarDate } from "@beenvoice/domain/time-zone";
interface ClientDetailPageProps { interface ClientDetailPageProps {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
@@ -34,17 +35,19 @@ export default async function ClientDetailPage({
const { id } = await params; const { id } = await params;
const client = await api.clients.getById({ id }); const client = await api.clients.getById({ id });
const profile = await api.settings.getProfile();
const timeZone = profile?.timeZone ?? "America/New_York";
if (!client) { if (!client) {
notFound(); notFound();
} }
const formatDate = (date: Date) => { const formatDate = (date: Date) => {
return new Intl.DateTimeFormat("en-US", { return formatCalendarDate(date, {
year: "numeric", year: "numeric",
month: "long", month: "long",
day: "numeric", day: "numeric",
}).format(date); });
}; };
const formatCurrency = (amount: number) => { const formatCurrency = (amount: number) => {
@@ -249,16 +252,19 @@ export default async function ClientDetailPage({
getEffectiveInvoiceStatus( getEffectiveInvoiceStatus(
invoice.status as StoredInvoiceStatus, invoice.status as StoredInvoiceStatus,
invoice.dueDate, invoice.dueDate,
timeZone,
) === "paid" ) === "paid"
? "default" ? "default"
: getEffectiveInvoiceStatus( : getEffectiveInvoiceStatus(
invoice.status as StoredInvoiceStatus, invoice.status as StoredInvoiceStatus,
invoice.dueDate, invoice.dueDate,
timeZone,
) === "sent" ) === "sent"
? "secondary" ? "secondary"
: getEffectiveInvoiceStatus( : getEffectiveInvoiceStatus(
invoice.status as StoredInvoiceStatus, invoice.status as StoredInvoiceStatus,
invoice.dueDate, invoice.dueDate,
timeZone,
) === "overdue" ) === "overdue"
? "destructive" ? "destructive"
: "outline" : "outline"
@@ -268,6 +274,7 @@ export default async function ClientDetailPage({
{getEffectiveInvoiceStatus( {getEffectiveInvoiceStatus(
invoice.status as StoredInvoiceStatus, invoice.status as StoredInvoiceStatus,
invoice.dueDate, invoice.dueDate,
timeZone,
)} )}
</Badge> </Badge>
</div> </div>
+11 -4
View File
@@ -44,6 +44,10 @@ import {
} from "lucide-react"; } from "lucide-react";
import { formatCurrency, SUPPORTED_CURRENCIES } from "~/lib/currency"; import { formatCurrency, SUPPORTED_CURRENCIES } from "~/lib/currency";
import { EXPENSE_CATEGORIES } from "~/lib/expense-categories"; import { EXPENSE_CATEGORIES } from "~/lib/expense-categories";
import {
calendarDateFromLocalDate,
formatCalendarDate,
} from "@beenvoice/domain/time-zone";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -66,7 +70,7 @@ interface ExpenseFormData {
} }
const defaultForm: ExpenseFormData = { const defaultForm: ExpenseFormData = {
date: new Date(), date: calendarDateFromLocalDate(new Date()),
description: "", description: "",
amount: 0, amount: 0,
currency: "USD", currency: "USD",
@@ -473,11 +477,11 @@ export default function ExpensesPage() {
)} )}
</div> </div>
<p className="text-muted-foreground mt-0.5 text-xs"> <p className="text-muted-foreground mt-0.5 text-xs">
{new Intl.DateTimeFormat("en-US", { {formatCalendarDate(expense.date, {
month: "short", month: "short",
day: "numeric", day: "numeric",
year: "numeric", year: "numeric",
}).format(new Date(expense.date))} })}
{expense.business ? ` · ${expense.business.name}` : ""} {expense.business ? ` · ${expense.business.name}` : ""}
{expense.client ? ` · ${expense.client.name}` : ""} {expense.client ? ` · ${expense.client.name}` : ""}
</p> </p>
@@ -690,7 +694,10 @@ export default function ExpensesPage() {
<DatePicker <DatePicker
date={form.date} date={form.date}
onDateChange={(d) => onDateChange={(d) =>
setForm((p) => ({ ...p, date: d ?? new Date() })) setForm((p) => ({
...p,
date: d ?? calendarDateFromLocalDate(new Date()),
}))
} }
className="w-full" className="w-full"
/> />
@@ -2,17 +2,15 @@
import type { ColumnDef } from "@tanstack/react-table"; import type { ColumnDef } from "@tanstack/react-table";
import { DataTable } from "~/components/data/data-table"; import { DataTable } from "~/components/data/data-table";
import { import { formatLineItemDetail, isFixedLineItem } from "~/lib/invoice-line-item";
formatLineItemDetail, import { formatCalendarDate } from "@beenvoice/domain/time-zone";
isFixedLineItem,
} from "~/lib/invoice-line-item";
const formatDate = (date: Date) => { const formatDate = (date: Date) => {
return new Intl.DateTimeFormat("en-US", { return formatCalendarDate(date, {
year: "numeric", year: "numeric",
month: "short", month: "short",
day: "numeric", day: "numeric",
}).format(new Date(date)); });
}; };
const formatCurrency = (amount: number) => { const formatCurrency = (amount: number) => {
@@ -20,7 +20,14 @@ import {
Trash2, Trash2,
User, User,
} from "lucide-react"; } 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 Link from "next/link";
import { import {
notFound, notFound,
@@ -65,7 +72,6 @@ import { Separator } from "~/components/ui/separator";
import { Textarea } from "~/components/ui/textarea"; import { Textarea } from "~/components/ui/textarea";
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label"; import { Label } from "~/components/ui/label";
import { DatePicker } from "~/components/ui/date-picker";
import { import {
getEffectiveInvoiceStatus, getEffectiveInvoiceStatus,
isInvoiceOverdue, isInvoiceOverdue,
@@ -110,6 +116,8 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
const { data: invoice, isLoading } = api.invoices.getById.useQuery({ const { data: invoice, isLoading } = api.invoices.getById.useQuery({
id: invoiceId, id: invoiceId,
}); });
const { data: profile } = api.settings.getProfile.useQuery();
const timeZone = profile?.timeZone ?? DEFAULT_TIME_ZONE;
const { data: payments, isLoading: paymentsLoading } = const { data: payments, isLoading: paymentsLoading } =
api.payments.getByInvoice.useQuery({ invoiceId }); api.payments.getByInvoice.useQuery({ invoiceId });
const utils = api.useUtils(); const utils = api.useUtils();
@@ -201,11 +209,11 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
if (!invoice) notFound(); if (!invoice) notFound();
const formatDate = (date: Date) => const formatDate = (date: Date) =>
new Intl.DateTimeFormat("en-US", { formatCalendarDate(date, {
year: "numeric", year: "numeric",
month: "short", month: "short",
day: "numeric", day: "numeric",
}).format(new Date(date)); });
const formatCurrency = (amount: number, currency = invoice.currency) => const formatCurrency = (amount: number, currency = invoice.currency) =>
new Intl.NumberFormat("en-US", { style: "currency", currency }).format( new Intl.NumberFormat("en-US", { style: "currency", currency }).format(
@@ -221,8 +229,9 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
const effectiveStatus = getEffectiveInvoiceStatus( const effectiveStatus = getEffectiveInvoiceStatus(
storedStatus, storedStatus,
invoice.dueDate, invoice.dueDate,
timeZone,
); );
const isOverdue = isInvoiceOverdue(storedStatus, invoice.dueDate); const isOverdue = isInvoiceOverdue(storedStatus, invoice.dueDate, timeZone);
const canSendReminder = const canSendReminder =
effectiveStatus === "sent" || effectiveStatus === "overdue"; effectiveStatus === "sent" || effectiveStatus === "overdue";
@@ -246,7 +255,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
createPayment.mutate({ createPayment.mutate({
invoiceId, invoiceId,
amount, amount,
date: new Date(), date: calendarDateFromLocalDate(new Date()),
method: paymentMethod as Parameters< method: paymentMethod as Parameters<
typeof createPayment.mutate typeof createPayment.mutate
>[0]["method"], >[0]["method"],
@@ -694,7 +703,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
key={`${invoiceId}-${invoice.sendReminderAt?.toISOString() ?? "none"}`} key={`${invoiceId}-${invoice.sendReminderAt?.toISOString() ?? "none"}`}
invoiceId={invoiceId} invoiceId={invoiceId}
savedReminderAt={invoice.sendReminderAt} savedReminderAt={invoice.sendReminderAt}
formatDate={formatDate} timeZone={timeZone}
isSaving={updateInvoice.isPending} isSaving={updateInvoice.isPending}
onSave={(sendReminderAt) => onSave={(sendReminderAt) =>
updateInvoice.mutate({ updateInvoice.mutate({
@@ -991,28 +1000,30 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
function SendReminderEditor({ function SendReminderEditor({
invoiceId, invoiceId,
savedReminderAt, savedReminderAt,
formatDate, timeZone,
isSaving, isSaving,
onSave, onSave,
onClear, onClear,
}: { }: {
invoiceId: string; invoiceId: string;
savedReminderAt: Date | null | undefined; savedReminderAt: Date | null | undefined;
formatDate: (date: Date) => string; timeZone: string;
isSaving: boolean; isSaving: boolean;
onSave: (sendReminderAt: Date | null) => void; onSave: (sendReminderAt: Date | null) => void;
onClear: () => void; onClear: () => void;
}) { }) {
const [sendReminderAt, setSendReminderAt] = useState<Date | undefined>(() => const [sendReminderAt, setSendReminderAt] = useState(() =>
savedReminderAt ? new Date(savedReminderAt) : undefined, savedReminderAt ? toZonedDateTimeInputValue(savedReminderAt, timeZone) : "",
); );
return ( return (
<div className="space-y-2 rounded-lg border p-3"> <div className="space-y-2 rounded-lg border p-3">
<Label htmlFor={`send-reminder-at-${invoiceId}`}>Remind me to send</Label> <Label htmlFor={`send-reminder-at-${invoiceId}`}>Remind me to send</Label>
<DatePicker <Input
date={sendReminderAt} id={`send-reminder-at-${invoiceId}`}
onDateChange={setSendReminderAt} type="datetime-local"
value={sendReminderAt}
onChange={(event) => setSendReminderAt(event.target.value)}
className="w-full" className="w-full"
/> />
<div className="flex gap-2"> <div className="flex gap-2">
@@ -1020,7 +1031,21 @@ function SendReminderEditor({
variant="outline" variant="outline"
size="sm" size="sm"
className="flex-1" 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} disabled={isSaving}
> >
Save reminder Save reminder
@@ -1030,7 +1055,7 @@ function SendReminderEditor({
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => { onClick={() => {
setSendReminderAt(undefined); setSendReminderAt("");
onClear(); onClear();
}} }}
> >
@@ -1042,7 +1067,7 @@ function SendReminderEditor({
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
{new Date(savedReminderAt) <= new Date() {new Date(savedReminderAt) <= new Date()
? "Reminder is due — time to send this invoice." ? "Reminder is due — time to send this invoice."
: `Scheduled for ${formatDate(savedReminderAt)}`} : `Scheduled for ${formatZonedDateTime(savedReminderAt, timeZone)}`}
</p> </p>
) : null} ) : null}
</div> </div>
@@ -12,9 +12,17 @@ import { Input } from "~/components/ui/input";
import { import {
formatZonedDateTime, formatZonedDateTime,
getDefaultScheduledSendAt, getDefaultScheduledSendAt,
getLocalTimeZone, DEFAULT_TIME_ZONE,
toLocalDateTimeInputValue, toZonedDateTimeInputValue,
zonedDateTimeToInstant,
} from "@beenvoice/domain/time-zone"; } from "@beenvoice/domain/time-zone";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -114,6 +122,9 @@ export default function SendEmailPage() {
const [showScheduleDialog, setShowScheduleDialog] = useState(false); const [showScheduleDialog, setShowScheduleDialog] = useState(false);
const [scheduledAt, setScheduledAt] = useState(""); const [scheduledAt, setScheduledAt] = useState("");
const [minimumScheduledAt, setMinimumScheduledAt] = useState(""); const [minimumScheduledAt, setMinimumScheduledAt] = useState("");
const [scheduleDisambiguation, setScheduleDisambiguation] = useState<
"earlier" | "later"
>("earlier");
const [retryCount, setRetryCount] = useState(0); const [retryCount, setRetryCount] = useState(0);
// Email content state // Email content state
@@ -128,10 +139,11 @@ export default function SendEmailPage() {
api.invoices.getById.useQuery({ api.invoices.getById.useQuery({
id: invoiceId, id: invoiceId,
}); });
const { data: profile } = api.settings.getProfile.useQuery();
// Get utils for cache invalidation // Get utils for cache invalidation
const utils = api.useUtils(); const utils = api.useUtils();
const timeZone = useMemo(() => getLocalTimeZone(), []); const timeZone = profile?.timeZone ?? DEFAULT_TIME_ZONE;
// Email sending mutation // Email sending mutation
const sendEmailMutation = api.email.sendInvoice.useMutation({ const sendEmailMutation = api.email.sendInvoice.useMutation({
@@ -330,7 +342,20 @@ export default function SendEmailPage() {
}; };
const confirmScheduleEmail = async () => { 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 ( if (
Number.isNaN(sendAt.getTime()) || Number.isNaN(sendAt.getTime()) ||
sendAt.getTime() < Date.now() + 60_000 sendAt.getTime() < Date.now() + 60_000
@@ -340,13 +365,6 @@ export default function SendEmailPage() {
}); });
return; 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 { try {
await scheduleEmailMutation.mutateAsync({ await scheduleEmailMutation.mutateAsync({
invoiceId, invoiceId,
@@ -685,10 +703,13 @@ export default function SendEmailPage() {
<Button <Button
onClick={() => { onClick={() => {
setMinimumScheduledAt( setMinimumScheduledAt(
toLocalDateTimeInputValue(new Date(Date.now() + 60_000)), toZonedDateTimeInputValue(
new Date(Date.now() + 60_000),
timeZone,
),
); );
setScheduledAt( setScheduledAt(
toLocalDateTimeInputValue(getDefaultScheduledSendAt()), toZonedDateTimeInputValue(getDefaultScheduledSendAt(), timeZone),
); );
setShowScheduleDialog(true); setShowScheduleDialog(true);
}} }}
@@ -800,6 +821,23 @@ export default function SendEmailPage() {
instant, so daylight saving changes and other devices will not instant, so daylight saving changes and other devices will not
shift this send. shift this send.
</p> </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> </div>
<DialogFooter> <DialogFooter>
<Button <Button
@@ -38,6 +38,7 @@ import { toast } from "sonner";
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status"; import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
import { formatCurrency } from "~/lib/currency"; import { formatCurrency } from "~/lib/currency";
import type { StoredInvoiceStatus } from "~/types/invoice"; import type { StoredInvoiceStatus } from "~/types/invoice";
import { formatCalendarDate } from "@beenvoice/domain/time-zone";
interface Invoice { interface Invoice {
id: string; id: string;
@@ -81,22 +82,27 @@ interface Invoice {
interface InvoicesDataTableProps { interface InvoicesDataTableProps {
invoices: Invoice[]; invoices: Invoice[];
timeZone: string;
} }
const getStatusType = (invoice: Invoice): StatusType => const getStatusType = (invoice: Invoice, timeZone: string): StatusType =>
getEffectiveInvoiceStatus( getEffectiveInvoiceStatus(
invoice.status as StoredInvoiceStatus, invoice.status as StoredInvoiceStatus,
invoice.dueDate, invoice.dueDate,
timeZone,
); );
const formatDate = (date: Date) => const formatDate = (date: Date) =>
new Intl.DateTimeFormat("en-US", { formatCalendarDate(date, {
month: "short", month: "short",
day: "2-digit", day: "2-digit",
year: "numeric", year: "numeric",
}).format(new Date(date)); });
export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) { export function InvoicesDataTable({
invoices,
timeZone,
}: InvoicesDataTableProps) {
const router = useRouter(); const router = useRouter();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [invoiceToDelete, setInvoiceToDelete] = useState<Invoice | null>(null); const [invoiceToDelete, setInvoiceToDelete] = useState<Invoice | null>(null);
@@ -183,7 +189,7 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
</p> </p>
<div className="mt-1 flex items-center gap-2 sm:hidden"> <div className="mt-1 flex items-center gap-2 sm:hidden">
<StatusBadge <StatusBadge
status={getStatusType(invoice)} status={getStatusType(invoice, timeZone)}
className="text-xs" className="text-xs"
/> />
<span className="text-foreground text-xs font-semibold"> <span className="text-foreground text-xs font-semibold">
@@ -218,14 +224,16 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<StatusBadge <StatusBadge
status={getStatusType(row.original)} status={getStatusType(row.original, timeZone)}
className={ className={
getStatusType(row.original) === "sent" ? "status-pending" : "" getStatusType(row.original, timeZone) === "sent"
? "status-pending"
: ""
} }
/> />
), ),
filterFn: (row, _id, value: string[]) => filterFn: (row, _id, value: string[]) =>
value.includes(getStatusType(row.original)), value.includes(getStatusType(row.original, timeZone)),
meta: { meta: {
headerClassName: "hidden sm:table-cell", headerClassName: "hidden sm:table-cell",
cellClassName: "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 // Invoices Table Component
async function InvoicesTable() { async function InvoicesTable() {
const invoices = await api.invoices.getAll(); 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() { export default async function InvoicesPage() {
@@ -39,6 +39,12 @@ import {
} from "~/components/ui/select"; } from "~/components/ui/select";
import { Textarea } from "~/components/ui/textarea"; import { Textarea } from "~/components/ui/textarea";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import {
DEFAULT_TIME_ZONE,
formatZonedDateTime,
getDefaultScheduledSendAt,
toZonedDateTimeInputValue,
} from "@beenvoice/domain/time-zone";
const SCHEDULES = [ const SCHEDULES = [
{ value: "weekly", label: "Weekly" }, { value: "weekly", label: "Weekly" },
@@ -66,10 +72,13 @@ interface RecurringFormState {
currency: string; currency: string;
notes: string; notes: string;
emailMessage: string; emailMessage: string;
timeZone: string;
nextRunLocal: string;
disambiguation: "earlier" | "later" | "reject";
items: RecurringItemInput[]; items: RecurringItemInput[];
} }
const defaultForm = (): RecurringFormState => ({ const defaultForm = (timeZone = DEFAULT_TIME_ZONE): RecurringFormState => ({
name: "", name: "",
clientId: "", clientId: "",
businessId: "", businessId: "",
@@ -79,15 +88,17 @@ const defaultForm = (): RecurringFormState => ({
currency: "USD", currency: "USD",
notes: "", notes: "",
emailMessage: "", emailMessage: "",
timeZone,
nextRunLocal: toZonedDateTimeInputValue(
getDefaultScheduledSendAt(),
timeZone,
),
disambiguation: "reject",
items: [{ description: "", hours: 0, rate: 0 }], items: [{ description: "", hours: 0, rate: 0 }],
}); });
function formatDate(date: Date) { function formatDate(date: Date, timeZone: string) {
return new Intl.DateTimeFormat("en-US", { return formatZonedDateTime(date, timeZone);
year: "numeric",
month: "short",
day: "numeric",
}).format(new Date(date));
} }
function scheduleLabel(s: string) { function scheduleLabel(s: string) {
@@ -106,19 +117,28 @@ function RecurringForm({
businesses: { id: string; name: string }[]; businesses: { id: string; name: string }[];
}) { }) {
const addItem = () => 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) => const removeItem = (idx: number) =>
setForm((f) => ({ ...f, items: f.items.filter((_, i) => i !== idx) })); 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) => ({ setForm((f) => ({
...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 ( 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"> <div className="space-y-1.5">
<Label>Template name</Label> <Label>Template name</Label>
<Input <Input
@@ -173,7 +193,9 @@ function RecurringForm({
<Label>Schedule</Label> <Label>Schedule</Label>
<Select <Select
value={form.schedule} value={form.schedule}
onValueChange={(v) => setForm((f) => ({ ...f, schedule: v as Schedule }))} onValueChange={(v) =>
setForm((f) => ({ ...f, schedule: v as Schedule }))
}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
@@ -193,11 +215,65 @@ function RecurringForm({
maxLength={3} maxLength={3}
placeholder="USD" placeholder="USD"
value={form.currency} 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> </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"> <div className="space-y-1.5">
<Label>Tax rate (%)</Label> <Label>Tax rate (%)</Label>
<NumberInput <NumberInput
@@ -226,7 +302,7 @@ function RecurringForm({
type="button" type="button"
size="sm" size="sm"
variant="ghost" 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)} onClick={() => removeItem(idx)}
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
@@ -281,7 +357,9 @@ export default function RecurringInvoicesPage() {
const [deleteId, setDeleteId] = useState<string | null>(null); const [deleteId, setDeleteId] = useState<string | null>(null);
const [form, setForm] = useState<RecurringFormState>(defaultForm()); 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: clients = [] } = api.clients.getAll.useQuery();
const { data: businesses = [] } = api.businesses.getAll.useQuery(); const { data: businesses = [] } = api.businesses.getAll.useQuery();
const utils = api.useUtils(); const utils = api.useUtils();
@@ -289,27 +367,47 @@ export default function RecurringInvoicesPage() {
const invalidate = () => void utils.recurringInvoices.getAll.invalidate(); const invalidate = () => void utils.recurringInvoices.getAll.invalidate();
const create = api.recurringInvoices.create.useMutation({ 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"), onError: (e) => toast.error(e.message ?? "Failed to create"),
}); });
const update = api.recurringInvoices.update.useMutation({ 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"), onError: (e) => toast.error(e.message ?? "Failed to update"),
}); });
const pause = api.recurringInvoices.pause.useMutation({ const pause = api.recurringInvoices.pause.useMutation({
onSuccess: () => { toast.success("Paused"); invalidate(); }, onSuccess: () => {
toast.success("Paused");
invalidate();
},
onError: (e) => toast.error(e.message), onError: (e) => toast.error(e.message),
}); });
const resume = api.recurringInvoices.resume.useMutation({ const resume = api.recurringInvoices.resume.useMutation({
onSuccess: () => { toast.success("Resumed"); invalidate(); }, onSuccess: () => {
toast.success("Resumed");
invalidate();
},
onError: (e) => toast.error(e.message), onError: (e) => toast.error(e.message),
}); });
const del = api.recurringInvoices.delete.useMutation({ 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), onError: (e) => toast.error(e.message),
}); });
@@ -333,6 +431,9 @@ export default function RecurringInvoicesPage() {
currency: rec.currency, currency: rec.currency,
notes: rec.notes ?? "", notes: rec.notes ?? "",
emailMessage: rec.emailMessage ?? "", emailMessage: rec.emailMessage ?? "",
timeZone: rec.timeZone,
nextRunLocal: toZonedDateTimeInputValue(rec.nextDueAt, rec.timeZone),
disambiguation: "reject",
items: rec.items.map((i) => ({ items: rec.items.map((i) => ({
description: i.description, description: i.description,
hours: i.hours, hours: i.hours,
@@ -365,7 +466,12 @@ export default function RecurringInvoicesPage() {
title="Recurring Invoices" title="Recurring Invoices"
description="Schedule automatic invoice generation" 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" /> <Plus className="mr-2 h-4 w-4" />
New recurring New recurring
</Button> </Button>
@@ -383,7 +489,12 @@ export default function RecurringInvoicesPage() {
title="Create your first recurring invoice" title="Create your first recurring invoice"
description="Automatically generate draft invoices on a schedule you choose." description="Automatically generate draft invoices on a schedule you choose."
action={ action={
<Button onClick={() => { setForm(defaultForm()); setCreateOpen(true); }}> <Button
onClick={() => {
setForm(defaultForm(profile?.timeZone));
setCreateOpen(true);
}}
>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Create recurring invoice Create recurring invoice
</Button> </Button>
@@ -400,7 +511,11 @@ export default function RecurringInvoicesPage() {
<div className="min-w-0 flex-1 space-y-1"> <div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<p className="font-semibold">{rec.name}</p> <p className="font-semibold">{rec.name}</p>
<Badge variant={rec.status === "active" ? "default" : "secondary"}> <Badge
variant={
rec.status === "active" ? "default" : "secondary"
}
>
{rec.status} {rec.status}
</Badge> </Badge>
</div> </div>
@@ -408,14 +523,18 @@ export default function RecurringInvoicesPage() {
{rec.client.name} · {scheduleLabel(rec.schedule)} {rec.client.name} · {scheduleLabel(rec.schedule)}
</p> </p>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
Next: {formatDate(rec.nextDueAt)} Next: {formatDate(rec.nextDueAt, rec.timeZone)}
{rec.lastGeneratedAt && ( {rec.lastGeneratedAt && (
<> · Last generated: {formatDate(rec.lastGeneratedAt)}</> <>
{" "}
· Last generated:{" "}
{formatDate(rec.lastGeneratedAt, rec.timeZone)}
</>
)} )}
</p> </p>
</div> </div>
<div className="flex flex-wrap gap-2 shrink-0"> <div className="flex shrink-0 flex-wrap gap-2">
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
@@ -473,14 +592,21 @@ export default function RecurringInvoicesPage() {
<Dialog <Dialog
open={createOpen || editId !== null} open={createOpen || editId !== null}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) { setCreateOpen(false); setEditId(null); setForm(defaultForm()); } if (!open) {
setCreateOpen(false);
setEditId(null);
setForm(defaultForm());
}
}} }}
> >
<DialogContent className="max-w-lg"> <DialogContent className="max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>{editId ? "Edit recurring invoice" : "New recurring invoice"}</DialogTitle> <DialogTitle>
{editId ? "Edit recurring invoice" : "New recurring invoice"}
</DialogTitle>
<DialogDescription> <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> </DialogDescription>
</DialogHeader> </DialogHeader>
<RecurringForm <RecurringForm
@@ -492,17 +618,30 @@ export default function RecurringInvoicesPage() {
<DialogFooter> <DialogFooter>
<Button <Button
variant="outline" variant="outline"
onClick={() => { setCreateOpen(false); setEditId(null); setForm(defaultForm()); }} onClick={() => {
setCreateOpen(false);
setEditId(null);
setForm(defaultForm());
}}
> >
Cancel Cancel
</Button> </Button>
<Button onClick={handleSubmit} disabled={isSubmitting || !form.name || !form.clientId}> <Button
onClick={handleSubmit}
disabled={isSubmitting || !form.name || !form.clientId}
>
{isSubmitting ? ( {isSubmitting ? (
<><Loader2 className="mr-2 h-4 w-4 animate-spin" /> Saving</> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Saving
</>
) : editId ? ( ) : 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> </Button>
</DialogFooter> </DialogFooter>
@@ -510,12 +649,18 @@ export default function RecurringInvoicesPage() {
</Dialog> </Dialog>
{/* Delete Confirmation */} {/* Delete Confirmation */}
<Dialog open={deleteId !== null} onOpenChange={(open) => { if (!open) setDeleteId(null); }}> <Dialog
open={deleteId !== null}
onOpenChange={(open) => {
if (!open) setDeleteId(null);
}}
>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Delete recurring invoice</DialogTitle> <DialogTitle>Delete recurring invoice</DialogTitle>
<DialogDescription> <DialogDescription>
This will stop automatic generation. Already-generated invoices are not affected. This will stop automatic generation. Already-generated invoices
are not affected.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
+35 -17
View File
@@ -3,7 +3,10 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { DashboardPageHeader } from "~/components/layout/page-header"; 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 { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { StatusBadge } from "~/components/data/status-badge"; import { StatusBadge } from "~/components/data/status-badge";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
@@ -24,6 +27,10 @@ import {
import { formatCurrency } from "~/lib/currency"; import { formatCurrency } from "~/lib/currency";
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status"; import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
import type { StoredInvoiceStatus } from "~/types/invoice"; import type { StoredInvoiceStatus } from "~/types/invoice";
import {
formatCalendarDate,
getZonedDateTimeParts,
} from "@beenvoice/domain/time-zone";
import { import {
AreaChart, AreaChart,
Area, Area,
@@ -63,7 +70,9 @@ export default function ReportsPage() {
const isLoading = invoicesLoading || expensesLoading; 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 [taxYear, setTaxYear] = useState(String(currentYear));
const filteredInvoices = useMemo(() => { const filteredInvoices = useMemo(() => {
@@ -76,10 +85,11 @@ export default function ReportsPage() {
if (!filteredInvoices.length) return null; if (!filteredInvoices.length) return null;
const now = new Date(); const now = new Date();
const current = getZonedDateTimeParts(now, reportTimeZone);
const monthMap: Record<string, number> = {}; const monthMap: Record<string, number> = {};
for (let i = 11; i >= 0; i--) { for (let i = 11; 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));
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; const key = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`;
monthMap[key] = 0; monthMap[key] = 0;
} }
@@ -91,10 +101,11 @@ export default function ReportsPage() {
const status = getEffectiveInvoiceStatus( const status = getEffectiveInvoiceStatus(
inv.status as StoredInvoiceStatus, inv.status as StoredInvoiceStatus,
inv.dueDate, inv.dueDate,
reportTimeZone,
); );
if (status === "paid") { if (status === "paid") {
totalRevenue += inv.totalAmount; 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; if (monthMap[key] !== undefined) monthMap[key] += inv.totalAmount;
} else if (status === "sent" || status === "overdue") { } else if (status === "sent" || status === "overdue") {
totalPending += inv.totalAmount; totalPending += inv.totalAmount;
@@ -103,7 +114,7 @@ export default function ReportsPage() {
} }
const revenueByMonth = Object.entries(monthMap).map(([month, revenue]) => ({ const revenueByMonth = Object.entries(monthMap).map(([month, revenue]) => ({
month: new Date(month + "-01").toLocaleDateString("en-US", { month: formatCalendarDate(month + "-01", {
month: "short", month: "short",
year: "2-digit", year: "2-digit",
}), }),
@@ -115,6 +126,7 @@ export default function ReportsPage() {
const status = getEffectiveInvoiceStatus( const status = getEffectiveInvoiceStatus(
inv.status as StoredInvoiceStatus, inv.status as StoredInvoiceStatus,
inv.dueDate, inv.dueDate,
reportTimeZone,
); );
if (status === "paid" && inv.client) { if (status === "paid" && inv.client) {
const id = inv.client.id; const id = inv.client.id;
@@ -139,6 +151,7 @@ export default function ReportsPage() {
const s = getEffectiveInvoiceStatus( const s = getEffectiveInvoiceStatus(
inv.status as StoredInvoiceStatus, inv.status as StoredInvoiceStatus,
inv.dueDate, inv.dueDate,
reportTimeZone,
); );
statusCount[s] = (statusCount[s] ?? 0) + 1; statusCount[s] = (statusCount[s] ?? 0) + 1;
} }
@@ -151,7 +164,7 @@ export default function ReportsPage() {
totalHours, totalHours,
statusCount, statusCount,
}; };
}, [filteredInvoices]); }, [filteredInvoices, reportTimeZone]);
// Tax summary for selected year // Tax summary for selected year
const taxData = useMemo(() => { const taxData = useMemo(() => {
@@ -161,13 +174,14 @@ export default function ReportsPage() {
const status = getEffectiveInvoiceStatus( const status = getEffectiveInvoiceStatus(
inv.status as StoredInvoiceStatus, inv.status as StoredInvoiceStatus,
inv.dueDate, inv.dueDate,
reportTimeZone,
); );
return ( return (
status === "paid" && new Date(inv.issueDate).getFullYear() === year status === "paid" && new Date(inv.issueDate).getUTCFullYear() === year
); );
}); });
const yearExpenses = expenses.filter( const yearExpenses = expenses.filter(
(exp) => new Date(exp.date).getFullYear() === year, (exp) => new Date(exp.date).getUTCFullYear() === year,
); );
const getSubtotal = (inv: (typeof yearInvoices)[number]) => { const getSubtotal = (inv: (typeof yearInvoices)[number]) => {
@@ -211,10 +225,12 @@ export default function ReportsPage() {
return { return {
label: `Q${q}`, label: `Q${q}`,
income: yearInvoices 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), .reduce((s, inv) => s + getSubtotal(inv), 0),
expenses: yearExpenses 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), .reduce((s, exp) => s + exp.amount, 0),
}; };
}); });
@@ -233,13 +249,13 @@ export default function ReportsPage() {
yearInvoices, yearInvoices,
yearExpenses, yearExpenses,
}; };
}, [filteredInvoices, expenses, taxYear]); }, [filteredInvoices, expenses, taxYear, reportTimeZone]);
const availableYears = useMemo(() => { const availableYears = useMemo(() => {
const years = new Set<number>([currentYear, currentYear - 1]); const years = new Set<number>([currentYear, currentYear - 1]);
for (const inv of filteredInvoices) for (const inv of filteredInvoices)
years.add(new Date(inv.issueDate).getFullYear()); years.add(new Date(inv.issueDate).getUTCFullYear());
for (const exp of expenses) years.add(new Date(exp.date).getFullYear()); for (const exp of expenses) years.add(new Date(exp.date).getUTCFullYear());
return Array.from(years).sort((a, b) => b - a); return Array.from(years).sort((a, b) => b - a);
}, [filteredInvoices, expenses, currentYear]); }, [filteredInvoices, expenses, currentYear]);
@@ -251,6 +267,7 @@ export default function ReportsPage() {
getEffectiveInvoiceStatus( getEffectiveInvoiceStatus(
i.status as StoredInvoiceStatus, i.status as StoredInvoiceStatus,
i.dueDate, i.dueDate,
reportTimeZone,
) === "paid", ) === "paid",
).length || 1) ).length || 1)
: 0; : 0;
@@ -272,7 +289,7 @@ export default function ReportsPage() {
const invoiceSubtotal = subtotal > 0 ? subtotal : fallbackSubtotal; const invoiceSubtotal = subtotal > 0 ? subtotal : fallbackSubtotal;
const taxAmt = inv.totalAmount - invoiceSubtotal; const taxAmt = inv.totalAmount - invoiceSubtotal;
return [ return [
new Date(inv.issueDate).toLocaleDateString("en-US"), formatCalendarDate(inv.issueDate),
inv.invoiceNumber, inv.invoiceNumber,
`"${inv.client?.name ?? ""}"`, `"${inv.client?.name ?? ""}"`,
invoiceSubtotal.toFixed(2), invoiceSubtotal.toFixed(2),
@@ -287,7 +304,7 @@ export default function ReportsPage() {
"Date,Description,Category,Amount,Currency,Billable,Reimbursable,Tax Deductible", "Date,Description,Category,Amount,Currency,Billable,Reimbursable,Tax Deductible",
...taxData.yearExpenses.map((exp) => ...taxData.yearExpenses.map((exp) =>
[ [
new Date(exp.date).toLocaleDateString("en-US"), formatCalendarDate(exp.date),
`"${exp.description}"`, `"${exp.description}"`,
`"${exp.category ?? ""}"`, `"${exp.category ?? ""}"`,
exp.amount.toFixed(2), exp.amount.toFixed(2),
@@ -634,7 +651,7 @@ export default function ReportsPage() {
<div> <div>
<p className="font-medium">{inv.client?.name ?? "—"}</p> <p className="font-medium">{inv.client?.name ?? "—"}</p>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
{new Date(inv.issueDate).toLocaleDateString("en-US", { {formatCalendarDate(inv.issueDate, {
month: "short", month: "short",
day: "numeric", day: "numeric",
year: "numeric", year: "numeric",
@@ -647,6 +664,7 @@ export default function ReportsPage() {
getEffectiveInvoiceStatus( getEffectiveInvoiceStatus(
inv.status as StoredInvoiceStatus, inv.status as StoredInvoiceStatus,
inv.dueDate, inv.dueDate,
reportTimeZone,
) as never ) as never
} }
/> />
@@ -92,6 +92,7 @@ import type { PdfFontFamily, PdfTemplate } from "~/lib/appearance";
import { pdfFontFamilyOptions } from "~/lib/pdf-fonts"; import { pdfFontFamilyOptions } from "~/lib/pdf-fonts";
import { ApiAccessSettings } from "./api-access-settings"; import { ApiAccessSettings } from "./api-access-settings";
import { ImportPageHeaderActions } from "./invoice-import/import-page-header-actions"; import { ImportPageHeaderActions } from "./invoice-import/import-page-header-actions";
import { DEFAULT_TIME_ZONE } from "@beenvoice/domain/time-zone";
const InvoiceImportPage = dynamic( const InvoiceImportPage = dynamic(
() => () =>
@@ -147,6 +148,7 @@ export function SettingsContent({
const { data: session } = useAuthSession(); const { data: session } = useAuthSession();
const [name, setName] = useState(""); const [name, setName] = useState("");
const [timeZone, setTimeZone] = useState(DEFAULT_TIME_ZONE);
const [nameInitialized, setNameInitialized] = useState(false); const [nameInitialized, setNameInitialized] = useState(false);
const [deleteConfirmText, setDeleteConfirmText] = useState(""); const [deleteConfirmText, setDeleteConfirmText] = useState("");
const [importData, setImportData] = useState(""); const [importData, setImportData] = useState("");
@@ -309,7 +311,7 @@ export function SettingsContent({
toast.error("Please enter your name"); toast.error("Please enter your name");
return; return;
} }
updateProfileMutation.mutate({ name: name.trim() }); updateProfileMutation.mutate({ name: name.trim(), timeZone });
}; };
const handleChangePassword = (e: React.FormEvent) => { const handleChangePassword = (e: React.FormEvent) => {
@@ -423,8 +425,15 @@ export function SettingsContent({
if (nameInitialized || !profileFetched) return; if (nameInitialized || !profileFetched) return;
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync async profile data into an editable form field. // 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 ?? ""); setName(profile?.name ?? session?.user?.name ?? "");
setTimeZone(profile?.timeZone ?? DEFAULT_TIME_ZONE);
setNameInitialized(true); 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) // (Removed direct DOM mutation; provider handles applying preferences globally)
@@ -497,6 +506,19 @@ export function SettingsContent({
Email address cannot be changed Email address cannot be changed
</p> </p>
</div> </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 <Button
type="submit" type="submit"
disabled={updateProfileMutation.isPending} disabled={updateProfileMutation.isPending}
+93 -33
View File
@@ -9,29 +9,52 @@ import { api } from "~/trpc/react";
import { generateInvoicePDF } from "~/lib/pdf-export"; import { generateInvoicePDF } from "~/lib/pdf-export";
import { formatLineItemDetail } from "~/lib/invoice-line-item"; import { formatLineItemDetail } from "~/lib/invoice-line-item";
import { toast } from "sonner"; import { toast } from "sonner";
import {
formatCalendarDate,
getEffectiveInvoiceStatus,
} from "@beenvoice/domain";
function formatDate(date: Date) { function formatDate(date: Date) {
return new Intl.DateTimeFormat("en-US", { return formatCalendarDate(date, {
year: "numeric", year: "numeric",
month: "long", month: "long",
day: "numeric", day: "numeric",
}).format(new Date(date)); });
} }
function formatCurrency(amount: number, currency = "USD") { 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 }) { function StatusPill({
const overdue = status === "sent" && new Date(dueDate) < new Date(); status,
const label = overdue ? "Overdue" : status.charAt(0).toUpperCase() + status.slice(1); 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 const cls = overdue
? "bg-red-50 text-red-700 border-red-200" ? "bg-red-50 text-red-700 border-red-200"
: status === "paid" : status === "paid"
? "bg-green-50 text-green-700 border-green-200" ? "bg-green-50 text-green-700 border-green-200"
: "bg-yellow-50 text-yellow-700 border-yellow-200"; : "bg-yellow-50 text-yellow-700 border-yellow-200";
return ( 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} {label}
</span> </span>
); );
@@ -40,7 +63,11 @@ function StatusPill({ status, dueDate }: { status: string; dueDate: Date }) {
function PublicInvoiceView({ token }: { token: string }) { function PublicInvoiceView({ token }: { token: string }) {
const [downloading, setDownloading] = useState(false); 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 () => { const handleDownload = async () => {
if (!invoice || downloading) return; if (!invoice || downloading) return;
@@ -79,7 +106,9 @@ function PublicInvoiceView({ token }: { token: string }) {
return ( return (
<div className="flex min-h-screen flex-col items-center justify-center gap-3 text-center"> <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-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> </div>
); );
} }
@@ -96,7 +125,7 @@ function PublicInvoiceView({ token }: { token: string }) {
const hideName = hasLogo && Boolean(invoice.business?.hideNameWithLogo); const hideName = hasLogo && Boolean(invoice.business?.hideNameWithLogo);
return ( 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"> <div className="mx-auto max-w-2xl">
{/* Card */} {/* Card */}
<div className="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm"> <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"> <div className="min-w-0">
{!hideName && ( {!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 && ( {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>
</div> </div>
{/* Body */} {/* Body */}
<div className="px-8 py-6 space-y-6"> <div className="space-y-6 px-8 py-6">
{/* Invoice meta */} {/* Invoice meta */}
<div className="flex flex-wrap items-start justify-between gap-4"> <div className="flex flex-wrap items-start justify-between gap-4">
<div> <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"> <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> </p>
</div> </div>
<StatusPill status={invoice.status} dueDate={invoice.dueDate} /> <StatusPill
status={invoice.status}
dueDate={invoice.dueDate}
timeZone={invoice.createdBy.timeZone}
/>
</div> </div>
{/* Bill to */} {/* Bill to */}
<div> <div>
<p className="text-xs font-semibold uppercase tracking-wider text-gray-400 mb-1">Bill to</p> <p className="mb-1 text-xs font-semibold tracking-wider text-gray-400 uppercase">
<p className="font-semibold text-gray-900">{invoice.client.name}</p> Bill to
</p>
<p className="font-semibold text-gray-900">
{invoice.client.name}
</p>
{invoice.client.email && ( {invoice.client.email && (
<p className="text-sm text-gray-500">{invoice.client.email}</p> <p className="text-sm text-gray-500">{invoice.client.email}</p>
)} )}
@@ -149,18 +193,21 @@ function PublicInvoiceView({ token }: { token: string }) {
{/* Line items */} {/* Line items */}
<div className="space-y-3"> <div className="space-y-3">
{invoice.items.map((item) => ( {invoice.items.map((item) => (
<div key={item.id} className="flex justify-between gap-4 text-sm"> <div
<div className="flex-1 min-w-0"> key={item.id}
<p className="font-medium text-gray-900 break-words">{item.description}</p> 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"> <p className="text-gray-500">
{formatLineItemDetail( {formatLineItemDetail(item.hours, item.rate, (amount) =>
item.hours, formatCurrency(amount, invoice.currency ?? "USD"),
item.rate,
(amount) => formatCurrency(amount, invoice.currency ?? "USD"),
)} )}
</p> </p>
</div> </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")} {formatCurrency(item.amount, invoice.currency ?? "USD")}
</p> </p>
</div> </div>
@@ -173,15 +220,19 @@ function PublicInvoiceView({ token }: { token: string }) {
<div className="space-y-2 text-sm"> <div className="space-y-2 text-sm">
<div className="flex justify-between text-gray-500"> <div className="flex justify-between text-gray-500">
<span>Subtotal</span> <span>Subtotal</span>
<span>{formatCurrency(subtotal, invoice.currency ?? "USD")}</span> <span>
{formatCurrency(subtotal, invoice.currency ?? "USD")}
</span>
</div> </div>
{invoice.taxRate > 0 && ( {invoice.taxRate > 0 && (
<div className="flex justify-between text-gray-500"> <div className="flex justify-between text-gray-500">
<span>Tax ({invoice.taxRate}%)</span> <span>Tax ({invoice.taxRate}%)</span>
<span>{formatCurrency(taxAmount, invoice.currency ?? "USD")}</span> <span>
{formatCurrency(taxAmount, invoice.currency ?? "USD")}
</span>
</div> </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>Total</span>
<span>{formatCurrency(total, invoice.currency ?? "USD")}</span> <span>{formatCurrency(total, invoice.currency ?? "USD")}</span>
</div> </div>
@@ -192,8 +243,12 @@ function PublicInvoiceView({ token }: { token: string }) {
<> <>
<Separator /> <Separator />
<div> <div>
<p className="text-xs font-semibold uppercase tracking-wider text-gray-400 mb-1">Notes</p> <p className="mb-1 text-xs font-semibold tracking-wider text-gray-400 uppercase">
<p className="text-sm text-gray-700 whitespace-pre-wrap">{invoice.notes}</p> Notes
</p>
<p className="text-sm whitespace-pre-wrap text-gray-700">
{invoice.notes}
</p>
</div> </div>
</> </>
)} )}
@@ -206,9 +261,14 @@ function PublicInvoiceView({ token }: { token: string }) {
className="w-full" className="w-full"
> >
{downloading ? ( {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> </Button>
</div> </div>
@@ -7,6 +7,7 @@ import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Skeleton } from "~/components/ui/skeleton"; import { Skeleton } from "~/components/ui/skeleton";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { formatCalendarDate } from "@beenvoice/domain/time-zone";
export function CurrentOpenInvoiceCard() { export function CurrentOpenInvoiceCard() {
const { data: currentInvoice, isLoading } = const { data: currentInvoice, isLoading } =
@@ -20,10 +21,10 @@ export function CurrentOpenInvoiceCard() {
}; };
const formatDate = (date: Date) => { const formatDate = (date: Date) => {
return new Intl.DateTimeFormat("en-US", { return formatCalendarDate(date, {
month: "short", month: "short",
day: "numeric", day: "numeric",
}).format(new Date(date)); });
}; };
if (isLoading) { if (isLoading) {
@@ -32,6 +32,7 @@ import {
Plus, Plus,
User, User,
} from "lucide-react"; } from "lucide-react";
import { formatCalendarDate } from "@beenvoice/domain/time-zone";
export function InvoiceList() { export function InvoiceList() {
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
@@ -72,7 +73,7 @@ export function InvoiceList() {
}; };
const formatDate = (date: Date) => { const formatDate = (date: Date) => {
return new Date(date).toLocaleDateString(); return formatCalendarDate(date);
}; };
const formatCurrency = (amount: number) => { const formatCurrency = (amount: number) => {
@@ -24,6 +24,10 @@ import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label"; import { Label } from "~/components/ui/label";
import { NumberInput } from "~/components/ui/number-input"; import { NumberInput } from "~/components/ui/number-input";
import {
calendarDateFromLocalDate,
calendarDateToLocalDate,
} from "@beenvoice/domain/time-zone";
import { import {
Plus, Plus,
Trash2, Trash2,
@@ -77,7 +81,7 @@ export function InvoiceCalendarView({
return items return items
.map((item, index) => ({ item, index })) .map((item, index) => ({ item, index }))
.filter((wrapper) => { .filter((wrapper) => {
const itemDate = new Date(wrapper.item.date); const itemDate = calendarDateToLocalDate(wrapper.item.date);
return isSameDay(itemDate, date); return isSameDay(itemDate, date);
}); });
}, [items, date]); }, [items, date]);
@@ -88,7 +92,7 @@ export function InvoiceCalendarView({
return items return items
.map((item, index) => ({ item, index })) .map((item, index) => ({ item, index }))
.filter((wrapper) => { .filter((wrapper) => {
const itemDate = new Date(wrapper.item.date); const itemDate = calendarDateToLocalDate(wrapper.item.date);
return isSameDay(itemDate, targetDate); return isSameDay(itemDate, targetDate);
}); });
}, },
@@ -103,7 +107,7 @@ export function InvoiceCalendarView({
const handleAddNewItem = () => { const handleAddNewItem = () => {
if (date) { if (date) {
onAddItem(date); onAddItem(calendarDateFromLocalDate(date));
} }
}; };
@@ -407,7 +411,11 @@ export function InvoiceCalendarView({
</p> </p>
</div> </div>
{!readOnly ? ( {!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" /> <Plus className="mr-2 h-4 w-4" />
Log Time Log Time
</Button> </Button>
@@ -494,7 +502,11 @@ export function InvoiceCalendarView({
Total Total
</span> </span>
<span className="text-primary text-lg font-bold"> <span className="text-primary text-lg font-bold">
${calculateLineItemAmount(item.hours, item.rate).toFixed(2)} $
{calculateLineItemAmount(
item.hours,
item.rate,
).toFixed(2)}
</span> </span>
</div> </div>
</div> </div>
+38 -14
View File
@@ -42,7 +42,8 @@ import {
Mail, Mail,
} from "lucide-react"; } from "lucide-react";
import { SUPPORTED_CURRENCIES } from "~/lib/currency"; 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 { Textarea } from "~/components/ui/textarea";
import { import {
DropdownMenu, DropdownMenu,
@@ -108,13 +109,14 @@ function plainTextToHtml(value: string) {
} }
function createDefaultInvoiceFormData(): InvoiceFormData { function createDefaultInvoiceFormData(): InvoiceFormData {
const today = calendarDateFromLocalDate(new Date());
return { return {
invoiceNumber: generateInvoiceNumber(), invoiceNumber: generateInvoiceNumber(),
invoicePrefix: "#", invoicePrefix: "#",
businessId: "", businessId: "",
clientId: "", clientId: "",
issueDate: new Date(), issueDate: today,
dueDate: new Date(), dueDate: defaultDueDate(today),
status: "draft", status: "draft",
notes: "", notes: "",
emailMessage: "", emailMessage: "",
@@ -124,7 +126,7 @@ function createDefaultInvoiceFormData(): InvoiceFormData {
items: [ items: [
{ {
id: crypto.randomUUID(), id: crypto.randomUUID(),
date: new Date(), date: today,
description: "", description: "",
hours: 1, hours: 1,
rate: 0, rate: 0,
@@ -209,7 +211,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
: [ : [
{ {
id: crypto.randomUUID(), id: crypto.randomUUID(),
date: new Date(), date: calendarDateFromLocalDate(new Date()),
description: "", description: "",
hours: 1, hours: 1,
rate: 0, rate: 0,
@@ -320,7 +322,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
...prev.items, ...prev.items,
{ {
id: crypto.randomUUID(), id: crypto.randomUUID(),
date: new Date(), date: calendarDateFromLocalDate(new Date()),
description: parsed.description, description: parsed.description,
hours: parsed.hours ?? 1, hours: parsed.hours ?? 1,
rate: parsed.rate ?? prev.defaultHourlyRate ?? 0, rate: parsed.rate ?? prev.defaultHourlyRate ?? 0,
@@ -350,7 +352,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
items: prev.items.map((item, i) => { items: prev.items.map((item, i) => {
if (i !== idx) return item; if (i !== idx) return item;
if (field === "billingType" && (value === "hourly" || value === "fixed")) { if (
field === "billingType" &&
(value === "hourly" || value === "fixed")
) {
const next = applyBillingTypeChange(value, item); const next = applyBillingTypeChange(value, item);
return { return {
...item, ...item,
@@ -401,7 +406,9 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
return; return;
} }
const itemsToSave = formData.items.filter((item) => item.description?.trim()); const itemsToSave = formData.items.filter((item) =>
item.description?.trim(),
);
let invalidItemIndex = -1; let invalidItemIndex = -1;
for (let i = 0; i < formData.items.length; i++) { for (let i = 0; i < formData.items.length; i++) {
@@ -515,7 +522,11 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
</Button> </Button>
</DashboardPageHeader> </DashboardPageHeader>
<PageTabs value={activeTab} className="w-full" onValueChange={setActiveTab}> <PageTabs
value={activeTab}
className="w-full"
onValueChange={setActiveTab}
>
<PageTabsList> <PageTabsList>
<PageTabsTrigger value="details">Details</PageTabsTrigger> <PageTabsTrigger value="details">Details</PageTabsTrigger>
<PageTabsTrigger value="items">Items</PageTabsTrigger> <PageTabsTrigger value="items">Items</PageTabsTrigger>
@@ -606,7 +617,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
<DatePicker <DatePicker
date={formData.issueDate} date={formData.issueDate}
onDateChange={(d) => onDateChange={(d) =>
updateField("issueDate", d ?? new Date()) updateField(
"issueDate",
d ?? calendarDateFromLocalDate(new Date()),
)
} }
className="w-full" className="w-full"
/> />
@@ -616,7 +630,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
<DatePicker <DatePicker
date={formData.dueDate} date={formData.dueDate}
onDateChange={(d) => onDateChange={(d) =>
updateField("dueDate", d ?? new Date()) updateField(
"dueDate",
d ?? calendarDateFromLocalDate(new Date()),
)
} }
className="w-full" className="w-full"
/> />
@@ -721,7 +738,9 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
<CardContent> <CardContent>
<Textarea <Textarea
value={formData.emailMessage} value={formData.emailMessage}
onChange={(e) => updateField("emailMessage", e.target.value)} onChange={(e) =>
updateField("emailMessage", e.target.value)
}
placeholder="Add a note that appears only in the email body..." placeholder="Add a note that appears only in the email body..."
className="min-h-[140px]" className="min-h-[140px]"
/> />
@@ -818,7 +837,9 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
onRemoveItem={removeItem} onRemoveItem={removeItem}
onUpdateItem={updateItem} onUpdateItem={updateItem}
onAddItemWithValues={addItemWithValues} onAddItemWithValues={addItemWithValues}
invoiceId={invoiceId && invoiceId !== "new" ? invoiceId : undefined} invoiceId={
invoiceId && invoiceId !== "new" ? invoiceId : undefined
}
clientId={formData.clientId || undefined} clientId={formData.clientId || undefined}
defaultRate={formData.items[0]?.rate} defaultRate={formData.items[0]?.rate}
readOnly={formData.status !== "draft"} readOnly={formData.status !== "draft"}
@@ -925,7 +946,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
description: item.description, description: item.description,
hours: item.hours, hours: item.hours,
rate: item.rate, rate: item.rate,
amount: calculateLineItemAmount(item.hours, item.rate), amount: calculateLineItemAmount(
item.hours,
item.rate,
),
})), })),
}} }}
/> />
@@ -51,6 +51,10 @@ import {
} from "~/lib/invoice-import"; } from "~/lib/invoice-import";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import {
addCalendarDays,
formatCalendarDate,
} from "@beenvoice/domain/time-zone";
interface StagedInvoice extends ImportInvoice { interface StagedInvoice extends ImportInvoice {
id: string; id: string;
@@ -173,9 +177,10 @@ export function InvoiceImportPage() {
if (inv.id !== id) return inv; if (inv.id !== id) return inv;
const updated = { ...inv, ...updates }; const updated = { ...inv, ...updates };
if (updates.issueDate !== undefined && !updates.dueDate) { if (updates.issueDate !== undefined && !updates.dueDate) {
const due = new Date(updated.issueDate ?? new Date()); updated.dueDate = addCalendarDays(
due.setDate(due.getDate() + 30); updated.issueDate ?? new Date(),
updated.dueDate = due; 30,
);
} }
return updated; return updated;
}), }),
@@ -628,12 +633,14 @@ export function InvoiceImportPage() {
{previewInvoice.items.map((item, idx) => ( {previewInvoice.items.map((item, idx) => (
<tr key={idx} className="border-border border-b"> <tr key={idx} className="border-border border-b">
<td className="p-2 text-sm whitespace-nowrap"> <td className="p-2 text-sm whitespace-nowrap">
{item.date?.toLocaleDateString() ?? "—"} {item.date ? formatCalendarDate(item.date) : "—"}
</td> </td>
<td className="max-w-xs truncate p-2 text-sm"> <td className="max-w-xs truncate p-2 text-sm">
{item.description} {item.description}
</td> </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"> <td className="p-2 text-right text-sm">
{item.rate.toLocaleString("en-US", { {item.rate.toLocaleString("en-US", {
style: "currency", style: "currency",
@@ -14,6 +14,7 @@ import type { TimeEntryListItem } from "~/lib/time-entry-display";
export function TimeEntriesHistory() { export function TimeEntriesHistory() {
const { data: entries, isLoading } = api.timeEntries.getAll.useQuery(); const { data: entries, isLoading } = api.timeEntries.getAll.useQuery();
const { data: profile } = api.settings.getProfile.useQuery();
const [editEntryId, setEditEntryId] = useState<string | null>(null); const [editEntryId, setEditEntryId] = useState<string | null>(null);
const completedEntries = useMemo( const completedEntries = useMemo(
@@ -22,8 +23,8 @@ export function TimeEntriesHistory() {
); );
const grouped = useMemo( const grouped = useMemo(
() => groupEntriesByDate(completedEntries), () => groupEntriesByDate(completedEntries, profile?.timeZone),
[completedEntries], [completedEntries, profile?.timeZone],
); );
if (isLoading) { if (isLoading) {
+19 -8
View File
@@ -13,6 +13,11 @@ import {
PopoverTrigger, PopoverTrigger,
} from "~/components/ui/popover"; } from "~/components/ui/popover";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import {
calendarDateFromLocalDate,
calendarDateToLocalDate,
formatCalendarDate,
} from "@beenvoice/domain/time-zone";
const DATE_FORMAT_OPTIONS: Intl.DateTimeFormatOptions = { const DATE_FORMAT_OPTIONS: Intl.DateTimeFormatOptions = {
day: "2-digit", day: "2-digit",
@@ -25,7 +30,7 @@ function formatDate(date: Date | undefined) {
return ""; 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). // Longest month name in en-US long format (September 30, 2026).
@@ -54,7 +59,9 @@ export function DatePicker({
}: DatePickerProps) { }: DatePickerProps) {
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
const [value, setValue] = React.useState(formatDate(date)); 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 = { const sizeClasses = {
sm: "h-9 text-xs", sm: "h-9 text-xs",
@@ -67,7 +74,7 @@ export function DatePicker({
React.useEffect(() => { React.useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Keep text input and calendar month synchronized with the controlled date prop. // 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)); setValue(formatDate(date));
setMonth(date); setMonth(date ? calendarDateToLocalDate(date) : undefined);
}, [date]); }, [date]);
return ( return (
@@ -81,7 +88,7 @@ export function DatePicker({
<span <span
aria-hidden aria-hidden
className={cn( className={cn(
"invisible block whitespace-nowrap px-3 pr-10", "invisible block px-3 pr-10 whitespace-nowrap",
sizeClasses[size], sizeClasses[size],
inputClassName, inputClassName,
)} )}
@@ -102,7 +109,8 @@ export function DatePicker({
setValue(e.target.value); setValue(e.target.value);
const parsedDate = parseDate(e.target.value); const parsedDate = parseDate(e.target.value);
if (parsedDate) { if (parsedDate) {
onDateChange(parsedDate); const calendarDate = calendarDateFromLocalDate(parsedDate);
onDateChange(calendarDate);
setMonth(parsedDate); setMonth(parsedDate);
} }
}} }}
@@ -130,13 +138,16 @@ export function DatePicker({
> >
<Calendar <Calendar
mode="single" mode="single"
selected={date} selected={date ? calendarDateToLocalDate(date) : undefined}
captionLayout="dropdown" captionLayout="dropdown"
month={month} month={month}
onMonthChange={setMonth} onMonthChange={setMonth}
onSelect={(selectedDate) => { onSelect={(selectedDate) => {
onDateChange(selectedDate); const calendarDate = selectedDate
setValue(formatDate(selectedDate)); ? calendarDateFromLocalDate(selectedDate)
: undefined;
onDateChange(calendarDate);
setValue(formatDate(calendarDate));
setOpen(false); 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). */ /** Default invoice number format (matches web/mobile create forms). */
export function generateInvoiceNumber(now = new Date()): string { export function generateInvoiceNumber(now = new Date()): string {
const date = [ const date = [
@@ -10,7 +12,5 @@ export function generateInvoiceNumber(now = new Date()): string {
} }
export function defaultDueDate(issueDate: Date): Date { export function defaultDueDate(issueDate: Date): Date {
const due = new Date(issueDate); return addCalendarDays(issueDate, 30);
due.setDate(due.getDate() + 30);
return due;
} }
@@ -1,11 +1,19 @@
import { getAppUrl } from "~/lib/app-url"; 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 // 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 // with SVG (Outlook and several webmail clients strip or refuse it), so
// non-raster logos are requested through the same on-the-fly PNG // non-raster logos are requested through the same on-the-fly PNG
// rasterization the PDF export uses. // rasterization the PDF export uses.
function resolveEmailLogoUrl( 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, baseUrl: string,
): string | null { ): string | null {
if (!business?.id || !business.logoStorageKey) return null; if (!business?.id || !business.logoStorageKey) return null;
@@ -57,6 +65,7 @@ interface InvoiceEmailTemplateProps {
userName?: string; userName?: string;
userEmail?: string; userEmail?: string;
baseUrl?: string; baseUrl?: string;
timeZone?: string;
} }
export function generateInvoiceEmailTemplate({ export function generateInvoiceEmailTemplate({
@@ -66,13 +75,14 @@ export function generateInvoiceEmailTemplate({
userName, userName,
userEmail, userEmail,
baseUrl = getAppUrl(), baseUrl = getAppUrl(),
timeZone = "America/New_York",
}: InvoiceEmailTemplateProps): { html: string; text: string } { }: InvoiceEmailTemplateProps): { html: string; text: string } {
const formatDate = (date: Date) => { const formatDate = (date: Date) => {
return new Intl.DateTimeFormat("en-US", { return formatCalendarDate(date, {
year: "numeric", year: "numeric",
month: "long", month: "long",
day: "numeric", day: "numeric",
}).format(new Date(date)); });
}; };
const formatCurrency = (amount: number) => { const formatCurrency = (amount: number) => {
@@ -83,7 +93,13 @@ export function generateInvoiceEmailTemplate({
}; };
const getTimeOfDayGreeting = () => { 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 < 12) return "Good morning";
if (hour < 17) return "Good afternoon"; if (hour < 17) return "Good afternoon";
return "Good evening"; return "Good evening";
@@ -1,3 +1,8 @@
import {
formatCalendarDate,
getEffectiveInvoiceStatus,
} from "@beenvoice/domain";
interface ReminderEmailTemplateProps { interface ReminderEmailTemplateProps {
invoice: { invoice: {
invoiceNumber: string; invoiceNumber: string;
@@ -15,6 +20,7 @@ interface ReminderEmailTemplateProps {
customMessage?: string; customMessage?: string;
userName?: string; userName?: string;
userEmail?: string; userEmail?: string;
timeZone?: string;
} }
export function generateReminderEmailTemplate({ export function generateReminderEmailTemplate({
@@ -22,11 +28,18 @@ export function generateReminderEmailTemplate({
customMessage, customMessage,
userName, userName,
userEmail, userEmail,
}: ReminderEmailTemplateProps): { html: string; text: string; subject: string } { timeZone = "America/New_York",
}: ReminderEmailTemplateProps): {
html: string;
text: string;
subject: string;
} {
const formatDate = (date: Date) => const formatDate = (date: Date) =>
new Intl.DateTimeFormat("en-US", { year: "numeric", month: "long", day: "numeric" }).format( formatCalendarDate(date, {
new Date(date), year: "numeric",
); month: "long",
day: "numeric",
});
const formatCurrency = (amount: number) => const formatCurrency = (amount: number) =>
new Intl.NumberFormat("en-US", { new Intl.NumberFormat("en-US", {
@@ -34,14 +47,14 @@ export function generateReminderEmailTemplate({
currency: invoice.currency ?? "USD", currency: invoice.currency ?? "USD",
}).format(amount); }).format(amount);
const senderName = const senderName = invoice.business?.name
invoice.business?.name
? invoice.business.nickname ? invoice.business.nickname
? `${invoice.business.name} (${invoice.business.nickname})` ? `${invoice.business.name} (${invoice.business.nickname})`
: invoice.business.name : invoice.business.name
: userName ?? "Your service provider"; : (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)}`; 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 type ImportFormat = "csv" | "json";
export interface ImportItem { export interface ImportItem {
@@ -86,8 +91,9 @@ export function parseFlexibleDate(dateStr: string): Date | undefined {
// ISO date (YYYY-MM-DD) // ISO date (YYYY-MM-DD)
const isoMatch = /^(\d{4})-(\d{2})-(\d{2})/.exec(trimmed); const isoMatch = /^(\d{4})-(\d{2})-(\d{2})/.exec(trimmed);
if (isoMatch) { if (isoMatch) {
const d = new Date(trimmed); const key = `${isoMatch[1]}-${isoMatch[2]}-${isoMatch[3]}`;
if (!isNaN(d.getTime())) return d; 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 // 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); let year = parseInt(slashParts[2] ?? "2000", 10);
if (year < 100) year += 2000; if (year < 100) year += 2000;
const d = new Date(year, month, day); 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); const d = new Date(trimmed);
if (!isNaN(d.getTime())) return d; if (!isNaN(d.getTime())) return calendarDateFromLocalDate(d);
return undefined; return undefined;
} }
@@ -128,13 +134,11 @@ function deriveIssueDate(items: ImportItem[], fallback?: Date): Date {
if (itemDates.length > 0) { if (itemDates.length > 0) {
return new Date(Math.max(...itemDates.map((d) => d.getTime()))); return new Date(Math.max(...itemDates.map((d) => d.getTime())));
} }
return fallback ?? new Date(); return fallback ?? calendarDateFromLocalDate(new Date());
} }
function defaultDueDate(issueDate: Date): Date { function defaultDueDate(issueDate: Date): Date {
const due = new Date(issueDate); return addCalendarDays(issueDate, 30);
due.setDate(due.getDate() + 30);
return due;
} }
export function parseInvoiceCSV( export function parseInvoiceCSV(
@@ -262,7 +266,9 @@ function normalizeJsonInvoice(raw: JsonInvoice, index: number): ImportInvoice {
const rate = item.rate ?? 0; const rate = item.rate ?? 0;
if (!description || description === "Imported item") { 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) { if (quantity <= 0) {
errors.push( errors.push(
@@ -356,7 +362,9 @@ export function parseInvoiceJSON(jsonText: string): ImportInvoice[] {
{ {
name: "JSON Import", name: "JSON Import",
items: [], 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( export function getEffectiveInvoiceStatus(
storedStatus: StoredInvoiceStatus, storedStatus: StoredInvoiceStatus,
dueDate: Date | string, dueDate: Date | string,
timeZone?: string,
): EffectiveInvoiceStatus { ): EffectiveInvoiceStatus {
return getSharedEffectiveInvoiceStatus(storedStatus, dueDate); return getSharedEffectiveInvoiceStatus(storedStatus, dueDate, timeZone);
} }
export function isInvoiceOverdue( export function isInvoiceOverdue(
storedStatus: StoredInvoiceStatus, storedStatus: StoredInvoiceStatus,
dueDate: Date | string, dueDate: Date | string,
timeZone?: string,
): boolean { ): boolean {
return isSharedInvoiceOverdue(storedStatus, dueDate); return isSharedInvoiceOverdue(storedStatus, dueDate, timeZone);
} }
export function getDaysPastDue( export function getDaysPastDue(
storedStatus: StoredInvoiceStatus, storedStatus: StoredInvoiceStatus,
dueDate: Date | string, dueDate: Date | string,
timeZone?: string,
): number { ): number {
return getSharedDaysPastDue(storedStatus, dueDate); return getSharedDaysPastDue(storedStatus, dueDate, timeZone);
} }
export const statusConfig = { export const statusConfig = {
+5 -11
View File
@@ -9,9 +9,8 @@ import {
type Styles, type Styles,
} from "@react-pdf/renderer"; } from "@react-pdf/renderer";
import { saveAs } from "file-saver"; import { saveAs } from "file-saver";
import { import { formatCalendarDate } from "@beenvoice/domain/time-zone";
isFixedLineItem, import { isFixedLineItem } from "~/lib/invoice-line-item";
} from "~/lib/invoice-line-item";
import React from "react"; import React from "react";
import { import {
type PdfFontFamily, type PdfFontFamily,
@@ -136,10 +135,7 @@ function resolvePDFSettings(settings?: PDFGenerationSettings) {
return { ...defaultPDFSettings, ...settings }; return { ...defaultPDFSettings, ...settings };
} }
function mapLegacyPdfFont( function mapLegacyPdfFont(fontFamily: string, fonts: ResolvedPdfFonts): string {
fontFamily: string,
fonts: ResolvedPdfFonts,
): string {
switch (fontFamily) { switch (fontFamily) {
case "Helvetica-Bold": case "Helvetica-Bold":
return fonts.bold; return fonts.bold;
@@ -177,9 +173,7 @@ type PdfStyleBundle = {
styles: typeof baseStyles; styles: typeof baseStyles;
minimalStyles: typeof baseMinimalStyles; minimalStyles: typeof baseMinimalStyles;
fonts: ResolvedPdfFonts; fonts: ResolvedPdfFonts;
getStatusStyle: ( getStatusStyle: (status: string) => Array<Record<string, string | number>>;
status: string,
) => Array<Record<string, string | number>>;
}; };
const pdfStyleCache = new Map<string, PdfStyleBundle>(); const pdfStyleCache = new Map<string, PdfStyleBundle>();
@@ -816,7 +810,7 @@ const formatCurrency = (amount: number, currency = "USD") => {
}; };
const formatDate = (date: Date) => { const formatDate = (date: Date) => {
return new Date(date).toLocaleDateString("en-US", { return formatCalendarDate(date, {
year: "numeric", year: "numeric",
month: "2-digit", month: "2-digit",
day: "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: { export function invoiceLabel(inv: {
invoicePrefix: string | null; invoicePrefix: string | null;
invoiceNumber: string; invoiceNumber: string;
@@ -37,12 +42,13 @@ export type TimeEntryListItem = {
export function groupEntriesByDate<T extends { startedAt: Date }>( export function groupEntriesByDate<T extends { startedAt: Date }>(
entries: T[], entries: T[],
timeZone = DEFAULT_TIME_ZONE,
): { dateKey: string; label: string; entries: T[] }[] { ): { dateKey: string; label: string; entries: T[] }[] {
const groups = new Map<string, T[]>(); const groups = new Map<string, T[]>();
for (const entry of entries) { for (const entry of entries) {
const d = new Date(entry.startedAt); const parts = getZonedDateTimeParts(entry.startedAt, timeZone);
const dateKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; const dateKey = `${parts.year}-${String(parts.month).padStart(2, "0")}-${String(parts.day).padStart(2, "0")}`;
const existing = groups.get(dateKey); const existing = groups.get(dateKey);
if (existing) { if (existing) {
existing.push(entry); existing.push(entry);
@@ -58,6 +64,7 @@ export function groupEntriesByDate<T extends { startedAt: Date }>(
year: "numeric", year: "numeric",
month: "long", month: "long",
day: "numeric", day: "numeric",
timeZone,
}); });
return { dateKey, label, entries: groupEntries }; return { dateKey, label, entries: groupEntries };
}); });
@@ -1,7 +1,8 @@
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import type { db } from "~/server/db"; 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 { resolveBillingDescription } from "~/lib/time-clock";
import { calendarDateFromInstant } from "@beenvoice/domain/time-zone";
type Db = typeof db; type Db = typeof db;
@@ -110,6 +111,10 @@ export async function syncLinkedInvoiceItem(
const rate = entry.rate ?? 0; const rate = entry.rate ?? 0;
const amount = hours * rate; const amount = hours * rate;
const description = resolveBillingDescription(entry.description ?? ""); const description = resolveBillingDescription(entry.description ?? "");
const owner = await database.query.users.findFirst({
where: eq(users.id, linked.invoice.createdById),
columns: { timeZone: true },
});
await database await database
.update(invoiceItems) .update(invoiceItems)
@@ -118,7 +123,10 @@ export async function syncLinkedInvoiceItem(
hours, hours,
rate, rate,
amount, amount,
date: entry.endedAt ?? entry.startedAt, date: calendarDateFromInstant(
entry.endedAt ?? entry.startedAt,
owner?.timeZone ?? "America/New_York",
),
}) })
.where(eq(invoiceItems.id, linked.id)); .where(eq(invoiceItems.id, linked.id));
@@ -136,7 +144,10 @@ export async function syncLinkedInvoiceItem(
.where(eq(invoices.id, linked.invoiceId)); .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); const linked = await findLinkedInvoiceItem(database, timeEntryId);
if (!linked?.invoice) return; if (!linked?.invoice) return;
@@ -190,6 +201,10 @@ export async function relinkTimeEntryToInvoice(
}); });
if (!invoice) return null; if (!invoice) return null;
const owner = await database.query.users.findFirst({
where: eq(users.id, userId),
columns: { timeZone: true },
});
return insertInvoiceLineForTimeEntry(database, { return insertInvoiceLineForTimeEntry(database, {
invoice, invoice,
@@ -197,6 +212,9 @@ export async function relinkTimeEntryToInvoice(
description: resolveBillingDescription(entry.description ?? ""), description: resolveBillingDescription(entry.description ?? ""),
hours: entry.hours, hours: entry.hours,
rate: entry.rate ?? 0, 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 { apiKeysRouter } from "~/server/api/routers/apiKeys";
import { timeEntriesRouter } from "~/server/api/routers/time-entries"; import { timeEntriesRouter } from "~/server/api/routers/time-entries";
import { adminRouter } from "~/server/api/routers/admin"; import { adminRouter } from "~/server/api/routers/admin";
import { notificationsRouter } from "~/server/api/routers/notifications";
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc"; import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
export const appRouter = createTRPCRouter({ export const appRouter = createTRPCRouter({
@@ -27,6 +28,7 @@ export const appRouter = createTRPCRouter({
apiKeys: apiKeysRouter, apiKeys: apiKeysRouter,
timeEntries: timeEntriesRouter, timeEntries: timeEntriesRouter,
admin: adminRouter, admin: adminRouter,
notifications: notificationsRouter,
}); });
// export type definition of API // export type definition of API
+41 -19
View File
@@ -1,7 +1,11 @@
import { and, desc, eq, gte, lt } from "drizzle-orm"; import { and, desc, eq, gte, lt } from "drizzle-orm";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status"; 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"; import type { StoredInvoiceStatus } from "~/types/invoice";
type LiteInvoice = { type LiteInvoice = {
@@ -12,20 +16,28 @@ type LiteInvoice = {
issueDate: Date; issueDate: Date;
}; };
function buildRevenueMonthKeys(now: Date, count: number) { function buildRevenueMonthKeys(now: Date, count: number, timeZone: string) {
const current = getZonedDateTimeParts(now, timeZone);
const keys: string[] = []; const keys: string[] = [];
for (let i = count - 1; i >= 0; i--) { 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( keys.push(
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`, `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`,
); );
} }
return keys; return keys;
} }
function aggregateDashboardMetrics(userInvoices: LiteInvoice[], now: Date) { function aggregateDashboardMetrics(
const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1); userInvoices: LiteInvoice[],
const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1); 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 totalRevenue = 0;
let pendingAmount = 0; let pendingAmount = 0;
@@ -34,7 +46,7 @@ function aggregateDashboardMetrics(userInvoices: LiteInvoice[], now: Date) {
let lastMonthRevenue = 0; let lastMonthRevenue = 0;
const revenueByMonth = Object.fromEntries( const revenueByMonth = Object.fromEntries(
buildRevenueMonthKeys(now, 6).map((key) => [key, 0]), buildRevenueMonthKeys(now, 6, timeZone).map((key) => [key, 0]),
) as Record<string, number>; ) as Record<string, number>;
const statusTotals: Record< const statusTotals: Record<
@@ -58,6 +70,7 @@ function aggregateDashboardMetrics(userInvoices: LiteInvoice[], now: Date) {
const effectiveStatus = getEffectiveInvoiceStatus( const effectiveStatus = getEffectiveInvoiceStatus(
inv.status as StoredInvoiceStatus, inv.status as StoredInvoiceStatus,
inv.dueDate, inv.dueDate,
timeZone,
); );
const amount = inv.totalAmount; const amount = inv.totalAmount;
const issueDate = new Date(inv.issueDate); const issueDate = new Date(inv.issueDate);
@@ -67,14 +80,11 @@ function aggregateDashboardMetrics(userInvoices: LiteInvoice[], now: Date) {
if (issueDate >= currentMonthStart) { if (issueDate >= currentMonthStart) {
currentMonthRevenue += amount; currentMonthRevenue += amount;
} else if ( } else if (issueDate >= lastMonthStart && issueDate < currentMonthStart) {
issueDate >= lastMonthStart &&
issueDate < currentMonthStart
) {
lastMonthRevenue += amount; 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]; const monthRevenue = revenueByMonth[revenueKey];
if (monthRevenue !== undefined) { if (monthRevenue !== undefined) {
revenueByMonth[revenueKey] = monthRevenue + amount; revenueByMonth[revenueKey] = monthRevenue + amount;
@@ -95,7 +105,7 @@ function aggregateDashboardMetrics(userInvoices: LiteInvoice[], now: Date) {
statusTotals[effectiveStatus].count += 1; statusTotals[effectiveStatus].count += 1;
statusTotals[effectiveStatus].value += amount; 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] ??= { monthlyTotals[monthKey] ??= {
month: monthKey, month: monthKey,
totalInvoices: 0, totalInvoices: 0,
@@ -126,7 +136,7 @@ function aggregateDashboardMetrics(userInvoices: LiteInvoice[], now: Date) {
.map(([month, revenue]) => ({ .map(([month, revenue]) => ({
month, month,
revenue, revenue,
monthLabel: new Date(month + "-01").toLocaleDateString("en-US", { monthLabel: formatCalendarDate(month + "-01", {
month: "short", month: "short",
year: "2-digit", year: "2-digit",
}), }),
@@ -143,7 +153,7 @@ function aggregateDashboardMetrics(userInvoices: LiteInvoice[], now: Date) {
.slice(-6) .slice(-6)
.map((item) => ({ .map((item) => ({
...item, ...item,
monthLabel: new Date(item.month + "-01").toLocaleDateString("en-US", { monthLabel: formatCalendarDate(item.month + "-01", {
month: "short", month: "short",
year: "2-digit", year: "2-digit",
}), }),
@@ -167,6 +177,12 @@ export const dashboardRouter = createTRPCRouter({
getStats: protectedProcedure.query(async ({ ctx }) => { getStats: protectedProcedure.query(async ({ ctx }) => {
const userId = ctx.session.user.id; const userId = ctx.session.user.id;
const now = new Date(); 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 [ const [
userInvoices, userInvoices,
@@ -203,8 +219,14 @@ export const dashboardRouter = createTRPCRouter({
ctx.db.query.invoices.findMany({ ctx.db.query.invoices.findMany({
where: and( where: and(
eq(invoices.createdById, userId), eq(invoices.createdById, userId),
gte(invoices.issueDate, new Date(now.getFullYear(), now.getMonth(), 1)), gte(
lt(invoices.issueDate, new Date(now.getFullYear(), now.getMonth() + 1, 1)), 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: [ orderBy: [
desc(invoices.issueDate), desc(invoices.issueDate),
@@ -249,7 +271,7 @@ export const dashboardRouter = createTRPCRouter({
}), }),
]); ]);
const metrics = aggregateDashboardMetrics(userInvoices, now); const metrics = aggregateDashboardMetrics(userInvoices, now, timeZone);
return { return {
...metrics, ...metrics,
@@ -13,6 +13,8 @@ import {
clients, clients,
businesses, businesses,
platformSettings, platformSettings,
users,
backgroundJobs,
} from "~/server/db/schema"; } from "~/server/db/schema";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { calculateLineItemAmount } from "~/lib/invoice-line-item"; 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 { generateReminderEmailTemplate } from "~/lib/email-templates/reminder-email";
import type { db } from "~/server/db"; import type { db } from "~/server/db";
import { resolveEmailSender } from "~/server/services/email-sender"; import { resolveEmailSender } from "~/server/services/email-sender";
import { jobTypes } from "~/server/jobs/queue";
type InvoiceRouterContext = { type InvoiceRouterContext = {
db: typeof db; db: typeof db;
@@ -249,6 +252,7 @@ export const invoicesRouter = createTRPCRouter({
return await ctx.db.query.invoices.findMany({ return await ctx.db.query.invoices.findMany({
where: and(...conditions), where: and(...conditions),
with: { with: {
createdBy: { columns: { timeZone: true } },
business: true, business: true,
client: true, client: true,
items: { items: {
@@ -347,6 +351,7 @@ export const invoicesRouter = createTRPCRouter({
const currentInvoice = await ctx.db.query.invoices.findFirst({ const currentInvoice = await ctx.db.query.invoices.findFirst({
where: eq(invoices.createdById, ctx.session.user.id), where: eq(invoices.createdById, ctx.session.user.id),
with: { with: {
createdBy: { columns: { timeZone: true } },
business: true, business: true,
client: true, client: true,
items: { items: {
@@ -386,6 +391,7 @@ export const invoicesRouter = createTRPCRouter({
const invoice = await ctx.db.query.invoices.findFirst({ const invoice = await ctx.db.query.invoices.findFirst({
where: eq(invoices.id, input.id), where: eq(invoices.id, input.id),
with: { with: {
createdBy: { columns: { timeZone: true } },
business: true, business: true,
client: true, client: true,
items: { items: {
@@ -452,10 +458,16 @@ export const invoicesRouter = createTRPCRouter({
); );
return await ctx.db.transaction(async (tx) => { return await ctx.db.transaction(async (tx) => {
const invoiceId = crypto.randomUUID();
const sendReminderJobId = cleanInvoiceData.sendReminderAt
? crypto.randomUUID()
: null;
const [invoice] = await tx const [invoice] = await tx
.insert(invoices) .insert(invoices)
.values({ .values({
id: invoiceId,
...cleanInvoiceData, ...cleanInvoiceData,
sendReminderJobId,
totalAmount, totalAmount,
createdById: ctx.session.user.id, 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; return invoice;
}); });
} catch (error) { } catch (error) {
@@ -561,6 +584,34 @@ export const invoicesRouter = createTRPCRouter({
} }
await ctx.db.transaction(async (tx) => { 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) { if (items) {
const totalAmount = calculateInvoiceTotal( const totalAmount = calculateInvoiceTotal(
items, items,
@@ -571,6 +622,7 @@ export const invoicesRouter = createTRPCRouter({
.update(invoices) .update(invoices)
.set({ .set({
...cleanInvoiceData, ...cleanInvoiceData,
...reminderJobPatch,
totalAmount, totalAmount,
updatedAt: new Date(), updatedAt: new Date(),
}) })
@@ -601,6 +653,7 @@ export const invoicesRouter = createTRPCRouter({
.update(invoices) .update(invoices)
.set({ .set({
...cleanInvoiceData, ...cleanInvoiceData,
...reminderJobPatch,
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(invoices.id, id)) .where(eq(invoices.id, id))
@@ -1050,6 +1103,7 @@ export const invoicesRouter = createTRPCRouter({
where: eq(invoices.publicToken, input.token), where: eq(invoices.publicToken, input.token),
with: { with: {
client: true, client: true,
createdBy: { columns: { timeZone: true } },
// Explicit allowlist: this is a publicProcedure — never let // Explicit allowlist: this is a publicProcedure — never let
// secret fields (resendApiKey, resendDomain) reach an // secret fields (resendApiKey, resendDomain) reach an
// unauthenticated caller via the business relation. // unauthenticated caller via the business relation.
@@ -1120,6 +1174,10 @@ export const invoicesRouter = createTRPCRouter({
ctx.session.user.name ?? ctx.session.user.name ??
""; "";
const userEmail = invoice.business?.email ?? ctx.session.user.email ?? ""; 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({ const { html, text, subject } = generateReminderEmailTemplate({
invoice: { invoice: {
@@ -1134,6 +1192,7 @@ export const invoicesRouter = createTRPCRouter({
customMessage: input.customMessage, customMessage: input.customMessage,
userName, userName,
userEmail, userEmail,
timeZone: owner?.timeZone ?? "America/New_York",
}); });
try { 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, businesses,
} from "~/server/db/schema"; } from "~/server/db/schema";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { generateInvoiceFromRecurring } from "~/server/services/recurring-invoices";
import { import {
generateInvoiceFromRecurring, DEFAULT_TIME_ZONE,
nextDueDate, isValidTimeZone,
} from "~/server/services/recurring-invoices"; 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({ const recurringItemSchema = z.object({
description: z.string().min(1), description: z.string().min(1),
@@ -32,9 +40,27 @@ const recurringInvoiceSchema = z.object({
currency: z.string().length(3).default("USD"), currency: z.string().length(3).default("USD"),
notes: z.string().optional().or(z.literal("")), notes: z.string().optional().or(z.literal("")),
emailMessage: 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), 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({ export const recurringInvoicesRouter = createTRPCRouter({
getAll: protectedProcedure.query(async ({ ctx }) => { getAll: protectedProcedure.query(async ({ ctx }) => {
return ctx.db.query.recurringInvoices.findMany({ return ctx.db.query.recurringInvoices.findMany({
@@ -51,14 +77,20 @@ export const recurringInvoicesRouter = createTRPCRouter({
where: eq(clients.id, input.clientId), where: eq(clients.id, input.clientId),
}); });
if (client?.createdById !== ctx.session.user.id) { 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) { if (input.businessId) {
const biz = await ctx.db.query.businesses.findFirst({ const biz = await ctx.db.query.businesses.findFirst({
where: eq(businesses.id, input.businessId), where: eq(businesses.id, input.businessId),
}); });
if (biz?.createdById !== ctx.session.user.id) { 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, currency: input.currency,
notes: input.notes ?? null, notes: input.notes ?? null,
emailMessage: input.emailMessage ?? null, emailMessage: input.emailMessage ?? null,
nextDueAt: nextDueDate(input.schedule), nextDueAt: parseNextRun(input),
timeZone: input.timeZone,
createdById: ctx.session.user.id, createdById: ctx.session.user.id,
}) })
.returning({ id: recurringInvoices.id }); .returning({ id: recurringInvoices.id });
@@ -117,6 +150,8 @@ export const recurringInvoicesRouter = createTRPCRouter({
currency: input.currency, currency: input.currency,
notes: input.notes ?? null, notes: input.notes ?? null,
emailMessage: input.emailMessage ?? null, emailMessage: input.emailMessage ?? null,
nextDueAt: parseNextRun(input),
timeZone: input.timeZone,
}) })
.where(eq(recurringInvoices.id, input.id)); .where(eq(recurringInvoices.id, input.id));
@@ -195,11 +230,12 @@ export const recurringInvoicesRouter = createTRPCRouter({
throw new TRPCError({ code: "NOT_FOUND" }); 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 await ctx.db
.update(recurringInvoices) .update(recurringInvoices)
.set({ lastGeneratedAt: new Date(), nextDueAt: nextDueDate(rec.schedule) }) .set({ lastGeneratedAt: now })
.where(eq(recurringInvoices.id, input.id)); .where(eq(recurringInvoices.id, input.id));
return { invoiceId: newInvoice.id }; return { invoiceId: newInvoice.id };
@@ -41,6 +41,10 @@ import {
type ColorMode, type ColorMode,
} from "~/lib/branding"; } from "~/lib/branding";
import { revokeUserSessions } from "~/lib/session-security"; import { revokeUserSessions } from "~/lib/session-security";
import {
DEFAULT_TIME_ZONE,
isValidTimeZone,
} from "@beenvoice/domain/time-zone";
function resolveBusinessId( function resolveBusinessId(
refs: { businessName?: string; businessNickname?: string }, refs: { businessName?: string; businessNickname?: string },
@@ -156,6 +160,7 @@ const RecurringInvoiceBackupSchema = z.object({
currency: z.string().default("USD"), currency: z.string().default("USD"),
notes: z.string().optional(), notes: z.string().optional(),
emailMessage: z.string().optional(), emailMessage: z.string().optional(),
timeZone: z.string().refine(isValidTimeZone).default(DEFAULT_TIME_ZONE),
nextDueAt: z.coerce.date(), nextDueAt: z.coerce.date(),
lastGeneratedAt: z.coerce.date().optional(), lastGeneratedAt: z.coerce.date().optional(),
items: z.array(RecurringInvoiceItemBackupSchema), items: z.array(RecurringInvoiceItemBackupSchema),
@@ -197,6 +202,7 @@ const BackupDataSchema = z.object({
prefersReducedMotion: z.boolean().optional(), prefersReducedMotion: z.boolean().optional(),
animationSpeedMultiplier: z.number().optional(), animationSpeedMultiplier: z.number().optional(),
theme: z.string().optional(), theme: z.string().optional(),
timeZone: z.string().refine(isValidTimeZone).optional(),
onboardingCompletedAt: z.coerce.date().nullable().optional(), onboardingCompletedAt: z.coerce.date().nullable().optional(),
}), }),
clients: z.array(ClientBackupSchema), clients: z.array(ClientBackupSchema),
@@ -291,6 +297,7 @@ export const settingsRouter = createTRPCRouter({
email: true, email: true,
image: true, image: true,
role: true, role: true,
timeZone: true,
onboardingCompletedAt: true, onboardingCompletedAt: true,
}, },
}); });
@@ -507,6 +514,7 @@ export const settingsRouter = createTRPCRouter({
.input( .input(
z.object({ z.object({
name: z.string().min(1, "Name is required"), name: z.string().min(1, "Name is required"),
timeZone: z.string().refine(isValidTimeZone).default(DEFAULT_TIME_ZONE),
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
@@ -514,6 +522,7 @@ export const settingsRouter = createTRPCRouter({
.update(users) .update(users)
.set({ .set({
name: input.name, name: input.name,
timeZone: input.timeZone,
}) })
.where(eq(users.id, ctx.session.user.id)); .where(eq(users.id, ctx.session.user.id));
@@ -621,6 +630,7 @@ export const settingsRouter = createTRPCRouter({
prefersReducedMotion: true, prefersReducedMotion: true,
animationSpeedMultiplier: true, animationSpeedMultiplier: true,
theme: true, theme: true,
timeZone: true,
onboardingCompletedAt: true, onboardingCompletedAt: true,
}, },
}); });
@@ -759,6 +769,7 @@ export const settingsRouter = createTRPCRouter({
prefersReducedMotion: user?.prefersReducedMotion ?? false, prefersReducedMotion: user?.prefersReducedMotion ?? false,
animationSpeedMultiplier: user?.animationSpeedMultiplier ?? 1, animationSpeedMultiplier: user?.animationSpeedMultiplier ?? 1,
theme: user?.theme ?? "system", theme: user?.theme ?? "system",
timeZone: user?.timeZone ?? DEFAULT_TIME_ZONE,
onboardingCompletedAt: user?.onboardingCompletedAt ?? null, onboardingCompletedAt: user?.onboardingCompletedAt ?? null,
}, },
clients: userClients.map((client) => ({ clients: userClients.map((client) => ({
@@ -835,6 +846,7 @@ export const settingsRouter = createTRPCRouter({
currency: recurring.currency, currency: recurring.currency,
notes: recurring.notes ?? undefined, notes: recurring.notes ?? undefined,
emailMessage: recurring.emailMessage ?? undefined, emailMessage: recurring.emailMessage ?? undefined,
timeZone: recurring.timeZone,
nextDueAt: recurring.nextDueAt, nextDueAt: recurring.nextDueAt,
lastGeneratedAt: recurring.lastGeneratedAt ?? undefined, lastGeneratedAt: recurring.lastGeneratedAt ?? undefined,
items: recurring.items, items: recurring.items,
@@ -1002,6 +1014,7 @@ export const settingsRouter = createTRPCRouter({
currency: recurringData.currency, currency: recurringData.currency,
notes: recurringData.notes, notes: recurringData.notes,
emailMessage: recurringData.emailMessage, emailMessage: recurringData.emailMessage,
timeZone: recurringData.timeZone,
nextDueAt: recurringData.nextDueAt, nextDueAt: recurringData.nextDueAt,
lastGeneratedAt: recurringData.lastGeneratedAt, lastGeneratedAt: recurringData.lastGeneratedAt,
createdById: userId, createdById: userId,
@@ -1110,6 +1123,9 @@ export const settingsRouter = createTRPCRouter({
...(input.user.animationSpeedMultiplier !== undefined && { ...(input.user.animationSpeedMultiplier !== undefined && {
animationSpeedMultiplier: input.user.animationSpeedMultiplier, animationSpeedMultiplier: input.user.animationSpeedMultiplier,
}), }),
...(input.user.timeZone !== undefined && {
timeZone: input.user.timeZone,
}),
...(input.user.theme !== undefined && { ...(input.user.theme !== undefined && {
theme: input.user.theme, theme: input.user.theme,
}), }),
+190 -39
View File
@@ -1,7 +1,13 @@
import { z } from "zod"; import { z } from "zod";
import { eq, and, desc, isNull, isNotNull, gte, lte } from "drizzle-orm"; import { eq, and, desc, isNull, isNotNull, gte, lte } from "drizzle-orm";
import { createTRPCRouter, protectedProcedure } from "../trpc"; 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 { TRPCError } from "@trpc/server";
import type { db } from "~/server/db"; import type { db } from "~/server/db";
import { import {
@@ -17,6 +23,7 @@ import {
removeLinkedInvoiceItem, removeLinkedInvoiceItem,
syncLinkedInvoiceItem, syncLinkedInvoiceItem,
} from "~/server/api/lib/time-entry-invoice-sync"; } from "~/server/api/lib/time-entry-invoice-sync";
import { calendarDateFromInstant } from "@beenvoice/domain/time-zone";
type Db = typeof db; type Db = typeof db;
@@ -55,20 +62,31 @@ function computeHours(startedAt: Date, endedAt: Date): number {
async function addEntryToInvoice( async function addEntryToInvoice(
database: Db, 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, entryId: string,
description: string, description: string,
hours: number, hours: number,
rate: number, rate: number,
date: Date, date: Date,
): Promise<{ id: string; invoiceNumber: string; invoicePrefix: string }> { ): 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, { return insertInvoiceLineForTimeEntry(database, {
invoice, invoice,
entryId, entryId,
description, description,
hours, hours,
rate, rate,
date, date: calendarDateFromInstant(date, owner?.timeZone ?? "America/New_York"),
}); });
} }
@@ -100,11 +118,21 @@ async function findOrCreateDraftInvoice(
if (!client) return null; if (!client) return null;
const defaultBusiness = await database.query.businesses.findFirst({ 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 }, 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 const [created] = await database
.insert(invoices) .insert(invoices)
.values({ .values({
@@ -135,10 +163,23 @@ async function addEntryToLatestInvoice(
hours: number, hours: number,
rate: number, rate: number,
date: Date, 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); const invoice = await findOrCreateDraftInvoice(database, userId, clientId);
if (!invoice) return null; 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( async function addEntryToSpecificInvoice(
@@ -150,7 +191,11 @@ async function addEntryToSpecificInvoice(
hours: number, hours: number,
rate: number, rate: number,
date: Date, 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({ const invoice = await database.query.invoices.findFirst({
where: and( where: and(
eq(invoices.id, invoiceId), eq(invoices.id, invoiceId),
@@ -161,7 +206,16 @@ async function addEntryToSpecificInvoice(
}); });
if (!invoice) return null; 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({ export const timeEntriesRouter = createTRPCRouter({
@@ -177,13 +231,19 @@ export const timeEntriesRouter = createTRPCRouter({
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const conditions = [eq(timeEntries.createdById, ctx.session.user.id)]; 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?.from) conditions.push(gte(timeEntries.startedAt, input.from));
if (input?.to) conditions.push(lte(timeEntries.startedAt, input.to)); if (input?.to) conditions.push(lte(timeEntries.startedAt, input.to));
return ctx.db.query.timeEntries.findMany({ return ctx.db.query.timeEntries.findMany({
where: and(...conditions), 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)], orderBy: [desc(timeEntries.startedAt)],
}); });
}), }),
@@ -198,7 +258,11 @@ export const timeEntriesRouter = createTRPCRouter({
), ),
with: { client: true }, 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; return entry;
}), }),
@@ -247,10 +311,17 @@ export const timeEntriesRouter = createTRPCRouter({
let clientRecord: { defaultHourlyRate: number | null } | null = null; let clientRecord: { defaultHourlyRate: number | null } | null = null;
if (clientId) { if (clientId) {
const found = await ctx.db.query.clients.findFirst({ 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 }, 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; clientRecord = found;
} }
@@ -282,7 +353,10 @@ export const timeEntriesRouter = createTRPCRouter({
const startedAt = input.startedAt ?? new Date(); const startedAt = input.startedAt ?? new Date();
if (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) { if (!clientRecord && resolvedClientId) {
@@ -337,7 +411,10 @@ export const timeEntriesRouter = createTRPCRouter({
}); });
if (!entry) { 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: { const updates: {
@@ -369,9 +446,16 @@ export const timeEntriesRouter = createTRPCRouter({
const clientId = input.clientId.trim() || null; const clientId = input.clientId.trim() || null;
if (clientId) { if (clientId) {
const found = await ctx.db.query.clients.findFirst({ 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; resolvedClientId = clientId;
updates.clientId = clientId; updates.clientId = clientId;
@@ -427,7 +511,10 @@ export const timeEntriesRouter = createTRPCRouter({
.returning(); .returning();
if (!updated) { if (!updated) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Update failed" }); throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Update failed",
});
} }
return updated; return updated;
@@ -435,10 +522,12 @@ export const timeEntriesRouter = createTRPCRouter({
clockOut: protectedProcedure clockOut: protectedProcedure
.input( .input(
z.object({ z
.object({
id: z.string().optional(), id: z.string().optional(),
description: z.string().max(500).optional(), description: z.string().max(500).optional(),
}).optional(), })
.optional(),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const conditions = [ const conditions = [
@@ -452,24 +541,41 @@ export const timeEntriesRouter = createTRPCRouter({
}); });
if (!entry) { 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 endedAt = new Date();
const hours = computeHours(entry.startedAt, endedAt); 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 billingDescription = resolveBillingDescription(rawDescription);
const rate = entry.rate ?? 0; const rate = entry.rate ?? 0;
const [updated] = await ctx.db const [updated] = await ctx.db
.update(timeEntries) .update(timeEntries)
.set({ endedAt, hours, description: rawDescription, updatedAt: new Date() }) .set({
endedAt,
hours,
description: rawDescription,
updatedAt: new Date(),
})
.where(eq(timeEntries.id, entry.id)) .where(eq(timeEntries.id, entry.id))
.returning(); .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"; let outcome: ClockOutOutcome = "zero_hours";
if (hours > 0) { if (hours > 0) {
@@ -518,9 +624,16 @@ export const timeEntriesRouter = createTRPCRouter({
const clientId = normalizeOptionalId(input.clientId); const clientId = normalizeOptionalId(input.clientId);
if (clientId) { if (clientId) {
const client = await ctx.db.query.clients.findFirst({ 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; let hours = input.hours ?? null;
@@ -542,9 +655,17 @@ export const timeEntriesRouter = createTRPCRouter({
}) })
.returning(); .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) { if (clientId && hours && input.endedAt) {
linkedInvoice = await addEntryToLatestInvoice( linkedInvoice = await addEntryToLatestInvoice(
ctx.db, ctx.db,
@@ -576,7 +697,11 @@ export const timeEntriesRouter = createTRPCRouter({
eq(timeEntries.createdById, ctx.session.user.id), 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) { if (existing.endedAt == null) {
throw new TRPCError({ throw new TRPCError({
@@ -590,16 +715,28 @@ export const timeEntriesRouter = createTRPCRouter({
if (clientId) { if (clientId) {
const client = await ctx.db.query.clients.findFirst({ 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; let hours = data.hours;
const startedAt = data.startedAt ?? existing.startedAt; const startedAt = data.startedAt ?? existing.startedAt;
const endedAt = data.endedAt ?? existing.endedAt; 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); hours = computeHours(startedAt, endedAt);
} }
@@ -619,11 +756,19 @@ export const timeEntriesRouter = createTRPCRouter({
}); });
if (!updated) { 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) { 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 { } else {
await syncLinkedInvoiceItem(ctx.db, updated); await syncLinkedInvoiceItem(ctx.db, updated);
} }
@@ -640,7 +785,11 @@ export const timeEntriesRouter = createTRPCRouter({
eq(timeEntries.createdById, ctx.session.user.id), 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 removeLinkedInvoiceItem(ctx.db, input.id);
await ctx.db.delete(timeEntries).where(eq(timeEntries.id, input.id)); await ctx.db.delete(timeEntries).where(eq(timeEntries.id, input.id));
@@ -649,10 +798,12 @@ export const timeEntriesRouter = createTRPCRouter({
getSummary: protectedProcedure getSummary: protectedProcedure
.input( .input(
z.object({ z
.object({
from: z.date().optional(), from: z.date().optional(),
to: z.date().optional(), to: z.date().optional(),
}).optional(), })
.optional(),
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const conditions = [ const conditions = [
+83 -54
View File
@@ -20,21 +20,22 @@ export const users = createTable("user", (d) => ({
email: d.varchar({ length: 255 }).notNull().unique(), email: d.varchar({ length: 255 }).notNull().unique(),
emailVerified: d.boolean().default(false).notNull(), emailVerified: d.boolean().default(false).notNull(),
image: d.varchar({ length: 255 }), 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 updatedAt: d
.timestamp() .timestamp({ withTimezone: true })
.notNull() .notNull()
.defaultNow() .defaultNow()
.$onUpdate(() => new Date()), .$onUpdate(() => new Date()),
password: d.varchar({ length: 255 }), // Matched DB: varchar(255) password: d.varchar({ length: 255 }), // Matched DB: varchar(255)
resetToken: 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 // Custom fields
prefersReducedMotion: d.boolean().default(false).notNull(), prefersReducedMotion: d.boolean().default(false).notNull(),
animationSpeedMultiplier: d.real().default(1).notNull(), animationSpeedMultiplier: d.real().default(1).notNull(),
theme: d.varchar({ length: 20 }).default("system").notNull(), theme: d.varchar({ length: 20 }).default("system").notNull(),
role: d.varchar({ length: 20 }).default("user").notNull(), role: d.varchar({ length: 20 }).default("user").notNull(),
onboardingCompletedAt: d.timestamp(), onboardingCompletedAt: d.timestamp({ withTimezone: true }),
})); }));
export const platformSettings = createTable("platform_setting", (d) => ({ export const platformSettings = createTable("platform_setting", (d) => ({
@@ -49,9 +50,9 @@ export const platformSettings = createTable("platform_setting", (d) => ({
.notNull(), .notNull(),
pdfShowLogo: d.boolean().default(true).notNull(), pdfShowLogo: d.boolean().default(true).notNull(),
pdfShowPageNumbers: 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 updatedAt: d
.timestamp() .timestamp({ withTimezone: true })
.notNull() .notNull()
.defaultNow() .defaultNow()
.$onUpdate(() => new Date()), .$onUpdate(() => new Date()),
@@ -68,6 +69,7 @@ export const usersRelations = relations(users, ({ many }) => ({
invoiceTemplates: many(invoiceTemplates), invoiceTemplates: many(invoiceTemplates),
recurringInvoices: many(recurringInvoices), recurringInvoices: many(recurringInvoices),
timeEntries: many(timeEntries), timeEntries: many(timeEntries),
pushTokens: many(pushTokens),
auditLogsAsActor: many(auditLog), auditLogsAsActor: many(auditLog),
})); }));
@@ -87,7 +89,7 @@ export const auditLog = createTable(
targetType: d.varchar({ length: 50 }).notNull(), targetType: d.varchar({ length: 50 }).notNull(),
targetId: d.varchar({ length: 255 }), targetId: d.varchar({ length: 255 }),
metadata: d.jsonb().$type<Record<string, unknown>>(), metadata: d.jsonb().$type<Record<string, unknown>>(),
createdAt: d.timestamp().notNull().defaultNow(), createdAt: d.timestamp({ withTimezone: true }).notNull().defaultNow(),
}), }),
(t) => [ (t) => [
index("audit_log_actor_user_id_idx").on(t.actorUserId), index("audit_log_actor_user_id_idx").on(t.actorUserId),
@@ -119,14 +121,14 @@ export const accounts = createTable(
providerId: d.varchar({ length: 255 }).notNull(), providerId: d.varchar({ length: 255 }).notNull(),
accessToken: d.text(), accessToken: d.text(),
refreshToken: d.text(), refreshToken: d.text(),
accessTokenExpiresAt: d.timestamp(), accessTokenExpiresAt: d.timestamp({ withTimezone: true }),
refreshTokenExpiresAt: d.timestamp(), refreshTokenExpiresAt: d.timestamp({ withTimezone: true }),
scope: d.varchar({ length: 255 }), scope: d.varchar({ length: 255 }),
idToken: d.text(), idToken: d.text(),
password: d.text(), // Matched DB: text password: d.text(), // Matched DB: text
createdAt: d.timestamp().notNull().defaultNow(), createdAt: d.timestamp({ withTimezone: true }).notNull().defaultNow(),
updatedAt: d updatedAt: d
.timestamp() .timestamp({ withTimezone: true })
.notNull() .notNull()
.defaultNow() .defaultNow()
.$onUpdate(() => new Date()), .$onUpdate(() => new Date()),
@@ -151,12 +153,12 @@ export const sessions = createTable(
.notNull() .notNull()
.references(() => users.id), .references(() => users.id),
token: d.varchar({ length: 255 }).notNull().unique(), token: d.varchar({ length: 255 }).notNull().unique(),
expiresAt: d.timestamp().notNull(), expiresAt: d.timestamp({ withTimezone: true }).notNull(),
ipAddress: d.text(), // Matched DB: text ipAddress: d.text(), // Matched DB: text
userAgent: 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 updatedAt: d
.timestamp() .timestamp({ withTimezone: true })
.notNull() .notNull()
.defaultNow() .defaultNow()
.$onUpdate(() => new Date()), .$onUpdate(() => new Date()),
@@ -183,12 +185,12 @@ export const apiKeys = createTable(
.varchar({ length: 255 }) .varchar({ length: 255 })
.notNull() .notNull()
.references(() => users.id, { onDelete: "cascade" }), .references(() => users.id, { onDelete: "cascade" }),
lastUsedAt: d.timestamp(), lastUsedAt: d.timestamp({ withTimezone: true }),
expiresAt: d.timestamp(), expiresAt: d.timestamp({ withTimezone: true }),
revokedAt: d.timestamp(), revokedAt: d.timestamp({ withTimezone: true }),
createdAt: d.timestamp().notNull().defaultNow(), createdAt: d.timestamp({ withTimezone: true }).notNull().defaultNow(),
updatedAt: d updatedAt: d
.timestamp() .timestamp({ withTimezone: true })
.notNull() .notNull()
.defaultNow() .defaultNow()
.$onUpdate(() => new Date()), .$onUpdate(() => new Date()),
@@ -214,10 +216,10 @@ export const verificationTokens = createTable(
.$defaultFn(() => crypto.randomUUID()), // Matched DB: text .$defaultFn(() => crypto.randomUUID()), // Matched DB: text
identifier: d.varchar({ length: 255 }).notNull(), identifier: d.varchar({ length: 255 }).notNull(),
value: d.text().notNull(), value: d.text().notNull(),
expiresAt: d.timestamp().notNull(), expiresAt: d.timestamp({ withTimezone: true }).notNull(),
createdAt: d.timestamp().notNull().defaultNow(), createdAt: d.timestamp({ withTimezone: true }).notNull().defaultNow(),
updatedAt: d updatedAt: d
.timestamp() .timestamp({ withTimezone: true })
.notNull() .notNull()
.defaultNow() .defaultNow()
.$onUpdate(() => new Date()), .$onUpdate(() => new Date()),
@@ -241,9 +243,9 @@ export const ssoProviders = createTable(
redirectURI: d.varchar({ length: 255 }).notNull().default(""), // Added detailed fields redirectURI: d.varchar({ length: 255 }).notNull().default(""), // Added detailed fields
oidcConfig: d.text(), oidcConfig: d.text(),
samlConfig: d.text(), samlConfig: d.text(),
createdAt: d.timestamp().notNull().defaultNow(), createdAt: d.timestamp({ withTimezone: true }).notNull().defaultNow(),
updatedAt: d updatedAt: d
.timestamp() .timestamp({ withTimezone: true })
.notNull() .notNull()
.defaultNow() .defaultNow()
.$onUpdate(() => new Date()), .$onUpdate(() => new Date()),
@@ -276,10 +278,10 @@ export const clients = createTable(
.notNull() .notNull()
.references(() => users.id), .references(() => users.id),
createdAt: d createdAt: d
.timestamp() .timestamp({ withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`) .default(sql`CURRENT_TIMESTAMP`)
.notNull(), .notNull(),
updatedAt: d.timestamp().$onUpdate(() => new Date()), updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()),
}), }),
(t) => [ (t) => [
index("client_created_by_idx").on(t.createdById), index("client_created_by_idx").on(t.createdById),
@@ -331,10 +333,10 @@ export const businesses = createTable(
.notNull() .notNull()
.references(() => users.id), .references(() => users.id),
createdAt: d createdAt: d
.timestamp() .timestamp({ withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`) .default(sql`CURRENT_TIMESTAMP`)
.notNull(), .notNull(),
updatedAt: d.timestamp().$onUpdate(() => new Date()), updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()),
}), }),
(t) => [ (t) => [
index("business_created_by_idx").on(t.createdById), index("business_created_by_idx").on(t.createdById),
@@ -368,8 +370,8 @@ export const invoices = createTable(
.varchar({ length: 255 }) .varchar({ length: 255 })
.notNull() .notNull()
.references(() => clients.id), .references(() => clients.id),
issueDate: d.timestamp().notNull(), issueDate: d.date({ mode: "date" }).notNull(),
dueDate: d.timestamp().notNull(), dueDate: d.date({ mode: "date" }).notNull(),
status: d.varchar({ length: 50 }).notNull().default("draft"), // draft, sent, paid (overdue computed) status: d.varchar({ length: 50 }).notNull().default("draft"), // draft, sent, paid (overdue computed)
totalAmount: d.real().notNull().default(0), totalAmount: d.real().notNull().default(0),
taxRate: d.real().notNull().default(0.0), taxRate: d.real().notNull().default(0.0),
@@ -381,19 +383,20 @@ export const invoices = createTable(
.notNull() .notNull()
.references(() => users.id), .references(() => users.id),
publicToken: d.varchar({ length: 255 }).unique(), publicToken: d.varchar({ length: 255 }).unique(),
publicTokenExpiresAt: d.timestamp(), publicTokenExpiresAt: d.timestamp({ withTimezone: true }),
lastReminderSentAt: d.timestamp(), lastReminderSentAt: d.timestamp({ withTimezone: true }),
sendReminderAt: d.timestamp(), sendReminderAt: d.timestamp({ withTimezone: true }),
sendReminderJobId: d.varchar({ length: 255 }),
sentAt: d.timestamp({ withTimezone: true }), sentAt: d.timestamp({ withTimezone: true }),
scheduledSendAt: d.timestamp({ withTimezone: true }), scheduledSendAt: d.timestamp({ withTimezone: true }),
scheduledSendTimeZone: d.varchar({ length: 100 }), scheduledSendTimeZone: d.varchar({ length: 100 }),
scheduledSendJobId: d.varchar({ length: 255 }), scheduledSendJobId: d.varchar({ length: 255 }),
scheduledSendStatus: d.varchar({ length: 20 }), // pending | processing | completed | failed | cancelled scheduledSendStatus: d.varchar({ length: 20 }), // pending | processing | completed | failed | cancelled
createdAt: d createdAt: d
.timestamp() .timestamp({ withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`) .default(sql`CURRENT_TIMESTAMP`)
.notNull(), .notNull(),
updatedAt: d.timestamp().$onUpdate(() => new Date()), updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()),
}), }),
(t) => [ (t) => [
index("invoice_business_id_idx").on(t.businessId), index("invoice_business_id_idx").on(t.businessId),
@@ -436,7 +439,7 @@ export const invoiceItems = createTable(
.varchar({ length: 255 }) .varchar({ length: 255 })
.notNull() .notNull()
.references(() => invoices.id, { onDelete: "cascade" }), .references(() => invoices.id, { onDelete: "cascade" }),
date: d.timestamp().notNull(), date: d.date({ mode: "date" }).notNull(),
description: d.varchar({ length: 500 }).notNull(), description: d.varchar({ length: 500 }).notNull(),
hours: d.real().notNull(), hours: d.real().notNull(),
rate: d.real().notNull(), rate: d.real().notNull(),
@@ -446,7 +449,7 @@ export const invoiceItems = createTable(
.varchar({ length: 255 }) .varchar({ length: 255 })
.references(() => timeEntries.id, { onDelete: "set null" }), .references(() => timeEntries.id, { onDelete: "set null" }),
createdAt: d createdAt: d
.timestamp() .timestamp({ withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`) .default(sql`CURRENT_TIMESTAMP`)
.notNull(), .notNull(),
}), }),
@@ -481,7 +484,7 @@ export const expenses = createTable(
invoiceId: d invoiceId: d
.varchar({ length: 255 }) .varchar({ length: 255 })
.references(() => invoices.id, { onDelete: "set null" }), .references(() => invoices.id, { onDelete: "set null" }),
date: d.timestamp().notNull(), date: d.date({ mode: "date" }).notNull(),
description: d.varchar({ length: 500 }).notNull(), description: d.varchar({ length: 500 }).notNull(),
amount: d.real().notNull(), amount: d.real().notNull(),
currency: d.varchar({ length: 3 }).default("USD").notNull(), currency: d.varchar({ length: 3 }).default("USD").notNull(),
@@ -495,10 +498,10 @@ export const expenses = createTable(
.notNull() .notNull()
.references(() => users.id), .references(() => users.id),
createdAt: d createdAt: d
.timestamp() .timestamp({ withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`) .default(sql`CURRENT_TIMESTAMP`)
.notNull(), .notNull(),
updatedAt: d.timestamp().$onUpdate(() => new Date()), updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()),
}), }),
(t) => [ (t) => [
index("expense_created_by_idx").on(t.createdById), index("expense_created_by_idx").on(t.createdById),
@@ -527,7 +530,7 @@ export const expenseReceipts = createTable(
mimeType: d.varchar({ length: 100 }).notNull(), mimeType: d.varchar({ length: 100 }).notNull(),
sizeBytes: d.integer().notNull(), sizeBytes: d.integer().notNull(),
createdAt: d createdAt: d
.timestamp() .timestamp({ withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`) .default(sql`CURRENT_TIMESTAMP`)
.notNull(), .notNull(),
}), }),
@@ -581,10 +584,10 @@ export const invoiceTemplates = createTable(
.notNull() .notNull()
.references(() => users.id), .references(() => users.id),
createdAt: d createdAt: d
.timestamp() .timestamp({ withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`) .default(sql`CURRENT_TIMESTAMP`)
.notNull(), .notNull(),
updatedAt: d.timestamp().$onUpdate(() => new Date()), updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()),
}), }),
(t) => [ (t) => [
index("invoice_template_created_by_idx").on(t.createdById), index("invoice_template_created_by_idx").on(t.createdById),
@@ -618,7 +621,7 @@ export const invoicePayments = createTable(
.references(() => invoices.id, { onDelete: "cascade" }), .references(() => invoices.id, { onDelete: "cascade" }),
amount: d.real().notNull(), amount: d.real().notNull(),
currency: d.varchar({ length: 3 }).default("USD").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 method: d.varchar({ length: 50 }).notNull().default("other"), // cash | check | bank_transfer | credit_card | paypal | other
notes: d.varchar({ length: 500 }), notes: d.varchar({ length: 500 }),
createdById: d createdById: d
@@ -626,7 +629,7 @@ export const invoicePayments = createTable(
.notNull() .notNull()
.references(() => users.id), .references(() => users.id),
createdAt: d createdAt: d
.timestamp() .timestamp({ withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`) .default(sql`CURRENT_TIMESTAMP`)
.notNull(), .notNull(),
}), }),
@@ -673,17 +676,18 @@ export const recurringInvoices = createTable(
currency: d.varchar({ length: 3 }).default("USD").notNull(), currency: d.varchar({ length: 3 }).default("USD").notNull(),
notes: d.varchar({ length: 1000 }), notes: d.varchar({ length: 1000 }),
emailMessage: d.varchar({ length: 2000 }), emailMessage: d.varchar({ length: 2000 }),
nextDueAt: d.timestamp().notNull(), nextDueAt: d.timestamp({ withTimezone: true }).notNull(),
lastGeneratedAt: d.timestamp(), lastGeneratedAt: d.timestamp({ withTimezone: true }),
timeZone: d.varchar({ length: 100 }).notNull().default("America/New_York"),
createdById: d createdById: d
.varchar({ length: 255 }) .varchar({ length: 255 })
.notNull() .notNull()
.references(() => users.id), .references(() => users.id),
createdAt: d createdAt: d
.timestamp() .timestamp({ withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`) .default(sql`CURRENT_TIMESTAMP`)
.notNull(), .notNull(),
updatedAt: d.timestamp().$onUpdate(() => new Date()), updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()),
}), }),
(t) => [ (t) => [
index("recurring_invoice_created_by_idx").on(t.createdById), index("recurring_invoice_created_by_idx").on(t.createdById),
@@ -729,7 +733,7 @@ export const recurringInvoiceItems = createTable(
rate: d.real().notNull(), rate: d.real().notNull(),
position: d.integer().notNull().default(0), position: d.integer().notNull().default(0),
createdAt: d createdAt: d
.timestamp() .timestamp({ withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`) .default(sql`CURRENT_TIMESTAMP`)
.notNull(), .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 ───────────────────────────────────────────────────────── // ─── Background Jobs ─────────────────────────────────────────────────────────
export const backgroundJobs = createTable( export const backgroundJobs = createTable(
@@ -795,8 +824,8 @@ export const timeEntries = createTable(
invoiceId: d invoiceId: d
.varchar({ length: 255 }) .varchar({ length: 255 })
.references(() => invoices.id, { onDelete: "set null" }), .references(() => invoices.id, { onDelete: "set null" }),
startedAt: d.timestamp().notNull(), startedAt: d.timestamp({ withTimezone: true }).notNull(),
endedAt: d.timestamp(), // null = currently running endedAt: d.timestamp({ withTimezone: true }), // null = currently running
hours: d.real(), // stored when stopped hours: d.real(), // stored when stopped
rate: d.real(), rate: d.real(),
notes: d.varchar({ length: 500 }), notes: d.varchar({ length: 500 }),
@@ -805,10 +834,10 @@ export const timeEntries = createTable(
.notNull() .notNull()
.references(() => users.id, { onDelete: "cascade" }), .references(() => users.id, { onDelete: "cascade" }),
createdAt: d createdAt: d
.timestamp() .timestamp({ withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`) .default(sql`CURRENT_TIMESTAMP`)
.notNull(), .notNull(),
updatedAt: d.timestamp().$onUpdate(() => new Date()), updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()),
}), }),
(t) => [ (t) => [
index("time_entry_created_by_idx").on(t.createdById), 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) { export async function generateRecurringInvoice(job: BackgroundJob) {
const recurringInvoiceId = job.payload.recurringInvoiceId; const recurringInvoiceId = job.payload.recurringInvoiceId;
const scheduledForValue = job.payload.scheduledFor; 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"); throw new Error("Invalid recurring invoice job payload");
} }
const scheduledFor = new Date(scheduledForValue); 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) => { await db.transaction(async (tx) => {
const recurring = await tx.query.recurringInvoices.findFirst({ const recurring = await tx.query.recurringInvoices.findFirst({
@@ -28,12 +32,16 @@ export async function generateRecurringInvoice(job: BackgroundJob) {
}); });
if (!recurring) return; if (!recurring) return;
await generateInvoiceFromRecurring(tx, recurring); await generateInvoiceFromRecurring(tx, recurring, scheduledFor);
await tx await tx
.update(recurringInvoices) .update(recurringInvoices)
.set({ .set({
lastGeneratedAt: new Date(), lastGeneratedAt: new Date(),
nextDueAt: nextDueDate(recurring.schedule, scheduledFor), nextDueAt: nextDueDate(
recurring.schedule,
scheduledFor,
recurring.timeZone,
),
}) })
.where(eq(recurringInvoices.id, recurring.id)); .where(eq(recurringInvoices.id, recurring.id));
}); });
@@ -4,27 +4,36 @@ import type {
recurringInvoiceItems, recurringInvoiceItems,
recurringInvoices, recurringInvoices,
} from "~/server/db/schema"; } from "~/server/db/schema";
import {
addZonedCalendarInterval,
getZonedDateTimeParts,
} from "@beenvoice/domain/time-zone";
export function nextDueDate(schedule: string, from = new Date()): Date { export function nextDueDate(
const date = new Date(from); schedule: string,
switch (schedule) { from = new Date(),
case "weekly": timeZone = "America/New_York",
date.setDate(date.getDate() + 7); ): Date {
break; if (
case "biweekly": !(
date.setDate(date.getDate() + 14); ["weekly", "biweekly", "monthly", "quarterly", "yearly"] as string[]
break; ).includes(schedule)
case "monthly": ) {
date.setMonth(date.getMonth() + 1); throw new RangeError("Invalid recurring schedule");
break;
case "quarterly":
date.setMonth(date.getMonth() + 3);
break;
case "yearly":
date.setFullYear(date.getFullYear() + 1);
break;
} }
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 & { type RecurringWithItems = typeof recurringInvoices.$inferSelect & {
@@ -34,10 +43,14 @@ type RecurringWithItems = typeof recurringInvoices.$inferSelect & {
export async function generateInvoiceFromRecurring( export async function generateInvoiceFromRecurring(
db: Pick<typeof DbType, "insert">, db: Pick<typeof DbType, "insert">,
recurring: RecurringWithItems, recurring: RecurringWithItems,
scheduledFor = new Date(),
): Promise<{ id: string }> { ): Promise<{ id: string }> {
const now = new Date(); const issueDate = calendarDateAt(scheduledFor, recurring.timeZone);
const invoiceNumber = `REC-${Date.now()}`; 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 taxAmount = (subtotal * recurring.taxRate) / 100;
const [newInvoice] = await db const [newInvoice] = await db
@@ -47,8 +60,11 @@ export async function generateInvoiceFromRecurring(
invoicePrefix: recurring.invoicePrefix ?? "#", invoicePrefix: recurring.invoicePrefix ?? "#",
clientId: recurring.clientId, clientId: recurring.clientId,
businessId: recurring.businessId ?? null, businessId: recurring.businessId ?? null,
issueDate: now, issueDate,
dueDate: nextDueDate("monthly", now), dueDate: calendarDateAt(
nextDueDate("monthly", scheduledFor, recurring.timeZone),
recurring.timeZone,
),
status: "draft", status: "draft",
totalAmount: subtotal + taxAmount, totalAmount: subtotal + taxAmount,
taxRate: recurring.taxRate, taxRate: recurring.taxRate,
@@ -65,7 +81,7 @@ export async function generateInvoiceFromRecurring(
await db.insert(invoiceItems).values( await db.insert(invoiceItems).values(
recurring.items.map((item, index) => ({ recurring.items.map((item, index) => ({
invoiceId: newInvoice.id, invoiceId: newInvoice.id,
date: now, date: issueDate,
description: item.description, description: item.description,
hours: item.hours, hours: item.hours,
rate: item.rate, rate: item.rate,
@@ -231,6 +231,7 @@ export async function deliverInvoiceEmail(input: DeliverInvoiceEmailInput) {
userName, userName,
userEmail, userEmail,
baseUrl: input.baseUrl, baseUrl: input.baseUrl,
timeZone: invoice.createdBy.timeZone,
}); });
const sender = resolveEmailSender(invoice.business, userName); const sender = resolveEmailSender(invoice.business, userName);
+5
View File
@@ -11,6 +11,7 @@ import {
type BackgroundJob, type BackgroundJob,
} from "../../web/src/server/jobs/queue"; } from "../../web/src/server/jobs/queue";
import { sendScheduledInvoice } from "./send-invoice"; import { sendScheduledInvoice } from "./send-invoice";
import { sendInvoiceReminder } from "../../web/src/server/jobs/handlers/invoice-reminder";
const workerId = `beenvoice-worker:${randomUUID()}`; const workerId = `beenvoice-worker:${randomUUID()}`;
const pollMs = Number(process.env.WORKER_POLL_MS ?? 2_000); const pollMs = Number(process.env.WORKER_POLL_MS ?? 2_000);
@@ -69,6 +70,10 @@ async function handleJob(job: BackgroundJob) {
await sendScheduledInvoice(job); await sendScheduledInvoice(job);
return; return;
} }
if (job.type === jobTypes.sendInvoiceReminder) {
await sendInvoiceReminder(job);
return;
}
throw new Error(`No handler registered for ${job.type}`); 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", () => { describe("recurring invoice scheduling", () => {
test("advances weekly schedules from their scheduled occurrence", () => { test("advances weekly schedules from their scheduled occurrence", () => {
const scheduledFor = new Date("2026-08-17T12:00:00.000Z"); const scheduledFor = new Date("2026-08-17T12:00:00.000Z");
expect(nextDueDate("weekly", scheduledFor).toISOString()).toBe( expect(
"2026-08-24T12:00:00.000Z", nextDueDate("weekly", scheduledFor, "America/New_York").toISOString(),
); ).toBe("2026-08-24T12:00:00.000Z");
}); });
test("does not mutate the source date", () => { test("does not mutate the source date", () => {
const scheduledFor = new Date("2026-08-17T12:00:00.000Z"); 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"); expect(scheduledFor.toISOString()).toBe("2026-08-17T12:00:00.000Z");
}); });
}); });
+2
View File
@@ -23,6 +23,7 @@ services:
environment: environment:
SERVICE_FQDN_APP: SERVICE_FQDN_APP:
NODE_ENV: production NODE_ENV: production
TZ: UTC
PORT: ${APP_PORT:-3000} PORT: ${APP_PORT:-3000}
AUTH_SECRET: ${AUTH_SECRET:?Set AUTH_SECRET in Coolify env} 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} 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} image: ${BEENVOICE_WORKER_IMAGE:-beenvoice-worker:coolify}
environment: environment:
NODE_ENV: production NODE_ENV: production
TZ: UTC
DATABASE_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-postgres} DATABASE_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-postgres}
DB_DISABLE_SSL: "true" DB_DISABLE_SSL: "true"
AUTH_SECRET: ${AUTH_SECRET:?Set AUTH_SECRET in Coolify env} AUTH_SECRET: ${AUTH_SECRET:?Set AUTH_SECRET in Coolify env}
+1
View File
@@ -5,6 +5,7 @@ services:
POSTGRES_USER: ${POSTGRES_USER:-postgres} POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: ${POSTGRES_DB:-postgres} POSTGRES_DB: ${POSTGRES_DB:-postgres}
TZ: UTC
volumes: volumes:
- beenvoice_dev_pg_data:/var/lib/postgresql/data - beenvoice_dev_pg_data:/var/lib/postgresql/data
healthcheck: healthcheck:
+3
View File
@@ -20,6 +20,7 @@ services:
image: ${BEENVOICE_IMAGE:-beenvoice:local} image: ${BEENVOICE_IMAGE:-beenvoice:local}
environment: environment:
NODE_ENV: production NODE_ENV: production
TZ: UTC
AUTH_SECRET: ${AUTH_SECRET:?Set AUTH_SECRET in .env} AUTH_SECRET: ${AUTH_SECRET:?Set AUTH_SECRET in .env}
DATABASE_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-postgres} DATABASE_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-postgres}
DB_DISABLE_SSL: "true" DB_DISABLE_SSL: "true"
@@ -63,6 +64,7 @@ services:
image: ${BEENVOICE_WORKER_IMAGE:-beenvoice-worker:local} image: ${BEENVOICE_WORKER_IMAGE:-beenvoice-worker:local}
environment: environment:
NODE_ENV: production NODE_ENV: production
TZ: UTC
AUTH_SECRET: ${AUTH_SECRET:?Set AUTH_SECRET in .env} AUTH_SECRET: ${AUTH_SECRET:?Set AUTH_SECRET in .env}
DATABASE_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-postgres} DATABASE_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-postgres}
DB_DISABLE_SSL: "true" DB_DISABLE_SSL: "true"
@@ -85,6 +87,7 @@ services:
POSTGRES_USER: ${POSTGRES_USER:-postgres} POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: ${POSTGRES_DB:-postgres} POSTGRES_DB: ${POSTGRES_DB:-postgres}
TZ: UTC
volumes: volumes:
- beenvoice_pg_data:/var/lib/postgresql/data - beenvoice_pg_data:/var/lib/postgresql/data
healthcheck: healthcheck:
+33 -13
View File
@@ -4,33 +4,48 @@ export type EffectiveInvoiceStatus = StoredInvoiceStatus | "overdue";
export function getEffectiveInvoiceStatus( export function getEffectiveInvoiceStatus(
storedStatus: StoredInvoiceStatus, storedStatus: StoredInvoiceStatus,
dueDate: Date | string, dueDate: Date | string,
timeZone = getLocalTimeZone(),
now = new Date(),
): EffectiveInvoiceStatus { ): EffectiveInvoiceStatus {
if (storedStatus === "paid" || storedStatus === "draft") return storedStatus; if (storedStatus === "paid" || storedStatus === "draft") return storedStatus;
return calendarDateKey(dueDate) < zonedTodayKey(now, timeZone)
const today = new Date(); ? "overdue"
const due = new Date(dueDate); : "sent";
today.setHours(0, 0, 0, 0);
due.setHours(0, 0, 0, 0);
return due < today ? "overdue" : "sent";
} }
export function isInvoiceOverdue( export function isInvoiceOverdue(
storedStatus: StoredInvoiceStatus, storedStatus: StoredInvoiceStatus,
dueDate: Date | string, dueDate: Date | string,
timeZone = getLocalTimeZone(),
): boolean { ): boolean {
return getEffectiveInvoiceStatus(storedStatus, dueDate) === "overdue"; return (
getEffectiveInvoiceStatus(storedStatus, dueDate, timeZone) === "overdue"
);
} }
export function getDaysPastDue( export function getDaysPastDue(
storedStatus: StoredInvoiceStatus, storedStatus: StoredInvoiceStatus,
dueDate: Date | string, dueDate: Date | string,
timeZone = getLocalTimeZone(),
now = new Date(),
): number { ): number {
if (!isInvoiceOverdue(storedStatus, dueDate)) return 0; if (
const today = new Date(); getEffectiveInvoiceStatus(storedStatus, dueDate, timeZone, now) !==
const due = new Date(dueDate); "overdue"
today.setHours(0, 0, 0, 0); )
due.setHours(0, 0, 0, 0); return 0;
return Math.max(0, Math.ceil((today.getTime() - due.getTime()) / 86_400_000)); 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( export function getValidStatusTransitions(
@@ -52,3 +67,8 @@ export function isValidStatusTransition(
): boolean { ): boolean {
return getValidStatusTransitions(from).includes(to); 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 { export function isValidTimeZone(value: string): boolean {
if (!value.trim()) return false; if (!value.trim()) return false;
+77
View File
@@ -3,14 +3,17 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { import {
formatZonedDateTime, formatZonedDateTime,
addZonedCalendarInterval,
getDefaultScheduledSendAt, getDefaultScheduledSendAt,
isValidTimeZone, isValidTimeZone,
toLocalDateTimeInputValue, toLocalDateTimeInputValue,
zonedDateTimeToInstant,
} from "../src/time-zone"; } from "../src/time-zone";
import { import {
EXPENSE_CATEGORIES, EXPENSE_CATEGORIES,
formatElapsedSeconds, formatElapsedSeconds,
getEffectiveInvoiceStatus, getEffectiveInvoiceStatus,
getDaysPastDue,
parseReceiptText, parseReceiptText,
} from "../src"; } from "../src";
@@ -26,6 +29,17 @@ describe("shared domain behavior", () => {
expect(getEffectiveInvoiceStatus("paid", yesterday)).toBe("paid"); 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", () => { test("formats elapsed time", () => {
expect(formatElapsedSeconds(3_661)).toBe("01:01:01"); expect(formatElapsedSeconds(3_661)).toBe("01:01:01");
}); });
@@ -56,6 +70,69 @@ describe("time-zone helpers", () => {
expect(isValidTimeZone("not/a-zone")).toBe(false); 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", () => { test("rounds the default schedule to the next local hour", () => {
const result = getDefaultScheduledSendAt(new Date(2026, 7, 17, 10, 42, 19)); const result = getDefaultScheduledSendAt(new Date(2026, 7, 17, 10, 42, 19));
expect(toLocalDateTimeInputValue(result)).toBe("2026-08-17T11:00"); expect(toLocalDateTimeInputValue(result)).toBe("2026-08-17T11:00");