# Quotation Delivery — PDF + Email + WhatsApp + Fixed Reminders

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** When a quotation is dispatched, the customer receives an email with a PDF attachment and a WhatsApp message with the payment link; unpaid quotations trigger a fixed 3-step reminder sequence (on due, +2 days, +5 days).

**Architecture:** Two new services — `quotation-pdf.service.ts` (generates a PDF buffer via `@react-pdf/renderer`) and `quotation-notifier.service.ts` (channel-agnostic: calls PDF service, enqueues email with attachment, sends WhatsApp via Twilio). `dispatchQuotation` and `sendQuotationReminder` both delegate to the notifier. The reminder schedule is replaced with a hard-coded constant.

**Tech Stack:** Next.js 14 App Router, Prisma 6, `@react-pdf/renderer` v3, Twilio WhatsApp (`twilioWhatsAppService.getCredentialsForOrg` + `sendTextMessage`), Redis job queue (`enqueueJob`), Jest

---

## Task 1: Install `@react-pdf/renderer`

**Files:**

- Modify: `package.json`

**Step 1: Install the package**

```bash
cd /Users/Asim/Desktop/mawidi_codex/mawidi-site && npm install @react-pdf/renderer 2>&1 | tail -5
```

Expected: `added N packages` with no errors.

**Step 2: Verify types are available**

```bash
cd /Users/Asim/Desktop/mawidi_codex/mawidi-site && node -e "require('@react-pdf/renderer')" && echo "OK"
```

Expected: `OK`

**Step 3: Commit**

```bash
cd /Users/Asim/Desktop/mawidi_codex && git add mawidi-site/package.json mawidi-site/package-lock.json && git commit -m "chore(quotations): install @react-pdf/renderer for PDF generation"
```

---

## Task 2: Build `quotation-pdf.service.ts`

**Files:**

- Create: `mawidi-site/lib/services/quotation-pdf.service.ts`
- Test: `mawidi-site/__tests__/lib/services/quotation-pdf.test.ts` (new)

**Step 1: Write the failing test**

Create `mawidi-site/__tests__/lib/services/quotation-pdf.test.ts`:

```typescript
jest.mock("@react-pdf/renderer", () => ({
  Document: ({ children }: { children: React.ReactNode }) => children,
  Page: ({ children }: { children: React.ReactNode }) => children,
  View: ({ children }: { children: React.ReactNode }) => children,
  Text: ({ children }: { children: React.ReactNode }) => children,
  StyleSheet: { create: (s: Record<string, unknown>) => s },
  renderToBuffer: jest.fn().mockResolvedValue(Buffer.from("MOCK_PDF_DATA")),
}));

import React from "react";
import { renderToBuffer } from "@react-pdf/renderer";
import { generateQuotationPdf } from "@/lib/services/quotation-pdf.service";

const mockQuotation = {
  quotationNumber: "Q-20260226-0001",
  title: "Website Design",
  createdAt: new Date("2026-02-26"),
  dueDate: new Date("2026-03-12"),
  total: 5000,
  currency: "QAR",
  taxRate: 10,
  discountRate: 0,
  portalToken: "token-abc",
  customer: {
    name: "Ahmed Al-Farsi",
    email: "ahmed@example.com",
    phone: "+97450000001",
  },
  items: [
    {
      description: "Homepage design",
      quantity: 1,
      unitPrice: 3000,
      total: 3000,
    },
    {
      description: "Inner pages (x4)",
      quantity: 4,
      unitPrice: 500,
      total: 2000,
    },
  ],
};

describe("generateQuotationPdf", () => {
  beforeEach(() => jest.clearAllMocks());

  it("returns a non-empty Buffer", async () => {
    const result = await generateQuotationPdf(mockQuotation);
    expect(Buffer.isBuffer(result)).toBe(true);
    expect(result.length).toBeGreaterThan(0);
  });

  it("calls renderToBuffer once", async () => {
    await generateQuotationPdf(mockQuotation);
    expect(renderToBuffer).toHaveBeenCalledTimes(1);
  });

  it("works when customer is null", async () => {
    const result = await generateQuotationPdf({
      ...mockQuotation,
      customer: null,
    });
    expect(Buffer.isBuffer(result)).toBe(true);
  });

  it("works when items is empty", async () => {
    const result = await generateQuotationPdf({ ...mockQuotation, items: [] });
    expect(Buffer.isBuffer(result)).toBe(true);
  });
});
```

**Step 2: Run test to verify it fails**

```bash
cd /Users/Asim/Desktop/mawidi_codex/mawidi-site && npx jest --testPathPattern="quotation-pdf" --no-coverage --forceExit 2>&1 | tail -10
```

Expected: FAIL — `Cannot find module '@/lib/services/quotation-pdf.service'`

**Step 3: Implement `quotation-pdf.service.ts`**

Create `mawidi-site/lib/services/quotation-pdf.service.ts`:

