em desenvolvimento5 min read

UniMenu Full-Stack + Mobile

Sistema completo de pedidos para restaurantes. Autenticação JWT, integração Stripe para pagamentos, backend NestJS + frontend React + app React Native.

NestJSReactReact NativeMySQLTypeScriptStripeJWT

O Problema

As cantinas da UNIFOR enfrentavam um problema clássico: filas longas nos horários de pico, pedidos perdidos em papéis, e zero visibilidade do que estava disponível. Os alunos perdiam tempo, os atendentes cometiam erros, e os gestores não tinham dados.

A solução típica seria "só fazer um app de pedidos". Mas eu queria construir algo que funcionasse de verdade em um ambiente universitário com suas particularidades.

Visão Geral

UniMenu é um sistema completo de pedidos que inclui:

  • App Mobile (React Native/Expo) para alunos fazerem pedidos
  • Painel Web (React) para gestão de cardápio e pedidos
  • API Backend (NestJS) centralizando toda a lógica
  • Integração Stripe para pagamentos

Arquitetura

1┌──────────────────────────────────────────────────────────┐
2│ CLIENTES │
3├─────────────────────┬────────────────────────────────────┤
4│ App Mobile │ Painel Admin │
5│ (React Native) │ (React + Vite) │
6│ ┌─────────────┐ │ ┌─────────────────────┐ │
7│ │ Expo │ │ │ Dashboard │ │
8│ │ Navigation │ │ │ Gestão Cardápio │ │
9│ │ Context │ │ │ Fila de Pedidos │ │
10│ └──────┬──────┘ │ └──────────┬──────────┘ │
11│ │ │ │ │
12└──────────┼──────────┴──────────────┼─────────────────────┘
13 │ │
14 ▼ ▼
15┌──────────────────────────────────────────────────────────┐
16│ API GATEWAY │
17│ (NestJS Backend) │
18├──────────────────────────────────────────────────────────┤
19│ ┌────────────┐ ┌────────────┐ ┌────────────────────┐ │
20│ │ Auth │ │ Orders │ │ Payments │ │
21│ │ Module │ │ Module │ │ Module (Stripe) │ │
22│ └────────────┘ └────────────┘ └────────────────────┘ │
23│ ┌────────────┐ ┌────────────┐ ┌────────────────────┐ │
24│ │ Users │ │ Products │ │ Notifications │ │
25│ │ Module │ │ Module │ │ Module │ │
26│ └────────────┘ └────────────┘ └────────────────────┘ │
27└──────────────────────────┬───────────────────────────────┘
28
29
30┌──────────────────────────────────────────────────────────┐
31│ DATABASE │
32│ (MySQL) │
33└──────────────────────────────────────────────────────────┘

Fluxo de Pedidos

O coração do sistema é o fluxo de pedidos, que precisava ser:

  • Rápido: aluno faz pedido em menos de 1 minuto
  • Confiável: nenhum pedido pode ser perdido
  • Rastreável: status em tempo real
1// orders/order.entity.ts
2export enum OrderStatus {
3 PENDING = "pending", // Aguardando pagamento
4 CONFIRMED = "confirmed", // Pagamento confirmado
5 PREPARING = "preparing", // Em preparo
6 READY = "ready", // Pronto para retirada
7 COMPLETED = "completed", // Entregue
8 CANCELLED = "cancelled", // Cancelado
9}
10
11@Entity("orders")
12export class Order {
13 @PrimaryGeneratedColumn("uuid")
14 id: string;
15
16 @Column()
17 orderNumber: string; // Ex: #0042
18
19 @ManyToOne(() => User)
20 customer: User;
21
22 @ManyToOne(() => Restaurant)
23 restaurant: Restaurant;
24
25 @OneToMany(() => OrderItem, (item) => item.order)
26 items: OrderItem[];
27
28 @Column({ type: "enum", enum: OrderStatus })
29 status: OrderStatus;
30
31 @Column({ type: "decimal", precision: 10, scale: 2 })
32 total: number;
33
34 @Column({ nullable: true })
35 stripePaymentIntentId: string;
36
37 @CreateDateColumn()
38 createdAt: Date;
39}

Integração Stripe

A integração de pagamento foi um dos maiores desafios. Implementei usando Payment Intents para máxima flexibilidade:

1// payments/stripe.service.ts
2@Injectable()
3export class StripeService {
4 private stripe: Stripe;
5
6 constructor(private configService: ConfigService) {
7 this.stripe = new Stripe(this.configService.get("STRIPE_SECRET_KEY"), {
8 apiVersion: "2023-10-16",
9 });
10 }
11
12 async createPaymentIntent(order: Order): Promise<PaymentIntent> {
13 return this.stripe.paymentIntents.create({
14 amount: Math.round(order.total * 100), // Centavos
15 currency: "brl",
16 metadata: {
17 orderId: order.id,
18 orderNumber: order.orderNumber,
19 customerId: order.customer.id,
20 },
21 automatic_payment_methods: {
22 enabled: true,
23 },
24 });
25 }
26
27 async handleWebhook(
28 payload: Buffer,
29 signature: string,
30 ): Promise<WebhookResult> {
31 const event = this.stripe.webhooks.constructEvent(
32 payload,
33 signature,
34 this.configService.get("STRIPE_WEBHOOK_SECRET"),
35 );
36
37 switch (event.type) {
38 case "payment_intent.succeeded":
39 return this.handlePaymentSuccess(event.data.object);
40 case "payment_intent.payment_failed":
41 return this.handlePaymentFailure(event.data.object);
42 default:
43 return { handled: false };
44 }
45 }
46}

Webhook Handler

Os webhooks do Stripe são essenciais para garantir que o status do pedido seja atualizado corretamente:

1// payments/stripe.controller.ts
2@Controller("webhooks/stripe")
3export class StripeWebhookController {
4 @Post()
5 @Public() // Webhooks não usam auth
6 async handleWebhook(
7 @Headers("stripe-signature") signature: string,
8 @Req() req: RawBodyRequest<Request>,
9 ) {
10 const result = await this.stripeService.handleWebhook(
11 req.rawBody,
12 signature,
13 );
14
15 if (result.orderId) {
16 await this.ordersService.updateStatus(result.orderId, result.newStatus);
17
18 // Notificar restaurante
19 await this.notificationService.notifyRestaurant(
20 result.orderId,
21 "Novo pedido confirmado!",
22 );
23 }
24
25 return { received: true };
26 }
27}

App Mobile

O app foi construído com Expo para facilitar o desenvolvimento cross-platform:

1// screens/OrderScreen.tsx
2export function OrderScreen() {
3 const { cart, total, clearCart } = useCart();
4 const [isProcessing, setIsProcessing] = useState(false);
5 const { initPaymentSheet, presentPaymentSheet } = useStripe();
6
7 const handleCheckout = async () => {
8 setIsProcessing(true);
9
10 try {
11 // 1. Criar pedido no backend
12 const { order, clientSecret } = await api.createOrder({
13 items: cart.map(item => ({
14 productId: item.product.id,
15 quantity: item.quantity,
16 })),
17 });
18
19 // 2. Inicializar payment sheet
20 await initPaymentSheet({
21 paymentIntentClientSecret: clientSecret,
22 merchantDisplayName: 'UniMenu',
23 });
24
25 // 3. Apresentar payment sheet
26 const { error } = await presentPaymentSheet();
27
28 if (!error) {
29 clearCart();
30 navigation.navigate('OrderConfirmation', {
31 orderId: order.id
32 });
33 }
34 } finally {
35 setIsProcessing(false);
36 }
37 };
38
39 return (
40 <View style={styles.container}>
41 <FlatList
42 data={cart}
43 renderItem={({ item }) => <CartItem item={item} />}
44 />
45
46 <View style={styles.footer}>
47 <Text style={styles.total}>Total: R$ {total.toFixed(2)}</Text>
48 <Button
49 title={isProcessing ? 'Processando...' : 'Finalizar Pedido'}
50 onPress={handleCheckout}
51 disabled={isProcessing || cart.length === 0}
52 />
53 </View>
54 </View>
55 );
56}

Decisões Técnicas

Por que MySQL?

  • Familiaridade da equipe
  • Bom suporte em provedores managed
  • Relações bem definidas (usuários, restaurantes, pedidos, produtos)

Por que Expo?

  • Setup zero para iOS e Android
  • Over-the-air updates (crucial para bugs em produção)
  • Acesso a APIs nativas sem eject

Trade-offs

DecisãoVantagemDesvantagem
Expo managedDev rápidoLimitações em módulos nativos
MySQLFamiliarMenos flexível que PostgreSQL
StripeConfiável, completoTaxas mais altas
JWT em localStorageSimplesVulnerável a XSS (mitigado com refresh tokens)

Status Atual

O projeto está em desenvolvimento ativo:

  • ✅ Backend completo com testes
  • ✅ Integração Stripe funcionando
  • ✅ App mobile com fluxo principal
  • 🔄 Painel admin em progresso
  • ⏳ Notificações push pendente
  • ⏳ Sistema de avaliações pendente

O que aprendi

  1. Integrações de pagamento são complexas: webhooks, idempotência, retry logic
  2. Estado distribuído é difícil: sincronizar app, web e backend em tempo real
  3. UX > features: um fluxo simples vale mais que 10 features mal feitas