Skip to content
Open
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
13 changes: 13 additions & 0 deletions app/api/invoices/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { verifyAuthToken } from '@/lib/auth'
import { generateInvoiceNumber } from '@/lib/utils'
import { sendInvoiceToClient } from '@/lib/email'

export async function GET(request: NextRequest) {
const authToken = request.headers.get('authorization')?.replace('Bearer ', '')
Expand Down Expand Up @@ -89,6 +90,18 @@ export async function POST(request: NextRequest) {
},
})

// Fire-and-forget — email failure must not block the API response
sendInvoiceToClient({
clientEmail: invoice.clientEmail,
clientName: invoice.clientName,
freelancerName: user.name || user.email || 'Your freelancer',
invoiceNumber: invoice.invoiceNumber,
amount: Number(invoice.amount),
currency: invoice.currency,
dueDate: invoice.dueDate ? invoice.dueDate.toLocaleDateString() : null,
paymentLink: invoice.paymentLink,
}).catch((err) => console.error('sendInvoiceToClient failed', err))

return NextResponse.json(
{
id: invoice.id,
Expand Down
42 changes: 42 additions & 0 deletions lib/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,48 @@ export async function sendEmail(params: { to: string; subject: string; template?
}
}

const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/

export async function sendInvoiceToClient(params: {
clientEmail: string
clientName: string | null
freelancerName: string
invoiceNumber: string
amount: number
currency: string
dueDate: string | null
paymentLink: string
}) {
if (!EMAIL_REGEX.test(params.clientEmail)) {
console.warn('sendInvoiceToClient: invalid clientEmail, skipping', params.clientEmail)
return { success: false, skipped: true as const }
}

const { clientEmail, clientName, freelancerName, invoiceNumber, amount, currency, dueDate, paymentLink } = params
const dueDateRow = dueDate
? `<p style="color:#555;margin:4px 0;"><strong>Due:</strong> ${escapeHtml(dueDate)}</p>`
: ''

return sendEmail({
to: clientEmail,
subject: `Invoice from ${freelancerName} — ${invoiceNumber}`,
html: `
<div style="font-family:system-ui,sans-serif;max-width:500px;margin:0 auto;padding:24px;">
<h2 style="color:#111;">You have a new invoice</h2>
<p style="color:#333;">Hi ${escapeHtml(clientName || 'there')},</p>
<p style="color:#333;"><strong>${escapeHtml(freelancerName)}</strong> has sent you an invoice.</p>
<div style="background:#F9FAFB;border:1px solid #E5E7EB;padding:20px;border-radius:12px;margin:20px 0;">
<p style="color:#555;margin:4px 0;"><strong>Invoice:</strong> ${escapeHtml(invoiceNumber)}</p>
<p style="color:#555;margin:4px 0;"><strong>Amount:</strong> ${currency} ${amount.toFixed(2)}</p>
${dueDateRow}
</div>
<a href="${paymentLink}" style="display:inline-block;background:#10b981;color:white;padding:14px 28px;border-radius:8px;text-decoration:none;font-weight:600;font-size:16px;">Pay Now</a>
<p style="color:#999;font-size:12px;margin-top:24px;">LancePay — Get paid globally, withdraw locally</p>
</div>
`,
})
}

// Invoice created email
export async function sendInvoiceCreatedEmail(params: {
to: string
Expand Down
10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

127 changes: 127 additions & 0 deletions tests/api/invoices.post.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { POST } from '@/app/api/invoices/route'
import { prisma } from '@/lib/db'
import { verifyAuthToken } from '@/lib/auth'
import { generateInvoiceNumber } from '@/lib/utils'
import { sendInvoiceToClient } from '@/lib/email'

vi.mock('@/lib/db', () => ({
prisma: {
user: { findUnique: vi.fn() },
invoice: { create: vi.fn() },
},
}))

vi.mock('@/lib/auth', () => ({
verifyAuthToken: vi.fn(),
}))

vi.mock('@/lib/utils', () => ({
generateInvoiceNumber: vi.fn(),
}))

vi.mock('@/lib/email', () => ({
sendInvoiceToClient: vi.fn().mockResolvedValue({ success: true }),
}))

const mockUser = {
id: 'user-1',
email: 'freelancer@example.com',
name: 'Alice Freelancer',
}

const mockInvoice = {
id: 'inv-1',
invoiceNumber: 'INV-001',
clientEmail: 'client@example.com',
clientName: 'Bob Client',
amount: 500,
currency: 'USD',
paymentLink: 'https://example.com/pay/INV-001',
status: 'pending',
dueDate: null,
}

function makeRequest(body: object, token = 'Bearer valid-token') {
return new Request('http://localhost/api/invoices', {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: token },
body: JSON.stringify(body),
}) as unknown as import('next/server').NextRequest
}

describe('POST /api/invoices', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(verifyAuthToken).mockResolvedValue({ userId: 'privy-1' } as never)
vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser as never)
vi.mocked(generateInvoiceNumber).mockReturnValue('INV-001')
vi.mocked(prisma.invoice.create).mockResolvedValue(mockInvoice as never)
process.env.NEXT_PUBLIC_APP_URL = 'https://example.com'
})

it('calls sendInvoiceToClient with correct params on success', async () => {
const res = await POST(makeRequest({
clientEmail: 'client@example.com',
clientName: 'Bob Client',
description: 'Web development',
amount: 500,
currency: 'USD',
}))

expect(res.status).toBe(201)
expect(sendInvoiceToClient).toHaveBeenCalledOnce()
expect(sendInvoiceToClient).toHaveBeenCalledWith({
clientEmail: 'client@example.com',
clientName: 'Bob Client',
freelancerName: 'Alice Freelancer',
invoiceNumber: 'INV-001',
amount: 500,
currency: 'USD',
dueDate: null,
paymentLink: 'https://example.com/pay/INV-001',
})
})

it('returns 201 even when sendInvoiceToClient rejects', async () => {
vi.mocked(sendInvoiceToClient).mockRejectedValue(new Error('SMTP failure'))

const res = await POST(makeRequest({
clientEmail: 'client@example.com',
clientName: null,
description: 'Design work',
amount: 200,
currency: 'USD',
}))

expect(res.status).toBe(201)
})

it('does not call sendInvoiceToClient when auth fails', async () => {
vi.mocked(verifyAuthToken).mockResolvedValue(null as never)

const res = await POST(makeRequest({
clientEmail: 'client@example.com',
description: 'Work',
amount: 100,
}))

expect(res.status).toBe(401)
expect(sendInvoiceToClient).not.toHaveBeenCalled()
})

it('falls back to user.email for freelancerName when name is null', async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({ ...mockUser, name: null } as never)

await POST(makeRequest({
clientEmail: 'client@example.com',
clientName: 'Bob',
description: 'Consulting',
amount: 300,
}))

expect(sendInvoiceToClient).toHaveBeenCalledWith(
expect.objectContaining({ freelancerName: 'freelancer@example.com' }),
)
})
})