mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-30 03:28:32 +01:00
feat: productionize paid modes and PvP capture
This commit is contained in:
@@ -0,0 +1,585 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user