plans
2026-05-03
2026 05 03 Notifications Crm

Notifications & CRM Sync Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Implement transactional email and WhatsApp notifications for customers and admin, plus Zoho CRM/Books integration for lead capture and invoicing. These are the post-payment revenue operations — confirming bookings, capturing leads, and notifying Hamza of operational issues.

Architecture:

  • Email: Resend (already configured per TFB — resend package)
  • WhatsApp: WhatsApp Business API (already has an existing API integration per TFB)
  • CRM/Books: Zoho CRM + Zoho Books REST API with OAuth2

Tech Stack: Convex mutations (triggers), Resend SDK, WhatsApp Business API, Zoho OAuth2.


Business Summary

What this does: Implements transactional email and WhatsApp notifications for booking confirmations, cancellations, and refunds. Also syncs all bookings to Zoho CRM (contacts, deals) and Zoho Books (invoices) for Hamza's sales pipeline and accounting.

Why it matters: Confirms bookings instantly via email/WhatsApp — reducing no-shows and building guest confidence. Captures every guest as a lead in Zoho CRM for follow-up marketing. Creates invoices automatically for accounting accuracy. Alerts Hamza to low-occupancy shows 24h before so he can take action.

Time to implement: 6-10 days | Complexity: Medium

Dependencies: Requires booking-flow (VNPay payment confirmation triggers notifications), show-system (show titles for email content). Can implement Zoho CRM/Books stubs in parallel with email/WhatsApp.


Context & Key Constraints

P0 RULE — No staffMutation/adminMutation: These wrappers do not exist in convex/auth.ts. Only getCurrentUser, upsertUser, and isAdmin exist. For staff/admin mutations, use plain mutation with role checks inside the handler:

import { mutation } from "convex/_generated/server";
export const someMutation = mutation({
  args: { ... },
  handler: async (ctx, args) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) throw new Error("UNAUTHORIZED");
    const user = await ctx.db.query("users").withIndex("by_email", ...).first();
    if (!user || (user.role !== "ADMIN" && user.role !== "STAFF")) throw new Error("UNAUTHORIZED");
    // ...
  },
});

P0 RULE — No getTranslations in Convex: getTranslations from next-intl/server is a Next.js server-side function and cannot run inside Convex functions. Translation and HTML email rendering must happen in a Next.js context (API route or Server Component), then pass pre-rendered content to Convex for sending. Convex functions accept locale, subject, html, and recipient as pre-built strings.

P1 RULE — Structured logging: Use consola instead of console.log. Import: import { consola } from "consola";

P1 RULE — All user-facing strings use translation keys: Error messages, email subjects, and admin copy must use translation key patterns — never hardcoded strings in shared code.


Context & Business Logic

Notification triggers:

TriggerRecipientChannelPriority
Payment confirmedCustomerEmail + WhatsAppP0
Booking cancelledCustomerEmail + WhatsAppP0
Refund processedCustomerEmailP0
New bookingAdminEmailP1
D-1 low occupancy alertAdminEmail + WhatsAppP1
Occurrence auto-cancelledCustomerEmailP0

CRM triggers:

EventActionPriority
Booking confirmed (PAID)Create/Update Contact in Zoho CRMP0
Booking confirmed (PAID)Create Deal in Zoho CRMP0
Payment receivedUpdate Deal stage in Zoho CRMP0
Booking cancelledUpdate Deal status in Zoho CRMP1

Data mapping:

  • Customer: firstName, lastName, email, phone → Zoho Contact
  • Booking: show title, date, ticket type, quantity, total → Zoho Deal
  • Invoice: line items, customer, paid status → Zoho Books Invoice

Email rendering architecture: Convex functions handle data fetching and API calls. Translation + HTML rendering happens in Next.js server actions (which have access to next-intl/server). Pre-rendered HTML and subject lines are passed to Convex sendEmail mutations for sending.


File Map

apps/backend/convex/
├── schema.ts                    # MODIFY — add notificationLogs, zohoSyncLogs tables
├── functions/
│   ├── notifications.ts         # Email + WhatsApp sending via direct SDK calls (CREATE)
│   └── crm-sync.ts             # Zoho CRM + Books sync via direct API calls (CREATE)

apps/frontend/lib/
├── resend.ts          # Resend client config (CREATE)
├── whatsapp.ts        # WhatsApp client config (CREATE)
└── zoho.ts           # Zoho OAuth2 client (CREATE)

.env.example           # Add Zoho + Resend env vars

Architecture: All backend logic runs in Convex mutations with direct SDK/API calls. No HTTP fetch to Next.js API routes needed for frontend-only operations.


Phase 1: Email Notifications (Resend)

Task 1: Set Up Resend Client

Files:

  • Create: apps/frontend/lib/resend.ts

  • Modify: .env.example

  • Step 1: Install Resend

npm install resend
  • Step 2: Create Resend client
// apps/frontend/lib/resend.ts
import { Resend } from "resend";
 
export const resend = new Resend(process.env.RESEND_API_KEY);
 
export async function sendEmail({
  to,
  subject,
  html,
}: {
  to: string;
  subject: string;
  html: string;
}) {
  const result = await resend.emails.send({
    from: "House of Legends <bookings@houseoflegends.vn>",
    to,
    subject,
    html,
  });
  return result;
}
  • Step 3: Update .env.example
RESEND_API_KEY=re_xxxxxxxxxxxx
  • Step 4: Commit

Task 2: Email Templates — Translation + HTML Rendering in Next.js

Architecture: Email translation and HTML rendering happens in Next.js server actions (which have access to next-intl/server). Pre-rendered subject and html strings are then passed to Convex mutations for sending.

Files:

  • Create: apps/frontend/lib/email-templates.ts — email content builders with translations

  • Modify: apps/frontend/app/actions/notifications.ts — server actions that build email and call Convex mutation

  • Create: apps/backend/convex/functions/notifications.ts — Convex functions that receive pre-rendered content and call Resend

  • Step 1: Create email content builders (in Next.js, with i18n access)

// apps/frontend/lib/email-templates.ts
import { getTranslations } from "next-intl/server";
 
type BookingEmailData = {
  locale: string;
  customerFirstName: string;
  showTitle: string;
  showDate: string;
  showTime: string;
  ticketType: string;
  quantity: number;
  totalAmount: number;
  qrCode?: string;
};
 
