Skip to Content
Apps & PortalsManageTenant Creation — Gap Analysis & Fix Plan

Tenant Creation — Gap Analysis & Fix Plan

Reference BRD: docs/apps/manage/tenant-creation.md v1.3
Audited: April 20, 2026
Implemented: April 20, 2026
Scope: Admin portal (apps/manage) tenant creation form + API (apps/api/src/routers/admin/tenants.ts) + downstream integrations


Summary of Gaps

#AreaGapPriorityStatus
1EmailsWelcome email not sent on admin-created tenants🔴 High✅ Done
2EmailsLogin credentials email (with password) not implemented🔴 High✅ Done
3ZohoZoho Books customer not auto-created on tenant creation🔴 High✅ Done
4ZohoZoho Books invoice not auto-created after first invoice🔴 High✅ Done
5ZohoDraft invoice alert email (on Zoho failure) not implemented🔴 High✅ Done
6FormGSTIN format validation missing🟡 Medium✅ Done
7FormPAN number field does not exist (not collected, not stored)🟡 Medium✅ Done
8FormSEZ customer checkbox not in admin form🟡 Medium✅ Done
9TaxGST treatment not stored (gstTreatment field missing)🟡 Medium✅ Done
10TaxTax type (IGST vs SGST+CGST) not computed or stored🟡 Medium✅ Done
11TaxPlace of supply not stored🟡 Medium✅ Done
12TaxIGST/SGST/CGST amounts not stored on invoice🟡 Medium✅ Done
13InvoiceInvoice status naming mismatch (pending vs draft/issued)🟢 Low✅ Done — Option A
14FormPassword generator produces 16 chars (BRD: 8 chars)🟢 Low⚪ Kept at 16 (product decision)
15FormTDS percent is 10% in seeded Region data (BRD: 2%)🟢 Low✅ Done

Gap 1 & 2 — Welcome Email + Credentials Email Not Sent

Problem

The welcome email (templateSlug: "welcome") is only enqueued inside apps/api/src/routers/auth.ts — the self-signup flow. When the Onboarding team creates a tenant via the admin portal (POST /admin/v1/tenants), no welcome email and no credentials email are sent to the customer.

The BRD requires two separate emails immediately after tenant creation:

  1. Welcome email"Welcome To Leadmetrics" — generic welcome, tells them to check inbox for credentials
  2. Credentials email"Your Leadmetrics Credentials" — contains the username (email) and the temporary password

Fix

Step 1 — Create the credentials email template in the DB seed

File: packages/db/src/seed.ts

Add a new EmailTemplate record with slug: "credentials" (platform-level, tenantId: null). Template variables: firstName, lastName, email, password, dashboardUrl.

The welcome template (slug: "welcome") may already be seeded — verify. If not, add it too.

Step 2 — Enqueue both emails from the admin tenant creation route

File: apps/api/src/routers/admin/tenants.ts — inside the POST /tenants handler, after the audit log write and before the final reply.send.

Add two enqueueNotification calls:

// 1. Welcome email enqueueNotification({ tenantId, channel: "email", type: "welcome", templateSlug: "welcome", recipients: [{ name: userName, email: body.email }], variables: { firstName: body.firstName.trim(), lastName: body.lastName.trim(), companyName: body.displayName.trim(), dashboardUrl: process.env.DASHBOARD_URL ?? "https://app.leadmetrics.ai", }, priority: "high", dedupeKey: `welcome__${tenantId}`, }).catch(err => fastify.log.error({ err }, "[create-tenant] Failed to enqueue welcome email")); // 2. Credentials email enqueueNotification({ tenantId, channel: "email", type: "credentials", templateSlug: "credentials", recipients: [{ name: userName, email: body.email }], variables: { firstName: body.firstName.trim(), lastName: body.lastName.trim(), email: body.email.toLowerCase().trim(), password: body.password, // plain-text password before hashing is needed dashboardUrl: process.env.DASHBOARD_URL ?? "https://app.leadmetrics.ai", }, priority: "high", dedupeKey: `credentials__${tenantId}`, }).catch(err => fastify.log.error({ err }, "[create-tenant] Failed to enqueue credentials email"));

