Ctrl K
ComunidadGuías…

src/billing.js

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

Archivo original ↗Preguntar a KiBot
import { WeaveError } from './store.js';

export const CLERK_API = 'https://api.clerk.com/v1';
export const PAID_ROLES = new Set(['editor', 'architect']);
export const FRESH_MS = 60_000;
export const STALE_MS = 15 * 60_000;
export const DAY_MS = 86_400_000;
export const TRIAL_DAYS = 14;
const ORG_ROLE = { architect: 'org:admin', editor: 'org:member' };
const PAGE = 500;

export class BillingUnavailable extends Error {
  constructor(cause) {
    super('Clerk Billing did not answer, so seats cannot be checked right now');
    this.name = 'BillingUnavailable';
    this.cause = cause;
  }
}

export function admits(item, at) {
  const status = item?.status;
  if (status === 'active' || status === 'past_due') return true;
  if (status === 'canceled') return typeof item.period_end === 'number' && at < item.period_end;
  if (status === 'ended' || status === 'expired') return item.past_due_at != null && item.canceled_at == null;
  return false;
}

const covers = (subscription, slug, at) => (subscription?.subscription_items ?? []).some((item) => item?.plan?.slug === slug && admits(item, at));

export function billingFromEnv(env = process.env, opts = {}) {
  const secretKey = String(env.WEAVE_CLERK_SECRET_KEY ?? '').trim();
  const plan = String(env.WEAVE_BILLING_PLAN ?? '').trim();
  if (!secretKey || !plan) return null;
  const rawSince = String(env.WEAVE_BILLING_SINCE ?? '').trim();
  const since = rawSince ? Date.parse(rawSince) : 0;
  if (Number.isNaN(since)) throw new WeaveError(`WEAVE_BILLING_SINCE must be a date like 2026-10-16T00:00:00Z (got '${rawSince}')`, 'invalid');
  const rawDays = String(env.WEAVE_TRIAL_DAYS ?? '').trim();
  const trialDays = rawDays ? Number(rawDays) : TRIAL_DAYS;
  if (!Number.isInteger(trialDays) || trialDays < 0) throw new WeaveError(`WEAVE_TRIAL_DAYS must be a whole number of days, 0 for no trial (got '${rawDays}')`, 'invalid');
  const issuer = String(env.WEAVE_OIDC_ISSUER ?? '').trim().replace(/\/+$/, '');
  const publishableKey = String(env.WEAVE_CLERK_PUBLISHABLE_KEY ?? '').trim() || null;
  return createBilling({ secretKey, plan, since, trialDays, issuer, publishableKey, ...opts });
}

export function trialOf(workspace, trialDays, at) {
  const from = Date.parse(workspace?.trialFrom ?? '');
  if (!trialDays || Number.isNaN(from)) return null;
  const ends = from + trialDays * DAY_MS;
  return at < ends ? { endsAt: new Date(ends).toISOString(), daysLeft: Math.ceil((ends - at) / DAY_MS) } : null;
}

