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
Adding indication for test/live key
110f91e
ARCHITECTURE.md | 13 +-
README.md | 24 +-
package-lock.json | 4 +-
package.json | 2 +-
scripts/setup-stripe.mjs | 2 +-
src/App.tsx | 275 +++++++++++++--------
src/styles.css | 10 +-
worker/google.ts | 125 ++++++++++
worker/index.ts | 603 ++++++++++++++++++++++++++++++++++++-----------
worker/money.ts | 26 ++
worker/settings.ts | 20 +-
worker/stripe.ts | 67 +++++-
worker/types.ts | 2 +-
13 files changed, 914 insertions(+), 259 deletions(-)
create mode 100644 worker/money.ts
Diff
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 3664910..1d6d21c 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -33,15 +33,16 @@ your-booking-domain.example
## Booking transaction
1. Calculate candidate times inside weekly availability.
-2. Subtract Google FreeBusy ranges and recently-created D1 bookings.
+2. Subtract Google FreeBusy ranges and recent D1 reservations.
3. Visitor chooses a start and duration.
4. Server recomputes availability rather than trusting the browser.
-5. D1 transaction reserves each time segment touched by the booking and buffers.
+5. D1 reserves each time segment touched by the booking and buffers.
6. Google FreeBusy is checked once more.
-7. Google Calendar event is created, with a unique Meet conference when selected.
-8. Booking is marked confirmed.
-9. D1 safety locks expire after ten minutes, after which Google Calendar is again the source of truth. This means manually moving/deleting the event in Google eventually frees the original time without maintaining a second calendar system.
-10. If optional payment is enabled, the confirmed visitor can open Stripe Checkout. Payment success or failure is tracked separately from booking status, so Stripe does not become part of the calendar transaction.
+7. If payment is disabled, the normal Google event is created and the booking is confirmed immediately.
+8. If payment is enabled, Slot creates a **private owner-only Google Calendar hold** with no attendee invitation. A Google Meet conference may be attached to the hold internally, but it is not exposed to the visitor.
+9. Stripe Checkout opens for the calculated duration × per-minute rate. The Checkout Session expires after 30 minutes.
+10. A signed Stripe webhook is the primary fulfillment signal. Only after `payment_status=paid` does Slot convert the private hold into the attendee event, send Google Calendar updates, expose the Meet link, and mark the booking confirmed.
+11. Cancelled or expired Checkout sessions release the private hold and D1 locks. A return-page status check provides an idempotent fallback if the webhook and browser redirect arrive in the opposite order.
## Why D1 exists
diff --git a/README.md b/README.md
index 6d8d26a..56b4169 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@ A small calendar-first booking app built as one Cloudflare Worker deployment.
The visitor flow is intentionally simple:
-**date → time → duration → details → booked → optional payment**
+**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,11 +17,11 @@ 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
-- Optional Stripe Checkout payments through the Stripe API
+- Stripe Checkout payments through the Stripe API, with payment required before confirmation when enabled
No GitHub Actions, Pages Functions, KV, Microsoft OAuth, external email service, or separately hosted backend.
-## What v0.1 does
+## What v0.2 does
- Calendar-first booking page
- Reads live busy periods from one or more Google calendars
@@ -35,7 +35,7 @@ No GitHub Actions, Pages Functions, KV, Microsoft OAuth, external email service,
- Creates the Google Calendar event and emails the attendee through Google Calendar
- 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
-- Keeps payment optional: the calendar booking is confirmed first, so Stripe failures or cancelled Checkout sessions do not destroy the reservation
+- 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
## First setup
@@ -150,9 +150,9 @@ https://book.example.com/admin
Sign in with the email entered during setup. The first authorization requests offline Calendar access so the Worker can check availability and create bookings while you are not present.
-## Optional Stripe payments
+## Stripe payments
-Stripe is deliberately optional. The existing booking flow works without any Stripe configuration.
+Stripe itself is optional. If payments are disabled, Slot confirms bookings immediately as before. If payments are enabled, payment is mandatory before the booking is confirmed.
This integration uses Stripe-hosted Checkout, so there is no publishable key or card form in the React app. The Stripe secret key stays in the Worker only.
@@ -191,11 +191,17 @@ npm run db:migrate:remote
npm run deploy
```
-6. Open `/admin` → **Settings** → **Stripe payments**. Choose the currency, amount in that currency's smallest unit, and checkout label, then enable **Offer optional payment**.
+6. Open `/admin` → **Settings** → **Stripe payments**. Choose the currency, set a **price per minute** in normal currency units, choose the checkout label, then enable **Require payment before confirmation**.
-For example, `5000` with `usd` is `$50.00`. Stripe Checkout opens only when the person chooses to pay. Cancelling Checkout does not cancel the calendar booking.
+The Stripe settings card also shows **TEST MODE** for `sk_test_...` keys and **LIVE MODE** for `sk_live_...` keys. The secret key itself is never sent to the browser.
-Payment status is stored on the booking and shown in the admin booking details. Cancelling a paid booking does **not** automatically issue a refund; handle refunds in Stripe so a calendar cancellation cannot unexpectedly move money.
+For example, `50` with `sek` means **SEK 50 per minute**, so a 15-minute booking costs SEK 750. Slot converts that amount to Stripe minor units internally.
+
+Upgrading from v0.1 intentionally disables the old fixed-amount payment toggle until you save a per-minute rate. The old fixed amount cannot be safely guessed as a per-minute price.
+
+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.
### Google project in Testing mode
diff --git a/package-lock.json b/package-lock.json
index 739389e..2bd836a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "slot",
- "version": "0.1.1",
+ "version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "slot",
- "version": "0.1.1",
+ "version": "0.2.0",
"dependencies": {
"lucide-react": "1.26.0",
"react": "19.2.8",
diff --git a/package.json b/package.json
index aafa595..9313d57 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "slot",
- "version": "0.1.1",
+ "version": "0.2.0",
"private": true,
"type": "module",
"scripts": {
diff --git a/scripts/setup-stripe.mjs b/scripts/setup-stripe.mjs
index 99cf44e..a9728b3 100644
--- a/scripts/setup-stripe.mjs
+++ b/scripts/setup-stripe.mjs
@@ -108,4 +108,4 @@ if (missing.length) {
}
console.log('\nDone. Stripe secrets are configured on the existing Worker.');
-console.log('Next, enable optional payments in Slot Admin -> Settings -> Stripe payments.\n');
+console.log('Next, set the per-minute rate and enable required payment in Slot Admin -> Settings -> Stripe payments.\n');
diff --git a/src/App.tsx b/src/App.tsx
index b353d9e..35dfe79 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -39,7 +39,9 @@ type PublicConfig = {
type PaymentOption = {
enabled: true;
- amountMinor: number;
+ required: true;
+ ready: boolean;
+ ratePerMinute: number;
currency: string;
label: string;
};
@@ -54,7 +56,7 @@ type Confirmation = {
timezone: string;
meetingMode: MeetingMode;
meetUrl?: string | null;
- payment?: PaymentOption;
+ receiptUrl?: string | null;
};
type AppSettings = {
@@ -73,7 +75,7 @@ type AppSettings = {
allowMeetingModes: MeetingMode[];
inPersonLocation: string;
paymentEnabled: boolean;
- paymentAmountMinor: number;
+ paymentRatePerMinute: number;
paymentCurrency: string;
paymentLabel: string;
availability: Record<string, Array<{ start: string; end: string }>>;
@@ -264,6 +266,18 @@ function formatMoneyMinor(amountMinor: number, currency: string): string {
}
}
+function formatMoneyMajor(amount: number, currency: string): string {
+ try {
+ return new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency: currency.toUpperCase(),
+ maximumFractionDigits: 2,
+ }).format(amount);
+ } catch {
+ return `${amount.toFixed(2)} ${currency.toUpperCase()}`;
+ }
+}
+
function monthCells(month: Date): Array<{ date: Date; current: boolean }> {
const first = new Date(month.getFullYear(), month.getMonth(), 1, 12);
const mondayIndex = (first.getDay() + 6) % 7;
@@ -313,7 +327,7 @@ function PublicBooking() {
const [fieldErrors, setFieldErrors] = useState<{ name?: string; email?: string }>({});
const [availabilityNotice, setAvailabilityNotice] = useState('');
const [confirmation, setConfirmation] = useState<Confirmation | null>(null);
- const paymentReturn = new URLSearchParams(window.location.search).get('payment');
+ const [paymentReturnState, setPaymentReturnState] = useState<{ kind: 'checking' | 'cancelled' | 'error'; message: string } | null>(null);
useEffect(() => {
api<PublicConfig>('/api/config')
@@ -325,6 +339,86 @@ function PublicBooking() {
.catch((err) => setError(err.message));
}, []);
+
+ useEffect(() => {
+ const params = new URLSearchParams(window.location.search);
+ const payment = params.get('payment');
+ const sessionId = params.get('session_id');
+ const bookingId = params.get('booking_id');
+ const released = params.get('released');
+ let cancelled = false;
+ let timer: ReturnType<typeof setTimeout> | undefined;
+
+ function cleanReturnUrl() {
+ window.history.replaceState({}, '', window.location.pathname);
+ }
+
+ async function pollStatus(attempt = 0) {
+ if (!sessionId || cancelled) return;
+ setPaymentReturnState({ kind: 'checking', message: attempt === 0 ? 'Payment received. Confirming your booking…' : 'Payment received. Finalizing your calendar invitation…' });
+ try {
+ const data = await api<{ state: string; message?: string; booking?: Confirmation }>(`/api/payments/status?session_id=${encodeURIComponent(sessionId)}`);
+ if (cancelled) return;
+ if (data.state === 'confirmed' && data.booking) {
+ setConfirmation(data.booking);
+ setMeetingMode(data.booking.meetingMode);
+ setPaymentReturnState(null);
+ cleanReturnUrl();
+ return;
+ }
+ if ((data.state === 'confirming' || data.state === 'processing') && attempt < 20) {
+ setPaymentReturnState({ kind: 'checking', message: data.message || 'Finalizing your booking…' });
+ timer = setTimeout(() => void pollStatus(attempt + 1), 1000);
+ return;
+ }
+ setPaymentReturnState({ kind: 'error', message: data.message || 'Payment did not complete, so no booking was confirmed.' });
+ cleanReturnUrl();
+ } catch (err) {
+ if (cancelled) return;
+ if (attempt < 8) {
+ timer = setTimeout(() => void pollStatus(attempt + 1), 1200);
+ return;
+ }
+ setPaymentReturnState({ kind: 'error', message: err instanceof Error ? err.message : 'Could not verify the payment.' });
+ }
+ }
+
+ async function releaseCancelledCheckout() {
+ if (!bookingId || cancelled) return;
+ setPaymentReturnState({ kind: 'checking', message: 'Cancelling payment and releasing the time…' });
+ try {
+ const data = await api<{ state: string; booking?: Confirmation }>('/api/payments/cancel', {
+ method: 'POST',
+ body: JSON.stringify({ bookingId }),
+ });
+ if (cancelled) return;
+ if (data.state === 'confirmed' && data.booking) {
+ setConfirmation(data.booking);
+ setMeetingMode(data.booking.meetingMode);
+ setPaymentReturnState(null);
+ } else if (data.state === 'processing') {
+ setPaymentReturnState({ kind: 'checking', message: 'Stripe is processing the payment. Please wait…' });
+ } else {
+ setPaymentReturnState({ kind: 'cancelled', message: 'Payment cancelled. No booking was confirmed and the time has been released.' });
+ }
+ cleanReturnUrl();
+ } catch (err) {
+ if (!cancelled) setPaymentReturnState({ kind: 'error', message: err instanceof Error ? err.message : 'Could not cancel the checkout cleanly.' });
+ }
+ }
+
+ if (payment === 'success' && sessionId) void pollStatus();
+ else if (payment === 'cancelled' && released === '1') {
+ setPaymentReturnState({ kind: 'cancelled', message: 'Payment cancelled. No booking was confirmed and the time has been released.' });
+ cleanReturnUrl();
+ } else if (payment === 'cancelled' && bookingId) void releaseCancelledCheckout();
+
+ return () => {
+ cancelled = true;
+ if (timer) clearTimeout(timer);
+ };
+ }, []);
+
const cells = useMemo(() => monthCells(month), [month]);
const today = config ? todayInZone(config.timezone) : dateKey(new Date());
const maxDate = config ? dateKey(addDays(parseDateKey(today), config.bookingWindowDays)) : today;
@@ -370,7 +464,7 @@ function PublicBooking() {
setBooking(true);
setError('');
try {
- const data = await api<{ booking: Confirmation }>('/api/book', {
+ const data = await api<{ requiresPayment: boolean; checkoutUrl?: string; booking?: Confirmation }>('/api/book', {
method: 'POST',
body: JSON.stringify({
start: selectedSlot.start,
@@ -383,7 +477,13 @@ function PublicBooking() {
website: form.get('website'),
}),
});
- setConfirmation({ ...data.booking, meetingMode });
+ if (data.requiresPayment) {
+ if (!data.checkoutUrl) throw new Error('Stripe did not return a secure checkout link.');
+ window.location.assign(data.checkoutUrl);
+ return;
+ }
+ if (!data.booking) throw new Error('The booking response was incomplete.');
+ setConfirmation(data.booking);
} catch (err) {
const message = err instanceof Error ? err.message : 'Booking failed';
setError(message);
@@ -603,19 +703,25 @@ function PublicBooking() {
<textarea name="message" rows={3} placeholder="Context, topic, links..." />
</label>
{activeConfig.payment && (
- <div className="payment-note">
+ <div className={`payment-note ${activeConfig.payment.ready ? '' : 'payment-unavailable'}`}>
<CreditCard size={17} />
<div>
- <strong>Optional online payment</strong>
- <span>After booking, you can pay {formatMoneyMinor(activeConfig.payment.amountMinor, activeConfig.payment.currency)} securely with Stripe. The reservation does not depend on payment.</span>
+ <strong>{activeConfig.payment.ready ? 'Payment required to confirm' : 'Payments are temporarily unavailable'}</strong>
+ <span>
+ {activeConfig.payment.ready
+ ? <>{formatMoneyMajor(activeConfig.payment.ratePerMinute, activeConfig.payment.currency)} per minute · {duration} min = <strong>{formatMoneyMajor(activeConfig.payment.ratePerMinute * duration, activeConfig.payment.currency)}</strong>. Your calendar invitation is sent only after Stripe confirms payment.</>
+ : <>This calendar requires payment, but its Stripe connection is not ready. Please contact the owner.</>}
+ </span>
</div>
</div>
)}
<label className="honeypot" aria-hidden="true">Website<input name="website" tabIndex={-1} autoComplete="off" /></label>
{error && <div className="error-banner" role="alert">{error}</div>}
- <button className="primary-button" type="submit" disabled={booking}>
- {booking ? <><Loader2 className="spin" size={18} /> Booking...</> : <>Book this slot <ArrowRight size={18} /></>}
+ <button className="primary-button" type="submit" disabled={booking || Boolean(activeConfig.payment && !activeConfig.payment.ready)}>
+ {booking
+ ? <><Loader2 className="spin" size={18} /> {activeConfig.payment ? 'Opening payment…' : 'Booking…'}</>
+ : <>{activeConfig.payment ? (activeConfig.payment.ready ? 'Continue to secure payment' : 'Payment unavailable') : 'Book this slot'} <ArrowRight size={18} /></>}
</button>
</form>
</div>
@@ -628,6 +734,18 @@ function PublicBooking() {
}
function renderRightPanel() {
+ if (confirmation) return renderConfirmationView();
+ if (paymentReturnState) {
+ return (
+ <div className="panel-view payment-return-panel" key="payment-return">
+ {paymentReturnState.kind === 'checking' ? <Loader2 className="spin" size={28} /> : paymentReturnState.kind === 'cancelled' ? <CreditCard size={28} /> : <Inbox size={28} />}
+ <p className="eyebrow">PAYMENT</p>
+ <h2>{paymentReturnState.kind === 'checking' ? 'Almost done.' : paymentReturnState.kind === 'cancelled' ? 'Payment cancelled.' : 'Booking not confirmed.'}</h2>
+ <p className="muted">{paymentReturnState.message}</p>
+ {paymentReturnState.kind !== 'checking' && <button className="text-button" type="button" onClick={() => setPaymentReturnState(null)}>Choose another time</button>}
+ </div>
+ );
+ }
if (!activeConfig.connected) {
return (
<div className="empty-state" key="disconnected">
@@ -646,7 +764,6 @@ function PublicBooking() {
</div>
);
}
- if (bookingStep === 'confirmed') return renderConfirmationView();
if (bookingStep === 'duration') return renderDurationView();
if (bookingStep === 'details') return renderDetailsView();
return renderTimesView();
@@ -720,56 +837,14 @@ function PublicBooking() {
<footer>
<a className="powered-by" href="https://www.nobgit.com/user/imalexnord/slot" target="_blank" rel="noreferrer">Powered by Slot</a>
</footer>
- {paymentReturn && (
- <div className={`payment-return-banner ${paymentReturn === 'success' ? 'success' : ''}`} role="status">
- {paymentReturn === 'success'
- ? 'Stripe checkout completed. Payment status is being recorded, and you can close this tab.'
- : 'Payment was skipped or cancelled. Your booking is still confirmed.'}
- </div>
- )}
</main>
);
}
function ConfirmationView({ title, confirmation, onBookAnother }: { title: string; confirmation: Confirmation; onBookAnother: () => void }) {
const [copied, copy] = useCopy();
- const [paying, setPaying] = useState(false);
- const [paymentError, setPaymentError] = useState('');
- const [paid, setPaid] = useState(false);
- const [paymentProcessing, setPaymentProcessing] = useState(false);
const tz = confirmation.timezone;
- async function openStripeCheckout() {
- const popup = window.open('about:blank', '_blank');
- if (popup) popup.opener = null;
- setPaying(true);
- setPaymentError('');
- try {
- const data = await api<{ url?: string; paid?: boolean; processing?: boolean }>('/api/payments/checkout', {
- method: 'POST',
- body: JSON.stringify({ bookingId: confirmation.id }),
- });
- if (data.paid) {
- setPaid(true);
- popup?.close();
- return;
- }
- if (data.processing) {
- setPaymentProcessing(true);
- popup?.close();
- return;
- }
- if (!data.url) throw new Error('Stripe did not return a checkout link.');
- if (popup) popup.location.href = data.url;
- else window.location.assign(data.url);
- } catch (err) {
- popup?.close();
- setPaymentError(err instanceof Error ? err.message : 'Could not open Stripe checkout');
- } finally {
- setPaying(false);
- }
- }
-
return (
<div className="panel-view confirmation-panel panel-view-scroll" key="confirmed">
<span className="success-icon"><Check /></span>
@@ -784,35 +859,17 @@ function ConfirmationView({ title, confirmation, onBookAnother }: { title: strin
<div className="confirm-row"><dt>Meeting</dt><dd>{modeMeta[confirmation.meetingMode].label}</dd></div>
</dl>
- <p className="muted">A calendar invitation has been sent to your email.</p>
-
- {confirmation.payment && (
- <div className="confirmation-payment">
- <div>
- <strong>{confirmation.payment.label}</strong>
- <span>{paid
- ? 'Payment received.'
- : paymentProcessing
- ? 'Stripe is processing the payment.'
- : `Optional · ${formatMoneyMinor(confirmation.payment.amountMinor, confirmation.payment.currency)}`}</span>
- </div>
- {paid ? (
- <span className="status-pill paid"><Check size={13} /> Paid</span>
- ) : paymentProcessing ? (
- <span className="status-pill upcoming"><Loader2 className="spin" size={13} /> Processing</span>
- ) : (
- <button className="secondary-button" type="button" onClick={openStripeCheckout} disabled={paying}>
- {paying ? <><Loader2 className="spin" size={16} /> Opening Stripe...</> : <><CreditCard size={16} /> Pay with Stripe</>}
- </button>
- )}
- </div>
- )}
- {paymentError && <div className="error-banner" role="alert">{paymentError}</div>}
+ <p className="muted">Your booking is confirmed. A calendar invitation has been sent to your email.</p>
<div className="confirm-actions">
<a className="primary-button" href={googleCalendarUrl(title, confirmation)} target="_blank" rel="noreferrer">
<CalendarPlus size={16} /> Add to calendar
</a>
+ {confirmation.receiptUrl && (
+ <a className="secondary-button" href={confirmation.receiptUrl} target="_blank" rel="noreferrer">
+ View Stripe receipt <ExternalLink size={15} />
+ </a>
+ )}
{confirmation.meetUrl && (
<>
<a className="secondary-button" href={confirmation.meetUrl} target="_blank" rel="noreferrer">
@@ -844,6 +901,7 @@ function BookingRowItem({
}) {
const [copied, copy] = useCopy();
const isCancelled = booking.status === 'cancelled';
+ const isPaymentHold = booking.status === 'pending' && booking.payment_status !== 'paid';
const isPast = Date.parse(booking.end_time) < Date.now() || isCancelled;
const Icon = modeMeta[booking.meeting_mode].icon;
return (
@@ -859,16 +917,16 @@ 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' : ''}</span>
+ <span>{booking.duration_minutes} min{booking.payment_status === 'paid' ? ' · Paid' : isPaymentHold ? ' · Awaiting payment' : ''}</span>
</div>
<div className="booking-actions">
<span className={`status-pill ${isCancelled ? 'cancelled' : isPast ? 'past' : 'upcoming'}`}>
- {isCancelled ? 'Cancelled' : isPast ? 'Past' : 'Upcoming'}
+ {isCancelled ? 'Cancelled' : isPaymentHold ? 'Payment hold' : isPast ? 'Past' : 'Upcoming'}
</span>
<button type="button" className="icon-button" aria-label="Open booking details" title="Open details" onClick={() => onOpen(booking)}>
<Inbox size={15} />
</button>
- {booking.meet_url && (
+ {booking.status === 'confirmed' && booking.meet_url && (
<>
<button
type="button"
@@ -910,6 +968,7 @@ function Admin() {
const [notice, setNotice] = useState('');
const [error, setError] = useState('');
const [stripeConfigured, setStripeConfigured] = useState(false);
+ const [stripeMode, setStripeMode] = useState<'test' | 'live' | 'unknown'>('unknown');
useEffect(() => {
document.title = 'Bookings';
@@ -933,12 +992,13 @@ function Admin() {
useEffect(() => {
if (!session?.authenticated) return;
Promise.all([
- api<{ settings: AppSettings; stripeConfigured: boolean }>('/api/admin/settings'),
+ api<{ settings: AppSettings; stripeConfigured: boolean; stripeMode: 'test' | 'live' | 'unknown' }>('/api/admin/settings'),
api<{ bookings: BookingRow[] }>('/api/admin/bookings'),
api<{ recurring: RecurringUnavailableRule[]; blocks: AvailabilityBlock[] }>('/api/admin/availability-rules'),
]).then(([settingsData, bookingData, availabilityData]) => {
setSettings(settingsData.settings);
setStripeConfigured(settingsData.stripeConfigured);
+ setStripeMode(settingsData.stripeMode);
setBookings(bookingData.bookings);
setRecurringUnavailable(availabilityData.recurring);
setAvailabilityBlocks(availabilityData.blocks);
@@ -1016,6 +1076,7 @@ function Admin() {
async function confirmCancelBooking() {
if (!cancelTarget) return;
+ const releasingPaymentHold = cancelTarget.status === 'pending';
setCancelling(true);
setError('');
try {
@@ -1023,7 +1084,7 @@ function Admin() {
setCancelTarget(null);
setSelectedBooking(null);
await refreshAdminData();
- setNotice('Meeting cancelled. Google Calendar will notify the attendee.');
+ setNotice(releasingPaymentHold ? '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 {
@@ -1264,10 +1325,17 @@ function Admin() {
</section>
<section className="settings-card">
- <div className="card-heading"><h2>Stripe payments</h2><p>Offer payment after the booking is already confirmed.</p></div>
- <div className={`connection-badge ${stripeConfigured ? 'connected' : ''}`}>
- <span className="status-dot" />
- {stripeConfigured ? 'Stripe secrets configured' : 'Stripe secrets missing'}
+ <div className="card-heading"><h2>Stripe payments</h2><p>Payment happens before the booking is confirmed or the invitation is sent.</p></div>
+ <div className="stripe-status-row">
+ <div className={`connection-badge ${stripeConfigured ? 'connected' : ''}`}>
+ <span className="status-dot" />
+ {stripeConfigured ? 'Stripe secrets configured' : 'Stripe secrets missing'}
+ </div>
+ {stripeConfigured && (
+ <span className={`stripe-mode-badge ${stripeMode}`}>
+ {stripeMode === 'test' ? 'TEST MODE' : stripeMode === 'live' ? 'LIVE MODE' : 'MODE UNKNOWN'}
+ </span>
+ )}
</div>
<div className="checkbox-chips">
<label className={activeSettings.paymentEnabled ? 'active' : ''}>
@@ -1276,15 +1344,20 @@ function Admin() {
checked={activeSettings.paymentEnabled}
onChange={(e) => setSettings({ ...activeSettings, paymentEnabled: e.target.checked })}
/>
- Offer optional payment
+ Require payment before confirmation
</label>
</div>
<div className="two-col-fields">
- <label>Currency<input maxLength={3} value={activeSettings.paymentCurrency} onChange={(e) => setSettings({ ...activeSettings, paymentCurrency: e.target.value.toLowerCase() })} /><small>ISO</small></label>
- <label>Amount<input type="number" min="0" step="1" value={activeSettings.paymentAmountMinor} onChange={(e) => setSettings({ ...activeSettings, paymentAmountMinor: Number(e.target.value) })} /><small>minor unit</small></label>
+ <label>Currency<input maxLength={3} value={activeSettings.paymentCurrency} onChange={(e) => setSettings({ ...activeSettings, paymentCurrency: e.target.value.toLowerCase() })} /><small>ISO code</small></label>
+ <label>Price per minute<input type="number" min="0" step="0.01" value={activeSettings.paymentRatePerMinute} onChange={(e) => setSettings({ ...activeSettings, paymentRatePerMinute: Number(e.target.value) })} /><small>{activeSettings.paymentCurrency.toUpperCase()} / minute</small></label>
</div>
<label>Checkout label<input value={activeSettings.paymentLabel} onChange={(e) => setSettings({ ...activeSettings, paymentLabel: e.target.value })} placeholder="Booking payment" /></label>
- <p className="theme-footnote">Example: 5000 with USD is $50.00. Add STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET as Worker secrets before enabling this.</p>
+ <p className="theme-footnote">
+ Enter normal currency, not Stripe minor units. Example: <strong>50</strong> with SEK means <strong>SEK 50 per minute</strong>, so a 15-minute booking costs <strong>{formatMoneyMajor(50 * 15, 'sek')}</strong>.
+ </p>
+ {activeSettings.paymentEnabled && !stripeConfigured && <div className="error-banner" role="alert">Payment is required but Stripe secrets are missing. Visitors will not be allowed to create unpaid bookings.</div>}
+ {stripeConfigured && stripeMode === 'unknown' && <div className="error-banner" role="alert">The Stripe secret key is configured, but its mode could not be identified. Slot expects a key beginning with sk_test_ or sk_live_.</div>}
+ {stripeConfigured && stripeMode === 'test' && <p className="settings-note">Stripe test mode only emails test receipts to verified Stripe account team-member addresses. Every successful payment still gets a hosted Stripe receipt link on Slot's confirmation screen.</p>}
</section>
<section className="settings-card wide-card">
@@ -1407,16 +1480,16 @@ function Admin() {
<div className="error-banner" role="alert">{selectedBooking.calendar_sync_error}</div>
)}
<div className="booking-detail-actions">
- {selectedBooking.meet_url && <a className="secondary-button" href={selectedBooking.meet_url} target="_blank" rel="noreferrer">Open meeting link</a>}
+ {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="danger-button"
type="button"
- disabled={selectedBooking.status !== 'confirmed' || Date.parse(selectedBooking.end_time) < Date.now()}
+ disabled={!(selectedBooking.status === 'pending' || selectedBooking.status === 'confirmed') || Date.parse(selectedBooking.end_time) < Date.now()}
onClick={() => setCancelTarget(selectedBooking)}
>
- Cancel meeting
+ {selectedBooking.status === 'pending' ? 'Release payment hold' : 'Cancel meeting'}
</button>
</div>
</section>
@@ -1428,22 +1501,26 @@ function Admin() {
{cancelTarget && (
<div className="modal-backdrop" role="presentation">
<section className="confirm-dialog" role="dialog" aria-modal="true" aria-labelledby="cancel-title">
- <p className="eyebrow">CANCEL MEETING</p>
- <h2 id="cancel-title">Cancel meeting?</h2>
+ <p className="eyebrow">{cancelTarget.status === 'pending' ? 'PAYMENT HOLD' : 'CANCEL MEETING'}</p>
+ <h2 id="cancel-title">{cancelTarget.status === 'pending' ? 'Release this payment hold?' : 'Cancel meeting?'}</h2>
<div className="cancel-summary">
<strong>{cancelTarget.name}</strong>
<span>{formatDateShort(cancelTarget.start_time, activeSettings.timezone)}</span>
<span>{formatClock(cancelTarget.start_time, activeSettings.timezone)}-{formatClock(cancelTarget.end_time, activeSettings.timezone)}</span>
<span>{cancelTarget.duration_minutes} min</span>
</div>
- <p className="muted">This will also cancel the Google Calendar event and send normal Calendar cancellation notifications.</p>
+ <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.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="dialog-actions">
- <button className="secondary-button" type="button" onClick={() => setCancelTarget(null)} disabled={cancelling}>Keep meeting</button>
+ <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} /> Cancelling...</> : 'Cancel meeting'}
+ {cancelling
+ ? <><Loader2 className="spin" size={16} /> {cancelTarget.status === 'pending' ? 'Releasing...' : 'Cancelling...'}</>
+ : cancelTarget.status === 'pending' ? 'Release hold' : 'Cancel meeting'}
</button>
</div>
</section>
diff --git a/src/styles.css b/src/styles.css
index 606a023..7d340db 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -691,6 +691,8 @@ input[aria-invalid="true"]:focus { box-shadow: 0 0 0 3px color-mix(in srgb, var(
.payment-note div { display: grid; gap: 3px; }
.payment-note strong { color: var(--text); font-size: 11px; }
.payment-note span { font-size: 10px; line-height: 1.45; }
+.payment-note.payment-unavailable { border-color: color-mix(in srgb, var(--danger) 30%, var(--border)); }
+.payment-note.payment-unavailable svg, .payment-note.payment-unavailable strong { color: var(--danger); }
.primary-button {
border: 0;
@@ -710,7 +712,7 @@ input[aria-invalid="true"]:focus { box-shadow: 0 0 0 3px color-mix(in srgb, var(
}
.primary-button:hover { opacity: .9; }
.primary-button:active { transform: translateY(1px); }
-.primary-button:disabled { opacity: .5; cursor: wait; }
+.primary-button:disabled { opacity: .5; cursor: not-allowed; }
.inline-button { width: fit-content; }
.secondary-button {
border: 1px solid var(--border);
@@ -1155,6 +1157,12 @@ input[aria-invalid="true"]:focus { box-shadow: 0 0 0 3px color-mix(in srgb, var(
.connection-badge { display: inline-flex; align-items: center; gap: 8px; color: var(--muted); font-size: 10px; font-weight: 700; }
.status-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--faint); }
.connection-badge.connected .status-dot { background: var(--success); box-shadow: 0 0 0 4px color-mix(in srgb, var(--success) 10%, transparent); }
+.stripe-status-row { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px; padding: 11px 12px; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface-soft); }
+.stripe-mode-badge { display: inline-flex; align-items: center; border: 1px solid var(--border); border-radius: 999px; padding: 5px 8px; color: var(--muted); background: var(--bg); font-size: 9px; font-weight: 800; letter-spacing: 0.08em; }
+.stripe-mode-badge.test { border-color: color-mix(in srgb, var(--success) 45%, var(--border)); color: var(--success); }
+.stripe-mode-badge.live { border-color: color-mix(in srgb, var(--text) 55%, var(--border)); color: var(--text); }
+.stripe-mode-badge.unknown { border-color: color-mix(in srgb, var(--faint) 65%, var(--border)); }
+.settings-note { margin: 0; color: var(--muted); font-size: 10px; line-height: 1.5; }
.connection-actions { display: flex; align-items: center; gap: 10px; }
.connection-actions a, .connection-actions button { border: 0; background: transparent; color: var(--muted); font-size: 10px; padding: 0; cursor: pointer; }
.connection-actions a:hover, .connection-actions button:hover { color: var(--text); }
diff --git a/worker/google.ts b/worker/google.ts
index 25c6c06..7423490 100644
--- a/worker/google.ts
+++ b/worker/google.ts
@@ -293,6 +293,131 @@ export async function createCalendarEvent(
return { eventId: data.id, meetUrl, htmlLink: data.htmlLink ?? null };
}
+
+export async function createCalendarHold(
+ env: Env,
+ settings: AppSettings,
+ booking: {
+ id: string;
+ start: string;
+ end: string;
+ meetingMode: MeetingMode;
+ },
+): Promise<{ eventId: string; meetUrl: string | null }> {
+ const token = await accessToken(env);
+ const params = new URLSearchParams({
+ sendUpdates: 'none',
+ conferenceDataVersion: '1',
+ });
+ const calendarId = encodeURIComponent(settings.calendarId || 'primary');
+
+ const event: Record<string, unknown> = {
+ summary: 'Pending booking payment',
+ description: `Temporary hold created by ${env.APP_URL}. No invitation is sent until payment succeeds.`,
+ start: { dateTime: booking.start, timeZone: settings.timezone },
+ end: { dateTime: booking.end, timeZone: settings.timezone },
+ visibility: 'private',
+ transparency: 'opaque',
+ };
+
+ if (booking.meetingMode === 'google_meet') {
+ event.conferenceData = {
+ createRequest: {
+ requestId: `hold-${booking.id}`,
+ conferenceSolutionKey: { type: 'hangoutsMeet' },
+ },
+ };
+ }
+
+ const response = await fetch(
+ `https://www.googleapis.com/calendar/v3/calendars/${calendarId}/events?${params.toString()}`,
+ {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${token}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(event),
+ },
+ );
+
+ if (!response.ok) await throwGoogleCalendarError(response, 'Google payment hold creation');
+ const data = (await response.json()) as GoogleEventResponse & { error?: { message?: string } };
+ if (!data.id) throw new GoogleCalendarError('Google payment hold creation', 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 confirmCalendarHold(
+ env: Env,
+ settings: AppSettings,
+ eventId: string,
+ booking: {
+ id: string;
+ name: string;
+ email: string;
+ phone?: string;
+ message?: string;
+ start: string;
+ end: string;
+ meetingMode: MeetingMode;
+ },
+): Promise<{ eventId: string; meetUrl: string | null; htmlLink: 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 descriptionLines = [
+ `Booked through ${env.APP_URL}`,
+ booking.phone ? `Phone: ${booking.phone}` : '',
+ booking.message ? `Message: ${booking.message}` : '',
+ 'Payment received via Stripe.',
+ ].filter(Boolean);
+
+ const event: Record<string, unknown> = {
+ summary: `Meeting with ${booking.name}`,
+ description: descriptionLines.join('\n\n'),
+ start: { dateTime: booking.start, timeZone: settings.timezone },
+ end: { dateTime: booking.end, timeZone: settings.timezone },
+ attendees: [{ email: booking.email, displayName: booking.name }],
+ visibility: 'default',
+ transparency: 'opaque',
+ };
+
+ // Google Meet was created on the private hold already. Omitting conferenceData
+ // here preserves that conference while the PATCH adds the attendee and sends the invite.
+ if (booking.meetingMode === 'in_person' && settings.inPersonLocation) {
+ event.location = settings.inPersonLocation;
+ } else if (booking.meetingMode === 'phone') {
+ event.location = 'Phone call';
+ }
+
+ 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(event),
+ },
+ );
+
+ if (!response.ok) await throwGoogleCalendarError(response, 'Google paid booking confirmation');
+ const data = (await response.json()) as GoogleEventResponse & { error?: { message?: string } };
+ if (!data.id) throw new GoogleCalendarError('Google paid booking confirmation', 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, htmlLink: data.htmlLink ?? null };
+}
+
export async function deleteCalendarEvent(
env: Env,
settings: AppSettings,
diff --git a/worker/index.ts b/worker/index.ts
index 754fe78..4595de0 100644
--- a/worker/index.ts
+++ b/worker/index.ts
@@ -8,22 +8,29 @@ import {
import {
GoogleCalendarError,
completeGoogleOAuth,
+ confirmCalendarHold,
createCalendarEvent,
+ createCalendarHold,
createGoogleAuthUrl,
deleteCalendarEvent,
disconnectGoogle,
hasGoogleConnection,
queryBusy,
} from './google';
+import { bookingAmountMinor } from './money';
import { DEFAULT_SETTINGS, getSettings, saveSettings } from './settings';
import {
createStripeCheckoutSession,
+ expireStripeCheckoutSession,
retrieveStripeCheckoutSession,
stripeConfigured,
+ stripeMode,
stripePaymentIntentId,
+ stripeReceiptUrl,
StripeError,
verifyStripeWebhook,
} from './stripe';
+import type { StripeCheckoutSession } from './stripe';
import {
addDaysToDateKey,
addMinutes,
@@ -133,6 +140,14 @@ function validateAppSettings(input: unknown): AppSettings {
const defaultMeetingMode = normalizedModes.includes(raw.defaultMeetingMode as MeetingMode)
? (raw.defaultMeetingMode as MeetingMode)
: normalizedModes[0];
+ const paymentEnabled = Boolean(raw.paymentEnabled);
+ const paymentRatePerMinute = Math.min(
+ 1_000_000,
+ Math.max(0, Math.round(Number(raw.paymentRatePerMinute ?? DEFAULT_SETTINGS.paymentRatePerMinute) * 10000) / 10000),
+ );
+ if (paymentEnabled && paymentRatePerMinute <= 0) {
+ throw new Error('Set a price per minute greater than zero before requiring payment.');
+ }
return {
title: String(raw.title ?? DEFAULT_SETTINGS.title).trim().slice(0, 100) || DEFAULT_SETTINGS.title,
@@ -153,11 +168,8 @@ function validateAppSettings(input: unknown): AppSettings {
defaultMeetingMode,
allowMeetingModes: normalizedModes,
inPersonLocation: String(raw.inPersonLocation ?? '').trim().slice(0, 240),
- paymentEnabled: Boolean(raw.paymentEnabled),
- paymentAmountMinor: Math.min(
- 999_999_999,
- Math.max(0, Math.round(Number(raw.paymentAmountMinor ?? DEFAULT_SETTINGS.paymentAmountMinor))),
- ),
+ paymentEnabled,
+ paymentRatePerMinute,
paymentCurrency: /^[a-zA-Z]{3}$/.test(String(raw.paymentCurrency ?? ''))
? String(raw.paymentCurrency).toLowerCase()
: DEFAULT_SETTINGS.paymentCurrency,
@@ -169,10 +181,6 @@ function validateAppSettings(input: unknown): AppSettings {
async function d1BusyRanges(env: Env, start: number, end: number): Promise<BusyRange[]> {
await env.DB.prepare("DELETE FROM slot_locks WHERE expires_at < ?1").bind(Date.now()).run();
- await env.DB.prepare(
- "DELETE FROM bookings WHERE status = 'pending' AND created_at < datetime('now', '-10 minutes')",
- ).run();
-
const result = await env.DB.prepare(`
SELECT start_time, end_time
FROM bookings
@@ -268,6 +276,7 @@ async function slotUnavailableRanges(env: Env, dateKey: string, timezone: string
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[] }> };
@@ -334,6 +343,227 @@ async function computeAvailability(env: Env, dateKey: string) {
return { settings, connected, date: dateKey, slots };
}
+type PaymentBookingRow = {
+ id: string;
+ name: string;
+ email: string;
+ phone: string | null;
+ message: string | null;
+ start_time: string;
+ end_time: string;
+ duration_minutes: number;
+ timezone: string;
+ meeting_mode: MeetingMode;
+ status: string;
+ google_event_id: string | null;
+ meet_url: string | null;
+ payment_status: string;
+ payment_amount: number | null;
+ payment_currency: string | null;
+ stripe_checkout_session_id: string | null;
+ updated_at: string | null;
+};
+
+function publicConfirmation(booking: PaymentBookingRow, receiptUrl: string | null = null) {
+ return {
+ id: booking.id,
+ start: booking.start_time,
+ end: booking.end_time,
+ duration: booking.duration_minutes,
+ timezone: booking.timezone,
+ meetingMode: booking.meeting_mode,
+ meetUrl: booking.meet_url,
+ receiptUrl,
+ };
+}
+
+async function paymentBookingById(env: Env, bookingId: string): Promise<PaymentBookingRow | null> {
+ 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
+ FROM bookings
+ WHERE id = ?1
+ `).bind(bookingId).first<PaymentBookingRow>();
+}
+
+async function releasePendingBooking(env: Env, settings: AppSettings, bookingId: string): Promise<void> {
+ const booking = await paymentBookingById(env, bookingId);
+ if (!booking || booking.status !== 'pending') return;
+ if (booking.google_event_id) {
+ let lastError: unknown = null;
+ for (let attempt = 0; attempt < 3; attempt += 1) {
+ try {
+ await deleteCalendarEvent(env, settings, booking.google_event_id);
+ lastError = null;
+ break;
+ } catch (error) {
+ lastError = error;
+ if (attempt < 2) await new Promise((resolve) => setTimeout(resolve, 150 * (attempt + 1)));
+ }
+ }
+ if (lastError) throw lastError;
+ }
+ 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),
+ ]);
+}
+
+async function recordPaidSession(
+ env: Env,
+ bookingId: string,
+ session: StripeCheckoutSession,
+): Promise<void> {
+ await env.DB.prepare(`
+ UPDATE bookings
+ SET payment_status = 'paid',
+ payment_amount = COALESCE(?1, payment_amount),
+ payment_currency = COALESCE(?2, payment_currency),
+ stripe_checkout_session_id = COALESCE(stripe_checkout_session_id, ?3),
+ stripe_payment_intent_id = ?4,
+ paid_at = COALESCE(paid_at, datetime('now'))
+ WHERE id = ?5
+ AND (stripe_checkout_session_id IS NULL OR stripe_checkout_session_id = ?3)
+ `).bind(
+ session.amount_total ?? null,
+ session.currency ?? null,
+ session.id,
+ stripePaymentIntentId(session),
+ bookingId,
+ ).run();
+}
+
+async function fulfillPaidBooking(env: Env, bookingId: string): Promise<PaymentBookingRow> {
+ let booking = await paymentBookingById(env, bookingId);
+ if (!booking) throw new Error('Paid booking record was not found.');
+ if (booking.status === 'confirmed') return booking;
+ if (booking.status === 'confirming') {
+ await env.DB.prepare(`
+ UPDATE bookings
+ SET status = 'pending', updated_at = datetime('now')
+ WHERE id = ?1
+ AND status = 'confirming'
+ AND updated_at < datetime('now', '-2 minutes')
+ `).bind(booking.id).run();
+ booking = await paymentBookingById(env, booking.id);
+ if (booking?.status === 'confirmed') return booking;
+ if (booking?.status === 'confirming') {
+ throw new Error('Paid booking confirmation is already in progress.');
+ }
+ if (!booking) throw new Error('Paid booking record was not found.');
+ }
+ if (booking.status !== 'pending' || booking.payment_status !== 'paid') {
+ throw new Error('Booking is not ready for paid confirmation.');
+ }
+
+ // Claim fulfillment before touching Google. This keeps the Stripe webhook and
+ // the browser return-page fallback from both sending the attendee invitation.
+ const claim = await env.DB.prepare(`
+ UPDATE bookings
+ SET status = 'confirming',
+ calendar_sync_status = 'confirming',
+ calendar_sync_error = NULL,
+ calendar_sync_updated_at = datetime('now'),
+ updated_at = datetime('now')
+ WHERE id = ?1
+ AND status = 'pending'
+ AND payment_status = 'paid'
+ `).bind(booking.id).run();
+ const claimed = ((claim as unknown as { meta?: { changes?: number } }).meta?.changes ?? 0) > 0;
+ if (!claimed) {
+ booking = await paymentBookingById(env, booking.id);
+ if (booking?.status === 'confirmed') return booking;
+ throw new Error('Paid booking confirmation is already in progress.');
+ }
+
+ const settings = await getSettings(env);
+ try {
+ const created = booking.google_event_id
+ ? await confirmCalendarHold(env, settings, booking.google_event_id, {
+ id: booking.id,
+ name: booking.name,
+ email: booking.email,
+ phone: booking.phone ?? undefined,
+ message: booking.message ?? undefined,
+ start: booking.start_time,
+ end: booking.end_time,
+ meetingMode: booking.meeting_mode,
+ })
+ : await createCalendarEvent(env, settings, {
+ id: booking.id,
+ name: booking.name,
+ email: booking.email,
+ phone: booking.phone ?? undefined,
+ message: booking.message ?? undefined,
+ start: booking.start_time,
+ end: booking.end_time,
+ meetingMode: booking.meeting_mode,
+ });
+
+ await env.DB.batch([
+ env.DB.prepare(`
+ UPDATE bookings
+ SET status = 'confirmed',
+ google_event_id = ?1,
+ meet_url = ?2,
+ calendar_sync_status = 'synced',
+ calendar_sync_error = NULL,
+ calendar_sync_updated_at = datetime('now'),
+ updated_at = datetime('now')
+ WHERE id = ?3
+ `).bind(created.eventId, created.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 env.DB.prepare(`
+ UPDATE bookings
+ SET status = 'pending',
+ calendar_sync_status = 'failed',
+ calendar_sync_error = ?1,
+ calendar_sync_updated_at = datetime('now'),
+ updated_at = datetime('now')
+ WHERE id = ?2
+ AND status = 'confirming'
+ `).bind(privateCalendarError(error), booking.id).run().catch(() => undefined);
+ throw error;
+ }
+
+ const confirmed = await paymentBookingById(env, booking.id);
+ if (!confirmed) throw new Error('Confirmed booking record disappeared.');
+ return confirmed;
+}
+
+async function cleanupStalePendingBookings(env: Env, settings: AppSettings): Promise<void> {
+ const stale = await env.DB.prepare(`
+ SELECT id, stripe_checkout_session_id
+ FROM bookings
+ WHERE status = 'pending'
+ AND created_at < datetime('now', '-35 minutes')
+ ORDER BY created_at
+ LIMIT 5
+ `).all<{ id: string; stripe_checkout_session_id: string | null }>();
+
+ 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);
+ 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);
+ }
+}
+
async function createBooking(env: Env, httpRequest: Request, request: BookingRequest): Promise<Response> {
if (request.website) return json({ ok: true, message: 'Booked' }); // honeypot
if (!(await enforceBookingRateLimit(httpRequest, env))) {
@@ -359,6 +589,21 @@ async function createBooking(env: Env, httpRequest: Request, request: BookingReq
return json({ error: 'A phone number is required for a phone meeting.' }, 400);
}
+ const paymentRequired = settings.paymentEnabled;
+ if (paymentRequired && settings.paymentRatePerMinute <= 0) {
+ return json({ error: 'Payment is required, but the owner has not configured a per-minute price.' }, 503);
+ }
+ if (paymentRequired && !stripeConfigured(env)) {
+ return json({ error: 'Payment is required, but Stripe is not configured. The owner needs to fix the Stripe setup.' }, 503);
+ }
+
+ const amountMinor = paymentRequired
+ ? bookingAmountMinor(settings.paymentRatePerMinute, duration, settings.paymentCurrency)
+ : 0;
+ if (paymentRequired && amountMinor <= 0) {
+ return json({ error: 'The configured per-minute price is too small for this currency.' }, 409);
+ }
+
const dateKey = dateKeyInZone(new Date(startMs), settings.timezone);
const availability = await computeAvailability(env, dateKey);
const matchingSlot = availability.slots.find((slot) => slot.start === new Date(startMs).toISOString());
@@ -370,11 +615,8 @@ async function createBooking(env: Env, httpRequest: Request, request: BookingReq
const lockStart = addMinutes(startMs, -settings.bufferBeforeMinutes);
const lockEnd = addMinutes(endMs, settings.bufferAfterMinutes);
const id = crypto.randomUUID();
- const lockExpiry = Date.now() + 3 * 60_000;
+ const lockExpiry = Date.now() + (paymentRequired ? 35 : 3) * 60_000;
const keys = slotKeys(lockStart, lockEnd, settings.slotIncrementMinutes);
- const paymentAvailable = settings.paymentEnabled
- && settings.paymentAmountMinor > 0
- && stripeConfigured(env);
await env.DB.prepare('DELETE FROM slot_locks WHERE expires_at < ?1').bind(Date.now()).run();
@@ -396,9 +638,9 @@ async function createBooking(env: Env, httpRequest: Request, request: BookingReq
duration,
settings.timezone,
request.meetingMode,
- paymentAvailable ? 'available' : 'not_requested',
- paymentAvailable ? settings.paymentAmountMinor : null,
- paymentAvailable ? settings.paymentCurrency : null,
+ paymentRequired ? 'awaiting_checkout' : 'not_requested',
+ paymentRequired ? amountMinor : null,
+ paymentRequired ? settings.paymentCurrency : null,
),
...keys.map((key) =>
env.DB.prepare('INSERT INTO slot_locks (slot_key, booking_id, expires_at) VALUES (?1, ?2, ?3)')
@@ -412,8 +654,11 @@ async function createBooking(env: Env, httpRequest: Request, request: BookingReq
return json({ error: 'Someone just took that time. Please choose another slot.' }, 409);
}
+ let holdEventId: string | null = null;
try {
- // Google is checked once more after the D1 reservation, limiting double-booking races.
+ // Recheck Google after the D1 reservation. Paid bookings then get a private
+ // owner-only calendar hold, so the visitor receives no invitation or Meet link
+ // until Stripe says the payment succeeded.
const fresh = await queryBusy(
env,
settings,
@@ -425,6 +670,52 @@ async function createBooking(env: Env, httpRequest: Request, request: BookingReq
);
if (conflicts) throw new Error('That slot became busy in Google Calendar.');
+ if (paymentRequired) {
+ const hold = await createCalendarHold(env, settings, {
+ id,
+ start: new Date(startMs).toISOString(),
+ end: new Date(endMs).toISOString(),
+ meetingMode: request.meetingMode,
+ });
+ holdEventId = hold.eventId;
+ await env.DB.prepare(`
+ UPDATE bookings
+ SET google_event_id = ?1,
+ meet_url = ?2,
+ calendar_sync_status = 'held',
+ calendar_sync_updated_at = datetime('now'),
+ updated_at = datetime('now')
+ WHERE id = ?3
+ `).bind(hold.eventId, hold.meetUrl, id).run();
+
+ const session = await createStripeCheckoutSession(env, {
+ id,
+ email,
+ durationMinutes: duration,
+ amountMinor,
+ currency: settings.paymentCurrency,
+ label: settings.paymentLabel,
+ });
+ if (!session.id || !session.url) throw new StripeError('Stripe did not return a Checkout URL.');
+
+ await env.DB.prepare(`
+ UPDATE bookings
+ SET payment_status = 'checkout_open',
+ stripe_checkout_session_id = ?1,
+ updated_at = datetime('now')
+ WHERE id = ?2
+ `).bind(session.id, id).run();
+
+ return json({
+ ok: true,
+ requiresPayment: true,
+ checkoutUrl: session.url,
+ bookingId: id,
+ amountMinor,
+ currency: settings.paymentCurrency,
+ }, 201);
+ }
+
const created = await createCalendarEvent(env, settings, {
id,
name,
@@ -436,39 +727,40 @@ async function createBooking(env: Env, httpRequest: Request, request: BookingReq
meetingMode: request.meetingMode,
});
- const keepLockUntil = Date.now() + 10 * 60_000;
await env.DB.batch([
env.DB.prepare(`
UPDATE bookings
- SET status = 'confirmed', google_event_id = ?1, meet_url = ?2
+ SET status = 'confirmed', google_event_id = ?1, meet_url = ?2,
+ calendar_sync_status = 'synced', calendar_sync_updated_at = datetime('now'),
+ updated_at = datetime('now')
WHERE id = ?3
`).bind(created.eventId, created.meetUrl, id),
env.DB.prepare('UPDATE slot_locks SET expires_at = ?1 WHERE booking_id = ?2')
- .bind(keepLockUntil, id),
+ .bind(Date.now() + 10 * 60_000, id),
]);
return json({
ok: true,
+ requiresPayment: false,
booking: {
id,
start: new Date(startMs).toISOString(),
end: new Date(endMs).toISOString(),
duration,
timezone: settings.timezone,
+ meetingMode: request.meetingMode,
meetUrl: created.meetUrl,
- payment: paymentAvailable ? {
- enabled: true,
- amountMinor: settings.paymentAmountMinor,
- currency: settings.paymentCurrency,
- label: settings.paymentLabel,
- } : undefined,
},
}, 201);
} catch (error) {
+ if (holdEventId) {
+ await deleteCalendarEvent(env, settings, holdEventId).catch(() => undefined);
+ }
await env.DB.batch([
env.DB.prepare('DELETE FROM slot_locks WHERE booking_id = ?1').bind(id),
env.DB.prepare('DELETE FROM bookings WHERE id = ?1').bind(id),
]).catch(() => undefined);
+ if (error instanceof StripeError) return json({ error: error.message }, error.status);
const message = errorMessage(error);
const status = message.includes('became busy') ? 409 : 502;
return json({ error: message }, status);
@@ -478,86 +770,134 @@ async function createBooking(env: Env, httpRequest: Request, request: BookingReq
async function createPaymentCheckout(env: Env, bookingId: string): Promise<Response> {
if (!/^[0-9a-f-]{36}$/i.test(bookingId)) return json({ error: 'Invalid booking id.' }, 400);
if (!stripeConfigured(env)) return json({ error: 'Stripe is not configured for this deployment.' }, 503);
-
- const booking = await env.DB.prepare(`
- SELECT id, email, duration_minutes, status, payment_status,
- payment_amount, payment_currency, stripe_checkout_session_id
- FROM bookings
- WHERE id = ?1
- `).bind(bookingId).first<{
- id: string;
- email: string;
- duration_minutes: number;
- status: string;
- payment_status: string;
- payment_amount: number | null;
- payment_currency: string | null;
- stripe_checkout_session_id: string | null;
- }>();
-
+ 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 be paid.' }, 409);
- if (booking.payment_status === 'paid') return json({ ok: true, paid: true });
- if (booking.payment_status === 'not_requested') {
- return json({ error: 'Online payment was not offered for this booking.' }, 409);
+ if (booking.status === 'confirmed' && booking.payment_status === 'paid') return json({ ok: true, paid: true, booking: publicConfirmation(booking) });
+ if (booking.status !== 'pending' || !booking.stripe_checkout_session_id) {
+ return json({ error: 'This booking does not have an active payment checkout.' }, 409);
}
- const settings = await getSettings(env);
- if (!settings.paymentEnabled) return json({ error: 'Online payment is not enabled.' }, 409);
-
- const amountMinor = booking.payment_amount;
- const currency = booking.payment_currency;
- if (typeof amountMinor !== 'number' || !Number.isInteger(amountMinor) || amountMinor <= 0 || !currency || !/^[a-z]{3}$/.test(currency)) {
- return json({ error: 'Payment amount is not configured.' }, 409);
+ 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, paid: true, booking: publicConfirmation(confirmed) });
+ }
+ if (session.status === 'open' && session.url) return json({ ok: true, url: session.url });
+ if (session.status === 'complete') return json({ ok: true, processing: true });
+ return json({ error: 'That Stripe checkout is no longer active. Please book the slot again.' }, 409);
+ } catch (error) {
+ if (error instanceof StripeError) return json({ error: error.message }, error.status);
+ throw error;
}
+}
+async function paymentStatus(env: Env, sessionId: string): Promise<Response> {
+ if (!stripeConfigured(env)) return json({ error: 'Stripe is not configured for this deployment.' }, 503);
try {
- if (booking.stripe_checkout_session_id && booking.payment_status !== 'failed') {
- const existing = await retrieveStripeCheckoutSession(env, booking.stripe_checkout_session_id);
- if (existing.payment_status === 'paid') {
- await env.DB.prepare(`
- UPDATE bookings
- SET payment_status = 'paid',
- stripe_payment_intent_id = ?1,
- paid_at = COALESCE(paid_at, datetime('now'))
- WHERE id = ?2
- `).bind(stripePaymentIntentId(existing), booking.id).run();
- return json({ ok: true, paid: true });
- }
- if (existing.status === 'open' && existing.url) {
- return json({ ok: true, url: existing.url });
- }
- if (existing.status === 'complete') {
- return json({ ok: true, processing: true });
- }
+ const session = await retrieveStripeCheckoutSession(env, sessionId);
+ const bookingId = session.metadata?.booking_id || session.client_reference_id;
+ if (!bookingId || !/^[0-9a-f-]{36}$/i.test(bookingId)) {
+ return json({ state: 'missing' }, 404);
}
- const session = await createStripeCheckoutSession(env, {
- id: booking.id,
- email: booking.email,
- durationMinutes: booking.duration_minutes,
- amountMinor,
- currency,
- label: settings.paymentLabel,
- });
- if (!session.id || !session.url) throw new StripeError('Stripe did not return a Checkout URL.');
+ const existing = await paymentBookingById(env, bookingId);
+ if (existing?.status === 'confirmed') {
+ const receiptUrl = session.payment_status === 'paid'
+ ? await stripeReceiptUrl(env, session).catch(() => null)
+ : null;
+ return json({ state: 'confirmed', booking: publicConfirmation(existing, receiptUrl) });
+ }
- await env.DB.prepare(`
- UPDATE bookings
- SET payment_status = 'checkout_open',
- payment_amount = ?1,
- payment_currency = ?2,
- stripe_checkout_session_id = ?3
- WHERE id = ?4
- `).bind(amountMinor, currency, session.id, booking.id).run();
-
- return json({ ok: true, url: session.url });
+ if (session.payment_status === 'paid') {
+ await recordPaidSession(env, bookingId, session);
+ try {
+ const confirmed = await fulfillPaidBooking(env, bookingId);
+ const receiptUrl = await stripeReceiptUrl(env, session).catch(() => null);
+ return json({ state: 'confirmed', booking: publicConfirmation(confirmed, receiptUrl) });
+ } catch {
+ return json({ state: 'confirming', message: 'Payment received. Finalizing your calendar invitation…' });
+ }
+ }
+
+ if (session.status === 'complete') {
+ return json({ state: 'processing', message: 'Stripe is still processing the payment.' });
+ }
+ if (session.status === 'expired') {
+ const settings = await getSettings(env);
+ await releasePendingBooking(env, settings, bookingId).catch(() => undefined);
+ return json({ state: 'expired', message: 'Payment expired. No booking was confirmed.' });
+ }
+ return json({ state: 'awaiting_payment', message: 'Payment has not completed yet.' });
} catch (error) {
if (error instanceof StripeError) return json({ error: error.message }, error.status);
throw error;
}
}
+async function cancelPendingPayment(env: Env, bookingId: 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({ ok: true, state: 'cancelled' });
+ if (booking.status === 'confirmed') return json({ ok: true, state: 'confirmed', booking: publicConfirmation(booking) });
+ if (booking.status === 'confirming') return json({ ok: true, state: 'processing' });
+ 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' });
+ }
+ 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') {
+ 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);
+ }
+ throw error;
+ }
+ }
+
+ await releasePendingBooking(env, settings, booking.id);
+ return json({ ok: true, state: 'cancelled' });
+}
+
+async function paymentCancelReturn(env: Env, bookingId: string): Promise<Response> {
+ const appUrl = env.APP_URL.replace(/\/+$/, '');
+ if (!/^[0-9a-f-]{36}$/i.test(bookingId)) {
+ return redirect(`${appUrl}/?payment=cancelled&release_error=invalid_booking`);
+ }
+
+ const before = await paymentBookingById(env, bookingId);
+ const sessionId = before?.stripe_checkout_session_id ?? null;
+ const response = await cancelPendingPayment(env, bookingId);
+ const payload = await response.clone().json().catch(() => ({})) as { state?: string };
+
+ if (response.ok && (payload.state === 'confirmed' || payload.state === 'processing') && sessionId) {
+ return redirect(`${appUrl}/?payment=success&session_id=${encodeURIComponent(sessionId)}`);
+ }
+ if (response.ok && payload.state === 'cancelled') {
+ return redirect(`${appUrl}/?payment=cancelled&released=1`);
+ }
+
+ // Keep the booking id only on the error fallback so the browser can retry the
+ // release through the normal POST endpoint and show a useful error if needed.
+ return redirect(`${appUrl}/?payment=cancelled&booking_id=${encodeURIComponent(bookingId)}&release_error=1`);
+}
+
async function handleStripeWebhook(request: Request, env: Env): Promise<Response> {
const rawBody = await request.text();
try {
@@ -571,31 +911,18 @@ async function handleStripeWebhook(request: Request, env: Env): Promise<Response
if (event.type === 'checkout.session.completed' || event.type === 'checkout.session.async_payment_succeeded') {
const paid = session.payment_status === 'paid' || event.type === 'checkout.session.async_payment_succeeded';
if (paid) {
- await env.DB.prepare(`
- UPDATE bookings
- SET payment_status = 'paid',
- payment_amount = COALESCE(?1, payment_amount),
- payment_currency = COALESCE(?2, payment_currency),
- stripe_checkout_session_id = ?3,
- stripe_payment_intent_id = ?4,
- paid_at = COALESCE(paid_at, datetime('now'))
- WHERE id = ?5
- `).bind(
- session.amount_total ?? null,
- session.currency ?? null,
- session.id,
- stripePaymentIntentId(session),
- bookingId,
- ).run();
+ await recordPaidSession(env, bookingId, session);
+ await fulfillPaidBooking(env, bookingId);
} else {
await env.DB.prepare(`
UPDATE bookings
SET payment_status = CASE WHEN payment_status = 'paid' THEN 'paid' ELSE 'processing' END,
payment_amount = COALESCE(?1, payment_amount),
payment_currency = COALESCE(?2, payment_currency),
- stripe_payment_intent_id = COALESCE(?3, stripe_payment_intent_id)
+ stripe_payment_intent_id = COALESCE(?3, stripe_payment_intent_id),
+ updated_at = datetime('now')
WHERE id = ?4
- AND (stripe_checkout_session_id IS NULL OR stripe_checkout_session_id = ?5)
+ AND stripe_checkout_session_id = ?5
`).bind(
session.amount_total ?? null,
session.currency ?? null,
@@ -604,20 +931,9 @@ async function handleStripeWebhook(request: Request, env: Env): Promise<Response
session.id,
).run();
}
- } else if (event.type === 'checkout.session.async_payment_failed') {
- await env.DB.prepare(`
- UPDATE bookings
- SET payment_status = CASE WHEN payment_status = 'paid' THEN 'paid' ELSE 'failed' END
- WHERE id = ?1
- AND stripe_checkout_session_id = ?2
- `).bind(bookingId, session.id).run();
- } else if (event.type === 'checkout.session.expired') {
- await env.DB.prepare(`
- UPDATE bookings
- SET payment_status = CASE WHEN payment_status = 'paid' THEN payment_status ELSE 'available' END
- WHERE id = ?1
- AND stripe_checkout_session_id = ?2
- `).bind(bookingId, session.id).run();
+ } else if (event.type === 'checkout.session.async_payment_failed' || event.type === 'checkout.session.expired') {
+ const settings = await getSettings(env);
+ await releasePendingBooking(env, settings, bookingId);
}
return json({ received: true });
@@ -630,14 +946,17 @@ async function handleStripeWebhook(request: Request, env: Env): Promise<Response
async function cancelBooking(env: Env, bookingId: 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
+ SELECT id, google_event_id, status, payment_status
FROM bookings
WHERE id = ?1
- `).bind(bookingId).first<{ id: string; google_event_id: string | null; status: string }>();
+ `).bind(bookingId).first<{ id: string; google_event_id: string | null; status: string; payment_status: string }>();
if (!booking) return json({ error: 'Booking not found.' }, 404);
if (booking.status === 'cancelled') return json({ ok: true });
- if (booking.status !== 'confirmed') return json({ error: 'Only confirmed bookings can be cancelled.' }, 409);
+ if (booking.status === 'pending' && booking.payment_status !== 'not_requested') {
+ return cancelPendingPayment(env, bookingId);
+ }
+ if (booking.status !== 'confirmed') return json({ error: 'Only confirmed bookings or pending payment holds can be cancelled.' }, 409);
const settings = await getSettings(env);
try {
@@ -766,9 +1085,7 @@ async function handleApi(request: Request, env: Env, url: URL): Promise<Response
if (request.method === 'GET' && url.pathname === '/api/config') {
const settings = await getSettings(env);
const connected = await hasGoogleConnection(env);
- const paymentEnabled = settings.paymentEnabled
- && settings.paymentAmountMinor > 0
- && stripeConfigured(env);
+ const paymentRequired = settings.paymentEnabled;
return json({
title: settings.title,
subtitle: settings.subtitle,
@@ -781,9 +1098,11 @@ async function handleApi(request: Request, env: Env, url: URL): Promise<Response
.filter(([, intervals]) => intervals.length > 0)
.map(([day]) => Number(day)),
connected,
- payment: paymentEnabled ? {
+ payment: paymentRequired ? {
enabled: true,
- amountMinor: settings.paymentAmountMinor,
+ required: true,
+ ready: stripeConfigured(env) && settings.paymentRatePerMinute > 0,
+ ratePerMinute: settings.paymentRatePerMinute,
currency: settings.paymentCurrency,
label: settings.paymentLabel,
} : undefined,
@@ -825,6 +1144,19 @@ async function handleApi(request: Request, env: Env, url: URL): Promise<Response
return createPaymentCheckout(env, String(body.bookingId ?? ''));
}
+ if (request.method === 'GET' && url.pathname === '/api/payments/status') {
+ return paymentStatus(env, String(url.searchParams.get('session_id') ?? ''));
+ }
+
+ if (request.method === 'GET' && url.pathname === '/api/payments/cancel-return') {
+ return paymentCancelReturn(env, String(url.searchParams.get('booking_id') ?? ''));
+ }
+
+ if (request.method === 'POST' && url.pathname === '/api/payments/cancel') {
+ const body = (await request.json()) as { bookingId?: string };
+ return cancelPendingPayment(env, String(body.bookingId ?? ''));
+ }
+
if (request.method === 'POST' && url.pathname === '/api/stripe/webhook') {
return handleStripeWebhook(request, env);
}
@@ -839,7 +1171,11 @@ async function handleApi(request: Request, env: Env, url: URL): Promise<Response
}
if (request.method === 'GET' && url.pathname === '/api/admin/settings') {
- return json({ settings: await getSettings(env), stripeConfigured: stripeConfigured(env) });
+ return json({
+ settings: await getSettings(env),
+ stripeConfigured: stripeConfigured(env),
+ stripeMode: stripeMode(env),
+ });
}
if (request.method === 'PUT' && url.pathname === '/api/admin/settings') {
@@ -849,6 +1185,8 @@ async function handleApi(request: Request, env: Env, url: URL): Promise<Response
}
if (request.method === 'GET' && url.pathname === '/api/admin/bookings') {
+ const settings = await getSettings(env);
+ await cleanupStalePendingBookings(env, settings);
const result = await env.DB.prepare(`
SELECT id, name, email, start_time, end_time, duration_minutes,
meeting_mode, status, meet_url, created_at,
@@ -857,7 +1195,6 @@ async function handleApi(request: Request, env: Env, url: URL): Promise<Response
payment_status, payment_amount, payment_currency,
stripe_checkout_session_id, stripe_payment_intent_id, paid_at
FROM bookings
- WHERE status != 'pending'
ORDER BY start_time DESC
LIMIT 50
`).all();
diff --git a/worker/money.ts b/worker/money.ts
new file mode 100644
index 0000000..aa93e15
--- /dev/null
+++ b/worker/money.ts
@@ -0,0 +1,26 @@
+const ZERO_DECIMAL_CURRENCIES = new Set([
+ 'bif', 'clp', 'djf', 'gnf', 'jpy', 'kmf', 'krw', 'mga', 'pyg', 'rwf', 'ugx', 'vnd', 'vuv', 'xaf', 'xof', 'xpf',
+]);
+
+const THREE_DECIMAL_CURRENCIES = new Set(['bhd', 'jod', 'kwd', 'omr', 'tnd']);
+
+export function currencyMinorUnitDigits(currency: string): number {
+ const normalized = currency.trim().toLowerCase();
+ if (ZERO_DECIMAL_CURRENCIES.has(normalized)) return 0;
+ if (THREE_DECIMAL_CURRENCIES.has(normalized)) return 3;
+ return 2;
+}
+
+export function majorToMinor(amountMajor: number, currency: string): number {
+ if (!Number.isFinite(amountMajor) || amountMajor < 0) return 0;
+ return Math.round(amountMajor * (10 ** currencyMinorUnitDigits(currency)));
+}
+
+export function minorToMajor(amountMinor: number, currency: string): number {
+ if (!Number.isFinite(amountMinor)) return 0;
+ return amountMinor / (10 ** currencyMinorUnitDigits(currency));
+}
+
+export function bookingAmountMinor(ratePerMinute: number, durationMinutes: number, currency: string): number {
+ return majorToMinor(ratePerMinute * durationMinutes, currency);
+}
diff --git a/worker/settings.ts b/worker/settings.ts
index f9cc20c..5b9450a 100644
--- a/worker/settings.ts
+++ b/worker/settings.ts
@@ -16,8 +16,8 @@ export const DEFAULT_SETTINGS: AppSettings = {
allowMeetingModes: ['google_meet'],
inPersonLocation: '',
paymentEnabled: false,
- paymentAmountMinor: 0,
- paymentCurrency: 'usd',
+ paymentRatePerMinute: 0,
+ paymentCurrency: 'sek',
paymentLabel: 'Booking payment',
availability: {
'0': [],
@@ -38,10 +38,24 @@ export async function getSettings(env: Env): Promise<AppSettings> {
if (!row?.value) return DEFAULT_SETTINGS;
try {
- const parsed = JSON.parse(row.value) as Partial<AppSettings>;
+ const parsed = JSON.parse(row.value) as Partial<AppSettings> & { paymentAmountMinor?: number };
+ const currency = typeof parsed.paymentCurrency === 'string' && /^[a-zA-Z]{3}$/.test(parsed.paymentCurrency)
+ ? parsed.paymentCurrency.toLowerCase()
+ : DEFAULT_SETTINGS.paymentCurrency;
+ const hasPerMinuteRate = parsed.paymentRatePerMinute !== undefined
+ && Number.isFinite(Number(parsed.paymentRatePerMinute));
+ const paymentRatePerMinute = hasPerMinuteRate
+ ? Math.max(0, Number(parsed.paymentRatePerMinute))
+ : DEFAULT_SETTINGS.paymentRatePerMinute;
return {
...DEFAULT_SETTINGS,
...parsed,
+ // Old releases stored one fixed Stripe amount in `paymentAmountMinor`.
+ // There is no safe way to reinterpret that as a per-minute rate, so legacy
+ // payment settings are deliberately disabled until the owner saves a rate.
+ paymentEnabled: hasPerMinuteRate ? Boolean(parsed.paymentEnabled) : false,
+ paymentCurrency: currency,
+ paymentRatePerMinute,
durations: Array.isArray(parsed.durations) ? parsed.durations : DEFAULT_SETTINGS.durations,
busyCalendarIds: Array.isArray(parsed.busyCalendarIds) ? parsed.busyCalendarIds : DEFAULT_SETTINGS.busyCalendarIds,
allowMeetingModes: Array.isArray(parsed.allowMeetingModes)
diff --git a/worker/stripe.ts b/worker/stripe.ts
index 9474a6e..890dd61 100644
--- a/worker/stripe.ts
+++ b/worker/stripe.ts
@@ -6,7 +6,7 @@ export class StripeError extends Error {
}
}
-type StripeCheckoutSession = {
+export type StripeCheckoutSession = {
id: string;
object: 'checkout.session';
url?: string | null;
@@ -19,6 +19,19 @@ type StripeCheckoutSession = {
metadata?: Record<string, string> | null;
};
+
+type StripePaymentIntent = {
+ id: string;
+ object: 'payment_intent';
+ latest_charge?: string | { id?: string; receipt_url?: string | null } | null;
+};
+
+type StripeCharge = {
+ id: string;
+ object: 'charge';
+ receipt_url?: string | null;
+};
+
type StripeEvent = {
id: string;
type: string;
@@ -59,6 +72,15 @@ export function stripeConfigured(env: Env): boolean {
return Boolean(env.STRIPE_SECRET_KEY?.trim() && env.STRIPE_WEBHOOK_SECRET?.trim());
}
+export type StripeMode = 'test' | 'live' | 'unknown';
+
+export function stripeMode(env: Env): StripeMode {
+ const key = env.STRIPE_SECRET_KEY?.trim() ?? '';
+ if (key.startsWith('sk_test_')) return 'test';
+ if (key.startsWith('sk_live_')) return 'live';
+ return 'unknown';
+}
+
export async function createStripeCheckoutSession(
env: Env,
booking: {
@@ -72,12 +94,21 @@ export async function createStripeCheckoutSession(
): Promise<StripeCheckoutSession> {
const params = new URLSearchParams();
params.set('mode', 'payment');
- params.set('success_url', `${env.APP_URL.replace(/\/+$/, '')}/?payment=success`);
- params.set('cancel_url', `${env.APP_URL.replace(/\/+$/, '')}/?payment=cancelled`);
+ const appUrl = env.APP_URL.replace(/\/+$/, '');
+ params.set('success_url', `${appUrl}/?payment=success&session_id={CHECKOUT_SESSION_ID}`);
+ params.set('cancel_url', `${appUrl}/api/payments/cancel-return?booking_id=${encodeURIComponent(booking.id)}`);
params.set('client_reference_id', booking.id);
+ // A time-slot reservation cannot safely wait days for a delayed bank payment.
+ // Card Checkout still supports 3DS and wallets backed by cards, while giving us
+ // a definitive paid/unpaid result before the calendar invitation is sent.
+ params.set('payment_method_types[0]', 'card');
+ params.set('expires_at', String(Math.floor(Date.now() / 1000) + 30 * 60));
params.set('customer_email', booking.email);
params.set('metadata[booking_id]', booking.id);
params.set('payment_intent_data[metadata][booking_id]', booking.id);
+ // Explicitly attach the booking email to the PaymentIntent receipt. This makes
+ // live-mode receipts independent of the account's automatic-email toggle.
+ params.set('payment_intent_data[receipt_email]', booking.email);
params.set('line_items[0][price_data][currency]', booking.currency.toLowerCase());
params.set('line_items[0][price_data][unit_amount]', String(booking.amountMinor));
params.set('line_items[0][price_data][product_data][name]', booking.label);
@@ -100,6 +131,17 @@ export async function retrieveStripeCheckoutSession(env: Env, sessionId: string)
return stripeRequest<StripeCheckoutSession>(env, `/v1/checkout/sessions/${encodeURIComponent(sessionId)}`);
}
+export async function expireStripeCheckoutSession(env: Env, sessionId: string): Promise<StripeCheckoutSession> {
+ if (!/^cs_(test_|live_)?[A-Za-z0-9_]+$/.test(sessionId)) {
+ throw new StripeError('Invalid Stripe Checkout session id.', 400);
+ }
+ return stripeRequest<StripeCheckoutSession>(
+ env,
+ `/v1/checkout/sessions/${encodeURIComponent(sessionId)}/expire`,
+ { method: 'POST' },
+ );
+}
+
function constantTimeEqual(left: string, right: string): boolean {
if (left.length !== right.length) return false;
let result = 0;
@@ -159,6 +201,25 @@ export async function verifyStripeWebhook(
}
}
+
+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;
+
+ const intent = await stripeRequest<StripePaymentIntent>(
+ env,
+ `/v1/payment_intents/${encodeURIComponent(paymentIntentId)}?expand%5B%5D=latest_charge`,
+ );
+ if (intent.latest_charge && typeof intent.latest_charge !== 'string') {
+ return intent.latest_charge.receipt_url ?? null;
+ }
+ if (typeof intent.latest_charge === 'string' && /^ch_[A-Za-z0-9_]+$/.test(intent.latest_charge)) {
+ const charge = await stripeRequest<StripeCharge>(env, `/v1/charges/${encodeURIComponent(intent.latest_charge)}`);
+ return charge.receipt_url ?? null;
+ }
+ return null;
+}
+
export function stripePaymentIntentId(session: StripeCheckoutSession): string | null {
if (typeof session.payment_intent === 'string') return session.payment_intent;
return session.payment_intent?.id ?? null;
diff --git a/worker/types.ts b/worker/types.ts
index 6d38da5..e9ccb47 100644
--- a/worker/types.ts
+++ b/worker/types.ts
@@ -27,7 +27,7 @@ export interface AppSettings {
allowMeetingModes: MeetingMode[];
inPersonLocation: string;
paymentEnabled: boolean;
- paymentAmountMinor: number;
+ paymentRatePerMinute: number;
paymentCurrency: string;
paymentLabel: string;
availability: WeeklyAvailability;