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