Display used and reserved limits capacity
This commit is contained in:
1 parent
ec43d34b4b
commit
2e81c43091
14 files changed
+287
-5
No files matched your search
@@ -168,3 +168,18 @@ Documentation only; no runtime tests were run.
|
|||||||
server-side review analytics remain integration work. Native mobile and
|
server-side review analytics remain integration work. Native mobile and
|
||||||
VoiceOver/TalkBack checks remain release QA. Security scans were not requested.
|
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.
|
- 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.
|
||||||
@@ -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
|
checklist was reviewed against the implemented demo scope; production/backend
|
||||||
and native assistive-technology gaps are recorded above rather than represented
|
and native assistive-technology gaps are recorded above rather than represented
|
||||||
as complete.
|
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.
|
||||||
@@ -991,6 +991,36 @@ abstract class AppLocalizations {
|
|||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Status could not be refreshed. The status shown is the last known result.'**
|
/// **'Status could not be refreshed. The status shown is the last known result.'**
|
||||||
String get kycStatusStale;
|
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
|
class _AppLocalizationsDelegate
|
||||||
|
|||||||
@@ -502,4 +502,20 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get kycStatusStale =>
|
String get kycStatusStale =>
|
||||||
'Status could not be refreshed. The status shown is the last known result.';
|
'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';
|
||||||
}
|
}
|
||||||
@@ -503,4 +503,20 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get kycStatusStale =>
|
String get kycStatusStale =>
|
||||||
'Не удалось обновить статус. Показан последний известный результат.';
|
'Не удалось обновить статус. Показан последний известный результат.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycUsed => 'Использовано';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycReserved => 'Зарезервировано';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycUsageHelp =>
|
||||||
|
'Использовано — завершённые операции. Зарезервировано — суммы, удержанные для ожидающих операций. Обе суммы учитываются в лимите.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycOccupied => 'Использовано + зарезервировано';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycNoCapacity => 'Доступный объём исчерпан';
|
||||||
}
|
}
|
||||||
@@ -49,6 +49,8 @@ class LimitsPanel extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
if (data.simulated)
|
if (data.simulated)
|
||||||
Text(l.kycLimitsDemo, style: t.textTheme.bodySmall),
|
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),
|
if (data.rows.isEmpty) Text(l.kycLimitsEmpty),
|
||||||
for (final row in data.rows) ...[
|
for (final row in data.rows) ...[
|
||||||
const Divider(height: 32),
|
const Divider(height: 32),
|
||||||
@@ -67,6 +69,13 @@ class LimitsPanel extends StatelessWidget {
|
|||||||
row.currentMinor,
|
row.currentMinor,
|
||||||
row.currency,
|
row.currency,
|
||||||
),
|
),
|
||||||
|
_amount(context, l.kycUsed, row.usedMinor, row.currency),
|
||||||
|
_amount(
|
||||||
|
context,
|
||||||
|
l.kycReserved,
|
||||||
|
row.reservedMinor,
|
||||||
|
row.currency,
|
||||||
|
),
|
||||||
_amount(
|
_amount(
|
||||||
context,
|
context,
|
||||||
l.kycRemaining,
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -148,5 +148,10 @@
|
|||||||
"kycDemoDeny": "Decline",
|
"kycDemoDeny": "Decline",
|
||||||
"kycPreview": "Explore verification & limits",
|
"kycPreview": "Explore verification & limits",
|
||||||
"kycChooseFirst": "Choose a photo to continue.",
|
"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"
|
||||||
}
|
}
|
||||||
@@ -148,5 +148,10 @@
|
|||||||
"kycDemoDeny": "Отклонить",
|
"kycDemoDeny": "Отклонить",
|
||||||
"kycPreview": "Посмотреть верификацию и лимиты",
|
"kycPreview": "Посмотреть верификацию и лимиты",
|
||||||
"kycChooseFirst": "Выберите фото для продолжения.",
|
"kycChooseFirst": "Выберите фото для продолжения.",
|
||||||
"kycStatusStale": "Не удалось обновить статус. Показан последний известный результат."
|
"kycStatusStale": "Не удалось обновить статус. Показан последний известный результат.",
|
||||||
|
"kycUsed": "Использовано",
|
||||||
|
"kycReserved": "Зарезервировано",
|
||||||
|
"kycUsageHelp": "Использовано — завершённые операции. Зарезервировано — суммы, удержанные для ожидающих операций. Обе суммы учитываются в лимите.",
|
||||||
|
"kycOccupied": "Использовано + зарезервировано",
|
||||||
|
"kycNoCapacity": "Доступный объём исчерпан"
|
||||||
}
|
}
|
||||||
@@ -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<Limits> 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<LinearProgressIndicator>(
|
||||||
|
find.byKey(const ValueKey('usage-day')),
|
||||||
|
)
|
||||||
|
.value,
|
||||||
|
0.4,
|
||||||
|
);
|
||||||
|
expect(find.textContaining('55'), findsOneWidget);
|
||||||
|
expect(tester.takeException(), null);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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).
|
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;
|
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.
|
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.
|
||||||
@@ -10,6 +10,8 @@ class VisibleLimit {
|
|||||||
required this.verifiedMinor,
|
required this.verifiedMinor,
|
||||||
required this.currentMinor,
|
required this.currentMinor,
|
||||||
required this.remainingMinor,
|
required this.remainingMinor,
|
||||||
|
required this.usedMinor,
|
||||||
|
required this.reservedMinor,
|
||||||
});
|
});
|
||||||
final LimitPeriod period;
|
final LimitPeriod period;
|
||||||
final String currency;
|
final String currency;
|
||||||
@@ -17,6 +19,13 @@ class VisibleLimit {
|
|||||||
final int verifiedMinor;
|
final int verifiedMinor;
|
||||||
final int currentMinor;
|
final int currentMinor;
|
||||||
final int remainingMinor;
|
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 {
|
class Limits {
|
||||||
|
|||||||
@@ -76,7 +76,9 @@ class DummyLimitsService implements LimitsService {
|
|||||||
10000,
|
10000,
|
||||||
250000,
|
250000,
|
||||||
approved ? 250000 : 10000,
|
approved ? 250000 : 10000,
|
||||||
approved ? 250000 : 10000,
|
(approved ? 250000 : 10000) - 4000,
|
||||||
|
3000,
|
||||||
|
1000,
|
||||||
),
|
),
|
||||||
LimitDTO(
|
LimitDTO(
|
||||||
'month',
|
'month',
|
||||||
@@ -84,7 +86,9 @@ class DummyLimitsService implements LimitsService {
|
|||||||
50000,
|
50000,
|
||||||
1000000,
|
1000000,
|
||||||
approved ? 1000000 : 50000,
|
approved ? 1000000 : 50000,
|
||||||
approved ? 1000000 : 50000,
|
(approved ? 1000000 : 50000) - 19000,
|
||||||
|
18000,
|
||||||
|
1000,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -38,10 +38,12 @@ class LimitDTO {
|
|||||||
this.verified,
|
this.verified,
|
||||||
this.current,
|
this.current,
|
||||||
this.remaining,
|
this.remaining,
|
||||||
|
this.used,
|
||||||
|
this.reserved,
|
||||||
);
|
);
|
||||||
final String period;
|
final String period;
|
||||||
final String currency;
|
final String currency;
|
||||||
final int basic, verified, current, remaining;
|
final int basic, verified, current, remaining, used, reserved;
|
||||||
}
|
}
|
||||||
|
|
||||||
class LimitsMapper {
|
class LimitsMapper {
|
||||||
@@ -57,6 +59,8 @@ class LimitsMapper {
|
|||||||
verifiedMinor: r.verified,
|
verifiedMinor: r.verified,
|
||||||
currentMinor: r.current,
|
currentMinor: r.current,
|
||||||
remainingMinor: r.remaining,
|
remainingMinor: r.remaining,
|
||||||
|
usedMinor: r.used,
|
||||||
|
reservedMinor: r.reserved,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in new issue
Block a user