mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-11-14 11:03:53 +01:00
Wownerogue
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.
This commit is contained in:
@@ -0,0 +1,634 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user