Payment Flow and Custom Development
To help template purchasers get started more easily and implement their own payment logic, this chapter will introduce the code implementation of Nexty.dev's built-in payment flow and explain how to start your custom logic development.
Payment Flow
This section focuses on the user-perceivable payment flow, with the Webhook portion covered separately in the next section.
Display Pricing Plans
The component that displays pricing cards is components/home/Pricing.tsx
and its child components.
It uses the getPublicPricingPlans
method to fetch active pricing cards set by administrators.
User Payment
When users click the purchase button, a request is sent to the /api/stripe/checkout-session
endpoint.
In the /api/stripe/checkout-session
endpoint, the current user's unique identifier (customer_id) in the Stripe account is queried, then based on the plan the user wants to purchase, a payment page (checkout page) address is requested from Stripe.
The parameters sent to Stripe also include success_url
and cancel_url
, which specify the pages to redirect to after successful payment and payment cancellation respectively.
After the frontend receives the checkout address, it enters Stripe's payment flow. Successful payment redirects to the page specified by success_url
.
Good to know
success_url
doesn't need modification, but you need to modify the content of theapp/[locale]payment/success/page.tsx
page to suit your businesscancel_url
usually doesn't need modification either; it points to the pricing card area on the homepage
Good to know
Before testing the payment flow, you must complete key steps like creating Webhooks and enabling Forward Port in the Stripe Integration chapter.
Use Stripe's provided test information to test the payment flow:
- Credit card: 4242 4242 4242 4242
- Use any valid future date, such as 12/34
- Use any three-digit CVC (four digits for American Express cards)
- Use any values for other form fields.
Payment Success
You need to modify the app/[locale]payment/success/page.tsx
page, because the Nexty.dev template's default payment success page displays the pricing plan the user purchased. You should modify the display content according to your business to provide a better user experience.
User Benefits Display
Let's first try purchasing the built-in initialized pricing plans, buying both one-time payment and recurring subscription. After completion, you can see current benefits in /dashboard/subscription
and the dropdown menu in the top-right corner.
Opening Supabase's Table Editor, you can see corresponding data in the orders
, subscriptions
, and usage
tables respectively.
The core method for getting user benefits is getUserBenefits
in lib/stripe/actions.ts
, with returned benefit information defined as follows:
export interface UserBenefits {
activePlanId: string | null;
subscriptionStatus: string | null; // e.g., 'active', 'trialing', 'past_due', 'canceled', null
totalAvailableCredits: number;
subscriptionCreditsBalance: number;
oneTimeCreditsBalance: number;
// Add other plan-specific benefits if needed, fetched via planId
}
When you need to customize benefits, you should note:
activePlanId
andsubscriptionStatus
are fetched fromsubscriptions
, reflecting the plan the user purchased and current subscription status, and should not be modifiedtotalAvailableCredits
,subscriptionCreditsBalance
, andoneTimeCreditsBalance
are fetched from theusage
table, reflecting built-in one-time payment and recurring subscription credit balances. If not needed, it's still recommended to keep them but not use them, meaning you simply don't take the return values of these three parameters where you call thegetUserBenefits
method- Your custom benefits should extend the existing
UserBenefits
, while also modifying theusage
table query method and return object (i.e., thereturn
object) ingetUserBenefits
After completing modifications to getUserBenefits
, you need to check components/layout/CurrentUserBenefitsDisplay.tsx
, as this component affects the user benefits displayed on the /dashboard/subscription
page and in the top-right corner.
Good to know
If you insist on modifying the original definition of
UserBenefits
, you also need to check whether corresponding fields in the following files need changes:
app/[locale]/(protected)/dashboard/(user)/subscription/page.tsx
actions/usage/deduct.ts
Simulate User Credit Usage
Starting from code repository v1.1.4, Nexty.dev provides an example for simulating user credit usage.
You can start the project locally and go to the /dashboard/credit-usage-example
page to test the built-in credit deduction flow. This page can only be opened in development environment, so there's no need to worry about production environment abuse.
The credit deduction method that works with /dashboard/credit-usage-example
is in actions/usage/deduct.ts
, which calls Supabase's RPC (Remote Procedure Call). The corresponding RPC definition is in the data/5、usage(deduct_rpc_demo).sql
file.
Good to know
Before simulating user credit usage, you need to confirm:
- You have executed
data/5、usage(deduct_rpc_demo).sql
in Supabase's SQL Editor- You have updated the Supabase database definition locally, refer to the steps in Supabase Integration
actions/usage/deduct.ts
provides four credit deduction strategies:
// 1. Deduct only one-time credits
await deductOneTimeCredits(amount, locale);
// 2. Deduct only subscription credits
await deductSubscriptionCredits(amount, locale);
// 3. Prioritize deducting subscription credits
await deductCreditsPrioritizingSubscription(amount, locale);
// 4. Prioritize deducting one-time credits
await deductCreditsPrioritizingOneTime(amount, locale);
These four methods can be used directly, but note that in actual development, these methods should not be called on the frontend; they should be called on the server side where users have completed using the functionality.
If you need to customize the credit system and deduction methods, you can reference these methods to implement new custom methods.
Webhook
In the Stripe Integration chapter, we have already selected the following 7 Webhook events in the Stripe console:
- charge.refunded
- checkout.session.completed
- customer.subscription.created
- customer.subscription.updated
- customer.subscription.deleted
- invoice.paid
- invoice.payment_failed
In the Nexty.dev code, the entry point for receiving Webhooks is app/api/stripe/webhook/route.ts
, with the main processing logic in the processWebhookEvent
method.
Now, you can start comparing the code with the following documentation to deeply understand the payment system provided by Nexty.dev.
One-time Payment
For one-time payments, receiving the checkout.session.completed
event indicates that the user has completed payment and an order has been generated on the Stripe side, so we can use this as the basis for processing one-time payment benefits.
Payment platforms including Stripe may have duplicate webhook event pushes, so before writing order data to the orders
table, we need to first check if the same order data already exists in the database. This is "idempotency checking," executed in the handleCheckoutSessionCompleted
method.
const { data: existingOrder, error: queryError } = await supabaseAdmin
.from('orders')
.select('id')
.eq('provider', 'stripe')
.eq('provider_order_id', paymentIntentId)
.maybeSingle();
if (existingOrder) {
return;
}
For events that pass idempotency checking, we write the necessary information pushed by Stripe to the database, while marking the order as completed through status
and marking this as a one-time payment order through order_type
.
const orderData = {
user_id: userId,
status: 'succeeded',
order_type: 'one_time_purchase',
// ...others...
};
After completing the order data write to the orders
table, we can start upgrading benefits for one-time payment users, i.e., the upgradeOneTimeCredits
method.
// --- [custom] Upgrade the user's benefits ---
upgradeOneTimeCredits(userId, planId);
// --- End: [custom] Upgrade the user's benefits ---
In the upgradeOneTimeCredits
logic, we query the database based on the plan_id field of the paid plan the user purchased to get the benefits set by the administrator in benefits_jsonb
(i.e., the Benefits item on the create/edit pricing plan page). By default, the built-in one_time_credits
is used to add credits for the user.
const { data: planData, error: planError } = await supabaseAdmin
.from('pricing_plans')
.select('benefits_jsonb')
.eq('id', planId)
.single();
const creditsToGrant = (planData.benefits_jsonb as any)?.one_time_credits || 0;
if (creditsToGrant && creditsToGrant > 0) {
const { error: usageError } = await supabaseAdmin.rpc('upsert_and_increment_one_time_credits', {
p_user_id: userId,
p_credits_to_add: creditsToGrant
});
}
If you create pricing plans with customized benefits_jsonb
, you need to re-implement the upgradeOneTimeCredits
method to meet your requirements.
Recurring Subscriptions and Renewals
For recurring subscriptions, only the first payment triggers the checkout.session.completed
event; subsequent renewals do not trigger it, so we don't handle this event for recurring subscription scenarios.
To track first subscription and renewal payment situations, we should listen to invoice.paid
, which indicates that user payment has been received. Additionally, to get the latest subscription information, we also need to listen to customer.subscription.created
for first subscriptions and customer.subscription.updated
for subscription renewals.
Good to know
In Stripe's Subscription object, the subscription status field may have these values:
'active' | 'canceled' | 'incomplete' | 'incomplete_expired' | 'past_due' | 'paused' | 'trialing' | 'unpaid';
invoice.paid
The core processing logic for invoice.paid
is in the handleInvoicePaid
method. It also requires idempotency checking:
const { data: existingOrder, error: queryError } = await supabaseAdmin
.from('orders')
.select('id')
.eq('provider', 'stripe')
.eq('provider_order_id', invoiceId)
.maybeSingle();
if (existingOrder) {
return;
}
After passing idempotency checking, we write the necessary information pushed by Stripe to the database, marking the order as completed through status
and marking this as a subscription payment order through order_type
.
const orderType = invoice.billing_reason === 'subscription_create' ? 'subscription_initial' : 'subscription_renewal';
const orderData = {
user_id: userId,
status: 'succeeded',
order_type: orderType,
// ...others...
};
After completing the order data write to the orders
table, we can start upgrading or updating benefits for recurring subscription users, i.e., the upgradeSubscriptionCredits
method.
// --- [custom] Upgrade the user's benefits ---
upgradeSubscriptionCredits(userId, planId, invoiceId);
// --- End: [custom] Upgrade the user's benefits ---
In the upgradeSubscriptionCredits
logic, we query the database based on the plan_id
field of the paid plan the user purchased to get the benefits set by the administrator in benefits_jsonb
(i.e., the Benefits item on the create/edit pricing plan page). By default, the built-in monthly_credits
is used to add credits for the user.
const { data: planData, error: planError } = await supabaseAdmin
.from('pricing_plans')
.select('benefits_jsonb')
.eq('id', planId)
.single();
const creditsToGrant = (planData.benefits_jsonb as any)?.monthly_credits || 0;
if (creditsToGrant && creditsToGrant > 0) {
const { error: usageError } = await supabaseAdmin.rpc('upsert_and_set_subscription_credits', {
p_user_id: userId,
p_credits_to_set: creditsToGrant
});
}
If you create pricing plans with customized benefits_jsonb
, you need to re-implement the upgradeSubscriptionCredits
method to meet your requirements.
At the end of the handleInvoicePaid
method, we also call the syncSubscriptionData
method to fetch user subscription information from Stripe and save it to the database. This step is designed because in actual operations, due to network and other reasons, the order of events pushed by Stripe may differ from the order we actually receive in our Webhook. If we only update based on the information carried by Webhooks, incorrect data overwrites may occur. syncSubscriptionData
follows best practices by actively fetching the latest data from Stripe, so we don't need to worry about the actual order of events pushed by Stripe.
try {
await syncSubscriptionData(subscriptionId, customerId);
} catch (syncError) {
console.error(`Error during post-invoice sync for sub ${subscriptionId}:`, syncError);
}
customer.subscription.created
customer.subscription.created
is triggered during first subscription, and the reason for listening is also to get the latest user subscription information, so it also calls syncSubscriptionData
for the same reason as above.
customer.subscription.updated
customer.subscription.updated
is triggered during subscription renewals, and the reason for listening is also to get the latest user subscription information, so it also calls syncSubscriptionData
for the same reason as above.
Recurring Subscription Renewal Failures
We don't need to handle one-time payment failure scenarios because one-time payment failures will show prompts on the Checkout page.
For recurring subscriptions, users may experience payment failures during subscription renewals due to insufficient balance, expired cards, and other reasons, so we must properly handle renewal failure scenarios, i.e., the invoice.payment_failed
event.
The core processing logic for the invoice.payment_failed
event is in the handleInvoicePaymentFailed
method.
The most important thing in this method is calling syncSubscriptionData
to update subscription status. After renewal failure, the status value may change to:
- past_due: Overdue unpaid
- unpaid: Unpaid
Additionally, it calls the sendInvoicePaymentFailedEmail
method to send payment failure notifications to users, attempting to win them back. The email template is in emails/invoice-payment-failed.tsx
, which you can customize according to your needs.
One-time Payment Order Refunds
Order refunds are usually manually operated by us in the Stripe console, but benefit recovery after refunds needs to be automatically executed by code, so for one-time payment refunds, Nexty.dev provides listening for the charge.refunded
event, with core processing logic in the handleRefund
method.
Listening to charge.refunded
still requires idempotency checking:
const { data: existingRefundOrder, error: queryError } = await supabaseAdmin
.from('orders')
.select('id')
.eq('provider', 'stripe')
.eq('provider_order_id', refundId)
.eq('order_type', 'refund')
.maybeSingle();
if (existingRefundOrder) {
return;
}
After passing idempotency checking, it finds the data corresponding to the refund order's charge.payment_intent
in the orders
table and assigns the order_type
of this data to refund
.
Then it performs benefit recovery processing:
// --- [custom] Revoke the user's benefits (only for one time purchase) ---
if (originalOrder) {
revokeOneTimeCredits(charge, originalOrder, refundId);
}
// --- End: [custom] Revoke the user's benefits ---
In the revokeOneTimeCredits
logic, we query the database based on the plan_id field of the paid plan the user purchased to get the benefits set by the administrator in benefits_jsonb (i.e., the Benefits item on the create/edit pricing plan page), then recover the benefits:
let oneTimeToRevoke = 0;
const benefits = planData.benefits_jsonb as any;
if (benefits?.one_time_credits > 0) {
oneTimeToRevoke = benefits.one_time_credits;
}
if (oneTimeToRevoke > 0) {
const { data: revokeResult, error: revokeError } = await supabaseAdmin.rpc('revoke_credits', {
p_user_id: originalOrder.user_id,
p_revoke_one_time: oneTimeToRevoke,
p_revoke_subscription: 0
});
}
If you create pricing plans with customized benefits_jsonb
, you need to re-implement the revokeOneTimeCredits
method to meet your requirements.
Recurring Subscription Cancellation and Refunds
For recurring subscriptions, refunds alone generally cannot resolve user disputes; the correct approach is to cancel the user's subscription and refund. Therefore, for recurring subscription scenarios, we should listen to the customer.subscription.deleted
event, which is generally triggered by manual operations in the Stripe console.
The processing logic for the customer.subscription.deleted
event is in handleSubscriptionUpdate
, which is the same method as for first subscriptions and renewals, and will update subscription status. When canceling subscriptions, the status becomes canceled
, and canceled_at
and ended_at
are assigned the current time.
The customer.subscription.deleted
event also passes an isDeleted
flag to handleSubscriptionUpdate
to trigger the benefit recovery method:
if (isDeleted && userId && planId) {
// --- [custom] Revoke the user's benefits (only for one time purchase) ---
revokeSubscriptionCredits(userId, planId, subscription.id);
// --- End: [custom] Revoke the user's benefits ---
}
In the revokeSubscriptionCredits
logic, we query the database based on the plan_id
field of the paid plan the user purchased to get the benefits set by the administrator in benefits_jsonb
(i.e., the Benefits item on the create/edit pricing plan page), then recover the benefits:
let subscriptionToRevoke = 0;
const benefits = planData.benefits_jsonb as any;
if (benefits?.monthly_credits > 0) {
subscriptionToRevoke = benefits.monthly_credits;
}
if (subscriptionToRevoke >= 0) {
const { data: revokeResult, error: revokeError } = await supabaseAdmin.rpc('revoke_credits', {
p_user_id: userId,
p_revoke_one_time: 0,
p_revoke_subscription: subscriptionToRevoke
});
}
If you create pricing plans with customized benefits_jsonb
, you need to re-implement the revokeSubscriptionCredits
method to meet your requirements.
How to Process Refunds?
When dealing with customer disputes, refunds need to be initiated from the Stripe console page. One-time payments and subscriptions have different processing methods.
One-time Payment Refund
For one-time payment refunds, you can directly process the refund on the Transactions page.
After completing the refund, it will trigger the charge.refunded
event in the Webhook, entering the workflow we analyzed above.
Recurring Subscription Cancellation and Refund
Recurring subscription refunds typically require canceling the subscription simultaneously, so you need to cancel the subscription and process the refund on the Subscriptions page.
After completing the cancellation and refund, it will trigger the customer.subscription.deleted
event in the Webhook, entering the workflow we analyzed above.
Custom Development Checklist
- Modify the
app/[locale]payment/success/page.tsx
page, combining your business to design payment success display information - If customizing user benefits
- Need to modify the method for getting benefits, i.e.,
getUserBenefits
inlib/stripe/actions.ts
- Need to modify user benefit display, i.e., the
components/layout/CurrentUserBenefitsDisplay.tsx
component - For one-time payments
- Need to re-implement the benefit upgrade method, i.e.,
upgradeOneTimeCredits
inlib/stripe/webhook-handlers.ts
- Need to re-implement the refund benefit recovery method, i.e.,
revokeOneTimeCredits
inlib/stripe/webhook-handlers.ts
- Need to re-implement the benefit upgrade method, i.e.,
- For recurring subscription payments
- Need to re-implement the benefit upgrade method, i.e.,
upgradeSubscriptionCredits
inlib/stripe/webhook-handlers.ts
- Need to re-implement the refund benefit recovery method, i.e.,
revokeSubscriptionCredits
inlib/stripe/webhook-handlers.ts
- Need to re-implement the benefit upgrade method, i.e.,
- If your product user benefits use a credit system
- Using the built-in
usage
table andone_time_credits_balance
andsubscription_credits_balance
fields, you can directly use the methods provided byactions/usage/deduct.ts
for credit deduction - If you redesign the credit system, you can reference
actions/usage/deduct.ts
to implement custom credit deduction methods
- Using the built-in
- Need to modify the method for getting benefits, i.e.,
- Modify the email notification for recurring subscription renewal failures (optional, default template is universal), i.e.,
emails/invoice-payment-failed.tsx