DEV Community

Cover image for Building a Multi-Currency Payment System with Promo Codes for a Laravel SaaS
Dmitry Isaenko
Dmitry Isaenko

Posted on

Building a Multi-Currency Payment System with Promo Codes for a Laravel SaaS

Payments. The module where "just integrate Stripe" turns into multi-currency revenue tracking, promo codes with 4-level validation chains, admin dashboards with configurable currency conversion, and a full audit trail on every discount ever applied.

I built Kohana.io - a production CRM/ERP for small businesses. The payment module handles real money from real customers across multiple currencies. Now I'm extracting it into LaraFoundry, an open-source SaaS framework for Laravel.

This post covers the full implementation.

Table of Contents

  1. Architecture Overview
  2. Database Schema
  3. Promo Code System
  4. Admin Dashboard & Statistics
  5. Promo Code CRUD
  6. Filtering & Multi-Currency
  7. Frontend: Payments & Promo Codes
  8. Events & Notifications
  9. Testing
  10. Design Decisions

Architecture Overview

LaraFoundry's payment module has two main parts: payment tracking and promo code management. Both are admin-only - end users interact with payments through the subscription flow, while admins get the full picture.

Payment Tracking:

  • View all company payments with full metadata
  • Revenue statistics with multi-currency support
  • Advanced filtering: period, status, country, plan, promo code, email, company
  • Latest payment per company detection (subscription expiration)

Promo Code Management:

  • Full CRUD with 7 endpoints
  • Two discount types: percentage and fixed
  • Four constraint levels: active, not expired, usage limit, per-user limit
  • Personal codes tied to specific users
  • Toggle active status with one click

The module is built with:

  • 2 controllers: PaymentController (read-only), PromoCodeController (full CRUD)
  • 2 models: CompanyPayment, PromoCode
  • 2 dedicated filter classes
  • 4 form request classes
  • 5 Vue pages + 2 components

Database Schema

company_payments

company_payments
├── id (PK)
├── company_id (FK → companies, cascade)
├── user_id (FK → users, cascade)
├── plan_id (string) - subscription plan
├── billing_period (enum: monthly, yearly)
├── amount (decimal 10,2) - original amount
├── currency (string 3, default 'USD')
├── discount_amount (decimal 10,2, default 0)
├── discount_reason (string, nullable) - 'free_month', 'promo_code', etc.
├── promo_code_id (FK → promo_codes, nullable, nullOnDelete)
├── payment_status (enum: pending, success, failed)
├── payment_method (string, nullable) - 'Stripe', 'LiqPay'
├── payment_response (json, nullable) - raw gateway response
├── paid_at (timestamp, nullable)
├── period_start (date)
├── period_end (date)
└── timestamps
Indexes: (company_id, payment_status), plan_id, user_id
Enter fullscreen mode Exit fullscreen mode

The key design: every payment carries its own discount data. The discount_amount, discount_reason, and promo_code_id are stored on the payment itself - not calculated from the promo code at display time. This means even if a promo code is deleted, you still know exactly what discount was applied and why.

promo_codes

promo_codes
├── id (PK)
├── user_id (FK → users, nullable, nullOnDelete) - personal codes
├── code (string 50, unique) - uppercase alphanumeric
├── description (text, nullable) - internal notes
├── discount_type (enum: percentage, fixed)
├── discount_value (decimal 10,2)
├── max_uses (int, nullable) - global limit
├── used_count (int, default 0)
├── single_use_per_user (boolean, default true)
├── is_active (boolean, default true)
├── expires_at (timestamp, nullable)
└── timestamps
Indexes: code, (is_active, expires_at)
Enter fullscreen mode Exit fullscreen mode

Promo Code System

The promo code model is the most logic-heavy part of the module. Four methods handle everything:

Validation Chain

// PromoCode model

public function isValid(): bool
{
    return $this->is_active
        && ($this->expires_at === null || $this->expires_at->isFuture());
}

public function canBeUsed(?int $userId): bool
{
    if (! $this->isValid()) {
        return false;
    }

    if ($this->isPersonal() && $this->user_id !== $userId) {
        return false;
    }

    if ($this->max_uses !== null && $this->used_count >= $this->max_uses) {
        return false;
    }

    if ($this->single_use_per_user && $userId) {
        $alreadyUsed = $this->payments()
            ->where('user_id', $userId)
            ->where('payment_status', 'success')
            ->exists();
        if ($alreadyUsed) {
            return false;
        }
    }

    return true;
}
Enter fullscreen mode Exit fullscreen mode

