Generated from a full codebase audit on 2026-05-26. Each issue is self-contained with context, problem statement, proposed solution, and acceptance criteria.
Labels: bug, critical, backend
In backend/src/controllers/paymentController.js, the syncAllPayments handler contains a logic error that causes two serious problems simultaneously:
syncPaymentsForSchool(req.school)is called twice — once beforeres.json(...)and once after. The second call runs after the HTTP response has already been sent, meaning its result is silently discarded and any errors it throws are unhandled.res.json(...)is called twice — the first call sends the response with the first sync's summary, and the second call (after the audit log) attempts to send another response on an already-closed socket. In Express this produces aCannot set headers after they are senterror that pollutes logs and can crash the process in some configurations.
// paymentController.js — syncAllPayments (current, broken)
const summary = await syncPaymentsForSchool(req.school);
res.json({ message: "Sync complete", summary: { ... } }); // ← first response sent here
const result = await syncPaymentsForSchool(req.school); // ← second redundant call
// ...
res.json({ message: "Sync complete" }); // ← second response — crashes- Every manual sync triggers two full blockchain polling cycles, doubling Horizon API usage and MongoDB writes.
- Duplicate payment records may be created if the idempotency check has a race window.
- The
Cannot set headers after they are senterror appears in every sync log, masking real errors. - The audit log captures the result of the second (redundant) sync, not the one whose summary was returned to the caller.
Remove the duplicate syncPaymentsForSchool call and the duplicate res.json call. The function should call the service once, log the audit, and send one response.
async function syncAllPayments(req, res, next) {
const schoolId = req.schoolId;
if (_syncLocks.has(schoolId)) {
return res.status(409).json({ error: "Sync already in progress", code: "SYNC_IN_PROGRESS" });
}
_syncLocks.add(schoolId);
try {
const summary = await syncPaymentsForSchool(req.school);
if (req.auditContext) {
await logAudit({ schoolId, action: 'payment_manual_sync', ... details: { syncResult: summary }, ... });
}
res.json({ message: "Sync complete", summary });
} catch (err) {
next(wrapStellarError(err));
} finally {
_syncLocks.delete(schoolId);
}
}-
syncPaymentsForSchoolis called exactly once perPOST /api/payments/syncrequest. -
res.jsonis called exactly once per request; no "headers already sent" errors appear in logs. - The audit log entry captures the summary from the single sync call.
- The in-memory
_syncLocksset is always cleaned up in thefinallyblock (already present — must be preserved). - Existing tests in
tests/payment.test.jscontinue to pass. - A new unit test asserts that
syncPaymentsForSchoolis called exactly once whenPOST /api/payments/syncis invoked.
Labels: bug, security, multi-school
getStudentPayments in paymentController.js queries payments using both schoolId and studentId:
const payments = await Payment.find({
schoolId: req.schoolId,
studentId: req.params.studentId,
});However, studentId values are not globally unique — they are only unique within a school (enforced by the compound index { studentId: 1, schoolId: 1 }). If two schools both have a student with ID STU001, a request from School A for STU001 will correctly return only School A's payments because schoolId is included in the filter.
The actual data-leak vector is in the student existence check that runs before the payment query:
const student = await Student.findOne({
schoolId: req.schoolId,
studentId: req.params.studentId,
});
if (!student) {
return res.status(404).json({ error: "Student not found", code: "NOT_FOUND" });
}This check is correct. But the getStudentBalance endpoint performs an aggregation that groups by studentId without a schoolId filter in the $match stage of the categoryPayments sub-aggregation:
const categoryPayments = await Payment.aggregate([
{ $match: { schoolId, studentId, feeCategory: { $ne: null } } },
...
]);While schoolId is present here, the top-level balance aggregation uses:
const result = await Payment.aggregate([
{ $match: { schoolId, studentId } },
...
]);The studentId here is a string from req.params, not a MongoDB ObjectId. If a malicious actor registers a student in their own school with the same studentId string as a student in a victim school, and then queries the balance endpoint, the aggregation will only return their own school's data because schoolId is in the match. However, the payment history endpoint (GET /api/payments/:studentId) does not validate that the student belongs to the school before returning payment records in all code paths — specifically when req.schoolId is derived from a header that can be spoofed if the resolveSchool middleware is bypassed or misconfigured.
The root issue is that there is no integration test covering cross-school payment isolation, making this class of bug invisible.
- In a misconfigured or future deployment where
resolveSchoolmiddleware is not applied to a route, payment records from one school could be returned to a requester from another school. - No cross-school data isolation tests exist, so regressions in this area will not be caught by CI.
- Add an explicit
schoolIdassertion in every payment query that accepts astudentIdparameter. - Add integration tests that create two schools with overlapping
studentIdvalues and assert that each school only sees its own data. - Add a middleware-level guard that rejects requests where
req.schoolIdis absent or does not match a known school.
-
GET /api/payments/:studentIdreturns404when the student exists in a different school than the one identified by the request's school context. -
GET /api/payments/:studentId/balancereturns404under the same condition. - A test suite
tests/cross-school-isolation.test.jsis added with at least 5 test cases covering payment history, balance, and instructions endpoints across two schools with overlapping student IDs. - All existing payment tests continue to pass.
- The
resolveSchoolmiddleware is applied to every payment and student route (verified by a route-level test that sends a request without a school header and expects400).
Issue 3: schoolModel.js has no timezone field — all timestamps are stored and displayed in UTC without school context
Labels: enhancement, multi-school, feature
The School model (backend/src/models/schoolModel.js) stores a localCurrency field for fiat conversion but has no timezone field. All timestamps in the system (confirmedAt, createdAt, paymentDeadline, etc.) are stored and returned in UTC.
This creates several user-facing problems:
- Reports are broken for non-UTC schools. The
aggregateByDatefunction inreportService.jsgroups payments by$dateToStringusing the default UTC timezone. A school in UTC+10 (e.g. Papua New Guinea) will see payments made at 11 PM local time grouped under the wrong date. - Payment deadlines are ambiguous.
paymentDeadlineis stored as a UTCDatebut displayed without timezone context. A deadline of2026-06-30T00:00:00Zmeans June 29 at 2 PM in UTC-10, which is confusing for parents. - Dashboard "today's payments" in
reportService.getDashboardMetricsusesnew Date()(server UTC) as the start of today, which is wrong for schools in different timezones. - Reminder scheduling in
reminderService.jsruns on a fixed UTC interval with no awareness of school business hours.
Add a timezone field to the School model (IANA timezone string, e.g. "Pacific/Port_Moresby", "America/New_York"). Use this field in:
reportService.aggregateByDate— passtimezoneto MongoDB's$dateToStringoperator.reportService.getDashboardMetrics— compute "start of today" in the school's local timezone.- All API responses that include timestamps — add a
timezonefield to the response so clients can display times correctly. reminderService— respect school timezone when determining whether it is a reasonable hour to send reminders.
// schoolModel.js
timezone: {
type: String,
default: 'UTC',
trim: true,
// Validated against a list of IANA timezone strings on save
},-
Schoolmodel has atimezonefield (String, default'UTC', validated as a valid IANA timezone identifier). -
POST /api/schoolsandPATCH /api/schools/:slugaccept and persist atimezonevalue; invalid values return400 VALIDATION_ERROR. -
reportService.aggregateByDatepasses the school'stimezoneto MongoDB's$dateToStringso daily groupings reflect local dates. -
reportService.getDashboardMetricscomputes "start of today" using the school's timezone. - All payment API responses that include
confirmedAtorpaymentDeadlinealso include the school'stimezonestring so clients can render times correctly. - A migration script (
backend/migrations/012_add_school_timezone.js) backfillstimezone: 'UTC'for all existing schools. - Unit tests cover date grouping for a school in UTC+10 and UTC-5.
- Existing tests continue to pass.
Issue 4: Payment memo is encrypted before being used as a Stellar memo — recipient wallets cannot match payments
Labels: bug, critical, stellar-integration
getPaymentInstructions in paymentController.js returns an encrypted memo to the client:
res.json({
memo: encryptMemo(req.params.studentId),
memoEncrypted: isEncryptionEnabled(),
...
});When MEMO_ENCRYPTION_KEY is set, encryptMemo returns an AES-256-GCM ciphertext string. The parent is instructed to include this string as the Stellar transaction memo. However:
- Stellar
MEMO_TEXTis limited to 28 bytes. An AES-GCM ciphertext (IV + tag + ciphertext, hex-encoded) is typically 80–100 characters — far exceeding the limit. The Stellar SDK will throwMemoTooLongErrorwhen the parent's wallet tries to build the transaction. - Even if the memo were short enough,
extractValidPaymentinstellarService.jsreadstx.memofrom the blockchain and passes it todecryptMemobefore matching it to a student. But the memo on-chain is the encrypted string that the parent copied from the instructions — it was never the raw student ID. The decryption will either fail (wrong key) or return garbage, and no student will ever be matched. - The
createPaymentIntentfunction generates a random 8-character hex memo and encrypts it, storing the encrypted value inPaymentIntent.memo. The same mismatch applies.
The memo encryption feature was designed to protect student IDs at rest in MongoDB (the paymentModel pre-save hook encrypts memo before writing to the database). It was incorrectly also applied to the outbound payment instructions, where the memo must be a plain-text student ID that the Stellar network and the backend's sync logic can read.
- With
MEMO_ENCRYPTION_KEYset, no payment can ever be matched to a student. The entire payment flow is broken. - Parents receive a memo string that their Stellar wallet will reject as too long.
- This is a silent failure — the sync endpoint returns
{ message: "Sync complete" }with zero matches and no error.
getPaymentInstructions must return the plain-text student ID as the memo. Encryption at rest (in MongoDB) is handled by the paymentModel pre-save hook and should not affect what is shown to the user.
// paymentController.js — getPaymentInstructions
res.json({
memo: req.params.studentId, // always plain text — Stellar memo must be readable
memoEncrypted: false,
...
});Remove encryptMemo from getPaymentInstructions and createPaymentIntent. The memoEncrypted field in the response should be removed or always set to false since the memo shown to the user is never encrypted.
-
GET /api/payments/instructions/:studentIdreturnsmemoequal to the plain-textstudentId, regardless of whetherMEMO_ENCRYPTION_KEYis set. -
POST /api/payments/intentstores the plain-text random memo inPaymentIntent.memo; the encrypted version is only written to MongoDB by the model's pre-save hook. - The
memoEncryptedfield is removed from the payment instructions response (or documented as alwaysfalse). - A test asserts that with
MEMO_ENCRYPTION_KEYset, the instructions endpoint still returns a plain-text memo ≤ 28 bytes. - A test asserts that the memo stored in MongoDB (via the model hook) is different from the memo returned by the instructions endpoint when encryption is enabled.
- End-to-end payment flow test passes with encryption enabled.
Issue 5: No authentication on student registration, fee structure creation, and school management endpoints
Labels: security, critical, backend
The requireAdminAuth middleware exists in backend/src/middleware/auth.js and is applied to some routes (e.g. GET /api/schools?includeInactive=true has an inline JWT check). However, the following high-privilege endpoints have no authentication applied:
POST /api/students— register a studentPUT/PATCH /api/students/:studentId— update a student's name, class, or fee amountDELETE /api/students/:studentId— delete a student (hard delete + orphan payments)POST /api/fees— create a fee structurePUT /api/fees/:className— update a fee structure (with optional cascade to all students)DELETE /api/fees/:className— deactivate a fee structurePOST /api/schools— create a new school tenantPATCH /api/schools/:slug— update school details includingstellarAddressDELETE /api/schools/:slug— deactivate a schoolPOST /api/payments/sync— trigger a blockchain syncPATCH /api/payments/:txHash/status— manually override payment status
Any unauthenticated actor on the network can:
- Register fake students and pollute the database.
- Change a school's
stellarAddressto redirect all future payment instructions to an attacker-controlled wallet. - Delete all students, causing all historical payments to be marked
studentDeleted: trueand excluded from reports. - Trigger unlimited blockchain syncs, exhausting Horizon API rate limits.
Apply requireAdminAuth to all write endpoints for students, fees, schools, and payment management. Read endpoints (GET) may remain public or be protected depending on the school's privacy requirements.
// studentRoutes.js
router.post('/', requireAdminAuth, registerStudent);
router.put('/:id', requireAdminAuth, updateStudent);
router.delete('/:id', requireAdminAuth, deleteStudent);A role-based access control (RBAC) model should be defined:
adminrole: full access to all endpoints.staffrole (future): read access + payment sync, no school/fee management.- Unauthenticated: read-only access to public endpoints (payment instructions, accepted assets).
-
POST /api/students,PUT /api/students/:id,DELETE /api/students/:idrequire a valid admin JWT; unauthenticated requests return401. -
POST /api/fees,PUT /api/fees/:className,DELETE /api/fees/:classNamerequire a valid admin JWT. -
POST /api/schools,PATCH /api/schools/:slug,DELETE /api/schools/:slugrequire a valid admin JWT. -
POST /api/payments/syncrequires a valid admin JWT. -
PATCH /api/payments/:txHash/statusrequires a valid admin JWT (already partially implemented — verify it is enforced on the route level, not just in the controller). -
GETendpoints for students, fees, and payments remain accessible without authentication (or are documented as requiring auth if the school requires it). - All existing tests are updated to include a valid JWT in requests that now require authentication.
- New tests assert that each protected endpoint returns
401without a token and403with a non-admin token. - The
README.mdanddocs/api-spec.mdare updated to document which endpoints require authentication.
Issue 6: deleteStudent performs a hard delete — student record and payment history are permanently lost
Labels: bug, data-integrity, backend
deleteStudent in studentController.js calls Student.findOneAndDelete(...), which permanently removes the student document from MongoDB:
const student = await Student.findOneAndDelete({ schoolId: req.schoolId, studentId });The studentModel has a deletedAt field and the softDelete utility is applied to the schema, which means soft-delete support is already built in. However, the delete controller bypasses it entirely and performs a hard delete.
Consequences:
- The student's name, class, and fee history are permanently gone. There is no way to recover them.
- Payments are marked
studentDeleted: trueand excluded from reports, but the payment records themselves still exist with astudentIdthat no longer resolves to a student document. This creates orphaned payment records that cannot be attributed to anyone. - Audit logs reference the deleted
studentIdbut the student document is gone, making audit trails incomplete. - If a new student is registered with the same
studentId(which is allowed after deletion), their payment history will be contaminated by the orphaned payments from the previous student.
Replace the hard delete with a soft delete using the existing softDelete utility:
// studentController.js — deleteStudent
const student = await Student.findOneAndUpdate(
{ schoolId: req.schoolId, studentId },
{ deletedAt: new Date() },
{ new: true }
);All queries that list students should already filter deletedAt: null (the softDelete utility adds this automatically via a query middleware hook — verify this is working). The student record is preserved for audit purposes but excluded from active listings.
Additionally, prevent re-registration of a studentId that has been soft-deleted (or require explicit confirmation to reuse a deleted ID).
-
DELETE /api/students/:studentIdsetsdeletedAtto the current timestamp instead of removing the document. - Soft-deleted students do not appear in
GET /api/studentsresponses. - Soft-deleted students do not appear in payment summary or report aggregations.
-
GET /api/students/:studentIdreturns404for soft-deleted students. - Attempting to register a new student with the same
studentIdas a soft-deleted student returns409 DUPLICATE_STUDENT(or a specificSTUDENT_PREVIOUSLY_DELETEDcode with a clear message). - Orphaned payments (where the student was previously hard-deleted) are handled gracefully — they remain in the database but are excluded from reports via the existing
studentDeletedflag. - A migration script backfills
deletedAt: nullfor all existing student documents that do not have the field. - Unit tests cover soft-delete, re-registration prevention, and exclusion from listings.
- The existing
tests/softDelete.test.jsis updated to cover the student delete endpoint.
Issue 7: Currency conversion service has no fallback when CoinGecko is unavailable — fee display silently breaks
Labels: enhancement, reliability, backend
currencyConversionService.js fetches live XLM/USDC prices from the CoinGecko API. When CoinGecko is unavailable (rate-limited, network outage, or API key expired), the service returns { available: false } and all fiat-equivalent fields in API responses are null.
This is documented as "graceful degradation," but the current implementation has several gaps:
- No stale-cache fallback. The in-memory cache is invalidated after
PRICE_CACHE_TTL_MS(default 60 seconds). If CoinGecko is down for 5 minutes, the cache expires and all subsequent requests returnavailable: false— even though a 5-minute-old rate would be far more useful than nothing. - No persistent fallback. On server restart, the cache is empty. If CoinGecko is down at startup, the first N requests all hit the API simultaneously (the in-flight deduplication helps, but only for concurrent requests within the same process tick).
- No alerting. When the price feed fails, a
logger.warnis emitted but there is no structured metric or health-check signal. The/healthendpoint does not report price feed status. getExchangeRatesendpoint returnsavailable: falsewith no indication of when rates were last successfully fetched, making it impossible for clients to decide whether to show a "rates unavailable" banner or use a cached value.
- Stale-while-revalidate cache: Keep the last successful rate in memory indefinitely. When a fresh fetch fails, return the stale rate with a
stale: trueflag and astaleAgefield (seconds since last successful fetch). Clients can decide whether to display a staleness warning. - Configurable stale threshold: Add
PRICE_STALE_THRESHOLD_MSenv var (default: 1 hour). If the cached rate is older than this threshold, returnavailable: falseinstead of the stale rate. - Health check integration: The
/healthendpoint should includepriceFeed: { available, lastFetchedAt, staleAge }. getExchangeRatesresponse enhancement: IncludelastFetchedAt,stale, andstaleAgein the response.
- When CoinGecko returns an error,
convertToLocalCurrencyreturns the last successfully fetched rate withstale: trueandstaleAge(seconds) if the cached rate is withinPRICE_STALE_THRESHOLD_MS. - When the cached rate is older than
PRICE_STALE_THRESHOLD_MS,convertToLocalCurrencyreturnsavailable: false. -
GET /api/payments/ratesresponse includeslastFetchedAt,stale(boolean), andstaleAge(seconds) fields. -
GET /healthincludes apriceFeedsubsystem withavailable,lastFetchedAt, andstaleAge. -
PRICE_STALE_THRESHOLD_MSis documented inREADME.mdandbackend/.env.example. - Unit tests cover: fresh fetch success, stale cache within threshold, stale cache beyond threshold, and concurrent request deduplication.
- Existing
tests/currencyConversion.test.jstests continue to pass.
Issue 8: Bulk student import (POST /api/students/bulk) processes rows sequentially — times out for large files
Labels: performance, backend
bulkImportStudents in studentController.js processes CSV rows in a for loop with await inside:
for (let i = 0; i < rows.length; i++) {
// ...
const student = await Student.create({ ... });
}This is fully sequential — each row waits for the previous MongoDB insert to complete before starting. For a 10,000-row CSV (the configured maximum), this means 10,000 sequential round-trips to MongoDB. On a typical Atlas cluster with 5 ms round-trip latency, this takes 50 seconds — well beyond the default 30-second HTTP timeout in most reverse proxies and load balancers.
Additionally:
- The fee structure lookup (
FeeStructure.findOne) is also inside the loop, causing up to 10,000 additional sequential queries even when all students are in the same class. - There is no progress reporting — the client receives no feedback until all rows are processed or the request times out.
- If the request times out, partial imports leave the database in an inconsistent state with no way to resume.
- Pre-fetch fee structures for all unique class names before the loop (one query per unique class, not one per row).
- Batch inserts using
Student.insertManywithordered: falseso MongoDB processes the batch in parallel and individual failures do not abort the entire batch. - Chunked processing for very large files: process rows in chunks of 500, inserting each chunk with
insertMany. - Async job for files > 1,000 rows: Return
202 Acceptedimmediately with a job ID, process in the background, and expose aGET /api/students/bulk/:jobIdstatus endpoint.
- Bulk import of 1,000 students completes in under 5 seconds (measured in a test with a mocked MongoDB).
- Fee structures are fetched once per unique class name, not once per row.
-
Student.insertMany(or equivalent batch operation) is used instead of per-rowStudent.create. - Individual row failures (e.g. duplicate
studentId) are reported in the responsedetailsarray without aborting the entire import. - Files with more than
CSV_MAX_ROWSrows (default 10,000) are rejected with400 CSV_TOO_MANY_ROWSbefore any processing begins (already implemented — must be preserved). - The response format is unchanged:
{ total, created, failed, details }. - Existing
tests/csvImportLimits.test.jstests continue to pass. - A new performance test asserts that 1,000-row import completes within the timeout.
Issue 9: reminderService has no opt-out mechanism exposed via API — parents cannot unsubscribe from reminders
Labels: feature, compliance, backend
The Student model has a reminderOptOut boolean field and reminderService.isEligible checks it:
if (student.reminderOptOut) return false;However, there is no API endpoint that allows a parent to set reminderOptOut: true. The only way to opt out is for a school administrator to directly update the student record via PATCH /api/students/:studentId — but reminderOptOut is not in the list of allowed update fields in updateStudent:
const update = {};
if (name !== undefined) update.name = name;
if (className !== undefined) update.class = className;
if (feeAmount !== undefined) update.feeAmount = feeAmount;
// reminderOptOut is never setThis means:
- Parents have no self-service way to stop receiving reminder emails.
- School administrators cannot opt out a parent through the normal API.
- In jurisdictions with anti-spam laws (CAN-SPAM, GDPR, etc.), the inability to unsubscribe from automated emails is a compliance violation.
- Admin endpoint: Add
reminderOptOutto the allowed fields inupdateStudentso administrators can toggle it. - Self-service unsubscribe link: Include a signed unsubscribe token in reminder emails. Add a public endpoint
GET /api/reminders/unsubscribe?token=<signed-token>that setsreminderOptOut: truewithout requiring authentication. - Re-subscribe endpoint:
POST /api/students/:studentId/reminders/resubscribe(admin-only) to re-enable reminders.
The token should be a HMAC-SHA256 signature of studentId:schoolId using JWT_SECRET, with a 90-day expiry encoded in the token. This avoids storing tokens in the database.
-
PATCH /api/students/:studentIdacceptsreminderOptOut: true/falseand persists it. - Reminder emails include an unsubscribe link containing a signed token.
-
GET /api/reminders/unsubscribe?token=<token>setsreminderOptOut: truefor the identified student and returns a confirmation page (HTML) or JSON response. - Expired or invalid unsubscribe tokens return
400with a clear error message. -
POST /api/students/:studentId/reminders/resubscribe(admin-only) setsreminderOptOut: false. -
reminderService.isEligiblecontinues to respectreminderOptOut: true. - Unit tests cover token generation, token validation, expiry, and the unsubscribe endpoint.
- The reminder email template (
backend/src/templates/reminderEmail.html) is updated to include the unsubscribe link. -
docs/api-spec.mdis updated with the new endpoints.
Issue 10: Webhook delivery has no replay protection — replayed webhooks can trigger duplicate actions in recipient systems
Labels: security, backend, webhooks
webhookService.js signs outbound webhook payloads with HMAC-SHA256:
headers['X-StellarEduPay-Signature'] = `sha256=${generateSignature(body, secret)}`;The signature covers the payload content but not a timestamp or nonce. This means:
- An attacker who intercepts a legitimate webhook delivery can replay it at any time. The signature will still be valid because the payload has not changed.
- Recipient systems that verify the signature but do not check for replay attacks will process the same payment event multiple times (e.g. crediting a student's account twice).
- There is no
X-Webhook-Delivery-IDheader, so recipients cannot use delivery IDs to deduplicate.
This is a well-known vulnerability in webhook implementations. GitHub, Stripe, and other major platforms address it by including a timestamp in the signed payload and requiring recipients to reject deliveries older than a configurable window (typically 5 minutes).
- Include timestamp in signature: Add a
X-StellarEduPay-Timestampheader with the Unix timestamp (seconds). Include the timestamp in the HMAC input:HMAC(timestamp + "." + JSON.stringify(body)). - Add delivery ID: Add a
X-StellarEduPay-Delivery-IDheader with a UUID for each delivery attempt. Include this in theWebhookRetrymodel for deduplication. - Document replay window: Document that recipients should reject deliveries where
|now - timestamp| > 300seconds (5 minutes). - Update
verifySignature: Update the verification helper to accept and validate the timestamp.
- Every webhook delivery includes
X-StellarEduPay-Timestamp(Unix seconds) andX-StellarEduPay-Delivery-ID(UUID) headers. - The HMAC signature is computed over
timestamp + "." + JSON.stringify(body)so the timestamp is part of the signed content. -
verifySignature(payload, signature, secret, timestamp)validates both the HMAC and that|now - timestamp| <= 300seconds. - The
WebhookRetrymodel storesdeliveryIdfor deduplication. - Retry attempts use the same
deliveryIdas the original delivery (so recipients can deduplicate retries). -
docs/WEBHOOK_INTEGRATION.mdis updated with the new signature format, timestamp header, and replay window guidance. - Unit tests in
tests/webhookSignature.test.jsare updated to cover timestamp validation and replay rejection. - Existing webhook tests continue to pass.
Issue 11: transactionQueue.js in-memory queue is lost on server restart — submitted transactions are silently dropped
Labels: bug, reliability, backend
backend/src/queue/transactionQueue.js implements an in-memory queue for processing submitted Stellar transactions. When the server restarts (deployment, crash, OOM kill), all jobs in the queue are lost. Any transaction that was submitted to the Stellar network but not yet verified will never be verified, and the associated payment record will remain in SUBMITTED status indefinitely.
The transactionRetryQueue.js uses BullMQ (Redis-backed) for the retry queue, which is durable. But the primary transaction queue is in-memory only.
Additionally, there is no background job that scans for payments stuck in SUBMITTED status and re-queues them for verification. A payment can be stuck in SUBMITTED forever if the server restarts between submission and verification.
- Persist the transaction queue to Redis using BullMQ (already a dependency via
bullMQRetryService.js). This makes the queue durable across restarts. - Add a startup reconciliation job that runs once on server start, finds all payments in
SUBMITTEDstatus older than 5 minutes, and re-queues them for verification. - Add a
GET /api/payments/stuckendpoint (admin-only) that lists payments inSUBMITTEDstatus older than a configurable threshold, for manual inspection.
- The transaction queue is backed by Redis (BullMQ) when
REDIS_HOSTis configured; falls back to in-memory when Redis is not available (with a startup warning). - On server startup, payments in
SUBMITTEDstatus older thanSTUCK_PAYMENT_THRESHOLD_MS(default: 5 minutes, configurable) are automatically re-queued for verification. -
GET /api/payments/stuck(admin-only) returns payments inSUBMITTEDstatus older than the threshold. - The startup reconciliation job is logged at
infolevel with the count of re-queued payments. - Unit tests cover: queue persistence across simulated restart, startup reconciliation, and the stuck payments endpoint.
-
README.mddocuments the Redis dependency for durable queue support. - Existing
tests/transactionQueueDurability.test.jstests are updated to cover the new behavior.
Issue 12: GET /api/payments (all payments) does not filter out soft-deleted payments — deleted records appear in results
Labels: bug, data-integrity, backend
getAllPayments in paymentController.js filters out payments where studentDeleted: true but does not filter out payments where deletedAt is set (soft-deleted payments):
const filter = { schoolId, studentDeleted: { $ne: true } };The Payment model has a deletedAt field and the softDelete utility is applied to the schema. However, the softDelete utility adds a query middleware hook that automatically appends { deletedAt: null } to find queries — but only when using the Mongoose model's query methods directly. When Payment.find(filter) is called with an explicit filter object, the soft-delete middleware may not apply correctly depending on the Mongoose version and how the middleware is registered.
Additionally, getStudentPayments and getStudentBalance do not explicitly filter deletedAt: null, relying entirely on the middleware hook.
Explicitly add deletedAt: null to all payment query filters to ensure soft-deleted records are never returned, regardless of middleware behavior:
const filter = {
schoolId,
studentDeleted: { $ne: true },
deletedAt: null,
};Audit all payment query sites (getAllPayments, getStudentPayments, getStudentBalance, getOverpayments, getSuspiciousPayments, getPendingPayments, reportService) and add explicit deletedAt: null filters.
-
GET /api/paymentsdoes not return payments wheredeletedAtis set. -
GET /api/payments/:studentIddoes not return soft-deleted payments. -
GET /api/payments/:studentId/balanceexcludes soft-deleted payments from the total paid calculation. - Report aggregations in
reportService.jsexclude soft-deleted payments. - A test creates a payment, soft-deletes it, and asserts it does not appear in any of the above endpoints.
- The
softDeleteutility's query middleware is tested to confirm it applies correctly to all query types used in the codebase. - Existing payment tests continue to pass.
Issue 13: No rate limiting on POST /api/students/bulk — allows denial-of-service via large CSV uploads
Labels: security, backend, rate-limiting
POST /api/students/bulk accepts CSV files up to 5 MB (CSV_MAX_SIZE_BYTES) with up to 10,000 rows (CSV_MAX_ROWS). The endpoint is subject only to the general rate limiter (100 requests per 15 minutes), which means an attacker can:
- Upload 100 × 10,000-row CSVs in 15 minutes = 1,000,000 student creation attempts.
- Each upload triggers up to 10,000 sequential MongoDB queries (fee structure lookups + inserts), saturating the database.
- The CSV parsing itself is CPU-intensive for large files; 100 concurrent large uploads can exhaust Node.js's single-threaded event loop.
Additionally, there is no authentication on this endpoint (see Issue 5), so the attack requires no credentials.
- Dedicated rate limiter for bulk import: maximum 5 requests per hour per IP.
- Authentication required (see Issue 5): only admin users can perform bulk imports.
- File size validation before parsing: Reject files larger than
CSV_MAX_SIZE_BYTESbefore callingparseCsvBuffer(already implemented — verify it runs before the stream is opened). - Async processing for large files: Files with more than 500 rows should be processed asynchronously (return
202 Acceptedwith a job ID).
-
POST /api/students/bulkis protected by a dedicated rate limiter: maximum 5 requests per hour per IP. - Requests exceeding the rate limit return
429withcode: RATE_LIMIT_EXCEEDED. - The endpoint requires admin authentication (see Issue 5).
- File size is validated before the CSV stream is opened; oversized files return
413immediately. - A test asserts that the 6th bulk import request within an hour returns
429. - Existing
tests/csvImportLimits.test.jstests continue to pass.
Issue 14: feeController.updateFeeStructure with cascadeToStudents: true does not recalculate remainingBalance correctly
Labels: bug, data-integrity, backend
When PUT /api/fees/:className is called with cascadeToStudents: true, the controller updates all students in the class:
const result = await Student.updateMany(
{ schoolId: req.schoolId, class: className, deletedAt: null },
{ feeAmount, remainingBalance: null }
);Setting remainingBalance: null is incorrect. The remaining balance should be recalculated as feeAmount - totalPaid for each student. Setting it to null means:
GET /api/students/:studentIdreturnsremainingBalance: nulluntil the student's next payment or the next time their document is saved (which triggers the pre-save hook that recalculates it).getStudentBalanceinpaymentController.jscomputes remaining balance from the aggregated payment total, so it is correct — but theStudentdocument itself has stale/null data.- The
isOverduevirtual depends onfeePaid, which is not updated by the cascade. A student who had fully paid the old fee amount may now be marked as unpaid after a fee increase.
Replace the updateMany with a per-student update that recalculates remainingBalance and feePaid:
const students = await Student.find({ schoolId: req.schoolId, class: className, deletedAt: null });
for (const student of students) {
student.feeAmount = feeAmount;
student.remainingBalance = Math.max(0, feeAmount - (student.totalPaid || 0));
student.feePaid = (student.totalPaid || 0) >= feeAmount;
await student.save(); // triggers pre-save hook for fees array sync
}For large classes, this should be done in batches to avoid memory issues.
- After
PUT /api/fees/:classNamewithcascadeToStudents: true, all students in the class have correctfeeAmount,remainingBalance, andfeePaidvalues. - A student who had
totalPaid >= oldFeeAmount(fully paid) andnewFeeAmount > oldFeeAmounthasfeePaid: falseandremainingBalance: newFeeAmount - totalPaidafter the cascade. - A student who had
totalPaid >= oldFeeAmountandnewFeeAmount <= totalPaidremainsfeePaid: true. -
remainingBalanceis nevernullafter a cascade update. - The cascade is performed in batches of 500 students to avoid memory issues for large classes.
- Unit tests cover all three scenarios above (fee increase, fee decrease, no change).
- Existing
tests/feeStructureUpdate.test.jstests continue to pass.
Issue 15: Health check endpoint does not verify Stellar Horizon connectivity — reports healthy when blockchain is unreachable
Labels: enhancement, observability, backend
GET /health is implemented in healthController.js. Based on the README, it returns:
200 { status: "ok" }when all systems are healthy.200 { status: "degraded" }when a subsystem is unreachable.503 { status: "unhealthy" }when MongoDB is disconnected.
However, the health check does not verify Stellar Horizon connectivity. If the Horizon API is unreachable (network partition, Stellar network outage, rate limit), the health endpoint still returns 200 ok even though:
POST /api/payments/verifywill fail for all new transactions.POST /api/payments/syncwill fail.- Background polling will silently stop matching payments.
This means load balancers and uptime monitors will not detect a Stellar outage, and the system will appear healthy while payments are not being processed.
Add a Stellar Horizon connectivity check to the health endpoint:
// healthController.js
async function checkStellarHealth() {
try {
await withStellarRetry(() => server.ledgers().limit(1).call(), { label: 'healthCheck', maxRetries: 1 });
return { status: 'ok', latencyMs: /* measured */ };
} catch (err) {
return { status: 'unreachable', error: err.message };
}
}The Stellar check should have a short timeout (3 seconds) and should not retry on failure (to keep the health check fast). If Stellar is unreachable, the overall status should be degraded (not unhealthy, since MongoDB is still up and cached data can be served).
-
GET /healthincludes astellarsubsystem in the response:{ status: 'ok' | 'unreachable', latencyMs?, error? }. - When Stellar Horizon is unreachable,
GET /healthreturns200 { status: "degraded", details: { stellar: { status: "unreachable" } } }. - The Stellar health check has a maximum timeout of 3 seconds and does not retry.
- The Stellar health check does not count against Horizon rate limits (use a lightweight endpoint like
GET /orGET /ledgers?limit=1). -
GET /healthresponse time is under 5 seconds even when Stellar is unreachable (due to the 3-second timeout). - Unit tests in
tests/health.test.jscover: all healthy, Stellar unreachable (degraded), MongoDB disconnected (unhealthy). -
README.mdmonitoring section is updated to document thestellarsubsystem in the health response.
Issue 16: auditLogModel.js has a TTL index but no pagination on GET /api/audit-logs — large audit logs cause memory exhaustion
Labels: performance, backend, observability
GET /api/audit-logs in auditController.js fetches audit log entries. The AuditLog model has a TTL index to expire old entries, but:
- There is no pagination on the audit log endpoint — it returns all matching records in a single response.
- A busy school with many payment verifications can accumulate thousands of audit log entries per day. Fetching all of them in one request loads the entire result set into Node.js memory before serializing to JSON.
- The frontend
audit-logs.jsxpage loads all audit logs at once, which can cause the browser to freeze for large datasets.
The .kiro/specs/audit-log-pagination/ spec directory exists, indicating this was planned but not implemented.
Implement cursor-based or offset-based pagination on the audit log endpoint, consistent with the pagination pattern used by getAllPayments and getStudentPayments.
GET /api/audit-logs?page=1&limit=50&action=payment_verify&startDate=2026-01-01&endDate=2026-12-31
Response:
{
"logs": [...],
"pagination": { "page": 1, "limit": 50, "total": 1234, "totalPages": 25 }
}-
GET /api/audit-logssupportspage(default 1) andlimit(default 50, max 200) query parameters. -
GET /api/audit-logssupports filtering byaction,startDate,endDate,result(success/failure), andperformedBy. - The response includes a
paginationobject withpage,limit,total, andtotalPages. - The endpoint never loads more than
limitdocuments into memory at once (uses.skip().limit()or cursor-based pagination). - The frontend
audit-logs.jsxpage is updated to use paginated loading with a "Load more" button or page navigation. - The
.kiro/specs/audit-log-pagination/tasks.mdtasks are completed. - Unit tests cover pagination, filtering, and boundary cases (empty results, last page).
- Existing audit log tests continue to pass.
Issue 17: paymentModel pre-save hook blocks status transitions from SUCCESS or FAILED — legitimate admin overrides are rejected
Labels: bug, backend
The paymentModel pre-save hook enforces immutability for payments in SUCCESS or FAILED status:
if (originalStatus === 'SUCCESS' || originalStatus === 'FAILED') {
return next(new Error('Payment audit trail is immutable once in SUCCESS or FAILED state'));
}However, updatePaymentStatus in paymentController.js allows the transition SUCCESS → DISPUTED:
const ALLOWED_TRANSITIONS = {
SUCCESS: ['DISPUTED'],
PENDING: ['FAILED'],
SUBMITTED: ['FAILED'],
};This transition is legitimate — an admin needs to mark a payment as disputed after it has been confirmed. But the pre-save hook will block it because the original status is SUCCESS.
The controller uses findOneAndUpdate (not save), which bypasses Mongoose pre-save hooks. So the transition currently works — but only because it bypasses the hook. This is fragile: any future refactor that switches to save() will silently break admin dispute flagging.
Additionally, the FAILED → SUCCESS transition is not allowed by ALLOWED_TRANSITIONS, but the pre-save hook would block it anyway. The two mechanisms are inconsistent and the interaction is not documented.
- Remove the blanket immutability check from the pre-save hook. Immutability should be enforced at the controller/service layer where business rules are defined, not in the model.
- Add a model-level validator that only blocks transitions not in
ALLOWED_TRANSITIONS(or a superset of it). - Document the transition rules in a comment in
paymentModel.js. - Alternatively, keep the pre-save hook but add an explicit exception for
SUCCESS → DISPUTED.
-
PATCH /api/payments/:txHash/statuswith{ status: "DISPUTED" }on aSUCCESSpayment succeeds and returns the updated payment. -
PATCH /api/payments/:txHash/statuswith an invalid transition (e.g.FAILED → SUCCESS) returns400 INVALID_TRANSITION. - The pre-save hook and the controller's
ALLOWED_TRANSITIONSare consistent — they enforce the same rules. - A unit test covers the
SUCCESS → DISPUTEDtransition via bothfindOneAndUpdateandsave(). - A unit test asserts that
FAILED → SUCCESSis rejected. - Existing
tests/updatePaymentStatus.test.jstests continue to pass.
Labels: bug, backend
backend/src/utils/generateStudentId.js generates a student ID (used when studentId is not provided in the registration request). The implementation generates a random or sequential ID but does not check for uniqueness across all schools — it only checks within the current school (via the unique compound index { studentId: 1, schoolId: 1 }).
While the compound index prevents duplicate studentId within a school, the generateStudentId function may generate an ID that already exists in the same school, causing a DUPLICATE_STUDENT error that is surfaced to the caller as an unexpected failure rather than being retried transparently.
Looking at tests/generateStudentId.test.js, the function appears to generate IDs without a retry loop. If the generated ID collides with an existing one, registerStudent will throw a 11000 duplicate key error, which is caught and returned as 409 DUPLICATE_STUDENT — but the caller asked for an auto-generated ID, so they have no way to resolve this without retrying the entire registration.
- Add a retry loop in
generateStudentIdthat checks for existence and regenerates if a collision is detected (up to 5 attempts). - Use a higher-entropy ID format to reduce collision probability (e.g.
STU-<timestamp-base36>-<4-random-hex-chars>). - Document the ID format and its uniqueness guarantees.
-
generateStudentIdretries up to 5 times if the generated ID already exists in the school. - After 5 failed attempts, it throws a descriptive error (
STUDENT_ID_GENERATION_FAILED) rather than a generic duplicate key error. - The generated ID format is documented in a comment.
- The generated ID is ≤ 28 characters (Stellar memo limit, enforced by the
studentIdfield'smaxlengthvalidator). - Unit tests cover: successful generation, single collision with retry, and exhausted retries.
- Existing
tests/generateStudentId.test.jstests continue to pass.
Issue 19: Frontend dashboard.jsx fetches all students without pagination — page freezes for schools with many students
Labels: performance, frontend
frontend/src/pages/dashboard.jsx fetches the student list from GET /api/students. The backend now supports pagination (page, limit query params), but the frontend does not use it — it fetches all students in a single request (defaulting to the backend's default of 50, but with no UI for loading more).
For schools with hundreds of students, the dashboard either:
- Shows only the first 50 students with no indication that more exist (silent data truncation).
- If the limit is raised, loads all students into memory and renders a very long list, causing browser jank.
Additionally, the dashboard's payment summary table (getPaymentSummary in studentController.js) fetches all students and all payments in memory to compute the summary — this is an O(n) memory operation that will degrade as the school grows.
- Paginate the student list in the dashboard: show 20 students per page with previous/next navigation.
- Add search and filter controls to the dashboard (the backend already supports
?search=,?class=,?status=filters). - Use the server-side
getPaymentSummaryendpoint instead of computing the summary client-side. - Virtualize the student list for very large schools (optional, lower priority).
- The dashboard student list is paginated: 20 students per page with page navigation controls.
- The dashboard shows the total student count and current page range (e.g. "Showing 1–20 of 347 students").
- Search by name or student ID is supported via the
?search=query parameter. - Filter by payment status (
paid,unpaid,partial) is supported via the?status=query parameter. - Filter by class is supported via the
?class=query parameter. - The dashboard does not load more than
limitstudent records into memory at once. - Page navigation is accessible (keyboard-navigable, ARIA labels on pagination controls).
- Unit tests in
tests/student.test.jscover the paginated student list response shape. - The frontend renders correctly when the student list is empty.
Issue 20: No end-to-end test covers the full payment flow from payment intent creation to blockchain sync
Labels: testing, quality
The test suite has separate unit tests for individual components (Stellar service, payment limits, student CRUD) but no single test that exercises the complete payment flow:
- Create a school and student.
- Create a payment intent.
- Submit a transaction (mocked Stellar network).
- Verify the transaction hash.
- Confirm the payment is recorded with correct
feeValidationStatus. - Sync payments from the blockchain.
- Assert the student's
feePaidstatus is updated.
tests/e2e-payment-flow.test.js exists but based on the file size (16 KB) it may not cover all steps, particularly the sync flow and the interaction between PaymentIntent, Payment, and Student models.
Without a full end-to-end test, regressions in the payment flow (like Issue 1's double-sync bug or Issue 4's memo encryption bug) can be introduced without any test failing.
Write a comprehensive end-to-end test that:
- Uses an in-memory MongoDB (via
mongodb-memory-server). - Mocks the Stellar SDK (
@stellar/stellar-sdk) to return controlled transaction data. - Exercises every step of the payment flow.
- Covers edge cases: overpayment, underpayment, duplicate transaction, expired payment intent, missing memo.
- A test file
tests/e2e-payment-flow.test.js(or an update to the existing one) covers all 7 steps listed above. - The test covers the following scenarios:
- Exact payment (valid)
- Overpayment (overpaid)
- Underpayment (rejected with
UNDERPAID) - Duplicate transaction hash (rejected with
DUPLICATE_TX) - Missing memo (rejected with
MISSING_MEMO) - Expired payment intent (rejected with
INTENT_EXPIRED) - Sync flow: new transaction on blockchain is matched to student and updates
feePaid
- All tests use mocked Stellar SDK — no real network calls.
- Tests run in under 30 seconds total.
- The test file is included in the CI workflow.
- All existing tests continue to pass.
End of issues.md — 20 issues total.