107 lines · 5.0 KB · TypeScript
RawDownload
// Cloud-only (billing): seat-based pricing bills the seat count Autumn holds,// so that count must actually REACH Autumn. Seats change through paths the// app never sees a mutation for (invitation acceptance in AuthKit, SSO JIT// provisioning, join by domain), so the app reconciles instead of tracking// deltas: it recounts active members from WorkOS and SETS the members balance// (`balances.update`), forked off org creation, login, invitation acceptance,// and member removal.//// Three guarantees are pinned here, all read from Autumn's own state (the// mutating responses can never show them):////   1. creating an organization lands its creator's seat on the members//      balance,//   2. a pending invite occupies a plan-gate seat but is NOT billed — active//      members only, and//   3. a later membership mutation reconciles rather than increments: the//      recount after removing the pending membership still reports exactly//      the active seats.import { expect } from "@effect/vitest";import { Effect, Schedule } from "effect";import { AccountHttpApi } from "@executor-js/api"; import { scenario } from "../src/scenario";import { Api, Autumn, Billing, Mcp, Target } from "../src/services";import type { Identity } from "../src/target"; // apps/cloud/src/extensions/billing/plans.ts → MEMBER_LIMITS.free, mirrored by// the free plan's members item in autumn.config.ts.const FREE_MEMBER_SEATS = 3; const emailOf = (identity: Identity): string => identity.credentials?.email ?? identity.label; /** The org the bearer is scoped to — the Autumn customer id every billing call *  is made against — read from the JWT's public claims. */const orgIdOf = (bearer: string): string => {  const claims = JSON.parse(Buffer.from(bearer.split(".")[1] ?? "", "base64url").toString()) as {    readonly org_id?: string;  };  if (!claims.org_id) throw new Error("orgIdOf: bearer carries no org_id claim");  return claims.org_id;}; scenario(  "Billing · the billed seat count reaches Autumn: active members only, reconciled not incremented",  { timeout: 180_000 },  Effect.gen(function* () {    yield* Billing;    const autumn = yield* Autumn;    const target = yield* Target;    const mcp = yield* Mcp;    const { client: apiClient } = yield* Api;     // A fresh user who owns a brand-new free org. Creating the org forks the    // first seat recount, so the creator's seat is on the meter before anyone    // is invited.    const identity = yield* target.newIdentity();    const bearer = yield* mcp.mintBearer(emailOf(identity));    const customerId = orgIdOf(bearer);    const client = yield* apiClient(AccountHttpApi, identity);     const seats = yield* autumn.expectMemberSeats(customerId, 1);    expect(seats.granted, "the free plan's members item grants the advertised seats").toBe(      FREE_MEMBER_SEATS,    );    expect(seats.unlimited, "free-plan seats are capped, not unlimited").toBe(false);     // An outstanding invite: the plan gate charges it a seat (so the org    // cannot invite past the cap), but billing counts active members only —    // nobody pays for a person who has not joined.    yield* client.account.inviteMember({ payload: { email: "invited@example.com" } });    const { members, seats: gateSeats } = yield* client.account.listMembers();    expect(gateSeats?.used, "the plan gate counts the pending invite as a seat").toBe(2);    const billedWithPendingInvite = yield* autumn.memberSeats(customerId);    expect(billedWithPendingInvite?.usage, "the pending invite is not billed").toBe(1);     // Removing the pending membership forks another recount. Its ledger entry    // is the barrier that separates "not billed yet" from "never billed": once    // the second reconciliation has landed, the balance is the recount's    // answer, not a stale read.    const pending = members.find((member) => member.status === "pending");    expect(pending, "the invited person appears as a pending membership").toBeTruthy();    yield* client.account.removeMember({ params: { membershipId: pending!.id } });     yield* autumn.ledgerFor("balances.update").pipe(      Effect.map((entries) => entries.filter((entry) => entry.customerId === customerId)),      Effect.filterOrFail(        (entries) => entries.length >= 2,        (entries) => `only ${entries.length}/2 seat reconciliations reached Autumn`,      ),      Effect.retry(Schedule.both(Schedule.spaced("500 millis"), Schedule.recurs(40))),    );     const reconciled = yield* autumn.memberSeats(customerId);    expect(      reconciled?.usage,      "the recount reconciles to the active seats — it never counted the invite, so it has nothing to walk back",    ).toBe(1);     // The balance moved by reconciliation events, not blind increments: one    // adjustment for the creator's seat, and none for the no-op recount.    const events = yield* autumn.usageEvents({ customerId, featureId: "members" });    expect(      events.map((event) => event.value),      "one +1 adjustment for the creator; the no-op recount writes no event",    ).toEqual([1]);  }),);

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t