Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion apps/api/src/routes/__tests__/webhooks.marketplace.v1.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
});
Expand Down Expand Up @@ -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);
Expand Down
7 changes: 6 additions & 1 deletion apps/api/src/routes/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -104,7 +107,9 @@ function getRefundService(): IRefundWriter {
entitlementRepo,
smsService,
ownerRepo,
marketplace
marketplace,
new LedgerService(ledgerRepo, ownerRepo),
ledgerRepo
);
}
return refundServiceInstance;
Expand Down
10 changes: 8 additions & 2 deletions apps/api/src/routes/webhooks.marketplace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?: {
Expand Down Expand Up @@ -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(),
});
}
}
}
Expand Down
84 changes: 60 additions & 24 deletions apps/api/src/services/RefundService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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<LedgerService, 'createRefundLedgerEntries'>,
private ledgerReader: Pick<ILedgerReader, 'findByReference'>
) {}

async evaluateRefundEligibility(purchaseId: string): Promise<RefundEvaluation> {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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');
Expand All @@ -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<void> {
await this.processMarketplaceRefund(refundId);
}
Expand Down Expand Up @@ -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;
}
Expand All @@ -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<void> {
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
*/
Expand Down
Loading
Loading