mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-17 14:21:25 +02: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.
586 lines
26 KiB
JavaScript
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);
|
|
});
|
|
});
|