feat(membership): pay through stripe checkout, adding apple pay and google pay

This commit is contained in:
ponas committed 2026-10-29 21:50:14 +01:00
1 parent ef379d3b7a
commit 5ce161225f
16 files changed
+147 -228

No files matched your search

+12 -17
View File
@@ -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
+2 -8
View File
@@ -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": {
+2 -3
View File
@@ -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
-2
View File
@@ -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"
}
}
-3
View File
@@ -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);
+11 -21
View File
@@ -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",
+1 -1
View File
@@ -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",
-7
View File
@@ -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());
+1 -12
View File
@@ -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 (
<GestureHandlerRootView>
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
<StatusBar style="light" />
<PersistQueryClientProvider client={appQueryClient} persistOptions={persistOptions}>
<AuthProvider>
{/* FIXME: Read urlScheme and merchantIdentifier from .Config */}
<StripeProvider
publishableKey={process.env.EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY || ''}
urlScheme={Constants.expoConfig?.scheme as string}
merchantIdentifier="merchant.no.neuf.chateau" // https://developer.apple.com/account/resources/identifiers/list/merchant
>
<AppInner />
</StripeProvider>
<AppInner />
</AuthProvider>
</PersistQueryClientProvider>
</SafeAreaProvider>
+16 -12
View File
@@ -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',
);
});
});
+13 -10
View File
@@ -141,16 +141,19 @@ export async function fetchMembershipTypes(): Promise<MembershipTypeOption[]> {
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<string> {
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;
}
+2 -36
View File
@@ -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]);
};
+55 -48
View File
@@ -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 () => {
+20 -36
View File
@@ -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,
+12
View File
@@ -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),
});
}
-12
View File
@@ -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),
});
}