export async function buildBookingConfirmationEmail(data: BookingEmailData) {
  const t = await getTranslations({
    locale: data.locale,
    namespace: "notifications.email",
  });
 
  const ticketTypeLabel =
    data.ticketType === "DINNER_THEATRE"
      ? t("ticketType.dinnerTheatre")
      : t("ticketType.showOnly");
 
  const html = `
    <div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
      <h1 style="color: #C5A059;">${t("confirmationTitle")}</h1>
      <p>${t("dearCustomer", { name: data.customerFirstName })}</p>
      <p>${t("bookingConfirmed")}</p>
      <table style="width: 100%; border-collapse: collapse;">
        <tr>
          <td style="padding: 8px; border: 1px solid #333;">${t("labelShow")}</td>
          <td style="padding: 8px; border: 1px solid #333;">${data.showTitle}</td>
        </tr>
        <tr>
          <td style="padding: 8px; border: 1px solid #333;">${t("labelDate")}</td>
          <td style="padding: 8px; border: 1px solid #333;">${data.showDate} ${t("at")} ${data.showTime}</td>
        </tr>
        <tr>
          <td style="padding: 8px; border: 1px solid #333;">${t("labelTicketType")}</td>
          <td style="padding: 8px; border: 1px solid #333;">${ticketTypeLabel}</td>
        </tr>
        <tr>
          <td style="padding: 8px; border: 1px solid #333;">${t("labelQuantity")}</td>
          <td style="padding: 8px; border: 1px solid #333;">${data.quantity}</td>
        </tr>
        <tr>
          <td style="padding: 8px; border: 1px solid #333;">${t("labelTotalPaid")}</td>
          <td style="padding: 8px; border: 1px solid #333;">${data.totalAmount.toLocaleString()} VND</td>
        </tr>
      </table>
      ${data.qrCode ? `<img src="${data.qrCode}" alt="QR Code" style="margin-top: 20px;" />` : ""}
      <p style="margin-top: 20px;">${t("seeYouAtVenue")}</p>
    </div>
  `;
 
  return {
    subject: t("confirmationSubject", { showTitle: data.showTitle }),
    html,
  };
}
 
type CancellationEmailData = {
  locale: string;
  customerFirstName: string;
  totalAmount: number;
};
 
export async function buildCancellationEmail(data: CancellationEmailData) {
  const t = await getTranslations({
    locale: data.locale,
    namespace: "notifications.email",
  });
 
  const html = `
    <div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
      <h1 style="color: #C5A059;">${t("cancellationTitle")}</h1>
      <p>${t("dearCustomer", { name: data.customerFirstName })}</p>
      <p>${t("bookingCancelled")}</p>
      ${data.totalAmount > 0 ? `<p>${t("refundInfo", { amount: data.totalAmount.toLocaleString() })}</p>` : ""}
    </div>
  `;
 
  return {
    subject: t("cancellationSubject"),
    html,
  };
}
 
type AdminNewBookingEmailData = {
  locale: string;
  customerFirstName: string;
  customerLastName: string;
  customerEmail: string;
  customerPhone?: string;
  showTitle: string;
  showDate: string;
  showTime: string;
  totalAmount: number;
};
 
export async function buildAdminNewBookingEmail(
  data: AdminNewBookingEmailData,
) {
  const t = await getTranslations({
    locale: data.locale,
    namespace: "notifications.email",
  });
 
  const html = `
    <div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
      <h1 style="color: #C5A059;">${t("adminNewBookingTitle")}</h1>
      <table style="width: 100%;">
        <tr><td>${t("labelCustomer")}</td><td>${data.customerFirstName} ${data.customerLastName}</td></tr>
        <tr><td>${t("labelEmail")}</td><td>${data.customerEmail}</td></tr>
        ${data.customerPhone ? `<tr><td>${t("labelPhone")}</td><td>${data.customerPhone}</td></tr>` : ""}
        <tr><td>${t("labelShow")}</td><td>${data.showTitle}</td></tr>
        <tr><td>${t("labelDate")}</td><td>${data.showDate} ${t("at")} ${data.showTime}</td></tr>
        <tr><td>${t("labelTotal")}</td><td>${data.totalAmount.toLocaleString()} VND</td></tr>
      </table>
    </div>
  `;
 
  return {
    subject: t("adminNewBookingSubject", {
      showTitle: data.showTitle,
      showDate: data.showDate,
    }),
    html,
  };
}
  • Step 2: Create Convex notification functions (direct SDK calls)

P0 FIX: Do NOT import getCurrentUser from "../auth" and try to call it — getCurrentUser is a QUERY that must be called via useQuery in frontend. Inside Convex mutation handlers, use ctx.auth.getUserIdentity() directly to get the Clerk identity, then query the users table to check roles.

// apps/backend/convex/functions/notifications.ts
import { mutation } from "convex/_generated/server";
import { v } from "convex/values";
import { consola } from "consola";
import { sendEmail } from "~/lib/resend";
 
const NOTIFICATION_ERROR_CODES = {
  RESEND_API_ERROR: "RESEND_API_ERROR",
  WHATSAPP_API_ERROR: "WHATSAPP_API_ERROR",
  WHATSAPP_NO_PHONE: "WHATSAPP_NO_PHONE",
  UNAUTHORIZED: "UNAUTHORIZED",
} as const;
 
type NotificationErrorCode = keyof typeof NOTIFICATION_ERROR_CODES;
 
/**
 * Helper: inline role check using ctx.auth.getUserIdentity()
 * Do NOT import and call getCurrentUser (it is a QUERY, not a utility function)
 */
async function requireStaffOrAdmin(
  ctx: HandlerContext,
): Promise<{ role: string }> {
  const identity = await ctx.auth.getUserIdentity();
  if (!identity) throw new Error(NOTIFICATION_ERROR_CODES.UNAUTHORIZED);
  const user = await ctx.db
    .query("users")
    .withIndex("by_email", (q) => q.eq("email", identity.email!))
    .first();
  if (!user || (user.role !== "ADMIN" && user.role !== "STAFF")) {
    throw new Error(NOTIFICATION_ERROR_CODES.UNAUTHORIZED);
  }
  return user as { role: string };
}
 
type HandlerContext = {
  auth: { getUserIdentity: () => Promise<{ email?: string } | null> };
  db: {
    query: (table: string) => {
      withIndex: (
        name: string,
        fn: (q: QueryBuilder) => QueryBuilder,
      ) => { first: () => Promise<unknown> };
    };
  };
};
 
type QueryBuilder = {
  eq: (field: string, value: string) => { first: () => Promise<unknown> };
};
 
/**
 * Send a pre-rendered email (HTML and subject pre-built by caller).
 * Direct Resend SDK call — no HTTP fetch needed.
 */
export const sendEmailNotification = mutation({
  args: {
    to: v.string(),
    subject: v.string(),
    html: v.string(),
    type: v.union(
      v.literal("EMAIL_CONFIRMATION"),
      v.literal("EMAIL_CANCELLATION"),
      v.literal("EMAIL_ADMIN_NEW_BOOKING"),
    ),
    reservationId: v.optional(v.id("reservations")),
  },
  handler: async (ctx, args) => {
    // CORRECT: Use inline role check via ctx.auth.getUserIdentity()
    await requireStaffOrAdmin(ctx);
 
    try {
      // Direct Resend SDK call — no HTTP fetch
      const result = await sendEmail({
        to: args.to,
        subject: args.subject,
        html: args.html,
      });
 
      if (result.error) {
        throw new Error(NOTIFICATION_ERROR_CODES.RESEND_API_ERROR);
      }
 
      consola.success("Email sent", { to: args.to, type: args.type });
      return { success: true };
    } catch (err) {
      consola.error("Email send failed", {
        to: args.to,
        type: args.type,
        error: err,
      });
      throw new Error(NOTIFICATION_ERROR_CODES.RESEND_API_ERROR);
    }
  },
});
 
