LarsaSub is launching soon. Purchases and account registration open shortly.

Documentation for larsasub v0.38.2. On an older version? Your release notes ship with the package (CHANGELOG.md).

Credit

Every billable has at most one credit account: a running balance with a full ledger behind it. Credit is spent automatically on the next charge, so it is the mechanism behind cost-line refunds, plan-change settlements and any goodwill you hand out. Read this page when you add credit yourself, show the balance, or need to know when credit is spent and when it expires.

The models

The balance lives on one row per billable; every movement of it is a ledger row. Both belong to the Billable registration, not to your owner model directly.

BillableCredit

Models\BillableCredit (larsasub_billable_credits) — one account per billable (billable_id is unique). The row is created on the first credit operation.

Property Description
billable_id The Billable registration
balance Current balance; never below zero
currency Currency code (currency.default when the account is created)
$credit->hasCredit();
$credit->getAvailableCredit();
$credit->billable;
$credit->transactions;

CreditTransaction

Models\CreditTransaction (larsasub_credit_transactions) — the ledger. Every mutation writes one row with the balance on both sides, so the account can always be reconstructed.

Property Description
billable_credit_id Credit account
billable_id The Billable registration (denormalized for direct querying)
amount Positive for additions, negative for deductions
remaining_amount On an addition: how much of it is still unspent. Deductions draw it down; expiry voids only this part
balance_before, balance_after The running balance around this entry
type See Transaction types
description Human-readable; reaches the customer's credit dashboard and the credit mails
source_type, source_id Polymorphic origin (e.g. the cost line or charge that triggered it)
recurring_charge_id The charge this entry funded, when applicable
expires_at, expired, expiration_notified_at Expiration bookkeeping
performed_by_user_id Operator who performed a manual mutation
meta Free-form metadata
$transaction->isAddition();
$transaction->isDeduction();
$transaction->isExpired();
$transaction->onExpirationNotified();

$transaction->billableCredit;
$transaction->billable;
$transaction->source;
$transaction->recurringCharge;
$transaction->performedBy;

The texts the package writes itself — credit applied to a payment, credit coming back from one, a cost-line refund, an expiry — are translated (en/nl) and frozen in the billable's stored locale. A description you pass yourself is stored as given.

CreditService

Service\CreditService is the only way to change a balance. It locks the registration and the balance row, writes the ledger entry and fires the event in one transaction.

Never update() a balance or write a ledger row yourself. The ledger is the audit trail, and expiry relies on remaining_amount matching the balance. A direct write breaks both without an error.

Adding and deducting

use PetersDevelopment\LarsaSub\Models\CreditTransaction;
use PetersDevelopment\LarsaSub\Service\CreditService;

$creditService = app(CreditService::class);

$creditService->addCredit(
    billable: $user,
    amount: 50.00,
    type: CreditTransaction::TYPE_PROMOTIONAL,
    description: 'Welcome bonus',
    expiresAt: now()->addYear(),
);

$creditService->deductCredit(
    billable: $user,
    amount: 25.00,
    type: CreditTransaction::TYPE_MANUAL,
    description: 'Manual correction',
);

Full signatures:

addCredit(
    Model $billable, float $amount,
    string $type = CreditTransaction::TYPE_MANUAL,
    ?string $description = null, ?Model $source = null,
    ?DateTimeInterface $expiresAt = null, ?int $performedByUserId = null,
    ?array $meta = null,
): CreditTransaction

deductCredit(
    Model $billable, float $amount,
    string $type = CreditTransaction::TYPE_CHARGE_DEDUCTION,
    ?string $description = null, ?RecurringCharge $charge = null,
    ?int $performedByUserId = null, ?array $meta = null,
): CreditTransaction

$billable is your owner model or a Billable registration; the registration is created when it does not exist yet.

Reading the balance

$balance = $creditService->getAvailableBalance($user);   // read-only; 0.0 without a registration
$credit  = $creditService->getOrCreateCredit($user);     // creates the registration and account when missing

The owner model exposes the read side directly:

$user->getCreditBalance();
$user->hasCredit();
$user->credit();                    // HasOneThrough BillableCredit
$user->creditTransactions()->get(); // the ledger (a query builder)

Giving back credit applied to a paid charge

CreditService::returnAppliedCredit(RecurringCharge $charge, ?float $upTo = null, ?string $description = null) returns credit that was applied to a paid charge — the part a provider refund cannot touch, because it never went through the provider.

The ledger row carries meta.kind = CreditTransaction::KIND_APPLIED_CREDIT_RETURN. returnCollectedAsCredit() is its counterpart for money the provider collected. Both are covered in Refunds.

Transaction types