The order matters: cheapest checks first (boolean flags), expensive checks last (database query for per-user usage).

Discount Calculation

public function calculateDiscount(float $amount): float
{
    return match ($this->discount_type) {
        'percentage' => round($amount * $this->discount_value / 100, 2),
        'fixed' => min($this->discount_value, $amount),
    };
}
Enter fullscreen mode Exit fullscreen mode

Fixed discounts are capped at the payment amount - a $50 discount on a $30 plan gives $30 off, not negative $20.

Status Detection

public function getStatus(): string
{
    if (! $this->is_active) {
        return 'inactive';
    }
    if ($this->expires_at && $this->expires_at->isPast()) {
        return 'expired';
    }
    if ($this->max_uses !== null && $this->used_count >= $this->max_uses) {
        return 'exhausted';
    }
    return 'active';
}
Enter fullscreen mode Exit fullscreen mode

Four possible statuses. Used for both backend filtering and frontend badge colors.

Personal Promo Codes

When user_id is set on a promo code, it becomes personal - only that specific user can use it. The admin creates these through a user search autocomplete that queries by email, first name, and last name.


Admin Dashboard & Statistics

The PaymentController@index returns a rich data structure:

Revenue Totals

// Simplified logic
$totals = CompanyPayment::query()
    ->selectRaw('currency, SUM(amount - discount_amount) as total')
    ->when($statusFilter, fn ($q) => $q->where('payment_status', $statusFilter))
    ->groupBy('currency')
    ->get();

// Convert to admin display currencies
foreach ($totals as $total) {
    $converted = CurrencyConverter::convert(
        $total->total,
        $total->currency,
        $adminDisplayCurrencies
    );
}
Enter fullscreen mode Exit fullscreen mode

The totals respect all active filters - period, status, country, plan. When filtering by "failed" status, the totals switch to show failed payment amounts, visually highlighted so the admin doesn't confuse them with revenue.

Latest Payment Per Company

$latestPayments = CompanyPayment::query()
    ->select('company_id', DB::raw('MAX(paid_at) as max_paid_at'))
    ->where('payment_status', 'success')
    ->groupBy('company_id')
    ->get();
Enter fullscreen mode Exit fullscreen mode

Each payment row in the table checks if it's the latest for its company. If yes, the admin sees the plan name with an expiration indicator - instantly showing which subscriptions are about to end.

AdminPaymentResource

The API resource formats each payment row:

return [
    'id' => $this->id,
    'paid_at' => $this->paid_at?->format('Y-m-d H:i:s'),
    'paid_at_date' => $this->paid_at?->format('d-m-Y'),
    'paid_at_time' => $this->paid_at?->format('H:i'),
    'user_email' => $this->user->email,
    'user_fullname' => $this->user->fullname,
    'company_name' => $this->company->name,
    'company_country' => $this->company->country,
    'plan_name' => $planConfig['name'] ?? $this->plan_id,
    'amount' => $this->amount,
    'currency' => $this->currency,
    'promo_code' => $this->promoCode?->code,
    'discount_amount' => $this->discount_amount,
    'total_amount' => $this->getTotalAmount(),
    'payment_status' => $this->payment_status,
    'payment_method' => $this->payment_method,
    'payment_response' => $this->payment_response,
    'is_latest_for_company' => $this->is_latest_for_company,
];
Enter fullscreen mode Exit fullscreen mode

Promo Code CRUD

7 endpoints handle the full lifecycle:

Method Endpoint Action
GET /admin/promo-codes Index with filters
GET /admin/promo-codes/create Create form
POST /admin/promo-codes Store
GET /admin/promo-codes/{id}/edit Edit form
PUT /admin/promo-codes/{id} Update
PATCH /admin/promo-codes/{id}/toggle Toggle active
GET /admin/promo-codes/search-users User autocomplete

Validation Rules

