public
imalexnord
read
Slot
Calendar-first scheduling powered by Cloudflare Workers and Google Calendar.
Languages
Repository composition by tracked source files.
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
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: {