Skip to content

Invoicing ​

An invoice is a document made of line items. A line item can reference a Charge (the record of money owed), or stand alone as a manual line. Meteric creates charges and invoices for you (subscriptions and usage accrue charges; a billing run invoices them), and you can create either by hand: a Charge directly, or an invoice line by line.

Creating invoices ​

Two ways to get an invoice: let the billing run make it, or build one yourself.

Automatically (the billing run) ​

The scheduled meteric:run tick invoices an account's pending charges after renewing its subscriptions, so recurring billing needs no manual call.

To bill a one-off this way, add a custom charge to the account. It sits pending, and the account's next invoice (next run) includes it:

php
use Brick\Money\Money;
use Meteric\Facades\Meteric;

Meteric::charge($account, Money::ofMinor(5000, 'EUR'), 'Setup fee', group: 'Services');
// pending; the account's next invoice bills it.

charge(BillingAccount $account, Money $amount, string $title, ?string $group = null, ?string $description = null, LineKind $kind = LineKind::OneOff): Charge creates the charge in pending. Off-cycle, Meteric::invoicePending($account) issues an account's pending charges immediately.

Manually (build it now) ​

For a standalone invoice you control, open an empty draft, add lines, then finalize:

php
$draft = Meteric::createInvoice($account);
$line  = Meteric::addLine($draft, 'Consulting', Money::ofMinor(50000, 'EUR'), 'October');
Meteric::addSubLine($line, 'Travel', Money::ofMinor(15000, 'EUR'));
$invoice = Meteric::finalizeInvoice($draft);

createInvoice(BillingAccount $account, ?string $currency = null): Invoice opens a draft with no charges, lines, or totals. Each edit recomputes the totals. See Editing a draft for the line-method signatures.

To start from the account's pending charges but review or adjust before sending, Meteric::draftInvoice($account) builds a draft from pending charges. Edit it, then finalizeInvoice.

Copy and re-issue ​

copyInvoice clones an invoice's header and lines, including the parent_id sub-line hierarchy, into a fresh draft. Each cloned line keeps its charge_id, so no charge is duplicated and no charge state changes:

php
$copy = Meteric::copyInvoice($source);
Meteric::voidInvoice($source);
$fixed = Meteric::finalizeInvoice($copy);

That is the re-issue flow for a wrong document with right charges (a wrong billing address, say). Because the copy's lines reference the same charges, voiding the source leaves those charges invoiced: they still have a live line on the copy.

Lifecycle ​

An invoice moves draft -> open -> paid or void. A draft is editable. An open invoice is immutable, frozen by database triggers, so corrections go through a void or a credit note, not an in-place edit.

The trigger freezes the currency, the totals, the tax amount and the tax profile of any invoice that has left draft, refuses to delete one, and refuses to move one that has payments against it to void. Two more facts about an issued document are frozen with them:

  • it does not become a draft again. A draft is what has not been issued yet, so putting an issued invoice back into that state takes it out of every figure computed over issued documents while its number and its issue date stay on the row. Nothing un-issues a document; a wrong one is voided or credited.
  • issued_at does not move. It is the tax point, so what it says decides which period the document belongs to. Moving it reassigns the supply to another period and leaves every total over the year unchanged, which is the one alteration to an issued document that is invisible in aggregate.

The collection lifecycle itself stays the caller's: the trigger does not police open -> partially_paid -> paid or a write-off to uncollectible. The return to open when a payment is reversed or charged back is reversePayment().

TRUNCATE is refused on invoices and invoice_lines at statement level, because a FOR EACH ROW trigger does not fire on one: Postgres empties the table without visiting the rows, so one statement removed every issued document and the branch refusing a delete never ran. A statement with nothing to remove is allowed, so a fresh install and a reference-data seeder whose CASCADE reaches these tables still work. REVOKE TRUNCATE from the role the application connects as is the other half of that, and it is the deployment's to run.

What the invoice records about the buyer ​

An issued invoice carries tax_profile, the account's tax profile as it stood when its lines were priced. Read it with $invoice->taxProfile() or $invoice->taxContext() rather than reaching through to the account, which moves with the customer. See Tax.

