Stripe สำหรับงานจริง: ออกแบบ Payment Flow, Webhook และ Subscription ให้ระบบไม่พัง
หลายทีมเริ่มจากการมอง Stripe ว่าเป็นแค่เครื่องมือรับบัตร แต่พอระบบเริ่มโตขึ้น เรื่องที่ยากจริงมักไม่ได้อยู่ที่ “เก็บเงินได้หรือยัง” แต่อยู่ที่คำถามพวกนี้มากกว่า
- ถ้าผู้ใช้กดจ่ายซ้ำ ระบบจะคิดเงินซ้ำไหม
- ถ้า frontend บอกว่าจ่ายสำเร็จ แต่ backend ยังไม่อัปเดต จะเชื่อฝั่งไหน
- ถ้า webhook มาช้า มาซ้ำ หรือมาผิดลำดับ จะจัดการอย่างไร
- ถ้าต้องคืนเงินบางส่วน จะอิงข้อมูลจาก order ไหน
- ถ้ามี subscription, invoice, trial, failed payment และ retry flow จะออกแบบ state ยังไง
Stripe ช่วยลดภาระเรื่อง payment infrastructure ได้มาก แต่ไม่ได้แปลว่าทีมจะไม่ต้องออกแบบระบบเอง สิ่งที่ยังต้องคิดให้ชัดคือ state ของธุรกิจ, ความสัมพันธ์ระหว่าง order กับ payment, และ วิธีทำให้ระบบเชื่อถือข้อมูลจากแหล่งที่ถูกต้อง
บทความนี้จะอธิบายวิธีใช้ Stripe ในงานจริง โดยเน้นมุมมองเชิงระบบและมีตัวอย่าง Node.js ให้เอาไปปรับใช้ต่อได้
Stripe ควรอยู่ตรงไหนในสถาปัตยกรรมระบบ
ในระบบจริง Stripe ไม่ควรถูกมองเป็นฐานข้อมูลหลักของธุรกิจ แต่เป็น external payment processor ที่ระบบของเราต้องเชื่อมและซิงก์สถานะอย่างมีวินัย
ภาพรวมที่แนะนำคือ:
- ระบบของเราสร้าง
orderหรือcheckout_sessionของตัวเองก่อน - ระบบเรียก Stripe เพื่อสร้าง
PaymentIntentหรือCheckout Session - frontend ใช้ client secret หรือ redirect ไปหน้า checkout
- Stripe ประมวลผลการจ่ายเงิน
- Stripe ส่ง webhook กลับมาที่ backend
- backend ตรวจ signature แล้วอัปเดตสถานะ
payment,order,subscriptionในฐานข้อมูลของเรา
สิ่งสำคัญคือ อย่าให้ frontend เป็นแหล่งตัดสินสุดท้ายว่าเงินสำเร็จแล้ว เพราะ frontend อาจโดนปิดหน้า, เน็ตหลุด, callback ไม่กลับ, หรือผู้ใช้ปลอมผลลัพธ์ได้
สำหรับงานจริง ฝั่งที่ควรตัดสิน final state คือ:
- ข้อมูลจาก Stripe API ที่ backend ดึงมาอย่างถูกต้อง
- webhook event ที่ตรวจ signature แล้วเท่านั้น
ควรใช้ PaymentIntent, Checkout Session หรือ Subscription เมื่อไร
1) PaymentIntent
เหมาะกับระบบที่ควบคุม UI เองมากขึ้น เช่น custom checkout page, mobile app, marketplace flow หรือกรณีที่ต้องจัดการ payment method แบบละเอียด
เหมาะเมื่อ:
- ต้องการ checkout UI ของตัวเอง
- ต้องรองรับ payment confirmation หลายขั้น
- ต้องควบคุม metadata และ order mapping เองแบบละเอียด
2) Checkout Session
เหมาะกับระบบที่อยากเริ่มเร็วและลดภาระเรื่อง payment UI/security
เหมาะเมื่อ:
- ต้องการ hosted checkout ที่ Stripe ดูแลให้
- ไม่อยากพัฒนา form รับบัตรเอง
- ต้องการลดความเสี่ยงและเวลาในการพัฒนา
3) Subscription
เหมาะกับระบบเก็บเงินรายเดือน/รายปี เช่น SaaS, membership, support contract
เหมาะเมื่อ:
- มี recurring billing
- ต้องใช้ invoice, billing cycle, retry, proration, cancellation
- ต้อง track สถานะเช่น
active,past_due,canceled,unpaid
ในหลายระบบจริง จุดเริ่มต้นที่ง่ายและปลอดภัยที่สุดคือ:
- one-time payment →
Checkout Session - recurring SaaS →
Checkout Session+mode: subscription - custom payment flow →
PaymentIntent
โครงสร้างข้อมูลที่ควรมีในฐานข้อมูลของเรา
อย่าพึ่ง Stripe object อย่างเดียว ควรมีตารางหลักอย่างน้อยดังนี้
orders
ใช้เก็บสิ่งที่ผู้ใช้กำลังซื้อจริงในภาษาของธุรกิจ
CREATE TABLE orders (
id UUID PRIMARY KEY,
user_id UUID NOT NULL,
order_number TEXT UNIQUE NOT NULL,
currency TEXT NOT NULL,
amount_subtotal BIGINT NOT NULL,
amount_total BIGINT NOT NULL,
status TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
payments
ใช้เก็บความสัมพันธ์กับ Stripe โดยไม่ปนกับ order domain มากเกินไป
CREATE TABLE payments (
id UUID PRIMARY KEY,
order_id UUID NOT NULL REFERENCES orders(id),
provider TEXT NOT NULL,
provider_payment_intent_id TEXT,
provider_checkout_session_id TEXT,
provider_charge_id TEXT,
currency TEXT NOT NULL,
amount BIGINT NOT NULL,
status TEXT NOT NULL,
last_event_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
webhook_events
ใช้กัน duplicate และใช้ audit/debug ได้ดีมาก
CREATE TABLE webhook_events (
id UUID PRIMARY KEY,
provider TEXT NOT NULL,
provider_event_id TEXT UNIQUE NOT NULL,
event_type TEXT NOT NULL,
processed_at TIMESTAMPTZ,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
subscriptions
ถ้าระบบมี recurring billing ควรแยกตารางชัดเจน
CREATE TABLE subscriptions (
id UUID PRIMARY KEY,
user_id UUID NOT NULL,
plan_code TEXT NOT NULL,
provider_subscription_id TEXT UNIQUE NOT NULL,
provider_customer_id TEXT,
status TEXT NOT NULL,
current_period_start TIMESTAMPTZ,
current_period_end TIMESTAMPTZ,
cancel_at_period_end BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
แนวคิดสำคัญคือ:
order= สิ่งที่ธุรกิจขายpayment= ความพยายามในการเก็บเงินsubscription= ความสัมพันธ์ระยะยาวของแผนบริการwebhook_event= บันทึกเหตุการณ์จาก Stripe
แยกแบบนี้แล้วระบบจะดูแลง่ายกว่าโยนทุกอย่างไปกองในตารางเดียว
การสร้าง Checkout Session แบบใช้งานจริง
ตัวอย่าง Node.js ด้วย Express:
import express from "express";
import Stripe from "stripe";
import { randomUUID } from "node:crypto";
const app = express();
app.use(express.json());
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2025-03-31.basil"
});
app.post("/api/checkout/session", async (req, res) => {
try {
const { userId, items } = req.body;
if (!userId || !Array.isArray(items) || items.length === 0) {
return res.status(400).json({ error: "Invalid payload" });
}
const orderId = randomUUID();
const amountTotal = items.reduce(
(sum: number, item: { unitAmount: number; quantity: number }) =>
sum + item.unitAmount * item.quantity,
0
);
// TODO: persist order in DB with status = pending_payment
const session = await stripe.checkout.sessions.create({
mode: "payment",
line_items: items.map((item: {
name: string;
unitAmount: number;
quantity: number;
}) => ({
price_data: {
currency: "usd",
product_data: {
name: item.name
},
unit_amount: item.unitAmount
},
quantity: item.quantity
})),
success_url: `${process.env.APP_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.APP_URL}/checkout/cancel`,
metadata: {
orderId,
userId
}
});
// TODO: persist payment row with provider_checkout_session_id = session.id
return res.status(201).json({
orderId,
checkoutUrl: session.url
});
} catch (error) {
console.error("Failed to create checkout session", error);
return res.status(500).json({ error: "Internal server error" });
}
});
แนวคิดที่สำคัญในโค้ดนี้:
- สร้าง
orderในระบบเราก่อนคุยกับ Stripe - ใส่
metadata.orderIdเพื่อ map กลับมาได้ตอน webhook - อย่าเชื่อ success page ว่าคือ final success
- ให้ success page เป็นแค่หน้าแสดงผล และให้ backend ตัดสินสถานะจริง
เมื่อไรควรใช้ metadata
metadata มีประโยชน์มากใน Stripe เพราะช่วย map object จาก Stripe กลับมาที่ domain ของเราได้ เช่น
orderIduserIdplanCodeenvironment
ตัวอย่าง:
const paymentIntent = await stripe.paymentIntents.create({
amount: 50000,
currency: "thb",
automatic_payment_methods: { enabled: true },
metadata: {
orderId: "ord_20260417_001",
userId: "user_123",
source: "web_checkout"
}
});
แต่ไม่ควรใช้ metadata เป็นที่เก็บข้อมูลสำคัญทั้งหมด เพราะมันไม่ใช่ฐานข้อมูลหลักของธุรกิจ ควรใช้เพื่ออ้างอิง ไม่ใช่แทน persistence ในระบบเรา
อย่าพึ่งพา success page เพื่อยืนยันการจ่ายเงิน
หลายระบบพังตรงนี้ เพราะทำ flow แบบนี้:
- ผู้ใช้จ่ายเงิน
- browser redirect กลับ
/success - frontend เรียก backend ว่า “สำเร็จแล้ว”
- backend mark order paid ทันที
ปัญหาคือ redirect สำเร็จไม่ได้แปลว่าเงิน settle แล้วจริงเสมอไป และบาง payment method อาจเป็น asynchronous confirmation
flow ที่ปลอดภัยกว่าคือ:
- redirect กลับ success page ได้
- success page แสดงว่า “ระบบกำลังตรวจสอบการชำระเงิน”
- backend รับ webhook เช่น
checkout.session.completedหรือpayment_intent.succeeded - backend ตรวจสอบ event และอัปเดตฐานข้อมูล
- frontend polling หรือ fetch order status จาก backend
ตัวอย่างหน้า success ฝั่ง frontend:
async function loadOrderStatus(orderId: string) {
const response = await fetch(`/api/orders/${orderId}`);
const order = await response.json();
if (order.status === "paid") {
renderPaidState(order);
return;
}
renderPendingVerificationState(order);
}
แนวคิดนี้ช่วยลด race condition ระหว่าง redirect, webhook และการอัปเดตฐานข้อมูล
Webhook คือหัวใจของระบบจ่ายเงินที่เชื่อถือได้
Stripe webhook เป็นกลไกที่บอก backend ว่าเกิดอะไรขึ้นจริง เช่น
payment_intent.succeededpayment_intent.payment_failedcheckout.session.completedcharge.refundedinvoice.paidinvoice.payment_failedcustomer.subscription.updatedcustomer.subscription.deleted
สิ่งที่ต้องทำเสมอ:
- รับ raw body
- ตรวจ signature ด้วย webhook secret
- บันทึก event id กันซ้ำ
- process แบบ idempotent
- อัปเดต state ในฐานข้อมูลอย่างระวัง
ตัวอย่าง Express webhook handler:
import express from "express";
import Stripe from "stripe";
const app = express();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2025-03-31.basil"
});
app.post(
"/api/stripe/webhook",
express.raw({ type: "application/json" }),
async (req, res) => {
const signature = req.headers["stripe-signature"];
if (!signature) {
return res.status(400).send("Missing stripe-signature header");
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
req.body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (error) {
console.error("Invalid webhook signature", error);
return res.status(400).send("Invalid signature");
}
try {
const alreadyProcessed = await hasProcessedEvent(event.id);
if (alreadyProcessed) {
return res.status(200).json({ received: true, duplicate: true });
}
await saveIncomingWebhookEvent(event);
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
await handleCheckoutCompleted(session, event.id);
break;
}
case "payment_intent.succeeded": {
const paymentIntent = event.data.object as Stripe.PaymentIntent;
await handlePaymentIntentSucceeded(paymentIntent, event.id);
break;
}
case "invoice.paid": {
const invoice = event.data.object as Stripe.Invoice;
await handleInvoicePaid(invoice, event.id);
break;
}
case "customer.subscription.updated": {
const subscription = event.data.object as Stripe.Subscription;
await handleSubscriptionUpdated(subscription, event.id);
break;
}
default:
console.log(`Unhandled event type: ${event.type}`);
}
await markWebhookEventProcessed(event.id);
return res.status(200).json({ received: true });
} catch (error) {
console.error("Webhook processing failed", error);
return res.status(500).send("Webhook processing failed");
}
}
);
จุดสำคัญมาก:
- route นี้ต้องใช้
express.raw()ไม่ใช่express.json() - ต้องตรวจ signature ก่อน parse เอง
- ต้องกัน event ซ้ำด้วย
event.id - handler ทุกตัวควร idempotent
ทำ idempotency ให้ครบทั้งขาออกและขาเข้า
คำว่า idempotency มีสองมิติที่ทีมชอบลืม
1) ขาออก: ตอนเรียก Stripe API
ถ้า request timeout แล้วระบบ retry คำสั่งเดิม อาจเสี่ยงสร้าง payment ซ้ำ ควรส่ง Idempotency-Key
ตัวอย่าง:
const orderId = "ord_20260417_001";
const paymentIntent = await stripe.paymentIntents.create(
{
amount: 89000,
currency: "thb",
automatic_payment_methods: { enabled: true },
metadata: { orderId }
},
{
idempotencyKey: `payment-intent:${orderId}`
}
);
2) ขาเข้า: ตอนรับ webhook
Stripe อาจส่ง event ซ้ำได้ หรือระบบเราอาจ process ซ้ำเพราะ retry ต้องเก็บ event.id และเช็คก่อนเสมอ
ตัวอย่าง pseudo code:
async function hasProcessedEvent(eventId: string): Promise<boolean> {
const row = await db.webhookEvents.findUnique({
where: { providerEventId: eventId }
});
return Boolean(row?.processedAt);
}
ระบบที่ไม่ทำ idempotency จะมีอาการพังแบบคลาสสิก เช่น
- order ถูก mark paid ซ้ำ
- ส่งอีเมลใบเสร็จซ้ำ
- เติมเครดิตซ้ำ
- เปิดสิทธิ์ใช้งานซ้ำ
ตัวอย่าง handler ที่ update order แบบปลอดภัยกว่า
async function handlePaymentIntentSucceeded(
paymentIntent: Stripe.PaymentIntent,
eventId: string
) {
const orderId = paymentIntent.metadata.orderId;
if (!orderId) {
throw new Error("Missing orderId in payment intent metadata");
}
const order = await findOrderById(orderId);
if (!order) {
throw new Error(`Order not found for ${orderId}`);
}
if (order.status === "paid") {
return;
}
await db.transaction(async (tx) => {
await tx.payments.update({
where: { orderId },
data: {
status: "succeeded",
providerPaymentIntentId: paymentIntent.id,
lastEventId: eventId
}
});
await tx.orders.update({
where: { id: orderId },
data: {
status: "paid"
}
});
});
}
สิ่งที่ควรสังเกต:
- หา order จาก metadata หรือ mapping ที่ไว้ใจได้
- เช็คก่อนว่า order paid ไปแล้วหรือยัง
- อัปเดต order และ payment ใน transaction เดียว
- ไม่พึ่งค่าจาก frontend
Refund ควรออกแบบเป็น flow แยก ไม่ใช่กดแล้วจบ
ในธุรกิจจริง การคืนเงินไม่ใช่แค่เรียก API แล้วถือว่าจบ ควรมีสถานะของตัวเอง เช่น
refund_requestedrefund_processingrefundedrefund_failedpartially_refunded
ตัวอย่างสร้าง refund:
app.post("/api/payments/:paymentId/refund", async (req, res) => {
try {
const { paymentId } = req.params;
const { amount } = req.body;
const payment = await findPaymentById(paymentId);
if (!payment || !payment.providerChargeId) {
return res.status(404).json({ error: "Payment not found" });
}
const refund = await stripe.refunds.create({
charge: payment.providerChargeId,
amount
});
await saveRefundRecord({
paymentId,
providerRefundId: refund.id,
amount: refund.amount,
status: refund.status ?? "pending"
});
return res.status(201).json({ refundId: refund.id, status: refund.status });
} catch (error) {
console.error("Refund failed", error);
return res.status(500).json({ error: "Refund failed" });
}
});
สิ่งสำคัญคือระบบเราควร track refund แยกเอง และฟัง webhook ที่เกี่ยวข้องเพื่อ sync สถานะจริง
Subscription ไม่ใช่แค่ create แล้วจบ
ระบบ subscription มักซับซ้อนกว่าที่คิด เพราะมีทั้ง
- trial
- initial checkout
- recurring invoice
- payment failure
- smart retry
- downgrade / upgrade
- cancel at period end
- immediate cancellation
- proration
ถ้าเป็น SaaS แนะนำให้คิด state ของ user access แยกจาก Stripe status ตรง ๆ เช่น
trialingactivegrace_periodsuspendedcanceled
และกำหนดกติกาธุรกิจเองว่าผู้ใช้เข้าถึงระบบได้เมื่อไร
ตัวอย่างสร้าง subscription ผ่าน Checkout Session:
app.post("/api/billing/checkout", async (req, res) => {
try {
const { userId, priceId } = req.body;
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [
{
price: priceId,
quantity: 1
}
],
success_url: `${process.env.APP_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.APP_URL}/billing/cancel`,
metadata: {
userId
}
});
return res.status(201).json({ checkoutUrl: session.url });
} catch (error) {
console.error("Failed to create billing checkout", error);
return res.status(500).json({ error: "Internal server error" });
}
});
จากนั้น webhook จะเป็นตัว sync สถานะ เช่น invoice.paid, invoice.payment_failed, customer.subscription.updated
ตัวอย่าง sync subscription จาก webhook
async function handleSubscriptionUpdated(
subscription: Stripe.Subscription,
eventId: string
) {
const userId = subscription.metadata?.userId;
await db.subscriptions.upsert({
where: {
providerSubscriptionId: subscription.id
},
create: {
id: crypto.randomUUID(),
userId: userId ?? "unknown",
planCode: String(subscription.items.data[0]?.price?.id ?? "unknown"),
providerSubscriptionId: subscription.id,
providerCustomerId: String(subscription.customer),
status: subscription.status,
currentPeriodStart: new Date(subscription.items.data[0]?.current_period_start * 1000),
currentPeriodEnd: new Date(subscription.items.data[0]?.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end
},
update: {
status: subscription.status,
providerCustomerId: String(subscription.customer),
currentPeriodStart: new Date(subscription.items.data[0]?.current_period_start * 1000),
currentPeriodEnd: new Date(subscription.items.data[0]?.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
updatedAt: new Date()
}
});
await saveProcessedEventReference(eventId, subscription.id);
}
ในงานจริงควรมี validation เพิ่ม เช่น
- subscription นี้ map กับ user คนไหนแน่
- ถ้า metadata ไม่มี จะ lookup จาก customer id ได้หรือไม่
- ถ้า event มาก่อนข้อมูล create ภายในระบบ จะรอ queue/retry อย่างไร
เรื่องที่ทีมมักพลาดเวลาเชื่อม Stripe
1) ใช้จำนวนเงินแบบทศนิยมลอยตัว
อย่าเก็บหรือส่ง 89.99 แบบ float ใน logic กลาง ควรใช้หน่วยเล็กสุด เช่น satang หรือ cents
ตัวอย่าง:
const amountInCents = 8999;
2) ใช้ frontend เป็นตัวตัดสิน final payment state
frontend ใช้แสดงผลได้ แต่ไม่ควรเป็น authority ของสถานะเงินจริง
3) ไม่เก็บ event log
พอมีปัญหาแล้วไล่ย้อนหลังไม่ได้ว่า webhook ไหนเข้าเมื่อไร
4) ไม่ทำ idempotency
เสี่ยงเงินซ้ำ, สิทธิ์ซ้ำ, email ซ้ำ
5) update หลายตารางแบบไม่อยู่ใน transaction
ถ้ากลางทางพัง จะเกิด state ครึ่งสำเร็จครึ่งล้มเหลว
6) ไม่แยก sandbox กับ production ให้ชัด
ควรแยก key, webhook secret, database records, logging และ environment labels
7) ไม่ทำ reconciliation
บางระบบควรมี job ตรวจเทียบข้อมูลระหว่าง Stripe กับฐานข้อมูลในระบบ เช่น order ที่ค้าง pending นานผิดปกติ หรือ payment ที่ Stripe success แต่ใน DB ยังไม่ paid
ควรมี background reconciliation job ด้วย
ถึง webhook จะเป็นกลไกหลัก แต่ระบบจริงควรมี job ตรวจทานย้อนหลัง เช่นทุก 10 นาทีหรือทุกชั่วโมง
ตัวอย่าง logic:
- หา payment ที่สถานะ
pendingนานเกิน threshold - ดึงข้อมูลจาก Stripe API
- ถ้า Stripe success แต่ DB ยังไม่อัปเดต ให้ซ่อม state
- บันทึก audit log ว่ามีการ reconcile
ตัวอย่าง pseudo code:
async function reconcilePendingPayments() {
const pendingPayments = await findOldPendingPayments();
for (const payment of pendingPayments) {
if (!payment.providerPaymentIntentId) {
continue;
}
const paymentIntent = await stripe.paymentIntents.retrieve(
payment.providerPaymentIntentId
);
if (paymentIntent.status === "succeeded") {
await markPaymentRecovered(payment.id, paymentIntent.id);
}
}
}
job นี้ช่วยอุดช่องว่างจาก webhook lost, internal processing failure หรือ bug บางช่วงเวลาได้ดีมาก
แนวทางแยก responsibility ระหว่าง frontend กับ backend
frontend ควรรับผิดชอบ
- เริ่ม checkout
- แสดง loading / success / pending verification / failure
- แสดงใบเสร็จหรือสถานะหลัง backend ยืนยันแล้ว
- redirect ผู้ใช้ไปยัง flow ที่เหมาะสม
backend ควรรับผิดชอบ
- สร้าง order และ payment record
- เรียก Stripe API ด้วย secret key
- ตรวจ webhook signature
- update state ธุรกิจ
- จัดการ refund, subscription, invoice sync
- audit, logging, reconciliation
ถ้าแบ่งแบบนี้ชัด ระบบจะดูแลง่ายกว่าและลดปัญหา logic ไปกระจุกใน frontend
ตัวอย่างโครงสร้างไฟล์ในโปรเจกต์ Node.js
src/
config/
env.ts
modules/
billing/
billing.routes.ts
billing.service.ts
billing.repository.ts
stripe.client.ts
stripe.webhook.ts
subscription.service.ts
refund.service.ts
shared/
db.ts
logger.ts
errors.ts
การแยกแบบนี้ช่วยให้ทีมไม่เอา logic ทุกอย่างไปกองไว้ใน route file เดียว
Checklist ก่อนปล่อย Stripe ขึ้น production
Configuration
- มี secret key แยก dev/staging/prod
- มี webhook secret แยกแต่ละ environment
- จำกัดสิทธิ์ของ environment variables ชัดเจน
Data model
- มี order/payment/subscription/refund แยกชัด
- มี webhook event table
- มี transaction ในจุดสำคัญ
Reliability
- มี idempotency key ตอนสร้าง payment
- มี duplicate protection ตอนรับ webhook
- มี retry strategy และ reconciliation job
Security
- ตรวจ webhook signature เสมอ
- ไม่ log ข้อมูลอ่อนไหวเกินจำเป็น
- ไม่ expose secret key ไปฝั่ง client
Operations
- มี structured logs
- มี alert เมื่อ webhook fail ติดต่อกัน
- มี dashboard ดู payment status และ refund status
บทสรุป
Stripe ทำให้การรับเงินออนไลน์ง่ายขึ้นมาก แต่ระบบจ่ายเงินที่ดีไม่ได้เกิดจากการเรียก SDK สำเร็จเพียงครั้งเดียว มันเกิดจากการออกแบบ flow ให้รองรับความจริงของระบบ distributed ที่ทุกอย่างอาจมาช้า ซ้ำ หลุด หรือผิดลำดับได้
หลักสำคัญที่ควรจำมีไม่กี่ข้อ:
- ให้ backend เป็นแหล่งตัดสินสถานะเงินจริง
- ใช้ webhook เป็นกลไกหลักในการ sync state
- ทำ idempotency ทั้งฝั่ง request และ webhook
- แยก order, payment, subscription, refund ให้ชัด
- มี reconciliation job สำหรับซ่อม state
- อย่าให้ logic payment สำคัญไปค้างอยู่ใน frontend
ถ้าวางโครงสร้างพวกนี้ตั้งแต่ต้น การขยายจาก one-time payment ไปสู่ refund, recurring billing, invoice และ subscription lifecycle จะง่ายขึ้นมาก และทีมจะ debug ปัญหาใน production ได้อย่างมีสติ ไม่ต้องไล่หาว่าเงินหายหรือสถานะเพี้ยนเพราะ callback ตัวไหนหลุดไป