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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user