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 = ` + + + + +
+ + Pay Now + +
`; + + 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 + ? `${esc(invoice.merchantName ?? '')}` + : ''; + + 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 = ` + + + + +
+ + Pay Now + +
`; + + 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 + ? `${esc(invoice.merchantName ?? '')}` + : ''; + + 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',