Khi bắt đầu xây dựng OpenWallet, câu hỏi đầu tiên của mình không còn là "dùng framework nào" mà là "làm sao lưu thông tin một cái thẻ tín dụng cho đúng." Nghe đơn giản, nhưng mất nhiều vòng iterate mới ra được schema hiện tại. Bài này kể lại hành trình đó, từ điểm xuất phát đến kiến trúc đang chạy hôm nay.
Điểm xuất phát: tich.vn và WordPress
Trước OpenWallet là tich.vn, một dự án so sánh thẻ tín dụng xây trên WordPress. Data thẻ lưu trong SQL database của WP thông qua ACF (Advanced Custom Fields) và Custom Post Type. WPGraphQL đứng ở giữa để Next.js có thể query dữ liệu qua GraphQL. Lúc đó WPGraphQL trông khá fancy (ít nhất là với mình), và đó cũng là lý do chính để chọn nó, nhưng nhìn lại thì đó là đặt product vào stack mình muốn chứ không phải chọn stack phù hợp với product.
Cấu trúc lúc đó:
- Mỗi thẻ là một CPT (
cpt_card) trong WordPress, data lưu trong MySQL thông qua ACF - Network và tier encode chung trong một taxonomy slug:
visa-signature,mastercard-platinum - Bank, loại thẻ, số ngày miễn lãi đều là taxonomy term
Khi export ra JSON để Next.js dùng, có một đoạn PHP như thế này:
$net_slug = get_the_terms($post_id, 'tax-network')[0]->slug; // "visa-signature"
$net_parts = explode('-', $net_slug, 2);
$card_network = $net_parts[0]; // "visa"
$card_tier = $net_parts[1]; // "signature"
Trông ổn cho đến khi gặp American Express. Slug "american-express-platinum" → network = "american", tier = "express-platinum". Fix bằng một dòng hardcode:
$card_network = $raw_network === 'american' ? 'amex' : $raw_network;
Đây là dấu hiệu đầu tiên: data model đang bị nhồi vào WP taxonomy thay vì được thiết kế từ đầu cho mục đích cụ thể.
Cashback lúc đó nằm trong ACF fields của một CPT riêng (cpt_reward), liên kết với thẻ qua GraphQL resolver. MCC code lưu dưới dạng string "3000, 3001, 3002" trong một ACF field, rồi query bằng meta_query REGEXP để tìm reward nào chứa mã MCC đó.
JSON xuất ra từ mỗi thẻ trông như thế này:
{
"id": "vib-cash-back",
"name": "VIB Cash Back",
"bank_id": "vib",
"card_network": "mastercard",
"card_tier": "platinum",
"card_type": ["credit"],
"annual_fee": 899000,
"currency": "VND",
"interest_free_days": 55,
"status": "published"
}
Phần cashback không có trong JSON này. Nó nằm ở WPGraphQL, cần query riêng. Mỗi lần cập nhật một thẻ: sửa trong WP admin, click "Generate All JSONs", commit file, deploy. Nhiều step và rất thủ công.
Tại sao tich.vn chết
Không phải vì WordPress tệ. Mà vì cấu trúc này không thể scale khi số lượng thẻ tăng và cashback rules trở nên phức tạp hơn. Mỗi lần thêm loại rule mới cần sửa ACF field group, sửa GraphQL resolver, sửa Next.js query, deploy cả WP lẫn frontend. Bốn điểm thay đổi cho một feature. Khi API không theo kịp tốc độ cần thiết để thêm thẻ mới và model cashback đúng, dự án bị đình lại.
Sau đó mình chuyển sang các dự án Next.js khác, tích lũy thêm kinh nghiệm, rồi quay lại xây OpenWallet từ đầu với kiến trúc hoàn toàn khác. Tháng 6/2025, tich.vn chính thức deprecated.
Vấn đề của WPGraphQL
WordPress là tool đúng cho blog hoặc commercial website. Nhưng nó lại là tool sai cho data API với business logic phức tạp. Mặc dù bản thân Automattic cũng cố gắng tích hợp RESTful API, GraphQL API vào Wordpress nhưng thật sự vẫn chỉ là những chắp vá ở phần ngọn.
WPGraphQL thêm overhead cả về runtime lẫn mental model. Taxonomy term không phải schema, nó là label. Khi cần validate data, không có contract nào cả, không thể chạy test, bất kỳ field nào cũng có thể có bất kỳ shape nào. GraphQL tốt khi client cần chọn field linh hoạt theo từng query. Nhưng với card comparison, ranking, và cashback calculation là các computed operation phức tạp, REST endpoint với typed response rõ ràng và dễ cache hơn nhiều.
Quyết định rebuild: JSON files thay vì SQL, REST thay vì GraphQL
Tại sao bỏ relational database như SQL?
Card data không có transaction. Không có concurrent write. Không có user session. "Relational" duy nhất thật sự cần là hai quan hệ: bank_id trỏ đến bank, card_network trỏ đến network entity. Hai foreign key này xử lý được bằng enrichment tại generate time, kết quả bundle vào JSON trước khi deploy.
SQL có nghĩa là server, connection pool, migration files, backup strategy. Với dữ liệu của 23 ngân hàng và 321 thẻ, đây là overhead không cần thiết.
JSON files trong git làm được tất cả: mỗi commit là một migration, git history là audit trail, diff đọc được bằng mắt thường.
data/cards/
vib/
vib-family-link/
vib-family-link.json ← toàn bộ data của một thẻ, kể cả cashback rules
kbank/
kbank-cashback-plus/
kbank-cashback-plus.json
JSON database và DX với coding assistant
Đây là điểm ít nói đến nhưng thực tế ảnh hưởng lớn nhất đến cách làm việc hàng ngày của dev: toàn bộ API server chạy được hoàn toàn local, kể cả database.
Không cần kết nối đến server nào. Không cần seed script. Không cần Docker compose. Clone repo, pnpm install, chạy được ngay.
Điều này có nghĩa là Claude, khi được giao task cập nhật data của một thẻ, có thể đọc file JSON trực tiếp, chỉnh sửa, validate ngay bằng Zod schema trong cùng repo, rồi commit. Không có bước nào cần gọi API bên ngoài, không có bước nào cần auth, không có delay do network.
Pipeline cập nhật thẻ trông như thế này:
Scrape URL/PDF → clean text → LLM extract → Zod validate → write JSON → git commit
Mỗi bước là file trung gian được lưu lại. LLM đọc được, sửa được, trace được từng bước.
Đây là lý do cốt lõi mình giữ JSON files làm database: best DX cho coding assistant. Khi tool hiểu được cả schema lẫn data trong cùng một repo, iteration nhanh hơn hẳn.
Cloudflare Workers cho API, Vercel cho web
Khi chọn hosting, câu hỏi không phải "platform nào tốt hơn" mà là "platform nào phù hợp nhất cho từng phần, với cost thấp nhất."
API server chạy trên Cloudflare Workers. Lý do đầu tiên rất đơn giản thôi: Cloudflare cho phép deploy từ private GitHub organization repository ở plan miễn phí. Vercel chỉ cho phép private personal repo. OpenWallet API vẫn đang là một org private repo, nên đây là yếu tố quyết định ngay từ đầu.
Ngoài ra Cloudflare Workers phù hợp về mặt kỹ thuật:
- Edge runtime: response được phục vụ từ PoP gần người dùng nhất thay vì một server duy nhất ở một region
- Near-zero cold start: code chạy trên V8 isolates thay vì container hoặc serverless function truyền thống, giúp giảm đáng kể startup latency
- Bundle size limit (3 MiB mỗi Worker bundle) tạo động lực chia nhỏ dữ liệu theo entity type. Kết quả là mỗi bundle chỉ tải phần dữ liệu cần thiết
wrangler deployđơn giản: gần tương đươnggit push, không cần quản lý server hay infrastructure riêng
Web (Next.js) được deploy trên Vercel vì lý do kỹ thuật cụ thể. ISR (Incremental Static Regeneration) được thiết kế và tối ưu trước tiên cho môi trường runtime của Next.js trên Vercel. Mặc dù Cloudflare Workers hiện hỗ trợ nhiều Node.js APIs và có thể chạy Next.js, mô hình runtime của Workers khác với môi trường Node.js truyền thống nên khả năng hỗ trợ ISR không hoàn toàn tương đương. Đây không phải vấn đề nền tảng nào tốt hơn, mà là mỗi platform phù hợp với workload khác nhau.
Nguyên tắc chung: tối ưu cost trước, performance sau, không lock-in vào bất kỳ vendor nào. API build trên Hono, có thể chuyển sang bất kỳ edge runtime nào. Web build trên Next.js, có thể chuyển sang bất kỳ platform nào hỗ trợ Node.js.
Schema evolution: cashback là phần khó nhất
Phần còn lại của bài này đi vào chi tiết kỹ thuật: schema cashback đã thay đổi như thế nào qua từng lần gặp thẻ thật không fit vào model cũ.
Điểm khởi đầu: flat và đơn giản
Schema đầu tiên cho fees rất phẳng:
{
"fees": {
"annual": { "amount": 899000, "type": "currency" },
"foreign": { "amount": 2.95, "type": "rate" }
}
}
type: "currency" vs type: "rate" để phân biệt phí VND và phí phần trăm. Đơn giản, đủ dùng cho 80% thẻ.
Cashback cũng flat ban đầu: rate, cap, categories[].
"Miễn phí năm đầu": pattern đầu tiên cần structured field
Hầu hết ngân hàng VN đều có waiver năm đầu với điều kiện chi tiêu. Không thể bỏ vào note vì cần hiển thị có cấu trúc trên UI. Kết quả:
{
"fees": {
"annual": {
"amount": 899000,
"type": "currency",
"first_year": {
"waiver": true,
"condition": "Miễn phí năm đầu khi chi tiêu tối thiểu 3 triệu trong 3 tháng đầu"
}
}
}
}
Rule: giữ text tiếng Việt nguyên vẹn trong condition. Không rút gọn, không dịch. Đây là nguồn truth từ bank.
"all" category slug: catch-all rule
Thẻ hoàn tiền đơn giản nhất vẫn cần rule để tính cashback cho mọi giao dịch. Ban đầu dùng categories: [] (mảng rỗng) nghĩa là áp dụng cho tất cả, nhưng mảng rỗng và thiếu data trông giống nhau. Cần sentinel rõ ràng:
{ "rate": 0.005, "intents": ["all"] }
"all" là slug đặc biệt: "áp dụng cho mọi giao dịch." Rule match theo thứ tự: rule cụ thể trước, catch-all cuối. First matching rule wins.
package_exclusive: LPBank JCB Ultimate
Thẻ này cho khách hàng chọn một trong hai gói khi phát hành thẻ, và lựa chọn đó là vĩnh viễn:
- Gói Chăm sóc toàn diện: 15% cho bảo hiểm, y tế, giáo dục
- Gói Trải nghiệm tinh hoa: 10% cho ăn uống, du lịch
Không thể dùng max_active_rules vì đó là "chọn lại mỗi tháng." Đây là chọn một lần khi phát hành. Schema:
{
"cashback": {
"package_exclusive": true,
"rules": [
{
"rate": 0.15,
"cap": { "amount": 800000 },
"intents": ["insurance", "health", "education"],
"note": "Gói Chăm sóc toàn diện - chọn khi phát hành thẻ"
},
{
"rate": 0.10,
"cap": { "amount": 800000 },
"intents": ["dining", "travel"],
"note": "Gói Trải nghiệm tinh hoa - chọn khi phát hành thẻ"
}
]
}
}
Engine đọc package_exclusive: true → đánh giá từng rule độc lập → trả về rule có cashback cao nhất. Không cộng, không cộng dồn.
tiers[]: VIB Family Link
Thẻ này có rate phụ thuộc vào tổng chi tiêu kỳ trước:
- Chi tiêu kỳ trước dưới 50 triệu: hoàn 5%
- Từ 50 triệu: hoàn 8%
- Từ 100 triệu: hoàn 10%
Flat rate không đủ. Cần tiers[]:
{
"rate": 0.05,
"rate_max": 0.10,
"tiers": [
{ "min_spend": 0, "rate": 0.05 },
{ "min_spend": 50000000, "rate": 0.08 },
{ "min_spend": 100000000, "rate": 0.10 }
],
"intents": ["education", "health", "insurance"]
}
rate và rate_max là display-only summary. tiers[] là nguồn truth cho engine tính toán.
scope: Sacombank AMEX, Eximbank JCB
Một số thẻ có rule chỉ áp dụng cho giao dịch nước ngoài, hoặc chỉ khi offline. Ban đầu dùng category string như foreign_offline, foreign_online. Vấn đề: đây không phải category chi tiêu, đây là dimension của giao dịch.
foreign_offline là tổ hợp của hai trục độc lập: channel=offline và geography=foreign. Khi Eximbank JCB có rule riêng cho giao dịch tại Nhật Bản cụ thể, không có category string nào fit được. Giải pháp:
{
"scope": {
"channel": "offline",
"geography": "foreign"
}
}
Hoặc cụ thể hơn:
{
"scope": {
"geography": "JP"
}
}
geography nhận "domestic", "foreign", hoặc ISO 3166-1 alpha-2 country code. Orthogonal model: channel và geography là hai trục độc lập, kết hợp tùy ý.
Tất cả card dùng foreign_offline và foreign_online được migrate sang syntax mới. Các deprecated string bị xóa khỏi schema.
max_active_rules: MSB mDigi
Thẻ này cho chọn một danh mục mỗi tháng, danh mục nào cũng hoàn 20%:
{
"cashback": {
"max_active_rules": 1,
"rules": [
{ "rate": 0.20, "cap": { "amount": 300000 }, "intents": ["dining"] },
{ "rate": 0.20, "cap": { "amount": 300000 }, "intents": ["travel"] },
{ "rate": 0.20, "cap": { "amount": 300000 }, "intents": ["digital"] }
],
"global_cap": { "amount": 300000 }
}
}
max_active_rules: 1 nghĩa là engine chọn N rule có cashback cao nhất, không phải tất cả cùng lúc. Khác với package_exclusive: người dùng chọn lại mỗi tháng, không phải một lần vĩnh viễn.
categories → intents: rename khi model trưởng thành
Sau khi xây intent model (data/intents.json), mỗi intent là một spending behavior với slug chuẩn như dining, travel, digital. Nhưng field trong cashback rule vẫn đang gọi là categories. Hai tên khác nhau cho cùng namespace gây nhầm lẫn.
Rename categories → intents trên toàn bộ schema và data. Breaking change, nhưng đúng về semantic. cashback-categories entity bị drop hoàn toàn, superseded bởi intents.
Structured vs deferred: nguyên tắc thiết kế quan trọng nhất
Trong suốt quá trình này, một số field được thêm vào rồi bị remove, hoặc bị comment là // deferred:
channels[]vàexcluded_channels[]→ thêm vào rồi remove, đưa vàonoteperiodtrên cap (month/quarter/year) → deferred, thực tế luôn là per-statement-periodmin_transaction→ remove, ngân hàng VN hầu như không document field nàyexcluded_categories→ deferred vàonote
Nguyên tắc: tốt hơn là deferred với note free text còn hơn là force structure không phản ánh thực tế. note giữ text tiếng Việt nguyên vẹn từ nguồn bank, không rút gọn, không translate. Field chỉ được promote lên structured khi gặp đủ nhiều thẻ thật cần nó và cần tính toán programmatic từ nó.
Một số convention khác
Decimal rates, VND integers. 10% lưu là 0.10, không phải 10. 300 nghìn lưu là 300000, không phải "300k". Nhất quán tuyệt đối, không có ngoại lệ.
amount: -1 cho unlimited cap. Phân biệt ba trạng thái: cap vắng mặt = không có thông tin, cap: { amount: 300000 } = có giới hạn, cap: { amount: -1 } = explicitly unlimited. Consumer code xử lý được ba trường hợp khác nhau.
Hai tầng cap. Per-rule cap giới hạn cashback từ một rule cụ thể. global_cap giới hạn tổng cashback từ tất cả rules. Hai tầng này phản ánh đúng cách các ngân hàng hiện tại cấu trúc giới hạn hoàn tiền.
Zod schema là contract. Mọi file JSON đều được validate qua Zod trước khi vào pipeline. Không có field nào có thể có shape tùy ý. Mỗi describe() trên Zod schema là documentation cho cả human và LLM.
Kết quả hiện tại
321 thẻ từ 23 ngân hàng. Mỗi thẻ có full cashback schema. Cashback engine chạy trên Cloudflare Worker edge runtime, không có database query, không có network call nào ngoài request ban đầu.
Pipeline update data: scrape URL hoặc PDF → LLM extract có guided prompt từ Zod schema → Zod validate → diff trước/sau → confirm → commit. Mỗi thẻ có audit trail đầy đủ trong git history.
Schema đủ expressive để model được hầu hết rule cashback từ ngân hàng VN, không phải vì được thiết kế hoàn hảo từ đầu, mà vì iterate qua từng thẻ thật trong ba tháng. Mỗi field trong schema hiện tại đều có một thẻ cụ thể là lý do nó tồn tại.
Đây không phải schema tổng quát cho mọi thị trường. Nó là schema được calibrate cho thị trường thẻ tín dụng Việt Nam, với các pattern phổ biến nhất ở đây: waiver năm đầu theo điều kiện chi tiêu, pick-one-category-per-cycle, tiered rate theo tổng chi tiêu, và giao dịch nước ngoài với rate riêng.
Đủ để dùng. Vẫn còn chỗ để grow.
