A founder once messaged me with a problem that sounded almost too simple to be real. Customers were saying they never received their refund. Support would check the payment provider's dashboard, see the refund clearly marked as processed, and tell the customer it was sent. The customer would insist it never showed up. Both sides were technically right, and that is exactly what made it so hard to track down.
The refund had genuinely gone through on the provider's side. What had not happened was anything on the backend's side. The internal record still showed the original payment as final, no refund entry existed anywhere in the ledger, and no email had gone out to tell the customer anything had changed. The money moved, but the system never found out.
Where this actually breaks
The refund flow looked simple on paper, call the provider's refund endpoint, and if it succeeds, mark the transaction as refunded.
@Injectable()
export class RefundsService {
constructor(
private readonly providerClient: PaymentProviderClient,
private readonly transactionRepo: Repository<Transaction>,
) {}
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 the call to the provider and the update to the internal database always both succeed together. In reality, the refund call to the provider can succeed while the very next line, updating the local database, fails or never runs at all, because the server restarted, the request timed out on the way back, or an unrelated error interrupted the function before it finished. From the provider's point of view, the refund is done. From your own system's point of view, nothing happened.
Treating a refund like its own event, not a status flip
The first real fix was similar to something I have run into with payments generally, stop treating a refund as simply flipping a status on the original transaction, and instead record it as its own event with its own history.
@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;
}
This gives the refund its own identity from the moment it starts, before the provider has even confirmed anything, so there is always a record that a refund was attempted, even if a later step fails.
Making the refund resilient to a crash in the middle
With the refund now recorded as its own row, the actual processing step can be structured so that a crash between the provider call and the internal update does not leave things silently inconsistent.
@Injectable()
export class RefundsService {
constructor(
private readonly providerClient: PaymentProviderClient,
private readonly refundRepo: Repository<RefundEvent>,
@InjectQueue('refund-notifications') private readonly notifyQueue: Queue,
) {}
async processRefund(transactionId: string, amount: number) {
const refund = await this.refundRepo.save({
transactionId,
amount,
status: 'initiated',
providerRefundId: '',
});
const providerResult = await this.providerClient.refund(transactionId, amount);
refund.providerRefundId = providerResult.id;
refund.status = 'confirmed';
await this.refundRepo.save(refund);
await this.notifyQueue.add('refund-confirmed', {
transactionId,
amount,
});
return refund;
}
}
The row exists as initiated before the provider is even called, so if the process dies right after the provider confirms but before the local update finishes, there is still a clear record that a refund was in progress and needs to be checked, rather than nothing at all.
Catching the ones that got stuck in the middle
Recording the refund as its own event also makes it possible to find and fix the exact case that started this whole problem, refunds that succeeded with the provider but never finished updating locally. A scheduled job can look for anything stuck in the initiated state for longer than it should reasonably take.
@Injectable()
export class StuckRefundsChecker {
constructor(
private readonly refundRepo: Repository<RefundEvent>,
private readonly providerClient: PaymentProviderClient,
) {}
@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);
}
}
}
}
This job runs every fifteen minutes, quietly closing the gap between what the provider actually did and what the internal system knows about, without needing a customer to complain first before anyone finds out.
The bigger picture
Nothing about this problem was caused by bad code in the traditional sense. Every individual line worked exactly 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 actually guarantees that. Once refunds were treated as their own tracked event instead of a simple status flip, both the crash resilience and the ability to catch anything that slipped through became straightforward to build with NestJS's queues and scheduled jobs.
The founder's original question was simple, why do refunds keep disappearing, but the honest answer was that they were never disappearing, they were just never fully arriving in the first place.
If your team has ever had a similar gap between what your payment provider says happened and what your own system actually recorded, that is exactly the kind of problem worth catching early.
I am Peace Melodi, a backend software engineer. If you want your business to scale big, comfortably handling millions of users without breaking, with strong scalability and security in place, feel free to reach out.
LinkedIn: https://www.linkedin.com/in/melodi-peace-406494368
GitHub: https://github.com/PeaceMelodi
Top comments (0)