Mooflair keeps plans, prices, subscriptions and invoices itself. A payment method (gateway) only moves money and reports back. The first one is PayPal Subscriptions. A workspace with no subscription is on Free.
Who pays
The workspace owner is the billing customer (BillingCustomer, one per user). Each workspace's subscription is billed to its owner. Their currency locks after the first paid invoice.
Plans and prices
Seeded once by the billing service when there are none (all editable later in Admin › Billing):
| Free | Pro | Team | Business | Enterprise | |
|---|---|---|---|---|---|
| USD / month | $0 | $19 | $39 per seat | $79 per seat | contact |
| IDR / month | Rp0 | Rp299.000 | Rp599.000 per seat | Rp1.199.000 per seat | contact |
| Seats | 1 | 1 | min 2 | min 3 | min 5 |
| Flows | 3 | unlimited | unlimited | unlimited | unlimited |
| Campaigns | 1 | 5 | 15 | 50 | custom |
| Browsers at once | 1 | 2 | 5 | 15 | custom |
| Browser hours / month | 10 | 150 | 600 | 2,000 | custom |
| Node steps / month | 2,000 | 50,000 | 250,000 | 1,000,000 | custom |
Yearly = 10 × monthly. Team has a 14-day trial (up to 5 seats, no card, once per person).
BillingPlan: key, name/description/highlight in en and id,feature[],limit,seat { min, max },unit(seat or flat),trial { days },isPublic.BillingPrice: plan,kind(plan, seat, addon, pack), interval, currency,amountin minor units (IDR has 0 decimals),isActive. Prices are never edited: a change is a new price, and subscriptions keep theirs.
Currency
Shown: the customer's locked currency, then the currency cookie (the pricing switcher), the country (CF-IPCountry → billing.currency.country), the language (id → IDR), then billing.currency.fallback. Only currencies that have active prices are offered. ?currency=usd on /pricing wins for that visit (shared links show what was shared).
Amounts are written by one component, BillingAmount, in the currency's own locale (Rp 299.000, $19.00), whatever the page language.
Showing IDR, charging USD
PayPal can't charge IDR. When a method can't charge the display currency, checkout shows the IDR total and under it "Charged as US$118.00 by PayPal". The charge is IDR total ÷ the day's rate × (1 + margin) (margin 2% by default), fixed on the subscription (currency { display, charge, rate }, amount.cycle). Invoices are issued in the charge currency with the IDR amount printed for reference.
Rates (BillingRate) are refreshed daily by the billing service from open.er-api.com (no key), for every configured currency into every currency an enabled gateway charges. Pinned rates are left alone.
The quote
billingQuote (src/controllers/billing/quote.ts) is the only place a price is worked out: the plan or seat line, a coupon, the gateway's fee when the customer pays it, the total, and the charge amount. The checkout summary and the gateway amount both come from it.
Checkout
/checkout?plan&interval&seat¤cy&workspace&coupon (sign-in first, then back with the choice kept):
- Pick the workspace (one you own), billing details (name, email, company, tax ID, address, country), the payment method, accept the Terms and Refund policy.
- Continue to PayPal saves the details, creates the subscription as
incompleteand sends you to PayPal's approval page. /checkout/returnwaits for confirmation (asking PayPal directly while the webhook is on its way): success ("Welcome to Team", invite your team or open Studio), pending, failed or canceled.
/checkout?plan=team&trial=1 starts the trial instead (no payment).
A workspace that already has a paid plan can't check out again; plan changes come from Settings › Billing.
Changing a plan
Settings › Billing › Change plan picks the plan, monthly or yearly, and seats, and shows the cost before confirming (billingChange, src/controllers/billing/change.ts):
| Change | When | Cost now |
|---|---|---|
| Upgrade or more seats | now | the difference for the rest of the period, prorated |
| Monthly → yearly | now, a new year starts | the year minus the unused part of the month |
| Downgrade, fewer seats, yearly → monthly | at the next renewal | nothing |
PayPal never charges without the buyer, so every change goes through PayPal:
- Now: a one-time PayPal payment for the prorated amount. Back in Settings › Billing it is captured, a
subscription_updateinvoice is issued, the new plan applies at once, and PayPal is asked to bill the new amount from the next period (approve it there). - At renewal: saved as a pending change and PayPal's amount is revised (approve it there). It applies when PayPal bills the next period. Keep current plan drops it.
- A change PayPal still waits to approve shows "Approve the change at PayPal" with the link.
The new amount uses the subscription's own exchange rate (fixed at checkout).
Before confirming, the preview also lists what the new plan wouldn't fit: members beyond its seats (they become view-only), enabled campaigns beyond its limit (turned off), flows beyond its limit (kept, no new ones) and flows using nodes it doesn't include (they save but won't run).
When a smaller plan takes effect (a downgrade at renewal, a trial or plan that ended, an unpaid plan), billingEnforce brings the workspace inside it: the newest admins and members beyond its seats become view-only (the owner keeps editing, guests are free) and the newest enabled campaigns beyond its limit turn off. Nothing is deleted, and paying again lifts it.
PayPal Subscriptions
- Credentials in
configuration.json(billing.gateway.paypal.credential: client ID, secret, webhook ID;environmentsandbox or live). The OAuth token is cached until it expires. - Sync creates one PayPal product and one generic plan per currency × interval (
billing.gateway.paypal.plan). Each subscription sets its real amount through PayPal's plan override, so new prices and seat counts never need a new PayPal plan. - Webhooks at
/api/billing/webhook/paypal, verified with PayPal's verify-webhook-signature API:
| PayPal event | Becomes |
|---|---|
BILLING.SUBSCRIPTION.ACTIVATED | active (members unfreeze; an older trial ends) |
PAYMENT.SALE.COMPLETED | a paid invoice for the period, the period moves forward |
BILLING.SUBSCRIPTION.PAYMENT.FAILED | past_due |
BILLING.SUBSCRIPTION.SUSPENDED | unpaid |
BILLING.SUBSCRIPTION.CANCELLED / EXPIRED | canceled (or kept until period end when canceled that way) |
PAYMENT.SALE.REFUNDED / REVERSED | payment refunded |
CUSTOMER.DISPUTE.CREATED / RESOLVED | payment disputed; lost = refunded and the subscription unpaid |
BILLING.SUBSCRIPTION.UPDATED | a waiting approval is cleared |
Every webhook is stored once in BillingEvent (a repeat is ignored); if applying it fails, the error stays on the event.
Invoices
BillingInvoice: a gapless number per year (MOO-2026-000001, from BillingCounter), lines with periods, amounts (subtotal, discount, tax, total, paid, due), tax fields (0 while tax is off), and copies of the seller (billing.seller) and buyer at issue time, so an issued invoice never changes. /invoice/<token> is a shareable, print-ready page (Print saves it as PDF).
Settings › Billing
/app/<workspace>/setting/billing (owner edits; admins read): plan and status, renew/end date, trial days left, the amount per period and seats, a change waiting for approval at PayPal, past-due warning, Cancel (a short survey; a trial ends now, a paid plan stays until the period ends and PayPal stops charging at once), Resume (a new PayPal subscription starting at the period end, approved again), and invoices.
The billing service
src/services/billing seeds the plans, refreshes rates daily, and every 10 minutes:
- ends subscriptions that reached their end (canceled at period end, trials over): the workspace goes back to Free, and with no other live plan admins and members are frozen (view-only, nobody removed; the owner keeps editing). Paying again unfreezes everyone.
- sends one reminder to trials ending within 3 days
- expires checkouts not approved within a day (
incomplete_expired)
An unpaid subscription (PayPal suspended it) freezes members the same way.
Gates
What a workspace may do comes from one place, billingEntitlement (src/controllers/billing/entitlement.ts): its plan's features and limits, and its seats. Unpaid and canceled plans give Free. Everything is data on BillingPlan, so moving a feature or changing a limit needs no code.
| Limit | Free | Pro | Team | Business | Checked when |
|---|---|---|---|---|---|
| Flows | 3 | ∞ | ∞ | ∞ | creating, using a template, importing a package |
| Campaigns | 1 | 5 | 15 | 50 | creating, importing |
| Accounts per platform | 1 | 5 | 20 | 100 | adding an account |
| Browsers at once | 1 | 2 | 5 | 15 | the engine opening a browser |
| Longest run | 1 h | 24 h | none | none | the run payload caps the flow's time limit |
Parallel lanes per run (lane.count) | 1 | 2 | 4 | 8 | the payload (1: parallel branches and items refused); the engine caps lanes too |
Fastest schedule / polling (schedule.minutes) | 60 min | 15 min | 5 min | 1 min | the scheduler and the polling service |
Files storage, largest file (storage.gb, storage.fileMb) | 1 GB, 10 MB | 10 GB, 50 MB | 50 GB, 100 MB | 200 GB, 500 MB | every Drive upload and save (the trash counts until emptied); the largest file also caps run files |
Knowledge chunks (knowledge.chunk) | — | 10k | 100k | 500k | adding a document (all bases together) |
Webhook, form, chat and MCP calls a month (call.month) | 1k | 20k | 100k | 1M | each call (billingCall; 402 over it) |
API calls per key a minute, per workspace a day (api.minute, api.day) | — | 60, 1k | 300, 10k | 1,000, 100k | apiAccess () |
Run and log history (history.days) | 7 d | 30 d | 90 d | 1 y | hourly: older finished runs go, with their logs and files |
Run recordings (recording.days) | — | 7 d | 30 d | 90 d | the engine's hourly sweep of their screenshots |
Version history (version) | 5 versions | 30 d | 1 y | all | publishing (50 kept at most) |
Trash (trash.days) |
Admin › Billing › Plans edits each plan's features (toggles) and limits (JSON) live.
Already over a limit after a downgrade? Nothing is deleted; only new ones are refused.
Features: nodes with a feature are locked on plans without it (see ). flowNodeCatalog (src/controllers/flow/node/catalog.ts) is the one node list for the run payload, the Studio agent and the editor, each node marked isLocked.
Grant a plan: Admin › Billing › Subscriptions › Grant a plan gives a workspace (searched by name or slug) a plan, seats and the plan's add-ons without payment until an end date; it ends by itself at the end of that day (the admin's time zone). A workspace with an active, past-due or unpaid plan can't be granted another; a live trial is replaced. Change edits a granted plan (plan, seats, add-ons, end) or a trial (its end only; a trial ending within 3 days gets its reminder again for the new end). Both are one action, aAdminBillingSubscriptionSave: members unfreeze at once, then only the plan's seats stay active (billingEnforce). This is how a workspace gets a plan while no payment method is on (checkout then says payments aren't open).
Usage and limits
Two meters, per workspace per month (BillingUsage, period like 2026-10): node steps and browser hours (seconds of running browser containers; a worker's seconds, the agent without Chrome, count at billing.meter.worker.weight, 0.25 by default). The engine reports them every minute ().
- At 100% of either meter in the plan, the workspace's runs pause at the next step; they resume by themselves after an upgrade or when the month turns. No surprise bills.
- At 80% and 100% the owner gets an email (
billing.limit.reached), once per meter per month. - Settings › Billing shows both meters against the plan, and the month's calls.
Add-ons and packs
Prices of kind addon and pack belong to a plan (Admin › Billing › Plans › a new price: kind, and its limit as JSON):
| Kind | Interval | Limit (example) | What it does |
|---|---|---|---|
| Add-on | month or year (the plan's) | { "browser": { "concurrent": 1 } }, { "browser": { "hour": 100 } }, { "storage": { "gb": 10 } }, { "step": 50000 }, { "knowledge": { "chunk": 10000 } } | Raises the plan's limit by its numbers × its quantity, every period. A plan's unlimited stays unlimited. |
| Pack | once | { "step": 50000 }, { "browser": { "hour": 100 } } | Steps or browser hours for 12 months, used only after a month's plan limit is reached (the soonest to expire first). |
Settings › Billing › Add-ons and packs (each add-on shows what it gives and its price per interval, "+1 browser at once · $10.00 · monthly"): set each add-on's quantity and Update add-ons (it's a plan change: the quote adds a line per add-on, prorated now when it costs more, the gateway's amount revised), or Buy a pack (a one-time payment at the gateway, captured on return with its invoice). What's left of each pack shows there.
billingWorkspacePlan adds the add-ons to the plan's limits (never saved); billingUsage adds what's left of the packs to the step and hour limits; the engine's usage report takes what's past the plan's own limit from the packs (billingPackUse).
Trials
A trial leaves three hashed traces (BillingTrial): the email without Gmail dots and +tags, a device cookie (mooflair_device, a year), and the IP. Another trial with the same email or device is refused; an IP gets 3 a month.
Idle Free browsers
A Free workspace's stopped browsers unused for 30 days are deleted with their profiles (hourly, serviceTrash).
Banners
One banner above every /app page, the most urgent first: you're view-only (frozen), payment needed (unpaid), last payment failed, plan limit reached, approve your plan change, trial ends in N days (from 3 days), 80% used. The owner gets the button (Upgrade, Open billing, Approve); others are told to ask the owner.
Emails
Through the message queue (), to the workspace owner:
| Template | When |
|---|---|
billing.receipt | Every successful payment, with the invoice link |
billing.payment.failed | A renewal payment failed (critical, also WhatsApp) |
billing.trial.ending | 3 days before a trial ends |
billing.subscription.canceled | A plan was canceled (here or at PayPal) |
billing.limit.reached | 80% or 100% of a meter this month |
Admin › Billing
| Page | What you can do |
|---|---|
| Revenue | Per charge currency: MRR, ARR and paid in the last 30 days (net of refunds); paying, trials, canceled, churn and failed payments in 30 days; open disputes |
| Customers | Every customer (search by name or email); open one for its subscriptions (by workspace name), invoices (Mark paid after a bank transfer with its reference, Void) and payments (Refund: at the gateway, recorded when its webhook confirms; manual payments right away) |
| Subscriptions | Every subscription by status or workspace (name, slug or id), with its workspace (to its customer), plan, seats and add-ons; Grant a plan (no payment, until a date); Change a granted plan (plan, seats, add-ons, end) or a trial (end); Cancel now (PayPal stops charging too; the workspace is brought inside Free). A gateway's plan is changed by its owner in Settings › Billing, where the gateway approves it |
| Plans | Each plan's active prices; Set new price (replaces the active one for that interval and currency for new customers; subscriptions keep theirs); show or hide a plan on /pricing |
| Coupons | Create (percent or an amount in one currency; first payment, some months or forever; plans; max uses; expiry), turn on or off, see uses |
| Payment methods | On/off, sandbox or live, credentials (secrets never shown; blank keeps), name at checkout, currencies it charges, fee and who pays it, maintenance with a message, the webhook URL, Test connection, Sync plans |
| Webhook events | Every event a gateway sent, applied or failed; Retry applies a stored one again |
Prices, plans, coupons, payment methods, sync, granting and changing plans are for super administrators.
Configuration
| Key | Default | Meaning |
|---|---|---|
billing.currency.list | USD (2, en-US), IDR (0, id-ID) | Currencies we price in: decimals and formatting locale |
billing.currency.country | { ID: "IDR" } | Country → currency |
billing.currency.fallback | USD | When nothing else decides |
billing.rate.margin.percent | 2 | Added when charging another currency |
billing.seller | Mooflair | Printed on invoices |
billing.tax.enabled | false | Tax structure is in place, rates come later |
billing.trial | team, 14 days, 5 seats | The trial |
billing.dunning.day | 1, 3, 5, 7 | Retry days (PayPal's failure threshold) |
billing.invoice.prefix | MOO | Invoice numbers |
billing.gateway.paypal | off, sandbox | enabled, environment, credential, maintenance, fee { percent, fixed, payer }, currency[], plan |
Not built yet
The owner choosing who stays active, the PDF file (the weekly report is an email for now), rate pinning and the drift check, other abuse rules (payment fingerprint, disposable emails), hosted AI credits.
FAQ
Why are Rupiah prices shown when PayPal can't charge Rupiah?
Indonesian visitors see Rupiah; checkout shows the exact US dollar amount PayPal charges before they pay, fixed for every renewal.
How do I give a workspace a plan before payments are open?
As a super administrator, open Admin › Billing › Subscriptions › Grant a plan: search the workspace, pick the plan, seats, add-ons and the end date. It's active at once with no payment and goes back to Free on the end date; Change moves it to another plan, other seats, add-ons or another end. Only super administrators see these buttons.
Where do I put the PayPal keys?
In Admin › Billing › Payment methods (saved to library/configuration.json under billing.gateway.paypal), never in .env. Then Test connection and Sync plans, and add the webhook URL in PayPal for the events above.