Extract Ladill QR Plus as standalone app at qr.ladill.com.
Deploy Ladill QR Plus / deploy (push) Failing after 1s
Deploy Ladill QR Plus / deploy (push) Failing after 1s
Utility QR types only (URL, WiFi, link list, business, app download) with SSO, Billing API integration, public /q resolver, and qr-plus:import for platform migration. vCard and commerce types stay on the platform. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/** Stub — document/file QRs belong to Ladill Transfer, not QR Plus. */
|
||||
class QrDocument extends Model
|
||||
{
|
||||
protected $guarded = [];
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class QrScanEvent extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'qr_code_id',
|
||||
'scanned_at',
|
||||
'ip_hash',
|
||||
'user_agent',
|
||||
'device_type',
|
||||
'browser',
|
||||
'os',
|
||||
'country_code',
|
||||
'referrer',
|
||||
'is_unique',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'scanned_at' => 'datetime',
|
||||
'is_unique' => 'boolean',
|
||||
];
|
||||
|
||||
public function qrCode(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(QrCode::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class QrTransaction extends Model
|
||||
{
|
||||
public const TYPE_CREDIT = 'credit';
|
||||
public const TYPE_DEBIT = 'debit';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'qr_wallet_id',
|
||||
'qr_code_id',
|
||||
'type',
|
||||
'amount_ghs',
|
||||
'balance_after_ghs',
|
||||
'reference',
|
||||
'status',
|
||||
'description',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'amount_ghs' => 'decimal:4',
|
||||
'balance_after_ghs' => 'decimal:4',
|
||||
'metadata' => 'array',
|
||||
];
|
||||
|
||||
public function wallet(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(QrWallet::class, 'qr_wallet_id');
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function qrCode(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(QrCode::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class QrWallet extends Model
|
||||
{
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
public const STATUS_SUSPENDED = 'suspended';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'credit_balance',
|
||||
'qr_codes_total',
|
||||
'scans_total',
|
||||
'status',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'credit_balance' => 'decimal:4',
|
||||
'qr_codes_total' => 'integer',
|
||||
'scans_total' => 'integer',
|
||||
];
|
||||
|
||||
public static function pricePerQr(): float
|
||||
{
|
||||
return (float) config('qr.price_per_qr_ghs', 5.0);
|
||||
}
|
||||
|
||||
public static function minTopupGhs(): float
|
||||
{
|
||||
return (float) config('qr.min_topup_ghs', 5.0);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function transactions(): HasMany
|
||||
{
|
||||
return $this->hasMany(QrTransaction::class)->latest();
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-wallet (siloing step 2): QR spends from the one UserWallet (tagged
|
||||
* 'qr'). Delegates to the billing service (lazily folds any legacy
|
||||
* credit_balance in). `spendableBalance()` is the unified balance for display.
|
||||
*/
|
||||
public function spendableBalance(): float
|
||||
{
|
||||
return $this->user
|
||||
? app(\App\Services\Qr\QrWalletBillingService::class)->balanceCedis($this->user)
|
||||
: (float) $this->credit_balance;
|
||||
}
|
||||
|
||||
public function canCreateQr(): bool
|
||||
{
|
||||
return $this->user
|
||||
? app(\App\Services\Qr\QrWalletBillingService::class)->canCreate($this->user)
|
||||
: false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?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 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 qrWallet(): HasOne
|
||||
{
|
||||
return $this->hasOne(QrWallet::class);
|
||||
}
|
||||
|
||||
public function qrCodes(): HasMany
|
||||
{
|
||||
return $this->hasMany(QrCode::class);
|
||||
}
|
||||
|
||||
public function getOrCreateQrWallet(): QrWallet
|
||||
{
|
||||
return $this->qrWallet()->firstOrCreate(
|
||||
[],
|
||||
['credit_balance' => 0, 'qr_codes_total' => 0, 'scans_total' => 0, 'status' => QrWallet::STATUS_ACTIVE],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user