Files
halobestie-clone/requirement/phase3.7.md
ramadhan sjamsani d09e50af55 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>
2026-05-03 23:02:49 +08:00

20 KiB

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 = trueat 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.