Skip to main content

Seats on the org

Seats belong to the org, but invites come from users. scope: 'org' counts the entitlement on the nearest customer of type org up the caller's refs, so your code calls it with whoever is acting.

import { Limitr } from '@formata/limitr';

const limitr = await Limitr.new(`
policy: {
credits: {
// $12 a month per extra seat
seat: { label: 'Seat', price: { amount: 12 } }
}
plans: {
team: {
default: true
entitlements: {
// Counted on the org, whichever user invites. 3 included, extra seats billed.
seats: { scope: 'org', limit: { credit: 'seat', mode: 'soft', value: 3 } }
}
}
starter: {
entitlements: {
// A hard ceiling: the 3rd invite is denied.
seats: { scope: 'org', limit: { credit: 'seat', value: 2 } }
}
}
}
}`);

limitr.addHandler('seats', (name, value) => {
if (name !== 'meter-overage') return;
const { customer, overage_price } = JSON.parse(value as string);
console.log(` extra seat for ${customer.id}: $${overage_price}`);
});

await limitr.ensureCustomer('acme', 'team', 'org', 'Acme');
await limitr.ensureCustomer('owner', '', 'user', 'Owner', ['acme']);

// Invites go through whichever user sends them; the seat counts on Acme.
async function invite(by: string, org: string, email: string) {
if (!(await limitr.increment(by, 'seats'))) {
console.log(`invite ${email}: no seats left`);
return;
}
console.log(`invite ${email}: ok (${await limitr.value(org, 'seats')} seats)`);
}
for (const email of ['bo@acme.test', 'cy@acme.test', 'di@acme.test', 'ed@acme.test']) {
await invite('owner', 'acme', email);
}

// Someone leaves.
await limitr.decrement('owner', 'seats');
console.log('after a removal:', await limitr.value('acme', 'seats'), 'seats');

// What to bill this month: seats past the 3 included.
const seats = (await limitr.value('acme', 'seats'))!;
const included = (await limitr.limit('acme', 'seats', false))!;
const extra = Math.max(0, seats - included);
console.log(`bill acme for ${extra} extra seat(s): $${extra * 12}`);

// On a plan with a hard seat limit, the invite that doesn't fit is denied.
await limitr.ensureCustomer('tiny', 'starter', 'org', 'Tiny');
await limitr.ensureCustomer('solo', '', 'user', 'Solo', ['tiny']);
for (const email of ['a@tiny.test', 'b@tiny.test', 'c@tiny.test']) {
await invite('solo', 'tiny', email);
}

It prints:

invite bo@acme.test: ok (1 seats)
invite cy@acme.test: ok (2 seats)
invite di@acme.test: ok (3 seats)
extra seat for acme: $12
invite ed@acme.test: ok (4 seats)
after a removal: 3 seats
bill acme for 0 extra seat(s): $0
invite a@tiny.test: ok (1 seats)
invite b@tiny.test: ok (2 seats)
invite c@tiny.test: no seats left

How it works​

  • Customers. The org is a customer of type org on the plan. Users have no plan of their own and ref the org, so they're on its plan and their seat calls count on it.
  • Invites and removals. increment when someone joins, decrement when they leave. Each moves the limit's increment (1 by default).
  • Included and extra. A soft limit includes its value and bills the rest. Each extra seat sends meter-overage with its price when it's added.
  • Hard seat limits. A hard limit denies the invite that doesn't fit, so your UI can offer an upgrade.
  • A user with no org in its refs is denied: scoped entitlements need the customer they're counted on.

Billing seats every month​

Seats don't reset: the count is how many seats there are right now. meter-overage tells you when a seat was added, which suits a prorated charge. For a monthly per-seat fee, bill the seats past the included amount at each billing date, as above: value(org, 'seats') - limit(org, 'seats', false).

Variations​

NeedHow
Seats per team instead of per orgscope: 'team', with teams as customers of type team.
A different number for one orgAn override on the org: createCustomerOverride('acme', 'seats', 25).
Seats bought in advanceA topup of seat credits; grants cover the extra seats.
Show seats in your appvalue(org, 'seats'), limit(org, 'seats'), and remaining(org, 'seats').
Seat-based AI allowanceMeter AI tokens on the org too (scope: 'org'), with a limit sized for the seats.