From 4596ce50e51431e0f5ad8537b4691a2ebebdbcd6 Mon Sep 17 00:00:00 2001
From: Collins Ikechukwu
Date: Sat, 4 Apr 2026 19:33:54 +0100
Subject: [PATCH] feat: invoice email sending, view tracking & BullMQ reminders
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Closes #66
- POST /v1/invoices/:id/send now sends branded email via Resend with
merchant logo, brand colour, optional custom message, Pay Now CTA,
and an embedded 1×1 tracking pixel
- GET /v1/invoices/:id/track — public pixel endpoint; loading it
transitions invoice SENT → VIEWED and fires invoice.viewed webhook
- Three BullMQ reminder jobs auto-scheduled on send (3 days before due,
on due date, 3 days after due date which also marks OVERDUE)
- Webhook events added: invoice.sent, invoice.viewed
- invoice.overdue webhook now fired per-invoice (bulk cron + reminder processor)
- invoice.paid webhook fired on full payment via recordPayment()
- Notification templates updated with merchant branding and contextual
due-date colouring (red when overdue)
Co-Authored-By: Claude Sonnet 4.6
---
.env.example | 1 +
.../modules/invoices/dto/send-invoice.dto.ts | 7 +
.../modules/invoices/invoices.controller.ts | 38 ++-
.../src/modules/invoices/invoices.module.ts | 15 +-
.../invoices/invoices.reminder.processor.ts | 139 ++++++++
.../src/modules/invoices/invoices.service.ts | 297 +++++++++++++++---
.../modules/notifications/templates/index.ts | 104 +++++-
apps/api/src/modules/notifications/types.ts | 11 +
.../modules/webhooks/webhooks.constants.ts | 2 +
9 files changed, 543 insertions(+), 71 deletions(-)
create mode 100644 apps/api/src/modules/invoices/dto/send-invoice.dto.ts
create mode 100644 apps/api/src/modules/invoices/invoices.reminder.processor.ts
diff --git a/.env.example b/.env.example
index c573195..f4b371b 100644
--- a/.env.example
+++ b/.env.example
@@ -70,6 +70,7 @@ CLOUDINARY_API_SECRET="your-api-secret"
# ── App ───────────────────────────────────────────────────────
PORT=3000
+API_URL="https://api.yourdomain.com"
NODE_ENV="development"
NEXT_PUBLIC_API_URL="http://localhost:3000"
FRONTEND_URL="http://localhost:3001"
diff --git a/apps/api/src/modules/invoices/dto/send-invoice.dto.ts b/apps/api/src/modules/invoices/dto/send-invoice.dto.ts
new file mode 100644
index 0000000..3d08dea
--- /dev/null
+++ b/apps/api/src/modules/invoices/dto/send-invoice.dto.ts
@@ -0,0 +1,7 @@
+import { z } from 'zod';
+
+export const SendInvoiceSchema = z.object({
+ message: z.string().max(1000).optional(),
+});
+
+export type SendInvoiceDto = z.infer;
diff --git a/apps/api/src/modules/invoices/invoices.controller.ts b/apps/api/src/modules/invoices/invoices.controller.ts
index 5f9f240..1fbd3a1 100644
--- a/apps/api/src/modules/invoices/invoices.controller.ts
+++ b/apps/api/src/modules/invoices/invoices.controller.ts
@@ -23,10 +23,18 @@ import {
RecordPaymentDto,
} from './dto/update-invoice.dto';
import { InvoiceFiltersSchema, InvoiceFiltersDto } from './dto/invoice-filters.dto';
+import { SendInvoiceSchema, SendInvoiceDto } from './dto/send-invoice.dto';
import { CombinedAuthGuard } from '../../common/guards/combined-auth.guard';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
import { CurrentMerchant } from '../merchant/decorators/current-merchant.decorator';
+import { PublicRoute } from '../../common/decorators/public-route.decorator';
+
+// 1×1 transparent GIF — used as an email open-tracking pixel
+const TRACKING_PIXEL = Buffer.from(
+ 'R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
+ 'base64',
+);
@Controller('v1/invoices')
@UseGuards(CombinedAuthGuard)
@@ -84,13 +92,15 @@ export class InvoicesController {
}
// ── POST /v1/invoices/:id/send ─────────────────────────────────────────────
+ // Sends the invoice email with optional custom message, schedules reminders
@Post(':id/send')
@UseGuards(JwtAuthGuard)
- async markSent(
+ async send(
@CurrentMerchant('id') merchantId: string,
@Param('id') id: string,
+ @Body(new ZodValidationPipe(SendInvoiceSchema)) dto: SendInvoiceDto,
) {
- return this.invoicesService.markSent(id, merchantId);
+ return this.invoicesService.markSent(id, merchantId, dto);
}
// ── POST /v1/invoices/:id/cancel ───────────────────────────────────────────
@@ -114,8 +124,29 @@ export class InvoicesController {
return this.invoicesService.recordPayment(id, merchantId, dto);
}
+ // ── GET /v1/invoices/:id/track ─────────────────────────────────────────────
+ // Public endpoint — email clients load this pixel when the customer opens the email.
+ // Updates invoice SENT → VIEWED and returns a 1×1 transparent GIF.
+ @Get(':id/track')
+ @PublicRoute()
+ @HttpCode(HttpStatus.OK)
+ async trackView(
+ @Param('id') id: string,
+ @Res() res: Response,
+ ): Promise {
+ // Fire-and-forget — never fail a pixel load due to our own errors
+ this.invoicesService.markViewed(id).catch(() => undefined);
+
+ res.set({
+ 'Content-Type': 'image/gif',
+ 'Content-Length': TRACKING_PIXEL.length,
+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
+ Pragma: 'no-cache',
+ });
+ res.end(TRACKING_PIXEL);
+ }
+
// ── GET /v1/invoices/:id/pdf ───────────────────────────────────────────────
- // Returns a redirect or the stored PDF URL (generates if not yet cached)
@Get(':id/pdf')
async getPdfUrl(
@CurrentMerchant('id') merchantId: string,
@@ -126,7 +157,6 @@ export class InvoicesController {
}
// ── GET /v1/invoices/:id/pdf/download ──────────────────────────────────────
- // Streams the PDF directly in the HTTP response
@Get(':id/pdf/download')
async downloadPdf(
@CurrentMerchant('id') merchantId: string,
diff --git a/apps/api/src/modules/invoices/invoices.module.ts b/apps/api/src/modules/invoices/invoices.module.ts
index 52f1a80..05fa40c 100644
--- a/apps/api/src/modules/invoices/invoices.module.ts
+++ b/apps/api/src/modules/invoices/invoices.module.ts
@@ -1,14 +1,25 @@
import { Module } from '@nestjs/common';
+import { BullModule } from '@nestjs/bullmq';
import { InvoicesController } from './invoices.controller';
import { InvoicesService } from './invoices.service';
import { PdfService } from './pdf.service';
+import { InvoicesReminderProcessor, INVOICE_REMINDER_QUEUE } from './invoices.reminder.processor';
import { PrismaModule } from '../prisma/prisma.module';
import { StorageModule } from '../storage/storage.module';
+import { NotificationsModule } from '../notifications/notifications.module';
+import { WebhooksModule } from '../webhooks/webhooks.module';
import { AuthModule } from '../auth/auth.module';
@Module({
- imports: [PrismaModule, StorageModule, AuthModule],
- providers: [InvoicesService, PdfService],
+ imports: [
+ PrismaModule,
+ StorageModule,
+ NotificationsModule,
+ WebhooksModule,
+ AuthModule,
+ BullModule.registerQueue({ name: INVOICE_REMINDER_QUEUE }),
+ ],
+ providers: [InvoicesService, PdfService, InvoicesReminderProcessor],
controllers: [InvoicesController],
exports: [InvoicesService],
})
diff --git a/apps/api/src/modules/invoices/invoices.reminder.processor.ts b/apps/api/src/modules/invoices/invoices.reminder.processor.ts
new file mode 100644
index 0000000..22c4353
--- /dev/null
+++ b/apps/api/src/modules/invoices/invoices.reminder.processor.ts
@@ -0,0 +1,139 @@
+import { Injectable, Logger } from '@nestjs/common';
+import { Processor, WorkerHost } from '@nestjs/bullmq';
+import { Job } from 'bullmq';
+import { InvoiceStatus, Prisma } from '@prisma/client';
+import { PrismaService } from '../prisma/prisma.service';
+import { NotificationsService } from '../notifications/notifications.service';
+import { WebhooksService } from '../webhooks/webhooks.service';
+import { ConfigService } from '@nestjs/config';
+
+export const INVOICE_REMINDER_QUEUE = 'invoice-reminders';
+
+export type ReminderType = 'before_due' | 'on_due' | 'after_due';
+
+export interface ReminderJobData {
+ invoiceId: string;
+ merchantId: string;
+ reminderType: ReminderType;
+}
+
+// Statuses that still need reminders (invoice hasn't been settled)
+const PENDING_STATUSES: InvoiceStatus[] = [
+ InvoiceStatus.SENT,
+ InvoiceStatus.VIEWED,
+ InvoiceStatus.PARTIALLY_PAID,
+ InvoiceStatus.OVERDUE,
+];
+
+@Injectable()
+@Processor(INVOICE_REMINDER_QUEUE)
+export class InvoicesReminderProcessor extends WorkerHost {
+ private readonly logger = new Logger(InvoicesReminderProcessor.name);
+ private readonly apiUrl: string;
+ private readonly checkoutUrl: string;
+
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly notifications: NotificationsService,
+ private readonly webhooks: WebhooksService,
+ private readonly config: ConfigService,
+ ) {
+ super();
+ this.apiUrl = this.config.get('API_URL', 'http://localhost:3333');
+ this.checkoutUrl = this.config.get(
+ 'CHECKOUT_URL',
+ 'http://localhost:3002',
+ );
+ }
+
+ async process(job: Job): Promise {
+ const { invoiceId, merchantId, reminderType } = job.data;
+
+ const invoice = await this.prisma.invoice.findUnique({
+ where: { id: invoiceId },
+ });
+
+ if (!invoice) {
+ this.logger.warn(`Reminder skipped — invoice ${invoiceId} not found`);
+ return;
+ }
+
+ // Skip if already settled
+ if (!PENDING_STATUSES.includes(invoice.status)) {
+ this.logger.log(
+ `Reminder skipped — invoice ${invoiceId} status: ${invoice.status}`,
+ );
+ return;
+ }
+
+ const merchant = await this.prisma.merchant.findUnique({
+ where: { id: merchantId },
+ select: {
+ name: true,
+ email: true,
+ logoUrl: true,
+ brandColor: true,
+ companyName: true,
+ },
+ });
+
+ if (!merchant) return;
+
+ const amountDue =
+ Number(invoice.total.toString()) - Number(invoice.amountPaid.toString());
+
+ const invoiceEmailData = {
+ id: invoice.id,
+ reference: invoice.invoiceNumber ?? invoice.id,
+ amount: amountDue,
+ currency: invoice.currency,
+ dueDate: invoice.dueDate ?? new Date(),
+ merchantName: merchant.companyName ?? merchant.name,
+ merchantEmail: merchant.email,
+ merchantLogo: merchant.logoUrl ?? undefined,
+ merchantBrandColor: merchant.brandColor ?? undefined,
+ customerName: invoice.customerName ?? undefined,
+ checkoutUrl: `${this.checkoutUrl}/invoice/${invoice.id}`,
+ };
+
+ // ── after_due: mark OVERDUE + fire webhook ─────────────────────────────
+ if (reminderType === 'after_due') {
+ if (invoice.status !== InvoiceStatus.OVERDUE) {
+ await this.prisma.invoice.update({
+ where: { id: invoiceId },
+ data: { status: InvoiceStatus.OVERDUE },
+ });
+
+ await this.webhooks.dispatch(merchantId, 'invoice.overdue', {
+ invoiceId,
+ customerEmail: invoice.customerEmail,
+ total: Number(invoice.total.toString()),
+ amountPaid: Number(invoice.amountPaid.toString()),
+ currency: invoice.currency,
+ dueDate: invoice.dueDate?.toISOString(),
+ });
+
+ this.logger.log(`Invoice ${invoiceId} marked OVERDUE`);
+ }
+ }
+
+ // ── Send reminder email for all types ──────────────────────────────────
+ try {
+ await this.notifications.sendInvoiceReminder(
+ invoice.customerEmail,
+ invoiceEmailData as Parameters<
+ NotificationsService['sendInvoiceReminder']
+ >[1],
+ );
+ this.logger.log(
+ `Sent ${reminderType} reminder for invoice ${invoiceId} to ${invoice.customerEmail}`,
+ );
+ } catch (err) {
+ this.logger.error(
+ `Failed to send ${reminderType} reminder for invoice ${invoiceId}`,
+ err,
+ );
+ throw err; // let BullMQ retry
+ }
+ }
+}
diff --git a/apps/api/src/modules/invoices/invoices.service.ts b/apps/api/src/modules/invoices/invoices.service.ts
index 1e2e1dc..ea44395 100644
--- a/apps/api/src/modules/invoices/invoices.service.ts
+++ b/apps/api/src/modules/invoices/invoices.service.ts
@@ -5,14 +5,24 @@ import {
Logger,
NotFoundException,
} from '@nestjs/common';
+import { InjectQueue } from '@nestjs/bullmq';
+import { Queue } from 'bullmq';
import { Invoice, InvoiceStatus, Merchant, Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { StorageService } from '../storage/storage.service';
import { PdfService } from './pdf.service';
+import { NotificationsService } from '../notifications/notifications.service';
+import { WebhooksService } from '../webhooks/webhooks.service';
+import { ConfigService } from '@nestjs/config';
import { CreateInvoiceDto } from './dto/create-invoice.dto';
import { UpdateInvoiceDto, RecordPaymentDto } from './dto/update-invoice.dto';
import { InvoiceFiltersDto } from './dto/invoice-filters.dto';
+import { SendInvoiceDto } from './dto/send-invoice.dto';
import { InvoiceTemplateData } from './templates/invoice.template';
+import {
+ INVOICE_REMINDER_QUEUE,
+ ReminderJobData,
+} from './invoices.reminder.processor';
// ── Types ──────────────────────────────────────────────────────────────────────
@@ -83,12 +93,25 @@ function resolveNextStatus(
@Injectable()
export class InvoicesService {
private readonly logger = new Logger(InvoicesService.name);
+ private readonly apiUrl: string;
+ private readonly checkoutUrl: string;
constructor(
private readonly prisma: PrismaService,
private readonly storage: StorageService,
private readonly pdf: PdfService,
- ) {}
+ private readonly notifications: NotificationsService,
+ private readonly webhooks: WebhooksService,
+ private readonly config: ConfigService,
+ @InjectQueue(INVOICE_REMINDER_QUEUE)
+ private readonly reminderQueue: Queue,
+ ) {
+ this.apiUrl = this.config.get('API_URL', 'http://localhost:3333');
+ this.checkoutUrl = this.config.get(
+ 'CHECKOUT_URL',
+ 'http://localhost:3002',
+ );
+ }
// ── Create ─────────────────────────────────────────────────────────────────
@@ -113,8 +136,7 @@ export class InvoicesService {
subtotal: new Prisma.Decimal(subtotal),
taxRate:
dto.taxRate != null ? new Prisma.Decimal(dto.taxRate) : undefined,
- taxAmount:
- taxAmount > 0 ? new Prisma.Decimal(taxAmount) : undefined,
+ taxAmount: taxAmount > 0 ? new Prisma.Decimal(taxAmount) : undefined,
discount:
dto.discount != null ? new Prisma.Decimal(dto.discount) : undefined,
total: new Prisma.Decimal(total),
@@ -136,8 +158,18 @@ export class InvoicesService {
merchantId: string,
filters: InvoiceFiltersDto,
): Promise {
- const { page, limit, sortBy, sortOrder, status, currency, customerEmail, from, to, search } =
- filters;
+ const {
+ page,
+ limit,
+ sortBy,
+ sortOrder,
+ status,
+ currency,
+ customerEmail,
+ from,
+ to,
+ search,
+ } = filters;
const where: Prisma.InvoiceWhereInput = {
merchantId,
@@ -155,9 +187,24 @@ export class InvoicesService {
...(search && {
OR: [
{ id: { contains: search, mode: Prisma.QueryMode.insensitive } },
- { invoiceNumber: { contains: search, mode: Prisma.QueryMode.insensitive } },
- { customerEmail: { contains: search, mode: Prisma.QueryMode.insensitive } },
- { customerName: { contains: search, mode: Prisma.QueryMode.insensitive } },
+ {
+ invoiceNumber: {
+ contains: search,
+ mode: Prisma.QueryMode.insensitive,
+ },
+ },
+ {
+ customerEmail: {
+ contains: search,
+ mode: Prisma.QueryMode.insensitive,
+ },
+ },
+ {
+ customerName: {
+ contains: search,
+ mode: Prisma.QueryMode.insensitive,
+ },
+ },
],
}),
};
@@ -217,8 +264,10 @@ export class InvoicesService {
if (dto.lineItems) {
const resolved = computeTotals(
dto.lineItems,
- dto.taxRate ?? (existing.taxRate ? toNumber(existing.taxRate) : undefined),
- dto.discount ?? (existing.discount ? toNumber(existing.discount) : undefined),
+ dto.taxRate ??
+ (existing.taxRate ? toNumber(existing.taxRate) : undefined),
+ dto.discount ??
+ (existing.discount ? toNumber(existing.discount) : undefined),
);
lineItems = resolved.lineItems;
subtotal = resolved.subtotal;
@@ -233,30 +282,39 @@ export class InvoicesService {
total = resolved.total;
}
- const updated = await this.prisma.invoice.update({
+ return this.prisma.invoice.update({
where: { id },
data: {
- ...(dto.invoiceNumber !== undefined && { invoiceNumber: dto.invoiceNumber }),
- ...(dto.customerEmail !== undefined && { customerEmail: dto.customerEmail }),
- ...(dto.customerName !== undefined && { customerName: dto.customerName }),
- ...(dto.customerPhone !== undefined && { customerPhone: dto.customerPhone }),
+ ...(dto.invoiceNumber !== undefined && {
+ invoiceNumber: dto.invoiceNumber,
+ }),
+ ...(dto.customerEmail !== undefined && {
+ customerEmail: dto.customerEmail,
+ }),
+ ...(dto.customerName !== undefined && {
+ customerName: dto.customerName,
+ }),
+ ...(dto.customerPhone !== undefined && {
+ customerPhone: dto.customerPhone,
+ }),
...(dto.customerAddress !== undefined && {
customerAddress: dto.customerAddress as Prisma.InputJsonValue,
}),
lineItems: lineItems as unknown as Prisma.InputJsonValue,
subtotal: new Prisma.Decimal(subtotal),
- taxRate: dto.taxRate !== undefined ? new Prisma.Decimal(dto.taxRate) : undefined,
+ taxRate:
+ dto.taxRate !== undefined
+ ? new Prisma.Decimal(dto.taxRate)
+ : undefined,
taxAmount: taxAmount > 0 ? new Prisma.Decimal(taxAmount) : undefined,
- discount: discountValue > 0 ? new Prisma.Decimal(discountValue) : undefined,
+ discount:
+ discountValue > 0 ? new Prisma.Decimal(discountValue) : undefined,
total: new Prisma.Decimal(total),
...(dto.dueDate !== undefined && { dueDate: dto.dueDate }),
...(dto.notes !== undefined && { notes: dto.notes }),
- // Invalidate cached PDF since content changed
- pdfUrl: null,
+ pdfUrl: null, // invalidate cached PDF
},
});
-
- return updated;
}
// ── Cancel / Delete ────────────────────────────────────────────────────────
@@ -293,32 +351,105 @@ export class InvoicesService {
this.logger.log(`Deleted invoice ${id}`);
}
- // ── Mark as sent ───────────────────────────────────────────────────────────
+ // ── Send (DRAFT → SENT + email + webhook + schedule reminders) ─────────────
- async markSent(id: string, merchantId: string): Promise {
+ async markSent(
+ id: string,
+ merchantId: string,
+ dto: SendInvoiceDto,
+ ): Promise {
const existing = await this.getById(id, merchantId);
if (existing.status !== InvoiceStatus.DRAFT) {
- throw new BadRequestException('Only DRAFT invoices can be marked as sent');
+ throw new BadRequestException('Only DRAFT invoices can be sent');
}
- return this.prisma.invoice.update({
+ const merchant = await this.prisma.merchant.findUniqueOrThrow({
+ where: { id: merchantId },
+ select: {
+ name: true,
+ email: true,
+ logoUrl: true,
+ brandColor: true,
+ companyName: true,
+ },
+ });
+
+ // Ensure PDF exists before sending
+ const pdfUrl = existing.pdfUrl ?? (await this.generatePdf(id, merchantId));
+
+ const amountDue =
+ toNumber(existing.total) - toNumber(existing.amountPaid);
+
+ // Send invoice email with branding + tracking pixel + Pay Now link
+ await this.notifications.sendInvoice(
+ existing.customerEmail,
+ {
+ id: existing.id,
+ reference: existing.invoiceNumber ?? existing.id,
+ amount: amountDue,
+ currency: existing.currency,
+ dueDate:
+ existing.dueDate ??
+ new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
+ merchantName: merchant.companyName ?? merchant.name,
+ merchantEmail: merchant.email,
+ merchantLogo: merchant.logoUrl ?? undefined,
+ merchantBrandColor: merchant.brandColor ?? undefined,
+ customerName: existing.customerName ?? undefined,
+ message: dto.message,
+ trackingPixelUrl: `${this.apiUrl}/v1/invoices/${id}/track`,
+ checkoutUrl: `${this.checkoutUrl}/invoice/${id}`,
+ },
+ pdfUrl,
+ );
+
+ // Update status
+ const updated = await this.prisma.invoice.update({
where: { id },
data: { status: InvoiceStatus.SENT },
});
+
+ // Fire webhook
+ await this.webhooks.dispatch(merchantId, 'invoice.sent', {
+ invoiceId: id,
+ invoiceNumber: existing.invoiceNumber,
+ customerEmail: existing.customerEmail,
+ total: toNumber(existing.total),
+ currency: existing.currency,
+ dueDate: existing.dueDate?.toISOString() ?? null,
+ });
+
+ // Schedule reminder jobs if due date is set
+ if (existing.dueDate) {
+ await this.scheduleReminders(id, merchantId, existing.dueDate);
+ }
+
+ return updated;
}
- // ── Mark as viewed (public / checkout) ────────────────────────────────────
+ // ── View tracking (pixel fires from customer email) ────────────────────────
async markViewed(id: string): Promise {
const invoice = await this.prisma.invoice.findUnique({ where: { id } });
if (!invoice) return;
- if (invoice.status === InvoiceStatus.SENT) {
- await this.prisma.invoice.update({
- where: { id },
- data: { status: InvoiceStatus.VIEWED },
- });
- }
+ if (invoice.status !== InvoiceStatus.SENT) return;
+
+ await this.prisma.invoice.update({
+ where: { id },
+ data: { status: InvoiceStatus.VIEWED },
+ });
+
+ // Fire webhook (best-effort — don't throw if it fails)
+ this.webhooks
+ .dispatch(invoice.merchantId, 'invoice.viewed', {
+ invoiceId: id,
+ customerEmail: invoice.customerEmail,
+ viewedAt: new Date().toISOString(),
+ })
+ .catch((err) =>
+ this.logger.warn(`invoice.viewed webhook failed for ${id}`, err),
+ );
}
// ── Record partial/full payment ────────────────────────────────────────────
@@ -355,7 +486,7 @@ export class InvoicesService {
const newStatus = resolveNextStatus(existing.status, total, newAmountPaid);
- return this.prisma.invoice.update({
+ const updated = await this.prisma.invoice.update({
where: { id },
data: {
amountPaid: new Prisma.Decimal(newAmountPaid),
@@ -364,19 +495,55 @@ export class InvoicesService {
...(dto.paymentId && { paymentId: dto.paymentId }),
},
});
+
+ if (newStatus === InvoiceStatus.PAID) {
+ await this.webhooks.dispatch(merchantId, 'invoice.paid', {
+ invoiceId: id,
+ customerEmail: existing.customerEmail,
+ total,
+ currency: existing.currency,
+ paidAt: updated.paidAt?.toISOString(),
+ });
+ }
+
+ return updated;
}
- // ── Overdue check (called by cron or scheduler) ────────────────────────────
+ // ── Overdue check (called by cron / scheduler) ─────────────────────────────
async markOverdueInvoices(): Promise {
- const { count } = await this.prisma.invoice.updateMany({
+ const overdueInvoices = await this.prisma.invoice.findMany({
where: {
status: { in: [InvoiceStatus.SENT, InvoiceStatus.VIEWED] },
dueDate: { lt: new Date() },
},
+ select: { id: true, merchantId: true, customerEmail: true, total: true, currency: true, dueDate: true },
+ });
+
+ if (overdueInvoices.length === 0) return 0;
+
+ await this.prisma.invoice.updateMany({
+ where: { id: { in: overdueInvoices.map((i) => i.id) } },
data: { status: InvoiceStatus.OVERDUE },
});
- return count;
+
+ // Fire webhook per invoice (best-effort)
+ for (const invoice of overdueInvoices) {
+ this.webhooks
+ .dispatch(invoice.merchantId, 'invoice.overdue', {
+ invoiceId: invoice.id,
+ customerEmail: invoice.customerEmail,
+ total: toNumber(invoice.total),
+ currency: invoice.currency,
+ dueDate: invoice.dueDate?.toISOString(),
+ })
+ .catch((err) =>
+ this.logger.warn(`invoice.overdue webhook failed for ${invoice.id}`, err),
+ );
+ }
+
+ this.logger.log(`Marked ${overdueInvoices.length} invoice(s) as OVERDUE`);
+ return overdueInvoices.length;
}
// ── PDF generation ─────────────────────────────────────────────────────────
@@ -384,10 +551,7 @@ export class InvoicesService {
async generatePdf(id: string, merchantId: string): Promise {
const invoice = await this.getById(id, merchantId);
- // Return cached URL if already generated and invoice not modified since
- if (invoice.pdfUrl) {
- return invoice.pdfUrl;
- }
+ if (invoice.pdfUrl) return invoice.pdfUrl;
const merchant = await this.prisma.merchant.findUnique({
where: { id: merchantId },
@@ -408,12 +572,9 @@ export class InvoicesService {
const key = `invoices/${merchantId}/${id}/invoice-${id}.pdf`;
const url = await this.storage.upload(key, pdfBuffer, 'application/pdf');
- await this.prisma.invoice.update({
- where: { id },
- data: { pdfUrl: url },
- });
+ await this.prisma.invoice.update({ where: { id }, data: { pdfUrl: url } });
- this.logger.log(`PDF generated and stored for invoice ${id}: ${url}`);
+ this.logger.log(`PDF generated for invoice ${id}: ${url}`);
return url;
}
@@ -433,15 +594,49 @@ export class InvoicesService {
if (!merchant) throw new NotFoundException('Merchant not found');
- const templateData = this.buildTemplateData(invoice, merchant);
- return this.pdf.generateInvoicePdf(templateData);
+ return this.pdf.generateInvoicePdf(this.buildTemplateData(invoice, merchant));
}
// ── Private helpers ────────────────────────────────────────────────────────
+ private async scheduleReminders(
+ invoiceId: string,
+ merchantId: string,
+ dueDate: Date,
+ ): Promise {
+ const now = Date.now();
+ const due = dueDate.getTime();
+
+ const jobs: Array<{ reminderType: ReminderJobData['reminderType']; delay: number }> = [
+ { reminderType: 'before_due', delay: due - now - 3 * 24 * 60 * 60 * 1000 },
+ { reminderType: 'on_due', delay: due - now },
+ { reminderType: 'after_due', delay: due - now + 3 * 24 * 60 * 60 * 1000 },
+ ];
+
+ for (const job of jobs) {
+ if (job.delay > 0) {
+ await this.reminderQueue.add(
+ 'reminder',
+ { invoiceId, merchantId, reminderType: job.reminderType },
+ {
+ delay: job.delay,
+ attempts: 3,
+ backoff: { type: 'exponential', delay: 5000 },
+ jobId: `${invoiceId}:${job.reminderType}`, // idempotent — won't duplicate on re-send
+ },
+ );
+ }
+ }
+
+ this.logger.log(`Scheduled reminder jobs for invoice ${invoiceId}`);
+ }
+
private buildTemplateData(
invoice: Invoice,
- merchant: Pick,
+ merchant: Pick<
+ Merchant,
+ 'name' | 'email' | 'logoUrl' | 'brandColor' | 'companyName'
+ >,
): InvoiceTemplateData {
return {
id: invoice.id,
@@ -458,8 +653,10 @@ export class InvoicesService {
customerEmail: invoice.customerEmail,
customerName: invoice.customerName ?? undefined,
customerPhone: invoice.customerPhone ?? undefined,
- customerAddress: invoice.customerAddress as unknown as InvoiceTemplateData['customerAddress'],
- lineItems: invoice.lineItems as unknown as InvoiceTemplateData['lineItems'],
+ customerAddress:
+ invoice.customerAddress as unknown as InvoiceTemplateData['customerAddress'],
+ lineItems:
+ invoice.lineItems as unknown as InvoiceTemplateData['lineItems'],
subtotal: toNumber(invoice.subtotal),
taxRate: invoice.taxRate ? toNumber(invoice.taxRate) : undefined,
taxAmount: invoice.taxAmount ? toNumber(invoice.taxAmount) : undefined,
diff --git a/apps/api/src/modules/notifications/templates/index.ts b/apps/api/src/modules/notifications/templates/index.ts
index 8243989..b201fdf 100644
--- a/apps/api/src/modules/notifications/templates/index.ts
+++ b/apps/api/src/modules/notifications/templates/index.ts
@@ -157,39 +157,113 @@ export const merchantPaymentNotificationTemplate = (payment: Payment) =>
`);
-export const invoiceTemplate = (invoice: Invoice, appUrl: string) =>
- layout(`
- Invoice ${esc(invoice.id)}
-
- You have a new invoice. Please review the details below.
-
+export const invoiceTemplate = (invoice: Invoice, appUrl: string) => {
+ const brand = invoice.merchantBrandColor ?? '#000000';
+ const payUrl = invoice.checkoutUrl ?? `${appUrl}/pay/${encodeURIComponent(invoice.id)}`;
+ const ctaButton = `
+`;
+
+ const pixel = invoice.trackingPixelUrl
+ ? `
`
+ : '';
+
+ const fromLine = invoice.merchantName
+ ? `
+ You have a new invoice from ${esc(invoice.merchantName)}.
+ ${invoice.merchantEmail ? `Questions? Reply to ${esc(invoice.merchantEmail)}.` : ''}
+
`
+ : `You have a new invoice. Please review the details below.
`;
+
+ const greeting = invoice.customerName
+ ? `Hi ${esc(invoice.customerName)},
`
+ : '';
+
+ const customMessage = invoice.message
+ ? `
+ ${esc(invoice.message)}
+
`
+ : '';
+
+ const logoHtml = invoice.merchantLogo
+ ? `
`
+ : '';
+
+ return layout(`
+ ${logoHtml}
+ Invoice #${esc(invoice.reference ?? invoice.id)}
+ ${greeting}
+ ${fromLine}
+ ${customMessage}
| Amount Due |
${formatAmount(invoice.amount, invoice.currency)} |
| Due Date |
${invoice.dueDate.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })} |
| Reference |
- ${esc(invoice.reference || invoice.id)} |
+ ${esc(invoice.reference ?? invoice.id)} |
- ${button('Pay Now', `${appUrl}/pay/${encodeURIComponent(invoice.id)}`)}
+ ${ctaButton}
+ ${pixel}
`);
+};
-export const invoiceReminderTemplate = (invoice: Invoice, appUrl: string) =>
- layout(`
+export const invoiceReminderTemplate = (invoice: Invoice, appUrl: string) => {
+ const brand = invoice.merchantBrandColor ?? '#000000';
+ const payUrl = invoice.checkoutUrl ?? `${appUrl}/pay/${encodeURIComponent(invoice.id)}`;
+ const ctaButton = `
+`;
+
+ const now = new Date();
+ const isOverdue = invoice.dueDate < now;
+ const dueLabelColor = isOverdue ? '#dc2626' : '#18181b';
+ const dueLabel = isOverdue ? 'overdue' : daysUntil(invoice.dueDate);
+
+ const fromLine = invoice.merchantName
+ ? `
+ This is a reminder from ${esc(invoice.merchantName)}.
+
`
+ : '';
+
+ const logoHtml = invoice.merchantLogo
+ ? `
`
+ : '';
+
+ return layout(`
+ ${logoHtml}
Invoice Reminder
+ ${fromLine}
- Your invoice ${esc(invoice.id)} for ${formatAmount(invoice.amount, invoice.currency)} is due ${daysUntil(invoice.dueDate)}.
+ Your invoice ${esc(invoice.reference ?? invoice.id)} for
+ ${formatAmount(invoice.amount, invoice.currency)} is
+ ${esc(dueLabel)}.
- | Invoice ID |
- ${esc(invoice.id)} |
+ | Reference |
+ ${esc(invoice.reference ?? invoice.id)} |
| Amount |
${formatAmount(invoice.amount, invoice.currency)} |
| Due Date |
- ${invoice.dueDate.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })} |
+ ${invoice.dueDate.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })} |
- ${button('Pay Now', `${appUrl}/pay/${encodeURIComponent(invoice.id)}`)}
+ ${ctaButton}
`);
+};
export const payoutConfirmationTemplate = (payout: Payout) =>
layout(`
diff --git a/apps/api/src/modules/notifications/types.ts b/apps/api/src/modules/notifications/types.ts
index 3e2dd23..7f7e675 100644
--- a/apps/api/src/modules/notifications/types.ts
+++ b/apps/api/src/modules/notifications/types.ts
@@ -25,6 +25,17 @@ export interface Invoice {
currency: string;
dueDate: Date;
reference?: string;
+ // Merchant branding (used in invoice emails)
+ merchantName?: string;
+ merchantEmail?: string;
+ merchantLogo?: string;
+ merchantBrandColor?: string;
+ // Customer
+ customerName?: string;
+ // Email extras
+ message?: string;
+ trackingPixelUrl?: string;
+ checkoutUrl?: string;
}
export interface Payout {
diff --git a/apps/api/src/modules/webhooks/webhooks.constants.ts b/apps/api/src/modules/webhooks/webhooks.constants.ts
index bb6dbde..2cb76bb 100644
--- a/apps/api/src/modules/webhooks/webhooks.constants.ts
+++ b/apps/api/src/modules/webhooks/webhooks.constants.ts
@@ -6,6 +6,8 @@ export const WEBHOOK_EVENTS = [
'payout.initiated',
'payout.completed',
'payout.failed',
+ 'invoice.sent',
+ 'invoice.viewed',
'invoice.paid',
'invoice.overdue',
'refund.created',