Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.39% covered (warning)
86.39%
127 / 147
25.00% covered (danger)
25.00%
1 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
NuvemshopCouponService
86.39% covered (warning)
86.39%
127 / 147
25.00% covered (danger)
25.00%
1 / 4
22.11
0.00% covered (danger)
0.00%
0 / 1
 createForInfluencer
96.00% covered (success)
96.00%
48 / 50
0.00% covered (danger)
0.00%
0 / 1
5
 updateCoupon
81.82% covered (warning)
81.82%
45 / 55
0.00% covered (danger)
0.00%
0 / 1
10.60
 deleteCoupon
71.43% covered (warning)
71.43%
20 / 28
0.00% covered (danger)
0.00%
0 / 1
5.58
 buildPayload
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace App\Services\Nuvemshop;
4
5use App\Enums\CouponStatus;
6use App\Models\Influencer;
7use App\Models\InfluencerCoupon;
8use App\Models\StoreIntegration;
9use Illuminate\Support\Facades\DB;
10use RuntimeException;
11use Throwable;
12
13class NuvemshopCouponService
14{
15    public function createForInfluencer(
16        Influencer $influencer,
17        StoreIntegration $integration,
18        string $code,
19        float $discountPercentage = 10.0,
20        array $options = []
21    ): InfluencerCoupon {
22        $normalized = InfluencerCoupon::normalize($code);
23
24        return DB::transaction(function () use ($influencer, $integration, $code, $discountPercentage, $options, $normalized) {
25            $duplicated = InfluencerCoupon::query()
26                ->where('store_id', $influencer->store_id)
27                ->where('coupon_code_normalized', $normalized)
28                ->exists();
29
30            if ($duplicated) {
31                throw new RuntimeException('Já existe um cupom com este código nesta loja.');
32            }
33
34            $client = new NuvemshopClient($integration);
35
36            $client->assertCanCallApi();
37
38            if (! $client->hasScope('write_coupons')) {
39                throw new RuntimeException('A integração Nuvemshop não possui o scope write_coupons.');
40            }
41
42            $payload = $this->buildPayload($code, $discountPercentage, $options);
43
44            try {
45                $response = $client->post(
46                    $integration->external_store_id.'/coupons',
47                    $payload,
48                    allowMock: false
49                );
50            } catch (Throwable $exception) {
51                throw new RuntimeException(
52                    'A Nuvemshop recusou a criação do cupom: '.$exception->getMessage(),
53                    previous: $exception
54                );
55            }
56
57            $externalCouponId = data_get($response, 'id');
58
59            if (blank($externalCouponId)) {
60                throw new RuntimeException('A Nuvemshop não retornou o ID do cupom criado.');
61            }
62
63            InfluencerCoupon::query()
64                ->where('influencer_id', $influencer->id)
65                ->where('status', CouponStatus::Active->value)
66                ->update([
67                    'status' => CouponStatus::Inactive->value,
68                    'ended_at' => now(),
69                ]);
70
71            return InfluencerCoupon::create([
72                'tenant_id' => $influencer->tenant_id,
73                'store_id' => $influencer->store_id,
74                'influencer_id' => $influencer->id,
75                'store_integration_id' => $integration->id,
76                'coupon_code_original' => $code,
77                'coupon_code_normalized' => $normalized,
78                'discount_percentage' => $discountPercentage,
79                'external_coupon_id' => (string) $externalCouponId,
80                'status' => CouponStatus::Active,
81                'started_at' => now(),
82                'metadata' => [
83                    'nuvemshop_response' => $response,
84                    'creation_options' => $options,
85                ],
86            ]);
87        });
88    }
89
90    public function updateCoupon(
91        InfluencerCoupon $coupon,
92        string $code,
93        float $discountPercentage,
94        CouponStatus $status
95    ): InfluencerCoupon {
96        $coupon->loadMissing(['integration', 'influencer']);
97
98        if (! $coupon->integration) {
99            throw new RuntimeException('Cupom sem integração vinculada.');
100        }
101
102        if (blank($coupon->external_coupon_id)) {
103            throw new RuntimeException('Cupom sem ID externo da Nuvemshop.');
104        }
105
106        $normalized = InfluencerCoupon::normalize($code);
107        $codeChanged = $normalized !== $coupon->coupon_code_normalized;
108
109        if ($codeChanged && $coupon->orders()->exists()) {
110            throw new RuntimeException('Este cupom já possui pedidos vinculados. Para manter histórico correto, crie um novo cupom em vez de alterar o código.');
111        }
112
113        $duplicated = InfluencerCoupon::query()
114            ->where('store_id', $coupon->store_id)
115            ->where('coupon_code_normalized', $normalized)
116            ->whereKeyNot($coupon->id)
117            ->exists();
118
119        if ($duplicated) {
120            throw new RuntimeException('Já existe outro cupom com este código nesta loja.');
121        }
122
123        return DB::transaction(function () use ($coupon, $code, $normalized, $discountPercentage, $status) {
124            $client = new NuvemshopClient($coupon->integration);
125
126            $client->assertCanCallApi();
127
128            if (! $client->hasScope('write_coupons')) {
129                throw new RuntimeException('A integração Nuvemshop não possui o scope write_coupons.');
130            }
131
132            $payload = $this->buildPayload($code, $discountPercentage, [
133                'valid' => $status === CouponStatus::Active,
134            ]);
135
136            try {
137                $response = $client->put(
138                    $coupon->integration->external_store_id.'/coupons/'.$coupon->external_coupon_id,
139                    $payload
140                );
141            } catch (Throwable $exception) {
142                throw new RuntimeException(
143                    'A Nuvemshop recusou a atualização do cupom: '.$exception->getMessage(),
144                    previous: $exception
145                );
146            }
147
148            if ($status === CouponStatus::Active) {
149                InfluencerCoupon::query()
150                    ->where('influencer_id', $coupon->influencer_id)
151                    ->where('id', '!=', $coupon->id)
152                    ->where('status', CouponStatus::Active->value)
153                    ->update([
154                        'status' => CouponStatus::Inactive->value,
155                        'ended_at' => now(),
156                    ]);
157            }
158
159            $metadata = $coupon->metadata ?? [];
160            $metadata['last_nuvemshop_update_response'] = $response;
161            $metadata['last_updated_at'] = now()->toISOString();
162
163            $coupon->update([
164                'coupon_code_original' => $code,
165                'coupon_code_normalized' => $normalized,
166                'discount_percentage' => $discountPercentage,
167                'status' => $status,
168                'ended_at' => $status === CouponStatus::Active ? null : now(),
169                'metadata' => $metadata,
170            ]);
171
172            return $coupon->refresh();
173        });
174    }
175
176    public function deleteCoupon(InfluencerCoupon $coupon): InfluencerCoupon
177    {
178        $coupon->loadMissing('integration');
179
180        if (! $coupon->integration) {
181            throw new RuntimeException('Cupom sem integração vinculada.');
182        }
183
184        if (blank($coupon->external_coupon_id)) {
185            throw new RuntimeException('Cupom sem ID externo da Nuvemshop.');
186        }
187
188        return DB::transaction(function () use ($coupon) {
189            $client = new NuvemshopClient($coupon->integration);
190
191            $client->assertCanCallApi();
192
193            if (! $client->hasScope('write_coupons')) {
194                throw new RuntimeException('A integração Nuvemshop não possui o scope write_coupons.');
195            }
196
197            try {
198                $response = $client->delete(
199                    $coupon->integration->external_store_id.'/coupons/'.$coupon->external_coupon_id
200                );
201            } catch (Throwable $exception) {
202                throw new RuntimeException(
203                    'A Nuvemshop recusou a exclusão do cupom: '.$exception->getMessage(),
204                    previous: $exception
205                );
206            }
207
208            $metadata = $coupon->metadata ?? [];
209            $metadata['nuvemshop_delete_response'] = $response;
210            $metadata['deleted_at_nuvemshop'] = now()->toISOString();
211
212            $coupon->update([
213                'status' => CouponStatus::Deleted,
214                'ended_at' => now(),
215                'metadata' => $metadata,
216            ]);
217
218            return $coupon->refresh();
219        });
220    }
221
222    public function buildPayload(string $code, float $discountPercentage, array $options = []): array
223    {
224        return array_filter([
225            'code' => strtoupper(trim($code)),
226            'type' => 'percentage',
227            'value' => number_format($discountPercentage, 2, '.', ''),
228            'valid' => $options['valid'] ?? true,
229            'includes_shipping' => false,
230            'first_consumer_purchase' => false,
231            'combines_with_other_discounts' => true,
232            'only_cheapest_shipping' => false,
233            'start_date' => $options['start_date'] ?? now()->toDateString(),
234            'end_date' => $options['end_date'] ?? null,
235            'max_uses' => $options['max_uses'] ?? null,
236            'min_price' => $options['min_price'] ?? null,
237        ], fn ($value) => $value !== null);
238    }
239}