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
- Architecture Overview
- Database Schema
- Promo Code System
- Admin Dashboard & Statistics
- Promo Code CRUD
- Filtering & Multi-Currency
- Frontend: Payments & Promo Codes
- Events & Notifications
- Testing
- 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
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)
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;
}
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),
};
}
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';
}
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
);
}
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();
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,
];
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):
-
codeanddiscount_typeare excluded - immutable after creation -
expires_atcan 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,
};
}
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'),
};
}
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 { ... }
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
});
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');
});
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);
});
Design Decisions
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.
Failed payments don't consume promo codes. The single_use_per_user check only counts successful payments. Users aren't penalized for payment failures.
Immutable code and discount_type. Once created, you can't change the code string or switch between percentage/fixed. This prevents audit trail corruption.
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.
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.
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.
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.
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.
Top comments (0)