// 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(); } }); }); });