Constant Value Written by
CreditTransaction::TYPE_MANUAL manual An operator; the default type of addCredit()
CreditTransaction::TYPE_PROMOTIONAL promotional Your app
CreditTransaction::TYPE_REFUND refund Credit given back: from a charge deleted or settled unpaid; from a paid charge through returnAppliedCredit() (meta.kind = CreditTransaction::KIND_APPLIED_CREDIT_RETURN); or collected money given back as credit through returnCollectedAsCredit() (meta.kind = CreditTransaction::KIND_COLLECTED_RETURN, plus meta.key) — see Refunds
CreditTransaction::TYPE_CHARGE_DEDUCTION charge_deduction Credit applied to a charge; the default type of deductCredit()
CreditTransaction::TYPE_EXPIRATION expiration larsasub:process-expired-credits
CreditTransaction::TYPE_SUBSCRIPTION_CHANGE subscription_change Prorated settlement on a plan change
CreditTransaction::TYPE_COST_LINE_ADJUSTMENT cost_line_adjustment Prepaid days refunded when a cost line ends

The type is a free string (up to 32 characters) as far as the database is concerned; use the constants so the dashboards and the ledger stay legible.

How credit reaches a charge

CreditService::applyCreditToCharge() runs when these charges are created:

It reduces RecurringCharge->amount, records credit_applied and keeps original_amount as the pre-credit figure. A charge fully covered by credit becomes zero-amount and is settled locally — no provider call, but still an invoice (with invoicing on) and a paid event. If the settling write fails on a charge the planner created, the charge stays planned at 0,00 and larsasub:execute-charges settles it: a 0,00 charge is never sent to the provider.

The first payment of a new subscription, standalone charges and single payments do not use credit.

Credit is applied only to a charge in the account's own currency. The package converts nothing, so a charge in another code (a follow-up of a standalone charge you created in a second allowed currency) is collected in full, the balance stays, and the registration's history says why.

This matters for invoicing: invoice.total equals the pre-credit amount, and total - credit_applied is what was actually debited. The PDF shows that difference as the amount due. See Invoicing and VAT.

If a planned charge is deleted before execution, or settled at €0 because the subscription stopped being chargeable, the credit it consumed comes back (TYPE_REFUND) as fresh, non-expiring credit — the original expiry date is not restored. CreditService::returnCreditForDeletedCharge() is the method behind the deletion case; LarsaSub calls it itself.

Expiration

Credit added with an expiresAt is warned about and then expired by two scheduled commands, both daily by default:

Command Does Frequency key
larsasub:notify-expiring-credits Dispatches CreditExpiringEvent (and so the CreditExpiringNotification mail) once per addition that expires within scheduling.notify_credit_expiration_days_before days (default 14) and still has something unspent. The mail names that unspent remainder scheduling.notify_expiring_credits_frequency
larsasub:process-expired-credits Writes the TYPE_EXPIRATION entries and dispatches CreditExpiredEvent scheduling.process_expired_credits_frequency

Expiry only takes what is still unspent of that addition (remaining_amount): a €100 credit of which €70 was spent expires €30, and never touches other, still-valid credit. An addition that was spent in full expires without a deduction and without an event, and is not announced beforehand either.

Both commands are scheduler-only: on a host without a running scheduler, or with scheduling.enabled = false, credit never expires and the events never fire. With scheduling.enabled = false even a manual run is a no-op.

Notifications and the dashboard

Each credit event has a customer mail and a company mail, toggled separately:

Event Customer (notifications.user.*, on) Company (notifications.company.*, off)
CreditAddedEvent credit_added credit_added (LARSASUB_NOTIFY_COMPANY_CREDIT_ADDED)
CreditExpiringEvent credit_expiring credit_expiring (LARSASUB_NOTIFY_COMPANY_CREDIT_EXPIRING)
CreditExpiredEvent credit_expired credit_expired (LARSASUB_NOTIFY_COMPANY_CREDIT_EXPIRED)

CreditDeductedEvent sends nothing. Every credit addition mails the customer, the package's own included (a cost-line refund, a plan-change settlement) — switch notifications.user.credit_added off if that is too much. See Notifications.

The bundled credit dashboard (standalone.dashboards.credit, at billing/credit by default) shows the customer their balance and ledger. See Dashboards.

Query scopes

BillableCredit::query()->forBillable($billable)->get();
BillableCredit::query()->hasBalance()->get();          // balance > 0

CreditTransaction::query()->additions()->get();            // positive amounts
CreditTransaction::query()->isExpired()->get();            // expired, or past expires_at
CreditTransaction::query()->expirationDue($days)->get();   // unexpired, unspent additions expiring within X days
CreditTransaction::query()->notExpirationNotified()->get();

Credit mutations dispatch CreditAddedEvent, CreditDeductedEvent, CreditExpiringEvent and CreditExpiredEvent — see Events.