Adjusting an invoice the engine raises ​

Meteric::createInvoice() and draftInvoice() hand you a draft to edit, but the invoices that matter most are the ones nobody asked for: meteric:run calls invoicePending() from inside the package, so there is no draft to catch and no return value to edit before the document exists. InvoiceDraftAdjuster is the window on those.

php
use Illuminate\Support\Collection;
use Meteric\Contracts\InvoiceDraftAdjuster;
use Meteric\Facades\Meteric;
use Meteric\Models\BillingAccount;

class GoodwillAdjuster implements InvoiceDraftAdjuster
{
    public function adjust(BillingAccount $account, string $currency, Collection $charges): iterable
    {
        $owed = (int) $charges->sum('amount_minor');
        $goodwill = min($this->balanceOf($account), $owed);

        return $goodwill > 0
            ? [Meteric::charge($account, Money::ofMinor(-$goodwill, $currency), 'Goodwill')]
            : [];
    }
}

Bind it over the default, which is nothing at all:

php
$this->app->singleton(InvoiceDraftAdjuster::class, GoodwillAdjuster::class);

It runs on every invoice issue() raises, which is invoicePending(), invoiceAllPending(), invoiceConsolidated() and the per-subscription split, and therefore on the scheduled billing run. It does not run on a draft you opened yourself: there you already hold the invoice and can add lines directly.

It returns charges, not lines. A charge is the unit the engine bills, prices and taxes; a line is what a charge became. Returning a charge means the addition is composed, taxed and grouped like every other one, it counts toward the invoice's totals and its idempotency key, and voiding the invoice puts it back in the pending pool like the rest.

It runs inside the caller's transaction, so a driver that refuses the document takes the adjustment back with it, and so does whatever the adjuster wrote about its own state. An adjuster may draw a balance down here without having to undo the draw by hand.

Every charge it returns is read back from the database and checked. It must be saved, pending, on this account and in this currency; anything else raises a LogicException rather than being billed, because an invoice is one account's claim in one currency. The read-back is also why an adjuster may hand over the model it just created: a column with a database default is null on the instance that wrote it.

What it adds is weighed by the net-credit guard. An adjustment larger than what is being billed holds the invoice rather than issuing a negative one, and the charges all stay pending, exactly as an account whose pending credits outweigh its charges does.

Editing a draft ​

Add and remove lines on a draft directly. All three methods require a draft and throw otherwise. They edit the lines in place, so they never rebuild from charges and never wipe a manual line.

php
use Brick\Money\Money;

$line = Meteric::addLine($draft, 'Consulting', Money::ofMinor(50000, 'EUR'), 'October');
Meteric::addSubLine($line, 'Travel', Money::ofMinor(15000, 'EUR'));
Meteric::removeLine($line);   // cascades its sub-lines
  • addLine(Invoice $invoice, string $title, Money $amount, ?string $description = null, ?string $group = null, LineKind $kind = LineKind::OneOff): InvoiceLine adds a top-level line with no charge behind it. Tax resolves from the account's context.
  • addSubLine(InvoiceLine $parent, string $title, Money $amount, ?string $description = null, LineKind $kind = LineKind::Option): InvoiceLine nests a child under an existing line. The child counts toward the totals on its own.
  • removeLine(InvoiceLine $line): void deletes a line and cascades its sub-lines. If the line was a charge's last live line, the charge returns to pending.

Lines somebody typed ​

addLine() takes a finished amount, which is what an automatic charge has. A person writing an invoice by hand states a quantity, a unit price and sometimes a discount, and the amount falls out of those. addManualLine() takes those instead and prices the line here, so there is one implementation of what a line costs:

php
use Brick\Money\Money;
use Meteric\Invoicing\ManualLine;

Meteric::addManualLine($draft, new ManualLine(
    title: 'Consulting',
    description: 'October',
    quantity: 3.0,
    unit: 'hour',
    unitPrice: Money::of('80.00', 'EUR'),
    discountPercent: 10.0,
));

