NobGit
public imalexnord read

Slot

Calendar-first scheduling powered by Cloudflare Workers and Google Calendar.

Languages

Repository composition by tracked source files.

TypeScript
TypeScript 79% CSS 19% SQL 2% HTML 0%
Create file Wiki Documentation
Clone
https://nobgit.com/user/imalexnord/slot.git
ssh://[email protected]:2222/user/imalexnord/slot.git

Commit

fix: clarify availability limits and document Stripe as optional

f1dbf89
Alex Nord <[email protected]> 25 hours ago
README.md        |   6 ++-
 src/App.tsx      |  50 +++++++++++++++---
 worker/index.ts  | 157 +++++++++++++++++++++++++++++++++++++++++--------------
 worker/stripe.ts |  21 ++++++--
 4 files changed, 183 insertions(+), 51 deletions(-)

Diff

diff --git a/README.md b/README.md
index 56b4169..c14753e 100644
--- a/README.md
+++ b/README.md
@@ -4,6 +4,10 @@ A small calendar-first booking app built as one Cloudflare Worker deployment.
 
 The visitor flow is intentionally simple:
 
+**date → time → duration → details → confirmed booking**
+
+When the owner enables paid bookings, Stripe Checkout is inserted before confirmation:
+
 **date → time → duration → details → Stripe payment → confirmed booking**
 
 There are no public accounts and no Google login for visitors. Google OAuth is private to the calendar owner.
@@ -17,7 +21,7 @@ There are no public accounts and no Google login for visitors. Google OAuth is p
 - Google Calendar FreeBusy for live availability
 - Google Calendar Events API for invites
 - Google Meet generated on the event when enabled
-- Stripe Checkout payments through the Stripe API, with payment required before confirmation when enabled
+- Optional Stripe Checkout payments through the Stripe API. Without Stripe, bookings confirm normally; when payment is enabled, payment is required before confirmation
 
 No GitHub Actions, Pages Functions, KV, Microsoft OAuth, external email service, or separately hosted backend.
 
diff --git a/src/App.tsx b/src/App.tsx
index 35dfe79..524aba1 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -46,7 +46,13 @@ type PaymentOption = {
   label: string;
 };
 
