Skip to content

Custom Billing Setup

Unlike packages like Laravel Cashier that enforce a specific database structure, this package gives you the flexibility to define your own models and migrations. This is useful because Paystack doesn't manage invoices and subscriptions in the exact same way as Stripe or Paddle, so you might want full control over how you store this data.

Note: The codes, migrations, and model methods shown below are not included in the package. They are purely examples to guide you on how you might structure your application to handle subscriptions and customers.

We recommend creating three dedicated models to handle your Paystack integration:

  1. PaystackCustomer: Links your local model (e.g., User, School) to a Paystack Customer.
  2. PaystackSubscription: Tracks subscription status and validity.
  3. PaystackTransaction: Logs payments and their status.

1. Database Migrations

Create the migrations for your tables. You can name them whatever you like, but here is a recommended schema.

Paystack Customers Table

php
Schema::create('paystack_customers', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id'); // or tenant_id, etc.
    $table->string('paystack_id')->unique()->index(); // e.g., CUS_xxxx
    $table->string('email');
    $table->string('pm_type')->nullable(); // e.g., 'visa' or 'mobile_money'
    $table->string('pm_last_four', 4)->nullable();
    $table->timestamps();
});

Paystack Subscriptions Table

php
Schema::create('paystack_subscriptions', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id'); // or tenant_id, etc.
    $table->string('name'); // e.g., 'default'
    $table->string('paystack_id')->unique(); // SUB_xxxx
    $table->string('paystack_status'); // active, non-renewing, attention
    $table->string('paystack_plan'); // PLN_xxxx
    $table->timestamp('trial_ends_at')->nullable();
    $table->timestamp('ends_at')->nullable();
    $table->timestamp('cancelled_at')->nullable();
    $table->timestamps();
});

Paystack Transactions Table

php
Schema::create('paystack_transactions', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id'); // or tenant_id, etc.
    $table->string('paystack_id')->unique(); // The transaction ID from Paystack
    $table->string('reference')->unique(); // The unique reference you generated
    $table->unsignedBigInteger('amount'); // Store in pesewas (subunits)
    $table->string('status'); // success, failed
    $table->string('currency')->default('GHS');
    $table->json('metadata')->nullable();
    $table->timestamps();
});

2. Eloquent Models

Create the corresponding models for these tables.

PaystackCustomer

php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class PaystackCustomer extends Model
{
    protected $fillable = ['paystack_id', 'email', 'pm_type', 'pm_last_four'];

    // If using User model
    public function user()
    {
        return $this->belongsTo(User::class);
    }
}

PaystackSubscription

php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class PaystackSubscription extends Model
{
    protected $fillable = ['name', 'paystack_id', 'paystack_status', 'paystack_plan', 'trial_ends_at', 'ends_at', 'cancelled_at'];

    protected $casts = [
        'trial_ends_at' => 'datetime',
        'ends_at' => 'datetime',
        'cancelled_at' => 'datetime',
    ];

    public function user()
    {
        return $this->belongsTo(User::class);
    }

    /**
     * 1. On Trial: They are in the 14-day window.
     */
    public function onTrial(): bool
    {
        // Check if the status is trialing OR if the trial date hasn't passed yet
        return $this->paystack_status === 'trialing'
            || ($this->trial_ends_at && $this->trial_ends_at->isFuture());
    }

    /**
     * 2. Is Active: They have a valid paid subscription that hasn't expired.
     */
    public function isActive(): bool
    {
        // Status must be active AND the expiration date must be in the future
        return $this->paystack_status === 'active'
            && $this->ends_at
            && $this->ends_at->isFuture();
    }

    /**
     * 3. Subscription Ended / Expired:
     * Logic: They are not on trial AND their paid subscription date has passed.
     */
    public function subscriptionEnded(): bool
    {
        // If they are on trial, it hasn't ended.
        if ($this->onTrial()) return false;

        // If they have no end date, or the end date is in the past
        return ! $this->ends_at || $this->ends_at->isPast();
    }

    /**
     * 4. On Grace Period:
     * Logic: They have cancelled but the subscription period hasn't ended yet
     */
    public function onGracePeriod(): bool
    {
        // They are only on a grace period if they cancelled but the date hasn't hit yet
        return $this->cancelled_at && $this->ends_at && $this->ends_at->isFuture();
    }
}

PaystackTransaction

php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class PaystackTransaction extends Model
{
    protected $fillable = ['paystack_id', 'reference', 'amount', 'status', 'currency', 'metadata'];
    protected $casts = ['metadata' => 'array'];

    public function user()
    {
        return $this->belongsTo(User::class);
    }

    // Accessor to show GH₵
    public function getAmountInGhcAttribute()
    {
        return $this->amount / 100;
    }
}

3. Setup Billable Model

Finally, add the relationships to your "Billable" model (e.g., User, Team, or School).

php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use App\Models\PaystackCustomer;
use App\Models\PaystackSubscription;
use App\Models\PaystackTransaction;

class User extends Model
{
    public function paystackCustomer()
    {
        return $this->hasOne(PaystackCustomer::class);
    }

    public function subscriptions()
    {
        return $this->hasMany(PaystackSubscription::class);
    }

    public function transactions()
    {
        return $this->hasMany(PaystackTransaction::class);
    }
}

Released under the MIT License.