Files
wownerogue/test/paymentRefundService.test.js
jwinterm fad2a695bd 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.
2026-07-27 08:23:01 -04:00

586 lines
26 KiB
JavaScript

const PaymentRefundService = require('../src/services/paymentRefundService');
function clone(value) {
return value == null ? value : { ...value };
}
class Mutex {
constructor() {
this.locked = false;
this.waiters = [];
}
async acquire() {
if (!this.locked) {
this.locked = true;
return () => this.release();
}
return new Promise(resolve => this.waiters.push(resolve));
}
release() {
const next = this.waiters.shift();
if (next) {
next(() => this.release());
} else {
this.locked = false;
}
}
}
/** Minimal transactional DB double implementing only the refund service's SQL. */
class FakeRefundDb {
constructor({ payment, user, grant = null, raceLot = null, packEntitlements = [], refund = null }) {
this.payment = clone(payment);
this.user = clone(user);
this.grant = clone(grant);
this.raceLot = clone(raceLot);
this.packEntitlements = packEntitlements.map(clone);
this.raceTransactions = [];
this.refund = clone(refund);
this.creditTransactions = [];
this.paymentUpdateCount = 0;
this.nextRefundId = refund?.id ? Number(refund.id) + 1 : 1;
this.paymentLock = new Mutex();
}
async getClient() {
const db = this;
let unlock = null;
return {
async query(text, params = []) {
const sql = text.replace(/\s+/g, ' ').trim().toLowerCase();
if (sql === 'begin') return { rows: [] };
if (sql === 'commit' || sql === 'rollback') {
unlock?.();
unlock = null;
return { rows: [] };
}
if (sql.includes('select p.*') && sql.includes('from payments p')) {
unlock = await db.paymentLock.acquire();
return { rows: db.payment && Number(db.payment.id) === Number(params[0]) ? [clone(db.payment)] : [] };
}
if (sql.includes('from payment_refunds') && sql.includes('for update')) {
return { rows: db.refund ? [clone(db.refund)] : [] };
}
if (sql.includes('select id, credits, race_entries') && sql.includes('from users')) {
return { rows: db.user && Number(db.user.id) === Number(params[0]) ? [clone(db.user)] : [] };
}
if (sql.includes('from payment_entitlement_grants') && sql.includes('for update')) {
return { rows: db.grant ? [clone(db.grant)] : [] };
}
if (sql.startsWith('select id from games')) return { rows: [] };
if (sql.startsWith('update users') && sql.includes('credits = credits -')) {
db.user.credits = (BigInt(db.user.credits) - BigInt(params[0])).toString();
db.user.total_credits_purchased = (BigInt(db.user.total_credits_purchased) - BigInt(params[1])).toString();
db.user.race_entries -= Number(params[2]);
db.user.premium_level = params[3];
return { rows: [clone(db.user)] };
}
if (sql.startsWith('insert into credit_transactions')) {
db.creditTransactions.push({
user_id: params[0],
amount: params[1],
reason: params[2],
balance_after: params[3],
transaction_type: 'refund'
});
return { rows: [] };
}
if (sql.startsWith('insert into race_entry_transactions')) {
db.raceTransactions.push({ delta: params[1], payment_id: params[3] });
return { rows: [] };
}
if (sql.startsWith('update payment_entitlement_grants')) {
if (sql.includes("status = 'needs_review'")) {
db.grant.status = 'needs_review';
db.grant.needs_review_at = new Date();
} else {
db.grant.status = 'reversed';
db.grant.credits_reversed = params[1];
db.grant.purchase_progress_reversed = params[2];
db.grant.race_entries_reversed = params[3];
db.grant.reversed_at = new Date();
}
return { rows: [clone(db.grant)], rowCount: 1 };
}
if (sql.includes('from race_entry_lots')) return { rows: db.raceLot ? [clone(db.raceLot)] : [] };
if (sql.startsWith('update race_entry_lots')) {
if (!db.raceLot || db.raceLot.refunded_at != null
|| db.raceLot.remaining_entries !== db.raceLot.original_entries) return { rows: [], rowCount: 0 };
db.raceLot.remaining_entries = 0;
db.raceLot.refunded_at = new Date();
return { rows: [{ id: db.raceLot.id }], rowCount: 1 };
}
if (sql.includes('from user_pack_entitlements') && sql.includes('for update')) {
return { rows: db.packEntitlements.map(clone) };
}
if (sql.startsWith('delete from user_pack_entitlements')) {
const wanted = new Set(params[1]);
const removed = db.packEntitlements.filter(row => wanted.has(row.pack_id)
&& String(row.metadata?.paymentId) === String(params[2]));
db.packEntitlements = db.packEntitlements.filter(row => !removed.includes(row));
return { rows: removed.map(row => ({ pack_id: row.pack_id })), rowCount: removed.length };
}
if (sql.startsWith('update payments')) {
db.payment.status = 'refunded';
db.paymentUpdateCount += 1;
return { rows: [] };
}
if (sql.startsWith('insert into payment_refunds')) {
const needsReview = sql.includes("'needs_review'");
db.refund = {
id: db.nextRefundId++,
payment_id: params[0],
user_id: params[1],
status: needsReview ? 'needs_review' : params[2],
amount: needsReview ? params[2] : params[3],
payout_address: needsReview ? params[3] : params[4],
credits_deducted: needsReview ? '0' : params[5],
purchase_progress_deducted: needsReview ? '0' : params[6],
race_entries_deducted: needsReview ? 0 : params[7],
packs_revoked: needsReview ? [] : JSON.parse(params[8]),
premium_level_restored: needsReview ? null : params[9],
reason: needsReview ? params[4] : params[10],
tx_hash: null,
error_message: needsReview ? params[5] : null,
requested_at: needsReview || params[2] === 'requested' ? new Date() : null,
processing_started_at: null,
completed_at: null,
needs_review_at: needsReview ? new Date() : null
};
return { rows: [clone(db.refund)] };
}
throw new Error(`Unexpected transaction SQL: ${sql}`);
},
release() {
unlock?.();
unlock = null;
}
};
}
async query(text, params = []) {
const sql = text.replace(/\s+/g, ' ').trim().toLowerCase();
if (sql.startsWith('update payment_refunds') && sql.includes("set status = 'processing'")) {
if (!this.refund || Number(this.refund.payment_id) !== Number(params[0]) || this.refund.status !== 'requested') {
return { rows: [] };
}
this.refund.status = 'processing';
this.refund.processing_started_at = new Date();
this.refund.error_message = null;
return { rows: [clone(this.refund)] };
}
if (sql.startsWith('update payment_refunds') && sql.includes("set status = 'completed'")) {
if (!this.refund || Number(this.refund.id) !== Number(params[0]) || this.refund.status !== 'processing') {
return { rows: [] };
}
this.refund.status = 'completed';
this.refund.tx_hash = params[1];
this.refund.completed_at = new Date();
this.refund.error_message = null;
return { rows: [clone(this.refund)] };
}
if (sql.startsWith('update payment_refunds') && sql.includes("set status = 'needs_review'")) {
if (!this.refund || Number(this.refund.id) !== Number(params[0]) || this.refund.status !== 'processing') {
return { rows: [] };
}
this.refund.status = 'needs_review';
this.refund.tx_hash = params[1] || this.refund.tx_hash;
this.refund.error_message = params[2];
this.refund.needs_review_at = new Date();
return { rows: [clone(this.refund)] };
}
if (sql.startsWith('select *') && sql.includes('from payment_refunds')) {
return { rows: this.refund && Number(this.refund.payment_id) === Number(params[0]) ? [clone(this.refund)] : [] };
}
throw new Error(`Unexpected pool SQL: ${sql}`);
}
}
function fixture(overrides = {}) {
return new FakeRefundDb({
payment: {
id: 42,
user_id: 7,
expected_amount: '1000',
received_amount: '900',
credits_purchased: '6',
status: 'confirmed',
description: 'Credits purchase',
...overrides.payment
},
user: {
id: 7,
credits: '6',
race_entries: 0,
total_credits_purchased: '6',
premium_level: 'free',
payout_address: '5'.repeat(95),
...overrides.user
},
grant: overrides.grant === undefined ? {
payment_id: 42,
user_id: 7,
source: 'product_confirmation',
credits_granted: '6',
purchase_progress_granted: '6',
race_entries_granted: 0,
packs_granted: [],
premium_level_granted: null,
premium_level_previous: null,
status: 'active'
} : overrides.grant,
raceLot: overrides.raceLot || null,
packEntitlements: overrides.packEntitlements || [],
refund: overrides.refund || null
});
}
describe('PaymentRefundService', () => {
test('the global payout gate refuses an automatic refund before any durable or wallet side effect', async () => {
const db = fixture();
const walletService = { processPayout: jest.fn() };
const service = new PaymentRefundService({
db,
walletService,
logger,
isSendEnabled: () => false
});
await expect(service.refundPayment({ paymentId: 42, sendFunds: true }))
.rejects.toMatchObject({ code: 'PAYOUT_DISPATCH_DISABLED', statusCode: 503 });
expect(walletService.processPayout).not.toHaveBeenCalled();
expect(db.refund).toBeNull();
expect(db.paymentUpdateCount).toBe(0);
});
test('a kill switch raised after the refund claim prevents the wallet call and strands safely for review', async () => {
const db = fixture();
const walletService = { processPayout: jest.fn() };
const isSendEnabled = jest.fn()
.mockReturnValueOnce(true)
.mockReturnValueOnce(false);
const service = new PaymentRefundService({
db,
walletService,
logger,
isSendEnabled
});
const result = await service.refundPayment({ paymentId: 42, sendFunds: true });
expect(result.refund.status).toBe('needs_review');
expect(walletService.processPayout).not.toHaveBeenCalled();
expect(isSendEnabled).toHaveBeenCalledTimes(2);
});
const txHash = 'a'.repeat(64);
const logger = { error: jest.fn() };
beforeEach(() => logger.error.mockClear());
test('concurrent transfer requests deduct and broadcast exactly once', async () => {
const db = fixture();
const walletService = {
processPayout: jest.fn().mockResolvedValue({ success: true, txHash })
};
const service = new PaymentRefundService({ db, walletService, logger });
const results = await Promise.all([
service.refundPayment({ paymentId: 42, reason: 'duplicate request', sendFunds: true }),
service.refundPayment({ paymentId: 42, reason: 'duplicate request', sendFunds: true })
]);
expect(walletService.processPayout).toHaveBeenCalledTimes(1);
expect(db.paymentUpdateCount).toBe(1);
expect(db.payment.description).toBe('Credits purchase');
expect(db.user.credits).toBe('0');
expect(db.user.total_credits_purchased).toBe('0');
expect(db.creditTransactions).toHaveLength(1);
expect(db.creditTransactions[0].amount).toBe('-6');
expect(db.refund.status).toBe('completed');
expect(db.refund.amount).toBe('900');
expect(db.refund.tx_hash).toBe(txHash);
expect(results.filter(result => result.existing)).toHaveLength(1);
});
test('reverses exact purchase progress and a premium tier granted solely by the payment', async () => {
const Entitlements = require('../src/multiplayer/entitlements');
const db = fixture({
user: {
credits: '6',
total_credits_purchased: '6',
premium_level: 'supporter'
},
grant: {
payment_id: 42,
user_id: 7,
source: 'product_confirmation',
credits_granted: '6',
purchase_progress_granted: '6',
race_entries_granted: 0,
packs_granted: [],
premium_level_granted: 'supporter',
premium_level_previous: 'free',
status: 'active'
}
});
const before = Entitlements.snapshotForUser(db.user);
expect(before.packs['generated-skins']).toBe(true);
const service = new PaymentRefundService({ db, walletService: null, logger });
const result = await service.refundPayment({ paymentId: 42, sendFunds: false });
const after = Entitlements.snapshotForUser(db.user);
expect(result.refund.status).toBe('recorded');
expect(db.user.credits).toBe('0');
expect(db.user.total_credits_purchased).toBe('0');
expect(db.user.premium_level).toBe('free');
expect(after.packs['generated-skins']).toBe(false);
expect(db.grant).toEqual(expect.objectContaining({
status: 'reversed',
credits_reversed: '6',
purchase_progress_reversed: '6'
}));
expect(db.refund).toEqual(expect.objectContaining({
credits_deducted: '6',
purchase_progress_deducted: '6',
premium_level_restored: 'free'
}));
});
test('changed purchase progress routes the whole refund to review without revoking benefits', async () => {
const db = fixture({ user: { credits: '6', total_credits_purchased: '5' } });
const walletService = { processPayout: jest.fn() };
const service = new PaymentRefundService({ db, walletService, logger });
const result = await service.refundPayment({ paymentId: 42, sendFunds: true });
expect(result.refund.status).toBe('needs_review');
expect(result.refund.error_message).toContain('progression cannot be reversed safely');
expect(db.user.credits).toBe('6');
expect(db.user.total_credits_purchased).toBe('5');
expect(db.payment.status).toBe('confirmed');
expect(walletService.processPayout).not.toHaveBeenCalled();
});
test('an unconsumed direct entry with unscoped purchase progress cannot auto-refund', async () => {
const db = fixture({
payment: { payment_type: 'single_game', credits_purchased: '0' },
user: { credits: '0', total_credits_purchased: '1' },
grant: null
});
const walletService = { processPayout: jest.fn() };
const service = new PaymentRefundService({ db, walletService, logger });
const result = await service.refundPayment({ paymentId: 42, sendFunds: true });
expect(result.refund.status).toBe('needs_review');
expect(result.refund.error_message).toContain('purchase progression requires manual reconciliation');
expect(db.user.total_credits_purchased).toBe('1');
expect(db.payment.status).toBe('confirmed');
expect(walletService.processPayout).not.toHaveBeenCalled();
});
test('record-only requests are durable, terminal, and idempotent', async () => {
const db = fixture();
const walletService = { processPayout: jest.fn() };
const service = new PaymentRefundService({ db, walletService, logger });
const first = await service.refundPayment({ paymentId: 42, sendFunds: false });
const second = await service.refundPayment({ paymentId: 42, sendFunds: true });
expect(first.refund.status).toBe('recorded');
expect(second.refund.status).toBe('recorded');
expect(second.existing).toBe(true);
expect(walletService.processPayout).not.toHaveBeenCalled();
expect(db.paymentUpdateCount).toBe(1);
expect(db.creditTransactions).toHaveLength(1);
});
test('ambiguous wallet failure moves to needs_review and is never retried', async () => {
const db = fixture();
const walletService = {
processPayout: jest.fn().mockRejectedValue(new Error('timeout after transfer submission'))
};
const service = new PaymentRefundService({ db, walletService, logger });
const first = await service.refundPayment({ paymentId: 42, sendFunds: true });
const second = await service.refundPayment({ paymentId: 42, sendFunds: true });
expect(first.refund.status).toBe('needs_review');
expect(first.refund.error_message).toContain('timeout');
expect(second.refund.status).toBe('needs_review');
expect(walletService.processPayout).toHaveBeenCalledTimes(1);
expect(db.paymentUpdateCount).toBe(1);
expect(db.creditTransactions).toHaveLength(1);
});
test('success without a valid transaction hash is treated as ambiguous', async () => {
const db = fixture();
const walletService = {
processPayout: jest.fn().mockResolvedValue({ success: true })
};
const service = new PaymentRefundService({ db, walletService, logger });
const result = await service.refundPayment({ paymentId: 42, sendFunds: true });
expect(result.refund.status).toBe('needs_review');
expect(result.refund.error_message).toContain('valid transaction hash');
expect(walletService.processPayout).toHaveBeenCalledTimes(1);
});
test('consumed purchased credits require review and no wallet transfer', async () => {
const db = fixture({ user: { credits: '5' } });
const walletService = {
processPayout: jest.fn().mockResolvedValue({ success: true, txHash })
};
const service = new PaymentRefundService({ db, walletService, logger });
const result = await service.refundPayment({ paymentId: 42, sendFunds: true });
expect(result.refund.status).toBe('needs_review');
expect(result.refund.error_message).toContain('already been consumed');
expect(walletService.processPayout).not.toHaveBeenCalled();
expect(db.user.credits).toBe('5');
expect(db.payment.status).toBe('confirmed');
expect(db.creditTransactions).toHaveLength(0);
});
test('unused race tickets and payment-owned packs reverse exactly once', async () => {
const db = fixture({
user: { credits: '0', total_credits_purchased: '0', race_entries: 2 },
payment: { credits_purchased: '0', payment_type: 'cosmetic_pack' },
grant: {
payment_id: 42,
user_id: 7,
source: 'product_confirmation',
credits_granted: '0',
purchase_progress_granted: '0',
race_entries_granted: 2,
packs_granted: [{ id: 'kenney-3d-characters' }],
premium_level_granted: null,
premium_level_previous: null,
status: 'active'
},
raceLot: { id: 8, original_entries: 2, remaining_entries: 2, refunded_at: null },
packEntitlements: [{
pack_id: 'kenney-3d-characters',
source: 'product_purchase',
metadata: { paymentId: 42 }
}]
});
const service = new PaymentRefundService({ db, walletService: null, logger });
const first = await service.refundPayment({ paymentId: 42, sendFunds: false });
const second = await service.refundPayment({ paymentId: 42, sendFunds: false });
expect(first.refund.status).toBe('recorded');
expect(second.existing).toBe(true);
expect(db.user.race_entries).toBe(0);
expect(db.raceLot.remaining_entries).toBe(0);
expect(db.raceLot.refunded_at).toBeTruthy();
expect(db.packEntitlements).toHaveLength(0);
expect(db.raceTransactions).toHaveLength(1);
expect(db.grant.status).toBe('reversed');
});
test('an escrowed race ticket makes the whole refund needs_review', async () => {
const db = fixture({
user: { credits: '0', total_credits_purchased: '0', race_entries: 2 },
payment: { credits_purchased: '0', payment_type: 'cosmetic_pack' },
grant: {
payment_id: 42,
user_id: 7,
source: 'product_confirmation',
credits_granted: '0',
purchase_progress_granted: '0',
race_entries_granted: 2,
packs_granted: [],
premium_level_granted: null,
premium_level_previous: null,
status: 'active'
},
raceLot: { id: 8, original_entries: 2, remaining_entries: 1, refunded_at: null }
});
const walletService = { processPayout: jest.fn() };
const service = new PaymentRefundService({ db, walletService, logger });
const result = await service.refundPayment({ paymentId: 42, sendFunds: true });
expect(result.refund.status).toBe('needs_review');
expect(result.refund.error_message).toContain('consumed or escrowed');
expect(walletService.processPayout).not.toHaveBeenCalled();
expect(db.user.race_entries).toBe(2);
expect(db.raceLot.remaining_entries).toBe(1);
});
test('a pending unpaid invoice cannot deduct credits or send its expected amount', async () => {
const db = fixture({
payment: { status: 'pending', received_amount: '0' }
});
const walletService = {
processPayout: jest.fn().mockResolvedValue({ success: true, txHash })
};
const service = new PaymentRefundService({ db, walletService, logger });
const result = await service.refundPayment({ paymentId: 42, sendFunds: true });
expect(result.refund.status).toBe('needs_review');
expect(result.refund.amount).toBe('0');
expect(result.refund.error_message).toContain('confirmed payment');
expect(walletService.processPayout).not.toHaveBeenCalled();
expect(db.user.credits).toBe('6');
expect(db.creditTransactions).toHaveLength(0);
});
test('a stranded processing row requires review and is never resent', async () => {
const db = fixture({
payment: { status: 'refunded' },
refund: {
id: 11,
payment_id: 42,
user_id: 7,
status: 'processing',
amount: '900',
payout_address: '5'.repeat(95),
credits_deducted: '4',
reason: 'Admin refund',
tx_hash: null,
error_message: null,
requested_at: new Date(),
processing_started_at: new Date()
}
});
const walletService = { processPayout: jest.fn() };
const service = new PaymentRefundService({ db, walletService, logger });
const result = await service.refundPayment({ paymentId: 42, sendFunds: true });
expect(result.refund.status).toBe('processing');
expect(walletService.processPayout).not.toHaveBeenCalled();
expect(db.paymentUpdateCount).toBe(0);
expect(db.creditTransactions).toHaveLength(0);
});
test('a legacy refunded payment is imported without repeating unknown side effects', async () => {
const db = fixture({
payment: { status: 'refunded', received_amount: '900' }
});
const walletService = { processPayout: jest.fn() };
const service = new PaymentRefundService({ db, walletService, logger });
const result = await service.refundPayment({ paymentId: 42, sendFunds: true });
expect(result.legacyImported).toBe(true);
expect(result.existing).toBe(true);
expect(result.refund.status).toBe('recorded');
expect(result.refund.amount).toBe('900');
expect(walletService.processPayout).not.toHaveBeenCalled();
expect(db.paymentUpdateCount).toBe(0);
expect(db.user.credits).toBe('6');
expect(db.creditTransactions).toHaveLength(0);
});
});