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
- CreditService
- Transaction types
- How credit reaches a charge
- Expiration
- Notifications and the dashboard
- Query scopes
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 onremaining_amountmatching 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.
- Both take a positive amount and throw
InvalidArgumentExceptionon zero or negative — the direction is the method you call, not the sign you pass. deductCredit()never takes the balance below zero: a deduction larger than the balance takes what is there, and the returned transaction says how much. With nothing there it throwsCreditServiceException.- A deduction is drawn from the additions that expire soonest first (non-expiring credit last), so credit about to lapse is spent before it does.
- Both lock the balance row (
lockForUpdate()), so concurrent deductions cannot oversell the balance.
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.
$upTois a cumulative target ("bring what was returned from this charge up to this much"), so a retry never returns twice. Null returns all ofcredit_applied; a higher target is capped at it; zero or negative throwsInvalidArgumentException.- It returns the amount this call gave back (
0.0when the target was already reached, and for an unpaid charge or one without applied credit). - It never changes the charge's
amountorcredit_applied. - It triggers the credit-added mail like any credit addition;
$descriptionis what the customer reads. - With invoicing on, it produces a credit note for what it returned.
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:
- every charge the planner (
larsasub:plan-charges) creates, on its collection day; - the first charge of a plan change;
- a chargeback follow-up charge.
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.