Ctrl K
ComunidadGuías…

src/lib/billing.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { createHmac, timingSafeEqual } from "node:crypto";
import { db } from "@/lib/db";
import { billingIsConfigured, getSettings, type InstanceSettings } from "@/lib/settings";
import { createInvite } from "@/lib/data/users";

/**
 * Paid hosting, kept as small as it can honestly be.
 *
 * The instance owner creates a Product and a Payment Link in the Stripe
 * Dashboard — no provisioning code, no price in this repo. Someone pays, the
 * webhook fires, and this module does the only two jobs that need code:
 *
 *   1. Turn a paid checkout into an invite, if the payer isn't a user yet.
 *   2. Keep one fact in sync: does this customer's subscription entitle them
 *      to be active on this instance?
 *
 * Sync is convergent rather than event-driven: whatever the webhook says
 * happened, we ask Stripe for the customer's current subscriptions and act on
 * that answer. Events can arrive late, twice, or out of order, and this stays
 * correct — the same property the stateless MCP transport has, for the same
 * reason. A subscription in `past_due` still counts as entitled: Stripe's own
 * retries are the grace period, and `customer.subscription.deleted` is the
 * definitive end.
 *
 * Like the Resend client in email.ts, this speaks to Stripe's REST API with
 * plain fetch. It is three GETs; an SDK would be the largest dependency in the
 * app.
 *
 * Billing only ever touches users whose stripeCustomerId matches the event.
 * The owner has no customer id, free invitees have no customer id — a Stripe
 * outage or a bad webhook cannot lock the owner out of their own instance.
 */

const STRIPE_API = "https://api.stripe.com";

/** Stripe's definition of "still paying", including their retry window. */
const LIVE_STATUSES = new Set(["active", "trialing", "past_due"]);

/** The events the webhook acts on. Anything else is acknowledged and ignored. */
export const BILLING_EVENTS = new Set([
  "checkout.session.completed",
  "customer.subscription.created",
  "customer.subscription.updated",
  "customer.subscription.deleted",
  "invoice.payment_failed",
]);

/**
 * Verify a Stripe-Signature header: v1 = HMAC-SHA256(`${t}.${payload}`) with
 * the webhook signing secret. Several v1 entries can appear during a secret
 * rotation; any one matching passes. The timestamp check bounds replays.
 */
export function verifyStripeSignature(
  payload: string,
  header: string | null,
  secret: string,
  toleranceSeconds = 300,
  now = Math.floor(Date.now() / 1000),
): boolean {
  if (!header || !secret) return false;
  const parts = new Map<string, string[]>();
  for (const piece of header.split(",")) {
    const [key, value] = piece.split("=", 2);
    if (!key || value === undefined) continue;
    const list = parts.get(key.trim()) ?? [];
    list.push(value.trim());
    parts.set(key.trim(), list);
  }
  const timestamp = Number(parts.get("t")?.[0]);
  if (!Number.isFinite(timestamp) || Math.abs(now - timestamp) > toleranceSeconds) return false;

  const expected = createHmac("sha256", secret).update(`${timestamp}.${payload}`).digest("hex");
  const expectedBuffer = Buffer.from(expected, "utf8");
  return (parts.get("v1") ?? []).some((candidate) => {
    const candidateBuffer = Buffer.from(candidate, "utf8");
    return (
      candidateBuffer.length === expectedBuffer.length &&
      timingSafeEqual(candidateBuffer, expectedBuffer)
    );
  });
}

async function stripeGet(path: string, secretKey: string): Promise<Record<string, unknown>> {
  const response = await fetch(`${STRIPE_API}${path}`, {
    headers: { Authorization: `Bearer ${secretKey}` },
    signal: AbortSignal.timeout(15000),
  });
  const body = (await response.json().catch(() => ({}))) as Record<string, unknown>;
  if (!response.ok) {
    const error = body.error as { message?: string } | undefined;
    throw new Error(error?.message || `Stripe returned ${response.status} for ${path}`);
  }
  return body;
}

/** Does this customer currently hold a live subscription? */
async function customerIsEntitled(customerId: string, secretKey: string): Promise<boolean> {
  const body = await stripeGet(
    `/v1/subscriptions?customer=${encodeURIComponent(customerId)}&status=all&limit=100`,
    secretKey,
  );
  const subscriptions = (body.data ?? []) as { status?: string }[];
  return subscriptions.some((sub) => sub.status && LIVE_STATUSES.has(sub.status));
}