```typescript
/**
 * Quotation PDF Service
 * Generates a PDF buffer from a quotation using @react-pdf/renderer (v3)
 */

import React from 'react';
import { Document, Page, View, Text, StyleSheet, renderToBuffer } from '@react-pdf/renderer';

// ═══════════════════════════════════════════════════════════════════════
// Input type
// ═══════════════════════════════════════════════════════════════════════

export interface PdfQuotationInput {
  quotationNumber: string;
  title: string;
  createdAt: Date;
  dueDate?: Date | null;
  total: number;
  currency: string;
  taxRate?: number | null;
  discountRate?: number | null;
  portalToken?: string | null;
  customer?: {
    name?: string | null;
    email?: string | null;
    phone?: string | null;
  } | null;
  items?: Array<{
    description: string;
    quantity: number;
    unitPrice: number;
    total: number;
  }>;
}

// ═══════════════════════════════════════════════════════════════════════
// Styles
// ═══════════════════════════════════════════════════════════════════════

const styles = StyleSheet.create({
  page:         { padding: 40, fontSize: 11, fontFamily: 'Helvetica', color: '#111827' },
  header:       { marginBottom: 20 },
  title:        { fontSize: 20, fontFamily: 'Helvetica-Bold', marginBottom: 4 },
  subtitle:     { fontSize: 12, color: '#6b7280' },
  section:      { marginBottom: 14 },
  sectionTitle: { fontSize: 11, fontFamily: 'Helvetica-Bold', marginBottom: 6, color: '#374151', textTransform: 'uppercase' },
  row:          { flexDirection: 'row', marginBottom: 3 },
  label:        { width: 110, color: '#6b7280', fontSize: 10 },
  value:        { flex: 1, fontSize: 10 },
  tableHeader:  { flexDirection: 'row', backgroundColor: '#f9fafb', padding: '6 8', borderBottomWidth: 1, borderBottomColor: '#e5e7eb' },
  tableRow:     { flexDirection: 'row', padding: '5 8', borderBottomWidth: 1, borderBottomColor: '#f3f4f6' },
  colDesc:      { flex: 3, fontSize: 10 },
  colNum:       { flex: 1, textAlign: 'right', fontSize: 10 },
  colNumBold:   { flex: 1, textAlign: 'right', fontSize: 10, fontFamily: 'Helvetica-Bold' },
  totalsBox:    { marginTop: 8, alignItems: 'flex-end' },
  totalRow:     { flexDirection: 'row', marginBottom: 3 },
  totalLabel:   { width: 110, color: '#6b7280', fontSize: 10 },
  totalValue:   { width: 80, textAlign: 'right', fontSize: 10 },
  grandLabel:   { width: 110, fontSize: 12, fontFamily: 'Helvetica-Bold', color: '#16a34a' },
  grandValue:   { width: 80, textAlign: 'right', fontSize: 12, fontFamily: 'Helvetica-Bold', color: '#16a34a' },
  footer:       { marginTop: 24, borderTopWidth: 1, borderTopColor: '#e5e7eb', paddingTop: 10, color: '#6b7280', fontSize: 9 },
  divider:      { borderBottomWidth: 1, borderBottomColor: '#e5e7eb', marginVertical: 12 },
});

// ═══════════════════════════════════════════════════════════════════════
// PDF Document component
// ═══════════════════════════════════════════════════════════════════════

function QuotationPdf({ q }: { q: PdfQuotationInput }) {
  const fmt = new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: (q.currency || 'QAR').toUpperCase(),
    minimumFractionDigits: 2,
  });

  const baseUrl = process.env.NEXTAUTH_URL ?? 'http://localhost:9000';
  const portalUrl = q.portalToken ? `${baseUrl}/quotation/${q.portalToken}` : null;

  const subtotal = (q.items ?? []).reduce((s, i) => s + i.total, 0) || q.total;
  const discountAmount = q.discountRate ? subtotal * q.discountRate / 100 : 0;
  const taxAmount = q.taxRate ? (subtotal - discountAmount) * q.taxRate / 100 : 0;

  return (
    <Document>
      <Page size="A4" style={styles.page}>

        {/* Header */}
        <View style={styles.header}>
          <Text style={styles.title}>{q.title}</Text>
          <Text style={styles.subtitle}>Quotation #{q.quotationNumber}</Text>
        </View>

        <View style={styles.divider} />

        {/* Dates */}
        <View style={styles.section}>
          <View style={styles.row}>
            <Text style={styles.label}>Date Issued:</Text>
            <Text style={styles.value}>{q.createdAt.toLocaleDateString('en-GB')}</Text>
          </View>
          {q.dueDate && (
            <View style={styles.row}>
              <Text style={styles.label}>Payment Due:</Text>
              <Text style={styles.value}>{q.dueDate.toLocaleDateString('en-GB')}</Text>
            </View>
          )}
        </View>

        {/* Customer */}
        {q.customer && (
          <View style={styles.section}>
            <Text style={styles.sectionTitle}>Customer</Text>
            {q.customer.name  && <View style={styles.row}><Text style={styles.label}>Name:</Text><Text style={styles.value}>{q.customer.name}</Text></View>}
            {q.customer.email && <View style={styles.row}><Text style={styles.label}>Email:</Text><Text style={styles.value}>{q.customer.email}</Text></View>}
            {q.customer.phone && <View style={styles.row}><Text style={styles.label}>Phone:</Text><Text style={styles.value}>{q.customer.phone}</Text></View>}
          </View>
        )}

        <View style={styles.divider} />

        {/* Line items */}
        {q.items && q.items.length > 0 && (
          <View style={styles.section}>
            <Text style={styles.sectionTitle}>Items</Text>
            <View style={styles.tableHeader}>
              <Text style={[styles.colDesc, { fontFamily: 'Helvetica-Bold' }]}>Description</Text>
              <Text style={[styles.colNum,  { fontFamily: 'Helvetica-Bold' }]}>Qty</Text>
              <Text style={[styles.colNum,  { fontFamily: 'Helvetica-Bold' }]}>Unit Price</Text>
              <Text style={[styles.colNum,  { fontFamily: 'Helvetica-Bold' }]}>Total</Text>
            </View>
            {q.items.map((item, i) => (
              <View key={i} style={styles.tableRow}>
                <Text style={styles.colDesc}>{item.description}</Text>
                <Text style={styles.colNum}>{item.quantity}</Text>
                <Text style={styles.colNum}>{fmt.format(item.unitPrice)}</Text>
                <Text style={styles.colNumBold}>{fmt.format(item.total)}</Text>
              </View>
            ))}
          </View>
        )}

        {/* Totals */}
        <View style={styles.totalsBox}>
          {discountAmount > 0 && (
            <View style={styles.totalRow}>
              <Text style={styles.totalLabel}>Discount ({q.discountRate}%):</Text>
              <Text style={styles.totalValue}>-{fmt.format(discountAmount)}</Text>
            </View>
          )}
          {taxAmount > 0 && (
            <View style={styles.totalRow}>
              <Text style={styles.totalLabel}>Tax ({q.taxRate}%):</Text>
              <Text style={styles.totalValue}>{fmt.format(taxAmount)}</Text>
            </View>
          )}
          <View style={styles.totalRow}>
            <Text style={styles.grandLabel}>Total:</Text>
            <Text style={styles.grandValue}>{fmt.format(q.total)}</Text>
          </View>
        </View>

        <View style={styles.divider} />

        {/* Footer */}
        <View style={styles.footer}>
          {portalUrl && <Text>View & Pay Online: {portalUrl}</Text>}
          <Text style={{ marginTop: 6 }}>© {new Date().getFullYear()} Mawidi. All rights reserved.</Text>
        </View>

      </Page>
    </Document>
  );
}

// ═══════════════════════════════════════════════════════════════════════
// Public API
// ═══════════════════════════════════════════════════════════════════════

/**
 * Generate a PDF buffer for a quotation.
 * Returns a Buffer that can be attached to an email or streamed to the client.
 */
export async function generateQuotationPdf(quotation: PdfQuotationInput): Promise<Buffer> {
  const buffer = await renderToBuffer(<QuotationPdf q={quotation} />);
  return Buffer.from(buffer);
}
```

