Blog
saassubscriptionvirtual-accountpayment-apib2b

Xây dựng SaaS Subscription tại Việt Nam: Tích hợp thanh toán định kỳ qua ngân hàng

Hướng dẫn implement subscription billing cho SaaS B2B Việt Nam — nơi khách hàng ưa chuyển khoản ngân hàng hơn thẻ. Dùng Virtual Account để tự động hóa gia hạn subscription.

TConnect Team 10 tháng 1, 2026 7 min read

Thực tế SaaS B2B tại Việt Nam

Khi build SaaS B2B cho thị trường Việt Nam, bạn sẽ gặp một thực tế khác với các thị trường phương Tây: kế toán doanh nghiệp không dùng thẻ để trả cước SaaS.

Họ muốn:

  • Chuyển khoản ngân hàng — có sao kê, dễ hạch toán
  • Hóa đơn điện tử VAT để khấu trừ thuế
  • Thanh toán theo invoice gửi qua email hoặc Zalo

Stripe, Paddle hay Chargebee giải quyết tốt cho thị trường Mỹ/EU. Với Việt Nam, bạn cần một approach khác.


Kiến trúc Subscription với Virtual Account

1. Khách đăng ký plan
   └─ Tạo Persistent VA riêng cho khách hàng
 
2. Gửi VA cho khách hàng
   └─ Kèm hướng dẫn qua email / Zalo
 
3. Mỗi kỳ (tháng / quý / năm)
   └─ Khách chuyển khoản vào VA
      → IPN → Hệ thống gia hạn tự động
      → Phát hành hóa đơn điện tử → Gửi cho KH

Implement: Subscription System

1. Data model

// Bảng subscriptions
table Subscription:
    id:                   String (primary key)
    customer_id:          String (foreign key → customers.id)
    plan_id:              String   // "starter", "pro", "enterprise"
    va_number:            String (unique)  // VA của subscription này
    status:               String   // active / suspended / cancelled
    current_period_start: DateTime
    current_period_end:   DateTime
    monthly_amount:       Integer  // VND
    billing_cycle:        String   // "monthly", "quarterly", "annual"
    created_at:           DateTime
 
// Bảng subscription_payments
table SubscriptionPayment:
    id:              String (primary key)
    subscription_id: String (foreign key → subscriptions.id)
    period_start:    DateTime
    period_end:      DateTime
    amount:          Integer
    ipn_request_id:  String (unique)  // Idempotency key
    paid_at:         DateTime (nullable)
    invoice_no:      String (nullable)
    status:          String   // pending / paid / overdue

2. Tạo subscription và VA

function create_subscription(customer, plan):
    // Tạo VA riêng cho subscription này
    va = tconnect.create_va(
        bank_code:    "970454",
        account_name: uppercase(customer.company_name, max_length=20)
    )
 
    // Tạo subscription record
    now = current_utc_time()
    sub = new Subscription(
        id:                   generate_id(),
        customer_id:          customer.id,
        plan_id:              plan.id,
        va_number:            va.va_number,
        status:               "pending_payment",  // Chờ thanh toán tháng đầu
        current_period_start: now,
        current_period_end:   add_period(now, plan.billing_cycle),
        monthly_amount:       plan.price,
        billing_cycle:        plan.billing_cycle
    )
    db.save(sub)
 
    // Gửi thông tin thanh toán cho khách
    send_subscription_welcome(customer, sub, va)
 
    return sub
 
function send_subscription_welcome(customer, sub, va):
    amount = calculate_first_payment(sub)
    email.send(
        to:       customer.billing_email,
        subject:  "[TConnect SaaS] Kích hoạt tài khoản — Thanh toán {format_vnd(amount)}",
        template: "subscription_welcome",
        data: {
            customer_name: customer.name,
            plan_name:     sub.plan_id,
            va_number:     va.va_number,
            bank_name:     "BIDV",
            amount:        amount,
            due_date:      sub.current_period_end,
            account_name:  va.account_name
        }
    )

3. IPN Handler — tim của subscription billing

// IPN endpoint: POST /webhook/tconnect/ipn
function ipn_subscription_handler(request):
    body = parse_json(request)
    txn  = decrypt_aes(body.data, AES_KEY)
 
    if txn.status != "SUCCESS":
        return { status: "ok" }
 
    // Idempotency — bỏ qua nếu đã xử lý
    if cache.get("ipn:{txn.request_id}") exists:
        return { status: "ok" }
 
    // Tìm subscription qua va_number
    sub = db.get_subscription_by_va(txn.va_number)
    if sub not found:
        log warning "IPN for unknown VA: {txn.va_number}"
        return { status: "ok" }
 
    customer = db.get_customer(sub.customer_id)
    expected = calculate_expected_amount(sub)
 
    if txn.amount < expected:
        // Thanh toán thiếu — ghi nhận, yêu cầu bổ sung
        handle_partial_payment(sub, txn)
 
    else if txn.amount >= expected:
        // Gia hạn subscription
        new_end = add_period(sub.current_period_end, sub.billing_cycle)
        db.extend_subscription(sub.id, new_end)
 
        // Phát hành hóa đơn điện tử
        invoice = tconnect.create_invoice(
            build_invoice_data(customer, sub, txn.amount)
        )
 
        // Lưu record thanh toán
        db.save_payment(new SubscriptionPayment(
            subscription_id: sub.id,
            period_start:    sub.current_period_end,
            period_end:      new_end,
            amount:          txn.amount,
            ipn_request_id:  txn.request_id,
            paid_at:         current_utc_time(),
            invoice_no:      invoice.invoice_no,
            status:          "paid"
        ))
 
        // Gửi confirmation + hóa đơn
        send_payment_confirmation(customer, sub, invoice)
 
    cache.set("ipn:{txn.request_id}", ttl=86400)
    return { status: "ok" }

