Add Events Pro/Business and BYO ticket gateways.
Deploy Ladill Events / deploy (push) Successful in 42s

Cut ticket checkouts off Ladill Pay, settle to merchant gateways at 0% platform fee, and mirror Invoice freemium pricing (GHS 49 / 149).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-15 01:26:28 +00:00
co-authored by Cursor
parent dda52722ce
commit 4c87c786da
26 changed files with 1376 additions and 105 deletions
@@ -0,0 +1,27 @@
<?php
namespace App\Console\Commands;
use App\Models\Events\ProSubscription;
use App\Services\Events\SubscriptionService;
use Illuminate\Console\Command;
class RenewProSubscriptionsCommand extends Command
{
protected $signature = 'events:pro-renew';
protected $description = 'Charge the Ladill wallet for due Events Pro subscriptions.';
public function handle(SubscriptionService $subscriptions): int
{
$due = ProSubscription::where('status', ProSubscription::STATUS_ACTIVE)
->where('auto_renew', true)
->where('current_period_end', '<=', now())
->get();
$this->info("Renewing {$due->count()} due subscription(s).");
$due->each(fn (ProSubscription $sub) => $subscriptions->renewIfDue($sub));
return self::SUCCESS;
}
}
@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Models\QrEventRegistration;
use App\Services\Billing\BillingClient;
use App\Services\Events\SubscriptionService;
use App\Support\Qr\QrTypeCatalog;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
@@ -14,7 +15,10 @@ use Throwable;
class OverviewController extends Controller
{
public function __construct(private BillingClient $billing) {}
public function __construct(
private BillingClient $billing,
private SubscriptionService $subscriptions,
) {}
public function index(Request $request): View
{
@@ -83,6 +87,7 @@ class OverviewController extends Controller
'recentEvents' => $recentEvents,
'programmeCount' => $programmeCount,
'balanceMinor' => $balanceMinor,
'hasPaidPlan' => $this->subscriptions->hasPaidPlan($account),
]);
}
}
@@ -0,0 +1,136 @@
<?php
namespace App\Http\Controllers\Events;
use App\Http\Controllers\Controller;
use App\Models\Events\ProSubscription;
use App\Models\User;
use App\Services\Billing\BillingClient;
use App\Services\Events\SubscriptionService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ProController extends Controller
{
public function __construct(private readonly SubscriptionService $subscriptions) {}
public function index(Request $request): View
{
$user = ladill_account() ?? $request->user();
$planKey = $this->subscriptions->planKey($user);
return view('events.pro.index', [
'subscription' => $this->subscriptions->subscriptionFor($user),
'planKey' => $planKey,
'isPro' => $planKey === ProSubscription::PLAN_PRO,
'isEnterprise' => $planKey === ProSubscription::PLAN_ENTERPRISE,
'hasPaidPlan' => $this->subscriptions->hasPaidPlan($user),
'gatingActive' => $this->subscriptions->gatingActive(),
'proPriceMinor' => $this->subscriptions->priceMinor(),
'enterprisePriceMinor' => $this->subscriptions->enterprisePriceMinor(),
'prepaidMonths' => (array) config('events.prepaid_months', [6, 12, 24]),
'currency' => (string) config('events.pro.currency', 'GHS'),
]);
}
public function subscribe(Request $request): RedirectResponse
{
[$ok, $message] = $this->subscriptions->subscribe(ladill_account() ?? $request->user());
return redirect()->route('events.pro.index')->with($ok ? 'success' : 'error', $message);
}
public function subscribeEnterprise(Request $request): RedirectResponse
{
[$ok, $message] = $this->subscriptions->subscribeEnterprise(ladill_account() ?? $request->user());
return redirect()->route('events.pro.index')->with($ok ? 'success' : 'error', $message);
}
public function subscribePrepaid(Request $request, BillingClient $billing): RedirectResponse
{
$validated = $request->validate([
'plan' => ['required', 'in:pro,enterprise'],
'months' => ['required', 'integer', 'in:6,12,24'],
]);
$user = ladill_account() ?? $request->user();
if ($this->subscriptions->hasPaidPlan($user)) {
return redirect()->route('events.pro.index')
->with('success', 'You already have an active subscription.');
}
$plan = $validated['plan'];
$months = (int) $validated['months'];
$monthlyMinor = $plan === 'enterprise'
? $this->subscriptions->enterprisePriceMinor()
: $this->subscriptions->priceMinor();
try {
$checkout = $billing->initiatePlanCheckout(
$user,
$plan,
$months,
$monthlyMinor * $months,
route('events.pro.paystack.callback'),
['user_id' => $user->id],
);
} catch (\Throwable) {
return redirect()->route('events.pro.index')
->with('error', 'Could not start Paystack checkout. Please try again.');
}
$url = trim((string) ($checkout['checkout_url'] ?? ''));
if ($url === '') {
return redirect()->route('events.pro.index')
->with('error', 'Paystack did not return a checkout URL.');
}
return redirect()->away($url);
}
public function paystackCallback(Request $request, BillingClient $billing): RedirectResponse
{
$reference = (string) $request->query('reference', '');
if ($reference === '') {
return redirect()->route('events.pro.index')->with('error', 'Missing payment reference.');
}
try {
$result = $billing->verifyPlanCheckout($reference);
} catch (\Throwable) {
return redirect()->route('events.pro.index')
->with('error', 'Could not verify payment. Contact support with reference: '.$reference);
}
if (! ($result['paid'] ?? false)) {
return redirect()->route('events.pro.index')
->with('error', 'Payment was not completed.');
}
$metadata = (array) ($result['metadata'] ?? []);
$user = User::query()->find((int) ($metadata['user_id'] ?? 0));
$account = ladill_account() ?? $request->user();
if (! $user || ! $account || $user->id !== $account->id) {
return redirect()->route('events.pro.index')
->with('error', 'Account not found for this payment.');
}
$plan = (string) ($result['plan'] ?? 'pro');
$months = (int) ($result['months'] ?? 0);
$this->subscriptions->activatePrepaid($user, $plan, $months);
$label = $plan === 'enterprise' ? 'Events Business' : 'Events Pro';
return redirect()->route('events.pro.index')
->with('success', "{$label} is active for {$months} months.");
}
public function cancel(Request $request): RedirectResponse
{
[$ok, $message] = $this->subscriptions->cancel(ladill_account() ?? $request->user());
return redirect()->route('events.pro.index')->with($ok ? 'success' : 'error', $message);
}
}
+39 -1
View File
@@ -3,19 +3,25 @@
namespace App\Http\Controllers\Qr;
use App\Http\Controllers\Controller;
use App\Models\PaymentGatewaySetting;
use App\Models\QrSetting;
use App\Services\Billing\BillingClient;
use App\Services\Payments\MerchantGatewayService;
use App\Support\AccountBranding;
use App\Support\Qr\QrCornerStyleCatalog;
use App\Support\Qr\QrFrameStyleCatalog;
use App\Support\Qr\QrModuleStyleCatalog;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
class AccountController extends Controller
{
public function __construct(private BillingClient $billing) {}
public function __construct(
private BillingClient $billing,
private MerchantGatewayService $gateway,
) {}
private function topupUrl(): string
{
@@ -76,6 +82,7 @@ class AccountController extends Controller
'cornerOuterStyles' => QrCornerStyleCatalog::outerStyles(),
'cornerInnerStyles' => QrCornerStyleCatalog::innerStyles(),
'frameStyles' => QrFrameStyleCatalog::visible(),
'gateway' => $this->gateway->settingFor($account),
]);
}
@@ -115,6 +122,16 @@ class AccountController extends Controller
'default_style.gradient_rotation' => ['nullable', 'integer', 'min:0', 'max:360'],
'logo' => ['nullable', 'image', 'mimes:jpeg,png,jpg,webp,svg', 'max:2048'],
'remove_logo' => ['nullable', 'boolean'],
'gateway_provider' => ['nullable', Rule::in([
PaymentGatewaySetting::PROVIDER_PAYSTACK,
PaymentGatewaySetting::PROVIDER_FLUTTERWAVE,
PaymentGatewaySetting::PROVIDER_HUBTEL,
'',
])],
'gateway_public_key' => ['nullable', 'string', 'max:2000'],
'gateway_secret_key' => ['nullable', 'string', 'max:2000'],
'gateway_webhook_secret' => ['nullable', 'string', 'max:2000'],
'gateway_is_active' => ['nullable', 'boolean'],
]);
$eventDefaults = $data['event_defaults'] ?? [];
@@ -146,6 +163,27 @@ class AccountController extends Controller
],
);
$provider = (string) ($data['gateway_provider'] ?? '');
if ($provider !== '') {
$existing = PaymentGatewaySetting::query()->firstOrNew([
'owner_ref' => $account->public_id,
]);
$existing->provider = $provider;
$existing->is_active = $request->boolean('gateway_is_active', true);
if (filled($data['gateway_public_key'] ?? null)) {
$existing->public_key = $data['gateway_public_key'];
}
if (filled($data['gateway_secret_key'] ?? null)) {
$existing->secret_key = $data['gateway_secret_key'];
}
if (array_key_exists('gateway_webhook_secret', $data) && $data['gateway_webhook_secret'] !== null) {
$existing->webhook_secret = $data['gateway_webhook_secret'] !== ''
? $data['gateway_webhook_secret']
: null;
}
$existing->save();
}
return redirect()->route('account.settings')->with('success', 'Settings saved.');
}
}
+17 -1
View File
@@ -7,6 +7,7 @@ use App\Models\QrCode;
use App\Models\QrEventRegistration;
use App\Models\QrWallet;
use App\Services\Billing\BillingClient;
use App\Services\Events\SubscriptionService;
use App\Services\Qr\QrAnalyticsService;
use App\Services\Qr\QrCodeManagerService;
use App\Services\Qr\QrImageGeneratorService;
@@ -37,6 +38,7 @@ class QrCodeController extends Controller
private QrImageGeneratorService $imageGenerator,
private QrPdfExporter $pdfExporter,
private BillingClient $platformBilling,
private SubscriptionService $subscriptions,
) {}
public function index(Request $request): View
@@ -72,8 +74,15 @@ class QrCodeController extends Controller
]);
}
public function create(Request $request): View
public function create(Request $request): View|RedirectResponse
{
$account = ladill_account() ?? $request->user();
$requestedType = $request->query('type', QrCode::TYPE_EVENT);
if ($account && ($requestedType === QrCode::TYPE_EVENT) && ! $this->subscriptions->canCreateEvent($account)) {
return redirect()->route('events.pro.index')
->with('upsell', 'Free plan includes up to '.config('events.free.max_live_events', 2).' live events. Upgrade for unlimited.');
}
$account = ladill_account();
$wallet = $this->manager->walletFor($account);
$qrSettings = $account->getOrCreateQrSetting();
@@ -126,6 +135,13 @@ class QrCodeController extends Controller
public function store(Request $request): RedirectResponse
{
$account = ladill_account() ?? $request->user();
$requestedType = (string) $request->input('type', QrCode::TYPE_EVENT);
if ($account && $requestedType === QrCode::TYPE_EVENT && ! $this->subscriptions->canCreateEvent($account)) {
return redirect()->route('events.pro.index')
->with('upsell', 'Free plan includes up to '.config('events.free.max_live_events', 2).' live events. Upgrade for unlimited.');
}
$validated = $request->validate([
'label' => ['required', 'string', 'max:120'],
'type' => ['required', 'in:' . implode(',', QrTypeCatalog::keys())],
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Middleware;
use App\Services\Events\SubscriptionService;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsurePro
{
public function __construct(private readonly SubscriptionService $subscriptions) {}
public function handle(Request $request, Closure $next): Response
{
$user = ladill_account() ?? $request->user();
if ($user && $this->subscriptions->isPro($user)) {
return $next($request);
}
return redirect()->route('events.pro.index')
->with('upsell', 'This feature is part of Ladill Events Pro or Business.');
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace App\Models\Events;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ProSubscription extends Model
{
public const STATUS_ACTIVE = 'active';
public const STATUS_CANCELED = 'canceled';
public const STATUS_PAST_DUE = 'past_due';
public const PLAN_PRO = 'pro';
public const PLAN_ENTERPRISE = 'enterprise';
protected $table = 'events_pro_subscriptions';
protected $fillable = [
'user_id', 'status', 'plan', 'price_minor', 'currency', 'auto_renew',
'started_at', 'current_period_end', 'last_charged_at', 'canceled_at',
'last_reference', 'last_error',
];
protected $casts = [
'auto_renew' => 'boolean',
'price_minor' => 'integer',
'started_at' => 'datetime',
'current_period_end' => 'datetime',
'last_charged_at' => 'datetime',
'canceled_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function entitled(): bool
{
return $this->status !== self::STATUS_PAST_DUE
&& $this->current_period_end !== null
&& $this->current_period_end->isFuture();
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class PaymentGatewaySetting extends Model
{
public const PROVIDER_PAYSTACK = 'paystack';
public const PROVIDER_FLUTTERWAVE = 'flutterwave';
public const PROVIDER_HUBTEL = 'hubtel';
protected $fillable = [
'owner_ref',
'provider',
'public_key',
'secret_key',
'webhook_secret',
'is_active',
'metadata',
];
protected function casts(): array
{
return [
'public_key' => 'encrypted',
'secret_key' => 'encrypted',
'webhook_secret' => 'encrypted',
'is_active' => 'boolean',
'metadata' => 'array',
];
}
public function isConfigured(): bool
{
if (! $this->is_active) {
return false;
}
$secret = trim((string) $this->secret_key);
return $secret !== '' && in_array($this->provider, [
self::PROVIDER_PAYSTACK,
self::PROVIDER_FLUTTERWAVE,
self::PROVIDER_HUBTEL,
], true);
}
}
+92
View File
@@ -3,6 +3,8 @@
namespace App\Services\Billing;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use App\Models\User;
/**
* Client for the platform Billing HTTP API the one UserWallet lives on the
@@ -84,4 +86,94 @@ class BillingClient
return (array) $res->json();
}
public function configured(): bool
{
return $this->base() !== '' && $this->token() !== '';
}
/**
* @return array{ok: bool, insufficient: bool, balance_minor: ?int, error: ?string}
*/
public function charge(User $user, int $amountMinor, string $reference, string $description): array
{
if (! $this->configured()) {
return ['ok' => false, 'insufficient' => false, 'balance_minor' => null, 'error' => 'Billing is not configured.'];
}
try {
$res = Http::withToken($this->token())
->acceptJson()->timeout(20)
->post($this->base().'/debit', [
'user' => $user->public_id,
'amount_minor' => $amountMinor,
'service' => (string) config('billing.service', 'events'),
'source' => 'subscription',
'reference' => $reference,
'description' => $description,
]);
} catch (\Throwable $e) {
Log::warning('Events BillingClient: charge failed', ['user' => $user->public_id, 'error' => $e->getMessage()]);
return ['ok' => false, 'insufficient' => false, 'balance_minor' => null, 'error' => 'Could not reach the billing service.'];
}
if ($res->status() === 402) {
return ['ok' => false, 'insufficient' => true, 'balance_minor' => (int) $res->json('balance_minor', 0), 'error' => 'Insufficient wallet balance.'];
}
if ($res->failed()) {
return ['ok' => false, 'insufficient' => false, 'balance_minor' => null, 'error' => 'Billing error ('.$res->status().').'];
}
return ['ok' => true, 'insufficient' => false, 'balance_minor' => null, 'error' => null];
}
/**
* @param array<string, mixed> $metadata
* @return array{checkout_url: string, reference: string}
*/
public function initiatePlanCheckout(
User $user,
string $plan,
int $months,
int $amountMinor,
string $returnUrl,
array $metadata = [],
): array {
$res = Http::withToken($this->token())
->acceptJson()->timeout(15)
->post($this->base().'/plan-checkout', [
'user' => $user->public_id,
'app' => (string) config('billing.service', 'events'),
'plan' => $plan,
'months' => $months,
'amount_minor' => $amountMinor,
'return_url' => $returnUrl,
'metadata' => $metadata,
]);
$res->throw();
return [
'checkout_url' => (string) $res->json('checkout_url'),
'reference' => (string) $res->json('reference'),
];
}
/** @return array<string, mixed> */
public function verifyPlanCheckout(string $reference): array
{
$res = Http::withToken($this->token())
->acceptJson()->timeout(15)
->post($this->base().'/plan-checkout/verify', [
'reference' => $reference,
]);
if ($res->status() === 422) {
return ['paid' => false, 'error' => $res->json('error')];
}
$res->throw();
return $res->json();
}
}
@@ -4,12 +4,10 @@ namespace App\Services\Events;
use App\Models\QrCode;
use App\Models\QrEventRegistration;
use App\Services\Billing\BillingClient;
use App\Services\Billing\PaystackService;
use App\Services\Billing\SmsService;
use App\Services\Events\EventEmailService;
use App\Services\Meet\EventMeetAccessService;
use App\Services\Pay\PayClient;
use App\Services\Payments\MerchantGatewayService;
use App\Support\LadillLink;
use Illuminate\Support\Str;
use RuntimeException;
@@ -17,9 +15,8 @@ use RuntimeException;
class EventRegistrationService
{
public function __construct(
private PayClient $pay,
private PaystackService $paystack,
private BillingClient $billing,
private MerchantGatewayService $gateway,
private SubscriptionService $subscriptions,
private SmsService $sms,
private EventEmailService $email,
private EventMeetAccessService $meetAccess,
@@ -27,7 +24,7 @@ class EventRegistrationService
/**
* Register an attendee. Free tiers confirm instantly; paid tiers return a
* Paystack checkout URL and stay pending until payment completes.
* merchant-gateway checkout URL and stay pending until payment completes.
*
* @param array<string, mixed> $data
* @return array{registration: QrEventRegistration, paid: bool, checkout_url: ?string}
@@ -98,7 +95,12 @@ class EventRegistrationService
}
$amountMinor = (int) round($priceGhs * 100);
$meetReturn = trim((string) ($data['meet_return'] ?? ''));
$qrCode->loadMissing('user');
if ($qrCode->user && ! $this->subscriptions->canAcceptTicket($qrCode->user)) {
throw new RuntimeException('This organizer has reached the free monthly ticket limit. Ask them to upgrade Events Pro.');
}
$meetReturn = trim((string) ($data['meet_return'] ?? ''));
$metadata = $meetReturn !== '' ? ['meet_return' => $meetReturn] : null;
$registration = QrEventRegistration::create([
@@ -124,131 +126,65 @@ class EventRegistrationService
}
$qrCode->loadMissing('user');
$feeTier = $mode === 'contributions' ? 'donations' : 'sales';
$lineName = $mode === 'contributions'
? sprintf('%s — %s', $content['name'] ?? $qrCode->label, $tierName)
: sprintf('%s ticket — %s', $content['name'] ?? $qrCode->label, $tierName);
$payOrder = $this->pay->createCheckout([
'merchant' => $qrCode->user->public_id,
'fee_tier' => $feeTier,
'source_service' => 'events',
'source_ref' => (string) $qrCode->id,
'callback_url' => LadillLink::path($qrCode->short_code, 'register/callback'),
'customer_name' => $registration->attendee_name,
'customer_email' => $email,
'customer_phone' => $registration->attendee_phone,
'line_items' => [
[
'name' => $lineName,
'unit_price_minor' => $amountMinor,
'quantity' => 1,
],
],
'metadata' => [
$checkout = $this->gateway->initializeCheckout(
$qrCode->user,
$amountMinor,
(string) ($registration->currency ?? 'GHS'),
$email,
LadillLink::path($qrCode->short_code, 'register/callback'),
$registration->reference,
[
'title' => $lineName,
'registration_id' => $registration->id,
'registration_reference' => $registration->reference,
'qr_code_id' => $qrCode->id,
'mode' => $mode,
],
]);
);
$registration->update([
'pay_order_id' => $payOrder['id'] ?? null,
'payment_reference' => $payOrder['reference'],
'payment_reference' => $checkout['reference'],
]);
$checkoutUrl = (string) ($payOrder['checkout_url'] ?? '');
if ($checkoutUrl === '') {
throw new RuntimeException('Could not start checkout. Please try again.');
}
return [
'registration' => $registration->fresh(),
'paid' => true,
'checkout_url' => $checkoutUrl,
'checkout_url' => $checkout['checkout_url'],
];
}
public function complete(string $reference): QrEventRegistration
{
if (str_starts_with($reference, 'LP-')) {
return $this->completeLadillPay($reference);
}
return $this->completeLegacy($reference);
}
private function completeLadillPay(string $reference): QrEventRegistration
{
$registration = QrEventRegistration::where('payment_reference', $reference)
->where('status', QrEventRegistration::STATUS_PENDING)
->with('qrCode.user')
->firstOrFail();
$payOrder = $this->pay->verify($reference);
$platformFeeGhs = ((int) ($payOrder['platform_fee_minor'] ?? 0)) / 100;
$registration->update([
'status' => QrEventRegistration::STATUS_CONFIRMED,
'paid_at' => now(),
'pay_order_id' => $payOrder['id'] ?? $registration->pay_order_id,
'metadata' => array_merge((array) $registration->metadata, [
'ladill_pay' => $payOrder,
'platform_fee_ghs' => $platformFeeGhs,
]),
]);
$this->notifyConfirmed($registration->fresh('qrCode'));
return $registration;
}
/** Legacy QREP-* references before Ladill Pay migration. */
private function completeLegacy(string $reference): QrEventRegistration
{
$registration = QrEventRegistration::where('payment_reference', $reference)
->where('status', QrEventRegistration::STATUS_PENDING)
->firstOrFail();
$data = $this->paystack->verifyTransaction($reference);
if (($data['status'] ?? '') !== 'success') {
$owner = $registration->qrCode?->user ?? $registration->organizer;
$result = $this->gateway->verify($owner, $reference);
if (! $result['paid']) {
$registration->update(['status' => QrEventRegistration::STATUS_FAILED]);
throw new RuntimeException('Payment was not successful.');
}
$feeRate = ($registration->qrCode?->content()['mode'] ?? 'ticketing') === 'contributions' ? 0.035 : 0.055;
$paidAmount = round(($data['amount'] ?? 0) / 100, 2);
$platformFee = round($paidAmount * $feeRate, 2);
$organizerAmountMinor = (int) round(($paidAmount - $platformFee) * 100);
$registration->update([
'status' => QrEventRegistration::STATUS_CONFIRMED,
'paid_at' => now(),
'amount_minor' => (int) ($result['amount_minor'] ?: $registration->amount_minor),
'metadata' => array_merge((array) $registration->metadata, [
'paystack' => $data,
'platform_fee_ghs' => $platformFee,
'legacy' => true,
'merchant_gateway' => [
'provider' => $result['provider'],
'reference' => $result['reference'],
'raw' => $result['raw'],
],
'platform_fee_ghs' => 0,
]),
]);
$mode = ($registration->qrCode->content()['mode'] ?? 'ticketing') === 'contributions' ? 'contributions' : 'ticketing';
$this->billing->credit(
$registration->organizer->public_id,
$organizerAmountMinor,
'events',
'pay',
$reference,
$registration->id,
sprintf(
'%s — %s (%s)',
$mode === 'contributions' ? 'Event contribution' : 'Event ticket',
$registration->qrCode->label,
$registration->tier_name
),
);
$this->notifyConfirmed($registration->fresh('qrCode'));
return $registration;
+257
View File
@@ -0,0 +1,257 @@
<?php
namespace App\Services\Events;
use App\Models\Events\ProSubscription;
use App\Models\QrCode;
use App\Models\QrEventRegistration;
use App\Models\User;
use App\Services\Billing\BillingClient;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
class SubscriptionService
{
public function __construct(private readonly BillingClient $billing) {}
public function gatingActive(): bool
{
return (bool) config('events.pro.enabled', true) && $this->billing->configured();
}
public function priceMinor(): int
{
return (int) config('events.plans.pro.price_minor', config('events.pro.price_minor', 4900));
}
public function enterprisePriceMinor(): int
{
return (int) config('events.plans.enterprise.price_minor', 14900);
}
public function subscriptionFor(User $user): ?ProSubscription
{
return ProSubscription::where('user_id', $user->id)->first();
}
public function planKey(User $user): string
{
if (! $this->gatingActive()) {
return ProSubscription::PLAN_PRO;
}
$sub = $this->subscriptionFor($user);
if (! $sub || ! $sub->entitled()) {
return 'free';
}
return $sub->plan === ProSubscription::PLAN_ENTERPRISE
? ProSubscription::PLAN_ENTERPRISE
: ProSubscription::PLAN_PRO;
}
public function hasPaidPlan(User $user): bool
{
if (! $this->gatingActive()) {
return true;
}
return (bool) $this->subscriptionFor($user)?->entitled();
}
public function isEnterprise(User $user): bool
{
return $this->planKey($user) === ProSubscription::PLAN_ENTERPRISE;
}
public function isPro(User $user): bool
{
return $this->hasPaidPlan($user);
}
public function liveEventCount(User $user): int
{
return QrCode::query()
->where('user_id', $user->id)
->where('type', QrCode::TYPE_EVENT)
->count();
}
public function canCreateEvent(User $user): bool
{
if ($this->isPro($user)) {
return true;
}
return $this->liveEventCount($user) < (int) config('events.free.max_live_events', 2);
}
public function ticketsThisMonth(User $user): int
{
return QrEventRegistration::query()
->where('user_id', $user->id)
->where('status', QrEventRegistration::STATUS_CONFIRMED)
->where('created_at', '>=', now()->startOfMonth())
->count();
}
public function canAcceptTicket(User $user): bool
{
if ($this->isPro($user)) {
return true;
}
return $this->ticketsThisMonth($user) < (int) config('events.free.max_tickets_per_month', 100);
}
/** @return array{0:bool,1:string} */
public function subscribe(User $user): array
{
$existing = $this->subscriptionFor($user);
if ($existing && $existing->entitled() && $existing->status === ProSubscription::STATUS_ACTIVE) {
return [true, 'You already have an active subscription.'];
}
if ($existing?->plan === ProSubscription::PLAN_ENTERPRISE) {
return $this->subscribeEnterprise($user);
}
return $this->activateWalletPlan($user, ProSubscription::PLAN_PRO, $this->priceMinor(), 'Ladill Events Pro — monthly subscription', 'Welcome to Ladill Events Pro!');
}
/** @return array{0:bool,1:string} */
public function subscribeEnterprise(User $user): array
{
$existing = $this->subscriptionFor($user);
if ($existing && $existing->entitled() && $existing->status === ProSubscription::STATUS_ACTIVE) {
return [true, 'You already have an active subscription.'];
}
return $this->activateWalletPlan(
$user,
ProSubscription::PLAN_ENTERPRISE,
$this->enterprisePriceMinor(),
'Ladill Events Business — monthly subscription',
'Welcome to Ladill Events Business!',
);
}
public function activatePrepaid(User $user, string $plan, int $months): void
{
$existing = $this->subscriptionFor($user);
$price = $plan === ProSubscription::PLAN_ENTERPRISE
? $this->enterprisePriceMinor()
: $this->priceMinor();
ProSubscription::updateOrCreate(
['user_id' => $user->id],
[
'plan' => $plan,
'status' => ProSubscription::STATUS_ACTIVE,
'price_minor' => $price,
'currency' => (string) config('events.pro.currency', 'GHS'),
'auto_renew' => false,
'started_at' => $existing?->started_at ?? now(),
'current_period_end' => Carbon::now()->addMonths($months),
'last_charged_at' => now(),
'canceled_at' => null,
'last_reference' => null,
'last_error' => null,
],
);
}
/** @return array{0:bool,1:string} */
public function cancel(User $user): array
{
$sub = $this->subscriptionFor($user);
if (! $sub || ! $sub->entitled()) {
return [false, 'You do not have an active subscription.'];
}
$sub->forceFill([
'status' => ProSubscription::STATUS_CANCELED,
'auto_renew' => false,
'canceled_at' => now(),
])->save();
$until = optional($sub->current_period_end)->format('d M Y');
return [true, "Auto-renew is off. You keep access until {$until}."];
}
public function renewIfDue(ProSubscription $sub): void
{
if (! $sub->auto_renew || $sub->status !== ProSubscription::STATUS_ACTIVE) {
return;
}
if ($sub->current_period_end && $sub->current_period_end->isFuture()) {
return;
}
$user = $sub->user;
if (! $user) {
return;
}
$planLabel = $sub->plan === ProSubscription::PLAN_ENTERPRISE ? 'Business' : 'Pro';
$reference = 'events-'.$sub->plan.'-'.$user->id.'-'.now()->format('YmdHis').'-'.Str::random(6);
$result = $this->billing->charge($user, $sub->price_minor, $reference, "Ladill Events {$planLabel} — renewal");
if ($result['ok']) {
$sub->forceFill([
'current_period_end' => Carbon::now()->addDays((int) config('events.pro.period_days', 30)),
'last_charged_at' => now(),
'last_reference' => $reference,
'last_error' => null,
])->save();
return;
}
$graceEnd = optional($sub->current_period_end)->addDays((int) config('events.pro.grace_days', 3));
if ($graceEnd && $graceEnd->isPast()) {
$sub->forceFill(['status' => ProSubscription::STATUS_PAST_DUE, 'last_error' => $result['error']])->save();
} else {
$sub->forceFill(['last_error' => $result['error']])->save();
}
}
/** @return array{0:bool,1:string} */
private function activateWalletPlan(User $user, string $plan, int $price, string $description, string $successMessage): array
{
$existing = $this->subscriptionFor($user);
$reference = 'events-'.$plan.'-'.$user->id.'-'.now()->format('YmdHis').'-'.Str::random(6);
$result = $this->billing->charge($user, $price, $reference, $description);
if (! $result['ok']) {
if ($result['insufficient']) {
$bal = number_format(((int) $result['balance_minor']) / 100, 2);
return [false, "Your wallet balance (GHS {$bal}) is too low. Top up, then subscribe."];
}
return [false, $result['error'] ?? 'Could not start your subscription. Please try again.'];
}
$periodEnd = Carbon::now()->addDays((int) config('events.pro.period_days', 30));
ProSubscription::updateOrCreate(
['user_id' => $user->id],
[
'plan' => $plan,
'status' => ProSubscription::STATUS_ACTIVE,
'price_minor' => $price,
'currency' => (string) config('events.pro.currency', 'GHS'),
'auto_renew' => true,
'started_at' => $existing?->started_at ?? now(),
'current_period_end' => $periodEnd,
'last_charged_at' => now(),
'canceled_at' => null,
'last_reference' => $reference,
'last_error' => null,
],
);
return [true, $successMessage.' Your subscription is active.'];
}
}
@@ -0,0 +1,288 @@
<?php
namespace App\Services\Payments;
use App\Models\PaymentGatewaySetting;
use App\Models\User;
use Illuminate\Support\Facades\Http;
use RuntimeException;
class MerchantGatewayService
{
public function settingFor(User|string $owner): ?PaymentGatewaySetting
{
$ownerRef = $owner instanceof User ? (string) $owner->public_id : $owner;
return PaymentGatewaySetting::query()->where('owner_ref', $ownerRef)->first();
}
public function isConfigured(User|string $owner): bool
{
return (bool) $this->settingFor($owner)?->isConfigured();
}
/**
* @param array<string, mixed> $metadata
* @return array{checkout_url: string, reference: string, provider: string}
*/
public function initializeCheckout(
User|string $owner,
int $amountMinor,
string $currency,
string $email,
string $callbackUrl,
string $reference,
array $metadata = [],
): array {
$setting = $this->requireConfigured($owner);
$currency = strtoupper($currency ?: 'GHS');
return match ($setting->provider) {
PaymentGatewaySetting::PROVIDER_PAYSTACK => $this->paystackInitialize($setting, $amountMinor, $currency, $email, $callbackUrl, $reference, $metadata),
PaymentGatewaySetting::PROVIDER_FLUTTERWAVE => $this->flutterwaveInitialize($setting, $amountMinor, $currency, $email, $callbackUrl, $reference, $metadata),
PaymentGatewaySetting::PROVIDER_HUBTEL => $this->hubtelInitialize($setting, $amountMinor, $currency, $email, $callbackUrl, $reference, $metadata),
default => throw new RuntimeException('Unsupported payment provider.'),
};
}
/**
* @return array{paid: bool, amount_minor: int, reference: string, provider: string, raw: array<string, mixed>}
*/
public function verify(User|string $owner, string $reference): array
{
$setting = $this->requireConfigured($owner);
return match ($setting->provider) {
PaymentGatewaySetting::PROVIDER_PAYSTACK => $this->paystackVerify($setting, $reference),
PaymentGatewaySetting::PROVIDER_FLUTTERWAVE => $this->flutterwaveVerify($setting, $reference),
PaymentGatewaySetting::PROVIDER_HUBTEL => $this->hubtelVerify($setting, $reference),
default => throw new RuntimeException('Unsupported payment provider.'),
};
}
protected function requireConfigured(User|string $owner): PaymentGatewaySetting
{
$setting = $this->settingFor($owner);
if (! $setting?->isConfigured()) {
throw new RuntimeException('Connect Paystack, Flutterwave, or Hubtel in Settings before accepting online payments.');
}
return $setting;
}
/**
* @param array<string, mixed> $metadata
* @return array{checkout_url: string, reference: string, provider: string}
*/
protected function paystackInitialize(
PaymentGatewaySetting $setting,
int $amountMinor,
string $currency,
string $email,
string $callbackUrl,
string $reference,
array $metadata,
): array {
$response = Http::withToken((string) $setting->secret_key)
->acceptJson()
->timeout(20)
->post('https://api.paystack.co/transaction/initialize', [
'email' => $email !== '' ? $email : 'payer@example.com',
'amount' => $amountMinor,
'currency' => $currency,
'reference' => $reference,
'callback_url' => $callbackUrl,
'metadata' => $metadata,
]);
if (! $response->successful() || ! ($response->json('status') ?? false)) {
throw new RuntimeException($response->json('message') ?: 'Paystack could not start checkout.');
}
$url = (string) $response->json('data.authorization_url');
if ($url === '') {
throw new RuntimeException('Paystack did not return a checkout URL.');
}
return [
'checkout_url' => $url,
'reference' => (string) ($response->json('data.reference') ?: $reference),
'provider' => PaymentGatewaySetting::PROVIDER_PAYSTACK,
];
}
/**
* @return array{paid: bool, amount_minor: int, reference: string, provider: string, raw: array<string, mixed>}
*/
protected function paystackVerify(PaymentGatewaySetting $setting, string $reference): array
{
$response = Http::withToken((string) $setting->secret_key)
->acceptJson()
->timeout(20)
->get('https://api.paystack.co/transaction/verify/'.rawurlencode($reference));
$data = (array) ($response->json('data') ?? []);
$paid = ($response->json('status') ?? false)
&& strtolower((string) ($data['status'] ?? '')) === 'success';
return [
'paid' => $paid,
'amount_minor' => (int) ($data['amount'] ?? 0),
'reference' => (string) ($data['reference'] ?? $reference),
'provider' => PaymentGatewaySetting::PROVIDER_PAYSTACK,
'raw' => $data,
];
}
/**
* @param array<string, mixed> $metadata
* @return array{checkout_url: string, reference: string, provider: string}
*/
protected function flutterwaveInitialize(
PaymentGatewaySetting $setting,
int $amountMinor,
string $currency,
string $email,
string $callbackUrl,
string $reference,
array $metadata,
): array {
$response = Http::withToken((string) $setting->secret_key)
->acceptJson()
->timeout(20)
->post('https://api.flutterwave.com/v3/payments', [
'tx_ref' => $reference,
'amount' => round($amountMinor / 100, 2),
'currency' => $currency,
'redirect_url' => $callbackUrl,
'customer' => [
'email' => $email !== '' ? $email : 'payer@example.com',
],
'customizations' => [
'title' => (string) ($metadata['title'] ?? 'Payment'),
],
'meta' => $metadata,
]);
if (! $response->successful() || ($response->json('status') ?? '') !== 'success') {
throw new RuntimeException($response->json('message') ?: 'Flutterwave could not start checkout.');
}
$url = (string) $response->json('data.link');
if ($url === '') {
throw new RuntimeException('Flutterwave did not return a checkout URL.');
}
return [
'checkout_url' => $url,
'reference' => $reference,
'provider' => PaymentGatewaySetting::PROVIDER_FLUTTERWAVE,
];
}
/**
* @return array{paid: bool, amount_minor: int, reference: string, provider: string, raw: array<string, mixed>}
*/
protected function flutterwaveVerify(PaymentGatewaySetting $setting, string $reference): array
{
$response = Http::withToken((string) $setting->secret_key)
->acceptJson()
->timeout(20)
->get('https://api.flutterwave.com/v3/transactions/verify_by_reference', [
'tx_ref' => $reference,
]);
$data = (array) ($response->json('data') ?? []);
$paid = ($response->json('status') ?? '') === 'success'
&& strtolower((string) ($data['status'] ?? '')) === 'successful';
$amountMajor = (float) ($data['amount'] ?? 0);
return [
'paid' => $paid,
'amount_minor' => (int) round($amountMajor * 100),
'reference' => (string) ($data['tx_ref'] ?? $reference),
'provider' => PaymentGatewaySetting::PROVIDER_FLUTTERWAVE,
'raw' => $data,
];
}
/**
* @param array<string, mixed> $metadata
* @return array{checkout_url: string, reference: string, provider: string}
*/
protected function hubtelInitialize(
PaymentGatewaySetting $setting,
int $amountMinor,
string $currency,
string $email,
string $callbackUrl,
string $reference,
array $metadata,
): array {
// Hubtel: public_key = merchant account number, secret_key = API key (client secret),
// webhook_secret optionally stores client id for Basic auth as "clientId:clientSecret".
$auth = trim((string) ($setting->webhook_secret ?: $setting->secret_key));
if (! str_contains($auth, ':')) {
$auth = trim((string) $setting->public_key).':'.trim((string) $setting->secret_key);
}
$response = Http::withBasicAuth(...explode(':', $auth, 2))
->acceptJson()
->timeout(20)
->post('https://payproxyapi.hubtel.com/items/initiate', [
'totalAmount' => round($amountMinor / 100, 2),
'description' => (string) ($metadata['title'] ?? 'Payment'),
'callbackUrl' => $callbackUrl,
'returnUrl' => $callbackUrl,
'merchantAccountNumber' => (string) $setting->public_key,
'cancellationUrl' => $callbackUrl,
'clientReference' => $reference,
]);
if (! $response->successful()) {
throw new RuntimeException($response->json('message') ?: 'Hubtel could not start checkout.');
}
$url = (string) ($response->json('data.checkoutUrl') ?? $response->json('data.checkoutDirectUrl') ?? '');
if ($url === '') {
throw new RuntimeException('Hubtel did not return a checkout URL.');
}
return [
'checkout_url' => $url,
'reference' => $reference,
'provider' => PaymentGatewaySetting::PROVIDER_HUBTEL,
];
}
/**
* @return array{paid: bool, amount_minor: int, reference: string, provider: string, raw: array<string, mixed>}
*/
protected function hubtelVerify(PaymentGatewaySetting $setting, string $reference): array
{
$auth = trim((string) ($setting->webhook_secret ?: $setting->secret_key));
if (! str_contains($auth, ':')) {
$auth = trim((string) $setting->public_key).':'.trim((string) $setting->secret_key);
}
$response = Http::withBasicAuth(...explode(':', $auth, 2))
->acceptJson()
->timeout(20)
->get('https://api-txnstatus.hubtel.com/transactions/'.$setting->public_key.'/status', [
'clientReference' => $reference,
]);
$data = (array) ($response->json('data') ?? $response->json() ?? []);
$status = strtolower((string) ($data['status'] ?? $data['transactionStatus'] ?? ''));
$paid = in_array($status, ['success', 'successful', 'paid', 'completed'], true);
$amountMajor = (float) ($data['amount'] ?? $data['totalAmount'] ?? 0);
return [
'paid' => $paid,
'amount_minor' => (int) round($amountMajor * 100),
'reference' => $reference,
'provider' => PaymentGatewaySetting::PROVIDER_HUBTEL,
'raw' => $data,
];
}
}
+26
View File
@@ -8,4 +8,30 @@ return [
'meet_app_url' => env('LADILL_MEET_APP_URL', 'https://meet.ladill.com'),
'pro' => [
'enabled' => filter_var(env('EVENTS_PRO_ENABLED', true), FILTER_VALIDATE_BOOLEAN),
'price_minor' => (int) env('EVENTS_PRO_PRICE_MINOR', 4900),
'currency' => env('EVENTS_PRO_CURRENCY', 'GHS'),
'period_days' => (int) env('EVENTS_PRO_PERIOD_DAYS', 30),
'grace_days' => (int) env('EVENTS_PRO_GRACE_DAYS', 3),
],
'plans' => [
'pro' => ['price_minor' => (int) env('EVENTS_PRO_PRICE_MINOR', 4900)],
'enterprise' => ['price_minor' => (int) env('EVENTS_ENTERPRISE_PRICE_MINOR', 14900)],
],
'prepaid_months' => [6, 12, 24],
'free' => [
'max_live_events' => (int) env('EVENTS_FREE_MAX_LIVE_EVENTS', 2),
'max_tickets_per_month' => (int) env('EVENTS_FREE_MAX_TICKETS_PER_MONTH', 100),
],
'upgrade_banner' => [
'title' => 'Unlock Events Pro or Business',
'description' => 'Unlimited events & tickets, with your own payment gateway — from GHS 49/mo.',
'route' => 'events.pro.index',
],
];
-3
View File
@@ -18,10 +18,7 @@ $root = config('app.platform_domain', 'ladill.com');
return [
'apps' => [
['name' => 'Merchant', 'url' => 'https://merchant.'.$root.'/sso/connect?redirect='.urlencode('https://merchant.'.$root.'/dashboard'), 'icon' => 'merchant.svg'],
['name' => 'POS', 'url' => 'https://pos.'.$root.'/sso/connect?redirect='.urlencode('https://pos.'.$root.'/dashboard'), 'icon' => 'pos.svg'],
['name' => 'Mini', 'url' => 'https://mini.'.$root.'/sso/connect?redirect='.urlencode('https://mini.'.$root.'/dashboard'), 'icon' => 'mini.svg'],
['name' => 'Give', 'url' => 'https://give.'.$root.'/sso/connect?redirect='.urlencode('https://give.'.$root.'/dashboard'), 'icon' => 'give.svg'],
['name' => 'Woo Manager', 'url' => 'https://woo.'.$root.'/sso/connect?redirect='.urlencode('https://woo.'.$root.'/dashboard'), 'icon' => 'woomanager.svg'],
['name' => 'Transfer', 'url' => 'https://transfer.'.$root.'/sso/connect?redirect='.urlencode('https://transfer.'.$root.'/dashboard'), 'icon' => 'transfer.svg'],
['name' => 'Accounting', 'url' => 'https://accounting.'.$root.'/sso/connect?redirect='.urlencode('https://accounting.'.$root.'/dashboard'), 'icon' => 'accounting.svg'],
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('payment_gateway_settings', function (Blueprint $table) {
$table->id();
$table->string('owner_ref', 64)->unique();
$table->string('provider', 32); // paystack|flutterwave|hubtel
$table->text('public_key')->nullable();
$table->text('secret_key')->nullable();
$table->text('webhook_secret')->nullable();
$table->boolean('is_active')->default(true);
$table->json('metadata')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('payment_gateway_settings');
}
};
@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('events_pro_subscriptions', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->unique()->constrained()->cascadeOnDelete();
$table->string('status', 16)->default('active');
$table->string('plan', 16)->default('pro');
$table->unsignedInteger('price_minor');
$table->char('currency', 3)->default('GHS');
$table->boolean('auto_renew')->default(true);
$table->timestamp('started_at')->nullable();
$table->timestamp('current_period_end')->nullable();
$table->timestamp('last_charged_at')->nullable();
$table->timestamp('canceled_at')->nullable();
$table->string('last_reference')->nullable();
$table->text('last_error')->nullable();
$table->timestamps();
$table->index(['status', 'current_period_end']);
});
}
public function down(): void
{
Schema::dropIfExists('events_pro_subscriptions');
}
};
@@ -0,0 +1,23 @@
@props([
'currency' => 'GHS',
'monthlyMinor' => 0,
'prepaidMinor' => null,
'monthlyDisplay' => null,
'monthlySuffix' => '/mo',
'dark' => false,
])
@php
$prepaidBase = (int) ($prepaidMinor ?? $monthlyMinor);
$monthlyFormatted = $monthlyDisplay ?? number_format((int) $monthlyMinor / 100, 0);
$muted = $dark ? 'text-slate-300' : 'text-slate-500';
$text = $dark ? 'text-white' : 'text-slate-900';
@endphp
<p {{ $attributes->merge(['class' => "mt-2 text-3xl font-bold {$text}"]) }}>
<span x-show="billing === 'monthly'">{{ $currency }} {{ $monthlyFormatted }}<span class="text-base font-medium {{ $muted }}">{{ $monthlySuffix }}</span></span>
<span x-show="billing !== 'monthly'" x-cloak>
<span x-text="'{{ $currency }} ' + ({{ $prepaidBase }} * billing / 100).toLocaleString()"></span>
<span class="block text-base font-medium {{ $muted }}" x-text="'for ' + billing + ' months'"></span>
</span>
</p>
@@ -2,6 +2,7 @@
<x-slot name="title">Overview</x-slot>
@php $fmt = fn ($m) => 'GHS '.number_format($m / 100, 2); @endphp
<div class="space-y-6">
@include('partials.upgrade-banner')
<div class="flex items-center justify-between gap-3">
<div class="min-w-0 flex-1">
<h1 class="truncate text-lg font-semibold text-slate-900 lg:text-xl">Events</h1>
+2 -2
View File
@@ -16,7 +16,7 @@
<div>
<h1 class="text-xl font-semibold text-slate-900">Payments & Payouts</h1>
<p class="mt-1 text-sm text-slate-500">Ticket and contribution revenue settles into your Ladill wallet (5.5% tickets / 3.5% contributions).</p>
<p class="mt-1 text-sm text-slate-500">Paid tickets and contributions settle directly to your connected gateway (0% Ladill fee). This wallet is for app subscriptions and older balances only.</p>
</div>
<div class="grid gap-4 sm:grid-cols-2">
@@ -97,7 +97,7 @@
<div class="rounded-2xl border border-slate-200 bg-white">
<div class="border-b border-slate-100 px-5 py-4">
<h2 class="text-sm font-semibold text-slate-900">Event payments</h2>
<p class="mt-0.5 text-xs text-slate-500">Recent ticket sales and contributions collected through Ladill Pay.</p>
<p class="mt-0.5 text-xs text-slate-500">Legacy wallet credits from before BYO gateway settlement. New ticket revenue does not land here.</p>
</div>
@if($transactions->isEmpty())
+100
View File
@@ -0,0 +1,100 @@
<x-app-layout title="Plans" heading="Events plans">
@php
$proPrice = number_format($proPriceMinor / 100, 0);
$enterprisePrice = number_format($enterprisePriceMinor / 100, 0);
$live = $subscription && $subscription->entitled();
@endphp
<div class="mx-auto max-w-5xl space-y-6" x-data="{ billing: 'monthly' }">
@if (session('success'))
<div class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">{{ session('success') }}</div>
@endif
@if (session('error'))
<div class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">{{ session('error') }}</div>
@endif
@if (session('upsell'))
<div class="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">{{ session('upsell') }}</div>
@endif
<div class="rounded-2xl border border-slate-200 bg-white px-6 py-5">
<h1 class="text-2xl font-bold text-slate-900">Choose your Events plan</h1>
<p class="mt-1 text-sm text-slate-600">Pay monthly from your Ladill wallet, or prepay 6/12/24 months via Paystack.</p>
@if ($gatingActive && ! $live)
<div class="mt-4 flex flex-wrap gap-2">
<button type="button" @click="billing = 'monthly'" :class="billing === 'monthly' ? 'bg-indigo-600 text-white' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'" class="rounded-lg px-3 py-1.5 text-sm font-medium transition">Monthly · wallet</button>
@foreach ($prepaidMonths as $months)
<button type="button" @click="billing = '{{ $months }}'" :class="billing === '{{ $months }}' ? 'bg-indigo-600 text-white' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'" class="rounded-lg px-3 py-1.5 text-sm font-medium transition">{{ $months }} months · Paystack</button>
@endforeach
</div>
@endif
</div>
@if ($live)
<div class="rounded-2xl border border-slate-200 bg-white px-6 py-5">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="text-sm text-slate-600">
<strong>{{ $isEnterprise ? 'Business' : 'Pro' }}</strong> active until <strong>{{ $subscription->current_period_end->format('d M Y') }}</strong>
</div>
@if ($subscription->auto_renew)
<form method="post" action="{{ route('events.pro.cancel') }}" onsubmit="return confirm('Turn off auto-renew?');">@csrf<button class="rounded-xl border border-slate-200 px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-50">Cancel auto-renew</button></form>
@elseif ($subscription->status === 'canceled')
<form method="post" action="{{ route($isEnterprise ? 'events.pro.subscribe-enterprise' : 'events.pro.subscribe') }}">@csrf<button class="btn-primary">Resume subscription</button></form>
@endif
</div>
</div>
@endif
<div class="grid gap-5 lg:grid-cols-3">
<div class="flex flex-col rounded-2xl border border-slate-200 bg-white p-6 {{ $planKey === 'free' ? 'ring-2 ring-indigo-500' : '' }}">
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Free</p>
<p class="mt-2 text-3xl font-bold text-slate-900">GHS 0</p>
<ul class="mt-5 flex-1 space-y-2 text-sm text-slate-700">
<li>2 live events</li>
<li>100 tickets / month</li>
<li>BYO Paystack / Flutterwave / Hubtel</li>
</ul>
@if ($planKey === 'free')<p class="mt-6 text-sm font-medium text-indigo-700">Current plan</p>@endif
</div>
<div class="flex flex-col rounded-2xl border border-slate-200 bg-white p-6 {{ $planKey === 'pro' ? 'ring-2 ring-indigo-500' : '' }}">
<p class="text-xs font-semibold uppercase tracking-wide text-indigo-600">Pro</p>
<x-plan-tier-price :currency="$currency" :monthly-minor="$proPriceMinor" />
<ul class="mt-5 flex-1 space-y-2 text-sm text-slate-700">
<li>Unlimited events</li>
<li>Unlimited tickets</li>
<li>Priority support</li>
</ul>
<div class="mt-6">
@if ($planKey === 'pro')
<p class="text-sm text-slate-600">Current plan</p>
@elseif ($gatingActive && ! $hasPaidPlan)
<form x-show="billing === 'monthly'" method="post" action="{{ route('events.pro.subscribe') }}">@csrf<button class="btn-primary w-full">Upgrade monthly wallet</button></form>
@foreach ($prepaidMonths as $months)
<form x-show="billing == {{ $months }}" method="post" action="{{ route('events.pro.subscribe-prepaid') }}">@csrf<input type="hidden" name="plan" value="pro"><input type="hidden" name="months" value="{{ $months }}"><button type="submit" class="btn-primary w-full">Pay {{ $months }} months via Paystack</button></form>
@endforeach
@endif
</div>
</div>
<div class="flex flex-col rounded-2xl border border-slate-200 bg-gradient-to-b from-slate-900 to-slate-800 p-6 text-white {{ $planKey === 'enterprise' ? 'ring-2 ring-amber-400' : '' }}">
<p class="text-xs font-semibold uppercase tracking-wide text-amber-300">Business</p>
<x-plan-tier-price :currency="$currency" :monthly-minor="$enterprisePriceMinor" dark />
<ul class="mt-5 flex-1 space-y-2 text-sm text-slate-200">
<li>Everything in Pro</li>
<li>Team seats</li>
<li>Advanced attendee tools</li>
</ul>
<div class="mt-6">
@if ($planKey === 'enterprise')
<p class="text-sm text-slate-200">Current plan</p>
@elseif ($gatingActive && ! $hasPaidPlan)
<form x-show="billing === 'monthly'" method="post" action="{{ route('events.pro.subscribe-enterprise') }}">@csrf<button class="w-full rounded-xl bg-amber-400 px-4 py-2.5 text-sm font-semibold text-slate-900 hover:bg-amber-300">Upgrade monthly wallet</button></form>
@foreach ($prepaidMonths as $months)
<form x-show="billing == {{ $months }}" method="post" action="{{ route('events.pro.subscribe-prepaid') }}">@csrf<input type="hidden" name="plan" value="enterprise"><input type="hidden" name="months" value="{{ $months }}"><button type="submit" class="w-full rounded-xl bg-amber-400 px-4 py-2.5 text-sm font-semibold text-slate-900 hover:bg-amber-300">Pay {{ $months }} months via Paystack</button></form>
@endforeach
@endif
</div>
</div>
</div>
</div>
</x-app-layout>
@@ -32,6 +32,11 @@
</nav>
<div class="shrink-0 border-t border-slate-100 px-3 py-3">
@php $proActive = request()->routeIs('events.pro.*'); @endphp
<a href="{{ route('events.pro.index') }}" class="group mt-0.5 flex items-center gap-3 rounded-lg px-3 py-2 text-[13px] transition {{ $proActive ? 'bg-indigo-50 text-indigo-700 font-semibold' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900' }}">
<svg class="h-[18px] w-[18px] shrink-0 {{ $proActive ? 'text-indigo-600' : 'text-slate-400' }}" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904 9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09Z"/></svg>
<span>Plans</span>
</a>
<a href="{{ route('account.settings') }}"
class="group flex items-center gap-3 rounded-lg px-3 py-2 text-[13px] transition {{ request()->routeIs('account.settings') ? 'bg-indigo-50 text-indigo-700 font-semibold' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900' }}">
<svg class="h-[18px] w-[18px] shrink-0 {{ request()->routeIs('account.settings') ? 'text-indigo-600' : 'text-slate-400' }}" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
@@ -0,0 +1,10 @@
@php $banner = config('events.upgrade_banner'); @endphp
@if (empty($hasPaidPlan) && ! empty($banner))
<div class="flex flex-col gap-3 rounded-2xl border border-indigo-200 bg-gradient-to-r from-indigo-50 to-emerald-50 px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<p class="font-semibold text-slate-900">{{ $banner['title'] }}</p>
<p class="mt-0.5 text-sm text-slate-600">{{ $banner['description'] }}</p>
</div>
<a href="{{ route($banner['route']) }}" class="btn-primary shrink-0">View plans</a>
</div>
@endif
@@ -269,6 +269,46 @@
</div>
</details>
<div class="rounded-2xl border border-slate-200 bg-white p-5 space-y-4">
<div>
<h2 class="text-base font-semibold text-slate-900">Payment gateway</h2>
<p class="mt-1 text-sm text-slate-500">Connect Paystack, Flutterwave, or Hubtel. Ticket and contribution payments go 100% to you 0% Ladill platform fee.</p>
</div>
<div>
<label class="text-sm font-medium text-slate-700">Provider</label>
<select name="gateway_provider" class="mt-1 w-full rounded-lg border-slate-200">
<option value="">Select a provider</option>
<option value="paystack" @selected(old('gateway_provider', $gateway?->provider) === 'paystack')>Paystack</option>
<option value="flutterwave" @selected(old('gateway_provider', $gateway?->provider) === 'flutterwave')>Flutterwave</option>
<option value="hubtel" @selected(old('gateway_provider', $gateway?->provider) === 'hubtel')>Hubtel</option>
</select>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div>
<label class="text-sm font-medium text-slate-700">Public key / Merchant account</label>
<input type="text" name="gateway_public_key" value="{{ old('gateway_public_key') }}" placeholder="{{ $gateway?->public_key ? '•••• saved — leave blank to keep' : 'pk_live_…' }}" class="mt-1 w-full rounded-lg border-slate-200">
</div>
<div>
<label class="text-sm font-medium text-slate-700">Secret / API key</label>
<input type="password" name="gateway_secret_key" value="" placeholder="{{ $gateway?->secret_key ? '•••• saved — leave blank to keep' : 'sk_live_…' }}" class="mt-1 w-full rounded-lg border-slate-200" autocomplete="new-password">
</div>
</div>
<div>
<label class="text-sm font-medium text-slate-700">Webhook secret (optional)</label>
<input type="password" name="gateway_webhook_secret" value="" placeholder="{{ $gateway?->webhook_secret ? '•••• saved — leave blank to keep' : 'Optional' }}" class="mt-1 w-full rounded-lg border-slate-200" autocomplete="new-password">
</div>
<label class="inline-flex items-center gap-2 text-sm text-slate-700">
<input type="hidden" name="gateway_is_active" value="0">
<input type="checkbox" name="gateway_is_active" value="1" @checked(old('gateway_is_active', $gateway?->is_active ?? true)) class="rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
Gateway enabled for paid tickets and contributions
</label>
@if ($gateway?->isConfigured())
<p class="rounded-lg bg-emerald-50 px-3 py-2 text-sm text-emerald-800">Gateway connected ({{ ucfirst($gateway->provider) }}).</p>
@else
<p class="rounded-lg bg-amber-50 px-3 py-2 text-sm text-amber-800">Paid checkouts stay disabled until a gateway is connected.</p>
@endif
</div>
<button type="submit" class="btn-primary">
Save settings
</button>
+2
View File
@@ -15,3 +15,5 @@ Schedule::command('hosting:process-expired-accounts')->daily()->withoutOverlappi
Schedule::command('hosting:notify-expiring')->daily()->withoutOverlapping();
Schedule::command('hosting:retry-pending-fulfillment')->everyFifteenMinutes()->withoutOverlapping();
Schedule::command('ssl:renew')->daily()->withoutOverlapping();
Schedule::command('events:pro-renew')->dailyAt('02:55')->withoutOverlapping();
+8
View File
@@ -5,6 +5,7 @@ use App\Http\Controllers\WalletBalanceController;
use App\Http\Controllers\Events\AttendeeController;
use App\Http\Controllers\Events\OverviewController;
use App\Http\Controllers\Events\PayoutsController;
use App\Http\Controllers\Events\ProController;
use App\Http\Controllers\Events\ProgrammeController;
use App\Http\Controllers\Events\SpeakerController;
use App\Http\Controllers\NotificationController;
@@ -99,6 +100,13 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
Route::put('/events/{event}/speakers', [SpeakerController::class, 'update'])->name('speakers.update');
Route::post('/events/{event}/speakers/invite', [SpeakerController::class, 'sendInvite'])->name('speakers.invite');
Route::get('/pro', [ProController::class, 'index'])->name('events.pro.index');
Route::post('/pro/subscribe', [ProController::class, 'subscribe'])->name('events.pro.subscribe');
Route::post('/pro/subscribe-enterprise', [ProController::class, 'subscribeEnterprise'])->name('events.pro.subscribe-enterprise');
Route::post('/pro/subscribe-prepaid', [ProController::class, 'subscribePrepaid'])->name('events.pro.subscribe-prepaid');
Route::get('/pro/paystack/callback', [ProController::class, 'paystackCallback'])->name('events.pro.paystack.callback');
Route::post('/pro/cancel', [ProController::class, 'cancel'])->name('events.pro.cancel');
Route::get('/payouts', [PayoutsController::class, 'index'])->name('events.payouts');
Route::get('/payouts/banks', [PayoutsController::class, 'banks'])->name('events.payouts.banks');
Route::put('/payouts/payout-account', [PayoutsController::class, 'updatePayoutAccount'])->name('events.payouts.payout-account');
+78
View File
@@ -0,0 +1,78 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\Events\ProSubscription;
use App\Models\User;
use App\Services\Events\SubscriptionService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class EventsProTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->withoutMiddleware(EnsurePlatformSession::class);
config([
'billing.api_url' => 'https://ladill.com/api/billing',
'billing.api_key' => 'events-billing-key',
'events.pro.enabled' => true,
'events.pro.price_minor' => 4900,
'events.plans.pro.price_minor' => 4900,
'events.plans.enterprise.price_minor' => 14900,
'events.pro.period_days' => 30,
'events.pro.grace_days' => 3,
'events.free.max_live_events' => 2,
'events.free.max_tickets_per_month' => 100,
]);
}
private function user(): User
{
return User::factory()->create(['public_id' => 'usr_'.uniqid()]);
}
public function test_subscribe_charges_wallet_and_activates(): void
{
Http::fake(['*/debit' => Http::response(['id' => 1], 201)]);
$user = $this->user();
$svc = app(SubscriptionService::class);
[$ok] = $svc->subscribe($user);
$this->assertTrue($ok);
$this->assertTrue($svc->isPro($user));
Http::assertSent(fn ($r) => str_ends_with(parse_url($r->url(), PHP_URL_PATH) ?: '', '/debit')
&& $r['amount_minor'] === 4900
&& $r['service'] === 'events');
}
public function test_subscribe_enterprise_charges_wallet_and_activates(): void
{
Http::fake(['*/debit' => Http::response(['id' => 1], 201)]);
$user = $this->user();
$svc = app(SubscriptionService::class);
[$ok] = $svc->subscribeEnterprise($user);
$this->assertTrue($ok);
$this->assertTrue($svc->isEnterprise($user));
$sub = ProSubscription::where('user_id', $user->id)->first();
$this->assertSame('enterprise', $sub->plan);
$this->assertSame(14900, $sub->price_minor);
}
public function test_free_user_is_gated_to_event_limit(): void
{
$user = $this->user();
$svc = app(SubscriptionService::class);
$this->assertTrue($svc->canCreateEvent($user));
$this->assertSame(0, $svc->liveEventCount($user));
}
}