Connect customer quotations and payment execution to Sendico (#50)
This commit is contained in:
1 parent
5d67c25c8e
commit
7a6510d1a8
45 files changed
+2696
-45
No files matched your search
@@ -15,7 +15,15 @@ flutter run -d chrome
|
||||
|
||||
A responsive login/sign-up preview with a themed brand panel, email and password validation, password visibility controls, keyboard submission, and inline feedback. Sign-up additionally asks for a name and matching password confirmation. The 12-character sign-up minimum is a UI preview policy and must be aligned with the backend when authentication is integrated.
|
||||
|
||||
No authentication endpoint is configured. Submission validates locally and calls the shared dummy authentication services; it never creates an account, starts a session, sends credentials, or persists them. Passwords are cleared when switching modes. Password managers/autofill integration is deferred until real authentication exists. LoginProvider and SignupProvider own loading/results/errors; SxchngeApp accepts injected loginService and signupService implementations. Dummy completion is explicitly labeled and does not establish a real session.
|
||||
Without backend configuration the app uses dummy services. The existing
|
||||
`SXCHNGE_API_URL` and `SXCHNGE_CLIENT_ID` select authenticated backend auth,
|
||||
identity, limits and wallet adapters. Wallet operations use Sendico through the
|
||||
BFF after the provider prerequisites and dedicated customer resource bindings
|
||||
are provisioned; they do not use demo balances in backend mode. LoginProvider,
|
||||
SignupProvider and shared wallet providers own loading/results/errors.
|
||||
SxchngeApp retains explicit service injection for tests and previews. See
|
||||
[quotation/payment integration](../../docs/payments.md) for setup, supported
|
||||
funding/destinations, authorization, reconciliation and rollout.
|
||||
|
||||
The interface uses `ColorScheme` and `TextTheme` roles from `sxchnge_theme`; the desktop brand panel installs the package's dark theme locally. Entrance animation respects reduced-motion preferences. The public website remains separate in `website/`.
|
||||
|
||||
|
||||
@@ -277,5 +277,17 @@
|
||||
"kycOperationMaximum": "Per operation maximum",
|
||||
"kycErrorAccess": "Verification is unavailable for this account. Contact support if this continues.",
|
||||
"kycUpgradePendingTitle": "Limits upgrade pending",
|
||||
"kycUpgradePendingBody": "Your identity review is approved. We are waiting for confirmation of the limits upgrade. You do not need to submit your photos again."
|
||||
"kycUpgradePendingBody": "Your identity review is approved. We are waiting for confirmation of the limits upgrade. You do not need to submit your photos again.",
|
||||
"wNoPayoutMethod": "No payout destination is set up for this account. Contact support to add one.",
|
||||
"wNoFundingMethod": "No funding method is set up for this account. Contact support for funding instructions.",
|
||||
"wLivePending": "Your payment is awaiting a final outcome. Refresh to see its latest status and balance.",
|
||||
"wLiveFailed": "Your payment did not complete. Refresh your accounts to see the current available balance.",
|
||||
"wLiveQuoteNotice": "Quote expires in {seconds} seconds.",
|
||||
"@wLiveQuoteNotice": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -277,5 +277,17 @@
|
||||
"kycOperationMaximum": "Максимум на операцию",
|
||||
"kycErrorAccess": "Проверка недоступна для этого аккаунта. Если это повторяется, обратитесь в поддержку.",
|
||||
"kycUpgradePendingTitle": "Ожидается повышение лимитов",
|
||||
"kycUpgradePendingBody": "Проверка личности одобрена. Ожидается подтверждение повышения лимитов. Отправлять фото повторно не нужно."
|
||||
"kycUpgradePendingBody": "Проверка личности одобрена. Ожидается подтверждение повышения лимитов. Отправлять фото повторно не нужно.",
|
||||
"wNoPayoutMethod": "Для этого счёта не настроен способ вывода. Обратитесь в поддержку.",
|
||||
"wNoFundingMethod": "Для этого счёта не настроен способ пополнения. Обратитесь в поддержку за инструкциями.",
|
||||
"wLivePending": "Ожидается окончательный результат платежа. Обновите страницу, чтобы проверить статус и баланс.",
|
||||
"wLiveFailed": "Платёж не завершён. Обновите счета, чтобы проверить доступный баланс.",
|
||||
"wLiveQuoteNotice": "Предложение истекает через {seconds} сек.",
|
||||
"@wLiveQuoteNotice": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,8 +63,11 @@ class _SxchngeAppState extends State<SxchngeApp> {
|
||||
late final _locale = widget.localeProvider ?? LocaleProvider();
|
||||
|
||||
late final _analytics = widget.analyticsService ?? DummyAnalyticsService();
|
||||
late final _factors =
|
||||
late final _authFactors =
|
||||
widget.backend ?? widget.twoFactorService ?? DummyTwoFactorService();
|
||||
late final _factors = _walletService is BackendWalletService
|
||||
? WalletFactorService(_walletService, _authFactors)
|
||||
: _authFactors;
|
||||
late final _security = SecurityProvider(_factors, _analytics);
|
||||
late final _identity =
|
||||
widget.identityService ??
|
||||
@@ -76,11 +79,16 @@ class _SxchngeAppState extends State<SxchngeApp> {
|
||||
));
|
||||
late final _walletService =
|
||||
widget.walletService ??
|
||||
DummyWalletService(
|
||||
_identity,
|
||||
initialLocale: _locale.locale.languageCode,
|
||||
factors: _factors,
|
||||
);
|
||||
(widget.backend != null
|
||||
? BackendWalletService(
|
||||
baseUri: widget.backend!.baseUri,
|
||||
accessToken: () => widget.backend!.session?.accessToken,
|
||||
)
|
||||
: DummyWalletService(
|
||||
_identity,
|
||||
initialLocale: _locale.locale.languageCode,
|
||||
factors: _authFactors,
|
||||
));
|
||||
late final _wallet = WalletProvider(
|
||||
_walletService,
|
||||
_analytics,
|
||||
@@ -109,14 +117,21 @@ class _SxchngeAppState extends State<SxchngeApp> {
|
||||
: () => widget.backend!.session != null,
|
||||
)..routeInformationProvider.addListener(_routeChanged);
|
||||
String _previousPath = '/';
|
||||
String? _walletCustomer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_previousPath = _router.routeInformationProvider.value.uri.path;
|
||||
_walletCustomer = widget.backend?.session?.userId;
|
||||
}
|
||||
|
||||
void _routeChanged() {
|
||||
final customer = widget.backend?.session?.userId;
|
||||
if (customer != _walletCustomer) {
|
||||
_walletCustomer = customer;
|
||||
_wallet.reset();
|
||||
}
|
||||
final path = _router.routeInformationProvider.value.uri.path;
|
||||
if (path == '/' && _previousPath != '/' && _previousPath != '/two-factor') {
|
||||
_journeys.abandon(JourneyExit.navigation);
|
||||
@@ -130,6 +145,9 @@ class _SxchngeAppState extends State<SxchngeApp> {
|
||||
_router.dispose();
|
||||
_journeys.dispose();
|
||||
_wallet.dispose();
|
||||
if (_walletService is BackendWalletService) {
|
||||
_walletService.close();
|
||||
}
|
||||
_security.dispose();
|
||||
if (_identity is BackendIdentityService) {
|
||||
_identity.close();
|
||||
|
||||
@@ -28,17 +28,25 @@ String statusText(AppLocalizations l, OperationStatus s) => switch (s) {
|
||||
OperationStatus.completed => l.wCompleted,
|
||||
OperationStatus.failed => l.wFailed,
|
||||
};
|
||||
String fundsText(AppLocalizations l, WalletOperation o) =>
|
||||
switch ((o.status, o.quote.request.kind)) {
|
||||
(OperationStatus.pending, TransferKind.topUp) => l.wPendingTopUp,
|
||||
(OperationStatus.pending, TransferKind.exchange) => l.wPendingExchange,
|
||||
(OperationStatus.pending, TransferKind.payout) => l.wPendingPayout,
|
||||
(OperationStatus.completed, TransferKind.topUp) => l.wCompleteTopUp,
|
||||
(OperationStatus.completed, TransferKind.exchange) => l.wCompleteExchange,
|
||||
(OperationStatus.completed, TransferKind.payout) => l.wCompletePayout,
|
||||
(OperationStatus.failed, TransferKind.topUp) => l.wFailedTopUp,
|
||||
_ => l.wFailedOutgoing,
|
||||
};
|
||||
String fundsText(
|
||||
AppLocalizations l,
|
||||
WalletOperation o, {
|
||||
bool simulated = true,
|
||||
}) => !simulated && o.status == OperationStatus.pending
|
||||
? l.wLivePending
|
||||
: !simulated && o.status == OperationStatus.failed
|
||||
? l.wLiveFailed
|
||||
: switch ((o.status, o.quote.request.kind)) {
|
||||
(OperationStatus.pending, TransferKind.topUp) => l.wPendingTopUp,
|
||||
(OperationStatus.pending, TransferKind.exchange) => l.wPendingExchange,
|
||||
(OperationStatus.pending, TransferKind.payout) => l.wPendingPayout,
|
||||
(OperationStatus.completed, TransferKind.topUp) => l.wCompleteTopUp,
|
||||
(OperationStatus.completed, TransferKind.exchange) =>
|
||||
l.wCompleteExchange,
|
||||
(OperationStatus.completed, TransferKind.payout) => l.wCompletePayout,
|
||||
(OperationStatus.failed, TransferKind.topUp) => l.wFailedTopUp,
|
||||
_ => l.wFailedOutgoing,
|
||||
};
|
||||
String walletError(AppLocalizations l, WalletError e) => switch (e) {
|
||||
WalletError.invalidAmount => l.wInvalidAmount,
|
||||
WalletError.insufficientFunds => l.wInsufficient,
|
||||
|
||||
@@ -97,7 +97,7 @@ class _OperationPageState extends State<OperationPage> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
fundsText(l, o),
|
||||
fundsText(l, o, simulated: p.service.isSimulated),
|
||||
style: TextStyle(
|
||||
color: o.status == OperationStatus.failed
|
||||
? c.onErrorContainer
|
||||
|
||||
@@ -133,6 +133,15 @@ class TransferPageState extends State<TransferPage> {
|
||||
final t = Theme.of(context);
|
||||
final accounts = wallet.data?.accounts ?? [];
|
||||
final source = wallet.account(p.source);
|
||||
final live = !wallet.service.isSimulated;
|
||||
final expiresIn =
|
||||
p.quote?.expiresAt.difference(DateTime.now()).inSeconds ?? 0;
|
||||
final unavailableMethod =
|
||||
live &&
|
||||
((widget.kind == TransferKind.payout &&
|
||||
(source?.payoutMethod.isEmpty ?? true)) ||
|
||||
(widget.kind == TransferKind.topUp &&
|
||||
!(source?.canFund ?? false)));
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(
|
||||
@@ -207,7 +216,15 @@ class TransferPageState extends State<TransferPage> {
|
||||
] else if (p.quote != null) ...[
|
||||
TransferSummary(quote: p.quote!),
|
||||
const SizedBox(height: 16),
|
||||
Text(p.expired ? l.wExpired : l.wQuoteNotice),
|
||||
Text(
|
||||
p.expired
|
||||
? l.wExpired
|
||||
: live
|
||||
? l.wLiveQuoteNotice(
|
||||
expiresIn < 0 ? 0 : expiresIn,
|
||||
)
|
||||
: l.wQuoteNotice,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (p.expired)
|
||||
FilledButton(
|
||||
@@ -299,21 +316,39 @@ class TransferPageState extends State<TransferPage> {
|
||||
),
|
||||
if (widget.kind == TransferKind.payout) ...[
|
||||
const SizedBox(height: 20),
|
||||
TextField(
|
||||
key: const ValueKey('transfer-recipient'),
|
||||
controller: _recipient,
|
||||
enabled: !p.busy,
|
||||
decoration: InputDecoration(
|
||||
labelText: l.wRecipient,
|
||||
hintText: l.wRecipientHint,
|
||||
if (live && source?.payoutMethod.isNotEmpty == true)
|
||||
InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
labelText: l.wRecipient,
|
||||
),
|
||||
child: Text(source!.payoutLabel),
|
||||
),
|
||||
onChanged: (v) => p.change(recipient: v),
|
||||
if (!live)
|
||||
TextField(
|
||||
key: const ValueKey('transfer-recipient'),
|
||||
controller: _recipient,
|
||||
enabled: !p.busy,
|
||||
decoration: InputDecoration(
|
||||
labelText: l.wRecipient,
|
||||
hintText: l.wRecipientHint,
|
||||
),
|
||||
onChanged: (v) => p.change(recipient: v),
|
||||
),
|
||||
],
|
||||
if (unavailableMethod) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
widget.kind == TransferKind.payout
|
||||
? l.wNoPayoutMethod
|
||||
: l.wNoFundingMethod,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 28),
|
||||
FilledButton(
|
||||
key: const ValueKey('transfer-review'),
|
||||
onPressed: p.busy ? null : p.review,
|
||||
onPressed: p.busy || unavailableMethod
|
||||
? null
|
||||
: p.review,
|
||||
child: Text(l.wReview),
|
||||
),
|
||||
if (p.error == WalletError.limitExceeded)
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||
|
||||
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||
|
||||
import 'package:sxchnge_app/main.dart';
|
||||
|
||||
const destinationLabel =
|
||||
'Saved bank ending 1234 — Personal current account for daily transfers and reimbursements';
|
||||
|
||||
class LiveWallet extends DummyWalletService {
|
||||
LiveWallet({this.hasMethod = true})
|
||||
: super(DummyIdentityService(delay: Duration.zero), delay: Duration.zero);
|
||||
final bool hasMethod;
|
||||
String? received;
|
||||
@override
|
||||
bool get isSimulated => false;
|
||||
@override
|
||||
Future<WalletSnapshot> fetch() async => WalletSnapshot(
|
||||
accounts: [
|
||||
CurrencyAccount(
|
||||
id: 'real-account',
|
||||
currency: 'EUR',
|
||||
precision: 2,
|
||||
available: 7500,
|
||||
payoutMethod: hasMethod ? 'method-ref' : '',
|
||||
payoutLabel: hasMethod ? destinationLabel : '',
|
||||
canFund: false,
|
||||
),
|
||||
],
|
||||
operations: [],
|
||||
);
|
||||
@override
|
||||
Future<Quote> quote(TransferRequest request) async {
|
||||
received = request.recipient;
|
||||
return Quote(
|
||||
id: 'quote',
|
||||
request: TransferRequest(
|
||||
kind: request.kind,
|
||||
source: request.source,
|
||||
amount: request.amount,
|
||||
recipient: destinationLabel,
|
||||
),
|
||||
fee: 100,
|
||||
debit: request.amount + 100,
|
||||
credit: request.amount,
|
||||
expiresAt: DateTime.now().add(const Duration(minutes: 5)),
|
||||
rateNumerator: 1,
|
||||
rateDenominator: 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
for (final hasMethod in [true, false]) {
|
||||
testWidgets(
|
||||
'backend payout ${hasMethod ? 'reviews saved destination' : 'explains missing destination'} at narrow width',
|
||||
(t) async {
|
||||
t.view.physicalSize = const Size(320, 1100);
|
||||
t.view.devicePixelRatio = 1;
|
||||
t.platformDispatcher.textScaleFactorTestValue = 1.6;
|
||||
t.platformDispatcher.defaultRouteNameTestValue = '/payout';
|
||||
addTearDown(t.view.resetPhysicalSize);
|
||||
addTearDown(t.view.resetDevicePixelRatio);
|
||||
addTearDown(t.platformDispatcher.clearTextScaleFactorTestValue);
|
||||
addTearDown(t.platformDispatcher.clearDefaultRouteNameTestValue);
|
||||
final wallet = LiveWallet(hasMethod: hasMethod);
|
||||
await t.pumpWidget(SxchngeApp(walletService: wallet));
|
||||
await t.pumpAndSettle();
|
||||
expect(find.byKey(const ValueKey('transfer-recipient')), findsNothing);
|
||||
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '20');
|
||||
final review = find.byKey(const ValueKey('transfer-review'));
|
||||
await t.ensureVisible(review);
|
||||
if (hasMethod) {
|
||||
expect(find.text(destinationLabel), findsOneWidget);
|
||||
await t.tap(review);
|
||||
await t.pumpAndSettle();
|
||||
expect(wallet.received, 'method-ref');
|
||||
expect(find.text(destinationLabel), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const ValueKey('transfer-confirm')),
|
||||
findsOneWidget,
|
||||
);
|
||||
} else {
|
||||
expect(t.widget<FilledButton>(review).onPressed, isNull);
|
||||
expect(
|
||||
find.text(
|
||||
'No payout destination is set up for this account. Contact support to add one.',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
}
|
||||
expect(t.takeException(), isNull);
|
||||
await t.pumpWidget(const SizedBox.shrink());
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user