Phase 4 §4: payment-before-pair for returning users + Maestro suite

Stages 5.1, 5.3, 5.4 of the returning-user flow rework. All three §4
entry paths now require payment BEFORE pairing, matching the updated
mermaid spec.

* Spec (requirement/flow_customer.mermaid.md §4): payment block converges
  three call-sites (bestie-yang-udah-kenal-online, bestie-baru,
  offline-popup → cari bestie lain). PairRoute dispatches lama → targeted
  pair, baru/cari-lain → §3 blast. §3 retains its post-payment-shared
  contract.

* Stage 5.1 (client_app): PaymentDraft carries targetedMitraId +
  topicSensitivity. bestie_history_list seeds the draft + pushes
  /payment/entry (was legacy /payment). searching_screen branches on
  draft.targetedMitraId for blast-vs-targeted dispatch.
  payment_entry uses resetExceptTarget(); bestie_choice_sheet + home
  _onCurhatBestieBaruPressed call explicit reset() before push so
  the keepAlive draft can't leak stale targeting into a blast.

* Stage 5.3 (client_app): new BestieOfflineVariant.prePayReturning.
  Bestie-history-list _BestieRow splits tappable from dim so offline
  rows render dimmed but route taps into the popup. CTA "cari bestie
  lain" resets the draft + pushes /payment/entry.

* Stage 5.4 (client_app): deleted legacy /payment route,
  payment_screen.dart, payment_notifier.dart(+.g.dart). router cleaned.

* Tests (requirement/phase4-customer-flow.md + client_app/.maestro/):
  six Maestro flows TS-01..TS-06 covering every §4 branching point,
  all passing end-to-end. Shared onboarding prelude under
  .maestro/subflows/. New helper scripts: accept_latest_pending,
  force_mitra_offline, force_other_mitra_online,
  reset_all_mitras_online, mitra_accept_latest_internal. New backend
  _test endpoints to match. /reset-phone now cascade-deletes
  customer_transactions (FK was blocking). /force-pairing-timeout
  branches targeted (RETURNING_CHAT_TIMEOUT via
  expireTargetedPairingRequest, now exported) vs blast (PAIRING_FAILED).
  seed_history_session also outputs MITRA_NAME_RE (regex-escaped) for
  reliable selectors against display names containing regex specials.

* mitra_app: dispose-during-deactivate guardrail for back-press on the
  mitra chat screen after the customer's goodbye message. Pending real
  emulator repro verification (carried over from 2026-05-15).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 20:25:15 +08:00
parent 1c9d81d81d
commit e09f76ceb6
32 changed files with 1755 additions and 680 deletions

View File

