Copy
Trading Bots
Events
More

NestJS Refund Bug Exposes Distributed System Pitfall

2026/07/18 06:48Browse 0

A founder recently reported a baffling issue: customers claimed they never received refunds, yet the payment provider's dashboard showed the refunds as processed. Support teams were stuck in a loop, telling customers the money was sent while customers insisted it never arrived. Both sides were technically correct, and that contradiction was the root of the problem.

The refund had indeed been completed on the provider's side. But the internal backend system never recorded it—no refund entry existed in the ledger, no email notification was sent, and the original transaction remained marked as paid. The money moved, but the system remained unaware.

Where the System Breaks

The refund flow seemed straightforward: call the provider's refund endpoint, then update the local database. A typical NestJS implementation might look like this:

```typescript

async processRefund(transactionId: string, amount: number) {

const result = await this.providerClient.refund(transactionId, amount);

const transaction = await this.transactionRepo.findOne({ where: { id: transactionId } });

transaction.status = 'refunded';

await this.transactionRepo.save(transaction);

return result;

}

```

This code assumes both operations always succeed together. In reality, the provider call can succeed while the database update fails—due to a server restart, a timeout, or an unrelated error interrupting the function. From the provider's perspective, the refund is done. From the internal system's perspective, nothing happened.

Treating Refunds as Events

The first fix was to stop treating a refund as a simple status flip on the original transaction. Instead, record it as its own event with its own history. A dedicated `RefundEvent` entity captures the refund from the moment it's initiated, before the provider even confirms anything:

```typescript

@Entity()

export class RefundEvent {

@PrimaryGeneratedColumn('uuid')

id: string;

@Column()

transactionId: string;

@Column()

amount: number;

@Column()

providerRefundId: string;

@Column({ default: 'initiated' })

status: string;

@CreateDateColumn()

createdAt: Date;

}

```

With this structure, the processing step becomes resilient to crashes. The refund row is saved as `initiated` before the provider call. If the process dies after the provider confirms but before the local update, there is a clear record that a refund was in progress and needs reconciliation.

Catching Stuck Refunds

A scheduled job runs every 15 minutes to find refunds stuck in `initiated` state for too long. It checks the provider's actual status and updates the local record accordingly, closing the gap without waiting for a customer complaint.

```typescript

@Cron('*/15 * * * *')

async checkStuckRefunds() {

const stuck = await this.refundRepo.find({ where: { status: 'initiated' } });

for (const refund of stuck) {

const providerStatus = await this.providerClient.getRefundStatus(refund.transactionId);

if (providerStatus.completed) {

refund.status = 'confirmed';

await this.refundRepo.save(refund);

}

}

}

```

The Bigger Picture

The problem wasn't caused by bad code in the traditional sense. Every line worked as written. The issue was assuming two separate operations—an external call and a local update—would always succeed or fail together, when nothing in a distributed system guarantees that. By treating refunds as tracked events rather than status flips, both crash resilience and the ability to catch slip-throughs became straightforward with NestJS queues and cron jobs.

The founder's question was simple: why do refunds keep disappearing? The honest answer was that they were never disappearing—they were just never fully arriving in the first place.

Disclaimer: This page may contain third-party information and does not necessarily reflect BYDFi's views or opinions. This content is for general reference only and does not constitute any representation, warranty, financial advice, or investment advice. BYDFi is not responsible for any errors, omissions, or any results arising from the use of such information. Virtual asset investments involve risks. Please carefully evaluate the risks of the product and your risk tolerance based on your financial situation. For more information, please refer to our Terms of Use and Risk Disclosure.