diff --git a/TODO.md b/TODO.md index 05c96b7..d111c0d 100644 --- a/TODO.md +++ b/TODO.md @@ -12,12 +12,18 @@ Remove items from this list once they are done. `android.enableMinifyInReleaseBuilds: false` in the `expo-build-properties` plugin config in `app.json`): smaller APK, but native Android stack traces get obfuscated unless the R8 mapping file reaches Bugsink, which doesn't document ProGuard/R8 mapping support. Also check whether - Stripe or Sentry need ProGuard keep rules -- verify the SDK 58 upgrade on devices: `useFrameworks: "dynamic"` (needed since - `@stripe/stripe-react-native` 0.78 pulls the Stripe iOS SDK through Swift Package Manager) is - untested with Skia, Reanimated and Sentry, so do an iOS EAS build first; then a test-mode - purchase on Android and iOS including a card that requires authentication (exercises the - `handleURLCallback` deep link), an expiry reminder notification, and an error that lands in Bugsink + Sentry needs ProGuard keep rules +- verify the SDK 58 upgrade on devices: an expiry reminder notification and an error that lands + in Bugsink, on Android and an iOS EAS build +- verify the Stripe Checkout purchase flow end to end (needs galtinn deployed with + `/api/stripe/checkout-session/app/` first): a test-mode purchase on Android and iOS with `4242…`, + a card that requires 3D Secure and a cancel, checking the app lands back on the proof with the + right toast. Check Apple Pay shows inside the iOS auth session (ASWebAuthenticationSession); if + not, switch to `WebBrowser.openBrowserAsync` plus a `Linking` listener. Confirm Google Pay with + a real card in Google Wallet, and that Apple Pay, Google Pay and Link are enabled in the Stripe + Dashboard's Checkout settings +- once app versions using the Payment Sheet are gone, drop galtinn's `/api/stripe/payment-sheet/` + endpoint and its `payment_intent.succeeded` handling - test in ios emulator? (`npm run ios`, requires macOS) - characters in the password input field are invisible - ask for date of birth and place of study when registering: galtinn now requires them on its web @@ -32,14 +38,3 @@ Remove items from this list once they are done. - try [`@expo/ui`](https://docs.expo.dev/versions/latest/sdk/ui/), production-ready as of SDK 56: real SwiftUI / Jetpack Compose components that could back the homegrown ones in `src/components/` -- add [Apple Pay](https://docs.stripe.com/apple-pay?platform=react-native) for membership - payments: either natively in the payment sheet (register the merchant ID - `merchant.no.neuf.chateau` + Apple Pay certificate, set `merchantIdentifier` in the - `@stripe/stripe-react-native` plugin config, pass `applePay` to `initPaymentSheet`, - new iOS build), or for free by moving payment to a Stripe-hosted Checkout page in the - browser (needs a backend Checkout Session endpoint and a return deep link) -- add [Google Pay](https://docs.stripe.com/google-pay?platform=react-native) for - membership payments: either natively in the payment sheet (`enableGooglePay: true` in - the plugin config, pass `googlePay` to `initPaymentSheet`, request production access in - the Google Pay & Wallet Console, new Android build), or for free via the same - Stripe-hosted Checkout page as for Apple Pay diff --git a/app.json b/app.json index bd4d646..4a143df 100644 --- a/app.json +++ b/app.json @@ -50,10 +50,6 @@ "project": "cnapp" } ], - [ - "@stripe/stripe-react-native", - {} - ], "expo-status-bar", [ "expo-navigation-bar", @@ -75,14 +71,12 @@ [ "expo-build-properties", { - "ios": { - "useFrameworks": "dynamic" - }, "android": { "enableMinifyInReleaseBuilds": false } } - ] + ], + "expo-web-browser" ], "extra": { "eas": { diff --git a/docs/dependency-upgrades.md b/docs/dependency-upgrades.md index 82a2f40..7aa258d 100644 --- a/docs/dependency-upgrades.md +++ b/docs/dependency-upgrades.md @@ -22,7 +22,7 @@ preview build before merging. | Area | Packages | What tests cover | Gaps and what to do | |---|---|---|---| -| Payments | `@stripe/stripe-react-native` | `Proof.test.tsx` runs the whole purchase flow against Stripe's own Jest mock: happy path, non-default type, sheet cancelled, backend rejection, `initPaymentSheet` error. `useProof.tsx` and `usePaymentSheetMutation.ts` at 100%. | The native SDK is mocked, so nothing proves the real sheet works. The 3DS/redirect return path (`useDeepLinking` in `src/hooks.tsx`, `handleURLCallback`) has no test. Expo-pinned, so it only moves with the SDK. Moved to 0.81.0 with SDK 58 on 2026-10-29, device check pending. When it does: run a test-mode purchase on Android and iOS, including one of Stripe's cards that requires authentication, to exercise the deep link. | +| Payments | `expo-web-browser` (Stripe Checkout) | `Proof.test.tsx` runs the purchase flow against a mocked `openAuthSessionAsync`: first purchase, renewal, non-default type, cancel on the Checkout page, dismissed browser, backend rejection, browser failure. `useProof.tsx` 100%, `requestStripeCheckoutSession` covered in `api.test.ts`. | The browser hop and galtinn's return page are mocked, so after an `expo-web-browser` bump run a test-mode purchase on Android and iOS, including a card that requires 3D Secure. `@stripe/stripe-react-native` was removed on 2026-10-29 when payments moved to Checkout. | | Error reporting | `@sentry/react-native` | Nothing. `App.ts` (init + `Sentry.wrap`) is outside the coverage scope and no test imports Sentry. | Low risk within 8.x, but any breakage is invisible to CI. Expo-pinned since SDK 58. After a bump: preview build, trigger an error, confirm it lands in Bugsink. Self-hosted requirement for 8.x is already met since 8.20 works. Pending: neither the 8.24.0 bump (2026-09-05) nor 8.28.0 (SDK 58) has had this check yet. | | Auth and storage | `@react-native-async-storage/async-storage`, `src/auth/*` | `tokenStorage.ts` 100%, `migrateFromReduxPersist.ts` 100%, `AuthContext.tsx` 97%, all against AsyncStorage's official Jest mock. | Good. The 2 to 3 major is Expo-pinned anyway; when SDK 58 brings it, the existing tests are the safety net. | | Data layer | `@tanstack/*` | `src/queries` 100% statements, `queryClient.ts` persistence 100%. `jest.setup.js` hooks into `notifyManager.setScheduler`, which is TanStack internals, so a breaking change there fails loudly. | Good. Keep the three TanStack packages in lockstep. | @@ -70,8 +70,7 @@ fallout is three new rules in `eslint:recommended` (`no-unassigned-vars`, `no-us currently reports them all as correct for SDK 58. Expect them to move with SDK 59: - Majors waiting: `@react-native-async-storage/async-storage` 3, `typescript` 7, `@babel/core` 8 -- Minors and patches: `react`, `@sentry/react-native` (8.33), `@stripe/stripe-react-native` - (0.84, which drops the `disableSPM` option entirely), `react-native-gesture-handler`, +- Minors and patches: `react`, `@sentry/react-native` (8.33), `react-native-gesture-handler`, `react-native-safe-area-context`, `react-native-screens`, `react-native-worklets` - `@babel/core` is implied by `expo` and could be removed from `package.json` at the next SDK upgrade diff --git a/eas.json b/eas.json index de3d9c3..23efc50 100644 --- a/eas.json +++ b/eas.json @@ -7,7 +7,6 @@ "EXPO_PUBLIC_DUSKEN_API_URL": "https://galtinn.neuf.no", "EXPO_PUBLIC_DUSKEN_FORGOT_PASSWORD_URL": "https://galtinn.neuf.no/auth/password_reset/", "EXPO_PUBLIC_DUSKEN_PRIVACY_POLICY_URL": "https://galtinn.neuf.no/nb/personvern/", - "EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY": "pk_live_4SNAG73lw9kqFGb5u1s0yHhA", "EXPO_PUBLIC_SENTRY_DSN": "https://af0b4ac4258049d9a21a615b9696190b@bugsink.neuf.no/2" } }, @@ -17,7 +16,6 @@ "EXPO_PUBLIC_DUSKEN_API_URL": "https://galtinn.neuf.no", "EXPO_PUBLIC_DUSKEN_FORGOT_PASSWORD_URL": "https://galtinn.neuf.no/auth/password_reset/", "EXPO_PUBLIC_DUSKEN_PRIVACY_POLICY_URL": "https://galtinn.neuf.no/nb/personvern/", - "EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY": "pk_live_4SNAG73lw9kqFGb5u1s0yHhA", "EXPO_PUBLIC_SENTRY_DSN": "https://af0b4ac4258049d9a21a615b9696190b@bugsink.neuf.no/2" } } diff --git a/jest.setup.js b/jest.setup.js index eb42b83..0ad6b2b 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -8,7 +8,6 @@ process.env.EXPO_PUBLIC_EVENT_API_URL = 'https://events.example.test/api/v1/even process.env.EXPO_PUBLIC_DUSKEN_API_URL = 'https://dusken.example.test'; process.env.EXPO_PUBLIC_DUSKEN_FORGOT_PASSWORD_URL = 'https://dusken.example.test/auth/password_reset/'; process.env.EXPO_PUBLIC_DUSKEN_PRIVACY_POLICY_URL = 'https://dusken.example.test/nb/personvern/'; -process.env.EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY = 'pk_test_fake'; // No test may hit the network. Individual tests override this (or mock // fetchWithTimeout in src/utils) when they need specific responses. @@ -27,8 +26,6 @@ jest.mock('@react-native-async-storage/async-storage', () => require('@react-native-async-storage/async-storage/jest/async-storage-mock'), ); -jest.mock('@stripe/stripe-react-native', () => require('@stripe/stripe-react-native/jest/mock.js')); - // Navigation sizes the tab bar from the bottom inset; the mock answers with zeros without a provider jest.mock('react-native-safe-area-context', () => require('react-native-safe-area-context/jest/mock').default); diff --git a/package-lock.json b/package-lock.json index 5ca0979..85262f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,6 @@ "@react-navigation/native-stack": "^7.21.0", "@sentry/react-native": "~8.28.0", "@shopify/react-native-skia": "2.14.0", - "@stripe/stripe-react-native": "0.81.0", "@tanstack/query-async-storage-persister": "^5.105.0", "@tanstack/react-query": "^5.105.0", "@tanstack/react-query-persist-client": "^5.105.0", @@ -32,6 +31,7 @@ "expo-notifications": "~58.1.7", "expo-splash-screen": "~58.0.12", "expo-status-bar": "~58.0.4", + "expo-web-browser": "~58.0.7", "react": "19.3.0", "react-hook-form": "^7.91.0", "react-native": "0.88.1", @@ -6537,26 +6537,6 @@ "@sinonjs/commons": "^3.0.1" } }, - "node_modules/@stripe/stripe-react-native": { - "version": "0.81.0", - "resolved": "https://registry.npmjs.org/@stripe/stripe-react-native/-/stripe-react-native-0.81.0.tgz", - "integrity": "sha512-7i1rGIEz8N1SQkHNOEIs6LoCLal48L6beYMyfg1p5VVto2hDqxo5IUjScJAE7FlYozNyUZBo/zKFFJksstunxQ==", - "license": "MIT", - "peerDependencies": { - "expo": ">=46.0.9", - "react": "*", - "react-native": "*", - "react-native-webview": "*" - }, - "peerDependenciesMeta": { - "expo": { - "optional": true - }, - "react-native-webview": { - "optional": true - } - } - }, "node_modules/@svgr/babel-plugin-add-jsx-attribute": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz", @@ -11050,6 +11030,16 @@ "expo": "*" } }, + "node_modules/expo-web-browser": { + "version": "58.0.7", + "resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-58.0.7.tgz", + "integrity": "sha512-dQ2/lkw+GR0+5xG6L2cixH+Zk7tY0IeWGhS2PRw+JIPDbqU+8eAb3F0twB7vMFBls4B/DdaLYoEj/hhq7msLHQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/expo/node_modules/@expo/cli": { "version": "58.1.17", "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-58.1.17.tgz", diff --git a/package.json b/package.json index da8ae0d..688152c 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,6 @@ "@react-navigation/native-stack": "^7.21.0", "@sentry/react-native": "~8.28.0", "@shopify/react-native-skia": "2.14.0", - "@stripe/stripe-react-native": "0.81.0", "@tanstack/query-async-storage-persister": "^5.105.0", "@tanstack/react-query": "^5.105.0", "@tanstack/react-query-persist-client": "^5.105.0", @@ -39,6 +38,7 @@ "expo-notifications": "~58.1.7", "expo-splash-screen": "~58.0.12", "expo-status-bar": "~58.0.4", + "expo-web-browser": "~58.0.7", "react": "19.3.0", "react-hook-form": "^7.91.0", "react-native": "0.88.1", diff --git a/src/App.test.tsx b/src/App.test.tsx index c90f8ca..24c0761 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -4,13 +4,6 @@ import React from 'react'; import App from '@/App'; import { appQueryClient } from '@/queries/queryClient'; -// The official stripe jest mock renders a placeholder instead of its children; -// pass them through so the app tree below it can mount -jest.mock('@stripe/stripe-react-native', () => ({ - ...jest.requireActual('@stripe/stripe-react-native/jest/mock.js'), - StripeProvider: ({ children }: { children: React.ReactNode }) => children, -})); - // The app-level query client is module-global, don't leak cache between tests afterEach(() => appQueryClient.clear()); diff --git a/src/App.tsx b/src/App.tsx index 7299109..0aec984 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,4 @@ -import { StripeProvider } from '@stripe/stripe-react-native'; import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'; -import Constants from 'expo-constants'; import * as Notifications from 'expo-notifications'; import { StatusBar } from 'expo-status-bar'; import React from 'react'; @@ -9,7 +7,6 @@ import { initialWindowMetrics, SafeAreaProvider } from 'react-native-safe-area-c import 'expo-dev-client'; import { AuthProvider } from '@/auth/AuthContext'; -import { useDeepLinking } from '@/hooks'; import { appQueryClient, persistOptions } from '@/queries/queryClient'; import AppInner from './AppInner'; @@ -25,21 +22,13 @@ Notifications.setNotificationHandler({ }); export default function App() { - useDeepLinking(); return ( - {/* FIXME: Read urlScheme and merchantIdentifier from .Config */} - - - + diff --git a/src/api.test.ts b/src/api.test.ts index e93d0e2..1f3718b 100644 --- a/src/api.test.ts +++ b/src/api.test.ts @@ -5,7 +5,7 @@ import { fetchUserData, login, registerUser, - requestStripePaymentSheet, + requestStripeCheckoutSession, } from '@/api'; import { ApiError } from '@/errors'; @@ -216,15 +216,16 @@ describe('fetchMembershipTypes', () => { }); }); -describe('requestStripePaymentSheet', () => { - it('posts the membership type and returns the payment sheet parameters', async () => { - const sheet = { paymentIntent: 'pi_secret', ephemeralKey: 'ek_secret', customer: 'cus_123' }; - mockResponseOnce(sheet); +describe('requestStripeCheckoutSession', () => { + it('posts the membership type and returns the checkout URL', async () => { + mockResponseOnce({ url: 'https://checkout.stripe.com/c/pay/cs_test_123' }); - await expect(requestStripePaymentSheet('token-123', 'standard')).resolves.toEqual(sheet); + await expect(requestStripeCheckoutSession('token-123', 'standard')).resolves.toBe( + 'https://checkout.stripe.com/c/pay/cs_test_123', + ); expect(fetchMock).toHaveBeenCalledWith( - 'https://dusken.example.test/api/stripe/payment-sheet/', + 'https://dusken.example.test/api/stripe/checkout-session/app/', expect.objectContaining({ method: 'POST', body: JSON.stringify({ membership_type: 'standard' }), @@ -236,14 +237,17 @@ describe('requestStripePaymentSheet', () => { it('rejects with the API detail message on failure', async () => { mockResponseOnce({ detail: 'Ugyldig medlemskapstype' }, false); - await expect(requestStripePaymentSheet('token-123', 'bogus')).rejects.toThrow('Ugyldig medlemskapstype'); + await expect(requestStripeCheckoutSession('token-123', 'bogus')).rejects.toThrow('Ugyldig medlemskapstype'); }); - it('rejects with a generic message when the API gives no detail', async () => { - mockResponseOnce({}, false); + it.each([ + ['an error without detail', {}, false], + ['a success without a URL', {}, true], + ])('rejects with a generic message on %s', async (_case, json, ok) => { + mockResponseOnce(json, ok); - await expect(requestStripePaymentSheet('token-123', 'standard')).rejects.toThrow( - 'Kunne ikke belaste bankkortet, prøv igjen', + await expect(requestStripeCheckoutSession('token-123', 'standard')).rejects.toThrow( + 'Kunne ikke starte betalingen, prøv igjen', ); }); }); diff --git a/src/api.ts b/src/api.ts index ad496b7..86fafe9 100644 --- a/src/api.ts +++ b/src/api.ts @@ -141,16 +141,19 @@ export async function fetchMembershipTypes(): Promise { return membershipTypes; } -/** Create a stripe payment sheet; rejects with a message for the failure toast. */ -export async function requestStripePaymentSheet(authToken: string, membershipType: string) { - const response = await fetchWithTimeout(`${process.env.EXPO_PUBLIC_DUSKEN_API_URL}/api/stripe/payment-sheet/`, { - method: 'POST', - headers: { ...JSON_HEADERS, Authorization: `Token ${authToken}` }, - body: JSON.stringify({ membership_type: membershipType }), - }); +/** Create a Stripe Checkout session and return its URL; rejects with a message for the failure toast. */ +export async function requestStripeCheckoutSession(authToken: string, membershipType: string): Promise { + const response = await fetchWithTimeout( + `${process.env.EXPO_PUBLIC_DUSKEN_API_URL}/api/stripe/checkout-session/app/`, + { + method: 'POST', + headers: { ...JSON_HEADERS, Authorization: `Token ${authToken}` }, + body: JSON.stringify({ membership_type: membershipType }), + }, + ); const json = await response.json(); - if (!response.ok) { - throw new Error(json?.detail || 'Kunne ikke belaste bankkortet, prøv igjen'); + if (!response.ok || !json?.url) { + throw new Error(json?.detail || 'Kunne ikke starte betalingen, prøv igjen'); } - return json; + return json.url; } diff --git a/src/hooks.tsx b/src/hooks.tsx index 0aa7f9d..076faf0 100644 --- a/src/hooks.tsx +++ b/src/hooks.tsx @@ -1,7 +1,6 @@ -import { useStripe } from '@stripe/stripe-react-native'; import { focusManager } from '@tanstack/react-query'; -import { useCallback, useEffect } from 'react'; -import { AppState, Linking } from 'react-native'; +import { useEffect } from 'react'; +import { AppState } from 'react-native'; /** Drives TanStack Query's refetch-on-focus from the app's foreground state. */ export function useAppStateFocusManager() { @@ -12,36 +11,3 @@ export function useAppStateFocusManager() { return () => subscription.remove(); }, []); } - -/** Handles deep links. Currently only used by stripe */ -export const useDeepLinking = () => { - const { handleURLCallback } = useStripe(); - - const handleDeepLink = useCallback( - async (url: string | null | undefined) => { - if (!url) { - return; - } - - // Pass url to stripe - // Note: This return value can be used to target non-stripe URLs in the future - await handleURLCallback(url); - }, - [handleURLCallback], - ); - - useEffect(() => { - const getUrlAsync = async () => { - const initialUrl = await Linking.getInitialURL(); - handleDeepLink(initialUrl); - }; - - getUrlAsync(); - - const deepLinkListener = Linking.addEventListener('url', (event) => { - handleDeepLink(event.url); - }); - - return () => deepLinkListener.remove(); - }, [handleDeepLink]); -}; diff --git a/src/modules/membership/Proof.test.tsx b/src/modules/membership/Proof.test.tsx index 793141e..fdd3667 100644 --- a/src/modules/membership/Proof.test.tsx +++ b/src/modules/membership/Proof.test.tsx @@ -1,5 +1,6 @@ import { act, fireEvent, waitFor } from '@testing-library/react-native'; import * as Notifications from 'expo-notifications'; +import * as WebBrowser from 'expo-web-browser'; import React from 'react'; import * as api from '@/api'; @@ -7,17 +8,18 @@ import Proof from '@/modules/membership/Proof'; import { renderWithProviders, testMembershipTypes, testUser } from '@/testUtils'; import type { MembershipTypeOption, UserType } from '@/types/data'; -// The setup-wide stripe mock resolves initPaymentSheet/presentPaymentSheet -// with { error: null }; useStripe() hands out these same jest.fn instances. -const { initPaymentSheet, presentPaymentSheet } = jest.requireMock('@stripe/stripe-react-native') as { - initPaymentSheet: jest.Mock; - presentPaymentSheet: jest.Mock; -}; +jest.mock('expo-web-browser', () => ({ openAuthSessionAsync: jest.fn() })); +const openAuthSessionAsync = WebBrowser.openAuthSessionAsync as jest.Mock; -const mockPaymentSheet = () => - jest - .spyOn(api, 'requestStripePaymentSheet') - .mockResolvedValue({ paymentIntent: 'pi_secret', ephemeralKey: 'ek_secret', customer: 'cus_123' }); +const CHECKOUT_URL = 'https://checkout.stripe.com/c/pay/cs_test_123'; + +const mockCheckoutSession = () => jest.spyOn(api, 'requestStripeCheckoutSession').mockResolvedValue(CHECKOUT_URL); + +// What the auth session hands back once galtinn's return page redirects to the app +const returnedWith = (status: string) => ({ + type: 'success', + url: `no-neuf-chateau://payment-return?status=${status}`, +}); const nonMember: UserType = { ...testUser, is_member: false, last_membership: null }; @@ -77,78 +79,83 @@ it('shows an error with retry instead of a price when membership types fail to l expect(getByRole('button', { name: 'Kjøp medlemskap (300 kr)' })).toBeEnabled(); }); -it('purchases the preselected membership through the stripe payment sheet', async () => { - const requestPaymentSheet = mockPaymentSheet(); - const { findByText } = await renderProof(); +it('purchases the preselected membership through stripe checkout', async () => { + const requestCheckoutSession = mockCheckoutSession(); + openAuthSessionAsync.mockResolvedValueOnce(returnedWith('success')); + const fetchUser = jest.fn(); + const { findByText } = await renderProof({ fetchUser }); // 'Ordinær' (slug: regular) is preselected; the button enables once the // membership type query has resolved and its price is shown await fireEvent.press(await findByText('Kjøp medlemskap (300 kr)')); - expect(await findByText('Your order is confirmed!')).toBeTruthy(); - expect(requestPaymentSheet).toHaveBeenCalledWith('test-token', 'regular'); - expect(initPaymentSheet).toHaveBeenCalledWith( - expect.objectContaining({ - paymentIntentClientSecret: 'pi_secret', - customerEphemeralKeySecret: 'ek_secret', - customerId: 'cus_123', - defaultBillingDetails: expect.objectContaining({ - email: testUser.email, - name: 'Test Testesen', - }), - }), - ); - expect(presentPaymentSheet).toHaveBeenCalled(); + expect(await findByText('Velkommen som medlem!')).toBeTruthy(); + expect(requestCheckoutSession).toHaveBeenCalledWith('test-token', 'regular'); + expect(openAuthSessionAsync).toHaveBeenCalledWith(CHECKOUT_URL, 'no-neuf-chateau://payment-return'); + expect(fetchUser).toHaveBeenCalled(); +}); + +it('confirms a renewal for someone who has been a member before', async () => { + mockCheckoutSession(); + openAuthSessionAsync.mockResolvedValueOnce(returnedWith('success')); + const { findByText } = await renderProof({ user: expiredMember }); + + await fireEvent.press(await findByText('Kjøp medlemskap (300 kr)')); + + expect(await findByText('Medlemskapet er fornyet!')).toBeTruthy(); }); it('purchases the selected membership type, not the default', async () => { - const requestPaymentSheet = mockPaymentSheet(); + const requestCheckoutSession = mockCheckoutSession(); + openAuthSessionAsync.mockResolvedValueOnce(returnedWith('success')); const { findByText } = await renderProof(); await fireEvent.press(await findByText('Student')); await fireEvent.press(await findByText('Kjøp medlemskap (150 kr)')); - expect(await findByText('Your order is confirmed!')).toBeTruthy(); - expect(requestPaymentSheet).toHaveBeenCalledWith('test-token', 'student'); + expect(await findByText('Velkommen som medlem!')).toBeTruthy(); + expect(requestCheckoutSession).toHaveBeenCalledWith('test-token', 'student'); }); -it('shows the stripe error when the payment sheet is not completed', async () => { - mockPaymentSheet(); - presentPaymentSheet.mockResolvedValueOnce({ - error: { code: 'Canceled', message: 'The payment flow has been canceled' }, - }); - const { findByText, queryByText } = await renderProof(); +it.each([ + ['cancelled on the checkout page', returnedWith('canceled')], + ['dismissed the browser', { type: 'cancel' }], +])('shows nothing and keeps the purchase button when the user %s', async (_case, result) => { + mockCheckoutSession(); + openAuthSessionAsync.mockResolvedValueOnce(result); + const fetchUser = jest.fn(); + const { findByText, getByRole, queryByText } = await renderProof({ fetchUser }); await fireEvent.press(await findByText('Kjøp medlemskap (300 kr)')); - expect(await findByText('Error code: Canceled The payment flow has been canceled')).toBeTruthy(); - expect(queryByText('Your order is confirmed!')).toBeNull(); + await waitFor(() => expect(getByRole('button', { name: 'Kjøp medlemskap (300 kr)' })).toBeEnabled()); + expect(queryByText('Velkommen som medlem!')).toBeNull(); + expect(queryByText('Kunne ikke starte betalingen, prøv igjen')).toBeNull(); + expect(fetchUser).not.toHaveBeenCalled(); }); -it('shows an error and does not present the sheet when the backend rejects the purchase', async () => { +it('shows an error and does not open checkout when the backend rejects the purchase', async () => { jest.spyOn(console, 'error').mockImplementation(() => {}); - jest.spyOn(api, 'requestStripePaymentSheet').mockRejectedValueOnce(new Error('Ugyldig medlemskapstype')); + jest.spyOn(api, 'requestStripeCheckoutSession').mockRejectedValueOnce(new Error('Ugyldig medlemskapstype')); const { findByText, getByRole } = await renderProof(); await fireEvent.press(await findByText('Kjøp medlemskap (300 kr)')); - expect(await findByText('Oops! Could not initialise payment')).toBeTruthy(); - expect(initPaymentSheet).not.toHaveBeenCalled(); - expect(presentPaymentSheet).not.toHaveBeenCalled(); + expect(await findByText('Kunne ikke starte betalingen, prøv igjen')).toBeTruthy(); + expect(openAuthSessionAsync).not.toHaveBeenCalled(); // The purchase button recovers so the user can retry expect(getByRole('button', { name: 'Kjøp medlemskap (300 kr)' })).toBeEnabled(); }); -it('shows an error and does not present the sheet when stripe fails to initialise it', async () => { +it('shows an error when the browser cannot be opened', async () => { jest.spyOn(console, 'error').mockImplementation(() => {}); - mockPaymentSheet(); - initPaymentSheet.mockResolvedValueOnce({ error: { code: 'Failed', message: 'Invalid client secret' } }); + mockCheckoutSession(); + openAuthSessionAsync.mockRejectedValueOnce(new Error('No browser available')); const { findByText } = await renderProof(); await fireEvent.press(await findByText('Kjøp medlemskap (300 kr)')); - expect(await findByText('Oops! Could not initialise payment')).toBeTruthy(); - expect(presentPaymentSheet).not.toHaveBeenCalled(); + expect(await findByText('Kunne ikke starte betalingen, prøv igjen')).toBeTruthy(); }); it('refetches user data when the stored membership has expired', async () => { diff --git a/src/modules/membership/useProof.tsx b/src/modules/membership/useProof.tsx index 714ddeb..d22ee81 100644 --- a/src/modules/membership/useProof.tsx +++ b/src/modules/membership/useProof.tsx @@ -1,13 +1,15 @@ -import { useStripe } from '@stripe/stripe-react-native'; -import Constants from 'expo-constants'; +import * as WebBrowser from 'expo-web-browser'; import { useEffect, useState } from 'react'; import { useNotifications } from '@/hooks/useNotifications'; +import { useCheckoutSessionMutation } from '@/queries/useCheckoutSessionMutation'; import { useMembershipTypes } from '@/queries/useMembershipTypes'; -import { usePaymentSheetMutation } from '@/queries/usePaymentSheetMutation'; import { useUser } from '@/queries/useUser'; import { ToastProps } from '@/types/toast'; +// Galtinn's Checkout return page redirects here (its APP_URL_SCHEME must match the scheme in app.json) +const CHECKOUT_RETURN_URL = 'no-neuf-chateau://payment-return'; + interface UseProofProps { fetchUser: () => void; } @@ -15,8 +17,7 @@ interface UseProofProps { const useProof = ({ fetchUser }: UseProofProps) => { const { user } = useUser(); const { data: membershipTypes, isPending, isError, refetch } = useMembershipTypes(); - const { initPaymentSheet, presentPaymentSheet } = useStripe(); - const { mutateAsync: createPaymentSheet } = usePaymentSheetMutation(); + const { mutateAsync: createCheckoutSession } = useCheckoutSessionMutation(); const { scheduleMembershipExpiryNotification, hasPermission } = useNotifications(); const [paymentLoading, setPaymentLoading] = useState(false); // Matches the preselected option in PurchaseButton's radio list @@ -29,44 +30,27 @@ const useProof = ({ fetchUser }: UseProofProps) => { return price != null ? price / 100 : null; }; - const openPaymentSheet = async () => { + const openCheckout = async () => { setPaymentLoading(true); + // Read before the refetch below replaces the user; purchasing is only offered to non-members + const isRenewal = user?.last_membership != null; try { - await initializePaymentSheet(); + const checkoutUrl = await createCheckoutSession(membershipTypeSlug); + const result = await WebBrowser.openAuthSessionAsync(checkoutUrl, CHECKOUT_RETURN_URL); + // Closing the browser or cancelling on the Checkout page needs no toast + if (result.type === 'success' && /[?&]status=success(&|$)/.test(result.url)) { + // Stripe only redirects once the webhook has created the membership + fetchUser(); + const message = isRenewal ? 'Medlemskapet er fornyet!' : 'Velkommen som medlem!'; + setToast({ message, visible: true, preset: 'success' }); + } } catch (err) { console.error('🔥', err); - setToast({ message: 'Oops! Could not initialise payment', visible: true, preset: 'failure' }); - setPaymentLoading(false); - return; + setToast({ message: 'Kunne ikke starte betalingen, prøv igjen', visible: true, preset: 'failure' }); } - const { error } = await presentPaymentSheet(); - const message = error ? `Error code: ${error.code} ${error.message}` : 'Your order is confirmed!'; - setToast({ message, visible: true, preset: error ? 'failure' : 'success' }); setPaymentLoading(false); }; - const initializePaymentSheet = async () => { - const { paymentIntent, ephemeralKey, customer } = await createPaymentSheet(membershipTypeSlug); - - const { error } = await initPaymentSheet({ - merchantDisplayName: 'Det Norske Studentersamfund', - customerId: customer, - customerEphemeralKeySecret: ephemeralKey, - paymentIntentClientSecret: paymentIntent, - defaultBillingDetails: { - email: user?.email, - name: `${user?.first_name} ${user?.last_name}`, - address: { - country: 'NO', - }, - }, - returnURL: `${Constants.expoConfig?.scheme}://stripe-redirect`, - }); - if (error) { - throw error; - } - }; - /* Refetch user data if membership has expired */ const membershipType = user?.last_membership?.membership_type; const membershipEndDate = user?.last_membership?.end_date; @@ -87,7 +71,7 @@ const useProof = ({ fetchUser }: UseProofProps) => { priceFor, membershipTypesError: isError, refetchMembershipTypes: refetch, - onPurchasePress: openPaymentSheet, + onPurchasePress: openCheckout, paymentLoading, toast, setToast, diff --git a/src/queries/useCheckoutSessionMutation.ts b/src/queries/useCheckoutSessionMutation.ts new file mode 100644 index 0000000..9a397a2 --- /dev/null +++ b/src/queries/useCheckoutSessionMutation.ts @@ -0,0 +1,12 @@ +import { useMutation } from '@tanstack/react-query'; + +import { requestStripeCheckoutSession } from '@/api'; +import { useAuth } from '@/auth/AuthContext'; + +export function useCheckoutSessionMutation() { + const { token } = useAuth(); + + return useMutation({ + mutationFn: (membershipTypeSlug: string) => requestStripeCheckoutSession(token ?? '', membershipTypeSlug), + }); +} diff --git a/src/queries/usePaymentSheetMutation.ts b/src/queries/usePaymentSheetMutation.ts deleted file mode 100644 index e7b1d54..0000000 --- a/src/queries/usePaymentSheetMutation.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { useMutation } from '@tanstack/react-query'; - -import { requestStripePaymentSheet } from '@/api'; -import { useAuth } from '@/auth/AuthContext'; - -export function usePaymentSheetMutation() { - const { token } = useAuth(); - - return useMutation({ - mutationFn: (membershipTypeSlug: string) => requestStripePaymentSheet(token ?? '', membershipTypeSlug), - }); -}