Files
halobestie-clone/requirement/phase3.7-plan.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

23 KiB

Phase 3.7 — Implementation Plan

See phase3.7.md for the PRD and phase3.7-questions.md for the answered question doc.

This document is the build sequence: what files change, in what order, with API contracts for new endpoints. The "why" is in the PRD — don't restate it here.


Build Order (4 stages)

The dependency graph forces this order:

  1. Backend foundation — schema + config + new services (nothing user-visible yet)
  2. Backend routes — endpoints, blast/extension behavior changes (smoke-testable via curl)
  3. Apps — client_app + mitra_app cut over to the new flow
  4. Control center — Failed Pairings screen + new config UI

Within each stage, items are listed in the order they should land.


Stage 1 — Backend Foundation

1.1 Schema additions (backend/src/db/migrate.js)

The repo uses a single migrate.js script (no per-file migrations). Append new DDL to the existing script.

New table: payment_sessions

CREATE TABLE IF NOT EXISTS payment_sessions (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  customer_id     UUID NOT NULL REFERENCES users(id),
  amount          INTEGER NOT NULL DEFAULT 0,        -- mocked, in IDR (rupiah, no decimals)
  duration_minutes INTEGER NOT NULL,
  is_free_trial   BOOLEAN NOT NULL DEFAULT false,
  status          TEXT NOT NULL DEFAULT 'pending'
                  CHECK (status IN ('pending','confirmed','consumed','failed_pairing','abandoned','expired')),
  targeted_mitra_id UUID REFERENCES mitras(id),       -- NULL for general blast; set for "Curhat lagi"
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  confirmed_at    TIMESTAMPTZ,
  consumed_at     TIMESTAMPTZ,
  expires_at      TIMESTAMPTZ NOT NULL                 -- created_at + payment_session_timeout_minutes
);

CREATE INDEX IF NOT EXISTS idx_payment_sessions_customer ON payment_sessions(customer_id);
CREATE INDEX IF NOT EXISTS idx_payment_sessions_status_expires ON payment_sessions(status, expires_at);

New table: pairing_failures

