Skip to content

Commit b7bdddb

Browse files
Add Pagarme integration for "Codando com IA" course
- Implemented payment flow via Pagarme for the live course "Codando com IA" without granting access or sending emails. - Created a new migration to ensure the plan exists in the database. - Added a new request class for validating checkout link creation. - Updated PagarmeController to handle the new checkout link and order status. - Enhanced webhook handling to create users and subscriptions based on order events. - Added tests for the webhook lifecycle to ensure proper functionality. This update lays the groundwork for managing course payments and user subscriptions effectively.
1 parent 77b38f6 commit b7bdddb

7 files changed

Lines changed: 713 additions & 214 deletions

File tree

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
## Escopo
2+
3+
Implementar e ajustar o fluxo de pagamento via Pagarme (Core v5 Orders + Checkout) para o curso ao vivo “Codando com IA”, sem liberar área de acesso ou enviar e-mails por enquanto, garantindo associação/criação de usuário e notificações no Discord.
4+
5+
## Requisitos Confirmados
6+
7+
- **Preço:** R$ 588,00 (58800 centavos), parcelamento em até 12x sem juros.
8+
- **Métodos de pagamento:** cartão de crédito, boleto e pix.
9+
- **Acesso/e-mails:** nenhum acesso adicional nem e-mails de confirmação neste momento.
10+
- **Usuário:** reutilizar usuário existente pelo e-mail ou criar um novo se não existir.
11+
- **Webhooks:** reutilizar o fluxo já existente, com tratamento específico para o produto do curso.
12+
- **URLs e metadados:** `success_url` = `/curso-ao-vivo/codando-com-ia/sucesso`; `product_slug` = `curso-ao-vivo-codando-com-ia-v1`.
13+
14+
## Estado Atual
15+
16+
- **POST `/api/pagarme/codando-com-ia/checkout`** já cria a Order + Checkout, retornando `payment_url`.
17+
- **GET `/api/pagarme/codando-com-ia/orders/{orderId}`** consulta a Order no Core v5, validando `product_slug`.
18+
- **POST `/api/pagarme/notification`** processa webhooks; atualmente o fluxo é focado em Subscription e apenas registra quando não encontra subscription para este produto.
19+
20+
## Plano de Implementação
21+
22+
### 1. Plano (`codando-com-ia-v1`)
23+
24+
- Garantir via seed/migração idempotente a existência do plano com:
25+
- `slug`: `codando-com-ia-v1`
26+
- `name`: `Codando com IA (Ao Vivo) v1`
27+
- `price_in_cents`: `58800`
28+
- `duration_in_months`: `0`
29+
- `details`: JSON opcional
30+
31+
### 2. Checkout (`POST /api/pagarme/codando-com-ia/checkout`)
32+
33+
- Sanitizar telefone (já implementado) e aceitar ausência sem falhar.
34+
- Localizar usuário pelo e-mail; se não existir, criar com nome, e-mail, telefone (se válido) e senha aleatória hasheada; atualizar dados faltantes quando reaproveitar usuários.
35+
- Incluir `customer.metadata.user_id` no payload enviado ao Pagarme.
36+
- Manter preço, métodos aceitos, parcelas sem juros, `success_url` e metadados existentes.
37+
- Após sucesso na criação da Order:
38+
- Criar `Subscription` vinculada ao plano `codando-com-ia-v1` com `status = pending`, `acquisition_type = purchase`, `provider_id = order.id`, `price_paid_in_cents = order.amount` e `payment_method = null` (até atualização via webhook).
39+
- Retornar `checkoutLink`, `pagarmeOrderID`, `amount`, `status` (sem autenticar/logar o usuário nem disparar e-mails).
40+
41+
### 3. Status (`GET /api/pagarme/codando-com-ia/orders/{orderId}`)
42+
43+
- Manter lógica atual de validação do `product_slug` e retorno filtrado dos dados da Order.
44+
45+
### 4. Webhooks (`POST /api/pagarme/notification`)
46+
47+
- Para eventos `order.*` onde `metadata.product_slug === curso-ao-vivo-codando-com-ia-v1` ou Subscription vinculada ao plano `codando-com-ia-v1`:
48+
- `findOrCreate` do `User` (atualizando nome/telefone ausentes).
49+
- Localizar ou criar a `Subscription` pendente vinculada ao plano.
50+
- Atualizar `status` e campos `payment_method`, `boleto_url`, `boleto_barcode` / `qr_code` diretamente (não usar `changeStatus` para evitar `upgradeUserToPro`).
51+
- Enviar mensagem ao Discord no canal `notificacoes-compras` com resumo do evento (comprador, método, valor, `orderId`, status e contexto).
52+
- Não liberar acesso, não enviar e-mails.
53+
- Garantir idempotência atualizando apenas dados necessários.
54+
55+
### 5. Segurança e Resiliência
56+
57+
- Manter uso de Basic Auth para chamadas à API Pagarme.
58+
- Tolerar telefones inválidos/vazios sem lançar exceções.
59+
- Preservar e detalhar logs de erro no checkout.
60+
61+
### 6. Testes Automatizados
62+
63+
- **Checkout:** garantir criação/associação do usuário e subscription pendente, além do retorno do `payment_url`.
64+
- **Status:** confirmar 200 quando o `product_slug` bate e 404 caso contrário.
65+
- **Webhooks:** simular `order.created`, `order.closed`, `order.paid`, `order.canceled/failed` para validar atualizações da subscription, criação/atualização de usuário e envio de notificações ao Discord (mockado), assegurando que não há promoção a PRO.
66+
67+
### 7. Pós-Implementação
68+
69+
- Rodar migration/seed para criar o plano.
70+
- Executar `php artisan test` para validar o backend.
71+
- Certificar que o webhook do Pagarme aponta para `/api/pagarme/notification` e que `PAGARME_API_KEY` correta está configurada no `.env` de cada ambiente.

