Extract Ladill Events as a standalone events suite at events.ladill.com.
Deploy Ladill QR Plus / deploy (push) Successful in 28s

Full control center for ticketed events, contributions, attendees, badges,
and programmes — not a QR utility clone. Includes SSO shell, import command,
and platform cutover runbook.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-07 09:39:53 +00:00
co-authored by Cursor
commit d8dbc83e2d
246 changed files with 34279 additions and 0 deletions
+219
View File
@@ -0,0 +1,219 @@
<?php
namespace App\Models;
use App\Support\Qr\QrStyleDefaults;
use App\Support\Qr\QrTypeCatalog;
use App\Support\Qr\QrWifiPayload;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class QrCode extends Model
{
public const TYPE_URL = 'url';
public const TYPE_DOCUMENT = 'document';
public const TYPE_LINK_LIST = 'link_list';
public const TYPE_VCARD = 'vcard';
public const TYPE_BUSINESS = 'business';
public const TYPE_IMAGE = 'image';
public const TYPE_CHURCH = 'church';
public const TYPE_MENU = 'menu';
public const TYPE_SHOP = 'shop';
public const TYPE_APP = 'app';
public const TYPE_BOOK = 'book';
public const TYPE_WIFI = 'wifi';
public const TYPE_COUPON = 'coupon';
public const TYPE_EVENT = 'event';
public const TYPE_ITINERARY = 'itinerary';
protected $fillable = [
'user_id',
'short_code',
'type',
'label',
'destination_url',
'qr_document_id',
'payload',
'is_active',
'png_path',
'svg_path',
'scans_total',
'unique_scans_total',
'last_scanned_at',
'destination_updated_at',
];
protected $casts = [
'payload' => 'array',
'is_active' => 'boolean',
'scans_total' => 'integer',
'unique_scans_total' => 'integer',
'last_scanned_at' => 'datetime',
'destination_updated_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function document(): BelongsTo
{
return $this->belongsTo(QrDocument::class, 'qr_document_id');
}
public function scanEvents(): HasMany
{
return $this->hasMany(QrScanEvent::class);
}
public function transactions(): HasMany
{
return $this->hasMany(QrTransaction::class);
}
public function saleOrders(): HasMany
{
return $this->hasMany(QrSaleOrder::class);
}
public function eventRegistrations(): HasMany
{
return $this->hasMany(QrEventRegistration::class);
}
public function publicUrl(): string
{
return self::publicBaseUrl() . '/q/' . $this->short_code;
}
/**
* Base URL for public QR links. Always the short platform domain
* (ladill.com) never the signed-in account/product host so printed
* codes and shared links stay short and host-independent of where the QR
* was created.
*/
public static function publicBaseUrl(): string
{
$appUrl = (string) config('app.url');
$scheme = parse_url($appUrl, PHP_URL_SCHEME) ?: 'https';
$host = (string) config('app.platform_domain')
?: (parse_url($appUrl, PHP_URL_HOST) ?: 'ladill.com');
return $scheme . '://' . $host;
}
/**
* String encoded inside the QR image.
*
* Most types encode the stable short link so the printed code never changes
* when content is edited. WiFi is the exception: it bakes the network
* credentials directly into the image so devices auto-join on scan. That
* payload is frozen at creation (payload.wifi_encoded) editing the network
* afterwards updates the saved info but never re-encodes the printed code.
*/
public function encodedPayload(): string
{
if ($this->type === self::TYPE_WIFI) {
$frozen = $this->payload['wifi_encoded'] ?? null;
return is_string($frozen) && $frozen !== ''
? $frozen
: QrWifiPayload::encode($this->content());
}
return $this->publicUrl();
}
/** WiFi codes encode their join payload directly (auto-join), not a redirect link. */
public function encodesDirectPayload(): bool
{
return $this->type === self::TYPE_WIFI;
}
public function typeLabel(): string
{
return QrTypeCatalog::label($this->type);
}
/** @return array<string, mixed> */
public function content(): array
{
return (array) ($this->payload['content'] ?? []);
}
/** @return array<string, mixed> */
public function style(): array
{
return QrStyleDefaults::merge($this->payload['style'] ?? null);
}
public function isUrlType(): bool
{
return $this->type === self::TYPE_URL;
}
public function isDocumentType(): bool
{
return $this->type === self::TYPE_DOCUMENT;
}
public function isImageType(): bool
{
return $this->type === self::TYPE_IMAGE;
}
public function isMenuType(): bool
{
return $this->type === self::TYPE_MENU;
}
public function isShopType(): bool
{
return $this->type === self::TYPE_SHOP;
}
public function isBookType(): bool
{
return $this->type === self::TYPE_BOOK;
}
public function acceptsOrders(): bool
{
return in_array($this->type, [self::TYPE_MENU, self::TYPE_SHOP, self::TYPE_CHURCH, self::TYPE_EVENT], true);
}
public function usesLandingPage(): bool
{
return in_array($this->type, [
self::TYPE_DOCUMENT,
self::TYPE_LINK_LIST,
self::TYPE_VCARD,
self::TYPE_BUSINESS,
self::TYPE_CHURCH,
self::TYPE_IMAGE,
self::TYPE_MENU,
self::TYPE_SHOP,
self::TYPE_APP,
self::TYPE_BOOK,
self::TYPE_WIFI,
self::TYPE_EVENT,
self::TYPE_ITINERARY,
], true);
}
public function resolvesToRedirect(): bool
{
return $this->type === self::TYPE_URL;
}
public function redirectUrl(): ?string
{
if ($this->destination_url) {
return $this->destination_url;
}
return $this->content()['url'] ?? null;
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class QrDocument extends Model
{
protected $fillable = [
'user_id',
'title',
'disk',
'path',
'mime_type',
'size_bytes',
'page_count',
];
protected $casts = [
'size_bytes' => 'integer',
'page_count' => 'integer',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function qrCodes(): HasMany
{
return $this->hasMany(QrCode::class);
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class QrEventRegistration extends Model
{
public const STATUS_PENDING = 'pending';
public const STATUS_CONFIRMED = 'confirmed';
public const STATUS_FAILED = 'failed';
public const STATUS_CANCELLED = 'cancelled';
protected $fillable = [
'qr_code_id',
'user_id',
'reference',
'badge_code',
'tier_name',
'amount_minor',
'currency',
'attendee_name',
'attendee_email',
'attendee_phone',
'badge_fields',
'status',
'payment_reference',
'paid_at',
'checked_in_at',
'metadata',
];
protected $casts = [
'badge_fields' => 'array',
'metadata' => 'array',
'amount_minor' => 'integer',
'paid_at' => 'datetime',
'checked_in_at' => 'datetime',
];
public function qrCode(): BelongsTo
{
return $this->belongsTo(QrCode::class);
}
public function organizer(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public function amountCedis(): float
{
return round($this->amount_minor / 100, 2);
}
public function isPaid(): bool
{
return $this->amount_minor > 0;
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class QrScanEvent extends Model
{
protected $fillable = [
'qr_code_id',
'scanned_at',
'ip_hash',
'user_agent',
'device_type',
'browser',
'os',
'country_code',
'referrer',
'is_unique',
];
protected $casts = [
'scanned_at' => 'datetime',
'is_unique' => 'boolean',
];
public function qrCode(): BelongsTo
{
return $this->belongsTo(QrCode::class);
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
namespace App\Models;
use App\Support\Qr\QrStyleDefaults;
use App\Support\Qr\QrTypeCatalog;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/** Per-account QR Plus preferences (notifications and new-code defaults). */
class QrSetting extends Model
{
protected $fillable = [
'user_id',
'notify_email',
'product_updates',
'low_balance_alerts',
'default_type',
'default_style',
];
protected $casts = [
'product_updates' => 'boolean',
'low_balance_alerts' => 'boolean',
'default_style' => 'array',
];
/** @return list<string> */
public static function storableStyleKeys(): array
{
return [
'foreground',
'background',
'error_correction',
'margin',
'module_style',
'finder_outer',
'finder_inner',
'frame_style',
'frame_text',
'frame_color',
'gradient_type',
'gradient_color1',
'gradient_color2',
'gradient_rotation',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function resolvedDefaultType(): string
{
$type = (string) ($this->default_type ?? QrCode::TYPE_EVENT);
return in_array($type, QrTypeCatalog::eventsTypes(), true) ? $type : QrCode::TYPE_EVENT;
}
/** @return array<string, mixed> */
public function resolvedDefaultStyle(): array
{
$stored = collect($this->default_style ?? [])
->only(self::storableStyleKeys())
->filter(fn ($value) => $value !== null && $value !== '')
->all();
return QrStyleDefaults::merge($stored !== [] ? $stored : null);
}
/** @param array<string, mixed>|null $input */
public static function sanitizeDefaultStyle(?array $input): ?array
{
if ($input === null) {
return null;
}
$merged = QrStyleDefaults::merge(
collect($input)->only(self::storableStyleKeys())->all(),
);
return collect($merged)->only(self::storableStyleKeys())->all();
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/** Membership linking a user to an account (owner) whose QR codes they can manage. */
class QrTeamMember extends Model
{
public const ROLE_ADMIN = 'admin';
public const ROLE_MEMBER = 'member';
public const STATUS_INVITED = 'invited';
public const STATUS_ACTIVE = 'active';
protected $fillable = ['account_id', 'user_id', 'email', 'role', 'status', 'token', 'accepted_at'];
protected $casts = ['accepted_at' => 'datetime'];
public function account(): BelongsTo
{
return $this->belongsTo(User::class, 'account_id');
}
public function member(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public static function linkPendingInvitesFor(User $user): void
{
static::query()
->whereNull('user_id')
->where('status', self::STATUS_INVITED)
->whereRaw('LOWER(email) = ?', [strtolower($user->email)])
->update([
'user_id' => $user->id,
'status' => self::STATUS_ACTIVE,
'accepted_at' => now(),
'token' => null,
]);
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class QrTransaction extends Model
{
public const TYPE_CREDIT = 'credit';
public const TYPE_DEBIT = 'debit';
protected $fillable = [
'user_id',
'qr_wallet_id',
'qr_code_id',
'type',
'amount_ghs',
'balance_after_ghs',
'reference',
'status',
'description',
'metadata',
];
protected $casts = [
'amount_ghs' => 'decimal:4',
'balance_after_ghs' => 'decimal:4',
'metadata' => 'array',
];
public function wallet(): BelongsTo
{
return $this->belongsTo(QrWallet::class, 'qr_wallet_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function qrCode(): BelongsTo
{
return $this->belongsTo(QrCode::class);
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class QrWallet extends Model
{
public const STATUS_ACTIVE = 'active';
public const STATUS_SUSPENDED = 'suspended';
protected $fillable = [
'user_id',
'credit_balance',
'qr_codes_total',
'scans_total',
'status',
];
protected $casts = [
'credit_balance' => 'decimal:4',
'qr_codes_total' => 'integer',
'scans_total' => 'integer',
];
public static function pricePerQr(): float
{
return (float) config('qr.price_per_qr_ghs', 5.0);
}
public static function minTopupGhs(): float
{
return (float) config('qr.min_topup_ghs', 5.0);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function transactions(): HasMany
{
return $this->hasMany(QrTransaction::class)->latest();
}
/**
* Single-wallet (siloing step 2): QR spends from the one UserWallet (tagged
* 'qr'). Delegates to the billing service (lazily folds any legacy
* credit_balance in). `spendableBalance()` is the unified balance for display.
*/
public function spendableBalance(): float
{
return $this->user
? app(\App\Services\Qr\QrWalletBillingService::class)->balanceCedis($this->user)
: (float) $this->credit_balance;
}
public function canCreateQr(): bool
{
return $this->user
? app(\App\Services\Qr\QrWalletBillingService::class)->canCreate($this->user)
: false;
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
namespace App\Models;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Collection;
use Laravel\Sanctum\HasApiTokens;
/**
* Thin local mirror of the platform identity (auth.ladill.com owns users).
*/
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasApiTokens, HasFactory, Notifiable;
protected $fillable = ['public_id', 'name', 'email', 'avatar_url', 'password'];
protected $hidden = ['password', 'remember_token'];
protected function casts(): array
{
return ['email_verified_at' => 'datetime', 'password' => 'hashed'];
}
public function memberships(): HasMany
{
return $this->hasMany(QrTeamMember::class, 'user_id')
->where('status', QrTeamMember::STATUS_ACTIVE);
}
public function canAccessAccount(int $accountId): bool
{
return $accountId === $this->id
|| $this->memberships()->where('account_id', $accountId)->exists();
}
/** @return Collection<int, User> */
public function accessibleAccounts(): Collection
{
$ids = $this->memberships()->pluck('account_id')->all();
return collect([$this])->merge(self::whereIn('id', $ids)->get())->unique('id')->values();
}
public function qrWallet(): HasOne
{
return $this->hasOne(QrWallet::class);
}
public function qrCodes(): HasMany
{
return $this->hasMany(QrCode::class);
}
public function qrSetting(): HasOne
{
return $this->hasOne(QrSetting::class);
}
public function getOrCreateQrSetting(): QrSetting
{
return $this->qrSetting()->firstOrCreate([]);
}
public function getOrCreateQrWallet(): QrWallet
{
return $this->qrWallet()->firstOrCreate(
[],
['credit_balance' => 0, 'qr_codes_total' => 0, 'scans_total' => 0, 'status' => QrWallet::STATUS_ACTIVE],
);
}
public function avatarUrl(): ?string
{
$url = trim((string) $this->avatar_url);
return $url !== '' ? $url : null;
}
}