Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Billing Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  • the status column (string enum vs. status_id vs. boolean active)
  • activated_at / paid_at / cancelled_at timestamps
  • the payments/transactions table and how it links back (subscription_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:

  • grep the package source for activate, status, and any event classes (SubscriptionActivated, PaymentReceived)
  • identify the method that performs the transition and what calls it (webhook handler, admin action, artisan command, or nothing at all)
  • check config/billing.php for an activation-status key, grace period, and any gateway/Eft settings

If 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.

Implementation Patterns

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.

Gotchas and Tips

  • Silent semantic break on upgrade. Anything written against pre-0.4 assumed creation ⇒ active. Audit every 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.
  • Bulk updates skip model events. 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.
  • Partial and over-payments. "Money received" is not binary. Decide explicitly whether 90% activates (it shouldn't), record partials as payments against a pending subscription, and don't silently round up. Over-payments need a credited/credit-note path or finance will chase you.
  • Races and double-clicks. Guard with 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.
  • Stale cached entitlements. If you cache 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.
  • Timezone drift. Bank value dates versus 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.
  • Config quirks. If the activation status is a config value, overriding it without matching the enum/column definition produces a silent no-op transition (status set to a string the app doesn't recognise, isLive() false forever). Check config against the migration.
  • Don't edit vendor code. Keep 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.
  • Log every transition with subscription id, previous status, new status, amount, actor, and source. When a user says "I paid", the log is the difference between a five-minute answer and an afternoon.
  • Pre-1.0 version pin. This is a v0.x package with a behavioural change already shipped. Pin the exact version in composer.json and re-run the pending→active test suite on every bump; treat minor releases as potentially breaking.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
abbasudo/artisan-route
mirvan/ippanel
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package