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
1,023 changes: 1,008 additions & 15 deletions backend/package-lock.json

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,13 @@
"axios": "^1.6.5",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"exceljs": "^4.4.0",
"express": "^4.18.2",
"fast-csv": "^5.0.5",
"ioredis": "^5.9.3",
"morgan": "^1.10.0",
"otplib": "^13.3.0",
"pdfkit": "^0.17.2",
"pg": "^8.11.3",
"qrcode": "^1.5.4",
"socket.io": "^4.8.3",
Expand All @@ -36,6 +39,7 @@
"@types/jest": "^29.5.11",
"@types/morgan": "^1.9.10",
"@types/node": "^20.10.6",
"@types/pdfkit": "^0.17.5",
"@types/pg": "^8.10.9",
"@types/supertest": "^6.0.2",
"@typescript-eslint/eslint-plugin": "^6.16.0",
Expand All @@ -49,4 +53,4 @@
"tsx": "^4.7.0",
"typescript": "^5.3.3"
}
}
}
96 changes: 96 additions & 0 deletions backend/src/controllers/exportController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { Request, Response } from 'express';
import { ExportService } from '../services/exportService';
import { payrollQueryService } from '../services/payroll-query.service';
import logger from '../utils/logger';

export class ExportController {
/**
* Generates and streams a PDF receipt for a specific transaction.
*/
static async getReceiptPdf(req: Request, res: Response): Promise<void> {
try {
const { txHash } = req.params;

const transaction = await payrollQueryService.getTransactionDetails(txHash);
if (!transaction) {
res.status(404).json({ success: false, error: 'Transaction not found' });
return;
}

res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="receipt-${txHash.substring(0, 8)}.pdf"`);

await ExportService.generateReceiptPdf(transaction, res);
} catch (error) {
logger.error('Failed to generate PDF receipt', { error });

// If headers are already sent, we can't send a JSON response.
if (!res.headersSent) {
res.status(500).json({ success: false, error: 'Internal server error during PDF generation' });
} else {
res.end();
}
}
}

/**
* Generates and streams an Excel report for a payroll batch.
*/
static async getPayrollExcel(req: Request, res: Response): Promise<void> {
try {
const { organizationPublicKey, batchId } = req.params;

// We would likely fetch all or a large chunk of transactions for the batch.
// Assuming getPayrollBatch returns a paginated result, we might need a way to fetch all,
// but for this implementation, we'll fetch the first massive page or assume limit handles it.
const batchData = await payrollQueryService.getPayrollBatch(organizationPublicKey, batchId, 1, 100000);

if (!batchData || batchData.data.length === 0) {
res.status(404).json({ success: false, error: 'Batch not found or empty' });
return;
}

res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', `attachment; filename="payroll-batch-${batchId}.xlsx"`);

await ExportService.generatePayrollExcel(batchId, batchData.data, res);
} catch (error) {
logger.error('Failed to generate Excel report', { error });

if (!res.headersSent) {
res.status(500).json({ success: false, error: 'Internal server error during Excel generation' });
} else {
res.end();
}
}
}

/**
* Generates and streams a CSV report for a payroll batch.
*/
static async getPayrollCsv(req: Request, res: Response): Promise<void> {
try {
const { organizationPublicKey, batchId } = req.params;

const batchData = await payrollQueryService.getPayrollBatch(organizationPublicKey, batchId, 1, 100000);

if (!batchData || batchData.data.length === 0) {
res.status(404).json({ success: false, error: 'Batch not found or empty' });
return;
}

res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', `attachment; filename="payroll-batch-${batchId}.csv"`);

await ExportService.generatePayrollCsv(batchData.data, res);
} catch (error) {
logger.error('Failed to generate CSV report', { error });

if (!res.headersSent) {
res.status(500).json({ success: false, error: 'Internal server error during CSV generation' });
} else {
res.end();
}
}
}
}
12 changes: 5 additions & 7 deletions backend/src/controllers/healthController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,11 @@ import { StellarService } from '../services/stellarService';

const pool = new pg.Pool({ connectionString: config.DATABASE_URL });

let redis: Redis | null = null;
if (config.REDIS_URL) {
redis = new Redis(config.REDIS_URL, {
maxRetriesPerRequest: 1,
retryStrategy: () => null, // Fail fast for health check
});
}
// We use 'any' to temporarily bypass the TS namespace error
export const redis: any | null = config.REDIS_URL ? new Redis(config.REDIS_URL, {
maxRetriesPerRequest: 1,
retryStrategy: () => null, // Fail fast for health check
}) : null;

export class HealthController {
static async getHealthStatus(req: Request, res: Response) {
Expand Down
15 changes: 15 additions & 0 deletions backend/src/routes/exportRoutes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Router } from 'express';
import { ExportController } from '../controllers/exportController';

const router = Router();

// GET /api/v1/exports/receipt/:txHash/pdf
router.get('/receipt/:txHash/pdf', ExportController.getReceiptPdf);

// GET /api/v1/exports/payroll/:organizationPublicKey/:batchId/excel
router.get('/payroll/:organizationPublicKey/:batchId/excel', ExportController.getPayrollExcel);

// GET /api/v1/exports/payroll/:organizationPublicKey/:batchId/csv
router.get('/payroll/:organizationPublicKey/:batchId/csv', ExportController.getPayrollCsv);

export default router;
2 changes: 2 additions & 0 deletions backend/src/routes/v1/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import auditRoutes from '../auditRoutes';
import balanceRoutes from '../balanceRoutes';
import trustlineRoutes from '../trustlineRoutes';
import payrollRoutes from '../payroll.routes';
import exportRoutes from '../exportRoutes';
import taxRoutes from '../taxRoutes';

const router = Router();
Expand All @@ -28,6 +29,7 @@ router.use('/payroll', payrollRoutes);
router.use('/audit', auditRoutes);
router.use('/balance', balanceRoutes);
router.use('/trustline', trustlineRoutes);
router.use('/exports', exportRoutes);
router.use('/taxes', taxRoutes);

export default router;
87 changes: 87 additions & 0 deletions backend/src/services/__tests__/exportService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { ExportService } from '../exportService';
import { PayrollTransaction } from '../payroll-indexing.service';
import { PassThrough } from 'stream';

// Mock stream to capture generated data
class MockStream extends PassThrough {
chunks: Buffer[] = [];
constructor() {
super();
this.on('data', chunk => this.chunks.push(Buffer.from(chunk)));
}

getOutput(): string {
return Buffer.concat(this.chunks).toString('utf8');
}
}

describe('ExportService', () => {
const mockTransaction: PayrollTransaction = {
id: '1',
sourceAccount: 'GORG123',
employeeId: 'EMP456',
amount: '500.00',
assetCode: 'USDC',
assetIssuer: 'GUSDC123',
successful: true,
txHash: 'hash123',
timestamp: 1708689600, // Unix timestamp for 2026-02-23T12:00:00Z
memo: 'February Salary',
operationType: 'payment',
ledgerHeight: 12345,
fee: '100',
signatures: [],
isPayrollRelated: true
};

const mockBatch: PayrollTransaction[] = [
mockTransaction,
{
...mockTransaction,
id: '2',
employeeId: 'EMP457',
amount: '750.00',
txHash: 'hash456',
timestamp: 1708689900,
memo: 'February Salary Plus Bonus',
}
];

it('should generate a PDF receipt for a transaction', async () => {
const stream = new MockStream();

await ExportService.generateReceiptPdf(mockTransaction, stream);

const output = stream.getOutput();
// A PDF always starts with %PDF-
expect(output.startsWith('%PDF-')).toBe(true);
// Since PDF content is compressed/encoded, we might not see the raw text perfectly, but we can verify it generated without throwing
});

it('should generate an Excel report for a payroll batch', async () => {
const stream = new MockStream();

await ExportService.generatePayrollExcel('batch_1', mockBatch, stream);

const output = stream.getOutput();
// Excel files are zip archives, which start with PK
expect(output.startsWith('PK')).toBe(true);
});

it('should generate a CSV report for a payroll batch', async () => {
const stream = new MockStream();

await ExportService.generatePayrollCsv(mockBatch, stream);

const output = stream.getOutput();

// Check if CSV headers exist
expect(output).toContain('txHash,organizationPublicKey,employeeId,amount,assetCode,assetIssuer,status,memo,timestamp');

// Check if the first mocked transaction data is in the output
expect(output).toContain('hash123,GORG123,EMP456,500.00,USDC,GUSDC123,Success,February Salary,2024-02-23T12:00:00.000Z');

// Check if the second mocked transaction data is in the output
expect(output).toContain('hash456,GORG123,EMP457,750.00,USDC,GUSDC123,Success,February Salary Plus Bonus,2024-02-23T12:05:00.000Z');
});
});
Loading