Meteric::addManualLine($draft, ManualLine::text('Everything below is covered by the retainer.'));
  • The multiplication is rounded once, at the line total, in decimal. Rounding the unit price first and multiplying after makes a ten-of-something line disagree with the unit price printed beside it by a cent. unit_minor is the unit price brought back to the currency's own minor unit for display; the total is computed from the unrounded figure.

  • priceIsGross: true reads the unit price as tax inclusive. It is converted to net once, using the rate this invoice's own tax context resolves to, so the same intent typed either way produces the same document. A zero-rated document divides by one and the two are identical.

  • discountPercent is per cent off that line, applied before rounding, and kept on the line's metadata as discount_percent so a document can print it.

  • ManualLine::text() writes a line carrying words and no money, stored with LineKind::Text, a zero amount and no tax. It enters no total; it exists so a document reads as it was written.

  • taxCategory sells the line under a product tax class, as meteric_tax_rates.category names it, so one invoice can carry a reduced-rate line beside a standard one:

    php
    Meteric::addManualLine($draft, new ManualLine(
        title: 'Handbuch',
        unitPrice: Money::of('20.00', 'EUR'),
        taxCategory: 'reduced',
    ));

    It selects which of the destination's rate rows applies and cannot change the document's treatment: a resolver settles reverse charge and out-of-scope before it looks a rate up, so a reverse-charged invoice stays reverse-charged whatever category its lines name. An unknown category falls back to standard rather than to no tax, so a typo overcharges rather than undercharges. Null is standard, which is what every line meant before this existed.

    database and ibericode honour it; flat and null do not, because neither has more than one rate to choose between. A suite pinned to the flat driver will therefore see one rate whatever category it sets, which is worth knowing before writing a test that appears to disprove this.

ManualLine::netTotal(float $rate) and netUnitPrice(float $rate) are public, so a screen that shows a total before the line exists shows the total the line will have. A caller that resolves the rate itself and calls these gets the engine's arithmetic without persisting a draft; it must not multiply on its own.

Finalize the draft with:

php
$invoice = Meteric::finalizeInvoice($draft);

finalizeInvoice(Invoice $draft): Invoice requires a draft. It sends the draft's current lines through the driver, sets the due date from meteric.invoice.net_days, flips the invoice to open, and fires InvoiceIssued. recordPayment and markOverdue apply from here. A driver failure leaves the draft untouched.

Line order ​

$invoice->lines comes back ordered by sort, then by id. So does $line->children. A reader never adds an orderBy of its own, and a document rendered twice lists its lines the same way both times.

sort is written by whoever writes the line: LineComposer numbers the positions 100 apart and their sub-lines from the position's own number, and addLine() takes the next number after the highest. It carries no unique constraint, so two writers can reach the same value; id decides those, and because the key is an ordered UUID that is the order the rows were written.

Ordering a relation has one consequence for a caller that aggregates over it:

php
// Postgres rejects an ORDER BY on a column a DISTINCT select list omits.
$invoice->lines()->reorder()->whereNotNull('charge_id')->distinct()->pluck('charge_id');

sum(), count() and max() need nothing, because an aggregate over an ungrouped query drops the order itself.

Sorting is not the same as arranging. Where a document puts its discount lines, whether a group prints a heading, and how a sub-line is indented are the renderer's decisions and stay with the renderer.

Sub-lines ​

Each charge gets its own InvoiceLine. Within a product, the base charge becomes the parent line and the options and addons nest under it through parent_id:

php
$parent = $invoice->lines->whereNull('parent_id')->first();

foreach ($parent->children as $sub) {
    // $sub->parent_id === $parent->id
}

Every line carries its own amount_minor and its own per-line tax. A parent's amount is its own line only; the children carry theirs. The invoice subtotal, tax, and total sum every row, parent and child. Because tax resolves per line and not on a summed net, a mixed-rate group can differ by a cent from a single summed-net computation; the per-line sums are authoritative.

A charge with a null line_group becomes a standalone parent line with no children. Every charge in a group flips to invoiced when its line is written.

A driver's issue(InvoiceDraft $draft) receives $draft->charges, a flat collection of Charge rows. A product and its options and addons arrive as separate charges, tied together by two fields:

  • line_group: the owning subscription item id. Every charge a product produces (the base line, each option, each addon, proration, setup, usage) carries the same line_group. Account-level charges with no item have a null line_group.
  • kind: a LineKind. $charge->kind->isBaseLine() returns true for Recurring, Prorated, FullPeriod, and OneOff, and false for Option, Addon, Setup, Usage, Discount, and Credit.

