User guide
Contracts & Recurring Billing
Looking for what it does rather than how to use it? Read the Contracts and recurring billing overview .
What it is
Contracts record the commercial agreement between an organization and whoever services it: term, value, billing cycle, payment terms, and whether the term renews on its own. Recurring billing schedules are the separate mechanism that raises a service order on a repeating date, once per period.
The two are related but not the same object. A contract can exist with no schedule attached, and a schedule can exist with no contract. Linking them is optional and is done from the schedule.
Concepts
| Object | What it is | Owned by |
|---|---|---|
| Contract | The agreement. Term, value, billing cycle, payment terms, auto-renew, notice period. | The client organization |
| Scheduled billing | A repeating charge. Cycle, billing day, amount in cents, time zone, optional end date. | The servicing MSP account |
| Scheduled billing run | One immutable record that a specific period was billed. Carries the period key and the order it produced. | System |
| Service order | What a billing run produces. Status APPROVED, flagged isRecurring. | The servicing MSP account |
Contract type
Five values, and only these five:
| Value | Meaning |
|---|---|
MSP | Managed services |
BLOCK_HOURS | Prepaid hours |
PROJECT | Fixed-scope project |
BREAK_FIX | Pay as you go |
RETAINER | Monthly retainer |
Contract status
DRAFT ──▶ PENDING ──▶ ACTIVE ──▶ CANCELLED
│ │ │
├────────────┴───────────┴──▶ CANCELLED
└──────────────────────────▶ ACTIVE
ACTIVE ──(term ends, autoRenew off)──▶ EXPIRED
ACTIVE ──(term ends, autoRenew on)───▶ ACTIVE, term rolled forward
CANCELLED and EXPIRED are terminal. A terminal contract refuses every edit, not only status changes — renaming or re-pricing one returns 409.
EXPIRED cannot be set by hand. It is assigned only by the contract-lifecycle job when a term actually runs out. Attempting to set it returns:
EXPIRED is set automatically when the contract term ends — it cannot be set by hand
Billing cycle
Ten values, shared by contracts and billing schedules: WEEKLY, BIWEEKLY, SEMI_MONTHLY, MONTHLY, QUARTERLY, SEMI_ANNUAL, ANNUAL, BIENNIAL, TRIENNIAL, ONE_TIME.
All nine recurring cycles contribute to MRR. ONE_TIME does not, by design.
Payment terms
DUE_ON_RECEIPT, NET_7, NET_15, NET_30, NET_45, NET_60, NET_90, CUSTOM. Optional on a contract; null means unspecified.
Scheduled billing status
ACTIVE ⇄ PAUSED
│ │
└────────┴──▶ CANCELLED (terminal)
│
└──▶ COMPLETED (terminal, set when the term ends)
A paused schedule does not bill — neither the sweep nor the manual trigger will run it. CANCELLED and COMPLETED refuse every edit.
Roles and permissions
| Action | platform administrator | MSP administrator | MSP technician | organization administrator | power user | CUSTOMER |
|---|---|---|---|---|---|---|
| View contracts | ● | ● | ● | ● | ● | ●¹ |
| Create / edit contract | ● | ● | ● | ● | — | — |
| Activate / cancel contract | ● | ● | ● | ● | — | — |
| View contract statistics | ● | ● | ● | ● | ● | ●¹ |
| View billing schedules (own org) | ● | ● | ● | ● | — | — |
| Create / edit / cancel a schedule | ● | ● | — | — | — | — |
| Trigger a billing run manually | ● | ● | — | — | — | — |
¹ Customer-portal callers are narrowed to their own organization’s rows.
Two things worth knowing, because both have bitten this codebase before:
- MSP technician ranks above organization administrator. A floor set at MSP technician therefore excludes every org admin. Contract writes are floored at organization administrator precisely so that org admins can manage their own agreements.
- Billing schedules stay at MSP administrator. Creating, running and cancelling a schedule is MSP revenue tooling. Organizations get a separate read-only view rather than a lowered floor.
Walkthroughs
Create and activate a contract (MSP)
- Go to MSP → Contracts (
/msp/contracts) and choose New Contract. - Pick the client. With a client already selected in the client switcher, the picker collapses to that client.
- Enter the name, type, start date, contract value and billing cycle. The value is the amount charged each billing period, not the whole-term total.
- Optionally set an end date, payment terms, a notice period and terms text. Tick Auto-renew to roll the term forward instead of letting it expire.
- Save. The contract is created as
DRAFT. - On the contract page, choose Activate. If the end date has already passed, the button is disabled and the page tells you to extend the term first — the server refuses the same transition.
Create and activate a contract (organization)
Identical, starting at Organization → Contracts (/org/contracts) → New Contract. The organization is your own; there is no client picker.
The New Contract button only appears if you are an organization administrator. A power user can see the list and the statistics but not the button.
Edit a contract
- Open the contract and choose Edit Contract (
/msp/contracts/{id}/editor/org/contracts/{id}/edit). - Change the name, value, billing cycle, end date, payment terms, notice period, auto-renew, description or terms.
- Type and start date are shown but disabled — the API does not accept changes to either.
- Save.
A cancelled or expired contract shows an explanation instead of the form.
Set up recurring billing
- Go to MSP → Billing → Scheduled (
/msp/billing/scheduled) and choose New Schedule. - Pick the client organization, the billing cycle, and the amount per period.
- Set the billing day. For monthly and longer cycles this is a day of month between 1 and 28 — the cap exists so every month can honour it. For weekly, pick a weekday. For semi-monthly, set both days.
- Set the billing time zone. This is the zone the billing day is evaluated in. Leave it blank to inherit the client’s configured business hours, which in turn falls back to UTC.
- Set a start date, and an end date if the arrangement is finite. Past the end date the schedule moves to
COMPLETEDrather than continuing to bill. - Save. The schedule starts
ACTIVEand its first billing date is computed in the zone you chose.
Bill a period manually
On the schedule page, choose Bill Now. This raises a service order for the current period and advances the schedule.
Running the same period twice is refused:
This schedule has already been billed for period 2026-07
That is a 409, not a duplicate order. See §6 for how the guarantee works.
Review upcoming charges (organization)
Go to Organization → Billing → Scheduled (/org/billing/scheduled). You see the recurring charges raised against your organization, what each costs, and when the next one falls due. The view is read-only: pausing, cancelling and triggering belong to the servicing MSP.
Automation
Four scheduled jobs touch this area. All times are UTC.
recurring-billing-run is opt-in, like every job in this platform that moves money. It raises real orders against real clients; arm it deliberately.
What the lifecycle job does each night
For every ACTIVE contract whose term has ended or is approaching:
- Renew — term ended and auto-renew is on: the term rolls forward by one billing cycle,
renewedAtis stamped,renewalCountincrements, and the organization’s admins are notified that the contract renewed. Renewal beats expiry: an auto-renewing contract never expires. - Expire — term ended and auto-renew is off, or the cycle has no next term (
ONE_TIME): the contract becomesEXPIRED. - Warn — the term ends within the contract’s own notice period and no warning has gone out yet: the organization’s admins and managers are notified once, and the contract is stamped so the next run does not re-notify.
The warning lead time is the contract’s noticePeriodDays, falling back to 30 days when unset. A contract with a 90-day notice period is warned 90 days out.
Notifications link to /org/contracts/{id} — recipients are the organization’s own admins.
How once-per-period billing is guaranteed
This is worth understanding, because it is the difference between a recurring billing system and a double-billing incident.
Each schedule, each period, has a period key derived from the billing date in the schedule’s billing time zone:
| Cycle | Key format | Example |
|---|---|---|
MONTHLY | YYYY-MM | 2026-07 |
QUARTERLY | YYYY-Qn | 2026-Q3 |
SEMI_ANNUAL | YYYY-Hn | 2026-H2 |
ANNUAL, BIENNIAL, TRIENNIAL | YYYY | 2026 |
WEEKLY | YYYY-Www | 2026-W30 |
BIWEEKLY, SEMI_MONTHLY | YYYY-MM-DD | 2026-07-15 |
ONE_TIME | once | once |
When a period is billed, the service order and a ScheduledBillingRun row claiming that period are written in the same database transaction. The run table carries a unique constraint on (scheduledBillingId, periodKey).
So a second attempt at the same period — a double click, a retried job, two workers racing — loses the insert, and the whole transaction rolls back with it. No duplicate order, and no orphaned order left behind either. The guarantee sits in the database, not in an application-level “have we billed this yet?” check that a race would walk straight through.
This is also why the job is safe to retry, and why re-running the sweep after a timeout bills nothing twice.
Why the time zone matters
Billing days are evaluated in billingTimezone, never in the server’s local time. On a UTC container, a schedule set to bill on the 1st would otherwise fire at 20:00 on the last day of the previous month in Toronto — putting the charge in the wrong period. Day-of-month is also clamped to the target month’s real length before the date is constructed, so a schedule asking for the 31st lands on 28 February rather than skipping the month.
Configuration
Contract fields
| Field | Effect | If unset |
|---|---|---|
value | Amount per billing period. Drives MRR/ARR. | Required |
billingCycle | Period length. Drives MRR normalisation and auto-renew term length. | MONTHLY |
endDate | Term end. Drives expiry, renewal and the expiry warning. | Open-ended; never expires and never warns |
autoRenew | Roll the term forward instead of expiring. | false — the contract expires |
noticePeriodDays | How far ahead of the end date the expiry warning is sent. Range 0–3650. | 30 days |
paymentTerms | Recorded on the contract. | Unspecified |
contactId | Customer-side signatory. Must be a member of the contract’s own organization. | No contact attached |
Schedule fields
| Field | Effect | If unset |
|---|---|---|
amountCents | Charged each period, in integer cents. | Required |
dayOfMonth | Billing day, 1–28. | The start date’s day |
secondDayOfMonth | Second billing day for SEMI_MONTHLY, 1–28. | 15 |
dayOfWeek | Billing weekday for WEEKLY, 0 (Sunday) – 6. | Seven days after the previous run |
billingTimezone | IANA zone the billing day is evaluated in. | The client’s business-hours zone, then UTC |
currency | ISO 4217, validated. | The account’s defaultCurrency (CAD by default) |
endDate | Past this date the schedule moves to COMPLETED. | Bills indefinitely |
Plan tiers
limits.maxContracts caps how many contracts an account may hold. It is the only plan gate in this area.
| Tier | Contracts |
|---|---|
| MSP Free | 0 |
| MSP Starter | 10 |
| MSP Growth | Unlimited |
| MSP Scale | Unlimited |
| MSP Enterprise | Unlimited |
| Customer Free | 0 |
| Customer Essentials | 0 |
| Customer Professional | 5 |
| Customer Business | Unlimited |
| Customer Enterprise | Unlimited |
Three things about how the cap behaves:
- Block-new-only. Existing contracts are never disabled, hidden or retroactively affected by a downgrade. Only creation above the cap is refused.
- Only live contracts count.
CANCELLEDandEXPIREDcontracts do not consume quota. An MSP on Starter that has cycled through ten finished agreements can still create an eleventh. - Platform admins are never blocked, and an account with no resolvable tier is treated as unlimited so legacy accounts keep working.
Over the cap, creation returns 403 with error.code === "PLAN_LIMIT" and a structured error.upgrade offer whose feature is maxContracts:
Your plan allows a maximum of 10 contract(s). Upgrade to add more.
Or, on a zero-contract tier:
Your plan does not include contracts. Upgrade to add contracts.
Recurring billing schedules carry no plan gate.
Troubleshooting
| Message | Cause | Fix |
|---|---|---|
Contract term has already ended — extend endDate before activating | Activating a contract whose end date is in the past. | Edit the contract and extend the end date first. |
EXPIRED is set automatically when the contract term ends — it cannot be set by hand | An update tried to set status EXPIRED. | Let the lifecycle job assign it, or cancel the contract instead. |
A cancelled contract is final and cannot be changed | Any edit to a terminal contract. | Create a replacement contract. |
Cannot change a draft contract to expired | An illegal status transition. | See the state machine in §2. |
Primary contact not found in the contract's organization | The chosen contact is not a member of the contract’s organization. | Pick someone who is a member, or add them to the organization first. |
Organization not found or access denied | The organization is outside your tenant scope. | Check the client selection. |
endDate must be on or after startDate | Term end precedes term start. | Correct the dates. |
Your plan allows a maximum of N contract(s). Upgrade to add more. | Account is at its maxContracts cap. | Cancel a live contract or upgrade — see §8. |
This schedule has already been billed for period 2026-07 | The period was already claimed by an earlier run. | Nothing to fix; the guard prevented a double bill. |
Cannot run billing for a paused schedule | Bill Now on a paused schedule. | Resume it first, if you intend it to bill. |
Billing schedule has passed its end date and is now complete | The period is past the schedule’s end date. | Extend the end date, or create a new schedule. |
Can only pause ACTIVE billing schedules | Pausing something already paused or terminal. | Check the current status. |
A cancelled billing schedule is final and cannot be changed | Any edit to a terminal schedule. | Create a new schedule. |
billingTimezone must be a valid IANA time zone | Zone string is not recognised. | Use a real IANA name, e.g. America/Toronto. |
currency must be a 3-letter ISO 4217 code | Currency was free text. | Use e.g. CAD, USD. |
Limits and known behaviour
Stated plainly, because each of these is a real boundary today.
- Details worth knowing:
- The invoice is raised as
DRAFTon the order’s own payment terms (DUE_ON_RECEIPT/NET_15/NET_30/NET_45/NET_60), in the order’s own currency, with a per-tenant invoice number. - The order’s own subtotal, tax and total are used verbatim. Tax is not recomputed at invoicing time, so the invoice cannot disagree with what the client was quoted when the period was billed.
- A CA client organization with no resolvable province is held, not invoiced at a guessed $0 tax. The order stays un-invoiced and is picked up once the address is corrected.
- Rolling the period back would re-bill the client on the next sweep.
- The nightly
msp-order-invoicingjob is separate and still invoices MSP store orders, which are a different record type.
- The invoice is raised as
- Subscriptions have no API. A contract’s
subscriptionsarray is always empty andsubscriptionCountis always0. Nothing in the platform can create a contract subscription. - No contract e-signature. The platform integrates BoldSign for document signing, but
Contractis not a signable document type.PENDINGis not a signature state — nothing sets it to await one. - Contract type and start date are immutable after creation.
- Renewal produces no billing event. Auto-renew rolls the term forward, stamps the renewal and notifies the organization. It does not raise a charge, and it does not advance any attached billing schedule.
- No proration. Changing a contract’s value or a schedule’s amount mid-term takes effect from the next period. Nothing is prorated, and cancellation issues no automatic refund or credit.
- They are unreachable from a browser.
- MRR excludes
ONE_TIMEcontracts, by design. The other nine cycles are normalised to a monthly figure.
Verification
This guide was written against the product, not from memory.