Every Xendit invoice now carries metadata: { app: 'halobestie_v2' } so an
external webhook router (no DB access) can fan out v1/v2 traffic purely off
the echoed payload.
Every inbound webhook lands in a new webhook_logs table BEFORE auth or
business logic, so a forensic row survives 401/409/unknown/exception paths.
Primary fields are parsed as columns; raw_body keeps the full payload
verbatim. The handler captures outcome in closure-scoped vars and stamps
http_status/processing_result/processing_error in a single update before
the lone reply.send() — Fastify flushes reply.send() immediately, which
defeated the original finally-block stamp.
A non-UUID external_id no longer crashes the Postgres cast; it ACKs with
ignored_non_uuid_external_id so Xendit stops retrying legacy old-app IDs.
When the DB log itself fails, an optional rolling JSONL file sink absorbs
the event. Disabled by default — opt in via XENDIT_WEBHOOK_FALLBACK_ENABLED.
Naming: <NAME>-YYYY-MM-DD.jsonl in XENDIT_WEBHOOK_FALLBACK_DIR (default
./logs), basename XENDIT_WEBHOOK_FALLBACK_NAME (default
xendit-webhook-fallback). No stdout fallback by design.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Backend
- payment_sessions → payment_requests rename across DB schema + 29 files
- payment.service.js becomes product-agnostic owner: EventEmitter +
Xendit wrapper + requestPayment / confirmPayment public API; legacy
aliases retained for existing chat callers
- Webhook handler at POST /api/shared/payment/webhooks/xendit, with
constant-time token verification (8 vitest cases)
- Server-driven pairing: payment.service emits
payment_request.confirmed → pairing subscriber starts the blast.
Legacy POST /chat/request still works during the cutover.
- Reconciliation sweeper extended (re-emits events for confirmed rows
with no chat session)
- SIGTERM drain + startup reconciliation pass in server.js
Customer app
- waiting_payment_screen opens xendit_invoice_url via
LaunchMode.inAppBrowserView
- searching / no-bestie / targeted-waiting / pairing-notifier updated
to consume the new payment_request_id contract
- pending_payments_provider + bestie-unavailable dialog migrated
Dev / testing
- XENDIT_ENABLED=false is the safe default; .env.example documents the
four new vars
- backend/.dev/xendit-fake-webhook.sh exercises the handler without
ngrok
- 90/92 backend tests pass (two pre-existing session-timer flakes,
unrelated); client_app analyzer clean
- requirement/phase5-xendit-plan.md is the canonical reference
Stage 8 (live E2E) blocked on Xendit test-mode keys. The dashboard's
single-webhook-URL constraint will be worked around via a self-poll
script next session.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Splits the single mitra_ping_interval_seconds config (which conflated
"how often the app pings" with "how long until offline" through a
hidden ×3 multiplier) into two orthogonal knobs:
- mitra_stale_after_seconds (CC-tunable, app_config DB row): the
operator-facing offline threshold. What you set is what you get —
no multiplier. Default 45s (preserves today's effective grace at
the legacy 15s ping default).
- MITRA_HEARTBEAT_CADENCE_SECONDS (env var, default 30s): how often
the mitra app sends a heartbeat. Backend-fixed per deployment;
surfaced to the mitra app via /api/mitra/status.
Backend:
- config.service: getMitraPingConfig returns the new tuple
{require_ping, stale_after_seconds, heartbeat_cadence_seconds}.
Env parser handles blank/non-numeric → 30 fallback.
- mitra-status.service::autoOfflineStaleMitras drops the *3 and uses
stale_after_seconds directly.
- mitra-status.service::getStatus returns heartbeat_cadence_seconds
instead of ping_interval_seconds.
- /internal/config/mitra-ping PATCH validates
stale_after_seconds >= cadence, returns 422 with a clear message
("stale_after_seconds must be a number >= heartbeat cadence (30s)").
- migrate.js: adds mitra_stale_after_seconds default 45. The old
mitra_ping_interval_seconds key is left in place (vestigial) —
no live code reads it; safe to drop after one release.
Mitra app:
- status_notifier reads heartbeat_cadence_seconds, uses it directly
as the Timer.periodic interval. Defaults to 30s if missing (older
backend safety).
Control center:
- SettingsPage: renames "Interval Ping" → "Ambang offline", input
min={heartbeat_cadence_seconds}, shows the cadence as a read-only
value with explanation that it's env-controlled.
Verified end-to-end on dev backend:
- GET /api/mitra/status returns {…, heartbeat_cadence_seconds: 30}
- GET /internal/config/mitra-ping returns {require_ping,
stale_after_seconds: 45, heartbeat_cadence_seconds: 30}
- PATCH with stale_after_seconds=20 → 422 with cadence message
- PATCH with stale_after_seconds=120 → 200, persisted
- Env override (=60, blank, "foo") parses correctly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the two `pricing_*_tiers_json` blobs and five `first_session_discount_*`
keys in app_config with dedicated `pricing_tiers` and `pricing_promotions`
tables plus matching `_history` audit tables. UUID PKs, UNIQUE(mode, minutes)
natural-key constraint, optimistic-lock via `updated_at` token returning 409
STALE_WRITE on conflicts. Every mutation writes a history row capturing the
operator (changed_by from request.auth.userId) and change_kind.
CC SettingsPage replaces the JSON-textarea editors with per-row tables —
add / edit / soft-delete / reactivate / reorder, plus a buffered first-session
discount form with the same optimistic-lock contract. `minutes` and `mode` are
read-only on edit since they form the natural key; operators soft-delete and
recreate to change duration.
Stage 5 fixes a latent leak: `client.payment.routes.js` had its own local
`readDiscountConfig` that still read from app_config — would have silently
fallen to hardcoded defaults once the legacy rows were deleted. Now reads from
pricing_promotions via the shared service helper, so CC edits to the first-
session discount affect actual payment pricing on the next request.
Customer-facing GET /api/client/chat/pricing shape unchanged (id values are
now UUIDs instead of "5"/"12"/"60" but lookups happen by (mode, minutes), so
no app changes needed). 27 new backend tests, all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds `customers.account_belongs_to UUID NULL` and refactors customer
sign-in (phone/Google/Apple) so an anon row that re-verifies into an
existing customer no longer 409s. Instead the anon row stays intact
with a breadcrumb pointing at the real customer; tokens are issued
for the existing user. Actual data reconciliation onto the existing
row (chat_sessions, customer_transactions, payment_sessions,
pairing_failures) is deferred.
Backend
- migrate.js: ADD COLUMN account_belongs_to UUID REFERENCES customers(id)
ON DELETE SET NULL.
- customer.service.js: stampAccountBelongsTo helper; account_belongs_to
exposed in CUSTOMER_SELECT.
- auth.service.js: new shared resolveCustomerForIdentity (4-case logic);
normalizeIdentityConflict + IDENTITY_ALREADY_LINKED 409 deleted;
completeCustomerPhoneSignIn / signInWithGoogle / signInWithApple all
route through the shared helper.
- client.auth.routes.js: new resolveAnonymousCustomerId picks the anon
prefix ONLY from a verified Bearer JWT — closes the UUID-leak attack
where a tamper-able body field could mis-route someone else's
transactions. /otp/verify, /google, /apple all use it; the body field
`anonymous_customer_id` is no longer accepted on any of them.
- test/services/auth.service.test.js: 9 Vitest cases covering phone +
Google + Apple, all 4 logic cases + multi-merge accumulation.
Customer app
- auth_notifier.dart::verifyOtp: drop `skipAuth: true` and the dead
body field so ApiClient auto-attaches the anon's Bearer from
AuthBridge. Survives the AuthOtpSentData state transition (the
earlier `_currentAnonymousCustomerId()` state-drop bug is bypassed by
sourcing the id from the bridge instead of state).
- Google + Apple client paths remain unchanged (gated on provider
creds; mirror this fix when wiring lands).
Docs
- flow_customer.mermaid.md: new §2.1 sub-section with the merge
diagram, schema note, replaces-current-behaviour paragraph, and
Bearer-only security callout.
- phase3.4-testing.md: §1.5 line 76 simplified (no more per-path
split); new §1.5.1 with the 5-step operator scenario + DB invariants
+ curl recipe + Vitest pointer; new §1.5.2 covering Google/Apple
parity (deferred client work flagged).
Verification (against live dev backend, before this commit):
- Vitest: 9/9 in auth.service.test.js; 49/51 overall (2 unrelated
pre-existing failures in session-timer.service.test.js).
- Operator Node smoke: 14/14 in the §1.5.1 scenario; 11/11 in the
Bearer-precedence cases.
- Real-device UI walkthrough on SM-A530F still pending — see resume
memory `project_phase4_2_1_resume_test`.
Sister WIP bundled in migrate.js + customer.service.js: `usp_seen`
column + `markCustomerUspSeen` helper (Phase 4 USP one-time gate, was
already uncommitted in the working tree).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Fastify public app now passes `trustProxy: true` so request.ip resolves to
the real client IP from X-Forwarded-For when behind Cloud Run / a load
balancer. Without this the per-IP rate limit was either useless or
collapsed all users into one shared LB IP.
- The `anonymity_enabled` config row + JS default + migration seed now
default to `false`. The flag is dead code (no business logic ever
consumed it) and the actual rule is simpler than the toggle implied:
mitras always see the customer's chosen call_name; only phone+email
are private. The whole feature is queued for rip-out as a separate
cleanup pass.
The per-IP OTP rate limit (10/hr) was also effectively disabled by
upserting `app_config.otp_max_per_ip_per_hour = 1000000` — a runtime
config change, not a code change. Per-phone (3/hr) + Fazpass cost
remains the real abuse gate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drop NOT NULL on customers.display_name so phone-OTP and social signups can
land before the user picks a name; frontend then routes them to /auth/set-name.
Google sign-in no longer requests the name claim and Apple SDK scope is
trimmed to email only.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- DB migration: add active_session_count column + mitra_notified index
- Constants: add MISSED to NotificationResponse
- Pairing service: record active_session_count on notification creation,
use MISSED (not IGNORED) when another mitra accepts first
- New mitra-activity.service.js: getMitraActivityLog (paginated),
getMitraActivitySummary (per-mitra aggregates with acceptance rate)
- New mitra-activity.routes.js: GET /internal/mitra-activity/log,
GET /internal/mitra-activity/summary
- Control center: new MitraActivityPage with summary table + detail log,
filters (mitra, date range), color-coded response types, pagination
- Register route in App.jsx, add "Aktivitas Mitra" nav link in Layout
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add require_mitra_ping + mitra_ping_interval_seconds config keys (migration)
- Add getMitraPingConfig/setMitraPingConfig to config service
- Add GET/PATCH /internal/config/mitra-ping routes for control center
- Update mitra status service: honor ping config in auto-offline sweep,
include ping config in GET /api/mitra/status response
- Enhance pairing FCM payload with action: 'open_accept' for deep-link
- Add FCM fallback to closure.service (initiateEarlyEnd, completeSession)
- Add FCM fallback to session-timer.service (onSessionExpired)
- Add unread count queries (getActiveSessionByCustomerWithUnread,
getActiveSessionsByMitraWithUnread)
- Add GET /api/client/chat/session/active-with-unread route
- Add GET /api/mitra/chat-requests/sessions/active-with-unread route
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Upgrade Fastify 4→5 with all plugins (@fastify/websocket 11, cors 11, sensible 6)
- Migrate all SSE endpoints to WebSocket + FCM push (mitra chat requests, customer pairing status)
- Add flutter_local_notifications for foreground push notifications with sound
- Add splash screen to both apps (hide auth loading flash)
- Introduce constants/enums across entire codebase (no raw string literals)
- Move price tiers from hardcoded array to app_config DB (data-driven, includes 1-min test tier)
- Add session ownership validation on all shared chat routes
- Add ownership checks on endSession, respondToExtension, requestExtension
- Fix session timer: auto-complete expired/stale sessions on server restart
- Add 5-min grace period for abandoned closing sessions
- Fix extension flow: proper session_resumed handling, clearExtensionRequest, closure grace timer cleanup
- Fix chat screens: ConnectChat in initState, session status check on connect
- Fix customer expired view: 5-min countdown, closure state priority over expired state
- Fix mitra extension UI: loading spinner, disable buttons, handle EXTENSION_RESOLVED error
- Fix GoRouter navigation consistency (no more Navigator.pushNamed)
- Fix goodbye view keyboard overflow (SingleChildScrollView)
- Add active session card on customer home screen with refresh on navigate back
- Fix PricingBottomSheet extension mode (RequestExtension instead of new pairing)
- Send session_resumed to both parties on extension accept
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add mitra online/offline status with heartbeat-based auto-offline,
customer-mitra pairing via Valkey pub/sub blast, session management,
and control center dashboard with real-time stats.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>