Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
198 changes: 198 additions & 0 deletions __tests__/commerce/order-summary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import { groupOrders, itemsPreview, parseOrderLine, paymentKey, placeLine, receiptText, summarizeOrder } from '../../src/commerce/order-summary';

const order = (overrides: any = {}) => ({
id: 'order_1',
status: 'driver_enroute',
created_at: '2026-10-01T10:00:00Z',
tracking_number: { tracking_number: 'SF-1001' },
notes: ' Leave at door ',
meta: {
storefront: 'Rochor Noodle House',
storefront_id: 'store_1',
subtotal: 2400,
delivery_fee: '399',
tip: 200,
delivery_tip: 100,
discount: 300,
promotions: [
{ name: 'Welcome', code: 'HELLO', amount: 200, delivery_amount: 100 },
{ name: null, code: null },
],
total: 2799,
currency: 'SGD',
gateway: 'stripe',
is_pickup: false,
},
payload: {
pickup: { name: 'Rochor Noodle House', street1: '1 Rochor Rd' },
dropoff: { name: 'Home', street1: '612 Hougang Ave 8' },
entities: [
{
id: 'entity_1',
name: 'Laksa',
photo_url: 'https://img/laksa.jpg',
meta: { quantity: 2, subtotal: 1600, variants: [{ name: 'Large' }], addons: [{ name: 'Extra prawns' }, { name: '' }] },
},
{ id: 'entity_2', name: 'Kopi', price: 400, meta: { quantity: '2' } },
],
},
...overrides,
});

describe('summarizeOrder', () => {
it('reads what checkout recorded', () => {
const summary = summarizeOrder(order());
expect(summary).toMatchObject({
id: 'order_1',
reference: 'SF-1001',
storeName: 'Rochor Noodle House',
storeId: 'store_1',
phase: 'onTheWay',
active: true,
canceled: false,
isPickup: false,
currency: 'SGD',
itemCount: 4,
subtotal: 2400,
deliveryFee: 399,
tip: 200,
deliveryTip: 100,
discount: 300,
total: 2799,
gateway: 'stripe',
address: 'Home, 612 Hougang Ave 8',
notes: 'Leave at door',
});
expect(summary.promotions).toEqual([{ name: 'Welcome', code: 'HELLO', amount: 300 }]);
expect(summary.lines[0]).toEqual({
id: 'entity_1',
name: 'Laksa',
quantity: 2,
subtotal: 1600,
imageUrl: 'https://img/laksa.jpg',
options: ['Large', 'Extra prawns'],
scheduledAt: null,
});
expect(summary.lines[1]).toMatchObject({ quantity: 2, subtotal: 800, imageUrl: null, options: [] });
});

it('handles pickup, finished and canceled orders', () => {
const pickup = summarizeOrder(order({ status: 'completed', meta: { is_pickup: 1, delivery_fee: 500, storefront: null } }));
expect(pickup).toMatchObject({ isPickup: true, phase: 'collected', active: false, deliveryFee: 0, address: 'Rochor Noodle House, 1 Rochor Rd', storeName: 'Rochor Noodle House' });

const canceled = summarizeOrder(order({ status: 'canceled' }));
expect(canceled).toMatchObject({ canceled: true, active: false });
});

it('copes with missing data', () => {
const empty = summarizeOrder({});
expect(empty).toMatchObject({
id: '',
reference: '',
storeName: '',
lines: [],
itemCount: 0,
total: 0,
currency: null,
gateway: null,
address: null,
notes: null,
phase: 'placed',
active: true,
});
expect(summarizeOrder({ id: 'order_2', tracking: 'TRK', payload: { payment_method: 'qpay' }, meta: { total: 'MNT 1,000' } })).toMatchObject({
reference: 'TRK',
gateway: 'qpay',
total: 1000,
});
expect(summarizeOrder({ id: 'order_3', meta: { total: 'n/a' } }).total).toBe(0);
});
});

describe('order lines', () => {
it('defaults the quantity and uses the price when there is no subtotal', () => {
expect(parseOrderLine({ name: 'Tea', price: 300, meta: { image_url: 'https://img/tea.jpg', scheduled_at: '2026-10-02 09:00', variants: [{ label: 'Hot' }] } }, 3)).toEqual({
id: '3',
name: 'Tea',
quantity: 1,
subtotal: 300,
imageUrl: 'https://img/tea.jpg',
options: ['Hot'],
scheduledAt: '2026-10-02 09:00',
});
expect(parseOrderLine(null).name).toBe('');
});

it('previews what was ordered', () => {
const lines = summarizeOrder(order()).lines;
const more = (count: number) => `and ${count} more`;
expect(itemsPreview(lines, more)).toBe('2× Laksa, 2× Kopi');
expect(itemsPreview([...lines, parseOrderLine({ name: 'Cake' })], more)).toBe('2× Laksa, 2× Kopi and 1 more');
expect(itemsPreview([], more)).toBe('');
});
});

