Repository navigation
Expand file tree
/
Copy pathpaymentController.js
More file actions
632 lines (568 loc) · 27.6 KB
/
Copy pathpaymentController.js
File metadata and controls
632 lines (568 loc) · 27.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
'use strict';
/**
* paymentController — core payment flow: instructions, intent, submit, verify.
* req.school and req.schoolId are injected by resolveSchool middleware.
*/
const crypto = require('crypto');
const Payment = require('../models/paymentModel');
const PaymentIntent = require('../models/paymentIntentModel');
const Student = require('../models/studentModel');
const StellarSdk = require('@stellar/stellar-sdk');
const {
verifyTransaction,
recordPayment,
validatePaymentWithDynamicFee,
determineConfirmationState,
} = require('../services/stellarService');
const { queueForRetry } = require('../services/retryService');
const { server } = require('../config/stellarConfig');
const { ACCEPTED_ASSETS } = require('../config/stellarConfig');
const { validateTransactionHash } = require('../utils/hashValidator');
const { getPaymentLimits, validatePaymentAmount } = require('../utils/paymentLimits');
const { SUPPORTED_MEMO_TYPES } = require('../utils/stellarMemo');
const { convertToLocalCurrency } = require('../services/currencyConversionService');
const { withStellarRetry } = require('../utils/withStellarRetry');
const { makePaymentAuditLogger } = require('../utils/paymentAuditLogger');
const lock = require('../services/distributedLock');
const logger = require('../utils/logger');
const { toMoney, decimalFromMongo, classifyFeePayment } = require('../utils/money');
// TTL for the per-school distributed verify lock. Verify is a fast Horizon
// round-trip; 30 s is more than enough while still auto-expiring on crash.
const VERIFY_LOCK_TTL_MS = parseInt(process.env.VERIFY_LOCK_TTL_MS || '30000', 10);
// TTL for the per-student balance lock (#1026). Guards the read-aggregate-then-write
// sequence that computes cumulativeTotal and writes it as an absolute Student.$set,
// which is not safe to run concurrently for the same student — whether the second
// writer is another verifyPayment call or syncPaymentsForSchool.
const STUDENT_BALANCE_LOCK_TTL_MS = parseInt(process.env.STUDENT_BALANCE_LOCK_TTL_MS || '15000', 10);
// Permanent error codes that should NOT be retried
const PERMANENT_FAIL_CODES = [
'TX_FAILED',
'MISSING_MEMO',
'INVALID_DESTINATION',
'UNSUPPORTED_ASSET',
'AMOUNT_TOO_LOW',
'AMOUNT_TOO_HIGH',
'UNDERPAID',
];
function getExplorerUrl(txHash) {
if (!txHash) return null;
const network = process.env.STELLAR_NETWORK === 'mainnet' ? 'public' : 'testnet';
return `https://stellar.expert/explorer/${network}/tx/${txHash}`;
}
function wrapStellarError(err) {
if (!err.code) {
err.code = 'STELLAR_NETWORK_ERROR';
err.message = `Stellar network error: ${err.message}`;
}
return err;
}
// ====================== PAYMENT INSTRUCTIONS ======================
async function getPaymentInstructions(req, res, next) {
try {
const targetCurrency = req.school.localCurrency || 'USD';
const { feeCategory, asset } = req.query;
if (asset) {
const assetCode = asset.split(':')[0];
if (!Object.keys(ACCEPTED_ASSETS).includes(assetCode)) {
const supportedAssets = Object.values(ACCEPTED_ASSETS).map((a) => ({ code: a.code, displayName: a.displayName }));
return res.status(400).json({ error: `Asset ${assetCode} is not accepted by this school`, code: 'ASSET_NOT_ACCEPTED', supportedAssets });
}
}
// Resolved after the asset is known so per-asset limits apply (#1117).
const limits = await getPaymentLimits({
schoolId: req.schoolId,
asset: asset ? asset.split(':')[0] : undefined,
});
const student = await Student.findOne({ schoolId: req.schoolId, studentId: req.params.studentId });
let feeAmount = student ? student.feeAmount : null;
let feeConversion = null;
let categoryInfo = null;
if (feeCategory && student?.fees?.length > 0) {
const fee = student.fees.find((f) => f.category === feeCategory);
if (fee) {
feeAmount = fee.amount;
categoryInfo = { category: fee.category, amount: fee.amount, paid: fee.paid, totalPaid: fee.totalPaid || 0, remainingBalance: fee.remainingBalance || fee.amount };
}
}
if (feeAmount) {
feeConversion = await convertToLocalCurrency(feeAmount, 'XLM', targetCurrency);
}
const fees = student?.fees?.length > 0
? student.fees.map((f) => ({ category: f.category, amount: f.amount, paid: f.paid, totalPaid: f.totalPaid || 0, remainingBalance: f.remainingBalance || f.amount }))
: [];
res.json({
walletAddress: req.school.stellarAddress,
memo: req.params.studentId,
acceptedAssets: Object.values(ACCEPTED_ASSETS).map((a) => ({ code: a.code, type: a.type, displayName: a.displayName, issuer: a.issuer ?? null })),
paymentLimits: { min: limits.min, max: limits.max },
minAmount: limits.min,
maxAmount: limits.max,
feeAmount,
feeCategory: feeCategory || null,
categoryInfo,
fees,
feeLocalEquivalent: feeConversion?.available
? { amount: feeConversion.localAmount, currency: feeConversion.currency, rate: feeConversion.rate, rateTimestamp: feeConversion.rateTimestamp }
: null,
note: 'Include the payment intent memo exactly when sending payment. MEMO_TEXT is recommended; wallets that cannot send text memos may use MEMO_ID or MEMO_HASH with the encoded form of the same reference. MEMO_RETURN is not recognised and such payments will not be matched.',
// `memoType` keeps its original 'text' spelling — it is part of the
// published API response and existing clients compare against it
// directly. New clients should read `supportedMemoTypes` (#1118).
memoType: 'text',
supportedMemoTypes: SUPPORTED_MEMO_TYPES,
});
} catch (err) {
next(err);
}
}
// ====================== PAYMENT INTENT ======================
async function createPaymentIntent(req, res, next) {
try {
const { schoolId } = req;
const { studentId, feeCategory } = req.body;
const student = await Student.findOne({ schoolId, studentId });
if (!student) return res.status(404).json({ error: 'Student not found', code: 'NOT_FOUND' });
let feeAmount = student.feeAmount;
let categoryInfo = null;
if (feeCategory && student.fees?.length > 0) {
const fee = student.fees.find((f) => f.category === feeCategory);
if (!fee) return res.status(400).json({ error: `Fee category '${feeCategory}' not found for student`, code: 'INVALID_FEE_CATEGORY' });
feeAmount = fee.amount;
categoryInfo = { category: fee.category, amount: fee.amount, paid: fee.paid, totalPaid: fee.totalPaid || 0, remainingBalance: fee.remainingBalance || fee.amount };
}
const limitValidation = await validatePaymentAmount(feeAmount, { schoolId });
if (!limitValidation.valid) return res.status(400).json({ error: limitValidation.error, code: limitValidation.code });
const memo = crypto.randomBytes(4).toString('hex').toUpperCase();
const ttlMs = parseInt(process.env.PAYMENT_INTENT_TTL_MS, 10) || 24 * 60 * 60 * 1000;
const intent = await PaymentIntent.create({
schoolId,
studentId,
amount: feeAmount,
feeCategory: feeCategory || null,
memo,
status: 'pending',
expiresAt: new Date(Date.now() + ttlMs),
startedAt: new Date(),
});
const intentObj = intent.toObject();
res.status(201).json({
...intentObj,
// Issue #1035: Include intentId in response so client can pass it back in submitTransaction
// to ensure the submission is matched to the correct PENDING record
intentId: intent._id.toString(),
categoryInfo,
});
} catch (err) {
if (err.code === 11000) {
return res.status(409).json({ error: 'A payment intent with that memo already exists for this school', code: 'DUPLICATE_PAYMENT_INTENT' });
}
next(err);
}
}
// ====================== SUBMIT XDR TRANSACTION ======================
async function submitTransaction(req, res, next) {
try {
const { xdr, paymentIntentId } = req.body;
if (!xdr) return res.status(400).json({ error: 'Missing xdr parameter' });
const tx = new StellarSdk.Transaction(xdr, require('../config/stellarConfig').networkPassphrase);
const transactionHash = tx.hash().toString('hex');
const hashValidation = validateTransactionHash(transactionHash);
if (!hashValidation.valid) {
const err = new Error(hashValidation.error);
err.code = hashValidation.code;
return next(err);
}
const normalizedHash = hashValidation.normalized;
const memo = tx.memo.value ? tx.memo.value.toString() : null;
if (!memo) return res.status(400).json({ error: 'Transaction must include the student ID as a memo' });
// Issue #1035: Match the specific PENDING record via paymentIntentId when provided
// to avoid misattribution if multiple PENDING records exist for the same memo.
// When paymentIntentId is not provided, fall back to matching by memo (backward compatibility).
let paymentRecord;
if (paymentIntentId) {
const intent = await PaymentIntent.findById(paymentIntentId);
if (!intent) {
return res.status(404).json({ error: 'Payment intent not found', code: 'INTENT_NOT_FOUND' });
}
if (intent.schoolId !== req.schoolId) {
return res.status(403).json({ error: 'Payment intent does not belong to this school', code: 'INTENT_MISMATCH' });
}
if (intent.status !== 'pending') {
return res.status(400).json({ error: `Payment intent is not in pending status (status: ${intent.status})`, code: 'INTENT_NOT_PENDING' });
}
// Look for a PENDING payment record that corresponds to this intent
paymentRecord = await Payment.findOne({
schoolId: req.schoolId,
studentId: intent.studentId,
memo: intent.memo,
status: 'PENDING',
});
} else {
// Backward compatibility: match by memo alone (highest risk path)
paymentRecord = await Payment.findOne({ schoolId: req.schoolId, memo, status: 'PENDING' }).sort({ createdAt: -1 });
}
if (!paymentRecord) {
const studentObj = await Student.findOne({ schoolId: req.schoolId, studentId: memo });
if (!studentObj) return res.status(404).json({ error: 'Associated student not found in the database. Cannot process transaction.' });
paymentRecord = new Payment({ schoolId: req.schoolId, studentId: studentObj.studentId || memo, memo, amount: 0 });
}
paymentRecord.transactionHash = normalizedHash;
paymentRecord.status = 'SUBMITTED';
paymentRecord.submittedAt = new Date();
await paymentRecord.save();
let txResponse;
try {
txResponse = await withStellarRetry(() => server.submitTransaction(tx), { label: 'submitTransaction' });
} catch (err) {
paymentRecord.status = 'FAILED';
paymentRecord.suspicionReason = err.response?.data?.extras?.result_codes?.transaction ?? err.message;
await paymentRecord.save();
return res.status(400).json({ error: 'Transaction submission failed', code: paymentRecord.suspicionReason });
}
if (!txResponse.successful) {
paymentRecord.status = 'FAILED';
paymentRecord.confirmationStatus = 'failed';
paymentRecord.suspicionReason = 'Transaction was included in ledger but failed on-chain';
await paymentRecord.save();
return res.status(400).json({ error: 'Transaction was included in the ledger but failed on-chain', code: 'TX_FAILED', hash: transactionHash });
}
paymentRecord.status = 'SUCCESS';
paymentRecord.confirmedAt = new Date();
paymentRecord.ledgerSequence = txResponse.ledger;
await paymentRecord.save();
// Issue #1034: Update student balance fields using the unified balance updater.
// This ensures submitTransaction produces the same Student state updates as
// verifyPayment and syncPaymentsForSchool, eliminating divergence.
const { updateStudentBalance } = require('../utils/studentBalanceUpdater');
try {
await updateStudentBalance(req.schoolId, paymentRecord.studentId, {});
} catch (balanceErr) {
logger.error('Failed to update student balance after submitTransaction', {
schoolId: req.schoolId,
studentId: paymentRecord.studentId,
txHash: normalizedHash,
error: balanceErr.message,
});
}
const network = process.env.STELLAR_NETWORK === 'mainnet' ? 'public' : 'testnet';
res.json({
verified: true,
hash: normalizedHash,
ledger: txResponse.ledger,
status: 'SUCCESS',
explorerUrl: `https://stellar.expert/explorer/${network}/tx/${transactionHash}`,
});
} catch (err) {
next(err);
}
}
// ====================== VERIFY PAYMENT ======================
async function verifyPayment(req, res, next) {
const startTime = Date.now();
try {
const { schoolId } = req;
const { txHash } = req.body;
if (!txHash) {
const audit = makePaymentAuditLogger(req, schoolId, `missing-tx:${schoolId}`);
await audit.failure('txHash is required', { receivedKeys: Object.keys(req.body || {}) });
return res.status(400).json({ error: 'txHash is required', code: 'VALIDATION_ERROR' });
}
const hashValidation = validateTransactionHash(txHash);
if (!hashValidation.valid) {
const audit = makePaymentAuditLogger(req, schoolId, txHash);
await audit.failure(hashValidation.error, { txHash, validationError: hashValidation.error });
const err = new Error(hashValidation.error);
err.code = hashValidation.code;
return next(err);
}
const normalizedHash = hashValidation.normalized;
const audit = makePaymentAuditLogger(req, schoolId, normalizedHash);
// Idempotency — return cached result if already recorded
const existing = await Payment.findOne({ schoolId, txHash: normalizedHash });
if (existing) {
await audit.success({ txHash: normalizedHash, cached: true, studentId: existing.studentId, amount: existing.amount });
const targetCurrency = req.school.localCurrency || 'USD';
const conversion = await convertToLocalCurrency(existing.amount, existing.assetCode || 'XLM', targetCurrency);
const stellarExplorerUrl = getExplorerUrl(existing.txHash);
return res.json({
verified: true,
cached: true,
hash: existing.txHash,
stellarExplorerUrl,
explorerUrl: stellarExplorerUrl,
memo: existing.memo,
studentId: existing.studentId,
amount: existing.amount,
assetCode: existing.assetCode,
assetType: existing.assetType,
feeAmount: existing.feeAmount,
feeValidation: { status: existing.feeValidationStatus, excessAmount: existing.excessAmount },
networkFee: existing.networkFee || null,
date: existing.confirmedAt || existing.createdAt,
status: existing.status,
confirmationStatus: existing.confirmationStatus,
localCurrency: {
amount: conversion.available ? conversion.localAmount : null,
currency: conversion.currency,
rate: conversion.rate,
rateTimestamp: conversion.rateTimestamp,
available: conversion.available,
},
});
}
// Issue #69 — distributed lock prevents two replicas from concurrently
// verifying the same transaction, which would risk a double-write race even
// though the unique index on Payment { schoolId, txHash } is the ultimate
// dedup guard. The lock is keyed per (school, txHash) so only identical
// concurrent verify calls contend — different hashes proceed in parallel.
const verifyLockKey = `sync:lock:${schoolId}:verify:${normalizedHash}`;
// acquire() returns { token, fencingToken } (or null); release() needs the
// raw token string, so destructure it — otherwise the lock never releases.
const verifyLockInfo = await lock.acquire(verifyLockKey, VERIFY_LOCK_TTL_MS);
if (!verifyLockInfo) {
return res.status(409).json({ error: 'Sync already in progress', code: 'SYNC_IN_PROGRESS' });
}
const verifyToken = verifyLockInfo.token;
try {
let result;
try {
// Pass schoolId so verifyTransaction scopes the student lookup to this school (#845).
result = await verifyTransaction(normalizedHash, req.school.stellarAddress, schoolId);
} catch (stellarErr) {
if (PERMANENT_FAIL_CODES.includes(stellarErr.code)) {
await audit.failure(stellarErr.message, { txHash: normalizedHash, errorCode: stellarErr.code });
await Payment.create({ schoolId, studentId: 'unknown', txHash: normalizedHash, amount: 0, status: 'FAILED', feeValidationStatus: 'unknown' }).catch(err => logger.error('[PaymentController] failed to persist permanent failure record', { txHash: normalizedHash, error: err.message }));
return next(stellarErr);
}
await audit.success({ txHash: normalizedHash, queuedForRetry: true, reason: stellarErr.message });
await queueForRetry(normalizedHash, req.body.studentId || null, stellarErr.message, schoolId);
return res.status(202).json({
message: 'Stellar network is temporarily unavailable. Your transaction has been queued and will be verified automatically.',
txHash: normalizedHash,
status: 'queued_for_retry',
});
}
if (!result) {
await audit.failure('Transaction found but contains no valid payment to this school wallet', { txHash: normalizedHash });
return res.status(404).json({ error: 'Transaction found but contains no valid payment to this school wallet', code: 'NOT_FOUND' });
}
const studentStrId = result.studentId || result.memo;
const studentObj = await Student.findOne({ schoolId, studentId: studentStrId });
if (!studentObj) {
await audit.failure('Associated student not found', { txHash: normalizedHash, studentId: studentStrId });
return res.status(404).json({ error: 'Associated student not found. Cannot record transaction.' });
}
// Intents are a UX convenience; an expired intent must not block crediting (#848).
// Mark it expired for bookkeeping but continue to record the payment.
const intent = await PaymentIntent.findOne({ memo: result.memo, schoolId });
if (intent?.expiresAt && intent.expiresAt < new Date()) {
await PaymentIntent.findByIdAndUpdate(intent._id, { status: 'expired' });
}
// Issue #1026 — the tx-hash lock above only prevents two calls for the SAME
// transaction from racing; two DIFFERENT transactions for the same student
// can still verify concurrently. The balance write below is a non-atomic
// read-aggregate-then-absolute-write, so it also needs a lock scoped to the
// student, shared with syncPaymentsForSchool's equivalent section so the two
// code paths serialize against each other too.
const studentLockKey = lock.studentBalanceLockKey(schoolId, studentStrId);
const studentLockInfo = await lock.acquire(studentLockKey, STUDENT_BALANCE_LOCK_TTL_MS);
if (!studentLockInfo) {
return res.status(409).json({ error: 'Sync already in progress', code: 'SYNC_IN_PROGRESS' });
}
const studentLockToken = studentLockInfo.token;
let cumulativeTotal;
let feeValidationStatus;
let computedExcessAmount;
let remainingBalance;
let now;
try {
// Determine cumulative feeValidationStatus (#846).
// Partial payments (amount < fee) are accepted — money is already on-chain.
// The cumulative total drives the status; per-payment underpaid rejection
// is not applied here because a parent may be paying in installments.
//
// Summed via $toDecimal (#1123 money-representation fix) so the addition
// happens in exact Decimal128 space inside MongoDB — a plain `$sum` over
// the `amount` doubles would accumulate float error server-side, before
// the result ever reaches this function, and toFixed(7) cannot repair
// that afterwards. classifyFeePayment then compares in exact Decimal
// space, so an installment plan that sums exactly to the fee is never
// misclassified by a float epsilon.
const prevAgg = await Payment.aggregate([
{ $match: { schoolId, studentId: studentStrId, deletedAt: null, status: 'SUCCESS' } },
{ $group: { _id: null, total: { $sum: { $toDecimal: '$amount' } } } },
]);
const prevTotal = prevAgg.length ? decimalFromMongo(prevAgg[0].total) : toMoney(0);
const classification = classifyFeePayment(prevTotal.plus(toMoney(result.amount)), studentObj.feeAmount);
cumulativeTotal = classification.cumulativeTotal;
feeValidationStatus = classification.status;
computedExcessAmount = classification.excessAmount;
remainingBalance = classification.remainingBalance;
now = new Date();
// Issue #1027: Compute confirmationState using the same logic as syncPaymentsForSchool
// to ensure manual verify produces consistent state machine values
const confirmation = await determineConfirmationState(
result.ledger || null,
undefined,
false,
);
try {
await recordPayment({
schoolId,
studentId: studentStrId,
txHash: result.hash,
amount: result.amount,
feeAmount: result.feeAmount || studentObj.feeAmount,
feeValidationStatus,
excessAmount: computedExcessAmount,
networkFee: result.networkFee,
status: 'SUCCESS',
memo: result.memo,
senderAddress: result.senderAddress || null,
ledgerSequence: result.ledger || null,
confirmationStatus: confirmation.confirmationStatus,
confirmationState: confirmation.state,
confirmedAt: result.date ? new Date(result.date) : now,
verifiedAt: now,
});
} catch (dupErr) {
if (dupErr.code === 'DUPLICATE_TX') {
// Concurrent path (poller or another verify call) already recorded this tx.
// Fetch and return the existing record as a cache hit.
const cached = await Payment.findOne({ schoolId, txHash: normalizedHash });
if (cached) {
await audit.success({ txHash: normalizedHash, cached: true, studentId: studentStrId, amount: cached.amount });
const targetCurrency = req.school.localCurrency || 'USD';
const cachedConv = await convertToLocalCurrency(cached.amount, cached.assetCode || 'XLM', targetCurrency);
return res.json({
verified: true, cached: true, hash: cached.txHash,
stellarExplorerUrl: getExplorerUrl(cached.txHash), explorerUrl: getExplorerUrl(cached.txHash),
memo: cached.memo, studentId: cached.studentId, amount: cached.amount,
assetCode: cached.assetCode, feeAmount: cached.feeAmount,
feeValidation: { status: cached.feeValidationStatus, excessAmount: cached.excessAmount },
date: cached.confirmedAt || cached.createdAt, status: cached.status,
localCurrency: {
amount: cachedConv.available ? cachedConv.localAmount : null,
currency: cachedConv.currency, rate: cachedConv.rate,
rateTimestamp: cachedConv.rateTimestamp, available: cachedConv.available,
},
});
}
}
throw dupErr;
}
// Issue #1034: Update student balance using the unified balance updater.
// This ensures verifyPayment, submitTransaction, and syncPaymentsForSchool
// all use the same balance computation logic.
const { updateStudentBalance } = require('../utils/studentBalanceUpdater');
await updateStudentBalance(schoolId, studentStrId, {});
} finally {
await lock.release(studentLockKey, studentLockToken);
}
await audit.success({
txHash: normalizedHash,
studentId: studentStrId,
amount: result.amount,
assetCode: result.assetCode || 'XLM',
feeValidationStatus,
duration: `${Date.now() - startTime}ms`,
});
// Auto-generate receipt (fire-and-forget)
const { createReceipt } = require('../services/receiptService');
createReceipt({
txHash: result.hash,
studentId: studentStrId,
schoolId,
amount: result.amount,
assetCode: result.assetCode || 'XLM',
feeAmount: result.feeAmount || studentObj.feeAmount,
feeValidationStatus,
memo: result.memo,
confirmedAt: result.date ? new Date(result.date) : now,
}).catch(err => {
// Fire-and-forget, but never silent (#1122). A systemic receipt failure
// (bad template, receipt-model fault, downstream outage) must leave a
// log trail and a metric, not wait for a parent to report it missing.
logger.error('[PaymentController] receipt generation failed', {
txHash: result.hash,
correlationId: req.correlationId,
schoolId,
studentId: studentStrId,
error: err.message,
stack: err.stack,
});
try {
require('../metrics').receiptGenerationFailuresTotal.inc({ source: 'verify_controller' });
} catch (_) {
// metrics module unavailable — logging above is still the primary signal
}
});
const targetCurrency = req.school.localCurrency || 'USD';
const conversion = await convertToLocalCurrency(result.amount, result.assetCode || 'XLM', targetCurrency);
const stellarExplorerUrl = getExplorerUrl(result.hash);
res.json({
verified: true,
cached: false,
hash: result.hash,
stellarExplorerUrl,
explorerUrl: stellarExplorerUrl,
memo: result.memo,
studentId: studentStrId,
amount: result.amount,
assetCode: result.assetCode,
assetType: result.assetType,
feeAmount: result.feeAmount || studentObj.feeAmount,
feeValidation: { status: feeValidationStatus, excessAmount: computedExcessAmount },
remainingBalance,
networkFee: result.networkFee,
date: result.date,
localCurrency: {
amount: conversion.available ? conversion.localAmount : null,
currency: conversion.currency,
rate: conversion.rate,
rateTimestamp: conversion.rateTimestamp,
available: conversion.available,
},
});
} finally {
await lock.release(verifyLockKey, verifyToken);
}
} catch (err) {
await makePaymentAuditLogger(req, req.schoolId, req.body?.txHash || 'unknown')
.failure(err.message, { error: err.message })
.catch(() => logger.debug('[PaymentController] audit failure log missed', { txHash: req.body?.txHash || 'unknown' }));
next(err);
}
}
// ====================== VERIFY TX HASH (no school context) ======================
async function verifyTransactionHash(req, res, next) {
try {
const { txHash } = req.params;
const tx = await server.transactions().transaction(txHash).call();
res.json({
hash: tx.hash,
successful: tx.successful,
created_at: tx.created_at,
ledger: tx.ledger_attr || tx.ledger,
memo: tx.memo,
fee_paid: tx.fee_paid,
source_account: tx.source_account,
operations_count: tx.operation_count,
});
} catch (err) {
if (err.response?.status === 404) return res.status(404).json({ error: 'Transaction not found', code: 'NOT_FOUND' });
next(wrapStellarError(err));
}
}
module.exports = {
getPaymentInstructions,
createPaymentIntent,
submitTransaction,
verifyPayment,
verifyTransactionHash,
getExplorerUrl,
wrapStellarError,
// Re-export from split controllers so tests importing paymentController still work
...require('./paymentQueryController'),
...require('./paymentAdminController'),
};