DEV Community

Cover image for The Second Your Money Changes Currency, NestJS Has to Catch Up
Peace Melodi
Peace Melodi

Posted on

The Second Your Money Changes Currency, NestJS Has to Catch Up

A customer holds a wallet in dollars. She wants to send money to a supplier in the United Kingdom who only accepts pounds. Somewhere in between, that money has to exist briefly in a form that is neither dollars nor pounds, a kind of intermediate value that gets converted at a specific rate, at a specific time, and every step of that conversion has to be recorded precisely enough that both sides can trust the final number.

This is the part of fintech backend work that looks simple from the outside and turns out to be one of the fastest ways to lose real money if it is modeled carelessly. A wallet that only ever holds one currency is a spreadsheet. A wallet that has to hold several currencies, convert between them, and stay correct under concurrent access is a real system, and it needs to be treated like one from the start.

Why a single balance column breaks immediately

The natural first instinct is to give a wallet a single balance field and a currency field, then update the balance whenever money moves. This works fine until a customer holds funds in more than one currency at once, which is the entire point of a multi currency wallet. The moment that happens, a single balance column cannot represent reality anymore.

The correct starting point is treating each currency a wallet holds as its own separate, trackable balance, all belonging to the same wallet owner.

@Entity()
export class Wallet {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column()
  ownerId: string;

  @OneToMany(() => WalletBalance, (balance) => balance.wallet)
  balances: WalletBalance[];
}

@Entity()
export class WalletBalance {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @ManyToOne(() => Wallet, (wallet) => wallet.balances)
  wallet: Wallet;

  @Column()
  currency: string;

  @Column('numeric', { precision: 18, scale: 6 })
  amount: string;

  @VersionColumn()
  version: number;
}
Enter fullscreen mode Exit fullscreen mode

Notice the amount is stored as a numeric string, not a floating point number. Money should never be represented as a float in a backend, since floating point rounding errors are exactly the kind of thing that quietly corrupts financial data over time.

Structuring a currency conversion as its own tracked operation

Converting money between currencies is not a single update, it is really two linked operations, a debit in one currency and a credit in another, tied together by the exchange rate that was actually used at that exact moment. That rate needs to be stored, not just applied and forgotten, because a customer or an auditor may later ask exactly what rate was used for a specific transfer.

@Entity()
export class CurrencyConversion {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column()
  walletId: string;

  @Column()
  fromCurrency: string;

  @Column()
  toCurrency: string;

  @Column('numeric', { precision: 18, scale: 6 })
  fromAmount: string;

  @Column('numeric', { precision: 18, scale: 6 })
  toAmount: string;

  @Column('numeric', { precision: 18, scale: 8 })
  exchangeRate: string;

  @CreateDateColumn()
  executedAt: Date;
}
Enter fullscreen mode Exit fullscreen mode

Recording the conversion this way means the wallet balances always reflect the outcome, and the conversion record explains exactly how that outcome was reached.

Keeping conversions correct under concurrent access

Two conversions hitting the same wallet balance at nearly the same moment is a real risk, especially at any real scale. NestJS's dependency injection makes it straightforward to keep this logic in one dedicated service, and using the version column defined earlier gives you optimistic locking, so a conflicting update fails cleanly instead of silently overwriting another one.

@Injectable()
export class ConversionService {
  constructor(
    @InjectRepository(WalletBalance)
    private readonly balanceRepo: Repository<WalletBalance>,
    @InjectRepository(CurrencyConversion)
    private readonly conversionRepo: Repository<CurrencyConversion>,
    private readonly rateService: ExchangeRateService,
  ) {}

