Skip to main content
TableOrder integrates with multiple external services to provide maps, payments, PDF generation, and notifications. All external API calls are isolated in the lib/services/ layer.

Service architecture

Services are organized by domain:
lib/services/
├── mapboxService.ts     # Directions API + polyline decoding
├── pdfService.ts        # HTML-to-PDF ticket generation
└── telegramService.ts   # Bot API document upload
Each service is a standalone module that can be tested, mocked, or replaced independently.

Mapbox service

Handles route calculation, polyline decoding, and shipping cost estimation. File: src/lib/services/mapboxService.ts

Route calculation

Fetches a driving route between two coordinates using the Mapbox Directions API:
import polyline from '@mapbox/polyline';
import { Config } from '@/src/lib/core/config';
import { Coordinates, DeliveryInfo } from '@/src/lib/core/types';

const MAPBOX_BASE = 'https://api.mapbox.com/directions/v5/mapbox/driving';

export async function getDeliveryRoute(
  origin: Coordinates,
  destination: Coordinates
): Promise<DeliveryInfo> {
  const { token } = Config.mapbox;

  if (!token) {
    throw new Error('EXPO_PUBLIC_MAPBOX_TOKEN is not configured.');
  }

  // Mapbox expects coordinates in lon,lat order
  const coords = `${origin.longitude},${origin.latitude};${destination.longitude},${destination.latitude}`;
  const url = `${MAPBOX_BASE}/${coords}?geometries=polyline&overview=full&access_token=${token}`;

  const response = await fetch(url);

  if (!response.ok) {
    throw new Error(`Mapbox API error: ${response.status}`);
  }

  const data = await response.json();

  if (data.code !== 'Ok' || data.routes.length === 0) {
    throw new Error('No route found between the two points.');
  }

  const route = data.routes[0];

  return {
    distanceKm: parseFloat((route.distance / 1000).toFixed(2)),
    etaMinutes: Math.ceil(route.duration / 60),
    polyline: route.geometry,
    decodedRoute: polyline.decode(route.geometry).map(([lat, lng]) => ({
      latitude: lat,
      longitude: lng,
    })),
  };
}
GET /directions/v5/mapbox/driving/-74.0060,40.7128;-73.9851,40.7589
  ?geometries=polyline
  &overview=full
  &access_token=pk.ey...
Parameters:
  • geometries=polyline — Return compressed polyline instead of GeoJSON
  • overview=full — Include all route points, not simplified

Polyline decoding

Mapbox returns routes as compressed polylines (precision 5). The service decodes them into coordinate arrays:
import polyline from '@mapbox/polyline';

// Encoded: "_p~iF~ps|U_ulLnnqC_mqNvxq`@"
// Decoded: [[38.5, -120.2], [40.7, -120.95], [43.252, -126.453]]

const decodedPairs: [number, number][] = polyline.decode(route.geometry);

// Convert to react-native-maps format
const decodedRoute: Coordinates[] = decodedPairs.map(([lat, lng]) => ({
  latitude: lat,
  longitude: lng,
}));
Polyline encoding reduces payload size by ~90%. A 500-point route that would be 20KB as JSON is only 2KB as an encoded polyline.

Shipping cost calculation

Calculates delivery cost based on distance and a per-kilometer rate:
export function calculateShippingCost(distanceKm: number): number {
  return parseFloat((distanceKm * Config.restaurant.costPerKm).toFixed(2));
}

// Example: 7.42 km × $1.50/km = $11.13
Usage in checkout flow:
const deliveryInfo = await getDeliveryRoute(restaurant, userLocation);
const shippingCost = calculateShippingCost(deliveryInfo.distanceKm);

useCartStore.getState().setShippingCost(shippingCost);
useLocationStore.getState().setDeliveryRoute(deliveryInfo);

PDF service

Generates branded PDF receipts using expo-print with HTML templates. File: src/lib/services/pdfService.ts

Template structure

The service builds an HTML template with embedded CSS and dynamic content:
import * as Print from 'expo-print';
import { CartItem } from '@/src/lib/core/types';

export interface TicketData {
  items: CartItem[];
  subtotal: number;
  discount: number;
  total: number;
  shippingCost: number;
  serviceType: 'TABLE' | 'DELIVERY';
  tableName?: string;
  timestamp?: string;
}

export async function generateTicketPDF(data: TicketData): Promise<string> {
  const html = buildTicketHTML(data);
  const { uri } = await Print.printToFileAsync({ html, base64: false });
  return uri;
}

HTML template

The template includes:

Header

TableOrder logo with branded colors (#E25822)

Metadata

Order ID, service type, date/time

Items table

Product name, quantity, and prices

Totals section

Subtotal, discount, shipping, and grand total
Key template features:
function buildTicketHTML(data: TicketData): string {
  const orderId = `TO-${Date.now().toString(36).toUpperCase()}`;
  const dateStr = new Date().toLocaleString('es-ES');
  
  const serviceLabel = data.serviceType === 'TABLE'
    ? `Mesa: ${data.tableName ?? 'Sin nombre'}`
    : `Delivery`;
  
  const discountAmount = data.subtotal - (data.total - data.shippingCost);
  const showDiscount = data.discount > 0 && discountAmount > 0;
  
  const itemsRows = data.items
    .map((item) => `
      <tr>
        <td class="item-name">${item.quantity}x ${item.product.name}</td>
        <td class="item-price">$${(item.product.price * item.quantity).toFixed(2)}</td>
      </tr>`)
    .join('');
  
  return `<!DOCTYPE html>...`;
}
<div class="header">
  <div class="logo">TableOrder</div>
  <div class="logo-sub">Comprobante de pago</div>
</div>
Styled with the brand color (#E25822) and bold typography.

Generated PDF example

The service returns a file URI that can be shared or uploaded:
const pdfUri = await generateTicketPDF({
  items: cartItems,
  subtotal: 28.48,
  discount: 0.15,
  total: 24.21,
  shippingCost: 0,
  serviceType: 'TABLE',
  tableName: 'Salon 05',
  timestamp: new Date().toISOString(),
});

// Returns: "file:///var/mobile/.../ticket_1234567890.pdf"

Telegram service

Sends PDF receipts to a Telegram chat using the Bot API. File: src/lib/services/telegramService.ts

Document upload

Uses multipart/form-data to upload PDF files:
import { Config } from '@/src/lib/core/config';

const TELEGRAM_API = 'https://api.telegram.org';

export async function sendTicketToTelegram(pdfUri: string): Promise<void> {
  const { botToken, chatId } = Config.telegram;

  if (!botToken || !chatId) {
    console.warn('[Telegram] Bot credentials not configured.');
    return;
  }

  try {
    const formData = new FormData();
    formData.append('chat_id', chatId);

    // React Native requires this specific object shape for file uploads
    formData.append('document', {
      uri: pdfUri,
      name: `ticket_orden_${Date.now()}.pdf`,
      type: 'application/pdf',
    } as unknown as Blob);

    formData.append(
      'caption',
      `Ticket de pago — ${Config.restaurant.name}\n${new Date().toLocaleString('es-ES')}`
    );

    const response = await fetch(
      `${TELEGRAM_API}/bot${botToken}/sendDocument`,
      {
        method: 'POST',
        headers: { 'Content-Type': 'multipart/form-data' },
        body: formData,
      }
    );

    if (!response.ok) {
      const body = await response.text();
      console.error(`[Telegram] sendDocument failed: ${response.status}`, body);
    }
  } catch (err) {
    // Silent failure — never block the payment success flow
    console.error('[Telegram] sendTicketToTelegram error:', err);
  }
}
Telegram delivery is non-blocking. If the upload fails, it’s logged to console but doesn’t affect the user’s payment success flow.

FormData structure

{
  chat_id: "-1001234567890",
  document: {
    uri: "file:///path/to/ticket.pdf",
    name: "ticket_orden_1234567890.pdf",
    type: "application/pdf"
  },
  caption: "Ticket de pago — Restaurant Name\n3/3/2026, 14:35:21"
}
API endpoint:
POST https://api.telegram.org/bot{TOKEN}/sendDocument
Content-Type: multipart/form-data

Integration flow

After successful payment, the app orchestrates PDF generation and Telegram upload:
// 1. Generate PDF
const pdfUri = await generateTicketPDF(ticketData);

// 2. Send to Telegram (non-blocking)
await sendTicketToTelegram(pdfUri);

// 3. Show local notification
await NotificationService.showPaymentSuccess(total);

// 4. Navigate to success screen
router.push('/success');

Error handling patterns

All services implement consistent error handling:
if (!token) {
  throw new Error('EXPO_PUBLIC_MAPBOX_TOKEN is not configured.');
}

if (!response.ok) {
  throw new Error(`Mapbox API error: ${response.status}`);
}

if (data.code !== 'Ok' || data.routes.length === 0) {
  throw new Error('No route found between the two points.');
}
Strategy: Throw errors immediately. Caller handles with try/catch.

Service testing

Services are pure functions that can be tested in isolation:
import { calculateShippingCost } from '@/src/lib/services/mapboxService';
import { generateTicketPDF } from '@/src/lib/services/pdfService';

describe('mapboxService', () => {
  it('calculates shipping cost correctly', () => {
    // Config.restaurant.costPerKm = 1.50
    expect(calculateShippingCost(7.42)).toBe(11.13);
    expect(calculateShippingCost(0)).toBe(0);
  });
});

describe('pdfService', () => {
  it('generates PDF with correct order ID format', async () => {
    const uri = await generateTicketPDF(mockTicketData);
    expect(uri).toMatch(/^file:\/\//);
  });
});
Mock external APIs in tests using jest.mock() or MSW (Mock Service Worker).

Configuration

All services read credentials from lib/core/config.ts, which loads from environment variables:
export const Config = {
  mapbox: {
    token: process.env.EXPO_PUBLIC_MAPBOX_TOKEN ?? '',
  },
  telegram: {
    botToken: process.env.EXPO_PUBLIC_TELEGRAM_BOT_TOKEN ?? '',
    chatId: process.env.EXPO_PUBLIC_TELEGRAM_CHAT_ID ?? '',
  },
  restaurant: {
    name: 'TableOrder Restaurant',
    costPerKm: 1.50,
    coordinates: { latitude: 40.7128, longitude: -74.0060 },
  },
};
See the Environment setup guide for configuration details.

Next steps

Architecture overview

Understand the overall system design

State management

Learn how Zustand stores work