Trường Số · Product Requirements · 09/2026

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
Status
Draft v1
Owner module
Tài chính
Backend
EXISTING Tuition/GroupInvoice/SePay · NEW fee plans, packages, vouchers, refunds, expenses, e-invoice
Phase
P1 core · P2 vouchers, refunds, expenses, e-invoice
Depends on
02 Students, 03 Classes, 04 Attendance

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-frontend codebase and the staging /v3 OpenAPI specification (182 paths), as of 10/09/2026.
  • The existing / extend / new tags on this page come from a comparison with the staging /v3 specification 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.

1 · Học phí

/finance/tuition · desktop

Invoice list
Four KPI tiles (invoiced, collected, outstanding, overdue), tabs per payment status and the full ten-column table from section 5. Overdue rows paint the Còn lại column red with an overdue-days chip (FIN-06); a row whose discount exceeds the limit sits in Chờ duyệt (rule 6); the voided row keeps only Print because a void invoice has no payments (rule 2).

2 · Invoice form

/finance/tuition/new?student_id= · desktop

Invoice drawer
The drawer opens over the list with two lines: a session package (VAT 0%) and the plan's default material (VAT 5%). The admissions consultant entered a 15% discount above the 10% role limit, so the field shows an error and the warning callout states the invoice will be created in Chờ duyệt (FIN-02, rule 6); the voucher is validated and stacks only when marked stackable (rule 7). The footer computes subtotal, discount, voucher, VAT and total per rule 1 and the primary button becomes "Tạo và gửi duyệt".

3 · Invoice detail

/finance/tuition/:id · desktop

Invoice detail
Header with code, student, status chip and the four figures total, paid, remaining, due. The payments table carries branch-and-month receipt numbers (rule 3); the bank transfer shows a SePay id with cashier "Tự động" (FIN-05), the cash payment shows its cashier; credit notes are empty (FIN-08), then the reminder log; the rail holds instalments, the not-yet-issued e-invoice block (FIN-11) and the audit log (rule 11). Void is unavailable because payments exist.

4 · Payment dialog

/finance/tuition/:id/pay · desktop

Payment dialog
The payment dialog over the invoice detail: the top callout says the SePay transaction matched by payment code and was de-duplicated (FIN-05, the webhook-retry edge case); 2.000.000 ₫ is below the remaining amount so it is recorded as a partial payment and overpayment is blocked (rule 2); the date defaults to now and cannot be in the future; the Zalo receipt toggle (FIN-04) and the receipt number to be issued are shown before confirming.

5 · Công nợ

/finance/debt · desktop

Debt list by aging
Five summary tiles per aging bucket 0–7, 8–30, 31–60 and over 60 days (FIN-06), the last one flagged as priority. The per-student table has guardian phone, invoice count, total due, oldest due date, bucket, last reminder, promise date and care owner; three students are selected for a bulk reminder with a template, and row actions are Thu tiền, Gửi nhắc and a menu with Ghi cuộc gọi and Kế hoạch trả.

6 · Thu chi

/finance/cashbook · desktop

Cash book filtered to expenses
The cash book filtered to Loại: Chi by expense category; the tiles show the cash drawer balance, the SePay bank account, total expenses under the filter and the pending approval. The 25.000.000 ₫ rent expense exceeds the 5.000.000 ₫ threshold so it sits in Chờ duyệt (FIN-10, rule 9); the refund appears as an expense linked to a credit note (FIN-08). The Chưa đối soát tab counts three unmatched SePay transactions and Chốt quỹ ngày locks the day's entries (rule 10).

7 · Fee plans

/settings/fee-plans · desktop

Fee plan settings
Plans grouped by course across the four plan types (one-time, monthly, session package, material line); a deactivated plan stays on old invoices (FIN-01). The edit drawer covers type, price, sessions, validity months, instalments, VAT, default materials and the active toggle, with a note on the tenant setting for when sessions are credited (rule 4).

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

Out of scope

2What exists today

Object / endpointFields and behaviourReuse
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_dateBulk 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
GroupInvoicetitle, course_id, classes[], totals, notify_app/zalo/emailMonthly billing run
tuitions/stats, classes/{id}/students/tuitions (month groups), students/{id}/tuitions, students/{id}/statstotals, per class, per studentReports and profile
tuitions/{id}/notify, tuitions/notify (batch)channels app, email, zalo with per-channel outcome and skip reasonReminders
SchoolBankAccount, link-token, manual accountsSePay connection or manual; active flagReceiving accounts per branch
SchoolCompanylegal_name, type ENTERPRISE|INDIVIDUAL, tax_id or citizen_id, address, issued info, sepay_company_xidIssuer for receipts and e-invoices
POST /v3/payment/qraccount, bank id, amount, add_info → QRPayment QR with payment_code
Courseprice, pricing_method ONE_TIME|MONTHLY, split_payment_countBase of fee plans
/v3/parent/tuitions, /overviewguardian view of invoices per childParent portal
CRM packages, customers/{id}/packagessales catalog and expected amount on a leadPre-sale only; not the fee plan

