Payment Flow and Custom Development
To help boilerplate buyers 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
In this section, we focus on the user-perceivable payment flow. The webhook part of the process will be explained separately in the next section.
Display Pricing Plans
The default component for displaying pricing plans is components/home/Pricing.tsx
and its child components.
The getPublicPricingPlans
method is used to get the active pricing plans set by administrators, which will display monthly, yearly, and one-time payment pricing in groups.

However, not all products need to provide these 3 groups of pricing plans simultaneously. Perhaps your product only has 1-3 pricing plans, and you prefer to list the 3 cards together without grouping. In that case, you can use components/home/PricingAll.tsx
. The modification is as follows:
export default async function HomeComponent() {
const messages = await getMessages();
return (
<div className="w-full">
{messages.Landing.Hero && <Hero />}
// Change the call from <Pricing /> to <PricingAll>
// {messages.Landing.Pricing && <Pricing />}
{messages.Landing.Pricing && <PricingAll />}
</div>
);
}

User Payment
When a user clicks the purchase button, a request is sent to the /api/stripe/checkout-session
endpoint.
In this endpoint, the current user's unique identifier (customer_id) in the Stripe account is queried, and then based on the plan the user wants to purchase, a payment page (checkout page) is requested from Stripe.
The request parameters to Stripe also include success_url
and cancel_url
, which are used to specify the pages to redirect to after successful payment and payment cancellation, respectively.
After the frontend obtains the checkout address, it will enter Stripe's payment page. Successful payment will redirect to the page specified by success_url
.
Good to know
success_url
usually doesn't need modification, but you need to modify the content of theapp/[locale]/payment/success/page.tsx
page to fit your businesscancel_url
usually doesn't need modification; it points to the pricing page set in environment variables (default is the homepage's/#pricing
)
Good to know
Before testing the payment flow, you must complete key steps such as creating webhooks and enabling Forward Port in the Stripe Integration.
Use test information provided by Stripe to test the payment flow:
- Credit card: 4242 4242 4242 4242
- Use a 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 Nexty's default payment success page displays the pricing plan purchased by the user. You should modify the display content according to your own business to improve user experience.

User Benefits Display
Let's first try purchasing the built-in initial pricing plans, purchasing both one-time payments and subscription cycles that have credit values defined in benefits_jsonb
. After completion, you can see the current benefits in /dashboard/subscription
and the dropdown box in the upper right corner of the page.

You can also view credit history records at /dashboard/credit-history