@@ -599,3 +599,282 @@ OTP smoke (existing `01_smoke.yaml`) must keep passing — uses the dev-only
10. **Maestro coverage** + visual regression sweep.
Each block is shippable independently — they share no breaking schema change.
---
# Test Scenarios
Manual reproduction checklists for Phase 4 customer flows. Tick boxes as
verified. Cluster tag `[C]` = client_app, `[BE]` = backend setup.
> **Coverage map** — these scenarios collectively exercise every branching
> point in §4 of `flow_customer.mermaid.md`:
>
> | Branching point | Scenario(s) |
> |---|---|
> | Choice: `bestie yang udah kenal` vs `bestie baru` | TS-01/02/03/05/06 vs TS-04 |
> | CheckOnline: yes vs no (pre-pay) | TS-01/05/06 vs TS-02/03 |
> | OfflinePopup (pre-pay): `cari bestie lain` vs `tanya admin` | TS-02 vs TS-03 |
> | PayStat: `paid` vs `timeout 20 min` | TS-01/02/04/06 vs TS-05 |
> | PairRoute: `lama (Targeted)` vs `baru / cari lain (BlastFlow)` | TS-01/05/06 vs TS-02/04 |
> | TargetedRes: `accept` vs `reject/timeout` | TS-01/05 vs TS-06 |
## TS-01 — Returning user re-pays an online bestie (lama happy path)
**Flow:** §4 `Choice → "bestie yang udah kenal" → CheckOnline (yes) → PickMethod → … → paid → PairRoute (lama) → Targeted → accept → S10`
**Affects:** `client_app`, `backend`.
**Goal:** Confirm the returning-user flow gates on payment and routes the
same picked mitra through targeted pairing (not blast).
**Pre-reqs**
- [ ] **[BE]** Backend reachable; test mitra signed in + online (renders
`ONLINE` pill in history list).
- [ ] **[BE]** Free-trial config OFF for the test customer (otherwise the
paywall path replaces the QRIS flow).
- [ ] **[C]** `client_app` pointed at local backend
(`--dart-define=API_BASE_URL=http://192.168.88.247:3000`); test customer
has at least one closed session with the test mitra so they appear in
bestie history.
**Steps**
1. [ ] **[C]** From home (returning state), tap `curhat sama bestie baru`
→ Bestie Choice Sheet appears.
2. [ ] **[C]** Tap `bestie yang udah kenal` → bestie history list opens;
the test mitra row shows `ONLINE` pill (not dimmed).
3. [ ] **[C]** Tap the test mitra row → app navigates to `/payment/entry`
(PickMethod). **The legacy `/payment` route is no longer reachable as
of Stage 5.4.**
4. [ ] **[C]** Pick `chat` (or `voice call`) → PickDuration.
5. [ ] **[C]** Pick any tier (e.g. `5 Menit`) → `/payment/method` (the
"cara bayar" screen).
6. [ ] **[C]** Pick a payment method (e.g. QRIS) → tap `Bayar` →
`/payment/waiting` (20-min QRIS countdown).
7. [ ] **[BE]** Manually confirm the payment via
`POST /api/client/payment-sessions/:id/confirm` (or use the mock
helper script).
8. [ ] **[C]** App auto-advances through notif-gate and lands on
`/chat/waiting-targeted/<mitraId>` ("Menunggu bestie tertentu" with
20s overlay).
9. [ ] **[mitra_app]** Accept the incoming targeted request.
10. [ ] **[C]** Customer lands on `/chat/session/:id` (S10 Chat Room) —
WS open, session timer running.
**Expected result**
- [ ] **[BE]** `payment_sessions` row has
`targeted_mitra_id = <test mitra id>` and `status = 'confirmed'`.
- [ ] **[BE]** `chat_sessions` row created with the same `mitra_id`; no
blast log entries.
- [ ] **[C]** Chat opens against the original mitra; no fallback to
`/chat/searching`.
**Notes / known gaps**
- Maestro flow `client_app/.maestro/flows/10_returning_repays.yaml` was
written against the pre-Stage-5.1 screen graph and needs a rewrite —
its selectors target the deleted legacy `/payment` screen
(`Chat lagi dengan <mitraName>` app-bar title, `MENUNGGU JAWABAN`
intermediate). When automating, rewrite this flow to walk the new
multi-screen path described above.
- Stage 5.4 (2026-05-18) deleted the legacy `/payment` route +
`payment_screen.dart`. Any selector still expecting the legacy app-bar
title is stale.
---
## TS-02 — Returning user picks offline bestie, "cari bestie lain" → blast
**Flow:** §4 `Choice → "bestie yang udah kenal" → CheckOnline (no) → OfflinePopup (pre-pay) → "cari bestie lain" → PickMethod → … → paid → PairRoute (baru) → BlastFlow → S10`
**Affects:** `client_app`, `backend`.
**Goal:** Verify the `BestieOfflineVariant.prePayReturning` popup fires
when the picked bestie is offline pre-payment, and that "cari bestie lain"
routes through a fresh blast-payment flow with the targeted intent cleared.
**Pre-reqs**
- [ ] **[BE]** Test mitra from customer's history is **offline** (signed
out or heartbeat expired — row shows no `ONLINE` pill in history list).
- [ ] **[BE]** At least one OTHER mitra is online (so the blast can match).
- [ ] **[BE]** Free-trial OFF.
**Steps**
1. [ ] **[C]** From home, tap `curhat sama bestie baru` → Bestie Choice
Sheet.
2. [ ] **[C]** Tap `bestie yang udah kenal` → history list; the test mitra
row is **dimmed** (offline styling preserved as of Stage 5.3).
3. [ ] **[C]** Tap the dimmed row → `BestieOfflinePopup`
(`prePayReturning` variant) appears showing the mitra's name. Two
CTAs: `cari bestie lain` and `tanya admin`.
4. [ ] **[C]** Tap `cari bestie lain` → popup closes; app navigates to
`/payment/entry`. Payment draft has been `reset()` (no stale
`targetedMitraId`).
5. [ ] **[C]** Walk PickMethod → PickDuration → PayMethod → Pay →
`/payment/waiting`.
6. [ ] **[BE]** Manually confirm payment.
7. [ ] **[C]** App routes to `/chat/searching` (NOT
`/chat/waiting-targeted/...`).
8. [ ] **[mitra_app]** A different online mitra receives the blast and
accepts.
9. [ ] **[C]** Customer lands on `/chat/session/:id` with the new mitra.
**Expected result**
- [ ] **[BE]** `payment_sessions` row has `targeted_mitra_id IS NULL`
(draft was reset before push to `/payment/entry`).
- [ ] **[C]** Chat opens with the fallback mitra, not the original
offline one.
---
## TS-03 — Returning user picks offline bestie, "tanya admin" (escape)
**Flow:** §4 `Choice → "bestie yang udah kenal" → CheckOnline (no) → OfflinePopup (pre-pay) → "tanya admin" → AdminSheet (terminal)`
**Affects:** `client_app`.
**Goal:** Confirm the escape hatch — the user can leave the offline-popup
flow without paying by tapping "tanya admin", and no payment row is
created.
**Pre-reqs**
- [ ] Same as TS-02 (offline test mitra in customer's history).
**Steps**
1. [ ] **[C]** Reach the `BestieOfflinePopup` (`prePayReturning` variant)
via TS-02 steps 1-3.
2. [ ] **[C]** Tap `tanya admin` → popup closes; admin sheet opens with
WhatsApp / Telegram contact options.
3. [ ] **[C]** Dismiss the admin sheet → user returns to the bestie
history list.
**Expected result**
- [ ] **[BE]** No new `payment_sessions` row created during this scenario.
- [ ] **[C]** Payment draft state unchanged (no `targetedMitraId`, no
`paymentId`). User can re-enter the flow normally afterward.
---
## TS-04 — Returning user picks "bestie baru" → blast happy path
**Flow:** §4 `Choice → "bestie baru" → PickMethod → … → paid → PairRoute (baru) → BlastFlow → S10`
**Affects:** `client_app`, `backend`.
**Goal:** Confirm the "bestie baru" branch routes through payment FIRST,
then blasts to all online mitras (no targeting).
**Pre-reqs**
- [ ] **[BE]** At least one online mitra (for blast match).
- [ ] **[BE]** Free-trial OFF.
- [ ] **[C]** Returning customer (has session history → Bestie Choice
Sheet renders both options).
**Steps**
1. [ ] **[C]** From home, tap `curhat sama bestie baru` → Bestie Choice
Sheet.
2. [ ] **[C]** Tap `bestie baru` → app navigates to `/payment/entry`.
Draft is explicitly `reset()` on this branch (clears any stale
`targetedMitraId` per Stage 5.1 Risk #4 mitigation).
3. [ ] **[C]** Walk PickMethod → PickDuration → PayMethod → Pay →
`/payment/waiting`.
4. [ ] **[BE]** Confirm payment.
5. [ ] **[C]** App routes to `/chat/searching` (NOT
`/chat/waiting-targeted/...`).
6. [ ] **[mitra_app]** An online mitra accepts the blast.
7. [ ] **[C]** Customer lands on `/chat/session/:id`.
**Expected result**
- [ ] **[BE]** `payment_sessions` row has `targeted_mitra_id IS NULL`.
- [ ] **[C]** Searching screen shows briefly; chat opens against whichever
mitra accepted.
---
## TS-05 — QRIS payment expired → retry preserves targeting
**Flow:** §4 `PickMethod → … → WaitPay → PayStat (timeout 20 min) → PayExpired → Pay (retry) → paid → PairRoute (lama) → Targeted → S10`
**Affects:** `client_app`, `backend`.
**Goal:** Verify the QRIS 20-min expired retry path works for a returning
targeted attempt. The `targetedMitraId` on the draft must survive the
retry (no need to re-pick mitra or duration) — this is the
`resetExceptTarget` invariant from Stage 5.1.
**Pre-reqs**
- [ ] **[BE]** Backend reachable; online test mitra (from customer's
history).
- [ ] **[BE]** Either the sweeper marks `pending → expired` after 20 min,
or the test uses a shortened TTL / direct `UPDATE` to force expiry.
**Steps**
1. [ ] **[C]** Walk TS-01 steps 1-6 to reach `/payment/waiting` for a
targeted attempt against the test mitra.
2. [ ] **[BE]** Wait for or force the `pending → expired` transition on
the payment row.
3. [ ] **[C]** Polling sees `status = 'expired'` → app routes to
`/payment/expired/:paymentId`.
4. [ ] **[C]** Tap the retry CTA → app routes back to `/payment/method`
(NOT all the way to PickMethod; draft preserved via
`resetExceptTarget`).
5. [ ] **[C]** Re-pick payment method → tap `Bayar` → new
`/payment/waiting`.
6. [ ] **[BE]** Confirm the new payment.
7. [ ] **[C]** App routes to `/chat/waiting-targeted/<mitraId>` for the
**same mitra** as step 1 (no re-pick required).
**Expected result**
- [ ] **[BE]** Original `payment_sessions` row has `status = 'expired'`.
**New** row created with `status = 'confirmed'`. Both rows have the
same `targeted_mitra_id`.
- [ ] **[C]** Targeted intent survives retry; chat opens with the
original picked mitra.
**Variant note:** the same retry path applies to the blast branch (TS-02 /
TS-04) — draft has `targetedMitraId IS NULL` throughout, retry routes
back to `/payment/method`, blast fires after re-confirm. Worth a quick
sanity check if behavior diverges.
---
## TS-06 — Targeted request fails post-payment → fallback to blast
**Flow:** §4 `Targeted → TargetedRes (reject / timeout) → OfflinePopup (post-pay, returning variant) → "cari bestie lain" → fallback-to-blast → §3 BlastFlow → S10`
**Affects:** `client_app`, `backend`.
**Goal:** Verify the post-payment fallback path. After paying for a
targeted pair, if the picked mitra rejects or doesn't answer within 20s,
the customer can fall back to blast WITHOUT a second payment.
**Pre-reqs**
- [ ] **[BE]** Online test mitra (from history) AND at least one OTHER
online mitra (for the blast fallback).
**Steps**
1. [ ] **[C]** Walk TS-01 steps 1-8 to reach
`/chat/waiting-targeted/<mitraId>`.
2. [ ] **[mitra_app]** Reject the incoming targeted request (or do
nothing for the 20s countdown).
3. [ ] **[C]** Targeted-waiting screen detects the failure →
`BestieOfflinePopup` (`returning` variant, post-pay) appears with
`canFallbackToBlast: true`. CTAs: `cari bestie lain` and `tanya admin`.
4. [ ] **[C]** Tap `cari bestie lain` → app calls
`POST /api/client/chat/chat-requests/:paymentSessionId/fallback-to-blast`
→ routes to `/chat/searching`.
5. [ ] **[mitra_app]** A DIFFERENT online mitra accepts the blast.
6. [ ] **[C]** Customer lands on `/chat/session/:id` with the new mitra.
**Expected result**
- [ ] **[BE]** Same `payment_sessions` row is reused (still
`status = 'confirmed'`); customer is **not** charged a second time.
- [ ] **[BE]** `chat_sessions` row created with the fallback mitra
(NOT the original `targeted_mitra_id`).
- [ ] **[C]** Chat opens with the fallback mitra; no fresh payment
screens shown.
**Variant note:** the "tanya admin" CTA on this same popup is a terminal
escape (same shape as TS-03), but post-payment — the customer has already
paid, so this is effectively abandoning a paid session. Worth confirming
the UX (probably a confirmation prompt) and whether the payment is
refunded / converted to credit.