CREATE TABLE IF NOT EXISTS pairing_failures (
  id                  UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  payment_session_id  UUID NOT NULL REFERENCES payment_sessions(id) ON DELETE CASCADE,
  customer_id         UUID NOT NULL REFERENCES users(id),
  targeted_mitra_id   UUID REFERENCES mitras(id),
  cause_tag           TEXT NOT NULL
                      CHECK (cause_tag IN (
                        'no_mitra_available',
                        'all_mitras_rejected',
                        'targeted_mitra_offline',
                        'targeted_mitra_rejected',
                        'targeted_mitra_timeout',
                        'payment_session_expired',
                        'customer_cancelled'
                      )),
  amount              INTEGER NOT NULL,
  operator_action     TEXT
                      CHECK (operator_action IS NULL OR operator_action IN ('refunded','credited','no_action')),
  actioned_by         UUID REFERENCES cc_users(id),
  actioned_at         TIMESTAMPTZ,
  created_at          TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS idx_pairing_failures_created_at ON pairing_failures(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_pairing_failures_cause ON pairing_failures(cause_tag);
CREATE INDEX IF NOT EXISTS idx_pairing_failures_unactioned ON pairing_failures(created_at DESC) WHERE operator_action IS NULL;

chat_sessions — add nullable FK

ALTER TABLE chat_sessions ADD COLUMN IF NOT EXISTS payment_session_id UUID REFERENCES payment_sessions(id);
CREATE INDEX IF NOT EXISTS idx_chat_sessions_payment ON chat_sessions(payment_session_id);

Nullable for backward compat with pre-3.7 sessions; required for newly-created rows (enforced at service layer, not DB).

Seed new config rows

Append to seed step:

INSERT INTO app_config (key, value) VALUES
  ('payment_session_timeout_minutes', '20'),
  ('returning_chat_confirmation_timeout_seconds', '20'),
  ('extension_default_action_on_timeout', 'auto_approve'),
  ('pairing_blast_timeout_seconds', '<existing-Phase-2-default>')   -- only if not already present
ON CONFLICT (key) DO NOTHING;

Also: in the same seed step, update extension_timeout_seconds default to 10 ONLY for new installs (use ON CONFLICT DO NOTHING; existing dev DB will need a manual one-line update or CC change).


1.2 New service: payment.service.js

File: backend/src/services/payment.service.js (new)

Exports:

  • createPaymentSession({ customerId, durationMinutes, amount, isFreeTrial, targetedMitraId? }) → returns { id, status: 'pending', expiresAt }. Reads payment_session_timeout_minutes from config to compute expires_at.
  • confirmPaymentSession(paymentSessionId, customerId) → transitions pending → confirmed. Throws if not owned by customer / wrong status / expired.
  • consumePaymentSession(paymentSessionId) → transitions confirmed → consumed. Called from pairing service when chat starts.
  • failPaymentSession(paymentSessionId, causeTag) → transitions confirmed → failed_pairing, writes a pairing_failures row in the same transaction. Idempotent (no-op if already failed/consumed).
  • expireStalePaymentSessions() → background sweeper: pending rows past expires_atexpired; confirmed rows past expires_at AND not consumed → failed_pairing with cause_tag = 'payment_session_expired'.
  • getPaymentSession(id) — for routes.

1.3 New service: pairing-failure.service.js

File: backend/src/services/pairing-failure.service.js (new)

Exports:

  • recordFailure({ paymentSessionId, customerId, targetedMitraId?, causeTag, amount }) — used by payment.service.failPaymentSession. Kept in its own service for clean CC queries.
  • listFailures({ causeTags?, dateFrom?, dateTo?, limit, offset }) — for CC.
  • setOperatorAction(failureId, ccUserId, action) — for CC operator action menu.

1.4 Modify: mitra-status.service.js

File: backend/src/services/mitra-status.service.js

Add a Valkey-only function:

  • countAvailableMitrasFromCache() → reads online-mitra set + per-mitra active session counters from Valkey, compares against max_customers_per_mitra (cached value, refreshed on config change), returns { available: boolean, count: number }. No Postgres queries on the hot path.

The existing getOnlineMitras() joins to active session counts via DB — that's too heavy for a 5s poll. Build a parallel cached path. The capacity cap is read once on cold start and kept in memory; bust the cache when CC updates the config (existing config-update path can publish a Valkey invalidate event).

1.5 Modify: extension.service.js

File: backend/src/services/extension.service.js

Changes (do not change the timeout numeric — just the action):

  • Read extension_default_action_on_timeout from app_config when timer fires.
  • If auto_approve: at timer fire, check mitra connectivity (WS + Valkey online status). If both OK → call existing approve path (which will trigger the mock charge). If either is offline/disconnected → treat as auto-reject (no charge). Update the cause_tag if a payment row is involved.
  • If auto_reject: existing behavior unchanged (back-compat with the data-driven flag).
  • Update extension-request creation: customer must pass extension_payment_session_id (separate payment session for the extension cost). If is_free_trial = true is not allowed for extensions — enforce server-side per PRD §5.2.

1.6 Modify: pairing.service.js

File: backend/src/services/pairing.service.js

Changes:

  • createPairingRequest() now requires paymentSessionId. On entry: load the payment session, assert status = confirmed and ownership. Refuse with 409 if not.
  • New variant: createTargetedPairingRequest({ paymentSessionId, customerId, targetedMitraId }) for "Curhat lagi". Skips the broad blast — sends a single targeted notification, starts a server-side 20s timer (read from returning_chat_confirmation_timeout_seconds).
    • Pre-check: targeted mitra online? If not → fail immediately with cause_tag = targeted_mitra_offline (call payment.service.failPaymentSession), return 409 with { reason: 'targeted_mitra_offline' }.
    • Pre-check: targeted mitra at capacity AND not mid-session with this customer? → same as offline.
    • On timer fire (no response): mark request as auto_rejected, fail payment with cause_tag = targeted_mitra_timeout, push WS event RETURNING_CHAT_TIMEOUT to customer.
    • On explicit decline: fail payment with cause_tag = targeted_mitra_rejected, push WS event RETURNING_CHAT_REJECTED.
    • On accept: existing accept path → consumePaymentSession, chat starts.
  • respondToPairingRequest() (general blast) on success: call consumePaymentSession. On blast-window expiry without acceptance: call failPaymentSession with cause_tag = no_mitra_available. On every-mitra-rejected before timeout: cause_tag = all_mitras_rejected.
  • New: cancelPaymentSearch(paymentSessionId, customerId) — customer-initiated cancel during searching/waiting → fail payment with cause_tag = customer_cancelled.
  • New: fallbackToGeneralBlast(paymentSessionId, customerId) — used when "Chat dengan bestie lain" is tapped after a returning-chat fail. Reuses the same payment session (no new charge), runs the standard blast.

1.7 Modify: pricing.service.js

File: backend/src/services/pricing.service.js

  • Add getExtensionPriceTiers(customerId) — same shape as initial pricing but always returns is_free_trial = false for every tier (per PRD §5.2 "no trial for extensions"). Existing getPriceTiers() for initial chat is unchanged.

1.8 Background sweeper

File: new entry in backend/src/index.js (or wherever existing intervals live; check for an existing setInterval pattern in the chat-session timer code).

  • Run paymentService.expireStalePaymentSessions() every 60 seconds.
  • When we scale to multi-instance, this should move to Valkey keyspace notifications (consistent with the existing memory item "Session Timer Scaling").

Stage 2 — Backend Routes

2.1 New: client.mitra-availability.routes.js

File: backend/src/routes/public/client.mitra-availability.routes.js (new)

GET /api/client/mitra-availability
  → 200 { "available": true|false, "count": number? }
  • Auth: customer JWT.
  • Backed entirely by countAvailableMitrasFromCache().
  • No rate limit needed — this is a hot endpoint by design (every 5s per active customer).

2.2 New: client.payment.routes.js

File: backend/src/routes/public/client.payment.routes.js (new)

POST /api/client/payment-sessions
  body: { duration_minutes, tier_id?, targeted_mitra_id? }
  → 201 { id, amount, is_free_trial, expires_at, status: "pending" }

POST /api/client/payment-sessions/:id/confirm
  → 200 { id, status: "confirmed" }

POST /api/client/payment-sessions/:id/cancel
  → 200 { id, status: "abandoned" }       # only valid while pending; failed_pairing for already-confirmed search-cancel uses 2.3

GET  /api/client/payment-sessions/:id
  → 200 { ...full row }

Free-trial logic: the POST checks pricingService.isCustomerEligibleForFreeTrial(customerId) and computes amount = 0, is_free_trial = true accordingly.

2.3 Modify: client.chat.routes.js (existing)

File: backend/src/routes/public/client.chat.routes.js

Changes:

  • The "start search" route now requires payment_session_id in the body. Old shape rejected with 400.
  • Add POST /api/client/chat-requests/cancel → forwards to pairing.service.cancelPaymentSearch.
  • Add POST /api/client/chat-requests/returning → body { payment_session_id, mitra_id }, forwards to createTargetedPairingRequest.
  • Add POST /api/client/chat-requests/:paymentSessionId/fallback-to-blast → forwards to fallbackToGeneralBlast.

Existing routes that bypass payment (any "start blast directly") — delete.

2.4 Modify: client.chat.routes.js (extension)

Update extension request route to require extension_payment_session_id. Free-trial path is forbidden (return 400 if is_free_trial = true).

2.5 New: internal.failed-pairings.routes.js

File: backend/src/routes/internal/failed-pairings.routes.js (new)

GET  /internal/failed-pairings
  query: cause_tags[]?, date_from?, date_to?, limit=50, offset=0
  → 200 { rows: [...], total }

POST /internal/failed-pairings/:id/action
  body: { action: "refunded"|"credited"|"no_action" }
  → 200 { ...updated row }

Auth: existing CC JWT. Mount on the internal listener (port 3001).

2.6 Modify: internal/config.routes.js

File: backend/src/routes/internal/config.routes.js

Add the four new keys to the allow-list (PRD §6.1):

  • pairing_blast_timeout_seconds (int)
  • payment_session_timeout_minutes (int)
  • returning_chat_confirmation_timeout_seconds (int)
  • extension_default_action_on_timeout (enum: auto_reject | auto_approve)

On any of these keys being updated, publish a Valkey invalidate event so the in-memory caches in mitra-status.service and extension.service refresh.

2.7 Smoke test (curl) — must pass before Stage 3

  • Happy path: POST payment-sessionsPOST confirmPOST chat-requests → mitra accepts → chat starts → payment_sessions.status = consumed.
  • No-mitra path: same as above but no mitra accepts within blast window → pairing_failures row created with cause_tag = no_mitra_available.
  • Returning-chat happy: POST chat-requests/returning → mitra accepts within 20s → chat starts.
  • Returning-chat timeout: no mitra response in 20s → cause_tag = targeted_mitra_timeout.
  • Returning-chat → fallback: POST .../fallback-to-blast → general blast runs against same payment session.
  • Mitra-availability: GET /api/client/mitra-availability returns { available: true } while at least one mitra is online and has spare capacity; flips to false when capacity hits cap.

Stage 3 — Apps

3.1 client_app — Mitra availability poll

File: client_app/lib/core/availability/mitra_availability_notifier.dart (new)

Riverpod AsyncNotifier:

  • build() — emits AsyncData(false) initially.
  • Starts a 5s Timer.periodic only when the home screen is in the foreground (AppLifecycleState.resumed). Pauses on paused/inactive. Hook via WidgetsBindingObserver in the home screen.
  • refresh() — manual trigger for pull-to-refresh.
  • On HTTP failure → emit AsyncData(false) (default to disabled per PRD §1.3).

File: client_app/lib/features/home/home_screen.dart

Changes:

  • Watch mitraAvailabilityProvider.
  • "Mulai Curhat" CTA: enabled when available == true, otherwise greyed-out with subtitle "Belum ada bestie tersedia".
  • Add lifecycle observer to gate the polling.

3.2 client_app — Payment screen

File: client_app/lib/features/payment/screens/payment_screen.dart (new)

  • Reuses existing pricingProvider for tier list + free-trial check.
  • Layout: tier picker + "Total" line ("Gratis" if free trial, else "Rp X") + primary CTA "Bayar" (or "Mulai" if free trial).
  • On confirm tap → POST /api/client/payment-sessions/:id/confirm → push to searching screen with payment_session_id.
  • On back/dispose: call POST .../cancel if status still pending (best-effort).

File: client_app/lib/features/payment/payment_notifier.dart (new)

Riverpod notifier managing the payment-session state for the screen.

Routes: add /payment to GoRouter. Replace the current _onStartChatPressed handler in home_screen.dart to push /payment instead of opening the pricing bottom-sheet directly.

3.3 client_app — Pairing notifier rewrite

File: client_app/lib/core/pairing/pairing_notifier.dart

Changes:

  • startSearch() now requires paymentSessionId. The HTTP body changes accordingly.
  • New state variants:
    • PairingTargetedWaitingData(mitraName, secondsRemaining) — for the 20s returning-chat overlay.
    • PairingFailedData(causeTag, fallbackOffered: bool) — for the failed-pairing terminal screen.
  • Listen to new WS events: RETURNING_CHAT_TIMEOUT, RETURNING_CHAT_REJECTED.

3.4 client_app — Searching/found/no-bestie screens

Reuse searching_screen.dart, bestie_found_screen.dart as-is.

Modify no_bestie_screen.dart: becomes the failed-pairing terminal screen. Shows the standard copy from PRD §2.5 ("Maaf, kami tidak bisa menemukan bestie...") + "Kembali ke beranda" CTA.

New widget: client_app/lib/features/chat/widgets/targeted_waiting_overlay.dart — modal overlay shown above the searching screen during the 20s window. Cancel button → POST /api/client/chat-requests/cancel.

New widget: client_app/lib/features/chat/widgets/bestie_unavailable_dialog.dart — popup for §3.4/§3.8: title "Bestie sedang tidak online" + "Chat dengan bestie lain" CTA (only if general availability allows) + "Kembali" CTA. The "Chat dengan bestie lain" CTA calls POST .../fallback-to-blast then transitions to the searching screen.

3.5 client_app — Chat history "Curhat lagi" CTA

File: client_app/lib/features/chat/screens/chat_history_screen.dart

Add a "Curhat lagi" trailing button on every row. On tap:

  1. Push /payment?targetedMitraId=<id> (payment screen reads the param; passes targeted_mitra_id to POST /payment-sessions).
  2. After confirm → POST /chat-requests/returning → push the searching screen with the targeted-waiting overlay shown.

3.6 mitra_app — Returning-chat card

File: mitra_app/lib/core/chat/widgets/chat_request_overlay.dart

  • Reuse the existing component for general blast requests.
  • Add a 20s countdown when the WS payload indicates request_type = 'returning'. Overlay closes itself if no action by 0s (UI follows server; server is the source of truth on auto-reject).

File: mitra_app/lib/core/chat/chat_request_notifier.dart

  • Handle the new WS payload field request_type. Branch the overlay rendering accordingly.

3.7 mitra_app — Extension card copy

File: existing extension card widget (find via grep for ExtensionRequestData — likely in mitra_app/lib/features/chat/widgets/extension_request_card.dart or similar).

  • Update copy: "Tidak menjawab dalam 10 detik = otomatis disetujui" (or whatever the team prefers in Bahasa).
  • No behavior change — the auto-approve fires server-side.

3.8 client_app — Extension flow

  • Customer-side: extension request still opens an in-app picker for duration + price (reuse the pricing UX, omit the free-trial path).
  • On submit → create an extension_payment_session (calls the new payment route with no targeted_mitra_id, just an is_extension: true flag — small adjustment in client.payment.routes.js).
  • On mitra approve / auto-approve → existing extended-session UX kicks in.
  • On mitra reject within 10s → existing rejected UX, no charge.

Stage 4 — Control Center

4.1 Settings page additions

File: control_center/src/pages/settings/SettingsPage.jsx

Add four new rows (follow the existing useQuery+useMutation pattern at lines 14-76):

  • payment_session_timeout_minutes — number input
  • returning_chat_confirmation_timeout_seconds — number input
  • extension_default_action_on_timeout — radio/select: "Auto-approve" / "Auto-reject"
  • pairing_blast_timeout_seconds — number input (only if not already present)

Use the same Indonesian labels and explanations from PRD §6.1.

4.2 New: Failed Pairings page

File: control_center/src/pages/failed-pairings/FailedPairingsPage.jsx (new)

  • Table per PRD §6.2.
  • Filters: cause-tag multi-select, date range.
  • Per-row action menu: "Mark as refunded" / "Mark as credited" / "Mark as no-action" → POST /internal/failed-pairings/:id/action.

File: control_center/src/components/Layout.jsx

Add a sidebar entry "Failed Pairings" → /failed-pairings.

File: wherever the React Router routes live — register the new page.


Verification Checklist

Before declaring done:

  • Stage 1: node backend/src/db/migrate.js runs cleanly; new tables and config rows exist.
  • Stage 2: All curl smoke tests in §2.7 pass.
  • Stage 3 client_app: home CTA disables when last mitra goes offline; payment screen renders; happy-path E2E chat starts; "Curhat lagi" path works end-to-end including the targeted-mitra-offline popup; cancel mid-search creates a failed-pairing row.
  • Stage 3 mitra_app: returning-chat card shows the 20s countdown; auto-reject lands in failed-pairing; extension card shows new copy; auto-approve fires when mitra ignores it; auto-reject fires when mitra goes offline mid-extension-window.
  • Stage 4: Failed Pairings page lists all rows from a test run and lets operator action a row; new config rows save and take effect (test by changing returning_chat_confirmation_timeout_seconds to 5 and watching auto-reject fire faster).
  • Old instant-blast code paths are gone — grep confirms no remaining call sites.

Risk Notes

  • Cache invalidation for countAvailableMitrasFromCache() — if we get the config-update Valkey publish wrong, the 5s poll will lie about availability for stale-cache duration. Mitigation: keep the cache TTL short (10s) as a backstop even when invalidate isn't published.
  • Server-side timer for the 20s returning-chat window — uses setTimeout per the existing pattern. Will not survive a backend restart mid-window. Acceptable for single-instance deploy; for multi-instance, fold into the existing Valkey-keyspace-notification follow-up (memory item "Session Timer Scaling").
  • Background sweeper raceexpireStalePaymentSessions runs every 60s; an extension request that lands on an expiring payment session in the same minute could see a confusing state. Mitigate by checking expiry inline at every state transition (confirmPaymentSession, consumePaymentSession).
  • Failed-pairing copy is a placeholder — PRD §2.5 explicitly notes the copy will be revised. Don't over-invest in the design.

Open Questions

None — ready to start coding from Stage 1.