mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-11-14 11:03:53 +01:00
A browser-based roguelike synchronized with crypto block timing. A run is a race against the next block: reach the exit before the block lands, or the house wins. The same codebase runs the solo dungeon, the social Tavern, and operator-selected PvP and race rulesets, with separate free and paid leaderboards. Self-hostable under the MIT License. See README.md to run it, docs/ARCHITECTURE.md for the design, and docs/DEPLOY.md to operate an instance.
635 lines
29 KiB
JavaScript
635 lines
29 KiB
JavaScript
const { AppError, NotFoundError } = require('../utils/errors');
|
|
|
|
const MAX_ERROR_LENGTH = 2000;
|
|
const TX_HASH_PATTERN = /^[0-9a-f]{64}$/i;
|
|
|
|
function parseAtomic(value, fieldName) {
|
|
try {
|
|
const parsed = BigInt(value == null ? 0 : value);
|
|
if (parsed < 0n) {
|
|
throw new Error('negative value');
|
|
}
|
|
return parsed;
|
|
} catch (error) {
|
|
throw new AppError(`Invalid ${fieldName} stored for payment refund`, {
|
|
code: 'INVALID_REFUND_DATA',
|
|
safeMessage: 'The payment record cannot be refunded automatically.'
|
|
});
|
|
}
|
|
}
|
|
|
|
function jsonInteger(value) {
|
|
const parsed = typeof value === 'bigint' ? value : BigInt(value || 0);
|
|
return parsed <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(parsed) : parsed.toString();
|
|
}
|
|
|
|
function errorMessage(error) {
|
|
const message = error && typeof error.message === 'string'
|
|
? error.message
|
|
: 'Wallet returned an ambiguous refund result.';
|
|
return message.slice(0, MAX_ERROR_LENGTH) || 'Wallet returned an ambiguous refund result.';
|
|
}
|
|
|
|
function jsonObject(value) {
|
|
if (value && typeof value === 'object') return value;
|
|
if (typeof value !== 'string' || !value.trim()) return {};
|
|
try {
|
|
const parsed = JSON.parse(value);
|
|
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
} catch (_) {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function packIds(value) {
|
|
const source = Array.isArray(value) ? value : [];
|
|
return [...new Set(source.map(item => {
|
|
if (typeof item === 'string') return item.trim();
|
|
return String(item?.id || item?.packId || item?.pack_id || '').trim();
|
|
}).filter(Boolean))];
|
|
}
|
|
|
|
/**
|
|
* Durable payment-refund state machine.
|
|
*
|
|
* Safety invariant: a wallet transfer is attempted only by the request that atomically
|
|
* changes `requested` to `processing`. No code path changes `processing` back to
|
|
* `requested`, so a crash or timeout can never cause an automatic second transfer.
|
|
*/
|
|
class PaymentRefundService {
|
|
constructor({ db, walletService, logger = console, isSendEnabled = null } = {}) {
|
|
if (!db) throw new Error('PaymentRefundService requires db');
|
|
this.db = db;
|
|
this.walletService = walletService;
|
|
this.logger = logger;
|
|
// The application injects its single payout master/readiness predicate. Keep the
|
|
// service independently testable, but never let an admin refund become a second wallet
|
|
// transfer path that bypasses PAYOUTS_ENABLED, startup recovery, or wallet health.
|
|
this.isSendEnabled = typeof isSendEnabled === 'function' ? isSendEnabled : () => true;
|
|
}
|
|
|
|
_assertSendEnabled() {
|
|
if (!this.isSendEnabled()) {
|
|
throw new AppError('Automatic refund transfer is disabled by the payout safety gate.', {
|
|
statusCode: 503,
|
|
code: 'PAYOUT_DISPATCH_DISABLED',
|
|
safeMessage: 'Automatic refund sending is disabled. Record the refund without sending funds, or restore the payout safety gate first.'
|
|
});
|
|
}
|
|
}
|
|
|
|
async refundPayment({ paymentId, reason = 'Admin refund', sendFunds = false }) {
|
|
if (sendFunds === true) this._assertSendEnabled();
|
|
const normalizedReason = typeof reason === 'string' && reason.trim()
|
|
? reason.trim()
|
|
: 'Admin refund';
|
|
|
|
const recorded = await this._recordOnce({
|
|
paymentId,
|
|
reason: normalizedReason,
|
|
sendFunds: sendFunds === true
|
|
});
|
|
|
|
let refund = recorded.refund;
|
|
let walletClaimed = false;
|
|
|
|
// A recorded-only refund is terminal. An existing requested row may be safely
|
|
// claimed after a crash that happened before any wallet call was started.
|
|
if (sendFunds === true && refund.status === 'requested') {
|
|
const attempt = await this._claimAndSend(paymentId);
|
|
refund = attempt.refund;
|
|
walletClaimed = attempt.claimed;
|
|
}
|
|
|
|
return {
|
|
payment: recorded.payment,
|
|
refund,
|
|
existing: recorded.existing,
|
|
legacyImported: recorded.legacyImported,
|
|
walletClaimed
|
|
};
|
|
}
|
|
|
|
async _recordOnce({ paymentId, reason, sendFunds }) {
|
|
const client = await this.db.getClient();
|
|
try {
|
|
await client.query('BEGIN');
|
|
|
|
// This is the serialization point for all refund requests for one payment.
|
|
const paymentResult = await client.query(`
|
|
SELECT p.*
|
|
FROM payments p
|
|
WHERE p.id = $1
|
|
FOR UPDATE
|
|
`, [paymentId]);
|
|
|
|
if (paymentResult.rows.length === 0) {
|
|
throw new NotFoundError('Payment not found', {
|
|
safeMessage: `Payment ${paymentId} not found.`
|
|
});
|
|
}
|
|
|
|
const payment = paymentResult.rows[0];
|
|
const existingResult = await client.query(`
|
|
SELECT *
|
|
FROM payment_refunds
|
|
WHERE payment_id = $1
|
|
FOR UPDATE
|
|
`, [paymentId]);
|
|
|
|
if (existingResult.rows.length > 0) {
|
|
await client.query('COMMIT');
|
|
return {
|
|
payment,
|
|
refund: existingResult.rows[0],
|
|
existing: true,
|
|
legacyImported: false
|
|
};
|
|
}
|
|
|
|
const receivedAmount = parseAtomic(payment.received_amount, 'received amount');
|
|
const expectedAmount = parseAtomic(payment.expected_amount, 'expected amount');
|
|
// Never pay an unconfirmed invoice merely because it has an expected amount.
|
|
// Confirmed legacy rows may lack received_amount, hence the narrow fallback.
|
|
const refundAmount = receivedAmount > 0n
|
|
? receivedAmount
|
|
: (payment.status === 'confirmed' ? expectedAmount : 0n);
|
|
|
|
let user = null;
|
|
if (payment.user_id != null) {
|
|
const userResult = await client.query(`
|
|
SELECT id, credits, race_entries, total_credits_purchased,
|
|
premium_level, payout_address
|
|
FROM users
|
|
WHERE id = $1
|
|
FOR UPDATE
|
|
`, [payment.user_id]);
|
|
user = userResult.rows[0] || null;
|
|
}
|
|
|
|
const payoutAddress = typeof user?.payout_address === 'string' && user.payout_address.trim()
|
|
? user.payout_address.trim()
|
|
: null;
|
|
|
|
// Historic refunded rows predate the outbox. Snapshot them as recorded, but
|
|
// never deduct grants or send funds because their prior side effects cannot be
|
|
// proven from the old schema.
|
|
const legacyImported = payment.status === 'refunded';
|
|
let grant = null;
|
|
let creditsDeducted = 0n;
|
|
let raceEntriesDeducted = 0;
|
|
let packsRevoked = [];
|
|
let premiumLevelRestored = null;
|
|
let reviewReason = null;
|
|
let lot = null;
|
|
|
|
if (!legacyImported && payment.status === 'confirmed') {
|
|
const grantResult = await client.query(`
|
|
SELECT *
|
|
FROM payment_entitlement_grants
|
|
WHERE payment_id = $1
|
|
FOR UPDATE
|
|
`, [paymentId]);
|
|
grant = grantResult.rows[0] || null;
|
|
|
|
// Upgrade fallback for a confirmed product that predates migration 035. The
|
|
// marker is still created under the payment lock before any reversal.
|
|
if (!grant && ['credits_package', 'cosmetic_pack'].includes(payment.payment_type)) {
|
|
const durable = jsonObject(payment.product_grants);
|
|
const legacyCredits = parseAtomic(
|
|
durable.credits ?? payment.credits_purchased ?? 0,
|
|
'legacy credits grant'
|
|
);
|
|
const legacyRace = Number(durable.raceEntries ?? durable.race_entries ?? 0);
|
|
const legacyPacks = Array.isArray(durable.packs) ? durable.packs : [];
|
|
const legacyPremium = durable.premiumLevel || durable.premium_level || null;
|
|
const inserted = await client.query(`
|
|
INSERT INTO payment_entitlement_grants (
|
|
payment_id, user_id, source, credits_granted,
|
|
purchase_progress_granted, race_entries_granted, packs_granted,
|
|
premium_level_granted, metadata
|
|
) VALUES (
|
|
$1, $2, 'legacy_product_backfill', $3::bigint, $3::bigint,
|
|
$4, $5::jsonb, $6, $7::jsonb
|
|
)
|
|
ON CONFLICT (payment_id) DO NOTHING
|
|
RETURNING *
|
|
`, [
|
|
paymentId,
|
|
payment.user_id,
|
|
legacyCredits.toString(),
|
|
Number.isSafeInteger(legacyRace) && legacyRace > 0 ? legacyRace : 0,
|
|
JSON.stringify(legacyPacks),
|
|
legacyPremium,
|
|
JSON.stringify({ runtimeLegacyBackfill: true })
|
|
]);
|
|
grant = inserted.rows[0] || null;
|
|
}
|
|
|
|
// A linked game proves that a direct entry was consumed. Never send its coins
|
|
// automatically; gameplay value and any payout liability require reconciliation.
|
|
const consumedGame = await client.query(
|
|
`SELECT id FROM games WHERE payment_id = $1 LIMIT 1`,
|
|
[paymentId]
|
|
);
|
|
if (consumedGame.rows.length > 0) {
|
|
reviewReason = 'Payment was consumed by a game; automatic refund is unsafe.';
|
|
}
|
|
}
|
|
|
|
const creditsGranted = grant ? parseAtomic(grant.credits_granted, 'credits grant') : 0n;
|
|
const progressGranted = grant ? parseAtomic(grant.purchase_progress_granted, 'purchase progress grant') : 0n;
|
|
const raceGranted = grant ? Number(grant.race_entries_granted || 0) : 0;
|
|
const grantedPackIds = grant ? packIds(jsonObject({ value: grant.packs_granted }).value || grant.packs_granted) : [];
|
|
// PostgreSQL returns JSONB arrays directly; string-backed test/adapter rows need parsing.
|
|
const parsedPacks = typeof grant?.packs_granted === 'string'
|
|
? (() => { try { return JSON.parse(grant.packs_granted); } catch (_) { return []; } })()
|
|
: grant?.packs_granted;
|
|
const actualPackIds = grant ? packIds(parsedPacks) : grantedPackIds;
|
|
|
|
if (!legacyImported && !reviewReason && payment.status !== 'confirmed') {
|
|
reviewReason = 'Only a confirmed payment can be refunded automatically.';
|
|
}
|
|
// Direct-entry confirmation historically advanced lifetime purchase progression
|
|
// outside the payment grant snapshot. Until that benefit has payment-scoped
|
|
// provenance, neither a bare direct payment nor its recovery-credit marker can be
|
|
// reversed exactly. Keep the coins in manual review instead of refunding while
|
|
// leaving a tier/cosmetic unlock behind.
|
|
if (!legacyImported && !reviewReason && payment.payment_type === 'single_game'
|
|
&& (!grant || grant.source === 'single_game_recovery')) {
|
|
reviewReason = 'Direct-entry purchase progression requires manual reconciliation.';
|
|
}
|
|
if (!legacyImported && !reviewReason && grant && grant.status !== 'active') {
|
|
reviewReason = `Entitlement grant is ${grant.status}; manual reconciliation is required.`;
|
|
}
|
|
if (!legacyImported && !reviewReason
|
|
&& (creditsGranted > 0n || progressGranted > 0n || raceGranted > 0
|
|
|| actualPackIds.length > 0 || grant?.premium_level_granted)
|
|
&& !user) {
|
|
reviewReason = 'The entitlement owner no longer exists.';
|
|
}
|
|
if (!legacyImported && !reviewReason && sendFunds && refundAmount <= 0n) {
|
|
reviewReason = 'No received payment amount is available for an automatic refund.';
|
|
}
|
|
if (!legacyImported && !reviewReason && sendFunds && !payoutAddress) {
|
|
reviewReason = 'No payout address is available for an automatic refund.';
|
|
}
|
|
if (!legacyImported && !reviewReason && creditsGranted > parseAtomic(user?.credits, 'user credits')) {
|
|
reviewReason = 'Purchased credits have already been consumed.';
|
|
}
|
|
if (!legacyImported && !reviewReason
|
|
&& progressGranted > parseAtomic(user?.total_credits_purchased, 'purchase progress')) {
|
|
reviewReason = 'Purchase progression cannot be reversed safely.';
|
|
}
|
|
if (!legacyImported && !reviewReason
|
|
&& (!Number.isSafeInteger(raceGranted) || raceGranted < 0
|
|
|| raceGranted > Number(user?.race_entries || 0))) {
|
|
reviewReason = 'Purchased race entries have already been consumed or escrowed.';
|
|
}
|
|
|
|
if (!legacyImported && !reviewReason && raceGranted > 0) {
|
|
const lotResult = await client.query(`
|
|
SELECT id, original_entries, remaining_entries, refunded_at
|
|
FROM race_entry_lots
|
|
WHERE payment_id = $1
|
|
FOR UPDATE
|
|
`, [paymentId]);
|
|
lot = lotResult.rows[0] || null;
|
|
if (lot && (lot.refunded_at != null
|
|
|| Number(lot.original_entries) !== raceGranted
|
|
|| Number(lot.remaining_entries) !== raceGranted)) {
|
|
reviewReason = 'Purchased race entries have already been consumed or escrowed.';
|
|
}
|
|
}
|
|
|
|
let lockedPacks = [];
|
|
if (!legacyImported && !reviewReason && actualPackIds.length > 0) {
|
|
if (grant.source !== 'product_confirmation') {
|
|
reviewReason = 'Legacy pack provenance is ambiguous.';
|
|
} else {
|
|
const packResult = await client.query(`
|
|
SELECT pack_id, source, metadata
|
|
FROM user_pack_entitlements
|
|
WHERE user_id = $1 AND pack_id = ANY($2::text[])
|
|
ORDER BY pack_id
|
|
FOR UPDATE
|
|
`, [payment.user_id, actualPackIds]);
|
|
lockedPacks = packResult.rows || [];
|
|
const ownedByPayment = new Set(lockedPacks
|
|
.filter(row => String(jsonObject(row.metadata).paymentId) === String(paymentId))
|
|
.map(row => row.pack_id));
|
|
if (actualPackIds.some(id => !ownedByPayment.has(id))) {
|
|
reviewReason = 'A purchased pack is missing or has different ownership provenance.';
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!legacyImported && !reviewReason && grant?.premium_level_granted) {
|
|
if (!grant.premium_level_previous
|
|
|| user?.premium_level !== grant.premium_level_granted) {
|
|
reviewReason = 'The purchased premium level changed after purchase.';
|
|
}
|
|
}
|
|
|
|
if (!legacyImported && reviewReason) {
|
|
if (grant) {
|
|
await client.query(`
|
|
UPDATE payment_entitlement_grants
|
|
SET status = 'needs_review', needs_review_at = NOW(), reversal_reason = $2
|
|
WHERE payment_id = $1 AND status = 'active'
|
|
`, [paymentId, reviewReason]);
|
|
}
|
|
const reviewInsert = await client.query(`
|
|
INSERT INTO payment_refunds (
|
|
payment_id, user_id, status, amount, payout_address,
|
|
credits_deducted, race_entries_deducted, packs_revoked,
|
|
reason, requested_at, error_message,
|
|
entitlement_grant_payment_id
|
|
) VALUES (
|
|
$1, $2, 'needs_review', $3::bigint, $4,
|
|
0, 0, '[]'::jsonb, $5, NOW(), $6, $7
|
|
)
|
|
RETURNING *
|
|
`, [paymentId, payment.user_id, refundAmount.toString(), payoutAddress,
|
|
reason, reviewReason, grant ? paymentId : null]);
|
|
await client.query('COMMIT');
|
|
return {
|
|
payment,
|
|
refund: reviewInsert.rows[0],
|
|
existing: false,
|
|
legacyImported: false
|
|
};
|
|
}
|
|
|
|
if (!legacyImported && grant && user) {
|
|
const premiumPrevious = grant.premium_level_granted
|
|
? grant.premium_level_previous
|
|
: user.premium_level;
|
|
const updatedUser = await client.query(`
|
|
UPDATE users
|
|
SET credits = credits - $1::bigint,
|
|
total_credits_purchased = total_credits_purchased - $2::bigint,
|
|
race_entries = race_entries - $3,
|
|
premium_level = $4
|
|
WHERE id = $5
|
|
RETURNING credits, race_entries, total_credits_purchased, premium_level
|
|
`, [creditsGranted.toString(), progressGranted.toString(), raceGranted,
|
|
premiumPrevious, payment.user_id]);
|
|
if (updatedUser.rows.length !== 1) {
|
|
throw new AppError('Locked refund user disappeared during entitlement reversal');
|
|
}
|
|
|
|
creditsDeducted = creditsGranted;
|
|
raceEntriesDeducted = raceGranted;
|
|
premiumLevelRestored = grant.premium_level_granted ? premiumPrevious : null;
|
|
if (creditsDeducted > 0n) {
|
|
await client.query(`
|
|
INSERT INTO credit_transactions (
|
|
user_id, amount, reason, balance_after, transaction_type, payment_id
|
|
) VALUES ($1, $2::bigint, $3, $4::bigint, 'refund', $5)
|
|
`, [payment.user_id, (-creditsDeducted).toString(),
|
|
`payment_refund:${paymentId}`, String(updatedUser.rows[0].credits), paymentId]);
|
|
}
|
|
if (raceEntriesDeducted > 0) {
|
|
await client.query(`
|
|
INSERT INTO race_entry_transactions (
|
|
user_id, delta, balance_after, reason, payment_id, metadata
|
|
) VALUES ($1, $2, $3, 'refund', $4, $5::jsonb)
|
|
`, [payment.user_id, -raceEntriesDeducted,
|
|
updatedUser.rows[0].race_entries, paymentId,
|
|
JSON.stringify({ refundPaymentId: paymentId })]);
|
|
}
|
|
if (lot) {
|
|
const reversedLot = await client.query(`
|
|
UPDATE race_entry_lots
|
|
SET remaining_entries = 0, refunded_at = NOW()
|
|
WHERE id = $1 AND refunded_at IS NULL
|
|
AND remaining_entries = original_entries
|
|
RETURNING id
|
|
`, [lot.id]);
|
|
if (reversedLot.rowCount !== 1) {
|
|
throw new AppError('Race-entry lot changed during refund reversal');
|
|
}
|
|
}
|
|
if (actualPackIds.length > 0) {
|
|
const removed = await client.query(`
|
|
DELETE FROM user_pack_entitlements
|
|
WHERE user_id = $1
|
|
AND pack_id = ANY($2::text[])
|
|
AND metadata->>'paymentId' = $3
|
|
RETURNING pack_id
|
|
`, [payment.user_id, actualPackIds, String(paymentId)]);
|
|
packsRevoked = (removed.rows || []).map(row => row.pack_id);
|
|
if (packsRevoked.length !== actualPackIds.length) {
|
|
throw new AppError('Pack entitlement changed during refund reversal');
|
|
}
|
|
}
|
|
await client.query(`
|
|
UPDATE payment_entitlement_grants
|
|
SET status = 'reversed', credits_reversed = $2::bigint,
|
|
purchase_progress_reversed = $3::bigint,
|
|
race_entries_reversed = $4, packs_reversed = $5::jsonb,
|
|
reversal_reason = $6, reversed_at = NOW()
|
|
WHERE payment_id = $1 AND status = 'active'
|
|
`, [paymentId, creditsDeducted.toString(), progressGranted.toString(),
|
|
raceEntriesDeducted, JSON.stringify(packsRevoked), reason]);
|
|
}
|
|
|
|
if (!legacyImported) {
|
|
await client.query(`
|
|
UPDATE payments
|
|
SET status = 'refunded'
|
|
WHERE id = $1
|
|
`, [paymentId]);
|
|
payment.status = 'refunded';
|
|
}
|
|
|
|
const initialStatus = sendFunds && !legacyImported ? 'requested' : 'recorded';
|
|
const insertResult = await client.query(`
|
|
INSERT INTO payment_refunds (
|
|
payment_id, user_id, status, amount, payout_address,
|
|
credits_deducted, purchase_progress_deducted,
|
|
race_entries_deducted, packs_revoked,
|
|
premium_level_restored, reason, requested_at,
|
|
entitlement_grant_payment_id
|
|
) VALUES (
|
|
$1, $2, $3, $4::bigint, $5, $6::bigint, $7::bigint,
|
|
$8, $9::jsonb, $10, $11,
|
|
CASE WHEN $3 = 'requested' THEN NOW() ELSE NULL END, $12
|
|
)
|
|
RETURNING *
|
|
`, [
|
|
paymentId,
|
|
payment.user_id,
|
|
initialStatus,
|
|
refundAmount.toString(),
|
|
payoutAddress,
|
|
creditsDeducted.toString(),
|
|
progressGranted.toString(),
|
|
raceEntriesDeducted,
|
|
JSON.stringify(packsRevoked),
|
|
premiumLevelRestored,
|
|
reason,
|
|
grant ? paymentId : null
|
|
]);
|
|
|
|
await client.query('COMMIT');
|
|
return {
|
|
payment,
|
|
refund: insertResult.rows[0],
|
|
existing: legacyImported,
|
|
legacyImported
|
|
};
|
|
} catch (error) {
|
|
await client.query('ROLLBACK').catch(() => {});
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
async _claimAndSend(paymentId) {
|
|
const claimResult = await this.db.query(`
|
|
UPDATE payment_refunds
|
|
SET status = 'processing',
|
|
processing_started_at = NOW(),
|
|
updated_at = NOW(),
|
|
error_message = NULL
|
|
WHERE payment_id = $1
|
|
AND status = 'requested'
|
|
RETURNING *
|
|
`, [paymentId]);
|
|
|
|
if (claimResult.rows.length === 0) {
|
|
return { claimed: false, refund: await this._getRefund(paymentId) };
|
|
}
|
|
|
|
const claimed = claimResult.rows[0];
|
|
let observedTxHash = null;
|
|
|
|
try {
|
|
if (!this.walletService || typeof this.walletService.processPayout !== 'function') {
|
|
throw new Error('Wallet service is unavailable; manual refund review is required.');
|
|
}
|
|
if (BigInt(claimed.amount) <= 0n) {
|
|
throw new Error('No received payment amount is available for an automatic refund.');
|
|
}
|
|
if (typeof claimed.payout_address !== 'string' || !claimed.payout_address.trim()) {
|
|
throw new Error('No payout address is available for an automatic refund.');
|
|
}
|
|
|
|
// Recheck after the durable processing claim and immediately before the wallet call.
|
|
// A concurrent kill-switch change therefore cannot be missed by a long DB reversal.
|
|
this._assertSendEnabled();
|
|
const result = await this.walletService.processPayout({
|
|
address: claimed.payout_address,
|
|
amount: BigInt(claimed.amount),
|
|
userId: claimed.user_id,
|
|
description: `Refund for payment ${paymentId}`
|
|
});
|
|
|
|
observedTxHash = typeof result?.txHash === 'string' ? result.txHash.trim() : null;
|
|
if (result?.success !== true || !observedTxHash || !TX_HASH_PATTERN.test(observedTxHash)) {
|
|
throw new Error('Wallet did not return explicit success with a valid transaction hash.');
|
|
}
|
|
|
|
const completedResult = await this.db.query(`
|
|
UPDATE payment_refunds
|
|
SET status = 'completed',
|
|
tx_hash = $2,
|
|
error_message = NULL,
|
|
completed_at = NOW(),
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
AND status = 'processing'
|
|
RETURNING *
|
|
`, [claimed.id, observedTxHash]);
|
|
|
|
if (completedResult.rows.length === 0) {
|
|
// The transfer may have happened, so no state transition here may ever
|
|
// make this row eligible for another automatic attempt.
|
|
return { claimed: true, refund: await this._getRefund(paymentId) };
|
|
}
|
|
|
|
return { claimed: true, refund: completedResult.rows[0] };
|
|
} catch (error) {
|
|
const reviewResult = await this.db.query(`
|
|
UPDATE payment_refunds
|
|
SET status = 'needs_review',
|
|
tx_hash = COALESCE($2, tx_hash),
|
|
error_message = $3,
|
|
needs_review_at = NOW(),
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
AND status = 'processing'
|
|
RETURNING *
|
|
`, [claimed.id, observedTxHash, errorMessage(error)]);
|
|
|
|
const finalRefund = reviewResult.rows[0] || await this._getRefund(paymentId);
|
|
if (finalRefund.status !== 'completed') {
|
|
this.logger?.error?.('Payment refund requires manual review', {
|
|
paymentId,
|
|
refundId: claimed.id,
|
|
error: errorMessage(error)
|
|
});
|
|
}
|
|
|
|
return {
|
|
claimed: true,
|
|
refund: finalRefund
|
|
};
|
|
}
|
|
}
|
|
|
|
async _getRefund(paymentId) {
|
|
const result = await this.db.query(`
|
|
SELECT *
|
|
FROM payment_refunds
|
|
WHERE payment_id = $1
|
|
`, [paymentId]);
|
|
|
|
if (result.rows.length === 0) {
|
|
throw new AppError('Payment refund state disappeared', {
|
|
code: 'REFUND_STATE_MISSING',
|
|
safeMessage: 'The refund state could not be loaded.'
|
|
});
|
|
}
|
|
return result.rows[0];
|
|
}
|
|
|
|
static toApiResult(result, gameModeManager) {
|
|
const { payment, refund } = result;
|
|
const requiresReview = refund.status === 'needs_review' || refund.status === 'processing';
|
|
let message = 'Payment refunded successfully.';
|
|
if (refund.status === 'processing') {
|
|
message = 'Refund transfer is processing. Do not retry it; reconcile the wallet if it remains processing.';
|
|
} else if (refund.status === 'needs_review') {
|
|
message = 'Payment refund recorded; the transfer requires manual review.';
|
|
} else if (result.existing) {
|
|
message = 'Payment refund already recorded.';
|
|
}
|
|
return {
|
|
success: !result.existing,
|
|
message,
|
|
refund: {
|
|
paymentId: payment.id,
|
|
originalAmount: payment.expected_amount,
|
|
originalAmountFormatted: gameModeManager.formatAtomicHuman(payment.expected_amount, 4),
|
|
amount: refund.amount,
|
|
creditsDeducted: jsonInteger(refund.credits_deducted),
|
|
purchaseProgressDeducted: jsonInteger(refund.purchase_progress_deducted),
|
|
raceEntriesDeducted: Number(refund.race_entries_deducted || 0),
|
|
packsRevoked: Array.isArray(refund.packs_revoked) ? refund.packs_revoked : [],
|
|
premiumLevelRestored: refund.premium_level_restored || null,
|
|
fundsSent: refund.status === 'completed',
|
|
txHash: refund.tx_hash || null,
|
|
reason: refund.reason,
|
|
status: refund.status,
|
|
requiresReview
|
|
}
|
|
};
|
|
}
|
|
}
|
|
|
|
module.exports = PaymentRefundService;
|