@@ -4,15 +4,25 @@ import { debugLog, maskHeaders } from '../../util/logger'
44import {
55 asPhazeCreateOrderResponse ,
66 asPhazeError ,
7+ asPhazeFxRatesResponse ,
78 asPhazeGiftCardsResponse ,
89 asPhazeOrderStatusResponse ,
910 asPhazeRegisterUserResponse ,
1011 asPhazeTokensResponse ,
1112 type PhazeCreateOrderRequest ,
13+ type PhazeFxRate ,
14+ type PhazeGiftCardBrand ,
1215 type PhazeOrderStatusResponse ,
1316 type PhazeRegisterUserRequest
1417} from './phazeGiftCardTypes'
1518
19+ // ---------------------------------------------------------------------------
20+ // Constants
21+ // ---------------------------------------------------------------------------
22+
23+ /** Minimum card value in USD. Cards below this are filtered out. */
24+ export const MINIMUM_CARD_VALUE_USD = 5
25+
1626// ---------------------------------------------------------------------------
1727// Field definitions for different use cases
1828// ---------------------------------------------------------------------------
@@ -71,6 +81,16 @@ export interface PhazeApi {
7181
7282 // Endpoints:
7383 getTokens : ( ) => Promise < ReturnType < typeof asPhazeTokensResponse > >
84+ /**
85+ * Ensures FX rates are fetched and cached.
86+ * Called during provider initialization to guarantee rates are available.
87+ */
88+ ensureFxRates : ( ) => Promise < void >
89+ /**
90+ * Returns cached FX rates. Guaranteed to be available after
91+ * ensureFxRates() completes (called during provider init).
92+ */
93+ getCachedFxRates : ( ) => PhazeFxRate [ ] | null
7494 getGiftCards : ( params : {
7595 countryCode : string
7696 currentPage ?: number
@@ -101,6 +121,7 @@ export interface PhazeApi {
101121
102122export const makePhazeApi = ( config : PhazeApiConfig ) : PhazeApi => {
103123 let userApiKey = config . userApiKey
124+ let cachedFxRates : PhazeFxRate [ ] | null = null
104125
105126 const makeHeaders = ( opts ?: {
106127 includeUserKey ?: boolean
@@ -179,6 +200,22 @@ export const makePhazeApi = (config: PhazeApiConfig): PhazeApi => {
179200 return response
180201 }
181202
203+ /** Fetch FX rates, using cached value if available */
204+ const getOrFetchFxRates = async ( ) : Promise < PhazeFxRate [ ] > => {
205+ if ( cachedFxRates != null ) return cachedFxRates
206+ const response = await fetchPhaze ( buildUrl ( '/crypto/exchange-rates' ) , {
207+ headers : makeHeaders ( )
208+ } )
209+ const text = await response . text ( )
210+ debugLog (
211+ 'phaze' ,
212+ `getFxRates response: ${ response . status } ${ response . statusText } `
213+ )
214+ const parsed = asJSON ( asPhazeFxRatesResponse ) ( text )
215+ cachedFxRates = parsed . rates
216+ return cachedFxRates
217+ }
218+
182219 return {
183220 setUserApiKey : ( key ?: string ) => {
184221 userApiKey = key
@@ -199,34 +236,54 @@ export const makePhazeApi = (config: PhazeApiConfig): PhazeApi => {
199236 return asJSON ( asPhazeTokensResponse ) ( text )
200237 } ,
201238
239+ ensureFxRates : async ( ) => {
240+ await getOrFetchFxRates ( )
241+ } ,
242+
243+ getCachedFxRates : ( ) => cachedFxRates ,
244+
202245 // GET /gift-cards/:country
203246 getGiftCards : async params => {
204247 const { countryCode, currentPage = 1 , perPage = 50 , brandName } = params
205- const response = await fetchPhaze (
206- buildUrl ( `/gift-cards/${ countryCode } ` , {
207- currentPage,
208- perPage,
209- brandName
210- } ) ,
211- {
212- headers : makeHeaders ( { includePublicKey : true } )
213- }
214- )
248+ const [ response , fxRates ] = await Promise . all ( [
249+ fetchPhaze (
250+ buildUrl ( `/gift-cards/${ countryCode } ` , {
251+ currentPage,
252+ perPage,
253+ brandName
254+ } ) ,
255+ {
256+ headers : makeHeaders ( { includePublicKey : true } )
257+ }
258+ ) ,
259+ getOrFetchFxRates ( )
260+ ] )
215261 const text = await response . text ( )
216- return asJSON ( asPhazeGiftCardsResponse ) ( text )
262+ const parsed = asJSON ( asPhazeGiftCardsResponse ) ( text )
263+ return {
264+ ...parsed ,
265+ brands : filterBrandsByMinimum ( parsed . brands , fxRates )
266+ }
217267 } ,
218268
219269 // GET /gift-cards/full/:country - Returns all brands without pagination
220270 getFullGiftCards : async params => {
221271 const { countryCode, fields, filter } = params
222- const response = await fetchPhaze (
223- buildUrl ( `/gift-cards/full/${ countryCode } ` , { fields, filter } ) ,
224- {
225- headers : makeHeaders ( { includePublicKey : true } )
226- }
227- )
272+ const [ response , fxRates ] = await Promise . all ( [
273+ fetchPhaze (
274+ buildUrl ( `/gift-cards/full/${ countryCode } ` , { fields, filter } ) ,
275+ {
276+ headers : makeHeaders ( { includePublicKey : true } )
277+ }
278+ ) ,
279+ getOrFetchFxRates ( )
280+ ] )
228281 const text = await response . text ( )
229- return asJSON ( asPhazeGiftCardsResponse ) ( text )
282+ const parsed = asJSON ( asPhazeGiftCardsResponse ) ( text )
283+ return {
284+ ...parsed ,
285+ brands : filterBrandsByMinimum ( parsed . brands , fxRates )
286+ }
230287 } ,
231288
232289 // GET /crypto/user?email=... - Lookup existing user by email
@@ -283,3 +340,101 @@ export const makePhazeApi = (config: PhazeApiConfig): PhazeApi => {
283340 }
284341 }
285342}
343+
344+ // ---------------------------------------------------------------------------
345+ // Brand Filtering Utilities
346+ // ---------------------------------------------------------------------------
347+
348+ /**
349+ * Convert USD amount to a local currency using FX rates.
350+ * Rates are expected to be FROM USD TO the target currency.
351+ * Rounds up to the next whole number to handle varying fiat precisions.
352+ * Returns null if no rate is found for the currency.
353+ */
354+ const convertFromUsd = (
355+ amountUsd : number ,
356+ toCurrency : string ,
357+ fxRates : PhazeFxRate [ ]
358+ ) : number | null => {
359+ if ( toCurrency === 'USD' ) return amountUsd
360+ const rate = fxRates . find (
361+ r => r . fromCurrency === 'USD' && r . toCurrency === toCurrency
362+ )
363+ if ( rate == null ) return null
364+ return Math . ceil ( amountUsd * rate . rate )
365+ }
366+
367+ /**
368+ * Filter gift card brands to enforce minimum card value.
369+ *
370+ * - Fixed denomination cards: removes denominations below the minimum,
371+ * and removes the brand entirely if no denominations remain.
372+ * - Variable amount cards: caps minVal to the minimum if below,
373+ * and removes the brand if maxVal is below the minimum.
374+ *
375+ * @param brands - Array of gift card brands to filter
376+ * @param fxRates - FX rates from USD to other currencies
377+ * @param minimumUsd - Minimum card value in USD (defaults to MINIMUM_CARD_VALUE_USD)
378+ * @returns Filtered array of brands with valid denominations/restrictions
379+ */
380+ export const filterBrandsByMinimum = (
381+ brands : PhazeGiftCardBrand [ ] ,
382+ fxRates : PhazeFxRate [ ] ,
383+ minimumUsd : number = MINIMUM_CARD_VALUE_USD
384+ ) : PhazeGiftCardBrand [ ] => {
385+ return brands
386+ . map ( brand => {
387+ const { currency, denominations, valueRestrictions } = brand
388+
389+ // Convert minimum USD to brand's currency
390+ const minInBrandCurrency = convertFromUsd ( minimumUsd , currency , fxRates )
391+
392+ // If we can't convert, just return the brand as-is
393+ if ( minInBrandCurrency == null ) return brand
394+
395+ // Variable amount card (has minVal/maxVal restrictions)
396+ if ( valueRestrictions . maxVal != null ) {
397+ // Exclude brand if maxVal is below minimum
398+ if ( valueRestrictions . maxVal < minInBrandCurrency ) {
399+ return null
400+ }
401+
402+ // Cap minVal to our minimum if it's below
403+ const cappedMinVal =
404+ valueRestrictions . minVal != null &&
405+ valueRestrictions . minVal < minInBrandCurrency
406+ ? minInBrandCurrency
407+ : valueRestrictions . minVal
408+
409+ return {
410+ ...brand ,
411+ valueRestrictions : {
412+ ...valueRestrictions ,
413+ minVal : cappedMinVal
414+ }
415+ }
416+ }
417+
418+ // Fixed denomination card
419+ if ( denominations . length > 0 ) {
420+ const filteredDenoms = denominations . filter (
421+ denom => denom >= minInBrandCurrency
422+ )
423+
424+ // No valid denominations remain, exclude the brand
425+ if ( filteredDenoms . length === 0 ) {
426+ return null
427+ }
428+
429+ // Return brand with filtered denominations
430+ return {
431+ ...brand ,
432+ denominations : filteredDenoms
433+ }
434+ }
435+
436+ // No denominations and no value restrictions - exclude
437+ return null
438+ } )
439+ . filter ( ( brand ) : brand is PhazeGiftCardBrand => brand != null )
440+ }
0 commit comments