Group the charges by line_group to reconstruct a product, then pick the base line as the parent:

php
foreach ($draft->charges->groupBy('line_group') as $group) {
    $parent = $group->firstWhere(fn ($c) => $c->kind->isBaseLine()) ?? $group->first();
    $subItems = $group->reject(fn ($c) => $c === $parent);
}

Drivers ​

The invoice driver is swappable. The default database driver writes the invoice and its lines to the meteric_* tables. To send invoices to an external system, bind a class implementing Meteric\Contracts\InvoiceDriver:

php
// config/meteric.php
'invoice' => [
    'driver' => 'lexoffice',
    'drivers' => [
        'database'  => \Meteric\Invoicing\Drivers\DatabaseInvoiceDriver::class,
        'lexoffice' => \Meteric\Invoicing\Drivers\LexofficeInvoiceDriver::class,
    ],
],

Throwing from issue() is the boundary that preserves pending charges, so a remote driver that fails to reach its API should throw rather than swallow the error. The bundled lexoffice driver composes the database driver, so the canonical invoice is always written locally even when the remote push fails. Reach the active driver with Meteric::driver().

The bundled lexoffice driver wraps the database driver: it persists the canonical local invoice first, then finalizes the document in Lexware Office. The local invoice is the source of truth, so if the Lexware Office call fails it re-throws without rolling the local invoice back. See Lexware Office (lexoffice) below.

Lexware Office (lexoffice) ​

To send finalized invoices and credit notes to Lexware Office, set the driver and token:

dotenv
METERIC_INVOICE_DRIVER=lexoffice
METERIC_LEXOFFICE_TOKEN=your-api-token

The config block:

php
// config/meteric.php
'invoice' => [
    'lexoffice' => [
        'api_token' => env('METERIC_LEXOFFICE_TOKEN'),
        'base_url'  => env('METERIC_LEXOFFICE_BASE_URL', 'https://api.lexware.io'),
        'tax_type'  => 'net',   // line amounts are posted net
        'country'   => 'DE',
    ],
],

Production runs against https://api.lexware.io. Trial and sandbox keys only work against the sandbox gateway at https://api.lexware-sandbox.io; generate them at app.lexware-sandbox.de/addons/public-api.

The driver keeps the canonical invoice locally, then POSTs to Lexware Office and stores the returned id and resource URI on external_id / external_url:

  • issue() posts to /v1/invoices?finalize=true.
  • creditNote() posts to /v1/credit-notes?finalize=true.

Lines map to lexoffice line items: the line title becomes name, the multi-line description stays the description, quantity and unit (as unitName) carry over, and amounts post net with a taxRatePercentage so lexoffice computes the gross. Lexoffice has no native line nesting, so the sub-line hierarchy flattens: each parent posts as a custom item, then its children follow as their own custom items with an indented - {title} name, each carrying its own net and tax. The net of every posted line sums to the invoice subtotal. A parent line group becomes a type:"text" separator (a heading row), and the billed cycle posts as a serviceperiod spanning the invoice with an inclusive end date.

Payments ​

Meteric does not talk to gateways. When your gateway confirms money arrived, record it against the invoice:

php
use Brick\Money\Money;

Meteric::recordPayment($invoice, Money::of('49.98', 'EUR'), 'pi_123');

This creates a Payment and a PaymentAllocation and advances the invoice state: partially_paid while the running total is below the invoice total, paid once it reaches it. A full payment settles the charges the invoice billed. Read the position off the invoice:

php
$invoice->total();        // Money
$invoice->outstanding();  // Money still owed
$invoice->isPaid();       // bool
$invoice->isOverdue();    // bool, issued, past due, not paid

Reversing a payment ​

Money that arrived and then left again is not a refund. A direct debit the bank returns, a card payment charged back, a transfer recalled: nothing was given back to the buyer, the invoice was never validly settled and the receivable stands.

php
Meteric::reversePayment($payment, 'AC04 account closed');