async function customerEmail(customerId: string, secretKey: string): Promise<string> {
  const body = await stripeGet(`/v1/customers/${encodeURIComponent(customerId)}`, secretKey);
  return typeof body.email === "string" ? body.email.toLowerCase() : "";
}

export type BillingSyncResult = {
  customerId: string;
  email: string;
  entitled: boolean;
  action:
    | "activated"
    | "suspended"
    | "invited"
    | "linked"
    | "unlinked"
    | "member-unlinked"
    | "unchanged"
    | "no-user";
};

/**
 * The unattended state-changing operation: converge a Stripe customer with
 * this instance. Finds the user by customer id only, invites the payer if no
 * account holds that email yet, and drives isActive from entitlement — with
 * the same semantics as an admin suspension, so a lapsed customer's sessions
 * and MCP calls stop working immediately. It never attaches a customer to an
 * existing member; that is linkBillingCustomer's job, on an admin's say-so.
 */
export async function syncStripeCustomer(input: {
  customerId: string;
  /** Email from the event when it carries one; looked up from Stripe otherwise. */
  email?: string | null;
  /** For invite links when settings.publicUrl is not set. */
  baseUrl: string;
  settings?: InstanceSettings;
}): Promise<BillingSyncResult> {
  const settings = input.settings ?? (await getSettings());
  if (!billingIsConfigured(settings)) {
    throw new Error("Billing is not configured. Add the Stripe keys in Admin → Billing.");
  }

  const entitled = await customerIsEntitled(input.customerId, settings.stripeSecretKey);
  const email =
    input.email?.toLowerCase() || (await customerEmail(input.customerId, settings.stripeSecretKey));

  const user = await db.user.findFirst({ where: { stripeCustomerId: input.customerId } });

  if (!user) {
    // Billing may CREATE accounts, never claim them. The email on a checkout
    // is whatever the payer typed, so matching an existing member by it would
    // let anyone with the public payment link bind a member's account to a
    // foreign subscription and then suspend them by cancelling it. An existing
    // member who starts paying is linked deliberately, by the owner, with
    // admin_link_billing — and the invite path below stays safe because only
    // the holder of that inbox can accept the invite.
    if (email) {
      const existing = await db.user.findUnique({ where: { email } });
      if (existing) {
        return { customerId: input.customerId, email, entitled, action: "member-unlinked" };
      }
    }

    // The payer isn't a member yet. If their money is good, invite them —
    // the invite carries the customer id so it lands on their User row when
    // they accept, and a checkout that never converts to an invite acceptance
    // costs nothing to anyone.
    if (entitled && email) {
      const owner = await db.user.findFirst({
        where: { role: "SUPER_ADMIN" },
        orderBy: { createdAt: "asc" },
      });
      if (!owner) throw new Error("No owner account exists to send the invite from.");
      const existingInvite = await db.invite.findFirst({
        where: { email, acceptedAt: null, expiresAt: { gt: new Date() } },
      });
      if (existingInvite) {
        // An invite is already out — likely a free one the owner sent. Leave
        // it exactly as it is: stamping this customer id onto it would turn
        // an invitation the owner chose to make free into a billed one.
        return { customerId: input.customerId, email, entitled, action: "invited" };
      }
      // createInvite replaces any prior unaccepted invite for the address, so
      // the stamp below can only ever land on the invite created right here.
      await createInvite({ actor: owner, email, role: "MEMBER", baseUrl: input.baseUrl });
      await db.invite.updateMany({
        where: { email, acceptedAt: null },
        data: { stripeCustomerId: input.customerId },
      });
      return { customerId: input.customerId, email, entitled, action: "invited" };
    }
    return { customerId: input.customerId, email, entitled, action: "no-user" };
  }

  // Billing never touches the owner. If the owner somehow pays their own
  // instance, nothing here may suspend them.
  if (user.role === "SUPER_ADMIN") {
    return { customerId: input.customerId, email: user.email, entitled, action: "unchanged" };
  }

  if (entitled && !user.isActive) {
    await db.user.update({ where: { id: user.id }, data: { isActive: true } });
    return { customerId: input.customerId, email: user.email, entitled, action: "activated" };
  }
  if (!entitled && user.isActive) {
    // Same semantics as an admin suspension: the flag flips and every session
    // dies now. MCP tokens survive but the stateless transport re-checks
    // isActive on every call, so they stop working on the next one. Data is
    // kept — paying again reactivates the same workspace.
    await db.user.update({ where: { id: user.id }, data: { isActive: false } });
    await db.session.deleteMany({ where: { userId: user.id } });
    return { customerId: input.customerId, email: user.email, entitled, action: "suspended" };
  }
  return { customerId: input.customerId, email: user.email, entitled, action: "unchanged" };
}

