- Given a course
- When I add fee plans (type, name, price, sessions, validity months, instalments, VAT rate, default materials)
- Then plans appear in the conversion wizard and the enrollment form; a course may have several plans; deactivated plans stay on old invoices.
PRD 05 · Tuition and finance
Fee plans and session packages, invoices and receipts, cash and bank payments with SePay reconciliation, discounts and vouchers, debt aging, refunds and credit notes, expenses, e-invoice hook and revenue reports. Reuses the existing Tuition, GroupInvoice, Course, bank account and company objects.
Key facts, origin and limits
Drafted by an AI assistant in a single session on 10/09/2026, from the original request quoted verbatim on the hub page; the Proposed design section was added on 16/09/2026. A proposal for team discussion, not a decision of the product team.
Sources
- Public competitor research (PSE ONE, pse4.one), the
center-crm-frontendcodebase and the staging/v3OpenAPI specification (182 paths), as of 10/09/2026. - The existing / extend / new tags on this page come from a comparison with the staging
/v3specification at that date.
Limits
- Verification pending in-app survey, because automated login to the reference product was not authorised in the drafting session:
- Competitor's tuition flow "đăng ký gói học, nộp tiền, đặt lịch" and "chốt số buổi còn": how packages and remaining sessions are modelled.
- Receipt (phiếu thu) and expense (phiếu chi) fields, numbering and categories.
- Voucher fields: percent vs amount, single vs multi use, scope.
- Whether they auto-match bank transfers or only record manually.
- How "công nợ" is presented (per student, per invoice) and whether aging exists.
- Whether e-invoice integration exists and with which provider.
Proposed design
Seven screens on the shared School OS frame: the invoice list with KPIs and aging, the invoice form as a drawer, invoice detail with the payments table, the payment dialog with SePay reconciliation, debt by aging bucket, the cash book with expenses, and fee plan settings. The point to notice: every money exception (a discount over the role limit, an expense over the threshold, an overdue balance) surfaces as a Chờ duyệt state or a red chip right in the list, never only in the detail.







