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:
180
client_app/lib/features/payment/payment_notifier.dart
Normal file
180
client_app/lib/features/payment/payment_notifier.dart
Normal file
@@ -0,0 +1,180 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import '../../core/api/api_client.dart';
|
||||
import '../../core/api/api_client_provider.dart';
|
||||
import '../../core/constants.dart';
|
||||
|
||||
part 'payment_notifier.g.dart';
|
||||
|
||||
/// Payment-session lifecycle, customer side. The screen owns one of these per
|
||||
/// (mitra-target, duration) attempt; the notifier wraps the REST calls to
|
||||
/// `/api/client/payment-sessions`.
|
||||
sealed class PaymentSessionData {
|
||||
const PaymentSessionData();
|
||||
}
|
||||
|
||||
class PaymentInitialData extends PaymentSessionData {
|
||||
const PaymentInitialData();
|
||||
}
|
||||
|
||||
class PaymentCreatingData extends PaymentSessionData {
|
||||
const PaymentCreatingData();
|
||||
}
|
||||
|
||||
/// Created server-side, sitting in `pending` until the customer taps "Bayar".
|
||||
class PaymentPendingData extends PaymentSessionData {
|
||||
final String paymentSessionId;
|
||||
final int amount;
|
||||
final int durationMinutes;
|
||||
final bool isFreeTrial;
|
||||
final bool isExtension;
|
||||
final String? targetedMitraId;
|
||||
|
||||
const PaymentPendingData({
|
||||
required this.paymentSessionId,
|
||||
required this.amount,
|
||||
required this.durationMinutes,
|
||||
required this.isFreeTrial,
|
||||
required this.isExtension,
|
||||
this.targetedMitraId,
|
||||
});
|
||||
}
|
||||
|
||||
class PaymentConfirmingData extends PaymentSessionData {
|
||||
final String paymentSessionId;
|
||||
const PaymentConfirmingData(this.paymentSessionId);
|
||||
}
|
||||
|
||||
/// Confirmed; the customer can now be routed to the searching screen with
|
||||
/// this `paymentSessionId` (and optional `targetedMitraId` for "Curhat lagi").
|
||||
class PaymentConfirmedData extends PaymentSessionData {
|
||||
final String paymentSessionId;
|
||||
final int durationMinutes;
|
||||
final bool isFreeTrial;
|
||||
final bool isExtension;
|
||||
final String? targetedMitraId;
|
||||
|
||||
const PaymentConfirmedData({
|
||||
required this.paymentSessionId,
|
||||
required this.durationMinutes,
|
||||
required this.isFreeTrial,
|
||||
required this.isExtension,
|
||||
this.targetedMitraId,
|
||||
});
|
||||
}
|
||||
|
||||
class PaymentErrorData extends PaymentSessionData {
|
||||
final String message;
|
||||
const PaymentErrorData(this.message);
|
||||
}
|
||||
|
||||
@riverpod
|
||||
class Payment extends _$Payment {
|
||||
ApiClient get _api => ref.read(apiClientProvider);
|
||||
|
||||
@override
|
||||
PaymentSessionData build() => const PaymentInitialData();
|
||||
|
||||
/// Create a `pending` payment session for the chosen [durationMinutes].
|
||||
/// Pass [targetedMitraId] for the "Curhat lagi" path; pass [isExtension]
|
||||
/// for an extension-cost payment (never combined with free trial).
|
||||
Future<void> createSession({
|
||||
required int durationMinutes,
|
||||
String? targetedMitraId,
|
||||
bool isExtension = false,
|
||||
}) async {
|
||||
state = const PaymentCreatingData();
|
||||
try {
|
||||
final body = <String, dynamic>{
|
||||
'duration_minutes': durationMinutes,
|
||||
if (targetedMitraId != null) 'targeted_mitra_id': targetedMitraId,
|
||||
if (isExtension) 'is_extension': true,
|
||||
};
|
||||
// Trailing slash matters: the backend route is `app.post('/', ...)` mounted
|
||||
// at prefix `/api/client/payment-sessions`, and Fastify is not configured
|
||||
// with `ignoreTrailingSlash: true`, so the canonical URL has the slash.
|
||||
final response = await _api.post('/api/client/payment-sessions/', data: body);
|
||||
final data = response['data'] as Map<String, dynamic>;
|
||||
state = PaymentPendingData(
|
||||
paymentSessionId: data['id'] as String,
|
||||
amount: data['amount'] as int? ?? 0,
|
||||
durationMinutes: data['duration_minutes'] as int? ?? durationMinutes,
|
||||
isFreeTrial: data['is_free_trial'] as bool? ?? false,
|
||||
isExtension: data['is_extension'] as bool? ?? isExtension,
|
||||
targetedMitraId: data['targeted_mitra_id'] as String?,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
state = PaymentErrorData(_humanError(e, fallback: 'Gagal membuat sesi pembayaran.'));
|
||||
} catch (_) {
|
||||
state = const PaymentErrorData('Gagal membuat sesi pembayaran.');
|
||||
}
|
||||
}
|
||||
|
||||
/// Confirm the pending payment. Backend rejects truly empty bodies on
|
||||
/// `POST .../confirm`, so we always send `{}`.
|
||||
Future<void> confirm() async {
|
||||
final current = state;
|
||||
if (current is! PaymentPendingData) return;
|
||||
state = PaymentConfirmingData(current.paymentSessionId);
|
||||
try {
|
||||
await _api.post(
|
||||
'/api/client/payment-sessions/${current.paymentSessionId}/confirm',
|
||||
data: const <String, dynamic>{},
|
||||
);
|
||||
state = PaymentConfirmedData(
|
||||
paymentSessionId: current.paymentSessionId,
|
||||
durationMinutes: current.durationMinutes,
|
||||
isFreeTrial: current.isFreeTrial,
|
||||
isExtension: current.isExtension,
|
||||
targetedMitraId: current.targetedMitraId,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
state = PaymentErrorData(_humanError(e, fallback: 'Gagal mengkonfirmasi pembayaran.'));
|
||||
} catch (_) {
|
||||
state = const PaymentErrorData('Gagal mengkonfirmasi pembayaran.');
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort cancel of a still-pending session. Safe to call on dispose
|
||||
/// even if the state isn't `pending` — we just no-op in that case.
|
||||
Future<void> cancelIfPending() async {
|
||||
final current = state;
|
||||
if (current is! PaymentPendingData) return;
|
||||
final id = current.paymentSessionId;
|
||||
try {
|
||||
await _api.post(
|
||||
'/api/client/payment-sessions/$id/cancel',
|
||||
data: const <String, dynamic>{},
|
||||
);
|
||||
} catch (_) {
|
||||
// Best-effort — backend sweeper will expire stale `pending` rows
|
||||
// after `payment_session_timeout_minutes` regardless.
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset to initial — used when the screen is re-entered for a new attempt.
|
||||
void reset() {
|
||||
state = const PaymentInitialData();
|
||||
}
|
||||
|
||||
String _humanError(DioException e, {required String fallback}) {
|
||||
final code = e.response?.data?['error']?['code'] as String?;
|
||||
final status = e.response?.statusCode;
|
||||
if (status == 422 || code == 'VALIDATION_ERROR' || code == 'INVALID_TIER') {
|
||||
return 'Pilihan durasi tidak valid.';
|
||||
}
|
||||
if (status == 403) return 'Sesi tidak diizinkan.';
|
||||
if (status == 404) return 'Sesi pembayaran tidak ditemukan.';
|
||||
if (code == 'EXPIRED') return 'Sesi pembayaran sudah kedaluwarsa.';
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirror of backend `PaymentSessionStatus` for any UI that needs to inspect
|
||||
/// the raw status field (kept tiny for now — most flows route via state above).
|
||||
class PaymentStatus {
|
||||
static const pending = PaymentSessionStatus.pending;
|
||||
static const confirmed = PaymentSessionStatus.confirmed;
|
||||
static const consumed = PaymentSessionStatus.consumed;
|
||||
PaymentStatus._();
|
||||
}
|
||||
Reference in New Issue
Block a user