feat(membership): pay through stripe checkout, adding apple pay and google pay
This commit is contained in:
16 files changed
+147
-228
No files matched your search
@@ -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
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Generated
+11
-21
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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]);
|
||||
};
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user