← All posts

Engineering · 2026.09.30 · 4 min · Updated 2026.10.26

SaaS Billing After Performance Fees: Plan Limits, Promotions, and Suspension in a Flat Subscription

My auto-lending SaaS moved from performance fees to subscriptions. Plan limits shipped before billing, promo bonuses freeze onto the invoice, the 30-day notice only gates passive charges, and suspension never touches users' lending toggles.

Reading
Theme
Size
Width

However much extra the bot earns you, it takes a cut: that's the idea behind a performance fee. My Bitfinex auto-lending SaaS replaced that model with a fixed-price subscription.

A few design decisions shaped the subscription billing. Plan limits shipped before billing. Bonus months from promotions are frozen onto the invoice when it's issued, so later rule changes never rewrite entitlements that already exist.

The "30 days' notice before charging" rule only gates charge paths the user didn't actively start. Suspension is handled by the engine excluding the user, not by touching the user's own lending toggle.

Prices and plan details aren't covered in this post.

The billing problem with performance fees

The idea is intuitive, and I first built a shadow ledger that measured the fee without charging it.

The problem is that a performance fee requires precisely measuring the bot's contribution, unrealized earnings and the amounts actually credited. The billing semantics get complicated, and any small error on the books changes what gets charged.

Switching to subscriptions wasn't just a matter of changing the price either. Plan limits, an invoice state machine, payment address monitoring, expiry reminders, admin corrections and user notifications all had to exist.

Plan limits first, then billing

The subscription data model and plan limits shipped first, and billing started afterward, to avoid collecting money first and adding the limits later.

The plan limits got another fix after that.

A single user can have multiple lending configs, and each one used to check the limit on its own, which effectively multiplied the limit. Now multiple configs in the same cycle share one quota.

Illustration: before, each of a user's lending configs checked the plan limit on its own, so together they multiplied the limit; now configs in the same cycle share one quota and together stay within the plan limit. Illustrative; bar lengths are not actual quotas.

Payments go through invoices plus a movements watcher (movements are a Bitfinex account's deposit and withdrawal history). The watcher talks directly to Bitfinex's real data, so receiving addresses, deposit events and invoice status can be reconciled against each other.

Building this part myself means one less external payment service to depend on. The cost is that reconciliation is my responsibility.

The payment page, notifications and admin actions all shipped together, so users and operations see the same set of states.

Promotions: bonuses frozen on the invoice

Annual-plan promotions are built as a data model with rules, not hardcoded into frontend copy.

The most important decision is that bonus months are frozen into the invoice's period_end at the moment it's issued.

However the campaign rules change later, they never go back and rewrite entitlements on invoices that already exist.

Illustration: when an invoice is issued, the plan period plus promotional bonus months is written into its period_end; when campaign rules change later, the period_end of existing invoices is not rewritten. Illustrative; lengths are not actual plan or bonus durations.

A few other rules:

  • A user who qualifies for multiple campaigns can stack them, but each person can redeem a given campaign only once.
  • Campaigns with redemptions can't be deleted, which preserves the audit trail for accounting.
  • When an overdue invoice is voided, the campaign redemptions it used are released too.
  • Campaigns only apply to checkouts the user starts, not to auto-renewals, so long-term billing rules don't get tied to an old campaign.

Permissions are separate too. Managing campaigns requires the permission to manage fees, while read-only access only needs the permission to view users. Operational lookups and price control stay apart.

The 30-day notice gates passive charges only

Billing must be announced 30 days before it starts. This gate covers the paths the user didn't actively take part in: automatically issued renewal invoices, and trials expiring.

Checkouts the user starts are deliberately exempt from this gate.

An active purchase isn't a passive start to billing.

Suspension leaves the user's toggle alone

Suspension used to simply turn off the user's lending toggle.

The problem is that this mixed the user's intent and their billing eligibility into the same flag. A suspended user could just turn it back on and keep lending.

Now the engine excludes suspended users when selecting configs, and the lending toggle isn't touched at all. Once payment is restored, any pause the user set themselves is still in place.

Before and after flow: before, the user's pause and resume and the suspension all wrote the same lending toggle, so a suspended user could turn it back on and keep lending; now the toggle only holds the user's intent, suspension lives in billing state, the engine skips suspended users when picking configs, and a pause the user set is still in place after payment is restored.

Store user intent and system billing state separately. A system action like suspension shouldn't change values the user set.

#System Design#Subscriptions#SaaS

Letters

Loading comments…

Next

Engineering

Code Review After Porting Java to Rust: 15 Findings, 6 Bugs Carried Over From Java

Four lessons from a full code review after a Java-to-Rust port: transaction semantics copied from a misleading annotation, sqlx returning Ok on failed commits, disconnects killing handlers, and suspension sharing the lending flag.