diff --git a/README.md b/README.md index 490d4081..7442678f 100644 --- a/README.md +++ b/README.md @@ -217,6 +217,7 @@ POST /api/transactions/deposit # Mobile money → Stellar POST /api/transactions/withdraw # Stellar → Mobile money GET /api/transactions # List (paginated, filterable) GET /api/transactions/:id # Transaction details +GET /api/transactions/:id/invoice # Download completed transaction invoice POST /api/transactions/:id/cancel # Cancel pending transaction POST /api/transactions/:id/dispute # Open dispute POST /api/transactions/bulk # Bulk operations diff --git a/src/openapi/paths/transactions.ts b/src/openapi/paths/transactions.ts index 216458c4..fa61e2ad 100644 --- a/src/openapi/paths/transactions.ts +++ b/src/openapi/paths/transactions.ts @@ -113,6 +113,35 @@ registry.registerPath({ }, }); +registry.registerPath({ + method: 'get', + path: '/api/v1/transactions/{id}/invoice', + tags: [TAG], + summary: 'Download a completed transaction invoice as a PDF', + security: SECURITY, + request: { + params: z.object({ + id: z.string().uuid().openapi({ example: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' }), + }), + query: z.object({ + download: z.string().optional().openapi({ description: 'Set to 0 to display inline instead of downloading' }), + }).optional(), + }, + responses: { + 200: { + description: 'PDF invoice generated', + content: { + 'application/pdf': { + schema: z.string().openapi({ type: 'string', format: 'binary' }), + }, + }, + }, + 400: { description: 'Invoice download only available for completed transactions', content: { 'application/json': { schema: ErrorResponseSchema } } }, + 401: { description: 'Unauthorized', content: { 'application/json': { schema: ErrorResponseSchema } } }, + 404: { description: 'Transaction not found', content: { 'application/json': { schema: ErrorResponseSchema } } }, + }, +}); + // ─── Cancel transaction ─────────────────────────────────────────────────────── registry.registerPath({ diff --git a/src/routes/transactions.ts b/src/routes/transactions.ts index abd8cd4f..08c02b90 100644 --- a/src/routes/transactions.ts +++ b/src/routes/transactions.ts @@ -23,7 +23,7 @@ import { cancelTransactionRateLimiter } from "../middleware/rateLimit"; import { checkAccountStatusStrict } from "../middleware/checkAccountStatus"; import { geolocateMiddleware } from "../middleware/geolocate"; import { geoFencingMiddleware } from "../middleware/geoFencing"; -import { TransactionModel } from "../models/transaction"; +import { TransactionModel, TransactionStatus } from "../models/transaction"; import { generateTransactionPdfBuffer } from "../services/pdfReceipt"; import { generateShareToken, verifyShareToken } from "../utils/share"; import { createExportRoutes } from "./export"; @@ -69,6 +69,48 @@ transactionRoutes.get( }, ); +transactionRoutes.get( + "/:id/invoice", + TimeoutPresets.quick, + haltOnTimedout, + authenticateToken, + async (req: Request, res: Response) => { + try { + const { id } = req.params; + const { download } = req.query; + + const transaction = await transactionModel.findById(id); + if (!transaction) + return res.status(404).json({ error: "Transaction not found" }); + + if (transaction.status !== TransactionStatus.Completed) + return res.status(400).json({ + error: "Invoice download is available only for completed transactions", + }); + + const pdf = await generateTransactionPdfBuffer(transaction, { + title: "Invoice", + }); + + res.setHeader("Content-Type", "application/pdf"); + const filename = `invoice-${transaction.referenceNumber}.pdf`; + if (download && String(download) === "0") { + res.setHeader("Content-Disposition", `inline; filename="${filename}"`); + } else { + res.setHeader( + "Content-Disposition", + `attachment; filename="${filename}"`, + ); + } + + res.status(200).send(pdf); + } catch (err) { + console.error("Failed to generate invoice PDF:", err); + res.status(500).json({ error: "Failed to generate invoice PDF" }); + } + }, +); + // Create a shareable URL (public or private) for a transaction receipt transactionRoutes.post( "/:id/receipt/share", diff --git a/src/routes/v1/transactions.ts b/src/routes/v1/transactions.ts index d95636d4..aded9fa8 100644 --- a/src/routes/v1/transactions.ts +++ b/src/routes/v1/transactions.ts @@ -26,6 +26,8 @@ import { createExportRoutes } from "../export"; export const transactionRoutesV1 = Router(); transactionRoutesV1.use(createExportRoutes()); +const transactionModel = new TransactionModel(); + // Deposit transaction route transactionRoutesV1.post( "/deposit", @@ -98,6 +100,49 @@ transactionRoutesV1.get( getTransactionHandler, ); +transactionRoutesV1.get( + "/:id/invoice", + TimeoutPresets.quick, + haltOnTimedout, + requireAuth, + setApiVersion("v1"), + async (req: VersionedRequest, res) => { + try { + const { id } = req.params; + const { download } = req.query; + + const transaction = await transactionModel.findById(id); + if (!transaction) + return res.status(404).json({ error: "Transaction not found" }); + + if (transaction.status !== TransactionStatus.Completed) + return res.status(400).json({ + error: "Invoice download is available only for completed transactions", + }); + + const pdf = await generateTransactionPdfBuffer(transaction, { + title: "Invoice", + }); + + res.setHeader("Content-Type", "application/pdf"); + const filename = `invoice-${transaction.referenceNumber}.pdf`; + if (download && String(download) === "0") { + res.setHeader("Content-Disposition", `inline; filename="${filename}"`); + } else { + res.setHeader( + "Content-Disposition", + `attachment; filename="${filename}"`, + ); + } + + res.status(200).send(pdf); + } catch (err) { + console.error("Failed to generate invoice PDF:", err); + res.status(500).json({ error: "Failed to generate invoice PDF" }); + } + }, +); + // Update transaction notes transactionRoutesV1.patch( "/:id/notes", diff --git a/src/services/pdfReceipt.ts b/src/services/pdfReceipt.ts index e6c5e476..2dcee404 100644 --- a/src/services/pdfReceipt.ts +++ b/src/services/pdfReceipt.ts @@ -2,10 +2,25 @@ import PDFDocument from "pdfkit"; import { Transaction } from "../models/transaction"; import { maskPhoneNumber, maskStellarAddress } from "../utils/masking"; +export interface TransactionPdfOptions { + title?: string; + merchantName?: string; + merchantUrl?: string; + merchantAddress?: string; + merchantDescription?: string; +} export async function generateTransactionPdfBuffer( transaction: Transaction, + options: TransactionPdfOptions = {}, ): Promise { + const merchantName = options.merchantName || process.env.ORG_NAME || "Mobile Money"; + const merchantUrl = options.merchantUrl || process.env.ORG_URL || ""; + const merchantAddress = options.merchantAddress || process.env.ORG_ADDRESS || ""; + const merchantDescription = options.merchantDescription || process.env.ORG_DESCRIPTION || "Mobile money to Stellar"; + const title = options.title || "Transaction Receipt"; + const idLabel = title.toLowerCase().includes("invoice") ? "Invoice ID" : "Receipt ID"; + return new Promise((resolve, reject) => { try { const doc = new PDFDocument({ size: "A4", margin: 50 }); @@ -15,13 +30,46 @@ export async function generateTransactionPdfBuffer( doc.on("end", () => resolve(Buffer.concat(chunks))); doc.on("error", (err) => reject(err)); - // Header - doc.fontSize(20).text("Mobile Money", { align: "left" }); + // Header with merchant branding + doc + .fillColor("#333") + .fontSize(18) + .text(merchantName, { align: "left" }); + + if (merchantUrl) { + doc + .fontSize(9) + .fillColor("#3498db") + .text(merchantUrl, { align: "left", link: merchantUrl }); + } + + if (merchantAddress) { + doc + .fontSize(9) + .fillColor("#666") + .text(merchantAddress, { align: "left" }); + } + + if (merchantDescription) { + doc + .fontSize(10) + .fillColor("#666") + .text(merchantDescription, { align: "left" }) + .moveDown(0.5); + } else { + doc.moveDown(0.5); + } + + doc + .fontSize(16) + .fillColor("#000") + .text(title, { align: "left" }); + doc.moveDown(0.25); doc .fontSize(10) .fillColor("#666") - .text(`Receipt ID: ${transaction.referenceNumber}`); + .text(`${idLabel}: ${transaction.referenceNumber}`); doc .fontSize(10) .fillColor("#666") @@ -44,23 +92,30 @@ export async function generateTransactionPdfBuffer( if (transaction.stellarAddress) doc.text(`Stellar: ${maskStellarAddress(transaction.stellarAddress)}`, leftX); - // Add StellarExpert Transaction Hash Link if available const metadata = (transaction as any).metadata as Record | undefined; - const txHash = metadata?.transactionHash || metadata?.stellarTransactionId || (transaction as any).transactionHash || (transaction as any).stellarTransactionId; - + const txHash = + metadata?.transactionHash || + metadata?.stellarTransactionId || + (transaction as any).transactionHash || + (transaction as any).stellarTransactionId; + if (txHash) { - const network = process.env.STELLAR_NETWORK === 'mainnet' || process.env.STELLAR_NETWORK === 'public' ? 'public' : 'testnet'; + const network = + process.env.STELLAR_NETWORK === "mainnet" || + process.env.STELLAR_NETWORK === "public" + ? "public" + : "testnet"; const stellarExpertUrl = `https://stellar.expert/explorer/${network}/tx/${txHash}`; - + doc.moveDown(0.2); doc .fontSize(10) .fillColor("#3498db") .text(`View Transaction on StellarExpert`, leftX, doc.y, { link: stellarExpertUrl, - underline: true + underline: true, }) - .fillColor("#000"); // Reset text color + .fillColor("#000"); } const amountStr = transaction.amount; @@ -69,15 +124,11 @@ export async function generateTransactionPdfBuffer( doc.moveDown(1.5); - // Status and timestamps doc .fontSize(10) .fillColor("#333") .text(`Status: ${transaction.status}`, leftX); - doc.text( - `Created: ${new Date(transaction.createdAt).toLocaleString()}`, - leftX, - ); + doc.text(`Created: ${new Date(transaction.createdAt).toLocaleString()}`, leftX); if (transaction.notes) { doc.moveDown(0.5); @@ -88,7 +139,6 @@ export async function generateTransactionPdfBuffer( .text(transaction.notes || "", { width: 500 }); } - // Footer doc.moveDown(2); doc .fontSize(9) diff --git a/tests/routes/transactionInvoice.test.ts b/tests/routes/transactionInvoice.test.ts new file mode 100644 index 00000000..475f4d2d --- /dev/null +++ b/tests/routes/transactionInvoice.test.ts @@ -0,0 +1,109 @@ +import express from "express"; +import request from "supertest"; +import { TransactionModel, TransactionStatus } from "../../src/models/transaction"; +import { transactionRoutes } from "../../src/routes/transactions"; +import { generateToken } from "../../src/auth/jwt"; + +const fakeTransaction = { + id: "tx-123", + referenceNumber: "REF-123", + type: "deposit", + amount: "10000", + phoneNumber: "+237600000000", + provider: "MTN", + status: TransactionStatus.Completed, + userId: "user-123", + createdAt: new Date("2026-05-30T10:00:00Z"), + updatedAt: new Date("2026-05-30T10:05:00Z"), +}; + +describe("GET /api/transactions/:id/invoice", () => { + let app: express.Express; + let token: string; + let findByIdSpy: jest.SpyInstance; + + beforeAll(() => { + process.env.JWT_SECRET = "test-jwt-secret"; + process.env.ORG_NAME = "Acme Merchant"; + process.env.ORG_URL = "https://merchant.example"; + process.env.ORG_ADDRESS = "1 Merchant Way"; + process.env.ORG_DESCRIPTION = "Branded merchant invoice"; + token = generateToken({ userId: "user-123", email: "user@example.com", role: "merchant" }); + + app = express(); + app.use("/api/transactions", transactionRoutes); + }); + + beforeEach(() => { + findByIdSpy = jest.spyOn(TransactionModel.prototype, "findById"); + }); + + afterEach(() => { + findByIdSpy.mockRestore(); + }); + + it("returns a downloadable invoice PDF for a completed transaction", async () => { + findByIdSpy.mockResolvedValue(fakeTransaction); + + const response = await request(app) + .get(`/api/transactions/${fakeTransaction.id}/invoice`) + .set("Authorization", `Bearer ${token}`) + .buffer(true) + .parse((res, callback) => { + res.setEncoding("binary"); + let data = ""; + res.on("data", (chunk) => { data += chunk; }); + res.on("end", () => callback(null, Buffer.from(data, "binary"))); + }); + + expect(response.status).toBe(200); + expect(response.headers["content-type"]).toMatch(/application\/pdf/); + expect(response.headers["content-disposition"]).toContain("attachment;"); + expect(response.headers["content-disposition"]).toContain("invoice-REF-123.pdf"); + expect(response.body).toBeInstanceOf(Buffer); + expect(response.body.slice(0, 4).toString()).toBe("%PDF"); + }); + + it("returns inline PDF when download=0", async () => { + findByIdSpy.mockResolvedValue(fakeTransaction); + + const response = await request(app) + .get(`/api/transactions/${fakeTransaction.id}/invoice`) + .query({ download: "0" }) + .set("Authorization", `Bearer ${token}`) + .buffer(true) + .parse((res, callback) => { + res.setEncoding("binary"); + let data = ""; + res.on("data", (chunk) => { data += chunk; }); + res.on("end", () => callback(null, Buffer.from(data, "binary"))); + }); + + expect(response.status).toBe(200); + expect(response.headers["content-disposition"]).toContain("inline;"); + }); + + it("returns 400 for non-completed transactions", async () => { + findByIdSpy.mockResolvedValue({ ...fakeTransaction, status: TransactionStatus.Pending }); + + const response = await request(app) + .get(`/api/transactions/${fakeTransaction.id}/invoice`) + .set("Authorization", `Bearer ${token}`); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ + error: "Invoice download is available only for completed transactions", + }); + }); + + it("returns 404 when transaction is not found", async () => { + findByIdSpy.mockResolvedValue(null); + + const response = await request(app) + .get(`/api/transactions/nonexistent-id/invoice`) + .set("Authorization", `Bearer ${token}`); + + expect(response.status).toBe(404); + expect(response.body).toEqual({ error: "Transaction not found" }); + }); +});