app/Http/Controllers/PagarmeController.php

Lines changed: 88 additions & 179 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,21 @@
22

33
namespace App\Http\Controllers;
44

5+
use App\Http\Requests\CreateCheckoutLinkV2Request;
56
use App\Http\Resources\SubscriptionResource;
67
use App\Mail\PaymentConfirmed;
78
use App\Models\Coupon;
89
use App\Models\Plan;
10+
use App\Models\Subscription;
11+
use App\Models\User;
912
use Carbon\Carbon;
1013
use Illuminate\Http\Request;
1114
use Illuminate\Support\Facades\Auth;
1215
use Illuminate\Support\Facades\Http;
13-
use Illuminate\Support\Str;
14-
use Mail;
16+
use Illuminate\Support\Facades\Mail;
1517

1618
class PagarmeController extends Controller
1719
{
18-
private const CODANDO_COM_IA_PRICE_IN_CENTS = 58800;
19-
private const CODANDO_COM_IA_PRODUCT_SLUG = 'curso-ao-vivo-codando-com-ia-v1';
20-
2120
public function __construct()
2221
{
2322
$this->middleware('auth:sanctum')->only([
@@ -26,177 +25,9 @@ public function __construct()
2625
]);
2726
}
2827

29-
public function createCodandoComIaCheckout(Request $request)
30-
{
31-
$validated = $request->validate([
32-
'name' => 'required|string|max:255',
33-
'email' => 'required|email',
34-
'phone' => 'required|string|max:30',
35-
'tag' => 'nullable|string|max:255',
36-
]);
37-
38-
$rawPhone = preg_replace('/\D/', '', $validated['phone']);
39-
40-
if (Str::startsWith($rawPhone, '55') && strlen($rawPhone) > 11) {
41-
$rawPhone = substr($rawPhone, 2);
42-
}
43-
44-
$phonesPayload = null;
45-
46-
if (strlen($rawPhone) >= 10) {
47-
$areaCode = substr($rawPhone, 0, 2);
48-
$number = substr($rawPhone, 2);
49-
50-
$phonesPayload = [
51-
'country_code' => '55',
52-
'area_code' => $areaCode,
53-
'number' => $number,
54-
];
55-
}
56-
57-
$customerPayload = [
58-
'name' => $validated['name'],
59-
'email' => $validated['email'],
60-
'type' => 'individual',
61-
'metadata' => [
62-
'product_slug' => self::CODANDO_COM_IA_PRODUCT_SLUG,
63-
'lead_tag' => $validated['tag'] ?? null,
64-
],
65-
];
66-
67-
if ($phonesPayload) {
68-
$customerPayload['phones'] = [
69-
'mobile_phone' => $phonesPayload,
70-
];
71-
}
72-
73-
$installments = collect(range(1, 12))->map(function ($number) {
74-
return [
75-
'number' => $number,
76-
'total' => self::CODANDO_COM_IA_PRICE_IN_CENTS,
77-
];
78-
})->values()->all();
79-
80-
$checkoutPayload = [
81-
'code' => 'codando-com-ia-'.Str::uuid()->toString(),
82-
'customer' => $customerPayload,
83-
'items' => [
84-
[
85-
'id' => 'codando-com-ia',
86-
'amount' => self::CODANDO_COM_IA_PRICE_IN_CENTS,
87-
'description' => 'Curso Codando com IA - Compra Única',
88-
'quantity' => 1,
89-
'code' => self::CODANDO_COM_IA_PRODUCT_SLUG,
90-
],
91-
],
92-
'payments' => [
93-
[
94-
'payment_method' => 'checkout',
95-
'checkout' => [
96-
'customer_editable' => true,
97-
'accepted_payment_methods' => [
98-
'credit_card',
99-
'boleto',
100-
'pix',
101-
],
102-
'success_url' => config('app.frontend_url').'/curso-ao-vivo/codando-com-ia/sucesso',
103-
'pix' => [
104-
'expires_in' => 86400,
105-
],
106-
'boleto' => [
107-
'due_at' => Carbon::now()
108-
->addDays(3)
109-
->toIso8601String(),
110-
],
111-
'credit_card' => [
112-
'installments' => $installments,
113-
'operation_type' => 'auth_and_capture',
114-
],
115-
],
116-
],
117-
],
118-
'metadata' => [
119-
'product_slug' => self::CODANDO_COM_IA_PRODUCT_SLUG,
120-
'lead_phone' => $validated['phone'],
121-
'lead_tag' => $validated['tag'] ?? null,
122-
'lead_email' => $validated['email'],
123-
'lead_name' => $validated['name'],
124-
'lead_phone_digits' => $phonesPayload
125-
? $phonesPayload['area_code'].$phonesPayload['number']
126-
: null,
127-
],
128-
];
129-
130-
$response = Http::withBasicAuth(
131-
config('services.pagarme.api_key'),
132-
''
133-
)->post('https://api.pagar.me/core/v5/orders', $checkoutPayload);
134-
135-
if ($response->failed()) {
136-
$status = $response->status();
137-
$errorPayload = $response->json();
138-
139-
logger()->error('Pagarme order checkout creation failed', [
140-
'status' => $status,
141-
'payload' => $checkoutPayload,
142-
'response' => $errorPayload,
143-
'response_body' => $response->body(),
144-
]);
145-
146-
return response()->json([
147-
'message' => 'Não foi possível iniciar o checkout no momento.',
148-
'details' => $errorPayload,
149-
], $status > 0 ? $status : 400);
150-
}
151-
152-
$order = $response->json();
153-
154-
return [
155-
'checkoutLink' => $order['checkouts'][0]['payment_url'] ?? null,
156-
'pagarmeOrderID' => $order['id'] ?? null,
157-
'amount' => $order['amount'] ?? self::CODANDO_COM_IA_PRICE_IN_CENTS,
158-
'status' => $order['status'] ?? null,
159-
];
160-
}
161-
162-
public function getCodandoComIaOrderStatus(string $orderId)
163-
{
164-
$endpoint = "https://api.pagar.me/core/v5/orders/{$orderId}";
165-
166-
$response = Http::withBasicAuth(
167-
config('services.pagarme.api_key'),
168-
''
169-
)->get($endpoint);
170-
171-
if ($response->failed()) {
172-
$status = $response->status();
173-
174-
return response()->json([
175-
'message' => 'Não foi possível recuperar o status do pedido.',
176-
], $status > 0 ? $status : 400);
177-
}
178-
179-
$order = $response->json();
180-
$productSlug = $order['metadata']['product_slug'] ?? null;
181-
182-
if ($productSlug !== self::CODANDO_COM_IA_PRODUCT_SLUG) {
183-
return response()->json(['message' => 'Pedido não encontrado'], 404);
184-
}
185-
186-
return [
187-
'id' => $order['id'] ?? null,
188-
'status' => $order['status'] ?? null,
189-
'amount' => $order['amount'] ?? null,
190-
'charges' => $order['charges'] ?? [],
191-
'customer' => [
192-
'name' => $order['customer']['name'] ?? null,
193-
'email' => $order['customer']['email'] ?? null,
194-
],
195-
];
196-
}
197-
19828
public function createOrderAndGetCheckoutLink(Request $request)
19929
{
30+
20031
$plan_id = $request->plan_id ?? 1;
20132
$user = Auth::user();
20233
$plan = Plan::find($plan_id);
@@ -209,14 +40,14 @@ public function createOrderAndGetCheckoutLink(Request $request)
20940
$planDetails->user_raised_count * 10 * 100;
21041

21142
$couponCode = $request->coupon;
212-
$coupon = (new Coupon())->getValidCoupon($couponCode, $plan_id);
43+
$coupon = (new Coupon)->getValidCoupon($couponCode, $plan_id);
21344

21445
if ($coupon) {
21546
$promoPrice =
21647
$coupon->type === 'percentage'
217-
? $promoPrice -
218-
($promoPrice * $coupon->discount_amount) / 100
219-
: $promoPrice - $coupon->discount_amount;
48+
? $promoPrice -
49+
($promoPrice * $coupon->discount_amount) / 100
50+
: $promoPrice - $coupon->discount_amount;
22051
}
22152

22253
$endpoint = 'https://api.pagar.me/core/v5/orders';
@@ -253,7 +84,7 @@ public function createOrderAndGetCheckoutLink(Request $request)
25384
'boleto',
25485
'pix',
25586
],
256-
'success_url' => config('app.frontend_url').'/assine/sucesso',
87+
'success_url' => config('app.frontend_url') . '/assine/sucesso',
25788
'pix' => [
25889
'expires_in' => 86400,
25990
],
@@ -341,6 +172,84 @@ public function createOrderAndGetCheckoutLink(Request $request)
341172
];
342173
}
343174