  async convert(
    walletId: string,
    fromCurrency: string,
    toCurrency: string,
    fromAmount: string,
  ): Promise<CurrencyConversion> {
    const fromBalance = await this.balanceRepo.findOneOrFail({
      where: { wallet: { id: walletId }, currency: fromCurrency },
    });

    if (Number(fromBalance.amount) < Number(fromAmount)) {
      throw new BadRequestException('Insufficient balance for conversion');
    }

    const rate = await this.rateService.getCurrentRate(fromCurrency, toCurrency);
    const toAmount = (Number(fromAmount) * rate).toFixed(6);

    let toBalance = await this.balanceRepo.findOne({
      where: { wallet: { id: walletId }, currency: toCurrency },
    });

    if (!toBalance) {
      toBalance = this.balanceRepo.create({
        wallet: { id: walletId },
        currency: toCurrency,
        amount: '0',
      });
    }

    fromBalance.amount = (Number(fromBalance.amount) - Number(fromAmount)).toFixed(6);
    toBalance.amount = (Number(toBalance.amount) + Number(toAmount)).toFixed(6);

    await this.balanceRepo.save([fromBalance, toBalance]);

    return this.conversionRepo.save({
      walletId,
      fromCurrency,
      toCurrency,
      fromAmount,
      toAmount,
      exchangeRate: rate.toString(),
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

If two conversions try to update the same balance at once, the version column causes the second save to fail with an optimistic locking error, which the caller can catch and safely retry against the fresh balance, rather than silently applying two updates on top of stale data.

Handling exchange rates as something that changes constantly

Exchange rates are not static values you hardcode, they shift constantly and need to come from a reliable source, cached briefly so you are not calling an external provider on every single request. A dedicated module keeps this concern separate from the wallet logic itself.

@Injectable()
export class ExchangeRateService {
  private cache = new Map<string, { rate: number; fetchedAt: number }>();
  private readonly cacheTtlMs = 60000;

  constructor(private readonly httpService: HttpService) {}

  async getCurrentRate(from: string, to: string): Promise<number> {
    const key = `${from}_${to}`;
    const cached = this.cache.get(key);

    if (cached && Date.now() - cached.fetchedAt < this.cacheTtlMs) {
      return cached.rate;
    }

    const response = await firstValueFrom(
      this.httpService.get(`/rates?from=${from}&to=${to}`),
    );

    const rate = response.data.rate;
    this.cache.set(key, { rate, fetchedAt: Date.now() });

    return rate;
  }
}
Enter fullscreen mode Exit fullscreen mode

Keeping the cache window short matters here. Too long and customers get stale rates that do not match what the provider is currently quoting, too short and you are calling an external API on nearly every request. A minute is often a reasonable starting point, adjusted based on how volatile the currency pair actually is.

The bigger picture

None of this complexity comes from NestJS itself, it comes from the nature of money that moves between currencies. What NestJS gives you is a clean place to put each concern, wallet balances as their own entities, conversions as their own auditable records, exchange rates handled by a dedicated service, and concurrency safety built in through the same tools the framework already gives you for other parts of the system.

A multi currency wallet that looks simple in a demo can fall apart quickly in production if any of these pieces are treated as an afterthought. Getting the structure right early is far cheaper than untangling incorrect balances after real customer money has already moved through the system.

If you are building a wallet system that needs to handle multiple currencies correctly under real production conditions, this is exactly the kind of work I focus on.

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 (2)

Collapse
 
johnfrandsen profile image
John Frandsen

Great structural breakdown of the conversion-as-first-class-entity pattern. One thing I'd add from working with PSD2 bank transaction feeds: the conversion problem gets meaningfully harder when the FX is happening upstream at the bank rather than inside your wallet.

When you import transactions from bank APIs, a currency conversion the bank executed on your behalf arrives as two separate entries — a debit in the source currency and a credit in the destination currency — in two different accounts, with no shared parent record. Your CurrencyConversion entity elegantly models wallet-initiated conversions, but bank-side FX requires reconstruction from reference numbers (end-to-end ID, remittance information) or timing windows, because the bank's ledger doesn't expose the link explicitly.

The exchange rate precision point matters more than it first appears. ISO 20022 bank transaction reports (camt.053) include a structured currencyExchange block with the rate the bank actually applied, not a mid-market rate. That rate is authoritative — it's what left the account. Storing it alongside your cached system rate lets you explain the delta when an auditor or customer asks why the conversion looks off. The difference is the bank's FX spread, and it's not small.

One subtle trap with numeric(18,6): ISO 4217 defines different minor-unit precisions per currency. JPY and KRW have zero decimal places, most majors have two, and BHD/IQD/JOD have three. Your storage precision is fine as a maximum-denominator container, but the conversion output has to round to the destination currency's actual minor-unit precision. Storing sub-yen fractions for a JPY destination creates phantom amounts that will surface during reconciliation.

The bookingDate vs valueDate split is also worth watching for FX transactions specifically — these can differ by T+2 or more, which shifts which reporting period a conversion lands in. The conversion is economically effective on the value date but operationally recorded on the booking date, and both are present in the bank transaction payload.

Collapse
 
peacemelodi profile image
Peace Melodi

Good breakdown, and this is a real gap in the article. Bank side FX arriving as two disconnected entries, with no shared parent record, means reconstruction has to lean on end to end ID or timing windows instead of a clean link. Fair point too on the bank's actual applied rate from camt.053 being authoritative over a cached mid market rate, since that difference is the real FX spread an auditor would ask about. The minor unit precision catch is the sharpest one. Storing everything at numeric(18,6) as a container is fine, but rounding the actual output to the destination currency's real precision matters, otherwise you get phantom sub yen amounts sitting in a JPY balance that never should have existed. Booking date versus value date is a good one to flag too, since the conversion being economically effective on one date but recorded on another can genuinely shift which reporting period it lands in. Appreciate you laying all this out.