Skip to content

Commit c57b2fa

Browse files
authored
Merge pull request #124 from 0xdevcollins/feat/invoice-email-reminders
feat: invoice email sending, view tracking & BullMQ payment reminders
2 parents 0a13598 + 4596ce5 commit c57b2fa

File tree

9 files changed

+543
-71
lines changed

9 files changed

+543
-71
lines changed

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ CLOUDINARY_API_SECRET="your-api-secret"
7070

7171
# ── App ───────────────────────────────────────────────────────
7272
PORT=3000
73+
API_URL="https://api.yourdomain.com"
7374
NODE_ENV="development"
7475
NEXT_PUBLIC_API_URL="http://localhost:3000"
7576
FRONTEND_URL="http://localhost:3001"
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { z } from 'zod';
2+
3+
export const SendInvoiceSchema = z.object({
4+
message: z.string().max(1000).optional(),
5+
});
6+
7+
export type SendInvoiceDto = z.infer<typeof SendInvoiceSchema>;

apps/api/src/modules/invoices/invoices.controller.ts

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,18 @@ import {
2323
RecordPaymentDto,
2424
} from './dto/update-invoice.dto';
2525
import { InvoiceFiltersSchema, InvoiceFiltersDto } from './dto/invoice-filters.dto';
26+
import { SendInvoiceSchema, SendInvoiceDto } from './dto/send-invoice.dto';
2627
import { CombinedAuthGuard } from '../../common/guards/combined-auth.guard';
2728
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
2829
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
2930
import { CurrentMerchant } from '../merchant/decorators/current-merchant.decorator';
31+
import { PublicRoute } from '../../common/decorators/public-route.decorator';
32+
33+
// 1×1 transparent GIF — used as an email open-tracking pixel
34+
const TRACKING_PIXEL = Buffer.from(
35+
'R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
36+
'base64',
37+
);
3038

3139
@Controller('v1/invoices')
3240
@UseGuards(CombinedAuthGuard)
@@ -84,13 +92,15 @@ export class InvoicesController {
8492
}
8593