type BookingWhatsAppParams = {
  customerPhone: string;
  customerFirstName: string;
  customerLastName: string;
  showTitle: string;
  showDateTime: string;
  totalAmount: number;
};
 
/**
 * Send WhatsApp booking confirmation.
 * Template content is pre-approved in WhatsApp Business Manager.
 * Direct WhatsApp API call — no HTTP fetch needed.
 */
export const sendBookingConfirmationWhatsApp = mutation({
  args: {
    customerPhone: v.string(),
    customerFirstName: v.string(),
    customerLastName: v.string(),
    showTitle: v.string(),
    showDateTime: v.string(),
    totalAmount: v.number(),
    reservationId: v.optional(v.id("reservations")),
  },
  handler: async (ctx, args) => {
    // CORRECT: Inline role check via ctx.auth.getUserIdentity()
    await requireStaffOrAdmin(ctx);
 
    if (!args.customerPhone) {
      consola.info("WhatsApp skipped: no phone number");
      return { success: true, skipped: true };
    }
 
    try {
      // Direct WhatsApp API call — no HTTP fetch
      const { sendWhatsAppTemplate } = await import("~/lib/whatsapp");
      await sendWhatsAppTemplate({
        to: args.customerPhone,
        templateName: "booking_confirmation",
        components: [
          {
            type: "body",
            parameters: [
              {
                type: "text",
                text: `${args.customerFirstName} ${args.customerLastName}`,
              },
              { type: "text", text: args.showTitle },
              { type: "text", text: args.showDateTime },
              { type: "text", text: args.totalAmount.toLocaleString() },
            ],
          },
        ],
      });
 
      consola.success("WhatsApp sent", { to: args.customerPhone });
      return { success: true };
    } catch (err) {
      consola.error("WhatsApp send failed", {
        to: args.customerPhone,
        error: err,
      });
      throw new Error(NOTIFICATION_ERROR_CODES.WHATSAPP_API_ERROR);
    }
  },
});
  • Step 3: Commit

Phase 2: WhatsApp Notifications

Task 3: WhatsApp Business API Integration

Files:

  • Create: apps/frontend/lib/whatsapp.ts — WhatsApp API client

WhatsApp Business API uses templates (pre-approved message templates). For transactional messages like booking confirmation, we use template messages with language: "en".

  • Step 1: Create WhatsApp client
// apps/frontend/lib/whatsapp.ts
const WHATSAPP_API_URL = `https://graph.facebook.com/v18.0/${process.env.WHATSAPP_PHONE_NUMBER_ID}/messages`;
 
export async function sendWhatsAppTemplate({
  to,
  templateName,
  components,
}: {
  to: string;
  templateName: string;
  components: Array<{
    type: "body";
    parameters: Array<{ type: "text"; text: string }>;
  }>;
}) {
  const response = await fetch(WHATSAPP_API_URL, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.WHATSAPP_ACCESS_TOKEN}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      messaging_product: "whatsapp",
      to,
      type: "template",
      template: {
        name: templateName,
        language: { code: "en" },
        components,
      },
    }),
  });
 
  if (!response.ok) {
    throw new Error(`WHATSAPP_API_ERROR: ${response.status}`);
  }
 
  return response.json();
}
  • Step 2: Update .env.example
WHATSAPP_PHONE_NUMBER_ID=xxxxxxxxxxxx
WHATSAPP_ACCESS_TOKEN=xxxxxxxxxxxx
  • Step 3: Commit

Phase 3: Zoho CRM + Books Sync

Task 4: Zoho OAuth2 Setup

Files:

  • Create: apps/frontend/lib/zoho.ts — Zoho API client

  • Modify: .env.example

  • Step 1: Create Zoho auth utility

// apps/frontend/lib/zoho.ts
let accessToken: string | null = null;
let tokenExpiry: number = 0;
 
export async function getZohoAccessToken(): Promise<string> {
  if (accessToken && Date.now() < tokenExpiry) {
    return accessToken;
  }
 
  const response = await fetch("https://accounts.zoho.com/oauth/v2/token", {
    method: "POST",
    body: new URLSearchParams({
      grant_type: "refresh_token",
      client_id: process.env.ZOHO_CLIENT_ID!,
      client_secret: process.env.ZOHO_CLIENT_SECRET!,
      refresh_token: process.env.ZOHO_REFRESH_TOKEN!,
    }),
  });
 
  const data = await response.json();
  accessToken = data.access_token;
  tokenExpiry = Date.now() + (data.expires_in - 60) * 1000;
 
  return accessToken!;
}
 
export async function zohoApiCall(endpoint: string, options: RequestInit = {}) {
  const token = await getZohoAccessToken();
  return fetch(`https://www.zohoapis.com${endpoint}`, {
    ...options,
    headers: {
      Authorization: `Zoho-oauthtoken ${token}`,
      "Content-Type": "application/json",
      ...options.headers,
    },
  });
}
  • Step 2: Update .env.example
# Zoho OAuth
ZOHO_CLIENT_ID=xxxxxxxxxxxx
ZOHO_CLIENT_SECRET=xxxxxxxxxxxx
ZOHO_REFRESH_TOKEN=1000.xxxxxx.xxxxxx
 
# Zoho Books
ZOHO_BOOKS_ORGANIZATION_ID=xxxxxxxxxxxx
  • Step 3: Commit

Task 5: Zoho CRM Contact Sync

Files:

  • Create: apps/backend/convex/functions/crm-sync.ts

  • Step 1: Create upsertContact function

P0 FIX: Convex calls Zoho API directly via SDK. Use inline role check via ctx.auth.getUserIdentity() — do NOT call getCurrentUser query.

// apps/backend/convex/functions/crm-sync.ts
import { mutation } from "convex/_generated/server";
import { v } from "convex/values";
import { consola } from "consola";
import { zohoApiCall } from "~/lib/zoho";
 
const CRM_ERROR_CODES = {
  ZOHO_AUTH_ERROR: "ZOHO_AUTH_ERROR",
  ZOHO_API_ERROR: "ZOHO_API_ERROR",
  ZOHO_CONTACT_ERROR: "ZOHO_CONTACT_ERROR",
  ZOHO_DEAL_ERROR: "ZOHO_DEAL_ERROR",
  ZOHO_INVOICE_ERROR: "ZOHO_INVOICE_ERROR",
  UNAUTHORIZED: "UNAUTHORIZED",
} as const;
 
type CrmErrorCode = keyof typeof CRM_ERROR_CODES;
 
type ContactParams = {
  customerFirstName: string;
  customerLastName: string;
  customerEmail: string;
  customerPhone?: string;
};
 
/**
 * Helper: inline role check using ctx.auth.getUserIdentity()
 */
async function requireStaffOrAdmin(
  ctx: CrmHandlerContext,
): Promise<{ role: string }> {
  const identity = await ctx.auth.getUserIdentity();
  if (!identity) throw new Error(CRM_ERROR_CODES.UNAUTHORIZED);
  const user = await ctx.db
    .query("users")
    .withIndex("by_email", (q) => q.eq("email", identity.email!))
    .first();
  if (!user || (user.role !== "ADMIN" && user.role !== "STAFF")) {
    throw new Error(CRM_ERROR_CODES.UNAUTHORIZED);
  }
  return user as { role: string };
}
 
