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,126 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Afia;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Afia — Ladill in-app AI assistant scoped to a product (QR Plus).
|
||||
*/
|
||||
class AfiaService
|
||||
{
|
||||
public function enabled(): bool
|
||||
{
|
||||
return (bool) config('afia.enabled', true) && (string) config('afia.api_key', '') !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array{role:string,text:string}> $history
|
||||
* @param array<string,mixed> $context
|
||||
*/
|
||||
public function chat(string $message, array $history, array $context): string
|
||||
{
|
||||
if (! $this->enabled()) {
|
||||
throw new RuntimeException('Afia is not configured.');
|
||||
}
|
||||
|
||||
$provider = (string) config('afia.provider', 'openai');
|
||||
$model = (string) config('afia.model', 'gpt-4o-mini');
|
||||
$apiKey = (string) config('afia.api_key');
|
||||
|
||||
$messages = [['role' => 'system', 'content' => $this->systemPrompt($context)]];
|
||||
foreach (array_slice($history, -8) as $turn) {
|
||||
$role = ($turn['role'] ?? 'user') === 'assistant' ? 'assistant' : 'user';
|
||||
$text = trim((string) ($turn['text'] ?? ''));
|
||||
if ($text !== '') {
|
||||
$messages[] = ['role' => $role, 'content' => $text];
|
||||
}
|
||||
}
|
||||
$messages[] = ['role' => 'user', 'content' => $message];
|
||||
|
||||
return $provider === 'anthropic'
|
||||
? $this->viaAnthropic($model, $apiKey, $messages)
|
||||
: $this->viaOpenAi($model, $apiKey, $messages);
|
||||
}
|
||||
|
||||
private function viaOpenAi(string $model, string $apiKey, array $messages): string
|
||||
{
|
||||
$res = Http::withToken($apiKey)->acceptJson()->timeout(45)
|
||||
->post('https://api.openai.com/v1/chat/completions', [
|
||||
'model' => $model,
|
||||
'temperature' => 0.3,
|
||||
'max_tokens' => 600,
|
||||
'messages' => $messages,
|
||||
]);
|
||||
|
||||
if ($res->failed()) {
|
||||
throw new RuntimeException('OpenAI request failed: '.$res->status());
|
||||
}
|
||||
|
||||
return trim((string) $res->json('choices.0.message.content', ''));
|
||||
}
|
||||
|
||||
private function viaAnthropic(string $model, string $apiKey, array $messages): string
|
||||
{
|
||||
$system = $messages[0]['content'];
|
||||
$turns = array_values(array_filter($messages, fn ($m) => $m['role'] !== 'system'));
|
||||
|
||||
$res = Http::withHeaders([
|
||||
'x-api-key' => $apiKey,
|
||||
'anthropic-version' => '2023-06-01',
|
||||
])->acceptJson()->timeout(45)->post('https://api.anthropic.com/v1/messages', [
|
||||
'model' => $model,
|
||||
'max_tokens' => 600,
|
||||
'system' => $system,
|
||||
'messages' => array_map(fn ($m) => ['role' => $m['role'], 'content' => $m['content']], $turns),
|
||||
]);
|
||||
|
||||
if ($res->failed()) {
|
||||
throw new RuntimeException('Anthropic request failed: '.$res->status());
|
||||
}
|
||||
|
||||
return trim((string) $res->json('content.0.text', ''));
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $context */
|
||||
private function systemPrompt(array $context): string
|
||||
{
|
||||
$ctx = collect($context)->map(fn ($v, $k) => "- {$k}: {$v}")->implode("\n");
|
||||
|
||||
return match ((string) config('afia.product', 'qr')) {
|
||||
'qr' => $this->qrSystemPrompt($ctx),
|
||||
default => $this->qrSystemPrompt($ctx),
|
||||
};
|
||||
}
|
||||
|
||||
private function qrSystemPrompt(string $ctx): string
|
||||
{
|
||||
return <<<PROMPT
|
||||
You are Afia, the assistant inside Ladill QR Plus (qrplus.ladill.com).
|
||||
Help users create and manage dynamic QR codes, understand scans and billing, and print codes that work reliably.
|
||||
Be concise, friendly, and actionable: short numbered steps and name the screen or section to use.
|
||||
|
||||
What Ladill QR Plus does and where things live:
|
||||
- Overview (Dashboard): recent codes, active code count, scans in the last 30 days, wallet balance.
|
||||
- My Codes: list, create, and edit QR codes. Types: Link (URL), PDF, List of Links, Business profile, WiFi, App download.
|
||||
- Creating a code: pick a type, set content, customize style (colors, logo, frame), then download PNG/SVG/PDF.
|
||||
- Dynamic codes: destination can change after printing — the printed QR always points to ladill.com/q/{code}.
|
||||
- Business QR: name, tagline, contact, hours, logo, cover, social links — shown as a mobile landing page.
|
||||
- WiFi QR: guests scan to join; network name and password are encoded in the code.
|
||||
- PDF QR: hosts a PDF with optional download button on the viewer.
|
||||
- Billing: each new code debits the Ladill wallet (account portal → Wallet). Top up at account.ladill.com/wallet.
|
||||
- Team: invite teammates to manage codes together (sidebar → Team).
|
||||
- Developers: API tokens for programmatic code creation (sidebar → Developers).
|
||||
|
||||
Rules:
|
||||
- Only answer questions about Ladill QR Plus — code types, styling, downloads, scans, wallet billing, team, and API.
|
||||
- If asked about domains, hosting, email, or payments/commerce QR (shop, events, orders), briefly say those live in other Ladill apps and suggest the app launcher.
|
||||
- Never invent prices, short codes, or wallet balances — use the user context below or tell them where to check.
|
||||
- For print tips: recommend high contrast, adequate quiet zone, test-scan before mass printing.
|
||||
|
||||
Current user context:
|
||||
{$ctx}
|
||||
PROMPT;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Billing;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
/**
|
||||
* Client for the platform Billing HTTP API — the one UserWallet lives on the
|
||||
* platform; Bird only consumes it. Amounts are integer minor units; the user is
|
||||
* identified by public_id. Authenticates with the per-consumer config('billing.service') key.
|
||||
* Contract validated in the monolith (smtp-extraction-runbook §4-A).
|
||||
*/
|
||||
class BillingClient
|
||||
{
|
||||
private function base(): string
|
||||
{
|
||||
return rtrim((string) config('billing.api_url'), '/');
|
||||
}
|
||||
|
||||
private function token(): string
|
||||
{
|
||||
return (string) (config('billing.api_key') ?? '');
|
||||
}
|
||||
|
||||
private function get(string $path, array $query): array
|
||||
{
|
||||
$res = Http::withToken($this->token())->acceptJson()->timeout(10)->get($this->base().$path, $query);
|
||||
$res->throw();
|
||||
|
||||
return (array) $res->json();
|
||||
}
|
||||
|
||||
public function balanceMinor(string $publicId): int
|
||||
{
|
||||
return (int) ($this->get('/balance', ['user' => $publicId])['balance_minor'] ?? 0);
|
||||
}
|
||||
|
||||
public function canAfford(string $publicId, int $amountMinor): bool
|
||||
{
|
||||
return (bool) ($this->get('/can-afford', ['user' => $publicId, 'amount_minor' => $amountMinor])['affordable'] ?? false);
|
||||
}
|
||||
|
||||
public function serviceLedger(string $publicId, string $service): array
|
||||
{
|
||||
return $this->get('/service-ledger', ['user' => $publicId, 'service' => $service]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Debit the wallet. Returns true on success, false on insufficient balance
|
||||
* (HTTP 402). Idempotent by $reference.
|
||||
*/
|
||||
public function debit(string $publicId, int $amountMinor, string $service, string $source, string $reference, ?int $serviceId = null, ?string $description = null): bool
|
||||
{
|
||||
$res = Http::withToken($this->token())->acceptJson()->timeout(10)->post($this->base().'/debit', array_filter([
|
||||
'user' => $publicId,
|
||||
'amount_minor' => $amountMinor,
|
||||
'service' => $service,
|
||||
'source' => $source,
|
||||
'reference' => $reference,
|
||||
'service_id' => $serviceId,
|
||||
'description' => $description,
|
||||
], static fn ($v) => $v !== null));
|
||||
|
||||
if ($res->status() === 402) {
|
||||
return false;
|
||||
}
|
||||
$res->throw();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function credit(string $publicId, int $amountMinor, string $service, string $source, string $reference, ?int $serviceId = null, ?string $description = null): array
|
||||
{
|
||||
$res = Http::withToken($this->token())->acceptJson()->timeout(10)->post($this->base().'/credit', array_filter([
|
||||
'user' => $publicId,
|
||||
'amount_minor' => $amountMinor,
|
||||
'service' => $service,
|
||||
'source' => $source,
|
||||
'reference' => $reference,
|
||||
'service_id' => $serviceId,
|
||||
'description' => $description,
|
||||
], static fn ($v) => $v !== null));
|
||||
$res->throw();
|
||||
|
||||
return (array) $res->json();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Billing;
|
||||
|
||||
use App\Models\PlatformSetting;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use RuntimeException;
|
||||
|
||||
class PaystackService
|
||||
{
|
||||
public function publicKey(): string
|
||||
{
|
||||
return (string) $this->settingValue('paystack_public_key', config('services.paystack.public_key', ''));
|
||||
}
|
||||
|
||||
public function initializeTransaction(array $payload): array
|
||||
{
|
||||
return $this->initializeTransactionWithCredentials($payload);
|
||||
}
|
||||
|
||||
public function initializeTransactionWithCredentials(array $payload, ?string $secretKey = null, ?string $baseUrl = null): array
|
||||
{
|
||||
$response = $this->request(secretKey: $secretKey, baseUrl: $baseUrl)
|
||||
->post('/transaction/initialize', $payload);
|
||||
|
||||
return $this->extractData($response, 'Failed to initialize Paystack transaction.');
|
||||
}
|
||||
|
||||
public function verifyTransaction(string $reference): array
|
||||
{
|
||||
return $this->verifyTransactionWithCredentials($reference);
|
||||
}
|
||||
|
||||
public function verifyTransactionWithCredentials(string $reference, ?string $secretKey = null, ?string $baseUrl = null): array
|
||||
{
|
||||
$response = $this->request(secretKey: $secretKey, baseUrl: $baseUrl)
|
||||
->get('/transaction/verify/' . urlencode($reference));
|
||||
|
||||
return $this->extractData($response, 'Failed to verify Paystack transaction.');
|
||||
}
|
||||
|
||||
public function verifyWebhookSignature(string $rawBody, ?string $signature): bool
|
||||
{
|
||||
$secret = (string) $this->settingValue('paystack_webhook_secret', config('services.paystack.webhook_secret'));
|
||||
if ($secret === '') {
|
||||
$secret = (string) $this->settingValue('paystack_secret_key', config('services.paystack.secret_key'));
|
||||
}
|
||||
if ($secret === '' || !$signature) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$computed = hash_hmac('sha512', $rawBody, $secret);
|
||||
return hash_equals($computed, $signature);
|
||||
}
|
||||
|
||||
protected function request(?string $secretKey = null, ?string $baseUrl = null)
|
||||
{
|
||||
$resolvedBaseUrl = rtrim((string) ($baseUrl ?: $this->settingValue('paystack_base_url', config('services.paystack.base_url', 'https://api.paystack.co'))), '/');
|
||||
$secret = $this->normalizeSecretKey(
|
||||
(string) ($secretKey ?: $this->settingValue('paystack_secret_key', config('services.paystack.secret_key')))
|
||||
);
|
||||
|
||||
return Http::baseUrl($resolvedBaseUrl)
|
||||
->withToken($secret)
|
||||
->acceptJson()
|
||||
->asJson();
|
||||
}
|
||||
|
||||
protected function settingsConnection(): string
|
||||
{
|
||||
return (string) config('billing.platform_settings_connection', 'platform');
|
||||
}
|
||||
|
||||
protected function settingValue(string $key, mixed $fallback = null): mixed
|
||||
{
|
||||
try {
|
||||
$connection = $this->settingsConnection();
|
||||
|
||||
if (! Schema::connection($connection)->hasTable('platform_settings')) {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
$setting = PlatformSetting::query()
|
||||
->where('key', $key)
|
||||
->where('is_active', true)
|
||||
->first();
|
||||
|
||||
return $setting ? ($setting->value['value'] ?? $fallback) : $fallback;
|
||||
} catch (\Throwable) {
|
||||
return $fallback;
|
||||
}
|
||||
}
|
||||
|
||||
protected function extractData(Response $response, string $message): array
|
||||
{
|
||||
if ($response->failed()) {
|
||||
throw new RuntimeException($message);
|
||||
}
|
||||
|
||||
$json = $response->json();
|
||||
if (!is_array($json) || !($json['status'] ?? false)) {
|
||||
throw new RuntimeException((string) ($json['message'] ?? $message));
|
||||
}
|
||||
|
||||
$data = $json['data'] ?? null;
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* List supported banks / mobile money providers.
|
||||
* @param string $country e.g. 'ghana', 'nigeria'
|
||||
* @param string $type 'nuban', 'mobile_money', or '' for all
|
||||
*/
|
||||
public function listBanks(string $country = 'ghana', string $type = '', ?string $currency = null): array
|
||||
{
|
||||
$query = ['perPage' => 200];
|
||||
if ($currency !== null && $currency !== '') {
|
||||
$query['currency'] = strtoupper($currency);
|
||||
} else {
|
||||
$query['country'] = $country;
|
||||
}
|
||||
if ($type !== '') {
|
||||
$query['type'] = $type;
|
||||
}
|
||||
$response = $this->request()->get('/bank', $query);
|
||||
$json = $response->json();
|
||||
return is_array($json['data'] ?? null) ? $json['data'] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a transfer recipient (bank account or mobile money).
|
||||
* @param array{type: string, name: string, account_number: string, bank_code: string, currency?: string} $payload
|
||||
*/
|
||||
public function createTransferRecipient(array $payload): array
|
||||
{
|
||||
$response = $this->request()->post('/transferrecipient', $payload);
|
||||
return $this->extractData($response, 'Failed to create transfer recipient.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate a transfer to a recipient.
|
||||
* @param array{source: string, amount: int, recipient: string, reason?: string, reference?: string} $payload
|
||||
*/
|
||||
public function initiateTransfer(array $payload): array
|
||||
{
|
||||
$response = $this->request()->post('/transfer', $payload);
|
||||
return $this->extractData($response, 'Failed to initiate transfer.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a transfer by reference.
|
||||
*/
|
||||
public function verifyTransfer(string $reference): array
|
||||
{
|
||||
$response = $this->request()->get('/transfer/verify/' . urlencode($reference));
|
||||
return $this->extractData($response, 'Failed to verify transfer.');
|
||||
}
|
||||
|
||||
protected function normalizeSecretKey(string $secret): string
|
||||
{
|
||||
$normalized = trim($secret);
|
||||
|
||||
if (str_starts_with(strtolower($normalized), 'bearer ')) {
|
||||
$normalized = trim(substr($normalized, 7));
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Billing;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class SmsService
|
||||
{
|
||||
public function send(string $to, string $message): void
|
||||
{
|
||||
$apiKey = config('services.termii.api_key', '');
|
||||
$senderId = config('services.termii.sender_id', config('app.name', 'LaDill'));
|
||||
|
||||
if ($apiKey === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$phone = preg_replace('/\D+/', '', $to);
|
||||
if (strlen($phone) < 9) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (strlen($phone) === 9) {
|
||||
$phone = '233'.$phone;
|
||||
} elseif (str_starts_with($phone, '0')) {
|
||||
$phone = '233'.substr($phone, 1);
|
||||
}
|
||||
|
||||
try {
|
||||
Http::timeout(10)->post('https://v3.api.termii.com/api/sms/send', [
|
||||
'api_key' => $apiKey,
|
||||
'to' => $phone,
|
||||
'from' => $senderId,
|
||||
'sms' => $message,
|
||||
'type' => 'plain',
|
||||
'channel' => 'dnd',
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('SMS send failed', ['to' => $phone, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Identity;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class IdentityClient
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
public function mailboxLinkStatus(string $publicId): array
|
||||
{
|
||||
$response = $this->request()->get($this->url('/identity/mailbox-link'), [
|
||||
'user' => $publicId,
|
||||
]);
|
||||
|
||||
$response->throw();
|
||||
|
||||
return (array) $response->json('data', []);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function linkMailbox(string $publicId, string $mailboxAddress): array
|
||||
{
|
||||
$response = $this->request()->put($this->url('/identity/mailbox-link'), [
|
||||
'user' => $publicId,
|
||||
'mailbox_address' => $mailboxAddress,
|
||||
]);
|
||||
|
||||
$response->throw();
|
||||
|
||||
return (array) $response->json('data', []);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function unlinkMailbox(string $publicId): array
|
||||
{
|
||||
$response = $this->request()->delete($this->url('/identity/mailbox-link'), [
|
||||
'user' => $publicId,
|
||||
]);
|
||||
|
||||
$response->throw();
|
||||
|
||||
return (array) $response->json('data', []);
|
||||
}
|
||||
|
||||
private function request()
|
||||
{
|
||||
return Http::withToken((string) config('identity.api_key'))
|
||||
->acceptJson()
|
||||
->timeout(15);
|
||||
}
|
||||
|
||||
private function url(string $path): string
|
||||
{
|
||||
return rtrim((string) config('identity.api_url'), '/').$path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Merchant;
|
||||
|
||||
use App\Models\QrBooking;
|
||||
use App\Models\QrCode;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class BookingSlotService
|
||||
{
|
||||
/** @var list<string> */
|
||||
private const DAY_KEYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
|
||||
|
||||
/**
|
||||
* @return list<array{starts_at: string, ends_at: string, label: string}>
|
||||
*/
|
||||
public function availableSlots(QrCode $qrCode, string $date, int $serviceIndex): array
|
||||
{
|
||||
$content = $qrCode->content();
|
||||
$services = $content['services'] ?? [];
|
||||
$service = $services[$serviceIndex] ?? null;
|
||||
if (! is_array($service)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$duration = max(15, (int) ($service['duration_minutes'] ?? 30));
|
||||
$day = Carbon::parse($date)->startOfDay();
|
||||
if ($day->isPast() && ! $day->isToday()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$windows = $this->windowsForDay($content, $day);
|
||||
if ($windows === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$booked = QrBooking::query()
|
||||
->where('qr_code_id', $qrCode->id)
|
||||
->whereIn('status', [QrBooking::STATUS_PENDING, QrBooking::STATUS_CONFIRMED])
|
||||
->whereDate('starts_at', $day->toDateString())
|
||||
->get(['starts_at', 'ends_at']);
|
||||
|
||||
$slots = [];
|
||||
foreach ($windows as $window) {
|
||||
$cursor = $day->copy()->setTimeFromTimeString($window['start']);
|
||||
$windowEnd = $day->copy()->setTimeFromTimeString($window['end']);
|
||||
|
||||
while ($cursor->copy()->addMinutes($duration)->lte($windowEnd)) {
|
||||
$slotEnd = $cursor->copy()->addMinutes($duration);
|
||||
if ($day->isToday() && $cursor->lte(now())) {
|
||||
$cursor->addMinutes($duration);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $this->overlapsBooked($cursor, $slotEnd, $booked)) {
|
||||
$slots[] = [
|
||||
'starts_at' => $cursor->toIso8601String(),
|
||||
'ends_at' => $slotEnd->toIso8601String(),
|
||||
'label' => $cursor->format('g:i A'),
|
||||
];
|
||||
}
|
||||
|
||||
$cursor->addMinutes($duration);
|
||||
}
|
||||
}
|
||||
|
||||
return $slots;
|
||||
}
|
||||
|
||||
/** @return list<array{start: string, end: string}> */
|
||||
private function windowsForDay(array $content, Carbon $day): array
|
||||
{
|
||||
$key = self::DAY_KEYS[$day->dayOfWeek];
|
||||
$availability = $content['availability'] ?? [];
|
||||
$windows = $availability[$key] ?? [];
|
||||
|
||||
if (! is_array($windows) || $windows === []) {
|
||||
$opens = (string) ($content['opens_at'] ?? '09:00');
|
||||
$closes = (string) ($content['closes_at'] ?? '17:00');
|
||||
$activeDays = $content['active_days'] ?? ['mon', 'tue', 'wed', 'thu', 'fri'];
|
||||
|
||||
if (! in_array($key, (array) $activeDays, true)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [['start' => $opens, 'end' => $closes]];
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
foreach ($windows as $window) {
|
||||
if (! is_array($window)) {
|
||||
continue;
|
||||
}
|
||||
$start = trim((string) ($window['start'] ?? ''));
|
||||
$end = trim((string) ($window['end'] ?? ''));
|
||||
if ($start !== '' && $end !== '') {
|
||||
$normalized[] = ['start' => $start, 'end' => $end];
|
||||
}
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/** @param Collection<int, QrBooking> $booked */
|
||||
private function overlapsBooked(Carbon $start, Carbon $end, Collection $booked): bool
|
||||
{
|
||||
foreach ($booked as $booking) {
|
||||
if ($start < $booking->ends_at && $end > $booking->starts_at) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Merchant;
|
||||
|
||||
use App\Models\QrSaleOrder;
|
||||
use App\Models\QrCode;
|
||||
use App\Services\Billing\BillingClient;
|
||||
use App\Services\Billing\PaystackService;
|
||||
use App\Services\Billing\SmsService;
|
||||
use App\Services\Pay\PayClient;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
class MerchantSaleService
|
||||
{
|
||||
public function __construct(
|
||||
private PayClient $pay,
|
||||
private PaystackService $paystack,
|
||||
private BillingClient $billing,
|
||||
private SmsService $sms,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array{customer_name: string, customer_email: string, customer_phone: string, items: list<array{name: string, price: float, qty: int}>} $data
|
||||
* @return array{order: QrSaleOrder, checkout_url: string, callback_url: string}
|
||||
*/
|
||||
public function initiateOrder(QrCode $qrCode, array $data): array
|
||||
{
|
||||
if ($qrCode->type !== QrCode::TYPE_SHOP) {
|
||||
throw new RuntimeException('This QR is not a storefront.');
|
||||
}
|
||||
|
||||
$items = $data['items'] ?? [];
|
||||
if (empty($items)) {
|
||||
throw new RuntimeException('Enter an amount to give.');
|
||||
}
|
||||
|
||||
$item = $items[0];
|
||||
$amountGhs = round((float) ($item['price'] ?? 0), 2);
|
||||
if ($amountGhs <= 0) {
|
||||
throw new RuntimeException('Enter an amount greater than zero.');
|
||||
}
|
||||
|
||||
$collectionType = trim((string) ($item['name'] ?? 'Order'));
|
||||
$qrCode->loadMissing('user');
|
||||
$amountMinor = (int) round($amountGhs * 100);
|
||||
$reference = 'MCHT-'.strtoupper(Str::random(16));
|
||||
$orgName = $qrCode->content()['name'] ?? $qrCode->label ?? 'Storefront';
|
||||
$callbackUrl = route('qr.public.order.callback', ['shortCode' => $qrCode->short_code]);
|
||||
|
||||
$order = QrSaleOrder::create([
|
||||
'qr_code_id' => $qrCode->id,
|
||||
'user_id' => $qrCode->user_id,
|
||||
'reference' => $reference,
|
||||
'collection_type' => $collectionType,
|
||||
'amount_minor' => $amountMinor,
|
||||
'currency' => $qrCode->content()['currency'] ?? 'GHS',
|
||||
'payer_name' => trim((string) ($data['customer_name'] ?? '')),
|
||||
'payer_email' => trim((string) ($data['customer_email'] ?? '')),
|
||||
'payer_phone' => trim((string) ($data['customer_phone'] ?? '')),
|
||||
'status' => QrSaleOrder::STATUS_PENDING,
|
||||
'payment_reference' => null,
|
||||
]);
|
||||
|
||||
$payOrder = $this->pay->createCheckout([
|
||||
'merchant' => $qrCode->user->public_id,
|
||||
'fee_tier' => 'orders',
|
||||
'source_service' => 'merchant',
|
||||
'source_ref' => (string) $qrCode->id,
|
||||
'callback_url' => $callbackUrl,
|
||||
'customer_name' => $order->payer_name,
|
||||
'customer_email' => $order->payer_email,
|
||||
'customer_phone' => $order->payer_phone,
|
||||
'line_items' => [
|
||||
[
|
||||
'name' => $collectionType.' — '.$orgName,
|
||||
'unit_price_minor' => $amountMinor,
|
||||
'quantity' => 1,
|
||||
],
|
||||
],
|
||||
'metadata' => [
|
||||
'give_order_id' => $order->id,
|
||||
'give_reference' => $reference,
|
||||
'qr_code_id' => $qrCode->id,
|
||||
'collection_type' => $collectionType,
|
||||
],
|
||||
]);
|
||||
|
||||
$order->update([
|
||||
'pay_order_id' => $payOrder['id'] ?? null,
|
||||
'payment_reference' => $payOrder['reference'],
|
||||
'platform_fee_minor' => $payOrder['platform_fee_minor'] ?? null,
|
||||
'merchant_amount_minor' => $payOrder['merchant_amount_minor'] ?? null,
|
||||
]);
|
||||
|
||||
$checkoutUrl = (string) ($payOrder['checkout_url'] ?? '');
|
||||
if ($checkoutUrl === '') {
|
||||
throw new RuntimeException('Could not start checkout. Please try again.');
|
||||
}
|
||||
|
||||
return [
|
||||
'order' => $order->fresh(),
|
||||
'checkout_url' => $checkoutUrl,
|
||||
'callback_url' => $callbackUrl,
|
||||
];
|
||||
}
|
||||
|
||||
public function complete(string $paymentReference): QrSaleOrder
|
||||
{
|
||||
if (str_starts_with($paymentReference, 'LP-')) {
|
||||
return $this->completeLadillPay($paymentReference);
|
||||
}
|
||||
|
||||
return $this->completeLegacy($paymentReference);
|
||||
}
|
||||
|
||||
private function completeLadillPay(string $reference): QrSaleOrder
|
||||
{
|
||||
$order = QrSaleOrder::where('payment_reference', $reference)
|
||||
->where('status', QrSaleOrder::STATUS_PENDING)
|
||||
->firstOrFail();
|
||||
|
||||
$payOrder = $this->pay->verify($reference);
|
||||
|
||||
$order->update([
|
||||
'status' => QrSaleOrder::STATUS_PAID,
|
||||
'amount_minor' => (int) ($payOrder['amount_minor'] ?? $order->amount_minor),
|
||||
'platform_fee_minor' => (int) ($payOrder['platform_fee_minor'] ?? 0),
|
||||
'merchant_amount_minor' => (int) ($payOrder['merchant_amount_minor'] ?? 0),
|
||||
'pay_order_id' => $payOrder['id'] ?? $order->pay_order_id,
|
||||
'paid_at' => now(),
|
||||
'metadata' => array_merge((array) $order->metadata, ['ladill_pay' => $payOrder]),
|
||||
]);
|
||||
|
||||
$this->notifyDonor($order->fresh(['qrCode', 'merchant']));
|
||||
|
||||
return $order;
|
||||
}
|
||||
|
||||
private function completeLegacy(string $paymentReference): QrSaleOrder
|
||||
{
|
||||
$order = QrSaleOrder::where('payment_reference', $paymentReference)
|
||||
->where('status', QrSaleOrder::STATUS_PENDING)
|
||||
->firstOrFail();
|
||||
|
||||
$data = $this->paystack->verifyTransaction($paymentReference);
|
||||
|
||||
if (($data['status'] ?? '') !== 'success') {
|
||||
$order->update(['status' => QrSaleOrder::STATUS_FAILED]);
|
||||
throw new RuntimeException('Payment was not successful.');
|
||||
}
|
||||
|
||||
$paidMinor = (int) ($data['amount'] ?? $order->amount_minor);
|
||||
$platformFeeMinor = (int) round($paidMinor * QrSaleOrder::PLATFORM_FEE_RATE);
|
||||
$merchantMinor = $paidMinor - $platformFeeMinor;
|
||||
|
||||
$order->update([
|
||||
'status' => QrSaleOrder::STATUS_PAID,
|
||||
'amount_minor' => $paidMinor,
|
||||
'platform_fee_minor' => $platformFeeMinor,
|
||||
'merchant_amount_minor' => $merchantMinor,
|
||||
'paid_at' => now(),
|
||||
'metadata' => array_merge((array) $order->metadata, ['paystack' => $data, 'legacy' => true]),
|
||||
]);
|
||||
|
||||
$orgName = $order->qrCode?->content()['name'] ?? $order->qrCode?->label ?? 'Storefront';
|
||||
$this->billing->credit(
|
||||
$order->merchant->public_id,
|
||||
$merchantMinor,
|
||||
'give',
|
||||
'pay',
|
||||
$paymentReference,
|
||||
$order->id,
|
||||
sprintf('Order via %s', $orgName),
|
||||
);
|
||||
|
||||
$this->notifyDonor($order->fresh(['qrCode', 'merchant']));
|
||||
|
||||
return $order;
|
||||
}
|
||||
|
||||
private function notifyDonor(QrSaleOrder $order): void
|
||||
{
|
||||
if (! $order->payer_phone) {
|
||||
return;
|
||||
}
|
||||
|
||||
$orgName = $order->qrCode?->content()['name'] ?? $order->qrCode?->label ?? 'Storefront';
|
||||
|
||||
$this->sms->send(
|
||||
$order->payer_phone,
|
||||
sprintf(
|
||||
'Thank you! Your %s %s order to %s is confirmed. Ref: %s',
|
||||
$order->currency,
|
||||
number_format($order->amount_minor / 100, 2),
|
||||
$orgName,
|
||||
$order->reference
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Merchant;
|
||||
|
||||
use App\Models\QrBooking;
|
||||
use App\Models\QrCode;
|
||||
use App\Models\QrSaleOrder;
|
||||
use Carbon\Carbon;
|
||||
use App\Services\Billing\BillingClient;
|
||||
use App\Services\Billing\PaystackService;
|
||||
use App\Services\Billing\SmsService;
|
||||
use App\Services\Pay\PayClient;
|
||||
use App\Support\Qr\QrTypeCatalog;
|
||||
use RuntimeException;
|
||||
|
||||
class MerchantSaleService
|
||||
{
|
||||
public function __construct(
|
||||
private PayClient $pay,
|
||||
private PaystackService $paystack,
|
||||
private BillingClient $billing,
|
||||
private SmsService $sms,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array{customer_name: string, customer_email: string, customer_phone: ?string, shipping_fee?: float, items: list<array{name: string, price: float, qty: int}>} $data
|
||||
* @return array{order: QrSaleOrder, checkout_url: string, callback_url: string}
|
||||
*/
|
||||
public function initiateOrder(QrCode $qrCode, array $data): array
|
||||
{
|
||||
if (! in_array($qrCode->type, \App\Support\Qr\QrTypeCatalog::storefrontTypes(), true)) {
|
||||
throw new RuntimeException('This QR is not a merchant storefront.');
|
||||
}
|
||||
|
||||
$items = $this->normalizeItems($data['items'] ?? []);
|
||||
if (empty($items)) {
|
||||
throw new RuntimeException('Your cart is empty.');
|
||||
}
|
||||
|
||||
$shippingFee = round(max(0, (float) ($data['shipping_fee'] ?? 0)), 2);
|
||||
if ($shippingFee > 0) {
|
||||
$items[] = ['name' => 'Shipping', 'price_ghs' => $shippingFee, 'qty' => 1];
|
||||
}
|
||||
|
||||
$totalGhs = collect($items)->sum(fn ($i) => $i['price_ghs'] * $i['qty']);
|
||||
if ($totalGhs <= 0) {
|
||||
throw new RuntimeException('Order total must be greater than zero to process payment.');
|
||||
}
|
||||
|
||||
$qrCode->loadMissing('user');
|
||||
$customerEmail = trim((string) ($data['customer_email'] ?? ''));
|
||||
$callbackUrl = route('qr.public.order.callback', ['shortCode' => $qrCode->short_code]);
|
||||
|
||||
$payOrder = $this->pay->createCheckout([
|
||||
'merchant' => $qrCode->user->public_id,
|
||||
'fee_tier' => 'sales',
|
||||
'source_service' => 'merchant',
|
||||
'source_ref' => (string) $qrCode->id,
|
||||
'callback_url' => $callbackUrl,
|
||||
'customer_name' => trim((string) ($data['customer_name'] ?? '')),
|
||||
'customer_email' => $customerEmail,
|
||||
'customer_phone' => trim((string) ($data['customer_phone'] ?? '')) ?: null,
|
||||
'line_items' => collect($items)->map(fn (array $i) => [
|
||||
'name' => $i['name'],
|
||||
'unit_price_minor' => (int) round($i['price_ghs'] * 100),
|
||||
'quantity' => $i['qty'],
|
||||
])->all(),
|
||||
'metadata' => [
|
||||
'qr_code_id' => $qrCode->id,
|
||||
'short_code' => $qrCode->short_code,
|
||||
],
|
||||
]);
|
||||
|
||||
$order = QrSaleOrder::create([
|
||||
'pay_order_id' => $payOrder['id'] ?? null,
|
||||
'qr_code_id' => $qrCode->id,
|
||||
'user_id' => $qrCode->user_id,
|
||||
'customer_name' => trim((string) ($data['customer_name'] ?? '')),
|
||||
'customer_email' => $customerEmail ?: null,
|
||||
'customer_phone' => trim((string) ($data['customer_phone'] ?? '')) ?: null,
|
||||
'items' => $items,
|
||||
'amount_ghs' => round(($payOrder['amount_minor'] ?? 0) / 100, 2),
|
||||
'status' => QrSaleOrder::STATUS_PENDING,
|
||||
'payment_reference' => $payOrder['reference'],
|
||||
]);
|
||||
|
||||
return [
|
||||
'order' => $order,
|
||||
'checkout_url' => (string) ($payOrder['checkout_url'] ?? ''),
|
||||
'callback_url' => $callbackUrl,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{customer_name: string, customer_email: string, customer_phone: ?string, service_index: int, starts_at: string} $data
|
||||
* @return array{booking: QrBooking, checkout_url: ?string, callback_url: ?string}
|
||||
*/
|
||||
public function initiateBooking(QrCode $qrCode, array $data): array
|
||||
{
|
||||
if (! $qrCode->isBookingType()) {
|
||||
throw new RuntimeException('This QR is not a booking page.');
|
||||
}
|
||||
|
||||
$content = $qrCode->content();
|
||||
$services = $content['services'] ?? [];
|
||||
$serviceIndex = (int) ($data['service_index'] ?? -1);
|
||||
$service = $services[$serviceIndex] ?? null;
|
||||
if (! is_array($service)) {
|
||||
throw new RuntimeException('Select a valid service.');
|
||||
}
|
||||
|
||||
$serviceName = trim((string) ($service['name'] ?? ''));
|
||||
$duration = max(15, (int) ($service['duration_minutes'] ?? 30));
|
||||
$priceGhs = round(max(0, (float) ($service['price_ghs'] ?? 0)), 2);
|
||||
$startsAt = Carbon::parse((string) ($data['starts_at'] ?? ''));
|
||||
$endsAt = $startsAt->copy()->addMinutes($duration);
|
||||
|
||||
if ($startsAt->isPast()) {
|
||||
throw new RuntimeException('That time slot has passed. Pick another.');
|
||||
}
|
||||
|
||||
$qrCode->loadMissing('user');
|
||||
$customerEmail = trim((string) ($data['customer_email'] ?? ''));
|
||||
$callbackUrl = route('qr.public.booking.callback', ['shortCode' => $qrCode->short_code]);
|
||||
|
||||
$booking = QrBooking::create([
|
||||
'qr_code_id' => $qrCode->id,
|
||||
'user_id' => $qrCode->user_id,
|
||||
'customer_name' => trim((string) ($data['customer_name'] ?? '')),
|
||||
'customer_email' => $customerEmail ?: null,
|
||||
'customer_phone' => trim((string) ($data['customer_phone'] ?? '')) ?: null,
|
||||
'service_name' => $serviceName,
|
||||
'duration_minutes' => $duration,
|
||||
'starts_at' => $startsAt,
|
||||
'ends_at' => $endsAt,
|
||||
'amount_ghs' => $priceGhs,
|
||||
'status' => QrBooking::STATUS_PENDING,
|
||||
'metadata' => ['service_index' => $serviceIndex],
|
||||
]);
|
||||
|
||||
if ($priceGhs <= 0 || ! ($content['accepts_payment'] ?? true)) {
|
||||
$booking->update([
|
||||
'status' => QrBooking::STATUS_CONFIRMED,
|
||||
'fulfillment_status' => QrBooking::FULFILLMENT_CONFIRMED,
|
||||
'paid_at' => now(),
|
||||
]);
|
||||
$this->notifyBookingConfirmed($booking->fresh(['qrCode']));
|
||||
|
||||
return ['booking' => $booking, 'checkout_url' => null, 'callback_url' => null];
|
||||
}
|
||||
|
||||
$lineLabel = sprintf('%s — %s', $serviceName, $startsAt->format('D j M, g:i A'));
|
||||
$payOrder = $this->pay->createCheckout([
|
||||
'merchant' => $qrCode->user->public_id,
|
||||
'fee_tier' => 'sales',
|
||||
'source_service' => 'merchant',
|
||||
'source_ref' => (string) $qrCode->id,
|
||||
'callback_url' => $callbackUrl,
|
||||
'customer_name' => $booking->customer_name,
|
||||
'customer_email' => $customerEmail,
|
||||
'customer_phone' => $booking->customer_phone,
|
||||
'line_items' => [
|
||||
['name' => $lineLabel, 'unit_price_minor' => (int) round($priceGhs * 100), 'quantity' => 1],
|
||||
],
|
||||
'metadata' => [
|
||||
'qr_code_id' => $qrCode->id,
|
||||
'qr_booking_id' => $booking->id,
|
||||
'type' => 'booking',
|
||||
],
|
||||
]);
|
||||
|
||||
$booking->update([
|
||||
'pay_order_id' => $payOrder['id'] ?? null,
|
||||
'payment_reference' => $payOrder['reference'],
|
||||
]);
|
||||
|
||||
return [
|
||||
'booking' => $booking,
|
||||
'checkout_url' => (string) ($payOrder['checkout_url'] ?? ''),
|
||||
'callback_url' => $callbackUrl,
|
||||
];
|
||||
}
|
||||
|
||||
public function completeBooking(string $reference): QrBooking
|
||||
{
|
||||
if (str_starts_with($reference, 'LP-')) {
|
||||
return $this->completeLadillPayBooking($reference);
|
||||
}
|
||||
|
||||
return $this->completeLegacyBooking($reference);
|
||||
}
|
||||
|
||||
public function completeOrder(string $reference): QrSaleOrder
|
||||
{
|
||||
if (str_starts_with($reference, 'LP-')) {
|
||||
return $this->completeLadillPay($reference);
|
||||
}
|
||||
|
||||
return $this->completeLegacy($reference);
|
||||
}
|
||||
|
||||
private function completeLadillPay(string $reference): QrSaleOrder
|
||||
{
|
||||
$order = QrSaleOrder::where('payment_reference', $reference)
|
||||
->where('status', QrSaleOrder::STATUS_PENDING)
|
||||
->firstOrFail();
|
||||
|
||||
$payOrder = $this->pay->verify($reference);
|
||||
|
||||
$order->update([
|
||||
'status' => QrSaleOrder::STATUS_PAID,
|
||||
'paid_at' => now(),
|
||||
'platform_fee_ghs' => round(($payOrder['platform_fee_minor'] ?? 0) / 100, 2),
|
||||
'merchant_amount_ghs' => round(($payOrder['merchant_amount_minor'] ?? 0) / 100, 2),
|
||||
'pay_order_id' => $payOrder['id'] ?? $order->pay_order_id,
|
||||
'metadata' => array_merge((array) $order->metadata, ['ladill_pay' => $payOrder]),
|
||||
]);
|
||||
|
||||
$this->notifyOrderPlaced($order->fresh(['qrCode', 'merchant']));
|
||||
|
||||
return $order;
|
||||
}
|
||||
|
||||
private function completeLegacy(string $reference): QrSaleOrder
|
||||
{
|
||||
$order = QrSaleOrder::where('payment_reference', $reference)
|
||||
->where('status', QrSaleOrder::STATUS_PENDING)
|
||||
->firstOrFail();
|
||||
|
||||
$data = $this->paystack->verifyTransaction($reference);
|
||||
if (($data['status'] ?? '') !== 'success') {
|
||||
$order->update(['status' => QrSaleOrder::STATUS_FAILED]);
|
||||
throw new RuntimeException('Payment was not successful.');
|
||||
}
|
||||
|
||||
$paidAmount = round(($data['amount'] ?? 0) / 100, 2);
|
||||
$platformFee = round($paidAmount * QrSaleOrder::PLATFORM_FEE_RATE, 2);
|
||||
$merchantAmount = round($paidAmount - $platformFee, 2);
|
||||
|
||||
$order->update([
|
||||
'status' => QrSaleOrder::STATUS_PAID,
|
||||
'paid_at' => now(),
|
||||
'platform_fee_ghs' => $platformFee,
|
||||
'merchant_amount_ghs' => $merchantAmount,
|
||||
'metadata' => array_merge((array) $order->metadata, ['paystack' => $data, 'legacy' => true]),
|
||||
]);
|
||||
|
||||
$storeName = $order->qrCode?->label ?? 'Storefront';
|
||||
$this->billing->credit(
|
||||
$order->merchant->public_id,
|
||||
(int) round($merchantAmount * 100),
|
||||
'merchant',
|
||||
'pay',
|
||||
$reference,
|
||||
$order->id,
|
||||
sprintf('Sale from %s — %s', $storeName, $order->customer_name ?: 'Customer'),
|
||||
);
|
||||
|
||||
$this->notifyOrderPlaced($order->fresh(['qrCode', 'merchant']));
|
||||
|
||||
return $order;
|
||||
}
|
||||
|
||||
private function completeLadillPayBooking(string $reference): QrBooking
|
||||
{
|
||||
$booking = QrBooking::where('payment_reference', $reference)
|
||||
->where('status', QrBooking::STATUS_PENDING)
|
||||
->firstOrFail();
|
||||
|
||||
$payOrder = $this->pay->verify($reference);
|
||||
|
||||
$booking->update([
|
||||
'status' => QrBooking::STATUS_CONFIRMED,
|
||||
'fulfillment_status' => QrBooking::FULFILLMENT_CONFIRMED,
|
||||
'paid_at' => now(),
|
||||
'platform_fee_ghs' => round(($payOrder['platform_fee_minor'] ?? 0) / 100, 2),
|
||||
'merchant_amount_ghs' => round(($payOrder['merchant_amount_minor'] ?? 0) / 100, 2),
|
||||
'pay_order_id' => $payOrder['id'] ?? $booking->pay_order_id,
|
||||
'metadata' => array_merge((array) $booking->metadata, ['ladill_pay' => $payOrder]),
|
||||
]);
|
||||
|
||||
$this->notifyBookingConfirmed($booking->fresh(['qrCode']));
|
||||
|
||||
return $booking;
|
||||
}
|
||||
|
||||
private function completeLegacyBooking(string $reference): QrBooking
|
||||
{
|
||||
$booking = QrBooking::where('payment_reference', $reference)
|
||||
->where('status', QrBooking::STATUS_PENDING)
|
||||
->firstOrFail();
|
||||
|
||||
$data = $this->paystack->verifyTransaction($reference);
|
||||
if (($data['status'] ?? '') !== 'success') {
|
||||
$booking->update(['status' => QrBooking::STATUS_FAILED]);
|
||||
throw new RuntimeException('Payment was not successful.');
|
||||
}
|
||||
|
||||
$paidAmount = round(($data['amount'] ?? 0) / 100, 2);
|
||||
$platformFee = round($paidAmount * 0.15, 2);
|
||||
$merchantAmount = round($paidAmount - $platformFee, 2);
|
||||
|
||||
$booking->update([
|
||||
'status' => QrBooking::STATUS_CONFIRMED,
|
||||
'fulfillment_status' => QrBooking::FULFILLMENT_CONFIRMED,
|
||||
'paid_at' => now(),
|
||||
'platform_fee_ghs' => $platformFee,
|
||||
'merchant_amount_ghs' => $merchantAmount,
|
||||
'metadata' => array_merge((array) $booking->metadata, ['paystack' => $data, 'legacy' => true]),
|
||||
]);
|
||||
|
||||
$this->billing->credit(
|
||||
$booking->merchant->public_id,
|
||||
(int) round($merchantAmount * 100),
|
||||
'merchant',
|
||||
'pay',
|
||||
$reference,
|
||||
$booking->id,
|
||||
sprintf('Booking — %s', $booking->service_name),
|
||||
);
|
||||
|
||||
$this->notifyBookingConfirmed($booking->fresh(['qrCode']));
|
||||
|
||||
return $booking;
|
||||
}
|
||||
|
||||
private function notifyBookingConfirmed(QrBooking $booking): void
|
||||
{
|
||||
if (! $booking->customer_phone) {
|
||||
return;
|
||||
}
|
||||
|
||||
$business = $booking->qrCode?->content()['booking_title'] ?? $booking->qrCode?->label ?? 'Business';
|
||||
$this->sms->send(
|
||||
$booking->customer_phone,
|
||||
sprintf(
|
||||
'Hi %s, your booking for %s at %s is confirmed for %s. Ref: %s',
|
||||
explode(' ', $booking->customer_name ?? 'there')[0],
|
||||
$booking->service_name,
|
||||
$business,
|
||||
$booking->starts_at->format('D j M, g:i A'),
|
||||
$booking->payment_reference ?? ('BK-'.$booking->id)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public function notifyBookingStatusUpdated(QrBooking $booking): void
|
||||
{
|
||||
if (! $booking->customer_phone) {
|
||||
return;
|
||||
}
|
||||
|
||||
$business = $booking->qrCode?->content()['booking_title'] ?? $booking->qrCode?->label ?? 'Business';
|
||||
$this->sms->send(
|
||||
$booking->customer_phone,
|
||||
sprintf(
|
||||
'Hi %s, your booking at %s (%s) is now: %s.',
|
||||
explode(' ', $booking->customer_name ?? 'there')[0],
|
||||
$business,
|
||||
$booking->starts_at->format('D j M, g:i A'),
|
||||
$booking->fulfillmentLabel()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public function notifyStatusUpdated(QrSaleOrder $order): void
|
||||
{
|
||||
if (! $order->customer_phone) {
|
||||
return;
|
||||
}
|
||||
|
||||
$storeName = $order->qrCode?->label ?? config('app.name');
|
||||
$this->sms->send(
|
||||
$order->customer_phone,
|
||||
sprintf(
|
||||
'Hi %s, your order #%d at %s is now: %s.',
|
||||
explode(' ', $order->customer_name ?? 'there')[0],
|
||||
$order->id,
|
||||
$storeName,
|
||||
$order->fulfillmentLabel()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private function notifyOrderPlaced(QrSaleOrder $order): void
|
||||
{
|
||||
if (! $order->customer_phone) {
|
||||
return;
|
||||
}
|
||||
|
||||
$storeName = $order->qrCode?->label ?? config('app.name');
|
||||
$this->sms->send(
|
||||
$order->customer_phone,
|
||||
sprintf(
|
||||
'Hi %s, your order #%d at %s has been received! Total: GHS %s. Ref: %s',
|
||||
explode(' ', $order->customer_name ?? 'there')[0],
|
||||
$order->id,
|
||||
$storeName,
|
||||
number_format((float) $order->amount_ghs, 2),
|
||||
$order->payment_reference
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** @return list<array{name: string, price_ghs: float, qty: int}> */
|
||||
private function normalizeItems(array $items): array
|
||||
{
|
||||
$normalized = [];
|
||||
foreach ($items as $item) {
|
||||
$name = trim((string) ($item['name'] ?? ''));
|
||||
$price = max(0, (float) ($item['price'] ?? 0));
|
||||
$qty = max(1, (int) ($item['qty'] ?? 1));
|
||||
|
||||
if ($name === '' || $price <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized[] = [
|
||||
'name' => $name,
|
||||
'price_ghs' => $price,
|
||||
'qty' => $qty,
|
||||
];
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Pay;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
/**
|
||||
* Client for the platform Ladill Pay HTTP API — checkout and settlement for
|
||||
* merchant↔buyer money. Paystack is the processor today; settlement lands in
|
||||
* the one UserWallet via Platform Billing.
|
||||
*/
|
||||
class PayClient
|
||||
{
|
||||
private function base(): string
|
||||
{
|
||||
return rtrim((string) config('pay.api_url'), '/');
|
||||
}
|
||||
|
||||
private function token(): string
|
||||
{
|
||||
return (string) (config('pay.api_key') ?? '');
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $payload */
|
||||
public function createCheckout(array $payload): array
|
||||
{
|
||||
$res = Http::withToken($this->token())->acceptJson()->timeout(15)->post($this->base().'/checkouts', $payload);
|
||||
$res->throw();
|
||||
|
||||
return (array) $res->json();
|
||||
}
|
||||
|
||||
public function verify(string $reference): array
|
||||
{
|
||||
$res = Http::withToken($this->token())->acceptJson()->timeout(15)->post($this->base().'/checkouts/verify', [
|
||||
'reference' => $reference,
|
||||
]);
|
||||
$res->throw();
|
||||
|
||||
return (array) $res->json();
|
||||
}
|
||||
|
||||
public function show(string $reference): array
|
||||
{
|
||||
$res = Http::withToken($this->token())->acceptJson()->timeout(10)->get($this->base().'/orders/'.$reference);
|
||||
$res->throw();
|
||||
|
||||
return (array) $res->json();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Qr;
|
||||
|
||||
use App\Models\QrCode;
|
||||
use App\Models\QrScanEvent;
|
||||
use App\Models\QrWallet;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class QrAnalyticsService
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function summaryFor(QrCode $qrCode): array
|
||||
{
|
||||
$now = now();
|
||||
$events = QrScanEvent::query()->where('qr_code_id', $qrCode->id);
|
||||
|
||||
return [
|
||||
'total_scans' => (int) $qrCode->scans_total,
|
||||
'unique_scans' => (int) $qrCode->unique_scans_total,
|
||||
'scans_7d' => (clone $events)->where('scanned_at', '>=', $now->copy()->subDays(7))->count(),
|
||||
'scans_30d' => (clone $events)->where('scanned_at', '>=', $now->copy()->subDays(30))->count(),
|
||||
'last_scanned_at' => $qrCode->last_scanned_at,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, object{date: string, total: int}>
|
||||
*/
|
||||
public function dailyScans(QrCode $qrCode, int $days = 30): Collection
|
||||
{
|
||||
$start = now()->subDays($days - 1)->startOfDay();
|
||||
|
||||
$rows = QrScanEvent::query()
|
||||
->selectRaw('DATE(scanned_at) as scan_date, COUNT(*) as total')
|
||||
->where('qr_code_id', $qrCode->id)
|
||||
->where('scanned_at', '>=', $start)
|
||||
->groupBy('scan_date')
|
||||
->orderBy('scan_date')
|
||||
->get()
|
||||
->keyBy('scan_date');
|
||||
|
||||
$series = collect();
|
||||
for ($i = 0; $i < $days; $i++) {
|
||||
$date = $start->copy()->addDays($i)->toDateString();
|
||||
$series->push((object) [
|
||||
'date' => $date,
|
||||
'total' => (int) ($rows->get($date)?->total ?? 0),
|
||||
]);
|
||||
}
|
||||
|
||||
return $series;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{label: string, total: int}>
|
||||
*/
|
||||
public function breakdown(QrCode $qrCode, string $column, int $limit = 5): array
|
||||
{
|
||||
return QrScanEvent::query()
|
||||
->select($column, DB::raw('COUNT(*) as total'))
|
||||
->where('qr_code_id', $qrCode->id)
|
||||
->whereNotNull($column)
|
||||
->groupBy($column)
|
||||
->orderByDesc('total')
|
||||
->limit($limit)
|
||||
->get()
|
||||
->map(fn ($row) => [
|
||||
'label' => (string) $row->{$column},
|
||||
'total' => (int) $row->total,
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \Illuminate\Database\Eloquent\Collection<int, QrScanEvent>
|
||||
*/
|
||||
public function recentScans(QrCode $qrCode, int $limit = 20)
|
||||
{
|
||||
return QrScanEvent::query()
|
||||
->where('qr_code_id', $qrCode->id)
|
||||
->latest('scanned_at')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Qr;
|
||||
|
||||
use App\Models\QrCode;
|
||||
use App\Models\QrDocument;
|
||||
use App\Models\QrWallet;
|
||||
use App\Models\User;
|
||||
use App\Support\Qr\QrStyleDefaults;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
class QrCodeManagerService
|
||||
{
|
||||
public function __construct(
|
||||
private QrWalletBillingService $billing,
|
||||
private QrImageGeneratorService $imageGenerator,
|
||||
private QrPayloadValidator $payloadValidator,
|
||||
) {}
|
||||
|
||||
public function walletFor(User $user): QrWallet
|
||||
{
|
||||
return $user->qrWallet()->firstOrCreate(
|
||||
[],
|
||||
['credit_balance' => 0, 'status' => QrWallet::STATUS_ACTIVE],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function create(User $user, array $data): QrCode
|
||||
{
|
||||
$wallet = $this->walletFor($user);
|
||||
$type = (string) ($data['type'] ?? '');
|
||||
|
||||
if (! in_array($type, [QrCode::TYPE_PAYMENT, QrCode::TYPE_SHOP], true) && ! $wallet->canCreateQr()) {
|
||||
throw new RuntimeException('Add at least GHS ' . number_format(QrWallet::pricePerQr(), 2) . ' to your QR wallet before creating codes.');
|
||||
}
|
||||
|
||||
$validated = $this->payloadValidator->validateForCreate($type, $data);
|
||||
$style = in_array($type, [QrCode::TYPE_PAYMENT, QrCode::TYPE_SHOP], true)
|
||||
? QrStyleDefaults::defaults()
|
||||
: QrStyleDefaults::merge($data['style'] ?? null);
|
||||
|
||||
return DB::transaction(function () use ($user, $wallet, $data, $type, $validated, $style) {
|
||||
$documentId = null;
|
||||
$content = $validated['content'];
|
||||
|
||||
if ($type === QrCode::TYPE_DOCUMENT) {
|
||||
$file = $data['document'] ?? null;
|
||||
if (! $file instanceof UploadedFile) {
|
||||
throw new RuntimeException('A PDF document is required for PDF QR codes.');
|
||||
}
|
||||
$documentId = $this->storeDocument($user, $file, $data['label'] ?? 'Document')->id;
|
||||
}
|
||||
|
||||
if ($type === QrCode::TYPE_IMAGE) {
|
||||
$images = $this->storeImages($user, $data);
|
||||
$content['images'] = $images;
|
||||
}
|
||||
|
||||
if ($type === QrCode::TYPE_VCARD && ($data['avatar'] ?? null) instanceof UploadedFile) {
|
||||
$content['avatar_path'] = $this->storeVcardAvatar($user, $data['avatar']);
|
||||
}
|
||||
|
||||
if ($type === QrCode::TYPE_BOOKING) {
|
||||
if (($data['menu_logo'] ?? null) instanceof UploadedFile) {
|
||||
$content['logo_path'] = $this->storeMenuBrandImage($user, $data['menu_logo'], 'menu-logos');
|
||||
}
|
||||
if (($data['menu_cover'] ?? null) instanceof UploadedFile) {
|
||||
$content['cover_path'] = $this->storeMenuBrandImage($user, $data['menu_cover'], 'menu-covers');
|
||||
}
|
||||
}
|
||||
|
||||
if ($type === QrCode::TYPE_APP && ($data['app_icon'] ?? null) instanceof UploadedFile) {
|
||||
$content['icon_path'] = $this->storeMenuBrandImage($user, $data['app_icon'], 'app-icons');
|
||||
}
|
||||
|
||||
if (in_array($type, [QrCode::TYPE_MENU, QrCode::TYPE_SHOP], true)) {
|
||||
$content['sections'] = $this->injectItemImages($user, $content['sections'] ?? [], $data, $type);
|
||||
if (($data['menu_logo'] ?? null) instanceof UploadedFile) {
|
||||
$content['logo_path'] = $this->storeMenuBrandImage($user, $data['menu_logo'], 'menu-logos');
|
||||
}
|
||||
if (($data['menu_cover'] ?? null) instanceof UploadedFile) {
|
||||
$content['cover_path'] = $this->storeMenuBrandImage($user, $data['menu_cover'], 'menu-covers');
|
||||
}
|
||||
}
|
||||
|
||||
if ($type === QrCode::TYPE_BUSINESS) {
|
||||
if (($data['business_logo'] ?? null) instanceof UploadedFile) {
|
||||
$content['logo_path'] = $this->storeMenuBrandImage($user, $data['business_logo'], 'business-logos');
|
||||
}
|
||||
if (($data['business_cover'] ?? null) instanceof UploadedFile) {
|
||||
$content['cover_path'] = $this->storeMenuBrandImage($user, $data['business_cover'], 'business-covers');
|
||||
}
|
||||
}
|
||||
|
||||
if ($type === QrCode::TYPE_SHOP) {
|
||||
if (($data['church_logo'] ?? null) instanceof UploadedFile) {
|
||||
$content['logo_path'] = $this->storeMenuBrandImage($user, $data['church_logo'], 'menu-logos');
|
||||
}
|
||||
if (($data['church_cover'] ?? null) instanceof UploadedFile) {
|
||||
$content['cover_path'] = $this->storeMenuBrandImage($user, $data['church_cover'], 'menu-covers');
|
||||
}
|
||||
}
|
||||
|
||||
if ($type === QrCode::TYPE_EVENT) {
|
||||
if (($data['event_logo'] ?? null) instanceof UploadedFile) {
|
||||
$content['logo_path'] = $this->storeMenuBrandImage($user, $data['event_logo'], 'event-logos');
|
||||
}
|
||||
if (($data['event_cover'] ?? null) instanceof UploadedFile) {
|
||||
$content['cover_path'] = $this->storeMenuBrandImage($user, $data['event_cover'], 'event-covers');
|
||||
}
|
||||
}
|
||||
|
||||
if ($type === QrCode::TYPE_ITINERARY && ($data['itinerary_cover'] ?? null) instanceof UploadedFile) {
|
||||
$content['cover_path'] = $this->storeMenuBrandImage($user, $data['itinerary_cover'], 'itinerary-covers');
|
||||
}
|
||||
|
||||
if ($type === QrCode::TYPE_PAYMENT && ($data['payment_logo'] ?? null) instanceof UploadedFile) {
|
||||
$content['logo_path'] = $this->storeMenuBrandImage($user, $data['payment_logo'], 'payment-logos');
|
||||
}
|
||||
|
||||
if ($style['logo_path'] === null && ($data['logo'] ?? null) instanceof UploadedFile && $type !== QrCode::TYPE_PAYMENT) {
|
||||
$style['logo_path'] = $this->storeLogo($user, $data['logo']);
|
||||
}
|
||||
|
||||
$shortCode = (isset($data['custom_short_code']) && $data['custom_short_code'] !== '')
|
||||
? (string) $data['custom_short_code']
|
||||
: $this->generateUniqueShortCode();
|
||||
|
||||
$payload = [
|
||||
'content' => $content,
|
||||
'style' => $style,
|
||||
];
|
||||
|
||||
// Freeze the WiFi auto-join payload so the printed code never changes on edit.
|
||||
if ($type === QrCode::TYPE_WIFI) {
|
||||
$payload['wifi_encoded'] = \App\Support\Qr\QrWifiPayload::encode($content);
|
||||
}
|
||||
|
||||
$qrCode = QrCode::create([
|
||||
'user_id' => $user->id,
|
||||
'short_code' => $shortCode,
|
||||
'type' => $type,
|
||||
'label' => trim((string) $data['label']),
|
||||
'destination_url' => $validated['destination_url'],
|
||||
'qr_document_id' => $documentId,
|
||||
'payload' => $payload,
|
||||
'is_active' => true,
|
||||
'destination_updated_at' => now(),
|
||||
]);
|
||||
|
||||
if (! in_array($type, [QrCode::TYPE_PAYMENT, QrCode::TYPE_SHOP], true)) {
|
||||
$this->billing->debitForQrCreation($wallet, $qrCode);
|
||||
}
|
||||
$this->imageGenerator->generateAndStore($qrCode);
|
||||
|
||||
return $qrCode->fresh(['document']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function update(QrCode $qrCode, array $data): QrCode
|
||||
{
|
||||
$content = $qrCode->content();
|
||||
$style = $qrCode->style();
|
||||
$destinationUrl = $qrCode->destination_url;
|
||||
$regenerate = false;
|
||||
|
||||
if (isset($data['label']) && trim((string) $data['label']) !== '') {
|
||||
$qrCode->label = trim((string) $data['label']);
|
||||
}
|
||||
|
||||
if (array_key_exists('is_active', $data)) {
|
||||
$qrCode->is_active = (bool) $data['is_active'];
|
||||
}
|
||||
|
||||
$typeFields = array_merge($content, $data);
|
||||
if ($this->hasContentChanges($qrCode, $data)) {
|
||||
$validated = $this->payloadValidator->validateForUpdate($qrCode, $typeFields);
|
||||
$content = $validated['content'];
|
||||
$destinationUrl = $validated['destination_url'];
|
||||
$qrCode->destination_updated_at = now();
|
||||
}
|
||||
|
||||
if ($qrCode->isDocumentType() && isset($data['document']) && $data['document'] instanceof UploadedFile) {
|
||||
$document = $this->storeDocument($qrCode->user, $data['document'], $data['label'] ?? $qrCode->label);
|
||||
$qrCode->qr_document_id = $document->id;
|
||||
$qrCode->destination_updated_at = now();
|
||||
}
|
||||
|
||||
if ($qrCode->isImageType()) {
|
||||
$newImages = $this->storeImages($qrCode->user, $data, false);
|
||||
if ($newImages !== []) {
|
||||
$content['images'] = array_merge($content['images'] ?? [], $newImages);
|
||||
$qrCode->destination_updated_at = now();
|
||||
}
|
||||
}
|
||||
|
||||
if ($qrCode->type === QrCode::TYPE_VCARD && ($data['avatar'] ?? null) instanceof UploadedFile) {
|
||||
if ($oldPath = $content['avatar_path'] ?? null) {
|
||||
Storage::disk('qr')->delete($oldPath);
|
||||
}
|
||||
$content['avatar_path'] = $this->storeVcardAvatar($qrCode->user, $data['avatar']);
|
||||
}
|
||||
|
||||
if ($qrCode->type === QrCode::TYPE_BOOKING) {
|
||||
if (($data['menu_logo'] ?? null) instanceof UploadedFile) {
|
||||
$content['logo_path'] = $this->storeMenuBrandImage($qrCode->user, $data['menu_logo'], 'menu-logos');
|
||||
}
|
||||
if (($data['menu_cover'] ?? null) instanceof UploadedFile) {
|
||||
$content['cover_path'] = $this->storeMenuBrandImage($qrCode->user, $data['menu_cover'], 'menu-covers');
|
||||
}
|
||||
}
|
||||
|
||||
if ($qrCode->type === QrCode::TYPE_APP && ($data['app_icon'] ?? null) instanceof UploadedFile) {
|
||||
$content['icon_path'] = $this->storeMenuBrandImage($qrCode->user, $data['app_icon'], 'app-icons');
|
||||
}
|
||||
|
||||
if (in_array($qrCode->type, [QrCode::TYPE_MENU, QrCode::TYPE_SHOP], true)) {
|
||||
$content['sections'] = $this->injectItemImages($qrCode->user, $content['sections'] ?? [], $data, $qrCode->type);
|
||||
if (($data['menu_logo'] ?? null) instanceof UploadedFile) {
|
||||
$content['logo_path'] = $this->storeMenuBrandImage($qrCode->user, $data['menu_logo'], 'menu-logos');
|
||||
}
|
||||
if (($data['menu_cover'] ?? null) instanceof UploadedFile) {
|
||||
$content['cover_path'] = $this->storeMenuBrandImage($qrCode->user, $data['menu_cover'], 'menu-covers');
|
||||
}
|
||||
}
|
||||
|
||||
if ($qrCode->type === QrCode::TYPE_BUSINESS) {
|
||||
if (($data['business_logo'] ?? null) instanceof UploadedFile) {
|
||||
$content['logo_path'] = $this->storeMenuBrandImage($qrCode->user, $data['business_logo'], 'business-logos');
|
||||
}
|
||||
if (($data['business_cover'] ?? null) instanceof UploadedFile) {
|
||||
$content['cover_path'] = $this->storeMenuBrandImage($qrCode->user, $data['business_cover'], 'business-covers');
|
||||
}
|
||||
}
|
||||
|
||||
if ($qrCode->type === QrCode::TYPE_SHOP) {
|
||||
if (($data['church_logo'] ?? null) instanceof UploadedFile) {
|
||||
$content['logo_path'] = $this->storeMenuBrandImage($qrCode->user, $data['church_logo'], 'menu-logos');
|
||||
}
|
||||
if (($data['church_cover'] ?? null) instanceof UploadedFile) {
|
||||
$content['cover_path'] = $this->storeMenuBrandImage($qrCode->user, $data['church_cover'], 'menu-covers');
|
||||
}
|
||||
}
|
||||
|
||||
if ($qrCode->type === QrCode::TYPE_PAYMENT && ($data['payment_logo'] ?? null) instanceof UploadedFile) {
|
||||
$content['logo_path'] = $this->storeMenuBrandImage($qrCode->user, $data['payment_logo'], 'payment-logos');
|
||||
}
|
||||
|
||||
if ($qrCode->type === QrCode::TYPE_EVENT) {
|
||||
if (($data['event_logo'] ?? null) instanceof UploadedFile) {
|
||||
$content['logo_path'] = $this->storeMenuBrandImage($qrCode->user, $data['event_logo'], 'event-logos');
|
||||
}
|
||||
if (($data['event_cover'] ?? null) instanceof UploadedFile) {
|
||||
$content['cover_path'] = $this->storeMenuBrandImage($qrCode->user, $data['event_cover'], 'event-covers');
|
||||
}
|
||||
}
|
||||
|
||||
if ($qrCode->type === QrCode::TYPE_ITINERARY && ($data['itinerary_cover'] ?? null) instanceof UploadedFile) {
|
||||
$content['cover_path'] = $this->storeMenuBrandImage($qrCode->user, $data['itinerary_cover'], 'itinerary-covers');
|
||||
}
|
||||
|
||||
if ($qrCode->type === QrCode::TYPE_PAYMENT) {
|
||||
$style = QrStyleDefaults::defaults();
|
||||
} else {
|
||||
if (isset($data['style']) && is_array($data['style'])) {
|
||||
$style = QrStyleDefaults::merge(array_merge($style, $data['style']));
|
||||
$regenerate = true;
|
||||
}
|
||||
|
||||
if (($data['logo'] ?? null) instanceof UploadedFile) {
|
||||
$style['logo_path'] = $this->storeLogo($qrCode->user, $data['logo']);
|
||||
$regenerate = true;
|
||||
}
|
||||
|
||||
if (($data['remove_logo'] ?? false) && $style['logo_path']) {
|
||||
$style['logo_path'] = null;
|
||||
$regenerate = true;
|
||||
}
|
||||
}
|
||||
|
||||
$qrCode->destination_url = $destinationUrl;
|
||||
// Preserve frozen keys (e.g. wifi_encoded) so the printed WiFi code stays put.
|
||||
$payload = (array) ($qrCode->payload ?? []);
|
||||
$payload['content'] = $content;
|
||||
$payload['style'] = $style;
|
||||
$qrCode->payload = $payload;
|
||||
$qrCode->save();
|
||||
|
||||
if ($regenerate) {
|
||||
$this->imageGenerator->generateAndStore($qrCode);
|
||||
}
|
||||
|
||||
return $qrCode->fresh(['document']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject uploaded item images into the sections array.
|
||||
* Form field: item_images[sectionIndex][itemIndex] (UploadedFile)
|
||||
*
|
||||
* @param array<int, mixed> $sections
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<int, mixed>
|
||||
*/
|
||||
private function injectItemImages(User $user, array $sections, array $data, string $type): array
|
||||
{
|
||||
$uploads = $data['item_images'] ?? [];
|
||||
if (! is_array($uploads) || $uploads === []) {
|
||||
return $sections;
|
||||
}
|
||||
|
||||
foreach ($uploads as $sIndex => $itemUploads) {
|
||||
if (! is_array($itemUploads)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($itemUploads as $iIndex => $file) {
|
||||
if (! ($file instanceof UploadedFile)) {
|
||||
continue;
|
||||
}
|
||||
if (! isset($sections[$sIndex]['items'][$iIndex])) {
|
||||
continue;
|
||||
}
|
||||
$mime = $file->getMimeType() ?: '';
|
||||
if (! str_starts_with($mime, 'image/')) {
|
||||
continue;
|
||||
}
|
||||
$subdir = $type === QrCode::TYPE_SHOP ? 'shop-items' : 'menu-items';
|
||||
$ext = $file->getClientOriginalExtension() ?: 'jpg';
|
||||
$path = $user->id . '/' . $subdir . '/' . Str::uuid()->toString() . '.' . $ext;
|
||||
$file->storeAs('', $path, 'qr');
|
||||
$sections[$sIndex]['items'][$iIndex]['image_path'] = $path;
|
||||
}
|
||||
}
|
||||
|
||||
return $sections;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $data */
|
||||
private function hasContentChanges(QrCode $qrCode, array $data): bool
|
||||
{
|
||||
$keys = match ($qrCode->type) {
|
||||
QrCode::TYPE_URL => ['destination_url'],
|
||||
QrCode::TYPE_LINK_LIST => ['links'],
|
||||
QrCode::TYPE_VCARD => ['first_name', 'last_name', 'phone', 'email', 'company', 'website', 'address', 'note', 'social'],
|
||||
QrCode::TYPE_BUSINESS => ['name', 'tagline', 'phone', 'email', 'website', 'address', 'hours'],
|
||||
QrCode::TYPE_SHOP => ['name', 'denomination', 'description', 'phone', 'email', 'website', 'address', 'service_times', 'org_type', 'accepts_payment', 'collection_types', 'brand_color'],
|
||||
QrCode::TYPE_EVENT => ['name', 'tagline', 'description', 'location', 'starts_at', 'ends_at', 'organizer', 'website', 'brand_color', 'tiers', 'badge_fields', 'badge_size', 'registration_open'],
|
||||
QrCode::TYPE_ITINERARY => ['title', 'subtitle', 'description', 'event_date', 'location', 'brand_color', 'days'],
|
||||
QrCode::TYPE_DOCUMENT => ['allow_download'],
|
||||
QrCode::TYPE_MENU => ['menu_title', 'sections', 'accepts_payment', 'brand_color', 'shipping_type', 'shipping_fee', 'free_shipping_above'],
|
||||
QrCode::TYPE_SHOP => ['shop_title', 'sections', 'currency', 'accepts_payment', 'brand_color', 'shipping_type', 'shipping_fee', 'free_shipping_above'],
|
||||
QrCode::TYPE_APP => ['app_name', 'ios_url', 'android_url', 'web_url', 'app_icon'],
|
||||
QrCode::TYPE_BOOKING => ['booking_title', 'description', 'services', 'accepts_payment', 'active_days', 'opens_at', 'closes_at', 'brand_color'],
|
||||
QrCode::TYPE_WIFI => ['ssid', 'password', 'encryption', 'hidden'],
|
||||
QrCode::TYPE_PAYMENT => ['business_name', 'branch_label', 'currency'],
|
||||
default => [],
|
||||
};
|
||||
|
||||
foreach ($keys as $key) {
|
||||
if (array_key_exists($key, $data)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function storeDocument(User $user, UploadedFile $file, string $title): QrDocument
|
||||
{
|
||||
$maxBytes = (int) config('qr.max_pdf_bytes', 104857600);
|
||||
|
||||
if ($file->getSize() > $maxBytes) {
|
||||
throw new RuntimeException('PDF must be 100 MB or smaller.');
|
||||
}
|
||||
|
||||
$mime = $file->getMimeType() ?: '';
|
||||
if (! in_array($mime, ['application/pdf', 'application/x-pdf'], true)) {
|
||||
throw new RuntimeException('Only PDF documents are supported.');
|
||||
}
|
||||
|
||||
$uuid = Str::uuid()->toString();
|
||||
$path = $user->id . '/documents/' . $uuid . '.pdf';
|
||||
|
||||
$file->storeAs('', $path, 'qr');
|
||||
|
||||
return QrDocument::create([
|
||||
'user_id' => $user->id,
|
||||
'title' => $title,
|
||||
'disk' => 'qr',
|
||||
'path' => $path,
|
||||
'mime_type' => 'application/pdf',
|
||||
'size_bytes' => (int) $file->getSize(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return list<array{path: string, title: string}>
|
||||
*/
|
||||
private function storeImages(User $user, array $data, bool $required = true): array
|
||||
{
|
||||
$files = [];
|
||||
if (($data['image'] ?? null) instanceof UploadedFile) {
|
||||
$files[] = $data['image'];
|
||||
}
|
||||
if (is_array($data['images'] ?? null)) {
|
||||
foreach ($data['images'] as $file) {
|
||||
if ($file instanceof UploadedFile) {
|
||||
$files[] = $file;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($required && $files === []) {
|
||||
$this->payloadValidator->validateImageUpload(null);
|
||||
}
|
||||
|
||||
$stored = [];
|
||||
foreach ($files as $index => $file) {
|
||||
$this->payloadValidator->validateImageUpload($file);
|
||||
$uuid = Str::uuid()->toString();
|
||||
$ext = $file->getClientOriginalExtension() ?: 'jpg';
|
||||
$path = $user->id . '/images/' . $uuid . '.' . $ext;
|
||||
$file->storeAs('', $path, 'qr');
|
||||
$stored[] = [
|
||||
'path' => $path,
|
||||
'title' => $file->getClientOriginalName() ?: ('Image ' . ($index + 1)),
|
||||
];
|
||||
}
|
||||
|
||||
return $stored;
|
||||
}
|
||||
|
||||
private function storeVcardAvatar(User $user, UploadedFile $file): string
|
||||
{
|
||||
$mime = $file->getMimeType() ?: '';
|
||||
if (! str_starts_with($mime, 'image/')) {
|
||||
throw new RuntimeException('Avatar must be an image file.');
|
||||
}
|
||||
|
||||
$ext = $file->getClientOriginalExtension() ?: 'jpg';
|
||||
$path = $user->id . '/vcards/' . Str::uuid()->toString() . '.' . $ext;
|
||||
$file->storeAs('', $path, 'qr');
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
private function storeBookFile(User $user, UploadedFile $file): array
|
||||
{
|
||||
$ext = strtolower($file->getClientOriginalExtension() ?: '');
|
||||
$mime = $file->getMimeType() ?: '';
|
||||
|
||||
if ($ext === 'pdf' || in_array($mime, ['application/pdf', 'application/x-pdf'], true)) {
|
||||
$type = 'pdf';
|
||||
} elseif ($ext === 'epub' || str_contains($mime, 'epub')) {
|
||||
$type = 'epub';
|
||||
} else {
|
||||
throw new RuntimeException('Only PDF and EPUB files are supported for books.');
|
||||
}
|
||||
|
||||
$path = $user->id . '/books/' . Str::uuid()->toString() . '.' . $type;
|
||||
$file->storeAs('', $path, 'qr');
|
||||
|
||||
return ['path' => $path, 'type' => $type, 'size' => (int) $file->getSize()];
|
||||
}
|
||||
|
||||
private function storeMenuBrandImage(User $user, UploadedFile $file, string $subdir): string
|
||||
{
|
||||
$mime = $file->getMimeType() ?: '';
|
||||
if (! str_starts_with($mime, 'image/')) {
|
||||
throw new RuntimeException('Only image files are supported.');
|
||||
}
|
||||
|
||||
$ext = $file->getClientOriginalExtension() ?: 'jpg';
|
||||
$path = $user->id . '/' . $subdir . '/' . Str::uuid()->toString() . '.' . $ext;
|
||||
$file->storeAs('', $path, 'qr');
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
private function storeBookCover(User $user, UploadedFile $file): string
|
||||
{
|
||||
$mime = $file->getMimeType() ?: '';
|
||||
if (! str_starts_with($mime, 'image/')) {
|
||||
throw new RuntimeException('Book cover must be an image file.');
|
||||
}
|
||||
|
||||
$ext = $file->getClientOriginalExtension() ?: 'jpg';
|
||||
$path = $user->id . '/book-covers/' . Str::uuid()->toString() . '.' . $ext;
|
||||
$file->storeAs('', $path, 'qr');
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
private function storeLogo(User $user, UploadedFile $file): string
|
||||
{
|
||||
$mime = $file->getMimeType() ?: '';
|
||||
if (! str_starts_with($mime, 'image/')) {
|
||||
throw new RuntimeException('Logo must be an image file.');
|
||||
}
|
||||
|
||||
$path = $user->id . '/logos/' . Str::uuid()->toString() . '.' . ($file->getClientOriginalExtension() ?: 'png');
|
||||
$file->storeAs('', $path, 'qr');
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
private function generateUniqueShortCode(): string
|
||||
{
|
||||
$length = (int) config('qr.short_code_length', 8);
|
||||
|
||||
for ($attempt = 0; $attempt < 20; $attempt++) {
|
||||
$code = Str::lower(Str::random($length));
|
||||
if (! QrCode::query()->where('short_code', $code)->exists()) {
|
||||
return $code;
|
||||
}
|
||||
}
|
||||
|
||||
throw new RuntimeException('Could not generate a unique QR short code.');
|
||||
}
|
||||
|
||||
public function delete(QrCode $qrCode): void
|
||||
{
|
||||
$paths = array_filter([$qrCode->png_path, $qrCode->svg_path]);
|
||||
|
||||
$logoPath = $qrCode->content()['logo_path'] ?? null;
|
||||
if (is_string($logoPath) && $logoPath !== '') {
|
||||
$paths[] = $logoPath;
|
||||
}
|
||||
|
||||
foreach ($paths as $path) {
|
||||
Storage::disk('qr')->delete($path);
|
||||
}
|
||||
|
||||
$qrCode->delete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Qr;
|
||||
|
||||
use App\Models\QrCode as QrCodeModel;
|
||||
use App\Support\Qr\QrFrameStyleCatalog;
|
||||
use App\Support\Qr\QrModuleStyleCatalog;
|
||||
use App\Support\Qr\QrStyleDefaults;
|
||||
use chillerlan\QRCode\Common\EccLevel;
|
||||
use chillerlan\QRCode\Data\QRMatrix;
|
||||
use chillerlan\QRCode\QROptions;
|
||||
use chillerlan\QRCode\QRCode;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class QrImageGeneratorService
|
||||
{
|
||||
public function generateAndStore(QrCodeModel $qrCode): void
|
||||
{
|
||||
$url = $qrCode->encodedPayload();
|
||||
$style = $qrCode->style();
|
||||
$basePath = $qrCode->user_id . '/codes/' . $qrCode->id;
|
||||
|
||||
$pngBinary = $this->renderPng($url, $style);
|
||||
$svgMarkup = $this->renderSvg($url, $style);
|
||||
|
||||
$pngPath = $basePath . '/qr.png';
|
||||
$svgPath = $basePath . '/qr.svg';
|
||||
|
||||
Storage::disk('qr')->put($pngPath, $pngBinary);
|
||||
Storage::disk('qr')->put($svgPath, $svgMarkup);
|
||||
|
||||
$qrCode->update([
|
||||
'png_path' => $pngPath,
|
||||
'svg_path' => $svgPath,
|
||||
]);
|
||||
}
|
||||
|
||||
public function ensureValidImages(QrCodeModel $qrCode): QrCodeModel
|
||||
{
|
||||
if ($this->pngIsValid($qrCode->png_path)) {
|
||||
return $qrCode;
|
||||
}
|
||||
|
||||
$this->generateAndStore($qrCode);
|
||||
|
||||
return $qrCode->fresh();
|
||||
}
|
||||
|
||||
public function previewDataUri(QrCodeModel $qrCode): string
|
||||
{
|
||||
$qrCode = $this->ensureValidImages($qrCode);
|
||||
|
||||
if ($qrCode->png_path && Storage::disk('qr')->exists($qrCode->png_path)) {
|
||||
$bytes = Storage::disk('qr')->get($qrCode->png_path);
|
||||
if ($this->isValidPngBinary($bytes)) {
|
||||
return 'data:image/png;base64,' . base64_encode($bytes);
|
||||
}
|
||||
}
|
||||
|
||||
$png = $this->renderPng($qrCode->encodedPayload(), $qrCode->style());
|
||||
$this->generateAndStore($qrCode);
|
||||
|
||||
return 'data:image/png;base64,' . base64_encode($png);
|
||||
}
|
||||
|
||||
public function logoDataUri(string $path): ?string
|
||||
{
|
||||
if (! Storage::disk('qr')->exists($path)) {
|
||||
return null;
|
||||
}
|
||||
$content = Storage::disk('qr')->get($path);
|
||||
if ($content === null) {
|
||||
return null;
|
||||
}
|
||||
$mimeType = Storage::disk('qr')->mimeType($path);
|
||||
if (! $mimeType) {
|
||||
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||
$mimeType = match ($ext) {
|
||||
'png' => 'image/png',
|
||||
'jpg', 'jpeg' => 'image/jpeg',
|
||||
'svg' => 'image/svg+xml',
|
||||
'gif' => 'image/gif',
|
||||
'webp' => 'image/webp',
|
||||
default => 'image/png',
|
||||
};
|
||||
}
|
||||
|
||||
return 'data:' . $mimeType . ';base64,' . base64_encode($content);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $style
|
||||
*/
|
||||
public function renderPng(string $content, array $style): string
|
||||
{
|
||||
$style = QrStyleDefaults::mergeForRender($style);
|
||||
$options = $this->buildOptions($style, QRCode::OUTPUT_IMAGE_PNG);
|
||||
$binary = (new QRCode($options))->render($content);
|
||||
|
||||
if ($style['logo_path'] && Storage::disk('qr')->exists($style['logo_path'])) {
|
||||
$binary = $this->applyLogo($binary, Storage::disk('qr')->path($style['logo_path']));
|
||||
}
|
||||
|
||||
return $this->applyFrame($binary, (string) ($style['frame_style'] ?? 'none'), $style);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $style
|
||||
*/
|
||||
public function renderSvg(string $content, array $style): string
|
||||
{
|
||||
$style = QrStyleDefaults::mergeForRender($style);
|
||||
$options = $this->buildOptions($style, QRCode::OUTPUT_MARKUP_SVG);
|
||||
|
||||
return (new QRCode($options))->render($content);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $style */
|
||||
private function buildOptions(array $style, string $outputType): QROptions
|
||||
{
|
||||
$fg = $this->hexToRgb((string) $style['foreground']);
|
||||
$bg = $this->hexToRgb((string) $style['background']);
|
||||
$ecc = match ($style['error_correction']) {
|
||||
'L' => EccLevel::L,
|
||||
'Q' => EccLevel::Q,
|
||||
'H' => EccLevel::H,
|
||||
default => EccLevel::M,
|
||||
};
|
||||
|
||||
$hasLogo = ! empty($style['logo_path']);
|
||||
$module = QrModuleStyleCatalog::optionsFor((string) $style['module_style']);
|
||||
$moduleUsesCircular = (bool) $module['circular'];
|
||||
$finderOuter = (string) ($style['finder_outer'] ?? 'square');
|
||||
$finderInner = (string) ($style['finder_inner'] ?? 'square');
|
||||
$finderUsesCircular = $finderOuter !== 'square' || $finderInner === 'dot';
|
||||
$usesCircular = $moduleUsesCircular || $finderUsesCircular;
|
||||
$connectPaths = $module['connect_paths'] && $outputType === QRCode::OUTPUT_MARKUP_SVG;
|
||||
|
||||
$circleRadius = (float) $module['circle_radius'];
|
||||
if ($finderOuter === 'circle') {
|
||||
$circleRadius = max($circleRadius, 0.5);
|
||||
} elseif ($finderOuter === 'rounded') {
|
||||
$circleRadius = max($circleRadius, 0.38);
|
||||
}
|
||||
|
||||
$keepAsSquare = $this->resolveKeepAsSquare($moduleUsesCircular, $finderOuter, $finderInner);
|
||||
|
||||
return new QROptions([
|
||||
'outputType' => $outputType,
|
||||
'outputBase64' => false,
|
||||
'scale' => (int) $style['scale'],
|
||||
'eccLevel' => $ecc,
|
||||
'addQuietzone' => true,
|
||||
'quietzoneSize' => (int) $style['margin'],
|
||||
'bgColor' => $bg,
|
||||
'drawCircularModules' => $usesCircular,
|
||||
'circleRadius' => $circleRadius,
|
||||
'connectPaths' => $connectPaths,
|
||||
'gdImageUseUpscale' => true,
|
||||
'keepAsSquare' => $keepAsSquare,
|
||||
'addLogoSpace' => $hasLogo,
|
||||
'logoSpaceWidth' => $hasLogo ? 13 : null,
|
||||
'logoSpaceHeight' => $hasLogo ? 13 : null,
|
||||
'moduleValues' => [
|
||||
QRMatrix::M_DATA_DARK => $fg,
|
||||
QRMatrix::M_FINDER_DARK => $fg,
|
||||
QRMatrix::M_ALIGNMENT_DARK => $fg,
|
||||
QRMatrix::M_TIMING_DARK => $fg,
|
||||
QRMatrix::M_FORMAT_DARK => $fg,
|
||||
QRMatrix::M_VERSION_DARK => $fg,
|
||||
QRMatrix::M_FINDER_DOT => $fg,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return list<int> */
|
||||
private function resolveKeepAsSquare(bool $moduleUsesCircular, string $finderOuter, string $finderInner): array
|
||||
{
|
||||
$finderUsesCircular = ($finderOuter !== 'square') || ($finderInner === 'dot') || ($finderInner === 'rounded');
|
||||
|
||||
if (! $moduleUsesCircular && ! $finderUsesCircular) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$keep = [
|
||||
// Structural modules must always stay square for reliable scanning.
|
||||
QRMatrix::M_ALIGNMENT_DARK,
|
||||
QRMatrix::M_TIMING_DARK,
|
||||
QRMatrix::M_FORMAT_DARK,
|
||||
QRMatrix::M_VERSION_DARK,
|
||||
// White separator (M_FINDER light modules) must always stay square.
|
||||
// Removing it causes the circular dark-ring modules to lose their solid
|
||||
// white backdrop, which produces a broken / empty-looking eye pattern.
|
||||
QRMatrix::M_FINDER,
|
||||
];
|
||||
|
||||
// Data modules stay square when only the finder style is circular.
|
||||
if (! $moduleUsesCircular) {
|
||||
$keep[] = QRMatrix::M_DATA_DARK;
|
||||
}
|
||||
|
||||
// Outer finder frame: square keeps the ring solid; non-square renders it as dots.
|
||||
if ($finderOuter === 'square') {
|
||||
$keep[] = QRMatrix::M_FINDER_DARK;
|
||||
}
|
||||
|
||||
// Inner finder dot: 'dot' and 'rounded' both get circular treatment.
|
||||
if ($finderInner !== 'dot' && $finderInner !== 'rounded') {
|
||||
$keep[] = QRMatrix::M_FINDER_DOT;
|
||||
}
|
||||
|
||||
return array_values(array_unique($keep));
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $style */
|
||||
private function applyFrame(string $pngBinary, string $frameStyle, array $style = []): string
|
||||
{
|
||||
$frame = QrFrameStyleCatalog::all()[$frameStyle] ?? QrFrameStyleCatalog::all()['none'];
|
||||
$borderPx = (int) $frame['border_px'];
|
||||
$mode = (string) ($frame['mode'] ?? 'border');
|
||||
|
||||
if ($borderPx === 0 || ! extension_loaded('gd')) {
|
||||
return $pngBinary;
|
||||
}
|
||||
|
||||
$source = @imagecreatefromstring($pngBinary);
|
||||
if (! $source) {
|
||||
return $pngBinary;
|
||||
}
|
||||
|
||||
$width = imagesx($source);
|
||||
$height = imagesy($source);
|
||||
$labelHeight = in_array($mode, ['label', 'pill'], true) ? max(44, (int) round($height * 0.18)) : 0;
|
||||
$canvasWidth = $width + ($borderPx * 2);
|
||||
$canvasHeight = $height + ($borderPx * 2) + $labelHeight;
|
||||
$frameColorHex = trim((string) ($style['frame_color'] ?? '#000000'));
|
||||
if (! preg_match('/^#[0-9a-fA-F]{6}$/', $frameColorHex)) {
|
||||
$frameColorHex = '#000000';
|
||||
}
|
||||
|
||||
$canvas = imagecreatetruecolor($canvasWidth, $canvasHeight);
|
||||
$white = imagecolorallocate($canvas, 255, 255, 255);
|
||||
|
||||
// For border mode the padding area IS the frame — fill with frame colour.
|
||||
// For label/pill modes the background stays white; only the CTA element uses frame colour.
|
||||
if ($mode === 'border') {
|
||||
[$fr, $fg, $fb] = $this->hexToRgb($frameColorHex);
|
||||
$frameBg = imagecolorallocate($canvas, $fr, $fg, $fb);
|
||||
imagefilledrectangle($canvas, 0, 0, $canvasWidth, $canvasHeight, $frameBg);
|
||||
} else {
|
||||
imagefilledrectangle($canvas, 0, 0, $canvasWidth, $canvasHeight, $white);
|
||||
}
|
||||
|
||||
imagecopy($canvas, $source, $borderPx, $borderPx, 0, 0, $width, $height);
|
||||
|
||||
if ($labelHeight > 0) {
|
||||
$customText = trim((string) ($style['frame_text'] ?? ''));
|
||||
$ctaText = $customText !== '' ? $customText : (string) ($frame['cta'] ?? 'SCAN ME');
|
||||
$this->drawFrameLabel($canvas, $ctaText, $borderPx, $width, $height, $labelHeight, $mode, $frameColorHex);
|
||||
}
|
||||
|
||||
ob_start();
|
||||
imagepng($canvas);
|
||||
$result = (string) ob_get_clean();
|
||||
imagedestroy($source);
|
||||
imagedestroy($canvas);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function drawFrameLabel(\GdImage $canvas, string $text, int $borderPx, int $qrWidth, int $qrHeight, int $labelHeight, string $mode, string $frameColorHex = '#000000'): void
|
||||
{
|
||||
$canvasWidth = imagesx($canvas);
|
||||
$canvasHeight = imagesy($canvas);
|
||||
[$fr, $fg, $fb] = $this->hexToRgb($frameColorHex);
|
||||
$frameColor = imagecolorallocate($canvas, $fr, $fg, $fb);
|
||||
// Choose black or white text depending on frame colour luminance
|
||||
$luminance = (0.2126 * $fr + 0.7152 * $fg + 0.0722 * $fb) / 255;
|
||||
$textOnFrame = $luminance > 0.35
|
||||
? imagecolorallocate($canvas, 0, 0, 0)
|
||||
: imagecolorallocate($canvas, 255, 255, 255);
|
||||
$dividerColor = imagecolorallocate($canvas, $fr, $fg, $fb);
|
||||
$labelTop = $borderPx + $qrHeight;
|
||||
$labelBottom = $canvasHeight - max(8, (int) round($borderPx * 0.6));
|
||||
|
||||
if ($mode === 'pill') {
|
||||
$pillMargin = max(12, (int) round($borderPx * 0.9));
|
||||
$pillTop = $labelTop + max(7, (int) round($labelHeight * 0.18));
|
||||
$pillBottom = $labelBottom - max(5, (int) round($labelHeight * 0.12));
|
||||
imagefilledrectangle($canvas, $pillMargin + 10, $pillTop, $canvasWidth - $pillMargin - 10, $pillBottom, $frameColor);
|
||||
imagefilledellipse($canvas, $pillMargin + 10, (int) (($pillTop + $pillBottom) / 2), $pillBottom - $pillTop, $pillBottom - $pillTop, $frameColor);
|
||||
imagefilledellipse($canvas, $canvasWidth - $pillMargin - 10, (int) (($pillTop + $pillBottom) / 2), $pillBottom - $pillTop, $pillBottom - $pillTop, $frameColor);
|
||||
$this->drawCenteredString($canvas, $text, $textOnFrame, 5, $pillTop, $pillBottom);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
imageline($canvas, $borderPx + 8, $labelTop + 3, $borderPx + $qrWidth - 8, $labelTop + 3, $dividerColor);
|
||||
$this->drawCenteredString($canvas, $text, $frameColor, 5, $labelTop + 7, $labelBottom);
|
||||
}
|
||||
|
||||
private function drawCenteredString(\GdImage $canvas, string $text, int $color, int $font, int $top, int $bottom): void
|
||||
{
|
||||
$text = strtoupper($text);
|
||||
$font = max(1, min(5, $font));
|
||||
$textWidth = imagefontwidth($font) * strlen($text);
|
||||
$textHeight = imagefontheight($font);
|
||||
$x = max(0, (int) round((imagesx($canvas) - $textWidth) / 2));
|
||||
$y = max($top, (int) round($top + (($bottom - $top - $textHeight) / 2)));
|
||||
imagestring($canvas, $font, $x, $y, $text, $color);
|
||||
}
|
||||
|
||||
/** @return array{0: int, 1: int, 2: int} */
|
||||
private function hexToRgb(string $hex): array
|
||||
{
|
||||
$hex = ltrim(trim($hex), '#');
|
||||
if (strlen($hex) === 3) {
|
||||
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
|
||||
}
|
||||
|
||||
if (strlen($hex) !== 6 || ! ctype_xdigit($hex)) {
|
||||
return [0, 0, 0];
|
||||
}
|
||||
|
||||
return [
|
||||
hexdec(substr($hex, 0, 2)),
|
||||
hexdec(substr($hex, 2, 2)),
|
||||
hexdec(substr($hex, 4, 2)),
|
||||
];
|
||||
}
|
||||
|
||||
private function applyLogo(string $pngBinary, string $logoPath): string
|
||||
{
|
||||
if (! extension_loaded('gd')) {
|
||||
return $pngBinary;
|
||||
}
|
||||
|
||||
$qr = imagecreatefromstring($pngBinary);
|
||||
$logo = @imagecreatefromstring((string) file_get_contents($logoPath));
|
||||
|
||||
if (! $qr || ! $logo) {
|
||||
return $pngBinary;
|
||||
}
|
||||
|
||||
$qrW = imagesx($qr);
|
||||
$qrH = imagesy($qr);
|
||||
$logoW = imagesx($logo);
|
||||
$logoH = imagesy($logo);
|
||||
$target = (int) round(min($qrW, $qrH) * 0.22);
|
||||
$resized = imagecreatetruecolor($target, $target);
|
||||
imagealphablending($resized, false);
|
||||
imagesavealpha($resized, true);
|
||||
$transparent = imagecolorallocatealpha($resized, 255, 255, 255, 127);
|
||||
imagefilledrectangle($resized, 0, 0, $target, $target, $transparent);
|
||||
imagecopyresampled($resized, $logo, 0, 0, 0, 0, $target, $target, $logoW, $logoH);
|
||||
|
||||
$pad = (int) round($target * 0.12);
|
||||
$bgSize = $target + ($pad * 2);
|
||||
$bgX = (int) (($qrW - $bgSize) / 2);
|
||||
$bgY = (int) (($qrH - $bgSize) / 2);
|
||||
$white = imagecolorallocate($qr, 255, 255, 255);
|
||||
imagefilledrectangle($qr, $bgX, $bgY, $bgX + $bgSize, $bgY + $bgSize, $white);
|
||||
imagecopy($qr, $resized, $bgX + $pad, $bgY + $pad, 0, 0, $target, $target);
|
||||
|
||||
ob_start();
|
||||
imagepng($qr);
|
||||
$result = (string) ob_get_clean();
|
||||
imagedestroy($qr);
|
||||
imagedestroy($logo);
|
||||
imagedestroy($resized);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function pngIsValid(?string $path): bool
|
||||
{
|
||||
if (! $path || ! Storage::disk('qr')->exists($path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->isValidPngBinary(Storage::disk('qr')->get($path));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a client-supplied QR SVG before storing it. QR SVGs are paths,
|
||||
* rects, gradients, clip-paths and an embedded data: logo — never scripts.
|
||||
* Strips script/foreignObject, inline event handlers, javascript: URIs and
|
||||
* any non-data external image/href references. Returns null if it isn't an
|
||||
* SVG. Downloads are served as attachments, so this is defence-in-depth.
|
||||
*/
|
||||
public function sanitizeSvg(string $svg): ?string
|
||||
{
|
||||
$svg = trim($svg);
|
||||
if (! preg_match('/<svg[\s>]/i', $svg)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Drop XML declaration / doctype.
|
||||
$svg = preg_replace('/<\?xml.*?\?>/is', '', $svg);
|
||||
$svg = preg_replace('/<!DOCTYPE.*?>/is', '', $svg);
|
||||
|
||||
// Remove dangerous elements entirely (with or without content).
|
||||
$svg = preg_replace('#<\s*(script|foreignObject|iframe|style)\b[^>]*>.*?<\s*/\s*\1\s*>#is', '', $svg);
|
||||
$svg = preg_replace('#<\s*(script|foreignObject|iframe|style)\b[^>]*/?>#is', '', $svg);
|
||||
|
||||
// Strip inline event handlers (onload, onclick, …).
|
||||
$svg = preg_replace('/\son[a-z]+\s*=\s*"[^"]*"/i', '', $svg);
|
||||
$svg = preg_replace("/\son[a-z]+\s*=\s*'[^']*'/i", '', $svg);
|
||||
|
||||
// Neutralise javascript: in any href / xlink:href.
|
||||
$svg = preg_replace('/((?:xlink:)?href)\s*=\s*"\s*javascript:[^"]*"/i', '$1="#"', $svg);
|
||||
$svg = preg_replace("/((?:xlink:)?href)\s*=\s*'\s*javascript:[^']*'/i", '$1="#"', $svg);
|
||||
|
||||
// Remove external (non-data:) image references; keep embedded data: logos.
|
||||
$svg = preg_replace('/<image\b(?:(?!href)[^>])*(?:xlink:)?href\s*=\s*"(?!data:)[^"]*"[^>]*>/is', '', $svg);
|
||||
|
||||
$svg = trim($svg);
|
||||
if (! str_contains($svg, '<svg')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Guarantee namespaces + an XML prolog so the file renders in strict SVG
|
||||
// viewers (Illustrator, Inkscape, macOS Preview, print pipelines), not just
|
||||
// browsers. qr-code-styling's DOM serialization can omit xmlns:xlink, which
|
||||
// browsers tolerate but standalone viewers reject (logo/refs fail to render).
|
||||
if (preg_match('/<svg\b[^>]*>/i', $svg, $m)) {
|
||||
$open = $m[0];
|
||||
$fixed = $open;
|
||||
if (! preg_match('/\sxmlns\s*=/i', $fixed)) {
|
||||
$fixed = preg_replace('/<svg\b/i', '<svg xmlns="http://www.w3.org/2000/svg"', $fixed, 1);
|
||||
}
|
||||
if (str_contains($svg, 'xlink:') && ! preg_match('/\sxmlns:xlink\s*=/i', $fixed)) {
|
||||
$fixed = preg_replace('/<svg\b/i', '<svg xmlns:xlink="http://www.w3.org/1999/xlink"', $fixed, 1);
|
||||
}
|
||||
if ($fixed !== $open) {
|
||||
$svg = substr_replace($svg, $fixed, strpos($svg, $open), strlen($open));
|
||||
}
|
||||
}
|
||||
|
||||
if (! preg_match('/^\s*<\?xml/i', $svg)) {
|
||||
$svg = '<?xml version="1.0" encoding="UTF-8"?>' . "\n" . $svg;
|
||||
}
|
||||
|
||||
return $svg;
|
||||
}
|
||||
|
||||
public function isValidPngBinary(string $bytes): bool
|
||||
{
|
||||
if ($bytes === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (str_starts_with($bytes, "\x89PNG\r\n\x1a\n")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Legacy bug: PNG was stored as a base64 string instead of raw bytes.
|
||||
if (str_starts_with($bytes, 'iVBORw0KGgo')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function normalizeStoredPng(?string $path): ?string
|
||||
{
|
||||
if (! $path || ! Storage::disk('qr')->exists($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$bytes = Storage::disk('qr')->get($path);
|
||||
|
||||
if ($this->isValidPngBinary($bytes)) {
|
||||
return $bytes;
|
||||
}
|
||||
|
||||
if (str_starts_with($bytes, 'iVBORw0KGgo')) {
|
||||
$decoded = base64_decode($bytes, true);
|
||||
if ($decoded !== false && $this->isValidPngBinary($decoded)) {
|
||||
Storage::disk('qr')->put($path, $decoded);
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Qr;
|
||||
|
||||
use App\Models\QrCode;
|
||||
use App\Support\Qr\QrDateFormatter;
|
||||
use App\Support\Qr\QrTypeCatalog;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use RuntimeException;
|
||||
|
||||
class QrPayloadValidator
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $input
|
||||
* @return array{content: array<string, mixed>, destination_url: ?string}
|
||||
*/
|
||||
public function validateForCreate(string $type, array $input): array
|
||||
{
|
||||
if (! QrTypeCatalog::isValid($type)) {
|
||||
throw new RuntimeException('Invalid QR type selected.');
|
||||
}
|
||||
|
||||
return match ($type) {
|
||||
QrCode::TYPE_URL => $this->validateUrl($input),
|
||||
QrCode::TYPE_DOCUMENT => $this->validateDocument($input),
|
||||
QrCode::TYPE_LINK_LIST => $this->validateLinkList($input),
|
||||
QrCode::TYPE_VCARD => $this->validateVcard($input),
|
||||
QrCode::TYPE_BUSINESS => $this->validateBusiness($input),
|
||||
QrCode::TYPE_EVENT => $this->validateEvent($input),
|
||||
QrCode::TYPE_ITINERARY => $this->validateItinerary($input),
|
||||
QrCode::TYPE_IMAGE => ['content' => [], 'destination_url' => null],
|
||||
QrCode::TYPE_MENU => $this->validateMenu($input),
|
||||
QrCode::TYPE_SHOP => $this->validateShop($input),
|
||||
QrCode::TYPE_APP => $this->validateApp($input),
|
||||
QrCode::TYPE_BOOKING => $this->validateBooking($input),
|
||||
QrCode::TYPE_WIFI => $this->validateWifi($input),
|
||||
QrCode::TYPE_PAYMENT => $this->validatePayment($input),
|
||||
default => throw new RuntimeException('Unsupported QR type.'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $input
|
||||
* @return array{content: array<string, mixed>, destination_url: ?string}
|
||||
*/
|
||||
public function validateForUpdate(QrCode $qrCode, array $input): array
|
||||
{
|
||||
$merged = array_merge($qrCode->content(), $input);
|
||||
|
||||
if ($qrCode->isUrlType() && empty($merged['destination_url']) && ! empty($merged['url'])) {
|
||||
$merged['destination_url'] = $merged['url'];
|
||||
}
|
||||
|
||||
return $this->validateForCreate($qrCode->type, $merged);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $input */
|
||||
private function validateDocument(array $input): array
|
||||
{
|
||||
$allowDownload = filter_var($input['allow_download'] ?? true, FILTER_VALIDATE_BOOL);
|
||||
|
||||
return ['content' => ['allow_download' => $allowDownload], 'destination_url' => null];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $input */
|
||||
private function validateUrl(array $input): array
|
||||
{
|
||||
$url = $this->requireUrl($input['destination_url'] ?? null, 'Enter a valid destination URL.');
|
||||
|
||||
return ['content' => ['url' => $url], 'destination_url' => $url];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $input */
|
||||
private function validateLinkList(array $input): array
|
||||
{
|
||||
$links = $this->normalizeLinks($input['links'] ?? []);
|
||||
|
||||
if ($links === []) {
|
||||
throw new RuntimeException('Add at least one link.');
|
||||
}
|
||||
|
||||
return ['content' => ['links' => $links], 'destination_url' => null];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $input */
|
||||
private function validateVcard(array $input): array
|
||||
{
|
||||
$first = trim((string) ($input['first_name'] ?? ''));
|
||||
$last = trim((string) ($input['last_name'] ?? ''));
|
||||
|
||||
if ($first === '' && $last === '') {
|
||||
throw new RuntimeException('Enter a first or last name for the vCard.');
|
||||
}
|
||||
|
||||
$social = [];
|
||||
foreach (['linkedin', 'twitter', 'instagram', 'facebook', 'tiktok', 'youtube', 'whatsapp', 'snapchat'] as $platform) {
|
||||
$raw = trim((string) (($input['social'] ?? [])[$platform] ?? ''));
|
||||
if ($raw !== '') {
|
||||
$social[$platform] = static::normalizeSocialUrl($platform, $raw);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'content' => [
|
||||
'first_name' => $first,
|
||||
'last_name' => $last,
|
||||
'phone' => trim((string) ($input['phone'] ?? '')),
|
||||
'email' => trim((string) ($input['email'] ?? '')),
|
||||
'company' => trim((string) ($input['company'] ?? '')),
|
||||
'website' => trim((string) ($input['website'] ?? '')),
|
||||
'address' => trim((string) ($input['address'] ?? '')),
|
||||
'note' => trim((string) ($input['note'] ?? '')),
|
||||
'avatar_path' => $input['avatar_path'] ?? null,
|
||||
'social' => $social,
|
||||
],
|
||||
'destination_url' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $input */
|
||||
private function validateBusiness(array $input): array
|
||||
{
|
||||
$name = trim((string) ($input['name'] ?? ''));
|
||||
|
||||
if ($name === '') {
|
||||
throw new RuntimeException('Enter a business name.');
|
||||
}
|
||||
|
||||
$social = [];
|
||||
foreach (['linkedin', 'twitter', 'instagram', 'facebook', 'tiktok', 'youtube', 'whatsapp', 'snapchat'] as $platform) {
|
||||
$raw = trim((string) (($input['social'] ?? [])[$platform] ?? ''));
|
||||
if ($raw !== '') {
|
||||
$social[$platform] = static::normalizeSocialUrl($platform, $raw);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'content' => [
|
||||
'name' => $name,
|
||||
'tagline' => trim((string) ($input['tagline'] ?? '')),
|
||||
'phone' => trim((string) ($input['phone'] ?? '')),
|
||||
'email' => trim((string) ($input['email'] ?? '')),
|
||||
'website' => trim((string) ($input['website'] ?? '')),
|
||||
'address' => trim((string) ($input['address'] ?? '')),
|
||||
'hours' => trim((string) ($input['hours'] ?? '')),
|
||||
'brand_color' => $this->normalizeBrandColor($input['brand_color'] ?? null),
|
||||
'logo_path' => $input['logo_path'] ?? null,
|
||||
'cover_path' => $input['cover_path'] ?? null,
|
||||
'social' => $social,
|
||||
],
|
||||
'destination_url' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $input */
|
||||
private function validateChurch(array $input): array
|
||||
{
|
||||
$name = trim((string) ($input['name'] ?? ''));
|
||||
if ($name === '') {
|
||||
throw new RuntimeException('Enter the church name.');
|
||||
}
|
||||
|
||||
$orgTypes = ['shop', 'school', 'mosque', 'ngo', 'club'];
|
||||
$orgType = in_array($input['org_type'] ?? 'shop', $orgTypes) ? $input['org_type'] : 'shop';
|
||||
|
||||
// Normalise legacy lowercase slugs → display strings
|
||||
$legacyMap = ['offering' => 'Offering', 'tithe' => 'Tithe', 'order' => 'Order', 'harvest' => 'Harvest'];
|
||||
$collectionTypes = array_values(array_unique(array_filter(
|
||||
array_map(fn ($t) => $legacyMap[strtolower(trim((string) $t))] ?? ucwords(trim((string) $t)), (array) ($input['collection_types'] ?? [])),
|
||||
fn ($t) => $t !== '' && strlen($t) <= 60
|
||||
)));
|
||||
if (empty($collectionTypes)) {
|
||||
$collectionTypes = ['Offering', 'Tithe', 'Order', 'Harvest'];
|
||||
}
|
||||
|
||||
return [
|
||||
'content' => [
|
||||
'name' => $name,
|
||||
'denomination' => trim((string) ($input['denomination'] ?? '')),
|
||||
'description' => trim((string) ($input['description'] ?? '')),
|
||||
'phone' => trim((string) ($input['phone'] ?? '')),
|
||||
'email' => trim((string) ($input['email'] ?? '')),
|
||||
'website' => trim((string) ($input['website'] ?? '')),
|
||||
'address' => trim((string) ($input['address'] ?? '')),
|
||||
'service_times' => trim((string) ($input['service_times'] ?? '')),
|
||||
'brand_color' => $this->normalizeBrandColor($input['brand_color'] ?? null) ?? '#1a3a5c',
|
||||
'logo_path' => $input['logo_path'] ?? null,
|
||||
'cover_path' => $input['cover_path'] ?? null,
|
||||
'org_type' => $orgType,
|
||||
'accepts_payment' => true,
|
||||
'currency' => 'GHS',
|
||||
'collection_types' => $collectionTypes,
|
||||
],
|
||||
'destination_url' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $input */
|
||||
private function validateEvent(array $input): array
|
||||
{
|
||||
$name = trim((string) ($input['name'] ?? ''));
|
||||
if ($name === '') {
|
||||
throw new RuntimeException('Enter the event name.');
|
||||
}
|
||||
|
||||
// Ticket tiers: [{ name, price, capacity }]
|
||||
$tiers = [];
|
||||
foreach ((array) ($input['tiers'] ?? []) as $tier) {
|
||||
if (! is_array($tier)) {
|
||||
continue;
|
||||
}
|
||||
$tierName = trim((string) ($tier['name'] ?? ''));
|
||||
if ($tierName === '') {
|
||||
continue;
|
||||
}
|
||||
$price = round((float) ($tier['price'] ?? 0), 2);
|
||||
$capacity = (int) ($tier['capacity'] ?? 0);
|
||||
$tiers[] = [
|
||||
'name' => mb_substr($tierName, 0, 80),
|
||||
'price' => max(0, $price),
|
||||
'capacity' => max(0, $capacity), // 0 = unlimited
|
||||
];
|
||||
}
|
||||
if (empty($tiers)) {
|
||||
$tiers = [['name' => 'General Admission', 'price' => 0.0, 'capacity' => 0]];
|
||||
}
|
||||
|
||||
// Extra registration/badge fields the organiser wants captured.
|
||||
$badgeFields = [];
|
||||
foreach ((array) ($input['badge_fields'] ?? []) as $field) {
|
||||
$label = trim((string) (is_array($field) ? ($field['label'] ?? '') : $field));
|
||||
if ($label !== '' && strlen($label) <= 60) {
|
||||
$badgeFields[] = mb_substr($label, 0, 60);
|
||||
}
|
||||
}
|
||||
|
||||
// Mode: sell tickets, collect cash contributions (weddings, non-profits),
|
||||
// or a free event (registration only — no tickets, no contributions).
|
||||
$mode = in_array($input['mode'] ?? 'ticketing', ['ticketing', 'contributions', 'free'], true)
|
||||
? ($input['mode'] ?? 'ticketing')
|
||||
: 'ticketing';
|
||||
|
||||
// Contribution categories: ['Wedding Gift', 'Order', …]
|
||||
$categories = [];
|
||||
foreach ((array) ($input['contribution_categories'] ?? []) as $cat) {
|
||||
$label = trim((string) (is_array($cat) ? ($cat['name'] ?? '') : $cat));
|
||||
if ($label !== '') {
|
||||
$categories[] = mb_substr($label, 0, 80);
|
||||
}
|
||||
}
|
||||
if ($mode === 'contributions' && empty($categories)) {
|
||||
$categories = ['Contribution'];
|
||||
}
|
||||
|
||||
// Free events collect neither tickets nor contributions — a single free
|
||||
// registration. Forced deterministically so switching from a paid setup
|
||||
// can never leave a chargeable tier behind.
|
||||
if ($mode === 'free') {
|
||||
$tiers = [['name' => 'Registration', 'price' => 0.0, 'capacity' => 0]];
|
||||
$categories = [];
|
||||
}
|
||||
|
||||
// Contributions always take payment; ticketing only for paid tiers; free never.
|
||||
$acceptsPayment = match ($mode) {
|
||||
'contributions' => true,
|
||||
'free' => false,
|
||||
default => $this->eventHasPaidTier($tiers),
|
||||
};
|
||||
|
||||
return [
|
||||
'content' => [
|
||||
'name' => mb_substr($name, 0, 120),
|
||||
'tagline' => trim((string) ($input['tagline'] ?? '')),
|
||||
'description' => trim((string) ($input['description'] ?? '')),
|
||||
'location' => trim((string) ($input['location'] ?? '')),
|
||||
'starts_at' => QrDateFormatter::normalize((string) ($input['starts_at'] ?? '')),
|
||||
'ends_at' => QrDateFormatter::normalize((string) ($input['ends_at'] ?? '')),
|
||||
'organizer' => trim((string) ($input['organizer'] ?? '')),
|
||||
'website' => trim((string) ($input['website'] ?? '')),
|
||||
'brand_color' => $this->normalizeBrandColor($input['brand_color'] ?? null) ?? '#4f46e5',
|
||||
'logo_path' => $input['logo_path'] ?? null,
|
||||
'cover_path' => $input['cover_path'] ?? null,
|
||||
'currency' => 'GHS',
|
||||
'mode' => $mode,
|
||||
'tiers' => array_values($tiers),
|
||||
'contribution_categories' => array_values($categories),
|
||||
'badge_fields' => array_values($badgeFields),
|
||||
'badge_size' => in_array($input['badge_size'] ?? '4x3', ['4x3', '4x6', 'cr80'], true) ? ($input['badge_size'] ?? '4x3') : '4x3',
|
||||
'accepts_payment' => $acceptsPayment,
|
||||
'registration_open' => filter_var($input['registration_open'] ?? true, FILTER_VALIDATE_BOOL),
|
||||
'programme_qr_id' => ($pid = (int) ($input['programme_qr_id'] ?? 0)) > 0 ? $pid : null,
|
||||
],
|
||||
'destination_url' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<int, array{price: float}> $tiers */
|
||||
private function eventHasPaidTier(array $tiers): bool
|
||||
{
|
||||
foreach ($tiers as $tier) {
|
||||
if ((float) ($tier['price'] ?? 0) > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $input */
|
||||
private function validateItinerary(array $input): array
|
||||
{
|
||||
$title = trim((string) ($input['title'] ?? ''));
|
||||
if ($title === '') {
|
||||
throw new RuntimeException('Enter the itinerary title.');
|
||||
}
|
||||
|
||||
// Days: [{ label, date, items: [{ time, title, description, location, host }] }]
|
||||
$days = [];
|
||||
foreach ((array) ($input['days'] ?? []) as $day) {
|
||||
if (! is_array($day)) {
|
||||
continue;
|
||||
}
|
||||
$items = [];
|
||||
foreach ((array) ($day['items'] ?? []) as $item) {
|
||||
if (! is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
$itemTitle = trim((string) ($item['title'] ?? ''));
|
||||
if ($itemTitle === '') {
|
||||
continue;
|
||||
}
|
||||
$items[] = [
|
||||
'time' => mb_substr(trim((string) ($item['time'] ?? '')), 0, 40),
|
||||
'title' => mb_substr($itemTitle, 0, 140),
|
||||
'description' => mb_substr(trim((string) ($item['description'] ?? '')), 0, 400),
|
||||
'location' => mb_substr(trim((string) ($item['location'] ?? '')), 0, 120),
|
||||
'host' => mb_substr(trim((string) ($item['host'] ?? '')), 0, 120),
|
||||
];
|
||||
}
|
||||
if (empty($items) && trim((string) ($day['label'] ?? '')) === '') {
|
||||
continue;
|
||||
}
|
||||
$days[] = [
|
||||
'label' => mb_substr(trim((string) ($day['label'] ?? '')), 0, 80),
|
||||
'date' => QrDateFormatter::normalize((string) ($day['date'] ?? '')),
|
||||
'items' => array_values($items),
|
||||
];
|
||||
}
|
||||
if (empty($days)) {
|
||||
throw new RuntimeException('Add at least one programme item.');
|
||||
}
|
||||
|
||||
return [
|
||||
'content' => [
|
||||
'title' => mb_substr($title, 0, 120),
|
||||
'subtitle' => trim((string) ($input['subtitle'] ?? '')),
|
||||
'description' => trim((string) ($input['description'] ?? '')),
|
||||
'event_date' => QrDateFormatter::normalize((string) ($input['event_date'] ?? '')),
|
||||
'location' => trim((string) ($input['location'] ?? '')),
|
||||
'brand_color' => $this->normalizeBrandColor($input['brand_color'] ?? null) ?? '#b45309',
|
||||
'cover_path' => $input['cover_path'] ?? null,
|
||||
'days' => array_values($days),
|
||||
],
|
||||
'destination_url' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $input */
|
||||
private function validateMenu(array $input): array
|
||||
{
|
||||
$title = trim((string) ($input['menu_title'] ?? 'Menu'));
|
||||
$sections = $this->normalizeMenuSections($input['sections'] ?? []);
|
||||
$acceptsPayment = filter_var($input['accepts_payment'] ?? false, FILTER_VALIDATE_BOOL);
|
||||
|
||||
$shippingType = in_array($input['shipping_type'] ?? 'none', ['none', 'flat'], true)
|
||||
? (string) ($input['shipping_type'] ?? 'none')
|
||||
: 'none';
|
||||
$shippingFee = $shippingType === 'flat' ? round(max(0, (float) ($input['shipping_fee'] ?? 0)), 2) : 0.0;
|
||||
$freeShippingAbove = round(max(0, (float) ($input['free_shipping_above'] ?? 0)), 2);
|
||||
|
||||
if ($sections === []) {
|
||||
throw new RuntimeException('Add at least one menu section with items.');
|
||||
}
|
||||
|
||||
return [
|
||||
'content' => [
|
||||
'title' => $title,
|
||||
'sections' => $sections,
|
||||
'accepts_payment' => $acceptsPayment,
|
||||
'shipping_type' => $shippingType,
|
||||
'shipping_fee' => $shippingFee,
|
||||
'free_shipping_above' => $freeShippingAbove,
|
||||
'brand_color' => $this->normalizeBrandColor($input['brand_color'] ?? null),
|
||||
'logo_path' => $input['logo_path'] ?? null,
|
||||
'cover_path' => $input['cover_path'] ?? null,
|
||||
],
|
||||
'destination_url' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $input */
|
||||
private function validateShop(array $input): array
|
||||
{
|
||||
$title = trim((string) ($input['shop_title'] ?? $input['menu_title'] ?? 'Shop'));
|
||||
$currency = trim((string) ($input['currency'] ?? 'GHS'));
|
||||
$sections = $this->normalizeMenuSections($input['sections'] ?? []);
|
||||
$acceptsPayment = filter_var($input['accepts_payment'] ?? false, FILTER_VALIDATE_BOOL);
|
||||
|
||||
$shippingType = in_array($input['shipping_type'] ?? 'none', ['none', 'flat'], true)
|
||||
? (string) ($input['shipping_type'] ?? 'none')
|
||||
: 'none';
|
||||
$shippingFee = $shippingType === 'flat' ? round(max(0, (float) ($input['shipping_fee'] ?? 0)), 2) : 0.0;
|
||||
$freeShippingAbove = round(max(0, (float) ($input['free_shipping_above'] ?? 0)), 2);
|
||||
|
||||
if ($sections === []) {
|
||||
throw new RuntimeException('Add at least one category with products.');
|
||||
}
|
||||
|
||||
return [
|
||||
'content' => [
|
||||
'title' => $title,
|
||||
'currency' => $currency,
|
||||
'sections' => $sections,
|
||||
'accepts_payment' => $acceptsPayment,
|
||||
'shipping_type' => $shippingType,
|
||||
'shipping_fee' => $shippingFee,
|
||||
'free_shipping_above' => $freeShippingAbove,
|
||||
'brand_color' => $this->normalizeBrandColor($input['brand_color'] ?? null),
|
||||
'logo_path' => $input['logo_path'] ?? null,
|
||||
'cover_path' => $input['cover_path'] ?? null,
|
||||
],
|
||||
'destination_url' => null,
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeBrandColor(mixed $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
$hex = trim((string) $value);
|
||||
|
||||
return preg_match('/^#[0-9A-Fa-f]{6}$/', $hex) ? $hex : null;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $input */
|
||||
private function validateApp(array $input): array
|
||||
{
|
||||
$name = trim((string) ($input['app_name'] ?? ''));
|
||||
$ios = trim((string) ($input['ios_url'] ?? ''));
|
||||
$android = trim((string) ($input['android_url'] ?? ''));
|
||||
$web = trim((string) ($input['web_url'] ?? ''));
|
||||
|
||||
if ($name === '') {
|
||||
throw new RuntimeException('Enter an app name.');
|
||||
}
|
||||
|
||||
if ($ios === '' && $android === '' && $web === '') {
|
||||
throw new RuntimeException('Add at least one app store or website link.');
|
||||
}
|
||||
|
||||
foreach (['ios' => $ios, 'android' => $android, 'web' => $web] as $label => $url) {
|
||||
if ($url !== '' && ! filter_var($url, FILTER_VALIDATE_URL)) {
|
||||
throw new RuntimeException("Enter a valid {$label} URL.");
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'content' => [
|
||||
'name' => $name,
|
||||
'ios_url' => $ios,
|
||||
'android_url' => $android,
|
||||
'web_url' => $web,
|
||||
'icon_path' => $input['icon_path'] ?? null,
|
||||
],
|
||||
'destination_url' => $web ?: ($ios ?: $android),
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $input */
|
||||
private function validateBooking(array $input): array
|
||||
{
|
||||
$title = trim((string) ($input['booking_title'] ?? $input['book_title'] ?? ''));
|
||||
$services = $this->normalizeBookingServices($input['services'] ?? []);
|
||||
$acceptsPayment = filter_var($input['accepts_payment'] ?? true, FILTER_VALIDATE_BOOL);
|
||||
$activeDays = array_values(array_filter((array) ($input['active_days'] ?? ['mon', 'tue', 'wed', 'thu', 'fri'])));
|
||||
|
||||
if ($title === '') {
|
||||
throw new RuntimeException('Enter the business or page title.');
|
||||
}
|
||||
if ($services === []) {
|
||||
throw new RuntimeException('Add at least one bookable service.');
|
||||
}
|
||||
if ($activeDays === []) {
|
||||
throw new RuntimeException('Select at least one available day.');
|
||||
}
|
||||
|
||||
return [
|
||||
'content' => [
|
||||
'booking_title' => $title,
|
||||
'description' => trim((string) ($input['description'] ?? '')),
|
||||
'services' => $services,
|
||||
'accepts_payment' => $acceptsPayment,
|
||||
'active_days' => $activeDays,
|
||||
'opens_at' => trim((string) ($input['opens_at'] ?? '09:00')),
|
||||
'closes_at' => trim((string) ($input['closes_at'] ?? '17:00')),
|
||||
'brand_color' => $this->normalizeBrandColor($input['brand_color'] ?? null),
|
||||
'logo_path' => $input['logo_path'] ?? null,
|
||||
'cover_path' => $input['cover_path'] ?? null,
|
||||
],
|
||||
'destination_url' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param mixed $services
|
||||
* @return list<array{name: string, duration_minutes: int, price_ghs: float, description: string}>
|
||||
*/
|
||||
private function normalizeBookingServices(mixed $services): array
|
||||
{
|
||||
if (! is_array($services)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
foreach ($services as $service) {
|
||||
if (! is_array($service)) {
|
||||
continue;
|
||||
}
|
||||
$name = trim((string) ($service['name'] ?? ''));
|
||||
$duration = max(15, (int) ($service['duration_minutes'] ?? 30));
|
||||
$price = round(max(0, (float) ($service['price_ghs'] ?? $service['price'] ?? 0)), 2);
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
$normalized[] = [
|
||||
'name' => $name,
|
||||
'duration_minutes' => $duration,
|
||||
'price_ghs' => $price,
|
||||
'description' => trim((string) ($service['description'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $input */
|
||||
private function validateWifi(array $input): array
|
||||
{
|
||||
$ssid = trim((string) ($input['ssid'] ?? ''));
|
||||
if ($ssid === '') {
|
||||
throw new RuntimeException('Enter a WiFi network name (SSID).');
|
||||
}
|
||||
|
||||
$encryption = strtoupper(trim((string) ($input['encryption'] ?? 'WPA')));
|
||||
if (! in_array($encryption, ['WPA', 'WEP', 'NOPASS'], true)) {
|
||||
$encryption = 'WPA';
|
||||
}
|
||||
|
||||
return [
|
||||
'content' => [
|
||||
'ssid' => $ssid,
|
||||
'password' => (string) ($input['password'] ?? ''),
|
||||
'encryption' => $encryption,
|
||||
'hidden' => filter_var($input['hidden'] ?? false, FILTER_VALIDATE_BOOL),
|
||||
],
|
||||
'destination_url' => null,
|
||||
];
|
||||
}
|
||||
|
||||
public static function normalizeSocialUrl(string $platform, string $value): string
|
||||
{
|
||||
// Already a full URL — leave as-is
|
||||
if (str_starts_with($value, 'http://') || str_starts_with($value, 'https://')) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$handle = ltrim($value, '@');
|
||||
|
||||
return match ($platform) {
|
||||
'linkedin' => 'https://linkedin.com/in/' . $handle,
|
||||
'twitter' => 'https://x.com/' . $handle,
|
||||
'instagram' => 'https://instagram.com/' . $handle,
|
||||
'facebook' => 'https://facebook.com/' . $handle,
|
||||
'tiktok' => 'https://tiktok.com/@' . $handle,
|
||||
'youtube' => 'https://youtube.com/@' . $handle,
|
||||
'snapchat' => 'https://snapchat.com/add/' . $handle,
|
||||
'whatsapp' => 'https://wa.me/' . preg_replace('/\D+/', '', $value),
|
||||
default => $value,
|
||||
};
|
||||
}
|
||||
|
||||
private function requireUrl(mixed $value, string $message): string
|
||||
{
|
||||
$url = trim((string) $value);
|
||||
if ($url === '' || ! filter_var($url, FILTER_VALIDATE_URL)) {
|
||||
throw new RuntimeException($message);
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
/** @return list<array{title: string, url: string}> */
|
||||
private function normalizeLinks(mixed $links): array
|
||||
{
|
||||
if (! is_array($links)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
foreach ($links as $link) {
|
||||
if (! is_array($link)) {
|
||||
continue;
|
||||
}
|
||||
$title = trim((string) ($link['title'] ?? ''));
|
||||
$url = trim((string) ($link['url'] ?? ''));
|
||||
if ($title === '' || $url === '' || ! filter_var($url, FILTER_VALIDATE_URL)) {
|
||||
continue;
|
||||
}
|
||||
$normalized[] = ['title' => $title, 'url' => $url];
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize menu/shop sections. Preserves existing image_path on items so that
|
||||
* the manager can inject freshly uploaded paths after validation.
|
||||
*
|
||||
* @return list<array{name: string, items: list<array{name: string, description: string, price: string, image_path: ?string}>}>
|
||||
*/
|
||||
private function normalizeMenuSections(mixed $sections): array
|
||||
{
|
||||
if (! is_array($sections)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
foreach ($sections as $section) {
|
||||
if (! is_array($section)) {
|
||||
continue;
|
||||
}
|
||||
$name = trim((string) ($section['name'] ?? ''));
|
||||
$items = [];
|
||||
foreach (($section['items'] ?? []) as $item) {
|
||||
if (! is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
$itemName = trim((string) ($item['name'] ?? ''));
|
||||
if ($itemName === '') {
|
||||
continue;
|
||||
}
|
||||
$items[] = [
|
||||
'name' => $itemName,
|
||||
'description' => trim((string) ($item['description'] ?? '')),
|
||||
'price' => trim((string) ($item['price'] ?? '')),
|
||||
'image_path' => ($item['image_path'] ?? null) ?: null,
|
||||
];
|
||||
}
|
||||
if ($name !== '' && $items !== []) {
|
||||
$normalized[] = ['name' => $name, 'items' => $items];
|
||||
}
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
public function validateImageUpload(?UploadedFile $file): void
|
||||
{
|
||||
if (! $file instanceof UploadedFile) {
|
||||
throw new RuntimeException('Upload at least one image.');
|
||||
}
|
||||
|
||||
$mime = $file->getMimeType() ?: '';
|
||||
if (! str_starts_with($mime, 'image/')) {
|
||||
throw new RuntimeException('Only image files are supported.');
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $input */
|
||||
private function validatePayment(array $input): array
|
||||
{
|
||||
$businessName = trim((string) ($input['business_name'] ?? ''));
|
||||
if ($businessName === '') {
|
||||
throw new RuntimeException('Enter your business or display name.');
|
||||
}
|
||||
|
||||
return [
|
||||
'content' => [
|
||||
'business_name' => mb_substr($businessName, 0, 120),
|
||||
'branch_label' => mb_substr(trim((string) ($input['branch_label'] ?? '')), 0, 80) ?: null,
|
||||
'currency' => strtoupper(trim((string) ($input['currency'] ?? 'GHS'))) ?: 'GHS',
|
||||
],
|
||||
'destination_url' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Qr;
|
||||
|
||||
class QrPdfExporter
|
||||
{
|
||||
public function fromPng(string $pngBinary, string $title): string
|
||||
{
|
||||
if (! extension_loaded('gd')) {
|
||||
throw new \RuntimeException('PDF export requires the GD extension.');
|
||||
}
|
||||
|
||||
$image = imagecreatefromstring($pngBinary);
|
||||
if ($image === false) {
|
||||
throw new \RuntimeException('Could not read QR image for PDF export.');
|
||||
}
|
||||
|
||||
$width = imagesx($image);
|
||||
$height = imagesy($image);
|
||||
$canvas = imagecreatetruecolor($width, $height);
|
||||
$white = imagecolorallocate($canvas, 255, 255, 255);
|
||||
imagefilledrectangle($canvas, 0, 0, $width, $height, $white);
|
||||
imagecopy($canvas, $image, 0, 0, 0, 0, $width, $height);
|
||||
imagedestroy($image);
|
||||
|
||||
ob_start();
|
||||
imagejpeg($canvas, null, 95);
|
||||
$jpeg = (string) ob_get_clean();
|
||||
imagedestroy($canvas);
|
||||
|
||||
return $this->buildPdf($jpeg, $title, $width, $height);
|
||||
}
|
||||
|
||||
private function buildPdf(string $jpeg, string $title, int $imgW, int $imgH): string
|
||||
{
|
||||
$pageW = 595.28;
|
||||
$pageH = 841.89;
|
||||
$margin = 48;
|
||||
$maxQr = min($pageW - ($margin * 2), 320);
|
||||
$scale = min($maxQr / max(1, $imgW), $maxQr / max(1, $imgH));
|
||||
$drawW = $imgW * $scale;
|
||||
$drawH = $imgH * $scale;
|
||||
$x = ($pageW - $drawW) / 2;
|
||||
$y = 120;
|
||||
$safeTitle = $this->pdfEscape(substr($title, 0, 80));
|
||||
|
||||
$objects = [];
|
||||
$objects[] = "1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n";
|
||||
$objects[] = "2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n";
|
||||
$objects[] = sprintf(
|
||||
"3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 %.2F %.2F] /Resources << /Font << /F1 4 0 R >> /XObject << /Im1 5 0 R >> >> /Contents 6 0 R >> endobj\n",
|
||||
$pageW,
|
||||
$pageH,
|
||||
);
|
||||
$objects[] = "4 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >> endobj\n";
|
||||
$objects[] = sprintf(
|
||||
"5 0 obj << /Type /XObject /Subtype /Image /Width %d /Height %d /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length %d >> stream\n%s\nendstream\nendobj\n",
|
||||
$imgW,
|
||||
$imgH,
|
||||
strlen($jpeg),
|
||||
$jpeg,
|
||||
);
|
||||
|
||||
$content = "BT /F1 16 Tf 48 " . ($pageH - 72) . " Td ({$safeTitle}) Tj ET\n";
|
||||
$content .= sprintf("q %.4F 0 0 %.4F %.2F %.2F cm /Im1 Do Q\n", $drawW, $drawH, $x, $pageH - $y - $drawH);
|
||||
$content .= "BT /F1 10 Tf 48 48 Td (Generated by Ladill QR Codes) Tj ET\n";
|
||||
$objects[] = sprintf("6 0 obj << /Length %d >> stream\n%s\nendstream\nendobj\n", strlen($content), $content);
|
||||
|
||||
$pdf = "%PDF-1.4\n";
|
||||
$offsets = [0];
|
||||
|
||||
foreach ($objects as $object) {
|
||||
$offsets[] = strlen($pdf);
|
||||
$pdf .= $object;
|
||||
}
|
||||
|
||||
$xrefPos = strlen($pdf);
|
||||
$pdf .= "xref\n0 " . count($offsets) . "\n";
|
||||
$pdf .= "0000000000 65535 f \n";
|
||||
for ($i = 1; $i < count($offsets); $i++) {
|
||||
$pdf .= sprintf("%010d 00000 n \n", $offsets[$i]);
|
||||
}
|
||||
$pdf .= "trailer << /Size " . count($offsets) . " /Root 1 0 R >>\n";
|
||||
$pdf .= "startxref\n{$xrefPos}\n%%EOF";
|
||||
|
||||
return $pdf;
|
||||
}
|
||||
|
||||
private function pdfEscape(string $text): string
|
||||
{
|
||||
return str_replace(['\\', '(', ')'], ['\\\\', '\\(', '\\)'], $text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Qr;
|
||||
|
||||
use App\Models\QrCode;
|
||||
use App\Models\QrScanEvent;
|
||||
use App\Models\QrWallet;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class QrScanRecorder
|
||||
{
|
||||
public function record(QrCode $qrCode, Request $request): QrScanEvent
|
||||
{
|
||||
$parsed = $this->parseUserAgent((string) $request->userAgent());
|
||||
$ipHash = $this->hashIp((string) $request->ip());
|
||||
$windowHours = (int) config('qr.scan_unique_window_hours', 24);
|
||||
$isUnique = ! QrScanEvent::query()
|
||||
->where('qr_code_id', $qrCode->id)
|
||||
->where('ip_hash', $ipHash)
|
||||
->where('scanned_at', '>=', now()->subHours($windowHours))
|
||||
->exists();
|
||||
|
||||
$event = QrScanEvent::create([
|
||||
'qr_code_id' => $qrCode->id,
|
||||
'scanned_at' => now(),
|
||||
'ip_hash' => $ipHash,
|
||||
'user_agent' => Str::limit((string) $request->userAgent(), 500, ''),
|
||||
'device_type' => $parsed['device_type'],
|
||||
'browser' => $parsed['browser'],
|
||||
'os' => $parsed['os'],
|
||||
'referrer' => Str::limit((string) $request->headers->get('referer'), 255, ''),
|
||||
'is_unique' => $isUnique,
|
||||
]);
|
||||
|
||||
$qrCode->increment('scans_total');
|
||||
if ($isUnique) {
|
||||
$qrCode->increment('unique_scans_total');
|
||||
}
|
||||
$qrCode->update(['last_scanned_at' => now()]);
|
||||
|
||||
QrWallet::query()
|
||||
->where('user_id', $qrCode->user_id)
|
||||
->increment('scans_total');
|
||||
|
||||
return $event;
|
||||
}
|
||||
|
||||
private function hashIp(string $ip): string
|
||||
{
|
||||
return hash('sha256', $ip . '|' . (string) config('app.key'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{device_type: string, browser: string, os: string}
|
||||
*/
|
||||
private function parseUserAgent(string $ua): array
|
||||
{
|
||||
$uaLower = strtolower($ua);
|
||||
|
||||
$device = str_contains($uaLower, 'mobile') || str_contains($uaLower, 'android') || str_contains($uaLower, 'iphone')
|
||||
? 'mobile'
|
||||
: (str_contains($uaLower, 'tablet') || str_contains($uaLower, 'ipad') ? 'tablet' : 'desktop');
|
||||
|
||||
$browser = match (true) {
|
||||
str_contains($uaLower, 'edg/') => 'Edge',
|
||||
str_contains($uaLower, 'chrome/') && ! str_contains($uaLower, 'edg/') => 'Chrome',
|
||||
str_contains($uaLower, 'safari/') && ! str_contains($uaLower, 'chrome/') => 'Safari',
|
||||
str_contains($uaLower, 'firefox/') => 'Firefox',
|
||||
default => 'Other',
|
||||
};
|
||||
|
||||
$os = match (true) {
|
||||
str_contains($uaLower, 'iphone') || str_contains($uaLower, 'ipad') => 'iOS',
|
||||
str_contains($uaLower, 'android') => 'Android',
|
||||
str_contains($uaLower, 'windows') => 'Windows',
|
||||
str_contains($uaLower, 'mac os') || str_contains($uaLower, 'macintosh') => 'macOS',
|
||||
str_contains($uaLower, 'linux') => 'Linux',
|
||||
default => 'Other',
|
||||
};
|
||||
|
||||
return [
|
||||
'device_type' => $device,
|
||||
'browser' => $browser,
|
||||
'os' => $os,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Qr;
|
||||
|
||||
use App\Models\QrCode;
|
||||
use App\Models\QrTransaction;
|
||||
use App\Models\QrWallet;
|
||||
use App\Models\User;
|
||||
use App\Services\Billing\BillingClient;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* QR Plus billing — consumes the platform UserWallet via the Billing API.
|
||||
*/
|
||||
class QrWalletBillingService
|
||||
{
|
||||
public function __construct(
|
||||
private BillingClient $billing,
|
||||
) {}
|
||||
|
||||
public function balanceCedis(User $user): float
|
||||
{
|
||||
return $this->billing->balanceMinor($user->public_id) / 100;
|
||||
}
|
||||
|
||||
public function canCreate(User $user): bool
|
||||
{
|
||||
$priceMinor = (int) round(QrWallet::pricePerQr() * 100);
|
||||
|
||||
return $this->billing->canAfford($user->public_id, $priceMinor);
|
||||
}
|
||||
|
||||
public function debitForQrCreation(QrWallet $wallet, QrCode $qrCode): QrTransaction
|
||||
{
|
||||
$price = QrWallet::pricePerQr();
|
||||
$priceMinor = (int) round($price * 100);
|
||||
$user = $wallet->user;
|
||||
$reference = 'QR-DEBIT-'.strtoupper(Str::random(12));
|
||||
|
||||
return DB::transaction(function () use ($wallet, $user, $qrCode, $price, $priceMinor, $reference) {
|
||||
$ok = $this->billing->debit(
|
||||
$user->public_id,
|
||||
$priceMinor,
|
||||
'qr',
|
||||
'qr_create',
|
||||
$reference,
|
||||
$qrCode->id,
|
||||
sprintf('Created QR code: %s', $qrCode->label),
|
||||
);
|
||||
|
||||
if (! $ok) {
|
||||
throw new RuntimeException('Insufficient wallet balance.');
|
||||
}
|
||||
|
||||
$wallet->increment('qr_codes_total');
|
||||
$balanceAfter = $this->billing->balanceMinor($user->public_id) / 100;
|
||||
|
||||
return QrTransaction::create([
|
||||
'user_id' => $wallet->user_id,
|
||||
'qr_wallet_id' => $wallet->id,
|
||||
'qr_code_id' => $qrCode->id,
|
||||
'type' => QrTransaction::TYPE_DEBIT,
|
||||
'amount_ghs' => round($price, 4),
|
||||
'balance_after_ghs' => $balanceAfter,
|
||||
'reference' => $reference,
|
||||
'status' => 'completed',
|
||||
'description' => sprintf('Created QR code: %s', $qrCode->label),
|
||||
'metadata' => ['qr_code_id' => $qrCode->id],
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user