Compare commits

...
10 Commits
129 changed files with 7352 additions and 2345 deletions
+44 -1
View File
@@ -6,9 +6,30 @@ WORKDIR /app
FROM base AS install FROM base AS install
COPY package.json bun.lock ./ COPY package.json bun.lock ./
COPY apps/web/package.json apps/web/package.json COPY apps/web/package.json apps/web/package.json
COPY apps/worker/package.json apps/worker/package.json
COPY apps/mobile/package.json apps/mobile/package.json COPY apps/mobile/package.json apps/mobile/package.json
COPY packages/domain/package.json packages/domain/package.json COPY packages/domain/package.json packages/domain/package.json
RUN bun install --frozen-lockfile --filter @beenvoice/web COPY packages/email/package.json packages/email/package.json
RUN --mount=type=cache,target=/root/.bun/install/cache,sharing=locked \
for attempt in 1 2 3; do \
bun install --frozen-lockfile --filter @beenvoice/web && exit 0; \
echo "Bun install failed (attempt ${attempt}/3); retrying" >&2; \
done; \
exit 1
FROM base AS worker-install
COPY package.json bun.lock ./
COPY apps/web/package.json apps/web/package.json
COPY apps/worker/package.json apps/worker/package.json
COPY apps/mobile/package.json apps/mobile/package.json
COPY packages/domain/package.json packages/domain/package.json
COPY packages/email/package.json packages/email/package.json
RUN --mount=type=cache,target=/root/.bun/install/cache,sharing=locked \
for attempt in 1 2 3; do \
bun install --frozen-lockfile --filter @beenvoice/worker && exit 0; \
echo "Bun install failed (attempt ${attempt}/3); retrying" >&2; \
done; \
exit 1
# Next's production build runs under Node because Bun can fail during the # Next's production build runs under Node because Bun can fail during the
# page-data worker phase on Linux arm64. Dependencies still come from Bun. # page-data worker phase on Linux arm64. Dependencies still come from Bun.
@@ -43,6 +64,7 @@ COPY --from=install /app/apps/web/node_modules ./apps/web/node_modules
COPY --from=build /app/package.json ./package.json COPY --from=build /app/package.json ./package.json
COPY --from=build /app/apps/web/package.json ./apps/web/package.json COPY --from=build /app/apps/web/package.json ./apps/web/package.json
COPY --from=build /app/packages/domain ./packages/domain COPY --from=build /app/packages/domain ./packages/domain
COPY --from=build /app/packages/email ./packages/email
COPY --from=build /app/apps/web/.next ./apps/web/.next COPY --from=build /app/apps/web/.next ./apps/web/.next
COPY --from=build /app/apps/web/public ./apps/web/public COPY --from=build /app/apps/web/public ./apps/web/public
COPY --from=build /app/apps/web/drizzle.config.ts ./apps/web/drizzle.config.ts COPY --from=build /app/apps/web/drizzle.config.ts ./apps/web/drizzle.config.ts
@@ -54,4 +76,25 @@ RUN chmod -R a+rX apps/web/drizzle apps/web/public apps/web/src/server/db/migrat
USER bun USER bun
EXPOSE 3000 EXPOSE 3000
WORKDIR /app/apps/web WORKDIR /app/apps/web
HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=5 \
CMD bun -e 'const port = process.env.PORT || "3000"; const response = await fetch("http://127.0.0.1:" + port + "/api/health"); if (!response.ok) process.exit(1)'
CMD ["sh", "-c", "bun src/server/db/migrate.ts && bun run start"] CMD ["sh", "-c", "bun src/server/db/migrate.ts && bun run start"]
FROM base AS worker
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1
COPY --from=worker-install /app ./
COPY apps/web/src ./apps/web/src
COPY apps/web/tsconfig.json ./apps/web/tsconfig.json
COPY apps/worker ./apps/worker
COPY packages/domain ./packages/domain
COPY packages/email ./packages/email
RUN ln -s ../worker/node_modules apps/web/node_modules
USER bun
WORKDIR /app/apps/worker
CMD ["bun", "run", "start"]
# Keep the web application as the default Dockerfile target.
FROM release AS final
+9 -4
View File
@@ -8,9 +8,11 @@ Beenvoice is a freelancer and small-business invoicing platform with a Next.js w
beenvoice/ beenvoice/
├── apps/ ├── apps/
│ ├── web/ # Next.js dashboard, tRPC API, PostgreSQL/Drizzle │ ├── web/ # Next.js dashboard, tRPC API, PostgreSQL/Drizzle
── mobile/ # Expo Router mobile app and iOS widgets ── mobile/ # Expo Router mobile app and iOS widgets
│ └── worker/ # PostgreSQL-backed scheduler and background jobs
├── packages/ ├── packages/
── domain/ # Platform-neutral shared rules and parsing ── domain/ # Platform-neutral shared rules and parsing
│ └── email/ # Resend and SMTP/Mailpit delivery adapter
├── Dockerfile ├── Dockerfile
├── docker-compose*.yml ├── docker-compose*.yml
├── package.json ├── package.json
@@ -32,7 +34,7 @@ bun run --filter @beenvoice/web db:push
bun run dev bun run dev
``` ```
`bun run dev` starts Next.js on port 3000 and Expo Metro on port 8082. For a physical iPhone, set `EXPO_PUBLIC_API_URL` in `apps/mobile/.env` to a host the device can reach and configure the web app's canonical/auth URLs consistently. `bun run dev` starts Next.js on port 3000, Expo Metro on port 8082, and the background worker. For a physical iPhone, set `EXPO_PUBLIC_API_URL` in `apps/mobile/.env` to a host the device can reach and configure the web app's canonical/auth URLs consistently.
Useful workspace commands: Useful workspace commands:
@@ -42,6 +44,7 @@ bun run lint
bun run test bun run test
bun run build bun run build
bun run check bun run check
bun run email:preview # sends a PDF-bearing message to local Mailpit
``` ```
Run an app-specific command with a workspace filter: Run an app-specific command with a workspace filter:
@@ -61,7 +64,7 @@ git pull
./scripts/docker-deploy.sh ./scripts/docker-deploy.sh
``` ```
The root Dockerfile installs the frozen Bun workspace lockfile, builds the Next.js app under Node, and runs migrations plus the web server under Bun. The root Dockerfile installs the frozen Bun workspace lockfile and exposes separate `final` (web) and `worker` targets. The Compose stack starts both; the web container runs migrations before serving requests and the worker tolerates that short startup race.
## Documentation ## Documentation
@@ -69,7 +72,9 @@ The root Dockerfile installs the frozen Bun workspace lockfile, builds the Next.
- [Web architecture](./apps/web/docs/ARCHITECTURE.md) - [Web architecture](./apps/web/docs/ARCHITECTURE.md)
- [Mobile setup](./apps/mobile/README.md) - [Mobile setup](./apps/mobile/README.md)
- [Mobile architecture](./apps/mobile/docs/ARCHITECTURE.md) - [Mobile architecture](./apps/mobile/docs/ARCHITECTURE.md)
- [Worker architecture](./apps/worker/README.md)
- [Shared domain package](./packages/domain/README.md) - [Shared domain package](./packages/domain/README.md)
- [Email delivery package](./packages/email/README.md)
## Product concepts ## Product concepts
+1 -1
View File
@@ -10,7 +10,7 @@
"ios": { "ios": {
"supportsTablet": true, "supportsTablet": true,
"bundleIdentifier": "com.beenvoice.app", "bundleIdentifier": "com.beenvoice.app",
"buildNumber": "28", "buildNumber": "29",
"icon": "./assets/beenvoice.icon", "icon": "./assets/beenvoice.icon",
"infoPlist": { "infoPlist": {
"ITSAppUsesNonExemptEncryption": false, "ITSAppUsesNonExemptEncryption": false,
+64 -21
View File
@@ -1,12 +1,6 @@
import { router } from "expo-router"; import { router } from "expo-router";
import { useState } from "react"; import { useState } from "react";
import { import { Alert, ScrollView, StyleSheet, Text, View } from "react-native";
Alert,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { AppBackground } from "@/components/AppBackground"; import { AppBackground } from "@/components/AppBackground";
import { FilterChip } from "@/components/FilterChip"; import { FilterChip } from "@/components/FilterChip";
@@ -24,6 +18,10 @@ import { formatCurrency } from "@/lib/format";
import type { ThemeColors } from "@/lib/theme-palette"; import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles"; import { useThemedStyles } from "@/lib/use-themed-styles";
import { api } from "@/lib/trpc"; import { api } from "@/lib/trpc";
import {
BusinessBrandImage,
hasMobileBusinessBrandAsset,
} from "@/components/businesses/BusinessBrandImage";
type EntityTab = "clients" | "businesses"; type EntityTab = "clients" | "businesses";
@@ -48,7 +46,8 @@ export default function EntitiesScreen() {
const activeQuery = tab === "clients" ? clientsQuery : businessesQuery; const activeQuery = tab === "clients" ? clientsQuery : businessesQuery;
const isLoading = const isLoading =
clientsQuery.isLoading || (tab === "businesses" && businessesQuery.isLoading); clientsQuery.isLoading ||
(tab === "businesses" && businessesQuery.isLoading);
if (isLoading) { if (isLoading) {
return <LoadingScreen message="Loading…" />; return <LoadingScreen message="Loading…" />;
@@ -71,11 +70,16 @@ export default function EntitiesScreen() {
const businesses = businessesQuery.data ?? []; const businesses = businessesQuery.data ?? [];
function refresh() { function refresh() {
return tab === "clients" ? clientsQuery.refetch() : businessesQuery.refetch(); return tab === "clients"
? clientsQuery.refetch()
: businessesQuery.refetch();
} }
function confirmDelete(id: string, name: string) { function confirmDelete(id: string, name: string) {
Alert.alert(`Delete ${tab === "clients" ? "client" : "business"}?`, `Remove ${name}?`, [ Alert.alert(
`Delete ${tab === "clients" ? "client" : "business"}?`,
`Remove ${name}?`,
[
{ text: "Cancel", style: "cancel" }, { text: "Cancel", style: "cancel" },
{ {
text: "Delete", text: "Delete",
@@ -85,7 +89,8 @@ export default function EntitiesScreen() {
else deleteBusiness.mutate({ id }); else deleteBusiness.mutate({ id });
}, },
}, },
]); ],
);
} }
return ( return (
@@ -99,10 +104,7 @@ export default function EntitiesScreen() {
/> />
} }
refreshControl={ refreshControl={
<PullToRefresh <PullToRefresh onRefresh={refresh} tintColor={colors.primary} />
onRefresh={refresh}
tintColor={colors.primary}
/>
} }
> >
<ScrollView <ScrollView
@@ -141,7 +143,10 @@ export default function EntitiesScreen() {
icon: "create-outline", icon: "create-outline",
color: "#fff", color: "#fff",
backgroundColor: colors.primary, backgroundColor: colors.primary,
onPress: () => router.push(`/(app)/entities/clients/edit/${client.id}`), onPress: () =>
router.push(
`/(app)/entities/clients/edit/${client.id}`,
),
}, },
{ {
key: "delete", key: "delete",
@@ -152,7 +157,9 @@ export default function EntitiesScreen() {
onPress: () => confirmDelete(client.id, client.name), onPress: () => confirmDelete(client.id, client.name),
}, },
]} ]}
onPress={() => router.push(`/(app)/entities/clients/${client.id}`)} onPress={() =>
router.push(`/(app)/entities/clients/${client.id}`)
}
> >
<GlassSurface style={styles.card}> <GlassSurface style={styles.card}>
<View style={styles.cardInner}> <View style={styles.cardInner}>
@@ -162,7 +169,10 @@ export default function EntitiesScreen() {
) : null} ) : null}
{client.defaultHourlyRate != null ? ( {client.defaultHourlyRate != null ? (
<Text style={styles.meta}> <Text style={styles.meta}>
{formatCurrency(client.defaultHourlyRate, client.currency ?? "USD")} {formatCurrency(
client.defaultHourlyRate,
client.currency ?? "USD",
)}
/hr /hr
</Text> </Text>
) : null} ) : null}
@@ -190,7 +200,10 @@ export default function EntitiesScreen() {
icon: "create-outline", icon: "create-outline",
color: "#fff", color: "#fff",
backgroundColor: colors.primary, backgroundColor: colors.primary,
onPress: () => router.push(`/(app)/entities/businesses/edit/${business.id}`), onPress: () =>
router.push(
`/(app)/entities/businesses/edit/${business.id}`,
),
}, },
{ {
key: "delete", key: "delete",
@@ -201,10 +214,21 @@ export default function EntitiesScreen() {
onPress: () => confirmDelete(business.id, business.name), onPress: () => confirmDelete(business.id, business.name),
}, },
]} ]}
onPress={() => router.push(`/(app)/entities/businesses/${business.id}`)} onPress={() =>
router.push(`/(app)/entities/businesses/${business.id}`)
}
> >
<GlassSurface style={styles.card}> <GlassSurface style={styles.card}>
<View style={styles.cardInner}> <View style={styles.cardInner}>
<View style={styles.businessRow}>
{hasMobileBusinessBrandAsset(business) ? (
<BusinessBrandImage
business={business}
kind="icon"
style={styles.businessIcon}
/>
) : null}
<View style={styles.businessCopy}>
<View style={styles.nameRow}> <View style={styles.nameRow}>
<Text style={styles.name}>{business.name}</Text> <Text style={styles.name}>{business.name}</Text>
{business.isDefault ? ( {business.isDefault ? (
@@ -214,7 +238,11 @@ export default function EntitiesScreen() {
{business.nickname ? ( {business.nickname ? (
<Text style={styles.meta}>{business.nickname}</Text> <Text style={styles.meta}>{business.nickname}</Text>
) : null} ) : null}
{business.email ? <Text style={styles.meta}>{business.email}</Text> : null} {business.email ? (
<Text style={styles.meta}>{business.email}</Text>
) : null}
</View>
</View>
</View> </View>
</GlassSurface> </GlassSurface>
</SwipeableRow> </SwipeableRow>
@@ -257,6 +285,21 @@ const createEntitiesStyles = (colors: ThemeColors, isDark: boolean) =>
gap: spacing.sm, gap: spacing.sm,
flexWrap: "wrap", flexWrap: "wrap",
}, },
businessRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.md,
},
businessCopy: {
flex: 1,
minWidth: 0,
gap: 4,
},
businessIcon: {
width: 44,
height: 44,
borderRadius: 10,
},
name: { name: {
fontSize: 16, fontSize: 16,
fontFamily: fonts.bodySemiBold, fontFamily: fonts.bodySemiBold,
+83 -23
View File
@@ -1,9 +1,19 @@
import { router, Stack, useLocalSearchParams } from "expo-router"; import { router, Stack, useLocalSearchParams } from "expo-router";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Alert, Platform, ScrollView, StyleSheet, Text, View } from "react-native"; import {
Alert,
Platform,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { AppBackground } from "@/components/AppBackground"; import { AppBackground } from "@/components/AppBackground";
import { InvoiceViewChips, type InvoiceViewSection } from "@/components/invoices/InvoiceViewChips"; import {
InvoiceViewChips,
type InvoiceViewSection,
} from "@/components/invoices/InvoiceViewChips";
import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview"; import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
import { InvoiceTotals } from "@/components/invoices/InvoiceTotals"; import { InvoiceTotals } from "@/components/invoices/InvoiceTotals";
import { InvoiceDetailActions } from "@/components/invoices/InvoiceDetailActions"; import { InvoiceDetailActions } from "@/components/invoices/InvoiceDetailActions";
@@ -21,6 +31,7 @@ import { getInvoiceStatus, type InvoiceStatus } from "@/lib/invoice-status";
import { buildPreviewPdfInputFromInvoice } from "@/lib/invoice-pdf-input"; import { buildPreviewPdfInputFromInvoice } from "@/lib/invoice-pdf-input";
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets"; import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
import { api } from "@/lib/trpc"; import { api } from "@/lib/trpc";
import { formatZonedDateTime } from "@beenvoice/domain/time-zone";
export default function InvoiceDetailScreen() { export default function InvoiceDetailScreen() {
const styles = useThemedStyles(createInvoiceDetailStyles); const styles = useThemedStyles(createInvoiceDetailStyles);
@@ -53,7 +64,10 @@ export default function InvoiceDetailScreen() {
}); });
const previewInput = useMemo( const previewInput = useMemo(
() => (invoiceQuery.data ? buildPreviewPdfInputFromInvoice(invoiceQuery.data) : null), () =>
invoiceQuery.data
? buildPreviewPdfInputFromInvoice(invoiceQuery.data)
: null,
[invoiceQuery.data], [invoiceQuery.data],
); );
@@ -73,7 +87,11 @@ export default function InvoiceDetailScreen() {
<Text style={styles.errorText}> <Text style={styles.errorText}>
{invoiceQuery.error?.message ?? "Invoice not found"} {invoiceQuery.error?.message ?? "Invoice not found"}
</Text> </Text>
<Button title="Go back" variant="secondary" onPress={() => router.back()} /> <Button
title="Go back"
variant="secondary"
onPress={() => router.back()}
/>
</View> </View>
</AppBackground> </AppBackground>
); );
@@ -126,18 +144,22 @@ export default function InvoiceDetailScreen() {
} }
function promptStatusChange(current: InvoiceStatus) { function promptStatusChange(current: InvoiceStatus) {
const options: Array<{ label: string; status: "draft" | "sent" | "paid" }> = []; const options: Array<{ label: string; status: "draft" | "sent" | "paid" }> =
if (current !== "draft") options.push({ label: "Mark as draft", status: "draft" }); [];
if (current !== "draft")
options.push({ label: "Mark as draft", status: "draft" });
if (current !== "sent" && current !== "overdue") { if (current !== "sent" && current !== "overdue") {
options.push({ label: "Mark as sent", status: "sent" }); options.push({ label: "Mark as sent", status: "sent" });
} }
if (current !== "paid") options.push({ label: "Mark as paid", status: "paid" }); if (current !== "paid")
options.push({ label: "Mark as paid", status: "paid" });
if (options.length === 0) return; if (options.length === 0) return;
Alert.alert("Update status", "Choose a new status", [ Alert.alert("Update status", "Choose a new status", [
...options.map((option) => ({ ...options.map((option) => ({
text: option.label, text: option.label,
onPress: () => updateStatus.mutate({ id: invoice.id, status: option.status }), onPress: () =>
updateStatus.mutate({ id: invoice.id, status: option.status }),
})), })),
{ text: "Cancel", style: "cancel" }, { text: "Cancel", style: "cancel" },
]); ]);
@@ -148,8 +170,13 @@ export default function InvoiceDetailScreen() {
<Stack.Screen options={{ headerBackTitle: "Invoices" }} /> <Stack.Screen options={{ headerBackTitle: "Invoices" }} />
<ScrollView <ScrollView
style={styles.scroll} style={styles.scroll}
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]} contentContainerStyle={[
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "never" : undefined} styles.container,
{ paddingBottom: scrollPadding },
]}
contentInsetAdjustmentBehavior={
Platform.OS === "ios" ? "never" : undefined
}
scrollIndicatorInsets={{ bottom: scrollPadding }} scrollIndicatorInsets={{ bottom: scrollPadding }}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
> >
@@ -160,7 +187,9 @@ export default function InvoiceDetailScreen() {
{invoice.invoicePrefix} {invoice.invoicePrefix}
{invoice.invoiceNumber} {invoice.invoiceNumber}
</Text> </Text>
<Text style={styles.clientName}>{invoice.client?.name ?? "Client"}</Text> <Text style={styles.clientName}>
{invoice.client?.name ?? "Client"}
</Text>
</View> </View>
<StatusBadge status={status} /> <StatusBadge status={status} />
</View> </View>
@@ -184,8 +213,14 @@ export default function InvoiceDetailScreen() {
) : ( ) : (
<> <>
<Card title="Details"> <Card title="Details">
<DetailRow label="Business" value={invoice.business?.name ?? "—"} /> <DetailRow
<DetailRow label="Client" value={invoice.client?.name ?? "Client"} /> label="Business"
value={invoice.business?.name ?? "—"}
/>
<DetailRow
label="Client"
value={invoice.client?.name ?? "Client"}
/>
<DetailRow label="Issued" value={formatDate(invoice.issueDate)} /> <DetailRow label="Issued" value={formatDate(invoice.issueDate)} />
<DetailRow label="Due" value={formatDate(invoice.dueDate)} /> <DetailRow label="Due" value={formatDate(invoice.dueDate)} />
<DetailRow label="Currency" value={invoice.currency} /> <DetailRow label="Currency" value={invoice.currency} />
@@ -202,20 +237,32 @@ export default function InvoiceDetailScreen() {
} }
/> />
) : null} ) : null}
{invoice.scheduledSendStatus === "pending" &&
invoice.scheduledSendAt ? (
<DetailRow
label="Scheduled send"
value={formatZonedDateTime(
invoice.scheduledSendAt,
invoice.scheduledSendTimeZone ?? "UTC",
)}
/>
) : null}
</Card> </Card>
<Card title="Line items"> <Card title="Line items">
{invoice.items.length === 0 ? ( {invoice.items.length === 0 ? (
<Text style={styles.emptyLines}> <Text style={styles.emptyLines}>
No line items yet. Clock time to this invoice from the Timer tab, or edit to No line items yet. Clock time to this invoice from the Timer
add lines manually. tab, or edit to add lines manually.
</Text> </Text>
) : ( ) : (
invoice.items.map((item) => { invoice.items.map((item) => {
const line = ( const line = (
<View style={styles.lineItem}> <View style={styles.lineItem}>
<View style={styles.lineMeta}> <View style={styles.lineMeta}>
<Text style={styles.lineDescription}>{item.description}</Text> <Text style={styles.lineDescription}>
{item.description}
</Text>
<Text style={styles.lineSub}> <Text style={styles.lineSub}>
{formatDate(item.date)} · {item.hours}h ×{" "} {formatDate(item.date)} · {item.hours}h ×{" "}
{formatCurrency(item.rate, invoice.currency)} {formatCurrency(item.rate, invoice.currency)}
@@ -242,7 +289,8 @@ export default function InvoiceDetailScreen() {
icon: "create-outline", icon: "create-outline",
color: "#fff", color: "#fff",
backgroundColor: colors.primary, backgroundColor: colors.primary,
onPress: () => router.push(`/(app)/invoices/edit/${invoice.id}`), onPress: () =>
router.push(`/(app)/invoices/edit/${invoice.id}`),
}, },
]} ]}
> >
@@ -253,9 +301,13 @@ export default function InvoiceDetailScreen() {
)} )}
<InvoiceTotals <InvoiceTotals
subtotal={formatCurrency(subtotal, invoice.currency)} subtotal={formatCurrency(subtotal, invoice.currency)}
taxLabel={invoice.taxRate > 0 ? `Tax (${invoice.taxRate}%)` : undefined} taxLabel={
invoice.taxRate > 0 ? `Tax (${invoice.taxRate}%)` : undefined
}
taxAmount={ taxAmount={
invoice.taxRate > 0 ? formatCurrency(taxAmount, invoice.currency) : undefined invoice.taxRate > 0
? formatCurrency(taxAmount, invoice.currency)
: undefined
} }
total={formatCurrency(invoice.totalAmount, invoice.currency)} total={formatCurrency(invoice.totalAmount, invoice.currency)}
/> />
@@ -271,13 +323,17 @@ export default function InvoiceDetailScreen() {
status={status} status={status}
clientEmail={clientEmail} clientEmail={clientEmail}
onPaymentReminder={ onPaymentReminder={
status === "sent" || status === "overdue" ? promptPaymentReminder : undefined status === "sent" || status === "overdue"
? promptPaymentReminder
: undefined
} }
paymentReminderLoading={sendPaymentReminder.isPending} paymentReminderLoading={sendPaymentReminder.isPending}
onUpdateStatus={() => promptStatusChange(status)} onUpdateStatus={() => promptStatusChange(status)}
updateStatusLoading={updateStatus.isPending} updateStatusLoading={updateStatus.isPending}
onTrackTime={() => onTrackTime={() =>
router.push(`/(app)/timer?clientId=${invoice.clientId}&invoiceId=${invoice.id}`) router.push(
`/(app)/timer?clientId=${invoice.clientId}&invoiceId=${invoice.id}`,
)
} }
/> />
</> </>
@@ -291,8 +347,12 @@ function DetailRow({ label, value }: { label: string; value: string }) {
const { colors } = useAppTheme(); const { colors } = useAppTheme();
return ( return (
<View style={detailStyles.row}> <View style={detailStyles.row}>
<Text style={[detailStyles.label, { color: colors.mutedForeground }]}>{label}</Text> <Text style={[detailStyles.label, { color: colors.mutedForeground }]}>
<Text style={[detailStyles.value, { color: colors.foreground }]}>{value}</Text> {label}
</Text>
<Text style={[detailStyles.value, { color: colors.foreground }]}>
{value}
</Text>
</View> </View>
); );
} }
+75 -23
View File
@@ -20,7 +20,10 @@ import { InvoiceEditorFooter } from "@/components/invoices/InvoiceEditorFooter";
import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview"; import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
import { InvoiceSetupForm } from "@/components/invoices/InvoiceSetupForm"; import { InvoiceSetupForm } from "@/components/invoices/InvoiceSetupForm";
import { InvoiceTotals } from "@/components/invoices/InvoiceTotals"; import { InvoiceTotals } from "@/components/invoices/InvoiceTotals";
import { LineItemEditor, type EditableLineItem } from "@/components/invoices/LineItemEditor"; import {
LineItemEditor,
type EditableLineItem,
} from "@/components/invoices/LineItemEditor";
import { LoadingScreen } from "@/components/LoadingScreen"; import { LoadingScreen } from "@/components/LoadingScreen";
import { Card } from "@/components/ui/Card"; import { Card } from "@/components/ui/Card";
import { fonts, spacing } from "@/constants/theme"; import { fonts, spacing } from "@/constants/theme";
@@ -35,6 +38,7 @@ import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
import type { ThemeColors } from "@/lib/theme-palette"; import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles"; import { useThemedStyles } from "@/lib/use-themed-styles";
import { api } from "@/lib/trpc"; import { api } from "@/lib/trpc";
import { calendarDateFromLocalDate } from "@beenvoice/domain/time-zone";
export default function InvoiceEditScreen() { export default function InvoiceEditScreen() {
const { colors } = useAppTheme(); const { colors } = useAppTheme();
@@ -53,7 +57,9 @@ export default function InvoiceEditScreen() {
const [businessId, setBusinessId] = useState(""); const [businessId, setBusinessId] = useState("");
const [clientId, setClientId] = useState(""); const [clientId, setClientId] = useState("");
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
const [dueDate, setDueDate] = useState(() => new Date()); const [dueDate, setDueDate] = useState(() =>
calendarDateFromLocalDate(new Date()),
);
const [taxRate, setTaxRate] = useState("0"); const [taxRate, setTaxRate] = useState("0");
const [sendReminderAt, setSendReminderAt] = useState<Date | null>(null); const [sendReminderAt, setSendReminderAt] = useState<Date | null>(null);
const [items, setItems] = useState<EditableLineItem[]>([]); const [items, setItems] = useState<EditableLineItem[]>([]);
@@ -68,7 +74,9 @@ export default function InvoiceEditScreen() {
setNotes(invoice.notes ?? ""); setNotes(invoice.notes ?? "");
setDueDate(new Date(invoice.dueDate)); setDueDate(new Date(invoice.dueDate));
setTaxRate(String(invoice.taxRate)); setTaxRate(String(invoice.taxRate));
setSendReminderAt(invoice.sendReminderAt ? new Date(invoice.sendReminderAt) : null); setSendReminderAt(
invoice.sendReminderAt ? new Date(invoice.sendReminderAt) : null,
);
setItems( setItems(
invoice.items.map((item) => ({ invoice.items.map((item) => ({
id: item.id, id: item.id,
@@ -119,9 +127,14 @@ export default function InvoiceEditScreen() {
[clientsQuery.data], [clientsQuery.data],
); );
const selectedClient = clientsQuery.data?.find((client) => client.id === clientId); const selectedClient = clientsQuery.data?.find(
(client) => client.id === clientId,
);
const currency = selectedClient?.currency ?? invoice?.currency ?? "USD"; const currency = selectedClient?.currency ?? invoice?.currency ?? "USD";
const resolvedBusinessId = resolveInvoiceBusinessId(businessId, businessesQuery.data); const resolvedBusinessId = resolveInvoiceBusinessId(
businessId,
businessesQuery.data,
);
const subtotal = useMemo( const subtotal = useMemo(
() => () =>
@@ -137,8 +150,12 @@ export default function InvoiceEditScreen() {
const taxAmount = subtotal * (parsedTaxRate / 100); const taxAmount = subtotal * (parsedTaxRate / 100);
const total = subtotal + taxAmount; const total = subtotal + taxAmount;
const lineItemsError = isDraft ? validateLineItems(items) : null; const lineItemsError = isDraft ? validateLineItems(items) : null;
const taxError = isDraft && !isValidTaxRate(taxRate) ? "Tax rate must be between 0 and 100" : null; const taxError =
const businessError = isDraft && !resolvedBusinessId ? "Select a business" : undefined; isDraft && !isValidTaxRate(taxRate)
? "Tax rate must be between 0 and 100"
: null;
const businessError =
isDraft && !resolvedBusinessId ? "Select a business" : undefined;
const clientError = isDraft && !clientId ? "Select a client" : undefined; const clientError = isDraft && !clientId ? "Select a client" : undefined;
const canSave = isDraft const canSave = isDraft
? !lineItemsError && !taxError && !businessError && !clientError ? !lineItemsError && !taxError && !businessError && !clientError
@@ -159,13 +176,26 @@ export default function InvoiceEditScreen() {
currency, currency,
items, items,
}); });
}, [invoice, resolvedBusinessId, clientId, dueDate, notes, parsedTaxRate, currency, items]); }, [
invoice,
resolvedBusinessId,
clientId,
dueDate,
notes,
parsedTaxRate,
currency,
items,
]);
if (!id) { if (!id) {
return <LoadingScreen message="Invalid invoice" />; return <LoadingScreen message="Invalid invoice" />;
} }
if (invoiceQuery.isLoading || businessesQuery.isLoading || clientsQuery.isLoading) { if (
invoiceQuery.isLoading ||
businessesQuery.isLoading ||
clientsQuery.isLoading
) {
return <LoadingScreen message="Loading invoice…" />; return <LoadingScreen message="Loading invoice…" />;
} }
@@ -177,14 +207,16 @@ export default function InvoiceEditScreen() {
const clientEmail = invoice.client?.email?.trim() ?? ""; const clientEmail = invoice.client?.email?.trim() ?? "";
function updateItem(index: number, patch: Partial<EditableLineItem>) { function updateItem(index: number, patch: Partial<EditableLineItem>) {
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item))); setItems((prev) =>
prev.map((item, i) => (i === index ? { ...item, ...patch } : item)),
);
} }
function addItem() { function addItem() {
setItems((prev) => [ setItems((prev) => [
...prev, ...prev,
{ {
date: new Date(), date: calendarDateFromLocalDate(new Date()),
description: "", description: "",
hours: "1", hours: "1",
rate: prev[prev.length - 1]?.rate ?? "0", rate: prev[prev.length - 1]?.rate ?? "0",
@@ -260,8 +292,13 @@ export default function InvoiceEditScreen() {
style={styles.flex} style={styles.flex}
> >
<ScrollView <ScrollView
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]} contentContainerStyle={[
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : undefined} styles.container,
{ paddingBottom: scrollPadding },
]}
contentInsetAdjustmentBehavior={
Platform.OS === "ios" ? "automatic" : undefined
}
scrollIndicatorInsets={{ bottom: scrollPadding }} scrollIndicatorInsets={{ bottom: scrollPadding }}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
> >
@@ -316,13 +353,13 @@ export default function InvoiceEditScreen() {
<Card title="Line items"> <Card title="Line items">
{!isDraft ? ( {!isDraft ? (
<Text style={styles.lockedHint}> <Text style={styles.lockedHint}>
Line items are locked after an invoice is sent. Mark as draft on the invoice Line items are locked after an invoice is sent. Mark as
screen to edit entries. draft on the invoice screen to edit entries.
</Text> </Text>
) : items.length === 0 ? ( ) : items.length === 0 ? (
<Text style={styles.emptyLines}> <Text style={styles.emptyLines}>
No line items yet. Add lines here or clock time to this invoice from the No line items yet. Add lines here or clock time to this
Timer tab. invoice from the Timer tab.
</Text> </Text>
) : null} ) : null}
{items.map((item, index) => ( {items.map((item, index) => (
@@ -334,26 +371,40 @@ export default function InvoiceEditScreen() {
isLast={index === items.length - 1} isLast={index === items.length - 1}
onChange={(patch) => updateItem(index, patch)} onChange={(patch) => updateItem(index, patch)}
onRemove={() => removeItem(index)} onRemove={() => removeItem(index)}
onDuplicate={isDraft ? () => duplicateItem(index) : undefined} onDuplicate={
isDraft ? () => duplicateItem(index) : undefined
}
readOnly={!isDraft} readOnly={!isDraft}
/> />
))} ))}
{isDraft ? ( {isDraft ? (
<Pressable accessibilityRole="button" onPress={addItem} style={styles.addLine}> <Pressable
accessibilityRole="button"
onPress={addItem}
style={styles.addLine}
>
<Text style={styles.addLineText}>+ Add another line</Text> <Text style={styles.addLineText}>+ Add another line</Text>
</Pressable> </Pressable>
) : null} ) : null}
<InvoiceTotals <InvoiceTotals
subtotal={formatCurrency(subtotal, currency)} subtotal={formatCurrency(subtotal, currency)}
taxLabel={parsedTaxRate > 0 ? `Tax (${parsedTaxRate}%)` : undefined} taxLabel={
taxAmount={parsedTaxRate > 0 ? formatCurrency(taxAmount, currency) : undefined} parsedTaxRate > 0 ? `Tax (${parsedTaxRate}%)` : undefined
}
taxAmount={
parsedTaxRate > 0
? formatCurrency(taxAmount, currency)
: undefined
}
total={formatCurrency(total, currency)} total={formatCurrency(total, currency)}
/> />
</Card> </Card>
{lineItemsError ? <Text style={styles.error}>{lineItemsError}</Text> : null} {lineItemsError ? (
<Text style={styles.error}>{lineItemsError}</Text>
) : null}
</> </>
)} )}
@@ -367,7 +418,8 @@ export default function InvoiceEditScreen() {
secondary={ secondary={
status !== "paid" status !== "paid"
? { ? {
title: status === "draft" ? "Send invoice" : "Resend invoice", title:
status === "draft" ? "Send invoice" : "Resend invoice",
subtitle: clientEmail subtitle: clientEmail
? items.length === 0 ? items.length === 0
? "Add line items before sending" ? "Add line items before sending"
+62 -20
View File
@@ -20,7 +20,10 @@ import { InvoiceEditorFooter } from "@/components/invoices/InvoiceEditorFooter";
import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview"; import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
import { InvoiceSetupForm } from "@/components/invoices/InvoiceSetupForm"; import { InvoiceSetupForm } from "@/components/invoices/InvoiceSetupForm";
import { InvoiceTotals } from "@/components/invoices/InvoiceTotals"; import { InvoiceTotals } from "@/components/invoices/InvoiceTotals";
import { LineItemEditor, type EditableLineItem } from "@/components/invoices/LineItemEditor"; import {
LineItemEditor,
type EditableLineItem,
} from "@/components/invoices/LineItemEditor";
import { LoadingScreen } from "@/components/LoadingScreen"; import { LoadingScreen } from "@/components/LoadingScreen";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card"; import { Card } from "@/components/ui/Card";
@@ -39,6 +42,7 @@ import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
import type { ThemeColors } from "@/lib/theme-palette"; import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles"; import { useThemedStyles } from "@/lib/use-themed-styles";
import { api } from "@/lib/trpc"; import { api } from "@/lib/trpc";
import { calendarDateFromLocalDate } from "@beenvoice/domain/time-zone";
export default function NewInvoiceScreen() { export default function NewInvoiceScreen() {
const styles = useThemedStyles(createNewInvoiceStyles); const styles = useThemedStyles(createNewInvoiceStyles);
@@ -53,8 +57,12 @@ export default function NewInvoiceScreen() {
const [businessId, setBusinessId] = useState(""); const [businessId, setBusinessId] = useState("");
const [clientId, setClientId] = useState(""); const [clientId, setClientId] = useState("");
const [invoiceNumber, setInvoiceNumber] = useState(generateInvoiceNumber); const [invoiceNumber, setInvoiceNumber] = useState(generateInvoiceNumber);
const [issueDate, setIssueDate] = useState(() => new Date()); const [issueDate, setIssueDate] = useState(() =>
const [dueDate, setDueDate] = useState(() => defaultDueDate(new Date())); calendarDateFromLocalDate(new Date()),
);
const [dueDate, setDueDate] = useState(() =>
defaultDueDate(calendarDateFromLocalDate(new Date())),
);
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
const [taxRate, setTaxRate] = useState("0"); const [taxRate, setTaxRate] = useState("0");
const [items, setItems] = useState<EditableLineItem[]>(() => const [items, setItems] = useState<EditableLineItem[]>(() =>
@@ -62,7 +70,7 @@ export default function NewInvoiceScreen() {
? [] ? []
: [ : [
{ {
date: new Date(), date: calendarDateFromLocalDate(new Date()),
description: "", description: "",
hours: "1", hours: "1",
rate: "0", rate: "0",
@@ -96,9 +104,14 @@ export default function NewInvoiceScreen() {
[clientsQuery.data], [clientsQuery.data],
); );
const selectedClient = clientsQuery.data?.find((client) => client.id === clientId); const selectedClient = clientsQuery.data?.find(
(client) => client.id === clientId,
);
const currency = selectedClient?.currency ?? "USD"; const currency = selectedClient?.currency ?? "USD";
const resolvedBusinessId = resolveInvoiceBusinessId(businessId, businessesQuery.data); const resolvedBusinessId = resolveInvoiceBusinessId(
businessId,
businessesQuery.data,
);
useEffect(() => { useEffect(() => {
if (!selectedClient?.defaultHourlyRate) return; if (!selectedClient?.defaultHourlyRate) return;
@@ -170,7 +183,9 @@ export default function NewInvoiceScreen() {
const invoiceNumberError = isRequiredString(invoiceNumber) const invoiceNumberError = isRequiredString(invoiceNumber)
? undefined ? undefined
: "Invoice number is required"; : "Invoice number is required";
const taxError = isValidTaxRate(taxRate) ? undefined : "Tax rate must be between 0 and 100"; const taxError = isValidTaxRate(taxRate)
? undefined
: "Tax rate must be between 0 and 100";
const lineItemsError = validateLineItems(items); const lineItemsError = validateLineItems(items);
const canCreate = const canCreate =
businessOptions.length > 0 && businessOptions.length > 0 &&
@@ -187,7 +202,9 @@ export default function NewInvoiceScreen() {
function updateItem(index: number, patch: Partial<EditableLineItem>) { function updateItem(index: number, patch: Partial<EditableLineItem>) {
touch("lineItems"); touch("lineItems");
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item))); setItems((prev) =>
prev.map((item, i) => (i === index ? { ...item, ...patch } : item)),
);
} }
function addItem() { function addItem() {
@@ -195,7 +212,7 @@ export default function NewInvoiceScreen() {
setItems((prev) => [ setItems((prev) => [
...prev, ...prev,
{ {
date: new Date(), date: calendarDateFromLocalDate(new Date()),
description: "", description: "",
hours: "1", hours: "1",
rate: prev[prev.length - 1]?.rate ?? "0", rate: prev[prev.length - 1]?.rate ?? "0",
@@ -266,8 +283,13 @@ export default function NewInvoiceScreen() {
style={styles.flex} style={styles.flex}
> >
<ScrollView <ScrollView
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]} contentContainerStyle={[
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : undefined} styles.container,
{ paddingBottom: scrollPadding },
]}
contentInsetAdjustmentBehavior={
Platform.OS === "ios" ? "automatic" : undefined
}
scrollIndicatorInsets={{ bottom: scrollPadding }} scrollIndicatorInsets={{ bottom: scrollPadding }}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
> >
@@ -287,7 +309,11 @@ export default function NewInvoiceScreen() {
: "Add a client before creating an invoice."} : "Add a client before creating an invoice."}
</Text> </Text>
<Button <Button
title={businessOptions.length === 0 ? "Add business" : "Add client"} title={
businessOptions.length === 0
? "Add business"
: "Add client"
}
variant="secondary" variant="secondary"
onPress={() => onPress={() =>
router.push( router.push(
@@ -303,7 +329,9 @@ export default function NewInvoiceScreen() {
businessId={businessId} businessId={businessId}
onBusinessIdChange={setBusinessId} onBusinessIdChange={setBusinessId}
businessOptions={businessOptions} businessOptions={businessOptions}
businessError={visible("business") ? businessError : undefined} businessError={
visible("business") ? businessError : undefined
}
onBusinessBlur={() => touch("business")} onBusinessBlur={() => touch("business")}
clientId={clientId} clientId={clientId}
onClientIdChange={setClientId} onClientIdChange={setClientId}
@@ -334,8 +362,8 @@ export default function NewInvoiceScreen() {
<Card title="Line items"> <Card title="Line items">
{isBlank && items.length === 0 ? ( {isBlank && items.length === 0 ? (
<Text style={styles.emptyLines}> <Text style={styles.emptyLines}>
No line items yet. Save this draft and clock time to it from the Timer tab, No line items yet. Save this draft and clock time to it from
or add lines here. the Timer tab, or add lines here.
</Text> </Text>
) : null} ) : null}
{items.map((item, index) => ( {items.map((item, index) => (
@@ -351,27 +379,41 @@ export default function NewInvoiceScreen() {
/> />
))} ))}
<Pressable accessibilityRole="button" onPress={addItem} style={styles.addLine}> <Pressable
accessibilityRole="button"
onPress={addItem}
style={styles.addLine}
>
<Text style={styles.addLineText}>+ Add another line</Text> <Text style={styles.addLineText}>+ Add another line</Text>
</Pressable> </Pressable>
<InvoiceTotals <InvoiceTotals
subtotal={formatCurrency(subtotal, currency)} subtotal={formatCurrency(subtotal, currency)}
taxLabel={parsedTaxRate > 0 ? `Tax (${parsedTaxRate}%)` : undefined} taxLabel={
parsedTaxRate > 0 ? `Tax (${parsedTaxRate}%)` : undefined
}
taxAmount={ taxAmount={
parsedTaxRate > 0 ? formatCurrency(taxAmount, currency) : undefined parsedTaxRate > 0
? formatCurrency(taxAmount, currency)
: undefined
} }
total={formatCurrency(total, currency)} total={formatCurrency(total, currency)}
/> />
</Card> </Card>
{visible("lineItems") && lineItemsError ? ( {visible("lineItems") && lineItemsError ? (
<Text selectable style={styles.error}>{lineItemsError}</Text> <Text selectable style={styles.error}>
{lineItemsError}
</Text>
) : null} ) : null}
</> </>
)} )}
{error ? <Text selectable style={styles.error}>{error}</Text> : null} {error ? (
<Text selectable style={styles.error}>
{error}
</Text>
) : null}
<InvoiceEditorFooter <InvoiceEditorFooter
primaryTitle={isBlank ? "Create blank invoice" : "Create invoice"} primaryTitle={isBlank ? "Create blank invoice" : "Create invoice"}
+141 -7
View File
@@ -16,6 +16,14 @@ import { LoadingScreen } from "@/components/LoadingScreen";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card"; import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input"; import { Input } from "@/components/ui/Input";
import { DateTimeField } from "@/components/ui/DateTimeField";
import {
formatZonedDateTime,
getDefaultScheduledSendAt,
DEFAULT_TIME_ZONE,
toLocalDateTimeInputValue,
zonedDateTimeToInstant,
} from "@beenvoice/domain/time-zone";
import { fonts, spacing } from "@/constants/theme"; import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext"; import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency, formatDate } from "@/lib/format"; import { formatCurrency, formatDate } from "@/lib/format";
@@ -33,6 +41,11 @@ export default function InvoiceSendScreen() {
const utils = api.useUtils(); const utils = api.useUtils();
const scrollPadding = useTabBarScrollPadding(); const scrollPadding = useTabBarScrollPadding();
const [customMessage, setCustomMessage] = useState(""); const [customMessage, setCustomMessage] = useState("");
const [scheduledAt, setScheduledAt] = useState(() =>
getDefaultScheduledSendAt(),
);
const profileQuery = api.settings.getProfile.useQuery();
const timeZone = profileQuery.data?.timeZone ?? DEFAULT_TIME_ZONE;
const invoiceQuery = api.invoices.getById.useQuery( const invoiceQuery = api.invoices.getById.useQuery(
{ id: id ?? "" }, { id: id ?? "" },
@@ -51,9 +64,39 @@ export default function InvoiceSendScreen() {
onError: (err) => Alert.alert("Could not send invoice", err.message), onError: (err) => Alert.alert("Could not send invoice", err.message),
}); });
const scheduleInvoice = api.email.scheduleInvoice.useMutation({
onSuccess: async (data) => {
await utils.invoices.getById.invalidate({ id: id ?? "" });
await utils.invoices.getAll.invalidate();
Alert.alert(
"Invoice scheduled",
`It will send ${formatZonedDateTime(data.scheduledAt, data.timeZone)}.`,
[
{
text: "OK",
onPress: () => router.replace(`/(app)/invoices/${id}`),
},
],
);
},
onError: (err) => Alert.alert("Could not schedule invoice", err.message),
});
const cancelScheduledInvoice = api.email.cancelScheduledInvoice.useMutation({
onSuccess: async () => {
await utils.invoices.getById.invalidate({ id: id ?? "" });
await utils.invoices.getAll.invalidate();
Alert.alert("Scheduled send cancelled");
},
onError: (err) =>
Alert.alert("Could not cancel scheduled send", err.message),
});
const previewInput = useMemo( const previewInput = useMemo(
() => () =>
invoiceQuery.data ? buildPreviewPdfInputFromInvoice(invoiceQuery.data) : null, invoiceQuery.data
? buildPreviewPdfInputFromInvoice(invoiceQuery.data)
: null,
[invoiceQuery.data], [invoiceQuery.data],
); );
@@ -97,22 +140,63 @@ export default function InvoiceSendScreen() {
}); });
} }
function handleSchedule() {
if (!clientEmail || invoice.items.length === 0) return;
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.",
);
return;
}
scheduleInvoice.mutate({
invoiceId: invoice.id,
scheduledAt: instant,
timeZone,
customMessage: customMessage.trim() || undefined,
});
}
return ( return (
<AppBackground> <AppBackground>
<Stack.Screen options={{ title: sendLabel, headerBackTitle: "Invoice" }} /> <Stack.Screen
options={{ title: sendLabel, headerBackTitle: "Invoice" }}
/>
<KeyboardAvoidingView <KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined} behavior={Platform.OS === "ios" ? "padding" : undefined}
style={styles.flex} style={styles.flex}
> >
<ScrollView <ScrollView
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]} contentContainerStyle={[
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : undefined} styles.container,
{ paddingBottom: scrollPadding },
]}
contentInsetAdjustmentBehavior={
Platform.OS === "ios" ? "automatic" : undefined
}
scrollIndicatorInsets={{ bottom: scrollPadding }} scrollIndicatorInsets={{ bottom: scrollPadding }}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
> >
<Card title="Email summary"> <Card title="Email summary">
<SummaryRow label="From" value={businessName} /> <SummaryRow label="From" value={businessName} />
<SummaryRow label="To" value={clientEmail || "No client email on file"} /> <SummaryRow
label="To"
value={clientEmail || "No client email on file"}
/>
<SummaryRow <SummaryRow
label="Invoice" label="Invoice"
value={`${invoice.invoicePrefix}${invoice.invoiceNumber}`} value={`${invoice.invoicePrefix}${invoice.invoiceNumber}`}
@@ -130,7 +214,9 @@ export default function InvoiceSendScreen() {
</Card> </Card>
<Card title="Message"> <Card title="Message">
<Text style={[styles.messageHint, { color: colors.mutedForeground }]}> <Text
style={[styles.messageHint, { color: colors.mutedForeground }]}
>
Optional note included in the email body. Optional note included in the email body.
</Text> </Text>
<Input <Input
@@ -143,6 +229,52 @@ export default function InvoiceSendScreen() {
/> />
</Card> </Card>
{invoice.scheduledSendStatus === "pending" &&
invoice.scheduledSendAt ? (
<Card title="Scheduled send">
<Text
style={[styles.messageHint, { color: colors.mutedForeground }]}
>
{formatZonedDateTime(
invoice.scheduledSendAt,
invoice.scheduledSendTimeZone ?? timeZone,
)}{" "}
({invoice.scheduledSendTimeZone ?? timeZone})
</Text>
<Button
title="Cancel scheduled send"
variant="secondary"
onPress={() =>
cancelScheduledInvoice.mutate({ invoiceId: invoice.id })
}
loading={cancelScheduledInvoice.isPending}
/>
</Card>
) : null}
<Card title="Send later">
<DateTimeField
label="Send date and time"
value={scheduledAt}
minimumDate={new Date(Date.now() + 60_000)}
maximumDate={new Date(2100, 0, 1)}
onChange={setScheduledAt}
/>
<Text
style={[styles.messageHint, { color: colors.mutedForeground }]}
>
Time zone: {timeZone}. The exact instant is preserved across
devices and daylight saving changes.
</Text>
<Button
title="Schedule invoice"
variant="secondary"
onPress={handleSchedule}
loading={scheduleInvoice.isPending}
disabled={!clientEmail || invoice.items.length === 0}
/>
</Card>
<Button <Button
title={sendLabel} title={sendLabel}
onPress={handleSend} onPress={handleSend}
@@ -176,7 +308,9 @@ function SummaryRow({
const { colors } = useAppTheme(); const { colors } = useAppTheme();
return ( return (
<View style={summaryStyles.row}> <View style={summaryStyles.row}>
<Text style={[summaryStyles.label, { color: colors.mutedForeground }]}>{label}</Text> <Text style={[summaryStyles.label, { color: colors.mutedForeground }]}>
{label}
</Text>
<Text <Text
style={[ style={[
summaryStyles.value, summaryStyles.value,
+5 -2
View File
@@ -19,6 +19,7 @@ import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency, formatDate } from "@/lib/format"; import { formatCurrency, formatDate } from "@/lib/format";
import { scanReceiptImage, type ReceiptScanResult } from "@/lib/receipt-scan"; import { scanReceiptImage, type ReceiptScanResult } from "@/lib/receipt-scan";
import { api } from "@/lib/trpc"; import { api } from "@/lib/trpc";
import { calendarDateFromLocalDate } from "@beenvoice/domain/time-zone";
type ReceiptSplitDraft = Pick< type ReceiptSplitDraft = Pick<
ReceiptScanResult, ReceiptScanResult,
@@ -37,7 +38,7 @@ export default function ExpenseDetailScreen() {
const [form, setForm] = useState<ExpenseFormState>({ const [form, setForm] = useState<ExpenseFormState>({
description: "", description: "",
amountText: "", amountText: "",
date: new Date(), date: calendarDateFromLocalDate(new Date()),
category: "", category: "",
businessId: "", businessId: "",
clientId: "", clientId: "",
@@ -165,7 +166,9 @@ export default function ExpenseDetailScreen() {
return ( return (
<AppBackground> <AppBackground>
<TabPage showMoreBack> <TabPage showMoreBack>
<TabScrollView header={<PageHeader title="Expense" subtitle="Expense details" />}> <TabScrollView
header={<PageHeader title="Expense" subtitle="Expense details" />}
>
<Text style={{ color: colors.mutedForeground }}> <Text style={{ color: colors.mutedForeground }}>
Expense not found Expense not found
</Text> </Text>
+106 -34
View File
@@ -1,12 +1,7 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router"; import { router } from "expo-router";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { import { ScrollView, StyleSheet, Text, View } from "react-native";
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import type { AppRouter } from "beenvoice/server/api/root"; import type { AppRouter } from "beenvoice/server/api/root";
import type { inferRouterOutputs } from "@trpc/server"; import type { inferRouterOutputs } from "@trpc/server";
@@ -26,6 +21,7 @@ import { api } from "@/lib/trpc";
import { formatTrpcErrorMessage } from "@/lib/trpc-errors"; import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
import type { ThemeColors } from "@/lib/theme-palette"; import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles"; import { useThemedStyles } from "@/lib/use-themed-styles";
import { formatCalendarDate } from "@beenvoice/domain/time-zone";
type ExpenseFilter = "all" | "billable" | "receipts"; type ExpenseFilter = "all" | "billable" | "receipts";
type Expense = inferRouterOutputs<AppRouter>["expenses"]["getAll"][number]; type Expense = inferRouterOutputs<AppRouter>["expenses"]["getAll"][number];
@@ -120,17 +116,27 @@ export default function ExpensesScreen() {
<View <View
style={[ style={[
styles.emptyCard, styles.emptyCard,
{ borderColor: colors.border, backgroundColor: colors.cardGlass }, {
borderColor: colors.border,
backgroundColor: colors.cardGlass,
},
]} ]}
> >
<View style={[styles.emptyIcon, { backgroundColor: colors.muted }]}> <View
<Ionicons name="receipt-outline" size={24} color={colors.primary} /> style={[styles.emptyIcon, { backgroundColor: colors.muted }]}
>
<Ionicons
name="receipt-outline"
size={24}
color={colors.primary}
/>
</View> </View>
<Text style={[styles.emptyTitle, { color: colors.foreground }]}> <Text style={[styles.emptyTitle, { color: colors.foreground }]}>
No expenses yet No expenses yet
</Text> </Text>
<Text style={[styles.empty, { color: colors.mutedForeground }]}> <Text style={[styles.empty, { color: colors.mutedForeground }]}>
Scan a receipt or add a manual entry when something needs to be tracked, billed, or reimbursed. Scan a receipt or add a manual entry when something needs to be
tracked, billed, or reimbursed.
</Text> </Text>
<Button <Button
title="Add expense" title="Add expense"
@@ -140,9 +146,18 @@ export default function ExpensesScreen() {
) : ( ) : (
<> <>
<View style={styles.summaryGrid}> <View style={styles.summaryGrid}>
<SummaryTile label="Visible total" value={formatCurrency(summary.total)} /> <SummaryTile
<SummaryTile label="Billable" value={formatCurrency(summary.billable)} /> label="Visible total"
<SummaryTile label="Receipts" value={String(summary.receiptCount)} /> value={formatCurrency(summary.total)}
/>
<SummaryTile
label="Billable"
value={formatCurrency(summary.billable)}
/>
<SummaryTile
label="Receipts"
value={String(summary.receiptCount)}
/>
</View> </View>
<ScrollView <ScrollView
@@ -174,14 +189,21 @@ export default function ExpensesScreen() {
) : ( ) : (
groupedExpenses.map(([monthLabel, group]) => ( groupedExpenses.map(([monthLabel, group]) => (
<View key={monthLabel} style={styles.monthGroup}> <View key={monthLabel} style={styles.monthGroup}>
<Text style={[styles.monthLabel, { color: colors.mutedForeground }]}> <Text
style={[
styles.monthLabel,
{ color: colors.mutedForeground },
]}
>
{monthLabel} {monthLabel}
</Text> </Text>
{group.map((expense) => ( {group.map((expense) => (
<ExpenseRow <ExpenseRow
key={expense.id} key={expense.id}
expense={expense} expense={expense}
onDelete={() => deleteExpense.mutate({ id: expense.id })} onDelete={() =>
deleteExpense.mutate({ id: expense.id })
}
/> />
))} ))}
</View> </View>
@@ -209,14 +231,23 @@ function SummaryTile({ label, value }: { label: string; value: string }) {
<Text style={[styles.summaryLabel, { color: colors.mutedForeground }]}> <Text style={[styles.summaryLabel, { color: colors.mutedForeground }]}>
{label} {label}
</Text> </Text>
<Text style={[styles.summaryValue, { color: colors.foreground }]} numberOfLines={1}> <Text
style={[styles.summaryValue, { color: colors.foreground }]}
numberOfLines={1}
>
{value} {value}
</Text> </Text>
</View> </View>
); );
} }
function ExpenseRow({ expense, onDelete }: { expense: Expense; onDelete: () => void }) { function ExpenseRow({
expense,
onDelete,
}: {
expense: Expense;
onDelete: () => void;
}) {
const { colors } = useAppTheme(); const { colors } = useAppTheme();
const styles = useThemedStyles(createStyles); const styles = useThemedStyles(createStyles);
@@ -236,7 +267,8 @@ function ExpenseRow({ expense, onDelete }: { expense: Expense; onDelete: () => v
icon: "open-outline", icon: "open-outline",
color: "#fff", color: "#fff",
backgroundColor: colors.primary, backgroundColor: colors.primary,
onPress: () => router.push(`/(app)/more/expenses/${expense.id}` as never), onPress: () =>
router.push(`/(app)/more/expenses/${expense.id}` as never),
}, },
{ {
key: "delete", key: "delete",
@@ -249,45 +281,77 @@ function ExpenseRow({ expense, onDelete }: { expense: Expense; onDelete: () => v
]} ]}
> >
<View style={[styles.categoryIcon, { backgroundColor: colors.muted }]}> <View style={[styles.categoryIcon, { backgroundColor: colors.muted }]}>
<Ionicons name={expenseIcon(expense.category)} size={18} color={colors.primary} /> <Ionicons
name={expenseIcon(expense.category)}
size={18}
color={colors.primary}
/>
</View> </View>
<View style={styles.meta}> <View style={styles.meta}>
<View style={styles.titleRow}> <View style={styles.titleRow}>
<Text style={[styles.title, { color: colors.foreground }]} numberOfLines={1}> <Text
style={[styles.title, { color: colors.foreground }]}
numberOfLines={1}
>
{expense.description} {expense.description}
</Text> </Text>
{expense.receiptCount ? ( {expense.receiptCount ? (
<View <View
style={[ style={[
styles.receiptPill, styles.receiptPill,
{ borderColor: colors.border, backgroundColor: colors.background }, {
borderColor: colors.border,
backgroundColor: colors.background,
},
]} ]}
> >
<Ionicons name="document-attach-outline" size={13} color={colors.primary} /> <Ionicons
name="document-attach-outline"
size={13}
color={colors.primary}
/>
<Text style={[styles.receiptPillText, { color: colors.primary }]}> <Text style={[styles.receiptPillText, { color: colors.primary }]}>
{expense.receiptCount} {expense.receiptCount}
</Text> </Text>
</View> </View>
) : null} ) : null}
</View> </View>
<Text style={[styles.sub, { color: colors.mutedForeground }]} numberOfLines={1}> <Text
style={[styles.sub, { color: colors.mutedForeground }]}
numberOfLines={1}
>
{formatDate(expense.date)} {formatDate(expense.date)}
{expense.category ? ` · ${expense.category}` : ""} {expense.category ? ` · ${expense.category}` : ""}
{expense.client?.name ? ` · ${expense.client.name}` : ""} {expense.client?.name ? ` · ${expense.client.name}` : ""}
</Text> </Text>
<View style={styles.tagRow}> <View style={styles.tagRow}>
{expense.billable ? ( {expense.billable ? (
<Text style={[styles.tag, { color: colors.primary, borderColor: colors.border }]}> <Text
style={[
styles.tag,
{ color: colors.primary, borderColor: colors.border },
]}
>
Billable Billable
</Text> </Text>
) : null} ) : null}
{expense.reimbursable ? ( {expense.reimbursable ? (
<Text style={[styles.tag, { color: colors.foreground, borderColor: colors.border }]}> <Text
style={[
styles.tag,
{ color: colors.foreground, borderColor: colors.border },
]}
>
Reimbursable Reimbursable
</Text> </Text>
) : null} ) : null}
{expense.taxDeductible ? ( {expense.taxDeductible ? (
<Text style={[styles.tag, { color: colors.success, borderColor: colors.border }]}> <Text
style={[
styles.tag,
{ color: colors.success, borderColor: colors.border },
]}
>
Tax Tax
</Text> </Text>
) : null} ) : null}
@@ -297,7 +361,11 @@ function ExpenseRow({ expense, onDelete }: { expense: Expense; onDelete: () => v
<Text style={[styles.amount, { color: colors.foreground }]}> <Text style={[styles.amount, { color: colors.foreground }]}>
{formatCurrency(expense.amount, expense.currency)} {formatCurrency(expense.amount, expense.currency)}
</Text> </Text>
<Ionicons name="chevron-forward" size={16} color={colors.mutedForeground} /> <Ionicons
name="chevron-forward"
size={16}
color={colors.mutedForeground}
/>
</View> </View>
</SwipeableRow> </SwipeableRow>
); );
@@ -306,8 +374,7 @@ function ExpenseRow({ expense, onDelete }: { expense: Expense; onDelete: () => v
function groupExpensesByMonth(expenses: Expense[]) { function groupExpensesByMonth(expenses: Expense[]) {
const groups = new Map<string, Expense[]>(); const groups = new Map<string, Expense[]>();
for (const expense of expenses) { for (const expense of expenses) {
const date = new Date(expense.date); const key = formatCalendarDate(expense.date, {
const key = date.toLocaleDateString(undefined, {
month: "long", month: "long",
year: "numeric", year: "numeric",
}); });
@@ -320,11 +387,16 @@ function groupExpensesByMonth(expenses: Expense[]) {
function expenseIcon(category: string | null): keyof typeof Ionicons.glyphMap { function expenseIcon(category: string | null): keyof typeof Ionicons.glyphMap {
const normalized = category?.toLowerCase() ?? ""; const normalized = category?.toLowerCase() ?? "";
if (normalized.includes("travel") || normalized.includes("mileage")) return "airplane-outline"; if (normalized.includes("travel") || normalized.includes("mileage"))
if (normalized.includes("meal") || normalized.includes("food")) return "restaurant-outline"; return "airplane-outline";
if (normalized.includes("software") || normalized.includes("subscription")) return "laptop-outline"; if (normalized.includes("meal") || normalized.includes("food"))
if (normalized.includes("office") || normalized.includes("supply")) return "briefcase-outline"; return "restaurant-outline";
if (normalized.includes("phone") || normalized.includes("internet")) return "wifi-outline"; if (normalized.includes("software") || normalized.includes("subscription"))
return "laptop-outline";
if (normalized.includes("office") || normalized.includes("supply"))
return "briefcase-outline";
if (normalized.includes("phone") || normalized.includes("internet"))
return "wifi-outline";
return "receipt-outline"; return "receipt-outline";
} }
+3
View File
@@ -303,6 +303,9 @@ export default function SettingsScreen() {
Role: {profile.role} Role: {profile.role}
</Text> </Text>
) : null} ) : null}
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
Time zone: {profile?.timeZone ?? "America/New_York"}
</Text>
</Card> </Card>
<Card title="Accounts"> <Card title="Accounts">
+49 -15
View File
@@ -17,36 +17,53 @@ import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
import { api } from "@/lib/trpc"; import { api } from "@/lib/trpc";
import type { AppRouter } from "beenvoice/server/api/root"; import type { AppRouter } from "beenvoice/server/api/root";
import type { inferRouterOutputs } from "@trpc/server"; import type { inferRouterOutputs } from "@trpc/server";
import {
DEFAULT_TIME_ZONE,
getZonedDateTimeParts,
} from "@beenvoice/domain/time-zone";
type TimeEntry = inferRouterOutputs<AppRouter>["timeEntries"]["getAll"][number]; type TimeEntry = inferRouterOutputs<AppRouter>["timeEntries"]["getAll"][number];
function groupByDate(entries: TimeEntry[]) { function groupByDate(entries: TimeEntry[], timeZone: string) {
const groups = new Map<string, typeof entries>(); const groups = new Map<string, typeof entries>();
for (const entry of entries) { for (const entry of entries) {
const d = new Date(entry.startedAt); const d = new Date(entry.startedAt);
const key = d.toLocaleDateString(undefined, { const parts = getZonedDateTimeParts(d, timeZone);
const dateKey = `${parts.year}-${String(parts.month).padStart(2, "0")}-${String(parts.day).padStart(2, "0")}`;
const list = groups.get(dateKey) ?? [];
list.push(entry);
groups.set(dateKey, list);
}
return Array.from(groups.entries()).map(
([, groupedEntries]) =>
[
new Date(groupedEntries[0]!.startedAt).toLocaleDateString(undefined, {
weekday: "long", weekday: "long",
month: "long", month: "long",
day: "numeric", day: "numeric",
year: "numeric", year: "numeric",
}); timeZone,
const list = groups.get(key) ?? []; }),
list.push(entry); groupedEntries,
groups.set(key, list); ] as const,
} );
return Array.from(groups.entries());
} }
export default function TimeEntriesScreen() { export default function TimeEntriesScreen() {
const { colors } = useAppTheme(); const { colors } = useAppTheme();
const [editEntryId, setEditEntryId] = useState<string | null>(null); const [editEntryId, setEditEntryId] = useState<string | null>(null);
const entriesQuery = api.timeEntries.getAll.useQuery(); const entriesQuery = api.timeEntries.getAll.useQuery();
const profileQuery = api.settings.getProfile.useQuery();
const completed = useMemo( const completed = useMemo(
() => (entriesQuery.data ?? []).filter((entry) => entry.endedAt), () => (entriesQuery.data ?? []).filter((entry) => entry.endedAt),
[entriesQuery.data], [entriesQuery.data],
); );
const grouped = useMemo(() => groupByDate(completed), [completed]); const grouped = useMemo(
() =>
groupByDate(completed, profileQuery.data?.timeZone ?? DEFAULT_TIME_ZONE),
[completed, profileQuery.data?.timeZone],
);
if (entriesQuery.isLoading) { if (entriesQuery.isLoading) {
return <LoadingScreen message="Loading time entries…" />; return <LoadingScreen message="Loading time entries…" />;
@@ -57,7 +74,10 @@ export default function TimeEntriesScreen() {
<AppBackground> <AppBackground>
<TabPage showMoreBack> <TabPage showMoreBack>
<View style={styles.errorBox}> <View style={styles.errorBox}>
<PageHeader title="Time entries" subtitle="Completed work history" /> <PageHeader
title="Time entries"
subtitle="Completed work history"
/>
<Text style={{ color: colors.mutedForeground }}> <Text style={{ color: colors.mutedForeground }}>
{formatTrpcErrorMessage(entriesQuery.error)} {formatTrpcErrorMessage(entriesQuery.error)}
</Text> </Text>
@@ -72,7 +92,10 @@ export default function TimeEntriesScreen() {
<TabPage showMoreBack> <TabPage showMoreBack>
<TabScrollView <TabScrollView
header={ header={
<PageHeader title="Time entries" subtitle={`${completed.length} completed entries`} /> <PageHeader
title="Time entries"
subtitle={`${completed.length} completed entries`}
/>
} }
refreshControl={ refreshControl={
<PullToRefresh <PullToRefresh
@@ -82,7 +105,9 @@ export default function TimeEntriesScreen() {
} }
> >
{grouped.length === 0 ? ( {grouped.length === 0 ? (
<Text style={{ color: colors.mutedForeground, fontFamily: fonts.body }}> <Text
style={{ color: colors.mutedForeground, fontFamily: fonts.body }}
>
No completed entries yet. Start the timer from the Timer tab. No completed entries yet. Start the timer from the Timer tab.
</Text> </Text>
) : ( ) : (
@@ -104,17 +129,26 @@ export default function TimeEntriesScreen() {
> >
<View style={styles.row}> <View style={styles.row}>
<View style={{ flex: 1, gap: 2 }}> <View style={{ flex: 1, gap: 2 }}>
<Text style={[styles.title, { color: colors.foreground }]}> <Text
style={[styles.title, { color: colors.foreground }]}
>
{formatRunningTimerLabel(entry.description)} {formatRunningTimerLabel(entry.description)}
</Text> </Text>
<Text style={{ color: colors.mutedForeground, fontFamily: fonts.body }}> <Text
style={{
color: colors.mutedForeground,
fontFamily: fonts.body,
}}
>
{entry.client?.name ?? "No client"} {entry.client?.name ?? "No client"}
{entry.invoice {entry.invoice
? ` · ${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}` ? ` · ${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
: " · not billed"} : " · not billed"}
</Text> </Text>
</View> </View>
<Text style={[styles.title, { color: colors.foreground }]}> <Text
style={[styles.title, { color: colors.foreground }]}
>
{entry.hours ?? "—"}h {entry.hours ?? "—"}h
</Text> </Text>
</View> </View>
+41 -12
View File
@@ -1,12 +1,18 @@
import * as Notifications from "expo-notifications"; import * as Notifications from "expo-notifications";
import Constants from "expo-constants";
import { router } from "expo-router"; import { router } from "expo-router";
import { useEffect, useRef } from "react"; import { useEffect, useRef, useState } from "react";
import { AppState, type AppStateStatus } from "react-native"; import { AppState, Platform, type AppStateStatus } from "react-native";
import { syncInvoiceSendReminders } from "@/lib/invoice-send-reminders"; import {
ensureNotificationPermissions,
syncInvoiceSendReminders,
} from "@/lib/invoice-send-reminders";
import { api } from "@/lib/trpc"; import { api } from "@/lib/trpc";
function openInvoiceFromNotification(data: Record<string, unknown> | undefined) { function openInvoiceFromNotification(
data: Record<string, unknown> | undefined,
) {
if (data?.type !== "invoice-send-reminder") return; if (data?.type !== "invoice-send-reminder") return;
const invoiceId = data.invoiceId; const invoiceId = data.invoiceId;
if (typeof invoiceId !== "string" || !invoiceId) return; if (typeof invoiceId !== "string" || !invoiceId) return;
@@ -21,14 +27,37 @@ export function InvoiceReminderSync() {
{ staleTime: 60_000 }, { staleTime: 60_000 },
); );
const wasBackgrounded = useRef(false); const wasBackgrounded = useRef(false);
const [remotePushReady, setRemotePushReady] = useState(false);
const registerPushToken = api.notifications.registerPushToken.useMutation();
useEffect(() => {
if (Platform.OS !== "ios" && Platform.OS !== "android") return;
void (async () => {
if (!(await ensureNotificationPermissions())) return;
const projectId =
Constants.easConfig?.projectId ??
(Constants.expoConfig?.extra?.eas as { projectId?: string } | undefined)
?.projectId;
if (!projectId) return;
const { data: token } = await Notifications.getExpoPushTokenAsync({
projectId,
});
await registerPushToken.mutateAsync({ token, platform: Platform.OS });
setRemotePushReady(true);
})().catch(() => {
// Local reminders remain available when remote push registration is unavailable.
});
}, [registerPushToken]);
useEffect(() => { useEffect(() => {
if (!invoicesQuery.data) return; if (!invoicesQuery.data) return;
void syncInvoiceSendReminders(invoicesQuery.data); void syncInvoiceSendReminders(remotePushReady ? [] : invoicesQuery.data);
}, [invoicesQuery.data]); }, [invoicesQuery.data, remotePushReady]);
useEffect(() => { useEffect(() => {
const subscription = AppState.addEventListener("change", (nextState: AppStateStatus) => { const subscription = AppState.addEventListener(
"change",
(nextState: AppStateStatus) => {
if (nextState === "background" || nextState === "inactive") { if (nextState === "background" || nextState === "inactive") {
wasBackgrounded.current = true; wasBackgrounded.current = true;
return; return;
@@ -37,19 +66,19 @@ export function InvoiceReminderSync() {
if (nextState !== "active" || !wasBackgrounded.current) return; if (nextState !== "active" || !wasBackgrounded.current) return;
wasBackgrounded.current = false; wasBackgrounded.current = false;
void utils.invoices.getAll.invalidate({ status: "draft" }); void utils.invoices.getAll.invalidate({ status: "draft" });
}); },
);
return () => subscription.remove(); return () => subscription.remove();
}, [utils.invoices.getAll]); }, [utils.invoices.getAll]);
useEffect(() => { useEffect(() => {
const responseSubscription = Notifications.addNotificationResponseReceivedListener( const responseSubscription =
(response) => { Notifications.addNotificationResponseReceivedListener((response) => {
openInvoiceFromNotification( openInvoiceFromNotification(
response.notification.request.content.data as Record<string, unknown>, response.notification.request.content.data as Record<string, unknown>,
); );
}, });
);
void Notifications.getLastNotificationResponseAsync().then((response) => { void Notifications.getLastNotificationResponseAsync().then((response) => {
if (!response) return; if (!response) return;
+24 -2
View File
@@ -8,6 +8,11 @@ import { Logo } from "@/components/Logo";
import { fonts, radii, spacing } from "@/constants/theme"; import { fonts, radii, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext"; import { useAppTheme } from "@/contexts/ThemeContext";
import { TOP_CHROME_ROW_HEIGHT } from "@/lib/top-chrome-insets"; import { TOP_CHROME_ROW_HEIGHT } from "@/lib/top-chrome-insets";
import {
BusinessBrandImage,
hasMobileBusinessBrandAsset,
} from "@/components/businesses/BusinessBrandImage";
import { api } from "@/lib/trpc";
type TopChromeProps = { type TopChromeProps = {
showMoreBack?: boolean; showMoreBack?: boolean;
@@ -16,6 +21,7 @@ type TopChromeProps = {
/** Wordmark left, account switcher right — sits on TopChromeBar blur. */ /** Wordmark left, account switcher right — sits on TopChromeBar blur. */
export function TopChrome({ showMoreBack = false }: TopChromeProps) { export function TopChrome({ showMoreBack = false }: TopChromeProps) {
const { colors, isDark } = useAppTheme(); const { colors, isDark } = useAppTheme();
const defaultBusiness = api.businesses.getDefault.useQuery();
function handleBack() { function handleBack() {
if (router.canGoBack()) { if (router.canGoBack()) {
@@ -34,13 +40,25 @@ export function TopChrome({ showMoreBack = false }: TopChromeProps) {
onPress={handleBack} onPress={handleBack}
style={({ pressed }) => [ style={({ pressed }) => [
styles.backButton, styles.backButton,
{ borderColor: colors.borderGlass, backgroundColor: colors.cardGlass }, {
borderColor: colors.borderGlass,
backgroundColor: colors.cardGlass,
},
pressed && styles.pressed, pressed && styles.pressed,
]} ]}
> >
<Ionicons name="chevron-back" size={18} color={colors.foreground} /> <Ionicons name="chevron-back" size={18} color={colors.foreground} />
<Text style={[styles.backLabel, { color: colors.foreground }]}>More</Text> <Text style={[styles.backLabel, { color: colors.foreground }]}>
More
</Text>
</Pressable> </Pressable>
) : defaultBusiness.data &&
hasMobileBusinessBrandAsset(defaultBusiness.data) ? (
<BusinessBrandImage
business={defaultBusiness.data}
kind="wordmark"
style={styles.businessWordmark}
/>
) : ( ) : (
<Logo size="xs" onDark={isDark} /> <Logo size="xs" onDark={isDark} />
)} )}
@@ -66,6 +84,10 @@ const styles = StyleSheet.create({
borderWidth: 1, borderWidth: 1,
borderRadius: radii.pill, borderRadius: radii.pill,
}, },
businessWordmark: {
width: 132,
height: 32,
},
backLabel: { backLabel: {
fontFamily: fonts.bodySemiBold, fontFamily: fonts.bodySemiBold,
fontSize: 13, fontSize: 13,
@@ -0,0 +1,53 @@
import { Image, type ImageStyle, type StyleProp } from "react-native";
import { useAccounts } from "@/contexts/AccountsContext";
import { useAppTheme } from "@/contexts/ThemeContext";
import { hasBusinessBrandAsset } from "@beenvoice/domain/brand-assets";
type BrandAssetKind = "logo" | "wordmark" | "icon";
type BrandAssetTheme = "light" | "dark";
type BusinessBrandImageProps = {
business: {
id: string;
name?: string | null;
logoStorageKey?: string | null;
logoDarkStorageKey?: string | null;
wordmarkLightStorageKey?: string | null;
wordmarkDarkStorageKey?: string | null;
iconLightStorageKey?: string | null;
iconDarkStorageKey?: string | null;
};
kind?: BrandAssetKind;
theme?: BrandAssetTheme;
style?: StyleProp<ImageStyle>;
};
export function hasMobileBusinessBrandAsset(
business: BusinessBrandImageProps["business"],
) {
return hasBusinessBrandAsset(business);
}
export function BusinessBrandImage({
business,
kind = "icon",
theme: themeOverride,
style,
}: BusinessBrandImageProps) {
const { apiUrl } = useAccounts();
const { isDark } = useAppTheme();
if (!hasMobileBusinessBrandAsset(business)) return null;
const theme = themeOverride ?? (isDark ? "dark" : "light");
const base = apiUrl.replace(/\/$/, "");
return (
<Image
source={{
uri: `${base}/api/business-logo/${business.id}?kind=${kind}&theme=${theme}&format=png`,
}}
accessibilityLabel={`${business.name ?? "Business"} ${kind}`}
resizeMode="contain"
style={style}
/>
);
}
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import * as ImagePicker from "expo-image-picker";
import { import {
Alert, Alert,
KeyboardAvoidingView, KeyboardAvoidingView,
@@ -19,6 +20,12 @@ import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles"; import { useThemedStyles } from "@/lib/use-themed-styles";
import { isRequiredString, useFieldVisibility } from "@/lib/form-validation"; import { isRequiredString, useFieldVisibility } from "@/lib/form-validation";
import { api } from "@/lib/trpc"; import { api } from "@/lib/trpc";
import { BusinessBrandImage } from "@/components/businesses/BusinessBrandImage";
import {
getBrandAssetFieldNames,
type BrandAssetKind,
type BrandAssetTheme,
} from "@beenvoice/domain/brand-assets";
type BusinessFormValues = { type BusinessFormValues = {
name: string; name: string;
@@ -33,6 +40,7 @@ type BusinessFormValues = {
country: string; country: string;
website: string; website: string;
taxId: string; taxId: string;
hideNameWithLogo: boolean;
isDefault: boolean; isDefault: boolean;
}; };
@@ -49,9 +57,24 @@ const emptyValues: BusinessFormValues = {
country: "United States", country: "United States",
website: "", website: "",
taxId: "", taxId: "",
hideNameWithLogo: false,
isDefault: false, isDefault: false,
}; };
const MAX_BRAND_ASSET_BYTES = 5 * 1024 * 1024;
const BRAND_ASSET_SLOTS: Array<{
kind: BrandAssetKind;
theme: BrandAssetTheme;
label: string;
}> = [
{ kind: "logo", theme: "light", label: "Logo · light" },
{ kind: "logo", theme: "dark", label: "Logo · dark" },
{ kind: "wordmark", theme: "light", label: "Wordmark · light" },
{ kind: "wordmark", theme: "dark", label: "Wordmark · dark" },
{ kind: "icon", theme: "light", label: "Icon · light" },
{ kind: "icon", theme: "dark", label: "Icon · dark" },
];
type BusinessFormProps = { type BusinessFormProps = {
mode: "create" | "edit"; mode: "create" | "edit";
businessId?: string; businessId?: string;
@@ -78,6 +101,7 @@ export function BusinessForm({
const [values, setValues] = useState<BusinessFormValues>(emptyValues); const [values, setValues] = useState<BusinessFormValues>(emptyValues);
const [fieldError, setFieldError] = useState<string | null>(null); const [fieldError, setFieldError] = useState<string | null>(null);
const [uploadingAsset, setUploadingAsset] = useState<string | null>(null);
const { touch, visible, markSubmitted } = useFieldVisibility(); const { touch, visible, markSubmitted } = useFieldVisibility();
const switchProps = { const switchProps = {
@@ -102,6 +126,7 @@ export function BusinessForm({
country: business.country ?? "United States", country: business.country ?? "United States",
website: business.website ?? "", website: business.website ?? "",
taxId: business.taxId ?? "", taxId: business.taxId ?? "",
hideNameWithLogo: business.hideNameWithLogo ?? false,
isDefault: business.isDefault ?? false, isDefault: business.isDefault ?? false,
}); });
}, [businessQuery.data]); }, [businessQuery.data]);
@@ -117,7 +142,8 @@ export function BusinessForm({
const updateBusiness = api.businesses.update.useMutation({ const updateBusiness = api.businesses.update.useMutation({
onSuccess: () => { onSuccess: () => {
void utils.businesses.getAll.invalidate(); void utils.businesses.getAll.invalidate();
if (businessId) void utils.businesses.getById.invalidate({ id: businessId }); if (businessId)
void utils.businesses.getById.invalidate({ id: businessId });
onSaved(); onSaved();
}, },
onError: (err) => setFieldError(err.message), onError: (err) => setFieldError(err.message),
@@ -130,8 +156,68 @@ export function BusinessForm({
}, },
onError: (err) => Alert.alert("Could not delete business", err.message), onError: (err) => Alert.alert("Could not delete business", err.message),
}); });
const uploadLogo = api.businesses.uploadLogo.useMutation({
onSuccess: () => {
if (businessId)
void utils.businesses.getById.invalidate({ id: businessId });
},
onError: (err) => Alert.alert("Could not upload brand asset", err.message),
onSettled: () => setUploadingAsset(null),
});
const removeLogo = api.businesses.removeLogo.useMutation({
onSuccess: () => {
if (businessId)
void utils.businesses.getById.invalidate({ id: businessId });
},
onError: (err) => Alert.alert("Could not remove brand asset", err.message),
});
function patch<K extends keyof BusinessFormValues>(field: K, value: BusinessFormValues[K]) { async function pickBrandAsset(kind: BrandAssetKind, theme: BrandAssetTheme) {
if (!businessId) return;
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!permission.granted) {
Alert.alert(
"Photos access needed",
"Allow photo access to upload branding.",
);
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ["images"],
quality: 1,
base64: true,
});
if (result.canceled) return;
const asset = result.assets[0];
if (!asset?.base64) return;
const mimeType = asset.mimeType ?? "image/jpeg";
if (!["image/png", "image/jpeg", "image/webp"].includes(mimeType)) {
Alert.alert(
"Unsupported image",
"Mobile supports PNG, JPEG, and WebP. Upload SVG assets from the web app.",
);
return;
}
if (asset.fileSize && asset.fileSize > MAX_BRAND_ASSET_BYTES) {
Alert.alert("Image too large", "Brand assets must be 5MB or less.");
return;
}
const key = `${kind}-${theme}`;
setUploadingAsset(key);
uploadLogo.mutate({
id: businessId,
kind,
theme,
filename: asset.fileName ?? `${key}.jpg`,
mimeType,
data: asset.base64,
});
}
function patch<K extends keyof BusinessFormValues>(
field: K,
value: BusinessFormValues[K],
) {
setValues((prev) => ({ ...prev, [field]: value })); setValues((prev) => ({ ...prev, [field]: value }));
setFieldError(null); setFieldError(null);
} }
@@ -150,6 +236,7 @@ export function BusinessForm({
country: values.country.trim() || "United States", country: values.country.trim() || "United States",
website: values.website.trim(), website: values.website.trim(),
taxId: values.taxId.trim(), taxId: values.taxId.trim(),
hideNameWithLogo: values.hideNameWithLogo,
isDefault: values.isDefault, isDefault: values.isDefault,
}; };
} }
@@ -186,7 +273,9 @@ export function BusinessForm({
} }
const saving = createBusiness.isPending || updateBusiness.isPending; const saving = createBusiness.isPending || updateBusiness.isPending;
const nameError = values.name.trim() ? undefined : "Business name is required"; const nameError = values.name.trim()
? undefined
: "Business name is required";
const canSave = isRequiredString(values.name); const canSave = isRequiredString(values.name);
return ( return (
@@ -195,8 +284,13 @@ export function BusinessForm({
style={styles.flex} style={styles.flex}
> >
<ScrollView <ScrollView
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]} contentContainerStyle={[
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : undefined} styles.container,
{ paddingBottom: scrollPadding },
]}
contentInsetAdjustmentBehavior={
Platform.OS === "ios" ? "automatic" : undefined
}
scrollIndicatorInsets={{ bottom: scrollPadding }} scrollIndicatorInsets={{ bottom: scrollPadding }}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
> >
@@ -247,7 +341,9 @@ export function BusinessForm({
<Text style={[styles.switchLabel, { color: colors.foreground }]}> <Text style={[styles.switchLabel, { color: colors.foreground }]}>
Default business Default business
</Text> </Text>
<Text style={[styles.switchHint, { color: colors.mutedForeground }]}> <Text
style={[styles.switchHint, { color: colors.mutedForeground }]}
>
Used for new invoices when none is selected Used for new invoices when none is selected
</Text> </Text>
</View> </View>
@@ -259,6 +355,108 @@ export function BusinessForm({
</View> </View>
</Card> </Card>
{mode === "edit" && businessQuery.data ? (
<Card title="Brand assets">
<Text style={[styles.brandHint, { color: colors.mutedForeground }]}>
Upload combined logos, wordmarks, and compact icons for light and
dark backgrounds. Missing variants fall back automatically.
</Text>
{BRAND_ASSET_SLOTS.map((slot) => {
const [storageField] = getBrandAssetFieldNames(
slot.kind,
slot.theme,
);
const hasAsset = Boolean(businessQuery.data[storageField]);
const key = `${slot.kind}-${slot.theme}`;
return (
<View key={key} style={styles.brandSlot}>
<View
style={[
styles.brandPreview,
{
backgroundColor:
slot.theme === "dark" ? "#09090b" : "#ffffff",
borderColor: colors.border,
},
]}
>
{hasAsset ? (
<BusinessBrandImage
business={businessQuery.data}
kind={slot.kind}
theme={slot.theme}
style={styles.brandImage}
/>
) : (
<Text
style={{
color:
slot.theme === "dark"
? "rgba(255,255,255,0.45)"
: "rgba(0,0,0,0.35)",
}}
>
No asset
</Text>
)}
</View>
<Text
style={[styles.brandLabel, { color: colors.foreground }]}
>
{slot.label}
</Text>
<View style={styles.brandActions}>
<Button
title={hasAsset ? "Replace" : "Upload"}
variant="secondary"
leftIcon="cloud-upload-outline"
loading={uploadingAsset === key}
disabled={Boolean(uploadingAsset)}
style={styles.brandAction}
onPress={() => void pickBrandAsset(slot.kind, slot.theme)}
/>
{hasAsset ? (
<Button
title="Remove"
variant="ghost"
leftIcon="trash-outline"
loading={removeLogo.isPending}
style={styles.brandAction}
onPress={() =>
removeLogo.mutate({
id: businessId!,
kind: slot.kind,
theme: slot.theme,
})
}
/>
) : null}
</View>
</View>
);
})}
<View style={styles.switchRow}>
<View style={styles.switchCopy}>
<Text
style={[styles.switchLabel, { color: colors.foreground }]}
>
Hide business name on invoices
</Text>
<Text
style={[styles.switchHint, { color: colors.mutedForeground }]}
>
Useful when the combined logo already includes the name
</Text>
</View>
<Switch
value={values.hideNameWithLogo}
onValueChange={(value) => patch("hideNameWithLogo", value)}
{...switchProps}
/>
</View>
</Card>
) : null}
<Card title="Address"> <Card title="Address">
<Input <Input
label="Address line 1" label="Address line 1"
@@ -270,8 +468,16 @@ export function BusinessForm({
value={values.addressLine2} value={values.addressLine2}
onChangeText={(v) => patch("addressLine2", v)} onChangeText={(v) => patch("addressLine2", v)}
/> />
<Input label="City" value={values.city} onChangeText={(v) => patch("city", v)} /> <Input
<Input label="State" value={values.state} onChangeText={(v) => patch("state", v)} /> label="City"
value={values.city}
onChangeText={(v) => patch("city", v)}
/>
<Input
label="State"
value={values.state}
onChangeText={(v) => patch("state", v)}
/>
<Input <Input
label="Postal code" label="Postal code"
value={values.postalCode} value={values.postalCode}
@@ -284,7 +490,11 @@ export function BusinessForm({
/> />
</Card> </Card>
{fieldError ? <Text selectable style={styles.error}>{fieldError}</Text> : null} {fieldError ? (
<Text selectable style={styles.error}>
{fieldError}
</Text>
) : null}
<View style={styles.actions}> <View style={styles.actions}>
<Button <Button
@@ -337,6 +547,38 @@ const createBusinessFormStyles = (colors: ThemeColors, _isDark: boolean) =>
actions: { actions: {
gap: spacing.sm, gap: spacing.sm,
}, },
brandHint: {
fontFamily: fonts.body,
fontSize: 13,
lineHeight: 18,
},
brandSlot: {
gap: spacing.sm,
paddingVertical: spacing.xs,
},
brandPreview: {
height: 92,
borderWidth: 1,
borderRadius: 12,
alignItems: "center",
justifyContent: "center",
padding: spacing.md,
},
brandImage: {
width: "100%",
height: "100%",
},
brandLabel: {
fontFamily: fonts.bodySemiBold,
fontSize: 14,
},
brandActions: {
flexDirection: "row",
gap: spacing.sm,
},
brandAction: {
flex: 1,
},
error: { error: {
color: colors.destructive, color: colors.destructive,
fontFamily: fonts.body, fontFamily: fonts.body,
@@ -6,6 +6,7 @@ import { SelectField, type SelectOption } from "@/components/ui/SelectField";
import { fonts, spacing } from "@/constants/theme"; import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext"; import { useAppTheme } from "@/contexts/ThemeContext";
import { EXPENSE_CATEGORIES } from "@/lib/expense-categories"; import { EXPENSE_CATEGORIES } from "@/lib/expense-categories";
import { calendarDateFromLocalDate } from "@beenvoice/domain/time-zone";
const NONE = "__none__"; const NONE = "__none__";
@@ -37,7 +38,7 @@ export function defaultExpenseFormState(
return { return {
description: "", description: "",
amountText: "", amountText: "",
date: new Date(), date: calendarDateFromLocalDate(new Date()),
category: "", category: "",
businessId: defaultBusinessId, businessId: defaultBusinessId,
clientId: "", clientId: "",
@@ -6,6 +6,7 @@ import { SelectField } from "@/components/ui/SelectField";
import { fonts, spacing } from "@/constants/theme"; import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext"; import { useAppTheme } from "@/contexts/ThemeContext";
import { defaultDueDate } from "@/lib/invoice-number"; import { defaultDueDate } from "@/lib/invoice-number";
import { formatCalendarDate } from "@beenvoice/domain/time-zone";
type SelectOption = { label: string; value: string }; type SelectOption = { label: string; value: string };
@@ -120,7 +121,9 @@ export function InvoiceSetupForm({
{invoiceNumberReadOnly ? ( {invoiceNumberReadOnly ? (
<View style={styles.readOnlyField}> <View style={styles.readOnlyField}>
<Text style={[styles.readOnlyLabel, { color: colors.mutedForeground }]}> <Text
style={[styles.readOnlyLabel, { color: colors.mutedForeground }]}
>
Invoice number Invoice number
</Text> </Text>
<Text style={[styles.readOnlyValue, { color: colors.foreground }]}> <Text style={[styles.readOnlyValue, { color: colors.foreground }]}>
@@ -141,11 +144,13 @@ export function InvoiceSetupForm({
{issueDateReadOnly ? ( {issueDateReadOnly ? (
<View style={styles.readOnlyField}> <View style={styles.readOnlyField}>
<Text style={[styles.readOnlyLabel, { color: colors.mutedForeground }]}> <Text
style={[styles.readOnlyLabel, { color: colors.mutedForeground }]}
>
Issue date Issue date
</Text> </Text>
<Text style={[styles.readOnlyValue, { color: colors.foreground }]}> <Text style={[styles.readOnlyValue, { color: colors.foreground }]}>
{issueDate.toLocaleDateString()} {formatCalendarDate(issueDate)}
</Text> </Text>
</View> </View>
) : ( ) : (
@@ -160,11 +165,18 @@ export function InvoiceSetupForm({
/> />
)} )}
<DateTimeField label="Due date" mode="date" value={dueDate} onChange={onDueDateChange} /> <DateTimeField
label="Due date"
mode="date"
value={dueDate}
onChange={onDueDateChange}
/>
{taxRateReadOnly ? ( {taxRateReadOnly ? (
<View style={styles.readOnlyField}> <View style={styles.readOnlyField}>
<Text style={[styles.readOnlyLabel, { color: colors.mutedForeground }]}> <Text
style={[styles.readOnlyLabel, { color: colors.mutedForeground }]}
>
Tax rate Tax rate
</Text> </Text>
<Text style={[styles.readOnlyValue, { color: colors.foreground }]}> <Text style={[styles.readOnlyValue, { color: colors.foreground }]}>
@@ -186,7 +198,7 @@ export function InvoiceSetupForm({
<> <>
<DateTimeField <DateTimeField
label="Remind me to send" label="Remind me to send"
mode="date" mode="datetime"
value={sendReminderAt ?? dueDate} value={sendReminderAt ?? dueDate}
minimumDate={new Date()} minimumDate={new Date()}
maximumDate={new Date(2100, 0, 1)} maximumDate={new Date(2100, 0, 1)}
+51 -12
View File
@@ -3,11 +3,22 @@ import DateTimePicker, {
type DateTimePickerEvent, type DateTimePickerEvent,
} from "@react-native-community/datetimepicker"; } from "@react-native-community/datetimepicker";
import { useState } from "react"; import { useState } from "react";
import { Modal, Platform, Pressable, StyleSheet, Text, View } from "react-native"; import {
Modal,
Platform,
Pressable,
StyleSheet,
Text,
View,
} from "react-native";
import { fonts, radii, spacing } from "@/constants/theme"; import { fonts, radii, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext"; import { useAppTheme } from "@/contexts/ThemeContext";
import { formatDate, formatDateTime } from "@/lib/format"; import { formatDate, formatDateTime } from "@/lib/format";
import {
calendarDateFromLocalDate,
calendarDateToLocalDate,
} from "@beenvoice/domain/time-zone";
type DateTimeFieldProps = { type DateTimeFieldProps = {
label: string; label: string;
@@ -31,17 +42,18 @@ export function DateTimeField({
const [draft, setDraft] = useState(value); const [draft, setDraft] = useState(value);
function openPicker() { function openPicker() {
setDraft(value); setDraft(mode === "date" ? calendarDateToLocalDate(value) : value);
setOpen(true); setOpen(true);
} }
function applyDate(next: Date) { function applyDate(next: Date) {
const normalized = mode === "date" ? calendarDateFromLocalDate(next) : next;
const clamped = const clamped =
next.getTime() > maximumDate.getTime() normalized.getTime() > maximumDate.getTime()
? maximumDate ? maximumDate
: minimumDate && next.getTime() < minimumDate.getTime() : minimumDate && normalized.getTime() < minimumDate.getTime()
? minimumDate ? minimumDate
: next; : normalized;
onChange(clamped); onChange(clamped);
} }
@@ -60,7 +72,9 @@ export function DateTimeField({
return ( return (
<View style={styles.wrapper}> <View style={styles.wrapper}>
<Text style={[styles.label, { color: colors.mutedForeground }]}>{label}</Text> <Text style={[styles.label, { color: colors.mutedForeground }]}>
{label}
</Text>
<Pressable <Pressable
accessible accessible
accessibilityLabel={`${label}, ${ accessibilityLabel={`${label}, ${
@@ -81,28 +95,53 @@ export function DateTimeField({
<Text style={[styles.value, { color: colors.foreground }]}> <Text style={[styles.value, { color: colors.foreground }]}>
{mode === "date" ? formatDate(value) : formatDateTime(value)} {mode === "date" ? formatDate(value) : formatDateTime(value)}
</Text> </Text>
<Ionicons name="calendar-outline" size={18} color={colors.mutedForeground} /> <Ionicons
name="calendar-outline"
size={18}
color={colors.mutedForeground}
/>
</Pressable> </Pressable>
{Platform.OS === "ios" ? ( {Platform.OS === "ios" ? (
<Modal visible={open} transparent animationType="slide" onRequestClose={() => setOpen(false)}> <Modal
visible={open}
transparent
animationType="slide"
onRequestClose={() => setOpen(false)}
>
<Pressable style={styles.backdrop} onPress={() => setOpen(false)}> <Pressable style={styles.backdrop} onPress={() => setOpen(false)}>
<Pressable <Pressable
style={[styles.sheet, { backgroundColor: colors.card }]} style={[styles.sheet, { backgroundColor: colors.card }]}
onPress={(event) => event.stopPropagation()} onPress={(event) => event.stopPropagation()}
> >
<View style={[styles.sheetHeader, { borderBottomColor: colors.border }]}> <View
style={[
styles.sheetHeader,
{ borderBottomColor: colors.border },
]}
>
<Pressable onPress={() => setOpen(false)}> <Pressable onPress={() => setOpen(false)}>
<Text style={[styles.sheetAction, { color: colors.mutedForeground }]}>Cancel</Text> <Text
style={[
styles.sheetAction,
{ color: colors.mutedForeground },
]}
>
Cancel
</Text>
</Pressable> </Pressable>
<Text style={[styles.sheetTitle, { color: colors.foreground }]}>{label}</Text> <Text style={[styles.sheetTitle, { color: colors.foreground }]}>
{label}
</Text>
<Pressable <Pressable
onPress={() => { onPress={() => {
applyDate(draft); applyDate(draft);
setOpen(false); setOpen(false);
}} }}
> >
<Text style={[styles.sheetAction, { color: colors.primary }]}>Done</Text> <Text style={[styles.sheetAction, { color: colors.primary }]}>
Done
</Text>
</Pressable> </Pressable>
</View> </View>
<DateTimePicker <DateTimePicker
+4 -2
View File
@@ -1,3 +1,5 @@
import { formatCalendarDate } from "@beenvoice/domain/time-zone";
export function formatCurrency(amount: number, currency = "USD") { export function formatCurrency(amount: number, currency = "USD") {
return new Intl.NumberFormat("en-US", { return new Intl.NumberFormat("en-US", {
style: "currency", style: "currency",
@@ -7,7 +9,7 @@ export function formatCurrency(amount: number, currency = "USD") {
} }
export function formatDate(date: Date | string) { export function formatDate(date: Date | string) {
return new Date(date).toLocaleDateString("en-US", { return formatCalendarDate(date, {
month: "short", month: "short",
day: "numeric", day: "numeric",
year: "numeric", year: "numeric",
@@ -15,7 +17,7 @@ export function formatDate(date: Date | string) {
} }
export function formatShortDate(date: Date | string) { export function formatShortDate(date: Date | string) {
return new Date(date).toLocaleDateString("en-US", { return formatCalendarDate(date, {
month: "short", month: "short",
day: "numeric", day: "numeric",
}); });
+3 -3
View File
@@ -1,3 +1,5 @@
import { addCalendarDays } from "@beenvoice/domain/time-zone";
/** Matches web invoice-form default numbering. */ /** Matches web invoice-form default numbering. */
export function generateInvoiceNumber(now = new Date()): string { export function generateInvoiceNumber(now = new Date()): string {
const date = [ const date = [
@@ -10,7 +12,5 @@ export function generateInvoiceNumber(now = new Date()): string {
} }
export function defaultDueDate(issueDate: Date): Date { export function defaultDueDate(issueDate: Date): Date {
const due = new Date(issueDate); return addCalendarDays(issueDate, 30);
due.setDate(due.getDate() + 30);
return due;
} }
+6 -1
View File
@@ -6,11 +6,16 @@ export type InvoiceStatus = EffectiveInvoiceStatus;
export function getInvoiceStatus(invoice: { export function getInvoiceStatus(invoice: {
status: string; status: string;
dueDate: Date | string; dueDate: Date | string;
createdBy?: { timeZone: string } | null;
}): InvoiceStatus { }): InvoiceStatus {
if (invoice.status === "paid" || invoice.status === "draft") { if (invoice.status === "paid" || invoice.status === "draft") {
return invoice.status; return invoice.status;
} }
return getEffectiveInvoiceStatus("sent", invoice.dueDate); return getEffectiveInvoiceStatus(
"sent",
invoice.dueDate,
invoice.createdBy?.timeZone ?? "America/New_York",
);
} }
export const statusLabels: Record<InvoiceStatus, string> = { export const statusLabels: Record<InvoiceStatus, string> = {
+20 -13
View File
@@ -3,6 +3,8 @@
import { afterEach, describe, expect, test } from "bun:test"; import { afterEach, describe, expect, test } from "bun:test";
import { import {
EXPENSE_CATEGORIES as domainExpenseCategories, EXPENSE_CATEGORIES as domainExpenseCategories,
addCalendarDays,
calendarDateFromInstant,
formatElapsedSeconds as formatDomainElapsedSeconds, formatElapsedSeconds as formatDomainElapsedSeconds,
getEffectiveInvoiceStatus, getEffectiveInvoiceStatus,
} from "@beenvoice/domain"; } from "@beenvoice/domain";
@@ -14,9 +16,7 @@ import { generateInvoiceNumber as generateMobileInvoiceNumber } from "../lib/inv
import { formatElapsedSeconds as formatAppElapsedSeconds } from "../lib/time-clock"; import { formatElapsedSeconds as formatAppElapsedSeconds } from "../lib/time-clock";
import { generateInvoiceNumber as generateWebInvoiceNumber } from "../../web/src/lib/draft-invoice"; import { generateInvoiceNumber as generateWebInvoiceNumber } from "../../web/src/lib/draft-invoice";
import { safeCallbackPath } from "../../web/src/lib/safe-callback-url"; import { safeCallbackPath } from "../../web/src/lib/safe-callback-url";
import { import { normalizeOptionalId } from "../../web/src/lib/time-clock";
normalizeOptionalId,
} from "../../web/src/lib/time-clock";
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;
@@ -52,15 +52,18 @@ describe("invoice parity", () => {
test("web and mobile use the device-local date in invoice numbers", () => { test("web and mobile use the device-local date in invoice numbers", () => {
const lateLocalEvening = new Date(2026, 7, 16, 23, 30, 0, 123); const lateLocalEvening = new Date(2026, 7, 16, 23, 30, 0, 123);
expect(generateMobileInvoiceNumber(lateLocalEvening)).toStartWith("INV-20260816-"); expect(generateMobileInvoiceNumber(lateLocalEvening)).toStartWith(
expect(generateWebInvoiceNumber(lateLocalEvening)).toStartWith("INV-20260816-"); "INV-20260816-",
);
expect(generateWebInvoiceNumber(lateLocalEvening)).toStartWith(
"INV-20260816-",
);
}); });
test("web and mobile agree on draft, paid, sent, and overdue states", () => { test("web and mobile agree on draft, paid, sent, and overdue states", () => {
const today = new Date(); const timeZone = "America/New_York";
today.setHours(0, 0, 0, 0); const today = calendarDateFromInstant(new Date(), timeZone);
const yesterday = new Date(today); const yesterday = addCalendarDays(today, -1);
yesterday.setDate(yesterday.getDate() - 1);
const fixtures = [ const fixtures = [
{ {
@@ -82,11 +85,15 @@ describe("invoice parity", () => {
]; ];
for (const fixture of fixtures) { for (const fixture of fixtures) {
expect(getEffectiveInvoiceStatus(fixture.stored, fixture.dueDate)).toBe(
fixture.expected,
);
expect( expect(
getInvoiceStatus({ status: fixture.stored, dueDate: fixture.dueDate }), getEffectiveInvoiceStatus(fixture.stored, fixture.dueDate, timeZone),
).toBe(fixture.expected);
expect(
getInvoiceStatus({
status: fixture.stored,
dueDate: fixture.dueDate,
createdBy: { timeZone },
}),
).toBe(fixture.expected); ).toBe(fixture.expected);
} }
}); });
+27 -5
View File
@@ -68,6 +68,20 @@ DB_DISABLE_SSL=true
POSTGRES_PORT=5432 POSTGRES_PORT=5432
GARAGE_API_PORT=3900 GARAGE_API_PORT=3900
# Coolify Compose container listener ports. APP_PORT is Coolify-only;
# POSTGRES_PORT and GARAGE_API_PORT are reused from above. The regular/dev
# compose files keep the database and Garage container ports at 5432/3900 and
# use those variables only for host-side mappings.
APP_PORT=3000
GARAGE_RPC_PORT=3901
GARAGE_WEB_PORT=3902
GARAGE_ADMIN_PORT=3903
# Garage requires a 64-character hexadecimal RPC secret. Generate with:
# openssl rand -hex 32
# GARAGE_RPC_SECRET=
# GARAGE_ADMIN_TOKEN=
# GARAGE_METRICS_TOKEN=
# Optional: if Next dev picks another port, you do not need to change URLs for # Optional: if Next dev picks another port, you do not need to change URLs for
# sign-in — the auth client uses window.location.origin in the browser. # sign-in — the auth client uses window.location.origin in the browser.
@@ -100,12 +114,19 @@ NEXT_PUBLIC_BRAND_LOGO_TEXT=beenvoice
NEXT_PUBLIC_BRAND_ICON=$ NEXT_PUBLIC_BRAND_ICON=$
# ============================================================================= # =============================================================================
# Email — Resend (optional) # Email — Mailpit locally, Resend in production
# ============================================================================= # =============================================================================
# Leave blank to disable invoice and password-reset email delivery. # Start local dependencies, then inspect messages at http://localhost:8028.
# Production must use EMAIL_PROVIDER=resend (Mailpit is rejected in production).
EMAIL_PROVIDER=mailpit
EMAIL_FROM=beenvoice <noreply@beenvoice.test>
SMTP_HOST=127.0.0.1
SMTP_PORT=1028
SMTP_SECURE=false
RESEND_API_KEY= RESEND_API_KEY=
RESEND_DOMAIN= RESEND_DOMAIN=
RESEND_FROM=
# ============================================================================= # =============================================================================
# Analytics — Umami (optional) # Analytics — Umami (optional)
@@ -138,7 +159,8 @@ NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.umami.is/script.js
# • Coolify — see docs/COOLIFY.md. Summary: # • Coolify — see docs/COOLIFY.md. Summary:
# - Best: one Compose resource with docker-compose.coolify.yml (app+db+garage). # - Best: one Compose resource with docker-compose.coolify.yml (app+db+garage).
# - Application + separate Garage: ENOTFOUND garage → set S3_ENDPOINT to # - Application + separate Garage: ENOTFOUND garage → set S3_ENDPOINT to
# SERVICE_URL_GARAGE_3900 (public domain) OR http://garage-<resource-uuid>:3900 # SERVICE_URL_GARAGE (public domain) OR
# http://garage-<resource-uuid>:<GARAGE_API_PORT>
# with Connect to Predefined Network on both resources. Never bare "garage". # with Connect to Predefined Network on both resources. Never bare "garage".
# - NEVER use localhost in production — inside the app container that is the app, not Garage. # - NEVER use localhost in production — inside the app container that is the app, not Garage.
# #
@@ -150,8 +172,8 @@ S3_SECRET_KEY=7d37d093435a41f2aab8f13c19ba067d9776c90215f56614adad6ece597dbb34
S3_REGION=garage S3_REGION=garage
# S3_FORCE_PATH_STYLE=true # default on when S3_ENDPOINT is set; required for Garage/HTTPS proxy # S3_FORCE_PATH_STYLE=true # default on when S3_ENDPOINT is set; required for Garage/HTTPS proxy
# #
# docker-compose.yml sets S3_ENDPOINT=http://garage:3900 inside the app container # docker-compose.yml uses http://garage:3900 internally. The Coolify compose
# automatically. S3_ACCESS_KEY / S3_SECRET_KEY must match the garage service env. # derives the internal URL from GARAGE_API_PORT. Credentials must match Garage.
# ============================================================================= # =============================================================================
# SSO — Authentik OIDC (optional) # SSO — Authentik OIDC (optional)
+33 -18
View File
@@ -10,13 +10,13 @@ Web application and API for **beenvoice** — invoicing for freelancers and smal
## Stack ## Stack
| Layer | Technology | | Layer | Technology |
|-------|------------| | ----------- | ------------------------------------------------------------------ |
| App | Next.js 16 App Router, React 19 | | App | Next.js 16 App Router, React 19 |
| API | tRPC 11 + SuperJSON | | API | tRPC 11 + SuperJSON |
| Database | PostgreSQL 17, Drizzle ORM | | Database | PostgreSQL 17, Drizzle ORM |
| Auth | better-auth (email/password, optional Authentik OIDC, Expo mobile) | | Auth | better-auth (email/password, optional Authentik OIDC, Expo mobile) |
| UI | shadcn/ui, Tailwind CSS v4 | | UI | shadcn/ui, Tailwind CSS v4 |
| Email / PDF | Resend, `@react-pdf/renderer` | | Email / PDF | Resend or SMTP/Mailpit, `@react-pdf/renderer` |
| Runtime | Bun | | Runtime | Bun |
## Features ## Features
@@ -24,7 +24,7 @@ Web application and API for **beenvoice** — invoicing for freelancers and smal
- Clients, businesses, invoices (line items, tax, status workflow) - Clients, businesses, invoices (line items, tax, status workflow)
- Time clock with one running timer per user; clock-out can append invoice lines - Time clock with one running timer per user; clock-out can append invoice lines
- Expenses, payments, recurring invoices, invoice templates - Expenses, payments, recurring invoices, invoice templates
- PDF export and email delivery (Resend) - PDF export and email delivery (Resend in production, Mailpit locally)
- Public invoice links (`/i/[token]`) - Public invoice links (`/i/[token]`)
- CSV import, reports, platform branding / admin settings - CSV import, reports, platform branding / admin settings
- MCP API (`/api/mcp`) for automation via API keys (`bv_…`) - MCP API (`/api/mcp`) for automation via API keys (`bv_…`)
@@ -62,7 +62,13 @@ BETTER_AUTH_URL=http://localhost:3000
NEXT_PUBLIC_APP_URL=http://localhost:3000 NEXT_PUBLIC_APP_URL=http://localhost:3000
``` ```
Email and SSO are optional for local work — leave `RESEND_*` and `AUTHENTIK_*` blank unless you need them. SSO is optional for local work. Email defaults to Mailpit: start the development
Compose services and open `http://localhost:8028` to inspect messages.
```bash
bun run --filter @beenvoice/web docker:up
bun run email:preview
```
### 3. Database ### 3. Database
@@ -145,15 +151,22 @@ App listens on `${WEB_PORT:-${PORT:-3000}}` on the host (container port is alway
### Scheduled recurring invoices ### Scheduled recurring invoices
The app container does not run a cron daemon. It starts the web server with The Compose stack includes a dedicated PostgreSQL-backed worker. It discovers due
`bun migrate.ts && bun run start`, and recurring invoice generation only happens recurring invoices every minute, enqueues idempotent jobs, and processes them with
when something calls `POST /api/cron/generate-recurring` with bounded retries and stale-lock recovery. No Coolify scheduled task or Redis
`Authorization: Bearer $CRON_SECRET`. service is required.
- **Coolify deploys:** use a Coolify scheduled task to call the endpoint. `POST /api/cron/generate-recurring` remains available as an optional authenticated
- **Full Docker deploys:** use host cron, a small scheduler sidecar, or an "schedule now" hook. It only enqueues due work; invoice generation stays in the
external scheduler to call worker.
`http://localhost:${WEB_PORT:-${PORT:-3000}}/api/cron/generate-recurring`.
### Scheduled invoice delivery
The web and mobile send screens can enqueue invoice email for a future date and
time. Each job stores an absolute instant and the originating IANA timezone,
supports rescheduling or cancellation before the worker claims it, and uses a
Resend idempotency key during bounded retries. The worker reaches the web app at
`APP_INTERNAL_URL` using `CRON_SECRET`; Compose configures the internal URL.
### 3. Updating an existing deploy ### 3. Updating an existing deploy
@@ -164,13 +177,13 @@ git pull
``` ```
| Command | New code? | Migrations run? | | Command | New code? | Migrations run? |
|---------|-----------|-----------------| | ----------------------------------------------------------------------- | ----------------------------- | ----------------------------------------------- |
| `git pull` only | No | No | | `git pull` only | No | No |
| `docker compose up -d` (no `--build`) | No — reuses `beenvoice:local` | Only if the app container restarts (same image) | | `docker compose up -d` (no `--build`) | No — reuses `beenvoice:local` | Only if the app container restarts (same image) |
| `../../scripts/docker-deploy.sh` or root `docker compose up -d --build` | Yes | Yes — on app container start | | `../../scripts/docker-deploy.sh` or root `docker compose up -d --build` | Yes | Yes — on app container start |
| `docker compose restart app` | No | Yes — migrate runs again (no-op if up to date) | | `docker compose restart app` | No | Yes — migrate runs again (no-op if up to date) |
Prune old app images occasionally: `docker image prune -f` (or remove specific `beenvoice:*` tags). Prune old app and worker images occasionally: `docker image prune -f` (or remove specific `beenvoice:*` / `beenvoice-worker:*` tags).
To verify migration files match the journal before deploy: `bun run db:verify-journal`. To verify migration files match the journal before deploy: `bun run db:verify-journal`.
@@ -191,8 +204,10 @@ Use the literal strings `true` or `false` (or omit the variable). Do not rely on
### 5. Optional services ### 5. Optional services
| Variable | Purpose | | Variable | Purpose |
|----------|---------| | -------------------------------------------------------------------- | -------------------------------------------------------------------- |
| `RESEND_API_KEY`, `RESEND_DOMAIN` | Invoice and password-reset email | | `EMAIL_PROVIDER`, `EMAIL_FROM` | Select `mailpit`, `smtp`, or `resend` and configure the sender |
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE` | Local Mailpit or another SMTP-compatible transport |
| `RESEND_API_KEY`, `RESEND_DOMAIN`, `RESEND_FROM` | Production Resend delivery |
| `AUTHENTIK_ISSUER`, `AUTHENTIK_CLIENT_ID`, `AUTHENTIK_CLIENT_SECRET` | OIDC SSO (also set `NEXT_PUBLIC_AUTHENTIK_ENABLED=true` and rebuild) | | `AUTHENTIK_ISSUER`, `AUTHENTIK_CLIENT_ID`, `AUTHENTIK_CLIENT_SECRET` | OIDC SSO (also set `NEXT_PUBLIC_AUTHENTIK_ENABLED=true` and rebuild) |
| `CRON_SECRET` | Protects `/api/cron/generate-recurring` | | `CRON_SECRET` | Protects `/api/cron/generate-recurring` |
| `DISABLE_SIGNUPS=true` | Block new registrations | | `DISABLE_SIGNUPS=true` | Block new registrations |
@@ -246,7 +261,7 @@ Full-stack deploy uses `bun run docker:deploy` or `../../scripts/docker-deploy.s
## API surface ## API surface
| Endpoint | Auth | Purpose | | Endpoint | Auth | Purpose |
|----------|------|---------| | -------------------- | ------------------------- | ---------------------------------------- |
| `/api/trpc` | Session cookie or API key | Primary API (web + mobile) | | `/api/trpc` | Session cookie or API key | Primary API (web + mobile) |
| `/api/auth/*` | Varies | better-auth + custom register/reset REST | | `/api/auth/*` | Varies | better-auth + custom register/reset REST |
| `/api/mcp` | API key only | JSON-RPC automation tools | | `/api/mcp` | API key only | JSON-RPC automation tools |
@@ -265,7 +280,7 @@ Business logic lives in `src/server/api/routers/` with Zod validation.
## Documentation ## Documentation
| Doc | Contents | | Doc | Contents |
|-----|----------| | ---------------------------------------------- | ------------------------------------------ |
| [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) | Stack, routers, schema, auth, Docker, MCP | | [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) | Stack, routers, schema, auth, Docker, MCP |
| [docs/COOLIFY.md](./docs/COOLIFY.md) | Coolify deploy paths and Garage networking | | [docs/COOLIFY.md](./docs/COOLIFY.md) | Coolify deploy paths and Garage networking |
| [docs/README.md](./docs/README.md) | Index of UI and product guides | | [docs/README.md](./docs/README.md) | Index of UI and product guides |
+15 -13
View File
@@ -7,13 +7,13 @@ This application is the server and browser workspace in the Beenvoice monorepo.
## Stack ## Stack
| Layer | Technology | | Layer | Technology |
|-------|------------| | --------- | ----------------------------------------------------------------------------- |
| Framework | Next.js 16 App Router (`src/app/`) | | Framework | Next.js 16 App Router (`src/app/`) |
| API | tRPC 11 (`/api/trpc`), SuperJSON transformer | | API | tRPC 11 (`/api/trpc`), SuperJSON transformer |
| ORM | Drizzle + `pg` pool | | ORM | Drizzle + `pg` pool |
| Auth | better-auth (email/password, optional Authentik OIDC, Expo plugin for mobile) | | Auth | better-auth (email/password, optional Authentik OIDC, Expo plugin for mobile) |
| UI | shadcn/ui, Tailwind CSS v4, Radix primitives | | UI | shadcn/ui, Tailwind CSS v4, Radix primitives |
| Email | Resend | | Email | Shared Resend/SMTP transport; Mailpit for local capture |
| PDF | `@react-pdf/renderer` | | PDF | `@react-pdf/renderer` |
## Request flow ## Request flow
@@ -70,14 +70,14 @@ drizzle/ # SQL migrations (00000014+)
Root: `src/server/api/root.ts`. All routers use Zod input validation. Root: `src/server/api/root.ts`. All routers use Zod input validation.
| Namespace | File | Key procedures | | Namespace | File | Key procedures |
|-----------|------|----------------| | ------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `clients` | `routers/clients.ts` | getAll, getById, create, update, delete | | `clients` | `routers/clients.ts` | getAll, getById, create, update, delete |
| `businesses` | `routers/businesses.ts` | getAll, getById, getDefault, create, update, delete, setDefault, getEmailConfig, updateEmailConfig | | `businesses` | `routers/businesses.ts` | getAll, getById, getDefault, create, update, delete, setDefault, getEmailConfig, updateEmailConfig |
| `invoices` | `routers/invoices.ts` | getAll, getBillable, getById, create, update, delete, updateStatus, bulk*, previewPdf, public token, **getByPublicToken** (public), sendReminder | | `invoices` | `routers/invoices.ts` | getAll, getBillable, getById, create, update, delete, updateStatus, bulk\*, previewPdf, public token, **getByPublicToken** (public), sendReminder |
| `payments` | `routers/payments.ts` | getByInvoice, create, delete | | `payments` | `routers/payments.ts` | getByInvoice, create, delete |
| `expenses` | `routers/expenses.ts` | getAll, getById, create, update, delete | | `expenses` | `routers/expenses.ts` | getAll, getById, create, update, delete |
| `invoiceTemplates` | `routers/invoiceTemplates.ts` | CRUD by template type | | `invoiceTemplates` | `routers/invoiceTemplates.ts` | CRUD by template type |
| `recurringInvoices` | `routers/recurring-invoices.ts` | CRUD, pause/resume, generateNow; cron helper `generateDueRecurringInvoices` | | `recurringInvoices` | `routers/recurring-invoices.ts` | CRUD, pause/resume, generateNow; due generation runs through the worker |
| `timeEntries` | `routers/time-entries.ts` | getAll, getRunning, clockIn, updateRunning, clockOut, create, update, delete, getSummary | | `timeEntries` | `routers/time-entries.ts` | getAll, getRunning, clockIn, updateRunning, clockOut, create, update, delete, getSummary |
| `dashboard` | `routers/dashboard.ts` | getStats | | `dashboard` | `routers/dashboard.ts` | getStats |
| `email` | `routers/email.ts` | sendInvoice | | `email` | `routers/email.ts` | sendInvoice |
@@ -98,7 +98,7 @@ Single file: `src/server/db/schema.ts`. Table names use `pgTableCreator` → pre
### Auth & platform ### Auth & platform
| Table | Notes | | Table | Notes |
|-------|-------| | ------------------------------ | ------------------------------------------------- |
| `beenvoice_user` | Core user; role for admin features | | `beenvoice_user` | Core user; role for admin features |
| `beenvoice_account` | OAuth/credential accounts (better-auth) | | `beenvoice_account` | OAuth/credential accounts (better-auth) |
| `beenvoice_session` | Sessions; unique token | | `beenvoice_session` | Sessions; unique token |
@@ -110,7 +110,7 @@ Single file: `src/server/db/schema.ts`. Table names use `pgTableCreator` → pre
### Domain ### Domain
| Table | FKs | Notes | | Table | FKs | Notes |
|-------|-----|-------| | ---------------------------------- | ---------------------------- | ------------------------------------- |
| `beenvoice_client` | `createdById` → user | defaultHourlyRate, currency | | `beenvoice_client` | `createdById` → user | defaultHourlyRate, currency |
| `beenvoice_business` | `createdById` | Resend config, `isDefault` | | `beenvoice_business` | `createdById` | Resend config, `isDefault` |
| `beenvoice_invoice` | client, business?, user | status draft/sent/paid; `publicToken` | | `beenvoice_invoice` | client, business?, user | status draft/sent/paid; `publicToken` |
@@ -167,26 +167,28 @@ API keys: format `bv_<base64url>`; stored as SHA-256 hash (`src/server/api/api-k
Validated in `src/env.js`. See `.env.example`. Validated in `src/env.js`. See `.env.example`.
| Variable | Required | Notes | | Variable | Required | Notes |
|----------|----------|-------| | ------------------------------------------------ | -------------------- | ------------------------------------------------------------------------------ |
| `DATABASE_URL` | yes | PostgreSQL connection string | | `DATABASE_URL` | yes | PostgreSQL connection string |
| `AUTH_SECRET` | prod | `openssl rand -base64 32` | | `AUTH_SECRET` | prod | `openssl rand -base64 32` |
| `BETTER_AUTH_URL` | yes | Public URL of API (no trailing path) | | `BETTER_AUTH_URL` | yes | Public URL of API (no trailing path) |
| `NEXT_PUBLIC_APP_URL` | yes | Browser-facing URL | | `NEXT_PUBLIC_APP_URL` | yes | Browser-facing URL |
| `DB_DISABLE_SSL` | local | `true` for Docker dev DB | | `DB_DISABLE_SSL` | local | `true` for Docker dev DB |
| `RESEND_API_KEY`, `RESEND_DOMAIN` | optional | Email; blank disables send | | `EMAIL_PROVIDER`, `EMAIL_FROM` | optional | `mailpit`, `smtp`, or `resend`; sender identity |
| `SMTP_HOST`, `SMTP_PORT` | SMTP/Mailpit | SMTP endpoint (`127.0.0.1:1028` in local development) |
| `RESEND_API_KEY`, `RESEND_DOMAIN`, `RESEND_FROM` | Resend | Production delivery credentials and verified sender |
| `AUTHENTIK_*` | optional | OIDC SSO | | `AUTHENTIK_*` | optional | OIDC SSO |
| `DISABLE_SIGNUPS` | optional | `true` blocks registration; use string `true`/`false` (parsed in `src/env.js`) | | `DISABLE_SIGNUPS` | optional | `true` blocks registration; use string `true`/`false` (parsed in `src/env.js`) |
| `CRON_SECRET` | cron route | Protects `/api/cron/generate-recurring` | | `CRON_SECRET` | worker / cron routes | Protects worker delivery and `/api/cron/generate-recurring` |
| `NEXT_PUBLIC_BRAND_*` | optional | Build-time white-label defaults | | `NEXT_PUBLIC_BRAND_*` | optional | Build-time white-label defaults |
## Docker ## Docker
| File | Use | | File | Use |
|------|-----| | ----------------------------- | ------------------------------------------------------------- |
| Root `docker-compose.yml` | Deploy: `app` + `db` + Garage; use `apps/web/.env` | | Root `docker-compose.yml` | Deploy: `app` + `worker` + `db` + Garage; use `apps/web/.env` |
| Root `docker-compose.dev.yml` | Local dev: Postgres + Garage | | Root `docker-compose.dev.yml` | Local dev: Postgres + Garage |
The app image is built from the root `Dockerfile`. Container startup runs the web migration script and then `next start` on port 3000. Docker builds run `next build` on Node 22 (not Bun) to avoid arm64 worker crashes; runtime stays on Bun. The root `Dockerfile` has separate web and worker targets. The web container runs migrations and then `next start` on port 3000. The Bun worker uses PostgreSQL as its durable queue, polls with row locking, generates recurring invoices, and triggers idempotent scheduled invoice delivery without Redis or an external cron. User-selected wall times are converted by the client to absolute instants and stored with their IANA timezone for consistent cross-device display. Docker builds run `next build` on Node 22 (not Bun) to avoid arm64 worker crashes; runtime stays on Bun.
Set `BETTER_AUTH_URL` and `NEXT_PUBLIC_APP_URL` to the public hostname before deploy. Rebuild the image when changing `NEXT_PUBLIC_*` build-time vars. Set `BETTER_AUTH_URL` and `NEXT_PUBLIC_APP_URL` to the public hostname before deploy. Rebuild the image when changing `NEXT_PUBLIC_*` build-time vars.
+19 -18
View File
@@ -6,16 +6,16 @@ beenvoice stores receipt files in S3-compatible storage when `S3_BUCKET`, `S3_AC
Docker DNS resolves service names **only inside the same Docker network**. Docker DNS resolves service names **only inside the same Docker network**.
| Setup | Does `http://garage:3900` work? | | Setup | Does `http://garage:<GARAGE_API_PORT>` work? |
|-------|--------------------------------| |-------|--------------------------------|
| Single Compose stack (app + garage together) | Yes — Compose service name `garage` | | Single Compose stack (app + garage together) | Yes — Compose service name `garage` |
| beenvoice **Application** + Garage **separate Compose** | **No** — each resource has its own network by default | | beenvoice **Application** + Garage **separate Compose** | **No** — each resource has its own network by default |
| Application + Garage with shared destination network + correct hostname | Yes — hostname is usually **`garage-<resource-uuid>`**, not bare `garage` | | Application + Garage with shared destination network + correct hostname | Yes — hostname is usually **`garage-<resource-uuid>`**, not bare `garage` |
| Application + Garage via **public domain** (`SERVICE_URL_GARAGE_3900`) | Yes — no Docker DNS needed | | Application + Garage via **public domain** (`SERVICE_URL_GARAGE`) | Yes — no Docker DNS needed |
Setting `S3_ENDPOINT=http://garage:3900` on a standalone beenvoice Application fails because the app container is not on the Garage stack's network. Node returns `ENOTFOUND garage`. Setting `S3_ENDPOINT=http://garage:<GARAGE_API_PORT>` on a standalone beenvoice Application fails because the app container is not on the Garage stack's network. Node returns `ENOTFOUND garage`.
Also avoid `http://localhost:3900` inside the app container — that points at the app itself, not Garage. Also avoid `http://localhost:<GARAGE_API_PORT>` inside the app container — that points at the app itself, not Garage.
--- ---
@@ -27,9 +27,9 @@ Use this if you are **not** migrating to a single Compose stack today.
This is the most reliable fix when beenvoice is a Coolify **Application** (Dockerfile) and Garage is a separate Compose resource. This is the most reliable fix when beenvoice is a Coolify **Application** (Dockerfile) and Garage is a separate Compose resource.
1. **Update the Garage stack** to the latest `docker-compose.coolify-garage.yml` from this repo (includes `SERVICE_FQDN_GARAGE_3900`) and **redeploy** the Garage resource. 1. **Update the Garage stack** to the latest `docker-compose.coolify-garage.yml` from this repo (includes `SERVICE_FQDN_GARAGE`) and **redeploy** the Garage resource.
2. In the **Garage Compose resource** → assign a domain for **port 3900** (e.g. `s3.yourdomain.com`). Coolify generates TLS via Traefik/Caddy. 2. In the **Garage Compose resource** → assign a domain to `GARAGE_API_PORT` (default **3900**, e.g. `s3.yourdomain.com`). Coolify generates TLS via Traefik/Caddy.
3. Open the Garage resource **Environment** tab and copy **`SERVICE_URL_GARAGE_3900`** (e.g. `https://s3.yourdomain.com`). 3. Open the Garage resource **Environment** tab and copy **`SERVICE_URL_GARAGE`** (e.g. `https://s3.yourdomain.com`).
4. On the **beenvoice Application** → Environment: 4. On the **beenvoice Application** → Environment:
```env ```env
@@ -55,12 +55,12 @@ Use when you want S3 API traffic to stay on the Docker network.
5. Set on beenvoice Application: 5. Set on beenvoice Application:
```env ```env
S3_ENDPOINT=http://garage-<GARAGE_RESOURCE_UUID>:3900 S3_ENDPOINT=http://garage-<GARAGE_RESOURCE_UUID>:<GARAGE_API_PORT>
``` ```
Example: resource UUID `k8w2o0g4s0g8` `S3_ENDPOINT=http://garage-k8w2o0g4s0g8:3900`. Example with the default port and resource UUID `k8w2o0g4s0g8`: `S3_ENDPOINT=http://garage-k8w2o0g4s0g8:3900`.
**Do not use bare `garage`** unless you verified it resolves from inside the beenvoice container (recent Coolify versions may also register the short service name when both sides use Connect to Predefined Network — if `wget http://garage:3900` fails, use the `garage-<uuid>` form or Path A). **Do not use bare `garage`** unless you verified it resolves from inside the beenvoice container (recent Coolify versions may also register the short service name when both sides use Connect to Predefined Network — if `wget http://garage:<GARAGE_API_PORT>` fails, use the `garage-<uuid>` form or Path A).
6. Match credentials and bucket: 6. Match credentials and bucket:
@@ -79,10 +79,12 @@ Deploy the root **[`docker-compose.coolify.yml`](../../../docker-compose.coolify
1. Coolify → **New Resource****Docker Compose** 1. Coolify → **New Resource****Docker Compose**
2. Point at this repo; compose file: **`docker-compose.coolify.yml`** 2. Point at this repo; compose file: **`docker-compose.coolify.yml`**
3. Set env vars from [`.env.example`](../.env.example): `AUTH_SECRET`, `POSTGRES_PASSWORD`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`, etc. 3. Set env vars from [`.env.example`](../.env.example): `AUTH_SECRET`, `POSTGRES_PASSWORD`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`, and a 64-character hexadecimal `GARAGE_RPC_SECRET` (`openssl rand -hex 32`). Garage has a valid development fallback, but production should override it.
4. Assign a domain to the **`app`** service (Coolify fills `SERVICE_URL_APP` / `BETTER_AUTH_URL` automatically). 4. Assign a domain to the **`app`** service (Coolify fills `SERVICE_URL_APP` / `BETTER_AUTH_URL` automatically).
5. **Do not** override `S3_ENDPOINT` — the compose file sets `S3_ENDPOINT=http://garage:3900` on the shared network. 5. Optionally set `APP_PORT`, `POSTGRES_PORT`, `GARAGE_API_PORT`, `GARAGE_RPC_PORT`, `GARAGE_WEB_PORT`, or `GARAGE_ADMIN_PORT`. Defaults are `3000`, `5432`, and `3900``3903` respectively.
6. Redeploy. 6. Only the app port is exposed to Coolify's proxy. PostgreSQL and every Garage listener remain reachable only through the stack's internal Docker network.
7. **Do not** override `S3_ENDPOINT` — the compose file derives it from `GARAGE_API_PORT` on the shared network.
8. Redeploy.
Alternative: [`docker-compose.yml`](../../../docker-compose.yml) works the same way; `docker-compose.coolify.yml` adds Coolify magic vars (`SERVICE_FQDN_APP`) and omits host port bindings for db/Garage. Alternative: [`docker-compose.yml`](../../../docker-compose.yml) works the same way; `docker-compose.coolify.yml` adds Coolify magic vars (`SERVICE_FQDN_APP`) and omits host port bindings for db/Garage.
@@ -95,7 +97,7 @@ Alternative: [`docker-compose.yml`](../../../docker-compose.yml) works the same
| Garage / MinIO compose | Remove after data migrated (rclone) or re-point receipts (new bucket) | | Garage / MinIO compose | Remove after data migrated (rclone) or re-point receipts (new bucket) |
| Env vars | Move `AUTH_SECRET`, Resend, Authentik, etc. to the Compose resource env | | Env vars | Move `AUTH_SECRET`, Resend, Authentik, etc. to the Compose resource env |
**Migrating from MinIO:** Garage uses port **3900** (not 9000) and Garage-format access keys (`GK…`). Update `S3_ENDPOINT`, `S3_REGION=garage`, and credentials. Receipt blobs in the old MinIO volume are not auto-migrated. **Migrating from MinIO:** Garage defaults to port **3900** (not 9000) and uses Garage-format access keys (`GK…`). Update `S3_ENDPOINT`, `S3_REGION=garage`, and credentials. Receipt blobs in the old MinIO volume are not auto-migrated.
--- ---
@@ -114,9 +116,8 @@ Do **not** add `networks: coolify: external: true` unless you know the exact ext
## Checklist (Application + separate Garage) ## Checklist (Application + separate Garage)
- [ ] Garage stack redeployed with current `docker-compose.coolify-garage.yml` - [ ] Garage stack redeployed with current `docker-compose.coolify-garage.yml`
- [ ] **Path A:** domain on port 3900 + `S3_ENDPOINT` = `SERVICE_URL_GARAGE_3900` - [ ] **Path A:** domain targets `GARAGE_API_PORT` + `S3_ENDPOINT` = `SERVICE_URL_GARAGE`; **or Path B:** Connect to Predefined Network on **both** resources + `S3_ENDPOINT=http://garage-<uuid>:<GARAGE_API_PORT>`
**or Path B:** Connect to Predefined Network on **both** resources + `S3_ENDPOINT=http://garage-<uuid>:3900` - [ ] `S3_ENDPOINT` is not a bare `http://garage:<port>` across separate resources and is **not** `localhost`
- [ ] `S3_ENDPOINT` is **not** `http://garage:3900`, **not** `localhost`
- [ ] `S3_ACCESS_KEY` / `S3_SECRET_KEY` match the Garage stack env - [ ] `S3_ACCESS_KEY` / `S3_SECRET_KEY` match the Garage stack env
- [ ] `S3_BUCKET` exists (Garage `--default-bucket` creates `beenvoice-receipts` on first start) - [ ] `S3_BUCKET` exists (Garage `--default-bucket` creates `beenvoice-receipts` on first start)
- [ ] Redeployed beenvoice after env or network changes - [ ] Redeployed beenvoice after env or network changes
@@ -131,7 +132,7 @@ docker exec -it <beenvoice-container> sh
wget -qO- "https://s3.yourdomain.com" || curl -sf "https://s3.yourdomain.com" wget -qO- "https://s3.yourdomain.com" || curl -sf "https://s3.yourdomain.com"
# Path B — internal host from S3_ENDPOINT # Path B — internal host from S3_ENDPOINT
wget -qO- "http://garage-<uuid>:3900" || curl -sf "http://garage-<uuid>:3900" wget -qO- "http://garage-<uuid>:<GARAGE_API_PORT>" || curl -sf "http://garage-<uuid>:<GARAGE_API_PORT>"
``` ```
If this fails with "bad address" or timeout, fix networking / `S3_ENDPOINT` before debugging app code. On first S3 use, the app logs a hint if DNS fails or if `S3_ENDPOINT` still uses bare `garage` in production. If this fails with "bad address" or timeout, fix networking / `S3_ENDPOINT` before debugging app code. On first S3 use, the app logs a hint if DNS fails or if `S3_ENDPOINT` still uses bare `garage` in production.
+21
View File
@@ -0,0 +1,21 @@
CREATE TABLE IF NOT EXISTS "beenvoice_background_job" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"type" varchar(100) NOT NULL,
"payload" jsonb DEFAULT '{}'::jsonb NOT NULL,
"status" varchar(20) DEFAULT 'pending' NOT NULL,
"idempotencyKey" varchar(500) NOT NULL,
"runAt" timestamp DEFAULT now() NOT NULL,
"attempts" integer DEFAULT 0 NOT NULL,
"maxAttempts" integer DEFAULT 5 NOT NULL,
"lockedAt" timestamp,
"lockedBy" varchar(255),
"lastError" text,
"completedAt" timestamp,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "beenvoice_background_job_idempotencyKey_unique" UNIQUE("idempotencyKey")
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "background_job_status_run_at_idx" ON "beenvoice_background_job" USING btree ("status", "runAt");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "background_job_type_status_idx" ON "beenvoice_background_job" USING btree ("type", "status");
@@ -0,0 +1,23 @@
ALTER TABLE "beenvoice_invoice" ADD COLUMN IF NOT EXISTS "sentAt" timestamp with time zone;
--> statement-breakpoint
ALTER TABLE "beenvoice_invoice" ADD COLUMN IF NOT EXISTS "scheduledSendAt" timestamp with time zone;
--> statement-breakpoint
ALTER TABLE "beenvoice_invoice" ADD COLUMN IF NOT EXISTS "scheduledSendTimeZone" varchar(100);
--> statement-breakpoint
ALTER TABLE "beenvoice_invoice" ADD COLUMN IF NOT EXISTS "scheduledSendJobId" varchar(255);
--> statement-breakpoint
ALTER TABLE "beenvoice_invoice" ADD COLUMN IF NOT EXISTS "scheduledSendStatus" varchar(20);
--> statement-breakpoint
ALTER TABLE "beenvoice_background_job" ALTER COLUMN "runAt" TYPE timestamp with time zone USING "runAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_background_job" ALTER COLUMN "lockedAt" TYPE timestamp with time zone USING "lockedAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_background_job" ALTER COLUMN "completedAt" TYPE timestamp with time zone USING "completedAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_background_job" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_background_job" ALTER COLUMN "updatedAt" TYPE timestamp with time zone USING "updatedAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "invoice_scheduled_send_at_idx" ON "beenvoice_invoice" USING btree ("scheduledSendAt");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "invoice_scheduled_send_job_idx" ON "beenvoice_invoice" USING btree ("scheduledSendJobId");
+124
View File
@@ -0,0 +1,124 @@
ALTER TABLE "beenvoice_user" ADD COLUMN IF NOT EXISTS "timeZone" varchar(100) DEFAULT 'America/New_York' NOT NULL;
--> statement-breakpoint
ALTER TABLE "beenvoice_recurring_invoice" ADD COLUMN IF NOT EXISTS "timeZone" varchar(100) DEFAULT 'America/New_York' NOT NULL;
--> statement-breakpoint
ALTER TABLE "beenvoice_invoice" ADD COLUMN IF NOT EXISTS "sendReminderJobId" varchar(255);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "beenvoice_push_token" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"userId" varchar(255) NOT NULL REFERENCES "beenvoice_user"("id") ON DELETE cascade,
"token" varchar(255) NOT NULL UNIQUE,
"platform" varchar(20) NOT NULL,
"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
"updatedAt" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "push_token_user_id_idx" ON "beenvoice_push_token" USING btree ("userId");
--> statement-breakpoint
ALTER TABLE "beenvoice_invoice" ALTER COLUMN "issueDate" TYPE date USING "issueDate"::date;
--> statement-breakpoint
ALTER TABLE "beenvoice_invoice" ALTER COLUMN "dueDate" TYPE date USING "dueDate"::date;
--> statement-breakpoint
ALTER TABLE "beenvoice_invoice_item" ALTER COLUMN "date" TYPE date USING "date"::date;
--> statement-breakpoint
ALTER TABLE "beenvoice_expense" ALTER COLUMN "date" TYPE date USING "date"::date;
--> statement-breakpoint
ALTER TABLE "beenvoice_invoice_payment" ALTER COLUMN "date" TYPE date USING "date"::date;
--> statement-breakpoint
ALTER TABLE "beenvoice_user" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_user" ALTER COLUMN "updatedAt" TYPE timestamp with time zone USING "updatedAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_user" ALTER COLUMN "resetTokenExpiry" TYPE timestamp with time zone USING "resetTokenExpiry" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_user" ALTER COLUMN "onboardingCompletedAt" TYPE timestamp with time zone USING "onboardingCompletedAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_platform_setting" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_platform_setting" ALTER COLUMN "updatedAt" TYPE timestamp with time zone USING "updatedAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_audit_log" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_account" ALTER COLUMN "accessTokenExpiresAt" TYPE timestamp with time zone USING "accessTokenExpiresAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_account" ALTER COLUMN "refreshTokenExpiresAt" TYPE timestamp with time zone USING "refreshTokenExpiresAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_account" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_account" ALTER COLUMN "updatedAt" TYPE timestamp with time zone USING "updatedAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_session" ALTER COLUMN "expiresAt" TYPE timestamp with time zone USING "expiresAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_session" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_session" ALTER COLUMN "updatedAt" TYPE timestamp with time zone USING "updatedAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_api_key" ALTER COLUMN "lastUsedAt" TYPE timestamp with time zone USING "lastUsedAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_api_key" ALTER COLUMN "expiresAt" TYPE timestamp with time zone USING "expiresAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_api_key" ALTER COLUMN "revokedAt" TYPE timestamp with time zone USING "revokedAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_api_key" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_api_key" ALTER COLUMN "updatedAt" TYPE timestamp with time zone USING "updatedAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_verification_token" ALTER COLUMN "expiresAt" TYPE timestamp with time zone USING "expiresAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_verification_token" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_verification_token" ALTER COLUMN "updatedAt" TYPE timestamp with time zone USING "updatedAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_sso_provider" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_sso_provider" ALTER COLUMN "updatedAt" TYPE timestamp with time zone USING "updatedAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_client" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_client" ALTER COLUMN "updatedAt" TYPE timestamp with time zone USING "updatedAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_business" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_business" ALTER COLUMN "updatedAt" TYPE timestamp with time zone USING "updatedAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_invoice" ALTER COLUMN "publicTokenExpiresAt" TYPE timestamp with time zone USING "publicTokenExpiresAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_invoice" ALTER COLUMN "lastReminderSentAt" TYPE timestamp with time zone USING "lastReminderSentAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_invoice" ALTER COLUMN "sendReminderAt" TYPE timestamp with time zone USING "sendReminderAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_invoice" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_invoice" ALTER COLUMN "updatedAt" TYPE timestamp with time zone USING "updatedAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_invoice_item" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_expense" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_expense" ALTER COLUMN "updatedAt" TYPE timestamp with time zone USING "updatedAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_expense_receipt" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_invoice_template" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_invoice_template" ALTER COLUMN "updatedAt" TYPE timestamp with time zone USING "updatedAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_invoice_payment" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_recurring_invoice" ALTER COLUMN "nextDueAt" TYPE timestamp with time zone USING "nextDueAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_recurring_invoice" ALTER COLUMN "lastGeneratedAt" TYPE timestamp with time zone USING "lastGeneratedAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_recurring_invoice" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_recurring_invoice" ALTER COLUMN "updatedAt" TYPE timestamp with time zone USING "updatedAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_recurring_invoice_item" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_time_entry" ALTER COLUMN "startedAt" TYPE timestamp with time zone USING "startedAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_time_entry" ALTER COLUMN "endedAt" TYPE timestamp with time zone USING "endedAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_time_entry" ALTER COLUMN "createdAt" TYPE timestamp with time zone USING "createdAt" AT TIME ZONE 'UTC';
--> statement-breakpoint
ALTER TABLE "beenvoice_time_entry" ALTER COLUMN "updatedAt" TYPE timestamp with time zone USING "updatedAt" AT TIME ZONE 'UTC';
@@ -0,0 +1,10 @@
ALTER TABLE "beenvoice_business" ADD COLUMN IF NOT EXISTS "logoDarkStorageKey" varchar(500);
ALTER TABLE "beenvoice_business" ADD COLUMN IF NOT EXISTS "logoDarkMimeType" varchar(100);
ALTER TABLE "beenvoice_business" ADD COLUMN IF NOT EXISTS "wordmarkLightStorageKey" varchar(500);
ALTER TABLE "beenvoice_business" ADD COLUMN IF NOT EXISTS "wordmarkLightMimeType" varchar(100);
ALTER TABLE "beenvoice_business" ADD COLUMN IF NOT EXISTS "wordmarkDarkStorageKey" varchar(500);
ALTER TABLE "beenvoice_business" ADD COLUMN IF NOT EXISTS "wordmarkDarkMimeType" varchar(100);
ALTER TABLE "beenvoice_business" ADD COLUMN IF NOT EXISTS "iconLightStorageKey" varchar(500);
ALTER TABLE "beenvoice_business" ADD COLUMN IF NOT EXISTS "iconLightMimeType" varchar(100);
ALTER TABLE "beenvoice_business" ADD COLUMN IF NOT EXISTS "iconDarkStorageKey" varchar(500);
ALTER TABLE "beenvoice_business" ADD COLUMN IF NOT EXISTS "iconDarkMimeType" varchar(100);
+28
View File
@@ -204,6 +204,34 @@
"when": 1786766968000, "when": 1786766968000,
"tag": "0028_enable_public_demo_password", "tag": "0028_enable_public_demo_password",
"breakpoints": true "breakpoints": true
},
{
"idx": 29,
"version": "7",
"when": 1786941568000,
"tag": "0029_background_jobs",
"breakpoints": true
},
{
"idx": 30,
"version": "7",
"when": 1786946793000,
"tag": "0030_scheduled_invoice_sends",
"breakpoints": true
},
{
"idx": 31,
"version": "7",
"when": 1786950000000,
"tag": "0031_timezone_safety",
"breakpoints": true
},
{
"idx": 32,
"version": "7",
"when": 1786975200000,
"tag": "0032_business_brand_assets",
"breakpoints": true
} }
] ]
} }
+1 -1
View File
@@ -29,13 +29,13 @@
}, },
"dependencies": { "dependencies": {
"@beenvoice/domain": "workspace:*", "@beenvoice/domain": "workspace:*",
"@beenvoice/email": "workspace:*",
"@aws-sdk/client-s3": "3.1075.0", "@aws-sdk/client-s3": "3.1075.0",
"@better-auth/expo": "1.6.19", "@better-auth/expo": "1.6.19",
"@dnd-kit/core": "6.3.1", "@dnd-kit/core": "6.3.1",
"@dnd-kit/modifiers": "9.0.0", "@dnd-kit/modifiers": "9.0.0",
"@dnd-kit/sortable": "10.0.0", "@dnd-kit/sortable": "10.0.0",
"@dnd-kit/utilities": "3.2.2", "@dnd-kit/utilities": "3.2.2",
"@fontsource-variable/playfair-display": "5.2.8",
"@radix-ui/react-alert-dialog": "1.1.16", "@radix-ui/react-alert-dialog": "1.1.16",
"@radix-ui/react-avatar": "1.1.12", "@radix-ui/react-avatar": "1.1.12",
"@radix-ui/react-checkbox": "1.3.4", "@radix-ui/react-checkbox": "1.3.4",
@@ -3,6 +3,13 @@ import { eq } from "drizzle-orm";
import { getObject } from "~/lib/object-storage"; import { getObject } from "~/lib/object-storage";
import { db } from "~/server/db"; import { db } from "~/server/db";
import { businesses } from "~/server/db/schema"; import { businesses } from "~/server/db/schema";
import {
brandAssetKinds,
brandAssetThemes,
resolveBusinessBrandAsset,
type BrandAssetKind,
type BrandAssetTheme,
} from "~/lib/business-branding";
export const runtime = "nodejs"; export const runtime = "nodejs";
@@ -16,27 +23,57 @@ export async function GET(
{ params }: { params: Promise<{ businessId: string }> }, { params }: { params: Promise<{ businessId: string }> },
) { ) {
const { businessId } = await params; const { businessId } = await params;
const url = new URL(req.url);
const requestedKind = url.searchParams.get("kind");
const requestedTheme = url.searchParams.get("theme");
const kind: BrandAssetKind = brandAssetKinds.includes(
requestedKind as BrandAssetKind,
)
? (requestedKind as BrandAssetKind)
: "logo";
const theme: BrandAssetTheme = brandAssetThemes.includes(
requestedTheme as BrandAssetTheme,
)
? (requestedTheme as BrandAssetTheme)
: "light";
const business = await db.query.businesses.findFirst({ const business = await db.query.businesses.findFirst({
where: eq(businesses.id, businessId), where: eq(businesses.id, businessId),
columns: { logoStorageKey: true, logoMimeType: true }, columns: {
logoStorageKey: true,
logoMimeType: true,
logoDarkStorageKey: true,
logoDarkMimeType: true,
wordmarkLightStorageKey: true,
wordmarkLightMimeType: true,
wordmarkDarkStorageKey: true,
wordmarkDarkMimeType: true,
iconLightStorageKey: true,
iconLightMimeType: true,
iconDarkStorageKey: true,
iconDarkMimeType: true,
},
}); });
const asset = business
? resolveBusinessBrandAsset(business, kind, theme)
: null;
if (!business?.logoStorageKey || !business.logoMimeType) { if (!asset) {
return NextResponse.json({ error: "Not found" }, { status: 404 }); return NextResponse.json({ error: "Not found" }, { status: 404 });
} }
// @react-pdf/renderer's Image component only decodes PNG/JPEG, so PDF // @react-pdf/renderer's Image component only decodes PNG/JPEG, so PDF
// generation requests a rasterized copy of SVG/WebP logos via this param. // generation requests a rasterized copy of SVG/WebP logos via this param.
const wantsPng = const wantsPng =
new URL(req.url).searchParams.get("format") === "png" && url.searchParams.get("format") === "png" &&
RASTERIZABLE_MIME_TYPES.has(business.logoMimeType); RASTERIZABLE_MIME_TYPES.has(asset.mimeType);
try { try {
const body = await getObject(business.logoStorageKey); const body = await getObject(asset.storageKey);
if (wantsPng) { if (wantsPng) {
const { default: sharp } = await import("sharp"); const { default: sharp } = await import("sharp");
const isSvg = business.logoMimeType === "image/svg+xml"; const isSvg = asset.mimeType === "image/svg+xml";
// SVG is vector: rasterize at a high density so the PNG stays crisp at // SVG is vector: rasterize at a high density so the PNG stays crisp at
// the size it's actually displayed (PDF header, up to ~2.2in wide). // the size it's actually displayed (PDF header, up to ~2.2in wide).
// withoutEnlargement only makes sense for the WebP (already-raster) // withoutEnlargement only makes sense for the WebP (already-raster)
@@ -62,7 +99,7 @@ export async function GET(
return new NextResponse(new Uint8Array(body), { return new NextResponse(new Uint8Array(body), {
headers: { headers: {
"Content-Type": business.logoMimeType, "Content-Type": asset.mimeType,
"Cache-Control": "public, max-age=300, must-revalidate", "Cache-Control": "public, max-age=300, must-revalidate",
"X-Content-Type-Options": "nosniff", "X-Content-Type-Options": "nosniff",
}, },
@@ -71,6 +108,8 @@ export async function GET(
console.error("[business-logo] Failed to serve logo", { console.error("[business-logo] Failed to serve logo", {
backendError: error, backendError: error,
businessId, businessId,
kind,
theme,
wantsPng, wantsPng,
}); });
return NextResponse.json({ error: "Logo not found" }, { status: 404 }); return NextResponse.json({ error: "Logo not found" }, { status: 404 });
@@ -1,7 +1,6 @@
import { type NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
import { env } from "~/env"; import { env } from "~/env";
import { db } from "~/server/db"; import { scheduleDueRecurringInvoiceJobs } from "~/server/jobs/queue";
import { generateDueRecurringInvoices } from "~/server/api/routers/recurring-invoices";
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
const authHeader = req.headers.get("authorization"); const authHeader = req.headers.get("authorization");
@@ -18,6 +17,6 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
} }
const generated = await generateDueRecurringInvoices(db); const result = await scheduleDueRecurringInvoiceJobs();
return NextResponse.json({ generated }); return NextResponse.json(result);
} }
+30
View File
@@ -0,0 +1,30 @@
import { sql } from "drizzle-orm";
import { NextResponse } from "next/server";
import { db } from "~/server/db";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET() {
try {
await Promise.race([
db.execute(sql`select 1`),
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error("Database readiness timed out")),
2_000,
),
),
]);
return NextResponse.json(
{ status: "ok", database: "ready" },
{ headers: { "Cache-Control": "no-store" } },
);
} catch {
return NextResponse.json(
{ status: "unavailable", database: "unavailable" },
{ status: 503, headers: { "Cache-Control": "no-store" } },
);
}
}
+18 -2
View File
@@ -35,6 +35,16 @@ export async function GET(
taxId: true, taxId: true,
logoStorageKey: true, logoStorageKey: true,
logoMimeType: true, logoMimeType: true,
logoDarkStorageKey: true,
logoDarkMimeType: true,
wordmarkLightStorageKey: true,
wordmarkLightMimeType: true,
wordmarkDarkStorageKey: true,
wordmarkDarkMimeType: true,
iconLightStorageKey: true,
iconLightMimeType: true,
iconDarkStorageKey: true,
iconDarkMimeType: true,
hideNameWithLogo: true, hideNameWithLogo: true,
}, },
}, },
@@ -52,8 +62,14 @@ export async function GET(
return NextResponse.json({ error: "Not found" }, { status: 404 }); return NextResponse.json({ error: "Not found" }, { status: 404 });
} }
if (invoice.publicTokenExpiresAt && new Date(invoice.publicTokenExpiresAt) < new Date()) { if (
return NextResponse.json({ error: "This link has expired" }, { status: 410 }); invoice.publicTokenExpiresAt &&
new Date(invoice.publicTokenExpiresAt) < new Date()
) {
return NextResponse.json(
{ error: "This link has expired" },
{ status: 410 },
);
} }
const settings = await db.query.platformSettings.findFirst({ const settings = await db.query.platformSettings.findFirst({
@@ -0,0 +1,89 @@
import { and, eq } from "drizzle-orm";
import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { env } from "~/env";
import { getAppUrl } from "~/lib/app-url";
import { db } from "~/server/db";
import { invoices } from "~/server/db/schema";
import { deliverInvoiceEmail } from "~/server/services/send-invoice-email";
const requestSchema = z.object({
jobId: z.string().min(1),
idempotencyKey: z.string().min(1),
invoiceId: z.string().min(1),
actorUserId: z.string().min(1),
customSubject: z.string().optional(),
customContent: z.string().optional(),
customMessage: z.string().optional(),
useHtml: z.boolean().optional(),
ccEmails: z.string().optional(),
bccEmails: z.string().optional(),
});
export async function POST(request: NextRequest) {
if (!env.CRON_SECRET) {
return NextResponse.json(
{ error: "Worker secret is not configured" },
{ status: 500 },
);
}
if (request.headers.get("authorization") !== `Bearer ${env.CRON_SECRET}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const parsed = requestSchema.safeParse(
await request.json().catch(() => null),
);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid scheduled send payload" },
{ status: 400 },
);
}
const input = parsed.data;
const [claimed] = await db
.update(invoices)
.set({ scheduledSendStatus: "processing", updatedAt: new Date() })
.where(
and(
eq(invoices.id, input.invoiceId),
eq(invoices.scheduledSendJobId, input.jobId),
eq(invoices.scheduledSendStatus, "pending"),
),
)
.returning({ id: invoices.id });
if (!claimed) {
const existing = await db.query.invoices.findFirst({
where: and(
eq(invoices.id, input.invoiceId),
eq(invoices.scheduledSendJobId, input.jobId),
),
columns: { scheduledSendStatus: true },
});
if (
!existing ||
!["processing", "completed"].includes(existing.scheduledSendStatus ?? "")
) {
return NextResponse.json({ skipped: true });
}
if (existing.scheduledSendStatus === "completed") {
return NextResponse.json({ success: true, alreadyCompleted: true });
}
}
try {
const result = await deliverInvoiceEmail({
...input,
scheduledJobId: input.jobId,
baseUrl: getAppUrl(),
});
return NextResponse.json(result);
} catch (error) {
const message =
error instanceof Error ? error.message : "Scheduled invoice send failed";
return NextResponse.json({ error: message }, { status: 500 });
}
}
+287 -75
View File
@@ -14,6 +14,7 @@ type ToolResult = {
type McpCaller = ReturnType<typeof createCaller>; type McpCaller = ReturnType<typeof createCaller>;
const dateString = z.string().min(1); const dateString = z.string().min(1);
const calendarDateString = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
const emptyableString = z.string().optional().or(z.literal("")); const emptyableString = z.string().optional().or(z.literal(""));
const invoiceStatus = z.enum(["draft", "sent", "paid"]); const invoiceStatus = z.enum(["draft", "sent", "paid"]);
const paymentMethod = z.enum([ const paymentMethod = z.enum([
@@ -26,7 +27,7 @@ const paymentMethod = z.enum([
]); ]);
const invoiceItemSchema = z.object({ const invoiceItemSchema = z.object({
date: dateString, date: calendarDateString,
description: z.string().min(1), description: z.string().min(1),
hours: z.number().min(0), hours: z.number().min(0),
rate: z.number().min(0), rate: z.number().min(0),
@@ -68,8 +69,8 @@ const invoiceCreateSchema = z.object({
invoicePrefix: z.string().optional(), invoicePrefix: z.string().optional(),
businessId: emptyableString, businessId: emptyableString,
clientId: z.string().min(1), clientId: z.string().min(1),
issueDate: dateString, issueDate: calendarDateString,
dueDate: dateString, dueDate: calendarDateString,
status: invoiceStatus.default("draft"), status: invoiceStatus.default("draft"),
notes: emptyableString, notes: emptyableString,
emailMessage: emptyableString, emailMessage: emptyableString,
@@ -83,7 +84,7 @@ const invoiceUpdateSchema = invoiceCreateSchema.partial().extend({
}); });
const expenseCreateSchema = z.object({ const expenseCreateSchema = z.object({
date: dateString, date: calendarDateString,
description: z.string().min(1), description: z.string().min(1),
amount: z.number().min(0), amount: z.number().min(0),
currency: z.string().length(3).default("USD"), currency: z.string().length(3).default("USD"),
@@ -97,7 +98,9 @@ const expenseCreateSchema = z.object({
invoiceId: z.string().optional().or(z.literal("")), invoiceId: z.string().optional().or(z.literal("")),
}); });
const expenseUpdateSchema = expenseCreateSchema.partial().extend({ id: z.string() }); const expenseUpdateSchema = expenseCreateSchema
.partial()
.extend({ id: z.string() });
const recurringItemSchema = z.object({ const recurringItemSchema = z.object({
description: z.string().min(1), description: z.string().min(1),
@@ -116,6 +119,9 @@ const recurringCreateSchema = z.object({
currency: z.string().length(3).default("USD"), currency: z.string().length(3).default("USD"),
notes: z.string().optional().or(z.literal("")), notes: z.string().optional().or(z.literal("")),
emailMessage: z.string().optional().or(z.literal("")), emailMessage: z.string().optional().or(z.literal("")),
timeZone: z.string().default("America/New_York"),
nextRunLocal: z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/),
disambiguation: z.enum(["earlier", "later", "reject"]).default("reject"),
items: z.array(recurringItemSchema).min(1), items: z.array(recurringItemSchema).min(1),
}); });
@@ -149,10 +155,17 @@ const jsonSchemas = {
properties: { properties: {
invoiceId: { type: "string" }, invoiceId: { type: "string" },
amount: { type: "number", exclusiveMinimum: 0 }, amount: { type: "number", exclusiveMinimum: 0 },
date: { type: "string", format: "date-time" }, date: { type: "string", format: "date" },
method: { method: {
type: "string", type: "string",
enum: ["cash", "check", "bank_transfer", "credit_card", "paypal", "other"], enum: [
"cash",
"check",
"bank_transfer",
"credit_card",
"paypal",
"other",
],
}, },
notes: { type: "string", maxLength: 500 }, notes: { type: "string", maxLength: 500 },
}, },
@@ -184,11 +197,17 @@ const jsonSchemas = {
invoicePrefix: { type: "string" }, invoicePrefix: { type: "string" },
businessId: { type: "string" }, businessId: { type: "string" },
clientId: { type: "string", minLength: 1 }, clientId: { type: "string", minLength: 1 },
issueDate: { type: "string", format: "date-time" }, issueDate: { type: "string", format: "date" },
dueDate: { type: "string", format: "date-time" }, dueDate: { type: "string", format: "date" },
status: { type: "string", enum: ["draft", "sent", "paid"] }, status: { type: "string", enum: ["draft", "sent", "paid"] },
notes: { type: "string" }, notes: { type: "string" },
emailMessage: { type: "string" }, emailMessage: { type: "string" },
timeZone: { type: "string", description: "IANA time zone" },
nextRunLocal: {
type: "string",
description: "First/next wall time as YYYY-MM-DDTHH:mm in timeZone",
},
disambiguation: { type: "string", enum: ["earlier", "later", "reject"] },
taxRate: { type: "number", minimum: 0, maximum: 100 }, taxRate: { type: "number", minimum: 0, maximum: 100 },
currency: { type: "string", minLength: 3, maxLength: 3 }, currency: { type: "string", minLength: 3, maxLength: 3 },
items: { items: {
@@ -197,7 +216,7 @@ const jsonSchemas = {
items: { items: {
type: "object", type: "object",
properties: { properties: {
date: { type: "string", format: "date-time" }, date: { type: "string", format: "date" },
description: { type: "string", minLength: 1 }, description: { type: "string", minLength: 1 },
hours: { type: "number", minimum: 0 }, hours: { type: "number", minimum: 0 },
rate: { type: "number", minimum: 0 }, rate: { type: "number", minimum: 0 },
@@ -234,11 +253,24 @@ const jsonSchemas = {
expenseCreate: { expenseCreate: {
type: "object", type: "object",
properties: { properties: {
date: { type: "string", format: "date-time" }, date: { type: "string", format: "date" },
description: { type: "string", minLength: 1 }, description: { type: "string", minLength: 1 },
amount: { type: "number", minimum: 0 }, amount: { type: "number", minimum: 0 },
currency: { type: "string", minLength: 3, maxLength: 3 }, currency: { type: "string", minLength: 3, maxLength: 3 },
category: { type: "string", enum: ["Travel", "Meals & Entertainment", "Software & Subscriptions", "Hardware & Equipment", "Office Supplies", "Marketing", "Professional Services", "Utilities", "Other"] }, category: {
type: "string",
enum: [
"Travel",
"Meals & Entertainment",
"Software & Subscriptions",
"Hardware & Equipment",
"Office Supplies",
"Marketing",
"Professional Services",
"Utilities",
"Other",
],
},
billable: { type: "boolean" }, billable: { type: "boolean" },
reimbursable: { type: "boolean" }, reimbursable: { type: "boolean" },
taxDeductible: { type: "boolean" }, taxDeductible: { type: "boolean" },
@@ -267,7 +299,10 @@ const jsonSchemas = {
name: { type: "string", minLength: 1, maxLength: 255 }, name: { type: "string", minLength: 1, maxLength: 255 },
clientId: { type: "string", minLength: 1 }, clientId: { type: "string", minLength: 1 },
businessId: { type: "string" }, businessId: { type: "string" },
schedule: { type: "string", enum: ["weekly", "biweekly", "monthly", "quarterly", "yearly"] }, schedule: {
type: "string",
enum: ["weekly", "biweekly", "monthly", "quarterly", "yearly"],
},
invoicePrefix: { type: "string" }, invoicePrefix: { type: "string" },
taxRate: { type: "number", minimum: 0, maximum: 100 }, taxRate: { type: "number", minimum: 0, maximum: 100 },
currency: { type: "string", minLength: 3, maxLength: 3 }, currency: { type: "string", minLength: 3, maxLength: 3 },
@@ -289,7 +324,7 @@ const jsonSchemas = {
}, },
}, },
}, },
required: ["name", "clientId", "schedule", "items"], required: ["name", "clientId", "schedule", "nextRunLocal", "items"],
additionalProperties: false, additionalProperties: false,
}, },
invoiceSend: { invoiceSend: {
@@ -298,15 +333,50 @@ const jsonSchemas = {
invoiceId: { type: "string" }, invoiceId: { type: "string" },
customSubject: { type: "string" }, customSubject: { type: "string" },
customMessage: { type: "string" }, customMessage: { type: "string" },
ccEmails: { type: "string", description: "Comma-separated CC email addresses" }, ccEmails: {
bccEmails: { type: "string", description: "Comma-separated BCC email addresses" }, type: "string",
description: "Comma-separated CC email addresses",
},
bccEmails: {
type: "string",
description: "Comma-separated BCC email addresses",
},
}, },
required: ["invoiceId"], required: ["invoiceId"],
additionalProperties: false, additionalProperties: false,
}, },
invoiceScheduleSend: {
type: "object",
properties: {
invoiceId: { type: "string" },
scheduledAt: {
type: "string",
format: "date-time",
description: "Absolute ISO 8601 send time, including its UTC offset",
},
timeZone: {
type: "string",
description: "IANA timezone used to choose and display the send time",
},
customSubject: { type: "string" },
customMessage: { type: "string" },
ccEmails: {
type: "string",
description: "Comma-separated CC email addresses",
},
bccEmails: {
type: "string",
description: "Comma-separated BCC email addresses",
},
},
required: ["invoiceId", "scheduledAt", "timeZone"],
additionalProperties: false,
},
bulkIds: { bulkIds: {
type: "object", type: "object",
properties: { ids: { type: "array", items: { type: "string" }, minItems: 1 } }, properties: {
ids: { type: "array", items: { type: "string" }, minItems: 1 },
},
required: ["ids"], required: ["ids"],
additionalProperties: false, additionalProperties: false,
}, },
@@ -353,10 +423,30 @@ function parseDate(value: string, fieldName: string) {
return date; return date;
} }
function parseCalendarDate(value: string, fieldName: string) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `${fieldName} must use YYYY-MM-DD`,
});
}
const date = new Date(`${value}T12:00:00.000Z`);
if (
Number.isNaN(date.getTime()) ||
date.toISOString().slice(0, 10) !== value
) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `${fieldName} is not a valid date`,
});
}
return date;
}
function parseInvoiceItems(items: z.infer<typeof invoiceItemSchema>[]) { function parseInvoiceItems(items: z.infer<typeof invoiceItemSchema>[]) {
return items.map((item) => ({ return items.map((item) => ({
...item, ...item,
date: parseDate(item.date, "item.date"), date: parseCalendarDate(item.date, "item.date"),
})); }));
} }
@@ -368,19 +458,27 @@ function textResult(data: unknown): ToolResult {
const tools = { const tools = {
invoices_list: defineTool({ invoices_list: defineTool({
description: "List invoices for the authenticated user. Optionally filter by status ('draft', 'sent', or 'paid') and/or clientId.", description:
"List invoices for the authenticated user. Optionally filter by status ('draft', 'sent', or 'paid') and/or clientId.",
inputSchema: { inputSchema: {
type: "object", type: "object",
properties: { properties: {
status: { type: "string", enum: ["draft", "sent", "paid"], description: "Filter by invoice status" }, status: {
type: "string",
enum: ["draft", "sent", "paid"],
description: "Filter by invoice status",
},
clientId: { type: "string", description: "Filter by client ID" }, clientId: { type: "string", description: "Filter by client ID" },
}, },
additionalProperties: false, additionalProperties: false,
}, },
schema: z.object({ schema: z
.object({
status: z.enum(["draft", "sent", "paid"]).optional(), status: z.enum(["draft", "sent", "paid"]).optional(),
clientId: z.string().optional(), clientId: z.string().optional(),
}).optional().default({}), })
.optional()
.default({}),
handler: async (input, caller) => caller.invoices.getAll(input ?? {}), handler: async (input, caller) => caller.invoices.getAll(input ?? {}),
}), }),
invoices_get: defineTool({ invoices_get: defineTool({
@@ -396,8 +494,8 @@ const tools = {
handler: async (input, caller) => handler: async (input, caller) =>
caller.invoices.create({ caller.invoices.create({
...input, ...input,
issueDate: parseDate(input.issueDate, "issueDate"), issueDate: parseCalendarDate(input.issueDate, "issueDate"),
dueDate: parseDate(input.dueDate, "dueDate"), dueDate: parseCalendarDate(input.dueDate, "dueDate"),
items: parseInvoiceItems(input.items), items: parseInvoiceItems(input.items),
}), }),
}), }),
@@ -416,9 +514,11 @@ const tools = {
caller.invoices.update({ caller.invoices.update({
...input, ...input,
issueDate: input.issueDate issueDate: input.issueDate
? parseDate(input.issueDate, "issueDate") ? parseCalendarDate(input.issueDate, "issueDate")
: undefined,
dueDate: input.dueDate
? parseCalendarDate(input.dueDate, "dueDate")
: undefined, : undefined,
dueDate: input.dueDate ? parseDate(input.dueDate, "dueDate") : undefined,
items: input.items ? parseInvoiceItems(input.items) : undefined, items: input.items ? parseInvoiceItems(input.items) : undefined,
}), }),
}), }),
@@ -446,14 +546,14 @@ const tools = {
schema: z.object({ schema: z.object({
invoiceId: z.string(), invoiceId: z.string(),
amount: z.number().positive(), amount: z.number().positive(),
date: dateString, date: calendarDateString,
method: paymentMethod.default("other"), method: paymentMethod.default("other"),
notes: z.string().max(500).optional(), notes: z.string().max(500).optional(),
}), }),
handler: async (input, caller) => handler: async (input, caller) =>
caller.payments.create({ caller.payments.create({
...input, ...input,
date: parseDate(input.date, "date"), date: parseCalendarDate(input.date, "date"),
}), }),
}), }),
payments_delete: defineTool({ payments_delete: defineTool({
@@ -485,7 +585,10 @@ const tools = {
inputSchema: { inputSchema: {
...jsonSchemas.clientCreate, ...jsonSchemas.clientCreate,
required: ["id"], required: ["id"],
properties: { id: { type: "string" }, ...jsonSchemas.clientCreate.properties }, properties: {
id: { type: "string" },
...jsonSchemas.clientCreate.properties,
},
}, },
schema: clientCreateSchema.partial().extend({ id: z.string() }), schema: clientCreateSchema.partial().extend({ id: z.string() }),
handler: async (input, caller) => caller.clients.update(input), handler: async (input, caller) => caller.clients.update(input),
@@ -521,7 +624,8 @@ const tools = {
handler: async (input, caller) => caller.businesses.create(input), handler: async (input, caller) => caller.businesses.create(input),
}), }),
businesses_update: defineTool({ businesses_update: defineTool({
description: "Update a business profile. All business fields should be provided.", description:
"Update a business profile. All business fields should be provided.",
inputSchema: { inputSchema: {
...jsonSchemas.businessCreate, ...jsonSchemas.businessCreate,
required: ["id", "name"], required: ["id", "name"],
@@ -553,9 +657,18 @@ const tools = {
properties: { properties: {
description: { type: "string", maxLength: 500 }, description: { type: "string", maxLength: 500 },
clientId: { type: "string" }, clientId: { type: "string" },
invoiceId: { type: "string", description: "Link this timer to a specific invoice. On clock-out, time is added directly to this invoice." }, invoiceId: {
type: "string",
description:
"Link this timer to a specific invoice. On clock-out, time is added directly to this invoice.",
},
rate: { type: "number", minimum: 0 }, rate: { type: "number", minimum: 0 },
startedAt: { type: "string", format: "date-time", description: "Optional backdated start time (ISO 8601). Defaults to now." }, startedAt: {
type: "string",
format: "date-time",
description:
"Optional backdated start time (ISO 8601). Defaults to now.",
},
}, },
additionalProperties: false, additionalProperties: false,
}, },
@@ -569,7 +682,9 @@ const tools = {
handler: async (input, caller) => handler: async (input, caller) =>
caller.timeEntries.clockIn({ caller.timeEntries.clockIn({
...input, ...input,
startedAt: input.startedAt ? parseDate(input.startedAt, "startedAt") : undefined, startedAt: input.startedAt
? parseDate(input.startedAt, "startedAt")
: undefined,
}), }),
}), }),
time_clock_out: defineTool({ time_clock_out: defineTool({
@@ -588,8 +703,13 @@ const tools = {
handler: async (input, caller) => caller.timeEntries.clockOut(input), handler: async (input, caller) => caller.timeEntries.clockOut(input),
}), }),
time_get_running: defineTool({ time_get_running: defineTool({
description: "Get the currently running timer, if any. Returns null if no timer is running.", description:
inputSchema: { type: "object", properties: {}, additionalProperties: false }, "Get the currently running timer, if any. Returns null if no timer is running.",
inputSchema: {
type: "object",
properties: {},
additionalProperties: false,
},
schema: z.object({}).optional().default({}), schema: z.object({}).optional().default({}),
handler: async (_input, caller) => caller.timeEntries.getRunning(), handler: async (_input, caller) => caller.timeEntries.getRunning(),
}), }),
@@ -646,11 +766,14 @@ const tools = {
caller.timeEntries.create({ caller.timeEntries.create({
...input, ...input,
startedAt: parseDate(input.startedAt, "startedAt"), startedAt: parseDate(input.startedAt, "startedAt"),
endedAt: input.endedAt ? parseDate(input.endedAt, "endedAt") : undefined, endedAt: input.endedAt
? parseDate(input.endedAt, "endedAt")
: undefined,
}), }),
}), }),
time_entries_update: defineTool({ time_entries_update: defineTool({
description: "Update an existing time entry by ID. All fields are optional except id.", description:
"Update an existing time entry by ID. All fields are optional except id.",
inputSchema: { inputSchema: {
type: "object", type: "object",
properties: { properties: {
@@ -679,8 +802,12 @@ const tools = {
handler: async (input, caller) => handler: async (input, caller) =>
caller.timeEntries.update({ caller.timeEntries.update({
...input, ...input,
startedAt: input.startedAt ? parseDate(input.startedAt, "startedAt") : undefined, startedAt: input.startedAt
endedAt: input.endedAt ? parseDate(input.endedAt, "endedAt") : undefined, ? parseDate(input.startedAt, "startedAt")
: undefined,
endedAt: input.endedAt
? parseDate(input.endedAt, "endedAt")
: undefined,
}), }),
}), }),
time_entries_delete: defineTool({ time_entries_delete: defineTool({
@@ -712,7 +839,8 @@ const tools = {
}), }),
// ── Expenses ──────────────────────────────────────────────────────────────── // ── Expenses ────────────────────────────────────────────────────────────────
expenses_list: defineTool({ expenses_list: defineTool({
description: "List all expenses for the authenticated user, ordered by date descending.", description:
"List all expenses for the authenticated user, ordered by date descending.",
inputSchema: jsonSchemas.empty, inputSchema: jsonSchemas.empty,
schema: z.object({}).optional().default({}), schema: z.object({}).optional().default({}),
handler: async (_input, caller) => caller.expenses.getAll(), handler: async (_input, caller) => caller.expenses.getAll(),
@@ -724,27 +852,32 @@ const tools = {
handler: async (input, caller) => caller.expenses.getById(input), handler: async (input, caller) => caller.expenses.getById(input),
}), }),
expenses_create: defineTool({ expenses_create: defineTool({
description: "Create an expense. Category must be one of the allowed values. Set billable=true if this will be charged to a client, taxDeductible=true for tax purposes.", description:
"Create an expense. Category must be one of the allowed values. Set billable=true if this will be charged to a client, taxDeductible=true for tax purposes.",
inputSchema: jsonSchemas.expenseCreate, inputSchema: jsonSchemas.expenseCreate,
schema: expenseCreateSchema, schema: expenseCreateSchema,
handler: async (input, caller) => handler: async (input, caller) =>
caller.expenses.create({ caller.expenses.create({
...input, ...input,
date: parseDate(input.date, "date"), date: parseCalendarDate(input.date, "date"),
}), }),
}), }),
expenses_update: defineTool({ expenses_update: defineTool({
description: "Update an existing expense by ID. All fields are optional except id.", description:
"Update an existing expense by ID. All fields are optional except id.",
inputSchema: { inputSchema: {
...jsonSchemas.expenseCreate, ...jsonSchemas.expenseCreate,
required: ["id"], required: ["id"],
properties: { id: { type: "string" }, ...jsonSchemas.expenseCreate.properties }, properties: {
id: { type: "string" },
...jsonSchemas.expenseCreate.properties,
},
}, },
schema: expenseUpdateSchema, schema: expenseUpdateSchema,
handler: async (input, caller) => handler: async (input, caller) =>
caller.expenses.update({ caller.expenses.update({
...input, ...input,
date: input.date ? parseDate(input.date, "date") : undefined, date: input.date ? parseCalendarDate(input.date, "date") : undefined,
}), }),
}), }),
expenses_delete: defineTool({ expenses_delete: defineTool({
@@ -756,13 +889,15 @@ const tools = {
// ── Recurring Invoices ─────────────────────────────────────────────────────── // ── Recurring Invoices ───────────────────────────────────────────────────────
recurring_list: defineTool({ recurring_list: defineTool({
description: "List all recurring invoice templates for the authenticated user, ordered by next due date.", description:
"List all recurring invoice templates for the authenticated user, ordered by next due date.",
inputSchema: jsonSchemas.empty, inputSchema: jsonSchemas.empty,
schema: z.object({}).optional().default({}), schema: z.object({}).optional().default({}),
handler: async (_input, caller) => caller.recurringInvoices.getAll(), handler: async (_input, caller) => caller.recurringInvoices.getAll(),
}), }),
recurring_create: defineTool({ recurring_create: defineTool({
description: "Create a recurring invoice template. Invoices will be auto-generated on the given schedule. Items are line item templates (description, hours, rate).", description:
"Create a recurring invoice template. Invoices will be auto-generated on the given schedule. Items are line item templates (description, hours, rate).",
inputSchema: jsonSchemas.recurringCreate, inputSchema: jsonSchemas.recurringCreate,
schema: recurringCreateSchema, schema: recurringCreateSchema,
handler: async (input, caller) => caller.recurringInvoices.create(input), handler: async (input, caller) => caller.recurringInvoices.create(input),
@@ -771,14 +906,18 @@ const tools = {
description: "Update a recurring invoice template. Replaces all items.", description: "Update a recurring invoice template. Replaces all items.",
inputSchema: { inputSchema: {
...jsonSchemas.recurringCreate, ...jsonSchemas.recurringCreate,
required: ["id", "name", "clientId", "schedule", "items"], required: ["id", "name", "clientId", "schedule", "nextRunLocal", "items"],
properties: { id: { type: "string" }, ...jsonSchemas.recurringCreate.properties }, properties: {
id: { type: "string" },
...jsonSchemas.recurringCreate.properties,
},
}, },
schema: recurringUpdateSchema, schema: recurringUpdateSchema,
handler: async (input, caller) => caller.recurringInvoices.update(input), handler: async (input, caller) => caller.recurringInvoices.update(input),
}), }),
recurring_pause: defineTool({ recurring_pause: defineTool({
description: "Pause a recurring invoice template. No invoices will be generated until resumed.", description:
"Pause a recurring invoice template. No invoices will be generated until resumed.",
inputSchema: jsonSchemas.id, inputSchema: jsonSchemas.id,
schema: z.object({ id: z.string() }), schema: z.object({ id: z.string() }),
handler: async (input, caller) => caller.recurringInvoices.pause(input), handler: async (input, caller) => caller.recurringInvoices.pause(input),
@@ -790,10 +929,12 @@ const tools = {
handler: async (input, caller) => caller.recurringInvoices.resume(input), handler: async (input, caller) => caller.recurringInvoices.resume(input),
}), }),
recurring_generate_now: defineTool({ recurring_generate_now: defineTool({
description: "Immediately generate a draft invoice from a recurring template, regardless of schedule. Returns the new invoice ID.", description:
"Immediately generate a draft invoice from a recurring template, regardless of schedule. Returns the new invoice ID.",
inputSchema: jsonSchemas.id, inputSchema: jsonSchemas.id,
schema: z.object({ id: z.string() }), schema: z.object({ id: z.string() }),
handler: async (input, caller) => caller.recurringInvoices.generateNow(input), handler: async (input, caller) =>
caller.recurringInvoices.generateNow(input),
}), }),
recurring_delete: defineTool({ recurring_delete: defineTool({
description: "Delete a recurring invoice template by ID.", description: "Delete a recurring invoice template by ID.",
@@ -804,7 +945,8 @@ const tools = {
// ── Dashboard ──────────────────────────────────────────────────────────────── // ── Dashboard ────────────────────────────────────────────────────────────────
dashboard_get_stats: defineTool({ dashboard_get_stats: defineTool({
description: "Get a business overview: total revenue (paid invoices), pending amount (sent/overdue invoices), overdue invoice count, total clients, month-over-month revenue change percentage, 6-month revenue chart data, and 5 most recent invoices.", description:
"Get a business overview: total revenue (paid invoices), pending amount (sent/overdue invoices), overdue invoice count, total clients, month-over-month revenue change percentage, 6-month revenue chart data, and 5 most recent invoices.",
inputSchema: jsonSchemas.empty, inputSchema: jsonSchemas.empty,
schema: z.object({}).optional().default({}), schema: z.object({}).optional().default({}),
handler: async (_input, caller) => caller.dashboard.getStats(), handler: async (_input, caller) => caller.dashboard.getStats(),
@@ -812,13 +954,15 @@ const tools = {
// ── Invoice extras ─────────────────────────────────────────────────────────── // ── Invoice extras ───────────────────────────────────────────────────────────
invoices_get_current_open: defineTool({ invoices_get_current_open: defineTool({
description: "Get the most recent draft invoice for the authenticated user. Useful for quickly finding the active working invoice.", description:
"Get the most recent draft invoice for the authenticated user. Useful for quickly finding the active working invoice.",
inputSchema: jsonSchemas.empty, inputSchema: jsonSchemas.empty,
schema: z.object({}).optional().default({}), schema: z.object({}).optional().default({}),
handler: async (_input, caller) => caller.invoices.getCurrentOpen(), handler: async (_input, caller) => caller.invoices.getCurrentOpen(),
}), }),
invoices_send: defineTool({ invoices_send: defineTool({
description: "Send an invoice to the client via email with a PDF attachment. Updates the invoice status to 'sent'. Requires email to be configured (Resend API key on the business or platform).", description:
"Send an invoice to the client via email with a PDF attachment. Updates the invoice status to 'sent'. Requires email to be configured (Resend API key on the business or platform).",
inputSchema: jsonSchemas.invoiceSend, inputSchema: jsonSchemas.invoiceSend,
schema: z.object({ schema: z.object({
invoiceId: z.string(), invoiceId: z.string(),
@@ -827,15 +971,44 @@ const tools = {
ccEmails: z.string().optional(), ccEmails: z.string().optional(),
bccEmails: z.string().optional(), bccEmails: z.string().optional(),
}), }),
handler: async (input, caller) => caller.email.sendInvoice({ ...input, useHtml: false }), handler: async (input, caller) =>
caller.email.sendInvoice({ ...input, useHtml: false }),
}),
invoices_schedule_send: defineTool({
description:
"Schedule an invoice email for a future absolute time. The IANA timezone is retained for consistent display; the worker performs idempotent delivery.",
inputSchema: jsonSchemas.invoiceScheduleSend,
schema: z.object({
invoiceId: z.string(),
scheduledAt: z.coerce.date(),
timeZone: z.string(),
customSubject: z.string().optional(),
customMessage: z.string().optional(),
ccEmails: z.string().optional(),
bccEmails: z.string().optional(),
}),
handler: async (input, caller) =>
caller.email.scheduleInvoice({ ...input, useHtml: false }),
}),
invoices_cancel_scheduled_send: defineTool({
description:
"Cancel a pending scheduled invoice email before the worker starts it.",
inputSchema: jsonSchemas.id,
schema: z.object({ id: z.string() }),
handler: async (input, caller) =>
caller.email.cancelScheduledInvoice({ invoiceId: input.id }),
}), }),
invoices_send_reminder: defineTool({ invoices_send_reminder: defineTool({
description: "Send a payment reminder email to the client for a sent or overdue invoice.", description:
"Send a payment reminder email to the client for a sent or overdue invoice.",
inputSchema: { inputSchema: {
type: "object", type: "object",
properties: { properties: {
id: { type: "string" }, id: { type: "string" },
customMessage: { type: "string", description: "Optional custom message to include in the reminder" }, customMessage: {
type: "string",
description: "Optional custom message to include in the reminder",
},
}, },
required: ["id"], required: ["id"],
additionalProperties: false, additionalProperties: false,
@@ -844,17 +1017,26 @@ const tools = {
handler: async (input, caller) => caller.invoices.sendReminder(input), handler: async (input, caller) => caller.invoices.sendReminder(input),
}), }),
invoices_generate_public_token: defineTool({ invoices_generate_public_token: defineTool({
description: "Generate a shareable public link for an invoice. Returns a web view URL (/i/{token}) and a direct PDF URL (/api/i/{token}/pdf). Set ttlHours to make the link expire automatically (e.g. 24 for a 24-hour preview link). Omit ttlHours for a permanent link.", description:
"Generate a shareable public link for an invoice. Returns a web view URL (/i/{token}) and a direct PDF URL (/api/i/{token}/pdf). Set ttlHours to make the link expire automatically (e.g. 24 for a 24-hour preview link). Omit ttlHours for a permanent link.",
inputSchema: { inputSchema: {
type: "object", type: "object",
properties: { properties: {
id: { type: "string" }, id: { type: "string" },
ttlHours: { type: "number", exclusiveMinimum: 0, description: "Hours until the link expires. Omit for a permanent link." }, ttlHours: {
type: "number",
exclusiveMinimum: 0,
description:
"Hours until the link expires. Omit for a permanent link.",
},
}, },
required: ["id"], required: ["id"],
additionalProperties: false, additionalProperties: false,
}, },
schema: z.object({ id: z.string(), ttlHours: z.number().positive().optional() }), schema: z.object({
id: z.string(),
ttlHours: z.number().positive().optional(),
}),
handler: async (input, caller) => { handler: async (input, caller) => {
const result = await caller.invoices.generatePublicToken(input); const result = await caller.invoices.generatePublicToken(input);
const base = getAppUrl(); const base = getAppUrl();
@@ -866,7 +1048,8 @@ const tools = {
}, },
}), }),
invoices_revoke_public_token: defineTool({ invoices_revoke_public_token: defineTool({
description: "Revoke the public shareable link for an invoice, making it inaccessible without authentication.", description:
"Revoke the public shareable link for an invoice, making it inaccessible without authentication.",
inputSchema: jsonSchemas.id, inputSchema: jsonSchemas.id,
schema: z.object({ id: z.string() }), schema: z.object({ id: z.string() }),
handler: async (input, caller) => caller.invoices.revokePublicToken(input), handler: async (input, caller) => caller.invoices.revokePublicToken(input),
@@ -889,13 +1072,15 @@ const tools = {
// ── Invoice Templates ──────────────────────────────────────────────────────── // ── Invoice Templates ────────────────────────────────────────────────────────
templates_list: defineTool({ templates_list: defineTool({
description: "List all saved invoice templates (notes and terms). Use these to populate invoice notes/terms fields.", description:
"List all saved invoice templates (notes and terms). Use these to populate invoice notes/terms fields.",
inputSchema: jsonSchemas.empty, inputSchema: jsonSchemas.empty,
schema: z.object({}).optional().default({}), schema: z.object({}).optional().default({}),
handler: async (_input, caller) => caller.invoiceTemplates.getAll(), handler: async (_input, caller) => caller.invoiceTemplates.getAll(),
}), }),
templates_list_by_type: defineTool({ templates_list_by_type: defineTool({
description: "List invoice templates filtered by type: 'notes' for invoice notes, 'terms' for payment terms.", description:
"List invoice templates filtered by type: 'notes' for invoice notes, 'terms' for payment terms.",
inputSchema: { inputSchema: {
type: "object", type: "object",
properties: { type: { type: "string", enum: ["notes", "terms"] } }, properties: { type: { type: "string", enum: ["notes", "terms"] } },
@@ -906,7 +1091,8 @@ const tools = {
handler: async (input, caller) => caller.invoiceTemplates.getByType(input), handler: async (input, caller) => caller.invoiceTemplates.getByType(input),
}), }),
templates_create: defineTool({ templates_create: defineTool({
description: "Create an invoice template. Set isDefault=true to automatically apply this template to new invoices of this type.", description:
"Create an invoice template. Set isDefault=true to automatically apply this template to new invoices of this type.",
inputSchema: { inputSchema: {
type: "object", type: "object",
properties: { properties: {
@@ -958,20 +1144,31 @@ const tools = {
// ── Business email config ───────────────────────────────────────────────────── // ── Business email config ─────────────────────────────────────────────────────
businesses_get_email_config: defineTool({ businesses_get_email_config: defineTool({
description: "Get the email configuration for a business (Resend domain, from-name, and whether an API key is set). The API key itself is never returned.", description:
"Get the email configuration for a business (Resend domain, from-name, and whether an API key is set). The API key itself is never returned.",
inputSchema: jsonSchemas.id, inputSchema: jsonSchemas.id,
schema: z.object({ id: z.string() }), schema: z.object({ id: z.string() }),
handler: async (input, caller) => caller.businesses.getEmailConfig(input), handler: async (input, caller) => caller.businesses.getEmailConfig(input),
}), }),
businesses_update_email_config: defineTool({ businesses_update_email_config: defineTool({
description: "Configure custom email sending for a business via Resend. Set resendApiKey and resendDomain to send invoices from your own domain. Set emailFromName for the sender display name.", description:
"Configure custom email sending for a business via Resend. Set resendApiKey and resendDomain to send invoices from your own domain. Set emailFromName for the sender display name.",
inputSchema: { inputSchema: {
type: "object", type: "object",
properties: { properties: {
id: { type: "string" }, id: { type: "string" },
resendApiKey: { type: "string", description: "Resend API key (re_...)" }, resendApiKey: {
resendDomain: { type: "string", description: "Verified Resend sending domain (e.g. mail.example.com)" }, type: "string",
emailFromName: { type: "string", description: "Display name for the From field" }, description: "Resend API key (re_...)",
},
resendDomain: {
type: "string",
description: "Verified Resend sending domain (e.g. mail.example.com)",
},
emailFromName: {
type: "string",
description: "Display name for the From field",
},
}, },
required: ["id"], required: ["id"],
additionalProperties: false, additionalProperties: false,
@@ -982,12 +1179,14 @@ const tools = {
resendDomain: z.string().optional().or(z.literal("")), resendDomain: z.string().optional().or(z.literal("")),
emailFromName: z.string().optional().or(z.literal("")), emailFromName: z.string().optional().or(z.literal("")),
}), }),
handler: async (input, caller) => caller.businesses.updateEmailConfig(input), handler: async (input, caller) =>
caller.businesses.updateEmailConfig(input),
}), }),
// ── User profile ────────────────────────────────────────────────────────────── // ── User profile ──────────────────────────────────────────────────────────────
profile_get: defineTool({ profile_get: defineTool({
description: "Get the authenticated user's profile: id, name, email, and role.", description:
"Get the authenticated user's profile: id, name, email, and role.",
inputSchema: jsonSchemas.empty, inputSchema: jsonSchemas.empty,
schema: z.object({}).optional().default({}), schema: z.object({}).optional().default({}),
handler: async (_input, caller) => caller.settings.getProfile(), handler: async (_input, caller) => caller.settings.getProfile(),
@@ -1045,7 +1244,12 @@ async function handleMcpRequest(request: Request) {
const ctx = await createTRPCContext({ headers: request.headers }); const ctx = await createTRPCContext({ headers: request.headers });
if (!ctx.session?.user || ctx.authSource !== "api-key") { if (!ctx.session?.user || ctx.authSource !== "api-key") {
return rpcError(body.id, -32001, "A valid beenvoice API key is required", 401); return rpcError(
body.id,
-32001,
"A valid beenvoice API key is required",
401,
);
} }
if (body.method === "initialize") { if (body.method === "initialize") {
@@ -1084,7 +1288,12 @@ async function handleMcpRequest(request: Request) {
const tool = tools[params.data.name as keyof typeof tools]; const tool = tools[params.data.name as keyof typeof tools];
if (!tool) { if (!tool) {
return rpcError(body.id, -32602, `Unknown tool: ${params.data.name}`, 400); return rpcError(
body.id,
-32602,
`Unknown tool: ${params.data.name}`,
400,
);
} }
const input = tool.schema.safeParse(params.data.arguments ?? {}); const input = tool.schema.safeParse(params.data.arguments ?? {});
@@ -1100,7 +1309,10 @@ async function handleMcpRequest(request: Request) {
try { try {
const caller = createCaller(async () => ctx); const caller = createCaller(async () => ctx);
return rpcResult(body.id, textResult(await tool.handler(input.data, caller))); return rpcResult(
body.id,
textResult(await tool.handler(input.data, caller)),
);
} catch (error) { } catch (error) {
return rpcError(body.id, -32000, getErrorMessage(error), 500); return rpcError(body.id, -32000, getErrorMessage(error), 500);
} }
+2
View File
@@ -1,6 +1,8 @@
import { env } from "~/env"; import { env } from "~/env";
import { RegisterForm } from "./register-form"; import { RegisterForm } from "./register-form";
export const dynamic = "force-dynamic";
export default function RegisterPage() { export default function RegisterPage() {
return <RegisterForm signupsDisabled={env.DISABLE_SIGNUPS === true} />; return <RegisterForm signupsDisabled={env.DISABLE_SIGNUPS === true} />;
} }
+2
View File
@@ -2,6 +2,8 @@ import { Suspense } from "react";
import { env } from "~/env"; import { env } from "~/env";
import { SignInForm } from "./signin-form"; import { SignInForm } from "./signin-form";
export const dynamic = "force-dynamic";
export default function SignInPage() { export default function SignInPage() {
return ( return (
<Suspense <Suspense
@@ -46,7 +46,11 @@ export function ActiveTimerWidget({
if (intervalRef.current) clearInterval(intervalRef.current); if (intervalRef.current) clearInterval(intervalRef.current);
if (running) { if (running) {
const tick = () => const tick = () =>
setElapsed(Math.floor((Date.now() - new Date(running.startedAt).getTime()) / 1000)); setElapsed(
Math.floor(
(Date.now() - new Date(running.startedAt).getTime()) / 1000,
),
);
tick(); tick();
intervalRef.current = setInterval(tick, 1000); intervalRef.current = setInterval(tick, 1000);
} }
@@ -73,7 +77,10 @@ export function ActiveTimerWidget({
window.location.assign(`/dashboard/invoices/${data.invoice!.id}`), window.location.assign(`/dashboard/invoices/${data.invoice!.id}`),
}, },
}); });
} else if (data.outcome === "saved_no_invoice" || data.outcome === "saved_no_client") { } else if (
data.outcome === "saved_no_invoice" ||
data.outcome === "saved_no_client"
) {
toast.warning("Time saved", { description: message }); toast.warning("Time saved", { description: message });
} else { } else {
toast.success(message); toast.success(message);
@@ -96,12 +103,19 @@ export function ActiveTimerWidget({
<Button <Button
variant="destructive" variant="destructive"
size="sm" size="sm"
aria-label={compact ? "Stop timer" : undefined}
onClick={() => clockOut.mutate({})} onClick={() => clockOut.mutate({})}
disabled={clockOut.isPending} disabled={clockOut.isPending}
className={cn(compact && "h-8 px-2", className)} className={cn(compact && "size-8 p-0", className)}
> >
<Square className={cn("h-3.5 w-3.5", !compact && "mr-1.5")} /> <Square data-icon={compact ? undefined : "inline-start"} />
{!compact && (clockOut.isPending ? "Stopping…" : "Stop")} {compact ? (
<span className="sr-only">Stop timer</span>
) : clockOut.isPending ? (
"Stopping…"
) : (
"Stop"
)}
</Button> </Button>
); );
@@ -133,18 +147,18 @@ export function ActiveTimerWidget({
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Link <Link
href="/dashboard/time-clock" href="/dashboard/time-clock"
className="border-primary/30 bg-primary/5 relative flex h-10 w-10 items-center justify-center rounded-md border transition-colors hover:bg-primary/10" className="border-primary/30 bg-primary/5 hover:bg-primary/10 relative flex size-10 items-center justify-center rounded-xl border transition-colors"
> >
<Clock className="text-primary h-5 w-5" /> <Clock className="text-primary size-5" />
<span className="absolute top-1 right-1 flex h-2 w-2"> <span className="absolute top-1 right-1 flex size-2">
<span className="bg-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75" /> <span className="bg-primary absolute inline-flex size-full animate-ping rounded-full opacity-75" />
<span className="bg-primary relative inline-flex h-2 w-2 rounded-full" /> <span className="bg-primary relative inline-flex size-2 rounded-full" />
</span> </span>
</Link> </Link>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent <TooltipContent
side="right" side="right"
className="bg-popover text-popover-foreground border-border max-w-56 space-y-2 border p-3 text-sm [&>svg]:bg-popover [&>svg]:fill-popover" className="bg-popover text-popover-foreground border-border [&>svg]:bg-popover [&>svg]:fill-popover max-w-56 space-y-2 border p-3 text-sm"
> >
<p className="text-sm font-medium"> <p className="text-sm font-medium">
{description} {description}
@@ -169,10 +183,17 @@ export function ActiveTimerWidget({
</Link> </Link>
</p> </p>
) : ( ) : (
<p className="text-muted-foreground text-xs">No invoice selected</p> <p className="text-muted-foreground text-xs">
No invoice selected
</p>
)} )}
<div className="flex gap-2 pt-1"> <div className="flex gap-2 pt-1">
<Button variant="outline" size="sm" asChild className="h-8 flex-1"> <Button
variant="outline"
size="sm"
asChild
className="h-8 flex-1"
>
<Link href="/dashboard/time-clock">Open</Link> <Link href="/dashboard/time-clock">Open</Link>
</Button> </Button>
{renderStopButton()} {renderStopButton()}
@@ -185,58 +206,41 @@ export function ActiveTimerWidget({
} }
return ( return (
<Card className="border-primary/30 bg-primary/5"> <Card className="border-primary/30 bg-primary/5 shadow-none">
<CardContent className="flex flex-col gap-3 p-3"> <CardContent className="flex flex-col gap-2.5 p-3">
<div className="flex items-start gap-2">
<span className="relative mt-1 flex h-2.5 w-2.5 flex-shrink-0">
<span className="bg-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75" />
<span className="bg-primary relative inline-flex h-2.5 w-2.5 rounded-full" />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm leading-snug font-medium">
{description}
{running.client && (
<span className="text-muted-foreground font-normal">
{" "}
· {running.client.name}
</span>
)}
</p>
<p className="text-muted-foreground mt-1 text-xs leading-snug">
{invoiceLabel ? (
<>
Billing to{" "}
<Link <Link
href={`/dashboard/invoices/${running.invoice!.id}`} href="/dashboard/time-clock"
className="text-primary hover:underline" className="focus-visible:ring-ring flex items-center justify-between gap-3 rounded-md outline-hidden focus-visible:ring-2"
> >
{invoiceLabel} <span className="flex min-w-0 items-center gap-2">
</Link> <span className="relative flex size-2.5 shrink-0">
</> <span className="bg-primary absolute inline-flex size-full animate-ping rounded-full opacity-75" />
) : ( <span className="bg-primary relative inline-flex size-2.5 rounded-full" />
<>No invoice selected open time clock to assign</> </span>
)} <span className="text-sm font-medium">Timer running</span>
{" · "} </span>
<Link href="/dashboard/time-clock" className="text-primary hover:underline"> <span className="text-primary shrink-0 font-mono text-sm font-bold tabular-nums">
Time clock
</Link>
</p>
</div>
</div>
<div className="flex flex-col items-center gap-2">
<span className="text-primary text-center font-mono text-xl font-bold tabular-nums">
{formatElapsedSeconds(elapsed)} {formatElapsedSeconds(elapsed)}
</span> </span>
<div className="flex w-full flex-col gap-1.5"> </Link>
<Button variant="outline" size="sm" asChild className="h-8 w-full">
<p
className="text-muted-foreground truncate text-xs"
title={description}
>
{description}
{running.client ? ` · ${running.client.name}` : ""}
{invoiceLabel ? ` · ${invoiceLabel}` : ""}
</p>
<div className="flex gap-2">
<Button variant="outline" size="sm" asChild className="flex-1">
<Link href="/dashboard/time-clock"> <Link href="/dashboard/time-clock">
<Clock className="mr-1 h-3.5 w-3.5" /> <Clock data-icon="inline-start" />
Open Open
</Link> </Link>
</Button> </Button>
{renderStopButton("w-full")} {renderStopButton("flex-1")}
</div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -24,6 +24,8 @@ import {
Hash, Hash,
ArrowLeft, ArrowLeft,
} from "lucide-react"; } from "lucide-react";
import { BusinessBrandImage } from "~/components/branding/business-brand-image";
import { hasBusinessBrandAsset } from "~/lib/business-branding";
interface BusinessDetailPageProps { interface BusinessDetailPageProps {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
@@ -74,13 +76,12 @@ export default async function BusinessDetailPage({
<Card className="bg-card border-border border"> <Card className="bg-card border-border border">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
{business.logoStorageKey ? ( {hasBusinessBrandAsset(business) ? (
<div className="bg-muted border-border/40 flex h-9 max-w-32 shrink-0 items-center justify-center overflow-hidden border px-1.5 py-1"> <div className="bg-muted border-border/40 flex h-9 max-w-32 shrink-0 items-center justify-center overflow-hidden border px-1.5 py-1">
{/* eslint-disable-next-line @next/next/no-img-element -- object-storage-backed image, not a static asset */} <BusinessBrandImage
<img business={business}
src={`/api/business-logo/${business.id}`} kind="icon"
alt={`${business.name} logo`} className="h-full w-full"
className="h-full w-auto max-w-full object-contain"
/> />
</div> </div>
) : ( ) : (
@@ -17,6 +17,8 @@ import {
} from "~/components/ui/dialog"; } from "~/components/ui/dialog";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { toast } from "sonner"; import { toast } from "sonner";
import { BusinessBrandImage } from "~/components/branding/business-brand-image";
import { hasBusinessBrandAsset } from "~/lib/business-branding";
// Type for business data // Type for business data
interface Business { interface Business {
@@ -35,6 +37,17 @@ interface Business {
taxId: string | null; taxId: string | null;
logoUrl: string | null; logoUrl: string | null;
logoStorageKey: string | null; logoStorageKey: string | null;
logoMimeType: string | null;
logoDarkStorageKey: string | null;
logoDarkMimeType: string | null;
wordmarkLightStorageKey: string | null;
wordmarkLightMimeType: string | null;
wordmarkDarkStorageKey: string | null;
wordmarkDarkMimeType: string | null;
iconLightStorageKey: string | null;
iconLightMimeType: string | null;
iconDarkStorageKey: string | null;
iconDarkMimeType: string | null;
createdById: string; createdById: string;
createdAt: Date; createdAt: Date;
updatedAt: Date | null; updatedAt: Date | null;
@@ -88,12 +101,12 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
return ( return (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="bg-primary/10 hidden h-8 w-8 shrink-0 items-center justify-center overflow-hidden p-2 sm:flex"> <div className="bg-primary/10 hidden h-8 w-8 shrink-0 items-center justify-center overflow-hidden p-2 sm:flex">
{business.logoStorageKey ? ( {hasBusinessBrandAsset(business) ? (
// eslint-disable-next-line @next/next/no-img-element -- object-storage-backed image, not a static asset <BusinessBrandImage
<img business={business}
src={`/api/business-logo/${business.id}`} kind="icon"
alt="" decorative
className="h-full w-full object-contain" className="h-full w-full"
/> />
) : ( ) : (
<Building className="text-primary h-4 w-4" /> <Building className="text-primary h-4 w-4" />
@@ -23,6 +23,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status"; import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
import type { StoredInvoiceStatus } from "~/types/invoice"; import type { StoredInvoiceStatus } from "~/types/invoice";
import { formatCalendarDate } from "@beenvoice/domain/time-zone";
interface ClientDetailPageProps { interface ClientDetailPageProps {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
@@ -34,17 +35,19 @@ export default async function ClientDetailPage({
const { id } = await params; const { id } = await params;
const client = await api.clients.getById({ id }); const client = await api.clients.getById({ id });
const profile = await api.settings.getProfile();
const timeZone = profile?.timeZone ?? "America/New_York";
if (!client) { if (!client) {
notFound(); notFound();
} }
const formatDate = (date: Date) => { const formatDate = (date: Date) => {
return new Intl.DateTimeFormat("en-US", { return formatCalendarDate(date, {
year: "numeric", year: "numeric",
month: "long", month: "long",
day: "numeric", day: "numeric",
}).format(date); });
}; };
const formatCurrency = (amount: number) => { const formatCurrency = (amount: number) => {
@@ -249,16 +252,19 @@ export default async function ClientDetailPage({
getEffectiveInvoiceStatus( getEffectiveInvoiceStatus(
invoice.status as StoredInvoiceStatus, invoice.status as StoredInvoiceStatus,
invoice.dueDate, invoice.dueDate,
timeZone,
) === "paid" ) === "paid"
? "default" ? "default"
: getEffectiveInvoiceStatus( : getEffectiveInvoiceStatus(
invoice.status as StoredInvoiceStatus, invoice.status as StoredInvoiceStatus,
invoice.dueDate, invoice.dueDate,
timeZone,
) === "sent" ) === "sent"
? "secondary" ? "secondary"
: getEffectiveInvoiceStatus( : getEffectiveInvoiceStatus(
invoice.status as StoredInvoiceStatus, invoice.status as StoredInvoiceStatus,
invoice.dueDate, invoice.dueDate,
timeZone,
) === "overdue" ) === "overdue"
? "destructive" ? "destructive"
: "outline" : "outline"
@@ -268,6 +274,7 @@ export default async function ClientDetailPage({
{getEffectiveInvoiceStatus( {getEffectiveInvoiceStatus(
invoice.status as StoredInvoiceStatus, invoice.status as StoredInvoiceStatus,
invoice.dueDate, invoice.dueDate,
timeZone,
)} )}
</Badge> </Badge>
</div> </div>
+11 -4
View File
@@ -44,6 +44,10 @@ import {
} from "lucide-react"; } from "lucide-react";
import { formatCurrency, SUPPORTED_CURRENCIES } from "~/lib/currency"; import { formatCurrency, SUPPORTED_CURRENCIES } from "~/lib/currency";
import { EXPENSE_CATEGORIES } from "~/lib/expense-categories"; import { EXPENSE_CATEGORIES } from "~/lib/expense-categories";
import {
calendarDateFromLocalDate,
formatCalendarDate,
} from "@beenvoice/domain/time-zone";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -66,7 +70,7 @@ interface ExpenseFormData {
} }
const defaultForm: ExpenseFormData = { const defaultForm: ExpenseFormData = {
date: new Date(), date: calendarDateFromLocalDate(new Date()),
description: "", description: "",
amount: 0, amount: 0,
currency: "USD", currency: "USD",
@@ -473,11 +477,11 @@ export default function ExpensesPage() {
)} )}
</div> </div>
<p className="text-muted-foreground mt-0.5 text-xs"> <p className="text-muted-foreground mt-0.5 text-xs">
{new Intl.DateTimeFormat("en-US", { {formatCalendarDate(expense.date, {
month: "short", month: "short",
day: "numeric", day: "numeric",
year: "numeric", year: "numeric",
}).format(new Date(expense.date))} })}
{expense.business ? ` · ${expense.business.name}` : ""} {expense.business ? ` · ${expense.business.name}` : ""}
{expense.client ? ` · ${expense.client.name}` : ""} {expense.client ? ` · ${expense.client.name}` : ""}
</p> </p>
@@ -690,7 +694,10 @@ export default function ExpensesPage() {
<DatePicker <DatePicker
date={form.date} date={form.date}
onDateChange={(d) => onDateChange={(d) =>
setForm((p) => ({ ...p, date: d ?? new Date() })) setForm((p) => ({
...p,
date: d ?? calendarDateFromLocalDate(new Date()),
}))
} }
className="w-full" className="w-full"
/> />
@@ -2,17 +2,15 @@
import type { ColumnDef } from "@tanstack/react-table"; import type { ColumnDef } from "@tanstack/react-table";
import { DataTable } from "~/components/data/data-table"; import { DataTable } from "~/components/data/data-table";
import { import { formatLineItemDetail, isFixedLineItem } from "~/lib/invoice-line-item";
formatLineItemDetail, import { formatCalendarDate } from "@beenvoice/domain/time-zone";
isFixedLineItem,
} from "~/lib/invoice-line-item";
const formatDate = (date: Date) => { const formatDate = (date: Date) => {
return new Intl.DateTimeFormat("en-US", { return formatCalendarDate(date, {
year: "numeric", year: "numeric",
month: "short", month: "short",
day: "numeric", day: "numeric",
}).format(new Date(date)); });
}; };
const formatCurrency = (amount: number) => { const formatCurrency = (amount: number) => {
+223 -73
View File
@@ -4,6 +4,7 @@ import {
AlertTriangle, AlertTriangle,
Bell, Bell,
Building, Building,
CalendarClock,
Check, Check,
Copy, Copy,
DollarSign, DollarSign,
@@ -19,8 +20,23 @@ import {
Trash2, Trash2,
User, User,
} from "lucide-react"; } from "lucide-react";
import {
DEFAULT_TIME_ZONE,
calendarDateFromLocalDate,
formatCalendarDate,
formatZonedDateTime,
toZonedDateTimeInputValue,
zonedDateTimeToInstant,
} from "@beenvoice/domain/time-zone";
import { BusinessBrandImage } from "~/components/branding/business-brand-image";
import { hasBusinessBrandAsset } from "~/lib/business-branding";
import Link from "next/link"; import Link from "next/link";
import { notFound, useParams, useRouter, useSearchParams } from "next/navigation"; import {
notFound,
useParams,
useRouter,
useSearchParams,
} from "next/navigation";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { StatusBadge } from "~/components/data/status-badge"; import { StatusBadge } from "~/components/data/status-badge";
@@ -58,7 +74,6 @@ import { Separator } from "~/components/ui/separator";
import { Textarea } from "~/components/ui/textarea"; import { Textarea } from "~/components/ui/textarea";
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label"; import { Label } from "~/components/ui/label";
import { DatePicker } from "~/components/ui/date-picker";
import { import {
getEffectiveInvoiceStatus, getEffectiveInvoiceStatus,
isInvoiceOverdue, isInvoiceOverdue,
@@ -103,6 +118,8 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
const { data: invoice, isLoading } = api.invoices.getById.useQuery({ const { data: invoice, isLoading } = api.invoices.getById.useQuery({
id: invoiceId, id: invoiceId,
}); });
const { data: profile } = api.settings.getProfile.useQuery();
const timeZone = profile?.timeZone ?? DEFAULT_TIME_ZONE;
const { data: payments, isLoading: paymentsLoading } = const { data: payments, isLoading: paymentsLoading } =
api.payments.getByInvoice.useQuery({ invoiceId }); api.payments.getByInvoice.useQuery({ invoiceId });
const utils = api.useUtils(); const utils = api.useUtils();
@@ -194,12 +211,16 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
if (!invoice) notFound(); if (!invoice) notFound();
const formatDate = (date: Date) => const formatDate = (date: Date) =>
new Intl.DateTimeFormat("en-US", { year: "numeric", month: "short", day: "numeric" }).format( formatCalendarDate(date, {
new Date(date), year: "numeric",
); month: "short",
day: "numeric",
});
const formatCurrency = (amount: number, currency = invoice.currency) => const formatCurrency = (amount: number, currency = invoice.currency) =>
new Intl.NumberFormat("en-US", { style: "currency", currency }).format(amount); new Intl.NumberFormat("en-US", { style: "currency", currency }).format(
amount,
);
const subtotal = invoice.items.reduce((s, i) => s + i.amount, 0); const subtotal = invoice.items.reduce((s, i) => s + i.amount, 0);
const taxAmount = (subtotal * invoice.taxRate) / 100; const taxAmount = (subtotal * invoice.taxRate) / 100;
@@ -207,9 +228,14 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
const totalPaid = (payments ?? []).reduce((s, p) => s + p.amount, 0); const totalPaid = (payments ?? []).reduce((s, p) => s + p.amount, 0);
const balanceDue = total - totalPaid; const balanceDue = total - totalPaid;
const storedStatus = invoice.status as StoredInvoiceStatus; const storedStatus = invoice.status as StoredInvoiceStatus;
const effectiveStatus = getEffectiveInvoiceStatus(storedStatus, invoice.dueDate); const effectiveStatus = getEffectiveInvoiceStatus(
const isOverdue = isInvoiceOverdue(storedStatus, invoice.dueDate); storedStatus,
const canSendReminder = effectiveStatus === "sent" || effectiveStatus === "overdue"; invoice.dueDate,
timeZone,
);
const isOverdue = isInvoiceOverdue(storedStatus, invoice.dueDate, timeZone);
const canSendReminder =
effectiveStatus === "sent" || effectiveStatus === "overdue";
const publicUrl = invoice.publicToken const publicUrl = invoice.publicToken
? `${window.location.origin}/i/${invoice.publicToken}` ? `${window.location.origin}/i/${invoice.publicToken}`
@@ -231,8 +257,10 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
createPayment.mutate({ createPayment.mutate({
invoiceId, invoiceId,
amount, amount,
date: new Date(), date: calendarDateFromLocalDate(new Date()),
method: paymentMethod as Parameters<typeof createPayment.mutate>[0]["method"], method: paymentMethod as Parameters<
typeof createPayment.mutate
>[0]["method"],
notes: paymentNotes || undefined, notes: paymentNotes || undefined,
}); });
}; };
@@ -243,7 +271,11 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
title="Invoice Details" title="Invoice Details"
description="View and manage invoice information" description="View and manage invoice information"
> >
<PDFDownloadButton invoiceId={invoice.id} variant="outline" className="hover-lift" /> <PDFDownloadButton
invoiceId={invoice.id}
variant="outline"
className="hover-lift"
/>
{storedStatus === "draft" ? ( {storedStatus === "draft" ? (
<Button asChild variant="default" className="hover-lift"> <Button asChild variant="default" className="hover-lift">
<Link href={`/dashboard/invoices/${invoice.id}/edit`}> <Link href={`/dashboard/invoices/${invoice.id}/edit`}>
@@ -270,15 +302,21 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
<StatusBadge status={effectiveStatus} /> <StatusBadge status={effectiveStatus} />
</div> </div>
<div className="text-muted-foreground space-y-1 text-sm sm:space-y-0"> <div className="text-muted-foreground space-y-1 text-sm sm:space-y-0">
<div className="sm:inline">Issued {formatDate(invoice.issueDate)}</div> <div className="sm:inline">
Issued {formatDate(invoice.issueDate)}
</div>
<div className="sm:inline sm:before:content-['_•_']"> <div className="sm:inline sm:before:content-['_•_']">
Due {formatDate(invoice.dueDate)} Due {formatDate(invoice.dueDate)}
</div> </div>
</div> </div>
</div> </div>
<div className="flex-shrink-0 text-left sm:text-right"> <div className="flex-shrink-0 text-left sm:text-right">
<p className="text-muted-foreground text-sm">Total Amount</p> <p className="text-muted-foreground text-sm">
<p className="text-primary text-3xl font-bold">{formatCurrency(total)}</p> Total Amount
</p>
<p className="text-primary text-3xl font-bold">
{formatCurrency(total)}
</p>
{totalPaid > 0 && balanceDue > 0 && ( {totalPaid > 0 && balanceDue > 0 && (
<p className="text-muted-foreground mt-0.5 text-sm"> <p className="text-muted-foreground mt-0.5 text-sm">
Balance due: {formatCurrency(balanceDue)} Balance due: {formatCurrency(balanceDue)}
@@ -300,7 +338,8 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
<p className="font-medium">Invoice Overdue</p> <p className="font-medium">Invoice Overdue</p>
<p className="text-sm"> <p className="text-sm">
{Math.ceil( {Math.ceil(
(new Date().getTime() - new Date(invoice.dueDate).getTime()) / (new Date().getTime() -
new Date(invoice.dueDate).getTime()) /
(1000 * 60 * 60 * 24), (1000 * 60 * 60 * 24),
)}{" "} )}{" "}
days past due date days past due date
@@ -321,14 +360,18 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<h3 className="text-foreground text-xl font-semibold">{invoice.client.name}</h3> <h3 className="text-foreground text-xl font-semibold">
{invoice.client.name}
</h3>
<div className="space-y-3"> <div className="space-y-3">
{invoice.client.email && ( {invoice.client.email && (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="bg-primary/10 p-2"> <div className="bg-primary/10 p-2">
<Mail className="text-primary h-4 w-4" /> <Mail className="text-primary h-4 w-4" />
</div> </div>
<span className="text-sm break-all">{invoice.client.email}</span> <span className="text-sm break-all">
{invoice.client.email}
</span>
</div> </div>
)} )}
{invoice.client.phone && ( {invoice.client.phone && (
@@ -345,8 +388,12 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
<MapPin className="text-primary h-4 w-4" /> <MapPin className="text-primary h-4 w-4" />
</div> </div>
<div className="space-y-1 text-sm"> <div className="space-y-1 text-sm">
{invoice.client.addressLine1 && <div>{invoice.client.addressLine1}</div>} {invoice.client.addressLine1 && (
{invoice.client.addressLine2 && <div>{invoice.client.addressLine2}</div>} <div>{invoice.client.addressLine1}</div>
)}
{invoice.client.addressLine2 && (
<div>{invoice.client.addressLine2}</div>
)}
{(invoice.client.city ?? {(invoice.client.city ??
invoice.client.state ?? invoice.client.state ??
invoice.client.postalCode) && ( invoice.client.postalCode) && (
@@ -360,7 +407,9 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
.join(", ")} .join(", ")}
</div> </div>
)} )}
{invoice.client.country && <div>{invoice.client.country}</div>} {invoice.client.country && (
<div>{invoice.client.country}</div>
)}
</div> </div>
</div> </div>
)} )}
@@ -377,13 +426,12 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{invoice.business.logoStorageKey && ( {hasBusinessBrandAsset(invoice.business) && (
<div className="bg-muted border-border/40 flex h-12 max-w-40 w-fit items-center justify-center overflow-hidden border px-2 py-1.5"> <div className="bg-muted border-border/40 flex h-12 w-fit max-w-40 items-center justify-center overflow-hidden border px-2 py-1.5">
{/* eslint-disable-next-line @next/next/no-img-element -- object-storage-backed image, not a static asset */} <BusinessBrandImage
<img business={invoice.business}
src={`/api/business-logo/${invoice.business.id}`} kind="logo"
alt={`${invoice.business.name} logo`} className="h-full max-w-36 min-w-20"
className="h-full w-auto max-w-full object-contain"
/> />
</div> </div>
)} )}
@@ -396,7 +444,9 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
<div className="bg-primary/10 p-2"> <div className="bg-primary/10 p-2">
<Mail className="text-primary h-4 w-4" /> <Mail className="text-primary h-4 w-4" />
</div> </div>
<span className="text-sm break-all">{invoice.business.email}</span> <span className="text-sm break-all">
{invoice.business.email}
</span>
</div> </div>
)} )}
{invoice.business.phone && ( {invoice.business.phone && (
@@ -404,7 +454,9 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
<div className="bg-primary/10 p-2"> <div className="bg-primary/10 p-2">
<Phone className="text-primary h-4 w-4" /> <Phone className="text-primary h-4 w-4" />
</div> </div>
<span className="text-sm">{invoice.business.phone}</span> <span className="text-sm">
{invoice.business.phone}
</span>
</div> </div>
)} )}
</div> </div>
@@ -437,7 +489,9 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
<span className="whitespace-nowrap"> <span className="whitespace-nowrap">
{item.hours.toString()}&nbsp;hours {item.hours.toString()}&nbsp;hours
</span> </span>
<span className="whitespace-nowrap">@&nbsp;${item.rate}/hr</span> <span className="whitespace-nowrap">
@&nbsp;${item.rate}/hr
</span>
</div> </div>
</div> </div>
<p className="text-primary flex-shrink-0 self-start text-lg font-semibold"> <p className="text-primary flex-shrink-0 self-start text-lg font-semibold">
@@ -449,15 +503,21 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
))} ))}
{/* Totals */} {/* Totals */}
<div className="bg-secondary rounded-lg p-4 space-y-3"> <div className="bg-secondary space-y-3 rounded-lg p-4">
<div className="flex flex-wrap justify-between gap-x-4 gap-y-1"> <div className="flex flex-wrap justify-between gap-x-4 gap-y-1">
<span className="text-muted-foreground">Subtotal:</span> <span className="text-muted-foreground">Subtotal:</span>
<span className="font-medium">{formatCurrency(subtotal)}</span> <span className="font-medium">
{formatCurrency(subtotal)}
</span>
</div> </div>
{invoice.taxRate > 0 && ( {invoice.taxRate > 0 && (
<div className="flex flex-wrap justify-between gap-x-4 gap-y-1"> <div className="flex flex-wrap justify-between gap-x-4 gap-y-1">
<span className="text-muted-foreground">Tax ({invoice.taxRate}%):</span> <span className="text-muted-foreground">
<span className="font-medium">{formatCurrency(taxAmount)}</span> Tax ({invoice.taxRate}%):
</span>
<span className="font-medium">
{formatCurrency(taxAmount)}
</span>
</div> </div>
)} )}
<Separator /> <Separator />
@@ -469,14 +529,18 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
<> <>
<div className="flex flex-wrap justify-between gap-x-4 gap-y-1 text-sm"> <div className="flex flex-wrap justify-between gap-x-4 gap-y-1 text-sm">
<span className="text-muted-foreground">Paid:</span> <span className="text-muted-foreground">Paid:</span>
<span className="text-green-600 font-medium"> <span className="font-medium text-green-600">
{formatCurrency(totalPaid)} {formatCurrency(totalPaid)}
</span> </span>
</div> </div>
<Separator /> <Separator />
<div className="flex flex-wrap justify-between gap-x-4 gap-y-1 font-bold"> <div className="flex flex-wrap justify-between gap-x-4 gap-y-1 font-bold">
<span>Balance Due:</span> <span>Balance Due:</span>
<span className={balanceDue <= 0 ? "text-green-600" : "text-primary"}> <span
className={
balanceDue <= 0 ? "text-green-600" : "text-primary"
}
>
{formatCurrency(Math.max(0, balanceDue))} {formatCurrency(Math.max(0, balanceDue))}
</span> </span>
</div> </div>
@@ -508,7 +572,9 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
{paymentsLoading ? ( {paymentsLoading ? (
<p className="text-muted-foreground text-sm">Loading</p> <p className="text-muted-foreground text-sm">Loading</p>
) : (payments ?? []).length === 0 ? ( ) : (payments ?? []).length === 0 ? (
<p className="text-muted-foreground text-sm">No payments recorded yet.</p> <p className="text-muted-foreground text-sm">
No payments recorded yet.
</p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{(payments ?? []).map((p) => ( {(payments ?? []).map((p) => (
@@ -517,11 +583,17 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
className="bg-secondary flex items-center justify-between gap-3 rounded-lg px-4 py-3 text-sm" className="bg-secondary flex items-center justify-between gap-3 rounded-lg px-4 py-3 text-sm"
> >
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="font-semibold">{formatCurrency(p.amount)}</span> <span className="font-semibold">
<Badge variant="secondary">{methodLabel(p.method)}</Badge> {formatCurrency(p.amount)}
<span className="text-muted-foreground">{formatDate(p.date)}</span> </span>
<Badge variant="secondary">
{methodLabel(p.method)}
</Badge>
<span className="text-muted-foreground">
{formatDate(p.date)}
</span>
{p.notes && ( {p.notes && (
<span className="text-muted-foreground truncate max-w-[200px]"> <span className="text-muted-foreground max-w-[200px] truncate">
{p.notes} {p.notes}
</span> </span>
)} )}
@@ -529,7 +601,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
className="text-destructive hover:bg-destructive/10 h-7 w-7 p-0 shrink-0" className="text-destructive hover:bg-destructive/10 h-7 w-7 shrink-0 p-0"
onClick={() => deletePayment.mutate({ id: p.id })} onClick={() => deletePayment.mutate({ id: p.id })}
disabled={deletePayment.isPending} disabled={deletePayment.isPending}
> >
@@ -549,7 +621,9 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
<CardTitle>Notes</CardTitle> <CardTitle>Notes</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-foreground whitespace-pre-wrap">{invoice.notes}</p> <p className="text-foreground whitespace-pre-wrap">
{invoice.notes}
</p>
</CardContent> </CardContent>
</Card> </Card>
)} )}
@@ -557,8 +631,39 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
{/* Right Column - Actions */} {/* Right Column - Actions */}
<div className={cn("flex flex-col", dashboardGapClass)}> <div className={cn("flex flex-col", dashboardGapClass)}>
{invoice.scheduledSendStatus === "pending" &&
invoice.scheduledSendAt ? (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<CalendarClock className="h-4 w-4" />
Scheduled send
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<p className="text-sm font-medium">
{formatZonedDateTime(
invoice.scheduledSendAt,
invoice.scheduledSendTimeZone ?? "UTC",
)}
</p>
<p className="text-muted-foreground text-xs">
{invoice.scheduledSendTimeZone ?? "UTC"}
</p>
<Button asChild variant="outline" size="sm" className="w-full">
<Link href={`/dashboard/invoices/${invoice.id}/send`}>
Manage scheduled send
</Link>
</Button>
</CardContent>
</Card>
) : null}
{storedStatus === "draft" && ( {storedStatus === "draft" && (
<InvoiceTimerCard invoiceId={invoiceId} clientId={invoice.clientId} /> <InvoiceTimerCard
invoiceId={invoiceId}
clientId={invoice.clientId}
/>
)} )}
<Card className="lg:sticky lg:top-6"> <Card className="lg:sticky lg:top-6">
@@ -579,7 +684,11 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
) : null} ) : null}
{invoice.items && invoice.client && ( {invoice.items && invoice.client && (
<PDFDownloadButton invoiceId={invoice.id} className="w-full" variant="secondary" /> <PDFDownloadButton
invoiceId={invoice.id}
className="w-full"
variant="secondary"
/>
)} )}
{effectiveStatus === "draft" && ( {effectiveStatus === "draft" && (
@@ -595,7 +704,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
key={`${invoiceId}-${invoice.sendReminderAt?.toISOString() ?? "none"}`} key={`${invoiceId}-${invoice.sendReminderAt?.toISOString() ?? "none"}`}
invoiceId={invoiceId} invoiceId={invoiceId}
savedReminderAt={invoice.sendReminderAt} savedReminderAt={invoice.sendReminderAt}
formatDate={formatDate} timeZone={timeZone}
isSaving={updateInvoice.isPending} isSaving={updateInvoice.isPending}
onSave={(sendReminderAt) => onSave={(sendReminderAt) =>
updateInvoice.mutate({ updateInvoice.mutate({
@@ -604,12 +713,16 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
}) })
} }
onClear={() => onClear={() =>
updateInvoice.mutate({ id: invoiceId, sendReminderAt: null }) updateInvoice.mutate({
id: invoiceId,
sendReminderAt: null,
})
} }
/> />
)} )}
{(effectiveStatus === "sent" || effectiveStatus === "overdue") && ( {(effectiveStatus === "sent" ||
effectiveStatus === "overdue") && (
<EnhancedSendInvoiceButton <EnhancedSendInvoiceButton
invoiceId={invoice.id} invoiceId={invoice.id}
className="w-full" className="w-full"
@@ -632,7 +745,10 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
{invoice.lastReminderSentAt && ( {invoice.lastReminderSentAt && (
<p className="text-muted-foreground mt-1 text-center text-xs"> <p className="text-muted-foreground mt-1 text-center text-xs">
Last sent {daysSince(invoice.lastReminderSentAt)} day Last sent {daysSince(invoice.lastReminderSentAt)} day
{daysSince(invoice.lastReminderSentAt) === 1 ? "" : "s"} ago {daysSince(invoice.lastReminderSentAt) === 1
? ""
: "s"}{" "}
ago
</p> </p>
)} )}
</div> </div>
@@ -655,7 +771,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
className="h-6 w-6 p-0 shrink-0" className="h-6 w-6 shrink-0 p-0"
onClick={handleCopyLink} onClick={handleCopyLink}
> >
{copied ? ( {copied ? (
@@ -669,7 +785,9 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
variant="outline" variant="outline"
size="sm" size="sm"
className="text-destructive hover:bg-destructive/10 w-full" className="text-destructive hover:bg-destructive/10 w-full"
onClick={() => revokePublicToken.mutate({ id: invoiceId })} onClick={() =>
revokePublicToken.mutate({ id: invoiceId })
}
disabled={revokePublicToken.isPending} disabled={revokePublicToken.isPending}
> >
<Link2Off className="mr-1.5 h-3.5 w-3.5" /> <Link2Off className="mr-1.5 h-3.5 w-3.5" />
@@ -679,13 +797,15 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
) : ( ) : (
<> <>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
Generate a shareable link your client can use to view this invoice without Generate a shareable link your client can use to view
logging in. this invoice without logging in.
</p> </p>
<Button <Button
size="sm" size="sm"
className="w-full" className="w-full"
onClick={() => generatePublicToken.mutate({ id: invoiceId })} onClick={() =>
generatePublicToken.mutate({ id: invoiceId })
}
disabled={generatePublicToken.isPending} disabled={generatePublicToken.isPending}
> >
{generatePublicToken.isPending ? ( {generatePublicToken.isPending ? (
@@ -701,9 +821,12 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
</Popover> </Popover>
{/* Mark as Paid */} {/* Mark as Paid */}
{(effectiveStatus === "sent" || effectiveStatus === "overdue") && ( {(effectiveStatus === "sent" ||
effectiveStatus === "overdue") && (
<Button <Button
onClick={() => updateStatus.mutate({ id: invoiceId, status: "paid" })} onClick={() =>
updateStatus.mutate({ id: invoiceId, status: "paid" })
}
disabled={updateStatus.isPending} disabled={updateStatus.isPending}
variant="secondary" variant="secondary"
className="w-full" className="w-full"
@@ -779,10 +902,16 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setRecordPaymentOpen(false)}> <Button
variant="outline"
onClick={() => setRecordPaymentOpen(false)}
>
Cancel Cancel
</Button> </Button>
<Button onClick={handleRecordPayment} disabled={createPayment.isPending}> <Button
onClick={handleRecordPayment}
disabled={createPayment.isPending}
>
{createPayment.isPending ? "Saving…" : "Record Payment"} {createPayment.isPending ? "Saving…" : "Record Payment"}
</Button> </Button>
</DialogFooter> </DialogFooter>
@@ -823,9 +952,13 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
disabled={sendReminder.isPending} disabled={sendReminder.isPending}
> >
{sendReminder.isPending ? ( {sendReminder.isPending ? (
<><Loader2 className="mr-2 h-4 w-4 animate-spin" /> Sending</> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Sending
</>
) : ( ) : (
<><Bell className="mr-2 h-4 w-4" /> Send Reminder</> <>
<Bell className="mr-2 h-4 w-4" /> Send Reminder
</>
)} )}
</Button> </Button>
</DialogFooter> </DialogFooter>
@@ -838,8 +971,9 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
<DialogHeader> <DialogHeader>
<DialogTitle>Delete Invoice</DialogTitle> <DialogTitle>Delete Invoice</DialogTitle>
<DialogDescription> <DialogDescription>
Are you sure you want to delete invoice <strong>{invoice.invoiceNumber}</strong>? Are you sure you want to delete invoice{" "}
This action cannot be undone. <strong>{invoice.invoiceNumber}</strong>? This action cannot be
undone.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
@@ -867,28 +1001,30 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
function SendReminderEditor({ function SendReminderEditor({
invoiceId, invoiceId,
savedReminderAt, savedReminderAt,
formatDate, timeZone,
isSaving, isSaving,
onSave, onSave,
onClear, onClear,
}: { }: {
invoiceId: string; invoiceId: string;
savedReminderAt: Date | null | undefined; savedReminderAt: Date | null | undefined;
formatDate: (date: Date) => string; timeZone: string;
isSaving: boolean; isSaving: boolean;
onSave: (sendReminderAt: Date | null) => void; onSave: (sendReminderAt: Date | null) => void;
onClear: () => void; onClear: () => void;
}) { }) {
const [sendReminderAt, setSendReminderAt] = useState<Date | undefined>(() => const [sendReminderAt, setSendReminderAt] = useState(() =>
savedReminderAt ? new Date(savedReminderAt) : undefined, savedReminderAt ? toZonedDateTimeInputValue(savedReminderAt, timeZone) : "",
); );
return ( return (
<div className="space-y-2 rounded-lg border p-3"> <div className="space-y-2 rounded-lg border p-3">
<Label htmlFor={`send-reminder-at-${invoiceId}`}>Remind me to send</Label> <Label htmlFor={`send-reminder-at-${invoiceId}`}>Remind me to send</Label>
<DatePicker <Input
date={sendReminderAt} id={`send-reminder-at-${invoiceId}`}
onDateChange={setSendReminderAt} type="datetime-local"
value={sendReminderAt}
onChange={(event) => setSendReminderAt(event.target.value)}
className="w-full" className="w-full"
/> />
<div className="flex gap-2"> <div className="flex gap-2">
@@ -896,7 +1032,21 @@ function SendReminderEditor({
variant="outline" variant="outline"
size="sm" size="sm"
className="flex-1" className="flex-1"
onClick={() => onSave(sendReminderAt ?? null)} onClick={() => {
try {
onSave(
sendReminderAt
? zonedDateTimeToInstant(sendReminderAt, timeZone, "earlier")
: null,
);
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Invalid reminder time",
);
}
}}
disabled={isSaving} disabled={isSaving}
> >
Save reminder Save reminder
@@ -906,7 +1056,7 @@ function SendReminderEditor({
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => { onClick={() => {
setSendReminderAt(undefined); setSendReminderAt("");
onClear(); onClear();
}} }}
> >
@@ -918,7 +1068,7 @@ function SendReminderEditor({
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
{new Date(savedReminderAt) <= new Date() {new Date(savedReminderAt) <= new Date()
? "Reminder is due — time to send this invoice." ? "Reminder is due — time to send this invoice."
: `Scheduled for ${formatDate(savedReminderAt)}`} : `Scheduled for ${formatZonedDateTime(savedReminderAt, timeZone)}`}
</p> </p>
) : null} ) : null}
</div> </div>
@@ -8,6 +8,21 @@ import { Badge } from "~/components/ui/badge";
import { Separator } from "~/components/ui/separator"; import { Separator } from "~/components/ui/separator";
import { Alert, AlertDescription } from "~/components/ui/alert"; import { Alert, AlertDescription } from "~/components/ui/alert";
import { Label } from "~/components/ui/label"; import { Label } from "~/components/ui/label";
import { Input } from "~/components/ui/input";
import {
formatZonedDateTime,
getDefaultScheduledSendAt,
DEFAULT_TIME_ZONE,
toZonedDateTimeInputValue,
zonedDateTimeToInstant,
} from "@beenvoice/domain/time-zone";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -44,6 +59,7 @@ import {
ArrowLeft, ArrowLeft,
Loader2, Loader2,
FileText, FileText,
CalendarClock,
} from "lucide-react"; } from "lucide-react";
function SendEmailPageSkeleton() { function SendEmailPageSkeleton() {
@@ -54,7 +70,9 @@ function SendEmailPageSkeleton() {
description="Loading invoice email" description="Loading invoice email"
/> />
<div className={cn(dashboardGridClass, "lg:grid-cols-3")}> <div className={cn(dashboardGridClass, "lg:grid-cols-3")}>
<div className={cn("lg:col-span-2", dashboardGapClass, "flex flex-col")}> <div
className={cn("lg:col-span-2", dashboardGapClass, "flex flex-col")}
>
<div className="bg-muted h-96 animate-pulse" /> <div className="bg-muted h-96 animate-pulse" />
</div> </div>
<div className={cn(dashboardGapClass, "flex flex-col")}> <div className={cn(dashboardGapClass, "flex flex-col")}>
@@ -101,6 +119,12 @@ export default function SendEmailPage() {
const [isSending, setIsSending] = useState(false); const [isSending, setIsSending] = useState(false);
const [isInitialized, setIsInitialized] = useState(false); const [isInitialized, setIsInitialized] = useState(false);
const [showConfirmDialog, setShowConfirmDialog] = useState(false); const [showConfirmDialog, setShowConfirmDialog] = useState(false);
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); const [retryCount, setRetryCount] = useState(0);
// Email content state // Email content state
@@ -115,9 +139,11 @@ export default function SendEmailPage() {
api.invoices.getById.useQuery({ api.invoices.getById.useQuery({
id: invoiceId, id: invoiceId,
}); });
const { data: profile } = api.settings.getProfile.useQuery();
// Get utils for cache invalidation // Get utils for cache invalidation
const utils = api.useUtils(); const utils = api.useUtils();
const timeZone = profile?.timeZone ?? DEFAULT_TIME_ZONE;
// Email sending mutation // Email sending mutation
const sendEmailMutation = api.email.sendInvoice.useMutation({ const sendEmailMutation = api.email.sendInvoice.useMutation({
@@ -183,6 +209,31 @@ export default function SendEmailPage() {
}, },
}); });
const scheduleEmailMutation = api.email.scheduleInvoice.useMutation({
onSuccess: async (data) => {
await utils.invoices.getById.invalidate({ id: invoiceId });
toast.success("Invoice scheduled", {
description: `It will send ${formatZonedDateTime(data.scheduledAt, data.timeZone)}.`,
});
router.push(`/dashboard/invoices/${invoiceId}`);
},
onError: (error) => {
toast.error("Could not schedule invoice", { description: error.message });
},
});
const cancelScheduleMutation = api.email.cancelScheduledInvoice.useMutation({
onSuccess: async () => {
await utils.invoices.getById.invalidate({ id: invoiceId });
toast.success("Scheduled send cancelled");
},
onError: (error) => {
toast.error("Could not cancel scheduled send", {
description: error.message,
});
},
});
// Transform invoice data for components // Transform invoice data for components
const invoice = useMemo(() => { const invoice = useMemo(() => {
return invoiceData return invoiceData
@@ -196,6 +247,9 @@ export default function SendEmailPage() {
taxRate: invoiceData.taxRate, taxRate: invoiceData.taxRate,
currency: invoiceData.currency, currency: invoiceData.currency,
emailMessage: invoiceData.emailMessage, emailMessage: invoiceData.emailMessage,
scheduledSendAt: invoiceData.scheduledSendAt,
scheduledSendTimeZone: invoiceData.scheduledSendTimeZone,
scheduledSendStatus: invoiceData.scheduledSendStatus,
client: invoiceData.client client: invoiceData.client
? { ? {
name: invoiceData.client.name, name: invoiceData.client.name,
@@ -210,6 +264,19 @@ export default function SendEmailPage() {
email: invoiceData.business.email, email: invoiceData.business.email,
logoStorageKey: invoiceData.business.logoStorageKey, logoStorageKey: invoiceData.business.logoStorageKey,
logoMimeType: invoiceData.business.logoMimeType, logoMimeType: invoiceData.business.logoMimeType,
logoDarkStorageKey: invoiceData.business.logoDarkStorageKey,
logoDarkMimeType: invoiceData.business.logoDarkMimeType,
wordmarkLightStorageKey:
invoiceData.business.wordmarkLightStorageKey,
wordmarkLightMimeType:
invoiceData.business.wordmarkLightMimeType,
wordmarkDarkStorageKey:
invoiceData.business.wordmarkDarkStorageKey,
wordmarkDarkMimeType: invoiceData.business.wordmarkDarkMimeType,
iconLightStorageKey: invoiceData.business.iconLightStorageKey,
iconLightMimeType: invoiceData.business.iconLightMimeType,
iconDarkStorageKey: invoiceData.business.iconDarkStorageKey,
iconDarkMimeType: invoiceData.business.iconDarkMimeType,
} }
: undefined, : undefined,
items: invoiceData.items?.map((item) => ({ items: invoiceData.items?.map((item) => ({
@@ -287,6 +354,48 @@ export default function SendEmailPage() {
} }
}; };
const confirmScheduleEmail = async () => {
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
) {
toast.error("Choose a future send time", {
description: "The scheduled time must be at least one minute from now.",
});
return;
}
try {
await scheduleEmailMutation.mutateAsync({
invoiceId,
scheduledAt: sendAt,
timeZone,
customSubject: subject,
customContent: emailContent,
customMessage: normalizedCustomMessage,
useHtml: true,
ccEmails: ccEmail.trim() || undefined,
bccEmails: bccEmail.trim() || undefined,
});
setShowScheduleDialog(false);
} catch {
// The mutation displays the server error.
}
};
const handleRetry = () => { const handleRetry = () => {
if (retryCount < 2) { if (retryCount < 2) {
setRetryCount((prev) => prev + 1); setRetryCount((prev) => prev + 1);
@@ -348,6 +457,31 @@ export default function SendEmailPage() {
</Alert> </Alert>
)} )}
{invoice.scheduledSendStatus === "pending" && invoice.scheduledSendAt ? (
<Alert>
<CalendarClock className="h-4 w-4" />
<AlertDescription className="flex flex-wrap items-center justify-between gap-3">
<span>
Scheduled for{" "}
{formatZonedDateTime(
invoice.scheduledSendAt,
invoice.scheduledSendTimeZone ?? timeZone,
)}{" "}
({invoice.scheduledSendTimeZone ?? timeZone})
</span>
<Button
type="button"
size="sm"
variant="outline"
disabled={cancelScheduleMutation.isPending}
onClick={() => cancelScheduleMutation.mutate({ invoiceId })}
>
Cancel scheduled send
</Button>
</AlertDescription>
</Alert>
) : null}
{/* Main Content */} {/* Main Content */}
<div className={cn(dashboardGridClass, "lg:grid-cols-3")}> <div className={cn(dashboardGridClass, "lg:grid-cols-3")}>
<div className="lg:col-span-2"> <div className="lg:col-span-2">
@@ -579,6 +713,27 @@ export default function SendEmailPage() {
Cancel Cancel
</Button> </Button>
<Button
onClick={() => {
setMinimumScheduledAt(
toZonedDateTimeInputValue(
new Date(Date.now() + 60_000),
timeZone,
),
);
setScheduledAt(
toZonedDateTimeInputValue(getDefaultScheduledSendAt(), timeZone),
);
setShowScheduleDialog(true);
}}
disabled={!canSend || scheduleEmailMutation.isPending}
variant="outline"
size="sm"
>
<CalendarClock className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline">Schedule</span>
</Button>
<Button <Button
onClick={handleSendEmail} onClick={handleSendEmail}
disabled={!canSend || isSending} disabled={!canSend || isSending}
@@ -655,6 +810,69 @@ export default function SendEmailPage() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<Dialog open={showScheduleDialog} onOpenChange={setShowScheduleDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Schedule invoice</DialogTitle>
<DialogDescription>
Choose when this invoice should be emailed to{" "}
<strong>{toEmail}</strong>.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="scheduled-send-at">Send date and time</Label>
<Input
id="scheduled-send-at"
type="datetime-local"
value={scheduledAt}
min={minimumScheduledAt}
onChange={(event) => setScheduledAt(event.target.value)}
/>
<p className="text-muted-foreground text-sm">
Time zone: {timeZone}. The worker stores the equivalent UTC
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
variant="outline"
onClick={() => setShowScheduleDialog(false)}
>
Cancel
</Button>
<Button
onClick={() => void confirmScheduleEmail()}
disabled={scheduleEmailMutation.isPending || !scheduledAt}
>
{scheduleEmailMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<CalendarClock className="mr-2 h-4 w-4" />
)}
Schedule send
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</DashboardPage> </DashboardPage>
); );
} }
@@ -38,6 +38,7 @@ import { toast } from "sonner";
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status"; import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
import { formatCurrency } from "~/lib/currency"; import { formatCurrency } from "~/lib/currency";
import type { StoredInvoiceStatus } from "~/types/invoice"; import type { StoredInvoiceStatus } from "~/types/invoice";
import { formatCalendarDate } from "@beenvoice/domain/time-zone";
interface Invoice { interface Invoice {
id: string; id: string;
@@ -81,22 +82,27 @@ interface Invoice {
interface InvoicesDataTableProps { interface InvoicesDataTableProps {
invoices: Invoice[]; invoices: Invoice[];
timeZone: string;
} }
const getStatusType = (invoice: Invoice): StatusType => const getStatusType = (invoice: Invoice, timeZone: string): StatusType =>
getEffectiveInvoiceStatus( getEffectiveInvoiceStatus(
invoice.status as StoredInvoiceStatus, invoice.status as StoredInvoiceStatus,
invoice.dueDate, invoice.dueDate,
timeZone,
); );
const formatDate = (date: Date) => const formatDate = (date: Date) =>
new Intl.DateTimeFormat("en-US", { formatCalendarDate(date, {
month: "short", month: "short",
day: "2-digit", day: "2-digit",
year: "numeric", year: "numeric",
}).format(new Date(date)); });
export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) { export function InvoicesDataTable({
invoices,
timeZone,
}: InvoicesDataTableProps) {
const router = useRouter(); const router = useRouter();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [invoiceToDelete, setInvoiceToDelete] = useState<Invoice | null>(null); const [invoiceToDelete, setInvoiceToDelete] = useState<Invoice | null>(null);
@@ -183,7 +189,7 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
</p> </p>
<div className="mt-1 flex items-center gap-2 sm:hidden"> <div className="mt-1 flex items-center gap-2 sm:hidden">
<StatusBadge <StatusBadge
status={getStatusType(invoice)} status={getStatusType(invoice, timeZone)}
className="text-xs" className="text-xs"
/> />
<span className="text-foreground text-xs font-semibold"> <span className="text-foreground text-xs font-semibold">
@@ -218,14 +224,16 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<StatusBadge <StatusBadge
status={getStatusType(row.original)} status={getStatusType(row.original, timeZone)}
className={ className={
getStatusType(row.original) === "sent" ? "status-pending" : "" getStatusType(row.original, timeZone) === "sent"
? "status-pending"
: ""
} }
/> />
), ),
filterFn: (row, _id, value: string[]) => filterFn: (row, _id, value: string[]) =>
value.includes(getStatusType(row.original)), value.includes(getStatusType(row.original, timeZone)),
meta: { meta: {
headerClassName: "hidden sm:table-cell", headerClassName: "hidden sm:table-cell",
cellClassName: "hidden sm:table-cell", cellClassName: "hidden sm:table-cell",
+7 -1
View File
@@ -11,8 +11,14 @@ import { DataTableSkeleton } from "~/components/data/data-table";
// Invoices Table Component // Invoices Table Component
async function InvoicesTable() { async function InvoicesTable() {
const invoices = await api.invoices.getAll(); const invoices = await api.invoices.getAll();
const profile = await api.settings.getProfile();
return <InvoicesDataTable invoices={invoices} />; return (
<InvoicesDataTable
invoices={invoices}
timeZone={profile?.timeZone ?? "America/New_York"}
/>
);
} }
export default async function InvoicesPage() { export default async function InvoicesPage() {
@@ -39,6 +39,12 @@ import {
} from "~/components/ui/select"; } from "~/components/ui/select";
import { Textarea } from "~/components/ui/textarea"; import { Textarea } from "~/components/ui/textarea";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import {
DEFAULT_TIME_ZONE,
formatZonedDateTime,
getDefaultScheduledSendAt,
toZonedDateTimeInputValue,
} from "@beenvoice/domain/time-zone";
const SCHEDULES = [ const SCHEDULES = [
{ value: "weekly", label: "Weekly" }, { value: "weekly", label: "Weekly" },
@@ -66,10 +72,13 @@ interface RecurringFormState {
currency: string; currency: string;
notes: string; notes: string;
emailMessage: string; emailMessage: string;
timeZone: string;
nextRunLocal: string;
disambiguation: "earlier" | "later" | "reject";
items: RecurringItemInput[]; items: RecurringItemInput[];
} }
const defaultForm = (): RecurringFormState => ({ const defaultForm = (timeZone = DEFAULT_TIME_ZONE): RecurringFormState => ({
name: "", name: "",
clientId: "", clientId: "",
businessId: "", businessId: "",
@@ -79,15 +88,17 @@ const defaultForm = (): RecurringFormState => ({
currency: "USD", currency: "USD",
notes: "", notes: "",
emailMessage: "", emailMessage: "",
timeZone,
nextRunLocal: toZonedDateTimeInputValue(
getDefaultScheduledSendAt(),
timeZone,
),
disambiguation: "reject",
items: [{ description: "", hours: 0, rate: 0 }], items: [{ description: "", hours: 0, rate: 0 }],
}); });
function formatDate(date: Date) { function formatDate(date: Date, timeZone: string) {
return new Intl.DateTimeFormat("en-US", { return formatZonedDateTime(date, timeZone);
year: "numeric",
month: "short",
day: "numeric",
}).format(new Date(date));
} }
function scheduleLabel(s: string) { function scheduleLabel(s: string) {
@@ -106,19 +117,28 @@ function RecurringForm({
businesses: { id: string; name: string }[]; businesses: { id: string; name: string }[];
}) { }) {
const addItem = () => const addItem = () =>
setForm((f) => ({ ...f, items: [...f.items, { description: "", hours: 0, rate: 0 }] })); setForm((f) => ({
...f,
items: [...f.items, { description: "", hours: 0, rate: 0 }],
}));
const removeItem = (idx: number) => const removeItem = (idx: number) =>
setForm((f) => ({ ...f, items: f.items.filter((_, i) => i !== idx) })); setForm((f) => ({ ...f, items: f.items.filter((_, i) => i !== idx) }));
const updateItem = (idx: number, field: keyof RecurringItemInput, value: string | number) => const updateItem = (
idx: number,
field: keyof RecurringItemInput,
value: string | number,
) =>
setForm((f) => ({ setForm((f) => ({
...f, ...f,
items: f.items.map((item, i) => (i === idx ? { ...item, [field]: value } : item)), items: f.items.map((item, i) =>
i === idx ? { ...item, [field]: value } : item,
),
})); }));
return ( return (
<div className="space-y-4 max-h-[60vh] overflow-y-auto pr-1"> <div className="max-h-[60vh] space-y-4 overflow-y-auto pr-1">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>Template name</Label> <Label>Template name</Label>
<Input <Input
@@ -173,7 +193,9 @@ function RecurringForm({
<Label>Schedule</Label> <Label>Schedule</Label>
<Select <Select
value={form.schedule} value={form.schedule}
onValueChange={(v) => setForm((f) => ({ ...f, schedule: v as Schedule }))} onValueChange={(v) =>
setForm((f) => ({ ...f, schedule: v as Schedule }))
}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
@@ -193,11 +215,65 @@ function RecurringForm({
maxLength={3} maxLength={3}
placeholder="USD" placeholder="USD"
value={form.currency} value={form.currency}
onChange={(e) => setForm((f) => ({ ...f, currency: e.target.value.toUpperCase() }))} onChange={(e) =>
setForm((f) => ({ ...f, currency: e.target.value.toUpperCase() }))
}
/> />
</div> </div>
</div> </div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="recurring-next-run">First/next run</Label>
<Input
id="recurring-next-run"
type="datetime-local"
value={form.nextRunLocal}
onChange={(event) =>
setForm((current) => ({
...current,
nextRunLocal: event.target.value,
}))
}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="recurring-time-zone">Time zone</Label>
<Input
id="recurring-time-zone"
value={form.timeZone}
onChange={(event) =>
setForm((current) => ({
...current,
timeZone: event.target.value,
}))
}
placeholder="America/New_York"
/>
</div>
</div>
<div className="space-y-1.5">
<Label>Repeated DST hour</Label>
<Select
value={form.disambiguation}
onValueChange={(value) =>
setForm((current) => ({
...current,
disambiguation: value as RecurringFormState["disambiguation"],
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="reject">Reject ambiguous time</SelectItem>
<SelectItem value="earlier">First occurrence</SelectItem>
<SelectItem value="later">Second occurrence</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>Tax rate (%)</Label> <Label>Tax rate (%)</Label>
<NumberInput <NumberInput
@@ -226,7 +302,7 @@ function RecurringForm({
type="button" type="button"
size="sm" size="sm"
variant="ghost" variant="ghost"
className="text-destructive h-8 w-8 p-0 shrink-0" className="text-destructive h-8 w-8 shrink-0 p-0"
onClick={() => removeItem(idx)} onClick={() => removeItem(idx)}
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
@@ -281,7 +357,9 @@ export default function RecurringInvoicesPage() {
const [deleteId, setDeleteId] = useState<string | null>(null); const [deleteId, setDeleteId] = useState<string | null>(null);
const [form, setForm] = useState<RecurringFormState>(defaultForm()); const [form, setForm] = useState<RecurringFormState>(defaultForm());
const { data: recurring, isLoading } = api.recurringInvoices.getAll.useQuery(); const { data: recurring, isLoading } =
api.recurringInvoices.getAll.useQuery();
const { data: profile } = api.settings.getProfile.useQuery();
const { data: clients = [] } = api.clients.getAll.useQuery(); const { data: clients = [] } = api.clients.getAll.useQuery();
const { data: businesses = [] } = api.businesses.getAll.useQuery(); const { data: businesses = [] } = api.businesses.getAll.useQuery();
const utils = api.useUtils(); const utils = api.useUtils();
@@ -289,27 +367,47 @@ export default function RecurringInvoicesPage() {
const invalidate = () => void utils.recurringInvoices.getAll.invalidate(); const invalidate = () => void utils.recurringInvoices.getAll.invalidate();
const create = api.recurringInvoices.create.useMutation({ const create = api.recurringInvoices.create.useMutation({
onSuccess: () => { toast.success("Recurring invoice created"); setCreateOpen(false); setForm(defaultForm()); invalidate(); }, onSuccess: () => {
toast.success("Recurring invoice created");
setCreateOpen(false);
setForm(defaultForm());
invalidate();
},
onError: (e) => toast.error(e.message ?? "Failed to create"), onError: (e) => toast.error(e.message ?? "Failed to create"),
}); });
const update = api.recurringInvoices.update.useMutation({ const update = api.recurringInvoices.update.useMutation({
onSuccess: () => { toast.success("Updated"); setEditId(null); setForm(defaultForm()); invalidate(); }, onSuccess: () => {
toast.success("Updated");
setEditId(null);
setForm(defaultForm());
invalidate();
},
onError: (e) => toast.error(e.message ?? "Failed to update"), onError: (e) => toast.error(e.message ?? "Failed to update"),
}); });
const pause = api.recurringInvoices.pause.useMutation({ const pause = api.recurringInvoices.pause.useMutation({
onSuccess: () => { toast.success("Paused"); invalidate(); }, onSuccess: () => {
toast.success("Paused");
invalidate();
},
onError: (e) => toast.error(e.message), onError: (e) => toast.error(e.message),
}); });
const resume = api.recurringInvoices.resume.useMutation({ const resume = api.recurringInvoices.resume.useMutation({
onSuccess: () => { toast.success("Resumed"); invalidate(); }, onSuccess: () => {
toast.success("Resumed");
invalidate();
},
onError: (e) => toast.error(e.message), onError: (e) => toast.error(e.message),
}); });
const del = api.recurringInvoices.delete.useMutation({ const del = api.recurringInvoices.delete.useMutation({
onSuccess: () => { toast.success("Deleted"); setDeleteId(null); invalidate(); }, onSuccess: () => {
toast.success("Deleted");
setDeleteId(null);
invalidate();
},
onError: (e) => toast.error(e.message), onError: (e) => toast.error(e.message),
}); });
@@ -333,6 +431,9 @@ export default function RecurringInvoicesPage() {
currency: rec.currency, currency: rec.currency,
notes: rec.notes ?? "", notes: rec.notes ?? "",
emailMessage: rec.emailMessage ?? "", emailMessage: rec.emailMessage ?? "",
timeZone: rec.timeZone,
nextRunLocal: toZonedDateTimeInputValue(rec.nextDueAt, rec.timeZone),
disambiguation: "reject",
items: rec.items.map((i) => ({ items: rec.items.map((i) => ({
description: i.description, description: i.description,
hours: i.hours, hours: i.hours,
@@ -365,7 +466,12 @@ export default function RecurringInvoicesPage() {
title="Recurring Invoices" title="Recurring Invoices"
description="Schedule automatic invoice generation" description="Schedule automatic invoice generation"
> >
<Button onClick={() => { setForm(defaultForm()); setCreateOpen(true); }}> <Button
onClick={() => {
setForm(defaultForm(profile?.timeZone));
setCreateOpen(true);
}}
>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
New recurring New recurring
</Button> </Button>
@@ -383,7 +489,12 @@ export default function RecurringInvoicesPage() {
title="Create your first recurring invoice" title="Create your first recurring invoice"
description="Automatically generate draft invoices on a schedule you choose." description="Automatically generate draft invoices on a schedule you choose."
action={ action={
<Button onClick={() => { setForm(defaultForm()); setCreateOpen(true); }}> <Button
onClick={() => {
setForm(defaultForm(profile?.timeZone));
setCreateOpen(true);
}}
>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Create recurring invoice Create recurring invoice
</Button> </Button>
@@ -400,7 +511,11 @@ export default function RecurringInvoicesPage() {
<div className="min-w-0 flex-1 space-y-1"> <div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<p className="font-semibold">{rec.name}</p> <p className="font-semibold">{rec.name}</p>
<Badge variant={rec.status === "active" ? "default" : "secondary"}> <Badge
variant={
rec.status === "active" ? "default" : "secondary"
}
>
{rec.status} {rec.status}
</Badge> </Badge>
</div> </div>
@@ -408,14 +523,18 @@ export default function RecurringInvoicesPage() {
{rec.client.name} · {scheduleLabel(rec.schedule)} {rec.client.name} · {scheduleLabel(rec.schedule)}
</p> </p>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
Next: {formatDate(rec.nextDueAt)} Next: {formatDate(rec.nextDueAt, rec.timeZone)}
{rec.lastGeneratedAt && ( {rec.lastGeneratedAt && (
<> · Last generated: {formatDate(rec.lastGeneratedAt)}</> <>
{" "}
· Last generated:{" "}
{formatDate(rec.lastGeneratedAt, rec.timeZone)}
</>
)} )}
</p> </p>
</div> </div>
<div className="flex flex-wrap gap-2 shrink-0"> <div className="flex shrink-0 flex-wrap gap-2">
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
@@ -473,14 +592,21 @@ export default function RecurringInvoicesPage() {
<Dialog <Dialog
open={createOpen || editId !== null} open={createOpen || editId !== null}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) { setCreateOpen(false); setEditId(null); setForm(defaultForm()); } if (!open) {
setCreateOpen(false);
setEditId(null);
setForm(defaultForm());
}
}} }}
> >
<DialogContent className="max-w-lg"> <DialogContent className="max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>{editId ? "Edit recurring invoice" : "New recurring invoice"}</DialogTitle> <DialogTitle>
{editId ? "Edit recurring invoice" : "New recurring invoice"}
</DialogTitle>
<DialogDescription> <DialogDescription>
Configure the template. Invoices will be generated as drafts on the selected schedule. Configure the template. Invoices will be generated as drafts on
the selected schedule.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<RecurringForm <RecurringForm
@@ -492,17 +618,30 @@ export default function RecurringInvoicesPage() {
<DialogFooter> <DialogFooter>
<Button <Button
variant="outline" variant="outline"
onClick={() => { setCreateOpen(false); setEditId(null); setForm(defaultForm()); }} onClick={() => {
setCreateOpen(false);
setEditId(null);
setForm(defaultForm());
}}
> >
Cancel Cancel
</Button> </Button>
<Button onClick={handleSubmit} disabled={isSubmitting || !form.name || !form.clientId}> <Button
onClick={handleSubmit}
disabled={isSubmitting || !form.name || !form.clientId}
>
{isSubmitting ? ( {isSubmitting ? (
<><Loader2 className="mr-2 h-4 w-4 animate-spin" /> Saving</> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Saving
</>
) : editId ? ( ) : editId ? (
<><Check className="mr-2 h-4 w-4" /> Save changes</> <>
<Check className="mr-2 h-4 w-4" /> Save changes
</>
) : ( ) : (
<><Plus className="mr-2 h-4 w-4" /> Create</> <>
<Plus className="mr-2 h-4 w-4" /> Create
</>
)} )}
</Button> </Button>
</DialogFooter> </DialogFooter>
@@ -510,12 +649,18 @@ export default function RecurringInvoicesPage() {
</Dialog> </Dialog>
{/* Delete Confirmation */} {/* Delete Confirmation */}
<Dialog open={deleteId !== null} onOpenChange={(open) => { if (!open) setDeleteId(null); }}> <Dialog
open={deleteId !== null}
onOpenChange={(open) => {
if (!open) setDeleteId(null);
}}
>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Delete recurring invoice</DialogTitle> <DialogTitle>Delete recurring invoice</DialogTitle>
<DialogDescription> <DialogDescription>
This will stop automatic generation. Already-generated invoices are not affected. This will stop automatic generation. Already-generated invoices
are not affected.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
+35 -17
View File
@@ -3,7 +3,10 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { DashboardPageHeader } from "~/components/layout/page-header"; import { DashboardPageHeader } from "~/components/layout/page-header";
import { DashboardPage, dashboardStatGridClass } from "~/components/layout/dashboard-page"; import {
DashboardPage,
dashboardStatGridClass,
} from "~/components/layout/dashboard-page";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { StatusBadge } from "~/components/data/status-badge"; import { StatusBadge } from "~/components/data/status-badge";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
@@ -24,6 +27,10 @@ import {
import { formatCurrency } from "~/lib/currency"; import { formatCurrency } from "~/lib/currency";
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status"; import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
import type { StoredInvoiceStatus } from "~/types/invoice"; import type { StoredInvoiceStatus } from "~/types/invoice";
import {
formatCalendarDate,
getZonedDateTimeParts,
} from "@beenvoice/domain/time-zone";
import { import {
AreaChart, AreaChart,
Area, Area,
@@ -63,7 +70,9 @@ export default function ReportsPage() {
const isLoading = invoicesLoading || expensesLoading; const isLoading = invoicesLoading || expensesLoading;
const currentYear = new Date().getFullYear(); const { data: profile } = api.settings.getProfile.useQuery();
const reportTimeZone = profile?.timeZone ?? "America/New_York";
const currentYear = getZonedDateTimeParts(new Date(), reportTimeZone).year;
const [taxYear, setTaxYear] = useState(String(currentYear)); const [taxYear, setTaxYear] = useState(String(currentYear));
const filteredInvoices = useMemo(() => { const filteredInvoices = useMemo(() => {
@@ -76,10 +85,11 @@ export default function ReportsPage() {
if (!filteredInvoices.length) return null; if (!filteredInvoices.length) return null;
const now = new Date(); const now = new Date();
const current = getZonedDateTimeParts(now, reportTimeZone);
const monthMap: Record<string, number> = {}; const monthMap: Record<string, number> = {};
for (let i = 11; i >= 0; i--) { for (let i = 11; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1); const d = new Date(Date.UTC(current.year, current.month - 1 - i, 1));
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; const key = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`;
monthMap[key] = 0; monthMap[key] = 0;
} }
@@ -91,10 +101,11 @@ export default function ReportsPage() {
const status = getEffectiveInvoiceStatus( const status = getEffectiveInvoiceStatus(
inv.status as StoredInvoiceStatus, inv.status as StoredInvoiceStatus,
inv.dueDate, inv.dueDate,
reportTimeZone,
); );
if (status === "paid") { if (status === "paid") {
totalRevenue += inv.totalAmount; totalRevenue += inv.totalAmount;
const key = `${new Date(inv.issueDate).getFullYear()}-${String(new Date(inv.issueDate).getMonth() + 1).padStart(2, "0")}`; const key = `${new Date(inv.issueDate).getUTCFullYear()}-${String(new Date(inv.issueDate).getUTCMonth() + 1).padStart(2, "0")}`;
if (monthMap[key] !== undefined) monthMap[key] += inv.totalAmount; if (monthMap[key] !== undefined) monthMap[key] += inv.totalAmount;
} else if (status === "sent" || status === "overdue") { } else if (status === "sent" || status === "overdue") {
totalPending += inv.totalAmount; totalPending += inv.totalAmount;
@@ -103,7 +114,7 @@ export default function ReportsPage() {
} }
const revenueByMonth = Object.entries(monthMap).map(([month, revenue]) => ({ const revenueByMonth = Object.entries(monthMap).map(([month, revenue]) => ({
month: new Date(month + "-01").toLocaleDateString("en-US", { month: formatCalendarDate(month + "-01", {
month: "short", month: "short",
year: "2-digit", year: "2-digit",
}), }),
@@ -115,6 +126,7 @@ export default function ReportsPage() {
const status = getEffectiveInvoiceStatus( const status = getEffectiveInvoiceStatus(
inv.status as StoredInvoiceStatus, inv.status as StoredInvoiceStatus,
inv.dueDate, inv.dueDate,
reportTimeZone,
); );
if (status === "paid" && inv.client) { if (status === "paid" && inv.client) {
const id = inv.client.id; const id = inv.client.id;
@@ -139,6 +151,7 @@ export default function ReportsPage() {
const s = getEffectiveInvoiceStatus( const s = getEffectiveInvoiceStatus(
inv.status as StoredInvoiceStatus, inv.status as StoredInvoiceStatus,
inv.dueDate, inv.dueDate,
reportTimeZone,
); );
statusCount[s] = (statusCount[s] ?? 0) + 1; statusCount[s] = (statusCount[s] ?? 0) + 1;
} }
@@ -151,7 +164,7 @@ export default function ReportsPage() {
totalHours, totalHours,
statusCount, statusCount,
}; };
}, [filteredInvoices]); }, [filteredInvoices, reportTimeZone]);
// Tax summary for selected year // Tax summary for selected year
const taxData = useMemo(() => { const taxData = useMemo(() => {
@@ -161,13 +174,14 @@ export default function ReportsPage() {
const status = getEffectiveInvoiceStatus( const status = getEffectiveInvoiceStatus(
inv.status as StoredInvoiceStatus, inv.status as StoredInvoiceStatus,
inv.dueDate, inv.dueDate,
reportTimeZone,
); );
return ( return (
status === "paid" && new Date(inv.issueDate).getFullYear() === year status === "paid" && new Date(inv.issueDate).getUTCFullYear() === year
); );
}); });
const yearExpenses = expenses.filter( const yearExpenses = expenses.filter(
(exp) => new Date(exp.date).getFullYear() === year, (exp) => new Date(exp.date).getUTCFullYear() === year,
); );
const getSubtotal = (inv: (typeof yearInvoices)[number]) => { const getSubtotal = (inv: (typeof yearInvoices)[number]) => {
@@ -211,10 +225,12 @@ export default function ReportsPage() {
return { return {
label: `Q${q}`, label: `Q${q}`,
income: yearInvoices income: yearInvoices
.filter((inv) => qMonths.includes(new Date(inv.issueDate).getMonth())) .filter((inv) =>
qMonths.includes(new Date(inv.issueDate).getUTCMonth()),
)
.reduce((s, inv) => s + getSubtotal(inv), 0), .reduce((s, inv) => s + getSubtotal(inv), 0),
expenses: yearExpenses expenses: yearExpenses
.filter((exp) => qMonths.includes(new Date(exp.date).getMonth())) .filter((exp) => qMonths.includes(new Date(exp.date).getUTCMonth()))
.reduce((s, exp) => s + exp.amount, 0), .reduce((s, exp) => s + exp.amount, 0),
}; };
}); });
@@ -233,13 +249,13 @@ export default function ReportsPage() {
yearInvoices, yearInvoices,
yearExpenses, yearExpenses,
}; };
}, [filteredInvoices, expenses, taxYear]); }, [filteredInvoices, expenses, taxYear, reportTimeZone]);
const availableYears = useMemo(() => { const availableYears = useMemo(() => {
const years = new Set<number>([currentYear, currentYear - 1]); const years = new Set<number>([currentYear, currentYear - 1]);
for (const inv of filteredInvoices) for (const inv of filteredInvoices)
years.add(new Date(inv.issueDate).getFullYear()); years.add(new Date(inv.issueDate).getUTCFullYear());
for (const exp of expenses) years.add(new Date(exp.date).getFullYear()); for (const exp of expenses) years.add(new Date(exp.date).getUTCFullYear());
return Array.from(years).sort((a, b) => b - a); return Array.from(years).sort((a, b) => b - a);
}, [filteredInvoices, expenses, currentYear]); }, [filteredInvoices, expenses, currentYear]);
@@ -251,6 +267,7 @@ export default function ReportsPage() {
getEffectiveInvoiceStatus( getEffectiveInvoiceStatus(
i.status as StoredInvoiceStatus, i.status as StoredInvoiceStatus,
i.dueDate, i.dueDate,
reportTimeZone,
) === "paid", ) === "paid",
).length || 1) ).length || 1)
: 0; : 0;
@@ -272,7 +289,7 @@ export default function ReportsPage() {
const invoiceSubtotal = subtotal > 0 ? subtotal : fallbackSubtotal; const invoiceSubtotal = subtotal > 0 ? subtotal : fallbackSubtotal;
const taxAmt = inv.totalAmount - invoiceSubtotal; const taxAmt = inv.totalAmount - invoiceSubtotal;
return [ return [
new Date(inv.issueDate).toLocaleDateString("en-US"), formatCalendarDate(inv.issueDate),
inv.invoiceNumber, inv.invoiceNumber,
`"${inv.client?.name ?? ""}"`, `"${inv.client?.name ?? ""}"`,
invoiceSubtotal.toFixed(2), invoiceSubtotal.toFixed(2),
@@ -287,7 +304,7 @@ export default function ReportsPage() {
"Date,Description,Category,Amount,Currency,Billable,Reimbursable,Tax Deductible", "Date,Description,Category,Amount,Currency,Billable,Reimbursable,Tax Deductible",
...taxData.yearExpenses.map((exp) => ...taxData.yearExpenses.map((exp) =>
[ [
new Date(exp.date).toLocaleDateString("en-US"), formatCalendarDate(exp.date),
`"${exp.description}"`, `"${exp.description}"`,
`"${exp.category ?? ""}"`, `"${exp.category ?? ""}"`,
exp.amount.toFixed(2), exp.amount.toFixed(2),
@@ -634,7 +651,7 @@ export default function ReportsPage() {
<div> <div>
<p className="font-medium">{inv.client?.name ?? "—"}</p> <p className="font-medium">{inv.client?.name ?? "—"}</p>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
{new Date(inv.issueDate).toLocaleDateString("en-US", { {formatCalendarDate(inv.issueDate, {
month: "short", month: "short",
day: "numeric", day: "numeric",
year: "numeric", year: "numeric",
@@ -647,6 +664,7 @@ export default function ReportsPage() {
getEffectiveInvoiceStatus( getEffectiveInvoiceStatus(
inv.status as StoredInvoiceStatus, inv.status as StoredInvoiceStatus,
inv.dueDate, inv.dueDate,
reportTimeZone,
) as never ) as never
} }
/> />
@@ -92,6 +92,7 @@ import type { PdfFontFamily, PdfTemplate } from "~/lib/appearance";
import { pdfFontFamilyOptions } from "~/lib/pdf-fonts"; import { pdfFontFamilyOptions } from "~/lib/pdf-fonts";
import { ApiAccessSettings } from "./api-access-settings"; import { ApiAccessSettings } from "./api-access-settings";
import { ImportPageHeaderActions } from "./invoice-import/import-page-header-actions"; import { ImportPageHeaderActions } from "./invoice-import/import-page-header-actions";
import { DEFAULT_TIME_ZONE } from "@beenvoice/domain/time-zone";
const InvoiceImportPage = dynamic( const InvoiceImportPage = dynamic(
() => () =>
@@ -147,6 +148,7 @@ export function SettingsContent({
const { data: session } = useAuthSession(); const { data: session } = useAuthSession();
const [name, setName] = useState(""); const [name, setName] = useState("");
const [timeZone, setTimeZone] = useState(DEFAULT_TIME_ZONE);
const [nameInitialized, setNameInitialized] = useState(false); const [nameInitialized, setNameInitialized] = useState(false);
const [deleteConfirmText, setDeleteConfirmText] = useState(""); const [deleteConfirmText, setDeleteConfirmText] = useState("");
const [importData, setImportData] = useState(""); const [importData, setImportData] = useState("");
@@ -309,7 +311,7 @@ export function SettingsContent({
toast.error("Please enter your name"); toast.error("Please enter your name");
return; return;
} }
updateProfileMutation.mutate({ name: name.trim() }); updateProfileMutation.mutate({ name: name.trim(), timeZone });
}; };
const handleChangePassword = (e: React.FormEvent) => { const handleChangePassword = (e: React.FormEvent) => {
@@ -423,8 +425,15 @@ export function SettingsContent({
if (nameInitialized || !profileFetched) return; if (nameInitialized || !profileFetched) return;
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync async profile data into an editable form field. // eslint-disable-next-line react-hooks/set-state-in-effect -- Sync async profile data into an editable form field.
setName(profile?.name ?? session?.user?.name ?? ""); setName(profile?.name ?? session?.user?.name ?? "");
setTimeZone(profile?.timeZone ?? DEFAULT_TIME_ZONE);
setNameInitialized(true); setNameInitialized(true);
}, [profile?.name, profileFetched, session?.user?.name, nameInitialized]); }, [
profile?.name,
profile?.timeZone,
profileFetched,
session?.user?.name,
nameInitialized,
]);
// (Removed direct DOM mutation; provider handles applying preferences globally) // (Removed direct DOM mutation; provider handles applying preferences globally)
@@ -497,6 +506,19 @@ export function SettingsContent({
Email address cannot be changed Email address cannot be changed
</p> </p>
</div> </div>
<div className="space-y-2">
<Label htmlFor="time-zone">Time zone</Label>
<Input
id="time-zone"
value={timeZone}
onChange={(event) => setTimeZone(event.target.value)}
placeholder="America/New_York"
/>
<p className="text-muted-foreground text-sm">
IANA time zone used for recurring schedules, reminders, and
reports.
</p>
</div>
<Button <Button
type="submit" type="submit"
disabled={updateProfileMutation.isPending} disabled={updateProfileMutation.isPending}
+102 -41
View File
@@ -9,29 +9,54 @@ import { api } from "~/trpc/react";
import { generateInvoicePDF } from "~/lib/pdf-export"; import { generateInvoicePDF } from "~/lib/pdf-export";
import { formatLineItemDetail } from "~/lib/invoice-line-item"; import { formatLineItemDetail } from "~/lib/invoice-line-item";
import { toast } from "sonner"; import { toast } from "sonner";
import {
formatCalendarDate,
getEffectiveInvoiceStatus,
} from "@beenvoice/domain";
import { BusinessBrandImage } from "~/components/branding/business-brand-image";
import { hasBusinessBrandAsset } from "~/lib/business-branding";
function formatDate(date: Date) { function formatDate(date: Date) {
return new Intl.DateTimeFormat("en-US", { return formatCalendarDate(date, {
year: "numeric", year: "numeric",
month: "long", month: "long",
day: "numeric", day: "numeric",
}).format(new Date(date)); });
} }
function formatCurrency(amount: number, currency = "USD") { function formatCurrency(amount: number, currency = "USD") {
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(amount); return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(
amount,
);
} }
function StatusPill({ status, dueDate }: { status: string; dueDate: Date }) { function StatusPill({
const overdue = status === "sent" && new Date(dueDate) < new Date(); status,
const label = overdue ? "Overdue" : status.charAt(0).toUpperCase() + status.slice(1); dueDate,
timeZone,
}: {
status: string;
dueDate: Date;
timeZone: string;
}) {
const overdue =
getEffectiveInvoiceStatus(
status as "draft" | "sent" | "paid",
dueDate,
timeZone,
) === "overdue";
const label = overdue
? "Overdue"
: status.charAt(0).toUpperCase() + status.slice(1);
const cls = overdue const cls = overdue
? "bg-red-50 text-red-700 border-red-200" ? "bg-red-50 text-red-700 border-red-200"
: status === "paid" : status === "paid"
? "bg-green-50 text-green-700 border-green-200" ? "bg-green-50 text-green-700 border-green-200"
: "bg-yellow-50 text-yellow-700 border-yellow-200"; : "bg-yellow-50 text-yellow-700 border-yellow-200";
return ( return (
<span className={`inline-flex items-center rounded-full border px-3 py-0.5 text-xs font-semibold ${cls}`}> <span
className={`inline-flex items-center rounded-full border px-3 py-0.5 text-xs font-semibold ${cls}`}
>
{label} {label}
</span> </span>
); );
@@ -40,7 +65,11 @@ function StatusPill({ status, dueDate }: { status: string; dueDate: Date }) {
function PublicInvoiceView({ token }: { token: string }) { function PublicInvoiceView({ token }: { token: string }) {
const [downloading, setDownloading] = useState(false); const [downloading, setDownloading] = useState(false);
const { data: invoice, isLoading, error } = api.invoices.getByPublicToken.useQuery({ token }); const {
data: invoice,
isLoading,
error,
} = api.invoices.getByPublicToken.useQuery({ token });
const handleDownload = async () => { const handleDownload = async () => {
if (!invoice || downloading) return; if (!invoice || downloading) return;
@@ -79,7 +108,9 @@ function PublicInvoiceView({ token }: { token: string }) {
return ( return (
<div className="flex min-h-screen flex-col items-center justify-center gap-3 text-center"> <div className="flex min-h-screen flex-col items-center justify-center gap-3 text-center">
<p className="text-2xl font-bold text-gray-800">Invoice not found</p> <p className="text-2xl font-bold text-gray-800">Invoice not found</p>
<p className="text-sm text-gray-500">This link may have expired or been revoked.</p> <p className="text-sm text-gray-500">
This link may have expired or been revoked.
</p>
</div> </div>
); );
} }
@@ -92,53 +123,67 @@ function PublicInvoiceView({ token }: { token: string }) {
? `${invoice.business.name} (${invoice.business.nickname})` ? `${invoice.business.name} (${invoice.business.nickname})`
: invoice.business.name : invoice.business.name
: null; : null;
const hasLogo = Boolean(invoice.business?.logoStorageKey); const hasLogo = hasBusinessBrandAsset(invoice.business);
const hideName = hasLogo && Boolean(invoice.business?.hideNameWithLogo); const hideName = hasLogo && Boolean(invoice.business?.hideNameWithLogo);
return ( return (
<div className="min-h-screen bg-gray-50 py-10 px-4"> <div className="min-h-screen bg-gray-50 px-4 py-10">
<div className="mx-auto max-w-2xl"> <div className="mx-auto max-w-2xl">
{/* Card */} {/* Card */}
<div className="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm"> <div className="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm">
{/* Header */} {/* Header */}
<div className="flex items-center gap-3 bg-gray-900 px-8 py-6"> <div className="flex items-center gap-3 bg-gray-900 px-8 py-6">
{hasLogo && ( {hasLogo && (
// Uploaded SVGs are sanitized and served by our route. next/image's <BusinessBrandImage
// optimizer intentionally rejects SVG, so a native img is required. business={invoice.business!}
// eslint-disable-next-line @next/next/no-img-element kind="logo"
<img theme="dark"
src={`/api/business-logo/${invoice.business!.id}`} decorative
alt="" className="h-16 w-[220px] max-w-[42%] shrink-0 rounded px-2 py-1.5"
className="h-16 w-auto max-w-[220px] shrink-0 rounded bg-white object-contain px-2 py-1.5"
/> />
)} )}
<div className="min-w-0"> <div className="min-w-0">
{!hideName && ( {!hideName && (
<p className="truncate text-lg font-bold text-white">{senderName ?? "Invoice"}</p> <p className="truncate text-lg font-bold text-white">
{senderName ?? "Invoice"}
</p>
)} )}
{invoice.business?.email && ( {invoice.business?.email && (
<p className="mt-0.5 truncate text-sm text-gray-400">{invoice.business.email}</p> <p className="mt-0.5 truncate text-sm text-gray-400">
{invoice.business.email}
</p>
)} )}
</div> </div>
</div> </div>
{/* Body */} {/* Body */}
<div className="px-8 py-6 space-y-6"> <div className="space-y-6 px-8 py-6">
{/* Invoice meta */} {/* Invoice meta */}
<div className="flex flex-wrap items-start justify-between gap-4"> <div className="flex flex-wrap items-start justify-between gap-4">
<div> <div>
<p className="text-2xl font-bold text-gray-900">{invoice.invoiceNumber}</p> <p className="text-2xl font-bold text-gray-900">
{invoice.invoiceNumber}
</p>
<p className="mt-1 text-sm text-gray-500"> <p className="mt-1 text-sm text-gray-500">
Issued {formatDate(invoice.issueDate)} · Due {formatDate(invoice.dueDate)} Issued {formatDate(invoice.issueDate)} · Due{" "}
{formatDate(invoice.dueDate)}
</p> </p>
</div> </div>
<StatusPill status={invoice.status} dueDate={invoice.dueDate} /> <StatusPill
status={invoice.status}
dueDate={invoice.dueDate}
timeZone={invoice.createdBy.timeZone}
/>
</div> </div>
{/* Bill to */} {/* Bill to */}
<div> <div>
<p className="text-xs font-semibold uppercase tracking-wider text-gray-400 mb-1">Bill to</p> <p className="mb-1 text-xs font-semibold tracking-wider text-gray-400 uppercase">
<p className="font-semibold text-gray-900">{invoice.client.name}</p> Bill to
</p>
<p className="font-semibold text-gray-900">
{invoice.client.name}
</p>
{invoice.client.email && ( {invoice.client.email && (
<p className="text-sm text-gray-500">{invoice.client.email}</p> <p className="text-sm text-gray-500">{invoice.client.email}</p>
)} )}
@@ -149,18 +194,21 @@ function PublicInvoiceView({ token }: { token: string }) {
{/* Line items */} {/* Line items */}
<div className="space-y-3"> <div className="space-y-3">
{invoice.items.map((item) => ( {invoice.items.map((item) => (
<div key={item.id} className="flex justify-between gap-4 text-sm"> <div
<div className="flex-1 min-w-0"> key={item.id}
<p className="font-medium text-gray-900 break-words">{item.description}</p> className="flex justify-between gap-4 text-sm"
>
<div className="min-w-0 flex-1">
<p className="font-medium break-words text-gray-900">
{item.description}
</p>
<p className="text-gray-500"> <p className="text-gray-500">
{formatLineItemDetail( {formatLineItemDetail(item.hours, item.rate, (amount) =>
item.hours, formatCurrency(amount, invoice.currency ?? "USD"),
item.rate,
(amount) => formatCurrency(amount, invoice.currency ?? "USD"),
)} )}
</p> </p>
</div> </div>
<p className="font-semibold text-gray-900 shrink-0"> <p className="shrink-0 font-semibold text-gray-900">
{formatCurrency(item.amount, invoice.currency ?? "USD")} {formatCurrency(item.amount, invoice.currency ?? "USD")}
</p> </p>
</div> </div>
@@ -173,15 +221,19 @@ function PublicInvoiceView({ token }: { token: string }) {
<div className="space-y-2 text-sm"> <div className="space-y-2 text-sm">
<div className="flex justify-between text-gray-500"> <div className="flex justify-between text-gray-500">
<span>Subtotal</span> <span>Subtotal</span>
<span>{formatCurrency(subtotal, invoice.currency ?? "USD")}</span> <span>
{formatCurrency(subtotal, invoice.currency ?? "USD")}
</span>
</div> </div>
{invoice.taxRate > 0 && ( {invoice.taxRate > 0 && (
<div className="flex justify-between text-gray-500"> <div className="flex justify-between text-gray-500">
<span>Tax ({invoice.taxRate}%)</span> <span>Tax ({invoice.taxRate}%)</span>
<span>{formatCurrency(taxAmount, invoice.currency ?? "USD")}</span> <span>
{formatCurrency(taxAmount, invoice.currency ?? "USD")}
</span>
</div> </div>
)} )}
<div className="flex justify-between text-base font-bold text-gray-900 pt-1"> <div className="flex justify-between pt-1 text-base font-bold text-gray-900">
<span>Total</span> <span>Total</span>
<span>{formatCurrency(total, invoice.currency ?? "USD")}</span> <span>{formatCurrency(total, invoice.currency ?? "USD")}</span>
</div> </div>
@@ -192,8 +244,12 @@ function PublicInvoiceView({ token }: { token: string }) {
<> <>
<Separator /> <Separator />
<div> <div>
<p className="text-xs font-semibold uppercase tracking-wider text-gray-400 mb-1">Notes</p> <p className="mb-1 text-xs font-semibold tracking-wider text-gray-400 uppercase">
<p className="text-sm text-gray-700 whitespace-pre-wrap">{invoice.notes}</p> Notes
</p>
<p className="text-sm whitespace-pre-wrap text-gray-700">
{invoice.notes}
</p>
</div> </div>
</> </>
)} )}
@@ -206,9 +262,14 @@ function PublicInvoiceView({ token }: { token: string }) {
className="w-full" className="w-full"
> >
{downloading ? ( {downloading ? (
<><Loader2 className="mr-2 h-4 w-4 animate-spin" /> Generating PDF</> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Generating
PDF
</>
) : ( ) : (
<><Download className="mr-2 h-4 w-4" /> Download PDF</> <>
<Download className="mr-2 h-4 w-4" /> Download PDF
</>
)} )}
</Button> </Button>
</div> </div>
+1 -7
View File
@@ -41,12 +41,6 @@ const geistSans = localFont({
display: "swap", display: "swap",
}); });
const playfair = localFont({
src: "../../node_modules/@fontsource-variable/playfair-display/files/playfair-display-latin-wght-normal.woff2",
variable: "--font-playfair",
display: "swap",
});
const geistMono = localFont({ const geistMono = localFont({
src: "../../public/fonts/geist/mono/GeistMono-VariableFont_wght.ttf", src: "../../public/fonts/geist/mono/GeistMono-VariableFont_wght.ttf",
variable: "--font-geist-mono", variable: "--font-geist-mono",
@@ -61,7 +55,7 @@ export default function RootLayout({
suppressHydrationWarning suppressHydrationWarning
lang="en" lang="en"
data-color-mode="system" data-color-mode="system"
className={`${geistSans.variable} ${playfair.variable} ${geistMono.variable}`} className={`${geistSans.variable} ${geistMono.variable}`}
> >
<head> <head>
<AppearanceInitScript /> <AppearanceInitScript />
@@ -0,0 +1,54 @@
import { cn } from "~/lib/utils";
import {
businessBrandAssetPath,
hasBusinessBrandAsset,
type BrandAssetKind,
type BrandAssetTheme,
type BusinessBrandAssets,
} from "~/lib/business-branding";
type BusinessBrandImageProps = {
business: BusinessBrandAssets & { id: string; name?: string | null };
kind?: BrandAssetKind;
theme?: BrandAssetTheme | "auto";
className?: string;
imageClassName?: string;
decorative?: boolean;
};
export function BusinessBrandImage({
business,
kind = "icon",
theme = "auto",
className,
imageClassName,
decorative = false,
}: BusinessBrandImageProps) {
if (!hasBusinessBrandAsset(business)) return null;
const alt = decorative ? "" : `${business.name ?? "Business"} ${kind}`;
const image = (variant: BrandAssetTheme, variantClassName?: string) => (
// eslint-disable-next-line @next/next/no-img-element -- object-storage-backed SVG/raster brand asset
<img
src={businessBrandAssetPath(business.id, kind, variant)}
alt={alt}
className={cn(
"h-full w-full object-contain",
imageClassName,
variantClassName,
)}
/>
);
return (
<span className={cn("block overflow-hidden", className)}>
{theme === "auto" ? (
<>
{image("light", "dark:hidden")}
{image("dark", "hidden dark:block")}
</>
) : (
image(theme)
)}
</span>
);
}
@@ -0,0 +1,7 @@
"use client";
import { Logo } from "~/components/branding/logo";
export function DashboardBrand({ compact = false }: { compact?: boolean }) {
return <Logo size={compact ? "icon" : "sm"} />;
}
@@ -7,6 +7,7 @@ import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Skeleton } from "~/components/ui/skeleton"; import { Skeleton } from "~/components/ui/skeleton";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { formatCalendarDate } from "@beenvoice/domain/time-zone";
export function CurrentOpenInvoiceCard() { export function CurrentOpenInvoiceCard() {
const { data: currentInvoice, isLoading } = const { data: currentInvoice, isLoading } =
@@ -20,10 +21,10 @@ export function CurrentOpenInvoiceCard() {
}; };
const formatDate = (date: Date) => { const formatDate = (date: Date) => {
return new Intl.DateTimeFormat("en-US", { return formatCalendarDate(date, {
month: "short", month: "short",
day: "numeric", day: "numeric",
}).format(new Date(date)); });
}; };
if (isLoading) { if (isLoading) {
@@ -32,6 +32,7 @@ import {
Plus, Plus,
User, User,
} from "lucide-react"; } from "lucide-react";
import { formatCalendarDate } from "@beenvoice/domain/time-zone";
export function InvoiceList() { export function InvoiceList() {
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
@@ -72,7 +73,7 @@ export function InvoiceList() {
}; };
const formatDate = (date: Date) => { const formatDate = (date: Date) => {
return new Date(date).toLocaleDateString(); return formatCalendarDate(date);
}; };
const formatCurrency = (amount: number) => { const formatCurrency = (amount: number) => {
+158 -47
View File
@@ -24,7 +24,10 @@ import { toast } from "sonner";
import { AddressForm } from "~/components/forms/address-form"; import { AddressForm } from "~/components/forms/address-form";
import { FloatingActionBar } from "~/components/layout/floating-action-bar"; import { FloatingActionBar } from "~/components/layout/floating-action-bar";
import { DashboardPageHeader } from "~/components/layout/page-header"; import { DashboardPageHeader } from "~/components/layout/page-header";
import { DashboardPage, dashboardGapClass } from "~/components/layout/dashboard-page"; import {
DashboardPage,
dashboardGapClass,
} from "~/components/layout/dashboard-page";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Alert, AlertDescription } from "~/components/ui/alert"; import { Alert, AlertDescription } from "~/components/ui/alert";
@@ -43,6 +46,13 @@ import {
VALIDATION_MESSAGES, VALIDATION_MESSAGES,
} from "~/lib/form-constants"; } from "~/lib/form-constants";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import {
businessBrandAssetPath,
getBrandAssetFieldNames,
hasBusinessBrandAsset,
type BrandAssetKind,
type BrandAssetTheme,
} from "~/lib/business-branding";
interface BusinessFormProps { interface BusinessFormProps {
businessId?: string; businessId?: string;
@@ -114,6 +124,54 @@ const ACCEPTED_LOGO_TYPES = new Set([
"image/svg+xml", "image/svg+xml",
]); ]);
const BRAND_ASSET_SLOTS: Array<{
kind: BrandAssetKind;
theme: BrandAssetTheme;
label: string;
description: string;
}> = [
{
kind: "logo",
theme: "light",
label: "Logo · light",
description: "Icon + text for light backgrounds",
},
{
kind: "logo",
theme: "dark",
label: "Logo · dark",
description: "Icon + text for dark backgrounds",
},
{
kind: "wordmark",
theme: "light",
label: "Wordmark · light",
description: "Text-only mark for light backgrounds",
},
{
kind: "wordmark",
theme: "dark",
label: "Wordmark · dark",
description: "Text-only mark for dark backgrounds",
},
{
kind: "icon",
theme: "light",
label: "Icon · light",
description: "Compact mark for light UI",
},
{
kind: "icon",
theme: "dark",
label: "Icon · dark",
description: "Compact mark for dark UI",
},
];
function assetSlotKey(kind: BrandAssetKind, theme: BrandAssetTheme) {
return `${kind}-${theme}`;
}
export function BusinessForm({ businessId, mode }: BusinessFormProps) { export function BusinessForm({ businessId, mode }: BusinessFormProps) {
const router = useRouter(); const router = useRouter();
const utils = api.useUtils(); const utils = api.useUtils();
@@ -123,7 +181,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
const [showApiKey, setShowApiKey] = useState(false); const [showApiKey, setShowApiKey] = useState(false);
const [isDirty, setIsDirty] = useState(false); const [isDirty, setIsDirty] = useState(false);
const [initialized, setInitialized] = useState(false); const [initialized, setInitialized] = useState(false);
const [isUploadingLogo, setIsUploadingLogo] = useState(false); const [uploadingAsset, setUploadingAsset] = useState<string | null>(null);
// Fetch business data if editing // Fetch business data if editing
const { data: business, isLoading: isLoadingBusiness } = const { data: business, isLoading: isLoadingBusiness } =
@@ -165,20 +223,20 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
}); });
const uploadLogo = api.businesses.uploadLogo.useMutation({ const uploadLogo = api.businesses.uploadLogo.useMutation({
onSuccess: async () => { onSuccess: async (_data, variables) => {
await utils.businesses.getById.invalidate({ id: businessId }); await utils.businesses.getById.invalidate({ id: businessId });
toast.success("Logo updated"); toast.success(`${variables.kind} ${variables.theme} variant updated`);
}, },
onError: (error) => { onError: (error) => {
toast.error(error.message || "Failed to upload logo"); toast.error(error.message || "Failed to upload logo");
}, },
onSettled: () => setIsUploadingLogo(false), onSettled: () => setUploadingAsset(null),
}); });
const removeLogo = api.businesses.removeLogo.useMutation({ const removeLogo = api.businesses.removeLogo.useMutation({
onSuccess: async () => { onSuccess: async (_data, variables) => {
await utils.businesses.getById.invalidate({ id: businessId }); await utils.businesses.getById.invalidate({ id: businessId });
toast.success("Logo removed"); toast.success(`${variables.kind} ${variables.theme} variant removed`);
}, },
onError: (error) => { onError: (error) => {
toast.error(error.message || "Failed to remove logo"); toast.error(error.message || "Failed to remove logo");
@@ -187,6 +245,8 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
const handleLogoFileSelected = async ( const handleLogoFileSelected = async (
e: React.ChangeEvent<HTMLInputElement>, e: React.ChangeEvent<HTMLInputElement>,
kind: BrandAssetKind,
theme: BrandAssetTheme,
) => { ) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
e.target.value = ""; e.target.value = "";
@@ -201,7 +261,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
return; return;
} }
setIsUploadingLogo(true); setUploadingAsset(assetSlotKey(kind, theme));
const data = await new Promise<string>((resolve, reject) => { const data = await new Promise<string>((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = () => { reader.onload = () => {
@@ -217,6 +277,8 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
filename: file.name, filename: file.name,
mimeType: file.type, mimeType: file.type,
data, data,
kind,
theme,
}); });
}; };
@@ -229,12 +291,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
// Load business data once when editing (avoid overwriting unsaved changes on refetch) // Load business data once when editing (avoid overwriting unsaved changes on refetch)
useEffect(() => { useEffect(() => {
if ( if (business && mode === "edit" && !initialized && !isLoadingEmailConfig) {
business &&
mode === "edit" &&
!initialized &&
!isLoadingEmailConfig
) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded business data into the edit form. // eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded business data into the edit form.
setFormData({ setFormData({
name: business.name, name: business.name,
@@ -732,74 +789,128 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
<ImageIcon className="text-muted-foreground h-5 w-5" /> <ImageIcon className="text-muted-foreground h-5 w-5" />
</div> </div>
<div> <div>
<CardTitle>Logo</CardTitle> <CardTitle>Brand assets</CardTitle>
<p className="text-muted-foreground mt-1 text-sm"> <p className="text-muted-foreground mt-1 text-sm">
Shown on invoices sent to your clients. PNG, JPEG, Add logos, wordmarks, and icons for light and dark
WebP, or SVG, up to 5MB. backgrounds. Missing variants fall back automatically.
PNG, JPEG, WebP, or SVG, up to 5MB.
</p> </p>
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex items-center gap-4"> <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<div className="bg-muted border-border/40 flex h-20 min-w-20 max-w-[240px] shrink-0 items-center justify-center overflow-hidden border px-2"> {BRAND_ASSET_SLOTS.map((slot) => {
{business?.logoStorageKey ? ( const [storageField] = getBrandAssetFieldNames(
// eslint-disable-next-line @next/next/no-img-element -- external/object-storage-backed image, not a static asset slot.kind,
slot.theme,
);
const hasAsset = Boolean(business?.[storageField]);
const key = assetSlotKey(slot.kind, slot.theme);
const inputId = `brand-asset-${key}`;
const isUploading = uploadingAsset === key;
return (
<div
key={key}
className="border-border/60 overflow-hidden rounded-xl border"
>
<div
className={cn(
"flex h-28 items-center justify-center p-4",
slot.theme === "dark"
? "bg-neutral-950"
: "bg-white",
)}
>
{hasAsset ? (
// eslint-disable-next-line @next/next/no-img-element -- object-storage-backed image
<img <img
src={`/api/business-logo/${businessId}?v=${business.updatedAt ? new Date(business.updatedAt).getTime() : 0}`} src={`${businessBrandAssetPath(businessId, slot.kind, slot.theme)}&v=${business?.updatedAt ? new Date(business.updatedAt).getTime() : 0}`}
alt={`${business.name} logo`} alt={`${business?.name ?? "Business"} ${slot.label}`}
className="h-full w-auto max-w-full object-contain" className={cn(
"max-h-full max-w-full object-contain",
slot.kind === "icon" && "aspect-square",
)}
/> />
) : ( ) : (
<ImageIcon className="text-muted-foreground/50 h-8 w-8" /> <ImageIcon
className={cn(
"h-8 w-8",
slot.theme === "dark"
? "text-white/35"
: "text-black/25",
)}
/>
)} )}
</div> </div>
<div className="flex flex-col gap-2 sm:flex-row"> <div className="space-y-3 p-3">
<div>
<p className="text-sm font-medium">
{slot.label}
</p>
<p className="text-muted-foreground text-xs">
{slot.description}
</p>
</div>
<div className="flex gap-2">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="sm"
disabled={isUploadingLogo} className="flex-1"
disabled={Boolean(uploadingAsset)}
onClick={() => onClick={() =>
document.getElementById("logo-upload-input")?.click() document.getElementById(inputId)?.click()
} }
> >
{isUploadingLogo ? ( {isUploading ? (
<Loader2 className="h-4 w-4 animate-spin sm:mr-2" /> <Loader2 className="h-4 w-4 animate-spin" />
) : ( ) : (
<Upload className="h-4 w-4 sm:mr-2" /> <Upload className="h-4 w-4" />
)} )}
<span className="hidden sm:inline"> {hasAsset ? "Replace" : "Upload"}
{business?.logoStorageKey
? "Replace logo"
: "Upload logo"}
</span>
</Button> </Button>
{business?.logoStorageKey && ( {hasAsset ? (
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="icon"
className="h-8 w-8 shrink-0"
aria-label={`Remove ${slot.label}`}
disabled={removeLogo.isPending} disabled={removeLogo.isPending}
onClick={() => onClick={() =>
businessId && removeLogo.mutate({ id: businessId }) businessId &&
removeLogo.mutate({
id: businessId,
kind: slot.kind,
theme: slot.theme,
})
} }
> >
<Trash2 className="h-4 w-4 sm:mr-2" /> <Trash2 className="h-4 w-4" />
<span className="hidden sm:inline">Remove</span>
</Button> </Button>
)} ) : null}
<input <input
id="logo-upload-input" id={inputId}
type="file" type="file"
accept="image/png,image/jpeg,image/webp,image/svg+xml" accept="image/png,image/jpeg,image/webp,image/svg+xml"
className="hidden" className="hidden"
onChange={handleLogoFileSelected} onChange={(event) =>
void handleLogoFileSelected(
event,
slot.kind,
slot.theme,
)
}
/> />
</div> </div>
</div> </div>
</div>
);
})}
</div>
{business?.logoStorageKey && ( {hasBusinessBrandAsset(business) && (
<div className="bg-muted border-border/40 mt-4 flex items-center justify-between border p-4"> <div className="bg-muted border-border/40 mt-4 flex items-center justify-between border p-4">
<div className="space-y-0.5"> <div className="space-y-0.5">
<Label <Label
@@ -809,8 +920,8 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
Hide business name on invoices Hide business name on invoices
</Label> </Label>
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
Show only the logo in the invoice header useful Show only the logo in the invoice header useful if
if your logo already includes your business name. your logo already includes your business name.
</p> </p>
</div> </div>
<Switch <Switch
@@ -3,6 +3,7 @@
import { generateInvoiceEmailTemplate } from "~/lib/email-templates"; import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
import { getAppUrl } from "~/lib/app-url"; import { getAppUrl } from "~/lib/app-url";
import { calculateLineItemAmount } from "~/lib/invoice-line-item"; import { calculateLineItemAmount } from "~/lib/invoice-line-item";
import type { BusinessBrandAssets } from "~/lib/business-branding";
interface EmailPreviewProps { interface EmailPreviewProps {
subject: string; subject: string;
@@ -28,9 +29,7 @@ interface EmailPreviewProps {
id?: string; id?: string;
name: string; name: string;
email: string | null; email: string | null;
logoStorageKey?: string | null; } & BusinessBrandAssets;
logoMimeType?: string | null;
};
items?: Array<{ items?: Array<{
id: string; id: string;
date?: Date; date?: Date;
@@ -87,7 +86,8 @@ export function EmailPreview({
description: item.description ?? "Service", description: item.description ?? "Service",
hours: item.hours, hours: item.hours,
rate: item.rate, rate: item.rate,
amount: item.amount ?? calculateLineItemAmount(item.hours, item.rate), amount:
item.amount ?? calculateLineItemAmount(item.hours, item.rate),
})) ?? [], })) ?? [],
}, },
customContent: content, customContent: content,
@@ -24,6 +24,10 @@ import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label"; import { Label } from "~/components/ui/label";
import { NumberInput } from "~/components/ui/number-input"; import { NumberInput } from "~/components/ui/number-input";
import {
calendarDateFromLocalDate,
calendarDateToLocalDate,
} from "@beenvoice/domain/time-zone";
import { import {
Plus, Plus,
Trash2, Trash2,
@@ -77,7 +81,7 @@ export function InvoiceCalendarView({
return items return items
.map((item, index) => ({ item, index })) .map((item, index) => ({ item, index }))
.filter((wrapper) => { .filter((wrapper) => {
const itemDate = new Date(wrapper.item.date); const itemDate = calendarDateToLocalDate(wrapper.item.date);
return isSameDay(itemDate, date); return isSameDay(itemDate, date);
}); });
}, [items, date]); }, [items, date]);
@@ -88,7 +92,7 @@ export function InvoiceCalendarView({
return items return items
.map((item, index) => ({ item, index })) .map((item, index) => ({ item, index }))
.filter((wrapper) => { .filter((wrapper) => {
const itemDate = new Date(wrapper.item.date); const itemDate = calendarDateToLocalDate(wrapper.item.date);
return isSameDay(itemDate, targetDate); return isSameDay(itemDate, targetDate);
}); });
}, },
@@ -103,7 +107,7 @@ export function InvoiceCalendarView({
const handleAddNewItem = () => { const handleAddNewItem = () => {
if (date) { if (date) {
onAddItem(date); onAddItem(calendarDateFromLocalDate(date));
} }
}; };
@@ -407,7 +411,11 @@ export function InvoiceCalendarView({
</p> </p>
</div> </div>
{!readOnly ? ( {!readOnly ? (
<Button onClick={handleAddNewItem} className="mt-2" size="lg"> <Button
onClick={handleAddNewItem}
className="mt-2"
size="lg"
>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Log Time Log Time
</Button> </Button>
@@ -494,7 +502,11 @@ export function InvoiceCalendarView({
Total Total
</span> </span>
<span className="text-primary text-lg font-bold"> <span className="text-primary text-lg font-bold">
${calculateLineItemAmount(item.hours, item.rate).toFixed(2)} $
{calculateLineItemAmount(
item.hours,
item.rate,
).toFixed(2)}
</span> </span>
</div> </div>
</div> </div>
+38 -14
View File
@@ -42,7 +42,8 @@ import {
Mail, Mail,
} from "lucide-react"; } from "lucide-react";
import { SUPPORTED_CURRENCIES } from "~/lib/currency"; import { SUPPORTED_CURRENCIES } from "~/lib/currency";
import { generateInvoiceNumber } from "~/lib/draft-invoice"; import { defaultDueDate, generateInvoiceNumber } from "~/lib/draft-invoice";
import { calendarDateFromLocalDate } from "@beenvoice/domain/time-zone";
import { Textarea } from "~/components/ui/textarea"; import { Textarea } from "~/components/ui/textarea";
import { import {
DropdownMenu, DropdownMenu,
@@ -108,13 +109,14 @@ function plainTextToHtml(value: string) {
} }
function createDefaultInvoiceFormData(): InvoiceFormData { function createDefaultInvoiceFormData(): InvoiceFormData {
const today = calendarDateFromLocalDate(new Date());
return { return {
invoiceNumber: generateInvoiceNumber(), invoiceNumber: generateInvoiceNumber(),
invoicePrefix: "#", invoicePrefix: "#",
businessId: "", businessId: "",
clientId: "", clientId: "",
issueDate: new Date(), issueDate: today,
dueDate: new Date(), dueDate: defaultDueDate(today),
status: "draft", status: "draft",
notes: "", notes: "",
emailMessage: "", emailMessage: "",
@@ -124,7 +126,7 @@ function createDefaultInvoiceFormData(): InvoiceFormData {
items: [ items: [
{ {
id: crypto.randomUUID(), id: crypto.randomUUID(),
date: new Date(), date: today,
description: "", description: "",
hours: 1, hours: 1,
rate: 0, rate: 0,
@@ -209,7 +211,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
: [ : [
{ {
id: crypto.randomUUID(), id: crypto.randomUUID(),
date: new Date(), date: calendarDateFromLocalDate(new Date()),
description: "", description: "",
hours: 1, hours: 1,
rate: 0, rate: 0,
@@ -320,7 +322,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
...prev.items, ...prev.items,
{ {
id: crypto.randomUUID(), id: crypto.randomUUID(),
date: new Date(), date: calendarDateFromLocalDate(new Date()),
description: parsed.description, description: parsed.description,
hours: parsed.hours ?? 1, hours: parsed.hours ?? 1,
rate: parsed.rate ?? prev.defaultHourlyRate ?? 0, rate: parsed.rate ?? prev.defaultHourlyRate ?? 0,
@@ -350,7 +352,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
items: prev.items.map((item, i) => { items: prev.items.map((item, i) => {
if (i !== idx) return item; if (i !== idx) return item;
if (field === "billingType" && (value === "hourly" || value === "fixed")) { if (
field === "billingType" &&
(value === "hourly" || value === "fixed")
) {
const next = applyBillingTypeChange(value, item); const next = applyBillingTypeChange(value, item);
return { return {
...item, ...item,
@@ -401,7 +406,9 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
return; return;
} }
const itemsToSave = formData.items.filter((item) => item.description?.trim()); const itemsToSave = formData.items.filter((item) =>
item.description?.trim(),
);
let invalidItemIndex = -1; let invalidItemIndex = -1;
for (let i = 0; i < formData.items.length; i++) { for (let i = 0; i < formData.items.length; i++) {
@@ -515,7 +522,11 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
</Button> </Button>
</DashboardPageHeader> </DashboardPageHeader>
<PageTabs value={activeTab} className="w-full" onValueChange={setActiveTab}> <PageTabs
value={activeTab}
className="w-full"
onValueChange={setActiveTab}
>
<PageTabsList> <PageTabsList>
<PageTabsTrigger value="details">Details</PageTabsTrigger> <PageTabsTrigger value="details">Details</PageTabsTrigger>
<PageTabsTrigger value="items">Items</PageTabsTrigger> <PageTabsTrigger value="items">Items</PageTabsTrigger>
@@ -606,7 +617,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
<DatePicker <DatePicker
date={formData.issueDate} date={formData.issueDate}
onDateChange={(d) => onDateChange={(d) =>
updateField("issueDate", d ?? new Date()) updateField(
"issueDate",
d ?? calendarDateFromLocalDate(new Date()),
)
} }
className="w-full" className="w-full"
/> />
@@ -616,7 +630,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
<DatePicker <DatePicker
date={formData.dueDate} date={formData.dueDate}
onDateChange={(d) => onDateChange={(d) =>
updateField("dueDate", d ?? new Date()) updateField(
"dueDate",
d ?? calendarDateFromLocalDate(new Date()),
)
} }
className="w-full" className="w-full"
/> />
@@ -721,7 +738,9 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
<CardContent> <CardContent>
<Textarea <Textarea
value={formData.emailMessage} value={formData.emailMessage}
onChange={(e) => updateField("emailMessage", e.target.value)} onChange={(e) =>
updateField("emailMessage", e.target.value)
}
placeholder="Add a note that appears only in the email body..." placeholder="Add a note that appears only in the email body..."
className="min-h-[140px]" className="min-h-[140px]"
/> />
@@ -818,7 +837,9 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
onRemoveItem={removeItem} onRemoveItem={removeItem}
onUpdateItem={updateItem} onUpdateItem={updateItem}
onAddItemWithValues={addItemWithValues} onAddItemWithValues={addItemWithValues}
invoiceId={invoiceId && invoiceId !== "new" ? invoiceId : undefined} invoiceId={
invoiceId && invoiceId !== "new" ? invoiceId : undefined
}
clientId={formData.clientId || undefined} clientId={formData.clientId || undefined}
defaultRate={formData.items[0]?.rate} defaultRate={formData.items[0]?.rate}
readOnly={formData.status !== "draft"} readOnly={formData.status !== "draft"}
@@ -925,7 +946,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
description: item.description, description: item.description,
hours: item.hours, hours: item.hours,
rate: item.rate, rate: item.rate,
amount: calculateLineItemAmount(item.hours, item.rate), amount: calculateLineItemAmount(
item.hours,
item.rate,
),
})), })),
}} }}
/> />
@@ -51,6 +51,10 @@ import {
} from "~/lib/invoice-import"; } from "~/lib/invoice-import";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import {
addCalendarDays,
formatCalendarDate,
} from "@beenvoice/domain/time-zone";
interface StagedInvoice extends ImportInvoice { interface StagedInvoice extends ImportInvoice {
id: string; id: string;
@@ -173,9 +177,10 @@ export function InvoiceImportPage() {
if (inv.id !== id) return inv; if (inv.id !== id) return inv;
const updated = { ...inv, ...updates }; const updated = { ...inv, ...updates };
if (updates.issueDate !== undefined && !updates.dueDate) { if (updates.issueDate !== undefined && !updates.dueDate) {
const due = new Date(updated.issueDate ?? new Date()); updated.dueDate = addCalendarDays(
due.setDate(due.getDate() + 30); updated.issueDate ?? new Date(),
updated.dueDate = due; 30,
);
} }
return updated; return updated;
}), }),
@@ -628,12 +633,14 @@ export function InvoiceImportPage() {
{previewInvoice.items.map((item, idx) => ( {previewInvoice.items.map((item, idx) => (
<tr key={idx} className="border-border border-b"> <tr key={idx} className="border-border border-b">
<td className="p-2 text-sm whitespace-nowrap"> <td className="p-2 text-sm whitespace-nowrap">
{item.date?.toLocaleDateString() ?? "—"} {item.date ? formatCalendarDate(item.date) : "—"}
</td> </td>
<td className="max-w-xs truncate p-2 text-sm"> <td className="max-w-xs truncate p-2 text-sm">
{item.description} {item.description}
</td> </td>
<td className="p-2 text-right text-sm">{item.quantity}</td> <td className="p-2 text-right text-sm">
{item.quantity}
</td>
<td className="p-2 text-right text-sm"> <td className="p-2 text-right text-sm">
{item.rate.toLocaleString("en-US", { {item.rate.toLocaleString("en-US", {
style: "currency", style: "currency",
@@ -9,9 +9,14 @@ import {
} from "~/components/layout/sidebar-provider"; } from "~/components/layout/sidebar-provider";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { Menu } from "lucide-react"; import { Menu } from "lucide-react";
import { Logo } from "~/components/branding/logo"; import { DashboardBrand } from "~/components/branding/dashboard-brand";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet"; import {
Sheet,
SheetContent,
SheetTitle,
SheetTrigger,
} from "~/components/ui/sheet";
import { ActiveTimerWidget } from "~/app/dashboard/_components/active-timer-widget"; import { ActiveTimerWidget } from "~/app/dashboard/_components/active-timer-widget";
import { OnboardingGuard } from "~/components/layout/onboarding-guard"; import { OnboardingGuard } from "~/components/layout/onboarding-guard";
@@ -40,21 +45,23 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
className="bg-background h-10 w-10 shadow-sm" className="bg-background size-10 shadow-sm"
suppressHydrationWarning suppressHydrationWarning
> >
<Menu className="h-5 w-5" /> <Menu />
<span className="sr-only">Toggle menu</span> <span className="sr-only">Toggle menu</span>
</Button> </Button>
</SheetTrigger> </SheetTrigger>
<div className="ml-3 flex min-w-0 flex-1 items-center gap-2 sm:ml-4"> <div className="ml-3 flex min-w-0 flex-1 items-center gap-2 sm:ml-4">
<Logo size="sm" className="shrink-0" /> <DashboardBrand />
<ActiveTimerWidget compact /> <ActiveTimerWidget compact />
</div> </div>
<SheetContent side="left" className="w-72 p-0"> <SheetContent
<div className="sr-only"> side="left"
<h2 id="mobile-nav-title">Navigation Menu</h2> className="w-80 max-w-[90vw] gap-0 p-0"
</div> aria-describedby={undefined}
>
<SheetTitle className="sr-only">Navigation menu</SheetTitle>
<Sidebar mobile onClose={() => setIsMobileOpen(false)} /> <Sidebar mobile onClose={() => setIsMobileOpen(false)} />
</SheetContent> </SheetContent>
</Sheet> </Sheet>
@@ -64,7 +71,7 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
suppressHydrationWarning suppressHydrationWarning
className={cn( className={cn(
"min-h-screen min-w-0 flex-1 transition-all duration-300 ease-in-out md:ml-0", "min-h-screen min-w-0 flex-1 transition-all duration-300 ease-in-out md:ml-0",
!isOnboarding && (isCollapsed ? "md:ml-16" : "md:ml-64"), !isOnboarding && (isCollapsed ? "md:ml-20" : "md:ml-72"),
)} )}
> >
{isOnboarding ? ( {isOnboarding ? (
@@ -13,29 +13,52 @@ const SidebarContext = React.createContext<SidebarContextType | undefined>(
undefined, undefined,
); );
const SIDEBAR_STORAGE_KEY = "sidebar-collapsed";
const SIDEBAR_CHANGE_EVENT = "beenvoice:sidebar-change";
function getSidebarSnapshot() {
return localStorage.getItem(SIDEBAR_STORAGE_KEY) === "true";
}
function getServerSidebarSnapshot() {
return false;
}
function subscribeToSidebar(callback: () => void) {
const handleStorage = (event: StorageEvent) => {
if (event.key === SIDEBAR_STORAGE_KEY) callback();
};
window.addEventListener("storage", handleStorage);
window.addEventListener(SIDEBAR_CHANGE_EVENT, callback);
return () => {
window.removeEventListener("storage", handleStorage);
window.removeEventListener(SIDEBAR_CHANGE_EVENT, callback);
};
}
function saveSidebarState(collapsed: boolean) {
localStorage.setItem(SIDEBAR_STORAGE_KEY, String(collapsed));
window.dispatchEvent(new Event(SIDEBAR_CHANGE_EVENT));
}
export function SidebarProvider({ children }: { children: React.ReactNode }) { export function SidebarProvider({ children }: { children: React.ReactNode }) {
const [isCollapsed, setIsCollapsed] = React.useState(() => { const isCollapsed = React.useSyncExternalStore(
if (typeof window === "undefined") return false; subscribeToSidebar,
const saved = localStorage.getItem("sidebar-collapsed"); getSidebarSnapshot,
return saved ? (JSON.parse(saved) as boolean) : false; getServerSidebarSnapshot,
}); );
const toggleCollapse = React.useCallback(() => { const toggleCollapse = React.useCallback(() => {
setIsCollapsed((prev) => { saveSidebarState(!isCollapsed);
const next = !prev; }, [isCollapsed]);
localStorage.setItem("sidebar-collapsed", JSON.stringify(next));
return next;
});
}, []);
const expand = React.useCallback(() => { const expand = React.useCallback(() => {
setIsCollapsed(false); saveSidebarState(false);
localStorage.setItem("sidebar-collapsed", JSON.stringify(false));
}, []); }, []);
const collapse = React.useCallback(() => { const collapse = React.useCallback(() => {
setIsCollapsed(true); saveSidebarState(true);
localStorage.setItem("sidebar-collapsed", JSON.stringify(true));
}, []); }, []);
return ( return (
+248 -169
View File
@@ -1,220 +1,298 @@
"use client"; "use client";
import type { ComponentType } from "react";
import {
ChevronsUpDown,
LogOut,
PanelLeftClose,
PanelLeftOpen,
Plus,
} from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { authClient } from "~/lib/auth-client";
import { Skeleton } from "~/components/ui/skeleton"; import { ActiveTimerWidget } from "~/app/dashboard/_components/active-timer-widget";
import { DashboardBrand } from "~/components/branding/dashboard-brand";
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { LogOut, PanelLeftClose, PanelLeftOpen } from "lucide-react"; import {
import { getNavigationForUser, isNavLinkActive } from "~/lib/navigation"; DropdownMenu,
import { useSidebar } from "./sidebar-provider"; DropdownMenuContent,
import { cn } from "~/lib/utils"; DropdownMenuGroup,
import { Logo } from "~/components/branding/logo"; DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import { Separator } from "~/components/ui/separator";
import { Skeleton } from "~/components/ui/skeleton";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "~/components/ui/tooltip"; } from "~/components/ui/tooltip";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import { getGravatarUrl } from "~/lib/gravatar";
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
import { useAuthSession } from "~/hooks/use-auth-session"; import { useAuthSession } from "~/hooks/use-auth-session";
import { useDashboardUser } from "~/components/layout/dashboard-user-context"; import { authClient } from "~/lib/auth-client";
import { ActiveTimerWidget } from "~/app/dashboard/_components/active-timer-widget"; import { getGravatarUrl } from "~/lib/gravatar";
import { getNavigationForUser, isNavLinkActive } from "~/lib/navigation";
import { cn } from "~/lib/utils";
import { useDashboardUser } from "./dashboard-user-context";
import { useSidebar } from "./sidebar-provider";
interface SidebarProps { interface SidebarProps {
mobile?: boolean; mobile?: boolean;
onClose?: () => void; onClose?: () => void;
} }
export function Sidebar({ mobile, onClose }: SidebarProps) { interface SidebarLinkProps {
name: string;
href: string;
icon: ComponentType<{ className?: string }>;
active: boolean;
collapsed: boolean;
mobile: boolean;
onClose?: () => void;
}
function SidebarLink({
name,
href,
icon: Icon,
active,
collapsed,
mobile,
onClose,
}: SidebarLinkProps) {
const link = (
<Link
href={href}
aria-current={active ? "page" : undefined}
onClick={mobile ? onClose : undefined}
className={cn(
"group focus-visible:ring-ring relative flex h-10 items-center rounded-xl text-sm font-medium outline-hidden transition-colors focus-visible:ring-2",
collapsed ? "w-11 justify-center" : "gap-3 px-3",
active
? "bg-accent text-accent-foreground shadow-sm"
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
)}
>
<Icon className="size-4.5 shrink-0" />
{!collapsed ? (
<>
<span className="min-w-0 flex-1 truncate">{name}</span>
{active ? (
<span className="bg-primary size-1.5 shrink-0 rounded-full" />
) : null}
</>
) : null}
</Link>
);
if (!collapsed) return link;
return (
<Tooltip>
<TooltipTrigger asChild>{link}</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{name}
</TooltipContent>
</Tooltip>
);
}
export function Sidebar({ mobile = false, onClose }: SidebarProps) {
const pathname = usePathname(); const pathname = usePathname();
const { data: session, isPending } = useAuthSession(); const { data: session, isPending } = useAuthSession();
const { isAdmin } = useDashboardUser(); const { isAdmin } = useDashboardUser();
const { isCollapsed, toggleCollapse } = useSidebar(); const { isCollapsed, toggleCollapse } = useSidebar();
const navSections = getNavigationForUser(isAdmin); const navSections = getNavigationForUser(isAdmin);
// If mobile, always expanded
const collapsed = mobile ? false : isCollapsed; const collapsed = mobile ? false : isCollapsed;
const SidebarContent = ( const sidebarContent = (
<div className="flex h-full flex-col justify-between"> <TooltipProvider delayDuration={150}>
<div> <div className="flex h-full min-h-0 flex-col">
{/* Header / Logo */} <header
<div
className={cn( className={cn(
"mb-2 flex h-14 items-center px-4", "border-border/70 flex h-20 shrink-0 items-center border-b",
collapsed ? "justify-center px-2" : "justify-between", collapsed ? "justify-center px-2" : "px-4",
)} )}
> >
{!collapsed && ( <Link
<div className="flex items-center gap-2"> href="/dashboard"
<Logo size="sm" /> onClick={mobile ? onClose : undefined}
</div> aria-label="Beenvoice dashboard"
)}
{collapsed && <Logo size="icon" />}
{!mobile && !collapsed && (
<div className="h-8 w-8" /> // Spacer to keep alignment if needed, or just remove
)}
</div>
{/* Navigation */}
<nav
className={cn( className={cn(
"mt-4 flex flex-col gap-6 px-2", "focus-visible:ring-ring flex min-w-0 items-center outline-hidden focus-visible:ring-2",
collapsed ? "justify-center rounded-lg" : "flex-1",
)}
>
<DashboardBrand compact={collapsed} />
</Link>
</header>
<div className="flex min-h-0 flex-1 flex-col">
<nav
id="dashboard-sidebar-navigation"
aria-label="Dashboard navigation"
className="min-h-0 flex-1 overflow-y-auto px-3 py-4"
>
<div className="flex flex-col gap-5">
<div className={cn("flex", collapsed && "justify-center")}>
{collapsed ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
asChild
size="icon"
className="size-11 rounded-xl"
>
<Link href="/dashboard/invoices/new">
<Plus />
<span className="sr-only">New invoice</span>
</Link>
</Button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
New invoice
</TooltipContent>
</Tooltip>
) : (
<Button asChild className="w-full justify-start">
<Link
href="/dashboard/invoices/new"
onClick={mobile ? onClose : undefined}
>
<Plus data-icon="inline-start" />
New invoice
</Link>
</Button>
)}
</div>
{navSections.map((section, sectionIndex) => (
<section
key={section.title}
aria-label={collapsed ? section.title : undefined}
aria-labelledby={
collapsed ? undefined : `sidebar-section-${sectionIndex}`
}
className="flex flex-col gap-2"
>
{collapsed ? (
sectionIndex > 0 ? (
<Separator className="mx-auto w-8" />
) : null
) : (
<h2
id={`sidebar-section-${sectionIndex}`}
className="text-muted-foreground px-3 text-[0.6875rem] font-semibold tracking-[0.12em] uppercase"
>
{section.title}
</h2>
)}
<div
className={cn(
"flex flex-col gap-1",
collapsed && "items-center", collapsed && "items-center",
)} )}
> >
{navSections.map((section) => ( {section.links.map((link) => (
<div key={section.title}> <SidebarLink
{!collapsed && ( key={link.href}
<div className="text-muted-foreground/60 mb-2 px-2 text-xs font-semibold tracking-wider uppercase"> {...link}
{section.title} active={isNavLinkActive(pathname, link.href)}
collapsed={collapsed}
mobile={mobile}
onClose={onClose}
/>
))}
</div> </div>
)} </section>
<div className="flex flex-col gap-1"> ))}
<div className="flex flex-col gap-1"> </div>
{section.links.map((link) => { </nav>
const Icon = link.icon;
const isActive = isNavLinkActive(pathname, link.href);
if (collapsed) { {!mobile ? (
return ( <div
<TooltipProvider key={link.href} delayDuration={0}> className={cn(
"shrink-0 px-3 pb-3",
collapsed && "flex justify-center",
)}
>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Link <Button
href={link.href} type="button"
data-active={isActive ? "true" : undefined} variant="ghost"
size={collapsed ? "icon" : "default"}
aria-label={
collapsed ? "Expand sidebar" : "Collapse sidebar"
}
aria-expanded={!collapsed}
aria-controls="dashboard-sidebar-navigation"
onClick={toggleCollapse}
className={cn( className={cn(
"flex h-10 w-10 items-center justify-center rounded-md transition-colors", "text-muted-foreground h-10 rounded-xl",
isActive collapsed
? "bg-primary text-primary-foreground shadow-sm" ? "mx-auto size-11"
: "text-muted-foreground hover:bg-muted hover:text-foreground", : "w-full justify-start gap-3 px-3",
)} )}
> >
<Icon className="h-5 w-5" /> {collapsed ? <PanelLeftOpen /> : <PanelLeftClose />}
</Link> {!collapsed ? <span>Collapse sidebar</span> : null}
</Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent <TooltipContent side="right" sideOffset={8}>
side="right" {collapsed ? "Expand sidebar" : "Collapse sidebar"}
className="font-medium"
>
{link.name}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider>
);
}
return (
<Link
key={link.href}
href={link.href}
data-active={isActive ? "true" : undefined}
onClick={mobile ? onClose : undefined}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<Icon className="h-4 w-4" />
{link.name}
</Link>
);
})}
</div> </div>
</div> ) : null}
</div>
))}
</nav>
</div>
{/* Footer / User */}
<div className="mt-auto space-y-2 p-2">
{!mobile && (
<div
className={cn(
"flex",
collapsed ? "justify-center" : "justify-end px-2",
)}
>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground h-8 w-8"
onClick={toggleCollapse}
>
{collapsed ? (
<PanelLeftOpen className="h-4 w-4" />
) : (
<PanelLeftClose className="h-4 w-4" />
)}
</Button>
</div>
)}
<footer className="border-border/70 flex shrink-0 flex-col gap-2 border-t p-3">
<ActiveTimerWidget collapsed={collapsed} /> <ActiveTimerWidget collapsed={collapsed} />
<div
className={cn(
"border-border/50 border-t pt-4",
collapsed ? "flex flex-col items-center gap-2" : "px-2",
)}
>
{isPending ? ( {isPending ? (
<div <div
className={cn( className={cn(
"flex items-center gap-3", "flex h-12 items-center gap-3 px-2",
collapsed ? "justify-center" : "px-2", collapsed && "justify-center px-0",
)} )}
> >
<Skeleton className="h-9 w-9 rounded-full" /> <Skeleton className="size-9 rounded-full" />
{!collapsed && ( {!collapsed ? (
<div className="flex-1 space-y-1"> <div className="flex min-w-0 flex-1 flex-col gap-1.5">
<Skeleton className="h-3 w-20" /> <Skeleton className="h-3 w-20" />
<Skeleton className="h-2 w-24" /> <Skeleton className="h-2.5 w-28" />
</div> </div>
)} ) : null}
</div> </div>
) : session?.user ? ( ) : session?.user ? (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
size={collapsed ? "icon" : "default"}
aria-label={collapsed ? "Open account menu" : undefined}
className={cn( className={cn(
"w-full justify-start p-0 hover:bg-transparent", "h-auto min-h-12 rounded-xl",
collapsed && "justify-center", collapsed
? "mx-auto size-11 p-0"
: "w-full justify-start gap-3 px-2 py-1.5",
)} )}
> >
{/* FIXED: Changed div to span to prevent hydration error */} <Avatar className="border-border size-9 shrink-0 border">
<span
className={cn(
"flex items-center gap-3",
collapsed ? "justify-center" : "w-full",
)}
>
<Avatar className="border-border h-9 w-9 border">
<AvatarImage <AvatarImage
src={getGravatarUrl(session.user.email)} src={getGravatarUrl(session.user.email)}
alt={session.user.name ?? "User"} alt=""
/> />
<AvatarFallback> <AvatarFallback>
{session.user.name?.[0] ?? "U"} {session.user.name?.[0] ?? "U"}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
{!collapsed && ( {!collapsed ? (
<>
<span className="min-w-0 flex-1 text-left"> <span className="min-w-0 flex-1 text-left">
<span className="block truncate text-sm font-medium"> <span className="block truncate text-sm font-medium">
{session.user.name} {session.user.name}
@@ -223,57 +301,58 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
{session.user.email} {session.user.email}
</span> </span>
</span> </span>
)} <ChevronsUpDown className="text-muted-foreground" />
</span> </>
) : null}
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
side="right" side={mobile ? "top" : "right"}
align="end" align="end"
className="bg-background/80 border-border/50 w-56 backdrop-blur-xl" sideOffset={8}
sideOffset={10} className="border-border w-60 border"
> >
<DropdownMenuLabel> <DropdownMenuLabel className="flex flex-col gap-1">
<div className="flex flex-col space-y-1"> <span className="truncate">{session.user.name}</span>
<p className="text-sm leading-none font-medium"> <span className="text-muted-foreground truncate text-xs font-normal">
{session.user.name}
</p>
<p className="text-muted-foreground text-xs leading-none">
{session.user.email} {session.user.email}
</p> </span>
</div>
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem <DropdownMenuItem
variant="destructive"
onClick={async () => { onClick={async () => {
await authClient.signOut(); await authClient.signOut();
window.location.href = "/"; window.location.href = "/";
}} }}
className="text-red-600 focus:bg-red-100/50 focus:text-red-600 dark:focus:bg-red-900/20"
> >
<LogOut className="mr-2 h-4 w-4" /> <LogOut />
Sign Out Sign out
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
) : null} ) : null}
</footer>
</div> </div>
</div> </div>
</div> </TooltipProvider>
); );
if (mobile) { if (mobile) {
return <div className="bg-background h-full">{SidebarContent}</div>; return <div className="bg-background h-full">{sidebarContent}</div>;
} }
return ( return (
<aside <aside
className={cn( className={cn(
"border-border bg-background fixed top-0 bottom-0 left-0 z-30 hidden flex-col rounded-none border-r shadow-none transition-all duration-300 ease-in-out md:flex", "border-border bg-background fixed inset-y-0 left-0 z-30 hidden border-r transition-[width] duration-200 ease-out md:block",
isCollapsed ? "w-16" : "w-64", isCollapsed ? "w-20" : "w-72",
)} )}
> >
{SidebarContent} {sidebarContent}
</aside> </aside>
); );
} }
@@ -46,15 +46,10 @@ import {
import { invoiceLabel } from "~/lib/time-entry-display"; import { invoiceLabel } from "~/lib/time-entry-display";
import { TimeEntryList } from "~/components/time-clock/time-entry-list"; import { TimeEntryList } from "~/components/time-clock/time-entry-list";
import { TimeEntryEditDialog } from "~/components/time-clock/time-entry-edit-dialog"; import { TimeEntryEditDialog } from "~/components/time-clock/time-entry-edit-dialog";
import { toLocalDateTimeInputValue } from "@beenvoice/domain/time-zone";
type StartMode = "now" | "pick" | "ago"; type StartMode = "now" | "pick" | "ago";
function toDatetimeLocalValue(value: Date | string) {
const start = new Date(value);
start.setMinutes(start.getMinutes() - start.getTimezoneOffset());
return start.toISOString().slice(0, 16);
}
function RunningTextFields({ function RunningTextFields({
running, running,
updateRunningPending, updateRunningPending,
@@ -68,7 +63,7 @@ function RunningTextFields({
}) { }) {
const [title, setTitle] = useState(running.description ?? ""); const [title, setTitle] = useState(running.description ?? "");
const [runningStartedAt, setRunningStartedAt] = useState(() => const [runningStartedAt, setRunningStartedAt] = useState(() =>
toDatetimeLocalValue(running.startedAt), toLocalDateTimeInputValue(new Date(running.startedAt)),
); );
return ( return (
@@ -121,10 +116,8 @@ export function TimeClockPanel({
compact = false, compact = false,
}: TimeClockPanelProps) { }: TimeClockPanelProps) {
const utils = api.useUtils(); const utils = api.useUtils();
const { data: running, isLoading: runningLoading } = api.timeEntries.getRunning.useQuery( const { data: running, isLoading: runningLoading } =
undefined, api.timeEntries.getRunning.useQuery(undefined, { refetchInterval: 30_000 });
{ refetchInterval: 30_000 },
);
const { data: clients } = api.clients.getAll.useQuery(); const { data: clients } = api.clients.getAll.useQuery();
const todayStart = useMemo(() => { const todayStart = useMemo(() => {
@@ -169,7 +162,9 @@ export function TimeClockPanel({
if (!running) return; if (!running) return;
const tick = () => const tick = () =>
setElapsed(Math.floor((Date.now() - new Date(running.startedAt).getTime()) / 1000)); setElapsed(
Math.floor((Date.now() - new Date(running.startedAt).getTime()) / 1000),
);
tick(); tick();
intervalRef.current = setInterval(tick, 1000); intervalRef.current = setInterval(tick, 1000);
return () => { return () => {
@@ -223,7 +218,10 @@ export function TimeClockPanel({
window.location.assign(`/dashboard/invoices/${data.invoice!.id}`), window.location.assign(`/dashboard/invoices/${data.invoice!.id}`),
}, },
}); });
} else if (data.outcome === "saved_no_invoice" || data.outcome === "saved_no_client") { } else if (
data.outcome === "saved_no_invoice" ||
data.outcome === "saved_no_client"
) {
toast.warning("Time saved", { description: message }); toast.warning("Time saved", { description: message });
} else { } else {
toast.success(message); toast.success(message);
@@ -289,7 +287,7 @@ export function TimeClockPanel({
if (mode === "pick" && !pickedStart) { if (mode === "pick" && !pickedStart) {
const now = new Date(); const now = new Date();
now.setMinutes(now.getMinutes() - now.getTimezoneOffset()); now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
setPickedStart(now.toISOString().slice(0, 16)); setPickedStart(toLocalDateTimeInputValue(now));
} }
} }
@@ -314,7 +312,9 @@ export function TimeClockPanel({
if (runningLoading) { if (runningLoading) {
return ( return (
<Card> <Card>
<CardContent className="text-muted-foreground p-6 text-sm">Loading timer</CardContent> <CardContent className="text-muted-foreground p-6 text-sm">
Loading timer
</CardContent>
</Card> </Card>
); );
} }
@@ -330,7 +330,12 @@ export function TimeClockPanel({
); );
return ( return (
<div className={cn("flex flex-col gap-6", !compact && "xl:grid xl:grid-cols-[minmax(0,1fr)_22rem]")}> <div
className={cn(
"flex flex-col gap-6",
!compact && "xl:grid xl:grid-cols-[minmax(0,1fr)_22rem]",
)}
>
<Card className="min-w-0 overflow-hidden"> <Card className="min-w-0 overflow-hidden">
<CardHeader className="gap-3"> <CardHeader className="gap-3">
<div className="flex flex-wrap items-start justify-between gap-4"> <div className="flex flex-wrap items-start justify-between gap-4">
@@ -338,7 +343,7 @@ export function TimeClockPanel({
<p className="text-muted-foreground text-xs font-semibold tracking-wide uppercase"> <p className="text-muted-foreground text-xs font-semibold tracking-wide uppercase">
{running ? "In progress" : "Ready to start"} {running ? "In progress" : "Ready to start"}
</p> </p>
<CardTitle className="text-pretty text-2xl"> <CardTitle className="text-2xl text-pretty">
{running ? runningTitle : "What are you working on?"} {running ? runningTitle : "What are you working on?"}
</CardTitle> </CardTitle>
<CardDescription className="text-pretty"> <CardDescription className="text-pretty">
@@ -403,10 +408,16 @@ export function TimeClockPanel({
<Label htmlFor="clock-client">Client</Label> <Label htmlFor="clock-client">Client</Label>
<Select <Select
value={activeClientId || "__none__"} value={activeClientId || "__none__"}
onValueChange={(value) => handleClientChange(value === "__none__" ? "" : value)} onValueChange={(value) =>
handleClientChange(value === "__none__" ? "" : value)
}
disabled={Boolean(running && updateRunning.isPending)} disabled={Boolean(running && updateRunning.isPending)}
> >
<SelectTrigger id="clock-client" aria-label="Client" className="h-11 w-full"> <SelectTrigger
id="clock-client"
aria-label="Client"
className="h-11 w-full"
>
<SelectValue placeholder="Select client…" /> <SelectValue placeholder="Select client…" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -427,16 +438,28 @@ export function TimeClockPanel({
<Select <Select
value={activeInvoiceId || "__none__"} value={activeInvoiceId || "__none__"}
onValueChange={handleInvoiceChange} onValueChange={handleInvoiceChange}
disabled={!activeClientId || Boolean(running && updateRunning.isPending)} disabled={
!activeClientId || Boolean(running && updateRunning.isPending)
}
>
<SelectTrigger
id="clock-invoice"
aria-label="Invoice"
className="h-11 w-full"
> >
<SelectTrigger id="clock-invoice" aria-label="Invoice" className="h-11 w-full">
<SelectValue <SelectValue
placeholder={activeClientId ? "Select invoice…" : "Choose a client first"} placeholder={
activeClientId
? "Select invoice…"
: "Choose a client first"
}
/> />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
<SelectItem value="__none__">Entry only no invoice</SelectItem> <SelectItem value="__none__">
Entry only no invoice
</SelectItem>
{billableInvoices?.map((invoice) => ( {billableInvoices?.map((invoice) => (
<SelectItem key={invoice.id} value={invoice.id}> <SelectItem key={invoice.id} value={invoice.id}>
{invoiceLabel(invoice)} {invoiceLabel(invoice)}
@@ -507,7 +530,9 @@ export function TimeClockPanel({
step={0.01} step={0.01}
placeholder="0.00" placeholder="0.00"
/> />
{clientId && rate === 0 && selectedClient?.defaultHourlyRate ? ( {clientId &&
rate === 0 &&
selectedClient?.defaultHourlyRate ? (
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
{`Uses ${selectedClient.defaultHourlyRate}/hr from ${selectedClient.name}.`} {`Uses ${selectedClient.defaultHourlyRate}/hr from ${selectedClient.name}.`}
</p> </p>
@@ -543,7 +568,9 @@ export function TimeClockPanel({
autoComplete="off" autoComplete="off"
type="datetime-local" type="datetime-local"
value={pickedStart} value={pickedStart}
onChange={(event) => setPickedStart(event.target.value)} onChange={(event) =>
setPickedStart(event.target.value)
}
/> />
) : null} ) : null}
{startMode === "ago" ? ( {startMode === "ago" ? (
@@ -556,10 +583,14 @@ export function TimeClockPanel({
min={1} min={1}
max={1440} max={1440}
value={minutesAgo} value={minutesAgo}
onChange={(event) => setMinutesAgo(event.target.value)} onChange={(event) =>
setMinutesAgo(event.target.value)
}
className="w-24" className="w-24"
/> />
<span className="text-muted-foreground text-sm">minutes ago</span> <span className="text-muted-foreground text-sm">
minutes ago
</span>
</div> </div>
) : null} ) : null}
</div> </div>
@@ -618,19 +649,25 @@ export function TimeClockPanel({
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{completedToday.length > 0 ? ( {completedToday.length > 0 ? (
<TimeEntryList entries={completedToday} onEdit={(entry) => setEditEntryId(entry.id)} /> <TimeEntryList
entries={completedToday}
onEdit={(entry) => setEditEntryId(entry.id)}
/>
) : ( ) : (
<div className="flex flex-col gap-1 py-6 text-center"> <div className="flex flex-col gap-1 py-6 text-center">
<p className="font-medium">No time logged yet</p> <p className="font-medium">No time logged yet</p>
<p className="text-muted-foreground text-sm text-pretty"> <p className="text-muted-foreground text-sm text-pretty">
Start your first timer or open history to add an entry manually. Start your first timer or open history to add an entry
manually.
</p> </p>
</div> </div>
)} )}
</CardContent> </CardContent>
<CardFooter> <CardFooter>
<Button variant="outline" className="w-full" asChild> <Button variant="outline" className="w-full" asChild>
<Link href="/dashboard/time-clock/entries">View time history</Link> <Link href="/dashboard/time-clock/entries">
View time history
</Link>
</Button> </Button>
</CardFooter> </CardFooter>
</Card> </Card>
@@ -14,6 +14,7 @@ import type { TimeEntryListItem } from "~/lib/time-entry-display";
export function TimeEntriesHistory() { export function TimeEntriesHistory() {
const { data: entries, isLoading } = api.timeEntries.getAll.useQuery(); const { data: entries, isLoading } = api.timeEntries.getAll.useQuery();
const { data: profile } = api.settings.getProfile.useQuery();
const [editEntryId, setEditEntryId] = useState<string | null>(null); const [editEntryId, setEditEntryId] = useState<string | null>(null);
const completedEntries = useMemo( const completedEntries = useMemo(
@@ -22,8 +23,8 @@ export function TimeEntriesHistory() {
); );
const grouped = useMemo( const grouped = useMemo(
() => groupEntriesByDate(completedEntries), () => groupEntriesByDate(completedEntries, profile?.timeZone),
[completedEntries], [completedEntries, profile?.timeZone],
); );
if (isLoading) { if (isLoading) {
@@ -23,15 +23,10 @@ import {
import { toast } from "sonner"; import { toast } from "sonner";
import { invoiceLabel } from "~/lib/time-entry-display"; import { invoiceLabel } from "~/lib/time-entry-display";
import type { RouterOutputs } from "~/trpc/react"; import type { RouterOutputs } from "~/trpc/react";
import { toLocalDateTimeInputValue } from "@beenvoice/domain/time-zone";
type TimeEntry = RouterOutputs["timeEntries"]["getById"]; type TimeEntry = RouterOutputs["timeEntries"]["getById"];
function toDatetimeLocalValue(value: Date | string) {
const start = new Date(value);
start.setMinutes(start.getMinutes() - start.getTimezoneOffset());
return start.toISOString().slice(0, 16);
}
export type TimeEntryEditDialogProps = { export type TimeEntryEditDialogProps = {
entryId: string | null; entryId: string | null;
open: boolean; open: boolean;
@@ -56,9 +51,11 @@ function TimeEntryEditForm({
const [clientId, setClientId] = useState(entry.clientId ?? ""); const [clientId, setClientId] = useState(entry.clientId ?? "");
const [invoiceId, setInvoiceId] = useState(entry.invoiceId ?? ""); const [invoiceId, setInvoiceId] = useState(entry.invoiceId ?? "");
const [rate, setRate] = useState(entry.rate ?? 0); const [rate, setRate] = useState(entry.rate ?? 0);
const [startedAt, setStartedAt] = useState(() => toDatetimeLocalValue(entry.startedAt)); const [startedAt, setStartedAt] = useState(() =>
toLocalDateTimeInputValue(new Date(entry.startedAt)),
);
const [endedAt, setEndedAt] = useState(() => const [endedAt, setEndedAt] = useState(() =>
entry.endedAt ? toDatetimeLocalValue(entry.endedAt) : "", entry.endedAt ? toLocalDateTimeInputValue(new Date(entry.endedAt)) : "",
); );
const { data: billableInvoices } = api.invoices.getBillable.useQuery( const { data: billableInvoices } = api.invoices.getBillable.useQuery(
@@ -70,7 +67,8 @@ function TimeEntryEditForm({
if (!startedAt || !endedAt) return null; if (!startedAt || !endedAt) return null;
const start = new Date(startedAt); const start = new Date(startedAt);
const end = new Date(endedAt); const end = new Date(endedAt);
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) return null; if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()))
return null;
return Math.max(0, (end.getTime() - start.getTime()) / 3_600_000); return Math.max(0, (end.getTime() - start.getTime()) / 3_600_000);
}, [endedAt, startedAt]); }, [endedAt, startedAt]);
@@ -228,7 +226,11 @@ function TimeEntryEditForm({
<Button type="button" variant="outline" onClick={onClose}> <Button type="button" variant="outline" onClick={onClose}>
Cancel Cancel
</Button> </Button>
<Button type="button" onClick={handleSave} disabled={updateEntry.isPending}> <Button
type="button"
onClick={handleSave}
disabled={updateEntry.isPending}
>
Save Save
</Button> </Button>
</div> </div>
@@ -246,7 +248,9 @@ export function TimeEntryEditDialog({
{ id: entryId ?? "" }, { id: entryId ?? "" },
{ enabled: Boolean(entryId) && open }, { enabled: Boolean(entryId) && open },
); );
const { data: clients = [] } = api.clients.getAll.useQuery(undefined, { enabled: open }); const { data: clients = [] } = api.clients.getAll.useQuery(undefined, {
enabled: open,
});
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
+19 -8
View File
@@ -13,6 +13,11 @@ import {
PopoverTrigger, PopoverTrigger,
} from "~/components/ui/popover"; } from "~/components/ui/popover";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import {
calendarDateFromLocalDate,
calendarDateToLocalDate,
formatCalendarDate,
} from "@beenvoice/domain/time-zone";
const DATE_FORMAT_OPTIONS: Intl.DateTimeFormatOptions = { const DATE_FORMAT_OPTIONS: Intl.DateTimeFormatOptions = {
day: "2-digit", day: "2-digit",
@@ -25,7 +30,7 @@ function formatDate(date: Date | undefined) {
return ""; return "";
} }
return date.toLocaleDateString("en-US", DATE_FORMAT_OPTIONS); return formatCalendarDate(date, DATE_FORMAT_OPTIONS);
} }
// Longest month name in en-US long format (September 30, 2026). // Longest month name in en-US long format (September 30, 2026).
@@ -54,7 +59,9 @@ export function DatePicker({
}: DatePickerProps) { }: DatePickerProps) {
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
const [value, setValue] = React.useState(formatDate(date)); const [value, setValue] = React.useState(formatDate(date));
const [month, setMonth] = React.useState<Date | undefined>(date); const [month, setMonth] = React.useState<Date | undefined>(
date ? calendarDateToLocalDate(date) : undefined,
);
const sizeClasses = { const sizeClasses = {
sm: "h-9 text-xs", sm: "h-9 text-xs",
@@ -67,7 +74,7 @@ export function DatePicker({
React.useEffect(() => { React.useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Keep text input and calendar month synchronized with the controlled date prop. // eslint-disable-next-line react-hooks/set-state-in-effect -- Keep text input and calendar month synchronized with the controlled date prop.
setValue(formatDate(date)); setValue(formatDate(date));
setMonth(date); setMonth(date ? calendarDateToLocalDate(date) : undefined);
}, [date]); }, [date]);
return ( return (
@@ -81,7 +88,7 @@ export function DatePicker({
<span <span
aria-hidden aria-hidden
className={cn( className={cn(
"invisible block whitespace-nowrap px-3 pr-10", "invisible block px-3 pr-10 whitespace-nowrap",
sizeClasses[size], sizeClasses[size],
inputClassName, inputClassName,
)} )}
@@ -102,7 +109,8 @@ export function DatePicker({
setValue(e.target.value); setValue(e.target.value);
const parsedDate = parseDate(e.target.value); const parsedDate = parseDate(e.target.value);
if (parsedDate) { if (parsedDate) {
onDateChange(parsedDate); const calendarDate = calendarDateFromLocalDate(parsedDate);
onDateChange(calendarDate);
setMonth(parsedDate); setMonth(parsedDate);
} }
}} }}
@@ -130,13 +138,16 @@ export function DatePicker({
> >
<Calendar <Calendar
mode="single" mode="single"
selected={date} selected={date ? calendarDateToLocalDate(date) : undefined}
captionLayout="dropdown" captionLayout="dropdown"
month={month} month={month}
onMonthChange={setMonth} onMonthChange={setMonth}
onSelect={(selectedDate) => { onSelect={(selectedDate) => {
onDateChange(selectedDate); const calendarDate = selectedDate
setValue(formatDate(selectedDate)); ? calendarDateFromLocalDate(selectedDate)
: undefined;
onDateChange(calendarDate);
setValue(formatDate(calendarDate));
setOpen(false); setOpen(false);
}} }}
/> />
+12
View File
@@ -27,8 +27,14 @@ export const env = createEnv({
: z.string().optional(), : z.string().optional(),
DATABASE_URL: z.string().url(), DATABASE_URL: z.string().url(),
BETTER_AUTH_URL: z.string().url().optional(), BETTER_AUTH_URL: z.string().url().optional(),
EMAIL_PROVIDER: z.enum(["mailpit", "smtp", "resend"]).default("resend"),
EMAIL_FROM: z.string().min(1).optional(),
SMTP_HOST: z.string().min(1).optional(),
SMTP_PORT: z.string().regex(/^\d+$/).optional(),
SMTP_SECURE: optionalEnvBoolean(),
RESEND_API_KEY: z.string().min(1).optional(), RESEND_API_KEY: z.string().min(1).optional(),
RESEND_DOMAIN: z.string().optional(), RESEND_DOMAIN: z.string().optional(),
RESEND_FROM: z.string().min(1).optional(),
NODE_ENV: z NODE_ENV: z
.enum(["development", "test", "production"]) .enum(["development", "test", "production"])
.default("development"), .default("development"),
@@ -76,8 +82,14 @@ export const env = createEnv({
AUTH_SECRET: process.env.AUTH_SECRET, AUTH_SECRET: process.env.AUTH_SECRET,
DATABASE_URL: process.env.DATABASE_URL, DATABASE_URL: process.env.DATABASE_URL,
BETTER_AUTH_URL: process.env.BETTER_AUTH_URL, BETTER_AUTH_URL: process.env.BETTER_AUTH_URL,
EMAIL_PROVIDER: process.env.EMAIL_PROVIDER,
EMAIL_FROM: process.env.EMAIL_FROM,
SMTP_HOST: process.env.SMTP_HOST,
SMTP_PORT: process.env.SMTP_PORT,
SMTP_SECURE: process.env.SMTP_SECURE,
RESEND_API_KEY: process.env.RESEND_API_KEY, RESEND_API_KEY: process.env.RESEND_API_KEY,
RESEND_DOMAIN: process.env.RESEND_DOMAIN, RESEND_DOMAIN: process.env.RESEND_DOMAIN,
RESEND_FROM: process.env.RESEND_FROM,
NODE_ENV: process.env.NODE_ENV, NODE_ENV: process.env.NODE_ENV,
DB_DISABLE_SSL: process.env.DB_DISABLE_SSL, DB_DISABLE_SSL: process.env.DB_DISABLE_SSL,
DISABLE_SIGNUPS: process.env.DISABLE_SIGNUPS, DISABLE_SIGNUPS: process.env.DISABLE_SIGNUPS,
+28
View File
@@ -0,0 +1,28 @@
export {
brandAssetKinds,
brandAssetThemes,
getBrandAssetFieldNames,
hasBusinessBrandAsset,
resolveBusinessBrandAsset,
} from "@beenvoice/domain/brand-assets";
export type {
BrandAssetKind,
BrandAssetTheme,
BusinessBrandAssets,
} from "@beenvoice/domain/brand-assets";
import type {
BrandAssetKind,
BrandAssetTheme,
} from "@beenvoice/domain/brand-assets";
export function businessBrandAssetPath(
businessId: string,
kind: BrandAssetKind = "logo",
theme: BrandAssetTheme = "light",
format?: "png",
): string {
const params = new URLSearchParams({ kind, theme });
if (format) params.set("format", format);
return `/api/business-logo/${businessId}?${params.toString()}`;
}
+3 -3
View File
@@ -1,3 +1,5 @@
import { addCalendarDays } from "@beenvoice/domain/time-zone";
/** Default invoice number format (matches web/mobile create forms). */ /** Default invoice number format (matches web/mobile create forms). */
export function generateInvoiceNumber(now = new Date()): string { export function generateInvoiceNumber(now = new Date()): string {
const date = [ const date = [
@@ -10,7 +12,5 @@ export function generateInvoiceNumber(now = new Date()): string {
} }
export function defaultDueDate(issueDate: Date): Date { export function defaultDueDate(issueDate: Date): Date {
const due = new Date(issueDate); return addCalendarDays(issueDate, 30);
due.setDate(due.getDate() + 30);
return due;
} }
@@ -1,18 +1,34 @@
import { getAppUrl } from "~/lib/app-url"; import { getAppUrl } from "~/lib/app-url";
import { formatCalendarDate } from "@beenvoice/domain/time-zone";
import {
businessBrandAssetPath,
resolveBusinessBrandAsset,
type BusinessBrandAssets,
} from "~/lib/business-branding";
// Most email clients render <img src> fine for PNG/JPEG but are inconsistent // Most email clients render <img src> fine for PNG/JPEG but are inconsistent
// with SVG (Outlook and several webmail clients strip or refuse it), so // with SVG (Outlook and several webmail clients strip or refuse it), so
// non-raster logos are requested through the same on-the-fly PNG // non-raster logos are requested through the same on-the-fly PNG
// rasterization the PDF export uses. // rasterization the PDF export uses.
function resolveEmailLogoUrl( function resolveEmailLogoUrl(
business: { id?: string; logoStorageKey?: string | null; logoMimeType?: string | null } | null | undefined, business:
| ({
id?: string;
} & BusinessBrandAssets)
| null
| undefined,
baseUrl: string, baseUrl: string,
): string | null { ): string | null {
if (!business?.id || !business.logoStorageKey) return null; if (!business?.id) return null;
const needsRaster = const asset = resolveBusinessBrandAsset(business, "logo", "light");
business.logoMimeType != null && if (!asset) return null;
!["image/png", "image/jpeg"].includes(business.logoMimeType); const needsRaster = !["image/png", "image/jpeg"].includes(asset.mimeType);
const path = `/api/business-logo/${business.id}${needsRaster ? "?format=png" : ""}`; const path = businessBrandAssetPath(
business.id,
"logo",
"light",
needsRaster ? "png" : undefined,
);
return `${baseUrl.replace(/\/$/, "")}${path}`; return `${baseUrl.replace(/\/$/, "")}${path}`;
} }
@@ -29,7 +45,8 @@ interface InvoiceEmailTemplateProps {
name: string; name: string;
email: string | null; email: string | null;
}; };
business?: { business?:
| ({
id?: string; id?: string;
name: string; name: string;
nickname?: string | null; nickname?: string | null;
@@ -41,9 +58,8 @@ interface InvoiceEmailTemplateProps {
state?: string | null; state?: string | null;
postalCode?: string | null; postalCode?: string | null;
country?: string | null; country?: string | null;
logoStorageKey?: string | null; } & BusinessBrandAssets)
logoMimeType?: string | null; | null;
} | null;
items: Array<{ items: Array<{
date: Date; date: Date;
description: string; description: string;
@@ -57,6 +73,7 @@ interface InvoiceEmailTemplateProps {
userName?: string; userName?: string;
userEmail?: string; userEmail?: string;
baseUrl?: string; baseUrl?: string;
timeZone?: string;
} }
export function generateInvoiceEmailTemplate({ export function generateInvoiceEmailTemplate({
@@ -66,13 +83,14 @@ export function generateInvoiceEmailTemplate({
userName, userName,
userEmail, userEmail,
baseUrl = getAppUrl(), baseUrl = getAppUrl(),
timeZone = "America/New_York",
}: InvoiceEmailTemplateProps): { html: string; text: string } { }: InvoiceEmailTemplateProps): { html: string; text: string } {
const formatDate = (date: Date) => { const formatDate = (date: Date) => {
return new Intl.DateTimeFormat("en-US", { return formatCalendarDate(date, {
year: "numeric", year: "numeric",
month: "long", month: "long",
day: "numeric", day: "numeric",
}).format(new Date(date)); });
}; };
const formatCurrency = (amount: number) => { const formatCurrency = (amount: number) => {
@@ -83,7 +101,13 @@ export function generateInvoiceEmailTemplate({
}; };
const getTimeOfDayGreeting = () => { const getTimeOfDayGreeting = () => {
const hour = new Date().getHours(); const hour = Number(
new Intl.DateTimeFormat("en-US", {
timeZone,
hour: "numeric",
hourCycle: "h23",
}).format(new Date()),
);
if (hour < 12) return "Good morning"; if (hour < 12) return "Good morning";
if (hour < 17) return "Good afternoon"; if (hour < 17) return "Good afternoon";
return "Good evening"; return "Good evening";
@@ -1,3 +1,8 @@
import {
formatCalendarDate,
getEffectiveInvoiceStatus,
} from "@beenvoice/domain";
interface ReminderEmailTemplateProps { interface ReminderEmailTemplateProps {
invoice: { invoice: {
invoiceNumber: string; invoiceNumber: string;
@@ -15,6 +20,7 @@ interface ReminderEmailTemplateProps {
customMessage?: string; customMessage?: string;
userName?: string; userName?: string;
userEmail?: string; userEmail?: string;
timeZone?: string;
} }
export function generateReminderEmailTemplate({ export function generateReminderEmailTemplate({
@@ -22,11 +28,18 @@ export function generateReminderEmailTemplate({
customMessage, customMessage,
userName, userName,
userEmail, userEmail,
}: ReminderEmailTemplateProps): { html: string; text: string; subject: string } { timeZone = "America/New_York",
}: ReminderEmailTemplateProps): {
html: string;
text: string;
subject: string;
} {
const formatDate = (date: Date) => const formatDate = (date: Date) =>
new Intl.DateTimeFormat("en-US", { year: "numeric", month: "long", day: "numeric" }).format( formatCalendarDate(date, {
new Date(date), year: "numeric",
); month: "long",
day: "numeric",
});
const formatCurrency = (amount: number) => const formatCurrency = (amount: number) =>
new Intl.NumberFormat("en-US", { new Intl.NumberFormat("en-US", {
@@ -34,14 +47,14 @@ export function generateReminderEmailTemplate({
currency: invoice.currency ?? "USD", currency: invoice.currency ?? "USD",
}).format(amount); }).format(amount);
const senderName = const senderName = invoice.business?.name
invoice.business?.name
? invoice.business.nickname ? invoice.business.nickname
? `${invoice.business.name} (${invoice.business.nickname})` ? `${invoice.business.name} (${invoice.business.nickname})`
: invoice.business.name : invoice.business.name
: userName ?? "Your service provider"; : (userName ?? "Your service provider");
const isOverdue = new Date(invoice.dueDate) < new Date(); const isOverdue =
getEffectiveInvoiceStatus("sent", invoice.dueDate, timeZone) === "overdue";
const subject = `Payment Reminder: Invoice ${invoice.invoiceNumber}${formatCurrency(invoice.totalAmount)}`; const subject = `Payment Reminder: Invoice ${invoice.invoiceNumber}${formatCurrency(invoice.totalAmount)}`;
+18 -10
View File
@@ -1,3 +1,8 @@
import {
addCalendarDays,
calendarDateFromLocalDate,
} from "@beenvoice/domain/time-zone";
export type ImportFormat = "csv" | "json"; export type ImportFormat = "csv" | "json";
export interface ImportItem { export interface ImportItem {
@@ -86,8 +91,9 @@ export function parseFlexibleDate(dateStr: string): Date | undefined {
// ISO date (YYYY-MM-DD) // ISO date (YYYY-MM-DD)
const isoMatch = /^(\d{4})-(\d{2})-(\d{2})/.exec(trimmed); const isoMatch = /^(\d{4})-(\d{2})-(\d{2})/.exec(trimmed);
if (isoMatch) { if (isoMatch) {
const d = new Date(trimmed); const key = `${isoMatch[1]}-${isoMatch[2]}-${isoMatch[3]}`;
if (!isNaN(d.getTime())) return d; const d = new Date(`${key}T12:00:00.000Z`);
if (!isNaN(d.getTime()) && d.toISOString().slice(0, 10) === key) return d;
} }
// M/DD/YY or M/DD/YYYY // M/DD/YY or M/DD/YYYY
@@ -98,11 +104,11 @@ export function parseFlexibleDate(dateStr: string): Date | undefined {
let year = parseInt(slashParts[2] ?? "2000", 10); let year = parseInt(slashParts[2] ?? "2000", 10);
if (year < 100) year += 2000; if (year < 100) year += 2000;
const d = new Date(year, month, day); const d = new Date(year, month, day);
if (!isNaN(d.getTime())) return d; if (!isNaN(d.getTime())) return calendarDateFromLocalDate(d);
} }
const d = new Date(trimmed); const d = new Date(trimmed);
if (!isNaN(d.getTime())) return d; if (!isNaN(d.getTime())) return calendarDateFromLocalDate(d);
return undefined; return undefined;
} }
@@ -128,13 +134,11 @@ function deriveIssueDate(items: ImportItem[], fallback?: Date): Date {
if (itemDates.length > 0) { if (itemDates.length > 0) {
return new Date(Math.max(...itemDates.map((d) => d.getTime()))); return new Date(Math.max(...itemDates.map((d) => d.getTime())));
} }
return fallback ?? new Date(); return fallback ?? calendarDateFromLocalDate(new Date());
} }
function defaultDueDate(issueDate: Date): Date { function defaultDueDate(issueDate: Date): Date {
const due = new Date(issueDate); return addCalendarDays(issueDate, 30);
due.setDate(due.getDate() + 30);
return due;
} }
export function parseInvoiceCSV( export function parseInvoiceCSV(
@@ -262,7 +266,9 @@ function normalizeJsonInvoice(raw: JsonInvoice, index: number): ImportInvoice {
const rate = item.rate ?? 0; const rate = item.rate ?? 0;
if (!description || description === "Imported item") { if (!description || description === "Imported item") {
errors.push(`Invoice "${name}" item ${itemIdx + 1}: description required`); errors.push(
`Invoice "${name}" item ${itemIdx + 1}: description required`,
);
} }
if (quantity <= 0) { if (quantity <= 0) {
errors.push( errors.push(
@@ -356,7 +362,9 @@ export function parseInvoiceJSON(jsonText: string): ImportInvoice[] {
{ {
name: "JSON Import", name: "JSON Import",
items: [], items: [],
errors: ['No invoices found (expected { "invoices": [...] } or an array)'], errors: [
'No invoices found (expected { "invoices": [...] } or an array)',
],
}, },
]; ];
} }
+6 -3
View File
@@ -13,22 +13,25 @@ import type {
export function getEffectiveInvoiceStatus( export function getEffectiveInvoiceStatus(
storedStatus: StoredInvoiceStatus, storedStatus: StoredInvoiceStatus,
dueDate: Date | string, dueDate: Date | string,
timeZone?: string,
): EffectiveInvoiceStatus { ): EffectiveInvoiceStatus {
return getSharedEffectiveInvoiceStatus(storedStatus, dueDate); return getSharedEffectiveInvoiceStatus(storedStatus, dueDate, timeZone);
} }
export function isInvoiceOverdue( export function isInvoiceOverdue(
storedStatus: StoredInvoiceStatus, storedStatus: StoredInvoiceStatus,
dueDate: Date | string, dueDate: Date | string,
timeZone?: string,
): boolean { ): boolean {
return isSharedInvoiceOverdue(storedStatus, dueDate); return isSharedInvoiceOverdue(storedStatus, dueDate, timeZone);
} }
export function getDaysPastDue( export function getDaysPastDue(
storedStatus: StoredInvoiceStatus, storedStatus: StoredInvoiceStatus,
dueDate: Date | string, dueDate: Date | string,
timeZone?: string,
): number { ): number {
return getSharedDaysPastDue(storedStatus, dueDate); return getSharedDaysPastDue(storedStatus, dueDate, timeZone);
} }
export const statusConfig = { export const statusConfig = {
+4 -15
View File
@@ -1,7 +1,5 @@
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { Resend } from "resend"; import { sendEmail } from "@beenvoice/email";
import { env } from "~/env";
import { APP_EMAIL_DOMAIN } from "~/lib/app-email";
import { getAppUrl } from "~/lib/app-url"; import { getAppUrl } from "~/lib/app-url";
import { generatePasswordResetEmailTemplate } from "~/lib/email-templates"; import { generatePasswordResetEmailTemplate } from "~/lib/email-templates";
import { import {
@@ -10,6 +8,7 @@ import {
} from "~/lib/reset-token"; } from "~/lib/reset-token";
import { db } from "~/server/db"; import { db } from "~/server/db";
import { users } from "~/server/db/schema"; import { users } from "~/server/db/schema";
import { resolveEmailSender } from "~/server/services/email-sender";
export type PasswordResetResult = { export type PasswordResetResult = {
success: boolean; success: boolean;
@@ -22,15 +21,7 @@ export async function sendPasswordResetEmail(input: {
userName?: string; userName?: string;
resetToken: string; resetToken: string;
}): Promise<PasswordResetResult> { }): Promise<PasswordResetResult> {
if (!env.RESEND_API_KEY) {
console.warn(
"Password reset requested, but RESEND_API_KEY is not configured.",
);
return { success: true, emailSent: false, userEmail: input.userEmail };
}
try { try {
const resend = new Resend(env.RESEND_API_KEY);
const resetUrl = `${getAppUrl()}/auth/reset-password?token=${input.resetToken}`; const resetUrl = `${getAppUrl()}/auth/reset-password?token=${input.resetToken}`;
const emailTemplate = generatePasswordResetEmailTemplate({ const emailTemplate = generatePasswordResetEmailTemplate({
userEmail: input.userEmail, userEmail: input.userEmail,
@@ -39,10 +30,8 @@ export async function sendPasswordResetEmail(input: {
resetUrl, resetUrl,
expiryHours: 1, expiryHours: 1,
}); });
const fromDomain = env.RESEND_DOMAIN ?? APP_EMAIL_DOMAIN; await sendEmail({
...resolveEmailSender(null, "beenvoice"),
await resend.emails.send({
from: `beenvoice <noreply@${fromDomain}>`,
to: input.userEmail, to: input.userEmail,
subject: emailTemplate.subject, subject: emailTemplate.subject,
html: emailTemplate.html, html: emailTemplate.html,
+24 -20
View File
@@ -9,9 +9,8 @@ import {
type Styles, type Styles,
} from "@react-pdf/renderer"; } from "@react-pdf/renderer";
import { saveAs } from "file-saver"; import { saveAs } from "file-saver";
import { import { formatCalendarDate } from "@beenvoice/domain/time-zone";
isFixedLineItem, import { isFixedLineItem } from "~/lib/invoice-line-item";
} from "~/lib/invoice-line-item";
import React from "react"; import React from "react";
import { import {
type PdfFontFamily, type PdfFontFamily,
@@ -19,6 +18,11 @@ import {
pdfFontCacheKey, pdfFontCacheKey,
resolvePdfFonts, resolvePdfFonts,
} from "~/lib/pdf-fonts"; } from "~/lib/pdf-fonts";
import {
businessBrandAssetPath,
resolveBusinessBrandAsset,
type BusinessBrandAssets,
} from "~/lib/business-branding";
// Fallback download function for better browser compatibility // Fallback download function for better browser compatibility
function downloadBlob(blob: Blob, filename: string): void { function downloadBlob(blob: Blob, filename: string): void {
@@ -74,7 +78,8 @@ export interface InvoiceData {
taxRate: number; taxRate: number;
currency?: string | null; currency?: string | null;
notes?: string | null; notes?: string | null;
business?: { business?:
| ({
id?: string; id?: string;
name: string; name: string;
nickname?: string | null; nickname?: string | null;
@@ -88,10 +93,9 @@ export interface InvoiceData {
country?: string | null; country?: string | null;
website?: string | null; website?: string | null;
taxId?: string | null; taxId?: string | null;
logoStorageKey?: string | null;
logoMimeType?: string | null;
hideNameWithLogo?: boolean | null; hideNameWithLogo?: boolean | null;
} | null; } & BusinessBrandAssets)
| null;
client?: { client?: {
name: string; name: string;
email?: string | null; email?: string | null;
@@ -136,10 +140,7 @@ function resolvePDFSettings(settings?: PDFGenerationSettings) {
return { ...defaultPDFSettings, ...settings }; return { ...defaultPDFSettings, ...settings };
} }
function mapLegacyPdfFont( function mapLegacyPdfFont(fontFamily: string, fonts: ResolvedPdfFonts): string {
fontFamily: string,
fonts: ResolvedPdfFonts,
): string {
switch (fontFamily) { switch (fontFamily) {
case "Helvetica-Bold": case "Helvetica-Bold":
return fonts.bold; return fonts.bold;
@@ -177,9 +178,7 @@ type PdfStyleBundle = {
styles: typeof baseStyles; styles: typeof baseStyles;
minimalStyles: typeof baseMinimalStyles; minimalStyles: typeof baseMinimalStyles;
fonts: ResolvedPdfFonts; fonts: ResolvedPdfFonts;
getStatusStyle: ( getStatusStyle: (status: string) => Array<Record<string, string | number>>;
status: string,
) => Array<Record<string, string | number>>;
}; };
const pdfStyleCache = new Map<string, PdfStyleBundle>(); const pdfStyleCache = new Map<string, PdfStyleBundle>();
@@ -816,7 +815,7 @@ const formatCurrency = (amount: number, currency = "USD") => {
}; };
const formatDate = (date: Date) => { const formatDate = (date: Date) => {
return new Date(date).toLocaleDateString("en-US", { return formatCalendarDate(date, {
year: "numeric", year: "numeric",
month: "2-digit", month: "2-digit",
day: "2-digit", day: "2-digit",
@@ -854,12 +853,17 @@ function resolveBusinessLogoSrc(
business: InvoiceData["business"], business: InvoiceData["business"],
baseUrlOverride?: string, baseUrlOverride?: string,
): string | null { ): string | null {
if (!business?.id || !business.logoStorageKey) return null; if (!business?.id) return null;
const asset = resolveBusinessBrandAsset(business, "logo", "light");
if (!asset) return null;
const needsRaster = const needsRaster = !["image/png", "image/jpeg"].includes(asset.mimeType);
business.logoMimeType != null && const path = businessBrandAssetPath(
!["image/png", "image/jpeg"].includes(business.logoMimeType); business.id,
const path = `/api/business-logo/${business.id}${needsRaster ? "?format=png" : ""}`; "logo",
"light",
needsRaster ? "png" : undefined,
);
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
return `${window.location.origin}${path}`; return `${window.location.origin}${path}`;
+9 -2
View File
@@ -1,3 +1,8 @@
import {
DEFAULT_TIME_ZONE,
getZonedDateTimeParts,
} from "@beenvoice/domain/time-zone";
export function invoiceLabel(inv: { export function invoiceLabel(inv: {
invoicePrefix: string | null; invoicePrefix: string | null;
invoiceNumber: string; invoiceNumber: string;
@@ -37,12 +42,13 @@ export type TimeEntryListItem = {
export function groupEntriesByDate<T extends { startedAt: Date }>( export function groupEntriesByDate<T extends { startedAt: Date }>(
entries: T[], entries: T[],
timeZone = DEFAULT_TIME_ZONE,
): { dateKey: string; label: string; entries: T[] }[] { ): { dateKey: string; label: string; entries: T[] }[] {
const groups = new Map<string, T[]>(); const groups = new Map<string, T[]>();
for (const entry of entries) { for (const entry of entries) {
const d = new Date(entry.startedAt); const parts = getZonedDateTimeParts(entry.startedAt, timeZone);
const dateKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; const dateKey = `${parts.year}-${String(parts.month).padStart(2, "0")}-${String(parts.day).padStart(2, "0")}`;
const existing = groups.get(dateKey); const existing = groups.get(dateKey);
if (existing) { if (existing) {
existing.push(entry); existing.push(entry);
@@ -58,6 +64,7 @@ export function groupEntriesByDate<T extends { startedAt: Date }>(
year: "numeric", year: "numeric",
month: "long", month: "long",
day: "numeric", day: "numeric",
timeZone,
}); });
return { dateKey, label, entries: groupEntries }; return { dateKey, label, entries: groupEntries };
}); });
+1
View File
@@ -23,6 +23,7 @@ export function proxy(request: NextRequest) {
"/api/mcp", "/api/mcp",
"/api/i", "/api/i",
"/api/business-logo", "/api/business-logo",
"/api/health",
]; ];
// Allow API routes to pass through // Allow API routes to pass through
@@ -1,7 +1,8 @@
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import type { db } from "~/server/db"; import type { db } from "~/server/db";
import { invoiceItems, invoices, timeEntries } from "~/server/db/schema"; import { invoiceItems, invoices, timeEntries, users } from "~/server/db/schema";
import { resolveBillingDescription } from "~/lib/time-clock"; import { resolveBillingDescription } from "~/lib/time-clock";
import { calendarDateFromInstant } from "@beenvoice/domain/time-zone";
type Db = typeof db; type Db = typeof db;
@@ -110,6 +111,10 @@ export async function syncLinkedInvoiceItem(
const rate = entry.rate ?? 0; const rate = entry.rate ?? 0;
const amount = hours * rate; const amount = hours * rate;
const description = resolveBillingDescription(entry.description ?? ""); const description = resolveBillingDescription(entry.description ?? "");
const owner = await database.query.users.findFirst({
where: eq(users.id, linked.invoice.createdById),
columns: { timeZone: true },
});
await database await database
.update(invoiceItems) .update(invoiceItems)
@@ -118,7 +123,10 @@ export async function syncLinkedInvoiceItem(
hours, hours,
rate, rate,
amount, amount,
date: entry.endedAt ?? entry.startedAt, date: calendarDateFromInstant(
entry.endedAt ?? entry.startedAt,
owner?.timeZone ?? "America/New_York",
),
}) })
.where(eq(invoiceItems.id, linked.id)); .where(eq(invoiceItems.id, linked.id));
@@ -136,7 +144,10 @@ export async function syncLinkedInvoiceItem(
.where(eq(invoices.id, linked.invoiceId)); .where(eq(invoices.id, linked.invoiceId));
} }
export async function removeLinkedInvoiceItem(database: Db, timeEntryId: string) { export async function removeLinkedInvoiceItem(
database: Db,
timeEntryId: string,
) {
const linked = await findLinkedInvoiceItem(database, timeEntryId); const linked = await findLinkedInvoiceItem(database, timeEntryId);
if (!linked?.invoice) return; if (!linked?.invoice) return;
@@ -190,6 +201,10 @@ export async function relinkTimeEntryToInvoice(
}); });
if (!invoice) return null; if (!invoice) return null;
const owner = await database.query.users.findFirst({
where: eq(users.id, userId),
columns: { timeZone: true },
});
return insertInvoiceLineForTimeEntry(database, { return insertInvoiceLineForTimeEntry(database, {
invoice, invoice,
@@ -197,6 +212,9 @@ export async function relinkTimeEntryToInvoice(
description: resolveBillingDescription(entry.description ?? ""), description: resolveBillingDescription(entry.description ?? ""),
hours: entry.hours, hours: entry.hours,
rate: entry.rate ?? 0, rate: entry.rate ?? 0,
date: entry.endedAt, date: calendarDateFromInstant(
entry.endedAt,
owner?.timeZone ?? "America/New_York",
),
}); });
} }
+2
View File
@@ -11,6 +11,7 @@ import { recurringInvoicesRouter } from "~/server/api/routers/recurring-invoices
import { apiKeysRouter } from "~/server/api/routers/apiKeys"; import { apiKeysRouter } from "~/server/api/routers/apiKeys";
import { timeEntriesRouter } from "~/server/api/routers/time-entries"; import { timeEntriesRouter } from "~/server/api/routers/time-entries";
import { adminRouter } from "~/server/api/routers/admin"; import { adminRouter } from "~/server/api/routers/admin";
import { notificationsRouter } from "~/server/api/routers/notifications";
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc"; import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
export const appRouter = createTRPCRouter({ export const appRouter = createTRPCRouter({
@@ -27,6 +28,7 @@ export const appRouter = createTRPCRouter({
apiKeys: apiKeysRouter, apiKeys: apiKeysRouter,
timeEntries: timeEntriesRouter, timeEntries: timeEntriesRouter,
admin: adminRouter, admin: adminRouter,
notifications: notificationsRouter,
}); });
// export type definition of API // export type definition of API
+59 -16
View File
@@ -7,6 +7,11 @@ import { invoices } from "~/server/db/schema";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { deleteObject, putObject } from "~/lib/object-storage"; import { deleteObject, putObject } from "~/lib/object-storage";
import { sanitizeSvg } from "~/lib/svg-sanitize"; import { sanitizeSvg } from "~/lib/svg-sanitize";
import {
brandAssetKinds,
brandAssetThemes,
getBrandAssetFieldNames,
} from "~/lib/business-branding";
const MAX_LOGO_BYTES = 5 * 1024 * 1024; const MAX_LOGO_BYTES = 5 * 1024 * 1024;
const allowedLogoMimeTypes = new Set([ const allowedLogoMimeTypes = new Set([
@@ -287,6 +292,7 @@ export const businessesRouter = createTRPCRouter({
"Business not found or you don't have permission to delete it", "Business not found or you don't have permission to delete it",
); );
} }
const existingBusiness = business[0];
// Check if this business has any invoices // Check if this business has any invoices
const invoiceCount = await ctx.db const invoiceCount = await ctx.db
@@ -300,6 +306,13 @@ export const businessesRouter = createTRPCRouter({
); );
} }
const storageKeys = brandAssetKinds.flatMap((kind) =>
brandAssetThemes.flatMap((theme) => {
const [storageField] = getBrandAssetFieldNames(kind, theme);
const storageKey = existingBusiness[storageField];
return storageKey ? [storageKey] : [];
}),
);
await ctx.db await ctx.db
.delete(businesses) .delete(businesses)
.where( .where(
@@ -309,6 +322,12 @@ export const businessesRouter = createTRPCRouter({
), ),
); );
await Promise.all(
[...new Set(storageKeys)].map((key) =>
deleteObject(key).catch(() => undefined),
),
);
return { success: true }; return { success: true };
}), }),
@@ -432,7 +451,8 @@ export const businessesRouter = createTRPCRouter({
}; };
}), }),
// Upload (or replace) a business logo, shown on invoices // Upload (or replace) a business brand asset. Kind/theme default to the
// legacy combined/light logo so older clients remain compatible.
uploadLogo: protectedProcedure uploadLogo: protectedProcedure
.input( .input(
z.object({ z.object({
@@ -440,6 +460,8 @@ export const businessesRouter = createTRPCRouter({
filename: z.string().min(1).max(255), filename: z.string().min(1).max(255),
mimeType: z.string().min(1).max(100), mimeType: z.string().min(1).max(100),
data: z.string().min(1), data: z.string().min(1),
kind: z.enum(brandAssetKinds).default("logo"),
theme: z.enum(brandAssetThemes).default("light"),
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
@@ -457,7 +479,8 @@ export const businessesRouter = createTRPCRouter({
if (!business) { if (!business) {
throw new TRPCError({ throw new TRPCError({
code: "NOT_FOUND", code: "NOT_FOUND",
message: "Business not found or you don't have permission to update it", message:
"Business not found or you don't have permission to update it",
}); });
} }
@@ -465,7 +488,7 @@ export const businessesRouter = createTRPCRouter({
if (!allowedLogoMimeTypes.has(mimeType)) { if (!allowedLogoMimeTypes.has(mimeType)) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
message: "Logo must be a PNG, JPEG, WebP, or SVG image", message: "Brand asset must be a PNG, JPEG, WebP, or SVG image",
}); });
} }
@@ -473,7 +496,7 @@ export const businessesRouter = createTRPCRouter({
if (!body.length || body.length > MAX_LOGO_BYTES) { if (!body.length || body.length > MAX_LOGO_BYTES) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
message: "Logo must be between 1 byte and 5MB", message: "Brand asset must be between 1 byte and 5MB",
}); });
} }
@@ -482,20 +505,24 @@ export const businessesRouter = createTRPCRouter({
} }
const safeName = input.filename.replace(/[^a-zA-Z0-9._-]/g, "_"); const safeName = input.filename.replace(/[^a-zA-Z0-9._-]/g, "_");
const storageKey = `logos/${ctx.session.user.id}/${business.id}/${crypto.randomUUID()}-${safeName}`; const storageKey = `logos/${ctx.session.user.id}/${business.id}/${input.kind}/${input.theme}/${crypto.randomUUID()}-${safeName}`;
const previousStorageKey = business.logoStorageKey; const [storageField, mimeField] = getBrandAssetFieldNames(
input.kind,
input.theme,
);
const previousStorageKey = business[storageField];
try { try {
await putObject(storageKey, body, mimeType); await putObject(storageKey, body, mimeType);
} catch (error) { } catch (error) {
console.error("[businesses.uploadLogo] Failed to store logo", { console.error("[businesses.uploadLogo] Failed to store brand asset", {
backendError: error, backendError: error,
businessId: business.id, businessId: business.id,
}); });
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",
message: message:
"Logo storage is unavailable. Check the object-storage service and try again.", "Brand asset storage is unavailable. Check the object-storage service and try again.",
cause: error, cause: error,
}); });
} }
@@ -503,8 +530,8 @@ export const businessesRouter = createTRPCRouter({
const [updatedBusiness] = await ctx.db const [updatedBusiness] = await ctx.db
.update(businesses) .update(businesses)
.set({ .set({
logoStorageKey: storageKey, [storageField]: storageKey,
logoMimeType: mimeType, [mimeField]: mimeType,
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(businesses.id, business.id)) .where(eq(businesses.id, business.id))
@@ -517,9 +544,15 @@ export const businessesRouter = createTRPCRouter({
return updatedBusiness; return updatedBusiness;
}), }),
// Remove a business logo // Remove one business brand asset. Defaults preserve older clients.
removeLogo: protectedProcedure removeLogo: protectedProcedure
.input(z.object({ id: z.string() })) .input(
z.object({
id: z.string(),
kind: z.enum(brandAssetKinds).default("logo"),
theme: z.enum(brandAssetThemes).default("light"),
}),
)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const [business] = await ctx.db const [business] = await ctx.db
.select() .select()
@@ -535,17 +568,27 @@ export const businessesRouter = createTRPCRouter({
if (!business) { if (!business) {
throw new TRPCError({ throw new TRPCError({
code: "NOT_FOUND", code: "NOT_FOUND",
message: "Business not found or you don't have permission to update it", message:
"Business not found or you don't have permission to update it",
}); });
} }
if (business.logoStorageKey) { const [storageField, mimeField] = getBrandAssetFieldNames(
await deleteObject(business.logoStorageKey).catch(() => undefined); input.kind,
input.theme,
);
const storageKey = business[storageField];
if (storageKey) {
await deleteObject(storageKey).catch(() => undefined);
} }
const [updatedBusiness] = await ctx.db const [updatedBusiness] = await ctx.db
.update(businesses) .update(businesses)
.set({ logoStorageKey: null, logoMimeType: null, updatedAt: new Date() }) .set({
[storageField]: null,
[mimeField]: null,
updatedAt: new Date(),
})
.where(eq(businesses.id, business.id)) .where(eq(businesses.id, business.id))
.returning(); .returning();
+41 -19
View File
@@ -1,7 +1,11 @@
import { and, desc, eq, gte, lt } from "drizzle-orm"; import { and, desc, eq, gte, lt } from "drizzle-orm";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status"; import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
import { clients, invoices } from "~/server/db/schema"; import { clients, invoices, users } from "~/server/db/schema";
import {
formatCalendarDate,
getZonedDateTimeParts,
} from "@beenvoice/domain/time-zone";
import type { StoredInvoiceStatus } from "~/types/invoice"; import type { StoredInvoiceStatus } from "~/types/invoice";
type LiteInvoice = { type LiteInvoice = {
@@ -12,20 +16,28 @@ type LiteInvoice = {
issueDate: Date; issueDate: Date;
}; };
function buildRevenueMonthKeys(now: Date, count: number) { function buildRevenueMonthKeys(now: Date, count: number, timeZone: string) {
const current = getZonedDateTimeParts(now, timeZone);
const keys: string[] = []; const keys: string[] = [];
for (let i = count - 1; i >= 0; i--) { for (let i = count - 1; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1); const d = new Date(Date.UTC(current.year, current.month - 1 - i, 1));
keys.push( keys.push(
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`, `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`,
); );
} }
return keys; return keys;
} }
function aggregateDashboardMetrics(userInvoices: LiteInvoice[], now: Date) { function aggregateDashboardMetrics(
const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1); userInvoices: LiteInvoice[],
const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1); now: Date,
timeZone: string,
) {
const current = getZonedDateTimeParts(now, timeZone);
const currentMonthStart = new Date(
Date.UTC(current.year, current.month - 1, 1),
);
const lastMonthStart = new Date(Date.UTC(current.year, current.month - 2, 1));
let totalRevenue = 0; let totalRevenue = 0;
let pendingAmount = 0; let pendingAmount = 0;
@@ -34,7 +46,7 @@ function aggregateDashboardMetrics(userInvoices: LiteInvoice[], now: Date) {
let lastMonthRevenue = 0; let lastMonthRevenue = 0;
const revenueByMonth = Object.fromEntries( const revenueByMonth = Object.fromEntries(
buildRevenueMonthKeys(now, 6).map((key) => [key, 0]), buildRevenueMonthKeys(now, 6, timeZone).map((key) => [key, 0]),
) as Record<string, number>; ) as Record<string, number>;
const statusTotals: Record< const statusTotals: Record<
@@ -58,6 +70,7 @@ function aggregateDashboardMetrics(userInvoices: LiteInvoice[], now: Date) {
const effectiveStatus = getEffectiveInvoiceStatus( const effectiveStatus = getEffectiveInvoiceStatus(
inv.status as StoredInvoiceStatus, inv.status as StoredInvoiceStatus,
inv.dueDate, inv.dueDate,
timeZone,
); );
const amount = inv.totalAmount; const amount = inv.totalAmount;
const issueDate = new Date(inv.issueDate); const issueDate = new Date(inv.issueDate);
@@ -67,14 +80,11 @@ function aggregateDashboardMetrics(userInvoices: LiteInvoice[], now: Date) {
if (issueDate >= currentMonthStart) { if (issueDate >= currentMonthStart) {
currentMonthRevenue += amount; currentMonthRevenue += amount;
} else if ( } else if (issueDate >= lastMonthStart && issueDate < currentMonthStart) {
issueDate >= lastMonthStart &&
issueDate < currentMonthStart
) {
lastMonthRevenue += amount; lastMonthRevenue += amount;
} }
const revenueKey = `${issueDate.getFullYear()}-${String(issueDate.getMonth() + 1).padStart(2, "0")}`; const revenueKey = `${issueDate.getUTCFullYear()}-${String(issueDate.getUTCMonth() + 1).padStart(2, "0")}`;
const monthRevenue = revenueByMonth[revenueKey]; const monthRevenue = revenueByMonth[revenueKey];
if (monthRevenue !== undefined) { if (monthRevenue !== undefined) {
revenueByMonth[revenueKey] = monthRevenue + amount; revenueByMonth[revenueKey] = monthRevenue + amount;
@@ -95,7 +105,7 @@ function aggregateDashboardMetrics(userInvoices: LiteInvoice[], now: Date) {
statusTotals[effectiveStatus].count += 1; statusTotals[effectiveStatus].count += 1;
statusTotals[effectiveStatus].value += amount; statusTotals[effectiveStatus].value += amount;
const monthKey = `${issueDate.getFullYear()}-${String(issueDate.getMonth() + 1).padStart(2, "0")}`; const monthKey = `${issueDate.getUTCFullYear()}-${String(issueDate.getUTCMonth() + 1).padStart(2, "0")}`;
monthlyTotals[monthKey] ??= { monthlyTotals[monthKey] ??= {
month: monthKey, month: monthKey,
totalInvoices: 0, totalInvoices: 0,
@@ -126,7 +136,7 @@ function aggregateDashboardMetrics(userInvoices: LiteInvoice[], now: Date) {
.map(([month, revenue]) => ({ .map(([month, revenue]) => ({
month, month,
revenue, revenue,
monthLabel: new Date(month + "-01").toLocaleDateString("en-US", { monthLabel: formatCalendarDate(month + "-01", {
month: "short", month: "short",
year: "2-digit", year: "2-digit",
}), }),
@@ -143,7 +153,7 @@ function aggregateDashboardMetrics(userInvoices: LiteInvoice[], now: Date) {
.slice(-6) .slice(-6)
.map((item) => ({ .map((item) => ({
...item, ...item,
monthLabel: new Date(item.month + "-01").toLocaleDateString("en-US", { monthLabel: formatCalendarDate(item.month + "-01", {
month: "short", month: "short",
year: "2-digit", year: "2-digit",
}), }),
@@ -167,6 +177,12 @@ export const dashboardRouter = createTRPCRouter({
getStats: protectedProcedure.query(async ({ ctx }) => { getStats: protectedProcedure.query(async ({ ctx }) => {
const userId = ctx.session.user.id; const userId = ctx.session.user.id;
const now = new Date(); const now = new Date();
const user = await ctx.db.query.users.findFirst({
where: eq(users.id, userId),
columns: { timeZone: true },
});
const timeZone = user?.timeZone ?? "America/New_York";
const current = getZonedDateTimeParts(now, timeZone);
const [ const [
userInvoices, userInvoices,
@@ -203,8 +219,14 @@ export const dashboardRouter = createTRPCRouter({
ctx.db.query.invoices.findMany({ ctx.db.query.invoices.findMany({
where: and( where: and(
eq(invoices.createdById, userId), eq(invoices.createdById, userId),
gte(invoices.issueDate, new Date(now.getFullYear(), now.getMonth(), 1)), gte(
lt(invoices.issueDate, new Date(now.getFullYear(), now.getMonth() + 1, 1)), invoices.issueDate,
new Date(Date.UTC(current.year, current.month - 1, 1)),
),
lt(
invoices.issueDate,
new Date(Date.UTC(current.year, current.month, 1)),
),
), ),
orderBy: [ orderBy: [
desc(invoices.issueDate), desc(invoices.issueDate),
@@ -249,7 +271,7 @@ export const dashboardRouter = createTRPCRouter({
}), }),
]); ]);
const metrics = aggregateDashboardMetrics(userInvoices, now); const metrics = aggregateDashboardMetrics(userInvoices, now, timeZone);
return { return {
...metrics, ...metrics,
+160 -310
View File
@@ -1,346 +1,196 @@
import { isValidTimeZone } from "@beenvoice/domain/time-zone";
import { and, eq } from "drizzle-orm";
import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
import { Resend } from "resend";
import { createTRPCRouter, sessionProcedure } from "~/server/api/trpc";
import { invoices, platformSettings } from "~/server/db/schema";
import { eq } from "drizzle-orm";
import { env } from "~/env";
import { NOREPLY_EMAIL } from "~/lib/app-email";
import { getRequestOrigin } from "~/lib/app-url"; import { getRequestOrigin } from "~/lib/app-url";
import { generateInvoicePDFBlob } from "~/lib/pdf-export"; import { createTRPCRouter, sessionProcedure } from "~/server/api/trpc";
import { generateInvoiceEmailTemplate } from "~/lib/email-templates"; import { backgroundJobs, invoices } from "~/server/db/schema";
import { enqueueJob, jobTypes } from "~/server/jobs/queue";
import { deliverInvoiceEmail } from "~/server/services/send-invoice-email";
function plainTextToHtml(value: string) { const emailOptionsSchema = z.object({
return value invoiceId: z.string().min(1),
.replace(/&/g, "&amp;") customSubject: z.string().max(500).optional(),
.replace(/</g, "&lt;") customContent: z.string().max(50_000).optional(),
.replace(/>/g, "&gt;") customMessage: z.string().max(10_000).optional(),
.replace(/"/g, "&quot;") useHtml: z.boolean().default(false),
.replace(/'/g, "&#39;") ccEmails: z.string().max(2_000).optional(),
.replace(/\n/g, "<br>"); bccEmails: z.string().max(2_000).optional(),
} });
function normalizeEmailNoteHtml(value: string) {
const visibleText = value
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/p>/gi, "\n")
.replace(/<[^>]*>/g, "")
.replace(/&nbsp;|\u00a0/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.trim();
return visibleText ? value.trim() : "";
}
export const emailRouter = createTRPCRouter({ export const emailRouter = createTRPCRouter({
sendInvoice: sessionProcedure sendInvoice: sessionProcedure
.input(emailOptionsSchema)
.mutation(async ({ ctx, input }) =>
deliverInvoiceEmail({
...input,
actorUserId: ctx.session.user.id,
baseUrl: getRequestOrigin(ctx.headers),
}),
),
scheduleInvoice: sessionProcedure
.input( .input(
z.object({ emailOptionsSchema.extend({
invoiceId: z.string(), scheduledAt: z.coerce.date(),
customSubject: z.string().optional(), timeZone: z
customContent: z.string().optional(), .string()
customMessage: z.string().optional(), .min(1)
useHtml: z.boolean().default(false), .max(100)
ccEmails: z.string().optional(), .refine(isValidTimeZone, "Invalid time zone"),
bccEmails: z.string().optional(),
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
// Fetch invoice with relations
const invoice = await ctx.db.query.invoices.findFirst({ const invoice = await ctx.db.query.invoices.findFirst({
where: eq(invoices.id, input.invoiceId), where: and(
with: { eq(invoices.id, input.invoiceId),
client: true, eq(invoices.createdById, ctx.session.user.id),
business: true, ),
items: true, with: { client: true, items: true },
}, });
if (!invoice)
throw new TRPCError({
code: "NOT_FOUND",
message: "Invoice not found",
}); });
if (!invoice) {
throw new Error("Invoice not found");
}
// Check if invoice belongs to the current user
if (invoice.createdById !== ctx.session.user.id) {
throw new Error("Unauthorized");
}
if (!invoice.client?.email) { if (!invoice.client?.email) {
throw new Error("Client has no email address"); throw new TRPCError({
code: "BAD_REQUEST",
message: "Client has no email address",
});
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(invoice.client.email)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Invalid client email address format",
});
} }
if (!invoice.items.length) { if (!invoice.items.length) {
throw new Error("Add at least one line item before sending this invoice"); throw new TRPCError({
} code: "BAD_REQUEST",
message: "Add at least one line item before sending this invoice",
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(invoice.client.email)) {
throw new Error("Invalid client email address format");
}
// Generate PDF for attachment
let pdfBuffer: Buffer;
try {
const settings = await ctx.db.query.platformSettings.findFirst({
where: eq(platformSettings.id, "global"),
}); });
const pdfBlob = await generateInvoicePDFBlob(
invoice,
{
pdfTemplate: settings?.pdfTemplate as
| "classic"
| "minimal"
| undefined,
pdfAccentColor: settings?.pdfAccentColor,
pdfFontFamily: settings?.pdfFontFamily as
| "sans"
| "serif"
| "mono"
| undefined,
pdfNumericFontFamily: settings?.pdfNumericFontFamily as
| "sans"
| "serif"
| "mono"
| undefined,
pdfFooterText: settings?.pdfFooterText,
pdfShowLogo: settings?.pdfShowLogo,
pdfShowPageNumbers: settings?.pdfShowPageNumbers,
},
{ logoBaseUrl: getRequestOrigin(ctx.headers) },
);
pdfBuffer = Buffer.from(await pdfBlob.arrayBuffer());
// Validate PDF was generated successfully
if (pdfBuffer.length === 0) {
throw new Error("Generated PDF is empty");
} }
} catch (pdfError) { if (input.scheduledAt.getTime() < Date.now() + 60_000) {
console.error("PDF generation error:", pdfError); throw new TRPCError({
// Re-throw the original error with more context code: "BAD_REQUEST",
if (pdfError instanceof Error) { message: "Choose a send time at least one minute in the future",
throw new Error(
`Failed to generate invoice PDF for attachment: ${pdfError.message}`,
);
}
throw new Error("Failed to generate invoice PDF for attachment");
}
// Create email content
const subject =
input.customSubject ??
`Invoice ${invoice.invoiceNumber} from ${invoice.business ? `${invoice.business.name}${invoice.business.nickname ? ` (${invoice.business.nickname})` : ""}` : "Your Business"}`;
const userName =
invoice.business?.emailFromName ??
invoice.business?.name ??
ctx.session.user?.name ??
"Your Name";
const userEmail =
invoice.business?.email ?? ctx.session.user?.email ?? "";
const customMessage =
input.customMessage !== undefined
? normalizeEmailNoteHtml(input.customMessage)
: invoice.emailMessage
? plainTextToHtml(invoice.emailMessage)
: undefined;
// Generate branded email template
const emailTemplate = generateInvoiceEmailTemplate({
invoice: {
invoiceNumber: invoice.invoiceNumber,
issueDate: invoice.issueDate,
dueDate: invoice.dueDate,
status: invoice.status,
totalAmount: invoice.totalAmount,
taxRate: invoice.taxRate,
currency: invoice.currency,
client: {
name: invoice.client.name,
email: invoice.client.email,
},
business: invoice.business,
items: invoice.items,
},
customContent: input.customContent,
customMessage,
userName,
userEmail,
baseUrl: getRequestOrigin(ctx.headers),
}); });
// Determine Resend instance and email configuration to use
let resendInstance: Resend;
let fromEmail: string;
// Check if business has custom Resend configuration
if (invoice.business?.resendApiKey && invoice.business?.resendDomain) {
// Use business's custom Resend setup
resendInstance = new Resend(invoice.business.resendApiKey);
const fromName =
invoice.business.emailFromName ??
(invoice.business.nickname
? `${invoice.business.name} (${invoice.business.nickname})`
: invoice.business.name) ??
userName;
fromEmail = `${fromName} <noreply@${invoice.business.resendDomain}>`;
} else if (env.RESEND_API_KEY && env.RESEND_DOMAIN) {
// Use system Resend configuration
resendInstance = new Resend(env.RESEND_API_KEY);
fromEmail = `noreply@${env.RESEND_DOMAIN}`;
} else if (env.RESEND_API_KEY) {
resendInstance = new Resend(env.RESEND_API_KEY);
fromEmail = invoice.business?.email ?? NOREPLY_EMAIL;
} else {
throw new Error(
"Email delivery is not configured. Add a Resend API key globally or on this business.",
);
} }
// Prepare CC and BCC lists
const ccEmails: string[] = [];
const bccEmails: string[] = [];
// Parse CC emails from input
if (input.ccEmails) {
const ccList = input.ccEmails
.split(",")
.map((email) => email.trim())
.filter((email) => email);
for (const email of ccList) {
if (emailRegex.test(email)) {
ccEmails.push(email);
}
}
}
// Parse BCC emails from input
if (input.bccEmails) {
const bccList = input.bccEmails
.split(",")
.map((email) => email.trim())
.filter((email) => email);
for (const email of bccList) {
if (emailRegex.test(email)) {
bccEmails.push(email);
}
}
}
// Include business email in CC if it exists and is different from sender
if (invoice.business?.email && invoice.business.email !== fromEmail) {
// Validate business email format before adding to CC
if (emailRegex.test(invoice.business.email)) {
ccEmails.push(invoice.business.email);
}
}
// Send email with Resend
let emailResult;
try {
// Send HTML email with plain text fallback
emailResult = await resendInstance.emails.send({
from: fromEmail,
to: [invoice.client?.email ?? ""],
cc: ccEmails.length > 0 ? ccEmails : undefined,
bcc: bccEmails.length > 0 ? bccEmails : undefined,
subject: subject,
html: emailTemplate.html,
text: emailTemplate.text,
headers: {
"X-Priority": "3",
"X-MSMail-Priority": "Normal",
"X-Mailer": "beenvoice",
"MIME-Version": "1.0",
},
attachments: [
{
filename: `invoice-${invoice.invoiceNumber}.pdf`,
content: pdfBuffer,
},
],
});
} catch {
throw new Error(
"Email service is currently unavailable. Please try again later.",
);
}
// Enhanced error checking
if (emailResult.error) {
const errorMsg = emailResult.error.message?.toLowerCase() ?? "";
// Provide more specific error messages based on error type
if ( if (
errorMsg.includes("invalid email") || invoice.scheduledSendJobId &&
errorMsg.includes("invalid recipient") invoice.scheduledSendStatus === "processing"
) { ) {
throw new Error("Invalid recipient email address"); throw new TRPCError({
} else if ( code: "CONFLICT",
errorMsg.includes("domain") || message: "This invoice is already being sent",
errorMsg.includes("not verified") });
) {
throw new Error(
"Email domain not verified. Please configure your Resend domain in business settings.",
);
} else if (
errorMsg.includes("rate limit") ||
errorMsg.includes("too many")
) {
throw new Error("Rate limit exceeded. Please try again later.");
} else if (
errorMsg.includes("api key") ||
errorMsg.includes("unauthorized")
) {
throw new Error(
"Email service configuration error. Please check your Resend API key.",
);
} else if (
errorMsg.includes("attachment") ||
errorMsg.includes("file size")
) {
throw new Error("Invoice PDF is too large to send via email.");
} else {
throw new Error(
`Email delivery failed: ${emailResult.error.message ?? "Unknown error"}`,
);
} }
const idempotencyKey = `${jobTypes.sendInvoice}:${invoice.id}:${input.scheduledAt.toISOString()}:${crypto.randomUUID()}`;
const job = await enqueueJob({
type: jobTypes.sendInvoice,
idempotencyKey,
runAt: input.scheduledAt,
payload: {
invoiceId: invoice.id,
actorUserId: ctx.session.user.id,
customSubject: input.customSubject,
customContent: input.customContent,
customMessage: input.customMessage,
useHtml: input.useHtml,
ccEmails: input.ccEmails,
bccEmails: input.bccEmails,
timeZone: input.timeZone,
},
});
if (!job) {
throw new TRPCError({
code: "CONFLICT",
message: "Unable to schedule invoice",
});
} }
if (!emailResult.data?.id) { await ctx.db.transaction(async (tx) => {
throw new Error( if (
"Email was not sent successfully - no delivery ID received", invoice.scheduledSendJobId &&
invoice.scheduledSendStatus === "pending"
) {
await tx
.update(backgroundJobs)
.set({ status: "cancelled", updatedAt: new Date() })
.where(
and(
eq(backgroundJobs.id, invoice.scheduledSendJobId),
eq(backgroundJobs.status, "pending"),
),
); );
} }
await tx
// Update invoice status to "sent" if it was draft
if (invoice.status === "draft") {
try {
await ctx.db
.update(invoices) .update(invoices)
.set({ .set({
status: "sent", scheduledSendAt: input.scheduledAt,
scheduledSendTimeZone: input.timeZone,
scheduledSendJobId: job.id,
scheduledSendStatus: "pending",
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(invoices.id, input.invoiceId)); .where(eq(invoices.id, invoice.id));
} catch { });
// Don't throw here - email was sent successfully, status update is secondary
}
}
return { return {
success: true, success: true,
emailId: emailResult.data.id, jobId: job.id,
message: `Invoice sent successfully to ${invoice.client?.email ?? "client"}${ccEmails.length > 0 ? ` (CC: ${ccEmails.join(", ")})` : ""}${bccEmails.length > 0 ? ` (BCC: ${bccEmails.join(", ")})` : ""}`, scheduledAt: input.scheduledAt.toISOString(),
deliveryDetails: { timeZone: input.timeZone,
to: invoice.client?.email ?? "",
cc: ccEmails,
bcc: bccEmails,
sentAt: new Date().toISOString(),
},
}; };
}), }),
cancelScheduledInvoice: sessionProcedure
.input(z.object({ invoiceId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const invoice = await ctx.db.query.invoices.findFirst({
where: and(
eq(invoices.id, input.invoiceId),
eq(invoices.createdById, ctx.session.user.id),
),
});
if (!invoice)
throw new TRPCError({
code: "NOT_FOUND",
message: "Invoice not found",
});
if (
!invoice.scheduledSendJobId ||
invoice.scheduledSendStatus !== "pending"
) {
throw new TRPCError({
code: "CONFLICT",
message: "This scheduled send can no longer be cancelled",
});
}
const cancelled = await ctx.db
.update(backgroundJobs)
.set({ status: "cancelled", updatedAt: new Date() })
.where(
and(
eq(backgroundJobs.id, invoice.scheduledSendJobId),
eq(backgroundJobs.status, "pending"),
),
)
.returning({ id: backgroundJobs.id });
if (!cancelled.length) {
throw new TRPCError({
code: "CONFLICT",
message: "The worker has already started sending this invoice",
});
}
await ctx.db
.update(invoices)
.set({ scheduledSendStatus: "cancelled", updatedAt: new Date() })
.where(eq(invoices.id, invoice.id));
return { success: true };
}),
}); });
+107 -42
View File
@@ -1,5 +1,6 @@
import { z } from "zod"; import { z } from "zod";
import { and, desc, eq, inArray } from "drizzle-orm"; import { and, desc, eq, inArray } from "drizzle-orm";
import { sendEmail } from "@beenvoice/email";
import { import {
createTRPCRouter, createTRPCRouter,
protectedProcedure, protectedProcedure,
@@ -12,17 +13,18 @@ import {
clients, clients,
businesses, businesses,
platformSettings, platformSettings,
users,
backgroundJobs,
} from "~/server/db/schema"; } from "~/server/db/schema";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { calculateLineItemAmount } from "~/lib/invoice-line-item"; import { calculateLineItemAmount } from "~/lib/invoice-line-item";
import { generateInvoicePDFBlob } from "~/lib/pdf-export"; import { generateInvoicePDFBlob } from "~/lib/pdf-export";
import { getRequestOrigin } from "~/lib/app-url"; import { getRequestOrigin } from "~/lib/app-url";
import { defaultDueDate, generateInvoiceNumber } from "~/lib/draft-invoice"; import { defaultDueDate, generateInvoiceNumber } from "~/lib/draft-invoice";
import { Resend } from "resend";
import { env } from "~/env";
import { NOREPLY_EMAIL } from "~/lib/app-email";
import { generateReminderEmailTemplate } from "~/lib/email-templates/reminder-email"; import { generateReminderEmailTemplate } from "~/lib/email-templates/reminder-email";
import type { db } from "~/server/db"; import type { db } from "~/server/db";
import { resolveEmailSender } from "~/server/services/email-sender";
import { jobTypes } from "~/server/jobs/queue";
type InvoiceRouterContext = { type InvoiceRouterContext = {
db: typeof db; db: typeof db;
@@ -204,9 +206,7 @@ function findExistingClient(
if (clientRef.email?.trim()) { if (clientRef.email?.trim()) {
const email = clientRef.email.trim().toLowerCase(); const email = clientRef.email.trim().toLowerCase();
const byEmail = userClients.find( const byEmail = userClients.find((c) => c.email?.toLowerCase() === email);
(c) => c.email?.toLowerCase() === email,
);
if (byEmail) return byEmail; if (byEmail) return byEmail;
} }
@@ -235,20 +235,24 @@ function deriveIssueDateFromItems(
export const invoicesRouter = createTRPCRouter({ export const invoicesRouter = createTRPCRouter({
getAll: protectedProcedure getAll: protectedProcedure
.input( .input(
z.object({ z
.object({
status: z.enum(["draft", "sent", "paid"]).optional(), status: z.enum(["draft", "sent", "paid"]).optional(),
clientId: z.string().optional(), clientId: z.string().optional(),
}).optional(), })
.optional(),
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
try { try {
const conditions = [eq(invoices.createdById, ctx.session.user.id)]; const conditions = [eq(invoices.createdById, ctx.session.user.id)];
if (input?.status) conditions.push(eq(invoices.status, input.status)); if (input?.status) conditions.push(eq(invoices.status, input.status));
if (input?.clientId) conditions.push(eq(invoices.clientId, input.clientId)); if (input?.clientId)
conditions.push(eq(invoices.clientId, input.clientId));
return await ctx.db.query.invoices.findMany({ return await ctx.db.query.invoices.findMany({
where: and(...conditions), where: and(...conditions),
with: { with: {
createdBy: { columns: { timeZone: true } },
business: true, business: true,
client: true, client: true,
items: { items: {
@@ -282,7 +286,8 @@ export const invoicesRouter = createTRPCRouter({
eq(invoices.createdById, ctx.session.user.id), eq(invoices.createdById, ctx.session.user.id),
eq(invoices.status, "draft"), eq(invoices.status, "draft"),
]; ];
if (input?.clientId) conditions.push(eq(invoices.clientId, input.clientId)); if (input?.clientId)
conditions.push(eq(invoices.clientId, input.clientId));
return ctx.db.query.invoices.findMany({ return ctx.db.query.invoices.findMany({
where: and(...conditions), where: and(...conditions),
@@ -346,6 +351,7 @@ export const invoicesRouter = createTRPCRouter({
const currentInvoice = await ctx.db.query.invoices.findFirst({ const currentInvoice = await ctx.db.query.invoices.findFirst({
where: eq(invoices.createdById, ctx.session.user.id), where: eq(invoices.createdById, ctx.session.user.id),
with: { with: {
createdBy: { columns: { timeZone: true } },
business: true, business: true,
client: true, client: true,
items: { items: {
@@ -385,6 +391,7 @@ export const invoicesRouter = createTRPCRouter({
const invoice = await ctx.db.query.invoices.findFirst({ const invoice = await ctx.db.query.invoices.findFirst({
where: eq(invoices.id, input.id), where: eq(invoices.id, input.id),
with: { with: {
createdBy: { columns: { timeZone: true } },
business: true, business: true,
client: true, client: true,
items: { items: {
@@ -451,10 +458,16 @@ export const invoicesRouter = createTRPCRouter({
); );
return await ctx.db.transaction(async (tx) => { return await ctx.db.transaction(async (tx) => {
const invoiceId = crypto.randomUUID();
const sendReminderJobId = cleanInvoiceData.sendReminderAt
? crypto.randomUUID()
: null;
const [invoice] = await tx const [invoice] = await tx
.insert(invoices) .insert(invoices)
.values({ .values({
id: invoiceId,
...cleanInvoiceData, ...cleanInvoiceData,
sendReminderJobId,
totalAmount, totalAmount,
createdById: ctx.session.user.id, createdById: ctx.session.user.id,
}) })
@@ -478,6 +491,17 @@ export const invoicesRouter = createTRPCRouter({
); );
} }
if (sendReminderJobId && cleanInvoiceData.sendReminderAt) {
await tx.insert(backgroundJobs).values({
id: sendReminderJobId,
type: jobTypes.sendInvoiceReminder,
payload: { invoiceId, userId: ctx.session.user.id },
idempotencyKey: `${jobTypes.sendInvoiceReminder}:${invoiceId}:${cleanInvoiceData.sendReminderAt.toISOString()}:${sendReminderJobId}`,
runAt: cleanInvoiceData.sendReminderAt,
maxAttempts: 5,
});
}
return invoice; return invoice;
}); });
} catch (error) { } catch (error) {
@@ -560,6 +584,34 @@ export const invoicesRouter = createTRPCRouter({
} }
await ctx.db.transaction(async (tx) => { await ctx.db.transaction(async (tx) => {
let sendReminderJobId = existingInvoice.sendReminderJobId;
if (cleanInvoiceData.sendReminderAt !== undefined) {
if (existingInvoice.sendReminderJobId) {
await tx
.update(backgroundJobs)
.set({ status: "cancelled", updatedAt: new Date() })
.where(
eq(backgroundJobs.id, existingInvoice.sendReminderJobId),
);
}
sendReminderJobId = cleanInvoiceData.sendReminderAt
? crypto.randomUUID()
: null;
if (sendReminderJobId && cleanInvoiceData.sendReminderAt) {
await tx.insert(backgroundJobs).values({
id: sendReminderJobId,
type: jobTypes.sendInvoiceReminder,
payload: { invoiceId: id, userId: ctx.session.user.id },
idempotencyKey: `${jobTypes.sendInvoiceReminder}:${id}:${cleanInvoiceData.sendReminderAt.toISOString()}:${sendReminderJobId}`,
runAt: cleanInvoiceData.sendReminderAt,
maxAttempts: 5,
});
}
}
const reminderJobPatch =
cleanInvoiceData.sendReminderAt !== undefined
? { sendReminderJobId }
: {};
if (items) { if (items) {
const totalAmount = calculateInvoiceTotal( const totalAmount = calculateInvoiceTotal(
items, items,
@@ -570,6 +622,7 @@ export const invoicesRouter = createTRPCRouter({
.update(invoices) .update(invoices)
.set({ .set({
...cleanInvoiceData, ...cleanInvoiceData,
...reminderJobPatch,
totalAmount, totalAmount,
updatedAt: new Date(), updatedAt: new Date(),
}) })
@@ -600,6 +653,7 @@ export const invoicesRouter = createTRPCRouter({
.update(invoices) .update(invoices)
.set({ .set({
...cleanInvoiceData, ...cleanInvoiceData,
...reminderJobPatch,
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(invoices.id, id)) .where(eq(invoices.id, id))
@@ -897,8 +951,7 @@ export const invoicesRouter = createTRPCRouter({
invoicesCreated++; invoicesCreated++;
} catch (err) { } catch (err) {
const msg = const msg = err instanceof Error ? err.message : "Unknown error";
err instanceof Error ? err.message : "Unknown error";
rowErrors.push(`${label}: ${msg}`); rowErrors.push(`${label}: ${msg}`);
} }
} }
@@ -1006,7 +1059,9 @@ export const invoicesRouter = createTRPCRouter({
// ── Public token (shareable link) ────────────────────────────────────────── // ── Public token (shareable link) ──────────────────────────────────────────
generatePublicToken: sessionProcedure generatePublicToken: sessionProcedure
.input(z.object({ id: z.string(), ttlHours: z.number().positive().optional() })) .input(
z.object({ id: z.string(), ttlHours: z.number().positive().optional() }),
)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const invoice = await ctx.db.query.invoices.findFirst({ const invoice = await ctx.db.query.invoices.findFirst({
where: eq(invoices.id, input.id), where: eq(invoices.id, input.id),
@@ -1048,6 +1103,7 @@ export const invoicesRouter = createTRPCRouter({
where: eq(invoices.publicToken, input.token), where: eq(invoices.publicToken, input.token),
with: { with: {
client: true, client: true,
createdBy: { columns: { timeZone: true } },
// Explicit allowlist: this is a publicProcedure — never let // Explicit allowlist: this is a publicProcedure — never let
// secret fields (resendApiKey, resendDomain) reach an // secret fields (resendApiKey, resendDomain) reach an
// unauthenticated caller via the business relation. // unauthenticated caller via the business relation.
@@ -1068,6 +1124,16 @@ export const invoicesRouter = createTRPCRouter({
taxId: true, taxId: true,
logoStorageKey: true, logoStorageKey: true,
logoMimeType: true, logoMimeType: true,
logoDarkStorageKey: true,
logoDarkMimeType: true,
wordmarkLightStorageKey: true,
wordmarkLightMimeType: true,
wordmarkDarkStorageKey: true,
wordmarkDarkMimeType: true,
iconLightStorageKey: true,
iconLightMimeType: true,
iconDarkStorageKey: true,
iconDarkMimeType: true,
hideNameWithLogo: true, hideNameWithLogo: true,
}, },
}, },
@@ -1081,8 +1147,14 @@ export const invoicesRouter = createTRPCRouter({
}, },
}); });
if (!invoice) throw new TRPCError({ code: "NOT_FOUND" }); if (!invoice) throw new TRPCError({ code: "NOT_FOUND" });
if (invoice.publicTokenExpiresAt && new Date(invoice.publicTokenExpiresAt) < new Date()) { if (
throw new TRPCError({ code: "FORBIDDEN", message: "This link has expired" }); invoice.publicTokenExpiresAt &&
new Date(invoice.publicTokenExpiresAt) < new Date()
) {
throw new TRPCError({
code: "FORBIDDEN",
message: "This link has expired",
});
} }
return invoice; return invoice;
}), }),
@@ -1100,12 +1172,22 @@ export const invoicesRouter = createTRPCRouter({
throw new TRPCError({ code: "NOT_FOUND" }); throw new TRPCError({ code: "NOT_FOUND" });
} }
if (!invoice.client?.email) { if (!invoice.client?.email) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Client has no email address" }); throw new TRPCError({
code: "BAD_REQUEST",
message: "Client has no email address",
});
} }
const userName = const userName =
invoice.business?.emailFromName ?? invoice.business?.name ?? ctx.session.user.name ?? ""; invoice.business?.emailFromName ??
invoice.business?.name ??
ctx.session.user.name ??
"";
const userEmail = invoice.business?.email ?? ctx.session.user.email ?? ""; const userEmail = invoice.business?.email ?? ctx.session.user.email ?? "";
const owner = await ctx.db.query.users.findFirst({
where: eq(users.id, ctx.session.user.id),
columns: { timeZone: true },
});
const { html, text, subject } = generateReminderEmailTemplate({ const { html, text, subject } = generateReminderEmailTemplate({
invoice: { invoice: {
@@ -1120,40 +1202,23 @@ export const invoicesRouter = createTRPCRouter({
customMessage: input.customMessage, customMessage: input.customMessage,
userName, userName,
userEmail, userEmail,
timeZone: owner?.timeZone ?? "America/New_York",
}); });
// Resolve Resend instance (same two-tier logic as email router) try {
let resendInstance: Resend; await sendEmail({
let fromEmail: string; ...resolveEmailSender(invoice.business, userName || "beenvoice"),
if (invoice.business?.resendApiKey && invoice.business?.resendDomain) {
resendInstance = new Resend(invoice.business.resendApiKey);
const fromName = invoice.business.emailFromName ?? invoice.business.name;
fromEmail = `${fromName} <noreply@${invoice.business.resendDomain}>`;
} else if (env.RESEND_API_KEY && env.RESEND_DOMAIN) {
resendInstance = new Resend(env.RESEND_API_KEY);
fromEmail = `noreply@${env.RESEND_DOMAIN}`;
} else if (env.RESEND_API_KEY) {
resendInstance = new Resend(env.RESEND_API_KEY);
fromEmail = invoice.business?.email ?? NOREPLY_EMAIL;
} else {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Email delivery is not configured. Add a Resend API key.",
});
}
const result = await resendInstance.emails.send({
from: fromEmail,
to: [invoice.client.email], to: [invoice.client.email],
subject, subject,
html, html,
text, text,
idempotencyKey: `invoice-reminder:${invoice.id}:${Date.now()}`,
}); });
} catch (error) {
if (result.error) {
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",
message: result.error.message, message:
error instanceof Error ? error.message : "Email delivery failed",
}); });
} }
@@ -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 };
}),
});
@@ -1,109 +1,27 @@
import { z } from "zod"; import { z } from "zod";
import { and, eq, lte } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { createTRPCRouter, protectedProcedure } from "../trpc"; import { createTRPCRouter, protectedProcedure } from "../trpc";
import { import {
recurringInvoices, recurringInvoices,
recurringInvoiceItems, recurringInvoiceItems,
invoices,
invoiceItems,
clients, clients,
businesses, businesses,
} from "~/server/db/schema"; } from "~/server/db/schema";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import type { db as DbType } from "~/server/db"; import { generateInvoiceFromRecurring } from "~/server/services/recurring-invoices";
import {
DEFAULT_TIME_ZONE,
isValidTimeZone,
zonedDateTimeToInstant,
} from "@beenvoice/domain/time-zone";
export function nextDueDate(schedule: string, from = new Date()): Date { const scheduleEnum = z.enum([
const d = new Date(from); "weekly",
switch (schedule) { "biweekly",
case "weekly": d.setDate(d.getDate() + 7); break; "monthly",
case "biweekly": d.setDate(d.getDate() + 14); break; "quarterly",
case "monthly": d.setMonth(d.getMonth() + 1); break; "yearly",
case "quarterly": d.setMonth(d.getMonth() + 3); break; ]);
case "yearly": d.setFullYear(d.getFullYear() + 1); break;
}
return d;
}
type RecurringWithItems = typeof recurringInvoices.$inferSelect & {
items: (typeof recurringInvoiceItems.$inferSelect)[];
};
export async function generateInvoiceFromRecurring(
db: typeof DbType,
recurring: RecurringWithItems,
): Promise<{ id: string }> {
const now = new Date();
const invoiceNumber = `REC-${Date.now()}`;
const subtotal = recurring.items.reduce((s, i) => s + i.hours * i.rate, 0);
const taxAmount = (subtotal * recurring.taxRate) / 100;
const total = subtotal + taxAmount;
const [newInvoice] = await db
.insert(invoices)
.values({
invoiceNumber,
invoicePrefix: recurring.invoicePrefix ?? "#",
clientId: recurring.clientId,
businessId: recurring.businessId ?? null,
issueDate: now,
dueDate: nextDueDate("monthly", now),
status: "draft",
totalAmount: total,
taxRate: recurring.taxRate,
notes: recurring.notes ?? null,
emailMessage: recurring.emailMessage ?? null,
currency: recurring.currency,
createdById: recurring.createdById,
})
.returning({ id: invoices.id });
if (!newInvoice) throw new Error("Failed to create invoice");
if (recurring.items.length > 0) {
await db.insert(invoiceItems).values(
recurring.items.map((item, idx) => ({
invoiceId: newInvoice.id,
date: now,
description: item.description,
hours: item.hours,
rate: item.rate,
amount: item.hours * item.rate,
position: item.position ?? idx,
})),
);
}
return newInvoice;
}
export async function generateDueRecurringInvoices(db: typeof DbType): Promise<number> {
const now = new Date();
const due = await db.query.recurringInvoices.findMany({
where: and(
eq(recurringInvoices.status, "active"),
lte(recurringInvoices.nextDueAt, now),
),
with: { items: true },
});
let generated = 0;
for (const rec of due) {
try {
await generateInvoiceFromRecurring(db, rec);
await db
.update(recurringInvoices)
.set({ lastGeneratedAt: now, nextDueAt: nextDueDate(rec.schedule, now) })
.where(eq(recurringInvoices.id, rec.id));
generated++;
} catch {
// continue on individual failures
}
}
return generated;
}
const scheduleEnum = z.enum(["weekly", "biweekly", "monthly", "quarterly", "yearly"]);
const recurringItemSchema = z.object({ const recurringItemSchema = z.object({
description: z.string().min(1), description: z.string().min(1),
@@ -122,9 +40,27 @@ const recurringInvoiceSchema = z.object({
currency: z.string().length(3).default("USD"), currency: z.string().length(3).default("USD"),
notes: z.string().optional().or(z.literal("")), notes: z.string().optional().or(z.literal("")),
emailMessage: z.string().optional().or(z.literal("")), emailMessage: z.string().optional().or(z.literal("")),
timeZone: z.string().refine(isValidTimeZone).default(DEFAULT_TIME_ZONE),
nextRunLocal: z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/),
disambiguation: z.enum(["earlier", "later", "reject"]).default("reject"),
items: z.array(recurringItemSchema).min(1), items: z.array(recurringItemSchema).min(1),
}); });
function parseNextRun(input: z.infer<typeof recurringInvoiceSchema>) {
try {
return zonedDateTimeToInstant(
input.nextRunLocal,
input.timeZone,
input.disambiguation,
);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: error instanceof Error ? error.message : "Invalid recurring run time",
});
}
}
export const recurringInvoicesRouter = createTRPCRouter({ export const recurringInvoicesRouter = createTRPCRouter({
getAll: protectedProcedure.query(async ({ ctx }) => { getAll: protectedProcedure.query(async ({ ctx }) => {
return ctx.db.query.recurringInvoices.findMany({ return ctx.db.query.recurringInvoices.findMany({
@@ -141,14 +77,20 @@ export const recurringInvoicesRouter = createTRPCRouter({
where: eq(clients.id, input.clientId), where: eq(clients.id, input.clientId),
}); });
if (client?.createdById !== ctx.session.user.id) { if (client?.createdById !== ctx.session.user.id) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Client not found" }); throw new TRPCError({
code: "BAD_REQUEST",
message: "Client not found",
});
} }
if (input.businessId) { if (input.businessId) {
const biz = await ctx.db.query.businesses.findFirst({ const biz = await ctx.db.query.businesses.findFirst({
where: eq(businesses.id, input.businessId), where: eq(businesses.id, input.businessId),
}); });
if (biz?.createdById !== ctx.session.user.id) { if (biz?.createdById !== ctx.session.user.id) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Business not found" }); throw new TRPCError({
code: "BAD_REQUEST",
message: "Business not found",
});
} }
} }
@@ -165,7 +107,8 @@ export const recurringInvoicesRouter = createTRPCRouter({
currency: input.currency, currency: input.currency,
notes: input.notes ?? null, notes: input.notes ?? null,
emailMessage: input.emailMessage ?? null, emailMessage: input.emailMessage ?? null,
nextDueAt: nextDueDate(input.schedule), nextDueAt: parseNextRun(input),
timeZone: input.timeZone,
createdById: ctx.session.user.id, createdById: ctx.session.user.id,
}) })
.returning({ id: recurringInvoices.id }); .returning({ id: recurringInvoices.id });
@@ -207,6 +150,8 @@ export const recurringInvoicesRouter = createTRPCRouter({
currency: input.currency, currency: input.currency,
notes: input.notes ?? null, notes: input.notes ?? null,
emailMessage: input.emailMessage ?? null, emailMessage: input.emailMessage ?? null,
nextDueAt: parseNextRun(input),
timeZone: input.timeZone,
}) })
.where(eq(recurringInvoices.id, input.id)); .where(eq(recurringInvoices.id, input.id));
@@ -285,11 +230,12 @@ export const recurringInvoicesRouter = createTRPCRouter({
throw new TRPCError({ code: "NOT_FOUND" }); throw new TRPCError({ code: "NOT_FOUND" });
} }
const newInvoice = await generateInvoiceFromRecurring(ctx.db, rec); const now = new Date();
const newInvoice = await generateInvoiceFromRecurring(ctx.db, rec, now);
await ctx.db await ctx.db
.update(recurringInvoices) .update(recurringInvoices)
.set({ lastGeneratedAt: new Date(), nextDueAt: nextDueDate(rec.schedule) }) .set({ lastGeneratedAt: now })
.where(eq(recurringInvoices.id, input.id)); .where(eq(recurringInvoices.id, input.id));
return { invoiceId: newInvoice.id }; return { invoiceId: newInvoice.id };
+34 -4
View File
@@ -41,6 +41,10 @@ import {
type ColorMode, type ColorMode,
} from "~/lib/branding"; } from "~/lib/branding";
import { revokeUserSessions } from "~/lib/session-security"; import { revokeUserSessions } from "~/lib/session-security";
import {
DEFAULT_TIME_ZONE,
isValidTimeZone,
} from "@beenvoice/domain/time-zone";
function resolveBusinessId( function resolveBusinessId(
refs: { businessName?: string; businessNickname?: string }, refs: { businessName?: string; businessNickname?: string },
@@ -156,6 +160,7 @@ const RecurringInvoiceBackupSchema = z.object({
currency: z.string().default("USD"), currency: z.string().default("USD"),
notes: z.string().optional(), notes: z.string().optional(),
emailMessage: z.string().optional(), emailMessage: z.string().optional(),
timeZone: z.string().refine(isValidTimeZone).default(DEFAULT_TIME_ZONE),
nextDueAt: z.coerce.date(), nextDueAt: z.coerce.date(),
lastGeneratedAt: z.coerce.date().optional(), lastGeneratedAt: z.coerce.date().optional(),
items: z.array(RecurringInvoiceItemBackupSchema), items: z.array(RecurringInvoiceItemBackupSchema),
@@ -197,6 +202,7 @@ const BackupDataSchema = z.object({
prefersReducedMotion: z.boolean().optional(), prefersReducedMotion: z.boolean().optional(),
animationSpeedMultiplier: z.number().optional(), animationSpeedMultiplier: z.number().optional(),
theme: z.string().optional(), theme: z.string().optional(),
timeZone: z.string().refine(isValidTimeZone).optional(),
onboardingCompletedAt: z.coerce.date().nullable().optional(), onboardingCompletedAt: z.coerce.date().nullable().optional(),
}), }),
clients: z.array(ClientBackupSchema), clients: z.array(ClientBackupSchema),
@@ -291,6 +297,7 @@ export const settingsRouter = createTRPCRouter({
email: true, email: true,
image: true, image: true,
role: true, role: true,
timeZone: true,
onboardingCompletedAt: true, onboardingCompletedAt: true,
}, },
}); });
@@ -507,6 +514,7 @@ export const settingsRouter = createTRPCRouter({
.input( .input(
z.object({ z.object({
name: z.string().min(1, "Name is required"), name: z.string().min(1, "Name is required"),
timeZone: z.string().refine(isValidTimeZone).default(DEFAULT_TIME_ZONE),
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
@@ -514,6 +522,7 @@ export const settingsRouter = createTRPCRouter({
.update(users) .update(users)
.set({ .set({
name: input.name, name: input.name,
timeZone: input.timeZone,
}) })
.where(eq(users.id, ctx.session.user.id)); .where(eq(users.id, ctx.session.user.id));
@@ -621,6 +630,7 @@ export const settingsRouter = createTRPCRouter({
prefersReducedMotion: true, prefersReducedMotion: true,
animationSpeedMultiplier: true, animationSpeedMultiplier: true,
theme: true, theme: true,
timeZone: true,
onboardingCompletedAt: true, onboardingCompletedAt: true,
}, },
}); });
@@ -759,6 +769,7 @@ export const settingsRouter = createTRPCRouter({
prefersReducedMotion: user?.prefersReducedMotion ?? false, prefersReducedMotion: user?.prefersReducedMotion ?? false,
animationSpeedMultiplier: user?.animationSpeedMultiplier ?? 1, animationSpeedMultiplier: user?.animationSpeedMultiplier ?? 1,
theme: user?.theme ?? "system", theme: user?.theme ?? "system",
timeZone: user?.timeZone ?? DEFAULT_TIME_ZONE,
onboardingCompletedAt: user?.onboardingCompletedAt ?? null, onboardingCompletedAt: user?.onboardingCompletedAt ?? null,
}, },
clients: userClients.map((client) => ({ clients: userClients.map((client) => ({
@@ -835,6 +846,7 @@ export const settingsRouter = createTRPCRouter({
currency: recurring.currency, currency: recurring.currency,
notes: recurring.notes ?? undefined, notes: recurring.notes ?? undefined,
emailMessage: recurring.emailMessage ?? undefined, emailMessage: recurring.emailMessage ?? undefined,
timeZone: recurring.timeZone,
nextDueAt: recurring.nextDueAt, nextDueAt: recurring.nextDueAt,
lastGeneratedAt: recurring.lastGeneratedAt ?? undefined, lastGeneratedAt: recurring.lastGeneratedAt ?? undefined,
items: recurring.items, items: recurring.items,
@@ -1002,6 +1014,7 @@ export const settingsRouter = createTRPCRouter({
currency: recurringData.currency, currency: recurringData.currency,
notes: recurringData.notes, notes: recurringData.notes,
emailMessage: recurringData.emailMessage, emailMessage: recurringData.emailMessage,
timeZone: recurringData.timeZone,
nextDueAt: recurringData.nextDueAt, nextDueAt: recurringData.nextDueAt,
lastGeneratedAt: recurringData.lastGeneratedAt, lastGeneratedAt: recurringData.lastGeneratedAt,
createdById: userId, createdById: userId,
@@ -1110,6 +1123,9 @@ export const settingsRouter = createTRPCRouter({
...(input.user.animationSpeedMultiplier !== undefined && { ...(input.user.animationSpeedMultiplier !== undefined && {
animationSpeedMultiplier: input.user.animationSpeedMultiplier, animationSpeedMultiplier: input.user.animationSpeedMultiplier,
}), }),
...(input.user.timeZone !== undefined && {
timeZone: input.user.timeZone,
}),
...(input.user.theme !== undefined && { ...(input.user.theme !== undefined && {
theme: input.user.theme, theme: input.user.theme,
}), }),
@@ -1196,14 +1212,21 @@ export const settingsRouter = createTRPCRouter({
.mutation(async ({ ctx }) => { .mutation(async ({ ctx }) => {
const userId = ctx.session.user.id; const userId = ctx.session.user.id;
const [receiptObjects, logoObjects] = await Promise.all([ const [receiptObjects, brandObjects] = await Promise.all([
ctx.db ctx.db
.select({ storageKey: expenseReceipts.storageKey }) .select({ storageKey: expenseReceipts.storageKey })
.from(expenseReceipts) .from(expenseReceipts)
.innerJoin(expenses, eq(expenseReceipts.expenseId, expenses.id)) .innerJoin(expenses, eq(expenseReceipts.expenseId, expenses.id))
.where(eq(expenses.createdById, userId)), .where(eq(expenses.createdById, userId)),
ctx.db ctx.db
.select({ storageKey: businesses.logoStorageKey }) .select({
logo: businesses.logoStorageKey,
logoDark: businesses.logoDarkStorageKey,
wordmarkLight: businesses.wordmarkLightStorageKey,
wordmarkDark: businesses.wordmarkDarkStorageKey,
iconLight: businesses.iconLightStorageKey,
iconDark: businesses.iconDarkStorageKey,
})
.from(businesses) .from(businesses)
.where(eq(businesses.createdById, userId)), .where(eq(businesses.createdById, userId)),
]); ]);
@@ -1211,9 +1234,16 @@ export const settingsRouter = createTRPCRouter({
// Delete uploaded personal data before removing its database pointers. If object // Delete uploaded personal data before removing its database pointers. If object
// storage is unavailable, the account remains intact so the user can retry. // storage is unavailable, the account remains intact so the user can retry.
await Promise.all( await Promise.all(
[...receiptObjects, ...logoObjects].flatMap(({ storageKey }) => [
storageKey ? [deleteObject(storageKey)] : [], ...receiptObjects.flatMap(({ storageKey }) =>
storageKey ? [storageKey] : [],
), ),
...brandObjects.flatMap((assets) =>
Object.values(assets).filter((storageKey): storageKey is string =>
Boolean(storageKey),
),
),
].map((storageKey) => deleteObject(storageKey)),
); );
await ctx.db.transaction(async (tx) => { await ctx.db.transaction(async (tx) => {
+190 -39
View File
@@ -1,7 +1,13 @@
import { z } from "zod"; import { z } from "zod";
import { eq, and, desc, isNull, isNotNull, gte, lte } from "drizzle-orm"; import { eq, and, desc, isNull, isNotNull, gte, lte } from "drizzle-orm";
import { createTRPCRouter, protectedProcedure } from "../trpc"; import { createTRPCRouter, protectedProcedure } from "../trpc";
import { timeEntries, clients, invoices, businesses } from "~/server/db/schema"; import {
timeEntries,
clients,
invoices,
businesses,
users,
} from "~/server/db/schema";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import type { db } from "~/server/db"; import type { db } from "~/server/db";
import { import {
@@ -17,6 +23,7 @@ import {
removeLinkedInvoiceItem, removeLinkedInvoiceItem,
syncLinkedInvoiceItem, syncLinkedInvoiceItem,
} from "~/server/api/lib/time-entry-invoice-sync"; } from "~/server/api/lib/time-entry-invoice-sync";
import { calendarDateFromInstant } from "@beenvoice/domain/time-zone";
type Db = typeof db; type Db = typeof db;
@@ -55,20 +62,31 @@ function computeHours(startedAt: Date, endedAt: Date): number {
async function addEntryToInvoice( async function addEntryToInvoice(
database: Db, database: Db,
invoice: { id: string; invoiceNumber: string; invoicePrefix: string | null; taxRate: number; items: { amount: number; position: number }[] }, userId: string,
invoice: {
id: string;
invoiceNumber: string;
invoicePrefix: string | null;
taxRate: number;
items: { amount: number; position: number }[];
},
entryId: string, entryId: string,
description: string, description: string,
hours: number, hours: number,
rate: number, rate: number,
date: Date, date: Date,
): Promise<{ id: string; invoiceNumber: string; invoicePrefix: string }> { ): Promise<{ id: string; invoiceNumber: string; invoicePrefix: string }> {
const owner = await database.query.users.findFirst({
where: eq(users.id, userId),
columns: { timeZone: true },
});
return insertInvoiceLineForTimeEntry(database, { return insertInvoiceLineForTimeEntry(database, {
invoice, invoice,
entryId, entryId,
description, description,
hours, hours,
rate, rate,
date, date: calendarDateFromInstant(date, owner?.timeZone ?? "America/New_York"),
}); });
} }
@@ -100,11 +118,21 @@ async function findOrCreateDraftInvoice(
if (!client) return null; if (!client) return null;
const defaultBusiness = await database.query.businesses.findFirst({ const defaultBusiness = await database.query.businesses.findFirst({
where: and(eq(businesses.createdById, userId), eq(businesses.isDefault, true)), where: and(
eq(businesses.createdById, userId),
eq(businesses.isDefault, true),
),
columns: { id: true }, columns: { id: true },
}); });
const issueDate = new Date(); const owner = await database.query.users.findFirst({
where: eq(users.id, userId),
columns: { timeZone: true },
});
const issueDate = calendarDateFromInstant(
new Date(),
owner?.timeZone ?? "America/New_York",
);
const [created] = await database const [created] = await database
.insert(invoices) .insert(invoices)
.values({ .values({
@@ -135,10 +163,23 @@ async function addEntryToLatestInvoice(
hours: number, hours: number,
rate: number, rate: number,
date: Date, date: Date,
): Promise<{ id: string; invoiceNumber: string; invoicePrefix: string } | null> { ): Promise<{
id: string;
invoiceNumber: string;
invoicePrefix: string;
} | null> {
const invoice = await findOrCreateDraftInvoice(database, userId, clientId); const invoice = await findOrCreateDraftInvoice(database, userId, clientId);
if (!invoice) return null; if (!invoice) return null;
return addEntryToInvoice(database, invoice, entryId, description, hours, rate, date); return addEntryToInvoice(
database,
userId,
invoice,
entryId,
description,
hours,
rate,
date,
);
} }
async function addEntryToSpecificInvoice( async function addEntryToSpecificInvoice(
@@ -150,7 +191,11 @@ async function addEntryToSpecificInvoice(
hours: number, hours: number,
rate: number, rate: number,
date: Date, date: Date,
): Promise<{ id: string; invoiceNumber: string; invoicePrefix: string } | null> { ): Promise<{
id: string;
invoiceNumber: string;
invoicePrefix: string;
} | null> {
const invoice = await database.query.invoices.findFirst({ const invoice = await database.query.invoices.findFirst({
where: and( where: and(
eq(invoices.id, invoiceId), eq(invoices.id, invoiceId),
@@ -161,7 +206,16 @@ async function addEntryToSpecificInvoice(
}); });
if (!invoice) return null; if (!invoice) return null;
return addEntryToInvoice(database, invoice, entryId, description, hours, rate, date); return addEntryToInvoice(
database,
userId,
invoice,
entryId,
description,
hours,
rate,
date,
);
} }
export const timeEntriesRouter = createTRPCRouter({ export const timeEntriesRouter = createTRPCRouter({
@@ -177,13 +231,19 @@ export const timeEntriesRouter = createTRPCRouter({
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const conditions = [eq(timeEntries.createdById, ctx.session.user.id)]; const conditions = [eq(timeEntries.createdById, ctx.session.user.id)];
if (input?.clientId) conditions.push(eq(timeEntries.clientId, input.clientId)); if (input?.clientId)
conditions.push(eq(timeEntries.clientId, input.clientId));
if (input?.from) conditions.push(gte(timeEntries.startedAt, input.from)); if (input?.from) conditions.push(gte(timeEntries.startedAt, input.from));
if (input?.to) conditions.push(lte(timeEntries.startedAt, input.to)); if (input?.to) conditions.push(lte(timeEntries.startedAt, input.to));
return ctx.db.query.timeEntries.findMany({ return ctx.db.query.timeEntries.findMany({
where: and(...conditions), where: and(...conditions),
with: { client: true, invoice: { columns: { id: true, invoiceNumber: true, invoicePrefix: true } } }, with: {
client: true,
invoice: {
columns: { id: true, invoiceNumber: true, invoicePrefix: true },
},
},
orderBy: [desc(timeEntries.startedAt)], orderBy: [desc(timeEntries.startedAt)],
}); });
}), }),
@@ -198,7 +258,11 @@ export const timeEntriesRouter = createTRPCRouter({
), ),
with: { client: true }, with: { client: true },
}); });
if (!entry) throw new TRPCError({ code: "NOT_FOUND", message: "Time entry not found" }); if (!entry)
throw new TRPCError({
code: "NOT_FOUND",
message: "Time entry not found",
});
return entry; return entry;
}), }),
@@ -247,10 +311,17 @@ export const timeEntriesRouter = createTRPCRouter({
let clientRecord: { defaultHourlyRate: number | null } | null = null; let clientRecord: { defaultHourlyRate: number | null } | null = null;
if (clientId) { if (clientId) {
const found = await ctx.db.query.clients.findFirst({ const found = await ctx.db.query.clients.findFirst({
where: and(eq(clients.id, clientId), eq(clients.createdById, ctx.session.user.id)), where: and(
eq(clients.id, clientId),
eq(clients.createdById, ctx.session.user.id),
),
columns: { defaultHourlyRate: true }, columns: { defaultHourlyRate: true },
}); });
if (!found) throw new TRPCError({ code: "FORBIDDEN", message: "Client not found" }); if (!found)
throw new TRPCError({
code: "FORBIDDEN",
message: "Client not found",
});
clientRecord = found; clientRecord = found;
} }
@@ -282,7 +353,10 @@ export const timeEntriesRouter = createTRPCRouter({
const startedAt = input.startedAt ?? new Date(); const startedAt = input.startedAt ?? new Date();
if (startedAt > new Date()) { if (startedAt > new Date()) {
throw new TRPCError({ code: "BAD_REQUEST", message: "startedAt cannot be in the future" }); throw new TRPCError({
code: "BAD_REQUEST",
message: "startedAt cannot be in the future",
});
} }
if (!clientRecord && resolvedClientId) { if (!clientRecord && resolvedClientId) {
@@ -337,7 +411,10 @@ export const timeEntriesRouter = createTRPCRouter({
}); });
if (!entry) { if (!entry) {
throw new TRPCError({ code: "NOT_FOUND", message: "No running timer found" }); throw new TRPCError({
code: "NOT_FOUND",
message: "No running timer found",
});
} }
const updates: { const updates: {
@@ -369,9 +446,16 @@ export const timeEntriesRouter = createTRPCRouter({
const clientId = input.clientId.trim() || null; const clientId = input.clientId.trim() || null;
if (clientId) { if (clientId) {
const found = await ctx.db.query.clients.findFirst({ const found = await ctx.db.query.clients.findFirst({
where: and(eq(clients.id, clientId), eq(clients.createdById, ctx.session.user.id)), where: and(
eq(clients.id, clientId),
eq(clients.createdById, ctx.session.user.id),
),
});
if (!found)
throw new TRPCError({
code: "FORBIDDEN",
message: "Client not found",
}); });
if (!found) throw new TRPCError({ code: "FORBIDDEN", message: "Client not found" });
} }
resolvedClientId = clientId; resolvedClientId = clientId;
updates.clientId = clientId; updates.clientId = clientId;
@@ -427,7 +511,10 @@ export const timeEntriesRouter = createTRPCRouter({
.returning(); .returning();
if (!updated) { if (!updated) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Update failed" }); throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Update failed",
});
} }
return updated; return updated;
@@ -435,10 +522,12 @@ export const timeEntriesRouter = createTRPCRouter({
clockOut: protectedProcedure clockOut: protectedProcedure
.input( .input(
z.object({ z
.object({
id: z.string().optional(), id: z.string().optional(),
description: z.string().max(500).optional(), description: z.string().max(500).optional(),
}).optional(), })
.optional(),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const conditions = [ const conditions = [
@@ -452,24 +541,41 @@ export const timeEntriesRouter = createTRPCRouter({
}); });
if (!entry) { if (!entry) {
throw new TRPCError({ code: "NOT_FOUND", message: "No running timer found" }); throw new TRPCError({
code: "NOT_FOUND",
message: "No running timer found",
});
} }
const endedAt = new Date(); const endedAt = new Date();
const hours = computeHours(entry.startedAt, endedAt); const hours = computeHours(entry.startedAt, endedAt);
const rawDescription = input?.description?.trim() ?? entry.description?.trim() ?? ""; const rawDescription =
input?.description?.trim() ?? entry.description?.trim() ?? "";
const billingDescription = resolveBillingDescription(rawDescription); const billingDescription = resolveBillingDescription(rawDescription);
const rate = entry.rate ?? 0; const rate = entry.rate ?? 0;
const [updated] = await ctx.db const [updated] = await ctx.db
.update(timeEntries) .update(timeEntries)
.set({ endedAt, hours, description: rawDescription, updatedAt: new Date() }) .set({
endedAt,
hours,
description: rawDescription,
updatedAt: new Date(),
})
.where(eq(timeEntries.id, entry.id)) .where(eq(timeEntries.id, entry.id))
.returning(); .returning();
if (!updated) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Clock out failed" }); if (!updated)
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Clock out failed",
});
let linkedInvoice: { id: string; invoiceNumber: string; invoicePrefix: string } | null = null; let linkedInvoice: {
id: string;
invoiceNumber: string;
invoicePrefix: string;
} | null = null;
let outcome: ClockOutOutcome = "zero_hours"; let outcome: ClockOutOutcome = "zero_hours";
if (hours > 0) { if (hours > 0) {
@@ -518,9 +624,16 @@ export const timeEntriesRouter = createTRPCRouter({
const clientId = normalizeOptionalId(input.clientId); const clientId = normalizeOptionalId(input.clientId);
if (clientId) { if (clientId) {
const client = await ctx.db.query.clients.findFirst({ const client = await ctx.db.query.clients.findFirst({
where: and(eq(clients.id, clientId), eq(clients.createdById, ctx.session.user.id)), where: and(
eq(clients.id, clientId),
eq(clients.createdById, ctx.session.user.id),
),
});
if (!client)
throw new TRPCError({
code: "FORBIDDEN",
message: "Client not found",
}); });
if (!client) throw new TRPCError({ code: "FORBIDDEN", message: "Client not found" });
} }
let hours = input.hours ?? null; let hours = input.hours ?? null;
@@ -542,9 +655,17 @@ export const timeEntriesRouter = createTRPCRouter({
}) })
.returning(); .returning();
if (!entry) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Create failed" }); if (!entry)
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Create failed",
});
let linkedInvoice: { id: string; invoiceNumber: string; invoicePrefix: string } | null = null; let linkedInvoice: {
id: string;
invoiceNumber: string;
invoicePrefix: string;
} | null = null;
if (clientId && hours && input.endedAt) { if (clientId && hours && input.endedAt) {
linkedInvoice = await addEntryToLatestInvoice( linkedInvoice = await addEntryToLatestInvoice(
ctx.db, ctx.db,
@@ -576,7 +697,11 @@ export const timeEntriesRouter = createTRPCRouter({
eq(timeEntries.createdById, ctx.session.user.id), eq(timeEntries.createdById, ctx.session.user.id),
), ),
}); });
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Time entry not found" }); if (!existing)
throw new TRPCError({
code: "NOT_FOUND",
message: "Time entry not found",
});
if (existing.endedAt == null) { if (existing.endedAt == null) {
throw new TRPCError({ throw new TRPCError({
@@ -590,16 +715,28 @@ export const timeEntriesRouter = createTRPCRouter({
if (clientId) { if (clientId) {
const client = await ctx.db.query.clients.findFirst({ const client = await ctx.db.query.clients.findFirst({
where: and(eq(clients.id, clientId), eq(clients.createdById, ctx.session.user.id)), where: and(
eq(clients.id, clientId),
eq(clients.createdById, ctx.session.user.id),
),
});
if (!client)
throw new TRPCError({
code: "FORBIDDEN",
message: "Client not found",
}); });
if (!client) throw new TRPCError({ code: "FORBIDDEN", message: "Client not found" });
} }
let hours = data.hours; let hours = data.hours;
const startedAt = data.startedAt ?? existing.startedAt; const startedAt = data.startedAt ?? existing.startedAt;
const endedAt = data.endedAt ?? existing.endedAt; const endedAt = data.endedAt ?? existing.endedAt;
if (endedAt && (data.startedAt !== undefined || data.endedAt !== undefined || data.hours === undefined)) { if (
endedAt &&
(data.startedAt !== undefined ||
data.endedAt !== undefined ||
data.hours === undefined)
) {
hours = computeHours(startedAt, endedAt); hours = computeHours(startedAt, endedAt);
} }
@@ -619,11 +756,19 @@ export const timeEntriesRouter = createTRPCRouter({
}); });
if (!updated) { if (!updated) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Update failed" }); throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Update failed",
});
} }
if (nextInvoiceId !== undefined) { if (nextInvoiceId !== undefined) {
await relinkTimeEntryToInvoice(ctx.db, ctx.session.user.id, updated, nextInvoiceId.trim() || null); await relinkTimeEntryToInvoice(
ctx.db,
ctx.session.user.id,
updated,
nextInvoiceId.trim() || null,
);
} else { } else {
await syncLinkedInvoiceItem(ctx.db, updated); await syncLinkedInvoiceItem(ctx.db, updated);
} }
@@ -640,7 +785,11 @@ export const timeEntriesRouter = createTRPCRouter({
eq(timeEntries.createdById, ctx.session.user.id), eq(timeEntries.createdById, ctx.session.user.id),
), ),
}); });
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Time entry not found" }); if (!existing)
throw new TRPCError({
code: "NOT_FOUND",
message: "Time entry not found",
});
await removeLinkedInvoiceItem(ctx.db, input.id); await removeLinkedInvoiceItem(ctx.db, input.id);
await ctx.db.delete(timeEntries).where(eq(timeEntries.id, input.id)); await ctx.db.delete(timeEntries).where(eq(timeEntries.id, input.id));
@@ -649,10 +798,12 @@ export const timeEntriesRouter = createTRPCRouter({
getSummary: protectedProcedure getSummary: protectedProcedure
.input( .input(
z.object({ z
.object({
from: z.date().optional(), from: z.date().optional(),
to: z.date().optional(), to: z.date().optional(),
}).optional(), })
.optional(),
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const conditions = [ const conditions = [
+132 -54
View File
@@ -20,21 +20,22 @@ export const users = createTable("user", (d) => ({
email: d.varchar({ length: 255 }).notNull().unique(), email: d.varchar({ length: 255 }).notNull().unique(),
emailVerified: d.boolean().default(false).notNull(), emailVerified: d.boolean().default(false).notNull(),
image: d.varchar({ length: 255 }), image: d.varchar({ length: 255 }),
createdAt: d.timestamp().notNull().defaultNow(), timeZone: d.varchar({ length: 100 }).notNull().default("America/New_York"),
createdAt: d.timestamp({ withTimezone: true }).notNull().defaultNow(),
updatedAt: d updatedAt: d
.timestamp() .timestamp({ withTimezone: true })
.notNull() .notNull()
.defaultNow() .defaultNow()
.$onUpdate(() => new Date()), .$onUpdate(() => new Date()),
password: d.varchar({ length: 255 }), // Matched DB: varchar(255) password: d.varchar({ length: 255 }), // Matched DB: varchar(255)
resetToken: d.varchar({ length: 255 }), // Matched DB: varchar(255) resetToken: d.varchar({ length: 255 }), // Matched DB: varchar(255)
resetTokenExpiry: d.timestamp(), resetTokenExpiry: d.timestamp({ withTimezone: true }),
// Custom fields // Custom fields
prefersReducedMotion: d.boolean().default(false).notNull(), prefersReducedMotion: d.boolean().default(false).notNull(),
animationSpeedMultiplier: d.real().default(1).notNull(), animationSpeedMultiplier: d.real().default(1).notNull(),
theme: d.varchar({ length: 20 }).default("system").notNull(), theme: d.varchar({ length: 20 }).default("system").notNull(),
role: d.varchar({ length: 20 }).default("user").notNull(), role: d.varchar({ length: 20 }).default("user").notNull(),
onboardingCompletedAt: d.timestamp(), onboardingCompletedAt: d.timestamp({ withTimezone: true }),
})); }));
export const platformSettings = createTable("platform_setting", (d) => ({ export const platformSettings = createTable("platform_setting", (d) => ({
@@ -49,9 +50,9 @@ export const platformSettings = createTable("platform_setting", (d) => ({
.notNull(), .notNull(),
pdfShowLogo: d.boolean().default(true).notNull(), pdfShowLogo: d.boolean().default(true).notNull(),
pdfShowPageNumbers: d.boolean().default(true).notNull(), pdfShowPageNumbers: d.boolean().default(true).notNull(),
createdAt: d.timestamp().notNull().defaultNow(), createdAt: d.timestamp({ withTimezone: true }).notNull().defaultNow(),
updatedAt: d updatedAt: d
.timestamp() .timestamp({ withTimezone: true })
.notNull() .notNull()
.defaultNow() .defaultNow()
.$onUpdate(() => new Date()), .$onUpdate(() => new Date()),
@@ -68,6 +69,7 @@ export const usersRelations = relations(users, ({ many }) => ({
invoiceTemplates: many(invoiceTemplates), invoiceTemplates: many(invoiceTemplates),
recurringInvoices: many(recurringInvoices), recurringInvoices: many(recurringInvoices),
timeEntries: many(timeEntries), timeEntries: many(timeEntries),
pushTokens: many(pushTokens),
auditLogsAsActor: many(auditLog), auditLogsAsActor: many(auditLog),
})); }));
@@ -87,7 +89,7 @@ export const auditLog = createTable(
targetType: d.varchar({ length: 50 }).notNull(), targetType: d.varchar({ length: 50 }).notNull(),
targetId: d.varchar({ length: 255 }), targetId: d.varchar({ length: 255 }),
metadata: d.jsonb().$type<Record<string, unknown>>(), metadata: d.jsonb().$type<Record<string, unknown>>(),
createdAt: d.timestamp().notNull().defaultNow(), createdAt: d.timestamp({ withTimezone: true }).notNull().defaultNow(),
}), }),
(t) => [ (t) => [
index("audit_log_actor_user_id_idx").on(t.actorUserId), index("audit_log_actor_user_id_idx").on(t.actorUserId),
@@ -119,14 +121,14 @@ export const accounts = createTable(
providerId: d.varchar({ length: 255 }).notNull(), providerId: d.varchar({ length: 255 }).notNull(),
accessToken: d.text(), accessToken: d.text(),
refreshToken: d.text(), refreshToken: d.text(),
accessTokenExpiresAt: d.timestamp(), accessTokenExpiresAt: d.timestamp({ withTimezone: true }),
refreshTokenExpiresAt: d.timestamp(), refreshTokenExpiresAt: d.timestamp({ withTimezone: true }),
scope: d.varchar({ length: 255 }), scope: d.varchar({ length: 255 }),
idToken: d.text(), idToken: d.text(),
password: d.text(), // Matched DB: text password: d.text(), // Matched DB: text
createdAt: d.timestamp().notNull().defaultNow(), createdAt: d.timestamp({ withTimezone: true }).notNull().defaultNow(),
updatedAt: d updatedAt: d
.timestamp() .timestamp({ withTimezone: true })
.notNull() .notNull()
.defaultNow() .defaultNow()
.$onUpdate(() => new Date()), .$onUpdate(() => new Date()),
@@ -151,12 +153,12 @@ export const sessions = createTable(
.notNull() .notNull()
.references(() => users.id), .references(() => users.id),
token: d.varchar({ length: 255 }).notNull().unique(), token: d.varchar({ length: 255 }).notNull().unique(),
expiresAt: d.timestamp().notNull(), expiresAt: d.timestamp({ withTimezone: true }).notNull(),
ipAddress: d.text(), // Matched DB: text ipAddress: d.text(), // Matched DB: text
userAgent: d.text(), // Matched DB: text userAgent: d.text(), // Matched DB: text
createdAt: d.timestamp().notNull().defaultNow(), createdAt: d.timestamp({ withTimezone: true }).notNull().defaultNow(),
updatedAt: d updatedAt: d
.timestamp() .timestamp({ withTimezone: true })
.notNull() .notNull()
.defaultNow() .defaultNow()
.$onUpdate(() => new Date()), .$onUpdate(() => new Date()),
@@ -183,12 +185,12 @@ export const apiKeys = createTable(
.varchar({ length: 255 }) .varchar({ length: 255 })
.notNull() .notNull()
.references(() => users.id, { onDelete: "cascade" }), .references(() => users.id, { onDelete: "cascade" }),
lastUsedAt: d.timestamp(), lastUsedAt: d.timestamp({ withTimezone: true }),
expiresAt: d.timestamp(), expiresAt: d.timestamp({ withTimezone: true }),
revokedAt: d.timestamp(), revokedAt: d.timestamp({ withTimezone: true }),
createdAt: d.timestamp().notNull().defaultNow(), createdAt: d.timestamp({ withTimezone: true }).notNull().defaultNow(),
updatedAt: d updatedAt: d
.timestamp() .timestamp({ withTimezone: true })
.notNull() .notNull()
.defaultNow() .defaultNow()
.$onUpdate(() => new Date()), .$onUpdate(() => new Date()),
@@ -214,10 +216,10 @@ export const verificationTokens = createTable(
.$defaultFn(() => crypto.randomUUID()), // Matched DB: text .$defaultFn(() => crypto.randomUUID()), // Matched DB: text
identifier: d.varchar({ length: 255 }).notNull(), identifier: d.varchar({ length: 255 }).notNull(),
value: d.text().notNull(), value: d.text().notNull(),
expiresAt: d.timestamp().notNull(), expiresAt: d.timestamp({ withTimezone: true }).notNull(),
createdAt: d.timestamp().notNull().defaultNow(), createdAt: d.timestamp({ withTimezone: true }).notNull().defaultNow(),
updatedAt: d updatedAt: d
.timestamp() .timestamp({ withTimezone: true })
.notNull() .notNull()
.defaultNow() .defaultNow()
.$onUpdate(() => new Date()), .$onUpdate(() => new Date()),
@@ -241,9 +243,9 @@ export const ssoProviders = createTable(
redirectURI: d.varchar({ length: 255 }).notNull().default(""), // Added detailed fields redirectURI: d.varchar({ length: 255 }).notNull().default(""), // Added detailed fields
oidcConfig: d.text(), oidcConfig: d.text(),
samlConfig: d.text(), samlConfig: d.text(),
createdAt: d.timestamp().notNull().defaultNow(), createdAt: d.timestamp({ withTimezone: true }).notNull().defaultNow(),
updatedAt: d updatedAt: d
.timestamp() .timestamp({ withTimezone: true })
.notNull() .notNull()
.defaultNow() .defaultNow()
.$onUpdate(() => new Date()), .$onUpdate(() => new Date()),
@@ -276,10 +278,10 @@ export const clients = createTable(
.notNull() .notNull()
.references(() => users.id), .references(() => users.id),
createdAt: d createdAt: d
.timestamp() .timestamp({ withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`) .default(sql`CURRENT_TIMESTAMP`)
.notNull(), .notNull(),
updatedAt: d.timestamp().$onUpdate(() => new Date()), updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()),
}), }),
(t) => [ (t) => [
index("client_created_by_idx").on(t.createdById), index("client_created_by_idx").on(t.createdById),
@@ -318,8 +320,20 @@ export const businesses = createTable(
website: d.varchar({ length: 255 }), website: d.varchar({ length: 255 }),
taxId: d.varchar({ length: 100 }), taxId: d.varchar({ length: 100 }),
logoUrl: d.varchar({ length: 500 }), logoUrl: d.varchar({ length: 500 }),
// Brand assets: the original logo columns are the combined/light variant
// for backwards compatibility with existing uploads.
logoStorageKey: d.varchar({ length: 500 }), logoStorageKey: d.varchar({ length: 500 }),
logoMimeType: d.varchar({ length: 100 }), logoMimeType: d.varchar({ length: 100 }),
logoDarkStorageKey: d.varchar({ length: 500 }),
logoDarkMimeType: d.varchar({ length: 100 }),
wordmarkLightStorageKey: d.varchar({ length: 500 }),
wordmarkLightMimeType: d.varchar({ length: 100 }),
wordmarkDarkStorageKey: d.varchar({ length: 500 }),
wordmarkDarkMimeType: d.varchar({ length: 100 }),
iconLightStorageKey: d.varchar({ length: 500 }),
iconLightMimeType: d.varchar({ length: 100 }),
iconDarkStorageKey: d.varchar({ length: 500 }),
iconDarkMimeType: d.varchar({ length: 100 }),
hideNameWithLogo: d.boolean().default(false).notNull(), hideNameWithLogo: d.boolean().default(false).notNull(),
isDefault: d.boolean().default(false), isDefault: d.boolean().default(false),
// Email configuration for custom Resend setup // Email configuration for custom Resend setup
@@ -331,10 +345,10 @@ export const businesses = createTable(
.notNull() .notNull()
.references(() => users.id), .references(() => users.id),
createdAt: d createdAt: d
.timestamp() .timestamp({ withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`) .default(sql`CURRENT_TIMESTAMP`)
.notNull(), .notNull(),
updatedAt: d.timestamp().$onUpdate(() => new Date()), updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()),
}), }),
(t) => [ (t) => [
index("business_created_by_idx").on(t.createdById), index("business_created_by_idx").on(t.createdById),
@@ -368,8 +382,8 @@ export const invoices = createTable(
.varchar({ length: 255 }) .varchar({ length: 255 })
.notNull() .notNull()
.references(() => clients.id), .references(() => clients.id),
issueDate: d.timestamp().notNull(), issueDate: d.date({ mode: "date" }).notNull(),
dueDate: d.timestamp().notNull(), dueDate: d.date({ mode: "date" }).notNull(),
status: d.varchar({ length: 50 }).notNull().default("draft"), // draft, sent, paid (overdue computed) status: d.varchar({ length: 50 }).notNull().default("draft"), // draft, sent, paid (overdue computed)
totalAmount: d.real().notNull().default(0), totalAmount: d.real().notNull().default(0),
taxRate: d.real().notNull().default(0.0), taxRate: d.real().notNull().default(0.0),
@@ -381,14 +395,20 @@ export const invoices = createTable(
.notNull() .notNull()
.references(() => users.id), .references(() => users.id),
publicToken: d.varchar({ length: 255 }).unique(), publicToken: d.varchar({ length: 255 }).unique(),
publicTokenExpiresAt: d.timestamp(), publicTokenExpiresAt: d.timestamp({ withTimezone: true }),
lastReminderSentAt: d.timestamp(), lastReminderSentAt: d.timestamp({ withTimezone: true }),
sendReminderAt: d.timestamp(), sendReminderAt: d.timestamp({ withTimezone: true }),
sendReminderJobId: d.varchar({ length: 255 }),
sentAt: d.timestamp({ withTimezone: true }),
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 createdAt: d
.timestamp() .timestamp({ withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`) .default(sql`CURRENT_TIMESTAMP`)
.notNull(), .notNull(),
updatedAt: d.timestamp().$onUpdate(() => new Date()), updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()),
}), }),
(t) => [ (t) => [
index("invoice_business_id_idx").on(t.businessId), index("invoice_business_id_idx").on(t.businessId),
@@ -397,6 +417,8 @@ export const invoices = createTable(
index("invoice_number_idx").on(t.invoiceNumber), index("invoice_number_idx").on(t.invoiceNumber),
index("invoice_status_idx").on(t.status), index("invoice_status_idx").on(t.status),
index("invoice_public_token_idx").on(t.publicToken), index("invoice_public_token_idx").on(t.publicToken),
index("invoice_scheduled_send_at_idx").on(t.scheduledSendAt),
index("invoice_scheduled_send_job_idx").on(t.scheduledSendJobId),
], ],
); );
@@ -429,7 +451,7 @@ export const invoiceItems = createTable(
.varchar({ length: 255 }) .varchar({ length: 255 })
.notNull() .notNull()
.references(() => invoices.id, { onDelete: "cascade" }), .references(() => invoices.id, { onDelete: "cascade" }),
date: d.timestamp().notNull(), date: d.date({ mode: "date" }).notNull(),
description: d.varchar({ length: 500 }).notNull(), description: d.varchar({ length: 500 }).notNull(),
hours: d.real().notNull(), hours: d.real().notNull(),
rate: d.real().notNull(), rate: d.real().notNull(),
@@ -439,7 +461,7 @@ export const invoiceItems = createTable(
.varchar({ length: 255 }) .varchar({ length: 255 })
.references(() => timeEntries.id, { onDelete: "set null" }), .references(() => timeEntries.id, { onDelete: "set null" }),
createdAt: d createdAt: d
.timestamp() .timestamp({ withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`) .default(sql`CURRENT_TIMESTAMP`)
.notNull(), .notNull(),
}), }),
@@ -474,7 +496,7 @@ export const expenses = createTable(
invoiceId: d invoiceId: d
.varchar({ length: 255 }) .varchar({ length: 255 })
.references(() => invoices.id, { onDelete: "set null" }), .references(() => invoices.id, { onDelete: "set null" }),
date: d.timestamp().notNull(), date: d.date({ mode: "date" }).notNull(),
description: d.varchar({ length: 500 }).notNull(), description: d.varchar({ length: 500 }).notNull(),
amount: d.real().notNull(), amount: d.real().notNull(),
currency: d.varchar({ length: 3 }).default("USD").notNull(), currency: d.varchar({ length: 3 }).default("USD").notNull(),
@@ -488,10 +510,10 @@ export const expenses = createTable(
.notNull() .notNull()
.references(() => users.id), .references(() => users.id),
createdAt: d createdAt: d
.timestamp() .timestamp({ withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`) .default(sql`CURRENT_TIMESTAMP`)
.notNull(), .notNull(),
updatedAt: d.timestamp().$onUpdate(() => new Date()), updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()),
}), }),
(t) => [ (t) => [
index("expense_created_by_idx").on(t.createdById), index("expense_created_by_idx").on(t.createdById),
@@ -520,7 +542,7 @@ export const expenseReceipts = createTable(
mimeType: d.varchar({ length: 100 }).notNull(), mimeType: d.varchar({ length: 100 }).notNull(),
sizeBytes: d.integer().notNull(), sizeBytes: d.integer().notNull(),
createdAt: d createdAt: d
.timestamp() .timestamp({ withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`) .default(sql`CURRENT_TIMESTAMP`)
.notNull(), .notNull(),
}), }),
@@ -574,10 +596,10 @@ export const invoiceTemplates = createTable(
.notNull() .notNull()
.references(() => users.id), .references(() => users.id),
createdAt: d createdAt: d
.timestamp() .timestamp({ withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`) .default(sql`CURRENT_TIMESTAMP`)
.notNull(), .notNull(),
updatedAt: d.timestamp().$onUpdate(() => new Date()), updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()),
}), }),
(t) => [ (t) => [
index("invoice_template_created_by_idx").on(t.createdById), index("invoice_template_created_by_idx").on(t.createdById),
@@ -611,7 +633,7 @@ export const invoicePayments = createTable(
.references(() => invoices.id, { onDelete: "cascade" }), .references(() => invoices.id, { onDelete: "cascade" }),
amount: d.real().notNull(), amount: d.real().notNull(),
currency: d.varchar({ length: 3 }).default("USD").notNull(), currency: d.varchar({ length: 3 }).default("USD").notNull(),
date: d.timestamp().notNull(), date: d.date({ mode: "date" }).notNull(),
method: d.varchar({ length: 50 }).notNull().default("other"), // cash | check | bank_transfer | credit_card | paypal | other method: d.varchar({ length: 50 }).notNull().default("other"), // cash | check | bank_transfer | credit_card | paypal | other
notes: d.varchar({ length: 500 }), notes: d.varchar({ length: 500 }),
createdById: d createdById: d
@@ -619,7 +641,7 @@ export const invoicePayments = createTable(
.notNull() .notNull()
.references(() => users.id), .references(() => users.id),
createdAt: d createdAt: d
.timestamp() .timestamp({ withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`) .default(sql`CURRENT_TIMESTAMP`)
.notNull(), .notNull(),
}), }),
@@ -666,17 +688,18 @@ export const recurringInvoices = createTable(
currency: d.varchar({ length: 3 }).default("USD").notNull(), currency: d.varchar({ length: 3 }).default("USD").notNull(),
notes: d.varchar({ length: 1000 }), notes: d.varchar({ length: 1000 }),
emailMessage: d.varchar({ length: 2000 }), emailMessage: d.varchar({ length: 2000 }),
nextDueAt: d.timestamp().notNull(), nextDueAt: d.timestamp({ withTimezone: true }).notNull(),
lastGeneratedAt: d.timestamp(), lastGeneratedAt: d.timestamp({ withTimezone: true }),
timeZone: d.varchar({ length: 100 }).notNull().default("America/New_York"),
createdById: d createdById: d
.varchar({ length: 255 }) .varchar({ length: 255 })
.notNull() .notNull()
.references(() => users.id), .references(() => users.id),
createdAt: d createdAt: d
.timestamp() .timestamp({ withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`) .default(sql`CURRENT_TIMESTAMP`)
.notNull(), .notNull(),
updatedAt: d.timestamp().$onUpdate(() => new Date()), updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()),
}), }),
(t) => [ (t) => [
index("recurring_invoice_created_by_idx").on(t.createdById), index("recurring_invoice_created_by_idx").on(t.createdById),
@@ -722,7 +745,7 @@ export const recurringInvoiceItems = createTable(
rate: d.real().notNull(), rate: d.real().notNull(),
position: d.integer().notNull().default(0), position: d.integer().notNull().default(0),
createdAt: d createdAt: d
.timestamp() .timestamp({ withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`) .default(sql`CURRENT_TIMESTAMP`)
.notNull(), .notNull(),
}), }),
@@ -741,6 +764,61 @@ 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(
"background_job",
(d) => ({
id: d
.varchar({ length: 255 })
.notNull()
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
type: d.varchar({ length: 100 }).notNull(),
payload: d.jsonb().$type<Record<string, unknown>>().notNull().default({}),
status: d.varchar({ length: 20 }).notNull().default("pending"),
idempotencyKey: d.varchar({ length: 500 }).notNull().unique(),
runAt: d.timestamp({ withTimezone: true }).notNull().defaultNow(),
attempts: d.integer().notNull().default(0),
maxAttempts: d.integer().notNull().default(5),
lockedAt: d.timestamp({ withTimezone: true }),
lockedBy: d.varchar({ length: 255 }),
lastError: d.text(),
completedAt: d.timestamp({ withTimezone: true }),
createdAt: d.timestamp({ withTimezone: true }).notNull().defaultNow(),
updatedAt: d.timestamp({ withTimezone: true }).notNull().defaultNow(),
}),
(t) => [
index("background_job_status_run_at_idx").on(t.status, t.runAt),
index("background_job_type_status_idx").on(t.type, t.status),
],
);
// ─── Time Entries ───────────────────────────────────────────────────────────── // ─── Time Entries ─────────────────────────────────────────────────────────────
export const timeEntries = createTable( export const timeEntries = createTable(
@@ -758,8 +836,8 @@ export const timeEntries = createTable(
invoiceId: d invoiceId: d
.varchar({ length: 255 }) .varchar({ length: 255 })
.references(() => invoices.id, { onDelete: "set null" }), .references(() => invoices.id, { onDelete: "set null" }),
startedAt: d.timestamp().notNull(), startedAt: d.timestamp({ withTimezone: true }).notNull(),
endedAt: d.timestamp(), // null = currently running endedAt: d.timestamp({ withTimezone: true }), // null = currently running
hours: d.real(), // stored when stopped hours: d.real(), // stored when stopped
rate: d.real(), rate: d.real(),
notes: d.varchar({ length: 500 }), notes: d.varchar({ length: 500 }),
@@ -768,10 +846,10 @@ export const timeEntries = createTable(
.notNull() .notNull()
.references(() => users.id, { onDelete: "cascade" }), .references(() => users.id, { onDelete: "cascade" }),
createdAt: d createdAt: d
.timestamp() .timestamp({ withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`) .default(sql`CURRENT_TIMESTAMP`)
.notNull(), .notNull(),
updatedAt: d.timestamp().$onUpdate(() => new Date()), updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()),
}), }),
(t) => [ (t) => [
index("time_entry_created_by_idx").on(t.createdById), index("time_entry_created_by_idx").on(t.createdById),
@@ -0,0 +1,71 @@
import { and, eq } from "drizzle-orm";
import { db } from "~/server/db";
import { invoices, pushTokens } from "~/server/db/schema";
import type { BackgroundJob } from "~/server/jobs/queue";
type ExpoPushTicket = {
status: "ok" | "error";
message?: string;
details?: { error?: string };
};
export async function sendInvoiceReminder(job: BackgroundJob) {
const invoiceId = job.payload.invoiceId;
const userId = job.payload.userId;
if (typeof invoiceId !== "string" || typeof userId !== "string") {
throw new Error("Invalid invoice reminder payload");
}
const invoice = await db.query.invoices.findFirst({
where: and(eq(invoices.id, invoiceId), eq(invoices.createdById, userId)),
with: { client: { columns: { name: true } } },
});
if (invoice?.status !== "draft" || invoice.sendReminderJobId !== job.id)
return;
const tokens = await db.query.pushTokens.findMany({
where: eq(pushTokens.userId, userId),
});
if (!tokens.length) return;
const label = `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`;
const response = await fetch("https://exp.host/--/api/v2/push/send", {
method: "POST",
headers: {
Accept: "application/json",
"Accept-Encoding": "gzip, deflate",
"Content-Type": "application/json",
},
body: JSON.stringify(
tokens.map(({ token }) => ({
to: token,
title: "Time to send invoice",
body: `${label} for ${invoice.client?.name ?? "your client"} is ready to send.`,
sound: "default",
data: { invoiceId, type: "invoice-send-reminder" },
})),
),
});
if (!response.ok)
throw new Error(`Expo push request failed (${response.status})`);
const result = (await response.json()) as { data?: ExpoPushTicket[] };
const tickets = result.data ?? [];
const invalidTokens = tokens.filter(
(_, index) => tickets[index]?.details?.error === "DeviceNotRegistered",
);
for (const invalid of invalidTokens) {
await db.delete(pushTokens).where(eq(pushTokens.id, invalid.id));
}
const retryableFailure = tickets.find(
(ticket) =>
ticket.status === "error" &&
ticket.details?.error !== "DeviceNotRegistered",
);
if (retryableFailure) {
throw new Error(
retryableFailure.message ?? "Expo rejected the push notification",
);
}
}
@@ -0,0 +1,48 @@
import { and, eq, lte } from "drizzle-orm";
import { db } from "~/server/db";
import { recurringInvoices } from "~/server/db/schema";
import type { BackgroundJob } from "~/server/jobs/queue";
import {
generateInvoiceFromRecurring,
nextDueDate,
} from "~/server/services/recurring-invoices";
export async function generateRecurringInvoice(job: BackgroundJob) {
const recurringInvoiceId = job.payload.recurringInvoiceId;
const scheduledForValue = job.payload.scheduledFor;
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");
await db.transaction(async (tx) => {
const recurring = await tx.query.recurringInvoices.findFirst({
where: and(
eq(recurringInvoices.id, recurringInvoiceId),
eq(recurringInvoices.status, "active"),
lte(recurringInvoices.nextDueAt, scheduledFor),
),
with: { items: true },
});
if (!recurring) return;
await generateInvoiceFromRecurring(tx, recurring, scheduledFor);
await tx
.update(recurringInvoices)
.set({
lastGeneratedAt: new Date(),
nextDueAt: nextDueDate(
recurring.schedule,
scheduledFor,
recurring.timeZone,
),
})
.where(eq(recurringInvoices.id, recurring.id));
});
}
+146
View File
@@ -0,0 +1,146 @@
import { randomUUID } from "node:crypto";
import { and, asc, eq, inArray, lte, or } from "drizzle-orm";
import { db } from "~/server/db";
import {
backgroundJobs,
invoices,
recurringInvoices,
} from "~/server/db/schema";
export const jobTypes = {
generateRecurringInvoice: "recurring_invoice.generate",
sendInvoice: "invoice.send_scheduled",
sendInvoiceReminder: "invoice.reminder.send",
sendPushNotification: "push_notification.send",
timeClockReminder: "time_clock.reminder",
} as const;
export type JobType = (typeof jobTypes)[keyof typeof jobTypes];
export type BackgroundJob = typeof backgroundJobs.$inferSelect;
export async function enqueueJob(input: {
type: JobType;
payload?: Record<string, unknown>;
idempotencyKey: string;
runAt?: Date;
maxAttempts?: number;
}) {
const [job] = await db
.insert(backgroundJobs)
.values({
id: randomUUID(),
type: input.type,
payload: input.payload ?? {},
idempotencyKey: input.idempotencyKey,
runAt: input.runAt ?? new Date(),
maxAttempts: input.maxAttempts ?? 5,
})
.onConflictDoNothing({ target: backgroundJobs.idempotencyKey })
.returning();
return job ?? null;
}
export async function scheduleDueRecurringInvoiceJobs(now = new Date()) {
const due = await db.query.recurringInvoices.findMany({
where: and(
eq(recurringInvoices.status, "active"),
lte(recurringInvoices.nextDueAt, now),
),
});
let enqueued = 0;
for (const recurring of due) {
const scheduledFor = recurring.nextDueAt.toISOString();
const job = await enqueueJob({
type: jobTypes.generateRecurringInvoice,
idempotencyKey: `${jobTypes.generateRecurringInvoice}:${recurring.id}:${scheduledFor}`,
payload: { recurringInvoiceId: recurring.id, scheduledFor },
});
if (job) enqueued++;
}
return { due: due.length, enqueued };
}
export async function claimNextJob(workerId: string) {
return db.transaction(async (tx) => {
const staleBefore = new Date(Date.now() - 5 * 60_000);
const [job] = await tx
.select()
.from(backgroundJobs)
.where(
and(
lte(backgroundJobs.runAt, new Date()),
or(
eq(backgroundJobs.status, "pending"),
and(
eq(backgroundJobs.status, "processing"),
lte(backgroundJobs.lockedAt, staleBefore),
),
),
),
)
.orderBy(asc(backgroundJobs.runAt), asc(backgroundJobs.createdAt))
.limit(1)
.for("update", { skipLocked: true });
if (!job) return null;
const [claimed] = await tx
.update(backgroundJobs)
.set({
status: "processing",
attempts: job.attempts + 1,
lockedAt: new Date(),
lockedBy: workerId,
updatedAt: new Date(),
})
.where(eq(backgroundJobs.id, job.id))
.returning();
return claimed ?? null;
});
}
export async function completeJob(id: string) {
await db
.update(backgroundJobs)
.set({
status: "completed",
completedAt: new Date(),
lockedAt: null,
lockedBy: null,
lastError: null,
updatedAt: new Date(),
})
.where(eq(backgroundJobs.id, id));
}
export async function failJob(job: BackgroundJob, error: unknown) {
const terminal = job.attempts >= job.maxAttempts;
const retryDelayMs = Math.min(
60 * 60_000,
2 ** Math.max(0, job.attempts - 1) * 15_000,
);
await db
.update(backgroundJobs)
.set({
status: terminal ? "failed" : "pending",
runAt: terminal ? job.runAt : new Date(Date.now() + retryDelayMs),
lockedAt: null,
lockedBy: null,
lastError: error instanceof Error ? error.message : "Unknown error",
updatedAt: new Date(),
})
.where(eq(backgroundJobs.id, job.id));
return terminal;
}
export async function markScheduledInvoiceJobFailed(job: BackgroundJob) {
await db
.update(invoices)
.set({ scheduledSendStatus: "failed", updatedAt: new Date() })
.where(
and(
eq(invoices.scheduledSendJobId, job.id),
inArray(invoices.scheduledSendStatus, ["pending", "processing"]),
),
);
}
@@ -0,0 +1,53 @@
import { getEmailReadiness } from "@beenvoice/email";
import { env } from "~/env";
import { NOREPLY_EMAIL } from "~/lib/app-email";
interface BusinessEmailSettings {
name?: string | null;
nickname?: string | null;
email?: string | null;
emailFromName?: string | null;
resendApiKey?: string | null;
resendDomain?: string | null;
}
export function resolveEmailSender(
business?: BusinessEmailSettings | null,
fallbackName = "beenvoice",
) {
const readiness = getEmailReadiness({
from: env.EMAIL_FROM ?? env.RESEND_FROM,
resendApiKey: business?.resendApiKey ?? undefined,
});
if (readiness.provider !== "resend") {
return {
from: env.EMAIL_FROM ?? `${fallbackName} <${NOREPLY_EMAIL}>`,
resendApiKey: undefined,
};
}
if (business?.resendApiKey && business.resendDomain) {
const fromName =
business.emailFromName ??
(business.nickname
? `${business.name ?? fallbackName} (${business.nickname})`
: business.name) ??
fallbackName;
return {
from: `${fromName} <noreply@${business.resendDomain}>`,
resendApiKey: business.resendApiKey,
};
}
return {
from:
env.RESEND_FROM ??
env.EMAIL_FROM ??
(env.RESEND_DOMAIN
? `noreply@${env.RESEND_DOMAIN}`
: (business?.email ?? NOREPLY_EMAIL)),
resendApiKey: env.RESEND_API_KEY,
};
}
@@ -0,0 +1,95 @@
import type { db as DbType } from "~/server/db";
import { invoiceItems, invoices } from "~/server/db/schema";
import type {
recurringInvoiceItems,
recurringInvoices,
} from "~/server/db/schema";
import {
addZonedCalendarInterval,
getZonedDateTimeParts,
} from "@beenvoice/domain/time-zone";
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 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 & {
items: (typeof recurringInvoiceItems.$inferSelect)[];
};
export async function generateInvoiceFromRecurring(
db: Pick<typeof DbType, "insert">,
recurring: RecurringWithItems,
scheduledFor = new Date(),
): Promise<{ id: string }> {
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 taxAmount = (subtotal * recurring.taxRate) / 100;
const [newInvoice] = await db
.insert(invoices)
.values({
invoiceNumber,
invoicePrefix: recurring.invoicePrefix ?? "#",
clientId: recurring.clientId,
businessId: recurring.businessId ?? null,
issueDate,
dueDate: calendarDateAt(
nextDueDate("monthly", scheduledFor, recurring.timeZone),
recurring.timeZone,
),
status: "draft",
totalAmount: subtotal + taxAmount,
taxRate: recurring.taxRate,
notes: recurring.notes ?? null,
emailMessage: recurring.emailMessage ?? null,
currency: recurring.currency,
createdById: recurring.createdById,
})
.returning({ id: invoices.id });
if (!newInvoice) throw new Error("Failed to create invoice");
if (recurring.items.length > 0) {
await db.insert(invoiceItems).values(
recurring.items.map((item, index) => ({
invoiceId: newInvoice.id,
date: issueDate,
description: item.description,
hours: item.hours,
rate: item.rate,
amount: item.hours * item.rate,
position: item.position ?? index,
})),
);
}
return newInvoice;
}
@@ -0,0 +1,303 @@
import { and, eq } from "drizzle-orm";
import { sendEmail } from "@beenvoice/email";
import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
import { db } from "~/server/db";
import { backgroundJobs, invoices, platformSettings } from "~/server/db/schema";
import { resolveEmailSender } from "~/server/services/email-sender";
export interface InvoiceEmailOptions {
customSubject?: string;
customContent?: string;
customMessage?: string;
useHtml?: boolean;
ccEmails?: string;
bccEmails?: string;
}
export interface DeliverInvoiceEmailInput extends InvoiceEmailOptions {
invoiceId: string;
actorUserId: string;
baseUrl: string;
idempotencyKey?: string;
scheduledJobId?: string;
}
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function plainTextToHtml(value: string) {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
.replace(/\n/g, "<br>");
}
function normalizeEmailNoteHtml(value: string) {
const visibleText = value
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/p>/gi, "\n")
.replace(/<[^>]*>/g, "")
.replace(/&nbsp;|\u00a0/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.trim();
return visibleText ? value.trim() : "";
}
function parseEmailList(value?: string): string[] {
if (!value) return [];
return value
.split(",")
.map((email) => email.trim())
.filter((email) => EMAIL_PATTERN.test(email));
}
function deliveryError(message: string | undefined): Error {
const errorMessage = message?.toLowerCase() ?? "";
if (
errorMessage.includes("invalid email") ||
errorMessage.includes("invalid recipient")
) {
return new Error("Invalid recipient email address");
}
if (
errorMessage.includes("domain") ||
errorMessage.includes("not verified")
) {
return new Error(
"Email domain not verified. Please configure your Resend domain in business settings.",
);
}
if (
errorMessage.includes("rate limit") ||
errorMessage.includes("too many")
) {
return new Error("Rate limit exceeded. Please try again later.");
}
if (
errorMessage.includes("api key") ||
errorMessage.includes("unauthorized")
) {
return new Error(
"Email service configuration error. Please check your Resend API key.",
);
}
if (
errorMessage.includes("attachment") ||
errorMessage.includes("file size")
) {
return new Error("Invoice PDF is too large to send via email.");
}
return new Error(`Email delivery failed: ${message ?? "Unknown error"}`);
}
export async function deliverInvoiceEmail(input: DeliverInvoiceEmailInput) {
const invoice = await db.query.invoices.findFirst({
where: eq(invoices.id, input.invoiceId),
with: {
client: true,
business: true,
createdBy: true,
items: true,
},
});
if (!invoice) throw new Error("Invoice not found");
if (invoice.createdById !== input.actorUserId)
throw new Error("Unauthorized");
if (!invoice.client?.email) throw new Error("Client has no email address");
if (!invoice.items.length) {
throw new Error("Add at least one line item before sending this invoice");
}
if (!EMAIL_PATTERN.test(invoice.client.email)) {
throw new Error("Invalid client email address format");
}
if (
input.scheduledJobId &&
(invoice.scheduledSendJobId !== input.scheduledJobId ||
!["pending", "processing"].includes(invoice.scheduledSendStatus ?? ""))
) {
return {
skipped: true as const,
message: "Scheduled send is no longer active",
};
}
if (
!input.scheduledJobId &&
invoice.scheduledSendJobId &&
invoice.scheduledSendStatus === "pending"
) {
const cancelled = await db
.update(backgroundJobs)
.set({ status: "cancelled", updatedAt: new Date() })
.where(
and(
eq(backgroundJobs.id, invoice.scheduledSendJobId),
eq(backgroundJobs.status, "pending"),
),
)
.returning({ id: backgroundJobs.id });
if (!cancelled.length) {
throw new Error("The worker has already started sending this invoice");
}
await db
.update(invoices)
.set({ scheduledSendStatus: "cancelled", updatedAt: new Date() })
.where(eq(invoices.id, invoice.id));
} else if (
!input.scheduledJobId &&
invoice.scheduledSendStatus === "processing"
) {
throw new Error("The worker is already sending this invoice");
}
const settings = await db.query.platformSettings.findFirst({
where: eq(platformSettings.id, "global"),
});
let pdfBuffer: Buffer;
try {
const pdfBlob = await generateInvoicePDFBlob(
invoice,
{
pdfTemplate: settings?.pdfTemplate as "classic" | "minimal" | undefined,
pdfAccentColor: settings?.pdfAccentColor,
pdfFontFamily: settings?.pdfFontFamily as
| "sans"
| "serif"
| "mono"
| undefined,
pdfNumericFontFamily: settings?.pdfNumericFontFamily as
| "sans"
| "serif"
| "mono"
| undefined,
pdfFooterText: settings?.pdfFooterText,
pdfShowLogo: settings?.pdfShowLogo,
pdfShowPageNumbers: settings?.pdfShowPageNumbers,
},
{ logoBaseUrl: input.baseUrl },
);
pdfBuffer = Buffer.from(await pdfBlob.arrayBuffer());
if (pdfBuffer.length === 0) throw new Error("Generated PDF is empty");
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
throw new Error(
`Failed to generate invoice PDF for attachment: ${message}`,
);
}
const subject =
input.customSubject ??
`Invoice ${invoice.invoiceNumber} from ${
invoice.business
? `${invoice.business.name}${invoice.business.nickname ? ` (${invoice.business.nickname})` : ""}`
: "Your Business"
}`;
const userName =
invoice.business?.emailFromName ??
invoice.business?.name ??
invoice.createdBy.name ??
"Your Name";
const userEmail = invoice.business?.email ?? invoice.createdBy.email ?? "";
const customMessage =
input.customMessage !== undefined
? normalizeEmailNoteHtml(input.customMessage)
: invoice.emailMessage
? plainTextToHtml(invoice.emailMessage)
: undefined;
const emailTemplate = generateInvoiceEmailTemplate({
invoice: {
invoiceNumber: invoice.invoiceNumber,
issueDate: invoice.issueDate,
dueDate: invoice.dueDate,
status: invoice.status,
totalAmount: invoice.totalAmount,
taxRate: invoice.taxRate,
currency: invoice.currency,
client: { name: invoice.client.name, email: invoice.client.email },
business: invoice.business,
items: invoice.items,
},
customContent: input.customContent,
customMessage,
userName,
userEmail,
baseUrl: input.baseUrl,
timeZone: invoice.createdBy.timeZone,
});
const sender = resolveEmailSender(invoice.business, userName);
const fromEmail = sender.from;
const ccEmails = parseEmailList(input.ccEmails);
const bccEmails = parseEmailList(input.bccEmails);
if (
invoice.business?.email &&
invoice.business.email !== fromEmail &&
EMAIL_PATTERN.test(invoice.business.email)
) {
ccEmails.push(invoice.business.email);
}
let emailResult;
try {
emailResult = await sendEmail({
...sender,
to: [invoice.client.email],
cc: ccEmails.length ? ccEmails : undefined,
bcc: bccEmails.length ? bccEmails : undefined,
subject,
html: emailTemplate.html,
text: emailTemplate.text,
headers: {
"X-Priority": "3",
"X-MSMail-Priority": "Normal",
"X-Mailer": "beenvoice",
"MIME-Version": "1.0",
},
attachments: [
{
filename: `invoice-${invoice.invoiceNumber}.pdf`,
content: pdfBuffer,
},
],
idempotencyKey: input.idempotencyKey,
});
} catch (error) {
throw deliveryError(error instanceof Error ? error.message : undefined);
}
const sentAt = new Date();
await db
.update(invoices)
.set({
...(invoice.status === "draft" ? { status: "sent" } : {}),
sentAt,
...(input.scheduledJobId ? { scheduledSendStatus: "completed" } : {}),
updatedAt: sentAt,
})
.where(eq(invoices.id, input.invoiceId));
return {
skipped: false as const,
success: true,
emailId: emailResult.id,
message: `Invoice sent successfully to ${invoice.client.email}${
ccEmails.length ? ` (CC: ${ccEmails.join(", ")})` : ""
}${bccEmails.length ? ` (BCC: ${bccEmails.join(", ")})` : ""}`,
deliveryDetails: {
to: invoice.client.email,
cc: ccEmails,
bcc: bccEmails,
sentAt: sentAt.toISOString(),
},
};
}

Some files were not shown because too many files have changed in this diff Show More