Changing Billing Behaviour
Adjust what gets invoiced, for how much and when, using the seams the billing engine exposes instead of editing it.
Overview
Billing is a chain: renewal falls due, price is worked out, unpaid invoice is written. Only payment moves the service forward. Each link has its own extension point. The wrong link gives a correct total with a due date that never advances.
One rule holds it together: issuing an invoice never extends anything. The due date moves on payment, in a separate handler, from the period stamped onto the invoice item.
Prerequisites
- Comfort with hook listeners: almost every seam here is one. Know the by-reference contract too. Most of these filters hand you the value to mutate rather than to return.
- A test service you can actually renew. Locked and live pricing differ only on a real row.
- A clear answer to "which link am I changing". The choices: the decision to bill, the amount, the invoice document, or payment.
Structure
The recurring chain and its seams.
| Step | What happens | Your seam |
|---|---|---|
| Discovery | A scheduled task finds services whose renewal date has arrived | A gate that can skip one target with a reason |
| Pricing | The unit price, quantity, tax exemption and discounts are resolved | A filter over the whole price result |
| Invoice | An unpaid invoice and its items are written, stamped with the period being renewed | Filters over the payload, the item description and the totals |
| Payment | The item event advances the due date and writes the cycle back to the service | An action fired after the extension |
Metered billing is a second chain feeding the first. Usage is collected hourly; a closed month becomes its own invoice or a sub-item on the next renewal invoice.
Walkthrough
Choose the Seam
- Write the sentence describing your change and find the noun. "Do not bill this customer" is discovery. "Charge 10 percent less" is pricing. "Add a line" is the invoice. "Tell our system it renewed" is payment.
- Check whether the point you picked fires for every path. The renewal price filter does; a filter on an operator screen does not.
- Confirm the direction. A by-reference filter expects mutation in place and ignores what you return.
- If nothing fits, do not edit the helper. Ask for a generic hook at the point you need and put your own condition in your listener.
Change What Something Costs
- Listen on the renewal amount filter. You receive the resolved result array by reference, plus the target type, the target row and the customer's billing data.
- Modify the unit amount, not the total. Quantity, taxes and discounts are applied around your value, so multiplying the wrong field double-counts.
- Respect the pricing source already in the result. It says whether the number came from a frozen price on the row or from the product's live price. A discount that suits one is often wrong for the other.
- Verify against a real renewal, not a hand calculation: inclusive tax is stripped after your filter runs.
Skip or Redirect a Renewal
- To stop one renewal, return a non-empty reason string from the creation gate. The task reports itself as cancelled with your string as the reason.
- To stop renewals permanently, set the skip flag on the service. The discovery step reads it, so a flagged service is never dispatched.
- To bill a term other than the service's own cycle, pass the period through the renewal entry point. Leave the service row alone. The engine prices the requested term live and stamps the invoice. Payment then extends by that term, and the stored cycle stays put.
- Never advance the due date yourself. The service would be extended without a paid invoice behind it, and the next run would invoice it again.
React to the Money
- For "the service was extended", listen on the renewal action. It fires after the new due date is written and hands you the snapshot from before the extension plus both dates.
- For "an invoice changed hands", listen on the status change action, which gives you the reloaded invoice plus the previous status.
- Do not use the invoice creation action as a proxy for payment. An unpaid invoice may never be paid at all.
- Make the listener idempotent. An operator can set a status again, and a payment can be recorded twice through different routes.
Reference
The Entry Points
// THE single entry point for renewing. $target_type is 'service' or 'addon'.
// It writes an UNPAID invoice and returns; it does not touch the due date.
public static function process_renewal(string $target_type, int $target_id, array $opts = []): array;
// Called by the above once the target and the customer are resolved.
public static function generate_renewal(string $target_type, array $target, array $user_data, array $opts = []): int|false;
// The price. Reads period and period_time OFF $target, which is why pricing a
// different term is a matter of handing it a modified target rather than a new method.
public static function calculate_renewal_amount(string $target_type, array $target, array $user_data): array;
// The de-duplication query behind 'already-invoiced'. The period is part of the key.
public static function has_renewal(string $target_type, int $target_id, string $duedate, string $period = ''): bool;
// The single INSERT point for every invoice in the product.
public static function create(array $data): int;
public static function add_item(array $data): int;
// Recomputes subtotal, taxes, commission and total. $persist = false to preview.
public static function recalculate_totals(int $invoiceId, bool $persist = true): array|false;
// The status transition, with all of its side effects.
public static function change_status(int $invoiceId, string $status, array $options = []): bool;
false as the second argument to compute without writing, which is how a report gets today's figure without touching the document.
success plus invoice_id, which is null when nothing was billed, and reason carrying why: already-invoiced, skip-renewal-invoice-flag, recurring-cycles-limit-reached, invalid-period and their siblings. A skip is a success, so branch on the invoice id.
Pricing and Document Seams
amount (the unit price), quantity, currency, taxexempt, additional_taxes, discounts, pricing_source and period_time. Context: the target type, the target row and the customer's billing data. Fires after discounts are resolved and before inclusive tax is stripped. Change the array in place; the return is ignored.
subtotal, tax, additional_tax, pmethod_commission, total and discounts, by reference, before they are stored. The invoice row and the items come along read-only. Fires on every recalculation, which includes adding an item and recording a payment. Change the totals in place; the return is ignored.
Decision and Event Seams
How the Due Date Actually Moves
Two candidate dates are produced; the choice depends on the state of the service, not only on the operator's setting.
| Service state | New due date | Why |
|---|---|---|
| Active, not yet overdue | The item's period end, whatever the setting says | Paying early must never cost the customer days |
| Active but overdue | Follows the operator's setting | The operator decides whether lateness is forgiven |
| Suspended | Follows the operator's setting | Same decision, same switch |
| Anything else | Now plus the period | The old cycle is no longer meaningful |
| An older unpaid renewal exists | Forced to the item's period end | Paying a backlog must not skip the months it covers |
| Hourly cycle | Forced to now plus the period | An hourly period end is already in the past by the time it is paid |
The result is then clamped so it can never move backwards. When the invoice was produced for a term other than the service's own, payment advances the date by that term. The stored cycle, amount and currency stay untouched.
Usage-Based Billing
// The price of a period's overage. $scheme is 'per_unit', 'volume' or 'graduated';
// $pricing is the tier map; $ccode is the CURRENCY CODE, because tiers are priced
// per currency rather than converted.
public static function calculate(string $scheme, float $billable, array $pricing, string $ccode): float;
// Usage minus the included allowance, floored at zero.
public static function get_billable(float $usage, float $included): float;
// The month's figure from the hourly snapshots. Metered periods are billed on the
// average, not on the last reading.
public static function monthly_average(array $snapshots): float;
Example
A loyalty discount on renewals, a skip rule, and the payment side that tells an external system.
// 1. PRICE. Everything arrives by reference; change the UNIT amount, because
// quantity, taxes and discounts are applied around it.
Hook::add('filter:invoice.renewal_amount', 20, function (&$result, $target_type, $target, $user_data) {
if ($target_type !== 'service') return;
$uid = (int) ($target['owner_id'] ?? 0);
if ($uid <= 0) return;
$years = (int) (User::getInfo($uid, ['acme_loyalty_years'])['acme_loyalty_years'] ?? 0);
if ($years < 3) return;
// A frozen price was agreed with this customer; discounting it again would
// break an agreement the operator made deliberately.
if (($result['pricing_source'] ?? '') !== 'live') return;
$result['amount'] = round((float) ($result['amount'] ?? 0) * 0.9, 4);
// The discount list is what the invoice shows the customer. Leaving it out
// produces a cheaper invoice with no explanation on it.
$result['discounts']['acme_loyalty'] = [
'label' => Language::gc('admin/invoices/acme-loyalty-label'),
'rate' => 10,
];
});
// 2. DECIDE. A non-empty string refuses; the job records it as the reason.
Hook::add('gate:invoice.create', 10, function ($target_type, $target_id, $duedate) {
if ($target_type !== 'service') return '';
$service = Services::get((int) $target_id, 'id,options');
$opts = $service['options'] ?? [];
// A service under migration should not be invoiced until it lands.
if ((int) ($opts['acme_migrating'] ?? 0) === 1) return 'acme-migration-in-progress';
return '';
});
// 3. REACT. Fired AFTER the due date is written, so this is the extension signal.
// $service is the snapshot from BEFORE the move: read old values from it.
Hook::add('action:service.renewed', 30, function ($serviceId, $service, $newDuedate, $oldDuedate) {
Utility::HttpRequest([
'url' => 'https://crm.example.com/renewals',
'type' => 'POST',
'data' => [
'service' => (int) $serviceId,
'from' => (string) $oldDuedate,
'to' => (string) $newDuedate,
'cycle' => (string) ($service['period'] ?? ''),
],
]);
});
Driving a renewal yourself, for a term the customer chose rather than the one stored on the service.
$result = Invoices::process_renewal('service', 5001, [
// Bill two years even though the service is stored as monthly. Pricing switches
// to live for this one invoice, and payment will extend by two years while the
// stored monthly cycle and its frozen amount stay exactly as they are.
'period' => 'year',
'period_time' => 2,
'source' => 'acme-portal',
]);
// invoice_id is null on a skip, and a skip is NOT a failure: branch on the id.
if (($result['invoice_id'] ?? null) === null) {
// already-invoiced | skip-renewal-invoice-flag | recurring-cycles-limit-reached
// | invalid-period | service-not-found | user-data-unavailable | ...
throw new Exception('Renewal not issued: ' . (string) ($result['reason'] ?? 'unknown'));
}
// The invoice exists and is UNPAID. Nothing about the service has changed yet.
$invoiceId = (int) $result['invoice_id'];
The reading side of the same money, for a report or a reconciliation job. The check is on the status transition, not on the existence of a document.
Hook::add('action:invoice.status_changed', 10, function ($invoice, $status, $old_status, $options) {
// Only the transition INTO paid is a sale; a repeat write is not.
if ($status !== 'paid' || $old_status === 'paid') return;
$id = (int) ($invoice['id'] ?? 0);
// Recompute rather than trusting a stored figure: an operator may have edited
// the items after the invoice was issued. false = preview, nothing persisted.
$totals = Invoices::recalculate_totals($id, false);
AcmeLedger::record([
'invoice' => $id,
'customer' => (int) ($invoice['user_id'] ?? 0),
'currency' => (string) ($invoice['currency'] ?? ''),
'net' => (float) ($totals['subtotal'] ?? 0),
'tax' => (float) ($totals['tax'] ?? 0),
'gross' => (float) ($totals['total'] ?? 0),
'method' => (string) ($options['pmethod'] ?? ''),
]);
});
Pitfalls
The renewal entry point writes an unpaid invoice and stops. Treating invoice creation as the renewal event extends services nobody paid for.
A locked price belongs to the cycle it was agreed for. Applying it to another term charges a monthly amount for a year. Pricing another term means going live for that call, which the renewal entry point does when you pass a period.
The invoice payload filter sees every invoice, including checkout, upgrades and usage; the totals filter fires on every recalculation. A listener without a narrow condition adds its line repeatedly.
A declined call still returns success, with a null invoice id and a reason. The reasons: already invoiced, skip flag set, recurring limit reached. Branch on the invoice id, not on success.
A closed metered period is priced from the average of the hourly snapshots, not the final reading. The tier table is keyed by currency code, so a currency with no entry prices at nothing.
Related Articles
Thanks for your feedback!
Our support team is here around the clock for anything you can't find above.