DDevArchive
Đăng nhập

Tích hợp thanh toán: Stripe & VNPay — từ checkout đến webhook

Mọi sản phẩm cần thu tiền. Bài này dạy end-to-end: tạo checkout session, xử lý webhook (payment confirmed), idempotency (không charge 2 lần), refund flow, và subscription recurring. Code thật, chạy thật.

Stripe Checkout: thu tiền trong 30 phút

// File: stripe.ts
import Stripe from 'stripe'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)

// Tạo checkout session — redirect user đến trang thanh toán Stripe
async function createCheckout(req: Request, res: Response) {
  const session = await stripe.checkout.sessions.create({
    mode: 'subscription', // hoặc 'payment' cho one-time
    line_items: [{
      price: 'price_pro_monthly', // price ID từ Stripe Dashboard
      quantity: 1,
    }],
    customer_email: req.user.email,
    metadata: { userId: req.user.id }, // gắn user ID để track
    success_url: `${BASE_URL}/payment/success?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${BASE_URL}/pricing`,
  })

  res.json({ url: session.url })
}

Webhook: khi nào Stripe nói “tiền đã vào”

// File: webhook.ts
// Stripe gửi webhook khi payment thành công
// QUAN TRỌNG: verify signature để chống fake webhook
async function handleWebhook(req: Request, res: Response) {
  const sig = req.headers["stripe-signature"]
  let event: Stripe.Event

  try {
    event = stripe.webhooks.constructEvent(
      req.body, sig, process.env.STRIPE_WEBHOOK_SECRET
    )
  } catch (err) {
    return res.status(400).json({ error: "Invalid signature" })
  }

  // Xử lý theo event type
  switch (event.type) {
    case "checkout.session.completed": {
      const session = event.data.object
      const userId = session.metadata.userId

      // Idempotency: check đã xử lý chưa
      const exists = await db.payments.findByStripeId(session.id)
      if (exists) break // đã xử lý rồi → skip

      await db.users.update(userId, { plan: "pro" })
      await db.payments.create({
        stripeSessionId: session.id,
        userId, amount: session.amount_total,
      })
      break
    }
    case "customer.subscription.deleted": {
      // User huỷ subscription → downgrade
      const sub = event.data.object
      await db.users.update(sub.metadata.userId, { plan: "free" })
      break
    }
  }

  res.json({ received: true })
}
Idempotency là BẮT BUỘC

Stripe có thể gửi cùng webhook NHIỀU LẦN (retry). Nếu bạn không check idempotency → charge user 2 lần, tạo 2 account, v.v. Luôn check “đã xử lý event này chưa” trước khi thực hiện.

VNPay / MoMo cho thị trường Việt Nam

Stripe chưa hỗ trợ đầy đủ tại VN. Dùng VNPay (phổ biến nhất) hoặc MoMo cho thanh toán nội địa. Flow tương tự: tạo URL → redirect user → nhận IPN (webhook).

❓ Vì sao cần idempotency khi xử lý webhook thanh toán?

  • Tạo Stripe Checkout session
  • Xử lý webhook với signature verification
  • Implement idempotency check
  • Xử lý subscription cancellation / downgrade
  • Biết flow VNPay/MoMo cho thị trường VN