3Concepts and fee plan types

Fee plan typeVIBillingBalanceExample
one_timeTrọn khoáOne invoice at enrollment; optional instalments (n parts with due dates)none; access ends at class endMovers 2 · 48 buổi · 9,600,000
monthlyTheo thángInvoice each month via billing run (GroupInvoice), prorated first month by sessionsnone1,200,000 / tháng
session_packageGói buổiInvoice at purchase; sessions credited to student balance with expirysessions_remainingGói 24 buổi · 6,000,000 · hạn 6 tháng
materialTài liệu / phí khácLine item on any invoice (books, uniform, exam fee)noneSá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

FIN-01Define fee plans on a course
As an accountant I set how a course is sold.
  • 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.
FIN-02Create an invoice for a student
As a front desk or accountant I bill a student.
  • 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_approval and 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").
FIN-03Bulk monthly billing
As an accountant I bill all monthly students in one run.
  • 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.
FIN-04Record a cash payment and issue a receipt
As a cashier I take cash at the desk.
  • 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.
FIN-05Bank transfer auto-matched
As an accountant I do not type bank transfers.
  • 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.
FIN-06Debt list and reminders
As a desk staff I chase overdue invoices.
  • 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).
FIN-07Session balance consumption
As a student's balance owner the system counts sessions.
  • 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.
FIN-08Refund on withdrawal
As an accountant I refund unused value.
  • 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.
FIN-09Vouchers
As a marketing owner I issue promotional codes.
  • 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.
FIN-10Expenses and cash book
As an accountant I record outgoing money.
  • 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.
FIN-11E-invoice
As an accountant I issue a legal e-invoice for a paid invoice when the parent asks.
  • 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.
FIN-12Reports
As an owner I read the money.
  • 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

Học phí (invoices) /finance/tuition

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.

ColumnNotes
Mã HĐpayment_code
Học viênname + class chip
Nội dungtitle (fee plan name)
Tổng tiềnamount after discount and VAT
Đã thupaid_amount
Còn lạiamount - paid; red when overdue
Hạndue_date; overdue days chip
Trạng tháichip
Nhắc gần nhấtdate + channel
Thao tácThu tiền · Gửi nhắc · In
Invoice form (drawer) /finance/tuition/new?student_id=
FieldTypeValidation
Học viênsearchrequired
Lớpselect of student's classesoptional
Dòngrepeatable: 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
VATpercent per line default from plan0–10
Hạn thanh toándatedefault today + 7
Trả góptoggle: n parts with dates and amountssum equals total
Tài khoản nhậnselect active bank accounts of branchrequired for QR
Ghi chútext
Gửi thông báochannels app / Zalo / emaildefaults from settings

Footer shows subtotal, discount, VAT, total; after save shows QR and "Thu tiền ngay".

Invoice detail /finance/tuition/:id

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.

Payment dialog /finance/tuition/:id/pay

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.

Công nợ (debt) /finance/debt

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).

Thu chi (cash book) /finance/cashbook

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".

Fee plans (settings) /settings/fee-plans

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

  1. Invoice total = Σ(qty × unit_price - line_discount) - invoice_discount + VAT. Rounded to whole VND.
  2. 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.
  3. Receipts are numbered per branch per month, sequential, never reused; voiding a payment keeps the number with status void and requires a reason.
  4. Sessions from a package are credited on full payment by default; tenant may choose on creation. Expiry = credit date + validity, extended by pauses.
  5. Consumption order: earliest expiry first; a session consumed from an expired package is refused and the student shows "Hết hạn gói".
  6. Discount limits per role (default: admissions 10%, academic_admin 5%, accountant 15%, branch_manager 30%, owner unlimited) are tenant settings; above limit requires approval.
  7. Vouchers: one voucher per invoice; not combinable with percent discount above 0 unless flagged stackable.
  8. Credit notes cannot exceed the invoice total minus previous credits; refund cannot exceed paid minus previous refunds.
  9. Expenses and refunds above tenant threshold (default 5,000,000) need approval by branch manager or owner.
  10. The cash drawer is per branch; a daily close locks payments of that day for edit (void only).
  11. All money writes are audited with before/after (PRD 12).

7Data model

