Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2b1dc7054 | ||
|
|
25ce2bc9de | ||
|
|
ce5ef67a7c | ||
|
|
7f65204127 | ||
|
|
863cad503c | ||
|
|
2b5644df7e | ||
|
|
3f4149c9a4 | ||
|
|
2fafc5e640 | ||
|
|
daf40f831b | ||
|
|
b8b6521058 | ||
|
|
1bcbdbfdb1 | ||
|
|
837c249634 | ||
|
|
14c17fffa7 | ||
|
|
d7ce7459a5 | ||
|
|
51244c7bad | ||
|
|
183f0305ce | ||
|
|
7b20b71ea0 | ||
|
|
29da6f12a7 | ||
|
|
c3be1b9132 | ||
|
|
701cffb143 | ||
|
|
11656eaad1 | ||
|
|
da5fc51262 | ||
|
|
66e6e117de | ||
|
|
a7231ab16f | ||
|
|
a5ebd1e414 | ||
|
|
3d5ffa593d | ||
|
|
b73b071cb1 | ||
|
|
6a793548ad | ||
|
|
69ede57c03 | ||
|
|
ab192e443f | ||
|
|
6b2d97967a | ||
|
|
4c87c786da | ||
|
|
dda52722ce | ||
|
|
b5debd79c4 | ||
|
|
407f1364fb | ||
|
|
d14180b335 | ||
|
|
6e42cd1cb2 | ||
|
|
13b2bd9702 | ||
|
|
8fcb4b8f8c | ||
|
|
d6957a1618 | ||
|
|
dbaea55cfd | ||
|
|
ce26d2cf87 | ||
|
|
eb2273242b | ||
|
|
6cf069d630 | ||
|
|
422c7e01bf | ||
|
|
129dd04bea | ||
|
|
f7ee8dc322 | ||
|
|
8ead108a27 | ||
|
|
71ab6e9d0e | ||
|
|
dac095ef92 | ||
|
|
a00f032b5b | ||
|
|
d25682bc6c | ||
|
|
0aafb14836 | ||
|
|
9cdfc331b0 | ||
|
|
c875a64fe4 | ||
|
|
0cf3ef5de0 | ||
|
|
157165954b | ||
|
|
5662ac9454 | ||
|
|
1746874aaa | ||
|
|
0c5a1ddb23 | ||
|
|
1db499ba2f | ||
|
|
99ffc510f9 | ||
|
|
fec24da7bd | ||
|
|
42d997a599 | ||
|
|
2084cbc852 | ||
|
|
7cce194567 | ||
|
|
9eab538f2e | ||
|
|
d2ec31f7a5 | ||
|
|
d942ebaa83 | ||
|
|
32c721b4f2 | ||
|
|
93272f968a | ||
|
|
e9b51024d9 | ||
|
|
05a6be7efe | ||
|
|
06fedcfc55 | ||
|
|
5a8a462102 | ||
|
|
7d00349847 | ||
|
|
d5a84c07c0 | ||
|
|
e356757d6c | ||
|
|
70316879b2 | ||
|
|
18bb548c9f | ||
|
|
7856ec854b | ||
|
|
af9d315c70 | ||
|
|
e437aff7bc | ||
|
|
2cafdb3dc1 | ||
|
|
a015cda2ee | ||
|
|
d369de0ce4 | ||
|
|
d9137b47e5 | ||
|
|
b789e81134 | ||
|
|
baf56479cc | ||
|
|
c6ba7f5604 | ||
|
|
fe6bffaf1d | ||
|
|
82259b1fac | ||
|
|
cbfb8733ea | ||
|
|
ceaa9ba8dc | ||
|
|
5bc3b3a064 | ||
|
|
e234889f60 | ||
|
|
c3fa39a053 | ||
|
|
75bb72abb3 | ||
|
|
8cca4fb6e3 | ||
|
|
5ddf674983 | ||
|
|
73b9c30562 | ||
|
|
ee2af28739 | ||
|
|
1476e4d9e1 | ||
|
|
5e37d6103c | ||
|
|
c9bcc58fb0 | ||
|
|
c62d04d333 | ||
|
|
5cfc800533 | ||
|
|
b08c27cf6e | ||
|
|
88665d3e97 | ||
|
|
4437dd781d | ||
|
|
60523c6a34 | ||
|
|
8a4e650347 | ||
|
|
903b27b5d2 | ||
|
|
1fe3f80c09 | ||
|
|
e4df5967c0 | ||
|
|
632ac64298 | ||
|
|
ea612fbc23 | ||
|
|
9015039e48 | ||
|
|
55ea2d506c | ||
|
|
77b3f3ccf4 | ||
|
|
e0e51d4f9f | ||
|
|
86e40bfbd2 |
@@ -17,8 +17,14 @@ DB_DATABASE=ladill_events
|
||||
DB_USERNAME=ladill_events
|
||||
DB_PASSWORD=
|
||||
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
SESSION_DRIVER=redis
|
||||
# Idle web session length in minutes (1440 = 24 hours of inactivity).
|
||||
SESSION_LIFETIME=1440
|
||||
SESSION_DOMAIN=.ladill.com
|
||||
|
||||
LADILL_SSO_CLIENT_ID=
|
||||
@@ -33,10 +39,50 @@ PAY_API_KEY_EVENTS=
|
||||
IDENTITY_API_URL=https://ladill.com/api
|
||||
IDENTITY_API_KEY_EVENTS=
|
||||
|
||||
MEET_API_URL=https://meet.ladill.com/api/service/v1
|
||||
MEET_API_KEY_EVENTS=
|
||||
MEET_WEBHOOK_SECRET_EVENTS=
|
||||
|
||||
# Ladill Mail calendar (sync events to the webmail utility calendar)
|
||||
LADILL_MAIL_API_URL=https://mail.ladill.com/api/service/v1
|
||||
MAIL_API_KEY_EVENTS=
|
||||
|
||||
# Inbound service API (Meet calls Events)
|
||||
EVENTS_API_KEY_MEET=
|
||||
|
||||
SMS_PLATFORM_API_URL=https://ladill.com/api
|
||||
SMS_API_KEY_EVENTS=
|
||||
SMS_CUSTOMER_RELAY_URL=https://ladill.com/api/sms
|
||||
SMS_DEFAULT_SENDER_ID=Ladill
|
||||
|
||||
# Transactional email (platform mailer — same pattern as Invoice)
|
||||
MAIL_MAILER=smtp
|
||||
MAIL_HOST=127.0.0.1
|
||||
MAIL_PORT=587
|
||||
MAIL_USERNAME=
|
||||
MAIL_PASSWORD=
|
||||
MAIL_FROM_ADDRESS=noreply@ladill.com
|
||||
MAIL_FROM_NAME=Ladill
|
||||
|
||||
# Platform suite messaging (zero-config — channel=suite; no Bird API key required)
|
||||
# Monolith must have MESSAGING_SUITE_PLATFORM_CHANNEL=true and SMTP_API_KEY_EVENTS set.
|
||||
SMTP_PLATFORM_API_URL=https://ladill.com/api/smtp
|
||||
SMTP_API_KEY_EVENTS=
|
||||
SMTP_CUSTOMER_RELAY_URL=https://ladill.com/api/smtp
|
||||
EVENTS_SMTP_FROM=events@ladill.com
|
||||
EVENTS_SMTP_FROM_NAME="Ladill Events"
|
||||
MESSAGING_SETTINGS_URL=https://account.ladill.com/account/settings/messaging?reason=messaging_mailbox_required&from=suite
|
||||
|
||||
AFIA_ENABLED=true
|
||||
AFIA_PRODUCT=events
|
||||
AFIA_PROVIDER=openai
|
||||
AFIA_MODEL=gpt-4o-mini
|
||||
AFIA_API_KEY=
|
||||
|
||||
LINK_PUBLIC_DOMAIN=ladl.link
|
||||
|
||||
# Suite demo accounts (sales walkthroughs) — reset Events data on each SSO login
|
||||
DEMO_ACCOUNTS_ENABLED=false
|
||||
DEMO_RESET_ON_LOGIN=true
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
@@ -19,8 +19,9 @@ jobs:
|
||||
env:
|
||||
NODE_ROOT: /tmp/ladill-node-22-r1
|
||||
NODE_VERSION: "22.14.0"
|
||||
RELEASE_ARCHIVE: /tmp/ladill-events-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz
|
||||
WORKSPACE: /tmp/${{ gitea.repository_owner }}-events-${{ gitea.run_id }}-${{ gitea.run_attempt }}
|
||||
# act_runner v0.2.x does not expose gitea.run_attempt — use run_id only.
|
||||
RELEASE_ARCHIVE: /tmp/ladill-events-release-${{ gitea.run_id }}.tgz
|
||||
WORKSPACE: /tmp/${{ gitea.repository_owner }}-events-${{ gitea.run_id }}
|
||||
LADILL_APP_ROOT: /var/www/ladill-events
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -38,11 +39,17 @@ jobs:
|
||||
clone --depth 1 --single-branch --no-tags --branch "${{ gitea.ref_name }}" \
|
||||
"$REPO_URL" "$WORKSPACE"
|
||||
|
||||
- name: Setup Node.js
|
||||
- name: Build frontend assets
|
||||
shell: bash {0}
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
if [ ! -x "$NODE_ROOT/bin/node" ]; then
|
||||
cd "$WORKSPACE"
|
||||
if command -v npm >/dev/null 2>&1 && npm -v >/dev/null 2>&1; then
|
||||
NPM_BIN="$(command -v npm)"
|
||||
elif [ -x "$NODE_ROOT/bin/npm" ]; then
|
||||
export PATH="$NODE_ROOT/bin:$PATH"
|
||||
NPM_BIN="$NODE_ROOT/bin/npm"
|
||||
else
|
||||
rm -rf "$NODE_ROOT"
|
||||
mkdir -p "$NODE_ROOT"
|
||||
case "$(uname -m)" in
|
||||
@@ -52,17 +59,12 @@ jobs:
|
||||
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
|
||||
NPM_BIN="$NODE_ROOT/bin/npm"
|
||||
fi
|
||||
"$NPM_BIN" ci --no-audit --no-fund
|
||||
"$NPM_BIN" run build
|
||||
|
||||
|
||||
- name: Build release archive
|
||||
shell: bash {0}
|
||||
@@ -78,7 +80,7 @@ jobs:
|
||||
- name: Deploy release
|
||||
shell: bash {0}
|
||||
env:
|
||||
LADILL_RELEASE_ARCHIVE: /tmp/ladill-events-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz
|
||||
LADILL_RELEASE_ARCHIVE: /tmp/ladill-events-release-${{ gitea.run_id }}.tgz
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
: "${LADILL_APP_ROOT:?Set LADILL_APP_ROOT}"
|
||||
|
||||
@@ -24,3 +24,4 @@
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
Thumbs.db
|
||||
storage/app/private/qr/
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Events\ProSubscription;
|
||||
use App\Models\PaymentGatewaySetting;
|
||||
use App\Models\QrCode;
|
||||
use App\Models\QrEventRegistration;
|
||||
use App\Models\QrTeamMember;
|
||||
use App\Models\User;
|
||||
use App\Services\Events\SubscriptionService;
|
||||
use App\Support\DemoWorld;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Seed plan-aware Events demo data for a suite demo identity.
|
||||
*/
|
||||
class DemoSeedCommand extends Command
|
||||
{
|
||||
protected $signature = 'demo:seed
|
||||
{identity : Email or public_id of the local mirrored user}
|
||||
{--plan=free : free|pro|enterprise}
|
||||
{--reset : Remove prior demo data for this user before seeding}
|
||||
{--public-id= : Platform public_id when creating a local mirror}
|
||||
{--email= : Email when creating a local mirror}
|
||||
{--name= : Display name when creating a local mirror}';
|
||||
|
||||
protected $description = 'Seed Events demo data for a suite demo account';
|
||||
|
||||
public function handle(SubscriptionService $subscriptions): int
|
||||
{
|
||||
$plan = strtolower((string) $this->option('plan'));
|
||||
if (! in_array($plan, ['free', 'pro', 'enterprise'], true)) {
|
||||
$this->error('Plan must be free, pro, or enterprise.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$user = $this->resolveUser();
|
||||
if (! $user) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($this->option('reset')) {
|
||||
$this->resetDemoData($user);
|
||||
$this->info('Reset demo data for '.$user->email);
|
||||
}
|
||||
|
||||
$this->assignPlan($user, $plan, $subscriptions);
|
||||
|
||||
if ($this->hasDemoMarker($user) && ! $this->option('reset')) {
|
||||
$this->info('Demo data already present; plan updated. Use --reset to reseed.');
|
||||
$this->printSummary($user, $plan, $subscriptions);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->seedEvents($user, $plan);
|
||||
$this->printSummary($user, $plan, $subscriptions);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function resolveUser(): ?User
|
||||
{
|
||||
$identity = trim((string) $this->argument('identity'));
|
||||
$publicIdOpt = trim((string) ($this->option('public-id') ?: ''));
|
||||
$emailOpt = trim((string) ($this->option('email') ?: ''));
|
||||
$nameOpt = trim((string) ($this->option('name') ?: ''));
|
||||
|
||||
$user = User::query()
|
||||
->where('email', $identity)
|
||||
->orWhere('public_id', $identity)
|
||||
->first();
|
||||
|
||||
if ($user) {
|
||||
return $user;
|
||||
}
|
||||
|
||||
$publicId = $publicIdOpt !== '' ? $publicIdOpt : (Str::isUuid($identity) ? $identity : '');
|
||||
$email = $emailOpt !== '' ? $emailOpt : (filter_var($identity, FILTER_VALIDATE_EMAIL) ? $identity : '');
|
||||
|
||||
if ($publicId === '' || $email === '') {
|
||||
$this->error('Local user not found for "'.$identity.'". Pass --public-id and --email to create a mirror.');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return User::query()->updateOrCreate(
|
||||
['public_id' => $publicId],
|
||||
[
|
||||
'email' => $email,
|
||||
'name' => $nameOpt !== '' ? $nameOpt : Str::before($email, '@'),
|
||||
'email_verified_at' => now(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
private function assignPlan(User $user, string $plan, SubscriptionService $subscriptions): void
|
||||
{
|
||||
if ($plan === 'free') {
|
||||
ProSubscription::query()->where('user_id', $user->id)->delete();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$planKey = $plan === 'enterprise'
|
||||
? ProSubscription::PLAN_ENTERPRISE
|
||||
: ProSubscription::PLAN_PRO;
|
||||
|
||||
ProSubscription::query()->updateOrCreate(
|
||||
['user_id' => $user->id],
|
||||
[
|
||||
'plan' => $planKey,
|
||||
'status' => ProSubscription::STATUS_ACTIVE,
|
||||
'price_minor' => $planKey === ProSubscription::PLAN_ENTERPRISE
|
||||
? $subscriptions->enterprisePriceMinor()
|
||||
: $subscriptions->priceMinor(),
|
||||
'currency' => (string) config('events.pro.currency', 'GHS'),
|
||||
'auto_renew' => false,
|
||||
'started_at' => now(),
|
||||
'current_period_end' => now()->addYear(),
|
||||
'last_charged_at' => null,
|
||||
'canceled_at' => null,
|
||||
'last_reference' => 'demo-seed-'.$planKey,
|
||||
'last_error' => null,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
private function hasDemoMarker(User $user): bool
|
||||
{
|
||||
return QrCode::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('type', QrCode::TYPE_EVENT)
|
||||
->where('short_code', 'like', $this->codePrefix($user).'%')
|
||||
->exists();
|
||||
}
|
||||
|
||||
private function codePrefix(User $user): string
|
||||
{
|
||||
return 'dm'.substr(md5((string) $user->public_id), 0, 8);
|
||||
}
|
||||
|
||||
private function resetDemoData(User $user): void
|
||||
{
|
||||
DB::transaction(function () use ($user) {
|
||||
PaymentGatewaySetting::query()->where('owner_ref', $user->public_id)->delete();
|
||||
|
||||
QrTeamMember::query()
|
||||
->where('account_id', $user->id)
|
||||
->orWhere('user_id', $user->id)
|
||||
->delete();
|
||||
|
||||
$codes = QrCode::query()->where('user_id', $user->id)->pluck('id');
|
||||
if ($codes->isNotEmpty()) {
|
||||
QrEventRegistration::query()->whereIn('qr_code_id', $codes)->delete();
|
||||
QrCode::query()->whereIn('id', $codes)->delete();
|
||||
}
|
||||
|
||||
QrEventRegistration::query()->where('user_id', $user->id)->delete();
|
||||
ProSubscription::query()->where('user_id', $user->id)->delete();
|
||||
});
|
||||
}
|
||||
|
||||
private function seedEvents(User $user, string $plan): void
|
||||
{
|
||||
$prefix = $this->codePrefix($user);
|
||||
$paid = $plan !== 'free';
|
||||
$eventCount = $paid ? ($plan === 'enterprise' ? 5 : 4) : 2;
|
||||
$regsPerEvent = $paid ? ($plan === 'enterprise' ? 40 : 25) : 35;
|
||||
|
||||
$keynoteSpeaker = DemoWorld::personByKey('ama-mensah');
|
||||
$breakoutSpeaker = DemoWorld::personByKey('kojo-darko');
|
||||
|
||||
$programmeId = null;
|
||||
if ($paid) {
|
||||
$programme = QrCode::query()->updateOrCreate(
|
||||
['short_code' => $prefix.'-itn'],
|
||||
[
|
||||
'user_id' => $user->id,
|
||||
'type' => QrCode::TYPE_ITINERARY,
|
||||
'label' => 'Demo Summit Programme',
|
||||
'payload' => [
|
||||
'content' => [
|
||||
'title' => 'Accra Tech Summit Programme',
|
||||
'subtitle' => 'Two-day agenda',
|
||||
'description' => 'Demo programme for sales walkthroughs.',
|
||||
'event_date' => now()->addDays(14)->toDateString(),
|
||||
'location' => 'Kempinski Accra',
|
||||
'brand_color' => '#0F766E',
|
||||
'days' => [[
|
||||
'label' => 'Day 1',
|
||||
'date' => now()->addDays(14)->toDateString(),
|
||||
'items' => [
|
||||
[
|
||||
'ref' => 'day0-item0',
|
||||
'time' => '09:00',
|
||||
'title' => 'Opening keynote',
|
||||
'description' => 'Welcome and product roadmap',
|
||||
'location' => 'Main hall',
|
||||
'host' => DemoWorld::fullName($keynoteSpeaker),
|
||||
'host_speaker_email' => $keynoteSpeaker['email'],
|
||||
],
|
||||
[
|
||||
'ref' => 'day0-item1',
|
||||
'time' => '11:00',
|
||||
'title' => 'Breakout: ticketing',
|
||||
'description' => 'Paid tiers and badges',
|
||||
'location' => 'Room B',
|
||||
'host' => DemoWorld::fullName($breakoutSpeaker),
|
||||
'host_speaker_email' => $breakoutSpeaker['email'],
|
||||
],
|
||||
],
|
||||
]],
|
||||
],
|
||||
'style' => [],
|
||||
],
|
||||
'is_active' => true,
|
||||
],
|
||||
);
|
||||
$programmeId = $programme->id;
|
||||
}
|
||||
|
||||
for ($i = 1; $i <= $eventCount; $i++) {
|
||||
$mode = $paid ? 'ticketing' : 'free';
|
||||
$tiers = $paid
|
||||
? [
|
||||
['name' => 'General', 'price' => 150, 'capacity' => 200],
|
||||
['name' => 'VIP', 'price' => 450, 'capacity' => 40],
|
||||
]
|
||||
: [['name' => 'Registration', 'price' => 0, 'capacity' => 100]];
|
||||
|
||||
$event = QrCode::query()->updateOrCreate(
|
||||
['short_code' => $prefix.'-e'.$i],
|
||||
[
|
||||
'user_id' => $user->id,
|
||||
'type' => QrCode::TYPE_EVENT,
|
||||
'label' => $paid ? 'Demo Paid Event '.$i : 'Demo Free Event '.$i,
|
||||
'payload' => [
|
||||
'content' => [
|
||||
'name' => $paid ? 'Accra Business Forum '.$i : 'Community Meetup '.$i,
|
||||
'tagline' => $paid ? 'Ticketed demo event' : 'Free RSVP demo event',
|
||||
'description' => 'Seeded by demo:seed for sales walkthroughs.',
|
||||
'location' => 'Accra, Ghana',
|
||||
'starts_at' => now()->addDays(7 + $i)->toIso8601String(),
|
||||
'ends_at' => now()->addDays(7 + $i)->addHours(6)->toIso8601String(),
|
||||
'organizer' => DemoWorld::business($plan)['name'],
|
||||
'currency' => 'GHS',
|
||||
'mode' => $mode,
|
||||
'format' => $i === 1 ? 'in_person' : 'hybrid',
|
||||
'tiers' => $tiers,
|
||||
'badge_fields' => ['company', 'role'],
|
||||
'badge_size' => 'standard',
|
||||
'accepts_payment' => $paid,
|
||||
'registration_open' => true,
|
||||
'programme_qr_id' => $programmeId,
|
||||
'speakers' => $paid ? [
|
||||
[
|
||||
'name' => DemoWorld::fullName($keynoteSpeaker),
|
||||
'email' => $keynoteSpeaker['email'],
|
||||
'role' => 'Keynote',
|
||||
],
|
||||
] : [],
|
||||
],
|
||||
'style' => [],
|
||||
],
|
||||
'is_active' => true,
|
||||
],
|
||||
);
|
||||
|
||||
$this->seedRegistrations($user, $event, $regsPerEvent, $paid, $prefix, $i);
|
||||
}
|
||||
|
||||
if ($paid) {
|
||||
PaymentGatewaySetting::query()->updateOrCreate(
|
||||
['owner_ref' => $user->public_id],
|
||||
[
|
||||
'provider' => PaymentGatewaySetting::PROVIDER_PAYSTACK,
|
||||
'public_key' => 'pk_test_demo_events',
|
||||
'secret_key' => 'sk_test_demo_events',
|
||||
'is_active' => true,
|
||||
'metadata' => ['demo' => true],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function seedRegistrations(
|
||||
User $user,
|
||||
QrCode $event,
|
||||
int $count,
|
||||
bool $paid,
|
||||
string $prefix,
|
||||
int $eventIndex,
|
||||
): void {
|
||||
$existing = QrEventRegistration::query()
|
||||
->where('qr_code_id', $event->id)
|
||||
->where('reference', 'like', $prefix.'%')
|
||||
->count();
|
||||
|
||||
if ($existing >= $count) {
|
||||
return;
|
||||
}
|
||||
|
||||
$people = DemoWorld::people();
|
||||
|
||||
for ($n = $existing + 1; $n <= $count; $n++) {
|
||||
$tier = $paid ? ($n % 5 === 0 ? 'VIP' : 'General') : 'Registration';
|
||||
$amount = $paid ? ($tier === 'VIP' ? 45000 : 15000) : 0;
|
||||
$ref = sprintf('%s-r%02d-%03d', $prefix, $eventIndex, $n);
|
||||
$badge = strtoupper(substr($prefix, 2, 4).sprintf('%02d%03d', $eventIndex, $n));
|
||||
$person = $people[($n - 1) % count($people)];
|
||||
|
||||
QrEventRegistration::query()->updateOrCreate(
|
||||
['reference' => $ref],
|
||||
[
|
||||
'qr_code_id' => $event->id,
|
||||
'user_id' => $user->id,
|
||||
'badge_code' => substr($badge, 0, 16),
|
||||
'tier_name' => $tier,
|
||||
'amount_minor' => $amount,
|
||||
'currency' => 'GHS',
|
||||
'attendee_name' => DemoWorld::fullName($person),
|
||||
'attendee_email' => $person['email'],
|
||||
'attendee_phone' => $person['phone'],
|
||||
'badge_fields' => [
|
||||
'company' => DemoWorld::companyNameForPerson($person),
|
||||
'role' => $tier === 'VIP' ? 'Executive' : 'Guest',
|
||||
],
|
||||
'status' => QrEventRegistration::STATUS_CONFIRMED,
|
||||
'payment_reference' => $paid ? 'demo-pay-'.$ref : null,
|
||||
'paid_at' => $paid ? now()->subDays(max(1, 30 - $n)) : null,
|
||||
'checked_in_at' => $n % 4 === 0 ? now()->subHours($n) : null,
|
||||
'metadata' => ['demo' => true],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function printSummary(User $user, string $plan, SubscriptionService $subscriptions): void
|
||||
{
|
||||
$events = QrCode::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('type', QrCode::TYPE_EVENT)
|
||||
->count();
|
||||
$programmes = QrCode::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('type', QrCode::TYPE_ITINERARY)
|
||||
->count();
|
||||
$regs = QrEventRegistration::query()->where('user_id', $user->id)->count();
|
||||
|
||||
$this->table(
|
||||
['Field', 'Value'],
|
||||
[
|
||||
['email', $user->email],
|
||||
['public_id', $user->public_id],
|
||||
['requested_plan', $plan],
|
||||
['effective_plan', $subscriptions->planKey($user)],
|
||||
['live_events', (string) $events],
|
||||
['programmes', (string) $programmes],
|
||||
['registrations', (string) $regs],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Events\ProSubscription;
|
||||
use App\Services\Events\SubscriptionService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class RenewProSubscriptionsCommand extends Command
|
||||
{
|
||||
protected $signature = 'events:pro-renew';
|
||||
|
||||
protected $description = 'Charge the Ladill wallet for due Events Pro subscriptions.';
|
||||
|
||||
public function handle(SubscriptionService $subscriptions): int
|
||||
{
|
||||
$due = ProSubscription::where('status', ProSubscription::STATUS_ACTIVE)
|
||||
->where('auto_renew', true)
|
||||
->where('current_period_end', '<=', now())
|
||||
->get();
|
||||
|
||||
$this->info("Renewing {$due->count()} due subscription(s).");
|
||||
$due->each(fn (ProSubscription $sub) => $subscriptions->renewIfDue($sub));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Meet\MeetLifecycleService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class MeetWebhookController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, MeetLifecycleService $lifecycle): JsonResponse
|
||||
{
|
||||
$secret = (string) config('meet.webhook_secret', '');
|
||||
if ($secret === '') {
|
||||
return response()->json(['error' => 'Webhook not configured.'], 503);
|
||||
}
|
||||
|
||||
$signature = (string) $request->header('X-Ladill-Meet-Signature', '');
|
||||
$body = $request->getContent();
|
||||
$expected = hash_hmac('sha256', $body, $secret);
|
||||
|
||||
if ($signature === '' || ! hash_equals($expected, $signature)) {
|
||||
return response()->json(['error' => 'Invalid signature.'], 401);
|
||||
}
|
||||
|
||||
/** @var array<string, mixed> $payload */
|
||||
$payload = $request->json()->all();
|
||||
$event = (string) ($payload['event'] ?? '');
|
||||
|
||||
if ($event === '') {
|
||||
return response()->json(['error' => 'Missing event.'], 422);
|
||||
}
|
||||
|
||||
$lifecycle->handle($event, $payload);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\CustomDomain\CustomDomainService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Signed completion callback from Ladill Domains' SSL service. Flips a custom
|
||||
* domain to live (or failed) once the certificate is issued. Auth is the HMAC
|
||||
* signature over the raw body (shared SSL_CALLBACK_SECRET), not a session.
|
||||
*/
|
||||
class SslCallbackController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, CustomDomainService $service): JsonResponse
|
||||
{
|
||||
$secret = (string) config('customdomain.callback_secret');
|
||||
$body = $request->getContent();
|
||||
$signature = (string) $request->header('X-Ladill-Signature', '');
|
||||
|
||||
if ($secret === '' || ! hash_equals(hash_hmac('sha256', $body, $secret), $signature)) {
|
||||
return response()->json(['error' => 'Invalid signature.'], 401);
|
||||
}
|
||||
|
||||
$payload = json_decode($body, true);
|
||||
$data = (array) ($payload['data'] ?? []);
|
||||
$host = (string) ($data['host'] ?? '');
|
||||
if ($host === '') {
|
||||
return response()->json(['error' => 'Missing host.'], 422);
|
||||
}
|
||||
|
||||
$service->applyCallback(
|
||||
$host,
|
||||
(string) ($data['status'] ?? 'failed'),
|
||||
$data['expires_at'] ?? null,
|
||||
$data['last_error'] ?? null,
|
||||
);
|
||||
|
||||
return response()->json(['status' => 'ok']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\QrCode;
|
||||
use App\Models\User;
|
||||
use App\Services\Meet\EventMeetRoomLinkService;
|
||||
use App\Services\Meet\EventRegistrationVerifyService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ServiceMeetController extends Controller
|
||||
{
|
||||
public function __construct(private readonly EventMeetRoomLinkService $links) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'owner_ref' => ['required', 'string', 'max:64'],
|
||||
]);
|
||||
|
||||
$owner = User::query()->where('public_id', $validated['owner_ref'])->firstOrFail();
|
||||
|
||||
return response()->json([
|
||||
'events' => $this->links->linkableEvents($owner),
|
||||
]);
|
||||
}
|
||||
|
||||
public function linkRoom(Request $request, QrCode $event): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'owner_ref' => ['required', 'string', 'max:64'],
|
||||
'meet_room_uuid' => ['required', 'string', 'max:64'],
|
||||
'meet_room_type' => ['required', 'string', 'in:town_hall,webinar'],
|
||||
'join_url' => ['nullable', 'string', 'max:2048'],
|
||||
'title' => ['nullable', 'string', 'max:255'],
|
||||
'scheduled_at' => ['nullable', 'date'],
|
||||
'duration_minutes' => ['nullable', 'integer', 'min:15', 'max:480'],
|
||||
'virtual_session_id' => ['nullable', 'string', 'max:64'],
|
||||
]);
|
||||
|
||||
$owner = User::query()->where('public_id', $validated['owner_ref'])->firstOrFail();
|
||||
abort_unless($event->type === QrCode::TYPE_EVENT, 404);
|
||||
|
||||
$result = $this->links->linkRoom($owner, $event, $validated);
|
||||
|
||||
return response()->json(['event' => $result]);
|
||||
}
|
||||
|
||||
public function verifyRegistration(Request $request, QrCode $event): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'owner_ref' => ['required', 'string', 'max:64'],
|
||||
'badge_code' => ['required', 'string', 'max:16'],
|
||||
]);
|
||||
|
||||
$owner = User::query()->where('public_id', $validated['owner_ref'])->firstOrFail();
|
||||
abort_unless($event->user_id === $owner->id, 403);
|
||||
abort_unless($event->type === QrCode::TYPE_EVENT, 404);
|
||||
|
||||
$result = app(EventRegistrationVerifyService::class)->verifyBadge($event, $validated['badge_code']);
|
||||
|
||||
if (! $result) {
|
||||
return response()->json(['message' => 'Badge not found.'], 404);
|
||||
}
|
||||
|
||||
return response()->json(['registration' => $result]);
|
||||
}
|
||||
|
||||
public function verifySpeaker(Request $request, QrCode $event): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'owner_ref' => ['required', 'string', 'max:64'],
|
||||
'speaker_token' => ['required', 'string', 'max:128'],
|
||||
]);
|
||||
|
||||
$owner = User::query()->where('public_id', $validated['owner_ref'])->firstOrFail();
|
||||
abort_unless($event->user_id === $owner->id, 403);
|
||||
abort_unless($event->type === QrCode::TYPE_EVENT, 404);
|
||||
|
||||
$result = app(\App\Services\Events\EventSpeakerInviteService::class)
|
||||
->verifyToken($event, $validated['speaker_token']);
|
||||
|
||||
if (! $result) {
|
||||
return response()->json(['message' => 'Speaker invite not found.'], 404);
|
||||
}
|
||||
|
||||
return response()->json(['speaker' => $result]);
|
||||
}
|
||||
}
|
||||
@@ -88,10 +88,7 @@ class SsoLoginController extends Controller
|
||||
if ($request->filled('error')) {
|
||||
if (in_array($request->query('error'), ['login_required', 'interaction_required', 'consent_required'], true)
|
||||
&& ! $request->boolean('interactive')) {
|
||||
return redirect()->route('sso.connect', [
|
||||
'redirect' => $intended,
|
||||
'interactive' => 1,
|
||||
]);
|
||||
return redirect()->away((string) config('ladill.marketing_url'));
|
||||
}
|
||||
|
||||
return $this->finishCallback($request, $intended, (string) $request->query('error_description', $request->query('error')), $popup);
|
||||
@@ -126,6 +123,8 @@ class SsoLoginController extends Controller
|
||||
Auth::login($user, remember: true);
|
||||
$request->session()->regenerate();
|
||||
|
||||
app(\App\Services\Events\DemoLoginReseeder::class)->maybeResetAfterLogin($user);
|
||||
|
||||
return $this->finishCallback($request, $intended, null, $popup);
|
||||
}
|
||||
|
||||
@@ -141,6 +140,18 @@ class SsoLoginController extends Controller
|
||||
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', ''));
|
||||
|
||||
@@ -5,27 +5,27 @@ namespace App\Http\Controllers\Events;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\QrCode;
|
||||
use App\Models\QrEventRegistration;
|
||||
use App\Notifications\EventProgrammeSharedNotification;
|
||||
use App\Services\Billing\SmsService;
|
||||
use App\Services\Events\EventCommsService;
|
||||
use App\Support\Events\EventBadgeZpl;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AttendeeController extends Controller
|
||||
{
|
||||
public function __construct(private readonly SmsService $sms) {}
|
||||
public function __construct(
|
||||
private readonly EventCommsService $comms,
|
||||
) {}
|
||||
|
||||
public function hub(Request $request): View
|
||||
{
|
||||
$account = ladill_account();
|
||||
$search = trim((string) $request->query('search', ''));
|
||||
|
||||
$events = $account->qrCodes()
|
||||
->where('type', QrCode::TYPE_EVENT)
|
||||
->when($search !== '', fn ($q) => $q->where('label', 'like', "%{$search}%"))
|
||||
->latest()
|
||||
->get()
|
||||
->map(function (QrCode $event) {
|
||||
@@ -41,18 +41,15 @@ class AttendeeController extends Controller
|
||||
|
||||
return view('events.attendees-hub', [
|
||||
'events' => $events,
|
||||
'search' => $search,
|
||||
]);
|
||||
}
|
||||
|
||||
public function badgesHub(Request $request): View
|
||||
{
|
||||
$account = ladill_account();
|
||||
$search = trim((string) $request->query('search', ''));
|
||||
|
||||
$events = $account->qrCodes()
|
||||
->where('type', QrCode::TYPE_EVENT)
|
||||
->when($search !== '', fn ($q) => $q->where('label', 'like', "%{$search}%"))
|
||||
->latest()
|
||||
->get()
|
||||
->map(function (QrCode $event) {
|
||||
@@ -65,7 +62,6 @@ class AttendeeController extends Controller
|
||||
|
||||
return view('events.badges-hub', [
|
||||
'events' => $events,
|
||||
'search' => $search,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -131,7 +127,24 @@ class AttendeeController extends Controller
|
||||
'revenue' => $event->eventRegistrations()->where('status', QrEventRegistration::STATUS_CONFIRMED)->sum('amount_minor') / 100,
|
||||
];
|
||||
|
||||
$content = $event->content();
|
||||
$format = (string) ($content['format'] ?? 'in_person');
|
||||
$virtualSessions = collect((array) ($content['virtual_sessions'] ?? []))->filter(fn ($s) => is_array($s));
|
||||
|
||||
if (in_array($format, ['virtual', 'hybrid'], true) && $virtualSessions->isNotEmpty()) {
|
||||
$stats['virtual_sessions'] = $virtualSessions->count();
|
||||
$stats['virtual_live'] = $virtualSessions->where('meet_status', 'live')->count();
|
||||
$stats['virtual_ended'] = $virtualSessions->where('meet_status', 'ended')->count();
|
||||
$stats['virtual_recordings'] = $virtualSessions->filter(fn ($s) => ! empty($s['recording_url']))->count();
|
||||
}
|
||||
|
||||
$programme = $this->programmeFor($event);
|
||||
$joinUrl = $this->comms->primaryJoinUrl($event);
|
||||
$commsModes = array_values(array_filter([
|
||||
$programme ? EventCommsService::MODE_PROGRAMME : null,
|
||||
$joinUrl !== '' ? EventCommsService::MODE_JOIN : null,
|
||||
($programme && $joinUrl !== '') ? EventCommsService::MODE_BOTH : null,
|
||||
]));
|
||||
|
||||
return view('events.attendees', [
|
||||
'qrCode' => $event,
|
||||
@@ -139,54 +152,67 @@ class AttendeeController extends Controller
|
||||
'registrations' => $registrations,
|
||||
'stats' => $stats,
|
||||
'programme' => $programme,
|
||||
'commsModes' => $commsModes,
|
||||
'defaultCommsMode' => $commsModes[0] ?? EventCommsService::MODE_PROGRAMME,
|
||||
'messagingSettingsUrl' => function_exists('ladill_account_url')
|
||||
? ladill_account_url('/account/settings/messaging')
|
||||
: (string) config('smtp.messaging_settings_url', 'https://account.ladill.com/account/settings/messaging'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function shareProgramme(QrCode $event): RedirectResponse
|
||||
public function previewComms(QrCode $event, Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('view', $event);
|
||||
abort_unless($event->type === QrCode::TYPE_EVENT, 404);
|
||||
|
||||
$programme = $this->programmeFor($event);
|
||||
if (! $programme) {
|
||||
return back()->with('error', 'No programme outline is attached to this event yet.');
|
||||
$mode = (string) $request->query('mode', EventCommsService::MODE_PROGRAMME);
|
||||
abort_unless(in_array($mode, [EventCommsService::MODE_PROGRAMME, EventCommsService::MODE_JOIN, EventCommsService::MODE_BOTH], true), 422);
|
||||
|
||||
return response()->json($this->comms->preview($event, $mode));
|
||||
}
|
||||
|
||||
$recipients = $event->eventRegistrations()
|
||||
->where('status', QrEventRegistration::STATUS_CONFIRMED)
|
||||
->get();
|
||||
public function shareComms(QrCode $event, Request $request): RedirectResponse
|
||||
{
|
||||
$this->authorize('view', $event);
|
||||
abort_unless($event->type === QrCode::TYPE_EVENT, 404);
|
||||
|
||||
$eventName = $event->content()['name'] ?? $event->label;
|
||||
$programmeUrl = $programme->publicUrl();
|
||||
$emailed = 0;
|
||||
$texted = 0;
|
||||
$validated = $request->validate([
|
||||
'mode' => ['required', Rule::in([
|
||||
EventCommsService::MODE_PROGRAMME,
|
||||
EventCommsService::MODE_JOIN,
|
||||
EventCommsService::MODE_BOTH,
|
||||
])],
|
||||
]);
|
||||
|
||||
foreach ($recipients as $reg) {
|
||||
if ($reg->attendee_email) {
|
||||
Notification::route('mail', $reg->attendee_email)
|
||||
->notify(new EventProgrammeSharedNotification($event, $programme, $reg->attendee_name));
|
||||
$emailed++;
|
||||
$preview = $this->comms->preview($event, $validated['mode']);
|
||||
if ($preview['integrations_error'] ?? null) {
|
||||
return back()->with('error', $preview['integrations_error']);
|
||||
}
|
||||
|
||||
if ($reg->attendee_phone) {
|
||||
$this->sms->send(
|
||||
$reg->attendee_phone,
|
||||
sprintf(
|
||||
'Hi %s, the programme for %s is here: %s',
|
||||
explode(' ', $reg->attendee_name ?? 'there')[0],
|
||||
$eventName,
|
||||
$programmeUrl
|
||||
)
|
||||
);
|
||||
$texted++;
|
||||
}
|
||||
if ($preview['recipients'] === 0) {
|
||||
return back()->with('error', 'No confirmed attendees to message yet.');
|
||||
}
|
||||
|
||||
if ($emailed === 0 && $texted === 0) {
|
||||
return back()->with('error', 'No confirmed attendees with contact details to share with yet.');
|
||||
$result = $this->comms->send($event, $validated['mode']);
|
||||
|
||||
if ($result['emailed'] === 0 && $result['texted'] === 0) {
|
||||
return back()->with('error', 'No messages were sent — check that attendees have email or phone on file.');
|
||||
}
|
||||
|
||||
return back()->with('success', "Programme shared — {$emailed} email(s) and {$texted} SMS sent.");
|
||||
return back()->with('success', sprintf(
|
||||
'Sent — %d email(s), %d SMS, %d skipped.',
|
||||
$result['emailed'],
|
||||
$result['texted'],
|
||||
$result['skipped'],
|
||||
));
|
||||
}
|
||||
|
||||
/** @deprecated Use shareComms with mode=programme */
|
||||
public function shareProgramme(QrCode $event): RedirectResponse
|
||||
{
|
||||
request()->merge(['mode' => EventCommsService::MODE_PROGRAMME]);
|
||||
|
||||
return $this->shareComms($event, request());
|
||||
}
|
||||
|
||||
private function programmeFor(QrCode $event): ?QrCode
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Events;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CustomDomain;
|
||||
use App\Models\QrCode;
|
||||
use App\Services\CustomDomain\CustomDomainService;
|
||||
use App\Services\CustomDomain\DomainsSslClient;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CustomDomainController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CustomDomainService $service,
|
||||
private readonly DomainsSslClient $domains,
|
||||
) {}
|
||||
|
||||
public function store(Request $request, QrCode $event): RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $event);
|
||||
abort_unless($this->service->enabled(), 404);
|
||||
|
||||
$data = $request->validate([
|
||||
'host' => ['required', 'string', 'max:255', 'regex:/^(?!-)([a-z0-9-]+\.)+[a-z]{2,}$/i'],
|
||||
'include_www' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
$host = strtolower(preg_replace('/^www\./', '', trim($data['host'])));
|
||||
|
||||
if (CustomDomain::where('host', $host)->exists()) {
|
||||
return back()->withErrors(['host' => 'That domain is already connected.']);
|
||||
}
|
||||
|
||||
$this->service->attach($event, $host, (bool) ($data['include_www'] ?? true));
|
||||
|
||||
// Surface this domain in Ladill Domains' "My Domains" (best-effort).
|
||||
$this->domains->registerConnected(
|
||||
$host,
|
||||
(string) $request->user()->public_id,
|
||||
'Event: '.$event->label,
|
||||
route('events.show', $event),
|
||||
);
|
||||
|
||||
return back()->with('success', "Domain added. Point an A record for {$host} (and www) to ".config('customdomain.server_ip').', then click Verify.');
|
||||
}
|
||||
|
||||
public function verify(Request $request, CustomDomain $customDomain): RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $customDomain->qrCode);
|
||||
|
||||
[$ok, $message] = $this->service->verifyAndProvision($customDomain);
|
||||
|
||||
return back()->with($ok ? 'success' : 'error', $message);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, CustomDomain $customDomain): RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $customDomain->qrCode);
|
||||
|
||||
$host = $customDomain->host;
|
||||
$customDomain->delete();
|
||||
$this->domains->removeConnected($host);
|
||||
|
||||
return back()->with('success', 'Custom domain removed.');
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\QrCode;
|
||||
use App\Models\QrEventRegistration;
|
||||
use App\Services\Billing\BillingClient;
|
||||
use App\Services\Events\SubscriptionService;
|
||||
use App\Support\Qr\QrTypeCatalog;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -14,7 +15,10 @@ use Throwable;
|
||||
|
||||
class OverviewController extends Controller
|
||||
{
|
||||
public function __construct(private BillingClient $billing) {}
|
||||
public function __construct(
|
||||
private BillingClient $billing,
|
||||
private SubscriptionService $subscriptions,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
@@ -83,6 +87,7 @@ class OverviewController extends Controller
|
||||
'recentEvents' => $recentEvents,
|
||||
'programmeCount' => $programmeCount,
|
||||
'balanceMinor' => $balanceMinor,
|
||||
'hasPaidPlan' => $this->subscriptions->hasPaidPlan($account),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,18 +6,24 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\QrCode;
|
||||
use App\Models\QrEventRegistration;
|
||||
use App\Services\Billing\BillingClient;
|
||||
use App\Services\Identity\IdentityClient;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\View\View;
|
||||
use Throwable;
|
||||
|
||||
class PayoutsController extends Controller
|
||||
{
|
||||
public function __construct(private BillingClient $billing) {}
|
||||
public function __construct(
|
||||
private BillingClient $billing,
|
||||
private IdentityClient $identity,
|
||||
) {}
|
||||
|
||||
public function index(): View
|
||||
{
|
||||
$account = ladill_account();
|
||||
|
||||
$eventIds = $account->qrCodes()
|
||||
->where('type', QrCode::TYPE_EVENT)
|
||||
->pluck('id');
|
||||
@@ -25,9 +31,24 @@ class PayoutsController extends Controller
|
||||
$revenueMinor = (int) QrEventRegistration::query()
|
||||
->whereIn('qr_code_id', $eventIds)
|
||||
->where('status', QrEventRegistration::STATUS_CONFIRMED)
|
||||
->sum('amount_minor');
|
||||
->where('amount_minor', '>', 0)
|
||||
->get()
|
||||
->sum(fn (QrEventRegistration $r) => $r->netAmountMinor());
|
||||
|
||||
$transactions = QrEventRegistration::query()
|
||||
->whereIn('qr_code_id', $eventIds)
|
||||
->where('status', QrEventRegistration::STATUS_CONFIRMED)
|
||||
->where('amount_minor', '>', 0)
|
||||
->with('qrCode:id,label,payload')
|
||||
->orderByDesc('paid_at')
|
||||
->orderByDesc('id')
|
||||
->limit(50)
|
||||
->get();
|
||||
|
||||
$balanceMinor = 0;
|
||||
$payoutAccount = null;
|
||||
$withdrawals = [];
|
||||
|
||||
try {
|
||||
$balanceMinor = $this->billing->balanceMinor($account->public_id);
|
||||
} catch (Throwable $e) {
|
||||
@@ -37,9 +58,68 @@ class PayoutsController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$payoutAccount = $this->identity->payoutAccount($account->public_id);
|
||||
$withdrawals = $this->identity->withdrawals($account->public_id);
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('Events payouts could not load payout account or withdrawals', [
|
||||
'user' => $account->public_id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return view('events.payouts', [
|
||||
'revenueMinor' => $revenueMinor,
|
||||
'balanceMinor' => $balanceMinor,
|
||||
'balanceGhs' => round($balanceMinor / 100, 2),
|
||||
'transactions' => $transactions,
|
||||
'payoutAccount' => $payoutAccount,
|
||||
'withdrawals' => $withdrawals,
|
||||
]);
|
||||
}
|
||||
|
||||
public function banks(Request $request): JsonResponse
|
||||
{
|
||||
$type = $request->query('type') === 'bank_account' ? 'bank' : 'mobile_money';
|
||||
|
||||
try {
|
||||
return response()->json(['data' => $this->identity->banks($type)]);
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('Events payouts could not load banks', ['error' => $e->getMessage()]);
|
||||
|
||||
return response()->json(['data' => []]);
|
||||
}
|
||||
}
|
||||
|
||||
public function updatePayoutAccount(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'account_type' => ['required', 'in:mobile_money,bank_account'],
|
||||
'account_name' => ['required', 'string', 'max:200'],
|
||||
'account_number' => ['required', 'string', 'max:30'],
|
||||
'bank_code' => ['required', 'string', 'max:30'],
|
||||
'bank_name' => ['required', 'string', 'max:200'],
|
||||
'currency' => ['sometimes', 'string', 'size:3'],
|
||||
]);
|
||||
$data['currency'] = $data['currency'] ?? 'GHS';
|
||||
|
||||
$this->identity->updatePayoutAccount(ladill_account()->public_id, $data);
|
||||
|
||||
return redirect()
|
||||
->route('events.payouts')
|
||||
->with('success', 'Payout account saved.');
|
||||
}
|
||||
|
||||
public function withdraw(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'amount' => ['required', 'numeric', 'min:1', 'max:50000'],
|
||||
]);
|
||||
|
||||
$this->identity->withdraw(ladill_account()->public_id, (float) $data['amount']);
|
||||
|
||||
return redirect()
|
||||
->route('events.payouts')
|
||||
->with('success', 'Withdrawal request submitted. Funds are sent to your payout account once processed.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Events;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Billing\BillingClient;
|
||||
use App\Services\Events\SubscriptionService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Paid Events plans are retired. Routes remain so old bookmarks and emails
|
||||
* do not 404; every action redirects to the dashboard.
|
||||
*/
|
||||
class ProController extends Controller
|
||||
{
|
||||
public function __construct(private readonly SubscriptionService $subscriptions) {}
|
||||
|
||||
public function index(Request $request): RedirectResponse
|
||||
{
|
||||
return $this->retired();
|
||||
}
|
||||
|
||||
public function subscribe(Request $request): RedirectResponse
|
||||
{
|
||||
return $this->retired();
|
||||
}
|
||||
|
||||
public function subscribeEnterprise(Request $request): RedirectResponse
|
||||
{
|
||||
return $this->retired();
|
||||
}
|
||||
|
||||
public function subscribePrepaid(Request $request, BillingClient $billing): RedirectResponse
|
||||
{
|
||||
return $this->retired();
|
||||
}
|
||||
|
||||
public function paystackCallback(Request $request, BillingClient $billing): RedirectResponse
|
||||
{
|
||||
return $this->retired();
|
||||
}
|
||||
|
||||
public function cancel(Request $request): RedirectResponse
|
||||
{
|
||||
return $this->retired();
|
||||
}
|
||||
|
||||
private function retired(): RedirectResponse
|
||||
{
|
||||
return redirect()->route('events.dashboard')
|
||||
->with('success', 'Ladill Events is free — paid subscriptions are no longer offered. Ticket payments use Ladill Pay with the standard platform fee.');
|
||||
}
|
||||
}
|
||||
@@ -3,22 +3,45 @@
|
||||
namespace App\Http\Controllers\Events;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\QrCode;
|
||||
use App\Models\QrWallet;
|
||||
use App\Services\Qr\QrCodeManagerService;
|
||||
use App\Support\Qr\QrTypeCatalog;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ProgrammeController extends Controller
|
||||
{
|
||||
public function __construct(private QrCodeManagerService $manager) {}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
return view('events.programme-create', [
|
||||
'pricePerQr' => QrWallet::pricePerQr(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$account = ladill_account();
|
||||
$wallet = $this->manager->walletFor($account);
|
||||
|
||||
$programmes = $account->qrCodes()
|
||||
->whereIn('type', QrTypeCatalog::programmeTypes())
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
return view('events.programmes', compact('programmes'));
|
||||
$totalSessions = $programmes->sum(function ($programme) {
|
||||
$days = (array) ($programme->content()['days'] ?? []);
|
||||
|
||||
return collect($days)->sum(fn ($day) => count((array) ($day['items'] ?? [])));
|
||||
});
|
||||
|
||||
return view('events.programmes', [
|
||||
'programmes' => $programmes,
|
||||
'wallet' => $wallet,
|
||||
'totalSessions' => $totalSessions,
|
||||
'pricePerQr' => QrWallet::pricePerQr(),
|
||||
'topupUrl' => 'https://'.config('app.account_domain').'/wallet',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Events;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\QrCode;
|
||||
use App\Services\Events\EventSpeakerInviteService;
|
||||
use App\Services\Events\EventSpeakerService;
|
||||
use App\Services\Qr\QrCodeManagerService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class SpeakerController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EventSpeakerService $speakers,
|
||||
private readonly EventSpeakerInviteService $invites,
|
||||
private readonly QrCodeManagerService $manager,
|
||||
) {}
|
||||
|
||||
public function hub(Request $request): View
|
||||
{
|
||||
$account = ladill_account();
|
||||
|
||||
$events = $this->speakers->eventsForAccount($account->id)
|
||||
->map(function (QrCode $event) {
|
||||
$event->speaker_count = count($this->speakers->rosterFor($event));
|
||||
|
||||
return $event;
|
||||
});
|
||||
|
||||
return view('events.speakers-hub', [
|
||||
'events' => $events,
|
||||
]);
|
||||
}
|
||||
|
||||
public function index(QrCode $event): View
|
||||
{
|
||||
$this->authorize('view', $event);
|
||||
abort_unless($event->type === QrCode::TYPE_EVENT, 404);
|
||||
|
||||
$roster = $this->invites->rosterWithInviteState($event);
|
||||
|
||||
return view('events.speakers', [
|
||||
'qrCode' => $event,
|
||||
'roster' => $roster,
|
||||
'programmeAssignments' => $this->speakers->programmeAssignments($event),
|
||||
'programme' => $this->speakers->linkedProgramme($event),
|
||||
'canManualInvite' => collect($roster)->mapWithKeys(fn ($speaker) => [
|
||||
$speaker['email'] => $this->invites->canSendManualInvite($event, $speaker),
|
||||
])->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, QrCode $event): RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $event);
|
||||
abort_unless($event->type === QrCode::TYPE_EVENT, 404);
|
||||
|
||||
$validated = $request->validate([
|
||||
'speakers' => ['nullable', 'array'],
|
||||
'speakers.*.name' => ['nullable', 'string', 'max:120'],
|
||||
'speakers.*.email' => ['required_with:speakers.*.name', 'email', 'max:190'],
|
||||
'speakers.*.role' => ['nullable', 'string', 'max:80'],
|
||||
'speakers.*.bio' => ['nullable', 'string', 'max:500'],
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->manager->update($event, [
|
||||
'speakers' => $validated['speakers'] ?? [],
|
||||
]);
|
||||
} catch (\RuntimeException $e) {
|
||||
return back()->withInput()->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('speakers.show', $event)
|
||||
->with('success', 'Speaker roster saved.');
|
||||
}
|
||||
|
||||
public function sendInvite(Request $request, QrCode $event): RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $event);
|
||||
abort_unless($event->type === QrCode::TYPE_EVENT, 404);
|
||||
|
||||
$validated = $request->validate([
|
||||
'email' => ['required', 'email', 'max:190'],
|
||||
]);
|
||||
|
||||
$owner = ladill_account() ?? $event->user;
|
||||
abort_unless($owner, 500);
|
||||
|
||||
$result = $this->invites->sendManualInvite($event, $owner, $validated['email']);
|
||||
|
||||
if (! $result['ok']) {
|
||||
return back()->with('error', $result['error']);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Speaker invitation sent.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Events;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Events\EventProgrammeService;
|
||||
use App\Services\Events\EventSpeakerInviteService;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class SpeakerPortalController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EventSpeakerInviteService $invites,
|
||||
private readonly EventProgrammeService $programmes,
|
||||
) {}
|
||||
|
||||
public function show(string $shortCode, string $token): View
|
||||
{
|
||||
$resolved = $this->invites->resolvePortal($shortCode, $token);
|
||||
abort_unless($resolved, 404);
|
||||
|
||||
$event = $resolved['event'];
|
||||
$speaker = $resolved['speaker'];
|
||||
$content = $event->content();
|
||||
$programme = null;
|
||||
$programmeSnapshot = null;
|
||||
|
||||
$programmeId = (int) ($content['programme_qr_id'] ?? 0);
|
||||
if ($programmeId > 0) {
|
||||
$programme = \App\Models\QrCode::query()
|
||||
->where('id', $programmeId)
|
||||
->where('type', \App\Models\QrCode::TYPE_ITINERARY)
|
||||
->first();
|
||||
$programmeSnapshot = $this->programmes->snapshot($programme);
|
||||
}
|
||||
|
||||
$token = (string) ($speaker['invite_token'] ?? '');
|
||||
$virtualSessions = collect((array) ($content['virtual_sessions'] ?? []))
|
||||
->filter(fn ($session) => is_array($session) && trim((string) ($session['join_url'] ?? '')) !== '')
|
||||
->map(function (array $session) use ($event, $token) {
|
||||
return [
|
||||
'title' => trim((string) ($session['title'] ?? 'Virtual session')),
|
||||
'type' => in_array($session['meet_room_type'] ?? '', ['webinar'], true) ? 'Webinar' : 'Conference',
|
||||
'scheduled_at' => $session['scheduled_at'] ?? null,
|
||||
'join_url' => $this->invites->speakerJoinUrl($event, $session, $token),
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return view('events.speaker-portal', [
|
||||
'event' => $event,
|
||||
'speaker' => $speaker,
|
||||
'eventContent' => $content,
|
||||
'programmeSnapshot' => $programmeSnapshot,
|
||||
'assignments' => $this->invites->assignmentsForSpeaker($event, $speaker),
|
||||
'virtualSessions' => $virtualSessions,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\QrCode;
|
||||
use App\Models\QrEventRegistration;
|
||||
use App\Services\Events\EventRegistrationService;
|
||||
use App\Support\LadillLink;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -29,9 +30,10 @@ class EventRegistrationController extends Controller
|
||||
'tier' => ['required', 'string', 'max:80'],
|
||||
'amount' => ['nullable', 'numeric', 'min:1', 'max:1000000'],
|
||||
'attendee_name' => ['required', 'string', 'max:120'],
|
||||
'attendee_email' => ['required', 'email', 'max:200'],
|
||||
'attendee_phone' => ['nullable', 'string', 'max:30'],
|
||||
'attendee_email' => ['nullable', 'email', 'max:200'],
|
||||
'attendee_phone' => ['required', 'string', 'max:30'],
|
||||
'badge_fields' => ['nullable', 'array'],
|
||||
'meet_return' => ['nullable', 'string', 'max:2048'],
|
||||
]);
|
||||
|
||||
try {
|
||||
@@ -43,11 +45,11 @@ class EventRegistrationController extends Controller
|
||||
return response()->json([
|
||||
'paid' => $result['paid'],
|
||||
'checkout_url' => $result['checkout_url'],
|
||||
'access_code' => $result['access_code'] ?? null,
|
||||
'public_key' => $result['public_key'] ?? null,
|
||||
'callback_url' => $result['callback_url'] ?? null,
|
||||
'badge_code' => $result['registration']->badge_code,
|
||||
'success_url' => route('qr.public.event.confirmed', [
|
||||
'shortCode' => $qrCode->short_code,
|
||||
'ref' => $result['registration']->reference,
|
||||
]),
|
||||
'success_url' => $this->confirmedUrl($qrCode, $result['registration'], $request->input('meet_return')),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -56,31 +58,55 @@ class EventRegistrationController extends Controller
|
||||
$reference = (string) $request->query('reference', '');
|
||||
|
||||
if ($reference === '') {
|
||||
return redirect('/q/' . $shortCode)->with('error', 'Missing payment reference.');
|
||||
return view('public.payment-return', [
|
||||
'redirect' => LadillLink::url($shortCode),
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$registration = $this->eventService->complete($reference);
|
||||
|
||||
return redirect()->route('qr.public.event.confirmed', [
|
||||
'shortCode' => $shortCode,
|
||||
'ref' => $registration->reference,
|
||||
return view('public.payment-return', [
|
||||
'redirect' => $this->confirmedUrl($registration->qrCode, $registration),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return redirect('/q/' . $shortCode)->with('order_error', 'Payment could not be verified. Reference: ' . $reference);
|
||||
return view('public.payment-return', [
|
||||
'redirect' => LadillLink::url($shortCode),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function confirmed(string $shortCode, string $ref): View
|
||||
public function confirmed(Request $request, string $shortCode, string $ref): View
|
||||
{
|
||||
$registration = QrEventRegistration::query()
|
||||
->where('reference', $ref)
|
||||
->whereHas('qrCode', fn ($q) => $q->where('short_code', $shortCode))
|
||||
->firstOrFail();
|
||||
|
||||
$meetReturn = (string) $request->query('meet_return', '');
|
||||
if ($meetReturn === '') {
|
||||
$meetReturn = (string) (($registration->metadata ?? [])['meet_return'] ?? '');
|
||||
}
|
||||
|
||||
return view('public.qr.event-confirmed', [
|
||||
'registration' => $registration,
|
||||
'qrCode' => $registration->qrCode,
|
||||
'meetReturn' => $meetReturn,
|
||||
]);
|
||||
}
|
||||
|
||||
private function confirmedUrl(QrCode $qrCode, QrEventRegistration $registration, ?string $meetReturn = null): string
|
||||
{
|
||||
$url = LadillLink::path($qrCode->short_code, 'registered/'.$registration->reference);
|
||||
$meetReturn = trim((string) ($meetReturn ?? ''));
|
||||
if ($meetReturn === '') {
|
||||
$meetReturn = trim((string) (($registration->metadata ?? [])['meet_return'] ?? ''));
|
||||
}
|
||||
|
||||
if ($meetReturn !== '') {
|
||||
$url .= '?meet_return='.urlencode($meetReturn);
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ class QrScanController extends Controller
|
||||
}
|
||||
|
||||
if ($qrCode->isDocumentType()) {
|
||||
return redirect()->route('qr.public.view', $shortCode);
|
||||
return redirect()->away($qrCode->publicPath('view'));
|
||||
}
|
||||
|
||||
if ($qrCode->isBookType()) {
|
||||
@@ -44,7 +44,10 @@ class QrScanController extends Controller
|
||||
}
|
||||
|
||||
if ($qrCode->usesLandingPage()) {
|
||||
return view('public.qr.landing', ['qrCode' => $qrCode]);
|
||||
return view('public.qr.landing', [
|
||||
'qrCode' => $qrCode,
|
||||
'meetReturn' => trim((string) $request->query('meet_return', '')),
|
||||
]);
|
||||
}
|
||||
|
||||
abort(404);
|
||||
@@ -62,7 +65,7 @@ class QrScanController extends Controller
|
||||
|
||||
return view('public.qr.document-viewer', [
|
||||
'qrCode' => $qrCode,
|
||||
'fileUrl' => route('qr.public.file', $shortCode),
|
||||
'fileUrl' => $qrCode->publicPath('file'),
|
||||
'allowDownload' => (bool) ($qrCode->content()['allow_download'] ?? true),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -3,18 +3,29 @@
|
||||
namespace App\Http\Controllers\Qr;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\PaymentGatewaySetting;
|
||||
use App\Models\QrSetting;
|
||||
use App\Services\Billing\BillingClient;
|
||||
use App\Services\Billing\PlatformEmailClient;
|
||||
use App\Services\Billing\PlatformSmsClient;
|
||||
use App\Services\Events\SubscriptionService;
|
||||
use App\Services\Payments\MerchantGatewayService;
|
||||
use App\Support\AccountBranding;
|
||||
use App\Support\Qr\QrCornerStyleCatalog;
|
||||
use App\Support\Qr\QrFrameStyleCatalog;
|
||||
use App\Support\Qr\QrModuleStyleCatalog;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AccountController extends Controller
|
||||
{
|
||||
public function __construct(private BillingClient $billing) {}
|
||||
public function __construct(
|
||||
private BillingClient $billing,
|
||||
private MerchantGatewayService $gateway,
|
||||
private SubscriptionService $subscriptions,
|
||||
) {}
|
||||
|
||||
private function topupUrl(): string
|
||||
{
|
||||
@@ -75,6 +86,13 @@ class AccountController extends Controller
|
||||
'cornerOuterStyles' => QrCornerStyleCatalog::outerStyles(),
|
||||
'cornerInnerStyles' => QrCornerStyleCatalog::innerStyles(),
|
||||
'frameStyles' => QrFrameStyleCatalog::visible(),
|
||||
'gateway' => $this->gateway->settingFor($account),
|
||||
'canUsePaymentGateway' => $this->subscriptions->canUsePaymentGateway($account),
|
||||
'messagingSettingsUrl' => function_exists('ladill_account_url')
|
||||
? ladill_account_url('/account/settings/messaging')
|
||||
: (string) config('smtp.messaging_settings_url', 'https://account.ladill.com/account/settings/messaging'),
|
||||
'suiteMessagingReady' => app(PlatformEmailClient::class)->isConfigured()
|
||||
|| app(PlatformSmsClient::class)->isConfigured(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -112,11 +130,35 @@ class AccountController extends Controller
|
||||
'default_style.gradient_color1' => ['nullable', 'regex:/^#[0-9a-fA-F]{6}$/'],
|
||||
'default_style.gradient_color2' => ['nullable', 'regex:/^#[0-9a-fA-F]{6}$/'],
|
||||
'default_style.gradient_rotation' => ['nullable', 'integer', 'min:0', 'max:360'],
|
||||
'logo' => ['nullable', 'image', 'mimes:jpeg,png,jpg,webp,svg', 'max:2048'],
|
||||
'remove_logo' => ['nullable', 'boolean'],
|
||||
'gateway_provider' => ['nullable', Rule::in([
|
||||
PaymentGatewaySetting::PROVIDER_PAYSTACK,
|
||||
PaymentGatewaySetting::PROVIDER_FLUTTERWAVE,
|
||||
PaymentGatewaySetting::PROVIDER_HUBTEL,
|
||||
'',
|
||||
])],
|
||||
'gateway_public_key' => ['nullable', 'string', 'max:2000'],
|
||||
'gateway_secret_key' => ['nullable', 'string', 'max:2000'],
|
||||
'gateway_webhook_secret' => ['nullable', 'string', 'max:2000'],
|
||||
'gateway_is_active' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
$eventDefaults = $data['event_defaults'] ?? [];
|
||||
$eventDefaults['registration_open'] = $request->boolean('event_defaults.registration_open');
|
||||
|
||||
$settings = $account->getOrCreateQrSetting();
|
||||
$logoPath = $settings->logo_path;
|
||||
|
||||
if ($request->boolean('remove_logo')) {
|
||||
AccountBranding::deleteStoredLogo($settings);
|
||||
$logoPath = null;
|
||||
}
|
||||
|
||||
if ($request->hasFile('logo')) {
|
||||
$logoPath = AccountBranding::storeLogo($settings, $request->file('logo'));
|
||||
}
|
||||
|
||||
QrSetting::updateOrCreate(
|
||||
['user_id' => $account->id],
|
||||
[
|
||||
@@ -127,9 +169,36 @@ class AccountController extends Controller
|
||||
'notify_payouts' => $request->boolean('notify_payouts'),
|
||||
'event_defaults' => QrSetting::sanitizeEventDefaults($eventDefaults),
|
||||
'default_style' => QrSetting::sanitizeDefaultStyle($data['default_style'] ?? null),
|
||||
'logo_path' => $logoPath,
|
||||
],
|
||||
);
|
||||
|
||||
$provider = (string) ($data['gateway_provider'] ?? '');
|
||||
if ($this->subscriptions->canUsePaymentGateway($account)) {
|
||||
$existing = PaymentGatewaySetting::query()->firstOrNew([
|
||||
'owner_ref' => $account->public_id,
|
||||
]);
|
||||
if ($provider === '' && ! $existing->exists) {
|
||||
return redirect()->route('account.settings')->with('success', 'Settings saved.');
|
||||
}
|
||||
if ($provider !== '') {
|
||||
$existing->provider = $provider;
|
||||
}
|
||||
$existing->is_active = $provider !== '' && $request->boolean('gateway_is_active');
|
||||
if (filled($data['gateway_public_key'] ?? null)) {
|
||||
$existing->public_key = $data['gateway_public_key'];
|
||||
}
|
||||
if (filled($data['gateway_secret_key'] ?? null)) {
|
||||
$existing->secret_key = $data['gateway_secret_key'];
|
||||
}
|
||||
if (array_key_exists('gateway_webhook_secret', $data) && $data['gateway_webhook_secret'] !== null) {
|
||||
$existing->webhook_secret = $data['gateway_webhook_secret'] !== ''
|
||||
? $data['gateway_webhook_secret']
|
||||
: null;
|
||||
}
|
||||
$existing->save();
|
||||
}
|
||||
|
||||
return redirect()->route('account.settings')->with('success', 'Settings saved.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Qr;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Messaging\CustomerSmsClient;
|
||||
use App\Services\Messaging\MessagingCredentialsService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class IntegrationsController extends Controller
|
||||
{
|
||||
public function edit(MessagingCredentialsService $credentials): View
|
||||
{
|
||||
$account = ladill_account();
|
||||
|
||||
return view('qr.account.integrations', [
|
||||
'account' => $account,
|
||||
'credential' => $credentials->forOwner((string) $account->public_id),
|
||||
'messagingSettingsUrl' => function_exists('ladill_account_url')
|
||||
? ladill_account_url('/account/settings/messaging')
|
||||
: (string) config('smtp.messaging_settings_url', 'https://account.ladill.com/account/settings/messaging'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function saveSms(Request $request, MessagingCredentialsService $credentials): RedirectResponse
|
||||
{
|
||||
$account = ladill_account();
|
||||
$ownerRef = (string) $account->public_id;
|
||||
|
||||
$data = $request->validate([
|
||||
'sms_api_key' => ['required', 'string', 'max:200'],
|
||||
'sms_sender_id' => ['required', 'string', 'max:11'],
|
||||
]);
|
||||
|
||||
$result = $credentials->validateAndSaveSms(
|
||||
$ownerRef,
|
||||
$data['sms_api_key'],
|
||||
$data['sms_sender_id'],
|
||||
);
|
||||
|
||||
if (! ($result['ok'] ?? false)) {
|
||||
return back()->withInput()->with('error', $result['error'] ?? 'Could not save SMS credentials.');
|
||||
}
|
||||
|
||||
return back()->with('success', 'SMS credentials saved.');
|
||||
}
|
||||
|
||||
public function disconnectSms(MessagingCredentialsService $credentials): RedirectResponse
|
||||
{
|
||||
$account = ladill_account();
|
||||
$credentials->disconnectSms((string) $account->public_id);
|
||||
|
||||
return back()->with('success', 'SMS disconnected.');
|
||||
}
|
||||
|
||||
public function saveBird(Request $request, MessagingCredentialsService $credentials): RedirectResponse
|
||||
{
|
||||
$account = ladill_account();
|
||||
$ownerRef = (string) $account->public_id;
|
||||
|
||||
$data = $request->validate([
|
||||
'bird_api_key' => ['required', 'string', 'max:200'],
|
||||
'bird_from_email' => ['required', 'email', 'max:255'],
|
||||
'bird_from_name' => ['nullable', 'string', 'max:100'],
|
||||
]);
|
||||
|
||||
$result = $credentials->validateAndSaveBird(
|
||||
$ownerRef,
|
||||
$data['bird_api_key'],
|
||||
$data['bird_from_email'],
|
||||
$data['bird_from_name'] ?? null,
|
||||
);
|
||||
|
||||
if (! ($result['ok'] ?? false)) {
|
||||
return back()->withInput()->with('error', $result['error'] ?? 'Could not save email credentials.');
|
||||
}
|
||||
|
||||
return back()->with('success', 'Email credentials saved.');
|
||||
}
|
||||
|
||||
public function disconnectBird(MessagingCredentialsService $credentials): RedirectResponse
|
||||
{
|
||||
$account = ladill_account();
|
||||
$credentials->disconnectBird((string) $account->public_id);
|
||||
|
||||
return back()->with('success', 'Email disconnected.');
|
||||
}
|
||||
|
||||
public function previewSenders(Request $request, CustomerSmsClient $sms): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'sms_api_key' => ['required', 'string', 'max:200'],
|
||||
]);
|
||||
|
||||
$result = $sms->senders(trim($data['sms_api_key']));
|
||||
if (! ($result['ok'] ?? false)) {
|
||||
return response()->json(['error' => $result['error'] ?? 'Could not load senders.'], 422);
|
||||
}
|
||||
|
||||
return response()->json($result['data'] ?? []);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use App\Models\QrCode;
|
||||
use App\Models\QrEventRegistration;
|
||||
use App\Models\QrWallet;
|
||||
use App\Services\Billing\BillingClient;
|
||||
use App\Services\Events\SubscriptionService;
|
||||
use App\Services\Qr\QrAnalyticsService;
|
||||
use App\Services\Qr\QrCodeManagerService;
|
||||
use App\Services\Qr\QrImageGeneratorService;
|
||||
@@ -37,6 +38,7 @@ class QrCodeController extends Controller
|
||||
private QrImageGeneratorService $imageGenerator,
|
||||
private QrPdfExporter $pdfExporter,
|
||||
private BillingClient $platformBilling,
|
||||
private SubscriptionService $subscriptions,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): View
|
||||
@@ -72,8 +74,15 @@ class QrCodeController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Request $request): View
|
||||
public function create(Request $request): View|RedirectResponse
|
||||
{
|
||||
$account = ladill_account() ?? $request->user();
|
||||
$requestedType = $request->query('type', QrCode::TYPE_EVENT);
|
||||
if ($account && ($requestedType === QrCode::TYPE_EVENT) && ! $this->subscriptions->canCreateEvent($account)) {
|
||||
return redirect()->route('events.pro.index')
|
||||
->with('upsell', 'Free plan includes up to '.config('events.free.max_live_events', 2).' live events. Upgrade for unlimited.');
|
||||
}
|
||||
|
||||
$account = ladill_account();
|
||||
$wallet = $this->manager->walletFor($account);
|
||||
$qrSettings = $account->getOrCreateQrSetting();
|
||||
@@ -82,6 +91,19 @@ class QrCodeController extends Controller
|
||||
$requestedType = QrCode::TYPE_EVENT;
|
||||
}
|
||||
|
||||
if ($requestedType === QrCode::TYPE_EVENT) {
|
||||
return view('events.create', [
|
||||
'pricePerQr' => QrWallet::pricePerQr(),
|
||||
'accountEventDefaults' => $qrSettings->resolvedEventDefaults(),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($requestedType === QrCode::TYPE_ITINERARY) {
|
||||
return view('events.programme-create', [
|
||||
'pricePerQr' => QrWallet::pricePerQr(),
|
||||
]);
|
||||
}
|
||||
|
||||
return view('qr-codes.create', [
|
||||
'wallet' => $wallet,
|
||||
'requestedType' => $requestedType,
|
||||
@@ -113,6 +135,13 @@ class QrCodeController extends Controller
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$account = ladill_account() ?? $request->user();
|
||||
$requestedType = (string) $request->input('type', QrCode::TYPE_EVENT);
|
||||
if ($account && $requestedType === QrCode::TYPE_EVENT && ! $this->subscriptions->canCreateEvent($account)) {
|
||||
return redirect()->route('events.pro.index')
|
||||
->with('upsell', 'Free plan includes up to '.config('events.free.max_live_events', 2).' live events. Upgrade for unlimited.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'label' => ['required', 'string', 'max:120'],
|
||||
'type' => ['required', 'in:' . implode(',', QrTypeCatalog::keys())],
|
||||
@@ -206,6 +235,31 @@ class QrCodeController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
$itineraryOptions = $qrCode->type === QrCode::TYPE_EVENT
|
||||
? $account->qrCodes()
|
||||
->where('type', QrCode::TYPE_ITINERARY)
|
||||
->orderBy('label')
|
||||
->get(['id', 'label'])
|
||||
: collect();
|
||||
|
||||
if ($qrCode->type === QrCode::TYPE_EVENT) {
|
||||
return view('events.show', [
|
||||
'qrCode' => $qrCode,
|
||||
'previewDataUri' => $previewDataUri,
|
||||
'itineraryOptions' => $itineraryOptions,
|
||||
'customDomains' => \App\Models\CustomDomain::where('qr_code_id', $qrCode->id)->get(),
|
||||
'customDomainsEnabled' => app(\App\Services\CustomDomain\CustomDomainService::class)->enabled(),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($qrCode->type === QrCode::TYPE_ITINERARY) {
|
||||
return view('events.programme-show', [
|
||||
'qrCode' => $qrCode,
|
||||
'previewDataUri' => $previewDataUri,
|
||||
'linkedEventSpeakers' => app(\App\Services\Events\EventProgrammeService::class)->speakerOptionsForProgramme($qrCode),
|
||||
]);
|
||||
}
|
||||
|
||||
return view('qr-codes.show', [
|
||||
'qrCode' => $qrCode,
|
||||
'previewDataUri' => $previewDataUri,
|
||||
@@ -226,6 +280,13 @@ class QrCodeController extends Controller
|
||||
'minTopup' => QrWallet::minTopupGhs(),
|
||||
'ladillWalletBalance' => $ladillWalletBalance,
|
||||
'topupUrl' => 'https://'.config('app.account_domain').'/wallet',
|
||||
'customDomains' => \App\Models\CustomDomain::where('qr_code_id', $qrCode->id)->get(),
|
||||
'customDomainsEnabled' => app(\App\Services\CustomDomain\CustomDomainService::class)->enabled(),
|
||||
'customDomainServerIp' => config('customdomain.server_ip'),
|
||||
'itineraryOptions' => $itineraryOptions,
|
||||
'linkedEventSpeakers' => $qrCode->type === QrCode::TYPE_ITINERARY
|
||||
? app(\App\Services\Events\EventProgrammeService::class)->speakerOptionsForProgramme($qrCode)
|
||||
: [],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -313,7 +374,7 @@ class QrCodeController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
$png = $this->imageGenerator->renderPng(QrCode::publicBaseUrl() . '/q/' . $shortCode, $style);
|
||||
$png = $this->imageGenerator->renderPng(\App\Support\LadillLink::url($shortCode), $style);
|
||||
|
||||
if ($tempLogoPath) {
|
||||
Storage::disk('qr')->delete($tempLogoPath);
|
||||
|
||||
@@ -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,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class AuthenticateService
|
||||
{
|
||||
public function handle(Request $request, Closure $next, string $namespace = 'events'): Response
|
||||
{
|
||||
$token = (string) $request->bearerToken();
|
||||
$caller = null;
|
||||
|
||||
if ($token !== '') {
|
||||
foreach ((array) config("{$namespace}.service_api_keys", []) as $name => $key) {
|
||||
if (is_string($key) && $key !== '' && hash_equals($key, $token)) {
|
||||
$caller = $name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($caller === null) {
|
||||
return response()->json(['error' => 'Unauthorized.'], 401);
|
||||
}
|
||||
|
||||
$request->attributes->set('service_caller', $caller);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -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,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Services\Events\SubscriptionService;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsurePro
|
||||
{
|
||||
public function __construct(private readonly SubscriptionService $subscriptions) {}
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
// Paid Events plans are retired — all authenticated users have full access.
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -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,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Support\LadillLink;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class RedirectLegacyQrToLadillLink
|
||||
{
|
||||
/** @var list<string> */
|
||||
private const STORAGE_ASSET_SUFFIXES = [
|
||||
'event-logo',
|
||||
'event-cover',
|
||||
'itinerary-cover',
|
||||
'business-logo',
|
||||
'business-cover',
|
||||
'menu-logo',
|
||||
'menu-cover',
|
||||
'church-logo',
|
||||
'church-cover',
|
||||
'book-cover',
|
||||
'vcard-avatar',
|
||||
'app-icon',
|
||||
'file',
|
||||
];
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! preg_match('#^q/[a-z0-9]#i', ltrim($request->path(), '/'))) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if (LadillLink::isInternalRequest($request)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Same-origin AJAX from Events-hosted pages must not 307 to ladl.link
|
||||
// (cross-origin fetch has no CORS → checkout sheet never opens).
|
||||
if ($request->ajax() || $request->expectsJson() || $request->wantsJson()) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if ($this->servesStoredAsset($request) || $this->servesRegistrationTransaction($request)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
return LadillLink::legacyRedirect($request);
|
||||
}
|
||||
|
||||
private function servesStoredAsset(Request $request): bool
|
||||
{
|
||||
$shortCode = $request->route('shortCode');
|
||||
if (! is_string($shortCode) || $shortCode === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$path = ltrim($request->path(), '/');
|
||||
$prefix = 'q/'.$shortCode.'/';
|
||||
if (! str_starts_with($path, $prefix)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$suffix = substr($path, strlen($prefix));
|
||||
|
||||
if (in_array($suffix, self::STORAGE_ASSET_SUFFIXES, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (bool) preg_match('#^(image/\d+|item-image/\d+/\d+)$#', $suffix);
|
||||
}
|
||||
|
||||
private function servesRegistrationTransaction(Request $request): bool
|
||||
{
|
||||
$shortCode = $request->route('shortCode');
|
||||
if (! is_string($shortCode) || $shortCode === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$path = ltrim($request->path(), '/');
|
||||
$prefix = 'q/'.$shortCode.'/';
|
||||
if (! str_starts_with($path, $prefix)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$suffix = substr($path, strlen($prefix));
|
||||
|
||||
return $suffix === 'register'
|
||||
|| str_starts_with($suffix, 'register/')
|
||||
|| str_starts_with($suffix, 'registered/')
|
||||
|| str_starts_with($suffix, 'speaker/');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* A customer's own domain (e.g. brand.com) pointed at one storefront (QrCode).
|
||||
* Visiting the domain serves that storefront's public page. DNS is verified to
|
||||
* resolve to the app, then Ladill Domains issues + installs the TLS cert.
|
||||
*/
|
||||
class CustomDomain extends Model
|
||||
{
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
public const STATUS_FAILED = 'failed';
|
||||
|
||||
protected $fillable = [
|
||||
'qr_code_id', 'user_id', 'host', 'include_www', 'status', 'ssl_status',
|
||||
'dns_verified_at', 'ssl_issued_at', 'ssl_expires_at', 'last_error',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'include_www' => 'boolean',
|
||||
'dns_verified_at' => 'datetime',
|
||||
'ssl_issued_at' => 'datetime',
|
||||
'ssl_expires_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function qrCode(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(QrCode::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function isLive(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_ACTIVE && $this->ssl_status === self::STATUS_ACTIVE;
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saving(function (CustomDomain $d) {
|
||||
$d->host = strtolower(trim((string) $d->host, " \t\n\r\0\x0B./"));
|
||||
$d->host = preg_replace('/^www\./', '', $d->host) ?: $d->host;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Events;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ProSubscription extends Model
|
||||
{
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
|
||||
public const STATUS_CANCELED = 'canceled';
|
||||
|
||||
public const STATUS_PAST_DUE = 'past_due';
|
||||
|
||||
public const PLAN_PRO = 'pro';
|
||||
|
||||
public const PLAN_ENTERPRISE = 'enterprise';
|
||||
|
||||
protected $table = 'events_pro_subscriptions';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id', 'status', 'plan', 'price_minor', 'currency', 'auto_renew',
|
||||
'started_at', 'current_period_end', 'last_charged_at', 'canceled_at',
|
||||
'last_reference', 'last_error',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'auto_renew' => 'boolean',
|
||||
'price_minor' => 'integer',
|
||||
'started_at' => 'datetime',
|
||||
'current_period_end' => 'datetime',
|
||||
'last_charged_at' => 'datetime',
|
||||
'canceled_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function entitled(): bool
|
||||
{
|
||||
return $this->status !== self::STATUS_PAST_DUE
|
||||
&& $this->current_period_end !== null
|
||||
&& $this->current_period_end->isFuture();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
|
||||
class MessagingCredential extends Model
|
||||
{
|
||||
public const STATUS_VALID = 'valid';
|
||||
|
||||
public const STATUS_INVALID = 'invalid';
|
||||
|
||||
protected $table = 'events_messaging_credentials';
|
||||
|
||||
protected $fillable = [
|
||||
'owner_ref',
|
||||
'sms_api_key_encrypted',
|
||||
'sms_api_key_prefix',
|
||||
'sms_sender_id',
|
||||
'sms_status',
|
||||
'sms_validated_at',
|
||||
'sms_last_error',
|
||||
'bird_api_key_encrypted',
|
||||
'bird_api_key_prefix',
|
||||
'bird_from_email',
|
||||
'bird_from_name',
|
||||
'bird_status',
|
||||
'bird_validated_at',
|
||||
'bird_last_error',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'sms_api_key_encrypted',
|
||||
'bird_api_key_encrypted',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'sms_validated_at' => 'datetime',
|
||||
'bird_validated_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function hasValidSms(): bool
|
||||
{
|
||||
return $this->sms_status === self::STATUS_VALID
|
||||
&& filled($this->sms_api_key_encrypted)
|
||||
&& filled($this->sms_sender_id);
|
||||
}
|
||||
|
||||
public function hasValidBird(): bool
|
||||
{
|
||||
return $this->bird_status === self::STATUS_VALID
|
||||
&& filled($this->bird_api_key_encrypted)
|
||||
&& filled($this->bird_from_email);
|
||||
}
|
||||
|
||||
public function smsApiKey(): ?string
|
||||
{
|
||||
if (! filled($this->sms_api_key_encrypted)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return Crypt::decryptString($this->sms_api_key_encrypted);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function birdApiKey(): ?string
|
||||
{
|
||||
if (! filled($this->bird_api_key_encrypted)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return Crypt::decryptString($this->bird_api_key_encrypted);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static function encryptKey(string $plain): string
|
||||
{
|
||||
return Crypt::encryptString($plain);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class PaymentGatewaySetting extends Model
|
||||
{
|
||||
public const PROVIDER_PAYSTACK = 'paystack';
|
||||
|
||||
public const PROVIDER_FLUTTERWAVE = 'flutterwave';
|
||||
|
||||
public const PROVIDER_HUBTEL = 'hubtel';
|
||||
|
||||
protected $fillable = [
|
||||
'owner_ref',
|
||||
'provider',
|
||||
'public_key',
|
||||
'secret_key',
|
||||
'webhook_secret',
|
||||
'is_active',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'public_key' => 'encrypted',
|
||||
'secret_key' => 'encrypted',
|
||||
'webhook_secret' => 'encrypted',
|
||||
'is_active' => 'boolean',
|
||||
'metadata' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
if (! $this->is_active) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$public = trim((string) $this->public_key);
|
||||
$secret = trim((string) $this->secret_key);
|
||||
|
||||
return match ($this->provider) {
|
||||
self::PROVIDER_PAYSTACK => str_starts_with($public, 'pk_') && str_starts_with($secret, 'sk_'),
|
||||
self::PROVIDER_FLUTTERWAVE, self::PROVIDER_HUBTEL => $public !== '' && $secret !== '',
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\LadillLink;
|
||||
use App\Support\Qr\QrStyleDefaults;
|
||||
use App\Support\Qr\QrTypeCatalog;
|
||||
use App\Support\Qr\QrWifiPayload;
|
||||
@@ -85,23 +86,19 @@ class QrCode extends Model
|
||||
|
||||
public function publicUrl(): string
|
||||
{
|
||||
return self::publicBaseUrl() . '/q/' . $this->short_code;
|
||||
return LadillLink::url($this->short_code);
|
||||
}
|
||||
|
||||
public function publicPath(string $suffix = ''): string
|
||||
{
|
||||
return $suffix === ''
|
||||
? $this->publicUrl()
|
||||
: LadillLink::path($this->short_code, $suffix);
|
||||
}
|
||||
|
||||
/**
|
||||
* Base URL for public QR links. Always the short platform domain
|
||||
* (ladill.com) — never the signed-in account/product host — so printed
|
||||
* codes and shared links stay short and host-independent of where the QR
|
||||
* was created.
|
||||
*/
|
||||
public static function publicBaseUrl(): string
|
||||
{
|
||||
$appUrl = (string) config('app.url');
|
||||
$scheme = parse_url($appUrl, PHP_URL_SCHEME) ?: 'https';
|
||||
$host = (string) config('app.platform_domain')
|
||||
?: (parse_url($appUrl, PHP_URL_HOST) ?: 'ladill.com');
|
||||
|
||||
return $scheme . '://' . $host;
|
||||
return LadillLink::baseUrl();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -55,6 +55,29 @@ class QrEventRegistration extends Model
|
||||
return round($this->amount_minor / 100, 2);
|
||||
}
|
||||
|
||||
public function platformFeeMinor(): int
|
||||
{
|
||||
$metadata = (array) ($this->metadata ?? []);
|
||||
|
||||
if (isset($metadata['ladill_pay']['platform_fee_minor'])) {
|
||||
return (int) $metadata['ladill_pay']['platform_fee_minor'];
|
||||
}
|
||||
|
||||
if (isset($metadata['platform_fee_ghs'])) {
|
||||
return (int) round((float) $metadata['platform_fee_ghs'] * 100);
|
||||
}
|
||||
|
||||
$mode = $this->qrCode?->content()['mode'] ?? 'ticketing';
|
||||
$rate = $mode === 'contributions' ? 0.035 : 0.055;
|
||||
|
||||
return (int) round($this->amount_minor * $rate);
|
||||
}
|
||||
|
||||
public function netAmountMinor(): int
|
||||
{
|
||||
return max(0, $this->amount_minor - $this->platformFeeMinor());
|
||||
}
|
||||
|
||||
public function isPaid(): bool
|
||||
{
|
||||
return $this->amount_minor > 0;
|
||||
|
||||
@@ -20,6 +20,7 @@ class QrSetting extends Model
|
||||
'default_type',
|
||||
'default_style',
|
||||
'event_defaults',
|
||||
'logo_path',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
@@ -42,6 +43,7 @@ class QrSetting extends Model
|
||||
'organizer',
|
||||
'registration_open',
|
||||
'badge_fields',
|
||||
'format',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -142,6 +144,9 @@ class QrSetting extends Model
|
||||
'organizer' => mb_substr(trim((string) ($input['organizer'] ?? '')), 0, 120),
|
||||
'registration_open' => filter_var($input['registration_open'] ?? true, FILTER_VALIDATE_BOOL),
|
||||
'badge_fields' => $badgeFields !== [] ? $badgeFields : ['Company', 'Role'],
|
||||
'format' => in_array($input['format'] ?? 'in_person', ['in_person', 'virtual', 'hybrid'], true)
|
||||
? ($input['format'] ?? 'in_person')
|
||||
: 'in_person',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,11 @@ namespace App\Providers;
|
||||
|
||||
use App\Models\QrCode;
|
||||
use App\Policies\QrCodePolicy;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use App\Services\Events\SubscriptionService;
|
||||
use App\Support\MobileTopbar;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
@@ -19,9 +20,17 @@ class AppServiceProvider extends ServiceProvider
|
||||
public function boot(): void
|
||||
{
|
||||
Gate::policy(QrCode::class, QrCodePolicy::class);
|
||||
|
||||
View::composer('partials.sidebar', function ($view) {
|
||||
$account = function_exists('ladill_account') ? (ladill_account() ?? auth()->user()) : auth()->user();
|
||||
$svc = app(SubscriptionService::class);
|
||||
$view->with('isPro', $account ? $svc->isPro($account) : false);
|
||||
$view->with('hasPaidPlan', $account ? $svc->hasPaidPlan($account) : false);
|
||||
$view->with('isEnterprise', $account ? $svc->isEnterprise($account) : false);
|
||||
});
|
||||
|
||||
View::composer(['partials.topbar', 'partials.topbar-qr'], function ($view) {
|
||||
$view->with(MobileTopbar::resolve());
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,36 +88,28 @@ class AfiaService
|
||||
{
|
||||
$ctx = collect($context)->map(fn ($v, $k) => "- {$k}: {$v}")->implode("\n");
|
||||
|
||||
return match ((string) config('afia.product', 'qr')) {
|
||||
'qr' => $this->qrSystemPrompt($ctx),
|
||||
default => $this->qrSystemPrompt($ctx),
|
||||
};
|
||||
return $this->eventsSystemPrompt($ctx);
|
||||
}
|
||||
|
||||
private function qrSystemPrompt(string $ctx): string
|
||||
private function eventsSystemPrompt(string $ctx): string
|
||||
{
|
||||
return <<<PROMPT
|
||||
You are Afia, the assistant inside Ladill QR Plus (qrplus.ladill.com).
|
||||
Help users create and manage dynamic QR codes, understand scans and billing, and print codes that work reliably.
|
||||
Be concise, friendly, and actionable: short numbered steps and name the screen or section to use.
|
||||
You are Afia, the assistant inside Ladill Events (events.ladill.com).
|
||||
Help users create events, sell tickets, and manage attendees. Be concise, friendly, and actionable: short numbered steps and name the screen or section to use.
|
||||
|
||||
What Ladill QR Plus does and where things live:
|
||||
- Overview (Dashboard): recent codes, active code count, scans in the last 30 days, wallet balance.
|
||||
- My Codes: list, create, and edit QR codes. Types: Link (URL), PDF, List of Links, Business profile, WiFi, App download.
|
||||
- Creating a code: pick a type, set content, customize style (colors, logo, frame), then download PNG/SVG/PDF.
|
||||
- Dynamic codes: destination can change after printing — the printed QR always points to ladill.com/q/{code}.
|
||||
- Business QR: name, tagline, contact, hours, logo, cover, social links — shown as a mobile landing page.
|
||||
- WiFi QR: guests scan to join; network name and password are encoded in the code.
|
||||
- PDF QR: hosts a PDF with optional download button on the viewer.
|
||||
- Billing: each new code debits the Ladill wallet (account portal → Wallet). Top up at account.ladill.com/wallet.
|
||||
- Team: invite teammates to manage codes together (sidebar → Team).
|
||||
- Developers: API tokens for programmatic code creation (sidebar → Developers).
|
||||
What Ladill Events does and where things live:
|
||||
- Overview (Dashboard): upcoming events, ticket sales, recent attendees, and wallet balance.
|
||||
- Events: create and manage events — set details, date, venue, and ticket types/prices, then publish and share the event link or QR.
|
||||
- Attendees: people holding tickets — view them and check them in (scan their ticket QR at the door).
|
||||
- Badges: design and print attendee badges.
|
||||
- Programmes: build the event schedule/agenda (sessions, times, speakers).
|
||||
- Payouts: ticket revenue lands in your Ladill wallet; request a withdrawal to your bank or mobile money from Payouts.
|
||||
- Account, Wallet & Team: manage billing, balance, and teammates from the account area.
|
||||
|
||||
Rules:
|
||||
- Only answer questions about Ladill QR Plus — code types, styling, downloads, scans, wallet billing, team, and API.
|
||||
- If asked about domains, hosting, email, or payments/commerce QR (shop, events, donations), briefly say those live in other Ladill apps and suggest the app launcher.
|
||||
- Never invent prices, short codes, or wallet balances — use the user context below or tell them where to check.
|
||||
- For print tips: recommend high contrast, adequate quiet zone, test-scan before mass printing.
|
||||
- Only answer questions about Ladill Events — events, tickets, attendees, check-in, badges, programmes, and payouts.
|
||||
- If asked about domains, hosting, email, plain QR codes, storefronts, donations, or payments, briefly say those live in other Ladill apps and suggest the app launcher.
|
||||
- Never invent prices, ticket numbers, payout amounts, or wallet balances — use the user context below or tell them where to check.
|
||||
|
||||
Current user context:
|
||||
{$ctx}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
namespace App\Services\Billing;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use App\Models\User;
|
||||
|
||||
/**
|
||||
* Client for the platform Billing HTTP API — the one UserWallet lives on the
|
||||
@@ -84,4 +86,150 @@ class BillingClient
|
||||
|
||||
return (array) $res->json();
|
||||
}
|
||||
|
||||
public function configured(): bool
|
||||
{
|
||||
return $this->base() !== '' && $this->token() !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ok: bool, insufficient: bool, balance_minor: ?int, error: ?string}
|
||||
*/
|
||||
public function charge(User $user, int $amountMinor, string $reference, string $description): array
|
||||
{
|
||||
if (! $this->configured()) {
|
||||
return ['ok' => false, 'insufficient' => false, 'balance_minor' => null, 'error' => 'Billing is not configured.'];
|
||||
}
|
||||
|
||||
try {
|
||||
$res = Http::withToken($this->token())
|
||||
->acceptJson()->timeout(20)
|
||||
->post($this->base().'/debit', [
|
||||
'user' => $user->public_id,
|
||||
'amount_minor' => $amountMinor,
|
||||
'service' => (string) config('billing.service', 'events'),
|
||||
'source' => 'subscription',
|
||||
'reference' => $reference,
|
||||
'description' => $description,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Events BillingClient: charge failed', ['user' => $user->public_id, 'error' => $e->getMessage()]);
|
||||
|
||||
return ['ok' => false, 'insufficient' => false, 'balance_minor' => null, 'error' => 'Could not reach the billing service.'];
|
||||
}
|
||||
|
||||
if ($res->status() === 402) {
|
||||
return ['ok' => false, 'insufficient' => true, 'balance_minor' => (int) $res->json('balance_minor', 0), 'error' => 'Insufficient wallet balance.'];
|
||||
}
|
||||
|
||||
if ($res->failed()) {
|
||||
return ['ok' => false, 'insufficient' => false, 'balance_minor' => null, 'error' => 'Billing error ('.$res->status().').'];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'insufficient' => false, 'balance_minor' => null, 'error' => null];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $metadata
|
||||
* @return array{checkout_url: string, reference: string}
|
||||
*/
|
||||
public function initiatePlanCheckout(
|
||||
User $user,
|
||||
string $plan,
|
||||
int $months,
|
||||
int $amountMinor,
|
||||
string $returnUrl,
|
||||
array $metadata = [],
|
||||
): array {
|
||||
$res = Http::withToken($this->token())
|
||||
->acceptJson()->timeout(15)
|
||||
->post($this->base().'/plan-checkout', [
|
||||
'user' => $user->public_id,
|
||||
'app' => (string) config('billing.service', 'events'),
|
||||
'plan' => $plan,
|
||||
'months' => $months,
|
||||
'amount_minor' => $amountMinor,
|
||||
'return_url' => $returnUrl,
|
||||
'metadata' => $metadata,
|
||||
]);
|
||||
$res->throw();
|
||||
|
||||
return [
|
||||
'checkout_url' => (string) $res->json('checkout_url'),
|
||||
'reference' => (string) $res->json('reference'),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function verifyPlanCheckout(string $reference): array
|
||||
{
|
||||
$res = Http::withToken($this->token())
|
||||
->acceptJson()->timeout(15)
|
||||
->post($this->base().'/plan-checkout/verify', [
|
||||
'reference' => $reference,
|
||||
]);
|
||||
|
||||
if ($res->status() === 422) {
|
||||
return ['paid' => false, 'error' => $res->json('error')];
|
||||
}
|
||||
$res->throw();
|
||||
|
||||
return $res->json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Write suite messaging plan entitlement (SoT for included email/SMS allowances).
|
||||
*
|
||||
* @param array{period_starts_at?: string, period_ends_at?: string|null, source?: string, metadata?: array} $options
|
||||
*/
|
||||
public function syncSuiteEntitlement(
|
||||
string $publicId,
|
||||
string $plan,
|
||||
string $status,
|
||||
string $reference,
|
||||
array $options = [],
|
||||
): bool {
|
||||
if (! $this->configured()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$app = (string) config('billing.service', 'events');
|
||||
$starts = $options['period_starts_at'] ?? now()->toIso8601String();
|
||||
$ends = array_key_exists('period_ends_at', $options)
|
||||
? $options['period_ends_at']
|
||||
: now()->addDays(30)->toIso8601String();
|
||||
|
||||
try {
|
||||
$res = Http::withToken($this->token())
|
||||
->acceptJson()
|
||||
->timeout(15)
|
||||
->post($this->base().'/suite-entitlements', array_filter([
|
||||
'user' => $publicId,
|
||||
'app' => $app,
|
||||
'plan' => $plan,
|
||||
'status' => $status,
|
||||
'period_starts_at' => $starts,
|
||||
'period_ends_at' => $ends,
|
||||
'source' => $options['source'] ?? 'wallet_debit',
|
||||
'reference' => $reference,
|
||||
'metadata' => $options['metadata'] ?? null,
|
||||
], static fn ($v) => $v !== null));
|
||||
|
||||
if ($res->failed()) {
|
||||
Log::warning('Events suite entitlement sync failed', [
|
||||
'user' => $publicId,
|
||||
'status' => $res->status(),
|
||||
'body' => $res->body(),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Events suite entitlement sync error', ['error' => $e->getMessage()]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Billing;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Platform suite email (zero-config): POST /api/smtp/messages/send with channel=suite.
|
||||
* No Bird API key or smtp_service_id required when platform messaging is enabled.
|
||||
*/
|
||||
class PlatformEmailClient
|
||||
{
|
||||
private ?string $lastError = null;
|
||||
|
||||
private ?string $lastErrorCode = null;
|
||||
|
||||
public function lastError(): ?string
|
||||
{
|
||||
return $this->lastError;
|
||||
}
|
||||
|
||||
public function lastErrorCode(): ?string
|
||||
{
|
||||
return $this->lastErrorCode;
|
||||
}
|
||||
|
||||
private function base(): string
|
||||
{
|
||||
return rtrim((string) config('smtp.platform_api_url', 'https://ladill.com/api/smtp'), '/');
|
||||
}
|
||||
|
||||
private function token(): string
|
||||
{
|
||||
return (string) config('smtp.platform_api_key', '');
|
||||
}
|
||||
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return $this->token() !== '';
|
||||
}
|
||||
|
||||
public function messagingSettingsUrl(): string
|
||||
{
|
||||
return (string) config(
|
||||
'smtp.messaging_settings_url',
|
||||
'https://account.ladill.com/account/settings/messaging?reason=messaging_mailbox_required&from=suite',
|
||||
);
|
||||
}
|
||||
|
||||
public function send(
|
||||
string $ownerPublicId,
|
||||
string $to,
|
||||
string $subject,
|
||||
string $html,
|
||||
?string $text = null,
|
||||
?string $fromName = null,
|
||||
?string $idempotencyKey = null,
|
||||
): bool {
|
||||
$this->lastError = null;
|
||||
$this->lastErrorCode = null;
|
||||
|
||||
if (! $this->isConfigured()) {
|
||||
$this->lastError = 'Outbound email is not configured on this Events instance.';
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$key = $idempotencyKey ?: ('events-email-'.Str::uuid());
|
||||
|
||||
try {
|
||||
$res = Http::withToken($this->token())
|
||||
->withHeaders(['Idempotency-Key' => $key])
|
||||
->acceptJson()
|
||||
->timeout(30)
|
||||
// from_name: pass the organizer/company display name. Do not default
|
||||
// to EVENTS_SMTP_FROM_NAME ("Ladill Events") — attendee mail is from the customer.
|
||||
->post($this->base().'/messages/send', array_filter([
|
||||
'user' => $ownerPublicId,
|
||||
'channel' => 'suite',
|
||||
'from_name' => $fromName,
|
||||
'to' => $to,
|
||||
'subject' => $subject,
|
||||
'html' => $html,
|
||||
'text' => $text,
|
||||
'reference' => $key,
|
||||
], static fn ($v) => $v !== null && $v !== ''));
|
||||
|
||||
if ($res->status() === 402) {
|
||||
$this->lastErrorCode = (string) ($res->json('error') ?: 'allowance_exceeded');
|
||||
$this->lastError = (string) ($res->json('error') ?: 'Email allowance exceeded. Upgrade your plan or top up your wallet.');
|
||||
Log::warning('Events suite email: allowance/balance', ['user' => $ownerPublicId, 'body' => $res->body()]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($res->failed()) {
|
||||
$code = (string) ($res->json('error') ?: 'send_failed');
|
||||
$this->lastErrorCode = $code;
|
||||
if ($code === 'messaging_mailbox_required') {
|
||||
$this->lastError = 'Set a Ladill mailbox for suite email in Account → Messaging settings: '.$this->messagingSettingsUrl();
|
||||
} else {
|
||||
$this->lastError = (string) ($res->json('error') ?: 'The email could not be sent. Please try again.');
|
||||
}
|
||||
Log::warning('Events suite email failed', ['status' => $res->status(), 'body' => $res->body()]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return (bool) ($res->json('success') ?? true);
|
||||
} catch (\Throwable $e) {
|
||||
$this->lastError = 'The email could not be sent. Please try again.';
|
||||
Log::warning('Events suite email error', ['error' => $e->getMessage()]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Billing;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Platform suite SMS (zero-config): POST /api/sms/messages/send with channel=suite.
|
||||
* No customer API key or sms_service_id required when platform messaging is enabled.
|
||||
*/
|
||||
class PlatformSmsClient
|
||||
{
|
||||
private ?string $lastError = null;
|
||||
|
||||
public function lastError(): ?string
|
||||
{
|
||||
return $this->lastError;
|
||||
}
|
||||
|
||||
private function base(): string
|
||||
{
|
||||
return rtrim((string) config('sms.platform_api_url', 'https://ladill.com/api'), '/');
|
||||
}
|
||||
|
||||
private function token(): string
|
||||
{
|
||||
return (string) config('sms.platform_api_key', '');
|
||||
}
|
||||
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return $this->token() !== '';
|
||||
}
|
||||
|
||||
public function send(
|
||||
string $ownerPublicId,
|
||||
string $to,
|
||||
string $message,
|
||||
?string $senderId = null,
|
||||
?string $idempotencyKey = null,
|
||||
): bool {
|
||||
$this->lastError = null;
|
||||
|
||||
if (! $this->isConfigured()) {
|
||||
$this->lastError = 'SMS is not configured on this Events instance.';
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$key = $idempotencyKey ?: ('events-sms-'.Str::uuid());
|
||||
|
||||
try {
|
||||
$res = Http::withToken($this->token())
|
||||
->withHeaders(['Idempotency-Key' => $key])
|
||||
->acceptJson()
|
||||
->timeout(30)
|
||||
->post($this->base().'/sms/messages/send', array_filter([
|
||||
'user' => $ownerPublicId,
|
||||
'channel' => 'suite',
|
||||
'to' => $to,
|
||||
'text' => $message,
|
||||
'sender_id' => $senderId ?? config('sms.default_sender_id'),
|
||||
'reference' => $key,
|
||||
], static fn ($v) => $v !== null && $v !== ''));
|
||||
|
||||
if ($res->status() === 402) {
|
||||
$this->lastError = (string) ($res->json('error') ?: 'SMS allowance exceeded. Upgrade your plan or top up your wallet.');
|
||||
Log::warning('Events suite SMS: allowance/balance', ['user' => $ownerPublicId]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($res->failed()) {
|
||||
$this->lastError = (string) ($res->json('error') ?: 'The SMS could not be sent.');
|
||||
Log::warning('Events suite SMS failed', ['status' => $res->status(), 'body' => $res->body()]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return (bool) ($res->json('success') ?? true);
|
||||
} catch (\Throwable $e) {
|
||||
$this->lastError = 'Could not reach the SMS service.';
|
||||
Log::warning('Events suite SMS error', ['error' => $e->getMessage()]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,23 +2,46 @@
|
||||
|
||||
namespace App\Services\Billing;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use App\Services\Messaging\CustomerSmsClient;
|
||||
use App\Services\Messaging\MessagingCredentialsService;
|
||||
|
||||
/**
|
||||
* Attendee SMS: prefer platform suite messaging (zero-config), fall back to
|
||||
* product SMS API keys when suite is unavailable.
|
||||
*/
|
||||
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'));
|
||||
private ?string $lastError = null;
|
||||
|
||||
if ($apiKey === '') {
|
||||
return;
|
||||
public function __construct(
|
||||
private readonly CustomerSmsClient $customer,
|
||||
private readonly MessagingCredentialsService $credentials,
|
||||
private readonly PlatformSmsClient $platformSms,
|
||||
) {}
|
||||
|
||||
public function lastError(): ?string
|
||||
{
|
||||
return $this->lastError;
|
||||
}
|
||||
|
||||
public function isConfiguredForOwner(string $ownerPublicId): bool
|
||||
{
|
||||
if ($this->platformSms->isConfigured()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->credentials->forOwner($ownerPublicId)->hasValidSms();
|
||||
}
|
||||
|
||||
public function send(string $ownerPublicId, string $to, string $message): bool
|
||||
{
|
||||
$this->lastError = null;
|
||||
|
||||
$phone = preg_replace('/\D+/', '', $to);
|
||||
if (strlen($phone) < 9) {
|
||||
return;
|
||||
$this->lastError = 'Enter a valid phone number.';
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (strlen($phone) === 9) {
|
||||
@@ -27,17 +50,53 @@ class SmsService
|
||||
$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()]);
|
||||
$credential = $this->credentials->forOwner($ownerPublicId);
|
||||
|
||||
if ($this->platformSms->isConfigured()) {
|
||||
$sender = $credential->sms_sender_id ?: null;
|
||||
$sent = $this->platformSms->send(
|
||||
$ownerPublicId,
|
||||
$phone,
|
||||
$message,
|
||||
$sender ? (string) $sender : null,
|
||||
);
|
||||
if ($sent) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$suiteError = $this->platformSms->lastError();
|
||||
if ($this->tryCustomerSms($credential, $phone, $message)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->lastError = $suiteError ?: $this->lastError;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->tryCustomerSms($credential, $phone, $message);
|
||||
}
|
||||
|
||||
private function tryCustomerSms(object $credential, string $phone, string $message): bool
|
||||
{
|
||||
if (! $credential->hasValidSms()) {
|
||||
$this->lastError = 'SMS is not available (platform SMS key missing and no product SMS credentials).';
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$apiKey = $credential->smsApiKey();
|
||||
if (! $apiKey) {
|
||||
$this->lastError = 'SMS credentials could not be decrypted. Reconnect SMS under Advanced keys.';
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$sent = $this->customer->send($apiKey, $phone, $message, (string) $credential->sms_sender_id);
|
||||
if (! $sent) {
|
||||
$this->lastError = $this->customer->lastError() ?: 'The SMS could not be sent.';
|
||||
}
|
||||
|
||||
return $sent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\CustomDomain;
|
||||
|
||||
use App\Models\CustomDomain;
|
||||
use App\Models\QrCode;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class CustomDomainService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DnsResolver $dns,
|
||||
private readonly DomainsSslClient $ssl,
|
||||
) {}
|
||||
|
||||
public function enabled(): bool
|
||||
{
|
||||
return (bool) config('customdomain.enabled', true) && $this->ssl->configured();
|
||||
}
|
||||
|
||||
/** Attach a custom domain to a storefront (pending DNS). */
|
||||
public function attach(QrCode $storefront, string $host, bool $includeWww = true): CustomDomain
|
||||
{
|
||||
return CustomDomain::create([
|
||||
'qr_code_id' => $storefront->id,
|
||||
'user_id' => $storefront->user_id,
|
||||
'host' => $host,
|
||||
'include_www' => $includeWww,
|
||||
'status' => CustomDomain::STATUS_PENDING,
|
||||
'ssl_status' => CustomDomain::STATUS_PENDING,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the domain's A record resolves to our app server, then ask Domains
|
||||
* to issue the certificate. Returns [ok, message].
|
||||
*
|
||||
* @return array{0:bool,1:string}
|
||||
*/
|
||||
public function verifyAndProvision(CustomDomain $domain): array
|
||||
{
|
||||
$serverIp = (string) config('customdomain.server_ip');
|
||||
$ips = $this->dns->aRecords($domain->host);
|
||||
|
||||
if (! in_array($serverIp, $ips, true)) {
|
||||
$domain->forceFill([
|
||||
'last_error' => 'DNS not pointing to '.$serverIp.' yet (found: '.(implode(', ', $ips) ?: 'none').').',
|
||||
])->save();
|
||||
|
||||
return [false, 'DNS not verified yet. Add an A record for '.$domain->host.' pointing to '.$serverIp.', then try again.'];
|
||||
}
|
||||
|
||||
$domain->forceFill(['dns_verified_at' => Carbon::now(), 'last_error' => null])->save();
|
||||
|
||||
$requested = $this->ssl->requestCertificate(
|
||||
$domain->host,
|
||||
$domain->include_www,
|
||||
route('api.ssl-callback'),
|
||||
);
|
||||
|
||||
if (! $requested) {
|
||||
$domain->forceFill(['last_error' => 'Could not reach the SSL service. Please try again shortly.'])->save();
|
||||
|
||||
return [false, 'Domain verified, but issuing the certificate failed. Please retry in a moment.'];
|
||||
}
|
||||
|
||||
return [true, 'Domain verified. Issuing your SSL certificate — this usually takes under a minute.'];
|
||||
}
|
||||
|
||||
/** Apply a signed SSL completion callback from Ladill Domains. */
|
||||
public function applyCallback(string $host, string $status, ?string $expiresAt, ?string $error): void
|
||||
{
|
||||
$domain = CustomDomain::where('host', strtolower(trim($host)))->first();
|
||||
if (! $domain) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($status === 'active') {
|
||||
$domain->forceFill([
|
||||
'ssl_status' => CustomDomain::STATUS_ACTIVE,
|
||||
'status' => CustomDomain::STATUS_ACTIVE,
|
||||
'ssl_issued_at' => Carbon::now(),
|
||||
'ssl_expires_at' => $expiresAt ? Carbon::parse($expiresAt) : null,
|
||||
'last_error' => null,
|
||||
])->save();
|
||||
} else {
|
||||
$domain->forceFill([
|
||||
'ssl_status' => CustomDomain::STATUS_FAILED,
|
||||
'status' => CustomDomain::STATUS_FAILED,
|
||||
'last_error' => $error ?: 'Certificate issuance failed.',
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve an incoming host to a live storefront, if any. */
|
||||
public function resolveStorefront(string $host): ?QrCode
|
||||
{
|
||||
$host = preg_replace('/^www\./', '', strtolower(trim($host)));
|
||||
$domain = CustomDomain::where('host', $host)->where('status', CustomDomain::STATUS_ACTIVE)->first();
|
||||
|
||||
return $domain?->qrCode;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\CustomDomain;
|
||||
|
||||
class DnsResolver
|
||||
{
|
||||
/** @return array<int,string> the A-record IPs for a host */
|
||||
public function aRecords(string $host): array
|
||||
{
|
||||
$records = @dns_get_record($host, DNS_A);
|
||||
if (! is_array($records)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(array_map(fn ($r) => $r['ip'] ?? null, $records)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\CustomDomain;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Thin client for Ladill Domains' central SSL API. Requests/queries certificates
|
||||
* for a custom domain on the shared app server (target = app).
|
||||
*/
|
||||
class DomainsSslClient
|
||||
{
|
||||
public function configured(): bool
|
||||
{
|
||||
return (string) config('customdomain.ssl_api_key', '') !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask Domains to issue + install a certificate for $host, calling back when done.
|
||||
*/
|
||||
public function requestCertificate(string $host, bool $includeWww, string $callbackUrl): bool
|
||||
{
|
||||
if (! $this->configured()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$res = Http::withToken((string) config('customdomain.ssl_api_key'))
|
||||
->acceptJson()->timeout(20)
|
||||
->post(config('customdomain.ssl_api_url').'/ssl/certificates', [
|
||||
'host' => $host,
|
||||
'target' => 'app',
|
||||
'include_www' => $includeWww,
|
||||
'callback_url' => $callbackUrl,
|
||||
'metadata' => ['nginx_include' => config('customdomain.nginx_include')],
|
||||
]);
|
||||
|
||||
if ($res->failed()) {
|
||||
Log::warning('DomainsSslClient: request failed', ['host' => $host, 'status' => $res->status()]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('DomainsSslClient: request error', ['host' => $host, 'error' => $e->getMessage()]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Register/update this domain in Ladill Domains' connected-domains registry (best-effort). */
|
||||
public function registerConnected(string $host, string $ownerPublicId, ?string $label = null, ?string $linkUrl = null): void
|
||||
{
|
||||
if (! $this->configured() || $ownerPublicId === '') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Http::withToken((string) config('customdomain.ssl_api_key'))->acceptJson()->timeout(10)
|
||||
->post(config('customdomain.ssl_api_url').'/connected-domains', array_filter([
|
||||
'host' => $host,
|
||||
'owner_public_id' => $ownerPublicId,
|
||||
'label' => $label,
|
||||
'link_url' => $linkUrl,
|
||||
]));
|
||||
} catch (\Throwable $e) {
|
||||
Log::info('DomainsSslClient: connected register skipped', ['host' => $host, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove this domain from the connected-domains registry (best-effort). */
|
||||
public function removeConnected(string $host): void
|
||||
{
|
||||
if (! $this->configured()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Http::withToken((string) config('customdomain.ssl_api_key'))->acceptJson()->timeout(10)
|
||||
->delete(config('customdomain.ssl_api_url').'/connected-domains/'.urlencode($host));
|
||||
} catch (\Throwable $e) {
|
||||
Log::info('DomainsSslClient: connected remove skipped', ['host' => $host, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Events;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Support\DemoWorld;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Reseed Events demo tenants after a fresh SSO login.
|
||||
*
|
||||
* Only owner demo accounts trigger a full reseed. Staff training logins
|
||||
* share the owner tenant but never wipe mid-demo.
|
||||
*/
|
||||
class DemoLoginReseeder
|
||||
{
|
||||
public function maybeResetAfterLogin(User $user): void
|
||||
{
|
||||
if (! (bool) config('demo_accounts.enabled', false)
|
||||
|| ! (bool) config('demo_accounts.reset_on_login', true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (DemoWorld::isStaffDemoEmail($user->email)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$plan = $this->planForEmail($user->email);
|
||||
if ($plan === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$seedUser = $this->resolveSeedUser($user);
|
||||
if (! $seedUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
$lockKey = 'demo-reseed:events:'.$seedUser->public_id;
|
||||
if (! Cache::add($lockKey, 1, 120)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$identity = (string) ($seedUser->email ?: $seedUser->public_id);
|
||||
|
||||
dispatch(function () use ($identity, $plan, $lockKey) {
|
||||
try {
|
||||
Artisan::call('demo:seed', [
|
||||
'identity' => $identity,
|
||||
'--plan' => $plan,
|
||||
'--reset' => true,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('demo_events_login_reseed_failed', [
|
||||
'identity' => $identity,
|
||||
'plan' => $plan,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
} finally {
|
||||
Cache::forget($lockKey);
|
||||
}
|
||||
})->afterResponse();
|
||||
}
|
||||
|
||||
private function planForEmail(?string $email): ?string
|
||||
{
|
||||
$email = strtolower(trim((string) $email));
|
||||
if ($email === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$accounts = (array) config('demo_accounts.accounts', []);
|
||||
if (isset($accounts[$email])) {
|
||||
return $accounts[$email];
|
||||
}
|
||||
|
||||
$members = (array) config('demo_accounts.member_emails', []);
|
||||
$ownerEmail = $members[$email] ?? null;
|
||||
|
||||
return $ownerEmail ? ($accounts[$ownerEmail] ?? null) : null;
|
||||
}
|
||||
|
||||
private function resolveSeedUser(User $user): ?User
|
||||
{
|
||||
$email = strtolower((string) $user->email);
|
||||
$ownerEmail = ((array) config('demo_accounts.member_emails', []))[$email] ?? null;
|
||||
if (! $ownerEmail) {
|
||||
return $user;
|
||||
}
|
||||
|
||||
return User::query()->where('email', $ownerEmail)->first() ?? $user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Events;
|
||||
|
||||
use App\Models\QrCode;
|
||||
use App\Models\QrEventRegistration;
|
||||
use App\Services\Billing\PlatformEmailClient;
|
||||
use App\Services\Billing\PlatformSmsClient;
|
||||
use App\Services\Billing\SmsService;
|
||||
use App\Services\Messaging\MessagingCredentialsService;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class EventCommsService
|
||||
{
|
||||
public const MODE_PROGRAMME = 'programme';
|
||||
public const MODE_JOIN = 'join';
|
||||
public const MODE_BOTH = 'both';
|
||||
|
||||
private const SMS_SEGMENT_CHARS = 160;
|
||||
private const SMS_PRICE_GHS = 0.03;
|
||||
private const EMAIL_PRICE_GHS = 0.0135;
|
||||
|
||||
public function __construct(
|
||||
private readonly EventEmailService $email,
|
||||
private readonly SmsService $sms,
|
||||
private readonly MessagingCredentialsService $credentials,
|
||||
private readonly EventProgrammeService $programmes,
|
||||
private readonly PlatformEmailClient $platformEmail,
|
||||
private readonly PlatformSmsClient $platformSms,
|
||||
) {}
|
||||
|
||||
/** @return array{recipients: int, emails: int, sms: int, estimated_ghs: float, affordable: bool, integrations_error: ?string} */
|
||||
public function preview(QrCode $event, string $mode): array
|
||||
{
|
||||
$regs = $this->confirmedRegistrations($event);
|
||||
$emails = $regs->filter(fn ($r) => (bool) $r->attendee_email)->count();
|
||||
$phones = $regs->filter(fn ($r) => (bool) $r->attendee_phone)->count();
|
||||
$smsSegments = $phones * $this->estimateSmsSegments($event, $mode);
|
||||
$estimated = round(($emails * self::EMAIL_PRICE_GHS) + ($smsSegments * self::SMS_PRICE_GHS), 2);
|
||||
$ownerRef = (string) $event->user->public_id;
|
||||
$integrationsError = $this->integrationsError($event, $mode, $emails, $phones);
|
||||
|
||||
return [
|
||||
'recipients' => $regs->count(),
|
||||
'emails' => $emails,
|
||||
'sms' => $phones,
|
||||
'estimated_ghs' => $estimated,
|
||||
'affordable' => $integrationsError === null,
|
||||
'integrations_error' => $integrationsError,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{emailed: int, texted: int, skipped: int}
|
||||
*/
|
||||
public function send(QrCode $event, string $mode): array
|
||||
{
|
||||
$preview = $this->preview($event, $mode);
|
||||
abort_if($preview['integrations_error'] !== null, 422, $preview['integrations_error']);
|
||||
|
||||
$programme = $this->programmeFor($event);
|
||||
$eventName = $event->content()['name'] ?? $event->label;
|
||||
$programmeUrl = $programme?->publicUrl();
|
||||
$joinUrl = $this->primaryJoinUrl($event);
|
||||
$ownerRef = (string) $event->user->public_id;
|
||||
$organizerEmail = $event->user->email;
|
||||
$organizerName = $event->user->name;
|
||||
|
||||
$emailed = 0;
|
||||
$texted = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($this->confirmedRegistrations($event) as $reg) {
|
||||
$sent = false;
|
||||
|
||||
if ($reg->attendee_email && in_array($mode, [self::MODE_PROGRAMME, self::MODE_BOTH], true) && $programmeUrl) {
|
||||
if ($mode === self::MODE_BOTH) {
|
||||
$sent = $this->email->sendJoinAndProgramme($ownerRef, $reg->attendee_email, $eventName, $joinUrl, $programmeUrl, $reg->attendee_name, $organizerEmail, $organizerName);
|
||||
} else {
|
||||
$sent = $this->email->sendProgrammeShare($ownerRef, $reg->attendee_email, $eventName, $programmeUrl, $reg->attendee_name, $organizerEmail, $organizerName);
|
||||
}
|
||||
if ($sent) {
|
||||
$emailed++;
|
||||
}
|
||||
} elseif ($reg->attendee_email && $mode === self::MODE_JOIN && $joinUrl !== '') {
|
||||
$sent = $this->email->sendJoinLink($ownerRef, $reg->attendee_email, $eventName, $joinUrl, $reg->attendee_name, $organizerEmail, $organizerName);
|
||||
if ($sent) {
|
||||
$emailed++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($reg->attendee_phone) {
|
||||
$message = $this->smsBody($reg, $eventName, $mode, $programmeUrl, $joinUrl);
|
||||
if ($message !== '' && $this->sms->send($ownerRef, $reg->attendee_phone, $message)) {
|
||||
$texted++;
|
||||
$sent = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $sent) {
|
||||
$skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
return compact('emailed', 'texted', 'skipped');
|
||||
}
|
||||
|
||||
/** @return Collection<int, QrEventRegistration> */
|
||||
private function confirmedRegistrations(QrCode $event): Collection
|
||||
{
|
||||
return $event->eventRegistrations()
|
||||
->where('status', QrEventRegistration::STATUS_CONFIRMED)
|
||||
->get();
|
||||
}
|
||||
|
||||
private function programmeFor(QrCode $event): ?QrCode
|
||||
{
|
||||
$programmeId = (int) ($event->content()['programme_qr_id'] ?? 0);
|
||||
if ($programmeId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return QrCode::where('id', $programmeId)
|
||||
->where('user_id', $event->user_id)
|
||||
->where('type', QrCode::TYPE_ITINERARY)
|
||||
->first();
|
||||
}
|
||||
|
||||
public function primaryJoinUrl(QrCode $event): string
|
||||
{
|
||||
foreach ((array) ($event->content()['virtual_sessions'] ?? []) as $session) {
|
||||
if (! is_array($session)) {
|
||||
continue;
|
||||
}
|
||||
$url = trim((string) ($session['join_url'] ?? ''));
|
||||
if ($url !== '') {
|
||||
return $url;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private function smsBody(QrEventRegistration $reg, string $eventName, string $mode, ?string $programmeUrl, string $joinUrl): string
|
||||
{
|
||||
$first = explode(' ', $reg->attendee_name ?? 'there')[0];
|
||||
|
||||
return match ($mode) {
|
||||
self::MODE_PROGRAMME => $programmeUrl
|
||||
? sprintf('Hi %s, the programme for %s: %s', $first, $eventName, $programmeUrl)
|
||||
: '',
|
||||
self::MODE_JOIN => $joinUrl !== ''
|
||||
? sprintf('Hi %s, join %s here: %s', $first, $eventName, $joinUrl)
|
||||
: '',
|
||||
self::MODE_BOTH => ($programmeUrl && $joinUrl !== '')
|
||||
? sprintf('Hi %s, %s — programme: %s | join: %s', $first, $eventName, $programmeUrl, $joinUrl)
|
||||
: ($joinUrl !== '' ? sprintf('Hi %s, join %s: %s', $first, $eventName, $joinUrl) : ''),
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
|
||||
private function estimateSmsSegments(QrCode $event, string $mode): int
|
||||
{
|
||||
$sample = $this->smsBody(
|
||||
new QrEventRegistration(['attendee_name' => 'Guest']),
|
||||
$event->content()['name'] ?? $event->label,
|
||||
$mode,
|
||||
$this->programmeFor($event)?->publicUrl(),
|
||||
$this->primaryJoinUrl($event),
|
||||
);
|
||||
|
||||
return max(1, (int) ceil(strlen($sample) / self::SMS_SEGMENT_CHARS));
|
||||
}
|
||||
|
||||
private function integrationsError(QrCode $event, string $mode, int $emails, int $phones): ?string
|
||||
{
|
||||
$ownerRef = (string) $event->user->public_id;
|
||||
$credential = $this->credentials->forOwner($ownerRef);
|
||||
$programmeUrl = $this->programmeFor($event)?->publicUrl();
|
||||
$joinUrl = $this->primaryJoinUrl($event);
|
||||
|
||||
$needsEmail = $emails > 0 && (
|
||||
(in_array($mode, [self::MODE_PROGRAMME, self::MODE_BOTH], true) && $programmeUrl)
|
||||
|| ($mode === self::MODE_JOIN && $joinUrl !== '')
|
||||
);
|
||||
|
||||
$needsSms = $phones > 0 && $this->smsBody(
|
||||
new QrEventRegistration(['attendee_name' => 'Guest']),
|
||||
$event->content()['name'] ?? $event->label,
|
||||
$mode,
|
||||
$programmeUrl,
|
||||
$joinUrl,
|
||||
) !== '';
|
||||
|
||||
$emailReady = $this->platformEmail->isConfigured() || $credential->hasValidBird();
|
||||
$smsReady = $this->platformSms->isConfigured() || $credential->hasValidSms();
|
||||
|
||||
if ($needsEmail && ! $emailReady) {
|
||||
return 'Email is not available yet. Bind a mailbox under Account → Messaging once platform keys are configured, or connect Bird under Advanced keys.';
|
||||
}
|
||||
|
||||
if ($needsSms && ! $smsReady) {
|
||||
return 'SMS is not available yet. Ensure platform SMS is configured on this Events instance, or connect a product SMS key under Advanced keys.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Events;
|
||||
|
||||
use App\Support\AccountBranding;
|
||||
use Illuminate\Support\Facades\View;
|
||||
|
||||
class EventEmailService
|
||||
{
|
||||
public function __construct(private readonly EventMailer $mailer) {}
|
||||
|
||||
public function lastError(): ?string
|
||||
{
|
||||
return $this->mailer->lastError();
|
||||
}
|
||||
|
||||
public function sendProgrammeShare(
|
||||
string $ownerPublicId,
|
||||
string $to,
|
||||
string $eventName,
|
||||
string $programmeUrl,
|
||||
?string $attendeeName = null,
|
||||
?string $organizerEmail = null,
|
||||
?string $organizerName = null,
|
||||
): bool {
|
||||
return $this->send(
|
||||
$ownerPublicId,
|
||||
$to,
|
||||
'Programme for '.$eventName,
|
||||
'mail.notifications.event-programme',
|
||||
compact('eventName', 'programmeUrl', 'attendeeName'),
|
||||
$organizerEmail,
|
||||
$organizerName,
|
||||
);
|
||||
}
|
||||
|
||||
public function sendJoinLink(
|
||||
string $ownerPublicId,
|
||||
string $to,
|
||||
string $eventName,
|
||||
string $joinUrl,
|
||||
?string $attendeeName = null,
|
||||
?string $organizerEmail = null,
|
||||
?string $organizerName = null,
|
||||
): bool {
|
||||
return $this->send(
|
||||
$ownerPublicId,
|
||||
$to,
|
||||
'Join '.$eventName,
|
||||
'mail.notifications.event-join',
|
||||
compact('eventName', 'joinUrl', 'attendeeName'),
|
||||
$organizerEmail,
|
||||
$organizerName,
|
||||
);
|
||||
}
|
||||
|
||||
public function sendJoinAndProgramme(
|
||||
string $ownerPublicId,
|
||||
string $to,
|
||||
string $eventName,
|
||||
string $joinUrl,
|
||||
string $programmeUrl,
|
||||
?string $attendeeName = null,
|
||||
?string $organizerEmail = null,
|
||||
?string $organizerName = null,
|
||||
): bool {
|
||||
return $this->send(
|
||||
$ownerPublicId,
|
||||
$to,
|
||||
$eventName.' — programme & join link',
|
||||
'mail.notifications.event-join-programme',
|
||||
compact('eventName', 'joinUrl', 'programmeUrl', 'attendeeName'),
|
||||
$organizerEmail,
|
||||
$organizerName,
|
||||
);
|
||||
}
|
||||
|
||||
public function sendRegistrationConfirmation(
|
||||
string $ownerPublicId,
|
||||
string $to,
|
||||
string $eventName,
|
||||
string $badgeCode,
|
||||
?string $joinUrl = null,
|
||||
?string $attendeeName = null,
|
||||
?string $organizerEmail = null,
|
||||
?string $organizerName = null,
|
||||
): bool {
|
||||
return $this->send(
|
||||
$ownerPublicId,
|
||||
$to,
|
||||
'Registration confirmed — '.$eventName,
|
||||
'mail.notifications.event-registration-confirmed',
|
||||
compact('eventName', 'badgeCode', 'joinUrl', 'attendeeName'),
|
||||
$organizerEmail,
|
||||
$organizerName,
|
||||
);
|
||||
}
|
||||
|
||||
public function sendSpeakerInvite(
|
||||
string $ownerPublicId,
|
||||
string $to,
|
||||
string $eventName,
|
||||
string $portalUrl,
|
||||
?string $speakerName = null,
|
||||
?string $organizerEmail = null,
|
||||
?string $organizerName = null,
|
||||
): bool {
|
||||
return $this->send(
|
||||
$ownerPublicId,
|
||||
$to,
|
||||
'Speaker invitation — '.$eventName,
|
||||
'mail.notifications.event-speaker-invite',
|
||||
[
|
||||
'eventName' => $eventName,
|
||||
'portalUrl' => $portalUrl,
|
||||
'speakerName' => $speakerName,
|
||||
],
|
||||
$organizerEmail,
|
||||
$organizerName,
|
||||
);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $viewData */
|
||||
private function send(
|
||||
string $ownerPublicId,
|
||||
string $to,
|
||||
string $subject,
|
||||
string $view,
|
||||
array $viewData,
|
||||
?string $replyToEmail = null,
|
||||
?string $replyToName = null,
|
||||
): bool {
|
||||
$brandingSettings = AccountBranding::forOwnerRef($ownerPublicId);
|
||||
$viewData['logoUrl'] = AccountBranding::emailLogoUrl($brandingSettings);
|
||||
$viewData['companyName'] = AccountBranding::companyName($brandingSettings);
|
||||
// From display: organizer name, else account/company branding — never "Ladill Events".
|
||||
$fromName = trim((string) ($replyToName ?: $viewData['companyName'] ?? '')) ?: null;
|
||||
|
||||
$html = View::make($view, $viewData)->render();
|
||||
$text = strip_tags(str_replace(['<br>', '<br/>', '<br />'], "\n", $html));
|
||||
|
||||
return $this->mailer->send(
|
||||
$ownerPublicId,
|
||||
$to,
|
||||
$subject,
|
||||
$html,
|
||||
$text,
|
||||
$replyToEmail,
|
||||
$replyToName,
|
||||
$fromName,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Events;
|
||||
|
||||
use App\Models\QrCode;
|
||||
use App\Models\User;
|
||||
use App\Services\Identity\IdentityClient;
|
||||
use App\Services\Integrations\MailCalendarClient;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class EventMailCalendarSyncService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MailCalendarClient $mail,
|
||||
private readonly IdentityClient $identity,
|
||||
) {}
|
||||
|
||||
public function sync(QrCode $event, User $owner): QrCode
|
||||
{
|
||||
if ($event->type !== QrCode::TYPE_EVENT || ! $this->mail->isConfigured()) {
|
||||
return $event;
|
||||
}
|
||||
|
||||
$mailbox = $this->resolveMailbox($owner);
|
||||
if ($mailbox === null) {
|
||||
return $event;
|
||||
}
|
||||
|
||||
$entries = $this->entriesFor($event);
|
||||
$previous = (array) data_get($event->payload, 'calendar_sync.items', []);
|
||||
$synced = [];
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
$result = $this->mail->upsertEvent(array_merge($entry, [
|
||||
'mailbox_email' => $mailbox,
|
||||
]));
|
||||
|
||||
if ($result && isset($result['id'])) {
|
||||
$synced[$entry['external_ref']] = (int) $result['id'];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($previous as $externalRef => $eventId) {
|
||||
if (! isset($synced[$externalRef]) && is_numeric($eventId)) {
|
||||
$this->mail->deleteEvent((int) $eventId, $mailbox, (string) $externalRef);
|
||||
}
|
||||
}
|
||||
|
||||
if ($entries === [] && $previous !== []) {
|
||||
foreach ($previous as $externalRef => $eventId) {
|
||||
if (is_numeric($eventId)) {
|
||||
$this->mail->deleteEvent((int) $eventId, $mailbox, (string) $externalRef);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$payload = (array) ($event->payload ?? []);
|
||||
$payload['calendar_sync'] = ['items' => $synced];
|
||||
$event->payload = $payload;
|
||||
$event->save();
|
||||
|
||||
Log::info('Events mail calendar synced', [
|
||||
'event_id' => $event->id,
|
||||
'entries' => count($synced),
|
||||
]);
|
||||
|
||||
return $event->fresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function entriesFor(QrCode $event): array
|
||||
{
|
||||
$content = $event->content();
|
||||
$format = (string) ($content['format'] ?? 'in_person');
|
||||
$sessions = array_values(array_filter(
|
||||
(array) ($content['virtual_sessions'] ?? []),
|
||||
fn ($session) => is_array($session) && trim((string) ($session['title'] ?? '')) !== '',
|
||||
));
|
||||
|
||||
$entries = [];
|
||||
|
||||
if (in_array($format, ['virtual', 'hybrid'], true) && $sessions !== []) {
|
||||
foreach ($sessions as $session) {
|
||||
$entry = $this->sessionEntry($event, $content, $session);
|
||||
if ($entry !== null) {
|
||||
$entries[] = $entry;
|
||||
}
|
||||
}
|
||||
|
||||
if ($entries === [] && filled($content['starts_at'] ?? null)) {
|
||||
$entries[] = $this->mainEventEntry($event, $content);
|
||||
} elseif ($format === 'hybrid' && filled($content['starts_at'] ?? null)) {
|
||||
$entries[] = $this->mainEventEntry($event, $content, ' (in person)');
|
||||
}
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
if (filled($content['starts_at'] ?? null)) {
|
||||
$entries[] = $this->mainEventEntry($event, $content);
|
||||
}
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $content
|
||||
* @param array<string, mixed> $session
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function sessionEntry(QrCode $event, array $content, array $session): ?array
|
||||
{
|
||||
$sessionId = trim((string) ($session['id'] ?? ''));
|
||||
if ($sessionId === '') {
|
||||
$sessionId = 'vs-'.Str::lower(Str::random(12));
|
||||
}
|
||||
|
||||
$starts = $this->parseDate($session['scheduled_at'] ?? $content['starts_at'] ?? null);
|
||||
if ($starts === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$duration = max(15, (int) ($session['duration_minutes'] ?? 60));
|
||||
$joinUrl = trim((string) ($session['join_url'] ?? ''));
|
||||
$description = trim((string) ($content['description'] ?? ''));
|
||||
|
||||
return [
|
||||
'external_ref' => $this->sessionExternalRef($event->id, $sessionId),
|
||||
'title' => trim((string) ($session['title'] ?? $content['name'] ?? $event->label)),
|
||||
'body' => $description !== '' ? $description : null,
|
||||
'starts_at' => $starts->toIso8601String(),
|
||||
'ends_at' => $starts->copy()->addMinutes($duration)->toIso8601String(),
|
||||
'join_url' => $joinUrl !== '' ? $joinUrl : $event->publicUrl(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $content
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function mainEventEntry(QrCode $event, array $content, string $titleSuffix = ''): array
|
||||
{
|
||||
$starts = $this->parseDate($content['starts_at'] ?? null) ?? now();
|
||||
$ends = $this->parseDate($content['ends_at'] ?? null) ?? $starts->copy()->addHours(2);
|
||||
$name = trim((string) ($content['name'] ?? $event->label));
|
||||
$location = trim((string) ($content['location'] ?? ''));
|
||||
$body = trim((string) ($content['description'] ?? ''));
|
||||
if ($location !== '') {
|
||||
$body = $body !== '' ? $body."\n\n".$location : $location;
|
||||
}
|
||||
|
||||
return [
|
||||
'external_ref' => $this->eventExternalRef($event->id),
|
||||
'title' => $name.$titleSuffix,
|
||||
'body' => $body !== '' ? $body : null,
|
||||
'starts_at' => $starts->toIso8601String(),
|
||||
'ends_at' => $ends->toIso8601String(),
|
||||
'join_url' => $event->publicUrl(),
|
||||
];
|
||||
}
|
||||
|
||||
private function eventExternalRef(int $eventId): string
|
||||
{
|
||||
return 'events:'.$eventId;
|
||||
}
|
||||
|
||||
private function sessionExternalRef(int $eventId, string $sessionId): string
|
||||
{
|
||||
return 'events:'.$eventId.':session:'.$sessionId;
|
||||
}
|
||||
|
||||
private function parseDate(mixed $value): ?CarbonInterface
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return Carbon::parse($value);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveMailbox(User $user): ?string
|
||||
{
|
||||
try {
|
||||
$status = $this->identity->mailboxLinkStatus($user->public_id);
|
||||
$linked = strtolower(trim((string) ($status['linked_mailbox'] ?? '')));
|
||||
if (filter_var($linked, FILTER_VALIDATE_EMAIL)) {
|
||||
return $linked;
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Fall back to the account email.
|
||||
}
|
||||
|
||||
$email = strtolower(trim((string) $user->email));
|
||||
|
||||
return filter_var($email, FILTER_VALIDATE_EMAIL) ? $email : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Events;
|
||||
|
||||
use App\Services\Billing\PlatformEmailClient;
|
||||
use App\Services\Messaging\CustomerEmailClient;
|
||||
use App\Services\Messaging\MessagingCredentialsService;
|
||||
use App\Support\AccountBranding;
|
||||
|
||||
/**
|
||||
* Attendee/speaker email: prefer platform suite messaging (zero-config mailbox),
|
||||
* fall back to customer Bird credentials when suite is unavailable.
|
||||
*/
|
||||
class EventMailer
|
||||
{
|
||||
private ?string $lastError = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly MessagingCredentialsService $credentials,
|
||||
private readonly CustomerEmailClient $customerEmail,
|
||||
private readonly PlatformEmailClient $platformEmail,
|
||||
) {}
|
||||
|
||||
public function lastError(): ?string
|
||||
{
|
||||
return $this->lastError;
|
||||
}
|
||||
|
||||
public function isConfiguredForOwner(string $ownerPublicId): bool
|
||||
{
|
||||
if ($this->platformEmail->isConfigured()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->credentials->forOwner($ownerPublicId)->hasValidBird();
|
||||
}
|
||||
|
||||
/** @deprecated Use isConfiguredForOwner() */
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return $this->platformEmail->isConfigured();
|
||||
}
|
||||
|
||||
public function send(
|
||||
string $ownerPublicId,
|
||||
string $to,
|
||||
string $subject,
|
||||
string $html,
|
||||
?string $text = null,
|
||||
?string $replyToEmail = null,
|
||||
?string $replyToName = null,
|
||||
?string $fromName = null,
|
||||
): bool {
|
||||
$this->lastError = null;
|
||||
$displayName = $this->resolveFromName($ownerPublicId, $fromName, $replyToName);
|
||||
|
||||
if ($this->platformEmail->isConfigured()) {
|
||||
$sent = $this->platformEmail->send($ownerPublicId, $to, $subject, $html, $text, $displayName);
|
||||
if ($sent) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Suite unavailable (mailbox, allowance) — try Bird power-user path next.
|
||||
$suiteError = $this->platformEmail->lastError();
|
||||
if ($this->tryCustomerBird($ownerPublicId, $to, $subject, $html, $text, $displayName)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->lastError = $suiteError ?: $this->lastError;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->tryCustomerBird($ownerPublicId, $to, $subject, $html, $text, $displayName);
|
||||
}
|
||||
|
||||
private function resolveFromName(string $ownerPublicId, ?string $fromName, ?string $replyToName): ?string
|
||||
{
|
||||
foreach ([$fromName, $replyToName] as $candidate) {
|
||||
$name = trim((string) $candidate);
|
||||
if ($name !== '') {
|
||||
return $name;
|
||||
}
|
||||
}
|
||||
|
||||
$settings = AccountBranding::forOwnerRef($ownerPublicId);
|
||||
$company = trim(AccountBranding::companyName($settings));
|
||||
|
||||
return $company !== '' && strcasecmp($company, 'Company') !== 0 ? $company : null;
|
||||
}
|
||||
|
||||
private function tryCustomerBird(
|
||||
string $ownerPublicId,
|
||||
string $to,
|
||||
string $subject,
|
||||
string $html,
|
||||
?string $text,
|
||||
?string $fromName = null,
|
||||
): bool {
|
||||
$credential = $this->credentials->forOwner($ownerPublicId);
|
||||
if (! $credential->hasValidBird()) {
|
||||
$this->lastError ??= 'Connect a Ladill mailbox in Account → Messaging, or connect Ladill Bird in Integrations.';
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$apiKey = $credential->birdApiKey();
|
||||
if (! $apiKey) {
|
||||
$this->lastError = 'Bird credentials could not be decrypted. Reconnect Bird in Integrations.';
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$sent = $this->customerEmail->send(
|
||||
$apiKey,
|
||||
(string) $credential->bird_from_email,
|
||||
$credential->bird_from_name ?: $fromName,
|
||||
$to,
|
||||
$subject,
|
||||
$html,
|
||||
$text,
|
||||
);
|
||||
|
||||
if (! $sent) {
|
||||
$this->lastError = $this->customerEmail->lastError() ?: 'The email could not be sent.';
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Events;
|
||||
|
||||
use App\Models\QrCode;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class EventProgrammeService
|
||||
{
|
||||
/** @return array{title: string, days: list<array<string, mixed>>}|null */
|
||||
public function snapshot(?QrCode $programme): ?array
|
||||
{
|
||||
if (! $programme || $programme->type !== QrCode::TYPE_ITINERARY) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$content = $programme->content();
|
||||
|
||||
return [
|
||||
'title' => (string) ($content['title'] ?? $programme->label),
|
||||
'subtitle' => (string) ($content['subtitle'] ?? ''),
|
||||
'location' => (string) ($content['location'] ?? ''),
|
||||
'days' => array_values((array) ($content['days'] ?? [])),
|
||||
];
|
||||
}
|
||||
|
||||
/** @param list<string> $itemRefs */
|
||||
public function itemsForRefs(?array $snapshot, array $itemRefs): array
|
||||
{
|
||||
if (! $snapshot) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$refs = array_flip($itemRefs);
|
||||
$matched = [];
|
||||
|
||||
foreach ((array) ($snapshot['days'] ?? []) as $day) {
|
||||
foreach ((array) ($day['items'] ?? []) as $item) {
|
||||
$ref = (string) ($item['ref'] ?? '');
|
||||
if ($ref !== '' && isset($refs[$ref])) {
|
||||
$matched[] = $item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $matched;
|
||||
}
|
||||
|
||||
/** @param list<array<string, mixed>> $items */
|
||||
public function hostsFromItems(array $items): array
|
||||
{
|
||||
return collect($items)
|
||||
->flatMap(function ($item) {
|
||||
if (! is_array($item)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$email = strtolower(trim((string) ($item['host_speaker_email'] ?? '')));
|
||||
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
return [$email];
|
||||
}
|
||||
|
||||
$host = trim((string) ($item['host'] ?? ''));
|
||||
if ($host !== '' && filter_var($host, FILTER_VALIDATE_EMAIL)) {
|
||||
return [$host];
|
||||
}
|
||||
|
||||
if ($host !== '') {
|
||||
return [$host];
|
||||
}
|
||||
|
||||
return [];
|
||||
})
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/** @return list<array{email: string, name: string}> */
|
||||
public function speakerOptionsForProgramme(QrCode $programme): array
|
||||
{
|
||||
$options = [];
|
||||
|
||||
foreach ($this->eventsLinkedToProgramme($programme) as $event) {
|
||||
foreach ((array) ($event->content()['speakers'] ?? []) as $speaker) {
|
||||
if (! is_array($speaker)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$email = trim((string) ($speaker['email'] ?? ''));
|
||||
$name = trim((string) ($speaker['name'] ?? ''));
|
||||
if ($email === '' || ! filter_var($email, FILTER_VALIDATE_EMAIL) || $name === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$options[strtolower($email)] = [
|
||||
'email' => $email,
|
||||
'name' => $name,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($options);
|
||||
}
|
||||
|
||||
/** @return Collection<int, QrCode> */
|
||||
public function eventsLinkedToProgramme(QrCode $programme): Collection
|
||||
{
|
||||
if ($programme->type !== QrCode::TYPE_ITINERARY) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return QrCode::query()
|
||||
->where('user_id', $programme->user_id)
|
||||
->where('type', QrCode::TYPE_EVENT)
|
||||
->get()
|
||||
->filter(fn (QrCode $event) => (int) ($event->content()['programme_qr_id'] ?? 0) === $programme->id);
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,11 @@ namespace App\Services\Events;
|
||||
|
||||
use App\Models\QrCode;
|
||||
use App\Models\QrEventRegistration;
|
||||
use App\Services\Billing\BillingClient;
|
||||
use App\Services\Billing\PaystackService;
|
||||
use App\Services\Billing\SmsService;
|
||||
use App\Services\Meet\EventMeetAccessService;
|
||||
use App\Services\Pay\PayClient;
|
||||
use App\Services\Payments\MerchantGatewayService;
|
||||
use App\Support\LadillLink;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
@@ -15,14 +16,16 @@ class EventRegistrationService
|
||||
{
|
||||
public function __construct(
|
||||
private PayClient $pay,
|
||||
private PaystackService $paystack,
|
||||
private BillingClient $billing,
|
||||
private MerchantGatewayService $gateway,
|
||||
private SubscriptionService $subscriptions,
|
||||
private SmsService $sms,
|
||||
private EventEmailService $email,
|
||||
private EventMeetAccessService $meetAccess,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Register an attendee. Free tiers confirm instantly; paid tiers return a
|
||||
* Paystack checkout URL and stay pending until payment completes.
|
||||
* Ladill Pay checkout URL and stay pending until payment completes.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
* @return array{registration: QrEventRegistration, paid: bool, checkout_url: ?string}
|
||||
@@ -93,6 +96,14 @@ class EventRegistrationService
|
||||
}
|
||||
$amountMinor = (int) round($priceGhs * 100);
|
||||
|
||||
$qrCode->loadMissing('user');
|
||||
if ($qrCode->user && ! $this->subscriptions->canAcceptTicket($qrCode->user)) {
|
||||
throw new RuntimeException('This organizer has reached the free monthly ticket limit. Ask them to upgrade Events Pro.');
|
||||
}
|
||||
|
||||
$meetReturn = trim((string) ($data['meet_return'] ?? ''));
|
||||
$metadata = $meetReturn !== '' ? ['meet_return' => $meetReturn] : null;
|
||||
|
||||
$registration = QrEventRegistration::create([
|
||||
'qr_code_id' => $qrCode->id,
|
||||
'user_id' => $qrCode->user_id,
|
||||
@@ -106,6 +117,7 @@ class EventRegistrationService
|
||||
'attendee_phone' => trim((string) ($data['attendee_phone'] ?? '')) ?: null,
|
||||
'badge_fields' => $badgeFields ?: null,
|
||||
'status' => $amountMinor > 0 ? QrEventRegistration::STATUS_PENDING : QrEventRegistration::STATUS_CONFIRMED,
|
||||
'metadata' => $metadata,
|
||||
]);
|
||||
|
||||
if ($amountMinor === 0) {
|
||||
@@ -114,50 +126,85 @@ class EventRegistrationService
|
||||
return ['registration' => $registration, 'paid' => false, 'checkout_url' => null];
|
||||
}
|
||||
|
||||
$qrCode->loadMissing('user');
|
||||
$feeTier = $mode === 'contributions' ? 'donations' : 'sales';
|
||||
$lineName = $mode === 'contributions'
|
||||
? sprintf('%s — %s', $content['name'] ?? $qrCode->label, $tierName)
|
||||
: sprintf('%s ticket — %s', $content['name'] ?? $qrCode->label, $tierName);
|
||||
|
||||
$feeTier = $mode === 'contributions' ? 'donations' : 'sales';
|
||||
$callbackUrl = LadillLink::path($qrCode->short_code, 'register/callback');
|
||||
|
||||
$usesOwnerGateway = $this->gateway->shouldUseOwnerGateway($qrCode->user);
|
||||
$payOrder = null;
|
||||
if ($usesOwnerGateway) {
|
||||
try {
|
||||
$payOrder = $this->gateway->initializeCheckout(
|
||||
$qrCode->user,
|
||||
$amountMinor,
|
||||
(string) ($content['currency'] ?? 'GHS'),
|
||||
(string) config('pay.mini_checkout_email', 'pay@ladill.com'),
|
||||
$callbackUrl,
|
||||
'EVTP-'.strtoupper(Str::random(16)),
|
||||
[
|
||||
'title' => $lineName,
|
||||
'registration_id' => $registration->id,
|
||||
'registration_reference' => $registration->reference,
|
||||
'qr_code_id' => $qrCode->id,
|
||||
'mode' => $mode,
|
||||
'attendee_email' => $email,
|
||||
],
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
$usesOwnerGateway = false;
|
||||
}
|
||||
}
|
||||
if (! $usesOwnerGateway) {
|
||||
$payOrder = $this->pay->createCheckout([
|
||||
'merchant' => $qrCode->user->public_id,
|
||||
'fee_tier' => $feeTier,
|
||||
'source_service' => 'events',
|
||||
'source_ref' => (string) $qrCode->id,
|
||||
'callback_url' => route('qr.public.event.callback', ['shortCode' => $qrCode->short_code]),
|
||||
'customer_name' => $registration->attendee_name,
|
||||
'callback_url' => $callbackUrl,
|
||||
'customer_name' => $name,
|
||||
'customer_email' => $email,
|
||||
'customer_phone' => $registration->attendee_phone,
|
||||
'line_items' => [
|
||||
[
|
||||
'line_items' => [[
|
||||
'name' => $lineName,
|
||||
'unit_price_minor' => $amountMinor,
|
||||
'quantity' => 1,
|
||||
],
|
||||
],
|
||||
]],
|
||||
'metadata' => [
|
||||
'registration_id' => $registration->id,
|
||||
'registration_reference' => $registration->reference,
|
||||
'qr_code_id' => $qrCode->id,
|
||||
'mode' => $mode,
|
||||
'attendee_email' => $email,
|
||||
],
|
||||
]);
|
||||
|
||||
$registration->update([
|
||||
'pay_order_id' => $payOrder['id'] ?? null,
|
||||
'payment_reference' => $payOrder['reference'],
|
||||
]);
|
||||
}
|
||||
|
||||
$checkoutUrl = (string) ($payOrder['checkout_url'] ?? '');
|
||||
if ($checkoutUrl === '') {
|
||||
throw new RuntimeException('Could not start checkout. Please try again.');
|
||||
}
|
||||
|
||||
$registration->update([
|
||||
'pay_order_id' => $usesOwnerGateway ? null : ($payOrder['id'] ?? null),
|
||||
'payment_reference' => $payOrder['reference'],
|
||||
'metadata' => array_merge((array) $registration->metadata, [
|
||||
($usesOwnerGateway ? 'merchant_gateway_init' : 'ladill_pay_init') => [
|
||||
'provider' => $payOrder['provider'] ?? 'ladill_pay',
|
||||
'platform_fee_minor' => $payOrder['platform_fee_minor'] ?? null,
|
||||
'merchant_amount_minor' => $payOrder['merchant_amount_minor'] ?? null,
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
return [
|
||||
'registration' => $registration->fresh(),
|
||||
'paid' => true,
|
||||
'checkout_url' => $checkoutUrl,
|
||||
'access_code' => isset($payOrder['access_code']) ? (string) $payOrder['access_code'] : null,
|
||||
'public_key' => isset($payOrder['public_key']) ? (string) $payOrder['public_key'] : null,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -167,25 +214,40 @@ class EventRegistrationService
|
||||
return $this->completeLadillPay($reference);
|
||||
}
|
||||
|
||||
return $this->completeLegacy($reference);
|
||||
return $this->completeLegacyGateway($reference);
|
||||
}
|
||||
|
||||
private function completeLadillPay(string $reference): QrEventRegistration
|
||||
{
|
||||
$registration = QrEventRegistration::where('payment_reference', $reference)
|
||||
->where('status', QrEventRegistration::STATUS_PENDING)
|
||||
->with('qrCode.user')
|
||||
->firstOrFail();
|
||||
|
||||
if ($registration->status === QrEventRegistration::STATUS_CONFIRMED) {
|
||||
return $registration;
|
||||
}
|
||||
|
||||
if ($registration->status !== QrEventRegistration::STATUS_PENDING) {
|
||||
throw new RuntimeException('This registration can no longer be completed.');
|
||||
}
|
||||
|
||||
$payOrder = $this->pay->verify($reference);
|
||||
$platformFeeGhs = ((int) ($payOrder['platform_fee_minor'] ?? 0)) / 100;
|
||||
|
||||
$verifiedMinor = (int) ($payOrder['amount_minor'] ?? 0);
|
||||
if ($verifiedMinor > 0 && $verifiedMinor < $registration->amount_minor) {
|
||||
$registration->update(['status' => QrEventRegistration::STATUS_FAILED]);
|
||||
throw new RuntimeException('Payment amount did not match the registration total.');
|
||||
}
|
||||
|
||||
$amountMinor = $verifiedMinor > 0 ? $verifiedMinor : $registration->amount_minor;
|
||||
|
||||
$registration->update([
|
||||
'status' => QrEventRegistration::STATUS_CONFIRMED,
|
||||
'paid_at' => now(),
|
||||
'pay_order_id' => $payOrder['id'] ?? $registration->pay_order_id,
|
||||
'amount_minor' => $amountMinor,
|
||||
'metadata' => array_merge((array) $registration->metadata, [
|
||||
'ladill_pay' => $payOrder,
|
||||
'platform_fee_ghs' => $platformFeeGhs,
|
||||
'platform_fee_ghs' => round(($payOrder['platform_fee_minor'] ?? 0) / 100, 2),
|
||||
]),
|
||||
]);
|
||||
|
||||
@@ -194,52 +256,41 @@ class EventRegistrationService
|
||||
return $registration;
|
||||
}
|
||||
|
||||
/** Legacy QREP-* references before Ladill Pay migration. */
|
||||
private function completeLegacy(string $reference): QrEventRegistration
|
||||
/** Legacy merchant-gateway references before Ladill Pay migration. */
|
||||
private function completeLegacyGateway(string $reference): QrEventRegistration
|
||||
{
|
||||
$registration = QrEventRegistration::where('payment_reference', $reference)
|
||||
->where('status', QrEventRegistration::STATUS_PENDING)
|
||||
->with('qrCode.user')
|
||||
->firstOrFail();
|
||||
|
||||
$data = $this->paystack->verifyTransaction($reference);
|
||||
|
||||
if (($data['status'] ?? '') !== 'success') {
|
||||
$owner = $registration->qrCode?->user ?? $registration->organizer;
|
||||
$result = $this->gateway->verify($owner, $reference);
|
||||
if (! $result['paid']) {
|
||||
$registration->update(['status' => QrEventRegistration::STATUS_FAILED]);
|
||||
throw new RuntimeException('Payment was not successful.');
|
||||
}
|
||||
|
||||
$feeRate = ($registration->qrCode?->content()['mode'] ?? 'ticketing') === 'contributions' ? 0.09 : 0.15;
|
||||
$paidAmount = round(($data['amount'] ?? 0) / 100, 2);
|
||||
$platformFee = round($paidAmount * $feeRate, 2);
|
||||
|
||||
$organizerAmountMinor = (int) round(($paidAmount - $platformFee) * 100);
|
||||
$verifiedMinor = (int) ($result['amount_minor'] ?: 0);
|
||||
if ($verifiedMinor > 0 && $verifiedMinor < $registration->amount_minor) {
|
||||
$registration->update(['status' => QrEventRegistration::STATUS_FAILED]);
|
||||
throw new RuntimeException('Payment amount did not match the registration total.');
|
||||
}
|
||||
|
||||
$registration->update([
|
||||
'status' => QrEventRegistration::STATUS_CONFIRMED,
|
||||
'paid_at' => now(),
|
||||
'amount_minor' => $verifiedMinor > 0 ? $verifiedMinor : $registration->amount_minor,
|
||||
'metadata' => array_merge((array) $registration->metadata, [
|
||||
'paystack' => $data,
|
||||
'platform_fee_ghs' => $platformFee,
|
||||
'legacy' => true,
|
||||
'merchant_gateway' => [
|
||||
'provider' => $result['provider'],
|
||||
'reference' => $result['reference'],
|
||||
'raw' => $result['raw'],
|
||||
],
|
||||
'platform_fee_ghs' => 0,
|
||||
]),
|
||||
]);
|
||||
|
||||
$mode = ($registration->qrCode->content()['mode'] ?? 'ticketing') === 'contributions' ? 'contributions' : 'ticketing';
|
||||
$this->billing->credit(
|
||||
$registration->organizer->public_id,
|
||||
$organizerAmountMinor,
|
||||
'events',
|
||||
'pay',
|
||||
$reference,
|
||||
$registration->id,
|
||||
sprintf(
|
||||
'%s — %s (%s)',
|
||||
$mode === 'contributions' ? 'Event contribution' : 'Event ticket',
|
||||
$registration->qrCode->label,
|
||||
$registration->tier_name
|
||||
),
|
||||
);
|
||||
|
||||
$this->notifyConfirmed($registration->fresh('qrCode'));
|
||||
|
||||
return $registration;
|
||||
@@ -247,14 +298,32 @@ class EventRegistrationService
|
||||
|
||||
private function notifyConfirmed(QrEventRegistration $registration): void
|
||||
{
|
||||
if (! $registration->attendee_phone) {
|
||||
$registration->loadMissing('qrCode.user');
|
||||
|
||||
$ownerPublicId = (string) $registration->qrCode?->user?->public_id;
|
||||
if ($ownerPublicId === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$content = $registration->qrCode?->content() ?? [];
|
||||
$eventName = $content['name'] ?? $registration->qrCode?->label ?? config('app.name');
|
||||
$firstName = explode(' ', $registration->attendee_name)[0];
|
||||
$joinUrl = $this->nextVirtualJoinUrl($content);
|
||||
|
||||
if ($registration->attendee_email) {
|
||||
$this->email->sendRegistrationConfirmation(
|
||||
$ownerPublicId,
|
||||
$registration->attendee_email,
|
||||
$eventName,
|
||||
$registration->badge_code,
|
||||
$joinUrl !== '' ? $joinUrl : null,
|
||||
$registration->attendee_name,
|
||||
$registration->qrCode?->user?->email,
|
||||
$registration->qrCode?->user?->name,
|
||||
);
|
||||
}
|
||||
|
||||
if ($registration->attendee_phone) {
|
||||
if (($content['mode'] ?? 'ticketing') === 'contributions') {
|
||||
$message = sprintf(
|
||||
'Hi %s, thank you for your %s of %s %s to %s. Ref: %s',
|
||||
@@ -275,7 +344,40 @@ class EventRegistrationService
|
||||
);
|
||||
}
|
||||
|
||||
$this->sms->send($registration->attendee_phone, $message);
|
||||
if ($joinUrl !== '') {
|
||||
$message .= ' Join: '.$joinUrl;
|
||||
}
|
||||
|
||||
$this->sms->send(
|
||||
$ownerPublicId,
|
||||
$registration->attendee_phone,
|
||||
$message,
|
||||
);
|
||||
}
|
||||
|
||||
$this->meetAccess->syncRegistration($registration->fresh());
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $content */
|
||||
private function nextVirtualJoinUrl(array $content): string
|
||||
{
|
||||
$format = (string) ($content['format'] ?? 'in_person');
|
||||
if (! in_array($format, ['virtual', 'hybrid'], true)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
foreach ((array) ($content['virtual_sessions'] ?? []) as $session) {
|
||||
if (! is_array($session)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$joinUrl = trim((string) ($session['join_url'] ?? ''));
|
||||
if ($joinUrl !== '') {
|
||||
return $joinUrl;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private function uniqueBadgeCode(): string
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Events;
|
||||
|
||||
use App\Models\QrCode;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class EventSpeakerInviteService
|
||||
{
|
||||
public const SOURCE_PROGRAMME = 'programme';
|
||||
|
||||
public const SOURCE_MANUAL = 'manual';
|
||||
|
||||
public function __construct(
|
||||
private readonly EventEmailService $email,
|
||||
private readonly EventProgrammeService $programmes,
|
||||
private readonly EventSpeakerService $speakers,
|
||||
) {}
|
||||
|
||||
/** @return list<array<string, mixed>> */
|
||||
public function rosterWithInviteState(QrCode $event): array
|
||||
{
|
||||
return collect((array) ($event->content()['speakers'] ?? []))
|
||||
->map(fn ($speaker) => $this->normalizeSpeakerRow(is_array($speaker) ? $speaker : []))
|
||||
->filter(fn ($speaker) => ($speaker['name'] ?? '') !== '')
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function canSendManualInvite(QrCode $event, array $speaker): bool
|
||||
{
|
||||
return ($speaker['email'] ?? '') !== '';
|
||||
}
|
||||
|
||||
/** @return array{ok: true}|array{ok: false, error: string} */
|
||||
public function sendManualInvite(QrCode $event, User $owner, string $email): array
|
||||
{
|
||||
$email = strtolower(trim($email));
|
||||
$event = $event->fresh() ?? $event;
|
||||
$speaker = $this->findSpeakerByEmail($event, $email);
|
||||
|
||||
if ($speaker === null) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'error' => 'That speaker is not on the saved roster. Click Save speakers first, then send the invitation.',
|
||||
];
|
||||
}
|
||||
|
||||
if (! $this->canSendManualInvite($event, $speaker)) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'error' => 'Add a valid email address for this speaker before sending an invitation.',
|
||||
];
|
||||
}
|
||||
|
||||
return $this->deliverInvite($event, $owner, $speaker, self::SOURCE_MANUAL);
|
||||
}
|
||||
|
||||
public function inviteProgrammeHosts(QrCode $programme, User $owner): int
|
||||
{
|
||||
if ($programme->type !== QrCode::TYPE_ITINERARY) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$sent = 0;
|
||||
|
||||
foreach ($this->programmes->eventsLinkedToProgramme($programme) as $event) {
|
||||
$sent += $this->inviteHostsForEvent($event->fresh(), $owner, $programme);
|
||||
}
|
||||
|
||||
return $sent;
|
||||
}
|
||||
|
||||
public function inviteHostsForEvent(QrCode $event, User $owner, ?QrCode $programme = null): int
|
||||
{
|
||||
$programme ??= $this->speakers->linkedProgramme($event);
|
||||
if (! $programme) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$hostEmails = $this->hostSpeakerEmailsFromProgramme($programme);
|
||||
if ($hostEmails === []) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$sent = 0;
|
||||
$speakers = $this->rosterWithInviteState($event);
|
||||
|
||||
foreach ($speakers as $speaker) {
|
||||
$email = strtolower((string) ($speaker['email'] ?? ''));
|
||||
if ($email === '' || ! in_array($email, $hostEmails, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (($speaker['invite_source'] ?? '') === self::SOURCE_PROGRAMME && ! empty($speaker['invited_at'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->deliverInvite($event, $owner, $speaker, self::SOURCE_PROGRAMME)['ok']) {
|
||||
$sent++;
|
||||
}
|
||||
}
|
||||
|
||||
return $sent;
|
||||
}
|
||||
|
||||
/** @return array{event: QrCode, speaker: array<string, mixed>}|null */
|
||||
public function resolvePortal(string $shortCode, string $token): ?array
|
||||
{
|
||||
$token = trim($token);
|
||||
if ($token === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$event = QrCode::query()
|
||||
->where('short_code', $shortCode)
|
||||
->where('type', QrCode::TYPE_EVENT)
|
||||
->where('is_active', true)
|
||||
->first();
|
||||
|
||||
if (! $event) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($this->rosterWithInviteState($event) as $speaker) {
|
||||
if (hash_equals((string) ($speaker['invite_token'] ?? ''), $token)) {
|
||||
return ['event' => $event, 'speaker' => $speaker];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed>|null */
|
||||
public function verifyToken(QrCode $event, string $token): ?array
|
||||
{
|
||||
$token = trim($token);
|
||||
if ($token === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($this->rosterWithInviteState($event) as $speaker) {
|
||||
if (hash_equals((string) ($speaker['invite_token'] ?? ''), $token)) {
|
||||
return [
|
||||
'email' => $speaker['email'],
|
||||
'name' => $speaker['name'],
|
||||
'role' => $speaker['role'] ?? '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function portalUrl(QrCode $event, string $token): string
|
||||
{
|
||||
return $event->publicPath('speaker/'.$token);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $session */
|
||||
public function speakerJoinUrl(QrCode $event, array $session, string $token): ?string
|
||||
{
|
||||
$joinUrl = trim((string) ($session['join_url'] ?? ''));
|
||||
if ($joinUrl === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$separator = str_contains($joinUrl, '?') ? '&' : '?';
|
||||
|
||||
return $joinUrl.$separator.'speaker='.urlencode($token);
|
||||
}
|
||||
|
||||
/** @return list<array{session: string, day: string, time: string, location: string}> */
|
||||
public function assignmentsForSpeaker(QrCode $event, array $speaker): array
|
||||
{
|
||||
$programme = $this->speakers->linkedProgramme($event);
|
||||
$snapshot = $this->programmes->snapshot($programme);
|
||||
if (! $snapshot) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$email = strtolower((string) ($speaker['email'] ?? ''));
|
||||
$name = trim((string) ($speaker['name'] ?? ''));
|
||||
$assignments = [];
|
||||
|
||||
foreach ((array) ($snapshot['days'] ?? []) as $day) {
|
||||
$dayLabel = trim((string) ($day['date'] ?? $day['label'] ?? ''));
|
||||
foreach ((array) ($day['items'] ?? []) as $item) {
|
||||
if (! is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$hostEmail = strtolower(trim((string) ($item['host_speaker_email'] ?? '')));
|
||||
$hostName = trim((string) ($item['host'] ?? ''));
|
||||
|
||||
$matches = ($email !== '' && $hostEmail === $email)
|
||||
|| ($name !== '' && strcasecmp($hostName, $name) === 0);
|
||||
|
||||
if (! $matches) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$assignments[] = [
|
||||
'session' => trim((string) ($item['title'] ?? '')),
|
||||
'day' => $dayLabel,
|
||||
'time' => trim((string) ($item['time'] ?? '')),
|
||||
'location' => trim((string) ($item['location'] ?? '')),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $assignments;
|
||||
}
|
||||
|
||||
/** @return array{ok: true}|array{ok: false, error: string} */
|
||||
private function deliverInvite(QrCode $event, User $owner, array $speaker, string $source): array
|
||||
{
|
||||
$email = trim((string) ($speaker['email'] ?? ''));
|
||||
if ($email === '') {
|
||||
return ['ok' => false, 'error' => 'Add a valid email address for this speaker before sending an invitation.'];
|
||||
}
|
||||
|
||||
$ownerRef = trim((string) ($owner->public_id ?? ''));
|
||||
if ($ownerRef === '') {
|
||||
return ['ok' => false, 'error' => 'Could not resolve the event owner account for sending email.'];
|
||||
}
|
||||
|
||||
if (! app(EventMailer::class)->isConfiguredForOwner($ownerRef)) {
|
||||
return ['ok' => false, 'error' => 'Email is not available yet. Bind a mailbox under Account → Messaging once platform keys are configured, or connect Bird under Advanced keys.'];
|
||||
}
|
||||
|
||||
$token = (string) ($speaker['invite_token'] ?? '');
|
||||
if ($token === '') {
|
||||
$token = Str::random(48);
|
||||
}
|
||||
|
||||
$eventName = (string) ($event->content()['name'] ?? $event->label);
|
||||
$portalUrl = $this->portalUrl($event, $token);
|
||||
|
||||
$sent = $this->email->sendSpeakerInvite(
|
||||
$ownerRef,
|
||||
$email,
|
||||
$eventName,
|
||||
$portalUrl,
|
||||
$speaker['name'] ?? null,
|
||||
$owner->email,
|
||||
$owner->name,
|
||||
);
|
||||
|
||||
if (! $sent) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'error' => $this->email->lastError()
|
||||
?: 'The invitation email could not be sent. Check your Bird email balance and verified sending domain, then try again.',
|
||||
];
|
||||
}
|
||||
|
||||
$this->persistSpeakerInviteMeta($event, $email, $token, $source);
|
||||
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
/** @return array<string, mixed>|null */
|
||||
private function findSpeakerByEmail(QrCode $event, string $email): ?array
|
||||
{
|
||||
foreach ($this->rosterWithInviteState($event) as $speaker) {
|
||||
if (strcasecmp((string) ($speaker['email'] ?? ''), $email) === 0) {
|
||||
return $speaker;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function persistSpeakerInviteMeta(QrCode $event, string $email, string $token, string $source): void
|
||||
{
|
||||
$email = strtolower(trim($email));
|
||||
$content = $event->content();
|
||||
$speakers = [];
|
||||
|
||||
foreach ((array) ($content['speakers'] ?? []) as $row) {
|
||||
if (! is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rowEmail = strtolower(trim((string) ($row['email'] ?? '')));
|
||||
if ($rowEmail === $email) {
|
||||
$row['invite_token'] = $token;
|
||||
$row['invited_at'] = now()->toIso8601String();
|
||||
$row['invite_source'] = $source;
|
||||
}
|
||||
|
||||
$speakers[] = $row;
|
||||
}
|
||||
|
||||
$payload = (array) ($event->payload ?? []);
|
||||
$payload['content'] = array_merge($content, ['speakers' => $speakers]);
|
||||
$event->payload = $payload;
|
||||
$event->save();
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function hostSpeakerEmailsFromProgramme(QrCode $programme): array
|
||||
{
|
||||
$emails = [];
|
||||
|
||||
foreach ((array) ($programme->content()['days'] ?? []) as $day) {
|
||||
foreach ((array) ($day['items'] ?? []) as $item) {
|
||||
if (! is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$email = strtolower(trim((string) ($item['host_speaker_email'] ?? '')));
|
||||
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$emails[] = $email;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($emails));
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $speaker */
|
||||
private function normalizeSpeakerRow(array $speaker): array
|
||||
{
|
||||
$name = trim((string) ($speaker['name'] ?? ''));
|
||||
$email = trim((string) ($speaker['email'] ?? ''));
|
||||
|
||||
return [
|
||||
'name' => $name,
|
||||
'email' => filter_var($email, FILTER_VALIDATE_EMAIL) ? $email : '',
|
||||
'role' => trim((string) ($speaker['role'] ?? '')),
|
||||
'bio' => trim((string) ($speaker['bio'] ?? '')),
|
||||
'invite_token' => trim((string) ($speaker['invite_token'] ?? '')),
|
||||
'invited_at' => $speaker['invited_at'] ?? null,
|
||||
'invite_source' => $speaker['invite_source'] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Events;
|
||||
|
||||
use App\Models\QrCode;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class EventSpeakerService
|
||||
{
|
||||
public function __construct(private readonly EventProgrammeService $programmes) {}
|
||||
|
||||
public function linkedProgramme(QrCode $event): ?QrCode
|
||||
{
|
||||
if ($event->type !== QrCode::TYPE_EVENT) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$programmeId = (int) ($event->content()['programme_qr_id'] ?? 0);
|
||||
if ($programmeId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return QrCode::query()
|
||||
->where('id', $programmeId)
|
||||
->where('user_id', $event->user_id)
|
||||
->where('type', QrCode::TYPE_ITINERARY)
|
||||
->first();
|
||||
}
|
||||
|
||||
/** @return list<array{name: string, email: string, role: string, bio: string, source: string}> */
|
||||
public function rosterFor(QrCode $event): array
|
||||
{
|
||||
if ($event->type !== QrCode::TYPE_EVENT) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$content = $event->content();
|
||||
$roster = [];
|
||||
|
||||
foreach ((array) ($content['speakers'] ?? []) as $speaker) {
|
||||
if (! is_array($speaker)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = trim((string) ($speaker['name'] ?? ''));
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$roster[] = [
|
||||
'name' => $name,
|
||||
'email' => trim((string) ($speaker['email'] ?? '')),
|
||||
'role' => trim((string) ($speaker['role'] ?? '')),
|
||||
'bio' => trim((string) ($speaker['bio'] ?? '')),
|
||||
'source' => 'roster',
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($this->programmeAssignments($event) as $assignment) {
|
||||
$name = trim((string) ($assignment['name'] ?? ''));
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$exists = collect($roster)->contains(fn ($row) => strcasecmp($row['name'], $name) === 0);
|
||||
if ($exists) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$roster[] = [
|
||||
'name' => $name,
|
||||
'email' => '',
|
||||
'role' => trim((string) ($assignment['session'] ?? '')),
|
||||
'bio' => '',
|
||||
'source' => 'programme',
|
||||
];
|
||||
}
|
||||
|
||||
return $roster;
|
||||
}
|
||||
|
||||
/** @return list<array{name: string, session: string, day: string, time: string}> */
|
||||
public function programmeAssignments(QrCode $event): array
|
||||
{
|
||||
$programme = $this->linkedProgramme($event);
|
||||
$snapshot = $this->programmes->snapshot($programme);
|
||||
if (! $snapshot) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$assignments = [];
|
||||
|
||||
foreach ((array) ($snapshot['days'] ?? []) as $day) {
|
||||
$dayLabel = trim((string) ($day['date'] ?? $day['label'] ?? ''));
|
||||
foreach ((array) ($day['items'] ?? []) as $item) {
|
||||
if (! is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$host = trim((string) ($item['host'] ?? ''));
|
||||
if ($host === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$assignments[] = [
|
||||
'name' => $host,
|
||||
'session' => trim((string) ($item['title'] ?? '')),
|
||||
'day' => $dayLabel,
|
||||
'time' => trim((string) ($item['time'] ?? '')),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $assignments;
|
||||
}
|
||||
|
||||
/** @return Collection<int, QrCode> */
|
||||
public function eventsForAccount(int $userId, ?string $search = null): Collection
|
||||
{
|
||||
return QrCode::query()
|
||||
->where('user_id', $userId)
|
||||
->where('type', QrCode::TYPE_EVENT)
|
||||
->when($search !== null && $search !== '', fn ($q) => $q->where('label', 'like', "%{$search}%"))
|
||||
->latest()
|
||||
->get();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Events;
|
||||
|
||||
use App\Models\Events\ProSubscription;
|
||||
use App\Models\QrCode;
|
||||
use App\Models\QrEventRegistration;
|
||||
use App\Models\User;
|
||||
use App\Services\Billing\BillingClient;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class SubscriptionService
|
||||
{
|
||||
public function __construct(private readonly BillingClient $billing) {}
|
||||
|
||||
public function gatingActive(): bool
|
||||
{
|
||||
// Paid Events subscriptions are retired — product is free for all accounts.
|
||||
return false;
|
||||
}
|
||||
|
||||
public function priceMinor(): int
|
||||
{
|
||||
return (int) config('events.plans.pro.price_minor', config('events.pro.price_minor', 4900));
|
||||
}
|
||||
|
||||
public function enterprisePriceMinor(): int
|
||||
{
|
||||
return (int) config('events.plans.enterprise.price_minor', 14900);
|
||||
}
|
||||
|
||||
public function subscriptionFor(User $user): ?ProSubscription
|
||||
{
|
||||
if (! Schema::hasTable('events_pro_subscriptions')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ProSubscription::where('user_id', $user->id)->first();
|
||||
}
|
||||
|
||||
public function planKey(User $user): string
|
||||
{
|
||||
if (! $this->gatingActive()) {
|
||||
return ProSubscription::PLAN_PRO;
|
||||
}
|
||||
|
||||
$sub = $this->subscriptionFor($user);
|
||||
if (! $sub || ! $sub->entitled()) {
|
||||
return 'free';
|
||||
}
|
||||
|
||||
return $sub->plan === ProSubscription::PLAN_ENTERPRISE
|
||||
? ProSubscription::PLAN_ENTERPRISE
|
||||
: ProSubscription::PLAN_PRO;
|
||||
}
|
||||
|
||||
public function hasPaidPlan(User $user): bool
|
||||
{
|
||||
if (! $this->gatingActive()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (bool) $this->subscriptionFor($user)?->entitled();
|
||||
}
|
||||
|
||||
public function isEnterprise(User $user): bool
|
||||
{
|
||||
return $this->planKey($user) === ProSubscription::PLAN_ENTERPRISE;
|
||||
}
|
||||
|
||||
public function isPro(User $user): bool
|
||||
{
|
||||
return $this->hasPaidPlan($user);
|
||||
}
|
||||
|
||||
/** Custom / owner payment gateways are retired — Ladill Pay only. */
|
||||
public function canUsePaymentGateway(User $user): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function liveEventCount(User $user): int
|
||||
{
|
||||
return QrCode::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('type', QrCode::TYPE_EVENT)
|
||||
->count();
|
||||
}
|
||||
|
||||
public function canCreateEvent(User $user): bool
|
||||
{
|
||||
if ($this->isPro($user)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->liveEventCount($user) < (int) config('events.free.max_live_events', 2);
|
||||
}
|
||||
|
||||
public function ticketsThisMonth(User $user): int
|
||||
{
|
||||
return QrEventRegistration::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('status', QrEventRegistration::STATUS_CONFIRMED)
|
||||
->where('created_at', '>=', now()->startOfMonth())
|
||||
->count();
|
||||
}
|
||||
|
||||
public function canAcceptTicket(User $user): bool
|
||||
{
|
||||
if ($this->isPro($user)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->ticketsThisMonth($user) < (int) config('events.free.max_tickets_per_month', 100);
|
||||
}
|
||||
|
||||
/** @return array{0:bool,1:string} */
|
||||
public function subscribe(User $user): array
|
||||
{
|
||||
$existing = $this->subscriptionFor($user);
|
||||
if ($existing && $existing->entitled() && $existing->status === ProSubscription::STATUS_ACTIVE) {
|
||||
return [true, 'You already have an active subscription.'];
|
||||
}
|
||||
|
||||
if ($existing?->plan === ProSubscription::PLAN_ENTERPRISE) {
|
||||
return $this->subscribeEnterprise($user);
|
||||
}
|
||||
|
||||
return $this->activateWalletPlan($user, ProSubscription::PLAN_PRO, $this->priceMinor(), 'Ladill Events Pro — monthly subscription', 'Welcome to Ladill Events Pro!');
|
||||
}
|
||||
|
||||
/** @return array{0:bool,1:string} */
|
||||
public function subscribeEnterprise(User $user): array
|
||||
{
|
||||
$existing = $this->subscriptionFor($user);
|
||||
if ($existing && $existing->entitled() && $existing->status === ProSubscription::STATUS_ACTIVE) {
|
||||
return [true, 'You already have an active subscription.'];
|
||||
}
|
||||
|
||||
return $this->activateWalletPlan(
|
||||
$user,
|
||||
ProSubscription::PLAN_ENTERPRISE,
|
||||
$this->enterprisePriceMinor(),
|
||||
'Ladill Events Business — monthly subscription',
|
||||
'Welcome to Ladill Events Business!',
|
||||
);
|
||||
}
|
||||
|
||||
public function activatePrepaid(User $user, string $plan, int $months): void
|
||||
{
|
||||
$existing = $this->subscriptionFor($user);
|
||||
$price = $plan === ProSubscription::PLAN_ENTERPRISE
|
||||
? $this->enterprisePriceMinor()
|
||||
: $this->priceMinor();
|
||||
|
||||
$periodEnd = Carbon::now()->addMonths($months);
|
||||
$reference = 'events-prepaid-'.$plan.'-'.$user->id.'-'.now()->format('YmdHis');
|
||||
|
||||
ProSubscription::updateOrCreate(
|
||||
['user_id' => $user->id],
|
||||
[
|
||||
'plan' => $plan,
|
||||
'status' => ProSubscription::STATUS_ACTIVE,
|
||||
'price_minor' => $price,
|
||||
'currency' => (string) config('events.pro.currency', 'GHS'),
|
||||
'auto_renew' => false,
|
||||
'started_at' => $existing?->started_at ?? now(),
|
||||
'current_period_end' => $periodEnd,
|
||||
'last_charged_at' => now(),
|
||||
'canceled_at' => null,
|
||||
'last_reference' => $reference,
|
||||
'last_error' => null,
|
||||
],
|
||||
);
|
||||
|
||||
// Prepaid checkout already writes entitlements on the platform; this is a repair/sync.
|
||||
$this->syncMessagingEntitlement($user, $plan, ProSubscription::STATUS_ACTIVE, $reference, $periodEnd, 'checkout');
|
||||
}
|
||||
|
||||
/** @return array{0:bool,1:string} */
|
||||
public function cancel(User $user): array
|
||||
{
|
||||
$sub = $this->subscriptionFor($user);
|
||||
if (! $sub || ! $sub->entitled()) {
|
||||
return [false, 'You do not have an active subscription.'];
|
||||
}
|
||||
|
||||
$sub->forceFill([
|
||||
'status' => ProSubscription::STATUS_CANCELED,
|
||||
'auto_renew' => false,
|
||||
'canceled_at' => now(),
|
||||
])->save();
|
||||
|
||||
$until = optional($sub->current_period_end)->format('d M Y');
|
||||
|
||||
return [true, "Auto-renew is off. You keep access until {$until}."];
|
||||
}
|
||||
|
||||
public function renewIfDue(ProSubscription $sub): void
|
||||
{
|
||||
if (! $sub->auto_renew || $sub->status !== ProSubscription::STATUS_ACTIVE) {
|
||||
return;
|
||||
}
|
||||
if ($sub->current_period_end && $sub->current_period_end->isFuture()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user = $sub->user;
|
||||
if (! $user) {
|
||||
return;
|
||||
}
|
||||
|
||||
$planLabel = $sub->plan === ProSubscription::PLAN_ENTERPRISE ? 'Business' : 'Pro';
|
||||
$reference = 'events-'.$sub->plan.'-'.$user->id.'-'.now()->format('YmdHis').'-'.Str::random(6);
|
||||
$result = $this->billing->charge($user, $sub->price_minor, $reference, "Ladill Events {$planLabel} — renewal");
|
||||
|
||||
if ($result['ok']) {
|
||||
$periodEnd = Carbon::now()->addDays((int) config('events.pro.period_days', 30));
|
||||
$sub->forceFill([
|
||||
'current_period_end' => $periodEnd,
|
||||
'last_charged_at' => now(),
|
||||
'last_reference' => $reference,
|
||||
'last_error' => null,
|
||||
])->save();
|
||||
|
||||
$this->syncMessagingEntitlement($user, $sub->plan, ProSubscription::STATUS_ACTIVE, $reference, $periodEnd);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$graceEnd = optional($sub->current_period_end)->addDays((int) config('events.pro.grace_days', 3));
|
||||
if ($graceEnd && $graceEnd->isPast()) {
|
||||
$sub->forceFill(['status' => ProSubscription::STATUS_PAST_DUE, 'last_error' => $result['error']])->save();
|
||||
$this->syncMessagingEntitlement($user, $sub->plan, 'past_due', $reference.'-pastdue', $sub->current_period_end);
|
||||
} else {
|
||||
$sub->forceFill(['last_error' => $result['error']])->save();
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array{0:bool,1:string} */
|
||||
private function activateWalletPlan(User $user, string $plan, int $price, string $description, string $successMessage): array
|
||||
{
|
||||
$existing = $this->subscriptionFor($user);
|
||||
$reference = 'events-'.$plan.'-'.$user->id.'-'.now()->format('YmdHis').'-'.Str::random(6);
|
||||
$result = $this->billing->charge($user, $price, $reference, $description);
|
||||
|
||||
if (! $result['ok']) {
|
||||
if ($result['insufficient']) {
|
||||
$bal = number_format(((int) $result['balance_minor']) / 100, 2);
|
||||
|
||||
return [false, "Your wallet balance (GHS {$bal}) is too low. Top up, then subscribe."];
|
||||
}
|
||||
|
||||
return [false, $result['error'] ?? 'Could not start your subscription. Please try again.'];
|
||||
}
|
||||
|
||||
$periodEnd = Carbon::now()->addDays((int) config('events.pro.period_days', 30));
|
||||
ProSubscription::updateOrCreate(
|
||||
['user_id' => $user->id],
|
||||
[
|
||||
'plan' => $plan,
|
||||
'status' => ProSubscription::STATUS_ACTIVE,
|
||||
'price_minor' => $price,
|
||||
'currency' => (string) config('events.pro.currency', 'GHS'),
|
||||
'auto_renew' => true,
|
||||
'started_at' => $existing?->started_at ?? now(),
|
||||
'current_period_end' => $periodEnd,
|
||||
'last_charged_at' => now(),
|
||||
'canceled_at' => null,
|
||||
'last_reference' => $reference,
|
||||
'last_error' => null,
|
||||
],
|
||||
);
|
||||
|
||||
$this->syncMessagingEntitlement($user, $plan, ProSubscription::STATUS_ACTIVE, $reference, $periodEnd);
|
||||
|
||||
return [true, $successMessage.' Your subscription is active.'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Events plan → platform suite entitlement plan (enterprise = Business tier).
|
||||
*/
|
||||
private function syncMessagingEntitlement(
|
||||
User $user,
|
||||
string $plan,
|
||||
string $status,
|
||||
string $reference,
|
||||
?Carbon $periodEnd,
|
||||
string $source = 'wallet_debit',
|
||||
): void {
|
||||
$platformPlan = $plan === ProSubscription::PLAN_ENTERPRISE ? 'enterprise' : ($plan === 'free' ? 'free' : 'pro');
|
||||
$publicId = (string) ($user->public_id ?? '');
|
||||
if ($publicId === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->billing->syncSuiteEntitlement($publicId, $platformPlan, $status, $reference, [
|
||||
'period_starts_at' => now()->toIso8601String(),
|
||||
'period_ends_at' => $periodEnd?->toIso8601String(),
|
||||
'source' => $source,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
namespace App\Services\Identity;
|
||||
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class IdentityClient
|
||||
{
|
||||
@@ -43,6 +45,79 @@ class IdentityClient
|
||||
return (array) $response->json('data', []);
|
||||
}
|
||||
|
||||
/** @return array<int, array<string, mixed>> */
|
||||
public function banks(string $type = 'mobile_money'): array
|
||||
{
|
||||
$queryType = $type === 'mobile_money' ? 'mobile_money' : 'bank';
|
||||
$response = $this->request()->get($this->url('/identity/banks'), [
|
||||
'type' => $queryType,
|
||||
]);
|
||||
$response->throw();
|
||||
|
||||
return (array) $response->json('data', []);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed>|null */
|
||||
public function payoutAccount(string $publicId): ?array
|
||||
{
|
||||
$response = $this->request()->get($this->url('/identity/payout-account'), [
|
||||
'user' => $publicId,
|
||||
]);
|
||||
$response->throw();
|
||||
|
||||
$account = $response->json('data.payout_account');
|
||||
|
||||
return is_array($account) ? $account : null;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $data
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function updatePayoutAccount(string $publicId, array $data): array
|
||||
{
|
||||
$response = $this->request()->put($this->url('/identity/payout-account'), array_merge(
|
||||
['user' => $publicId],
|
||||
$data,
|
||||
));
|
||||
$this->throwValidation($response, 'account_number');
|
||||
|
||||
$account = $response->json('data.payout_account');
|
||||
|
||||
return is_array($account) ? $account : [];
|
||||
}
|
||||
|
||||
/** @return array<int, array<string, mixed>> */
|
||||
public function withdrawals(string $publicId): array
|
||||
{
|
||||
$response = $this->request()->get($this->url('/identity/wallet/withdrawals'), [
|
||||
'user' => $publicId,
|
||||
]);
|
||||
$response->throw();
|
||||
|
||||
return (array) $response->json('data', []);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function withdraw(string $publicId, float $amountGhs): array
|
||||
{
|
||||
$response = $this->request()->post($this->url('/identity/wallet/withdraw'), [
|
||||
'user' => $publicId,
|
||||
'amount' => $amountGhs,
|
||||
]);
|
||||
$this->throwValidation($response, 'amount');
|
||||
|
||||
return (array) $response->json('data', []);
|
||||
}
|
||||
|
||||
private function throwValidation(Response $response, string $fallbackField = 'base'): void
|
||||
{
|
||||
if ($response->status() === 422) {
|
||||
throw ValidationException::withMessages(
|
||||
$response->json('errors') ?: [$fallbackField => [$response->json('message') ?: 'Request failed.']],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function request()
|
||||
{
|
||||
return Http::withToken((string) config('identity.api_key'))
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Integrations;
|
||||
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class MailCalendarClient
|
||||
{
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return filled(config('mail_calendar.api_key')) && filled(config('mail_calendar.api_url'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function upsertEvent(array $payload): ?array
|
||||
{
|
||||
if (! $this->isConfigured()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Http::withToken((string) config('mail_calendar.api_key'))
|
||||
->acceptJson()
|
||||
->asJson()
|
||||
->connectTimeout(10)
|
||||
->timeout(15)
|
||||
->post($this->baseUrl().'/calendar/events', $payload);
|
||||
} catch (ConnectionException $e) {
|
||||
Log::warning('Events mail calendar upsert failed', ['error' => $e->getMessage()]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::warning('Events mail calendar upsert failed', [
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = $response->json('data');
|
||||
|
||||
return is_array($data) ? $data : null;
|
||||
}
|
||||
|
||||
public function deleteEvent(int $eventId, string $mailboxEmail, string $externalRef): bool
|
||||
{
|
||||
if (! $this->isConfigured()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Http::withToken((string) config('mail_calendar.api_key'))
|
||||
->acceptJson()
|
||||
->connectTimeout(10)
|
||||
->timeout(15)
|
||||
->delete($this->baseUrl().'/calendar/events/'.$eventId, [
|
||||
'mailbox_email' => strtolower(trim($mailboxEmail)),
|
||||
'external_ref' => $externalRef,
|
||||
]);
|
||||
} catch (ConnectionException $e) {
|
||||
Log::warning('Events mail calendar delete failed', ['error' => $e->getMessage()]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return $response->successful();
|
||||
}
|
||||
|
||||
protected function baseUrl(): string
|
||||
{
|
||||
return rtrim((string) config('mail_calendar.api_url'), '/');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Meet;
|
||||
|
||||
use App\Models\QrCode;
|
||||
use App\Models\QrEventRegistration;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class EventMeetAccessService
|
||||
{
|
||||
public function syncRegistration(QrEventRegistration $registration): void
|
||||
{
|
||||
$event = $registration->qrCode;
|
||||
if (! $event || $event->type !== QrCode::TYPE_EVENT) {
|
||||
return;
|
||||
}
|
||||
|
||||
$format = (string) ($event->content()['format'] ?? 'in_person');
|
||||
if (! in_array($format, ['virtual', 'hybrid'], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((string) config('meet.key', '') === '' || ! $registration->attendee_email) {
|
||||
return;
|
||||
}
|
||||
|
||||
$ownerRef = (string) $event->user->public_id;
|
||||
$client = MeetClient::for($ownerRef);
|
||||
|
||||
foreach ((array) ($event->content()['virtual_sessions'] ?? []) as $session) {
|
||||
if (! is_array($session)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$roomUuid = trim((string) ($session['meet_room_uuid'] ?? ''));
|
||||
if ($roomUuid === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$client->updateRoom($roomUuid, [
|
||||
'invite_emails' => [$registration->attendee_email],
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Events Meet invite failed', [
|
||||
'registration_id' => $registration->id,
|
||||
'room_uuid' => $roomUuid,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$metadata = (array) ($registration->metadata ?? []);
|
||||
$metadata['meet_invited_at'] = now()->toIso8601String();
|
||||
$registration->update(['metadata' => $metadata]);
|
||||
}
|
||||
|
||||
public function isJoinWindowOpen(array $session, int $minutesBefore = 15): bool
|
||||
{
|
||||
$scheduled = trim((string) ($session['scheduled_at'] ?? ''));
|
||||
if ($scheduled === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
$start = \Carbon\Carbon::parse($scheduled);
|
||||
} catch (\Throwable) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$duration = max(15, (int) ($session['duration_minutes'] ?? 60));
|
||||
|
||||
return now()->between($start->copy()->subMinutes($minutesBefore), $start->copy()->addMinutes($duration));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Meet;
|
||||
|
||||
use App\Models\QrCode;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class EventMeetRoomLinkService
|
||||
{
|
||||
public function __construct(private readonly EventMeetSyncService $sync) {}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function linkableEvents(User $owner): array
|
||||
{
|
||||
return QrCode::query()
|
||||
->where('user_id', $owner->id)
|
||||
->where('type', QrCode::TYPE_EVENT)
|
||||
->orderByDesc('updated_at')
|
||||
->get()
|
||||
->filter(function (QrCode $event) {
|
||||
$format = (string) ($event->content()['format'] ?? 'in_person');
|
||||
|
||||
return in_array($format, ['virtual', 'hybrid'], true);
|
||||
})
|
||||
->map(fn (QrCode $event) => $this->summarizeEvent($event))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function linkRoom(User $owner, QrCode $event, array $payload): array
|
||||
{
|
||||
abort_unless($event->user_id === $owner->id, 403);
|
||||
abort_unless($event->type === QrCode::TYPE_EVENT, 422);
|
||||
|
||||
$format = (string) ($event->content()['format'] ?? 'in_person');
|
||||
abort_unless(in_array($format, ['virtual', 'hybrid'], true), 422, 'Only virtual or hybrid events can be linked.');
|
||||
|
||||
$roomUuid = trim((string) ($payload['meet_room_uuid'] ?? ''));
|
||||
$roomType = in_array($payload['meet_room_type'] ?? '', ['town_hall', 'webinar'], true)
|
||||
? (string) $payload['meet_room_type']
|
||||
: 'town_hall';
|
||||
abort_unless($roomUuid !== '', 422, 'meet_room_uuid is required.');
|
||||
|
||||
$joinUrl = trim((string) ($payload['join_url'] ?? ''));
|
||||
$title = trim((string) ($payload['title'] ?? $event->label ?? 'Virtual session'));
|
||||
$sessionId = trim((string) ($payload['virtual_session_id'] ?? ''));
|
||||
|
||||
$content = $event->content();
|
||||
$sessions = array_values(array_filter(
|
||||
(array) ($content['virtual_sessions'] ?? []),
|
||||
fn ($session) => is_array($session),
|
||||
));
|
||||
|
||||
$matchedIndex = null;
|
||||
if ($sessionId !== '') {
|
||||
foreach ($sessions as $index => $session) {
|
||||
if ((string) ($session['id'] ?? '') === $sessionId) {
|
||||
$matchedIndex = $index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($matchedIndex === null) {
|
||||
foreach ($sessions as $index => $session) {
|
||||
$existingUuid = trim((string) ($session['meet_room_uuid'] ?? ''));
|
||||
$existingType = (string) ($session['meet_room_type'] ?? 'town_hall');
|
||||
if ($existingUuid === '' && $existingType === $roomType) {
|
||||
$matchedIndex = $index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($matchedIndex === null) {
|
||||
$sessionId = $sessionId !== '' ? $sessionId : 'vs-'.Str::lower(Str::random(12));
|
||||
$sessions[] = [
|
||||
'id' => $sessionId,
|
||||
'title' => $title,
|
||||
'meet_room_type' => $roomType,
|
||||
'scheduled_at' => $payload['scheduled_at'] ?? $content['starts_at'] ?? null,
|
||||
'duration_minutes' => max(15, (int) ($payload['duration_minutes'] ?? 60)),
|
||||
];
|
||||
$matchedIndex = count($sessions) - 1;
|
||||
} else {
|
||||
$sessionId = (string) ($sessions[$matchedIndex]['id'] ?? $sessionId);
|
||||
}
|
||||
|
||||
$sessions[$matchedIndex] = array_merge($sessions[$matchedIndex], [
|
||||
'id' => $sessionId,
|
||||
'title' => $title !== '' ? $title : (string) ($sessions[$matchedIndex]['title'] ?? 'Virtual session'),
|
||||
'meet_room_type' => $roomType,
|
||||
'meet_room_uuid' => $roomUuid,
|
||||
'join_url' => $joinUrl !== '' ? $joinUrl : ($sessions[$matchedIndex]['join_url'] ?? null),
|
||||
'scheduled_at' => $payload['scheduled_at'] ?? $sessions[$matchedIndex]['scheduled_at'] ?? null,
|
||||
'duration_minutes' => max(15, (int) ($payload['duration_minutes'] ?? ($sessions[$matchedIndex]['duration_minutes'] ?? 60))),
|
||||
]);
|
||||
|
||||
$payloadData = (array) ($event->payload ?? []);
|
||||
$payloadData['content'] = array_merge($content, ['virtual_sessions' => $sessions]);
|
||||
$event->payload = $payloadData;
|
||||
$event->save();
|
||||
|
||||
$event = $this->sync->sync($event->fresh(), $owner);
|
||||
|
||||
$linkedSession = collect($event->content()['virtual_sessions'] ?? [])
|
||||
->first(fn ($session) => is_array($session) && (string) ($session['id'] ?? '') === $sessionId);
|
||||
|
||||
return array_merge($this->summarizeEvent($event), [
|
||||
'entity_id' => $sessionId,
|
||||
'meet_room_uuid' => $roomUuid,
|
||||
'join_url' => $joinUrl,
|
||||
], $this->sync->programmePayloadForSession($event, is_array($linkedSession) ? $linkedSession : $sessions[$matchedIndex]));
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function summarizeEvent(QrCode $event): array
|
||||
{
|
||||
$content = $event->content();
|
||||
$sessions = collect((array) ($content['virtual_sessions'] ?? []))
|
||||
->filter(fn ($session) => is_array($session))
|
||||
->map(fn (array $session) => [
|
||||
'id' => (string) ($session['id'] ?? ''),
|
||||
'title' => (string) ($session['title'] ?? ''),
|
||||
'meet_room_type' => (string) ($session['meet_room_type'] ?? 'town_hall'),
|
||||
'meet_room_uuid' => ($uuid = trim((string) ($session['meet_room_uuid'] ?? ''))) !== '' ? $uuid : null,
|
||||
'scheduled_at' => $session['scheduled_at'] ?? null,
|
||||
'duration_minutes' => (int) ($session['duration_minutes'] ?? 60),
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return [
|
||||
'id' => $event->id,
|
||||
'title' => (string) ($content['title'] ?? $event->label ?? 'Event'),
|
||||
'format' => (string) ($content['format'] ?? 'in_person'),
|
||||
'short_code' => $event->short_code,
|
||||
'registration_url' => $event->publicUrl(),
|
||||
'admin_url' => url('/events/'.$event->id.'/attendees'),
|
||||
'edit_url' => url('/events/'.$event->id),
|
||||
'virtual_sessions' => $sessions,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Meet;
|
||||
|
||||
use App\Models\QrCode;
|
||||
use App\Models\User;
|
||||
use App\Services\Events\EventProgrammeService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class EventMeetSyncService
|
||||
{
|
||||
public function __construct(private readonly EventProgrammeService $programmes) {}
|
||||
|
||||
public function sync(QrCode $event, User $owner): QrCode
|
||||
{
|
||||
if ($event->type !== QrCode::TYPE_EVENT) {
|
||||
return $event;
|
||||
}
|
||||
|
||||
$content = $event->content();
|
||||
$format = (string) ($content['format'] ?? 'in_person');
|
||||
|
||||
if (! in_array($format, ['virtual', 'hybrid'], true)) {
|
||||
$this->cancelRemovedSessions($event, $owner, []);
|
||||
|
||||
return $event;
|
||||
}
|
||||
|
||||
if ((string) config('meet.key', '') === '') {
|
||||
return $event;
|
||||
}
|
||||
|
||||
$programmeSnapshot = $this->programmeSnapshotFor($event);
|
||||
$sessions = (array) ($content['virtual_sessions'] ?? []);
|
||||
$client = MeetClient::for($owner->public_id);
|
||||
$synced = [];
|
||||
$activeIds = [];
|
||||
|
||||
foreach ($sessions as $session) {
|
||||
if (! is_array($session)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sessionId = trim((string) ($session['id'] ?? ''));
|
||||
if ($sessionId === '') {
|
||||
$sessionId = 'vs-'.Str::lower(Str::random(12));
|
||||
}
|
||||
|
||||
$activeIds[] = $sessionId;
|
||||
$title = trim((string) ($session['title'] ?? ''));
|
||||
if ($title === '') {
|
||||
$synced[] = array_merge($session, ['id' => $sessionId]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$roomType = in_array($session['meet_room_type'] ?? '', ['town_hall', 'webinar'], true)
|
||||
? (string) $session['meet_room_type']
|
||||
: 'town_hall';
|
||||
|
||||
$scheduledAt = $session['scheduled_at'] ?? $content['starts_at'] ?? null;
|
||||
$itemRefs = (array) ($session['programme_item_refs'] ?? []);
|
||||
$linkedItems = $this->programmes->itemsForRefs($programmeSnapshot, $itemRefs);
|
||||
$inviteEmails = $this->inviteEmailsFromHosts($this->programmes->hostsFromItems($linkedItems));
|
||||
|
||||
$settings = [
|
||||
'programme_snapshot' => $programmeSnapshot,
|
||||
'linked_programme_items' => $linkedItems,
|
||||
];
|
||||
|
||||
if ($roomType === 'town_hall' && $linkedItems !== []) {
|
||||
$settings['panels'] = [[
|
||||
'id' => 'panel-'.$sessionId,
|
||||
'name' => $title,
|
||||
'speaker_refs' => [],
|
||||
]];
|
||||
$settings['panel_discussions'] = true;
|
||||
}
|
||||
|
||||
$roomPayload = [
|
||||
'title' => $title,
|
||||
'description' => trim((string) ($content['description'] ?? '')) ?: null,
|
||||
'type' => $roomType,
|
||||
'scheduled_at' => $scheduledAt,
|
||||
'duration_minutes' => max(15, (int) ($session['duration_minutes'] ?? 60)),
|
||||
'timezone' => config('app.timezone', 'UTC'),
|
||||
'settings' => $settings,
|
||||
'invite_emails' => $inviteEmails,
|
||||
'source' => [
|
||||
'app' => 'events',
|
||||
'entity_type' => 'virtual_session',
|
||||
'entity_id' => $sessionId,
|
||||
'event_id' => (string) $event->id,
|
||||
],
|
||||
];
|
||||
|
||||
$existingRoomUuid = trim((string) ($session['meet_room_uuid'] ?? ''));
|
||||
|
||||
try {
|
||||
$result = $existingRoomUuid !== ''
|
||||
? $client->updateRoom($existingRoomUuid, $roomPayload)
|
||||
: $client->upsertRoom($roomPayload);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Events Meet sync failed', [
|
||||
'event_id' => $event->id,
|
||||
'session_id' => $sessionId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
$synced[] = array_merge($session, ['id' => $sessionId]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$room = (array) ($result['room'] ?? []);
|
||||
$synced[] = array_merge($session, [
|
||||
'id' => $sessionId,
|
||||
'meet_room_uuid' => $room['uuid'] ?? $session['meet_room_uuid'] ?? null,
|
||||
'join_url' => $result['join_url'] ?? $session['join_url'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->cancelRemovedSessions($event, $owner, $activeIds);
|
||||
|
||||
$payload = (array) ($event->payload ?? []);
|
||||
$payload['content'] = array_merge($content, ['virtual_sessions' => array_values($synced)]);
|
||||
$event->payload = $payload;
|
||||
$event->save();
|
||||
|
||||
return $event->fresh();
|
||||
}
|
||||
|
||||
public function syncEventsForProgramme(QrCode $programme, User $owner): void
|
||||
{
|
||||
foreach ($this->programmes->eventsLinkedToProgramme($programme) as $event) {
|
||||
$this->sync($event->fresh(), $owner);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $session
|
||||
* @return array{programme_snapshot: array<string, mixed>|null, linked_programme_items: list<array<string, mixed>>}
|
||||
*/
|
||||
public function programmePayloadForSession(QrCode $event, array $session): array
|
||||
{
|
||||
$programmeSnapshot = $this->programmeSnapshotFor($event);
|
||||
$itemRefs = (array) ($session['programme_item_refs'] ?? []);
|
||||
|
||||
return [
|
||||
'programme_snapshot' => $programmeSnapshot,
|
||||
'linked_programme_items' => $this->programmes->itemsForRefs($programmeSnapshot, $itemRefs),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, mixed>|null */
|
||||
private function programmeSnapshotFor(QrCode $event): ?array
|
||||
{
|
||||
$programmeId = (int) ($event->content()['programme_qr_id'] ?? 0);
|
||||
if ($programmeId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$programme = QrCode::where('id', $programmeId)
|
||||
->where('user_id', $event->user_id)
|
||||
->where('type', QrCode::TYPE_ITINERARY)
|
||||
->first();
|
||||
|
||||
return $this->programmes->snapshot($programme);
|
||||
}
|
||||
|
||||
/** @param list<string> $hosts */
|
||||
private function inviteEmailsFromHosts(array $hosts): array
|
||||
{
|
||||
return collect($hosts)
|
||||
->filter(fn ($host) => filter_var($host, FILTER_VALIDATE_EMAIL))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/** @param list<string> $keepIds */
|
||||
private function cancelRemovedSessions(QrCode $event, User $owner, array $keepIds): void
|
||||
{
|
||||
if ((string) config('meet.key', '') === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$previous = (array) ($event->content()['virtual_sessions'] ?? []);
|
||||
$client = MeetClient::for($owner->public_id);
|
||||
|
||||
foreach ($previous as $session) {
|
||||
if (! is_array($session)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sessionId = (string) ($session['id'] ?? '');
|
||||
$roomUuid = (string) ($session['meet_room_uuid'] ?? '');
|
||||
|
||||
if ($sessionId === '' || in_array($sessionId, $keepIds, true) || $roomUuid === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$client->cancelRoom($roomUuid);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Events Meet cancel failed', [
|
||||
'event_id' => $event->id,
|
||||
'session_id' => $sessionId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Meet;
|
||||
|
||||
use App\Models\QrCode;
|
||||
use App\Models\QrEventRegistration;
|
||||
|
||||
class EventRegistrationVerifyService
|
||||
{
|
||||
/** @return array<string, mixed>|null */
|
||||
public function verifyBadge(QrCode $event, string $badgeCode): ?array
|
||||
{
|
||||
abort_unless($event->type === QrCode::TYPE_EVENT, 404);
|
||||
|
||||
$normalized = strtoupper(trim($badgeCode));
|
||||
if ($normalized === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$registration = QrEventRegistration::query()
|
||||
->where('qr_code_id', $event->id)
|
||||
->where('badge_code', $normalized)
|
||||
->where('status', QrEventRegistration::STATUS_CONFIRMED)
|
||||
->first();
|
||||
|
||||
if (! $registration) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'attendee_name' => $registration->attendee_name,
|
||||
'attendee_email' => $registration->attendee_email,
|
||||
'badge_code' => $registration->badge_code,
|
||||
'tier_name' => $registration->tier_name,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Meet;
|
||||
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
/**
|
||||
* Client for the Ladill Meet service API — virtual sessions linked to Events.
|
||||
*/
|
||||
class MeetClient
|
||||
{
|
||||
public function __construct(private readonly string $ownerRef) {}
|
||||
|
||||
public static function for(string $ownerRef): self
|
||||
{
|
||||
return new self($ownerRef);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function findBySource(string $app, string $entityId): ?array
|
||||
{
|
||||
try {
|
||||
$response = $this->client()->get('rooms/by-source', [
|
||||
'source_app' => $app,
|
||||
'entity_id' => $entityId,
|
||||
'owner_ref' => $this->ownerRef,
|
||||
]);
|
||||
} catch (ConnectionException) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($response->status() === 404) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($response->failed()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (array) $response->json();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function upsertRoom(array $data): array
|
||||
{
|
||||
return $this->post('rooms', array_merge($data, [
|
||||
'owner_ref' => $this->ownerRef,
|
||||
'host_user_ref' => $data['host_user_ref'] ?? $this->ownerRef,
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function updateRoom(string $roomUuid, array $data): array
|
||||
{
|
||||
return $this->patch("rooms/{$roomUuid}", array_merge($data, [
|
||||
'owner_ref' => $this->ownerRef,
|
||||
'host_user_ref' => $data['host_user_ref'] ?? $this->ownerRef,
|
||||
]));
|
||||
}
|
||||
|
||||
public function cancelRoom(string $roomUuid, ?string $hostUserRef = null): array
|
||||
{
|
||||
return $this->post("rooms/{$roomUuid}/cancel", [
|
||||
'owner_ref' => $this->ownerRef,
|
||||
'host_user_ref' => $hostUserRef ?? $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
|
||||
{
|
||||
return $this->request('post', $path, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function patch(string $path, array $data): array
|
||||
{
|
||||
return $this->request('patch', $path, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function request(string $method, string $path, array $data): array
|
||||
{
|
||||
try {
|
||||
$response = $this->client()->{$method}($path, $data);
|
||||
} catch (ConnectionException) {
|
||||
throw new \RuntimeException('Could not reach the Meet service.');
|
||||
}
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new \RuntimeException('Meet service error: '.($response->json('message') ?? $response->body()));
|
||||
}
|
||||
|
||||
return (array) $response->json();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Meet;
|
||||
|
||||
use App\Models\QrCode;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class MeetLifecycleService
|
||||
{
|
||||
/** @param array<string, mixed> $payload */
|
||||
public function handle(string $event, array $payload): void
|
||||
{
|
||||
$room = (array) ($payload['room'] ?? []);
|
||||
$source = (array) ($room['source'] ?? []);
|
||||
|
||||
if (($source['app'] ?? '') !== 'events') {
|
||||
return;
|
||||
}
|
||||
|
||||
$eventId = (int) ($source['event_id'] ?? 0);
|
||||
$sessionId = (string) ($source['entity_id'] ?? '');
|
||||
|
||||
if ($eventId <= 0 || $sessionId === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$qrCode = QrCode::find($eventId);
|
||||
if (! $qrCode || $qrCode->type !== QrCode::TYPE_EVENT) {
|
||||
return;
|
||||
}
|
||||
|
||||
$content = $qrCode->content();
|
||||
$sessions = (array) ($content['virtual_sessions'] ?? []);
|
||||
$updated = false;
|
||||
|
||||
foreach ($sessions as $index => $session) {
|
||||
if (! is_array($session) || ($session['id'] ?? '') !== $sessionId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sessions[$index] = array_merge($session, $this->patchForEvent($event, $payload, $room));
|
||||
$updated = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (! $updated) {
|
||||
return;
|
||||
}
|
||||
|
||||
$payloadData = (array) ($qrCode->payload ?? []);
|
||||
$payloadData['content'] = array_merge($content, ['virtual_sessions' => array_values($sessions)]);
|
||||
$qrCode->update(['payload' => $payloadData]);
|
||||
|
||||
Log::info('Events Meet lifecycle applied', ['event' => $event, 'event_id' => $eventId, 'session_id' => $sessionId]);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function patchForEvent(string $event, array $payload, array $room): array
|
||||
{
|
||||
return match ($event) {
|
||||
'meeting.started' => ['meet_status' => 'live', 'meet_live_at' => now()->toIso8601String()],
|
||||
'meeting.ended' => ['meet_status' => 'ended', 'meet_ended_at' => now()->toIso8601String()],
|
||||
'meeting.cancelled' => ['meet_status' => 'cancelled'],
|
||||
'recording.ready' => [
|
||||
'recording_url' => (string) (($payload['recording']['download_url'] ?? '') ?: ''),
|
||||
'recording_ready_at' => now()->toIso8601String(),
|
||||
],
|
||||
default => [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Messaging;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class CustomerEmailClient
|
||||
{
|
||||
private ?string $lastError = null;
|
||||
|
||||
public function lastError(): ?string
|
||||
{
|
||||
return $this->lastError;
|
||||
}
|
||||
|
||||
private function base(): string
|
||||
{
|
||||
return rtrim((string) config('smtp.customer_relay_url', 'https://ladill.com/api/smtp'), '/');
|
||||
}
|
||||
|
||||
/** @return array{ok: bool, data?: array<string, mixed>, error?: string, status?: int} */
|
||||
public function me(string $apiKey): array
|
||||
{
|
||||
try {
|
||||
$res = Http::withToken($apiKey)->acceptJson()->timeout(15)
|
||||
->get($this->base().'/me');
|
||||
|
||||
if ($res->status() === 401) {
|
||||
return ['ok' => false, 'error' => 'Invalid Bird API key.', 'status' => 401];
|
||||
}
|
||||
|
||||
if ($res->failed()) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'error' => (string) ($res->json('error') ?: 'Could not validate Bird credentials.'),
|
||||
'status' => $res->status(),
|
||||
];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'data' => $res->json() ?? [], 'status' => $res->status()];
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Customer Bird meta request failed', ['error' => $e->getMessage()]);
|
||||
|
||||
return ['ok' => false, 'error' => 'Could not reach Ladill Bird. Please try again.'];
|
||||
}
|
||||
}
|
||||
|
||||
public function send(
|
||||
string $apiKey,
|
||||
string $from,
|
||||
?string $fromName,
|
||||
string $to,
|
||||
string $subject,
|
||||
string $html,
|
||||
?string $text = null,
|
||||
): bool {
|
||||
$this->lastError = null;
|
||||
|
||||
try {
|
||||
$res = Http::withToken($apiKey)->acceptJson()->timeout(30)
|
||||
->post($this->base().'/send', array_filter([
|
||||
'from' => $from,
|
||||
'from_name' => $fromName,
|
||||
'to' => [$to],
|
||||
'subject' => $subject,
|
||||
'html' => $html,
|
||||
'text' => $text,
|
||||
], fn ($value) => $value !== null && $value !== ''));
|
||||
|
||||
if ($res->status() === 402) {
|
||||
$this->lastError = (string) ($res->json('error') ?: 'Insufficient Bird email credits. Top up Bird and try again.');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($res->failed()) {
|
||||
$this->lastError = (string) ($res->json('error') ?: $res->json('message') ?: 'The email could not be sent.');
|
||||
Log::warning('Customer email send failed', ['status' => $res->status()]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return (bool) ($res->json('success') ?? true);
|
||||
} catch (\Throwable $e) {
|
||||
$this->lastError = 'Could not reach the email service. Please try again.';
|
||||
Log::warning('Customer email send error', ['error' => $e->getMessage()]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Messaging;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class CustomerSmsClient
|
||||
{
|
||||
private ?string $lastError = null;
|
||||
|
||||
public function lastError(): ?string
|
||||
{
|
||||
return $this->lastError;
|
||||
}
|
||||
|
||||
private function base(): string
|
||||
{
|
||||
return rtrim((string) config('sms.customer_relay_url', 'https://ladill.com/api/sms'), '/');
|
||||
}
|
||||
|
||||
/** @return array{ok: bool, data?: array<string, mixed>, error?: string, status?: int} */
|
||||
public function me(string $apiKey): array
|
||||
{
|
||||
return $this->get($apiKey, '/me');
|
||||
}
|
||||
|
||||
/** @return array{ok: bool, data?: array<string, mixed>, error?: string, status?: int} */
|
||||
public function senders(string $apiKey): array
|
||||
{
|
||||
return $this->get($apiKey, '/senders');
|
||||
}
|
||||
|
||||
public function send(string $apiKey, string $to, string $text, string $senderId): bool
|
||||
{
|
||||
$this->lastError = null;
|
||||
|
||||
try {
|
||||
$res = Http::withToken($apiKey)->acceptJson()->timeout(30)
|
||||
->post($this->base().'/send', [
|
||||
'to' => $to,
|
||||
'text' => $text,
|
||||
'sender_id' => $senderId,
|
||||
]);
|
||||
|
||||
if ($res->status() === 402) {
|
||||
$this->lastError = (string) ($res->json('error') ?: 'Insufficient SMS credit. Top up your Ladill SMS wallet and try again.');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($res->failed()) {
|
||||
$this->lastError = (string) ($res->json('error') ?: $res->json('message') ?: 'The SMS could not be sent.');
|
||||
Log::warning('Customer SMS send failed', ['status' => $res->status()]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return (bool) ($res->json('success') ?? true);
|
||||
} catch (\Throwable $e) {
|
||||
$this->lastError = 'Could not reach the SMS service. Please try again.';
|
||||
Log::warning('Customer SMS send error', ['error' => $e->getMessage()]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array{ok: bool, data?: array<string, mixed>, error?: string, status?: int} */
|
||||
private function get(string $apiKey, string $path): array
|
||||
{
|
||||
try {
|
||||
$res = Http::withToken($apiKey)->acceptJson()->timeout(15)
|
||||
->get($this->base().$path);
|
||||
|
||||
if ($res->status() === 401) {
|
||||
return ['ok' => false, 'error' => 'Invalid SMS API key.', 'status' => 401];
|
||||
}
|
||||
|
||||
if ($res->failed()) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'error' => (string) ($res->json('error') ?: 'Could not validate SMS credentials.'),
|
||||
'status' => $res->status(),
|
||||
];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'data' => $res->json() ?? [], 'status' => $res->status()];
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Customer SMS meta request failed', ['path' => $path, 'error' => $e->getMessage()]);
|
||||
|
||||
return ['ok' => false, 'error' => 'Could not reach Ladill SMS. Please try again.'];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Messaging;
|
||||
|
||||
use App\Models\MessagingCredential;
|
||||
|
||||
class MessagingCredentialsService
|
||||
{
|
||||
public function __construct(
|
||||
private CustomerSmsClient $sms,
|
||||
private CustomerEmailClient $email,
|
||||
) {}
|
||||
|
||||
public function forOwner(string $ownerRef): MessagingCredential
|
||||
{
|
||||
return MessagingCredential::query()->firstOrCreate(
|
||||
['owner_ref' => $ownerRef],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ok: bool, error?: string, credential?: MessagingCredential, senders?: list<string>}
|
||||
*/
|
||||
public function validateAndSaveSms(string $ownerRef, string $apiKey, string $senderId): array
|
||||
{
|
||||
$apiKey = trim($apiKey);
|
||||
$senderId = trim($senderId);
|
||||
|
||||
if (! str_starts_with($apiKey, 'lsk_sms_live_')) {
|
||||
return ['ok' => false, 'error' => 'SMS API keys must start with lsk_sms_live_.'];
|
||||
}
|
||||
|
||||
if ($senderId === '' || strlen($senderId) > 11) {
|
||||
return ['ok' => false, 'error' => 'Sender ID must be 1–11 characters.'];
|
||||
}
|
||||
|
||||
$me = $this->sms->me($apiKey);
|
||||
if (! ($me['ok'] ?? false)) {
|
||||
$credential = $this->forOwner($ownerRef);
|
||||
$credential->update([
|
||||
'sms_status' => MessagingCredential::STATUS_INVALID,
|
||||
'sms_last_error' => $me['error'] ?? 'Invalid SMS API key.',
|
||||
]);
|
||||
|
||||
return ['ok' => false, 'error' => $me['error'] ?? 'Invalid SMS API key.', 'credential' => $credential];
|
||||
}
|
||||
|
||||
$senders = $this->sms->senders($apiKey);
|
||||
$approved = collect($senders['data']['data'] ?? [])->pluck('sender_id')->map(fn ($id) => (string) $id)->all();
|
||||
$defaultSender = (string) ($senders['data']['default_sender'] ?? '');
|
||||
|
||||
$allowed = array_values(array_unique(array_filter([...$approved, $defaultSender])));
|
||||
if ($allowed !== [] && ! in_array($senderId, $allowed, true)) {
|
||||
$error = 'Sender ID is not approved for this SMS key. Approved: '.implode(', ', $allowed);
|
||||
|
||||
$credential = $this->forOwner($ownerRef);
|
||||
$credential->update([
|
||||
'sms_status' => MessagingCredential::STATUS_INVALID,
|
||||
'sms_last_error' => $error,
|
||||
]);
|
||||
|
||||
return ['ok' => false, 'error' => $error, 'credential' => $credential, 'senders' => $allowed];
|
||||
}
|
||||
|
||||
$credential = $this->forOwner($ownerRef);
|
||||
$credential->update([
|
||||
'sms_api_key_encrypted' => MessagingCredential::encryptKey($apiKey),
|
||||
'sms_api_key_prefix' => substr($apiKey, 0, 16),
|
||||
'sms_sender_id' => $senderId,
|
||||
'sms_status' => MessagingCredential::STATUS_VALID,
|
||||
'sms_validated_at' => now(),
|
||||
'sms_last_error' => null,
|
||||
]);
|
||||
|
||||
return ['ok' => true, 'credential' => $credential->fresh(), 'senders' => $allowed];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ok: bool, error?: string, credential?: MessagingCredential}
|
||||
*/
|
||||
public function validateAndSaveBird(string $ownerRef, string $apiKey, string $fromEmail, ?string $fromName): array
|
||||
{
|
||||
$apiKey = trim($apiKey);
|
||||
$fromEmail = strtolower(trim($fromEmail));
|
||||
$fromName = trim((string) $fromName);
|
||||
|
||||
if (! str_starts_with($apiKey, 'lsk_live_') && ! str_starts_with($apiKey, 'lsk_acct_')) {
|
||||
return ['ok' => false, 'error' => 'Bird API keys must start with lsk_live_ or lsk_acct_.'];
|
||||
}
|
||||
|
||||
if (! filter_var($fromEmail, FILTER_VALIDATE_EMAIL)) {
|
||||
return ['ok' => false, 'error' => 'Enter a valid from email address.'];
|
||||
}
|
||||
|
||||
$me = $this->email->me($apiKey);
|
||||
if (! ($me['ok'] ?? false)) {
|
||||
$credential = $this->forOwner($ownerRef);
|
||||
$credential->update([
|
||||
'bird_status' => MessagingCredential::STATUS_INVALID,
|
||||
'bird_last_error' => $me['error'] ?? 'Invalid Bird API key.',
|
||||
]);
|
||||
|
||||
return ['ok' => false, 'error' => $me['error'] ?? 'Invalid Bird API key.', 'credential' => $credential];
|
||||
}
|
||||
|
||||
$domains = collect($me['data']['verified_domains'] ?? [])->map(fn ($d) => strtolower((string) $d))->all();
|
||||
$fromDomain = strtolower((string) (explode('@', $fromEmail, 2)[1] ?? ''));
|
||||
if ($domains !== [] && ! in_array($fromDomain, $domains, true)) {
|
||||
$error = 'From email must use a verified Bird domain ('.implode(', ', $domains).').';
|
||||
$credential = $this->forOwner($ownerRef);
|
||||
$credential->update([
|
||||
'bird_status' => MessagingCredential::STATUS_INVALID,
|
||||
'bird_last_error' => $error,
|
||||
]);
|
||||
|
||||
return ['ok' => false, 'error' => $error, 'credential' => $credential];
|
||||
}
|
||||
|
||||
$credential = $this->forOwner($ownerRef);
|
||||
$credential->update([
|
||||
'bird_api_key_encrypted' => MessagingCredential::encryptKey($apiKey),
|
||||
'bird_api_key_prefix' => substr($apiKey, 0, 16),
|
||||
'bird_from_email' => $fromEmail,
|
||||
'bird_from_name' => $fromName !== '' ? $fromName : null,
|
||||
'bird_status' => MessagingCredential::STATUS_VALID,
|
||||
'bird_validated_at' => now(),
|
||||
'bird_last_error' => null,
|
||||
]);
|
||||
|
||||
return ['ok' => true, 'credential' => $credential->fresh()];
|
||||
}
|
||||
|
||||
public function disconnectSms(string $ownerRef): MessagingCredential
|
||||
{
|
||||
$credential = $this->forOwner($ownerRef);
|
||||
$credential->update([
|
||||
'sms_api_key_encrypted' => null,
|
||||
'sms_api_key_prefix' => null,
|
||||
'sms_sender_id' => null,
|
||||
'sms_status' => null,
|
||||
'sms_validated_at' => null,
|
||||
'sms_last_error' => null,
|
||||
]);
|
||||
|
||||
return $credential->fresh();
|
||||
}
|
||||
|
||||
public function disconnectBird(string $ownerRef): MessagingCredential
|
||||
{
|
||||
$credential = $this->forOwner($ownerRef);
|
||||
$credential->update([
|
||||
'bird_api_key_encrypted' => null,
|
||||
'bird_api_key_prefix' => null,
|
||||
'bird_from_email' => null,
|
||||
'bird_from_name' => null,
|
||||
'bird_status' => null,
|
||||
'bird_validated_at' => null,
|
||||
'bird_last_error' => null,
|
||||
]);
|
||||
|
||||
return $credential->fresh();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Payments;
|
||||
|
||||
use App\Models\PaymentGatewaySetting;
|
||||
use App\Models\User;
|
||||
use App\Services\Events\SubscriptionService;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
|
||||
class MerchantGatewayService
|
||||
{
|
||||
public function settingFor(User|string $owner): ?PaymentGatewaySetting
|
||||
{
|
||||
$ownerRef = $owner instanceof User ? (string) $owner->public_id : $owner;
|
||||
|
||||
return PaymentGatewaySetting::query()->where('owner_ref', $ownerRef)->first();
|
||||
}
|
||||
|
||||
public function isConfigured(User|string $owner): bool
|
||||
{
|
||||
return $this->shouldUseOwnerGateway($owner);
|
||||
}
|
||||
|
||||
public function shouldUseOwnerGateway(User|string $owner): bool
|
||||
{
|
||||
// Custom / owner payment gateways are retired. All checkouts use Ladill Pay
|
||||
// with platform fees.
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $metadata
|
||||
* @return array{checkout_url: string, reference: string, provider: string}
|
||||
*/
|
||||
public function initializeCheckout(
|
||||
User|string $owner,
|
||||
int $amountMinor,
|
||||
string $currency,
|
||||
string $email,
|
||||
string $callbackUrl,
|
||||
string $reference,
|
||||
array $metadata = [],
|
||||
): array {
|
||||
$setting = $this->requireConfigured($owner);
|
||||
$currency = strtoupper($currency ?: 'GHS');
|
||||
|
||||
return match ($setting->provider) {
|
||||
PaymentGatewaySetting::PROVIDER_PAYSTACK => $this->paystackInitialize($setting, $amountMinor, $currency, $email, $callbackUrl, $reference, $metadata),
|
||||
PaymentGatewaySetting::PROVIDER_FLUTTERWAVE => $this->flutterwaveInitialize($setting, $amountMinor, $currency, $email, $callbackUrl, $reference, $metadata),
|
||||
PaymentGatewaySetting::PROVIDER_HUBTEL => $this->hubtelInitialize($setting, $amountMinor, $currency, $email, $callbackUrl, $reference, $metadata),
|
||||
default => throw new RuntimeException('Unsupported payment provider.'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{paid: bool, amount_minor: int, reference: string, provider: string, raw: array<string, mixed>}
|
||||
*/
|
||||
public function verify(User|string $owner, string $reference): array
|
||||
{
|
||||
// Do not re-check the current plan here: an owner-gateway payment
|
||||
// initiated before a downgrade must remain verifiable.
|
||||
$setting = $this->settingFor($owner);
|
||||
if (! $setting?->isConfigured()) {
|
||||
throw new RuntimeException('The payment gateway used for this transaction is no longer configured.');
|
||||
}
|
||||
|
||||
return match ($setting->provider) {
|
||||
PaymentGatewaySetting::PROVIDER_PAYSTACK => $this->paystackVerify($setting, $reference),
|
||||
PaymentGatewaySetting::PROVIDER_FLUTTERWAVE => $this->flutterwaveVerify($setting, $reference),
|
||||
PaymentGatewaySetting::PROVIDER_HUBTEL => $this->hubtelVerify($setting, $reference),
|
||||
default => throw new RuntimeException('Unsupported payment provider.'),
|
||||
};
|
||||
}
|
||||
|
||||
protected function requireConfigured(User|string $owner): PaymentGatewaySetting
|
||||
{
|
||||
if (! $this->ownerMayUseGateway($owner)) {
|
||||
throw new RuntimeException('Connecting your own payment gateway requires a paid plan. Upgrade to Pro or Business.');
|
||||
}
|
||||
|
||||
$setting = $this->settingFor($owner);
|
||||
if (! $setting?->isConfigured()) {
|
||||
throw new RuntimeException('Connect Paystack, Flutterwave, or Hubtel in Settings before accepting online payments.');
|
||||
}
|
||||
|
||||
return $setting;
|
||||
}
|
||||
|
||||
protected function ownerMayUseGateway(User|string $owner): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $metadata
|
||||
* @return array{checkout_url: string, reference: string, provider: string}
|
||||
*/
|
||||
protected function paystackInitialize(
|
||||
PaymentGatewaySetting $setting,
|
||||
int $amountMinor,
|
||||
string $currency,
|
||||
string $email,
|
||||
string $callbackUrl,
|
||||
string $reference,
|
||||
array $metadata,
|
||||
): array {
|
||||
$response = Http::withToken((string) $setting->secret_key)
|
||||
->acceptJson()
|
||||
->timeout(20)
|
||||
->post('https://api.paystack.co/transaction/initialize', [
|
||||
'email' => $email !== '' ? $email : 'payer@example.com',
|
||||
'amount' => $amountMinor,
|
||||
'currency' => $currency,
|
||||
'reference' => $reference,
|
||||
'callback_url' => $callbackUrl,
|
||||
'metadata' => $metadata,
|
||||
]);
|
||||
|
||||
if (! $response->successful() || ! ($response->json('status') ?? false)) {
|
||||
throw new RuntimeException($response->json('message') ?: 'Paystack could not start checkout.');
|
||||
}
|
||||
|
||||
$url = (string) $response->json('data.authorization_url');
|
||||
if ($url === '') {
|
||||
throw new RuntimeException('Paystack did not return a checkout URL.');
|
||||
}
|
||||
|
||||
return [
|
||||
'checkout_url' => $url,
|
||||
'reference' => (string) ($response->json('data.reference') ?: $reference),
|
||||
'provider' => PaymentGatewaySetting::PROVIDER_PAYSTACK,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{paid: bool, amount_minor: int, reference: string, provider: string, raw: array<string, mixed>}
|
||||
*/
|
||||
protected function paystackVerify(PaymentGatewaySetting $setting, string $reference): array
|
||||
{
|
||||
$response = Http::withToken((string) $setting->secret_key)
|
||||
->acceptJson()
|
||||
->timeout(20)
|
||||
->get('https://api.paystack.co/transaction/verify/'.rawurlencode($reference));
|
||||
|
||||
$data = (array) ($response->json('data') ?? []);
|
||||
$paid = ($response->json('status') ?? false)
|
||||
&& strtolower((string) ($data['status'] ?? '')) === 'success';
|
||||
|
||||
return [
|
||||
'paid' => $paid,
|
||||
'amount_minor' => (int) ($data['amount'] ?? 0),
|
||||
'reference' => (string) ($data['reference'] ?? $reference),
|
||||
'provider' => PaymentGatewaySetting::PROVIDER_PAYSTACK,
|
||||
'raw' => $data,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $metadata
|
||||
* @return array{checkout_url: string, reference: string, provider: string}
|
||||
*/
|
||||
protected function flutterwaveInitialize(
|
||||
PaymentGatewaySetting $setting,
|
||||
int $amountMinor,
|
||||
string $currency,
|
||||
string $email,
|
||||
string $callbackUrl,
|
||||
string $reference,
|
||||
array $metadata,
|
||||
): array {
|
||||
$response = Http::withToken((string) $setting->secret_key)
|
||||
->acceptJson()
|
||||
->timeout(20)
|
||||
->post('https://api.flutterwave.com/v3/payments', [
|
||||
'tx_ref' => $reference,
|
||||
'amount' => round($amountMinor / 100, 2),
|
||||
'currency' => $currency,
|
||||
'redirect_url' => $callbackUrl,
|
||||
'customer' => [
|
||||
'email' => $email !== '' ? $email : 'payer@example.com',
|
||||
],
|
||||
'customizations' => [
|
||||
'title' => (string) ($metadata['title'] ?? 'Payment'),
|
||||
],
|
||||
'meta' => $metadata,
|
||||
]);
|
||||
|
||||
if (! $response->successful() || ($response->json('status') ?? '') !== 'success') {
|
||||
throw new RuntimeException($response->json('message') ?: 'Flutterwave could not start checkout.');
|
||||
}
|
||||
|
||||
$url = (string) $response->json('data.link');
|
||||
if ($url === '') {
|
||||
throw new RuntimeException('Flutterwave did not return a checkout URL.');
|
||||
}
|
||||
|
||||
return [
|
||||
'checkout_url' => $url,
|
||||
'reference' => $reference,
|
||||
'provider' => PaymentGatewaySetting::PROVIDER_FLUTTERWAVE,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{paid: bool, amount_minor: int, reference: string, provider: string, raw: array<string, mixed>}
|
||||
*/
|
||||
protected function flutterwaveVerify(PaymentGatewaySetting $setting, string $reference): array
|
||||
{
|
||||
$response = Http::withToken((string) $setting->secret_key)
|
||||
->acceptJson()
|
||||
->timeout(20)
|
||||
->get('https://api.flutterwave.com/v3/transactions/verify_by_reference', [
|
||||
'tx_ref' => $reference,
|
||||
]);
|
||||
|
||||
$data = (array) ($response->json('data') ?? []);
|
||||
$paid = ($response->json('status') ?? '') === 'success'
|
||||
&& strtolower((string) ($data['status'] ?? '')) === 'successful';
|
||||
$amountMajor = (float) ($data['amount'] ?? 0);
|
||||
|
||||
return [
|
||||
'paid' => $paid,
|
||||
'amount_minor' => (int) round($amountMajor * 100),
|
||||
'reference' => (string) ($data['tx_ref'] ?? $reference),
|
||||
'provider' => PaymentGatewaySetting::PROVIDER_FLUTTERWAVE,
|
||||
'raw' => $data,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $metadata
|
||||
* @return array{checkout_url: string, reference: string, provider: string}
|
||||
*/
|
||||
protected function hubtelInitialize(
|
||||
PaymentGatewaySetting $setting,
|
||||
int $amountMinor,
|
||||
string $currency,
|
||||
string $email,
|
||||
string $callbackUrl,
|
||||
string $reference,
|
||||
array $metadata,
|
||||
): array {
|
||||
// Hubtel: public_key = merchant account number, secret_key = API key (client secret),
|
||||
// webhook_secret optionally stores client id for Basic auth as "clientId:clientSecret".
|
||||
$auth = trim((string) ($setting->webhook_secret ?: $setting->secret_key));
|
||||
if (! str_contains($auth, ':')) {
|
||||
$auth = trim((string) $setting->public_key).':'.trim((string) $setting->secret_key);
|
||||
}
|
||||
|
||||
$response = Http::withBasicAuth(...explode(':', $auth, 2))
|
||||
->acceptJson()
|
||||
->timeout(20)
|
||||
->post('https://payproxyapi.hubtel.com/items/initiate', [
|
||||
'totalAmount' => round($amountMinor / 100, 2),
|
||||
'description' => (string) ($metadata['title'] ?? 'Payment'),
|
||||
'callbackUrl' => $callbackUrl,
|
||||
'returnUrl' => $callbackUrl,
|
||||
'merchantAccountNumber' => (string) $setting->public_key,
|
||||
'cancellationUrl' => $callbackUrl,
|
||||
'clientReference' => $reference,
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
throw new RuntimeException($response->json('message') ?: 'Hubtel could not start checkout.');
|
||||
}
|
||||
|
||||
$url = (string) ($response->json('data.checkoutUrl') ?? $response->json('data.checkoutDirectUrl') ?? '');
|
||||
if ($url === '') {
|
||||
throw new RuntimeException('Hubtel did not return a checkout URL.');
|
||||
}
|
||||
|
||||
return [
|
||||
'checkout_url' => $url,
|
||||
'reference' => $reference,
|
||||
'provider' => PaymentGatewaySetting::PROVIDER_HUBTEL,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{paid: bool, amount_minor: int, reference: string, provider: string, raw: array<string, mixed>}
|
||||
*/
|
||||
protected function hubtelVerify(PaymentGatewaySetting $setting, string $reference): array
|
||||
{
|
||||
$auth = trim((string) ($setting->webhook_secret ?: $setting->secret_key));
|
||||
if (! str_contains($auth, ':')) {
|
||||
$auth = trim((string) $setting->public_key).':'.trim((string) $setting->secret_key);
|
||||
}
|
||||
|
||||
$response = Http::withBasicAuth(...explode(':', $auth, 2))
|
||||
->acceptJson()
|
||||
->timeout(20)
|
||||
->get('https://api-txnstatus.hubtel.com/transactions/'.$setting->public_key.'/status', [
|
||||
'clientReference' => $reference,
|
||||
]);
|
||||
|
||||
$data = (array) ($response->json('data') ?? $response->json() ?? []);
|
||||
$status = strtolower((string) ($data['status'] ?? $data['transactionStatus'] ?? ''));
|
||||
$paid = in_array($status, ['success', 'successful', 'paid', 'completed'], true);
|
||||
$amountMajor = (float) ($data['amount'] ?? $data['totalAmount'] ?? 0);
|
||||
|
||||
return [
|
||||
'paid' => $paid,
|
||||
'amount_minor' => (int) round($amountMajor * 100),
|
||||
'reference' => $reference,
|
||||
'provider' => PaymentGatewaySetting::PROVIDER_HUBTEL,
|
||||
'raw' => $data,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,8 @@ class QrCodeManagerService
|
||||
private QrWalletBillingService $billing,
|
||||
private QrImageGeneratorService $imageGenerator,
|
||||
private QrPayloadValidator $payloadValidator,
|
||||
private \App\Services\Meet\EventMeetSyncService $meetSync,
|
||||
private \App\Services\Events\EventMailCalendarSyncService $mailCalendar,
|
||||
) {}
|
||||
|
||||
public function walletFor(User $user): QrWallet
|
||||
@@ -42,7 +44,11 @@ class QrCodeManagerService
|
||||
|
||||
$type = (string) ($data['type'] ?? '');
|
||||
$validated = $this->payloadValidator->validateForCreate($type, $data);
|
||||
$style = QrStyleDefaults::merge($data['style'] ?? null);
|
||||
$styleInput = $data['style'] ?? null;
|
||||
if (in_array($type, [QrCode::TYPE_EVENT, QrCode::TYPE_ITINERARY], true) && empty(array_filter((array) $styleInput))) {
|
||||
$styleInput = $user->getOrCreateQrSetting()->resolvedDefaultStyle();
|
||||
}
|
||||
$style = QrStyleDefaults::merge($styleInput);
|
||||
|
||||
return DB::transaction(function () use ($user, $wallet, $data, $type, $validated, $style) {
|
||||
$documentId = null;
|
||||
@@ -158,6 +164,11 @@ class QrCodeManagerService
|
||||
$this->billing->debitForQrCreation($wallet, $qrCode);
|
||||
$this->imageGenerator->generateAndStore($qrCode);
|
||||
|
||||
if ($type === QrCode::TYPE_EVENT) {
|
||||
$qrCode = $this->meetSync->sync($qrCode->fresh(), $user);
|
||||
$qrCode = $this->mailCalendar->sync($qrCode->fresh(), $user);
|
||||
}
|
||||
|
||||
return $qrCode->fresh(['document']);
|
||||
});
|
||||
}
|
||||
@@ -294,6 +305,17 @@ class QrCodeManagerService
|
||||
$this->imageGenerator->generateAndStore($qrCode);
|
||||
}
|
||||
|
||||
if ($qrCode->type === QrCode::TYPE_EVENT) {
|
||||
$qrCode = $this->meetSync->sync($qrCode->fresh(), $qrCode->user);
|
||||
$qrCode = $this->mailCalendar->sync($qrCode->fresh(), $qrCode->user);
|
||||
}
|
||||
|
||||
if ($qrCode->type === QrCode::TYPE_ITINERARY) {
|
||||
$this->meetSync->syncEventsForProgramme($qrCode->fresh(), $qrCode->user);
|
||||
app(\App\Services\Events\EventSpeakerInviteService::class)
|
||||
->inviteProgrammeHosts($qrCode->fresh(), $qrCode->user);
|
||||
}
|
||||
|
||||
return $qrCode->fresh(['document']);
|
||||
}
|
||||
|
||||
@@ -347,7 +369,7 @@ class QrCodeManagerService
|
||||
QrCode::TYPE_VCARD => ['first_name', 'last_name', 'phone', 'email', 'company', 'website', 'address', 'note', 'social'],
|
||||
QrCode::TYPE_BUSINESS => ['name', 'tagline', 'phone', 'email', 'website', 'address', 'hours'],
|
||||
QrCode::TYPE_CHURCH => ['name', 'denomination', 'description', 'phone', 'email', 'website', 'address', 'service_times', 'org_type', 'accepts_payment', 'collection_types', 'brand_color'],
|
||||
QrCode::TYPE_EVENT => ['name', 'tagline', 'description', 'location', 'starts_at', 'ends_at', 'organizer', 'website', 'brand_color', 'tiers', 'badge_fields', 'badge_size', 'registration_open'],
|
||||
QrCode::TYPE_EVENT => ['name', 'tagline', 'description', 'location', 'starts_at', 'ends_at', 'organizer', 'website', 'brand_color', 'tiers', 'badge_fields', 'badge_size', 'registration_open', 'format', 'virtual_sessions', 'speakers', 'programme_qr_id'],
|
||||
QrCode::TYPE_ITINERARY => ['title', 'subtitle', 'description', 'event_date', 'location', 'brand_color', 'days'],
|
||||
QrCode::TYPE_DOCUMENT => ['allow_download'],
|
||||
QrCode::TYPE_MENU => ['menu_title', 'sections', 'accepts_payment', 'brand_color', 'shipping_type', 'shipping_fee', 'free_shipping_above'],
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Services\Qr;
|
||||
|
||||
use App\Models\QrCode;
|
||||
use App\Support\Qr\QrDateFormatter;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Support\Qr\QrTypeCatalog;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use RuntimeException;
|
||||
@@ -51,7 +52,16 @@ class QrPayloadValidator
|
||||
$merged['destination_url'] = $merged['url'];
|
||||
}
|
||||
|
||||
return $this->validateForCreate($qrCode->type, $merged);
|
||||
$result = $this->validateForCreate($qrCode->type, $merged);
|
||||
|
||||
if ($qrCode->type === QrCode::TYPE_EVENT && isset($result['content']['speakers'])) {
|
||||
$result['content']['speakers'] = $this->mergeSpeakerInviteMeta(
|
||||
(array) ($qrCode->content()['speakers'] ?? []),
|
||||
$result['content']['speakers'],
|
||||
);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $input */
|
||||
@@ -260,6 +270,13 @@ class QrPayloadValidator
|
||||
$categories = [];
|
||||
}
|
||||
|
||||
$format = in_array($input['format'] ?? 'in_person', ['in_person', 'virtual', 'hybrid'], true)
|
||||
? ($input['format'] ?? 'in_person')
|
||||
: 'in_person';
|
||||
|
||||
$virtualSessions = $this->normalizeVirtualSessions($input['virtual_sessions'] ?? [], $format);
|
||||
$speakers = $this->normalizeSpeakers($input['speakers'] ?? null, $input);
|
||||
|
||||
// Contributions always take payment; ticketing only for paid tiers; free never.
|
||||
$acceptsPayment = match ($mode) {
|
||||
'contributions' => true,
|
||||
@@ -289,11 +306,136 @@ class QrPayloadValidator
|
||||
'accepts_payment' => $acceptsPayment,
|
||||
'registration_open' => filter_var($input['registration_open'] ?? true, FILTER_VALIDATE_BOOL),
|
||||
'programme_qr_id' => ($pid = (int) ($input['programme_qr_id'] ?? 0)) > 0 ? $pid : null,
|
||||
'format' => $format,
|
||||
'virtual_sessions' => $virtualSessions,
|
||||
'speakers' => $speakers,
|
||||
],
|
||||
'destination_url' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param mixed $sessions */
|
||||
private function normalizeVirtualSessions(mixed $sessions, string $format): array
|
||||
{
|
||||
if ($format === 'in_person') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (! is_array($sessions)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
foreach ($sessions as $session) {
|
||||
if (! is_array($session)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$title = trim((string) ($session['title'] ?? ''));
|
||||
if ($title === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$id = trim((string) ($session['id'] ?? ''));
|
||||
if ($id === '') {
|
||||
$id = 'vs-'.Str::lower(Str::random(12));
|
||||
}
|
||||
|
||||
$roomType = in_array($session['meet_room_type'] ?? '', ['town_hall', 'webinar'], true)
|
||||
? (string) $session['meet_room_type']
|
||||
: 'town_hall';
|
||||
|
||||
$itemRefs = [];
|
||||
foreach ((array) ($session['programme_item_refs'] ?? []) as $ref) {
|
||||
$ref = trim((string) $ref);
|
||||
if ($ref !== '') {
|
||||
$itemRefs[] = mb_substr($ref, 0, 80);
|
||||
}
|
||||
}
|
||||
|
||||
$normalized[] = [
|
||||
'id' => mb_substr($id, 0, 64),
|
||||
'title' => mb_substr($title, 0, 120),
|
||||
'meet_room_type' => $roomType,
|
||||
'scheduled_at' => QrDateFormatter::normalize((string) ($session['scheduled_at'] ?? '')),
|
||||
'duration_minutes' => max(15, min(480, (int) ($session['duration_minutes'] ?? 60))),
|
||||
'meet_room_uuid' => ($uuid = trim((string) ($session['meet_room_uuid'] ?? ''))) !== '' ? $uuid : null,
|
||||
'join_url' => ($url = trim((string) ($session['join_url'] ?? ''))) !== '' ? $url : null,
|
||||
'programme_item_refs' => array_values($itemRefs),
|
||||
];
|
||||
}
|
||||
|
||||
return array_values($normalized);
|
||||
}
|
||||
|
||||
/** @return list<array{name: string, email: string, role: string, bio: string, invite_token?: string, invited_at?: string, invite_source?: string}> */
|
||||
private function normalizeSpeakers(mixed $speakers, array $input): array
|
||||
{
|
||||
$rows = is_array($speakers) ? $speakers : (array) ($input['speakers'] ?? []);
|
||||
$normalized = [];
|
||||
|
||||
foreach ($rows as $speaker) {
|
||||
if (! is_array($speaker)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = trim((string) ($speaker['name'] ?? ''));
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$email = trim((string) ($speaker['email'] ?? ''));
|
||||
if ($email === '' || ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
throw new RuntimeException('Each speaker must have a valid email address.');
|
||||
}
|
||||
|
||||
$row = [
|
||||
'name' => mb_substr($name, 0, 120),
|
||||
'email' => $email,
|
||||
'role' => mb_substr(trim((string) ($speaker['role'] ?? '')), 0, 80),
|
||||
'bio' => mb_substr(trim((string) ($speaker['bio'] ?? '')), 0, 500),
|
||||
];
|
||||
|
||||
foreach (['invite_token', 'invited_at', 'invite_source'] as $key) {
|
||||
if (! empty($speaker[$key])) {
|
||||
$row[$key] = $speaker[$key];
|
||||
}
|
||||
}
|
||||
|
||||
$normalized[] = $row;
|
||||
}
|
||||
|
||||
return array_values($normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $previous
|
||||
* @param list<array<string, mixed>> $current
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function mergeSpeakerInviteMeta(array $previous, array $current): array
|
||||
{
|
||||
$byEmail = collect($previous)
|
||||
->filter(fn ($row) => is_array($row) && filled($row['email'] ?? null))
|
||||
->keyBy(fn ($row) => strtolower((string) $row['email']));
|
||||
|
||||
return collect($current)->map(function (array $speaker) use ($byEmail) {
|
||||
$email = strtolower((string) ($speaker['email'] ?? ''));
|
||||
$prev = $byEmail->get($email);
|
||||
if (! $prev) {
|
||||
return $speaker;
|
||||
}
|
||||
|
||||
foreach (['invite_token', 'invited_at', 'invite_source'] as $key) {
|
||||
if (! empty($prev[$key]) && empty($speaker[$key])) {
|
||||
$speaker[$key] = $prev[$key];
|
||||
}
|
||||
}
|
||||
|
||||
return $speaker;
|
||||
})->values()->all();
|
||||
}
|
||||
|
||||
/** @param array<int, array{price: float}> $tiers */
|
||||
private function eventHasPaidTier(array $tiers): bool
|
||||
{
|
||||
@@ -316,12 +458,12 @@ class QrPayloadValidator
|
||||
|
||||
// Days: [{ label, date, items: [{ time, title, description, location, host }] }]
|
||||
$days = [];
|
||||
foreach ((array) ($input['days'] ?? []) as $day) {
|
||||
foreach ((array) ($input['days'] ?? []) as $dayIndex => $day) {
|
||||
if (! is_array($day)) {
|
||||
continue;
|
||||
}
|
||||
$items = [];
|
||||
foreach ((array) ($day['items'] ?? []) as $item) {
|
||||
foreach ((array) ($day['items'] ?? []) as $itemIndex => $item) {
|
||||
if (! is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
@@ -329,12 +471,24 @@ class QrPayloadValidator
|
||||
if ($itemTitle === '') {
|
||||
continue;
|
||||
}
|
||||
$hostSpeakerEmail = strtolower(trim((string) ($item['host_speaker_email'] ?? '')));
|
||||
if ($hostSpeakerEmail !== '' && ! filter_var($hostSpeakerEmail, FILTER_VALIDATE_EMAIL)) {
|
||||
$hostSpeakerEmail = '';
|
||||
}
|
||||
|
||||
$host = mb_substr(trim((string) ($item['host'] ?? '')), 0, 120);
|
||||
if ($host === '' && $hostSpeakerEmail !== '') {
|
||||
$host = mb_substr(strtok($hostSpeakerEmail, '@') ?: $hostSpeakerEmail, 0, 120);
|
||||
}
|
||||
|
||||
$items[] = [
|
||||
'ref' => 'day'.$dayIndex.'-item'.$itemIndex,
|
||||
'time' => mb_substr(trim((string) ($item['time'] ?? '')), 0, 40),
|
||||
'title' => mb_substr($itemTitle, 0, 140),
|
||||
'description' => mb_substr(trim((string) ($item['description'] ?? '')), 0, 400),
|
||||
'location' => mb_substr(trim((string) ($item['location'] ?? '')), 0, 120),
|
||||
'host' => mb_substr(trim((string) ($item['host'] ?? '')), 0, 120),
|
||||
'host' => $host,
|
||||
'host_speaker_email' => $hostSpeakerEmail,
|
||||
];
|
||||
}
|
||||
if (empty($items) && trim((string) ($day['label'] ?? '')) === '') {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\QrSetting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class AccountBranding
|
||||
{
|
||||
public static function forUser(User $user): QrSetting
|
||||
{
|
||||
return $user->getOrCreateQrSetting();
|
||||
}
|
||||
|
||||
public static function forOwnerRef(string $ownerPublicId): ?QrSetting
|
||||
{
|
||||
$user = User::query()->where('public_id', $ownerPublicId)->first();
|
||||
|
||||
return $user ? $user->getOrCreateQrSetting() : null;
|
||||
}
|
||||
|
||||
public static function companyName(?QrSetting $settings, ?User $user = null): string
|
||||
{
|
||||
if ($user) {
|
||||
return (string) ($user->name ?: 'Company');
|
||||
}
|
||||
|
||||
if ($settings?->user) {
|
||||
return (string) ($settings->user->name ?: 'Company');
|
||||
}
|
||||
|
||||
if ($settings?->user_id) {
|
||||
$owner = User::query()->find($settings->user_id);
|
||||
|
||||
return (string) ($owner?->name ?: 'Company');
|
||||
}
|
||||
|
||||
return 'Company';
|
||||
}
|
||||
|
||||
public static function hasCustomLogo(?QrSetting $settings): bool
|
||||
{
|
||||
return $settings !== null
|
||||
&& $settings->logo_path !== null
|
||||
&& Storage::disk('public')->exists($settings->logo_path);
|
||||
}
|
||||
|
||||
public static function emailLogoUrl(?QrSetting $settings): ?string
|
||||
{
|
||||
if (! self::hasCustomLogo($settings)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$ext = strtolower(pathinfo((string) $settings->logo_path, PATHINFO_EXTENSION));
|
||||
if ($ext === 'svg') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$version = $settings->updated_at?->getTimestamp() ?? time();
|
||||
|
||||
return Storage::disk('public')->url($settings->logo_path).'?v='.$version;
|
||||
}
|
||||
|
||||
public static function logoUrl(?QrSetting $settings): ?string
|
||||
{
|
||||
if (! self::hasCustomLogo($settings)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$version = $settings->updated_at?->getTimestamp() ?? time();
|
||||
|
||||
return Storage::disk('public')->url($settings->logo_path).'?v='.$version;
|
||||
}
|
||||
|
||||
public static function storeLogo(QrSetting $settings, UploadedFile $file): string
|
||||
{
|
||||
self::deleteStoredLogo($settings);
|
||||
|
||||
return $file->store('events/accounts/'.$settings->user_id, 'public');
|
||||
}
|
||||
|
||||
public static function deleteStoredLogo(QrSetting $settings): void
|
||||
{
|
||||
if ($settings->logo_path) {
|
||||
Storage::disk('public')->delete($settings->logo_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,634 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* Canonical shared demo world for the Ladill suite.
|
||||
*
|
||||
* Single source of truth for business identity, people, companies, branches,
|
||||
* and staff rosters used across Care, Queue, Frontdesk, CRM, Invoice,
|
||||
* Accounting, POS, Meet, Events, and Woo. Suite apps copy this file via
|
||||
* `php scripts/sync-demo-world.php` — keep copies byte-identical.
|
||||
*
|
||||
* Continuity is by matching identifiers (phone, email, national_id, company
|
||||
* name), not cross-database foreign keys.
|
||||
*
|
||||
* Reseed policy: only owner demo emails (`demo-{tier}@ladill.com`) trigger
|
||||
* full product reseed on login. Staff logins never wipe mid-demo.
|
||||
*/
|
||||
final class DemoWorld
|
||||
{
|
||||
public const PASSWORD_ENV = 'DEMO_ACCOUNT_PASSWORD';
|
||||
|
||||
public const DEFAULT_PASSWORD = 'DemoLadill2026!';
|
||||
|
||||
public const TIMEZONE = 'Africa/Accra';
|
||||
|
||||
public const CURRENCY = 'GHS';
|
||||
|
||||
/** Anchor “today” for same-day appointments / tickets / visits (relative). */
|
||||
public const STORY_DAY_OFFSET = 0;
|
||||
|
||||
/**
|
||||
* @var array<string, array{
|
||||
* name: string,
|
||||
* legal_name: string,
|
||||
* phone: string,
|
||||
* email: string,
|
||||
* address: string,
|
||||
* city: string,
|
||||
* region: string,
|
||||
* timezone: string
|
||||
* }>
|
||||
*/
|
||||
public const BUSINESS = [
|
||||
'free' => [
|
||||
'name' => 'Ridge Family Clinic',
|
||||
'legal_name' => 'Ridge Family Clinic Ltd',
|
||||
'phone' => '+233302400100',
|
||||
'email' => 'hello@ridgeclinic.demo.ladill.com',
|
||||
'address' => '12 Castle Road, Ridge, Accra',
|
||||
'city' => 'Accra',
|
||||
'region' => 'Greater Accra',
|
||||
'timezone' => self::TIMEZONE,
|
||||
],
|
||||
'pro' => [
|
||||
'name' => 'Ridge Medical Centre',
|
||||
'legal_name' => 'Ridge Medical Centre Ltd',
|
||||
'phone' => '+233302400200',
|
||||
'email' => 'hello@ridgemedical.demo.ladill.com',
|
||||
'address' => '45 Independence Ave, Ridge, Accra',
|
||||
'city' => 'Accra',
|
||||
'region' => 'Greater Accra',
|
||||
'timezone' => self::TIMEZONE,
|
||||
],
|
||||
'enterprise' => [
|
||||
'name' => 'Accra Healthcare Group',
|
||||
'legal_name' => 'Accra Healthcare Group Ltd',
|
||||
'phone' => '+233302400300',
|
||||
'email' => 'hello@accrahealth.demo.ladill.com',
|
||||
'address' => '1 Liberation Road, Airport Residential, Accra',
|
||||
'city' => 'Accra',
|
||||
'region' => 'Greater Accra',
|
||||
'timezone' => self::TIMEZONE,
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* Shared branch catalog. Plans take the first N (free=1, pro=3, enterprise=6).
|
||||
*
|
||||
* @var list<array{code: string, name: string, phone: string, address: string}>
|
||||
*/
|
||||
public const BRANCHES = [
|
||||
['code' => 'HQ', 'name' => 'Ridge Clinic', 'phone' => '+233302400101', 'address' => '12 Castle Road, Ridge, Accra'],
|
||||
['code' => 'EL', 'name' => 'East Legon Care', 'phone' => '+233302400102', 'address' => '18 Boundary Rd, East Legon, Accra'],
|
||||
['code' => 'TM', 'name' => 'Tema Harbour Clinic', 'phone' => '+233302400103', 'address' => 'Harbour Road, Community 1, Tema'],
|
||||
['code' => 'OS', 'name' => 'Osu Medical Centre', 'phone' => '+233302400104', 'address' => 'Oxford Street, Osu, Accra'],
|
||||
['code' => 'SX', 'name' => 'Spintex Wellness', 'phone' => '+233302400105', 'address' => 'Spintex Road, Accra'],
|
||||
['code' => 'AP', 'name' => 'Airport Residential Care', 'phone' => '+233302400106', 'address' => 'Liberation Rd, Airport Residential, Accra'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Canonical people reused as patients / visitors / ticket guests / CRM
|
||||
* contacts / invoice clients / POS customers / event attendees.
|
||||
*
|
||||
* @var list<array{
|
||||
* key: string,
|
||||
* first_name: string,
|
||||
* last_name: string,
|
||||
* phone: string,
|
||||
* email: string,
|
||||
* national_id: string,
|
||||
* gender: string,
|
||||
* dob: string,
|
||||
* company_key: string,
|
||||
* city: string,
|
||||
* address: string
|
||||
* }>
|
||||
*/
|
||||
public const PEOPLE = [
|
||||
['key' => 'ama-mensah', 'first_name' => 'Ama', 'last_name' => 'Mensah', 'phone' => '+233241000001', 'email' => 'ama.mensah@demo.ladill.com', 'national_id' => 'GHA-729001001', 'gender' => 'female', 'dob' => '1990-03-12', 'company_key' => 'accra-retail', 'city' => 'Accra', 'address' => '14 Ring Road Central, Accra'],
|
||||
['key' => 'kofi-asante', 'first_name' => 'Kofi', 'last_name' => 'Asante', 'phone' => '+233241000002', 'email' => 'kofi.asante@demo.ladill.com', 'national_id' => 'GHA-729001002', 'gender' => 'male', 'dob' => '1985-07-21', 'company_key' => 'kumasi-foods', 'city' => 'Accra', 'address' => '3 Labone Crescent, Accra'],
|
||||
['key' => 'abena-osei', 'first_name' => 'Abena', 'last_name' => 'Osei', 'phone' => '+233241000003', 'email' => 'abena.osei@demo.ladill.com', 'national_id' => 'GHA-729001003', 'gender' => 'female', 'dob' => '1992-11-05', 'company_key' => 'tema-logistics', 'city' => 'Tema', 'address' => 'Community 4, Tema'],
|
||||
['key' => 'kwame-boateng', 'first_name' => 'Kwame', 'last_name' => 'Boateng', 'phone' => '+233241000004', 'email' => 'kwame.boateng@demo.ladill.com', 'national_id' => 'GHA-729001004', 'gender' => 'male', 'dob' => '1978-01-30', 'company_key' => 'cape-coast-tours', 'city' => 'Accra', 'address' => '8 Cantonments Road, Accra'],
|
||||
['key' => 'akosua-owusu', 'first_name' => 'Akosua', 'last_name' => 'Owusu', 'phone' => '+233241000005', 'email' => 'akosua.owusu@demo.ladill.com', 'national_id' => 'GHA-729001005', 'gender' => 'female', 'dob' => '1995-06-18', 'company_key' => 'tamale-agro', 'city' => 'Accra', 'address' => '22 Dansoman Estates, Accra'],
|
||||
['key' => 'yaw-adjei', 'first_name' => 'Yaw', 'last_name' => 'Adjei', 'phone' => '+233241000006', 'email' => 'yaw.adjei@demo.ladill.com', 'national_id' => 'GHA-729001006', 'gender' => 'male', 'dob' => '1988-09-09', 'company_key' => 'spintex-clinics', 'city' => 'Accra', 'address' => 'Spintex Junction, Accra'],
|
||||
['key' => 'efua-appiah', 'first_name' => 'Efua', 'last_name' => 'Appiah', 'phone' => '+233241000007', 'email' => 'efua.appiah@demo.ladill.com', 'national_id' => 'GHA-729001007', 'gender' => 'female', 'dob' => '1993-02-14', 'company_key' => 'osu-cafe', 'city' => 'Accra', 'address' => 'Oxford Street, Osu, Accra'],
|
||||
['key' => 'kojo-darko', 'first_name' => 'Kojo', 'last_name' => 'Darko', 'phone' => '+233241000008', 'email' => 'kojo.darko@demo.ladill.com', 'national_id' => 'GHA-729001008', 'gender' => 'male', 'dob' => '1982-12-01', 'company_key' => 'legon-tech', 'city' => 'Accra', 'address' => 'University Area, Legon'],
|
||||
['key' => 'adwoa-agyeman', 'first_name' => 'Adwoa', 'last_name' => 'Agyeman', 'phone' => '+233241000009', 'email' => 'adwoa.agyeman@demo.ladill.com', 'national_id' => 'GHA-729001009', 'gender' => 'female', 'dob' => '1991-04-22', 'company_key' => 'airport-hotels', 'city' => 'Accra', 'address' => 'Airport Residential, Accra'],
|
||||
['key' => 'kwesi-sarpong', 'first_name' => 'Kwesi', 'last_name' => 'Sarpong', 'phone' => '+233241000010', 'email' => 'kwesi.sarpong@demo.ladill.com', 'national_id' => 'GHA-729001010', 'gender' => 'male', 'dob' => '1987-08-16', 'company_key' => 'kaneshie-traders', 'city' => 'Accra', 'address' => 'Kaneshie Market, Accra'],
|
||||
['key' => 'afia-nyarko', 'first_name' => 'Afia', 'last_name' => 'Nyarko', 'phone' => '+233241000011', 'email' => 'afia.nyarko@demo.ladill.com', 'national_id' => 'GHA-729001011', 'gender' => 'female', 'dob' => '1994-10-03', 'company_key' => 'accra-retail', 'city' => 'Accra', 'address' => 'Adabraka, Accra'],
|
||||
['key' => 'kwadwo-ankrah', 'first_name' => 'Kwadwo', 'last_name' => 'Ankrah', 'phone' => '+233241000012', 'email' => 'kwadwo.ankrah@demo.ladill.com', 'national_id' => 'GHA-729001012', 'gender' => 'male', 'dob' => '1980-05-27', 'company_key' => 'kumasi-foods', 'city' => 'Accra', 'address' => 'Mamprobi, Accra'],
|
||||
['key' => 'esi-tetteh', 'first_name' => 'Esi', 'last_name' => 'Tetteh', 'phone' => '+233241000013', 'email' => 'esi.tetteh@demo.ladill.com', 'national_id' => 'GHA-729001013', 'gender' => 'female', 'dob' => '1996-01-19', 'company_key' => 'tema-logistics', 'city' => 'Tema', 'address' => 'Community 8, Tema'],
|
||||
['key' => 'fiifi-quaye', 'first_name' => 'Fiifi', 'last_name' => 'Quaye', 'phone' => '+233241000014', 'email' => 'fiifi.quaye@demo.ladill.com', 'national_id' => 'GHA-729001014', 'gender' => 'male', 'dob' => '1989-07-07', 'company_key' => 'cape-coast-tours', 'city' => 'Accra', 'address' => 'Tesano, Accra'],
|
||||
['key' => 'mansa-addo', 'first_name' => 'Mansa', 'last_name' => 'Addo', 'phone' => '+233241000015', 'email' => 'mansa.addo@demo.ladill.com', 'national_id' => 'GHA-729001015', 'gender' => 'female', 'dob' => '1993-09-25', 'company_key' => 'tamale-agro', 'city' => 'Accra', 'address' => 'Abelemkpe, Accra'],
|
||||
['key' => 'nana-frimpong', 'first_name' => 'Nana', 'last_name' => 'Frimpong', 'phone' => '+233241000016', 'email' => 'nana.frimpong@demo.ladill.com', 'national_id' => 'GHA-729001016', 'gender' => 'male', 'dob' => '1984-03-11', 'company_key' => 'spintex-clinics', 'city' => 'Accra', 'address' => 'Teshie, Accra'],
|
||||
['key' => 'ama-boateng', 'first_name' => 'Ama', 'last_name' => 'Boateng', 'phone' => '+233241000017', 'email' => 'ama.boateng@demo.ladill.com', 'national_id' => 'GHA-729001017', 'gender' => 'female', 'dob' => '1997-12-08', 'company_key' => 'osu-cafe', 'city' => 'Accra', 'address' => 'Kokomlemle, Accra'],
|
||||
['key' => 'kofi-owusu', 'first_name' => 'Kofi', 'last_name' => 'Owusu', 'phone' => '+233241000018', 'email' => 'kofi.owusu@demo.ladill.com', 'national_id' => 'GHA-729001018', 'gender' => 'male', 'dob' => '1986-06-02', 'company_key' => 'legon-tech', 'city' => 'Accra', 'address' => 'Haatso, Accra'],
|
||||
['key' => 'abena-darko', 'first_name' => 'Abena', 'last_name' => 'Darko', 'phone' => '+233241000019', 'email' => 'abena.darko@demo.ladill.com', 'national_id' => 'GHA-729001019', 'gender' => 'female', 'dob' => '1990-08-29', 'company_key' => 'airport-hotels', 'city' => 'Accra', 'address' => 'Dzorwulu, Accra'],
|
||||
['key' => 'kwame-mensah', 'first_name' => 'Kwame', 'last_name' => 'Mensah', 'phone' => '+233241000020', 'email' => 'kwame.mensah@demo.ladill.com', 'national_id' => 'GHA-729001020', 'gender' => 'male', 'dob' => '1979-11-13', 'company_key' => 'kaneshie-traders', 'city' => 'Accra', 'address' => 'Ablekuma, Accra'],
|
||||
['key' => 'akosua-asante', 'first_name' => 'Akosua', 'last_name' => 'Asante', 'phone' => '+233241000021', 'email' => 'akosua.asante@demo.ladill.com', 'national_id' => 'GHA-729001021', 'gender' => 'female', 'dob' => '1992-02-20', 'company_key' => 'accra-retail', 'city' => 'Accra', 'address' => 'Roman Ridge, Accra'],
|
||||
['key' => 'yaw-osei', 'first_name' => 'Yaw', 'last_name' => 'Osei', 'phone' => '+233241000022', 'email' => 'yaw.osei@demo.ladill.com', 'national_id' => 'GHA-729001022', 'gender' => 'male', 'dob' => '1983-04-15', 'company_key' => 'kumasi-foods', 'city' => 'Accra', 'address' => 'Nungua, Accra'],
|
||||
['key' => 'efua-boateng', 'first_name' => 'Efua', 'last_name' => 'Boateng', 'phone' => '+233241000023', 'email' => 'efua.boateng@demo.ladill.com', 'national_id' => 'GHA-729001023', 'gender' => 'female', 'dob' => '1995-07-01', 'company_key' => 'tema-logistics', 'city' => 'Tema', 'address' => 'Community 2, Tema'],
|
||||
['key' => 'kojo-owusu', 'first_name' => 'Kojo', 'last_name' => 'Owusu', 'phone' => '+233241000024', 'email' => 'kojo.owusu@demo.ladill.com', 'national_id' => 'GHA-729001024', 'gender' => 'male', 'dob' => '1981-10-28', 'company_key' => 'cape-coast-tours', 'city' => 'Accra', 'address' => 'Madina, Accra'],
|
||||
['key' => 'adwoa-adjei', 'first_name' => 'Adwoa', 'last_name' => 'Adjei', 'phone' => '+233241000025', 'email' => 'adwoa.adjei@demo.ladill.com', 'national_id' => 'GHA-729001025', 'gender' => 'female', 'dob' => '1994-05-06', 'company_key' => 'tamale-agro', 'city' => 'Accra', 'address' => 'Ashaiman, Accra'],
|
||||
['key' => 'kwesi-appiah', 'first_name' => 'Kwesi', 'last_name' => 'Appiah', 'phone' => '+233241000026', 'email' => 'kwesi.appiah@demo.ladill.com', 'national_id' => 'GHA-729001026', 'gender' => 'male', 'dob' => '1988-12-17', 'company_key' => 'spintex-clinics', 'city' => 'Accra', 'address' => 'Baatsona, Accra'],
|
||||
['key' => 'afia-darko', 'first_name' => 'Afia', 'last_name' => 'Darko', 'phone' => '+233241000027', 'email' => 'afia.darko@demo.ladill.com', 'national_id' => 'GHA-729001027', 'gender' => 'female', 'dob' => '1991-09-23', 'company_key' => 'osu-cafe', 'city' => 'Accra', 'address' => 'Labadi, Accra'],
|
||||
['key' => 'kwadwo-agyeman', 'first_name' => 'Kwadwo', 'last_name' => 'Agyeman', 'phone' => '+233241000028', 'email' => 'kwadwo.agyeman@demo.ladill.com', 'national_id' => 'GHA-729001028', 'gender' => 'male', 'dob' => '1985-01-04', 'company_key' => 'legon-tech', 'city' => 'Accra', 'address' => 'Kwabenya, Accra'],
|
||||
['key' => 'esi-sarpong', 'first_name' => 'Esi', 'last_name' => 'Sarpong', 'phone' => '+233241000029', 'email' => 'esi.sarpong@demo.ladill.com', 'national_id' => 'GHA-729001029', 'gender' => 'female', 'dob' => '1996-03-30', 'company_key' => 'airport-hotels', 'city' => 'Accra', 'address' => 'East Legon Hills, Accra'],
|
||||
['key' => 'fiifi-nyarko', 'first_name' => 'Fiifi', 'last_name' => 'Nyarko', 'phone' => '+233241000030', 'email' => 'fiifi.nyarko@demo.ladill.com', 'national_id' => 'GHA-729001030', 'gender' => 'male', 'dob' => '1987-06-14', 'company_key' => 'kaneshie-traders', 'city' => 'Accra', 'address' => 'Odorkor, Accra'],
|
||||
['key' => 'mansa-ankrah', 'first_name' => 'Mansa', 'last_name' => 'Ankrah', 'phone' => '+233241000031', 'email' => 'mansa.ankrah@demo.ladill.com', 'national_id' => 'GHA-729001031', 'gender' => 'female', 'dob' => '1993-11-11', 'company_key' => 'accra-retail', 'city' => 'Accra', 'address' => 'Korle Bu, Accra'],
|
||||
['key' => 'nana-tetteh', 'first_name' => 'Nana', 'last_name' => 'Tetteh', 'phone' => '+233241000032', 'email' => 'nana.tetteh@demo.ladill.com', 'national_id' => 'GHA-729001032', 'gender' => 'male', 'dob' => '1982-08-08', 'company_key' => 'kumasi-foods', 'city' => 'Accra', 'address' => 'Weija, Accra'],
|
||||
['key' => 'ama-quaye', 'first_name' => 'Ama', 'last_name' => 'Quaye', 'phone' => '+233241000033', 'email' => 'ama.quaye@demo.ladill.com', 'national_id' => 'GHA-729001033', 'gender' => 'female', 'dob' => '1990-04-04', 'company_key' => 'tema-logistics', 'city' => 'Tema', 'address' => 'Community 11, Tema'],
|
||||
['key' => 'kofi-addo', 'first_name' => 'Kofi', 'last_name' => 'Addo', 'phone' => '+233241000034', 'email' => 'kofi.addo@demo.ladill.com', 'national_id' => 'GHA-729001034', 'gender' => 'male', 'dob' => '1989-02-26', 'company_key' => 'cape-coast-tours', 'city' => 'Accra', 'address' => 'Kasoa Road, Accra'],
|
||||
['key' => 'abena-frimpong', 'first_name' => 'Abena', 'last_name' => 'Frimpong', 'phone' => '+233241000035', 'email' => 'abena.frimpong@demo.ladill.com', 'national_id' => 'GHA-729001035', 'gender' => 'female', 'dob' => '1994-07-19', 'company_key' => 'tamale-agro', 'city' => 'Accra', 'address' => 'Achimota, Accra'],
|
||||
['key' => 'kwame-agyeman', 'first_name' => 'Kwame', 'last_name' => 'Agyeman', 'phone' => '+233241000036', 'email' => 'kwame.agyeman@demo.ladill.com', 'national_id' => 'GHA-729001036', 'gender' => 'male', 'dob' => '1980-09-01', 'company_key' => 'spintex-clinics', 'city' => 'Accra', 'address' => 'Sakumono, Accra'],
|
||||
];
|
||||
|
||||
/**
|
||||
* @var list<array{key: string, name: string, email: string, phone: string, address: string}>
|
||||
*/
|
||||
public const COMPANIES = [
|
||||
['key' => 'accra-retail', 'name' => 'Accra Retail Ltd', 'email' => 'accounts@accra-retail.demo.ladill.com', 'phone' => '+233302500001', 'address' => 'Oxford Street, Accra'],
|
||||
['key' => 'kumasi-foods', 'name' => 'Kumasi Foods', 'email' => 'billing@kumasi-foods.demo.ladill.com', 'phone' => '+233302500002', 'address' => 'Kejetia Road, Kumasi'],
|
||||
['key' => 'tema-logistics', 'name' => 'Tema Logistics', 'email' => 'finance@tema-logistics.demo.ladill.com', 'phone' => '+233302500003', 'address' => 'Harbour Road, Tema'],
|
||||
['key' => 'cape-coast-tours', 'name' => 'Cape Coast Tours', 'email' => 'pay@cape-coast-tours.demo.ladill.com', 'phone' => '+233302500004', 'address' => 'Castle Road, Cape Coast'],
|
||||
['key' => 'tamale-agro', 'name' => 'Tamale Agro', 'email' => 'accounts@tamale-agro.demo.ladill.com', 'phone' => '+233302500005', 'address' => 'Salaga Road, Tamale'],
|
||||
['key' => 'spintex-clinics', 'name' => 'Spintex Clinics', 'email' => 'billing@spintex-clinics.demo.ladill.com', 'phone' => '+233302500006', 'address' => 'Spintex Road, Accra'],
|
||||
['key' => 'osu-cafe', 'name' => 'Osu Café Co', 'email' => 'hello@osu-cafe.demo.ladill.com', 'phone' => '+233302500007', 'address' => 'Oxford Street, Osu'],
|
||||
['key' => 'legon-tech', 'name' => 'Legon Tech', 'email' => 'ap@legon-tech.demo.ladill.com', 'phone' => '+233302500008', 'address' => 'University Area, Legon'],
|
||||
['key' => 'airport-hotels', 'name' => 'Airport City Hotels', 'email' => 'finance@airport-hotels.demo.ladill.com', 'phone' => '+233302500009', 'address' => 'Airport City, Accra'],
|
||||
['key' => 'kaneshie-traders', 'name' => 'Kaneshie Market Traders', 'email' => 'pay@kaneshie-traders.demo.ladill.com', 'phone' => '+233302500010', 'address' => 'Kaneshie Market, Accra'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Plausible shared story amounts (GHS major units) for Accounting / Invoice / POS labels.
|
||||
*
|
||||
* @var array{care_consult: int, care_lab: int, invoice_typical: int, pos_typical: int, crm_deal: int}
|
||||
*/
|
||||
public const STORY_AMOUNTS_GHS = [
|
||||
'care_consult' => 250,
|
||||
'care_lab' => 180,
|
||||
'invoice_typical' => 1500,
|
||||
'pos_typical' => 85,
|
||||
'crm_deal' => 12000,
|
||||
];
|
||||
|
||||
/**
|
||||
* Staff roster keyed by tier. Free: owner + receptionist. Pro/Enterprise: full roster.
|
||||
*
|
||||
* @var array<string, list<array{
|
||||
* key: string,
|
||||
* email: string,
|
||||
* name: string,
|
||||
* apps: list<string>,
|
||||
* roles: array<string, string>,
|
||||
* account_role?: string
|
||||
* }>>
|
||||
*/
|
||||
/**
|
||||
* Care specialty module doctors (Pro / Enterprise). Emails: demo-{tier}-{key}@ladill.com
|
||||
*
|
||||
* @var array<string, string> module key => display name
|
||||
*/
|
||||
public const SPECIALTY_DOCTORS = [
|
||||
'emergency' => 'Dr. Kojo Emergency',
|
||||
'blood_bank' => 'Dr. Ama Blood Bank',
|
||||
'dentistry' => 'Dr. Ama Dental',
|
||||
'ophthalmology' => 'Dr. Kofi Eye',
|
||||
'physiotherapy' => 'Dr. Efua Physio',
|
||||
'maternity' => 'Dr. Abena Maternity',
|
||||
'radiology' => 'Dr. Yaw Radiology',
|
||||
'cardiology' => 'Dr. Kwesi Cardiology',
|
||||
'psychiatry' => 'Dr. Adwoa Psychiatry',
|
||||
'pediatrics' => 'Dr. Fiifi Pediatrics',
|
||||
'orthopedics' => 'Dr. Kojo Orthopedics',
|
||||
'ent' => 'Dr. Esi ENT',
|
||||
'oncology' => 'Dr. Mansa Oncology',
|
||||
'renal' => 'Dr. Nana Renal',
|
||||
'surgery' => 'Dr. Kwadwo Surgery',
|
||||
'vaccination' => 'Dr. Afia Vaccination',
|
||||
'pathology' => 'Dr. Yaw Pathology',
|
||||
'infusion' => 'Dr. Akosua Infusion',
|
||||
'dermatology' => 'Dr. Ama Dermatology',
|
||||
'podiatry' => 'Dr. Kofi Podiatry',
|
||||
'fertility' => 'Dr. Abena Fertility',
|
||||
'child_welfare' => 'Dr. Efua Child Welfare',
|
||||
];
|
||||
|
||||
public const STAFF = [
|
||||
'free' => [
|
||||
[
|
||||
'key' => 'receptionist',
|
||||
'email' => 'demo-free-receptionist@ladill.com',
|
||||
'name' => 'Abena Reception (Free)',
|
||||
'apps' => ['care', 'frontdesk'],
|
||||
'roles' => [
|
||||
'care' => 'receptionist',
|
||||
'frontdesk' => 'receptionist',
|
||||
],
|
||||
],
|
||||
[
|
||||
'key' => 'queue',
|
||||
'email' => 'demo-free-queue@ladill.com',
|
||||
'name' => 'Ama Queue (Free)',
|
||||
'apps' => ['queue'],
|
||||
'roles' => ['queue' => 'receptionist'],
|
||||
],
|
||||
],
|
||||
'pro' => [
|
||||
[
|
||||
'key' => 'doctor',
|
||||
'email' => 'demo-pro-doctor@ladill.com',
|
||||
'name' => 'Dr. Kwame Mensah (Pro)',
|
||||
'apps' => ['care'],
|
||||
'roles' => ['care' => 'doctor'],
|
||||
],
|
||||
[
|
||||
'key' => 'nurse',
|
||||
'email' => 'demo-pro-nurse@ladill.com',
|
||||
'name' => 'Akosua Nurse (Pro)',
|
||||
'apps' => ['care'],
|
||||
'roles' => ['care' => 'nurse'],
|
||||
],
|
||||
[
|
||||
'key' => 'receptionist',
|
||||
'email' => 'demo-pro-receptionist@ladill.com',
|
||||
'name' => 'Abena Reception (Pro)',
|
||||
'apps' => ['care', 'frontdesk'],
|
||||
'roles' => [
|
||||
'care' => 'receptionist',
|
||||
'frontdesk' => 'receptionist',
|
||||
],
|
||||
],
|
||||
[
|
||||
'key' => 'queue',
|
||||
'email' => 'demo-pro-queue@ladill.com',
|
||||
'name' => 'Ama Queue (Pro)',
|
||||
'apps' => ['queue'],
|
||||
'roles' => ['queue' => 'receptionist'],
|
||||
],
|
||||
[
|
||||
'key' => 'pharmacist',
|
||||
'email' => 'demo-pro-pharmacist@ladill.com',
|
||||
'name' => 'Yaw Pharmacist (Pro)',
|
||||
'apps' => ['care'],
|
||||
'roles' => ['care' => 'pharmacist'],
|
||||
],
|
||||
[
|
||||
'key' => 'lab',
|
||||
'email' => 'demo-pro-lab@ladill.com',
|
||||
'name' => 'Efua Lab Tech (Pro)',
|
||||
'apps' => ['care'],
|
||||
'roles' => ['care' => 'lab_technician'],
|
||||
],
|
||||
[
|
||||
'key' => 'cashier',
|
||||
'email' => 'demo-pro-cashier@ladill.com',
|
||||
'name' => 'Kojo Cashier (Pro)',
|
||||
'apps' => ['care', 'pos'],
|
||||
'roles' => [
|
||||
'care' => 'cashier',
|
||||
'pos' => 'cashier',
|
||||
],
|
||||
],
|
||||
[
|
||||
'key' => 'accountant',
|
||||
'email' => 'demo-pro-accountant@ladill.com',
|
||||
'name' => 'Adwoa Accountant (Pro)',
|
||||
'apps' => ['accounting', 'invoice'],
|
||||
'roles' => [
|
||||
'accounting' => 'member',
|
||||
'invoice' => 'member',
|
||||
'care' => 'accountant',
|
||||
],
|
||||
],
|
||||
[
|
||||
'key' => 'sales',
|
||||
'email' => 'demo-pro-sales@ladill.com',
|
||||
'name' => 'Kofi Sales (Pro)',
|
||||
'apps' => ['crm', 'invoice', 'meet'],
|
||||
'roles' => [
|
||||
'crm' => 'member',
|
||||
'invoice' => 'member',
|
||||
'meet' => 'member',
|
||||
],
|
||||
],
|
||||
],
|
||||
'enterprise' => [
|
||||
[
|
||||
'key' => 'doctor',
|
||||
'email' => 'demo-enterprise-doctor@ladill.com',
|
||||
'name' => 'Dr. Kwame Mensah',
|
||||
'apps' => ['care'],
|
||||
'roles' => ['care' => 'doctor'],
|
||||
],
|
||||
[
|
||||
'key' => 'nurse',
|
||||
'email' => 'demo-enterprise-nurse@ladill.com',
|
||||
'name' => 'Akosua Nurse',
|
||||
'apps' => ['care'],
|
||||
'roles' => ['care' => 'nurse'],
|
||||
],
|
||||
[
|
||||
'key' => 'receptionist',
|
||||
'email' => 'demo-enterprise-receptionist@ladill.com',
|
||||
'name' => 'Abena Reception',
|
||||
'apps' => ['care', 'frontdesk'],
|
||||
'roles' => [
|
||||
'care' => 'receptionist',
|
||||
'frontdesk' => 'receptionist',
|
||||
],
|
||||
],
|
||||
[
|
||||
'key' => 'queue',
|
||||
'email' => 'demo-enterprise-queue@ladill.com',
|
||||
'name' => 'Ama Queue',
|
||||
'apps' => ['queue'],
|
||||
'roles' => ['queue' => 'receptionist'],
|
||||
],
|
||||
[
|
||||
'key' => 'pharmacist',
|
||||
'email' => 'demo-enterprise-pharmacist@ladill.com',
|
||||
'name' => 'Yaw Pharmacist',
|
||||
'apps' => ['care'],
|
||||
'roles' => ['care' => 'pharmacist'],
|
||||
],
|
||||
[
|
||||
'key' => 'lab',
|
||||
'email' => 'demo-enterprise-lab@ladill.com',
|
||||
'name' => 'Efua Lab Tech',
|
||||
'apps' => ['care'],
|
||||
'roles' => ['care' => 'lab_technician'],
|
||||
],
|
||||
[
|
||||
'key' => 'cashier',
|
||||
'email' => 'demo-enterprise-cashier@ladill.com',
|
||||
'name' => 'Kojo Cashier',
|
||||
'apps' => ['care', 'pos'],
|
||||
'roles' => [
|
||||
'care' => 'cashier',
|
||||
'pos' => 'cashier',
|
||||
],
|
||||
],
|
||||
[
|
||||
'key' => 'accountant',
|
||||
'email' => 'demo-enterprise-accountant@ladill.com',
|
||||
'name' => 'Adwoa Accountant',
|
||||
'apps' => ['accounting', 'invoice', 'care'],
|
||||
'roles' => [
|
||||
'accounting' => 'member',
|
||||
'invoice' => 'member',
|
||||
'care' => 'accountant',
|
||||
],
|
||||
],
|
||||
[
|
||||
'key' => 'sales',
|
||||
'email' => 'demo-enterprise-sales@ladill.com',
|
||||
'name' => 'Kofi Sales',
|
||||
'apps' => ['crm', 'invoice', 'meet', 'events'],
|
||||
'roles' => [
|
||||
'crm' => 'member',
|
||||
'invoice' => 'member',
|
||||
'meet' => 'member',
|
||||
'events' => 'member',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
/** @var array<string, string> owner email → tier */
|
||||
public const OWNER_EMAILS = [
|
||||
'demo-free@ladill.com' => 'free',
|
||||
'demo-pro@ladill.com' => 'pro',
|
||||
'demo-enterprise@ladill.com' => 'enterprise',
|
||||
];
|
||||
|
||||
public static function normalizeTier(string $tier): string
|
||||
{
|
||||
$tier = strtolower(trim($tier));
|
||||
|
||||
return in_array($tier, ['free', 'pro', 'enterprise'], true) ? $tier : 'free';
|
||||
}
|
||||
|
||||
public static function ownerEmailForTier(string $tier): string
|
||||
{
|
||||
$tier = self::normalizeTier($tier);
|
||||
|
||||
return match ($tier) {
|
||||
'pro' => 'demo-pro@ladill.com',
|
||||
'enterprise' => 'demo-enterprise@ladill.com',
|
||||
default => 'demo-free@ladill.com',
|
||||
};
|
||||
}
|
||||
|
||||
public static function tierForOwnerEmail(?string $email): ?string
|
||||
{
|
||||
$email = strtolower(trim((string) $email));
|
||||
|
||||
return self::OWNER_EMAILS[$email] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{name: string, legal_name: string, phone: string, email: string, address: string, city: string, region: string, timezone: string}
|
||||
*/
|
||||
public static function business(string $tier): array
|
||||
{
|
||||
return self::BUSINESS[self::normalizeTier($tier)];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{code: string, name: string, phone: string, address: string}>
|
||||
*/
|
||||
public static function branches(string $tier, ?int $limit = null): array
|
||||
{
|
||||
$tier = self::normalizeTier($tier);
|
||||
$limit ??= match ($tier) {
|
||||
'enterprise' => 6,
|
||||
'pro' => 3,
|
||||
default => 1,
|
||||
};
|
||||
|
||||
return array_slice(self::BRANCHES, 0, max(1, $limit));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{key: string, first_name: string, last_name: string, phone: string, email: string, national_id: string, gender: string, dob: string, company_key: string, city: string, address: string}>
|
||||
*/
|
||||
public static function people(?int $limit = null): array
|
||||
{
|
||||
if ($limit === null) {
|
||||
return self::PEOPLE;
|
||||
}
|
||||
|
||||
return array_slice(self::PEOPLE, 0, max(0, $limit));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{key: string, first_name: string, last_name: string, phone: string, email: string, national_id: string, gender: string, dob: string, company_key: string, city: string, address: string}|null
|
||||
*/
|
||||
public static function personByKey(string $key): ?array
|
||||
{
|
||||
foreach (self::PEOPLE as $person) {
|
||||
if ($person['key'] === $key) {
|
||||
return $person;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function fullName(array $person): string
|
||||
{
|
||||
return trim($person['first_name'].' '.$person['last_name']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{key: string, name: string, email: string, phone: string, address: string}>
|
||||
*/
|
||||
public static function companies(?int $limit = null): array
|
||||
{
|
||||
if ($limit === null) {
|
||||
return self::COMPANIES;
|
||||
}
|
||||
|
||||
return array_slice(self::COMPANIES, 0, max(0, $limit));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{key: string, name: string, email: string, phone: string, address: string}|null
|
||||
*/
|
||||
public static function companyByKey(string $key): ?array
|
||||
{
|
||||
foreach (self::COMPANIES as $company) {
|
||||
if ($company['key'] === $key) {
|
||||
return $company;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function companyNameForPerson(array $person): string
|
||||
{
|
||||
return self::companyByKey($person['company_key'])['name'] ?? 'Demo Co';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{key: string, email: string, name: string, apps: list<string>, roles: array<string, string>, account_role?: string}>
|
||||
*/
|
||||
public static function staff(string $tier): array
|
||||
{
|
||||
$tier = self::normalizeTier($tier);
|
||||
$roster = self::STAFF[$tier] ?? [];
|
||||
if (! in_array($tier, ['pro', 'enterprise'], true)) {
|
||||
return $roster;
|
||||
}
|
||||
|
||||
foreach (self::SPECIALTY_DOCTORS as $key => $name) {
|
||||
$email = sprintf('demo-%s-%s@ladill.com', $tier, $key);
|
||||
$already = false;
|
||||
foreach ($roster as $row) {
|
||||
if (strtolower((string) ($row['email'] ?? '')) === $email) {
|
||||
$already = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($already) {
|
||||
continue;
|
||||
}
|
||||
$suffix = $tier === 'pro' ? ' (Pro)' : '';
|
||||
$roster[] = [
|
||||
'key' => $key,
|
||||
'email' => $email,
|
||||
'name' => $name.$suffix,
|
||||
'apps' => ['care'],
|
||||
'roles' => ['care' => 'doctor'],
|
||||
];
|
||||
}
|
||||
|
||||
return $roster;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{key: string, email: string, name: string, apps: list<string>, roles: array<string, string>, account_role?: string}|null
|
||||
*/
|
||||
public static function staffByEmail(?string $email): ?array
|
||||
{
|
||||
$email = strtolower(trim((string) $email));
|
||||
if ($email === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (['free', 'pro', 'enterprise'] as $tier) {
|
||||
foreach (self::staff($tier) as $member) {
|
||||
if (strtolower($member['email']) === $email) {
|
||||
return $member;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function isOwnerDemoEmail(?string $email): bool
|
||||
{
|
||||
return self::tierForOwnerEmail($email) !== null;
|
||||
}
|
||||
|
||||
public static function isStaffDemoEmail(?string $email): bool
|
||||
{
|
||||
return self::staffByEmail($email) !== null;
|
||||
}
|
||||
|
||||
/** Owner email for a staff member, or null if not staff. */
|
||||
public static function ownerEmailForStaff(?string $email): ?string
|
||||
{
|
||||
$email = strtolower(trim((string) $email));
|
||||
if ($email === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use staff() so dynamically appended specialty doctors resolve too.
|
||||
foreach (['free', 'pro', 'enterprise'] as $tier) {
|
||||
foreach (self::staff($tier) as $member) {
|
||||
if (strtolower((string) $member['email']) === $email) {
|
||||
return self::ownerEmailForTier($tier);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Identity AccountTeamMember metadata from a staff roles map.
|
||||
*
|
||||
* @param array<string, string> $roles
|
||||
* @return array<string, array{role: string}>
|
||||
*/
|
||||
public static function metadataFromRoles(array $roles): array
|
||||
{
|
||||
$metadata = [];
|
||||
foreach ($roles as $app => $role) {
|
||||
$metadata[$app] = ['role' => $role];
|
||||
}
|
||||
|
||||
return $metadata;
|
||||
}
|
||||
|
||||
public static function storyDay(): \Carbon\CarbonInterface
|
||||
{
|
||||
return now(self::TIMEZONE)->startOfDay()->addDays(self::STORY_DAY_OFFSET);
|
||||
}
|
||||
|
||||
/** Local phone form used by some apps (024####### without +233). */
|
||||
public static function phoneLocal(string $e164): string
|
||||
{
|
||||
if (str_starts_with($e164, '+233')) {
|
||||
return '0'.substr($e164, 4);
|
||||
}
|
||||
|
||||
return $e164;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Canonical short-link URLs for the Ladill platform (ladl.link).
|
||||
*/
|
||||
final class LadillLink
|
||||
{
|
||||
public const INTERNAL_HEADER = 'X-Ladill-Internal';
|
||||
|
||||
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, '/');
|
||||
}
|
||||
|
||||
public static function isInternalRequest(Request $request): bool
|
||||
{
|
||||
return $request->header(self::INTERNAL_HEADER) === '1';
|
||||
}
|
||||
|
||||
public static function legacyRedirect(Request $request): RedirectResponse
|
||||
{
|
||||
$shortCode = $request->route('shortCode');
|
||||
if (! is_string($shortCode) || $shortCode === '') {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$path = ltrim($request->path(), '/');
|
||||
$prefix = 'q/'.$shortCode;
|
||||
$suffix = '';
|
||||
if ($path !== $prefix && str_starts_with($path, $prefix.'/')) {
|
||||
$suffix = substr($path, strlen($prefix) + 1);
|
||||
}
|
||||
|
||||
$target = $suffix === '' ? self::url($shortCode) : self::path($shortCode, $suffix);
|
||||
if ($qs = $request->getQueryString()) {
|
||||
$target .= '?'.$qs;
|
||||
}
|
||||
|
||||
$status = in_array($request->method(), ['GET', 'HEAD'], true) ? 301 : 307;
|
||||
|
||||
return redirect()->away($target, $status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -13,12 +13,23 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
// Public event registration is posted from ladl.link (cross-site); session CSRF cannot bind there.
|
||||
$middleware->validateCsrfTokens(except: [
|
||||
'q/*/register',
|
||||
]);
|
||||
|
||||
$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,
|
||||
'redirect.legacy.qr' => \App\Http\Middleware\RedirectLegacyQrToLadillLink::class,
|
||||
'auth.service' => \App\Http\Middleware\AuthenticateService::class,
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
//
|
||||
|
||||
@@ -4,4 +4,6 @@ return [
|
||||
'api_url' => env('BILLING_API_URL', 'https://ladill.com/api/billing'),
|
||||
'api_key' => env('BILLING_API_KEY_EVENTS'),
|
||||
'service' => 'events',
|
||||
'wallet_balance_route' => 'wallet.balance',
|
||||
'currency' => 'GHS',
|
||||
];
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
$appHost = parse_url((string) env('APP_URL', 'https://events.ladill.com'), PHP_URL_HOST) ?: 'events.ladill.com';
|
||||
|
||||
return [
|
||||
// Custom domains are opt-in; the feature only surfaces/provisions when a
|
||||
// Domains SSL API key is configured.
|
||||
'enabled' => filter_var(env('CUSTOM_DOMAINS_ENABLED', true), FILTER_VALIDATE_BOOLEAN),
|
||||
|
||||
// This app's own host(s) — requests on any other host are treated as a
|
||||
// customer custom domain and resolved to the mapped event page.
|
||||
'app_host' => $appHost,
|
||||
|
||||
// Where customers point their A records (apex + www).
|
||||
'server_ip' => env('LADILL_APP_SERVER_IP', '161.97.138.149'),
|
||||
|
||||
// Central SSL provisioning (Ladill Domains).
|
||||
'ssl_api_url' => rtrim(env('DOMAINS_SSL_API_URL', 'https://domains.ladill.com/api'), '/'),
|
||||
'ssl_api_key' => env('DOMAINS_API_KEY_EVENTS', ''),
|
||||
|
||||
// certbot issues + installs an nginx server block that includes this snippet
|
||||
// (root + fastcgi for the Events app) so the custom domain serves this app.
|
||||
'nginx_include' => env('CUSTOM_DOMAINS_NGINX_INCLUDE', '/etc/nginx/snippets/ladill-events-app.conf'),
|
||||
|
||||
// Shared secret to verify the signed completion callback from Domains.
|
||||
'callback_secret' => env('SSL_CALLBACK_SECRET', ''),
|
||||
];
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'enabled' => (bool) env('DEMO_ACCOUNTS_ENABLED', false),
|
||||
'reset_on_login' => (bool) env('DEMO_RESET_ON_LOGIN', true),
|
||||
|
||||
'accounts' => [
|
||||
'demo-free@ladill.com' => 'free',
|
||||
'demo-pro@ladill.com' => 'pro',
|
||||
'demo-enterprise@ladill.com' => 'enterprise',
|
||||
],
|
||||
|
||||
// Org members share the enterprise owner tenant.
|
||||
'member_emails' => [
|
||||
'demo-enterprise-member-1@ladill.com' => 'demo-enterprise@ladill.com',
|
||||
'demo-enterprise-member-2@ladill.com' => 'demo-enterprise@ladill.com',
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
'service_api_keys' => [
|
||||
'meet' => env('EVENTS_API_KEY_MEET', env('MEET_API_KEY_EVENTS')),
|
||||
],
|
||||
|
||||
'meet_app_url' => env('LADILL_MEET_APP_URL', 'https://meet.ladill.com'),
|
||||
|
||||
/*
|
||||
| Paid Events subscriptions (Pro / Business) are retired. Keep pro.* keys for
|
||||
| legacy renew command / DB rows; gating is always off via SubscriptionService.
|
||||
*/
|
||||
'pro' => [
|
||||
'enabled' => false,
|
||||
'price_minor' => (int) env('EVENTS_PRO_PRICE_MINOR', 49000),
|
||||
'currency' => env('EVENTS_PRO_CURRENCY', 'GHS'),
|
||||
'period_days' => (int) env('EVENTS_PRO_PERIOD_DAYS', 30),
|
||||
'grace_days' => (int) env('EVENTS_PRO_GRACE_DAYS', 3),
|
||||
],
|
||||
|
||||
'plans' => [],
|
||||
|
||||
'prepaid_months' => [],
|
||||
|
||||
'free' => [
|
||||
'max_live_events' => (int) env('EVENTS_FREE_MAX_LIVE_EVENTS', 2),
|
||||
'max_tickets_per_month' => (int) env('EVENTS_FREE_MAX_TICKETS_PER_MONTH', 100),
|
||||
],
|
||||
|
||||
'upgrade_banner' => null,
|
||||
|
||||
];
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'product_slug' => 'events',
|
||||
'marketing_url' => env('LADILL_MARKETING_URL', 'https://ladill.com/products/events'),
|
||||
];
|
||||
@@ -2,39 +2,44 @@
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Ladill App Launcher — SHARED, IDENTICAL across every app/service repo
|
||||
| Ladill App Launcher — GENERATED, IDENTICAL across every app/service repo
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Lists ONLY fully-extracted apps (each on its own subdomain). To replicate the
|
||||
| launcher in a new app, copy these three things verbatim into that repo:
|
||||
| - config/ladill_launcher.php (this file)
|
||||
| - resources/views/partials/launcher.blade.php
|
||||
| - public/images/launcher-icons/* (the icon set)
|
||||
| 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
|
||||
|
|
||||
| When a service becomes FULLY extracted: add one row below AND drop its icon in
|
||||
| public/images/launcher-icons/ — in EVERY repo. That's the only edit needed.
|
||||
| Do NOT list a service here until it is fully extracted to its own subdomain.
|
||||
|
|
||||
| URLs are absolute (built from the platform root) so this file is byte-identical
|
||||
| in every app; the blade omits whichever row matches the app's APP_URL host.
|
||||
| Icons are served from /images/launcher-icons/<icon>.
|
||||
| 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' => 'Woo Manager', 'url' => 'https://woo.'.$root.'/sso/connect?redirect='.urlencode('https://woo.'.$root.'/dashboard'), 'icon' => 'woomanager.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' => 'Email', 'url' => 'https://email.'.$root, 'icon' => 'email.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'],
|
||||
['name' => 'QR Plus', 'url' => 'https://qrplus.'.$root.'/sso/connect?redirect='.urlencode('https://qrplus.'.$root.'/dashboard'), 'icon' => 'qrplus.svg'],
|
||||
['name' => 'Events', 'url' => 'https://events.'.$root.'/sso/connect?redirect='.urlencode('https://events.'.$root.'/dashboard'), 'icon' => 'events.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' => 'Merchant', 'url' => 'https://merchant.'.$root.'/sso/connect?redirect='.urlencode('https://merchant.'.$root.'/dashboard'), 'icon' => 'merchant.svg'],
|
||||
['name' => 'Transfer', 'url' => 'https://transfer.'.$root.'/sso/connect?redirect='.urlencode('https://transfer.'.$root.'/dashboard'), 'icon' => 'transfer.svg'],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'public_domain' => env('LINK_PUBLIC_DOMAIN', 'ladl.link'),
|
||||
];
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'api_url' => env('LADILL_MAIL_API_URL', 'https://mail.ladill.com/api/service/v1'),
|
||||
'api_key' => env('MAIL_API_KEY_EVENTS'),
|
||||
];
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
'url' => rtrim((string) env('MEET_API_URL', 'https://meet.ladill.com/api/service/v1'), '/'),
|
||||
'key' => env('MEET_API_KEY_EVENTS'),
|
||||
'webhook_secret' => env('MEET_WEBHOOK_SECRET_EVENTS'),
|
||||
|
||||
];
|
||||
@@ -32,7 +32,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'lifetime' => (int) env('SESSION_LIFETIME', 120),
|
||||
'lifetime' => (int) env('SESSION_LIFETIME', 1440),
|
||||
|
||||
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'platform_api_url' => env('SMS_PLATFORM_API_URL', 'https://ladill.com/api'),
|
||||
'platform_api_key' => env('SMS_API_KEY_EVENTS'),
|
||||
'customer_relay_url' => env('SMS_CUSTOMER_RELAY_URL', 'https://ladill.com/api/sms'),
|
||||
'default_sender_id' => env('SMS_DEFAULT_SENDER_ID', 'Ladill'),
|
||||
];
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'platform_api_url' => env('SMTP_PLATFORM_API_URL', 'https://ladill.com/api/smtp'),
|
||||
'platform_api_key' => env('SMTP_API_KEY_EVENTS'),
|
||||
'customer_relay_url' => env('SMTP_CUSTOMER_RELAY_URL', 'https://ladill.com/api/smtp'),
|
||||
'from' => env('EVENTS_SMTP_FROM', 'events@ladill.com'),
|
||||
'from_name' => env('EVENTS_SMTP_FROM_NAME', 'Ladill Events'),
|
||||
// Deep link when suite path returns messaging_mailbox_required
|
||||
'messaging_settings_url' => env(
|
||||
'MESSAGING_SETTINGS_URL',
|
||||
'https://account.ladill.com/account/settings/messaging?reason=messaging_mailbox_required&from=suite',
|
||||
),
|
||||
];
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('custom_domains', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('qr_code_id')->constrained('qr_codes')->cascadeOnDelete(); // the storefront
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('host')->unique();
|
||||
$table->boolean('include_www')->default(true);
|
||||
$table->string('status', 16)->default('pending'); // pending | active | failed
|
||||
$table->string('ssl_status', 16)->default('pending'); // pending | active | failed
|
||||
$table->timestamp('dns_verified_at')->nullable();
|
||||
$table->timestamp('ssl_issued_at')->nullable();
|
||||
$table->timestamp('ssl_expires_at')->nullable();
|
||||
$table->text('last_error')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('qr_code_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('custom_domains');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('events_messaging_credentials', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('owner_ref')->unique();
|
||||
$table->text('sms_api_key_encrypted')->nullable();
|
||||
$table->string('sms_api_key_prefix', 32)->nullable();
|
||||
$table->string('sms_sender_id', 11)->nullable();
|
||||
$table->string('sms_status', 32)->nullable();
|
||||
$table->timestamp('sms_validated_at')->nullable();
|
||||
$table->text('sms_last_error')->nullable();
|
||||
$table->text('bird_api_key_encrypted')->nullable();
|
||||
$table->string('bird_api_key_prefix', 32)->nullable();
|
||||
$table->string('bird_from_email')->nullable();
|
||||
$table->string('bird_from_name')->nullable();
|
||||
$table->string('bird_status', 32)->nullable();
|
||||
$table->timestamp('bird_validated_at')->nullable();
|
||||
$table->text('bird_last_error')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('events_messaging_credentials');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('qr_settings', function (Blueprint $table) {
|
||||
$table->string('logo_path')->nullable()->after('event_defaults');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('qr_settings', function (Blueprint $table) {
|
||||
$table->dropColumn('logo_path');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('payment_gateway_settings', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('owner_ref', 64)->unique();
|
||||
$table->string('provider', 32); // paystack|flutterwave|hubtel
|
||||
$table->text('public_key')->nullable();
|
||||
$table->text('secret_key')->nullable();
|
||||
$table->text('webhook_secret')->nullable();
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->json('metadata')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('payment_gateway_settings');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('events_pro_subscriptions', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->unique()->constrained()->cascadeOnDelete();
|
||||
$table->string('status', 16)->default('active');
|
||||
$table->string('plan', 16)->default('pro');
|
||||
$table->unsignedInteger('price_minor');
|
||||
$table->char('currency', 3)->default('GHS');
|
||||
$table->boolean('auto_renew')->default(true);
|
||||
$table->timestamp('started_at')->nullable();
|
||||
$table->timestamp('current_period_end')->nullable();
|
||||
$table->timestamp('last_charged_at')->nullable();
|
||||
$table->timestamp('canceled_at')->nullable();
|
||||
$table->string('last_reference')->nullable();
|
||||
$table->text('last_error')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['status', 'current_period_end']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('events_pro_subscriptions');
|
||||
}
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "ladill-qr-plus",
|
||||
"name": "ladill-events",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 360 360">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #009e6b;
|
||||
}
|
||||
|
||||
.cls-1, .cls-2, .cls-3 {
|
||||
stroke-width: 0px;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #db5443;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #00a3d7;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-3" d="M1.7,170.6c0,9,.1,18.2,0,27.2-.1,8.8,4.3,15,13.8,19.3,17.3,7.5,34.4,15.2,51.7,22.8,69,30.6,138.2,61.1,207.1,92,16.9,7.6,37.1-.3,36.9-17.8v-54c0-17.9-4.5-15.2-14.4-19.4-47-20.7-94-41.6-141-62.4-39-17.3-78.2-34.2-117-51.9C19.9,117.7,0,128.1,1.3,144.4c.6,8.8.1,17.6.1,26.3h.3Z"/>
|
||||
<path class="cls-1" d="M194.6,25.4c9,0,18.2.1,27.2,0,8.8-.1,15,4.3,19.3,13.8,7.5,17.3,15.2,34.4,22.8,51.7,30.6,69.1,60.9,138.3,91.9,207.2,7.6,16.9-.3,37.1-17.8,36.9h-54c-17.9,0-15.2-4.5-19.4-14.4-20.7-47-41.6-94-62.4-141-17.3-39-34.2-78.2-51.9-117-8.7-18.8,1.8-38.7,18.1-37.4,8.8.6,17.6.1,26.3.1h0Z"/>
|
||||
<path class="cls-2" d="M194.9,25.3c-9,0-18.2.1-27.2,0-8.8-.1-15,4.3-19.3,13.8-7.5,17.3-15.2,34.4-22.8,51.7-30.6,69.1-60.9,138.3-91.9,207.2-7.6,16.9.3,37.1,17.8,36.9h54c17.9,0,15.2-4.5,19.4-14.4,20.7-47,41.6-94,62.4-141,17.3-39,34.2-78.2,51.9-117,8.7-18.8-1.8-38.7-18.1-37.4-8.8.6-17.6.1-26.3.1h0Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 360 360">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #5cc45b;
|
||||
}
|
||||
|
||||
.cls-1, .cls-2, .cls-3 {
|
||||
stroke-width: 0px;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #96c;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #7b9594;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-1" d="M347.3,272.3c-50.4,84.4-159.2,112.7-244.6,63.8C17.4,286.6-12.4,177.8,35.6,92.4l311.7,179.8Z"/>
|
||||
<path class="cls-2" d="M347.3,87.6C297,3.2,188.1-25.1,102.7,24.3,17.4,73.2-12.4,182.1,35.6,267.5L347.3,87.6Z"/>
|
||||
<path class="cls-3" d="M35.6,92.4c-6,10.6-10.7,21.6-14.3,32.7,0,.3-.2.6-.3.9-.3.9-.6,1.8-.8,2.7-.3,1-.6,1.9-.8,2.9,0,.3-.1.5-.2.8-3.4,12.3-5.4,24.9-6.1,37.7,0,0,0,0,0,.1,0,1.3-.1,2.5-.2,3.8,0,.3,0,.7,0,1,0,1,0,1.9,0,2.9,0,.7,0,1.3,0,2,0,.7,0,1.3,0,2,0,1,0,1.9,0,2.9,0,.3,0,.7,0,1,0,1.2,0,2.5.2,3.7,0,0,0,.1,0,.2.6,9.9,1.9,19.8,4.1,29.4,0,.2,0,.3.1.5.2,1.1.5,2.2.8,3.3.2.6.3,1.3.5,1.9.2.6.3,1.2.5,1.8,3.8,14,9.3,27.7,16.7,40.9l151.7-87.5L35.6,92.4Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 360 360">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #d7d135;
|
||||
}
|
||||
|
||||
.cls-1, .cls-2, .cls-3, .cls-4, .cls-5 {
|
||||
stroke-width: 0px;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #8c67d5;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #3d85a4;
|
||||
}
|
||||
|
||||
.cls-4 {
|
||||
fill: #6576bd;
|
||||
}
|
||||
|
||||
.cls-5 {
|
||||
fill: #b29c85;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-2" d="M168.1,0C74,6.3-.4,84.6.2,179.8h0c0,95.1,74.4,173.4,167.9,179.8V0Z"/>
|
||||
<path class="cls-3" d="M.2,192.1c6.3,94.1,84.6,168.5,179.8,167.9h0c95.1,0,173.4-74.4,179.8-167.9H.2Z"/>
|
||||
<path class="cls-1" d="M.2,167.9C6.5,73.8,84.8-.6,180,0h0C275.1,0,353.4,74.4,359.7,167.9H.2Z"/>
|
||||
<path class="cls-5" d="M168.1,167.9V.4c-19.4,1.2-37.9,5.4-55.1,12.3-.2,0-.4.2-.7.3-2.1.8-4.1,1.7-6.2,2.7-.7.3-1.4.6-2.1.9-1.6.8-3.2,1.5-4.8,2.3-1,.5-2.1,1-3.1,1.6-1.3.7-2.6,1.4-3.8,2.1-1.2.7-2.5,1.4-3.7,2.1-1.1.6-2.1,1.3-3.2,1.9-1.4.8-2.7,1.7-4,2.5-.9.6-1.9,1.3-2.8,1.9-1.4.9-2.7,1.9-4.1,2.8-.9.7-1.8,1.3-2.7,2-1.3,1-2.6,2-3.9,3-.9.7-1.8,1.5-2.7,2.2-1.2,1-2.4,2-3.6,3.1-.9.8-1.9,1.7-2.8,2.5-1.1,1-2.2,2-3.3,3-1,.9-1.9,1.9-2.9,2.9-1,1-1.9,1.9-2.9,2.9-1,1.1-2,2.2-3,3.3-.8.9-1.7,1.8-2.5,2.8-1,1.2-2.1,2.4-3.1,3.6-.7.9-1.5,1.8-2.2,2.7-1,1.3-2,2.6-3,3.9-.7.9-1.3,1.8-2,2.7-1,1.3-1.9,2.7-2.8,4.1-.6.9-1.3,1.9-1.9,2.8-.9,1.3-1.7,2.7-2.5,4-.7,1.1-1.3,2.1-1.9,3.2-.7,1.2-1.4,2.5-2.1,3.7-.7,1.3-1.4,2.5-2.1,3.8-.5,1-1.1,2-1.6,3.1-.8,1.6-1.6,3.2-2.3,4.8-.3.7-.6,1.4-.9,2.1-.9,2-1.8,4.1-2.7,6.2,0,.2-.2.4-.3.7-6.9,17.2-11.1,35.8-12.3,55.1h167.6Z"/>
|
||||
<path class="cls-4" d="M168.1,359.5v-167.5H.7c6.1,89.5,78,161.4,167.5,167.5Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 360 360">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #ff79b7;
|
||||
}
|
||||
|
||||
.cls-1, .cls-2, .cls-3 {
|
||||
stroke-width: 0px;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #7b50c4;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #0094c8;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-2" d="M14.1,74.6c3.7,8.2,7.5,16.9,11.1,25.1s9.9,12.2,20.5,12c19.1-.4,37.6,0,56.9-.1,76.1-.1,152.2-.2,228.2.2,18.9.1,34.1-15.1,26.8-31.6l-22.1-49.8c-7.3-16.5-10.3-12-21.1-12.2-51.9.2-103.5.1-155.4.3-43.1-.2-85.9.4-129.1-.1-20.7-.2-34.9,17.3-27.2,31.7,4.4,7.9,7.1,16.1,10.8,24.3h0l.5.2Z"/>
|
||||
<path class="cls-1" d="M63.1,188.9c3.7,8.2,7.5,16.9,11.1,25.1,3.7,8.2,9.9,12.2,20.5,12,19.1-.4,4.8-.1,24.1-.2,76.1-.1,86.6-.4,162.6,0,18.9.1,34.1-15.1,26.8-31.6l-22.1-49.8c-7.3-16.5-10.3-12-21.1-12.2-51.9.2-103.5.1-155.4.3-43.1-.2,12.5.6-30.7,0-20.7-.2-34.9,17.3-27.2,31.7,4.4,7.9,7.1,16.1,10.8,24.3h0l.5.2Z"/>
|
||||
<path class="cls-3" d="M116.1,305.2c3.7,8.2,7.5,16.9,11.1,25.1,3.7,8.2,9.9,12.2,20.5,12,19.1-.4,4.8-.1,24.1-.2,76.1-.1,16.9-.5,92.9-.1,18.9.1,34.1-15.1,26.8-31.6l-22.1-49.8c-7.3-16.5-10.3-12-21.1-12.2-51.9.2-33.7.3-85.7.4-43.1-.2,12.5.6-30.7,0-20.7-.2-34.9,17.3-27.2,31.7,4.4,7.9,7.1,16.1,10.8,24.3h0l.5.2Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -3,18 +3,23 @@
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #f6398a;
|
||||
fill: #007e7d;
|
||||
}
|
||||
|
||||
.cls-1, .cls-2 {
|
||||
.cls-1, .cls-2, .cls-3 {
|
||||
stroke-width: 0px;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #007e7d;
|
||||
fill: #7b5c84;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #f6398a;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-1" d="M267.9,17.5c-30.1-.2-56.9,15.4-73.6,39.6l58.6,68.2c4.7,5.5,4.1,13.7-1.4,18.4-2.5,2.1-5.5,3.1-8.5,3.1s-7.3-1.5-9.9-4.5c-17.5-20.3-34.9-40.6-52.4-61-9.6-11.2-19.3-22.4-28.9-33.6-7-8.1-13.3-17.2-23.1-22.1-11.2-5.6-24.3-8.1-36.7-8.1C32.8,17.9-10.8,83.4,2.3,144.7c6.5,30.6,28.4,55.4,45.8,79.4,22.2,30.7,65.6,74.1,96.5,103.8,20.2,19.4,50.6,19.4,70.7,0,30.9-29.7,74.3-73.2,96.5-103.8,17.4-24,39.3-48.8,45.8-79.4,13-61.3-30.6-126.9-89.8-127.2Z"/>
|
||||
<path class="cls-2" d="M267.9,17.5c-30.1-.2-56.9,15.4-73.6,39.6l58.6,68.2c4.7,5.5,4.1,13.7-1.4,18.4-2.5,2.1-5.5,3.1-8.5,3.1s-7.3-1.5-9.9-4.5l75.1,86.7c1.3-1.6,2.5-3.3,3.6-4.8,17.4-24,39.3-48.8,45.8-79.4,13-61.3-30.6-126.9-89.8-127.2Z"/>
|
||||
<path class="cls-1" d="M357,144.9c13.6-60.9-29.8-126.9-89-126.9s-56.6,15.3-73.7,39.6l127.6,152.5c14.8-19.8,30-40.8,35.1-65.2Z"/>
|
||||
<path class="cls-3" d="M180.7,81.5c-9.8-11.1-19.2-22.6-29-33.6-6.8-8.1-13.2-17-23-22.1-10.6-5.1-23.9-7.7-36.2-7.7-59.1-.1-102.6,65.9-89.8,126.8,6.4,30.7,28.5,55.4,45.6,79.2,22.1,30.7,65.6,74.1,96.3,103.5,20,19.2,50.7,19.2,70.7,0,28.7-27.5,67.6-66.7,90.8-96.3-44-52.6-112.7-135.1-125.3-149.8h-.1Z"/>
|
||||
<path class="cls-2" d="M234.3,143.1c-.4-.3-.8-.6-1.1-1-17.5-20.4-34.9-40.5-52.4-60.9-.2-.3-.4-.5-.7-.8-.4.4-.7.9-1.1,1.3-17.5,20.4-34.9,40.5-52.4,60.9-.3.4-.8.7-1.1,1l-73.5,85.1c23,30.3,63.5,70.8,92.6,98.6,20,19.2,50.7,19.2,70.7,0,29.6-28.3,69.8-69,92.8-98.9l-73.7-85.3h0Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -31,10 +31,10 @@
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<rect class="cls-4" x="0" y="11.7" width="104.6" height="334.3" rx="52.3" ry="52.3"/>
|
||||
<rect class="cls-1" x="251.7" y="126.2" width="105.8" height="219.8" rx="52.9" ry="52.9"/>
|
||||
<path class="cls-6" d="M304.6,126.2H51.7C22.7,126.2,0,150.1,0,178.6s23.3,52.3,52.3,52.3h252.9c29.1,0,52.3-23.3,52.3-52.3s-23.3-52.3-52.3-52.3h-.6Z"/>
|
||||
<path class="cls-4" d="M52.3,11.7h0c28.9,0,52.3,23.4,52.3,52.3v229.7c0,28.9-23.4,52.3-52.3,52.3h0c-28.9,0-52.3-23.4-52.3-52.3V64C0,35.1,23.4,11.7,52.3,11.7Z"/>
|
||||
<path class="cls-1" d="M304.6,126.2h0c29.2,0,52.9,23.7,52.9,52.9v114c0,29.2-23.7,52.9-52.9,52.9h0c-29.2,0-52.9-23.7-52.9-52.9v-114c0-29.2,23.7-52.9,52.9-52.9Z"/>
|
||||
<path class="cls-6" d="M304.6,126.2H51.7c-29,0-51.7,23.9-51.7,52.4s23.3,52.3,52.3,52.3h252.9c29.1,0,52.3-23.3,52.3-52.3s-23.3-52.3-52.3-52.3h-.6,0Z"/>
|
||||
<circle class="cls-3" cx="304.1" cy="64" r="52.3"/>
|
||||
<path class="cls-2" d="M251.7,178.6v52.3h52.9c26.7,0,48.3-19.8,51.7-45.3v-7c0-29.1-23.3-52.3-52.3-52.3s-52.3,23.3-52.3,52.3Z"/>
|
||||
<path class="cls-5" d="M0,172.2v14c3.5,25.6,25,45.3,51.7,45.3h52.9v-104.6h-52.9C25,126.8,3.5,146.6,0,172.2Z"/>
|
||||
<path class="cls-2" d="M251.7,178.6v52.3h52.9c26.7,0,48.3-19.8,51.7-45.3v-7c0-29.1-23.3-52.3-52.3-52.3s-52.3,23.3-52.3,52.3h0Z"/>
|
||||
<path class="cls-5" d="M0,172.2v14c3.5,25.6,25,45.3,51.7,45.3h52.9v-104.6h-52.9c-26.7,0-48.2,19.7-51.7,45.3Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 360 360">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #c1c93d;
|
||||
}
|
||||
|
||||
.cls-1, .cls-2, .cls-3 {
|
||||
stroke-width: 0px;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #3dc3a4;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #2a4372;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<circle class="cls-3" cx="259.2" cy="76.3" r="76.3"/>
|
||||
<path class="cls-1" d="M168.8,360v-189.2c-79.5,0-144.3,64.7-144.3,144.3v45h144.3Z"/>
|
||||
<path class="cls-2" d="M187.1,170.8v189.2c79.5,0,144.3-64.7,144.3-144.3v-45h-144.3Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 638 B |
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 360 360">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #a8ca38;
|
||||
}
|
||||
|
||||
.cls-1, .cls-2, .cls-3 {
|
||||
stroke-width: 0px;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #678e9c;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #2551ff;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<rect class="cls-1" x="32" y="36.1" width="175.5" height="290.1" rx="87.7" ry="87.7" transform="translate(-74.5 84.1) rotate(-30)"/>
|
||||
<rect class="cls-3" x="152.6" y="36.1" width="175.5" height="290.1" rx="87.7" ry="87.7" transform="translate(-58.4 144.4) rotate(-30)"/>
|
||||
<path class="cls-2" d="M167.1,87.6c-4.4-7.5-9.7-14.2-15.7-19.9-28.9,27.3-36.4,71.7-15.7,107.6l57.3,99.2c4.4,7.5,9.7,14.2,15.7,19.9,28.9-27.3,36.4-71.7,15.7-107.6l-57.3-99.2Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 859 B |
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 360 360">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #db54d9;
|
||||
}
|
||||
|
||||
.cls-1, .cls-2, .cls-3 {
|
||||
stroke-width: 0px;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #c0d343;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #009e46;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-1" d="M126.3,60.6c-6.9,0-14,0-20.9,0-6.8,0-11.5,3.3-14.9,10.6-5.8,13.3-11.7,26.5-17.6,39.8C49.4,164.2,26.1,217.5,2.2,270.5c-5.9,13,.2,28.6,13.7,28.4h41.6c13.8,0,11.7-3.5,14.9-11.1,15.9-36.2,32-72.4,48-108.6,13.3-30,26.3-60.2,40-90.1,6.7-14.5-1.4-29.8-13.9-28.8-6.8.5-13.5,0-20.2,0h0Z"/>
|
||||
<path class="cls-2" d="M238.2,60.5c-6.9,0-14,0-20.9,0-6.8,0-11.5,3.3-14.9,10.6-5.8,13.3-11.7,26.5-17.6,39.8-23.6,53.2-46.9,106.5-70.8,159.5-5.9,13,.2,28.6,13.7,28.4h41.6c13.8,0,11.7-3.5,14.9-11.1,15.9-36.2,32-72.4,48-108.6,13.3-30,26.3-60.2,40-90.1,6.7-14.5-1.4-29.8-13.9-28.8-6.8.5-13.5,0-20.2,0h0Z"/>
|
||||
<circle class="cls-3" cx="300.2" cy="239.1" r="59.8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |