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
{
"routes" : [{
"distance" : 7420.3 ,
"duration" : 1080 ,
"geometry" : "..encoded polyline..."
}],
"code" : "Ok"
}
Processing:
Distance: 7420.3 m → 7.42 km
Duration: 1080 s → 18 minutes (rounded up)
Geometry: Decoded to array of coordinates
{
distanceKm : 7.42 ,
etaMinutes : 18 ,
polyline : "..." ,
decodedRoute : [
{ latitude: 40.7128 , longitude: - 74.0060 },
{ latitude: 40.7135 , longitude: - 74.0055 },
// ... hundreds more points
{ latitude: 40.7589 , longitude: - 73.9851 }
]
}
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>...` ;
}
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.
{
chat_id : "-1001234567890" ,
document : {
uri : "file:///path/to/ticket.pdf" ,
name : "ticket_orden_1234567890.pdf" ,
type : "application/pdf"
},
caption : "Ticket de pago — Restaurant Name \n 3/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.export async function generateTicketPDF ( data : TicketData ) : Promise < string > {
const html = buildTicketHTML ( data );
const { uri } = await Print . printToFileAsync ({ html , base64: false });
return uri ;
}
Strategy: Let expo-print errors propagate. Caller handles failure.try {
// ... upload logic
} catch ( err ) {
// Silent failure — never block the payment success flow
console . error ( '[Telegram] sendTicketToTelegram error:' , err );
}
Strategy: Catch and log errors silently. Never interrupt user flow.
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