**Step 4: Add `jsx` support for the service file if needed**

Check `mawidi-site/tsconfig.json` — ensure `"jsx": "react-jsx"` or `"jsx": "preserve"` is set (it already should be for Next.js). No change needed if it compiles fine.

**Step 5: Run tests to verify they pass**

```bash
cd /Users/Asim/Desktop/mawidi_codex/mawidi-site && npx jest --testPathPattern="quotation-pdf" --no-coverage --forceExit 2>&1 | tail -10
```

Expected: PASS — 4 tests passing

**Step 6: TypeScript check**

```bash
cd /Users/Asim/Desktop/mawidi_codex/mawidi-site && npx tsc --noEmit 2>&1 | grep "quotation-pdf" | head -5
```

Expected: no output

**Step 7: Commit**

```bash
cd /Users/Asim/Desktop/mawidi_codex && git add mawidi-site/lib/services/quotation-pdf.service.ts mawidi-site/__tests__/lib/services/quotation-pdf.test.ts && git commit -m "feat(quotations): add quotation PDF generation service using @react-pdf/renderer"
```

---

## Task 3: Build `quotation-notifier.service.ts`

**Files:**

- Create: `mawidi-site/lib/services/quotation-notifier.service.ts`
- Test: `mawidi-site/__tests__/lib/services/quotation-notifier.test.ts` (new)

### Background

This service is the single place that handles all quotation customer notifications. It:

1. Generates a PDF buffer (non-blocking on failure)
2. Enqueues an email with the PDF attached
3. Looks up the org's Twilio credentials via `twilioWhatsAppService.getCredentialsForOrg(org.id)` — `organizations.ownerId === users.id`, so `prisma.organizations.findUnique({ where: { ownerId: quotation.userId } })` gets the org
4. Sends a WhatsApp message via `twilioWhatsAppService.sendTextMessage(creds, 'whatsapp:+PHONE', body)`

**Step 1: Write failing tests**

Create `mawidi-site/__tests__/lib/services/quotation-notifier.test.ts`:

