Skip to main content
The payment system orchestrates a multi-step checkout flow: form validation, payment processing, PDF generation, Telegram notification, and push notifications. It supports both table and delivery orders with different post-payment flows.

Payment flow

1

Order summary display

The checkout screen displays:
  • Cart items with quantities and prices
  • Subtotal calculation
  • Birthday discount (if applicable)
  • Shipping cost (delivery only)
  • Grand total
src/app/(checkout)/payment.tsx
const subtotal = items.reduce((s, i) => s + i.product.price * i.quantity, 0);
const grandTotal = total + shippingCost;
2

Virtual card preview

A live card preview shows as the user types:
  • Card number (masked as •••• •••• •••• 1234)
  • Cardholder name
  • Expiry date (MM/YY format)
The preview uses a gradient background with the TableOrder branding.
3

Form validation

The pay button is disabled until all fields are valid:
src/app/(checkout)/payment.tsx
const isFormValid =
  cardNumber.replace(/\s/g, '').length === 16 &&
  holder.trim().length > 2 &&
  expiry.length === 5 &&
  cvv.length >= 3;
4

Payment processing

When the user taps “Pay”, a mock payment service is called:
src/lib/core/payments/paymentService.ts
export async function processMockPayment(_amount: number): Promise<PaymentResult> {
  await new Promise<void>((resolve) => setTimeout(resolve, 2000));

  const willSucceed = Math.random() > 0.15;

  if (willSucceed) {
    return { success: true };
  }

  return {
    success: false,
    error: 'Lo sentimos, el banco rechazo la transaccion. Intenta con otro metodo.',
  };
}
Success rate: 85% (15% failure rate for testing error flows)Latency: 2000ms (simulates real network round-trip)
5

PDF receipt generation

On success, a branded PDF ticket is generated using expo-print:
src/app/(checkout)/payment.tsx
let pdfUri = '';
try {
  pdfUri = await generateTicketPDF({
    items,
    subtotal,
    discount,
    total,
    shippingCost,
    serviceType,
    tableName: currentTable?.displayName,
    timestamp: new Date().toISOString(),
  });
} catch (err) {
  console.error('[PDF] Generation failed:', err);
}
The PDF includes:
  • Restaurant branding
  • Order details with item breakdown
  • Subtotal, discount, shipping (if delivery)
  • Total amount paid
  • Timestamp
6

Telegram dispatch

The PDF is sent to a Telegram channel via Bot API:
src/app/(checkout)/payment.tsx
if (pdfUri) {
  await sendTicketToTelegram(pdfUri);
}
This allows back-office staff to track orders in real-time.
Telegram dispatch fails silently if credentials are not configured. The app continues normally.
7

Push notification

A local push notification is sent immediately:
src/app/(checkout)/payment.tsx
const serviceName =
  serviceType === 'TABLE'
    ? (currentTable?.displayName ?? 'tu mesa')
    : 'delivery';
await sendPaymentNotification(grandTotal, serviceName);
8

Success modal and navigation

A success modal displays with contextual messaging:
  • Table orders: “Pago exitoso. Gracias por tu visita.” → Navigate to scanner
  • Delivery orders: “Tu pedido está en camino.” → Navigate to tracking

Virtual card component

The card preview updates in real-time as the user types (src/app/(checkout)/payment.tsx:55):
function CardPreview({ number, holder, expiry }: CardPreviewProps) {
  return (
    <LinearGradient
      colors={['#1A1040', '#2D1B69', '#0F0F2E']}
      start={{ x: 0, y: 0 }}
      end={{ x: 1, y: 1 }}
      style={styles.card}
    >
      <View style={styles.cardTopRow}>
        <Cpu size={28} color="rgba(255,255,255,0.6)" />
        <Text style={styles.cardBrand}>TABLEORDER</Text>
      </View>
      <Text style={styles.cardNumber}>{maskCardDisplay(number)}</Text>
      <View style={styles.cardBottomRow}>
        <View>
          <Text style={styles.cardLabel}>TITULAR</Text>
          <Text style={styles.cardValue}>{holder.toUpperCase() || 'TU NOMBRE'}</Text>
        </View>
        <View>
          <Text style={styles.cardLabel}>VENCE</Text>
          <Text style={styles.cardValue}>{expiry || 'MM/AA'}</Text>
        </View>
      </View>
    </LinearGradient>
  );
}

Card number masking

src/app/(checkout)/payment.tsx
function maskCardDisplay(number: string): string {
  const digits = number.replace(/\s/g, '');
  const visible = digits.slice(-4).padStart(4, '');
  if (digits.length < 4) return number || '•••• •••• •••• ••••';
  return `•••• •••• •••• ${visible}`;
}
Only the last 4 digits are shown, matching real credit card UX patterns.

Input formatting

Form inputs are formatted automatically as the user types:
src/app/(checkout)/payment.tsx
function formatCardNumber(raw: string): string {
  const digits = raw.replace(/\D/g, '').slice(0, 16);
  return digits.replace(/(.{4})/g, '$1 ').trim();
}
Input: 1234567890123456Formatted: 1234 5678 9012 3456

Order summary

The order summary adapts based on service type and discounts (src/app/(checkout)/payment.tsx:88):
function OrderSummary() {
  const items = useCartStore((s) => s.items);
  const total = useCartStore((s) => s.total);
  const isBirthdayMode = useCartStore((s) => s.isBirthdayMode);
  const discount = useCartStore((s) => s.discount);
  const shippingCost = useCartStore((s) => s.shippingCost);
  const serviceType = useCartStore((s) => s.serviceType);

  const subtotal = items.reduce((s, i) => s + i.product.price * i.quantity, 0);
  const grandTotal = total + shippingCost;

  return (
    <View style={styles.summary}>
      {/* Item list */}
      {items.map((item) => (
        <View key={item.product.id} style={styles.summaryRow}>
          <Text style={styles.summaryItem}>
            {item.quantity}x {item.product.name}
          </Text>
          <Text style={styles.summaryItemPrice}>
            ${(item.product.price * item.quantity).toFixed(2)}
          </Text>
        </View>
      ))}

      {/* Birthday discount */}
      {isBirthdayMode && (
        <View style={styles.summaryRow}>
          <Text style={styles.discountLabel}>
            Descuento ({Math.round(discount * 100)}% OFF)
          </Text>
          <Text style={styles.discountValue}>-${(subtotal - total).toFixed(2)}</Text>
        </View>
      )}

      {/* Shipping cost */}
      {serviceType === 'DELIVERY' && shippingCost > 0 && (
        <View style={styles.summaryRow}>
          <Text style={styles.summaryItem}>Costo de envío</Text>
          <Text style={styles.summaryItemPrice}>${shippingCost.toFixed(2)}</Text>
        </View>
      )}

      <View style={styles.divider} />
      <View style={styles.summaryRow}>
        <Text style={styles.totalLabel}>Total</Text>
        <Text style={styles.totalValue}>${grandTotal.toFixed(2)}</Text>
      </View>
    </View>
  );
}
The summary automatically shows/hides the discount and shipping rows based on the order type.

Loading states

The payment flow has three loading steps with different messages (src/app/(checkout)/payment.tsx:140):
const LOADING_LABELS: Record<string, string> = {
  payment: 'Validando con el banco...',
  ticket: 'Generando ticket PDF...',
  telegram: 'Enviando comprobante...',
};
The pay button shows the current step:
<TouchableOpacity style={styles.payBtn} onPress={handlePay}>
  {paymentState === 'loading' ? (
    <>
      <ActivityIndicator size="small" color="#fff" />
      <Text style={styles.payBtnText}>
        {LOADING_LABELS[loadingStep] ?? 'Procesando...'}
      </Text>
    </>
  ) : (
    <Text style={styles.payBtnText}>Pagar ${grandTotal.toFixed(2)}</Text>
  )}
</TouchableOpacity>

Error handling

Payment failures display a modal with the error message (src/app/(checkout)/payment.tsx:386):
<Modal visible={paymentState === 'error'} transparent animationType="fade">
  <View style={styles.modalOverlay}>
    <View style={[styles.modalCard, styles.modalCardError]}>
      <XCircle size={56} color={Brand.error} />
      <Text style={styles.modalTitle}>Pago rechazado</Text>
      <Text style={styles.modalBody}>{errorMsg}</Text>
      <TouchableOpacity
        style={[styles.modalBtn, styles.modalBtnError]}
        onPress={() => setPaymentState('idle')}
      >
        <Text style={styles.modalBtnText}>Intentar de nuevo</Text>
      </TouchableOpacity>
    </View>
  </View>
</Modal>
The modal includes:
  • Red error icon
  • “Pago rechazado” title
  • Error message from payment service
  • “Try again” button that resets to idle state
Common error message: “Lo sentimos, el banco rechazo la transaccion. Intenta con otro metodo.”This message is hardcoded in the mock payment service (src/lib/core/payments/paymentService.ts:20).

Success modal

Success displays a contextual message based on service type (src/app/(checkout)/payment.tsx:366):
<Modal visible={paymentState === 'success'} transparent animationType="fade">
  <View style={styles.modalOverlay}>
    <View style={styles.modalCard}>
      <CheckCircle size={64} color={Brand.success} />
      <Text style={styles.modalTitle}>Pago exitoso</Text>
      <Text style={styles.modalBody}>
        {serviceType === 'TABLE'
          ? `Pagaste $${grandTotal.toFixed(2)} en ${currentTable?.displayName ?? 'tu mesa'}.\nGracias por tu visita.`
          : `Pagaste $${grandTotal.toFixed(2)}.\nTu pedido está en camino.`}
      </Text>
      <TouchableOpacity style={styles.modalBtn} onPress={handleSuccessDismiss}>
        <Text style={styles.modalBtnText}>
          {serviceType === 'TABLE' ? 'Finalizar' : 'Ver ruta'}
        </Text>
      </TouchableOpacity>
    </View>
  </View>
</Modal>

Post-payment navigation

The app routes differently based on service type (src/app/(checkout)/payment.tsx:250):
const handleSuccessDismiss = useCallback(() => {
  resetCart();
  if (serviceType === 'TABLE') {
    // Table order: clear session and go back to scanner
    clearSession();
    resetLocation();
    router.replace('/(tabs)');
  } else {
    // Delivery order: navigate to tracking screen
    router.replace('/(delivery)/track-order');
  }
}, [serviceType, resetCart, clearSession, resetLocation, router]);
After payment:
  1. Cart is cleared
  2. Table session is cleared
  3. Location store is reset
  4. User returns to the home screen (scanner/context switcher)
This allows them to scan a new QR code immediately.

Haptic feedback

The payment flow includes haptic feedback at key moments (src/app/(checkout)/payment.tsx):
// Payment initiated
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);

// Payment failed
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);

// Payment succeeded
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);

Payment result type

The payment service returns a discriminated union (src/lib/core/payments/paymentService.ts:1):
export type PaymentResult =
  | { success: true }
  | { success: false; error: string };
This allows type-safe handling:
const result = await processMockPayment(grandTotal);

if (!result.success) {
  setErrorMsg(result.error); // TypeScript knows `error` exists here
  setPaymentState('error');
  return;
}

// TypeScript knows payment succeeded here
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);

Integration with real payment processors

To replace the mock with real payment processing:
1

Install Stripe SDK

npm install @stripe/stripe-react-native
2

Replace processMockPayment

import { useStripe } from '@stripe/stripe-react-native';

const { confirmPayment } = useStripe();

const result = await confirmPayment(clientSecret, {
  paymentMethodType: 'Card',
});
3

Backend payment intent

Create a Stripe payment intent on your backend:
const paymentIntent = await stripe.paymentIntents.create({
  amount: grandTotal * 100, // cents
  currency: 'usd',
});
Return paymentIntent.client_secret to the app.
4

Handle 3D Secure

Stripe SDK automatically handles 3D Secure authentication if required.
The current mock is production-ready from a UX perspective — you only need to swap the payment service implementation.

Tracking

Post-payment delivery tracking (delivery orders only)

Table Mode

In-restaurant ordering workflow

Delivery Mode

Delivery ordering with shipping costs

Contextual Menus

Menu filtering and birthday discounts