Subscription bulanan bayar belakangan, harga ditarik dari matriks 4-axis (grade × segmentasi × kategori × subject), slot bisa diisi item beda-beda per sesi. Online via Zoom masuk ke MVP. Invoice otomatis di-generate cron akhir bulan dari sesi yang benar-benar jalan. PWA installable dengan push reminder + offline read cache, kamera lewat browser API.
Sistem ini punya empat peran:
materialsCovered),
catatan progress per murid (studentProgressNotes),
homework yang di-assign. Pas murid stuck atau jarang masuk, kelihatan
dari data — bukan cuma dari memori guru.
Yang dijaga sistem cuma:
closed setelah ada foto evidence + presence. Bukan blokir
waktu, tapi blokir state.
price_list_items, lengkap dengan harga dan
honorariumGuru. Murid cuma lihat total gabungan, bukan
breakdown.
tabel →
closed di bulan sebelumnya → bikin
invoice draft. Admin review
→ issue → murid bayar.
cron endpoint →
mode = offline | online. Kalau online, zoomLink
wajib (MVP: admin/guru isi manual; auto-generate via Zoom API di-defer).
requirement →
withinGraceWindow).
konstanta →
transactions punya productType discriminator
— siap buat bank soal dan produk lain ke depan tanpa rombak
skema.
latar belakang →
Update bisnis flow yang signifikan setelah review operasi nyata:
harga (income XPrivate) dan honorariumGuru
(income guru). Yang ditampilkan ke murid cuma jumlah keduanya —
breakdown disembunyikan.
closed. Admin review, lalu issue
ke murid.
Tabel orders yang lama sekalian dirombak jadi
transactions — header generik buat semua produk, biar
nanti pas mau jualan bank soal atau produk lain, gak
perlu rombak skema lagi.
| Requirement | Keputusan |
|---|---|
| Harga 4-axis (grade × segmentasi × kategori × subject) |
price_list_items di-key sama empat axis itu.
Tiap baris punya harga + honorariumGuru
(int IDR). Yang ditampilin ke murid =
harga + honorariumGuru sebagai satu angka. Matrix
sparse — admin gak perlu fill semua kombinasi, cuma yang valid.
|
| Slot ↔ item matrix |
Per-slot bebas. Tiap lesson_sessions
punya priceListItemId sendiri. UI nyediain
weekly template helper ("tiap Senin = item X") biar
gak perlu fill manual tiap minggu.
|
| Segmentasi (Reguler / Reguler-Plus / Internasional) |
Per slot/sesi (bagian dari
priceListItemId). Murid bisa campur antar sesi di
satu bulan.
|
| Cadence package |
cadence_packages = catalog admin: nama,
slotsPerMonth, weeklyPattern (json
opsional). Murni container — tidak ada harga di sini, semua
harga datang dari matrix per slot.
|
| Les online via Zoom |
Masuk MVP. lesson_sessions.mode
= offline | online; kalau online,
zoomLink wajib. MVP: admin/guru isi manual. Defer:
auto-generate via Zoom API.
|
| Bayar les sebulan (postpaid) |
Cron invoicing.monthlyClose jalan tanggal 1 bulan
berikutnya. Sum semua sesi closed di bulan tagihan
tsb (snapshot harga + honorarium per sesi) → bikin
transactions status=draft. Admin
review → issue → murid bayar.
|
| Tambah sesi mid-month |
slot_add_requests (murid → admin). Approved =
admin lanjut bikin lesson_sessions baru di
subscription tsb. Otomatis ter-include di invoice akhir bulan.
|
| Menu reschedule | Dua jalur: (a) murid request → admin approve; (b) admin langsung reschedule. Tetep harus jatuh di billing month yang sama. |
| Durasi les 90 menit | Tetep, gak variabel. |
| Jam tambahan free 15 menit |
Grace overtime: 90 + 15 bebas (sampai 105 menit
total). Soft enforcement — cuma di-flag di presence.
|
| Konfirmasi mulai / selesai sesi | Cuma guru yang tap confirm start & end. Murid tidak confirm — di lapangan sering lupa / males, jadi operasi mandek. Tidak ada cek waktu: guru bisa tap kapan saja. Cuma urutan yang dijaga — tombol "selesai" baru muncul setelah "mulai" pernah ditekan. |
| Bukti foto les | Wajib. Guru harus upload minimal satu foto les bareng murid sebelum sesi bisa closed. Foto ini sekaligus jadi kontrol kehadiran (gantiin fungsi murid-confirm). |
| PWA installable | Web manifest + service worker. Install prompt diberi onboarding ringan — terutama iOS, karena push notifications cuma jalan kalau "Add to Home Screen" (iOS 16.4+). |
| Push notifications (reminder) |
Web Push API + VAPID. Triggers: (a) reminder pre-sesi
T-30 & T-15 menit ke guru +
murid; (b) guru: H+2 jam dari
scheduledAt tapi belum confirm end / belum upload
foto; (c) status update ke murid (invoice issued,
reschedule / slot-add approve/reject); (d) admin: notif
pas cron monthlyClose selesai.
|
| Camera (upload foto evidence) |
Browser API via
<input type="file" accept="image/*" capture="environment">.
Buka native camera device, balik sebagai File,
upload ke R2. Fallback ke galeri kalau permission ditolak.
Native wrapper (Capacitor) di-skip — overkill.
|
| Offline scope |
App shell + read cache. Service worker cache
static assets + foto evidence yang sudah upload. Data API
persisted via TanStack Query
(@tanstack/query-async-storage-persister ke
IndexedDB), SWR + refetchOnReconnect. Tidak ada
write queue di MVP.
|
| Stale data UI |
Tiap screen yang nge-render dari cache nampilin
Last synced: Xm ago (dari
query.dataUpdatedAt). Lewat threshold (5 menit
untuk data day-of, 30 menit untuk catalog) → tampil badge
"Data mungkin belum terbaru" + tombol refresh manual. Banner
offline global pas navigator.onLine = false.
|
Alur dari murid subscribe sampai invoice akhir bulan diverifikasi. Garis
putus-putus = jalur reschedule. Cron month-end di kanan-bawah yang
ngumpulin sesi closed jadi invoice draft.
flowchart TD
A([Murid pilih cadence package]) --> B[Subscribe
student_subscriptions]
B --> C[Materialize sessions
dari weeklyPattern]
C --> D[Slot fill: pilih item matrix
per sesi - subject + grade
+ segmentasi + kategori + mode]
D --> E[(lesson_sessions
priceSnapshot + honorariumSnapshot
terkunci)]
E --> F[Guru tap
confirm start]
F --> G[Sesi jalan
90 + 15 menit grace
online: Zoom, offline: ketemu]
G --> H[Guru tap
confirm end]
H --> P[Guru upload foto les
bareng murid - WAJIB]
P --> M[Guru submit presence
flag withinGraceWindow]
M --> N([Sesi closed])
E -. reschedule .-> R{Jalur reschedule}
R -- murid request --> S[Admin approve/reject]
R -- admin langsung --> T[adminReschedule]
S -- approved --> E
T --> E
E -. tambah sesi .-> Q[Murid request slot]
Q --> QA[Admin approve]
QA --> C
N --> CR[Cron tanggal 1
bulan berikutnya]
CR --> INV[(transactions
status: draft
sum harga + honorarium)]
INV --> ADM{Admin review}
ADM -- issue --> IS[(transactions
status: pending)]
ADM -- cancel --> XC[(transactions
status: cancelled)]
IS --> PAY[Murid upload bukti bayar]
PAY --> VF[(transactions
status: paid)]
VF --> AV{Admin verify}
AV -- verify --> OK[(transactions
status: verified)]
AV -- reject --> XR[(transactions
status: cancelled)]
classDef state fill:#fef3c7,stroke:#b45309,color:#1c1917;
classDef done fill:#dcfce7,stroke:#16a34a,color:#1c1917;
classDef bad fill:#fee2e2,stroke:#b91c1c,color:#1c1917;
class E,INV,IS,VF,OK state;
class N done;
class XC,XR bad;
Subscribe → materialize → slot fill → sesi jalan → cron month-end →
invoice → pembayaran. Audit log (lesson_session_events) ngerekam
tiap transisi.
Tiga perspektif: murid (subscribe + ikut les + bayar invoice), guru (jalanin sesi + lihat honorarium), admin (operasi bulanan + invoicing). Angka 1–5 = level kepuasan di tiap step.
journey
title Murid: subscribe & bayar postpaid
section Subscribe
Buka aplikasi: 5: Murid
Pilih cadence: 5: Murid
Set weekly template: 4: Murid
Confirm subscribe: 5: Murid
section Les jalan
Lihat jadwal: 5: Murid
Ikut sesi 90m: 5: Murid, Guru
Difoto bareng guru: 4: Murid, Guru
section Tambah/reschedule
Request tambah sesi: 3: Murid
Request reschedule: 3: Murid
Tunggu approve: 2: Murid
section Bayar akhir bulan
Terima invoice: 4: Murid
Lihat total: 4: Murid
Transfer: 3: Murid
Upload bukti: 4: Murid
Murid: subscribe di awal bulan, bayar setelah bulan jalan.
journey
title Guru: jalanin sesi & lihat honorarium
section Persiapan
Lihat jadwal: 5: Guru
Lihat zoom link: 4: Guru
section Sesi jalan
Tap confirm start: 5: Guru
Ajar 90m: 5: Guru, Murid
Tap confirm end: 5: Guru
Upload foto: 4: Guru
Submit presence: 4: Guru
section Akhir bulan
Lihat honorarium: 5: Guru
Lihat agregat: 4: Guru
Guru: confirm + foto + presence per sesi, lihat honorarium snapshot.
journey
title Admin: operasi bulanan + invoicing
section Catalog
CRUD matrix harga: 4: Admin
CRUD cadence package: 4: Admin
section Operasi harian
Approve slot-add: 3: Admin
Approve reschedule: 4: Admin
Cek session timeline: 5: Admin
section Invoicing
Cron jalan: 5: Admin
Review draft invoice: 4: Admin
Issue ke murid: 5: Admin
Verify bukti bayar: 4: Admin
section Payout
Lihat aggregate guru: 4: Admin
Export CSV: 3: Admin
Operasi admin sepanjang satu billing month.
packages/db/src/schema/)| Tabel | Aksi | Field penting |
|---|---|---|
price_list_items |
New (matrix) |
id, grade (enum 15), segmentasi (enum 3), kategori (enum),
subject (enum), harga (int IDR),
honorariumGuru (int IDR), isActive, timestamps.
Unique (grade, segmentasi, kategori, subject).
Sparse — gak perlu isi semua kombinasi, cuma yang valid.
|
cadence_packages |
New |
id, name, slotsPerMonth, weeklyPattern
(json nullable; per-weekday default mis.
[{dow:1, defaultPriceListItemId:"…"}]), isActive,
timestamps.
|
student_subscriptions |
New |
id, studentId, cadencePackageId, billingMonth
(YYYY-MM), status (active |
cancelled), timestamps. Unique
(studentId, billingMonth). Replace lama lesson_packages.
|
lesson_sessions |
Reshape |
orderId → subscriptionId; tambah
priceListItemId, priceSnapshot (int),
honorariumSnapshot (int) — locked saat session
creation; subject tetap di sini (denormalized
snapshot dari item matrix buat fast read); tambah
mode enum (offline default,
online), zoomLink (text, required
kalau online); tambah rescheduledFromSessionId;
drop studentConfirm*.
|
session_evidence |
Tighten |
Sudah ada. Sekarang wajib: minimal 1 row
sebelum lesson_sessions.status bisa pindah ke
completed/closed. Aturan di app-level
(cek di submitPresence).
|
lesson_session_events |
New (audit) |
Audit log. id, sessionId, actorUserId,
eventType (confirm_start | confirm_end
| evidence_uploaded | presence_submitted
| slot_filled | reschedule_requested |
reschedule_approved | reschedule_executed
| status_changed), fromState, toState, payload
(json, nullable), createdAt. Append-only.
|
reschedule_requests |
New |
id, sessionId, initiatedBy (student | admin),
requestedAt, proposedScheduledAt, status, reviewedByAdminId,
reviewedAt, reason. Aturan: stay in same
billingMonth.
|
slot_add_requests |
New |
id, subscriptionId, studentId, reason, status
(pending | approved |
rejected), reviewedByAdminId, reviewedAt,
createdAt. Approve = admin lanjut bikin sesi baru.
|
transactions |
Rename + postpaid |
dari orders: id, transactionNo
(TXN-YYYYMMDD-XXXX), studentId,
productType enum (lesson_subscription
sekarang; reserved: question_bank, …),
subscriptionId (nullable buat produk non-lesson),
totalAmount (int, locked saat issue), status
(draft → pending → paid →
verified | cancelled), billingMonth,
notes, issuedAt, paidAt, verifiedAt, timestamps.
Cron-generated as draft.
|
payment_proofs |
Repoint |
orderId → transactionId; satu proof
per transaction.
|
orders |
Drop |
belum live, belum ada data — diganti transactions
+ student_subscriptions
|
push_subscriptions |
New |
id, userId, endpoint (unique), p256dh, auth, userAgent
(nullable), createdAt, lastSeenAt. Index on
userId. Satu row per device user yang opt-in
ke push.
|
File baru: packages/shared/src/constants/lessons.ts
LESSON_DURATION_MIN = 90LESSON_GRACE_MIN = 15 — overtime free, max 105 menit total
"1", "2",
…, "12", "mahasiswa", "umum",
"profesional"
"reguler",
"reguler_plus", "internasional"
"kelas_reguler", "program_khusus",
"persiapan_sertifikasi", "bahasa_asing",
"persiapan_tes_cpns_bumn_kedinasan", "musik",
"komputer"
"matematika", "fisika", "kimia",
"biologi", "ipa", "ips",
"bahasa_indonesia", "bahasa_inggris",
"bahasa_mandarin", "bahasa_jepang",
"calistung", "musik_piano",
"musik_gitar", "komputer_dasar",
"coding", "toefl", "ielts",
"sat", "utbk", "cpns" — populate
sesuai kebutuhan
"offline", "online"
apps/api)pricelist.list (public, filter by grade / segmentasi /
kategori / subject), pricelist.upsert,
pricelist.deactivate (admin)
cadencePackages.list (public),
cadencePackages.upsert,
cadencePackages.deactivate (admin)
subscriptions.create (murid) — pilih cadence package +
billingMonth. Tidak ada bayar di sini.
subscriptions.adminCreate (admin on behalf of student)
subscriptions.requestAdditionalSlot (murid) →
slotAdd.approve / slotAdd.reject (admin)
lessonSessions.materializeFromTemplate (admin/cron pas
subscription dibikin) — given subscription + weeklyPattern, bikin
sesi-sesi untuk bulan tsb; tiap sesi pre-filled dengan
defaultPriceListItemId dari template (null kalau gak
ada default).
lessonSessions.updateSlot (admin / murid sebelum sesi
ongoing) — set/ubah priceListItemId,
subject, mode, zoomLink.
Re-snapshots priceSnapshot +
honorariumSnapshot dari item matrix yang dipilih.
lessonSessions.requestReschedule (murid),
lessonSessions.adminReschedule (admin langsung),
reschedule.approve / reschedule.reject (admin)
proposedScheduledAt wajib di
billingMonth yang sama.
lessonSessions.teacherConfirmStart — cuma guru,
tidak ada cek waktu.
lessonSessions.teacherConfirmEnd — cuma guru,
cuma aktif kalau teacherConfirmStartAt sudah ada
(urutan logis, bukan time-based).
lessonSessions.uploadEvidence (guru) — wajib minimal 1
foto sebelum sesi bisa closed.
lessonSessions.submitPresence (guru): nge-block kalau
belum ada evidence. Tambah withinGraceWindow
(read-only, true kalau durationActualMs ≤ 105 min).
invoicing.monthlyClose — jalan
tanggal 1 bulan berikutnya (UTC+7). Buat tiap subscription
active yang billingMonth-nya baru tutup: sum
(priceSnapshot + honorariumSnapshot) atas semua sesi
closed di bulan tsb → bikin transactions
row dengan status = draft. Notify admin.
transactions.list (admin filterable; murid lihat
punyanya sendiri)
transactions.issue (admin): draft →
pending, kirim ke murid.
transactions.uploadProof (murid): pending
→ paid
transactions.verify (admin): paid →
verified
transactions.cancel (admin, dengan reason)
teacherPayouts.summaryByMonth (admin) — aggregate per
guru: sum honorariumSnapshot atas sesi yang ada di
transaksi verified di bulan tsb. (Actual disbursement
defer.)
lesson_session_events (append-only). Source of
truth buat "siapa nekan apa kapan".
lessonSessions.getTimeline (admin) — return semua
event buat satu sesi, urut by createdAt, dengan info
actor user-nya. Ini yang nge-power timeline view di backoffice.
push.subscribe (any role) — simpan endpoint + keys ke
push_subscriptions. Idempotent on
endpoint.
push.unsubscribe — hapus row by endpoint (atau by
userId untuk semua device user).
push.testFire (admin/dev) — kirim push uji ke device
tertentu.
notifications.upcomingSessions
— jalan tiap 5 menit. Cari sesi dengan scheduledAt
di window T-30 dan T-15. Fire push ke guru + murid. Track flag di
lesson_session_events
(push_reminder_30m_sent /
push_reminder_15m_sent) supaya tidak dobel.
notifications.unclosedSessions
— jalan tiap 30 menit. Cari sesi yang
scheduledAt + 2h sudah lewat tapi belum
closed. Fire push ke guru. Event type:
push_unclosed_alert_sent.
transactions.issue → murid (invoice issued),
reschedule.approve/reject,
slotAdd.approve/reject → murid,
invoicing.monthlyClose (cron) → admin pas drafts
selesai dibikin.
apps/web)manifest.webmanifest): name, short_name, icons
(192/512/maskable), display: standalone, theme_color,
background_color, start_url.
staleWhileRevalidate) + foto evidence yang sudah
di-load (cacheFirst). Tidak cache
JSON oRPC response di SW — itu diserahkan ke TanStack Query.
@tanstack/query-async-storage-persister backed by
IndexedDB. refetchOnReconnect: true,
refetchOnWindowFocus selektif (true untuk day-of,
false untuk catalog statis).
navigator.onLine = false.
query.dataUpdatedAt dari
TanStack Query). Threshold stale: 5 menit untuk
data day-of (detail sesi, jadwal hari ini),
30 menit untuk data statis (catalog, profile).
Lewat threshold → badge "Data mungkin belum terbaru" + tombol
refresh manual.
<input type="file" accept="image/*" capture="environment">.
Permission denied → fallback ke galeri.
displayPrice = harga + honorariumGuru sebagai satu
angka (no breakdown).
billingMonth aktif.
online: lihat zoomLink, klik to join.price_list_items
dengan filter per axis + bulk edit. View harga + honorariumGuru
lengkap.
weeklyPattern editor.
lesson_sessions → lihat kronologi event-nya berurutan
(siapa, kapan, transisi state apa). Data dari
lesson_session_events. Tool utama buat rekonsiliasi
dispute.
Karena belum live (belum ada data production):
orders + lama
lesson_packages (kalau ada); bikin
price_list_items (matrix), cadence_packages,
student_subscriptions, lesson_session_events,
reschedule_requests, slot_add_requests;
reshape lesson_sessions + payment_proofs;
bikin transactions fresh.
apps/docs/overview/product.md — model postpaid subscription
+ matrix, online masuk MVP
apps/docs/technical/user-flow.md — flow subscribe →
materialize → slot fill → day-of → month-end invoice
apps/docs/technical/database.md — skema matrix +
transactions discriminator buat produk masa depan
.agents/database.md kalau ada konvensi skema yang kena
closed
telat (guru baru tap end keesokan harinya, dst)?
categories / subjects table? Subject list
cenderung paling sering nambah.
closed tetap masuk invoice; scheduled
sesi di-cancel.
Item yang sudah dipertimbangkan tapi sengaja di-defer karena cakupan implementasinya besar dan bukan blocker buat MVP.
calendar.events).
lesson_sessions push
jadi event di calendar user; reschedule → update event; cancel →
delete event.
zoomLink (untuk online) atau location (untuk offline),
subject, materi.
google_calendar_tokens (atau extend
profiles).
packages/shared
(matrix, cadence_packages, student_subscriptions, lesson_session_events,
slot_add_requests; transactions reshape;
lesson_sessions reshape)
monthlyClose + admin invoice queue + payment proof
flow
zoomLink editorpush_subscriptions schema +
subscribe/unsubscribe endpoints + permission flow + crons
(upcomingSessions, unclosedSessions) +
inline push triggers