Add draft-only invoicing rules, send reminders, and time clock billing.

Restrict line item edits to draft invoices, auto-create drafts on clock-out,
and add sendReminderAt scheduling with dashboard due reminders.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-22 16:06:11 -04:00
co-authored by Cursor
parent 4cd8ad3c4c
commit 1928084acb
12 changed files with 311 additions and 85 deletions
@@ -0,0 +1 @@
ALTER TABLE "beenvoice_invoice" ADD COLUMN "send_reminder_at" timestamp;
+91 -1
View File
@@ -52,6 +52,7 @@ import { Separator } from "~/components/ui/separator";
import { Textarea } from "~/components/ui/textarea"; import { Textarea } from "~/components/ui/textarea";
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label"; import { Label } from "~/components/ui/label";
import { DatePicker } from "~/components/ui/date-picker";
import { import {
getEffectiveInvoiceStatus, getEffectiveInvoiceStatus,
isInvoiceOverdue, isInvoiceOverdue,
@@ -166,6 +167,15 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
onError: (e) => toast.error(e.message ?? "Failed to send reminder"), onError: (e) => toast.error(e.message ?? "Failed to send reminder"),
}); });
const updateInvoice = api.invoices.update.useMutation({
onSuccess: () => {
toast.success("Reminder saved");
void utils.invoices.getById.invalidate({ id: invoiceId });
void utils.dashboard.getStats.invalidate();
},
onError: (e) => toast.error(e.message ?? "Failed to save reminder"),
});
if (isLoading) return <InvoiceDetailsSkeleton />; if (isLoading) return <InvoiceDetailsSkeleton />;
if (!invoice) notFound(); if (!invoice) notFound();
@@ -522,7 +532,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
{/* Right Column - Actions */} {/* Right Column - Actions */}
<div className="space-y-6"> <div className="space-y-6">
{effectiveStatus !== "paid" && ( {storedStatus === "draft" && (
<InvoiceTimerCard invoiceId={invoiceId} clientId={invoice.clientId} /> <InvoiceTimerCard invoiceId={invoiceId} clientId={invoice.clientId} />
)} )}
@@ -553,6 +563,25 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
/> />
)} )}
{effectiveStatus === "draft" && (
<SendReminderEditor
key={`${invoiceId}-${invoice.sendReminderAt?.toISOString() ?? "none"}`}
invoiceId={invoiceId}
savedReminderAt={invoice.sendReminderAt}
formatDate={formatDate}
isSaving={updateInvoice.isPending}
onSave={(sendReminderAt) =>
updateInvoice.mutate({
id: invoiceId,
sendReminderAt,
})
}
onClear={() =>
updateInvoice.mutate({ id: invoiceId, sendReminderAt: null })
}
/>
)}
{(effectiveStatus === "sent" || effectiveStatus === "overdue") && ( {(effectiveStatus === "sent" || effectiveStatus === "overdue") && (
<EnhancedSendInvoiceButton <EnhancedSendInvoiceButton
invoiceId={invoice.id} invoiceId={invoice.id}
@@ -808,6 +837,67 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
); );
} }
function SendReminderEditor({
invoiceId,
savedReminderAt,
formatDate,
isSaving,
onSave,
onClear,
}: {
invoiceId: string;
savedReminderAt: Date | null | undefined;
formatDate: (date: Date) => string;
isSaving: boolean;
onSave: (sendReminderAt: Date | null) => void;
onClear: () => void;
}) {
const [sendReminderAt, setSendReminderAt] = useState<Date | undefined>(() =>
savedReminderAt ? new Date(savedReminderAt) : undefined,
);
return (
<div className="space-y-2 rounded-lg border p-3">
<Label htmlFor={`send-reminder-at-${invoiceId}`}>Remind me to send</Label>
<DatePicker
date={sendReminderAt}
onDateChange={setSendReminderAt}
className="w-full"
/>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={() => onSave(sendReminderAt ?? null)}
disabled={isSaving}
>
Save reminder
</Button>
{sendReminderAt ? (
<Button
variant="ghost"
size="sm"
onClick={() => {
setSendReminderAt(undefined);
onClear();
}}
>
Clear
</Button>
) : null}
</div>
{savedReminderAt ? (
<p className="text-muted-foreground text-xs">
{new Date(savedReminderAt) <= new Date()
? "Reminder is due — time to send this invoice."
: `Scheduled for ${formatDate(savedReminderAt)}`}
</p>
) : null}
</div>
);
}
export default function InvoiceViewPage() { export default function InvoiceViewPage() {
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
@@ -54,6 +54,7 @@ interface InvoiceCalendarViewProps {
onRemoveItem: (index: number) => void; onRemoveItem: (index: number) => void;
className?: string; className?: string;
defaultHourlyRate: number | null; defaultHourlyRate: number | null;
readOnly?: boolean;
} }
export function InvoiceCalendarView({ export function InvoiceCalendarView({
@@ -63,6 +64,7 @@ export function InvoiceCalendarView({
onRemoveItem, onRemoveItem,
className, className,
defaultHourlyRate: _defaultHourlyRate, defaultHourlyRate: _defaultHourlyRate,
readOnly = false,
}: InvoiceCalendarViewProps) { }: InvoiceCalendarViewProps) {
const [date, setDate] = React.useState<Date | undefined>(undefined); // Start unselected const [date, setDate] = React.useState<Date | undefined>(undefined); // Start unselected
const [viewDate, setViewDate] = React.useState<Date>(new Date()); // Controls the view (month/week) const [viewDate, setViewDate] = React.useState<Date>(new Date()); // Controls the view (month/week)
@@ -403,10 +405,12 @@ export function InvoiceCalendarView({
There are no time entries recorded for this day yet. There are no time entries recorded for this day yet.
</p> </p>
</div> </div>
{!readOnly ? (
<Button onClick={handleAddNewItem} className="mt-2" size="lg"> <Button onClick={handleAddNewItem} className="mt-2" size="lg">
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Log Time Log Time
</Button> </Button>
) : null}
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
@@ -428,6 +432,7 @@ export function InvoiceCalendarView({
} }
placeholder="Describe the work performed..." placeholder="Describe the work performed..."
className="pl-3 text-sm" className="pl-3 text-sm"
disabled={readOnly}
/> />
</div> </div>
@@ -443,6 +448,7 @@ export function InvoiceCalendarView({
step={0.25} step={0.25}
min={0} min={0}
width="full" width="full"
disabled={readOnly}
/> />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
@@ -456,6 +462,7 @@ export function InvoiceCalendarView({
min={0} min={0}
step={1} step={1}
width="full" width="full"
disabled={readOnly}
/> />
</div> </div>
</div> </div>
@@ -464,6 +471,7 @@ export function InvoiceCalendarView({
{/* Bottom section with controls, item name, and total */} {/* Bottom section with controls, item name, and total */}
<div className="border-border bg-muted/50 flex items-center justify-between border-t px-4 py-2"> <div className="border-border bg-muted/50 flex items-center justify-between border-t px-4 py-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{!readOnly ? (
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@@ -473,6 +481,7 @@ export function InvoiceCalendarView({
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
) : null}
</div> </div>
<div className="flex-1 px-3 text-center"> <div className="flex-1 px-3 text-center">
<span className="text-muted-foreground block text-sm font-medium"> <span className="text-muted-foreground block text-sm font-medium">
@@ -490,6 +499,7 @@ export function InvoiceCalendarView({
</div> </div>
</div> </div>
))} ))}
{!readOnly ? (
<Button <Button
variant="outline" variant="outline"
onClick={handleAddNewItem} onClick={handleAddNewItem}
@@ -500,6 +510,7 @@ export function InvoiceCalendarView({
</div> </div>
<span>Add Another Entry</span> <span>Add Another Entry</span>
</Button> </Button>
) : null}
</div> </div>
)} )}
</div> </div>
+2
View File
@@ -808,6 +808,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
invoiceId={invoiceId && invoiceId !== "new" ? invoiceId : undefined} invoiceId={invoiceId && invoiceId !== "new" ? invoiceId : undefined}
clientId={formData.clientId || undefined} clientId={formData.clientId || undefined}
defaultRate={formData.items[0]?.rate} defaultRate={formData.items[0]?.rate}
readOnly={formData.status !== "draft"}
/> />
</CardContent> </CardContent>
</Card> </Card>
@@ -831,6 +832,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
onRemoveItem={removeItem} onRemoveItem={removeItem}
onUpdateItem={updateItem} onUpdateItem={updateItem}
defaultHourlyRate={formData.defaultHourlyRate} defaultHourlyRate={formData.defaultHourlyRate}
readOnly={formData.status !== "draft"}
/> />
</CardContent> </CardContent>
</Card> </Card>
+33 -4
View File
@@ -40,6 +40,7 @@ interface InvoiceLineItemsProps {
clientId?: string; clientId?: string;
defaultRate?: number; defaultRate?: number;
className?: string; className?: string;
readOnly?: boolean;
} }
interface LineItemRowProps { interface LineItemRowProps {
@@ -55,6 +56,7 @@ interface LineItemRowProps {
suggestions: LineItemSuggestion[]; suggestions: LineItemSuggestion[];
onSelectSuggestion: (index: number, suggestion: LineItemSuggestion) => void; onSelectSuggestion: (index: number, suggestion: LineItemSuggestion) => void;
onDescriptionChange: (index: number, value: string) => void; onDescriptionChange: (index: number, value: string) => void;
readOnly?: boolean;
} }
interface DescriptionAutocompleteProps { interface DescriptionAutocompleteProps {
@@ -64,6 +66,7 @@ interface DescriptionAutocompleteProps {
suggestions: LineItemSuggestion[]; suggestions: LineItemSuggestion[];
placeholder?: string; placeholder?: string;
className?: string; className?: string;
disabled?: boolean;
} }
function DescriptionAutocomplete({ function DescriptionAutocomplete({
@@ -73,6 +76,7 @@ function DescriptionAutocomplete({
suggestions, suggestions,
placeholder, placeholder,
className, className,
disabled,
}: DescriptionAutocompleteProps) { }: DescriptionAutocompleteProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1); const [activeIndex, setActiveIndex] = useState(-1);
@@ -116,6 +120,7 @@ function DescriptionAutocomplete({
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder={placeholder} placeholder={placeholder}
className={className} className={className}
disabled={disabled}
/> />
{showDropdown && ( {showDropdown && (
<div className="bg-popover text-popover-foreground border-border absolute top-full left-0 z-50 mt-1 w-full overflow-hidden rounded-md border shadow-md"> <div className="bg-popover text-popover-foreground border-border absolute top-full left-0 z-50 mt-1 w-full overflow-hidden rounded-md border shadow-md">
@@ -146,7 +151,7 @@ function DescriptionAutocomplete({
} }
const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>( const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
({ item, index, canRemove, onRemove, onUpdate, suggestions, onSelectSuggestion, onDescriptionChange }, ref) => { ({ item, index, canRemove, onRemove, onUpdate, suggestions, onSelectSuggestion, onDescriptionChange, readOnly }, ref) => {
return ( return (
<div <div
ref={ref} ref={ref}
@@ -160,6 +165,7 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
size="sm" size="sm"
className="w-full" className="w-full"
inputClassName="h-9" inputClassName="h-9"
disabled={readOnly}
/> />
<DescriptionAutocomplete <DescriptionAutocomplete
@@ -169,6 +175,7 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
suggestions={suggestions} suggestions={suggestions}
placeholder="Describe the work performed..." placeholder="Describe the work performed..."
className="h-9 w-full text-sm font-medium" className="h-9 w-full text-sm font-medium"
disabled={readOnly}
/> />
<NumberInput <NumberInput
@@ -179,6 +186,7 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
width="full" width="full"
className="h-9 font-mono [&_button]:w-6 [&_input]:min-w-12" className="h-9 font-mono [&_button]:w-6 [&_input]:min-w-12"
suffix="h" suffix="h"
disabled={readOnly}
/> />
<NumberInput <NumberInput
@@ -189,12 +197,14 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
prefix="$" prefix="$"
width="full" width="full"
className="h-9 font-mono [&_button]:w-6 [&_input]:min-w-14" className="h-9 font-mono [&_button]:w-6 [&_input]:min-w-14"
disabled={readOnly}
/> />
<div className="text-primary text-right font-mono font-semibold"> <div className="text-primary text-right font-mono font-semibold">
${(item.hours * item.rate).toFixed(2)} ${(item.hours * item.rate).toFixed(2)}
</div> </div>
{!readOnly ? (
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@@ -206,6 +216,9 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
) : (
<span />
)}
</div> </div>
); );
}, },
@@ -221,6 +234,7 @@ function MobileLineItem({
suggestions, suggestions,
onSelectSuggestion, onSelectSuggestion,
onDescriptionChange, onDescriptionChange,
readOnly,
}: LineItemRowProps) { }: LineItemRowProps) {
return ( return (
<motion.div <motion.div
@@ -243,6 +257,7 @@ function MobileLineItem({
suggestions={suggestions} suggestions={suggestions}
placeholder="Describe the work performed..." placeholder="Describe the work performed..."
className="pl-3 text-sm" className="pl-3 text-sm"
disabled={readOnly}
/> />
</div> </div>
@@ -254,6 +269,7 @@ function MobileLineItem({
onDateChange={(date) => onUpdate(index, "date", date ?? new Date())} onDateChange={(date) => onUpdate(index, "date", date ?? new Date())}
size="sm" size="sm"
inputClassName="h-9" inputClassName="h-9"
disabled={readOnly}
/> />
</div> </div>
@@ -267,6 +283,7 @@ function MobileLineItem({
min={0} min={0}
step={0.25} step={0.25}
width="full" width="full"
disabled={readOnly}
/> />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
@@ -279,6 +296,7 @@ function MobileLineItem({
prefix="$" prefix="$"
width="full" width="full"
className="font-mono" className="font-mono"
disabled={readOnly}
/> />
</div> </div>
</div> </div>
@@ -287,6 +305,7 @@ function MobileLineItem({
{/* Bottom section with controls, item name, and total */} {/* Bottom section with controls, item name, and total */}
<div className="border-border bg-muted/50 flex items-center justify-between border-t px-4 py-2"> <div className="border-border bg-muted/50 flex items-center justify-between border-t px-4 py-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{!readOnly ? (
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@@ -298,6 +317,7 @@ function MobileLineItem({
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
) : null}
</div> </div>
<div className="flex-1 px-3 text-center"> <div className="flex-1 px-3 text-center">
<span className="text-muted-foreground block text-sm font-medium"> <span className="text-muted-foreground block text-sm font-medium">
@@ -352,6 +372,7 @@ export function InvoiceLineItems({
clientId, clientId,
defaultRate: _defaultRate, defaultRate: _defaultRate,
className, className,
readOnly = false,
}: InvoiceLineItemsProps) { }: InvoiceLineItemsProps) {
const canRemoveItems = items.length > 1; const canRemoveItems = items.length > 1;
const { search } = useLineItemSuggestions(); const { search } = useLineItemSuggestions();
@@ -378,6 +399,11 @@ export function InvoiceLineItems({
return ( return (
<div className={cn("space-y-2", className)}> <div className={cn("space-y-2", className)}>
{readOnly ? (
<p className="text-muted-foreground text-sm">
Line items are locked after an invoice is sent. Revert to draft to edit entries.
</p>
) : null}
<AnimatePresence> <AnimatePresence>
<div className="space-y-2 md:space-y-0 md:overflow-hidden md:rounded-lg md:border"> <div className="space-y-2 md:space-y-0 md:overflow-hidden md:rounded-lg md:border">
<div className="bg-muted/60 text-muted-foreground hidden grid-cols-[140px_minmax(200px,1fr)_124px_136px_104px_32px] gap-2 border-b px-3 py-2 text-xs font-medium md:grid"> <div className="bg-muted/60 text-muted-foreground hidden grid-cols-[140px_minmax(200px,1fr)_124px_136px_104px_32px] gap-2 border-b px-3 py-2 text-xs font-medium md:grid">
@@ -408,6 +434,7 @@ export function InvoiceLineItems({
suggestions={getSuggestionsForIndex(index)} suggestions={getSuggestionsForIndex(index)}
onSelectSuggestion={handleSelectSuggestion} onSelectSuggestion={handleSelectSuggestion}
onDescriptionChange={handleDescriptionChange} onDescriptionChange={handleDescriptionChange}
readOnly={readOnly}
/> />
</motion.div> </motion.div>
@@ -421,6 +448,7 @@ export function InvoiceLineItems({
suggestions={getSuggestionsForIndex(index)} suggestions={getSuggestionsForIndex(index)}
onSelectSuggestion={handleSelectSuggestion} onSelectSuggestion={handleSelectSuggestion}
onDescriptionChange={handleDescriptionChange} onDescriptionChange={handleDescriptionChange}
readOnly={readOnly}
/> />
</React.Fragment> </React.Fragment>
))} ))}
@@ -444,13 +472,13 @@ export function InvoiceLineItems({
</Button> </Button>
</div> </div>
)} )}
{onAddItemWithValues && ( {onAddItemWithValues && !readOnly ? (
<NLQuickAdd onAdd={onAddItemWithValues} /> <NLQuickAdd onAdd={onAddItemWithValues} />
)} ) : null}
</div> </div>
</AnimatePresence> </AnimatePresence>
{/* Add Item Button */} {!readOnly ? (
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
@@ -460,6 +488,7 @@ export function InvoiceLineItems({
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Add Line Item Add Line Item
</Button> </Button>
) : null}
</div> </div>
); );
} }
@@ -134,7 +134,7 @@ export function TimeClockPanel({
invoicePrefix: string | null; invoicePrefix: string | null;
invoiceNumber: string; invoiceNumber: string;
status: string; status: string;
}) => `${inv.invoicePrefix ?? "#"}${inv.invoiceNumber} (${inv.status})`; }) => `${inv.invoicePrefix ?? "#"}${inv.invoiceNumber}`;
const displayDescription = running ? running.description : description; const displayDescription = running ? running.description : description;
const displayRate = running ? (running.rate ?? 0) : rate; const displayRate = running ? (running.rate ?? 0) : rate;
+11
View File
@@ -0,0 +1,11 @@
/** Default invoice number format (matches web/mobile create forms). */
export function generateInvoiceNumber(now = new Date()): string {
const date = now.toISOString().slice(0, 10).replace(/-/g, "");
return `INV-${date}-${String(now.getTime()).slice(-6)}`;
}
export function defaultDueDate(issueDate: Date): Date {
const due = new Date(issueDate);
due.setDate(due.getDate() + 30);
return due;
}
+1 -1
View File
@@ -32,7 +32,7 @@ export function describeClockOutOutcome(input: {
} }
return `Added ${input.hours}h to invoice`; return `Added ${input.hours}h to invoice`;
case "saved_no_invoice": case "saved_no_invoice":
return `Saved ${input.hours}h — no open invoice found for this client. Pick an invoice on the time clock or create one.`; return `Saved ${input.hours}h — could not create or find a draft invoice for this client.`;
case "saved_no_client": case "saved_no_client":
return `Saved ${input.hours}h — assign a client and invoice to bill this time.`; return `Saved ${input.hours}h — assign a client and invoice to bill this time.`;
case "zero_hours": case "zero_hours":
+22 -1
View File
@@ -1,6 +1,6 @@
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import { invoices, clients } from "~/server/db/schema"; import { invoices, clients } from "~/server/db/schema";
import { eq, desc } from "drizzle-orm"; import { and, desc, eq, isNotNull, lte } from "drizzle-orm";
export const dashboardRouter = createTRPCRouter({ export const dashboardRouter = createTRPCRouter({
getStats: protectedProcedure.query(async ({ ctx }) => { getStats: protectedProcedure.query(async ({ ctx }) => {
@@ -118,6 +118,26 @@ export const dashboardRouter = createTRPCRouter({
}, },
}); });
const sendReminderDue = await ctx.db.query.invoices.findMany({
where: and(
eq(invoices.createdById, userId),
eq(invoices.status, "draft"),
isNotNull(invoices.sendReminderAt),
lte(invoices.sendReminderAt, now),
),
columns: {
id: true,
invoiceNumber: true,
invoicePrefix: true,
sendReminderAt: true,
},
with: {
client: { columns: { name: true } },
},
orderBy: [desc(invoices.sendReminderAt)],
limit: 10,
});
return { return {
totalRevenue, totalRevenue,
pendingAmount, pendingAmount,
@@ -129,6 +149,7 @@ export const dashboardRouter = createTRPCRouter({
: 0, : 0,
revenueChartData, revenueChartData,
recentInvoices, recentInvoices,
sendReminderDue,
}; };
}), }),
}); });
+20 -2
View File
@@ -43,6 +43,7 @@ const createInvoiceSchema = z.object({
emailMessage: z.string().optional().or(z.literal("")), emailMessage: z.string().optional().or(z.literal("")),
taxRate: z.number().min(0).max(100).default(0), taxRate: z.number().min(0).max(100).default(0),
currency: z.string().length(3).default("USD"), currency: z.string().length(3).default("USD"),
sendReminderAt: z.date().nullable().optional(),
items: z.array(invoiceItemSchema).min(1, "At least one item is required"), items: z.array(invoiceItemSchema).min(1, "At least one item is required"),
}); });
@@ -155,13 +156,13 @@ export const invoicesRouter = createTRPCRouter({
} }
}), }),
/** Draft and sent invoices available for time-clock billing. */ /** Draft invoices available for time-clock billing. */
getBillable: protectedProcedure getBillable: protectedProcedure
.input(z.object({ clientId: z.string().optional() }).optional()) .input(z.object({ clientId: z.string().optional() }).optional())
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const conditions = [ const conditions = [
eq(invoices.createdById, ctx.session.user.id), eq(invoices.createdById, ctx.session.user.id),
inArray(invoices.status, ["draft", "sent"]), eq(invoices.status, "draft"),
]; ];
if (input?.clientId) conditions.push(eq(invoices.clientId, input.clientId)); if (input?.clientId) conditions.push(eq(invoices.clientId, input.clientId));
@@ -418,6 +419,23 @@ export const invoicesRouter = createTRPCRouter({
}); });
} }
if (items && existingInvoice.status !== "draft") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Line items can only be edited on draft invoices",
});
}
if (
cleanInvoiceData.sendReminderAt !== undefined &&
existingInvoice.status !== "draft"
) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Send reminders can only be set on draft invoices",
});
}
// If business is being updated, verify it belongs to user // If business is being updated, verify it belongs to user
if ( if (
cleanInvoiceData.businessId && cleanInvoiceData.businessId &&
+63 -21
View File
@@ -1,13 +1,14 @@
import { z } from "zod"; import { z } from "zod";
import { eq, and, desc, isNull, isNotNull, gte, lte, or } from "drizzle-orm"; import { eq, and, desc, isNull, isNotNull, gte, lte } from "drizzle-orm";
import { createTRPCRouter, protectedProcedure } from "../trpc"; import { createTRPCRouter, protectedProcedure } from "../trpc";
import { timeEntries, clients, invoices, invoiceItems } from "~/server/db/schema"; import { timeEntries, clients, invoices, invoiceItems, businesses } from "~/server/db/schema";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import type { db } from "~/server/db"; import type { db } from "~/server/db";
import { import {
computeTrackedHours, computeTrackedHours,
type ClockOutOutcome, type ClockOutOutcome,
} from "~/lib/time-clock"; } from "~/lib/time-clock";
import { defaultDueDate, generateInvoiceNumber } from "~/lib/draft-invoice";
type Db = typeof db; type Db = typeof db;
@@ -86,6 +87,60 @@ async function addEntryToInvoice(
}; };
} }
async function findOrCreateDraftInvoice(
database: Db,
userId: string,
clientId: string,
) {
const existing = await database.query.invoices.findFirst({
where: and(
eq(invoices.clientId, clientId),
eq(invoices.createdById, userId),
eq(invoices.status, "draft"),
),
with: { items: true },
orderBy: [
desc(invoices.updatedAt),
desc(invoices.issueDate),
desc(invoices.invoiceNumber),
],
});
if (existing) return existing;
const client = await database.query.clients.findFirst({
where: and(eq(clients.id, clientId), eq(clients.createdById, userId)),
columns: { currency: true },
});
if (!client) return null;
const defaultBusiness = await database.query.businesses.findFirst({
where: and(eq(businesses.createdById, userId), eq(businesses.isDefault, true)),
columns: { id: true },
});
const issueDate = new Date();
const [created] = await database
.insert(invoices)
.values({
invoiceNumber: generateInvoiceNumber(issueDate),
clientId,
businessId: defaultBusiness?.id ?? null,
issueDate,
dueDate: defaultDueDate(issueDate),
status: "draft",
totalAmount: 0,
taxRate: 0,
currency: client.currency,
createdById: userId,
})
.returning();
if (!created) return null;
return { ...created, items: [] as { amount: number; position: number }[] };
}
async function addEntryToLatestInvoice( async function addEntryToLatestInvoice(
database: Db, database: Db,
userId: string, userId: string,
@@ -96,20 +151,7 @@ async function addEntryToLatestInvoice(
rate: number, rate: number,
date: Date, date: Date,
): Promise<{ id: string; invoiceNumber: string; invoicePrefix: string } | null> { ): Promise<{ id: string; invoiceNumber: string; invoicePrefix: string } | null> {
const invoice = await database.query.invoices.findFirst({ const invoice = await findOrCreateDraftInvoice(database, userId, clientId);
where: and(
eq(invoices.clientId, clientId),
eq(invoices.createdById, userId),
or(eq(invoices.status, "draft"), eq(invoices.status, "sent")),
),
with: { items: true },
orderBy: [
desc(invoices.issueDate),
desc(invoices.dueDate),
desc(invoices.invoiceNumber),
],
});
if (!invoice) return null; if (!invoice) return null;
return addEntryToInvoice(database, invoice, entryId, description, hours, rate, date); return addEntryToInvoice(database, invoice, entryId, description, hours, rate, date);
} }
@@ -128,7 +170,7 @@ async function addEntryToSpecificInvoice(
where: and( where: and(
eq(invoices.id, invoiceId), eq(invoices.id, invoiceId),
eq(invoices.createdById, userId), eq(invoices.createdById, userId),
or(eq(invoices.status, "draft"), eq(invoices.status, "sent")), eq(invoices.status, "draft"),
), ),
with: { items: true }, with: { items: true },
}); });
@@ -231,14 +273,14 @@ export const timeEntriesRouter = createTRPCRouter({
where: and( where: and(
eq(invoices.id, invoiceId), eq(invoices.id, invoiceId),
eq(invoices.createdById, ctx.session.user.id), eq(invoices.createdById, ctx.session.user.id),
or(eq(invoices.status, "draft"), eq(invoices.status, "sent")), eq(invoices.status, "draft"),
), ),
columns: { id: true, clientId: true }, columns: { id: true, clientId: true },
}); });
if (!invoice) { if (!invoice) {
throw new TRPCError({ throw new TRPCError({
code: "FORBIDDEN", code: "FORBIDDEN",
message: "Invoice not found or not open for time tracking", message: "Only draft invoices accept new time entries",
}); });
} }
if (resolvedClientId && invoice.clientId !== resolvedClientId) { if (resolvedClientId && invoice.clientId !== resolvedClientId) {
@@ -345,14 +387,14 @@ export const timeEntriesRouter = createTRPCRouter({
where: and( where: and(
eq(invoices.id, invoiceId), eq(invoices.id, invoiceId),
eq(invoices.createdById, ctx.session.user.id), eq(invoices.createdById, ctx.session.user.id),
or(eq(invoices.status, "draft"), eq(invoices.status, "sent")), eq(invoices.status, "draft"),
), ),
columns: { id: true, clientId: true }, columns: { id: true, clientId: true },
}); });
if (!invoice) { if (!invoice) {
throw new TRPCError({ throw new TRPCError({
code: "FORBIDDEN", code: "FORBIDDEN",
message: "Invoice not found or not open for time tracking", message: "Only draft invoices accept new time entries",
}); });
} }
if (resolvedClientId && invoice.clientId !== resolvedClientId) { if (resolvedClientId && invoice.clientId !== resolvedClientId) {
+1
View File
@@ -369,6 +369,7 @@ export const invoices = createTable(
publicToken: d.varchar({ length: 255 }).unique(), publicToken: d.varchar({ length: 255 }).unique(),
publicTokenExpiresAt: d.timestamp(), publicTokenExpiresAt: d.timestamp(),
lastReminderSentAt: d.timestamp(), lastReminderSentAt: d.timestamp(),
sendReminderAt: d.timestamp(),
createdAt: d createdAt: d
.timestamp() .timestamp()
.default(sql`CURRENT_TIMESTAMP`) .default(sql`CURRENT_TIMESTAMP`)