```typescript
jest.mock("@/lib/services/quotation-pdf.service", () => ({
  generateQuotationPdf: jest.fn().mockResolvedValue(Buffer.from("MOCK_PDF")),
}));
jest.mock("@/lib/queue/job-queue", () => ({
  enqueueJob: jest.fn().mockResolvedValue("job-1"),
}));
jest.mock("@/lib/services/email/templates/quotation-templates", () => ({
  createQuotationSentEmail: jest.fn().mockReturnValue({
    subject: "New Quotation",
    html: "<p>hi</p>",
    text: "hi",
  }),
  createQuotationReminderEmail: jest
    .fn()
    .mockReturnValue({ subject: "Reminder", html: "<p>pay</p>", text: "pay" }),
  createQuotationOverdueEmail: jest.fn().mockReturnValue({
    subject: "Overdue",
    html: "<p>overdue</p>",
    text: "overdue",
  }),
}));
jest.mock("@/lib/db", () => ({
  prisma: {
    organizations: {
      findUnique: jest.fn().mockResolvedValue({ id: "org-1" }),
    },
  },
}));
jest.mock("@/lib/services/twilio-whatsapp.service", () => ({
  twilioWhatsAppService: {
    getCredentialsForOrg: jest.fn().mockResolvedValue({
      accountSid: "ACtest",
      authToken: "token",
      whatsappNumber: "+14155551234",
    }),
    sendTextMessage: jest
      .fn()
      .mockResolvedValue({ success: true, messageSid: "SM123" }),
  },
}));

import { prisma } from "@/lib/db";
import { enqueueJob } from "@/lib/queue/job-queue";
import { twilioWhatsAppService } from "@/lib/services/twilio-whatsapp.service";
import * as pdfService from "@/lib/services/quotation-pdf.service";
import { notifyQuotationCustomer } from "@/lib/services/quotation-notifier.service";

const mockQuotation = {
  id: "q1",
  userId: "u1",
  quotationNumber: "Q-20260226-0001",
  title: "Website Design",
  total: 5000,
  amountPaid: 0,
  currency: "QAR",
  dueDate: new Date("2026-03-12"),
  portalToken: "token-abc",
  createdAt: new Date("2026-02-26"),
  taxRate: 0,
  discountRate: 0,
  customer: {
    name: "Ahmed",
    email: "ahmed@example.com",
    phone: "+97450000001",
  },
  items: [{ description: "Design", quantity: 1, unitPrice: 5000, total: 5000 }],
};

describe("notifyQuotationCustomer", () => {
  beforeEach(() => jest.clearAllMocks());

  it("enqueues email and sends WhatsApp when both email and phone present", async () => {
    const result = await notifyQuotationCustomer(mockQuotation, "dispatch");
    expect(enqueueJob).toHaveBeenCalledWith(
      "email",
      expect.objectContaining({ to: "ahmed@example.com" }),
      expect.anything(),
    );
    expect(twilioWhatsAppService.sendTextMessage).toHaveBeenCalled();
    expect(result.emailQueued).toBe(true);
    expect(result.whatsappSent).toBe(true);
  });

  it("skips email when customer has no email", async () => {
    const q = {
      ...mockQuotation,
      customer: { ...mockQuotation.customer, email: null },
    };
    const result = await notifyQuotationCustomer(q, "dispatch");
    expect(enqueueJob).not.toHaveBeenCalled();
    expect(result.emailQueued).toBe(false);
  });

  it("skips WhatsApp when customer has no phone", async () => {
    const q = {
      ...mockQuotation,
      customer: { ...mockQuotation.customer, phone: null },
    };
    const result = await notifyQuotationCustomer(q, "dispatch");
    expect(twilioWhatsAppService.sendTextMessage).not.toHaveBeenCalled();
    expect(result.whatsappSent).toBe(false);
  });

  it("skips WhatsApp when org has no Twilio credentials", async () => {
    (
      twilioWhatsAppService.getCredentialsForOrg as jest.Mock
    ).mockResolvedValueOnce(null);
    const result = await notifyQuotationCustomer(mockQuotation, "dispatch");
    expect(twilioWhatsAppService.sendTextMessage).not.toHaveBeenCalled();
    expect(result.whatsappSent).toBe(false);
  });

  it("email still sends when PDF generation fails", async () => {
    (pdfService.generateQuotationPdf as jest.Mock).mockRejectedValueOnce(
      new Error("PDF error"),
    );
    const result = await notifyQuotationCustomer(mockQuotation, "dispatch");
    expect(enqueueJob).toHaveBeenCalled();
    expect(result.emailQueued).toBe(true);
  });

  it("email includes PDF as base64 attachment", async () => {
    await notifyQuotationCustomer(mockQuotation, "dispatch");
    expect(enqueueJob).toHaveBeenCalledWith(
      "email",
      expect.objectContaining({
        attachments: expect.arrayContaining([
          expect.objectContaining({
            filename: expect.stringContaining(".pdf"),
            encoding: "base64",
          }),
        ]),
      }),
      expect.anything(),
    );
  });

  it("uses reminder template for reminder type", async () => {
    await notifyQuotationCustomer(mockQuotation, "reminder");
    const {
      createQuotationReminderEmail,
    } = require("@/lib/services/email/templates/quotation-templates");
    expect(createQuotationReminderEmail).toHaveBeenCalled();
  });

  it("uses overdue template for overdue type", async () => {
    await notifyQuotationCustomer(mockQuotation, "overdue");
    const {
      createQuotationOverdueEmail,
    } = require("@/lib/services/email/templates/quotation-templates");
    expect(createQuotationOverdueEmail).toHaveBeenCalled();
  });
});
```

**Step 2: Run test to verify it fails**

```bash
cd /Users/Asim/Desktop/mawidi_codex/mawidi-site && npx jest --testPathPattern="quotation-notifier" --no-coverage --forceExit 2>&1 | tail -10
```

Expected: FAIL — `Cannot find module '@/lib/services/quotation-notifier.service'`

**Step 3: Implement `quotation-notifier.service.ts`**

Create `mawidi-site/lib/services/quotation-notifier.service.ts`:

