diff --git a/apps/api/src/routes/__tests__/webhooks.marketplace.v1.test.ts b/apps/api/src/routes/__tests__/webhooks.marketplace.v1.test.ts index 50b1433..6c462dc 100644 --- a/apps/api/src/routes/__tests__/webhooks.marketplace.v1.test.ts +++ b/apps/api/src/routes/__tests__/webhooks.marketplace.v1.test.ts @@ -6,13 +6,14 @@ import { describe, expect, it, vi, afterEach, beforeEach } from 'vitest'; const getById = vi.fn(); const update = vi.fn(); +const getByPaymentProviderId = vi.fn(); vi.mock('../../lib/prisma', () => ({ prisma: { viewerIdentity: { findUnique: vi.fn().mockResolvedValue({ email: 'viewer@example.com' }) } }, })); vi.mock('../../repositories/implementations/PurchaseRepository', () => ({ - PurchaseRepository: vi.fn().mockImplementation(() => ({ getById, update, getByPaymentProviderId: vi.fn() })), + PurchaseRepository: vi.fn().mockImplementation(() => ({ getById, update, getByPaymentProviderId })), })); import type { PurchaseFulfillmentService } from '../../services/PurchaseFulfillmentService'; @@ -70,6 +71,7 @@ describe('POST /api/webhooks/marketplace (event v1)', () => { vi.stubEnv('FIELDVIEW_WEBHOOK_CALLBACK_URL', CALLBACK); getById.mockReset(); update.mockReset(); + getByPaymentProviderId.mockReset(); fulfillPaidPurchase.mockClear(); setPurchaseFulfillmentService({ fulfillPaidPurchase } as unknown as PurchaseFulfillmentService); }); @@ -105,6 +107,29 @@ describe('POST /api/webhooks/marketplace (event v1)', () => { expect(update).toHaveBeenCalledWith('purchase_1', expect.objectContaining({ status: 'failed' })); }); + it('marks a partial refund as partially_refunded', async () => { + getByPaymentProviderId.mockResolvedValue({ id: 'purchase_1', status: 'paid', amountCents: 500 }); + const res = await send( + v1('marketplace.refund.completed', { + money: { amountCents: 500, currency: 'USD', refundedCents: 200, feeCents: 50 }, + }), + ); + expect(res.status).toBe(200); + expect(getByPaymentProviderId).toHaveBeenCalledWith('pay_1'); + expect(update).toHaveBeenCalledWith('purchase_1', expect.objectContaining({ status: 'partially_refunded' })); + }); + + it('marks a full refund as refunded', async () => { + getByPaymentProviderId.mockResolvedValue({ id: 'purchase_1', status: 'paid', amountCents: 500 }); + const res = await send( + v1('marketplace.refund.completed', { + money: { amountCents: 500, currency: 'USD', refundedCents: 500, feeCents: 50 }, + }), + ); + expect(res.status).toBe(200); + expect(update).toHaveBeenCalledWith('purchase_1', expect.objectContaining({ status: 'refunded' })); + }); + it('rejects an event signed with another key', async () => { const res = await send(v1('marketplace.purchase.paid'), 'wrong'); expect(res.status).toBe(401); diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts index ee6ecd3..b6b8c0f 100644 --- a/apps/api/src/routes/admin.ts +++ b/apps/api/src/routes/admin.ts @@ -17,6 +17,7 @@ import { validateRequest } from '../middleware/validation'; import { AdminAccountRepository } from '../repositories/implementations/AdminAccountRepository'; import { EntitlementRepository } from '../repositories/implementations/EntitlementRepository'; import { GameRepository } from '../repositories/implementations/GameRepository'; +import { LedgerRepository } from '../repositories/implementations/LedgerRepository'; import { PlaybackSessionRepository } from '../repositories/implementations/PlaybackSessionRepository'; import { PurchaseRepository } from '../repositories/implementations/PurchaseRepository'; import { RefundRepository } from '../repositories/implementations/RefundRepository'; @@ -27,6 +28,7 @@ import { AudienceService } from '../services/AudienceService'; import { getMarketplaceConfig } from '../lib/marketplace'; import { OwnerAccountRepository } from '../repositories/implementations/OwnerAccountRepository'; import type { IRefundWriter } from '../services/IRefundService'; +import { LedgerService } from '../services/LedgerService'; import { RefundService } from '../services/RefundService'; import { MarketplaceStoreService } from '../services/MarketplaceStoreService'; import { SmsService } from '../services/SmsService'; @@ -95,6 +97,7 @@ function getRefundService(): IRefundWriter { const ownerRepo = new OwnerAccountRepository(prisma); const smsService = new SmsService(gameRepo, viewerIdentityRepo, viewerIdentityRepo); const marketplace = new MarketplaceStoreService(getMarketplaceConfig()); + const ledgerRepo = new LedgerRepository(prisma); refundServiceInstance = new RefundService( purchaseRepo, purchaseRepo, @@ -104,7 +107,9 @@ function getRefundService(): IRefundWriter { entitlementRepo, smsService, ownerRepo, - marketplace + marketplace, + new LedgerService(ledgerRepo, ownerRepo), + ledgerRepo ); } return refundServiceInstance; diff --git a/apps/api/src/routes/webhooks.marketplace.ts b/apps/api/src/routes/webhooks.marketplace.ts index 9cba87b..fb821b6 100644 --- a/apps/api/src/routes/webhooks.marketplace.ts +++ b/apps/api/src/routes/webhooks.marketplace.ts @@ -30,7 +30,7 @@ export interface MarketplaceWebhookEvent { id?: string; type?: string; buyer?: { userId?: string | null } | null; - money?: { amountCents?: number | null; feeCents?: number | null } | null; + money?: { amountCents?: number | null; feeCents?: number | null; refundedCents?: number | null } | null; refs?: { paymentId?: string | null } | null; seller?: { key?: string | null; chargesEnabled?: boolean } | null; data?: { @@ -173,7 +173,13 @@ router.post('/marketplace', (req, res, next) => { if (paymentId) { const purchase = await purchaseRepo.getByPaymentProviderId(paymentId); if (purchase) { - await purchaseRepo.update(purchase.id, { status: 'refunded', refundedAt: new Date() }); + // money.refundedCents is the total refunded so far; without it, assume a full refund. + const refundedCents = event.money?.refundedCents; + const isPartial = typeof refundedCents === 'number' && refundedCents < purchase.amountCents; + await purchaseRepo.update(purchase.id, { + status: isPartial ? 'partially_refunded' : 'refunded', + refundedAt: new Date(), + }); } } } diff --git a/apps/api/src/services/RefundService.ts b/apps/api/src/services/RefundService.ts index 8da8541..db999e2 100644 --- a/apps/api/src/services/RefundService.ts +++ b/apps/api/src/services/RefundService.ts @@ -5,18 +5,23 @@ * Handles refund evaluation and processing. */ +import type { Purchase, Refund } from '@prisma/client'; + import { BadRequestError, NotFoundError } from '../lib/errors'; +import { logger } from '../lib/logger'; import { resolveSellerKey } from '../lib/marketplace'; +import type { IEntitlementReader as IEntitlementRepoReader } from '../repositories/IEntitlementRepository'; +import type { ILedgerReader } from '../repositories/ILedgerRepository'; import type { IOwnerAccountReader } from '../repositories/IOwnerAccountRepository'; import type { IPlaybackSessionReader } from '../repositories/IPlaybackSessionRepository'; import type { IPurchaseReader, IPurchaseWriter } from '../repositories/IPurchaseRepository'; import type { IRefundReader as IRefundRepoReader, IRefundWriter as IRefundRepoWriter } from '../repositories/IRefundRepository'; import { calculateRefund, type RefundCalculationInput } from '../utils/refundCalculator'; -import type { IEntitlementReader as IEntitlementRepoReader } from '../repositories/IEntitlementRepository'; -import type { IMarketplaceStorePayments } from './MarketplaceStoreService'; import type { IRefundReader, IRefundWriter, AggregatedTelemetry, RefundEvaluation } from './IRefundService'; import type { ISmsWriter } from './ISmsService'; +import type { LedgerService } from './LedgerService'; +import type { IMarketplaceStorePayments } from './MarketplaceStoreService'; const DEFAULT_GAME_DURATION_MS = 90 * 60 * 1000; // 90 minutes @@ -30,7 +35,9 @@ export class RefundService implements IRefundReader, IRefundWriter { private entitlementReader: IEntitlementRepoReader, private smsWriter: ISmsWriter, private ownerReader: IOwnerAccountReader, - private marketplace: IMarketplaceStorePayments + private marketplace: IMarketplaceStorePayments, + private ledgerService: Pick, + private ledgerReader: Pick ) {} async evaluateRefundEligibility(purchaseId: string): Promise { @@ -117,10 +124,11 @@ export class RefundService implements IRefundReader, IRefundWriter { throw new NotFoundError('Purchase not found'); } - // Check if already refunded - const existingRefunds = await this.refundReader.getByPurchaseId(purchaseId); - if (existingRefunds.length > 0) { - throw new BadRequestError('Purchase already refunded'); + // Check if already refunded, or retry a refund whose store call failed + const pending = this.findRetryableRefund(await this.refundReader.getByPurchaseId(purchaseId)); + if (pending) { + await this.processMarketplaceRefund(pending.id); + return pending; } // Re-evaluate to get refund amount @@ -148,15 +156,8 @@ export class RefundService implements IRefundReader, IRefundWriter { }, }); - // Update purchase status + // Refund on the store; this also updates the purchase status once the store accepts it const refundAmount = evaluation.amountCents; - const isFullRefund = refundAmount >= purchase.amountCents; - await this.purchaseWriter.update(purchaseId, { - status: isFullRefund ? 'refunded' : 'partially_refunded', - refundedAt: new Date(), - }); - - // Process Square refund await this.processMarketplaceRefund(refund.id); // Send SMS notification @@ -189,9 +190,15 @@ export class RefundService implements IRefundReader, IRefundWriter { if (!purchase) { throw new NotFoundError('Purchase not found'); } - const existingRefunds = await this.refundReader.getByPurchaseId(purchaseId); - if (existingRefunds.length > 0) { - throw new BadRequestError('Purchase already refunded'); + const pending = this.findRetryableRefund(await this.refundReader.getByPurchaseId(purchaseId)); + if (pending) { + if (pending.amountCents !== amountCents) { + throw new BadRequestError( + `A refund of ${pending.amountCents} cents is already pending for this purchase; retry with that amount` + ); + } + await this.processMarketplaceRefund(pending.id); + return pending; } if (amountCents <= 0 || amountCents > purchase.amountCents) { throw new BadRequestError('Invalid refund amount'); @@ -215,17 +222,22 @@ export class RefundService implements IRefundReader, IRefundWriter { }, }); - const isFullRefund = amountCents >= purchase.amountCents; - await this.purchaseWriter.update(purchaseId, { - status: isFullRefund ? 'refunded' : 'partially_refunded', - refundedAt: new Date(), - }); - await this.processMarketplaceRefund(refund.id); return refund; } + /** + * A refund whose store call failed stays unprocessed. Retrying it reuses its id, + * which is the store's idempotency key, so a retry can never refund twice. + */ + private findRetryableRefund(existingRefunds: Refund[]): Refund | undefined { + if (existingRefunds.some((r) => r.processedAt)) { + throw new BadRequestError('Purchase already refunded'); + } + return existingRefunds[0]; + } + async processSquareRefund(refundId: string): Promise { await this.processMarketplaceRefund(refundId); } @@ -261,6 +273,18 @@ export class RefundService implements IRefundReader, IRefundWriter { refundId, reason: refund.reasonCode, }); + + // Update the purchase only after the store accepts the refund. processedAt goes + // last, so a failure in between leaves the refund retryable (every step is idempotent). + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const isFullRefund = refund.amountCents >= (purchase.amountCents as number); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access + await this.purchaseWriter.update(purchase.id, { + status: isFullRefund ? 'refunded' : 'partially_refunded', + refundedAt: new Date(), + }); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + await this.writeRefundLedger(purchase, refund.amountCents, refundId); await this.refundWriter.update(refundId, { processedAt: new Date() }); return; } @@ -269,6 +293,18 @@ export class RefundService implements IRefundReader, IRefundWriter { throw new BadRequestError('Owner marketplace seller is not configured for this purchase'); } + private async writeRefundLedger(purchase: Purchase, amountCents: number, refundId: string): Promise { + try { + const existing = await this.ledgerReader.findByReference('refund', refundId); + if (existing.length === 0) { + await this.ledgerService.createRefundLedgerEntries(purchase, amountCents, refundId); + } + } catch (ledgerError) { + // The money has already moved; a missing ledger row must not fail the refund. + logger.error({ ledgerError, refundId }, 'Failed to create refund ledger entries'); + } + } + /** * Aggregate telemetry from all playback sessions for a purchase */ diff --git a/apps/api/src/services/__tests__/RefundService.test.ts b/apps/api/src/services/__tests__/RefundService.test.ts index 9ecdfc8..ee9687a 100644 --- a/apps/api/src/services/__tests__/RefundService.test.ts +++ b/apps/api/src/services/__tests__/RefundService.test.ts @@ -2,21 +2,54 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; import { RefundService } from '../RefundService'; -describe('RefundService.processMarketplaceRefund', () => { +const purchase = { + id: 'purchase-1', + amountCents: 500, + platformFeeCents: 50, + currency: 'USD', + status: 'paid', + paymentProviderPaymentId: 'pay-1', + recipientOwnerAccountId: 'owner-1', +}; + +function pendingRefund(over: Record = {}) { + return { + id: 'refund-1', + purchaseId: 'purchase-1', + amountCents: 500, + reasonCode: 'TEST', + processedAt: null, + purchase, + ...over, + }; +} + +describe('RefundService', () => { const purchaseReader = { getById: vi.fn() }; const purchaseWriter = { update: vi.fn() }; const playbackSessionReader = { listByEntitlementId: vi.fn() }; const refundReader = { getById: vi.fn(), getByPurchaseId: vi.fn() }; const refundWriter = { create: vi.fn(), update: vi.fn() }; const entitlementReader = { getByPurchaseId: vi.fn() }; - const smsWriter = { sendPaymentLink: vi.fn() }; + const smsWriter = { sendPaymentLink: vi.fn(), sendNotification: vi.fn() }; const ownerReader = { findById: vi.fn() }; const marketplace = { refund: vi.fn(), createCheckout: vi.fn() }; + const ledgerService = { createRefundLedgerEntries: vi.fn() }; + const ledgerReader = { findByReference: vi.fn() }; let svc: RefundService; beforeEach(() => { vi.clearAllMocks(); + ownerReader.findById.mockResolvedValue({ id: 'owner-1', marketplaceSellerKey: 'owner-1' }); + purchaseReader.getById.mockResolvedValue(purchase); + refundReader.getByPurchaseId.mockResolvedValue([]); + refundWriter.create.mockImplementation((data: { amountCents: number }) => + Promise.resolve(pendingRefund({ amountCents: data.amountCents })), + ); + refundReader.getById.mockResolvedValue(pendingRefund()); + marketplace.refund.mockResolvedValue(undefined); + ledgerReader.findByReference.mockResolvedValue([]); svc = new RefundService( purchaseReader as never, purchaseWriter as never, @@ -27,21 +60,108 @@ describe('RefundService.processMarketplaceRefund', () => { smsWriter as never, ownerReader as never, marketplace as never, + ledgerService as never, + ledgerReader as never, ); }); - it('calls marketplace refund API', async () => { - refundReader.getById.mockResolvedValue({ - id: 'refund-1', - amountCents: 500, - reasonCode: 'TEST', - processedAt: null, - purchase: { paymentProviderPaymentId: 'pay-1', recipientOwnerAccountId: 'owner-1' }, + describe('processMarketplaceRefund', () => { + it('calls marketplace refund API', async () => { + await svc.processMarketplaceRefund('refund-1'); + expect(marketplace.refund).toHaveBeenCalledWith( + expect.objectContaining({ paymentId: 'pay-1', sellerKey: 'owner-1' }), + ); + }); + + it('marks the purchase refunded only after the store accepts the refund', async () => { + await svc.processMarketplaceRefund('refund-1'); + expect(purchaseWriter.update).toHaveBeenCalledWith( + 'purchase-1', + expect.objectContaining({ status: 'refunded' }), + ); + expect(marketplace.refund.mock.invocationCallOrder[0]).toBeLessThan( + purchaseWriter.update.mock.invocationCallOrder[0], + ); + }); + + it('marks a partial refund as partially_refunded', async () => { + refundReader.getById.mockResolvedValue(pendingRefund({ amountCents: 200 })); + await svc.processMarketplaceRefund('refund-1'); + expect(purchaseWriter.update).toHaveBeenCalledWith( + 'purchase-1', + expect.objectContaining({ status: 'partially_refunded' }), + ); + }); + + it('leaves the purchase and refund untouched when the store rejects the refund', async () => { + marketplace.refund.mockRejectedValue(new Error('store down')); + await expect(svc.processMarketplaceRefund('refund-1')).rejects.toThrow('store down'); + expect(purchaseWriter.update).not.toHaveBeenCalled(); + expect(refundWriter.update).not.toHaveBeenCalled(); + expect(ledgerService.createRefundLedgerEntries).not.toHaveBeenCalled(); + }); + + it('writes the ledger reversal for the refund', async () => { + refundReader.getById.mockResolvedValue(pendingRefund({ amountCents: 200 })); + await svc.processMarketplaceRefund('refund-1'); + expect(ledgerService.createRefundLedgerEntries).toHaveBeenCalledWith( + expect.objectContaining({ id: 'purchase-1' }), + 200, + 'refund-1', + ); + }); + + it('does not write the ledger reversal twice', async () => { + ledgerReader.findByReference.mockResolvedValue([{ id: 'ledger-1' }]); + await svc.processMarketplaceRefund('refund-1'); + expect(ledgerReader.findByReference).toHaveBeenCalledWith('refund', 'refund-1'); + expect(ledgerService.createRefundLedgerEntries).not.toHaveBeenCalled(); + }); + + it('still completes the refund when the ledger write fails', async () => { + ledgerService.createRefundLedgerEntries.mockRejectedValue(new Error('db')); + await expect(svc.processMarketplaceRefund('refund-1')).resolves.toBeUndefined(); + expect(refundWriter.update).toHaveBeenCalledWith( + 'refund-1', + expect.objectContaining({ processedAt: expect.any(Date) as Date }), + ); + }); + + it('does nothing for a refund that was already processed', async () => { + refundReader.getById.mockResolvedValue(pendingRefund({ processedAt: new Date() })); + await svc.processMarketplaceRefund('refund-1'); + expect(marketplace.refund).not.toHaveBeenCalled(); + }); + }); + + describe('issueManualRefund', () => { + it('does not mark the purchase refunded when the store rejects the refund', async () => { + marketplace.refund.mockRejectedValue(new Error('store down')); + await expect(svc.issueManualRefund('purchase-1', 500, 'admin_manual', 'admin-1')).rejects.toThrow(); + expect(purchaseWriter.update).not.toHaveBeenCalled(); + }); + + it('retries a pending refund with the same idempotency key instead of refusing', async () => { + refundReader.getByPurchaseId.mockResolvedValue([pendingRefund()]); + const refund = await svc.issueManualRefund('purchase-1', 500, 'admin_manual', 'admin-1'); + expect(refund.id).toBe('refund-1'); + expect(refundWriter.create).not.toHaveBeenCalled(); + expect(marketplace.refund).toHaveBeenCalledWith(expect.objectContaining({ refundId: 'refund-1' })); + }); + + it('refuses a different amount while another refund is pending', async () => { + refundReader.getByPurchaseId.mockResolvedValue([pendingRefund()]); + await expect(svc.issueManualRefund('purchase-1', 200, 'admin_manual', 'admin-1')).rejects.toThrow( + /already pending/, + ); + expect(marketplace.refund).not.toHaveBeenCalled(); + }); + + it('refuses once the purchase has a processed refund', async () => { + refundReader.getByPurchaseId.mockResolvedValue([pendingRefund({ processedAt: new Date() })]); + await expect(svc.issueManualRefund('purchase-1', 500, 'admin_manual', 'admin-1')).rejects.toThrow( + /already refunded/, + ); }); - ownerReader.findById.mockResolvedValue({ id: 'owner-1', marketplaceSellerKey: 'owner-1' }); - await svc.processMarketplaceRefund('refund-1'); - expect(marketplace.refund).toHaveBeenCalledWith( - expect.objectContaining({ paymentId: 'pay-1', sellerKey: 'owner-1' }), - ); }); });