Store (StorePromoCodeRequest):

  • code: required, max 50, regex /^[A-Z0-9_-]+$/, unique
  • discount_type: required, in: percentage, fixed
  • discount_value: required, numeric, min 0, max 100 (for percentage only)
  • max_uses: nullable, integer, min 1
  • single_use_per_user: boolean, default true
  • expires_at: nullable, date, must be after now
  • user_id: nullable, must exist in users table
  • is_active: boolean

Update (UpdatePromoCodeRequest):

  • code and discount_type are excluded - immutable after creation
  • expires_at can be set to past dates (to manually expire a code)
  • All other fields follow the same rules

The immutability of code and discount_type is intentional: existing payment records reference these values. Changing them would break the audit trail.

Toggle

A dedicated PATCH endpoint flips is_active. One click, one request. No form, no page navigation.

User Search

For personal promo codes, the admin types a name or email. The backend searches users by email, name, and lastname with a minimum of 2 characters, returning max 10 results. The Vue frontend shows these in an autocomplete dropdown.


Filtering & Multi-Currency

AdminPaymentsFilter

The filter class uses method-per-filter pattern:

public function period(string $value): Builder
{
    return match ($value) {
        'this_month' => $this->builder->whereBetween(
            DB::raw('COALESCE(paid_at, created_at)'),
            [now()->startOfMonth(), now()->endOfMonth()]
        ),
        'last_month' => $this->builder->whereBetween(
            DB::raw('COALESCE(paid_at, created_at)'),
            [now()->subMonth()->startOfMonth(), now()->subMonth()->endOfMonth()]
        ),
        'year' => $this->builder->whereYear(
            DB::raw('COALESCE(paid_at, created_at)'),
            now()->year
        ),
        'range' => $this->builder, // handled by date_from/date_to
        default => $this->builder,
    };
}
Enter fullscreen mode Exit fullscreen mode

The COALESCE(paid_at, created_at) pattern ensures pending and failed payments (which have no paid_at) are still filterable by date.

Smart default: when filtering by promo_code_id, the period automatically switches to "all time" so you see every payment that used that code.

AdminPromoCodesFilter

Status filtering uses SQL conditions matching the model's getStatus() logic:

public function status(string $value): Builder
{
    return match ($value) {
        'active' => $this->builder
            ->where('is_active', true)
            ->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))
            ->where(fn ($q) => $q->whereNull('max_uses')->orWhereColumn('used_count', '<', 'max_uses')),
        'inactive' => $this->builder->where('is_active', false),
        'expired' => $this->builder->where('expires_at', '<', now()),
        'exhausted' => $this->builder->whereNotNull('max_uses')->whereColumn('used_count', '>=', 'max_uses'),
    };
}
Enter fullscreen mode Exit fullscreen mode

Frontend: Payments & Promo Codes

Payments Table (PaymentsTable.vue)

The main payments view includes:

Filter bar:

  • Period selector with 5 options (this month, last month, year, all time, custom range)
  • Date range pickers (enabled only when "Range" is selected)
  • Status, country, plan, promo code dropdowns
  • Email and company name text search
  • Clear all filters button

Revenue totals:

  • Displayed at the top, auto-updated with filter changes
  • Multiple currencies separated by " / "
  • Failed totals highlighted in a different color

Payment rows:

  • Date (date + time formatted separately)
  • User (email, full name)
  • Company (name, country)
  • Plan with expiration indicator for latest payment
  • Price breakdown: amount → promo code → discount → total
  • Status badge
  • Gateway info with JSON tooltip
  • Document links (Invoice, Receipt)

Responsive: Desktop shows a full table. Mobile switches to vertical card layout with the same data.

Promo Codes (PromoCodesTab.vue)

Tabbed alongside payments. Each row shows:

  • Code (bold)
  • Description
  • Formatted discount (e.g., "20%" or "$50")
  • Usage counter (e.g., "3 / 10" or "Unlimited")
  • Usage type badge ("1x per user" or "Multi-use")
  • Personal/general badge (with email for personal)
  • Expiration date or "Never expires"
  • Color-coded status badge: Active (green), Inactive (gray), Expired (yellow), Exhausted (red)
  • Actions: toggle active, edit, view payments (with count)

Create/Edit Promo Code

Create form includes a "Generate" button that produces a random 8-character uppercase alphanumeric code.

Edit form displays immutable fields (code, discount type, usage count) in a read-only info block at the top, with editable fields below.


