From b5ef0e7b48cd728991ac2beb25bb6bee302bf3f7 Mon Sep 17 00:00:00 2001 From: "Abdulmalik A." Date: Mon, 23 Feb 2026 16:38:55 +0100 Subject: [PATCH] implement xlm/usd price conversion service --- .env.example | 9 +- src/app.module.ts | 2 + src/stellar/price.service.spec.ts | 291 ++++++++++++++++++ src/stellar/price.service.ts | 152 +++++++++ src/stellar/stellar.module.ts | 10 + src/tickets/entities/ticket-pruchase.ts | 3 + .../provider/tickets-purchase.service.ts | 6 + src/tickets/tickets.module.ts | 4 +- 8 files changed, 475 insertions(+), 2 deletions(-) create mode 100644 src/stellar/price.service.spec.ts create mode 100644 src/stellar/price.service.ts create mode 100644 src/stellar/stellar.module.ts diff --git a/.env.example b/.env.example index 1e3de677..e96eebfc 100644 --- a/.env.example +++ b/.env.example @@ -46,4 +46,11 @@ FRONTEND_URL=http://localhost:3000 # Google OAuth Configuration GOOGLE_CLIENT_ID=your_google_client_id GOOGLE_CLIENT_SECRET=your_google_client_secret -GOOGLE_CALLBACK_URL=http://localhost:3002/auth/google/callback \ No newline at end of file +GOOGLE_CALLBACK_URL=http://localhost:3002/auth/google/callback + +# XLM Price Feed Configuration +# Options: "coingecko" (default) or "custom" +XLM_PRICE_API_SOURCE=coingecko +# Required when XLM_PRICE_API_SOURCE=custom +# Example: https://api.example.com/xlm-price +XLM_PRICE_API_URL= \ No newline at end of file diff --git a/src/app.module.ts b/src/app.module.ts index 0643e198..499b03a3 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -29,6 +29,7 @@ import { ConferenceGalleryModule } from "./conference-gallery/conference-gallery import { JwtStrategy } from "../security/strategies/jwt.strategy"; import { PromoCodeModule } from './promo-code/promo-code.module'; import { WebhookModule } from "./webhook/webhook.module"; +import { StellarModule } from "./stellar/stellar.module"; @Module({ imports: [ ConfigModule.forRoot({ @@ -76,6 +77,7 @@ import { WebhookModule } from "./webhook/webhook.module"; ConferenceGalleryModule, PromoCodeModule, AuditLogModule, + StellarModule, ], controllers: [AppController], providers: [AppService, PdfService, JwtStrategy], diff --git a/src/stellar/price.service.spec.ts b/src/stellar/price.service.spec.ts new file mode 100644 index 00000000..46210c9a --- /dev/null +++ b/src/stellar/price.service.spec.ts @@ -0,0 +1,291 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { ConfigService } from "@nestjs/config"; +import { HttpService } from "@nestjs/axios"; +import { CACHE_MANAGER } from "@nestjs/cache-manager"; +import { Cache } from "cache-manager"; +import { of, throwError } from "rxjs"; +import { PriceService } from "./price.service"; +import { ServiceUnavailableException } from "@nestjs/common"; + +describe("PriceService", () => { + let service: PriceService; + let httpService: HttpService; + let cacheManager: Cache; + let configService: ConfigService; + + const mockCacheManager = { + get: jest.fn(), + set: jest.fn(), + }; + + const mockConfigService = { + get: jest.fn(), + }; + + const mockHttpService = { + get: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PriceService, + { + provide: CACHE_MANAGER, + useValue: mockCacheManager, + }, + { + provide: ConfigService, + useValue: mockConfigService, + }, + { + provide: HttpService, + useValue: mockHttpService, + }, + ], + }).compile(); + + service = module.get(PriceService); + httpService = module.get(HttpService); + cacheManager = module.get(CACHE_MANAGER); + configService = module.get(ConfigService); + + jest.clearAllMocks(); + }); + + describe("getXLMUSDRate", () => { + it("should return cached rate when available (cache hit)", async () => { + const cachedRate = 0.1234; + mockCacheManager.get.mockResolvedValue(cachedRate); + + const result = await service.getXLMUSDRate(); + + expect(result).toBe(cachedRate); + expect(mockCacheManager.get).toHaveBeenCalledWith("xlm_usd_rate"); + expect(mockHttpService.get).not.toHaveBeenCalled(); + }); + + it("should fetch from CoinGecko API on cache miss and cache the result", async () => { + const apiRate = 0.15; + mockCacheManager.get.mockResolvedValue(null); + mockConfigService.get.mockReturnValue("coingecko"); + mockHttpService.get.mockReturnValue( + of({ + data: { + stellar: { + usd: apiRate, + }, + }, + }), + ); + + const result = await service.getXLMUSDRate(); + + expect(result).toBe(apiRate); + expect(mockCacheManager.get).toHaveBeenCalledWith("xlm_usd_rate"); + expect(mockHttpService.get).toHaveBeenCalledWith( + "https://api.coingecko.com/api/v3/simple/price?ids=stellar&vs_currencies=usd", + ); + expect(mockCacheManager.set).toHaveBeenCalledWith( + "xlm_usd_rate", + apiRate, + 60000, + ); + }); + + it("should throw ServiceUnavailableException when API fails", async () => { + mockCacheManager.get.mockResolvedValue(null); + mockConfigService.get.mockReturnValue("coingecko"); + mockHttpService.get.mockReturnValue( + throwError(() => new Error("Network error")), + ); + + await expect(service.getXLMUSDRate()).rejects.toThrow( + ServiceUnavailableException, + ); + }); + + it("should throw ServiceUnavailableException when API returns invalid rate", async () => { + mockCacheManager.get.mockResolvedValue(null); + mockConfigService.get.mockReturnValue("coingecko"); + mockHttpService.get.mockReturnValue( + of({ + data: { + stellar: { + usd: -1, + }, + }, + }), + ); + + await expect(service.getXLMUSDRate()).rejects.toThrow( + ServiceUnavailableException, + ); + }); + + it("should use custom API when configured", async () => { + const customUrl = "https://custom.api.com/xlm-price"; + const apiRate = 0.2; + mockCacheManager.get.mockResolvedValue(null); + mockConfigService.get.mockImplementation((key: string) => { + if (key === "XLM_PRICE_API_SOURCE") return "custom"; + if (key === "XLM_PRICE_API_URL") return customUrl; + return undefined; + }); + mockHttpService.get.mockReturnValue( + of({ + data: { price: apiRate }, + }), + ); + + const result = await service.getXLMUSDRate(); + + expect(result).toBe(apiRate); + expect(mockHttpService.get).toHaveBeenCalledWith(customUrl); + }); + + it("should throw error when custom API URL is not configured", async () => { + mockCacheManager.get.mockResolvedValue(null); + mockConfigService.get.mockImplementation((key: string) => { + if (key === "XLM_PRICE_API_SOURCE") return "custom"; + if (key === "XLM_PRICE_API_URL") return undefined; + return undefined; + }); + + await expect(service.getXLMUSDRate()).rejects.toThrow( + ServiceUnavailableException, + ); + }); + }); + + describe("convertUSDToXLM", () => { + it("should convert USD to XLM and round up to 7 decimal places", async () => { + const usdAmount = 100; + const rate = 0.1; // 1 XLM = $0.10, so $100 = 1000 XLM + mockCacheManager.get.mockResolvedValue(rate); + + const result = await service.convertUSDToXLM(usdAmount); + + expect(result).toBe(1000); + }); + + it("should round up to avoid underpayment (ceiling)", async () => { + const usdAmount = 1; + const rate = 0.3; // 1 XLM = $0.30, so $1 = 3.3333333... XLM + mockCacheManager.get.mockResolvedValue(rate); + + const result = await service.convertUSDToXLM(usdAmount); + + // Should round UP to 3.3333334 + expect(result).toBe(3.3333334); + }); + + it("should return 0 when USD amount is 0", async () => { + const result = await service.convertUSDToXLM(0); + + expect(result).toBe(0); + expect(mockCacheManager.get).not.toHaveBeenCalled(); + }); + + it("should throw error when USD amount is negative", async () => { + await expect(service.convertUSDToXLM(-100)).rejects.toThrow( + "USD amount cannot be negative", + ); + }); + + it("should handle small amounts with precision", async () => { + const usdAmount = 0.01; + const rate = 0.1; // 1 XLM = $0.10, so $0.01 = 0.1 XLM + mockCacheManager.get.mockResolvedValue(rate); + + const result = await service.convertUSDToXLM(usdAmount); + + expect(result).toBe(0.1); + }); + + it("should propagate ServiceUnavailableException when price feed fails", async () => { + mockCacheManager.get.mockResolvedValue(null); + mockConfigService.get.mockReturnValue("coingecko"); + mockHttpService.get.mockReturnValue( + throwError(() => new Error("Network error")), + ); + + await expect(service.convertUSDToXLM(100)).rejects.toThrow( + ServiceUnavailableException, + ); + }); + }); + + describe("Custom API response formats", () => { + beforeEach(() => { + mockCacheManager.get.mockResolvedValue(null); + mockConfigService.get.mockImplementation((key: string) => { + if (key === "XLM_PRICE_API_SOURCE") return "custom"; + if (key === "XLM_PRICE_API_URL") return "https://custom.api.com/price"; + return undefined; + }); + }); + + it("should handle 'rate' field in custom API response", async () => { + mockHttpService.get.mockReturnValue( + of({ data: { rate: 0.25 } }), + ); + + const result = await service.getXLMUSDRate(); + + expect(result).toBe(0.25); + }); + + it("should handle 'usd' field in custom API response", async () => { + mockHttpService.get.mockReturnValue( + of({ data: { usd: 0.35 } }), + ); + + const result = await service.getXLMUSDRate(); + + expect(result).toBe(0.35); + }); + + it("should handle 'value' field in custom API response", async () => { + mockHttpService.get.mockReturnValue( + of({ data: { value: 0.45 } }), + ); + + const result = await service.getXLMUSDRate(); + + expect(result).toBe(0.45); + }); + + it("should handle plain number response", async () => { + mockHttpService.get.mockReturnValue(of({ data: 0.55 })); + + const result = await service.getXLMUSDRate(); + + expect(result).toBe(0.55); + }); + + it("should handle plain string response", async () => { + mockHttpService.get.mockReturnValue(of({ data: "0.65" })); + + const result = await service.getXLMUSDRate(); + + expect(result).toBe(0.65); + }); + + it("should throw error for invalid response format", async () => { + mockHttpService.get.mockReturnValue(of({ data: null })); + + await expect(service.getXLMUSDRate()).rejects.toThrow( + ServiceUnavailableException, + ); + }); + + it("should throw error for NaN in response", async () => { + mockHttpService.get.mockReturnValue(of({ data: { price: "invalid" } })); + + await expect(service.getXLMUSDRate()).rejects.toThrow( + ServiceUnavailableException, + ); + }); + }); +}); diff --git a/src/stellar/price.service.ts b/src/stellar/price.service.ts new file mode 100644 index 00000000..a4f72c0e --- /dev/null +++ b/src/stellar/price.service.ts @@ -0,0 +1,152 @@ +import { Injectable, ServiceUnavailableException, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { HttpService } from "@nestjs/axios"; +import { CACHE_MANAGER } from "@nestjs/cache-manager"; +import { Inject } from "@nestjs/common"; +import { Cache } from "cache-manager"; +import { firstValueFrom } from "rxjs"; + +interface CoinGeckoResponse { + stellar?: { + usd?: number; + }; +} + +@Injectable() +export class PriceService { + private readonly logger = new Logger(PriceService.name); + private readonly CACHE_KEY = "xlm_usd_rate"; + private readonly CACHE_TTL = 60000; // 60 seconds in milliseconds + + constructor( + private readonly configService: ConfigService, + private readonly httpService: HttpService, + @Inject(CACHE_MANAGER) private readonly cacheManager: Cache, + ) {} + + /** + * Fetches the current XLM/USD exchange rate. + * Uses cache if available (60 second TTL). + * Supports CoinGecko API or custom price feed via environment configuration. + */ + async getXLMUSDRate(): Promise { + // Check cache first + const cachedRate = await this.cacheManager.get(this.CACHE_KEY); + if (cachedRate !== null && cachedRate !== undefined) { + this.logger.debug(`Cache hit: XLM/USD rate = ${cachedRate}`); + return cachedRate; + } + + this.logger.debug("Cache miss: Fetching XLM/USD rate from API"); + + try { + const rate = await this.fetchRateFromAPI(); + + // Store in cache for 60 seconds + await this.cacheManager.set(this.CACHE_KEY, rate, this.CACHE_TTL); + + this.logger.debug(`Fetched and cached XLM/USD rate = ${rate}`); + return rate; + } catch (error) { + this.logger.error(`Failed to fetch XLM/USD rate: ${error.message}`); + throw new ServiceUnavailableException( + "Price feed unavailable. Unable to retrieve XLM/USD exchange rate. Please try again later.", + ); + } + } + + /** + * Converts USD amount to XLM at the current market rate. + * Rounds up to 7 decimal places (Stellar's precision) to avoid underpayment. + */ + async convertUSDToXLM(usd: number): Promise { + if (usd < 0) { + throw new Error("USD amount cannot be negative"); + } + + if (usd === 0) { + return 0; + } + + const rate = await this.getXLMUSDRate(); + const xlmAmount = usd / rate; + + // Round up to 7 decimal places (Stellar's precision) + // Using ceiling to avoid underpayment edge cases + const factor = 10 ** 7; + const roundedXLM = Math.ceil(xlmAmount * factor) / factor; + + this.logger.debug(`Converted ${usd} USD to ${roundedXLM} XLM at rate ${rate}`); + + return roundedXLM; + } + + /** + * Fetches the rate from the configured API source. + * Supports CoinGecko (default) or custom API via XLM_PRICE_API_URL. + */ + private async fetchRateFromAPI(): Promise { + const apiSource = this.configService.get("XLM_PRICE_API_SOURCE", "coingecko"); + + if (apiSource === "coingecko") { + return this.fetchFromCoinGecko(); + } else { + return this.fetchFromCustomAPI(); + } + } + + /** + * Fetches XLM/USD rate from CoinGecko's free API. + */ + private async fetchFromCoinGecko(): Promise { + const url = "https://api.coingecko.com/api/v3/simple/price?ids=stellar&vs_currencies=usd"; + + const response = await firstValueFrom( + this.httpService.get(url), + ); + + const rate = response.data?.stellar?.usd; + + if (rate === undefined || rate === null || rate <= 0) { + throw new Error("Invalid rate received from CoinGecko API"); + } + + return rate; + } + + /** + * Fetches XLM/USD rate from a custom API endpoint. + * Expects JSON response with 'price' or 'rate' field, or plain text with the rate. + */ + private async fetchFromCustomAPI(): Promise { + const customUrl = this.configService.get("XLM_PRICE_API_URL"); + + if (!customUrl) { + throw new Error("XLM_PRICE_API_URL must be configured when using custom API source"); + } + + const response = await firstValueFrom( + this.httpService.get(customUrl), + ); + + let rate: number; + + // Handle different response formats + if (typeof response.data === "number") { + rate = response.data; + } else if (typeof response.data === "string") { + rate = parseFloat(response.data); + } else if (response.data && typeof response.data === "object") { + // Try common field names + rate = response.data.price || response.data.rate || response.data.usd || response.data.value; + } else { + throw new Error("Unexpected response format from custom price API"); + } + + if (rate === undefined || rate === null || isNaN(rate) || rate <= 0) { + throw new Error("Invalid rate received from custom price API"); + } + + return rate; + } +} diff --git a/src/stellar/stellar.module.ts b/src/stellar/stellar.module.ts new file mode 100644 index 00000000..dca799a3 --- /dev/null +++ b/src/stellar/stellar.module.ts @@ -0,0 +1,10 @@ +import { Module } from "@nestjs/common"; +import { HttpModule } from "@nestjs/axios"; +import { PriceService } from "./price.service"; + +@Module({ + imports: [HttpModule], + providers: [PriceService], + exports: [PriceService], +}) +export class StellarModule {} diff --git a/src/tickets/entities/ticket-pruchase.ts b/src/tickets/entities/ticket-pruchase.ts index 74806fe5..a4141505 100644 --- a/src/tickets/entities/ticket-pruchase.ts +++ b/src/tickets/entities/ticket-pruchase.ts @@ -31,6 +31,9 @@ import { @Column('decimal', { precision: 10, scale: 2 }) totalPrice: number; + + @Column('decimal', { precision: 20, scale: 7, nullable: true }) + totalAmountXLM: number; @Column('jsonb') billingDetails: { diff --git a/src/tickets/provider/tickets-purchase.service.ts b/src/tickets/provider/tickets-purchase.service.ts index e3adb4dd..71703199 100644 --- a/src/tickets/provider/tickets-purchase.service.ts +++ b/src/tickets/provider/tickets-purchase.service.ts @@ -14,6 +14,7 @@ import { v4 as uuidv4 } from "uuid"; import { TicketPurchase } from "../entities/ticket-pruchase"; import { UsersService } from "src/users/users.service"; import { TicketService } from "../tickets.service"; +import { PriceService } from "../../stellar/price.service"; @Injectable() export class TicketPurchaseService { @@ -30,6 +31,7 @@ export class TicketPurchaseService { private userServices: UsersService, private ticketServices: TicketService, + private priceService: PriceService, ) {} async purchaseTickets( @@ -55,6 +57,9 @@ export class TicketPurchaseService { // Calculate total price const totalPrice = ticket.price * createTicketPurchaseDto.ticketQuantity; + // Convert USD to XLM using the price service + const totalAmountXLM = await this.priceService.convertUSDToXLM(totalPrice); + // Process payment first const paymentSuccessful = await this.paymentService.processPayment( totalPrice, @@ -89,6 +94,7 @@ export class TicketPurchaseService { ticket, ticketQuantity: createTicketPurchaseDto.ticketQuantity, totalPrice, + totalAmountXLM, billingDetails: createTicketPurchaseDto.billingDetails, addressDetails: createTicketPurchaseDto.addressDetails, }); diff --git a/src/tickets/tickets.module.ts b/src/tickets/tickets.module.ts index 75376e96..393a5ebc 100644 --- a/src/tickets/tickets.module.ts +++ b/src/tickets/tickets.module.ts @@ -20,6 +20,7 @@ import { Receipt } from "./entities/receipt.entity"; import { StripeModule } from "../payment/stripe.module"; import { PaymentModule } from '../payment/payment.module'; import { PromoCodeModule } from "src/promo-code/promo-code.module"; +import { StellarModule } from "../stellar/stellar.module"; @Module({ imports: [ @@ -36,7 +37,8 @@ import { PromoCodeModule } from "src/promo-code/promo-code.module"; EventsModule, StripeModule, PaymentModule, - PromoCodeModule + PromoCodeModule, + StellarModule, ], controllers: [TicketController, TicketPurchaseController], providers: [