Add Ladill Link URL shortener on ladl.link.
Management UI at link.ladill.com with GHS 0.05 per link wallet billing, click analytics, and legacy fallback to ladill.com/q for QR ecosystem codes. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Link;
|
||||
|
||||
use App\Models\LinkTransaction;
|
||||
use App\Models\LinkWallet;
|
||||
use App\Models\ShortLink;
|
||||
use App\Models\User;
|
||||
use App\Services\Billing\BillingClient;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
class LinkBillingService
|
||||
{
|
||||
public function __construct(
|
||||
private BillingClient $billing,
|
||||
) {}
|
||||
|
||||
public function balanceCedis(User $user): float
|
||||
{
|
||||
return $this->billing->balanceMinor($user->public_id) / 100;
|
||||
}
|
||||
|
||||
public function canCreate(User $user): bool
|
||||
{
|
||||
$priceMinor = (int) round(LinkWallet::pricePerLink() * 100);
|
||||
|
||||
return $this->billing->canAfford($user->public_id, $priceMinor);
|
||||
}
|
||||
|
||||
public function debitForLinkCreation(LinkWallet $wallet, ShortLink $link): LinkTransaction
|
||||
{
|
||||
$price = LinkWallet::pricePerLink();
|
||||
$priceMinor = (int) round($price * 100);
|
||||
$user = $wallet->user;
|
||||
$reference = 'LINK-DEBIT-'.strtoupper(Str::random(12));
|
||||
|
||||
return DB::transaction(function () use ($wallet, $user, $link, $price, $priceMinor, $reference) {
|
||||
$ok = $this->billing->debit(
|
||||
$user->public_id,
|
||||
$priceMinor,
|
||||
'link',
|
||||
'link_create',
|
||||
$reference,
|
||||
$link->id,
|
||||
sprintf('Created short link: %s', $link->label ?: $link->slug),
|
||||
);
|
||||
|
||||
if (! $ok) {
|
||||
throw new RuntimeException('Insufficient wallet balance.');
|
||||
}
|
||||
|
||||
$wallet->increment('links_total');
|
||||
$balanceAfter = $this->billing->balanceMinor($user->public_id) / 100;
|
||||
|
||||
return LinkTransaction::create([
|
||||
'user_id' => $wallet->user_id,
|
||||
'link_wallet_id' => $wallet->id,
|
||||
'short_link_id' => $link->id,
|
||||
'type' => LinkTransaction::TYPE_DEBIT,
|
||||
'amount_ghs' => round($price, 4),
|
||||
'balance_after_ghs' => $balanceAfter,
|
||||
'reference' => $reference,
|
||||
'status' => 'completed',
|
||||
'description' => sprintf('Created short link: %s', $link->label ?: $link->slug),
|
||||
'metadata' => ['short_link_id' => $link->id],
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Link;
|
||||
|
||||
use App\Models\LinkClick;
|
||||
use App\Models\LinkWallet;
|
||||
use App\Models\ShortLink;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class LinkClickRecorder
|
||||
{
|
||||
public function record(ShortLink $link, Request $request): void
|
||||
{
|
||||
$ipHash = hash('sha256', $request->ip().'|'.config('app.key'));
|
||||
$windowHours = (int) config('link.click_unique_window_hours', 24);
|
||||
$isUnique = ! LinkClick::query()
|
||||
->where('short_link_id', $link->id)
|
||||
->where('ip_hash', $ipHash)
|
||||
->where('clicked_at', '>=', now()->subHours($windowHours))
|
||||
->exists();
|
||||
|
||||
DB::transaction(function () use ($link, $request, $ipHash, $isUnique) {
|
||||
LinkClick::create([
|
||||
'short_link_id' => $link->id,
|
||||
'ip_hash' => $ipHash,
|
||||
'user_agent' => $this->truncate($request->userAgent(), 512),
|
||||
'referer' => $this->truncate($request->header('referer'), 512),
|
||||
'is_unique' => $isUnique,
|
||||
'clicked_at' => now(),
|
||||
]);
|
||||
|
||||
$link->increment('clicks_total');
|
||||
if ($isUnique) {
|
||||
$link->increment('unique_clicks_total');
|
||||
}
|
||||
$link->update(['last_clicked_at' => now()]);
|
||||
|
||||
LinkWallet::query()
|
||||
->where('user_id', $link->user_id)
|
||||
->increment('clicks_total');
|
||||
});
|
||||
}
|
||||
|
||||
private function truncate(?string $value, int $max): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return mb_strlen($value) > $max ? mb_substr($value, 0, $max) : $value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Link;
|
||||
|
||||
use App\Models\LinkClick;
|
||||
use App\Models\LinkWallet;
|
||||
use App\Models\ShortLink;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
class LinkManagerService
|
||||
{
|
||||
public function __construct(
|
||||
private LinkBillingService $billing,
|
||||
private LinkClickRecorder $clickRecorder,
|
||||
) {}
|
||||
|
||||
public function create(User $user, array $data): ShortLink
|
||||
{
|
||||
if (! $this->billing->canCreate($user)) {
|
||||
throw new RuntimeException('Insufficient wallet balance. Top up to create links.');
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($user, $data) {
|
||||
$slug = $this->resolveSlug($data['custom_slug'] ?? null);
|
||||
$wallet = $user->getOrCreateLinkWallet();
|
||||
|
||||
$link = ShortLink::create([
|
||||
'user_id' => $user->id,
|
||||
'slug' => $slug,
|
||||
'label' => $data['label'] ?? null,
|
||||
'destination_url' => $this->normalizeUrl($data['destination_url']),
|
||||
'expires_at' => $data['expires_at'] ?? null,
|
||||
]);
|
||||
|
||||
$this->billing->debitForLinkCreation($wallet, $link);
|
||||
|
||||
return $link;
|
||||
});
|
||||
}
|
||||
|
||||
public function update(ShortLink $link, array $data): ShortLink
|
||||
{
|
||||
$link->fill([
|
||||
'label' => $data['label'] ?? $link->label,
|
||||
'destination_url' => isset($data['destination_url'])
|
||||
? $this->normalizeUrl($data['destination_url'])
|
||||
: $link->destination_url,
|
||||
'is_active' => $data['is_active'] ?? $link->is_active,
|
||||
'expires_at' => array_key_exists('expires_at', $data) ? $data['expires_at'] : $link->expires_at,
|
||||
])->save();
|
||||
|
||||
return $link->fresh();
|
||||
}
|
||||
|
||||
public function resolve(Request $request, string $slug): ?ShortLink
|
||||
{
|
||||
$link = ShortLink::query()
|
||||
->where('slug', $slug)
|
||||
->where('is_active', true)
|
||||
->first();
|
||||
|
||||
if ($link === null || $link->isExpired()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->clickRecorder->record($link, $request);
|
||||
|
||||
return $link;
|
||||
}
|
||||
|
||||
private function resolveSlug(?string $custom): string
|
||||
{
|
||||
if ($custom !== null && $custom !== '') {
|
||||
$custom = strtolower(trim($custom));
|
||||
if (! preg_match('/^[a-z0-9][a-z0-9-]{1,18}[a-z0-9]$/', $custom)) {
|
||||
throw new RuntimeException('Custom slug must be 3–20 characters: lowercase letters, numbers, and hyphens.');
|
||||
}
|
||||
if (ShortLink::where('slug', $custom)->exists()) {
|
||||
throw new RuntimeException('That slug is already taken.');
|
||||
}
|
||||
|
||||
return $custom;
|
||||
}
|
||||
|
||||
do {
|
||||
$slug = Str::lower(Str::random((int) config('link.slug_length', 8)));
|
||||
} while (ShortLink::where('slug', $slug)->exists());
|
||||
|
||||
return $slug;
|
||||
}
|
||||
|
||||
private function normalizeUrl(string $url): string
|
||||
{
|
||||
$url = trim($url);
|
||||
if ($url === '') {
|
||||
throw new RuntimeException('Destination URL is required.');
|
||||
}
|
||||
if (! preg_match('#^https?://#i', $url)) {
|
||||
$url = 'https://'.$url;
|
||||
}
|
||||
if (! filter_var($url, FILTER_VALIDATE_URL)) {
|
||||
throw new RuntimeException('Enter a valid URL.');
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user