Spreadsheet-UX op een echte relationele engine

main

Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.

2.7 kB Ruw bestand
/**
 * What Wellformed costs, and what each tier allows.
 *
 * Two flat tiers, unlimited people on both. No seats, no credits, no add-ons —
 * every extra dimension of a price list is a support question, a billing edge
 * case and a migration path forever. The limits below are the only two numbers
 * that ever change what someone pays.
 *
 * A plan belongs to a **workspace**, not to an account, because a workspace is
 * what a customer buys: one database, one bill, one thing to cancel. The same
 * person can be a guest in someone else's paid workspace and never see an
 * invoice for it.
 *
 * Shared so that the price on the marketing page and the limit the server
 * enforces cannot drift apart.
 */
export const PLAN_IDS = ['free', 'team'] as const;

export type PlanId = (typeof PLAN_IDS)[number];

export interface Plan {
  id: PlanId;
  name: string;
  /** Euros per month, excluding VAT. Zero for the free tier. */
  priceEur: number;
  /** Rows across every table in the workspace; null means no limit. */
  rowLimit: number | null;
  /** Tables in the workspace; null means no limit. */
  tableLimit: number | null;
  /** One line, on the pricing card, saying who this is for. */
  blurb: string;
  includes: string[];
}

export const PLANS: Record<PlanId, Plan> = {
  free: {
    id: 'free',
    name: 'Free',
    priceEur: 0,
    rowLimit: 1_000,
    tableLimit: 5,
    blurb: 'Enough to move a real spreadsheet over and see whether it holds.',
    includes: [
      'Everything in the engine: links, formulas, rollups, views',
      'Unlimited people, any mix of admins, editors and viewers',
      'The full audit trail',
      'Export the whole workspace as a SQLite file, whenever you like',
    ],
  },
  team: {
    id: 'team',
    name: 'Team',
    priceEur: 20,
    rowLimit: 250_000,
    tableLimit: null,
    blurb: 'One flat price per workspace. Add everyone you work with.',
    includes: [
      'Everything in Free, without the ceiling',
      'As many tables as the work needs',
      '250,000 rows per workspace',
      'Still unlimited people — there is no per-seat price to plan around',
    ],
  },
};

export const DEFAULT_PLAN: PlanId = 'free';

export function isPlanId(value: string): value is PlanId {
  return (PLAN_IDS as readonly string[]).includes(value);
}

/** The named plan, falling back to free — an unknown plan must never mean "unlimited". */
export function planOf(id: string | null | undefined): Plan {
  return id && isPlanId(id) ? PLANS[id] : PLANS[DEFAULT_PLAN];
}

/** How a limit reads in a sentence: "1,000 rows", "unlimited tables". */
export function describeLimit(limit: number | null, noun: string): string {
  return limit === null ? `unlimited ${noun}` : `${limit.toLocaleString('en-US')} ${noun}`;
}