Skip to content

Commit 380ea4c

Browse files
committed
Integrate Phaze FX API, enforce $5 minimum
- Fully support non-USD fiats - Simplify and making it look neater by using whole numbers for conversions. - Global minimums from Phaze that are >$5 remain intact, e.g. XMR.
1 parent 7266037 commit 380ea4c

File tree

4 files changed

+248
-20
lines changed

4 files changed

+248
-20
lines changed

src/components/scenes/GiftCardPurchaseScene.tsx

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,16 @@ import Ionicons from 'react-native-vector-icons/Ionicons'
1515
import { sprintf } from 'sprintf-js'
1616
import { v4 as uuidv4 } from 'uuid'
1717

18+
import { getFiatSymbol } from '../../constants/WalletAndCurrencyConstants'
1819
import { ENV } from '../../env'
20+
import { displayFiatAmount } from '../../hooks/useFiatText'
1921
import { useGiftCardProvider } from '../../hooks/useGiftCardProvider'
2022
import { useHandler } from '../../hooks/useHandler'
2123
import { usePhazeBrand } from '../../hooks/usePhazeBrand'
2224
import { lstrings } from '../../locales/strings'
2325
import type {
2426
PhazeCreateOrderResponse,
27+
PhazeFxRate,
2528
PhazeGiftCardBrand,
2629
PhazeToken
2730
} from '../../plugins/gift-cards/phazeGiftCardTypes'
@@ -163,6 +166,42 @@ export const GiftCardPurchaseScene: React.FC<Props> = props => {
163166
gcTime: 10 * 60 * 1000
164167
})
165168

169+
// Get cached FX rates (already loaded during provider initialization)
170+
const fxRates = provider?.getCachedFxRates() ?? null
171+
172+
/**
173+
* Convert USD amount to brand's currency using FX rates.
174+
* Returns formatted string like "€5" or "$5.00" for USD brands.
175+
*/
176+
const formatMinimumInBrandCurrency = React.useCallback(
177+
(minimumUsd: number): string => {
178+
const symbol = getFiatSymbol(brand.currency)
179+
180+
if (brand.currency === 'USD') {
181+
return `${symbol}${displayFiatAmount(minimumUsd, 2)}`
182+
}
183+
184+
if (fxRates == null) {
185+
// Fallback to USD if rates not loaded
186+
return `$${displayFiatAmount(minimumUsd, 2)}`
187+
}
188+
189+
const rate = fxRates.find(
190+
(r: PhazeFxRate) =>
191+
r.fromCurrency === 'USD' && r.toCurrency === brand.currency
192+
)
193+
if (rate == null) {
194+
// Fallback to USD if rate not found
195+
return `$${displayFiatAmount(minimumUsd, 2)}`
196+
}
197+
198+
const amountInBrandCurrency = Math.ceil(minimumUsd * rate.rate)
199+
// Use 0 decimals for non-USD since we ceil to whole number
200+
return `${symbol}${displayFiatAmount(amountInBrandCurrency, 0)}`
201+
},
202+
[fxRates, brand.currency]
203+
)
204+
166205
// Extract assets for wallet list modal and sync token map to ref
167206
// This ensures the ref is populated even when query returns cached data
168207
const allowedAssets = tokenQueryResult?.assets
@@ -337,7 +376,7 @@ export const GiftCardPurchaseScene: React.FC<Props> = props => {
337376
),
338377
footer: sprintf(
339378
lstrings.gift_card_minimum_warning_footer,
340-
`$${tokenInfo.minimumAmountInUSD.toFixed(2)} USD`
379+
formatMinimumInBrandCurrency(tokenInfo.minimumAmountInUSD)
341380
)
342381
})
343382
return
@@ -537,7 +576,7 @@ export const GiftCardPurchaseScene: React.FC<Props> = props => {
537576
),
538577
footer: sprintf(
539578
lstrings.gift_card_minimum_warning_footer,
540-
`$${minimumUSD.toFixed(2)} USD`
579+
formatMinimumInBrandCurrency(minimumUSD)
541580
)
542581
})
543582
} else {

src/plugins/gift-cards/phazeApi.ts

Lines changed: 173 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,25 @@ import { debugLog, maskHeaders } from '../../util/logger'
44
import {
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

102122
export 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+
}

src/plugins/gift-cards/phazeGiftCardProvider.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { saveOrderAugment } from './phazeGiftCardOrderStore'
1717
import {
1818
cleanBrandName,
1919
type PhazeCreateOrderRequest,
20+
type PhazeFxRate,
2021
type PhazeGiftCardBrand,
2122
type PhazeGiftCardsResponse,
2223
type PhazeOrderStatusResponse,
@@ -90,6 +91,11 @@ export interface PhazeGiftCardProvider {
9091
getCache: () => PhazeGiftCardCache
9192

9293
getTokens: () => Promise<PhazeTokensResponse>
94+
/**
95+
* Returns cached FX rates. Rates are automatically fetched and cached
96+
* when calling getMarketBrands or other brand-fetching methods.
97+
*/
98+
getCachedFxRates: () => PhazeFxRate[] | null
9399

94100
// ---------------------------------------------------------------------------
95101
// Brand fetching - smart methods
@@ -265,6 +271,12 @@ export const makePhazeGiftCardProvider = (
265271
return false
266272
}
267273

274+
// Pre-fetch FX rates so they're available for brand filtering and
275+
// minimum amount display. This runs in parallel with identity loading.
276+
const fxRatesPromise = api.ensureFxRates().catch((err: unknown) => {
277+
debugLog('phaze', 'Failed to pre-fetch FX rates:', err)
278+
})
279+
268280
// Check for existing identities. Uses the first identity found for purchases/orders.
269281
// Multiple identities is an edge case (multi-device before sync completes) -
270282
// new orders simply go to whichever identity is active.
@@ -275,6 +287,7 @@ export const makePhazeGiftCardProvider = (
275287
if (identity.userApiKey != null) {
276288
api.setUserApiKey(identity.userApiKey)
277289
debugLog('phaze', 'Using existing identity:', identity.uniqueId)
290+
await fxRatesPromise
278291
return true
279292
}
280293
}
@@ -313,6 +326,7 @@ export const makePhazeGiftCardProvider = (
313326

314327
api.setUserApiKey(userApiKey)
315328
debugLog('phaze', 'Auto-registered and saved identity:', uniqueId)
329+
await fxRatesPromise
316330
return true
317331
} catch (err: unknown) {
318332
debugLog('phaze', 'Auto-registration failed:', err)
@@ -331,6 +345,10 @@ export const makePhazeGiftCardProvider = (
331345
return await api.getTokens()
332346
},
333347

348+
getCachedFxRates: () => {
349+
return api.getCachedFxRates()
350+
},
351+
334352
// ---------------------------------------------------------------------------
335353
// Brand fetching - smart methods
336354
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)