448 lines
17 KiB
JavaScript
448 lines
17 KiB
JavaScript
// Tests for FR-SMS4 transactional payment surface.
|
|
//
|
|
// Verifies:
|
|
// 1. POST /api/fees/pay records a manual payment, bumps fee.paid_amount,
|
|
// transitions status to 'partial' (or 'paid' on full settlement),
|
|
// and is atomic across fee + linked invoice.
|
|
// 2-4. Validation + RBAC of /api/fees/pay.
|
|
// 5. Paynow webhook idempotency: two identical calls produce a single
|
|
// student_fees.paid_amount increment.
|
|
// 6. Paynow 'Awaiting' webhook does NOT touch student_fees / invoices.
|
|
// 7. Paynow 'Paid' webhook updates student_fees AND the linked invoice
|
|
// in the same transaction.
|
|
//
|
|
// Each test creates its own isolated user + fee_group + student_fee (and
|
|
// invoice / payment where relevant) so we don't race with other suites
|
|
// that share the dev DB.
|
|
|
|
const { pointAtDevDb } = require('./setup');
|
|
|
|
pointAtDevDb();
|
|
const request = require('supertest');
|
|
const app = require('../src/index');
|
|
const Database = require('better-sqlite3');
|
|
const { v4: uuidv4 } = require('uuid');
|
|
|
|
// -------- Per-test fixture helpers ---------------------------------------
|
|
|
|
function openDb() {
|
|
const db = new Database(process.env.DB_PATH);
|
|
db.pragma('foreign_keys = ON');
|
|
return db;
|
|
}
|
|
|
|
function createStudent(db) {
|
|
const uid = uuidv4();
|
|
const email = `paytest-student-${uid}@school.com`;
|
|
const r = db.prepare(`
|
|
INSERT INTO users (uid, email, password, role, first_name, last_name, is_active)
|
|
VALUES (?, ?, 'hash', 'student', 'Pay', 'Test', 1)
|
|
`).run(uid, email);
|
|
return { id: r.lastInsertRowid, uid, email };
|
|
}
|
|
|
|
function createFeeGroup(db, name = 'Test Tuition', amount = 500) {
|
|
const uid = uuidv4();
|
|
const r = db.prepare(`
|
|
INSERT INTO fee_groups (uid, name, amount, type, frequency, academic_year)
|
|
VALUES (?, ?, ?, 'tuition', 'termly', '2026')
|
|
`).run(uid, name, amount);
|
|
return { id: r.lastInsertRowid, uid };
|
|
}
|
|
|
|
function createStudentFee(db, { student_id, fee_group_id, amount = 500, paid_amount = 0, status = 'pending' }) {
|
|
const uid = uuidv4();
|
|
const r = db.prepare(`
|
|
INSERT INTO student_fees (uid, student_id, fee_group_id, amount, paid_amount, status, academic_year)
|
|
VALUES (?, ?, ?, ?, ?, ?, '2026')
|
|
`).run(uid, student_id, fee_group_id, amount, paid_amount, status);
|
|
return { id: r.lastInsertRowid, uid };
|
|
}
|
|
|
|
function createInvoice(db, { student_id, fee_group_id, total = 500, paid_amount = 0, status = 'issued' }) {
|
|
const uid = uuidv4();
|
|
const invoiceNumber = `INV-${Date.now()}-${Math.random().toString(36).slice(2, 8).toUpperCase()}`;
|
|
const r = db.prepare(`
|
|
INSERT INTO invoices (uid, invoice_number, student_id, fee_group_id, total, paid_amount, status, issued_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now','localtime'))
|
|
`).run(uid, invoiceNumber, student_id, fee_group_id, total, paid_amount, status);
|
|
return { id: r.lastInsertRowid, uid };
|
|
}
|
|
|
|
function createPendingPayment(db, { student_fee_id, amount, reference, payment_method = 'paynow' }) {
|
|
const uid = uuidv4();
|
|
db.prepare(`
|
|
INSERT INTO payments (uid, student_fee_id, amount, payment_method, reference_number, status, collected_by)
|
|
VALUES (?, ?, ?, ?, ?, 'pending', 1)
|
|
`).run(uid, student_fee_id, amount, payment_method, reference);
|
|
return { uid, reference };
|
|
}
|
|
|
|
// -------- Suite ----------------------------------------------------------
|
|
|
|
describe('FR-SMS4 transactional payment surface', () => {
|
|
let adminToken;
|
|
let studentToken;
|
|
|
|
beforeAll(async () => {
|
|
const r1 = await request(app)
|
|
.post('/api/auth/login')
|
|
.send({ email: 'admin@school.com', password: 'admin123' });
|
|
adminToken = r1.body?.token;
|
|
const r2 = await request(app)
|
|
.post('/api/auth/login')
|
|
.send({ email: 'student@school.com', password: 'student123' });
|
|
studentToken = r2.body?.token;
|
|
});
|
|
|
|
// -------- Task B-1: /api/fees/pay ----------------------------------------
|
|
|
|
describe('POST /api/fees/pay (manual payment recording)', () => {
|
|
it('1. records a partial payment, updates paid_amount + status', async () => {
|
|
const db = openDb();
|
|
try {
|
|
const student = createStudent(db);
|
|
const group = createFeeGroup(db, 'Partial Tuition', 500);
|
|
const fee = createStudentFee(db, {
|
|
student_id: student.id, fee_group_id: group.id,
|
|
amount: 500, paid_amount: 0, status: 'pending',
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post('/api/fees/pay')
|
|
.set('Authorization', `Bearer ${adminToken}`)
|
|
.send({ student_fee_id: fee.id, amount: 200, method: 'cash', reference: 'CASH-TEST-001' });
|
|
|
|
expect(res.status).toBe(201);
|
|
expect(res.body.success).toBe(true);
|
|
expect(res.body.payment_id).toMatch(/^[0-9a-f-]{36}$/);
|
|
expect(res.body.student_fee_id).toBe(fee.id);
|
|
expect(res.body.new_paid_amount).toBe(200);
|
|
expect(res.body.outstanding_after).toBe(300);
|
|
expect(res.body.invoice_updated).toBeNull();
|
|
|
|
// DB row matches.
|
|
const row = db.prepare('SELECT paid_amount, status, sync_status FROM student_fees WHERE id = ?').get(fee.id);
|
|
expect(row.paid_amount).toBe(200);
|
|
expect(row.status).toBe('partial');
|
|
expect(row.sync_status).toBe('pending');
|
|
|
|
// Payment row exists.
|
|
const pay = db.prepare("SELECT * FROM payments WHERE uid = ?").get(res.body.payment_id);
|
|
expect(pay).toBeTruthy();
|
|
expect(pay.amount).toBe(200);
|
|
expect(pay.status).toBe('completed');
|
|
expect(pay.reference_number).toBe('CASH-TEST-001');
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
it('settles to status=paid when amount covers the full outstanding', async () => {
|
|
const db = openDb();
|
|
try {
|
|
const student = createStudent(db);
|
|
const group = createFeeGroup(db, 'Full Settle', 200);
|
|
const fee = createStudentFee(db, {
|
|
student_id: student.id, fee_group_id: group.id,
|
|
amount: 200, paid_amount: 0, status: 'pending',
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post('/api/fees/pay')
|
|
.set('Authorization', `Bearer ${adminToken}`)
|
|
.send({ student_fee_id: fee.id, amount: 200, method: 'bank' });
|
|
|
|
expect(res.status).toBe(201);
|
|
expect(res.body.new_paid_amount).toBe(200);
|
|
expect(res.body.outstanding_after).toBe(0);
|
|
|
|
const row = db.prepare('SELECT paid_amount, status, paid_at FROM student_fees WHERE id = ?').get(fee.id);
|
|
expect(row.status).toBe('paid');
|
|
expect(row.paid_at).toBeTruthy();
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
it('also updates the linked invoice atomically (same fee_group + student)', async () => {
|
|
const db = openDb();
|
|
try {
|
|
const student = createStudent(db);
|
|
const group = createFeeGroup(db, 'Linked Inv', 500);
|
|
const fee = createStudentFee(db, {
|
|
student_id: student.id, fee_group_id: group.id,
|
|
amount: 500, paid_amount: 0, status: 'pending',
|
|
});
|
|
const invoice = createInvoice(db, {
|
|
student_id: student.id, fee_group_id: group.id,
|
|
total: 500, paid_amount: 0, status: 'issued',
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post('/api/fees/pay')
|
|
.set('Authorization', `Bearer ${adminToken}`)
|
|
.send({ student_fee_id: fee.id, amount: 150 });
|
|
|
|
expect(res.status).toBe(201);
|
|
expect(res.body.invoice_updated).toBe(invoice.uid);
|
|
|
|
const inv = db.prepare('SELECT paid_amount, status FROM invoices WHERE uid = ?').get(invoice.uid);
|
|
expect(inv.paid_amount).toBe(150);
|
|
expect(inv.status).toBe('partial');
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
it('2. rejects overpay with 400', async () => {
|
|
const db = openDb();
|
|
try {
|
|
const student = createStudent(db);
|
|
const group = createFeeGroup(db, 'Overpay', 100);
|
|
const fee = createStudentFee(db, {
|
|
student_id: student.id, fee_group_id: group.id,
|
|
amount: 100, paid_amount: 0, status: 'pending',
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post('/api/fees/pay')
|
|
.set('Authorization', `Bearer ${adminToken}`)
|
|
.send({ student_fee_id: fee.id, amount: 5000 });
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/exceeds outstanding/i);
|
|
|
|
// DB untouched.
|
|
const row = db.prepare('SELECT paid_amount, status FROM student_fees WHERE id = ?').get(fee.id);
|
|
expect(row.paid_amount).toBe(0);
|
|
expect(row.status).toBe('pending');
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
it('rejects amount <= 0 with 400', async () => {
|
|
const db = openDb();
|
|
try {
|
|
const student = createStudent(db);
|
|
const group = createFeeGroup(db, 'Zero', 100);
|
|
const fee = createStudentFee(db, {
|
|
student_id: student.id, fee_group_id: group.id,
|
|
amount: 100, paid_amount: 0, status: 'pending',
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post('/api/fees/pay')
|
|
.set('Authorization', `Bearer ${adminToken}`)
|
|
.send({ student_fee_id: fee.id, amount: 0 });
|
|
|
|
expect(res.status).toBe(400);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
it('3. rejects unknown student_fee_id with 404', async () => {
|
|
const res = await request(app)
|
|
.post('/api/fees/pay')
|
|
.set('Authorization', `Bearer ${adminToken}`)
|
|
.send({ student_fee_id: 9999999, amount: 10 });
|
|
|
|
expect(res.status).toBe(404);
|
|
expect(res.body.error).toMatch(/not found/i);
|
|
});
|
|
|
|
it('4. rejects student role with 403 (RBAC)', async () => {
|
|
const db = openDb();
|
|
try {
|
|
const student = createStudent(db);
|
|
const group = createFeeGroup(db, 'RBAC', 100);
|
|
const fee = createStudentFee(db, {
|
|
student_id: student.id, fee_group_id: group.id,
|
|
amount: 100, paid_amount: 0, status: 'pending',
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post('/api/fees/pay')
|
|
.set('Authorization', `Bearer ${studentToken}`)
|
|
.send({ student_fee_id: fee.id, amount: 50 });
|
|
|
|
expect(res.status).toBe(403);
|
|
|
|
// DB untouched.
|
|
const row = db.prepare('SELECT paid_amount, status FROM student_fees WHERE id = ?').get(fee.id);
|
|
expect(row.paid_amount).toBe(0);
|
|
expect(row.status).toBe('pending');
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
it('rejects unauthenticated request with 401', async () => {
|
|
const res = await request(app)
|
|
.post('/api/fees/pay')
|
|
.send({ student_fee_id: 1, amount: 10 });
|
|
expect(res.status).toBe(401);
|
|
});
|
|
});
|
|
|
|
// -------- Task B-2: webhook transactions --------------------------------
|
|
|
|
describe('POST /api/payments/webhook (transactional)', () => {
|
|
it('5. is idempotent — replay does not double-credit', async () => {
|
|
const db = openDb();
|
|
try {
|
|
const student = createStudent(db);
|
|
const group = createFeeGroup(db, 'Webhook Idem', 500);
|
|
const fee = createStudentFee(db, {
|
|
student_id: student.id, fee_group_id: group.id,
|
|
amount: 500, paid_amount: 0, status: 'pending',
|
|
});
|
|
const ref = `IDEMP-${uuidv4().slice(0, 8)}`;
|
|
const { uid: payUid } = createPendingPayment(db, {
|
|
student_fee_id: fee.id, amount: 100, reference: ref,
|
|
});
|
|
|
|
const r1 = await request(app)
|
|
.post('/api/payments/webhook')
|
|
.set('Content-Type', 'application/json')
|
|
.send({ reference: ref, status: 'Paid', amount: 100 });
|
|
const r2 = await request(app)
|
|
.post('/api/payments/webhook')
|
|
.set('Content-Type', 'application/json')
|
|
.send({ reference: ref, status: 'Paid', amount: 100 });
|
|
|
|
expect(r1.status).toBeLessThan(500);
|
|
expect(r2.status).toBeLessThan(500);
|
|
// Second response includes the 'already processed' note.
|
|
expect(r2.body.note).toBe('already processed');
|
|
|
|
// Exactly ONE increment: 0 + 100 = 100, not 0 + 100 + 100 = 200.
|
|
const feeRow = db.prepare('SELECT paid_amount, status FROM student_fees WHERE id = ?').get(fee.id);
|
|
expect(feeRow.paid_amount).toBe(100);
|
|
expect(feeRow.status).toBe('partial');
|
|
|
|
// Only one payment row for this reference.
|
|
const count = db.prepare("SELECT COUNT(*) as c FROM payments WHERE reference_number = ?").get(ref).c;
|
|
expect(count).toBe(1);
|
|
|
|
// Payment row is completed.
|
|
const pay = db.prepare('SELECT status FROM payments WHERE uid = ?').get(payUid);
|
|
expect(pay.status).toBe('completed');
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
it('6. Awaiting status does NOT touch student_fees / invoices', async () => {
|
|
const db = openDb();
|
|
try {
|
|
const student = createStudent(db);
|
|
const group = createFeeGroup(db, 'Webhook Awaiting', 300);
|
|
const fee = createStudentFee(db, {
|
|
student_id: student.id, fee_group_id: group.id,
|
|
amount: 300, paid_amount: 0, status: 'pending',
|
|
});
|
|
const invoice = createInvoice(db, {
|
|
student_id: student.id, fee_group_id: group.id,
|
|
total: 300, paid_amount: 0, status: 'issued',
|
|
});
|
|
const ref = `AWAIT-${uuidv4().slice(0, 8)}`;
|
|
createPendingPayment(db, { student_fee_id: fee.id, amount: 50, reference: ref });
|
|
|
|
const res = await request(app)
|
|
.post('/api/payments/webhook')
|
|
.set('Content-Type', 'application/json')
|
|
.send({ reference: ref, status: 'Awaiting', amount: 50 });
|
|
expect(res.status).toBeLessThan(500);
|
|
|
|
// student_fees untouched.
|
|
const feeRow = db.prepare('SELECT paid_amount, status FROM student_fees WHERE id = ?').get(fee.id);
|
|
expect(feeRow.paid_amount).toBe(0);
|
|
expect(feeRow.status).toBe('pending');
|
|
// Invoice untouched.
|
|
const invRow = db.prepare('SELECT paid_amount, status FROM invoices WHERE uid = ?').get(invoice.uid);
|
|
expect(invRow.paid_amount).toBe(0);
|
|
expect(invRow.status).toBe('issued');
|
|
// Payment row is pending (not completed).
|
|
const pay = db.prepare('SELECT status FROM payments WHERE reference_number = ?').get(ref);
|
|
expect(pay.status).toBe('pending');
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
it('7. Paid status updates both student_fees AND linked invoice atomically', async () => {
|
|
const db = openDb();
|
|
try {
|
|
const student = createStudent(db);
|
|
const group = createFeeGroup(db, 'Webhook Inv', 500);
|
|
const fee = createStudentFee(db, {
|
|
student_id: student.id, fee_group_id: group.id,
|
|
amount: 500, paid_amount: 0, status: 'pending',
|
|
});
|
|
const invoice = createInvoice(db, {
|
|
student_id: student.id, fee_group_id: group.id,
|
|
total: 500, paid_amount: 0, status: 'issued',
|
|
});
|
|
const ref = `PAID-INV-${uuidv4().slice(0, 8)}`;
|
|
createPendingPayment(db, { student_fee_id: fee.id, amount: 200, reference: ref });
|
|
|
|
const res = await request(app)
|
|
.post('/api/payments/webhook')
|
|
.set('Content-Type', 'application/json')
|
|
.send({ reference: ref, status: 'Paid', amount: 200 });
|
|
expect(res.status).toBeLessThan(500);
|
|
|
|
const feeRow = db.prepare('SELECT paid_amount, status FROM student_fees WHERE id = ?').get(fee.id);
|
|
expect(feeRow.paid_amount).toBe(200);
|
|
expect(feeRow.status).toBe('partial');
|
|
|
|
const invRow = db.prepare('SELECT paid_amount, status, sync_status FROM invoices WHERE uid = ?').get(invoice.uid);
|
|
expect(invRow.paid_amount).toBe(200);
|
|
expect(invRow.status).toBe('partial');
|
|
expect(invRow.sync_status).toBe('pending');
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
// -------- Task B-3: live-mode refusal -----------------------------------
|
|
|
|
describe('POST /api/payments/initiate (no creds → test mode refusal)', () => {
|
|
it('returns mode=test with the configured-credentials instructions text', async () => {
|
|
// We deliberately don't touch env vars here — the test-setup sets a
|
|
// blank PAYNOW_INTEGRATION_ID/KEY, and we assert on the live refusal
|
|
// shape. If a developer overrides those env vars locally, this test
|
|
// will exercise the live path and fail; that is the desired signal.
|
|
const db = openDb();
|
|
try {
|
|
const student = createStudent(db);
|
|
const group = createFeeGroup(db, 'Initiate Refusal', 100);
|
|
const fee = createStudentFee(db, {
|
|
student_id: student.id, fee_group_id: group.id,
|
|
amount: 100, paid_amount: 0, status: 'pending',
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post('/api/payments/initiate')
|
|
.set('Authorization', `Bearer ${adminToken}`)
|
|
.send({ student_fee_id: fee.id, amount: 50 });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.success).toBe(true);
|
|
expect(res.body.mode).toBe('test');
|
|
expect(res.body.poll_url).toBeNull();
|
|
expect(res.body.payment_id).toBeTruthy();
|
|
expect(res.body.reference).toBeTruthy();
|
|
expect(res.body.instructions).toBe(
|
|
'Set PAYNOW_INTEGRATION_ID and PAYNOW_INTEGRATION_KEY to enable live payments.',
|
|
);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
});
|
|
}); |