/**
 * The deliberate half of linking, for the one case sync refuses to handle on
 * its own: an existing member who starts paying. The admin names the person;
 * the customer comes from Stripe's records for that same email (or an explicit
 * customer id when Stripe holds more than one). Unlink is the recovery hatch —
 * it detaches the account from billing entirely, which also ends billing's
 * authority over their isActive.
 */
export async function linkBillingCustomer(input: {
  email: string;
  customerId?: string;
  unlink?: boolean;
  baseUrl: string;
}): Promise<BillingSyncResult> {
  const settings = await getSettings();
  if (!billingIsConfigured(settings)) {
    throw new Error("Billing is not configured. Add the Stripe keys in Admin → Billing.");
  }

  const email = input.email.trim().toLowerCase();
  const user = await db.user.findUnique({ where: { email } });
  if (!user) throw new Error(`No user with email ${email}`);

  if (input.unlink) {
    await db.user.update({ where: { id: user.id }, data: { stripeCustomerId: "" } });
    return { customerId: user.stripeCustomerId, email, entitled: false, action: "unlinked" };
  }

  if (user.role === "SUPER_ADMIN") {
    throw new Error("The owner's account can't be put under billing.");
  }

  let customerId = input.customerId?.trim() ?? "";
  if (!customerId) {
    const body = await stripeGet(
      `/v1/customers?email=${encodeURIComponent(email)}&limit=3`,
      settings.stripeSecretKey,
    );
    const customers = (body.data ?? []) as { id?: string }[];
    if (customers.length === 0) {
      throw new Error(`Stripe has no customer with the email ${email}. Ask them to check out first, or pass a customerId.`);
    }
    if (customers.length > 1) {
      throw new Error(
        `Stripe has ${customers.length} customers with that email (${customers.map((c) => c.id).join(", ")}). Pass the right customerId explicitly.`,
      );
    }
    customerId = customers[0].id ?? "";
  }
  if (!customerId) throw new Error("Could not resolve a Stripe customer id.");

  const clash = await db.user.findFirst({
    where: { stripeCustomerId: customerId, NOT: { id: user.id } },
  });
  if (clash) throw new Error(`That Stripe customer is already linked to ${clash.email}.`);

  await db.user.update({ where: { id: user.id }, data: { stripeCustomerId: customerId } });
  return syncStripeCustomer({ customerId, baseUrl: input.baseUrl, settings });
}

/**
 * Re-converge from Stripe on demand — the recovery path for a missed webhook.
 * With an email, syncs that one person; without, every user carrying a
 * customer id. Safe to run any time, any number of times.
 */
export async function syncAllBilling(baseUrl: string, email?: string): Promise<BillingSyncResult[]> {
  const settings = await getSettings();
  if (!billingIsConfigured(settings)) {
    throw new Error("Billing is not configured. Add the Stripe keys in Admin → Billing.");
  }

  if (email) {
    const user = await db.user.findUnique({ where: { email: email.toLowerCase() } });
    if (!user) throw new Error(`No user with email ${email}`);
    if (!user.stripeCustomerId) {
      throw new Error(`${email} has no Stripe customer attached — they aren't a billed user.`);
    }
    return [
      await syncStripeCustomer({ customerId: user.stripeCustomerId, baseUrl, settings }),
    ];
  }

  const billed = await db.user.findMany({
    where: { stripeCustomerId: { not: "" } },
    orderBy: { createdAt: "asc" },
  });
  const results: BillingSyncResult[] = [];
  for (const user of billed) {
    results.push(
      await syncStripeCustomer({ customerId: user.stripeCustomerId, baseUrl, settings }),
    );
  }
  return results;
}

/** How many people currently pay for this instance. */
export async function billedUserCount(): Promise<number> {
  return db.user.count({ where: { stripeCustomerId: { not: "" } } });
}