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:
isaacclad
2026-06-27 10:58:41 +00:00
co-authored by Cursor
parent 04e4f6ab51
commit d9c91ad7d8
30 changed files with 1002 additions and 247 deletions
+71
View File
@@ -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],
]);
});
}
}
+53
View File
@@ -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;
}
}
+111
View File
@@ -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 320 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;
}
}