describe('placeLine', () => {
it('joins the name and street without repeating them', () => {
expect(placeLine({ name: 'Home', street1: 'Home' })).toBe('Home');
expect(placeLine({ address: '1 Main St, Singapore' })).toBe('1 Main St, Singapore');
expect(placeLine({})).toBeNull();
expect(placeLine(null)).toBeNull();
});
});

describe('groupOrders', () => {
it('pins orders in progress and sorts each group newest first', () => {
const a = summarizeOrder(order({ id: 'a', status: 'completed', created_at: '2026-09-01T00:00:00Z' }));
const b = summarizeOrder(order({ id: 'b', status: 'completed', created_at: '2026-09-05T00:00:00Z' }));
const c = summarizeOrder(order({ id: 'c', status: 'preparing', created_at: '2026-09-02T00:00:00Z' }));
const d = summarizeOrder(order({ id: 'd', status: 'created', created_at: null }));
const { active, past } = groupOrders([a, b, c, d, b, summarizeOrder({})]);
expect(active.map((entry) => entry.id)).toEqual(['c', 'd']);
expect(past.map((entry) => entry.id)).toEqual(['b', 'a']);
});
});

describe('paymentKey', () => {
it.each([
['stripe', 'card'],
['QPay', 'qpay'],
['paypal', 'paypal'],
['cash', 'cash'],
['cod', 'cash'],
['other', 'other'],
[null, 'other'],
])('%s → %s', (gateway, key) => expect(paymentKey(gateway)).toBe(key));
});

describe('receiptText', () => {
const t = (key: string, params?: Record<string, unknown>) => (params ? `${key} ${JSON.stringify(params)}` : key);
const money = (value: number) => `$${(value / 100).toFixed(2)}`;

it('lists the lines and every charge that applies', () => {
const text = receiptText(summarizeOrder(order()), { t, money, date: 'Oct 1, 2026' });
expect(text.split('\n')).toEqual([
'Rochor Noodle House',
'Receipt.orderNumber {"number":"SF-1001"}',
'Oct 1, 2026',
'',
'2 × Laksa (Large, Extra prawns) $16.00',
'2 × Kopi $8.00',
'',
'Receipt.subtotal: $24.00',
'Receipt.deliveryFee: $3.99',
'Receipt.tip: $2.00',
'Receipt.driverTip: $1.00',
'Receipt.discount: −$3.00',
'Receipt.total: $27.99',
]);
});

it('leaves out charges that are zero and a missing date', () => {
const text = receiptText(summarizeOrder(order({ meta: { subtotal: 500, total: 500, is_pickup: true } })), { t, money, date: null });
expect(text).not.toContain('deliveryFee');
expect(text).not.toContain('tip');
expect(text).not.toContain('discount');
expect(text.split('\n')[2]).toBe('');
});
});
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"test": "jest",
"test:ci": "jest --runInBand",
"lint": "eslint .",
"lint:strict": "eslint src/ui storefront.extensions.ts storefront.brand.ts src/env.ts src/branding src/hooks/use-app-theme.ts src/contexts/ThemeContext.tsx src/extensions src/navigation/NetworkNavigator.tsx src/navigation/AppNavigator.tsx src/network src/screens/network src/navigation/NetworkNavigator.tsx src/contexts/StorefrontRuntimeContext.tsx src/contexts/CartContext.tsx src/screens/BootScreen.tsx src/screens/LocationPermissionScreen.tsx src/commerce src/components/checkout src/components/booking src/hooks/use-cart-promotions.ts src/hooks/use-review-request.ts src/screens/reviews src/screens/offers src/components/offers src/screens/notifications src/screens/chat src/hooks/use-chat-request.ts src/hooks/use-customer-request.ts src/hooks/use-open-notification.ts src/hooks/use-unread-notifications.ts src/hooks/use-footer-offset.ts src/screens/CartScreen.tsx src/screens/OrderScreen.tsx src/screens/StripeCheckoutScreen.tsx src/screens/QPayCheckoutScreen.tsx web/react-native-mmkv-storage.js web/react-native-device-info.web.js __tests__ src/screens/auth src/screens/account src/components/auth src/navigation/auth-redirect.ts",
"lint:strict": "eslint src/ui storefront.extensions.ts storefront.brand.ts src/env.ts src/branding src/hooks/use-app-theme.ts src/contexts/ThemeContext.tsx src/extensions src/navigation/NetworkNavigator.tsx src/navigation/AppNavigator.tsx src/network src/screens/network src/navigation/NetworkNavigator.tsx src/contexts/StorefrontRuntimeContext.tsx src/contexts/CartContext.tsx src/screens/BootScreen.tsx src/screens/LocationPermissionScreen.tsx src/commerce src/components/checkout src/components/booking src/hooks/use-cart-promotions.ts src/hooks/use-review-request.ts src/screens/reviews src/screens/offers src/components/offers src/screens/notifications src/screens/chat src/hooks/use-chat-request.ts src/hooks/use-customer-request.ts src/hooks/use-open-notification.ts src/hooks/use-unread-notifications.ts src/hooks/use-footer-offset.ts src/screens/CartScreen.tsx src/screens/OrderScreen.tsx src/screens/StripeCheckoutScreen.tsx src/screens/QPayCheckoutScreen.tsx web/react-native-mmkv-storage.js web/react-native-device-info.web.js __tests__ src/screens/auth src/screens/account src/components/auth src/navigation/auth-redirect.ts src/screens/orders src/navigation/stacks/OrderStack.tsx",
"typecheck:strict": "tsc --noEmit -p tsconfig.strict.json",
"format": "prettier --write .",
"tamagui:build": "./node_modules/.bin/tamagui-build",
Expand Down
184 changes: 184 additions & 0 deletions src/commerce/order-summary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { orderProgress, type OrderPhase } from './order-progress';

