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.ts2export enum OrderStatus {3 PENDING = "pending", // Aguardando pagamento4 CONFIRMED = "confirmed", // Pagamento confirmado5 PREPARING = "preparing", // Em preparo6 READY = "ready", // Pronto para retirada7 COMPLETED = "completed", // Entregue8 CANCELLED = "cancelled", // Cancelado9}1011@Entity("orders")12export class Order {13 @PrimaryGeneratedColumn("uuid")14 id: string;1516 @Column()17 orderNumber: string; // Ex: #00421819 @ManyToOne(() => User)20 customer: User;2122 @ManyToOne(() => Restaurant)23 restaurant: Restaurant;2425 @OneToMany(() => OrderItem, (item) => item.order)26 items: OrderItem[];2728 @Column({ type: "enum", enum: OrderStatus })29 status: OrderStatus;3031 @Column({ type: "decimal", precision: 10, scale: 2 })32 total: number;3334 @Column({ nullable: true })35 stripePaymentIntentId: string;3637 @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.ts2@Injectable()3export class StripeService {4 private stripe: Stripe;56 constructor(private configService: ConfigService) {7 this.stripe = new Stripe(this.configService.get("STRIPE_SECRET_KEY"), {8 apiVersion: "2023-10-16",9 });10 }1112 async createPaymentIntent(order: Order): Promise<PaymentIntent> {13 return this.stripe.paymentIntents.create({14 amount: Math.round(order.total * 100), // Centavos15 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 }2627 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 );3637 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.ts2@Controller("webhooks/stripe")3export class StripeWebhookController {4 @Post()5 @Public() // Webhooks não usam auth6 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 );1415 if (result.orderId) {16 await this.ordersService.updateStatus(result.orderId, result.newStatus);1718 // Notificar restaurante19 await this.notificationService.notifyRestaurant(20 result.orderId,21 "Novo pedido confirmado!",22 );23 }2425 return { received: true };26 }27}App Mobile
O app foi construído com Expo para facilitar o desenvolvimento cross-platform:
1// screens/OrderScreen.tsx2export function OrderScreen() {3 const { cart, total, clearCart } = useCart();4 const [isProcessing, setIsProcessing] = useState(false);5 const { initPaymentSheet, presentPaymentSheet } = useStripe();67 const handleCheckout = async () => {8 setIsProcessing(true);910 try {11 // 1. Criar pedido no backend12 const { order, clientSecret } = await api.createOrder({13 items: cart.map(item => ({14 productId: item.product.id,15 quantity: item.quantity,16 })),17 });1819 // 2. Inicializar payment sheet20 await initPaymentSheet({21 paymentIntentClientSecret: clientSecret,22 merchantDisplayName: 'UniMenu',23 });2425 // 3. Apresentar payment sheet26 const { error } = await presentPaymentSheet();2728 if (!error) {29 clearCart();30 navigation.navigate('OrderConfirmation', {31 orderId: order.id32 });33 }34 } finally {35 setIsProcessing(false);36 }37 };3839 return (40 <View style={styles.container}>41 <FlatList42 data={cart}43 renderItem={({ item }) => <CartItem item={item} />}44 />4546 <View style={styles.footer}>47 <Text style={styles.total}>Total: R$ {total.toFixed(2)}</Text>48 <Button49 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ão | Vantagem | Desvantagem |
|---|---|---|
| Expo managed | Dev rápido | Limitações em módulos nativos |
| MySQL | Familiar | Menos flexível que PostgreSQL |
| Stripe | Confiável, completo | Taxas mais altas |
| JWT em localStorage | Simples | Vulnerá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
- Integrações de pagamento são complexas: webhooks, idempotência, retry logic
- Estado distribuído é difícil: sincronizar app, web e backend em tempo real
- UX > features: um fluxo simples vale mais que 10 features mal feitas