Menu

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.

pricing

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 the app/[locale]payment/success/page.tsx page to suit your business
  • cancel_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.

payment success

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.

benefits

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 and subscriptionStatus are fetched from subscriptions, reflecting the plan the user purchased and current subscription status, and should not be modified
  • totalAvailableCredits, subscriptionCreditsBalance, and oneTimeCreditsBalance are fetched from the usage 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 the getUserBenefits method
  • Your custom benefits should extend the existing UserBenefits, while also modifying the usage table query method and return object (i.e., the return object) in getUserBenefits

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

credit-usage-example

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.

refund-onetime-1

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.

refund-sub-1

refund-sub-2

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 in lib/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 in lib/stripe/webhook-handlers.ts
      • Need to re-implement the refund benefit recovery method, i.e., revokeOneTimeCredits in lib/stripe/webhook-handlers.ts
    • For recurring subscription payments
      • Need to re-implement the benefit upgrade method, i.e., upgradeSubscriptionCredits in lib/stripe/webhook-handlers.ts
      • Need to re-implement the refund benefit recovery method, i.e., revokeSubscriptionCredits in lib/stripe/webhook-handlers.ts
    • If your product user benefits use a credit system
      • Using the built-in usage table and one_time_credits_balance and subscription_credits_balance fields, you can directly use the methods provided by actions/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
  • Modify the email notification for recurring subscription renewal failures (optional, default template is universal), i.e., emails/invoice-payment-failed.tsx