/**
* Plain summaries of a storefront order (the Fleetbase order resource) for the order
* history and the receipt. Checkout records what the customer saw in `meta`:
* `storefront`, `storefront_id`, `subtotal`, `delivery_fee`, `tip`, `delivery_tip`,
* `discount`, `promotions`, `total`, `currency`, `gateway` and `is_pickup`. Each line
* is a payload entity whose `meta` holds `quantity`, `subtotal`, `variants` and `addons`.
*/

export type OrderLine = {
id: string;
name: string;
quantity: number;
subtotal: number;
imageUrl: string | null;
/** Chosen variant options and add-ons, e.g. ["Large", "Extra shot"]. */
options: string[];
scheduledAt: string | null;
};

export type OrderSummary = {
id: string;
reference: string;
storeName: string;
storeId: string | null;
createdAt: string | null;
status: string;
phase: OrderPhase;
active: boolean;
canceled: boolean;
isPickup: boolean;
currency: string | null;
lines: OrderLine[];
itemCount: number;
subtotal: number;
deliveryFee: number;
tip: number;
deliveryTip: number;
discount: number;
promotions: { name: string; code: string | null; amount: number }[];
total: number;
gateway: string | null;
address: string | null;
notes: string | null;
};

const amount = (value: unknown): number => {
const number = typeof value === 'string' ? Number(value.replace(/[^\d.-]/g, '')) : Number(value);
return Number.isFinite(number) ? number : 0;
};

const text = (value: unknown): string | null => (typeof value === 'string' && value.trim() ? value.trim() : null);

function optionNames(meta: any): string[] {
const names: string[] = [];
for (const variant of Array.isArray(meta?.variants) ? meta.variants : []) {
const name = text(variant?.name) ?? text(variant?.label);
if (name) names.push(name);
}
for (const addon of Array.isArray(meta?.addons) ? meta.addons : []) {
const name = text(addon?.name);
if (name) names.push(name);
}
return names;
}

export function parseOrderLine(entity: any, index = 0): OrderLine {
const meta = entity?.meta ?? {};
const quantity = Math.max(1, Math.round(amount(meta.quantity)) || 1);
return {
id: String(entity?.id ?? entity?.public_id ?? index),
name: text(entity?.name) ?? '',
quantity,
subtotal: meta.subtotal != null ? amount(meta.subtotal) : amount(entity?.price) * quantity,
imageUrl: text(entity?.photo_url) ?? text(meta.image_url),
options: optionNames(meta),
scheduledAt: text(meta.scheduled_at),
};
}

/** One line for an address: name and street, falling back to the full address. */
export function placeLine(place: any): string | null {
if (!place) return null;
const parts = [text(place.name), text(place.street1)].filter(Boolean) as string[];
if (parts.length) return [...new Set(parts)].join(', ');
return text(place.address);
}