175+
// Esse é o novo checkout link do Pagarme.
176+
public function createCheckoutLinkV2(CreateCheckoutLinkV2Request $request)
177+
{
178+
179+
$environment = config('app.env');
180+
$endpoint = 'https://api.pagar.me/core/v5/paymentlinks';
181+
182+
if ($environment != 'production') {
183+
$endpoint = 'https://sdx-api.pagar.me/core/v5/paymentlinks';
184+
}
185+
186+
// get with plan_slug:
187+
188+
$payload = $this->buildCheckoutLinkV2Payload($request);
189+
190+
$response = Http::withBasicAuth(
191+
config('services.pagarme.api_key'),
192+
''
193+
)->post($endpoint, $payload);
194+
195+
return $response->json();
196+
}
197+
198+
private function buildCheckoutLinkV2Payload(CreateCheckoutLinkV2Request $request)
199+
{
200+
$validated = $request->validated();
201+
202+
$plan = Plan::where('slug', $validated['plan_slug'])->firstOrFail();
203+
204+
$acceptedPaymentMethods = $validated['accepted_payment_methods'] ?? [
205+
'credit_card',
206+
'boleto',
207+
'pix',
208+
];
209+
210+
$installmentsSetup = [
211+
'interest_type' => 'simple',
212+
'max_installments' => 12,
213+
'amount' => $plan->price_in_cents,
214+
'interest_rate' => 0,
215+
];
216+
217+
$boletoSettings = [
218+
'due_in' => 2,
219+
];
220+
221+
$pixSettings = [
222+
'expires_in' => 4,
223+
];
224+
225+
$items = [
226+
[
227+
'name' => $plan->name,
228+
'amount' => $plan->price_in_cents,
229+
'description' => $validated['plan_description'] ?? '',
230+
'default_quantity' => 1,
231+
],
232+
];
233+
234+
return [
235+
'name' => $plan->slug,
236+
'is_building' => false,
237+
'payment_settings' => [
238+
'accepted_payment_methods' => $acceptedPaymentMethods,
239+
'credit_card_settings' => [
240+
'installments_setup' => $installmentsSetup,
241+
'operation_type' => 'auth_and_capture',
242+
],
243+
'boleto_settings' => $boletoSettings,
244+
'pix_settings' => $pixSettings,
245+
],
246+
'cart_settings' => [
247+
'items' => $items,
248+
],
249+
'type' => 'order',
250+
];
251+
}
252+
344253
// Função para retornar na página de sucesso.
345254
// Vamos pegar também o status dela para saber se foi pago ou não.
346255
public function getSubscriptionByPagarmeOrderId($pagarmeOrderID)

0 commit comments

Comments
 (0)