```typescript
/**
 * Quotation Notifier Service
 *
 * Channel-agnostic notification dispatcher.
 * Called by dispatchQuotation and sendQuotationReminder.
 *
 * Sends:
 * - Email with PDF attachment (if customer has email)
 * - WhatsApp text with payment link (if customer has phone AND org has Twilio creds)
 *
 * All delivery failures are non-blocking.
 */

import { prisma } from "@/lib/db";
import { logger } from "@/lib/logger";
import { enqueueJob } from "@/lib/queue/job-queue";
import { generateQuotationPdf } from "@/lib/services/quotation-pdf.service";
import { twilioWhatsAppService } from "@/lib/services/twilio-whatsapp.service";
import {
  createQuotationSentEmail,
  createQuotationReminderEmail,
  createQuotationOverdueEmail,
} from "@/lib/services/email/templates/quotation-templates";

const log = logger.scope("QuotationNotifier");

// ═══════════════════════════════════════════════════════════════════════
// Types
// ═══════════════════════════════════════════════════════════════════════

export type NotificationType = "dispatch" | "reminder" | "overdue";

export interface NotifierQuotation {
  id: string;
  userId: string;
  quotationNumber: string;
  title: string;
  total: number;
  amountPaid: number;
  currency: string;
  dueDate?: Date | null;
  portalToken?: string | null;
  createdAt: Date;
  taxRate?: number | null;
  discountRate?: number | null;
  penaltyAmount?: number | null;
  customer?: {
    name?: string | null;
    email?: string | null;
    phone?: string | null;
  } | null;
  items?: Array<{
    description: string;
    quantity: number;
    unitPrice: number;
    total: number;
  }>;
}

export interface NotifyResult {
  emailQueued: boolean;
  whatsappSent: boolean;
}

// ═══════════════════════════════════════════════════════════════════════
// Main function
// ═══════════════════════════════════════════════════════════════════════

export async function notifyQuotationCustomer(
  quotation: NotifierQuotation,
  type: NotificationType,
): Promise<NotifyResult> {
  const baseUrl = process.env.NEXTAUTH_URL ?? "http://localhost:9000";
  const portalUrl = quotation.portalToken
    ? `${baseUrl}/quotation/${quotation.portalToken}`
    : `${baseUrl}/dashboard`;

  // 1. Generate PDF (non-blocking on failure)
  let pdfBuffer: Buffer | null = null;
  try {
    pdfBuffer = await generateQuotationPdf({
      quotationNumber: quotation.quotationNumber,
      title: quotation.title,
      createdAt: quotation.createdAt,
      dueDate: quotation.dueDate,
      total: quotation.total,
      currency: quotation.currency,
      taxRate: quotation.taxRate,
      discountRate: quotation.discountRate,
      portalToken: quotation.portalToken,
      customer: quotation.customer,
      items: quotation.items,
    });
  } catch (err) {
    log.warn("PDF generation failed — sending without attachment", {
      quotationId: quotation.id,
      err,
    });
  }

  // 2. Email
  let emailQueued = false;
  const customerEmail = quotation.customer?.email;
  if (customerEmail) {
    try {
      const lang = "en"; // TODO: derive from customer.language when available
      const dueDate = quotation.dueDate?.toLocaleDateString("en-GB") ?? "";
      const daysUntilDue = quotation.dueDate
        ? Math.ceil((quotation.dueDate.getTime() - Date.now()) / 86_400_000)
        : 0;
      const remaining = quotation.total - quotation.amountPaid;

      let template;
      if (type === "dispatch") {
        template = createQuotationSentEmail({
          quotationNumber: quotation.quotationNumber,
          title: quotation.title,
          customerName: quotation.customer?.name ?? "Customer",
          total: quotation.total,
          currency: quotation.currency,
          dueDate,
          portalUrl,
          items: quotation.items ?? [],
          lang,
        });
      } else if (type === "reminder") {
        template = createQuotationReminderEmail({
          quotationNumber: quotation.quotationNumber,
          customerName: quotation.customer?.name ?? "Customer",
          total: quotation.total,
          amountPaid: quotation.amountPaid,
          remaining,
          dueDate,
          daysUntilDue,
          portalUrl,
          lang,
        });
      } else {
        // overdue
        template = createQuotationOverdueEmail({
          quotationNumber: quotation.quotationNumber,
          customerName: quotation.customer?.name ?? "Customer",
          total: quotation.total,
          amountPaid: quotation.amountPaid,
          penaltyAmount: quotation.penaltyAmount ?? 0,
          grandTotal: quotation.total + (quotation.penaltyAmount ?? 0),
          dueDate,
          daysOverdue: Math.abs(daysUntilDue),
          portalUrl,
          lang,
        });
      }

      const attachments = pdfBuffer
        ? [
            {
              filename: `quotation-${quotation.quotationNumber}.pdf`,
              content: pdfBuffer.toString("base64"),
              encoding: "base64" as const,
            },
          ]
        : [];

      await enqueueJob(
        "email",
        {
          to: customerEmail,
          subject: template.subject,
          html: template.html,
          text: template.text,
          category: `quotation_${type}`,
          metadata: { quotationId: quotation.id },
          attachments,
        },
        { priority: "high" },
      );

      emailQueued = true;
    } catch (err) {
      log.warn("Failed to enqueue quotation email", {
        quotationId: quotation.id,
        type,
        err,
      });
    }
  }

  // 3. WhatsApp
  let whatsappSent = false;
  const customerPhone = quotation.customer?.phone;
  if (customerPhone) {
    try {
      // Credentials live on the organization; org.ownerId === userId
      const org = await prisma.organizations.findUnique({
        where: { ownerId: quotation.userId },
        select: { id: true },
      });

      if (org) {
        const creds = await twilioWhatsAppService.getCredentialsForOrg(org.id);

        if (creds) {
          const remaining = (quotation.total - quotation.amountPaid).toFixed(2);
          const dueDate = quotation.dueDate?.toLocaleDateString("en-GB") ?? "";
          const daysOverdue = quotation.dueDate
            ? Math.abs(
                Math.ceil(
                  (quotation.dueDate.getTime() - Date.now()) / 86_400_000,
                ),
              )
            : 0;

          let body: string;
          if (type === "dispatch") {
            body = [
              `📋 New Quotation #${quotation.quotationNumber} — ${quotation.title}`,
              `Total: ${quotation.currency} ${quotation.total.toFixed(2)}`,
              `View & Pay: ${portalUrl}`,
            ].join("\n");
          } else if (type === "reminder") {
            body = [
              `⏰ Payment Reminder — Quotation #${quotation.quotationNumber}`,
              `Due: ${dueDate}  |  Remaining: ${quotation.currency} ${remaining}`,
              `Pay now: ${portalUrl}`,
            ].join("\n");
          } else {
            body = [
              `🚨 Payment Overdue — Quotation #${quotation.quotationNumber}`,
              `${daysOverdue} day${daysOverdue !== 1 ? "s" : ""} past due`,
              `Pay now: ${portalUrl}`,
            ].join("\n");
          }

          const result = await twilioWhatsAppService.sendTextMessage(
            creds,
            `whatsapp:${customerPhone}`,
            body,
          );

          whatsappSent = result.success;
          if (!result.success) {
            log.warn("WhatsApp send failed", {
              quotationId: quotation.id,
              error: result.error,
            });
          }
        }
      }
    } catch (err) {
      log.warn("Failed to send WhatsApp notification", {
        quotationId: quotation.id,
        type,
        err,
      });
    }
  }

  log.info("Quotation notification sent", {
    quotationId: quotation.id,
    type,
    emailQueued,
    whatsappSent,
  });

  return { emailQueued, whatsappSent };
}
```

**Step 4: Run tests to verify they pass**

```bash
cd /Users/Asim/Desktop/mawidi_codex/mawidi-site && npx jest --testPathPattern="quotation-notifier" --no-coverage --forceExit 2>&1 | tail -10
```

Expected: PASS — 7 tests passing

**Step 5: TypeScript check**

```bash
cd /Users/Asim/Desktop/mawidi_codex/mawidi-site && npx tsc --noEmit 2>&1 | grep "quotation-notifier" | head -5
```

Expected: no output

**Step 6: Commit**

```bash
cd /Users/Asim/Desktop/mawidi_codex && git add mawidi-site/lib/services/quotation-notifier.service.ts mawidi-site/__tests__/lib/services/quotation-notifier.test.ts && git commit -m "feat(quotations): add quotation notifier service — email+PDF+WhatsApp"
```

---

## Task 4: Update `dispatchQuotation` to use the notifier

**Files:**

- Modify: `mawidi-site/lib/services/quotations.service.ts`
- Test: `mawidi-site/__tests__/lib/services/quotations-dispatch.test.ts` (update)

### Background

`dispatchQuotation` currently inlines the email enqueue + mock SMS log. Replace both with a single call to `notifyQuotationCustomer`. Also expand `DispatchResult` to include `whatsappSent`.

**Step 1: Update the existing dispatch test**

Open `mawidi-site/__tests__/lib/services/quotations-dispatch.test.ts`.

Add a new mock at the top (before imports):

```typescript
jest.mock("@/lib/services/quotation-notifier.service", () => ({
  notifyQuotationCustomer: jest
    .fn()
    .mockResolvedValue({ emailQueued: true, whatsappSent: true }),
}));
```

Remove the existing mocks for:

- `@/lib/services/quotation-portal.service` — keep this (still used)
- `@/lib/queue/job-queue` — **remove** this mock (no longer called directly)
- `@/lib/services/email/templates/quotation-templates` — remove if present

Update the test `"enqueues an email job when customer has email"` → rename to `"calls notifyQuotationCustomer"` and assert:

```typescript
it("calls notifyQuotationCustomer with dispatch type", async () => {
  const {
    notifyQuotationCustomer,
  } = require("@/lib/services/quotation-notifier.service");
  await dispatchQuotation("u1", "q1");
  expect(notifyQuotationCustomer).toHaveBeenCalledWith(
    expect.objectContaining({ id: "q1" }),
    "dispatch",
  );
});
```

Remove the test `"returns emailQueued: false when customer has no email"` — that logic now lives in the notifier (already tested there).

Add new test:

```typescript
it("returns whatsappSent from notifier result", async () => {
  const result = await dispatchQuotation("u1", "q1");
  expect(result?.whatsappSent).toBe(true);
});
```

**Step 2: Run updated tests to verify they now fail (expect missing whatsappSent)**

```bash
cd /Users/Asim/Desktop/mawidi_codex/mawidi-site && npx jest --testPathPattern="quotations-dispatch" --no-coverage --forceExit 2>&1 | tail -15
```

Expected: some failures (notifier not yet called, whatsappSent missing)

**Step 3: Update `dispatchQuotation` in `lib/services/quotations.service.ts`**

Add import at top of file (with other service imports):

```typescript
import { notifyQuotationCustomer } from "@/lib/services/quotation-notifier.service";
```

Remove the existing imports that are no longer needed directly by this function:

- `import { createId } from '@paralleldrive/cuid2';` — keep (still used for portalToken)
- `import { createPortalCheckoutSession } from ...` — keep (Stripe checkout still here)
- `import { enqueueJob } from ...` — **remove** if only used in dispatchQuotation
- `import { createQuotationSentEmail } from ...` — **remove** if only used in dispatchQuotation

Update `DispatchResult` type:

```typescript
type DispatchResult = {
  quotation: Awaited<ReturnType<typeof prisma.quotations.update>>;
  phoneStatus: "sent";
  emailQueued: boolean;
  whatsappSent: boolean;
};
```

Replace the inline email + mock SMS block (everything after the Stripe checkout try/catch) with:

```typescript
// Notify customer via email (with PDF) + WhatsApp
const notify = await notifyQuotationCustomer(quotation, "dispatch");

