Multi-Channel Communication System Design
Executive Summary
This document outlines the design for a comprehensive multi-channel communication system that will enable the FlowPOS platform to send invoices, payment links, collection reminders, and other notifications through Email, SMS, WhatsApp, and Facebook Messenger.
Current State:
- ✅ Email service using Sendgrid (basic implementation)
- ✅ Mandrill service for transactional emails
- ✅ PDF generation for various documents
- ✅ Event-driven architecture using NestJS EventEmitter
- ❌ No SMS/WhatsApp integration
- ❌ No communication tracking
- ❌ No template management
- ❌ No unified communication interface
Integration Partners:
- Sendgrid: Email delivery
- Twilio: SMS, WhatsApp, and Facebook Messenger
Table of Contents
- System Architecture
- Database Schema Design
- Module Structure
- API Design
- Channel Integrations
- Use Cases & Workflows
- Template System
- Queue & Retry Logic
- Frontend Integration
- Security & Configuration
- Implementation Phases
1. System Architecture
1.1 High-Level Architecture
┌─────────────────────────────────────────────────────────────────┐
│ Frontend PWA │
│ (Trigger communication, view history, manage templates) │
└────────────────────────┬────────────────────────────────────────┘
│ REST API
┌────────────────────────▼────────────────────────────────────────┐
│ Backend NestJS │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Communication Service (Core) │ │
│ │ • Unified interface for all channels │ │
│ │ • Template rendering │ │
│ │ • Event listeners (sales, invoices, collections) │ │
│ └──────────┬───────────────────────────────────────────────┘ │
│ │ │
│ ┌──────────▼───────────────────────────────────────────────┐ │
│ │ Channel Adapters (Strategy Pattern) │ │
│ │ ┌─────────┐ ┌─────────┐ ┌──────────┐ ┌───────────┐ │ │
│ │ │ Email │ │ SMS │ │ WhatsApp │ │ Messenger │ │ │
│ │ │ Adapter │ │ Adapter │ │ Adapter │ │ Adapter │ │ │
│ │ └────┬────┘ └────┬────┘ └────┬─────┘ └─────┬─────┘ │ │
│ └───────┼────────────┼────────────┼──────────────┼────────┘ │
│ │ │ │ │ │
│ ┌───────▼────────────▼────────────▼──────────────▼────────┐ │
│ │ Communication Queue Service │ │
│ │ • Job scheduling │ │
│ │ • Retry logic │ │
│ │ • Rate limiting │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Database (PostgreSQL) │ │
│ │ • Communication History │ │
│ │ • Templates │ │
│ │ • Queue/Jobs │ │
│ └──────────────────────────────────────────────────────────┘ │
└─────────────┬──────────────────────────┬──────────────────────┘
│ │
┌─────────▼────────┐ ┌──────────▼───────────┐
│ Sendgrid │ │ Twilio │
│ (Email API) │ │ (SMS, WhatsApp, │
│ │ │ Messenger API) │
└──────────────────┘ └──────────────────────┘
1.2 Design Patterns
- Strategy Pattern: Channel adapters (email, SMS, WhatsApp, Messenger)
- Factory Pattern: Create channel adapters based on channel type
- Observer Pattern: Event-driven communication triggers
- Template Method Pattern: Common communication flow with channel-specific implementations
- Repository Pattern: Data access for communications, templates, and jobs
1.3 Key Principles
- Channel Agnostic: Core business logic independent of delivery channel
- Fail-Safe: Graceful degradation if a channel fails
- Auditable: Complete history of all communications
- Scalable: Queue-based architecture for high volume
- Testable: Mock adapters for testing
2. Database Schema Design
2.1 Communications Table
Stores all communication attempts and their results.
CREATE TYPE communication_channel AS ENUM ('email', 'sms', 'whatsapp', 'messenger');
CREATE TYPE communication_status AS ENUM ('pending', 'queued', 'sent', 'delivered', 'failed', 'bounced', 'opened', 'clicked');
CREATE TYPE communication_type AS ENUM (
'invoice',
'invoice_reminder',
'payment_confirmation',
'payment_link',
'collection_notice',
'low_stock_alert',
'general_notification'
);
CREATE TABLE communication (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
-- Business Context
business_id UUID NOT NULL REFERENCES business(id),
created_by UUID NOT NULL REFERENCES "user"(id),
-- Recipient Information
recipient_type VARCHAR(50) NOT NULL, -- 'customer', 'supplier', 'user'
recipient_id UUID, -- Can be customer_id, supplier_id, or user_id
recipient_name VARCHAR(255),
recipient_contact VARCHAR(255) NOT NULL, -- email, phone, etc.
-- Communication Details
channel communication_channel NOT NULL,
type communication_type NOT NULL,
subject VARCHAR(500),
content TEXT NOT NULL,
-- Related Entity (optional)
entity_type VARCHAR(50), -- 'sale', 'accountsReceivableInvoice', 'purchase', etc.
entity_id UUID,
-- Template Information
communication_template_id UUID REFERENCES communication_template(id),
template_variables JSON, -- Variables used in template
-- Delivery Status
status communication_status NOT NULL DEFAULT 'pending',
sent_at TIMESTAMP,
delivered_at TIMESTAMP,
opened_at TIMESTAMP,
clicked_at TIMESTAMP,
failed_at TIMESTAMP,
error_message TEXT,
-- External Provider Information
provider VARCHAR(50), -- 'sendgrid', 'twilio'
provider_message_id VARCHAR(255), -- External provider's message ID
provider_response JSON, -- Full provider response
-- Retry Information
retry_count INT DEFAULT 0,
max_retries INT DEFAULT 3,
next_retry_at TIMESTAMP,
-- Metadata
metadata JSON, -- Additional flexible data
-- Timestamps
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP,
-- Indexes
INDEX idx_communication_business_id (business_id),
INDEX idx_communication_recipient (recipient_type, recipient_id),
INDEX idx_communication_status (status),
INDEX idx_communication_entity (entity_type, entity_id),
INDEX idx_communication_created_at (created_at DESC),
INDEX idx_communication_channel (channel),
INDEX idx_communication_type (type)
);
2.2 Communication Templates Table
Manages reusable message templates with variable substitution.
CREATE TABLE communication_template (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
-- Business Context
business_id UUID REFERENCES business(id), -- NULL for global templates
-- Template Identity
name VARCHAR(255) NOT NULL,
code VARCHAR(100) NOT NULL, -- Unique code for programmatic access
description TEXT,
-- Channel & Type
channel communication_channel NOT NULL,
type communication_type NOT NULL,
-- Template Content
subject_template VARCHAR(500), -- For email/messenger
body_template TEXT NOT NULL,
-- Template Variables (for validation and UI)
available_variables JSON, -- Array of variable names: ["customerName", "invoiceNumber", "amount"]
-- Configuration
is_active BOOLEAN DEFAULT true,
is_system BOOLEAN DEFAULT false, -- System templates cannot be deleted
-- Metadata
created_by UUID NOT NULL REFERENCES "user"(id),
updated_by UUID REFERENCES "user"(id),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP,
UNIQUE (business_id, code, channel),
INDEX idx_template_business (business_id),
INDEX idx_template_channel_type (channel, type),
INDEX idx_template_active (is_active)
);
2.3 Communication Queue Table
Manages asynchronous communication jobs.
CREATE TYPE queue_status AS ENUM ('pending', 'processing', 'completed', 'failed', 'cancelled');
CREATE TYPE queue_priority AS ENUM ('low', 'normal', 'high', 'urgent');
CREATE TABLE communication_queue (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
-- Job Configuration
communication_id UUID REFERENCES communication(id),
priority queue_priority DEFAULT 'normal',
-- Scheduling
scheduled_at TIMESTAMP NOT NULL, -- When to send
started_at TIMESTAMP,
completed_at TIMESTAMP,
-- Status
status queue_status DEFAULT 'pending',
attempts INT DEFAULT 0,
max_attempts INT DEFAULT 3,
last_error TEXT,
-- Payload
payload JSON NOT NULL, -- All data needed to send the message
-- Timestamps
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP,
INDEX idx_queue_status_scheduled (status, scheduled_at),
INDEX idx_queue_priority (priority DESC, created_at ASC),
INDEX idx_queue_communication (communication_id)
);
2.4 Communication Preferences Table
User/Customer preferences for receiving communications.
CREATE TABLE communication_preference (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
-- Owner
entity_type VARCHAR(50) NOT NULL, -- 'customer', 'user'
entity_id UUID NOT NULL,
business_id UUID NOT NULL REFERENCES business(id),
-- Channel Preferences
email_enabled BOOLEAN DEFAULT true,
sms_enabled BOOLEAN DEFAULT true,
whatsapp_enabled BOOLEAN DEFAULT true,
messenger_enabled BOOLEAN DEFAULT true,
-- Type Preferences (which types of messages they want)
preferences JSON, -- { "invoice": ["email", "whatsapp"], "payment_reminder": ["sms"] }
-- Preferred Contact Methods
preferred_email VARCHAR(255),
preferred_phone VARCHAR(50),
preferred_whatsapp VARCHAR(50),
preferred_messenger_id VARCHAR(255),
-- Opt-out
opted_out_at TIMESTAMP,
opted_out_reason TEXT,
-- Timestamps
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP,
UNIQUE (entity_type, entity_id, business_id),
INDEX idx_preference_entity (entity_type, entity_id),
INDEX idx_preference_business (business_id)
);
2.5 Communication Attachments Table
Store references to attachments (PDFs, images, etc.).
CREATE TABLE communication_attachment (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
communication_id UUID NOT NULL REFERENCES communication(id) ON DELETE CASCADE,
-- File Information
filename VARCHAR(255) NOT NULL,
file_path VARCHAR(500), -- Local storage path or NULL if external
file_url VARCHAR(500), -- External URL or NULL if local
mime_type VARCHAR(100),
size_bytes INT,
-- Metadata
created_at TIMESTAMP DEFAULT NOW(),
INDEX idx_attachment_communication (communication_id)
);
3. Module Structure
Following the existing NestJS architecture pattern in the codebase.
3.1 Module Organization
apps/backend/src/
├── communications/
│ ├── communications.module.ts
│ ├── application/
│ │ ├── communications.service.ts # Core orchestration service
│ │ ├── services/
│ │ │ ├── channel-factory.service.ts # Creates channel adapters
│ │ │ ├── template-renderer.service.ts # Renders templates with variables
│ │ │ └── attachment-handler.service.ts # Manages attachments
│ │ ├── adapters/
│ │ │ ├── communication-channel.interface.ts
│ │ │ ├── email-adapter.service.ts # Sendgrid integration
│ │ │ ├── sms-adapter.service.ts # Twilio SMS
│ │ │ ├── whatsapp-adapter.service.ts # Twilio WhatsApp
│ │ │ └── messenger-adapter.service.ts # Twilio/Facebook Messenger
│ │ ├── use-cases/
│ │ │ ├── send-invoice.use-case.ts
│ │ │ ├── send-payment-link.use-case.ts
│ │ │ ├── send-collection-notice.use-case.ts
│ │ │ └── send-payment-confirmation.use-case.ts
│ │ └── events/
│ │ ├── on-create-sale.handler.ts # Auto-send invoice on sale
│ │ ├── on-create-ar-invoice.handler.ts
│ │ └── on-payment-received.handler.ts
│ ├── domain/
│ │ ├── communications-repository.domain.ts
│ │ ├── value-objects/
│ │ │ ├── communication-channel.vo.ts
│ │ │ ├── communication-status.vo.ts
│ │ │ └── recipient.vo.ts
│ │ └── entities/
│ │ ├── communication.entity.ts
│ │ └── communication-result.entity.ts
│ ├── infrastructure/
│ │ ├── communications.repository.ts
│ │ └── providers/
│ │ ├── sendgrid.provider.ts
│ │ └── twilio.provider.ts
│ └── interfaces/
│ ├── communications.controller.ts
│ ├── dtos/
│ │ ├── send-communication.dto.ts
│ │ ├── create-communication.dto.ts
│ │ ├── update-communication.dto.ts
│ │ └── communication-filters.dto.ts
│ └── query/
│ └── paginate-communications.query.ts
│
├── communication-templates/
│ ├── communication-templates.module.ts
│ ├── application/
│ │ └── communication-templates.service.ts
│ ├── domain/
│ │ └── communication-templates-repository.domain.ts
│ ├── infrastructure/
│ │ └── communication-templates.repository.ts
│ └── interfaces/
│ ├── communication-templates.controller.ts
│ └── dtos/
│ ├── create-template.dto.ts
│ ├── update-template.dto.ts
│ └── render-template.dto.ts
│
├── communication-queue/
│ ├── communication-queue.module.ts
│ ├── application/
│ │ ├── communication-queue.service.ts
│ │ └── jobs/
│ │ ├── process-communication.job.ts
│ │ └── retry-failed-communications.job.ts
│ ├── infrastructure/
│ │ └── communication-queue.repository.ts
│ └── domain/
│ └── communication-queue-repository.domain.ts
│
└── communication-preferences/
├── communication-preferences.module.ts
├── application/
│ └── communication-preferences.service.ts
├── domain/
│ └── communication-preferences-repository.domain.ts
├── infrastructure/
│ └── communication-preferences.repository.ts
└── interfaces/
├── communication-preferences.controller.ts
└── dtos/
├── create-preference.dto.ts
└── update-preference.dto.ts
3.2 Module Dependencies
// communications.module.ts
@Module({
imports: [
DatabaseModule,
CommunicationTemplatesModule,
CommunicationQueueModule,
CommunicationPreferencesModule,
CustomersModule, // For customer data
SalesModule, // For sale events
AccountsReceivableInvoicesModule, // For invoice events
PdfModule, // For generating attachments
],
controllers: [CommunicationsController],
providers: [
CommunicationsService,
CommunicationsRepository,
ChannelFactoryService,
TemplateRendererService,
AttachmentHandlerService,
// Adapters
EmailAdapterService,
SmsAdapterService,
WhatsAppAdapterService,
MessengerAdapterService,
// Event Handlers
OnCreateSaleHandler,
OnCreateARInvoiceHandler,
OnPaymentReceivedHandler,
],
exports: [CommunicationsService],
})
export class CommunicationsModule {}
4. API Design
4.1 Communications Endpoints
Send Communication
POST /api/communications/send
Authorization: Bearer <token>
Content-Type: application/json
Request Body:
{
"channel": "email" | "sms" | "whatsapp" | "messenger",
"type": "invoice" | "payment_link" | "collection_notice" | "general",
"recipientType": "customer" | "supplier" | "user",
"recipientId": "uuid",
"recipientContact": "email@example.com" | "+1234567890",
"subject": "Your Invoice #1234", // Optional for SMS/WhatsApp
"templateId": "uuid", // Optional - use template
"templateVariables": { // If using template
"customerName": "John Doe",
"invoiceNumber": "INV-1234",
"amount": 1500.00
},
"content": "Plain text or HTML", // Required if not using template
"entityType": "sale", // Optional - link to entity
"entityId": "uuid",
"attachments": [ // Optional
{
"filename": "invoice.pdf",
"fileUrl": "https://..."
}
],
"scheduledAt": "2025-10-20T10:00:00Z", // Optional - schedule for later
"priority": "normal" | "high" | "urgent"
}
Response: 201 Created
{
"id": "uuid",
"status": "queued",
"estimatedSendTime": "2025-10-20T10:00:00Z",
"communication": { ...communication object }
}
Get Communication History
GET /api/communications?page=1&size=20&channel=email&status=sent&recipientId=uuid
Authorization: Bearer <token>
Response: 200 OK
{
"results": [
{
"id": "uuid",
"channel": "email",
"type": "invoice",
"recipientName": "John Doe",
"recipientContact": "john@example.com",
"subject": "Your Invoice #1234",
"status": "delivered",
"sentAt": "2025-10-19T15:30:00Z",
"deliveredAt": "2025-10-19T15:30:05Z",
"openedAt": "2025-10-19T16:45:00Z",
"entityType": "sale",
"entityId": "uuid"
}
],
"count": 150,
"page": 1,
"size": 20
}
Get Communication Details
GET /api/communications/:id
Authorization: Bearer <token>
Response: 200 OK
{
"id": "uuid",
"businessId": "uuid",
"channel": "email",
"type": "invoice",
"recipientType": "customer",
"recipientId": "uuid",
"recipientName": "John Doe",
"recipientContact": "john@example.com",
"subject": "Your Invoice #1234",
"content": "...",
"status": "delivered",
"sentAt": "2025-10-19T15:30:00Z",
"deliveredAt": "2025-10-19T15:30:05Z",
"openedAt": "2025-10-19T16:45:00Z",
"provider": "sendgrid",
"providerMessageId": "sg-message-id",
"retryCount": 0,
"attachments": [
{
"filename": "invoice.pdf",
"fileUrl": "https://..."
}
],
"entityType": "sale",
"entityId": "uuid",
"createdAt": "2025-10-19T15:29:50Z"
}
Resend Communication
POST /api/communications/:id/resend
Authorization: Bearer <token>
Request Body:
{
"channel": "sms", // Optional - change channel
"recipientContact": "+1234567890" // Optional - change recipient
}
Response: 201 Created
{
"id": "new-uuid",
"originalCommunicationId": "uuid",
"status": "queued"
}
Get Communication Stats
GET /api/communications/stats?startDate=2025-10-01&endDate=2025-10-31&businessId=uuid
Authorization: Bearer <token>
Response: 200 OK
{
"totalSent": 1500,
"byChannel": {
"email": 800,
"sms": 400,
"whatsapp": 250,
"messenger": 50
},
"byStatus": {
"sent": 1450,
"failed": 50
},
"byType": {
"invoice": 600,
"payment_link": 400,
"collection_notice": 300,
"general": 200
},
"openRate": 0.65,
"clickRate": 0.35
}
4.2 Template Endpoints
Create Template
POST /api/communication-templates
Authorization: Bearer <token>
Content-Type: application/json
Request Body:
{
"name": "Invoice Email",
"code": "invoice_email",
"description": "Standard email template for invoices",
"channel": "email",
"type": "invoice",
"subjectTemplate": "Invoice {{invoiceNumber}} from {{businessName}}",
"bodyTemplate": "Dear {{customerName}},\n\nYour invoice #{{invoiceNumber}} for {{amount}} is attached.\n\nThank you!",
"availableVariables": ["customerName", "invoiceNumber", "amount", "businessName", "dueDate"],
"isActive": true
}
Response: 201 Created
{
"id": "uuid",
"name": "Invoice Email",
"code": "invoice_email",
...
}
List Templates
GET /api/communication-templates?channel=email&type=invoice&isActive=true
Authorization: Bearer <token>
Response: 200 OK
{
"results": [
{
"id": "uuid",
"name": "Invoice Email",
"code": "invoice_email",
"channel": "email",
"type": "invoice",
"isActive": true,
"isSystem": false
}
]
}
Preview Template
POST /api/communication-templates/:id/preview
Authorization: Bearer <token>
Content-Type: application/json
Request Body:
{
"variables": {
"customerName": "John Doe",
"invoiceNumber": "INV-1234",
"amount": "$1,500.00",
"businessName": "ACME Corp"
}
}
Response: 200 OK
{
"subject": "Invoice INV-1234 from ACME Corp",
"body": "Dear John Doe,\n\nYour invoice #INV-1234 for $1,500.00 is attached.\n\nThank you!"
}
4.3 Preferences Endpoints
Get Preferences
GET /api/communication-preferences/customer/:customerId
Authorization: Bearer <token>
Response: 200 OK
{
"id": "uuid",
"entityType": "customer",
"entityId": "uuid",
"emailEnabled": true,
"smsEnabled": true,
"whatsappEnabled": false,
"messengerEnabled": false,
"preferences": {
"invoice": ["email"],
"payment_reminder": ["email", "sms"],
"collection_notice": ["email"]
},
"preferredEmail": "john@example.com",
"preferredPhone": "+1234567890"
}
Update Preferences
PATCH /api/communication-preferences/customer/:customerId
Authorization: Bearer <token>
Content-Type: application/json
Request Body:
{
"emailEnabled": true,
"smsEnabled": false,
"preferences": {
"invoice": ["email", "whatsapp"],
"payment_reminder": ["whatsapp"]
}
}
Response: 200 OK
{
"id": "uuid",
...updated preferences
}
5. Channel Integrations
5.1 Email (Sendgrid)
Already Implemented: Basic email service exists.
Enhancements Needed:
- Add tracking (opens, clicks)
- Template support
- Attachment handling
- Better error handling
// email-adapter.service.ts
@Injectable()
export class EmailAdapterService implements ICommunicationChannel {
private readonly logger = new Logger(EmailAdapterService.name);
constructor() {
sgMail.setApiKey(process.env.SENDGRID_API_KEY);
}
async send(
communication: SendCommunicationDto
): Promise<CommunicationResult> {
try {
const msg = {
to: communication.recipientContact,
from: {
email: process.env.SENDGRID_FROM_EMAIL,
name: communication.businessName || "FlowPOS",
},
subject: communication.subject,
text: this.stripHtml(communication.content),
html: communication.content,
attachments: this.prepareAttachments(communication.attachments),
trackingSettings: {
clickTracking: { enable: true },
openTracking: { enable: true },
},
customArgs: {
communicationId: communication.id,
businessId: communication.businessId,
},
};
const response = await sgMail.send(msg);
return {
success: true,
messageId: response[0].headers["x-message-id"],
provider: "sendgrid",
providerResponse: response[0],
};
} catch (error) {
this.logger.error("Failed to send email", error);
return {
success: false,
error: error.message,
provider: "sendgrid",
};
}
}
async getStatus(messageId: string): Promise<CommunicationStatus> {
// Call Sendgrid API to get message status
// This requires Sendgrid webhooks to be set up
}
private prepareAttachments(attachments?: Attachment[]): any[] {
if (!attachments) return [];
return attachments.map((att) => ({
filename: att.filename,
content: att.content, // Base64 encoded
type: att.mimeType,
disposition: "attachment",
}));
}
private stripHtml(html: string): string {
return html.replace(/<[^>]*>/g, "");
}
}
5.2 SMS (Twilio)
New Implementation Required
// sms-adapter.service.ts
import twilio from "twilio";
@Injectable()
export class SmsAdapterService implements ICommunicationChannel {
private readonly logger = new Logger(SmsAdapterService.name);
private client: twilio.Twilio;
constructor() {
this.client = twilio(
process.env.TWILIO_ACCOUNT_SID,
process.env.TWILIO_AUTH_TOKEN
);
}
async send(
communication: SendCommunicationDto
): Promise<CommunicationResult> {
try {
// SMS content should be plain text, max 1600 chars
const body = this.truncateForSms(communication.content);
const message = await this.client.messages.create({
body,
from: process.env.TWILIO_PHONE_NUMBER,
to: communication.recipientContact,
statusCallback: `${process.env.API_URL}/webhooks/twilio/sms-status`,
});
return {
success: true,
messageId: message.sid,
provider: "twilio",
providerResponse: message,
};
} catch (error) {
this.logger.error("Failed to send SMS", error);
return {
success: false,
error: error.message,
provider: "twilio",
};
}
}
private truncateForSms(content: string, maxLength = 1600): string {
const plainText = content.replace(/<[^>]*>/g, "");
return plainText.length > maxLength
? plainText.substring(0, maxLength - 3) + "..."
: plainText;
}
async getStatus(messageId: string): Promise<CommunicationStatus> {
const message = await this.client.messages(messageId).fetch();
return this.mapTwilioStatus(message.status);
}
private mapTwilioStatus(twilioStatus: string): CommunicationStatus {
const statusMap = {
queued: "queued",
sending: "queued",
sent: "sent",
delivered: "delivered",
failed: "failed",
undelivered: "failed",
};
return statusMap[twilioStatus] || "pending";
}
}
5.3 WhatsApp (Twilio)
New Implementation Required
// whatsapp-adapter.service.ts
import twilio from "twilio";
@Injectable()
export class WhatsAppAdapterService implements ICommunicationChannel {
private readonly logger = new Logger(WhatsAppAdapterService.name);
private client: twilio.Twilio;
constructor() {
this.client = twilio(
process.env.TWILIO_ACCOUNT_SID,
process.env.TWILIO_AUTH_TOKEN
);
}
async send(
communication: SendCommunicationDto
): Promise<CommunicationResult> {
try {
// WhatsApp requires phone numbers in E.164 format
const to = this.formatWhatsAppNumber(communication.recipientContact);
const from = `whatsapp:${process.env.TWILIO_WHATSAPP_NUMBER}`;
const message = await this.client.messages.create({
body: communication.content,
from,
to,
statusCallback: `${process.env.API_URL}/webhooks/twilio/whatsapp-status`,
// For media/attachments:
...(communication.attachments?.length > 0 && {
mediaUrl: communication.attachments.map((a) => a.fileUrl),
}),
});
return {
success: true,
messageId: message.sid,
provider: "twilio",
providerResponse: message,
};
} catch (error) {
this.logger.error("Failed to send WhatsApp message", error);
return {
success: false,
error: error.message,
provider: "twilio",
};
}
}
private formatWhatsAppNumber(phone: string): string {
// Ensure phone is in E.164 format with whatsapp: prefix
const cleaned = phone.replace(/[^\d+]/g, "");
return `whatsapp:${cleaned.startsWith("+") ? cleaned : "+" + cleaned}`;
}
async getStatus(messageId: string): Promise<CommunicationStatus> {
const message = await this.client.messages(messageId).fetch();
return this.mapTwilioStatus(message.status);
}
}
5.4 Facebook Messenger (Twilio/Facebook)
New Implementation Required
Note: Messenger integration requires Facebook Business approval and setup.
// messenger-adapter.service.ts
@Injectable()
export class MessengerAdapterService implements ICommunicationChannel {
private readonly logger = new Logger(MessengerAdapterService.name);
private client: twilio.Twilio;
constructor() {
this.client = twilio(
process.env.TWILIO_ACCOUNT_SID,
process.env.TWILIO_AUTH_TOKEN
);
}
async send(
communication: SendCommunicationDto
): Promise<CommunicationResult> {
try {
// Messenger requires PSID (Page-Scoped ID)
const to = `messenger:${communication.recipientContact}`;
const from = `messenger:${process.env.FACEBOOK_PAGE_ID}`;
const message = await this.client.messages.create({
body: communication.content,
from,
to,
statusCallback: `${process.env.API_URL}/webhooks/twilio/messenger-status`,
});
return {
success: true,
messageId: message.sid,
provider: "twilio",
providerResponse: message,
};
} catch (error) {
this.logger.error("Failed to send Messenger message", error);
return {
success: false,
error: error.message,
provider: "twilio",
};
}
}
}
5.5 Channel Interface
Common interface for all adapters:
// communication-channel.interface.ts
export interface ICommunicationChannel {
send(communication: SendCommunicationDto): Promise<CommunicationResult>;
getStatus(messageId: string): Promise<CommunicationStatus>;
validate?(recipientContact: string): boolean;
}
export interface CommunicationResult {
success: boolean;
messageId?: string;
error?: string;
provider: string;
providerResponse?: any;
}
export enum CommunicationStatus {
PENDING = "pending",
QUEUED = "queued",
SENT = "sent",
DELIVERED = "delivered",
FAILED = "failed",
BOUNCED = "bounced",
OPENED = "opened",
CLICKED = "clicked",
}
6. Use Cases & Workflows
6.1 Send Invoice Email After Sale
Trigger: Sale is created Flow:
// on-create-sale.handler.ts
@Injectable()
export class OnCreateSaleHandler {
constructor(
private readonly communicationsService: CommunicationsService,
private readonly customersService: CustomersService,
private readonly pdfService: GenerateSalePdfUseCase
) {}
@OnEvent(OnCreateSaleEvent.eventName, { async: true })
async handle(event: OnCreateSaleEvent) {
const sale = event.sale;
// Get customer details
const customer = await this.customersService.getCustomerById(
sale.customerId
);
if (!customer?.email) {
this.logger.warn(`Customer ${sale.customerId} has no email`);
return;
}
// Generate PDF
const pdfBuffer = await this.pdfService.execute(sale.id);
// Send communication
await this.communicationsService.send({
channel: "email",
type: "invoice",
recipientType: "customer",
recipientId: customer.id,
recipientContact: customer.email,
templateId: await this.getInvoiceTemplateId(sale.businessId),
templateVariables: {
customerName: customer.taxName,
invoiceNumber: sale.documentNumber,
amount: this.formatAmount(sale.totalAmount),
saleDate: this.formatDate(sale.saleDate),
businessName: sale.businessName,
},
entityType: "sale",
entityId: sale.id,
attachments: [
{
filename: `invoice-${sale.documentNumber}.pdf`,
content: pdfBuffer.toString("base64"),
mimeType: "application/pdf",
},
],
businessId: sale.businessId,
createdBy: sale.createdBy,
});
}
}
6.2 Send Payment Link via SMS
Trigger: Manual action from UI Flow:
// send-payment-link.use-case.ts
@Injectable()
export class SendPaymentLinkUseCase {
constructor(
private readonly communicationsService: CommunicationsService,
private readonly arInvoicesService: AccountsReceivableInvoicesService
) {}
async execute(params: {
invoiceId: string;
channel: "sms" | "whatsapp";
businessId: string;
userId: string;
}) {
// Get invoice
const invoice =
await this.arInvoicesService.getAccountsReceivableInvoiceById(
params.invoiceId
);
// Get customer
const customer = await this.customersService.getCustomerById(
invoice.customerId
);
// Generate payment link
const paymentLink = await this.generatePaymentLink(invoice);
// Determine contact based on channel
const contact = params.channel === "sms" ? customer.phone : customer.phone; // WhatsApp uses same phone
if (!contact) {
throw new Error(`Customer has no ${params.channel} contact`);
}
// Send message
return this.communicationsService.send({
channel: params.channel,
type: "payment_link",
recipientType: "customer",
recipientId: customer.id,
recipientContact: contact,
content: `Hi ${customer.firstName}, pay your invoice ${invoice.documentNumber} here: ${paymentLink}`,
entityType: "accountsReceivableInvoice",
entityId: invoice.id,
businessId: params.businessId,
createdBy: params.userId,
});
}
private async generatePaymentLink(
invoice: SelectableAccountsReceivableInvoice
): Promise<string> {
// Generate secure payment link (implement payment gateway integration)
const token = this.generateSecureToken(invoice.id);
return `${process.env.PAYMENT_URL}/pay/${token}`;
}
}
6.3 Automated Collection Reminders
Trigger: Scheduled job (daily check for overdue invoices) Flow:
// collection-reminders.scheduler.ts
@Injectable()
export class CollectionRemindersScheduler {
constructor(
private readonly arInvoicesService: AccountsReceivableInvoicesService,
private readonly communicationsService: CommunicationsService
) {}
@Cron(CronExpression.EVERY_DAY_AT_9AM)
async checkOverdueInvoices() {
const overdueInvoices = await this.arInvoicesService.findOverdueInvoices({
status: ["submitted", "approved"],
daysOverdue: 1, // At least 1 day overdue
});
for (const invoice of overdueInvoices) {
await this.sendCollectionReminder(invoice);
}
}
private async sendCollectionReminder(
invoice: SelectableAccountsReceivableInvoice
) {
const customer = await this.customersService.getCustomerById(
invoice.customerId
);
// Check preferences
const preferences = await this.preferencesService.getPreferences(
"customer",
customer.id,
invoice.businessId
);
// Get preferred channels for collection notices
const channels = preferences.preferences.collection_notice || ["email"];
for (const channel of channels) {
const contact = this.getContactForChannel(customer, channel);
if (!contact) continue;
await this.communicationsService.send({
channel,
type: "collection_notice",
recipientType: "customer",
recipientId: customer.id,
recipientContact: contact,
templateId: await this.getCollectionTemplateId(
invoice.businessId,
channel
),
templateVariables: {
customerName: customer.taxName,
invoiceNumber: invoice.documentNumber,
amount: this.formatAmount(invoice.balanceDue),
daysOverdue: this.calculateDaysOverdue(invoice.saleDate),
},
entityType: "accountsReceivableInvoice",
entityId: invoice.id,
businessId: invoice.businessId,
createdBy: "SYSTEM_collections",
});
}
}
}
6.4 Low Stock Alerts (Multi-Channel)
Trigger: Already exists - enhance with multi-channel Flow:
// Enhance existing OnCreateLowStockAlertEvent handler
@Injectable()
export class OnCreateLowStockAlertHandler {
@OnEvent(OnCreateLowStockAlertEvent.eventName, { async: true })
async handle(event: OnCreateLowStockAlertEvent) {
// Get business users with inventory_manager role
const managers = await this.businessUsersService.getUsersByRole(
event.alert.businessId,
"inventory_manager"
);
for (const manager of managers) {
const preferences = await this.preferencesService.getPreferences(
"user",
manager.userId,
event.alert.businessId
);
// Send via preferred channels
const channels = preferences.preferences.low_stock_alert || ["email"];
for (const channel of channels) {
await this.communicationsService.send({
channel,
type: "low_stock_alert",
recipientType: "user",
recipientId: manager.userId,
recipientContact: this.getContactForChannel(manager, channel),
templateId: await this.getLowStockTemplateId(
event.alert.businessId,
channel
),
templateVariables: {
locationName: event.alert.locationName,
itemCount: JSON.parse(event.alert.detail).items.length,
},
entityType: "lowStockAlert",
entityId: event.alert.id,
businessId: event.alert.businessId,
createdBy: "SYSTEM_low_stock",
});
}
}
}
}
7. Template System
7.1 Template Rendering
Uses Handlebars for variable substitution:
// template-renderer.service.ts
import Handlebars from "handlebars";
@Injectable()
export class TemplateRendererService {
private readonly logger = new Logger(TemplateRendererService.name);
// Register custom helpers
constructor() {
this.registerHelpers();
}
async render(
template: SelectableCommunicationTemplate,
variables: Record<string, any>
): Promise<{ subject?: string; body: string }> {
try {
// Compile templates
const subjectCompiled = template.subjectTemplate
? Handlebars.compile(template.subjectTemplate)
: null;
const bodyCompiled = Handlebars.compile(template.bodyTemplate);
// Render with variables
return {
subject: subjectCompiled ? subjectCompiled(variables) : undefined,
body: bodyCompiled(variables),
};
} catch (error) {
this.logger.error("Failed to render template", error);
throw new Error(`Template rendering failed: ${error.message}`);
}
}
private registerHelpers() {
// Currency formatting
Handlebars.registerHelper(
"currency",
function (amount, currencyCode = "USD") {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: currencyCode,
}).format(amount);
}
);
// Date formatting
Handlebars.registerHelper("date", function (date, format = "medium") {
return new Intl.DateTimeFormat("en-US", {
dateStyle: format,
}).format(new Date(date));
});
// Conditional helpers
Handlebars.registerHelper("ifEquals", function (arg1, arg2, options) {
return arg1 === arg2 ? options.fn(this) : options.inverse(this);
});
}
validateTemplate(
templateString: string,
requiredVariables: string[]
): boolean {
// Extract variables from template
const variableRegex = /{{([^}]+)}}/g;
const usedVariables = new Set<string>();
let match;
while ((match = variableRegex.exec(templateString)) !== null) {
usedVariables.add(match[1].trim());
}
// Check if all required variables are present
return requiredVariables.every((v) => usedVariables.has(v));
}
}
7.2 Default System Templates
Create system templates for common scenarios:
Invoice Email Template:
SMS Invoice Template:
Hi {{customerName}}, your invoice {{invoiceNumber}} for {{currency amount}} is ready. View & pay: {{shortLink}}
WhatsApp Payment Reminder:
Hello {{customerName}}!
Just a friendly reminder that invoice {{invoiceNumber}} for {{currency amount}} is due on {{date dueDate}}.
Pay securely here: {{paymentLink}}
Thank you! 🙏
Collection Notice Email:
8. Queue & Retry Logic
8.1 Queue Processing
// communication-queue.service.ts
@Injectable()
export class CommunicationQueueService {
constructor(
@Inject(DATABASE) private readonly database: KyselyDatabase,
private readonly communicationsService: CommunicationsService
) {}
async enqueue(params: {
communicationId: string;
payload: any;
scheduledAt?: Date;
priority?: "low" | "normal" | "high" | "urgent";
}): Promise<string> {
const job = await this.database
.insertInto("communicationQueue")
.values({
communicationId: params.communicationId,
payload: JSON.stringify(params.payload),
scheduledAt: params.scheduledAt || new Date(),
priority: params.priority || "normal",
status: "pending",
})
.returningAll()
.executeTakeFirstOrThrow();
return job.id;
}
@Cron(CronExpression.EVERY_30_SECONDS)
async processQueue() {
// Get pending jobs that are ready to be processed
const jobs = await this.database
.selectFrom("communicationQueue")
.selectAll()
.where("status", "=", "pending")
.where("scheduledAt", "<=", new Date())
.orderBy("priority", "desc")
.orderBy("scheduledAt", "asc")
.limit(10) // Process 10 at a time
.execute();
for (const job of jobs) {
await this.processJob(job);
}
}
private async processJob(job: any) {
try {
// Mark as processing
await this.updateJobStatus(job.id, "processing");
// Execute the communication
const payload = JSON.parse(job.payload);
await this.communicationsService.executeSend(payload);
// Mark as completed
await this.updateJobStatus(job.id, "completed", new Date());
} catch (error) {
this.logger.error(`Job ${job.id} failed`, error);
// Retry logic
if (job.attempts < job.maxAttempts) {
const nextRetryAt = this.calculateNextRetry(job.attempts);
await this.database
.updateTable("communicationQueue")
.set({
status: "pending",
attempts: job.attempts + 1,
scheduledAt: nextRetryAt,
lastError: error.message,
updatedAt: new Date(),
})
.where("id", "=", job.id)
.execute();
} else {
// Max retries exceeded
await this.updateJobStatus(job.id, "failed", null, error.message);
}
}
}
private calculateNextRetry(attempts: number): Date {
// Exponential backoff: 1min, 5min, 15min, 1hour
const delays = [60, 300, 900, 3600];
const delaySeconds = delays[Math.min(attempts, delays.length - 1)];
return new Date(Date.now() + delaySeconds * 1000);
}
private async updateJobStatus(
id: string,
status: string,
completedAt?: Date,
error?: string
) {
await this.database
.updateTable("communicationQueue")
.set({
status,
...(completedAt && { completedAt }),
...(error && { lastError: error }),
updatedAt: new Date(),
})
.where("id", "=", id)
.execute();
}
}
8.2 Rate Limiting
Prevent hitting API rate limits:
// rate-limiter.service.ts
@Injectable()
export class RateLimiterService {
private buckets = new Map<string, { tokens: number; lastRefill: number }>();
async checkAndConsume(
provider: string,
channel: string,
tokens = 1
): Promise<boolean> {
const key = `${provider}:${channel}`;
const config = this.getRateLimitConfig(provider, channel);
let bucket = this.buckets.get(key);
if (!bucket) {
bucket = { tokens: config.maxTokens, lastRefill: Date.now() };
this.buckets.set(key, bucket);
}
// Refill tokens based on time elapsed
const now = Date.now();
const elapsedMs = now - bucket.lastRefill;
const refillAmount = (elapsedMs / 1000) * config.refillRate;
bucket.tokens = Math.min(config.maxTokens, bucket.tokens + refillAmount);
bucket.lastRefill = now;
// Try to consume tokens
if (bucket.tokens >= tokens) {
bucket.tokens -= tokens;
return true;
}
return false; // Rate limit exceeded
}
private getRateLimitConfig(provider: string, channel: string) {
// Sendgrid: 600 emails per second (generous)
// Twilio SMS: 100 per second
// Twilio WhatsApp: 80 per second
const configs = {
"sendgrid:email": { maxTokens: 100, refillRate: 10 }, // 10 per second
"twilio:sms": { maxTokens: 50, refillRate: 5 }, // 5 per second
"twilio:whatsapp": { maxTokens: 40, refillRate: 4 }, // 4 per second
"twilio:messenger": { maxTokens: 40, refillRate: 4 },
};
return (
configs[`${provider}:${channel}`] || { maxTokens: 10, refillRate: 1 }
);
}
}
9. Frontend Integration
9.1 New Components
Communication History Component
// apps/frontend-pwa/src/components/communications/CommunicationHistory.tsx
export function CommunicationHistory({ entityType, entityId }: Props) {
const { token } = useAuth();
const [communications, setCommunications] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadCommunications();
}, [entityType, entityId]);
const loadCommunications = async () => {
const data = await getCommunications(token, {
entityType,
entityId,
page: 1,
size: 20,
});
setCommunications(data.results);
setLoading(false);
};
return (
<Card>
<CardHeader>
<CardTitle>Communication History</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-20" />
) : (
<div className="space-y-4">
{communications.map((comm) => (
<CommunicationItem key={comm.id} communication={comm} />
))}
</div>
)}
</CardContent>
</Card>
);
}
Send Communication Dialog
// apps/frontend-pwa/src/components/communications/SendCommunicationDialog.tsx
export function SendCommunicationDialog({
entityType,
entityId,
recipientType,
recipientId,
}: Props) {
const [channel, setChannel] = useState<"email" | "sms" | "whatsapp">("email");
const [type, setType] = useState<CommunicationType>("invoice");
const [templateId, setTemplateId] = useState<string | null>(null);
const [customMessage, setCustomMessage] = useState("");
const handleSend = async () => {
await sendCommunication(token, {
channel,
type,
recipientType,
recipientId,
templateId,
content: customMessage,
entityType,
entityId,
businessId: currentBusiness.id,
createdBy: user.id,
});
toast({ title: "Message sent successfully!" });
onClose();
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Send Communication</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>Channel</Label>
<Select value={channel} onValueChange={setChannel}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="email">Email</SelectItem>
<SelectItem value="sms">SMS</SelectItem>
<SelectItem value="whatsapp">WhatsApp</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Template (optional)</Label>
<TemplateSelector
channel={channel}
type={type}
onSelect={setTemplateId}
/>
</div>
<div>
<Label>Message</Label>
<Textarea
value={customMessage}
onChange={(e) => setCustomMessage(e.target.value)}
placeholder="Enter your message..."
rows={6}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSend}>Send</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
9.2 Integration Points
Add communication triggers to existing pages:
Sales Page - Add "Send Invoice" button:
// In SalesPage.tsx or SalesList.tsx
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => handleSendInvoice(sale)}>
<Mail className="mr-2 h-4 w-4" />
Send Invoice via Email
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleSendInvoiceSms(sale)}>
<MessageSquare className="mr-2 h-4 w-4" />
Send Invoice via SMS
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleViewHistory(sale)}>
<History className="mr-2 h-4 w-4" />
Communication History
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
Accounts Receivable Page - Add "Send Payment Link" button Customer Page - Add communication preferences tab
9.3 Frontend Services
// apps/frontend-pwa/src/services/communicationsService.ts
import { api } from "@/lib/api";
export async function sendCommunication(
token: string,
data: SendCommunicationDto
): Promise<Communication> {
return api("/communications/send", token, {
method: "POST",
body: JSON.stringify(data),
});
}
export async function getCommunications(
token: string,
params: CommunicationQueryParams
): Promise<PaginatedCommunications> {
const query = new URLSearchParams(params as any).toString();
return api(`/communications?${query}`, token);
}
export async function getCommunicationDetails(
token: string,
id: string
): Promise<Communication> {
return api(`/communications/${id}`, token);
}
export async function resendCommunication(
token: string,
id: string,
options?: { channel?: string; recipientContact?: string }
): Promise<Communication> {
return api(`/communications/${id}/resend`, token, {
method: "POST",
body: options ? JSON.stringify(options) : undefined,
});
}
export async function getCommunicationStats(
token: string,
params: StatsParams
): Promise<CommunicationStats> {
const query = new URLSearchParams(params as any).toString();
return api(`/communications/stats?${query}`, token);
}
10. Security & Configuration
10.1 Environment Variables
Add to apps/backend/.env:
# Sendgrid (already exists)
SENDGRID_API_KEY=SG.xxx
SENDGRID_FROM_EMAIL=noreply@flowpos.com
SENDGRID_FROM_NAME=FlowPOS
# Twilio
TWILIO_ACCOUNT_SID=ACxxxxx
TWILIO_AUTH_TOKEN=xxxxx
TWILIO_PHONE_NUMBER=+1234567890
TWILIO_WHATSAPP_NUMBER=+1234567890
# Facebook Messenger (if using)
FACEBOOK_PAGE_ID=xxxxx
FACEBOOK_APP_SECRET=xxxxx
# API URL for webhooks
API_URL=https://api.flowpos.com
# Payment Gateway (for payment links)
PAYMENT_URL=https://pay.flowpos.com
10.2 Webhook Endpoints
Handle delivery status updates from providers:
// apps/backend/src/communications/interfaces/webhooks.controller.ts
@Controller("webhooks")
export class WebhooksController {
constructor(private readonly communicationsService: CommunicationsService) {}
@Post("sendgrid/events")
async handleSendgridWebhook(@Body() events: any[]) {
// Sendgrid sends batch of events
for (const event of events) {
await this.communicationsService.updateStatus({
providerMessageId: event.sg_message_id,
status: this.mapSendgridEvent(event.event),
timestamp: new Date(event.timestamp * 1000),
metadata: event,
});
}
return { success: true };
}
@Post("twilio/sms-status")
async handleTwilioSmsWebhook(@Body() body: any) {
await this.communicationsService.updateStatus({
providerMessageId: body.MessageSid,
status: this.mapTwilioStatus(body.MessageStatus),
timestamp: new Date(),
metadata: body,
});
return { success: true };
}
@Post("twilio/whatsapp-status")
async handleTwilioWhatsAppWebhook(@Body() body: any) {
await this.communicationsService.updateStatus({
providerMessageId: body.MessageSid,
status: this.mapTwilioStatus(body.MessageStatus),
timestamp: new Date(),
metadata: body,
});
return { success: true };
}
private mapSendgridEvent(event: string): CommunicationStatus {
const map = {
processed: "queued",
delivered: "delivered",
open: "opened",
click: "clicked",
bounce: "bounced",
dropped: "failed",
spamreport: "failed",
};
return map[event] || "pending";
}
private mapTwilioStatus(status: string): CommunicationStatus {
const map = {
queued: "queued",
sent: "sent",
delivered: "delivered",
failed: "failed",
undelivered: "failed",
};
return map[status] || "pending";
}
}
10.3 Permissions
Add new permissions to the existing roles system:
// packages/global/policies/communication.policies.ts
export const CommunicationPolicies = {
send: {
action: "send",
subject: "Communication",
},
view: {
action: "view",
subject: "Communication",
},
viewHistory: {
action: "viewHistory",
subject: "Communication",
},
manageTemplates: {
action: "manage",
subject: "CommunicationTemplate",
},
viewStats: {
action: "viewStats",
subject: "Communication",
},
};
11. Implementation Phases
Phase 1: Foundation (Week 1-2)
Goal: Set up database and core infrastructure
- Create database migrations for all tables
- Create base module structure (communications, templates, queue, preferences)
- Implement repository layer
- Create DTOs and domain entities
- Set up basic API endpoints
- Add environment variables and configuration
Deliverables:
- Database schema deployed
- Module structure in place
- Basic CRUD operations working
Phase 2: Email Enhancement (Week 2-3)
Goal: Enhance existing email service and integrate template system
- Refactor existing EmailService into EmailAdapter
- Implement TemplateRendererService with Handlebars
- Create default email templates (invoice, payment reminder, collection notice)
- Add attachment handling
- Implement email tracking (opens, clicks)
- Set up Sendgrid webhooks
- Add email to queue system
Deliverables:
- Enhanced email service with templates
- Email tracking operational
- Template management API working
Phase 3: SMS & WhatsApp (Week 3-4)
Goal: Implement Twilio integration for SMS and WhatsApp
- Install and configure Twilio SDK
- Implement SmsAdapter
- Implement WhatsAppAdapter
- Create SMS/WhatsApp templates
- Set up Twilio webhooks for delivery status
- Add rate limiting for Twilio
- Test message delivery for both channels
Deliverables:
- SMS sending functional
- WhatsApp sending functional
- Delivery tracking working
Phase 4: Use Cases & Automations (Week 4-5)
Goal: Implement business use cases
- Implement SendInvoiceUseCase
- Implement SendPaymentLinkUseCase
- Implement SendCollectionNoticeUseCase
- Create event handlers (OnCreateSale, OnCreateARInvoice, OnPaymentReceived)
- Implement collection reminders scheduler
- Enhance low stock alerts with multi-channel
- Add communication preferences logic
Deliverables:
- Automated invoice sending on sale creation
- Payment link generation and sending
- Automated collection reminders
- Multi-channel low stock alerts
Phase 5: Frontend Integration (Week 5-6)
Goal: Build frontend components and integrate with backend
- Create communicationsService.ts
- Build CommunicationHistory component
- Build SendCommunicationDialog component
- Build TemplateManager component
- Build CommunicationPreferences component
- Integrate into Sales page
- Integrate into Accounts Receivable page
- Integrate into Customer page
- Add communication stats dashboard
Deliverables:
- UI for sending communications
- Communication history visible on relevant pages
- Template management UI
- Customer preferences UI
Phase 6: Queue & Reliability (Week 6-7)
Goal: Implement robust queue system and retry logic
- Implement CommunicationQueueService
- Add job scheduling
- Implement retry logic with exponential backoff
- Add rate limiting
- Create job monitoring dashboard
- Implement dead letter queue for failed messages
- Add queue cleanup job
Deliverables:
- Reliable message delivery
- Automatic retries
- Rate limiting preventing API throttling
- Monitoring dashboard
Phase 7: Messenger & Testing (Week 7-8)
Goal: Add Facebook Messenger and comprehensive testing
- Implement MessengerAdapter
- Set up Facebook Business integration
- Create Messenger templates
- Write unit tests for all services
- Write integration tests for adapters
- Write E2E tests for use cases
- Load testing for queue system
- Security audit
Deliverables:
- Facebook Messenger integration
- Comprehensive test coverage (>80%)
- Performance validated
- Security validated
Phase 8: Documentation & Polish (Week 8)
Goal: Complete documentation and final refinements
- API documentation (Swagger)
- User guide for frontend
- Admin guide for template management
- Deployment guide
- Monitoring and alerting setup
- Final bug fixes
- Performance optimizations
Deliverables:
- Complete documentation
- Production-ready system
- Monitoring in place
12. Key Metrics & Monitoring
12.1 Metrics to Track
-
Delivery Metrics:
- Total messages sent (by channel, by type)
- Delivery rate
- Failure rate
- Average delivery time
- Retry count
-
Engagement Metrics (Email):
- Open rate
- Click-through rate
- Bounce rate
- Unsubscribe rate
-
Business Metrics:
- Invoices sent automatically vs manually
- Payment link usage
- Collection notice effectiveness (payments after notice)
- Customer preference distribution
-
System Metrics:
- Queue size
- Processing time
- API rate limit proximity
- Failed jobs
12.2 Monitoring Dashboard
Create admin dashboard showing:
- Real-time message volume
- Channel distribution
- Failure trends
- Queue health
- Recent failures with errors
- Top templates used
13. Cost Considerations
Sendgrid Pricing
- Free tier: 100 emails/day
- Essentials: $19.95/month (up to 50k emails)
- Pro: $89.95/month (up to 100k emails)
Twilio Pricing
- SMS: ~$0.0075 per message (US)
- WhatsApp: ~$0.005 per session + $0.0042 per message
- Facebook Messenger: ~$0.0025 per message
Estimated Monthly Costs (Example)
For a business sending:
- 5,000 invoices/month via email: $19.95 (Sendgrid Essentials)
- 1,000 payment reminders via SMS: $7.50
- 500 collection notices via WhatsApp: $4.60
- Total: ~$32/month
14. Success Criteria
Technical
- ✅ All four channels functional (email, SMS, WhatsApp, Messenger)
- ✅ Template system operational
- ✅ Queue processing with retry logic
- ✅ Delivery tracking and status updates
- ✅ Webhooks receiving and processing correctly
- ✅ 95%+ delivery rate for valid contacts
- ✅ <5 second average queue processing time
- ✅ Test coverage >80%
Business
- ✅ Automated invoice sending on sale creation
- ✅ Payment link generation and distribution
- ✅ Automated collection reminders
- ✅ Reduced manual communication effort by 80%
- ✅ Improved payment collection rate
- ✅ Customer satisfaction (fewer missed communications)
User Experience
- ✅ Intuitive UI for sending communications
- ✅ Easy template management
- ✅ Clear communication history
- ✅ Simple preference management
- ✅ <2 clicks to send a communication
15. Future Enhancements
Short-term (3-6 months)
- Voice calls via Twilio
- MMS support (images in SMS)
- Rich media in WhatsApp (documents, images)
- Scheduled campaigns (bulk sends)
- A/B testing for templates
- Two-way communication (replies)
Long-term (6-12 months)
- AI-powered template suggestions
- Sentiment analysis for customer responses
- Automated chatbots for common queries
- Integration with CRM systems
- Advanced analytics and reporting
- Multi-language support
- SMS surveys and feedback collection
Appendix A: Database Migration Example
-- Migration: 2025-10-19-create-communication-tables.sql
-- Create ENUMs
CREATE TYPE communication_channel AS ENUM ('email', 'sms', 'whatsapp', 'messenger');
CREATE TYPE communication_status AS ENUM ('pending', 'queued', 'sent', 'delivered', 'failed', 'bounced', 'opened', 'clicked');
CREATE TYPE communication_type AS ENUM ('invoice', 'invoice_reminder', 'payment_confirmation', 'payment_link', 'collection_notice', 'low_stock_alert', 'general_notification');
CREATE TYPE queue_status AS ENUM ('pending', 'processing', 'completed', 'failed', 'cancelled');
CREATE TYPE queue_priority AS ENUM ('low', 'normal', 'high', 'urgent');
-- Communication table
CREATE TABLE communication (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
business_id UUID NOT NULL REFERENCES business(id),
created_by UUID NOT NULL REFERENCES "user"(id),
recipient_type VARCHAR(50) NOT NULL,
recipient_id UUID,
recipient_name VARCHAR(255),
recipient_contact VARCHAR(255) NOT NULL,
channel communication_channel NOT NULL,
type communication_type NOT NULL,
subject VARCHAR(500),
content TEXT NOT NULL,
entity_type VARCHAR(50),
entity_id UUID,
communication_template UUID,
template_variables JSONB,
status communication_status NOT NULL DEFAULT 'pending',
sent_at TIMESTAMP,
delivered_at TIMESTAMP,
opened_at TIMESTAMP,
clicked_at TIMESTAMP,
failed_at TIMESTAMP,
error_message TEXT,
provider VARCHAR(50),
provider_message_id VARCHAR(255),
provider_response JSONB,
retry_count INT DEFAULT 0,
max_retries INT DEFAULT 3,
next_retry_at TIMESTAMP,
metadata JSONB,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP
);
-- Indexes for communication
CREATE INDEX idx_communication_business_id ON communication(business_id);
CREATE INDEX idx_communication_recipient ON communication(recipient_type, recipient_id);
CREATE INDEX idx_communication_status ON communication(status);
CREATE INDEX idx_communication_entity ON communication(entity_type, entity_id);
CREATE INDEX idx_communication_created_at ON communication(created_at DESC);
CREATE INDEX idx_communication_channel ON communication(channel);
CREATE INDEX idx_communication_type ON communication(type);
CREATE INDEX idx_communication_provider_message_id ON communication(provider_message_id);
-- Communication template table
CREATE TABLE communication_template (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
business_id UUID REFERENCES business(id),
name VARCHAR(255) NOT NULL,
code VARCHAR(100) NOT NULL,
description TEXT,
channel communication_channel NOT NULL,
type communication_type NOT NULL,
subject_template VARCHAR(500),
body_template TEXT NOT NULL,
available_variables JSONB,
is_active BOOLEAN DEFAULT true,
is_system BOOLEAN DEFAULT false,
created_by UUID NOT NULL REFERENCES "user"(id),
updated_by UUID REFERENCES "user"(id),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP,
CONSTRAINT unique_template_code UNIQUE (business_id, code, channel)
);
CREATE INDEX idx_template_business ON communication_template(business_id);
CREATE INDEX idx_template_channel_type ON communication_template(channel, type);
CREATE INDEX idx_template_active ON communication_template(is_active);
CREATE INDEX idx_template_code ON communication_template(code);
-- Communication queue table
CREATE TABLE communication_queue (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
communication_id UUID REFERENCES communication(id),
priority queue_priority DEFAULT 'normal',
scheduled_at TIMESTAMP NOT NULL,
started_at TIMESTAMP,
completed_at TIMESTAMP,
status queue_status DEFAULT 'pending',
attempts INT DEFAULT 0,
max_attempts INT DEFAULT 3,
last_error TEXT,
payload JSONB NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP
);
CREATE INDEX idx_queue_status_scheduled ON communication_queue(status, scheduled_at);
CREATE INDEX idx_queue_priority ON communication_queue(priority DESC, created_at ASC);
CREATE INDEX idx_queue_communication ON communication_queue(communication_id);
-- Communication preferences table
CREATE TABLE communication_preference (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
entity_type VARCHAR(50) NOT NULL,
entity_id UUID NOT NULL,
business_id UUID NOT NULL REFERENCES business(id),
email_enabled BOOLEAN DEFAULT true,
sms_enabled BOOLEAN DEFAULT true,
whatsapp_enabled BOOLEAN DEFAULT true,
messenger_enabled BOOLEAN DEFAULT true,
preferences JSONB,
preferred_email VARCHAR(255),
preferred_phone VARCHAR(50),
preferred_whatsapp VARCHAR(50),
preferred_messenger_id VARCHAR(255),
opted_out_at TIMESTAMP,
opted_out_reason TEXT,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP,
CONSTRAINT unique_preference UNIQUE (entity_type, entity_id, business_id)
);
CREATE INDEX idx_preference_entity ON communication_preference(entity_type, entity_id);
CREATE INDEX idx_preference_business ON communication_preference(business_id);
-- Communication attachments table
CREATE TABLE communication_attachment (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
communication_id UUID NOT NULL REFERENCES communication(id) ON DELETE CASCADE,
filename VARCHAR(255) NOT NULL,
file_path VARCHAR(500),
file_url VARCHAR(500),
mime_type VARCHAR(100),
size_bytes INT,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_attachment_communication ON communication_attachment(communication_id);
-- Add foreign key to template
ALTER TABLE communication
ADD CONSTRAINT fk_communication_template
FOREIGN KEY (communication_template) REFERENCES communication_template(id);
Appendix B: Sample Code Structure
// Core service interface
export interface ICommunicationChannel {
send(communication: SendCommunicationDto): Promise<CommunicationResult>;
getStatus(messageId: string): Promise<CommunicationStatus>;
validate?(recipientContact: string): boolean;
}
// Main service
@Injectable()
export class CommunicationsService {
constructor(
private readonly channelFactory: ChannelFactoryService,
private readonly templateRenderer: TemplateRendererService,
private readonly queueService: CommunicationQueueService,
private readonly preferencesService: CommunicationPreferencesService,
private readonly repository: CommunicationsRepository
) {}
async send(dto: SendCommunicationDto): Promise<Communication> {
// 1. Check preferences
const canSend = await this.checkPreferences(dto);
if (!canSend) {
throw new Error("Recipient has opted out or disabled this channel");
}
// 2. Render template if using one
let content = dto.content;
let subject = dto.subject;
if (dto.templateId) {
const rendered = await this.templateRenderer.render(
dto.templateId,
dto.templateVariables
);
content = rendered.body;
subject = rendered.subject;
}
// 3. Create communication record
const communication = await this.repository.create({
...dto,
content,
subject,
status: "pending",
});
// 4. Enqueue for sending
await this.queueService.enqueue({
communicationId: communication.id,
payload: { ...dto, content, subject },
scheduledAt: dto.scheduledAt || new Date(),
priority: dto.priority || "normal",
});
return communication;
}
async executeSend(payload: any): Promise<void> {
// Get channel adapter
const adapter = this.channelFactory.getAdapter(payload.channel);
// Send via adapter
const result = await adapter.send(payload);
// Update communication record
await this.repository.update({
id: payload.communicationId,
data: {
status: result.success ? "sent" : "failed",
sentAt: result.success ? new Date() : undefined,
failedAt: !result.success ? new Date() : undefined,
errorMessage: result.error,
provider: result.provider,
providerMessageId: result.messageId,
providerResponse: result.providerResponse,
},
});
}
}
Conclusion
This design provides a comprehensive, scalable, and maintainable solution for multi-channel communications in the FlowPOS platform. The modular architecture allows for easy extension to additional channels in the future, while the event-driven approach ensures seamless integration with existing business processes.
The phased implementation approach allows for incremental delivery of value, starting with enhanced email capabilities and progressively adding SMS, WhatsApp, and Messenger support. Each phase builds on the previous one, ensuring a solid foundation before moving to more complex features.
By following this design, the FlowPOS platform will have a robust communication system that improves customer engagement, automates routine communications, and provides valuable insights into communication effectiveness.