Changing Billing Behaviour

4 views Markdown

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.

StepWhat happensYour seam
DiscoveryA scheduled task finds services whose renewal date has arrivedA gate that can skip one target with a reason
PricingThe unit price, quantity, tax exemption and discounts are resolvedA filter over the whole price result
InvoiceAn unpaid invoice and its items are written, stamped with the period being renewedFilters over the payload, the item description and the totals
PaymentThe item event advances the due date and writes the cycle back to the serviceAn 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

  1. 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.
  2. Check whether the point you picked fires for every path. The renewal price filter does; a filter on an operator screen does not.
  3. Confirm the direction. A by-reference filter expects mutation in place and ignores what you return.
  4. 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

  1. 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.
  2. Modify the unit amount, not the total. Quantity, taxes and discounts are applied around your value, so multiplying the wrong field double-counts.
  3. 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.
  4. Verify against a real renewal, not a hand calculation: inclusive tax is stripped after your filter runs.

Skip or Redirect a Renewal

  1. 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.
  2. To stop renewals permanently, set the skip flag on the service. The discovery step reads it, so a flagged service is never dispatched.
  3. 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.
  4. 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

  1. 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.
  2. For "an invoice changed hands", listen on the status change action, which gives you the reloaded invoice plus the previous status.
  3. Do not use the invoice creation action as a proxy for payment. An unpaid invoice may never be paid at all.
  4. 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

coremio/helpers/invoices.php
// 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;
Invoices::process_renewal() Every renewal path in the product goes through it: the scheduled task, the operator button, the customer's own renew action and the API. Extending renewal behaviour here reaches all four at once.
opts.period and opts.period_time Bill a term other than the service's own. Accepted values are hour, day, week, month and year. Passing the service's own cycle is a no-op; a different one switches pricing to live and marks the invoice so the stored cycle is preserved on payment.
opts.duedate Overrides the period being renewed, which is what the catch-up path uses to invoice an older period that was missed.
opts.source, opts.is_catchup Provenance stamped onto the work, defaulting to the scheduled run. Set them when you drive a renewal from your own code so the record says where it came from.
opts.run_hook, opts.dispatch_notify Both default to true. Set them false when you are producing an invoice as part of a larger flow that will announce and notify once at the end, rather than once per target.
Invoices::calculate_renewal_amount() Reads the term off the target array it is given, so pricing a different term means handing it a copy of the row with the period changed. There is no separate method for that.
Invoices::recalculate_totals() Pass false as the second argument to compute without writing, which is how a report gets today's figure without touching the document.
The return value Always an array. 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

filter:invoice.renewal_amount By reference, over the whole price result: 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.
filter:invoice.renewal_description The line's text, by reference, after the localised period range has been built and before the item is added. Context is the target type and the target row. Change the string in place; the return is ignored.
filter:invoice.create_payload The insert data of every invoice in the product, by reference, before the row is written: checkout, renewal, upgrade, metered and API. The widest seam here, and the easiest to make too wide. Change the payload in place; the return is ignored.
filter:invoice.totals The computed 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.
filter:invoice.late_fee_amount The computed fee, by reference, with the invoice and the fee cycle as context. The value is rounded again after you, and a result at or below zero skips the fee entirely. Change the fee in place; the return is ignored.
filter:order.cart_totals The other half of the money surface: the cart summary, by reference, with the priced items, the subtotal, the applied coupons and the tax context. It feeds both the preview the visitor sees and the order that is actually persisted, so a listener here has to be deterministic. Change the summary in place; the return is ignored.

Decision and Event Seams

gate:invoice.create Fires in the renewal task before the entry point is called, with the target type, the target id and the due date. Returning a non-empty string refuses: the job reports cancelled and your string is the recorded reason. Returning empty or null continues.
gate:invoice.client_pay Refuses a customer payment attempt. Note the arguments: the customer id is the invoice's owner, and a share-link payment has no identified payer at all, which the fourth argument tells you. Returning a non-empty string blocks the payment; no commission is booked and no balance is taken. Empty or null continues.
action:service.renewed The real "it was extended" signal, fired after the new due date is written. Arguments: the service id, the snapshot from before the extension, the new due date and the old one. Do not confuse it with the recurring-limit announcement, which fires while the invoice is being produced and before any payment. The return is ignored.
action:invoice.status_changed Fired at the end of the status transition, once the write, the revenue record and the history entry are done. Arguments: the reloaded invoice, the new status, the previous status and the transition options. The return is ignored.
action:invoice.created An invoice document now exists. It says nothing about money having moved, and an integration that treats it as a sale will book revenue that may never arrive. The return is ignored.
action:invoice.renewal_generated The narrower twin of the row above, for renewals specifically. Useful when you want to hear about recurring billing without also hearing about every checkout. The return is ignored.

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 stateNew due dateWhy
Active, not yet overdueThe item's period end, whatever the setting saysPaying early must never cost the customer days
Active but overdueFollows the operator's settingThe operator decides whether lateness is forgiven
SuspendedFollows the operator's settingSame decision, same switch
Anything elseNow plus the periodThe old cycle is no longer meaningful
An older unpaid renewal existsForced to the item's period endPaying a backlog must not skip the months it covers
Hourly cycleForced to now plus the periodAn 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

coremio/helpers/Metrics.php
// 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;
Metrics::calculate() The whole pricing engine for usage, in one call. The customer surface uses it live for an estimate and the closing task uses it for the real figure, which is why the two agree.
Where the configuration lives On the service row as a JSON snapshot, one entry per metric key, not on the product. Both the scheduled tasks and the customer surfaces read that snapshot, so changing a product's metric definition does not retroactively change what an existing service is billed for.
Turning a metric off does not cancel its bill Usage recorded while it was on is billed at the end of the period regardless. Only collection stops. The closing task scans services that have usage as well as services that have the metric enabled.
The unpaid lock is service-wide While any metric on a service has a billed, unpaid invoice, no metric on that service can be switched on. Switching off stays allowed, and nothing re-enables automatically once the invoice is paid.
Overdue usage disables itself A metric whose usage invoice is unpaid past its due date is switched off automatically, one day after the date, and the service is suspended two days later by a separate task. The automatic switch-off stamps a reason onto the service so the customer can see why.
One invoice or a sub-item A closed period becomes its own invoice when the service's due date is more than about a month away, and otherwise waits and rides on the next renewal invoice as a sub-item. That is why a yearly service gets monthly usage invoices while a monthly service gets one combined document.
Cancelling the invoice rolls the period back Deleting, refunding or cancelling a usage invoice returns its billing row to the pending state with no invoice attached, and the chain re-bills it on the next pass. Do not clean up those rows by hand.

Example

A loyalty discount on renewals, a skip rule, and the payment side that tells an external system.

coremio/hooks/acme-billing.php
// 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.

renewing from your own code
$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.

reading the result back
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

Issuing an invoice is not extending a service

The renewal entry point writes an unpaid invoice and stops. Treating invoice creation as the renewal event extends services nobody paid for.

A frozen price is only frozen for its own cycle

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 widest seams fire more often than you expect

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 skip is a success

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.

Usage is averaged, and its tiers are per currency

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.

Was this helpful?

Thanks for your feedback!

Still Need Help?

Our support team is here around the clock for anything you can't find above.