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

feat: add rescheduling, time proposals and Stripe cancellation refunds

9aa588b
Alex Nord <[email protected]> 24 hours ago
ARCHITECTURE.md                        |  14 +-
 README.md                              |  29 ++-
 migrations/0004_booking_management.sql |  14 ++
 src/App.tsx                            | 311 ++++++++++++++++++++++-
 src/styles.css                         |  95 +++++++
 worker/crypto.ts                       |  10 +
 worker/google.ts                       | 101 ++++++++
 worker/index.ts                        | 439 ++++++++++++++++++++++++++++++---
 worker/stripe.ts                       |  34 ++-
 9 files changed, 1004 insertions(+), 43 deletions(-)
 create mode 100644 migrations/0004_booking_management.sql

Diff

diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 1d6d21c..64d7f4b 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -17,7 +17,8 @@ your-booking-domain.example
              │    OAuth state
              │    short-lived slot locks
              │    basic rate limits
-             │    Stripe payment state
+             │    Stripe payment/refund state
+             │    reschedule proposals (hashed bearer tokens)
              │
              ├── Google Calendar API
              │    FreeBusy
@@ -27,7 +28,8 @@ your-booking-domain.example
              │
              └── Stripe API
                   Checkout Sessions
-                  signed webhooks
+                  PaymentIntent refunds
+                  signed payment/refund webhooks
 ```
 
 ## Booking transaction
@@ -47,3 +49,11 @@ your-booking-domain.example
 ## Why D1 exists
 
 Google Calendar owns calendar truth. D1 only holds application state Google does not provide: UI settings, booking metadata, encrypted OAuth credentials, and concurrency locks.
+
+## Booking management
+
+A direct admin reschedule acquires short-lived D1 locks for the destination, rechecks Google FreeBusy, PATCHes the existing Google Calendar event with `sendUpdates=all`, and then moves the D1 booking record. The booking ID and Stripe payment remain the same.
+
+A suggested new time does **not** move or release the original booking. Slot stores the proposed start/end plus a SHA-256 hash of a random bearer token, then PATCHes the Google event description with a secure acceptance URL using `sendUpdates=all`. When the attendee accepts, Slot re-runs normal availability checks before moving the existing event.
+
+For a paid cancellation, Slot creates an idempotent full refund using the stored Stripe PaymentIntent before finalizing cancellation. Refund webhook events keep the D1 refund status current. If the configured Stripe mode does not match the payment's Checkout Session mode, Slot refuses the paid cancellation rather than cancelling without a safe refund path.
diff --git a/README.md b/README.md
index c14753e..b62fe63 100644
--- a/README.md
+++ b/README.md
@@ -40,7 +40,10 @@ No GitHub Actions, Pages Functions, KV, Microsoft OAuth, external email service,
 - Private `/admin` UI authenticated through the configured Google account
 - Stores the Google refresh token encrypted at rest in D1 using an AES-GCM key kept as a Worker secret
 - When payments are enabled, creates only a private calendar hold until Stripe confirms payment; the attendee invite and Meet link are sent only after payment
-- Verifies Stripe webhook signatures before recording payment state
+- Verifies Stripe webhook signatures before recording payment or refund state
+- Admin rescheduling moves the existing Google Calendar event and sends attendee updates
+- Admin can suggest a new time without moving the original booking; the attendee gets a secure accept link through the Google Calendar update
+- Cancelling a paid booking issues a full Stripe refund to the original payment method before Slot finalizes the cancellation
 
 ## First setup
 
@@ -180,6 +183,9 @@ checkout.session.completed
 checkout.session.async_payment_succeeded
 checkout.session.async_payment_failed
 checkout.session.expired
+refund.created
+refund.updated
+refund.failed
 ```
 
 4. Copy the webhook signing secret (it starts with `whsec_`) into the Worker:
@@ -205,9 +211,24 @@ Upgrading from v0.1 intentionally disables the old fixed-amount payment toggle u
 
 When Checkout starts, Slot creates a private owner-only calendar hold so nobody else can take the time. The visitor receives no invitation and no Meet link yet. After Stripe reports the payment as paid, Slot converts that hold into the real attendee event and sends the invitation. Cancelling or letting Checkout expire releases the hold.
 
-Checkout is limited to card-based payment methods because appointment inventory cannot safely remain reserved for delayed bank-payment methods. Payment status is stored on the booking and shown in admin. Cancelling a paid booking does **not** automatically issue a refund; handle refunds in Stripe separately.
+Checkout is limited to card-based payment methods because appointment inventory cannot safely remain reserved for delayed bank-payment methods. Payment status is stored on the booking and shown in admin.
 
-### Google project in Testing mode
+Cancelling a paid confirmed booking from Slot Admin issues a **full Stripe refund** against the original PaymentIntent. Slot will not silently cancel a paid booking if the currently configured Stripe key cannot access the environment that took the payment. Refund state is stored on the booking and updated by Stripe refund webhooks when those events are enabled. Refunds always go back to the original payment method.
+
+#
+## Booking management
+
+Open a confirmed future booking in `/admin` to manage its time:
+
+- **Reschedule** moves the existing booking to another currently available start time. The duration and payment stay unchanged. Google Calendar sends the attendee an updated invitation.
+- **Suggest new time** leaves the original booking untouched and sends a Google Calendar update containing a secure acceptance link. The suggested time is not held, so Slot rechecks availability when the attendee accepts it. If somebody else takes it first, the attendee is asked to contact the organizer instead of causing a double booking.
+- **Cancel meeting** cancels the Google Calendar event. If the booking was paid through Stripe, Slot first creates a full refund to the original payment method.
+
+Suggestion links contain a high-entropy bearer token. Slot stores only a SHA-256 hash of that token in D1, and the link becomes invalid when the suggestion is accepted, superseded, the booking is rescheduled directly, or the booking is cancelled.
+
+Paid reschedules keep the original charge because the meeting duration does not change. Slot does not create a second Checkout Session for a pure time change.
+
+## Google project in Testing mode
 
 If the Google OAuth audience is **External / Testing**, add the admin account as a test user. Google may expire testing-mode authorizations, so move the OAuth app to the appropriate production state when you are satisfied with it.
 
@@ -255,7 +276,7 @@ The Google refresh token is encrypted with AES-GCM before it is persisted in D1.
 This is a focused first version, not a Cal.com clone.
 
 - One owner/admin account
-- No self-service cancel/reschedule link yet
+- No general guest self-service cancel/reschedule portal yet; guests can securely accept organizer-proposed new times
 - No recurring availability exceptions UI yet (block time in Google Calendar instead)
 - Admin currently accepts calendar IDs as text rather than fetching a calendar picker
 - No custom reminder emails; Google Calendar sends the invitation
diff --git a/migrations/0004_booking_management.sql b/migrations/0004_booking_management.sql
new file mode 100644
index 0000000..eb7323a
--- /dev/null
+++ b/migrations/0004_booking_management.sql
@@ -0,0 +1,14 @@
+ALTER TABLE bookings ADD COLUMN rescheduled_at TEXT;
+ALTER TABLE bookings ADD COLUMN reschedule_count INTEGER NOT NULL DEFAULT 0;
+ALTER TABLE bookings ADD COLUMN proposed_start_time TEXT;
+ALTER TABLE bookings ADD COLUMN proposed_end_time TEXT;
+ALTER TABLE bookings ADD COLUMN proposed_at TEXT;
+ALTER TABLE bookings ADD COLUMN proposal_status TEXT;
+ALTER TABLE bookings ADD COLUMN proposal_token_hash TEXT;
+ALTER TABLE bookings ADD COLUMN refund_status TEXT NOT NULL DEFAULT 'not_requested';
+ALTER TABLE bookings ADD COLUMN stripe_refund_id TEXT;
+ALTER TABLE bookings ADD COLUMN refunded_amount INTEGER;
+ALTER TABLE bookings ADD COLUMN refunded_at TEXT;
+
+CREATE INDEX IF NOT EXISTS idx_bookings_proposal_token_hash ON bookings(proposal_token_hash);
+CREATE INDEX IF NOT EXISTS idx_bookings_refund_status ON bookings(refund_status);
diff --git a/src/App.tsx b/src/App.tsx
index 524aba1..6ab2933 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -109,8 +109,33 @@ type BookingRow = {
   paid_at?: string;
   cancelled_at?: string;
   created_at?: string;
+  rescheduled_at?: string;
+  reschedule_count?: number;
+  proposed_start_time?: string;
+  proposed_end_time?: string;
+  proposed_at?: string;
+  proposal_status?: string;
+  refund_status?: string;
+  stripe_refund_id?: string;
+  refunded_amount?: number;
+  refunded_at?: string;
 };
 
