Add Ladill POS v1 — register, Pay checkout, and commerce links.
Deploy Ladill Mini / deploy (push) Successful in 23s
Deploy Ladill Mini / deploy (push) Successful in 23s
Staff-facing counter register at pos.ladill.com with catalog cart, cash and MoMo/card checkout via Ladill Pay, CRM timeline/import, invoice prefill, and Merchant catalog import. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
APP_NAME="Ladill POS"
|
||||||
|
APP_ENV=production
|
||||||
|
APP_KEY=
|
||||||
|
APP_DEBUG=false
|
||||||
|
APP_URL=https://pos.ladill.com
|
||||||
|
|
||||||
|
PLATFORM_URL=https://ladill.com
|
||||||
|
PLATFORM_DOMAIN=ladill.com
|
||||||
|
AUTH_DOMAIN=auth.ladill.com
|
||||||
|
ACCOUNT_DOMAIN=account.ladill.com
|
||||||
|
POS_DOMAIN=pos.ladill.com
|
||||||
|
|
||||||
|
DB_CONNECTION=mysql
|
||||||
|
DB_HOST=127.0.0.1
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_DATABASE=ladill_pos
|
||||||
|
DB_USERNAME=ladill_pos
|
||||||
|
DB_PASSWORD=
|
||||||
|
|
||||||
|
PLATFORM_DB_HOST=127.0.0.1
|
||||||
|
PLATFORM_DB_PORT=3306
|
||||||
|
PLATFORM_DB_DATABASE=ladilldb
|
||||||
|
PLATFORM_DB_USERNAME=ladill_pos
|
||||||
|
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_POS=
|
||||||
|
|
||||||
|
PAY_API_URL=https://ladill.com/api/pay
|
||||||
|
PAY_API_KEY_POS=
|
||||||
|
|
||||||
|
IDENTITY_API_URL=https://ladill.com/api
|
||||||
|
IDENTITY_API_KEY_POS=
|
||||||
|
|
||||||
|
POS_DEFAULT_CURRENCY=GHS
|
||||||
|
POS_MERCHANT_IMPORT_ENABLED=true
|
||||||
|
|
||||||
|
CRM_API_URL=https://crm.ladill.com/api
|
||||||
|
CRM_API_KEY_POS=
|
||||||
|
|
||||||
|
MERCHANT_DB_HOST=127.0.0.1
|
||||||
|
MERCHANT_DB_PORT=3306
|
||||||
|
MERCHANT_DB_DATABASE=ladill_merchant
|
||||||
|
MERCHANT_DB_USERNAME=ladill_pos
|
||||||
|
MERCHANT_DB_PASSWORD=
|
||||||
|
|
||||||
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
name: Deploy Ladill Mini
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-mini
|
||||||
|
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-mini-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz
|
||||||
|
WORKSPACE: /tmp/${{ gitea.repository_owner }}-mini-${{ gitea.run_id }}-${{ gitea.run_attempt }}
|
||||||
|
LADILL_APP_ROOT: /var/www/ladill-mini
|
||||||
|
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-mini-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 mini.ladill.com vhost manually"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if sudo -n bash "$NGINX_SCRIPT" mini --app /var/www/ladill-mini/current; then
|
||||||
|
echo "nginx vhost updated for mini.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"
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
*.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
|
||||||
|
|
||||||
|
# Native mobile apps — kept locally, not tracked in this repo
|
||||||
|
/apps
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Ladill POS — deploy runbook
|
||||||
|
|
||||||
|
Standalone **in-store register** at `pos.ladill.com`. Charges via Ladill Pay (`source_service: pos`, `fee_tier: sales`).
|
||||||
|
|
||||||
|
## Platform wiring
|
||||||
|
|
||||||
|
1. OIDC client: `php artisan passport:client` on monolith — redirect `https://pos.ladill.com/sso/callback`
|
||||||
|
2. Env keys on monolith: `BILLING_API_KEY_POS`, `PAY_API_KEY_POS`, `IDENTITY_API_KEY_POS`
|
||||||
|
3. Env on POS app: matching consumer keys + `LADILL_SSO_CLIENT_*`
|
||||||
|
4. Database: `ladill_pos` (MySQL) — migrations via deploy script
|
||||||
|
5. Marketing: `php scripts/ensure-app-entry-routes.php` (includes `ladill-pos`)
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
Same release model as Mini/Merchant — see `deploy/deploy.sh`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan migrate --force
|
||||||
|
php artisan config:cache && php artisan route:cache && php artisan view:cache
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commerce links
|
||||||
|
|
||||||
|
- **CRM** — `CRM_API_KEY_POS` on CRM + `CRM_API_URL` / `CRM_API_KEY_POS` on POS; timeline on paid sales; product import from CRM API
|
||||||
|
- **Invoice** — "Create invoice" on paid sale receipt (`kind: pos_sale` prefill)
|
||||||
|
- **Merchant** — `MERCHANT_DB_*` read-only connection; import storefront catalog into `pos_products`
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Ladill POS
|
||||||
|
|
||||||
|
In-store register at **pos.ladill.com** — product grid, cart, cash or Ladill Pay checkout.
|
||||||
|
|
||||||
|
## Features (v1)
|
||||||
|
|
||||||
|
- **Register** — catalog + quick-amount sales
|
||||||
|
- **Products** — local catalog CRUD
|
||||||
|
- **Sales** — history and receipt view
|
||||||
|
- **Ladill Pay** — MoMo/card via `source_service: pos`, `fee_tier: sales`
|
||||||
|
|
||||||
|
## Local dev
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
composer install
|
||||||
|
php artisan key:generate
|
||||||
|
touch database/database.sqlite
|
||||||
|
# set DB_CONNECTION=sqlite in .env
|
||||||
|
php artisan migrate
|
||||||
|
npm install && npm run build
|
||||||
|
php artisan serve
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
See [DEPLOY.md](DEPLOY.md).
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
- CRM timeline + product import
|
||||||
|
- Invoice receipt prefill
|
||||||
|
- Merchant catalog import
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\MiniPayment;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancels Mini payments left pending longer than MiniPayment::STALE_PENDING_HOURS.
|
||||||
|
* Scheduled hourly (routes/console.php).
|
||||||
|
*/
|
||||||
|
class CancelStalePayments extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'mini:cancel-stale-payments';
|
||||||
|
|
||||||
|
protected $description = 'Cancel Ladill Mini payments stuck pending beyond the stale window.';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$cutoff = now()->subHours(MiniPayment::STALE_PENDING_HOURS);
|
||||||
|
|
||||||
|
$count = MiniPayment::query()
|
||||||
|
->where('status', MiniPayment::STATUS_PENDING)
|
||||||
|
->where('created_at', '<', $cutoff)
|
||||||
|
->update([
|
||||||
|
'status' => MiniPayment::STATUS_CANCELED,
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->info("Canceled {$count} stale pending payment(s).");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\Mini\AutoWithdrawService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class ProcessAutoWithdrawals extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'mini:process-auto-withdrawals';
|
||||||
|
|
||||||
|
protected $description = 'Withdraw wallet balances that have reached each user\'s auto-withdraw threshold.';
|
||||||
|
|
||||||
|
public function handle(AutoWithdrawService $autoWithdraw): int
|
||||||
|
{
|
||||||
|
$count = $autoWithdraw->processAll();
|
||||||
|
$this->info("Processed {$count} auto-withdrawal(s).");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\QrTeamMember;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\Client\ConnectionException;
|
||||||
|
use Illuminate\Http\Client\Response as HttpResponse;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Native token auth for the Ladill Mini mobile app.
|
||||||
|
*
|
||||||
|
* Login and registration proxy to the central Ladill identity API
|
||||||
|
* (auth.ladill.com /api/identity/auth/*, gated by the shared first-party
|
||||||
|
* service key) so app accounts are the same single identity used by the website
|
||||||
|
* and every other Ladill app. We then provision the thin local user mirror
|
||||||
|
* (keyed by the OIDC `sub`, exactly like the web SSO callback) and hand back a
|
||||||
|
* Sanctum token the app uses for `auth:sanctum` requests.
|
||||||
|
*/
|
||||||
|
class AuthController extends Controller
|
||||||
|
{
|
||||||
|
public function login(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$credentials = $request->validate([
|
||||||
|
'email' => ['required', 'email'],
|
||||||
|
'password' => ['required', 'string'],
|
||||||
|
'device_name' => ['nullable', 'string', 'max:120'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->identityPost('/api/identity/auth/login', [
|
||||||
|
'email' => $credentials['email'],
|
||||||
|
'password' => $credentials['password'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($response->status() === 422) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'email' => ['The email or password is incorrect.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $this->provisionFromResponse($response);
|
||||||
|
|
||||||
|
return $this->issueToken($user, $credentials['device_name'] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function register(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
// Mirror the web signup form; the monolith validates authoritatively, but
|
||||||
|
// we pre-validate so the app gets fast, field-level feedback.
|
||||||
|
$data = $request->validate([
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'email' => ['required', 'email', 'max:255'],
|
||||||
|
'password' => ['required', 'string', 'min:8', 'confirmed'],
|
||||||
|
'company' => ['nullable', 'string', 'max:255'],
|
||||||
|
'address' => ['required', 'string', 'max:255'],
|
||||||
|
'city' => ['required', 'string', 'max:255'],
|
||||||
|
'state' => ['required', 'string', 'max:255'],
|
||||||
|
'country' => ['required', 'string', 'size:2'],
|
||||||
|
'zipcode' => ['required', 'string', 'max:32'],
|
||||||
|
'phone_cc' => ['required', 'string', 'max:8'],
|
||||||
|
'phone' => ['required', 'string', 'max:32'],
|
||||||
|
'mobile_cc' => ['nullable', 'string', 'max:8'],
|
||||||
|
'mobile' => ['nullable', 'string', 'max:32'],
|
||||||
|
'terms' => ['accepted'],
|
||||||
|
'device_name' => ['nullable', 'string', 'max:120'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->identityPost('/api/identity/auth/register', array_merge(
|
||||||
|
$request->only([
|
||||||
|
'name', 'email', 'password', 'password_confirmation', 'company',
|
||||||
|
'address', 'city', 'state', 'country', 'zipcode',
|
||||||
|
'phone_cc', 'phone', 'mobile_cc', 'mobile',
|
||||||
|
]),
|
||||||
|
['terms' => $request->boolean('terms')],
|
||||||
|
));
|
||||||
|
|
||||||
|
// Surface the monolith's validation errors (e.g. email already taken).
|
||||||
|
if ($response->status() === 422) {
|
||||||
|
throw ValidationException::withMessages(
|
||||||
|
$response->json('errors') ?: ['email' => [$response->json('message') ?: 'Registration failed.']],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $this->provisionFromResponse($response);
|
||||||
|
|
||||||
|
return $this->issueToken($user, $data['device_name'] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logout(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$request->user()?->currentAccessToken()?->delete();
|
||||||
|
|
||||||
|
return response()->json(['data' => ['message' => 'Signed out.']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function me(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json(['data' => $this->presentUser($request->user())]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function identity(): \Illuminate\Http\Client\PendingRequest
|
||||||
|
{
|
||||||
|
return Http::baseUrl(rtrim((string) config('services.ladill_identity.url'), '/'))
|
||||||
|
->withToken((string) config('services.ladill_identity.key'))
|
||||||
|
->connectTimeout(10)
|
||||||
|
->timeout(20)
|
||||||
|
->acceptJson()
|
||||||
|
->asJson();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST to the identity API, turning connection failures into a clean message. */
|
||||||
|
private function identityPost(string $path, array $payload): HttpResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return $this->identity()->post($path, $payload);
|
||||||
|
} catch (ConnectionException $e) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'email' => ['Could not reach Ladill sign-in. Please try again in a moment.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upsert the local mirror from the identity API's OIDC claims. */
|
||||||
|
private function provisionFromResponse(HttpResponse $response): User
|
||||||
|
{
|
||||||
|
if ($response->failed()) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'email' => ['We could not reach Ladill sign-in. Please try again.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$claims = (array) $response->json('data.user', []);
|
||||||
|
$sub = (string) ($claims['sub'] ?? '');
|
||||||
|
|
||||||
|
if ($sub === '') {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'email' => ['We could not verify your Ladill account. Please try again.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$email = (string) ($claims['email'] ?? '');
|
||||||
|
|
||||||
|
return User::updateOrCreate(
|
||||||
|
['public_id' => $sub],
|
||||||
|
[
|
||||||
|
'name' => $claims['name'] ?? null,
|
||||||
|
'email' => $email !== '' ? $email : $sub.'@users.ladill.com',
|
||||||
|
'avatar_url' => $claims['picture'] ?? null,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function issueToken(User $user, ?string $deviceName): JsonResponse
|
||||||
|
{
|
||||||
|
QrTeamMember::linkPendingInvitesFor($user);
|
||||||
|
$user->update(['last_app_active_at' => now()]);
|
||||||
|
|
||||||
|
$token = $user->createToken($deviceName ?: 'Ladill Mini Android', ['mini:read', 'mini:write']);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => [
|
||||||
|
'token' => $token->plainTextToken,
|
||||||
|
'user' => $this->presentUser($user),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
private function presentUser(User $user): array
|
||||||
|
{
|
||||||
|
// On the public login/register routes SetActingAccount hasn't run and
|
||||||
|
// there's no authenticated guard yet, so ladill_account() is null —
|
||||||
|
// the acting account at sign-in is simply the user themselves.
|
||||||
|
$account = ladill_account() ?? $user;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $user->id,
|
||||||
|
'public_id' => $user->public_id,
|
||||||
|
'name' => $user->name,
|
||||||
|
'email' => $user->email,
|
||||||
|
'avatar_url' => $user->avatarUrl(),
|
||||||
|
'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,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Concerns;
|
||||||
|
|
||||||
|
use Illuminate\Http\Client\ConnectionException;
|
||||||
|
use Illuminate\Http\Client\PendingRequest;
|
||||||
|
use Illuminate\Http\Client\Response as HttpResponse;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared helper for talking to the central Ladill identity API
|
||||||
|
* (auth.ladill.com /api/identity/*) with the first-party service key.
|
||||||
|
*/
|
||||||
|
trait CallsIdentityApi
|
||||||
|
{
|
||||||
|
protected function identity(): PendingRequest
|
||||||
|
{
|
||||||
|
return Http::baseUrl(rtrim((string) config('services.ladill_identity.url'), '/'))
|
||||||
|
->withToken((string) config('services.ladill_identity.key'))
|
||||||
|
->connectTimeout(10)
|
||||||
|
->timeout(20)
|
||||||
|
->acceptJson()
|
||||||
|
->asJson();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function identitySend(string $method, string $path, array $payload): HttpResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return $this->identity()->send($method, $path, ['json' => $payload]);
|
||||||
|
} catch (ConnectionException) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'base' => ['Could not reach Ladill. Please try again in a moment.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-throw a 422 from the identity API as local validation errors. */
|
||||||
|
protected function rethrowValidation(HttpResponse $response, string $fallbackField = 'base'): void
|
||||||
|
{
|
||||||
|
if ($response->status() === 422) {
|
||||||
|
throw ValidationException::withMessages(
|
||||||
|
$response->json('errors') ?: [$fallbackField => [$response->json('message') ?: 'Request failed.']],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,156 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Mini;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\Concerns\CallsIdentityApi;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\QrSetting;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
|
class AccountController extends Controller
|
||||||
|
{
|
||||||
|
use CallsIdentityApi;
|
||||||
|
|
||||||
|
public function settings(): JsonResponse
|
||||||
|
{
|
||||||
|
$account = ladill_account();
|
||||||
|
$settings = $account->getOrCreateQrSetting();
|
||||||
|
|
||||||
|
// Phone lives on the central account, not the local mirror — fetch it,
|
||||||
|
// but never let a transient identity-API hiccup break settings loading.
|
||||||
|
$phone = '';
|
||||||
|
$phoneCc = '';
|
||||||
|
try {
|
||||||
|
$profile = $this->identitySend('GET', '/api/identity/profile?user='.urlencode((string) $account->public_id), []);
|
||||||
|
if ($profile->successful()) {
|
||||||
|
$phone = (string) $profile->json('data.phone', '');
|
||||||
|
$phoneCc = (string) $profile->json('data.phone_cc', '');
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// leave phone blank
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => [
|
||||||
|
'profile' => [
|
||||||
|
'name' => $account->name,
|
||||||
|
'email' => $account->email,
|
||||||
|
'phone' => $phone,
|
||||||
|
'phone_cc' => $phoneCc,
|
||||||
|
],
|
||||||
|
'notifications' => [
|
||||||
|
'notify_email' => $settings->notify_email ?: $account->email,
|
||||||
|
'product_updates' => (bool) ($settings->product_updates ?? true),
|
||||||
|
'notify_registrations' => (bool) ($settings->notify_registrations ?? true),
|
||||||
|
'notify_payouts' => (bool) ($settings->notify_payouts ?? true),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateSettings(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$account = ladill_account();
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'notify_email' => ['nullable', 'email', 'max:255'],
|
||||||
|
'product_updates' => ['sometimes', 'boolean'],
|
||||||
|
'notify_registrations' => ['sometimes', 'boolean'],
|
||||||
|
'notify_payouts' => ['sometimes', 'boolean'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$settings = QrSetting::updateOrCreate(
|
||||||
|
['user_id' => $account->id],
|
||||||
|
[
|
||||||
|
'notify_email' => $data['notify_email'] ?? $account->email,
|
||||||
|
'product_updates' => $request->boolean('product_updates'),
|
||||||
|
'notify_registrations' => $request->boolean('notify_registrations'),
|
||||||
|
'notify_payouts' => $request->boolean('notify_payouts'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => [
|
||||||
|
'notify_email' => $settings->notify_email,
|
||||||
|
'product_updates' => (bool) $settings->product_updates,
|
||||||
|
'notify_registrations' => (bool) $settings->notify_registrations,
|
||||||
|
'notify_payouts' => (bool) $settings->notify_payouts,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateProfile(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$account = ladill_account();
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'phone_cc' => ['nullable', 'string', 'max:8'],
|
||||||
|
'phone' => ['nullable', 'string', 'max:32'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->identitySend('PUT', '/api/identity/profile', array_merge(
|
||||||
|
['user' => $account->public_id],
|
||||||
|
$data,
|
||||||
|
));
|
||||||
|
$this->rethrowValidation($response, 'name');
|
||||||
|
|
||||||
|
if ($response->successful()) {
|
||||||
|
$account->update(['name' => $data['name']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => ['name' => $account->fresh()->name, 'email' => $account->email],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function uploadAvatar(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$account = ladill_account();
|
||||||
|
|
||||||
|
$request->validate([
|
||||||
|
'avatar' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:4096'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$file = $request->file('avatar');
|
||||||
|
|
||||||
|
$response = Http::baseUrl(rtrim((string) config('services.ladill_identity.url'), '/'))
|
||||||
|
->withToken((string) config('services.ladill_identity.key'))
|
||||||
|
->acceptJson()
|
||||||
|
->attach('avatar', (string) file_get_contents($file->getRealPath()), $file->getClientOriginalName())
|
||||||
|
->post('/api/identity/avatar', ['user' => $account->public_id]);
|
||||||
|
|
||||||
|
$this->rethrowValidation($response, 'avatar');
|
||||||
|
|
||||||
|
if ($response->successful()) {
|
||||||
|
$picture = (string) $response->json('data.user.picture', '');
|
||||||
|
if ($picture !== '') {
|
||||||
|
$account->update(['avatar_url' => $picture]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['data' => ['avatar_url' => $account->fresh()->avatarUrl()]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function changePassword(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$account = ladill_account();
|
||||||
|
|
||||||
|
$request->validate([
|
||||||
|
'current_password' => ['required', 'string'],
|
||||||
|
'password' => ['required', 'string', 'min:8', 'confirmed'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->identitySend('POST', '/api/identity/auth/change-password', [
|
||||||
|
'user' => $account->public_id,
|
||||||
|
'current_password' => $request->string('current_password'),
|
||||||
|
'password' => $request->string('password'),
|
||||||
|
'password_confirmation' => $request->string('password_confirmation'),
|
||||||
|
]);
|
||||||
|
$this->rethrowValidation($response, 'current_password');
|
||||||
|
|
||||||
|
return response()->json(['data' => ['message' => 'Password updated.']]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Mini;
|
||||||
|
|
||||||
|
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();
|
||||||
|
$types = QrTypeCatalog::paymentTypes();
|
||||||
|
$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,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Mini;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class NotificationController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$account = ladill_account();
|
||||||
|
|
||||||
|
$notifications = $account->notifications()
|
||||||
|
->latest()
|
||||||
|
->limit(100)
|
||||||
|
->get()
|
||||||
|
->map(fn ($n) => $this->present($n));
|
||||||
|
|
||||||
|
$unread = $account->unreadNotifications()->count();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => $notifications,
|
||||||
|
'unread_count' => $unread,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function unreadCount(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$account = ladill_account();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => [
|
||||||
|
'unread_count' => $account->unreadNotifications()->count(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markAsRead(Request $request, string $id): JsonResponse
|
||||||
|
{
|
||||||
|
$notification = ladill_account()
|
||||||
|
->notifications()
|
||||||
|
->where('id', $id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($notification) {
|
||||||
|
$notification->markAsRead();
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['data' => ['success' => true]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markAllAsRead(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
ladill_account()->unreadNotifications->markAsRead();
|
||||||
|
|
||||||
|
return response()->json(['data' => ['success' => true]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
private function present(mixed $notification): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $notification->id,
|
||||||
|
'type' => class_basename($notification->type),
|
||||||
|
'title' => $notification->data['title'] ?? 'Notification',
|
||||||
|
'message' => $notification->data['message'] ?? '',
|
||||||
|
'icon' => $notification->data['icon'] ?? 'bell',
|
||||||
|
'milestone' => $notification->data['milestone'] ?? null,
|
||||||
|
'url' => $notification->data['url'] ?? null,
|
||||||
|
'read_at' => $notification->read_at?->toIso8601String(),
|
||||||
|
'created_at' => $notification->created_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Mini;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\MiniPayment;
|
||||||
|
use App\Models\QrCode;
|
||||||
|
use App\Services\Billing\BillingClient;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class OverviewController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private BillingClient $billing) {}
|
||||||
|
|
||||||
|
public function __invoke(): JsonResponse
|
||||||
|
{
|
||||||
|
$account = ladill_account();
|
||||||
|
|
||||||
|
$qrIds = $account->qrCodes()
|
||||||
|
->where('type', QrCode::TYPE_PAYMENT)
|
||||||
|
->pluck('id');
|
||||||
|
|
||||||
|
$todayPayments = MiniPayment::query()
|
||||||
|
->whereIn('qr_code_id', $qrIds)
|
||||||
|
->where('status', MiniPayment::STATUS_PAID)
|
||||||
|
->where('paid_at', '>=', now()->startOfDay());
|
||||||
|
|
||||||
|
$recentPayments = MiniPayment::query()
|
||||||
|
->whereIn('qr_code_id', $qrIds)
|
||||||
|
->where('status', MiniPayment::STATUS_PAID)
|
||||||
|
->with('qrCode')
|
||||||
|
->latest('paid_at')
|
||||||
|
->limit(8)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$balanceMinor = 0;
|
||||||
|
try {
|
||||||
|
$balanceMinor = $this->billing->balanceMinor($account->public_id);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::warning('Mini API overview could not load wallet balance', [
|
||||||
|
'user' => $account->public_id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => [
|
||||||
|
'currency' => 'GHS',
|
||||||
|
'today_takings_minor' => (int) (clone $todayPayments)->sum('merchant_amount_minor'),
|
||||||
|
'today_count' => (clone $todayPayments)->count(),
|
||||||
|
'payment_qr_count' => $qrIds->count(),
|
||||||
|
'wallet_balance_minor' => $balanceMinor,
|
||||||
|
'recent_payments' => $recentPayments->map(fn (MiniPayment $p) => PaymentPresenter::present($p))->values(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Mini;
|
||||||
|
|
||||||
|
use App\Models\MiniPayment;
|
||||||
|
use App\Models\QrCode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared JSON shape for Mini payments and payment QRs so the Android app and
|
||||||
|
* the web views stay describing the same records.
|
||||||
|
*/
|
||||||
|
class PaymentPresenter
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public static function present(MiniPayment $payment): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $payment->id,
|
||||||
|
'reference' => $payment->reference,
|
||||||
|
'amount_minor' => $payment->amount_minor,
|
||||||
|
'merchant_amount_minor' => $payment->merchant_amount_minor,
|
||||||
|
'platform_fee_minor' => $payment->platform_fee_minor,
|
||||||
|
'currency' => $payment->currency,
|
||||||
|
'status' => $payment->status,
|
||||||
|
'payer_name' => $payment->payer_name,
|
||||||
|
'payer_email' => $payment->payer_email,
|
||||||
|
'payer_phone' => $payment->payer_phone,
|
||||||
|
'payer_note' => $payment->payer_note,
|
||||||
|
'qr_code_id' => $payment->qr_code_id,
|
||||||
|
'qr_label' => $payment->qrCode?->label,
|
||||||
|
'paid_at' => $payment->paid_at?->toIso8601String(),
|
||||||
|
'created_at' => $payment->created_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public static function presentQr(QrCode $qr, ?string $previewUrl = null): array
|
||||||
|
{
|
||||||
|
$content = $qr->content();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $qr->id,
|
||||||
|
'label' => $qr->label,
|
||||||
|
'business_name' => $content['business_name'] ?? null,
|
||||||
|
'branch_label' => $content['branch_label'] ?? null,
|
||||||
|
'currency' => $content['currency'] ?? 'GHS',
|
||||||
|
'short_code' => $qr->short_code,
|
||||||
|
'public_url' => $qr->publicUrl(),
|
||||||
|
'is_active' => $qr->is_active,
|
||||||
|
'scans_total' => $qr->scans_total,
|
||||||
|
'preview_url' => $previewUrl,
|
||||||
|
'created_at' => $qr->created_at?->toIso8601String(),
|
||||||
|
'updated_at' => $qr->updated_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Mini;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\QrCode;
|
||||||
|
use App\Services\Qr\QrCodeManagerService;
|
||||||
|
use App\Services\Qr\QrImageGeneratorService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use RuntimeException;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class PaymentQrController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private QrCodeManagerService $manager,
|
||||||
|
private QrImageGeneratorService $imageGenerator,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(): JsonResponse
|
||||||
|
{
|
||||||
|
$qrCodes = ladill_account()->qrCodes()
|
||||||
|
->where('type', QrCode::TYPE_PAYMENT)
|
||||||
|
->latest()
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => $qrCodes->map(fn (QrCode $qr) => PaymentPresenter::presentQr($qr, $this->previewUrl($qr)))->values(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
abort_unless($request->user()->tokenCan('mini:write'), 403, 'Token requires mini:write ability.');
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'label' => ['required', 'string', 'max:120'],
|
||||||
|
'business_name' => ['required', 'string', 'max:120'],
|
||||||
|
'branch_label' => ['nullable', 'string', 'max:80'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$data['type'] = QrCode::TYPE_PAYMENT;
|
||||||
|
$data['currency'] = 'GHS';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$qrCode = $this->manager->create(ladill_account(), $data);
|
||||||
|
} catch (RuntimeException $e) {
|
||||||
|
return response()->json(['message' => $e->getMessage()], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => PaymentPresenter::presentQr($qrCode->fresh(), $this->previewUrl($qrCode)),
|
||||||
|
], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(QrCode $paymentQr): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorizePaymentQr($paymentQr);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => PaymentPresenter::presentQr($paymentQr, $this->previewUrl($paymentQr)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, QrCode $paymentQr): JsonResponse
|
||||||
|
{
|
||||||
|
abort_unless($request->user()->tokenCan('mini:write'), 403, 'Token requires mini:write ability.');
|
||||||
|
$this->authorizePaymentQr($paymentQr);
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'label' => ['sometimes', 'string', 'max:120'],
|
||||||
|
'business_name' => ['sometimes', 'string', 'max:120'],
|
||||||
|
'branch_label' => ['nullable', 'string', 'max:80'],
|
||||||
|
'is_active' => ['sometimes', 'boolean'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($request->has('is_active')) {
|
||||||
|
$data['is_active'] = $request->boolean('is_active');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->manager->update($paymentQr, $data);
|
||||||
|
} catch (RuntimeException $e) {
|
||||||
|
return response()->json(['message' => $e->getMessage()], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => PaymentPresenter::presentQr($paymentQr->fresh(), $this->previewUrl($paymentQr)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(QrCode $paymentQr): JsonResponse
|
||||||
|
{
|
||||||
|
abort_unless(request()->user()->tokenCan('mini:write'), 403, 'Token requires mini:write ability.');
|
||||||
|
$this->authorizePaymentQr($paymentQr);
|
||||||
|
|
||||||
|
$this->manager->delete($paymentQr);
|
||||||
|
|
||||||
|
return response()->json(['data' => ['message' => 'Payment QR deleted.']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function preview(QrCode $paymentQr): Response
|
||||||
|
{
|
||||||
|
$this->authorizePaymentQr($paymentQr);
|
||||||
|
|
||||||
|
$qrCode = $this->imageGenerator->ensureValidImages($paymentQr);
|
||||||
|
$bytes = $this->imageGenerator->normalizeStoredPng($qrCode->png_path);
|
||||||
|
|
||||||
|
if ($bytes === null) {
|
||||||
|
$bytes = $this->imageGenerator->renderPng($qrCode->encodedPayload(), $qrCode->style());
|
||||||
|
$this->imageGenerator->generateAndStore($qrCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response($bytes, 200, [
|
||||||
|
'Content-Type' => 'image/png',
|
||||||
|
'Cache-Control' => 'private, max-age=3600',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function previewUrl(QrCode $qr): string
|
||||||
|
{
|
||||||
|
return route('api.mini.payment-qrs.preview', $qr);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function authorizePaymentQr(QrCode $paymentQr): void
|
||||||
|
{
|
||||||
|
abort_unless($paymentQr->type === QrCode::TYPE_PAYMENT, 404);
|
||||||
|
abort_unless($paymentQr->user_id === ladill_account()->id, 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Mini;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\MiniPayment;
|
||||||
|
use App\Models\QrCode;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class PaymentsController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$search = trim((string) $request->query('q', ''));
|
||||||
|
|
||||||
|
$qrIds = ladill_account()->qrCodes()
|
||||||
|
->where('type', QrCode::TYPE_PAYMENT)
|
||||||
|
->pluck('id');
|
||||||
|
|
||||||
|
$payments = MiniPayment::query()
|
||||||
|
->whereIn('qr_code_id', $qrIds)
|
||||||
|
->when($search !== '', function ($query) use ($search) {
|
||||||
|
$like = '%'.$search.'%';
|
||||||
|
$query->where(function ($inner) use ($like) {
|
||||||
|
$inner->where('payer_name', 'like', $like)
|
||||||
|
->orWhere('payer_email', 'like', $like)
|
||||||
|
->orWhere('payer_note', 'like', $like)
|
||||||
|
->orWhere('reference', 'like', $like)
|
||||||
|
->orWhere('payment_reference', 'like', $like)
|
||||||
|
->orWhereHas('qrCode', fn ($qr) => $qr->where('label', 'like', $like));
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->with('qrCode')
|
||||||
|
->latest('created_at')
|
||||||
|
->paginate(25)
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => collect($payments->items())->map(fn (MiniPayment $p) => PaymentPresenter::present($p))->values(),
|
||||||
|
'meta' => [
|
||||||
|
'current_page' => $payments->currentPage(),
|
||||||
|
'last_page' => $payments->lastPage(),
|
||||||
|
'per_page' => $payments->perPage(),
|
||||||
|
'total' => $payments->total(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Mini;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\MiniPayment;
|
||||||
|
use App\Models\QrCode;
|
||||||
|
use App\Services\Billing\BillingClient;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class PayoutsController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private BillingClient $billing) {}
|
||||||
|
|
||||||
|
public function __invoke(): JsonResponse
|
||||||
|
{
|
||||||
|
$account = ladill_account();
|
||||||
|
|
||||||
|
$qrIds = $account->qrCodes()
|
||||||
|
->where('type', QrCode::TYPE_PAYMENT)
|
||||||
|
->pluck('id');
|
||||||
|
|
||||||
|
$revenueMinor = (int) MiniPayment::query()
|
||||||
|
->whereIn('qr_code_id', $qrIds)
|
||||||
|
->where('status', MiniPayment::STATUS_PAID)
|
||||||
|
->sum('merchant_amount_minor');
|
||||||
|
|
||||||
|
$balanceMinor = 0;
|
||||||
|
try {
|
||||||
|
$balanceMinor = $this->billing->balanceMinor($account->public_id);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::warning('Mini API payouts could not load wallet balance', [
|
||||||
|
'user' => $account->public_id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => [
|
||||||
|
'currency' => 'GHS',
|
||||||
|
'total_revenue_minor' => $revenueMinor,
|
||||||
|
'wallet_balance_minor' => $balanceMinor,
|
||||||
|
'account_wallet_url' => 'https://'.config('app.account_domain').'/wallet',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Mini;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\UserPushToken;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class PushTokenController extends Controller
|
||||||
|
{
|
||||||
|
public function store(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'token' => ['required', 'string', 'max:512'],
|
||||||
|
'platform' => ['nullable', 'string', 'max:32'],
|
||||||
|
'device_name' => ['nullable', 'string', 'max:120'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = $request->user();
|
||||||
|
$now = now();
|
||||||
|
|
||||||
|
UserPushToken::updateOrCreate(
|
||||||
|
['token' => $data['token']],
|
||||||
|
[
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'platform' => $data['platform'] ?? 'android',
|
||||||
|
'device_name' => $data['device_name'] ?? $user->currentAccessToken()?->name,
|
||||||
|
'last_seen_at' => $now,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$user->update(['last_app_active_at' => $now]);
|
||||||
|
|
||||||
|
return response()->json(['data' => ['registered' => true]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'token' => ['required', 'string', 'max:512'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$request->user()
|
||||||
|
->pushTokens()
|
||||||
|
->where('token', $data['token'])
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
return response()->json(['data' => ['removed' => true]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Mini;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\Concerns\CallsIdentityApi;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Support tickets — proxies to the central support system (auth.ladill.com),
|
||||||
|
* so tickets land in the same admin support queue as every other Ladill app.
|
||||||
|
*/
|
||||||
|
class SupportController extends Controller
|
||||||
|
{
|
||||||
|
use CallsIdentityApi;
|
||||||
|
|
||||||
|
public function tickets(): JsonResponse
|
||||||
|
{
|
||||||
|
$response = $this->identitySend('GET', '/api/identity/support/tickets?user='.urlencode((string) ladill_account()->public_id), []);
|
||||||
|
|
||||||
|
return response()->json(['data' => $response->json('data', [])]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ticket(int $ticket): JsonResponse
|
||||||
|
{
|
||||||
|
$response = $this->identitySend(
|
||||||
|
'GET',
|
||||||
|
'/api/identity/support/tickets/'.$ticket.'?user='.urlencode((string) ladill_account()->public_id),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($response->status() === 404) {
|
||||||
|
return response()->json(['message' => 'Ticket not found.'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['data' => $response->json('data')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'subject' => ['required', 'string', 'max:255'],
|
||||||
|
'message' => ['required', 'string', 'max:5000'],
|
||||||
|
'priority' => ['sometimes', 'in:low,normal,high'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->identitySend('POST', '/api/identity/support/tickets', array_merge(
|
||||||
|
['user' => ladill_account()->public_id],
|
||||||
|
$data,
|
||||||
|
));
|
||||||
|
$this->rethrowValidation($response, 'subject');
|
||||||
|
|
||||||
|
return response()->json(['data' => $response->json('data')], 201);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Mini;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\Concerns\CallsIdentityApi;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\Billing\BillingClient;
|
||||||
|
use App\Services\Mini\AutoWithdrawService;
|
||||||
|
use App\Services\Notifications\MiniNotificationService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class WalletController extends Controller
|
||||||
|
{
|
||||||
|
use CallsIdentityApi;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private BillingClient $billing,
|
||||||
|
private MiniNotificationService $notifications,
|
||||||
|
private AutoWithdrawService $autoWithdraw,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function show(): JsonResponse
|
||||||
|
{
|
||||||
|
$account = ladill_account();
|
||||||
|
$balanceMinor = 0;
|
||||||
|
$ledger = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$balanceMinor = $this->billing->balanceMinor($account->public_id);
|
||||||
|
$ledger = $this->billing->serviceLedger($account->public_id, config('billing.service', 'mini'));
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::warning('Mini API wallet load failed', ['user' => $account->public_id, 'error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$settings = $account->getOrCreateQrSetting();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => [
|
||||||
|
'currency' => 'GHS',
|
||||||
|
'balance_minor' => $balanceMinor,
|
||||||
|
'spent_minor' => (int) ($ledger['spent_minor'] ?? 0),
|
||||||
|
'credited_minor' => (int) ($ledger['credited_minor'] ?? 0),
|
||||||
|
'auto_withdraw_amount_minor' => $settings->auto_withdraw_amount_minor,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateAutoWithdraw(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$account = ladill_account();
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'amount' => ['nullable', 'numeric', 'min:1', 'max:50000'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$amountMinor = isset($data['amount']) ? (int) round((float) $data['amount'] * 100) : null;
|
||||||
|
|
||||||
|
$settings = $account->getOrCreateQrSetting();
|
||||||
|
$settings->update(['auto_withdraw_amount_minor' => $amountMinor]);
|
||||||
|
|
||||||
|
if ($amountMinor !== null) {
|
||||||
|
$this->autoWithdraw->attemptForUser($account);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => [
|
||||||
|
'auto_withdraw_amount_minor' => $settings->fresh()->auto_withdraw_amount_minor,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function topup(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$account = ladill_account();
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'amount' => ['required', 'numeric', 'min:1', 'max:10000'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->identitySend('POST', '/api/identity/wallet-topup-account', [
|
||||||
|
'user' => $account->public_id,
|
||||||
|
'amount' => (float) $data['amount'],
|
||||||
|
// Paystack returns the customer here after payment; the app catches the deep link.
|
||||||
|
'return_url' => 'https://'.config('app.platform_domain').'/mini/wallet/topup-complete',
|
||||||
|
]);
|
||||||
|
$this->rethrowValidation($response, 'amount');
|
||||||
|
|
||||||
|
$checkoutUrl = (string) $response->json('data.checkout_url', '');
|
||||||
|
if ($checkoutUrl === '') {
|
||||||
|
return response()->json(['message' => 'Could not start top-up. Please try again.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['data' => ['checkout_url' => $checkoutUrl]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function banks(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$type = $request->query('type') === 'mobile_money' ? 'mobile_money' : 'bank';
|
||||||
|
$response = $this->identitySend('GET', '/api/identity/banks?type='.urlencode($type), []);
|
||||||
|
|
||||||
|
return response()->json(['data' => $response->json('data', [])]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function payoutAccount(): JsonResponse
|
||||||
|
{
|
||||||
|
$response = $this->identitySend('GET', '/api/identity/payout-account?user='.urlencode((string) ladill_account()->public_id), []);
|
||||||
|
|
||||||
|
return response()->json(['data' => ['payout_account' => $response->json('data.payout_account')]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatePayoutAccount(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'account_type' => ['required', 'in:mobile_money,bank_account'],
|
||||||
|
'account_name' => ['required', 'string', 'max:200'],
|
||||||
|
'account_number' => ['required', 'string', 'max:30'],
|
||||||
|
'bank_code' => ['required', 'string', 'max:30'],
|
||||||
|
'bank_name' => ['required', 'string', 'max:200'],
|
||||||
|
'currency' => ['sometimes', 'string', 'size:3'],
|
||||||
|
]);
|
||||||
|
$data['currency'] = $data['currency'] ?? 'GHS';
|
||||||
|
|
||||||
|
$response = $this->identitySend('PUT', '/api/identity/payout-account', array_merge(
|
||||||
|
['user' => ladill_account()->public_id],
|
||||||
|
$data,
|
||||||
|
));
|
||||||
|
$this->rethrowValidation($response, 'account_number');
|
||||||
|
|
||||||
|
return response()->json(['data' => ['payout_account' => $response->json('data.payout_account')]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function withdrawals(): JsonResponse
|
||||||
|
{
|
||||||
|
$response = $this->identitySend('GET', '/api/identity/wallet/withdrawals?user='.urlencode((string) ladill_account()->public_id), []);
|
||||||
|
|
||||||
|
return response()->json(['data' => $response->json('data', [])]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function withdraw(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'amount' => ['required', 'numeric', 'min:1', 'max:50000'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->identitySend('POST', '/api/identity/wallet/withdraw', [
|
||||||
|
'user' => ladill_account()->public_id,
|
||||||
|
'amount' => (float) $data['amount'],
|
||||||
|
]);
|
||||||
|
$this->rethrowValidation($response, 'amount');
|
||||||
|
|
||||||
|
$this->notifications->withdrawalSubmitted(
|
||||||
|
ladill_account(),
|
||||||
|
(float) $data['amount'],
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json(['data' => $response->json('data', [])]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,324 @@
|
|||||||
|
<?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('pos.dashboard'));
|
||||||
|
|
||||||
|
if (Auth::check()) {
|
||||||
|
return $this->safeRedirect($intended, route('pos.dashboard'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->attemptSilentRefresh($request, $intended)) {
|
||||||
|
return $this->safeRedirect($intended, route('pos.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('pos.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()->away((string) config('ladill.marketing_url'));
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
// Per-app sign-out: end only this app's session and keep the platform
|
||||||
|
// (auth.ladill.com) SSO session alive so "Sign in again" re-auths silently
|
||||||
|
// without a password. Full "sign out of all Ladill apps" lives on account.
|
||||||
|
return redirect()->away($this->defaultSignedOutUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Platform session ended — clear this app and offer silent sign-in again. */
|
||||||
|
public function platformSignedOut(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
Auth::logout();
|
||||||
|
$request->session()->invalidate();
|
||||||
|
$request->session()->regenerateToken();
|
||||||
|
|
||||||
|
return redirect()->route('sso.connect', [
|
||||||
|
'redirect' => (string) $request->query('redirect', ''),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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('pos.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
|
|
||||||
|
abstract class Controller
|
||||||
|
{
|
||||||
|
use AuthorizesRequests;
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Mini;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\MiniPayment;
|
||||||
|
use App\Models\QrCode;
|
||||||
|
use App\Services\Billing\BillingClient;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class OverviewController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private BillingClient $billing) {}
|
||||||
|
|
||||||
|
public function index(Request $request): View
|
||||||
|
{
|
||||||
|
$account = ladill_account();
|
||||||
|
|
||||||
|
$qrIds = $account->qrCodes()
|
||||||
|
->where('type', QrCode::TYPE_PAYMENT)
|
||||||
|
->pluck('id');
|
||||||
|
|
||||||
|
$todayStart = now()->startOfDay();
|
||||||
|
|
||||||
|
$todayPayments = MiniPayment::query()
|
||||||
|
->whereIn('qr_code_id', $qrIds)
|
||||||
|
->where('status', MiniPayment::STATUS_PAID)
|
||||||
|
->where('paid_at', '>=', $todayStart);
|
||||||
|
|
||||||
|
$todayCount = (clone $todayPayments)->count();
|
||||||
|
$todayMinor = (int) (clone $todayPayments)->sum('merchant_amount_minor');
|
||||||
|
|
||||||
|
$recentPayments = MiniPayment::query()
|
||||||
|
->whereIn('qr_code_id', $qrIds)
|
||||||
|
->where('status', MiniPayment::STATUS_PAID)
|
||||||
|
->with('qrCode')
|
||||||
|
->latest('paid_at')
|
||||||
|
->limit(8)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$paymentQrCount = $qrIds->count();
|
||||||
|
|
||||||
|
$balanceMinor = 0;
|
||||||
|
try {
|
||||||
|
$balanceMinor = $this->billing->balanceMinor($account->public_id);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::warning('Mini dashboard could not load wallet balance', [
|
||||||
|
'user' => $account->public_id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('mini.dashboard', [
|
||||||
|
'todayCount' => $todayCount,
|
||||||
|
'todayMinor' => $todayMinor,
|
||||||
|
'paymentQrCount' => $paymentQrCount,
|
||||||
|
'recentPayments' => $recentPayments,
|
||||||
|
'balanceMinor' => $balanceMinor,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Mini;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\QrCode;
|
||||||
|
use App\Services\Qr\QrCodeManagerService;
|
||||||
|
use App\Services\Qr\QrImageGeneratorService;
|
||||||
|
use App\Services\Qr\QrPdfExporter;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
use RuntimeException;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
|
|
||||||
|
class PaymentQrController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private QrCodeManagerService $manager,
|
||||||
|
private QrImageGeneratorService $imageGenerator,
|
||||||
|
private QrPdfExporter $pdfExporter,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(Request $request): View
|
||||||
|
{
|
||||||
|
$account = ladill_account();
|
||||||
|
$qrCodes = $account->qrCodes()
|
||||||
|
->where('type', QrCode::TYPE_PAYMENT)
|
||||||
|
->latest()
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$previewDataUris = $qrCodes->mapWithKeys(function (QrCode $qr) {
|
||||||
|
return [$qr->id => $this->imageGenerator->previewDataUri($qr)];
|
||||||
|
});
|
||||||
|
|
||||||
|
return view('mini.payment-qrs.index', [
|
||||||
|
'qrCodes' => $qrCodes,
|
||||||
|
'previewDataUris' => $previewDataUris,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(): View
|
||||||
|
{
|
||||||
|
return view('mini.payment-qrs.create');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$account = ladill_account();
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'label' => 'required|string|max:120',
|
||||||
|
'business_name' => 'required|string|max:120',
|
||||||
|
'branch_label' => 'nullable|string|max:80',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$data['type'] = QrCode::TYPE_PAYMENT;
|
||||||
|
$data['currency'] = 'GHS';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$qrCode = $this->manager->create($account, $data);
|
||||||
|
} catch (RuntimeException $e) {
|
||||||
|
return back()->withInput()->with('error', $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->route('mini.payment-qrs.show', $qrCode)->with('success', 'Payment QR created.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(QrCode $paymentQr): View
|
||||||
|
{
|
||||||
|
$this->authorizePaymentQr($paymentQr);
|
||||||
|
|
||||||
|
$previewDataUri = $this->imageGenerator->previewDataUri($paymentQr);
|
||||||
|
|
||||||
|
return view('mini.payment-qrs.show', [
|
||||||
|
'qrCode' => $paymentQr->fresh(),
|
||||||
|
'previewDataUri' => $previewDataUri,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, QrCode $paymentQr): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorizePaymentQr($paymentQr);
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'label' => 'sometimes|string|max:120',
|
||||||
|
'business_name' => 'sometimes|string|max:120',
|
||||||
|
'branch_label' => 'nullable|string|max:80',
|
||||||
|
'is_active' => 'sometimes|boolean',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$data['is_active'] = $request->boolean('is_active', $paymentQr->is_active);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->manager->update($paymentQr, $data);
|
||||||
|
} catch (RuntimeException $e) {
|
||||||
|
return back()->withInput()->with('error', $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('success', 'Payment QR updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(QrCode $paymentQr): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorizePaymentQr($paymentQr);
|
||||||
|
|
||||||
|
$this->manager->delete($paymentQr);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('mini.payment-qrs.index')
|
||||||
|
->with('success', 'Payment QR deleted.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function preview(QrCode $paymentQr): Response
|
||||||
|
{
|
||||||
|
$this->authorizePaymentQr($paymentQr);
|
||||||
|
|
||||||
|
$qrCode = $this->imageGenerator->ensureValidImages($paymentQr);
|
||||||
|
$bytes = $this->imageGenerator->normalizeStoredPng($qrCode->png_path);
|
||||||
|
|
||||||
|
if ($bytes === null) {
|
||||||
|
$bytes = $this->imageGenerator->renderPng($qrCode->encodedPayload(), $qrCode->style());
|
||||||
|
$this->imageGenerator->generateAndStore($qrCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response($bytes, 200, [
|
||||||
|
'Content-Type' => 'image/png',
|
||||||
|
'Cache-Control' => 'private, max-age=3600',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function download(QrCode $paymentQr, string $format): StreamedResponse
|
||||||
|
{
|
||||||
|
$this->authorizePaymentQr($paymentQr);
|
||||||
|
|
||||||
|
$qrCode = $this->imageGenerator->ensureValidImages($paymentQr);
|
||||||
|
$path = $format === 'svg' ? $qrCode->svg_path : $qrCode->png_path;
|
||||||
|
$filename = Str::slug($qrCode->label).'-qr.'.($format === 'svg' ? 'svg' : 'png');
|
||||||
|
|
||||||
|
if ($format === 'png') {
|
||||||
|
$bytes = $this->imageGenerator->normalizeStoredPng($path);
|
||||||
|
if ($bytes === null) {
|
||||||
|
$bytes = $this->imageGenerator->renderPng($qrCode->encodedPayload(), $qrCode->style());
|
||||||
|
$this->imageGenerator->generateAndStore($qrCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->streamDownload(fn () => print($bytes), $filename, [
|
||||||
|
'Content-Type' => 'image/png',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($format === 'pdf') {
|
||||||
|
$bytes = $this->imageGenerator->normalizeStoredPng($qrCode->png_path);
|
||||||
|
if ($bytes === null) {
|
||||||
|
$bytes = $this->imageGenerator->renderPng($qrCode->encodedPayload(), $qrCode->style());
|
||||||
|
$this->imageGenerator->generateAndStore($qrCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdf = $this->pdfExporter->fromPng($bytes, $qrCode->label);
|
||||||
|
$filename = Str::slug($qrCode->label).'-qr.pdf';
|
||||||
|
|
||||||
|
return response()->streamDownload(fn () => print($pdf), $filename, [
|
||||||
|
'Content-Type' => 'application/pdf',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
abort_unless($path && Storage::disk('qr')->exists($path), 404);
|
||||||
|
|
||||||
|
return Storage::disk('qr')->download($path, $filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function authorizePaymentQr(QrCode $paymentQr): void
|
||||||
|
{
|
||||||
|
abort_unless($paymentQr->type === QrCode::TYPE_PAYMENT, 404);
|
||||||
|
abort_unless($paymentQr->user_id === ladill_account()->id, 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Mini;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\MiniPayment;
|
||||||
|
use App\Models\QrCode;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class PaymentsController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): View
|
||||||
|
{
|
||||||
|
$account = ladill_account();
|
||||||
|
$search = trim((string) $request->query('q', ''));
|
||||||
|
|
||||||
|
$qrIds = $account->qrCodes()
|
||||||
|
->where('type', QrCode::TYPE_PAYMENT)
|
||||||
|
->pluck('id');
|
||||||
|
|
||||||
|
$payments = MiniPayment::query()
|
||||||
|
->whereIn('qr_code_id', $qrIds)
|
||||||
|
->when($search !== '', function ($query) use ($search) {
|
||||||
|
$like = '%'.$search.'%';
|
||||||
|
$query->where(function ($inner) use ($like) {
|
||||||
|
$inner->where('payer_name', 'like', $like)
|
||||||
|
->orWhere('payer_email', 'like', $like)
|
||||||
|
->orWhere('payer_note', 'like', $like)
|
||||||
|
->orWhere('reference', 'like', $like)
|
||||||
|
->orWhere('payment_reference', 'like', $like)
|
||||||
|
->orWhereHas('qrCode', fn ($qr) => $qr->where('label', 'like', $like));
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->with('qrCode')
|
||||||
|
->latest('created_at')
|
||||||
|
->paginate(25)
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
return view('mini.payments', [
|
||||||
|
'payments' => $payments,
|
||||||
|
'search' => $search,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Mini;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\MiniPayment;
|
||||||
|
use App\Models\QrCode;
|
||||||
|
use App\Services\Billing\BillingClient;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class PayoutsController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private BillingClient $billing) {}
|
||||||
|
|
||||||
|
public function index(): View
|
||||||
|
{
|
||||||
|
$account = ladill_account();
|
||||||
|
|
||||||
|
$qrIds = $account->qrCodes()
|
||||||
|
->where('type', QrCode::TYPE_PAYMENT)
|
||||||
|
->pluck('id');
|
||||||
|
|
||||||
|
$revenueMinor = (int) MiniPayment::query()
|
||||||
|
->whereIn('qr_code_id', $qrIds)
|
||||||
|
->where('status', MiniPayment::STATUS_PAID)
|
||||||
|
->sum('merchant_amount_minor');
|
||||||
|
|
||||||
|
$balanceMinor = 0;
|
||||||
|
try {
|
||||||
|
$balanceMinor = $this->billing->balanceMinor($account->public_id);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::warning('Mini payouts could not load wallet balance', [
|
||||||
|
'user' => $account->public_id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$accountWalletUrl = 'https://'.config('app.account_domain').'/wallet';
|
||||||
|
|
||||||
|
return view('mini.payouts', [
|
||||||
|
'revenueMinor' => $revenueMinor,
|
||||||
|
'balanceMinor' => $balanceMinor,
|
||||||
|
'accountWalletUrl' => $accountWalletUrl,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Pos\Concerns;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
trait ScopesToAccount
|
||||||
|
{
|
||||||
|
protected function ownerRef(Request $request): string
|
||||||
|
{
|
||||||
|
$user = ladill_account() ?? $request->user();
|
||||||
|
|
||||||
|
return (string) $user->public_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function authorizeOwner(Request $request, Model $model): void
|
||||||
|
{
|
||||||
|
abort_unless($model->getAttribute('owner_ref') === $this->ownerRef($request), 404);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Pos;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Pos\Concerns\ScopesToAccount;
|
||||||
|
use App\Models\PosProduct;
|
||||||
|
use App\Models\PosSale;
|
||||||
|
use App\Services\Pos\PosLocationService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class DashboardController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesToAccount;
|
||||||
|
|
||||||
|
public function __construct(private PosLocationService $locations) {}
|
||||||
|
|
||||||
|
public function index(Request $request): View
|
||||||
|
{
|
||||||
|
$owner = $this->ownerRef($request);
|
||||||
|
$todayStart = now()->startOfDay();
|
||||||
|
|
||||||
|
$todaySales = PosSale::owned($owner)
|
||||||
|
->where('status', PosSale::STATUS_PAID)
|
||||||
|
->where('paid_at', '>=', $todayStart);
|
||||||
|
|
||||||
|
$stats = [
|
||||||
|
'today_total_minor' => (int) (clone $todaySales)->sum('total_minor'),
|
||||||
|
'today_count' => (clone $todaySales)->count(),
|
||||||
|
'product_count' => PosProduct::owned($owner)->active()->count(),
|
||||||
|
'open_pending' => PosSale::owned($owner)->where('status', PosSale::STATUS_PENDING)->count(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$recentSales = PosSale::owned($owner)
|
||||||
|
->with('lines')
|
||||||
|
->latest()
|
||||||
|
->limit(8)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return view('pos.dashboard', compact('stats', 'recentSales'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Pos;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Pos\Concerns\ScopesToAccount;
|
||||||
|
use App\Models\PosProduct;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class ProductController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesToAccount;
|
||||||
|
|
||||||
|
public function index(Request $request): View
|
||||||
|
{
|
||||||
|
$products = PosProduct::owned($this->ownerRef($request))
|
||||||
|
->orderBy('name')
|
||||||
|
->paginate(30)
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
return view('pos.products.index', compact('products'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(): View
|
||||||
|
{
|
||||||
|
return view('pos.products.create', ['product' => new PosProduct([
|
||||||
|
'currency' => config('pos.default_currency', 'GHS'),
|
||||||
|
'is_active' => true,
|
||||||
|
])]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
PosProduct::create([
|
||||||
|
...$this->validated($request),
|
||||||
|
'owner_ref' => $this->ownerRef($request),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()->route('pos.products.index')->with('success', 'Product added.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit(Request $request, PosProduct $product): View
|
||||||
|
{
|
||||||
|
$this->authorizeOwner($request, $product);
|
||||||
|
|
||||||
|
return view('pos.products.edit', compact('product'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, PosProduct $product): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorizeOwner($request, $product);
|
||||||
|
$product->update($this->validated($request));
|
||||||
|
|
||||||
|
return redirect()->route('pos.products.index')->with('success', 'Product updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request, PosProduct $product): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorizeOwner($request, $product);
|
||||||
|
$product->delete();
|
||||||
|
|
||||||
|
return redirect()->route('pos.products.index')->with('success', 'Product removed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
private function validated(Request $request): array
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'name' => ['required', 'string', 'max:200'],
|
||||||
|
'sku' => ['nullable', 'string', 'max:80'],
|
||||||
|
'price' => ['required', 'numeric', 'min:0.01'],
|
||||||
|
'currency' => ['nullable', 'string', 'size:3'],
|
||||||
|
'is_active' => ['sometimes', 'boolean'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => $data['name'],
|
||||||
|
'sku' => $data['sku'] ?? null,
|
||||||
|
'price_minor' => (int) round(((float) $data['price']) * 100),
|
||||||
|
'currency' => strtoupper($data['currency'] ?? config('pos.default_currency', 'GHS')),
|
||||||
|
'is_active' => $request->boolean('is_active', true),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Pos;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Pos\Concerns\ScopesToAccount;
|
||||||
|
use App\Models\PosProduct;
|
||||||
|
use App\Models\PosSale;
|
||||||
|
use App\Services\Pos\PosLocationService;
|
||||||
|
use App\Services\Pos\PosSaleService;
|
||||||
|
use App\Services\Crm\CrmClient;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class RegisterController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesToAccount;
|
||||||
|
|
||||||
|
public function __construct(private PosSaleService $sales, private PosLocationService $locations) {}
|
||||||
|
|
||||||
|
public function index(Request $request): View
|
||||||
|
{
|
||||||
|
$owner = $this->ownerRef($request);
|
||||||
|
$location = $this->locations->ensureDefault($owner);
|
||||||
|
|
||||||
|
$products = PosProduct::owned($owner)
|
||||||
|
->active()
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return view('pos.register', [
|
||||||
|
'products' => $products,
|
||||||
|
'location' => $location,
|
||||||
|
'crmCustomers' => $this->crmCustomers($owner),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<array<string, mixed>> */
|
||||||
|
private function crmCustomers(string $owner): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return (array) ((CrmClient::for($owner)->customers(['per_page' => 200])['data']) ?? []);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function charge(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'lines' => ['required', 'array', 'min:1'],
|
||||||
|
'lines.*.product_id' => ['nullable', 'integer'],
|
||||||
|
'lines.*.name' => ['required', 'string', 'max:200'],
|
||||||
|
'lines.*.unit_price_minor' => ['required', 'integer', 'min:1'],
|
||||||
|
'lines.*.quantity' => ['required', 'integer', 'min:1', 'max:999'],
|
||||||
|
'customer_name' => ['nullable', 'string', 'max:120'],
|
||||||
|
'customer_email' => ['nullable', 'email', 'max:255'],
|
||||||
|
'customer_phone' => ['nullable', 'string', 'max:40'],
|
||||||
|
'crm_customer_id' => ['nullable', 'integer'],
|
||||||
|
'payment_method' => ['required', 'in:pay,cash'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$merchant = ladill_account() ?? $request->user();
|
||||||
|
$location = $this->locations->ensureDefault($this->ownerRef($request));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$sale = $this->sales->createSale($merchant, $data['lines'], [
|
||||||
|
'location_id' => $location->id,
|
||||||
|
'currency' => $location->currency,
|
||||||
|
'customer_name' => $data['customer_name'] ?? null,
|
||||||
|
'customer_email' => $data['customer_email'] ?? null,
|
||||||
|
'customer_phone' => $data['customer_phone'] ?? null,
|
||||||
|
'crm_customer_id' => $data['crm_customer_id'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($data['payment_method'] === 'cash') {
|
||||||
|
$this->sales->recordCashPayment($sale);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('pos.sales.show', $sale)
|
||||||
|
->with('success', 'Cash sale recorded.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->sales->initiatePayCheckout($sale, $merchant);
|
||||||
|
|
||||||
|
return redirect()->away($result['checkout_url']);
|
||||||
|
} catch (RuntimeException $e) {
|
||||||
|
return back()->withInput()->with('error', $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Pos;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Pos\Concerns\ScopesToAccount;
|
||||||
|
use App\Models\PosSale;
|
||||||
|
use App\Services\CrossApp\CrossAppLinkService;
|
||||||
|
use App\Services\Pos\PosSaleService;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class SaleController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesToAccount;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private PosSaleService $sales,
|
||||||
|
private CrossAppLinkService $links,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(Request $request): View
|
||||||
|
{
|
||||||
|
$sales = PosSale::owned($this->ownerRef($request))
|
||||||
|
->with('lines')
|
||||||
|
->latest()
|
||||||
|
->paginate(25)
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
return view('pos.sales.index', compact('sales'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Request $request, PosSale $sale): View
|
||||||
|
{
|
||||||
|
$this->authorizeOwner($request, $sale);
|
||||||
|
$sale->load('lines', 'location');
|
||||||
|
|
||||||
|
$invoiceUrl = $sale->isPaid()
|
||||||
|
? $this->links->invoiceFromSale($sale)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return view('pos.sales.show', compact('sale', 'invoiceUrl'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function callback(Request $request, PosSale $sale): RedirectResponse
|
||||||
|
{
|
||||||
|
$reference = trim((string) $request->query('reference', $sale->payment_reference ?? ''));
|
||||||
|
|
||||||
|
if ($reference === '') {
|
||||||
|
return redirect()->route('pos.sales.show', $sale)->with('error', 'Missing payment reference.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$sale = $this->sales->completePayCheckout($reference);
|
||||||
|
} catch (RuntimeException $e) {
|
||||||
|
return redirect()->route('pos.sales.show', $sale)->with('error', $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('pos.sales.show', $sale)
|
||||||
|
->with('success', 'Payment received.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Pos;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Pos\Concerns\ScopesToAccount;
|
||||||
|
use App\Services\Import\CrmProductImportService;
|
||||||
|
use App\Services\Import\MerchantCatalogImportService;
|
||||||
|
use App\Services\Pos\PosLocationService;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class SettingsController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesToAccount;
|
||||||
|
|
||||||
|
public function __construct(private PosLocationService $locations) {}
|
||||||
|
|
||||||
|
public function index(Request $request): View
|
||||||
|
{
|
||||||
|
$location = $this->locations->ensureDefault($this->ownerRef($request));
|
||||||
|
|
||||||
|
return view('pos.settings', [
|
||||||
|
'location' => $location,
|
||||||
|
'merchantImportEnabled' => (bool) config('pos.merchant_import_enabled', true),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'name' => ['required', 'string', 'max:120'],
|
||||||
|
'currency' => ['required', 'string', 'size:3'],
|
||||||
|
'receipt_footer' => ['nullable', 'string', 'max:1000'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$location = $this->locations->ensureDefault($this->ownerRef($request));
|
||||||
|
$location->update([
|
||||||
|
'name' => $data['name'],
|
||||||
|
'currency' => strtoupper($data['currency']),
|
||||||
|
'receipt_footer' => $data['receipt_footer'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return back()->with('success', 'Settings saved.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function importCrm(Request $request, CrmProductImportService $import): RedirectResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$result = $import->import($this->ownerRef($request));
|
||||||
|
|
||||||
|
return back()->with('success', "Imported {$result['imported']} product(s), updated {$result['updated']}.");
|
||||||
|
} catch (RuntimeException $e) {
|
||||||
|
return back()->with('error', $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function importMerchant(Request $request, MerchantCatalogImportService $import): RedirectResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$result = $import->import($this->ownerRef($request));
|
||||||
|
|
||||||
|
return back()->with('success', "Imported {$result['imported']} item(s) from {$result['storefronts']} storefront(s), updated {$result['updated']}.");
|
||||||
|
} catch (RuntimeException $e) {
|
||||||
|
return back()->with('error', $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Public;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\QrCode;
|
||||||
|
use App\Services\Mini\MiniPaymentService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class PaymentController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private MiniPaymentService $payments) {}
|
||||||
|
|
||||||
|
public function pay(Request $request, string $shortCode): JsonResponse|RedirectResponse
|
||||||
|
{
|
||||||
|
$qrCode = QrCode::query()
|
||||||
|
->with('user.qrSetting')
|
||||||
|
->where('short_code', $shortCode)
|
||||||
|
->where('type', QrCode::TYPE_PAYMENT)
|
||||||
|
->where('is_active', true)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'amount' => 'required|numeric|min:0.01',
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $this->payments->initiate($qrCode, $validated);
|
||||||
|
} catch (RuntimeException $e) {
|
||||||
|
if ($request->expectsJson()) {
|
||||||
|
return response()->json(['error' => $e->getMessage()], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->withInput()->with('error', $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->expectsJson()) {
|
||||||
|
return response()->json(['checkout_url' => $result['checkout_url']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->away($result['checkout_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 {
|
||||||
|
$payment = $this->payments->complete($reference);
|
||||||
|
} catch (RuntimeException $e) {
|
||||||
|
return redirect('/q/'.$shortCode)->with('error', $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('public.qr.payment-confirmed', [
|
||||||
|
'payment' => $payment,
|
||||||
|
'qrCode' => $payment->qrCode,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
<?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->isBookType()) {
|
||||||
|
return view('public.qr.book-landing', ['qrCode' => $qrCode]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($qrCode->isPaymentType()) {
|
||||||
|
return view('public.qr.payment-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->isBookType(), 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 churchLogo(string $shortCode): StreamedResponse
|
||||||
|
{
|
||||||
|
$qrCode = QrCode::query()
|
||||||
|
->where('short_code', $shortCode)
|
||||||
|
->where('is_active', true)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
abort_unless($qrCode->type === QrCode::TYPE_CHURCH, 404);
|
||||||
|
|
||||||
|
$path = $qrCode->content()['logo_path'] ?? null;
|
||||||
|
abort_unless($path && Storage::disk('qr')->exists($path), 404);
|
||||||
|
|
||||||
|
return Storage::disk('qr')->response($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function churchCover(string $shortCode): StreamedResponse
|
||||||
|
{
|
||||||
|
$qrCode = QrCode::query()
|
||||||
|
->where('short_code', $shortCode)
|
||||||
|
->where('is_active', true)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
abort_unless($qrCode->type === QrCode::TYPE_CHURCH, 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,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('mini.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::paymentTypes();
|
||||||
|
$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 won’t 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,454 @@
|
|||||||
|
<?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::eventTypes())
|
||||||
|
->withCount(['eventRegistrations as registrations_count' => fn ($q) => $q->where('status', QrEventRegistration::STATUS_CONFIRMED)])
|
||||||
|
->latest()
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$totalRegistrations = (int) $qrCodes->sum('registrations_count');
|
||||||
|
|
||||||
|
$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_EVENT);
|
||||||
|
if (! QrTypeCatalog::isValid($requestedType)) {
|
||||||
|
$requestedType = QrCode::TYPE_EVENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
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('events.show', $qrCode)
|
||||||
|
->with('success', QrTypeCatalog::label($qrCode->type).' created.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Request $request, QrCode $event): View
|
||||||
|
{
|
||||||
|
$this->authorize('view', $event);
|
||||||
|
|
||||||
|
$previewDataUri = $this->imageGenerator->previewDataUri($event);
|
||||||
|
$qrCode = $event->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 $event): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorize('update', $event);
|
||||||
|
$qrCode = $event;
|
||||||
|
|
||||||
|
$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 $event): Response
|
||||||
|
{
|
||||||
|
$this->authorize('view', $event);
|
||||||
|
|
||||||
|
$qrCode = $this->imageGenerator->ensureValidImages($event);
|
||||||
|
|
||||||
|
$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 $event, string $format): StreamedResponse
|
||||||
|
{
|
||||||
|
$this->authorize('view', $event);
|
||||||
|
|
||||||
|
$qrCode = $this->imageGenerator->ensureValidImages($event);
|
||||||
|
$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 $event): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('update', $event);
|
||||||
|
$qrCode = $event;
|
||||||
|
|
||||||
|
$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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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('mini.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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\MiniPayment;
|
||||||
|
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_PAYMENT)
|
||||||
|
->pluck('id');
|
||||||
|
|
||||||
|
if ($qrIds->isEmpty()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return MiniPayment::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('payer_note', '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 (MiniPayment $payment): array {
|
||||||
|
$amount = number_format(
|
||||||
|
($payment->status === MiniPayment::STATUS_PAID ? $payment->merchant_amount_minor : $payment->amount_minor) / 100,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'type' => 'payment',
|
||||||
|
'title' => $payment->payer_name ?: 'Walk-in customer',
|
||||||
|
'subtitle' => 'GHS '.$amount.' · '.($payment->qrCode?->label ?? 'Payment QR').' · '.ucfirst($payment->status),
|
||||||
|
'url' => route('mini.payments.index', ['q' => $payment->reference]),
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Services\Billing\BillingClient;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight wallet balance for the avatar dropdown widget.
|
||||||
|
*/
|
||||||
|
class WalletBalanceController extends Controller
|
||||||
|
{
|
||||||
|
public function balance(Request $request, BillingClient $billing): JsonResponse
|
||||||
|
{
|
||||||
|
$publicId = (string) $request->user()->public_id;
|
||||||
|
|
||||||
|
$minor = Cache::remember("wallet_balance:{$publicId}", now()->addSeconds(30), function () use ($billing, $publicId) {
|
||||||
|
try {
|
||||||
|
return $billing->balanceMinor($publicId);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if ($minor === null) {
|
||||||
|
return response()->json(['available' => false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$currency = (string) config('billing.currency', 'GHS');
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'available' => true,
|
||||||
|
'balance_minor' => $minor,
|
||||||
|
'currency' => $currency,
|
||||||
|
'formatted' => $currency.' '.number_format($minor / 100, 2),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\WellKnown;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class AssetLinksController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(): JsonResponse
|
||||||
|
{
|
||||||
|
$fingerprints = config('android_app_links.sha256_cert_fingerprints', []);
|
||||||
|
|
||||||
|
abort_if($fingerprints === [], 404);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
[
|
||||||
|
'relation' => ['delegate_permission/common.handle_all_urls'],
|
||||||
|
'target' => [
|
||||||
|
'namespace' => 'android_app',
|
||||||
|
'package_name' => config('android_app_links.package_name'),
|
||||||
|
'sha256_cert_fingerprints' => $fingerprints,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
], 200, [], JSON_UNESCAPED_SLASHES);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App sessions are subordinate to the platform (auth.ladill.com) session.
|
||||||
|
* When the platform session ends, clear this app's local session too.
|
||||||
|
*/
|
||||||
|
class EnsurePlatformSession
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
if (! Auth::check()) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
$authDomain = trim((string) config('app.auth_domain', ''));
|
||||||
|
if ($authDomain === '') {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
$cookieHeader = (string) $request->headers->get('Cookie', '');
|
||||||
|
if ($cookieHeader === '') {
|
||||||
|
return $this->clearAppSession($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = Http::withHeaders(['Cookie' => $cookieHeader])
|
||||||
|
->timeout(3)
|
||||||
|
->get('https://'.$authDomain.'/sso/ping');
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response->status() === 401) {
|
||||||
|
return $this->clearAppSession($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function clearAppSession(Request $request): Response
|
||||||
|
{
|
||||||
|
Auth::logout();
|
||||||
|
$request->session()->invalidate();
|
||||||
|
$request->session()->regenerateToken();
|
||||||
|
|
||||||
|
return redirect()->route('sso.connect', [
|
||||||
|
'redirect' => $request->fullUrl(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class MiniPayment extends Model
|
||||||
|
{
|
||||||
|
public const STATUS_PENDING = 'pending';
|
||||||
|
public const STATUS_PAID = 'paid';
|
||||||
|
public const STATUS_FAILED = 'failed';
|
||||||
|
public const STATUS_CANCELED = 'canceled';
|
||||||
|
|
||||||
|
/** Pending payments are auto-canceled after this many hours. */
|
||||||
|
public const STALE_PENDING_HOURS = 6;
|
||||||
|
|
||||||
|
/** Platform fee on trader payments (Ladill Mini tier). */
|
||||||
|
public const PLATFORM_FEE_RATE = 0.035;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'pay_order_id',
|
||||||
|
'qr_code_id',
|
||||||
|
'user_id',
|
||||||
|
'reference',
|
||||||
|
'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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class PosLocation extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'owner_ref',
|
||||||
|
'name',
|
||||||
|
'currency',
|
||||||
|
'receipt_footer',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function products(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(PosProduct::class, 'location_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sales(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(PosSale::class, 'location_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeOwned(Builder $query, string $ownerRef): Builder
|
||||||
|
{
|
||||||
|
return $query->where('owner_ref', $ownerRef);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class PosProduct extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'owner_ref',
|
||||||
|
'location_id',
|
||||||
|
'name',
|
||||||
|
'sku',
|
||||||
|
'price_minor',
|
||||||
|
'currency',
|
||||||
|
'is_active',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'price_minor' => 'integer',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function location(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(PosLocation::class, 'location_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeOwned(Builder $query, string $ownerRef): Builder
|
||||||
|
{
|
||||||
|
return $query->where('owner_ref', $ownerRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeActive(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where('is_active', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class PosSale extends Model
|
||||||
|
{
|
||||||
|
public const STATUS_PENDING = 'pending';
|
||||||
|
|
||||||
|
public const STATUS_PAID = 'paid';
|
||||||
|
|
||||||
|
public const STATUS_FAILED = 'failed';
|
||||||
|
|
||||||
|
public const STATUS_CANCELLED = 'cancelled';
|
||||||
|
|
||||||
|
public const METHOD_PAY = 'pay';
|
||||||
|
|
||||||
|
public const METHOD_CASH = 'cash';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'owner_ref',
|
||||||
|
'location_id',
|
||||||
|
'reference',
|
||||||
|
'status',
|
||||||
|
'payment_method',
|
||||||
|
'pay_order_id',
|
||||||
|
'payment_reference',
|
||||||
|
'customer_name',
|
||||||
|
'customer_email',
|
||||||
|
'customer_phone',
|
||||||
|
'crm_customer_id',
|
||||||
|
'subtotal_minor',
|
||||||
|
'total_minor',
|
||||||
|
'currency',
|
||||||
|
'paid_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'subtotal_minor' => 'integer',
|
||||||
|
'total_minor' => 'integer',
|
||||||
|
'pay_order_id' => 'integer',
|
||||||
|
'crm_customer_id' => 'integer',
|
||||||
|
'paid_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function location(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(PosLocation::class, 'location_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function lines(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(PosSaleLine::class)->orderBy('position');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isPaid(): bool
|
||||||
|
{
|
||||||
|
return $this->status === self::STATUS_PAID;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeOwned(Builder $query, string $ownerRef): Builder
|
||||||
|
{
|
||||||
|
return $query->where('owner_ref', $ownerRef);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class PosSaleLine extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'pos_sale_id',
|
||||||
|
'product_id',
|
||||||
|
'name',
|
||||||
|
'unit_price_minor',
|
||||||
|
'quantity',
|
||||||
|
'line_total_minor',
|
||||||
|
'position',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'unit_price_minor' => 'integer',
|
||||||
|
'quantity' => 'integer',
|
||||||
|
'line_total_minor' => 'integer',
|
||||||
|
'position' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sale(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(PosSale::class, 'pos_sale_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function product(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(PosProduct::class, 'product_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
<?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_CHURCH = 'church';
|
||||||
|
public const TYPE_MENU = 'menu';
|
||||||
|
public const TYPE_SHOP = 'shop';
|
||||||
|
public const TYPE_APP = 'app';
|
||||||
|
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(MiniPayment::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 isBookType(): bool
|
||||||
|
{
|
||||||
|
return $this->type === self::TYPE_BOOK;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function acceptsOrders(): bool
|
||||||
|
{
|
||||||
|
return in_array($this->type, [self::TYPE_MENU, self::TYPE_SHOP, self::TYPE_CHURCH, self::TYPE_EVENT], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isPaymentType(): bool
|
||||||
|
{
|
||||||
|
return $this->type === self::TYPE_PAYMENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function usesLandingPage(): bool
|
||||||
|
{
|
||||||
|
return in_array($this->type, [
|
||||||
|
self::TYPE_PAYMENT,
|
||||||
|
], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
<?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',
|
||||||
|
'auto_withdraw_amount_minor',
|
||||||
|
'default_type',
|
||||||
|
'default_style',
|
||||||
|
'event_defaults',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'product_updates' => 'boolean',
|
||||||
|
'low_balance_alerts' => 'boolean',
|
||||||
|
'notify_registrations' => 'boolean',
|
||||||
|
'notify_payouts' => 'boolean',
|
||||||
|
'auto_withdraw_amount_minor' => 'integer',
|
||||||
|
'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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<?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', 'last_app_active_at'];
|
||||||
|
|
||||||
|
protected $hidden = ['password', 'remember_token'];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'email_verified_at' => 'datetime',
|
||||||
|
'last_app_active_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 pushTokens(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(UserPushToken::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,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class UserPushToken extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'token',
|
||||||
|
'platform',
|
||||||
|
'device_name',
|
||||||
|
'last_seen_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'last_seen_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Notifications\Mini;
|
||||||
|
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
|
||||||
|
class MiniAlertNotification extends Notification implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $extra
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly string $title,
|
||||||
|
private readonly string $message,
|
||||||
|
private readonly string $icon,
|
||||||
|
private readonly ?string $url,
|
||||||
|
private readonly string $milestone,
|
||||||
|
private readonly array $extra = [],
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
public function via(object $notifiable): array
|
||||||
|
{
|
||||||
|
return ['database'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(object $notifiable): array
|
||||||
|
{
|
||||||
|
return array_merge([
|
||||||
|
'title' => $this->title,
|
||||||
|
'message' => $this->message,
|
||||||
|
'icon' => $this->icon,
|
||||||
|
'url' => $this->url,
|
||||||
|
'milestone' => $this->milestone,
|
||||||
|
], $this->extra);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<?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 $this->miniSystemPrompt($ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function miniSystemPrompt(string $ctx): string
|
||||||
|
{
|
||||||
|
return <<<PROMPT
|
||||||
|
You are Afia, the assistant inside Ladill Mini (mini.ladill.com).
|
||||||
|
Help users create payment QR codes, accept payments, and get paid out. Be concise, friendly, and actionable: short numbered steps and name the screen or section to use.
|
||||||
|
|
||||||
|
What Ladill Mini does and where things live:
|
||||||
|
- Overview (Dashboard): recent payments, active payment QRs, totals collected, and wallet balance.
|
||||||
|
- Payment QRs: create a payment QR — set a fixed amount (or let payers enter one) and a label, then publish and share the link or QR for customers to scan and pay.
|
||||||
|
- Payments: incoming payments from your QRs — view payer, amount, and status.
|
||||||
|
- Payouts: collected funds land in your Ladill wallet; request a withdrawal to your bank or mobile money from Payouts.
|
||||||
|
- Fees: Ladill takes a 3.5% platform fee on payments; the remainder is yours.
|
||||||
|
- Account & Wallet: check balance or top up at account.ladill.com/wallet.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Only answer questions about Ladill Mini — payment QRs, payments, payouts, fees, and sharing.
|
||||||
|
- If asked about domains, hosting, email, plain QR codes, storefronts, donations, or events, briefly say those live in other Ladill apps and suggest the app launcher.
|
||||||
|
- Never invent payment amounts, payout amounts, or wallet balances — use the user context below or tell them where to check.
|
||||||
|
|
||||||
|
Current user context:
|
||||||
|
{$ctx}
|
||||||
|
PROMPT;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Billing;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client for the platform Billing HTTP API — the one UserWallet lives on the
|
||||||
|
* platform; Bird only consumes it. Amounts are integer minor units; the user is
|
||||||
|
* identified by public_id. Authenticates with the per-consumer config('billing.service') key.
|
||||||
|
* Contract validated in the monolith (smtp-extraction-runbook §4-A).
|
||||||
|
*/
|
||||||
|
class BillingClient
|
||||||
|
{
|
||||||
|
private function base(): string
|
||||||
|
{
|
||||||
|
return rtrim((string) config('billing.api_url'), '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function token(): string
|
||||||
|
{
|
||||||
|
return (string) (config('billing.api_key') ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function get(string $path, array $query): array
|
||||||
|
{
|
||||||
|
$res = Http::withToken($this->token())->acceptJson()->timeout(10)->get($this->base().$path, $query);
|
||||||
|
$res->throw();
|
||||||
|
|
||||||
|
return (array) $res->json();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function balanceMinor(string $publicId): int
|
||||||
|
{
|
||||||
|
return (int) ($this->get('/balance', ['user' => $publicId])['balance_minor'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canAfford(string $publicId, int $amountMinor): bool
|
||||||
|
{
|
||||||
|
return (bool) ($this->get('/can-afford', ['user' => $publicId, 'amount_minor' => $amountMinor])['affordable'] ?? false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function serviceLedger(string $publicId, string $service): array
|
||||||
|
{
|
||||||
|
return $this->get('/service-ledger', ['user' => $publicId, 'service' => $service]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debit the wallet. Returns true on success, false on insufficient balance
|
||||||
|
* (HTTP 402). Idempotent by $reference.
|
||||||
|
*/
|
||||||
|
public function debit(string $publicId, int $amountMinor, string $service, string $source, string $reference, ?int $serviceId = null, ?string $description = null): bool
|
||||||
|
{
|
||||||
|
$res = Http::withToken($this->token())->acceptJson()->timeout(10)->post($this->base().'/debit', array_filter([
|
||||||
|
'user' => $publicId,
|
||||||
|
'amount_minor' => $amountMinor,
|
||||||
|
'service' => $service,
|
||||||
|
'source' => $source,
|
||||||
|
'reference' => $reference,
|
||||||
|
'service_id' => $serviceId,
|
||||||
|
'description' => $description,
|
||||||
|
], static fn ($v) => $v !== null));
|
||||||
|
|
||||||
|
if ($res->status() === 402) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$res->throw();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function credit(string $publicId, int $amountMinor, string $service, string $source, string $reference, ?int $serviceId = null, ?string $description = null): array
|
||||||
|
{
|
||||||
|
$res = Http::withToken($this->token())->acceptJson()->timeout(10)->post($this->base().'/credit', array_filter([
|
||||||
|
'user' => $publicId,
|
||||||
|
'amount_minor' => $amountMinor,
|
||||||
|
'service' => $service,
|
||||||
|
'source' => $source,
|
||||||
|
'reference' => $reference,
|
||||||
|
'service_id' => $serviceId,
|
||||||
|
'description' => $description,
|
||||||
|
], static fn ($v) => $v !== null));
|
||||||
|
$res->throw();
|
||||||
|
|
||||||
|
return (array) $res->json();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Billing;
|
||||||
|
|
||||||
|
use App\Models\PlatformSetting;
|
||||||
|
use Illuminate\Http\Client\Response;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class PaystackService
|
||||||
|
{
|
||||||
|
public function publicKey(): string
|
||||||
|
{
|
||||||
|
return (string) $this->settingValue('paystack_public_key', config('services.paystack.public_key', ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function initializeTransaction(array $payload): array
|
||||||
|
{
|
||||||
|
return $this->initializeTransactionWithCredentials($payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function initializeTransactionWithCredentials(array $payload, ?string $secretKey = null, ?string $baseUrl = null): array
|
||||||
|
{
|
||||||
|
$response = $this->request(secretKey: $secretKey, baseUrl: $baseUrl)
|
||||||
|
->post('/transaction/initialize', $payload);
|
||||||
|
|
||||||
|
return $this->extractData($response, 'Failed to initialize Paystack transaction.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verifyTransaction(string $reference): array
|
||||||
|
{
|
||||||
|
return $this->verifyTransactionWithCredentials($reference);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verifyTransactionWithCredentials(string $reference, ?string $secretKey = null, ?string $baseUrl = null): array
|
||||||
|
{
|
||||||
|
$response = $this->request(secretKey: $secretKey, baseUrl: $baseUrl)
|
||||||
|
->get('/transaction/verify/' . urlencode($reference));
|
||||||
|
|
||||||
|
return $this->extractData($response, 'Failed to verify Paystack transaction.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verifyWebhookSignature(string $rawBody, ?string $signature): bool
|
||||||
|
{
|
||||||
|
$secret = (string) $this->settingValue('paystack_webhook_secret', config('services.paystack.webhook_secret'));
|
||||||
|
if ($secret === '') {
|
||||||
|
$secret = (string) $this->settingValue('paystack_secret_key', config('services.paystack.secret_key'));
|
||||||
|
}
|
||||||
|
if ($secret === '' || !$signature) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$computed = hash_hmac('sha512', $rawBody, $secret);
|
||||||
|
return hash_equals($computed, $signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function request(?string $secretKey = null, ?string $baseUrl = null)
|
||||||
|
{
|
||||||
|
$resolvedBaseUrl = rtrim((string) ($baseUrl ?: $this->settingValue('paystack_base_url', config('services.paystack.base_url', 'https://api.paystack.co'))), '/');
|
||||||
|
$secret = $this->normalizeSecretKey(
|
||||||
|
(string) ($secretKey ?: $this->settingValue('paystack_secret_key', config('services.paystack.secret_key')))
|
||||||
|
);
|
||||||
|
|
||||||
|
return Http::baseUrl($resolvedBaseUrl)
|
||||||
|
->withToken($secret)
|
||||||
|
->acceptJson()
|
||||||
|
->asJson();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function settingsConnection(): string
|
||||||
|
{
|
||||||
|
return (string) config('billing.platform_settings_connection', 'platform');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function settingValue(string $key, mixed $fallback = null): mixed
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$connection = $this->settingsConnection();
|
||||||
|
|
||||||
|
if (! Schema::connection($connection)->hasTable('platform_settings')) {
|
||||||
|
return $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
$setting = PlatformSetting::query()
|
||||||
|
->where('key', $key)
|
||||||
|
->where('is_active', true)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return $setting ? ($setting->value['value'] ?? $fallback) : $fallback;
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return $fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function extractData(Response $response, string $message): array
|
||||||
|
{
|
||||||
|
if ($response->failed()) {
|
||||||
|
throw new RuntimeException($message);
|
||||||
|
}
|
||||||
|
|
||||||
|
$json = $response->json();
|
||||||
|
if (!is_array($json) || !($json['status'] ?? false)) {
|
||||||
|
throw new RuntimeException((string) ($json['message'] ?? $message));
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $json['data'] ?? null;
|
||||||
|
return is_array($data) ? $data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List supported banks / mobile money providers.
|
||||||
|
* @param string $country e.g. 'ghana', 'nigeria'
|
||||||
|
* @param string $type 'nuban', 'mobile_money', or '' for all
|
||||||
|
*/
|
||||||
|
public function listBanks(string $country = 'ghana', string $type = '', ?string $currency = null): array
|
||||||
|
{
|
||||||
|
$query = ['perPage' => 200];
|
||||||
|
if ($currency !== null && $currency !== '') {
|
||||||
|
$query['currency'] = strtoupper($currency);
|
||||||
|
} else {
|
||||||
|
$query['country'] = $country;
|
||||||
|
}
|
||||||
|
if ($type !== '') {
|
||||||
|
$query['type'] = $type;
|
||||||
|
}
|
||||||
|
$response = $this->request()->get('/bank', $query);
|
||||||
|
$json = $response->json();
|
||||||
|
return is_array($json['data'] ?? null) ? $json['data'] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a transfer recipient (bank account or mobile money).
|
||||||
|
* @param array{type: string, name: string, account_number: string, bank_code: string, currency?: string} $payload
|
||||||
|
*/
|
||||||
|
public function createTransferRecipient(array $payload): array
|
||||||
|
{
|
||||||
|
$response = $this->request()->post('/transferrecipient', $payload);
|
||||||
|
return $this->extractData($response, 'Failed to create transfer recipient.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initiate a transfer to a recipient.
|
||||||
|
* @param array{source: string, amount: int, recipient: string, reason?: string, reference?: string} $payload
|
||||||
|
*/
|
||||||
|
public function initiateTransfer(array $payload): array
|
||||||
|
{
|
||||||
|
$response = $this->request()->post('/transfer', $payload);
|
||||||
|
return $this->extractData($response, 'Failed to initiate transfer.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a transfer by reference.
|
||||||
|
*/
|
||||||
|
public function verifyTransfer(string $reference): array
|
||||||
|
{
|
||||||
|
$response = $this->request()->get('/transfer/verify/' . urlencode($reference));
|
||||||
|
return $this->extractData($response, 'Failed to verify transfer.');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function normalizeSecretKey(string $secret): string
|
||||||
|
{
|
||||||
|
$normalized = trim($secret);
|
||||||
|
|
||||||
|
if (str_starts_with(strtolower($normalized), 'bearer ')) {
|
||||||
|
$normalized = trim(substr($normalized, 7));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Billing;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class SmsService
|
||||||
|
{
|
||||||
|
public function send(string $to, string $message): void
|
||||||
|
{
|
||||||
|
$apiKey = config('services.termii.api_key', '');
|
||||||
|
$senderId = config('services.termii.sender_id', config('app.name', 'LaDill'));
|
||||||
|
|
||||||
|
if ($apiKey === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$phone = preg_replace('/\D+/', '', $to);
|
||||||
|
if (strlen($phone) < 9) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strlen($phone) === 9) {
|
||||||
|
$phone = '233'.$phone;
|
||||||
|
} elseif (str_starts_with($phone, '0')) {
|
||||||
|
$phone = '233'.substr($phone, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Http::timeout(10)->post('https://v3.api.termii.com/api/sms/send', [
|
||||||
|
'api_key' => $apiKey,
|
||||||
|
'to' => $phone,
|
||||||
|
'from' => $senderId,
|
||||||
|
'sms' => $message,
|
||||||
|
'type' => 'plain',
|
||||||
|
'channel' => 'dnd',
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('SMS send failed', ['to' => $phone, 'error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Crm;
|
||||||
|
|
||||||
|
use Illuminate\Http\Client\ConnectionException;
|
||||||
|
use Illuminate\Http\Client\PendingRequest;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class CrmClient
|
||||||
|
{
|
||||||
|
public function __construct(private readonly string $owner) {}
|
||||||
|
|
||||||
|
public static function for(string $owner): self
|
||||||
|
{
|
||||||
|
return new self($owner);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function customers(array $filters = []): array
|
||||||
|
{
|
||||||
|
return $this->get('customers', $filters);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function products(array $filters = []): array
|
||||||
|
{
|
||||||
|
return $this->get('products', $filters);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pushTimeline(array $data): array
|
||||||
|
{
|
||||||
|
return $this->post('timeline', $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function client(): PendingRequest
|
||||||
|
{
|
||||||
|
return Http::baseUrl((string) config('crm.url'))
|
||||||
|
->withToken((string) config('crm.key'))
|
||||||
|
->acceptJson()
|
||||||
|
->asJson()
|
||||||
|
->connectTimeout(10)
|
||||||
|
->timeout(20);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function get(string $path, array $query = []): array
|
||||||
|
{
|
||||||
|
return $this->handle(fn () => $this->client()->get($path, [...$query, 'owner' => $this->owner]));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function post(string $path, array $data): array
|
||||||
|
{
|
||||||
|
return $this->handle(fn () => $this->client()->post($path, [...$data, 'owner' => $this->owner]));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handle(callable $request): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$response = $request();
|
||||||
|
} catch (ConnectionException) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'crm' => ['Could not reach the CRM service. Please try again in a moment.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
abort($response->status() === 404 ? 404 : 502, 'CRM service error.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (array) $response->json();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\CrossApp;
|
||||||
|
|
||||||
|
use App\Models\PosSale;
|
||||||
|
use App\Support\CrmPrefillCodec;
|
||||||
|
use App\Support\LadillAppUrl;
|
||||||
|
|
||||||
|
class CrossAppLinkService
|
||||||
|
{
|
||||||
|
public function invoiceFromSale(PosSale $sale): string
|
||||||
|
{
|
||||||
|
$sale->loadMissing('lines');
|
||||||
|
|
||||||
|
$lines = $sale->lines->map(fn ($line) => [
|
||||||
|
'description' => $line->name,
|
||||||
|
'quantity' => (float) $line->quantity,
|
||||||
|
'unit_price' => number_format($line->unit_price_minor / 100, 2, '.', ''),
|
||||||
|
])->values()->all();
|
||||||
|
|
||||||
|
$prefill = CrmPrefillCodec::encode([
|
||||||
|
'kind' => 'pos_sale',
|
||||||
|
'crm_customer_id' => $sale->crm_customer_id,
|
||||||
|
'client_name' => $sale->customer_name ?: 'Walk-in customer',
|
||||||
|
'client_email' => $sale->customer_email,
|
||||||
|
'notes' => 'Receipt for POS sale '.$sale->reference,
|
||||||
|
'payment_enabled' => false,
|
||||||
|
'lines' => $lines,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return LadillAppUrl::connect('invoice', '/invoices/create?prefill='.urlencode($prefill));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Import;
|
||||||
|
|
||||||
|
use App\Models\PosProduct;
|
||||||
|
use App\Services\Crm\CrmClient;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class CrmProductImportService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array{imported: int, updated: int}
|
||||||
|
*/
|
||||||
|
public function import(string $ownerRef): array
|
||||||
|
{
|
||||||
|
$response = CrmClient::for($ownerRef)->products(['active' => 1, 'per_page' => 500]);
|
||||||
|
$rows = (array) ($response['data'] ?? []);
|
||||||
|
|
||||||
|
if ($rows === []) {
|
||||||
|
throw new RuntimeException('No active products found in CRM.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$imported = 0;
|
||||||
|
$updated = 0;
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$name = trim((string) ($row['name'] ?? ''));
|
||||||
|
if ($name === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sku = isset($row['sku']) && $row['sku'] !== '' ? (string) $row['sku'] : null;
|
||||||
|
$matchQuery = PosProduct::owned($ownerRef)->where('name', $name);
|
||||||
|
if ($sku) {
|
||||||
|
$matchQuery = PosProduct::owned($ownerRef)->where(fn ($q) => $q->where('sku', $sku)->orWhere('name', $name));
|
||||||
|
}
|
||||||
|
$existing = $matchQuery->first();
|
||||||
|
|
||||||
|
$attrs = [
|
||||||
|
'name' => $name,
|
||||||
|
'sku' => $sku,
|
||||||
|
'price_minor' => max(0, (int) ($row['unit_price_minor'] ?? 0)),
|
||||||
|
'currency' => strtoupper((string) ($row['currency'] ?? config('pos.default_currency', 'GHS'))),
|
||||||
|
'is_active' => (bool) ($row['active'] ?? true),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
$existing->update($attrs);
|
||||||
|
$updated++;
|
||||||
|
} else {
|
||||||
|
PosProduct::create([...$attrs, 'owner_ref' => $ownerRef]);
|
||||||
|
$imported++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return compact('imported', 'updated');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Import;
|
||||||
|
|
||||||
|
use App\Models\PosProduct;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class MerchantCatalogImportService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array{imported: int, updated: int, storefronts: int}
|
||||||
|
*/
|
||||||
|
public function import(string $ownerRef): array
|
||||||
|
{
|
||||||
|
if (! config('pos.merchant_import_enabled', true)) {
|
||||||
|
throw new RuntimeException('Merchant catalog import is not configured.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$connection = config('database.connections.merchant.database') ? 'merchant' : null;
|
||||||
|
if ($connection === null || ! $this->connectionReady($connection)) {
|
||||||
|
throw new RuntimeException('Merchant database connection is not available.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$storefronts = DB::connection($connection)
|
||||||
|
->table('qr_codes')
|
||||||
|
->join('users', 'users.id', '=', 'qr_codes.user_id')
|
||||||
|
->where('users.public_id', $ownerRef)
|
||||||
|
->where('qr_codes.is_active', true)
|
||||||
|
->whereIn('qr_codes.type', ['shop', 'menu'])
|
||||||
|
->select('qr_codes.id', 'qr_codes.payload', 'qr_codes.label')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($storefronts->isEmpty()) {
|
||||||
|
throw new RuntimeException('No active Merchant storefronts found for this account.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$imported = 0;
|
||||||
|
$updated = 0;
|
||||||
|
|
||||||
|
foreach ($storefronts as $storefront) {
|
||||||
|
$payload = json_decode((string) $storefront->payload, true);
|
||||||
|
$content = is_array($payload) ? (array) ($payload['content'] ?? []) : [];
|
||||||
|
$sections = (array) ($content['sections'] ?? []);
|
||||||
|
|
||||||
|
foreach ($sections as $section) {
|
||||||
|
foreach ((array) ($section['items'] ?? []) as $item) {
|
||||||
|
$name = trim((string) ($item['name'] ?? ''));
|
||||||
|
$priceGhs = (float) ($item['price'] ?? 0);
|
||||||
|
if ($name === '' || $priceGhs <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$priceMinor = (int) round($priceGhs * 100);
|
||||||
|
$sku = 'm'.$storefront->id.'-'.substr(md5($name), 0, 8);
|
||||||
|
|
||||||
|
$existing = PosProduct::owned($ownerRef)->where('sku', $sku)->first();
|
||||||
|
$attrs = [
|
||||||
|
'name' => $name,
|
||||||
|
'sku' => $sku,
|
||||||
|
'price_minor' => $priceMinor,
|
||||||
|
'currency' => config('pos.default_currency', 'GHS'),
|
||||||
|
'is_active' => true,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
$existing->update($attrs);
|
||||||
|
$updated++;
|
||||||
|
} else {
|
||||||
|
PosProduct::create([...$attrs, 'owner_ref' => $ownerRef]);
|
||||||
|
$imported++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($imported === 0 && $updated === 0) {
|
||||||
|
throw new RuntimeException('No catalog items found in Merchant storefronts.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'imported' => $imported,
|
||||||
|
'updated' => $updated,
|
||||||
|
'storefronts' => $storefronts->count(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function connectionReady(string $connection): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
DB::connection($connection)->getPdo();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Mini;
|
||||||
|
|
||||||
|
use App\Models\QrSetting;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Billing\BillingClient;
|
||||||
|
use App\Services\Notifications\MiniNotificationService;
|
||||||
|
use Illuminate\Http\Client\Response as HttpResponse;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class AutoWithdrawService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private BillingClient $billing,
|
||||||
|
private MiniNotificationService $notifications,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function attemptForUser(User $user): bool
|
||||||
|
{
|
||||||
|
$settings = $user->getOrCreateQrSetting();
|
||||||
|
$thresholdMinor = $settings->auto_withdraw_amount_minor;
|
||||||
|
|
||||||
|
if ($thresholdMinor === null || $thresholdMinor < 100) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$balanceMinor = $this->billing->balanceMinor($user->public_id);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::warning('Auto-withdraw balance check failed', [
|
||||||
|
'user' => $user->public_id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($balanceMinor < $thresholdMinor) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->hasPayoutAccount($user)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->hasPendingWithdrawal($user)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$amountMajor = round($balanceMinor / 100, 2);
|
||||||
|
if ($amountMajor < 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = $this->identitySend('POST', '/api/identity/wallet/withdraw', [
|
||||||
|
'user' => $user->public_id,
|
||||||
|
'amount' => $amountMajor,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! $response->successful()) {
|
||||||
|
Log::warning('Auto-withdraw failed', [
|
||||||
|
'user' => $user->public_id,
|
||||||
|
'status' => $response->status(),
|
||||||
|
'body' => $response->body(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->notifications->withdrawalSubmitted($user, $amountMajor);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::warning('Auto-withdraw exception', [
|
||||||
|
'user' => $user->public_id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function processAll(): int
|
||||||
|
{
|
||||||
|
$count = 0;
|
||||||
|
|
||||||
|
QrSetting::query()
|
||||||
|
->whereNotNull('auto_withdraw_amount_minor')
|
||||||
|
->where('auto_withdraw_amount_minor', '>=', 100)
|
||||||
|
->with('user')
|
||||||
|
->chunkById(50, function ($settings) use (&$count) {
|
||||||
|
foreach ($settings as $setting) {
|
||||||
|
$user = $setting->user;
|
||||||
|
if ($user && $this->attemptForUser($user)) {
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return $count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hasPayoutAccount(User $user): bool
|
||||||
|
{
|
||||||
|
$response = $this->identitySend(
|
||||||
|
'GET',
|
||||||
|
'/api/identity/payout-account?user='.urlencode((string) $user->public_id),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $response->successful()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return filled($response->json('data.payout_account.account_number'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hasPendingWithdrawal(User $user): bool
|
||||||
|
{
|
||||||
|
$response = $this->identitySend(
|
||||||
|
'GET',
|
||||||
|
'/api/identity/wallet/withdrawals?user='.urlencode((string) $user->public_id),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $response->successful()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return collect($response->json('data', []))
|
||||||
|
->contains(fn ($withdrawal) => in_array($withdrawal['status'] ?? '', ['pending', 'processing'], true));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function identitySend(string $method, string $path, array $payload): HttpResponse
|
||||||
|
{
|
||||||
|
return Http::baseUrl(rtrim((string) config('services.ladill_identity.url'), '/'))
|
||||||
|
->withToken((string) config('services.ladill_identity.key'))
|
||||||
|
->connectTimeout(10)
|
||||||
|
->timeout(20)
|
||||||
|
->acceptJson()
|
||||||
|
->asJson()
|
||||||
|
->send($method, $path, ['json' => $payload]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Mini;
|
||||||
|
|
||||||
|
use App\Models\MiniPayment;
|
||||||
|
use App\Models\QrCode;
|
||||||
|
use App\Services\Billing\BillingClient;
|
||||||
|
use App\Services\Billing\PaystackService;
|
||||||
|
use App\Services\Billing\SmsService;
|
||||||
|
use App\Services\Notifications\MiniNotificationService;
|
||||||
|
use App\Services\Pay\PayClient;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class MiniPaymentService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private PayClient $pay,
|
||||||
|
private PaystackService $paystack,
|
||||||
|
private BillingClient $billing,
|
||||||
|
private SmsService $sms,
|
||||||
|
private MiniNotificationService $notifications,
|
||||||
|
private AutoWithdrawService $autoWithdraw,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{amount: float} $data
|
||||||
|
* @return array{payment: MiniPayment, checkout_url: string}
|
||||||
|
*/
|
||||||
|
public function initiate(QrCode $qrCode, array $data): array
|
||||||
|
{
|
||||||
|
if ($qrCode->type !== QrCode::TYPE_PAYMENT) {
|
||||||
|
throw new RuntimeException('This QR is not a payment code.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$amountGhs = round((float) ($data['amount'] ?? 0), 2);
|
||||||
|
if ($amountGhs <= 0) {
|
||||||
|
throw new RuntimeException('Enter an amount greater than zero.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$qrCode->loadMissing('user');
|
||||||
|
$amountMinor = (int) round($amountGhs * 100);
|
||||||
|
$reference = 'MINP-'.strtoupper(Str::random(16));
|
||||||
|
$businessName = $qrCode->content()['business_name'] ?? $qrCode->label ?? 'Payment';
|
||||||
|
|
||||||
|
$payment = MiniPayment::create([
|
||||||
|
'qr_code_id' => $qrCode->id,
|
||||||
|
'user_id' => $qrCode->user_id,
|
||||||
|
'reference' => $reference,
|
||||||
|
'amount_minor' => $amountMinor,
|
||||||
|
'currency' => $qrCode->content()['currency'] ?? 'GHS',
|
||||||
|
'payer_name' => null,
|
||||||
|
'payer_email' => null,
|
||||||
|
'payer_phone' => null,
|
||||||
|
'payer_note' => null,
|
||||||
|
'status' => MiniPayment::STATUS_PENDING,
|
||||||
|
'payment_reference' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$payOrder = $this->pay->createCheckout([
|
||||||
|
'merchant' => $qrCode->user->public_id,
|
||||||
|
'fee_tier' => 'payments',
|
||||||
|
'source_service' => 'mini',
|
||||||
|
'source_ref' => (string) $qrCode->id,
|
||||||
|
'callback_url' => route('qr.public.payment.callback', ['shortCode' => $qrCode->short_code]),
|
||||||
|
'line_items' => [
|
||||||
|
[
|
||||||
|
'name' => $businessName,
|
||||||
|
'unit_price_minor' => $amountMinor,
|
||||||
|
'quantity' => 1,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'metadata' => [
|
||||||
|
'mini_payment_id' => $payment->id,
|
||||||
|
'mini_reference' => $reference,
|
||||||
|
'qr_code_id' => $qrCode->id,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$payment->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 [
|
||||||
|
'payment' => $payment->fresh(),
|
||||||
|
'checkout_url' => $checkoutUrl,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function complete(string $paymentReference): MiniPayment
|
||||||
|
{
|
||||||
|
if (str_starts_with($paymentReference, 'LP-')) {
|
||||||
|
return $this->completeLadillPay($paymentReference);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->completeLegacy($paymentReference);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function completeLadillPay(string $reference): MiniPayment
|
||||||
|
{
|
||||||
|
$payment = MiniPayment::where('payment_reference', $reference)
|
||||||
|
->where('status', MiniPayment::STATUS_PENDING)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$payOrder = $this->pay->verify($reference);
|
||||||
|
|
||||||
|
$payment->update([
|
||||||
|
'status' => MiniPayment::STATUS_PAID,
|
||||||
|
'amount_minor' => (int) ($payOrder['amount_minor'] ?? $payment->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'] ?? $payment->pay_order_id,
|
||||||
|
'paid_at' => now(),
|
||||||
|
'metadata' => array_merge((array) $payment->metadata, ['ladill_pay' => $payOrder]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$payment = $payment->fresh(['qrCode', 'merchant']);
|
||||||
|
$this->notifyPayer($payment);
|
||||||
|
$this->notifications->paymentReceived($payment);
|
||||||
|
$this->autoWithdraw->attemptForUser($payment->merchant);
|
||||||
|
|
||||||
|
return $payment;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legacy MIN-* references before Ladill Pay migration. */
|
||||||
|
private function completeLegacy(string $paymentReference): MiniPayment
|
||||||
|
{
|
||||||
|
$payment = MiniPayment::where('payment_reference', $paymentReference)
|
||||||
|
->where('status', MiniPayment::STATUS_PENDING)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$data = $this->paystack->verifyTransaction($paymentReference);
|
||||||
|
|
||||||
|
if (($data['status'] ?? '') !== 'success') {
|
||||||
|
$payment->update(['status' => MiniPayment::STATUS_FAILED]);
|
||||||
|
throw new RuntimeException('Payment was not successful.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$paidMinor = (int) ($data['amount'] ?? $payment->amount_minor);
|
||||||
|
$platformFeeMinor = (int) round($paidMinor * MiniPayment::PLATFORM_FEE_RATE);
|
||||||
|
$merchantMinor = $paidMinor - $platformFeeMinor;
|
||||||
|
|
||||||
|
$payment->update([
|
||||||
|
'status' => MiniPayment::STATUS_PAID,
|
||||||
|
'amount_minor' => $paidMinor,
|
||||||
|
'platform_fee_minor' => $platformFeeMinor,
|
||||||
|
'merchant_amount_minor' => $merchantMinor,
|
||||||
|
'paid_at' => now(),
|
||||||
|
'metadata' => array_merge((array) $payment->metadata, ['paystack' => $data, 'legacy' => true]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$businessName = $payment->qrCode?->content()['business_name'] ?? $payment->qrCode?->label ?? 'Payment QR';
|
||||||
|
$this->billing->credit(
|
||||||
|
$payment->merchant->public_id,
|
||||||
|
$merchantMinor,
|
||||||
|
'mini',
|
||||||
|
'pay',
|
||||||
|
$paymentReference,
|
||||||
|
$payment->id,
|
||||||
|
sprintf('Payment via %s', $businessName),
|
||||||
|
);
|
||||||
|
|
||||||
|
$payment = $payment->fresh(['qrCode', 'merchant']);
|
||||||
|
$this->notifyPayer($payment);
|
||||||
|
$this->notifications->paymentReceived($payment);
|
||||||
|
$this->autoWithdraw->attemptForUser($payment->merchant);
|
||||||
|
|
||||||
|
return $payment;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function notifyPayer(MiniPayment $payment): void
|
||||||
|
{
|
||||||
|
if (! $payment->payer_phone) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$businessName = $payment->qrCode?->content()['business_name'] ?? $payment->qrCode?->label ?? 'Payment QR';
|
||||||
|
|
||||||
|
$this->sms->send(
|
||||||
|
$payment->payer_phone,
|
||||||
|
sprintf(
|
||||||
|
'Payment of %s %s to %s confirmed. Ref: %s',
|
||||||
|
$payment->currency,
|
||||||
|
number_format($payment->amount_minor / 100, 2),
|
||||||
|
$businessName,
|
||||||
|
$payment->reference
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Notifications;
|
||||||
|
|
||||||
|
use Illuminate\Http\Client\Response;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class FcmService
|
||||||
|
{
|
||||||
|
public const DELIVERED = 'ok';
|
||||||
|
|
||||||
|
public const INVALID_TOKEN = 'invalid_token';
|
||||||
|
|
||||||
|
public const FAILED = 'failed';
|
||||||
|
|
||||||
|
public function isConfigured(): bool
|
||||||
|
{
|
||||||
|
return filled(config('services.fcm.project_id'))
|
||||||
|
&& filled(config('services.fcm.service_account_json'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return self::DELIVERED|self::INVALID_TOKEN|self::FAILED
|
||||||
|
*/
|
||||||
|
public function send(string $fcmToken, string $title, string $body, array $data = []): string
|
||||||
|
{
|
||||||
|
if (! $this->isConfigured()) {
|
||||||
|
return self::FAILED;
|
||||||
|
}
|
||||||
|
|
||||||
|
$projectId = (string) config('services.fcm.project_id');
|
||||||
|
$accessToken = $this->accessToken();
|
||||||
|
|
||||||
|
$response = Http::withToken($accessToken)
|
||||||
|
->timeout(15)
|
||||||
|
->post("https://fcm.googleapis.com/v1/projects/{$projectId}/messages:send", [
|
||||||
|
'message' => [
|
||||||
|
'token' => $fcmToken,
|
||||||
|
'notification' => compact('title', 'body'),
|
||||||
|
'data' => array_map('strval', $data),
|
||||||
|
'android' => [
|
||||||
|
'priority' => 'high',
|
||||||
|
'notification' => [
|
||||||
|
'sound' => 'default',
|
||||||
|
'channel_id' => 'payments',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($response->successful()) {
|
||||||
|
return self::DELIVERED;
|
||||||
|
}
|
||||||
|
|
||||||
|
$outcome = $this->classifyFailure($response);
|
||||||
|
|
||||||
|
Log::warning('FCM push failed', [
|
||||||
|
'token' => substr($fcmToken, 0, 20).'…',
|
||||||
|
'status' => $response->status(),
|
||||||
|
'outcome' => $outcome,
|
||||||
|
'body' => $response->body(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $outcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return self::INVALID_TOKEN|self::FAILED
|
||||||
|
*/
|
||||||
|
private function classifyFailure(Response $response): string
|
||||||
|
{
|
||||||
|
$statusCode = $response->status();
|
||||||
|
$body = strtolower((string) $response->body());
|
||||||
|
$apiStatus = strtoupper((string) data_get($response->json(), 'error.status', ''));
|
||||||
|
|
||||||
|
if ($statusCode === 404
|
||||||
|
|| $apiStatus === 'NOT_FOUND'
|
||||||
|
|| str_contains($body, 'not_found')
|
||||||
|
|| str_contains($body, 'requested entity was not found')
|
||||||
|
|| str_contains($body, 'registration token is not a valid')
|
||||||
|
|| str_contains($body, 'invalid registration')
|
||||||
|
|| str_contains($body, 'unregistered')) {
|
||||||
|
return self::INVALID_TOKEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::FAILED;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function accessToken(): string
|
||||||
|
{
|
||||||
|
return Cache::remember('mini_fcm_access_token', 3300, function (): string {
|
||||||
|
$sa = $this->serviceAccount();
|
||||||
|
$jwt = $this->buildJwt($sa);
|
||||||
|
|
||||||
|
$response = Http::asForm()->post('https://oauth2.googleapis.com/token', [
|
||||||
|
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||||
|
'assertion' => $jwt,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! $response->successful()) {
|
||||||
|
throw new \RuntimeException('FCM OAuth2 token exchange failed: '.$response->body());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response->json('access_token');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildJwt(array $sa): string
|
||||||
|
{
|
||||||
|
$header = $this->base64url(json_encode(['alg' => 'RS256', 'typ' => 'JWT']));
|
||||||
|
$now = time();
|
||||||
|
$payload = $this->base64url(json_encode([
|
||||||
|
'iss' => $sa['client_email'],
|
||||||
|
'scope' => 'https://www.googleapis.com/auth/firebase.messaging',
|
||||||
|
'aud' => 'https://oauth2.googleapis.com/token',
|
||||||
|
'iat' => $now,
|
||||||
|
'exp' => $now + 3600,
|
||||||
|
]));
|
||||||
|
|
||||||
|
$message = "{$header}.{$payload}";
|
||||||
|
openssl_sign($message, $signature, $sa['private_key'], 'sha256WithRSAEncryption');
|
||||||
|
|
||||||
|
return "{$message}.{$this->base64url($signature)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private function base64url(string $data): string
|
||||||
|
{
|
||||||
|
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
private function serviceAccount(): array
|
||||||
|
{
|
||||||
|
$value = config('services.fcm.service_account_json');
|
||||||
|
|
||||||
|
if (blank($value)) {
|
||||||
|
throw new \RuntimeException('FCM service account JSON is not configured.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$json = is_file($value) ? file_get_contents($value) : $value;
|
||||||
|
$decoded = json_decode((string) $json, true);
|
||||||
|
|
||||||
|
if (! is_array($decoded) || empty($decoded['private_key'])) {
|
||||||
|
throw new \RuntimeException('FCM service account JSON is invalid or missing private_key.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Notifications;
|
||||||
|
|
||||||
|
use App\Models\MiniPayment;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Notifications\Mini\MiniAlertNotification;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class MiniNotificationService
|
||||||
|
{
|
||||||
|
public function __construct(private FcmService $fcm) {}
|
||||||
|
|
||||||
|
public function paymentReceived(MiniPayment $payment): void
|
||||||
|
{
|
||||||
|
$payment->loadMissing(['qrCode', 'merchant']);
|
||||||
|
$merchant = $payment->merchant;
|
||||||
|
|
||||||
|
if (! $merchant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$businessName = $payment->qrCode?->content()['business_name'] ?? $payment->qrCode?->label ?? 'Payment QR';
|
||||||
|
$amount = $this->formatMoney($payment->currency, $payment->merchant_amount_minor ?: $payment->amount_minor);
|
||||||
|
$title = 'Payment received';
|
||||||
|
$message = sprintf('%s paid %s to %s.', $this->payerLabel($payment), $amount, $businessName);
|
||||||
|
|
||||||
|
$this->alert(
|
||||||
|
$merchant,
|
||||||
|
$title,
|
||||||
|
$message,
|
||||||
|
'payment',
|
||||||
|
route('mini.payments.index'),
|
||||||
|
'payment_received',
|
||||||
|
[
|
||||||
|
'payment_id' => $payment->id,
|
||||||
|
'reference' => $payment->reference,
|
||||||
|
'amount_minor' => $payment->amount_minor,
|
||||||
|
'currency' => $payment->currency,
|
||||||
|
],
|
||||||
|
respectPayoutPref: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function withdrawalSubmitted(User $user, float $amountMajor, string $currency = 'GHS'): void
|
||||||
|
{
|
||||||
|
if (! $this->payoutAlertsEnabled($user)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$amount = sprintf('%s %s', $currency, number_format($amountMajor, 2));
|
||||||
|
$this->alert(
|
||||||
|
$user,
|
||||||
|
'Withdrawal requested',
|
||||||
|
sprintf('Your withdrawal of %s has been submitted and is being processed.', $amount),
|
||||||
|
'payout',
|
||||||
|
route('mini.payouts'),
|
||||||
|
'withdrawal_submitted',
|
||||||
|
['amount_major' => $amountMajor, 'currency' => $currency],
|
||||||
|
respectPayoutPref: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function walletCredited(User $user, int $amountMinor, string $currency, string $description): void
|
||||||
|
{
|
||||||
|
if (! $this->payoutAlertsEnabled($user)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$amount = $this->formatMoney($currency, $amountMinor);
|
||||||
|
$this->alert(
|
||||||
|
$user,
|
||||||
|
'Wallet credited',
|
||||||
|
sprintf('%s added to your wallet. %s', $amount, $description),
|
||||||
|
'wallet',
|
||||||
|
route('mini.payouts'),
|
||||||
|
'wallet_credited',
|
||||||
|
['amount_minor' => $amountMinor, 'currency' => $currency],
|
||||||
|
respectPayoutPref: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $extra
|
||||||
|
*/
|
||||||
|
private function alert(
|
||||||
|
User $user,
|
||||||
|
string $title,
|
||||||
|
string $message,
|
||||||
|
string $icon,
|
||||||
|
?string $url,
|
||||||
|
string $milestone,
|
||||||
|
array $extra = [],
|
||||||
|
bool $respectPayoutPref = true,
|
||||||
|
): void {
|
||||||
|
if ($respectPayoutPref && ! $this->payoutAlertsEnabled($user)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->notify(new MiniAlertNotification($title, $message, $icon, $url, $milestone, $extra));
|
||||||
|
$this->pushToDevices($user, $title, $message, array_merge($extra, [
|
||||||
|
'milestone' => $milestone,
|
||||||
|
'url' => $url,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $data */
|
||||||
|
private function pushToDevices(User $user, string $title, string $body, array $data = []): void
|
||||||
|
{
|
||||||
|
if (! $this->fcm->isConfigured() || ! $this->shouldAttemptFcm($user)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$windowDays = (int) config('notifications.fcm_active_user_within_days', 60);
|
||||||
|
$cutoff = now()->subDays($windowDays);
|
||||||
|
|
||||||
|
$tokens = $user->pushTokens()
|
||||||
|
->where(function ($query) use ($cutoff) {
|
||||||
|
$query->where('last_seen_at', '>=', $cutoff)
|
||||||
|
->orWhere('updated_at', '>=', $cutoff);
|
||||||
|
})
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($tokens as $device) {
|
||||||
|
try {
|
||||||
|
$outcome = $this->fcm->send($device->token, $title, $body, $data);
|
||||||
|
|
||||||
|
if ($outcome === FcmService::INVALID_TOKEN) {
|
||||||
|
$device->delete();
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('Mini push failed', [
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function shouldAttemptFcm(User $user): bool
|
||||||
|
{
|
||||||
|
if ($user->pushTokens()->doesntExist()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$windowDays = (int) config('notifications.fcm_active_user_within_days', 60);
|
||||||
|
|
||||||
|
if ($user->last_app_active_at === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user->last_app_active_at->gte(now()->subDays($windowDays));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function payoutAlertsEnabled(User $user): bool
|
||||||
|
{
|
||||||
|
return (bool) ($user->getOrCreateQrSetting()->notify_payouts ?? true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatMoney(string $currency, int $amountMinor): string
|
||||||
|
{
|
||||||
|
return sprintf('%s %s', $currency, number_format($amountMinor / 100, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function payerLabel(MiniPayment $payment): string
|
||||||
|
{
|
||||||
|
if ($payment->payer_name) {
|
||||||
|
return $payment->payer_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($payment->payer_phone) {
|
||||||
|
return $payment->payer_phone;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'A customer';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Pay;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client for the platform Ladill Pay HTTP API — checkout and settlement for
|
||||||
|
* merchant↔buyer money. Paystack is the processor today; settlement lands in
|
||||||
|
* the one UserWallet via Platform Billing.
|
||||||
|
*/
|
||||||
|
class PayClient
|
||||||
|
{
|
||||||
|
private function base(): string
|
||||||
|
{
|
||||||
|
return rtrim((string) config('pay.api_url'), '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function token(): string
|
||||||
|
{
|
||||||
|
return (string) (config('pay.api_key') ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string,mixed> $payload */
|
||||||
|
public function createCheckout(array $payload): array
|
||||||
|
{
|
||||||
|
$res = Http::withToken($this->token())->acceptJson()->timeout(15)->post($this->base().'/checkouts', $payload);
|
||||||
|
$res->throw();
|
||||||
|
|
||||||
|
return (array) $res->json();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verify(string $reference): array
|
||||||
|
{
|
||||||
|
$res = Http::withToken($this->token())->acceptJson()->timeout(15)->post($this->base().'/checkouts/verify', [
|
||||||
|
'reference' => $reference,
|
||||||
|
]);
|
||||||
|
$res->throw();
|
||||||
|
|
||||||
|
return (array) $res->json();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(string $reference): array
|
||||||
|
{
|
||||||
|
$res = Http::withToken($this->token())->acceptJson()->timeout(10)->get($this->base().'/orders/'.$reference);
|
||||||
|
$res->throw();
|
||||||
|
|
||||||
|
return (array) $res->json();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Pos;
|
||||||
|
|
||||||
|
use App\Models\PosLocation;
|
||||||
|
|
||||||
|
class PosLocationService
|
||||||
|
{
|
||||||
|
public function ensureDefault(string $ownerRef): PosLocation
|
||||||
|
{
|
||||||
|
return PosLocation::owned($ownerRef)->firstOrCreate(
|
||||||
|
['owner_ref' => $ownerRef, 'name' => 'Main register'],
|
||||||
|
['currency' => config('pos.default_currency', 'GHS')],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Pos;
|
||||||
|
|
||||||
|
use App\Models\PosSale;
|
||||||
|
use App\Models\PosSaleLine;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Pay\PayClient;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class PosSaleService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private PayClient $pay,
|
||||||
|
private PosTimelineService $timeline,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array{product_id?: ?int, name: string, unit_price_minor: int, quantity: int}> $lines
|
||||||
|
* @param array{customer_name?: ?string, customer_email?: ?string, customer_phone?: ?string, crm_customer_id?: ?int, location_id?: ?int, currency?: string} $meta
|
||||||
|
*/
|
||||||
|
public function createSale(User $merchant, array $lines, array $meta = []): PosSale
|
||||||
|
{
|
||||||
|
$normalized = $this->normalizeLines($lines);
|
||||||
|
if ($normalized === []) {
|
||||||
|
throw new RuntimeException('Add at least one item to the sale.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$subtotal = collect($normalized)->sum('line_total_minor');
|
||||||
|
if ($subtotal <= 0) {
|
||||||
|
throw new RuntimeException('Sale total must be greater than zero.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$currency = strtoupper((string) ($meta['currency'] ?? config('pos.default_currency', 'GHS')));
|
||||||
|
|
||||||
|
$sale = PosSale::create([
|
||||||
|
'owner_ref' => $merchant->public_id,
|
||||||
|
'location_id' => $meta['location_id'] ?? null,
|
||||||
|
'reference' => 'POS-'.strtoupper(Str::random(12)),
|
||||||
|
'status' => PosSale::STATUS_PENDING,
|
||||||
|
'payment_method' => PosSale::METHOD_PAY,
|
||||||
|
'customer_name' => $meta['customer_name'] ?? null,
|
||||||
|
'customer_email' => $meta['customer_email'] ?? null,
|
||||||
|
'customer_phone' => $meta['customer_phone'] ?? null,
|
||||||
|
'crm_customer_id' => $meta['crm_customer_id'] ?? null,
|
||||||
|
'subtotal_minor' => $subtotal,
|
||||||
|
'total_minor' => $subtotal,
|
||||||
|
'currency' => $currency,
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach ($normalized as $index => $line) {
|
||||||
|
PosSaleLine::create([
|
||||||
|
'pos_sale_id' => $sale->id,
|
||||||
|
'product_id' => $line['product_id'] ?? null,
|
||||||
|
'name' => $line['name'],
|
||||||
|
'unit_price_minor' => $line['unit_price_minor'],
|
||||||
|
'quantity' => $line['quantity'],
|
||||||
|
'line_total_minor' => $line['line_total_minor'],
|
||||||
|
'position' => $index,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $sale->fresh('lines');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{sale: PosSale, checkout_url: string}
|
||||||
|
*/
|
||||||
|
public function initiatePayCheckout(PosSale $sale, User $merchant): array
|
||||||
|
{
|
||||||
|
if ($sale->isPaid()) {
|
||||||
|
throw new RuntimeException('This sale is already paid.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$sale->load('lines');
|
||||||
|
$callbackUrl = route('pos.sales.callback', $sale);
|
||||||
|
|
||||||
|
$payOrder = $this->pay->createCheckout([
|
||||||
|
'merchant' => $merchant->public_id,
|
||||||
|
'fee_tier' => 'sales',
|
||||||
|
'source_service' => 'pos',
|
||||||
|
'source_ref' => (string) $sale->id,
|
||||||
|
'callback_url' => $callbackUrl,
|
||||||
|
'customer_name' => $sale->customer_name,
|
||||||
|
'customer_email' => $sale->customer_email,
|
||||||
|
'customer_phone' => $sale->customer_phone,
|
||||||
|
'line_items' => $sale->lines->map(fn (PosSaleLine $line) => [
|
||||||
|
'name' => $line->name,
|
||||||
|
'unit_price_minor' => $line->unit_price_minor,
|
||||||
|
'quantity' => $line->quantity,
|
||||||
|
])->all(),
|
||||||
|
'metadata' => [
|
||||||
|
'pos_sale_id' => $sale->id,
|
||||||
|
'pos_reference' => $sale->reference,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$checkoutUrl = (string) ($payOrder['checkout_url'] ?? '');
|
||||||
|
if ($checkoutUrl === '') {
|
||||||
|
throw new RuntimeException('Could not start checkout. Please try again.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$sale->forceFill([
|
||||||
|
'payment_method' => PosSale::METHOD_PAY,
|
||||||
|
'pay_order_id' => $payOrder['id'] ?? null,
|
||||||
|
'payment_reference' => $payOrder['reference'] ?? null,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'sale' => $sale->fresh('lines'),
|
||||||
|
'checkout_url' => $checkoutUrl,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function recordCashPayment(PosSale $sale): PosSale
|
||||||
|
{
|
||||||
|
if ($sale->isPaid()) {
|
||||||
|
throw new RuntimeException('This sale is already paid.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$sale->forceFill([
|
||||||
|
'payment_method' => PosSale::METHOD_CASH,
|
||||||
|
'status' => PosSale::STATUS_PAID,
|
||||||
|
'paid_at' => now(),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$sale = $sale->fresh('lines');
|
||||||
|
$this->timeline->pushPaidSale($sale);
|
||||||
|
|
||||||
|
return $sale;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function completePayCheckout(string $paymentReference): PosSale
|
||||||
|
{
|
||||||
|
$sale = PosSale::query()
|
||||||
|
->where('payment_reference', $paymentReference)
|
||||||
|
->where('status', PosSale::STATUS_PENDING)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$payOrder = $this->pay->verify($paymentReference);
|
||||||
|
|
||||||
|
$sale->forceFill([
|
||||||
|
'status' => PosSale::STATUS_PAID,
|
||||||
|
'total_minor' => (int) ($payOrder['amount_minor'] ?? $sale->total_minor),
|
||||||
|
'pay_order_id' => $payOrder['id'] ?? $sale->pay_order_id,
|
||||||
|
'paid_at' => now(),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$sale = $sale->fresh('lines');
|
||||||
|
$this->timeline->pushPaidSale($sale);
|
||||||
|
|
||||||
|
return $sale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array{product_id?: ?int, name: string, unit_price_minor: int, quantity: int}> $lines
|
||||||
|
* @return list<array{product_id?: ?int, name: string, unit_price_minor: int, quantity: int, line_total_minor: int}>
|
||||||
|
*/
|
||||||
|
private function normalizeLines(array $lines): array
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$qty = max(1, (int) ($line['quantity'] ?? 1));
|
||||||
|
$unit = max(0, (int) ($line['unit_price_minor'] ?? 0));
|
||||||
|
$name = trim((string) ($line['name'] ?? ''));
|
||||||
|
|
||||||
|
if ($name === '' || $unit <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$out[] = [
|
||||||
|
'product_id' => $line['product_id'] ?? null,
|
||||||
|
'name' => $name,
|
||||||
|
'unit_price_minor' => $unit,
|
||||||
|
'quantity' => $qty,
|
||||||
|
'line_total_minor' => $unit * $qty,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Pos;
|
||||||
|
|
||||||
|
use App\Models\PosSale;
|
||||||
|
use App\Services\Crm\CrmClient;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class PosTimelineService
|
||||||
|
{
|
||||||
|
public function pushPaidSale(PosSale $sale): void
|
||||||
|
{
|
||||||
|
if ($sale->status !== PosSale::STATUS_PAID) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
CrmClient::for($sale->owner_ref)->pushTimeline([
|
||||||
|
'event' => 'order.paid',
|
||||||
|
'title' => 'POS sale '.$sale->reference,
|
||||||
|
'external_id' => (string) $sale->id,
|
||||||
|
'amount_minor' => (int) $sale->total_minor,
|
||||||
|
'currency' => (string) $sale->currency,
|
||||||
|
'url' => route('pos.sales.show', $sale),
|
||||||
|
'customer_id' => $sale->crm_customer_id,
|
||||||
|
'customer_email' => $sale->customer_email,
|
||||||
|
'customer_phone' => $sale->customer_phone,
|
||||||
|
'customer_name' => $sale->customer_name,
|
||||||
|
'description' => ucfirst($sale->payment_method).' payment at register',
|
||||||
|
'occurred_at' => ($sale->paid_at ?? now())->toIso8601String(),
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::info('CRM timeline push skipped for POS sale', [
|
||||||
|
'sale_id' => $sale->id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Qr;
|
||||||
|
|
||||||
|
use App\Models\QrCode;
|
||||||
|
use App\Models\QrScanEvent;
|
||||||
|
use App\Models\QrWallet;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class QrAnalyticsService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function summaryFor(QrCode $qrCode): array
|
||||||
|
{
|
||||||
|
$now = now();
|
||||||
|
$events = QrScanEvent::query()->where('qr_code_id', $qrCode->id);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total_scans' => (int) $qrCode->scans_total,
|
||||||
|
'unique_scans' => (int) $qrCode->unique_scans_total,
|
||||||
|
'scans_7d' => (clone $events)->where('scanned_at', '>=', $now->copy()->subDays(7))->count(),
|
||||||
|
'scans_30d' => (clone $events)->where('scanned_at', '>=', $now->copy()->subDays(30))->count(),
|
||||||
|
'last_scanned_at' => $qrCode->last_scanned_at,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, object{date: string, total: int}>
|
||||||
|
*/
|
||||||
|
public function dailyScans(QrCode $qrCode, int $days = 30): Collection
|
||||||
|
{
|
||||||
|
$start = now()->subDays($days - 1)->startOfDay();
|
||||||
|
|
||||||
|
$rows = QrScanEvent::query()
|
||||||
|
->selectRaw('DATE(scanned_at) as scan_date, COUNT(*) as total')
|
||||||
|
->where('qr_code_id', $qrCode->id)
|
||||||
|
->where('scanned_at', '>=', $start)
|
||||||
|
->groupBy('scan_date')
|
||||||
|
->orderBy('scan_date')
|
||||||
|
->get()
|
||||||
|
->keyBy('scan_date');
|
||||||
|
|
||||||
|
$series = collect();
|
||||||
|
for ($i = 0; $i < $days; $i++) {
|
||||||
|
$date = $start->copy()->addDays($i)->toDateString();
|
||||||
|
$series->push((object) [
|
||||||
|
'date' => $date,
|
||||||
|
'total' => (int) ($rows->get($date)?->total ?? 0),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $series;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{label: string, total: int}>
|
||||||
|
*/
|
||||||
|
public function breakdown(QrCode $qrCode, string $column, int $limit = 5): array
|
||||||
|
{
|
||||||
|
return QrScanEvent::query()
|
||||||
|
->select($column, DB::raw('COUNT(*) as total'))
|
||||||
|
->where('qr_code_id', $qrCode->id)
|
||||||
|
->whereNotNull($column)
|
||||||
|
->groupBy($column)
|
||||||
|
->orderByDesc('total')
|
||||||
|
->limit($limit)
|
||||||
|
->get()
|
||||||
|
->map(fn ($row) => [
|
||||||
|
'label' => (string) $row->{$column},
|
||||||
|
'total' => (int) $row->total,
|
||||||
|
])
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return \Illuminate\Database\Eloquent\Collection<int, QrScanEvent>
|
||||||
|
*/
|
||||||
|
public function recentScans(QrCode $qrCode, int $limit = 20)
|
||||||
|
{
|
||||||
|
return QrScanEvent::query()
|
||||||
|
->where('qr_code_id', $qrCode->id)
|
||||||
|
->latest('scanned_at')
|
||||||
|
->limit($limit)
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,556 @@
|
|||||||
|
<?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 ($type !== QrCode::TYPE_PAYMENT && ! $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 = $type === QrCode::TYPE_PAYMENT
|
||||||
|
? 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_BOOK) {
|
||||||
|
$bookFile = $data['book_file'] ?? null;
|
||||||
|
if (! $bookFile instanceof UploadedFile) {
|
||||||
|
throw new RuntimeException('Upload the book file (PDF or EPUB).');
|
||||||
|
}
|
||||||
|
$bookData = $this->storeBookFile($user, $bookFile);
|
||||||
|
$content['file_path'] = $bookData['path'];
|
||||||
|
$content['file_type'] = $bookData['type'];
|
||||||
|
$content['file_size'] = $bookData['size'];
|
||||||
|
|
||||||
|
if (($data['cover'] ?? null) instanceof UploadedFile) {
|
||||||
|
$content['cover_path'] = $this->storeBookCover($user, $data['cover']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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_CHURCH) {
|
||||||
|
if (($data['church_logo'] ?? null) instanceof UploadedFile) {
|
||||||
|
$content['logo_path'] = $this->storeMenuBrandImage($user, $data['church_logo'], 'church-logos');
|
||||||
|
}
|
||||||
|
if (($data['church_cover'] ?? null) instanceof UploadedFile) {
|
||||||
|
$content['cover_path'] = $this->storeMenuBrandImage($user, $data['church_cover'], 'church-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 ($type !== QrCode::TYPE_PAYMENT) {
|
||||||
|
$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_BOOK) {
|
||||||
|
if (($data['book_file'] ?? null) instanceof UploadedFile) {
|
||||||
|
$bookData = $this->storeBookFile($qrCode->user, $data['book_file']);
|
||||||
|
$content['file_path'] = $bookData['path'];
|
||||||
|
$content['file_type'] = $bookData['type'];
|
||||||
|
$content['file_size'] = $bookData['size'];
|
||||||
|
$qrCode->destination_updated_at = now();
|
||||||
|
}
|
||||||
|
if (($data['cover'] ?? null) instanceof UploadedFile) {
|
||||||
|
$content['cover_path'] = $this->storeBookCover($qrCode->user, $data['cover']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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_CHURCH) {
|
||||||
|
if (($data['church_logo'] ?? null) instanceof UploadedFile) {
|
||||||
|
$content['logo_path'] = $this->storeMenuBrandImage($qrCode->user, $data['church_logo'], 'church-logos');
|
||||||
|
}
|
||||||
|
if (($data['church_cover'] ?? null) instanceof UploadedFile) {
|
||||||
|
$content['cover_path'] = $this->storeMenuBrandImage($qrCode->user, $data['church_cover'], 'church-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_CHURCH => ['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_BOOK => ['book_title', 'author', 'description', 'price_ghs'],
|
||||||
|
QrCode::TYPE_WIFI => ['ssid', 'password', 'encryption', 'hidden'],
|
||||||
|
QrCode::TYPE_PAYMENT => ['business_name', 'branch_label', 'currency'],
|
||||||
|
default => [],
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach ($keys as $key) {
|
||||||
|
if (array_key_exists($key, $data)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function storeDocument(User $user, UploadedFile $file, string $title): QrDocument
|
||||||
|
{
|
||||||
|
$maxBytes = (int) config('qr.max_pdf_bytes', 104857600);
|
||||||
|
|
||||||
|
if ($file->getSize() > $maxBytes) {
|
||||||
|
throw new RuntimeException('PDF must be 100 MB or smaller.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$mime = $file->getMimeType() ?: '';
|
||||||
|
if (! in_array($mime, ['application/pdf', 'application/x-pdf'], true)) {
|
||||||
|
throw new RuntimeException('Only PDF documents are supported.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$uuid = Str::uuid()->toString();
|
||||||
|
$path = $user->id . '/documents/' . $uuid . '.pdf';
|
||||||
|
|
||||||
|
$file->storeAs('', $path, 'qr');
|
||||||
|
|
||||||
|
return QrDocument::create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'title' => $title,
|
||||||
|
'disk' => 'qr',
|
||||||
|
'path' => $path,
|
||||||
|
'mime_type' => 'application/pdf',
|
||||||
|
'size_bytes' => (int) $file->getSize(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
* @return list<array{path: string, title: string}>
|
||||||
|
*/
|
||||||
|
private function storeImages(User $user, array $data, bool $required = true): array
|
||||||
|
{
|
||||||
|
$files = [];
|
||||||
|
if (($data['image'] ?? null) instanceof UploadedFile) {
|
||||||
|
$files[] = $data['image'];
|
||||||
|
}
|
||||||
|
if (is_array($data['images'] ?? null)) {
|
||||||
|
foreach ($data['images'] as $file) {
|
||||||
|
if ($file instanceof UploadedFile) {
|
||||||
|
$files[] = $file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($required && $files === []) {
|
||||||
|
$this->payloadValidator->validateImageUpload(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stored = [];
|
||||||
|
foreach ($files as $index => $file) {
|
||||||
|
$this->payloadValidator->validateImageUpload($file);
|
||||||
|
$uuid = Str::uuid()->toString();
|
||||||
|
$ext = $file->getClientOriginalExtension() ?: 'jpg';
|
||||||
|
$path = $user->id . '/images/' . $uuid . '.' . $ext;
|
||||||
|
$file->storeAs('', $path, 'qr');
|
||||||
|
$stored[] = [
|
||||||
|
'path' => $path,
|
||||||
|
'title' => $file->getClientOriginalName() ?: ('Image ' . ($index + 1)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function storeVcardAvatar(User $user, UploadedFile $file): string
|
||||||
|
{
|
||||||
|
$mime = $file->getMimeType() ?: '';
|
||||||
|
if (! str_starts_with($mime, 'image/')) {
|
||||||
|
throw new RuntimeException('Avatar must be an image file.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$ext = $file->getClientOriginalExtension() ?: 'jpg';
|
||||||
|
$path = $user->id . '/vcards/' . Str::uuid()->toString() . '.' . $ext;
|
||||||
|
$file->storeAs('', $path, 'qr');
|
||||||
|
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function storeBookFile(User $user, UploadedFile $file): array
|
||||||
|
{
|
||||||
|
$ext = strtolower($file->getClientOriginalExtension() ?: '');
|
||||||
|
$mime = $file->getMimeType() ?: '';
|
||||||
|
|
||||||
|
if ($ext === 'pdf' || in_array($mime, ['application/pdf', 'application/x-pdf'], true)) {
|
||||||
|
$type = 'pdf';
|
||||||
|
} elseif ($ext === 'epub' || str_contains($mime, 'epub')) {
|
||||||
|
$type = 'epub';
|
||||||
|
} else {
|
||||||
|
throw new RuntimeException('Only PDF and EPUB files are supported for books.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = $user->id . '/books/' . Str::uuid()->toString() . '.' . $type;
|
||||||
|
$file->storeAs('', $path, 'qr');
|
||||||
|
|
||||||
|
return ['path' => $path, 'type' => $type, 'size' => (int) $file->getSize()];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function storeMenuBrandImage(User $user, UploadedFile $file, string $subdir): string
|
||||||
|
{
|
||||||
|
$mime = $file->getMimeType() ?: '';
|
||||||
|
if (! str_starts_with($mime, 'image/')) {
|
||||||
|
throw new RuntimeException('Only image files are supported.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$ext = $file->getClientOriginalExtension() ?: 'jpg';
|
||||||
|
$path = $user->id . '/' . $subdir . '/' . Str::uuid()->toString() . '.' . $ext;
|
||||||
|
$file->storeAs('', $path, 'qr');
|
||||||
|
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function storeBookCover(User $user, UploadedFile $file): string
|
||||||
|
{
|
||||||
|
$mime = $file->getMimeType() ?: '';
|
||||||
|
if (! str_starts_with($mime, 'image/')) {
|
||||||
|
throw new RuntimeException('Book cover must be an image file.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$ext = $file->getClientOriginalExtension() ?: 'jpg';
|
||||||
|
$path = $user->id . '/book-covers/' . Str::uuid()->toString() . '.' . $ext;
|
||||||
|
$file->storeAs('', $path, 'qr');
|
||||||
|
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function storeLogo(User $user, UploadedFile $file): string
|
||||||
|
{
|
||||||
|
$mime = $file->getMimeType() ?: '';
|
||||||
|
if (! str_starts_with($mime, 'image/')) {
|
||||||
|
throw new RuntimeException('Logo must be an image file.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = $user->id . '/logos/' . Str::uuid()->toString() . '.' . ($file->getClientOriginalExtension() ?: 'png');
|
||||||
|
$file->storeAs('', $path, 'qr');
|
||||||
|
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateUniqueShortCode(): string
|
||||||
|
{
|
||||||
|
$length = (int) config('qr.short_code_length', 8);
|
||||||
|
|
||||||
|
for ($attempt = 0; $attempt < 20; $attempt++) {
|
||||||
|
$code = Str::lower(Str::random($length));
|
||||||
|
if (! QrCode::query()->where('short_code', $code)->exists()) {
|
||||||
|
return $code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new RuntimeException('Could not generate a unique QR short code.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(QrCode $qrCode): void
|
||||||
|
{
|
||||||
|
$paths = array_filter([$qrCode->png_path, $qrCode->svg_path]);
|
||||||
|
|
||||||
|
$logoPath = $qrCode->content()['logo_path'] ?? null;
|
||||||
|
if (is_string($logoPath) && $logoPath !== '') {
|
||||||
|
$paths[] = $logoPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($paths as $path) {
|
||||||
|
Storage::disk('qr')->delete($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
$qrCode->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,488 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Qr;
|
||||||
|
|
||||||
|
use App\Models\QrCode as QrCodeModel;
|
||||||
|
use App\Support\Qr\QrFrameStyleCatalog;
|
||||||
|
use App\Support\Qr\QrModuleStyleCatalog;
|
||||||
|
use App\Support\Qr\QrStyleDefaults;
|
||||||
|
use chillerlan\QRCode\Common\EccLevel;
|
||||||
|
use chillerlan\QRCode\Data\QRMatrix;
|
||||||
|
use chillerlan\QRCode\QROptions;
|
||||||
|
use chillerlan\QRCode\QRCode;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class QrImageGeneratorService
|
||||||
|
{
|
||||||
|
public function generateAndStore(QrCodeModel $qrCode): void
|
||||||
|
{
|
||||||
|
$url = $qrCode->encodedPayload();
|
||||||
|
$style = $qrCode->style();
|
||||||
|
$basePath = $qrCode->user_id . '/codes/' . $qrCode->id;
|
||||||
|
|
||||||
|
$pngBinary = $this->renderPng($url, $style);
|
||||||
|
$svgMarkup = $this->renderSvg($url, $style);
|
||||||
|
|
||||||
|
$pngPath = $basePath . '/qr.png';
|
||||||
|
$svgPath = $basePath . '/qr.svg';
|
||||||
|
|
||||||
|
Storage::disk('qr')->put($pngPath, $pngBinary);
|
||||||
|
Storage::disk('qr')->put($svgPath, $svgMarkup);
|
||||||
|
|
||||||
|
$qrCode->update([
|
||||||
|
'png_path' => $pngPath,
|
||||||
|
'svg_path' => $svgPath,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ensureValidImages(QrCodeModel $qrCode): QrCodeModel
|
||||||
|
{
|
||||||
|
if ($this->pngIsValid($qrCode->png_path)) {
|
||||||
|
return $qrCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->generateAndStore($qrCode);
|
||||||
|
|
||||||
|
return $qrCode->fresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function previewDataUri(QrCodeModel $qrCode): string
|
||||||
|
{
|
||||||
|
$qrCode = $this->ensureValidImages($qrCode);
|
||||||
|
|
||||||
|
if ($qrCode->png_path && Storage::disk('qr')->exists($qrCode->png_path)) {
|
||||||
|
$bytes = Storage::disk('qr')->get($qrCode->png_path);
|
||||||
|
if ($this->isValidPngBinary($bytes)) {
|
||||||
|
return 'data:image/png;base64,' . base64_encode($bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$png = $this->renderPng($qrCode->encodedPayload(), $qrCode->style());
|
||||||
|
$this->generateAndStore($qrCode);
|
||||||
|
|
||||||
|
return 'data:image/png;base64,' . base64_encode($png);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logoDataUri(string $path): ?string
|
||||||
|
{
|
||||||
|
if (! Storage::disk('qr')->exists($path)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$content = Storage::disk('qr')->get($path);
|
||||||
|
if ($content === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$mimeType = Storage::disk('qr')->mimeType($path);
|
||||||
|
if (! $mimeType) {
|
||||||
|
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||||
|
$mimeType = match ($ext) {
|
||||||
|
'png' => 'image/png',
|
||||||
|
'jpg', 'jpeg' => 'image/jpeg',
|
||||||
|
'svg' => 'image/svg+xml',
|
||||||
|
'gif' => 'image/gif',
|
||||||
|
'webp' => 'image/webp',
|
||||||
|
default => 'image/png',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'data:' . $mimeType . ';base64,' . base64_encode($content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $style
|
||||||
|
*/
|
||||||
|
public function renderPng(string $content, array $style): string
|
||||||
|
{
|
||||||
|
$style = QrStyleDefaults::mergeForRender($style);
|
||||||
|
$options = $this->buildOptions($style, QRCode::OUTPUT_IMAGE_PNG);
|
||||||
|
$binary = (new QRCode($options))->render($content);
|
||||||
|
|
||||||
|
if ($style['logo_path'] && Storage::disk('qr')->exists($style['logo_path'])) {
|
||||||
|
$binary = $this->applyLogo($binary, Storage::disk('qr')->path($style['logo_path']));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->applyFrame($binary, (string) ($style['frame_style'] ?? 'none'), $style);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $style
|
||||||
|
*/
|
||||||
|
public function renderSvg(string $content, array $style): string
|
||||||
|
{
|
||||||
|
$style = QrStyleDefaults::mergeForRender($style);
|
||||||
|
$options = $this->buildOptions($style, QRCode::OUTPUT_MARKUP_SVG);
|
||||||
|
|
||||||
|
return (new QRCode($options))->render($content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $style */
|
||||||
|
private function buildOptions(array $style, string $outputType): QROptions
|
||||||
|
{
|
||||||
|
$fg = $this->hexToRgb((string) $style['foreground']);
|
||||||
|
$bg = $this->hexToRgb((string) $style['background']);
|
||||||
|
$ecc = match ($style['error_correction']) {
|
||||||
|
'L' => EccLevel::L,
|
||||||
|
'Q' => EccLevel::Q,
|
||||||
|
'H' => EccLevel::H,
|
||||||
|
default => EccLevel::M,
|
||||||
|
};
|
||||||
|
|
||||||
|
$hasLogo = ! empty($style['logo_path']);
|
||||||
|
$module = QrModuleStyleCatalog::optionsFor((string) $style['module_style']);
|
||||||
|
$moduleUsesCircular = (bool) $module['circular'];
|
||||||
|
$finderOuter = (string) ($style['finder_outer'] ?? 'square');
|
||||||
|
$finderInner = (string) ($style['finder_inner'] ?? 'square');
|
||||||
|
$finderUsesCircular = $finderOuter !== 'square' || $finderInner === 'dot';
|
||||||
|
$usesCircular = $moduleUsesCircular || $finderUsesCircular;
|
||||||
|
$connectPaths = $module['connect_paths'] && $outputType === QRCode::OUTPUT_MARKUP_SVG;
|
||||||
|
|
||||||
|
$circleRadius = (float) $module['circle_radius'];
|
||||||
|
if ($finderOuter === 'circle') {
|
||||||
|
$circleRadius = max($circleRadius, 0.5);
|
||||||
|
} elseif ($finderOuter === 'rounded') {
|
||||||
|
$circleRadius = max($circleRadius, 0.38);
|
||||||
|
}
|
||||||
|
|
||||||
|
$keepAsSquare = $this->resolveKeepAsSquare($moduleUsesCircular, $finderOuter, $finderInner);
|
||||||
|
|
||||||
|
return new QROptions([
|
||||||
|
'outputType' => $outputType,
|
||||||
|
'outputBase64' => false,
|
||||||
|
'scale' => (int) $style['scale'],
|
||||||
|
'eccLevel' => $ecc,
|
||||||
|
'addQuietzone' => true,
|
||||||
|
'quietzoneSize' => (int) $style['margin'],
|
||||||
|
'bgColor' => $bg,
|
||||||
|
'drawCircularModules' => $usesCircular,
|
||||||
|
'circleRadius' => $circleRadius,
|
||||||
|
'connectPaths' => $connectPaths,
|
||||||
|
'gdImageUseUpscale' => true,
|
||||||
|
'keepAsSquare' => $keepAsSquare,
|
||||||
|
'addLogoSpace' => $hasLogo,
|
||||||
|
'logoSpaceWidth' => $hasLogo ? 13 : null,
|
||||||
|
'logoSpaceHeight' => $hasLogo ? 13 : null,
|
||||||
|
'moduleValues' => [
|
||||||
|
QRMatrix::M_DATA_DARK => $fg,
|
||||||
|
QRMatrix::M_FINDER_DARK => $fg,
|
||||||
|
QRMatrix::M_ALIGNMENT_DARK => $fg,
|
||||||
|
QRMatrix::M_TIMING_DARK => $fg,
|
||||||
|
QRMatrix::M_FORMAT_DARK => $fg,
|
||||||
|
QRMatrix::M_VERSION_DARK => $fg,
|
||||||
|
QRMatrix::M_FINDER_DOT => $fg,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<int> */
|
||||||
|
private function resolveKeepAsSquare(bool $moduleUsesCircular, string $finderOuter, string $finderInner): array
|
||||||
|
{
|
||||||
|
$finderUsesCircular = ($finderOuter !== 'square') || ($finderInner === 'dot') || ($finderInner === 'rounded');
|
||||||
|
|
||||||
|
if (! $moduleUsesCircular && ! $finderUsesCircular) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$keep = [
|
||||||
|
// Structural modules must always stay square for reliable scanning.
|
||||||
|
QRMatrix::M_ALIGNMENT_DARK,
|
||||||
|
QRMatrix::M_TIMING_DARK,
|
||||||
|
QRMatrix::M_FORMAT_DARK,
|
||||||
|
QRMatrix::M_VERSION_DARK,
|
||||||
|
// White separator (M_FINDER light modules) must always stay square.
|
||||||
|
// Removing it causes the circular dark-ring modules to lose their solid
|
||||||
|
// white backdrop, which produces a broken / empty-looking eye pattern.
|
||||||
|
QRMatrix::M_FINDER,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Data modules stay square when only the finder style is circular.
|
||||||
|
if (! $moduleUsesCircular) {
|
||||||
|
$keep[] = QRMatrix::M_DATA_DARK;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outer finder frame: square keeps the ring solid; non-square renders it as dots.
|
||||||
|
if ($finderOuter === 'square') {
|
||||||
|
$keep[] = QRMatrix::M_FINDER_DARK;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inner finder dot: 'dot' and 'rounded' both get circular treatment.
|
||||||
|
if ($finderInner !== 'dot' && $finderInner !== 'rounded') {
|
||||||
|
$keep[] = QRMatrix::M_FINDER_DOT;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($keep));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $style */
|
||||||
|
private function applyFrame(string $pngBinary, string $frameStyle, array $style = []): string
|
||||||
|
{
|
||||||
|
$frame = QrFrameStyleCatalog::all()[$frameStyle] ?? QrFrameStyleCatalog::all()['none'];
|
||||||
|
$borderPx = (int) $frame['border_px'];
|
||||||
|
$mode = (string) ($frame['mode'] ?? 'border');
|
||||||
|
|
||||||
|
if ($borderPx === 0 || ! extension_loaded('gd')) {
|
||||||
|
return $pngBinary;
|
||||||
|
}
|
||||||
|
|
||||||
|
$source = @imagecreatefromstring($pngBinary);
|
||||||
|
if (! $source) {
|
||||||
|
return $pngBinary;
|
||||||
|
}
|
||||||
|
|
||||||
|
$width = imagesx($source);
|
||||||
|
$height = imagesy($source);
|
||||||
|
$labelHeight = in_array($mode, ['label', 'pill'], true) ? max(44, (int) round($height * 0.18)) : 0;
|
||||||
|
$canvasWidth = $width + ($borderPx * 2);
|
||||||
|
$canvasHeight = $height + ($borderPx * 2) + $labelHeight;
|
||||||
|
$frameColorHex = trim((string) ($style['frame_color'] ?? '#000000'));
|
||||||
|
if (! preg_match('/^#[0-9a-fA-F]{6}$/', $frameColorHex)) {
|
||||||
|
$frameColorHex = '#000000';
|
||||||
|
}
|
||||||
|
|
||||||
|
$canvas = imagecreatetruecolor($canvasWidth, $canvasHeight);
|
||||||
|
$white = imagecolorallocate($canvas, 255, 255, 255);
|
||||||
|
|
||||||
|
// For border mode the padding area IS the frame — fill with frame colour.
|
||||||
|
// For label/pill modes the background stays white; only the CTA element uses frame colour.
|
||||||
|
if ($mode === 'border') {
|
||||||
|
[$fr, $fg, $fb] = $this->hexToRgb($frameColorHex);
|
||||||
|
$frameBg = imagecolorallocate($canvas, $fr, $fg, $fb);
|
||||||
|
imagefilledrectangle($canvas, 0, 0, $canvasWidth, $canvasHeight, $frameBg);
|
||||||
|
} else {
|
||||||
|
imagefilledrectangle($canvas, 0, 0, $canvasWidth, $canvasHeight, $white);
|
||||||
|
}
|
||||||
|
|
||||||
|
imagecopy($canvas, $source, $borderPx, $borderPx, 0, 0, $width, $height);
|
||||||
|
|
||||||
|
if ($labelHeight > 0) {
|
||||||
|
$customText = trim((string) ($style['frame_text'] ?? ''));
|
||||||
|
$ctaText = $customText !== '' ? $customText : (string) ($frame['cta'] ?? 'SCAN ME');
|
||||||
|
$this->drawFrameLabel($canvas, $ctaText, $borderPx, $width, $height, $labelHeight, $mode, $frameColorHex);
|
||||||
|
}
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
imagepng($canvas);
|
||||||
|
$result = (string) ob_get_clean();
|
||||||
|
imagedestroy($source);
|
||||||
|
imagedestroy($canvas);
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function drawFrameLabel(\GdImage $canvas, string $text, int $borderPx, int $qrWidth, int $qrHeight, int $labelHeight, string $mode, string $frameColorHex = '#000000'): void
|
||||||
|
{
|
||||||
|
$canvasWidth = imagesx($canvas);
|
||||||
|
$canvasHeight = imagesy($canvas);
|
||||||
|
[$fr, $fg, $fb] = $this->hexToRgb($frameColorHex);
|
||||||
|
$frameColor = imagecolorallocate($canvas, $fr, $fg, $fb);
|
||||||
|
// Choose black or white text depending on frame colour luminance
|
||||||
|
$luminance = (0.2126 * $fr + 0.7152 * $fg + 0.0722 * $fb) / 255;
|
||||||
|
$textOnFrame = $luminance > 0.35
|
||||||
|
? imagecolorallocate($canvas, 0, 0, 0)
|
||||||
|
: imagecolorallocate($canvas, 255, 255, 255);
|
||||||
|
$dividerColor = imagecolorallocate($canvas, $fr, $fg, $fb);
|
||||||
|
$labelTop = $borderPx + $qrHeight;
|
||||||
|
$labelBottom = $canvasHeight - max(8, (int) round($borderPx * 0.6));
|
||||||
|
|
||||||
|
if ($mode === 'pill') {
|
||||||
|
$pillMargin = max(12, (int) round($borderPx * 0.9));
|
||||||
|
$pillTop = $labelTop + max(7, (int) round($labelHeight * 0.18));
|
||||||
|
$pillBottom = $labelBottom - max(5, (int) round($labelHeight * 0.12));
|
||||||
|
imagefilledrectangle($canvas, $pillMargin + 10, $pillTop, $canvasWidth - $pillMargin - 10, $pillBottom, $frameColor);
|
||||||
|
imagefilledellipse($canvas, $pillMargin + 10, (int) (($pillTop + $pillBottom) / 2), $pillBottom - $pillTop, $pillBottom - $pillTop, $frameColor);
|
||||||
|
imagefilledellipse($canvas, $canvasWidth - $pillMargin - 10, (int) (($pillTop + $pillBottom) / 2), $pillBottom - $pillTop, $pillBottom - $pillTop, $frameColor);
|
||||||
|
$this->drawCenteredString($canvas, $text, $textOnFrame, 5, $pillTop, $pillBottom);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
imageline($canvas, $borderPx + 8, $labelTop + 3, $borderPx + $qrWidth - 8, $labelTop + 3, $dividerColor);
|
||||||
|
$this->drawCenteredString($canvas, $text, $frameColor, 5, $labelTop + 7, $labelBottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function drawCenteredString(\GdImage $canvas, string $text, int $color, int $font, int $top, int $bottom): void
|
||||||
|
{
|
||||||
|
$text = strtoupper($text);
|
||||||
|
$font = max(1, min(5, $font));
|
||||||
|
$textWidth = imagefontwidth($font) * strlen($text);
|
||||||
|
$textHeight = imagefontheight($font);
|
||||||
|
$x = max(0, (int) round((imagesx($canvas) - $textWidth) / 2));
|
||||||
|
$y = max($top, (int) round($top + (($bottom - $top - $textHeight) / 2)));
|
||||||
|
imagestring($canvas, $font, $x, $y, $text, $color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{0: int, 1: int, 2: int} */
|
||||||
|
private function hexToRgb(string $hex): array
|
||||||
|
{
|
||||||
|
$hex = ltrim(trim($hex), '#');
|
||||||
|
if (strlen($hex) === 3) {
|
||||||
|
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strlen($hex) !== 6 || ! ctype_xdigit($hex)) {
|
||||||
|
return [0, 0, 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
hexdec(substr($hex, 0, 2)),
|
||||||
|
hexdec(substr($hex, 2, 2)),
|
||||||
|
hexdec(substr($hex, 4, 2)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function applyLogo(string $pngBinary, string $logoPath): string
|
||||||
|
{
|
||||||
|
if (! extension_loaded('gd')) {
|
||||||
|
return $pngBinary;
|
||||||
|
}
|
||||||
|
|
||||||
|
$qr = imagecreatefromstring($pngBinary);
|
||||||
|
$logo = @imagecreatefromstring((string) file_get_contents($logoPath));
|
||||||
|
|
||||||
|
if (! $qr || ! $logo) {
|
||||||
|
return $pngBinary;
|
||||||
|
}
|
||||||
|
|
||||||
|
$qrW = imagesx($qr);
|
||||||
|
$qrH = imagesy($qr);
|
||||||
|
$logoW = imagesx($logo);
|
||||||
|
$logoH = imagesy($logo);
|
||||||
|
$target = (int) round(min($qrW, $qrH) * 0.22);
|
||||||
|
$resized = imagecreatetruecolor($target, $target);
|
||||||
|
imagealphablending($resized, false);
|
||||||
|
imagesavealpha($resized, true);
|
||||||
|
$transparent = imagecolorallocatealpha($resized, 255, 255, 255, 127);
|
||||||
|
imagefilledrectangle($resized, 0, 0, $target, $target, $transparent);
|
||||||
|
imagecopyresampled($resized, $logo, 0, 0, 0, 0, $target, $target, $logoW, $logoH);
|
||||||
|
|
||||||
|
$pad = (int) round($target * 0.12);
|
||||||
|
$bgSize = $target + ($pad * 2);
|
||||||
|
$bgX = (int) (($qrW - $bgSize) / 2);
|
||||||
|
$bgY = (int) (($qrH - $bgSize) / 2);
|
||||||
|
$white = imagecolorallocate($qr, 255, 255, 255);
|
||||||
|
imagefilledrectangle($qr, $bgX, $bgY, $bgX + $bgSize, $bgY + $bgSize, $white);
|
||||||
|
imagecopy($qr, $resized, $bgX + $pad, $bgY + $pad, 0, 0, $target, $target);
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
imagepng($qr);
|
||||||
|
$result = (string) ob_get_clean();
|
||||||
|
imagedestroy($qr);
|
||||||
|
imagedestroy($logo);
|
||||||
|
imagedestroy($resized);
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function pngIsValid(?string $path): bool
|
||||||
|
{
|
||||||
|
if (! $path || ! Storage::disk('qr')->exists($path)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->isValidPngBinary(Storage::disk('qr')->get($path));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize a client-supplied QR SVG before storing it. QR SVGs are paths,
|
||||||
|
* rects, gradients, clip-paths and an embedded data: logo — never scripts.
|
||||||
|
* Strips script/foreignObject, inline event handlers, javascript: URIs and
|
||||||
|
* any non-data external image/href references. Returns null if it isn't an
|
||||||
|
* SVG. Downloads are served as attachments, so this is defence-in-depth.
|
||||||
|
*/
|
||||||
|
public function sanitizeSvg(string $svg): ?string
|
||||||
|
{
|
||||||
|
$svg = trim($svg);
|
||||||
|
if (! preg_match('/<svg[\s>]/i', $svg)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop XML declaration / doctype.
|
||||||
|
$svg = preg_replace('/<\?xml.*?\?>/is', '', $svg);
|
||||||
|
$svg = preg_replace('/<!DOCTYPE.*?>/is', '', $svg);
|
||||||
|
|
||||||
|
// Remove dangerous elements entirely (with or without content).
|
||||||
|
$svg = preg_replace('#<\s*(script|foreignObject|iframe|style)\b[^>]*>.*?<\s*/\s*\1\s*>#is', '', $svg);
|
||||||
|
$svg = preg_replace('#<\s*(script|foreignObject|iframe|style)\b[^>]*/?>#is', '', $svg);
|
||||||
|
|
||||||
|
// Strip inline event handlers (onload, onclick, …).
|
||||||
|
$svg = preg_replace('/\son[a-z]+\s*=\s*"[^"]*"/i', '', $svg);
|
||||||
|
$svg = preg_replace("/\son[a-z]+\s*=\s*'[^']*'/i", '', $svg);
|
||||||
|
|
||||||
|
// Neutralise javascript: in any href / xlink:href.
|
||||||
|
$svg = preg_replace('/((?:xlink:)?href)\s*=\s*"\s*javascript:[^"]*"/i', '$1="#"', $svg);
|
||||||
|
$svg = preg_replace("/((?:xlink:)?href)\s*=\s*'\s*javascript:[^']*'/i", '$1="#"', $svg);
|
||||||
|
|
||||||
|
// Remove external (non-data:) image references; keep embedded data: logos.
|
||||||
|
$svg = preg_replace('/<image\b(?:(?!href)[^>])*(?:xlink:)?href\s*=\s*"(?!data:)[^"]*"[^>]*>/is', '', $svg);
|
||||||
|
|
||||||
|
$svg = trim($svg);
|
||||||
|
if (! str_contains($svg, '<svg')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guarantee namespaces + an XML prolog so the file renders in strict SVG
|
||||||
|
// viewers (Illustrator, Inkscape, macOS Preview, print pipelines), not just
|
||||||
|
// browsers. qr-code-styling's DOM serialization can omit xmlns:xlink, which
|
||||||
|
// browsers tolerate but standalone viewers reject (logo/refs fail to render).
|
||||||
|
if (preg_match('/<svg\b[^>]*>/i', $svg, $m)) {
|
||||||
|
$open = $m[0];
|
||||||
|
$fixed = $open;
|
||||||
|
if (! preg_match('/\sxmlns\s*=/i', $fixed)) {
|
||||||
|
$fixed = preg_replace('/<svg\b/i', '<svg xmlns="http://www.w3.org/2000/svg"', $fixed, 1);
|
||||||
|
}
|
||||||
|
if (str_contains($svg, 'xlink:') && ! preg_match('/\sxmlns:xlink\s*=/i', $fixed)) {
|
||||||
|
$fixed = preg_replace('/<svg\b/i', '<svg xmlns:xlink="http://www.w3.org/1999/xlink"', $fixed, 1);
|
||||||
|
}
|
||||||
|
if ($fixed !== $open) {
|
||||||
|
$svg = substr_replace($svg, $fixed, strpos($svg, $open), strlen($open));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! preg_match('/^\s*<\?xml/i', $svg)) {
|
||||||
|
$svg = '<?xml version="1.0" encoding="UTF-8"?>' . "\n" . $svg;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $svg;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isValidPngBinary(string $bytes): bool
|
||||||
|
{
|
||||||
|
if ($bytes === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($bytes, "\x89PNG\r\n\x1a\n")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy bug: PNG was stored as a base64 string instead of raw bytes.
|
||||||
|
if (str_starts_with($bytes, 'iVBORw0KGgo')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function normalizeStoredPng(?string $path): ?string
|
||||||
|
{
|
||||||
|
if (! $path || ! Storage::disk('qr')->exists($path)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$bytes = Storage::disk('qr')->get($path);
|
||||||
|
|
||||||
|
if ($this->isValidPngBinary($bytes)) {
|
||||||
|
return $bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($bytes, 'iVBORw0KGgo')) {
|
||||||
|
$decoded = base64_decode($bytes, true);
|
||||||
|
if ($decoded !== false && $this->isValidPngBinary($decoded)) {
|
||||||
|
Storage::disk('qr')->put($path, $decoded);
|
||||||
|
|
||||||
|
return $decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,663 @@
|
|||||||
|
<?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_CHURCH => $this->validateChurch($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_BOOK => $this->validateBook($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 = ['church', 'school', 'mosque', 'ngo', 'club'];
|
||||||
|
$orgType = in_array($input['org_type'] ?? 'church', $orgTypes) ? $input['org_type'] : 'church';
|
||||||
|
|
||||||
|
// Normalise legacy lowercase slugs → display strings
|
||||||
|
$legacyMap = ['offering' => 'Offering', 'tithe' => 'Tithe', 'donation' => 'Donation', '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', 'Donation', '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' => filter_var($input['accepts_payment'] ?? false, FILTER_VALIDATE_BOOL),
|
||||||
|
'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', 'Donation', …]
|
||||||
|
$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 validateBook(array $input): array
|
||||||
|
{
|
||||||
|
$title = trim((string) ($input['book_title'] ?? ''));
|
||||||
|
$author = trim((string) ($input['author'] ?? ''));
|
||||||
|
$price = (float) ($input['price_ghs'] ?? 0);
|
||||||
|
|
||||||
|
if ($title === '') {
|
||||||
|
throw new RuntimeException('Enter the book title.');
|
||||||
|
}
|
||||||
|
if ($author === '') {
|
||||||
|
throw new RuntimeException('Enter the author name.');
|
||||||
|
}
|
||||||
|
if ($price <= 0) {
|
||||||
|
throw new RuntimeException('Enter a price greater than zero.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'content' => [
|
||||||
|
'book_title' => $title,
|
||||||
|
'author' => $author,
|
||||||
|
'description' => trim((string) ($input['description'] ?? '')),
|
||||||
|
'price_ghs' => round($price, 2),
|
||||||
|
'cover_path' => $input['cover_path'] ?? null,
|
||||||
|
'file_path' => $input['file_path'] ?? null,
|
||||||
|
'file_type' => $input['file_type'] ?? null,
|
||||||
|
'file_size' => (int) ($input['file_size'] ?? 0),
|
||||||
|
],
|
||||||
|
'destination_url' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $input */
|
||||||
|
private function validateWifi(array $input): array
|
||||||
|
{
|
||||||
|
$ssid = trim((string) ($input['ssid'] ?? ''));
|
||||||
|
if ($ssid === '') {
|
||||||
|
throw new RuntimeException('Enter a WiFi network name (SSID).');
|
||||||
|
}
|
||||||
|
|
||||||
|
$encryption = strtoupper(trim((string) ($input['encryption'] ?? 'WPA')));
|
||||||
|
if (! in_array($encryption, ['WPA', 'WEP', 'NOPASS'], true)) {
|
||||||
|
$encryption = 'WPA';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'content' => [
|
||||||
|
'ssid' => $ssid,
|
||||||
|
'password' => (string) ($input['password'] ?? ''),
|
||||||
|
'encryption' => $encryption,
|
||||||
|
'hidden' => filter_var($input['hidden'] ?? false, FILTER_VALIDATE_BOOL),
|
||||||
|
],
|
||||||
|
'destination_url' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function normalizeSocialUrl(string $platform, string $value): string
|
||||||
|
{
|
||||||
|
// Already a full URL — leave as-is
|
||||||
|
if (str_starts_with($value, 'http://') || str_starts_with($value, 'https://')) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
$handle = ltrim($value, '@');
|
||||||
|
|
||||||
|
return match ($platform) {
|
||||||
|
'linkedin' => 'https://linkedin.com/in/' . $handle,
|
||||||
|
'twitter' => 'https://x.com/' . $handle,
|
||||||
|
'instagram' => 'https://instagram.com/' . $handle,
|
||||||
|
'facebook' => 'https://facebook.com/' . $handle,
|
||||||
|
'tiktok' => 'https://tiktok.com/@' . $handle,
|
||||||
|
'youtube' => 'https://youtube.com/@' . $handle,
|
||||||
|
'snapchat' => 'https://snapchat.com/add/' . $handle,
|
||||||
|
'whatsapp' => 'https://wa.me/' . preg_replace('/\D+/', '', $value),
|
||||||
|
default => $value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function requireUrl(mixed $value, string $message): string
|
||||||
|
{
|
||||||
|
$url = trim((string) $value);
|
||||||
|
if ($url === '' || ! filter_var($url, FILTER_VALIDATE_URL)) {
|
||||||
|
throw new RuntimeException($message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<array{title: string, url: string}> */
|
||||||
|
private function normalizeLinks(mixed $links): array
|
||||||
|
{
|
||||||
|
if (! is_array($links)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = [];
|
||||||
|
foreach ($links as $link) {
|
||||||
|
if (! is_array($link)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$title = trim((string) ($link['title'] ?? ''));
|
||||||
|
$url = trim((string) ($link['url'] ?? ''));
|
||||||
|
if ($title === '' || $url === '' || ! filter_var($url, FILTER_VALIDATE_URL)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$normalized[] = ['title' => $title, 'url' => $url];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize menu/shop sections. Preserves existing image_path on items so that
|
||||||
|
* the manager can inject freshly uploaded paths after validation.
|
||||||
|
*
|
||||||
|
* @return list<array{name: string, items: list<array{name: string, description: string, price: string, image_path: ?string}>}>
|
||||||
|
*/
|
||||||
|
private function normalizeMenuSections(mixed $sections): array
|
||||||
|
{
|
||||||
|
if (! is_array($sections)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = [];
|
||||||
|
foreach ($sections as $section) {
|
||||||
|
if (! is_array($section)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$name = trim((string) ($section['name'] ?? ''));
|
||||||
|
$items = [];
|
||||||
|
foreach (($section['items'] ?? []) as $item) {
|
||||||
|
if (! is_array($item)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$itemName = trim((string) ($item['name'] ?? ''));
|
||||||
|
if ($itemName === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$items[] = [
|
||||||
|
'name' => $itemName,
|
||||||
|
'description' => trim((string) ($item['description'] ?? '')),
|
||||||
|
'price' => trim((string) ($item['price'] ?? '')),
|
||||||
|
'image_path' => ($item['image_path'] ?? null) ?: null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if ($name !== '' && $items !== []) {
|
||||||
|
$normalized[] = ['name' => $name, 'items' => $items];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function validateImageUpload(?UploadedFile $file): void
|
||||||
|
{
|
||||||
|
if (! $file instanceof UploadedFile) {
|
||||||
|
throw new RuntimeException('Upload at least one image.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$mime = $file->getMimeType() ?: '';
|
||||||
|
if (! str_starts_with($mime, 'image/')) {
|
||||||
|
throw new RuntimeException('Only image files are supported.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $input */
|
||||||
|
private function validatePayment(array $input): array
|
||||||
|
{
|
||||||
|
$businessName = trim((string) ($input['business_name'] ?? ''));
|
||||||
|
if ($businessName === '') {
|
||||||
|
throw new RuntimeException('Enter your business or display name.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'content' => [
|
||||||
|
'business_name' => mb_substr($businessName, 0, 120),
|
||||||
|
'branch_label' => mb_substr(trim((string) ($input['branch_label'] ?? '')), 0, 80) ?: null,
|
||||||
|
'currency' => strtoupper(trim((string) ($input['currency'] ?? 'GHS'))) ?: 'GHS',
|
||||||
|
],
|
||||||
|
'destination_url' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Qr;
|
||||||
|
|
||||||
|
class QrPdfExporter
|
||||||
|
{
|
||||||
|
public function fromPng(string $pngBinary, string $title): string
|
||||||
|
{
|
||||||
|
if (! extension_loaded('gd')) {
|
||||||
|
throw new \RuntimeException('PDF export requires the GD extension.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$image = imagecreatefromstring($pngBinary);
|
||||||
|
if ($image === false) {
|
||||||
|
throw new \RuntimeException('Could not read QR image for PDF export.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$width = imagesx($image);
|
||||||
|
$height = imagesy($image);
|
||||||
|
$canvas = imagecreatetruecolor($width, $height);
|
||||||
|
$white = imagecolorallocate($canvas, 255, 255, 255);
|
||||||
|
imagefilledrectangle($canvas, 0, 0, $width, $height, $white);
|
||||||
|
imagecopy($canvas, $image, 0, 0, 0, 0, $width, $height);
|
||||||
|
imagedestroy($image);
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
imagejpeg($canvas, null, 95);
|
||||||
|
$jpeg = (string) ob_get_clean();
|
||||||
|
imagedestroy($canvas);
|
||||||
|
|
||||||
|
return $this->buildPdf($jpeg, $title, $width, $height);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildPdf(string $jpeg, string $title, int $imgW, int $imgH): string
|
||||||
|
{
|
||||||
|
$pageW = 595.28;
|
||||||
|
$pageH = 841.89;
|
||||||
|
$margin = 48;
|
||||||
|
$maxQr = min($pageW - ($margin * 2), 320);
|
||||||
|
$scale = min($maxQr / max(1, $imgW), $maxQr / max(1, $imgH));
|
||||||
|
$drawW = $imgW * $scale;
|
||||||
|
$drawH = $imgH * $scale;
|
||||||
|
$x = ($pageW - $drawW) / 2;
|
||||||
|
$y = 120;
|
||||||
|
$safeTitle = $this->pdfEscape(substr($title, 0, 80));
|
||||||
|
|
||||||
|
$objects = [];
|
||||||
|
$objects[] = "1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n";
|
||||||
|
$objects[] = "2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n";
|
||||||
|
$objects[] = sprintf(
|
||||||
|
"3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 %.2F %.2F] /Resources << /Font << /F1 4 0 R >> /XObject << /Im1 5 0 R >> >> /Contents 6 0 R >> endobj\n",
|
||||||
|
$pageW,
|
||||||
|
$pageH,
|
||||||
|
);
|
||||||
|
$objects[] = "4 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >> endobj\n";
|
||||||
|
$objects[] = sprintf(
|
||||||
|
"5 0 obj << /Type /XObject /Subtype /Image /Width %d /Height %d /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length %d >> stream\n%s\nendstream\nendobj\n",
|
||||||
|
$imgW,
|
||||||
|
$imgH,
|
||||||
|
strlen($jpeg),
|
||||||
|
$jpeg,
|
||||||
|
);
|
||||||
|
|
||||||
|
$content = "BT /F1 16 Tf 48 " . ($pageH - 72) . " Td ({$safeTitle}) Tj ET\n";
|
||||||
|
$content .= sprintf("q %.4F 0 0 %.4F %.2F %.2F cm /Im1 Do Q\n", $drawW, $drawH, $x, $pageH - $y - $drawH);
|
||||||
|
$content .= "BT /F1 10 Tf 48 48 Td (Generated by Ladill QR Codes) Tj ET\n";
|
||||||
|
$objects[] = sprintf("6 0 obj << /Length %d >> stream\n%s\nendstream\nendobj\n", strlen($content), $content);
|
||||||
|
|
||||||
|
$pdf = "%PDF-1.4\n";
|
||||||
|
$offsets = [0];
|
||||||
|
|
||||||
|
foreach ($objects as $object) {
|
||||||
|
$offsets[] = strlen($pdf);
|
||||||
|
$pdf .= $object;
|
||||||
|
}
|
||||||
|
|
||||||
|
$xrefPos = strlen($pdf);
|
||||||
|
$pdf .= "xref\n0 " . count($offsets) . "\n";
|
||||||
|
$pdf .= "0000000000 65535 f \n";
|
||||||
|
for ($i = 1; $i < count($offsets); $i++) {
|
||||||
|
$pdf .= sprintf("%010d 00000 n \n", $offsets[$i]);
|
||||||
|
}
|
||||||
|
$pdf .= "trailer << /Size " . count($offsets) . " /Root 1 0 R >>\n";
|
||||||
|
$pdf .= "startxref\n{$xrefPos}\n%%EOF";
|
||||||
|
|
||||||
|
return $pdf;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function pdfEscape(string $text): string
|
||||||
|
{
|
||||||
|
return str_replace(['\\', '(', ')'], ['\\\\', '\\(', '\\)'], $text);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Qr;
|
||||||
|
|
||||||
|
use App\Models\QrCode;
|
||||||
|
use App\Models\QrScanEvent;
|
||||||
|
use App\Models\QrWallet;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class QrScanRecorder
|
||||||
|
{
|
||||||
|
public function record(QrCode $qrCode, Request $request): QrScanEvent
|
||||||
|
{
|
||||||
|
$parsed = $this->parseUserAgent((string) $request->userAgent());
|
||||||
|
$ipHash = $this->hashIp((string) $request->ip());
|
||||||
|
$windowHours = (int) config('qr.scan_unique_window_hours', 24);
|
||||||
|
$isUnique = ! QrScanEvent::query()
|
||||||
|
->where('qr_code_id', $qrCode->id)
|
||||||
|
->where('ip_hash', $ipHash)
|
||||||
|
->where('scanned_at', '>=', now()->subHours($windowHours))
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
$event = QrScanEvent::create([
|
||||||
|
'qr_code_id' => $qrCode->id,
|
||||||
|
'scanned_at' => now(),
|
||||||
|
'ip_hash' => $ipHash,
|
||||||
|
'user_agent' => Str::limit((string) $request->userAgent(), 500, ''),
|
||||||
|
'device_type' => $parsed['device_type'],
|
||||||
|
'browser' => $parsed['browser'],
|
||||||
|
'os' => $parsed['os'],
|
||||||
|
'referrer' => Str::limit((string) $request->headers->get('referer'), 255, ''),
|
||||||
|
'is_unique' => $isUnique,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$qrCode->increment('scans_total');
|
||||||
|
if ($isUnique) {
|
||||||
|
$qrCode->increment('unique_scans_total');
|
||||||
|
}
|
||||||
|
$qrCode->update(['last_scanned_at' => now()]);
|
||||||
|
|
||||||
|
QrWallet::query()
|
||||||
|
->where('user_id', $qrCode->user_id)
|
||||||
|
->increment('scans_total');
|
||||||
|
|
||||||
|
return $event;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hashIp(string $ip): string
|
||||||
|
{
|
||||||
|
return hash('sha256', $ip . '|' . (string) config('app.key'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{device_type: string, browser: string, os: string}
|
||||||
|
*/
|
||||||
|
private function parseUserAgent(string $ua): array
|
||||||
|
{
|
||||||
|
$uaLower = strtolower($ua);
|
||||||
|
|
||||||
|
$device = str_contains($uaLower, 'mobile') || str_contains($uaLower, 'android') || str_contains($uaLower, 'iphone')
|
||||||
|
? 'mobile'
|
||||||
|
: (str_contains($uaLower, 'tablet') || str_contains($uaLower, 'ipad') ? 'tablet' : 'desktop');
|
||||||
|
|
||||||
|
$browser = match (true) {
|
||||||
|
str_contains($uaLower, 'edg/') => 'Edge',
|
||||||
|
str_contains($uaLower, 'chrome/') && ! str_contains($uaLower, 'edg/') => 'Chrome',
|
||||||
|
str_contains($uaLower, 'safari/') && ! str_contains($uaLower, 'chrome/') => 'Safari',
|
||||||
|
str_contains($uaLower, 'firefox/') => 'Firefox',
|
||||||
|
default => 'Other',
|
||||||
|
};
|
||||||
|
|
||||||
|
$os = match (true) {
|
||||||
|
str_contains($uaLower, 'iphone') || str_contains($uaLower, 'ipad') => 'iOS',
|
||||||
|
str_contains($uaLower, 'android') => 'Android',
|
||||||
|
str_contains($uaLower, 'windows') => 'Windows',
|
||||||
|
str_contains($uaLower, 'mac os') || str_contains($uaLower, 'macintosh') => 'macOS',
|
||||||
|
str_contains($uaLower, 'linux') => 'Linux',
|
||||||
|
default => 'Other',
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
'device_type' => $device,
|
||||||
|
'browser' => $browser,
|
||||||
|
'os' => $os,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user