Each allocation the payment made is answered by a negative allocation of its own, so the original row is still there with what it applied and when, and sum(amount_minor) over an invoice is what is allocated to it now. The invoice's paid_minor falls by the same amount, its state goes back to partially_paid or open, paid_at is cleared once paid falls below the total, and the charges a full payment settled go back to invoiced. A PaymentReversed event carries the payment, the invoice, the reason and the negative allocation, which is the identifier a listener keys on to run once.

reversePayment(Payment $payment, string $reason): Payment reverses everything the payment still has applied. A payment with nothing applied left, and one allocated to no invoice at all, both raise Meteric\Exceptions\PaymentNotReversible, so a redelivered gateway event cannot take the same money back twice. No allocation is ever reversed for more than it applied.

The reversal writes no credit note and no refund, because the buyer owes what they owed. Moving the money is the gateway's and the bank's; recording that it went is this. Use a credit note instead when what is being undone is the claim rather than the payment, and a refund when money is being sent back deliberately.

Credit notes and refunds ​

Meteric does not move money. A credit note is the accounting reversal document. The refund itself is your payment gateway's job, the same gateway-agnostic split as payments: Meteric records the document, you move the money.

A refund is not a reversal. A credit note plus a refund says the claim was wrong or is being given up: what the buyer owes goes down, and money is sent back. A clawback says the payment never happened: what the buyer owes has not moved and nothing is sent anywhere. Reach for reversePayment() there, or the buyer ends up owing nothing for a service they still have.

php
use Brick\Money\Money;

// Reverse the full net of an invoice; VAT is mirrored automatically.
$note = Meteric::creditNote($invoice, Money::ofMinor($invoice->subtotal_minor, 'EUR'), 'Customer refund');

creditNote(Invoice $invoice, Money $amount, ?string $reason = null, array $meta = []): CreditNote takes the net amount to credit. The driver adds the invoice's tax rate on top so the credit note reverses the same VAT the invoice charged, and fires a CreditNoteIssued event. A credit note mirrors the invoice's tax rate: a net 10.00 EUR credit at 19% VAT comes to 11.90 EUR gross. $meta is stored on the note's metadata. The CreditNote model carries amount_minor (net), tax_minor (mirrored), currency, number, reason, metadata and state, and gross().

Credit note lines ​

A single net amount is taxed at the invoice's blended rate. To reverse specific lines, each at the tax it actually carried, build the note line by line:

php
$note = Meteric::creditNoteLines($invoice, [
    ['invoice_line_id' => $vps->id, 'net_minor' => 500, 'title' => 'Half the VPS'],
    ['invoice_line_id' => $storage->id, 'net_minor' => 2000],
], 'Outage', ['ticket' => 42]);

$note->lines;   // CreditNoteLine rows: invoice_line_id, title, net_minor, tax_minor, tax_rate, gross_minor

creditNoteLines(Invoice $invoice, array $lines, ?string $reason = null, array $meta = []): CreditNote. Each entry names an invoice_line_id on that invoice and a positive net_minor; title is optional and defaults to the invoice line's title. The line's tax is the invoice line's own tax in proportion (its tax_minor scaled to the credited net, rounded once), so a mixed-rate invoice credits exactly the VAT it charged. A line cannot be credited past its remaining net across every earlier non-void note on that invoice, and the invoice's cumulative net guard applies on top. Two entries for the same line in one call are summed before the check. Any refusal throws InvalidArgumentException and writes nothing.

Recording a refund ​

Payment and PaymentAllocation are positive and never change. Money that goes back out is its own row:

php
$refund = Meteric::recordRefund($payment, Money::ofMinor(1190, 'EUR'), $note, 're_123');

$payment->refundedMinor();   // int, minor units returned so far
$payment->refundable();      // Money still refundable
$payment->refunds;           // Refund rows

recordRefund(Payment $payment, Money $amount, ?CreditNote $creditNote = null, ?string $reference = null): Refund records the gross amount returned, optionally tied to the credit note that justified it. It refuses more than the payment's unrefunded remainder, a currency mismatch, or a non-positive amount. The payment, its allocations and the invoice's paid_minor and state are left as they were: what came in and what went back out both stay readable. Moving the money is still your gateway's job.