+
+type RescheduleProposal = {
+  title: string;
+  name: string;
+  currentStart: string;
+  currentEnd: string;
+  proposedStart: string;
+  proposedEnd: string;
+  duration: number;
+  timezone: string;
+  meetingMode: MeetingMode;
+};
+
+type BookingScheduleAction = 'reschedule' | 'suggest';
+
 type RecurringUnavailableRule = {
   id: string;
   weekdays: number[];
@@ -217,6 +242,17 @@ function todayInZone(timezone: string): string {
   return `${map.year}-${map.month}-${map.day}`;
 }
 
+function dateKeyForInstant(iso: string, timezone: string): string {
+  const parts = new Intl.DateTimeFormat('en-CA', {
+    timeZone: timezone,
+    year: 'numeric',
+    month: '2-digit',
+    day: '2-digit',
+  }).formatToParts(new Date(iso));
+  const map = Object.fromEntries(parts.filter((part) => part.type !== 'literal').map((part) => [part.type, part.value]));
+  return `${map.year}-${map.month}-${map.day}`;
+}
+
 function prettyDate(key: string, timezone: string): string {
   return new Intl.DateTimeFormat('en-US', {
     timeZone: timezone,
@@ -909,6 +945,93 @@ function ConfirmationView({ title, confirmation, onBookAnother }: { title: strin
   );
 }
 
+
+function RescheduleSuggestionPage() {
+  const token = decodeURIComponent(window.location.pathname.split('/').filter(Boolean)[1] ?? '');
+  const [proposal, setProposal] = useState<RescheduleProposal | null>(null);
+  const [accepted, setAccepted] = useState<Confirmation | null>(null);
+  const [loading, setLoading] = useState(true);
+  const [accepting, setAccepting] = useState(false);
+  const [error, setError] = useState('');
+
+  useEffect(() => {
+    api<RescheduleProposal>(`/api/reschedule-suggestions/${encodeURIComponent(token)}`)
+      .then((data) => {
+        setProposal(data);
+        document.title = `Reschedule · ${data.title}`;
+      })
+      .catch((err) => setError(err instanceof Error ? err.message : 'Could not load this suggestion.'))
+      .finally(() => setLoading(false));
+  }, [token]);
+
+  async function acceptSuggestion() {
+    setAccepting(true);
+    setError('');
+    try {
+      const result = await api<{ booking: Confirmation }>(`/api/reschedule-suggestions/${encodeURIComponent(token)}/accept`, {
+        method: 'POST',
+      });
+      setAccepted(result.booking);
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Could not accept the new time.');
+    } finally {
+      setAccepting(false);
+    }
+  }
+
+  if (loading) return <div className="center-screen"><Loader2 className="spin" /> Loading suggestion…</div>;
+  if (accepted && proposal) {
+    return (
+      <main className="admin-login-shell">
+        <section className="admin-login-card reschedule-public-card">
+          <ConfirmationView title={proposal.title} confirmation={accepted} onBookAnother={() => { window.location.href = '/'; }} />
+        </section>
+      </main>
+    );
+  }
+  if (!proposal) {
+    return (
+      <main className="admin-login-shell">
+        <section className="admin-login-card">
+          <span className="admin-login-icon"><CalendarDays /></span>
+          <p className="eyebrow">RESCHEDULE</p>
+          <h1>Suggestion unavailable.</h1>
+          <p>{error || 'This reschedule link has expired or was already used.'}</p>
+        </section>
+      </main>
+    );
+  }
+
+  return (
+    <main className="admin-login-shell">
+      <section className="admin-login-card reschedule-public-card">
+        <span className="admin-login-icon"><CalendarDays /></span>
+        <p className="eyebrow">NEW TIME SUGGESTED</p>
+        <h1>Does this time work?</h1>
+        <p>Hi {proposal.name}. The organizer suggested moving your meeting. Your current time stays booked until you accept.</p>
+        <div className="schedule-compare">
+          <div>
+            <span>Current</span>
+            <strong>{formatDateShort(proposal.currentStart, proposal.timezone)}</strong>
+            <small>{formatClock(proposal.currentStart, proposal.timezone)} – {formatClock(proposal.currentEnd, proposal.timezone)}</small>
+          </div>
+          <ArrowRight size={18} />
+          <div className="proposed">
+            <span>Suggested</span>
+            <strong>{formatDateShort(proposal.proposedStart, proposal.timezone)}</strong>
+            <small>{formatClock(proposal.proposedStart, proposal.timezone)} – {formatClock(proposal.proposedEnd, proposal.timezone)}</small>
+          </div>
+        </div>
+        <p className="muted">{proposal.duration} min · {proposal.timezone} · {modeMeta[proposal.meetingMode].label}</p>
+        {error && <div className="error-banner" role="alert">{error}</div>}
+        <button className="primary-button inline-button" type="button" onClick={acceptSuggestion} disabled={accepting}>
+          {accepting ? <><Loader2 className="spin" size={17} /> Rescheduling…</> : <>Accept new time <ArrowRight size={17} /></>}
+        </button>
+      </section>
+    </main>
+  );
+}
+
 function BookingRowItem({
   booking,
   timezone,
@@ -936,7 +1059,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 · ${checkoutSessionMode(booking.stripe_checkout_session_id).toUpperCase()}` : ''}</span>
+        <span>{booking.duration_minutes} min{booking.payment_status === 'paid' ? booking.refund_status === 'succeeded' ? ' · Refunded' : booking.refund_status === 'pending' ? ' · Refund pending' : ' · 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'}`}>
@@ -977,6 +1100,15 @@ function Admin() {
   const [selectedBooking, setSelectedBooking] = useState<BookingRow | null>(null);
   const [cancelTarget, setCancelTarget] = useState<BookingRow | null>(null);
   const [cancelling, setCancelling] = useState(false);
+  const [scheduleTarget, setScheduleTarget] = useState<BookingRow | null>(null);
+  const [scheduleAction, setScheduleAction] = useState<BookingScheduleAction>('reschedule');
+  const [scheduleDate, setScheduleDate] = useState('');
+  const [scheduleSlots, setScheduleSlots] = useState<Slot[]>([]);
+  const [scheduleStart, setScheduleStart] = useState('');
+  const [loadingScheduleSlots, setLoadingScheduleSlots] = useState(false);
+  const [savingScheduleAction, setSavingScheduleAction] = useState(false);
+  const [suggestionUrl, setSuggestionUrl] = useState('');
+  const [scheduleLinkCopied, copyScheduleLink] = useCopy();
   const [recurringForm, setRecurringForm] = useState({ weekdays: [1, 2, 3, 4, 5], start: '12:00', end: '13:00', label: 'Lunch' });
   const [blockForm, setBlockForm] = useState({ date: '', start: '09:00', end: '12:00', allDay: false, label: '' });
   const [theme, setTheme] = useState<ThemeId>(() => {
@@ -1024,6 +1156,72 @@ function Admin() {
     }).catch((err) => setError(err.message));
   }, [session]);
 
+
+  useEffect(() => {
+    if (!scheduleTarget || !scheduleDate) {
+      setScheduleSlots([]);
+      return;
+    }
+    let cancelled = false;
+    setLoadingScheduleSlots(true);
+    setScheduleStart('');
+    api<{ slots: Slot[] }>(`/api/admin/bookings/${scheduleTarget.id}/availability?date=${encodeURIComponent(scheduleDate)}`)
+      .then((data) => {
+        if (!cancelled) setScheduleSlots(data.slots);
+      })
+      .catch((err) => {
+        if (!cancelled) {
+          setScheduleSlots([]);
+          setError(err instanceof Error ? err.message : 'Could not load available times.');
+        }
+      })
+      .finally(() => {
+        if (!cancelled) setLoadingScheduleSlots(false);
+      });
+    return () => { cancelled = true; };
+  }, [scheduleTarget?.id, scheduleDate]);
+
+  function openScheduleDialog(booking: BookingRow, action: BookingScheduleAction) {
+    if (!settings) return;
+    setScheduleTarget(booking);
+    setScheduleAction(action);
+    setScheduleDate(dateKeyForInstant(booking.start_time, settings.timezone));
+    setScheduleStart('');
+    setScheduleSlots([]);
+    setSuggestionUrl('');
+    setError('');
+  }
+
+  async function submitScheduleAction() {
+    if (!scheduleTarget || !scheduleStart) return;
+    setSavingScheduleAction(true);
+    setError('');
+    try {
+      if (scheduleAction === 'reschedule') {
+        await api(`/api/admin/bookings/${scheduleTarget.id}/reschedule`, {
+          method: 'POST',
+          body: JSON.stringify({ start: scheduleStart }),
+        });
+        await refreshAdminData();
+        setScheduleTarget(null);
+        setSelectedBooking(null);
+        setNotice('Meeting rescheduled. Google Calendar sent the attendee an updated invitation.');
+      } else {
+        const result = await api<{ suggestionUrl: string }>(`/api/admin/bookings/${scheduleTarget.id}/suggest`, {
+          method: 'POST',
+          body: JSON.stringify({ start: scheduleStart }),
+        });
+        await refreshAdminData();
+        setSuggestionUrl(result.suggestionUrl);
+        setNotice('New time suggested. Google Calendar notified the attendee and the current meeting remains booked until they accept.');
+      }
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Could not update the meeting.');
+    } finally {
+      setSavingScheduleAction(false);
+    }
+  }
+
   async function refreshAdminData() {
     const [bookingData, availabilityData] = await Promise.all([
       api<{ bookings: BookingRow[] }>('/api/admin/bookings'),
@@ -1099,7 +1297,7 @@ function Admin() {
     setCancelling(true);
     setError('');
     try {
-      const result = await api<{ warning?: string }>(`/api/admin/bookings/${cancelTarget.id}/cancel`, {
+      const result = await api<{ warning?: string; refunded?: boolean; refundStatus?: string }>(`/api/admin/bookings/${cancelTarget.id}/cancel`, {
         method: 'POST',
         body: JSON.stringify({ allowStaleStripeRelease: releasingPaymentHold }),
       });
@@ -1110,7 +1308,9 @@ function Admin() {
         ? 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.');
+        : result.refunded
+          ? `Meeting cancelled and Stripe refund ${result.refundStatus === 'pending' ? 'started' : 'issued'}.${result.warning ? ` ${result.warning}` : ''}`
+          : `Meeting cancelled. Google Calendar will notify the attendee.${result.warning ? ` ${result.warning}` : ''}`);
     } catch (err) {
       setError(err instanceof Error ? err.message : 'Could not cancel meeting');
     } finally {
@@ -1495,11 +1695,15 @@ function Admin() {
                     <div><dt>Calendar</dt><dd>{selectedBooking.calendar_provider ?? 'google'}</dd></div>
                     <div><dt>Sync</dt><dd>{selectedBooking.calendar_sync_status ?? 'synced'}</dd></div>
                     <div><dt>Payment</dt><dd>{selectedBooking.payment_status ?? 'not_requested'}</dd></div>
+                    {selectedBooking.payment_status === 'paid' && <div><dt>Refund</dt><dd>{selectedBooking.refund_status ?? 'not_requested'}</dd></div>}
                     {selectedBooking.payment_amount != null && selectedBooking.payment_currency && (
                       <div><dt>Amount</dt><dd>{formatMoneyMinor(selectedBooking.payment_amount, selectedBooking.payment_currency)}</dd></div>
                     )}
                     {selectedBooking.paid_at && <div><dt>Paid</dt><dd>{formatInstant(`${selectedBooking.paid_at.replace(' ', 'T')}Z`, activeSettings.timezone)}</dd></div>}
                     {selectedBooking.created_at && <div><dt>Created</dt><dd>{formatInstant(`${selectedBooking.created_at.replace(' ', 'T')}Z`, activeSettings.timezone)}</dd></div>}
+                    {selectedBooking.rescheduled_at && <div><dt>Rescheduled</dt><dd>{formatInstant(`${selectedBooking.rescheduled_at.replace(' ', 'T')}Z`, activeSettings.timezone)}{selectedBooking.reschedule_count ? ` · ${selectedBooking.reschedule_count}×` : ''}</dd></div>}
+                    {selectedBooking.proposal_status === 'pending' && selectedBooking.proposed_start_time && <div><dt>Suggested time</dt><dd>{formatDateShort(selectedBooking.proposed_start_time, activeSettings.timezone)} {formatClock(selectedBooking.proposed_start_time, activeSettings.timezone)}</dd></div>}
+                    {selectedBooking.refunded_at && <div><dt>Refunded</dt><dd>{formatInstant(`${selectedBooking.refunded_at.replace(' ', 'T')}Z`, activeSettings.timezone)}</dd></div>}
                     {selectedBooking.cancelled_at && <div><dt>Cancelled</dt><dd>{formatInstant(`${selectedBooking.cancelled_at.replace(' ', 'T')}Z`, activeSettings.timezone)}</dd></div>}
                   </dl>
                   {selectedBooking.calendar_sync_status === 'failed' && selectedBooking.calendar_sync_error && (
@@ -1507,8 +1711,18 @@ function Admin() {
                   )}
                   <div className="booking-detail-actions">
                     {selectedBooking.status === 'confirmed' && selectedBooking.meet_url && <a className="secondary-button" href={selectedBooking.meet_url} target="_blank" rel="noreferrer">Open meeting link</a>}
-                    <button className="secondary-button" type="button" disabled>Reschedule</button>
-                    <button className="secondary-button" type="button" disabled>Suggest new time</button>
+                    <button
+                      className="secondary-button"
+                      type="button"
+                      disabled={selectedBooking.status !== 'confirmed' || Date.parse(selectedBooking.end_time) < Date.now()}
+                      onClick={() => openScheduleDialog(selectedBooking, 'reschedule')}
+                    >Reschedule</button>
+                    <button
+                      className="secondary-button"
+                      type="button"
+                      disabled={selectedBooking.status !== 'confirmed' || Date.parse(selectedBooking.end_time) < Date.now()}
+                      onClick={() => openScheduleDialog(selectedBooking, 'suggest')}
+                    >Suggest new time</button>
                     <button
                       className="danger-button"
                       type="button"
@@ -1524,6 +1738,80 @@ function Admin() {
           )}
         </section>
       </div>
+      {scheduleTarget && (
+        <div className="modal-backdrop" role="presentation">
+          <section className="confirm-dialog schedule-dialog" role="dialog" aria-modal="true" aria-labelledby="schedule-action-title">
+            <p className="eyebrow">{scheduleAction === 'reschedule' ? 'RESCHEDULE' : 'SUGGEST NEW TIME'}</p>
+            <h2 id="schedule-action-title">{scheduleAction === 'reschedule' ? 'Move this meeting?' : 'Propose a different time?'}</h2>
+            <div className="cancel-summary">
+              <strong>{scheduleTarget.name}</strong>
+              <span>Current: {formatDateShort(scheduleTarget.start_time, activeSettings.timezone)}</span>
+              <span>{formatClock(scheduleTarget.start_time, activeSettings.timezone)}-{formatClock(scheduleTarget.end_time, activeSettings.timezone)} · {scheduleTarget.duration_minutes} min</span>
+            </div>
+            <p className="muted">{scheduleAction === 'reschedule'
+              ? 'Choose an available start time. The existing Google Calendar event is moved and the attendee receives an updated invitation.'
+              : 'The existing meeting stays exactly where it is. Slot sends a Google Calendar update containing a secure acceptance link for the proposed time.'}</p>
+
+            {!suggestionUrl ? (
+              <>
+                <label className="schedule-date-label">Date
+                  <input
+                    type="date"
+                    min={todayInZone(activeSettings.timezone)}
+                    value={scheduleDate}
+                    onChange={(event) => setScheduleDate(event.target.value)}
+                  />
+                </label>
+                <div className="schedule-slot-picker" aria-label="Available start times">
+                  {loadingScheduleSlots ? (
+                    <span className="schedule-loading"><Loader2 className="spin" size={17} /> Checking availability…</span>
+                  ) : scheduleSlots.length ? (
+                    scheduleSlots.map((slot) => (
+                      <button
+                        type="button"
+                        key={slot.start}
+                        className={scheduleStart === slot.start ? 'active' : ''}
+                        onClick={() => setScheduleStart(slot.start)}
+                      >
+                        {slot.time}
+                      </button>
+                    ))
+                  ) : (
+                    <span className="schedule-loading">No {scheduleTarget.duration_minutes}-minute starts are available on this date.</span>
+                  )}
+                </div>
+              </>
+            ) : (
+              <div className="suggestion-link-box">
+                <strong>Suggestion sent</strong>
+                <span>Google Calendar notified the attendee. You can also send this link directly:</span>
+                <div className="suggestion-link-row">
+                  <input readOnly value={suggestionUrl} aria-label="Reschedule suggestion link" />
+                  <button type="button" className="secondary-button" onClick={() => copyScheduleLink(suggestionUrl)}>
+                    {scheduleLinkCopied ? <><Check size={15} /> Copied</> : <><Copy size={15} /> Copy</>}
+                  </button>
+                </div>
+              </div>
+            )}
+
+            <div className="dialog-actions">
+              <button
+                className="secondary-button"
+                type="button"
+                onClick={() => { setScheduleTarget(null); setSuggestionUrl(''); }}
+                disabled={savingScheduleAction}
+              >{suggestionUrl ? 'Done' : 'Close'}</button>
+              {!suggestionUrl && (
+                <button className="primary-button" type="button" onClick={submitScheduleAction} disabled={!scheduleStart || savingScheduleAction}>
+                  {savingScheduleAction
+                    ? <><Loader2 className="spin" size={16} /> {scheduleAction === 'reschedule' ? 'Rescheduling…' : 'Sending…'}</>
+                    : scheduleAction === 'reschedule' ? 'Reschedule meeting' : 'Send suggestion'}
+                </button>
+              )}
+            </div>
+          </section>
+        </div>
+      )}
       {cancelTarget && (
         <div className="modal-backdrop" role="presentation">
           <section className="confirm-dialog" role="dialog" aria-modal="true" aria-labelledby="cancel-title">
@@ -1547,14 +1835,19 @@ function Admin() {
                 </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>
+              <div className="refund-banner" role="status">
+                <strong>Full Stripe refund included</strong>
+                <span>{cancelTarget.payment_amount != null && cancelTarget.payment_currency
+                  ? `${formatMoneyMinor(cancelTarget.payment_amount, cancelTarget.payment_currency)} will be refunded to the original payment method before Slot finalizes the cancellation.`
+                  : 'Slot will refund the Stripe payment to the original payment method before finalizing the cancellation.'}</span>
+              </div>
             )}
             <div className="dialog-actions">
               <button className="secondary-button" type="button" onClick={() => setCancelTarget(null)} disabled={cancelling}>{cancelTarget.status === 'pending' ? 'Keep hold' : 'Keep meeting'}</button>
               <button className="danger-button" type="button" onClick={confirmCancelBooking} disabled={cancelling}>
                 {cancelling
                   ? <><Loader2 className="spin" size={16} /> {cancelTarget.status === 'pending' ? 'Releasing...' : 'Cancelling...'}</>
-                  : cancelTarget.status === 'pending' ? 'Release hold' : 'Cancel meeting'}
+                  : cancelTarget.status === 'pending' ? 'Release hold' : cancelTarget.payment_status === 'paid' ? 'Cancel & refund' : 'Cancel meeting'}
               </button>
             </div>
           </section>
@@ -1565,5 +1858,7 @@ function Admin() {
 }
 
 export default function App() {
-  return window.location.pathname.startsWith('/admin') ? <Admin /> : <PublicBooking />;
+  if (window.location.pathname.startsWith('/admin')) return <Admin />;
+  if (window.location.pathname.startsWith('/reschedule/')) return <RescheduleSuggestionPage />;
+  return <PublicBooking />;
 }
diff --git a/src/styles.css b/src/styles.css
index 7d340db..b0003af 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -1466,3 +1466,98 @@ select { width: 100%; color: var(--text); background: var(--surface-soft); borde
     scroll-behavior: auto !important;
   }
 }
+
+/* Booking management */
+.schedule-dialog { width: min(100%, 560px); }
+.schedule-date-label { display: grid; gap: 7px; color: var(--muted); font-size: 11px; font-weight: 700; }
+.schedule-date-label input,
+.suggestion-link-row input {
+  min-height: var(--control-h);
+  border: 1px solid var(--border);
+  border-radius: var(--radius-md);
+  background: var(--surface-soft);
+  color: var(--text);
+  padding: 10px 12px;
+  font: inherit;
+}
+.schedule-slot-picker {
+  min-height: 66px;
+  display: grid;
+  grid-template-columns: repeat(5, minmax(0, 1fr));
+  gap: 7px;
+}
+.schedule-slot-picker button {
+  min-height: 42px;
+  border: 1px solid var(--border);
+  border-radius: var(--radius-sm);
+  background: var(--surface-soft);
+  color: var(--text);
+  font: inherit;
+  font-size: 12px;
+  font-weight: 720;
+  cursor: pointer;
+}
+.schedule-slot-picker button:hover,
+.schedule-slot-picker button.active {
+  border-color: var(--text);
+  background: var(--text);
+  color: var(--bg);
+}
+.schedule-loading {
+  grid-column: 1 / -1;
+  min-height: 58px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 8px;
+  border: 1px dashed var(--border);
+  border-radius: var(--radius-md);
+  color: var(--muted);
+  font-size: 12px;
+  text-align: center;
+  padding: 12px;
+}
+.suggestion-link-box,
+.refund-banner {
+  display: grid;
+  gap: 7px;
+  border: 1px solid color-mix(in srgb, var(--success) 35%, var(--border));
+  border-radius: var(--radius-md);
+  background: color-mix(in srgb, var(--success) 8%, var(--surface));
+  padding: 13px;
+}
+.suggestion-link-box strong,
+.refund-banner strong { color: var(--success); font-size: 12px; }
+.suggestion-link-box > span,
+.refund-banner span { color: var(--muted); font-size: 11px; line-height: 1.5; }
+.suggestion-link-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 7px; }
+.suggestion-link-row input { min-width: 0; font-size: 11px; }
+.reschedule-public-card { width: min(100%, 610px); }
+.reschedule-public-card .confirmation-panel { min-height: 0; padding: 0; }
+.schedule-compare {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
+  align-items: center;
+  gap: 12px;
+  width: 100%;
+}
+.schedule-compare > div {
+  display: grid;
+  gap: 4px;
+  border: 1px solid var(--border);
+  border-radius: var(--radius-md);
+  background: var(--surface-soft);
+  padding: 13px;
+}
+.schedule-compare > div.proposed { border-color: color-mix(in srgb, var(--success) 40%, var(--border)); }
+.schedule-compare span { color: var(--faint); font-size: 9px; font-weight: 760; letter-spacing: .08em; text-transform: uppercase; }
+.schedule-compare strong { color: var(--text); font-size: 13px; }
+.schedule-compare small { color: var(--muted); font-size: 11px; }
+.primary-button:disabled { opacity: .45; cursor: not-allowed; }
+
+@media (max-width: 560px) {
+  .schedule-slot-picker { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+  .suggestion-link-row { grid-template-columns: 1fr; }
+  .schedule-compare { grid-template-columns: 1fr; }
+  .schedule-compare > svg { transform: rotate(90deg); justify-self: center; }
+}
diff --git a/worker/crypto.ts b/worker/crypto.ts
index 5017901..79fcf88 100644
--- a/worker/crypto.ts
+++ b/worker/crypto.ts
@@ -84,6 +84,16 @@ export async function decryptToken(env: Env, value: string): Promise<string> {
   return decoder.decode(plaintext);
 }
 
+
+export function randomUrlToken(bytes = 32): string {
+  return base64Url(crypto.getRandomValues(new Uint8Array(bytes)));
+}
+
+export async function sha256Base64Url(value: string): Promise<string> {
+  const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', encoder.encode(value)));
+  return base64Url(digest);
+}
+
 export function getCookie(request: Request, key: string): string | null {
   const cookie = request.headers.get('Cookie') ?? '';
   for (const part of cookie.split(';')) {
diff --git a/worker/google.ts b/worker/google.ts
index 7423490..f38f3a6 100644
--- a/worker/google.ts
+++ b/worker/google.ts
@@ -418,6 +418,107 @@ export async function confirmCalendarHold(
   return { eventId: data.id, meetUrl, htmlLink: data.htmlLink ?? null };
 }
 
+
+function managedBookingDescription(
+  env: Env,
+  booking: { phone?: string; message?: string; paid?: boolean },
+  extraLines: string[] = [],
+): string {
+  return [
+    `Booked through ${env.APP_URL}`,
+    booking.phone ? `Phone: ${booking.phone}` : '',
+    booking.message ? `Message: ${booking.message}` : '',
+    booking.paid ? 'Payment received via Stripe.' : '',
+    ...extraLines,
+  ].filter(Boolean).join('\n\n');
+}
+
+export async function rescheduleCalendarEvent(
+  env: Env,
+  settings: AppSettings,
+  eventId: string,
+  booking: {
+    phone?: string;
+    message?: string;
+    paid?: boolean;
+    start: string;
+    end: string;
+  },
+): Promise<{ eventId: string; meetUrl: string | null }> {
+  const token = await accessToken(env);
+  const calendarId = encodeURIComponent(settings.calendarId || 'primary');
+  const encodedEventId = encodeURIComponent(eventId);
+  const params = new URLSearchParams({ sendUpdates: 'all', conferenceDataVersion: '1' });
+  const response = await fetch(
+    `https://www.googleapis.com/calendar/v3/calendars/${calendarId}/events/${encodedEventId}?${params.toString()}`,
+    {
+      method: 'PATCH',
+      headers: {
+        Authorization: `Bearer ${token}`,
+        'Content-Type': 'application/json',
+      },
+      body: JSON.stringify({
+        start: { dateTime: booking.start, timeZone: settings.timezone },
+        end: { dateTime: booking.end, timeZone: settings.timezone },
+        description: managedBookingDescription(env, booking),
+      }),
+    },
+  );
+  if (!response.ok) await throwGoogleCalendarError(response, 'Google event reschedule');
+  const data = (await response.json()) as GoogleEventResponse & { error?: { message?: string } };
+  if (!data.id) throw new GoogleCalendarError('Google event reschedule', 502, data.error?.message ?? 'Missing event id');
+  const meetUrl = data.hangoutLink
+    ?? data.conferenceData?.entryPoints?.find((entry) => entry.entryPointType === 'video')?.uri
+    ?? null;
+  return { eventId: data.id, meetUrl };
+}
+
+export async function sendRescheduleSuggestion(
+  env: Env,
+  settings: AppSettings,
+  eventId: string,
+  booking: { phone?: string; message?: string; paid?: boolean },
+  proposal: { start: string; end: string; url: string },
+): Promise<void> {
+  const token = await accessToken(env);
+  const calendarId = encodeURIComponent(settings.calendarId || 'primary');
+  const encodedEventId = encodeURIComponent(eventId);
+  const params = new URLSearchParams({ sendUpdates: 'all' });
+  const formatter = new Intl.DateTimeFormat('en-US', {
+    timeZone: settings.timezone,
+    weekday: 'long',
+    month: 'long',
+    day: 'numeric',
+    hour: '2-digit',
+    minute: '2-digit',
+    hourCycle: 'h23',
+  });
+  const proposedText = `${formatter.format(new Date(proposal.start))} – ${new Intl.DateTimeFormat('en-US', {
+    timeZone: settings.timezone,
+    hour: '2-digit',
+    minute: '2-digit',
+    hourCycle: 'h23',
+  }).format(new Date(proposal.end))} (${settings.timezone})`;
+  const description = managedBookingDescription(env, booking, [
+    'Slot reschedule suggestion',
+    `Suggested new time: ${proposedText}`,
+    `Review and accept: ${proposal.url}`,
+    'The existing meeting time stays confirmed until the suggestion is accepted.',
+  ]);
+  const response = await fetch(
+    `https://www.googleapis.com/calendar/v3/calendars/${calendarId}/events/${encodedEventId}?${params.toString()}`,
+    {
+      method: 'PATCH',
+      headers: {
+        Authorization: `Bearer ${token}`,
+        'Content-Type': 'application/json',
+      },
+      body: JSON.stringify({ description }),
+    },
+  );
+  if (!response.ok) await throwGoogleCalendarError(response, 'Google reschedule suggestion');
+}
+
 export async function deleteCalendarEvent(
   env: Env,
   settings: AppSettings,
diff --git a/worker/index.ts b/worker/index.ts
index 325d17d..903124e 100644
--- a/worker/index.ts
+++ b/worker/index.ts
@@ -1,7 +1,9 @@
 import {
   clearSessionCookie,
   getCookie,
+  randomUrlToken,
   sessionCookie,
+  sha256Base64Url,
   signSession,
   verifySession,
 } from './crypto';
@@ -16,11 +18,14 @@ import {
   disconnectGoogle,
   hasGoogleConnection,
   queryBusy,
+  rescheduleCalendarEvent,
+  sendRescheduleSuggestion,
 } from './google';
 import { bookingAmountMinor } from './money';
 import { DEFAULT_SETTINGS, getSettings, saveSettings } from './settings';
 import {
   createStripeCheckoutSession,
+  createStripeRefund,
   expireStripeCheckoutSession,
   retrieveStripeCheckoutSession,
   stripeCheckoutSessionMode,
@@ -31,7 +36,7 @@ import {
   StripeError,
   verifyStripeWebhook,
 } from './stripe';
-import type { StripeCheckoutSession } from './stripe';
+import type { StripeCheckoutSession, StripeRefund } from './stripe';
 import {
   addDaysToDateKey,
   addMinutes,
@@ -383,6 +388,19 @@ type PaymentBookingRow = {
   payment_amount: number | null;
   payment_currency: string | null;
   stripe_checkout_session_id: string | null;
+  stripe_payment_intent_id: string | null;
+  paid_at: string | null;
+  rescheduled_at: string | null;
+  reschedule_count: number;
+  proposed_start_time: string | null;
+  proposed_end_time: string | null;
+  proposed_at: string | null;
+  proposal_status: string | null;
+  proposal_token_hash: string | null;
+  refund_status: string;
+  stripe_refund_id: string | null;
+  refunded_amount: number | null;
+  refunded_at: string | null;
   updated_at: string | null;
 };
 
@@ -403,7 +421,10 @@ async function paymentBookingById(env: Env, bookingId: string): Promise<PaymentB
   return env.DB.prepare(`
     SELECT id, name, email, phone, message, start_time, end_time, duration_minutes,
            timezone, meeting_mode, status, google_event_id, meet_url, payment_status,
-           payment_amount, payment_currency, stripe_checkout_session_id, updated_at
+           payment_amount, payment_currency, stripe_checkout_session_id, stripe_payment_intent_id, paid_at,
+           rescheduled_at, reschedule_count, proposed_start_time, proposed_end_time, proposed_at,
+           proposal_status, proposal_token_hash, refund_status, stripe_refund_id, refunded_amount,
+           refunded_at, updated_at
     FROM bookings
     WHERE id = ?1
   `).bind(bookingId).first<PaymentBookingRow>();
@@ -980,9 +1001,29 @@ async function handleStripeWebhook(request: Request, env: Env): Promise<Response
   const rawBody = await request.text();
   try {
     const event = await verifyStripeWebhook(env, rawBody, request.headers.get('Stripe-Signature'));
-    const session = event.data?.object;
-    if (!session || session.object !== 'checkout.session') return json({ received: true });
+    const object = event.data?.object;
+    if (!object) return json({ received: true });
+
+    if (object.object === 'refund') {
+      const refund = object as StripeRefund;
+      const bookingId = refund.metadata?.booking_id;
+      if (bookingId && /^[0-9a-f-]{36}$/i.test(bookingId)) {
+        const status = event.type === 'refund.failed' ? 'failed' : (refund.status ?? 'pending');
+        await env.DB.prepare(`
+          UPDATE bookings
+          SET refund_status = ?1,
+              stripe_refund_id = COALESCE(stripe_refund_id, ?2),
+              refunded_amount = COALESCE(?3, refunded_amount),
+              refunded_at = CASE WHEN ?1 = 'succeeded' THEN COALESCE(refunded_at, datetime('now')) ELSE refunded_at END,
+              updated_at = datetime('now')
+          WHERE id = ?4
+        `).bind(status, refund.id, refund.amount ?? null, bookingId).run();
+      }
+      return json({ received: true });
+    }
 
+    if (object.object !== 'checkout.session') return json({ received: true });
+    const session = object;
     const bookingId = session.metadata?.booking_id || session.client_reference_id;
     if (!bookingId || !/^[0-9a-f-]{36}$/i.test(bookingId)) return json({ received: true });
 
@@ -1021,37 +1062,326 @@ async function handleStripeWebhook(request: Request, env: Env): Promise<Response
   }
 }
 
-async function cancelBooking(env: Env, bookingId: string, allowStaleStripeRelease = false): Promise<Response> {
+
+async function assertRescheduleSlotAvailable(
+  env: Env,
+  booking: PaymentBookingRow,
+  startIso: string,
+): Promise<{ settings: AppSettings; startMs: number; endMs: number }> {
+  const startMs = Date.parse(startIso);
+  if (!Number.isFinite(startMs) || startMs <= Date.now()) throw new Error('Choose a future time.');
+  const settings = await getSettings(env);
+  const dateKey = dateKeyInZone(new Date(startMs), settings.timezone);
+  const availability = await computeAvailability(env, dateKey);
+  const matching = availability.slots.find((slot) => slot.start === new Date(startMs).toISOString());
+  if (!matching || !matching.durations.includes(booking.duration_minutes)) {
+    throw new Error('That suggested time is no longer available. Choose another time.');
+  }
+  return { settings, startMs, endMs: addMinutes(startMs, booking.duration_minutes) };
+}
+
+async function rescheduleConfirmedBooking(
+  env: Env,
+  booking: PaymentBookingRow,
+  startIso: string,
+): Promise<PaymentBookingRow> {
+  if (booking.status !== 'confirmed') throw new Error('Only confirmed bookings can be rescheduled.');
+  if (!booking.google_event_id) throw new Error('This booking is missing its Google Calendar event.');
+  const { settings, startMs, endMs } = await assertRescheduleSlotAvailable(env, booking, startIso);
+  const lockStart = addMinutes(startMs, -settings.bufferBeforeMinutes);
+  const lockEnd = addMinutes(endMs, settings.bufferAfterMinutes);
+  const keys = slotKeys(lockStart, lockEnd, settings.slotIncrementMinutes);
+
+  await env.DB.prepare('DELETE FROM slot_locks WHERE expires_at < ?1').bind(Date.now()).run();
+  await env.DB.prepare('DELETE FROM slot_locks WHERE booking_id = ?1').bind(booking.id).run();
+  try {
+    await env.DB.batch(keys.map((key) =>
+      env.DB.prepare('INSERT INTO slot_locks (slot_key, booking_id, expires_at) VALUES (?1, ?2, ?3)')
+        .bind(key, booking.id, Date.now() + 3 * 60_000),
+    ));
+  } catch {
+    throw new Error('Someone just took that time. Choose another slot.');
+  }
+
+  try {
+    const fresh = await queryBusy(env, settings, new Date(lockStart).toISOString(), new Date(lockEnd).toISOString());
+    const conflicts = fresh.some((range) => lockStart < Date.parse(range.end) && lockEnd > Date.parse(range.start));
+    if (conflicts) throw new Error('That time became busy in Google Calendar. Choose another slot.');
+
+    const updated = await rescheduleCalendarEvent(env, settings, booking.google_event_id, {
+      phone: booking.phone ?? undefined,
+      message: booking.message ?? undefined,
+      paid: booking.payment_status === 'paid',
+      start: new Date(startMs).toISOString(),
+      end: new Date(endMs).toISOString(),
+    });
+
+    try {
+      await env.DB.batch([
+        env.DB.prepare(`
+          UPDATE bookings
+          SET start_time = ?1,
+              end_time = ?2,
+              meet_url = COALESCE(?3, meet_url),
+              rescheduled_at = datetime('now'),
+              reschedule_count = reschedule_count + 1,
+              proposed_start_time = NULL,
+              proposed_end_time = NULL,
+              proposed_at = NULL,
+              proposal_status = NULL,
+              proposal_token_hash = NULL,
+              calendar_sync_status = 'synced',
+              calendar_sync_error = NULL,
+              calendar_sync_updated_at = datetime('now'),
+              updated_at = datetime('now')
+          WHERE id = ?4 AND status = 'confirmed'
+        `).bind(new Date(startMs).toISOString(), new Date(endMs).toISOString(), updated.meetUrl, booking.id),
+        env.DB.prepare('UPDATE slot_locks SET expires_at = ?1 WHERE booking_id = ?2')
+          .bind(Date.now() + 10 * 60_000, booking.id),
+      ]);
+    } catch (error) {
+      await rescheduleCalendarEvent(env, settings, booking.google_event_id, {
+        phone: booking.phone ?? undefined,
+        message: booking.message ?? undefined,
+        paid: booking.payment_status === 'paid',
+        start: booking.start_time,
+        end: booking.end_time,
+      }).catch(() => undefined);
+      throw error;
+    }
+  } catch (error) {
+    await env.DB.prepare('DELETE FROM slot_locks WHERE booking_id = ?1').bind(booking.id).run().catch(() => undefined);
+    throw error;
+  }
+
+  const result = await paymentBookingById(env, booking.id);
+  if (!result) throw new Error('Rescheduled booking record was not found.');
+  return result;
+}
+
+async function adminRescheduleBooking(env: Env, bookingId: string, start: string): 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
+  const booking = await paymentBookingById(env, bookingId);
+  if (!booking) return json({ error: 'Booking not found.' }, 404);
+  try {
+    const updated = await rescheduleConfirmedBooking(env, booking, start);
+    return json({ ok: true, booking: publicConfirmation(updated) });
+  } catch (error) {
+    if (error instanceof GoogleCalendarError) return json({ error: error.publicMessage }, 502);
+    return json({ error: errorMessage(error) }, /available|busy|future|took/i.test(errorMessage(error)) ? 409 : 400);
+  }
+}
+
+async function adminSuggestNewTime(env: Env, bookingId: string, start: string): 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({ error: 'Booking not found.' }, 404);
+  if (booking.status !== 'confirmed') return json({ error: 'Only confirmed bookings can receive a new-time suggestion.' }, 409);
+  if (!booking.google_event_id) return json({ error: 'This booking is missing its Google Calendar event.' }, 409);
+
+  try {
+    const { settings, startMs, endMs } = await assertRescheduleSlotAvailable(env, booking, start);
+    const token = randomUrlToken();
+    const tokenHash = await sha256Base64Url(token);
+    const suggestionUrl = `${env.APP_URL.replace(/\/+$/, '')}/reschedule/${encodeURIComponent(token)}`;
+    await env.DB.prepare(`
+      UPDATE bookings
+      SET proposed_start_time = ?1,
+          proposed_end_time = ?2,
+          proposed_at = datetime('now'),
+          proposal_status = 'pending',
+          proposal_token_hash = ?3,
+          updated_at = datetime('now')
+      WHERE id = ?4 AND status = 'confirmed'
+    `).bind(new Date(startMs).toISOString(), new Date(endMs).toISOString(), tokenHash, booking.id).run();
+
+    try {
+      await sendRescheduleSuggestion(env, settings, booking.google_event_id, {
+        phone: booking.phone ?? undefined,
+        message: booking.message ?? undefined,
+        paid: booking.payment_status === 'paid',
+      }, {
+        start: new Date(startMs).toISOString(),
+        end: new Date(endMs).toISOString(),
+        url: suggestionUrl,
+      });
+    } catch (error) {
+      await env.DB.prepare(`
+        UPDATE bookings
+        SET proposed_start_time = NULL, proposed_end_time = NULL, proposed_at = NULL,
+            proposal_status = NULL, proposal_token_hash = NULL, updated_at = datetime('now')
+        WHERE id = ?1
+      `).bind(booking.id).run().catch(() => undefined);
+      throw error;
+    }
+
+    return json({
+      ok: true,
+      suggestionUrl,
+      proposedStart: new Date(startMs).toISOString(),
+      proposedEnd: new Date(endMs).toISOString(),
+    });
+  } catch (error) {
+    if (error instanceof GoogleCalendarError) return json({ error: error.publicMessage }, 502);
+    return json({ error: errorMessage(error) }, /available|busy|future/i.test(errorMessage(error)) ? 409 : 400);
+  }
+}
+
+async function proposalBookingByToken(env: Env, token: string): Promise<PaymentBookingRow | null> {
+  if (!/^[A-Za-z0-9_-]{20,200}$/.test(token)) return null;
+  const hash = await sha256Base64Url(token);
+  return env.DB.prepare(`
+    SELECT id, name, email, phone, message, start_time, end_time, duration_minutes,
+           timezone, meeting_mode, status, google_event_id, meet_url, payment_status,
+           payment_amount, payment_currency, stripe_checkout_session_id, stripe_payment_intent_id, paid_at,
+           rescheduled_at, reschedule_count, proposed_start_time, proposed_end_time, proposed_at,
+           proposal_status, proposal_token_hash, refund_status, stripe_refund_id, refunded_amount,
+           refunded_at, updated_at
     FROM bookings
-    WHERE id = ?1
-  `).bind(bookingId).first<{ id: string; google_event_id: string | null; status: string; payment_status: string }>();
+    WHERE proposal_token_hash = ?1
+      AND proposal_status = 'pending'
+  `).bind(hash).first<PaymentBookingRow>();
+}
+
+async function getRescheduleProposal(env: Env, token: string): Promise<Response> {
+  const booking = await proposalBookingByToken(env, token);
+  if (!booking || !booking.proposed_start_time || !booking.proposed_end_time) {
+    return json({ error: 'This reschedule suggestion is invalid or no longer active.' }, 404);
+  }
+  const settings = await getSettings(env);
+  return json({
+    title: settings.title,
+    name: booking.name,
+    currentStart: booking.start_time,
+    currentEnd: booking.end_time,
+    proposedStart: booking.proposed_start_time,
+    proposedEnd: booking.proposed_end_time,
+    duration: booking.duration_minutes,
+    timezone: booking.timezone,
+    meetingMode: booking.meeting_mode,
+  });
+}
+
+async function acceptRescheduleProposal(env: Env, token: string): Promise<Response> {
+  const booking = await proposalBookingByToken(env, token);
+  if (!booking || !booking.proposed_start_time) {
+    return json({ error: 'This reschedule suggestion is invalid or no longer active.' }, 404);
+  }
+  try {
+    const updated = await rescheduleConfirmedBooking(env, booking, booking.proposed_start_time);
+    return json({ ok: true, booking: publicConfirmation(updated) });
+  } catch (error) {
+    if (error instanceof GoogleCalendarError) return json({ error: error.publicMessage }, 502);
+    return json({ error: errorMessage(error) }, /available|busy|future|took/i.test(errorMessage(error)) ? 409 : 400);
+  }
+}
+
+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 paymentBookingById(env, bookingId);
 
   if (!booking) return json({ error: 'Booking not found.' }, 404);
-  if (booking.status === 'cancelled') return json({ ok: true });
+  if (booking.status === 'cancelled') {
+    return json({
+      ok: true,
+      refunded: booking.refund_status === 'succeeded' || booking.refund_status === 'pending',
+      refundStatus: booking.refund_status,
+    });
+  }
   if (booking.status === 'pending' && booking.payment_status !== 'not_requested') {
     return cancelPendingPayment(env, bookingId, { allowStaleStripeRelease });
   }
   if (booking.status !== 'confirmed') return json({ error: 'Only confirmed bookings or pending payment holds can be cancelled.' }, 409);
 
+  let refundStatus = booking.refund_status;
+  let refunded = refundStatus === 'succeeded' || refundStatus === 'pending';
+  let refundWarning: string | null = null;
+
+  if (booking.payment_status === 'paid' && !refunded) {
+    if (!stripeConfigured(env)) {
+      return json({ error: 'This booking was paid, but Stripe is not configured. Slot will not cancel it without attempting the refund.' }, 503);
+    }
+    const sessionMode = stripeCheckoutSessionMode(booking.stripe_checkout_session_id);
+    const configuredMode = stripeMode(env);
+    if (sessionMode !== 'unknown' && configuredMode !== 'unknown' && sessionMode !== configuredMode) {
+      return json({
+        error: `This booking was paid in Stripe ${sessionMode} mode, but Slot is currently using ${configuredMode} mode. Switch to the ${sessionMode} Stripe key before cancelling so Slot can refund the payment.`,
+        code: 'stripe_mode_mismatch',
+      }, 409);
+    }
+
+    let paymentIntentId = booking.stripe_payment_intent_id;
+    if (!paymentIntentId && booking.stripe_checkout_session_id) {
+      try {
+        const session = await retrieveStripeCheckoutSession(env, booking.stripe_checkout_session_id);
+        paymentIntentId = stripePaymentIntentId(session);
+        if (paymentIntentId) {
+          await env.DB.prepare(`
+            UPDATE bookings
+            SET stripe_payment_intent_id = ?1, updated_at = datetime('now')
+            WHERE id = ?2
+          `).bind(paymentIntentId, booking.id).run();
+        }
+      } catch (error) {
+        if (error instanceof StripeError) return json({ error: error.message }, error.status);
+        throw error;
+      }
+    }
+    if (!paymentIntentId) {
+      return json({ error: 'This paid booking is missing its Stripe PaymentIntent, so Slot cannot safely refund it.' }, 409);
+    }
+
+    try {
+      const refund = await createStripeRefund(env, paymentIntentId, booking.id);
+      refundStatus = refund.status ?? 'pending';
+      if (refundStatus === 'failed' || refundStatus === 'canceled' || refundStatus === 'requires_action') {
+        await env.DB.prepare(`
+          UPDATE bookings
+          SET refund_status = ?1,
+              stripe_refund_id = ?2,
+              refunded_amount = ?3,
+              updated_at = datetime('now')
+          WHERE id = ?4
+        `).bind(refundStatus, refund.id, refund.amount, booking.id).run();
+        return json({ error: `Stripe created refund ${refund.id}, but its status is ${refundStatus}. The booking has not been cancelled.` }, 502);
+      }
+      refunded = true;
+      await env.DB.prepare(`
+        UPDATE bookings
+        SET refund_status = ?1,
+            stripe_refund_id = ?2,
+            refunded_amount = ?3,
+            refunded_at = CASE WHEN ?1 = 'succeeded' THEN datetime('now') ELSE refunded_at END,
+            updated_at = datetime('now')
+        WHERE id = ?4
+      `).bind(refundStatus, refund.id, refund.amount, booking.id).run();
+      if (refundStatus === 'pending') {
+        refundWarning = 'Stripe accepted the refund, but it is still pending.';
+      }
+    } catch (error) {
+      if (error instanceof StripeError) return json({ error: `Stripe refund failed: ${error.message}` }, error.status);
+      throw error;
+    }
+  }
+
   const settings = await getSettings(env);
+  let calendarWarning: string | null = null;
   try {
-    if (booking.google_event_id) {
-      await deleteCalendarEvent(env, settings, booking.google_event_id);
-    }
+    if (booking.google_event_id) await deleteCalendarEvent(env, settings, booking.google_event_id);
   } catch (error) {
     const message = privateCalendarError(error);
-    await env.DB.prepare(`
-      UPDATE bookings
-      SET calendar_sync_status = 'failed',
-          calendar_sync_error = ?1,
-          calendar_sync_updated_at = datetime('now'),
-          updated_at = datetime('now')
-      WHERE id = ?2
-    `).bind(message, bookingId).run();
-    return json({ error: 'Slot could not cancel the Google Calendar event. The booking has not been cancelled.' }, 502);
+    if (!refunded) {
+      await env.DB.prepare(`
+        UPDATE bookings
+        SET calendar_sync_status = 'failed',
+            calendar_sync_error = ?1,
+            calendar_sync_updated_at = datetime('now'),
+            updated_at = datetime('now')
+        WHERE id = ?2
+      `).bind(message, bookingId).run();
+      return json({ error: 'Slot could not cancel the Google Calendar event. The booking has not been cancelled.' }, 502);
+    }
+    calendarWarning = 'The Stripe refund was created, but Google Calendar could not be updated. Remove the calendar event manually.';
   }
 
   await env.DB.batch([
@@ -1059,16 +1389,26 @@ async function cancelBooking(env: Env, bookingId: string, allowStaleStripeReleas
       UPDATE bookings
       SET status = 'cancelled',
           cancelled_at = datetime('now'),
-          calendar_sync_status = 'synced',
-          calendar_sync_error = NULL,
+          proposed_start_time = NULL,
+          proposed_end_time = NULL,
+          proposed_at = NULL,
+          proposal_status = NULL,
+          proposal_token_hash = NULL,
+          calendar_sync_status = ?1,
+          calendar_sync_error = ?2,
           calendar_sync_updated_at = datetime('now'),
           updated_at = datetime('now')
-      WHERE id = ?1
-    `).bind(bookingId),
+      WHERE id = ?3
+    `).bind(calendarWarning ? 'failed' : 'synced', calendarWarning, bookingId),
     env.DB.prepare('DELETE FROM slot_locks WHERE booking_id = ?1').bind(bookingId),
   ]);
 
-  return json({ ok: true });
+  return json({
+    ok: true,
+    refunded,
+    refundStatus,
+    warning: [refundWarning, calendarWarning].filter(Boolean).join(' ') || undefined,
+  });
 }
 
 function cleanLabel(value: unknown): string | null {
@@ -1239,6 +1579,15 @@ async function handleApi(request: Request, env: Env, url: URL): Promise<Response
     return handleStripeWebhook(request, env);
   }
 
+  const proposalMatch = url.pathname.match(/^\/api\/reschedule-suggestions\/([^/]+)$/);
+  if (request.method === 'GET' && proposalMatch) {
+    return getRescheduleProposal(env, decodeURIComponent(proposalMatch[1]));
+  }
+  const proposalAcceptMatch = url.pathname.match(/^\/api\/reschedule-suggestions\/([^/]+)\/accept$/);
+  if (request.method === 'POST' && proposalAcceptMatch) {
+    return acceptRescheduleProposal(env, decodeURIComponent(proposalAcceptMatch[1]));
+  }
+
   if (url.pathname.startsWith('/api/admin/')) {
     const admin = await requireAdmin(request, env);
     if (!admin) return json({ error: 'Unauthorized' }, 401);
@@ -1271,7 +1620,10 @@ async function handleApi(request: Request, env: Env, url: URL): Promise<Response
                cancelled_at, calendar_provider, calendar_sync_status,
                calendar_sync_error, calendar_sync_updated_at,
                payment_status, payment_amount, payment_currency,
-               stripe_checkout_session_id, stripe_payment_intent_id, paid_at
+               stripe_checkout_session_id, stripe_payment_intent_id, paid_at,
+               rescheduled_at, reschedule_count, proposed_start_time, proposed_end_time,
+               proposed_at, proposal_status, refund_status, stripe_refund_id,
+               refunded_amount, refunded_at
         FROM bookings
         ORDER BY start_time DESC
         LIMIT 50
@@ -1285,6 +1637,37 @@ async function handleApi(request: Request, env: Env, url: URL): Promise<Response
       return cancelBooking(env, cancelMatch[1], body.allowStaleStripeRelease === true);
     }
 
+    const bookingAvailabilityMatch = url.pathname.match(/^\/api\/admin\/bookings\/([^/]+)\/availability$/);
+    if (request.method === 'GET' && bookingAvailabilityMatch) {
+      const booking = await paymentBookingById(env, bookingAvailabilityMatch[1]);
+      if (!booking) return json({ error: 'Booking not found.' }, 404);
+      if (booking.status !== 'confirmed') return json({ error: 'Only confirmed bookings can be rescheduled.' }, 409);
+      const date = url.searchParams.get('date') ?? '';
+      try {
+        const result = await computeAvailability(env, date);
+        return json({
+          date: result.date,
+          timezone: result.settings.timezone,
+          duration: booking.duration_minutes,
+          slots: result.slots.filter((slot) => slot.durations.includes(booking.duration_minutes)),
+        });
+      } catch (error) {
+        return json({ error: errorMessage(error) }, 400);
+      }
+    }
+
+    const rescheduleMatch = url.pathname.match(/^\/api\/admin\/bookings\/([^/]+)\/reschedule$/);
+    if (request.method === 'POST' && rescheduleMatch) {
+      const body = await request.json().catch(() => ({})) as { start?: string };
+      return adminRescheduleBooking(env, rescheduleMatch[1], String(body.start ?? ''));
+    }
+
+    const suggestMatch = url.pathname.match(/^\/api\/admin\/bookings\/([^/]+)\/suggest$/);
+    if (request.method === 'POST' && suggestMatch) {
+      const body = await request.json().catch(() => ({})) as { start?: string };
+      return adminSuggestNewTime(env, suggestMatch[1], String(body.start ?? ''));
+    }
+
     if (request.method === 'GET' && url.pathname === '/api/admin/availability-rules') {
       return listAvailabilityRules(env);
     }
diff --git a/worker/stripe.ts b/worker/stripe.ts
index 45ed817..bb59725 100644
--- a/worker/stripe.ts
+++ b/worker/stripe.ts
@@ -24,6 +24,18 @@ export type StripeCheckoutSession = {
 };
 
 
+
+export type StripeRefund = {
+  id: string;
+  object: 'refund';
+  amount: number;
+  currency?: string | null;
+  payment_intent?: string | null;
+  status?: 'pending' | 'requires_action' | 'succeeded' | 'failed' | 'canceled' | null;
+  metadata?: Record<string, string> | null;
+  failure_reason?: string | null;
+};
+
 type StripePaymentIntent = {
   id: string;
   object: 'payment_intent';
@@ -39,7 +51,7 @@ type StripeCharge = {
 type StripeEvent = {
   id: string;
   type: string;
-  data?: { object?: StripeCheckoutSession };
+  data?: { object?: StripeCheckoutSession | StripeRefund };
 };
 
 function stripeSecret(env: Env): string {
@@ -217,6 +229,26 @@ export async function verifyStripeWebhook(
 }
 
 
+
+export async function createStripeRefund(
+  env: Env,
+  paymentIntentId: string,
+  bookingId: string,
+): Promise<StripeRefund> {
+  if (!/^pi_[A-Za-z0-9_]+$/.test(paymentIntentId)) {
+    throw new StripeError('Invalid Stripe PaymentIntent id.', 400);
+  }
+  const params = new URLSearchParams();
+  params.set('payment_intent', paymentIntentId);
+  params.set('metadata[booking_id]', bookingId);
+  params.set('metadata[source]', 'slot_booking_cancellation');
+  return stripeRequest<StripeRefund>(env, '/v1/refunds', {
+    method: 'POST',
+    body: params.toString(),
+    headers: { 'Idempotency-Key': `slot-refund-${bookingId}` },
+  });
+}
+
 export async function stripeReceiptUrl(env: Env, session: StripeCheckoutSession): Promise<string | null> {
   const paymentIntentId = stripePaymentIntentId(session);
   if (!paymentIntentId || !/^pi_[A-Za-z0-9_]+$/.test(paymentIntentId)) return null;