Files
isaacclad 25ce2bc9de
Deploy Ladill Events / deploy (push) Successful in 55s
Retire Events subscriptions and owner payment gateways.
Make Ladill Events free for all accounts, remove plan upgrade UI, force Ladill Pay only for tickets and contributions, and reinstate standard platform fees.
2026-07-24 14:15:34 +00:00

306 lines
10 KiB
PHP

<?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\Facades\Schema;
use Illuminate\Support\Str;
class SubscriptionService
{
public function __construct(private readonly BillingClient $billing) {}
public function gatingActive(): bool
{
// Paid Events subscriptions are retired — product is free for all accounts.
return false;
}
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
{
if (! Schema::hasTable('events_pro_subscriptions')) {
return null;
}
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);
}
/** Custom / owner payment gateways are retired — Ladill Pay only. */
public function canUsePaymentGateway(User $user): bool
{
return false;
}
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();
$periodEnd = Carbon::now()->addMonths($months);
$reference = 'events-prepaid-'.$plan.'-'.$user->id.'-'.now()->format('YmdHis');
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' => $periodEnd,
'last_charged_at' => now(),
'canceled_at' => null,
'last_reference' => $reference,
'last_error' => null,
],
);
// Prepaid checkout already writes entitlements on the platform; this is a repair/sync.
$this->syncMessagingEntitlement($user, $plan, ProSubscription::STATUS_ACTIVE, $reference, $periodEnd, 'checkout');
}
/** @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']) {
$periodEnd = Carbon::now()->addDays((int) config('events.pro.period_days', 30));
$sub->forceFill([
'current_period_end' => $periodEnd,
'last_charged_at' => now(),
'last_reference' => $reference,
'last_error' => null,
])->save();
$this->syncMessagingEntitlement($user, $sub->plan, ProSubscription::STATUS_ACTIVE, $reference, $periodEnd);
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();
$this->syncMessagingEntitlement($user, $sub->plan, 'past_due', $reference.'-pastdue', $sub->current_period_end);
} 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,
],
);
$this->syncMessagingEntitlement($user, $plan, ProSubscription::STATUS_ACTIVE, $reference, $periodEnd);
return [true, $successMessage.' Your subscription is active.'];
}
/**
* Map Events plan → platform suite entitlement plan (enterprise = Business tier).
*/
private function syncMessagingEntitlement(
User $user,
string $plan,
string $status,
string $reference,
?Carbon $periodEnd,
string $source = 'wallet_debit',
): void {
$platformPlan = $plan === ProSubscription::PLAN_ENTERPRISE ? 'enterprise' : ($plan === 'free' ? 'free' : 'pro');
$publicId = (string) ($user->public_id ?? '');
if ($publicId === '') {
return;
}
$this->billing->syncSuiteEntitlement($publicId, $platformPlan, $status, $reference, [
'period_starts_at' => now()->toIso8601String(),
'period_ends_at' => $periodEnd?->toIso8601String(),
'source' => $source,
]);
}
}