Overview
This document provides a complete guide to implement improvements to the invoice payment system, including dynamic button text, payment type selection for optional recurring invoices, and optimized payment intent initialization.
File to Modify
apps/invoice/src/app/pay/[handle]/[invoiceId]/_components/invoice-payment -form.tsx
Changes Required
- Update Imports Section
Find this block (around lines 1-15): 'use client';
import type { z } from 'zod/v4'; import { useCallback, useState } from 'react'; import { Elements } from '@stripe/react-stripe-js'; import { loadStripe } from '@stripe/stripe-js'; import { useMutation } from '@tanstack/react-query';
import type { selectInvoiceSchema } from '@barely/validators/schemas/invoice.schema';
import { useInvoiceRenderTRPC } from '@barely/api/public/invoice-render.trpc.react';
import { Button } from '@barely/ui/button'; import { Modal } from '@barely/ui/modal'; import { H, Text } from '@barely/ui/typography';
import { CheckoutForm } from './checkout-form';
Replace with: 'use client';
import type { z } from 'zod/v4'; import { useCallback, useEffect, useRef, useState } from 'react'; import { Elements } from '@stripe/react-stripe-js'; import { loadStripe } from '@stripe/stripe-js'; import { useMutation } from '@tanstack/react-query';
import type { selectInvoiceSchema } from '@barely/validators/schemas/invoice.schema';
import { useInvoiceRenderTRPC } from '@barely/api/public/invoice-render.trpc.react';
import { Button } from '@barely/ui/button'; import { Modal } from '@barely/ui/modal'; import { Switch } from '@barely/ui/switch'; import { H, Text } from '@barely/ui/typography'; import { cn } from '@barely/utils/cn'; import { formatCurrency } from '@barely/utils/currency';
import { CheckoutForm } from './checkout-form';
- Update State Variables
Find this block (around lines 35-40): const [showPaymentModal, setShowPaymentModal] = useState(false); const [clientSecret, setClientSecret] = useState<string | null>(null); const [isRecurring, setIsRecurring] = useState( invoice.type === 'recurring' || invoice.type === 'recurringOptional', );
Replace with: const [showOneTimeModal, setShowOneTimeModal] = useState(false); const [showRecurringModal, setShowRecurringModal] = useState(false); const [paymentIntentClientSecret, setPaymentIntentClientSecret] = useState<string | null>(null); const [setupIntentClientSecret, setSetupIntentClientSecret] = useState<string | null>(null); const [isRecurring, setIsRecurring] = useState( invoice.type === 'recurring', ); const [isInitializing, setIsInitializing] = useState(true); const initializedRef = useRef(false);
- Update Mutation and Add useEffect for Pre-initialization
Find this block (around lines 42-54): const { mutate: initializePayment, isPending } = useMutation( trpc.initializePayment.mutationOptions({ onSuccess: data => { setClientSecret(data.clientSecret); setShowPaymentModal(true); }, onError: error => { console.error('Failed to initialize payment:', error); alert('Failed to initialize payment. Please try again.'); }, }), );
Replace with: const { mutate: initializePayment } = useMutation( trpc.initializePayment.mutationOptions({ onError: error => { console.error('Failed to initialize payment:', error); }, }), );
// Pre-initialize payment intents on mount
useEffect(() => {
// Guard against multiple initializations (especially in StrictMode)
if (initializedRef.current) {
return;
}
const initializePayments = async () => {
try {
if (invoice.type === 'oneTime') {
// Initialize only payment intent
initializePayment(
{
invoiceId: invoice.id,
isRecurring: false,
},
{
onSuccess: data => {
setPaymentIntentClientSecret(data.clientSecret);
setIsInitializing(false);
initializedRef.current = true;
},
},
);
} else if (invoice.type === 'recurring') {
// Initialize only setup intent
initializePayment(
{
invoiceId: invoice.id,
isRecurring: true,
},
{
onSuccess: data => {
setSetupIntentClientSecret(data.clientSecret);
setIsInitializing(false);
initializedRef.current = true;
},
},
);
} else if (invoice.type === 'recurringOptional') {
// Initialize both in parallel
let completedCount = 0;
const checkComplete = () => {
completedCount++;
if (completedCount === 2) {
setIsInitializing(false);
initializedRef.current = true;
}
};
// Initialize one-time payment
initializePayment(
{
invoiceId: invoice.id,
isRecurring: false,
},
{
onSuccess: data => {
setPaymentIntentClientSecret(data.clientSecret);
checkComplete();
},
},
);
// Initialize recurring payment
initializePayment(
{
invoiceId: invoice.id,
isRecurring: true,
},
{
onSuccess: data => {
setSetupIntentClientSecret(data.clientSecret);
checkComplete();
},
},
);
}
} catch (error) {
console.error('Failed to initialize payment:', error);
setIsInitializing(false);
// Don't set initializedRef.current = true on error
}
};
initializePayments();
}, [invoice.id, invoice.type, initializePayment]);
- Replace handlePayment Function and Add Helper Functions
Find this block (around lines 139-149): const handlePayment = useCallback(() => { initializePayment({ invoiceId: invoice.id, isRecurring: invoice.type === 'recurring' || (invoice.type === 'recurringOptional' && isRecurring), }); }, [invoice.id, invoice.type, isRecurring, initializePayment]);
Replace with: const handlePayment = useCallback(() => { if (invoice.type === 'oneTime') { setShowOneTimeModal(true); } else if (invoice.type === 'recurring') { setShowRecurringModal(true); } else if (invoice.type === 'recurringOptional') { if (isRecurring) { setShowRecurringModal(true); } else { setShowOneTimeModal(true); } } }, [invoice.type, isRecurring]);
const getButtonText = () => {
if (invoice.type === 'oneTime') {
return 'Pay Now';
} else if (invoice.type === 'recurring') {
return 'Set up Auto-Pay';
} else if (invoice.type === 'recurringOptional') {
return isRecurring ? 'Set up Auto-Pay' : 'Pay Now';
}
return 'Pay';
};
const getDisplayAmount = () => {
if (invoice.type === 'recurringOptional' && isRecurring &&
invoice.recurringDiscountPercent) { const discountedAmount = invoice.amount * (1 - invoice.recurringDiscountPercent / 100); return discountedAmount; } return invoice.amount; };
- Add Payment Type Toggle UI
Find the start of the return statement (around line 151): return (
Insert the following right after the closing
<div className='space-y-3'>
<label className={cn(
'flex items-center justify-between p-3 rounded-md border cursor-pointer
transition-colors', !isRecurring ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:bg-gray-50' )}>
<label className={cn(
'flex items-center justify-between p-3 rounded-md border cursor-pointer
transition-colors', isRecurring ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:bg-gray-50' )}>
- Update Button
Find this block (around line 170-178): {isPending ? 'Processing...' : 'Subscribe'}
Replace with: {isInitializing ? 'Loading...' : getButtonText()}
- Replace Single Modal with Dual Modal Architecture
Find the Modal section (around lines 180-210): {stripePromise && clientSecret && (
Replace with: {/_ One-time Payment Modal _/} {stripePromise && paymentIntentClientSecret && (
{/* Recurring Payment Modal */}
{stripePromise && setupIntentClientSecret && (
<Modal
open={showRecurringModal}
onOpenChange={setShowRecurringModal}
className='sm:max-w-md'
>
<div className='p-6'>
<H size='3' className='mb-4'>
Set Up Auto-Pay
</H>
<Text className='mb-4 text-gray-600'>
{invoice.recurringDiscountPercent && invoice.recurringDiscountPercent > 0
? ( <> Amount: {formatCurrency(invoice.amount, invoice.currency)}{' '} {formatCurrency(getDisplayAmount(), invoice.currency)} {' '}per {invoice.recurringInterval} </> ) : ( <>Amount: {formatCurrency(invoice.amount, invoice.currency)} per {invoice.recurringInterval}</> )} <Elements stripe={stripePromise} options={{ clientSecret: setupIntentClientSecret, appearance: { theme: 'stripe', }, }} > <CheckoutForm invoiceId={invoice.id} isRecurring={true} onSuccess={() => { setShowRecurringModal(false); if (onPaymentSuccess) { onPaymentSuccess(); } }} />
)}Testing Checklist
After implementing these changes:
-
Test oneTime invoices:
- Button shows "Pay Now"
- Payment intent initializes on page load
- Modal opens instantly when button clicked
- Payment completes successfully
-
Test recurring invoices:
- Button shows "Set up Auto-Pay"
- Setup intent initializes on page load
- Modal opens instantly when button clicked
- Subscription setup completes successfully
-
Test recurringOptional invoices:
- Payment type toggle appears
- Both payment and setup intents initialize on page load
- Button text changes based on selection
- Discount information displays correctly (if applicable)
- Correct modal opens based on selection
- Both payment types work correctly
-
Test edge cases:
- React StrictMode doesn't cause double initialization
- Closing and reopening modals doesn't re-initialize
- Error handling works if initialization fails
Commands to Run After Changes
pnpm lint --filter=@barely/invoice
pnpm typecheck --filter=@barely/invoice
pnpm dev:app
Summary of Improvements
- Dynamic Button Text: Button shows appropriate text based on invoice type and user selection
- Payment Type Toggle: For optional recurring invoices, users can choose between one-time or auto-pay
- Discount Display: Shows savings when choosing recurring option with discount
- Pre-initialization: Payment intents initialize on page load for instant modal opening
- Dual Modal Architecture: Separate modals for one-time and recurring payments
- useRef Guard: Prevents double initialization in React StrictMode
- Better UX: No waiting for payment initialization when clicking button
Notes
- The formatCurrency utility should already exist in @barely/utils/currency
- The cn utility should already exist in @barely/utils/cn
- The Switch component import might not be needed if using radio buttons instead
- Make sure the invoice schema includes recurringDiscountPercent and recurringInterval fields