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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions src/openapi/paths/transactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
44 changes: 43 additions & 1 deletion src/routes/transactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
Expand Down
45 changes: 45 additions & 0 deletions src/routes/v1/transactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
82 changes: 66 additions & 16 deletions src/services/pdfReceipt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Buffer> {
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 });
Expand All @@ -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")
Expand All @@ -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<string, any> | 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;
Expand All @@ -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);
Expand All @@ -88,7 +139,6 @@ export async function generateTransactionPdfBuffer(
.text(transaction.notes || "", { width: 500 });
}

// Footer
doc.moveDown(2);
doc
.fontSize(9)
Expand Down
Loading
Loading