return {
  quotation,
  phoneStatus: "sent",
  emailQueued: notify.emailQueued,
  whatsappSent: notify.whatsappSent,
};
```

**Step 4: Run tests to verify they pass**

```bash
cd /Users/Asim/Desktop/mawidi_codex/mawidi-site && npx jest --testPathPattern="quotations-dispatch" --no-coverage --forceExit 2>&1 | tail -10
```

Expected: PASS — all tests passing

**Step 5: TypeScript check**

```bash
cd /Users/Asim/Desktop/mawidi_codex/mawidi-site && npx tsc --noEmit 2>&1 | grep "quotations.service" | head -5
```

Expected: no output

**Step 6: Commit**

```bash
cd /Users/Asim/Desktop/mawidi_codex && git add mawidi-site/lib/services/quotations.service.ts mawidi-site/__tests__/lib/services/quotations-dispatch.test.ts && git commit -m "feat(quotations): wire notifier into dispatchQuotation — email+PDF+WhatsApp"
```

---

## Task 5: Update `quotation-reminders.service.ts` — fixed sequence + notifier

**Files:**

- Modify: `mawidi-site/lib/services/quotation-reminders.service.ts`
- Test: `mawidi-site/__tests__/lib/services/quotation-reminders.test.ts` (new)

### Background

Replace the configurable `reminderSchedule` lookup with a hard-coded 3-step constant. `processDueReminders` no longer checks `reminderEnabled` — it fires for ALL unpaid quotations with a due date. `sendQuotationReminder` replaces inline email + WhatsApp placeholder with a call to `notifyQuotationCustomer`.

**Step 1: Write failing tests**

Create `mawidi-site/__tests__/lib/services/quotation-reminders.test.ts`:

```typescript
jest.mock("@/lib/db", () => ({
  prisma: {
    quotations: {
      findMany: jest.fn(),
      findUnique: jest.fn(),
    },
    quotation_reminders: {
      findUnique: jest.fn().mockResolvedValue(null), // not already sent
      create: jest.fn().mockResolvedValue({ id: "rem-1" }),
      update: jest.fn().mockResolvedValue({}),
    },
    quotation_settings: {
      findUnique: jest.fn().mockResolvedValue(null), // settings not needed anymore
    },
  },
}));
jest.mock("@/lib/queue/job-queue", () => ({
  enqueueJob: jest.fn().mockResolvedValue("job-1"),
}));
jest.mock("@/lib/services/quotation-notifier.service", () => ({
  notifyQuotationCustomer: jest
    .fn()
    .mockResolvedValue({ emailQueued: true, whatsappSent: true }),
}));