type CrmHandlerContext = {
  auth: { getUserIdentity: () => Promise<{ email?: string } | null> };
  db: {
    query: (table: string) => {
      withIndex: (
        name: string,
        fn: (q: QueryBuilder) => QueryBuilder,
      ) => { first: () => Promise<unknown> };
    };
  };
};
 
type QueryBuilder = {
  eq: (field: string, value: string) => { first: () => Promise<unknown> };
};
 
export const upsertZohoContact = mutation({
  args: {
    customerFirstName: v.string(),
    customerLastName: v.string(),
    customerEmail: v.string(),
    customerPhone: v.optional(v.string()),
  },
  handler: async (ctx, args) => {
    await requireStaffOrAdmin(ctx);
 
    const {
      customerFirstName,
      customerLastName,
      customerEmail,
      customerPhone,
    } = args;
 
    const data = {
      First_Name: customerFirstName,
      Last_Name: customerLastName,
      Email: customerEmail,
      Phone: customerPhone ?? undefined,
      Lead_Source: "Website Booking",
    };
 
    try {
      // Check if contact exists by email — direct Zoho API call
      const searchResponse = await zohoApiCall(
        "/crm/v3/contacts/search?email=" + encodeURIComponent(customerEmail),
        { method: "GET" },
      );
      const searchData = await searchResponse.json();
 
      if (searchData.data && searchData.data.length > 0) {
        // Update existing contact
        const contactId = searchData.data[0].id;
        await zohoApiCall(`/crm/v3/contacts/${contactId}`, {
          method: "PUT",
          body: JSON.stringify({ data }),
        });
        consola.success("Zoho contact updated", {
          contactId,
          email: customerEmail,
        });
        return { success: true, action: "updated", contactId };
      } else {
        // Create new contact
        const createResponse = await zohoApiCall("/crm/v3/contacts", {
          method: "POST",
          body: JSON.stringify({ data }),
        });
        const createData = await createResponse.json();
        consola.success("Zoho contact created", {
          email: customerEmail,
          id: createData.data?.[0]?.details?.id,
        });
        return {
          success: true,
          action: "created",
          contactId: createData.data?.[0]?.details?.id,
        };
      }
    } catch (err) {
      consola.error("Zoho contact upsert failed", {
        email: customerEmail,
        error: err,
      });
      throw new Error(CRM_ERROR_CODES.ZOHO_CONTACT_ERROR);
    }
  },
});
  • Step 2: Create Zoho Deal for booking
export const createZohoDeal = mutation({
  args: {
    customerFirstName: v.string(),
    customerLastName: v.string(),
    showTitle: v.string(),
    showDate: v.string(),
    ticketType: v.union(v.literal("DINNER_THEATRE"), v.literal("SHOW_ONLY")),
    quantity: v.number(),
    totalAmount: v.number(),
  },
  handler: async (ctx, args) => {
    await requireStaffOrAdmin(ctx);
 
    const {
      customerFirstName,
      customerLastName,
      showTitle,
      showDate,
      ticketType,
      quantity,
      totalAmount,
    } = args;
 
    const dealData = {
      Deal_Name: `HOL - ${showTitle} - ${customerLastName}`,
      Amount: totalAmount,
      Closing_Date: showDate,
      Stage: "Closed Won",
      Lead_Source: "Website Booking",
      Description: `Ticket: ${ticketType} x ${quantity}`,
      Contact_Name: `${customerFirstName} ${customerLastName}`,
    };
 
    try {
      // Direct Zoho API call
      const response = await zohoApiCall("/crm/v3/deals", {
        method: "POST",
        body: JSON.stringify({ data: [dealData] }),
      });
      const responseData = await response.json();
      consola.success("Zoho deal created", {
        dealName: dealData.Deal_Name,
        id: responseData.data?.[0]?.details?.id,
      });
      return { success: true, dealId: responseData.data?.[0]?.details?.id };
    } catch (err) {
      consola.error("Zoho deal creation failed", {
        dealName: dealData.Deal_Name,
        error: err,
      });
      throw new Error(CRM_ERROR_CODES.ZOHO_DEAL_ERROR);
    }
  },
});
  • Step 3: Commit

Task 6: Zoho Books Invoice Creation

Files:

  • Create: apps/backend/convex/functions/crm-sync.ts — add invoice creation

  • Step 1: Create invoice on PAID booking

export const createZohoInvoice = mutation({
  args: {
    customerEmail: v.string(),
    showTitle: v.string(),
    ticketType: v.union(v.literal("DINNER_THEATRE"), v.literal("SHOW_ONLY")),
    quantity: v.number(),
    subtotal: v.number(),
    totalAmount: v.number(),
    vnpayTransactionId: v.optional(v.string()),
    addOns: v.optional(
      v.array(
        v.object({
          name: v.string(),
          quantity: v.number(),
          price: v.number(),
        }),
      ),
    ),
  },
  handler: async (ctx, args) => {
    await requireStaffOrAdmin(ctx);
 
    const {
      customerEmail,
      showTitle,
      ticketType,
      quantity,
      subtotal,
      totalAmount,
      vnpayTransactionId,
      addOns = [],
    } = args;
 
    const lineItems = [
      {
        item_id: "ticket_product_id", // Pre-configured in Zoho Books
        name: `${showTitle} - ${ticketType}`,
        quantity,
        rate: subtotal / quantity,
      },
    ];
 
    // Add add-ons as separate line items
    for (const addon of addOns) {
      lineItems.push({
        name: addon.name,
        quantity: addon.quantity,
        rate: addon.price,
      });
    }
 
    const invoiceData = {
      customer_email: customerEmail,
      line_items: lineItems,
      date: new Date().toISOString().split("T")[0],
      payment_terms: "Due on Receipt",
    };
 
    try {
      // Direct Zoho Books API call
      const response = await zohoApiCall("/books/v3/invoices", {
        method: "POST",
        body: JSON.stringify({ data: [invoiceData] }),
      });
      const responseData = await response.json();
 
      // Mark invoice as paid (since it's a confirmed booking)
      if (responseData.invoice?.id) {
        await zohoApiCall(
          `/books/v3/invoices/${responseData.invoice.id}/markaspaid`,
          {
            method: "POST",
            body: JSON.stringify({
              amount: totalAmount,
              payment_mode: "VNPay",
              reference_number: vnpayTransactionId ?? undefined,
            }),
          },
        );
        consola.success("Zoho invoice created and marked paid", {
          invoiceId: responseData.invoice.id,
        });
      }
      return { success: true, invoiceId: responseData.invoice?.id };
    } catch (err) {
      consola.error("Zoho invoice creation failed", {
        customerEmail,
        error: err,
      });
      throw new Error(CRM_ERROR_CODES.ZOHO_INVOICE_ERROR);
    }
  },
});
  • Step 2: Commit

Phase 4: Wire Notifications + CRM to Payment Flow

Task 7: Trigger Notifications on Payment Confirmation

Files:

  • Modify: apps/backend/convex/http/vnpay.ts — add post-payment notification calls via ctx.runMutation

  • Step 1: After confirmPayment in VNPay IPN handler, trigger notifications and CRM sync via direct mutations