Security note: The plain-text password must be captured before hashPassword() is called. The current code hashes it early (const hashed = await hashPassword(body.password)). Store the raw value in a const plainPassword = body.password before hashing, and pass plainPassword to the credentials email. The raw password is never stored — it exists only in memory for the duration of this request.

Step 3 — Add the credentials email template HTML

The notification server (apps/servers/notifications) loads email templates from the EmailTemplate DB table via email-loader.ts. The template content matches the HTML specified in BRD Section 14.2 — Email 2.


Gap 3, 4 & 5 — Zoho Books Auto-Sync Entirely Missing

Problem

ZohoBooksService (packages/providers/zoho/src/zoho-books.ts) is fully built — it has createCustomer(), createInvoice(), markInvoiceAsSent(), and all supporting methods. However, it is never called during tenant creation. Zoho Books is only wired as an optional manual channel connection.

The BRD requires:

  • Auto-create Zoho customer profile on tenant creation
  • Auto-create Zoho invoice after the first LM Portal invoice is created
  • Fetch Zoho invoice number back → update LM Portal invoice status to Issued
  • If Zoho sync fails → invoice stays in Draft → internal alert email sent to tech team

Fix

Step 1 — Store Zoho OAuth token as a system-level credential

The ZohoBooksService currently requires a per-user OAuth token (tied to the Zoho channel connection). For the auto-sync to work during tenant creation, the admin portal needs to use a system-level Zoho Books OAuth token — not a per-tenant one.

Options:

  • Option A (recommended): Store the system Zoho OAuth token in environment variables (ZOHO_BOOKS_CLIENT_ID, ZOHO_BOOKS_CLIENT_SECRET, ZOHO_BOOKS_REFRESH_TOKEN, ZOHO_BOOKS_ORG_ID). Create a getSystemZohoToken() helper that refreshes as needed.
  • Option B: Use the Admin user’s Zoho Books connected channel token (requires the admin to have connected Zoho Books in the manage portal).

Step 2 — Create a ZohoBooksSync service module

New file: apps/api/src/services/zoho-books-sync.ts

