Files
ladill-events/app/Services/Events/SubscriptionService.php
T
isaacclad 3d5ffa593d
Deploy Ladill Events / deploy (push) Successful in 48s
feat(messaging): suite-channel email/SMS and entitlement sync
Prefer platform suite messaging (channel=suite) for attendee email, fall back
to Bird integrations; sync suite plan entitlements on subscribe/renew.
2026-07-16 11:24:21 +00:00

305 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
{
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
{
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);
}
/** Own payment gateway (Paystack / Flutterwave / Hubtel) requires Pro or Business. */
public function canUsePaymentGateway(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();
$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,
]);
}
}