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
55 changes: 55 additions & 0 deletions src/__tests__/invoiceVerification.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
const { verifyInvoice } = require('../services/invoiceVerification');

describe('Invoice Verification Service', () => {
it('should verify a valid invoice', async () => {
const payload = { amount: 5000, customer: 'Acme Corp' };
const result = await verifyInvoice(payload);
expect(result).toEqual({ status: 'VERIFIED' });
});

it('should reject if payload is not an object', async () => {
const result = await verifyInvoice(null);
expect(result).toEqual({ status: 'REJECTED', reason: 'Invalid payload structure' });

const result2 = await verifyInvoice('string');
expect(result2).toEqual({ status: 'REJECTED', reason: 'Invalid payload structure' });
});

it('should reject invalid amount types', async () => {
const result = await verifyInvoice({ amount: '5000', customer: 'Acme Corp' });
expect(result).toEqual({ status: 'REJECTED', reason: 'Invalid amount: must be a positive number' });
});

it('should reject zero or negative amounts', async () => {
const resultZero = await verifyInvoice({ amount: 0, customer: 'Acme Corp' });
expect(resultZero).toEqual({ status: 'REJECTED', reason: 'Invalid amount: must be a positive number' });

const resultNegative = await verifyInvoice({ amount: -100, customer: 'Acme Corp' });
expect(resultNegative).toEqual({ status: 'REJECTED', reason: 'Invalid amount: must be a positive number' });
});

it('should reject invalid customer types', async () => {
const result = await verifyInvoice({ amount: 5000, customer: 12345 });
expect(result).toEqual({ status: 'REJECTED', reason: 'Invalid customer: must be a non-empty string' });
});

it('should reject empty customer string', async () => {
const result = await verifyInvoice({ amount: 5000, customer: ' ' });
expect(result).toEqual({ status: 'REJECTED', reason: 'Invalid customer: must be a non-empty string' });
});

it('should reject an amount exceeding the maximum allowed threshold', async () => {
const result = await verifyInvoice({ amount: 15000000, customer: 'Acme Corp' });
expect(result).toEqual({ status: 'REJECTED', reason: 'Amount exceeds maximum allowed threshold' });
});

it('should require manual review for high value invoices', async () => {
const result = await verifyInvoice({ amount: 1500000, customer: 'Acme Corp' });
expect(result).toEqual({ status: 'MANUAL_REVIEW', reason: 'High value invoice requires manual approval' });
});

it('should reject customers with suspicious characters (XSS/Injection)', async () => {
const result = await verifyInvoice({ amount: 5000, customer: 'Acme <script>' });
expect(result).toEqual({ status: 'REJECTED', reason: 'Suspicious characters detected in customer data' });
});
});
1 change: 1 addition & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const errorHandler = require('./middleware/errorHandler');
const { callSorobanContract } = require('./services/soroban');
const AppError = require('./errors/AppError');

const app = express();
const PORT = process.env.PORT || 3001;

// In-memory storage for invoices (Issue #25).
Expand Down
11 changes: 11 additions & 0 deletions src/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,20 @@ describe('LiquiFact API', () => {
expect(response.body.data).toHaveProperty('id');
expect(response.body.data.amount).toBe(1000);
expect(response.body.data.customer).toBe('Test Corp');
expect(response.body.data.status).toBe('VERIFIED');
expect(response.body.data.deletedAt).toBeNull();
});

it('POST /api/invoices - creates a rejected invoice if verification fails', async () => {
const response = await request(app)
.post('/api/invoices')
.send({ amount: -500, customer: 'Shady Corp' });

expect(response.status).toBe(201);
expect(response.body.data.status).toBe('REJECTED');
expect(response.body.data.verificationReason).toBe('Invalid amount: must be a positive number');
});

it('POST /api/invoices - fails if missing fields', async () => {
const response = await request(app)
.post('/api/invoices')
Expand Down
66 changes: 66 additions & 0 deletions src/services/invoiceVerification.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* Invoice Verification Service
* Handles fraud checks and business validation before invoice approval.
*/

/**
* Result of an invoice verification.
* @typedef {Object} VerificationResult
* @property {string} status - The resulting status: 'VERIFIED', 'REJECTED', or 'MANUAL_REVIEW'.
* @property {string} [reason] - The reason for rejection or manual review, if applicable.
*/

/**
* Validates an invoice for fraud and business rules.
*
* Security Assumptions:
* - The input payload must be an object.
* - `amount` must be a strictly positive number.
* - `customer` must be a non-empty string avoiding potentially malicious injection patterns.
*
* @param {Object} invoicePayload - The invoice data to verify.
* @param {number} invoicePayload.amount - The invoice amount in the system's base currency.
* @param {string} invoicePayload.customer - The customer identifier or name.
* @returns {Promise<VerificationResult>} Returns the verification status and optional reason.
*/
async function verifyInvoice(invoicePayload) {
if (!invoicePayload || typeof invoicePayload !== 'object') {
return { status: 'REJECTED', reason: 'Invalid payload structure' };
}

const { amount, customer } = invoicePayload;

// Security: strict type and value checks
if (typeof amount !== 'number' || Number.isNaN(amount) || amount <= 0) {
return { status: 'REJECTED', reason: 'Invalid amount: must be a positive number' };
}

if (typeof customer !== 'string' || customer.trim() === '') {
return { status: 'REJECTED', reason: 'Invalid customer: must be a non-empty string' };
}

// Business Validation: Fraud Check Example
// Reject absurdly high amounts automatically
if (amount > 10000000) {
return { status: 'REJECTED', reason: 'Amount exceeds maximum allowed threshold' };
}

// Business Validation: Manual Review Example
// Require manual review for high value invoices
if (amount >= 1000000) {
return { status: 'MANUAL_REVIEW', reason: 'High value invoice requires manual approval' };
}

// Security: Check for obvious injection patterns in customer string
const suspiciousPatterns = /[<>{}$]/;
if (suspiciousPatterns.test(customer)) {
return { status: 'REJECTED', reason: 'Suspicious characters detected in customer data' };
}

// Verification passed
return { status: 'VERIFIED' };
}

module.exports = {
verifyInvoice
};
Loading