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
10 changes: 4 additions & 6 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,10 @@ STRIPE_WEBHOOK_SECRET="whsec_..."
RESEND_API_KEY="re_..."
EMAIL_FROM="payments@useroutr.io"

# ── Storage ───────────────────────────────────────────────────
R2_ACCOUNT_ID="your-cloudflare-account-id"
R2_ACCESS_KEY_ID="your-r2-access-key"
R2_SECRET_ACCESS_KEY="your-r2-secret-key"
R2_BUCKET_NAME="useroutr-files"
R2_PUBLIC_URL="https://files.useroutr.io"
# ── Storage (Cloudinary) ──────────────────────────────────────
CLOUDINARY_CLOUD_NAME="your-cloud-name"
CLOUDINARY_API_KEY="your-api-key"
CLOUDINARY_API_SECRET="your-api-secret"

# ── App ───────────────────────────────────────────────────────
PORT=3000
Expand Down
4 changes: 4 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1024.0",
"@aws-sdk/s3-request-presigner": "^3.1024.0",
"@nestjs-modules/ioredis": "^2.2.1",
"@nestjs/bullmq": "^11.0.4",
"@nestjs/common": "^11.0.1",
Expand All @@ -41,12 +43,14 @@
"bullmq": "^5.70.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"cloudinary": "^2.9.0",
"ethers": "^6.16.0",
"helmet": "^8.1.0",
"ioredis": "^5.10.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"prisma": "^7.4.2",
"puppeteer": "^24.37.5",
"qrcode": "^1.5.3",
"reflect-metadata": "^0.2.2",
"resend": "^6.10.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*
Warnings:

- Added the required column `updatedAt` to the `Invoice` table without a default value. This is not possible if the table is not empty.

*/
-- AlterTable
ALTER TABLE "Invoice" ADD COLUMN "amountPaid" DECIMAL(36,18) NOT NULL DEFAULT 0,
ADD COLUMN "customerAddress" JSONB,
ADD COLUMN "customerPhone" TEXT,
ADD COLUMN "invoiceNumber" TEXT,
ADD COLUMN "notes" TEXT,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT NOW();

-- Backfill existing rows, then drop the default so Prisma manages it going forward
ALTER TABLE "Invoice" ALTER COLUMN "updatedAt" DROP DEFAULT;

-- CreateIndex
CREATE INDEX "Invoice_merchantId_idx" ON "Invoice"("merchantId");

-- CreateIndex
CREATE INDEX "Invoice_status_idx" ON "Invoice"("status");

