Add PDF invoices/receipts with manual send, fix early-bird tier edit data loss

- Registration confirmations attach an invoice PDF (itemized breakdown,
  early-bird discount, balance due, Yoco pay-now link/QR) whenever a
  balance is outstanding; payment/donation confirmations attach a
  payment receipt PDF. Sent as an email attachment and, over WhatsApp,
  as the PDF itself with the existing message as its caption.
- Users can also (re)send either document on demand: an "Invoice"
  button on the registration detail popup, and a "Receipt" button next
  to each payment there and on the Payment history page, each opening
  an Email/WhatsApp choice popup, via two new endpoints restricted to
  the registration/payment's own owner.
- Fix: editing an event option's early-bird tiers deleted and
  recreated every tier for that option with brand-new ids, silently
  severing the appliedTierId link on all historical purchases (losing
  early-bird attribution and undercounting stock-limit usage) even for
  tiers the admin didn't touch. Tiers are now upserted by id.
- Update the "My Events" help content and the API docs index for the
  new endpoints.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 10:26:49 +02:00
co-authored by Claude Sonnet 5
parent e9cb238ce1
commit bc64069021
13 changed files with 835 additions and 82 deletions
@@ -21,7 +21,7 @@ function toLocalDateTimeInputValue(input: string | number | Date | null | undefi
return `${y}-${m}-${day}T${hh}:${mm}`;
}
function EarlyBirdTiersEditor({ option, onSave }: { option: any; onSave: (tiers: { deadline: string; price: number; order?: number }[]) => void }) {
function EarlyBirdTiersEditor({ option, onSave }: { option: any; onSave: (tiers: { id?: string; deadline: string; price: number; order?: number }[]) => void }) {
const [rows, setRows] = React.useState<{ id?: string; deadline: string; price: string; order?: number }[]>([]);
const [open, setOpen] = React.useState(false);
const [saving, setSaving] = React.useState(false);
@@ -54,7 +54,7 @@ function EarlyBirdTiersEditor({ option, onSave }: { option: any; onSave: (tiers:
try {
const tiers = rows
.filter((r) => !!r.deadline && String(r.price).trim() !== '')
.map((r, i) => ({ deadline: new Date(r.deadline).toISOString(), price: parseFloat(r.price), order: typeof r.order === 'number' ? r.order : i }))
.map((r, i) => ({ id: r.id, deadline: new Date(r.deadline).toISOString(), price: parseFloat(r.price), order: typeof r.order === 'number' ? r.order : i }))
.filter((t) => t.price >= 0 && !isNaN(new Date(t.deadline).getTime()));
onSave(tiers);
} finally {
+86 -45
View File
@@ -89,7 +89,9 @@ export default function UserDashboardPage() {
// Registration details modal
const [activeRegId, setActiveRegId] = useState<string | null>(null);
const [dialog, setDialog] = useState<{ open: boolean; message: string; loading?: boolean; loadingTitle?: string; loadingSubtitle?: string }>({ open: false, message: "", loading: false });
const [dialog, setDialog] = useState<{ open: boolean; message: string; loading?: boolean; loadingTitle?: string; loadingSubtitle?: string; title?: string }>({ open: false, message: "", loading: false });
// Channel-choice popup shown by the single "Invoice"/"Receipt" buttons — asks Email or WhatsApp.
const [channelPicker, setChannelPicker] = useState<{ kind: 'invoice' | 'receipt'; id: string } | null>(null);
// Track whether the active registration's event has attendee forms
const [activeEventHasForm, setActiveEventHasForm] = useState<boolean | null>(null);
@@ -253,26 +255,6 @@ export default function UserDashboardPage() {
}
};
const emailRegistration = async (registrationId: string) => {
if (!token) return;
setError(null);
setInfo(null);
// Show loading dialog while sending
setDialog({ open: true, message: "Sending tickets…", loading: true });
try {
const res: any = await apiFetch("/api/tickets/email", {
method: "POST",
body: { registrationId },
authToken: token,
});
const msg = (res && res.message) ? res.message : `Tickets for registration emailed successfully.`;
setDialog({ open: true, message: msg, loading: false });
} catch (e: any) {
setDialog({ open: false, message: "", loading: false });
setError(e?.message || "Failed to email registration tickets");
}
};
const whatsappTickets = async (ticketIds: string[]) => {
if (!token || ticketIds.length === 0) return;
if (!user?.phoneNumber) { setError("No phone number on your account. Add one in your profile."); return; }
@@ -293,26 +275,60 @@ export default function UserDashboardPage() {
}
};
const whatsappRegistration = async (registrationId: string) => {
const sendInvoice = async (registrationId: string, channel: 'email' | 'whatsapp') => {
if (!token) return;
if (!user?.phoneNumber) { setError("No phone number on your account. Add one in your profile."); return; }
setError(null);
setInfo(null);
setDialog({ open: true, message: "Sending to WhatsApp…", loading: true });
setDialog({
open: true, loading: true, message: "",
loadingTitle: channel === 'whatsapp' ? "Sending invoice to WhatsApp…" : "Sending invoice…",
loadingSubtitle: "Please wait while we prepare your invoice.",
});
try {
const res: any = await apiFetch("/api/tickets/email", {
const res: any = await apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}/send-invoice`, {
method: "POST",
body: { registrationId, channel: "whatsapp" },
body: { channel },
authToken: token,
});
const msg = (res && res.message) ? res.message : `Tickets sent to WhatsApp.`;
setDialog({ open: true, message: msg, loading: false });
setDialog({ open: true, loading: false, title: "Invoice sent", message: (res && res.message) || "Invoice sent." });
} catch (e: any) {
setDialog({ open: false, message: "", loading: false });
setError(e?.message || "Failed to send tickets to WhatsApp");
setError(e?.message || "Failed to send invoice");
}
};
const sendReceipt = async (paymentId: string, channel: 'email' | 'whatsapp') => {
if (!token) return;
setError(null);
setInfo(null);
setDialog({
open: true, loading: true, message: "",
loadingTitle: channel === 'whatsapp' ? "Sending receipt to WhatsApp…" : "Sending receipt…",
loadingSubtitle: "Please wait while we prepare your receipt.",
});
try {
const res: any = await apiFetch(`/api/payments/${encodeURIComponent(paymentId)}/send-receipt`, {
method: "POST",
body: { channel },
authToken: token,
});
setDialog({ open: true, loading: false, title: "Receipt sent", message: (res && res.message) || "Receipt sent." });
} catch (e: any) {
setDialog({ open: false, message: "", loading: false });
setError(e?.message || "Failed to send receipt");
}
};
// Resolves the channel-picker popup: dispatches to the invoice or receipt sender for
// whichever id it was opened with, then closes the popup.
const chooseChannel = (channel: 'email' | 'whatsapp') => {
if (!channelPicker) return;
const { kind, id } = channelPicker;
setChannelPicker(null);
if (kind === 'invoice') sendInvoice(id, channel);
else sendReceipt(id, channel);
};
// Creates a Yoco checkout for the full outstanding balance and redirects there directly —
// choosing a partial amount is only available from the supervisor payments dashboard.
const payNow = async (registrationId: string) => {
@@ -1129,9 +1145,12 @@ export default function UserDashboardPage() {
{activeBill && activeBill.payments.length > 0 && (
<div>
<div className="font-medium mb-1">Payments</div>
<ul className="text-sm list-disc pl-5 space-y-1">
<ul className="text-sm space-y-1">
{activeBill.payments.map((p: any) => (
<li key={p.id}>{new Date(p.createdAt).toLocaleString()} {formatRand(p.amount)} ({formatPaymentMethod(p.method)})</li>
<li key={p.id} className="flex items-center justify-between gap-2 py-0.5">
<span>{new Date(p.createdAt).toLocaleString()} {formatRand(p.amount)} ({formatPaymentMethod(p.method)})</span>
<button className="text-xs text-brand-700 hover:underline shrink-0" onClick={() => setChannelPicker({ kind: 'receipt', id: p.id })}>Receipt</button>
</li>
))}
</ul>
</div>
@@ -1143,24 +1162,21 @@ export default function UserDashboardPage() {
onClick={() => router.push(`/dashboard/user/forms?registrationId=${encodeURIComponent(activeReg.id)}`)}
>Attendee forms</button>
)}
{canModifyActive && activeBill && activeBill.outstanding > 0 ? (
<button
className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
onClick={() => setChannelPicker({ kind: 'invoice', id: activeReg.id })}
>Invoice</button>
{canModifyActive && activeBill && activeBill.outstanding > 0 && (
<button
className="px-3 py-1.5 text-sm bg-green-600 text-white rounded hover:bg-green-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1"
onClick={() => payNow(activeReg.id)}
>Make payment</button>
) : (
<>
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onClick={() => {
const regOptIds = new Set((activeReg.registrationOptions || []).map((o: any) => o.id));
const list = tickets.filter(t => regOptIds.has(t.registrationOptionId));
printTickets(list);
}}>Print all tickets</button>
<button className="px-3 py-1.5 text-sm bg-brand-600 text-white rounded hover:bg-brand-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1" onClick={() => emailRegistration(activeReg.id)}>Email all tickets</button>
{user?.phoneNumber && (
<button className="px-3 py-1.5 text-sm bg-green-600 text-white rounded hover:bg-green-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1" onClick={() => whatsappRegistration(activeReg.id)}>WhatsApp tickets</button>
)}
</>
)}
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onClick={() => {
const regOptIds = new Set((activeReg.registrationOptions || []).map((o: any) => o.id));
const list = tickets.filter(t => regOptIds.has(t.registrationOptionId));
printTickets(list);
}}>Print all tickets</button>
</div>
{/* Cancel registration — only shown when no payments have been made and the event still permits changes */}
@@ -1194,6 +1210,31 @@ export default function UserDashboardPage() {
</div>
)}
{channelPicker && (
<div
className="fixed inset-0 bg-black/40 flex items-center justify-center z-[60]"
onClick={() => setChannelPicker(null)}
>
<div className="bg-white rounded-lg shadow-lg w-full max-w-xs mx-4 p-5" onClick={e => e.stopPropagation()}>
<div className="text-base font-semibold mb-1">Send {channelPicker.kind === 'invoice' ? 'invoice' : 'receipt'}</div>
<p className="text-sm text-gray-600 mb-4">How would you like to receive it?</p>
<div className="flex flex-col gap-2">
<button
className="px-3 py-2 text-sm bg-brand-600 text-white rounded hover:bg-brand-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
onClick={() => chooseChannel('email')}
>Email</button>
{user?.phoneNumber && (
<button
className="px-3 py-2 text-sm bg-green-600 text-white rounded hover:bg-green-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1"
onClick={() => chooseChannel('whatsapp')}
>WhatsApp</button>
)}
</div>
<button className="mt-3 text-xs text-gray-500 hover:underline" onClick={() => setChannelPicker(null)}>Cancel</button>
</div>
</div>
)}
{dialog.open && (
<div
className="fixed inset-0 bg-black/40 flex items-center justify-center z-50"
@@ -1208,7 +1249,7 @@ export default function UserDashboardPage() {
</div>
) : (
<>
<div className="text-lg font-semibold mb-2">Tickets sent</div>
<div className="text-lg font-semibold mb-2">{dialog.title || "Tickets sent"}</div>
<p className="text-sm text-gray-700 mb-4">{dialog.message}</p>
<div className="flex justify-end">
<button
@@ -33,10 +33,35 @@ export default function UserPaymentsPage() {
const [payments, setPayments] = useState<PaymentItem[]>([]);
const [fetching, setFetching] = useState(false);
const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [total, setTotal] = useState(0);
// Receipt send: which payment's channel-choice popup is open, and whether a send is in flight.
const [receiptPickerId, setReceiptPickerId] = useState<string | null>(null);
const [sendingReceipt, setSendingReceipt] = useState(false);
const sendReceipt = async (paymentId: string, channel: 'email' | 'whatsapp') => {
if (!token) return;
setReceiptPickerId(null);
setError(null);
setInfo(null);
setSendingReceipt(true);
try {
const res: any = await apiFetch<any>(`/api/payments/${encodeURIComponent(paymentId)}/send-receipt`, {
method: "POST",
body: { channel },
authToken: token,
});
setInfo((res && res.message) || "Receipt sent.");
} catch (e: any) {
setError(e?.message || "Failed to send receipt");
} finally {
setSendingReceipt(false);
}
};
// Filters
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
@@ -89,6 +114,8 @@ export default function UserPaymentsPage() {
</div>
{error && <p className="text-red-600 text-sm mb-3">{error}</p>}
{info && <p className="text-green-700 text-sm mb-3">{info}</p>}
{sendingReceipt && <p className="text-gray-500 text-sm mb-3">Sending receipt</p>}
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="flex flex-wrap items-end gap-3 mb-4">
@@ -156,8 +183,19 @@ export default function UserPaymentsPage() {
</div>
<div className="text-xs text-gray-500">{formatDateTime(p.createdAt)}</div>
</div>
<div className="text-xs text-gray-600">Method: {formatPaymentMethod(p.method)}</div>
{eventTitle && <div className="text-xs text-gray-600">Event: {eventTitle}</div>}
<div className="flex items-center justify-between gap-2">
<div>
<div className="text-xs text-gray-600">Method: {formatPaymentMethod(p.method)}</div>
{eventTitle && <div className="text-xs text-gray-600">Event: {eventTitle}</div>}
</div>
{!isRefund && (
<button
className="text-xs text-brand-700 hover:underline shrink-0"
disabled={sendingReceipt}
onClick={() => setReceiptPickerId(p.id)}
>Receipt</button>
)}
</div>
</li>
);
})}
@@ -204,6 +242,31 @@ export default function UserPaymentsPage() {
</div>
)}
</div>
{receiptPickerId && (
<div
className="fixed inset-0 bg-black/40 flex items-center justify-center z-50"
onClick={() => setReceiptPickerId(null)}
>
<div className="bg-white rounded-lg shadow-lg w-full max-w-xs mx-4 p-5" onClick={e => e.stopPropagation()}>
<div className="text-base font-semibold mb-1">Send receipt</div>
<p className="text-sm text-gray-600 mb-4">How would you like to receive it?</p>
<div className="flex flex-col gap-2">
<button
className="px-3 py-2 text-sm bg-brand-600 text-white rounded hover:bg-brand-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
onClick={() => sendReceipt(receiptPickerId, 'email')}
>Email</button>
{user?.phoneNumber && (
<button
className="px-3 py-2 text-sm bg-green-600 text-white rounded hover:bg-green-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1"
onClick={() => sendReceipt(receiptPickerId, 'whatsapp')}
>WhatsApp</button>
)}
</div>
<button className="mt-3 text-xs text-gray-500 hover:underline" onClick={() => setReceiptPickerId(null)}>Cancel</button>
</div>
</div>
)}
</div>
);
}
+7 -1
View File
@@ -1,5 +1,5 @@
import React from "react";
import { Calendar, Ticket, CreditCard, HandHeart, Printer, CheckCircle2, ClipboardList, Clock, SquarePen, History, CheckSquare, Receipt } from "lucide-react";
import { Calendar, Ticket, CreditCard, HandHeart, Printer, CheckCircle2, ClipboardList, Clock, SquarePen, History, CheckSquare, Receipt, FileText } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types";
@@ -33,6 +33,9 @@ export const dashboardUserHelpContent: HelpContent = {
<GuideItem icon={SquarePen} title="Editing or cancelling" tone="violet">
Tap a registration to change quantities or options, pay what&apos;s outstanding, or cancel it entirely.
</GuideItem>
<GuideItem icon={FileText} title="Invoice" tone="rose">
The Invoice button sends a PDF breakdown of the registration itemized cost, any early-bird discount, and what&apos;s still owing (or &quot;Paid in full&quot;) to your email or WhatsApp, whichever you pick.
</GuideItem>
<GuideItem icon={History} title="Show past events" tone="gray">
Registrations page defaults to upcoming events only tick &quot;Show past events&quot; at the top to bring back ones that have already happened.
</GuideItem>
@@ -72,6 +75,9 @@ export const dashboardUserHelpContent: HelpContent = {
<GuideItem icon={Receipt} title="Payment history" tone="green">
The Payment history page lists every payment you&apos;ve made across all your registrations, past and present.
</GuideItem>
<GuideItem icon={Receipt} title="Receipts" tone="amber">
Every payment in a registration&apos;s Payments list, or on the Payment history page has its own Receipt button. Pick email or WhatsApp to get a PDF receipt for that specific payment.
</GuideItem>
<GuideItem icon={HandHeart} title="Donations" tone="rose">
A donation isn&apos;t tied to any one registration make one any time to support an event or the ministry directly. It can later be used to help cover an outstanding balance.
</GuideItem>