A Jaipur-based travel aggregator running 90 agents across Rajasthan and Delhi NCR — 28,000 yearly bookings, ₹84 cr GMV — had been waiting 18 months to replace their patchwork CRM. The window opened on October 22, 2025 (the day after Bhai Dooj). Travel demand collapses in the post-Diwali, pre-winter-honeymoon slow window — Oct 22 to about Nov 14 is the deadest 3 weeks of the Indian travel calendar. Their CEO called us: "ship it before the December rush." We did. 4 core modules in 3 weeks, full data migration on day 19, soft launch on day 21. Here is the sprint plan, the 4-module phased rollout, and the data-migration script we use on every CRM project.
TL;DR (60 Words)
3-week sprint, 4 modules shipped first: lead capture, itinerary builder, booking + payment, customer 360. Phased rollout: pilot at the Jaipur HQ on day 16, satellite offices on day 21. Data migration: a Node script that pulled 67,000 historical bookings from their old MySQL CRM, normalised to our Postgres schema, ran nightly for the 6-day overlap window. Year-1 ROI: savings on Zoho + manual rework.
Why The Post-Diwali Window Is The Right Time
For Indian travel, Oct 22 to Nov 14 is the deadest 3 weeks of the calendar. Diwali ends. Winter honeymoon and December tour bookings start in earnest from Nov 15. The 3-week window between is when agents have time to learn a new tool. We have shipped 4 travel CRMs in this exact window across 3 years; the adoption curve is dramatically gentler than during peak season. If you can plan for it, the post-Diwali calendar is the ideal CRM cutover window for any Indian travel business.
The Client (Specific Details)
The 4 Modules We Built First (Phase 1)
The Architecture
The 3-Week Sprint Plan (Day-by-Day)
The Data-Migration Script (The Reusable Part)
This is the script we run on every CRM data migration. It is the most-asked-for snippet from our CRM clients.
// migrate-bookings.js - the nightly migration during the 6-day overlap window
const PROD_DELTA_HOURS = 24; // pull yesterday's changes only
const BATCH_SIZE = 5000;
async function migrateBookings() {
// Capture watermark BEFORE pulling
const lastSyncRaw = await fs.readFile('.last-sync', 'utf-8').catch(() => null);
const lastSync = lastSyncRaw ? new Date(lastSyncRaw) : new Date('2010-01-01');
const newSyncMark = new Date();
console.log(`Pulling bookings updated since ${lastSync.toISOString()}`);
// Pull from old MySQL CRM in batches (LIMIT/OFFSET on a sortable key)
let offset = 0;
let total = 0;
while (true) {
const oldBookings = await mysqlPool.query(`
SELECT b.*, c.phone, c.email, c.name as customer_name
FROM bookings b
LEFT JOIN customers c ON b.customer_id = c.id
WHERE b.updated_at > ?
ORDER BY b.id
LIMIT ? OFFSET ?
`, [lastSync, BATCH_SIZE, offset]);
if (oldBookings.length === 0) break;
// Idempotent upsert into the new Postgres CRM
for (const b of oldBookings) {
await pgPool.query(`
INSERT INTO bookings (
legacy_id, customer_phone, itinerary_json, total_amount,
payment_status, created_at, updated_at, agent_id, source
) VALUES ($1, $2, $3, $4, $5, $6, $7,
(SELECT id FROM agents WHERE legacy_id = $8),
$9
)
ON CONFLICT (legacy_id) DO UPDATE SET
itinerary_json = EXCLUDED.itinerary_json,
total_amount = EXCLUDED.total_amount,
payment_status = EXCLUDED.payment_status,
updated_at = EXCLUDED.updated_at
`, [
b.id, b.phone,
JSON.stringify(parseLegacyItinerary(b.itinerary_blob)),
b.total_amount, b.payment_status,
b.created_at, b.updated_at,
b.agent_id, b.source || 'phone',
]);
}
total += oldBookings.length;
offset += BATCH_SIZE;
console.log(`Migrated ${total} so far`);
}
// Persist the watermark so next nightly run picks up correctly
await fs.writeFile('.last-sync', newSyncMark.toISOString());
console.log(`Done. Total: ${total}. Next sync watermark: ${newSyncMark.toISOString()}`);
} Three things to notice. The watermark is captured before pulling so we never miss rows updated during the migration. The upsert uses a legacy_id column so re-runs are idempotent. The itinerary blob — stored as a custom binary format in the old CRM — gets parsed into structured JSON we can query in Postgres. We have run variants of this script on 14 client migrations.
What Happened in Week 1 of the New System
The 14-Day Support Window (What We Build In)
Every CRM cutover comes with a 14-day support window where one of us is on WhatsApp full-time during business hours and a second engineer is on-call evening + weekend. Volume curve over 14 days, from this client and 9 others:
Pre-Cutover Decision Checklist
- Discovery sprint (2 days on-site at HQ + at least one satellite office)
- 4-module Phase 1 scope signed off on a single A4 page by CEO + CFO + ops manager
- Postgres schema with row-level RBAC matrix signed off in writing
- Data-migration script tested on a staging copy of the old CRM
- 6-day overlap window planned in the operational calendar (no festival, no month-end)
- Round-robin lead-assignment Redis lock load-tested at 6x projected peak
- WhatsApp Business API templates approved by Meta 2 weeks before cutover
- Razorpay UPI Intent + bank transfer reconciliation tested with sample bookings
- Pilot day-1 + day-2 schedule with all 3 engineers on-site
- 14-day support window staffed with one engineer on WhatsApp during business hours
Common Mistakes (Each One Hurts)
Symptom: "Migration runs forever and breaks the old CRM." Cause: pulling all rows in a single query without batching. Fix: batch by primary key, run nightly during the overlap window, capture watermarks.
Symptom: "Round-robin assignment skips agents." Cause: race condition in the assignment counter. Fix: Redis SETNX lock per-team, with a 30-second TTL.
Symptom: "Agents revert to the old CRM after week 2." Cause: the new CRM is technically better but slower for the most-used workflow. Fix: instrument every workflow with timing in week 1; optimise the top-3 to be at least as fast as the old one.
Symptom: "Razorpay webhook fires twice and creates duplicate payments." Cause: webhook retry without idempotency key. Fix: every webhook handler is idempotent on the Razorpay payment_id.
Symptom: "Itinerary PDF generation crashes the server." Cause: Puppeteer instance not pooled, every PDF spins a new Chromium. Fix: pool of 4 Puppeteer instances; queue if all busy.
When 3 Weeks Is The Wrong Timeline
Skip the 3-week sprint if (a) you have more than 8 must-have modules — split into 2 phased sprints, (b) your old CRM has more than 200,000 bookings — migration alone needs more time, or (c) your team has not done a CRM cutover before — the support burden is higher than expected. We have done 3-week sprints on 4 travel CRMs of this size; below 60 agents it shrinks to 2 weeks, above 120 agents it stretches to 5 weeks.
A Detail That Saved Us On Day 19
On day 19 (the pilot day at Jaipur HQ) the round-robin lead-assignment skipped agent #62 every time. We had tested round-robin extensively. The bug was a 1-off in the modulo calculation when the agent count was a power of 2 plus one. Found it in 18 minutes by adding logging to the assignment function and watching live. Fixed in 4 minutes. The lesson: the agent who feels skipped will tell you in the first 30 minutes of pilot. Listen to the human signal before trusting the dashboard.
FAQ
Why did you pick the post-Diwali window?
Travel demand drops to 25% of normal between Bhai Dooj (Oct 22) and the start of December tour bookings (Nov 14-18). Agents have 3 weeks of bandwidth to learn a new tool. We have used this window 4 times on travel CRM cutovers; the adoption curve is gentler than during peak.
How does the data-migration script handle deleted records?
The old CRM had a soft-delete pattern (deleted_at column). We migrate soft-deleted records into a separate "archive" schema rather than discard. Useful for audit and the rare "the customer says they booked with us in 2019" scenario.
What about supplier integrations (hotels, transport)?
Phase 1 did not integrate with supplier APIs. The aggregator works mostly with regional hotel chains that confirm bookings via WhatsApp + email. Phase 2 (planned for Q1 2026) integrates with HotelHub and TBO. We deferred to keep the Phase 1 scope tight.
How did you handle the Hindi/English UI?
Single i18n setup with next-intl. Every UI string has both Hindi and English. Agents toggle their preference per-account. Form labels and help text are translated; data fields (customer name, hotel name) are stored in their original language without translation.
What is the row-level RBAC pattern?
Postgres views per agent that filter rows by ownership. Senior agents have an additional "team_lead" claim that widens the filter. CEO and CFO have a "global_read" claim. Every claim is auditable and the audit log captures every elevated read.
How did you handle the existing WhatsApp thread history?
The old CRM stored WhatsApp messages in a JSON blob per booking. We parsed and migrated 1.2 million messages into a thread-per-customer schema in Postgres. This took 4 hours in the migration window. The customer 360 view shows the full thread with the new threads appended.
What was the team for this build?
Three full-stack engineers (one lead on schema + lead-assignment, one on itinerary builder, one on payment + reconciliation), our designer at 0.4 FTE for the Hindi UI strings and the itinerary PDF template, our QA lead Manvi at 0.5 FTE for the pilot day. Our CTO Hrishikesh at 0.3 FTE for architecture review.
Want a 3-Week CRM Sprint For Your Travel Business?
Need a 3-Week CRM Sprint for Your Travel/Tour Business?
We run 3-week CRM sprints for Indian travel agencies and tour operators in the 30-150 agent range. Phase 1 covers the 4 most-used modules at a fixed price. We do the data migration, the cutover, and the 14-day support window. First call is with the engineer who would lead your build.
Book a 30-min Sprint CallRelated reading: our 7-branch manufacturing CRM cost math, the 240-agent real-estate CRM build, the Radiant Finance lead pipeline, and our CRM development service.
For more on the build-vs-buy decision from a founder's perspective, our founder Vivek Singh has written on the same beat. Email contact@softechinfra.com to receive the data-migration-script template before the call.