-- CreateIndex
CREATE INDEX "Invoice_createdAt_idx" ON "Invoice"("createdAt");
46 changes: 28 additions & 18 deletions apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -215,24 +215,34 @@ model PaymentLink {
// ── Invoices ──────────────────────────────────────────────────

model Invoice {
id String @id @default(cuid())
merchantId String
customerEmail String
customerName String?
lineItems Json // [{description, qty, unitPrice}]
subtotal Decimal @db.Decimal(36, 18)
taxRate Decimal? @db.Decimal(5, 4)
taxAmount Decimal? @db.Decimal(36, 18)
discount Decimal? @db.Decimal(36, 18)
total Decimal @db.Decimal(36, 18)
currency String @default("USD")
status InvoiceStatus @default(DRAFT)
dueDate DateTime?
pdfUrl String?
paidAt DateTime?
paymentId String?
createdAt DateTime @default(now())
merchant Merchant @relation(fields: [merchantId], references: [id])
id String @id @default(cuid())
merchantId String
invoiceNumber String? // e.g. "INV-2024-001"
customerEmail String
customerName String?
customerPhone String?
customerAddress Json? // {line1, line2?, city, state?, country, zip?}
lineItems Json // [{description, qty, unitPrice, amount}]
subtotal Decimal @db.Decimal(36, 18)
taxRate Decimal? @db.Decimal(5, 4)
taxAmount Decimal? @db.Decimal(36, 18)
discount Decimal? @db.Decimal(36, 18)
total Decimal @db.Decimal(36, 18)
amountPaid Decimal @default(0) @db.Decimal(36, 18)
currency String @default("USD")
status InvoiceStatus @default(DRAFT)
dueDate DateTime?
notes String?
pdfUrl String?
paidAt DateTime?
paymentId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
merchant Merchant @relation(fields: [merchantId], references: [id])

@@index([merchantId])
@@index([status])
@@index([createdAt])
}

enum InvoiceStatus {
Expand Down
52 changes: 52 additions & 0 deletions apps/api/src/modules/invoices/dto/create-invoice.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { z } from 'zod';

export const CustomerAddressSchema = z.object({
line1: z.string().min(1).max(255),
line2: z.string().max(255).optional(),
city: z.string().min(1).max(100),
state: z.string().max(100).optional(),
country: z.string().min(1).max(100),
zip: z.string().max(20).optional(),
});

export const LineItemSchema = z.object({
description: z.string().min(1).max(500),
qty: z.number().positive().finite(),
unitPrice: z.number().nonnegative().finite(),
});

const SUPPORTED_CURRENCIES = [
'USD', 'EUR', 'GBP', 'NGN', 'KES', 'GHS', 'ZAR',
'CAD', 'AUD', 'JPY', 'CNY', 'INR', 'BRL', 'MXN',
'AED', 'SAR', 'SGD', 'HKD', 'CHF', 'SEK', 'NOK',
] as const;

export const CreateInvoiceSchema = z.object({
customerEmail: z.string().email(),
customerName: z.string().min(1).max(255).optional(),
customerPhone: z.string().max(30).optional(),
customerAddress: CustomerAddressSchema.optional(),

lineItems: z.array(LineItemSchema).min(1, 'At least one line item is required'),

taxRate: z
.number()
.min(0)
.max(1, 'Tax rate must be between 0 and 1 (e.g. 0.1 for 10%)')
.optional(),
discount: z.number().nonnegative().optional(),

currency: z
.string()
.toUpperCase()
.refine((v) => SUPPORTED_CURRENCIES.includes(v as (typeof SUPPORTED_CURRENCIES)[number]), {
message: `Currency must be one of: ${SUPPORTED_CURRENCIES.join(', ')}`,
})
.default('USD'),

dueDate: z.coerce.date().optional(),
notes: z.string().max(2000).optional(),
invoiceNumber: z.string().max(50).optional(),
});

export type CreateInvoiceDto = z.infer<typeof CreateInvoiceSchema>;
17 changes: 17 additions & 0 deletions apps/api/src/modules/invoices/dto/invoice-filters.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { z } from 'zod';
import { InvoiceStatus } from '@prisma/client';

export const InvoiceFiltersSchema = z.object({
status: z.nativeEnum(InvoiceStatus).optional(),
currency: z.string().toUpperCase().optional(),
customerEmail: z.string().email().optional(),
from: z.coerce.date().optional(),
to: z.coerce.date().optional(),
search: z.string().max(100).optional(),
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
sortBy: z.enum(['createdAt', 'dueDate', 'total', 'status']).default('createdAt'),
sortOrder: z.enum(['asc', 'desc']).default('desc'),
});

export type InvoiceFiltersDto = z.infer<typeof InvoiceFiltersSchema>;
29 changes: 29 additions & 0 deletions apps/api/src/modules/invoices/dto/update-invoice.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { z } from 'zod';
import { CustomerAddressSchema, LineItemSchema } from './create-invoice.dto';

export const UpdateInvoiceSchema = z.object({
customerEmail: z.string().email().optional(),
customerName: z.string().min(1).max(255).optional(),
customerPhone: z.string().max(30).optional(),
customerAddress: CustomerAddressSchema.optional(),

lineItems: z.array(LineItemSchema).min(1).optional(),

taxRate: z.number().min(0).max(1).optional(),
discount: z.number().nonnegative().optional(),

dueDate: z.coerce.date().optional(),
notes: z.string().max(2000).optional(),
invoiceNumber: z.string().max(50).optional(),
});

export type UpdateInvoiceDto = z.infer<typeof UpdateInvoiceSchema>;

// ── Partial payment ────────────────────────────────────────────────────────────

export const RecordPaymentSchema = z.object({
amount: z.number().positive('Payment amount must be positive'),
paymentId: z.string().optional(),
});

export type RecordPaymentDto = z.infer<typeof RecordPaymentSchema>;
146 changes: 146 additions & 0 deletions apps/api/src/modules/invoices/invoices.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
Patch,
Post,
Query,
Res,
StreamableFile,
UseGuards,
} from '@nestjs/common';
import type { Response } from 'express';
import { InvoicesService } from './invoices.service';
import { CreateInvoiceSchema, CreateInvoiceDto } from './dto/create-invoice.dto';
import {
UpdateInvoiceSchema,
UpdateInvoiceDto,
RecordPaymentSchema,
RecordPaymentDto,
} from './dto/update-invoice.dto';
import { InvoiceFiltersSchema, InvoiceFiltersDto } from './dto/invoice-filters.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';

@Controller('v1/invoices')
@UseGuards(CombinedAuthGuard)
export class InvoicesController {
constructor(private readonly invoicesService: InvoicesService) {}

// ── POST /v1/invoices ──────────────────────────────────────────────────────
@Post()
async create(
@CurrentMerchant('id') merchantId: string,
@Body(new ZodValidationPipe(CreateInvoiceSchema)) dto: CreateInvoiceDto,
) {
return this.invoicesService.create(merchantId, dto);
}

// ── GET /v1/invoices ───────────────────────────────────────────────────────
@Get()
@UseGuards(JwtAuthGuard)
async list(
@CurrentMerchant('id') merchantId: string,
@Query(new ZodValidationPipe(InvoiceFiltersSchema)) filters: InvoiceFiltersDto,
) {
return this.invoicesService.list(merchantId, filters);
}

// ── GET /v1/invoices/:id ───────────────────────────────────────────────────
@Get(':id')
async getOne(
@CurrentMerchant('id') merchantId: string,
@Param('id') id: string,
) {
return this.invoicesService.getById(id, merchantId);
}

// ── PATCH /v1/invoices/:id ─────────────────────────────────────────────────
@Patch(':id')
@UseGuards(JwtAuthGuard)
async update(
@CurrentMerchant('id') merchantId: string,
@Param('id') id: string,
@Body(new ZodValidationPipe(UpdateInvoiceSchema)) dto: UpdateInvoiceDto,
) {
return this.invoicesService.update(id, merchantId, dto);
}

// ── DELETE /v1/invoices/:id ────────────────────────────────────────────────
@Delete(':id')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.NO_CONTENT)
async delete(
@CurrentMerchant('id') merchantId: string,
@Param('id') id: string,
): Promise<void> {
return this.invoicesService.delete(id, merchantId);
}

// ── POST /v1/invoices/:id/send ─────────────────────────────────────────────
@Post(':id/send')
@UseGuards(JwtAuthGuard)
async markSent(
@CurrentMerchant('id') merchantId: string,
@Param('id') id: string,
) {
return this.invoicesService.markSent(id, merchantId);
}

// ── POST /v1/invoices/:id/cancel ───────────────────────────────────────────
@Post(':id/cancel')
@UseGuards(JwtAuthGuard)
async cancel(
@CurrentMerchant('id') merchantId: string,
@Param('id') id: string,
) {
return this.invoicesService.cancel(id, merchantId);
}

// ── POST /v1/invoices/:id/payments ────────────────────────────────────────
@Post(':id/payments')
@UseGuards(JwtAuthGuard)
async recordPayment(
@CurrentMerchant('id') merchantId: string,
@Param('id') id: string,
@Body(new ZodValidationPipe(RecordPaymentSchema)) dto: RecordPaymentDto,
) {
return this.invoicesService.recordPayment(id, merchantId, dto);
}

// ── 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,
@Param('id') id: string,
) {
const url = await this.invoicesService.generatePdf(id, merchantId);
return { url };
}

// ── GET /v1/invoices/:id/pdf/download ──────────────────────────────────────
// Streams the PDF directly in the HTTP response
@Get(':id/pdf/download')
async downloadPdf(
@CurrentMerchant('id') merchantId: string,
@Param('id') id: string,
@Res({ passthrough: true }) res: Response,
): Promise<StreamableFile> {
const buffer = await this.invoicesService.getPdfBuffer(id, merchantId);

res.set({
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="invoice-${id}.pdf"`,
'Content-Length': buffer.length,
});

return new StreamableFile(buffer);
}
}
13 changes: 12 additions & 1 deletion apps/api/src/modules/invoices/invoices.module.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
import { Module } from '@nestjs/common';
import { InvoicesController } from './invoices.controller';
import { InvoicesService } from './invoices.service';
import { PdfService } from './pdf.service';
import { PrismaModule } from '../prisma/prisma.module';
import { StorageModule } from '../storage/storage.module';
import { AuthModule } from '../auth/auth.module';

@Module({})
@Module({
imports: [PrismaModule, StorageModule, AuthModule],
providers: [InvoicesService, PdfService],
controllers: [InvoicesController],
exports: [InvoicesService],
})
export class InvoicesModule {}
Loading
Loading