Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/ci-cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,10 @@ jobs:
if: github.event_name == 'push'
run: |
flutter build appbundle --flavor default

- name: Verify release Internet permission
if: github.event_name == 'push'
run: |
manifest="$(find build/app/intermediates/merged_manifests -name AndroidManifest.xml -print -quit)"
test -n "$manifest"
grep -q 'android.permission.INTERNET' "$manifest"
13 changes: 13 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.ripster.sossoldi">

<!-- Required by Enable Banking and every other production network call. -->
<uses-permission android:name="android.permission.INTERNET"/>

<!-- Permissions for scheduled notifications-->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
Expand Down Expand Up @@ -35,10 +38,20 @@
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<meta-data
android:name="flutter_deeplinking_enabled"
android:value="false" />
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<!-- Enable Banking OAuth callback deep link -->
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="sossoldi" android:host="eb-callback"/>
</intent-filter>
</activity>
<!-- Notifications scheduler -->
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
Expand Down
13 changes: 13 additions & 0 deletions ios/Runner/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,21 @@
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.ripster.sossoldi.eb</string>
<key>CFBundleURLSchemes</key>
<array>
<string>sossoldi</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>FlutterDeepLinkingEnabled</key>
<false/>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
Expand Down
24 changes: 24 additions & 0 deletions lib/providers/banking_provider.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// dart format width=400

import 'package:riverpod_annotation/riverpod_annotation.dart';

import '../services/banking/banking_provider.dart';
import '../services/banking/bank_institution_directory.dart' as domain;
import '../services/banking/bank_consent_service.dart' as domain;
import '../services/banking/bank_account_data_source.dart' as domain;
import '../services/banking/enable_banking/enable_banking_dependencies.dart';
import '../services/banking/enable_banking/enable_banking_provider.dart';

part 'banking_provider.g.dart';

@Riverpod(keepAlive: true)
BankingProvider bankingService(Ref ref) => EnableBankingProvider(ref.watch(enableBankingApiProvider));

@Riverpod(keepAlive: true)
domain.BankInstitutionDirectory bankInstitutionDirectory(Ref ref) => ref.watch(bankingServiceProvider).institutions;

@Riverpod(keepAlive: true)
domain.BankConsentService bankConsentService(Ref ref) => ref.watch(bankingServiceProvider).consent;

@Riverpod(keepAlive: true)
domain.BankAccountDataSource bankAccountDataSource(Ref ref) => ref.watch(bankingServiceProvider).accountData;
12 changes: 12 additions & 0 deletions lib/services/banking/bank_account_data_source.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// dart format width=400

import 'banking_account.dart';
import 'banking_balance.dart';
import 'banking_reference.dart';
import 'banking_transaction.dart';

abstract interface class BankAccountDataSource {
Future<BankingAccount> getAccount(BankingAccountReference reference);
Future<List<BankingBalance>> getBalances(BankingAccountReference reference);
Future<BankingTransactionsPage> getTransactions(BankingAccountReference reference, {BankingTransactionQuery query = const BankingTransactionQuery()});
}
14 changes: 14 additions & 0 deletions lib/services/banking/bank_consent_service.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// dart format width=400

import 'banking_authorization.dart';
import 'banking_connection.dart';
import 'banking_reference.dart';

abstract interface class BankConsentService {
Future<BankingAuthorization> startAuthorization(BankingAuthorizationRequest request);

/// Exchanges an authorization code after the application lifecycle has validated and accepted the callback.
Future<BankingConnectionResult> createConnection(String authorizationCode);
Future<BankingConnection> getConnection(BankingConnectionReference reference);
Future<void> revokeConnection(BankingConnectionReference reference);
}
17 changes: 17 additions & 0 deletions lib/services/banking/bank_institution.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// dart format width=400

enum BankingCustomerType { personal, business }

class BankInstitution {
final String providerId;
final String id;
final String name;
final String country;
final Uri? logoUri;
final Set<BankingCustomerType> customerTypes;
final Duration? maximumConsentDuration;
final bool isSandbox;
final bool isBeta;

BankInstitution({required this.providerId, required this.id, required this.name, required this.country, this.logoUri, Set<BankingCustomerType> customerTypes = const {}, this.maximumConsentDuration, this.isSandbox = false, this.isBeta = false}) : customerTypes = Set.unmodifiable(customerTypes);
}
7 changes: 7 additions & 0 deletions lib/services/banking/bank_institution_directory.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// dart format width=400

