Connect customer quotations and payment execution to Sendico (#50)

This commit is contained in:
Stephan D committed 2026-10-16 09:57:28 +02:00
1 parent 5d67c25c8e
commit 7a6510d1a8
45 files changed
+2696 -45

No files matched your search

+9 -1
View File
@@ -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/`.
+13 -1
View File
@@ -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"
}
}
}
}
+13 -1
View File
@@ -277,5 +277,17 @@
"kycOperationMaximum": "Максимум на операцию",
"kycErrorAccess": "Проверка недоступна для этого аккаунта. Если это повторяется, обратитесь в поддержку.",
"kycUpgradePendingTitle": "Ожидается повышение лимитов",
"kycUpgradePendingBody": "Проверка личности одобрена. Ожидается подтверждение повышения лимитов. Отправлять фото повторно не нужно."
"kycUpgradePendingBody": "Проверка личности одобрена. Ожидается подтверждение повышения лимитов. Отправлять фото повторно не нужно.",
"wNoPayoutMethod": "Для этого счёта не настроен способ вывода. Обратитесь в поддержку.",
"wNoFundingMethod": "Для этого счёта не настроен способ пополнения. Обратитесь в поддержку за инструкциями.",
"wLivePending": "Ожидается окончательный результат платежа. Обновите страницу, чтобы проверить статус и баланс.",
"wLiveFailed": "Платёж не завершён. Обновите счета, чтобы проверить доступный баланс.",
"wLiveQuoteNotice": "Предложение истекает через {seconds} сек.",
"@wLiveQuoteNotice": {
"placeholders": {
"seconds": {
"type": "int"
}
}
}
}
+24 -6
View File
@@ -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();
+19 -11
View File
@@ -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,
+1 -1
View File
@@ -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
+45 -10
View File
@@ -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)
+101
View File
@@ -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());
},
);
}
}