import { prisma } from "@/lib/db";
import { enqueueJob } from "@/lib/queue/job-queue";
import { notifyQuotationCustomer } from "@/lib/services/quotation-notifier.service";
import {
  processDueReminders,
  sendQuotationReminder,
} from "@/lib/services/quotation-reminders.service";

const today = new Date();
today.setHours(0, 0, 0, 0);

describe("processDueReminders", () => {
  beforeEach(() => jest.clearAllMocks());

  it("enqueues reminder jobs for quotations due today", async () => {
    (prisma.quotations.findMany as jest.Mock).mockResolvedValue([
      { id: "q1", userId: "u1", dueDate: today },
    ]);
    const result = await processDueReminders();
    expect(enqueueJob).toHaveBeenCalledWith(
      "quotation_reminder",
      expect.objectContaining({
        quotationId: "q1",
        trigger: "on_due",
        dayOffset: 0,
      }),
      expect.anything(),
    );
    expect(result.sent).toBe(1);
  });

  it("enqueues reminder for 2 days after due", async () => {
    const twoDaysAgo = new Date(today);
    twoDaysAgo.setDate(twoDaysAgo.getDate() - 2);
    (prisma.quotations.findMany as jest.Mock).mockResolvedValue([
      { id: "q1", userId: "u1", dueDate: twoDaysAgo },
    ]);
    const result = await processDueReminders();
    expect(enqueueJob).toHaveBeenCalledWith(
      "quotation_reminder",
      expect.objectContaining({ trigger: "after_due", dayOffset: 2 }),
      expect.anything(),
    );
    expect(result.sent).toBe(1);
  });

  it("enqueues overdue notice for 5 days after due", async () => {
    const fiveDaysAgo = new Date(today);
    fiveDaysAgo.setDate(fiveDaysAgo.getDate() - 5);
    (prisma.quotations.findMany as jest.Mock).mockResolvedValue([
      { id: "q1", userId: "u1", dueDate: fiveDaysAgo },
    ]);
    const result = await processDueReminders();
    expect(enqueueJob).toHaveBeenCalledWith(
      "quotation_reminder",
      expect.objectContaining({ trigger: "after_due", dayOffset: 5 }),
      expect.anything(),
    );
  });

  it("skips already-sent reminders (dedup)", async () => {
    (prisma.quotations.findMany as jest.Mock).mockResolvedValue([
      { id: "q1", userId: "u1", dueDate: today },
    ]);
    (prisma.quotation_reminders.findUnique as jest.Mock).mockResolvedValue({
      id: "existing",
    });
    const result = await processDueReminders();
    expect(enqueueJob).not.toHaveBeenCalled();
    expect(result.skipped).toBe(1);
  });
});

