Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.22% covered (success)
97.22%
70 / 72
33.33% covered (danger)
33.33%
1 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
StartCheckoutAction
97.22% covered (success)
97.22%
70 / 72
33.33% covered (danger)
33.33%
1 / 3
13
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 execute
98.46% covered (success)
98.46%
64 / 65
0.00% covered (danger)
0.00%
0 / 1
10
 syncTenantType
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
1<?php
2
3declare(strict_types=1);
4
5namespace ImovelZapAi\Billing\Application\Actions;
6
7use ImovelZapAi\Billing\Application\Contracts\MercadoPagoClient;
8use ImovelZapAi\Billing\Application\Services\CouponCalculator;
9use ImovelZapAi\Billing\Domain\Enums\BillingCycle;
10use ImovelZapAi\Billing\Domain\Exceptions\BillingException;
11use ImovelZapAi\Billing\Infrastructure\Persistence\Eloquent\BillingCouponRedemptionModel;
12use ImovelZapAi\Organizations\Application\Actions\EnsureOrganizationBootstrapAction;
13use ImovelZapAi\Organizations\Domain\Enums\SubscriptionStatus;
14use ImovelZapAi\Organizations\Infrastructure\Persistence\Eloquent\OrganizationSubscriptionModel;
15use ImovelZapAi\Organizations\Infrastructure\Persistence\Eloquent\SubscriptionPlanModel;
16
17final class StartCheckoutAction
18{
19    public function __construct(
20        private readonly EnsureOrganizationBootstrapAction $bootstrap,
21        private readonly SyncMercadoPagoPlanCatalogAction $syncCatalog,
22        private readonly MercadoPagoClient $client,
23        private readonly CouponCalculator $coupons,
24    ) {}
25
26    /**
27     * @return array{subscription: OrganizationSubscriptionModel, init_point: ?string}
28     */
29    public function execute(
30        string $tenantId,
31        string $planCode,
32        BillingCycle $cycle,
33        string $payerEmail,
34        ?string $couponCode = null,
35    ): array {
36        if (! (bool) config('billing.enabled', true)) {
37            throw BillingException::disabled();
38        }
39
40        $this->bootstrap->execute($tenantId);
41        $this->syncCatalog->execute();
42
43        $plan = SubscriptionPlanModel::query()
44            ->where('code', $planCode)
45            ->where('is_active', true)
46            ->first();
47
48        if ($plan === null) {
49            throw BillingException::planNotFound($planCode);
50        }
51
52        if ((int) $plan->price_monthly_cents <= 0 && (int) $plan->price_yearly_cents <= 0) {
53            throw BillingException::checkoutFailed('Plano Enterprise requer contato comercial.');
54        }
55
56        $mpPlanId = $cycle === BillingCycle::Yearly
57            ? $plan->mp_preapproval_plan_id_yearly
58            : $plan->mp_preapproval_plan_id_monthly;
59
60        if (blank($mpPlanId)) {
61            throw BillingException::checkoutFailed('Plano sem vĂ­nculo Mercado Pago.');
62        }
63
64        $coupon = null;
65        $discountCents = 0;
66        if ($couponCode !== null && trim($couponCode) !== '') {
67            $coupon = $this->coupons->findValid($couponCode);
68            $this->coupons->assertApplicable($coupon, $plan);
69            $discountCents = $this->coupons->discountCents($coupon, $plan, $cycle);
70        }
71
72        $amountCents = $this->coupons->amountAfterDiscount($plan, $cycle, $coupon);
73
74        /** @var OrganizationSubscriptionModel $subscription */
75        $subscription = OrganizationSubscriptionModel::query()->updateOrCreate(
76            ['tenant_id' => $tenantId],
77            [
78                'subscription_plan_id' => $plan->id,
79                'status' => SubscriptionStatus::Trialing,
80                'billing_cycle' => $cycle->value,
81                'mp_payer_email' => $payerEmail,
82                'coupon_code' => $coupon?->code,
83                'trial_ends_at' => now()->addDays((int) config('billing.trial_days', 14)),
84                'current_period_ends_at' => now()->addDays((int) config('billing.trial_days', 14)),
85            ],
86        );
87
88        $this->syncTenantType($tenantId, $plan);
89
90        $preapproval = $this->client->createPreapproval(
91            (string) $mpPlanId,
92            $payerEmail,
93            (string) $subscription->id,
94            (string) config('billing.checkout_back_url'),
95            $amountCents,
96        );
97
98        $subscription->forceFill([
99            'mp_preapproval_id' => $preapproval['id'],
100        ])->save();
101
102        if ($coupon !== null) {
103            BillingCouponRedemptionModel::query()->updateOrCreate(
104                [
105                    'billing_coupon_id' => $coupon->id,
106                    'tenant_id' => $tenantId,
107                ],
108                [
109                    'organization_subscription_id' => $subscription->id,
110                    'discount_cents' => $discountCents,
111                ],
112            );
113            $coupon->forceFill([
114                'redemptions_count' => (int) $coupon->redemptions_count + 1,
115            ])->save();
116        }
117
118        return [
119            'subscription' => $subscription->fresh(['plan']) ?? $subscription,
120            'init_point' => $preapproval['init_point'] ?? null,
121        ];
122    }
123
124    private function syncTenantType(string $tenantId, SubscriptionPlanModel $plan): void
125    {
126        $type = (int) $plan->max_users === 1
127            ? \ImovelZapAi\Tenancy\Domain\Enums\OrganizationType::Solo
128            : \ImovelZapAi\Tenancy\Domain\Enums\OrganizationType::Agency;
129
130        \ImovelZapAi\Tenancy\Infrastructure\Persistence\Eloquent\TenantModel::query()
131            ->whereKey($tenantId)
132            ->update(['type' => $type]);
133    }
134}