Open Supabase's Table Editor, and you can see corresponding data in orders
, subscriptions
, usage
, and credit_logs
tables respectively.
The core method for obtaining user benefits is in actions/usage/benefits.ts
under getUserBenefits
, which returns benefit information defined as follows:
export interface UserBenefits {
activePlanId: string | null;
subscriptionStatus: string | null; // e.g., 'active', 'trialing', 'past_due', 'canceled', null
currentPeriodEnd: string | null;
nextCreditDate: string | 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
,subscriptionStatus
,currentPeriodEnd
are retrieved from thesubscriptions
table, reflecting the plan purchased by the user and current subscription status, and should not be modifiednextCreditDate
is for annual subscription monthly credit reset usage, letting users know when the next credit reset will occur; if not needed, it can be retained but not used, or you can globally search for "nextCreditDate" and delete all occurrencestotalAvailableCredits
,subscriptionCreditsBalance
, andoneTimeCreditsBalance
are retrieved from theusage
table, reflecting the built-in one-time payment and subscription cycle credit balances. If not needed, it's still recommended to retain but not use them, meaning you don't take the return values of these three parameters when calling thegetUserBenefits
method- Your custom benefits should extend the existing
UserBenefits
, and you also need to modify theusage
table query method and return object ingetUserBenefits
After you complete the modification of getUserBenefits
, you need to check components/layout/CurrentUserBenefitsDisplay.tsx
. This component will affect the user benefits displayed on the /dashboard/subscription
page and in the upper right corner.
Simulate User Credit Usage
Starting from code repository v1.1.4, Nexty provides an example of 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 process. This page can only be opened in the development environment, so you don't need to worry about abuse in the production environment.
The credit deduction method that pairs with /dashboard/credit-usage-example
is in actions/usage/deduct.ts
, which calls Supabase's RPC deduct_credits_and_log
. This RPC is defined in the supabase/migrations/20250725053916_initial_credit_logs.sql
file.

Webhook
In the Stripe Integration chapter, we have already checked the following 7 webhook events in the Stripe dashboard:
- charge.refunded
- checkout.session.completed
- customer.subscription.created
- customer.subscription.updated
- customer.subscription.deleted
- invoice.paid
- invoice.payment_failed
The endpoint for receiving webhooks is at app/api/stripe/webhook/route.ts
, with 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.
One-time Payment
For one-time payments, receiving a checkout.session.completed
event means the user has completed payment, and we can start processing one-time payment benefits.
Payment platforms including Stripe have the possibility of 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 called "idempotency check," 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();
After passing the idempotency check, 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, which is the upgradeOneTimeCredits
method.
// --- [custom] Upgrade the user's benefits ---
upgradeOneTimeCredits(userId, planId);
// --- End: [custom] Upgrade the user's benefits ---
In the upgradeOneTimeCredits
method, we query the database based on the user's purchased paid plan's plan_id
field, get the benefits defined by the administrator in benefits_jsonb
, and then upgrade the paid benefits for the user. Nexty defaults to using the built-in one_time_credits
to increase user credits.
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('grant_one_time_credits_and_log', {
p_user_id: userId,
p_credits_to_add: creditsToGrant,
p_related_order_id: orderId,
});
}
If you create a new pricing plan and customize benefits_jsonb
, then you need to customize the upgradeOneTimeCredits
method according to actual needs.
Subscription and Renewal
For subscriptions, only the first payment triggers a checkout.session.completed
event; subsequent renewals don't trigger it, so for subscription scenarios, we don't process this event.
To track initial 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 initial subscriptions and customer.subscription.updated
for subscription renewals.
Good to know
In Stripe's Subscription object, the subscription status field can have 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. Idempotency check is still required:
const { data: existingOrder, error: queryError } = await supabaseAdmin
.from('orders')
.select('id')
.eq('provider', 'stripe')
.eq('provider_order_id', invoiceId)
.maybeSingle();
After passing the idempotency check, we get the latest subscription information through the Stripe SDK, write the subscription information to the orders
table, while 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 subscription users, which is the upgradeSubscriptionCredits
method.
// --- [custom] Upgrade the user's benefits ---
upgradeSubscriptionCredits(userId, planId, invoiceId);
// --- End: [custom] Upgrade the user's benefits ---
In the upgradeSubscriptionCredits
method, we query the database based on the user's purchased paid plan's plan_id
field, get the benefits defined by the administrator in benefits_jsonb
, and then upgrade the paid benefits for the user. Nexty defaults to using the built-in monthly_credits
to increase user credits.
const { data: planData, error: planError } = await supabaseAdmin
.from('pricing_plans')
.select('recurring_interval, benefits_jsonb')
.eq('id', planId)
.single();
const benefits = planData.benefits_jsonb as any;
const recurringInterval = planData.recurring_interval;
const creditsToGrant = benefits?.monthly_credits || 0;
if (recurringInterval === 'month' && creditsToGrant) {
const { error: usageError } = await supabaseAdmin.rpc('grant_subscription_credits_and_log', {
p_user_id: userId,
p_credits_to_set: creditsToGrant,
p_related_order_id: orderId,
});
}
If you create a new pricing plan and customize benefits_jsonb
, then you need to customize the upgradeSubscriptionCredits
method according to actual needs.
At the end of the handleInvoicePaid
method, we also call the syncSubscriptionData
method to pull user subscription information from Stripe and save it to the database. The reason for designing this step is that in actual operations, due to network and other reasons, the order of events pushed by Stripe and the order we actually receive in webhooks may be different. If we only update based on information carried in webhooks, data errors and 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 event order 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 initial subscription. 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. The reason for listening is also to get the latest user subscription information, so it also calls syncSubscriptionData
, for the same reason as above.
Subscription Renewal Failure
We don't need to handle one-time payment failure scenarios because one-time payment failures will show prompts on the Checkout page.
For subscriptions, users may encounter payment failures during subscription renewals due to insufficient balance, invalid cards, and other reasons. Therefore, we must properly handle renewal failure scenarios, which is 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 payment
- unpaid: unpaid
In addition, the sendInvoicePaymentFailedEmail
method is called to send payment failure notifications to users, attempting to re-engage users. The email template is in emails/invoice-payment-failed.tsx
, which you can customize according to your needs.
One-time Payment Order Refund
Order refunds are usually manually operated by us in the Stripe dashboard, but benefit recovery after refunds needs to be automatically executed by code. Therefore, for refunds of one-time payments, Nexty provides listening to the charge.refunded
event, with core processing logic in the handleRefund
method.
Listening to charge.refunded
still requires idempotency check:
const { data: existingRefundOrder, error: queryError } = await supabaseAdmin
.from('orders')
.select('id')
.eq('provider', 'stripe')
.eq('provider_order_id', refundId)
.eq('order_type', 'refund')
.maybeSingle();
After passing the idempotency check, data corresponding to charge.payment_intent
of the refund order will be found in the orders
table, and the order_type
of this data will be assigned the value refund
.
Then benefit recovery processing is performed:
// --- [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
method, we query the database based on the user's purchased paid plan's plan_id
field, get the benefits defined by the administrator in benefits_jsonb
, and 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_and_log', {
p_user_id: originalOrder.user_id,
p_revoke_one_time: oneTimeToRevoke,
p_revoke_subscription: 0,
p_log_type: 'refund_revoke',
p_notes: `Full refund for order ${originalOrder.id}.`,
p_related_order_id: refundOrderId,
p_clear_yearly_details: false,
p_clear_monthly_details: false
});
}
If you create a new pricing plan and customize benefits_jsonb
, then you need to customize the revokeOneTimeCredits
method according to actual needs.
Subscription Cancellation and Refund
For subscriptions, refunding alone generally cannot resolve user disputes. The correct approach is to cancel the user's subscription and refund. Therefore, for subscription scenarios, we should listen to the customer.subscription.deleted
event, which is usually triggered by manual operations in the Stripe dashboard.
The processing logic for the customer.subscription.deleted
event is in handleSubscriptionUpdate
, which is the same method as initial subscription and renewal, and will update subscription status. When canceling subscription, the status becomes canceled
, and canceled_at
and ended_at
will be assigned the current time.
The customer.subscription.deleted
event also passes an isDeleted
identifier to handleSubscriptionUpdate
, used 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 perform benefit recovery based on the benefit definition recorded in the balance_jsonb
field of the usage
table:
if (subscriptionToRevoke) {
const { data: revokeResult, error: revokeError } = await supabaseAdmin.rpc('revoke_credits_and_log', {
p_user_id: userId,
p_revoke_one_time: 0,
p_revoke_subscription: subscriptionToRevoke,
p_log_type: 'subscription_cancel_revoke',
p_notes: `Subscription ${subscriptionId} cancelled/ended.`,
p_related_order_id: null,
p_clear_yearly_details: clearYearly,
p_clear_monthly_details: clearMonthly
});
}
If you create a new pricing plan and customize benefits_jsonb
, then you need to customize the revokeSubscriptionCredits
method according to actual needs.
Good to know
Whether performing benefit rollback based on the pricing plan's
benefits_jsonb
or based on thebalance_jsonb
ofusage
, neither is the best handling method. The next version will be upgraded to associate withorder
data.
How to Process Refunds?
When encountering user disputes, refunds need to be initiated from the Stripe dashboard page. One-time payments and subscriptions are handled differently.
One-time Payment Refund
For one-time payment refunds, directly refund from the Transactions page.