import 'bank_institution.dart';

abstract interface class BankInstitutionDirectory {
Future<List<BankInstitution>> getInstitutions({String? country, BankingCustomerType customerType = BankingCustomerType.personal});
}
16 changes: 16 additions & 0 deletions lib/services/banking/banking_account.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// dart format width=400

import 'banking_reference.dart';

class BankingAccount {
final BankingAccountReference reference;
final String? iban;
final String? name;
final String? details;
final String? currency;
final String? product;
final String? accountType;
final String? usage;

const BankingAccount({required this.reference, this.iban, this.name, this.details, this.currency, this.product, this.accountType, this.usage});
}
21 changes: 21 additions & 0 deletions lib/services/banking/banking_authorization.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// dart format width=400

import 'bank_institution.dart';

class BankingAuthorizationRequest {
final BankInstitution institution;
final BankingCustomerType customerType;
final DateTime validUntil;
final String state;
final String? language;

const BankingAuthorizationRequest({required this.institution, required this.validUntil, required this.state, this.customerType = BankingCustomerType.personal, this.language});
}

class BankingAuthorization {
final String providerId;
final String authorizationId;
final Uri uri;

const BankingAuthorization({required this.providerId, required this.authorizationId, required this.uri});
}
16 changes: 16 additions & 0 deletions lib/services/banking/banking_balance.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// dart format width=400

import 'banking_money.dart';

enum BankingBalanceKind { closingBooked, interimBooked, available, other }

class BankingBalance {
final String name;
final BankingMoney amount;
final BankingBalanceKind kind;
final DateTime? referenceDate;
final DateTime? observedAt;
final String? lastCommittedEntryId;

const BankingBalance({required this.name, required this.amount, required this.kind, this.referenceDate, this.observedAt, this.lastCommittedEntryId});
}
29 changes: 29 additions & 0 deletions lib/services/banking/banking_connection.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// dart format width=400

import 'bank_institution.dart';
import 'banking_account.dart';
import 'banking_reference.dart';

enum BankingConnectionStatus { pending, active, cancelled, closed, expired, invalid, revoked }

class BankingConnection {
final BankingConnectionReference reference;
final BankInstitution institution;
final DateTime validUntil;
final BankingConnectionStatus status;
final List<BankingAccountReference> accounts;
final DateTime? createdAt;
final DateTime? authorizedAt;
final DateTime? closedAt;

BankingConnection({required this.reference, required this.institution, required this.validUntil, required this.status, required List<BankingAccountReference> accounts, this.createdAt, this.authorizedAt, this.closedAt}) : accounts = List.unmodifiable(accounts);

bool isExpiredAt(DateTime now) => !now.isBefore(validUntil);
}

class BankingConnectionResult {
final BankingConnection connection;
final List<BankingAccount> accounts;

BankingConnectionResult({required this.connection, required List<BankingAccount> accounts}) : accounts = List.unmodifiable(accounts);
}
13 changes: 13 additions & 0 deletions lib/services/banking/banking_exception.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// dart format width=400

enum BankingFailure { authentication, forbidden, rateLimited, unavailable, invalidResponse, rejected }

class BankingException implements Exception {
final String providerId;
final BankingFailure failure;

const BankingException({required this.providerId, required this.failure});

@override
String toString() => 'BankingException($providerId, ${failure.name})';
}
12 changes: 12 additions & 0 deletions lib/services/banking/banking_money.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// dart format width=400

class BankingMoney {
final String decimalAmount;
final String currency;

BankingMoney({required this.decimalAmount, required this.currency}) {
if (!RegExp(r'^-?[0-9]+(?:\.[0-9]+)?$').hasMatch(decimalAmount) || !RegExp(r'^[A-Z]{3}$').hasMatch(currency)) {
throw const FormatException('Invalid banking amount');
}
}
}
12 changes: 12 additions & 0 deletions lib/services/banking/banking_provider.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// dart format width=400

import 'bank_institution_directory.dart';
import 'bank_consent_service.dart';
import 'bank_account_data_source.dart';

abstract interface class BankingProvider {
String get id;
BankInstitutionDirectory get institutions;
BankConsentService get consent;
BankAccountDataSource get accountData;
}
28 changes: 28 additions & 0 deletions lib/services/banking/banking_reference.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// dart format width=400

