StellarEduPay is a three-tier application: a Next.js frontend, a Node.js/Express backend, and the Stellar blockchain as the payment ledger. MongoDB stores student records and payment metadata; the Stellar Horizon API is the authoritative source for transaction data.
- High-Level Overview
- Component Diagram
- Data Flow: Payment Initiation to Confirmation
- Backend Services
- Controllers
- Middleware
- MongoDB Schema Relationships
- Background Workers
- Multi-School Tenancy
- Error Handling and Resilience
┌─────────────────────────────────────────────────────────────┐
│ Parent / Admin Browser │
└────────────────────────┬────────────────────────────────────┘
│ HTTPS
▼
┌─────────────────────────────────────────────────────────────┐
│ Next.js Frontend (React) │
│ PaymentForm · VerifyPayment · Dashboard · Reports │
└────────────────────────┬────────────────────────────────────┘
│ REST API (JSON)
▼
┌─────────────────────────────────────────────────────────────┐
│ Express.js Backend (Node.js) │
│ │
│ Routes → Controllers → Services → Models │
│ │
│ Background workers: │
│ transactionService (polling) │
│ retryService (outage recovery) │
│ consistencyScheduler (drift detection) │
│ reminderService (fee reminders) │
└──────────────┬──────────────────────────┬───────────────────┘
│ │
▼ ▼
┌──────────────────────┐ ┌─────────────────────────────────┐
│ MongoDB │ │ Stellar Horizon API │
│ │ │ │
│ School │ │ Transaction ledger │
│ Student │ │ Account operations │
│ Payment │ │ Ledger sequence │
│ PaymentIntent │ │ │
│ FeeStructure │ │ testnet.stellar.org (dev) │
│ PendingVerification│ │ horizon.stellar.org (prod) │
│ Dispute │ └─────────────────────────────────┘
└──────────────────────┘
frontend/src/
├── pages/
│ ├── pay-fees.jsx ← PaymentForm + VerifyPayment
│ ├── dashboard.jsx ← student list, sync button
│ └── reports.jsx ← report download
├── components/
│ ├── PaymentForm.jsx ← student lookup + instructions + history
│ ├── VerifyPayment.jsx ← tx hash verification UI
│ └── TransactionCard.jsx ← single payment display + dispute flag
└── services/
└── api.js ← axios client, all API calls
backend/src/
├── app.js ← Express setup, route mounting, startup
├── config/
│ ├── index.js ← env var loading and validation
│ └── stellarConfig.js ← Horizon server, accepted assets
├── routes/ ← thin route files, map HTTP → controller
├── controllers/ ← request/response handling
├── services/ ← business logic (no HTTP concerns)
├── models/ ← Mongoose schemas
├── middleware/ ← auth, validation, rate limiting, idempotency
└── utils/ ← paymentLimits, withStellarRetry, logger
POST /api/fees
→ feeController.createFeeStructure
→ FeeStructure.create({ schoolId, className, feeAmount, academicYear })
POST /api/students
→ studentController.createStudent
→ FeeStructure.findOne({ schoolId, className }) ← auto-assigns feeAmount
→ Student.create({ schoolId, studentId, name, class, feeAmount })
GET /api/payments/instructions/:studentId
→ paymentController.getPaymentInstructions
→ PaymentIntent.create({ schoolId, studentId, memo: studentId, amount, expiresAt })
→ returns { walletAddress, memo, acceptedAssets, paymentLimits }
The parent's Stellar wallet sends XLM or USDC to the school wallet address with the student ID as the transaction memo.
POST /api/payments/sync (or automatic via transactionService every 30s)
→ stellarService.syncPaymentsForSchool(school)
│
├─ Horizon: fetch latest 200 txs for school wallet (newest first)
│
├─ For each tx:
│ ├─ Skip if txHash already in Payment collection
│ ├─ extractValidPayment: check tx.successful, memo present,
│ │ payment op targets school wallet, asset is accepted
│ ├─ Match memo → PaymentIntent (schoolId + memo + status:pending)
│ ├─ Validate amount against global limits (MIN/MAX_PAYMENT_AMOUNT)
│ ├─ Calculate cumulative payment total for partial payments
│ ├─ detectMemoCollision: flag if same memo used by different sender
│ ├─ checkConfirmationStatus: compare tx ledger vs latest ledger
│ └─ Payment.create(...)
│
└─ If confirmed + not suspicious:
├─ Student.update({ totalPaid, feePaid })
└─ PaymentIntent.update({ status: 'completed' })
POST /api/payments/verify { txHash }
→ paymentController.verifyPayment
→ stellarService.verifyTransaction(txHash, walletAddress)
├─ Fetch tx from Horizon
├─ Validate: successful, memo present, correct destination, accepted asset
├─ Compare amount against student fee
└─ Return { hash, memo, amount, feeValidation, date, networkFee }
Admin creates fee → Admin registers student → Parent gets instructions
│
Parent sends XLM/USDC on Stellar
│
┌───────────▼────────────┐
│ Stellar Blockchain │
│ (3-5 second finality) │
└───────────┬────────────┘
│
Background poller (30s interval)
or POST /api/payments/sync
│
stellarService.syncPaymentsForSchool
│
┌───────────▼────────────┐
│ MongoDB │
│ Payment created │
│ Student.feePaid=true │
└────────────────────────┘
The core blockchain integration layer. All Horizon API calls go through here.
| Function | Purpose |
|---|---|
syncPaymentsForSchool(school) |
Fetches and processes recent transactions for one school wallet |
verifyTransaction(txHash, walletAddress) |
Validates a single transaction by hash; returns structured result |
extractValidPayment(tx, walletAddress) |
Checks tx success, memo, payment op, and asset acceptance |
validatePaymentAgainstFee(amount, fee) |
Returns valid / overpaid / underpaid with excess amount |
detectMemoCollision(...) |
Flags if the same memo was used by a different sender within 24h |
detectAbnormalPatterns(...) |
Flags velocity abuse (>3 txs in 10 min) and unusual amounts |
checkConfirmationStatus(txLedger) |
Compares tx ledger against latest ledger sequence |
finalizeConfirmedPayments(schoolId) |
Promotes pending_confirmation payments once threshold is met |
recordPayment(data) |
Persists a payment, enforces uniqueness on txHash |
All Horizon calls are wrapped in withStellarRetry (exponential backoff, retries on 429/5xx/network errors).
Background polling service. Runs on startup and calls syncPaymentsForSchool for every active school in parallel on a fixed interval (POLL_INTERVAL_MS, default 30s).
startPolling()
└─ setInterval(async () => {
schools = School.find({ isActive: true })
Promise.allSettled(schools.map(syncPaymentsForSchool))
}, 30_000)
Errors for individual schools are logged but do not stop polling for other schools.
Outage recovery worker. When a Stellar network call fails with a transient error, the transaction hash is stored as a PendingVerification document. This service runs on RETRY_INTERVAL_MS (default 60s), checks network reachability, and re-attempts verification with exponential backoff (1m → 2m → 4m … capped at 60m).
Permanent errors (TX_FAILED, MISSING_MEMO, INVALID_DESTINATION, UNSUPPORTED_ASSET, DUPLICATE_TX) are moved to dead_letter status immediately without retrying.
Drift detection. Compares Payment documents in MongoDB against the last 200 on-chain transactions and reports mismatches:
missing_on_chain— payment in DB but not found on Stellaramount_mismatch— DB amount differs from on-chain amountstudent_mismatch— DBstudentIddoesn't match the transaction memo
Triggered via GET /api/consistency or the consistencyScheduler background job.
Fee reminder scheduler. Finds students with unpaid fees and a parentEmail, respects reminderCooldownHours and reminderMaxCount per student, and sends emails via nodemailer (SMTP configured in .env).
| Controller | Routes | Responsibility |
|---|---|---|
paymentController |
/api/payments/* |
Instructions, verify, sync, payment history |
studentController |
/api/students/* |
CRUD, auto fee assignment from FeeStructure |
feeController |
/api/fees/* |
Fee structure CRUD |
schoolController |
/api/schools/* |
School registration and management |
reportController |
/api/reports/* |
Payment reports, CSV export |
disputeController |
/api/disputes/* |
Flag and resolve payment disputes |
healthController |
/health |
Liveness check (used by Docker HEALTHCHECK) |
consistencyController |
/api/consistency |
On-demand consistency check |
Controllers are thin — they validate input, call a service, and return a response. Business logic lives in services.
| Middleware | Purpose |
|---|---|
auth.js |
JWT verification for admin routes |
validate.js |
Joi schema validation on request bodies |
schoolContext.js |
Resolves X-School-ID or X-School-Slug header to a School document |
idempotency.js |
Deduplicates requests using Idempotency-Key header |
rateLimiter.js |
Per-IP rate limiting |
concurrentRequestHandler.js |
Circuit breaker + request queue for Horizon call bursts |
errorHandler.js |
Maps error codes to HTTP status codes, formats JSON error responses |
requestLogger.js |
Structured request/response logging |
School
│ schoolId (PK)
│ stellarAddress
│ slug
│
├──< FeeStructure
│ schoolId (FK)
│ className ─────────────────────────────┐
│ feeAmount │
│ │ auto-assigned on student create
├──< Student │
│ schoolId (FK) │
│ studentId (unique per school) │
│ class ─────────────────────────────────┘
│ feeAmount
│ feePaid
│ totalPaid
│
├──< PaymentIntent
│ schoolId (FK)
│ studentId
│ memo (= studentId, unique)
│ status: pending | completed | expired
│ expiresAt
│
├──< Payment
│ schoolId (FK)
│ studentId
│ txHash (globally unique)
│ amount
│ feeValidationStatus: valid | underpaid | overpaid | unknown
│ confirmationStatus: pending_confirmation | confirmed | failed
│ isSuspicious
│ confirmedAt
│
└──< PendingVerification
schoolId (FK)
txHash
status: pending | processing | resolved | dead_letter
attempts
nextRetryAt
txHashis globally unique across all schools — a transaction can only be recorded once.studentIdis unique per school ({ studentId, schoolId }compound unique index).PaymentIntent.memois unique — prevents duplicate intents for the same student.FeeStructure.classNameis unique per school ({ schoolId, className }compound unique index).- Payments and Students use soft delete (
deletedAtfield) — records are never hard-deleted. - Payment audit trail is immutable once
statusreachesSUCCESSorFAILED.
All workers start on server boot inside the mongoose.connect().then(...) callback in app.js and shut down gracefully on SIGTERM/SIGINT.
| Worker | Start function | Interval | Purpose |
|---|---|---|---|
| Transaction poller | startPolling() |
POLL_INTERVAL_MS (30s) |
Sync new payments from Stellar |
| Retry worker | startRetryWorker() |
RETRY_INTERVAL_MS (60s) |
Re-attempt failed verifications |
| Consistency scheduler | startConsistencyScheduler() |
configurable | Detect DB/chain drift |
| Reminder scheduler | startReminderScheduler() |
REMINDER_INTERVAL_MS (24h) |
Send fee reminder emails |
| TX queue worker | startTxQueueWorker() |
event-driven (BullMQ) | Process queued transactions via Redis |
Every document in MongoDB carries a schoolId field. The schoolContext middleware resolves the school from the X-School-ID or X-School-Slug request header and attaches it to req.school. All queries are scoped to req.school.schoolId.
Each school has its own stellarAddress. The transaction poller fans out to all active schools in parallel. There is no shared wallet.
- All Horizon API calls use
withStellarRetry— exponential backoff with jitter, retries on transient network errors and HTTP 429/5xx. - Transient failures during sync are queued as
PendingVerificationdocuments and retried byretryService. - The
concurrentRequestHandlermiddleware adds a circuit breaker (opens after 5 failures, resets after 30s) and a request queue (max 50 concurrent, max 1000 queued) to protect against Horizon API bursts. - Idempotency keys prevent duplicate payment processing from retried HTTP requests.
- Graceful shutdown waits up to 8s for the retry worker to finish its current batch before closing the MongoDB connection.
CSP is enforced at two distinct layers, each appropriate to what it serves.
The browser-facing CSP is configured in frontend/next.config.js via the headers() function. It applies to every route (/(.*)):
| Directive | Value | Reason |
|---|---|---|
default-src |
'self' |
Baseline: only same-origin resources |
script-src |
'self' |
No inline scripts, no eval |
style-src |
'self' |
No inline styles |
img-src |
'self' data: |
Allows base64 data URIs for QR codes |
font-src |
'self' |
Same-origin fonts only |
connect-src |
'self' https://horizon-testnet.stellar.org https://horizon.stellar.org |
Allows fetch to the backend API and Stellar Horizon |
object-src |
'none' |
Blocks Flash and plugins |
frame-ancestors |
'none' |
Prevents clickjacking |
base-uri |
'self' |
Prevents base tag injection |
form-action |
'self' |
Restricts form submissions |
'unsafe-inline' and 'unsafe-eval' are intentionally absent.
The backend serves only JSON API responses — it never renders HTML, loads scripts, or applies styles. HTML-oriented CSP directives (scriptSrc, styleSrc, imgSrc, etc.) are therefore meaningless here and have been removed.
The backend Helmet CSP is intentionally minimal:
contentSecurityPolicy: {
directives: {
defaultSrc: ["'none'"], // deny everything by default
frameAncestors: ["'none'"], // prevent embedding in iframes
},
}This follows the principle of least privilege: the backend declares that no browser should ever render its responses as a document.