After completing the refund, it will trigger the webhook's charge.refunded
event, entering the process we analyzed above.
Subscription Cancellation and Refund
Subscription refunds usually need to synchronously cancel the subscription, so you need to cancel the subscription and refund on the Subscriptions page.


After completing cancellation and refund, it will trigger the webhook's customer.subscription.deleted
event, entering the process we analyzed above.
Custom Development Checklist
- Modify the
app/[locale]/payment/success/page.tsx
page, design payment success display information combined with your own business - Customize user benefits
- Need to modify the method for obtaining benefits, i.e.,
getUserBenefits
inactions/usage/benefits.ts
- Need to modify user benefit display, i.e., the
components/layout/CurrentUserBenefitsDisplay.tsx
component - For one-time payments
- Need to re-implement benefit upgrade method, i.e.,
upgradeOneTimeCredits
inlib/stripe/webhook-handlers.ts
- Need to re-implement refund benefit recovery method, i.e.,
revokeOneTimeCredits
inlib/stripe/webhook-handlers.ts
- Need to re-implement benefit upgrade method, i.e.,
- For subscription payments
- Need to re-implement benefit upgrade method, i.e.,
upgradeSubscriptionCredits
inlib/stripe/webhook-handlers.ts
- Need to re-implement refund benefit recovery method, i.e.,
revokeSubscriptionCredits
inlib/stripe/webhook-handlers.ts
- Need to re-implement benefit upgrade method, i.e.,
- If your product's 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 inactions/usage/deduct.ts
for credit deduction - If you redesign the credit system, you can refer to
actions/usage/deduct.ts
to implement custom credit deduction methods
- Using the built-in
- Need to modify the method for obtaining benefits, i.e.,
- Modify the email notification for subscription renewal failure (optional, default boilerplate is universal), i.e.,
emails/invoice-payment-failed.tsx