4. Nhắc nhở thanh toán tự động

// Cronjob chạy hàng ngày 9:00 sáng
function send_payment_reminders():
    today    = today()
    in_7days = today + 7 days
    in_3days = today + 3 days
    overdue  = today - 1 day
 
    // Nhắc 7 ngày trước hết hạn
    for each sub in db.get_subscriptions_expiring_on(in_7days):
        send_reminder(sub, days_left=7)
 
    // Nhắc 3 ngày trước hết hạn
    for each sub in db.get_subscriptions_expiring_on(in_3days):
        send_reminder(sub, days_left=3)
 
    // Suspend sau 1 ngày quá hạn
    for each sub in db.get_subscriptions_expired_before(overdue):
        db.suspend_subscription(sub.id)
        notify_subscription_suspended(sub)
 
function send_reminder(sub, days_left):
    customer = db.get_customer(sub.customer_id)
    amount   = calculate_expected_amount(sub)
 
    email.send(
        to:       customer.billing_email,
        subject:  "[TConnect] Subscription hết hạn sau {days_left} ngày",
        template: "payment_reminder",
        data: {
            days_left:   days_left,
            va_number:   sub.va_number,
            bank_name:   "BIDV",
            amount:      format_vnd(amount),
            expiry_date: sub.current_period_end
        }
    )

Xử lý các tình huống đặc biệt

Khách trả trước nhiều tháng

function handle_overpayment(sub, paid_amount):
    monthly     = sub.monthly_amount
    months_paid = floor(paid_amount / monthly)
    remainder   = paid_amount mod monthly
 
    // Gia hạn theo số tháng đã trả
    new_end = add_months(sub.current_period_end, months_paid)
    db.extend_subscription(sub.id, new_end)
 
    // Ghi nhận số dư (nếu có)
    if remainder > 0:
        db.add_credit(sub.customer_id, remainder)
 
    return months_paid, remainder

Nâng/hạ cấp plan

function upgrade_plan(subscription_id, new_plan):
    sub = db.get_subscription(subscription_id)
 
    // Tính phần dư của kỳ hiện tại
    days_remaining = days_between(current_time(), sub.current_period_end)
    days_in_period = 30  // hoặc tính chính xác theo billing cycle
    credit = round(sub.monthly_amount * days_remaining / days_in_period)
 
    // Cập nhật plan và VA amount
    db.update_subscription(subscription_id, {
        plan_id:        new_plan.id,
        monthly_amount: new_plan.price
    })
 
    // Gửi email với VA và số tiền cần bổ sung
    balance_due = new_plan.price - credit
    send_upgrade_invoice(sub, new_plan, credit, balance_due)

Dashboard subscriber

Cung cấp cho khách một trang tự quản lý:

// Trang tự quản lý subscription của khách — SubscriptionPage
render SubscriptionPage(subscription):
 
    section "Thông tin thanh toán":
        display "Số tài khoản ảo (VA): " + subscription.va_number
        display "Ngân hàng: BIDV"
        display "Tên tài khoản: " + subscription.va_account_name
 
    section "Chu kỳ thanh toán":
        display "Kỳ hiện tại: " + formatDate(subscription.current_period_start)
                              + " → " + formatDate(subscription.current_period_end)
        display "Số tiền kỳ tới: " + formatVND(subscription.monthly_amount)
        display "Hạn thanh toán: " + formatDate(subscription.current_period_end)
 
    section "Lịch sử thanh toán":
        for each payment in subscription.payments:
            display formatDate(payment.paid_at)
                  + "  " + formatVND(payment.amount)
                  + "  " + link("Tải hóa đơn", payment.invoice_pdf_url)

Kết luận

Subscription billing cho SaaS B2B Việt Nam không phức tạp khi dùng Persistent VA — mỗi khách hàng có một số tài khoản cố định, chuyển khoản bất kỳ lúc nào và hệ thống tự gia hạn. Pattern này phù hợp với thói quen thanh toán của kế toán doanh nghiệp Việt, đơn giản hơn nhiều so với card subscription, và dễ đối soát hơn chuyển khoản thủ công.

Bắt đầu tích hợp ngay

Sandbox miễn phí · Tài liệu API đầy đủ · Hỗ trợ kỹ thuật