export function createBilling({ secretKey, plan, since = 0, trialDays = TRIAL_DAYS, issuer = '', publishableKey = null, fetch = globalThis.fetch, now = Date.now }) {
  const cache = new Map();

  async function api(method, path, body) {
    const res = await fetch(`${CLERK_API}${path}`, {
      method,
      headers: { Authorization: `Bearer ${secretKey}`, 'Content-Type': 'application/json', Accept: 'application/json' },
      body: body ? JSON.stringify(body) : undefined,
      redirect: 'error',
      signal: AbortSignal.timeout(10_000),
    });
    const json = await res.json().catch(() => null);
    if (!res.ok) {
      const error = new Error(`Clerk answered ${res.status} at ${method} ${path.split('?')[0]}${json?.errors?.[0]?.code ? `: ${json.errors[0].code}` : ''}`);
      error.status = res.status;
      error.code = json?.errors?.[0]?.code ?? null;
      throw error;
    }
    return json;
  }

  const subscription = (kind, id) => api('GET', `/${kind}/${encodeURIComponent(id)}/billing/subscription`).catch((error) => {
    if (error.status === 404) return null;
    throw error;
  });

  async function members(orgId) {
    const out = [];
    for (let offset = 0; ; offset += PAGE) {
      const page = await api('GET', `/organizations/${encodeURIComponent(orgId)}/memberships?limit=${PAGE}&offset=${offset}`);
      const data = page?.data ?? [];
      out.push(...data);
      if (data.length < PAGE) return out;
    }
  }

  const subjectOf = (account) => (account.identities ?? []).find((i) => !issuer || i.issuer === issuer)?.subject ?? null;
  const seatsOf = (accounts) => accounts.filter((a) => PAID_ROLES.has(a.seatRole ?? a.role) && subjectOf(a));

  async function lookup(workspace, accounts) {
    const seats = seatsOf(accounts);
    const orgId = workspace.billing?.orgId ?? null;
    if (orgId && covers(await subscription('organizations', orgId), plan, now())) {
      const seated = new Set((await members(orgId)).map((m) => m?.public_user_data?.user_id).filter(Boolean));
      return { plan: 'paid', seats: seats.length, seatedSubjects: seated };
    }
    return { plan: null, seats: seats.length, seatedSubjects: new Set() };
  }

  const shape = ({ plan: state, seats, seatedSubjects }, accounts) => {
    const byName = new Map(accounts.map((a) => [a.name, a]));
    return {
      plan: state,
      seats,
      orgId: null,
      seated(name) {
        const account = byName.get(name);
        if (!account || !PAID_ROLES.has(account.seatRole ?? account.role) || state === 'comped' || state === 'trial') return true;
        const subject = subjectOf(account);
        return !subject || seatedSubjects.has(subject);
      },
    };
  };

  return {
    plan,
    publishableKey,
    subjectOf,
    trialDays,
    trial: (workspace) => trialOf(workspace, trialDays, now()),
    async standing({ workspace, accounts }) {
      const created = Date.parse(workspace.createdAt ?? '');
      const orgId = workspace.billing?.orgId ?? null;
      if (!Number.isNaN(created) && created < since) return { ...shape({ plan: 'comped', seats: seatsOf(accounts).length, seatedSubjects: new Set() }, accounts), orgId, trial: null };
      const trial = trialOf(workspace, trialDays, now());
      const key = `${workspace.slug ?? ''}\u0000${workspace.billing?.orgId ?? ''}\u0000${seatsOf(accounts).map(subjectOf).sort().join(',')}`;
      const hit = cache.get(key);
      let value;
      if (hit && now() - hit.at < FRESH_MS) value = hit.value;
      else {
        try {
          value = await lookup(workspace, accounts);
          if (cache.size > 10_000) cache.clear();
          cache.set(key, { at: now(), value });
        } catch (error) {
          if (hit && now() - hit.at <= STALE_MS) value = hit.value;
          else if (trial) value = { plan: null, seats: seatsOf(accounts).length, seatedSubjects: new Set() };
          else throw new BillingUnavailable(error);
        }
      }
      if (value.plan == null && trial) return { ...shape({ ...value, plan: 'trial' }, accounts), orgId, trial };
      return { ...shape(value, accounts), orgId, trial: null };
    },
    forget() { cache.clear(); },
    async ensureOrg({ workspace, createdBy }) {
      if (workspace.billing?.orgId) return workspace.billing.orgId;
      const org = await api('POST', '/organizations', { name: workspace.name || workspace.slug, created_by: createdBy, private_metadata: { weave_workspace: workspace.slug } });
      return org.id;
    },
    async syncMembers({ orgId, accounts }) {
      const wanted = new Map();
      for (const account of seatsOf(accounts)) wanted.set(subjectOf(account), { name: account.name, role: ORG_ROLE[account.role] ?? ORG_ROLE.editor });
      const present = new Map((await members(orgId)).map((m) => [m?.public_user_data?.user_id, m.role]).filter(([id]) => id));
      const refused = [];
      const base = `/organizations/${encodeURIComponent(orgId)}/memberships`;
      for (const [subject, { name, role }] of wanted) {
        try {
          if (!present.has(subject)) await api('POST', base, { user_id: subject, role });
          else if (present.get(subject) !== role) await api('PATCH', `${base}/${encodeURIComponent(subject)}`, { role });
        } catch (error) {
          if (error.status >= 500 || !error.status) throw error;
          refused.push(name);
        }
      }
      for (const subject of present.keys()) if (!wanted.has(subject)) await api('DELETE', `${base}/${encodeURIComponent(subject)}`);
      cache.clear();
      return { refused };
    },
  };
}