Desenvolvimento do Projeto — Implementação em Código da Plataforma SaaS ShopMetrics
Os diagramas de design finalmente se tornaram código — mas não se apresse. Implemente módulo por módulo e teste cada módulo ao concluir.
1. O Que Você Vai Aprender
- Middleware multi-tenant e a implementação de isolamento de scope
- Integração de Cobrança de Assinatura Stripe: Plan/Subscription/Processamento Webhook
- Dashboard de Análise de Dados: Agregações Eloquent + Visualização Chart.js
- Geração assíncrona de relatórios: Queue Job + armazenamento S3 + notificações por email
- Implementação completa da camada API: autenticação, resources, filtragem, paginação e versionamento
2. Uma História Real de Desenvolvimento Colaborativo
(1) Dor: Três pessoas estão cada uma escrevendo seu próprio código, o que leva a conflitos ao mesclar o código
Bob distribuiu as tarefas: Alice deveria escrever o código de gerenciamento de tenants e Charlie deveria escrever o código de análise de dados. Três dias depois, quando mesclaram o código, os relacionamentos entre o model Tenant de Alice e o model Order de Charlie não estavam alinhados, e a lógica de middleware deles conflitava entre si, resultando em erros 500 em todo o site após a mesclagem. Bob disse: "Precisamos de um processo de desenvolvimento que garanta que o código de todos possa rodar independentemente."
(2) Soluções para Desenvolvimento Modular
Desenvolva na seguinte ordem de prioridade: Autenticação → Tenant → Loja → Pedidos → Analytics → Cobrança. Após completar cada módulo, execute testes e mescle o código para garantir que a branch principal esteja sempre em estado executável.
Semana 1: Auth + Tenant + Middleware (Framework Básico)
Semana 2: Shop + Order + Product (Negócio Principal)
Semana 3: Dashboard + Analytics (Análise de Dados)
Semana 4: Subscription + Stripe (Sistema de Cobrança)
(3) Resultado
Após adotar desenvolvimento modular, os três desenvolvedores mesclaram seu código uma vez por dia, reduzindo a taxa de conflitos de 40% para 5%, e a branch principal permaneceu consistentemente executável.
3. Middleware Multi-tenant e Scope
(1) Fluxo de Processamento de Requisição
sequenceDiagram
participant Client
participant Nginx
participant Middleware
participant TenantScope
participant Controller
participant DB
Client->>Nginx: GET alice.shopmetrics.io/api/v1/shops
Nginx->>Middleware: X-Tenant: alice
Middleware->>DB: SELECT * FROM tenants WHERE slug=alice
DB-->>Middleware: Tenant{id:1, slug:alice}
Middleware->>TenantScope: Definir tenant atual
Middleware->>Controller: Request com contexto de tenant
Controller->>DB: SELECT * FROM shops WHERE tenant_id=1
DB-->>Controller: Apenas lojas de Alice
Controller-->>Client: 200 OK {data: [...]}
(2) Implementação do Middleware
// app/Http/Middleware/SetTenantContext.php
class SetTenantContext
{
public function handle(Request $request, Closure $next): Response
{
if (!$user = $request->user()) {
return $next($request);
}
$tenant = $user->tenant;
if (!$tenant || !$tenant->isActive()) {
abort(403, 'Conta do tenant está inativa ou suspensa.');
}
// Verificar limites do plano
if ($tenant->isOverLimit()) {
abort(402, 'Limite do plano excedido. Atualize sua assinatura.');
}
Tenant::setCurrent($tenant);
$request->attributes->set('tenant', $tenant);
return $next($request);
}
}
// app/Http/Kernel.php - Registrar middleware
protected $middlewareAliases = [
'tenant' => SetTenantContext::class,
];
(1) ▶ Exemplo: Implementação Completa da Trait BelongsToTenant
// app/Models/Traits/BelongsToTenant.php
namespace App\Models\Traits;
use App\Models\Tenant;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
trait BelongsToTenant
{
public static function bootBelongsToTenant(): void
{
static::addGlobalScope('tenant', function (Builder $builder) {
$tenant = Tenant::current();
if ($tenant) {
$builder->where($builder->getModel()->getTable() . '.tenant_id', $tenant->id);
}
});
static::creating(function (Model $model) {
$tenant = Tenant::current();
if ($tenant && !$model->isDirty('tenant_id')) {
$model->tenant_id = $tenant->id;
}
});
static::saving(function (Model $model) {
$tenant = Tenant::current();
if ($tenant) {
$currentTenantId = $model->getOriginal('tenant_id') ?? $model->tenant_id;
if ((int) $currentTenantId !== $tenant->id) {
throw new \Illuminate\Auth\Access\AuthorizationException(
'Não é possível modificar dados pertencentes a outro tenant.'
);
}
}
});
}
public function tenant(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function scopeForTenant(Builder $query, Tenant $tenant): Builder
{
return $query->withoutGlobalScope('tenant')
->where('tenant_id', $tenant->id);
}
public function scopeAllTenants(Builder $query): Builder
{
return $query->withoutGlobalScope('tenant');
}
}
Saída:
// Execução bem-sucedida
4. Integração de Cobrança de Assinatura Stripe
(1) Ciclo de Vida da Assinatura
stateDiagram-v2
[*] --> Trial: Novo Cadastro
Trial --> Active: Trial→Pago
Trial --> Cancelled: Cancelar durante trial
Active --> PastDue: Pagamento falhou
PastDue --> Active: Retry de pagamento bem-sucedido
PastDue --> Cancelled: Retry esgotado
Active --> Cancelled: Usuário cancela
Cancelled --> Active: Reassinar
(2) Os Models Plan e Subscription
// app/Models/Plan.php
class Plan extends Model
{
protected $fillable = [
'name', 'slug', 'shop_limit', 'user_limit',
'price_cents', 'stripe_price_id', 'is_active',
];
protected $casts = [
'price_cents' => 'integer',
'shop_limit' => 'integer',
'user_limit' => 'integer',
'is_active' => 'boolean',
];
public function subscriptions(): HasMany
{
return $this->hasMany(Subscription::class);
}
public function isFree(): bool
{
return $this->price_cents === 0;
}
public function formattedPrice(): string
{
return $this->isFree() ? 'Grátis' : '$' . number_format($this->price_cents / 100, 2) . '/mês';
}
}
// app/Models/Subscription.php
class Subscription extends Model
{
protected $fillable = [
'tenant_id', 'plan_id', 'stripe_id',
'stripe_status', 'trial_ends_at', 'ends_at',
];
protected $casts = [
'trial_ends_at' => 'datetime',
'ends_at' => 'datetime',
];
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function plan(): BelongsTo
{
return $this->belongsTo(Plan::class);
}
public function isActive(): bool
{
return in_array($this->stripe_status, ['active', 'trialing']);
}
public function onTrial(): bool
{
return $this->stripe_status === 'trialing' ||
($this->trial_ends_at && $this->trial_ends_at->isFuture());
}
public function onGracePeriod(): bool
{
return $this->ends_at && $this->ends_at->isFuture();
}
}
(1) ▶ Exemplo: Stripe Checkout e Processamento Webhook
// app/Http/Controllers/SubscriptionController.php
class SubscriptionController extends Controller
{
public function store(Request $request)
{
$request->validate(['plan_slug' => 'required|exists:plans,slug']);
$plan = Plan::whereSlug($request->plan_slug)->firstOrFail();
$tenant = $request->attributes->get('tenant');
if ($plan->isFree()) {
$tenant->updateToFreePlan($plan);
return response()->json(['message' => 'Assinado no plano gratuito']);
}
// Criar sessão Stripe Checkout
$checkout = Stripe::checkout()->sessions()->create([
'customer' => $tenant->stripe_customer_id ?? $this->createStripeCustomer($tenant),
'mode' => 'subscription',
'line_items' => [[
'price' => $plan->stripe_price_id,
'quantity' => 1,
]],
'subscription_data' => [
'trial_period_days' => config('shopmetrics.tenant.trial_days'),
'metadata' => ['tenant_id' => $tenant->id],
],
'success_url' => config('app.url') . '/billing?success=1',
'cancel_url' => config('app.url') . '/billing?cancel=1',
]);
return response()->json(['checkout_url' => $checkout->url]);
}
public function cancel(Request $request)
{
$tenant = $request->attributes->get('tenant');
$subscription = $tenant->subscription;
Stripe::subscriptions()->update($subscription->stripe_id, [
'cancel_at_period_end' => true,
]);
$subscription->update(['ends_at' => $subscription->current_period_end]);
return response()->json(['message' => 'Assinatura será cancelada ao final do período']);
}
}
// app/Http/Controllers/WebhookController.php
class WebhookController extends Controller
{
public function handleStripe(Request $request)
{
$payload = $request->all();
$event = Stripe::webhooks()->constructEvent(
$payload,
$request->header('Stripe-Signature'),
config('services.stripe.webhook_secret')
);
match ($event->type) {
'customer.subscription.created' => $this->handleSubscriptionCreated($event),
'customer.subscription.updated' => $this->handleSubscriptionUpdated($event),
'customer.subscription.deleted' => $this->handleSubscriptionDeleted($event),
'invoice.payment_failed' => $this->handlePaymentFailed($event),
default => null,
};
return response()->json(['received' => true]);
}
private function handleSubscriptionUpdated($event): void
{
$stripeSubscription = $event->data->object;
$subscription = Subscription::whereStripeId($stripeSubscription->id)->firstOrFail();
$subscription->update([
'stripe_status' => $stripeSubscription->status,
'trial_ends_at' => $stripeSubscription->trial_end
? \Carbon\Carbon::createFromTimestamp($stripeSubscription->trial_end)
: null,
'ends_at' => $stripeSubscription->cancel_at_period_end
? \Carbon\Carbon::createFromTimestamp($stripeSubscription->current_period_end)
: null,
]);
}
private function handlePaymentFailed($event): void
{
$stripeSubscription = $event->data->object->subscription;
$subscription = Subscription::whereStripeId($stripeSubscription)->first();
if ($subscription) {
$subscription->tenant->users->each(
fn ($user) => $user->notify(new PaymentFailedNotification())
);
}
}
}
Saída:
// Execução bem-sucedida
5. Dashboard de Análise de Dados
(1) Design da API do Dashboard
| Endpoint | Dados Retornados | TTL do Cache |
|---|---|---|
GET /dashboard/overview |
Receita Total/Número de Pedidos/Número de Clientes/Ano-a-Ano | 15 minutos |
GET /dashboard/revenue |
Dados do Gráfico de Tendência de Receita | 30 minutos |
GET /dashboard/top-products |
Top 10 Produtos | 1 hora |
GET /dashboard/shop-comparison |
Comparação de Lojas | 1 hora |
(1) ▶ Exemplo: Serviço de Dashboard do ShopMetrics
// app/Services/DashboardService.php
class DashboardService
{
public function getOverview(Tenant $tenant): array
{
return Cache::remember(
"dashboard:{$tenant->id}:overview",
now()->addMinutes(15),
fn () => $this->calculateOverview($tenant)
);
}
private function calculateOverview(Tenant $tenant): array
{
$currentMonth = now()->startOfMonth();
$lastMonth = now()->subMonth()->startOfMonth();
$current = DB::table('orders')
->where('tenant_id', $tenant->id)
->where('ordered_at', '>=', $currentMonth)
->select([
DB::raw('SUM(total_cents) as revenue_cents'),
DB::raw('COUNT(*) as order_count'),
DB::raw('COUNT(DISTINCT customer_email) as customer_count'),
])
->first();
$previous = DB::table('orders')
->where('tenant_id', $tenant->id)
->whereBetween('ordered_at', [$lastMonth, $currentMonth])
->select([
DB::raw('SUM(total_cents) as revenue_cents'),
DB::raw('COUNT(*) as order_count'),
])
->first();
return [
'revenue' => [
'current' => (float) ($current->revenue_cents / 100),
'previous' => (float) ($previous->revenue_cents / 100),
'change_percent' => $this->percentChange(
$previous->revenue_cents, $current->revenue_cents
),
],
'orders' => [
'current' => (int) $current->order_count,
'previous' => (int) $previous->order_count,
'change_percent' => $this->percentChange(
$previous->order_count, $current->order_count
),
],
'customers' => (int) $current->customer_count,
];
}
public function getRevenueChart(Tenant $tenant, string $period = '30d'): array
{
$days = match ($period) {
'7d' => 7, '30d' => 30, '90d' => 90, '1y' => 365,
default => 30,
};
return Cache::remember(
"dashboard:{$tenant->id}:revenue:{$period}",
now()->addMinutes(30),
fn () => $this->calculateRevenueChart($tenant, $days)
);
}
private function calculateRevenueChart(Tenant $tenant, int $days): array
{
$data = DB::table('orders')
->where('tenant_id', $tenant->id)
->where('ordered_at', '>=', now()->subDays($days))
->groupBy('date')
->orderBy('date')
->select([
DB::raw('DATE(ordered_at) as date'),
DB::raw('SUM(total_cents) as revenue_cents'),
DB::raw('COUNT(*) as order_count'),
])
->get();
return [
'labels' => $data->pluck('date')->map(fn ($d) => \Carbon\Carbon::parse($d)->format('M d')),
'revenue' => $data->pluck('revenue_cents')->map(fn ($v) => $v / 100),
'orders' => $data->pluck('order_count'),
];
}
private function percentChange(float|int $old, float|int $new): float
{
if ($old == 0) return $new > 0 ? 100.0 : 0.0;
return round((($new - $old) / $old) * 100, 1);
}
}
Saída:
// Execução bem-sucedida
6. Geração Assíncrona de Relatórios
(1) Processo de Geração de Relatório
sequenceDiagram
participant User as Alice
participant API as Servidor API
participant Queue as Fila Redis
participant Worker as Queue Worker
participant S3 as S3 Storage
participant Mail as Serviço de Email
User->>API: POST /reports {type: monthly, format: xlsx}
API->>Queue: Dispatch GenerateReportJob
API-->>User: 202 {report_id, status: pending}
Queue->>Worker: Pegar job
Worker->>Worker: Consultar & agregar dados
Worker->>S3: Upload arquivo de relatório
Worker->>Worker: Atualizar status=completed
Worker->>Mail: Enviar email de notificação
Mail-->>User: Email de relatório pronto
User->>API: GET /reports/{id}
API-->>User: {status: completed, download_url}
(1) ▶ Exemplo: Job de Geração de Relatório do ShopMetrics
// app/Jobs/GenerateReportJob.php
class GenerateReportJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $backoff = 60;
public int $timeout = 600;
public function __construct(
public Tenant $tenant,
public string $type,
public string $format,
public array $filters = [],
) {}
public function handle(
ReportGenerator $generator,
ReportStorage $storage,
): void {
$report = Report::create([
'tenant_id' => $this->tenant->id,
'type' => $this->type,
'format' => $this->format,
'status' => 'processing',
'filters' => $this->filters,
]);
try {
$data = $this->fetchData();
$filePath = $generator->generate($data, $this->format, $report);
$storagePath = $storage->store($filePath, $this->tenant, $report);
$report->update([
'status' => 'completed',
'storage_path' => $storagePath,
'generated_at' => now(),
]);
$this->notifyUser($report);
} catch (\Throwable $e) {
$report->update(['status' => 'failed']);
throw $e;
} finally {
Cache::forget("dashboard:{$this->tenant->id}:*");
}
}
private function fetchData(): Collection
{
return match ($this->type) {
'monthly' => $this->fetchMonthlyData(),
'product_comparison' => $this->fetchProductComparison(),
'shop_performance' => $this->fetchShopPerformance(),
default => throw new \InvalidArgumentException("Tipo de relatório desconhecido: {$this->type}"),
};
}
private function fetchMonthlyData(): Collection
{
return Order::with('items.product')
->where('tenant_id', $this->tenant->id)
->whereBetween('ordered_at', [
$this->filters['date_from'] ?? now()->subMonth(),
$this->filters['date_to'] ?? now(),
])
->orderBy('ordered_at')
->get();
}
private function notifyUser(Report $report): void
{
$user = User::find($this->filters['user_id'] ?? $this->tenant->users()->first()->id);
$user?->notify(new ReportReadyNotification($report));
}
}
// app/Services/ReportGenerator.php
class ReportGenerator
{
public function generate(Collection $data, string $format, Report $report): string
{
return match ($format) {
'csv' => $this->generateCsv($data, $report),
'xlsx' => $this->generateExcel($data, $report),
'json' => $this->generateJson($data, $report),
default => throw new \InvalidArgumentException("Formato não suportado: {$format}"),
};
}
private function generateCsv(Collection $data, Report $report): string
{
$path = tempnam(sys_get_temp_dir(), 'report_');
$file = fopen($path, 'w');
fputcsv($file, ['ID do Pedido', 'Data', 'Cliente', 'Total', 'Status']);
foreach ($data as $order) {
fputcsv($file, [
$order->external_id,
$order->ordered_at->format('Y-m-d'),
$order->customer_email,
number_format($order->total_cents / 100, 2),
$order->status,
]);
}
fclose($file);
return $path;
}
}
Saída:
// Execução bem-sucedida
7. Implementação Completa da Camada API
(1) Saída de Recursos API
// app/Http/Resources/ShopResource.php
class ShopResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'platform' => $this->platform,
'status' => $this->status,
'metadata' => $this->metadata,
'order_count' => $this->whenCounted('orders'),
'revenue_cents' => $this->whenAggregated('orders', 'total_cents', 'sum'),
'created_at' => $this->created_at->toIso8601String(),
];
}
}
// app/Http/Resources/OrderResource.php
class OrderResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'external_id' => $this->external_id,
'customer_email' => $this->customer_email,
'total' => [
'cents' => $this->total_cents,
'formatted' => '$' . number_format($this->total_cents / 100, 2),
],
'status' => $this->status,
'items' => OrderItemResource::collection($this->whenLoaded('items')),
'ordered_at' => $this->ordered_at->toIso8601String(),
];
}
}
(2) Filtragem e Paginação
// app/Http/Controllers/OrderController.php
class OrderController extends Controller
{
public function index(Request $request)
{
$query = Order::with(['items.product'])
->where('shop_id', $request->route('shop')->id);
// Filtros
if ($status = $request->query('status')) {
$query->where('status', $status);
}
if ($dateFrom = $request->query('date_from')) {
$query->where('ordered_at', '>=', $dateFrom);
}
if ($dateTo = $request->query('date_to')) {
$query->where('ordered_at', '<=', $dateTo);
}
if ($minTotal = $request->query('min_total')) {
$query->where('total_cents', '>=', $minTotal * 100);
}
if ($search = $request->query('search')) {
$query->where('external_id', 'like', "%{$search}%")
->orWhere('customer_email', 'like', "%{$search}%");
}
// Ordenação
$sortField = $request->query('sort_by', 'ordered_at');
$sortDir = $request->query('sort_dir', 'desc');
$query->orderBy($sortField, $sortDir);
// Paginação
$orders = $query->paginate($request->query('per_page', 25));
return OrderResource::collection($orders);
}
}
(1) ▶ Exemplo: Rotas API e Controllers Completos do ShopMetrics
// routes/api.php
Route::prefix('v1')->group(function () {
Route::post('auth/login', [AuthController::class, 'login']);
Route::post('auth/register', [AuthController::class, 'register']);
Route::middleware(['auth:sanctum', 'tenant'])->group(function () {
Route::post('auth/logout', [AuthController::class, 'logout']);
Route::get('auth/me', [AuthController::class, 'me']);
Route::apiResource('shops', ShopController::class);
Route::prefix('shops/{shop}')->group(function () {
Route::apiResource('orders', OrderController::class)->only(['index', 'show']);
Route::apiResource('products', ProductController::class);
});
Route::prefix('dashboard')->group(function () {
Route::get('overview', [DashboardController::class, 'overview']);
Route::get('revenue', [DashboardController::class, 'revenue']);
Route::get('top-products', [DashboardController::class, 'topProducts']);
});
Route::apiResource('reports', ReportController::class)->only(['index', 'store', 'show']);
Route::get('reports/{report}/download', [ReportController::class, 'download']);
Route::apiResource('alerts', AlertController::class);
Route::get('plans', [PlanController::class, 'index']);
Route::post('subscriptions', [SubscriptionController::class, 'store']);
Route::get('subscription', [SubscriptionController::class, 'show']);
Route::delete('subscription', [SubscriptionController::class, 'cancel']);
Route::middleware('role:tenant_owner')->prefix('team')->group(function () {
Route::apiResource('members', TeamController::class)->only(['index', 'destroy']);
Route::post('invite', [TeamController::class, 'invite']);
});
});
Route::post('webhooks/stripe', [WebhookController::class, 'handleStripe']);
});
Saída:
// Execução bem-sucedida
8. Exemplo Compreensivo: Integração Completa de Funcionalidades do ShopMetrics
// ============================================
// Compreensivo: Fluxo de requisição end-to-end
// Do login → dashboard → relatório → notificação
// ============================================
// Passo 1: Alice faz login
// POST /api/v1/auth/login {email, password}
class AuthController extends Controller
{
public function login(Request $request)
{
$credentials = $request->validate([
'email' => 'required|email',
'password' => 'required',
]);
if (!Auth::attempt($credentials)) {
return response()->json(['message' => 'Credenciais inválidas'], 401);
}
$user = Auth::user();
$token = $user->createToken('auth-token')->plainTextToken;
return response()->json([
'user' => new UserResource($user),
'token' => $token,
'tenant' => new TenantResource($user->tenant),
]);
}
}
// Passo 2: Alice visualiza dashboard (middleware de tenant auto-configura contexto)
// GET /api/v1/dashboard/overview
class DashboardController extends Controller
{
public function overview(Request $request)
{
$tenant = $request->attributes->get('tenant');
return response()->json(
app(DashboardService::class)->getOverview($tenant)
);
}
}
// Passo 3: Alice gera um relatório mensal
// POST /api/v1/reports {type: monthly, format: xlsx, date_from, date_to}
class ReportController extends Controller
{
public function store(Request $request)
{
$validated = $request->validate([
'type' => 'required|in:monthly,product_comparison,shop_performance',
'format' => 'required|in:csv,xlsx,json',
'date_from' => 'nullable|date',
'date_to' => 'nullable|date|after:date_from',
]);
$tenant = $request->attributes->get('tenant');
// Verificar limites do plano
$monthlyReports = Report::where('tenant_id', $tenant->id)
->whereMonth('created_at', now()->month)
->count();
$limit = $tenant->subscription->plan->limits['reports_per_month'] ?? 10;
if ($monthlyReports >= $limit && $limit !== -1) {
return response()->json([
'message' => 'Limite mensal de relatórios atingido. Atualize seu plano.',
], 402);
}
GenerateReportJob::dispatch(
$tenant,
$validated['type'],
$validated['format'],
[...$validated, 'user_id' => $request->user()->id]
);
return response()->json([
'message' => 'Geração de relatório iniciada',
'estimated_time' => '2-5 minutos',
], 202);
}
}
// Passo 4: Worker processa o job, faz upload para S3, envia email
// (Ver GenerateReportJob na Seção 6)
// Passo 5: Alice faz download do relatório
// GET /api/v1/reports/{report}/download
class ReportController extends Controller
{
public function download(Report $report, Request $request)
{
$tenant = $request->attributes->get('tenant');
if ($report->tenant_id !== $tenant->id) {
abort(403);
}
if ($report->status !== 'completed') {
return response()->json(['message' => 'Relatório não está pronto', 'status' => $report->status], 202);
}
// Gerar URL S3 temporária (5 minutos)
$url = Storage::disk('s3')->temporaryUrl(
$report->storage_path,
now()->addMinutes(5)
);
return redirect($url);
}
}
❓ Perguntas Frequentes
withoutGlobalScope(). Medidas de mitigação: Verificar propriedade do tenant_id na camada API, implementar políticas de Row-Level Security (RLS) e registrar tentativas de bypass em logs de auditoria.dashboard:{tenant_id}:overview. Ao verificar expiração, use o prefixo para escanear o cache (Redis SCAN); não use KEYS (que causará bloqueio).whenLoaded para um recurso API??include=items.product, e o controller determina with() com base no parâmetro include.📖 Resumo
- Use Global Scope + Middleware para injetar automaticamente o
tenant_idpara isolamento multi-tenant - Stripe Checkout gerencia o processo de pagamento, e Webhooks sincronizam o status da assinatura
- A camada Service do dashboard encapsula lógica de agregação, e cache Redis reduz a carga no banco de dados
- Relatórios são gerados assincronamente usando Queue Job, armazenados no S3, e usuários são notificados por email
- Formato de saída padronizado para recursos API; filtragem, paginação e ordenação são encapsulados no controller
- Desenvolvimento modular: Autenticação → Tenant → Loja → Pedido → Analytics → Cobrança; prossiga nesta ordem
📝 Exercícios
-
Exercício Básico (⭐): Implemente ShopResource e OrderResource, incluindo dados associados (whenLoaded), e escreva os testes funcionais correspondentes para verificar a estrutura JSON.
-
Exercício Avançado (⭐⭐): Implemente completamente o fluxo de assinatura Stripe — criar uma sessão Checkout + processar webhooks (3 eventos: created/updated/deleted) + sincronizar status da assinatura + enviar notificações por email para falhas de pagamento.
-
Desafio (⭐⭐⭐): Implemente um sistema completo de geração de relatórios — GenerateReportJob — que suporta formatos CSV e JSON, acompanhamento de progresso (armazenando porcentagens de progresso no Redis), URLs temporárias de download S3 (válidas por 5 minutos), verificação de limite de jobs agendados e notificações por email ao concluir. Escreva testes funcionais cobrindo todo o fluxo: Criar → Em Progresso → Concluído → Download.



