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
import type { Env } from './types';
export class StripeError extends Error {
constructor(
message: string,
public readonly status = 502,
public readonly code?: string,
) {
super(message);
}
}
export type StripeCheckoutSession = {
id: string;
object: 'checkout.session';
url?: string | null;
status?: 'open' | 'complete' | 'expired' | null;
payment_status?: 'paid' | 'unpaid' | 'no_payment_required';
amount_total?: number | null;
currency?: string | null;
client_reference_id?: string | null;
payment_intent?: string | { id?: string } | null;
metadata?: Record<string, string> | null;
};
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';
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;
data?: { object?: StripeCheckoutSession | StripeRefund };
};
function stripeSecret(env: Env): string {
const key = env.STRIPE_SECRET_KEY?.trim();
if (!key) throw new StripeError('Stripe is not configured for this deployment.', 503);
return key;
}
async function stripeRequest<T>(env: Env, path: string, init: RequestInit = {}): Promise<T> {
let response: Response;
try {
response = await fetch(`https://api.stripe.com${path}`, {
...init,
headers: {
Authorization: `Bearer ${stripeSecret(env)}`,
...(init.body ? { 'Content-Type': 'application/x-www-form-urlencoded' } : {}),
...(init.headers ?? {}),
},
});
} catch {
throw new StripeError('Could not reach Stripe. Try again in a moment.', 502);
}
const data = (await response.json().catch(() => ({}))) as T & {
error?: { message?: string; code?: string };
};
if (!response.ok) {
throw new StripeError(
data.error?.message || `Stripe request failed (${response.status}).`,
response.status >= 500 ? 502 : response.status,
data.error?.code,
);
}
return data;
}
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 function stripeCheckoutSessionMode(sessionId: string | null | undefined): StripeMode {
const id = sessionId?.trim() ?? '';
if (id.startsWith('cs_test_')) return 'test';
if (id.startsWith('cs_live_')) return 'live';
return 'unknown';
}
export async function createStripeCheckoutSession(
env: Env,
booking: {
id: string;
email: string;
durationMinutes: number;
amountMinor: number;
currency: string;
label: string;
},
): Promise<StripeCheckoutSession> {
const params = new URLSearchParams();
params.set('mode', 'payment');
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);
params.set(
'line_items[0][price_data][product_data][description]',
`${booking.durationMinutes}-minute booking`,
);
params.set('line_items[0][quantity]', '1');
return stripeRequest<StripeCheckoutSession>(env, '/v1/checkout/sessions', {
method: 'POST',
body: params.toString(),
});
}
export async function retrieveStripeCheckoutSession(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)}`);
}
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;
for (let index = 0; index < left.length; index += 1) {
result |= left.charCodeAt(index) ^ right.charCodeAt(index);
}
return result === 0;
}
function bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
}
async function hmacSha256Hex(secret: string, message: string): Promise<string> {
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(message));
return bytesToHex(new Uint8Array(signature));
}
export async function verifyStripeWebhook(
env: Env,
rawBody: string,
signatureHeader: string | null,
toleranceSeconds = 300,
): Promise<StripeEvent> {
const secret = env.STRIPE_WEBHOOK_SECRET?.trim();
if (!secret) throw new StripeError('Stripe webhook secret is not configured.', 503);
if (!signatureHeader) throw new StripeError('Missing Stripe-Signature header.', 400);
const parts = signatureHeader.split(',').map((part) => part.trim());
const timestamp = parts.find((part) => part.startsWith('t='))?.slice(2);
const signatures = parts.filter((part) => part.startsWith('v1=')).map((part) => part.slice(3));
const timestampNumber = Number(timestamp);
if (!timestamp || !Number.isFinite(timestampNumber) || signatures.length === 0) {
throw new StripeError('Invalid Stripe webhook signature.', 400);
}
const ageSeconds = Math.abs(Date.now() / 1000 - timestampNumber);
if (ageSeconds > toleranceSeconds) throw new StripeError('Expired Stripe webhook signature.', 400);
const expected = await hmacSha256Hex(secret, `${timestamp}.${rawBody}`);
if (!signatures.some((signature) => constantTimeEqual(signature, expected))) {
throw new StripeError('Invalid Stripe webhook signature.', 400);
}
try {
return JSON.parse(rawBody) as StripeEvent;
} catch {
throw new StripeError('Invalid Stripe webhook payload.', 400);
}
}
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;
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;
}