From 21dddb0221aa75edd422a1f64d662623d0ed1888 Mon Sep 17 00:00:00 2001 From: manoahLinks Date: Tue, 21 Jul 2026 02:20:29 +0100 Subject: [PATCH] feat(events): on-chain crypto refund flow on event cancellation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire up the refund processing deferred in cancelEvent. Cancelling a PUBLISHED event with sold tickets now fans out one on-chain claimRefund per confirmed, minted crypto (USDC) ticket, signed by the buyer's Circle wallet (the contract gates on token ownership). Fiat tickets can't be refunded on-chain (claimRefund reverts FiatTicketNotRefundable) and are logged as needing off-chain handling. - schema: BlockchainTxType.REFUND + Ticket.refundedAt/refundTxHash - RefundQueueService: producer mirroring the mint queue (5 attempts, exp backoff, eager BlockchainTransaction audit row) - RefundProcessor: claimRefund via buyer wallet, webhook-or-poll completion, terminal-vs-retryable revert classification (RefundPeriodExpired/NotEnabled/etc → UnrecoverableError) - RefundFinalizerService: idempotent REFUNDED flip + soldCount release - circle-webhook.processor: route confirmed REFUND txs to finalizer - cancelEvent: enqueue refunds for crypto tickets Tests: refund.processor (7) + events.cancel (3); suite green. --- .../20260721010244_refund_flow/migration.sql | 6 + prisma/schema.prisma | 3 + src/blockchain/blockchain.module.ts | 13 + .../circle-webhook.processor.spec.ts | 3 + src/blockchain/circle-webhook.processor.ts | 9 + src/blockchain/refund-finalizer.service.ts | 85 ++++++ src/blockchain/refund-queue.service.ts | 74 +++++ src/blockchain/refund.processor.spec.ts | 165 ++++++++++ src/blockchain/refund.processor.ts | 287 ++++++++++++++++++ src/events/events.module.ts | 4 + src/events/events.service.cancel.spec.ts | 88 ++++++ src/events/events.service.ts | 34 ++- 12 files changed, 768 insertions(+), 3 deletions(-) create mode 100644 prisma/migrations/20260721010244_refund_flow/migration.sql create mode 100644 src/blockchain/refund-finalizer.service.ts create mode 100644 src/blockchain/refund-queue.service.ts create mode 100644 src/blockchain/refund.processor.spec.ts create mode 100644 src/blockchain/refund.processor.ts create mode 100644 src/events/events.service.cancel.spec.ts diff --git a/prisma/migrations/20260721010244_refund_flow/migration.sql b/prisma/migrations/20260721010244_refund_flow/migration.sql new file mode 100644 index 0000000..bf9d67a --- /dev/null +++ b/prisma/migrations/20260721010244_refund_flow/migration.sql @@ -0,0 +1,6 @@ +-- AlterEnum +ALTER TYPE "BlockchainTxType" ADD VALUE 'REFUND'; + +-- AlterTable +ALTER TABLE "tickets" ADD COLUMN "refund_tx_hash" TEXT, +ADD COLUMN "refunded_at" TIMESTAMP(3); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index eac41f4..fee6022 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -72,6 +72,7 @@ enum BlockchainTxType { WITHDRAW SET_FEES SET_ADMINS + REFUND } enum TicketAdminStatus { @@ -339,6 +340,8 @@ model Ticket { status TicketStatus @default(PENDING) deliveryChannel DeliveryChannel @map("delivery_channel") checkedInAt DateTime? @map("checked_in_at") + refundedAt DateTime? @map("refunded_at") + refundTxHash String? @map("refund_tx_hash") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/src/blockchain/blockchain.module.ts b/src/blockchain/blockchain.module.ts index ec5c749..51603bc 100644 --- a/src/blockchain/blockchain.module.ts +++ b/src/blockchain/blockchain.module.ts @@ -17,6 +17,12 @@ import { EventPublishProcessor } from './event-publish.processor'; import { MintFinalizerService } from './mint-finalizer.service'; import { MintQueueService, TICKET_MINT_QUEUE } from './mint-queue.service'; import { MintTicketProcessor } from './mint-ticket.processor'; +import { RefundFinalizerService } from './refund-finalizer.service'; +import { RefundProcessor } from './refund.processor'; +import { + RefundQueueService, + TICKET_REFUND_QUEUE, +} from './refund-queue.service'; import { TicketCheckinProcessor } from './ticket-checkin.processor'; /** @@ -49,6 +55,9 @@ import { TicketCheckinProcessor } from './ticket-checkin.processor'; BullModule.registerQueue({ name: 'event-publish' }), BullModule.registerQueue({ name: TICKET_MINT_QUEUE }), BullModule.registerQueue({ name: TICKET_CHECKIN_QUEUE }), + // Consumer side of the refund queue; EventsModule registers the + // producer side. registerQueue is idempotent across modules. + BullModule.registerQueue({ name: TICKET_REFUND_QUEUE }), // Consumer side of the Circle webhook queue. The WebhooksModule // registers the producer side; registerQueue is idempotent. BullModule.registerQueue({ name: CIRCLE_WEBHOOK_QUEUE }), @@ -61,6 +70,9 @@ import { TicketCheckinProcessor } from './ticket-checkin.processor'; MintFinalizerService, MintQueueService, MintTicketProcessor, + RefundFinalizerService, + RefundQueueService, + RefundProcessor, CheckinQueueService, TicketCheckinProcessor, CircleWebhookProcessor, @@ -71,6 +83,7 @@ import { TicketCheckinProcessor } from './ticket-checkin.processor'; CircleContractService, MintFinalizerService, MintQueueService, + RefundQueueService, CheckinQueueService, ], }) diff --git a/src/blockchain/circle-webhook.processor.spec.ts b/src/blockchain/circle-webhook.processor.spec.ts index 5c45313..569412b 100644 --- a/src/blockchain/circle-webhook.processor.spec.ts +++ b/src/blockchain/circle-webhook.processor.spec.ts @@ -43,6 +43,7 @@ function setup(opts: { const findBt = jest.fn().mockResolvedValue(opts.bt ?? null); const reconcile = jest.fn().mockResolvedValue(undefined); const finalize = jest.fn().mockResolvedValue(true); + const refundFinalize = jest.fn().mockResolvedValue(true); const enqueueMint = jest.fn().mockResolvedValue(undefined); const findDeposit = jest.fn().mockResolvedValue(opts.deposit ?? null); @@ -68,6 +69,7 @@ function setup(opts: { prisma as never, { reconcile } as never, { finalize } as never, + { finalize: refundFinalize } as never, { enqueueMint } as never, ); @@ -78,6 +80,7 @@ function setup(opts: { findBt, reconcile, finalize, + refundFinalize, enqueueMint, findDeposit, updateDeposit, diff --git a/src/blockchain/circle-webhook.processor.ts b/src/blockchain/circle-webhook.processor.ts index 615b314..46f8fc0 100644 --- a/src/blockchain/circle-webhook.processor.ts +++ b/src/blockchain/circle-webhook.processor.ts @@ -11,6 +11,7 @@ import { PrismaService } from '../prisma/prisma.service'; import { CircleContractService } from './circle-contract.service'; import { MintFinalizerService } from './mint-finalizer.service'; import { MintQueueService } from './mint-queue.service'; +import { RefundFinalizerService } from './refund-finalizer.service'; import { CIRCLE_WEBHOOK_JOB, CIRCLE_WEBHOOK_QUEUE, @@ -59,6 +60,7 @@ export class CircleWebhookProcessor extends WorkerHost { private readonly prisma: PrismaService, private readonly circle: CircleContractService, private readonly finalizer: MintFinalizerService, + private readonly refundFinalizer: RefundFinalizerService, private readonly mintQueue: MintQueueService, ) { super(); @@ -265,6 +267,13 @@ export class CircleWebhookProcessor extends WorkerHost { ); } await this.finalizer.finalize(bt.ticketId, tx.txHash); + } else if (bt.type === BlockchainTxType.REFUND && bt.ticketId) { + if (!tx.txHash) { + throw new Error( + `Confirmed refund webhook for ${circleTxId} has no txHash`, + ); + } + await this.refundFinalizer.finalize(bt.ticketId, tx.txHash); } else if (bt.type === BlockchainTxType.CHECKIN) { // The door already flipped the ticket to USED (#24); the // confirmed CHECKIN tx is the on-chain audit record, now diff --git a/src/blockchain/refund-finalizer.service.ts b/src/blockchain/refund-finalizer.service.ts new file mode 100644 index 0000000..69918c2 --- /dev/null +++ b/src/blockchain/refund-finalizer.service.ts @@ -0,0 +1,85 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { TicketStatus } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; + +/** + * Shared post-refund finalization. Given a confirmed `claimRefund` tx + * hash, flips the Ticket to REFUNDED, stamps `refundedAt`/`refundTxHash`, + * and releases the seat by decrementing the TicketType's soldCount. + * + * Extracted so both completion paths drive it identically: + * - the polling fallback in RefundProcessor, and + * - the Circle webhook handler, authoritative once + * `circle.webhooksEnabled` is on. + * + * Idempotent: a ticket already REFUNDED is a no-op, so whichever path + * wins the race finalizes and the other simply returns — the soldCount + * decrement therefore runs at most once per ticket. + */ +@Injectable() +export class RefundFinalizerService { + private readonly logger = new Logger(RefundFinalizerService.name); + + constructor(private readonly prisma: PrismaService) {} + + /** + * Finalize a refunded ticket from its confirmed tx hash. Returns true + * if it performed finalization, false if it was a no-op (already + * finalized by the other completion path). + */ + async finalize(ticketId: string, txHash: string): Promise { + const ticket = await this.prisma.ticket.findUnique({ + where: { id: ticketId }, + select: { id: true, status: true, ticketTypeId: true }, + }); + + if (!ticket) { + throw new Error(`Ticket ${ticketId} not found`); + } + + // Idempotency — already finalized (webhook + poll race, or webhook + // re-delivery). Guard the soldCount decrement behind the status flip + // so it can never double-count. + if (ticket.status === TicketStatus.REFUNDED) { + this.logger.log( + `Ticket ${ticketId} already refunded; skipping finalization`, + ); + return false; + } + + // Flip status + release the seat atomically. updateMany with a + // status guard makes the write a compare-and-set: a concurrent + // finalizer that already flipped the row updates zero rows, so we + // skip the decrement for it too. + const flipped = await this.prisma.$transaction(async (db) => { + const { count } = await db.ticket.updateMany({ + where: { id: ticketId, status: { not: TicketStatus.REFUNDED } }, + data: { + status: TicketStatus.REFUNDED, + refundedAt: new Date(), + refundTxHash: txHash, + }, + }); + + if (count === 0) { + return false; + } + + await db.ticketType.update({ + where: { id: ticket.ticketTypeId }, + data: { soldCount: { decrement: 1 } }, + }); + return true; + }); + + if (!flipped) { + this.logger.log( + `Ticket ${ticketId} refunded by a concurrent finalizer; skipping`, + ); + return false; + } + + this.logger.log(`Ticket refunded (ticket=${ticketId}, txHash=${txHash})`); + return true; + } +} diff --git a/src/blockchain/refund-queue.service.ts b/src/blockchain/refund-queue.service.ts new file mode 100644 index 0000000..064c2ef --- /dev/null +++ b/src/blockchain/refund-queue.service.ts @@ -0,0 +1,74 @@ +import { InjectQueue } from '@nestjs/bullmq'; +import { Injectable, Logger } from '@nestjs/common'; +import { BlockchainTxStatus, BlockchainTxType } from '@prisma/client'; +import { Queue } from 'bullmq'; +import { PrismaService } from '../prisma/prisma.service'; + +export const TICKET_REFUND_QUEUE = 'ticket-refund'; +export const REFUND_TICKET_JOB = 'refund-ticket'; + +export interface RefundTicketJobData { + ticketId: string; + eventId: string; + blockchainTxId: string; +} + +/** + * Producer for the `ticket-refund` queue. Called when a PUBLISHED event + * with sold tickets is cancelled — one enqueue per crypto-paid Ticket, + * so a cancelled event with N such tickets fans out into N independent + * refund jobs (mirrors MintQueueService). + * + * Only crypto (USDC) tickets are enqueued: the on-chain `claimRefund` + * reverts `FiatTicketNotRefundable` for fiat-minted tickets, so those + * are handled off-chain (out of scope here — cancelEvent logs them as + * pending). The producer stays rail-agnostic; the caller filters. + * + * The BlockchainTransaction row is written eagerly at enqueue time so we + * always have an audit row. The worker (RefundProcessor) attaches the + * Circle transaction id to this row when it submits to the chain. + */ +@Injectable() +export class RefundQueueService { + private readonly logger = new Logger(RefundQueueService.name); + + constructor( + private readonly prisma: PrismaService, + @InjectQueue(TICKET_REFUND_QUEUE) private readonly queue: Queue, + ) {} + + async enqueueRefund(ticketId: string, eventId: string): Promise { + const blockchainTx = await this.prisma.blockchainTransaction.create({ + data: { + ticketId, + eventId, + type: BlockchainTxType.REFUND, + status: BlockchainTxStatus.PENDING, + }, + }); + + await this.queue.add( + REFUND_TICKET_JOB, + { + ticketId, + eventId, + blockchainTxId: blockchainTx.id, + } satisfies RefundTicketJobData, + { + // Refund moves real on-chain value (escrowed USDC back to the + // buyer). Five attempts with exponential backoff rides out + // transient Circle outages; the processor short-circuits the + // contract's terminal reverts (window expired, not refundable) + // so those don't burn the full retry budget. + attempts: 5, + backoff: { type: 'exponential', delay: 10_000 }, + removeOnComplete: { age: 60 * 60 * 24, count: 1000 }, + removeOnFail: { age: 60 * 60 * 24 * 7 }, + }, + ); + + this.logger.log( + `Queued ticket-refund for ticket=${ticketId} event=${eventId} blockchainTxId=${blockchainTx.id}`, + ); + } +} diff --git a/src/blockchain/refund.processor.spec.ts b/src/blockchain/refund.processor.spec.ts new file mode 100644 index 0000000..6764c7f --- /dev/null +++ b/src/blockchain/refund.processor.spec.ts @@ -0,0 +1,165 @@ +import { + BlockchainTxStatus, + BlockchainTxType, + WalletCreationStatus, +} from '@prisma/client'; +import { UnrecoverableError, type Job } from 'bullmq'; +import { RefundProcessor } from './refund.processor'; +import { FEE_TYPE_USDC } from './onchain-fees'; +import { REFUND_TICKET_JOB, RefundTicketJobData } from './refund-queue.service'; + +function ticketRow(overrides: Record = {}) { + return { + id: 't-1', + status: 'CONFIRMED', + tokenId: 42, + ticketTypeId: 'tt-1', + ticketType: { onChainTicketId: 5n }, + transaction: { provider: 'CRYPTO' }, + event: { id: 'e-1', chain: 'BASE-SEPOLIA' }, + buyer: { + id: 'b-1', + wallets: [ + { + id: 'w-1', + chain: 'BASE-SEPOLIA', + address: '0xBUYER', + circleWalletId: 'cw-buyer', + creationStatus: WalletCreationStatus.CREATED, + }, + ], + }, + ...overrides, + }; +} + +function setup( + row: Record | null, + opts: { + poll?: Record; + webhooksEnabled?: boolean; + } = {}, +) { + const findUnique = jest.fn().mockResolvedValue(row); + const update = jest.fn().mockResolvedValue({}); + const executeContract = jest + .fn() + .mockResolvedValue({ circleTransactionId: 'refund-1' }); + const pollUntilTerminal = jest + .fn() + .mockResolvedValue(opts.poll ?? { state: 'CONFIRMED', txHash: '0xhash' }); + const finalize = jest.fn().mockResolvedValue(true); + const get = jest.fn().mockReturnValue(opts.webhooksEnabled ?? false); + + const proc = new RefundProcessor( + { ticket: { findUnique }, blockchainTransaction: { update } } as never, + { executeContract, pollUntilTerminal } as never, + { finalize } as never, + { get } as never, + ); + return { proc, executeContract, pollUntilTerminal, finalize, update }; +} + +function job(): Job { + return { + name: REFUND_TICKET_JOB, + data: { ticketId: 't-1', eventId: 'e-1', blockchainTxId: 'bt-1' }, + attemptsMade: 0, + opts: { attempts: 5 }, + } as Job; +} + +describe('RefundProcessor', () => { + it('crypto ticket: claimRefund signed by the buyer wallet, then finalize', async () => { + const m = setup(ticketRow()); + + await m.proc.process(job()); + + expect(m.executeContract).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'claimRefund', + args: [5n, FEE_TYPE_USDC, 42n, '0xBUYER'], + walletId: 'cw-buyer', // buyer signs — contract checks ownership + txType: BlockchainTxType.REFUND, + existingBlockchainTransactionId: 'bt-1', + chain: 'BASE-SEPOLIA', + }), + ); + expect(m.finalize).toHaveBeenCalledWith('t-1', '0xhash'); + }); + + it('webhook-authoritative: submits and returns without inline finalize', async () => { + const m = setup(ticketRow(), { webhooksEnabled: true }); + + await m.proc.process(job()); + + expect(m.executeContract).toHaveBeenCalled(); + expect(m.pollUntilTerminal).not.toHaveBeenCalled(); + expect(m.finalize).not.toHaveBeenCalled(); + }); + + it('already refunded: no-op, no on-chain call', async () => { + const m = setup(ticketRow({ status: 'REFUNDED' })); + + await m.proc.process(job()); + + expect(m.executeContract).not.toHaveBeenCalled(); + expect(m.finalize).not.toHaveBeenCalled(); + }); + + it('non-crypto ticket: fails terminally without an on-chain call', async () => { + const m = setup(ticketRow({ transaction: { provider: 'PAYSTACK' } })); + + await expect(m.proc.process(job())).rejects.toBeInstanceOf( + UnrecoverableError, + ); + expect(m.executeContract).not.toHaveBeenCalled(); + expect(m.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: BlockchainTxStatus.FAILED }), + }), + ); + }); + + it('never minted (no tokenId): fails terminally', async () => { + const m = setup(ticketRow({ tokenId: null })); + + await expect(m.proc.process(job())).rejects.toBeInstanceOf( + UnrecoverableError, + ); + expect(m.executeContract).not.toHaveBeenCalled(); + }); + + it('terminal on-chain revert (window expired): no retry, marks FAILED', async () => { + const m = setup(ticketRow(), { + poll: { state: 'FAILED', errorReason: 'RefundPeriodExpired' }, + }); + + await expect(m.proc.process(job())).rejects.toBeInstanceOf( + UnrecoverableError, + ); + expect(m.finalize).not.toHaveBeenCalled(); + expect(m.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'bt-1' }, + data: expect.objectContaining({ status: BlockchainTxStatus.FAILED }), + }), + ); + }); + + it('transient on-chain failure: retryable (plain error, not Unrecoverable)', async () => { + const m = setup(ticketRow(), { + poll: { state: 'FAILED', errorReason: 'network blip' }, + }); + + await expect(m.proc.process(job())).rejects.not.toBeInstanceOf( + UnrecoverableError, + ); + // Not final attempt → row stays PENDING for the next retry. + expect(m.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: BlockchainTxStatus.PENDING }), + }), + ); + }); +}); diff --git a/src/blockchain/refund.processor.ts b/src/blockchain/refund.processor.ts new file mode 100644 index 0000000..ca757ce --- /dev/null +++ b/src/blockchain/refund.processor.ts @@ -0,0 +1,287 @@ +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + BlockchainTxStatus, + BlockchainTxType, + PaymentProvider, + TicketStatus, + WalletCreationStatus, +} from '@prisma/client'; +import { Job, UnrecoverableError } from 'bullmq'; +import { Interface } from 'ethers'; +import { PrismaService } from '../prisma/prisma.service'; +import { diamondAbi } from './abis'; +import { CircleContractService } from './circle-contract.service'; +import { FEE_TYPE_USDC } from './onchain-fees'; +import { RefundFinalizerService } from './refund-finalizer.service'; +import { + REFUND_TICKET_JOB, + RefundTicketJobData, + TICKET_REFUND_QUEUE, +} from './refund-queue.service'; + +/** + * Contract reverts that no amount of retrying will fix — the refund is + * simply not allowed for this ticket. We short-circuit Bull's retry + * budget for these (UnrecoverableError → straight to the failed set) + * and mark the BlockchainTransaction FAILED for auditor review. + * + * `RefundPeriodNotReached` is intentionally NOT here: it means the + * on-chain window hasn't opened yet, which is transient-ish, so we let + * it ride the normal retry/backoff and land in `failed` only after the + * budget is spent (ops can re-trigger once the window opens). + */ +const TERMINAL_REFUND_ERRORS = new Set([ + 'RefundNotEnabled', + 'RefundPeriodExpired', + 'FiatTicketNotRefundable', + 'TicketNotOwned', + 'TicketDoesNotExist', + 'TicketIsFree', +]); + +/** + * Consumes `ticket-refund` jobs and issues the on-chain refund via + * `claimRefund(uint64 ticketId, FeeType feeType, uint256 tokenId, + * address to)` on the Diamond, signed by the BUYER's Circle wallet (the + * contract checks token ownership — the treasury cannot refund on their + * behalf). Escrowed USDC is returned to the buyer's wallet. + * + * Crypto (USDC) tickets only: `claimRefund` reverts + * `FiatTicketNotRefundable` for fiat-minted tickets, so the producer + * only enqueues CRYPTO tickets and this worker re-checks defensively. + * + * Completion mirrors the mint worker: + * - `circle.webhooksEnabled` ON: submit and return — the Circle webhook + * handler reconciles and calls RefundFinalizerService. + * - OFF (fallback): poll until terminal here, then finalize inline. + * Both converge on the same idempotent RefundFinalizerService. + */ +@Processor(TICKET_REFUND_QUEUE) +export class RefundProcessor extends WorkerHost { + private readonly logger = new Logger(RefundProcessor.name); + private readonly iface = new Interface(diamondAbi); + + constructor( + private readonly prisma: PrismaService, + private readonly circle: CircleContractService, + private readonly finalizer: RefundFinalizerService, + private readonly config: ConfigService, + ) { + super(); + } + + async process(job: Job): Promise { + if (job.name !== REFUND_TICKET_JOB) { + this.logger.warn( + `Unexpected job name on ${TICKET_REFUND_QUEUE}: ${job.name}`, + ); + return; + } + + const { ticketId, blockchainTxId } = job.data; + + const ticket = await this.prisma.ticket.findUnique({ + where: { id: ticketId }, + include: { + ticketType: { select: { onChainTicketId: true } }, + transaction: { select: { provider: true } }, + event: { select: { id: true, chain: true } }, + buyer: { include: { wallets: true } }, + }, + }); + + if (!ticket) { + throw new Error(`Ticket ${ticketId} not found`); + } + + // Idempotency — never re-refund a ticket that already settled. + if (ticket.status === TicketStatus.REFUNDED) { + this.logger.log(`Ticket ${ticketId} already refunded; skipping`); + return; + } + + // Crypto-only rail. Fiat tickets can't be refunded on-chain + // (claimRefund reverts FiatTicketNotRefundable); they're handled + // off-chain. Guard here even though the producer already filters. + if (ticket.transaction?.provider !== PaymentProvider.CRYPTO) { + this.logger.warn( + `Ticket ${ticketId} is not a crypto purchase (provider=${ticket.transaction?.provider ?? 'none'}); on-chain refund not applicable — skipping`, + ); + await this.markFailed( + blockchainTxId, + 'Non-crypto ticket — off-chain refund required', + ); + throw new UnrecoverableError( + 'Non-crypto ticket — no on-chain refund path', + ); + } + + if (ticket.ticketType.onChainTicketId === null) { + throw new Error( + `TicketType ${ticket.ticketTypeId} has no onChainTicketId — cannot refund`, + ); + } + if (ticket.tokenId === null) { + // Never minted on-chain → nothing to refund. Terminal. + await this.markFailed( + blockchainTxId, + 'Ticket has no on-chain tokenId — never minted', + ); + throw new UnrecoverableError( + `Ticket ${ticketId} has no tokenId; nothing to refund`, + ); + } + if (!ticket.buyer) { + await this.markFailed( + blockchainTxId, + 'Ticket has no buyer — no wallet to sign refund', + ); + throw new UnrecoverableError(`Ticket ${ticketId} has no buyer row`); + } + + const wallet = ticket.buyer.wallets.find( + (w) => w.chain === ticket.event.chain, + ); + if (!wallet?.circleWalletId || !wallet.address) { + throw new Error( + `Buyer ${ticket.buyer.id} has no usable wallet on ${ticket.event.chain} to sign the refund`, + ); + } + if (wallet.creationStatus !== WalletCreationStatus.CREATED) { + // Transient during provisioning: retry with backoff. + throw new Error( + `Buyer wallet ${wallet.id} not ready (status=${wallet.creationStatus}); cannot sign refund yet`, + ); + } + + try { + const { circleTransactionId } = await this.circle.executeContract({ + method: 'claimRefund', + args: [ + ticket.ticketType.onChainTicketId, // uint64 _ticketId + FEE_TYPE_USDC, // enum FeeType _feeType (USDC) + BigInt(ticket.tokenId), // uint256 _tokenId + wallet.address, // address _to (refund recipient) + ], + chain: ticket.event.chain, + txType: BlockchainTxType.REFUND, + eventId: ticket.event.id, + ticketId: ticket.id, + existingBlockchainTransactionId: blockchainTxId, + walletId: wallet.circleWalletId, // buyer signs — contract checks ownership + }); + + this.logger.log( + `claimRefund submitted (ticket=${ticket.id}, circleTxId=${circleTransactionId})`, + ); + + // Webhook is authoritative for completion when wired. + if (this.config.get('circle.webhooksEnabled')) { + this.logger.log( + `claimRefund awaiting Circle webhook for completion (ticket=${ticket.id})`, + ); + return; + } + + // Fallback: poll until terminal. A refund is one block. + const final = await this.circle.pollUntilTerminal(circleTransactionId, { + intervalMs: 4_000, + timeoutMs: 180_000, + }); + + if (final.state !== 'CONFIRMED' && final.state !== 'COMPLETE') { + // Classify the revert: a terminal contract error must not burn + // the whole retry budget. + const reason = final.errorReason ?? '(no reason)'; + if (this.isTerminalRefundError(reason)) { + throw new UnrecoverableError( + `claimRefund not permitted (ticket=${ticket.id}): ${this.describeError(reason)}`, + ); + } + throw new Error(`claimRefund on-chain state ${final.state}: ${reason}`); + } + if (!final.txHash) { + throw new Error('Circle reported terminal success without a txHash'); + } + + await this.finalizer.finalize(ticket.id, final.txHash); + } catch (error) { + const message = error instanceof Error ? error.message : 'unknown error'; + const terminal = + error instanceof UnrecoverableError || + this.isTerminalRefundError(message); + const isFinal = + terminal || job.attemptsMade + 1 >= (job.opts.attempts ?? 1); + + await this.prisma.blockchainTransaction.update({ + where: { id: blockchainTxId }, + data: { + status: isFinal + ? BlockchainTxStatus.FAILED + : BlockchainTxStatus.PENDING, + error: message.slice(0, 500), + }, + }); + + if (terminal && !(error instanceof UnrecoverableError)) { + // A terminal revert surfaced as a plain Error (e.g. thrown from + // executeContract). Re-wrap so Bull stops retrying. + this.logger.error( + `Refund not permitted (ticket=${ticketId}): ${message}`, + ); + throw new UnrecoverableError(message); + } + + if (isFinal) { + this.logger.error( + `Refund failed (ticket=${ticketId}, attempts=${job.attemptsMade + 1}): ${message}`, + ); + } else { + this.logger.warn( + `Refund attempt ${job.attemptsMade + 1} failed (ticket=${ticketId}): ${message}`, + ); + } + throw error; + } + } + + // ---------- internals ---------- + + private async markFailed( + blockchainTxId: string, + error: string, + ): Promise { + await this.prisma.blockchainTransaction.update({ + where: { id: blockchainTxId }, + data: { status: BlockchainTxStatus.FAILED, error: error.slice(0, 500) }, + }); + } + + /** + * True when a revert reason maps to a contract error that will never + * succeed on retry. Handles both a decoded name in the string and a + * raw ABI-encoded custom-error payload (0x… selector). + */ + private isTerminalRefundError(reason: string): boolean { + const name = this.decodeErrorName(reason); + if (name && TERMINAL_REFUND_ERRORS.has(name)) return true; + return [...TERMINAL_REFUND_ERRORS].some((e) => reason.includes(e)); + } + + private describeError(reason: string): string { + return this.decodeErrorName(reason) ?? reason; + } + + /** Parse a custom-error name out of raw revert data, if present. */ + private decodeErrorName(reason: string): string | null { + const match = reason.match(/0x[0-9a-fA-F]{8,}/); + if (!match) return null; + try { + return this.iface.parseError(match[0])?.name ?? null; + } catch { + return null; + } + } +} diff --git a/src/events/events.module.ts b/src/events/events.module.ts index 7196e0e..a463dc3 100644 --- a/src/events/events.module.ts +++ b/src/events/events.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { BullModule } from '@nestjs/bullmq'; import { EventsController } from './events.controller'; import { EventsService } from './events.service'; +import { BlockchainModule } from '../blockchain/blockchain.module'; import { NotificationsModule } from '../notifications/notifications.module'; @Module({ @@ -9,6 +10,9 @@ import { NotificationsModule } from '../notifications/notifications.module'; BullModule.registerQueue({ name: 'event-publish', }), + // RefundQueueService (producer for `ticket-refund`) — used to fan out + // refunds when a published event with sold tickets is cancelled. + BlockchainModule, NotificationsModule, ], controllers: [EventsController], diff --git a/src/events/events.service.cancel.spec.ts b/src/events/events.service.cancel.spec.ts new file mode 100644 index 0000000..1d56640 --- /dev/null +++ b/src/events/events.service.cancel.spec.ts @@ -0,0 +1,88 @@ +import { BadRequestException } from '@nestjs/common'; +import { EventStatus } from '@prisma/client'; +import { EventsService } from './events.service'; + +function eventRow(overrides: Record = {}) { + return { + id: 'e-1', + name: 'My Event', + slug: 'my-event', + organizerId: 'org-1', + status: EventStatus.PUBLISHED, + isRefundable: true, + ticketTypes: [{ soldCount: 3 }], + ...overrides, + }; +} + +function setup(row: Record, cryptoTicketIds: string[] = []) { + const findUnique = jest.fn().mockResolvedValue(row); + const update = jest + .fn() + .mockResolvedValue({ ...row, status: EventStatus.CANCELLED }); + const deleteMany = jest.fn().mockResolvedValue({}); + const findMany = jest + .fn() + .mockResolvedValue(cryptoTicketIds.map((id) => ({ id }))); + const enqueueRefund = jest.fn().mockResolvedValue(undefined); + + const service = new EventsService( + { + event: { findUnique, update }, + ticketType: { deleteMany }, + ticket: { findMany }, + } as never, + {} as never, // eventPublishQueue + {} as never, // notifications + { get: jest.fn() } as never, // configService + { enqueueRefund } as never, + ); + return { service, enqueueRefund, findMany, update }; +} + +describe('EventsService.cancel — refunds', () => { + it('enqueues one on-chain refund per confirmed crypto ticket', async () => { + const { service, enqueueRefund, findMany } = setup(eventRow(), [ + 't-1', + 't-2', + ]); + + await service.cancel('e-1', 'org-1'); + + // Query is scoped to confirmed, minted, crypto tickets on this event. + expect(findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + eventId: 'e-1', + tokenId: { not: null }, + }), + }), + ); + expect(enqueueRefund).toHaveBeenCalledTimes(2); + expect(enqueueRefund).toHaveBeenCalledWith('t-1', 'e-1'); + expect(enqueueRefund).toHaveBeenCalledWith('t-2', 'e-1'); + }); + + it('cancels without enqueuing when there are no crypto tickets', async () => { + const { service, enqueueRefund, update } = setup(eventRow(), []); + + await service.cancel('e-1', 'org-1'); + + expect(update).toHaveBeenCalledWith( + expect.objectContaining({ data: { status: EventStatus.CANCELLED } }), + ); + expect(enqueueRefund).not.toHaveBeenCalled(); + }); + + it('blocks cancelling a non-refundable published event with sold tickets', async () => { + const { service, enqueueRefund } = setup( + eventRow({ isRefundable: false }), + ['t-1'], + ); + + await expect(service.cancel('e-1', 'org-1')).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(enqueueRefund).not.toHaveBeenCalled(); + }); +}); diff --git a/src/events/events.service.ts b/src/events/events.service.ts index c826247..1d4f1c1 100644 --- a/src/events/events.service.ts +++ b/src/events/events.service.ts @@ -17,8 +17,11 @@ import { EventStatus, BlockchainTxType, BlockchainTxStatus, + PaymentProvider, + TicketStatus, Prisma, } from '@prisma/client'; +import { RefundQueueService } from '../blockchain/refund-queue.service'; import * as crypto from 'crypto'; import { computeUsdcFees, @@ -39,6 +42,7 @@ export class EventsService { @InjectQueue('event-publish') private readonly eventPublishQueue: Queue, private readonly notifications: NotificationsService, private readonly configService: ConfigService, + private readonly refundQueue: RefundQueueService, ) {} /** @@ -722,10 +726,34 @@ export class EventsService { }); } - // TODO: If tickets sold and refundable, queue refund processing (Phase 4/5) + // Fan out refunds for sold tickets. Only crypto (USDC) tickets can be + // refunded on-chain via claimRefund — fiat tickets revert + // FiatTicketNotRefundable and are settled off-chain (out of scope + // here). We enqueue one refund job per confirmed, minted crypto + // ticket; the RefundProcessor honors the contract's refund-window + // rules and marks jobs failed (for auditor review) when a refund is + // no longer permitted. if (totalSold > 0) { - this.logger.warn( - `Cancelled event ${eventId} has ${totalSold} sold tickets — refunds pending`, + const refundable = await this.prisma.ticket.findMany({ + where: { + eventId, + status: TicketStatus.CONFIRMED, + tokenId: { not: null }, + transaction: { provider: PaymentProvider.CRYPTO }, + }, + select: { id: true }, + }); + + for (const ticket of refundable) { + await this.refundQueue.enqueueRefund(ticket.id, eventId); + } + + const fiatPending = totalSold - refundable.length; + this.logger.log( + `Cancelled event ${eventId}: queued ${refundable.length} on-chain refund(s)` + + (fiatPending > 0 + ? `; ${fiatPending} non-crypto ticket(s) require off-chain refund handling` + : ''), ); }