// In the VNPay IPN handler, after successful payment update to PAID:
// Direct Convex mutation calls — no HTTP fetch needed
await ctx.runMutation(internal.crm_sync.upsertZohoContact, {
  customerFirstName: reservation.customerFirstName,
  customerLastName: reservation.customerLastName,
  customerEmail: reservation.customerEmail,
  customerPhone: reservation.customerPhone ?? undefined,
});
 
await ctx.runMutation(internal.crm_sync.createZohoDeal, {
  customerFirstName: reservation.customerFirstName,
  customerLastName: reservation.customerLastName,
  showTitle: template?.title ?? "House of Legends",
  showDate: occurrence?.date ?? new Date().toISOString().split("T")[0],
  ticketType: reservation.ticketType,
  quantity: reservation.quantity,
  totalAmount: reservation.totalAmount,
});
 
await ctx.runMutation(internal.crm_sync.createZohoInvoice, {
  customerEmail: reservation.customerEmail,
  showTitle: template?.title ?? "House of Legends",
  ticketType: reservation.ticketType,
  quantity: reservation.quantity,
  subtotal: reservation.subtotal,
  totalAmount: reservation.totalAmount,
  vnpayTransactionId: reservation.vnpayTransactionId ?? undefined,
  addOns: reservation.addOns ?? [],
});
 
// Email and WhatsApp notifications (pre-rendered content passed directly)
await ctx.runMutation(internal.notifications.sendEmailNotification, {
  to: reservation.customerEmail,
  subject: emailSubject,
  html: emailHtml,
  type: "EMAIL_CONFIRMATION",
  reservationId: reservation._id,
});
 
if (reservation.customerPhone) {
  await ctx.runMutation(
    internal.notifications.sendBookingConfirmationWhatsApp,
    {
      customerPhone: reservation.customerPhone,
      customerFirstName: reservation.customerFirstName,
      customerLastName: reservation.customerLastName,
      showTitle: template?.title ?? "House of Legends",
      showDateTime: `${occurrence?.date ?? ""} at ${occurrence?.time ?? ""}`,
      totalAmount: reservation.totalAmount,
      reservationId: reservation._id,
    },
  );
}
  • Step 2: On cancellation, trigger email via direct mutation
// In the cancelReservation mutation:
await ctx.runMutation(internal.notifications.sendEmailNotification, {
  to: reservation.customerEmail,
  subject: cancellationSubject,
  html: cancellationHtml,
  type: "EMAIL_CANCELLATION",
  reservationId: reservation._id,
});
  • Step 3: Commit

Phase 5: Schema Extensions for Logging

Task 8: Add Notification and Sync Log Tables

Files:

  • Modify: convex/schema.ts

  • Step 1: Add notificationLogs table

notificationLogs: defineTable({
  reservationId: v.optional(v.id("reservations")),
  type: v.union(
    v.literal("EMAIL_CONFIRMATION"),
    v.literal("EMAIL_CANCELLATION"),
    v.literal("EMAIL_REFUND"),
    v.literal("EMAIL_ADMIN_NEW_BOOKING"),
    v.literal("WHATSAPP_CONFIRMATION"),
    v.literal("WHATSAPP_REMINDER")
  ),
  channel: v.union(v.literal("EMAIL"), v.literal("WHATSAPP")),
  recipient: v.string(),
  status: v.union(
    v.literal("PENDING"),
    v.literal("SENT"),
    v.literal("FAILED")
  ),
  externalId: v.optional(v.string()),
  errorMessage: v.optional(v.string()),
  createdAt: v.number(),
  sentAt: v.optional(v.number()),
})
  .index("by_reservation", ["reservationId"])
  .index("by_status", ["status"]),
  • Step 2: Add zohoSyncLogs table
zohoSyncLogs: defineTable({
  reservationId: v.id("reservations"),
  action: v.union(
    v.literal("CONTACT_UPSERT"),
    v.literal("DEAL_CREATE"),
    v.literal("INVOICE_CREATE"),
    v.literal("INVOICE_PAYMENT")
  ),
  zohoRecordId: v.optional(v.string()),
  status: v.union(
    v.literal("PENDING"),
    v.literal("SUCCESS"),
    v.literal("FAILED")
  ),
  errorMessage: v.optional(v.string()),
  retryCount: v.number(),
  createdAt: v.number(),
  syncedAt: v.optional(v.number()),
})
  .index("by_reservation", ["reservationId"])
  .index("by_status", ["status"]),
  • Step 3: Commit

Enrichment Sections

1. Zod Schemas

// apps/backend/convex/functions/notifications.ts
import { z } from "zod";
 
export const SendEmailNotificationSchema = z.object({
  to: z.string().email("Invalid email address"),
  subject: z.string().min(1, "Subject is required"),
  html: z.string().min(1, "HTML content is required"),
  type: z.enum([
    "EMAIL_CONFIRMATION",
    "EMAIL_CANCELLATION",
    "EMAIL_ADMIN_NEW_BOOKING",
  ]),
  reservationId: z.string().optional(),
});
 
export const SendWhatsAppNotificationSchema = z.object({
  customerPhone: z.string().min(1, "Phone number is required"),
  customerFirstName: z.string().min(1, "First name is required"),
  customerLastName: z.string().min(1, "Last name is required"),
  showTitle: z.string().min(1, "Show title is required"),
  showDateTime: z.string().min(1, "Show date/time is required"),
  totalAmount: z.number().nonnegative(),
  reservationId: z.string().optional(),
});
 
// apps/backend/convex/functions/crm-sync.ts
export const UpsertZohoContactSchema = z.object({
  customerFirstName: z.string().min(1, "First name is required"),
  customerLastName: z.string().min(1, "Last name is required"),
  customerEmail: z.string().email("Invalid email address"),
  customerPhone: z.string().optional(),
});
 
export const CreateZohoDealSchema = z.object({
  customerFirstName: z.string().min(1, "First name is required"),
  customerLastName: z.string().min(1, "Last name is required"),
  showTitle: z.string().min(1, "Show title is required"),
  showDate: z
    .string()
    .regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be YYYY-MM-DD format"),
  ticketType: z.enum(["DINNER_THEATRE", "SHOW_ONLY"]),
  quantity: z.number().int().positive("Quantity must be positive"),
  totalAmount: z.number().nonnegative(),
});
 
export const CreateZohoInvoiceSchema = z.object({
  customerEmail: z.string().email("Invalid email address"),
  showTitle: z.string().min(1, "Show title is required"),
  ticketType: z.enum(["DINNER_THEATRE", "SHOW_ONLY"]),
  quantity: z.number().int().positive("Quantity must be positive"),
  subtotal: z.number().nonnegative(),
  totalAmount: z.number().nonnegative(),
  vnpayTransactionId: z.string().optional(),
  addOns: z
    .array(
      z.object({
        name: z.string(),
        quantity: z.number().int().positive(),
        price: z.number().nonnegative(),
      }),
    )
    .optional(),
});

2. Error Handling

