Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 79
0.00% covered (danger)
0.00%
0 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
PlanLimitGuard
0.00% covered (danger)
0.00%
0 / 79
0.00% covered (danger)
0.00%
0 / 8
992
0.00% covered (danger)
0.00%
0 / 1
 assertCanAddUser
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 assertCanAddProperty
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 assertFeatureEnabled
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 assertCanSendWhatsAppMessage
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
20
 hasFeature
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 resolveActivePlan
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
20
 refreshExpiredTrial
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
30
 subscriptionAllowsUsage
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2
3declare(strict_types=1);
4
5namespace ImovelZapAi\Organizations\Application\Services;
6
7use App\Models\User;
8use ImovelZapAi\Conversations\Domain\Enums\MessageDirection;
9use ImovelZapAi\Conversations\Infrastructure\Persistence\Eloquent\MessageModel;
10use ImovelZapAi\Organizations\Domain\Enums\SubscriptionStatus;
11use ImovelZapAi\Organizations\Domain\Exceptions\PlanLimitExceededException;
12use ImovelZapAi\Organizations\Infrastructure\Persistence\Eloquent\OrganizationSubscriptionModel;
13use ImovelZapAi\Organizations\Infrastructure\Persistence\Eloquent\SubscriptionPlanModel;
14use ImovelZapAi\Properties\Infrastructure\Persistence\Eloquent\PropertyModel;
15
16final class PlanLimitGuard
17{
18    public function assertCanAddUser(string $tenantId): void
19    {
20        $resolved = $this->resolveActivePlan($tenantId);
21        if ($resolved === null) {
22            return;
23        }
24
25        [, $plan] = $resolved;
26
27        if ($plan->isUnlimitedUsers()) {
28            return;
29        }
30
31        $count = User::query()->where('tenant_id', $tenantId)->count();
32
33        if ($count >= $plan->max_users) {
34            throw PlanLimitExceededException::users($plan->max_users);
35        }
36    }
37
38    public function assertCanAddProperty(string $tenantId): void
39    {
40        $resolved = $this->resolveActivePlan($tenantId);
41        if ($resolved === null) {
42            return;
43        }
44
45        [, $plan] = $resolved;
46
47        if ($plan->isUnlimitedProperties()) {
48            return;
49        }
50
51        $count = PropertyModel::query()->withoutGlobalScopes()->where('tenant_id', $tenantId)->count();
52
53        if ($count >= $plan->max_properties) {
54            throw PlanLimitExceededException::properties($plan->max_properties);
55        }
56    }
57
58    public function assertFeatureEnabled(string $tenantId, string $feature): void
59    {
60        $resolved = $this->resolveActivePlan($tenantId);
61        if ($resolved === null) {
62            return;
63        }
64
65        [, $plan] = $resolved;
66
67        if (! $plan->hasFeature($feature)) {
68            throw PlanLimitExceededException::feature($feature);
69        }
70    }
71
72    public function assertCanSendWhatsAppMessage(string $tenantId): void
73    {
74        $resolved = $this->resolveActivePlan($tenantId);
75        if ($resolved === null) {
76            return;
77        }
78
79        [, $plan] = $resolved;
80
81        $limit = (int) $plan->max_whatsapp_messages_month;
82        if ($limit === 0) {
83            return;
84        }
85
86        $count = MessageModel::query()
87            ->withoutGlobalScopes()
88            ->where('tenant_id', $tenantId)
89            ->where('direction', MessageDirection::Outbound)
90            ->where('created_at', '>=', now()->startOfMonth())
91            ->count();
92
93        if ($count >= $limit) {
94            throw PlanLimitExceededException::whatsappMessages($limit);
95        }
96    }
97
98    public function hasFeature(string $tenantId, string $feature): bool
99    {
100        try {
101            $this->assertFeatureEnabled($tenantId, $feature);
102
103            return true;
104        } catch (PlanLimitExceededException) {
105            return false;
106        }
107    }
108
109    /**
110     * @return array{0: OrganizationSubscriptionModel, 1: SubscriptionPlanModel}|null
111     */
112    private function resolveActivePlan(string $tenantId): ?array
113    {
114        $subscription = OrganizationSubscriptionModel::query()
115            ->with('plan')
116            ->where('tenant_id', $tenantId)
117            ->first();
118
119        // Sem assinatura (testes legados / tenant sem bootstrap): não bloqueia.
120        if ($subscription === null) {
121            return null;
122        }
123
124        $this->refreshExpiredTrial($subscription);
125        $subscription->refresh();
126
127        if (! $this->subscriptionAllowsUsage($subscription)) {
128            throw PlanLimitExceededException::subscriptionInactive();
129        }
130
131        $plan = $subscription->plan;
132
133        if (! $plan instanceof SubscriptionPlanModel) {
134            throw PlanLimitExceededException::subscriptionInactive();
135        }
136
137        return [$subscription, $plan];
138    }
139
140    private function refreshExpiredTrial(OrganizationSubscriptionModel $subscription): void
141    {
142        if ($subscription->status !== SubscriptionStatus::Trialing) {
143            return;
144        }
145
146        if ($subscription->trial_ends_at === null || $subscription->trial_ends_at->isFuture()) {
147            return;
148        }
149
150        if (filled($subscription->mp_preapproval_id)) {
151            $subscription->forceFill([
152                'status' => SubscriptionStatus::PastDue,
153                'grace_ends_at' => now()->addDays((int) config('billing.grace_days', 3)),
154            ])->save();
155
156            return;
157        }
158
159        $subscription->forceFill([
160            'status' => SubscriptionStatus::Canceled,
161            'canceled_at' => now(),
162        ])->save();
163    }
164
165    private function subscriptionAllowsUsage(OrganizationSubscriptionModel $subscription): bool
166    {
167        $status = $subscription->status;
168
169        if (! $status instanceof SubscriptionStatus) {
170            return false;
171        }
172
173        if (in_array($status, [SubscriptionStatus::Trialing, SubscriptionStatus::Active], true)) {
174            return true;
175        }
176
177        // past_due: permite uso até o fim do grace period
178        if ($status === SubscriptionStatus::PastDue) {
179            return $subscription->grace_ends_at !== null && $subscription->grace_ends_at->isFuture();
180        }
181
182        return false;
183    }
184}