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 createSession({ required int durationMinutes, String? targetedMitraId, bool isExtension = false, }) async { state = const PaymentCreatingData(); try { final body = { '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; 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 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 {}, ); 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 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 {}, ); } 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._(); }