export const NOTIFICATION_ERROR_CODES = {
  RESEND_API_ERROR: "RESEND_API_ERROR",
  WHATSAPP_API_ERROR: "WHATSAPP_API_ERROR",
  WHATSAPP_NO_PHONE: "WHATSAPP_NO_PHONE",
  UNAUTHORIZED: "UNAUTHORIZED",
} as const;
 
export const CRM_ERROR_CODES = {
  ZOHO_AUTH_ERROR: "ZOHO_AUTH_ERROR",
  ZOHO_API_ERROR: "ZOHO_API_ERROR",
  ZOHO_CONTACT_ERROR: "ZOHO_CONTACT_ERROR",
  ZOHO_DEAL_ERROR: "ZOHO_DEAL_ERROR",
  ZOHO_INVOICE_ERROR: "ZOHO_INVOICE_ERROR",
  UNAUTHORIZED: "UNAUTHORIZED",
} as const;
FunctionError CodeMessage KeyCondition
sendEmailNotificationRESEND_API_ERRORerrors.notifications.resendFailedResend API returns error
sendEmailNotificationUNAUTHORIZEDerrors.auth.unauthorizedNot admin or staff
sendBookingConfirmationWhatsAppWHATSAPP_API_ERRORerrors.notifications.whatsappFailedWhatsApp API returns error
sendBookingConfirmationWhatsAppWHATSAPP_NO_PHONEerrors.notifications.noPhoneCustomer phone not provided
sendBookingConfirmationWhatsAppUNAUTHORIZEDerrors.auth.unauthorizedNot admin or staff
upsertZohoContactZOHO_AUTH_ERRORerrors.notifications.zohoAuthFailedZoho OAuth token refresh fails
upsertZohoContactZOHO_API_ERRORerrors.notifications.zohoApiFailedZoho API returns error
upsertZohoContactZOHO_CONTACT_ERRORerrors.notifications.zohoContactFailedZoho contact upsert fails
upsertZohoContactUNAUTHORIZEDerrors.auth.unauthorizedNot admin or staff
createZohoDealZOHO_DEAL_ERRORerrors.notifications.zohoDealFailedZoho deal creation fails
createZohoInvoiceZOHO_INVOICE_ERRORerrors.notifications.zohoInvoiceFailedZoho invoice creation fails

3. Convex Real-time Subscription Pattern

// Admin dashboard — recent notifications
const recentNotifications = useQuery(api.notifications.listRecent, {
  limit: 50,
});
 
// Admin dashboard — sync status
const syncStatus = useQuery(api.crmSync.getByReservation, { reservationId });

4. Mobile/Responsive Considerations

ComponentMobile Behavior
Admin notification dashboardTable layout collapses to card list
Email templatesResponsive email design with media queries
WhatsApp messagesTemplate content must fit WhatsApp character limits

5. PWA / Offline Behavior

Not applicable — notifications are server-triggered, not user-facing PWA features.

6. i18n / next-intl Requirements

P1 FIX — No getTranslations in Convex: All email translation MUST happen in Next.js contexts (API routes, Server Components). Convex functions receive pre-rendered subject and html strings.

{
  "notifications": {
    "email": {
      "confirmationTitle": "Booking Confirmed",
      "cancellationTitle": "Booking Cancelled",
      "dearCustomer": "Dear {name}",
      "bookingConfirmed": "Your booking has been confirmed.",
      "bookingCancelled": "Your booking has been cancelled.",
      "refundInfo": "A refund of {amount} VND will be processed.",
      "seeYouAtVenue": "We look forward to seeing you at House of Legends!",
      "adminNewBookingTitle": "New Booking Received",
      "ticketType": {
        "dinnerTheatre": "Dinner Theatre",
        "showOnly": "Show Only"
      },
      "labelShow": "Show",
      "labelDate": "Date",
      "labelTicketType": "Ticket Type",
      "labelQuantity": "Quantity",
      "labelTotalPaid": "Total Paid",
      "labelCustomer": "Customer",
      "labelEmail": "Email",
      "labelPhone": "Phone",
      "labelTotal": "Total",
      "at": "at"
    },
    "whatsapp": {
      "confirmationBody": [
        "Dear {name}",
        "Your booking for {showTitle} on {dateTime} is confirmed. Total: {amount} VND."
      ]
    }
  },
  "errors": {
    "notifications": {
      "resendFailed": "Failed to send email. Please try again.",
      "whatsappFailed": "Failed to send WhatsApp message.",
      "noPhone": "No phone number provided.",
      "zohoAuthFailed": "CRM authentication failed.",
      "zohoApiFailed": "CRM API error.",
      "zohoContactFailed": "Failed to sync customer to CRM.",
      "zohoDealFailed": "Failed to create deal in CRM.",
      "zohoInvoiceFailed": "Failed to create invoice in CRM."
    },
    "auth": {
      "unauthorized": "You must be signed in as staff or admin"
    }
  }
}

Email subject lines must be i18n (done in Next.js):

// In Next.js server action — Good (has access to getTranslations via next-intl/server)
const { subject, html } = await buildBookingConfirmationEmail(data);
 
// Convex receives pre-built strings — Good
await sendEmail({ to, subject, html });

Admin email recipient must come from env:

// Good — env var with fallback
adminEmail: process.env.ADMIN_EMAIL ?? "admin@houseoflegends.vn";

7. Environment-Specific Configuration

# Server-only (never exposed to client):
CLERK_SECRET_KEY=           # Clerk secret key
RESEND_API_KEY=re_xxxxxxxxxxxx
WHATSAPP_ACCESS_TOKEN=xxxxxxxxxxxx
ZOHO_CLIENT_ID=xxxxxxxxxxxx
ZOHO_CLIENT_SECRET=xxxxxxxxxxxx
ZOHO_REFRESH_TOKEN=1000.xxxxxx.xxxxxx
ZOHO_BOOKS_ORGANIZATION_ID=xxxxxxxxxxxx
 
# Client-safe (NEXT_PUBLIC_ prefix):
NEXT_PUBLIC_CONVEX_URL=    # Convex deployment URL
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=  # Clerk publishable key
NEXT_PUBLIC_APP_URL=        # Frontend URL (still needed for redirects, not for internal calls)
 
# Admin configuration:
ADMIN_EMAIL=admin@houseoflegends.vn
GOOGLE_REVIEW_URL=https://g.page/r/.../review

8. TDD Test Cases

E2E Tests (Playwright):

// e2e/notifications.spec.ts
 
test("NOT-E2E-1.1: Customer receives confirmation email after payment", async ({
  page,
}) => {
  // Given: VNPay payment confirmed for booking "BOOK-123"
  // When: Payment webhook is received with code "00"
  // Then: Customer receives email with show title, date, time, ticket type, quantity, total, QR code
  // (This is tested via mock - actual email delivery verified in staging)
});
 
test("NOT-E2E-1.2: Customer receives WhatsApp if phone provided", async ({
  page,
}) => {
  // Given: Booking confirmed with customerPhone "+84912345678"
  // When: Payment confirmed
  // Then: WhatsApp template message sent to +84912345678
});
 
test("NOT-E2E-1.3: WhatsApp skipped when no phone", async ({ page }) => {
  // Given: Booking confirmed without customerPhone
  // When: Payment confirmed
  // Then: No WhatsApp message attempted
});
 
