Block self-service payment and cancellation on closed/past registrations

Extends the earlier user-dashboard fix: canEditActive is renamed
canModifyActive and now also gates the "Make payment" and "Cancel
registration" actions, not just editing. Backend enforcement added to
cancelRegistration and createYocoCheckout to block past-event
self-service payment/cancellation server-side (cashup-closed events
were already blocked via assertEventOpen; admins/supervisors are
exempt from the past-date check, consistent with existing overrides).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 16:51:10 +02:00
parent eb46ed8264
commit 349686e182
4 changed files with 21 additions and 8 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ and this project follows [Semantic Versioning](https://semver.org/).
### Fixed
- User dashboard: registration-edit errors now show inside the edit popup instead of being hidden behind it.
- User dashboard: closed events are now hidden by default alongside past events (revealed via "Show past events"), and the Edit button no longer appears for closed or past registrations.
- User dashboard: closed events are now hidden by default alongside past events (revealed via "Show past events"), and the Edit, Make payment, and Cancel registration actions no longer appear for closed or past registrations (also enforced server-side).
## [1.0.0] - 2026-07-23
+5 -1
View File
@@ -788,11 +788,15 @@ const createYocoCheckout = async (req, res) => {
// Branch 1: Registration payment — delegate to internal helper
if (registrationId) {
// Authorisation check (the internal helper skips this)
const reg = await prisma.registration.findUnique({ where: { id: registrationId }, select: { userId: true, eventId: true } });
const reg = await prisma.registration.findUnique({ where: { id: registrationId }, select: { userId: true, eventId: true, event: { select: { endDate: true } } } });
if (!reg) { res.status(404); throw new Error('Registration not found'); }
if (reg.userId !== userId && req.user.role !== 'admin' && req.user.role !== 'supervisor') {
res.status(403); throw new Error('Not authorized to create checkout for this registration');
}
// If event is in the past, block self-service payment (mirrors the registration-edit block)
if (req.user.role !== 'admin' && req.user.role !== 'supervisor' && reg.event?.endDate && new Date(reg.event.endDate).getTime() < Date.now()) {
res.status(400); throw new Error('This event has already ended; payment can no longer be made for this registration');
}
await assertEventOpen(reg.eventId, res);
// Refresh early-bird pricing — updates priceSnapshot if any tier has expired or sold out.
@@ -583,7 +583,8 @@ const updateRegistrationStatus = async (req, res) => {
const cancelRegistration = async (req, res) => {
try {
const registration = await prisma.registration.findUnique({
where: { id: req.params.id }
where: { id: req.params.id },
include: { event: { select: { endDate: true } } }
});
if (!registration) {
@@ -597,6 +598,12 @@ const cancelRegistration = async (req, res) => {
throw new Error('Not authorized to cancel this registration');
}
// If event is in the past, block self-cancellation (mirrors the edit-block above)
if (req.user.role !== 'admin' && registration.event?.endDate && new Date(registration.event.endDate).getTime() < Date.now()) {
res.status(400);
throw new Error('This event has already ended; registration can no longer be cancelled');
}
await assertEventOpen(registration.eventId, res);
// Check if registration can be cancelled
+7 -5
View File
@@ -447,7 +447,9 @@ export default function UserDashboardPage() {
const [editMinQty, setEditMinQty] = useState<Record<string, number>>({});
const [editError, setEditError] = useState<string | null>(null);
const canEditActive = useMemo(() => {
// Whether the active registration's event still permits self-service changes
// (edit items, pay, cancel) — false once the event is past or its cashup is closed.
const canModifyActive = useMemo(() => {
if (!activeReg) return false;
return !isEventOver(activeReg.event);
}, [activeReg]);
@@ -847,7 +849,7 @@ export default function UserDashboardPage() {
<div>
<div className="font-medium mb-1 flex items-center justify-between">
<span>Registration items</span>
{!editMode && canEditActive && (
{!editMode && canModifyActive && (
<button disabled={editLoading} onClick={beginEdit} className="px-2.5 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50">Edit</button>
)}
</div>
@@ -987,7 +989,7 @@ export default function UserDashboardPage() {
onClick={() => router.push(`/dashboard/user/forms?registrationId=${encodeURIComponent(activeReg.id)}`)}
>Attendee forms</button>
)}
{activeBill && activeBill.outstanding > 0 ? (
{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={() => router.push(`/dashboard/user/pay?registrationId=${encodeURIComponent(activeReg.id)}`)}
@@ -1007,8 +1009,8 @@ export default function UserDashboardPage() {
)}
</div>
{/* Cancel registration — only shown when no payments have been made */}
{activeReg.status !== 'cancelled' && (activeBill?.totalPaid ?? 0) === 0 && (
{/* Cancel registration — only shown when no payments have been made and the event still permits changes */}
{canModifyActive && activeReg.status !== 'cancelled' && (activeBill?.totalPaid ?? 0) === 0 && (
<div className="pt-3 border-t mt-1">
{!showCancelConfirm ? (
<button