1Purpose
Centers sell learning in three shapes: a course paid once (or in instalments), a monthly fee, or a package of N sessions consumed by attendance. They collect by bank transfer with a QR, by cash at the desk, and sometimes by card. They need a receipt the parent trusts, a debt list the desk can act on, and a monthly picture of revenue, collections and expenses per branch. The backend already has a Tuition object with cost, discount, VAT, payment status, transactions (cash and bank transfer) and SePay-verified bank accounts, a group invoice for bulk monthly billing, a legal company profile and payment QR generation. This PRD fills in fee plans, session packages with balance, partial payments and receipts numbering, vouchers, refunds, expenses and the e-invoice hook.
In scope
- Fee plans per course: one-time, instalments, monthly, session package; materials as line items.
- Invoice (tuition) creation single and bulk; discounts with approval; vouchers.
- Payments: cash, bank transfer (SePay auto-match), card/POS manual; partial payments; receipts with numbering and PDF; payment QR.
- Session package balance, expiry, top-up, transfer, freeze on pause.
- Debt list and aging, reminders (PRD 07), payment plans.
- Refunds and credit notes; expenses (phiếu chi) with categories; cash book.
- E-invoice hook to a Vietnamese provider (interface only in P1).
- Revenue, collection, deferred revenue reports.
Out of scope
- General ledger, VAT filing, payroll accounting (export only).
- Card acquiring integration; POS payments are recorded manually.
- Commission to sales staff.
2What exists today
| Object / endpoint | Fields and behaviour | Reuse |
|---|---|---|
Tuition (/v3/teacher/school-finance/tuitions) | student, class_id, course, title, description, note, cost, amount, discount_pct, vat_pct, payment_status UNPAID|PAID, overdue, notification_status, paid_amount, payment_code, bank_account_id, due_date, paid_at, teacher; detail adds bank_account, qr_url, transactions[] | The invoice. Extend with fee_plan, line items, status PARTIAL, receipts |
POST .../tuitions (CreateTuitionRequest) | students[{student_id, cost, amount, discount_pct, note}], class_id, course_id, title, description, vat_pct, bank_account_id, due_date | Bulk create per class; keep |
POST .../tuitions/{id}/pay (PayTuitionRequest) | amount, method CASH|BANK_TRANSFER, note → TuitionTransaction (sepay_transaction_id when matched) | The payment; extend with method CARD, receipt number, cashier |
GroupInvoice | title, course_id, classes[], totals, notify_app/zalo/email | Monthly billing run |
tuitions/stats, classes/{id}/students/tuitions (month groups), students/{id}/tuitions, students/{id}/stats | totals, per class, per student | Reports and profile |
tuitions/{id}/notify, tuitions/notify (batch) | channels app, email, zalo with per-channel outcome and skip reason | Reminders |
SchoolBankAccount, link-token, manual accounts | SePay connection or manual; active flag | Receiving accounts per branch |
SchoolCompany | legal_name, type ENTERPRISE|INDIVIDUAL, tax_id or citizen_id, address, issued info, sepay_company_xid | Issuer for receipts and e-invoices |
POST /v3/payment/qr | account, bank id, amount, add_info → QR | Payment QR with payment_code |
Course | price, pricing_method ONE_TIME|MONTHLY, split_payment_count | Base of fee plans |
/v3/parent/tuitions, /overview | guardian view of invoices per child | Parent portal |
CRM packages, customers/{id}/packages | sales catalog and expected amount on a lead | Pre-sale only; not the fee plan |
3Concepts and fee plan types
| Fee plan type | VI | Billing | Balance | Example |
|---|---|---|---|---|
one_time | Trọn khoá | One invoice at enrollment; optional instalments (n parts with due dates) | none; access ends at class end | Movers 2 · 48 buổi · 9,600,000 |
monthly | Theo tháng | Invoice each month via billing run (GroupInvoice), prorated first month by sessions | none | 1,200,000 / tháng |
session_package | Gói buổi | Invoice at purchase; sessions credited to student balance with expiry | sessions_remaining | Gói 24 buổi · 6,000,000 · hạn 6 tháng |
material | Tài liệu / phí khác | Line item on any invoice (books, uniform, exam fee) | none | Sách Family and Friends 2 · 180,000 |
An invoice (existing Tuition) has line items, a discount, VAT, a due date, a payment status unpaid | partial | paid | void and payments. A receipt (phiếu thu) is issued per payment with a sequential number per branch. A credit note reverses part of an invoice; a refund is a payment out linked to a credit note. An expense (phiếu chi) is a payment out not linked to a student. The cash book lists all payments in and out by account (cash drawer or bank account).
4User stories and acceptance criteria
- Given a student and permission
finance.invoice.create - When I choose fee plan or free lines, quantity, discount (percent or amount, reason), voucher, due date, receiving bank account
- Then an invoice is created (existing Tuition create) with lines, totals, VAT, payment_code and QR; discount above the user's limit sets status
pending_approvaland notifies the branch manager; a session package plan credits sessions on full payment (or on creation if tenant setting "cấp buổi khi tạo hoá đơn").
- Given classes with monthly fee plans
- When I run "Lập hoá đơn tháng 10" for selected classes
- Then a GroupInvoice is created with one Tuition per active enrollment, prorated for students starting mid-month by sessions remaining over sessions in month, skipping students paused for the whole month, with a preview table before commit and notifications per chosen channel.
- Given an unpaid or partial invoice
- When I record amount, method Tiền mặt, payer name
- Then a payment is created (existing pay endpoint, extended), the receipt number
PT-<branch>-YYYYMM-####is assigned, a PDF receipt is generated with company legal info, and the guardian receives it by Zalo if consented; the invoice becomes partial or paid.
- Given a SePay-linked account and an invoice with payment_code in the QR
- When the transfer arrives with the code in the description
- Then the payment is created automatically with
sepay_transaction_id, receipt issued, guardian notified; transfers without a matching code land in "Chưa đối soát" for manual matching to an invoice or student credit; partial amounts are accepted as partial payments.
- Given invoices past due date
- When I open Công nợ
- Then I see students with balance due, aging buckets (0–7, 8–30, 31–60, >60 days), last reminder date, promise-to-pay date; I can send reminders in bulk (existing batch notify) with a template, log a call, or set a payment plan (split into dated parts).
- Given a student with 24 credited sessions
- When attendance marks present, late or absent (per policy)
- Then the balance decrements FIFO across packages by expiry; at 3 remaining the guardian and desk are notified "Sắp hết buổi"; at 0 the student appears in "Hết buổi" and the roster shows a red chip; a pause freezes expiry.
- Given a withdrawn student with remaining sessions or unused months
- When I create a credit note (auto-calculated proposal: unused sessions × unit price minus admin fee per policy, editable with reason) and a refund payment (cash or bank)
- Then the invoice shows the credit, the refund appears in the cash book as payment out, the balance is zeroed, and approval by branch manager is required above the tenant threshold.
- Given a voucher group
- When I create codes (single-use or multi-use, percent or amount, min amount, applicable courses, branches, valid dates, max uses)
- Then codes can be applied on invoice creation with validation and usage tracked per student.
- Given permission
finance.expense.create - When I create an expense with category, amount, payee, account (cash drawer or bank), date, attachment
- Then it appears in the cash book; expenses above threshold need approval; the daily cash close compares drawer balance with recorded cash.
- Given a paid invoice and a configured provider (adapter interface; first provider selected in P2)
- When I click Xuất hoá đơn điện tử and enter buyer details
- Then the request is sent to the provider, the returned invoice number and lookup code are stored on the invoice, and the PDF link is available; failures are shown with the provider message and retry.
- Given a period and branch
- When I open Báo cáo tài chính
- Then I see billed, collected, outstanding, overdue, refunds, expenses, net cash, collection rate, revenue by course and by fee plan type, deferred revenue (unused sessions value), with drill-down to invoices and export.
5Screens
KPI tiles: Đã lập tháng này, Đã thu, Còn phải thu, Quá hạn. Tabs: Tất cả · Chưa thanh toán · Thanh toán một phần · Quá hạn · Chờ duyệt · Đã thanh toán · Đã huỷ. Filters: branch, class, course, fee plan type, due date range, created range, q (student, code, payment code). Buttons: + Tạo hoá đơn, Lập hoá đơn tháng, Xuất Excel.
| Column | Notes |
|---|---|
| Mã HĐ | payment_code |
| Học viên | name + class chip |
| Nội dung | title (fee plan name) |
| Tổng tiền | amount after discount and VAT |
| Đã thu | paid_amount |
| Còn lại | amount - paid; red when overdue |
| Hạn | due_date; overdue days chip |
| Trạng thái | chip |
| Nhắc gần nhất | date + channel |
| Thao tác | Thu tiền · Gửi nhắc · In |
| Field | Type | Validation |
|---|---|---|
| Học viên | search | required |
| Lớp | select of student's classes | optional |
| Dòng | repeatable: fee plan or material or free text; qty; unit price; line discount | ≥ 1 line; price ≥ 0 |
| Giảm giá | percent or amount + reason + voucher code | ≤ limit else approval |
| VAT | percent per line default from plan | 0–10 |
| Hạn thanh toán | date | default today + 7 |
| Trả góp | toggle: n parts with dates and amounts | sum equals total |
| Tài khoản nhận | select active bank accounts of branch | required for QR |
| Ghi chú | text | |
| Gửi thông báo | channels app / Zalo / email | defaults from settings |
Footer shows subtotal, discount, VAT, total; after save shows QR and "Thu tiền ngay".
Header: code, student, status chip, total, paid, remaining, due. Sections: lines; payments table (date, method, amount, receipt number, cashier, SePay id, PDF); credit notes; reminders log; e-invoice block; audit. Buttons: Thu tiền, Gửi nhắc, In hoá đơn, Tạo phiếu giảm, Huỷ (void, only if no payments), Xuất HĐĐT.
Fields: amount (default remaining), method (Tiền mặt / Chuyển khoản / Thẻ), account (cash drawer of branch or bank account), payer name, date (default now, no future), note, "Gửi biên lai Zalo" toggle. Result: receipt number, PDF preview, print.
Aging summary tiles; table per student: student, guardian phone, invoices count, total due, oldest due date, bucket, last reminder, promise date, care owner; row actions: Gửi nhắc, Ghi cuộc gọi, Kế hoạch trả, Thu tiền. Bulk: Gửi nhắc (template picker).
Filters: branch, account, date range, type (thu/chi), category. Table: date, code (PT/PC), type, category, counterparty, amount in, amount out, account, created by, attachment. Buttons: + Phiếu chi, Chốt quỹ ngày (shows expected vs counted with difference and note). Tab Chưa đối soát lists SePay transactions without match with "Gán vào hoá đơn".
Grouped by course. Form: name, type, price, sessions (package), validity months, instalment count and rule, VAT, default materials, active. Voucher groups and codes under /settings/vouchers; expense categories under /settings/expense-categories; receipt numbering and company info under /settings/finance.
6Business rules
- Invoice total = Σ(qty × unit_price - line_discount) - invoice_discount + VAT. Rounded to whole VND.
- Payment status: unpaid (paid 0), partial (0 < paid < total), paid (paid ≥ total), void (no payments, cancelled). Overpayment is refused; excess becomes student credit only through the unmatched-transfer flow.
- Receipts are numbered per branch per month, sequential, never reused; voiding a payment keeps the number with status void and requires a reason.
- Sessions from a package are credited on full payment by default; tenant may choose on creation. Expiry = credit date + validity, extended by pauses.
- Consumption order: earliest expiry first; a session consumed from an expired package is refused and the student shows "Hết hạn gói".
- Discount limits per role (default: admissions 10%, academic_admin 5%, accountant 15%, branch_manager 30%, owner unlimited) are tenant settings; above limit requires approval.
- Vouchers: one voucher per invoice; not combinable with percent discount above 0 unless flagged stackable.
- Credit notes cannot exceed the invoice total minus previous credits; refund cannot exceed paid minus previous refunds.
- Expenses and refunds above tenant threshold (default 5,000,000) need approval by branch manager or owner.
- The cash drawer is per branch; a daily close locks payments of that day for edit (void only).
- All money writes are audited with before/after (PRD 12).
7Data model
| Entity | Fields | Notes |
|---|---|---|
| fee_plan | id uuid; tenant_id; course_id null; name; type enum(one_time, monthly, session_package, material); price; sessions int null; validity_months null; instalments jsonb null [{pct, days_after}]; vat_pct; default_material_ids uuid[]; is_active | new |
| tuition (existing Tuition) | + fee_plan_id; status enum extends PAID/UNPAID with PARTIAL, VOID, PENDING_APPROVAL; discount_amount; discount_reason; voucher_id; approved_by; instalment_of uuid null; einvoice jsonb {provider, number, lookup_code, pdf_url, issued_at} | extend |
| tuition_line | id; tuition_id; type enum(fee, material, other); fee_plan_id null; inventory_item_id null; description; qty; unit_price; discount; vat_pct; amount | new |
| payment (existing TuitionTransaction) | + method CARD; receipt_no; account_id (cash drawer or bank); payer_name; paid_at; cashier_staff_id; status enum(valid, void); void_reason; receipt_pdf_file_id | extend |
| session_package | id; student_id; tuition_id; fee_plan_id; sessions_total; sessions_used; credited_at; expires_on; frozen_days; status enum(pending, active, exhausted, expired, refunded) | new |
| session_consumption | id; package_id; attendance_record_id; consumed_at; reversed_at null | new |
| credit_note | id; tuition_id; amount; reason; created_by; approved_by; created_at | new |
| refund | id; credit_note_id; amount; method; account_id; paid_to; paid_at; receipt_no (PC); status | new (payment out) |
| expense | id; school_id; code PC-; category_id; amount; payee; account_id; date; note; file_id; created_by; approved_by; status | new |
| cash_account | id; school_id; type enum(cash_drawer, bank); bank_account_id null; name; is_active | new; bank rows mirror SchoolBankAccount |
| cash_close | id; cash_account_id; date; expected; counted; difference; note; closed_by | new |
| voucher_group, voucher, voucher_use | group: name, type percent|amount, value, min_amount, course_ids, school_ids, valid_from, valid_to, max_uses; voucher: code, single_use; use: voucher_id, tuition_id, student_id, used_at | new |
| unmatched_transaction | id; bank_account_id; sepay_transaction_id; amount; description; received_at; matched_tuition_id null; matched_by | new |
| finance_settings | receipt_prefix; numbering_scope; credit_on_create bool; discount_limits {role: pct}; approval_thresholds; einvoice_provider config (encrypted) | tenant |
8API contract
| Method | Path | Request / response | Status |
|---|---|---|---|
| GET/POST/PUT/DELETE | /v3/teacher/school-finance/course/courses[...] | course base | existing |
| GET/POST/PATCH | /v3/school/fee-plans[/{id}] | course_id filter | new |
| GET | /v3/teacher/school-finance/tuitions | filters as today + fee_plan_type, status incl. PARTIAL, aging_bucket | extend |
| POST | /v3/teacher/school-finance/tuitions | as today + lines[], discount_amount, discount_reason, voucher_code, instalments[] → 202 PENDING_APPROVAL when over limit | extend |
| GET | /v3/teacher/school-finance/tuitions/{id} | + lines, credit_notes, reminders, einvoice, package | extend |
| POST | /v3/teacher/school-finance/tuitions/{id}/pay | {amount, method CASH|BANK_TRANSFER|CARD, account_id, payer_name, paid_at?, note, send_receipt} → payment with receipt_no, pdf | extend |
| POST | /v3/school/payments/{id}/void | {reason} | new |
| GET | /v3/school/payments/{id}/receipt.pdf | signed | new |
| POST | /v3/school/tuitions/{id}/approve | /reject | {note} | new |
| POST | /v3/school/tuitions/{id}/void | {reason}; only when no valid payments | new |
| POST | /v3/teacher/school-finance/tuitions/{id}/notify, /tuitions/notify | reminders; add template_id | extend |
| GET/POST | /v3/teacher/school-finance/group-invoices | add POST with {title, month, class_ids[], fee_plan_id?, prorate: bool, notify: {app, zalo, email}, dry_run} | extend |
| GET | /v3/school/debt | school_id, bucket, class_id, q → per-student aggregates | new |
| POST | /v3/school/tuitions/{id}/payment-plan | {parts: [{amount, due_date}]} | new |
| GET | /v3/school/students/{id}/packages | packages with consumption history | new |
| POST | /v3/school/packages/{id}/adjust | {sessions_delta, reason} manual credit (approval) | new |
| POST | /v3/school/tuitions/{id}/credit-notes | {amount, reason} → credit note (approval if over threshold) | new |
| POST | /v3/school/credit-notes/{id}/refund | {amount, method, account_id, paid_to} | new |
| GET | /v3/school/refund-estimate?student_id= | policy-based proposal | new |
| GET/POST/PATCH | /v3/school/expenses[/{id}]; POST /approve | new | |
| GET/POST | /v3/school/expense-categories | new | |
| GET | /v3/school/cashbook | school_id, account_id, from, to, type, category → entries; format=xlsx | new |
| GET/POST | /v3/school/cash-accounts; POST /v3/school/cash-accounts/{id}/close | {date, counted, note} | new |
| GET/POST | /v3/school/unmatched-transactions; POST /{id}/match | {tuition_id | student_id (credit)} | new |
| GET/POST/PATCH | /v3/school/voucher-groups, /vouchers; POST /vouchers/validate | {code, student_id, course_id, amount} → discount or error | new |
| POST | /v3/school/tuitions/{id}/einvoice | {buyer: {name, tax_id?, address, email}} → provider result | new |
| GET | /v3/school/finance/report | school_id, from, to, group_by → billed, collected, outstanding, overdue, refunds, expenses, deferred; series | new |
| GET/POST/PUT/DELETE | /v3/teacher/school-finance/bank-accounts[...], /company, /link-token | unchanged | existing |
| POST | /v3/payment/qr | unchanged | existing |
| GET | /v3/parent/tuitions, /{id}, /overview | guardian view; add receipts and package balance | extend |
9Permissions
| Code | owner | branch_manager | accountant | academic_admin | admissions | teacher | parent |
|---|---|---|---|---|---|---|---|
finance.view | all | branch | branch | branch (balances) | own leads' invoices | no | own children |
finance.invoice.create | yes | yes | yes | yes | via convert | no | no |
finance.invoice.void | yes | yes | yes | no | no | no | no |
finance.payment.create | yes | yes | yes | cash if granted | no | no | no |
finance.payment.void | yes | yes | yes | no | no | no | no |
finance.discount.approve | yes | yes | no | no | no | no | no |
finance.refund | yes | approve | create | no | no | no | no |
finance.expense | yes | approve | create | no | no | no | no |
finance.cashbook, finance.close | yes | view | yes | no | no | no | no |
finance.reminders | yes | yes | yes | yes | no | no | no |
finance.vouchers, finance.settings, finance.einvoice | yes | no | settings, einvoice | no | no | no | no |
finance.report, finance.export | yes | branch | yes | no | no | no | no |
10Analytics events
| Event | Properties |
|---|---|
| invoice_created | fee_plan_type, lines, discount_pct_bucket, voucher, approval_required, via (wizard|form|bulk) |
| billing_run_created | classes, invoices, prorated_count |
| payment_recorded | method, partial, auto_matched, receipt_sent |
| transfer_unmatched / transfer_matched | age_hours |
| reminder_sent | channel, bucket, bulk |
| package_low / package_exhausted | |
| credit_note_created / refund_paid | amount_bucket, approval |
| expense_created / cash_closed | category, difference_nonzero |
| einvoice_issued / einvoice_failed | provider |
| finance_report_viewed | period, group_by |
11Edge cases
- Sibling discount: implemented as an invoice discount reason "Anh chị em"; automatic sibling percent is a tenant setting applied in the wizard.
- Transfer arrives before the invoice exists (parent pays early): unmatched transaction becomes student credit; next invoice offers "Dùng số dư".
- Two payments for the same invoice within seconds (SePay webhook retry): dedupe on sepay_transaction_id.
- Parent pays one transfer for two children: unmatched flow allows splitting across invoices.
- Package bought at branch A used at branch B after transfer: package moves with the student; revenue stays at A; report shows inter-branch note.
- VAT on tuition is exempt in Vietnam for education services; VAT applies to materials. Default VAT per fee plan type: fee 0, material 5 (configurable).
- Currency: VND only in v1; the existing Course currency field must be VND.
- Instalment invoice partially paid then student withdraws: credit note on the remaining instalments, refund only on paid portion.
12Open questions
- Which e-invoice provider first (MISA meInvoice, Viettel SInvoice, VNPT)? Adapter design assumes REST with token; pick after pilot accountant interviews.
- Should package sessions be credited on invoice creation by default (centers let students start before paying)?
- Do we need a "deposit" (đặt cọc) object before enrollment, or is a partial payment on the invoice enough?
Trường Số (truongso.net) · FLYER internal