test("NOT-E2E-1.4: Admin receives new booking email", async ({ page }) => {
  // Given: New booking confirmed
  // When: Payment confirmed
  // Then: Admin email receives new booking notification with customer details
});

Component Tests (Vitest + RTL):

// __tests__/components/email-templates.test.tsx
 
it("NOT-CT-1.1: Booking confirmation email renders with all fields", async () => {
  // Given: Booking data with all fields populated
  const data = {
    locale: "en",
    customerFirstName: "John",
    showTitle: "Legendary Show",
    showDate: "May 15, 2026",
    showTime: "7:30 PM",
    ticketType: "DINNER_THEATRE",
    quantity: 2,
    totalAmount: 2300000,
    qrCode: "https://example.com/qr.png",
  };
  // When: buildBookingConfirmationEmail is called
  const { subject, html } = await buildBookingConfirmationEmail(data);
  // Then: Subject contains show title, HTML contains all data fields
  expect(subject).toContain("Legendary Show");
  expect(html).toContain("John");
  expect(html).toContain("Legendary Show");
  expect(html).toContain("May 15, 2026");
  expect(html).toContain("7:30 PM");
  expect(html).toContain("Dinner Theatre");
  expect(html).toContain("2");
  expect(html).toContain("2,300,000");
});
 
it("NOT-CT-1.2: Cancellation email shows refund info when amount > 0", async () => {
  // Given: Cancellation with totalAmount 2,300,000 VND
  const data = {
    locale: "en",
    customerFirstName: "John",
    totalAmount: 2300000,
  };
  // When: buildCancellationEmail is called
  const { html } = await buildCancellationEmail(data);
  // Then: HTML contains refund info
  expect(html).toContain("2,300,000");
});
 
it("NOT-CT-1.3: Cancellation email shows no refund info when amount is 0", async () => {
  // Given: Cancellation with totalAmount 0
  const data = { locale: "en", customerFirstName: "John", totalAmount: 0 };
  // When: buildCancellationEmail is called
  const { html } = await buildCancellationEmail(data);
  // Then: HTML does not contain refund info
  expect(html).not.toContain("refundInfo");
});

Backend Tests (Vitest):

// __tests__/convex/notifications.test.ts
 
it("NOT-BE-1.1: sendEmailNotification throws UNAUTHORIZED when not staff/admin", async () => {
  // Given: Mock context with unauthenticated identity
  const ctx = createMockContext({ identity: null });
  // When: Calling sendEmailNotification
  // Then: Throws UNAUTHORIZED
  await expect(
    ctx.runMutation(api.notifications.sendEmailNotification, {
      to: "john@example.com",
      subject: "Test",
      html: "<p>Test</p>",
      type: "EMAIL_CONFIRMATION",
    }),
  ).rejects.toThrow("UNAUTHORIZED");
});
 
it("NOT-BE-1.2: sendEmailNotification throws UNAUTHORIZED for GUEST role", async () => {
  // Given: Mock context with GUEST role
  const ctx = createMockContext({
    identity: { email: "guest@example.com" },
    userRole: "GUEST",
  });
  // When: Calling sendEmailNotification
  // Then: Throws UNAUTHORIZED
  await expect(
    ctx.runMutation(api.notifications.sendEmailNotification, {
      to: "john@example.com",
      subject: "Test",
      html: "<p>Test</p>",
      type: "EMAIL_CONFIRMATION",
    }),
  ).rejects.toThrow("UNAUTHORIZED");
});
 
it("NOT-BE-1.3: sendEmailNotification succeeds for ADMIN role", async () => {
  // Given: Mock context with ADMIN role
  const ctx = createMockContext({
    identity: { email: "admin@example.com" },
    userRole: "ADMIN",
  });
  // Mock the Resend SDK call
  const mockSendEmail = vi.fn().mockResolvedValue({ data: { id: "test-id" } });
  vi.mock("~/lib/resend", () => ({
    sendEmail: mockSendEmail,
  }));
  // When: Calling sendEmailNotification
  // Then: Succeeds and calls Resend directly
  await ctx.runMutation(api.notifications.sendEmailNotification, {
    to: "john@example.com",
    subject: "Booking Confirmed",
    html: "<h1>Confirmed</h1>",
    type: "EMAIL_CONFIRMATION",
  });
  expect(mockSendEmail).toHaveBeenCalledWith({
    to: "john@example.com",
    subject: "Booking Confirmed",
    html: "<h1>Confirmed</h1>",
  });
});
 
it("NOT-BE-1.4: sendBookingConfirmationWhatsApp skips when phone empty", async () => {
  // Given: Mock context with ADMIN role
  const ctx = createMockContext({
    identity: { email: "admin@example.com" },
    userRole: "ADMIN",
  });
  // When: Calling with empty customerPhone
  // Then: Returns { success: true, skipped: true }
  const result = await ctx.runMutation(
    api.notifications.sendBookingConfirmationWhatsApp,
    {
      customerPhone: "",
      customerFirstName: "John",
      customerLastName: "Doe",
      showTitle: "Legendary Show",
      showDateTime: "2026-05-15 at 19:30",
      totalAmount: 1800000,
    },
  );
  expect(result).toEqual({ success: true, skipped: true });
});
 
it("NOT-BE-1.5: upsertZohoContact throws UNAUTHORIZED when not staff/admin", async () => {
  // Given: Mock context with GUEST role
  const ctx = createMockContext({
    identity: { email: "guest@example.com" },
    userRole: "GUEST",
  });
  // When: Calling upsertZohoContact
  // Then: Throws UNAUTHORIZED
  await expect(
    ctx.runMutation(api.crmSync.upsertZohoContact, {
      customerFirstName: "John",
      customerLastName: "Doe",
      customerEmail: "john@example.com",
    }),
  ).rejects.toThrow("UNAUTHORIZED");
});
 
it("NOT-BE-1.6: createZohoDeal succeeds for ADMIN role", async () => {
  // Given: Mock context with ADMIN role
  const ctx = createMockContext({
    identity: { email: "admin@example.com" },
    userRole: "ADMIN",
  });
  // Mock the Zoho API call
  const mockZohoApiCall = vi.fn().mockResolvedValue({
    json: () => Promise.resolve({ data: [{ details: { id: "deal123" } }] }),
  });
  vi.mock("~/lib/zoho", () => ({
    zohoApiCall: mockZohoApiCall,
  }));
  // When: Calling createZohoDeal
  // Then: Succeeds
  const result = await ctx.runMutation(api.crmSync.createZohoDeal, {
    customerFirstName: "John",
    customerLastName: "Doe",
    showTitle: "Legendary Show",
    showDate: "2026-05-15",
    ticketType: "DINNER_THEATRE",
    quantity: 2,
    totalAmount: 2300000,
  });
  expect(result.success).toBe(true);
});

9. Cross-Plan Dependencies

DependencyPlanShared Schema
Required bypackage-bundle-pricingReservation totals used in Zoho invoices
Required bystaff-operationsReservation check-in triggers
Shares schema withshow-systemshowTemplates.title for email content
Triggered bybooking-flowVNPay payment confirmation triggers all notifications
Depends onshow-systemshowTemplates.title for email subject lines
Depends ontable-pos-systemreservation.tableId links to POS table
Zoho invoice usespackage-bundle-pricingLine items from pricing breakdown

