Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
7 changes: 7 additions & 0 deletions apps/api/src/modules/invoices/dto/send-invoice.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { z } from 'zod';

export const SendInvoiceSchema = z.object({
message: z.string().max(1000).optional(),
});

export type SendInvoiceDto = z.infer<typeof SendInvoiceSchema>;
38 changes: 34 additions & 4 deletions apps/api/src/modules/invoices/invoices.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 ───────────────────────────────────────────
Expand All @@ -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<void> {
// 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,
Expand All @@ -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,
Expand Down
15 changes: 13 additions & 2 deletions apps/api/src/modules/invoices/invoices.module.ts
Original file line number Diff line number Diff line change
@@ -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],
})
Expand Down
139 changes: 139 additions & 0 deletions apps/api/src/modules/invoices/invoices.reminder.processor.ts
Original file line number Diff line number Diff line change
@@ -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<string>('API_URL', 'http://localhost:3333');
this.checkoutUrl = this.config.get<string>(
'CHECKOUT_URL',
'http://localhost:3002',
);
}

async process(job: Job<ReminderJobData>): Promise<void> {
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
}
}
}
Loading
Loading