Skip to content
Merged

Qa #2576

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,14 @@ EXPO_PUBLIC_TRUSTPILOT_LOCALE=en-US
# Where "Write a review" goes if the widget script is blocked. Defaults to
# https://www.trustpilot.com/evaluate/<domain>.
EXPO_PUBLIC_TRUSTPILOT_REVIEW_URL=

# --- Flashnet Orchestra: Lightning fiat onramp (https://docs.flashnet.xyz/orchestra/onramp) ---
# The app needs NO Orchestra key. The accounts service holds the server key
# (ORCHESTRA_SERVER_KEY in solid-backend) and proxies every call under
# /accounts/v1/orchestra, so this flow reaches Orchestra through
# EXPO_PUBLIC_FLASH_API_BASE_URL like any other backend feature — and the
# recipient address is resolved from the session rather than sent from here.
#
# A client key (fnp_) would let the app call Orchestra directly. It is
# deliberately not used: it would put the recipient address in a body the user
# controls, and make an order id enough to read someone else's deposit.
95 changes: 95 additions & 0 deletions __tests__/orchestraErrors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import {
asOrchestraError,
ORCHESTRA_ERROR_CODE,
OrchestraError,
orchestraErrorFromCode,
orchestraErrorTitle,
toOrchestraError,
} from '@/lib/orchestraErrors';
import { ORCHESTRA_FAILED_STATUSES, ORCHESTRA_SETTLED_STATUSES } from '@/lib/types/orchestra';

const responseWith = (status: number, body?: unknown) =>
({
status,
json: async () => {
if (body === undefined) throw new SyntaxError('Unexpected end of JSON input');
return body;
},
}) as Response;

describe('toOrchestraError', () => {
it('reads Orchestra’s nested error body', async () => {
const error = await toOrchestraError(
responseWith(400, { error: { code: 'amount_too_small', message: 'below minimum' } }),
);
expect(error.code).toBe(ORCHESTRA_ERROR_CODE.AMOUNT_TOO_SMALL);
expect(error.action).toBe('adjust_amount');
expect(error.status).toBe(400);
// The user-facing copy is ours; Orchestra's wording is kept for telemetry.
expect(error.message).not.toBe('below minimum');
expect(error.rawMessage).toBe('below minimum');
});

it('falls back to a generic retry for a body that is not ours', async () => {
const error = await toOrchestraError(responseWith(502));
expect(error.code).toBe(ORCHESTRA_ERROR_CODE.UNKNOWN);
expect(error.action).toBe('retry');
expect(error.status).toBe(502);
});

it('keeps the status so isHTTPError-style checks still work', async () => {
const error = await toOrchestraError(
responseWith(403, { error: { code: 'invalid_read_token' } }),
);
expect(error.status).toBe(403);
expect(error).toBeInstanceOf(OrchestraError);
});
});

describe('orchestraErrorFromCode', () => {
it('maps order-level failure codes to copy and an action', () => {
const slippage = orchestraErrorFromCode('slippage_exceeded');
expect(slippage.action).toBe('none');
expect(slippage.message).toMatch(/refunded/i);

const missingRefund = orchestraErrorFromCode('refund_address_missing');
expect(missingRefund.action).toBe('contact_support');
});

it('treats an unknown code as retryable rather than fatal', () => {
const error = orchestraErrorFromCode('some_new_code_we_have_not_seen');
expect(error.action).toBe('retry');
expect(orchestraErrorTitle(error)).toBe('Something went wrong');
});
});

describe('asOrchestraError', () => {
it('passes an OrchestraError through untouched', () => {
const original = orchestraErrorFromCode('amount_too_large', 400);
expect(asOrchestraError(original)).toBe(original);
});

it('turns a dropped connection into something renderable', () => {
const error = asOrchestraError(new TypeError('Failed to fetch'));
expect(error.action).toBe('retry');
expect(error.message).not.toMatch(/fetch/i);
expect(error.rawMessage).toBe('Failed to fetch');
});
});