10. Performance Considerations

ScenarioAt Scale (100 bookings/day)
Email sendingResend handles 100 emails/day easily; async, non-blocking
WhatsApp sending100 WhatsApp messages/day well within rate limits
Zoho syncEach booking = 3 API calls (contact, deal, invoice); ~300 calls/day
Retry logicExponential backoff: 1min, 5min, 15min; max 3 retries
Email renderingNext.js server action handles translation via next-intl/server; ~50ms added latency

Acceptance Criteria

  1. Email confirmation — customer receives HTML email with booking details and QR code after payment
  2. WhatsApp confirmation — customer receives WhatsApp template message after payment (if phone provided)
  3. Admin new booking email — admin receives email for every new PAID booking
  4. D-1 low occupancy alert — admin receives email + WhatsApp when a tomorrow show has < 50% occupancy
  5. Zoho CRM contact — customer contact is created/updated in Zoho CRM on booking
  6. Zoho CRM deal — deal is created in Zoho CRM with booking amount and show date as closing date
  7. Zoho Books invoice — invoice is created in Zoho Books and marked as paid
  8. Retry logic — if Zoho/email API fails, there is a retry mechanism or queue
  9. No duplicate contacts — Zoho contact upsert checks for existing email before creating

User Stories

IDAs a...I want to...So that...Priority
NOT-US01GuestReceive confirmation email after paymentI have proof of my bookingMust
NOT-US02GuestReceive WhatsApp message after bookingI get notified via a channel I check frequently (if phone provided)Should
NOT-US03AdminReceive new booking email notificationI can track revenue and upcoming showsMust
NOT-US04AdminReceive D-1 low occupancy alertI can take action to avoid empty showsShould
NOT-US05GuestReceive cancellation email with refund infoI understand the booking was cancelled and know when to expect my refundMust
NOT-US06SystemCreate/update Zoho CRM contact on bookingCustomer data is captured in Zoho for marketing and follow-upMust
NOT-US07SystemCreate Zoho Deal with booking amountAdmin can track pipeline value and forecast revenueMust
NOT-US08SystemCreate Zoho Books invoice marked as paidAccounting has accurate records of received paymentsMust
NOT-US09SystemAvoid duplicate contacts in ZohoContact list stays clean and marketing segments remain accurateMust

Test Scenarios

IDScenarioGivenWhenThen
NOT-TS01Email sent on paymentBooking confirmed via VNPay IPNPayment code = 00Confirmation email sent to customer
NOT-TS02Email contains booking detailsBooking confirmedPayment code = 00Email includes show title, date, time, ticket type, quantity, total, QR code
NOT-TS03WhatsApp sent when phone providedBooking confirmed with customerPhonePayment code = 00WhatsApp template message sent to customer's phone
NOT-TS04WhatsApp skipped when no phoneBooking confirmed without customerPhonePayment code = 00WhatsApp message not attempted
NOT-TS05Admin email on new bookingBooking confirmedPayment code = 00Admin receives new booking notification email
NOT-TS06Zoho contact created for new emailNew customer email (not in Zoho)Booking confirmedNew contact created in Zoho CRM
NOT-TS07Zoho contact updated for existing emailExisting customer email (in Zoho)Booking confirmedExisting Zoho contact updated with latest booking info
NOT-TS08Zoho Deal created on bookingBooking confirmedPayment code = 00Zoho Deal created with correct amount and closing date
NOT-TS09Zoho Books invoice createdBooking confirmedPayment code = 00Zoho Books invoice created and marked as paid
NOT-TS10Invoice line items correctBooking with add-ons confirmedPayment code = 00Invoice includes ticket line item and each add-on as separate line item
NOT-TS11Cancellation email on user cancellationBooking cancelled by userCancellation confirmedCancellation email sent to customer with refund info
NOT-TS12Cancellation email on admin cancellationBooking cancelled by adminCancellation confirmedCancellation email sent to customer with refund info
NOT-TS13D-1 low occupancy alert triggeredTomorrow's show has < 50% occupancy24h before showAdmin receives low occupancy alert via email and WhatsApp
NOT-TS14D-1 alert not sent for high occupancyTomorrow's show has >= 50% occupancy24h before showAdmin does not receive low occupancy alert
NOT-TS15No duplicate Zoho contact on rebookingCustomer email already exists in ZohoSame customer books againExisting contact updated, no new contact created
NOT-TS16Retry on email API failureResend API returns 500Sending confirmation emailRetry attempted up to 3 times with exponential backoff
NOT-TS17Retry on Zoho API failureZoho API returns 503Creating contact/deal/invoiceRetry attempted with exponential backoff
NOT-TS18Email not sent for failed paymentVNPay IPN with payment code != 00Payment failedNo confirmation email sent
NOT-TS19QR code in emailBooking confirmed with QR code generatedPayment code = 00QR code image included in confirmation email
NOT-TS20Partial refund notificationBooking partially refundedRefund processedEmail sent to customer with refund amount and remaining balance

Consistency Audit: notifications-crm

P0 Violations (fixed in plan text)

#LocationIssueFix Applied
1Phase 2, sendEmailNotificationWas importing getCurrentUser from "../auth" and calling it as utility function — but getCurrentUser is a QUERY, not a utilityChanged to inline role check using ctx.auth.getUserIdentity() + DB lookup
2Phase 2, sendBookingConfirmationWhatsAppSame incorrect getCurrentUser usageChanged to inline role check
3Phase 3, upsertZohoContactSame incorrect getCurrentUser usageChanged to inline role check
4Phase 3, createZohoDealSame incorrect getCurrentUser usageChanged to inline role check
5Phase 3, createZohoInvoiceSame incorrect getCurrentUser usageChanged to inline role check
6Phase 2, requireStaffOrAdminOver-complex explicit type annotation (q: unknown) => (q as { eq: ... }).eq(...) on QueryBuilder parameterSimplified to cleaner named types — Convex QueryBuilder is inferred automatically, explicit casts unnecessary

P1 Violations (fixed in plan text)

#LocationIssueFix Applied
1Globalconsole.log usageChanged to consola.info/debug/success/error
2GlobalInconsistent error code namingFixed to use NOTIFICATION_ERROR_CODES and CRM_ERROR_CODES consistently
3Error handlingError codes not defined as const objectAdded NOTIFICATION_ERROR_CODES and CRM_ERROR_CODES const objects

P0 Gaps (cannot fix in plan — requires codebase change)

#IssueAction Required
1staffMutation/adminMutation/authenticatedMutation/authenticatedQuery not in convex/auth.tsUse plain mutation with inline role checks until auth helpers are implemented
2Convex can't import Next.js libs directlyExternal service calls (Resend, WhatsApp, Zoho) are made directly from Convex mutations via SDK — no Next.js imports needed

Schema Consistency Check

  • notificationLogs table uses correct index names by_reservation and by_status
  • zohoSyncLogs table uses correct index names by_reservation and by_status
  • All mutation args use proper Convex v.* validators matching Zod schemas above