Phase 5.x payment revamp + Xendit Stage-8 prep

- Backend wraps idn-finlogos npm at /assets/payment-icons/<slug>.svg with
  1y immutable cache. Mobile drops bundled SVGs (only placeholder remains)
  and fetches via flutter_cache_manager. payment_methods.icon is now a
  CSV of slugs; catalog emits icon_urls[]. CARDS tile renders Visa + MC +
  JCB side by side.
- Per-method min/max amount bounds (BIGINT, nullable). Picker greys out
  out-of-range tiles with subtitle; backend gates with INVALID_PAYMENT_AMOUNT
  (422). Defense in depth against stale-catalog clients.
- Xendit channel codes corrected from authoritative docs
  (BCA_VA -> BCA_VIRTUAL_ACCOUNT, CREDIT_CARD -> CARDS, ovo -> ovo-new,
  shopeepay -> shopee-pay, ...). 18 methods x 5 groups seeded with
  Xendit-published per-channel min/max.
- Re-runnable seed (ON CONFLICT DO NOTHING on payment_code + new unique
  index on group name). Operator CC edits never clobbered across re-runs.
  One-shot reset + inspect scripts under backend/.dev/.
- Customer redirect HTML pages at /payment/return/{success,failure},
  brand-styled with "Buka HaloBestie" CTA firing halobestie:// deeplink.
  URL scheme registered on Android (intent-filter w/ BROWSABLE on
  MainActivity) and iOS (CFBundleURLTypes). Waiting-payment poller still
  owns confirmation; deeplink just brings the activity to foreground.
- Control center payment-catalog page: min/max inputs + columns. Other
  CC pages restyled with new theme tokens (separate work, bundled here).

169/169 backend tests pass. See requirement/phase5-payment-revamp-2026-05-27.md
for the full revamp doc. Stage 8 (E2E) still pending: webhook URL routing
decision + two client_app follow-ups (legacy /chat/request removal,
extension Custom Tab).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 21:33:51 +08:00
parent 1f6d8e09ae
commit 2c95fd040d
53 changed files with 2389 additions and 832 deletions

View File

