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
stripe.ts
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;
}