-type Slot = { start: string; time: string; durations: number[] };
+type DurationBlockReason = 'outside_hours' | 'unavailable';
+type Slot = {
+  start: string;
+  time: string;
+  durations: number[];
+  blockedDurations?: Record<string, DurationBlockReason>;
+};
 
 type Confirmation = {
   id: string;
@@ -176,6 +182,12 @@ async function api<T>(url: string, init?: RequestInit): Promise<T> {
   return data;
 }
 
+function checkoutSessionMode(sessionId?: string): 'test' | 'live' | 'unknown' {
+  if (sessionId?.startsWith('cs_test_')) return 'test';
+  if (sessionId?.startsWith('cs_live_')) return 'live';
+  return 'unknown';
+}
+
 function dateKey(date: Date): string {
   const year = date.getFullYear();
   const month = String(date.getMonth() + 1).padStart(2, '0');
@@ -592,7 +604,14 @@ function PublicBooking() {
   function renderDurationView() {
     if (!selectedSlot) return null;
     const available = new Set(selectedSlot.durations);
-    const hasDisabled = activeConfig.durations.some((minutes) => !available.has(minutes));
+    const disabledDurations = activeConfig.durations.filter((minutes) => !available.has(minutes));
+    const hasDisabled = disabledDurations.length > 0;
+    const disabledReasons = new Set(disabledDurations.map((minutes) => selectedSlot.blockedDurations?.[String(minutes)] ?? 'unavailable'));
+    const disabledHint = disabledReasons.size === 1 && disabledReasons.has('outside_hours')
+      ? "Greyed-out lengths extend past today's booking hours."
+      : disabledReasons.size === 1 && disabledReasons.has('unavailable')
+        ? 'Greyed-out lengths run into unavailable calendar time, an availability block, or a configured buffer.'
+        : "Greyed-out lengths either extend past today's booking hours or run into unavailable time.";
     return (
       <div className="panel-view" key="duration">
         <button className="panel-back" type="button" onClick={changeTime}>
@@ -614,7 +633,7 @@ function PublicBooking() {
                   className={`duration-button ${duration === minutes ? 'active' : ''}`}
                   disabled={!enabled}
                   aria-pressed={duration === minutes}
-                  title={enabled ? undefined : 'Runs into a later booking'}
+                  title={enabled ? undefined : (selectedSlot.blockedDurations?.[String(minutes)] === 'outside_hours' ? "Extends past today's booking hours" : 'Runs into unavailable calendar time or a configured block')}
                   onClick={() => setDuration(minutes)}
                 >
                   <strong>{minutes}</strong>
@@ -624,7 +643,7 @@ function PublicBooking() {
             })}
           </div>
           {hasDisabled && (
-            <p className="duration-hint">Greyed-out lengths would overlap a later booking.</p>
+            <p className="duration-hint">{disabledHint}</p>
           )}
         </div>
       </div>
@@ -917,7 +936,7 @@ function BookingRowItem({
       </div>
       <div className="booking-meta">
         <span className="booking-method"><Icon size={13} /> {modeMeta[booking.meeting_mode].label}</span>
-        <span>{booking.duration_minutes} min{booking.payment_status === 'paid' ? ' · Paid' : isPaymentHold ? ' · Awaiting payment' : ''}</span>
+        <span>{booking.duration_minutes} min{booking.payment_status === 'paid' ? ' · Paid' : isPaymentHold ? ` · Awaiting payment · ${checkoutSessionMode(booking.stripe_checkout_session_id).toUpperCase()}` : ''}</span>
       </div>
       <div className="booking-actions">
         <span className={`status-pill ${isCancelled ? 'cancelled' : isPast ? 'past' : 'upcoming'}`}>
@@ -1080,11 +1099,18 @@ function Admin() {
     setCancelling(true);
     setError('');
     try {
-      await api(`/api/admin/bookings/${cancelTarget.id}/cancel`, { method: 'POST' });
+      const result = await api<{ warning?: string }>(`/api/admin/bookings/${cancelTarget.id}/cancel`, {
+        method: 'POST',
+        body: JSON.stringify({ allowStaleStripeRelease: releasingPaymentHold }),
+      });
       setCancelTarget(null);
       setSelectedBooking(null);
       await refreshAdminData();
-      setNotice(releasingPaymentHold ? 'Payment hold released. The time is available again.' : 'Meeting cancelled. Google Calendar will notify the attendee.');
+      setNotice(releasingPaymentHold
+        ? result.warning
+          ? `Payment hold released. The time is available again. ${result.warning}`
+          : 'Payment hold released. The time is available again.'
+        : 'Meeting cancelled. Google Calendar will notify the attendee.');
     } catch (err) {
       setError(err instanceof Error ? err.message : 'Could not cancel meeting');
     } finally {
@@ -1441,7 +1467,7 @@ function Admin() {
                   <div className="empty-list">
                     <Inbox size={26} />
                     <strong>{bookingFilter === 'upcoming' ? 'No upcoming bookings' : 'No past bookings'}</strong>
-                    <span>{bookingFilter === 'upcoming' ? 'New bookings from your calendar page will show up here.' : 'Completed meetings will be listed here.'}</span>
+                    <span>{bookingFilter === 'upcoming' ? 'No Slot bookings are upcoming. Google Calendar busy time and availability rules can still make public times unavailable.' : 'Completed meetings will be listed here.'}</span>
                   </div>
                 ) : (
                   shown.map((booking) => (
@@ -1512,6 +1538,14 @@ function Admin() {
             <p className="muted">{cancelTarget.status === 'pending'
               ? 'This expires the open Stripe Checkout, removes the temporary calendar hold, and makes the time bookable again.'
               : 'This will also cancel the Google Calendar event and send normal Calendar cancellation notifications.'}</p>
+            {cancelTarget.status === 'pending'
+              && checkoutSessionMode(cancelTarget.stripe_checkout_session_id) !== 'unknown'
+              && stripeMode !== 'unknown'
+              && checkoutSessionMode(cancelTarget.stripe_checkout_session_id) !== stripeMode && (
+                <div className="error-banner" role="alert">
+                  This hold was created in Stripe {checkoutSessionMode(cancelTarget.stripe_checkout_session_id).toUpperCase()} mode, but Slot is currently using {stripeMode.toUpperCase()} mode. The current key cannot expire that old Checkout session. Releasing will still remove Slot's lock and calendar hold; the old Stripe Checkout may remain payable until its own expiry.
+                </div>
+            )}
             {cancelTarget.payment_status === 'paid' && (
               <div className="error-banner" role="alert">This booking is paid. Cancelling the calendar event will not issue a Stripe refund. Refund it separately in Stripe if needed.</div>
             )}
diff --git a/worker/index.ts b/worker/index.ts
index 4595de0..325d17d 100644
--- a/worker/index.ts
+++ b/worker/index.ts
@@ -23,6 +23,7 @@ import {
   createStripeCheckoutSession,
   expireStripeCheckoutSession,
   retrieveStripeCheckoutSession,
+  stripeCheckoutSessionMode,
   stripeConfigured,
   stripeMode,
   stripePaymentIntentId,
@@ -274,25 +275,33 @@ async function slotUnavailableRanges(env: Env, dateKey: string, timezone: string
   return ranges;
 }
 
+type DurationBlockReason = 'outside_hours' | 'unavailable';
+type AvailabilitySlot = {
+  start: string;
+  time: string;
+  durations: number[];
+  blockedDurations: Record<string, DurationBlockReason>;
+};
+
 async function computeAvailability(env: Env, dateKey: string) {
   const settings = await getSettings(env);
   await cleanupStalePendingBookings(env, settings);
   const connected = await hasGoogleConnection(env);
   if (!connected) {
-    return { settings, connected, date: dateKey, slots: [] as Array<{ start: string; time: string; durations: number[] }> };
+    return { settings, connected, date: dateKey, slots: [] as AvailabilitySlot[] };
   }
 
   if (!/^\d{4}-\d{2}-\d{2}$/.test(dateKey)) throw new Error('Invalid date');
   const today = dateKeyInZone(new Date(), settings.timezone);
   const maxDate = addDaysToDateKey(today, settings.bookingWindowDays);
   if (dateKey < today || dateKey > maxDate) {
-    return { settings, connected, date: dateKey, slots: [] as Array<{ start: string; time: string; durations: number[] }> };
+    return { settings, connected, date: dateKey, slots: [] as AvailabilitySlot[] };
   }
 
   const day = weekdayForDate(dateKey, settings.timezone);
   const intervals = settings.availability[String(day)] ?? [];
   if (!intervals.length) {
-    return { settings, connected, date: dateKey, slots: [] as Array<{ start: string; time: string; durations: number[] }> };
+    return { settings, connected, date: dateKey, slots: [] as AvailabilitySlot[] };
   }
 
   const queryStart = zonedDateTimeToUtc(dateKey, intervals[0].start, settings.timezone).getTime() - 6 * 3600_000;
@@ -309,7 +318,7 @@ async function computeAvailability(env: Env, dateKey: string) {
     ...slotBusy,
   ];
   const earliestAllowed = Date.now() + settings.minimumNoticeMinutes * 60_000;
-  const slots: Array<{ start: string; time: string; durations: number[] }> = [];
+  const slots: AvailabilitySlot[] = [];
 
   for (const interval of intervals) {
     const intervalStartMinutes = timeToMinutes(interval.start);
@@ -326,16 +335,29 @@ async function computeAvailability(env: Env, dateKey: string) {
       const start = zonedDateTimeToUtc(dateKey, time, settings.timezone).getTime();
       if (start < earliestAllowed) continue;
 
+      const blockedDurations: Record<string, DurationBlockReason> = {};
       const availableDurations = settings.durations.filter((duration) => {
         const endMinute = minute + duration;
-        if (endMinute > intervalEndMinutes) return false;
+        if (endMinute > intervalEndMinutes) {
+          blockedDurations[String(duration)] = 'outside_hours';
+          return false;
+        }
         const bufferedStart = addMinutes(start, -settings.bufferBeforeMinutes);
         const bufferedEnd = addMinutes(start, duration + settings.bufferAfterMinutes);
-        return !overlaps(bufferedStart, bufferedEnd, busy);
+        if (overlaps(bufferedStart, bufferedEnd, busy)) {
+          blockedDurations[String(duration)] = 'unavailable';
+          return false;
+        }
+        return true;
       });
 
       if (availableDurations.length) {
-        slots.push({ start: new Date(start).toISOString(), time, durations: availableDurations });
+        slots.push({
+          start: new Date(start).toISOString(),
+          time,
+          durations: availableDurations,
+          blockedDurations,
+        });
       }
     }
   }
@@ -404,9 +426,24 @@ async function releasePendingBooking(env: Env, settings: AppSettings, bookingId:
     }
     if (lastError) throw lastError;
   }
+  // Keep a cancelled audit row instead of deleting the booking. Besides making
+  // released holds visible in Admin history, this preserves the Stripe session id
+  // if a stale Checkout from another Stripe mode ever reports back later.
   await env.DB.batch([
     env.DB.prepare('DELETE FROM slot_locks WHERE booking_id = ?1').bind(bookingId),
-    env.DB.prepare("DELETE FROM bookings WHERE id = ?1 AND status = 'pending'").bind(bookingId),
+    env.DB.prepare(`
+      UPDATE bookings
+      SET status = 'cancelled',
+          payment_status = CASE WHEN payment_status = 'paid' THEN 'paid' ELSE 'cancelled' END,
+          google_event_id = NULL,
+          meet_url = NULL,
+          cancelled_at = COALESCE(cancelled_at, datetime('now')),
+          calendar_sync_status = 'cancelled',
+          calendar_sync_error = NULL,
+          calendar_sync_updated_at = datetime('now'),
+          updated_at = datetime('now')
+      WHERE id = ?1 AND status = 'pending'
+    `).bind(bookingId),
   ]);
 }
 
@@ -547,17 +584,28 @@ async function cleanupStalePendingBookings(env: Env, settings: AppSettings): Pro
 
   for (const row of stale.results ?? []) {
     if (row.stripe_checkout_session_id && stripeConfigured(env)) {
-      try {
-        const session = await retrieveStripeCheckoutSession(env, row.stripe_checkout_session_id);
-        if (session.payment_status === 'paid') {
-          await recordPaidSession(env, row.id, session);
-          await fulfillPaidBooking(env, row.id);
+      const sessionMode = stripeCheckoutSessionMode(row.stripe_checkout_session_id);
+      const configuredMode = stripeMode(env);
+      // These rows are already older than 35 minutes and Slot creates Checkout
+      // sessions with a 30-minute expires_at. If the Worker has since switched
+      // Stripe modes, the current key cannot retrieve the old session, but it is
+      // safe to release the expired local hold instead of keeping it forever.
+      const modeMismatch = sessionMode !== 'unknown'
+        && configuredMode !== 'unknown'
+        && sessionMode !== configuredMode;
+      if (!modeMismatch) {
+        try {
+          const session = await retrieveStripeCheckoutSession(env, row.stripe_checkout_session_id);
+          if (session.payment_status === 'paid') {
+            await recordPaidSession(env, row.id, session);
+            await fulfillPaidBooking(env, row.id);
+            continue;
+          }
+          if (session.status === 'open') continue;
+        } catch {
+          // Do not destroy a reservation when Stripe cannot be checked safely.
           continue;
         }
-        if (session.status === 'open') continue;
-      } catch {
-        // Do not destroy a reservation when Stripe cannot be checked safely.
-        continue;
       }
     }
     await releasePendingBooking(env, settings, row.id).catch(() => undefined);
@@ -836,7 +884,11 @@ async function paymentStatus(env: Env, sessionId: string): Promise<Response> {
   }
 }
 
-async function cancelPendingPayment(env: Env, bookingId: string): Promise<Response> {
+async function cancelPendingPayment(
+  env: Env,
+  bookingId: string,
+  options: { allowStaleStripeRelease?: boolean } = {},
+): Promise<Response> {
   if (!/^[0-9a-f-]{36}$/i.test(bookingId)) return json({ error: 'Invalid booking id.' }, 400);
   const booking = await paymentBookingById(env, bookingId);
   if (!booking) return json({ ok: true, state: 'cancelled' });
@@ -845,34 +897,60 @@ async function cancelPendingPayment(env: Env, bookingId: string): Promise<Respon
   if (booking.status !== 'pending') return json({ ok: true, state: booking.status });
 
   const settings = await getSettings(env);
-  if (booking.stripe_checkout_session_id && stripeConfigured(env)) {
-    try {
-      const session = await retrieveStripeCheckoutSession(env, booking.stripe_checkout_session_id);
-      if (session.payment_status === 'paid') {
-        await recordPaidSession(env, booking.id, session);
-        const confirmed = await fulfillPaidBooking(env, booking.id);
-        return json({ ok: true, state: 'confirmed', booking: publicConfirmation(confirmed) });
-      }
-      if (session.status === 'complete') {
-        return json({ ok: true, state: 'processing' });
+  let releaseWarning: string | null = null;
+  const sessionId = booking.stripe_checkout_session_id;
+
+  if (sessionId && stripeConfigured(env)) {
+    const sessionMode = stripeCheckoutSessionMode(sessionId);
+    const configuredMode = stripeMode(env);
+    const modeMismatch = sessionMode !== 'unknown'
+      && configuredMode !== 'unknown'
+      && sessionMode !== configuredMode;
+
+    if (modeMismatch) {
+      if (!options.allowStaleStripeRelease) {
+        return json({
+          error: `This payment hold was created in Stripe ${sessionMode} mode, but this deployment is using ${configuredMode} mode. Switch back to the ${sessionMode} key to expire that Checkout session, or release it from Admin.`,
+          code: 'stripe_mode_mismatch',
+          sessionMode,
+          configuredMode,
+        }, 409);
       }
-      if (session.status === 'open') await expireStripeCheckoutSession(env, session.id);
-    } catch (error) {
-      if (error instanceof StripeError) {
-        const session = await retrieveStripeCheckoutSession(env, booking.stripe_checkout_session_id).catch(() => null);
-        if (session?.payment_status === 'paid') {
+      releaseWarning = `Released locally. The hold belongs to Stripe ${sessionMode} mode while this Worker uses ${configuredMode} mode, so Slot could not expire the old Stripe Checkout session.`;
+    } else {
+      try {
+        const session = await retrieveStripeCheckoutSession(env, sessionId);
+        if (session.payment_status === 'paid') {
           await recordPaidSession(env, booking.id, session);
           const confirmed = await fulfillPaidBooking(env, booking.id);
           return json({ ok: true, state: 'confirmed', booking: publicConfirmation(confirmed) });
         }
-        return json({ error: error.message }, error.status);
+        if (session.status === 'complete') {
+          return json({ ok: true, state: 'processing' });
+        }
+        if (session.status === 'open') await expireStripeCheckoutSession(env, session.id);
+      } catch (error) {
+        if (error instanceof StripeError) {
+          // A resource_missing error is what Stripe returns when a Checkout
+          // session belongs to another account/mode. Admin has already made an
+          // explicit release decision, so let it clean up the local/calendar hold
+          // instead of trapping the slot behind a stale Stripe id.
+          if (options.allowStaleStripeRelease && (error.code === 'resource_missing' || /No such checkout\.session/i.test(error.message))) {
+            releaseWarning = 'Released locally because Stripe could not find the stored Checkout session with the currently configured key.';
+          } else {
+            return json({ error: error.message }, error.status);
+          }
+        } else {
+          throw error;
+        }
       }
-      throw error;
     }
+  } else if (sessionId && options.allowStaleStripeRelease) {
+    releaseWarning = 'Released locally. Stripe is not configured on this deployment, so Slot could not expire the stored Checkout session.';
   }
 
   await releasePendingBooking(env, settings, booking.id);
-  return json({ ok: true, state: 'cancelled' });
+  return json({ ok: true, state: 'cancelled', warning: releaseWarning });
 }
 
 async function paymentCancelReturn(env: Env, bookingId: string): Promise<Response> {
@@ -943,7 +1021,7 @@ async function handleStripeWebhook(request: Request, env: Env): Promise<Response
   }
 }
 
-async function cancelBooking(env: Env, bookingId: string): Promise<Response> {
+async function cancelBooking(env: Env, bookingId: string, allowStaleStripeRelease = false): Promise<Response> {
   if (!/^[0-9a-f-]{36}$/i.test(bookingId)) return json({ error: 'Invalid booking id.' }, 400);
   const booking = await env.DB.prepare(`
     SELECT id, google_event_id, status, payment_status
@@ -954,7 +1032,7 @@ async function cancelBooking(env: Env, bookingId: string): Promise<Response> {
   if (!booking) return json({ error: 'Booking not found.' }, 404);
   if (booking.status === 'cancelled') return json({ ok: true });
   if (booking.status === 'pending' && booking.payment_status !== 'not_requested') {
-    return cancelPendingPayment(env, bookingId);
+    return cancelPendingPayment(env, bookingId, { allowStaleStripeRelease });
   }
   if (booking.status !== 'confirmed') return json({ error: 'Only confirmed bookings or pending payment holds can be cancelled.' }, 409);
 
@@ -1203,7 +1281,8 @@ async function handleApi(request: Request, env: Env, url: URL): Promise<Response
 
     const cancelMatch = url.pathname.match(/^\/api\/admin\/bookings\/([^/]+)\/cancel$/);
     if (request.method === 'POST' && cancelMatch) {
-      return cancelBooking(env, cancelMatch[1]);
+      const body = await request.json().catch(() => ({})) as { allowStaleStripeRelease?: boolean };
+      return cancelBooking(env, cancelMatch[1], body.allowStaleStripeRelease === true);
     }
 
     if (request.method === 'GET' && url.pathname === '/api/admin/availability-rules') {
diff --git a/worker/stripe.ts b/worker/stripe.ts
index 890dd61..45ed817 100644
--- a/worker/stripe.ts
+++ b/worker/stripe.ts
@@ -1,7 +1,11 @@
 import type { Env } from './types';
 
 export class StripeError extends Error {
-  constructor(message: string, public readonly status = 502) {
+  constructor(
+    message: string,
+    public readonly status = 502,
+    public readonly code?: string,
+  ) {
     super(message);
   }
 }
@@ -60,10 +64,14 @@ async function stripeRequest<T>(env: Env, path: string, init: RequestInit = {}):
   }
 
   const data = (await response.json().catch(() => ({}))) as T & {
-    error?: { message?: string };
+    error?: { message?: string; code?: string };
   };
   if (!response.ok) {
-    throw new StripeError(data.error?.message || `Stripe request failed (${response.status}).`, 502);
+    throw new StripeError(
+      data.error?.message || `Stripe request failed (${response.status}).`,
+      response.status >= 500 ? 502 : response.status,
+      data.error?.code,
+    );
   }
   return data;
 }
@@ -81,6 +89,13 @@ export function stripeMode(env: Env): StripeMode {
   return 'unknown';
 }
 
+export function stripeCheckoutSessionMode(sessionId: string | null | undefined): StripeMode {
+  const id = sessionId?.trim() ?? '';
+  if (id.startsWith('cs_test_')) return 'test';
+  if (id.startsWith('cs_live_')) return 'live';
+  return 'unknown';
+}
+
 export async function createStripeCheckoutSession(
   env: Env,
   booking: {