Scaffold Ladill Woo Manager for WooCommerce order fulfillment.
Deploy Ladill Woo Manager / deploy (push) Failing after 2s
Deploy Ladill Woo Manager / deploy (push) Failing after 2s
Standalone app with SSO shell, WordPress plugin connect flow, webhook ingest, fulfillment inbox, and plugin activation API — no payment processing. 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,32 @@
|
|||||||
|
APP_NAME="Ladill Woo Manager"
|
||||||
|
APP_ENV=local
|
||||||
|
APP_KEY=
|
||||||
|
APP_DEBUG=true
|
||||||
|
APP_URL=http://localhost:8088
|
||||||
|
|
||||||
|
PLATFORM_URL=https://ladill.com
|
||||||
|
PLATFORM_DOMAIN=ladill.com
|
||||||
|
AUTH_DOMAIN=auth.ladill.com
|
||||||
|
ACCOUNT_DOMAIN=account.ladill.com
|
||||||
|
WOO_MANAGER_DOMAIN=woo.ladill.com
|
||||||
|
|
||||||
|
DB_CONNECTION=sqlite
|
||||||
|
DB_DATABASE=database/database.sqlite
|
||||||
|
|
||||||
|
SESSION_DRIVER=array
|
||||||
|
CACHE_STORE=array
|
||||||
|
QUEUE_CONNECTION=sync
|
||||||
|
|
||||||
|
LADILL_SSO_CLIENT_ID=
|
||||||
|
LADILL_SSO_CLIENT_SECRET=
|
||||||
|
|
||||||
|
BILLING_API_URL=https://ladill.com/api/billing
|
||||||
|
BILLING_API_KEY_WOO_MANAGER=
|
||||||
|
|
||||||
|
IDENTITY_API_URL=https://ladill.com/api
|
||||||
|
IDENTITY_API_KEY_WOO_MANAGER=
|
||||||
|
|
||||||
|
WOO_MANAGER_PLUGIN_CLIENT_ID=
|
||||||
|
WOO_MANAGER_PLUGIN_CLIENT_SECRET=
|
||||||
|
|
||||||
|
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 Woo Manager
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-woo-manager
|
||||||
|
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-woo-manager-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz
|
||||||
|
WORKSPACE: /tmp/${{ gitea.repository_owner }}-woo-manager-${{ gitea.run_id }}-${{ gitea.run_attempt }}
|
||||||
|
LADILL_APP_ROOT: /var/www/ladill-woo-manager
|
||||||
|
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-woo-manager-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 woo.ladill.com vhost manually"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if sudo -n bash "$NGINX_SCRIPT" woo --app /var/www/ladill-woo-manager/current; then
|
||||||
|
echo "nginx vhost updated for woo.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,74 @@
|
|||||||
|
# Ladill Woo Manager — deploy runbook
|
||||||
|
|
||||||
|
Standalone app for **WooCommerce order fulfillment** at `woo.ladill.com`.
|
||||||
|
Merchants connect stores via the Ladill WordPress plugin (Sign in with Ladill).
|
||||||
|
Payments stay in WooCommerce — this app only syncs orders and fulfillment status.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Prerequisites
|
||||||
|
|
||||||
|
| Secret | Where |
|
||||||
|
|---|---|
|
||||||
|
| `LADILL_SSO_CLIENT_ID` / `LADILL_SSO_CLIENT_SECRET` | `passport:client` on platform |
|
||||||
|
| `BILLING_API_KEY_WOO_MANAGER` | platform `.env` + this app |
|
||||||
|
| `IDENTITY_API_KEY_WOO_MANAGER` | platform `.env` + this app |
|
||||||
|
| `WOO_MANAGER_PLUGIN_CLIENT_ID` / `WOO_MANAGER_PLUGIN_CLIENT_SECRET` | OAuth client for WP plugin (future) |
|
||||||
|
|
||||||
|
## 1. Gitea repo + CI
|
||||||
|
|
||||||
|
1. Repo: **ladill-woo-manager**
|
||||||
|
2. Push to `main` triggers `.gitea/workflows/deploy.yml`.
|
||||||
|
3. App root: `/var/www/ladill-woo-manager`
|
||||||
|
|
||||||
|
## 2. Server app-slot + database
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo install -d -o deploy -g www-data /var/www/ladill-woo-manager
|
||||||
|
sudo install -d -o deploy -g www-data /var/www/ladill-woo-manager/{releases,shared}
|
||||||
|
sudo mysql -e "CREATE DATABASE ladill_woo_manager CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
|
||||||
|
sudo mysql -e "CREATE USER 'ladill_woo_manager'@'127.0.0.1' IDENTIFIED BY '<pw>';"
|
||||||
|
sudo mysql -e "GRANT ALL ON ladill_woo_manager.* TO 'ladill_woo_manager'@'127.0.0.1'; FLUSH PRIVILEGES;"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Register OIDC client (platform)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan passport:client \
|
||||||
|
--name="Ladill Woo Manager" \
|
||||||
|
--redirect_uri="https://woo.ladill.com/sso/callback"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Platform integration
|
||||||
|
|
||||||
|
```env
|
||||||
|
BILLING_API_KEY_WOO_MANAGER=<same>
|
||||||
|
IDENTITY_API_KEY_WOO_MANAGER=<same>
|
||||||
|
RP_WOO_MANAGER_FRONTCHANNEL_LOGOUT=https://woo.ladill.com/sso/logout-frontchannel
|
||||||
|
LADILL_WOO_MANAGER_APP_URL=https://woo.ladill.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `woo` to `config/pdns.php` service subdomains via `php artisan ladill:onboard-app woo --run-dns`.
|
||||||
|
|
||||||
|
## 5. nginx + TLS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo deployment/setup-service-subdomain-nginx.sh woo --app /var/www/ladill-woo-manager/current
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. First deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/ladill-woo-manager/current
|
||||||
|
php artisan migrate --force
|
||||||
|
php artisan config:cache route:cache view:cache
|
||||||
|
```
|
||||||
|
|
||||||
|
## WordPress plugin flow
|
||||||
|
|
||||||
|
1. Merchant installs **Ladill Woo Manager** plugin on their WooCommerce store.
|
||||||
|
2. Plugin redirects to `https://woo.ladill.com/connect/wordpress?site_url=…&return_url=…`
|
||||||
|
3. Merchant signs in with Ladill; store is registered and an install token is returned.
|
||||||
|
4. Plugin calls `POST /api/v1/stores/activate` with the install token.
|
||||||
|
5. Plugin registers WooCommerce webhooks to `https://woo.ladill.com/webhooks/woocommerce/{store_id}`.
|
||||||
|
6. Orders appear in Woo Manager; fulfillment updates push back to the plugin REST API.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Ladill Woo Manager
|
||||||
|
|
||||||
|
Fulfill **WooCommerce** orders from Ladill — no payment processing. Merchants connect stores via the Ladill WordPress plugin (Sign in with Ladill).
|
||||||
|
|
||||||
|
- **App:** `https://woo.ladill.com`
|
||||||
|
- **Repo:** `ladill-woo-manager`
|
||||||
|
|
||||||
|
## Features (MVP)
|
||||||
|
|
||||||
|
- Sign in with Ladill (SSO)
|
||||||
|
- WordPress plugin connect flow (`/connect/wordpress`)
|
||||||
|
- WooCommerce webhook ingest → order inbox
|
||||||
|
- Fulfillment status updates (sync back to plugin REST API)
|
||||||
|
- Multi-store per account
|
||||||
|
|
||||||
|
## Local development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
composer install
|
||||||
|
cp .env.example .env
|
||||||
|
php artisan key:generate
|
||||||
|
touch database/database.sqlite
|
||||||
|
php artisan migrate
|
||||||
|
npm install && npm run build
|
||||||
|
php artisan serve --port=8088
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bootstrap
|
||||||
|
|
||||||
|
This repo was scaffolded from `ladill-invoice` via `scripts/bootstrap-ladill-woo-manager.sh` in the Ladill monolith.
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\WooStore;
|
||||||
|
use App\Services\Woo\InstallTokenService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class StoreActivationController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private InstallTokenService $tokens) {}
|
||||||
|
|
||||||
|
public function activate(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'install_token' => ['required', 'string'],
|
||||||
|
'site_url' => ['required', 'url', 'max:500'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$store = $this->tokens->consume($validated['install_token']);
|
||||||
|
} catch (RuntimeException $e) {
|
||||||
|
return response()->json(['message' => $e->getMessage()], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$parsed = parse_url($validated['site_url']);
|
||||||
|
$scheme = $parsed['scheme'] ?? 'https';
|
||||||
|
$host = strtolower((string) ($parsed['host'] ?? ''));
|
||||||
|
$siteUrl = rtrim($scheme.'://'.$host, '/');
|
||||||
|
if ($store->site_url !== $siteUrl) {
|
||||||
|
return response()->json(['message' => 'Site URL does not match the connection request.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pluginToken = $this->tokens->issuePluginToken($store);
|
||||||
|
$store = $store->fresh();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'store_id' => $store->public_id,
|
||||||
|
'webhook_url' => $store->webhookUrl(),
|
||||||
|
'webhook_secret' => $store->webhook_secret,
|
||||||
|
'plugin_token' => $pluginToken,
|
||||||
|
'webhook_topics' => config('woo.webhook_topics'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$store = $this->storeFromRequest($request);
|
||||||
|
if (! $store) {
|
||||||
|
return response()->json(['message' => 'Unauthorized.'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'store_id' => $store->public_id,
|
||||||
|
'site_url' => $store->site_url,
|
||||||
|
'site_name' => $store->site_name,
|
||||||
|
'status' => $store->status,
|
||||||
|
'webhook_url' => $store->webhookUrl(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function storeFromRequest(Request $request): ?WooStore
|
||||||
|
{
|
||||||
|
$storeId = (string) $request->header('X-Ladill-Store', '');
|
||||||
|
$token = (string) $request->bearerToken();
|
||||||
|
if ($storeId === '' || $token === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$store = WooStore::query()->where('public_id', $storeId)->first();
|
||||||
|
if (! $store || ! $this->tokens->verifyPluginToken($store, $token)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $store;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
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
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Max consecutive failed callbacks before we stop bouncing back into the
|
||||||
|
* authorize flow and show an error page instead. Without this, a persistent
|
||||||
|
* upstream failure (e.g. the token endpoint erroring) produces an infinite
|
||||||
|
* /sso/callback ↔ /sso/connect redirect loop in the browser.
|
||||||
|
*/
|
||||||
|
private const MAX_SSO_ATTEMPTS = 3;
|
||||||
|
|
||||||
|
public function connect(Request $request): RedirectResponse|View
|
||||||
|
{
|
||||||
|
$intended = (string) $request->query('redirect', route('woo.dashboard'));
|
||||||
|
|
||||||
|
if (Auth::check()) {
|
||||||
|
return $this->safeRedirect($intended, route('woo.dashboard'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fresh, user-initiated sign-in (not a post-failure retry) resets the
|
||||||
|
// loop guard so the counter only accrues across an actual failure loop.
|
||||||
|
if (! $request->boolean('fallback')) {
|
||||||
|
$request->session()->forget('sso.attempts');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->attemptSilentRefresh($request, $intended)) {
|
||||||
|
return $this->safeRedirect($intended, route('woo.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()->forget('sso.popup');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->boolean('interactive') && ! $request->boolean('fallback')) {
|
||||||
|
$request->session()->forget('sso.popup');
|
||||||
|
|
||||||
|
return redirect()->away($authorizeUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->away($authorizeUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function callback(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$intended = (string) $request->session()->get('sso.intended', route('woo.dashboard'));
|
||||||
|
|
||||||
|
$popup = (bool) $request->session()->pull('sso.popup', false);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
Auth::login($user, remember: true);
|
||||||
|
$request->session()->regenerate();
|
||||||
|
$request->session()->forget('sso.attempts');
|
||||||
|
|
||||||
|
return $this->finishCallback($request, $intended, null, $popup);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function failed(Request $request): View
|
||||||
|
{
|
||||||
|
return view('auth.sso-error', [
|
||||||
|
'reason' => (string) $request->session()->get('sso.error', ''),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
{
|
||||||
|
if ($error) {
|
||||||
|
$attempts = (int) $request->session()->get('sso.attempts', 0) + 1;
|
||||||
|
|
||||||
|
if ($attempts >= self::MAX_SSO_ATTEMPTS) {
|
||||||
|
$request->session()->forget(['sso.attempts', 'sso.state', 'sso.verifier', 'sso.intended']);
|
||||||
|
$request->session()->flash('sso.error', $error);
|
||||||
|
|
||||||
|
return redirect()->route('sso.failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->session()->put('sso.attempts', $attempts);
|
||||||
|
|
||||||
|
return redirect()->route('sso.connect', [
|
||||||
|
'redirect' => $intended,
|
||||||
|
'interactive' => 1,
|
||||||
|
'fallback' => 1,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->safeRedirect($intended, route('woo.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;
|
||||||
|
|
||||||
|
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,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,97 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Woo;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\WooStore;
|
||||||
|
use App\Services\Woo\InstallTokenService;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class ConnectWordPressController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private InstallTokenService $tokens) {}
|
||||||
|
|
||||||
|
public function start(Request $request): RedirectResponse|View
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'site_url' => ['required', 'url', 'max:500'],
|
||||||
|
'return_url' => ['required', 'url', 'max:2048'],
|
||||||
|
'site_name' => ['nullable', 'string', 'max:160'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$siteUrl = $this->normalizeSiteUrl($validated['site_url']);
|
||||||
|
if (! $this->isAllowedReturnUrl($validated['return_url'], $siteUrl)) {
|
||||||
|
abort(422, 'Return URL must belong to the WooCommerce site.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->session()->put('woo.connect', [
|
||||||
|
'site_url' => $siteUrl,
|
||||||
|
'return_url' => $validated['return_url'],
|
||||||
|
'site_name' => $validated['site_name'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! $request->user()) {
|
||||||
|
return redirect()->route('sso.connect', [
|
||||||
|
'redirect' => $request->fullUrl(),
|
||||||
|
'interactive' => 1,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->complete($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function complete(Request $request): RedirectResponse|View
|
||||||
|
{
|
||||||
|
$pending = (array) $request->session()->pull('woo.connect', []);
|
||||||
|
if ($pending === []) {
|
||||||
|
return redirect()->route('woo.dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $request->user();
|
||||||
|
abort_if(! $user, 401);
|
||||||
|
|
||||||
|
$siteUrl = (string) ($pending['site_url'] ?? '');
|
||||||
|
$returnUrl = (string) ($pending['return_url'] ?? '');
|
||||||
|
|
||||||
|
$store = WooStore::query()->updateOrCreate(
|
||||||
|
[
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'site_url' => $siteUrl,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'site_name' => $pending['site_name'] ?? parse_url($siteUrl, PHP_URL_HOST),
|
||||||
|
'return_url' => $returnUrl,
|
||||||
|
'status' => WooStore::STATUS_PENDING,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$issued = $this->tokens->issue($store);
|
||||||
|
$separator = str_contains($returnUrl, '?') ? '&' : '?';
|
||||||
|
|
||||||
|
return redirect()->away($returnUrl.$separator.http_build_query([
|
||||||
|
'ladill_connect' => '1',
|
||||||
|
'install_token' => $issued['token'],
|
||||||
|
'store_id' => $store->public_id,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeSiteUrl(string $url): string
|
||||||
|
{
|
||||||
|
$parsed = parse_url($url);
|
||||||
|
$scheme = $parsed['scheme'] ?? 'https';
|
||||||
|
$host = strtolower((string) ($parsed['host'] ?? ''));
|
||||||
|
|
||||||
|
return rtrim($scheme.'://'.$host, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isAllowedReturnUrl(string $returnUrl, string $siteUrl): bool
|
||||||
|
{
|
||||||
|
$returnHost = strtolower((string) (parse_url($returnUrl, PHP_URL_HOST) ?: ''));
|
||||||
|
$siteHost = strtolower((string) (parse_url($siteUrl, PHP_URL_HOST) ?: ''));
|
||||||
|
|
||||||
|
return $returnHost !== '' && $returnHost === $siteHost;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Woo;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\WooOrder;
|
||||||
|
use App\Models\WooStore;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class DashboardController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): View
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
$storeIds = WooStore::query()->where('user_id', $user->id)->pluck('id');
|
||||||
|
|
||||||
|
$stats = [
|
||||||
|
'stores' => WooStore::query()->where('user_id', $user->id)->where('status', WooStore::STATUS_ACTIVE)->count(),
|
||||||
|
'orders_new' => WooOrder::query()->whereIn('woo_store_id', $storeIds)->where('fulfillment_status', WooOrder::FULFILLMENT_NEW)->count(),
|
||||||
|
'orders_open' => WooOrder::query()->whereIn('woo_store_id', $storeIds)->whereNotIn('fulfillment_status', [
|
||||||
|
WooOrder::FULFILLMENT_DELIVERED,
|
||||||
|
WooOrder::FULFILLMENT_CANCELLED,
|
||||||
|
])->count(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$recent = WooOrder::query()
|
||||||
|
->whereIn('woo_store_id', $storeIds)
|
||||||
|
->with('store')
|
||||||
|
->latest('created_at')
|
||||||
|
->limit(8)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return view('woo.dashboard', compact('stats', 'recent'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Woo;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\WooOrder;
|
||||||
|
use App\Models\WooStore;
|
||||||
|
use App\Services\Woo\FulfillmentSyncService;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class OrderController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private FulfillmentSyncService $sync) {}
|
||||||
|
|
||||||
|
public function index(Request $request): View
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
$search = trim((string) $request->query('q', ''));
|
||||||
|
$storeFilter = $request->query('store');
|
||||||
|
|
||||||
|
$storeIds = WooStore::query()->where('user_id', $user->id)->pluck('id');
|
||||||
|
|
||||||
|
$orders = WooOrder::query()
|
||||||
|
->whereIn('woo_store_id', $storeIds)
|
||||||
|
->when($storeFilter, fn ($q) => $q->where('woo_store_id', $storeFilter))
|
||||||
|
->when($search !== '', function ($query) use ($search) {
|
||||||
|
$like = '%'.$search.'%';
|
||||||
|
$query->where(function ($inner) use ($like) {
|
||||||
|
$inner->where('customer_name', 'like', $like)
|
||||||
|
->orWhere('customer_email', 'like', $like)
|
||||||
|
->orWhere('order_number', 'like', $like);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->with('store')
|
||||||
|
->latest('created_at')
|
||||||
|
->paginate(20)
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
$stores = WooStore::query()->where('user_id', $user->id)->orderBy('site_name')->get();
|
||||||
|
|
||||||
|
return view('woo.orders.index', compact('orders', 'stores', 'search', 'storeFilter'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateStatus(Request $request, WooOrder $order): RedirectResponse
|
||||||
|
{
|
||||||
|
$order->load('store');
|
||||||
|
abort_if($order->store?->user_id !== $request->user()->id, 403);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'fulfillment_status' => ['required', Rule::in(array_keys(WooOrder::FULFILLMENT_STATUSES))],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$order->update(['fulfillment_status' => $validated['fulfillment_status']]);
|
||||||
|
$this->sync->push($order->fresh());
|
||||||
|
|
||||||
|
return back()->with('success', 'Fulfillment status updated.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Woo;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\WooStore;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class StoreController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): View
|
||||||
|
{
|
||||||
|
$stores = WooStore::query()
|
||||||
|
->where('user_id', $request->user()->id)
|
||||||
|
->latest('updated_at')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return view('woo.stores.index', compact('stores'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request, WooStore $store): RedirectResponse
|
||||||
|
{
|
||||||
|
abort_if($store->user_id !== $request->user()->id, 403);
|
||||||
|
|
||||||
|
$store->update(['status' => WooStore::STATUS_DISCONNECTED]);
|
||||||
|
|
||||||
|
return back()->with('success', 'Store disconnected. Install the Ladill plugin again to reconnect.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\WooStore;
|
||||||
|
use App\Services\Woo\OrderIngestService;
|
||||||
|
use App\Services\Woo\WooWebhookVerifier;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class WooWebhookController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private WooWebhookVerifier $verifier,
|
||||||
|
private OrderIngestService $ingest,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(Request $request, string $storePublicId): JsonResponse
|
||||||
|
{
|
||||||
|
$store = WooStore::query()
|
||||||
|
->where('public_id', $storePublicId)
|
||||||
|
->where('status', WooStore::STATUS_ACTIVE)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $store) {
|
||||||
|
return response()->json(['message' => 'Store not found.'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->verifier->verify($request, $store)) {
|
||||||
|
return response()->json(['message' => 'Invalid signature.'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$topic = (string) $request->header('X-WC-Webhook-Topic', '');
|
||||||
|
if (! in_array($topic, (array) config('woo.webhook_topics', []), true)) {
|
||||||
|
return response()->json(['ignored' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var array<string, mixed> $payload */
|
||||||
|
$payload = $request->json()->all();
|
||||||
|
if ($payload === []) {
|
||||||
|
return response()->json(['ignored' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$order = $this->ingest->ingest($store, $payload);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'order_id' => $order->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?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.
|
||||||
|
*
|
||||||
|
* Re-checks auth.ladill.com at most once per TTL — not on every request.
|
||||||
|
* Client-side sso-keepalive still pings periodically between checks.
|
||||||
|
*/
|
||||||
|
class EnsurePlatformSession
|
||||||
|
{
|
||||||
|
private const VERIFY_TTL_SECONDS = 90;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
$verifiedAt = (int) $request->session()->get('platform_session_verified_at', 0);
|
||||||
|
if ($verifiedAt > 0 && (time() - $verifiedAt) < self::VERIFY_TTL_SECONDS) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
$cookieHeader = (string) $request->headers->get('Cookie', '');
|
||||||
|
if ($cookieHeader === '') {
|
||||||
|
return $this->clearAppSession($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = Http::withHeaders(['Cookie' => $cookieHeader])
|
||||||
|
->timeout(3)
|
||||||
|
->connectTimeout(2)
|
||||||
|
->get('https://'.$authDomain.'/sso/ping');
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response->status() === 401) {
|
||||||
|
return $this->clearAppSession($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response->successful()) {
|
||||||
|
$request->session()->put('platform_session_verified_at', time());
|
||||||
|
}
|
||||||
|
|
||||||
|
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,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\View;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injects a branded boot splash (Ladill Mail style) into authenticated HTML
|
||||||
|
* pages. The splash shows once per browser session while the app loads, then
|
||||||
|
* fades out — masking the client-side boot so the app feels instant.
|
||||||
|
*/
|
||||||
|
class InjectBootSplash
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
$response = $next($request);
|
||||||
|
|
||||||
|
if (! $request->isMethod('GET') || ! Auth::check()) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! str_contains((string) $response->headers->get('Content-Type', ''), 'text/html')) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
$content = $response->getContent();
|
||||||
|
if (! is_string($content)
|
||||||
|
|| stripos($content, '<body') === false
|
||||||
|
|| str_contains($content, 'id="ladill-boot"')) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
$splash = View::make('partials.boot-splash')->render();
|
||||||
|
$content = preg_replace_callback('/<body\b[^>]*>/i', fn ($m) => $m[0].$splash, $content, 1);
|
||||||
|
|
||||||
|
if (is_string($content)) {
|
||||||
|
$response->setContent($content);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class SetActingAccount
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
if ($user = $request->user()) {
|
||||||
|
$request->attributes->set('actingAccount', $user);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,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,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,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Database\Factories\UserFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
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 wooStores(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(WooStore::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function avatarUrl(): ?string
|
||||||
|
{
|
||||||
|
$url = trim((string) $this->avatar_url);
|
||||||
|
|
||||||
|
return $url !== '' ? $url : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class WooOrder extends Model
|
||||||
|
{
|
||||||
|
public const FULFILLMENT_NEW = 'new';
|
||||||
|
public const FULFILLMENT_CONFIRMED = 'confirmed';
|
||||||
|
public const FULFILLMENT_PREPARING = 'preparing';
|
||||||
|
public const FULFILLMENT_READY = 'ready';
|
||||||
|
public const FULFILLMENT_DELIVERED = 'delivered';
|
||||||
|
public const FULFILLMENT_CANCELLED = 'cancelled';
|
||||||
|
|
||||||
|
public const FULFILLMENT_STATUSES = [
|
||||||
|
self::FULFILLMENT_NEW => 'New',
|
||||||
|
self::FULFILLMENT_CONFIRMED => 'Confirmed',
|
||||||
|
self::FULFILLMENT_PREPARING => 'Preparing',
|
||||||
|
self::FULFILLMENT_READY => 'Ready',
|
||||||
|
self::FULFILLMENT_DELIVERED => 'Delivered',
|
||||||
|
self::FULFILLMENT_CANCELLED => 'Cancelled',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'woo_store_id',
|
||||||
|
'external_order_id',
|
||||||
|
'order_number',
|
||||||
|
'wc_status',
|
||||||
|
'payment_status',
|
||||||
|
'customer_name',
|
||||||
|
'customer_email',
|
||||||
|
'customer_phone',
|
||||||
|
'line_items',
|
||||||
|
'billing_address',
|
||||||
|
'shipping_address',
|
||||||
|
'currency',
|
||||||
|
'total_minor',
|
||||||
|
'fulfillment_status',
|
||||||
|
'wc_updated_at',
|
||||||
|
'metadata',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'line_items' => 'array',
|
||||||
|
'billing_address' => 'array',
|
||||||
|
'shipping_address' => 'array',
|
||||||
|
'metadata' => 'array',
|
||||||
|
'total_minor' => 'integer',
|
||||||
|
'wc_updated_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function store(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(WooStore::class, 'woo_store_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fulfillmentLabel(): string
|
||||||
|
{
|
||||||
|
return self::FULFILLMENT_STATUSES[$this->fulfillment_status ?? self::FULFILLMENT_NEW]
|
||||||
|
?? ucfirst((string) $this->fulfillment_status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function totalFormatted(): string
|
||||||
|
{
|
||||||
|
$minor = (int) $this->total_minor;
|
||||||
|
$currency = strtoupper((string) ($this->currency ?: 'GHS'));
|
||||||
|
|
||||||
|
return $currency.' '.number_format($minor / 100, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class WooStore extends Model
|
||||||
|
{
|
||||||
|
public const STATUS_PENDING = 'pending';
|
||||||
|
public const STATUS_ACTIVE = 'active';
|
||||||
|
public const STATUS_DISCONNECTED = 'disconnected';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'public_id',
|
||||||
|
'site_url',
|
||||||
|
'site_name',
|
||||||
|
'return_url',
|
||||||
|
'webhook_secret',
|
||||||
|
'plugin_token_hash',
|
||||||
|
'status',
|
||||||
|
'connected_at',
|
||||||
|
'last_webhook_at',
|
||||||
|
'metadata',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'connected_at' => 'datetime',
|
||||||
|
'last_webhook_at' => 'datetime',
|
||||||
|
'metadata' => 'array',
|
||||||
|
'webhook_secret' => 'encrypted',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::creating(function (self $store) {
|
||||||
|
if (! $store->public_id) {
|
||||||
|
$store->public_id = (string) Str::uuid();
|
||||||
|
}
|
||||||
|
if (! $store->webhook_secret) {
|
||||||
|
$store->webhook_secret = Str::random(48);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function orders(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(WooOrder::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function webhookUrl(): string
|
||||||
|
{
|
||||||
|
return rtrim((string) config('app.url'), '/').'/webhooks/woocommerce/'.$this->public_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
|
class AppServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Billing;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client for the platform Billing HTTP API — the one UserWallet lives on the
|
||||||
|
* platform; Bird only consumes it. Amounts are integer minor units; the user is
|
||||||
|
* identified by public_id. Authenticates with the per-consumer config('billing.service') key.
|
||||||
|
* Contract validated in the monolith (smtp-extraction-runbook §4-A).
|
||||||
|
*/
|
||||||
|
class BillingClient
|
||||||
|
{
|
||||||
|
private function base(): string
|
||||||
|
{
|
||||||
|
return rtrim((string) config('billing.api_url'), '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function token(): string
|
||||||
|
{
|
||||||
|
return (string) (config('billing.api_key') ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function get(string $path, array $query): array
|
||||||
|
{
|
||||||
|
$res = Http::withToken($this->token())->acceptJson()->timeout(10)->get($this->base().$path, $query);
|
||||||
|
$res->throw();
|
||||||
|
|
||||||
|
return (array) $res->json();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function balanceMinor(string $publicId): int
|
||||||
|
{
|
||||||
|
return (int) ($this->get('/balance', ['user' => $publicId])['balance_minor'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canAfford(string $publicId, int $amountMinor): bool
|
||||||
|
{
|
||||||
|
return (bool) ($this->get('/can-afford', ['user' => $publicId, 'amount_minor' => $amountMinor])['affordable'] ?? false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function serviceLedger(string $publicId, string $service): array
|
||||||
|
{
|
||||||
|
return $this->get('/service-ledger', ['user' => $publicId, 'service' => $service]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debit the wallet. Returns true on success, false on insufficient balance
|
||||||
|
* (HTTP 402). Idempotent by $reference.
|
||||||
|
*/
|
||||||
|
public function debit(string $publicId, int $amountMinor, string $service, string $source, string $reference, ?int $serviceId = null, ?string $description = null): bool
|
||||||
|
{
|
||||||
|
$res = Http::withToken($this->token())->acceptJson()->timeout(10)->post($this->base().'/debit', array_filter([
|
||||||
|
'user' => $publicId,
|
||||||
|
'amount_minor' => $amountMinor,
|
||||||
|
'service' => $service,
|
||||||
|
'source' => $source,
|
||||||
|
'reference' => $reference,
|
||||||
|
'service_id' => $serviceId,
|
||||||
|
'description' => $description,
|
||||||
|
], static fn ($v) => $v !== null));
|
||||||
|
|
||||||
|
if ($res->status() === 402) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$res->throw();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function credit(string $publicId, int $amountMinor, string $service, string $source, string $reference, ?int $serviceId = null, ?string $description = null): array
|
||||||
|
{
|
||||||
|
$res = Http::withToken($this->token())->acceptJson()->timeout(10)->post($this->base().'/credit', array_filter([
|
||||||
|
'user' => $publicId,
|
||||||
|
'amount_minor' => $amountMinor,
|
||||||
|
'service' => $service,
|
||||||
|
'source' => $source,
|
||||||
|
'reference' => $reference,
|
||||||
|
'service_id' => $serviceId,
|
||||||
|
'description' => $description,
|
||||||
|
], static fn ($v) => $v !== null));
|
||||||
|
$res->throw();
|
||||||
|
|
||||||
|
return (array) $res->json();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Billing;
|
||||||
|
|
||||||
|
use App\Models\PlatformSetting;
|
||||||
|
use Illuminate\Http\Client\Response;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class PaystackService
|
||||||
|
{
|
||||||
|
public function publicKey(): string
|
||||||
|
{
|
||||||
|
return (string) $this->settingValue('paystack_public_key', config('services.paystack.public_key', ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function initializeTransaction(array $payload): array
|
||||||
|
{
|
||||||
|
return $this->initializeTransactionWithCredentials($payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function initializeTransactionWithCredentials(array $payload, ?string $secretKey = null, ?string $baseUrl = null): array
|
||||||
|
{
|
||||||
|
$response = $this->request(secretKey: $secretKey, baseUrl: $baseUrl)
|
||||||
|
->post('/transaction/initialize', $payload);
|
||||||
|
|
||||||
|
return $this->extractData($response, 'Failed to initialize Paystack transaction.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verifyTransaction(string $reference): array
|
||||||
|
{
|
||||||
|
return $this->verifyTransactionWithCredentials($reference);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verifyTransactionWithCredentials(string $reference, ?string $secretKey = null, ?string $baseUrl = null): array
|
||||||
|
{
|
||||||
|
$response = $this->request(secretKey: $secretKey, baseUrl: $baseUrl)
|
||||||
|
->get('/transaction/verify/' . urlencode($reference));
|
||||||
|
|
||||||
|
return $this->extractData($response, 'Failed to verify Paystack transaction.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verifyWebhookSignature(string $rawBody, ?string $signature): bool
|
||||||
|
{
|
||||||
|
$secret = (string) $this->settingValue('paystack_webhook_secret', config('services.paystack.webhook_secret'));
|
||||||
|
if ($secret === '') {
|
||||||
|
$secret = (string) $this->settingValue('paystack_secret_key', config('services.paystack.secret_key'));
|
||||||
|
}
|
||||||
|
if ($secret === '' || !$signature) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$computed = hash_hmac('sha512', $rawBody, $secret);
|
||||||
|
return hash_equals($computed, $signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function request(?string $secretKey = null, ?string $baseUrl = null)
|
||||||
|
{
|
||||||
|
$resolvedBaseUrl = rtrim((string) ($baseUrl ?: $this->settingValue('paystack_base_url', config('services.paystack.base_url', 'https://api.paystack.co'))), '/');
|
||||||
|
$secret = $this->normalizeSecretKey(
|
||||||
|
(string) ($secretKey ?: $this->settingValue('paystack_secret_key', config('services.paystack.secret_key')))
|
||||||
|
);
|
||||||
|
|
||||||
|
return Http::baseUrl($resolvedBaseUrl)
|
||||||
|
->withToken($secret)
|
||||||
|
->acceptJson()
|
||||||
|
->asJson();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function settingsConnection(): string
|
||||||
|
{
|
||||||
|
return (string) config('billing.platform_settings_connection', 'platform');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function settingValue(string $key, mixed $fallback = null): mixed
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$connection = $this->settingsConnection();
|
||||||
|
|
||||||
|
if (! Schema::connection($connection)->hasTable('platform_settings')) {
|
||||||
|
return $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
$setting = PlatformSetting::query()
|
||||||
|
->where('key', $key)
|
||||||
|
->where('is_active', true)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return $setting ? ($setting->value['value'] ?? $fallback) : $fallback;
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return $fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function extractData(Response $response, string $message): array
|
||||||
|
{
|
||||||
|
if ($response->failed()) {
|
||||||
|
throw new RuntimeException($message);
|
||||||
|
}
|
||||||
|
|
||||||
|
$json = $response->json();
|
||||||
|
if (!is_array($json) || !($json['status'] ?? false)) {
|
||||||
|
throw new RuntimeException((string) ($json['message'] ?? $message));
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $json['data'] ?? null;
|
||||||
|
return is_array($data) ? $data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List supported banks / mobile money providers.
|
||||||
|
* @param string $country e.g. 'ghana', 'nigeria'
|
||||||
|
* @param string $type 'nuban', 'mobile_money', or '' for all
|
||||||
|
*/
|
||||||
|
public function listBanks(string $country = 'ghana', string $type = '', ?string $currency = null): array
|
||||||
|
{
|
||||||
|
$query = ['perPage' => 200];
|
||||||
|
if ($currency !== null && $currency !== '') {
|
||||||
|
$query['currency'] = strtoupper($currency);
|
||||||
|
} else {
|
||||||
|
$query['country'] = $country;
|
||||||
|
}
|
||||||
|
if ($type !== '') {
|
||||||
|
$query['type'] = $type;
|
||||||
|
}
|
||||||
|
$response = $this->request()->get('/bank', $query);
|
||||||
|
$json = $response->json();
|
||||||
|
return is_array($json['data'] ?? null) ? $json['data'] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a transfer recipient (bank account or mobile money).
|
||||||
|
* @param array{type: string, name: string, account_number: string, bank_code: string, currency?: string} $payload
|
||||||
|
*/
|
||||||
|
public function createTransferRecipient(array $payload): array
|
||||||
|
{
|
||||||
|
$response = $this->request()->post('/transferrecipient', $payload);
|
||||||
|
return $this->extractData($response, 'Failed to create transfer recipient.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initiate a transfer to a recipient.
|
||||||
|
* @param array{source: string, amount: int, recipient: string, reason?: string, reference?: string} $payload
|
||||||
|
*/
|
||||||
|
public function initiateTransfer(array $payload): array
|
||||||
|
{
|
||||||
|
$response = $this->request()->post('/transfer', $payload);
|
||||||
|
return $this->extractData($response, 'Failed to initiate transfer.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a transfer by reference.
|
||||||
|
*/
|
||||||
|
public function verifyTransfer(string $reference): array
|
||||||
|
{
|
||||||
|
$response = $this->request()->get('/transfer/verify/' . urlencode($reference));
|
||||||
|
return $this->extractData($response, 'Failed to verify transfer.');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function normalizeSecretKey(string $secret): string
|
||||||
|
{
|
||||||
|
$normalized = trim($secret);
|
||||||
|
|
||||||
|
if (str_starts_with(strtolower($normalized), 'bearer ')) {
|
||||||
|
$normalized = trim(substr($normalized, 7));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Billing;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class SmsService
|
||||||
|
{
|
||||||
|
public function send(string $to, string $message): void
|
||||||
|
{
|
||||||
|
$apiKey = config('services.termii.api_key', '');
|
||||||
|
$senderId = config('services.termii.sender_id', config('app.name', 'LaDill'));
|
||||||
|
|
||||||
|
if ($apiKey === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$phone = preg_replace('/\D+/', '', $to);
|
||||||
|
if (strlen($phone) < 9) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strlen($phone) === 9) {
|
||||||
|
$phone = '233'.$phone;
|
||||||
|
} elseif (str_starts_with($phone, '0')) {
|
||||||
|
$phone = '233'.substr($phone, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Http::timeout(10)->post('https://v3.api.termii.com/api/sms/send', [
|
||||||
|
'api_key' => $apiKey,
|
||||||
|
'to' => $phone,
|
||||||
|
'from' => $senderId,
|
||||||
|
'sms' => $message,
|
||||||
|
'type' => 'plain',
|
||||||
|
'channel' => 'dnd',
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('SMS send failed', ['to' => $phone, 'error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Identity;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
|
class IdentityClient
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function mailboxLinkStatus(string $publicId): array
|
||||||
|
{
|
||||||
|
$response = $this->request()->get($this->url('/identity/mailbox-link'), [
|
||||||
|
'user' => $publicId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->throw();
|
||||||
|
|
||||||
|
return (array) $response->json('data', []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function linkMailbox(string $publicId, string $mailboxAddress): array
|
||||||
|
{
|
||||||
|
$response = $this->request()->put($this->url('/identity/mailbox-link'), [
|
||||||
|
'user' => $publicId,
|
||||||
|
'mailbox_address' => $mailboxAddress,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->throw();
|
||||||
|
|
||||||
|
return (array) $response->json('data', []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function unlinkMailbox(string $publicId): array
|
||||||
|
{
|
||||||
|
$response = $this->request()->delete($this->url('/identity/mailbox-link'), [
|
||||||
|
'user' => $publicId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->throw();
|
||||||
|
|
||||||
|
return (array) $response->json('data', []);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function request()
|
||||||
|
{
|
||||||
|
return Http::withToken((string) config('identity.api_key'))
|
||||||
|
->acceptJson()
|
||||||
|
->timeout(15);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function url(string $path): string
|
||||||
|
{
|
||||||
|
return rtrim((string) config('identity.api_url'), '/').$path;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Meet;
|
||||||
|
|
||||||
|
use Illuminate\Http\Client\ConnectionException;
|
||||||
|
use Illuminate\Http\Client\PendingRequest;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class MeetClient
|
||||||
|
{
|
||||||
|
public function __construct(private readonly string $ownerRef) {}
|
||||||
|
|
||||||
|
public static function for(string $ownerRef): self
|
||||||
|
{
|
||||||
|
return new self($ownerRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function createRoom(array $data): array
|
||||||
|
{
|
||||||
|
return $this->post('rooms', array_merge($data, [
|
||||||
|
'owner_ref' => $this->ownerRef,
|
||||||
|
'host_user_ref' => $data['host_user_ref'] ?? $this->ownerRef,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function client(): PendingRequest
|
||||||
|
{
|
||||||
|
return Http::baseUrl((string) config('meet.url'))
|
||||||
|
->withToken((string) config('meet.key'))
|
||||||
|
->acceptJson()
|
||||||
|
->asJson()
|
||||||
|
->connectTimeout(10)
|
||||||
|
->timeout(20);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function post(string $path, array $data): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$response = $this->client()->post($path, $data);
|
||||||
|
} catch (ConnectionException) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'meet' => ['Could not reach the Meet service. Please try again in a moment.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response->status() === 422) {
|
||||||
|
throw ValidationException::withMessages(
|
||||||
|
$response->json('errors') ?: ['meet' => [$response->json('message') ?: 'Request failed.']]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
abort($response->status() === 404 ? 404 : 502, 'Meet service error.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (array) $response->json();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Woo;
|
||||||
|
|
||||||
|
use App\Models\WooOrder;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class FulfillmentSyncService
|
||||||
|
{
|
||||||
|
public function push(WooOrder $order): void
|
||||||
|
{
|
||||||
|
$order->loadMissing('store');
|
||||||
|
$store = $order->store;
|
||||||
|
if (! $store || ! $store->plugin_token_hash) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$site = rtrim((string) $store->site_url, '/');
|
||||||
|
$url = $site.'/wp-json/ladill-woo/v1/orders/'.$order->external_order_id.'/fulfillment';
|
||||||
|
|
||||||
|
try {
|
||||||
|
Http::acceptJson()
|
||||||
|
->asJson()
|
||||||
|
->timeout(12)
|
||||||
|
->withHeaders([
|
||||||
|
'X-Ladill-Store' => $store->public_id,
|
||||||
|
])
|
||||||
|
->post($url, [
|
||||||
|
'fulfillment_status' => $order->fulfillment_status,
|
||||||
|
'order_number' => $order->order_number,
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('Woo fulfillment sync failed', [
|
||||||
|
'order_id' => $order->id,
|
||||||
|
'store_id' => $store->id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Woo;
|
||||||
|
|
||||||
|
use App\Models\WooStore;
|
||||||
|
use Illuminate\Support\Facades\Crypt;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class InstallTokenService
|
||||||
|
{
|
||||||
|
/** @return array{token: string, store: WooStore} */
|
||||||
|
public function issue(WooStore $store): array
|
||||||
|
{
|
||||||
|
$ttl = max(1, (int) config('woo.install_token_ttl_minutes', 10));
|
||||||
|
$payload = [
|
||||||
|
'store_id' => $store->id,
|
||||||
|
'public_id' => $store->public_id,
|
||||||
|
'nonce' => Str::random(16),
|
||||||
|
'exp' => now()->addMinutes($ttl)->timestamp,
|
||||||
|
];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'token' => Crypt::encryptString(json_encode($payload, JSON_THROW_ON_ERROR)),
|
||||||
|
'store' => $store,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function consume(string $token): WooStore
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$payload = json_decode(Crypt::decryptString($token), true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
throw new RuntimeException('Invalid or expired install token.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_array($payload) || ($payload['exp'] ?? 0) < now()->timestamp) {
|
||||||
|
throw new RuntimeException('Install token has expired.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$store = WooStore::query()->whereKey($payload['store_id'] ?? 0)->first();
|
||||||
|
if (! $store || $store->public_id !== ($payload['public_id'] ?? null)) {
|
||||||
|
throw new RuntimeException('Install token does not match a store.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $store;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function issuePluginToken(WooStore $store): string
|
||||||
|
{
|
||||||
|
$plain = Str::random(48);
|
||||||
|
$store->update([
|
||||||
|
'plugin_token_hash' => hash('sha256', $plain),
|
||||||
|
'status' => WooStore::STATUS_ACTIVE,
|
||||||
|
'connected_at' => $store->connected_at ?? now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $plain;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verifyPluginToken(WooStore $store, ?string $plain): bool
|
||||||
|
{
|
||||||
|
if ($plain === null || $plain === '' || ! $store->plugin_token_hash) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash_equals($store->plugin_token_hash, hash('sha256', $plain));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Woo;
|
||||||
|
|
||||||
|
use App\Models\WooOrder;
|
||||||
|
use App\Models\WooStore;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
|
||||||
|
class OrderIngestService
|
||||||
|
{
|
||||||
|
/** @param array<string, mixed> $payload */
|
||||||
|
public function ingest(WooStore $store, array $payload): WooOrder
|
||||||
|
{
|
||||||
|
$externalId = (int) ($payload['id'] ?? 0);
|
||||||
|
if ($externalId === 0) {
|
||||||
|
throw new \InvalidArgumentException('WooCommerce order id missing.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$billing = (array) ($payload['billing'] ?? []);
|
||||||
|
$shipping = (array) ($payload['shipping'] ?? []);
|
||||||
|
$lineItems = collect((array) ($payload['line_items'] ?? []))
|
||||||
|
->map(fn (array $item) => [
|
||||||
|
'name' => (string) ($item['name'] ?? ''),
|
||||||
|
'sku' => (string) ($item['sku'] ?? ''),
|
||||||
|
'quantity' => max(1, (int) ($item['quantity'] ?? 1)),
|
||||||
|
'total_minor' => (int) round(((float) ($item['total'] ?? 0)) * 100),
|
||||||
|
])
|
||||||
|
->filter(fn (array $i) => $i['name'] !== '')
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$totalMinor = (int) round(((float) ($payload['total'] ?? 0)) * 100);
|
||||||
|
$currency = strtoupper((string) ($payload['currency'] ?? 'GHS'));
|
||||||
|
|
||||||
|
$wcUpdatedAt = isset($payload['date_modified_gmt'])
|
||||||
|
? Carbon::parse($payload['date_modified_gmt'].' UTC')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$order = WooOrder::query()->updateOrCreate(
|
||||||
|
[
|
||||||
|
'woo_store_id' => $store->id,
|
||||||
|
'external_order_id' => $externalId,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'order_number' => (string) ($payload['number'] ?? $externalId),
|
||||||
|
'wc_status' => (string) ($payload['status'] ?? ''),
|
||||||
|
'payment_status' => $this->paymentStatus($payload),
|
||||||
|
'customer_name' => trim(collect([$billing['first_name'] ?? '', $billing['last_name'] ?? ''])->filter()->implode(' ')) ?: null,
|
||||||
|
'customer_email' => $billing['email'] ?? null,
|
||||||
|
'customer_phone' => $billing['phone'] ?? null,
|
||||||
|
'line_items' => $lineItems,
|
||||||
|
'billing_address' => $billing ?: null,
|
||||||
|
'shipping_address' => $shipping ?: null,
|
||||||
|
'currency' => $currency,
|
||||||
|
'total_minor' => $totalMinor,
|
||||||
|
'wc_updated_at' => $wcUpdatedAt,
|
||||||
|
'metadata' => [
|
||||||
|
'payment_method' => $payload['payment_method_title'] ?? null,
|
||||||
|
'customer_note' => $payload['customer_note'] ?? null,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$store->update(['last_webhook_at' => now()]);
|
||||||
|
|
||||||
|
return $order;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $payload */
|
||||||
|
private function paymentStatus(array $payload): string
|
||||||
|
{
|
||||||
|
if (! empty($payload['date_paid']) || ($payload['needs_payment'] ?? true) === false) {
|
||||||
|
return 'paid';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'unpaid';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Woo;
|
||||||
|
|
||||||
|
use App\Models\WooStore;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class WooWebhookVerifier
|
||||||
|
{
|
||||||
|
public function verify(Request $request, WooStore $store): bool
|
||||||
|
{
|
||||||
|
$signature = (string) $request->header('X-WC-Webhook-Signature', '');
|
||||||
|
if ($signature === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$secret = (string) $store->webhook_secret;
|
||||||
|
if ($secret === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$expected = base64_encode(hash_hmac('sha256', $request->getContent(), $secret, true));
|
||||||
|
|
||||||
|
return hash_equals($expected, $signature);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode CRM cross-app prefill tokens (base64url JSON).
|
||||||
|
*/
|
||||||
|
final class CrmPrefillCodec
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed>|null */
|
||||||
|
public static function decode(?string $token): ?array
|
||||||
|
{
|
||||||
|
if (! is_string($token) || $token === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$padded = $token.str_repeat('=', (4 - strlen($token) % 4) % 4);
|
||||||
|
$json = base64_decode(strtr($padded, '-_', '+/'), true);
|
||||||
|
|
||||||
|
if ($json === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
} catch (\JsonException) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return is_array($data) ? $data : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use App\Services\Domain\DomainPricingService;
|
||||||
|
|
||||||
|
class DomainConfig
|
||||||
|
{
|
||||||
|
private const PRIORITY_TLDS = [
|
||||||
|
'com', 'org', 'net', 'ai', 'io', 'co', 'app', 'dev',
|
||||||
|
'store', 'shop', 'online', 'site', 'tech', 'cloud', 'pro', 'me',
|
||||||
|
'xyz', 'club', 'live', 'com.ng', 'com.gh', 'co.za', 'co.uk', 'info', 'biz',
|
||||||
|
];
|
||||||
|
|
||||||
|
private const DEFAULT_PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get featured TLDs for initial display (lightweight).
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function featuredTlds(): array
|
||||||
|
{
|
||||||
|
$allTlds = self::allTlds();
|
||||||
|
$featured = array_values(array_intersect(self::PRIORITY_TLDS, $allTlds));
|
||||||
|
|
||||||
|
if ($featured !== []) {
|
||||||
|
return $featured;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_slice($allTlds, 0, self::DEFAULT_PAGE_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all available TLDs sorted by priority.
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function allTlds(): array
|
||||||
|
{
|
||||||
|
$current = self::normalizeTlds(config('domain.search_tlds', []));
|
||||||
|
$legacy = self::normalizeTlds(config('mailinfra.domain_search_tlds', []));
|
||||||
|
|
||||||
|
$tlds = $legacy !== [] ? $legacy : $current;
|
||||||
|
$livePricedTlds = self::livePricedTlds();
|
||||||
|
|
||||||
|
if ($livePricedTlds === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$livePricedLookup = array_flip($livePricedTlds);
|
||||||
|
$tlds = array_values(array_filter(
|
||||||
|
$tlds,
|
||||||
|
static fn (string $tld): bool => isset($livePricedLookup[$tld])
|
||||||
|
));
|
||||||
|
|
||||||
|
return self::sortWithPriority($tlds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get paginated TLDs (excluding featured ones).
|
||||||
|
*
|
||||||
|
* @return array{tlds: list<string>, hasMore: bool, total: int}
|
||||||
|
*/
|
||||||
|
public static function paginatedTlds(int $page = 1, int $perPage = self::DEFAULT_PAGE_SIZE): array
|
||||||
|
{
|
||||||
|
$allTlds = self::allTlds();
|
||||||
|
$featured = self::featuredTlds();
|
||||||
|
|
||||||
|
$remaining = array_values(array_filter($allTlds, fn ($tld) => !in_array($tld, $featured, true)));
|
||||||
|
$total = count($remaining);
|
||||||
|
|
||||||
|
$offset = ($page - 1) * $perPage;
|
||||||
|
$tlds = array_slice($remaining, $offset, $perPage);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'tlds' => $tlds,
|
||||||
|
'hasMore' => ($offset + $perPage) < $total,
|
||||||
|
'total' => $total,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use featuredTlds() or allTlds() instead
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function searchTlds(): array
|
||||||
|
{
|
||||||
|
return self::featuredTlds();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the TLD list and query term for a search page request.
|
||||||
|
*
|
||||||
|
* @return array{query: string, tlds: list<string>, hasMore: bool, exact_domain: string|null}
|
||||||
|
*/
|
||||||
|
public static function searchPageConfig(string $query, int $page = 1, int $perPage = self::DEFAULT_PAGE_SIZE): array
|
||||||
|
{
|
||||||
|
$query = strtolower(trim($query));
|
||||||
|
$allTlds = self::allTlds();
|
||||||
|
|
||||||
|
if ($allTlds === []) {
|
||||||
|
return [
|
||||||
|
'query' => $query,
|
||||||
|
'tlds' => [],
|
||||||
|
'hasMore' => false,
|
||||||
|
'exact_domain' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$exactDomain = self::normalizeExactDomain($query);
|
||||||
|
|
||||||
|
if ($exactDomain === null) {
|
||||||
|
if ($page === 1) {
|
||||||
|
$featured = self::featuredTlds();
|
||||||
|
$paginatedData = self::paginatedTlds(1, $perPage);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'query' => $query,
|
||||||
|
'tlds' => $featured,
|
||||||
|
'hasMore' => $paginatedData['hasMore'] || count($paginatedData['tlds']) > 0,
|
||||||
|
'exact_domain' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$paginatedData = self::paginatedTlds($page - 1, $perPage);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'query' => $query,
|
||||||
|
'tlds' => $paginatedData['tlds'],
|
||||||
|
'hasMore' => $paginatedData['hasMore'],
|
||||||
|
'exact_domain' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$exactTld = self::extractTld($exactDomain);
|
||||||
|
$keyword = explode('.', $exactDomain, 2)[0] ?? $exactDomain;
|
||||||
|
$firstPageTlds = array_values(array_unique(array_filter([
|
||||||
|
$exactTld,
|
||||||
|
...self::featuredTlds(),
|
||||||
|
])));
|
||||||
|
$remainingTlds = array_values(array_filter(
|
||||||
|
$allTlds,
|
||||||
|
static fn (string $tld): bool => ! in_array($tld, $firstPageTlds, true)
|
||||||
|
));
|
||||||
|
|
||||||
|
if ($page === 1) {
|
||||||
|
return [
|
||||||
|
'query' => $keyword,
|
||||||
|
'tlds' => $firstPageTlds,
|
||||||
|
'hasMore' => $remainingTlds !== [],
|
||||||
|
'exact_domain' => $exactDomain,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$offset = max(0, ($page - 2) * $perPage);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'query' => $keyword,
|
||||||
|
'tlds' => array_slice($remainingTlds, $offset, $perPage),
|
||||||
|
'hasMore' => ($offset + $perPage) < count($remainingTlds),
|
||||||
|
'exact_domain' => $exactDomain,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sort domain search results by TLD priority.
|
||||||
|
*
|
||||||
|
* @param array<int, array{domain: string, ...}> $results
|
||||||
|
* @return array<int, array{domain: string, ...}>
|
||||||
|
*/
|
||||||
|
public static function sortResultsByTldPriority(array $results, ?string $query = null): array
|
||||||
|
{
|
||||||
|
$priorityMap = array_flip(self::PRIORITY_TLDS);
|
||||||
|
$exactDomain = self::normalizeExactDomain((string) $query);
|
||||||
|
|
||||||
|
usort($results, static function (array $a, array $b) use ($priorityMap, $exactDomain): int {
|
||||||
|
$aDomain = strtolower((string) ($a['domain'] ?? ''));
|
||||||
|
$bDomain = strtolower((string) ($b['domain'] ?? ''));
|
||||||
|
|
||||||
|
$aExact = $exactDomain !== null && $aDomain === $exactDomain ? 0 : 1;
|
||||||
|
$bExact = $exactDomain !== null && $bDomain === $exactDomain ? 0 : 1;
|
||||||
|
|
||||||
|
if ($aExact !== $bExact) {
|
||||||
|
return $aExact <=> $bExact;
|
||||||
|
}
|
||||||
|
|
||||||
|
$aPremium = self::isPremiumResult($a) ? 0 : 1;
|
||||||
|
$bPremium = self::isPremiumResult($b) ? 0 : 1;
|
||||||
|
|
||||||
|
if ($aPremium !== $bPremium) {
|
||||||
|
return $aPremium <=> $bPremium;
|
||||||
|
}
|
||||||
|
|
||||||
|
$aAvailable = ! empty($a['available']) ? 0 : 1;
|
||||||
|
$bAvailable = ! empty($b['available']) ? 0 : 1;
|
||||||
|
|
||||||
|
if ($aAvailable !== $bAvailable) {
|
||||||
|
return $aAvailable <=> $bAvailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
$aTld = self::extractTld($a['domain'] ?? '');
|
||||||
|
$bTld = self::extractTld($b['domain'] ?? '');
|
||||||
|
|
||||||
|
$aPriority = $priorityMap[$aTld] ?? PHP_INT_MAX;
|
||||||
|
$bPriority = $priorityMap[$bTld] ?? PHP_INT_MAX;
|
||||||
|
|
||||||
|
if ($aPriority !== $bPriority) {
|
||||||
|
return $aPriority <=> $bPriority;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $aDomain <=> $bDomain;
|
||||||
|
});
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract TLD from a domain name.
|
||||||
|
*/
|
||||||
|
private static function extractTld(string $domain): string
|
||||||
|
{
|
||||||
|
$parts = explode('.', strtolower($domain), 2);
|
||||||
|
|
||||||
|
return $parts[1] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function normalizeExactDomain(string $query): ?string
|
||||||
|
{
|
||||||
|
$query = strtolower(trim($query));
|
||||||
|
|
||||||
|
if ($query === '' || str_starts_with($query, '.') || str_ends_with($query, '.') || ! str_contains($query, '.')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return preg_match('/^(?=.{1,253}$)(?!-)(?:[a-z0-9-]{1,63}\.)+[a-z]{2,63}$/', $query) === 1
|
||||||
|
? $query
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $result
|
||||||
|
*/
|
||||||
|
private static function isPremiumResult(array $result): bool
|
||||||
|
{
|
||||||
|
$premium = $result['premium'] ?? false;
|
||||||
|
|
||||||
|
if (is_bool($premium)) {
|
||||||
|
return $premium;
|
||||||
|
}
|
||||||
|
|
||||||
|
return in_array(strtolower(trim((string) $premium)), ['1', 'yes', 'true', 'premium'], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $tlds
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private static function sortWithPriority(array $tlds): array
|
||||||
|
{
|
||||||
|
$priorityMap = array_flip(self::PRIORITY_TLDS);
|
||||||
|
$pricingMap = self::pricingMapForTlds($tlds);
|
||||||
|
|
||||||
|
usort($tlds, static function (string $a, string $b) use ($priorityMap, $pricingMap): int {
|
||||||
|
$aPriority = $priorityMap[$a] ?? PHP_INT_MAX;
|
||||||
|
$bPriority = $priorityMap[$b] ?? PHP_INT_MAX;
|
||||||
|
|
||||||
|
if ($aPriority !== $bPriority) {
|
||||||
|
return $aPriority <=> $bPriority;
|
||||||
|
}
|
||||||
|
|
||||||
|
$aPrice = (int) ($pricingMap[$a]['register'] ?? 0);
|
||||||
|
$bPrice = (int) ($pricingMap[$b]['register'] ?? 0);
|
||||||
|
|
||||||
|
if ($aPrice !== $bPrice) {
|
||||||
|
return $bPrice <=> $aPrice;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $a <=> $b;
|
||||||
|
});
|
||||||
|
|
||||||
|
return $tlds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public static function resellerClubTldProductKeys(): array
|
||||||
|
{
|
||||||
|
return array_replace(
|
||||||
|
self::normalizeProductKeyMap(config('domain.resellerclub.tld_product_keys', [])),
|
||||||
|
self::normalizeProductKeyMap(config('mailinfra.resellerclub_tld_product_keys', []))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function resellerClubProductKeyCacheTtlSeconds(): int
|
||||||
|
{
|
||||||
|
$ttl = (int) config('domain.resellerclub.product_key_cache_ttl_seconds', 86400);
|
||||||
|
|
||||||
|
return $ttl > 0 ? $ttl : 86400;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $value
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private static function normalizeTlds(mixed $value): array
|
||||||
|
{
|
||||||
|
if (! is_array($value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique(array_filter(array_map(
|
||||||
|
static fn ($tld): string => ltrim(strtolower(trim((string) $tld)), '.'),
|
||||||
|
$value
|
||||||
|
))));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $value
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private static function normalizeProductKeyMap(mixed $value): array
|
||||||
|
{
|
||||||
|
if (! is_array($value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$map = [];
|
||||||
|
|
||||||
|
foreach ($value as $tld => $productKey) {
|
||||||
|
$normalizedTld = ltrim(strtolower(trim((string) $tld)), '.');
|
||||||
|
$normalizedProductKey = trim((string) $productKey);
|
||||||
|
|
||||||
|
if ($normalizedTld === '' || $normalizedProductKey === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$map[$normalizedTld] = $normalizedProductKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private static function livePricedTlds(): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return app(DomainPricingService::class)->getLivePricedTlds();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $tlds
|
||||||
|
* @return array<string, array{register: int, renew: int, transfer: int, currency: string}>
|
||||||
|
*/
|
||||||
|
private static function pricingMapForTlds(array $tlds): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return app(DomainPricingService::class)->getPricingForTlds($tlds);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
final class DomainGlobeIcon
|
||||||
|
{
|
||||||
|
public const VIEW_BOX = '0 0 14 14';
|
||||||
|
|
||||||
|
/** 14×14 globe for UI icons (inherits color via currentColor). */
|
||||||
|
public const ICON_ASSET = 'images/ladill-icons/domain.svg';
|
||||||
|
|
||||||
|
public static function paths(): string
|
||||||
|
{
|
||||||
|
return '<path d="M7 13.5C10.5899 13.5 13.5 10.5899 13.5 7C13.5 3.41015 10.5899 0.5 7 0.5C3.41015 0.5 0.5 3.41015 0.5 7C0.5 10.5899 3.41015 13.5 7 13.5Z" stroke-linecap="round" stroke-linejoin="round"/>'
|
||||||
|
.'<path d="M0.5 7H13.5" stroke-linecap="round" stroke-linejoin="round"/>'
|
||||||
|
.'<path d="M9.5 7C9.3772 9.37699 8.50168 11.6533 7 13.5C5.49832 11.6533 4.6228 9.37699 4.5 7C4.6228 4.62301 5.49832 2.34665 7 0.5C8.50168 2.34665 9.3772 4.62301 9.5 7V7Z" stroke-linecap="round" stroke-linejoin="round"/>';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function svg(string $class = 'h-5 w-5'): string
|
||||||
|
{
|
||||||
|
return sprintf(
|
||||||
|
'<svg class="%s" viewBox="%s" fill="none" stroke="currentColor" stroke-width="1" aria-hidden="true">%s</svg>',
|
||||||
|
e($class),
|
||||||
|
self::VIEW_BOX,
|
||||||
|
self::paths()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support\Events;
|
||||||
|
|
||||||
|
use App\Models\QrCode;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates ZPL II label code for Zebra-style badge/label printers.
|
||||||
|
* One ^XA…^XZ label block per attendee, sized to the chosen badge size at 203 dpi.
|
||||||
|
*/
|
||||||
|
class EventBadgeZpl
|
||||||
|
{
|
||||||
|
/** @param Collection<int, \App\Models\QrEventRegistration> $registrations */
|
||||||
|
public static function forRegistrations(QrCode $qrCode, Collection $registrations): string
|
||||||
|
{
|
||||||
|
$content = $qrCode->content();
|
||||||
|
$eventName = self::sanitize($content['name'] ?? $qrCode->label);
|
||||||
|
$size = $content['badge_size'] ?? '4x3';
|
||||||
|
|
||||||
|
// Label dimensions in dots @ 203 dpi.
|
||||||
|
[$widthDots, $heightDots] = match ($size) {
|
||||||
|
'4x6' => [812, 1218],
|
||||||
|
'cr80' => [685, 431],
|
||||||
|
default => [812, 609], // 4x3
|
||||||
|
};
|
||||||
|
|
||||||
|
$labels = [];
|
||||||
|
foreach ($registrations as $reg) {
|
||||||
|
$name = self::sanitize($reg->attendee_name);
|
||||||
|
$tier = self::sanitize($reg->tier_name);
|
||||||
|
$extra = collect($reg->badge_fields ?? [])
|
||||||
|
->map(fn ($v, $k) => self::sanitize($k . ': ' . $v))
|
||||||
|
->implode(' | ');
|
||||||
|
|
||||||
|
$zpl = "^XA\n";
|
||||||
|
$zpl .= "^PW{$widthDots}\n";
|
||||||
|
$zpl .= "^LL{$heightDots}\n";
|
||||||
|
$zpl .= "^CI28\n"; // UTF-8
|
||||||
|
// Event name (top)
|
||||||
|
$zpl .= "^FO40,40^A0N,40,40^FB" . ($widthDots - 80) . ",1,0,C^FD{$eventName}^FS\n";
|
||||||
|
// Attendee name (large, centered)
|
||||||
|
$zpl .= "^FO40,150^A0N,80,80^FB" . ($widthDots - 80) . ",2,0,C^FD{$name}^FS\n";
|
||||||
|
// Tier
|
||||||
|
$zpl .= "^FO40,320^A0N,40,40^FB" . ($widthDots - 80) . ",1,0,C^FD{$tier}^FS\n";
|
||||||
|
// Extra fields
|
||||||
|
if ($extra !== '') {
|
||||||
|
$zpl .= "^FO40,375^A0N,28,28^FB" . ($widthDots - 80) . ",2,0,C^FD{$extra}^FS\n";
|
||||||
|
}
|
||||||
|
// QR of the badge code (bottom)
|
||||||
|
$qrX = (int) (($widthDots / 2) - 70);
|
||||||
|
$zpl .= "^FO{$qrX}," . ($heightDots - 200) . "^BQN,2,5^FDLA,{$reg->badge_code}^FS\n";
|
||||||
|
// Badge code text
|
||||||
|
$zpl .= "^FO40," . ($heightDots - 60) . "^A0N,34,34^FB" . ($widthDots - 80) . ",1,0,C^FD{$reg->badge_code}^FS\n";
|
||||||
|
$zpl .= "^XZ\n";
|
||||||
|
|
||||||
|
$labels[] = $zpl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode("\n", $labels);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function sanitize(string $value): string
|
||||||
|
{
|
||||||
|
// Escape ZPL control chars (^ and ~) and collapse whitespace.
|
||||||
|
$value = str_replace(['^', '~'], [' ', ' '], $value);
|
||||||
|
|
||||||
|
return trim(preg_replace('/\s+/', ' ', $value) ?? '');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support\Invoice;
|
||||||
|
|
||||||
|
class InvoiceTemplateCatalog
|
||||||
|
{
|
||||||
|
/** @return array<string, string> */
|
||||||
|
public static function labels(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'classic' => 'Classic',
|
||||||
|
'modern' => 'Modern',
|
||||||
|
'minimal' => 'Minimal',
|
||||||
|
'bold' => 'Bold',
|
||||||
|
'professional' => 'Professional',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isValid(string $template): bool
|
||||||
|
{
|
||||||
|
return array_key_exists($template, self::labels());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function default(): string
|
||||||
|
{
|
||||||
|
return 'classic';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function view(string $template): string
|
||||||
|
{
|
||||||
|
$template = self::isValid($template) ? $template : self::default();
|
||||||
|
|
||||||
|
return 'invoices.templates.'.$template;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Preview thumbnail URL for a template (public/images/invoice-templates/<key>.svg). */
|
||||||
|
public static function thumbnail(string $template): string
|
||||||
|
{
|
||||||
|
$template = self::isValid($template) ? $template : self::default();
|
||||||
|
|
||||||
|
return asset('images/invoice-templates/'.$template.'.svg');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical short-link URLs for the Ladill platform (ladl.link).
|
||||||
|
*/
|
||||||
|
final class LadillLink
|
||||||
|
{
|
||||||
|
public static function baseUrl(): string
|
||||||
|
{
|
||||||
|
$domain = (string) config('link.public_domain', 'ladl.link');
|
||||||
|
|
||||||
|
return 'https://'.$domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function url(string $slug): string
|
||||||
|
{
|
||||||
|
return rtrim(self::baseUrl(), '/').'/'.ltrim($slug, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function path(string $slug, string $suffix): string
|
||||||
|
{
|
||||||
|
return self::url($slug).'/'.ltrim($suffix, '/');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
/** Storage-tier pricing for mailboxes (config/email.php quota_tiers). */
|
||||||
|
class MailboxPricing
|
||||||
|
{
|
||||||
|
/** @return array<int,array{mb:int,price_minor:int,label:string}> */
|
||||||
|
public static function tiers(): array
|
||||||
|
{
|
||||||
|
return array_map(fn ($t) => [
|
||||||
|
'mb' => (int) $t['mb'],
|
||||||
|
'price_minor' => (int) $t['price_minor'],
|
||||||
|
'label' => self::label((int) $t['mb']),
|
||||||
|
], (array) config('email.quota_tiers', []));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isValidQuota(int $mb): bool
|
||||||
|
{
|
||||||
|
foreach (self::tiers() as $t) {
|
||||||
|
if ($t['mb'] === $mb) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function priceMinorFor(int $mb): int
|
||||||
|
{
|
||||||
|
foreach (self::tiers() as $t) {
|
||||||
|
if ($t['mb'] === $mb) {
|
||||||
|
return $t['price_minor'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A tier is free when its monthly price is zero (the 1 GB plan). */
|
||||||
|
public static function isFree(int $mb): bool
|
||||||
|
{
|
||||||
|
return self::priceMinorFor($mb) === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The smallest free tier's quota (the default for new mailboxes). */
|
||||||
|
public static function freeQuotaMb(): int
|
||||||
|
{
|
||||||
|
foreach (self::tiers() as $t) {
|
||||||
|
if ($t['price_minor'] === 0) {
|
||||||
|
return $t['mb'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::tiers()[0]['mb'] ?? 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function defaultQuotaMb(): int
|
||||||
|
{
|
||||||
|
$default = (int) config('email.default_quota_mb', 1024);
|
||||||
|
|
||||||
|
return self::isValidQuota($default) ? $default : self::freeQuotaMb();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function label(int $mb): string
|
||||||
|
{
|
||||||
|
return $mb % 1024 === 0 ? ($mb / 1024).' GB' : $mb.' MB';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
class MobileTopbar
|
||||||
|
{
|
||||||
|
public static function resolve(): array
|
||||||
|
{
|
||||||
|
$appName = config('mobile-topbar.app_name', 'Ladill');
|
||||||
|
|
||||||
|
$title = $appName === 'Ladill'
|
||||||
|
? 'Ladill'
|
||||||
|
: "Ladill {$appName}";
|
||||||
|
|
||||||
|
return [
|
||||||
|
'mobileTopbarTitle' => $title,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support\Qr;
|
||||||
|
|
||||||
|
use App\Models\QrCode;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates ZPL II label code for Zebra-style badge/label printers.
|
||||||
|
* One ^XA…^XZ label block per attendee, sized to the chosen badge size at 203 dpi.
|
||||||
|
*/
|
||||||
|
class EventBadgeZpl
|
||||||
|
{
|
||||||
|
/** @param Collection<int, \App\Models\QrEventRegistration> $registrations */
|
||||||
|
public static function forRegistrations(QrCode $qrCode, Collection $registrations): string
|
||||||
|
{
|
||||||
|
$content = $qrCode->content();
|
||||||
|
$eventName = self::sanitize($content['name'] ?? $qrCode->label);
|
||||||
|
$size = $content['badge_size'] ?? '4x3';
|
||||||
|
|
||||||
|
// Label dimensions in dots @ 203 dpi.
|
||||||
|
[$widthDots, $heightDots] = match ($size) {
|
||||||
|
'4x6' => [812, 1218],
|
||||||
|
'cr80' => [685, 431],
|
||||||
|
default => [812, 609], // 4x3
|
||||||
|
};
|
||||||
|
|
||||||
|
$labels = [];
|
||||||
|
foreach ($registrations as $reg) {
|
||||||
|
$name = self::sanitize($reg->attendee_name);
|
||||||
|
$tier = self::sanitize($reg->tier_name);
|
||||||
|
$extra = collect($reg->badge_fields ?? [])
|
||||||
|
->map(fn ($v, $k) => self::sanitize($k . ': ' . $v))
|
||||||
|
->implode(' | ');
|
||||||
|
|
||||||
|
$zpl = "^XA\n";
|
||||||
|
$zpl .= "^PW{$widthDots}\n";
|
||||||
|
$zpl .= "^LL{$heightDots}\n";
|
||||||
|
$zpl .= "^CI28\n"; // UTF-8
|
||||||
|
// Event name (top)
|
||||||
|
$zpl .= "^FO40,40^A0N,40,40^FB" . ($widthDots - 80) . ",1,0,C^FD{$eventName}^FS\n";
|
||||||
|
// Attendee name (large, centered)
|
||||||
|
$zpl .= "^FO40,150^A0N,80,80^FB" . ($widthDots - 80) . ",2,0,C^FD{$name}^FS\n";
|
||||||
|
// Tier
|
||||||
|
$zpl .= "^FO40,320^A0N,40,40^FB" . ($widthDots - 80) . ",1,0,C^FD{$tier}^FS\n";
|
||||||
|
// Extra fields
|
||||||
|
if ($extra !== '') {
|
||||||
|
$zpl .= "^FO40,375^A0N,28,28^FB" . ($widthDots - 80) . ",2,0,C^FD{$extra}^FS\n";
|
||||||
|
}
|
||||||
|
// QR of the badge code (bottom)
|
||||||
|
$qrX = (int) (($widthDots / 2) - 70);
|
||||||
|
$zpl .= "^FO{$qrX}," . ($heightDots - 200) . "^BQN,2,5^FDLA,{$reg->badge_code}^FS\n";
|
||||||
|
// Badge code text
|
||||||
|
$zpl .= "^FO40," . ($heightDots - 60) . "^A0N,34,34^FB" . ($widthDots - 80) . ",1,0,C^FD{$reg->badge_code}^FS\n";
|
||||||
|
$zpl .= "^XZ\n";
|
||||||
|
|
||||||
|
$labels[] = $zpl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode("\n", $labels);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function sanitize(string $value): string
|
||||||
|
{
|
||||||
|
// Escape ZPL control chars (^ and ~) and collapse whitespace.
|
||||||
|
$value = str_replace(['^', '~'], [' ', ' '], $value);
|
||||||
|
|
||||||
|
return trim(preg_replace('/\s+/', ' ', $value) ?? '');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support\Qr;
|
||||||
|
|
||||||
|
class QrCornerStyleCatalog
|
||||||
|
{
|
||||||
|
/** @return array<string, array{label: string}> */
|
||||||
|
public static function outerStyles(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'square' => ['label' => 'Square eye'],
|
||||||
|
'rounded' => ['label' => 'Rounded'],
|
||||||
|
'circle' => ['label' => 'Circular'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, array{label: string}> */
|
||||||
|
public static function innerStyles(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'square' => ['label' => 'Square'],
|
||||||
|
'rounded' => ['label' => 'Rounded'],
|
||||||
|
'dot' => ['label' => 'Dot'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isValidOuter(string $style): bool
|
||||||
|
{
|
||||||
|
return isset(self::outerStyles()[$style]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isValidInner(string $style): bool
|
||||||
|
{
|
||||||
|
return isset(self::innerStyles()[$style]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support\Qr;
|
||||||
|
|
||||||
|
class QrCoverImageSpec
|
||||||
|
{
|
||||||
|
/** Wide hero banners (business, church, event, itinerary, menu, shop). */
|
||||||
|
public const BANNER = '1920×1080 px (16:9 landscape)';
|
||||||
|
|
||||||
|
/** Book product cover on the landing page. */
|
||||||
|
public const BOOK = '800×1067 px (3:4 portrait)';
|
||||||
|
|
||||||
|
public static function label(string $variant = 'banner'): string
|
||||||
|
{
|
||||||
|
return match ($variant) {
|
||||||
|
'book' => self::BOOK,
|
||||||
|
default => self::BANNER,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support\Qr;
|
||||||
|
|
||||||
|
use Carbon\Carbon;
|
||||||
|
|
||||||
|
class QrDateFormatter
|
||||||
|
{
|
||||||
|
public static function forInput(?string $value): string
|
||||||
|
{
|
||||||
|
if ($value === null || trim($value) === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return Carbon::parse($value)->format('Y-m-d');
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function forDisplay(?string $value): string
|
||||||
|
{
|
||||||
|
if ($value === null || trim($value) === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = trim($value);
|
||||||
|
|
||||||
|
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) {
|
||||||
|
try {
|
||||||
|
return Carbon::createFromFormat('Y-m-d', $value)->format('D, j M Y');
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function normalize(?string $value, int $maxLength = 60): string
|
||||||
|
{
|
||||||
|
if ($value === null || trim($value) === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = trim($value);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return Carbon::parse($value)->format('Y-m-d');
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return mb_substr($value, 0, $maxLength);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support\Qr;
|
||||||
|
|
||||||
|
class QrFrameStyleCatalog
|
||||||
|
{
|
||||||
|
/** @return array<string, array{label: string, description: string, border_px: int, mode: string, cta?: string, visible?: bool}> */
|
||||||
|
public static function all(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'none' => [
|
||||||
|
'label' => 'None',
|
||||||
|
'description' => 'Code only',
|
||||||
|
'border_px' => 0,
|
||||||
|
'mode' => 'none',
|
||||||
|
],
|
||||||
|
'thin' => [
|
||||||
|
'label' => 'Border',
|
||||||
|
'description' => 'Simple white sticker edge',
|
||||||
|
'border_px' => 8,
|
||||||
|
'mode' => 'border',
|
||||||
|
],
|
||||||
|
'bold' => [
|
||||||
|
'label' => 'Wide border',
|
||||||
|
'description' => 'Legacy wide border',
|
||||||
|
'border_px' => 16,
|
||||||
|
'mode' => 'border',
|
||||||
|
'visible' => false,
|
||||||
|
],
|
||||||
|
'scan_me' => [
|
||||||
|
'label' => 'Scan me',
|
||||||
|
'description' => 'CTA sticker below code',
|
||||||
|
'border_px' => 14,
|
||||||
|
'mode' => 'label',
|
||||||
|
'cta' => 'SCAN ME',
|
||||||
|
],
|
||||||
|
'tap_to_scan' => [
|
||||||
|
'label' => 'Tap to scan',
|
||||||
|
'description' => 'Rounded CTA sticker',
|
||||||
|
'border_px' => 14,
|
||||||
|
'mode' => 'pill',
|
||||||
|
'cta' => 'TAP TO SCAN',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, array{label: string, description: string, border_px: int, mode: string, cta?: string, visible?: bool}> */
|
||||||
|
public static function visible(): array
|
||||||
|
{
|
||||||
|
return array_filter(self::all(), fn (array $style): bool => ($style['visible'] ?? true) === true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
public static function keys(): array
|
||||||
|
{
|
||||||
|
return array_keys(self::all());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isValid(string $style): bool
|
||||||
|
{
|
||||||
|
return isset(self::all()[$style]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support\Qr;
|
||||||
|
|
||||||
|
class QrModuleStyleCatalog
|
||||||
|
{
|
||||||
|
/** @return array<string, array{label: string, description: string, circular: bool, circle_radius: float, connect_paths: bool, scan_risk: string, visible?: bool}> */
|
||||||
|
public static function all(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'square' => [
|
||||||
|
'label' => 'Standard',
|
||||||
|
'description' => 'Traditional square QR modules',
|
||||||
|
'circular' => false,
|
||||||
|
'circle_radius' => 0.4,
|
||||||
|
'connect_paths' => false,
|
||||||
|
'scan_risk' => 'low',
|
||||||
|
],
|
||||||
|
'soft' => [
|
||||||
|
'label' => 'Rounded',
|
||||||
|
'description' => 'Soft rounded modules',
|
||||||
|
'circular' => true,
|
||||||
|
'circle_radius' => 0.36,
|
||||||
|
'connect_paths' => false,
|
||||||
|
'scan_risk' => 'medium',
|
||||||
|
'visible' => false,
|
||||||
|
],
|
||||||
|
'dots' => [
|
||||||
|
'label' => 'Dots',
|
||||||
|
'description' => 'Round dot modules',
|
||||||
|
'circular' => true,
|
||||||
|
'circle_radius' => 0.45,
|
||||||
|
'connect_paths' => false,
|
||||||
|
'scan_risk' => 'medium',
|
||||||
|
],
|
||||||
|
'bubble' => [
|
||||||
|
'label' => 'Large dots',
|
||||||
|
'description' => 'Bolder round dot modules',
|
||||||
|
'circular' => true,
|
||||||
|
'circle_radius' => 0.52,
|
||||||
|
'connect_paths' => false,
|
||||||
|
'scan_risk' => 'high',
|
||||||
|
'visible' => false,
|
||||||
|
],
|
||||||
|
'fluid' => [
|
||||||
|
'label' => 'Fluid',
|
||||||
|
'description' => 'Legacy decorative style',
|
||||||
|
'circular' => true,
|
||||||
|
'circle_radius' => 0.38,
|
||||||
|
'connect_paths' => true,
|
||||||
|
'scan_risk' => 'high',
|
||||||
|
'visible' => false,
|
||||||
|
],
|
||||||
|
'bold' => [
|
||||||
|
'label' => 'Bold',
|
||||||
|
'description' => 'Legacy thick square modules',
|
||||||
|
'circular' => false,
|
||||||
|
'circle_radius' => 0.4,
|
||||||
|
'connect_paths' => false,
|
||||||
|
'scan_risk' => 'low',
|
||||||
|
'visible' => false,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, array{label: string, description: string, circular: bool, circle_radius: float, connect_paths: bool, scan_risk: string, visible?: bool}> */
|
||||||
|
public static function visible(): array
|
||||||
|
{
|
||||||
|
return array_filter(self::all(), fn (array $style): bool => ($style['visible'] ?? true) === true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
public static function keys(): array
|
||||||
|
{
|
||||||
|
return array_keys(self::all());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isValid(string $style): bool
|
||||||
|
{
|
||||||
|
return isset(self::all()[$style]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public static function optionsFor(string $style): array
|
||||||
|
{
|
||||||
|
return self::all()[$style] ?? self::all()['square'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isDecorative(string $style): bool
|
||||||
|
{
|
||||||
|
return in_array(self::optionsFor($style)['scan_risk'], ['medium', 'high'], true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support\Qr;
|
||||||
|
|
||||||
|
class QrScanReliability
|
||||||
|
{
|
||||||
|
/** @param array<string, mixed> $style */
|
||||||
|
public static function contrastRatio(array $style): float
|
||||||
|
{
|
||||||
|
$fg = self::relativeLuminance((string) ($style['foreground'] ?? '#000000'));
|
||||||
|
$bg = self::relativeLuminance((string) ($style['background'] ?? '#ffffff'));
|
||||||
|
|
||||||
|
$lighter = max($fg, $bg);
|
||||||
|
$darker = min($fg, $bg);
|
||||||
|
|
||||||
|
return ($lighter + 0.05) / ($darker + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $style
|
||||||
|
* @return array{level: string, messages: list<string>}
|
||||||
|
*/
|
||||||
|
public static function assess(array $style): array
|
||||||
|
{
|
||||||
|
$style = QrStyleDefaults::merge($style);
|
||||||
|
$messages = [];
|
||||||
|
$level = 'good';
|
||||||
|
|
||||||
|
$contrast = self::contrastRatio($style);
|
||||||
|
$isClassic = self::isClassicBlackOnWhite($style);
|
||||||
|
$moduleStyle = (string) ($style['module_style'] ?? 'square');
|
||||||
|
$moduleMeta = QrModuleStyleCatalog::optionsFor($moduleStyle);
|
||||||
|
|
||||||
|
if ($moduleMeta['scan_risk'] === 'medium') {
|
||||||
|
$messages[] = 'This module style may not scan on every phone camera. Square is the safest choice.';
|
||||||
|
$level = 'fair';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($moduleMeta['scan_risk'] === 'high') {
|
||||||
|
$messages[] = 'Decorative styles like ' . $moduleMeta['label'] . ' often fail on Samsung Camera. Use square for print and signage.';
|
||||||
|
$level = 'poor';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $isClassic) {
|
||||||
|
$messages[] = 'Colored QR codes often fail on Samsung Camera and other basic scanners. Black on white works everywhere.';
|
||||||
|
$level = $level === 'good' ? 'fair' : 'poor';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($contrast < 4.5) {
|
||||||
|
$messages[] = sprintf(
|
||||||
|
'Contrast is low (%.1f:1). Aim for at least 4.5:1 — dark foreground, white background.',
|
||||||
|
$contrast,
|
||||||
|
);
|
||||||
|
$level = 'poor';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($moduleMeta['scan_risk'] !== 'low' && ! $isClassic) {
|
||||||
|
$level = 'poor';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($messages === []) {
|
||||||
|
$messages[] = 'Black square modules on white give the best compatibility with Samsung, iPhone, and printed codes.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['level' => $level, 'messages' => $messages];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $style */
|
||||||
|
public static function isClassicBlackOnWhite(array $style): bool
|
||||||
|
{
|
||||||
|
$fg = strtolower(ltrim((string) ($style['foreground'] ?? ''), '#'));
|
||||||
|
$bg = strtolower(ltrim((string) ($style['background'] ?? ''), '#'));
|
||||||
|
|
||||||
|
$fg = strlen($fg) === 3 ? $fg[0] . $fg[0] . $fg[1] . $fg[1] . $fg[2] . $fg[2] : $fg;
|
||||||
|
$bg = strlen($bg) === 3 ? $bg[0] . $bg[0] . $bg[1] . $bg[1] . $bg[2] . $bg[2] : $bg;
|
||||||
|
|
||||||
|
return in_array($fg, ['000000', '0f172a', '111111', '1a1a1a'], true)
|
||||||
|
&& in_array($bg, ['ffffff', 'fff'], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function relativeLuminance(string $hex): float
|
||||||
|
{
|
||||||
|
$hex = ltrim(trim($hex), '#');
|
||||||
|
if (strlen($hex) === 3) {
|
||||||
|
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strlen($hex) !== 6 || ! ctype_xdigit($hex)) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$channels = [];
|
||||||
|
foreach ([0, 2, 4] as $offset) {
|
||||||
|
$value = hexdec(substr($hex, $offset, 2)) / 255;
|
||||||
|
$channels[] = $value <= 0.03928
|
||||||
|
? $value / 12.92
|
||||||
|
: (($value + 0.055) / 1.055) ** 2.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (0.2126 * $channels[0]) + (0.7152 * $channels[1]) + (0.0722 * $channels[2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support\Qr;
|
||||||
|
|
||||||
|
class QrStyleDefaults
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public static function defaults(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'foreground' => '#000000',
|
||||||
|
'background' => '#ffffff',
|
||||||
|
'error_correction' => 'M',
|
||||||
|
'margin' => 4,
|
||||||
|
'module_style' => 'square',
|
||||||
|
'finder_outer' => 'square',
|
||||||
|
'finder_inner' => 'square',
|
||||||
|
'frame_style' => 'none',
|
||||||
|
'frame_text' => '',
|
||||||
|
'frame_color' => '#000000',
|
||||||
|
'scale' => 8,
|
||||||
|
'logo_path' => null,
|
||||||
|
'gradient_type' => 'none',
|
||||||
|
'gradient_color1' => '#000000',
|
||||||
|
'gradient_color2' => '#7c3aed',
|
||||||
|
'gradient_rotation' => 45,
|
||||||
|
'logo_size' => 0.3,
|
||||||
|
'logo_margin' => 5,
|
||||||
|
'logo_white_bg' => false,
|
||||||
|
'logo_shape' => 'none',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed>|null $style */
|
||||||
|
public static function merge(?array $style): array
|
||||||
|
{
|
||||||
|
$merged = array_merge(self::defaults(), $style ?? []);
|
||||||
|
|
||||||
|
if (! in_array($merged['error_correction'], ['L', 'M', 'Q', 'H'], true)) {
|
||||||
|
$merged['error_correction'] = 'M';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($merged['module_style'], QrModuleStyleCatalog::keys(), true)) {
|
||||||
|
$merged['module_style'] = 'square';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! QrCornerStyleCatalog::isValidOuter((string) ($merged['finder_outer'] ?? 'square'))) {
|
||||||
|
$merged['finder_outer'] = 'square';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! QrCornerStyleCatalog::isValidInner((string) ($merged['finder_inner'] ?? 'square'))) {
|
||||||
|
$merged['finder_inner'] = 'square';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! QrFrameStyleCatalog::isValid((string) ($merged['frame_style'] ?? 'none'))) {
|
||||||
|
$merged['frame_style'] = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
$merged['frame_text'] = substr(trim((string) ($merged['frame_text'] ?? '')), 0, 100);
|
||||||
|
$frameColor = trim((string) ($merged['frame_color'] ?? '#000000'));
|
||||||
|
$merged['frame_color'] = preg_match('/^#[0-9a-fA-F]{6}$/', $frameColor) ? $frameColor : '#000000';
|
||||||
|
|
||||||
|
if (! in_array($merged['gradient_type'], ['none', 'linear', 'radial'], true)) {
|
||||||
|
$merged['gradient_type'] = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
$merged['logo_size'] = max(0.1, min(0.4, (float) ($merged['logo_size'] ?? 0.3)));
|
||||||
|
$merged['logo_margin'] = max(0, min(15, (int) ($merged['logo_margin'] ?? 5)));
|
||||||
|
if (! in_array($merged['logo_shape'], ['none', 'rounded', 'circle'], true)) {
|
||||||
|
$merged['logo_shape'] = 'none';
|
||||||
|
}
|
||||||
|
$merged['logo_white_bg'] = (bool) ($merged['logo_white_bg'] ?? false);
|
||||||
|
|
||||||
|
if ($merged['module_style'] === 'bold') {
|
||||||
|
$merged['scale'] = min(16, (int) $merged['scale'] + 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
$merged['margin'] = max(0, min(10, (int) $merged['margin']));
|
||||||
|
$merged['scale'] = max(4, min(16, (int) $merged['scale']));
|
||||||
|
|
||||||
|
return $merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed>|null $style */
|
||||||
|
public static function mergeForRender(?array $style): array
|
||||||
|
{
|
||||||
|
return QrStyleNormalizer::normalize(self::merge($style));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $style */
|
||||||
|
public static function recommendedEcc(array $style): string
|
||||||
|
{
|
||||||
|
$ecc = (string) ($style['error_correction'] ?? 'M');
|
||||||
|
$order = ['L' => 0, 'M' => 1, 'Q' => 2, 'H' => 3];
|
||||||
|
$minimum = 'M';
|
||||||
|
$moduleStyle = (string) ($style['module_style'] ?? 'square');
|
||||||
|
|
||||||
|
if (in_array($moduleStyle, ['bubble', 'fluid'], true)) {
|
||||||
|
$minimum = 'H';
|
||||||
|
} elseif (QrModuleStyleCatalog::isDecorative($moduleStyle)) {
|
||||||
|
$minimum = 'Q';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($style['logo_path'])) {
|
||||||
|
$minimum = 'H';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (QrScanReliability::contrastRatio($style) < 4.5) {
|
||||||
|
$minimum = 'H';
|
||||||
|
}
|
||||||
|
|
||||||
|
return ($order[$minimum] ?? 1) > ($order[$ecc] ?? 1) ? $minimum : $ecc;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support\Qr;
|
||||||
|
|
||||||
|
class QrStyleNormalizer
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Silently tune styles so more phone cameras (including Samsung) can read the code.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $style
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public static function normalize(array $style): array
|
||||||
|
{
|
||||||
|
$style = self::ensureContrast($style);
|
||||||
|
$style = self::ensureQuietZone($style);
|
||||||
|
$style = self::ensureRenderScale($style);
|
||||||
|
|
||||||
|
$style['error_correction'] = QrStyleDefaults::recommendedEcc($style);
|
||||||
|
|
||||||
|
return $style;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $style */
|
||||||
|
private static function ensureContrast(array $style): array
|
||||||
|
{
|
||||||
|
$target = 4.5;
|
||||||
|
$attempts = 0;
|
||||||
|
|
||||||
|
while (QrScanReliability::contrastRatio($style) < $target && $attempts < 16) {
|
||||||
|
$fg = self::hexToRgb((string) $style['foreground']);
|
||||||
|
$bg = self::hexToRgb((string) $style['background']);
|
||||||
|
|
||||||
|
if (self::relativeLuminance($fg) > self::relativeLuminance($bg)) {
|
||||||
|
$style['foreground'] = self::rgbToHex(self::darkenToward($fg, 0.18));
|
||||||
|
} else {
|
||||||
|
$style['foreground'] = self::rgbToHex(self::darkenToward($fg, 0.15));
|
||||||
|
$style['background'] = self::rgbToHex(self::lightenToward($bg, 0.15));
|
||||||
|
}
|
||||||
|
|
||||||
|
$attempts++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (QrScanReliability::contrastRatio($style) < $target) {
|
||||||
|
$style['foreground'] = '#000000';
|
||||||
|
$style['background'] = '#ffffff';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $style;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $style */
|
||||||
|
private static function ensureQuietZone(array $style): array
|
||||||
|
{
|
||||||
|
$margin = (int) ($style['margin'] ?? 4);
|
||||||
|
$moduleStyle = (string) ($style['module_style'] ?? 'square');
|
||||||
|
|
||||||
|
if (QrModuleStyleCatalog::isDecorative($moduleStyle)) {
|
||||||
|
$margin = max($margin, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
$style['margin'] = max(4, min(10, $margin));
|
||||||
|
|
||||||
|
return $style;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $style */
|
||||||
|
private static function ensureRenderScale(array $style): array
|
||||||
|
{
|
||||||
|
$scale = (int) ($style['scale'] ?? 8);
|
||||||
|
$module = QrModuleStyleCatalog::optionsFor((string) ($style['module_style'] ?? 'square'));
|
||||||
|
|
||||||
|
if ($module['circular'] ?? false) {
|
||||||
|
$scale = max($scale, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($style['logo_path'])) {
|
||||||
|
$scale = max($scale, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
$style['scale'] = max(6, min(16, $scale));
|
||||||
|
|
||||||
|
return $style;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{0: int, 1: int, 2: int} */
|
||||||
|
private static function hexToRgb(string $hex): array
|
||||||
|
{
|
||||||
|
$hex = ltrim(trim($hex), '#');
|
||||||
|
if (strlen($hex) === 3) {
|
||||||
|
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strlen($hex) !== 6 || ! ctype_xdigit($hex)) {
|
||||||
|
return [0, 0, 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [hexdec(substr($hex, 0, 2)), hexdec(substr($hex, 2, 2)), hexdec(substr($hex, 4, 2))];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array{0: int, 1: int, 2: int} $rgb */
|
||||||
|
private static function rgbToHex(array $rgb): string
|
||||||
|
{
|
||||||
|
return sprintf('#%02x%02x%02x', $rgb[0], $rgb[1], $rgb[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array{0: int, 1: int, 2: int} $rgb */
|
||||||
|
private static function darkenToward(array $rgb, float $amount): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
(int) max(0, $rgb[0] * (1 - $amount)),
|
||||||
|
(int) max(0, $rgb[1] * (1 - $amount)),
|
||||||
|
(int) max(0, $rgb[2] * (1 - $amount)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array{0: int, 1: int, 2: int} $rgb */
|
||||||
|
private static function lightenToward(array $rgb, float $amount): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
(int) min(255, $rgb[0] + ((255 - $rgb[0]) * $amount)),
|
||||||
|
(int) min(255, $rgb[1] + ((255 - $rgb[1]) * $amount)),
|
||||||
|
(int) min(255, $rgb[2] + ((255 - $rgb[2]) * $amount)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array{0: int, 1: int, 2: int} $rgb */
|
||||||
|
private static function relativeLuminance(array $rgb): float
|
||||||
|
{
|
||||||
|
$channels = [];
|
||||||
|
foreach ($rgb as $value) {
|
||||||
|
$v = $value / 255;
|
||||||
|
$channels[] = $v <= 0.03928 ? $v / 12.92 : (($v + 0.055) / 1.055) ** 2.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (0.2126 * $channels[0]) + (0.7152 * $channels[1]) + (0.0722 * $channels[2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support\Qr;
|
||||||
|
|
||||||
|
use App\Models\QrCode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ladill Mini — static payment QR codes only.
|
||||||
|
*/
|
||||||
|
class QrTypeCatalog
|
||||||
|
{
|
||||||
|
/** @return list<string> */
|
||||||
|
public static function paymentTypes(): array
|
||||||
|
{
|
||||||
|
return [QrCode::TYPE_PAYMENT];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, array{label: string, description: string, category: string, icon: string}> */
|
||||||
|
public static function all(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
QrCode::TYPE_PAYMENT => [
|
||||||
|
'label' => 'Payment QR',
|
||||||
|
'description' => 'A static QR customers scan to pay you any amount',
|
||||||
|
'category' => 'payments',
|
||||||
|
'icon' => 'payment.svg',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
public static function keys(): array
|
||||||
|
{
|
||||||
|
return array_keys(self::all());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function label(string $type): string
|
||||||
|
{
|
||||||
|
return self::all()[$type]['label'] ?? ucfirst(str_replace('_', ' ', $type));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isValid(string $type): bool
|
||||||
|
{
|
||||||
|
return isset(self::all()[$type]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function iconUrl(string $icon): string
|
||||||
|
{
|
||||||
|
$path = public_path('images/qr-icons/'.$icon);
|
||||||
|
$url = '/images/qr-icons/'.$icon;
|
||||||
|
|
||||||
|
if (is_file($path)) {
|
||||||
|
return $url.'?v='.filemtime($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support\Qr;
|
||||||
|
|
||||||
|
class QrWifiPayload
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Build a WiFi QR payload (ZXing MECARD format) for one-tap network join on scan.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $content
|
||||||
|
*/
|
||||||
|
public static function encode(array $content): string
|
||||||
|
{
|
||||||
|
$encryption = strtoupper(trim((string) ($content['encryption'] ?? 'WPA')));
|
||||||
|
$auth = $encryption === 'NOPASS' ? 'nopass' : $encryption;
|
||||||
|
if (! in_array($auth, ['WPA', 'WEP', 'nopass'], true)) {
|
||||||
|
$auth = 'WPA';
|
||||||
|
}
|
||||||
|
|
||||||
|
$ssid = self::escape(trim((string) ($content['ssid'] ?? '')));
|
||||||
|
$payload = 'WIFI:T:' . $auth . ';S:' . $ssid;
|
||||||
|
|
||||||
|
if ($auth !== 'nopass') {
|
||||||
|
$password = trim((string) ($content['password'] ?? ''));
|
||||||
|
if ($password !== '') {
|
||||||
|
$payload .= ';P:' . self::escape($password);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter_var($content['hidden'] ?? false, FILTER_VALIDATE_BOOL)) {
|
||||||
|
$payload .= ';H:true';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $payload . ';;';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function escape(string $value): string
|
||||||
|
{
|
||||||
|
return str_replace(
|
||||||
|
['\\', ';', ',', '"', ':'],
|
||||||
|
['\\\\', '\\;', '\\,', '\\"', '\\:'],
|
||||||
|
$value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use App\Models\DomainOrder;
|
||||||
|
use App\Models\RcServiceOrder;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ResellerClub is legacy-only for new sales. Renewals and management remain available
|
||||||
|
* for customers with existing ResellerClub services.
|
||||||
|
*/
|
||||||
|
final class ResellerClubLegacy
|
||||||
|
{
|
||||||
|
public static function integrationEnabled(): bool
|
||||||
|
{
|
||||||
|
return (bool) config('hosting.legacy.rc_enabled', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function newOrdersEnabled(): bool
|
||||||
|
{
|
||||||
|
return self::integrationEnabled()
|
||||||
|
&& (bool) config('hosting.legacy.rc_new_orders_enabled', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function renewalsEnabled(): bool
|
||||||
|
{
|
||||||
|
return self::integrationEnabled()
|
||||||
|
&& (bool) config('hosting.legacy.rc_renewals_enabled', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isRenewalRcServiceOrder(RcServiceOrder $order): bool
|
||||||
|
{
|
||||||
|
if ($order->order_type === RcServiceOrder::TYPE_RENEWAL) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (bool) data_get($order->meta, 'is_renewal', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isRenewalDomainOrder(DomainOrder $order): bool
|
||||||
|
{
|
||||||
|
return $order->order_type === DomainOrder::TYPE_RENEWAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canAutomateFulfillment(RcServiceOrder $order): bool
|
||||||
|
{
|
||||||
|
if (! self::integrationEnabled()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self::newOrdersEnabled()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::renewalsEnabled() && self::isRenewalRcServiceOrder($order);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, RcServiceOrder>|iterable<int, RcServiceOrder> $items
|
||||||
|
*/
|
||||||
|
public static function assertCartCheckoutAllowed(iterable $items): void
|
||||||
|
{
|
||||||
|
$items = $items instanceof Collection ? $items : collect($items);
|
||||||
|
|
||||||
|
if ($items->isEmpty()) {
|
||||||
|
throw new RuntimeException('Your cart is empty.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self::newOrdersEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! self::renewalsEnabled()) {
|
||||||
|
throw new RuntimeException(self::renewalsDisabledMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($items->contains(fn (RcServiceOrder $item) => ! self::isRenewalRcServiceOrder($item))) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
'ResellerClub checkout is limited to renewals for existing services. '
|
||||||
|
.'New product purchases use our current domain and hosting products instead.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function assertNewSaleAllowed(): void
|
||||||
|
{
|
||||||
|
if (self::newOrdersEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new RuntimeException(
|
||||||
|
'ResellerClub is no longer available for new product purchases. '
|
||||||
|
.'Use our domain and hosting products instead, or renew an existing ResellerClub service from your dashboard.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function assertRenewalsAllowed(): void
|
||||||
|
{
|
||||||
|
if (! self::renewalsEnabled()) {
|
||||||
|
throw new RuntimeException(self::renewalsDisabledMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use assertNewSaleAllowed() */
|
||||||
|
public static function assertNewOrdersAllowed(): void
|
||||||
|
{
|
||||||
|
self::assertNewSaleAllowed();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function renewalsDisabledMessage(): string
|
||||||
|
{
|
||||||
|
return 'ResellerClub renewals are not available at this time. Please contact support if you need assistance.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fulfillmentDisabledMessage(): string
|
||||||
|
{
|
||||||
|
return 'Automated ResellerClub fulfillment for new purchases is no longer available. '
|
||||||
|
.'Open a support ticket if you need help with a legacy order.';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Auth\Authenticatable;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
class UserProfileMenu
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Standard signed-in profile links for Ladill product apps.
|
||||||
|
* Not used by Ladill Mail (mail.ladill.com) — that app keeps a mailbox-specific menu.
|
||||||
|
*
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public static function items(?Authenticatable $user = null): array
|
||||||
|
{
|
||||||
|
$user ??= auth()->user();
|
||||||
|
|
||||||
|
if (! $user) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = [];
|
||||||
|
|
||||||
|
foreach ([
|
||||||
|
['label' => 'Home', 'path' => '', 'host' => 'home'],
|
||||||
|
['label' => 'Profile', 'path' => 'profile'],
|
||||||
|
['label' => 'Account Settings', 'path' => 'account-settings'],
|
||||||
|
['label' => 'Dashboard', 'path' => 'dashboard'],
|
||||||
|
['label' => 'Billing', 'path' => 'billing'],
|
||||||
|
] as $link) {
|
||||||
|
$href = self::platformUrl($link['host'] ?? 'account', $link['path']);
|
||||||
|
|
||||||
|
if (self::isCurrentMenuLink($link, $href)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$items[] = [
|
||||||
|
'type' => 'link',
|
||||||
|
'label' => $link['label'],
|
||||||
|
'href' => $href,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (Route::has((string) config("billing.wallet_balance_route", "wallet.balance"))) {
|
||||||
|
$items[] = ["type" => "wallet"];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self::userIsAdmin($user)) {
|
||||||
|
$adminHref = self::platformUrl('account', 'admin');
|
||||||
|
|
||||||
|
if (! self::isCurrentMenuLink(['path' => 'admin', 'host' => 'account'], $adminHref)) {
|
||||||
|
$items[] = [
|
||||||
|
'type' => 'link',
|
||||||
|
'label' => 'Admin',
|
||||||
|
'href' => $adminHref,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Route::has('logout')) {
|
||||||
|
$items[] = [
|
||||||
|
'type' => 'logout',
|
||||||
|
'label' => 'Logout',
|
||||||
|
'action' => route('logout'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $items;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function platformUrl(string $host, string $path = ''): string
|
||||||
|
{
|
||||||
|
if ($host === 'home') {
|
||||||
|
if (function_exists('ladill_home_url')) {
|
||||||
|
return ladill_home_url($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
$domain = config('app.home_domain');
|
||||||
|
if (! $domain) {
|
||||||
|
$platform = (string) config('app.platform_domain', parse_url((string) config('app.url', ''), PHP_URL_HOST) ?: '');
|
||||||
|
$domain = $platform !== '' ? 'home.'.$platform : (string) config('app.account_domain');
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::absoluteUrl((string) $domain, $path);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (function_exists('ladill_account_url')) {
|
||||||
|
return ladill_account_url($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::absoluteUrl((string) config('app.account_domain'), $path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide a menu link when the signed-in user is already on that destination.
|
||||||
|
*
|
||||||
|
* @param array{label?: string, path?: string, host?: string} $link
|
||||||
|
*/
|
||||||
|
private static function isCurrentMenuLink(array $link, string $href): bool
|
||||||
|
{
|
||||||
|
if (app()->runningInConsole() && ! app()->runningUnitTests()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$request = request();
|
||||||
|
if (! $request) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$linkHost = strtolower((string) parse_url($href, PHP_URL_HOST));
|
||||||
|
$currentHost = strtolower($request->getHost());
|
||||||
|
|
||||||
|
if ($linkHost === '' || $linkHost !== $currentHost) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentPath = trim($request->getPathInfo(), '/');
|
||||||
|
$targetPath = trim((string) parse_url($href, PHP_URL_PATH), '/');
|
||||||
|
|
||||||
|
if (($link['host'] ?? 'account') === 'home') {
|
||||||
|
return $currentPath === '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($link['path'] ?? '') === 'dashboard') {
|
||||||
|
return in_array($currentPath, ['dashboard', 'account'], true)
|
||||||
|
|| ($targetPath !== '' && self::pathMatchesSection($currentPath, $targetPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::pathMatchesSection($currentPath, $targetPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function pathMatchesSection(string $currentPath, string $targetPath): bool
|
||||||
|
{
|
||||||
|
if ($targetPath === '') {
|
||||||
|
return $currentPath === '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $currentPath === $targetPath
|
||||||
|
|| str_starts_with($currentPath, $targetPath.'/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function absoluteUrl(string $domain, string $path = ''): string
|
||||||
|
{
|
||||||
|
$base = 'https://'.trim($domain, '/');
|
||||||
|
|
||||||
|
if ($path === '') {
|
||||||
|
return $base;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $base.'/'.ltrim($path, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function userIsAdmin(Authenticatable $user): bool
|
||||||
|
{
|
||||||
|
if (method_exists($user, 'isAdmin')) {
|
||||||
|
return $user->isAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (bool) ($user->is_admin ?? false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
if (! function_exists('invoice_money')) {
|
||||||
|
function invoice_money(int $minor, string $currency = 'GHS'): string
|
||||||
|
{
|
||||||
|
return $currency.' '.number_format($minor / 100, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('ladill_account')) {
|
||||||
|
/**
|
||||||
|
* The account the current request acts within — the owner User. Defaults to
|
||||||
|
* the authenticated user; a team member who switched accounts gets the owner.
|
||||||
|
* Set by the SetActingAccount middleware.
|
||||||
|
*/
|
||||||
|
function ladill_account(): ?User
|
||||||
|
{
|
||||||
|
$request = request();
|
||||||
|
|
||||||
|
return $request->attributes->get('actingAccount') ?? $request->user();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('ladill_domains_url')) {
|
||||||
|
function ladill_domains_url(string $path = ''): string
|
||||||
|
{
|
||||||
|
return 'https://'.config('app.domains_domain').($path !== '' ? '/'.ltrim($path, '/') : '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('ladill_account_url')) {
|
||||||
|
function ladill_account_url(string $path = ''): string
|
||||||
|
{
|
||||||
|
return 'https://'.config('app.account_domain').($path !== '' ? '/'.ltrim($path, '/') : '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('ladill_servers_url')) {
|
||||||
|
function ladill_servers_url(string $path = ''): string
|
||||||
|
{
|
||||||
|
return 'https://'.config('app.servers_domain').($path !== '' ? '/'.ltrim($path, '/') : '');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\View\Components;
|
||||||
|
|
||||||
|
use Illuminate\View\Component;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class UserLayout extends Component
|
||||||
|
{
|
||||||
|
public function render(): View
|
||||||
|
{
|
||||||
|
return view('layouts.user');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Application;
|
||||||
|
use Symfony\Component\Console\Input\ArgvInput;
|
||||||
|
|
||||||
|
define('LARAVEL_START', microtime(true));
|
||||||
|
|
||||||
|
// Register the Composer autoloader...
|
||||||
|
require __DIR__.'/vendor/autoload.php';
|
||||||
|
|
||||||
|
// Bootstrap Laravel and handle the command...
|
||||||
|
/** @var Application $app */
|
||||||
|
$app = require_once __DIR__.'/bootstrap/app.php';
|
||||||
|
|
||||||
|
$status = $app->handleCommand(new ArgvInput);
|
||||||
|
|
||||||
|
exit($status);
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Application;
|
||||||
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
|
->withRouting(
|
||||||
|
web: __DIR__.'/../routes/web.php',
|
||||||
|
api: __DIR__.'/../routes/api.php',
|
||||||
|
commands: __DIR__.'/../routes/console.php',
|
||||||
|
health: '/up',
|
||||||
|
)
|
||||||
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
|
$middleware->redirectGuestsTo(fn (Request $request) => route('sso.connect', [
|
||||||
|
'redirect' => $request->fullUrl(),
|
||||||
|
]));
|
||||||
|
$middleware->web(append: [
|
||||||
|
\App\Http\Middleware\InjectBootSplash::class,
|
||||||
|
\App\Http\Middleware\SetActingAccount::class,
|
||||||
|
]);
|
||||||
|
$middleware->alias([
|
||||||
|
'platform.session' => \App\Http\Middleware\EnsurePlatformSession::class,
|
||||||
|
]);
|
||||||
|
})
|
||||||
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
|
//
|
||||||
|
})->create();
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Providers\AppServiceProvider;
|
||||||
|
|
||||||
|
return [
|
||||||
|
AppServiceProvider::class,
|
||||||
|
];
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://getcomposer.org/schema.json",
|
||||||
|
"name": "ladill/woo-manager",
|
||||||
|
"type": "project",
|
||||||
|
"description": "Ladill Woo Manager — send invoices and collect Paystack payments at woo.ladill.com",
|
||||||
|
"keywords": [
|
||||||
|
"laravel",
|
||||||
|
"framework"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"require": {
|
||||||
|
"php": "^8.2",
|
||||||
|
"barryvdh/laravel-dompdf": "^3.1",
|
||||||
|
"chillerlan/php-qrcode": "^5.0",
|
||||||
|
"laravel/framework": "^12.0",
|
||||||
|
"laravel/sanctum": "^4.3",
|
||||||
|
"laravel/tinker": "^2.10.1",
|
||||||
|
"phpseclib/phpseclib": "^3.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"fakerphp/faker": "^1.23",
|
||||||
|
"laravel/pail": "^1.2.2",
|
||||||
|
"laravel/pint": "^1.24",
|
||||||
|
"laravel/sail": "^1.41",
|
||||||
|
"mockery/mockery": "^1.6",
|
||||||
|
"nunomaduro/collision": "^8.6",
|
||||||
|
"phpunit/phpunit": "^11.5.50"
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"App\\": "app/",
|
||||||
|
"Database\\Factories\\": "database/factories/",
|
||||||
|
"Database\\Seeders\\": "database/seeders/"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"app/Support/helpers.php"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"autoload-dev": {
|
||||||
|
"psr-4": {
|
||||||
|
"Tests\\": "tests/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"setup": [
|
||||||
|
"composer install",
|
||||||
|
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
|
||||||
|
"@php artisan key:generate",
|
||||||
|
"@php artisan migrate --force",
|
||||||
|
"npm install",
|
||||||
|
"npm run build"
|
||||||
|
],
|
||||||
|
"dev": [
|
||||||
|
"Composer\\Config::disableProcessTimeout",
|
||||||
|
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
|
||||||
|
],
|
||||||
|
"test": [
|
||||||
|
"@php artisan config:clear --ansi",
|
||||||
|
"@php artisan test"
|
||||||
|
],
|
||||||
|
"post-autoload-dump": [
|
||||||
|
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||||
|
"@php artisan package:discover --ansi"
|
||||||
|
],
|
||||||
|
"post-update-cmd": [
|
||||||
|
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
|
||||||
|
],
|
||||||
|
"post-root-package-install": [
|
||||||
|
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
|
||||||
|
],
|
||||||
|
"post-create-project-cmd": [
|
||||||
|
"@php artisan key:generate --ansi",
|
||||||
|
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
|
||||||
|
"@php artisan migrate --graceful --ansi"
|
||||||
|
],
|
||||||
|
"pre-package-uninstall": [
|
||||||
|
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"dont-discover": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"optimize-autoloader": true,
|
||||||
|
"preferred-install": "dist",
|
||||||
|
"sort-packages": true,
|
||||||
|
"allow-plugins": {
|
||||||
|
"pestphp/pest-plugin": true,
|
||||||
|
"php-http/discovery": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"minimum-stability": "stable",
|
||||||
|
"prefer-stable": true
|
||||||
|
}
|
||||||
Generated
+9380
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Ladill Mini Android App Links (Digital Asset Links)
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Served at /.well-known/assetlinks.json for Android App Link verification.
|
||||||
|
| Use the App signing certificate SHA-256 from Play Console → Setup →
|
||||||
|
| App signing (not the upload key unless you opted out of Play signing).
|
||||||
|
|
|
||||||
|
| Multiple fingerprints may be comma-separated in ANDROID_APP_SHA256_FINGERPRINTS.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'package_name' => env('ANDROID_APP_PACKAGE', 'com.ladill.mini'),
|
||||||
|
|
||||||
|
'sha256_cert_fingerprints' => array_values(array_filter(array_map(
|
||||||
|
static fn (string $fingerprint): string => strtoupper(trim($fingerprint)),
|
||||||
|
explode(',', (string) env('ANDROID_APP_SHA256_FINGERPRINTS', '')),
|
||||||
|
))),
|
||||||
|
|
||||||
|
];
|
||||||
+134
@@ -0,0 +1,134 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Application Name
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This value is the name of your application, which will be used when the
|
||||||
|
| framework needs to place the application's name in a notification or
|
||||||
|
| other UI elements where an application name needs to be displayed.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'name' => env('APP_NAME', 'Ladill Woo Manager'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Application Environment
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This value determines the "environment" your application is currently
|
||||||
|
| running in. This may determine how you prefer to configure various
|
||||||
|
| services the application utilizes. Set this in your ".env" file.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'env' => env('APP_ENV', 'production'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Application Debug Mode
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When your application is in debug mode, detailed error messages with
|
||||||
|
| stack traces will be shown on every error that occurs within your
|
||||||
|
| application. If disabled, a simple generic error page is shown.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'debug' => (bool) env('APP_DEBUG', false),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Application URL
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This URL is used by the console to properly generate URLs when using
|
||||||
|
| the Artisan command line tool. You should set this to the root of
|
||||||
|
| the application so that it's available within Artisan commands.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'url' => env('APP_URL', 'http://localhost'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Application Timezone
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify the default timezone for your application, which
|
||||||
|
| will be used by the PHP date and date-time functions. The timezone
|
||||||
|
| is set to "UTC" by default as it is suitable for most use cases.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'timezone' => 'UTC',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Application Locale Configuration
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The application locale determines the default locale that will be used
|
||||||
|
| by Laravel's translation / localization methods. This option can be
|
||||||
|
| set to any locale for which you plan to have translation strings.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'locale' => env('APP_LOCALE', 'en'),
|
||||||
|
|
||||||
|
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
|
||||||
|
|
||||||
|
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Encryption Key
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This key is utilized by Laravel's encryption services and should be set
|
||||||
|
| to a random, 32 character string to ensure that all encrypted values
|
||||||
|
| are secure. You should do this prior to deploying the application.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'cipher' => 'AES-256-CBC',
|
||||||
|
|
||||||
|
'key' => env('APP_KEY'),
|
||||||
|
|
||||||
|
'previous_keys' => [
|
||||||
|
...array_filter(
|
||||||
|
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Maintenance Mode Driver
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| These configuration options determine the driver used to determine and
|
||||||
|
| manage Laravel's "maintenance mode" status. The "cache" driver will
|
||||||
|
| allow maintenance mode to be controlled across multiple machines.
|
||||||
|
|
|
||||||
|
| Supported drivers: "file", "cache"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'maintenance' => [
|
||||||
|
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
|
||||||
|
'store' => env('APP_MAINTENANCE_STORE', 'database'),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Platform identity surfaces (Zoho One model). Ladill Woo Manager (woo.ladill.com).
|
||||||
|
'platform_domain' => env('PLATFORM_DOMAIN', parse_url((string) env('PLATFORM_URL', 'https://ladill.com'), PHP_URL_HOST) ?: 'ladill.com'),
|
||||||
|
'auth_domain' => env('AUTH_DOMAIN', 'auth.'.(parse_url((string) env('PLATFORM_URL', 'https://ladill.com'), PHP_URL_HOST) ?: 'ladill.com')),
|
||||||
|
'account_domain' => env('ACCOUNT_DOMAIN', 'account.'.(parse_url((string) env('PLATFORM_URL', 'https://ladill.com'), PHP_URL_HOST) ?: 'ladill.com')),
|
||||||
|
'invoice_domain' => env('WOO_MANAGER_DOMAIN', parse_url((string) env('APP_URL', 'https://woo.ladill.com'), PHP_URL_HOST) ?: 'woo.ladill.com'),
|
||||||
|
'servers_domain' => env('SERVERS_DOMAIN', 'servers.'.(parse_url((string) env('PLATFORM_URL', 'https://ladill.com'), PHP_URL_HOST) ?: 'ladill.com')),
|
||||||
|
'domains_domain' => env('DOMAINS_DOMAIN', 'domains.'.(parse_url((string) env('PLATFORM_URL', 'https://ladill.com'), PHP_URL_HOST) ?: 'ladill.com')),
|
||||||
|
|
||||||
|
];
|
||||||
+117
@@ -0,0 +1,117 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Authentication Defaults
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option defines the default authentication "guard" and password
|
||||||
|
| reset "broker" for your application. You may change these values
|
||||||
|
| as required, but they're a perfect start for most applications.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'defaults' => [
|
||||||
|
'guard' => env('AUTH_GUARD', 'web'),
|
||||||
|
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Authentication Guards
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Next, you may define every authentication guard for your application.
|
||||||
|
| Of course, a great default configuration has been defined for you
|
||||||
|
| which utilizes session storage plus the Eloquent user provider.
|
||||||
|
|
|
||||||
|
| All authentication guards have a user provider, which defines how the
|
||||||
|
| users are actually retrieved out of your database or other storage
|
||||||
|
| system used by the application. Typically, Eloquent is utilized.
|
||||||
|
|
|
||||||
|
| Supported: "session"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'guards' => [
|
||||||
|
'web' => [
|
||||||
|
'driver' => 'session',
|
||||||
|
'provider' => 'users',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| User Providers
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| All authentication guards have a user provider, which defines how the
|
||||||
|
| users are actually retrieved out of your database or other storage
|
||||||
|
| system used by the application. Typically, Eloquent is utilized.
|
||||||
|
|
|
||||||
|
| If you have multiple user tables or models you may configure multiple
|
||||||
|
| providers to represent the model / table. These providers may then
|
||||||
|
| be assigned to any extra authentication guards you have defined.
|
||||||
|
|
|
||||||
|
| Supported: "database", "eloquent"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'providers' => [
|
||||||
|
'users' => [
|
||||||
|
'driver' => 'eloquent',
|
||||||
|
'model' => env('AUTH_MODEL', User::class),
|
||||||
|
],
|
||||||
|
|
||||||
|
// 'users' => [
|
||||||
|
// 'driver' => 'database',
|
||||||
|
// 'table' => 'users',
|
||||||
|
// ],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Resetting Passwords
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| These configuration options specify the behavior of Laravel's password
|
||||||
|
| reset functionality, including the table utilized for token storage
|
||||||
|
| and the user provider that is invoked to actually retrieve users.
|
||||||
|
|
|
||||||
|
| The expiry time is the number of minutes that each reset token will be
|
||||||
|
| considered valid. This security feature keeps tokens short-lived so
|
||||||
|
| they have less time to be guessed. You may change this as needed.
|
||||||
|
|
|
||||||
|
| The throttle setting is the number of seconds a user must wait before
|
||||||
|
| generating more password reset tokens. This prevents the user from
|
||||||
|
| quickly generating a very large amount of password reset tokens.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'passwords' => [
|
||||||
|
'users' => [
|
||||||
|
'provider' => 'users',
|
||||||
|
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
|
||||||
|
'expire' => 60,
|
||||||
|
'throttle' => 60,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Password Confirmation Timeout
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may define the number of seconds before a password confirmation
|
||||||
|
| window expires and users are asked to re-enter their password via the
|
||||||
|
| confirmation screen. By default, the timeout lasts for three hours.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'api_url' => env('BILLING_API_URL', 'https://ladill.com/api/billing'),
|
||||||
|
'api_key' => env('BILLING_API_KEY_WOO_MANAGER'),
|
||||||
|
'service' => 'woo-manager',
|
||||||
|
'wallet_balance_route' => 'wallet.balance',
|
||||||
|
'currency' => 'GHS',
|
||||||
|
];
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Cache Store
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option controls the default cache store that will be used by the
|
||||||
|
| framework. This connection is utilized if another isn't explicitly
|
||||||
|
| specified when running a cache operation inside the application.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('CACHE_STORE', 'database'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Cache Stores
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may define all of the cache "stores" for your application as
|
||||||
|
| well as their drivers. You may even define multiple stores for the
|
||||||
|
| same cache driver to group types of items stored in your caches.
|
||||||
|
|
|
||||||
|
| Supported drivers: "array", "database", "file", "memcached",
|
||||||
|
| "redis", "dynamodb", "octane",
|
||||||
|
| "failover", "null"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'stores' => [
|
||||||
|
|
||||||
|
'array' => [
|
||||||
|
'driver' => 'array',
|
||||||
|
'serialize' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'database' => [
|
||||||
|
'driver' => 'database',
|
||||||
|
'connection' => env('DB_CACHE_CONNECTION'),
|
||||||
|
'table' => env('DB_CACHE_TABLE', 'cache'),
|
||||||
|
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
|
||||||
|
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'file' => [
|
||||||
|
'driver' => 'file',
|
||||||
|
'path' => storage_path('framework/cache/data'),
|
||||||
|
'lock_path' => storage_path('framework/cache/data'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'memcached' => [
|
||||||
|
'driver' => 'memcached',
|
||||||
|
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
|
||||||
|
'sasl' => [
|
||||||
|
env('MEMCACHED_USERNAME'),
|
||||||
|
env('MEMCACHED_PASSWORD'),
|
||||||
|
],
|
||||||
|
'options' => [
|
||||||
|
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
|
||||||
|
],
|
||||||
|
'servers' => [
|
||||||
|
[
|
||||||
|
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
|
||||||
|
'port' => env('MEMCACHED_PORT', 11211),
|
||||||
|
'weight' => 100,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'redis' => [
|
||||||
|
'driver' => 'redis',
|
||||||
|
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
|
||||||
|
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'dynamodb' => [
|
||||||
|
'driver' => 'dynamodb',
|
||||||
|
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||||
|
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||||
|
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||||
|
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
|
||||||
|
'endpoint' => env('DYNAMODB_ENDPOINT'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'octane' => [
|
||||||
|
'driver' => 'octane',
|
||||||
|
],
|
||||||
|
|
||||||
|
'failover' => [
|
||||||
|
'driver' => 'failover',
|
||||||
|
'stores' => [
|
||||||
|
'database',
|
||||||
|
'array',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Cache Key Prefix
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When utilizing the APC, database, memcached, Redis, and DynamoDB cache
|
||||||
|
| stores, there might be other applications using the same cache. For
|
||||||
|
| that reason, you may prefix every cache key to avoid collisions.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Pdo\Mysql;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Database Connection Name
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify which of the database connections below you wish
|
||||||
|
| to use as your default connection for database operations. This is
|
||||||
|
| the connection which will be utilized unless another connection
|
||||||
|
| is explicitly specified when you execute a query / statement.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('DB_CONNECTION', 'sqlite'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Database Connections
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Below are all of the database connections defined for your application.
|
||||||
|
| An example configuration is provided for each database system which
|
||||||
|
| is supported by Laravel. You're free to add / remove connections.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'connections' => [
|
||||||
|
|
||||||
|
'sqlite' => [
|
||||||
|
'driver' => 'sqlite',
|
||||||
|
'url' => env('DB_URL'),
|
||||||
|
'database' => env('DB_DATABASE', database_path('database.sqlite')),
|
||||||
|
'prefix' => '',
|
||||||
|
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
|
||||||
|
'busy_timeout' => null,
|
||||||
|
'journal_mode' => null,
|
||||||
|
'synchronous' => null,
|
||||||
|
'transaction_mode' => 'DEFERRED',
|
||||||
|
],
|
||||||
|
|
||||||
|
'mysql' => [
|
||||||
|
'driver' => 'mysql',
|
||||||
|
'url' => env('DB_URL'),
|
||||||
|
'host' => env('DB_HOST', '127.0.0.1'),
|
||||||
|
'port' => env('DB_PORT', '3306'),
|
||||||
|
'database' => env('DB_DATABASE', 'laravel'),
|
||||||
|
'username' => env('DB_USERNAME', 'root'),
|
||||||
|
'password' => env('DB_PASSWORD', ''),
|
||||||
|
'unix_socket' => env('DB_SOCKET', ''),
|
||||||
|
'charset' => env('DB_CHARSET', 'utf8mb4'),
|
||||||
|
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
|
||||||
|
'prefix' => '',
|
||||||
|
'prefix_indexes' => true,
|
||||||
|
'strict' => true,
|
||||||
|
'engine' => null,
|
||||||
|
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||||
|
(PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
|
||||||
|
]) : [],
|
||||||
|
],
|
||||||
|
|
||||||
|
// Read-only access to platform admin settings (Paystack keys, etc.).
|
||||||
|
'platform' => [
|
||||||
|
'driver' => 'mysql',
|
||||||
|
'host' => env('PLATFORM_DB_HOST', env('DB_HOST', '127.0.0.1')),
|
||||||
|
'port' => env('PLATFORM_DB_PORT', env('DB_PORT', '3306')),
|
||||||
|
'database' => env('PLATFORM_DB_DATABASE', 'ladilldb'),
|
||||||
|
'username' => env('PLATFORM_DB_USERNAME', env('DB_USERNAME', 'root')),
|
||||||
|
'password' => env('PLATFORM_DB_PASSWORD', env('DB_PASSWORD', '')),
|
||||||
|
'unix_socket' => env('PLATFORM_DB_SOCKET', env('DB_SOCKET', '')),
|
||||||
|
'charset' => env('DB_CHARSET', 'utf8mb4'),
|
||||||
|
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
|
||||||
|
'prefix' => '',
|
||||||
|
'prefix_indexes' => true,
|
||||||
|
'strict' => true,
|
||||||
|
'engine' => null,
|
||||||
|
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||||
|
(PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
|
||||||
|
]) : [],
|
||||||
|
],
|
||||||
|
|
||||||
|
'mariadb' => [
|
||||||
|
'driver' => 'mariadb',
|
||||||
|
'url' => env('DB_URL'),
|
||||||
|
'host' => env('DB_HOST', '127.0.0.1'),
|
||||||
|
'port' => env('DB_PORT', '3306'),
|
||||||
|
'database' => env('DB_DATABASE', 'laravel'),
|
||||||
|
'username' => env('DB_USERNAME', 'root'),
|
||||||
|
'password' => env('DB_PASSWORD', ''),
|
||||||
|
'unix_socket' => env('DB_SOCKET', ''),
|
||||||
|
'charset' => env('DB_CHARSET', 'utf8mb4'),
|
||||||
|
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
|
||||||
|
'prefix' => '',
|
||||||
|
'prefix_indexes' => true,
|
||||||
|
'strict' => true,
|
||||||
|
'engine' => null,
|
||||||
|
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||||
|
(PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
|
||||||
|
]) : [],
|
||||||
|
],
|
||||||
|
|
||||||
|
'pgsql' => [
|
||||||
|
'driver' => 'pgsql',
|
||||||
|
'url' => env('DB_URL'),
|
||||||
|
'host' => env('DB_HOST', '127.0.0.1'),
|
||||||
|
'port' => env('DB_PORT', '5432'),
|
||||||
|
'database' => env('DB_DATABASE', 'laravel'),
|
||||||
|
'username' => env('DB_USERNAME', 'root'),
|
||||||
|
'password' => env('DB_PASSWORD', ''),
|
||||||
|
'charset' => env('DB_CHARSET', 'utf8'),
|
||||||
|
'prefix' => '',
|
||||||
|
'prefix_indexes' => true,
|
||||||
|
'search_path' => 'public',
|
||||||
|
'sslmode' => env('DB_SSLMODE', 'prefer'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'sqlsrv' => [
|
||||||
|
'driver' => 'sqlsrv',
|
||||||
|
'url' => env('DB_URL'),
|
||||||
|
'host' => env('DB_HOST', 'localhost'),
|
||||||
|
'port' => env('DB_PORT', '1433'),
|
||||||
|
'database' => env('DB_DATABASE', 'laravel'),
|
||||||
|
'username' => env('DB_USERNAME', 'root'),
|
||||||
|
'password' => env('DB_PASSWORD', ''),
|
||||||
|
'charset' => env('DB_CHARSET', 'utf8'),
|
||||||
|
'prefix' => '',
|
||||||
|
'prefix_indexes' => true,
|
||||||
|
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
|
||||||
|
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Migration Repository Table
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This table keeps track of all the migrations that have already run for
|
||||||
|
| your application. Using this information, we can determine which of
|
||||||
|
| the migrations on disk haven't actually been run on the database.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'migrations' => [
|
||||||
|
'table' => 'migrations',
|
||||||
|
'update_date_on_publish' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Redis Databases
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Redis is an open source, fast, and advanced key-value store that also
|
||||||
|
| provides a richer body of commands than a typical key-value system
|
||||||
|
| such as Memcached. You may define your connection settings here.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'redis' => [
|
||||||
|
|
||||||
|
'client' => env('REDIS_CLIENT', 'phpredis'),
|
||||||
|
|
||||||
|
'options' => [
|
||||||
|
'cluster' => env('REDIS_CLUSTER', 'redis'),
|
||||||
|
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
|
||||||
|
'persistent' => env('REDIS_PERSISTENT', false),
|
||||||
|
],
|
||||||
|
|
||||||
|
'default' => [
|
||||||
|
'url' => env('REDIS_URL'),
|
||||||
|
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||||
|
'username' => env('REDIS_USERNAME'),
|
||||||
|
'password' => env('REDIS_PASSWORD'),
|
||||||
|
'port' => env('REDIS_PORT', '6379'),
|
||||||
|
'database' => env('REDIS_DB', '0'),
|
||||||
|
'max_retries' => env('REDIS_MAX_RETRIES', 3),
|
||||||
|
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
|
||||||
|
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
|
||||||
|
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
|
||||||
|
],
|
||||||
|
|
||||||
|
'cache' => [
|
||||||
|
'url' => env('REDIS_URL'),
|
||||||
|
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||||
|
'username' => env('REDIS_USERNAME'),
|
||||||
|
'password' => env('REDIS_PASSWORD'),
|
||||||
|
'port' => env('REDIS_PORT', '6379'),
|
||||||
|
'database' => env('REDIS_CACHE_DB', '1'),
|
||||||
|
'max_retries' => env('REDIS_MAX_RETRIES', 3),
|
||||||
|
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
|
||||||
|
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
|
||||||
|
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'api_url' => env('DOMAIN_API_URL', 'https://ladill.com/api/domains'),
|
||||||
|
'api_key' => env('DOMAIN_API_KEY_HOSTING'),
|
||||||
|
];
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
/*
|
||||||
|
| Email product plans. Mailboxes are priced purely by the storage tier chosen
|
||||||
|
| at creation (and changeable later via Upgrade). The 1 GB tier is FREE,
|
||||||
|
| forever, for every mailbox; larger tiers are billed monthly from the one
|
||||||
|
| Ladill wallet (§4-A). Money is in minor units (pesewas).
|
||||||
|
*/
|
||||||
|
'currency' => env('EMAIL_CURRENCY', 'GHS'),
|
||||||
|
'default_quota_mb' => (int) env('EMAIL_DEFAULT_QUOTA_MB', 1024), // new mailboxes start on the free 1 GB tier
|
||||||
|
|
||||||
|
// Storage tiers: quota (MB) → monthly price (minor units). 1 GB is free.
|
||||||
|
'quota_tiers' => [
|
||||||
|
['mb' => 1024, 'price_minor' => 0], // 1 GB — Free forever
|
||||||
|
['mb' => 5120, 'price_minor' => 1000], // 5 GB — GHS 10
|
||||||
|
['mb' => 10240, 'price_minor' => 2000], // 10 GB — GHS 20
|
||||||
|
['mb' => 25600, 'price_minor' => 3000], // 25 GB — GHS 30
|
||||||
|
['mb' => 51200, 'price_minor' => 6000], // 50 GB — GHS 60
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
// Consumer config for the shared Email-Domain verification API (§4-D).
|
||||||
|
'api_url' => env('EMAILDOMAIN_API_URL', 'https://ladill.com/api/email-domains'),
|
||||||
|
'api_key' => env('EMAILDOMAIN_API_KEY_EMAIL'),
|
||||||
|
];
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Filesystem Disk
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify the default filesystem disk that should be used
|
||||||
|
| by the framework. The "local" disk, as well as a variety of cloud
|
||||||
|
| based disks are available to your application for file storage.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('FILESYSTEM_DISK', 'local'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Filesystem Disks
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Below you may configure as many filesystem disks as necessary, and you
|
||||||
|
| may even configure multiple disks for the same driver. Examples for
|
||||||
|
| most supported storage drivers are configured here for reference.
|
||||||
|
|
|
||||||
|
| Supported drivers: "local", "ftp", "sftp", "s3"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'disks' => [
|
||||||
|
|
||||||
|
'local' => [
|
||||||
|
'driver' => 'local',
|
||||||
|
'root' => storage_path('app/private'),
|
||||||
|
'serve' => true,
|
||||||
|
'throw' => false,
|
||||||
|
'report' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'public' => [
|
||||||
|
'driver' => 'local',
|
||||||
|
'root' => storage_path('app/public'),
|
||||||
|
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
|
||||||
|
'visibility' => 'public',
|
||||||
|
'throw' => false,
|
||||||
|
'report' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
's3' => [
|
||||||
|
'driver' => 's3',
|
||||||
|
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||||
|
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||||
|
'region' => env('AWS_DEFAULT_REGION'),
|
||||||
|
'bucket' => env('AWS_BUCKET'),
|
||||||
|
'url' => env('AWS_URL'),
|
||||||
|
'endpoint' => env('AWS_ENDPOINT'),
|
||||||
|
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
|
||||||
|
'throw' => false,
|
||||||
|
'report' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'qr' => [
|
||||||
|
'driver' => 'local',
|
||||||
|
'root' => storage_path('app/private/qr'),
|
||||||
|
'throw' => false,
|
||||||
|
'report' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Symbolic Links
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may configure the symbolic links that will be created when the
|
||||||
|
| `storage:link` Artisan command is executed. The array keys should be
|
||||||
|
| the locations of the links and the values should be their targets.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'links' => [
|
||||||
|
public_path('storage') => storage_path('app/public'),
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,703 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Contabo API Configuration
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Credentials for the Contabo Compute Management API.
|
||||||
|
| Used for provisioning VPS and VDS instances.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'contabo' => [
|
||||||
|
'client_id' => env('CONTABO_CLIENT_ID'),
|
||||||
|
'client_secret' => env('CONTABO_CLIENT_SECRET'),
|
||||||
|
'api_user' => env('CONTABO_API_USER'),
|
||||||
|
'api_password' => env('CONTABO_API_PASSWORD'),
|
||||||
|
'product_catalog_endpoint' => env('CONTABO_PRODUCT_CATALOG_ENDPOINT'),
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Pricing Configuration
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Dynamic pricing settings for VPS/Dedicated servers.
|
||||||
|
| Base prices from Contabo are in USD, converted to GHS using exchange rate.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'pricing' => [
|
||||||
|
'base_currency' => 'USD',
|
||||||
|
'display_currency' => 'GHS',
|
||||||
|
|
||||||
|
// Fallback rate used when live API fails
|
||||||
|
'fallback_usd_to_ghs_rate' => env('FALLBACK_USD_TO_GHS_RATE', 15.50),
|
||||||
|
|
||||||
|
// Profit margins (percentage on top of converted Contabo USD price)
|
||||||
|
'margins' => [
|
||||||
|
'vps' => env('VPS_PROFIT_MARGIN', 45),
|
||||||
|
'dedicated' => env('DEDICATED_PROFIT_MARGIN', 30),
|
||||||
|
],
|
||||||
|
|
||||||
|
'contabo_price_cache_ttl' => env('CONTABO_PRICE_CACHE_TTL', 3600),
|
||||||
|
|
||||||
|
'term_discounts' => [
|
||||||
|
'quarterly' => 5,
|
||||||
|
'semiannual' => 10,
|
||||||
|
'yearly' => 20,
|
||||||
|
],
|
||||||
|
|
||||||
|
'setup_fee_rules' => [
|
||||||
|
// VPS 10 only — monthly and semiannual cycles carry a setup fee equal to 1× the monthly price
|
||||||
|
[
|
||||||
|
'product_ids' => ['V91'],
|
||||||
|
'billing_cycles' => ['monthly', 'semiannual'],
|
||||||
|
'monthly_price_multiplier' => 1,
|
||||||
|
'label' => 'One-time setup fee',
|
||||||
|
],
|
||||||
|
// Dedicated servers — fixed EUR fee that decreases with longer commitment; waived on yearly
|
||||||
|
[
|
||||||
|
'product_ids' => ['amd-ryzen-12-cores', 'amd-genoa-24-cores'],
|
||||||
|
'billing_cycles' => ['monthly', 'quarterly', 'semiannual'],
|
||||||
|
'fixed_eur_by_cycle' => [
|
||||||
|
'monthly' => 39.99,
|
||||||
|
'quarterly' => 29.99,
|
||||||
|
'semiannual' => 19.99,
|
||||||
|
],
|
||||||
|
'label' => 'One-time setup fee',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
// Fallback Contabo base prices in USD. Live API/feed prices are preferred.
|
||||||
|
// Keep these values as a fail-safe for API downtime.
|
||||||
|
'contabo_base_prices' => [
|
||||||
|
'V91' => ['monthly' => 4.99, 'name' => 'Cloud VPS 10 NVMe', 'cpu' => 3, 'ram_gb' => 8, 'disk_gb' => 75],
|
||||||
|
'V94' => ['monthly' => 7.00, 'name' => 'Cloud VPS 20 NVMe', 'cpu' => 6, 'ram_gb' => 12, 'disk_gb' => 100],
|
||||||
|
'V97' => ['monthly' => 14.00, 'name' => 'Cloud VPS 30 NVMe', 'cpu' => 8, 'ram_gb' => 24, 'disk_gb' => 200],
|
||||||
|
'V100' => ['monthly' => 25.00, 'name' => 'Cloud VPS 40 NVMe', 'cpu' => 12, 'ram_gb' => 48, 'disk_gb' => 250],
|
||||||
|
'amd-ryzen-12-cores' => ['monthly' => 104.64, 'name' => 'AMD Ryzen 12 Cores', 'cpu' => 12, 'ram_gb' => 64, 'disk_gb' => 1000],
|
||||||
|
'amd-genoa-24-cores' => ['monthly' => 184.21, 'name' => 'AMD Genoa 24 Cores', 'cpu' => 24, 'ram_gb' => 128, 'disk_gb' => 2000],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Server Order Options
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Surcharges shown here are Contabo-side monthly USD add-on costs before
|
||||||
|
| the Ladill exchange-rate conversion and product margin are applied.
|
||||||
|
| Update these values whenever Contabo changes its commercial pricing.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'server_order' => [
|
||||||
|
'regions' => [
|
||||||
|
'EU' => ['label' => 'Europe (Germany)', 'monthly_usd' => 0.00, 'automated' => true],
|
||||||
|
'US-central' => ['label' => 'US Central', 'monthly_usd' => 0.00, 'automated' => true],
|
||||||
|
'US-east' => ['label' => 'US East', 'monthly_usd' => 0.00, 'automated' => true],
|
||||||
|
'US-west' => ['label' => 'US West', 'monthly_usd' => 0.00, 'automated' => true],
|
||||||
|
'UK' => ['label' => 'United Kingdom', 'monthly_usd' => 0.00, 'automated' => true],
|
||||||
|
'SIN' => ['label' => 'Singapore', 'monthly_usd' => 0.00, 'automated' => true],
|
||||||
|
'AUS' => ['label' => 'Australia', 'monthly_usd' => 0.00, 'automated' => true],
|
||||||
|
'JPN' => ['label' => 'Japan', 'monthly_usd' => 0.00, 'automated' => true],
|
||||||
|
],
|
||||||
|
'image_pricing_rules' => [
|
||||||
|
[
|
||||||
|
'key' => 'windows',
|
||||||
|
'label' => 'Windows Server',
|
||||||
|
'monthly_usd' => 9.30,
|
||||||
|
'match' => ['windows'],
|
||||||
|
'os_family' => 'windows',
|
||||||
|
'default_user' => 'administrator',
|
||||||
|
'requires_custom_image_addon' => false,
|
||||||
|
'automated' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'ubuntu',
|
||||||
|
'label' => 'Ubuntu',
|
||||||
|
'monthly_usd' => 0.00,
|
||||||
|
'match' => ['ubuntu'],
|
||||||
|
'os_family' => 'linux',
|
||||||
|
'default_user' => 'root',
|
||||||
|
'requires_custom_image_addon' => false,
|
||||||
|
'automated' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'rhel',
|
||||||
|
'label' => 'RHEL Variants',
|
||||||
|
'monthly_usd' => 0.00,
|
||||||
|
'match' => ['alma', 'rocky', 'rhel', 'centos'],
|
||||||
|
'os_family' => 'linux',
|
||||||
|
'default_user' => 'root',
|
||||||
|
'requires_custom_image_addon' => false,
|
||||||
|
'automated' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'custom',
|
||||||
|
'label' => 'Custom Images',
|
||||||
|
'monthly_usd' => 0.00,
|
||||||
|
'custom_image' => true,
|
||||||
|
'os_family' => 'linux',
|
||||||
|
'default_user' => 'root',
|
||||||
|
'requires_custom_image_addon' => true,
|
||||||
|
'automated' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'linux',
|
||||||
|
'label' => 'Linux',
|
||||||
|
'monthly_usd' => 0.00,
|
||||||
|
'match' => ['debian', 'fedora', 'linux', 'bsd'],
|
||||||
|
'os_family' => 'linux',
|
||||||
|
'default_user' => 'root',
|
||||||
|
'requires_custom_image_addon' => false,
|
||||||
|
'automated' => true,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'fallback_images' => [
|
||||||
|
[
|
||||||
|
'value' => 'afecbb85-e2fc-46f0-9684-b46b1faf00bb',
|
||||||
|
'label' => 'Ubuntu 22.04',
|
||||||
|
'description' => 'Fallback Ubuntu image',
|
||||||
|
'os_family' => 'linux',
|
||||||
|
'monthly_usd' => 0.00,
|
||||||
|
'default_user' => 'root',
|
||||||
|
'requires_custom_image_addon' => false,
|
||||||
|
'automated' => true,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'managed_stack_supported_images' => [
|
||||||
|
[
|
||||||
|
'value' => 'afecbb85-e2fc-46f0-9684-b46b1faf00bb',
|
||||||
|
'label' => 'Ubuntu 22.04 LTS',
|
||||||
|
'match' => ['ubuntu 22.04'],
|
||||||
|
'os_family' => 'linux',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'Ubuntu 24.04 LTS',
|
||||||
|
'match' => ['ubuntu 24.04'],
|
||||||
|
'os_family' => 'linux',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'Debian 12',
|
||||||
|
'match' => ['debian 12', 'bookworm'],
|
||||||
|
'os_family' => 'linux',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'AlmaLinux',
|
||||||
|
'match' => ['almalinux', 'alma linux', 'alma'],
|
||||||
|
'os_family' => 'linux',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'Rocky Linux',
|
||||||
|
'match' => ['rocky linux', 'rocky'],
|
||||||
|
'os_family' => 'linux',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'Fedora',
|
||||||
|
'match' => ['fedora'],
|
||||||
|
'os_family' => 'linux',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'openSUSE Leap',
|
||||||
|
'match' => ['opensuse leap', 'open suse leap', 'opensuse', 'leap'],
|
||||||
|
'os_family' => 'linux',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'CentOS',
|
||||||
|
'match' => ['centos'],
|
||||||
|
'os_family' => 'linux',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'licenses' => [
|
||||||
|
'none' => [
|
||||||
|
'label' => 'Remote Login Only',
|
||||||
|
'description' => 'No hosting panel or commercial control-panel license.',
|
||||||
|
'monthly_usd' => 0.00,
|
||||||
|
'license' => null,
|
||||||
|
'panel' => null,
|
||||||
|
'automated' => true,
|
||||||
|
],
|
||||||
|
'ladill_panel' => [
|
||||||
|
'label' => 'Ladill Server Manager',
|
||||||
|
'description' => 'Manage power, status, and server details from the Ladill server manager.',
|
||||||
|
'monthly_usd' => 0.00,
|
||||||
|
'license' => null,
|
||||||
|
'panel' => 'ladill',
|
||||||
|
'compatible_os_families' => ['linux'],
|
||||||
|
'requires_image' => true,
|
||||||
|
'requires_managed_stack_image' => true,
|
||||||
|
'automated' => true,
|
||||||
|
],
|
||||||
|
'plesk_host' => [
|
||||||
|
'label' => 'Plesk + Linux',
|
||||||
|
'description' => 'Plesk host edition on Linux.',
|
||||||
|
'monthly_usd' => 15.00,
|
||||||
|
'license' => 'PleskHost',
|
||||||
|
'compatible_os_families' => ['linux'],
|
||||||
|
'requires_image' => true,
|
||||||
|
'automated' => true,
|
||||||
|
],
|
||||||
|
'plesk_windows' => [
|
||||||
|
'label' => 'Plesk + Windows',
|
||||||
|
'description' => 'Plesk on Windows Server.',
|
||||||
|
'monthly_usd' => 22.70,
|
||||||
|
'license' => 'PleskHost',
|
||||||
|
'compatible_os_families' => ['windows'],
|
||||||
|
'requires_image' => true,
|
||||||
|
'automated' => true,
|
||||||
|
],
|
||||||
|
'cpanel_5' => [
|
||||||
|
'label' => 'cPanel 5',
|
||||||
|
'description' => 'cPanel license for up to 5 accounts.',
|
||||||
|
'monthly_usd' => 35.99,
|
||||||
|
'license' => 'cPanel5',
|
||||||
|
'compatible_os_families' => ['linux'],
|
||||||
|
'requires_image' => true,
|
||||||
|
'automated' => true,
|
||||||
|
],
|
||||||
|
'cpanel_30' => [
|
||||||
|
'label' => 'cPanel 30',
|
||||||
|
'description' => 'cPanel license for up to 30 accounts.',
|
||||||
|
'monthly_usd' => 53.99,
|
||||||
|
'license' => 'cPanel30',
|
||||||
|
'compatible_os_families' => ['linux'],
|
||||||
|
'requires_image' => true,
|
||||||
|
'automated' => true,
|
||||||
|
],
|
||||||
|
'cpanel_50' => [
|
||||||
|
'label' => 'cPanel 50',
|
||||||
|
'description' => 'cPanel license for up to 50 accounts.',
|
||||||
|
'monthly_usd' => 53.99 + (0.49 * 20),
|
||||||
|
'license' => 'cPanel50',
|
||||||
|
'compatible_os_families' => ['linux'],
|
||||||
|
'requires_image' => true,
|
||||||
|
'automated' => true,
|
||||||
|
],
|
||||||
|
'cpanel_100' => [
|
||||||
|
'label' => 'cPanel 100',
|
||||||
|
'description' => 'cPanel license for up to 100 accounts.',
|
||||||
|
'monthly_usd' => 69.99,
|
||||||
|
'license' => 'cPanel100',
|
||||||
|
'compatible_os_families' => ['linux'],
|
||||||
|
'requires_image' => true,
|
||||||
|
'automated' => true,
|
||||||
|
],
|
||||||
|
'cpanel_150' => [
|
||||||
|
'label' => 'cPanel 150',
|
||||||
|
'description' => 'cPanel license for up to 150 accounts.',
|
||||||
|
'monthly_usd' => 69.99 + (0.49 * 50),
|
||||||
|
'license' => 'cPanel150',
|
||||||
|
'compatible_os_families' => ['linux'],
|
||||||
|
'requires_image' => true,
|
||||||
|
'automated' => true,
|
||||||
|
],
|
||||||
|
'cpanel_200' => [
|
||||||
|
'label' => 'cPanel 200',
|
||||||
|
'description' => 'cPanel license for up to 200 accounts.',
|
||||||
|
'monthly_usd' => 69.99 + (0.49 * 100),
|
||||||
|
'license' => 'cPanel200',
|
||||||
|
'compatible_os_families' => ['linux'],
|
||||||
|
'requires_image' => true,
|
||||||
|
'automated' => true,
|
||||||
|
],
|
||||||
|
'cpanel_250' => [
|
||||||
|
'label' => 'cPanel 250',
|
||||||
|
'description' => 'cPanel license for up to 250 accounts.',
|
||||||
|
'monthly_usd' => 69.99 + (0.49 * 150),
|
||||||
|
'license' => 'cPanel250',
|
||||||
|
'compatible_os_families' => ['linux'],
|
||||||
|
'requires_image' => true,
|
||||||
|
'automated' => true,
|
||||||
|
],
|
||||||
|
'cpanel_300' => [
|
||||||
|
'label' => 'cPanel 300',
|
||||||
|
'description' => 'cPanel license for up to 300 accounts.',
|
||||||
|
'monthly_usd' => 69.99 + (0.49 * 200),
|
||||||
|
'license' => 'cPanel300',
|
||||||
|
'compatible_os_families' => ['linux'],
|
||||||
|
'requires_image' => true,
|
||||||
|
'automated' => true,
|
||||||
|
],
|
||||||
|
'cpanel_350' => [
|
||||||
|
'label' => 'cPanel 350',
|
||||||
|
'description' => 'cPanel license for up to 350 accounts.',
|
||||||
|
'monthly_usd' => 69.99 + (0.49 * 250),
|
||||||
|
'license' => 'cPanel350',
|
||||||
|
'compatible_os_families' => ['linux'],
|
||||||
|
'requires_image' => true,
|
||||||
|
'automated' => true,
|
||||||
|
],
|
||||||
|
'cpanel_400' => [
|
||||||
|
'label' => 'cPanel 400',
|
||||||
|
'description' => 'cPanel license for up to 400 accounts.',
|
||||||
|
'monthly_usd' => 69.99 + (0.49 * 300),
|
||||||
|
'license' => 'cPanel400',
|
||||||
|
'compatible_os_families' => ['linux'],
|
||||||
|
'requires_image' => true,
|
||||||
|
'automated' => true,
|
||||||
|
],
|
||||||
|
'cpanel_450' => [
|
||||||
|
'label' => 'cPanel 450',
|
||||||
|
'description' => 'cPanel license for up to 450 accounts.',
|
||||||
|
'monthly_usd' => 69.99 + (0.49 * 350),
|
||||||
|
'license' => 'cPanel450',
|
||||||
|
'compatible_os_families' => ['linux'],
|
||||||
|
'requires_image' => true,
|
||||||
|
'automated' => true,
|
||||||
|
],
|
||||||
|
'cpanel_500' => [
|
||||||
|
'label' => 'cPanel 500',
|
||||||
|
'description' => 'cPanel license for up to 500 accounts.',
|
||||||
|
'monthly_usd' => 69.99 + (0.49 * 400),
|
||||||
|
'license' => 'cPanel500',
|
||||||
|
'compatible_os_families' => ['linux'],
|
||||||
|
'requires_image' => true,
|
||||||
|
'automated' => true,
|
||||||
|
],
|
||||||
|
'cpanel_550' => [
|
||||||
|
'label' => 'cPanel 550',
|
||||||
|
'description' => 'cPanel license for up to 550 accounts.',
|
||||||
|
'monthly_usd' => 69.99 + (0.49 * 450),
|
||||||
|
'license' => 'cPanel550',
|
||||||
|
'compatible_os_families' => ['linux'],
|
||||||
|
'requires_image' => true,
|
||||||
|
'automated' => true,
|
||||||
|
],
|
||||||
|
'cpanel_600' => [
|
||||||
|
'label' => 'cPanel 600',
|
||||||
|
'description' => 'cPanel license for up to 600 accounts.',
|
||||||
|
'monthly_usd' => 69.99 + (0.49 * 500),
|
||||||
|
'license' => 'cPanel600',
|
||||||
|
'compatible_os_families' => ['linux'],
|
||||||
|
'requires_image' => true,
|
||||||
|
'automated' => true,
|
||||||
|
],
|
||||||
|
'cpanel_650' => [
|
||||||
|
'label' => 'cPanel 650',
|
||||||
|
'description' => 'cPanel license for up to 650 accounts.',
|
||||||
|
'monthly_usd' => 69.99 + (0.49 * 550),
|
||||||
|
'license' => 'cPanel650',
|
||||||
|
'compatible_os_families' => ['linux'],
|
||||||
|
'requires_image' => true,
|
||||||
|
'automated' => true,
|
||||||
|
],
|
||||||
|
'cpanel_700' => [
|
||||||
|
'label' => 'cPanel 700',
|
||||||
|
'description' => 'cPanel license for up to 700 accounts.',
|
||||||
|
'monthly_usd' => 69.99 + (0.49 * 600),
|
||||||
|
'license' => 'cPanel700',
|
||||||
|
'compatible_os_families' => ['linux'],
|
||||||
|
'requires_image' => true,
|
||||||
|
'automated' => true,
|
||||||
|
],
|
||||||
|
'cpanel_750' => [
|
||||||
|
'label' => 'cPanel 750',
|
||||||
|
'description' => 'cPanel license for up to 750 accounts.',
|
||||||
|
'monthly_usd' => 69.99 + (0.49 * 650),
|
||||||
|
'license' => 'cPanel750',
|
||||||
|
'compatible_os_families' => ['linux'],
|
||||||
|
'requires_image' => true,
|
||||||
|
'automated' => true,
|
||||||
|
],
|
||||||
|
'cpanel_800' => [
|
||||||
|
'label' => 'cPanel 800',
|
||||||
|
'description' => 'cPanel license for up to 800 accounts.',
|
||||||
|
'monthly_usd' => 69.99 + (0.49 * 700),
|
||||||
|
'license' => 'cPanel800',
|
||||||
|
'compatible_os_families' => ['linux'],
|
||||||
|
'requires_image' => true,
|
||||||
|
'automated' => true,
|
||||||
|
],
|
||||||
|
'cpanel_850' => [
|
||||||
|
'label' => 'cPanel 850',
|
||||||
|
'description' => 'cPanel license for up to 850 accounts.',
|
||||||
|
'monthly_usd' => 69.99 + (0.49 * 750),
|
||||||
|
'license' => 'cPanel850',
|
||||||
|
'compatible_os_families' => ['linux'],
|
||||||
|
'requires_image' => true,
|
||||||
|
'automated' => true,
|
||||||
|
],
|
||||||
|
'cpanel_900' => [
|
||||||
|
'label' => 'cPanel 900',
|
||||||
|
'description' => 'cPanel license for up to 900 accounts.',
|
||||||
|
'monthly_usd' => 69.99 + (0.49 * 800),
|
||||||
|
'license' => 'cPanel900',
|
||||||
|
'compatible_os_families' => ['linux'],
|
||||||
|
'requires_image' => true,
|
||||||
|
'automated' => true,
|
||||||
|
],
|
||||||
|
'cpanel_950' => [
|
||||||
|
'label' => 'cPanel 950',
|
||||||
|
'description' => 'cPanel license for up to 950 accounts.',
|
||||||
|
'monthly_usd' => 69.99 + (0.49 * 850),
|
||||||
|
'license' => 'cPanel950',
|
||||||
|
'compatible_os_families' => ['linux'],
|
||||||
|
'requires_image' => true,
|
||||||
|
'automated' => true,
|
||||||
|
],
|
||||||
|
'cpanel_1000' => [
|
||||||
|
'label' => 'cPanel 1000',
|
||||||
|
'description' => 'cPanel license for up to 1000 accounts.',
|
||||||
|
'monthly_usd' => 69.99 + (0.49 * 900),
|
||||||
|
'license' => 'cPanel1000',
|
||||||
|
'compatible_os_families' => ['linux'],
|
||||||
|
'requires_image' => true,
|
||||||
|
'automated' => true,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'applications' => [
|
||||||
|
'none' => [
|
||||||
|
'label' => 'No Preinstalled App',
|
||||||
|
'description' => 'Provision the server without an extra preinstalled application.',
|
||||||
|
'monthly_usd' => 0.00,
|
||||||
|
'automated' => true,
|
||||||
|
],
|
||||||
|
'webmin' => [
|
||||||
|
'label' => 'Webmin',
|
||||||
|
'description' => 'Free Webmin server panel installed automatically on Linux.',
|
||||||
|
'monthly_usd' => 0.00,
|
||||||
|
'cloud_init_preset' => 'webmin',
|
||||||
|
'compatible_os_families' => ['linux'],
|
||||||
|
'requires_image' => true,
|
||||||
|
'automated' => true,
|
||||||
|
],
|
||||||
|
'webmin_lamp' => [
|
||||||
|
'label' => 'Webmin + LAMP',
|
||||||
|
'description' => 'Free Webmin plus Apache, MariaDB, and PHP on Linux.',
|
||||||
|
'monthly_usd' => 0.00,
|
||||||
|
'cloud_init_preset' => 'webmin_lamp',
|
||||||
|
'compatible_os_families' => ['linux'],
|
||||||
|
'requires_image' => true,
|
||||||
|
'automated' => true,
|
||||||
|
],
|
||||||
|
'ipfs_node' => [
|
||||||
|
'label' => 'IPFS Node',
|
||||||
|
'description' => 'Contabo application profile for IPFS nodes.',
|
||||||
|
'monthly_usd' => 0.00,
|
||||||
|
'application_id' => env('CONTABO_APP_ID_IPFS_NODE'),
|
||||||
|
'compatible_os_families' => ['linux'],
|
||||||
|
'requires_image' => true,
|
||||||
|
'automated' => true,
|
||||||
|
],
|
||||||
|
'flux_node' => [
|
||||||
|
'label' => 'Flux Node',
|
||||||
|
'description' => 'Contabo application profile for Flux nodes.',
|
||||||
|
'monthly_usd' => 0.00,
|
||||||
|
'application_id' => env('CONTABO_APP_ID_FLUX_NODE'),
|
||||||
|
'compatible_os_families' => ['linux'],
|
||||||
|
'requires_image' => true,
|
||||||
|
'automated' => true,
|
||||||
|
],
|
||||||
|
'horizon_node' => [
|
||||||
|
'label' => 'Horizen Node',
|
||||||
|
'description' => 'Contabo application profile for Horizen nodes.',
|
||||||
|
'monthly_usd' => 0.00,
|
||||||
|
'application_id' => env('CONTABO_APP_ID_HORIZON_NODE'),
|
||||||
|
'compatible_os_families' => ['linux'],
|
||||||
|
'requires_image' => true,
|
||||||
|
'automated' => true,
|
||||||
|
],
|
||||||
|
'ethereum_node' => [
|
||||||
|
'label' => 'Ethereum Node',
|
||||||
|
'description' => 'Contabo application profile for Ethereum 2.0 nodes.',
|
||||||
|
'monthly_usd' => 0.00,
|
||||||
|
'application_id' => null,
|
||||||
|
'compatible_os_families' => ['linux'],
|
||||||
|
'requires_image' => true,
|
||||||
|
'automated' => true,
|
||||||
|
],
|
||||||
|
'bitcoin_node' => [
|
||||||
|
'label' => 'Bitcoin Full Node',
|
||||||
|
'description' => 'Contabo application profile for Bitcoin full nodes.',
|
||||||
|
'monthly_usd' => 0.00,
|
||||||
|
'application_id' => null,
|
||||||
|
'compatible_os_families' => ['linux'],
|
||||||
|
'requires_image' => true,
|
||||||
|
'automated' => true,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'additional_ip' => [
|
||||||
|
'none' => [
|
||||||
|
'label' => '1 IP Address',
|
||||||
|
'description' => 'Default primary IP only.',
|
||||||
|
'monthly_usd' => 0.00,
|
||||||
|
'add_on' => null,
|
||||||
|
'automated' => true,
|
||||||
|
],
|
||||||
|
'one_extra' => [
|
||||||
|
'label' => '1 Additional IP',
|
||||||
|
'description' => 'Adds one extra IPv4 address.',
|
||||||
|
'monthly_usd' => 4.50,
|
||||||
|
'add_on' => 'additionalIps',
|
||||||
|
'automated' => true,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'private_networking' => [
|
||||||
|
'disabled' => [
|
||||||
|
'label' => 'No Private Networking',
|
||||||
|
'description' => 'Do not enable the private networking add-on.',
|
||||||
|
'monthly_usd' => 0.00,
|
||||||
|
'add_on' => null,
|
||||||
|
'automated' => true,
|
||||||
|
],
|
||||||
|
'enabled' => [
|
||||||
|
'label' => 'Private Networking Enabled',
|
||||||
|
'description' => 'Purchases the private networking add-on.',
|
||||||
|
'monthly_usd' => 2.99,
|
||||||
|
'add_on' => 'privateNetworking',
|
||||||
|
'automated' => true,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'storage_types' => [
|
||||||
|
'included' => [
|
||||||
|
'label' => 'Included Storage',
|
||||||
|
'description' => 'Use the plan default storage.',
|
||||||
|
'monthly_usd' => 0.00,
|
||||||
|
'add_on' => null,
|
||||||
|
'automated' => true,
|
||||||
|
],
|
||||||
|
'ssd_300' => [
|
||||||
|
'label' => '300 GB SSD',
|
||||||
|
'description' => 'Higher SSD storage tier.',
|
||||||
|
'monthly_usd' => 1.95,
|
||||||
|
'add_on' => 'extraStorage',
|
||||||
|
'automated' => false,
|
||||||
|
],
|
||||||
|
'nvme_150' => [
|
||||||
|
'label' => '150 GB NVMe',
|
||||||
|
'description' => 'Higher NVMe storage tier.',
|
||||||
|
'monthly_usd' => 2.30,
|
||||||
|
'add_on' => 'extraStorage',
|
||||||
|
'automated' => false,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'object_storage' => [
|
||||||
|
'none' => [
|
||||||
|
'label' => 'No Object Storage',
|
||||||
|
'description' => 'Do not order object storage with this server.',
|
||||||
|
'monthly_usd' => 0.00,
|
||||||
|
'automated' => false,
|
||||||
|
],
|
||||||
|
'eu_250' => [
|
||||||
|
'label' => '250 GB Object Storage (EU)',
|
||||||
|
'description' => 'S3-compatible object storage in the European Union.',
|
||||||
|
'monthly_usd' => 2.99,
|
||||||
|
'automated' => false,
|
||||||
|
],
|
||||||
|
'eu_500' => [
|
||||||
|
'label' => '500 GB Object Storage (EU)',
|
||||||
|
'description' => 'S3-compatible object storage in the European Union.',
|
||||||
|
'monthly_usd' => 5.98,
|
||||||
|
'automated' => false,
|
||||||
|
],
|
||||||
|
'eu_750' => [
|
||||||
|
'label' => '750 GB Object Storage (EU)',
|
||||||
|
'description' => 'S3-compatible object storage in the European Union.',
|
||||||
|
'monthly_usd' => 8.97,
|
||||||
|
'automated' => false,
|
||||||
|
],
|
||||||
|
'eu_1024' => [
|
||||||
|
'label' => '1 TB Object Storage (EU)',
|
||||||
|
'description' => 'S3-compatible object storage in the European Union.',
|
||||||
|
'monthly_usd' => 11.96,
|
||||||
|
'automated' => false,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'linux_default_users' => [
|
||||||
|
'root' => ['label' => 'root'],
|
||||||
|
'admin' => ['label' => 'admin'],
|
||||||
|
],
|
||||||
|
'windows_default_users' => [
|
||||||
|
'admin' => ['label' => 'admin'],
|
||||||
|
'administrator' => ['label' => 'administrator'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'server_agent' => [
|
||||||
|
'release_version' => env('SERVER_AGENT_RELEASE_VERSION', '0.2.0'),
|
||||||
|
'signed_release_ttl_minutes' => (int) env('SERVER_AGENT_SIGNED_RELEASE_TTL_MINUTES', 10080),
|
||||||
|
'heartbeat_interval_seconds' => 15,
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Shared Hosting Configuration
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Settings for the shared hosting nodes.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'shared' => [
|
||||||
|
'default_php_version' => '8.2',
|
||||||
|
'available_php_versions' => ['8.0', '8.1', '8.2', '8.3'],
|
||||||
|
'default_document_root' => 'public_html',
|
||||||
|
'max_upload_size_mb' => 64,
|
||||||
|
'max_execution_time' => 300,
|
||||||
|
'memory_limit_mb' => 256,
|
||||||
|
'phpmyadmin_url' => env('HOSTING_PHPMYADMIN_URL', ''),
|
||||||
|
'phpmyadmin_sso_secret' => env('HOSTING_PHPMYADMIN_SSO_SECRET', ''),
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| VPS Configuration
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Default settings for VPS instances.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'vps' => [
|
||||||
|
'default_region' => 'EU',
|
||||||
|
'default_image' => 'afecbb85-e2fc-46f0-9684-b46b1faf00bb', // Ubuntu 22.04
|
||||||
|
'available_regions' => ['EU', 'US-central', 'US-east', 'US-west', 'SIN', 'UK', 'AUS', 'JPN'],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| App Installer Configuration
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Settings for the one-click app installer.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'apps' => [
|
||||||
|
'enabled' => ['wordpress', 'joomla', 'drupal', 'opencart'],
|
||||||
|
'magento_enabled' => false, // Requires higher resource plans
|
||||||
|
'wordpress' => [
|
||||||
|
'default_version' => '6.4',
|
||||||
|
'wp_cli_path' => '/usr/local/bin/wp',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Provisioning Settings
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| General provisioning configuration.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'provisioning' => [
|
||||||
|
'max_retries' => 3,
|
||||||
|
'retry_delay_minutes' => 5,
|
||||||
|
'timeout_minutes' => 30,
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Legacy ResellerClub Integration
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Keep RC integration active for existing customers.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'legacy' => [
|
||||||
|
// Keep RC data/API available for customers with existing ResellerClub services.
|
||||||
|
'rc_enabled' => (bool) env('LADILL_RC_LEGACY_ENABLED', true),
|
||||||
|
// New sales and automated fulfillment use Ladill (Dynadot domains, native hosting).
|
||||||
|
'rc_new_orders_enabled' => (bool) env('LADILL_RC_NEW_ORDERS_ENABLED', false),
|
||||||
|
// Renew existing ResellerClub services (hosting, domains, VPS, etc.).
|
||||||
|
'rc_renewals_enabled' => (bool) env('LADILL_RC_RENEWALS_ENABLED', true),
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'api_url' => env('IDENTITY_API_URL', 'https://ladill.com/api'),
|
||||||
|
'api_key' => env('IDENTITY_API_KEY_MINI'),
|
||||||
|
];
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'product_slug' => 'woo-manager',
|
||||||
|
'marketing_url' => env('LADILL_MARKETING_URL', 'https://ladill.com/products/invoice'),
|
||||||
|
];
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Ladill App Launcher — GENERATED, IDENTICAL across every app/service repo
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| DO NOT EDIT BY HAND. This file is generated from the app registry in the
|
||||||
|
| monolith (config/ladill_apps.php). To change the launcher, edit that
|
||||||
|
| registry and run: php artisan ladill:launcher:sync --propagate
|
||||||
|
|
|
||||||
|
| URLs are absolute (built from the platform root) so this file is
|
||||||
|
| byte-identical in every repo; the blade omits whichever row matches the
|
||||||
|
| app's own host. Icons are served from /images/launcher-icons/<icon>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
$root = config('app.platform_domain', 'ladill.com');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'apps' => [
|
||||||
|
['name' => 'Merchant', 'url' => 'https://merchant.'.$root.'/sso/connect?redirect='.urlencode('https://merchant.'.$root.'/dashboard'), 'icon' => 'merchant.svg'],
|
||||||
|
['name' => 'POS', 'url' => 'https://pos.'.$root.'/sso/connect?redirect='.urlencode('https://pos.'.$root.'/dashboard'), 'icon' => 'pos.svg'],
|
||||||
|
['name' => 'Mini', 'url' => 'https://mini.'.$root.'/sso/connect?redirect='.urlencode('https://mini.'.$root.'/dashboard'), 'icon' => 'mini.svg'],
|
||||||
|
['name' => 'Give', 'url' => 'https://give.'.$root.'/sso/connect?redirect='.urlencode('https://give.'.$root.'/dashboard'), 'icon' => 'give.svg'],
|
||||||
|
['name' => 'Transfer', 'url' => 'https://transfer.'.$root.'/sso/connect?redirect='.urlencode('https://transfer.'.$root.'/dashboard'), 'icon' => 'transfer.svg'],
|
||||||
|
['name' => 'Accounting', 'url' => 'https://accounting.'.$root.'/sso/connect?redirect='.urlencode('https://accounting.'.$root.'/dashboard'), 'icon' => 'accounting.svg'],
|
||||||
|
['name' => 'Invoice', 'url' => 'https://invoice.'.$root.'/sso/connect?redirect='.urlencode('https://invoice.'.$root.'/dashboard'), 'icon' => 'invoice.svg'],
|
||||||
|
['name' => 'CRM', 'url' => 'https://crm.'.$root.'/sso/connect?redirect='.urlencode('https://crm.'.$root.'/dashboard'), 'icon' => 'crm.svg'],
|
||||||
|
['name' => 'Events', 'url' => 'https://events.'.$root.'/sso/connect?redirect='.urlencode('https://events.'.$root.'/dashboard'), 'icon' => 'events.svg'],
|
||||||
|
['name' => 'QR Plus', 'url' => 'https://qrplus.'.$root.'/sso/connect?redirect='.urlencode('https://qrplus.'.$root.'/dashboard'), 'icon' => 'qrplus.svg'],
|
||||||
|
['name' => 'Link', 'url' => 'https://link.'.$root.'/sso/connect?redirect='.urlencode('https://link.'.$root.'/dashboard'), 'icon' => 'link.svg'],
|
||||||
|
['name' => 'Frontdesk', 'url' => 'https://frontdesk.'.$root.'/sso/connect?redirect='.urlencode('https://frontdesk.'.$root.'/dashboard'), 'icon' => 'frontdesk.svg'],
|
||||||
|
['name' => 'Care', 'url' => 'https://care.'.$root.'/sso/connect?redirect='.urlencode('https://care.'.$root.'/dashboard'), 'icon' => 'care.svg'],
|
||||||
|
['name' => 'Queue', 'url' => 'https://queue.'.$root.'/sso/connect?redirect='.urlencode('https://queue.'.$root.'/dashboard'), 'icon' => 'queue.svg'],
|
||||||
|
['name' => 'SMS', 'url' => 'https://sms.'.$root, 'icon' => 'sms.svg'],
|
||||||
|
['name' => 'Bird', 'url' => 'https://bird.'.$root, 'icon' => 'bird.svg'],
|
||||||
|
['name' => 'Mail', 'url' => 'https://mail.'.$root, 'icon' => 'mail.svg'],
|
||||||
|
['name' => 'Meet', 'url' => 'https://meet.'.$root.'/sso/connect?redirect='.urlencode('https://meet.'.$root.'/dashboard'), 'icon' => 'meet.svg'],
|
||||||
|
['name' => 'Email', 'url' => 'https://email.'.$root, 'icon' => 'email.svg'],
|
||||||
|
['name' => 'Domains', 'url' => 'https://domains.'.$root, 'icon' => 'domains.svg'],
|
||||||
|
['name' => 'Servers', 'url' => 'https://servers.'.$root, 'icon' => 'servers.svg'],
|
||||||
|
['name' => 'Hosting', 'url' => 'https://hosting.'.$root, 'icon' => 'hosting.svg'],
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'public_domain' => env('LINK_PUBLIC_DOMAIN', 'ladl.link'),
|
||||||
|
];
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Monolog\Handler\NullHandler;
|
||||||
|
use Monolog\Handler\StreamHandler;
|
||||||
|
use Monolog\Handler\SyslogUdpHandler;
|
||||||
|
use Monolog\Processor\PsrLogMessageProcessor;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Log Channel
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option defines the default log channel that is utilized to write
|
||||||
|
| messages to your logs. The value provided here should match one of
|
||||||
|
| the channels present in the list of "channels" configured below.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('LOG_CHANNEL', 'stack'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Deprecations Log Channel
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option controls the log channel that should be used to log warnings
|
||||||
|
| regarding deprecated PHP and library features. This allows you to get
|
||||||
|
| your application ready for upcoming major versions of dependencies.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'deprecations' => [
|
||||||
|
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
|
||||||
|
'trace' => env('LOG_DEPRECATIONS_TRACE', false),
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Log Channels
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may configure the log channels for your application. Laravel
|
||||||
|
| utilizes the Monolog PHP logging library, which includes a variety
|
||||||
|
| of powerful log handlers and formatters that you're free to use.
|
||||||
|
|
|
||||||
|
| Available drivers: "single", "daily", "slack", "syslog",
|
||||||
|
| "errorlog", "monolog", "custom", "stack"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'channels' => [
|
||||||
|
|
||||||
|
'stack' => [
|
||||||
|
'driver' => 'stack',
|
||||||
|
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
|
||||||
|
'ignore_exceptions' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'single' => [
|
||||||
|
'driver' => 'single',
|
||||||
|
'path' => storage_path('logs/laravel.log'),
|
||||||
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
|
'replace_placeholders' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
'daily' => [
|
||||||
|
'driver' => 'daily',
|
||||||
|
'path' => storage_path('logs/laravel.log'),
|
||||||
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
|
'days' => env('LOG_DAILY_DAYS', 14),
|
||||||
|
'replace_placeholders' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
'slack' => [
|
||||||
|
'driver' => 'slack',
|
||||||
|
'url' => env('LOG_SLACK_WEBHOOK_URL'),
|
||||||
|
'username' => env('LOG_SLACK_USERNAME', env('APP_NAME', 'Laravel')),
|
||||||
|
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
|
||||||
|
'level' => env('LOG_LEVEL', 'critical'),
|
||||||
|
'replace_placeholders' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
'papertrail' => [
|
||||||
|
'driver' => 'monolog',
|
||||||
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
|
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
|
||||||
|
'handler_with' => [
|
||||||
|
'host' => env('PAPERTRAIL_URL'),
|
||||||
|
'port' => env('PAPERTRAIL_PORT'),
|
||||||
|
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
|
||||||
|
],
|
||||||
|
'processors' => [PsrLogMessageProcessor::class],
|
||||||
|
],
|
||||||
|
|
||||||
|
'stderr' => [
|
||||||
|
'driver' => 'monolog',
|
||||||
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
|
'handler' => StreamHandler::class,
|
||||||
|
'handler_with' => [
|
||||||
|
'stream' => 'php://stderr',
|
||||||
|
],
|
||||||
|
'formatter' => env('LOG_STDERR_FORMATTER'),
|
||||||
|
'processors' => [PsrLogMessageProcessor::class],
|
||||||
|
],
|
||||||
|
|
||||||
|
'syslog' => [
|
||||||
|
'driver' => 'syslog',
|
||||||
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
|
'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
|
||||||
|
'replace_placeholders' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
'errorlog' => [
|
||||||
|
'driver' => 'errorlog',
|
||||||
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
|
'replace_placeholders' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
'null' => [
|
||||||
|
'driver' => 'monolog',
|
||||||
|
'handler' => NullHandler::class,
|
||||||
|
],
|
||||||
|
|
||||||
|
'emergency' => [
|
||||||
|
'path' => storage_path('logs/laravel.log'),
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
+118
@@ -0,0 +1,118 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Mailer
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option controls the default mailer that is used to send all email
|
||||||
|
| messages unless another mailer is explicitly specified when sending
|
||||||
|
| the message. All additional mailers can be configured within the
|
||||||
|
| "mailers" array. Examples of each type of mailer are provided.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('MAIL_MAILER', 'log'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Mailer Configurations
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may configure all of the mailers used by your application plus
|
||||||
|
| their respective settings. Several examples have been configured for
|
||||||
|
| you and you are free to add your own as your application requires.
|
||||||
|
|
|
||||||
|
| Laravel supports a variety of mail "transport" drivers that can be used
|
||||||
|
| when delivering an email. You may specify which one you're using for
|
||||||
|
| your mailers below. You may also add additional mailers if needed.
|
||||||
|
|
|
||||||
|
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
|
||||||
|
| "postmark", "resend", "log", "array",
|
||||||
|
| "failover", "roundrobin"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'mailers' => [
|
||||||
|
|
||||||
|
'smtp' => [
|
||||||
|
'transport' => 'smtp',
|
||||||
|
'scheme' => env('MAIL_SCHEME'),
|
||||||
|
'url' => env('MAIL_URL'),
|
||||||
|
'host' => env('MAIL_HOST', '127.0.0.1'),
|
||||||
|
'port' => env('MAIL_PORT', 2525),
|
||||||
|
'username' => env('MAIL_USERNAME'),
|
||||||
|
'password' => env('MAIL_PASSWORD'),
|
||||||
|
'timeout' => null,
|
||||||
|
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
|
||||||
|
],
|
||||||
|
|
||||||
|
'ses' => [
|
||||||
|
'transport' => 'ses',
|
||||||
|
],
|
||||||
|
|
||||||
|
'postmark' => [
|
||||||
|
'transport' => 'postmark',
|
||||||
|
// 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
|
||||||
|
// 'client' => [
|
||||||
|
// 'timeout' => 5,
|
||||||
|
// ],
|
||||||
|
],
|
||||||
|
|
||||||
|
'resend' => [
|
||||||
|
'transport' => 'resend',
|
||||||
|
],
|
||||||
|
|
||||||
|
'sendmail' => [
|
||||||
|
'transport' => 'sendmail',
|
||||||
|
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'log' => [
|
||||||
|
'transport' => 'log',
|
||||||
|
'channel' => env('MAIL_LOG_CHANNEL'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'array' => [
|
||||||
|
'transport' => 'array',
|
||||||
|
],
|
||||||
|
|
||||||
|
'failover' => [
|
||||||
|
'transport' => 'failover',
|
||||||
|
'mailers' => [
|
||||||
|
'smtp',
|
||||||
|
'log',
|
||||||
|
],
|
||||||
|
'retry_after' => 60,
|
||||||
|
],
|
||||||
|
|
||||||
|
'roundrobin' => [
|
||||||
|
'transport' => 'roundrobin',
|
||||||
|
'mailers' => [
|
||||||
|
'ses',
|
||||||
|
'postmark',
|
||||||
|
],
|
||||||
|
'retry_after' => 60,
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Global "From" Address
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| You may wish for all emails sent by your application to be sent from
|
||||||
|
| the same address. Here you may specify a name and address that is
|
||||||
|
| used globally for all emails that are sent by your application.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'from' => [
|
||||||
|
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
|
||||||
|
'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')),
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
$platformHost = parse_url((string) env('APP_URL', 'https://ladill.com'), PHP_URL_HOST) ?: 'ladill.com';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'account_url' => env(
|
||||||
|
'LADILL_ACCOUNT_URL',
|
||||||
|
'https://'.env('ACCOUNT_DOMAIN', 'account.'.$platformHost)
|
||||||
|
),
|
||||||
|
|
||||||
|
'brands' => [
|
||||||
|
'ladill' => [
|
||||||
|
'name' => 'Ladill',
|
||||||
|
'logo' => 'ladill-logo-white.png',
|
||||||
|
'logo_class' => 'email-logo',
|
||||||
|
'app_url' => env('APP_URL', 'https://ladill.com'),
|
||||||
|
'footer_account' => 'an account with Ladill',
|
||||||
|
'dashboard_path' => '/dashboard',
|
||||||
|
'support_path' => '/support',
|
||||||
|
'home_label' => 'ladill.com',
|
||||||
|
],
|
||||||
|
'hosting' => [
|
||||||
|
'name' => 'Ladill Hosting',
|
||||||
|
'logo' => 'ladillhosting-logo-email.png',
|
||||||
|
'logo_class' => 'email-logo',
|
||||||
|
'app_url' => env('LADILL_HOSTING_APP_URL', 'https://hosting.ladill.com'),
|
||||||
|
'footer_account' => 'a Ladill Hosting account',
|
||||||
|
'dashboard_path' => '/',
|
||||||
|
'support_path' => '/support',
|
||||||
|
'home_label' => 'hosting.ladill.com',
|
||||||
|
],
|
||||||
|
'domains' => [
|
||||||
|
'name' => 'Ladill Domains',
|
||||||
|
'logo' => 'ladilldomains-logo-email.png',
|
||||||
|
'logo_class' => 'email-logo',
|
||||||
|
'app_url' => env('LADILL_DOMAINS_APP_URL', 'https://domains.ladill.com'),
|
||||||
|
'footer_account' => 'a Ladill Domains account',
|
||||||
|
'dashboard_path' => '/',
|
||||||
|
'support_path' => '/support',
|
||||||
|
'home_label' => 'domains.ladill.com',
|
||||||
|
],
|
||||||
|
'bird' => [
|
||||||
|
'name' => 'Ladill Bird',
|
||||||
|
'logo' => 'ladillbird-logo-email.png',
|
||||||
|
'logo_class' => 'email-logo',
|
||||||
|
'app_url' => env('LADILL_BIRD_APP_URL', 'https://bird.ladill.com'),
|
||||||
|
'footer_account' => 'a Ladill Bird account',
|
||||||
|
'dashboard_path' => '/',
|
||||||
|
'support_path' => '/support',
|
||||||
|
'home_label' => 'bird.ladill.com',
|
||||||
|
],
|
||||||
|
'mail' => [
|
||||||
|
'name' => 'Ladill Mail',
|
||||||
|
'logo' => 'ladillmail-logo-email.png',
|
||||||
|
'logo_class' => 'email-logo',
|
||||||
|
'app_url' => env('LADILL_WEBMAIL_URL', 'https://mail.ladill.com'),
|
||||||
|
'footer_account' => 'a Ladill Mail account',
|
||||||
|
'dashboard_path' => '/',
|
||||||
|
'support_path' => '/support',
|
||||||
|
'home_label' => 'mail.ladill.com',
|
||||||
|
],
|
||||||
|
'email' => [
|
||||||
|
'name' => 'Ladill Email',
|
||||||
|
'logo' => 'ladillemail-logo-email.png',
|
||||||
|
'logo_class' => 'email-logo',
|
||||||
|
'app_url' => env('LADILL_EMAIL_APP_URL', 'https://email.ladill.com'),
|
||||||
|
'footer_account' => 'a Ladill Email account',
|
||||||
|
'dashboard_path' => '/',
|
||||||
|
'support_path' => '/support',
|
||||||
|
'home_label' => 'email.ladill.com',
|
||||||
|
],
|
||||||
|
'qrplus' => [
|
||||||
|
'name' => 'Ladill QR Plus',
|
||||||
|
'logo' => 'ladillqrplus-logo-email.png',
|
||||||
|
'logo_class' => 'email-logo',
|
||||||
|
'app_url' => env('LADILL_QR_APP_URL', 'https://qrplus.ladill.com'),
|
||||||
|
'footer_account' => 'a Ladill QR Plus account',
|
||||||
|
'dashboard_path' => '/',
|
||||||
|
'support_path' => '/support',
|
||||||
|
'home_label' => 'qrplus.ladill.com',
|
||||||
|
],
|
||||||
|
'events' => [
|
||||||
|
'name' => 'Ladill Events',
|
||||||
|
'logo' => 'ladillevents-logo-email.png',
|
||||||
|
'logo_class' => 'email-logo',
|
||||||
|
'app_url' => env('LADILL_EVENTS_APP_URL', 'https://events.ladill.com'),
|
||||||
|
'footer_account' => 'a Ladill Events account',
|
||||||
|
'dashboard_path' => '/',
|
||||||
|
'support_path' => '/support',
|
||||||
|
'home_label' => 'events.ladill.com',
|
||||||
|
],
|
||||||
|
'transfer' => [
|
||||||
|
'name' => 'Ladill Transfer',
|
||||||
|
'logo' => 'ladilltransfer-logo-email.png',
|
||||||
|
'logo_class' => 'email-logo email-logo-transfer',
|
||||||
|
'app_url' => env('LADILL_TRANSFER_APP_URL', 'https://transfer.ladill.com'),
|
||||||
|
'footer_account' => 'a Ladill Transfer account',
|
||||||
|
'dashboard_path' => '/',
|
||||||
|
'support_path' => '/support',
|
||||||
|
'home_label' => 'transfer.ladill.com',
|
||||||
|
],
|
||||||
|
'mini' => [
|
||||||
|
'name' => 'Ladill Mini',
|
||||||
|
'logo' => 'ladillmini-logo-email.png',
|
||||||
|
'logo_class' => 'email-logo',
|
||||||
|
'app_url' => env('LADILL_MINI_APP_URL', 'https://mini.ladill.com'),
|
||||||
|
'footer_account' => 'a Ladill Mini account',
|
||||||
|
'dashboard_path' => '/',
|
||||||
|
'support_path' => '/support',
|
||||||
|
'home_label' => 'mini.ladill.com',
|
||||||
|
],
|
||||||
|
'servers' => [
|
||||||
|
'name' => 'Ladill Servers',
|
||||||
|
'logo' => 'ladillservers-logo-email.png',
|
||||||
|
'logo_class' => 'email-logo',
|
||||||
|
'app_url' => env('LADILL_SERVERS_APP_URL', 'https://servers.ladill.com'),
|
||||||
|
'footer_account' => 'a Ladill Servers account',
|
||||||
|
'dashboard_path' => '/',
|
||||||
|
'support_path' => '/support',
|
||||||
|
'home_label' => 'servers.ladill.com',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
// Consumer config for the platform Mailbox provisioning API (§4). Mail infra
|
||||||
|
// (Postfix/Dovecot via mail DB + pools) lives on the platform; this app
|
||||||
|
// provisions through the API. The user is identified by public_id.
|
||||||
|
'api_url' => env('MAILBOX_API_URL', 'https://ladill.com/api/mailboxes'),
|
||||||
|
'api_key' => env('MAILBOX_API_KEY_EMAIL'),
|
||||||
|
];
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user