When a booking is cancelled, the customer's calendar app needs a METHOD:CANCEL invite with the original UID to remove the existing event. Without it, the customer's calendar continues to show the appointment — they may still show up. Sending a METHOD:PUBLISH invite on cancellation makes the situation worse: the calendar client treats it as a new event confirmation rather than a removal. RFC 5546 defines METHOD:CANCEL specifically for this case. The failure is silent from the system's perspective (the email is sent, the queue job succeeds) but creates a confused customer experience and potential unnecessary no-shows.
Medium because without a METHOD:CANCEL invite, cancelled appointments persist on customer calendars, causing unnecessary arrivals and customer confusion that burdens support.
Add a generateCancelICS() function to src/lib/calendar.ts that uses the same stable UID as the original event and sets METHOD:CANCEL and STATUS:CANCELLED. Attach it to the cancellation email:
export function generateCancelICS(booking: Booking): string {
const dtstamp = new Date().toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'
return [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'METHOD:CANCEL',
'BEGIN:VEVENT',
`UID:${booking.id}@yourdomain.com`, // Same UID as original
`DTSTAMP:${dtstamp}`,
'STATUS:CANCELLED',
`SUMMARY:CANCELLED: ${booking.service_name}`,
`DTSTART:${formatICSDate(booking.start_time)}`,
`DTEND:${formatICSDate(booking.end_time)}`,
'END:VEVENT',
'END:VCALENDAR',
].join('\r\n')
}
Call this from the cancellation email worker, not from the main cancellation handler, to keep it on the retry path.
ID: booking-notifications-reminders.calendar-invites.ics-cancel
Severity: medium
What to look for: Find the booking cancellation code path. Search for: (1) A .ics generation with METHOD:CANCEL — this is distinct from the regular METHOD:PUBLISH. Search for the string "METHOD:CANCEL" or a library parameter that sets the method to CANCEL. (2) The CANCEL .ics must use the SAME UID as the original event (verify the same stable UID derivation). (3) The CANCEL .ics should include STATUS:CANCELLED. (4) The CANCEL .ics is attached to the cancellation email.
Pass criteria: Enumerate all 4 conditions (minimum 4 of 4 required). (1) A .ics with METHOD:CANCEL is generated — quote the code. (2) UID matches the original event's UID. (3) STATUS:CANCELLED is present. (4) CANCEL .ics is attached to or linked from the cancellation email. A regular METHOD:PUBLISH .ics does NOT count as a cancel invite.
Fail criteria: No .ics sent on cancellation. .ics sent but METHOD is PUBLISH instead of CANCEL (calendar client treats it as a new event). UID does not match original. CANCEL .ics generated but not attached to email.
Skip (N/A) when: Calendar invites not implemented (same criteria as ics-complete-uid skip).
Detail on fail: For each of the 4 conditions: "MET" or "NOT MET" with evidence. Example: "(1) METHOD:CANCEL: NOT MET — cancelBooking() at src/lib/bookings.ts:92 sends cancellation email but no .ics is generated or attached. (2) UID: N/A. (3) STATUS: N/A. (4) Attached: NOT MET.".
Remediation: Generate a CANCEL .ics with the same UID on booking cancellation:
// src/lib/calendar.ts
export function generateCancelICS(booking: Booking): string {
const dtstamp = new Date().toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'
return [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'METHOD:CANCEL',
'BEGIN:VEVENT',
`UID:${booking.id}@yourdomain.com`, // Same UID as original
`DTSTAMP:${dtstamp}`,
'STATUS:CANCELLED',
`SUMMARY:CANCELLED: ${booking.service_name}`,
`DTSTART:${formatICSDate(booking.start_time)}`,
`DTEND:${formatICSDate(booking.end_time)}`,
'END:VEVENT',
'END:VCALENDAR',
].join('\r\n')
}
Cross-reference: Booking Flow & Lifecycle audit checks cancellation state transitions. Email/SMS Compliance audit checks cancellation email compliance. Booking Calendar Availability audit checks that cancelled slots are freed.