Add Ladill POS v1 — register, Pay checkout, and commerce links.
Deploy Ladill Mini / deploy (push) Successful in 23s

Staff-facing counter register at pos.ladill.com with catalog cart, cash and
MoMo/card checkout via Ladill Pay, CRM timeline/import, invoice prefill, and
Merchant catalog import.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-23 22:52:24 +00:00
co-authored by Cursor
commit e5d2b84388
378 changed files with 41419 additions and 0 deletions
@@ -0,0 +1,64 @@
<?php
namespace App\Http\Controllers\Mini;
use App\Http\Controllers\Controller;
use App\Models\MiniPayment;
use App\Models\QrCode;
use App\Services\Billing\BillingClient;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
use Throwable;
class OverviewController extends Controller
{
public function __construct(private BillingClient $billing) {}
public function index(Request $request): View
{
$account = ladill_account();
$qrIds = $account->qrCodes()
->where('type', QrCode::TYPE_PAYMENT)
->pluck('id');
$todayStart = now()->startOfDay();
$todayPayments = MiniPayment::query()
->whereIn('qr_code_id', $qrIds)
->where('status', MiniPayment::STATUS_PAID)
->where('paid_at', '>=', $todayStart);
$todayCount = (clone $todayPayments)->count();
$todayMinor = (int) (clone $todayPayments)->sum('merchant_amount_minor');
$recentPayments = MiniPayment::query()
->whereIn('qr_code_id', $qrIds)
->where('status', MiniPayment::STATUS_PAID)
->with('qrCode')
->latest('paid_at')
->limit(8)
->get();
$paymentQrCount = $qrIds->count();
$balanceMinor = 0;
try {
$balanceMinor = $this->billing->balanceMinor($account->public_id);
} catch (Throwable $e) {
Log::warning('Mini dashboard could not load wallet balance', [
'user' => $account->public_id,
'error' => $e->getMessage(),
]);
}
return view('mini.dashboard', [
'todayCount' => $todayCount,
'todayMinor' => $todayMinor,
'paymentQrCount' => $paymentQrCount,
'recentPayments' => $recentPayments,
'balanceMinor' => $balanceMinor,
]);
}
}
@@ -0,0 +1,180 @@
<?php
namespace App\Http\Controllers\Mini;
use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Services\Qr\QrCodeManagerService;
use App\Services\Qr\QrImageGeneratorService;
use App\Services\Qr\QrPdfExporter;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\View\View;
use RuntimeException;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
class PaymentQrController extends Controller
{
public function __construct(
private QrCodeManagerService $manager,
private QrImageGeneratorService $imageGenerator,
private QrPdfExporter $pdfExporter,
) {}
public function index(Request $request): View
{
$account = ladill_account();
$qrCodes = $account->qrCodes()
->where('type', QrCode::TYPE_PAYMENT)
->latest()
->get();
$previewDataUris = $qrCodes->mapWithKeys(function (QrCode $qr) {
return [$qr->id => $this->imageGenerator->previewDataUri($qr)];
});
return view('mini.payment-qrs.index', [
'qrCodes' => $qrCodes,
'previewDataUris' => $previewDataUris,
]);
}
public function create(): View
{
return view('mini.payment-qrs.create');
}
public function store(Request $request): RedirectResponse
{
$account = ladill_account();
$data = $request->validate([
'label' => 'required|string|max:120',
'business_name' => 'required|string|max:120',
'branch_label' => 'nullable|string|max:80',
]);
$data['type'] = QrCode::TYPE_PAYMENT;
$data['currency'] = 'GHS';
try {
$qrCode = $this->manager->create($account, $data);
} catch (RuntimeException $e) {
return back()->withInput()->with('error', $e->getMessage());
}
return redirect()->route('mini.payment-qrs.show', $qrCode)->with('success', 'Payment QR created.');
}
public function show(QrCode $paymentQr): View
{
$this->authorizePaymentQr($paymentQr);
$previewDataUri = $this->imageGenerator->previewDataUri($paymentQr);
return view('mini.payment-qrs.show', [
'qrCode' => $paymentQr->fresh(),
'previewDataUri' => $previewDataUri,
]);
}
public function update(Request $request, QrCode $paymentQr): RedirectResponse
{
$this->authorizePaymentQr($paymentQr);
$data = $request->validate([
'label' => 'sometimes|string|max:120',
'business_name' => 'sometimes|string|max:120',
'branch_label' => 'nullable|string|max:80',
'is_active' => 'sometimes|boolean',
]);
$data['is_active'] = $request->boolean('is_active', $paymentQr->is_active);
try {
$this->manager->update($paymentQr, $data);
} catch (RuntimeException $e) {
return back()->withInput()->with('error', $e->getMessage());
}
return back()->with('success', 'Payment QR updated.');
}
public function destroy(QrCode $paymentQr): RedirectResponse
{
$this->authorizePaymentQr($paymentQr);
$this->manager->delete($paymentQr);
return redirect()
->route('mini.payment-qrs.index')
->with('success', 'Payment QR deleted.');
}
public function preview(QrCode $paymentQr): Response
{
$this->authorizePaymentQr($paymentQr);
$qrCode = $this->imageGenerator->ensureValidImages($paymentQr);
$bytes = $this->imageGenerator->normalizeStoredPng($qrCode->png_path);
if ($bytes === null) {
$bytes = $this->imageGenerator->renderPng($qrCode->encodedPayload(), $qrCode->style());
$this->imageGenerator->generateAndStore($qrCode);
}
return response($bytes, 200, [
'Content-Type' => 'image/png',
'Cache-Control' => 'private, max-age=3600',
]);
}
public function download(QrCode $paymentQr, string $format): StreamedResponse
{
$this->authorizePaymentQr($paymentQr);
$qrCode = $this->imageGenerator->ensureValidImages($paymentQr);
$path = $format === 'svg' ? $qrCode->svg_path : $qrCode->png_path;
$filename = Str::slug($qrCode->label).'-qr.'.($format === 'svg' ? 'svg' : 'png');
if ($format === 'png') {
$bytes = $this->imageGenerator->normalizeStoredPng($path);
if ($bytes === null) {
$bytes = $this->imageGenerator->renderPng($qrCode->encodedPayload(), $qrCode->style());
$this->imageGenerator->generateAndStore($qrCode);
}
return response()->streamDownload(fn () => print($bytes), $filename, [
'Content-Type' => 'image/png',
]);
}
if ($format === 'pdf') {
$bytes = $this->imageGenerator->normalizeStoredPng($qrCode->png_path);
if ($bytes === null) {
$bytes = $this->imageGenerator->renderPng($qrCode->encodedPayload(), $qrCode->style());
$this->imageGenerator->generateAndStore($qrCode);
}
$pdf = $this->pdfExporter->fromPng($bytes, $qrCode->label);
$filename = Str::slug($qrCode->label).'-qr.pdf';
return response()->streamDownload(fn () => print($pdf), $filename, [
'Content-Type' => 'application/pdf',
]);
}
abort_unless($path && Storage::disk('qr')->exists($path), 404);
return Storage::disk('qr')->download($path, $filename);
}
private function authorizePaymentQr(QrCode $paymentQr): void
{
abort_unless($paymentQr->type === QrCode::TYPE_PAYMENT, 404);
abort_unless($paymentQr->user_id === ladill_account()->id, 403);
}
}
@@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers\Mini;
use App\Http\Controllers\Controller;
use App\Models\MiniPayment;
use App\Models\QrCode;
use Illuminate\Http\Request;
use Illuminate\View\View;
class PaymentsController extends Controller
{
public function index(Request $request): View
{
$account = ladill_account();
$search = trim((string) $request->query('q', ''));
$qrIds = $account->qrCodes()
->where('type', QrCode::TYPE_PAYMENT)
->pluck('id');
$payments = MiniPayment::query()
->whereIn('qr_code_id', $qrIds)
->when($search !== '', function ($query) use ($search) {
$like = '%'.$search.'%';
$query->where(function ($inner) use ($like) {
$inner->where('payer_name', 'like', $like)
->orWhere('payer_email', 'like', $like)
->orWhere('payer_note', 'like', $like)
->orWhere('reference', 'like', $like)
->orWhere('payment_reference', 'like', $like)
->orWhereHas('qrCode', fn ($qr) => $qr->where('label', 'like', $like));
});
})
->with('qrCode')
->latest('created_at')
->paginate(25)
->withQueryString();
return view('mini.payments', [
'payments' => $payments,
'search' => $search,
]);
}
}
@@ -0,0 +1,48 @@
<?php
namespace App\Http\Controllers\Mini;
use App\Http\Controllers\Controller;
use App\Models\MiniPayment;
use App\Models\QrCode;
use App\Services\Billing\BillingClient;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
use Throwable;
class PayoutsController extends Controller
{
public function __construct(private BillingClient $billing) {}
public function index(): View
{
$account = ladill_account();
$qrIds = $account->qrCodes()
->where('type', QrCode::TYPE_PAYMENT)
->pluck('id');
$revenueMinor = (int) MiniPayment::query()
->whereIn('qr_code_id', $qrIds)
->where('status', MiniPayment::STATUS_PAID)
->sum('merchant_amount_minor');
$balanceMinor = 0;
try {
$balanceMinor = $this->billing->balanceMinor($account->public_id);
} catch (Throwable $e) {
Log::warning('Mini payouts could not load wallet balance', [
'user' => $account->public_id,
'error' => $e->getMessage(),
]);
}
$accountWalletUrl = 'https://'.config('app.account_domain').'/wallet';
return view('mini.payouts', [
'revenueMinor' => $revenueMinor,
'balanceMinor' => $balanceMinor,
'accountWalletUrl' => $accountWalletUrl,
]);
}
}