export function summarizeOrder(json: any): OrderSummary {
const meta = json?.meta ?? {};
const payload = json?.payload ?? {};
const isPickup = meta.is_pickup === true || meta.is_pickup === 1 || meta.is_pickup === '1';
const status = String(json?.status ?? 'created');
const progress = orderProgress({ status, isPickup, trackingStatuses: json?.tracking_statuses ?? [], createdAt: json?.created_at ?? null });
const lines = (Array.isArray(payload.entities) ? payload.entities : []).map(parseOrderLine);

return {
id: String(json?.id ?? json?.public_id ?? ''),
reference: text(json?.tracking_number?.tracking_number) ?? text(json?.tracking) ?? String(json?.id ?? ''),
storeName: text(meta.storefront) ?? text(payload.pickup?.name) ?? '',
storeId: text(meta.storefront_id),
createdAt: text(json?.created_at),
status,
phase: progress.phase,
active: !progress.finished && !progress.canceled,
canceled: progress.canceled,
isPickup,
currency: text(meta.currency),
lines,
itemCount: lines.reduce((sum: number, line: OrderLine) => sum + line.quantity, 0),
subtotal: amount(meta.subtotal),
deliveryFee: isPickup ? 0 : amount(meta.delivery_fee),
tip: amount(meta.tip),
deliveryTip: amount(meta.delivery_tip),
discount: amount(meta.discount),
promotions: (Array.isArray(meta.promotions) ? meta.promotions : [])
.map((promotion: any) => ({
name: text(promotion?.name) ?? text(promotion?.code) ?? '',
code: text(promotion?.code),
amount: amount(promotion?.amount) + amount(promotion?.delivery_amount),
}))
.filter((promotion: { name: string }) => promotion.name),
total: amount(meta.total),
gateway: text(meta.gateway) ?? text(payload.payment_method),
address: placeLine(isPickup ? payload.pickup : payload.dropoff),
notes: text(json?.notes),
};
}

/** "Latte, Croissant and 2 more" style preview of what was ordered. */
export function itemsPreview(lines: OrderLine[], more: (count: number) => string, shown = 2): string {
const names = lines.map((line) => (line.quantity > 1 ? `${line.quantity}× ${line.name}` : line.name)).filter(Boolean);
if (names.length <= shown) return names.join(', ');
return `${names.slice(0, shown).join(', ')} ${more(names.length - shown)}`;
}

/** Orders still in progress first (newest first), then the rest newest first. */
export function groupOrders(orders: OrderSummary[]): { active: OrderSummary[]; past: OrderSummary[] } {
const newest = (a: OrderSummary, b: OrderSummary) => (b.createdAt ? Date.parse(b.createdAt) : 0) - (a.createdAt ? Date.parse(a.createdAt) : 0);
const unique = [...new Map(orders.filter((order) => order.id).map((order) => [order.id, order])).values()];
return { active: unique.filter((order) => order.active).sort(newest), past: unique.filter((order) => !order.active).sort(newest) };
}

/** Translation key for how the order was paid. */
export function paymentKey(gateway: string | null): string {
switch ((gateway ?? '').toLowerCase()) {
case 'stripe':
return 'card';
case 'qpay':
return 'qpay';
case 'paypal':
return 'paypal';
case 'cash':
case 'cod':
return 'cash';
default:
return 'other';
}
}

/** The receipt as plain text, for sharing. */
export function receiptText(
summary: OrderSummary,
{ t, money, date }: { t: (key: string, params?: Record<string, unknown>) => string; money: (value: number) => string; date: string | null }
): string {
const lines = [
summary.storeName,
t('Receipt.orderNumber', { number: summary.reference }),
date,
'',
...summary.lines.map((line) => `${line.quantity} × ${line.name}${line.options.length ? ` (${line.options.join(', ')})` : ''} ${money(line.subtotal)}`),
'',
`${t('Receipt.subtotal')}: ${money(summary.subtotal)}`,
summary.deliveryFee ? `${t('Receipt.deliveryFee')}: ${money(summary.deliveryFee)}` : null,
summary.tip ? `${t('Receipt.tip')}: ${money(summary.tip)}` : null,
summary.deliveryTip ? `${t('Receipt.driverTip')}: ${money(summary.deliveryTip)}` : null,
summary.discount ? `${t('Receipt.discount')}: −${money(summary.discount)}` : null,
`${t('Receipt.total')}: ${money(summary.total)}`,
];
return lines.filter((line) => line !== null).join('\n');
}

export const ORDER_PAGE_SIZE = 20;
Loading