With the Lexware Office driver, creditNote() also POSTs a real credit-note document to lexoffice (POST /v1/credit-notes?finalize=true) and stores its external_id.

Void or credit note ​

Meteric::voidInvoice($invoice, bool $voidCharges = false) cancels an invoice issued in error, before any money moves. It works only on an unpaid invoice and refuses once any payment exists; correct a paid or finalized invoice with a credit note instead, and the check reads the invoice row rather than the model handed in, so a copy taken before a payment was collected does not get past it. The database refuses it too, so a migration, a seeder or a console session that moves a settled invoice to void in one statement is rejected rather than leaving a payment allocated to a document that officially never existed.

php
Meteric::voidInvoice($invoice);                     // charges return to pending, the next run re-bills them
Meteric::voidInvoice($invoice, voidCharges: true);  // charges are voided with the document, nothing re-bills

By default voiding returns each charge the invoice billed to pending, so the next invoicePending re-bills it. That is the right thing for a wrong document over real work. It is the wrong thing for a cancellation where the work itself is written off: pass voidCharges: true and the charges are set to void instead, so nothing comes back on a later invoice. Either way a charge stays put if it still has a line on another non-void invoice (a re-issued copy), or if it is settled or soft-deleted. To re-bill onto a corrected document, copy the invoice first so the charges keep a live line, then void the original. See Copy and re-issue.

Voiding routes through the driver, so the Lexware Office driver voids a draft that never reached the API and refuses a finalized one (use a credit note).

Collective invoicing ​

By default every path that raises a charge invoices the account's pending pool straight after, so a renewal, an upgrade and a one-off each produce their own document on their own date. An account can defer that instead: charges accrue exactly as they did, nothing invoices them as they happen, and one invoice a cycle bills everything that accrued.

php
use Meteric\Enums\InvoiceSchedule;
use Meteric\Facades\Meteric;

Meteric::setInvoiceSchedule($account, InvoiceSchedule::Collective);          // the configured day
Meteric::setInvoiceSchedule($account, InvoiceSchedule::Collective, day: 15); // its own day
Meteric::setInvoiceSchedule($account, InvoiceSchedule::Immediate);           // back to per event

setInvoiceSchedule(BillingAccount $account, InvoiceSchedule $schedule, ?int $day = null, ?CarbonImmutable $at = null): BillingAccount. The day is 1-31 and defaults to meteric.invoice.collection_day (1); short months clamp to their last day, so a cycle on the 31st issues on 28 February.

Nothing about the charges changes, only when the document is written. They sit pending the whole cycle, so they are visible, they are the same billable pool, and everything already true of a pending charge stays true: a service canceled mid-cycle still bills what it accrued, because cancelling stops future charges and does not touch the ones already raised.

invoicePending and invoiceAllPending return nothing for a collective account. That is the whole mechanism: the deferral lives on the one method every caller already goes through, rather than in each caller. A caller that means to bill regardless passes force:

php
Meteric::invoicePending($account);               // null while the account defers
Meteric::invoicePending($account, force: true);  // bills anyway

draftInvoice, createInvoice and finalizeInvoice are not affected. They build a document the caller is composing rather than billing what an account owes, so an invoice for money already taken is still issued at the moment it is taken, which is where it belongs.

The run ​

meteric:run bills the accounts whose collection date has come round, after the renewal pass that accrued their charges. Nothing else to schedule.

php
Meteric::invoiceCollective($account);        // bill this account's closed cycle
Meteric::dueForCollection($at);              // the accounts a run should look at
$account->isDueForCollection($at);           // has its date come round again
$account->nextCollectionAt($at);             // when it bills next

invoiceCollective is idempotent per cycle and that is the double-billing guard. The account carries collected_through, the boundary date already billed, written after the driver returned:

  • A run repeated inside the cycle finds the stamp already covering the boundary and issues nothing.
  • A cycle nobody ran for is billed by the next run that happens, once, and stamped with the boundary it billed rather than the day it ran. The document states the day it was issued.
  • A driver failure leaves the charges pending and the stamp unmoved, so the next run bills the same cycle again.

