Phase 3.7: paid pairing flow + returning chat + extension flip
- Backend: payment_sessions + pairing_failures tables; payment.service.js and pairing-failure.service.js (new); rewritten pairing.service.js (payment-gated blast + targeted "Curhat lagi" + cancel + fallback); rewritten extension.service.js (data-driven auto-approve with offline safeguard, charge-at-approval); pricing.service.js (extension tiers without free trial); mitra-status.service.js (countAvailableMitras cached path); 60s sweeper for stale payment sessions - Backend routes: client.payment.routes, client.mitra-availability.routes, internal/failed-pairings.routes; client.chat.routes rewritten for payment-gated start + /returning + /cancel + /fallback-to-blast; internal/config.routes adds 4 new keys with Valkey invalidate publish - client_app: mitra-availability poll, payment screen + notifier, pairing notifier rewrite (PairingTargetedWaiting + PairingFailed states), targeted-waiting overlay + bestie-unavailable dialog, "Curhat lagi" CTA, failed-pairing terminal, extension via payment-session - mitra_app: PairingRequestType enum, returning-chat 20s countdown auto-dismiss, extension card "otomatis disetujui" copy - control_center: 4 new config rows in Settings, Failed Pairings page (filter + paginate + action menu), sidebar + route registered - Test infrastructure: Vitest backend (7/7 pass), Playwright CC (4/4 pass), Maestro mobile scaffold (CLI install pending) - Bugs found via Playwright + fixed: LoginPage labels not associated with inputs (a11y); backend internal CORS missing PATCH/PUT/DELETE in allow-methods (silent settings breakage in browsers since Stage 4) - Docs: phase3.7.md PRD, phase3.7-plan.md, phase3.7-questions.md (Q&A), phase3.7-testing.md (E2E checklist), phase3.7-test-run-2026-05-03.md (today's run results) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
308
requirement/phase3.7.md
Normal file
308
requirement/phase3.7.md
Normal file
@@ -0,0 +1,308 @@
|
||||
# PRD: Phase 3.7 — Paid Pairing Flow + Returning-Chat + Extension Flip
|
||||
|
||||
# Overview
|
||||
|
||||
**Goal:** Reshape the customer pairing path so that **payment precedes blast** (today: blast fires immediately on CTA, no payment in path), gate the home CTA on real-time mitra availability, add a "chat with the same bestie again" path from chat history, give mitras a short approval window on returning-chat requests (20s, auto-reject), and **flip extension behavior** from auto-reject to auto-approve (10s).
|
||||
|
||||
**Success looks like:**
|
||||
- A customer can only tap "Mulai Curhat" when at least one mitra is online and has spare capacity.
|
||||
- Tapping the CTA leads to a payment screen → confirmation → blast → mitra accept → chat.
|
||||
- A customer can re-engage a previous bestie directly from chat history; that bestie has 20s to confirm before auto-reject.
|
||||
- An in-progress chat extension is auto-approved if the mitra doesn't respond within 10s (current behavior auto-rejects); customer is charged at approval moment, never before.
|
||||
- Every payment that does not result in a chat is persisted with a cause-tag and surfaced in the control center for manual review/refund.
|
||||
|
||||
**Affects:** `client_app`, `mitra_app`, `backend`, `control_center`
|
||||
|
||||
## Background
|
||||
|
||||
- **Phase 2** introduced instant blast on CTA tap — no payment in path, first-mitra-to-accept wins via DB-level uniqueness.
|
||||
- **Phase 3** introduced WebSocket chat, an extension flow that **auto-rejects** on timeout, mock pricing service (free-trial + tiered prices), and FCM push for incoming requests.
|
||||
- **Phase 3.5** exposed chat-request history for mitras with a CTA badge on home.
|
||||
- **Pricing remains mocked** for Phase 3.7 — no real Xendit; real pricing/tiers + real Xendit are deferred to a later phase. (See memory: "Pricing Still Mocked Through Phase 3.7".)
|
||||
- **Anonymity rule unchanged** (mitra always sees customer call_name; phone+email private; no legacy masking).
|
||||
|
||||
## Phase numbering note
|
||||
|
||||
Originally captured under "Phase 4" filename. Renamed to **Phase 3.7** at user request — same scope, different number.
|
||||
|
||||
---
|
||||
|
||||
# Functional Requirements
|
||||
|
||||
## 1. CTA Gating on Customer Home (mitra availability)
|
||||
|
||||
### 1.1 Polling
|
||||
- The "Mulai Curhat" CTA on customer home polls `GET /api/client/mitra-availability` **every 5 seconds** while the home screen is **foregrounded**. Polling pauses on background and resumes on foreground.
|
||||
- No background poll. No FCM-triggered refresh. Pull-to-refresh on home triggers an immediate poll.
|
||||
|
||||
### 1.2 Endpoint
|
||||
- **New:** `GET /api/client/mitra-availability`
|
||||
- **Response:** `{ "available": boolean, "count"?: number }`
|
||||
- `available = true` ⟺ **at least one mitra is online AND below their max-customer capacity** (per existing `app_config.max_customers_per_mitra`).
|
||||
- `count` is optional, included for control-center debugging only — the client must read only `available`.
|
||||
- **Implementation constraint:** the endpoint must compute availability from **Valkey only** (existing online-status keys + active-session counters). It must NOT issue per-poll Postgres queries.
|
||||
|
||||
### 1.3 CTA visual state
|
||||
| State | Visual | Subtitle |
|
||||
|---|---|---|
|
||||
| `available = true` | Enabled (default style) | Existing copy ("Mulai cerita ke bestie") |
|
||||
| `available = false` OR poll failed | Greyed-out, non-tappable | "Belum ada bestie tersedia" |
|
||||
|
||||
- Binary only — **never show the count** (e.g. "3 bestie tersedia") in the customer UI.
|
||||
- On poll failure (network error, 5xx, timeout): default to greyed-out — do **not** keep the last-known state.
|
||||
|
||||
---
|
||||
|
||||
## 2. New Pairing Flow: CTA → Payment → Blast → Accept → Chat
|
||||
|
||||
### 2.1 Flow overview
|
||||
```
|
||||
[Customer Home: CTA enabled]
|
||||
↓ tap
|
||||
[Payment Screen]
|
||||
↓ confirm (mock)
|
||||
[Backend: payment_session row created (status=confirmed)]
|
||||
↓
|
||||
[Existing "Searching for bestie..." screen]
|
||||
↓ blast to all available mitras
|
||||
↓ first accept wins (existing DB uniqueness)
|
||||
[Chat starts — existing WS chat flow]
|
||||
```
|
||||
|
||||
### 2.2 Payment screen (mocked)
|
||||
- New screen: **Pilih Sesi & Bayar** (or whatever copy fits the existing style).
|
||||
- Reuses the existing **mock pricing service** (Phase 3 tiers + free trial). No new pricing tables, no Xendit SDK, no webview.
|
||||
- Layout: tier/duration picker + "Total" line + primary CTA "Bayar".
|
||||
- **Free-trial-eligible customer:** the screen shows the tier picker but the Total displays "Gratis" / Rp 0. Primary CTA reads "Mulai" (still goes through the same confirm step — does **not** skip directly to blast).
|
||||
- On confirm tap → backend creates a `payment_session` row with `status = confirmed`, returns `{ payment_session_id }`.
|
||||
- Customer is then routed to the existing "Searching for bestie..." screen, carrying `payment_session_id`.
|
||||
|
||||
### 2.3 Payment-session lifecycle
|
||||
- **Status enum:** `pending` (screen open, not confirmed yet) → `confirmed` (confirm tap succeeded) → `consumed` (chat session started against this payment) | `failed_pairing` (no mitra accepted) | `abandoned` (customer closed payment screen) | `expired` (TTL elapsed before confirm).
|
||||
- **TTL:** payment session expires after `payment_session_timeout_minutes` (default **20 min**, **CC-configurable**) from creation. Background sweeper transitions stale `pending` rows to `expired`.
|
||||
- **Abandonment:** if the customer closes the payment screen / backs out before tapping confirm, the row is left in `pending` (will eventually expire). No active rollback needed.
|
||||
|
||||
### 2.4 Blast & accept
|
||||
- Once the screen receives `payment_session_id`, the existing Phase 2 blast logic runs as today, with one change: the resulting `chat_session` row carries a `payment_session_id` foreign key.
|
||||
- **Idempotency unchanged:** the existing DB-level unique constraint on session acceptance (Phase 2) ensures first-mitra-wins.
|
||||
- On successful accept → `payment_session.status = consumed`, chat begins (existing WS flow).
|
||||
|
||||
### 2.5 Failed pairing — single consistent model (also applies to Sections 5 and 6)
|
||||
When a payment is `confirmed` but no chat starts (no mitra accepts within blast window, all mitras explicitly reject, targeted mitra rejects/auto-rejects, payment expires before consume, customer cancels mid-search):
|
||||
|
||||
1. The `payment_session` row stays in DB; status transitions to `failed_pairing` (or `expired` / `abandoned` as appropriate).
|
||||
2. A **failed-pairing event** row is written to a new `pairing_failures` table (or equivalent) with a **`cause_tag`** for filter/audit:
|
||||
- `no_mitra_available` — no mitra accepted within the general blast window
|
||||
- `all_mitras_rejected` — every blasted mitra explicitly declined
|
||||
- `targeted_mitra_offline` — returning-chat target was offline at request time
|
||||
- `targeted_mitra_rejected` — returning-chat target explicitly declined within the 20s window
|
||||
- `targeted_mitra_timeout` — returning-chat target did not respond within 20s
|
||||
- `payment_session_expired` — TTL elapsed before customer confirmed or before chat started
|
||||
- `customer_cancelled` — customer abandoned during searching/waiting
|
||||
3. Surfaced to the **control center** in a new **"Failed Pairings"** review screen (filterable by `cause_tag` and date), where an operator can manually mark a row as: refunded / credit issued / no-action. The actual refund/credit operation is mock for Phase 3.7 (no real money movement) — operator action just records the decision in the row.
|
||||
4. **Customer-facing copy** (used everywhere a failed pairing terminates the flow): _"Maaf, kami tidak bisa menemukan bestie untuk sesimu. Tim kami akan menghubungimu segera."_ — with a "Kembali ke beranda" CTA. This copy will be revised in a later phase; ship the placeholder for now.
|
||||
|
||||
### 2.6 Customer screen during blast
|
||||
- Reuse the existing Phase 2 "Searching for bestie..." screen as-is. Real visual design will land in a later UI-design pass.
|
||||
|
||||
### 2.7 Blast timeout
|
||||
- The general blast timeout reuses the Phase 2 value. If it is not already CC-configurable, expose it as `pairing_blast_timeout_seconds` in the same CC config screen.
|
||||
|
||||
---
|
||||
|
||||
## 3. "Curhat lagi" — Returning Chat from Chat History
|
||||
|
||||
### 3.1 CTA placement
|
||||
- Every row in the customer's chat-history list gets a **"Curhat lagi"** CTA. Per-row, not per-unique-mitra.
|
||||
- The CTA targets the mitra of that specific session.
|
||||
|
||||
### 3.2 Flow
|
||||
```
|
||||
[Chat History Row: "Curhat lagi"]
|
||||
↓ tap
|
||||
[Payment Screen — same as Section 2.2]
|
||||
↓ confirm
|
||||
[Backend: targeted-request created against this mitra]
|
||||
↓ FCM push + WS event to that mitra
|
||||
[Customer overlay: "Menunggu konfirmasi bestie..." + Cancel]
|
||||
↓ within 20s: mitra accepts → chat starts
|
||||
↓ within 20s: mitra rejects OR 20s expires (auto-reject)
|
||||
↓ at request time: mitra is offline OR at-capacity-not-mid-session
|
||||
[Popup: "Bestie sedang tidak online" (or analogous)
|
||||
+ option "Chat dengan bestie lain" if any other mitra is available]
|
||||
```
|
||||
|
||||
### 3.3 Pre-tap: targeted mitra status
|
||||
- "Curhat lagi" does **not** depend on the Section 1 availability poll — it depends only on the targeted mitra's status (queried at tap time, not pre-cached on each row).
|
||||
- The targeted-mitra status check happens server-side as part of creating the targeted request.
|
||||
|
||||
### 3.4 Targeted mitra unavailable at request time
|
||||
If, at the moment of tapping "Curhat lagi" (before the 20s window starts):
|
||||
- The targeted mitra is **offline** (not online in Valkey), or
|
||||
- The targeted mitra is **at capacity AND not mid-session with the requesting customer**
|
||||
|
||||
then the request is **not** sent. Customer sees a popup:
|
||||
- **Title:** "Bestie sedang tidak online"
|
||||
- **Body:** brief explanation copy
|
||||
- **CTAs:**
|
||||
- If any other mitra is available (per Section 1 logic): primary "Chat dengan bestie lain" — invokes the general blast flow against the same payment session (no double-charge)
|
||||
- Always: "Kembali"
|
||||
|
||||
### 3.5 Targeted mitra is mid-session with someone else (concurrency)
|
||||
- If the targeted mitra is online and currently in another active chat: still send the targeted request card. Let the mitra decide (they may wrap up the active chat, or they may decline). No automatic block.
|
||||
|
||||
### 3.6 During the 20s window — mitra side
|
||||
See Section 4.
|
||||
|
||||
### 3.7 During the 20s window — customer side
|
||||
- Customer sees an overlay on top of the searching/payment-confirmed state:
|
||||
- **Copy:** "Menunggu konfirmasi bestie..."
|
||||
- **Cancel button:** customer can cancel before the mitra responds. On cancel: targeted request is closed, payment session enters `failed_pairing` with `cause_tag = customer_cancelled`.
|
||||
|
||||
### 3.8 On rejection / 20s auto-reject
|
||||
- The same fallback popup as Section 3.4 is shown ("Bestie sedang tidak online" / similar copy):
|
||||
- Primary CTA "Chat dengan bestie lain" (if other mitras available) — fires general blast with the **same payment session**, no double-charge.
|
||||
- "Kembali" — terminates the flow; payment_session ends in `failed_pairing` with `cause_tag = targeted_mitra_rejected` or `targeted_mitra_timeout`.
|
||||
- If general blast also fails: payment ends in `failed_pairing` with `cause_tag = no_mitra_available` (or `all_mitras_rejected` per Section 2.5), and the standard failed-pairing copy from Section 2.5 is shown.
|
||||
|
||||
### 3.9 Anonymity
|
||||
- Unchanged. Mitra sees the customer's `call_name`. No phone, no email.
|
||||
|
||||
---
|
||||
|
||||
## 4. Returning-Chat Approval Window (Mitra Side, 20s, Auto-Reject)
|
||||
|
||||
### 4.1 Mitra UX
|
||||
- Reuse the existing incoming-request notification component (FCM background push + foreground accept/decline card).
|
||||
- **Add a visible 20s countdown** to the card (the only visual change).
|
||||
- The card's accept/decline buttons retain current behavior; on tap → existing accept endpoint.
|
||||
|
||||
### 4.2 Auto-reject on timeout
|
||||
- If the mitra does not tap accept or decline within 20s: backend marks the targeted request as auto-rejected (`response = ignored` or a new `auto_rejected` value — implementation detail for the plan doc).
|
||||
- Triggers the customer-side fallback popup (Section 3.8).
|
||||
|
||||
### 4.3 Mitra offline at request time
|
||||
- If the targeted mitra is offline in Valkey at the moment the request would be created: do **not** send the request. Trigger the customer-side popup (Section 3.4) immediately. Do not wait the full 20s.
|
||||
|
||||
### 4.4 Mitra mid-session with someone else
|
||||
- See Section 3.5. Card is sent regardless; mitra decides.
|
||||
|
||||
### 4.5 New CC config
|
||||
- **Row:** `returning_chat_confirmation_timeout_seconds`
|
||||
- **Default:** `20`
|
||||
- **CC label:** "Batas waktu konfirmasi chat lanjutan (detik)"
|
||||
- **CC explanation:** "Berapa detik bestie punya waktu untuk menerima/menolak permintaan chat lanjutan dari customer sebelum otomatis ditolak."
|
||||
- **Effective immediately** on save (no app restart required) — read by backend per request.
|
||||
|
||||
---
|
||||
|
||||
## 5. Extension Approval Flip (10s, Auto-Approve)
|
||||
|
||||
### 5.1 Behavior change — confirmed intentional
|
||||
Phase 3 today: extension request **auto-rejects** on mitra non-response.
|
||||
|
||||
Phase 3.7: extension request **auto-approves** on mitra non-response within 10s, with safeguards (Section 5.5).
|
||||
|
||||
### 5.2 Customer flow (price-then-charge)
|
||||
- Extension is **never auto-charged**. The customer first chooses extension duration + price (same UX as initial chat request, but **without the free trial path**).
|
||||
- Once the customer confirms duration + price → request is sent to mitra with a 10s window.
|
||||
- The customer sees an overlay: "Menunggu konfirmasi extension..." (existing copy, just shorter timer).
|
||||
|
||||
### 5.3 Charge timing
|
||||
- **At approval moment**: when the mitra explicitly approves OR the 10s timer auto-approves, the extension is charged (mock charge — same `payment_session` mechanism as Section 2 but bound to the existing chat session).
|
||||
- If the mitra explicitly rejects within 10s: **no charge**.
|
||||
- If auto-approve fires due to mitra non-response → charge fires.
|
||||
- If safeguard auto-rejects (Section 5.5) → no charge.
|
||||
|
||||
### 5.4 Mitra UX
|
||||
- Existing extension card stays visually the same; copy adjusted to reflect auto-approve (e.g. "Tidak menjawab dalam 10 detik = otomatis disetujui").
|
||||
- Buttons unchanged.
|
||||
|
||||
### 5.5 Safeguard — mitra disconnected/offline during the 10s
|
||||
Auto-approve does **not** fire if the mitra:
|
||||
- Is **disconnected** from WS at the moment the 10s timer expires, OR
|
||||
- Has flipped themselves to **offline** (Valkey state) since receiving the extension request.
|
||||
|
||||
In either case, treat as **auto-reject** — no charge, customer sees the standard extension-rejected UX.
|
||||
|
||||
> **Domain rule (memory: "Mitra Can Go Offline Mid-Session"):** A mitra can go offline during an active session. Never use "in-session" as a proxy for "online".
|
||||
|
||||
### 5.6 New CC config
|
||||
- **Existing row:** the Phase 3 `extension_timeout_seconds` config stays. Default value updated to **10** during the 3.7 migration.
|
||||
- **New row:** `extension_default_action_on_timeout` — enum (`auto_reject` | `auto_approve`), default `auto_approve`.
|
||||
- **CC label:** "Aksi default jika bestie tidak menjawab extension"
|
||||
- **CC explanation:** "Jika bestie tidak merespon permintaan extension dalam batas waktu, sistem akan otomatis menerima atau menolak sesuai pilihan ini."
|
||||
|
||||
This data-driven approach means we can flip the behavior back to auto-reject without a deploy if needed.
|
||||
|
||||
---
|
||||
|
||||
## 6. Control Center — New Configs and Failed-Pairings Screen
|
||||
|
||||
### 6.1 Config additions
|
||||
| Key | Type | Default | Label | Explanation |
|
||||
|---|---|---|---|---|
|
||||
| `pairing_blast_timeout_seconds` | int | (existing Phase 2 value, if not already configurable) | "Batas waktu blast pairing (detik)" | "Berapa lama backend menunggu mitra menerima sebelum blast dianggap gagal." |
|
||||
| `payment_session_timeout_minutes` | int | 20 | "Batas waktu sesi pembayaran (menit)" | "Sesi pembayaran customer akan kedaluwarsa otomatis setelah durasi ini jika belum dikonfirmasi atau belum menghasilkan chat." |
|
||||
| `returning_chat_confirmation_timeout_seconds` | int | 20 | "Batas waktu konfirmasi chat lanjutan (detik)" | (per Section 4.5) |
|
||||
| `extension_default_action_on_timeout` | enum | `auto_approve` | "Aksi default jika bestie tidak menjawab extension" | (per Section 5.6) |
|
||||
|
||||
### 6.2 Failed Pairings screen
|
||||
- New CC route: `/failed-pairings`.
|
||||
- Table columns: `created_at`, `customer_call_name`, `targeted_mitra_call_name` (nullable), `cause_tag`, `payment_amount` (mock), `operator_action` (none / refunded / credited / no-action), `actioned_by`, `actioned_at`.
|
||||
- Filters: `cause_tag` (multi-select), date range.
|
||||
- Per-row action menu: **Mark as refunded**, **Mark as credited**, **Mark as no-action**. All three just record the decision (no real money movement in 3.7).
|
||||
- No bulk actions in this phase.
|
||||
|
||||
### 6.3 Existing CC screens — no changes other than the config row additions and the failed-pairings entry in the navigation.
|
||||
|
||||
---
|
||||
|
||||
## 7. Migration — Replace Phase 2 Instant Blast Entirely
|
||||
|
||||
### 7.1 Removals (no kill-switch, no feature flag)
|
||||
- Customer-side: the existing direct CTA → blast wiring in `client_app` is removed.
|
||||
- Backend: the route(s) that bypass payment to start a blast are removed (existing endpoints are repurposed: blast no longer fires until a confirmed `payment_session` exists).
|
||||
- Any client-side state assuming "tap CTA = immediate searching" is updated to route through payment first.
|
||||
|
||||
### 7.2 Reuse vs. replace (per 2c.2)
|
||||
- **Reuse:** "Searching for bestie..." screen, "Found bestie" screen, "No bestie found" screen, ChatBloc, ChatRequestBloc/notifier, mitra incoming-request card.
|
||||
- **Replace / new:** Payment screen (new), payment-session lifecycle service (new), `pairing_failures` table + service (new), failed-pairings CC screen (new).
|
||||
|
||||
### 7.3 Data migration
|
||||
- New tables: `payment_sessions`, `pairing_failures`. Migrations created per backend convention.
|
||||
- New column: `chat_sessions.payment_session_id` (nullable for backward compat with any pre-3.7 rows; required for newly-created rows).
|
||||
- No backfill of historical sessions.
|
||||
|
||||
---
|
||||
|
||||
## 8. Edge Cases
|
||||
|
||||
- **Customer logs out mid-payment** — payment_session left as `pending`, expires on TTL.
|
||||
- **Customer logs out mid-search** — payment_session transitions to `failed_pairing` with `cause_tag = customer_cancelled` (signal: WS disconnect + no consume within blast window).
|
||||
- **Targeted mitra accepts after auto-reject fired** — race rejected by the existing DB uniqueness; mitra sees a "request no longer available" state via existing UX.
|
||||
- **Customer hits "Curhat lagi" on a session whose mitra has been deactivated** — treat as `targeted_mitra_offline` (offline in Valkey will be true for deactivated mitras).
|
||||
- **Free-trial customer abandons the Gratis confirmation** — payment_session left `pending` with mock `amount = 0`; expires on TTL; no failed-pairing event (never confirmed).
|
||||
- **Customer taps CTA exactly as the last available mitra goes offline** — payment screen still renders (gating is best-effort, not transactional). After confirmation, blast fires and falls into the standard "no_mitra_available" failed-pairing path.
|
||||
- **Extension auto-approve fires; mitra reconnects 1s later** — they see the extended session as already extended; no surprise UX needed.
|
||||
- **Mitra force-quits app during the 20s returning-chat window** — auto-reject fires at 20s; this is the desired safer behavior (matches Section 4.2).
|
||||
|
||||
---
|
||||
|
||||
## 9. Non-Goals (this phase)
|
||||
|
||||
- Real Xendit checkout / webview / redirect (deferred).
|
||||
- Real pricing or finalized tiers (deferred — keeps mock).
|
||||
- Auto-refund / auto-credit transactions (CC operator records the decision; no actual money movement).
|
||||
- New customer chat-history UI design (reuses existing rows + adds CTA only).
|
||||
- New "Searching for bestie..." visual design (reuses existing).
|
||||
- Bulk actions on failed-pairings.
|
||||
- Mitra-side history of returning-chat requests (covered by Phase 3.5 chat-request history; same component).
|
||||
- Push-notification preferences for the new fallback popup.
|
||||
- Wallet / credit balance UX on the customer app (failed-pairings handled CC-side only for now).
|
||||
|
||||
---
|
||||
|
||||
# Open Questions
|
||||
|
||||
_None — ready for `phase3.7-plan.md`._
|
||||
Reference in New Issue
Block a user