class BankingConnectionReference {
final String providerId;
final String remoteId;

const BankingConnectionReference({required this.providerId, required this.remoteId});

@override
bool operator ==(Object other) => other is BankingConnectionReference && providerId == other.providerId && remoteId == other.remoteId;

@override
int get hashCode => Object.hash(providerId, remoteId);
}

class BankingAccountReference {
final String providerId;
final String remoteId;
final Set<String> identityKeys;

BankingAccountReference({required this.providerId, required this.remoteId, Iterable<String> identityKeys = const []}) : identityKeys = Set.unmodifiable(identityKeys);

@override
bool operator ==(Object other) => other is BankingAccountReference && providerId == other.providerId && remoteId == other.remoteId;

@override
int get hashCode => Object.hash(providerId, remoteId);
}
40 changes: 40 additions & 0 deletions lib/services/banking/banking_transaction.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// dart format width=400

import 'banking_money.dart';

enum BankingTransactionStatus { booked, pending, unknown }

enum BankingDirection { credit, debit, unknown }

class BankingTransaction {
final String? entryId;
final String? transactionId;
final BankingTransactionStatus status;
final BankingDirection direction;
final BankingMoney amount;
final DateTime? bookingDate;
final DateTime? valueDate;
final DateTime? transactionDate;
final String? creditorName;
final String? debtorName;
final String? note;
final List<String> remittanceInformation;

BankingTransaction({this.entryId, this.transactionId, required this.status, required this.direction, required this.amount, this.bookingDate, this.valueDate, this.transactionDate, this.creditorName, this.debtorName, this.note, List<String> remittanceInformation = const []}) : remittanceInformation = List.unmodifiable(remittanceInformation);
}

class BankingTransactionQuery {
final DateTime? dateFrom;
final DateTime? dateTo;
final BankingTransactionStatus? status;
final String? cursor;

const BankingTransactionQuery({this.dateFrom, this.dateTo, this.status, this.cursor});
}

class BankingTransactionsPage {
final List<BankingTransaction> transactions;
final String? nextCursor;

BankingTransactionsPage({required List<BankingTransaction> transactions, this.nextCursor}) : transactions = List.unmodifiable(transactions);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// dart format width=400

import '../bank_account_data_source.dart';
import '../banking_account.dart';
import '../banking_balance.dart';
import '../banking_exception.dart';
import '../banking_reference.dart';
import '../banking_transaction.dart';
import 'enable_banking_account_mapper.dart';
import 'enable_banking_api.dart';
import 'enable_banking_data_mapper.dart';
import 'enable_banking_errors.dart';
import 'enable_banking_reference.dart';

class EnableBankingAccountDataSource implements BankAccountDataSource {
final EnableBankingApi _api;
final _accounts = const EnableBankingAccountMapper();
final _data = const EnableBankingDataMapper();

EnableBankingAccountDataSource(this._api);

@override
Future<BankingAccount> getAccount(BankingAccountReference reference) => withEnableBankingErrors(() async {
checkEnableBankingReference(reference.providerId, reference.remoteId);
final account = await _api.getAccount(reference.remoteId);
if (account.uid != reference.remoteId) throw const FormatException('Account identity mismatch');
return _accounts.account(account);
});

@override
Future<List<BankingBalance>> getBalances(BankingAccountReference reference) => withEnableBankingErrors(() async {
checkEnableBankingReference(reference.providerId, reference.remoteId);
return List.unmodifiable((await _api.getBalances(reference.remoteId)).map(_data.balance));
});

@override
Future<BankingTransactionsPage> getTransactions(BankingAccountReference reference, {BankingTransactionQuery query = const BankingTransactionQuery()}) => withEnableBankingErrors(() async {
checkEnableBankingReference(reference.providerId, reference.remoteId);
if (query.status == BankingTransactionStatus.unknown) throw const BankingException(providerId: enableBankingId, failure: BankingFailure.rejected);
final from = query.dateFrom;
final to = query.dateTo;
if (from != null && to != null && DateTime.utc(from.year, from.month, from.day).isAfter(DateTime.utc(to.year, to.month, to.day))) throw const BankingException(providerId: enableBankingId, failure: BankingFailure.rejected);
final status = switch (query.status) {
BankingTransactionStatus.booked => 'BOOK',
BankingTransactionStatus.pending => 'PDNG',
_ => null,
};
return _data.transactions(await _api.getTransactions(reference.remoteId, dateFrom: from, dateTo: to, continuationKey: query.cursor, transactionStatus: status));
});
}
Loading
Loading