@@ -86,6 +86,12 @@ class _PaymentMethodScreenState extends ConsumerState<PaymentMethodScreen> {
String _humanError(DioException e) {
final code = e.response?.data?['error']?['code'] as String?;
final status = e.response?.statusCode;
if (code == 'INVALID_PAYMENT_AMOUNT') {
// Server confirms the picker should have caught this — most likely a
// stale catalog. The picker's tile subtitle already explains; we just
// need to nudge the user to pick a different method.
return 'Metode pembayaran tidak cocok untuk nominal ini. Pilih metode lain.';
}
if (status == 422 || code == 'VALIDATION_ERROR' || code == 'INVALID_TIER') {
return 'Pilihan durasi tidak valid.';
}
@@ -204,6 +210,7 @@ class _PaymentMethodScreenState extends ConsumerState<PaymentMethodScreen> {
group: g,
expanded: _expandedGroupIds.contains(g.id),
selectedCode: _selectedCode,
amount: amount,
onToggle: () => setState(() {
if (!_expandedGroupIds.add(g.id)) {
_expandedGroupIds.remove(g.id);
@@ -277,6 +284,7 @@ class _GroupSection extends StatelessWidget {
final PaymentMethodGroup group;
final bool expanded;
final String? selectedCode;
final int amount;
final VoidCallback onToggle;
final ValueChanged<String> onSelect;
@@ -284,6 +292,7 @@ class _GroupSection extends StatelessWidget {
required this.group,
required this.expanded,
required this.selectedCode,
required this.amount,
required this.onToggle,
required this.onSelect,
});
@@ -334,13 +343,17 @@ class _GroupSection extends StatelessWidget {
firstChild: const SizedBox(height: 0),
secondChild: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: group.methods
.map((m) => _MethodTile(
method: m,
selected: selectedCode == m.paymentCode,
onTap: () => onSelect(m.paymentCode),
))
.toList(),
children: group.methods.map((m) {
final disabledReason = m.disabledReason(amount);
return _MethodTile(
method: m,
selected: selectedCode == m.paymentCode,
disabledReason: disabledReason,
onTap: disabledReason == null
? () => onSelect(m.paymentCode)
: null,
);
}).toList(),
),
crossFadeState:
expanded ? CrossFadeState.showSecond : CrossFadeState.showFirst,
@@ -351,90 +364,136 @@ class _GroupSection extends StatelessWidget {
}
}
/// Visual container for one or more brand-mark icons on a payment-method tile.
///
/// Single-icon: 40×40 box with the icon at 22px. Multi-icon (e.g. credit-card
/// row showing Visa + Mastercard + JCB): box widens to fit, icons render at
/// 18px with 3px gaps. Empty list: placeholder via [PaymentIcon] in the box.
class _MethodIconBox extends StatelessWidget {
final List<String> iconUrls;
const _MethodIconBox({required this.iconUrls});
@override
Widget build(BuildContext context) {
final multi = iconUrls.length > 1;
final iconSize = multi ? 18.0 : 22.0;
final children = iconUrls.isEmpty
? <Widget>[
PaymentIcon(iconUrl: null, size: iconSize, color: HaloTokens.brandDark),
]
: [
for (var i = 0; i < iconUrls.length; i++) ...[
if (i > 0) const SizedBox(width: 3),
PaymentIcon(iconUrl: iconUrls[i], size: iconSize, color: HaloTokens.brandDark),
],
];
return Container(
height: 40,
constraints: BoxConstraints(minWidth: multi ? 0 : 40),
padding: EdgeInsets.symmetric(horizontal: multi ? 6 : 0),
decoration: BoxDecoration(
color: HaloTokens.surface,
borderRadius: HaloRadius.md,
border: Border.all(color: HaloTokens.border),
),
alignment: Alignment.center,
child: Row(mainAxisSize: MainAxisSize.min, children: children),
);
}
}
class _MethodTile extends StatelessWidget {
final PaymentMethodEntry method;
final bool selected;
final VoidCallback onTap;
final String? disabledReason;
final VoidCallback? onTap;
const _MethodTile({
required this.method,
required this.selected,
required this.disabledReason,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final disabled = disabledReason != null;
return Padding(
padding: const EdgeInsets.only(bottom: HaloSpacing.s8),
child: Material(
color: selected ? HaloTokens.brandSofter : HaloTokens.surface,
borderRadius: HaloRadius.lg,
child: InkWell(
child: Opacity(
opacity: disabled ? 0.5 : 1.0,
child: Material(
color: selected ? HaloTokens.brandSofter : HaloTokens.surface,
borderRadius: HaloRadius.lg,
onTap: onTap,
child: AnimatedContainer(
duration: HaloMotion.fast,
padding: const EdgeInsets.all(HaloSpacing.s12),
decoration: BoxDecoration(
border: Border.all(
color: selected ? HaloTokens.brand : HaloTokens.border,
width: selected ? 2 : 1,
child: InkWell(
borderRadius: HaloRadius.lg,
onTap: onTap,
child: AnimatedContainer(
duration: HaloMotion.fast,
padding: const EdgeInsets.all(HaloSpacing.s12),
decoration: BoxDecoration(
border: Border.all(
color: selected ? HaloTokens.brand : HaloTokens.border,
width: selected ? 2 : 1,
),
borderRadius: HaloRadius.lg,
),
borderRadius: HaloRadius.lg,
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: HaloTokens.surface,
borderRadius: HaloRadius.md,
border: Border.all(color: HaloTokens.border),
),
alignment: Alignment.center,
child: PaymentIcon(
slug: method.icon,
size: 22,
color: HaloTokens.brandDark,
),
),
const SizedBox(width: HaloSpacing.s12),
Expanded(
child: Text(
method.displayName,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: HaloTokens.ink,
),
),
),
Container(
width: 20,
height: 20,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: selected ? HaloTokens.brand : HaloTokens.border,
width: 2,
),
color: selected ? HaloTokens.brand : HaloTokens.surface,
),
child: selected
? Center(
child: Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Colors.white,
child: Row(
children: [
_MethodIconBox(iconUrls: method.iconUrls),
const SizedBox(width: HaloSpacing.s12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
method.displayName,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: HaloTokens.ink,
),
),
if (disabled)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
disabledReason!,
style: const TextStyle(
fontSize: 11.5,
color: HaloTokens.inkMuted,
),
),
),
)
: null,
),
],
],
),
),
Container(
width: 20,
height: 20,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: selected ? HaloTokens.brand : HaloTokens.border,
width: 2,
),
color: selected ? HaloTokens.brand : HaloTokens.surface,
),
child: selected
? Center(
child: Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Colors.white,
),
),
)
: null,
),
],
),
),
),
),