eugenefvdm/billing
A Laravel/PHP billing package by eugenefvdm that provides a foundation for handling invoices, payments, and subscription billing inside your application. Framework-native and lightweight, it aims to keep core billing logic organized and easy to integrate.
1. Install and publish.
composer require eugenefvdm/billing
php artisan vendor:publish --provider="EugeneFvdm\Billing\BillingServiceProvider"
php artisan migrate
Publish config and migrations separately if the provider offers tags (--tag=billing-config, --tag=billing-migrations) — you want the config file in version control before migrate runs.
2. Read the subscriptions migration before writing a line of app code. The whole shape of v0.4 lives here. Look for:
status_id vs. boolean active)activated_at / paid_at / cancelled_at timestampssubscription_id, reference, amount, received_at)Whatever column pair encodes "created but unpaid" versus "money received and live" becomes the contract your app codes against. Do not invent a second is_paid flag on your own users table.
3. Find the activation seam. v0.4 gates activation on EFT funds being confirmed received. Trace it from the inside out:
activate, status, and any event classes (SubscriptionActivated, PaymentReceived)config/billing.php for an activation-status key, grace period, and any gateway/Eft settingsIf the package exposes a method, you call it. If it only flips a column, you wrap that in your own action class (see below) rather than scattering ->update() calls.
4. First use case — the full happy path. This is the smallest slice worth shipping:
// checkout: create subscription, do NOT expect it to be live
$subscription = $user->newSubscription('default', $plan)->create([
'status' => 'pending',
'reference' => $reference = 'INV-' . Str::upper(Str::random(8)),
]);
return view('billing.awaiting-eft', [
'reference' => $reference,
'amount' => $plan->price,
]);
Then a confirm action (admin screen or reconciliation job) that transitions it, and a hard assertion that $user->subscribed() is false until that happens. Build that assertion as a test on day one — it is the thing v0.4 changed.
Where to look first when something is wrong: the status column → activated_at → whether the confirm action actually ran → failed_jobs. That order solves ~80% of "I paid but have no access" tickets.
Treat pending as a real state, not a side effect. Gate entitlements on a single method, never on the record's existence:
// app/Models/Subscription.php or a trait on your side
public function isLive(): bool
{
return $this->status === 'active'
&& $this->activated_at !== null
&& $this->activated_at->isPast();
}
Every middleware, policy, Blade @if, and API resource reads isLive() (or the package's own active()/valid() scope — verify which exists and standardise on one). Mixed checks are how you end up with a dashboard that says "active" and a paywall that says "no".
Centralise confirmation in one action class. This is the seam that survives package upgrades:
namespace App\Actions\Billing;
class ConfirmEftPayment
{
public function __construct(
private string $subscriptionId,
private int $amountCents,
private string $source, // 'admin', 'bank-feed', 'webhook'
private ?int $actorId = null,
) {}
public function handle(): bool
{
return DB::transaction(function () {
$sub = Subscription::whereKey($this->subscriptionId)
->lockForUpdate()
->firstOrFail();
if ($sub->status === 'active') {
return false; // idempotent no-op, not an error
}
if ($this->amountCents < $sub->amount_due_cents) {
// partial: record it, do not activate
$sub->payments()->create([
'amount_cents' => $this->amountCents,
'received_at' => now(),
'source' => $this->source,
]);
return false;
}
$sub->payments()->create([...]);
$sub->forceFill([
'status' => 'active',
'activated_at' => now(), // app timezone, see Gotchas
])->save();
event(new SubscriptionActivated($sub, $this->source, $this->actorId));
return true;
});
}
}
Call this from both the admin "mark received" button and the reconciliation job. Two entry points, one code path.
Fire side effects off the event, queued after commit. Provisioning, welcome mail, entitlement grants:
class ProvisionWorkspace implements ShouldQueue
{
public $afterCommit = true;
public function handle(SubscriptionActivated $e): void { /* idempotent */ }
}
afterCommit matters: without it, a rolled-back confirmation still queues a "your account is live" email. Make the listener idempotent anyway — check whether the workspace already exists before creating it — because payment confirmations are at-least-once by nature.
Reconciliation as a scheduled command. EFT means a matching step, not a webhook:
// app/Console/Commands/ReconcileBankFeed.php
Schedule::command('billing:reconcile')->everyFifteenMinutes()->withoutOverlapping();
Parse the statement, match on the stored reference (unique index on it), call ConfirmEftPayment for each match, and push unmatched lines into a payment_review_queue table rather than guessing. Log the value date, not just the processing time.
Admin override with audit. The manual "I can see it in the bank" button is not optional in EFT land — your finance team will need it the day something goes wrong. Put it behind a policy, record actorId, and let it use the same ConfirmEftPayment action so the audit trail is uniform.
Testing pattern that catches the v0.4 regression class:
Event::fake([SubscriptionActivated::class]);
$sub = $this->createPendingSubscription($user); // status: pending
$this->assertFalse($user->fresh()->subscribed());
(new ConfirmEftPayment($sub->id, $sub->amount_due_cents, 'admin', $admin->id))->handle();
$this->assertTrue($user->fresh()->subscribed());
Event::assertDispatched(SubscriptionActivated::class);
// call it again — assert no second event, no second payment row
Use RefreshDatabase plus travel()/freezeTime() for grace-period and pending-expiry tests.
subscribed(), middleware, policy, notification condition, and Blade check. Users mid-flow at deploy time may be stranded in pending — run a report before you ship.Subscription::whereIn(...)->update(['status' => 'active']) fires no observers, dispatches no package events, and therefore provisions nothing. This is the single most common reconciliation bug. Update models individually through your action, or dispatch events manually.lockForUpdate() inside a transaction plus a status check, and put a unique constraint on the payment reference. Webhook retries and overlapping cron runs are the classic duplicate-activation source; withoutOverlapping() on the schedule is table stakes.isLive() or feature flags, invalidate on activation and on pending-expiry. A cached "locked" state after payment is a support ticket you will get within the hour.APP_TIMEZONE can push activated_at into the wrong billing period and mis-bill the first cycle. Normalise to app timezone before writing, and confirm whether any grace period is measured from the EFT confirmation date or the invoice date — they differ, and the release note doesn't say.isLive() false forever). Check config against the migration.ConfirmEftPayment, the event listeners, and the reconciliation command in app/. That's your seam for audit logging, partial-payment policy, retries, and the eventual upgrade to v0.5.composer.json and re-run the pending→active test suite on every bump; treat minor releases as potentially breaking.How can I help you explore Laravel packages today?