describe('terminal status sets', () => {
it('does not treat unfulfilled as settled — a late deposit can still resume it', () => {
expect(ORCHESTRA_SETTLED_STATUSES).not.toContain('unfulfilled');
expect(ORCHESTRA_FAILED_STATUSES).not.toContain('unfulfilled');
});

it('settles on completed as well as the three failure outcomes', () => {
expect([...ORCHESTRA_SETTLED_STATUSES].sort()).toEqual([
'completed',
'expired',
'failed',
'refunded',
]);
expect(ORCHESTRA_FAILED_STATUSES).not.toContain('completed');
});
});
77 changes: 77 additions & 0 deletions __tests__/orchestraFormat.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { formatSats, formatSmallestUnits } from '@/lib/orchestraFormat';

describe('formatSmallestUnits', () => {
it('renders smallest units at the asset decimals', () => {
expect(formatSmallestUnits('49750000', 6)).toBe('49.75');
expect(formatSmallestUnits('250000', 6)).toBe('0.25');
expect(formatSmallestUnits('50000000', 6)).toBe('50');
expect(formatSmallestUnits('1', 6)).toBe('0');
});

it('pads when the asset has fewer decimals than requested places', () => {
expect(formatSmallestUnits('4975', 2, 4)).toBe('49.75');
expect(formatSmallestUnits('7', 0, 2)).toBe('7');
});

it('rounds on the first dropped digit without going through a float', () => {
expect(formatSmallestUnits('49755000', 6)).toBe('49.76');
expect(formatSmallestUnits('49754000', 6)).toBe('49.75');
expect(formatSmallestUnits('999999', 6, 2)).toBe('1');
expect(formatSmallestUnits('5000000000000000000000', 18, 2)).toBe('5,000');
});

it('rejects anything that is not an integer string', () => {
expect(formatSmallestUnits('49.75', 6)).toBeUndefined();
expect(formatSmallestUnits(undefined, 6)).toBeUndefined();
expect(formatSmallestUnits('100', undefined)).toBeUndefined();
});

it('groups sats', () => {
expect(formatSats('44210')).toBe('44,210 sats');
expect(formatSats('abc')).toBeUndefined();
});
});