export async function syncTenantToZohoBooks(params: { tenantId: string; title: string; // Mr./Ms./etc firstName: string; lastName: string; companyName: string; // legalName or displayName displayName: string; email: string; phone: string; billingName: string; billingAddress: string; billingCity: string; billingState: string; billingCountry: string; billingPostal: string; gstin: string | null; panNumber: string | null; tdsDeduction: boolean; gstTreatment: string; // "Registered Business - Regular" | "Unregistered Business" | "SEZ" | "Overseas" taxType: string | null; // "IGST" | "SGST+CGST" | null taxRate: number; placeOfSupply: string; }): Promise<{ zohoCustomerId: string }> export async function createZohoInvoice(params: { zohoCustomerId: string; invoiceId: string; // LM Portal invoice ID (for logging) lineItems: { description: string; quantity: number; unitPrice: number }[]; currency: string; dueDate: Date; taxType: string | null; taxRate: number; tdsDeduction: boolean; }): Promise<{ zohoInvoiceNumber: string }>

Step 3 — Wire Zoho sync into the tenant creation route

File: apps/api/src/routers/admin/tenants.ts

After the invoice is created in the LM Portal DB, add:

// --- Zoho Books sync (best-effort, non-blocking) --- (async () => { try { const { zohoCustomerId } = await syncTenantToZohoBooks({ ... }); const { zohoInvoiceNumber } = await createZohoInvoice({ zohoCustomerId, invoiceId: invoice.id, ... }); // Update invoice: status draft → issued, store Zoho invoice number await db.invoice.update({ where: { id: invoice.id }, data: { status: "issued", externalInvoiceNumber: zohoInvoiceNumber }, }); } catch (err) { fastify.log.error({ err, invoiceId: invoice.id }, "[create-tenant] Zoho sync failed"); // Send internal alert email enqueueNotification({ tenantId, channel: "email", type: "draft_invoice_alert", templateSlug: "draft_invoice_alert", recipients: [{ name: "Tech Team", email: process.env.TECH_TEAM_EMAIL! }], variables: { tenantName: tenant.name, invoiceId: invoice.id, billedOn: now.toISOString(), createdBy: actor?.name ?? "Super Admin", errorMessage: String(err), }, priority: "high", }).catch(() => {}); } })();

The Zoho sync runs asynchronously after the response is sent so the admin sees a success toast regardless of Zoho outcome (matching BRD Section 7 — Alternative Path A).

Step 4 — Add DB fields for Zoho sync state

Add to Subscription model (or a new TenantBillingProfile model):

  • zohoCustomerId String? — Zoho Books contact ID
  • zohoSyncedAt DateTime? — last successful sync time

Add to Invoice model:

  • externalInvoiceNumber String? — Zoho invoice number (currently invoiceNumber refers to the internal LM Portal INV-YYYY-MM-NNNN number)

Step 5 — Create draft_invoice_alert email template

Seed a new EmailTemplate with slug: "draft_invoice_alert", tenantId: null using the HTML from BRD Section 11.3. Template variables: tenantName, invoiceId, billedOn, createdBy, errorMessage.


Gap 6 — GSTIN Format Validation Missing

Problem

The form accepts any string in the GSTIN field. The BRD requires format validation matching the Indian GST number pattern: ^\d{2}[A-Z]{5}\d{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$ (15 characters).

Fix

In the form — CreateTenantForm.tsx

Add client-side validation in the submit handler:

if (hasGST && gstin) { const gstinPattern = /^\d{2}[A-Z]{5}\d{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$/; if (!gstinPattern.test(gstin.toUpperCase())) { setErrors(e => ({ ...e, gstin: 'Invalid GST number format. Must be 15 characters (e.g., 29ABCDE1234F1Z5)' })); return; } }

Also auto-uppercase the field value on input change.

In the API — apps/api/src/routers/admin/tenants.ts

Add server-side validation:

if (body.gstin) { const gstinPattern = /^\d{2}[A-Z]{5}\d{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$/; if (!gstinPattern.test(body.gstin.toUpperCase())) { return apiError(reply, 400, "VALIDATION_ERROR", "Invalid GSTIN format."); } }

Gap 7 — PAN Number Not Collected or Stored

Problem

PAN number is required for Indian GST compliance and syncing to Zoho Books. The field does not exist in the form, the API body, or the database schema.

Fix

Step 1 — DB schema: add panNumber to Subscription

File: packages/db/prisma/schema.prisma

// inside the Subscription model, near billingGstin billingPanNumber String? // PAN number (India only)

Run pnpm db:migrate.

Step 2 — API: accept panNumber in the body

File: apps/api/src/routers/admin/tenants.ts

Add to the body schema:

panNumber: { type: "string", description: "PAN number for India tenants (optional)" }

Add PAN validation:

if (body.panNumber) { const panPattern = /^[A-Z]{5}[0-9]{4}[A-Z]{1}$/; if (!panPattern.test(body.panNumber.toUpperCase())) { return apiError(reply, 400, "VALIDATION_ERROR", "Invalid PAN number format."); } }

Store on subscription create:

billingPanNumber: body.panNumber?.toUpperCase() || null,

Step 3 — Form: add PAN input field

File: apps/manage/src/app/(manage)/tenants/create/CreateTenantForm.tsx

In Section 2 (Company Information), below the Display Name field, add:

{/* PAN Number — India only */} {country === "IN" && ( <div> <label className={labelCls}>PAN Number <span style={{ color: "#9ca3af" }}>(Optional)</span></label> <input className={inputCls} style={inputStyle} placeholder="e.g. ABCDE1234F" value={panNumber} onChange={e => setPanNumber(e.target.value.toUpperCase())} maxLength={10} /> {errors.panNumber && <p className={errCls} style={errStyle}>{errors.panNumber}</p>} </div> )}

Add PAN format validation in the submit handler (same pattern as above).


Gap 8 — SEZ Customer Checkbox Not in Admin Form

Problem

The billingSezStatus field exists on the Subscription model and is populated from the self-signup flow, but the admin form has no SEZ checkbox. The BRD specifies the checkbox appears when a valid GSTIN is entered.

Fix

Form: add SEZ checkbox

File: apps/manage/src/app/(manage)/tenants/create/CreateTenantForm.tsx

In Section 1 (Location & Tax Details), show the checkbox when hasGST === "yes" and gstin is non-empty:

{hasGST === "yes" && gstin && ( <label className="flex items-start gap-2 cursor-pointer"> <input type="checkbox" checked={isSez} onChange={e => setIsSez(e.target.checked)} className="mt-0.5" /> <span className="text-xs text-slate-300"> This is a SEZ (Special Economic Zone) customer <span className="block text-xs text-slate-500 mt-0.5"> SEZ customers are exempt from GST. Select this only if the customer is registered under a Special Economic Zone. </span> </span> </label> )}

API: accept and store sezCustomer

File: apps/api/src/routers/admin/tenants.ts

Add to body schema:

sezCustomer: { type: "boolean" }

Store on subscription create:

billingSezStatus: body.sezCustomer ? "yes" : "no",

Gap 9, 10 & 11 — GST Treatment, Tax Type, Place of Supply Not Stored

Problem

The tax summary card in the form is a display-only UI widget — the computed values (gstTreatment, taxType, placeOfSupply) are never sent to the API and never stored. This means Zoho Books cannot be correctly configured and audit queries cannot be run on tax treatment history.

Fix

Step 1 — DB schema: add tax fields to Subscription

File: packages/db/prisma/schema.prisma

// inside Subscription model, near billingGstin gstTreatment String? // "Registered Business - Regular" | "Unregistered Business" | "SEZ" | "Overseas" taxType String? // "IGST" | "SGST+CGST" | null (for SEZ/Overseas) placeOfSupply String? // Indian state name or "Foreign Country"

Run pnpm db:migrate.

Step 2 — Compute these values server-side in the API

File: apps/api/src/routers/admin/tenants.ts

Extract a pure function (can live in apps/api/src/lib/tax.ts):

export function computeTaxSettings(params: { country: string; state: string | null; hasGstin: boolean; isSez: boolean; }): { gstTreatment: string; taxType: string | null; taxRate: number; placeOfSupply: string; } { if (params.country !== "IN") { return { gstTreatment: "Overseas", taxType: null, taxRate: 0, placeOfSupply: "Foreign Country" }; } if (params.hasGstin && params.isSez) { return { gstTreatment: "SEZ", taxType: null, taxRate: 0, placeOfSupply: params.state ?? "" }; } const treatment = params.hasGstin ? "Registered Business - Regular" : "Unregistered Business"; const isKerala = params.state === "Kerala"; return { gstTreatment: treatment, taxType: isKerala ? "SGST+CGST" : "IGST", taxRate: 18, placeOfSupply: params.state ?? "", }; }

Call this in the tenant creation handler and store the result on the subscription.

Step 3 — Add state to the API body

File: apps/api/src/routers/admin/tenants.ts

The state field (Indian state for tax purposes) is currently not included in the API body even though the form collects it. Add:

state: { type: "string", description: "Indian state (for GST tax type calculation)" }

Store it: billingState currently holds the billing address state; the tax state is separate. Consider:

  • Add a dedicated taxState String? field on Subscription, or
  • Accept taxState as a separate body field alongside billingState

Step 4 — Update the form to send the new fields

File: apps/manage/src/app/(manage)/tenants/create/actions.ts

Add state, sezCustomer, panNumber, gstTreatment, taxType, placeOfSupply to the payload sent to POST /admin/v1/tenants.


Gap 12 — IGST/SGST/CGST Amounts Not Stored on Invoice

Problem

The Invoice model stores a single taxAmount with a flat taxPercent. Indian GST compliance requires the invoice to break down the tax as either:

  • IGST: single 18% line
  • SGST + CGST: two 9% lines each

This breakdown needs to be stored on the invoice and displayed on invoice PDFs.

Fix

DB schema: add GST component fields to Invoice

File: packages/db/prisma/schema.prisma

// inside Invoice model, near taxAmount igstAmount Int? // in smallest currency unit; set when taxType = "IGST" sgstAmount Int? // in smallest currency unit; set when taxType = "SGST+CGST" cgstAmount Int? // in smallest currency unit; set when taxType = "SGST+CGST" taxType String? // "IGST" | "SGST+CGST" | null placeOfSupply String? gstTreatment String?

API: populate these fields when creating the invoice

File: apps/api/src/routers/admin/tenants.ts

const taxSettings = computeTaxSettings({ country, state, hasGstin, isSez }); const igstAmount = taxSettings.taxType === "IGST" ? taxAmount : null; const sgstAmount = taxSettings.taxType === "SGST+CGST" ? taxAmount / 2 : null; const cgstAmount = taxSettings.taxType === "SGST+CGST" ? taxAmount / 2 : null; await db.invoice.create({ data: { ... taxType: taxSettings.taxType, placeOfSupply: taxSettings.placeOfSupply, gstTreatment: taxSettings.gstTreatment, igstAmount, sgstAmount, cgstAmount, }, });

Gap 13 — Invoice Status Naming Mismatch

Problem

The BRD uses Draft and Issued as invoice statuses. The code uses pending. There is also no draft state in the current status flow — the Zoho sync happens asynchronously after a pending invoice is already created.

Recommendation

Two options:

Option A — Align code to BRD (recommended):

  • Rename pendingdraft when invoice is first created (before Zoho sync)
  • After successful Zoho sync → update to issued
  • This requires adding a DB migration to rename the enum value and updating all references

Option B — Update BRD to reflect code:

  • Document that the actual states are pending (pre-payment) and the BRD’s “Issued” maps to the same pending state since the invoice is already visible to customers
  • Simpler but maintains a terminology gap between BRD and code

Gap 14 — Password Generator Length

Problem

The form’s generatePassword() function produces a 16-character password. The BRD specifies 8 characters (minimum recommended).

Fix

File: apps/manage/src/app/(manage)/tenants/create/CreateTenantForm.tsx

// Change length from 16 to 8 return Array.from({ length: 8 }, () => chars[Math.floor(Math.random() * chars.length)]).join("");

Note: 16 characters is more secure. Confirm with product whether BRD’s “8 characters” is a minimum (in which case 16 is fine) or an exact length requirement.


Gap 15 — TDS Percentage Discrepancy

Problem

The BRD states TDS is fixed at 2%. The Region table seeds tdsPercent for India as 10%.

Fix

If BRD is correct (2% for SaaS services under section 194J): Update the India region seed in packages/db/src/seed.ts to tdsPercent: 2. Run pnpm db:seed on staging to update.

If 10% is correct (10% for professional services): Update the BRD and document the applicable TDS section.

Action required: Confirm the correct TDS section (194J at 2% vs 194J at 10%) with the finance/tax team before making changes.


Implementation Task Plan

Phase 1 — Emails ✅ Complete

TaskFileStatus
1.1 Seed welcome and credentials email templatespackages/db/src/seed.ts
1.2 Seed draft_invoice_alert internal email templatepackages/db/src/seed.ts
1.3 Enqueue welcome email on admin tenant creationapps/api/src/routers/admin/tenants.ts
1.4 Enqueue credentials email on admin tenant creationapps/api/src/routers/admin/tenants.ts
1.5 Capture plain password before hashing for credentials emailapps/api/src/routers/admin/tenants.ts

Phase 2 — Form Validation Fixes ✅ Complete

TaskFileStatus
2.1 Add GSTIN format validation (client + server)CreateTenantForm.tsx + admin/tenants.ts
2.2 Auto-uppercase GSTIN fieldCreateTenantForm.tsx
2.3 Confirm and fix password generator length (8 vs 16)CreateTenantForm.tsx⚪ Kept at 16 (product decision)

Phase 3 — Tax Fields ✅ Complete

TaskFileStatus
3.1 Add billingPanNumber, gstTreatment, taxType, placeOfSupply to Subscription modelschema.prisma
3.2 Add igstAmount, sgstAmount, cgstAmount, taxType, placeOfSupply, gstTreatment to Invoice modelschema.prisma
3.3 Run DB migration20260420_add_tax_zoho_fields
3.4 Create computeTaxSettings() helperapps/api/src/lib/tax.ts
3.5 Add PAN field to form (India-only, with format validation)CreateTenantForm.tsx
3.6 Add SEZ checkbox to form (shown when GSTIN entered)CreateTenantForm.tsx
3.7 Update API to accept + store all new tax fieldsapps/api/src/routers/admin/tenants.ts
3.8 Update actions.ts to send all new fields in the POST bodyapps/manage/.../tenants/create/actions.ts
3.9 Fix TDS % to 2%packages/db/src/seed.ts

Phase 4 — Zoho Books Auto-Sync ✅ Complete (env vars pending)

TaskFileStatus
4.1 System Zoho OAuth token via env vars (Option A)Architecture decision
4.2 System-level token refresh with in-process cacheapps/api/src/services/zoho-books-sync.ts
4.3 Create zoho-books-sync.ts service moduleapps/api/src/services/zoho-books-sync.ts
4.4 Add zohoCustomerId, zohoSyncedAt to Subscription modelschema.prisma
4.5 Add externalInvoiceNumber to Invoice modelschema.prisma
4.6 Run DB migration20260420_add_tax_zoho_fields
4.7 Wire Zoho sync (async, best-effort) into tenant creation routeapps/api/src/routers/admin/tenants.ts
4.8 Implement draft invoice alert email on Zoho failureapps/api/src/routers/admin/tenants.ts
4.9 Handle “Zoho profile already exists” edge caseapps/api/src/services/zoho-books-sync.ts⏳ Deferred
4.10 Invoice status rename (pendingdraft/issued)Multiple files✅ Option A

Effort Key

SymbolMeaning
XS< 30 minutes
S30 min – 2 hours
MHalf a day
LFull day

Implementation Notes (April 20, 2026)

Key decisions made during implementation

  • Gap 14 (password length): Kept at 16 characters. 16 is more secure than 8; BRD value treated as minimum not exact.
  • Gap 13 (invoice status): Option A chosen. pendingdraft on admin creation; issued after Zoho sync.
  • Tax state: Reused billingState for GST tax type computation (body.billingState || body.state). No separate taxState field needed.
  • Zoho auth: Option A — system-level env vars (ZOHO_BOOKS_CLIENT_ID, ZOHO_BOOKS_CLIENT_SECRET, ZOHO_BOOKS_REFRESH_TOKEN, ZOHO_BOOKS_ORG_ID, ZOHO_BOOKS_ACCOUNTS_SERVER). Add to apps/api/.env before going live.
  • Alert email recipient: New TECH_TEAM_EMAIL env var. Default: moble@leadmetrics.ai.

Files changed

FileChange
packages/db/prisma/schema.prismaAdded 13 new fields on Subscription + Invoice models
packages/db/prisma/migrations/20260420_add_tax_zoho_fields/New migration for all tax + Zoho fields
packages/db/src/seed.tsTDS 10→2%; added credentials + draft_invoice_alert email templates
packages/common/src/invoice.tsAdded "issued" to VALID_INVOICE_STATUSES
apps/api/src/lib/tax.tsNEW — computeTaxSettings() pure function
apps/api/src/services/zoho-books-sync.tsNEW — syncTenantToZohoBooks() + createZohoInvoice()
apps/api/src/routers/admin/tenants.tsFull overhaul: GSTIN/PAN validation, emails, tax fields, Zoho sync, draft status
apps/api/src/routers/admin/billing.tsAdded "issued" to status enums; fixed listTenantInvoices pagination schema
apps/manage/.../tenants/create/CreateTenantForm.tsxAdded PAN input, SEZ checkbox, GSTIN format validation
apps/manage/.../tenants/create/actions.tsAdded panNumber, sezCustomer to CreateTenantInput
apps/manage/.../invoices/actions.tsManual invoice status "pending""draft"
apps/manage/tests/e2e/global-setup.tsFixed tdsPercent: 10tdsPercent: 2

Test fixes applied

FileFix
packages/common/src/__tests__/invoice.test.tsUpdated length assertion 5→6; added "issued" to it.each
apps/api/src/routers/admin/billing.tsFixed listTenantInvoices response schema to include page, limit, totalPages
leadmetrics_api_test DBDropped and recreated with migrate deploy + db push + seed
leadmetrics_manage_test DBCreated new DB with migrate deploy + db push + seed

Outstanding (not implemented)

  • Gap 4.9: “Zoho profile already exists” edge case — if the same tenant creation is retried, a duplicate Zoho customer may be created. Low risk in normal usage.
  • Zoho env vars: ZOHO_BOOKS_* vars added to apps/api/.env as blank strings. Must be filled with real credentials before testing Zoho sync in staging/production.

© 2026 Leadmetrics — Internal use only