Bill customers by MAU
Charge SaaS customers based on monthly active users
If you charge customers (B2B) by MAU (monthly active users), you need to count accurately. Olympus's audit log is the source of truth.
Defining "active"
Define before measuring:
- Logged in at least once: any login event in the month.
- Used the app: any API call, not just login.
- Daily active for N+ days: stricter.
Common: "logged in at least once." Easy to count and reason about.
Counting
SELECT
i.traits->>'tenant_id' AS tenant_id,
COUNT(DISTINCT i.id) AS mau
FROM identities i
JOIN security_audit a
ON a.identity_id = i.id
AND a.event_type = 'login'
AND a.outcome = 'success'
WHERE a.created_at >= DATE_TRUNC('month', NOW())
AND a.created_at < DATE_TRUNC('month', NOW() + INTERVAL '1 month')
GROUP BY 1;For previous month:
... WHERE a.created_at >= DATE_TRUNC('month', NOW() - INTERVAL '1 month')
AND a.created_at < DATE_TRUNC('month', NOW())Run on the 1st of each month for billing.
Cron + Stripe
# /etc/cron.monthly/mau-billing
node scripts/bill-mau.js// scripts/bill-mau.js
const tenants = await getActiveTenants();
const lastMonthRange = [startOfLastMonth(), endOfLastMonth()];
for (const tenant of tenants) {
const mau = await countMau(tenant.id, lastMonthRange);
// Stripe metered billing
await stripe.subscriptionItems.createUsageRecord(tenant.stripe_subscription_item_id, {
quantity: mau,
timestamp: Math.floor(lastMonthRange[1].getTime() / 1000),
action: "set",
});
console.log(`${tenant.name}: ${mau} MAU`);
}Stripe invoices: usage × per-MAU rate.
Tier-based pricing
const PRICING = [
{ upTo: 100, rate: 0 }, // free up to 100
{ upTo: 1000, rate: 0.50 }, // $0.50 per MAU above
{ upTo: 10000, rate: 0.30 }, // $0.30
{ upTo: Infinity, rate: 0.20 },
];
function calculatePrice(mau: number) {
let total = 0;
let remaining = mau;
let prevTier = 0;
for (const tier of PRICING) {
const tierUsage = Math.min(remaining, tier.upTo - prevTier);
total += tierUsage * tier.rate;
remaining -= tierUsage;
prevTier = tier.upTo;
if (remaining <= 0) break;
}
return total;
}Volume discount.
Excluding test users
In production tenant, you might have:
- Admin test accounts.
- Internal staff.
- Service accounts.
These shouldn't count as billable MAU.
WHERE i.traits->>'tenant_id' = $tenant
AND NOT (i.traits->>'email' LIKE '%@your-saas.com') -- exclude your staff
AND NOT (i.metadata_admin->>'excluded_from_billing' = 'true') -- explicit flagMark exclusions explicitly:
await kratos.adminPatch(id, [
{ op: "add", path: "/metadata_admin/excluded_from_billing", value: true }
]);Customer view of usage
Show your customers their MAU in their dashboard:
function UsageWidget({ tenant }) {
const { mau, limit, projected } = useMauData(tenant.id);
return (
<Card>
<h3>Monthly active users</h3>
<p>This month: <strong>{mau}</strong> / {limit}</p>
<ProgressBar value={mau / limit} />
{projected > limit && (
<Alert>Projected: {projected}. Consider upgrading.</Alert>
)}
</Card>
);
}Transparency. They see what they're paying for.
Soft overage
If they exceed plan:
- Block new users? Bad UX (existing users still work).
- Notify + bill overages: better.
- Auto-upgrade? Some prefer.
if (currentMau > plan.maxMau) {
await sendNotification(tenant.admin_email, "You've exceeded your MAU limit. Overage will be billed at $0.50/MAU.");
}Hard limit (block new signups)
If plan is strict:
// Pre-registration hook
const currentMau = await countMau(tenantId);
if (currentMau >= plan.maxMau) {
return Response.json({ reject: true, error: "tenant_user_limit" });
}Existing users still work, new ones blocked. Customer must upgrade.
Mid-month additions
User signs up on the 20th. Count for that month? Yes, they're active in this month.
User signs up at end of month, logs in twice. Still 1 MAU.
Recurring vs one-time
If billing recurring per MAU per month:
- Compute and report MAU at month-end.
- Stripe automatically applies.
If billing one-time per MAU (rare):
- Same compute.
- Charge once at month-end.
Audit + reconciliation
-- Annual reconciliation
SELECT
DATE_TRUNC('month', a.created_at) AS month,
i.traits->>'tenant_id' AS tenant,
COUNT(DISTINCT i.id) AS mau,
SUM(...) AS price
FROM ...
GROUP BY 1, 2;Compare to Stripe invoices. Should match. Investigate discrepancies.
Disputes
If customer disputes MAU count:
- Show breakdown: list of users who counted.
- Audit log entries for each.
If they say "alice@acme.com was a test, shouldn't count":
- Add to exclusion list.
- Re-compute (going forward).
For past months: don't re-bill (already settled). Just clarify policy.
Per-product MAU
If you offer multiple products, each with own MAU:
SELECT product, COUNT(DISTINCT identity_id) AS mau
FROM security_audit
WHERE event_type = 'login'
GROUP BY 1;Stripe metered with multiple items.
Daily / hourly tracking
For visibility:
CREATE MATERIALIZED VIEW daily_active_users AS
SELECT
DATE_TRUNC('day', a.created_at) AS day,
i.traits->>'tenant_id' AS tenant,
COUNT(DISTINCT a.identity_id) AS dau
FROM security_audit a
JOIN identities i ON a.identity_id = i.id
WHERE a.event_type = 'login' AND a.outcome = 'success'
GROUP BY 1, 2;
REFRESH MATERIALIZED VIEW daily_active_users;Cron daily refresh. Dashboard updates.