Enforces consistent Stripe payment and checkout patterns across the Ernit app. Use whenever creating, modifying, or reviewing checkout screens, payment flows, or gift creation logic.
This skill defines the standard patterns for Stripe payments, checkout screens, gift polling, and confirmation flows in the Ernit app.
Every purchase follows this pipeline:
Cart / Selection Screen
-> ExperienceCheckoutScreen (payment form + Stripe Elements)
-> stripe.confirmPayment() (client-side)
-> Stripe webhook triggers Cloud Function (server-side gift creation)
-> Client polls for gift(s)
-> ConfirmationScreen (single gift) or ConfirmationMultipleScreen (multi-gift)
Key files:
src/screens/giver/ExperienceCheckoutScreen.tsx — payment form, Stripe Elements, pollingsrc/screens/giver/ConfirmationScreen.tsx — post-payment success UIsrc/services/stripeService.ts — all Stripe-related API callssrc/config/environment.ts — environment-aware function routingAlways create PaymentIntents via the stripeService wrapper, never by calling Stripe APIs directly from the client.
import { stripeService } from '../../services/stripeService';
const { clientSecret, paymentIntentId } = await stripeService.createPaymentIntent(
amount, // number — total in cents
giverId, // string — authenticated user ID
giverName, // string — display name
partnerId, // string — optional partner ID
cartItems, // { experienceId, partnerId, quantity }[]
personalizedMessage // string — optional message
);
Rules:
currentUser.getIdToken()).config.functionsUrl / config.stripeFunctions.createPaymentIntent.src/config/environment.ts) routes to _Test suffixed functions in test mode and bare names in production.paymentIntentId is extracted from clientSecret.split("_secret_")[0].logErrorToFirestore with full context (amount, giverId, partnerId, cart count).Standard setup inside ExperienceCheckoutScreen:
import { Elements, PaymentElement, useStripe, useElements } from '@stripe/react-stripe-js';
import { loadStripe } from '@stripe/stripe-js';
// Top-level: load Stripe once
const stripePromise = loadStripe(process.env.EXPO_PUBLIC_STRIPE_PK!);
// Outer component: wrap with Elements provider
<Elements stripe={stripePromise} options={{ clientSecret }}>
<CheckoutInner ... />
</Elements>
// Inner component: use hooks
const stripe = useStripe();
const elements = useElements();
Payment submission pattern:
const { error, paymentIntent } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: Platform.OS === 'web'
? window.location.href
: 'https://ernit-nine.vercel.app/payment-success',
},
redirect: 'if_required',
});
if (error) throw error;
if (!paymentIntent) throw new Error('No payment intent returned.');
Rules:
redirect: 'if_required' so card payments resolve inline while redirect-based methods (MB Way, iDEAL) work too.payment_intent_client_secret from URL params (web) or AsyncStorage flag (native) and call stripe.retrievePaymentIntent().pending_payment_{clientSecret}) before calling confirmPayment and remove it after success.After payment succeeds, the webhook-triggered Cloud Function creates gift(s) asynchronously. The client must poll to pick them up.
const pollForGifts = async (
paymentIntentId: string,
expectedCount: number,
maxAttempts: number = 12,
delayMs: number = 1000
): Promise<ExperienceGift[]> => {
for (let i = 0; i < maxAttempts; i++) {
const gifts = await checkGiftCreation(paymentIntentId);
if (gifts.length === expectedCount) return gifts;
await new Promise((res) => setTimeout(res, delayMs));
}
return [];
};
The checkGiftCreation helper calls the authenticated getGiftsByPaymentIntent Cloud Function.
After polling:
dispatch({ type: 'SET_EXPERIENCE_GIFT', payload: gifts[0] }) then navigate to Confirmation with { experienceGift, goalId }.dispatch({ type: 'CLEAR_CART' }) then navigate to ConfirmationMultiple with { experienceGifts }.Always clear the cart (dispatch({ type: 'CLEAR_CART' })) and remove the pending payment storage flag after a successful poll.
Stripe returns structured error objects. Map them to user-friendly messages:
error.type / error.code | User Message |
|---|---|
card_error / card_declined | "Your card was declined. Please try another payment method." |
card_error / expired_card | "Your card has expired. Please update your card details." |
card_error / insufficient_funds | "Insufficient funds. Please try another card." |
card_error / incorrect_cvc | "Incorrect CVC. Please check and try again." |
validation_error | "Please check your payment details." |
| Network / timeout | "Connection issue. Please check your internet and try again." |
Always log payment errors to Firestore with full context:
await logErrorToFirestore(error, {
feature: 'PaymentIntent', // or 'RedirectReturn', 'GiftPolling'
screenName: 'ExperienceCheckoutScreen',
userId: state.user?.id,
additionalData: {
amount,
paymentIntentId,
cartItemsCount: cartItems?.length || 0,
errorType: error.name,
},
});
If gift polling exhausts all attempts:
When a user buys an experience to empower another user's goal:
empowerContext is set in AppContext with { userId, goalId, userName, isMystery }.goalId is threaded through as a route param to ExperienceCheckoutScreen.empowerContext from state.empowerContext and checks isEmpower = Boolean(empowerContext && empowerContext.userId !== state.user?.id).Empower confirmation behavior:
experience_empowered notification to the goal owner via notificationService.createNotification().isMystery === true, notification says "mystery experience" (details hidden until goal completion).dispatch({ type: 'SET_EMPOWER_CONTEXT', payload: null }).Self-purchase with goalId (not empower):
goalService.attachGiftToGoal(goalId, giftId, userId).PaymentElement). The client never sees card numbers, CVVs, or expiry dates.stripeService methods fetches a Firebase ID token and sends it as Authorization: Bearer {token}._Test suffixed Cloud Functions and Stripe test keys. Never mix test and production function names.ConfirmationScreen receives { experienceGift, goalId } as route params.
Standard behavior:
experienceGift is missing/invalid (e.g., browser refresh), redirect to CategorySelection.experienceService.getExperienceById().FeedGoalsCategorySelectionUse this before submitting any PR that touches payment or checkout code:
stripeService.createPaymentIntent(), not raw fetchconfig.stripeFunctions.*)Elements provider wraps the checkout component with clientSecretstripe.confirmPayment() called with redirect: 'if_required'confirmPayment and cleared after successdispatch({ type: 'CLEAR_CART' }))logErrorToFirestore with full contextgoalService.attachGiftToGoal()