承接 graystackit/laravel-mollie-billing 相关项目开发

从需求分析到上线部署,全程专人跟进,保证项目质量与交付效率

邮箱:yvsm@zunyunkeji.com | QQ:316430983 | 微信:yvsm316

graystackit/laravel-mollie-billing

Composer 安装命令:

composer require graystackit/laravel-mollie-billing

包简介

Mollie billing for Laravel with VAT, metered billing, coupons, access grants and admin panel.

README 文档

README

Mollie billing for Laravel with VAT, metered billing, coupons, access grants and admin panel.

Latest Version on Packagist PHP Version Laravel Version Total Downloads License

A batteries-included Mollie billing layer for Laravel that wraps mollie/laravel-mollie ^4 and adds VAT/OSS compliance, wallet-based metered billing, a coupon engine, scheduled plan changes, an admin panel, and a Livewire 4 customer portal — all keyed off a Billable contract that lives on whichever model owns the subscription (typically your Organization, not your User).

Highlights

  • Mollie subscriptions, mandates and webhooks (built on Mollie's official Laravel SDK v4 with typed request objects)
  • VAT calculation, VIES validation and OSS export (mpociot/vat-calculator)
  • Country-mismatch reconciliation: three-way reconciliation of user-declared, payment-derived, and IP-derived country at every recurring payment. If the user country matches none of the other signals, the subscription is set to cancel-at-period-end, the billable is notified by email, and the user can self-correct via a dashboard modal (refund + reissue at the corrected VAT rate). B2B billables with a VIES-validated VAT number bypass the check (reverse-charge makes the bank country fiscally irrelevant). Manual admin override available. See docs/vat-handling.md.
  • Wallet-based metered billing with included quotas and overage prices (bavix/laravel-wallet), with case-insensitive usage-type lookups
  • Direct overage charging with retry and past_due state
  • Five coupon types — SinglePayment, Recurring, Credits, TrialExtension, AccessGrant
  • Access Grants for full-plan or addon-only complimentary access
  • Scheduled plan changes, prorata, end-of-period downgrades
  • Refunds and credit notes (full, overage units, wallet-only)
  • IP-based country pre-fill in the checkout/billing-data dropdown (UX only — never persisted)
  • Trial flow with Local-to-Mollie subscription conversion
  • Feature gating via @planFeature Blade directive and billing.feature middleware
  • Built-in first-checkout flow with configurable country list, VAT/VIES validation and coupon support
  • Livewire 4 SFC customer portal with optional Flux Pro admin panel
  • Promotion links via signed /promotion/{token} URLs
  • Localized notifications (English and German out of the box)
  • All Livewire SFC views publishable and overridable

Requirements

  • PHP 8.3+
  • Laravel 11, 12 or 13
  • A Mollie account with API key
  • Livewire 4 (for the customer portal views)
  • livewire/flux-pro for the billing portal and the admin panel

Installation

Laravel 13 notempociot/vat-calculator does not yet declare Laravel 13 compatibility upstream. We maintain a drop-in fork at GraystackIT/laravel-vat-calculator that loosens the constraint and replaces the original package. Add it as a VCS repository before requiring the billing package in your root composer.json:

"repositories": [
    { "type": "vcs", "url": "https://github.com/GraystackIT/laravel-vat-calculator" }
]

Composer will then transparently resolve mpociot/vat-calculator through the fork. Laravel 11 and 12 consumers can skip this step.

composer require graystackit/laravel-mollie-billing

Publish the config and migrations:

php artisan vendor:publish --tag=mollie-billing-config       # mollie-billing.php + mollie-billing-plans.php
php artisan vendor:publish --tag=mollie-billing-migrations
php artisan vendor:publish --tag=mollie-billing-views        # optional: override Blade/Livewire views
php artisan vendor:publish --tag=billing-lang                # optional: override translations

Edit config/mollie-billing.php and set the billable model:

'billable_model' => \App\Models\Organization::class,
'billable_key_type' => 'uuid', // 'uuid' | 'ulid' | 'int'
'user_key_type' => 'int',      // 'uuid' | 'ulid' | 'int' — primary key type of your auth user model

Important: billable_key_type and user_key_type must be set before running migrations for the first time. billable_key_type controls the column type of every polymorphic foreign key that references your billable — including bavix/laravel-wallet's wallets.holder_id, transactions.payable_id, and transfers.{from,to}_id, which we rewrite from the default bigint to uuid/ulid. user_key_type controls columns that reference the auth user (e.g. billing_country_mismatches.resolved_by_user_id). Changing them later requires manually altering those columns.

Then run migrations:

php artisan migrate

Verify your configuration before deploying — the package ships a validator that checks both mollie-billing.php and mollie-billing-plans.php for syntax errors, broken references (unknown feature_keys, allowed_addons, product group, …) and likely misconfigurations:

php artisan billing:check-config

See the Commands section below for the full list of issues it detects.

Quick start

Add the HasBilling trait and implement the Billable contract on your billable model — typically a tenant or organization, not the User:

<?php

namespace App\Models;

use GraystackIT\MollieBilling\Concerns\HasBilling;
use GraystackIT\MollieBilling\Contracts\Billable;
use Illuminate\Database\Eloquent\Model;

class Organization extends Model implements Billable
{
    use HasBilling;

    public function getUsedBillingSeats(): int
    {
        return $this->users()->count();
    }
}

Configure your environment:

MOLLIE_KEY=test_xxxxxxxxxxxxxxxxxxxxxxxxxxxx
BILLING_BILLABLE_MODEL=App\Models\Organization
BILLING_BILLABLE_KEY_TYPE=uuid
BILLING_USER_KEY_TYPE=int
BILLING_CURRENCY=EUR

Mount the package routes in routes/web.php. The three route groups serve different scopes and need different middleware:

use GraystackIT\MollieBilling\Facades\MollieBilling;

// Customer portal — needs auth + your tenant resolution middleware
Route::middleware(['web', 'auth', 'tenant'])->group(function () {
    MollieBilling::routes();
});

// Checkout — needs auth but NOT tenant resolution (the checkout creates the tenant)
Route::middleware(['web', 'auth'])->group(function () {
    MollieBilling::checkoutRoutes();
});

// Admin panel — auth only, no tenant scope. AuthorizeBillingAdmin runs inside the group.
Route::middleware(['web', 'auth'])->group(function () {
    MollieBilling::adminRoutes();
});

The admin routes are auto-loaded by the service provider as well, so you only need to call adminRoutes() if you want them under a custom middleware stack.

Multi-tenant URL prefixes

If your app nests the portal behind a tenant parameter (e.g. prefix('{organization:slug}')), mount MollieBilling::routes() inside that group — do not apply your own ->name('tenant.') prefix around it, because the package's views call route('billing.*') by those exact names. Keep checkoutRoutes() outside the tenant group since no tenant exists yet at checkout time:

Route::middleware(['auth', 'tenant'])
    ->prefix('{organization:slug}')
    ->group(function () {
        MollieBilling::routes();
    });

// Checkout lives outside the tenant prefix — the billable is created during checkout
Route::middleware(['auth'])->group(function () {
    MollieBilling::checkoutRoutes();
});

The package ships a PropagateRouteDefaults middleware that copies the active route's parameters into URL::defaults, so generated links inside the portal (e.g. route('billing.plan')) automatically carry the tenant slug — no app-side URL::defaults wiring required.

For contexts without an active HTTP request — queued notifications, background jobs, or services that run before tenant resolution — PropagateRouteDefaults cannot help. Register a global URL parameter resolver so the package can build correct URLs from any context:

use GraystackIT\MollieBilling\Contracts\Billable;
use GraystackIT\MollieBilling\Facades\MollieBilling;

MollieBilling::urlParametersUsing(
    fn (?Billable $billable) => $billable
        ? ['organization' => $billable->slug]
        : []
);

The $billable parameter is null in rare cases where no billable is available yet (e.g. some middleware checks). In those cases the closure should return [] or derive a fallback from auth()->user() or session state.

How the two mechanisms interact: PropagateRouteDefaults covers the request context (portal views, form submissions). urlParametersUsing covers everything else (Mollie webhook URLs, redirect URLs sent to Mollie, queued mail, background jobs). They complement each other — both can be active simultaneously without conflict.

If your billable model needs custom logic beyond the global resolver, you can still override urlRouteParameters() on the model directly — the override takes precedence.

Tell the facade how to resolve the current billable for the authenticated user — usually in AppServiceProvider::boot():

use GraystackIT\MollieBilling\Facades\MollieBilling;

MollieBilling::resolveBillableUsing(fn () => auth()->user()?->currentOrganization);
MollieBilling::authUsing(fn () => auth()->check());

Checkout-route fallback: the checkout route is mounted outside any tenant prefix, so a typical resolveBillableUsing closure that reads from a tenant context returns null there. When that happens the package falls back to looking up the billable from the request's query parameters, matching against the billable model's getRouteKeyName(). A returning customer hitting /billing/checkout?organization=acme-corp therefore re-uses the existing billable instead of being asked for the company details again. Reserved query keys (back, plan, interval, redirect, token) are skipped.

Customize config/mollie-billing-plans.php to define your plans, addons and feature keys.

First checkout

The package ships a complete first-checkout flow — a multi-step Livewire wizard that collects billing details, lets the customer choose a plan, optional addons/seats, apply a coupon, and redirects to Mollie for payment.

Setup

Register three callbacks in your AppServiceProvider::boot():

use GraystackIT\MollieBilling\Facades\MollieBilling;

// How to create a billable (Organization, Team, …) from checkout form data:
MollieBilling::createBillableUsing(function (array $data) {
    return Organization::create([
        'name'              => $data['name'],
        'billing_street'    => $data['billing_street'],
        'billing_city'      => $data['billing_city'],
        'billing_postal_code' => $data['billing_postal_code'],
        'billing_country'   => $data['billing_country'],
        'vat_number'        => $data['vat_number'],
    ]);
});

// Optional: run logic before the Mollie payment is created.
// Return null to proceed, or a string to block checkout with that error message.
MollieBilling::beforeCheckoutUsing(function (Billable $billable): ?string {
    // e.g. create a User and attach to the billable
    return null;
});

// Optional: run cleanup after checkout succeeds or fails.
MollieBilling::afterCheckoutUsing(function (Billable $billable, bool $success): void {
    if (! $success) {
        // e.g. delete the orphaned user
    }
});

// Optional: cascade-delete logic for billables abandoned mid-checkout. The
// CleanupOrphanedBillablesJob runs every 15 minutes and identifies billables
// that never reached an active subscription. When a closure is registered it
// receives the billable and is responsible for cascading cleanup (e.g.
// deleting tenants, users with no other organizations, etc.). Without a
// closure the package falls back to `$billable->delete()`.
MollieBilling::cleanupOrphanedBillableUsing(function (Billable $billable): void {
    DB::transaction(function () use ($billable): void {
        foreach ($billable->users()->get() as $user) {
            if ($user->organizations()->where('id', '!=', $billable->id)->doesntExist()) {
                $user->forceDelete();
            }
        }
        $billable->forceDelete();
    });
});

Link to checkout

<a href="{{ MollieBilling::checkoutUrl('/pricing') }}">Subscribe now</a>

The optional $backUrl parameter controls where the "Back" link in the checkout header leads. When omitted, the package falls back to config('mollie-billing.checkout_back_url') (default /).

To pre-select a plan and/or billing interval, pass them as additional parameters:

<a href="{{ MollieBilling::checkoutUrl('/pricing', plan: 'pro', interval: 'yearly') }}">
    Get Pro yearly
</a>

The plan step will still be shown so the customer can change their mind, but the given plan will be pre-selected. Invalid plan codes or intervals are silently ignored.

Checkout countries

By default the checkout shows all 27 EU member states. Customize via config:

// config/mollie-billing.php
'checkout_countries' => [
    'regions' => ['EU'],          // built-in: 'EU' (27 member states)
    'include' => ['CH', 'GB'],    // additional ISO codes
    'exclude' => ['MT'],          // remove from the list
],

// Countries defined here are auto-included in the checkout selector:
'additional_countries' => [
    'CH' => ['vat_rate' => 8.1, 'name' => 'Switzerland'],
],

Country names are translated via the package's billing::countries lang files (English and German included). Publish and extend them for additional locales:

php artisan vendor:publish --tag=billing-lang

Custom checkout steps

If your app needs additional steps before the billing-address form (e.g. "Create your account"), register them via the facade. Custom steps are inserted before the package's built-in steps; numbering, timeline and navigation adjust automatically.

use Livewire\Component;
use GraystackIT\MollieBilling\Facades\MollieBilling;

// AppServiceProvider::boot()
MollieBilling::checkoutStepsUsing(fn () => [
    [
        'key'         => 'account',
        'label'       => 'Account',
        'headline'    => 'Create your account',
        'description' => 'Set up your login credentials before we continue.',
        'view'        => 'checkout.steps.account', // your app's Blade view
        'validate'    => function (Component $component) {
            $component->validate([
                'customData.name'  => ['required', 'string', 'max:255'],
                'customData.email' => ['required', 'email', 'unique:users,email'],
            ]);
        },
    ],
]);

Each step definition requires:

Key Type Description
key string Unique identifier for the step.
label string Short label shown in the timeline.
headline string Heading displayed above the step content.
description string Subheading text below the headline.
view string Blade view name to @include for this step's form fields.
validate Closure (optional) Receives the Livewire Component instance. Throw a ValidationException (or call $component->validate(...)) to block navigation.

Binding form data — The checkout component exposes a public array $customData = [] property. Use wire:model with dot notation in your step view:

{{-- resources/views/checkout/steps/account.blade.php --}}
<div class="flex flex-col gap-5">
    <flux:input wire:model.live="customData.name" label="Full name" required />
    <flux:input wire:model.live="customData.email" label="Email" type="email" required />
    <flux:input wire:model="customData.password" label="Password" type="password" required />
</div>

The customData array is passed to your createBillableUsing callback as $data['custom'], so you can access it when creating the billable:

MollieBilling::createBillableUsing(function (array $data) {
    $user = User::create([
        'name'     => $data['custom']['name'],
        'email'    => $data['custom']['email'],
        'password' => Hash::make($data['custom']['password']),
    ]);

    $org = Organization::create([
        'name'            => $data['name'],
        'billing_street'  => $data['billing_street'],
        'billing_city'    => $data['billing_city'],
        'billing_postal_code' => $data['billing_postal_code'],
        'billing_country' => $data['billing_country'],
        'vat_number'      => $data['vat_number'],
    ]);

    $user->organizations()->attach($org);

    return $org;
});

You can register multiple custom steps — they appear in the order returned by the callback.

Customizing views

All Livewire views (checkout, portal, admin) can be published and customized:

php artisan vendor:publish --tag=mollie-billing-views

Views are published to resources/views/vendor/mollie-billing/. SFC files use the ⚡ prefix convention (e.g. ⚡checkout.blade.php).

Tailwind CSS content source

The package's Blade views use Tailwind utility classes (including responsive breakpoints like sm:, lg:). Your host app's Tailwind build must scan the package views, otherwise these classes will be purged.

Tailwind v4 — add a @source directive in your resources/css/app.css:

@source "../../vendor/graystackit/laravel-mollie-billing/resources/views/**/*.blade.php";

Tailwind v3 — add the path to the content array in tailwind.config.js:

content: [
    // ...
    './vendor/graystackit/laravel-mollie-billing/resources/views/**/*.blade.php',
],

Without this, responsive grid layouts and other utility classes in the portal, checkout and admin panel may not render correctly.

Configuration

Highlights of config/mollie-billing.php:

Key Purpose
currency Default currency for prices and invoices (e.g. EUR).
logo_url Logo displayed in checkout and portal headers.
primary_color Accent color for checkout UI (hex, e.g. #6366f1).
dashboard_url URL the portal logo links to (e.g. your app's main dashboard). Supports route: prefix.
checkout_back_url Where the checkout "Back" link leads (default /).
checkout_countries Countries shown in checkout (regions, include, exclude).
allow_overage_default Default policy when a plan does not declare its own overage rule.
additional_countries ISO-3166 codes + VAT rates for non-EU jurisdictions.
vat_rate_overrides Map of country code to override VAT percentage.
company_name Display name used in headers, notifications and signatures.
billable_model Fully-qualified class name of your billable model.
billable_key_type uuid, ulid, or int — determines morph column shape.
user_key_type uuid, ulid, or int — primary key type of your auth user model (used e.g. for billing_country_mismatches.resolved_by_user_id).
billing_timezone IANA timezone for the customer portal display (BILLING_TIMEZONE, default UTC). Persistence and computation always remain UTC; the admin panel renders UTC. See Timezones.

Portal "back to dashboard" link

By default the portal logo links to the billing dashboard itself. Set dashboard_url to link it to your app's main dashboard instead — a "Back to dashboard" link will also appear at the bottom of the sidebar:

# Plain URL:
BILLING_DASHBOARD_URL=/dashboard

# Or a named route (prefix with "route:"):
BILLING_DASHBOARD_URL=route:dashboard

Plans and addons

Define your catalog in config/mollie-billing-plans.php. Free plans run as SubscriptionSource::Local (no Mollie subscription), paid plans are SubscriptionSource::Mollie.

<?php

return [
    'plans' => [
        'pro' => [
            'name' => 'Pro',
            'tier' => 2,
            'included_seats' => 3,
            'feature_keys' => ['dashboard', 'advanced-reports'],
            'allowed_addons' => ['softdrinks'],
            'intervals' => [
                'monthly' => [
                    'base_price_net' => 2900,
                    'seat_price_net' => 990,
                    'trial_days' => 14, // optional, per-interval trial length
                    // Included quota per billing period (here: per month)
                    'included_usages' => ['tokens' => 100, 'sms' => 50],
                    // Cents per unit over quota; omit a key for "no overage"
                    'usage_overage_prices' => ['tokens' => 10, 'sms' => 15],
                ],
                'yearly' => [
                    'base_price_net' => 29000,
                    'seat_price_net' => 9900,
                    'trial_days' => 14,
                    // Included quota per billing period (here: per year)
                    'included_usages' => ['tokens' => 1500, 'sms' => 600],
                    'usage_overage_prices' => ['tokens' => 10, 'sms' => 15],
                ],
            ],
        ],
    ],

    'addons' => [
        'softdrinks' => [
            'name' => 'Softdrinks',
            'feature_keys' => ['softdrinks'],
            'intervals' => [
                'monthly' => ['price_net' => 490],
                'yearly' => ['price_net' => 4900],
            ],
        ],
    ],
];

The Billable contract

A minimal billable model needs the trait plus one required method — getUsedBillingSeats(). This method is intentionally not provided by the trait because only your app knows how to count active seats (team members, users, etc.):

class Organization extends Model implements Billable
{
    use HasBilling;

    public function getUsedBillingSeats(): int
    {
        return $this->users()->count();
    }
}

The seat count is used during plan-change previews to calculate whether extra seats need to be purchased on the new plan.

HasBilling provides among others:

  • recordBillingUsage($type, $quantity) and creditBillingUsage(...)
  • hasPlanFeature('reports.export')
  • cancelBillingSubscription(), changeBillingPlan(...), enableBillingAddon(...)
  • billingPortalUrl(), billingPlanChangeUrl()
  • latestBillingInvoice() and billingInvoices() morph relation
  • getWallet($type) / hasWallet($type) / createWallet($data) — overridden bavix wrappers that resolve usage-type slugs case-insensitively (so tokens, Tokens, and TOKENS all hit the same wallet, and createWallet will not insert a duplicate row when a casing variant already exists). Catalog lookups (includedUsage, usageOveragePrice) follow the same case-insensitive rule. See Usage Billing — Casing of usage-type identifiers.

Coupon types

Type Behavior Quick example
SinglePayment Discounts only a single invoice (Subscription Checkout or One-Time-Order). 100 % is supported — Subscription Checkout uses a Mandate-Only flow so Mollie keeps a mandate for period 2; One-Time-Order skips Mollie entirely and writes a local 0-EUR audit invoice. MollieBilling::coupons()->singlePaymentCoupon('LAUNCH', 50, 'percent');
Recurring Discounts each invoice for N periods (Subscription Checkout or any plan-change-style flow). 100 % is supported via a deferred Mollie startDate. Not accepted on One-Time-Orders (no follow-up charges to attach to). MollieBilling::coupons()->recurringCoupon('LOYAL', 10, 'percent', periods: 6);
Credits Adds wallet credit balance. MollieBilling::coupons()->creditsCoupon('PROMO5', cents: 500);
TrialExtension Extends the active trial. MollieBilling::coupons()->trialExtensionCoupon('EXTEND14', days: 14);
AccessGrant Grants free access without payment. See below.

Access Grants

Access Grants come in two flavors — full-plan grants and addon-only grants:

use GraystackIT\MollieBilling\Facades\MollieBilling;

// Full plan access for 90 days, no payment method required:
MollieBilling::coupons()->accessGrantCoupon(
    code: 'BETA90',
    planCode: 'pro',
    interval: 'monthly',
    days: 90,
);

// Addon-only grant — the customer keeps their existing plan:
MollieBilling::coupons()->addonGrantCoupon(
    code: 'PRIORITY30',
    addonCode: 'priority_support',
    days: 30,
);

Updating subscriptions

The update orchestrator handles plan changes, addon toggles and seat sync atomically:

use GraystackIT\MollieBilling\Facades\MollieBilling;

MollieBilling::subscriptions()->update($organization, [
    'plan_code' => 'pro',
    'interval' => 'yearly',
    'addons' => ['priority_support' => true],
    'seats' => 12,
    'apply' => 'immediate', // or 'end_of_period'
]);

Local subscriptions

The package distinguishes two subscription sources via the subscription_source column:

  • mollie — a real Mollie subscription with mandate, recurring charges and invoices.
  • local — a free / coupon-granted subscription with no Mollie mandate. The wallet receives the included usages on activation (and at scheduled renewals via PrepareUsageOverageJob), but no money flows.

When does a Local subscription arise?

Trigger Service Notes
Free plan checkout StartSubscriptionCheckoutActivateLocalSubscription Mollie returns no checkout_url for a 0 € first payment; the app activates the plan locally.
AccessGrant coupon CouponService::applyAccessGrantActivateLocalSubscription Coupon-granted plans (timed or unlimited) live as Local.
Mollie → Free downgrade UpdateSubscription Cancels the Mollie subscription, sets subscription_source = local, status remains active, wallets are rebalanced (purchased credits preserved).

What is allowed on a Local subscription?

A Local subscription has no Mollie mandate, so no money can flow from the customer. Anything that would result in a charge is blocked.

Operation Allowed?
Free addons (price 0) yes
Paid addons noLocalSubscriptionDoesNotSupportPaidExtrasException
Extra seats on a plan with seat_price_net > 0 no — same exception
Switch to another free plan yes
Switch directly to a paid plan via UpdateSubscription noLocalSubscriptionUpgradeRequiresMolliePathException. Use UpgradeLocalToMollie instead (the bundled plan-change UI does this automatically).
Track metered usage (recordBillingUsage) yes — included quota is credited and reset at period boundaries
Charge the customer for usage overages noPrepareUsageOverageJob only charges Mollie + mandate billables. Negative balances on Local subs are silently reset at the next period. See docs/usage-billing.md.
Purchase one-time products opt-in via config('mollie-billing.local_subscription.allow_one_time_orders'). Default is false — purchase attempts throw LocalSubscriptionCannotPurchaseProductsException and the products page hides the buy buttons. Set to true if your business model treats the free plan as a default tier monetised through token packs etc.
Cancel yes — status switches to cancelled, wallets are kept until subscription_ends_at.

If you need to bill free-tier users for usage overages or sell them seat upgrades, do not ship the plan at price 0. Set the lowest non-zero amount that still makes commercial sense — that triggers the regular Mollie checkout, captures a mandate, and makes the user a paid (Mollie-source) subscriber.

Local → Mollie upgrade

use GraystackIT\MollieBilling\Services\Billing\UpgradeLocalToMollie;

['checkout_url' => $url, 'payment_id' => $id] = app(UpgradeLocalToMollie::class)->handle($organization, [
    'plan_code'   => 'pro',
    'interval'    => 'monthly',
    'addon_codes' => [],
    'extra_seats' => 0,
    'amount_gross' => $previewedGross, // pre-computed by PreviewService
]);

return redirect()->away($url);

The webhook on the resulting first payment carries metadata.upgrade_from_local = true and routes through MollieWebhookController::handleLocalToMollieUpgrade(), which reuses the existing wallet (purchased balance preserved) instead of seeding a fresh one.

The bundled plan-change UI (resources/views/livewire/billing/⚡plan-change.blade.php) detects Local → paid plan automatically and shows a confirmation step before the Mollie redirect — no second checkout wizard.

Mollie → Free behaviour

A user-initiated downgrade follows whatever config('mollie-billing.plan_change_mode') is set to (Immediate, EndOfPeriod, UserChoice). For EndOfPeriod, ScheduleSubscriptionChange queues the change and PrepareUsageOverageJob applies it at the period boundary — the same Mollie cancel + Source=Local flip happens then.

purchased_balance (one-time orders, coupon credits) is preserved across every plan change, including downgrades to free.

Preview

Preview the financial impact of an update before applying it:

$preview = MollieBilling::preview()->previewUpdate($organization, [
    'plan_code' => 'pro',
    'interval' => 'yearly',
]);

// $preview->prorataCredit, $preview->newChargeGross, $preview->vatAmount, ...

Refunds

Three convenience methods cover the common cases:

use GraystackIT\MollieBilling\Facades\MollieBilling;

// Refund a full invoice and issue a credit note:
MollieBilling::refunds()->refundFully($invoice, RefundReasonCode::BillingError);

// Partial refund of a specific net amount (in cents):
MollieBilling::refunds()->refundPartially($invoice, 500, RefundReasonCode::Goodwill, 'customer request');

// Refund specific overage units (auto-calculates amount from unit price, credits wallet):
MollieBilling::refunds()->refundOverageUnits($invoice, 'tokens', 1_000, RefundReasonCode::Goodwill);

// Wallet-only credit without touching Mollie — use WalletUsageService directly:
app(WalletUsageService::class)->credit($organization, 'tokens', 500, 'goodwill bonus');

Admin panel

The admin panel lives at /billing/admin and requires livewire/flux-pro. Authorize access by implementing AuthorizesBillingAdmin directly on your user model. The billing.admin middleware checks auth()->user() instanceof AuthorizesBillingAdmin && canAccessBillingAdmin(); users without the interface receive a 403.

<?php

namespace App\Models;

use GraystackIT\MollieBilling\Contracts\AuthorizesBillingAdmin;
use Illuminate\Foundation\Auth\User as Authenticatable;

class User extends Authenticatable implements AuthorizesBillingAdmin
{
    public function canAccessBillingAdmin(): bool
    {
        return $this->is_admin === true;
    }
}

Promotion links

Generate signed promotion URLs that auto-apply a coupon when the customer follows them:

https://your-app.test/promotion/{token}

Tokens are generated via MollieBilling::coupons()->promotionToken($coupon).

Events

Every state change dispatches a Laravel event so apps can react via listeners. Notable events include:

  • CheckoutStarted, CheckoutAbandoned
  • SubscriptionCreated, SubscriptionCancelled, SubscriptionExpired, SubscriptionResumed
  • PlanChanged, SubscriptionUpdated, SubscriptionChangeScheduled
  • TrialStarted, TrialConverted, TrialExpired, TrialExtended
  • MandateUpdated
  • PaymentSucceeded, PaymentFailed, PaymentAmountMismatch, DuplicatePaymentReceived
  • InvoiceCreated, InvoiceRefunded, CreditNoteIssued
  • OverageCharged, OverageChargeFailed
  • CouponRedeemed, GrantRevoked
  • CountryMismatchFlagged, CountryMismatchResolved
  • WalletCredited, UsageLimitReached

Subscribe in your EventServiceProvider exactly like any other Laravel event.

Testing

The package ships with helpers for both unit and feature tests:

use GraystackIT\MollieBilling\Facades\MollieBilling;
use GraystackIT\MollieBilling\Testing\TestBillable;
use GraystackIT\MollieBilling\Testing\BillableStateHelper;

MollieBilling::fake();

$billable = TestBillable::factory()->create();
BillableStateHelper::onPaidPlan($billable, 'pro', 'monthly');

$billable->recordBillingUsage('api_calls', 1_500);

MollieBilling::assertSubscriptionStarted($billable);

Commands

# Re-queue overage charges for everyone whose period ended in the last hour
php artisan billing:prepare-overage

# Export the OSS report for a given calendar year (writes to the configured
# OSS disk — S3-compatible — and persists a downloadable row that the admin
# panel surfaces alongside queued/admin-triggered exports)
php artisan billing:oss-export 2026

# Validate the syntax and semantic integrity of mollie-billing.php and mollie-billing-plans.php
php artisan billing:check-config

# Delete billables that were created during checkout but never reached an active
# subscription (abandoned tabs, expired Mollie sessions, captured-but-unused
# mandates). Runs every 15 minutes via the scheduler in normal operation; this
# command is for manual / on-demand cleanup.
php artisan billing:cleanup-orphans

billing:check-config reports two classes of issues:

  • Errors — broken references or invalid values that will cause runtime failures (missing billable_model, plan feature_keys pointing at undefined features, invoices.disk not declared in config/filesystems.php, invalid serial-number format, unknown plan_change_mode, …). Exits with status 1.
  • Warnings — likely misconfigurations that don't break the app but degrade behavior (incomplete invoice seller data, included_usages quota without a matching usage_overage_prices entry, features defined but never referenced, ambiguous tier ranking, …). Exit status stays 0.

Run it after editing either config file or as part of CI to catch typos before deployment.

Documentation

Detailed technical documentation is available in the docs/ directory:

  • Configurationmollie-billing.php and mollie-billing-plans.php reference
  • Plan Changes — deferred upgrade flow, validation rules, events, extension points
  • Subscription Lifecycle — states, transitions, service overview
  • Lifecycle and Cleanup — orphaned checkouts, billable deletion cascade, past-due auto-cancel, paid-without-billable reconciliation, mandate policy
  • VAT Handling — VAT calculation, VIES, OSS, country reconciliation, automatic resolution
  • Timezones — UTC persistence and computation, per-user portal timezone, UTC-rendered admin views

Architecture

This package wraps mollie/laravel-mollie ^4 and adds a VAT/OSS layer (mpociot/vat-calculator plus VIES), a wallet layer for metered billing (bavix/laravel-wallet), a coupon engine, a built-in first-checkout wizard, an admin panel and a Livewire 4 customer portal. Subscription lifecycle is split into single-purpose service classes per action (Start, Create, Activate, Change, Cancel, Resubscribe, EnableAddon, DisableAddon, SyncSeats) — the HasBilling trait delegates to them via the container, so apps customize behavior by rebinding services rather than subclassing models. Extension points are provided via facade callbacks (createBillableUsing, beforeCheckoutUsing, afterCheckoutUsing, resolveBillableUsing, etc.) and events.

Webhook handling is similarly split: MollieWebhookController is a thin façade that reserves the payment, fetches it from Mollie, and routes by payment type to a dedicated handler in src/Services/Webhook/ (FirstPaymentHandler, MandateOnlyPaymentHandler, SubscriptionPaymentHandler, ProrataChargeHandler, SingleChargeHandler, CountryCorrectionHandler, LocalToMollieUpgradeHandler, OneTimeOrderHandler, RefundHandler). Each handler is auto-resolved from the container — apps can rebind any of them to customize per-payment-type behavior without touching the controller.

Free or zero-price plans run as SubscriptionSource::Local without a Mollie subscription; paid plans are SubscriptionSource::Mollie. Paid plans with a configured trial_days go through a Mandate-Only checkout (0 EUR, captures the payment method) and create the Mollie subscription with startDate = now + trial_days — no charge during the trial, status = trial, wallet hydrated aliquot to the trial length. See Subscription Lifecycle.

License

The MIT License (MIT). See LICENSE for details.

Credits

统计信息

  • 总下载量: 0
  • 月度下载量: 0
  • 日度下载量: 0
  • 收藏数: 0
  • 点击次数: 4
  • 依赖项目数: 0
  • 推荐数: 0

GitHub 信息

  • Stars: 0
  • Watchers: 0
  • Forks: 0
  • 开发语言: PHP

其他信息

  • 授权协议: MIT
  • 更新时间: 2026-05-12

承接程序开发

PHP开发

VUE

Vue开发

前端开发

小程序开发

公众号开发

系统定制

数据库设计

云部署

网站建设

安全加固