Phase 4 Stage 3: payment shell (multi-screen flow)

Six new screens under /payment/* + a paymentDraftProvider holding
mode/durationId/durationMinutes/priceIDR/paymentId/isFirstSessionDiscount
across the flow. PaymentEntryScreen handles the routing decision
(eligible+enabled -> /payment/discount-paywall, else /payment/method-pick)
and clears the draft on fresh entry.

Screens:
- discount_paywall_screen: S6 first-session discount with struck-through
  gimmick price + actual price + 'mulai · Rp{actual}' CTA -> /payment/method
- method_pick_screen: chat vs call cards
- duration_pick_screen: tier list with chat|call mode toggle that resets
  the selection on swap
- payment_method_screen: QRIS-first list, posts to existing
  /api/client/payment-sessions with mode/duration/price/discount/method
- waiting_payment_screen: qr_flutter QR (encodes paymentId in mock mode),
  20-min countdown header, 3s polling for status, pauses on background
  via WidgetsBindingObserver
- payment_expired_screen: retry CTA -> /payment/method with draft retained

Status mapping: real payment_sessions.status uses 'confirmed'/'consumed'
for paid (not 'paid' as in plan) and 'expired'/'abandoned' as terminal.

home_screen 'Mulai Curhat' CTA now pushes /payment/entry.

Dev-only /internal/_test/force-expire-payment endpoint to drive Maestro
flow 04_payment_expired.yaml without waiting 20 minutes. Gated behind
NODE_ENV !== 'production'.

chat_opening_provider PricingData extended to carry Phase 4 chat/call
groups + firstSessionDiscount, back-compat with the Phase 3 shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-10 16:28:59 +08:00
parent 2645bcd0e5
commit 706149c75e
19 changed files with 2209 additions and 15 deletions

View File

@@ -8,27 +8,97 @@ class PriceTier {
final int durationMinutes;
final int price;
final String label;
final String? id;
final String? tag;
PriceTier({required this.durationMinutes, required this.price, required this.label});
const PriceTier({
required this.durationMinutes,
required this.price,
required this.label,
this.id,
this.tag,
});
/// Phase 4 shape: `{ id, minutes, price_idr, tag }` — used by the new
/// chat/call tier groups. Falls back to the legacy free-trial-pricing shape
/// (`{ duration_minutes, price, label }`) for back-compat with the Phase 3
/// `/api/client/chat/pricing` payload still consumed by the legacy payment
/// screen + bottom sheet.
factory PriceTier.fromJson(Map<String, dynamic> json) {
final minutes = (json['minutes'] ?? json['duration_minutes']) as int;
final price = (json['price_idr'] ?? json['price']) as int;
final label = (json['label'] as String?) ?? '$minutes Menit';
return PriceTier(
durationMinutes: json['duration_minutes'] as int,
price: json['price'] as int,
label: json['label'] as String,
durationMinutes: minutes,
price: price,
label: label,
id: (json['id'] as String?) ?? minutes.toString(),
tag: json['tag'] as String?,
);
}
}
/// First-session discount block. Mirrors backend
/// `pricing.first_session_discount`. Server-authoritative — the client only
/// reads this; the actual discount price is re-validated on the backend when
/// the payment session is created.
class FirstSessionDiscount {
final bool eligible;
final int actualPriceIDR;
final int gimmickPriceIDR;
final int durationMinutes;
final List<String> modes;
const FirstSessionDiscount({
required this.eligible,
required this.actualPriceIDR,
required this.gimmickPriceIDR,
required this.durationMinutes,
required this.modes,
});
factory FirstSessionDiscount.fromJson(Map<String, dynamic> json) {
final modesRaw = json['modes'];
final modes = modesRaw is List
? modesRaw.map((e) => e.toString()).toList()
: const <String>['chat'];
return FirstSessionDiscount(
eligible: json['eligible'] as bool? ?? false,
actualPriceIDR: json['actual_price_idr'] as int? ?? 0,
gimmickPriceIDR: json['gimmick_price_idr'] as int? ?? 0,
durationMinutes: json['duration_minutes'] as int? ?? 0,
modes: modes,
);
}
}
class PricingData {
/// Legacy single-list tiers. Populated from `data.tiers` when the response
/// uses the Phase 3 shape; populated from `data.chat.tiers` when the
/// Phase 4 shape is returned (so existing callers keep working).
final List<PriceTier> tiers;
final bool freeTrialEligible;
final int freeTrialDurationMinutes;
/// Phase 4 chat-mode tiers (`pricing.chat.tiers`). Empty when the backend
/// still returns the Phase 3 shape.
final List<PriceTier> chatTiers;
/// Phase 4 call-mode tiers (`pricing.call.tiers`). Empty when the backend
/// still returns the Phase 3 shape.
final List<PriceTier> callTiers;
/// Phase 4 first-session discount block. Null when the backend still
/// returns the Phase 3 shape.
final FirstSessionDiscount? firstSessionDiscount;
const PricingData({
required this.tiers,
required this.freeTrialEligible,
this.freeTrialDurationMinutes = 5,
this.chatTiers = const [],
this.callTiers = const [],
this.firstSessionDiscount,
});
}
@@ -37,9 +107,35 @@ Future<PricingData> chatPricing(Ref ref) async {
final apiClient = ref.read(apiClientProvider);
final response = await apiClient.get('/api/client/chat/pricing');
final data = response['data'] as Map<String, dynamic>;
// Phase 4 shape — `data.chat.tiers` + `data.call.tiers` + `first_session_discount`.
// Phase 3 shape — `data.tiers` + `data.free_trial`. Detect which we got and
// populate the model accordingly.
final hasPhase4Groups = data['chat'] is Map<String, dynamic>;
if (hasPhase4Groups) {
final chat = data['chat'] as Map<String, dynamic>;
final call = (data['call'] as Map<String, dynamic>?) ?? const {};
final chatTiers = (chat['tiers'] as List<dynamic>? ?? const [])
.map((t) => PriceTier.fromJson(t as Map<String, dynamic>))
.toList();
final callTiers = (call['tiers'] as List<dynamic>? ?? const [])
.map((t) => PriceTier.fromJson(t as Map<String, dynamic>))
.toList();
final discountJson = data['first_session_discount'] as Map<String, dynamic>?;
final discount = discountJson != null ? FirstSessionDiscount.fromJson(discountJson) : null;
return PricingData(
tiers: chatTiers,
freeTrialEligible: false,
chatTiers: chatTiers,
callTiers: callTiers,
firstSessionDiscount: discount,
);
}
final tiersJson = data['tiers'] as List<dynamic>;
final tiers = tiersJson.map((t) => PriceTier.fromJson(t as Map<String, dynamic>)).toList();
final freeTrial = data['free_trial'] as Map<String, dynamic>;
final freeTrial = (data['free_trial'] as Map<String, dynamic>?) ?? const {};
return PricingData(
tiers: tiers,

View File

@@ -6,7 +6,7 @@ part of 'chat_opening_provider.dart';
// RiverpodGenerator
// **************************************************************************
String _$chatPricingHash() => r'53ca829d46f7ba3b481e7a6b54482c62f9fe3ad0';
String _$chatPricingHash() => r'6dfbdf77942a67d3da689849eda89fc1fa3e6e39';
/// See also [chatPricing].
@ProviderFor(chatPricing)