Initial Ladill Merchant app with Gitea deploy pipeline.

Shop, menu, and booking storefronts with OIDC SSO, Pay checkout, and merchant.ladill.com deployment workflow.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-09 23:05:21 +00:00
co-authored by Cursor
commit f718b9cfbf
311 changed files with 38972 additions and 0 deletions
@@ -0,0 +1,100 @@
<?php
namespace App\Http\Controllers\Merchant;
use App\Http\Controllers\Controller;
use App\Models\QrBooking;
use App\Models\QrCode;
use App\Models\QrSaleOrder;
use App\Services\Merchant\MerchantSaleService;
use App\Support\Qr\QrTypeCatalog;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
class OrdersController extends Controller
{
public function __construct(private MerchantSaleService $sales) {}
public function index(Request $request): View
{
$account = ladill_account();
$search = trim((string) $request->query('q', ''));
$qrIds = $account->qrCodes()
->whereIn('type', QrTypeCatalog::storefrontTypes())
->pluck('id');
$orders = QrSaleOrder::query()
->whereIn('qr_code_id', $qrIds)
->when($search !== '', function ($query) use ($search) {
$like = '%'.$search.'%';
$query->where(function ($inner) use ($like) {
$inner->where('customer_name', 'like', $like)
->orWhere('customer_email', 'like', $like)
->orWhere('payment_reference', 'like', $like)
->orWhereHas('qrCode', fn ($qr) => $qr->where('label', 'like', $like));
});
})
->with('qrCode')
->latest('created_at')
->paginate(15, ['*'], 'orders_page')
->withQueryString();
$bookings = QrBooking::query()
->whereIn('qr_code_id', $qrIds)
->whereIn('status', [QrBooking::STATUS_CONFIRMED, QrBooking::STATUS_PENDING])
->when($search !== '', function ($query) use ($search) {
$like = '%'.$search.'%';
$query->where(function ($inner) use ($like) {
$inner->where('customer_name', 'like', $like)
->orWhere('customer_email', 'like', $like)
->orWhere('service_name', 'like', $like)
->orWhereHas('qrCode', fn ($qr) => $qr->where('label', 'like', $like));
});
})
->with('qrCode')
->latest('starts_at')
->paginate(15, ['*'], 'bookings_page')
->withQueryString();
return view('merchant.orders', [
'orders' => $orders,
'bookings' => $bookings,
'search' => $search,
]);
}
public function updateStatus(Request $request, QrSaleOrder $order): RedirectResponse
{
abort_if($order->user_id !== ladill_account()->id, 403);
abort_if($order->status !== QrSaleOrder::STATUS_PAID, 422, 'Only paid orders can be updated.');
$validated = $request->validate([
'fulfillment_status' => ['required', Rule::in(array_keys(QrSaleOrder::FULFILLMENT_STATUSES))],
]);
$order->update(['fulfillment_status' => $validated['fulfillment_status']]);
$order->load('qrCode');
$this->sales->notifyStatusUpdated($order);
return back()->with('success', 'Order status updated.');
}
public function updateBookingStatus(Request $request, QrBooking $booking): RedirectResponse
{
abort_if($booking->user_id !== ladill_account()->id, 403);
abort_if($booking->status !== QrBooking::STATUS_CONFIRMED, 422);
$validated = $request->validate([
'fulfillment_status' => ['required', Rule::in(array_keys(QrBooking::FULFILLMENT_STATUSES))],
]);
$booking->update(['fulfillment_status' => $validated['fulfillment_status']]);
$booking->load('qrCode');
$this->sales->notifyBookingStatusUpdated($booking);
return back()->with('success', 'Booking status updated.');
}
}
@@ -0,0 +1,71 @@
<?php
namespace App\Http\Controllers\Merchant;
use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Models\QrSaleOrder;
use App\Services\Billing\BillingClient;
use App\Support\Qr\QrTypeCatalog;
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()
->whereIn('type', QrTypeCatalog::storefrontTypes())
->pluck('id');
$todayStart = now()->startOfDay();
$todayOrders = QrSaleOrder::query()
->whereIn('qr_code_id', $qrIds)
->where('status', QrSaleOrder::STATUS_PAID)
->where('paid_at', '>=', $todayStart);
$todayCount = (clone $todayOrders)->count();
$todayRevenue = (float) (clone $todayOrders)->sum('merchant_amount_ghs');
$recentOrders = QrSaleOrder::query()
->whereIn('qr_code_id', $qrIds)
->where('status', QrSaleOrder::STATUS_PAID)
->with('qrCode')
->latest('paid_at')
->limit(8)
->get();
$storefrontCount = $qrIds->count();
$orders30d = QrSaleOrder::query()
->whereIn('qr_code_id', $qrIds)
->where('status', QrSaleOrder::STATUS_PAID)
->where('paid_at', '>=', now()->subDays(30))
->count();
$balanceMinor = 0;
try {
$balanceMinor = $this->billing->balanceMinor($account->public_id);
} catch (Throwable $e) {
Log::warning('Merchant dashboard could not load wallet balance', [
'user' => $account->public_id,
'error' => $e->getMessage(),
]);
}
return view('merchant.dashboard', [
'todayCount' => $todayCount,
'todayRevenue' => $todayRevenue,
'storefrontCount' => $storefrontCount,
'orders30d' => $orders30d,
'recentOrders' => $recentOrders,
'balanceMinor' => $balanceMinor,
]);
}
}
@@ -0,0 +1,48 @@
<?php
namespace App\Http\Controllers\Merchant;
use App\Http\Controllers\Controller;
use App\Models\QrSaleOrder;
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_SHOP)
->pluck('id');
$revenueMinor = (int) QrSaleOrder::query()
->whereIn('qr_code_id', $qrIds)
->where('status', QrSaleOrder::STATUS_PAID)
->sum('merchant_amount_minor');
$balanceMinor = 0;
try {
$balanceMinor = $this->billing->balanceMinor($account->public_id);
} catch (Throwable $e) {
Log::warning('Give payouts could not load wallet balance', [
'user' => $account->public_id,
'error' => $e->getMessage(),
]);
}
$accountWalletUrl = 'https://'.config('app.account_domain').'/wallet';
return view('merchant.payouts', [
'revenueMinor' => $revenueMinor,
'balanceMinor' => $balanceMinor,
'accountWalletUrl' => $accountWalletUrl,
]);
}
}
@@ -0,0 +1,211 @@
<?php
namespace App\Http\Controllers\Merchant;
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 StorefrontController 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()
->whereIn('type', \App\Support\Qr\QrTypeCatalog::storefrontTypes())
->latest()
->get();
$previewDataUris = $qrCodes->mapWithKeys(function (QrCode $qr) {
return [$qr->id => $this->imageGenerator->previewDataUri($qr)];
});
return view('merchant.storefronts.index', [
'qrCodes' => $qrCodes,
'previewDataUris' => $previewDataUris,
]);
}
public function create(): View
{
return view('merchant.storefronts.create');
}
public function store(Request $request): RedirectResponse
{
$account = ladill_account();
$request->validate([
'label' => ['required', 'string', 'max:120'],
'name' => ['required', 'string', 'max:120'],
'org_type' => ['required', 'in:church,school,mosque,ngo,club'],
'denomination' => ['nullable', 'string', 'max:120'],
'description' => ['nullable', 'string', 'max:2000'],
'phone' => ['nullable', 'string', 'max:30'],
'email' => ['nullable', 'email', 'max:200'],
'website' => ['nullable', 'url', 'max:2048'],
'address' => ['nullable', 'string', 'max:500'],
'service_times' => ['nullable', 'string', 'max:200'],
'brand_color' => ['nullable', 'string', 'max:20'],
'collection_types' => ['nullable', 'array', 'min:1'],
'collection_types.*' => ['string', 'max:80'],
'church_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
'church_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
]);
$data = array_merge($request->all(), [
'type' => QrCode::TYPE_SHOP,
'currency' => 'GHS',
'accepts_payment' => true,
'church_logo' => $request->file('church_logo'),
'church_cover' => $request->file('church_cover'),
]);
try {
$qrCode = $this->manager->create($account, $data);
} catch (RuntimeException $e) {
return back()->withInput()->with('error', $e->getMessage());
}
return redirect()->route('merchant.storefronts.show', $qrCode)->with('success', 'Storefront created.');
}
public function show(QrCode $storefront): View
{
$this->authorizeStorefront($storefront);
return view('merchant.storefronts.show', [
'qrCode' => $storefront->fresh(),
'previewDataUri' => $this->imageGenerator->previewDataUri($storefront),
]);
}
public function update(Request $request, QrCode $storefront): RedirectResponse
{
$this->authorizeStorefront($storefront);
$request->validate([
'label' => ['sometimes', 'string', 'max:120'],
'name' => ['sometimes', 'string', 'max:120'],
'org_type' => ['sometimes', 'in:church,school,mosque,ngo,club'],
'denomination' => ['nullable', 'string', 'max:120'],
'description' => ['nullable', 'string', 'max:2000'],
'phone' => ['nullable', 'string', 'max:30'],
'email' => ['nullable', 'email', 'max:200'],
'website' => ['nullable', 'url', 'max:2048'],
'address' => ['nullable', 'string', 'max:500'],
'service_times' => ['nullable', 'string', 'max:200'],
'brand_color' => ['nullable', 'string', 'max:20'],
'collection_types' => ['nullable', 'array', 'min:1'],
'collection_types.*' => ['string', 'max:80'],
'is_active' => ['sometimes', 'boolean'],
'church_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
'church_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
]);
$data = array_merge($request->all(), [
'accepts_payment' => true,
'is_active' => $request->boolean('is_active', $storefront->is_active),
'church_logo' => $request->file('church_logo'),
'church_cover' => $request->file('church_cover'),
]);
try {
$this->manager->update($storefront, $data);
} catch (RuntimeException $e) {
return back()->withInput()->with('error', $e->getMessage());
}
return back()->with('success', 'Storefront updated.');
}
public function destroy(QrCode $storefront): RedirectResponse
{
$this->authorizeStorefront($storefront);
$this->manager->delete($storefront);
return redirect()
->route('merchant.storefronts.index')
->with('success', 'Storefront deleted.');
}
public function preview(QrCode $storefront): Response
{
$this->authorizeStorefront($storefront);
$qrCode = $this->imageGenerator->ensureValidImages($storefront);
$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 $storefront, string $format): StreamedResponse
{
$this->authorizeStorefront($storefront);
$qrCode = $this->imageGenerator->ensureValidImages($storefront);
$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);
return response()->streamDownload(fn () => print($pdf), Str::slug($qrCode->label).'-qr.pdf', [
'Content-Type' => 'application/pdf',
]);
}
abort_unless($path && Storage::disk('qr')->exists($path), 404);
return Storage::disk('qr')->download($path, $filename);
}
private function authorizeStorefront(QrCode $storefront): void
{
abort_unless(in_array($storefront->type, \App\Support\Qr\QrTypeCatalog::storefrontTypes(), true), 404);
abort_unless($storefront->user_id === ladill_account()->id, 403);
}
}