Events & Notifications

Three components handle post-payment processing:

// CompanyPaymentProcessed event
class CompanyPaymentProcessed
{
    public function __construct(
        public CompanyPayment $payment
    ) {}
}

// Queued jobs
class NotifyOwnerAboutPaymentSuccess implements ShouldQueue { ... }
class NotifyOwnerAboutPaymentFailed implements ShouldQueue { ... }
Enter fullscreen mode Exit fullscreen mode

When a payment is processed, the event fires. Listeners dispatch queued jobs that notify the company owner about success or failure. The jobs run asynchronously - the payment flow isn't blocked by email/notification delivery.


Testing

Three test files cover every scenario:

PaymentControllerTest (Feature)

test('admin can view payments with revenue statistics', function () {
    // create payments in different currencies with discounts
    // assert totals are calculated correctly: sum(amount - discount_amount)
    // assert currency conversion works
    // assert latest payment per company is detected
});

test('period filter uses COALESCE for pending payments', function () {
    // create pending payment (no paid_at)
    // filter by this month
    // assert pending payment appears (filtered by created_at)
});

test('promo code filter switches period to all time', function () {
    // create payments across different months
    // filter by promo_code_id
    // assert all payments with that code appear regardless of date
});
Enter fullscreen mode Exit fullscreen mode

PromoCodeControllerTest (Feature)

test('code and discount_type cannot be changed after creation', function () {
    $promo = PromoCode::factory()->create([
        'code' => 'ORIGINAL',
        'discount_type' => 'percentage',
    ]);

    actingAs($admin)
        ->put(route('admin.promo-codes.update', $promo), [
            'code' => 'CHANGED', // should be ignored
            'discount_type' => 'fixed', // should be ignored
        ]);

    expect($promo->fresh())
        ->code->toBe('ORIGINAL')
        ->discount_type->toBe('percentage');
});
Enter fullscreen mode Exit fullscreen mode

PromoCodeTest (Unit)

test('failed payments do not consume single use per user quota', function () {
    $promo = PromoCode::factory()->create(['single_use_per_user' => true]);

    CompanyPayment::factory()->create([
        'promo_code_id' => $promo->id,
        'user_id' => $user->id,
        'payment_status' => 'failed',
    ]);

    expect($promo->canBeUsed($user->id))->toBeTrue();
});

test('percentage discount calculates correctly', function () {
    $promo = PromoCode::factory()->create([
        'discount_type' => 'percentage',
        'discount_value' => 25,
    ]);

    expect($promo->calculateDiscount(100))->toBe(25.00);
    expect($promo->calculateDiscount(50))->toBe(12.50);
});

test('fixed discount is capped at payment amount', function () {
    $promo = PromoCode::factory()->create([
        'discount_type' => 'fixed',
        'discount_value' => 50,
    ]);

    expect($promo->calculateDiscount(30))->toBe(30.00);
});
Enter fullscreen mode Exit fullscreen mode

Design Decisions

  1. Discount on the payment, not computed from promo code. Each payment stores its own discount_amount and discount_reason. Even if the promo code is deleted or modified, the payment history stays accurate.

  2. Failed payments don't consume promo codes. The single_use_per_user check only counts successful payments. Users aren't penalized for payment failures.

  3. Immutable code and discount_type. Once created, you can't change the code string or switch between percentage/fixed. This prevents audit trail corruption.

  4. COALESCE for date filtering. Pending and failed payments don't have paid_at, so we fall back to created_at. Every payment is always filterable.

  5. Personal promo codes via user_id. Instead of a separate table or a complex system, a nullable FK on the promo code turns it into a personal discount. Simple, effective.

  6. Payment response as JSON. The raw gateway response is stored on the payment. No separate table, no logging service. The admin sees it in a tooltip for debugging.

  7. Latest payment detection per company. Calculated on the fly with a MAX(paid_at) subquery. No denormalization, no cron job. The admin always sees current data.

  8. Toggle as a separate endpoint. Activating/deactivating a promo code is the most common admin action. A dedicated PATCH endpoint makes it a single click.


This is module 13 of the LaraFoundry series. Built with Laravel 12, Inertia.js v2, Vue 3, and Pest. The code runs in production at Kohana.io.

Follow along on LinkedIn and GitHub.

Top comments (0)