Deploy Ladill POS / deploy (push) Successful in 2m15s
Enforce plan location caps in SubscriptionService, surface upgrade copy on plans and branch settings, and cover the limits in tests.
328 lines
10 KiB
PHP
328 lines
10 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Pos;
|
|
|
|
use App\Models\Pos\ProSubscription;
|
|
use App\Models\PosLocation;
|
|
use App\Models\PosProduct;
|
|
use App\Models\User;
|
|
use App\Services\Crm\CrmClient;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Str;
|
|
|
|
class SubscriptionService
|
|
{
|
|
public function __construct(private readonly BillingClient $billing) {}
|
|
|
|
public function gatingActive(): bool
|
|
{
|
|
return (bool) config('pos.pro.enabled', true) && $this->billing->configured();
|
|
}
|
|
|
|
public function priceMinor(): int
|
|
{
|
|
return (int) config('pos.plans.pro.price_minor', config('pos.pro.price_minor', 7900));
|
|
}
|
|
|
|
public function enterprisePriceMinor(): int
|
|
{
|
|
return (int) config('pos.plans.enterprise.price_minor', 24900);
|
|
}
|
|
|
|
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 productCount(string $ownerRef, bool $restaurant): int
|
|
{
|
|
if ($restaurant) {
|
|
return PosProduct::owned($ownerRef)->count();
|
|
}
|
|
|
|
try {
|
|
// CRM products only (not services). Laravel paginator exposes total at the root.
|
|
$response = CrmClient::for($ownerRef)->products(['type' => 'product', 'per_page' => 1]);
|
|
|
|
return (int) ($response['total'] ?? $response['meta']['total'] ?? count((array) ($response['data'] ?? [])));
|
|
} catch (\Throwable) {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
public function canAddProduct(User $user, string $ownerRef, bool $restaurant): bool
|
|
{
|
|
if ($this->isPro($user)) {
|
|
return true;
|
|
}
|
|
|
|
$limit = (int) config('pos.free.max_products', 50);
|
|
|
|
return $this->productCount($ownerRef, $restaurant) < $limit;
|
|
}
|
|
|
|
public function canUseRestaurantMode(User $user): bool
|
|
{
|
|
return $this->isPro($user);
|
|
}
|
|
|
|
public function canImportCatalog(User $user): bool
|
|
{
|
|
return $this->isPro($user);
|
|
}
|
|
|
|
public function locationCount(string $ownerRef): int
|
|
{
|
|
return PosLocation::owned($ownerRef)->count();
|
|
}
|
|
|
|
/**
|
|
* Max branches for the account's plan. null = unlimited.
|
|
*/
|
|
public function maxLocations(User $user): ?int
|
|
{
|
|
if (! $this->gatingActive()) {
|
|
return null;
|
|
}
|
|
|
|
$key = $this->planKey($user);
|
|
|
|
if ($key === 'free') {
|
|
return (int) config('pos.free.max_locations', 1);
|
|
}
|
|
|
|
if ($key === ProSubscription::PLAN_ENTERPRISE) {
|
|
$max = config('pos.plans.enterprise.max_locations');
|
|
|
|
return $max === null ? null : (int) $max;
|
|
}
|
|
|
|
$max = config('pos.plans.pro.max_locations', 3);
|
|
|
|
return $max === null ? null : (int) $max;
|
|
}
|
|
|
|
public function canUseMultiLocation(User $user): bool
|
|
{
|
|
return $this->isPro($user);
|
|
}
|
|
|
|
public function canAddLocation(User $user, string $ownerRef): bool
|
|
{
|
|
$max = $this->maxLocations($user);
|
|
|
|
if ($max === null) {
|
|
return true;
|
|
}
|
|
|
|
return $this->locationCount($ownerRef) < $max;
|
|
}
|
|
|
|
public function locationLimitMessage(User $user): string
|
|
{
|
|
$key = $this->planKey($user);
|
|
$max = $this->maxLocations($user);
|
|
|
|
if ($key === 'free') {
|
|
return 'Your Free plan allows 1 branch. Upgrade to Pro for up to 3 branches, or Business for unlimited.';
|
|
}
|
|
|
|
if ($key === ProSubscription::PLAN_PRO && $max !== null) {
|
|
return "Your Pro plan allows up to {$max} branches. Upgrade to Business for unlimited locations.";
|
|
}
|
|
|
|
return 'Branch limit reached for your current plan.';
|
|
}
|
|
|
|
public function canManageTeam(User $user): bool
|
|
{
|
|
return $this->isPro($user);
|
|
}
|
|
|
|
/** @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 POS Pro — monthly subscription', 'Welcome to Ladill POS 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 POS Business — monthly subscription',
|
|
'Welcome to Ladill POS 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('pos.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 = 'pos-'.$sub->plan.'-'.$user->id.'-'.now()->format('YmdHis').'-'.Str::random(6);
|
|
$result = $this->billing->charge($user, $sub->price_minor, $reference, "Ladill POS {$planLabel} — renewal");
|
|
|
|
if ($result['ok']) {
|
|
$sub->forceFill([
|
|
'current_period_end' => Carbon::now()->addDays((int) config('pos.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('pos.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 = 'pos-'.$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('pos.pro.period_days', 30));
|
|
ProSubscription::updateOrCreate(
|
|
['user_id' => $user->id],
|
|
[
|
|
'plan' => $plan,
|
|
'status' => ProSubscription::STATUS_ACTIVE,
|
|
'price_minor' => $price,
|
|
'currency' => (string) config('pos.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.'];
|
|
}
|
|
}
|