EntityFieldsNotes
fee_planid 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_activenew
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_lineid; tuition_id; type enum(fee, material, other); fee_plan_id null; inventory_item_id null; description; qty; unit_price; discount; vat_pct; amountnew
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_idextend
session_packageid; 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_consumptionid; package_id; attendance_record_id; consumed_at; reversed_at nullnew
credit_noteid; tuition_id; amount; reason; created_by; approved_by; created_atnew
refundid; credit_note_id; amount; method; account_id; paid_to; paid_at; receipt_no (PC); statusnew (payment out)
expenseid; school_id; code PC-; category_id; amount; payee; account_id; date; note; file_id; created_by; approved_by; statusnew
cash_accountid; school_id; type enum(cash_drawer, bank); bank_account_id null; name; is_activenew; bank rows mirror SchoolBankAccount
cash_closeid; cash_account_id; date; expected; counted; difference; note; closed_bynew
voucher_group, voucher, voucher_usegroup: 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_atnew
unmatched_transactionid; bank_account_id; sepay_transaction_id; amount; description; received_at; matched_tuition_id null; matched_bynew
finance_settingsreceipt_prefix; numbering_scope; credit_on_create bool; discount_limits {role: pct}; approval_thresholds; einvoice_provider config (encrypted)tenant

8API contract

MethodPathRequest / responseStatus
GET/POST/PUT/DELETE/v3/teacher/school-finance/course/courses[...]course baseexisting
GET/POST/PATCH/v3/school/fee-plans[/{id}]course_id filternew
GET/v3/teacher/school-finance/tuitionsfilters as today + fee_plan_type, status incl. PARTIAL, aging_bucketextend
POST/v3/teacher/school-finance/tuitionsas today + lines[], discount_amount, discount_reason, voucher_code, instalments[] → 202 PENDING_APPROVAL when over limitextend
GET/v3/teacher/school-finance/tuitions/{id}+ lines, credit_notes, reminders, einvoice, packageextend
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, pdfextend
POST/v3/school/payments/{id}/void{reason}new
GET/v3/school/payments/{id}/receipt.pdfsignednew
POST/v3/school/tuitions/{id}/approve | /reject{note}new
POST/v3/school/tuitions/{id}/void{reason}; only when no valid paymentsnew
POST/v3/teacher/school-finance/tuitions/{id}/notify, /tuitions/notifyreminders; add template_idextend
GET/POST/v3/teacher/school-finance/group-invoicesadd POST with {title, month, class_ids[], fee_plan_id?, prorate: bool, notify: {app, zalo, email}, dry_run}extend
GET/v3/school/debtschool_id, bucket, class_id, q → per-student aggregatesnew
POST/v3/school/tuitions/{id}/payment-plan{parts: [{amount, due_date}]}new
GET/v3/school/students/{id}/packagespackages with consumption historynew
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 proposalnew
GET/POST/PATCH/v3/school/expenses[/{id}]; POST /approvenew
GET/POST/v3/school/expense-categoriesnew
GET/v3/school/cashbookschool_id, account_id, from, to, type, category → entries; format=xlsxnew
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 errornew
POST/v3/school/tuitions/{id}/einvoice{buyer: {name, tax_id?, address, email}} → provider resultnew
GET/v3/school/finance/reportschool_id, from, to, group_by → billed, collected, outstanding, overdue, refunds, expenses, deferred; seriesnew
GET/POST/PUT/DELETE/v3/teacher/school-finance/bank-accounts[...], /company, /link-tokenunchangedexisting
POST/v3/payment/qrunchangedexisting
GET/v3/parent/tuitions, /{id}, /overviewguardian view; add receipts and package balanceextend

9Permissions

Codeownerbranch_manageraccountantacademic_adminadmissionsteacherparent
finance.viewallbranchbranchbranch (balances)own leads' invoicesnoown children
finance.invoice.createyesyesyesyesvia convertnono
finance.invoice.voidyesyesyesnononono
finance.payment.createyesyesyescash if grantednonono
finance.payment.voidyesyesyesnononono
finance.discount.approveyesyesnonononono
finance.refundyesapprovecreatenononono
finance.expenseyesapprovecreatenononono
finance.cashbook, finance.closeyesviewyesnononono
finance.remindersyesyesyesyesnonono
finance.vouchers, finance.settings, finance.einvoiceyesnosettings, einvoicenononono
finance.report, finance.exportyesbranchyesnononono

10Analytics events

EventProperties
invoice_createdfee_plan_type, lines, discount_pct_bucket, voucher, approval_required, via (wizard|form|bulk)
billing_run_createdclasses, invoices, prorated_count
payment_recordedmethod, partial, auto_matched, receipt_sent
transfer_unmatched / transfer_matchedage_hours
reminder_sentchannel, bucket, bulk
package_low / package_exhausted
credit_note_created / refund_paidamount_bucket, approval
expense_created / cash_closedcategory, difference_nonzero
einvoice_issued / einvoice_failedprovider
finance_report_viewedperiod, group_by

11Edge cases

12Open questions

Trường Số (truongso.net) · FLYER internal