Initial Ladill Merchant app with Gitea deploy pipeline.

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-09 23:05:21 +00:00
co-authored by Cursor
commit f718b9cfbf
311 changed files with 38972 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[compose.yaml]
indent_size = 4
+49
View File
@@ -0,0 +1,49 @@
APP_NAME="Ladill Merchant"
APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=https://merchant.ladill.com
PLATFORM_URL=https://ladill.com
PLATFORM_DOMAIN=ladill.com
AUTH_DOMAIN=auth.ladill.com
ACCOUNT_DOMAIN=account.ladill.com
MERCHANT_DOMAIN=merchant.ladill.com
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=ladill_merchant
DB_USERNAME=ladill_merchant
DB_PASSWORD=
# Read platform admin settings (Paystack keys live in ladilldb.platform_settings).
PLATFORM_DB_HOST=127.0.0.1
PLATFORM_DB_PORT=3306
PLATFORM_DB_DATABASE=ladilldb
PLATFORM_DB_USERNAME=ladill_merchant
PLATFORM_DB_PASSWORD=
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_DOMAIN=.ladill.com
LADILL_SSO_CLIENT_ID=
LADILL_SSO_CLIENT_SECRET=
BILLING_API_URL=https://ladill.com/api/billing
BILLING_API_KEY_MERCHANT=
PAY_API_URL=https://ladill.com/api/pay
PAY_API_KEY_MERCHANT=
IDENTITY_API_URL=https://ladill.com/api
IDENTITY_API_KEY_MERCHANT=
AFIA_ENABLED=true
AFIA_PRODUCT=merchant
AFIA_PROVIDER=openai
AFIA_MODEL=gpt-4o-mini
AFIA_API_KEY=
VITE_APP_NAME="${APP_NAME}"
+11
View File
@@ -0,0 +1,11 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
/.github export-ignore
CHANGELOG.md export-ignore
.styleci.yml export-ignore
+110
View File
@@ -0,0 +1,110 @@
name: Deploy Ladill Merchant
on:
push:
branches:
- main
workflow_dispatch:
permissions:
contents: read
concurrency:
group: deploy-merchant
cancel-in-progress: true
jobs:
deploy:
runs-on: deploy
env:
NODE_ROOT: /tmp/ladill-node-22-r1
NODE_VERSION: "22.14.0"
RELEASE_ARCHIVE: /tmp/ladill-merchant-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz
WORKSPACE: /tmp/${{ gitea.repository_owner }}-merchant-${{ gitea.run_id }}-${{ gitea.run_attempt }}
LADILL_APP_ROOT: /var/www/ladill-merchant
steps:
- name: Checkout
shell: bash {0}
run: |
set -Eeuo pipefail
DEPLOY_GITEA_TOKEN="$(cat /home/deploy/.ladill-deploy-gitea-token)"
REPO_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
rm -rf "$WORKSPACE"
mkdir -p "$WORKSPACE"
export GIT_TERMINAL_PROMPT=0
git \
-c credential.helper= \
-c http.extraHeader="Authorization: token ${DEPLOY_GITEA_TOKEN}" \
clone --depth 1 --single-branch --no-tags --branch "${{ gitea.ref_name }}" \
"$REPO_URL" "$WORKSPACE"
- name: Setup Node.js
shell: bash {0}
run: |
set -Eeuo pipefail
if [ ! -x "$NODE_ROOT/bin/node" ]; then
rm -rf "$NODE_ROOT"
mkdir -p "$NODE_ROOT"
case "$(uname -m)" in
x86_64|amd64) NODE_ARCH="x64" ;;
aarch64|arm64) NODE_ARCH="arm64" ;;
*) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;;
esac
curl -fsSL "https://nodejs.org/download/release/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz" \
| tar -xJ --strip-components=1 -C "$NODE_ROOT"
fi
"$NODE_ROOT/bin/node" -v
- name: Build frontend assets
shell: bash {0}
run: |
set -Eeuo pipefail
cd "$WORKSPACE"
export PATH="$NODE_ROOT/bin:$PATH"
npm ci --no-audit --no-fund
npm run build
- name: Build release archive
shell: bash {0}
run: |
set -Eeuo pipefail
cd "$WORKSPACE"
printf '%s\n' "${{ gitea.sha }}" > REVISION
rm -f "$RELEASE_ARCHIVE"
tar -czf "$RELEASE_ARCHIVE" \
--exclude=.git --exclude=.gitea --exclude=.github --exclude=.env \
--exclude=node_modules --exclude=vendor --exclude=storage --exclude=tests .
- name: Deploy release
shell: bash {0}
env:
LADILL_RELEASE_ARCHIVE: /tmp/ladill-merchant-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz
run: |
set -Eeuo pipefail
: "${LADILL_APP_ROOT:?Set LADILL_APP_ROOT}"
bash "$WORKSPACE/deploy/deploy.sh"
- name: Ensure nginx vhost
shell: bash {0}
run: |
set -Eeuo pipefail
NGINX_SCRIPT="$WORKSPACE/deployment/setup-service-subdomain-nginx.sh"
if [ ! -f "$NGINX_SCRIPT" ]; then
NGINX_SCRIPT="/var/www/ladill.com/current/deployment/setup-service-subdomain-nginx.sh"
fi
if [ ! -f "$NGINX_SCRIPT" ]; then
echo "WARN: setup-service-subdomain-nginx.sh not found — configure merchant.ladill.com vhost manually"
exit 0
fi
if sudo -n bash "$NGINX_SCRIPT" merchant --app /var/www/ladill-merchant/current; then
echo "nginx vhost updated for merchant.ladill.com"
else
echo "WARN: nginx vhost step skipped (deploy user cannot sudo) — vhost must already be provisioned"
fi
- name: Cleanup
if: always()
shell: bash {0}
run: |
rm -rf "$WORKSPACE"
rm -f "$RELEASE_ARCHIVE"
+26
View File
@@ -0,0 +1,26 @@
*.log
.DS_Store
.env
.env.backup
.env.production
.phpactor.json
.phpunit.result.cache
/.fleet
/.idea
/.nova
/.phpunit.cache
/.vscode
/.zed
/auth.json
/node_modules
/public/build
/public/hot
/public/storage
/storage/*.key
/storage/pail
/storage/framework/views/*
!/storage/framework/views/.gitignore
/vendor
Homestead.json
Homestead.yaml
Thumbs.db
+95
View File
@@ -0,0 +1,95 @@
# Ladill Merchant — deploy & cutover runbook
Standalone app for **shop, menu & booking storefronts** at `merchant.ladill.com` — QR
storefronts with catalog checkout. Sales settle into the one platform UserWallet
(15% fee), then withdraw via `account.ladill.com`.
Public scans stay at `ladill.com/q/<code>` (blueprint decision — printed codes
never migrate). The platform host forwards shop/menu/booking codes to this app (see
`MerchantQrForwarder` on the monolith); grant the platform DB user read access:
```bash
sudo mysql -e "GRANT SELECT ON ladill_merchant.qr_codes TO 'ladilldb'@'127.0.0.1'; FLUSH PRIVILEGES;"
```
Set `MERCHANT_DB_*` on the platform `.env` if the lookup user differs from the
main app DB user.
---
## 0. Prerequisites
| Secret | Where |
|---|---|
| `LADILL_SSO_CLIENT_ID` / `LADILL_SSO_CLIENT_SECRET` | `passport:client` on platform |
| `BILLING_API_KEY_MERCHANT` | platform `.env` + this app |
| `PAY_API_KEY_MERCHANT` | platform `.env` + this app — Ladill Pay checkout (15% sales tier) |
| `IDENTITY_API_KEY_MERCHANT` | platform `.env` + this app |
| `PLATFORM_DB_*` | read `ladilldb.platform_settings` (Paystack keys from admin → Billing) |
## 1. Gitea repo + CI
1. Repo: **ladill-merchant** (create on Gitea)
2. Push to `main` triggers `.gitea/workflows/deploy.yml`.
3. App root: `/var/www/ladill-merchant`
## 2. Server app-slot + database
```bash
sudo install -d -o deploy -g www-data /var/www/ladill-merchant
sudo install -d -o deploy -g www-data /var/www/ladill-merchant/{releases,shared}
sudo mysql -e "CREATE DATABASE ladill_merchant CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
sudo mysql -e "CREATE USER 'ladill_merchant'@'127.0.0.1' IDENTIFIED BY '<pw>';"
sudo mysql -e "GRANT ALL ON ladill_merchant.* TO 'ladill_merchant'@'127.0.0.1'; FLUSH PRIVILEGES;"
sudo mysql -e "GRANT SELECT ON ladilldb.platform_settings TO 'ladill_merchant'@'127.0.0.1'; FLUSH PRIVILEGES;"
```
## 3. Register OIDC client (platform)
```bash
php artisan passport:client \
--name="Ladill Merchant" \
--redirect_uri="https://merchant.ladill.com/sso/callback"
```
## 4. Platform integration
```env
BILLING_API_KEY_MERCHANT=<same>
IDENTITY_API_KEY_MERCHANT=<same>
PAY_API_KEY_MERCHANT=<same>
RP_MERCHANT_FRONTCHANNEL_LOGOUT=https://merchant.ladill.com/sso/logout-frontchannel
LADILL_MERCHANT_APP_URL=https://merchant.ladill.com
MERCHANT_DB_DATABASE=ladill_merchant
```
Add `merchant` to `config/pdns.php` service subdomains.
## 5. nginx + TLS
```bash
sudo deployment/setup-service-subdomain-nginx.sh merchant --app /var/www/ladill-merchant/current
```
## 6. First deploy
```bash
cd /var/www/ladill-merchant/current
php artisan migrate --force
php artisan config:cache route:cache view:cache
```
## 7. Verify
- SSO login at `merchant.ladill.com`
- Create a shop, menu, or booking storefront, download QR PNG
- Scan `/q/<code>`, place test order, confirm wallet credit (net of 15%)
- Withdraw from `account.ladill.com/wallet`
## Product IA (blueprint)
- **Overview** — sales + orders (today/30d), revenue, payout balance
- **Storefronts** — shop / menu / booking QR storefronts
- **Orders** — incoming orders, fulfilment status
- **Payouts** — wallet-first settlement, withdraw to bank/MoMo
- **Settings** — organisation profile
+59
View File
@@ -0,0 +1,59 @@
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
<p align="center">
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
</p>
## About Laravel
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
- [Simple, fast routing engine](https://laravel.com/docs/routing).
- [Powerful dependency injection container](https://laravel.com/docs/container).
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
- [Robust background job processing](https://laravel.com/docs/queues).
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
Laravel is accessible, powerful, and provides tools required for large, robust applications.
## Learning Laravel
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. You can also check out [Laravel Learn](https://laravel.com/learn), where you will be guided through building a modern Laravel application.
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
## Laravel Sponsors
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com).
### Premium Partners
- **[Vehikl](https://vehikl.com)**
- **[Tighten Co.](https://tighten.co)**
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
- **[64 Robots](https://64robots.com)**
- **[Curotec](https://www.curotec.com/services/technologies/laravel)**
- **[DevSquad](https://devsquad.com/hire-laravel-developers)**
- **[Redberry](https://redberry.international/laravel-development)**
- **[Active Logic](https://activelogic.com)**
## Contributing
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
## Code of Conduct
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
## Security Vulnerabilities
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
## License
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
+1
View File
@@ -0,0 +1 @@
manual-deploy
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class MeController extends Controller
{
public function __invoke(Request $request): JsonResponse
{
$user = $request->user();
$account = ladill_account();
return response()->json([
'data' => [
'id' => $user->id,
'public_id' => $user->public_id,
'name' => $user->name,
'email' => $user->email,
'acting_account' => [
'id' => $account->id,
'public_id' => $account->public_id,
'name' => $account->name,
'email' => $account->email,
],
'accessible_account_ids' => $user->accessibleAccounts()->pluck('id')->values(),
],
]);
}
}
@@ -0,0 +1,153 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Services\Qr\QrAnalyticsService;
use App\Services\Qr\QrCodeManagerService;
use App\Support\Qr\QrTypeCatalog;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use RuntimeException;
class QrCodeController extends Controller
{
public function __construct(
private QrCodeManagerService $manager,
private QrAnalyticsService $analytics,
) {}
public function index(Request $request): JsonResponse
{
$codes = $this->accountQuery()
->latest()
->get()
->map(fn (QrCode $code) => $this->present($code));
return response()->json(['data' => $codes]);
}
public function show(QrCode $qrCode): JsonResponse
{
$this->ensurePlusCode($qrCode);
$this->authorize('view', $qrCode);
return response()->json(['data' => $this->present($qrCode, detailed: true)]);
}
public function analytics(QrCode $qrCode): JsonResponse
{
$this->ensurePlusCode($qrCode);
$this->authorize('view', $qrCode);
return response()->json([
'data' => [
'summary' => $this->analytics->summaryFor($qrCode),
'daily_scans' => $this->analytics->dailyScans($qrCode, 30)->values(),
'devices' => $this->analytics->breakdown($qrCode, 'device_type'),
'browsers' => $this->analytics->breakdown($qrCode, 'browser'),
],
]);
}
public function store(Request $request): JsonResponse
{
abort_unless($request->user()->tokenCan('qr:write'), 403, 'Token requires qr:write ability.');
$validated = $request->validate([
'label' => ['required', 'string', 'max:120'],
'type' => ['required', 'in:'.implode(',', QrTypeCatalog::keys())],
'destination_url' => ['nullable', 'url', 'max:2048'],
'custom_short_code' => ['nullable', 'string', 'regex:/^[a-z0-9][a-z0-9-]{1,18}[a-z0-9]$/', 'unique:qr_codes,short_code'],
'is_active' => ['sometimes', 'boolean'],
]);
if ($validated['type'] === QrCode::TYPE_DOCUMENT) {
return response()->json([
'message' => 'PDF QR codes must be created in the QR Plus app (file upload required).',
], 422);
}
try {
$qrCode = $this->manager->create(ladill_account(), array_merge(
$request->all(),
$validated,
));
} catch (RuntimeException $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
return response()->json(['data' => $this->present($qrCode->fresh(), detailed: true)], 201);
}
public function update(Request $request, QrCode $qrCode): JsonResponse
{
abort_unless($request->user()->tokenCan('qr:write'), 403, 'Token requires qr:write ability.');
$this->ensurePlusCode($qrCode);
$this->authorize('update', $qrCode);
$request->validate([
'label' => ['sometimes', 'string', 'max:120'],
'destination_url' => ['nullable', 'url', 'max:2048'],
'is_active' => ['sometimes', 'boolean'],
]);
if ($qrCode->isDocumentType() && $request->hasFile('document')) {
return response()->json([
'message' => 'Replace PDF files in the QR Plus app.',
], 422);
}
try {
$updated = $this->manager->update($qrCode, $request->all());
} catch (RuntimeException $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
return response()->json(['data' => $this->present($updated->fresh(), detailed: true)]);
}
/** @return \Illuminate\Database\Eloquent\Builder<QrCode> */
private function accountQuery()
{
return QrCode::query()
->where('user_id', ladill_account()->id)
->whereIn('type', QrTypeCatalog::eventTypes());
}
private function ensurePlusCode(QrCode $qrCode): void
{
abort_unless(
$qrCode->user_id === ladill_account()->id && QrTypeCatalog::isValid($qrCode->type),
404,
);
}
/** @return array<string, mixed> */
private function present(QrCode $qrCode, bool $detailed = false): array
{
$data = [
'id' => $qrCode->id,
'label' => $qrCode->label,
'type' => $qrCode->type,
'type_label' => $qrCode->typeLabel(),
'short_code' => $qrCode->short_code,
'public_url' => $qrCode->publicUrl(),
'is_active' => $qrCode->is_active,
'scans_total' => $qrCode->scans_total,
'unique_scans_total' => $qrCode->unique_scans_total,
'last_scanned_at' => $qrCode->last_scanned_at?->toIso8601String(),
'created_at' => $qrCode->created_at?->toIso8601String(),
'updated_at' => $qrCode->updated_at?->toIso8601String(),
];
if ($detailed) {
$data['destination_url'] = $qrCode->destination_url;
$data['content'] = $qrCode->content();
}
return $data;
}
}
@@ -0,0 +1,314 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\QrTeamMember;
use App\Models\User;
use Illuminate\Http\Client\Response as HttpResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Str;
use Illuminate\View\View;
/**
* "Sign in with Ladill" Authorization Code + PKCE against auth.ladill.com.
* Establishes a local session + thin user mirror keyed by the OIDC `sub`.
*/
class SsoLoginController extends Controller
{
public function connect(Request $request): RedirectResponse|View
{
$intended = (string) $request->query('redirect', route('merchant.dashboard'));
if (Auth::check()) {
return $this->safeRedirect($intended, route('merchant.dashboard'));
}
if ($this->attemptSilentRefresh($request, $intended)) {
return $this->safeRedirect($intended, route('merchant.dashboard'));
}
$verifier = Str::random(64);
$state = Str::random(40);
$request->session()->put('sso.verifier', $verifier);
$request->session()->put('sso.state', $state);
$request->session()->put('sso.intended', $intended);
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
$query = [
'response_type' => 'code',
'client_id' => (string) config('services.ladill_sso.client_id'),
'redirect_uri' => (string) config('services.ladill_sso.redirect'),
'scope' => 'openid profile email',
'state' => $state,
'code_challenge' => $challenge,
'code_challenge_method' => 'S256',
];
$loginHint = (string) $request->session()->get('sso.login_hint', '');
if ($loginHint !== '') {
$query['login_hint'] = $loginHint;
}
if (! $request->boolean('interactive')) {
$query['prompt'] = 'none';
}
$authorizeUrl = rtrim((string) config('services.ladill_sso.issuer'), '/').'/oauth/authorize?'.http_build_query($query);
if ($request->boolean('interactive') && ! $request->boolean('fallback')) {
$request->session()->put('sso.popup', true);
return view('auth.sso-signing-in', [
'authorizeUrl' => $authorizeUrl,
'intended' => $intended,
'fallbackUrl' => route('sso.connect', [
'redirect' => $intended,
'interactive' => 1,
'fallback' => 1,
]),
]);
}
return redirect()->away($authorizeUrl);
}
public function callback(Request $request): RedirectResponse|View
{
$intended = (string) $request->session()->get('sso.intended', route('merchant.dashboard'));
$popup = (bool) $request->session()->get('sso.popup');
if ($request->filled('error')) {
if (in_array($request->query('error'), ['login_required', 'interaction_required', 'consent_required'], true)
&& ! $request->boolean('interactive')) {
return redirect()->route('sso.connect', [
'redirect' => $intended,
'interactive' => 1,
]);
}
return $this->finishCallback($request, $intended, (string) $request->query('error_description', $request->query('error')), $popup);
}
if (! $request->filled('code')
|| $request->query('state') !== $request->session()->pull('sso.state')) {
return $this->finishCallback($request, $intended, 'invalid_state', $popup);
}
$issuer = rtrim((string) config('services.ladill_sso.issuer'), '/');
$tokenRes = Http::asForm()->post($issuer.'/oauth/token', [
'grant_type' => 'authorization_code',
'client_id' => (string) config('services.ladill_sso.client_id'),
'client_secret' => (string) config('services.ladill_sso.client_secret'),
'redirect_uri' => (string) config('services.ladill_sso.redirect'),
'code' => (string) $request->query('code'),
'code_verifier' => (string) $request->session()->pull('sso.verifier'),
]);
if ($tokenRes->failed()) {
return $this->finishCallback($request, $intended, 'token_exchange_failed', $popup);
}
$user = $this->loginFromTokenResponse($request, $tokenRes);
if (! $user) {
return $this->finishCallback($request, $intended, 'userinfo_failed', $popup);
}
QrTeamMember::linkPendingInvitesFor($user);
Auth::login($user, remember: true);
$request->session()->regenerate();
return $this->finishCallback($request, $intended, null, $popup);
}
public function logout(Request $request): RedirectResponse
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('sso.logout-bridge', [
'return' => $this->defaultSignedOutUrl(),
]);
}
public function logoutBridge(Request $request): View
{
$return = $this->safeReturnUrl((string) $request->query('return', ''));
$hubUrl = 'https://'.config('app.auth_domain').'/logout/sso/hub?'.http_build_query([
'embedded' => 1,
'return' => $return,
]);
return view('auth.sso-logout-bridge', [
'hubUrl' => $hubUrl,
'return' => $return,
'authOrigin' => 'https://'.config('app.auth_domain'),
]);
}
public function frontchannelLogout(Request $request): Response|RedirectResponse
{
if ($this->shouldLogoutForMailbox($request)) {
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
}
$return = (string) $request->query('return', '');
$root = (string) config('app.platform_domain', 'ladill.com');
$host = parse_url($return, PHP_URL_HOST);
if (str_starts_with($return, 'https://') && is_string($host) && ($host === $root || str_ends_with($host, '.'.$root))) {
return redirect()->away($return);
}
return response('', 204);
}
private function attemptSilentRefresh(Request $request, string $intended): bool
{
$refreshToken = (string) $request->session()->get('sso.refresh_token', '');
if ($refreshToken === '') {
return false;
}
$issuer = rtrim((string) config('services.ladill_sso.issuer'), '/');
$tokenRes = Http::asForm()->post($issuer.'/oauth/token', [
'grant_type' => 'refresh_token',
'refresh_token' => $refreshToken,
'client_id' => (string) config('services.ladill_sso.client_id'),
'client_secret' => (string) config('services.ladill_sso.client_secret'),
'scope' => 'openid profile email',
]);
if ($tokenRes->failed()) {
$request->session()->forget('sso.refresh_token');
return false;
}
$user = $this->loginFromTokenResponse($request, $tokenRes);
if (! $user) {
return false;
}
Auth::login($user, remember: true);
$request->session()->put('sso.intended', $intended);
return true;
}
private function loginFromTokenResponse(Request $request, HttpResponse $tokenRes): ?User
{
$refreshToken = (string) $tokenRes->json('refresh_token', '');
if ($refreshToken !== '') {
$request->session()->put('sso.refresh_token', $refreshToken);
}
$issuer = rtrim((string) config('services.ladill_sso.issuer'), '/');
$claims = Http::withToken((string) $tokenRes->json('access_token'))->acceptJson()->get($issuer.'/oauth/userinfo');
if ($claims->failed() || ! $claims->json('sub')) {
return null;
}
$email = (string) ($claims->json('email') ?: '');
if ($email !== '') {
$request->session()->put('sso.login_hint', $email);
}
return User::updateOrCreate(
['public_id' => (string) $claims->json('sub')],
[
'name' => $claims->json('name'),
'email' => $email !== '' ? $email : (string) $claims->json('sub').'@users.ladill.com',
'avatar_url' => $claims->json('picture'),
],
);
}
private function shouldLogoutForMailbox(Request $request): bool
{
$mailbox = strtolower(trim((string) $request->query('mailbox', '')));
if ($mailbox === '' || ! str_contains($mailbox, '@')) {
return true;
}
$user = Auth::user();
if (! $user) {
return false;
}
return strtolower((string) $user->email) !== $mailbox;
}
private function finishCallback(Request $request, string $intended, ?string $error = null, bool $popup = false): RedirectResponse|View
{
if ($popup) {
return view('auth.sso-popup-done', [
'intended' => $intended,
'error' => $error,
'appOrigin' => rtrim((string) config('app.url'), '/'),
'fallbackUrl' => route('sso.connect', [
'redirect' => $intended,
'interactive' => 1,
'fallback' => 1,
]),
]);
}
if ($error) {
return redirect()->route('sso.connect', [
'redirect' => $intended,
'interactive' => 1,
]);
}
return $this->safeRedirect($intended, route('merchant.dashboard'));
}
private function defaultSignedOutUrl(): string
{
foreach (array_keys(Route::getRoutes()->getRoutesByName()) as $name) {
if (str_ends_with($name, '.signed-out')) {
return route($name);
}
}
return 'https://'.config('app.platform_domain');
}
private function safeReturnUrl(string $url): string
{
$host = parse_url($url, PHP_URL_HOST);
$root = (string) config('app.platform_domain', 'ladill.com');
if (is_string($host) && str_starts_with($url, 'https://')
&& ($host === $root || str_ends_with($host, '.'.$root))) {
return $url;
}
return $this->defaultSignedOutUrl();
}
private function safeRedirect(string $url, string $fallback): RedirectResponse
{
$host = parse_url($url, PHP_URL_HOST);
$root = (string) config('app.platform_domain', 'ladill.com');
if (is_string($host) && str_starts_with($url, 'https://')
&& ($host === $root || str_ends_with($host, '.'.$root))) {
return redirect()->away($url);
}
return redirect()->away($fallback);
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
abstract class Controller
{
use AuthorizesRequests;
}
@@ -0,0 +1,100 @@
<?php
namespace App\Http\Controllers\Merchant;
use App\Http\Controllers\Controller;
use App\Models\QrBooking;
use App\Models\QrCode;
use App\Models\QrSaleOrder;
use App\Services\Merchant\MerchantSaleService;
use App\Support\Qr\QrTypeCatalog;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
class OrdersController extends Controller
{
public function __construct(private MerchantSaleService $sales) {}
public function index(Request $request): View
{
$account = ladill_account();
$search = trim((string) $request->query('q', ''));
$qrIds = $account->qrCodes()
->whereIn('type', QrTypeCatalog::storefrontTypes())
->pluck('id');
$orders = QrSaleOrder::query()
->whereIn('qr_code_id', $qrIds)
->when($search !== '', function ($query) use ($search) {
$like = '%'.$search.'%';
$query->where(function ($inner) use ($like) {
$inner->where('customer_name', 'like', $like)
->orWhere('customer_email', 'like', $like)
->orWhere('payment_reference', 'like', $like)
->orWhereHas('qrCode', fn ($qr) => $qr->where('label', 'like', $like));
});
})
->with('qrCode')
->latest('created_at')
->paginate(15, ['*'], 'orders_page')
->withQueryString();
$bookings = QrBooking::query()
->whereIn('qr_code_id', $qrIds)
->whereIn('status', [QrBooking::STATUS_CONFIRMED, QrBooking::STATUS_PENDING])
->when($search !== '', function ($query) use ($search) {
$like = '%'.$search.'%';
$query->where(function ($inner) use ($like) {
$inner->where('customer_name', 'like', $like)
->orWhere('customer_email', 'like', $like)
->orWhere('service_name', 'like', $like)
->orWhereHas('qrCode', fn ($qr) => $qr->where('label', 'like', $like));
});
})
->with('qrCode')
->latest('starts_at')
->paginate(15, ['*'], 'bookings_page')
->withQueryString();
return view('merchant.orders', [
'orders' => $orders,
'bookings' => $bookings,
'search' => $search,
]);
}
public function updateStatus(Request $request, QrSaleOrder $order): RedirectResponse
{
abort_if($order->user_id !== ladill_account()->id, 403);
abort_if($order->status !== QrSaleOrder::STATUS_PAID, 422, 'Only paid orders can be updated.');
$validated = $request->validate([
'fulfillment_status' => ['required', Rule::in(array_keys(QrSaleOrder::FULFILLMENT_STATUSES))],
]);
$order->update(['fulfillment_status' => $validated['fulfillment_status']]);
$order->load('qrCode');
$this->sales->notifyStatusUpdated($order);
return back()->with('success', 'Order status updated.');
}
public function updateBookingStatus(Request $request, QrBooking $booking): RedirectResponse
{
abort_if($booking->user_id !== ladill_account()->id, 403);
abort_if($booking->status !== QrBooking::STATUS_CONFIRMED, 422);
$validated = $request->validate([
'fulfillment_status' => ['required', Rule::in(array_keys(QrBooking::FULFILLMENT_STATUSES))],
]);
$booking->update(['fulfillment_status' => $validated['fulfillment_status']]);
$booking->load('qrCode');
$this->sales->notifyBookingStatusUpdated($booking);
return back()->with('success', 'Booking status updated.');
}
}
@@ -0,0 +1,71 @@
<?php
namespace App\Http\Controllers\Merchant;
use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Models\QrSaleOrder;
use App\Services\Billing\BillingClient;
use App\Support\Qr\QrTypeCatalog;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
use Throwable;
class OverviewController extends Controller
{
public function __construct(private BillingClient $billing) {}
public function index(Request $request): View
{
$account = ladill_account();
$qrIds = $account->qrCodes()
->whereIn('type', QrTypeCatalog::storefrontTypes())
->pluck('id');
$todayStart = now()->startOfDay();
$todayOrders = QrSaleOrder::query()
->whereIn('qr_code_id', $qrIds)
->where('status', QrSaleOrder::STATUS_PAID)
->where('paid_at', '>=', $todayStart);
$todayCount = (clone $todayOrders)->count();
$todayRevenue = (float) (clone $todayOrders)->sum('merchant_amount_ghs');
$recentOrders = QrSaleOrder::query()
->whereIn('qr_code_id', $qrIds)
->where('status', QrSaleOrder::STATUS_PAID)
->with('qrCode')
->latest('paid_at')
->limit(8)
->get();
$storefrontCount = $qrIds->count();
$orders30d = QrSaleOrder::query()
->whereIn('qr_code_id', $qrIds)
->where('status', QrSaleOrder::STATUS_PAID)
->where('paid_at', '>=', now()->subDays(30))
->count();
$balanceMinor = 0;
try {
$balanceMinor = $this->billing->balanceMinor($account->public_id);
} catch (Throwable $e) {
Log::warning('Merchant dashboard could not load wallet balance', [
'user' => $account->public_id,
'error' => $e->getMessage(),
]);
}
return view('merchant.dashboard', [
'todayCount' => $todayCount,
'todayRevenue' => $todayRevenue,
'storefrontCount' => $storefrontCount,
'orders30d' => $orders30d,
'recentOrders' => $recentOrders,
'balanceMinor' => $balanceMinor,
]);
}
}
@@ -0,0 +1,48 @@
<?php
namespace App\Http\Controllers\Merchant;
use App\Http\Controllers\Controller;
use App\Models\QrSaleOrder;
use App\Models\QrCode;
use App\Services\Billing\BillingClient;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
use Throwable;
class PayoutsController extends Controller
{
public function __construct(private BillingClient $billing) {}
public function index(): View
{
$account = ladill_account();
$qrIds = $account->qrCodes()
->where('type', QrCode::TYPE_SHOP)
->pluck('id');
$revenueMinor = (int) QrSaleOrder::query()
->whereIn('qr_code_id', $qrIds)
->where('status', QrSaleOrder::STATUS_PAID)
->sum('merchant_amount_minor');
$balanceMinor = 0;
try {
$balanceMinor = $this->billing->balanceMinor($account->public_id);
} catch (Throwable $e) {
Log::warning('Give payouts could not load wallet balance', [
'user' => $account->public_id,
'error' => $e->getMessage(),
]);
}
$accountWalletUrl = 'https://'.config('app.account_domain').'/wallet';
return view('merchant.payouts', [
'revenueMinor' => $revenueMinor,
'balanceMinor' => $balanceMinor,
'accountWalletUrl' => $accountWalletUrl,
]);
}
}
@@ -0,0 +1,211 @@
<?php
namespace App\Http\Controllers\Merchant;
use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Services\Qr\QrCodeManagerService;
use App\Services\Qr\QrImageGeneratorService;
use App\Services\Qr\QrPdfExporter;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\View\View;
use RuntimeException;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
class StorefrontController extends Controller
{
public function __construct(
private QrCodeManagerService $manager,
private QrImageGeneratorService $imageGenerator,
private QrPdfExporter $pdfExporter,
) {}
public function index(Request $request): View
{
$account = ladill_account();
$qrCodes = $account->qrCodes()
->whereIn('type', \App\Support\Qr\QrTypeCatalog::storefrontTypes())
->latest()
->get();
$previewDataUris = $qrCodes->mapWithKeys(function (QrCode $qr) {
return [$qr->id => $this->imageGenerator->previewDataUri($qr)];
});
return view('merchant.storefronts.index', [
'qrCodes' => $qrCodes,
'previewDataUris' => $previewDataUris,
]);
}
public function create(): View
{
return view('merchant.storefronts.create');
}
public function store(Request $request): RedirectResponse
{
$account = ladill_account();
$request->validate([
'label' => ['required', 'string', 'max:120'],
'name' => ['required', 'string', 'max:120'],
'org_type' => ['required', 'in:church,school,mosque,ngo,club'],
'denomination' => ['nullable', 'string', 'max:120'],
'description' => ['nullable', 'string', 'max:2000'],
'phone' => ['nullable', 'string', 'max:30'],
'email' => ['nullable', 'email', 'max:200'],
'website' => ['nullable', 'url', 'max:2048'],
'address' => ['nullable', 'string', 'max:500'],
'service_times' => ['nullable', 'string', 'max:200'],
'brand_color' => ['nullable', 'string', 'max:20'],
'collection_types' => ['nullable', 'array', 'min:1'],
'collection_types.*' => ['string', 'max:80'],
'church_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
'church_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
]);
$data = array_merge($request->all(), [
'type' => QrCode::TYPE_SHOP,
'currency' => 'GHS',
'accepts_payment' => true,
'church_logo' => $request->file('church_logo'),
'church_cover' => $request->file('church_cover'),
]);
try {
$qrCode = $this->manager->create($account, $data);
} catch (RuntimeException $e) {
return back()->withInput()->with('error', $e->getMessage());
}
return redirect()->route('merchant.storefronts.show', $qrCode)->with('success', 'Storefront created.');
}
public function show(QrCode $storefront): View
{
$this->authorizeStorefront($storefront);
return view('merchant.storefronts.show', [
'qrCode' => $storefront->fresh(),
'previewDataUri' => $this->imageGenerator->previewDataUri($storefront),
]);
}
public function update(Request $request, QrCode $storefront): RedirectResponse
{
$this->authorizeStorefront($storefront);
$request->validate([
'label' => ['sometimes', 'string', 'max:120'],
'name' => ['sometimes', 'string', 'max:120'],
'org_type' => ['sometimes', 'in:church,school,mosque,ngo,club'],
'denomination' => ['nullable', 'string', 'max:120'],
'description' => ['nullable', 'string', 'max:2000'],
'phone' => ['nullable', 'string', 'max:30'],
'email' => ['nullable', 'email', 'max:200'],
'website' => ['nullable', 'url', 'max:2048'],
'address' => ['nullable', 'string', 'max:500'],
'service_times' => ['nullable', 'string', 'max:200'],
'brand_color' => ['nullable', 'string', 'max:20'],
'collection_types' => ['nullable', 'array', 'min:1'],
'collection_types.*' => ['string', 'max:80'],
'is_active' => ['sometimes', 'boolean'],
'church_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
'church_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
]);
$data = array_merge($request->all(), [
'accepts_payment' => true,
'is_active' => $request->boolean('is_active', $storefront->is_active),
'church_logo' => $request->file('church_logo'),
'church_cover' => $request->file('church_cover'),
]);
try {
$this->manager->update($storefront, $data);
} catch (RuntimeException $e) {
return back()->withInput()->with('error', $e->getMessage());
}
return back()->with('success', 'Storefront updated.');
}
public function destroy(QrCode $storefront): RedirectResponse
{
$this->authorizeStorefront($storefront);
$this->manager->delete($storefront);
return redirect()
->route('merchant.storefronts.index')
->with('success', 'Storefront deleted.');
}
public function preview(QrCode $storefront): Response
{
$this->authorizeStorefront($storefront);
$qrCode = $this->imageGenerator->ensureValidImages($storefront);
$bytes = $this->imageGenerator->normalizeStoredPng($qrCode->png_path);
if ($bytes === null) {
$bytes = $this->imageGenerator->renderPng($qrCode->encodedPayload(), $qrCode->style());
$this->imageGenerator->generateAndStore($qrCode);
}
return response($bytes, 200, [
'Content-Type' => 'image/png',
'Cache-Control' => 'private, max-age=3600',
]);
}
public function download(QrCode $storefront, string $format): StreamedResponse
{
$this->authorizeStorefront($storefront);
$qrCode = $this->imageGenerator->ensureValidImages($storefront);
$path = $format === 'svg' ? $qrCode->svg_path : $qrCode->png_path;
$filename = Str::slug($qrCode->label).'-qr.'.($format === 'svg' ? 'svg' : 'png');
if ($format === 'png') {
$bytes = $this->imageGenerator->normalizeStoredPng($path);
if ($bytes === null) {
$bytes = $this->imageGenerator->renderPng($qrCode->encodedPayload(), $qrCode->style());
$this->imageGenerator->generateAndStore($qrCode);
}
return response()->streamDownload(fn () => print($bytes), $filename, [
'Content-Type' => 'image/png',
]);
}
if ($format === 'pdf') {
$bytes = $this->imageGenerator->normalizeStoredPng($qrCode->png_path);
if ($bytes === null) {
$bytes = $this->imageGenerator->renderPng($qrCode->encodedPayload(), $qrCode->style());
$this->imageGenerator->generateAndStore($qrCode);
}
$pdf = $this->pdfExporter->fromPng($bytes, $qrCode->label);
return response()->streamDownload(fn () => print($pdf), Str::slug($qrCode->label).'-qr.pdf', [
'Content-Type' => 'application/pdf',
]);
}
abort_unless($path && Storage::disk('qr')->exists($path), 404);
return Storage::disk('qr')->download($path, $filename);
}
private function authorizeStorefront(QrCode $storefront): void
{
abort_unless(in_array($storefront->type, \App\Support\Qr\QrTypeCatalog::storefrontTypes(), true), 404);
abort_unless($storefront->user_id === ladill_account()->id, 403);
}
}
@@ -0,0 +1,64 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class NotificationController extends Controller
{
public function index(Request $request): View
{
$notifications = $request->user()
->notifications()
->latest()
->paginate(20);
return view('notifications.index', compact('notifications'));
}
public function unread(Request $request): JsonResponse
{
$notifications = $request->user()
->unreadNotifications()
->latest()
->take(10)
->get()
->map(fn ($n) => [
'id' => $n->id,
'type' => class_basename($n->type),
'title' => $n->data['title'] ?? 'Notification',
'message' => $n->data['message'] ?? '',
'icon' => $n->data['icon'] ?? 'bell',
'url' => $n->data['url'] ?? null,
'created_at' => $n->created_at->diffForHumans(),
]);
return response()->json([
'notifications' => $notifications,
'unread_count' => $request->user()->unreadNotifications()->count(),
]);
}
public function markAsRead(Request $request, string $id): JsonResponse
{
$notification = $request->user()
->notifications()
->where('id', $id)
->first();
if ($notification) {
$notification->markAsRead();
}
return response()->json(['success' => true]);
}
public function markAllAsRead(Request $request): JsonResponse
{
$request->user()->unreadNotifications->markAsRead();
return response()->json(['success' => true]);
}
}
@@ -0,0 +1,114 @@
<?php
namespace App\Http\Controllers\Public;
use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Services\Merchant\BookingSlotService;
use App\Services\Merchant\MerchantSaleService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use RuntimeException;
class BookingController extends Controller
{
public function __construct(
private BookingSlotService $slots,
private MerchantSaleService $sales,
) {}
public function slots(Request $request, string $shortCode): JsonResponse
{
$qrCode = $this->resolveBookingQr($shortCode);
$validated = $request->validate([
'date' => ['required', 'date', 'after_or_equal:today'],
'service_index' => ['required', 'integer', 'min:0'],
]);
return response()->json([
'slots' => $this->slots->availableSlots(
$qrCode,
$validated['date'],
(int) $validated['service_index'],
),
]);
}
public function store(Request $request, string $shortCode): JsonResponse
{
$qrCode = $this->resolveBookingQr($shortCode);
$validated = $request->validate([
'customer_name' => ['required', 'string', 'max:120'],
'customer_email' => ['required', 'email', 'max:200'],
'customer_phone' => ['nullable', 'string', 'max:30'],
'service_index' => ['required', 'integer', 'min:0'],
'starts_at' => ['required', 'date'],
]);
try {
$result = $this->sales->initiateBooking($qrCode, $validated);
} catch (RuntimeException $e) {
return response()->json(['error' => $e->getMessage()], 422);
}
if ($result['checkout_url']) {
return response()->json([
'checkout_url' => $result['checkout_url'],
'callback_url' => $result['callback_url'],
]);
}
return response()->json([
'confirmed' => true,
'redirect_url' => route('qr.public.booking.confirmed', [
'shortCode' => $shortCode,
'booking' => $result['booking']->id,
]),
]);
}
public function callback(Request $request, string $shortCode): RedirectResponse
{
$reference = trim((string) $request->query('reference', ''));
if ($reference === '') {
return redirect('/q/'.$shortCode)->with('error', 'Missing payment reference.');
}
try {
$booking = $this->sales->completeBooking($reference);
} catch (\Throwable) {
return redirect('/q/'.$shortCode)->with('order_error', 'Payment could not be verified. Reference: '.$reference);
}
return redirect()->route('qr.public.booking.confirmed', [
'shortCode' => $shortCode,
'booking' => $booking->id,
]);
}
public function confirmed(string $shortCode, int $booking): View
{
$qrCode = $this->resolveBookingQr($shortCode);
$record = $qrCode->bookings()->whereKey($booking)->firstOrFail();
return view('public.qr.booking-confirmed', [
'qrCode' => $qrCode,
'booking' => $record,
]);
}
private function resolveBookingQr(string $shortCode): QrCode
{
$qrCode = QrCode::query()
->where('short_code', $shortCode)
->where('is_active', true)
->firstOrFail();
abort_unless($qrCode->isBookingType(), 404);
return $qrCode;
}
}
@@ -0,0 +1,341 @@
<?php
namespace App\Http\Controllers\Public;
use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Services\Qr\QrScanRecorder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\StreamedResponse;
class QrScanController extends Controller
{
public function __construct(
private QrScanRecorder $scanRecorder,
) {}
public function resolve(Request $request, string $shortCode): RedirectResponse|View
{
$qrCode = QrCode::query()->where('short_code', $shortCode)->firstOrFail();
$this->scanRecorder->record($qrCode, $request);
if (! $qrCode->is_active) {
return view('public.qr.inactive', ['qrCode' => $qrCode]);
}
if ($qrCode->resolvesToRedirect()) {
$url = $qrCode->redirectUrl();
abort_unless($url, 404);
return redirect()->away($url);
}
if ($qrCode->isDocumentType()) {
return redirect()->route('qr.public.view', $shortCode);
}
if ($qrCode->isBookingType()) {
return view('public.qr.booking-landing', ['qrCode' => $qrCode]);
}
if ($qrCode->usesLandingPage()) {
return view('public.qr.landing', ['qrCode' => $qrCode]);
}
abort(404);
}
public function view(string $shortCode): View
{
$qrCode = QrCode::query()
->where('short_code', $shortCode)
->where('is_active', true)
->with('document')
->firstOrFail();
abort_unless($qrCode->isDocumentType() && $qrCode->document, 404);
return view('public.qr.document-viewer', [
'qrCode' => $qrCode,
'fileUrl' => route('qr.public.file', $shortCode),
'allowDownload' => (bool) ($qrCode->content()['allow_download'] ?? true),
]);
}
public function file(string $shortCode): StreamedResponse
{
$qrCode = QrCode::query()
->where('short_code', $shortCode)
->where('is_active', true)
->with('document')
->firstOrFail();
$document = $qrCode->document;
abort_unless($document && Storage::disk($document->disk)->exists($document->path), 404);
return Storage::disk($document->disk)->response(
$document->path,
($document->title ?: 'document') . '.pdf',
['Content-Type' => 'application/pdf'],
);
}
public function image(string $shortCode, int $index): StreamedResponse
{
$qrCode = QrCode::query()
->where('short_code', $shortCode)
->where('is_active', true)
->firstOrFail();
abort_unless($qrCode->isImageType(), 404);
$images = $qrCode->content()['images'] ?? [];
abort_unless(isset($images[$index]['path']), 404);
$path = $images[$index]['path'];
abort_unless(Storage::disk('qr')->exists($path), 404);
return Storage::disk('qr')->response($path);
}
public function itemImage(string $shortCode, int $sectionIndex, int $itemIndex): StreamedResponse
{
$qrCode = QrCode::query()
->where('short_code', $shortCode)
->where('is_active', true)
->firstOrFail();
abort_unless(in_array($qrCode->type, [QrCode::TYPE_MENU, QrCode::TYPE_SHOP], true), 404);
$sections = $qrCode->content()['sections'] ?? [];
$path = $sections[$sectionIndex]['items'][$itemIndex]['image_path'] ?? null;
abort_unless($path && Storage::disk('qr')->exists($path), 404);
return Storage::disk('qr')->response($path);
}
public function vcard(string $shortCode): StreamedResponse
{
$qrCode = QrCode::query()
->where('short_code', $shortCode)
->where('is_active', true)
->firstOrFail();
abort_unless($qrCode->type === QrCode::TYPE_VCARD, 404);
$c = $qrCode->content();
$lines = [
'BEGIN:VCARD',
'VERSION:3.0',
'N:' . ($c['last_name'] ?? '') . ';' . ($c['first_name'] ?? '') . ';;;',
'FN:' . trim(($c['first_name'] ?? '') . ' ' . ($c['last_name'] ?? '')),
];
if (! empty($c['company'])) {
$lines[] = 'ORG:' . $this->escapeVcard($c['company']);
}
if (! empty($c['phone'])) {
$lines[] = 'TEL;TYPE=CELL:' . $this->escapeVcard($c['phone']);
}
if (! empty($c['email'])) {
$lines[] = 'EMAIL;TYPE=INTERNET:' . $this->escapeVcard($c['email']);
}
if (! empty($c['website'])) {
$lines[] = 'URL:' . $this->escapeVcard($c['website']);
}
if (! empty($c['address'])) {
$lines[] = 'ADR;TYPE=WORK:;;' . $this->escapeVcard($c['address']) . ';;;;';
}
if (! empty($c['note'])) {
$lines[] = 'NOTE:' . $this->escapeVcard($c['note']);
}
$lines[] = 'END:VCARD';
$vcf = implode("\r\n", $lines) . "\r\n";
$filename = Str::slug($qrCode->label ?: 'contact') . '.vcf';
return response()->streamDownload(fn () => print($vcf), $filename, [
'Content-Type' => 'text/vcard',
]);
}
public function vcardAvatar(string $shortCode): StreamedResponse
{
$qrCode = QrCode::query()
->where('short_code', $shortCode)
->where('is_active', true)
->firstOrFail();
abort_unless($qrCode->type === QrCode::TYPE_VCARD, 404);
$path = $qrCode->content()['avatar_path'] ?? null;
abort_unless($path && Storage::disk('qr')->exists($path), 404);
return Storage::disk('qr')->response($path);
}
public function bookCover(string $shortCode): StreamedResponse
{
$qrCode = QrCode::query()
->where('short_code', $shortCode)
->where('is_active', true)
->firstOrFail();
abort_unless($qrCode->isBookingType(), 404);
$path = $qrCode->content()['cover_path'] ?? null;
abort_unless($path && Storage::disk('qr')->exists($path), 404);
return Storage::disk('qr')->response($path);
}
public function menuLogo(string $shortCode): StreamedResponse
{
$qrCode = QrCode::query()
->where('short_code', $shortCode)
->where('is_active', true)
->firstOrFail();
abort_unless(in_array($qrCode->type, [QrCode::TYPE_MENU, QrCode::TYPE_SHOP], true), 404);
$path = $qrCode->content()['logo_path'] ?? null;
abort_unless($path && Storage::disk('qr')->exists($path), 404);
return Storage::disk('qr')->response($path);
}
public function menuCover(string $shortCode): StreamedResponse
{
$qrCode = QrCode::query()
->where('short_code', $shortCode)
->where('is_active', true)
->firstOrFail();
abort_unless(in_array($qrCode->type, [QrCode::TYPE_MENU, QrCode::TYPE_SHOP], true), 404);
$path = $qrCode->content()['cover_path'] ?? null;
abort_unless($path && Storage::disk('qr')->exists($path), 404);
return Storage::disk('qr')->response($path);
}
public function businessLogo(string $shortCode): StreamedResponse
{
$qrCode = QrCode::query()
->where('short_code', $shortCode)
->where('is_active', true)
->firstOrFail();
abort_unless($qrCode->type === QrCode::TYPE_BUSINESS, 404);
$path = $qrCode->content()['logo_path'] ?? null;
abort_unless($path && Storage::disk('qr')->exists($path), 404);
return Storage::disk('qr')->response($path);
}
public function businessCover(string $shortCode): StreamedResponse
{
$qrCode = QrCode::query()
->where('short_code', $shortCode)
->where('is_active', true)
->firstOrFail();
abort_unless($qrCode->type === QrCode::TYPE_BUSINESS, 404);
$path = $qrCode->content()['cover_path'] ?? null;
abort_unless($path && Storage::disk('qr')->exists($path), 404);
return Storage::disk('qr')->response($path);
}
public function menuLogo(string $shortCode): StreamedResponse
{
$qrCode = QrCode::query()
->where('short_code', $shortCode)
->where('is_active', true)
->firstOrFail();
abort_unless($qrCode->type === QrCode::TYPE_SHOP, 404);
$path = $qrCode->content()['logo_path'] ?? null;
abort_unless($path && Storage::disk('qr')->exists($path), 404);
return Storage::disk('qr')->response($path);
}
public function menuCover(string $shortCode): StreamedResponse
{
$qrCode = QrCode::query()
->where('short_code', $shortCode)
->where('is_active', true)
->firstOrFail();
abort_unless($qrCode->type === QrCode::TYPE_SHOP, 404);
$path = $qrCode->content()['cover_path'] ?? null;
abort_unless($path && Storage::disk('qr')->exists($path), 404);
return Storage::disk('qr')->response($path);
}
public function paymentLogo(string $shortCode): StreamedResponse
{
return $this->serveContentImage($shortCode, QrCode::TYPE_PAYMENT, 'logo_path');
}
public function eventLogo(string $shortCode): StreamedResponse
{
return $this->serveContentImage($shortCode, QrCode::TYPE_EVENT, 'logo_path');
}
public function eventCover(string $shortCode): StreamedResponse
{
return $this->serveContentImage($shortCode, QrCode::TYPE_EVENT, 'cover_path');
}
public function itineraryCover(string $shortCode): StreamedResponse
{
return $this->serveContentImage($shortCode, QrCode::TYPE_ITINERARY, 'cover_path');
}
private function serveContentImage(string $shortCode, string $type, string $key): StreamedResponse
{
$qrCode = QrCode::query()
->where('short_code', $shortCode)
->where('is_active', true)
->firstOrFail();
abort_unless($qrCode->type === $type, 404);
$path = $qrCode->content()[$key] ?? null;
abort_unless($path && Storage::disk('qr')->exists($path), 404);
return Storage::disk('qr')->response($path);
}
public function appIcon(string $shortCode): StreamedResponse
{
$qrCode = QrCode::query()
->where('short_code', $shortCode)
->where('is_active', true)
->firstOrFail();
abort_unless($qrCode->type === QrCode::TYPE_APP, 404);
$path = $qrCode->content()['icon_path'] ?? null;
abort_unless($path && Storage::disk('qr')->exists($path), 404);
return Storage::disk('qr')->response($path);
}
private function escapeVcard(string $value): string
{
return str_replace(["\n", "\r", ',', ';'], [' ', ' ', '\\,', '\\;'], $value);
}
}
@@ -0,0 +1,76 @@
<?php
namespace App\Http\Controllers\Public;
use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Services\Merchant\MerchantSaleService;
use App\Support\Qr\QrTypeCatalog;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use RuntimeException;
class SaleOrderController extends Controller
{
public function __construct(private MerchantSaleService $sales) {}
public function store(Request $request, string $shortCode): JsonResponse
{
$qrCode = QrCode::query()
->where('short_code', $shortCode)
->whereIn('type', QrTypeCatalog::storefrontTypes())
->where('is_active', true)
->firstOrFail();
if (! $qrCode->acceptsOrders()) {
return response()->json(['error' => 'This QR code does not accept orders.'], 422);
}
if (empty($qrCode->content()['accepts_payment'])) {
return response()->json(['error' => 'Online payments are not enabled for this storefront.'], 422);
}
$validated = $request->validate([
'customer_name' => ['required', 'string', 'max:120'],
'customer_email' => ['required', 'email', 'max:200'],
'customer_phone' => ['required', 'string', 'max:30'],
'shipping_fee' => ['nullable', 'numeric', 'min:0', 'max:10000'],
'items' => ['required', 'array', 'min:1'],
'items.*.name' => ['required', 'string', 'max:200'],
'items.*.price' => ['required', 'numeric', 'min:0'],
'items.*.qty' => ['required', 'integer', 'min:1', 'max:100'],
]);
try {
$result = $this->sales->initiateOrder($qrCode, $validated);
} catch (RuntimeException $e) {
return response()->json(['error' => $e->getMessage()], 422);
}
return response()->json([
'checkout_url' => $result['checkout_url'],
'callback_url' => $result['callback_url'],
]);
}
public function callback(Request $request, string $shortCode): RedirectResponse|View
{
$reference = trim((string) $request->query('reference', ''));
if ($reference === '') {
return redirect('/q/'.$shortCode)->with('error', 'Missing payment reference.');
}
try {
$order = $this->sales->completeOrder($reference);
} catch (\Throwable) {
return redirect('/q/'.$shortCode)->with('order_error', 'Payment could not be verified. Contact the merchant with reference: '.$reference);
}
return view('public.qr.order-success', [
'order' => $order,
'qrCode' => $order->qrCode,
]);
}
}
@@ -0,0 +1,94 @@
<?php
namespace App\Http\Controllers\Qr;
use App\Http\Controllers\Controller;
use App\Models\QrSetting;
use App\Services\Billing\BillingClient;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class AccountController extends Controller
{
public function __construct(private BillingClient $billing) {}
private function topupUrl(): string
{
return 'https://'.config('app.account_domain').'/wallet';
}
/** @return array{0: int, 1: array<string, mixed>} */
private function billingSnapshot(?string $publicId): array
{
if (! $publicId) {
return [0, []];
}
$balanceMinor = $this->billing->balanceMinor($publicId);
$ledger = $this->billing->serviceLedger($publicId, config('billing.service', 'mini'));
return [$balanceMinor, $ledger];
}
public function wallet(): View
{
$user = ladill_account();
[$balanceMinor, $ledger] = $this->billingSnapshot($user?->public_id);
return view('qr.account.wallet', [
'balanceMinor' => $balanceMinor,
'spentMinor' => (int) ($ledger['spent_minor'] ?? 0),
'creditedMinor' => (int) ($ledger['credited_minor'] ?? 0),
'topupUrl' => $this->topupUrl(),
]);
}
public function billing(): View
{
$user = ladill_account();
[$balanceMinor, $ledger] = $this->billingSnapshot($user?->public_id);
return view('qr.account.billing', [
'balanceMinor' => $balanceMinor,
'spentMinor' => (int) ($ledger['spent_minor'] ?? 0),
'creditedMinor' => (int) ($ledger['credited_minor'] ?? 0),
'topupUrl' => $this->topupUrl(),
]);
}
public function settings(): View
{
$account = ladill_account();
$settings = $account->getOrCreateQrSetting();
return view('merchant.settings', [
'account' => $account,
'settings' => $settings,
]);
}
public function updateSettings(Request $request): RedirectResponse
{
$account = ladill_account();
$data = $request->validate([
'notify_email' => ['nullable', 'email', 'max:255'],
'product_updates' => ['nullable', 'boolean'],
'notify_registrations' => ['nullable', 'boolean'],
'notify_payouts' => ['nullable', 'boolean'],
]);
QrSetting::updateOrCreate(
['user_id' => $account->id],
[
'notify_email' => $data['notify_email'] ?? null,
'product_updates' => $request->boolean('product_updates'),
'notify_registrations' => $request->boolean('notify_registrations'),
'notify_payouts' => $request->boolean('notify_payouts'),
],
);
return redirect()->route('account.settings')->with('success', 'Settings saved.');
}
}
@@ -0,0 +1,87 @@
<?php
namespace App\Http\Controllers\Qr;
use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Services\Afia\AfiaService;
use App\Services\Billing\BillingClient;
use App\Support\Qr\QrTypeCatalog;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class AfiaController extends Controller
{
public function chat(Request $request, AfiaService $afia): JsonResponse
{
$validated = $request->validate([
'message' => ['required', 'string', 'max:2000'],
'history' => ['nullable', 'array', 'max:20'],
'history.*.role' => ['nullable', 'string'],
'history.*.text' => ['nullable', 'string'],
]);
if (! $afia->enabled()) {
return response()->json(['message' => 'Afia is not available right now.'], 503);
}
try {
$reply = $afia->chat(trim($validated['message']), $validated['history'] ?? [], $this->context());
} catch (\Throwable $e) {
report($e);
return response()->json(['message' => 'Afia could not respond right now. Please try again.'], 502);
}
return response()->json(['reply' => $reply]);
}
/** @return array<string,mixed> */
private function context(): array
{
$account = ladill_account();
if (! $account) {
return ['signed_in' => 'no'];
}
$types = QrTypeCatalog::eventTypes();
$codes = $account->qrCodes()->whereIn('type', $types);
$ctx = [
'signed_in' => 'yes',
'qr_codes_total' => (clone $codes)->count(),
'qr_codes_active' => (clone $codes)->where('is_active', true)->count(),
'scans_total' => (int) (clone $codes)->sum('scans_total'),
'top_code_type' => (clone $codes)
->selectRaw('type, count(*) as total')
->groupBy('type')
->orderByDesc('total')
->value('type') ?: 'none yet',
];
if ($account->public_id) {
try {
$ctx['wallet_balance_ghs'] = number_format(app(BillingClient::class)->balanceMinor((string) $account->public_id) / 100, 2);
} catch (\Throwable) {
}
}
$recent = $account->qrCodes()
->whereIn('type', $types)
->latest('updated_at')
->limit(3)
->get(['type', 'label', 'short_code', 'scans_total']);
if ($recent->isNotEmpty()) {
$ctx['recent_codes'] = $recent->map(fn (QrCode $code): string => sprintf(
'%s (%s, %d scans)',
$code->label ?: $code->short_code,
QrTypeCatalog::label($code->type),
$code->scans_total,
))->implode('; ');
}
return $ctx;
}
}
@@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers\Qr;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
/**
* Developers personal API tokens for the Ladill QR Plus API (Sanctum). Tokens
* belong to the signed-in user and authorize calls to /api/v1/*. The plaintext
* token is shown once on create.
*/
class DeveloperController extends Controller
{
public function index(Request $request): View
{
return view('qr.account.developers', [
'tokens' => $request->user()->tokens()->latest()->get(),
'apiBase' => rtrim((string) config('app.url'), '/').'/api/v1',
'newToken' => session('new_token'),
]);
}
public function store(Request $request): RedirectResponse
{
$data = $request->validate([
'name' => ['required', 'string', 'max:60'],
]);
$token = $request->user()->createToken($data['name'], ['qr:read', 'qr:write']);
return redirect()->route('account.developers')
->with('new_token', $token->plainTextToken)
->with('success', 'Token created — copy it now, it wont be shown again.');
}
public function destroy(Request $request, int $token): RedirectResponse
{
$request->user()->tokens()->whereKey($token)->delete();
return redirect()->route('account.developers')->with('success', 'Token revoked.');
}
}
@@ -0,0 +1,453 @@
<?php
namespace App\Http\Controllers\Qr;
use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Models\QrEventRegistration;
use App\Models\QrWallet;
use App\Services\Billing\BillingClient;
use App\Services\Qr\QrAnalyticsService;
use App\Services\Qr\QrCodeManagerService;
use App\Services\Qr\QrImageGeneratorService;
use App\Services\Qr\QrPdfExporter;
use App\Services\Qr\QrWalletBillingService;
use App\Support\Qr\QrCornerStyleCatalog;
use App\Support\Qr\QrFrameStyleCatalog;
use App\Support\Qr\QrModuleStyleCatalog;
use App\Support\Qr\QrTypeCatalog;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
use RuntimeException;
use Throwable;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
class QrCodeController extends Controller
{
public function __construct(
private QrCodeManagerService $manager,
private QrWalletBillingService $billing,
private QrAnalyticsService $analytics,
private QrImageGeneratorService $imageGenerator,
private QrPdfExporter $pdfExporter,
private BillingClient $platformBilling,
) {}
public function index(Request $request): View
{
$account = ladill_account();
$wallet = $this->manager->walletFor($account);
$qrCodes = $account->qrCodes()
->whereIn('type', QrTypeCatalog::storefrontTypes())
->latest()
->get();
$totalRegistrations = 0;
$ladillWalletBalance = 0.0;
try {
$ladillWalletBalance = $this->platformBilling->balanceMinor($account->public_id) / 100;
} catch (Throwable $e) {
Log::warning('Events index could not load Ladill wallet balance', [
'user' => $account->public_id,
'error' => $e->getMessage(),
]);
}
return view('qr-codes.index', [
'wallet' => $wallet,
'qrCodes' => $qrCodes,
'totalRegistrations' => $totalRegistrations,
'pricePerQr' => QrWallet::pricePerQr(),
'minTopup' => QrWallet::minTopupGhs(),
'ladillWalletBalance' => $ladillWalletBalance,
'topupUrl' => 'https://'.config('app.account_domain').'/wallet',
]);
}
public function create(Request $request): View
{
$account = ladill_account();
$wallet = $this->manager->walletFor($account);
$qrSettings = $account->getOrCreateQrSetting();
$requestedType = $request->query('type', QrCode::TYPE_SHOP);
if (! QrTypeCatalog::isValid($requestedType)) {
$requestedType = QrCode::TYPE_SHOP;
}
return view('qr-codes.create', [
'wallet' => $wallet,
'requestedType' => $requestedType,
'moduleStyles' => QrModuleStyleCatalog::visible(),
'cornerOuterStyles' => QrCornerStyleCatalog::outerStyles(),
'cornerInnerStyles' => QrCornerStyleCatalog::innerStyles(),
'frameStyles' => QrFrameStyleCatalog::visible(),
'pricePerQr' => QrWallet::pricePerQr(),
'minTopup' => QrWallet::minTopupGhs(),
'ladillWalletBalance' => $this->platformBilling->balanceMinor($account->public_id) / 100,
'topupUrl' => 'https://'.config('app.account_domain').'/wallet',
'accountDefaultStyle' => $qrSettings->resolvedDefaultStyle(),
'accountEventDefaults' => $qrSettings->resolvedEventDefaults(),
]);
}
public function checkSlug(Request $request): JsonResponse
{
$code = (string) $request->query('code', '');
if (! preg_match('/^[a-z0-9][a-z0-9-]{1,18}[a-z0-9]$/', $code)) {
return response()->json(['available' => false, 'reason' => 'invalid']);
}
$taken = QrCode::where('short_code', $code)->exists();
return response()->json(['available' => ! $taken]);
}
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'label' => ['required', 'string', 'max:120'],
'type' => ['required', 'in:' . implode(',', QrTypeCatalog::keys())],
'custom_short_code' => ['nullable', 'string', 'regex:/^[a-z0-9][a-z0-9-]{1,18}[a-z0-9]$/', 'unique:qr_codes,short_code'],
'destination_url' => ['nullable', 'url', 'max:2048'],
'document' => ['nullable', 'file', 'mimes:pdf', 'max:102400'],
'image' => ['nullable', 'image', 'max:10240'],
'images' => ['nullable', 'array'],
'images.*' => ['image', 'max:10240'],
'item_images' => ['nullable', 'array'],
'item_images.*' => ['array'],
'item_images.*.*' => ['image', 'max:4096'],
'logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:2048'],
'menu_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
'menu_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
'business_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
'business_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
'church_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
'church_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
'event_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
'event_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
'itinerary_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
...$this->styleRules(),
]);
if ($validated['type'] === QrCode::TYPE_DOCUMENT && ! $request->hasFile('document')) {
return back()->withInput()->with('error', 'Upload a PDF for PDF QR codes.');
}
if ($validated['type'] === QrCode::TYPE_IMAGE && ! $request->hasFile('image') && ! $request->hasFile('images')) {
return back()->withInput()->with('error', 'Upload at least one image.');
}
try {
$qrCode = $this->manager->create(ladill_account(), array_merge(
$request->all(),
[
'style' => $validated['style'] ?? [],
'custom_short_code' => $validated['custom_short_code'] ?? null,
'avatar' => $request->file('avatar'),
'book_file' => $request->file('book_file'),
'cover' => $request->file('cover'),
'menu_logo' => $request->file('menu_logo'),
'menu_cover' => $request->file('menu_cover'),
'business_logo' => $request->file('business_logo'),
'business_cover' => $request->file('business_cover'),
'church_logo' => $request->file('church_logo'),
'church_cover' => $request->file('church_cover'),
'event_logo' => $request->file('event_logo'),
'event_cover' => $request->file('event_cover'),
'itinerary_cover' => $request->file('itinerary_cover'),
],
));
} catch (RuntimeException $e) {
if (str_contains($e->getMessage(), 'Insufficient') || str_contains($e->getMessage(), 'Add at least')) {
return back()->withInput()->with('open_topup_modal', 'qr');
}
return back()->withInput()->with('error', $e->getMessage());
}
return redirect()->route('merchant.storefronts.show', $qrCode)
->with('success', QrTypeCatalog::label($qrCode->type).' created.');
}
public function show(Request $request, QrCode $storefront): View
{
$this->authorize('view', $storefront);
$previewDataUri = $this->imageGenerator->previewDataUri($storefront);
$qrCode = $storefront->fresh();
$qrStyle = $qrCode->style();
$logoDataUri = ! empty($qrStyle['logo_path'])
? $this->imageGenerator->logoDataUri($qrStyle['logo_path'])
: null;
$account = ladill_account();
$wallet = $this->manager->walletFor($account);
$summary = $this->analytics->summaryFor($qrCode);
$dailyScans = $this->analytics->dailyScans($qrCode, 30);
$devices = $this->analytics->breakdown($qrCode, 'device_type');
$browsers = $this->analytics->breakdown($qrCode, 'browser');
$recentScans = $this->analytics->recentScans($qrCode);
$ladillWalletBalance = 0.0;
try {
$ladillWalletBalance = $this->platformBilling->balanceMinor($account->public_id) / 100;
} catch (Throwable $e) {
Log::warning('QR Plus show could not load Ladill wallet balance', [
'user' => $account->public_id,
'error' => $e->getMessage(),
]);
}
return view('qr-codes.show', [
'qrCode' => $qrCode,
'previewDataUri' => $previewDataUri,
'logoDataUri' => $logoDataUri,
'types' => QrTypeCatalog::all(),
'moduleStyles' => QrModuleStyleCatalog::visible(),
'cornerOuterStyles' => QrCornerStyleCatalog::outerStyles(),
'cornerInnerStyles' => QrCornerStyleCatalog::innerStyles(),
'frameStyles' => QrFrameStyleCatalog::visible(),
'wallet' => $wallet,
'summary' => $summary,
'dailyScans' => $dailyScans,
'devices' => $devices,
'browsers' => $browsers,
'recentScans' => $recentScans,
'orders' => null,
'pricePerQr' => QrWallet::pricePerQr(),
'minTopup' => QrWallet::minTopupGhs(),
'ladillWalletBalance' => $ladillWalletBalance,
'topupUrl' => 'https://'.config('app.account_domain').'/wallet',
]);
}
public function update(Request $request, QrCode $storefront): RedirectResponse
{
$this->authorize('update', $storefront);
$qrCode = $storefront;
$validated = $request->validate([
'label' => ['sometimes', 'string', 'max:120'],
'destination_url' => ['nullable', 'url', 'max:2048'],
'document' => ['nullable', 'file', 'mimes:pdf', 'max:102400'],
'image' => ['nullable', 'image', 'max:10240'],
'images' => ['nullable', 'array'],
'images.*' => ['image', 'max:10240'],
'item_images' => ['nullable', 'array'],
'item_images.*' => ['array'],
'item_images.*.*' => ['image', 'max:4096'],
'logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:2048'],
'menu_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
'menu_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
'business_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
'business_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
'church_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
'church_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
'event_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
'event_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
'itinerary_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
'remove_logo' => ['sometimes', 'boolean'],
'is_active' => ['sometimes', 'boolean'],
...$this->styleRules(),
]);
try {
$this->manager->update($qrCode, array_merge(
$request->all(),
[
'document' => $request->file('document'),
'logo' => $request->file('logo'),
'style' => $validated['style'] ?? null,
'is_active' => $request->boolean('is_active', $qrCode->is_active),
'remove_logo' => $request->boolean('remove_logo'),
'avatar' => $request->file('avatar'),
'book_file' => $request->file('book_file'),
'cover' => $request->file('cover'),
'menu_logo' => $request->file('menu_logo'),
'menu_cover' => $request->file('menu_cover'),
'business_logo' => $request->file('business_logo'),
'business_cover' => $request->file('business_cover'),
'church_logo' => $request->file('church_logo'),
'church_cover' => $request->file('church_cover'),
'event_logo' => $request->file('event_logo'),
'event_cover' => $request->file('event_cover'),
'itinerary_cover' => $request->file('itinerary_cover'),
],
));
} catch (RuntimeException $e) {
return back()->with('error', $e->getMessage());
}
return back()->with('success', QrTypeCatalog::label($qrCode->type).' updated.');
}
public function stylePreview(Request $request): Response
{
$validated = $request->validate(array_merge($this->styleRules(), [
'short_code' => ['nullable', 'string', 'max:16'],
'logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:2048'],
'use_existing_logo' => ['nullable', 'boolean'],
'existing_logo_path' => ['nullable', 'string', 'max:500'],
]));
$shortCode = $validated['short_code'] ?? 'preview';
$style = $validated['style'] ?? [];
$tempLogoPath = null;
if ($request->hasFile('logo')) {
$tempLogoPath = $request->file('logo')->store('tmp/previews', 'qr');
$style['logo_path'] = $tempLogoPath;
} elseif ($request->boolean('use_existing_logo') && ! empty($validated['existing_logo_path'])) {
$existingPath = $validated['existing_logo_path'];
$userPrefix = ladill_account()->id . '/';
if (str_starts_with($existingPath, $userPrefix) && Storage::disk('qr')->exists($existingPath)) {
$style['logo_path'] = $existingPath;
}
}
$png = $this->imageGenerator->renderPng(QrCode::publicBaseUrl() . '/q/' . $shortCode, $style);
if ($tempLogoPath) {
Storage::disk('qr')->delete($tempLogoPath);
}
return response($png, 200, [
'Content-Type' => 'image/png',
'Cache-Control' => 'no-store',
]);
}
/** @return array<string, mixed> */
private function styleRules(): array
{
return [
'style' => ['nullable', 'array'],
'style.foreground' => ['nullable', 'regex:/^#[0-9A-Fa-f]{6}$/'],
'style.background' => ['nullable', 'regex:/^#[0-9A-Fa-f]{6}$/'],
'style.error_correction' => ['nullable', 'in:L,M,Q,H'],
'style.margin' => ['nullable', 'integer', 'min:0', 'max:10'],
'style.module_style' => ['nullable', 'in:' . implode(',', QrModuleStyleCatalog::keys())],
'style.finder_outer' => ['nullable', 'in:' . implode(',', array_keys(QrCornerStyleCatalog::outerStyles()))],
'style.finder_inner' => ['nullable', 'in:' . implode(',', array_keys(QrCornerStyleCatalog::innerStyles()))],
'style.frame_style' => ['nullable', 'in:' . implode(',', QrFrameStyleCatalog::keys())],
'style.frame_text' => ['nullable', 'string', 'max:100'],
'style.frame_color' => ['nullable', 'regex:/^#[0-9A-Fa-f]{6}$/'],
'style.scale' => ['nullable', 'integer', 'min:4', 'max:16'],
'style.gradient_type' => ['nullable', 'in:none,linear,radial'],
'style.gradient_color1' => ['nullable', 'regex:/^#[0-9A-Fa-f]{6}$/'],
'style.gradient_color2' => ['nullable', 'regex:/^#[0-9A-Fa-f]{6}$/'],
'style.gradient_rotation' => ['nullable', 'integer', 'min:0', 'max:360'],
'style.logo_size' => ['nullable', 'numeric', 'min:0.1', 'max:0.4'],
'style.logo_margin' => ['nullable', 'integer', 'min:0', 'max:15'],
'style.logo_white_bg' => ['nullable', 'boolean'],
'style.logo_shape' => ['nullable', 'in:none,rounded,circle'],
];
}
public function preview(QrCode $storefront): Response
{
$this->authorize('view', $storefront);
$qrCode = $this->imageGenerator->ensureValidImages($storefront);
$bytes = $this->imageGenerator->normalizeStoredPng($qrCode->png_path);
if ($bytes === null) {
$bytes = $this->imageGenerator->renderPng($qrCode->encodedPayload(), $qrCode->style());
$this->imageGenerator->generateAndStore($qrCode);
}
return response($bytes, 200, [
'Content-Type' => 'image/png',
'Cache-Control' => 'private, max-age=3600',
]);
}
public function download(QrCode $storefront, string $format): StreamedResponse
{
$this->authorize('view', $storefront);
$qrCode = $this->imageGenerator->ensureValidImages($storefront);
$path = $format === 'svg' ? $qrCode->svg_path : $qrCode->png_path;
$filename = Str::slug($qrCode->label) . '-qr.' . ($format === 'svg' ? 'svg' : 'png');
if ($format === 'png') {
$bytes = $this->imageGenerator->normalizeStoredPng($path);
if ($bytes === null) {
$bytes = $this->imageGenerator->renderPng($qrCode->encodedPayload(), $qrCode->style());
$this->imageGenerator->generateAndStore($qrCode);
}
return response()->streamDownload(fn () => print($bytes), $filename, [
'Content-Type' => 'image/png',
]);
}
if ($format === 'pdf') {
$bytes = $this->imageGenerator->normalizeStoredPng($qrCode->png_path);
if ($bytes === null) {
$bytes = $this->imageGenerator->renderPng($qrCode->encodedPayload(), $qrCode->style());
$this->imageGenerator->generateAndStore($qrCode);
}
$pdf = $this->pdfExporter->fromPng($bytes, $qrCode->label);
$filename = Str::slug($qrCode->label) . '-qr.pdf';
return response()->streamDownload(fn () => print($pdf), $filename, [
'Content-Type' => 'application/pdf',
]);
}
abort_unless($path && Storage::disk('qr')->exists($path), 404);
return Storage::disk('qr')->download($path, $filename);
}
/**
* Persist the exact client-rendered QR (qr-code-styling) as the canonical
* SVG + PNG so downloads match the preview pixel-for-pixel. The browser
* renders with the real /q/{code} URL on the show page, then posts here.
*/
public function storeCanonicalImage(Request $request, QrCode $storefront): JsonResponse
{
$this->authorize('update', $storefront);
$qrCode = $storefront;
$validated = $request->validate([
'svg' => ['required', 'string', 'max:600000'],
'png' => ['required', 'string', 'max:4000000'],
]);
$svg = $this->imageGenerator->sanitizeSvg($validated['svg']);
if ($svg === null) {
return response()->json(['error' => 'Invalid SVG.'], 422);
}
$pngB64 = $validated['png'];
if (str_contains($pngB64, ',')) {
$pngB64 = substr($pngB64, strpos($pngB64, ',') + 1);
}
$png = base64_decode($pngB64, true);
if ($png === false || ! $this->imageGenerator->isValidPngBinary($png)) {
return response()->json(['error' => 'Invalid PNG.'], 422);
}
$basePath = $qrCode->user_id . '/codes/' . $qrCode->id;
Storage::disk('qr')->put($basePath . '/qr.svg', $svg);
Storage::disk('qr')->put($basePath . '/qr.png', $png);
$qrCode->update([
'svg_path' => $basePath . '/qr.svg',
'png_path' => $basePath . '/qr.png',
]);
return response()->json(['ok' => true]);
}
}
+108
View File
@@ -0,0 +1,108 @@
<?php
namespace App\Http\Controllers\Qr;
use App\Http\Controllers\Controller;
use App\Models\QrTeamMember;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\View\View;
class TeamController extends Controller
{
public function index(Request $request): View
{
$account = ladill_account();
$members = QrTeamMember::query()
->where('account_id', $account->id)
->with('member')
->orderBy('status')->orderBy('email')
->get();
return view('qr.account.team', [
'account' => $account,
'members' => $members,
'canManage' => $this->canManage($request),
'isOwner' => $request->user()->id === $account->id,
]);
}
public function store(Request $request): RedirectResponse
{
abort_unless($this->canManage($request), 403);
$validated = $request->validate([
'email' => ['required', 'email', 'max:255'],
'role' => ['required', 'in:admin,member'],
]);
$account = ladill_account();
$email = strtolower($validated['email']);
if ($email === strtolower($account->email)) {
return back()->withErrors(['email' => 'The owner is already on the account.']);
}
$member = QrTeamMember::firstOrNew(['account_id' => $account->id, 'email' => $email]);
$member->role = $validated['role'];
if (! $member->exists) {
$member->status = QrTeamMember::STATUS_INVITED;
$member->token = Str::random(40);
}
$member->save();
if ($existing = User::whereRaw('LOWER(email) = ?', [$email])->first()) {
QrTeamMember::linkPendingInvitesFor($existing);
}
return back()->with('success', $email.' invited.');
}
public function updateRole(Request $request, QrTeamMember $member): RedirectResponse
{
abort_unless($this->canManage($request) && $member->account_id === ladill_account()->id, 403);
$validated = $request->validate(['role' => ['required', 'in:admin,member']]);
$member->update(['role' => $validated['role']]);
return back()->with('success', 'Role updated.');
}
public function destroy(Request $request, QrTeamMember $member): RedirectResponse
{
abort_unless($this->canManage($request) && $member->account_id === ladill_account()->id, 403);
$member->delete();
return back()->with('success', 'Member removed.');
}
public function switchAccount(Request $request): RedirectResponse
{
$validated = $request->validate(['account' => ['required', 'integer']]);
abort_unless($request->user()->canAccessAccount((int) $validated['account']), 403);
$request->session()->put('ladill_account', (int) $validated['account']);
return redirect()->route('merchant.dashboard');
}
private function canManage(Request $request): bool
{
$user = $request->user();
$account = ladill_account();
if ($user->id === $account->id) {
return true;
}
return $user->memberships()
->where('account_id', $account->id)
->where('role', QrTeamMember::ROLE_ADMIN)
->exists();
}
}
+71
View File
@@ -0,0 +1,71 @@
<?php
namespace App\Http\Controllers;
use App\Models\QrSaleOrder;
use App\Models\QrCode;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class SearchController extends Controller
{
public function __invoke(Request $request): JsonResponse|View
{
$q = trim((string) $request->query('q'));
$results = mb_strlen($q) >= 2 ? $this->results($q) : [];
if ($request->expectsJson() || $request->ajax() || $request->wantsJson()) {
return response()->json(['results' => $results]);
}
return view('search.index', [
'query' => $q,
'results' => $results,
]);
}
/** @return list<array{type: string, title: string, subtitle: string, url: string}> */
private function results(string $q): array
{
$account = ladill_account();
$like = '%'.$q.'%';
$qrIds = $account->qrCodes()
->where('type', QrCode::TYPE_SHOP)
->pluck('id');
if ($qrIds->isEmpty()) {
return [];
}
return QrSaleOrder::query()
->whereIn('qr_code_id', $qrIds)
->with('qrCode')
->where(function ($query) use ($like) {
$query->where('payer_name', 'like', $like)
->orWhere('payer_email', 'like', $like)
->orWhere('collection_type', 'like', $like)
->orWhere('reference', 'like', $like)
->orWhere('payment_reference', 'like', $like)
->orWhereHas('qrCode', fn ($qr) => $qr->where('label', 'like', $like));
})
->latest('created_at')
->limit(15)
->get()
->map(function (QrSaleOrder $order): array {
$amount = number_format(
($order->status === QrSaleOrder::STATUS_PAID ? $order->merchant_amount_minor : $order->amount_minor) / 100,
2,
);
return [
'type' => 'order',
'title' => $order->payer_name ?: 'Anonymous donor',
'subtitle' => 'GHS '.$amount.' · '.($order->collection_type ?: 'Order').' · '.ucfirst($order->status),
'url' => route('merchant.orders.index', ['q' => $order->reference]),
];
})
->all();
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace App\Http\Middleware;
use App\Models\User;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\View;
use Symfony\Component\HttpFoundation\Response;
class SetActingAccount
{
public function handle(Request $request, Closure $next): Response
{
if ($user = $request->user()) {
if ($request->is('api/*')) {
$accountId = (int) ($request->header('X-Ladill-Account') ?: $user->id);
} else {
$accountId = (int) $request->session()->get('ladill_account', $user->id);
}
if (! $user->canAccessAccount($accountId)) {
$accountId = $user->id;
if (! $request->is('api/*')) {
$request->session()->put('ladill_account', $accountId);
}
}
$account = $accountId === $user->id ? $user : (User::find($accountId) ?? $user);
$request->attributes->set('actingAccount', $account);
if (! $request->is('api/*')) {
View::share('actingAccount', $account);
View::share('accessibleAccounts', $user->accessibleAccounts());
}
}
return $next($request);
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class QrSaleOrder extends Model
{
public const STATUS_PENDING = 'pending';
public const STATUS_PAID = 'paid';
public const STATUS_FAILED = 'failed';
/** Platform fee on orders (Ladill Merchant tier). */
public const PLATFORM_FEE_RATE = 0.09;
protected $fillable = [
'pay_order_id',
'qr_code_id',
'user_id',
'reference',
'collection_type',
'amount_minor',
'currency',
'platform_fee_minor',
'merchant_amount_minor',
'payer_name',
'payer_email',
'payer_phone',
'payer_note',
'status',
'payment_reference',
'paid_at',
'metadata',
];
protected $casts = [
'amount_minor' => 'integer',
'platform_fee_minor' => 'integer',
'merchant_amount_minor' => 'integer',
'metadata' => 'array',
'paid_at' => 'datetime',
];
public function qrCode(): BelongsTo
{
return $this->belongsTo(QrCode::class);
}
public function merchant(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public function amountMajor(): float
{
return $this->amount_minor / 100;
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class PlatformSetting extends Model
{
protected $connection = 'platform';
protected $fillable = [
'group',
'key',
'label',
'description',
'type',
'value',
'is_secret',
'is_active',
];
protected $casts = [
'value' => 'array',
'is_secret' => 'boolean',
'is_active' => 'boolean',
];
}
+75
View File
@@ -0,0 +1,75 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class QrBooking extends Model
{
public const STATUS_PENDING = 'pending';
public const STATUS_CONFIRMED = 'confirmed';
public const STATUS_CANCELLED = 'cancelled';
public const STATUS_FAILED = 'failed';
public const FULFILLMENT_NEW = 'new';
public const FULFILLMENT_CONFIRMED = 'confirmed';
public const FULFILLMENT_COMPLETED = 'completed';
public const FULFILLMENT_NO_SHOW = 'no_show';
public const FULFILLMENT_CANCELLED = 'cancelled';
public const FULFILLMENT_STATUSES = [
self::FULFILLMENT_NEW => 'New',
self::FULFILLMENT_CONFIRMED => 'Confirmed',
self::FULFILLMENT_COMPLETED => 'Completed',
self::FULFILLMENT_NO_SHOW => 'No show',
self::FULFILLMENT_CANCELLED => 'Cancelled',
];
protected $fillable = [
'pay_order_id',
'qr_code_id',
'user_id',
'customer_name',
'customer_email',
'customer_phone',
'service_name',
'duration_minutes',
'starts_at',
'ends_at',
'amount_ghs',
'platform_fee_ghs',
'merchant_amount_ghs',
'status',
'fulfillment_status',
'payment_reference',
'paid_at',
'metadata',
];
protected $casts = [
'starts_at' => 'datetime',
'ends_at' => 'datetime',
'paid_at' => 'datetime',
'amount_ghs' => 'decimal:2',
'platform_fee_ghs' => 'decimal:2',
'merchant_amount_ghs' => 'decimal:2',
'metadata' => 'array',
'duration_minutes' => 'integer',
];
public function fulfillmentLabel(): string
{
return self::FULFILLMENT_STATUSES[$this->fulfillment_status ?? self::FULFILLMENT_NEW] ?? ucfirst((string) $this->fulfillment_status);
}
public function qrCode(): BelongsTo
{
return $this->belongsTo(QrCode::class);
}
public function merchant(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
}
+227
View File
@@ -0,0 +1,227 @@
<?php
namespace App\Models;
use App\Support\Qr\QrStyleDefaults;
use App\Support\Qr\QrTypeCatalog;
use App\Support\Qr\QrWifiPayload;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class QrCode extends Model
{
public const TYPE_URL = 'url';
public const TYPE_DOCUMENT = 'document';
public const TYPE_LINK_LIST = 'link_list';
public const TYPE_VCARD = 'vcard';
public const TYPE_BUSINESS = 'business';
public const TYPE_IMAGE = 'image';
public const TYPE_SHOP = 'shop';
public const TYPE_MENU = 'menu';
public const TYPE_SHOP = 'shop';
public const TYPE_APP = 'app';
public const TYPE_BOOKING = 'booking';
/** @deprecated Legacy ebook type — migrated to {@see TYPE_BOOKING} */
public const TYPE_BOOK = 'book';
public const TYPE_WIFI = 'wifi';
public const TYPE_COUPON = 'coupon';
public const TYPE_EVENT = 'event';
public const TYPE_ITINERARY = 'itinerary';
public const TYPE_PAYMENT = 'payment';
protected $fillable = [
'user_id',
'short_code',
'type',
'label',
'destination_url',
'qr_document_id',
'payload',
'is_active',
'png_path',
'svg_path',
'scans_total',
'unique_scans_total',
'last_scanned_at',
'destination_updated_at',
];
protected $casts = [
'payload' => 'array',
'is_active' => 'boolean',
'scans_total' => 'integer',
'unique_scans_total' => 'integer',
'last_scanned_at' => 'datetime',
'destination_updated_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function document(): BelongsTo
{
return $this->belongsTo(QrDocument::class, 'qr_document_id');
}
public function scanEvents(): HasMany
{
return $this->hasMany(QrScanEvent::class);
}
public function transactions(): HasMany
{
return $this->hasMany(QrTransaction::class);
}
public function miniPayments(): HasMany
{
return $this->hasMany(QrSaleOrder::class);
}
public function publicUrl(): string
{
return self::publicBaseUrl() . '/q/' . $this->short_code;
}
/**
* Base URL for public QR links. Always the short platform domain
* (ladill.com) never the signed-in account/product host so printed
* codes and shared links stay short and host-independent of where the QR
* was created.
*/
public static function publicBaseUrl(): string
{
$appUrl = (string) config('app.url');
$scheme = parse_url($appUrl, PHP_URL_SCHEME) ?: 'https';
$host = (string) config('app.platform_domain')
?: (parse_url($appUrl, PHP_URL_HOST) ?: 'ladill.com');
return $scheme . '://' . $host;
}
/**
* String encoded inside the QR image.
*
* Most types encode the stable short link so the printed code never changes
* when content is edited. WiFi is the exception: it bakes the network
* credentials directly into the image so devices auto-join on scan. That
* payload is frozen at creation (payload.wifi_encoded) editing the network
* afterwards updates the saved info but never re-encodes the printed code.
*/
public function encodedPayload(): string
{
if ($this->type === self::TYPE_WIFI) {
$frozen = $this->payload['wifi_encoded'] ?? null;
return is_string($frozen) && $frozen !== ''
? $frozen
: QrWifiPayload::encode($this->content());
}
return $this->publicUrl();
}
/** WiFi codes encode their join payload directly (auto-join), not a redirect link. */
public function encodesDirectPayload(): bool
{
return $this->type === self::TYPE_WIFI;
}
public function typeLabel(): string
{
return QrTypeCatalog::label($this->type);
}
/** @return array<string, mixed> */
public function content(): array
{
return (array) ($this->payload['content'] ?? []);
}
/** @return array<string, mixed> */
public function style(): array
{
if ($this->isPaymentType()) {
return QrStyleDefaults::defaults();
}
return QrStyleDefaults::merge($this->payload['style'] ?? null);
}
public function isUrlType(): bool
{
return $this->type === self::TYPE_URL;
}
public function isDocumentType(): bool
{
return $this->type === self::TYPE_DOCUMENT;
}
public function isImageType(): bool
{
return $this->type === self::TYPE_IMAGE;
}
public function isMenuType(): bool
{
return $this->type === self::TYPE_MENU;
}
public function isShopType(): bool
{
return $this->type === self::TYPE_SHOP;
}
public function isBookingType(): bool
{
return in_array($this->type, [self::TYPE_BOOKING, self::TYPE_BOOK], true);
}
/** @deprecated Use {@see isBookingType()} */
public function isBookType(): bool
{
return $this->isBookingType();
}
public function acceptsOrders(): bool
{
return in_array($this->type, [self::TYPE_MENU, self::TYPE_SHOP, self::TYPE_BOOKING, self::TYPE_BOOK], true);
}
public function isPaymentType(): bool
{
return $this->type === self::TYPE_PAYMENT;
}
public function usesLandingPage(): bool
{
return in_array($this->type, [
self::TYPE_MENU,
self::TYPE_SHOP,
self::TYPE_PAYMENT,
], true);
}
public function bookings(): HasMany
{
return $this->hasMany(QrBooking::class);
}
public function resolvesToRedirect(): bool
{
return $this->type === self::TYPE_URL;
}
public function redirectUrl(): ?string
{
if ($this->destination_url) {
return $this->destination_url;
}
return $this->content()['url'] ?? null;
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class QrDocument extends Model
{
protected $fillable = [
'user_id',
'title',
'disk',
'path',
'mime_type',
'size_bytes',
'page_count',
];
protected $casts = [
'size_bytes' => 'integer',
'page_count' => 'integer',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function qrCodes(): HasMany
{
return $this->hasMany(QrCode::class);
}
}
+73
View File
@@ -0,0 +1,73 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class QrSaleOrder extends Model
{
public const STATUS_PENDING = 'pending';
public const STATUS_PAID = 'paid';
public const STATUS_FAILED = 'failed';
public const FULFILLMENT_NEW = 'new';
public const FULFILLMENT_CONFIRMED = 'confirmed';
public const FULFILLMENT_PREPARING = 'preparing';
public const FULFILLMENT_READY = 'ready';
public const FULFILLMENT_DELIVERED = 'delivered';
public const FULFILLMENT_CANCELLED = 'cancelled';
public const FULFILLMENT_STATUSES = [
self::FULFILLMENT_NEW => 'New',
self::FULFILLMENT_CONFIRMED => 'Confirmed',
self::FULFILLMENT_PREPARING => 'Preparing',
self::FULFILLMENT_READY => 'Ready',
self::FULFILLMENT_DELIVERED => 'Delivered',
self::FULFILLMENT_CANCELLED => 'Cancelled',
];
public const PLATFORM_FEE_RATE = 0.15;
protected $fillable = [
'pay_order_id',
'qr_code_id',
'user_id',
'customer_name',
'customer_email',
'customer_phone',
'items',
'amount_ghs',
'platform_fee_ghs',
'merchant_amount_ghs',
'status',
'fulfillment_status',
'payment_reference',
'paid_at',
'metadata',
];
protected $casts = [
'items' => 'array',
'amount_ghs' => 'decimal:2',
'platform_fee_ghs' => 'decimal:2',
'merchant_amount_ghs' => 'decimal:2',
'metadata' => 'array',
'paid_at' => 'datetime',
];
public function fulfillmentLabel(): string
{
return self::FULFILLMENT_STATUSES[$this->fulfillment_status ?? self::FULFILLMENT_NEW] ?? ucfirst((string) $this->fulfillment_status);
}
public function qrCode(): BelongsTo
{
return $this->belongsTo(QrCode::class);
}
public function merchant(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class QrScanEvent extends Model
{
protected $fillable = [
'qr_code_id',
'scanned_at',
'ip_hash',
'user_agent',
'device_type',
'browser',
'os',
'country_code',
'referrer',
'is_unique',
];
protected $casts = [
'scanned_at' => 'datetime',
'is_unique' => 'boolean',
];
public function qrCode(): BelongsTo
{
return $this->belongsTo(QrCode::class);
}
}
+172
View File
@@ -0,0 +1,172 @@
<?php
namespace App\Models;
use App\Support\Qr\QrStyleDefaults;
use App\Support\Qr\QrTypeCatalog;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/** Per-account Ladill Events preferences (notifications, event defaults, QR style). */
class QrSetting extends Model
{
protected $fillable = [
'user_id',
'notify_email',
'product_updates',
'low_balance_alerts',
'notify_registrations',
'notify_payouts',
'default_type',
'default_style',
'event_defaults',
];
protected $casts = [
'product_updates' => 'boolean',
'low_balance_alerts' => 'boolean',
'notify_registrations' => 'boolean',
'notify_payouts' => 'boolean',
'default_style' => 'array',
'event_defaults' => 'array',
];
/** @return list<string> */
public static function storableEventDefaultKeys(): array
{
return [
'currency',
'mode',
'badge_size',
'brand_color',
'organizer',
'registration_open',
'badge_fields',
];
}
/** @return list<string> */
public static function storableStyleKeys(): array
{
return [
'foreground',
'background',
'error_correction',
'margin',
'module_style',
'finder_outer',
'finder_inner',
'frame_style',
'frame_text',
'frame_color',
'gradient_type',
'gradient_color1',
'gradient_color2',
'gradient_rotation',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function resolvedDefaultType(): string
{
$type = (string) ($this->default_type ?? QrCode::TYPE_EVENT);
return in_array($type, QrTypeCatalog::eventsTypes(), true) ? $type : QrCode::TYPE_EVENT;
}
/** @return array<string, mixed> */
public function resolvedEventDefaults(): array
{
$defaults = [
'currency' => 'GHS',
'mode' => 'ticketing',
'badge_size' => '4x3',
'brand_color' => '#4f46e5',
'organizer' => '',
'registration_open' => true,
'badge_fields' => ['Company', 'Role'],
];
$stored = collect($this->event_defaults ?? [])
->only(self::storableEventDefaultKeys())
->filter(fn ($value) => $value !== null && $value !== '')
->all();
if (isset($stored['badge_fields']) && is_array($stored['badge_fields'])) {
$stored['badge_fields'] = array_values(array_filter(
array_map('strval', $stored['badge_fields']),
fn (string $field) => trim($field) !== '',
));
if ($stored['badge_fields'] === []) {
unset($stored['badge_fields']);
}
}
return array_merge($defaults, $stored);
}
/** @param array<string, mixed>|null $input */
public static function sanitizeEventDefaults(?array $input): ?array
{
if ($input === null) {
return null;
}
$allowedModes = ['ticketing', 'contributions', 'free'];
$allowedSizes = ['4x3', '4x6', 'cr80'];
$allowedCurrencies = ['GHS', 'USD', 'NGN', 'KES'];
$mode = (string) ($input['mode'] ?? 'ticketing');
$badgeSize = (string) ($input['badge_size'] ?? '4x3');
$currency = strtoupper(trim((string) ($input['currency'] ?? 'GHS')));
$badgeFields = array_values(array_filter(
array_map(fn ($field) => mb_substr(trim((string) $field), 0, 40), (array) ($input['badge_fields'] ?? [])),
fn (string $field) => $field !== '',
));
$brandColor = (string) ($input['brand_color'] ?? '#4f46e5');
if (! preg_match('/^#[0-9a-fA-F]{6}$/', $brandColor)) {
$brandColor = '#4f46e5';
}
return [
'currency' => in_array($currency, $allowedCurrencies, true) ? $currency : 'GHS',
'mode' => in_array($mode, $allowedModes, true) ? $mode : 'ticketing',
'badge_size' => in_array($badgeSize, $allowedSizes, true) ? $badgeSize : '4x3',
'brand_color' => $brandColor,
'organizer' => mb_substr(trim((string) ($input['organizer'] ?? '')), 0, 120),
'registration_open' => filter_var($input['registration_open'] ?? true, FILTER_VALIDATE_BOOL),
'badge_fields' => $badgeFields !== [] ? $badgeFields : ['Company', 'Role'],
];
}
/** @return array<string, mixed> */
public function resolvedDefaultStyle(): array
{
$stored = collect($this->default_style ?? [])
->only(self::storableStyleKeys())
->filter(fn ($value) => $value !== null && $value !== '')
->all();
return QrStyleDefaults::merge($stored !== [] ? $stored : null);
}
/** @param array<string, mixed>|null $input */
public static function sanitizeDefaultStyle(?array $input): ?array
{
if ($input === null) {
return null;
}
$merged = QrStyleDefaults::merge(
collect($input)->only(self::storableStyleKeys())->all(),
);
return collect($merged)->only(self::storableStyleKeys())->all();
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/** Membership linking a user to an account (owner) whose QR codes they can manage. */
class QrTeamMember extends Model
{
public const ROLE_ADMIN = 'admin';
public const ROLE_MEMBER = 'member';
public const STATUS_INVITED = 'invited';
public const STATUS_ACTIVE = 'active';
protected $fillable = ['account_id', 'user_id', 'email', 'role', 'status', 'token', 'accepted_at'];
protected $casts = ['accepted_at' => 'datetime'];
public function account(): BelongsTo
{
return $this->belongsTo(User::class, 'account_id');
}
public function member(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public static function linkPendingInvitesFor(User $user): void
{
static::query()
->whereNull('user_id')
->where('status', self::STATUS_INVITED)
->whereRaw('LOWER(email) = ?', [strtolower($user->email)])
->update([
'user_id' => $user->id,
'status' => self::STATUS_ACTIVE,
'accepted_at' => now(),
'token' => null,
]);
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class QrTransaction extends Model
{
public const TYPE_CREDIT = 'credit';
public const TYPE_DEBIT = 'debit';
protected $fillable = [
'user_id',
'qr_wallet_id',
'qr_code_id',
'type',
'amount_ghs',
'balance_after_ghs',
'reference',
'status',
'description',
'metadata',
];
protected $casts = [
'amount_ghs' => 'decimal:4',
'balance_after_ghs' => 'decimal:4',
'metadata' => 'array',
];
public function wallet(): BelongsTo
{
return $this->belongsTo(QrWallet::class, 'qr_wallet_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function qrCode(): BelongsTo
{
return $this->belongsTo(QrCode::class);
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class QrWallet extends Model
{
public const STATUS_ACTIVE = 'active';
public const STATUS_SUSPENDED = 'suspended';
protected $fillable = [
'user_id',
'credit_balance',
'qr_codes_total',
'scans_total',
'status',
];
protected $casts = [
'credit_balance' => 'decimal:4',
'qr_codes_total' => 'integer',
'scans_total' => 'integer',
];
public static function pricePerQr(): float
{
return (float) config('qr.price_per_qr_ghs', 5.0);
}
public static function minTopupGhs(): float
{
return (float) config('qr.min_topup_ghs', 5.0);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function transactions(): HasMany
{
return $this->hasMany(QrTransaction::class)->latest();
}
/**
* Single-wallet (siloing step 2): QR spends from the one UserWallet (tagged
* 'qr'). Delegates to the billing service (lazily folds any legacy
* credit_balance in). `spendableBalance()` is the unified balance for display.
*/
public function spendableBalance(): float
{
return $this->user
? app(\App\Services\Qr\QrWalletBillingService::class)->balanceCedis($this->user)
: (float) $this->credit_balance;
}
public function canCreateQr(): bool
{
return $this->user
? app(\App\Services\Qr\QrWalletBillingService::class)->canCreate($this->user)
: false;
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
namespace App\Models;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Collection;
use Laravel\Sanctum\HasApiTokens;
/**
* Thin local mirror of the platform identity (auth.ladill.com owns users).
*/
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasApiTokens, HasFactory, Notifiable;
protected $fillable = ['public_id', 'name', 'email', 'avatar_url', 'password'];
protected $hidden = ['password', 'remember_token'];
protected function casts(): array
{
return ['email_verified_at' => 'datetime', 'password' => 'hashed'];
}
public function memberships(): HasMany
{
return $this->hasMany(QrTeamMember::class, 'user_id')
->where('status', QrTeamMember::STATUS_ACTIVE);
}
public function canAccessAccount(int $accountId): bool
{
return $accountId === $this->id
|| $this->memberships()->where('account_id', $accountId)->exists();
}
/** @return Collection<int, User> */
public function accessibleAccounts(): Collection
{
$ids = $this->memberships()->pluck('account_id')->all();
return collect([$this])->merge(self::whereIn('id', $ids)->get())->unique('id')->values();
}
public function qrWallet(): HasOne
{
return $this->hasOne(QrWallet::class);
}
public function qrCodes(): HasMany
{
return $this->hasMany(QrCode::class);
}
public function qrSetting(): HasOne
{
return $this->hasOne(QrSetting::class);
}
public function getOrCreateQrSetting(): QrSetting
{
return $this->qrSetting()->firstOrCreate([]);
}
public function getOrCreateQrWallet(): QrWallet
{
return $this->qrWallet()->firstOrCreate(
[],
['credit_balance' => 0, 'qr_codes_total' => 0, 'scans_total' => 0, 'status' => QrWallet::STATUS_ACTIVE],
);
}
public function avatarUrl(): ?string
{
$url = trim((string) $this->avatar_url);
return $url !== '' ? $url : null;
}
}
@@ -0,0 +1,45 @@
<?php
namespace App\Notifications;
use App\Models\Domain;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;
use Illuminate\Notifications\Messages\MailMessage;
class DomainVerifiedNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
private readonly Domain $domain
) {
}
public function via($notifiable): array
{
return ['mail', 'database'];
}
public function toMail($notifiable): MailMessage
{
return (new MailMessage())
->subject("Domain Verified: {$this->domain->host} is now active!")
->view('mail.notifications.domain-verified', [
'domain' => $this->domain,
'manageUrl' => route('user.domains.show', $this->domain),
'emailUrl' => route('user.mailboxes.index'),
]);
}
public function toArray($notifiable): array
{
return [
'title' => 'Domain Verified',
'message' => "Your domain {$this->domain->host} has been verified and is now active.",
'icon' => 'success',
'url' => route('user.domains.show', $this->domain),
];
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Notifications;
use App\Models\QrCode;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class EventProgrammeSharedNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
private readonly QrCode $event,
private readonly QrCode $programme,
private readonly ?string $attendeeName = null,
) {}
public function via(mixed $notifiable): array
{
return ['mail'];
}
public function toMail(mixed $notifiable): MailMessage
{
$eventName = $this->event->content()['name'] ?? $this->event->label;
return (new MailMessage())
->subject('Programme for ' . $eventName)
->view('mail.notifications.event-programme', [
'eventName' => $eventName,
'programmeUrl' => $this->programme->publicUrl(),
'attendeeName' => $this->attendeeName,
]);
}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;
use Illuminate\Notifications\Messages\MailMessage;
class HostingActivatedNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
private readonly string $planName,
private readonly string $activationDate,
private readonly ?string $domainName = null,
private readonly ?string $manageUrl = null
) {
}
public function via($notifiable): array
{
return ['mail', 'database'];
}
public function toMail($notifiable): MailMessage
{
return (new MailMessage())
->subject('Your hosting is now active!')
->view('mail.notifications.hosting-activated', [
'planName' => $this->planName,
'activationDate' => $this->activationDate,
'domainName' => $this->domainName,
'manageUrl' => $this->manageUrl ?? route('hosting.index'),
]);
}
public function toArray($notifiable): array
{
$message = "Your {$this->planName} hosting plan is now active";
if ($this->domainName) {
$message .= " for {$this->domainName}";
}
return [
'title' => 'Hosting Activated',
'message' => $message . '.',
'icon' => 'hosting',
'url' => $this->manageUrl ?? route('hosting.index'),
];
}
}
@@ -0,0 +1,54 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class HostingDeveloperAddedNotification extends Notification implements ShouldQueue
{
use Queueable;
/**
* @param array<int, string> $accountLabels
*/
public function __construct(
private string $ownerName,
private array $accountLabels,
private ?string $setupUrl = null
) {}
public function via($notifiable): array
{
return ['mail', 'database'];
}
public function toMail($notifiable): MailMessage
{
return (new MailMessage())
->subject('You were added to a Ladill hosting team')
->view('mail.notifications.hosting-developer-added', [
'developer' => $notifiable,
'ownerName' => $this->ownerName,
'accountLabels' => $this->accountLabels,
'setupUrl' => $this->setupUrl,
'loginUrl' => route('login'),
'dashboardUrl' => route('dashboard'),
]);
}
public function toArray($notifiable): array
{
$accountCount = count($this->accountLabels);
$scope = $accountCount === 1 ? $this->accountLabels[0] : $accountCount.' hosting accounts';
return [
'title' => 'Hosting team access granted',
'message' => "You were added by {$this->ownerName} to {$scope}.",
'icon' => 'hosting',
'url' => route('hosting.single-domain'),
];
}
}
@@ -0,0 +1,68 @@
<?php
namespace App\Notifications;
use App\Models\HostingAccount;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class HostingExpiringNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
private readonly HostingAccount $account,
private readonly int $daysRemaining,
) {
}
public function via($notifiable): array
{
return ['mail', 'database'];
}
public function toMail($notifiable): MailMessage
{
return (new MailMessage())
->subject($this->subjectLine())
->view('mail.notifications.hosting-expiring', [
'account' => $this->account,
'daysRemaining' => $this->daysRemaining,
'renewUrl' => route('hosting.accounts.show', $this->account),
]);
}
public function toArray($notifiable): array
{
$label = $this->account->primary_domain ?: $this->account->username;
return [
'title' => $this->headline(),
'message' => "Hosting for {$label} expires in {$this->daysRemaining} days.",
'icon' => 'hosting',
'url' => route('hosting.accounts.show', $this->account),
];
}
private function subjectLine(): string
{
$label = $this->account->primary_domain ?: $this->account->username;
return match (true) {
$this->daysRemaining <= 1 => "Hosting expires tomorrow: {$label}",
$this->daysRemaining <= 7 => "Urgent: hosting for {$label} expires in {$this->daysRemaining} days",
default => "Hosting renewal reminder: {$label}",
};
}
private function headline(): string
{
return match (true) {
$this->daysRemaining <= 1 => 'Hosting expires soon',
$this->daysRemaining <= 7 => 'Urgent hosting renewal',
default => 'Hosting renewal reminder',
};
}
}
@@ -0,0 +1,47 @@
<?php
namespace App\Notifications;
use App\Models\HostingAccount;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class HostingResourceWarningNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
private readonly HostingAccount $account,
private readonly string $subjectLine,
private readonly string $messageBody
) {
}
public function via($notifiable): array
{
return ['mail', 'database'];
}
public function toMail($notifiable): MailMessage
{
return (new MailMessage())
->subject($this->subjectLine)
->greeting('Hosting resource update')
->line($this->messageBody)
->line('Domain: ' . ($this->account->primary_domain ?: $this->account->username))
->line('Resource state: ' . ucfirst((string) ($this->account->resource_status ?: 'active')))
->action('Manage Hosting', route('hosting.accounts.show', $this->account));
}
public function toArray($notifiable): array
{
return [
'title' => $this->subjectLine,
'message' => $this->messageBody,
'icon' => 'hosting',
'url' => route('hosting.accounts.show', $this->account),
];
}
}
@@ -0,0 +1,48 @@
<?php
namespace App\Notifications;
use App\Models\HostingAccount;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class HostingSuspendedNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
private readonly HostingAccount $account,
private readonly string $reason,
) {
}
public function via($notifiable): array
{
return ['mail', 'database'];
}
public function toMail($notifiable): MailMessage
{
return (new MailMessage())
->subject('Hosting suspended: '.($this->account->primary_domain ?: $this->account->username))
->view('mail.notifications.hosting-suspended', [
'account' => $this->account,
'reason' => $this->reason,
'dashboardUrl' => route('hosting.accounts.show', $this->account),
]);
}
public function toArray($notifiable): array
{
$label = $this->account->primary_domain ?: $this->account->username;
return [
'title' => 'Hosting suspended',
'message' => "Hosting for {$label} has been suspended. {$this->reason}",
'icon' => 'hosting',
'url' => route('hosting.accounts.show', $this->account),
];
}
}
@@ -0,0 +1,47 @@
<?php
namespace App\Notifications;
use App\Models\Domain;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;
use Illuminate\Notifications\Messages\MailMessage;
class SslExpiringNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
private readonly Domain $domain,
private readonly int $daysUntilExpiry
) {
}
public function via($notifiable): array
{
return ['mail', 'database'];
}
public function toMail($notifiable): MailMessage
{
return (new MailMessage())
->subject("SSL Certificate Expiring Soon: {$this->domain->host}")
->view('mail.notifications.ssl-expiring', [
'domain' => $this->domain,
'daysUntilExpiry' => $this->daysUntilExpiry,
'expiresAt' => $this->domain->ssl_expires_at,
'manageUrl' => route('user.domains.show', $this->domain),
]);
}
public function toArray($notifiable): array
{
return [
'title' => 'SSL Certificate Expiring',
'message' => "Your SSL certificate for {$this->domain->host} expires in {$this->daysUntilExpiry} days.",
'icon' => 'warning',
'url' => route('user.domains.show', $this->domain),
];
}
}
@@ -0,0 +1,45 @@
<?php
namespace App\Notifications;
use App\Models\Domain;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;
use Illuminate\Notifications\Messages\MailMessage;
class SslProvisionedNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
private readonly Domain $domain
) {
}
public function via($notifiable): array
{
return ['mail', 'database'];
}
public function toMail($notifiable): MailMessage
{
return (new MailMessage())
->subject("SSL Certificate Active: {$this->domain->host}")
->view('mail.notifications.ssl-provisioned', [
'domain' => $this->domain,
'expiresAt' => $this->domain->ssl_expires_at,
'websiteUrl' => "https://{$this->domain->host}",
]);
}
public function toArray($notifiable): array
{
return [
'title' => 'SSL Certificate Active',
'message' => "Your SSL certificate for {$this->domain->host} is now active. Your site is secure!",
'icon' => 'ssl',
'url' => route('user.domains.show', $this->domain),
];
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Policies;
use App\Models\QrCode;
use App\Models\User;
class QrCodePolicy
{
public function view(User $user, QrCode $qrCode): bool
{
return $user->canAccessAccount($qrCode->user_id);
}
public function update(User $user, QrCode $qrCode): bool
{
return $user->canAccessAccount($qrCode->user_id);
}
public function delete(User $user, QrCode $qrCode): bool
{
return $user->canAccessAccount($qrCode->user_id);
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Providers;
use App\Models\QrCode;
use App\Policies\QrCodePolicy;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;
use App\Support\MobileTopbar;
use Illuminate\Support\Facades\View;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
//
}
public function boot(): void
{
Gate::policy(QrCode::class, QrCodePolicy::class);
View::composer(['partials.topbar', 'partials.topbar-qr'], function ($view) {
$view->with(MobileTopbar::resolve());
});
}
}
+126
View File
@@ -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;
}
}
+87
View File
@@ -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();
}
}
+171
View File
@@ -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;
}
}
+43
View File
@@ -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()]);
}
}
}
+57
View File
@@ -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;
}
}
+50
View File
@@ -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();
}
}
+91
View File
@@ -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();
}
}
+546
View File
@@ -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();
}
}
+488
View File
@@ -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;
}
}
+696
View File
@@ -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,
];
}
}
+93
View File
@@ -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);
}
}
+89
View File
@@ -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],
]);
});
}
}
+369
View File
@@ -0,0 +1,369 @@
<?php
namespace App\Support;
use App\Services\Domain\DomainPricingService;
class DomainConfig
{
private const PRIORITY_TLDS = [
'com', 'org', 'net', 'ai', 'io', 'co', 'app', 'dev',
'store', 'shop', 'online', 'site', 'tech', 'cloud', 'pro', 'me',
'xyz', 'club', 'live', 'com.ng', 'com.gh', 'co.za', 'co.uk', 'info', 'biz',
];
private const DEFAULT_PAGE_SIZE = 20;
/**
* Get featured TLDs for initial display (lightweight).
*
* @return list<string>
*/
public static function featuredTlds(): array
{
$allTlds = self::allTlds();
$featured = array_values(array_intersect(self::PRIORITY_TLDS, $allTlds));
if ($featured !== []) {
return $featured;
}
return array_slice($allTlds, 0, self::DEFAULT_PAGE_SIZE);
}
/**
* Get all available TLDs sorted by priority.
*
* @return list<string>
*/
public static function allTlds(): array
{
$current = self::normalizeTlds(config('domain.search_tlds', []));
$legacy = self::normalizeTlds(config('mailinfra.domain_search_tlds', []));
$tlds = $legacy !== [] ? $legacy : $current;
$livePricedTlds = self::livePricedTlds();
if ($livePricedTlds === []) {
return [];
}
$livePricedLookup = array_flip($livePricedTlds);
$tlds = array_values(array_filter(
$tlds,
static fn (string $tld): bool => isset($livePricedLookup[$tld])
));
return self::sortWithPriority($tlds);
}
/**
* Get paginated TLDs (excluding featured ones).
*
* @return array{tlds: list<string>, hasMore: bool, total: int}
*/
public static function paginatedTlds(int $page = 1, int $perPage = self::DEFAULT_PAGE_SIZE): array
{
$allTlds = self::allTlds();
$featured = self::featuredTlds();
$remaining = array_values(array_filter($allTlds, fn ($tld) => !in_array($tld, $featured, true)));
$total = count($remaining);
$offset = ($page - 1) * $perPage;
$tlds = array_slice($remaining, $offset, $perPage);
return [
'tlds' => $tlds,
'hasMore' => ($offset + $perPage) < $total,
'total' => $total,
];
}
/**
* @deprecated Use featuredTlds() or allTlds() instead
* @return list<string>
*/
public static function searchTlds(): array
{
return self::featuredTlds();
}
/**
* Build the TLD list and query term for a search page request.
*
* @return array{query: string, tlds: list<string>, hasMore: bool, exact_domain: string|null}
*/
public static function searchPageConfig(string $query, int $page = 1, int $perPage = self::DEFAULT_PAGE_SIZE): array
{
$query = strtolower(trim($query));
$allTlds = self::allTlds();
if ($allTlds === []) {
return [
'query' => $query,
'tlds' => [],
'hasMore' => false,
'exact_domain' => null,
];
}
$exactDomain = self::normalizeExactDomain($query);
if ($exactDomain === null) {
if ($page === 1) {
$featured = self::featuredTlds();
$paginatedData = self::paginatedTlds(1, $perPage);
return [
'query' => $query,
'tlds' => $featured,
'hasMore' => $paginatedData['hasMore'] || count($paginatedData['tlds']) > 0,
'exact_domain' => null,
];
}
$paginatedData = self::paginatedTlds($page - 1, $perPage);
return [
'query' => $query,
'tlds' => $paginatedData['tlds'],
'hasMore' => $paginatedData['hasMore'],
'exact_domain' => null,
];
}
$exactTld = self::extractTld($exactDomain);
$keyword = explode('.', $exactDomain, 2)[0] ?? $exactDomain;
$firstPageTlds = array_values(array_unique(array_filter([
$exactTld,
...self::featuredTlds(),
])));
$remainingTlds = array_values(array_filter(
$allTlds,
static fn (string $tld): bool => ! in_array($tld, $firstPageTlds, true)
));
if ($page === 1) {
return [
'query' => $keyword,
'tlds' => $firstPageTlds,
'hasMore' => $remainingTlds !== [],
'exact_domain' => $exactDomain,
];
}
$offset = max(0, ($page - 2) * $perPage);
return [
'query' => $keyword,
'tlds' => array_slice($remainingTlds, $offset, $perPage),
'hasMore' => ($offset + $perPage) < count($remainingTlds),
'exact_domain' => $exactDomain,
];
}
/**
* Sort domain search results by TLD priority.
*
* @param array<int, array{domain: string, ...}> $results
* @return array<int, array{domain: string, ...}>
*/
public static function sortResultsByTldPriority(array $results, ?string $query = null): array
{
$priorityMap = array_flip(self::PRIORITY_TLDS);
$exactDomain = self::normalizeExactDomain((string) $query);
usort($results, static function (array $a, array $b) use ($priorityMap, $exactDomain): int {
$aDomain = strtolower((string) ($a['domain'] ?? ''));
$bDomain = strtolower((string) ($b['domain'] ?? ''));
$aExact = $exactDomain !== null && $aDomain === $exactDomain ? 0 : 1;
$bExact = $exactDomain !== null && $bDomain === $exactDomain ? 0 : 1;
if ($aExact !== $bExact) {
return $aExact <=> $bExact;
}
$aPremium = self::isPremiumResult($a) ? 0 : 1;
$bPremium = self::isPremiumResult($b) ? 0 : 1;
if ($aPremium !== $bPremium) {
return $aPremium <=> $bPremium;
}
$aAvailable = ! empty($a['available']) ? 0 : 1;
$bAvailable = ! empty($b['available']) ? 0 : 1;
if ($aAvailable !== $bAvailable) {
return $aAvailable <=> $bAvailable;
}
$aTld = self::extractTld($a['domain'] ?? '');
$bTld = self::extractTld($b['domain'] ?? '');
$aPriority = $priorityMap[$aTld] ?? PHP_INT_MAX;
$bPriority = $priorityMap[$bTld] ?? PHP_INT_MAX;
if ($aPriority !== $bPriority) {
return $aPriority <=> $bPriority;
}
return $aDomain <=> $bDomain;
});
return $results;
}
/**
* Extract TLD from a domain name.
*/
private static function extractTld(string $domain): string
{
$parts = explode('.', strtolower($domain), 2);
return $parts[1] ?? '';
}
private static function normalizeExactDomain(string $query): ?string
{
$query = strtolower(trim($query));
if ($query === '' || str_starts_with($query, '.') || str_ends_with($query, '.') || ! str_contains($query, '.')) {
return null;
}
return preg_match('/^(?=.{1,253}$)(?!-)(?:[a-z0-9-]{1,63}\.)+[a-z]{2,63}$/', $query) === 1
? $query
: null;
}
/**
* @param array<string, mixed> $result
*/
private static function isPremiumResult(array $result): bool
{
$premium = $result['premium'] ?? false;
if (is_bool($premium)) {
return $premium;
}
return in_array(strtolower(trim((string) $premium)), ['1', 'yes', 'true', 'premium'], true);
}
/**
* @param list<string> $tlds
* @return list<string>
*/
private static function sortWithPriority(array $tlds): array
{
$priorityMap = array_flip(self::PRIORITY_TLDS);
$pricingMap = self::pricingMapForTlds($tlds);
usort($tlds, static function (string $a, string $b) use ($priorityMap, $pricingMap): int {
$aPriority = $priorityMap[$a] ?? PHP_INT_MAX;
$bPriority = $priorityMap[$b] ?? PHP_INT_MAX;
if ($aPriority !== $bPriority) {
return $aPriority <=> $bPriority;
}
$aPrice = (int) ($pricingMap[$a]['register'] ?? 0);
$bPrice = (int) ($pricingMap[$b]['register'] ?? 0);
if ($aPrice !== $bPrice) {
return $bPrice <=> $aPrice;
}
return $a <=> $b;
});
return $tlds;
}
/**
* @return array<string, string>
*/
public static function resellerClubTldProductKeys(): array
{
return array_replace(
self::normalizeProductKeyMap(config('domain.resellerclub.tld_product_keys', [])),
self::normalizeProductKeyMap(config('mailinfra.resellerclub_tld_product_keys', []))
);
}
public static function resellerClubProductKeyCacheTtlSeconds(): int
{
$ttl = (int) config('domain.resellerclub.product_key_cache_ttl_seconds', 86400);
return $ttl > 0 ? $ttl : 86400;
}
/**
* @param mixed $value
* @return list<string>
*/
private static function normalizeTlds(mixed $value): array
{
if (! is_array($value)) {
return [];
}
return array_values(array_unique(array_filter(array_map(
static fn ($tld): string => ltrim(strtolower(trim((string) $tld)), '.'),
$value
))));
}
/**
* @param mixed $value
* @return array<string, string>
*/
private static function normalizeProductKeyMap(mixed $value): array
{
if (! is_array($value)) {
return [];
}
$map = [];
foreach ($value as $tld => $productKey) {
$normalizedTld = ltrim(strtolower(trim((string) $tld)), '.');
$normalizedProductKey = trim((string) $productKey);
if ($normalizedTld === '' || $normalizedProductKey === '') {
continue;
}
$map[$normalizedTld] = $normalizedProductKey;
}
return $map;
}
/**
* @return list<string>
*/
private static function livePricedTlds(): array
{
try {
return app(DomainPricingService::class)->getLivePricedTlds();
} catch (\Throwable) {
return [];
}
}
/**
* @param list<string> $tlds
* @return array<string, array{register: int, renew: int, transfer: int, currency: string}>
*/
private static function pricingMapForTlds(array $tlds): array
{
try {
return app(DomainPricingService::class)->getPricingForTlds($tlds);
} catch (\Throwable) {
return [];
}
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace App\Support;
final class DomainGlobeIcon
{
public const VIEW_BOX = '0 0 14 14';
/** 14×14 globe for UI icons (inherits color via currentColor). */
public const ICON_ASSET = 'images/ladill-icons/domain.svg';
public static function paths(): string
{
return '<path d="M7 13.5C10.5899 13.5 13.5 10.5899 13.5 7C13.5 3.41015 10.5899 0.5 7 0.5C3.41015 0.5 0.5 3.41015 0.5 7C0.5 10.5899 3.41015 13.5 7 13.5Z" stroke-linecap="round" stroke-linejoin="round"/>'
.'<path d="M0.5 7H13.5" stroke-linecap="round" stroke-linejoin="round"/>'
.'<path d="M9.5 7C9.3772 9.37699 8.50168 11.6533 7 13.5C5.49832 11.6533 4.6228 9.37699 4.5 7C4.6228 4.62301 5.49832 2.34665 7 0.5C8.50168 2.34665 9.3772 4.62301 9.5 7V7Z" stroke-linecap="round" stroke-linejoin="round"/>';
}
public static function svg(string $class = 'h-5 w-5'): string
{
return sprintf(
'<svg class="%s" viewBox="%s" fill="none" stroke="currentColor" stroke-width="1" aria-hidden="true">%s</svg>',
e($class),
self::VIEW_BOX,
self::paths()
);
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace App\Support\Events;
use App\Models\QrCode;
use Illuminate\Support\Collection;
/**
* Generates ZPL II label code for Zebra-style badge/label printers.
* One ^XA…^XZ label block per attendee, sized to the chosen badge size at 203 dpi.
*/
class EventBadgeZpl
{
/** @param Collection<int, \App\Models\QrEventRegistration> $registrations */
public static function forRegistrations(QrCode $qrCode, Collection $registrations): string
{
$content = $qrCode->content();
$eventName = self::sanitize($content['name'] ?? $qrCode->label);
$size = $content['badge_size'] ?? '4x3';
// Label dimensions in dots @ 203 dpi.
[$widthDots, $heightDots] = match ($size) {
'4x6' => [812, 1218],
'cr80' => [685, 431],
default => [812, 609], // 4x3
};
$labels = [];
foreach ($registrations as $reg) {
$name = self::sanitize($reg->attendee_name);
$tier = self::sanitize($reg->tier_name);
$extra = collect($reg->badge_fields ?? [])
->map(fn ($v, $k) => self::sanitize($k . ': ' . $v))
->implode(' | ');
$zpl = "^XA\n";
$zpl .= "^PW{$widthDots}\n";
$zpl .= "^LL{$heightDots}\n";
$zpl .= "^CI28\n"; // UTF-8
// Event name (top)
$zpl .= "^FO40,40^A0N,40,40^FB" . ($widthDots - 80) . ",1,0,C^FD{$eventName}^FS\n";
// Attendee name (large, centered)
$zpl .= "^FO40,150^A0N,80,80^FB" . ($widthDots - 80) . ",2,0,C^FD{$name}^FS\n";
// Tier
$zpl .= "^FO40,320^A0N,40,40^FB" . ($widthDots - 80) . ",1,0,C^FD{$tier}^FS\n";
// Extra fields
if ($extra !== '') {
$zpl .= "^FO40,375^A0N,28,28^FB" . ($widthDots - 80) . ",2,0,C^FD{$extra}^FS\n";
}
// QR of the badge code (bottom)
$qrX = (int) (($widthDots / 2) - 70);
$zpl .= "^FO{$qrX}," . ($heightDots - 200) . "^BQN,2,5^FDLA,{$reg->badge_code}^FS\n";
// Badge code text
$zpl .= "^FO40," . ($heightDots - 60) . "^A0N,34,34^FB" . ($widthDots - 80) . ",1,0,C^FD{$reg->badge_code}^FS\n";
$zpl .= "^XZ\n";
$labels[] = $zpl;
}
return implode("\n", $labels);
}
private static function sanitize(string $value): string
{
// Escape ZPL control chars (^ and ~) and collapse whitespace.
$value = str_replace(['^', '~'], [' ', ' '], $value);
return trim(preg_replace('/\s+/', ' ', $value) ?? '');
}
}
+69
View File
@@ -0,0 +1,69 @@
<?php
namespace App\Support;
/** Storage-tier pricing for mailboxes (config/email.php quota_tiers). */
class MailboxPricing
{
/** @return array<int,array{mb:int,price_minor:int,label:string}> */
public static function tiers(): array
{
return array_map(fn ($t) => [
'mb' => (int) $t['mb'],
'price_minor' => (int) $t['price_minor'],
'label' => self::label((int) $t['mb']),
], (array) config('email.quota_tiers', []));
}
public static function isValidQuota(int $mb): bool
{
foreach (self::tiers() as $t) {
if ($t['mb'] === $mb) {
return true;
}
}
return false;
}
public static function priceMinorFor(int $mb): int
{
foreach (self::tiers() as $t) {
if ($t['mb'] === $mb) {
return $t['price_minor'];
}
}
return 0;
}
/** A tier is free when its monthly price is zero (the 1 GB plan). */
public static function isFree(int $mb): bool
{
return self::priceMinorFor($mb) === 0;
}
/** The smallest free tier's quota (the default for new mailboxes). */
public static function freeQuotaMb(): int
{
foreach (self::tiers() as $t) {
if ($t['price_minor'] === 0) {
return $t['mb'];
}
}
return self::tiers()[0]['mb'] ?? 1024;
}
public static function defaultQuotaMb(): int
{
$default = (int) config('email.default_quota_mb', 1024);
return self::isValidQuota($default) ? $default : self::freeQuotaMb();
}
public static function label(int $mb): string
{
return $mb % 1024 === 0 ? ($mb / 1024).' GB' : $mb.' MB';
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace App\Support;
class MobileTopbar
{
public static function resolve(): array
{
$appName = config('mobile-topbar.app_name', 'Ladill');
$title = $appName === 'Ladill'
? 'Ladill'
: "Ladill {$appName}";
return [
'mobileTopbarTitle' => $title,
];
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace App\Support\Qr;
use App\Models\QrCode;
use Illuminate\Support\Collection;
/**
* Generates ZPL II label code for Zebra-style badge/label printers.
* One ^XA…^XZ label block per attendee, sized to the chosen badge size at 203 dpi.
*/
class EventBadgeZpl
{
/** @param Collection<int, \App\Models\QrEventRegistration> $registrations */
public static function forRegistrations(QrCode $qrCode, Collection $registrations): string
{
$content = $qrCode->content();
$eventName = self::sanitize($content['name'] ?? $qrCode->label);
$size = $content['badge_size'] ?? '4x3';
// Label dimensions in dots @ 203 dpi.
[$widthDots, $heightDots] = match ($size) {
'4x6' => [812, 1218],
'cr80' => [685, 431],
default => [812, 609], // 4x3
};
$labels = [];
foreach ($registrations as $reg) {
$name = self::sanitize($reg->attendee_name);
$tier = self::sanitize($reg->tier_name);
$extra = collect($reg->badge_fields ?? [])
->map(fn ($v, $k) => self::sanitize($k . ': ' . $v))
->implode(' | ');
$zpl = "^XA\n";
$zpl .= "^PW{$widthDots}\n";
$zpl .= "^LL{$heightDots}\n";
$zpl .= "^CI28\n"; // UTF-8
// Event name (top)
$zpl .= "^FO40,40^A0N,40,40^FB" . ($widthDots - 80) . ",1,0,C^FD{$eventName}^FS\n";
// Attendee name (large, centered)
$zpl .= "^FO40,150^A0N,80,80^FB" . ($widthDots - 80) . ",2,0,C^FD{$name}^FS\n";
// Tier
$zpl .= "^FO40,320^A0N,40,40^FB" . ($widthDots - 80) . ",1,0,C^FD{$tier}^FS\n";
// Extra fields
if ($extra !== '') {
$zpl .= "^FO40,375^A0N,28,28^FB" . ($widthDots - 80) . ",2,0,C^FD{$extra}^FS\n";
}
// QR of the badge code (bottom)
$qrX = (int) (($widthDots / 2) - 70);
$zpl .= "^FO{$qrX}," . ($heightDots - 200) . "^BQN,2,5^FDLA,{$reg->badge_code}^FS\n";
// Badge code text
$zpl .= "^FO40," . ($heightDots - 60) . "^A0N,34,34^FB" . ($widthDots - 80) . ",1,0,C^FD{$reg->badge_code}^FS\n";
$zpl .= "^XZ\n";
$labels[] = $zpl;
}
return implode("\n", $labels);
}
private static function sanitize(string $value): string
{
// Escape ZPL control chars (^ and ~) and collapse whitespace.
$value = str_replace(['^', '~'], [' ', ' '], $value);
return trim(preg_replace('/\s+/', ' ', $value) ?? '');
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace App\Support\Qr;
class QrCornerStyleCatalog
{
/** @return array<string, array{label: string}> */
public static function outerStyles(): array
{
return [
'square' => ['label' => 'Square eye'],
'rounded' => ['label' => 'Rounded'],
'circle' => ['label' => 'Circular'],
];
}
/** @return array<string, array{label: string}> */
public static function innerStyles(): array
{
return [
'square' => ['label' => 'Square'],
'rounded' => ['label' => 'Rounded'],
'dot' => ['label' => 'Dot'],
];
}
public static function isValidOuter(string $style): bool
{
return isset(self::outerStyles()[$style]);
}
public static function isValidInner(string $style): bool
{
return isset(self::innerStyles()[$style]);
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Support\Qr;
class QrCoverImageSpec
{
/** Wide hero banners (business, church, event, itinerary, menu, shop). */
public const BANNER = '1920×1080 px (16:9 landscape)';
/** Book product cover on the landing page. */
public const BOOK = '800×1067 px (3:4 portrait)';
public static function label(string $variant = 'banner'): string
{
return match ($variant) {
'book' => self::BOOK,
default => self::BANNER,
};
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace App\Support\Qr;
use Carbon\Carbon;
class QrDateFormatter
{
public static function forInput(?string $value): string
{
if ($value === null || trim($value) === '') {
return '';
}
try {
return Carbon::parse($value)->format('Y-m-d');
} catch (\Throwable) {
return '';
}
}
public static function forDisplay(?string $value): string
{
if ($value === null || trim($value) === '') {
return '';
}
$value = trim($value);
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) {
try {
return Carbon::createFromFormat('Y-m-d', $value)->format('D, j M Y');
} catch (\Throwable) {
return $value;
}
}
return $value;
}
public static function normalize(?string $value, int $maxLength = 60): string
{
if ($value === null || trim($value) === '') {
return '';
}
$value = trim($value);
try {
return Carbon::parse($value)->format('Y-m-d');
} catch (\Throwable) {
return mb_substr($value, 0, $maxLength);
}
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace App\Support\Qr;
class QrFrameStyleCatalog
{
/** @return array<string, array{label: string, description: string, border_px: int, mode: string, cta?: string, visible?: bool}> */
public static function all(): array
{
return [
'none' => [
'label' => 'None',
'description' => 'Code only',
'border_px' => 0,
'mode' => 'none',
],
'thin' => [
'label' => 'Border',
'description' => 'Simple white sticker edge',
'border_px' => 8,
'mode' => 'border',
],
'bold' => [
'label' => 'Wide border',
'description' => 'Legacy wide border',
'border_px' => 16,
'mode' => 'border',
'visible' => false,
],
'scan_me' => [
'label' => 'Scan me',
'description' => 'CTA sticker below code',
'border_px' => 14,
'mode' => 'label',
'cta' => 'SCAN ME',
],
'tap_to_scan' => [
'label' => 'Tap to scan',
'description' => 'Rounded CTA sticker',
'border_px' => 14,
'mode' => 'pill',
'cta' => 'TAP TO SCAN',
],
];
}
/** @return array<string, array{label: string, description: string, border_px: int, mode: string, cta?: string, visible?: bool}> */
public static function visible(): array
{
return array_filter(self::all(), fn (array $style): bool => ($style['visible'] ?? true) === true);
}
/** @return list<string> */
public static function keys(): array
{
return array_keys(self::all());
}
public static function isValid(string $style): bool
{
return isset(self::all()[$style]);
}
}
+93
View File
@@ -0,0 +1,93 @@
<?php
namespace App\Support\Qr;
class QrModuleStyleCatalog
{
/** @return array<string, array{label: string, description: string, circular: bool, circle_radius: float, connect_paths: bool, scan_risk: string, visible?: bool}> */
public static function all(): array
{
return [
'square' => [
'label' => 'Standard',
'description' => 'Traditional square QR modules',
'circular' => false,
'circle_radius' => 0.4,
'connect_paths' => false,
'scan_risk' => 'low',
],
'soft' => [
'label' => 'Rounded',
'description' => 'Soft rounded modules',
'circular' => true,
'circle_radius' => 0.36,
'connect_paths' => false,
'scan_risk' => 'medium',
'visible' => false,
],
'dots' => [
'label' => 'Dots',
'description' => 'Round dot modules',
'circular' => true,
'circle_radius' => 0.45,
'connect_paths' => false,
'scan_risk' => 'medium',
],
'bubble' => [
'label' => 'Large dots',
'description' => 'Bolder round dot modules',
'circular' => true,
'circle_radius' => 0.52,
'connect_paths' => false,
'scan_risk' => 'high',
'visible' => false,
],
'fluid' => [
'label' => 'Fluid',
'description' => 'Legacy decorative style',
'circular' => true,
'circle_radius' => 0.38,
'connect_paths' => true,
'scan_risk' => 'high',
'visible' => false,
],
'bold' => [
'label' => 'Bold',
'description' => 'Legacy thick square modules',
'circular' => false,
'circle_radius' => 0.4,
'connect_paths' => false,
'scan_risk' => 'low',
'visible' => false,
],
];
}
/** @return array<string, array{label: string, description: string, circular: bool, circle_radius: float, connect_paths: bool, scan_risk: string, visible?: bool}> */
public static function visible(): array
{
return array_filter(self::all(), fn (array $style): bool => ($style['visible'] ?? true) === true);
}
/** @return list<string> */
public static function keys(): array
{
return array_keys(self::all());
}
public static function isValid(string $style): bool
{
return isset(self::all()[$style]);
}
/** @return array<string, mixed> */
public static function optionsFor(string $style): array
{
return self::all()[$style] ?? self::all()['square'];
}
public static function isDecorative(string $style): bool
{
return in_array(self::optionsFor($style)['scan_risk'], ['medium', 'high'], true);
}
}
+102
View File
@@ -0,0 +1,102 @@
<?php
namespace App\Support\Qr;
class QrScanReliability
{
/** @param array<string, mixed> $style */
public static function contrastRatio(array $style): float
{
$fg = self::relativeLuminance((string) ($style['foreground'] ?? '#000000'));
$bg = self::relativeLuminance((string) ($style['background'] ?? '#ffffff'));
$lighter = max($fg, $bg);
$darker = min($fg, $bg);
return ($lighter + 0.05) / ($darker + 0.05);
}
/**
* @param array<string, mixed> $style
* @return array{level: string, messages: list<string>}
*/
public static function assess(array $style): array
{
$style = QrStyleDefaults::merge($style);
$messages = [];
$level = 'good';
$contrast = self::contrastRatio($style);
$isClassic = self::isClassicBlackOnWhite($style);
$moduleStyle = (string) ($style['module_style'] ?? 'square');
$moduleMeta = QrModuleStyleCatalog::optionsFor($moduleStyle);
if ($moduleMeta['scan_risk'] === 'medium') {
$messages[] = 'This module style may not scan on every phone camera. Square is the safest choice.';
$level = 'fair';
}
if ($moduleMeta['scan_risk'] === 'high') {
$messages[] = 'Decorative styles like ' . $moduleMeta['label'] . ' often fail on Samsung Camera. Use square for print and signage.';
$level = 'poor';
}
if (! $isClassic) {
$messages[] = 'Colored QR codes often fail on Samsung Camera and other basic scanners. Black on white works everywhere.';
$level = $level === 'good' ? 'fair' : 'poor';
}
if ($contrast < 4.5) {
$messages[] = sprintf(
'Contrast is low (%.1f:1). Aim for at least 4.5:1 — dark foreground, white background.',
$contrast,
);
$level = 'poor';
}
if ($moduleMeta['scan_risk'] !== 'low' && ! $isClassic) {
$level = 'poor';
}
if ($messages === []) {
$messages[] = 'Black square modules on white give the best compatibility with Samsung, iPhone, and printed codes.';
}
return ['level' => $level, 'messages' => $messages];
}
/** @param array<string, mixed> $style */
public static function isClassicBlackOnWhite(array $style): bool
{
$fg = strtolower(ltrim((string) ($style['foreground'] ?? ''), '#'));
$bg = strtolower(ltrim((string) ($style['background'] ?? ''), '#'));
$fg = strlen($fg) === 3 ? $fg[0] . $fg[0] . $fg[1] . $fg[1] . $fg[2] . $fg[2] : $fg;
$bg = strlen($bg) === 3 ? $bg[0] . $bg[0] . $bg[1] . $bg[1] . $bg[2] . $bg[2] : $bg;
return in_array($fg, ['000000', '0f172a', '111111', '1a1a1a'], true)
&& in_array($bg, ['ffffff', 'fff'], true);
}
private static function relativeLuminance(string $hex): float
{
$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;
}
$channels = [];
foreach ([0, 2, 4] as $offset) {
$value = hexdec(substr($hex, $offset, 2)) / 255;
$channels[] = $value <= 0.03928
? $value / 12.92
: (($value + 0.055) / 1.055) ** 2.4;
}
return (0.2126 * $channels[0]) + (0.7152 * $channels[1]) + (0.0722 * $channels[2]);
}
}
+114
View File
@@ -0,0 +1,114 @@
<?php
namespace App\Support\Qr;
class QrStyleDefaults
{
/** @return array<string, mixed> */
public static function defaults(): array
{
return [
'foreground' => '#000000',
'background' => '#ffffff',
'error_correction' => 'M',
'margin' => 4,
'module_style' => 'square',
'finder_outer' => 'square',
'finder_inner' => 'square',
'frame_style' => 'none',
'frame_text' => '',
'frame_color' => '#000000',
'scale' => 8,
'logo_path' => null,
'gradient_type' => 'none',
'gradient_color1' => '#000000',
'gradient_color2' => '#7c3aed',
'gradient_rotation' => 45,
'logo_size' => 0.3,
'logo_margin' => 5,
'logo_white_bg' => false,
'logo_shape' => 'none',
];
}
/** @param array<string, mixed>|null $style */
public static function merge(?array $style): array
{
$merged = array_merge(self::defaults(), $style ?? []);
if (! in_array($merged['error_correction'], ['L', 'M', 'Q', 'H'], true)) {
$merged['error_correction'] = 'M';
}
if (! in_array($merged['module_style'], QrModuleStyleCatalog::keys(), true)) {
$merged['module_style'] = 'square';
}
if (! QrCornerStyleCatalog::isValidOuter((string) ($merged['finder_outer'] ?? 'square'))) {
$merged['finder_outer'] = 'square';
}
if (! QrCornerStyleCatalog::isValidInner((string) ($merged['finder_inner'] ?? 'square'))) {
$merged['finder_inner'] = 'square';
}
if (! QrFrameStyleCatalog::isValid((string) ($merged['frame_style'] ?? 'none'))) {
$merged['frame_style'] = 'none';
}
$merged['frame_text'] = substr(trim((string) ($merged['frame_text'] ?? '')), 0, 100);
$frameColor = trim((string) ($merged['frame_color'] ?? '#000000'));
$merged['frame_color'] = preg_match('/^#[0-9a-fA-F]{6}$/', $frameColor) ? $frameColor : '#000000';
if (! in_array($merged['gradient_type'], ['none', 'linear', 'radial'], true)) {
$merged['gradient_type'] = 'none';
}
$merged['logo_size'] = max(0.1, min(0.4, (float) ($merged['logo_size'] ?? 0.3)));
$merged['logo_margin'] = max(0, min(15, (int) ($merged['logo_margin'] ?? 5)));
if (! in_array($merged['logo_shape'], ['none', 'rounded', 'circle'], true)) {
$merged['logo_shape'] = 'none';
}
$merged['logo_white_bg'] = (bool) ($merged['logo_white_bg'] ?? false);
if ($merged['module_style'] === 'bold') {
$merged['scale'] = min(16, (int) $merged['scale'] + 2);
}
$merged['margin'] = max(0, min(10, (int) $merged['margin']));
$merged['scale'] = max(4, min(16, (int) $merged['scale']));
return $merged;
}
/** @param array<string, mixed>|null $style */
public static function mergeForRender(?array $style): array
{
return QrStyleNormalizer::normalize(self::merge($style));
}
/** @param array<string, mixed> $style */
public static function recommendedEcc(array $style): string
{
$ecc = (string) ($style['error_correction'] ?? 'M');
$order = ['L' => 0, 'M' => 1, 'Q' => 2, 'H' => 3];
$minimum = 'M';
$moduleStyle = (string) ($style['module_style'] ?? 'square');
if (in_array($moduleStyle, ['bubble', 'fluid'], true)) {
$minimum = 'H';
} elseif (QrModuleStyleCatalog::isDecorative($moduleStyle)) {
$minimum = 'Q';
}
if (! empty($style['logo_path'])) {
$minimum = 'H';
}
if (QrScanReliability::contrastRatio($style) < 4.5) {
$minimum = 'H';
}
return ($order[$minimum] ?? 1) > ($order[$ecc] ?? 1) ? $minimum : $ecc;
}
}
+138
View File
@@ -0,0 +1,138 @@
<?php
namespace App\Support\Qr;
class QrStyleNormalizer
{
/**
* Silently tune styles so more phone cameras (including Samsung) can read the code.
*
* @param array<string, mixed> $style
* @return array<string, mixed>
*/
public static function normalize(array $style): array
{
$style = self::ensureContrast($style);
$style = self::ensureQuietZone($style);
$style = self::ensureRenderScale($style);
$style['error_correction'] = QrStyleDefaults::recommendedEcc($style);
return $style;
}
/** @param array<string, mixed> $style */
private static function ensureContrast(array $style): array
{
$target = 4.5;
$attempts = 0;
while (QrScanReliability::contrastRatio($style) < $target && $attempts < 16) {
$fg = self::hexToRgb((string) $style['foreground']);
$bg = self::hexToRgb((string) $style['background']);
if (self::relativeLuminance($fg) > self::relativeLuminance($bg)) {
$style['foreground'] = self::rgbToHex(self::darkenToward($fg, 0.18));
} else {
$style['foreground'] = self::rgbToHex(self::darkenToward($fg, 0.15));
$style['background'] = self::rgbToHex(self::lightenToward($bg, 0.15));
}
$attempts++;
}
if (QrScanReliability::contrastRatio($style) < $target) {
$style['foreground'] = '#000000';
$style['background'] = '#ffffff';
}
return $style;
}
/** @param array<string, mixed> $style */
private static function ensureQuietZone(array $style): array
{
$margin = (int) ($style['margin'] ?? 4);
$moduleStyle = (string) ($style['module_style'] ?? 'square');
if (QrModuleStyleCatalog::isDecorative($moduleStyle)) {
$margin = max($margin, 5);
}
$style['margin'] = max(4, min(10, $margin));
return $style;
}
/** @param array<string, mixed> $style */
private static function ensureRenderScale(array $style): array
{
$scale = (int) ($style['scale'] ?? 8);
$module = QrModuleStyleCatalog::optionsFor((string) ($style['module_style'] ?? 'square'));
if ($module['circular'] ?? false) {
$scale = max($scale, 10);
}
if (! empty($style['logo_path'])) {
$scale = max($scale, 10);
}
$style['scale'] = max(6, min(16, $scale));
return $style;
}
/** @return array{0: int, 1: int, 2: int} */
private static 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))];
}
/** @param array{0: int, 1: int, 2: int} $rgb */
private static function rgbToHex(array $rgb): string
{
return sprintf('#%02x%02x%02x', $rgb[0], $rgb[1], $rgb[2]);
}
/** @param array{0: int, 1: int, 2: int} $rgb */
private static function darkenToward(array $rgb, float $amount): array
{
return [
(int) max(0, $rgb[0] * (1 - $amount)),
(int) max(0, $rgb[1] * (1 - $amount)),
(int) max(0, $rgb[2] * (1 - $amount)),
];
}
/** @param array{0: int, 1: int, 2: int} $rgb */
private static function lightenToward(array $rgb, float $amount): array
{
return [
(int) min(255, $rgb[0] + ((255 - $rgb[0]) * $amount)),
(int) min(255, $rgb[1] + ((255 - $rgb[1]) * $amount)),
(int) min(255, $rgb[2] + ((255 - $rgb[2]) * $amount)),
];
}
/** @param array{0: int, 1: int, 2: int} $rgb */
private static function relativeLuminance(array $rgb): float
{
$channels = [];
foreach ($rgb as $value) {
$v = $value / 255;
$channels[] = $v <= 0.03928 ? $v / 12.92 : (($v + 0.055) / 1.055) ** 2.4;
}
return (0.2126 * $channels[0]) + (0.7152 * $channels[1]) + (0.0722 * $channels[2]);
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace App\Support\Qr;
use App\Models\QrCode;
/**
* Ladill Merchant shop, menu & booking storefronts.
*/
class QrTypeCatalog
{
/** @return list<string> */
public static function storefrontTypes(): array
{
return [QrCode::TYPE_SHOP, QrCode::TYPE_MENU, QrCode::TYPE_BOOKING];
}
/** @return array<string, array{label: string, description: string, category: string, icon: string}> */
public static function all(): array
{
return [
QrCode::TYPE_SHOP => [
'label' => 'Shop',
'description' => 'Product catalogue with ordering',
'category' => 'commerce',
'icon' => 'shop.svg',
],
QrCode::TYPE_MENU => [
'label' => 'Menu',
'description' => 'Restaurant or cafe menu with ordering',
'category' => 'commerce',
'icon' => 'menu-food.svg',
],
QrCode::TYPE_BOOKING => [
'label' => 'Booking',
'description' => 'Appointments & reservations with online payment',
'category' => 'commerce',
'icon' => 'books.svg',
],
];
}
/** @return list<string> */
public static function keys(): array
{
return array_keys(self::all());
}
public static function label(string $type): string
{
return self::all()[$type]['label'] ?? ucfirst(str_replace('_', ' ', $type));
}
public static function isValid(string $type): bool
{
return isset(self::all()[$type]);
}
public static function iconUrl(string $icon): string
{
$path = public_path('images/qr-icons/'.$icon);
$url = '/images/qr-icons/'.$icon;
if (is_file($path)) {
return $url.'?v='.filemtime($path);
}
return $url;
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace App\Support\Qr;
class QrWifiPayload
{
/**
* Build a WiFi QR payload (ZXing MECARD format) for one-tap network join on scan.
*
* @param array<string, mixed> $content
*/
public static function encode(array $content): string
{
$encryption = strtoupper(trim((string) ($content['encryption'] ?? 'WPA')));
$auth = $encryption === 'NOPASS' ? 'nopass' : $encryption;
if (! in_array($auth, ['WPA', 'WEP', 'nopass'], true)) {
$auth = 'WPA';
}
$ssid = self::escape(trim((string) ($content['ssid'] ?? '')));
$payload = 'WIFI:T:' . $auth . ';S:' . $ssid;
if ($auth !== 'nopass') {
$password = trim((string) ($content['password'] ?? ''));
if ($password !== '') {
$payload .= ';P:' . self::escape($password);
}
}
if (filter_var($content['hidden'] ?? false, FILTER_VALIDATE_BOOL)) {
$payload .= ';H:true';
}
return $payload . ';;';
}
private static function escape(string $value): string
{
return str_replace(
['\\', ';', ',', '"', ':'],
['\\\\', '\\;', '\\,', '\\"', '\\:'],
$value
);
}
}
+122
View File
@@ -0,0 +1,122 @@
<?php
namespace App\Support;
use App\Models\DomainOrder;
use App\Models\RcServiceOrder;
use Illuminate\Support\Collection;
use RuntimeException;
/**
* ResellerClub is legacy-only for new sales. Renewals and management remain available
* for customers with existing ResellerClub services.
*/
final class ResellerClubLegacy
{
public static function integrationEnabled(): bool
{
return (bool) config('hosting.legacy.rc_enabled', true);
}
public static function newOrdersEnabled(): bool
{
return self::integrationEnabled()
&& (bool) config('hosting.legacy.rc_new_orders_enabled', false);
}
public static function renewalsEnabled(): bool
{
return self::integrationEnabled()
&& (bool) config('hosting.legacy.rc_renewals_enabled', true);
}
public static function isRenewalRcServiceOrder(RcServiceOrder $order): bool
{
if ($order->order_type === RcServiceOrder::TYPE_RENEWAL) {
return true;
}
return (bool) data_get($order->meta, 'is_renewal', false);
}
public static function isRenewalDomainOrder(DomainOrder $order): bool
{
return $order->order_type === DomainOrder::TYPE_RENEWAL;
}
public static function canAutomateFulfillment(RcServiceOrder $order): bool
{
if (! self::integrationEnabled()) {
return false;
}
if (self::newOrdersEnabled()) {
return true;
}
return self::renewalsEnabled() && self::isRenewalRcServiceOrder($order);
}
/**
* @param Collection<int, RcServiceOrder>|iterable<int, RcServiceOrder> $items
*/
public static function assertCartCheckoutAllowed(iterable $items): void
{
$items = $items instanceof Collection ? $items : collect($items);
if ($items->isEmpty()) {
throw new RuntimeException('Your cart is empty.');
}
if (self::newOrdersEnabled()) {
return;
}
if (! self::renewalsEnabled()) {
throw new RuntimeException(self::renewalsDisabledMessage());
}
if ($items->contains(fn (RcServiceOrder $item) => ! self::isRenewalRcServiceOrder($item))) {
throw new RuntimeException(
'ResellerClub checkout is limited to renewals for existing services. '
.'New product purchases use our current domain and hosting products instead.'
);
}
}
public static function assertNewSaleAllowed(): void
{
if (self::newOrdersEnabled()) {
return;
}
throw new RuntimeException(
'ResellerClub is no longer available for new product purchases. '
.'Use our domain and hosting products instead, or renew an existing ResellerClub service from your dashboard.'
);
}
public static function assertRenewalsAllowed(): void
{
if (! self::renewalsEnabled()) {
throw new RuntimeException(self::renewalsDisabledMessage());
}
}
/** @deprecated Use assertNewSaleAllowed() */
public static function assertNewOrdersAllowed(): void
{
self::assertNewSaleAllowed();
}
public static function renewalsDisabledMessage(): string
{
return 'ResellerClub renewals are not available at this time. Please contact support if you need assistance.';
}
public static function fulfillmentDisabledMessage(): string
{
return 'Automated ResellerClub fulfillment for new purchases is no longer available. '
.'Open a support ticket if you need help with a legacy order.';
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
use App\Models\User;
if (! function_exists('ladill_account')) {
/**
* The account the current request acts within the owner User. Defaults to
* the authenticated user; a team member who switched accounts gets the owner.
* Set by the SetActingAccount middleware.
*/
function ladill_account(): ?User
{
$request = request();
return $request->attributes->get('actingAccount') ?? $request->user();
}
}
if (! function_exists('ladill_domains_url')) {
function ladill_domains_url(string $path = ''): string
{
return 'https://'.config('app.domains_domain').($path !== '' ? '/'.ltrim($path, '/') : '');
}
}
if (! function_exists('ladill_account_url')) {
function ladill_account_url(string $path = ''): string
{
return 'https://'.config('app.account_domain').($path !== '' ? '/'.ltrim($path, '/') : '');
}
}
if (! function_exists('ladill_servers_url')) {
function ladill_servers_url(string $path = ''): string
{
return 'https://'.config('app.servers_domain').($path !== '' ? '/'.ltrim($path, '/') : '');
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace App\View\Components;
use Illuminate\View\Component;
use Illuminate\View\View;
class UserLayout extends Component
{
public function render(): View
{
return view('layouts.user');
}
}
Executable
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env php
<?php
use Illuminate\Foundation\Application;
use Symfony\Component\Console\Input\ArgvInput;
define('LARAVEL_START', microtime(true));
// Register the Composer autoloader...
require __DIR__.'/vendor/autoload.php';
// Bootstrap Laravel and handle the command...
/** @var Application $app */
$app = require_once __DIR__.'/bootstrap/app.php';
$status = $app->handleCommand(new ArgvInput);
exit($status);
+25
View File
@@ -0,0 +1,25 @@
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->redirectGuestsTo(fn (Request $request) => route('sso.connect', [
'redirect' => $request->fullUrl(),
]));
$middleware->web(append: [
\App\Http\Middleware\SetActingAccount::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
//
})->create();
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+7
View File
@@ -0,0 +1,7 @@
<?php
use App\Providers\AppServiceProvider;
return [
AppServiceProvider::class,
];
+95
View File
@@ -0,0 +1,95 @@
{
"$schema": "https://getcomposer.org/schema.json",
"name": "ladill/merchant",
"type": "project",
"description": "Ladill Merchant — online storefronts and order payouts at merchant.ladill.com",
"keywords": [
"laravel",
"framework"
],
"license": "MIT",
"require": {
"php": "^8.2",
"chillerlan/php-qrcode": "^5.0",
"laravel/framework": "^12.0",
"laravel/sanctum": "^4.3",
"laravel/tinker": "^2.10.1",
"phpseclib/phpseclib": "^3.0"
},
"require-dev": {
"fakerphp/faker": "^1.23",
"laravel/pail": "^1.2.2",
"laravel/pint": "^1.24",
"laravel/sail": "^1.41",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"phpunit/phpunit": "^11.5.50"
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
},
"files": [
"app/Support/helpers.php"
]
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"setup": [
"composer install",
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
"@php artisan key:generate",
"@php artisan migrate --force",
"npm install",
"npm run build"
],
"dev": [
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
],
"test": [
"@php artisan config:clear --ansi",
"@php artisan test"
],
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"@php artisan key:generate --ansi",
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
"@php artisan migrate --graceful --ansi"
],
"pre-package-uninstall": [
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
]
},
"extra": {
"laravel": {
"dont-discover": []
}
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true,
"php-http/discovery": true
}
},
"minimum-stability": "stable",
"prefer-stable": true
}
Generated
+8858
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -0,0 +1,10 @@
<?php
return [
// Afia — in-app AI assistant scoped to Ladill Merchant.
'product' => env('AFIA_PRODUCT', 'mini'),
'enabled' => (bool) env('AFIA_ENABLED', true),
'provider' => env('AFIA_PROVIDER', 'openai'), // openai | anthropic
'model' => env('AFIA_MODEL', 'gpt-4o-mini'),
'api_key' => env('AFIA_API_KEY'),
];
+134
View File
@@ -0,0 +1,134 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application Name
|--------------------------------------------------------------------------
|
| This value is the name of your application, which will be used when the
| framework needs to place the application's name in a notification or
| other UI elements where an application name needs to be displayed.
|
*/
'name' => env('APP_NAME', 'Ladill Merchant'),
/*
|--------------------------------------------------------------------------
| Application Environment
|--------------------------------------------------------------------------
|
| This value determines the "environment" your application is currently
| running in. This may determine how you prefer to configure various
| services the application utilizes. Set this in your ".env" file.
|
*/
'env' => env('APP_ENV', 'production'),
/*
|--------------------------------------------------------------------------
| Application Debug Mode
|--------------------------------------------------------------------------
|
| When your application is in debug mode, detailed error messages with
| stack traces will be shown on every error that occurs within your
| application. If disabled, a simple generic error page is shown.
|
*/
'debug' => (bool) env('APP_DEBUG', false),
/*
|--------------------------------------------------------------------------
| Application URL
|--------------------------------------------------------------------------
|
| This URL is used by the console to properly generate URLs when using
| the Artisan command line tool. You should set this to the root of
| the application so that it's available within Artisan commands.
|
*/
'url' => env('APP_URL', 'http://localhost'),
/*
|--------------------------------------------------------------------------
| Application Timezone
|--------------------------------------------------------------------------
|
| Here you may specify the default timezone for your application, which
| will be used by the PHP date and date-time functions. The timezone
| is set to "UTC" by default as it is suitable for most use cases.
|
*/
'timezone' => 'UTC',
/*
|--------------------------------------------------------------------------
| Application Locale Configuration
|--------------------------------------------------------------------------
|
| The application locale determines the default locale that will be used
| by Laravel's translation / localization methods. This option can be
| set to any locale for which you plan to have translation strings.
|
*/
'locale' => env('APP_LOCALE', 'en'),
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
/*
|--------------------------------------------------------------------------
| Encryption Key
|--------------------------------------------------------------------------
|
| This key is utilized by Laravel's encryption services and should be set
| to a random, 32 character string to ensure that all encrypted values
| are secure. You should do this prior to deploying the application.
|
*/
'cipher' => 'AES-256-CBC',
'key' => env('APP_KEY'),
'previous_keys' => [
...array_filter(
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
),
],
/*
|--------------------------------------------------------------------------
| Maintenance Mode Driver
|--------------------------------------------------------------------------
|
| These configuration options determine the driver used to determine and
| manage Laravel's "maintenance mode" status. The "cache" driver will
| allow maintenance mode to be controlled across multiple machines.
|
| Supported drivers: "file", "cache"
|
*/
'maintenance' => [
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
'store' => env('APP_MAINTENANCE_STORE', 'database'),
],
// Platform identity surfaces (Zoho One model). Ladill Merchant (merchant.ladill.com).
'platform_domain' => env('PLATFORM_DOMAIN', parse_url((string) env('PLATFORM_URL', 'https://ladill.com'), PHP_URL_HOST) ?: 'ladill.com'),
'auth_domain' => env('AUTH_DOMAIN', 'auth.'.(parse_url((string) env('PLATFORM_URL', 'https://ladill.com'), PHP_URL_HOST) ?: 'ladill.com')),
'account_domain' => env('ACCOUNT_DOMAIN', 'account.'.(parse_url((string) env('PLATFORM_URL', 'https://ladill.com'), PHP_URL_HOST) ?: 'ladill.com')),
'give_domain' => env('MERCHANT_DOMAIN', parse_url((string) env('APP_URL', 'https://merchant.ladill.com'), PHP_URL_HOST) ?: 'merchant.ladill.com'),
'servers_domain' => env('SERVERS_DOMAIN', 'servers.'.(parse_url((string) env('PLATFORM_URL', 'https://ladill.com'), PHP_URL_HOST) ?: 'ladill.com')),
'domains_domain' => env('DOMAINS_DOMAIN', 'domains.'.(parse_url((string) env('PLATFORM_URL', 'https://ladill.com'), PHP_URL_HOST) ?: 'ladill.com')),
];
+117
View File
@@ -0,0 +1,117 @@
<?php
use App\Models\User;
return [
/*
|--------------------------------------------------------------------------
| Authentication Defaults
|--------------------------------------------------------------------------
|
| This option defines the default authentication "guard" and password
| reset "broker" for your application. You may change these values
| as required, but they're a perfect start for most applications.
|
*/
'defaults' => [
'guard' => env('AUTH_GUARD', 'web'),
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
],
/*
|--------------------------------------------------------------------------
| Authentication Guards
|--------------------------------------------------------------------------
|
| Next, you may define every authentication guard for your application.
| Of course, a great default configuration has been defined for you
| which utilizes session storage plus the Eloquent user provider.
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| Supported: "session"
|
*/
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
],
/*
|--------------------------------------------------------------------------
| User Providers
|--------------------------------------------------------------------------
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| If you have multiple user tables or models you may configure multiple
| providers to represent the model / table. These providers may then
| be assigned to any extra authentication guards you have defined.
|
| Supported: "database", "eloquent"
|
*/
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => env('AUTH_MODEL', User::class),
],
// 'users' => [
// 'driver' => 'database',
// 'table' => 'users',
// ],
],
/*
|--------------------------------------------------------------------------
| Resetting Passwords
|--------------------------------------------------------------------------
|
| These configuration options specify the behavior of Laravel's password
| reset functionality, including the table utilized for token storage
| and the user provider that is invoked to actually retrieve users.
|
| The expiry time is the number of minutes that each reset token will be
| considered valid. This security feature keeps tokens short-lived so
| they have less time to be guessed. You may change this as needed.
|
| The throttle setting is the number of seconds a user must wait before
| generating more password reset tokens. This prevents the user from
| quickly generating a very large amount of password reset tokens.
|
*/
'passwords' => [
'users' => [
'provider' => 'users',
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
'expire' => 60,
'throttle' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Password Confirmation Timeout
|--------------------------------------------------------------------------
|
| Here you may define the number of seconds before a password confirmation
| window expires and users are asked to re-enter their password via the
| confirmation screen. By default, the timeout lasts for three hours.
|
*/
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
];
+8
View File
@@ -0,0 +1,8 @@
<?php
return [
'api_url' => env('BILLING_API_URL', 'https://ladill.com/api/billing'),
'api_key' => env('BILLING_API_KEY_MERCHANT'),
'service' => 'merchant',
'platform_settings_connection' => env('BILLING_PLATFORM_SETTINGS_CONNECTION', 'platform'),
];
+117
View File
@@ -0,0 +1,117 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Cache Store
|--------------------------------------------------------------------------
|
| This option controls the default cache store that will be used by the
| framework. This connection is utilized if another isn't explicitly
| specified when running a cache operation inside the application.
|
*/
'default' => env('CACHE_STORE', 'database'),
/*
|--------------------------------------------------------------------------
| Cache Stores
|--------------------------------------------------------------------------
|
| Here you may define all of the cache "stores" for your application as
| well as their drivers. You may even define multiple stores for the
| same cache driver to group types of items stored in your caches.
|
| Supported drivers: "array", "database", "file", "memcached",
| "redis", "dynamodb", "octane",
| "failover", "null"
|
*/
'stores' => [
'array' => [
'driver' => 'array',
'serialize' => false,
],
'database' => [
'driver' => 'database',
'connection' => env('DB_CACHE_CONNECTION'),
'table' => env('DB_CACHE_TABLE', 'cache'),
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
],
'file' => [
'driver' => 'file',
'path' => storage_path('framework/cache/data'),
'lock_path' => storage_path('framework/cache/data'),
],
'memcached' => [
'driver' => 'memcached',
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
'sasl' => [
env('MEMCACHED_USERNAME'),
env('MEMCACHED_PASSWORD'),
],
'options' => [
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
],
'servers' => [
[
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
'port' => env('MEMCACHED_PORT', 11211),
'weight' => 100,
],
],
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
],
'dynamodb' => [
'driver' => 'dynamodb',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
'endpoint' => env('DYNAMODB_ENDPOINT'),
],
'octane' => [
'driver' => 'octane',
],
'failover' => [
'driver' => 'failover',
'stores' => [
'database',
'array',
],
],
],
/*
|--------------------------------------------------------------------------
| Cache Key Prefix
|--------------------------------------------------------------------------
|
| When utilizing the APC, database, memcached, Redis, and DynamoDB cache
| stores, there might be other applications using the same cache. For
| that reason, you may prefix every cache key to avoid collisions.
|
*/
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
];
+204
View File
@@ -0,0 +1,204 @@
<?php
use Illuminate\Support\Str;
use Pdo\Mysql;
return [
/*
|--------------------------------------------------------------------------
| Default Database Connection Name
|--------------------------------------------------------------------------
|
| Here you may specify which of the database connections below you wish
| to use as your default connection for database operations. This is
| the connection which will be utilized unless another connection
| is explicitly specified when you execute a query / statement.
|
*/
'default' => env('DB_CONNECTION', 'sqlite'),
/*
|--------------------------------------------------------------------------
| Database Connections
|--------------------------------------------------------------------------
|
| Below are all of the database connections defined for your application.
| An example configuration is provided for each database system which
| is supported by Laravel. You're free to add / remove connections.
|
*/
'connections' => [
'sqlite' => [
'driver' => 'sqlite',
'url' => env('DB_URL'),
'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
'busy_timeout' => null,
'journal_mode' => null,
'synchronous' => null,
'transaction_mode' => 'DEFERRED',
],
'mysql' => [
'driver' => 'mysql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
(PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
// Read-only access to platform admin settings (Paystack keys, etc.).
'platform' => [
'driver' => 'mysql',
'host' => env('PLATFORM_DB_HOST', env('DB_HOST', '127.0.0.1')),
'port' => env('PLATFORM_DB_PORT', env('DB_PORT', '3306')),
'database' => env('PLATFORM_DB_DATABASE', 'ladilldb'),
'username' => env('PLATFORM_DB_USERNAME', env('DB_USERNAME', 'root')),
'password' => env('PLATFORM_DB_PASSWORD', env('DB_PASSWORD', '')),
'unix_socket' => env('PLATFORM_DB_SOCKET', env('DB_SOCKET', '')),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
(PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'mariadb' => [
'driver' => 'mariadb',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
(PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'pgsql' => [
'driver' => 'pgsql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => env('DB_SSLMODE', 'prefer'),
],
'sqlsrv' => [
'driver' => 'sqlsrv',
'url' => env('DB_URL'),
'host' => env('DB_HOST', 'localhost'),
'port' => env('DB_PORT', '1433'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
],
],
/*
|--------------------------------------------------------------------------
| Migration Repository Table
|--------------------------------------------------------------------------
|
| This table keeps track of all the migrations that have already run for
| your application. Using this information, we can determine which of
| the migrations on disk haven't actually been run on the database.
|
*/
'migrations' => [
'table' => 'migrations',
'update_date_on_publish' => true,
],
/*
|--------------------------------------------------------------------------
| Redis Databases
|--------------------------------------------------------------------------
|
| Redis is an open source, fast, and advanced key-value store that also
| provides a richer body of commands than a typical key-value system
| such as Memcached. You may define your connection settings here.
|
*/
'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),
'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'),
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
'persistent' => env('REDIS_PERSISTENT', false),
],
'default' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_DB', '0'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
'cache' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_CACHE_DB', '1'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
],
];
+6
View File
@@ -0,0 +1,6 @@
<?php
return [
'api_url' => env('DOMAIN_API_URL', 'https://ladill.com/api/domains'),
'api_key' => env('DOMAIN_API_KEY_HOSTING'),
];
+21
View File
@@ -0,0 +1,21 @@
<?php
return [
/*
| Email product plans. Mailboxes are priced purely by the storage tier chosen
| at creation (and changeable later via Upgrade). The 1 GB tier is FREE,
| forever, for every mailbox; larger tiers are billed monthly from the one
| Ladill wallet (§4-A). Money is in minor units (pesewas).
*/
'currency' => env('EMAIL_CURRENCY', 'GHS'),
'default_quota_mb' => (int) env('EMAIL_DEFAULT_QUOTA_MB', 1024), // new mailboxes start on the free 1 GB tier
// Storage tiers: quota (MB) → monthly price (minor units). 1 GB is free.
'quota_tiers' => [
['mb' => 1024, 'price_minor' => 0], // 1 GB — Free forever
['mb' => 5120, 'price_minor' => 1000], // 5 GB — GHS 10
['mb' => 10240, 'price_minor' => 2000], // 10 GB — GHS 20
['mb' => 25600, 'price_minor' => 3000], // 25 GB — GHS 30
['mb' => 51200, 'price_minor' => 6000], // 50 GB — GHS 60
],
];
+7
View File
@@ -0,0 +1,7 @@
<?php
return [
// Consumer config for the shared Email-Domain verification API (§4-D).
'api_url' => env('EMAILDOMAIN_API_URL', 'https://ladill.com/api/email-domains'),
'api_key' => env('EMAILDOMAIN_API_KEY_EMAIL'),
];

Some files were not shown because too many files have changed in this diff Show More