/**
* Hermes and the React Native Web Intl shim coerce `format`'s argument with
* ToNumber, so handing them a BigInt throws "Cannot convert a BigInt value to a
* number" — which is what the invoice screen did in the app. Node's Intl accepts
* BigInt happily, so the only way to catch this in a test is to make Intl behave
* the way the app's runtime does.
*/
describe('never hands a BigInt to Intl', () => {
const RealNumberFormat = Intl.NumberFormat;

beforeAll(() => {
// Deliberately replacing the global for this suite.
Intl.NumberFormat = function PatchedNumberFormat(
...args: ConstructorParameters<typeof Intl.NumberFormat>
) {
const instance = new RealNumberFormat(...args);
const realFormat = instance.format.bind(instance);
instance.format = (value: number | bigint) => {
if (typeof value === 'bigint') {
throw new TypeError('Cannot convert a BigInt value to a number');
}
return realFormat(value);
};
return instance;
} as unknown as typeof Intl.NumberFormat;
Object.assign(Intl.NumberFormat, RealNumberFormat);
});

afterAll(() => {
Intl.NumberFormat = RealNumberFormat;
});

it('formats smallest units on a runtime whose Intl rejects BigInt', () => {
expect(() => formatSmallestUnits('49750000', 6)).not.toThrow();
expect(formatSmallestUnits('49750000', 6)).toBe('49.75');
expect(formatSmallestUnits('5000000000000000000000', 18, 2)).toBe('5,000');
});

it('formats sats on a runtime whose Intl rejects BigInt', () => {
expect(() => formatSats('44210')).not.toThrow();
expect(formatSats('44210')).toBe('44,210 sats');
});
});
2 changes: 2 additions & 0 deletions app/(protected)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
import CardHeroOverlay from '@/components/Card/NewCardDetails/CardHeroOverlay';
import { SidebarShell } from '@/components/Navbar/Sidebar';
import NotificationPermissionSheet from '@/components/Notifications/NotificationPermissionSheet';
import TierUpgradeModalProvider from '@/components/Rewards/NewRewards/UpgradeTier/TierUpgradeModalProvider';
import RewardsUpgradeFeedback from '@/components/Rewards/RewardsUpgradeFeedback';
import { DEPOSIT_MODAL } from '@/constants/modals';
import { path } from '@/constants/path';
Expand Down Expand Up @@ -299,6 +300,7 @@ export default function ProtectedLayout() {
screens only — stays dormant/null otherwise). Mounted above the whole
navigator so the card can fly across the home → card/details change. */}
<CardHeroOverlay />
<TierUpgradeModalProvider />
<RewardsUpgradeFeedback />
<NotificationPermissionSheet
visible={showNotificationPermissionSheet}
Expand Down
5 changes: 5 additions & 0 deletions assets/images/rewards-tiers/upgrade-card-cap.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions assets/images/rewards-tiers/upgrade-card-star.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions assets/images/rewards-tiers/upgrade-card-subscription.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions assets/images/rewards-tiers/upgrade-card-yield.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 3 additions & 7 deletions components/Activity/CardActivityRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,8 @@ const CardActivityRow = ({

const formattedTimestamp = formatActivityTimestamp(getCardTransactionTimestamp(transaction));

const cashbackLabel = cashbackInfo?.isEscrowed
? 'Cashback (Escrowed)'
: cashbackInfo?.isPending
? 'Cashback (Pending)'
: 'Cashback';
const cashbackLabel =
cashbackInfo?.isPending && !cashbackInfo.isEscrowed ? 'Cashback (Pending)' : 'Cashback';

return (
<Pressable
Expand Down Expand Up @@ -134,8 +131,7 @@ const CardActivityRow = ({
</Text>
{usdEquivalent && <Text className="text-sm text-white/70">{usdEquivalent}</Text>}
{/* Green whether or not the payout has landed: it is money coming back
either way, and the label on the left already carries the escrow
status, so the figure does not repeat it. */}
either way. The receipt shows when escrowed cashback releases. */}
{cashbackInfo?.amount && (
<Text className="text-sm font-medium text-brand">{cashbackInfo.amount}</Text>
)}
Expand Down
48 changes: 22 additions & 26 deletions components/DepositOption/DepositCashOptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,12 @@ import { Text } from '@/components/ui/text';
import { DEPOSIT_MODAL } from '@/constants/modals';
import { TRACKING_EVENTS } from '@/constants/tracking-events';
import { useBuyCryptoEntry } from '@/hooks/useBuyCryptoEntry';
import { useCardStatus } from '@/hooks/useCardStatus';
import { useCashAppDepositAvailability } from '@/hooks/useCashAppDepositAvailability';
import useGeoCompliance from '@/hooks/useGeoCompliance';
import { useOnrampAutomation } from '@/hooks/useOnrampAutomation';
import { useVirtualAccountProvider } from '@/hooks/useVirtualAccountProvider';
import { useOrchestraConfig } from '@/hooks/useOrchestra';
import { useVirtualAccountEntry } from '@/hooks/useVirtualAccountEntry';
import { track } from '@/lib/analytics';
import { getAsset } from '@/lib/assets';
import { RainApplicationStatus } from '@/lib/types';
import { useDepositStore } from '@/store/useDepositStore';
import { useTransfiStore } from '@/store/useTransfiStore';

Expand All @@ -34,6 +33,8 @@ const SHOW_MORE_ICON_COLOR = 'rgba(255,255,255,0.7)';
* local currencies show their committed corridor list (localCurrencies.tsx).
*/
const USD_PAYMENT_METHOD_CHIPS = ['ACH', 'Wire'];
/** In the US the same row also leads to Cash App, so the chips say so. */
const USD_PAYMENT_METHOD_CHIPS_US = ['ACH', 'Wire', 'Cash App'];
const FEATURED_LOCAL_CURRENCY_CODES = ['EUR', 'BRL', 'BDT', 'PHP'] as const;
const ADDITIONAL_LOCAL_CURRENCY_CODES = ['MXN'] as const;

Expand All @@ -57,12 +58,15 @@ const DepositCashOptions = () => {
const setModal = useDepositStore(state => state.setModal);
const resetTransfi = useTransfiStore(state => state.reset);
const setTransfiCurrency = useTransfiStore(state => state.setFiatCurrency);
const [isVirtualAccountApplyOpen, setIsVirtualAccountApplyOpen] = useState(false);

const [showAllCurrencies, setShowAllCurrencies] = useState(false);
const { data: cardStatus } = useCardStatus();
const isRainApproved = cardStatus?.rainApplicationStatus === RainApplicationStatus.APPROVED;
const { data: existingAutomation } = useOnrampAutomation(isRainApproved);
const { provider: virtualAccountProvider } = useVirtualAccountProvider();
const { open: openVirtualAccount, isApplyOpen, closeApply } = useVirtualAccountEntry();
// One rule, decided server-side: supported region **or** allowlisted. The
// country is resolved here only because the backend has no geoip — the verdict
// is still theirs, and they enforce it again on order creation.
const { countryCode, isResolving: isResolvingCountry } = useCashAppDepositAvailability();
const { data: orchestraConfig } = useOrchestraConfig(countryCode, !isResolvingCountry);
const isCashAppAvailable = orchestraConfig?.isAvailable === true;
const { isBuyCryptoAvailable } = useGeoCompliance();
const { handleBuyCryptoPress } = useBuyCryptoEntry();

Expand All @@ -77,20 +81,15 @@ const DepositCashOptions = () => {
}, [showAllCurrencies]);

const handleUsdPress = () => {
track(TRACKING_EVENTS.DEPOSIT_METHOD_SELECTED, {
deposit_method: 'bank_transfer',
provider: virtualAccountProvider,
});
// A Wirex user has no Rain automation and never will, so the Rain apply
// pitch is not their next step — their details screen owns activation for
// both rails. The other entry points into this flow already route on the
// provider; this one did not, which is how Wirex users reached a "Verify
// now" that could only bounce them off the Rain KYC gate.
if (virtualAccountProvider === 'wirex' || existingAutomation) {
setModal(DEPOSIT_MODAL.OPEN_VIRTUAL_ACCOUNT_DETAILS);
// Two USD rails in the US, one everywhere else. Showing a chooser outside
// the US would ask a question with a single answer, so USD goes straight to
// the bank rail there — which is exactly what it did before Cash App
// existed.
if (isCashAppAvailable) {
setModal(DEPOSIT_MODAL.OPEN_DEPOSIT_USD_METHOD);
return;
}
setIsVirtualAccountApplyOpen(true);
openVirtualAccount();
};

const handleLocalCurrencyPress = (code: string) => {
Expand Down Expand Up @@ -122,7 +121,7 @@ const DepositCashOptions = () => {
/>
}
title="USD"
chips={USD_PAYMENT_METHOD_CHIPS}
chips={isCashAppAvailable ? USD_PAYMENT_METHOD_CHIPS_US : USD_PAYMENT_METHOD_CHIPS}
onPress={handleUsdPress}
/>
{localCurrencies.map(currency => (
Expand Down Expand Up @@ -153,10 +152,7 @@ const DepositCashOptions = () => {
</Pressable>
</CardFundGroup>

<VirtualAccountApplyDialog
isOpen={isVirtualAccountApplyOpen}
onClose={() => setIsVirtualAccountApplyOpen(false)}
/>
<VirtualAccountApplyDialog isOpen={isApplyOpen} onClose={closeApply} />
</>
);
};
Expand Down
Loading
Loading