Add scheduled invoice delivery

This commit is contained in:
2026-08-17 13:54:45 -04:00
parent 29589c1f32
commit 67ab6b78bd
26 changed files with 2099 additions and 835 deletions
+258 -198
View File
@@ -1,9 +1,19 @@
import { router, Stack, useLocalSearchParams } from "expo-router";
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 { InvoiceViewChips, type InvoiceViewSection } from "@/components/invoices/InvoiceViewChips";
import {
InvoiceViewChips,
type InvoiceViewSection,
} from "@/components/invoices/InvoiceViewChips";
import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
import { InvoiceTotals } from "@/components/invoices/InvoiceTotals";
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 { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
import { api } from "@/lib/trpc";
import { formatZonedDateTime } from "@beenvoice/domain/time-zone";
export default function InvoiceDetailScreen() {
const styles = useThemedStyles(createInvoiceDetailStyles);
@@ -53,7 +64,10 @@ export default function InvoiceDetailScreen() {
});
const previewInput = useMemo(
() => (invoiceQuery.data ? buildPreviewPdfInputFromInvoice(invoiceQuery.data) : null),
() =>
invoiceQuery.data
? buildPreviewPdfInputFromInvoice(invoiceQuery.data)
: null,
[invoiceQuery.data],
);
@@ -73,7 +87,11 @@ export default function InvoiceDetailScreen() {
<Text style={styles.errorText}>
{invoiceQuery.error?.message ?? "Invoice not found"}
</Text>
<Button title="Go back" variant="secondary" onPress={() => router.back()} />
<Button
title="Go back"
variant="secondary"
onPress={() => router.back()}
/>
</View>
</AppBackground>
);
@@ -126,18 +144,22 @@ export default function InvoiceDetailScreen() {
}
function promptStatusChange(current: InvoiceStatus) {
const options: Array<{ label: string; status: "draft" | "sent" | "paid" }> = [];
if (current !== "draft") options.push({ label: "Mark as draft", status: "draft" });
const options: Array<{ label: string; status: "draft" | "sent" | "paid" }> =
[];
if (current !== "draft")
options.push({ label: "Mark as draft", status: "draft" });
if (current !== "sent" && current !== "overdue") {
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;
Alert.alert("Update status", "Choose a new status", [
...options.map((option) => ({
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" },
]);
@@ -148,8 +170,13 @@ export default function InvoiceDetailScreen() {
<Stack.Screen options={{ headerBackTitle: "Invoices" }} />
<ScrollView
style={styles.scroll}
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "never" : undefined}
contentContainerStyle={[
styles.container,
{ paddingBottom: scrollPadding },
]}
contentInsetAdjustmentBehavior={
Platform.OS === "ios" ? "never" : undefined
}
scrollIndicatorInsets={{ bottom: scrollPadding }}
keyboardShouldPersistTaps="handled"
>
@@ -160,7 +187,9 @@ export default function InvoiceDetailScreen() {
{invoice.invoicePrefix}
{invoice.invoiceNumber}
</Text>
<Text style={styles.clientName}>{invoice.client?.name ?? "Client"}</Text>
<Text style={styles.clientName}>
{invoice.client?.name ?? "Client"}
</Text>
</View>
<StatusBadge status={status} />
</View>
@@ -183,103 +212,130 @@ export default function InvoiceDetailScreen() {
</Card>
) : (
<>
<Card title="Details">
<DetailRow label="Business" value={invoice.business?.name ?? "—"} />
<DetailRow label="Client" value={invoice.client?.name ?? "Client"} />
<DetailRow label="Issued" value={formatDate(invoice.issueDate)} />
<DetailRow label="Due" value={formatDate(invoice.dueDate)} />
<DetailRow label="Currency" value={invoice.currency} />
{invoice.taxRate > 0 ? (
<DetailRow label="Tax rate" value={`${invoice.taxRate}%`} />
) : null}
{invoice.status === "draft" && invoice.sendReminderAt ? (
<DetailRow
label="Send reminder"
value={
new Date(invoice.sendReminderAt) <= new Date()
? "Due now"
: formatDate(invoice.sendReminderAt)
<Card title="Details">
<DetailRow
label="Business"
value={invoice.business?.name ?? "—"}
/>
<DetailRow
label="Client"
value={invoice.client?.name ?? "Client"}
/>
<DetailRow label="Issued" value={formatDate(invoice.issueDate)} />
<DetailRow label="Due" value={formatDate(invoice.dueDate)} />
<DetailRow label="Currency" value={invoice.currency} />
{invoice.taxRate > 0 ? (
<DetailRow label="Tax rate" value={`${invoice.taxRate}%`} />
) : null}
{invoice.status === "draft" && invoice.sendReminderAt ? (
<DetailRow
label="Send reminder"
value={
new Date(invoice.sendReminderAt) <= new Date()
? "Due now"
: formatDate(invoice.sendReminderAt)
}
/>
) : null}
{invoice.scheduledSendStatus === "pending" &&
invoice.scheduledSendAt ? (
<DetailRow
label="Scheduled send"
value={formatZonedDateTime(
invoice.scheduledSendAt,
invoice.scheduledSendTimeZone ?? "UTC",
)}
/>
) : null}
</Card>
<Card title="Line items">
{invoice.items.length === 0 ? (
<Text style={styles.emptyLines}>
No line items yet. Clock time to this invoice from the Timer
tab, or edit to add lines manually.
</Text>
) : (
invoice.items.map((item) => {
const line = (
<View style={styles.lineItem}>
<View style={styles.lineMeta}>
<Text style={styles.lineDescription}>
{item.description}
</Text>
<Text style={styles.lineSub}>
{formatDate(item.date)} · {item.hours}h ×{" "}
{formatCurrency(item.rate, invoice.currency)}
</Text>
</View>
<Text style={styles.lineAmount}>
{formatCurrency(item.amount, invoice.currency)}
</Text>
</View>
);
if (invoice.status !== "draft") {
return <View key={item.id}>{line}</View>;
}
return (
<SwipeableRow
key={item.id}
backgroundColor={colors.card}
actions={[
{
key: "edit",
label: "Edit",
icon: "create-outline",
color: "#fff",
backgroundColor: colors.primary,
onPress: () =>
router.push(`/(app)/invoices/edit/${invoice.id}`),
},
]}
>
{line}
</SwipeableRow>
);
})
)}
<InvoiceTotals
subtotal={formatCurrency(subtotal, invoice.currency)}
taxLabel={
invoice.taxRate > 0 ? `Tax (${invoice.taxRate}%)` : undefined
}
taxAmount={
invoice.taxRate > 0
? formatCurrency(taxAmount, invoice.currency)
: undefined
}
total={formatCurrency(invoice.totalAmount, invoice.currency)}
/>
</Card>
{invoice.notes ? (
<Card title="Notes">
<Text style={styles.notes}>{invoice.notes}</Text>
</Card>
) : null}
<InvoiceDetailActions
status={status}
clientEmail={clientEmail}
onPaymentReminder={
status === "sent" || status === "overdue"
? promptPaymentReminder
: undefined
}
paymentReminderLoading={sendPaymentReminder.isPending}
onUpdateStatus={() => promptStatusChange(status)}
updateStatusLoading={updateStatus.isPending}
onTrackTime={() =>
router.push(
`/(app)/timer?clientId=${invoice.clientId}&invoiceId=${invoice.id}`,
)
}
/>
) : null}
</Card>
<Card title="Line items">
{invoice.items.length === 0 ? (
<Text style={styles.emptyLines}>
No line items yet. Clock time to this invoice from the Timer tab, or edit to
add lines manually.
</Text>
) : (
invoice.items.map((item) => {
const line = (
<View style={styles.lineItem}>
<View style={styles.lineMeta}>
<Text style={styles.lineDescription}>{item.description}</Text>
<Text style={styles.lineSub}>
{formatDate(item.date)} · {item.hours}h ×{" "}
{formatCurrency(item.rate, invoice.currency)}
</Text>
</View>
<Text style={styles.lineAmount}>
{formatCurrency(item.amount, invoice.currency)}
</Text>
</View>
);
if (invoice.status !== "draft") {
return <View key={item.id}>{line}</View>;
}
return (
<SwipeableRow
key={item.id}
backgroundColor={colors.card}
actions={[
{
key: "edit",
label: "Edit",
icon: "create-outline",
color: "#fff",
backgroundColor: colors.primary,
onPress: () => router.push(`/(app)/invoices/edit/${invoice.id}`),
},
]}
>
{line}
</SwipeableRow>
);
})
)}
<InvoiceTotals
subtotal={formatCurrency(subtotal, invoice.currency)}
taxLabel={invoice.taxRate > 0 ? `Tax (${invoice.taxRate}%)` : undefined}
taxAmount={
invoice.taxRate > 0 ? formatCurrency(taxAmount, invoice.currency) : undefined
}
total={formatCurrency(invoice.totalAmount, invoice.currency)}
/>
</Card>
{invoice.notes ? (
<Card title="Notes">
<Text style={styles.notes}>{invoice.notes}</Text>
</Card>
) : null}
<InvoiceDetailActions
status={status}
clientEmail={clientEmail}
onPaymentReminder={
status === "sent" || status === "overdue" ? promptPaymentReminder : undefined
}
paymentReminderLoading={sendPaymentReminder.isPending}
onUpdateStatus={() => promptStatusChange(status)}
updateStatusLoading={updateStatus.isPending}
onTrackTime={() =>
router.push(`/(app)/timer?clientId=${invoice.clientId}&invoiceId=${invoice.id}`)
}
/>
</>
)}
</ScrollView>
@@ -291,8 +347,12 @@ function DetailRow({ label, value }: { label: string; value: string }) {
const { colors } = useAppTheme();
return (
<View style={detailStyles.row}>
<Text style={[detailStyles.label, { color: colors.mutedForeground }]}>{label}</Text>
<Text style={[detailStyles.value, { color: colors.foreground }]}>{value}</Text>
<Text style={[detailStyles.label, { color: colors.mutedForeground }]}>
{label}
</Text>
<Text style={[detailStyles.value, { color: colors.foreground }]}>
{value}
</Text>
</View>
);
}
@@ -316,93 +376,93 @@ const detailStyles = StyleSheet.create({
const createInvoiceDetailStyles = (colors: ThemeColors, _isDark: boolean) =>
StyleSheet.create({
scroll: {
flex: 1,
},
container: {
padding: spacing.md,
gap: spacing.md,
},
headerRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-start",
gap: spacing.md,
},
headerMeta: {
flex: 1,
gap: 4,
},
invoiceNumber: {
fontSize: 22,
lineHeight: 26,
fontFamily: fonts.heading,
color: colors.foreground,
},
clientName: {
fontSize: 15,
fontFamily: fonts.body,
color: colors.mutedForeground,
},
total: {
marginTop: spacing.sm,
fontSize: 28,
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
},
lineItem: {
flexDirection: "row",
justifyContent: "space-between",
gap: spacing.md,
paddingVertical: spacing.sm,
borderTopWidth: 1,
borderTopColor: colors.border,
},
lineMeta: {
flex: 1,
gap: 2,
},
lineDescription: {
fontFamily: fonts.bodyMedium,
color: colors.foreground,
fontSize: 14,
},
lineSub: {
fontFamily: fonts.body,
color: colors.mutedForeground,
fontSize: 12,
},
lineAmount: {
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
fontSize: 14,
},
emptyLines: {
fontFamily: fonts.body,
fontSize: 14,
lineHeight: 20,
color: colors.mutedForeground,
},
notes: {
fontFamily: fonts.body,
color: colors.foreground,
fontSize: 14,
lineHeight: 20,
},
errorBox: {
flex: 1,
justifyContent: "center",
padding: spacing.lg,
gap: spacing.md,
},
errorTitle: {
fontSize: 18,
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
},
errorText: {
color: colors.mutedForeground,
fontFamily: fonts.body,
lineHeight: 20,
},
});
scroll: {
flex: 1,
},
container: {
padding: spacing.md,
gap: spacing.md,
},
headerRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-start",
gap: spacing.md,
},
headerMeta: {
flex: 1,
gap: 4,
},
invoiceNumber: {
fontSize: 22,
lineHeight: 26,
fontFamily: fonts.heading,
color: colors.foreground,
},
clientName: {
fontSize: 15,
fontFamily: fonts.body,
color: colors.mutedForeground,
},
total: {
marginTop: spacing.sm,
fontSize: 28,
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
},
lineItem: {
flexDirection: "row",
justifyContent: "space-between",
gap: spacing.md,
paddingVertical: spacing.sm,
borderTopWidth: 1,
borderTopColor: colors.border,
},
lineMeta: {
flex: 1,
gap: 2,
},
lineDescription: {
fontFamily: fonts.bodyMedium,
color: colors.foreground,
fontSize: 14,
},
lineSub: {
fontFamily: fonts.body,
color: colors.mutedForeground,
fontSize: 12,
},
lineAmount: {
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
fontSize: 14,
},
emptyLines: {
fontFamily: fonts.body,
fontSize: 14,
lineHeight: 20,
color: colors.mutedForeground,
},
notes: {
fontFamily: fonts.body,
color: colors.foreground,
fontSize: 14,
lineHeight: 20,
},
errorBox: {
flex: 1,
justifyContent: "center",
padding: spacing.lg,
gap: spacing.md,
},
errorTitle: {
fontSize: 18,
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
},
errorText: {
color: colors.mutedForeground,
fontFamily: fonts.body,
lineHeight: 20,
},
});
+124 -7
View File
@@ -16,6 +16,12 @@ import { LoadingScreen } from "@/components/LoadingScreen";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input";
import { DateTimeField } from "@/components/ui/DateTimeField";
import {
formatZonedDateTime,
getDefaultScheduledSendAt,
getLocalTimeZone,
} from "@beenvoice/domain/time-zone";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency, formatDate } from "@/lib/format";
@@ -33,6 +39,10 @@ export default function InvoiceSendScreen() {
const utils = api.useUtils();
const scrollPadding = useTabBarScrollPadding();
const [customMessage, setCustomMessage] = useState("");
const [scheduledAt, setScheduledAt] = useState(() =>
getDefaultScheduledSendAt(),
);
const timeZone = useMemo(() => getLocalTimeZone(), []);
const invoiceQuery = api.invoices.getById.useQuery(
{ id: id ?? "" },
@@ -51,9 +61,39 @@ export default function InvoiceSendScreen() {
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(
() =>
invoiceQuery.data ? buildPreviewPdfInputFromInvoice(invoiceQuery.data) : null,
invoiceQuery.data
? buildPreviewPdfInputFromInvoice(invoiceQuery.data)
: null,
[invoiceQuery.data],
);
@@ -97,22 +137,49 @@ export default function InvoiceSendScreen() {
});
}
function handleSchedule() {
if (!clientEmail || invoice.items.length === 0) return;
if (scheduledAt.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,
timeZone,
customMessage: customMessage.trim() || undefined,
});
}
return (
<AppBackground>
<Stack.Screen options={{ title: sendLabel, headerBackTitle: "Invoice" }} />
<Stack.Screen
options={{ title: sendLabel, headerBackTitle: "Invoice" }}
/>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
style={styles.flex}
>
<ScrollView
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : undefined}
contentContainerStyle={[
styles.container,
{ paddingBottom: scrollPadding },
]}
contentInsetAdjustmentBehavior={
Platform.OS === "ios" ? "automatic" : undefined
}
scrollIndicatorInsets={{ bottom: scrollPadding }}
keyboardShouldPersistTaps="handled"
>
<Card title="Email summary">
<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
label="Invoice"
value={`${invoice.invoicePrefix}${invoice.invoiceNumber}`}
@@ -130,7 +197,9 @@ export default function InvoiceSendScreen() {
</Card>
<Card title="Message">
<Text style={[styles.messageHint, { color: colors.mutedForeground }]}>
<Text
style={[styles.messageHint, { color: colors.mutedForeground }]}
>
Optional note included in the email body.
</Text>
<Input
@@ -143,6 +212,52 @@ export default function InvoiceSendScreen() {
/>
</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
title={sendLabel}
onPress={handleSend}
@@ -176,7 +291,9 @@ function SummaryRow({
const { colors } = useAppTheme();
return (
<View style={summaryStyles.row}>
<Text style={[summaryStyles.label, { color: colors.mutedForeground }]}>{label}</Text>
<Text style={[summaryStyles.label, { color: colors.mutedForeground }]}>
{label}
</Text>
<Text
style={[
summaryStyles.value,
+41 -33
View File
@@ -9,15 +9,15 @@ Web application and API for **beenvoice** — invoicing for freelancers and smal
## Stack
| Layer | Technology |
|-------|------------|
| App | Next.js 16 App Router, React 19 |
| API | tRPC 11 + SuperJSON |
| Database | PostgreSQL 17, Drizzle ORM |
| Auth | better-auth (email/password, optional Authentik OIDC, Expo mobile) |
| UI | shadcn/ui, Tailwind CSS v4 |
| Email / PDF | Resend, `@react-pdf/renderer` |
| Runtime | Bun |
| Layer | Technology |
| ----------- | ------------------------------------------------------------------ |
| App | Next.js 16 App Router, React 19 |
| API | tRPC 11 + SuperJSON |
| Database | PostgreSQL 17, Drizzle ORM |
| Auth | better-auth (email/password, optional Authentik OIDC, Expo mobile) |
| UI | shadcn/ui, Tailwind CSS v4 |
| Email / PDF | Resend, `@react-pdf/renderer` |
| Runtime | Bun |
## Features
@@ -154,6 +154,14 @@ service is required.
"schedule now" hook. It only enqueues due work; invoice generation stays in the
worker.
### 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
```bash
@@ -162,12 +170,12 @@ git pull
# or: docker compose up -d --build
```
| Command | New code? | Migrations run? |
|---------|-----------|-----------------|
| `git pull` only | No | No |
| `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 |
| `docker compose restart app` | No | Yes — migrate runs again (no-op if up to date) |
| Command | New code? | Migrations run? |
| ----------------------------------------------------------------------- | ----------------------------- | ----------------------------------------------- |
| `git pull` only | No | No |
| `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 |
| `docker compose restart app` | No | Yes — migrate runs again (no-op if up to date) |
Prune old app and worker images occasionally: `docker image prune -f` (or remove specific `beenvoice:*` / `beenvoice-worker:*` tags).
@@ -189,12 +197,12 @@ Use the literal strings `true` or `false` (or omit the variable). Do not rely on
### 5. Optional services
| Variable | Purpose |
|----------|---------|
| `RESEND_API_KEY`, `RESEND_DOMAIN` | Invoice and password-reset email |
| Variable | Purpose |
| -------------------------------------------------------------------- | -------------------------------------------------------------------- |
| `RESEND_API_KEY`, `RESEND_DOMAIN` | Invoice and password-reset email |
| `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` |
| `DISABLE_SIGNUPS=true` | Block new registrations |
| `CRON_SECRET` | Protects `/api/cron/generate-recurring` |
| `DISABLE_SIGNUPS=true` | Block new registrations |
## Project structure
@@ -244,13 +252,13 @@ Full-stack deploy uses `bun run docker:deploy` or `../../scripts/docker-deploy.s
## API surface
| Endpoint | Auth | Purpose |
|----------|------|---------|
| `/api/trpc` | Session cookie or API key | Primary API (web + mobile) |
| `/api/auth/*` | Varies | better-auth + custom register/reset REST |
| `/api/mcp` | API key only | JSON-RPC automation tools |
| `/i/[token]` | Public token | Client invoice view |
| `/api/i/[token]/pdf` | Public token | Invoice PDF download |
| Endpoint | Auth | Purpose |
| -------------------- | ------------------------- | ---------------------------------------- |
| `/api/trpc` | Session cookie or API key | Primary API (web + mobile) |
| `/api/auth/*` | Varies | better-auth + custom register/reset REST |
| `/api/mcp` | API key only | JSON-RPC automation tools |
| `/i/[token]` | Public token | Client invoice view |
| `/api/i/[token]/pdf` | Public token | Invoice PDF download |
Business logic lives in `src/server/api/routers/` with Zod validation.
@@ -263,12 +271,12 @@ Business logic lives in `src/server/api/routers/` with Zod validation.
## Documentation
| Doc | Contents |
|-----|----------|
| [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/README.md](./docs/README.md) | Index of UI and product guides |
| [AGENTS.md](./AGENTS.md) | Conventions for AI-assisted development |
| Doc | Contents |
| ---------------------------------------------- | ------------------------------------------ |
| [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/README.md](./docs/README.md) | Index of UI and product guides |
| [AGENTS.md](./AGENTS.md) | Conventions for AI-assisted development |
## License
+61 -61
View File
@@ -6,15 +6,15 @@ This application is the server and browser workspace in the Beenvoice monorepo.
## Stack
| Layer | Technology |
|-------|------------|
| Framework | Next.js 16 App Router (`src/app/`) |
| API | tRPC 11 (`/api/trpc`), SuperJSON transformer |
| ORM | Drizzle + `pg` pool |
| Auth | better-auth (email/password, optional Authentik OIDC, Expo plugin for mobile) |
| UI | shadcn/ui, Tailwind CSS v4, Radix primitives |
| Email | Resend |
| PDF | `@react-pdf/renderer` |
| Layer | Technology |
| --------- | ----------------------------------------------------------------------------- |
| Framework | Next.js 16 App Router (`src/app/`) |
| API | tRPC 11 (`/api/trpc`), SuperJSON transformer |
| ORM | Drizzle + `pg` pool |
| Auth | better-auth (email/password, optional Authentik OIDC, Expo plugin for mobile) |
| UI | shadcn/ui, Tailwind CSS v4, Radix primitives |
| Email | Resend |
| PDF | `@react-pdf/renderer` |
## Request flow
@@ -69,20 +69,20 @@ drizzle/ # SQL migrations (00000014+)
Root: `src/server/api/root.ts`. All routers use Zod input validation.
| Namespace | File | Key procedures |
|-----------|------|----------------|
| `clients` | `routers/clients.ts` | getAll, getById, create, update, delete |
| `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 |
| `payments` | `routers/payments.ts` | getByInvoice, create, delete |
| `expenses` | `routers/expenses.ts` | getAll, getById, create, update, delete |
| `invoiceTemplates` | `routers/invoiceTemplates.ts` | CRUD by template type |
| `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 |
| `dashboard` | `routers/dashboard.ts` | getStats |
| `email` | `routers/email.ts` | sendInvoice |
| `settings` | `routers/settings.ts` | profile, theme, animation prefs, export/import data, admin account roles |
| `apiKeys` | `routers/apiKeys.ts` | list, create, revoke (session-only) |
| Namespace | File | Key procedures |
| ------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `clients` | `routers/clients.ts` | getAll, getById, create, update, delete |
| `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 |
| `payments` | `routers/payments.ts` | getByInvoice, create, delete |
| `expenses` | `routers/expenses.ts` | getAll, getById, create, update, delete |
| `invoiceTemplates` | `routers/invoiceTemplates.ts` | CRUD by template type |
| `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 |
| `dashboard` | `routers/dashboard.ts` | getStats |
| `email` | `routers/email.ts` | sendInvoice |
| `settings` | `routers/settings.ts` | profile, theme, animation prefs, export/import data, admin account roles |
| `apiKeys` | `routers/apiKeys.ts` | list, create, revoke (session-only) |
### Time clock semantics
@@ -97,30 +97,30 @@ Single file: `src/server/db/schema.ts`. Table names use `pgTableCreator` → pre
### Auth & platform
| Table | Notes |
|-------|-------|
| `beenvoice_user` | Core user; role for admin features |
| `beenvoice_account` | OAuth/credential accounts (better-auth) |
| `beenvoice_session` | Sessions; unique token |
| `beenvoice_verification_token` | Email verification / reset |
| `beenvoice_api_key` | `bv_` prefix keys; SHA-256 hash stored |
| `beenvoice_sso_provider` | OIDC/SAML config per user |
| `beenvoice_platform_setting` | Singleton (`id = global`) branding/PDF/appearance |
| Table | Notes |
| ------------------------------ | ------------------------------------------------- |
| `beenvoice_user` | Core user; role for admin features |
| `beenvoice_account` | OAuth/credential accounts (better-auth) |
| `beenvoice_session` | Sessions; unique token |
| `beenvoice_verification_token` | Email verification / reset |
| `beenvoice_api_key` | `bv_` prefix keys; SHA-256 hash stored |
| `beenvoice_sso_provider` | OIDC/SAML config per user |
| `beenvoice_platform_setting` | Singleton (`id = global`) branding/PDF/appearance |
### Domain
| Table | FKs | Notes |
|-------|-----|-------|
| `beenvoice_client` | `createdById` → user | defaultHourlyRate, currency |
| `beenvoice_business` | `createdById` | Resend config, `isDefault` |
| `beenvoice_invoice` | client, business?, user | status draft/sent/paid; `publicToken` |
| `beenvoice_invoice_item` | invoice (cascade) | position ordering |
| `beenvoice_invoice_payment` | invoice, user | payment method enum |
| `beenvoice_expense` | business?, client?, invoice? | billable flags |
| `beenvoice_invoice_template` | user | notes/terms templates |
| `beenvoice_recurring_invoice` | client, business?, user | schedule, `nextDueAt` |
| `beenvoice_recurring_invoice_item` | recurring (cascade) | |
| `beenvoice_time_entry` | client?, invoice?, user | `endedAt` null = running |
| Table | FKs | Notes |
| ---------------------------------- | ---------------------------- | ------------------------------------- |
| `beenvoice_client` | `createdById` → user | defaultHourlyRate, currency |
| `beenvoice_business` | `createdById` | Resend config, `isDefault` |
| `beenvoice_invoice` | client, business?, user | status draft/sent/paid; `publicToken` |
| `beenvoice_invoice_item` | invoice (cascade) | position ordering |
| `beenvoice_invoice_payment` | invoice, user | payment method enum |
| `beenvoice_expense` | business?, client?, invoice? | billable flags |
| `beenvoice_invoice_template` | user | notes/terms templates |
| `beenvoice_recurring_invoice` | client, business?, user | schedule, `nextDueAt` |
| `beenvoice_recurring_invoice_item` | recurring (cascade) | |
| `beenvoice_time_entry` | client?, invoice?, user | `endedAt` null = running |
Migrations: `bun run db:generate``drizzle/`; apply with `db:push` (dev) or `db:migrate` (prod script).
@@ -166,27 +166,27 @@ API keys: format `bv_<base64url>`; stored as SHA-256 hash (`src/server/api/api-k
Validated in `src/env.js`. See `.env.example`.
| Variable | Required | Notes |
|----------|----------|-------|
| `DATABASE_URL` | yes | PostgreSQL connection string |
| `AUTH_SECRET` | prod | `openssl rand -base64 32` |
| `BETTER_AUTH_URL` | yes | Public URL of API (no trailing path) |
| `NEXT_PUBLIC_APP_URL` | yes | Browser-facing URL |
| `DB_DISABLE_SSL` | local | `true` for Docker dev DB |
| `RESEND_API_KEY`, `RESEND_DOMAIN` | optional | Email; blank disables send |
| `AUTHENTIK_*` | optional | OIDC SSO |
| `DISABLE_SIGNUPS` | optional | `true` blocks registration; use string `true`/`false` (parsed in `src/env.js`) |
| `CRON_SECRET` | cron route | Protects `/api/cron/generate-recurring` |
| `NEXT_PUBLIC_BRAND_*` | optional | Build-time white-label defaults |
| Variable | Required | Notes |
| --------------------------------- | -------------------- | ------------------------------------------------------------------------------ |
| `DATABASE_URL` | yes | PostgreSQL connection string |
| `AUTH_SECRET` | prod | `openssl rand -base64 32` |
| `BETTER_AUTH_URL` | yes | Public URL of API (no trailing path) |
| `NEXT_PUBLIC_APP_URL` | yes | Browser-facing URL |
| `DB_DISABLE_SSL` | local | `true` for Docker dev DB |
| `RESEND_API_KEY`, `RESEND_DOMAIN` | optional | Email; blank disables send |
| `AUTHENTIK_*` | optional | OIDC SSO |
| `DISABLE_SIGNUPS` | optional | `true` blocks registration; use string `true`/`false` (parsed in `src/env.js`) |
| `CRON_SECRET` | worker / cron routes | Protects worker delivery and `/api/cron/generate-recurring` |
| `NEXT_PUBLIC_BRAND_*` | optional | Build-time white-label defaults |
## Docker
| File | Use |
|------|-----|
| Root `docker-compose.yml` | Deploy: `app` + `worker` + `db` + Garage; use `apps/web/.env` |
| Root `docker-compose.dev.yml` | Local dev: Postgres + Garage |
| File | Use |
| ----------------------------- | ------------------------------------------------------------- |
| Root `docker-compose.yml` | Deploy: `app` + `worker` + `db` + Garage; use `apps/web/.env` |
| Root `docker-compose.dev.yml` | Local dev: Postgres + Garage |
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, and schedules recurring invoice generation without Redis or an external cron. 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.
@@ -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");
+7
View File
@@ -211,6 +211,13 @@
"when": 1786941568000,
"tag": "0029_background_jobs",
"breakpoints": true
},
{
"idx": 30,
"version": "7",
"when": 1786946793000,
"tag": "0030_scheduled_invoice_sends",
"breakpoints": true
}
]
}
@@ -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 });
}
}
+240 -58
View File
@@ -97,7 +97,9 @@ const expenseCreateSchema = z.object({
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({
description: z.string().min(1),
@@ -152,7 +154,14 @@ const jsonSchemas = {
date: { type: "string", format: "date-time" },
method: {
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 },
},
@@ -238,7 +247,20 @@ const jsonSchemas = {
description: { type: "string", minLength: 1 },
amount: { type: "number", minimum: 0 },
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" },
reimbursable: { type: "boolean" },
taxDeductible: { type: "boolean" },
@@ -267,7 +289,10 @@ const jsonSchemas = {
name: { type: "string", minLength: 1, maxLength: 255 },
clientId: { type: "string", minLength: 1 },
businessId: { type: "string" },
schedule: { type: "string", enum: ["weekly", "biweekly", "monthly", "quarterly", "yearly"] },
schedule: {
type: "string",
enum: ["weekly", "biweekly", "monthly", "quarterly", "yearly"],
},
invoicePrefix: { type: "string" },
taxRate: { type: "number", minimum: 0, maximum: 100 },
currency: { type: "string", minLength: 3, maxLength: 3 },
@@ -298,15 +323,50 @@ const jsonSchemas = {
invoiceId: { type: "string" },
customSubject: { type: "string" },
customMessage: { type: "string" },
ccEmails: { type: "string", description: "Comma-separated CC email addresses" },
bccEmails: { type: "string", description: "Comma-separated BCC email addresses" },
ccEmails: {
type: "string",
description: "Comma-separated CC email addresses",
},
bccEmails: {
type: "string",
description: "Comma-separated BCC email addresses",
},
},
required: ["invoiceId"],
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: {
type: "object",
properties: { ids: { type: "array", items: { type: "string" }, minItems: 1 } },
properties: {
ids: { type: "array", items: { type: "string" }, minItems: 1 },
},
required: ["ids"],
additionalProperties: false,
},
@@ -368,19 +428,27 @@ function textResult(data: unknown): ToolResult {
const tools = {
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: {
type: "object",
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" },
},
additionalProperties: false,
},
schema: z.object({
status: z.enum(["draft", "sent", "paid"]).optional(),
clientId: z.string().optional(),
}).optional().default({}),
schema: z
.object({
status: z.enum(["draft", "sent", "paid"]).optional(),
clientId: z.string().optional(),
})
.optional()
.default({}),
handler: async (input, caller) => caller.invoices.getAll(input ?? {}),
}),
invoices_get: defineTool({
@@ -418,7 +486,9 @@ const tools = {
issueDate: input.issueDate
? parseDate(input.issueDate, "issueDate")
: undefined,
dueDate: input.dueDate ? parseDate(input.dueDate, "dueDate") : undefined,
dueDate: input.dueDate
? parseDate(input.dueDate, "dueDate")
: undefined,
items: input.items ? parseInvoiceItems(input.items) : undefined,
}),
}),
@@ -485,7 +555,10 @@ const tools = {
inputSchema: {
...jsonSchemas.clientCreate,
required: ["id"],
properties: { id: { type: "string" }, ...jsonSchemas.clientCreate.properties },
properties: {
id: { type: "string" },
...jsonSchemas.clientCreate.properties,
},
},
schema: clientCreateSchema.partial().extend({ id: z.string() }),
handler: async (input, caller) => caller.clients.update(input),
@@ -521,7 +594,8 @@ const tools = {
handler: async (input, caller) => caller.businesses.create(input),
}),
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: {
...jsonSchemas.businessCreate,
required: ["id", "name"],
@@ -553,9 +627,18 @@ const tools = {
properties: {
description: { type: "string", maxLength: 500 },
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 },
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,
},
@@ -569,7 +652,9 @@ const tools = {
handler: async (input, caller) =>
caller.timeEntries.clockIn({
...input,
startedAt: input.startedAt ? parseDate(input.startedAt, "startedAt") : undefined,
startedAt: input.startedAt
? parseDate(input.startedAt, "startedAt")
: undefined,
}),
}),
time_clock_out: defineTool({
@@ -588,8 +673,13 @@ const tools = {
handler: async (input, caller) => caller.timeEntries.clockOut(input),
}),
time_get_running: defineTool({
description: "Get the currently running timer, if any. Returns null if no timer is running.",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
description:
"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({}),
handler: async (_input, caller) => caller.timeEntries.getRunning(),
}),
@@ -646,11 +736,14 @@ const tools = {
caller.timeEntries.create({
...input,
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({
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: {
type: "object",
properties: {
@@ -679,8 +772,12 @@ const tools = {
handler: async (input, caller) =>
caller.timeEntries.update({
...input,
startedAt: input.startedAt ? parseDate(input.startedAt, "startedAt") : undefined,
endedAt: input.endedAt ? parseDate(input.endedAt, "endedAt") : undefined,
startedAt: input.startedAt
? parseDate(input.startedAt, "startedAt")
: undefined,
endedAt: input.endedAt
? parseDate(input.endedAt, "endedAt")
: undefined,
}),
}),
time_entries_delete: defineTool({
@@ -712,7 +809,8 @@ const tools = {
}),
// ── Expenses ────────────────────────────────────────────────────────────────
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,
schema: z.object({}).optional().default({}),
handler: async (_input, caller) => caller.expenses.getAll(),
@@ -724,7 +822,8 @@ const tools = {
handler: async (input, caller) => caller.expenses.getById(input),
}),
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,
schema: expenseCreateSchema,
handler: async (input, caller) =>
@@ -734,11 +833,15 @@ const tools = {
}),
}),
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: {
...jsonSchemas.expenseCreate,
required: ["id"],
properties: { id: { type: "string" }, ...jsonSchemas.expenseCreate.properties },
properties: {
id: { type: "string" },
...jsonSchemas.expenseCreate.properties,
},
},
schema: expenseUpdateSchema,
handler: async (input, caller) =>
@@ -756,13 +859,15 @@ const tools = {
// ── Recurring Invoices ───────────────────────────────────────────────────────
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,
schema: z.object({}).optional().default({}),
handler: async (_input, caller) => caller.recurringInvoices.getAll(),
}),
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,
schema: recurringCreateSchema,
handler: async (input, caller) => caller.recurringInvoices.create(input),
@@ -772,13 +877,17 @@ const tools = {
inputSchema: {
...jsonSchemas.recurringCreate,
required: ["id", "name", "clientId", "schedule", "items"],
properties: { id: { type: "string" }, ...jsonSchemas.recurringCreate.properties },
properties: {
id: { type: "string" },
...jsonSchemas.recurringCreate.properties,
},
},
schema: recurringUpdateSchema,
handler: async (input, caller) => caller.recurringInvoices.update(input),
}),
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,
schema: z.object({ id: z.string() }),
handler: async (input, caller) => caller.recurringInvoices.pause(input),
@@ -790,10 +899,12 @@ const tools = {
handler: async (input, caller) => caller.recurringInvoices.resume(input),
}),
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,
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({
description: "Delete a recurring invoice template by ID.",
@@ -804,7 +915,8 @@ const tools = {
// ── Dashboard ────────────────────────────────────────────────────────────────
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,
schema: z.object({}).optional().default({}),
handler: async (_input, caller) => caller.dashboard.getStats(),
@@ -812,13 +924,15 @@ const tools = {
// ── Invoice extras ───────────────────────────────────────────────────────────
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,
schema: z.object({}).optional().default({}),
handler: async (_input, caller) => caller.invoices.getCurrentOpen(),
}),
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,
schema: z.object({
invoiceId: z.string(),
@@ -827,15 +941,44 @@ const tools = {
ccEmails: 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({
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: {
type: "object",
properties: {
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"],
additionalProperties: false,
@@ -844,17 +987,26 @@ const tools = {
handler: async (input, caller) => caller.invoices.sendReminder(input),
}),
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: {
type: "object",
properties: {
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"],
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) => {
const result = await caller.invoices.generatePublicToken(input);
const base = getAppUrl();
@@ -866,7 +1018,8 @@ const tools = {
},
}),
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,
schema: z.object({ id: z.string() }),
handler: async (input, caller) => caller.invoices.revokePublicToken(input),
@@ -889,13 +1042,15 @@ const tools = {
// ── Invoice Templates ────────────────────────────────────────────────────────
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,
schema: z.object({}).optional().default({}),
handler: async (_input, caller) => caller.invoiceTemplates.getAll(),
}),
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: {
type: "object",
properties: { type: { type: "string", enum: ["notes", "terms"] } },
@@ -906,7 +1061,8 @@ const tools = {
handler: async (input, caller) => caller.invoiceTemplates.getByType(input),
}),
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: {
type: "object",
properties: {
@@ -958,20 +1114,31 @@ const tools = {
// ── Business email config ─────────────────────────────────────────────────────
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,
schema: z.object({ id: z.string() }),
handler: async (input, caller) => caller.businesses.getEmailConfig(input),
}),
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: {
type: "object",
properties: {
id: { type: "string" },
resendApiKey: { type: "string", 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" },
resendApiKey: {
type: "string",
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"],
additionalProperties: false,
@@ -982,12 +1149,14 @@ const tools = {
resendDomain: 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 ──────────────────────────────────────────────────────────────
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,
schema: z.object({}).optional().default({}),
handler: async (_input, caller) => caller.settings.getProfile(),
@@ -1045,7 +1214,12 @@ async function handleMcpRequest(request: Request) {
const ctx = await createTRPCContext({ headers: request.headers });
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") {
@@ -1084,7 +1258,12 @@ async function handleMcpRequest(request: Request) {
const tool = tools[params.data.name as keyof typeof tools];
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 ?? {});
@@ -1100,7 +1279,10 @@ async function handleMcpRequest(request: Request) {
try {
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) {
return rpcError(body.id, -32000, getErrorMessage(error), 500);
}
+177 -53
View File
@@ -4,6 +4,7 @@ import {
AlertTriangle,
Bell,
Building,
CalendarClock,
Check,
Copy,
DollarSign,
@@ -19,8 +20,14 @@ import {
Trash2,
User,
} from "lucide-react";
import { formatZonedDateTime } from "@beenvoice/domain/time-zone";
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 { toast } from "sonner";
import { StatusBadge } from "~/components/data/status-badge";
@@ -194,12 +201,16 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
if (!invoice) notFound();
const formatDate = (date: Date) =>
new Intl.DateTimeFormat("en-US", { year: "numeric", month: "short", day: "numeric" }).format(
new Date(date),
);
new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "short",
day: "numeric",
}).format(new Date(date));
const formatCurrency = (amount: number, currency = invoice.currency) =>
new Intl.NumberFormat("en-US", { style: "currency", currency }).format(amount);
new Intl.NumberFormat("en-US", { style: "currency", currency }).format(
amount,
);
const subtotal = invoice.items.reduce((s, i) => s + i.amount, 0);
const taxAmount = (subtotal * invoice.taxRate) / 100;
@@ -207,9 +218,13 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
const totalPaid = (payments ?? []).reduce((s, p) => s + p.amount, 0);
const balanceDue = total - totalPaid;
const storedStatus = invoice.status as StoredInvoiceStatus;
const effectiveStatus = getEffectiveInvoiceStatus(storedStatus, invoice.dueDate);
const effectiveStatus = getEffectiveInvoiceStatus(
storedStatus,
invoice.dueDate,
);
const isOverdue = isInvoiceOverdue(storedStatus, invoice.dueDate);
const canSendReminder = effectiveStatus === "sent" || effectiveStatus === "overdue";
const canSendReminder =
effectiveStatus === "sent" || effectiveStatus === "overdue";
const publicUrl = invoice.publicToken
? `${window.location.origin}/i/${invoice.publicToken}`
@@ -232,7 +247,9 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
invoiceId,
amount,
date: new Date(),
method: paymentMethod as Parameters<typeof createPayment.mutate>[0]["method"],
method: paymentMethod as Parameters<
typeof createPayment.mutate
>[0]["method"],
notes: paymentNotes || undefined,
});
};
@@ -243,7 +260,11 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
title="Invoice Details"
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" ? (
<Button asChild variant="default" className="hover-lift">
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
@@ -270,15 +291,21 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
<StatusBadge status={effectiveStatus} />
</div>
<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-['_•_']">
Due {formatDate(invoice.dueDate)}
</div>
</div>
</div>
<div className="flex-shrink-0 text-left sm:text-right">
<p className="text-muted-foreground text-sm">Total Amount</p>
<p className="text-primary text-3xl font-bold">{formatCurrency(total)}</p>
<p className="text-muted-foreground text-sm">
Total Amount
</p>
<p className="text-primary text-3xl font-bold">
{formatCurrency(total)}
</p>
{totalPaid > 0 && balanceDue > 0 && (
<p className="text-muted-foreground mt-0.5 text-sm">
Balance due: {formatCurrency(balanceDue)}
@@ -300,7 +327,8 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
<p className="font-medium">Invoice Overdue</p>
<p className="text-sm">
{Math.ceil(
(new Date().getTime() - new Date(invoice.dueDate).getTime()) /
(new Date().getTime() -
new Date(invoice.dueDate).getTime()) /
(1000 * 60 * 60 * 24),
)}{" "}
days past due date
@@ -321,14 +349,18 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
</CardTitle>
</CardHeader>
<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">
{invoice.client.email && (
<div className="flex items-center gap-3">
<div className="bg-primary/10 p-2">
<Mail className="text-primary h-4 w-4" />
</div>
<span className="text-sm break-all">{invoice.client.email}</span>
<span className="text-sm break-all">
{invoice.client.email}
</span>
</div>
)}
{invoice.client.phone && (
@@ -345,8 +377,12 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
<MapPin className="text-primary h-4 w-4" />
</div>
<div className="space-y-1 text-sm">
{invoice.client.addressLine1 && <div>{invoice.client.addressLine1}</div>}
{invoice.client.addressLine2 && <div>{invoice.client.addressLine2}</div>}
{invoice.client.addressLine1 && (
<div>{invoice.client.addressLine1}</div>
)}
{invoice.client.addressLine2 && (
<div>{invoice.client.addressLine2}</div>
)}
{(invoice.client.city ??
invoice.client.state ??
invoice.client.postalCode) && (
@@ -360,7 +396,9 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
.join(", ")}
</div>
)}
{invoice.client.country && <div>{invoice.client.country}</div>}
{invoice.client.country && (
<div>{invoice.client.country}</div>
)}
</div>
</div>
)}
@@ -378,7 +416,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
</CardHeader>
<CardContent className="space-y-4">
{invoice.business.logoStorageKey && (
<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 */}
<img
src={`/api/business-logo/${invoice.business.id}`}
@@ -396,7 +434,9 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
<div className="bg-primary/10 p-2">
<Mail className="text-primary h-4 w-4" />
</div>
<span className="text-sm break-all">{invoice.business.email}</span>
<span className="text-sm break-all">
{invoice.business.email}
</span>
</div>
)}
{invoice.business.phone && (
@@ -404,7 +444,9 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
<div className="bg-primary/10 p-2">
<Phone className="text-primary h-4 w-4" />
</div>
<span className="text-sm">{invoice.business.phone}</span>
<span className="text-sm">
{invoice.business.phone}
</span>
</div>
)}
</div>
@@ -437,7 +479,9 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
<span className="whitespace-nowrap">
{item.hours.toString()}&nbsp;hours
</span>
<span className="whitespace-nowrap">@&nbsp;${item.rate}/hr</span>
<span className="whitespace-nowrap">
@&nbsp;${item.rate}/hr
</span>
</div>
</div>
<p className="text-primary flex-shrink-0 self-start text-lg font-semibold">
@@ -449,15 +493,21 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
))}
{/* 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">
<span className="text-muted-foreground">Subtotal:</span>
<span className="font-medium">{formatCurrency(subtotal)}</span>
<span className="font-medium">
{formatCurrency(subtotal)}
</span>
</div>
{invoice.taxRate > 0 && (
<div className="flex flex-wrap justify-between gap-x-4 gap-y-1">
<span className="text-muted-foreground">Tax ({invoice.taxRate}%):</span>
<span className="font-medium">{formatCurrency(taxAmount)}</span>
<span className="text-muted-foreground">
Tax ({invoice.taxRate}%):
</span>
<span className="font-medium">
{formatCurrency(taxAmount)}
</span>
</div>
)}
<Separator />
@@ -469,14 +519,18 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
<>
<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-green-600 font-medium">
<span className="font-medium text-green-600">
{formatCurrency(totalPaid)}
</span>
</div>
<Separator />
<div className="flex flex-wrap justify-between gap-x-4 gap-y-1 font-bold">
<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))}
</span>
</div>
@@ -508,7 +562,9 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
{paymentsLoading ? (
<p className="text-muted-foreground text-sm">Loading</p>
) : (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">
{(payments ?? []).map((p) => (
@@ -517,11 +573,17 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
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">
<span className="font-semibold">{formatCurrency(p.amount)}</span>
<Badge variant="secondary">{methodLabel(p.method)}</Badge>
<span className="text-muted-foreground">{formatDate(p.date)}</span>
<span className="font-semibold">
{formatCurrency(p.amount)}
</span>
<Badge variant="secondary">
{methodLabel(p.method)}
</Badge>
<span className="text-muted-foreground">
{formatDate(p.date)}
</span>
{p.notes && (
<span className="text-muted-foreground truncate max-w-[200px]">
<span className="text-muted-foreground max-w-[200px] truncate">
{p.notes}
</span>
)}
@@ -529,7 +591,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
<Button
size="sm"
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 })}
disabled={deletePayment.isPending}
>
@@ -549,7 +611,9 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
<CardTitle>Notes</CardTitle>
</CardHeader>
<CardContent>
<p className="text-foreground whitespace-pre-wrap">{invoice.notes}</p>
<p className="text-foreground whitespace-pre-wrap">
{invoice.notes}
</p>
</CardContent>
</Card>
)}
@@ -557,8 +621,39 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
{/* Right Column - Actions */}
<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" && (
<InvoiceTimerCard invoiceId={invoiceId} clientId={invoice.clientId} />
<InvoiceTimerCard
invoiceId={invoiceId}
clientId={invoice.clientId}
/>
)}
<Card className="lg:sticky lg:top-6">
@@ -579,7 +674,11 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
) : null}
{invoice.items && invoice.client && (
<PDFDownloadButton invoiceId={invoice.id} className="w-full" variant="secondary" />
<PDFDownloadButton
invoiceId={invoice.id}
className="w-full"
variant="secondary"
/>
)}
{effectiveStatus === "draft" && (
@@ -604,12 +703,16 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
})
}
onClear={() =>
updateInvoice.mutate({ id: invoiceId, sendReminderAt: null })
updateInvoice.mutate({
id: invoiceId,
sendReminderAt: null,
})
}
/>
)}
{(effectiveStatus === "sent" || effectiveStatus === "overdue") && (
{(effectiveStatus === "sent" ||
effectiveStatus === "overdue") && (
<EnhancedSendInvoiceButton
invoiceId={invoice.id}
className="w-full"
@@ -632,7 +735,10 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
{invoice.lastReminderSentAt && (
<p className="text-muted-foreground mt-1 text-center text-xs">
Last sent {daysSince(invoice.lastReminderSentAt)} day
{daysSince(invoice.lastReminderSentAt) === 1 ? "" : "s"} ago
{daysSince(invoice.lastReminderSentAt) === 1
? ""
: "s"}{" "}
ago
</p>
)}
</div>
@@ -655,7 +761,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0 shrink-0"
className="h-6 w-6 shrink-0 p-0"
onClick={handleCopyLink}
>
{copied ? (
@@ -669,7 +775,9 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
variant="outline"
size="sm"
className="text-destructive hover:bg-destructive/10 w-full"
onClick={() => revokePublicToken.mutate({ id: invoiceId })}
onClick={() =>
revokePublicToken.mutate({ id: invoiceId })
}
disabled={revokePublicToken.isPending}
>
<Link2Off className="mr-1.5 h-3.5 w-3.5" />
@@ -679,13 +787,15 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
) : (
<>
<p className="text-muted-foreground text-xs">
Generate a shareable link your client can use to view this invoice without
logging in.
Generate a shareable link your client can use to view
this invoice without logging in.
</p>
<Button
size="sm"
className="w-full"
onClick={() => generatePublicToken.mutate({ id: invoiceId })}
onClick={() =>
generatePublicToken.mutate({ id: invoiceId })
}
disabled={generatePublicToken.isPending}
>
{generatePublicToken.isPending ? (
@@ -701,9 +811,12 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
</Popover>
{/* Mark as Paid */}
{(effectiveStatus === "sent" || effectiveStatus === "overdue") && (
{(effectiveStatus === "sent" ||
effectiveStatus === "overdue") && (
<Button
onClick={() => updateStatus.mutate({ id: invoiceId, status: "paid" })}
onClick={() =>
updateStatus.mutate({ id: invoiceId, status: "paid" })
}
disabled={updateStatus.isPending}
variant="secondary"
className="w-full"
@@ -779,10 +892,16 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setRecordPaymentOpen(false)}>
<Button
variant="outline"
onClick={() => setRecordPaymentOpen(false)}
>
Cancel
</Button>
<Button onClick={handleRecordPayment} disabled={createPayment.isPending}>
<Button
onClick={handleRecordPayment}
disabled={createPayment.isPending}
>
{createPayment.isPending ? "Saving…" : "Record Payment"}
</Button>
</DialogFooter>
@@ -823,9 +942,13 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
disabled={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>
</DialogFooter>
@@ -838,8 +961,9 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
<DialogHeader>
<DialogTitle>Delete Invoice</DialogTitle>
<DialogDescription>
Are you sure you want to delete invoice <strong>{invoice.invoiceNumber}</strong>?
This action cannot be undone.
Are you sure you want to delete invoice{" "}
<strong>{invoice.invoiceNumber}</strong>? This action cannot be
undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
@@ -8,6 +8,13 @@ import { Badge } from "~/components/ui/badge";
import { Separator } from "~/components/ui/separator";
import { Alert, AlertDescription } from "~/components/ui/alert";
import { Label } from "~/components/ui/label";
import { Input } from "~/components/ui/input";
import {
formatZonedDateTime,
getDefaultScheduledSendAt,
getLocalTimeZone,
toLocalDateTimeInputValue,
} from "@beenvoice/domain/time-zone";
import {
Dialog,
DialogContent,
@@ -44,6 +51,7 @@ import {
ArrowLeft,
Loader2,
FileText,
CalendarClock,
} from "lucide-react";
function SendEmailPageSkeleton() {
@@ -54,7 +62,9 @@ function SendEmailPageSkeleton() {
description="Loading invoice email"
/>
<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>
<div className={cn(dashboardGapClass, "flex flex-col")}>
@@ -101,6 +111,9 @@ export default function SendEmailPage() {
const [isSending, setIsSending] = useState(false);
const [isInitialized, setIsInitialized] = useState(false);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [showScheduleDialog, setShowScheduleDialog] = useState(false);
const [scheduledAt, setScheduledAt] = useState("");
const [minimumScheduledAt, setMinimumScheduledAt] = useState("");
const [retryCount, setRetryCount] = useState(0);
// Email content state
@@ -118,6 +131,7 @@ export default function SendEmailPage() {
// Get utils for cache invalidation
const utils = api.useUtils();
const timeZone = useMemo(() => getLocalTimeZone(), []);
// Email sending mutation
const sendEmailMutation = api.email.sendInvoice.useMutation({
@@ -183,6 +197,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
const invoice = useMemo(() => {
return invoiceData
@@ -196,6 +235,9 @@ export default function SendEmailPage() {
taxRate: invoiceData.taxRate,
currency: invoiceData.currency,
emailMessage: invoiceData.emailMessage,
scheduledSendAt: invoiceData.scheduledSendAt,
scheduledSendTimeZone: invoiceData.scheduledSendTimeZone,
scheduledSendStatus: invoiceData.scheduledSendStatus,
client: invoiceData.client
? {
name: invoiceData.client.name,
@@ -287,6 +329,42 @@ export default function SendEmailPage() {
}
};
const confirmScheduleEmail = async () => {
const sendAt = new Date(scheduledAt);
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;
}
if (toLocalDateTimeInputValue(sendAt) !== scheduledAt) {
toast.error("That local time does not exist", {
description:
"Choose another time. The selected value falls inside a daylight-saving clock change.",
});
return;
}
try {
await scheduleEmailMutation.mutateAsync({
invoiceId,
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 = () => {
if (retryCount < 2) {
setRetryCount((prev) => prev + 1);
@@ -348,6 +426,31 @@ export default function SendEmailPage() {
</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 */}
<div className={cn(dashboardGridClass, "lg:grid-cols-3")}>
<div className="lg:col-span-2">
@@ -365,66 +468,66 @@ export default function SendEmailPage() {
<PageTabsContent value="compose">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Mail className="h-5 w-5" />
Compose Email
</CardTitle>
</CardHeader>
<CardContent>
{isInitialized ? (
<EmailComposer
subject={subject}
onSubjectChange={setSubject}
content={emailContent}
onContentChange={setEmailContent}
customMessage={customMessage}
onCustomMessageChange={setCustomMessage}
fromEmail={fromEmail}
toEmail={toEmail}
ccEmail={ccEmail}
onCcEmailChange={setCcEmail}
bccEmail={bccEmail}
onBccEmailChange={setBccEmail}
/>
) : (
<div className="bg-muted flex h-[400px] items-center justify-center border">
<div className="text-center">
<div className="border-primary mx-auto mb-2 h-4 w-4 animate-spin border-2 border-t-transparent"></div>
<p className="text-muted-foreground text-sm">
Initializing email content...
</p>
</div>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Mail className="h-5 w-5" />
Compose Email
</CardTitle>
</CardHeader>
<CardContent>
{isInitialized ? (
<EmailComposer
subject={subject}
onSubjectChange={setSubject}
content={emailContent}
onContentChange={setEmailContent}
customMessage={customMessage}
onCustomMessageChange={setCustomMessage}
fromEmail={fromEmail}
toEmail={toEmail}
ccEmail={ccEmail}
onCcEmailChange={setCcEmail}
bccEmail={bccEmail}
onBccEmailChange={setBccEmail}
/>
) : (
<div className="bg-muted flex h-[400px] items-center justify-center border">
<div className="text-center">
<div className="border-primary mx-auto mb-2 h-4 w-4 animate-spin border-2 border-t-transparent"></div>
<p className="text-muted-foreground text-sm">
Initializing email content...
</p>
</div>
)}
</CardContent>
</Card>
</div>
)}
</CardContent>
</Card>
</PageTabsContent>
<PageTabsContent value="preview">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Eye className="h-5 w-5" />
Email Preview
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<EmailPreview
subject={subject}
fromEmail={fromEmail}
toEmail={toEmail}
ccEmail={ccEmail}
bccEmail={bccEmail}
content={emailContent}
customMessage={normalizedCustomMessage}
invoice={invoice}
className="min-w-0 border-0"
/>
</div>
</CardContent>
</Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Eye className="h-5 w-5" />
Email Preview
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<EmailPreview
subject={subject}
fromEmail={fromEmail}
toEmail={toEmail}
ccEmail={ccEmail}
bccEmail={bccEmail}
content={emailContent}
customMessage={normalizedCustomMessage}
invoice={invoice}
className="min-w-0 border-0"
/>
</div>
</CardContent>
</Card>
</PageTabsContent>
</PageTabs>
</div>
@@ -579,6 +682,24 @@ export default function SendEmailPage() {
Cancel
</Button>
<Button
onClick={() => {
setMinimumScheduledAt(
toLocalDateTimeInputValue(new Date(Date.now() + 60_000)),
);
setScheduledAt(
toLocalDateTimeInputValue(getDefaultScheduledSendAt()),
);
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
onClick={handleSendEmail}
disabled={!canSend || isSending}
@@ -655,6 +776,52 @@ export default function SendEmailPage() {
</DialogFooter>
</DialogContent>
</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>
<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>
);
}
@@ -46,15 +46,10 @@ import {
import { invoiceLabel } from "~/lib/time-entry-display";
import { TimeEntryList } from "~/components/time-clock/time-entry-list";
import { TimeEntryEditDialog } from "~/components/time-clock/time-entry-edit-dialog";
import { toLocalDateTimeInputValue } from "@beenvoice/domain/time-zone";
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({
running,
updateRunningPending,
@@ -68,7 +63,7 @@ function RunningTextFields({
}) {
const [title, setTitle] = useState(running.description ?? "");
const [runningStartedAt, setRunningStartedAt] = useState(() =>
toDatetimeLocalValue(running.startedAt),
toLocalDateTimeInputValue(new Date(running.startedAt)),
);
return (
@@ -121,10 +116,8 @@ export function TimeClockPanel({
compact = false,
}: TimeClockPanelProps) {
const utils = api.useUtils();
const { data: running, isLoading: runningLoading } = api.timeEntries.getRunning.useQuery(
undefined,
{ refetchInterval: 30_000 },
);
const { data: running, isLoading: runningLoading } =
api.timeEntries.getRunning.useQuery(undefined, { refetchInterval: 30_000 });
const { data: clients } = api.clients.getAll.useQuery();
const todayStart = useMemo(() => {
@@ -169,7 +162,9 @@ export function TimeClockPanel({
if (!running) return;
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();
intervalRef.current = setInterval(tick, 1000);
return () => {
@@ -223,7 +218,10 @@ export function TimeClockPanel({
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 });
} else {
toast.success(message);
@@ -289,7 +287,7 @@ export function TimeClockPanel({
if (mode === "pick" && !pickedStart) {
const now = new Date();
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
setPickedStart(now.toISOString().slice(0, 16));
setPickedStart(toLocalDateTimeInputValue(now));
}
}
@@ -314,7 +312,9 @@ export function TimeClockPanel({
if (runningLoading) {
return (
<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>
);
}
@@ -330,7 +330,12 @@ export function TimeClockPanel({
);
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">
<CardHeader className="gap-3">
<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">
{running ? "In progress" : "Ready to start"}
</p>
<CardTitle className="text-pretty text-2xl">
<CardTitle className="text-2xl text-pretty">
{running ? runningTitle : "What are you working on?"}
</CardTitle>
<CardDescription className="text-pretty">
@@ -403,10 +408,16 @@ export function TimeClockPanel({
<Label htmlFor="clock-client">Client</Label>
<Select
value={activeClientId || "__none__"}
onValueChange={(value) => handleClientChange(value === "__none__" ? "" : value)}
onValueChange={(value) =>
handleClientChange(value === "__none__" ? "" : value)
}
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…" />
</SelectTrigger>
<SelectContent>
@@ -427,16 +438,28 @@ export function TimeClockPanel({
<Select
value={activeInvoiceId || "__none__"}
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
placeholder={activeClientId ? "Select invoice…" : "Choose a client first"}
placeholder={
activeClientId
? "Select invoice…"
: "Choose a client first"
}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="__none__">Entry only no invoice</SelectItem>
<SelectItem value="__none__">
Entry only no invoice
</SelectItem>
{billableInvoices?.map((invoice) => (
<SelectItem key={invoice.id} value={invoice.id}>
{invoiceLabel(invoice)}
@@ -507,7 +530,9 @@ export function TimeClockPanel({
step={0.01}
placeholder="0.00"
/>
{clientId && rate === 0 && selectedClient?.defaultHourlyRate ? (
{clientId &&
rate === 0 &&
selectedClient?.defaultHourlyRate ? (
<p className="text-muted-foreground text-xs">
{`Uses ${selectedClient.defaultHourlyRate}/hr from ${selectedClient.name}.`}
</p>
@@ -543,7 +568,9 @@ export function TimeClockPanel({
autoComplete="off"
type="datetime-local"
value={pickedStart}
onChange={(event) => setPickedStart(event.target.value)}
onChange={(event) =>
setPickedStart(event.target.value)
}
/>
) : null}
{startMode === "ago" ? (
@@ -556,10 +583,14 @@ export function TimeClockPanel({
min={1}
max={1440}
value={minutesAgo}
onChange={(event) => setMinutesAgo(event.target.value)}
onChange={(event) =>
setMinutesAgo(event.target.value)
}
className="w-24"
/>
<span className="text-muted-foreground text-sm">minutes ago</span>
<span className="text-muted-foreground text-sm">
minutes ago
</span>
</div>
) : null}
</div>
@@ -618,19 +649,25 @@ export function TimeClockPanel({
</CardHeader>
<CardContent>
{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">
<p className="font-medium">No time logged yet</p>
<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>
</div>
)}
</CardContent>
<CardFooter>
<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>
</CardFooter>
</Card>
@@ -23,15 +23,10 @@ import {
import { toast } from "sonner";
import { invoiceLabel } from "~/lib/time-entry-display";
import type { RouterOutputs } from "~/trpc/react";
import { toLocalDateTimeInputValue } from "@beenvoice/domain/time-zone";
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 = {
entryId: string | null;
open: boolean;
@@ -56,9 +51,11 @@ function TimeEntryEditForm({
const [clientId, setClientId] = useState(entry.clientId ?? "");
const [invoiceId, setInvoiceId] = useState(entry.invoiceId ?? "");
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(() =>
entry.endedAt ? toDatetimeLocalValue(entry.endedAt) : "",
entry.endedAt ? toLocalDateTimeInputValue(new Date(entry.endedAt)) : "",
);
const { data: billableInvoices } = api.invoices.getBillable.useQuery(
@@ -70,7 +67,8 @@ function TimeEntryEditForm({
if (!startedAt || !endedAt) return null;
const start = new Date(startedAt);
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);
}, [endedAt, startedAt]);
@@ -228,7 +226,11 @@ function TimeEntryEditForm({
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="button" onClick={handleSave} disabled={updateEntry.isPending}>
<Button
type="button"
onClick={handleSave}
disabled={updateEntry.isPending}
>
Save
</Button>
</div>
@@ -246,7 +248,9 @@ export function TimeEntryEditDialog({
{ id: entryId ?? "" },
{ 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 (
<Dialog open={open} onOpenChange={onOpenChange}>
+165 -315
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 { 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 { generateInvoicePDFBlob } from "~/lib/pdf-export";
import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
import { createTRPCRouter, sessionProcedure } from "~/server/api/trpc";
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) {
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() : "";
}
const emailOptionsSchema = z.object({
invoiceId: z.string().min(1),
customSubject: z.string().max(500).optional(),
customContent: z.string().max(50_000).optional(),
customMessage: z.string().max(10_000).optional(),
useHtml: z.boolean().default(false),
ccEmails: z.string().max(2_000).optional(),
bccEmails: z.string().max(2_000).optional(),
});
export const emailRouter = createTRPCRouter({
sendInvoice: sessionProcedure
.input(emailOptionsSchema)
.mutation(async ({ ctx, input }) =>
deliverInvoiceEmail({
...input,
actorUserId: ctx.session.user.id,
baseUrl: getRequestOrigin(ctx.headers),
}),
),
scheduleInvoice: sessionProcedure
.input(
z.object({
invoiceId: z.string(),
customSubject: z.string().optional(),
customContent: z.string().optional(),
customMessage: z.string().optional(),
useHtml: z.boolean().default(false),
ccEmails: z.string().optional(),
bccEmails: z.string().optional(),
emailOptionsSchema.extend({
scheduledAt: z.coerce.date(),
timeZone: z
.string()
.min(1)
.max(100)
.refine(isValidTimeZone, "Invalid time zone"),
}),
)
.mutation(async ({ ctx, input }) => {
// Fetch invoice with relations
const invoice = await ctx.db.query.invoices.findFirst({
where: eq(invoices.id, input.invoiceId),
with: {
client: true,
business: true,
items: true,
},
where: and(
eq(invoices.id, input.invoiceId),
eq(invoices.createdById, ctx.session.user.id),
),
with: { client: true, items: true },
});
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)
throw new TRPCError({
code: "NOT_FOUND",
message: "Invoice not found",
});
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) {
throw new Error("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"),
throw new TRPCError({
code: "BAD_REQUEST",
message: "Add at least one line item before sending this invoice",
});
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) {
console.error("PDF generation error:", pdfError);
// Re-throw the original error with more context
if (pdfError instanceof Error) {
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,
if (input.scheduledAt.getTime() < Date.now() + 60_000) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Choose a send time at least one minute in the future",
});
}
if (
invoice.scheduledSendJobId &&
invoice.scheduledSendStatus === "processing"
) {
throw new TRPCError({
code: "CONFLICT",
message: "This invoice is already being sent",
});
}
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,
},
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,
},
],
if (!job) {
throw new TRPCError({
code: "CONFLICT",
message: "Unable to schedule invoice",
});
} 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
await ctx.db.transaction(async (tx) => {
if (
errorMsg.includes("invalid email") ||
errorMsg.includes("invalid recipient")
invoice.scheduledSendJobId &&
invoice.scheduledSendStatus === "pending"
) {
throw new Error("Invalid recipient email address");
} else if (
errorMsg.includes("domain") ||
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"}`,
);
await tx
.update(backgroundJobs)
.set({ status: "cancelled", updatedAt: new Date() })
.where(
and(
eq(backgroundJobs.id, invoice.scheduledSendJobId),
eq(backgroundJobs.status, "pending"),
),
);
}
}
if (!emailResult.data?.id) {
throw new Error(
"Email was not sent successfully - no delivery ID received",
);
}
// Update invoice status to "sent" if it was draft
if (invoice.status === "draft") {
try {
await ctx.db
.update(invoices)
.set({
status: "sent",
updatedAt: new Date(),
})
.where(eq(invoices.id, input.invoiceId));
} catch {
// Don't throw here - email was sent successfully, status update is secondary
}
}
await tx
.update(invoices)
.set({
scheduledSendAt: input.scheduledAt,
scheduledSendTimeZone: input.timeZone,
scheduledSendJobId: job.id,
scheduledSendStatus: "pending",
updatedAt: new Date(),
})
.where(eq(invoices.id, invoice.id));
});
return {
success: true,
emailId: emailResult.data.id,
message: `Invoice sent successfully to ${invoice.client?.email ?? "client"}${ccEmails.length > 0 ? ` (CC: ${ccEmails.join(", ")})` : ""}${bccEmails.length > 0 ? ` (BCC: ${bccEmails.join(", ")})` : ""}`,
deliveryDetails: {
to: invoice.client?.email ?? "",
cc: ccEmails,
bcc: bccEmails,
sentAt: new Date().toISOString(),
},
jobId: job.id,
scheduledAt: input.scheduledAt.toISOString(),
timeZone: input.timeZone,
};
}),
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 };
}),
});
+12 -5
View File
@@ -384,6 +384,11 @@ export const invoices = createTable(
publicTokenExpiresAt: d.timestamp(),
lastReminderSentAt: d.timestamp(),
sendReminderAt: d.timestamp(),
sentAt: d.timestamp({ withTimezone: true }),
scheduledSendAt: d.timestamp({ withTimezone: true }),
scheduledSendTimeZone: d.varchar({ length: 100 }),
scheduledSendJobId: d.varchar({ length: 255 }),
scheduledSendStatus: d.varchar({ length: 20 }), // pending | processing | completed | failed | cancelled
createdAt: d
.timestamp()
.default(sql`CURRENT_TIMESTAMP`)
@@ -397,6 +402,8 @@ export const invoices = createTable(
index("invoice_number_idx").on(t.invoiceNumber),
index("invoice_status_idx").on(t.status),
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),
],
);
@@ -755,15 +762,15 @@ export const backgroundJobs = createTable(
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().notNull().defaultNow(),
runAt: d.timestamp({ withTimezone: true }).notNull().defaultNow(),
attempts: d.integer().notNull().default(0),
maxAttempts: d.integer().notNull().default(5),
lockedAt: d.timestamp(),
lockedAt: d.timestamp({ withTimezone: true }),
lockedBy: d.varchar({ length: 255 }),
lastError: d.text(),
completedAt: d.timestamp(),
createdAt: d.timestamp().notNull().defaultNow(),
updatedAt: d.timestamp().notNull().defaultNow(),
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),
+22 -3
View File
@@ -1,8 +1,12 @@
import { randomUUID } from "node:crypto";
import { and, asc, eq, lte, or } from "drizzle-orm";
import { and, asc, eq, inArray, lte, or } from "drizzle-orm";
import { db } from "~/server/db";
import { backgroundJobs, recurringInvoices } from "~/server/db/schema";
import {
backgroundJobs,
invoices,
recurringInvoices,
} from "~/server/db/schema";
export const jobTypes = {
generateRecurringInvoice: "recurring_invoice.generate",
@@ -111,7 +115,10 @@ export async function completeJob(id: string) {
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);
const retryDelayMs = Math.min(
60 * 60_000,
2 ** Math.max(0, job.attempts - 1) * 15_000,
);
await db
.update(backgroundJobs)
.set({
@@ -125,3 +132,15 @@ export async function failJob(job: BackgroundJob, error: unknown) {
.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,336 @@
import { and, eq } from "drizzle-orm";
import { Resend } from "resend";
import { NOREPLY_EMAIL } from "~/lib/app-email";
import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
import { env } from "~/env";
import { db } from "~/server/db";
import { backgroundJobs, invoices, platformSettings } from "~/server/db/schema";
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,
});
let resend: Resend;
let fromEmail: string;
if (invoice.business?.resendApiKey && invoice.business?.resendDomain) {
resend = 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) {
resend = new Resend(env.RESEND_API_KEY);
fromEmail = `noreply@${env.RESEND_DOMAIN}`;
} else if (env.RESEND_API_KEY) {
resend = 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.",
);
}
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 resend.emails.send(
{
from: fromEmail,
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,
},
],
},
input.idempotencyKey
? { idempotencyKey: input.idempotencyKey }
: undefined,
);
} catch {
throw new Error(
"Email service is currently unavailable. Please try again later.",
);
}
if (emailResult.error) throw deliveryError(emailResult.error.message);
if (!emailResult.data?.id) {
throw new Error(
"Email was not sent successfully - no delivery ID received",
);
}
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.data.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(),
},
};
}
+4 -1
View File
@@ -2,10 +2,12 @@
The worker is a separate Bun process backed by the same PostgreSQL database as the web app. It follows the Racetix worker model: a durable database outbox, a lightweight scheduler, and horizontally safe polling with `FOR UPDATE SKIP LOCKED`.
Currently it schedules and generates due recurring invoices. New asynchronous workflows should enqueue a typed job through `apps/web/src/server/jobs/queue.ts` and add a handler in `src/index.ts`.
It generates due recurring invoices and delivers scheduled invoice emails. New asynchronous workflows should enqueue a typed job through `apps/web/src/server/jobs/queue.ts` and add a handler in `src/index.ts`.
Jobs have an idempotency key, scheduled run time, bounded exponential retries, and stale-lock recovery. Multiple worker replicas can run safely. Timer elapsed time is still derived from `startedAt`; the worker should only send time-clock reminders, never increment a counter every second.
Scheduled sends store an absolute UTC instant plus the IANA timezone selected by the client. The worker calls the app's secret-protected internal delivery endpoint through `APP_INTERNAL_URL`; that endpoint passes the job idempotency key to Resend, so a retry cannot send the same invoice twice.
```bash
# Uses the web app's .env/.env.local files
bun run dev
@@ -15,3 +17,4 @@ bun run start
```
`WORKER_POLL_MS` defaults to 2000 and `WORKER_SCHEDULE_MS` defaults to 60000.
`APP_INTERNAL_URL` defaults to `http://app:3000` in Compose, and `CRON_SECRET` authenticates worker requests to the app.
+19 -2
View File
@@ -6,9 +6,11 @@ import {
completeJob,
failJob,
jobTypes,
markScheduledInvoiceJobFailed,
scheduleDueRecurringInvoiceJobs,
type BackgroundJob,
} from "../../web/src/server/jobs/queue";
import { sendScheduledInvoice } from "./send-invoice";
const workerId = `beenvoice-worker:${randomUUID()}`;
const pollMs = Number(process.env.WORKER_POLL_MS ?? 2_000);
@@ -17,7 +19,11 @@ let stopping = false;
let working = false;
let scheduling = false;
function log(level: "info" | "error", event: string, fields: Record<string, unknown> = {}) {
function log(
level: "info" | "error",
event: string,
fields: Record<string, unknown> = {},
) {
const record = JSON.stringify({
timestamp: new Date().toISOString(),
level,
@@ -59,6 +65,10 @@ async function handleJob(job: BackgroundJob) {
await generateRecurringInvoice(job);
return;
}
if (job.type === jobTypes.sendInvoice) {
await sendScheduledInvoice(job);
return;
}
throw new Error(`No handler registered for ${job.type}`);
}
@@ -72,9 +82,16 @@ async function drainJobs() {
try {
await handleJob(job);
await completeJob(job.id);
log("info", "job.completed", { jobId: job.id, jobType: job.type, attempts: job.attempts });
log("info", "job.completed", {
jobId: job.id,
jobType: job.type,
attempts: job.attempts,
});
} catch (error) {
const terminal = await failJob(job, error);
if (terminal && job.type === jobTypes.sendInvoice) {
await markScheduledInvoiceJobFailed(job);
}
log("error", "job.failed", {
jobId: job.id,
jobType: job.type,
+41
View File
@@ -0,0 +1,41 @@
import type { BackgroundJob } from "../../web/src/server/jobs/queue";
function requiredPayloadString(job: BackgroundJob, key: string): string {
const value = job.payload[key];
if (typeof value !== "string" || !value) {
throw new Error(`Invalid scheduled invoice job payload: ${key}`);
}
return value;
}
export async function sendScheduledInvoice(job: BackgroundJob) {
const appUrl =
process.env.APP_INTERNAL_URL?.replace(/\/$/, "") ?? "http://app:3000";
const secret = process.env.CRON_SECRET;
if (!secret)
throw new Error("CRON_SECRET is required for scheduled invoice delivery");
const response = await fetch(`${appUrl}/api/internal/jobs/send-invoice`, {
method: "POST",
headers: {
Authorization: `Bearer ${secret}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
...job.payload,
jobId: job.id,
idempotencyKey: job.idempotencyKey,
invoiceId: requiredPayloadString(job, "invoiceId"),
actorUserId: requiredPayloadString(job, "actorUserId"),
}),
});
const result = (await response.json().catch(() => ({}))) as {
error?: string;
};
if (!response.ok) {
throw new Error(
result.error ?? `Invoice delivery request failed (${response.status})`,
);
}
return result;
}
@@ -0,0 +1,88 @@
/// <reference types="bun" />
import { afterEach, describe, expect, mock, test } from "bun:test";
import type { BackgroundJob } from "../../web/src/server/jobs/queue";
import { sendScheduledInvoice } from "../src/send-invoice";
const originalFetch = globalThis.fetch;
const originalAppUrl = process.env.APP_INTERNAL_URL;
const originalSecret = process.env.CRON_SECRET;
function scheduledJob(): BackgroundJob {
const now = new Date("2026-08-17T16:00:00.000Z");
return {
id: "job-1",
type: "invoice.send_scheduled",
payload: {
invoiceId: "invoice-1",
actorUserId: "user-1",
customMessage: "Thanks!",
timeZone: "America/New_York",
},
status: "processing",
idempotencyKey: "invoice.send_scheduled:invoice-1:once",
runAt: now,
attempts: 1,
maxAttempts: 5,
lockedAt: now,
lockedBy: "worker-1",
lastError: null,
completedAt: null,
createdAt: now,
updatedAt: now,
};
}
afterEach(() => {
globalThis.fetch = originalFetch;
if (originalAppUrl === undefined) delete process.env.APP_INTERNAL_URL;
else process.env.APP_INTERNAL_URL = originalAppUrl;
if (originalSecret === undefined) delete process.env.CRON_SECRET;
else process.env.CRON_SECRET = originalSecret;
});
describe("scheduled invoice delivery", () => {
test("calls the internal app endpoint with auth and an idempotency key", async () => {
process.env.APP_INTERNAL_URL = "http://app:3000/";
process.env.CRON_SECRET = "worker-secret";
const fetchMock = mock(
async (_url: string | URL | Request, _request?: RequestInit) =>
Response.json({ success: true, emailId: "email-1" }),
);
globalThis.fetch = fetchMock as unknown as typeof fetch;
await sendScheduledInvoice(scheduledJob());
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, request] = fetchMock.mock.calls[0]!;
expect(url).toBe("http://app:3000/api/internal/jobs/send-invoice");
expect(request?.headers).toEqual({
Authorization: "Bearer worker-secret",
"Content-Type": "application/json",
});
expect(JSON.parse(String(request?.body))).toMatchObject({
jobId: "job-1",
invoiceId: "invoice-1",
actorUserId: "user-1",
idempotencyKey: "invoice.send_scheduled:invoice-1:once",
timeZone: "America/New_York",
});
});
test("surfaces retryable delivery errors to the worker", async () => {
process.env.APP_INTERNAL_URL = "http://app:3000";
process.env.CRON_SECRET = "worker-secret";
globalThis.fetch = mock(
async (_url: string | URL | Request, _request?: RequestInit) =>
Response.json(
{ error: "Email service is unavailable" },
{ status: 503 },
),
) as unknown as typeof fetch;
await expect(sendScheduledInvoice(scheduledJob())).rejects.toThrow(
"Email service is unavailable",
);
});
});