geocrop-platform./apps/nextgen/server/tests/payment-transactional.test.js

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