describe("sendQuotationReminder", () => {
  beforeEach(() => jest.clearAllMocks());

  it("calls notifyQuotationCustomer with reminder type for dayOffset 0", async () => {
    (prisma.quotations.findUnique as jest.Mock).mockResolvedValue({
      id: "q1",
      userId: "u1",
      quotationNumber: "Q-001",
      title: "Test",
      total: 500,
      amountPaid: 0,
      currency: "QAR",
      dueDate: today,
      portalToken: "tok",
      createdAt: new Date(),
      penaltyAmount: 0,
      customer: { name: "Ahmed", email: "a@b.com", phone: "+974123" },
      items: [],
    });
    await sendQuotationReminder("q1", "on_due", 0, "email");
    expect(notifyQuotationCustomer).toHaveBeenCalledWith(
      expect.objectContaining({ id: "q1" }),
      "reminder",
    );
  });

  it("calls notifyQuotationCustomer with overdue type for dayOffset 5", async () => {
    (prisma.quotations.findUnique as jest.Mock).mockResolvedValue({
      id: "q1",
      userId: "u1",
      quotationNumber: "Q-001",
      title: "Test",
      total: 500,
      amountPaid: 0,
      currency: "QAR",
      dueDate: today,
      portalToken: "tok",
      createdAt: new Date(),
      penaltyAmount: 0,
      customer: { name: "Ahmed", email: "a@b.com", phone: "+974123" },
      items: [],
    });
    await sendQuotationReminder("q1", "after_due", 5, "both");
    expect(notifyQuotationCustomer).toHaveBeenCalledWith(
      expect.objectContaining({ id: "q1" }),
      "overdue",
    );
  });
});
```

**Step 2: Run tests to verify they fail**

```bash
cd /Users/Asim/Desktop/mawidi_codex/mawidi-site && npx jest --testPathPattern="quotation-reminders" --no-coverage --forceExit 2>&1 | tail -15
```

Expected: FAIL — `processDueReminders` still uses configurable schedule, `sendQuotationReminder` still uses inline email

**Step 3: Update `quotation-reminders.service.ts`**

**3a.** Add import at the top:

```typescript
import { notifyQuotationCustomer } from "@/lib/services/quotation-notifier.service";
```

**3b.** Add hard-coded constant after the existing `ReminderScheduleEntry` interface:

```typescript
// Hard-coded 3-step reminder sequence — fires for ALL unpaid quotations with a due date.
// Step 1: on the due date itself
// Step 2: 2 days after due
// Step 3: 5 days after due (escalated to overdue notice)
const REMINDER_SEQUENCE: ReminderScheduleEntry[] = [
  { trigger: "on_due", dayOffset: 0 },
  { trigger: "after_due", dayOffset: 2 },
  { trigger: "after_due", dayOffset: 5 },
];
```

**3c.** In `processDueReminders`, replace the per-quotation settings lookup block:

Before:

```typescript
    // Get user's quotation settings
    const settings = await prisma.quotation_settings.findUnique({
      where: { userId: quotation.userId },
    });

    if (!settings?.reminderEnabled) {
      skipped++;
      continue;
    }

    // Parse reminder schedule
    let schedule: ReminderScheduleEntry[];
    try {
      schedule = settings.reminderSchedule as unknown as ReminderScheduleEntry[];
    } catch {
      log.warn('Invalid reminder schedule for user', { userId: quotation.userId });
      skipped++;
      continue;
    }

    for (const entry of schedule) {
```

After:

```typescript
    for (const entry of REMINDER_SEQUENCE) {
```

**3d.** In `sendQuotationReminder`, replace the email enqueue block and WhatsApp placeholder with:

First update the `findUnique` include to also fetch `items` and `portalToken`:

```typescript
const quotation = await prisma.quotations.findUnique({
  where: { id: quotationId },
  include: {
    customer: true,
    items: { orderBy: { sortOrder: "asc" } },
    users: { select: { email: true, name: true } },
  },
});
```

Then replace everything from `if (channel === 'email' || ...)` down to the final `prisma.quotation_reminders.update` call with:

```typescript
// Determine notification type: day-5 is 'overdue', everything else is 'reminder'
const notificationType = dayOffset >= 5 ? "overdue" : "reminder";

let emailSent = false;
let whatsappSent = false;

if (quotation) {
  try {
    const notify = await notifyQuotationCustomer(quotation, notificationType);
    emailSent = notify.emailQueued;
    whatsappSent = notify.whatsappSent;
  } catch (err) {
    log.error("Failed to notify customer for reminder", { quotationId, err });
  }
}

// Update the reminder record with send status
await prisma.quotation_reminders.update({
  where: { id: reminder.id },
  data: { emailSent, whatsappSent },
});
```

**Step 4: Run tests to verify they pass**

```bash
cd /Users/Asim/Desktop/mawidi_codex/mawidi-site && npx jest --testPathPattern="quotation-reminders" --no-coverage --forceExit 2>&1 | tail -10
```

Expected: PASS — all tests passing

**Step 5: TypeScript check**

```bash
cd /Users/Asim/Desktop/mawidi_codex/mawidi-site && npx tsc --noEmit 2>&1 | grep "quotation-reminder" | head -5
```

Expected: no output

**Step 6: Commit**

```bash
cd /Users/Asim/Desktop/mawidi_codex && git add mawidi-site/lib/services/quotation-reminders.service.ts mawidi-site/__tests__/lib/services/quotation-reminders.test.ts && git commit -m "feat(quotations): fixed 3-step reminder sequence, wire notifier into reminders"
```

---

## Task 6: Final verification

**Step 1: Run all quotation tests**

```bash
cd /Users/Asim/Desktop/mawidi_codex/mawidi-site && npx jest --testPathPattern="quotation" --no-coverage --forceExit --runInBand 2>&1 | tail -20
```

Expected: All suites pass. Note the test count — should be higher than before (new suites added).

**Step 2: Full TypeScript check for quotation files**

```bash
cd /Users/Asim/Desktop/mawidi_codex/mawidi-site && npx tsc --noEmit 2>&1 | grep -E "(quotation|notifier|pdf)" | head -10
```

Expected: no output

**Step 3: Check git status — commit any remaining changes**

```bash
cd /Users/Asim/Desktop/mawidi_codex && git status --short | grep "mawidi-site"
```

If any quotation-related files are uncommitted, add and commit them.

**Step 4: Final summary commit if needed**

```bash
cd /Users/Asim/Desktop/mawidi_codex && git add <any remaining files> && git commit -m "chore(quotations): final cleanup after PDF+WhatsApp delivery feature"
```

---

## Summary of Changes

| File                                                 | Action | What changes                                                                     |
| ---------------------------------------------------- | ------ | -------------------------------------------------------------------------------- |
| `package.json`                                       | Modify | Add `@react-pdf/renderer`                                                        |
| `lib/services/quotation-pdf.service.ts`              | Create | PDF buffer generation                                                            |
| `lib/services/quotation-notifier.service.ts`         | Create | Channel-agnostic email+WhatsApp dispatch                                         |
| `lib/services/quotations.service.ts`                 | Modify | Replace inline email+SMS with `notifyQuotationCustomer`; expand `DispatchResult` |
| `lib/services/quotation-reminders.service.ts`        | Modify | Hard-coded `REMINDER_SEQUENCE`; `sendQuotationReminder` calls notifier           |
| `__tests__/lib/services/quotation-pdf.test.ts`       | Create | 4 tests                                                                          |
| `__tests__/lib/services/quotation-notifier.test.ts`  | Create | 7 tests                                                                          |
| `__tests__/lib/services/quotations-dispatch.test.ts` | Modify | Update for notifier                                                              |
| `__tests__/lib/services/quotation-reminders.test.ts` | Create | 6 tests                                                                          |