Switching to Collective stamps the cycle already running, so opting in on the 15th does not bill on the 15th: the account joins the run at its next whole boundary. Switching back clears the stamp and issues nothing by itself; the pool that accrued is billable again the moment the account is Immediate, and what to do with it is the caller's decision.

An account put on the schedule by hand with no stamp (a factory row, a seed) is anchored by the first run, which bills whatever it happens to be holding.

Payment terms run from the issue date. finalizeInvoice sets due_at from meteric.invoice.net_days when the document is written, so a charge that accrued on the 3rd is due net_days after the collection date and not after the 3rd. markOverdue reads due_at and needs nothing else.

Each accrued charge keeps its own line on the collective invoice, with its own title, its own line_group (the subscription item it came from) and its own covers period, so one document states what each service was billed for and when. See Sub-lines.

Consolidated billing ​

A payer account can bill its own pending charges plus all its child accounts' charges onto a single invoice, a reseller or an organization with sub-accounts:

php
$invoice = Meteric::invoiceConsolidated($payer);

This collects pending charges across the payer's scope (itself and its children, via payerScopeIds()) and issues one invoice, itemized per account. A driver failure leaves every charge pending. A payer on a collective schedule defers this the same way (force overrides it), so the two compose: a reseller can bill its whole subtree once a month.

One invoice per subscription ​

A schedule says when the pool becomes a document. InvoiceSplit says how many documents it becomes. The two are orthogonal: an account can be billed monthly and still want one invoice per subscription.

php
use Meteric\Enums\InvoiceSplit;
use Meteric\Facades\Meteric;

Meteric::setInvoiceSplit($account, InvoiceSplit::PerSubscription);
Meteric::setInvoiceSplit($account, InvoiceSplit::Pooled);       // the default

setInvoiceSplit(BillingAccount $account, InvoiceSplit $split): BillingAccount.

invoiceAllPending honours it, and therefore so does the collective run. A customer on PerSubscription billed monthly gets one invoice per subscription on their collection day instead of one for everything. Charges that belong to no subscription - an account-level one-off, a manual charge, a restore fee - are one document of their own: they have no subscription to be split by, and dropping them would strand them pending for ever.

invoicePending is unaffected, and that is deliberate: it issues the pending pool as one document by definition and returns that one invoice. A caller that asks for one invoice gets one. The split is a property of billing everything that is pending, which is what invoiceAllPending does.

The pool is read and locked once for the whole split, so two concurrent runs cannot bill the same charge onto two documents - the same guarantee invoicePending gives, taken over the set rather than per group.

It exists for a customer whose accounts payable department needs one invoice per contract, which is a common request from a business running several services on one account and cannot be answered by the schedule.

Consolidation and collective invoicing answer different questions and are not alternatives. Consolidation is whose charges go on one document; a collective schedule is when the document is written.

Set the relationship by giving a child account a parent_id:

php
use Meteric\Models\BillingAccount;

BillingAccount::create([
    'owner_type' => $org->getMorphClass(),
    'owner_id' => $org->getKey(),
    'parent_id' => $payer->id,
    'currency' => 'EUR',
]);

How charges and invoices relate ​

A Charge accrues as pending the moment money is owed: a renewal, an upgrade, a usage rollup, an addon. The link between a charge and an invoice is the line, invoice_lines.charge_id. A charge moves through four states, maintained by the line that references it:

  • pending: owed, not yet on any live invoice. The billable pool.
  • invoiced: a line references it on a non-void invoice.
  • settled: that invoice is paid in full.
  • void: discarded. A charge can also be soft-deleted to drop it entirely.

Two facts follow from the charge being the source of truth:

  • The charge stays pending if the driver throws. An accounting outage loses no revenue; the next run reuses a deterministic batch key and retries the same charges, so a partial failure does not create a second invoice.
  • An invoice total never goes negative. Credits ride as itemized negative lines, each carrying the product name of what it credits, offsetting the positive charges down to zero and no further. If pending credits outweigh the charges, invoicePending issues nothing and returns null; the credit lines stay pending and reduce a later invoice. Money back to a customer is a credit note, not a negative invoice.

Released under the MIT License.