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 KHImplement: 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 / overdue2. 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, remainderNâ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.