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:
@@ -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,
|
||||
|
||||
@@ -6,7 +6,7 @@ part of 'chat_opening_provider.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$chatPricingHash() => r'53ca829d46f7ba3b481e7a6b54482c62f9fe3ad0';
|
||||
String _$chatPricingHash() => r'6dfbdf77942a67d3da689849eda89fc1fa3e6e39';
|
||||
|
||||
/// See also [chatPricing].
|
||||
@ProviderFor(chatPricing)
|
||||
|
||||
Reference in New Issue
Block a user