Olympus Docs
CookbookOperations

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 flag

Mark 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.

On this page