Menu

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.

pricing

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:

components/home/index.tsx
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>
  );
}
pricingAll

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

payment success

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.

benefits

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

credit_logs

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 the subscriptions table, reflecting the plan purchased by the user and current subscription status, and should not be modified
  • nextCreditDate 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 occurrences
  • totalAvailableCredits, subscriptionCreditsBalance, and oneTimeCreditsBalance are retrieved from the usage 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 the getUserBenefits method
  • Your custom benefits should extend the existing UserBenefits, and you also need to modify the usage table query method and return object in getUserBenefits

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.

credit-usage-example

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 the balance_jsonb of usage, neither is the best handling method. The next version will be upgraded to associate with order 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.

refund-onetime-1

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.

refund-sub-1
refund-sub-2

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 in actions/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 in lib/stripe/webhook-handlers.ts
      • Need to re-implement refund benefit recovery method, i.e., revokeOneTimeCredits in lib/stripe/webhook-handlers.ts
    • For subscription payments
      • Need to re-implement benefit upgrade method, i.e., upgradeSubscriptionCredits in lib/stripe/webhook-handlers.ts
      • Need to re-implement refund benefit recovery method, i.e., revokeSubscriptionCredits in lib/stripe/webhook-handlers.ts
    • If your product's 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 in actions/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
  • Modify the email notification for subscription renewal failure (optional, default boilerplate is universal), i.e., emails/invoice-payment-failed.tsx