From 2e81c43091094d010cb4364b9f9290ede6c70798 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Sat, 12 Sep 2026 10:05:20 +0200 Subject: [PATCH] Display used and reserved limits capacity --- docs/implementation.md | 15 ++++ docs/ux/kyc.md | 15 ++++ .../lib/generated/i18n/app_localizations.dart | 30 +++++++ .../generated/i18n/app_localizations_en.dart | 16 ++++ .../generated/i18n/app_localizations_ru.dart | 16 ++++ frontend/app/lib/kyc/limits.dart | 25 ++++++ frontend/app/lib/l10n/en.arb | 7 +- frontend/app/lib/l10n/ru.arb | 7 +- frontend/app/test/limits_test.dart | 86 +++++++++++++++++++ frontend/packages/sxchnge_kyc/README.md | 5 ++ .../sxchnge_kyc/lib/src/models/limits.dart | 9 ++ .../sxchnge_kyc/lib/src/services/dummy.dart | 8 +- .../lib/src/transport/contracts.dart | 6 +- .../sxchnge_kyc/test/limits_test.dart | 47 ++++++++++ 14 files changed, 287 insertions(+), 5 deletions(-) create mode 100644 frontend/app/test/limits_test.dart create mode 100644 frontend/packages/sxchnge_kyc/test/limits_test.dart diff --git a/docs/implementation.md b/docs/implementation.md index 4743504..12e29a3 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -168,3 +168,18 @@ Documentation only; no runtime tests were run. server-side review analytics remain integration work. Native mobile and VoiceOver/TalkBack checks remain release QA. Security scans were not requested. - Delivery: [PR #21](https://git.sendico.io/tech/sxchnge/pulls/21), pushed on `kyc-20`; not merged. Final browser review also preserved heading semantics when moving keyboard focus. + +## Limits usage display — task #22 + +- Branch `limits-22`, stacked on `kyc-20` / PR #21 (merge #21 first). +- Added explicit used/committed and reserved fields through DTO, mapper, service + and model. Remaining is independently supplied by the backend stand-in. +- Daily/monthly cards show used, reserved, available, cap and upgrade amounts, + with a labeled theme-based occupied-capacity indicator and exhausted text. + Dummy fixtures contain nonzero activity; usage survives tier increases. +- UX review: EN/RU labels explain pending holds, no color-only information, + 320px/1.6-scale widget checks, actual browser visual verification, zero and + over-cap ratio handling. Existing KYC telemetry is sufficient; no usage amounts + are sent to analytics. +- Validation passed: all 91 frontend tests, analyze app and sxchnge_kyc, + format check of all 9 affected Dart files, web build and diff checks. diff --git a/docs/ux/kyc.md b/docs/ux/kyc.md index b5518d4..554e098 100644 --- a/docs/ux/kyc.md +++ b/docs/ux/kyc.md @@ -147,3 +147,18 @@ packages, `make test-frontend` (86 tests) and release web build passed. The UX checklist was reviewed against the implemented demo scope; production/backend and native assistive-technology gaps are recorded above rather than represented as complete. + +## Limits usage correction — task #22 + +Visible limits include backend-provided committed (`usedMinor`) and reserved +(`reservedMinor`) usage, matching Sendico's `VisibleLimitSlot`. Show both amounts, +the current cap and authoritative remaining capacity for each period. A labeled +theme-based indicator visualizes used plus reserved against the cap; clamp only +the visual ratio, never rewrite the returned monetary values. No capacity is +communicated in text when remaining is zero. Daily/monthly demo fixtures now have +nonzero activity, retained when caps increase after approval. + +This is a display correction within the existing KYC journey, so no additional +analytics events or financial data collection are introduced. Tests cover mapping +without inferring remaining, unused/exhausted/over-cap/zero-cap ratios and EN/RU +usage display at 320px with 1.6 text scaling. diff --git a/frontend/app/lib/generated/i18n/app_localizations.dart b/frontend/app/lib/generated/i18n/app_localizations.dart index 7188c75..32dec96 100644 --- a/frontend/app/lib/generated/i18n/app_localizations.dart +++ b/frontend/app/lib/generated/i18n/app_localizations.dart @@ -991,6 +991,36 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Status could not be refreshed. The status shown is the last known result.'** String get kycStatusStale; + + /// No description provided for @kycUsed. + /// + /// In en, this message translates to: + /// **'Used'** + String get kycUsed; + + /// No description provided for @kycReserved. + /// + /// In en, this message translates to: + /// **'Reserved'** + String get kycReserved; + + /// No description provided for @kycUsageHelp. + /// + /// In en, this message translates to: + /// **'Used is completed activity. Reserved is held for pending transactions. Both count toward your limit.'** + String get kycUsageHelp; + + /// No description provided for @kycOccupied. + /// + /// In en, this message translates to: + /// **'Used + reserved'** + String get kycOccupied; + + /// No description provided for @kycNoCapacity. + /// + /// In en, this message translates to: + /// **'No capacity available'** + String get kycNoCapacity; } class _AppLocalizationsDelegate diff --git a/frontend/app/lib/generated/i18n/app_localizations_en.dart b/frontend/app/lib/generated/i18n/app_localizations_en.dart index 626a86b..be62464 100644 --- a/frontend/app/lib/generated/i18n/app_localizations_en.dart +++ b/frontend/app/lib/generated/i18n/app_localizations_en.dart @@ -502,4 +502,20 @@ class AppLocalizationsEn extends AppLocalizations { @override String get kycStatusStale => 'Status could not be refreshed. The status shown is the last known result.'; + + @override + String get kycUsed => 'Used'; + + @override + String get kycReserved => 'Reserved'; + + @override + String get kycUsageHelp => + 'Used is completed activity. Reserved is held for pending transactions. Both count toward your limit.'; + + @override + String get kycOccupied => 'Used + reserved'; + + @override + String get kycNoCapacity => 'No capacity available'; } diff --git a/frontend/app/lib/generated/i18n/app_localizations_ru.dart b/frontend/app/lib/generated/i18n/app_localizations_ru.dart index 9e856f0..b85054f 100644 --- a/frontend/app/lib/generated/i18n/app_localizations_ru.dart +++ b/frontend/app/lib/generated/i18n/app_localizations_ru.dart @@ -503,4 +503,20 @@ class AppLocalizationsRu extends AppLocalizations { @override String get kycStatusStale => 'Не удалось обновить статус. Показан последний известный результат.'; + + @override + String get kycUsed => 'Использовано'; + + @override + String get kycReserved => 'Зарезервировано'; + + @override + String get kycUsageHelp => + 'Использовано — завершённые операции. Зарезервировано — суммы, удержанные для ожидающих операций. Обе суммы учитываются в лимите.'; + + @override + String get kycOccupied => 'Использовано + зарезервировано'; + + @override + String get kycNoCapacity => 'Доступный объём исчерпан'; } diff --git a/frontend/app/lib/kyc/limits.dart b/frontend/app/lib/kyc/limits.dart index 724ba85..2e39513 100644 --- a/frontend/app/lib/kyc/limits.dart +++ b/frontend/app/lib/kyc/limits.dart @@ -49,6 +49,8 @@ class LimitsPanel extends StatelessWidget { ), if (data.simulated) Text(l.kycLimitsDemo, style: t.textTheme.bodySmall), + const SizedBox(height: 12), + Text(l.kycUsageHelp, style: t.textTheme.bodySmall), if (data.rows.isEmpty) Text(l.kycLimitsEmpty), for (final row in data.rows) ...[ const Divider(height: 32), @@ -67,6 +69,13 @@ class LimitsPanel extends StatelessWidget { row.currentMinor, row.currency, ), + _amount(context, l.kycUsed, row.usedMinor, row.currency), + _amount( + context, + l.kycReserved, + row.reservedMinor, + row.currency, + ), _amount( context, l.kycRemaining, @@ -82,6 +91,22 @@ class LimitsPanel extends StatelessWidget { ), ], ), + const SizedBox(height: 16), + LinearProgressIndicator( + key: ValueKey('usage-${row.period.name}'), + value: row.occupiedFraction, + minHeight: 8, + borderRadius: BorderRadius.circular(4), + semanticsLabel: + '${row.period == LimitPeriod.day ? l.kycDay : l.kycMonth}: ${l.kycOccupied}', + ), + if (row.remainingMinor <= 0) ...[ + const SizedBox(height: 8), + Text( + l.kycNoCapacity, + style: TextStyle(color: t.colorScheme.error), + ), + ], ], ], ], diff --git a/frontend/app/lib/l10n/en.arb b/frontend/app/lib/l10n/en.arb index af54b29..3cf71c8 100644 --- a/frontend/app/lib/l10n/en.arb +++ b/frontend/app/lib/l10n/en.arb @@ -148,5 +148,10 @@ "kycDemoDeny": "Decline", "kycPreview": "Explore verification & limits", "kycChooseFirst": "Choose a photo to continue.", - "kycStatusStale": "Status could not be refreshed. The status shown is the last known result." + "kycStatusStale": "Status could not be refreshed. The status shown is the last known result.", + "kycUsed": "Used", + "kycReserved": "Reserved", + "kycUsageHelp": "Used is completed activity. Reserved is held for pending transactions. Both count toward your limit.", + "kycOccupied": "Used + reserved", + "kycNoCapacity": "No capacity available" } diff --git a/frontend/app/lib/l10n/ru.arb b/frontend/app/lib/l10n/ru.arb index 21eb357..b967206 100644 --- a/frontend/app/lib/l10n/ru.arb +++ b/frontend/app/lib/l10n/ru.arb @@ -148,5 +148,10 @@ "kycDemoDeny": "Отклонить", "kycPreview": "Посмотреть верификацию и лимиты", "kycChooseFirst": "Выберите фото для продолжения.", - "kycStatusStale": "Не удалось обновить статус. Показан последний известный результат." + "kycStatusStale": "Не удалось обновить статус. Показан последний известный результат.", + "kycUsed": "Использовано", + "kycReserved": "Зарезервировано", + "kycUsageHelp": "Использовано — завершённые операции. Зарезервировано — суммы, удержанные для ожидающих операций. Обе суммы учитываются в лимите.", + "kycOccupied": "Использовано + зарезервировано", + "kycNoCapacity": "Доступный объём исчерпан" } diff --git a/frontend/app/test/limits_test.dart b/frontend/app/test/limits_test.dart new file mode 100644 index 0000000..553bebd --- /dev/null +++ b/frontend/app/test/limits_test.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:provider/provider.dart'; + +import 'package:sxchnge_kyc/sxchnge_kyc.dart'; + +import 'package:sxchnge_theme/sxchnge_theme.dart'; + +import 'package:sxchnge_app/kyc/limits.dart'; + +import 'package:sxchnge_app/generated/i18n/app_localizations.dart'; + +class Service implements LimitsService { + Service(this.row); + final VisibleLimit row; + @override + Future fetch() async => + Limits(tier: AccountTier.basic, rows: [row], simulated: true); +} + +void main() { + for (final locale in ['en', 'ru']) { + testWidgets( + 'usage and authoritative available capacity fit narrow $locale layout', + (tester) async { + tester.view.physicalSize = const Size(320, 1800); + tester.view.devicePixelRatio = 1; + tester.platformDispatcher.textScaleFactorTestValue = 1.6; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + final provider = LimitsProvider( + Service( + const VisibleLimit( + period: LimitPeriod.day, + currency: 'EUR', + basicMinor: 10000, + verifiedMinor: 250000, + currentMinor: 10000, + remainingMinor: 5500, + usedMinor: 3000, + reservedMinor: 1000, + ), + ), + ); + addTearDown(provider.dispose); + await provider.refresh(); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: provider, + child: MaterialApp( + theme: SxchngeTheme.light(), + locale: Locale(locale), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold( + body: SingleChildScrollView(child: LimitsPanel()), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect( + find.text(locale == 'en' ? 'Used' : 'Использовано'), + findsOneWidget, + ); + expect( + find.text(locale == 'en' ? 'Reserved' : 'Зарезервировано'), + findsOneWidget, + ); + expect( + tester + .widget( + find.byKey(const ValueKey('usage-day')), + ) + .value, + 0.4, + ); + expect(find.textContaining('55'), findsOneWidget); + expect(tester.takeException(), null); + }, + ); + } +} diff --git a/frontend/packages/sxchnge_kyc/README.md b/frontend/packages/sxchnge_kyc/README.md index 18181be..afe46bf 100644 --- a/frontend/packages/sxchnge_kyc/README.md +++ b/frontend/packages/sxchnge_kyc/README.md @@ -36,3 +36,8 @@ Photo selection uses Flutter's [file_selector](https://pub.dev/packages/file_sel and [image metadata decoding](https://pub.dev/documentation/image/latest/image/Decoder-class.html). For native macOS consumers enable the user-selected read-only file entitlement; iOS/Android camera permissions are not required for this file-picker-only flow. + +Visible limit rows also expose `usedMinor` (committed activity) and `reservedMinor` +(pending holds). `remainingMinor` is independently provided by the backend and +must not be inferred as cap minus those fields. The display-only occupied ratio +includes used plus reserved and is bounded for progress rendering. diff --git a/frontend/packages/sxchnge_kyc/lib/src/models/limits.dart b/frontend/packages/sxchnge_kyc/lib/src/models/limits.dart index 6e58621..476e028 100644 --- a/frontend/packages/sxchnge_kyc/lib/src/models/limits.dart +++ b/frontend/packages/sxchnge_kyc/lib/src/models/limits.dart @@ -10,6 +10,8 @@ class VisibleLimit { required this.verifiedMinor, required this.currentMinor, required this.remainingMinor, + required this.usedMinor, + required this.reservedMinor, }); final LimitPeriod period; final String currency; @@ -17,6 +19,13 @@ class VisibleLimit { final int verifiedMinor; final int currentMinor; final int remainingMinor; + final int usedMinor; + final int reservedMinor; + + /// Presentation only: remaining capacity and payment authorization stay server-owned. + double get occupiedFraction => currentMinor <= 0 + ? 0 + : ((usedMinor + reservedMinor) / currentMinor).clamp(0.0, 1.0); } class Limits { diff --git a/frontend/packages/sxchnge_kyc/lib/src/services/dummy.dart b/frontend/packages/sxchnge_kyc/lib/src/services/dummy.dart index 9dfef29..6b9a3ba 100644 --- a/frontend/packages/sxchnge_kyc/lib/src/services/dummy.dart +++ b/frontend/packages/sxchnge_kyc/lib/src/services/dummy.dart @@ -76,7 +76,9 @@ class DummyLimitsService implements LimitsService { 10000, 250000, approved ? 250000 : 10000, - approved ? 250000 : 10000, + (approved ? 250000 : 10000) - 4000, + 3000, + 1000, ), LimitDTO( 'month', @@ -84,7 +86,9 @@ class DummyLimitsService implements LimitsService { 50000, 1000000, approved ? 1000000 : 50000, - approved ? 1000000 : 50000, + (approved ? 1000000 : 50000) - 19000, + 18000, + 1000, ), ], ), diff --git a/frontend/packages/sxchnge_kyc/lib/src/transport/contracts.dart b/frontend/packages/sxchnge_kyc/lib/src/transport/contracts.dart index b15337b..04bde34 100644 --- a/frontend/packages/sxchnge_kyc/lib/src/transport/contracts.dart +++ b/frontend/packages/sxchnge_kyc/lib/src/transport/contracts.dart @@ -38,10 +38,12 @@ class LimitDTO { this.verified, this.current, this.remaining, + this.used, + this.reserved, ); final String period; final String currency; - final int basic, verified, current, remaining; + final int basic, verified, current, remaining, used, reserved; } class LimitsMapper { @@ -57,6 +59,8 @@ class LimitsMapper { verifiedMinor: r.verified, currentMinor: r.current, remainingMinor: r.remaining, + usedMinor: r.used, + reservedMinor: r.reserved, ), ) .toList(), diff --git a/frontend/packages/sxchnge_kyc/test/limits_test.dart b/frontend/packages/sxchnge_kyc/test/limits_test.dart new file mode 100644 index 0000000..4ec6978 --- /dev/null +++ b/frontend/packages/sxchnge_kyc/test/limits_test.dart @@ -0,0 +1,47 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:sxchnge_kyc/sxchnge_kyc.dart'; +import 'package:sxchnge_kyc/src/transport/contracts.dart'; + +void main() { + test('mapping preserves backend usage and independent remaining value', () { + final data = LimitsMapper.fromDTO( + const LimitsDTO( + tier: 'basic', + simulated: false, + rows: [LimitDTO('day', 'EUR', 10000, 250000, 10000, 5500, 3000, 1000)], + ), + ); + final row = data.rows.single; + expect(row.usedMinor, 3000); + expect(row.reservedMinor, 1000); + expect(row.remainingMinor, 5500); + expect(row.occupiedFraction, 0.4); + }); + test('capacity visualization handles unused, exhausted and zero caps', () { + VisibleLimit row(int cap, int used, int reserved) => VisibleLimit( + period: LimitPeriod.day, + currency: 'EUR', + basicMinor: cap, + verifiedMinor: cap, + currentMinor: cap, + remainingMinor: 0, + usedMinor: used, + reservedMinor: reserved, + ); + expect(row(10000, 0, 0).occupiedFraction, 0); + expect(row(10000, 8000, 2000).occupiedFraction, 1); + expect(row(10000, 12000, 2000).occupiedFraction, 1); + expect(row(0, 0, 0).occupiedFraction, 0); + }); + test('stub provides nonzero daily and monthly usage', () async { + final identity = DummyIdentityService(delay: Duration.zero); + final service = DummyLimitsService(identity); + final data = await service.fetch(); + expect(data.rows.first.usedMinor, 3000); + expect(data.rows.first.reservedMinor, 1000); + expect(data.rows.first.remainingMinor, 6000); + expect(data.rows.last.usedMinor, 18000); + expect(data.rows.last.remainingMinor, 31000); + }); +}