Payments
Currently, ShipAny supports Stripe web payments. Other payment channels will be integrated gradually.
Configuring Stripe Payments
-
Before configuring Stripe payments in ShipAny, make sure you have a Stripe merchant account.
-
Create payment keys in the Stripe Developer Console.
For local debugging, you can enable test mode to obtain a pair of test keys.
- Modify ShipAny project configuration to enable Stripe payments.
Configure environment variables and modify the callback URLs for payment success/failure/cancellation according to your project needs.
STRIPE_PUBLIC_KEY = "pk_test_xxx"
STRIPE_PRIVATE_KEY = "sk_test_xxx"
NEXT_PUBLIC_PAY_SUCCESS_URL = "http://localhost:3000/my-orders"
NEXT_PUBLIC_PAY_FAIL_URL = "http://localhost:3000/#pricing"
NEXT_PUBLIC_PAY_CANCEL_URL = "http://localhost:3000/#pricing"
- Create Orders Table
Before creating the orders table, ensure you have configured database storage and connection information by following the steps in the Database chapter.
Also, make sure you have executed the SQL to create the users
table.
Copy the table creation SQL for the orders
table from the data/install.sql
file and create the orders table in your database.
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
order_no VARCHAR(255) UNIQUE NOT NULL,
created_at timestamptz,
user_uuid VARCHAR(255) NOT NULL DEFAULT '',
user_email VARCHAR(255) NOT NULL DEFAULT '',
amount INT NOT NULL,
interval VARCHAR(50),
expired_at timestamptz,
status VARCHAR(50) NOT NULL,
stripe_session_id VARCHAR(255),
credits INT NOT NULL,
currency VARCHAR(50),
sub_id VARCHAR(255),
sub_interval_count int,
sub_cycle_anchor int,
sub_period_end int,
sub_period_start int,
sub_times int,
product_id VARCHAR(255),
product_name VARCHAR(255),
valid_months int,
order_detail TEXT,
paid_at timestamptz,
paid_email VARCHAR(255),
paid_detail TEXT
);
- Configure Pricing Table
ShipAny includes a built-in pricing table component (Pricing):
components/blocks/pricing/index.tsx
It displays pricing information through configuration data and supports multiple languages by default.
For example, the default English pricing table configuration is located at:
i18n/pages/landing/en.json
You can modify the pricing information under the pricing
field according to your needs.
- Preview Pricing Table
After configuration, open the website homepage to see the configured pricing table.
- Test Payment
Click the order button on the pricing table to jump to the payment console.
For test environments, you can copy a test card number from the Stripe test cards page,
Enter it into the Stripe payment form to test the payment.
- Handle Payment Results
After successful payment, it defaults to redirecting to the /pay-success/xxx
page and processes the payment callback synchronously.
import Stripe from "stripe";
import { handleOrderSession } from "@/services/order";
import { redirect } from "next/navigation";
export const runtime = "edge";
export default async function ({ params }: { params: { session_id: string } }) {
try {
const stripe = new Stripe(process.env.STRIPE_PRIVATE_KEY || "");
const session = await stripe.checkout.sessions.retrieve(params.session_id);
await handleOrderSession(session);
redirect(process.env.NEXT_PUBLIC_PAY_SUCCESS_URL || "/");
} catch (e) {
redirect(process.env.NEXT_PUBLIC_PAY_FAIL_URL || "/");
}
}
After updating the order status, it redirects to the NEXT_PUBLIC_PAY_SUCCESS_URL
address set in the configuration file.
By default, the post-payment processing logic only updates the order status and payment information.
You can modify the logic here to add your own business logic, such as sending emails/notifications/adding points, etc.
import { findOrderByOrderNo, updateOrderStatus } from "@/models/order";
import Stripe from "stripe";
import { getIsoTimestr } from "@/lib/time";
export async function handleOrderSession(session: Stripe.Checkout.Session) {
try {
if (
!session ||
!session.metadata ||
!session.metadata.order_no ||
session.payment_status !== "paid"
) {
throw new Error("invalid session");
}
const order_no = session.metadata.order_no;
const paid_email =
session.customer_details?.email || session.customer_email || "";
const paid_detail = JSON.stringify(session);
const order = await findOrderByOrderNo(order_no);
if (!order || order.status !== "created") {
throw new Error("invalid order");
}
const paid_at = getIsoTimestr();
await updateOrderStatus(order_no, "paid", paid_at, paid_email, paid_detail);
console.log(
"handle order session successed: ",
order_no,
paid_at,
paid_email,
paid_detail
);
} catch (e) {
console.log("handle order session failed: ", e);
throw e;
}
}
Asynchronous Payment Notifications
Synchronous payment result processing is unreliable. Users might accidentally close their browser during the redirect, preventing the order status and payment information update logic from executing. This could lead to disputes when users later see their orders still showing as unpaid.
Before going live, it’s recommended to configure asynchronous payment notifications.
- Configure Webhook in Stripe Dashboard
Reference: Configure Webhook to Receive Stripe Events
For local debugging, listen to events using the stripe CLI:
stripe listen --events checkout.session.completed,invoice.payment_succeeded --forward-to localhost:3000/api/stripe-notify
This forwards Stripe callback events after user payments to the /api/stripe-notify
endpoint of your local ShipAny service.
- Modify Configuration File
After successful local listening, you’ll receive a webhook signing secret
. Add this parameter’s value to your ShipAny project configuration:
STRIPE_WEBHOOK_SECRET = "whsec_cexxx"
- Test Payment Callback
Place an order from the previously configured pricing table and complete payment. The local listening service will receive the callback notification.
- Handle Payment Callback Results
You can modify the default payment callback handling logic according to your needs:
import Stripe from "stripe";
import { handleOrderSession } from "@/services/order";
import { respOk } from "@/lib/resp";
export const runtime = "edge";
export async function POST(req: Request) {
try {
const stripePrivateKey = process.env.STRIPE_PRIVATE_KEY;
const stripeWebhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
if (!stripePrivateKey || !stripeWebhookSecret) {
throw new Error("invalid stripe config");
}
const stripe = new Stripe(stripePrivateKey);
const sign = req.headers.get("stripe-signature") as string;
const body = await req.text();
if (!sign || !body) {
throw new Error("invalid notify data");
}
const event = await stripe.webhooks.constructEventAsync(
body,
sign,
stripeWebhookSecret
);
console.log("stripe notify event: ", event);
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object;
await handleOrderSession(session);
break;
}
default:
console.log("not handle event: ", event.type);
}
return respOk();
} catch (e: any) {
console.log("stripe notify failed: ", e);
return Response.json(
{ error: `handle stripe notify failed: ${e.message}` },
{ status: 500 }
);
}
}
- Before deploying to production, you need to configure the payment callback Webhook in your Stripe production environment.
Payment Customization
- Grouped Pricing Tables
Modify the pricing table configuration file by adding groups
data to configure multiple price groups. Set a group
name for each pricing plan under pricing.items
.
After configuration, clicking on price groups will switch between different pricing plans based on the group
field.
- Subscription Payments
ShipAny supports three payment plans by default:
- One-time charge:
one-time
- Monthly subscription:
month
- Annual subscription:
year
You only need to modify the pricing table configuration by setting the interval
field of each pricing plan to one of the above values.
Also, modify the price (amount) / credits / validity period (valid_months) fields as needed.
Example: For monthly subscription billing at $99/month, with 30 credits and 1-month validity, the core pricing configuration would be:
"interval": "month",
"amount": 9900,
"currency": "USD",
"credits": 30,
"valid_months": 1
- Setting Up Discount Codes
First, create discount codes in the Stripe dashboard.
Then modify the options
parameter in the app/api/checkout/route.ts
file.
- Allow users to input custom discount codes:
options.allow_promotion_codes = true;
- Use system default discount codes:
options.allow_promotion_codes = false;
options.discounts = [
{
coupon: "HAPPY-NEW-YEAR",
},
];
- Disable discounts:
options.allow_promotion_codes = false;
options.discounts = [];
- CNY Payments
First, set up payment methods in the Stripe dashboard.
Enable Alipay and WeChat Pay.
Then, modify the pricing table configuration file by adding a cn_amount
field to each pricing plan to support CNY payments.
For example, for a product priced at $99 with a CNY price of ¥699, the core configuration would be:
"amount": 9900,
"cn_amount": 69900,
"currency": "USD"
After configuration, a CNY payment icon will appear above the order button in the pricing table.
ShipAny may not accommodate all payment scenarios. Please modify according to your business needs:
- Pricing table component:
components/blocks/pricing/index.tsx
- Payment order API:
app/api/checkout/route.ts
- Payment callback logic:
app/api/stripe-notify/route.ts