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
orgon 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.
incrementwhen someone joins,decrementwhen they leave. Each moves the limit'sincrement(1 by default). - Included and extra. A soft limit includes its
valueand bills the rest. Each extra seat sendsmeter-overagewith 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
| Need | How |
|---|---|
| Seats per team instead of per org | scope: 'team', with teams as customers of type team. |
| A different number for one org | An override on the org: createCustomerOverride('acme', 'seats', 25). |
| Seats bought in advance | A topup of seat credits; grants cover the extra seats. |
| Show seats in your app | value(org, 'seats'), limit(org, 'seats'), and remaining(org, 'seats'). |
| Seat-based AI allowance | Meter AI tokens on the org too (scope: 'org'), with a limit sized for the seats. |