The Vision: A POS That Just Works
Picture this: cashier adds products to cart → clicks "Card Payment" → terminal shows the amount → customer taps card → sale auto-saves in our software, receipt prints, inventory updates. All in under 3 seconds.
This is what I built for CAAD ERP with Geidea payment terminals.
The Core Challenge
Traditional POS integration takes 5-10 seconds with polling:
- 1Cashier clicks "Charge"
- 2Software sends request to payment gateway
- 3Gateway processes card
- 4Software polls gateway for status
- 5Software receives "approved"
- 6Sale saves
My solution: WebSocket-driven real-time sync that completes in 2-3 seconds.
Architecture Overview
[POS Frontend] --WebSocket--> [NestJS Backend] --WebSocket--> [Geidea Terminal]
↓ ↓
UI Update Sale Record
Receipt Print Inventory UpdateBackend: WebSocket Gateway
@WebSocketGateway({ cors: { origin: '*' } })
export class PaymentGateway {
@WebSocketServer()
server: Server;
private geideaWS: WebSocket;
private pendingTransactions = new Map<string, Subject<any>>();
async handleConnection(client: Socket) {
// Authenticate connection
const token = client.handshake.auth.token;
const user = await this.validateToken(token);
client.data.user = user;
}
@SubscribeMessage('process_payment')
async processPayment(client: Socket, payload: any) {
const transactionId = uuid();
const subject = new Subject();
this.pendingTransactions.set(transactionId, subject);
// Set 60 second timeout for customer to tap card
const timeout = setTimeout(() => {
this.pendingTransactions.delete(transactionId);
subject.error(new Error('Payment timeout - customer did not tap card'));
}, 60000);
this.pendingTransactions.get(transactionId)!
.pipe(take(1))
.subscribe({
next: (result) => {
clearTimeout(timeout);
resolve(result);
},
error: (err) => {
clearTimeout(timeout);
reject(err);
}
});
// Send to Geidea terminal
this.geideaWS.send(JSON.stringify({
action: 'PUSH_TRANSACTION',
transactionId,
amount: payload.amount,
currency: 'AED'
}));
}
private handleGeideaMessage(msg: any) {
const subject = this.pendingTransactions.get(msg.transactionId);
if (!subject) return;
switch (msg.status) {
case 'APPROVED':
subject.next({
success: true,
transactionId: msg.geideaTransactionId,
authCode: msg.authCode,
cardLast4: msg.cardLast4
});
subject.complete();
this.pendingTransactions.delete(msg.transactionId);
break;
case 'DECLINED':
subject.error(new Error(`Card declined - ${msg.reason}`));
this.pendingTransactions.delete(msg.transactionId);
break;
case 'CANCELLED':
subject.error(new Error('Customer cancelled'));
this.pendingTransactions.delete(msg.transactionId);
break;
}
}
}Sale Transaction Endpoint
@Post('sales/process')
@UseGuards(JwtAuthGuard)
async processSale(@Body() saleData: CreateSaleDto, @Req() req) {
const saleId = uuid();
const sale = await this.saleModel.create({
_id: saleId,
...saleData,
status: 'PENDING_PAYMENT',
cashier: req.user.id,
timestamp: new Date()
});
try {
const paymentResult = await this.paymentGateway.processPayment({
amount: saleData.total,
items: saleData.items
});
const completedSale = await this.saleModel.findByIdAndUpdate(
saleId,
{
$set: {
status: 'COMPLETED',
paymentResult,
completedAt: new Date()
}
},
{ new: true }
);
// Async: print receipt, update inventory
this.printService.printReceipt(completedSale)
.catch(err => this.logger.error('Print failed', err));
this.inventoryService.decrementStock(saleData.items)
.catch(err => this.logger.error('Stock update failed', err));
return completedSale;
} catch (paymentError) {
await this.saleModel.findByIdAndUpdate(saleId, {
$set: {
status: 'FAILED',
failureReason: paymentError.message,
failedAt: new Date()
}
});
throw paymentError;
}
}WebSocket Reconnection Logic
Terminal connections drop. Handle it gracefully:
class TerminalConnection {
private reconnectAttempts = 0;
private maxReconnectAttempts = 10;
setupReconnection() {
this.ws.on('close', () => {
if (this.reconnectAttempts < this.maxReconnectAttempts) {
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
setTimeout(() => {
this.reconnectAttempts++;
this.connect();
}, delay);
} else {
this.alertOps('Payment terminal offline for 10+ minutes');
}
});
this.ws.on('open', () => {
this.reconnectAttempts = 0;
});
}
}Real Production Numbers
After deploying this across 5 retail stores:
- Average sale completion: 2.8 seconds
- Customer satisfaction: +40% (vs old 8-second wait)
- Cashier training time: 30 minutes (vs 2 hours)
- Failed transactions: 0.3% (industry average is 2%)
Wrapping Up
Real-time POS integration is the difference between a frustrating checkout and a delightful one. Customers notice the 3-second sale vs the 8-second sale.
Key takeaways:
- WebSockets for real-time payment sync
- Pending transaction map for request/response correlation
- Graceful reconnection with exponential backoff
- Automatic inventory + receipt printing after payment
This pattern is now in production serving 20K+ users across UAE.