Prune the upstream Mini/QR mobile subsystem from POS
Deploy Ladill POS / deploy (push) Successful in 33s

POS was forked from Ladill Mini and carried Mini's entire mobile/QR
subsystem (API, QR codes, wallet, payments, push, Afia) which polluted the
ladill_pos schema with 8 unused tables and left ~half the test suite red.

Remove the dead subsystem: Api/Mini/Qr/Public/Search/WellKnown controllers,
Mini/Qr/Afia/Notifications services, the 8 unused models, QrCodePolicy,
Support/Qr + Support/Events, the two mini: scheduled commands, the Mini/QR
view trees, and their (failing) tests. Empty routes/api.php (POS is web-only)
and strip dead schedules from routes/console.php.

Keep QrTeamMember — it is the platform team-membership model that POS's
SetActingAccount middleware and SSO login depend on for multi-account access.
Also keep notifications + personal_access_tokens (used by POS).

Drops 7 migrations (qr product/settings, mini_payments, push tokens); the 8
orphan tables are dropped from the live ladill_pos DB separately. Test suite
is green (8 passed) and all routes resolve.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
isaacclad
2026-06-24 00:08:16 +00:00
co-authored by Claude Opus 4.8
parent 57862acb47
commit 46b5091b1e
119 changed files with 3 additions and 15877 deletions
-62
View File
@@ -1,62 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class MiniPayment extends Model
{
public const STATUS_PENDING = 'pending';
public const STATUS_PAID = 'paid';
public const STATUS_FAILED = 'failed';
public const STATUS_CANCELED = 'canceled';
/** Pending payments are auto-canceled after this many hours. */
public const STALE_PENDING_HOURS = 6;
/** Platform fee on trader payments (Ladill Mini tier). */
public const PLATFORM_FEE_RATE = 0.035;
protected $fillable = [
'pay_order_id',
'qr_code_id',
'user_id',
'reference',
'amount_minor',
'currency',
'platform_fee_minor',
'merchant_amount_minor',
'payer_name',
'payer_email',
'payer_phone',
'payer_note',
'status',
'payment_reference',
'paid_at',
'metadata',
];
protected $casts = [
'amount_minor' => 'integer',
'platform_fee_minor' => 'integer',
'merchant_amount_minor' => 'integer',
'metadata' => 'array',
'paid_at' => 'datetime',
];
public function qrCode(): BelongsTo
{
return $this->belongsTo(QrCode::class);
}
public function merchant(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public function amountMajor(): float
{
return $this->amount_minor / 100;
}
}
-212
View File
@@ -1,212 +0,0 @@
<?php
namespace App\Models;
use App\Support\Qr\QrStyleDefaults;
use App\Support\Qr\QrTypeCatalog;
use App\Support\Qr\QrWifiPayload;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class QrCode extends Model
{
public const TYPE_URL = 'url';
public const TYPE_DOCUMENT = 'document';
public const TYPE_LINK_LIST = 'link_list';
public const TYPE_VCARD = 'vcard';
public const TYPE_BUSINESS = 'business';
public const TYPE_IMAGE = 'image';
public const TYPE_CHURCH = 'church';
public const TYPE_MENU = 'menu';
public const TYPE_SHOP = 'shop';
public const TYPE_APP = 'app';
public const TYPE_BOOK = 'book';
public const TYPE_WIFI = 'wifi';
public const TYPE_COUPON = 'coupon';
public const TYPE_EVENT = 'event';
public const TYPE_ITINERARY = 'itinerary';
public const TYPE_PAYMENT = 'payment';
protected $fillable = [
'user_id',
'short_code',
'type',
'label',
'destination_url',
'qr_document_id',
'payload',
'is_active',
'png_path',
'svg_path',
'scans_total',
'unique_scans_total',
'last_scanned_at',
'destination_updated_at',
];
protected $casts = [
'payload' => 'array',
'is_active' => 'boolean',
'scans_total' => 'integer',
'unique_scans_total' => 'integer',
'last_scanned_at' => 'datetime',
'destination_updated_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function document(): BelongsTo
{
return $this->belongsTo(QrDocument::class, 'qr_document_id');
}
public function scanEvents(): HasMany
{
return $this->hasMany(QrScanEvent::class);
}
public function transactions(): HasMany
{
return $this->hasMany(QrTransaction::class);
}
public function miniPayments(): HasMany
{
return $this->hasMany(MiniPayment::class);
}
public function publicUrl(): string
{
return self::publicBaseUrl() . '/q/' . $this->short_code;
}
/**
* Base URL for public QR links. Always the short platform domain
* (ladill.com) never the signed-in account/product host so printed
* codes and shared links stay short and host-independent of where the QR
* was created.
*/
public static function publicBaseUrl(): string
{
$appUrl = (string) config('app.url');
$scheme = parse_url($appUrl, PHP_URL_SCHEME) ?: 'https';
$host = (string) config('app.platform_domain')
?: (parse_url($appUrl, PHP_URL_HOST) ?: 'ladill.com');
return $scheme . '://' . $host;
}
/**
* String encoded inside the QR image.
*
* Most types encode the stable short link so the printed code never changes
* when content is edited. WiFi is the exception: it bakes the network
* credentials directly into the image so devices auto-join on scan. That
* payload is frozen at creation (payload.wifi_encoded) editing the network
* afterwards updates the saved info but never re-encodes the printed code.
*/
public function encodedPayload(): string
{
if ($this->type === self::TYPE_WIFI) {
$frozen = $this->payload['wifi_encoded'] ?? null;
return is_string($frozen) && $frozen !== ''
? $frozen
: QrWifiPayload::encode($this->content());
}
return $this->publicUrl();
}
/** WiFi codes encode their join payload directly (auto-join), not a redirect link. */
public function encodesDirectPayload(): bool
{
return $this->type === self::TYPE_WIFI;
}
public function typeLabel(): string
{
return QrTypeCatalog::label($this->type);
}
/** @return array<string, mixed> */
public function content(): array
{
return (array) ($this->payload['content'] ?? []);
}
/** @return array<string, mixed> */
public function style(): array
{
if ($this->isPaymentType()) {
return QrStyleDefaults::defaults();
}
return QrStyleDefaults::merge($this->payload['style'] ?? null);
}
public function isUrlType(): bool
{
return $this->type === self::TYPE_URL;
}
public function isDocumentType(): bool
{
return $this->type === self::TYPE_DOCUMENT;
}
public function isImageType(): bool
{
return $this->type === self::TYPE_IMAGE;
}
public function isMenuType(): bool
{
return $this->type === self::TYPE_MENU;
}
public function isShopType(): bool
{
return $this->type === self::TYPE_SHOP;
}
public function isBookType(): bool
{
return $this->type === self::TYPE_BOOK;
}
public function acceptsOrders(): bool
{
return in_array($this->type, [self::TYPE_MENU, self::TYPE_SHOP, self::TYPE_CHURCH, self::TYPE_EVENT], true);
}
public function isPaymentType(): bool
{
return $this->type === self::TYPE_PAYMENT;
}
public function usesLandingPage(): bool
{
return in_array($this->type, [
self::TYPE_PAYMENT,
], true);
}
public function resolvesToRedirect(): bool
{
return $this->type === self::TYPE_URL;
}
public function redirectUrl(): ?string
{
if ($this->destination_url) {
return $this->destination_url;
}
return $this->content()['url'] ?? null;
}
}
-35
View File
@@ -1,35 +0,0 @@
<?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);
}
}
-32
View File
@@ -1,32 +0,0 @@
<?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);
}
}
-174
View File
@@ -1,174 +0,0 @@
<?php
namespace App\Models;
use App\Support\Qr\QrStyleDefaults;
use App\Support\Qr\QrTypeCatalog;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/** Per-account Ladill Events preferences (notifications, event defaults, QR style). */
class QrSetting extends Model
{
protected $fillable = [
'user_id',
'notify_email',
'product_updates',
'low_balance_alerts',
'notify_registrations',
'notify_payouts',
'auto_withdraw_amount_minor',
'default_type',
'default_style',
'event_defaults',
];
protected $casts = [
'product_updates' => 'boolean',
'low_balance_alerts' => 'boolean',
'notify_registrations' => 'boolean',
'notify_payouts' => 'boolean',
'auto_withdraw_amount_minor' => 'integer',
'default_style' => 'array',
'event_defaults' => 'array',
];
/** @return list<string> */
public static function storableEventDefaultKeys(): array
{
return [
'currency',
'mode',
'badge_size',
'brand_color',
'organizer',
'registration_open',
'badge_fields',
];
}
/** @return list<string> */
public static function storableStyleKeys(): array
{
return [
'foreground',
'background',
'error_correction',
'margin',
'module_style',
'finder_outer',
'finder_inner',
'frame_style',
'frame_text',
'frame_color',
'gradient_type',
'gradient_color1',
'gradient_color2',
'gradient_rotation',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function resolvedDefaultType(): string
{
$type = (string) ($this->default_type ?? QrCode::TYPE_EVENT);
return in_array($type, QrTypeCatalog::eventsTypes(), true) ? $type : QrCode::TYPE_EVENT;
}
/** @return array<string, mixed> */
public function resolvedEventDefaults(): array
{
$defaults = [
'currency' => 'GHS',
'mode' => 'ticketing',
'badge_size' => '4x3',
'brand_color' => '#4f46e5',
'organizer' => '',
'registration_open' => true,
'badge_fields' => ['Company', 'Role'],
];
$stored = collect($this->event_defaults ?? [])
->only(self::storableEventDefaultKeys())
->filter(fn ($value) => $value !== null && $value !== '')
->all();
if (isset($stored['badge_fields']) && is_array($stored['badge_fields'])) {
$stored['badge_fields'] = array_values(array_filter(
array_map('strval', $stored['badge_fields']),
fn (string $field) => trim($field) !== '',
));
if ($stored['badge_fields'] === []) {
unset($stored['badge_fields']);
}
}
return array_merge($defaults, $stored);
}
/** @param array<string, mixed>|null $input */
public static function sanitizeEventDefaults(?array $input): ?array
{
if ($input === null) {
return null;
}
$allowedModes = ['ticketing', 'contributions', 'free'];
$allowedSizes = ['4x3', '4x6', 'cr80'];
$allowedCurrencies = ['GHS', 'USD', 'NGN', 'KES'];
$mode = (string) ($input['mode'] ?? 'ticketing');
$badgeSize = (string) ($input['badge_size'] ?? '4x3');
$currency = strtoupper(trim((string) ($input['currency'] ?? 'GHS')));
$badgeFields = array_values(array_filter(
array_map(fn ($field) => mb_substr(trim((string) $field), 0, 40), (array) ($input['badge_fields'] ?? [])),
fn (string $field) => $field !== '',
));
$brandColor = (string) ($input['brand_color'] ?? '#4f46e5');
if (! preg_match('/^#[0-9a-fA-F]{6}$/', $brandColor)) {
$brandColor = '#4f46e5';
}
return [
'currency' => in_array($currency, $allowedCurrencies, true) ? $currency : 'GHS',
'mode' => in_array($mode, $allowedModes, true) ? $mode : 'ticketing',
'badge_size' => in_array($badgeSize, $allowedSizes, true) ? $badgeSize : '4x3',
'brand_color' => $brandColor,
'organizer' => mb_substr(trim((string) ($input['organizer'] ?? '')), 0, 120),
'registration_open' => filter_var($input['registration_open'] ?? true, FILTER_VALIDATE_BOOL),
'badge_fields' => $badgeFields !== [] ? $badgeFields : ['Company', 'Role'],
];
}
/** @return array<string, mixed> */
public function resolvedDefaultStyle(): array
{
$stored = collect($this->default_style ?? [])
->only(self::storableStyleKeys())
->filter(fn ($value) => $value !== null && $value !== '')
->all();
return QrStyleDefaults::merge($stored !== [] ? $stored : null);
}
/** @param array<string, mixed>|null $input */
public static function sanitizeDefaultStyle(?array $input): ?array
{
if ($input === null) {
return null;
}
$merged = QrStyleDefaults::merge(
collect($input)->only(self::storableStyleKeys())->all(),
);
return collect($merged)->only(self::storableStyleKeys())->all();
}
}
-46
View File
@@ -1,46 +0,0 @@
<?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
@@ -1,66 +0,0 @@
<?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;
}
}
-34
View File
@@ -5,7 +5,6 @@ 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;
@@ -52,39 +51,6 @@ class User extends Authenticatable
return collect([$this])->merge(self::whereIn('id', $ids)->get())->unique('id')->values();
}
public function qrWallet(): HasOne
{
return $this->hasOne(QrWallet::class);
}
public function qrCodes(): HasMany
{
return $this->hasMany(QrCode::class);
}
public function qrSetting(): HasOne
{
return $this->hasOne(QrSetting::class);
}
public function pushTokens(): HasMany
{
return $this->hasMany(UserPushToken::class);
}
public function getOrCreateQrSetting(): QrSetting
{
return $this->qrSetting()->firstOrCreate([]);
}
public function getOrCreateQrWallet(): QrWallet
{
return $this->qrWallet()->firstOrCreate(
[],
['credit_balance' => 0, 'qr_codes_total' => 0, 'scans_total' => 0, 'status' => QrWallet::STATUS_ACTIVE],
);
}
public function avatarUrl(): ?string
{
$url = trim((string) $this->avatar_url);
-26
View File
@@ -1,26 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class UserPushToken extends Model
{
protected $fillable = [
'user_id',
'token',
'platform',
'device_name',
'last_seen_at',
];
protected $casts = [
'last_seen_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}