Chia 100.000 đồng cho ba người – 1 đồng còn dư và cách PM viết product invariant

Giả sử bạn đang làm một ứng dụng chia hóa đơn.
User nhập tổng tiền 100.000 đồng, chọn ba người rồi bấm Chia đều. Màn hình hiện ra:
- An: 33.333 đồng
- Bình: 33.333 đồng
- Chi: 33.333 đồng
Nhìn khá ổn. Cho đến khi cộng lại.
Tổng ba phần là 99.999 đồng. Còn thiếu 1 đồng.
Nếu làm tròn cả ba lên 33.334 đồng thì ứng dụng lại đòi mọi người trả 100.002 đồng. Tự nhiên quán được tặng thêm 2 đồng mà không ai biết cảm ơn ai.
Một đồng chẳng đáng bao nhiêu. Nhưng nó làm lộ ra một câu hỏi mà câu user story “Tôi muốn chia đều hóa đơn cho nhóm” chưa trả lời được: sau khi chia, điều gì bắt buộc phải luôn đúng?
PM cần viết ra một product invariant cho flow này.
Ba phần cộng lại phải đúng 100.000 đồng
Product invariant là điều kiện mà sản phẩm không được vi phạm trong một phạm vi đã xác định, chẳng hạn khi tạo payment request. Với flow chia hóa đơn, invariant dễ thấy nhất là:
Tổng số tiền của các phần = Tổng số tiền cần thanh toán
Nếu sản phẩm chỉ cho nhập số tiền nguyên theo đồng, mỗi phần cũng phải là số nguyên. Với chế độ chia đều, chênh lệch giữa phần cao nhất và thấp nhất không được quá 1 đồng.
Ba điều kiện này đủ để team đi tới một kết quả hợp lệ:
100.000 = 33.334 + 33.333 + 33.333
Nhưng bài toán vẫn chưa xong. Team vẫn phải quyết định ai nhận 1 đồng còn dư.
Có thể cộng phần dư cho người tạo hóa đơn. Có thể cộng lần lượt theo thứ tự thành viên. Hoặc nếu ứng dụng thường xuyên chia nhiều hóa đơn trong cùng một nhóm, hệ thống có thể luân phiên để một người không phải ôm phần dư mãi.
Mình nghiêng về quy tắc đơn giản và nhìn thấy được: người tạo hóa đơn nhận phần dư, ngay trên screen có một dòng giải thích ngắn. Không phải vì đây là cách công bằng tuyệt đối. Với 1 đồng, cố xây một thuật toán công bằng tuyệt đối có khi tốn nhiều tiền hơn toàn bộ số dư mà nó xử lý.
Tổng các phần phải đúng 100.000 đồng, phần dư phải được phân bổ theo một quy tắc đã thống nhất và user cần biết ứng dụng vừa làm gì.
Nếu PRD chỉ ghi “làm tròn số tiền cho hợp lý”, designer có thể cộng phần dư cho người tạo hóa đơn, developer có thể cộng cho thành viên đầu tiên trong database, còn QA lại test theo thứ tự hiển thị trên screen. Team cần chốt một quy tắc. Nếu không, kết quả hiển thị, logic tính tiền và test case sẽ không còn khớp nhau nữa.
Lệch 1 đồng có thể ảnh hưởng đến cả payment và đối soát
Trong một app ghi chép chi tiêu, số liệu lệch 1 đồng có thể chưa gây nhiều rắc rối. User sửa tay là xong.
Trong một sản phẩm tạo payment request, từng phần có thể trở thành một giao dịch thật. An nhận yêu cầu chuyển 33.333 đồng, Bình cũng vậy, Chi cũng vậy. Sau khi cả ba đã thanh toán, hóa đơn gốc vẫn còn trạng thái Thiếu 1 đồng.
Nếu hệ thống kế toán đối soát theo tổng tiền, kết quả đối soát vẫn báo thiếu 1 đồng. Nếu nhà hàng phải nhận đúng 100.000 đồng trước khi đơn được đánh dấu đã thanh toán, đơn có thể cứ nằm ở trạng thái chờ. Từ đây, sai lệch 1 đồng xuất hiện trong payment, ledger, notification và cả ticket gửi tới support.
Vì vậy invariant không phải câu chữ kỹ thuật để developer tự lo. Nó cho team biết khi nào hệ thống được phép báo hóa đơn đã được chia hết: tổng các phần phải bằng đúng số tiền cần thanh toán.
Quay lại flow một chút.
Giả sử An muốn trả 40.000 đồng vì gọi thêm đồ uống. User sửa phần của An từ 33.334 thành 40.000 đồng. Hai phần còn lại nên tự đổi thành 30.000 đồng, hay ứng dụng báo tổng đang vượt 6.666 đồng và chờ user sửa tiếp?
Cả hai cách đều có thể dùng được. Nhưng team phải chọn kiểu thao tác sẽ đưa vào sản phẩm.
Nếu các ô tiền luôn tự cân bằng, invariant được giữ ngay trong lúc user sửa. Flow nhanh hơn, nhưng một thay đổi có thể âm thầm sửa số tiền của hai người khác.
Nếu ứng dụng cho phép tổng tạm thời lệch, user có nhiều quyền kiểm soát hơn. Đổi lại, button Gửi yêu cầu thanh toán phải bị disable cho tới khi tổng các phần quay về đúng 100.000 đồng. Screen cũng nên nói đang thừa hay thiếu bao nhiêu, chứ đừng chỉ tô đỏ ba ô rồi để user tự cộng.
Điều kiện về tổng tiền không đổi. Team chỉ đang chọn cách kiểm tra và xử lý khi tổng bị lệch.
Viết acceptance criteria từ điều không được phép vỡ
PM thường viết acceptance criteria bằng các hành động trên giao diện:
- User nhập tổng hóa đơn.
- User chọn số người.
- User bấm
Chia đều. - Hệ thống hiển thị số tiền mỗi người.
Flow này chạy rất đẹp với 90.000 đồng và ba người. Nhưng nó không giúp QA biết phải kỳ vọng điều gì với 100.000 đồng.
Thay vì cố đoán thật nhiều edge case ngay từ đầu, team có thể chốt invariant trước:
Ở thời điểm user gửi payment request, tổng số tiền của tất cả các phần phải bằng chính xác tổng tiền cần thanh toán.
Từ invariant này, team có thể viết acceptance criteria cụ thể hơn:
- Khi chia không hết, hệ thống phân bổ phần dư theo một quy tắc cố định.
- Khi user sửa một phần, screen cho biết tổng hiện đang thừa hoặc thiếu bao nhiêu.
- User không thể gửi payment request khi tổng các phần còn lệch.
- Sau khi thêm hoặc xóa một người, hệ thống tính lại các phần nhưng vẫn giữ tổng tiền gốc.
Invariant cần gắn với thời điểm kiểm tra.
Trong lúc user đang gõ 40.000, tổng tạm lệch có thể hoàn toàn chấp nhận được. Nhưng lúc hệ thống tạo giao dịch, tổng lệch 1 đồng cũng không còn hợp lệ. Nếu PRD chỉ ghi “tổng các phần phải bằng tổng hóa đơn” mà không nói hệ thống kiểm tra điều kiện này ở bước nào, team có thể xây một form liên tục tự sửa các ô tiền khi user đang gõ. Các con số luôn cộng lại đúng, nhưng user khó nhập số tiền mình muốn.
Với flow này, mình sẽ cho phép bản nháp tạm lệch. Khi user bấm gửi, hệ thống mới kiểm tra tổng các phần và không tạo payment request nếu tổng chưa đúng 100.000 đồng.
Invariant chỉ cần nói rõ đối tượng nào phải thỏa điều kiện nào và điều kiện đó được kiểm tra ở thời điểm nào. Cách cảnh báo, disable button hay tự cân bằng số tiền là lựa chọn UX và validation của team.
Thêm coupon thì chia 100.000 hay 80.000 đồng?
Bây giờ thêm một coupon 20.000 đồng vào hóa đơn 100.000 đồng.
Team chia 100.000 hay 80.000 đồng?
Nếu coupon thuộc về cả nhóm, team có thể chia 80.000 đồng cho ba người. Nếu coupon là ưu đãi riêng của Chi, một cách xử lý là tính số tiền mỗi người phải trả theo món đã gọi, rồi trừ 20.000 đồng khỏi phần của Chi. Cách này cần dữ liệu từng món; một app chỉ biết tổng tiền sẽ phải chọn quy tắc khác. Ứng dụng cũng không thể tự suy ra khái niệm “công bằng” mà nhóm đang dùng.
Ở đây, viết thêm mười công thức chưa chắc giải quyết được việc. PM cần quay lại mô hình sản phẩm: app đang chia tổng tiền phải trả cho quán, hay đang tính khoản nợ giữa từng người dựa trên món họ gọi, phí dịch vụ, coupon và người đã ứng tiền?
Hai bài toán đó trông gần giống nhau trên một screen. Dữ liệu và invariant phía sau lại khác.
Nếu sản phẩm chỉ chia đều tổng tiền phải trả, phiên bản đầu cần ba điều kiện đã nêu ở đầu bài: mỗi phần là số nguyên, tổng các phần đúng bằng số tiền cần thanh toán và chênh lệch giữa hai phần bất kỳ không quá 1 đồng. Nếu sản phẩm theo dõi khoản nợ, team cần thêm điều kiện: tổng số tiền các thành viên khác nợ người ứng tiền phải bằng số tiền người đó đã thanh toán, sau khi trừ phần chi phí của chính họ và các khoản đã được hoàn.
Mình sẽ dừng ở đây trước khi hóa đơn 100.000 đồng biến thành giáo trình kế toán. Bài này không cần liệt kê hết edge case. Cách tìm chúng hữu ích hơn.
Hãy lấy một câu mô tả trạng thái thành công, như “hóa đơn đã được chia hết”, rồi hỏi tiếp: điều gì phải đúng để sản phẩm được phép nói câu đó?
Nếu câu trả lời chưa viết thành một điều kiện mà designer, developer và QA cùng kiểm tra được, mỗi người có thể xử lý cùng một edge case theo một quy tắc khác. Lỗi lệch 1 đồng sẽ có thể chỉ được phát hiện khi QA thử chia 100.000 đồng cho ba người sát ngày release thôi.
Bài gốc trên Substack: https://henrypham.substack.com/p/chia-100000-ong-cho-ba-nguoi-1-ong
All rights reserved