8694
// ── POST /v1/invoices/:id/send ─────────────────────────────────────────────
95+
// Sends the invoice email with optional custom message, schedules reminders
8796
@Post(':id/send')
8897
@UseGuards(JwtAuthGuard)
89-
async markSent(
98+
async send(
9099
@CurrentMerchant('id') merchantId: string,
91100
@Param('id') id: string,
101+
@Body(new ZodValidationPipe(SendInvoiceSchema)) dto: SendInvoiceDto,
92102
) {
93-
return this.invoicesService.markSent(id, merchantId);
103+
return this.invoicesService.markSent(id, merchantId, dto);
94104
}
95105

96106
// ── POST /v1/invoices/:id/cancel ───────────────────────────────────────────
@@ -114,8 +124,29 @@ export class InvoicesController {
114124
return this.invoicesService.recordPayment(id, merchantId, dto);
115125
}
116126

127+
// ── GET /v1/invoices/:id/track ─────────────────────────────────────────────
128+
// Public endpoint — email clients load this pixel when the customer opens the email.
129+
// Updates invoice SENT → VIEWED and returns a 1×1 transparent GIF.
130+
@Get(':id/track')
131+
@PublicRoute()
132+
@HttpCode(HttpStatus.OK)
133+
async trackView(
134+
@Param('id') id: string,
135+
@Res() res: Response,
136+
): Promise<void> {
137+
// Fire-and-forget — never fail a pixel load due to our own errors
138+
this.invoicesService.markViewed(id).catch(() => undefined);
139+
140+
res.set({
141+
'Content-Type': 'image/gif',
142+
'Content-Length': TRACKING_PIXEL.length,
143+
'Cache-Control': 'no-store, no-cache, must-revalidate',
144+
Pragma: 'no-cache',
145+
});
146+
res.end(TRACKING_PIXEL);
147+
}
148+
117149
// ── GET /v1/invoices/:id/pdf ───────────────────────────────────────────────
118-
// Returns a redirect or the stored PDF URL (generates if not yet cached)
119150
@Get(':id/pdf')
120151
async getPdfUrl(
121152
@CurrentMerchant('id') merchantId: string,
@@ -126,7 +157,6 @@ export class InvoicesController {
126157
}
127158

128159
// ── GET /v1/invoices/:id/pdf/download ──────────────────────────────────────
129-
// Streams the PDF directly in the HTTP response
130160
@Get(':id/pdf/download')
131161
async downloadPdf(
132162
@CurrentMerchant('id') merchantId: string,

apps/api/src/modules/invoices/invoices.module.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,25 @@
11
import { Module } from '@nestjs/common';
2+
import { BullModule } from '@nestjs/bullmq';
23
import { InvoicesController } from './invoices.controller';
34
import { InvoicesService } from './invoices.service';
45
import { PdfService } from './pdf.service';
6+
import { InvoicesReminderProcessor, INVOICE_REMINDER_QUEUE } from './invoices.reminder.processor';
57
import { PrismaModule } from '../prisma/prisma.module';
68
import { StorageModule } from '../storage/storage.module';
9+
import { NotificationsModule } from '../notifications/notifications.module';
10+
import { WebhooksModule } from '../webhooks/webhooks.module';
711
import { AuthModule } from '../auth/auth.module';
812

913
@Module({
10-
imports: [PrismaModule, StorageModule, AuthModule],
11-
providers: [InvoicesService, PdfService],
14+
imports: [
15+
PrismaModule,
16+
StorageModule,
17+
NotificationsModule,
18+
WebhooksModule,
19+
AuthModule,
20+
BullModule.registerQueue({ name: INVOICE_REMINDER_QUEUE }),
21+
],
22+
providers: [InvoicesService, PdfService, InvoicesReminderProcessor],
1223
controllers: [InvoicesController],
1324
exports: [InvoicesService],
1425
})
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { Injectable, Logger } from '@nestjs/common';
2+
import { Processor, WorkerHost } from '@nestjs/bullmq';
3+
import { Job } from 'bullmq';
4+
import { InvoiceStatus, Prisma } from '@prisma/client';
5+
import { PrismaService } from '../prisma/prisma.service';
6+
import { NotificationsService } from '../notifications/notifications.service';
7+
import { WebhooksService } from '../webhooks/webhooks.service';
8+
import { ConfigService } from '@nestjs/config';
9+
10+
export const INVOICE_REMINDER_QUEUE = 'invoice-reminders';
11+
12+
export type ReminderType = 'before_due' | 'on_due' | 'after_due';
13+
14+
export interface ReminderJobData {
15+
invoiceId: string;
16+
merchantId: string;
17+
reminderType: ReminderType;
18+
}
19+
20+
// Statuses that still need reminders (invoice hasn't been settled)
21+
const PENDING_STATUSES: InvoiceStatus[] = [
22+
InvoiceStatus.SENT,
23+
InvoiceStatus.VIEWED,
24+
InvoiceStatus.PARTIALLY_PAID,
25+
InvoiceStatus.OVERDUE,
26+
];
27+
28+
@Injectable()
29+
@Processor(INVOICE_REMINDER_QUEUE)
30+
export class InvoicesReminderProcessor extends WorkerHost {
31+
private readonly logger = new Logger(InvoicesReminderProcessor.name);
32+
private readonly apiUrl: string;
33+
private readonly checkoutUrl: string;
34+
35+
constructor(
36+
private readonly prisma: PrismaService,
37+
private readonly notifications: NotificationsService,
38+
private readonly webhooks: WebhooksService,
39+
private readonly config: ConfigService,
40+
) {
41+
super();
42+
this.apiUrl = this.config.get<string>('API_URL', 'http://localhost:3333');
43+
this.checkoutUrl = this.config.get<string>(
44+
'CHECKOUT_URL',
45+
'http://localhost:3002',
46+
);
47+
}
48+
49+
async process(job: Job<ReminderJobData>): Promise<void> {
50+
const { invoiceId, merchantId, reminderType } = job.data;
51+
52+
const invoice = await this.prisma.invoice.findUnique({
53+
where: { id: invoiceId },
54+
});
55+
56+
if (!invoice) {
57+
this.logger.warn(`Reminder skipped — invoice ${invoiceId} not found`);
58+
return;
59+
}
60+
61+
// Skip if already settled
62+
if (!PENDING_STATUSES.includes(invoice.status)) {
63+
this.logger.log(
64+
`Reminder skipped — invoice ${invoiceId} status: ${invoice.status}`,
65+
);
66+
return;
67+
}
68+
69+
const merchant = await this.prisma.merchant.findUnique({
70+
where: { id: merchantId },
71+
select: {
72+
name: true,
73+
email: true,
74+
logoUrl: true,
75+
brandColor: true,
76+
companyName: true,
77+
},
78+
});
79+
80+
if (!merchant) return;
81+
82+
const amountDue =
83+
Number(invoice.total.toString()) - Number(invoice.amountPaid.toString());
84+
85+
const invoiceEmailData = {
86+
id: invoice.id,
87+
reference: invoice.invoiceNumber ?? invoice.id,
88+
amount: amountDue,
89+
currency: invoice.currency,
90+
dueDate: invoice.dueDate ?? new Date(),
91+
merchantName: merchant.companyName ?? merchant.name,
92+
merchantEmail: merchant.email,
93+
merchantLogo: merchant.logoUrl ?? undefined,
94+
merchantBrandColor: merchant.brandColor ?? undefined,
95+
customerName: invoice.customerName ?? undefined,
96+
checkoutUrl: `${this.checkoutUrl}/invoice/${invoice.id}`,
97+
};
98+
99+
// ── after_due: mark OVERDUE + fire webhook ─────────────────────────────
100+
if (reminderType === 'after_due') {
101+
if (invoice.status !== InvoiceStatus.OVERDUE) {
102+
await this.prisma.invoice.update({
103+
where: { id: invoiceId },
104+
data: { status: InvoiceStatus.OVERDUE },
105+
});
106+
107+
await this.webhooks.dispatch(merchantId, 'invoice.overdue', {
108+
invoiceId,
109+
customerEmail: invoice.customerEmail,
110+
total: Number(invoice.total.toString()),
111+
amountPaid: Number(invoice.amountPaid.toString()),
112+
currency: invoice.currency,
113+
dueDate: invoice.dueDate?.toISOString(),
114+
});
115+
116+
this.logger.log(`Invoice ${invoiceId} marked OVERDUE`);
117+
}
118+
}
119+
120+
// ── Send reminder email for all types ──────────────────────────────────
121+
try {
122+
await this.notifications.sendInvoiceReminder(
123+
invoice.customerEmail,
124+
invoiceEmailData as Parameters<
125+
NotificationsService['sendInvoiceReminder']
126+
>[1],
127+
);
128+
this.logger.log(
129+
`Sent ${reminderType} reminder for invoice ${invoiceId} to ${invoice.customerEmail}`,
130+
);
131+
} catch (err) {
132+
this.logger.error(
133+
`Failed to send ${reminderType} reminder for invoice ${invoiceId}`,
134+
err,
135+
);
136+
throw err; // let BullMQ retry
137+
}
138+
}
139+
}

0 commit comments

Comments
 (0)