Most banking backend tutorials focus on transactions your own system creates. A user taps a button, your service writes a record, and you control every part of that process from start to finish.
But in a real fintech product, a large portion of the transaction data your system deals with does not originate inside your system at all. It comes from the customer's actual bank, through an Open Banking style API. That data has already passed through at least one external step before it ever reaches you, so it cannot be treated with the same confidence as a transfer your own service generated.
This is where a lot of backends quietly get into trouble. They treat everything that lands in the database the same way, whether it came from their own logic or from an outside provider. NestJS gives you the structure to draw a clear line between the two, and to treat provider data with the caution it actually deserves.
Why external data is not the same as internal data
When your own service creates a transaction, you control every step of that process, so you can trust it completely by the time it reaches your database.
When a transaction comes from an outside provider, you are only seeing a copy of what the bank recorded, not the original event itself. That copy needs to be checked and matched against your own ledger before you treat it as fact. It can arrive late, arrive twice, or arrive with slightly different metadata than a previous version of the same event.
A useful mental model here is to treat anything coming from outside your system as unverified input, not as a trusted fact, until it has been checked against what you already know.
The duplicate entry problem
If you are polling an external provider on a schedule to pull in new transactions, duplicates are almost guaranteed to happen at some point. A network retry, an overlapping polling window, or a provider sending the same batch twice can all result in the same transaction reaching your system more than once.
The fix is to key off the bank's own transaction id, not one your own system generated, and to check for an existing record before inserting anything new.
@Injectable()
export class BankIngestionService {
constructor(
@InjectRepository(ExternalTransaction)
private readonly externalTransactionRepo: Repository<ExternalTransaction>,
) {}
async ingestTransaction(payload: ProviderTransactionDto): Promise<void> {
const existing = await this.externalTransactionRepo.findOne({
where: { providerTransactionId: payload.providerTransactionId },
});
if (existing) {
// Already recorded, safe to ignore or update metadata only
return;
}
const record = this.externalTransactionRepo.create({
providerTransactionId: payload.providerTransactionId,
amount: payload.amount,
currency: payload.currency,
rawPayload: payload,
reconciled: false,
});
await this.externalTransactionRepo.save(record);
}
}
Notice that the provider's transaction id is the thing being checked, not anything generated locally. Your own id generation has no way of knowing whether an event has already been seen by the outside world.
When the call itself fails partway through
Pulling data from an external provider is not a clean, all or nothing operation the way a database transaction inside your own system can be. A token can expire mid sync. A batch of transactions can partially load before a request times out. Your system needs to know exactly where it stopped, so the next attempt does not miss transactions or record the same one twice.
A simple way to handle this in NestJS is to track a sync checkpoint per provider connection, and only advance it once a batch has been fully and successfully processed.
@Injectable()
export class BankSyncService {
constructor(
private readonly bankIngestionService: BankIngestionService,
@InjectRepository(SyncCheckpoint)
private readonly checkpointRepo: Repository<SyncCheckpoint>,
) {}
async syncAccount(accountId: string): Promise<void> {
const checkpoint = await this.checkpointRepo.findOne({
where: { accountId },
});
const fromCursor = checkpoint?.lastCursor ?? null;
const batch = await this.fetchTransactionsFromProvider(accountId, fromCursor);
for (const transaction of batch.transactions) {
await this.bankIngestionService.ingestTransaction(transaction);
}
await this.checkpointRepo.save({
accountId,
lastCursor: batch.nextCursor,
lastSyncedAt: new Date(),
});
}
private async fetchTransactionsFromProvider(accountId: string, cursor: string | null) {
// Call to the actual Open Banking provider goes here
// Returns a batch of transactions plus the next cursor to resume from
return { transactions: [], nextCursor: cursor };
}
}
If the process fails halfway through the loop, the checkpoint has not moved yet, so the next run picks up from the same place instead of skipping ahead or starting completely over.
Applying the same discipline at a new boundary
If you have worked with NestJS on the request side, you already know the pattern. Guards protect requests coming into your system, and interceptors let you log and shape what happens as a request moves through your application.
The same mindset needs to wrap around the outbound call to the external provider too, not just your own controllers. An interceptor style wrapper around your provider calls lets you log every attempt, what came back, and whether it was successfully reconciled, without scattering that logic across every place you happen to call the provider.
@Injectable()
export class ProviderCallLogger {
private readonly logger = new Logger(ProviderCallLogger.name);
async wrap<T>(callName: string, fn: () => Promise<T>): Promise<T> {
const startedAt = Date.now();
try {
const result = await fn();
this.logger.log(`${callName} succeeded in ${Date.now() - startedAt}ms`);
return result;
} catch (error) {
this.logger.error(`${callName} failed after ${Date.now() - startedAt}ms`, error.stack);
throw error;
}
}
}
Wrapping every external call this way means that when something goes wrong three weeks from now, you have a clear record of exactly what was called, when, and what came back, instead of trying to guess after the fact.
The bigger picture
NestJS does not automatically make external data trustworthy. No framework can do that on its own. What it gives you is a clear place to put the discipline that external data needs, checkpoints as their own tracked entities, ingestion logic separated from your core business logic, and consistent logging around every outbound call.
The pattern is simple once it clicks. Anything that enters your system from outside stays unreconciled until it has been checked against your own records. NestJS's structure, services, repositories, and interceptors, gives you a natural place to enforce that instead of leaving it to scattered checks across your codebase.
If you are building a product that pulls in financial data from outside providers and want this handled properly from the start, this is exactly the kind of problem worth getting right early, since fixing it after the fact usually means untangling data that has already been trusted for too long.
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