From 86a165caef85accf0e40beac4a67dc1e88efb101 Mon Sep 17 00:00:00 2001 From: Alessandro Mariani Date: Tue, 21 Jul 2026 10:31:25 +0200 Subject: [PATCH 01/15] chore(banking): add dependencies and register the OAuth callback scheme --- android/app/src/main/AndroidManifest.xml | 7 + ios/Runner/Info.plist | 11 + macos/Runner/DebugProfile.entitlements | 2 + macos/Runner/Info.plist | 11 + macos/Runner/Release.entitlements | 2 + pubspec.lock | 550 +++++++++++++++-------- pubspec.yaml | 4 + 7 files changed, 396 insertions(+), 191 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 38bcb672..a4a9f638 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -39,6 +39,13 @@ + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 7e9a6226..0bc075d0 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -22,6 +22,17 @@ $(FLUTTER_BUILD_NAME) CFBundleSignature ???? + CFBundleURLTypes + + + CFBundleURLName + com.ripster.sossoldi.eb + CFBundleURLSchemes + + sossoldi + + + CFBundleVersion $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index dddb8a30..3ba6c126 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -6,6 +6,8 @@ com.apple.security.cs.allow-jit + com.apple.security.network.client + com.apple.security.network.server diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist index 4789daa6..f0f36cb3 100644 --- a/macos/Runner/Info.plist +++ b/macos/Runner/Info.plist @@ -16,6 +16,17 @@ $(PRODUCT_NAME) CFBundlePackageType APPL + CFBundleURLTypes + + + CFBundleURLName + com.ripster.sossoldi.eb + CFBundleURLSchemes + + sossoldi + + + CFBundleShortVersionString $(FLUTTER_BUILD_NAME) CFBundleVersion diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index 852fa1a4..ee95ab7e 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -4,5 +4,7 @@ com.apple.security.app-sandbox + com.apple.security.network.client + diff --git a/pubspec.lock b/pubspec.lock index f5ed85f7..89c86d60 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,42 +5,50 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d + sha256: "5b7468c326d2f8a4f630056404ca0d291ade42918f4a3c6233618e724f39da8e" url: "https://pub.dev" source: hosted - version: "91.0.0" + version: "92.0.0" + adaptive_number: + dependency: transitive + description: + name: adaptive_number + sha256: "3a567544e9b5c9c803006f51140ad544aedc79604fd4f3f2c1380003f97c1d77" + url: "https://pub.dev" + source: hosted + version: "1.0.0" analysis_server_plugin: dependency: transitive description: name: analysis_server_plugin - sha256: "26844e7f977087567135d62532b67d5639fe206c5194c3f410ba75e1a04a2747" + sha256: "44adba4d74a2541173bad4c11531d2a4d22810c29c5ddb458a38e9f4d0e5eac7" url: "https://pub.dev" source: hosted - version: "0.3.3" + version: "0.3.4" analyzer: dependency: transitive description: name: analyzer - sha256: a40a0cee526a7e1f387c6847bd8a5ccbf510a75952ef8a28338e989558072cb0 + sha256: "70e4b1ef8003c64793a9e268a551a82869a8a96f39deb73dea28084b0e8bf75e" url: "https://pub.dev" source: hosted - version: "8.4.0" + version: "9.0.0" analyzer_buffer: dependency: transitive description: name: analyzer_buffer - sha256: aba2f75e63b3135fd1efaa8b6abefe1aa6e41b6bd9806221620fa48f98156033 + sha256: ff4bd291778c7417fe53fe24ee0d0a1f1ffe281a2d4ea887e7094f16e36eace7 url: "https://pub.dev" source: hosted - version: "0.1.11" + version: "0.3.0" analyzer_plugin: dependency: "direct main" description: name: analyzer_plugin - sha256: "08cfefa90b4f4dd3b447bda831cecf644029f9f8e22820f6ee310213ebe2dd53" + sha256: "6645a029da947ffd823d98118f385d4bd26b54eb069c006b22e0b94e451814b5" url: "https://pub.dev" source: hosted - version: "0.13.10" + version: "0.13.11" ansicolor: dependency: transitive description: @@ -49,14 +57,46 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.3" + app_links: + dependency: "direct main" + description: + name: app_links + sha256: "5f88447519add627fe1cbcab4fd1da3d4fed15b9baf29f28b22535c95ecee3e8" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + app_links_linux: + dependency: transitive + description: + name: app_links_linux + sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + app_links_platform_interface: + dependency: transitive + description: + name: app_links_platform_interface + sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + app_links_web: + dependency: transitive + description: + name: app_links_web + sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555 + url: "https://pub.dev" + source: hosted + version: "1.0.4" archive: dependency: transitive description: name: archive - sha256: "528579c7e4579719f04b21eeeeddfd73a18b31dabc22766893b7d1be7f49b967" + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff url: "https://pub.dev" source: hosted - version: "4.0.3" + version: "4.0.9" args: dependency: transitive description: @@ -69,10 +109,10 @@ packages: dependency: transitive description: name: async - sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63 + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 url: "https://pub.dev" source: hosted - version: "2.12.0" + version: "2.13.1" boolean_selector: dependency: transitive description: @@ -85,34 +125,34 @@ packages: dependency: transitive description: name: build - sha256: "275bf6bb2a00a9852c28d4e0b410da1d833a734d57d39d44f94bfc895a484ec3" + sha256: "45d14a0fb23e018d8287c32fc98d726ce466b231928ed9b9200f29bd3ccd39ae" url: "https://pub.dev" source: hosted - version: "4.0.4" + version: "4.0.7" build_config: dependency: transitive description: name: build_config - sha256: "4f64382b97504dc2fcdf487d5aae33418e08b4703fc21249e4db6d804a4d0187" + sha256: "94eaf6708fe64408c632ef2689ca3777b112f9421306ccf4f8c84d7c5c9f83f8" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.2" build_daemon: dependency: transitive description: name: build_daemon - sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 + sha256: fd754058c342243718d5171a95f352cfc9fcf0cba8cfa26df67cb13a5836db78 url: "https://pub.dev" source: hosted - version: "4.1.1" + version: "4.1.2" build_runner: dependency: "direct dev" description: name: build_runner - sha256: b4d854962a32fd9f8efc0b76f98214790b833af8b2e9b2df6bfc927c0415a072 + sha256: "5367e521935b102bdf1e735d2aab461e36b2edca6517662d088dd04cc39f8d16" url: "https://pub.dev" source: hosted - version: "2.10.5" + version: "2.15.1" built_collection: dependency: transitive description: @@ -125,26 +165,34 @@ packages: dependency: transitive description: name: built_value - sha256: "426cf75afdb23aa74bd4e471704de3f9393f3c7b04c1e2d9c6f1073ae0b8b139" + sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56" url: "https://pub.dev" source: hosted - version: "8.12.1" + version: "8.12.6" characters: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: name: checked_yaml - sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" url: "https://pub.dev" source: hosted - version: "2.0.3" + version: "2.0.4" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" cli_util: dependency: transitive description: @@ -165,18 +213,18 @@ packages: dependency: transitive description: name: code_assets - sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.2.1" code_builder: dependency: transitive description: name: code_builder - sha256: "11654819532ba94c34de52ff5feb52bd81cba1de00ef2ed622fd50295f9d4243" + sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" url: "https://pub.dev" source: hosted - version: "4.11.0" + version: "4.11.1" collection: dependency: "direct main" description: @@ -197,26 +245,26 @@ packages: dependency: transitive description: name: coverage - sha256: e3493833ea012784c740e341952298f1cc77f1f01b1bbc3eb4eecf6984fb7f43 + sha256: "956a3de0725ca232ad353565a8290d3357592bf4250f6f298a185e2d949c5d3d" url: "https://pub.dev" source: hosted - version: "1.11.1" + version: "1.15.1" cross_file: dependency: transitive description: name: cross_file - sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" + sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51" url: "https://pub.dev" source: hosted - version: "0.3.4+2" + version: "0.3.5+4" crypto: dependency: transitive description: name: crypto - sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf url: "https://pub.dev" source: hosted - version: "3.0.6" + version: "3.0.7" csslib: dependency: transitive description: @@ -237,10 +285,18 @@ packages: dependency: "direct main" description: name: cupertino_icons - sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + dart_jsonwebtoken: + dependency: "direct main" + description: + name: dart_jsonwebtoken + sha256: "00a0812d2aeaeb0d30bcbc4dd3cee57971dbc0ab2216adf4f0247f37793f15ef" url: "https://pub.dev" source: hosted - version: "1.0.8" + version: "2.17.0" dart_style: dependency: transitive description: @@ -253,26 +309,26 @@ packages: dependency: transitive description: name: dbus - sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" + sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.14" dependency_validator: dependency: "direct dev" description: name: dependency_validator - sha256: a5928c0e3773808027bdafeb13fb4be0e4fdd79819773ad3df34d0fcf42636f2 + sha256: d6084f8df7677843c8fd0e08b66c11d9c2ce9bae1bb1f18cc574bcb28ebe71b0 url: "https://pub.dev" source: hosted - version: "5.0.3" + version: "5.0.5" device_info_plus: dependency: "direct main" description: name: device_info_plus - sha256: "4df8babf73058181227e18b08e6ea3520cf5fc5d796888d33b7cb0f33f984b7c" + sha256: b4fed1b2835da9d670d7bed7db79ae2a94b0f5ad6312268158a9b5479abbacdd url: "https://pub.dev" source: hosted - version: "12.3.0" + version: "12.4.0" device_info_plus_platform_interface: dependency: transitive description: @@ -281,14 +337,22 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.3" + ed25519_edwards: + dependency: transitive + description: + name: ed25519_edwards + sha256: "6ce0112d131327ec6d42beede1e5dfd526069b18ad45dcf654f15074ad9276cd" + url: "https://pub.dev" + source: hosted + version: "0.3.1" equatable: dependency: transitive description: name: equatable - sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" + sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2" url: "https://pub.dev" source: hosted - version: "2.0.7" + version: "2.1.0" fake_async: dependency: transitive description: @@ -301,10 +365,10 @@ packages: dependency: transitive description: name: ffi - sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" file: dependency: transitive description: @@ -333,10 +397,10 @@ packages: dependency: "direct main" description: name: fl_chart - sha256: "7ca9a40f4eb85949190e54087be8b4d6ac09dc4c54238d782a34cf1f7c011de9" + sha256: b938f77d042cbcd822936a7a359a7235bad8bd72070de1f827efc2cc297ac888 url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.2.0" flutter: dependency: "direct main" description: flutter @@ -362,10 +426,10 @@ packages: dependency: "direct main" description: name: flutter_local_notifications - sha256: "76cd20bcfa72fabe50ea27eeaf165527f446f55d3033021462084b87805b4cac" + sha256: "2b50e938a275e1ad77352d6a25e25770f4130baa61eaf02de7a9a884680954ad" url: "https://pub.dev" source: hosted - version: "20.0.0" + version: "20.1.0" flutter_local_notifications_linux: dependency: transitive description: @@ -386,10 +450,10 @@ packages: dependency: transitive description: name: flutter_local_notifications_windows - sha256: "7ddd964fa85b6a23e96956c5b63ef55cdb9e5947b71b95712204db42ad46da61" + sha256: e97a1a3016512437d9c0b12fae7d1491c3c7b9aa7f03a69b974308840656b02a url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.0.1" flutter_native_splash: dependency: "direct main" description: @@ -410,18 +474,66 @@ packages: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: "5a1e6fb2c0561958d7e4c33574674bda7b77caaca7a33b758876956f2902eea3" + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" url: "https://pub.dev" source: hosted - version: "2.0.27" + version: "2.0.35" flutter_riverpod: dependency: "direct main" description: name: flutter_riverpod - sha256: "38ec6c303e2c83ee84512f5fc2a82ae311531021938e63d7137eccc107bf3c02" + sha256: "4e166be88e1dbbaa34a280bdb744aeae73b7ef25fdf8db7a3bb776760a3648e2" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.3.1" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + url: "https://pub.dev" + source: hosted + version: "9.2.4" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" flutter_test: dependency: "direct dev" description: flutter @@ -444,10 +556,10 @@ packages: dependency: "direct dev" description: name: freezed - sha256: "13065f10e135263a4f5a4391b79a8efc5fb8106f8dd555a9e49b750b45393d77" + sha256: f23ea33b3863f119b58ed1b586e881a46bd28715ddcc4dbc33104524e3434131 url: "https://pub.dev" source: hosted - version: "3.2.3" + version: "3.2.5" freezed_annotation: dependency: transitive description: @@ -480,14 +592,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + gtk: + dependency: transitive + description: + name: gtk + sha256: "4ff85b2a16724029dd9e5bbb5a94b6918f9973f74ba571c949d2002801879cf5" + url: "https://pub.dev" + source: hosted + version: "2.2.0" hooks: dependency: transitive description: name: hooks - sha256: "5d309c86e7ce34cd8e37aa71cb30cb652d3829b900ab145e4d9da564b31d59f7" + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "2.0.2" html: dependency: transitive description: @@ -497,13 +617,13 @@ packages: source: hosted version: "0.15.6" http: - dependency: transitive + dependency: "direct main" description: name: http - sha256: fe7ab022b76f3034adc518fb6ea04a82387620e19977665ea18d30a1cf43442f + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.6.0" http_multi_server: dependency: transitive description: @@ -524,18 +644,18 @@ packages: dependency: transitive description: name: image - sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce url: "https://pub.dev" source: hosted - version: "4.5.4" + version: "4.8.0" intl: dependency: "direct main" description: name: intl - sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" url: "https://pub.dev" source: hosted - version: "0.20.2" + version: "0.20.3" io: dependency: transitive description: @@ -544,30 +664,54 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + jni: + dependency: transitive + description: + name: jni + sha256: "5bc9a9daac5ccfbd6a758377600b9f7fdce13d93f26e8012a8941014a5d978d1" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "7b717011ea40d04fd47c2731d3d1d36eb99eba3435c2753d62489e8c3c9991d5" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" js: dependency: transitive description: name: js - sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 url: "https://pub.dev" source: hosted - version: "0.7.2" + version: "0.6.7" json_annotation: dependency: transitive description: name: json_annotation - sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 url: "https://pub.dev" source: hosted - version: "4.9.0" + version: "4.11.0" json_serializable: dependency: "direct dev" description: name: json_serializable - sha256: c5b2ee75210a0f263c6c7b9eeea80553dbae96ea1bf57f02484e806a3ffdffa3 + sha256: "44729f5c45748e6748f6b9a57ab8f7e4336edc8ae41fc295070e3814e616a6c0" url: "https://pub.dev" source: hosted - version: "6.11.2" + version: "6.13.0" leak_tracker: dependency: transitive description: @@ -596,34 +740,34 @@ packages: dependency: transitive description: name: lints - sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0 + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" url: "https://pub.dev" source: hosted - version: "6.0.0" + version: "6.1.0" local_auth: dependency: "direct main" description: name: local_auth - sha256: a4f1bf57f0236a4aeb5e8f0ec180e197f4b112a3456baa6c1e73b546630b0422 + sha256: ecf24edf2283c509ecd217e3595f6f71034b68888d28ad1dae6bfa0857b816ac url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "3.0.2" local_auth_android: dependency: transitive description: name: local_auth_android - sha256: "04dd9050b59cb4bcaf051d44eec65865779a9b2f6daccc523f59f96b565a5d54" + sha256: b201c006fa769c23386f89aa6837ec0eb8179fcfb212eadcf87b422b3f9a6a78 url: "https://pub.dev" source: hosted - version: "2.0.3" + version: "2.0.8" local_auth_darwin: dependency: transitive description: name: local_auth_darwin - sha256: "668ea65edaab17380956e9713f57e34f78ede505ca0cfd8d39db34e2f260bfee" + sha256: a8c3d4e17454111f7fd31ff72a31222359f6059f7fe956c2dcfe0f88f49826d4 url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.0.3" local_auth_platform_interface: dependency: transitive description: @@ -652,18 +796,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: @@ -684,18 +828,18 @@ packages: dependency: transitive description: name: mockito - sha256: dac24d461418d363778d53198d9ac0510b9d073869f078450f195766ec48d05e + sha256: eff30d002f0c8bf073b6f929df4483b543133fcafce056870163587b03f1d422 url: "https://pub.dev" source: hosted - version: "5.6.1" + version: "5.6.4" native_toolchain_c: dependency: transitive description: name: native_toolchain_c - sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac" + sha256: f9c168717100ae6d9fee9ffb0be379bf1f8b26b0f6bcbd4fdddcd931993a6a72 url: "https://pub.dev" source: hosted - version: "0.17.4" + version: "0.19.2" node_preamble: dependency: transitive description: @@ -704,22 +848,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e + url: "https://pub.dev" + source: hosted + version: "9.5.0" package_config: dependency: transitive description: name: package_config - sha256: "92d4488434b520a62570293fbd33bb556c7d49230791c1b4bbd973baf6d2dc67" + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.2.0" package_info_plus: dependency: "direct main" description: name: package_info_plus - sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d + sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20" url: "https://pub.dev" source: hosted - version: "9.0.0" + version: "9.0.1" package_info_plus_platform_interface: dependency: transitive description: @@ -740,42 +892,42 @@ packages: dependency: "direct main" description: name: path_provider - sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 url: "https://pub.dev" source: hosted - version: "2.1.5" + version: "2.1.6" path_provider_android: dependency: transitive description: name: path_provider_android - sha256: "0ca7359dad67fd7063cb2892ab0c0737b2daafd807cf1acecd62374c8fae6c12" + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" url: "https://pub.dev" source: hosted - version: "2.2.16" + version: "2.3.1" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.6.0" path_provider_linux: dependency: transitive description: name: path_provider_linux - sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.2.2" path_provider_platform_interface: dependency: transitive description: name: path_provider_platform_interface - sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.3" path_provider_windows: dependency: transitive description: @@ -796,10 +948,10 @@ packages: dependency: "direct main" description: name: permission_handler - sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1 + sha256: fe54465bcc62a4564c6e4db337bbaded6c0c0fa6e10487414436d163114784f6 url: "https://pub.dev" source: hosted - version: "12.0.1" + version: "12.0.3" permission_handler_android: dependency: transitive description: @@ -812,10 +964,10 @@ packages: dependency: transitive description: name: permission_handler_apple - sha256: f84a188e79a35c687c132a0a0556c254747a08561e99ab933f12f6ca71ef3c98 + sha256: "79dfa1df734798aa3cfdad166d3a3698c206d8813de13516ea1071b5d7e2f420" url: "https://pub.dev" source: hosted - version: "9.4.6" + version: "9.4.10" permission_handler_html: dependency: transitive description: @@ -844,10 +996,10 @@ packages: dependency: transitive description: name: petitparser - sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" url: "https://pub.dev" source: hosted - version: "7.0.1" + version: "7.0.2" platform: dependency: transitive description: @@ -864,22 +1016,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" + url: "https://pub.dev" + source: hosted + version: "3.9.1" pool: dependency: transitive description: name: pool - sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" url: "https://pub.dev" source: hosted - version: "1.5.1" + version: "1.5.2" posix: dependency: transitive description: name: posix - sha256: a0117dc2167805aa9125b82eee515cc891819bac2f538c83646d355b16f58b9a + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e url: "https://pub.dev" source: hosted - version: "6.0.1" + version: "6.5.2" pub_semver: dependency: transitive description: @@ -896,70 +1056,78 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" riverpod: dependency: transitive description: name: riverpod - sha256: "16ff608d21e8ea64364f2b7c049c94a02ab81668f78845862b6e88b71dd4935a" + sha256: "8c22216be8ad3ef2b44af3a329693558c98eca7b8bd4ef495c92db0bba279f83" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.2.1" riverpod_analyzer_utils: dependency: transitive description: name: riverpod_analyzer_utils - sha256: "947b05d04c52a546a2ac6b19ef2a54b08520ff6bdf9f23d67957a4c8df1c3bc0" + sha256: e55bc08c084a424e1bbdc303fe8ea75daafe4269b68fd0e0f6f1678413715b66 url: "https://pub.dev" source: hosted - version: "1.0.0-dev.8" + version: "1.0.0-dev.9" riverpod_annotation: dependency: "direct main" description: name: riverpod_annotation - sha256: cc1474bc2df55ec3c1da1989d139dcef22cd5e2bd78da382e867a69a8eca2e46 + sha256: "16471a1260b94e939394d78f1c63a9350936ac4a68c9fbdab40be47268c0b04f" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "4.0.2" riverpod_generator: dependency: "direct dev" description: name: riverpod_generator - sha256: e43b1537229cc8f487f09b0c20d15dba840acbadcf5fc6dad7ad5e8ab75950dc + sha256: "6f9220534d7a353b53c875ea191a84d28cb4e52ac420a66a1bd7318329d977b0" url: "https://pub.dev" source: hosted - version: "4.0.0+1" + version: "4.0.3" riverpod_lint: dependency: "direct dev" description: name: riverpod_lint - sha256: "4d2eb0d19bbe7e3323bd0ce4553b2e6170d161a13914bfdd85a3612329edcb43" + sha256: "64e8debf5b719a37d48b9785dd595d34133fdcd84b8fd07157a621c54ab2156f" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.1.3" shared_preferences: dependency: "direct main" description: name: shared_preferences - sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf url: "https://pub.dev" source: hosted - version: "2.5.4" + version: "2.5.5" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: a768fc8ede5f0c8e6150476e14f38e2417c0864ca36bb4582be8e21925a03c22 + sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53 url: "https://pub.dev" source: hosted - version: "2.4.6" + version: "2.4.23" shared_preferences_foundation: dependency: transitive description: name: shared_preferences_foundation - sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" url: "https://pub.dev" source: hosted - version: "2.5.4" + version: "2.5.6" shared_preferences_linux: dependency: transitive description: @@ -972,10 +1140,10 @@ packages: dependency: transitive description: name: shared_preferences_platform_interface - sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2" shared_preferences_web: dependency: transitive description: @@ -1033,18 +1201,18 @@ packages: dependency: transitive description: name: source_gen - sha256: "07b277b67e0096c45196cbddddf2d8c6ffc49342e88bf31d460ce04605ddac75" + sha256: a603f1fb984a7391ae5978d1b92bfaaa08b350dca5c825256f925818f7943bf5 url: "https://pub.dev" source: hosted - version: "4.1.1" + version: "4.2.4" source_helper: dependency: transitive description: name: source_helper - sha256: "6a3c6cc82073a8797f8c4dc4572146114a39652851c157db37e964d9c7038723" + sha256: "4227d54ceefd0bb8ca4c8fcb96e1719dc53f1ee1b6e2ca9d7a6069da160e4eae" url: "https://pub.dev" source: hosted - version: "1.3.8" + version: "1.3.12" source_map_stack_trace: dependency: transitive description: @@ -1065,42 +1233,42 @@ packages: dependency: transitive description: name: source_span - sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" url: "https://pub.dev" source: hosted - version: "1.10.1" + version: "1.10.2" sqflite: dependency: "direct main" description: name: sqflite - sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 + sha256: "564cfed0746fe53140c23b70b308e045c3b31f17778f2f326ccb7d804ea0250a" url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.4.2+1" sqflite_android: dependency: transitive description: name: sqflite_android - sha256: "2b3070c5fa881839f8b402ee4a39c1b4d561704d4ebbbcfb808a119bc2a1701b" + sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2+3" sqflite_common: dependency: transitive description: name: sqflite_common - sha256: "84731e8bfd8303a3389903e01fb2141b6e59b5973cacbb0929021df08dddbe8b" + sha256: "1581ffbf7a0e333b380d6a30737d78516b826cb35beb7fb0bf8a3ea0c678b465" url: "https://pub.dev" source: hosted - version: "2.5.5" + version: "2.5.8" sqflite_common_ffi: dependency: "direct main" description: name: sqflite_common_ffi - sha256: "40d430bac3c84fef92d030d4013e807f70a03e6091da3f604c3ea09521136e8d" + sha256: cd0c7f7de39a08f2d54ef144d9058c46eca8461879aaa648025643455c1e5a20 url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.0+3" sqflite_darwin: dependency: transitive description: @@ -1121,18 +1289,18 @@ packages: dependency: transitive description: name: sqlite3 - sha256: "0e690e9633d530e46f1229efd2f280b97e363f8ed10f581dc1426ea5d39374fc" + sha256: c73fd75df1332d76a6257f4823ae4df9c791f522b97e4a60cbcad214de1becf4 url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.5.0" sqlite3_flutter_libs: dependency: "direct main" description: name: sqlite3_flutter_libs - sha256: "7adb4cc96dc08648a5eb1d80a7619070796ca6db03901ff2b6dcb15ee30468f3" + sha256: "3ed7553eee7bb368f8950f58ba29f634e06e813c029aff6a0d60862b96de8454" url: "https://pub.dev" source: hosted - version: "0.5.31" + version: "0.6.0+eol" stack_trace: dependency: transitive description: @@ -1177,10 +1345,10 @@ packages: dependency: transitive description: name: synchronized - sha256: "0669c70faae6270521ee4f05bffd2919892d42d1276e6c495be80174b6bc0ef6" + sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5" url: "https://pub.dev" source: hosted - version: "3.3.1" + version: "3.4.0+1" term_glyph: dependency: transitive description: @@ -1193,26 +1361,26 @@ packages: dependency: "direct dev" description: name: test - sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" + sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" url: "https://pub.dev" source: hosted - version: "1.26.3" + version: "1.30.0" test_api: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.10" test_core: dependency: transitive description: name: test_core - sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" + sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" url: "https://pub.dev" source: hosted - version: "0.6.12" + version: "0.6.16" timezone: dependency: "direct main" description: @@ -1233,10 +1401,10 @@ packages: dependency: transitive description: name: universal_io - sha256: "1722b2dcc462b4b2f3ee7d188dad008b6eb4c40bbd03a3de451d82c78bba9aad" + sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2 url: "https://pub.dev" source: hosted - version: "2.2.2" + version: "2.3.1" url_launcher: dependency: "direct main" description: @@ -1249,34 +1417,34 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: "6fc2f56536ee873eeb867ad176ae15f304ccccc357848b351f6f0d8d4a40d193" + sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c" url: "https://pub.dev" source: hosted - version: "6.3.14" + version: "6.3.30" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: "16a513b6c12bb419304e72ea0ae2ab4fed569920d1c7cb850263fe3acc824626" + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" url: "https://pub.dev" source: hosted - version: "6.3.2" + version: "6.4.1" url_launcher_linux: dependency: transitive description: name: url_launcher_linux - sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a url: "https://pub.dev" source: hosted - version: "3.2.1" + version: "3.2.2" url_launcher_macos: dependency: transitive description: name: url_launcher_macos - sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2" + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" url: "https://pub.dev" source: hosted - version: "3.2.2" + version: "3.2.5" url_launcher_platform_interface: dependency: transitive description: @@ -1289,18 +1457,18 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "3ba963161bd0fe395917ba881d320b9c4f6dd3c4a233da62ab18a5025c85f1e9" + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.3" url_launcher_windows: dependency: transitive description: name: url_launcher_windows - sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" url: "https://pub.dev" source: hosted - version: "3.1.4" + version: "3.1.5" vector_math: dependency: transitive description: @@ -1313,42 +1481,42 @@ packages: dependency: transitive description: name: vm_service - sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14" + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" url: "https://pub.dev" source: hosted - version: "14.3.1" + version: "15.2.0" watcher: dependency: transitive description: name: watcher - sha256: "69da27e49efa56a15f8afe8f4438c4ec02eff0a117df1b22ea4aad194fe1c104" + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.2.1" web: dependency: transitive description: name: web - sha256: cd3543bd5798f6ad290ea73d210f423502e71900302dde696f8bff84bf89a1cb + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.1" web_socket: dependency: transitive description: name: web_socket - sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83" + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" url: "https://pub.dev" source: hosted - version: "0.1.6" + version: "1.0.1" web_socket_channel: dependency: transitive description: name: web_socket_channel - sha256: "0b8e2457400d8a859b7b2030786835a28a8e80836ef64402abef392ff4f1d0e5" + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "3.0.3" webkit_inspection_protocol: dependency: transitive description: @@ -1361,10 +1529,10 @@ packages: dependency: transitive description: name: win32 - sha256: dc6ecaa00a7c708e5b4d10ee7bec8c270e9276dfcab1783f57e9962d7884305f + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e url: "https://pub.dev" source: hosted - version: "5.12.0" + version: "5.15.0" win32_registry: dependency: transitive description: @@ -1401,10 +1569,10 @@ packages: dependency: transitive description: name: yaml_edit - sha256: ec709065bb2c911b336853b67f3732dd13e0336bd065cc2f1061d7610ddf45e3 + sha256: "07c9e63ba42519745182b88ca12264a7ba2484d8239958778dfe4d44fe760488" url: "https://pub.dev" source: hosted - version: "2.2.3" + version: "2.2.4" sdks: - dart: ">=3.10.7 <4.0.0" - flutter: ">=3.35.0" + dart: ">=3.11.0 <4.0.0" + flutter: ">=3.38.4" diff --git a/pubspec.yaml b/pubspec.yaml index 43f72a5a..3ae1a980 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -13,9 +13,11 @@ dependencies: sdk: flutter analyzer_plugin: ^0.13.1 + app_links: ^6.3.0 collection: ^1.19.1 csv: ^6.0.0 cupertino_icons: ^1.0.8 + dart_jsonwebtoken: ^2.14.0 device_info_plus: ^12.3.0 file_picker: ^10.3.10 fl_chart: ^1.1.1 @@ -23,6 +25,8 @@ dependencies: flutter_native_splash: ^2.4.7 flutter_phoenix: ^1.1.1 flutter_riverpod: ^3.1.0 + flutter_secure_storage: ^9.2.0 + http: ^1.2.0 intl: ^0.20.2 local_auth: ^3.0.0 package_info_plus: ^9.0.0 From ffc37c137d3b9a297e580fe2dcd4c215abfaecb9 Mon Sep 17 00:00:00 2001 From: Alessandro Mariani Date: Tue, 21 Jul 2026 10:31:37 +0200 Subject: [PATCH 02/15] feat(banking): parse the Enable Banking API responses DTOs for ASPSPs, authorization, session, account, balance and transactions, with tests over sample JSON fixtures. --- lib/services/banking/models/aspsp.dart | 50 +++++++ lib/services/banking/models/eb_account.dart | 39 ++++++ lib/services/banking/models/eb_amount.dart | 15 +++ lib/services/banking/models/eb_auth.dart | 20 +++ lib/services/banking/models/eb_balance.dart | 22 +++ lib/services/banking/models/eb_session.dart | 39 ++++++ .../banking/models/eb_transaction.dart | 70 ++++++++++ .../banking/models/eb_transactions_page.dart | 22 +++ test/fixtures/eb_account.json | 12 ++ test/fixtures/eb_aspsp.json | 14 ++ test/fixtures/eb_balance.json | 5 + test/fixtures/eb_session.json | 19 +++ test/fixtures/eb_transactions.json | 33 +++++ test/services/banking/eb_models_test.dart | 125 ++++++++++++++++++ 14 files changed, 485 insertions(+) create mode 100644 lib/services/banking/models/aspsp.dart create mode 100644 lib/services/banking/models/eb_account.dart create mode 100644 lib/services/banking/models/eb_amount.dart create mode 100644 lib/services/banking/models/eb_auth.dart create mode 100644 lib/services/banking/models/eb_balance.dart create mode 100644 lib/services/banking/models/eb_session.dart create mode 100644 lib/services/banking/models/eb_transaction.dart create mode 100644 lib/services/banking/models/eb_transactions_page.dart create mode 100644 test/fixtures/eb_account.json create mode 100644 test/fixtures/eb_aspsp.json create mode 100644 test/fixtures/eb_balance.json create mode 100644 test/fixtures/eb_session.json create mode 100644 test/fixtures/eb_transactions.json create mode 100644 test/services/banking/eb_models_test.dart diff --git a/lib/services/banking/models/aspsp.dart b/lib/services/banking/models/aspsp.dart new file mode 100644 index 00000000..dc30812b --- /dev/null +++ b/lib/services/banking/models/aspsp.dart @@ -0,0 +1,50 @@ +/// Sandbox metadata attached to an [Aspsp] in the `/aspsps` response. +/// +/// Only present for sandbox ASPSPs; captured defensively as the shape of the +/// nested `users` entries is not stable across banks. +class SandboxInfo { + final List> users; + + const SandboxInfo({this.users = const []}); + + static SandboxInfo fromJson(Map json) => SandboxInfo( + users: ((json['users'] as List?) ?? const []).cast>(), + ); +} + +/// A bank (ASPSP) as returned by `GET /aspsps`. +class Aspsp { + final String name; + final String country; + final String? logo; + final List psuTypes; + + /// Maximum consent validity in **seconds** (`maximum_consent_validity`). + final int? maximumConsentValidity; + final bool beta; + final SandboxInfo? sandbox; + + const Aspsp({ + required this.name, + required this.country, + this.logo, + this.psuTypes = const [], + this.maximumConsentValidity, + this.beta = false, + this.sandbox, + }); + + static Aspsp fromJson(Map json) => Aspsp( + name: json['name'] as String, + country: json['country'] as String, + logo: json['logo'] as String?, + psuTypes: ((json['psu_types'] as List?) ?? const []) + .map((e) => e as String) + .toList(), + maximumConsentValidity: json['maximum_consent_validity'] as int?, + beta: (json['beta'] as bool?) ?? false, + sandbox: json['sandbox'] == null + ? null + : SandboxInfo.fromJson(json['sandbox'] as Map), + ); +} diff --git a/lib/services/banking/models/eb_account.dart b/lib/services/banking/models/eb_account.dart new file mode 100644 index 00000000..db22d783 --- /dev/null +++ b/lib/services/banking/models/eb_account.dart @@ -0,0 +1,39 @@ +/// A bank account (AccountResource) as returned inside a session or accounts +/// list. +/// +/// The IBAN is nested under `account_id.iban`. +class EbAccount { + final String uid; + final String? iban; + final String? name; + final String? details; + final String? currency; + final String? product; + final String? cashAccountType; + final String? usage; + final String? identificationHash; + + const EbAccount({ + required this.uid, + this.iban, + this.name, + this.details, + this.currency, + this.product, + this.cashAccountType, + this.usage, + this.identificationHash, + }); + + static EbAccount fromJson(Map json) => EbAccount( + uid: json['uid'] as String, + iban: (json['account_id'] as Map?)?['iban'] as String?, + name: json['name'] as String?, + details: json['details'] as String?, + currency: json['currency'] as String?, + product: json['product'] as String?, + cashAccountType: json['cash_account_type'] as String?, + usage: json['usage'] as String?, + identificationHash: json['identification_hash'] as String?, + ); +} diff --git a/lib/services/banking/models/eb_amount.dart b/lib/services/banking/models/eb_amount.dart new file mode 100644 index 00000000..a55e30a2 --- /dev/null +++ b/lib/services/banking/models/eb_amount.dart @@ -0,0 +1,15 @@ +/// Monetary amount as returned by Enable Banking. +/// +/// ⚠️ The API serializes [amount] as a STRING (e.g. `"10.33"`), so it must be +/// parsed with [num.parse]. +class EbAmount { + final num amount; + final String currency; + + const EbAmount({required this.amount, required this.currency}); + + static EbAmount fromJson(Map json) => EbAmount( + amount: num.parse(json['amount'] as String), + currency: json['currency'] as String, + ); +} diff --git a/lib/services/banking/models/eb_auth.dart b/lib/services/banking/models/eb_auth.dart new file mode 100644 index 00000000..0070595d --- /dev/null +++ b/lib/services/banking/models/eb_auth.dart @@ -0,0 +1,20 @@ +/// Authorization start response from `POST /auth`. +/// +/// [url] is the consent page the PSU must be sent to. +class EbAuthorization { + final String url; + final String authorizationId; + final String psuIdHash; + + const EbAuthorization({ + required this.url, + required this.authorizationId, + required this.psuIdHash, + }); + + static EbAuthorization fromJson(Map json) => EbAuthorization( + url: json['url'] as String, + authorizationId: json['authorization_id'] as String, + psuIdHash: json['psu_id_hash'] as String, + ); +} diff --git a/lib/services/banking/models/eb_balance.dart b/lib/services/banking/models/eb_balance.dart new file mode 100644 index 00000000..9e587a31 --- /dev/null +++ b/lib/services/banking/models/eb_balance.dart @@ -0,0 +1,22 @@ +import 'eb_amount.dart'; + +/// An account balance as returned by `GET /accounts/{uid}/balances`. +class EbBalance { + final String name; + final EbAmount balanceAmount; + final String? balanceType; + + const EbBalance({ + required this.name, + required this.balanceAmount, + this.balanceType, + }); + + static EbBalance fromJson(Map json) => EbBalance( + name: json['name'] as String, + balanceAmount: EbAmount.fromJson( + json['balance_amount'] as Map, + ), + balanceType: json['balance_type'] as String?, + ); +} diff --git a/lib/services/banking/models/eb_session.dart b/lib/services/banking/models/eb_session.dart new file mode 100644 index 00000000..57b0ded0 --- /dev/null +++ b/lib/services/banking/models/eb_session.dart @@ -0,0 +1,39 @@ +import 'eb_account.dart'; + +/// An authorized session as returned by `POST /sessions` and +/// `GET /sessions/{id}`. +/// +/// [aspspName]/[aspspCountry] come from the nested `aspsp` object and +/// [validUntil] from `access.valid_until`. +class EbSession { + final String sessionId; + final List accounts; + final String aspspName; + final String aspspCountry; + final DateTime validUntil; + final String? psuType; + + const EbSession({ + required this.sessionId, + this.accounts = const [], + required this.aspspName, + required this.aspspCountry, + required this.validUntil, + this.psuType, + }); + + static EbSession fromJson(Map json) { + final aspsp = json['aspsp'] as Map?; + final access = json['access'] as Map?; + return EbSession( + sessionId: json['session_id'] as String, + accounts: ((json['accounts'] as List?) ?? const []) + .map((e) => EbAccount.fromJson(e as Map)) + .toList(), + aspspName: (aspsp?['name'] as String?) ?? '', + aspspCountry: (aspsp?['country'] as String?) ?? '', + validUntil: DateTime.parse(access?['valid_until'] as String), + psuType: json['psu_type'] as String?, + ); + } +} diff --git a/lib/services/banking/models/eb_transaction.dart b/lib/services/banking/models/eb_transaction.dart new file mode 100644 index 00000000..8c2e4766 --- /dev/null +++ b/lib/services/banking/models/eb_transaction.dart @@ -0,0 +1,70 @@ +import 'eb_amount.dart'; + +DateTime? _parseDate(Object? value) { + if (value == null) return null; + return DateTime.tryParse(value as String); +} + +/// A single transaction as returned by `GET /accounts/{uid}/transactions`. +class EbTransaction { + final String? entryReference; + final String? transactionId; + final String status; + final DateTime? bookingDate; + final DateTime? valueDate; + final DateTime? transactionDate; + final EbAmount transactionAmount; + final String creditDebitIndicator; + final String? creditorName; + final String? debtorName; + final List remittanceInformation; + final String? note; + + const EbTransaction({ + this.entryReference, + this.transactionId, + required this.status, + this.bookingDate, + this.valueDate, + this.transactionDate, + required this.transactionAmount, + required this.creditDebitIndicator, + this.creditorName, + this.debtorName, + this.remittanceInformation = const [], + this.note, + }); + + static EbTransaction fromJson(Map json) => EbTransaction( + entryReference: json['entry_reference'] as String?, + transactionId: json['transaction_id'] as String?, + status: json['status'] as String, + bookingDate: _parseDate(json['booking_date']), + valueDate: _parseDate(json['value_date']), + transactionDate: _parseDate(json['transaction_date']), + transactionAmount: EbAmount.fromJson( + json['transaction_amount'] as Map, + ), + creditDebitIndicator: json['credit_debit_indicator'] as String, + creditorName: + (json['creditor'] as Map?)?['name'] as String?, + debtorName: (json['debtor'] as Map?)?['name'] as String?, + remittanceInformation: + ((json['remittance_information'] as List?) ?? const []) + .map((e) => e as String) + .toList(), + note: json['note'] as String?, + ); + + /// Signed amount: positive for credits (`CRDT`), negative otherwise. + /// + /// Anything that is not `CRDT` (e.g. `DBIT`/`DBDT`) is treated as an outflow. + num get signedAmount => creditDebitIndicator == 'CRDT' + ? transactionAmount.amount + : -transactionAmount.amount; + + /// Stable dedup key: `entry_reference` when present, else `transaction_id`. + String? get stableId => entryReference ?? transactionId; + + bool get isBooked => status == 'BOOK'; +} diff --git a/lib/services/banking/models/eb_transactions_page.dart b/lib/services/banking/models/eb_transactions_page.dart new file mode 100644 index 00000000..e4bca986 --- /dev/null +++ b/lib/services/banking/models/eb_transactions_page.dart @@ -0,0 +1,22 @@ +import 'eb_transaction.dart'; + +/// Paginated envelope from `GET /accounts/{uid}/transactions`. +/// +/// [continuationKey] is echoed back on the next request to fetch more pages. +class EbTransactionsPage { + final List transactions; + final String? continuationKey; + + const EbTransactionsPage({ + this.transactions = const [], + this.continuationKey, + }); + + static EbTransactionsPage fromJson(Map json) => + EbTransactionsPage( + transactions: ((json['transactions'] as List?) ?? const []) + .map((e) => EbTransaction.fromJson(e as Map)) + .toList(), + continuationKey: json['continuation_key'] as String?, + ); +} diff --git a/test/fixtures/eb_account.json b/test/fixtures/eb_account.json new file mode 100644 index 00000000..3d5bc69f --- /dev/null +++ b/test/fixtures/eb_account.json @@ -0,0 +1,12 @@ +{ + "uid": "acc-uid-123", + "account_id": { "iban": "IT60X0542811101000000123456" }, + "all_account_ids": [{ "iban": "IT60X0542811101000000123456" }], + "name": "Main Account", + "details": "Checking", + "currency": "EUR", + "product": "Current Account", + "cash_account_type": "CACC", + "usage": "PRIV", + "identification_hash": "idhash-abc" +} diff --git a/test/fixtures/eb_aspsp.json b/test/fixtures/eb_aspsp.json new file mode 100644 index 00000000..103c2252 --- /dev/null +++ b/test/fixtures/eb_aspsp.json @@ -0,0 +1,14 @@ +{ + "name": "Mock ASPSP", + "country": "IT", + "logo": "https://example.com/logo.png", + "psu_types": ["personal", "business"], + "auth_methods": [], + "maximum_consent_validity": 7776000, + "beta": false, + "sandbox": { + "users": [ + { "username": "sandbox-user-1" } + ] + } +} diff --git a/test/fixtures/eb_balance.json b/test/fixtures/eb_balance.json new file mode 100644 index 00000000..1f010163 --- /dev/null +++ b/test/fixtures/eb_balance.json @@ -0,0 +1,5 @@ +{ + "name": "Closing booked", + "balance_amount": { "amount": "1234.56", "currency": "EUR" }, + "balance_type": "CLBD" +} diff --git a/test/fixtures/eb_session.json b/test/fixtures/eb_session.json new file mode 100644 index 00000000..7abc8f28 --- /dev/null +++ b/test/fixtures/eb_session.json @@ -0,0 +1,19 @@ +{ + "session_id": "sess-789", + "status": "AUTHORIZED", + "accounts": [ + { + "uid": "acc-uid-123", + "account_id": { "iban": "IT60X0542811101000000123456" }, + "name": "Main Account", + "currency": "EUR", + "product": "Current Account", + "cash_account_type": "CACC", + "usage": "PRIV", + "identification_hash": "idhash-abc" + } + ], + "aspsp": { "name": "Mock ASPSP", "country": "IT" }, + "psu_type": "personal", + "access": { "valid_until": "2025-04-15T10:00:00.000Z" } +} diff --git a/test/fixtures/eb_transactions.json b/test/fixtures/eb_transactions.json new file mode 100644 index 00000000..3a844d5e --- /dev/null +++ b/test/fixtures/eb_transactions.json @@ -0,0 +1,33 @@ +{ + "transactions": [ + { + "entry_reference": "ref-001", + "transaction_id": "tx-001", + "status": "BOOK", + "booking_date": "2025-01-15", + "value_date": "2025-01-15", + "transaction_date": "2025-01-14", + "transaction_amount": { "amount": "10.33", "currency": "EUR" }, + "credit_debit_indicator": "CRDT", + "creditor": { "name": "Alice" }, + "debtor": { "name": "Bob" }, + "remittance_information": ["Salary", "January"], + "note": null + }, + { + "entry_reference": null, + "transaction_id": "tx-002", + "status": "PDNG", + "booking_date": null, + "value_date": "2025-01-16", + "transaction_date": "2025-01-16", + "transaction_amount": { "amount": "25.00", "currency": "EUR" }, + "credit_debit_indicator": "DBIT", + "creditor": { "name": "Grocery Store" }, + "debtor": { "name": "Me" }, + "remittance_information": ["Groceries"], + "note": "supermarket" + } + ], + "continuation_key": "next-page-123" +} diff --git a/test/services/banking/eb_models_test.dart b/test/services/banking/eb_models_test.dart new file mode 100644 index 00000000..4e2b13e2 --- /dev/null +++ b/test/services/banking/eb_models_test.dart @@ -0,0 +1,125 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:sossoldi/services/banking/models/aspsp.dart'; +import 'package:sossoldi/services/banking/models/eb_account.dart'; +import 'package:sossoldi/services/banking/models/eb_balance.dart'; +import 'package:sossoldi/services/banking/models/eb_session.dart'; +import 'package:sossoldi/services/banking/models/eb_transactions_page.dart'; + +Map _loadJson(String name) => + jsonDecode(File('test/fixtures/$name').readAsStringSync()) + as Map; + +void main() { + group('Aspsp.fromJson', () { + test('parses fields including psu_types and nested sandbox', () { + final aspsp = Aspsp.fromJson(_loadJson('eb_aspsp.json')); + + expect(aspsp.name, 'Mock ASPSP'); + expect(aspsp.country, 'IT'); + expect(aspsp.logo, 'https://example.com/logo.png'); + expect(aspsp.psuTypes, ['personal', 'business']); + expect(aspsp.maximumConsentValidity, 7776000); + expect(aspsp.beta, false); + expect(aspsp.sandbox, isNotNull); + expect(aspsp.sandbox!.users, isNotEmpty); + }); + + test('null sandbox and missing psu_types are handled', () { + final aspsp = Aspsp.fromJson({'name': 'X', 'country': 'FR'}); + + expect(aspsp.sandbox, isNull); + expect(aspsp.psuTypes, isEmpty); + expect(aspsp.beta, false); + expect(aspsp.maximumConsentValidity, isNull); + }); + }); + + group('EbAccount.fromJson', () { + test('reads nested account_id.iban', () { + final account = EbAccount.fromJson(_loadJson('eb_account.json')); + + expect(account.uid, 'acc-uid-123'); + expect(account.iban, 'IT60X0542811101000000123456'); + expect(account.name, 'Main Account'); + expect(account.currency, 'EUR'); + expect(account.cashAccountType, 'CACC'); + expect(account.usage, 'PRIV'); + expect(account.identificationHash, 'idhash-abc'); + }); + + test('missing account_id yields null iban', () { + final account = EbAccount.fromJson({'uid': 'only-uid'}); + + expect(account.uid, 'only-uid'); + expect(account.iban, isNull); + }); + }); + + group('EbBalance.fromJson', () { + test('parses string amount into num', () { + final balance = EbBalance.fromJson(_loadJson('eb_balance.json')); + + expect(balance.name, 'Closing booked'); + expect(balance.balanceAmount.amount, 1234.56); + expect(balance.balanceAmount.currency, 'EUR'); + expect(balance.balanceType, 'CLBD'); + }); + }); + + group('EbTransactionsPage.fromJson', () { + late EbTransactionsPage page; + + setUp(() { + page = EbTransactionsPage.fromJson(_loadJson('eb_transactions.json')); + }); + + test('parses envelope with continuation_key', () { + expect(page.continuationKey, 'next-page-123'); + expect(page.transactions.length, 2); + }); + + test('credit transaction: positive signedAmount, stableId, isBooked', () { + final credit = page.transactions[0]; + + expect(credit.transactionAmount.amount, 10.33); + expect(credit.signedAmount, 10.33); + expect(credit.creditDebitIndicator, 'CRDT'); + expect(credit.stableId, 'ref-001'); + expect(credit.isBooked, isTrue); + expect(credit.creditorName, 'Alice'); + expect(credit.debtorName, 'Bob'); + expect(credit.remittanceInformation, ['Salary', 'January']); + expect(credit.bookingDate, DateTime.parse('2025-01-15')); + }); + + test('debit transaction: negative signedAmount, fallback stableId', () { + final debit = page.transactions[1]; + + expect(debit.signedAmount, -25.00); + expect(debit.creditDebitIndicator, 'DBIT'); + // entry_reference is null → stableId falls back to transaction_id. + expect(debit.stableId, 'tx-002'); + expect(debit.isBooked, isFalse); + expect(debit.bookingDate, isNull); + expect(debit.note, 'supermarket'); + }); + }); + + group('EbSession.fromJson', () { + test('reads nested aspsp and access.valid_until', () { + final session = EbSession.fromJson(_loadJson('eb_session.json')); + + expect(session.sessionId, 'sess-789'); + expect(session.aspspName, 'Mock ASPSP'); + expect(session.aspspCountry, 'IT'); + expect(session.psuType, 'personal'); + expect(session.validUntil, DateTime.parse('2025-04-15T10:00:00.000Z')); + expect(session.accounts.length, 1); + expect(session.accounts.first.iban, 'IT60X0542811101000000123456'); + }); + }); +} From 4311c9ccdd674a0b39162e22341a32d7a2b7d198 Mon Sep 17 00:00:00 2001 From: Alessandro Mariani Date: Tue, 28 Jul 2026 16:11:48 +0200 Subject: [PATCH 03/15] chore(banking): regenerate platform plugin registrants via flutter pub get --- linux/flutter/generated_plugin_registrant.cc | 12 ++++++++---- linux/flutter/generated_plugins.cmake | 4 +++- macos/Flutter/GeneratedPluginRegistrant.swift | 8 ++++---- windows/flutter/generated_plugin_registrant.cc | 9 ++++++--- windows/flutter/generated_plugins.cmake | 4 +++- 5 files changed, 24 insertions(+), 13 deletions(-) diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 4c0025f9..5a27a5d6 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,13 +6,17 @@ #include "generated_plugin_registrant.h" -#include +#include +#include #include void fl_register_plugins(FlPluginRegistry* registry) { - g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin"); - sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_registrar); + g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); + flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); + g_autoptr(FlPluginRegistrar) gtk_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin"); + gtk_plugin_register_with_registrar(gtk_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index ad279a8f..015388dc 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,11 +3,13 @@ # list(APPEND FLUTTER_PLUGIN_LIST - sqlite3_flutter_libs + flutter_secure_storage_linux + gtk url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 1d6592df..a3181549 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,26 +5,26 @@ import FlutterMacOS import Foundation +import app_links import device_info_plus import file_picker import flutter_local_notifications +import flutter_secure_storage_macos import local_auth_darwin import package_info_plus -import path_provider_foundation import shared_preferences_foundation import sqflite_darwin -import sqlite3_flutter_libs import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) + FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) - PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) - Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index e2958fab..edd6aea9 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,18 +6,21 @@ #include "generated_plugin_registrant.h" +#include +#include #include #include -#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + AppLinksPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("AppLinksPluginCApi")); + FlutterSecureStorageWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); LocalAuthPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("LocalAuthPlugin")); PermissionHandlerWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); - Sqlite3FlutterLibsPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index bd1bfbdb..91e1afca 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,14 +3,16 @@ # list(APPEND FLUTTER_PLUGIN_LIST + app_links + flutter_secure_storage_windows local_auth_windows permission_handler_windows - sqlite3_flutter_libs url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST flutter_local_notifications_windows + jni ) set(PLUGIN_BUNDLED_LIBRARIES) From 812ac1ba635a7366d168e1088dcf1924c5430b5a Mon Sep 17 00:00:00 2001 From: Alessandro Mariani Date: Tue, 28 Jul 2026 16:12:08 +0200 Subject: [PATCH 04/15] chore(banking): update desktop plugin integration --- macos/Podfile | 9 ++- macos/Podfile.lock | 101 +++++++++++++++---------- macos/Runner.xcodeproj/project.pbxproj | 6 +- macos/Runner/AppDelegate.swift | 6 +- 4 files changed, 77 insertions(+), 45 deletions(-) diff --git a/macos/Podfile b/macos/Podfile index c795730d..88aae1c4 100644 --- a/macos/Podfile +++ b/macos/Podfile @@ -1,4 +1,11 @@ -platform :osx, '10.14' +# Not driven by any single plugin: flutter_secure_storage_macos, app_links +# and the other desktop plugins added for Enable Banking all declare 10.14 +# as their own minimum. 10.15 is this Flutter SDK's own current macOS +# Podfile template floor (see templates/cocoapods/Podfile-macos), which a +# `flutter pub get`/`pod install` regenerates independently of app code — +# dropping Mojave (10.14) support is a side effect of the SDK, not a +# deliberate product decision. +platform :osx, '10.15' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 3ae8ea94..c7831310 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -1,58 +1,79 @@ PODS: + - app_links (6.4.1): + - FlutterMacOS + - device_info_plus (0.0.1): + - FlutterMacOS + - file_picker (0.0.1): + - FlutterMacOS + - flutter_local_notifications (0.0.1): + - FlutterMacOS + - flutter_secure_storage_macos (6.1.3): + - FlutterMacOS - FlutterMacOS (1.0.0) - - FMDB (2.7.5): - - FMDB/standard (= 2.7.5) - - FMDB/standard (2.7.5) - - sqflite (0.0.2): - - FlutterMacOS - - FMDB (>= 2.7.5) - - sqlite3 (3.44.0): - - sqlite3/common (= 3.44.0) - - sqlite3/common (3.44.0) - - sqlite3/fts5 (3.44.0): - - sqlite3/common - - sqlite3/perf-threadsafe (3.44.0): - - sqlite3/common - - sqlite3/rtree (3.44.0): - - sqlite3/common - - sqlite3_flutter_libs (0.0.1): - - FlutterMacOS - - sqlite3 (~> 3.44.0) - - sqlite3/fts5 - - sqlite3/perf-threadsafe - - sqlite3/rtree + - local_auth_darwin (0.0.1): + - Flutter + - FlutterMacOS + - package_info_plus (0.0.1): + - FlutterMacOS + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - sqflite_darwin (0.0.4): + - Flutter + - FlutterMacOS - url_launcher_macos (0.0.1): - FlutterMacOS DEPENDENCIES: + - app_links (from `Flutter/ephemeral/.symlinks/plugins/app_links/macos`) + - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`) + - file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`) + - flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`) + - flutter_secure_storage_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - - sqflite (from `Flutter/ephemeral/.symlinks/plugins/sqflite/macos`) - - sqlite3_flutter_libs (from `Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/macos`) + - local_auth_darwin (from `Flutter/ephemeral/.symlinks/plugins/local_auth_darwin/darwin`) + - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) + - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) + - sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`) - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) -SPEC REPOS: - trunk: - - FMDB - - sqlite3 - EXTERNAL SOURCES: + app_links: + :path: Flutter/ephemeral/.symlinks/plugins/app_links/macos + device_info_plus: + :path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos + file_picker: + :path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos + flutter_local_notifications: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos + flutter_secure_storage_macos: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos FlutterMacOS: :path: Flutter/ephemeral - sqflite: - :path: Flutter/ephemeral/.symlinks/plugins/sqflite/macos - sqlite3_flutter_libs: - :path: Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/macos + local_auth_darwin: + :path: Flutter/ephemeral/.symlinks/plugins/local_auth_darwin/darwin + package_info_plus: + :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos + shared_preferences_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin + sqflite_darwin: + :path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin url_launcher_macos: :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos SPEC CHECKSUMS: - FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 - FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a - sqflite: a5789cceda41d54d23f31d6de539d65bb14100ea - sqlite3: 6e2d4a4879854d0ec86b476bf3c3e30870bac273 - sqlite3_flutter_libs: 5b7e226d522d67be60d7ade93f5aa11ebc0cd796 - url_launcher_macos: d2691c7dd33ed713bf3544850a623080ec693d95 + app_links: 05a6ec2341985eb05e9f97dc63f5837c39895c3f + device_info_plus: 4fb280989f669696856f8b129e4a5e3cd6c48f76 + file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a + flutter_local_notifications: 4bf37a31afde695b56091b4ae3e4d9c7a7e6cda0 + flutter_secure_storage_macos: 7f45e30f838cf2659862a4e4e3ee1c347c2b3b54 + FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb + package_info_plus: f0052d280d17aa382b932f399edf32507174e870 + shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb + sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 + url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd -PODFILE CHECKSUM: 236401fc2c932af29a9fcf0e97baeeb2d750d367 +PODFILE CHECKSUM: 9ebaf0ce3d369aaa26a9ea0e159195ed94724cf3 -COCOAPODS: 1.12.1 +COCOAPODS: 1.16.2 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index c1e7cca4..2d87b4a4 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -553,7 +553,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -632,7 +632,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -679,7 +679,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift index d53ef643..b3c17614 100644 --- a/macos/Runner/AppDelegate.swift +++ b/macos/Runner/AppDelegate.swift @@ -1,9 +1,13 @@ import Cocoa import FlutterMacOS -@NSApplicationMain +@main class AppDelegate: FlutterAppDelegate { override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return true } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } } From ccd59bfa30ca6af7e4003e614ebdbb328f539cf1 Mon Sep 17 00:00:00 2001 From: Alessandro Mariani Date: Tue, 28 Jul 2026 14:41:56 +0200 Subject: [PATCH 05/15] feat(banking): add enable banking config and secure credentials store Adds EnableBankingConfig (environment/redirect/base URL) and a FlutterSecureStorage-backed store for the user's BYOC app_id and private key PEM, plus a draft riverpod provider exposing them. --- lib/providers/banking_provider.dart | 50 ++++++++++++++ .../banking/enable_banking_config.dart | 67 +++++++++++++++++++ .../enable_banking_credentials_store.dart | 65 ++++++++++++++++++ .../banking/enable_banking_config_test.dart | 45 +++++++++++++ ...enable_banking_credentials_store_test.dart | 64 ++++++++++++++++++ 5 files changed, 291 insertions(+) create mode 100644 lib/providers/banking_provider.dart create mode 100644 lib/services/banking/enable_banking_config.dart create mode 100644 lib/services/banking/enable_banking_credentials_store.dart create mode 100644 test/services/banking/enable_banking_config_test.dart create mode 100644 test/services/banking/enable_banking_credentials_store_test.dart diff --git a/lib/providers/banking_provider.dart b/lib/providers/banking_provider.dart new file mode 100644 index 00000000..4305dcca --- /dev/null +++ b/lib/providers/banking_provider.dart @@ -0,0 +1,50 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../services/banking/enable_banking_config.dart'; +import '../services/banking/enable_banking_credentials_store.dart'; + +part 'banking_provider.g.dart'; + +@Riverpod(keepAlive: true) +EnableBankingCredentialsStore enableBankingCredentialsStore(Ref ref) => + const EnableBankingCredentialsStore(); + +@Riverpod(keepAlive: true) +class EnableBankingSettings extends _$EnableBankingSettings { + @override + Future build() async { + final store = ref.watch(enableBankingCredentialsStoreProvider); + return store.readConfig(); + } + + Future hasCredentials() async { + final store = ref.watch(enableBankingCredentialsStoreProvider); + return store.hasCredentials(); + } + + Future save({ + required String appId, + required String privateKeyPem, + required EnableBankingConfig config, + }) async { + state = const AsyncLoading(); + state = await AsyncValue.guard(() async { + final store = ref.read(enableBankingCredentialsStoreProvider); + await store.saveCredentials( + appId: appId, + privateKeyPem: privateKeyPem, + config: config, + ); + return store.readConfig(); + }); + } + + Future clear() async { + state = const AsyncLoading(); + state = await AsyncValue.guard(() async { + final store = ref.read(enableBankingCredentialsStoreProvider); + await store.clear(); + return null; + }); + } +} diff --git a/lib/services/banking/enable_banking_config.dart b/lib/services/banking/enable_banking_config.dart new file mode 100644 index 00000000..02a9d8b0 --- /dev/null +++ b/lib/services/banking/enable_banking_config.dart @@ -0,0 +1,67 @@ +import 'enable_banking_exception.dart'; + +/// Default OAuth callback URI registered with Enable Banking. +const String kEbRedirectUri = 'sossoldi://eb-callback'; + +enum EnableBankingEnvironment { + production, + sandbox; + + static EnableBankingEnvironment fromJson(String value) => + EnableBankingEnvironment.values.firstWhere( + (e) => e.name == value, + orElse: () => EnableBankingEnvironment.production, + ); + + String toJson() => name; +} + +/// Non-secret configuration for a user's Enable Banking application. +/// +/// The `app_id` and private key are BYOC credentials owned by the user and +/// live in [EnableBankingCredentialsStore], not here. +class EnableBankingConfig { + final String appId; + final EnableBankingEnvironment environment; + final String redirectUri; + final String? defaultCountry; + + const EnableBankingConfig({ + required this.appId, + this.environment = EnableBankingEnvironment.production, + this.redirectUri = kEbRedirectUri, + this.defaultCountry, + }); + + /// Enable Banking serves the same host for both production and sandbox; + /// the environment is fixed at application registration time. + String get baseUrl => 'https://api.enablebanking.com'; + + /// Throws [EnableBankingException] (not a raw [TypeError]) if [json] is a + /// corrupted or otherwise incompatible blob — e.g. leftover from a + /// previous, incompatible app version — instead of the required `app_id` + /// field being missing or of an unexpected type. + static EnableBankingConfig fromJson(Map json) { + try { + return EnableBankingConfig( + appId: json['app_id'] as String, + environment: EnableBankingEnvironment.fromJson( + json['environment'] as String? ?? 'production', + ), + redirectUri: json['redirect_uri'] as String? ?? kEbRedirectUri, + defaultCountry: json['default_country'] as String?, + ); + } catch (e) { + throw EnableBankingException( + message: 'Malformed Enable Banking configuration: $e', + ); + } + } + + Map toJson() => { + 'app_id': appId, + 'environment': environment.toJson(), + 'redirect_uri': redirectUri, + 'default_country': defaultCountry, + }; +} diff --git a/lib/services/banking/enable_banking_credentials_store.dart b/lib/services/banking/enable_banking_credentials_store.dart new file mode 100644 index 00000000..6a9809ce --- /dev/null +++ b/lib/services/banking/enable_banking_credentials_store.dart @@ -0,0 +1,65 @@ +import 'dart:convert'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +import 'enable_banking_config.dart'; + +const _kAppIdKey = 'eb_app_id'; +const _kPrivateKeyPemKey = 'eb_private_key_pem'; +const _kConfigJsonKey = 'eb_config_json'; + +/// Encrypted persistence for the user's BYOC Enable Banking credentials +/// (Keychain on iOS/macOS, Keystore-backed EncryptedSharedPreferences on +/// Android). The private key is never logged nor exposed beyond this store. +class EnableBankingCredentialsStore { + final FlutterSecureStorage _storage; + + const EnableBankingCredentialsStore({FlutterSecureStorage? storage}) + : _storage = + storage ?? + const FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + iOptions: IOSOptions( + accessibility: KeychainAccessibility.first_unlock, + ), + ); + + Future saveCredentials({ + required String appId, + required String privateKeyPem, + required EnableBankingConfig config, + }) async { + await _storage.write(key: _kAppIdKey, value: appId); + await _storage.write(key: _kPrivateKeyPemKey, value: privateKeyPem); + await _storage.write( + key: _kConfigJsonKey, + value: jsonEncode(config.toJson()), + ); + } + + Future readConfig() async { + final appId = await _storage.read(key: _kAppIdKey); + if (appId == null) { + return null; + } + final configJson = await _storage.read(key: _kConfigJsonKey); + if (configJson == null) { + return EnableBankingConfig(appId: appId); + } + return EnableBankingConfig.fromJson( + jsonDecode(configJson) as Map, + ); + } + + Future readPrivateKey() => _storage.read(key: _kPrivateKeyPemKey); + + Future hasCredentials() async => + await _storage.containsKey(key: _kAppIdKey) && + await _storage.containsKey(key: _kPrivateKeyPemKey); + + Future clear() async { + await _storage.delete(key: _kAppIdKey); + await _storage.delete(key: _kPrivateKeyPemKey); + await _storage.delete(key: _kConfigJsonKey); + } +} diff --git a/test/services/banking/enable_banking_config_test.dart b/test/services/banking/enable_banking_config_test.dart new file mode 100644 index 00000000..fef9fbb2 --- /dev/null +++ b/test/services/banking/enable_banking_config_test.dart @@ -0,0 +1,45 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sossoldi/services/banking/enable_banking_config.dart'; +import 'package:sossoldi/services/banking/enable_banking_exception.dart'; + +void main() { + group('EnableBankingConfig.fromJson', () { + test('parses a well-formed config', () { + final config = EnableBankingConfig.fromJson({ + 'app_id': 'app-1', + 'environment': 'sandbox', + 'redirect_uri': 'https://example.com/callback', + 'default_country': 'IT', + }); + + expect(config.appId, 'app-1'); + expect(config.environment, EnableBankingEnvironment.sandbox); + expect(config.redirectUri, 'https://example.com/callback'); + expect(config.defaultCountry, 'IT'); + }); + + test('defaults environment and redirectUri when absent', () { + final config = EnableBankingConfig.fromJson({'app_id': 'app-1'}); + + expect(config.environment, EnableBankingEnvironment.production); + expect(config.redirectUri, kEbRedirectUri); + expect(config.defaultCountry, isNull); + }); + + test('throws EnableBankingException instead of a raw TypeError when ' + 'app_id is missing', () { + expect( + () => EnableBankingConfig.fromJson({'environment': 'sandbox'}), + throwsA(isA()), + ); + }); + + test('throws EnableBankingException instead of a raw TypeError when ' + 'app_id is the wrong type (corrupted/incompatible stored blob)', () { + expect( + () => EnableBankingConfig.fromJson({'app_id': 42}), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/services/banking/enable_banking_credentials_store_test.dart b/test/services/banking/enable_banking_credentials_store_test.dart new file mode 100644 index 00000000..76062ef7 --- /dev/null +++ b/test/services/banking/enable_banking_credentials_store_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sossoldi/services/banking/enable_banking_config.dart'; +import 'package:sossoldi/services/banking/enable_banking_credentials_store.dart'; + +void main() { + setUp(() { + FlutterSecureStorage.setMockInitialValues({}); + }); + + group('EnableBankingCredentialsStore', () { + test('hasCredentials is false before anything is saved', () async { + final store = const EnableBankingCredentialsStore(); + expect(await store.hasCredentials(), isFalse); + expect(await store.readConfig(), isNull); + expect(await store.readPrivateKey(), isNull); + }); + + test('saves and reads back appId, private key and config', () async { + final store = const EnableBankingCredentialsStore(); + const config = EnableBankingConfig( + appId: 'app-123', + environment: EnableBankingEnvironment.sandbox, + defaultCountry: 'IT', + ); + + await store.saveCredentials( + appId: 'app-123', + privateKeyPem: + '-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----', + config: config, + ); + + expect(await store.hasCredentials(), isTrue); + expect( + await store.readPrivateKey(), + '-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----', + ); + + final readConfig = await store.readConfig(); + expect(readConfig, isNotNull); + expect(readConfig!.appId, 'app-123'); + expect(readConfig.environment, EnableBankingEnvironment.sandbox); + expect(readConfig.defaultCountry, 'IT'); + expect(readConfig.redirectUri, kEbRedirectUri); + expect(readConfig.baseUrl, 'https://api.enablebanking.com'); + }); + + test('clear removes appId, private key and config', () async { + final store = const EnableBankingCredentialsStore(); + await store.saveCredentials( + appId: 'app-123', + privateKeyPem: 'pem', + config: const EnableBankingConfig(appId: 'app-123'), + ); + + await store.clear(); + + expect(await store.hasCredentials(), isFalse); + expect(await store.readConfig(), isNull); + expect(await store.readPrivateKey(), isNull); + }); + }); +} From ca8d9e3a7c1e52c99e2f7922d03efa9c2af13722 Mon Sep 17 00:00:00 2001 From: Alessandro Mariani Date: Tue, 28 Jul 2026 16:26:17 +0200 Subject: [PATCH 06/15] feat(banking): add RS256 JWT signing for enable banking auth Signs Enable Banking API JWTs on-device from the user's BYOC app_id and private key PEM, caching the token in memory and refreshing it shortly before expiry. --- lib/services/banking/enable_banking_auth.dart | 99 +++++++++++ .../banking/enable_banking_auth_test.dart | 163 ++++++++++++++++++ 2 files changed, 262 insertions(+) create mode 100644 lib/services/banking/enable_banking_auth.dart create mode 100644 test/services/banking/enable_banking_auth_test.dart diff --git a/lib/services/banking/enable_banking_auth.dart b/lib/services/banking/enable_banking_auth.dart new file mode 100644 index 00000000..812ebc50 --- /dev/null +++ b/lib/services/banking/enable_banking_auth.dart @@ -0,0 +1,99 @@ +import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; + +import 'enable_banking_credentials_store.dart'; + +const _kIssuer = 'enablebanking.com'; +const _kAudience = 'api.enablebanking.com'; +const _kMaxTtl = Duration(hours: 24); +const _kRefreshMargin = Duration(minutes: 5); + +/// Thrown when the Enable Banking JWT cannot be built: an unparsable private +/// key PEM or missing BYOC credentials. +class EnableBankingAuthException implements Exception { + final String message; + + const EnableBankingAuthException(this.message); + + @override + String toString() => 'EnableBankingAuthException: $message'; +} + +/// Signs Enable Banking API requests with an RS256 JWT built from the +/// user's BYOC `app_id` + private key, on-device. The signed token is only +/// ever cached in memory (never persisted) and reused until close to expiry. +class EnableBankingAuth { + final Duration _tokenTtl; + final Duration _refreshMargin; + final DateTime Function() _now; + + String? _cachedToken; + DateTime? _cachedExpiry; + + /// [tokenTtl] and [refreshMargin] default to the production values; they + /// (and [now]) are only overridden in tests to make cache expiry + /// deterministic without real waiting. + EnableBankingAuth({ + Duration tokenTtl = const Duration(hours: 1), + Duration refreshMargin = _kRefreshMargin, + DateTime Function() now = DateTime.now, + }) : _tokenTtl = tokenTtl, + _refreshMargin = refreshMargin, + _now = now; + + /// Builds and signs a new RS256 JWT for the Enable Banking API. + String buildJwt({ + required String appId, + required String privateKeyPem, + Duration ttl = const Duration(hours: 1), + }) { + assert(ttl <= _kMaxTtl, 'ttl must not exceed 24h'); + + final RSAPrivateKey key; + try { + key = RSAPrivateKey(privateKeyPem); + } catch (_) { + throw const EnableBankingAuthException('Invalid private key'); + } + + final jwt = JWT( + {'iss': _kIssuer, 'aud': _kAudience}, + header: {'kid': appId}, + ); + + try { + return jwt.sign(key, algorithm: JWTAlgorithm.RS256, expiresIn: ttl); + } catch (e) { + throw EnableBankingAuthException('Could not sign the request: $e'); + } + } + + /// Returns a cached token while it still has more than [_refreshMargin] + /// left before expiry, otherwise signs and caches a fresh one. + Future getValidToken(EnableBankingCredentialsStore store) async { + final now = _now(); + final cachedExpiry = _cachedExpiry; + if (_cachedToken != null && + cachedExpiry != null && + cachedExpiry.isAfter(now.add(_refreshMargin))) { + return _cachedToken!; + } + + final config = await store.readConfig(); + final privateKeyPem = await store.readPrivateKey(); + if (config == null || privateKeyPem == null) { + throw const EnableBankingAuthException( + 'Enable Banking credentials are not configured', + ); + } + + final token = buildJwt( + appId: config.appId, + privateKeyPem: privateKeyPem, + ttl: _tokenTtl, + ); + + _cachedToken = token; + _cachedExpiry = now.add(_tokenTtl); + return token; + } +} diff --git a/test/services/banking/enable_banking_auth_test.dart b/test/services/banking/enable_banking_auth_test.dart new file mode 100644 index 00000000..01a4cc63 --- /dev/null +++ b/test/services/banking/enable_banking_auth_test.dart @@ -0,0 +1,163 @@ +import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sossoldi/services/banking/enable_banking_auth.dart'; +import 'package:sossoldi/services/banking/enable_banking_config.dart'; +import 'package:sossoldi/services/banking/enable_banking_credentials_store.dart'; + +const _testAppId = 'test-app-id'; + +// Test-only RSA key pair (2048 bit), generated locally with openssl. Not +// used anywhere outside this test. +const _testPrivateKeyPem = '''-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCK8tcYKu5wY289 +mQaXvehFJDyUnWxIKaKPNa+Vj1HV+mYtciLBBvLFjXmG8av4FFSctXNHSlkBDhjY +h0L5QASYI2vgVLn9NFb7F8Y97v2/T64AXf26PbeDrOdWmgKieliX8/36h2iVoEJD +1dq7yCtO8gLRkHLbFk3+Uc8T+p2yILcLcQVjWSwZH3o1egmTG8VUoxY5MxU7gbfG +iL5Y+UzxGgzR0EZYY+lG8vkZrBBw2RKbOMr9W/OIJivA/ycDf8tci+IyJUbW5jOB +xskpoA0s4uP3QI054tiWepHJlOL8JsQvlBi8TDGuSzzxgJy67JG1a45HZigPUwQD ++viEBlXFAgMBAAECggEAALlaOJSEsI48FqiU+bZyHuYc8LNPTKL252MiupdIGGLZ +JHR7TOEx46ikkPjyafUFKdniM2VmSFSf+YTe6viT7Y0Fs2INpA2hRGav0EPOC7Cp +GfRjbZSDv8pnXDKqP9l+W7BYSvVorJrsrXjQTnkxDJTDQf9MMesQVfpsJ1D/gF2c +T19QmPS0dSG5CQbYM5qfXY39z+mMeaHpYUOrAa4GcKrSua7B+7FRaAxaPTQ8ADvF +4e7fjSXZtp8CMKXcoFd83uzzsV/S6EtSxSlduQiTFQ6uX5bxbA54gAGyg3Y7ycd3 +zvrgn0FDWb0F6Pf/dvZTZlC6NSujSuDoxMadtDVLswKBgQC91mCVwgrXo9nscJ2F +TbzVnFRKvAQJTiBIuNZWKgYLFZkF3xl98sZZmF/o5gr52i5L6D8rdF0WyBd/6Wlt +HG36AiLV4QcsEcHRA/p0itm2FwGMrrCjcNrum2EQxWZyTgXpg4LmZdTOshGPQTJb +157/wdA7vWwzKosa0Hx/UIoSwwKBgQC7YBMVGKVaM+sZA4vCh3hyZfSQVw6Fm2Fs +rDVjzLlyEIfatyF7OMlXL1FBQqRO1c5L+r+Jx6niBNt2gDaxIRSoMDBcqG/6coJq +X7Xtnu07//GCjypUO84mV+gDVVTb3kmkvMtndmlwekIwIZHFOmrozaesklaQbi// +7R1pNzvc1wKBgQCjagokgC/TJwHuDZcbbCq0euj4mFalJ/KUh0BCVdRz8DiUnFMW +X0ktUQSmuHgUUiNuRlMyde24MbBA9M1DFqj5AKO6FwaUggikg1cywV/d5nn3/1yg +pQJhSbHJvROOK+nc9M9Ww6vn7dM9zPKrqrX9FXrKIiok9WdfQr8Y3Vyt/wKBgGGU +jRuFdEdflmW2dQhfqJhbBFxPGh72ZmxD2qIYsdL3TOjYSjVzw1cGK1jDLfJoYkEr +WqiUNKKiSPCToOfqBGn6fpUimv5guA5Rvdr70yxWBB4sFK09YAST7x4dSE4gm1WR +hJRjFFIhOWr7cIC4BYkr4NB8fVxGJfUwNQrgWuqNAoGALvVqAS0Tk0iPX9xMdEe2 +TKQiIDtjdJ/X/CVOI+v6FJ25xgnrUaQ8lAuGATJrHtnE0DiotGdtUF4q8qZvCLIu +B2eonlqf99uayj7vqYbyVsQkD7c7Ykj1nxC3ZKpD+ptmkCbsni5uanzbIIyXQAw1 +f6fEtvtDl8wdWoHhdmPPNfQ= +-----END PRIVATE KEY-----'''; + +const _testPublicKeyPem = '''-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAivLXGCrucGNvPZkGl73o +RSQ8lJ1sSCmijzWvlY9R1fpmLXIiwQbyxY15hvGr+BRUnLVzR0pZAQ4Y2IdC+UAE +mCNr4FS5/TRW+xfGPe79v0+uAF39uj23g6znVpoConpYl/P9+odolaBCQ9Xau8gr +TvIC0ZBy2xZN/lHPE/qdsiC3C3EFY1ksGR96NXoJkxvFVKMWOTMVO4G3xoi+WPlM +8RoM0dBGWGPpRvL5GawQcNkSmzjK/VvziCYrwP8nA3/LXIviMiVG1uYzgcbJKaAN +LOLj90CNOeLYlnqRyZTi/CbEL5QYvEwxrks88YCcuuyRtWuOR2YoD1MEA/r4hAZV +xQIDAQAB +-----END PUBLIC KEY-----'''; + +/// Spies on how many times credentials are fetched from the store, so tests +/// can assert whether [EnableBankingAuth] reused its in-memory cache or +/// signed a fresh token. +class _CountingCredentialsStore extends EnableBankingCredentialsStore { + int readConfigCalls = 0; + + @override + Future readConfig() async { + readConfigCalls++; + return super.readConfig(); + } +} + +void main() { + group('EnableBankingAuth.buildJwt', () { + test('produces an RS256 token with the expected header and claims', () { + final auth = EnableBankingAuth(); + const ttl = Duration(hours: 1); + + final token = auth.buildJwt( + appId: _testAppId, + privateKeyPem: _testPrivateKeyPem, + ttl: ttl, + ); + + final decoded = JWT.decode(token); + expect(decoded.header?['typ'], 'JWT'); + expect(decoded.header?['alg'], 'RS256'); + expect(decoded.header?['kid'], _testAppId); + + final payload = decoded.payload as Map; + expect(payload['iss'], 'enablebanking.com'); + expect(payload['aud'], 'api.enablebanking.com'); + + final iat = payload['iat'] as int; + final exp = payload['exp'] as int; + expect(exp - iat, greaterThan(0)); + expect(exp - iat, lessThanOrEqualTo(ttl.inSeconds)); + + // Signature must verify against the matching public key. + final verified = JWT.verify(token, RSAPublicKey(_testPublicKeyPem)); + expect(verified.payload['iss'], 'enablebanking.com'); + }); + + test('throws EnableBankingAuthException on an unparsable PEM', () { + final auth = EnableBankingAuth(); + + expect( + () => auth.buildJwt(appId: _testAppId, privateKeyPem: 'not a pem'), + throwsA(isA()), + ); + }); + }); + + group('EnableBankingAuth.getValidToken', () { + setUp(() { + FlutterSecureStorage.setMockInitialValues({}); + }); + + Future<_CountingCredentialsStore> storeWithCredentials() async { + final store = _CountingCredentialsStore(); + await store.saveCredentials( + appId: _testAppId, + privateKeyPem: _testPrivateKeyPem, + config: const EnableBankingConfig(appId: _testAppId), + ); + return store; + } + + test('throws when no credentials are configured', () { + final store = _CountingCredentialsStore(); + final auth = EnableBankingAuth(); + + expect( + () => auth.getValidToken(store), + throwsA(isA()), + ); + }); + + test('reuses the cached token within the refresh margin', () async { + final store = await storeWithCredentials(); + var now = DateTime(2026); + final auth = EnableBankingAuth( + tokenTtl: const Duration(hours: 1), + refreshMargin: const Duration(minutes: 5), + now: () => now, + ); + + final first = await auth.getValidToken(store); + now = now.add(const Duration(minutes: 30)); + final second = await auth.getValidToken(store); + + expect(second, first); + expect(store.readConfigCalls, 1); + }); + + test('signs a fresh token once inside the refresh margin', () async { + final store = await storeWithCredentials(); + var now = DateTime(2026); + final auth = EnableBankingAuth( + tokenTtl: const Duration(hours: 1), + refreshMargin: const Duration(minutes: 5), + now: () => now, + ); + + await auth.getValidToken(store); + now = now.add(const Duration(minutes: 56)); + await auth.getValidToken(store); + + expect(store.readConfigCalls, 2); + }); + }); +} From 3848c27afbf943845330e3c1656980d10c23a45c Mon Sep 17 00:00:00 2001 From: Alessandro Mariani Date: Tue, 28 Jul 2026 21:27:19 +0200 Subject: [PATCH 07/15] feat(banking): add enable banking REST API client Thin http client for the Enable Banking endpoints (aspsps, auth, sessions, balances, transactions), mapping responses onto the DTOs and raising EnableBankingException on HTTP errors. --- lib/services/banking/enable_banking_api.dart | 169 ++++++++++++ .../banking/enable_banking_exception.dart | 20 ++ .../banking/enable_banking_api_test.dart | 243 ++++++++++++++++++ 3 files changed, 432 insertions(+) create mode 100644 lib/services/banking/enable_banking_api.dart create mode 100644 lib/services/banking/enable_banking_exception.dart create mode 100644 test/services/banking/enable_banking_api_test.dart diff --git a/lib/services/banking/enable_banking_api.dart b/lib/services/banking/enable_banking_api.dart new file mode 100644 index 00000000..d1f5904d --- /dev/null +++ b/lib/services/banking/enable_banking_api.dart @@ -0,0 +1,169 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import 'enable_banking_auth.dart'; +import 'enable_banking_config.dart'; +import 'enable_banking_credentials_store.dart'; +import 'enable_banking_exception.dart'; +import 'models/aspsp.dart'; +import 'models/eb_auth.dart'; +import 'models/eb_balance.dart'; +import 'models/eb_session.dart'; +import 'models/eb_transactions_page.dart'; + +const _kBaseUrl = 'https://api.enablebanking.com'; + +String _formatDate(DateTime date) { + final utc = date.toUtc(); + final year = utc.year.toString().padLeft(4, '0'); + final month = utc.month.toString().padLeft(2, '0'); + final day = utc.day.toString().padLeft(2, '0'); + return '$year-$month-$day'; +} + +/// Thin REST client for the Enable Banking API. +/// +/// Every request is authenticated with a fresh RS256 JWT (via [_auth]) and +/// mapped onto the DTOs in `models/`; HTTP errors (status >= 400) become +/// [EnableBankingException]. +class EnableBankingApi { + final EnableBankingAuth _auth; + final EnableBankingCredentialsStore _store; + final http.Client _client; + + EnableBankingApi({ + required EnableBankingAuth auth, + required EnableBankingCredentialsStore store, + http.Client? client, + }) : _auth = auth, + _store = store, + _client = client ?? http.Client(); + + Future> _headers() async => { + 'Authorization': 'Bearer ${await _auth.getValidToken(_store)}', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }; + + void _check(http.Response response) { + if (response.statusCode < 400) return; + + Map? body; + try { + body = jsonDecode(response.body) as Map; + } catch (_) { + body = null; + } + throw EnableBankingException( + statusCode: response.statusCode, + error: body?['error'] as String?, + message: body?['message'] as String?, + ); + } + + Future> _get( + String path, [ + Map? query, + ]) async { + final uri = Uri.parse('$_kBaseUrl$path').replace( + queryParameters: query != null && query.isNotEmpty ? query : null, + ); + final response = await _client.get(uri, headers: await _headers()); + _check(response); + return jsonDecode(response.body) as Map; + } + + Future> _post( + String path, + Map body, + ) async { + final uri = Uri.parse('$_kBaseUrl$path'); + final response = await _client.post( + uri, + headers: await _headers(), + body: jsonEncode(body), + ); + _check(response); + return jsonDecode(response.body) as Map; + } + + Future _delete(String path) async { + final uri = Uri.parse('$_kBaseUrl$path'); + final response = await _client.delete(uri, headers: await _headers()); + _check(response); + } + + Future> getAspsps({ + required String country, + String psuType = 'personal', + }) async { + final json = await _get('/aspsps', { + 'country': country, + 'psu_type': psuType, + }); + return ((json['aspsps'] as List?) ?? const []) + .map((e) => Aspsp.fromJson(e as Map)) + .toList(); + } + + Future startAuthorization({ + required String aspspName, + required String aspspCountry, + required String state, + required DateTime validUntil, + String psuType = 'personal', + String? language, + }) async { + final json = await _post('/auth', { + 'access': { + 'valid_until': validUntil.toUtc().toIso8601String(), + 'balances': true, + 'transactions': true, + }, + 'aspsp': {'name': aspspName, 'country': aspspCountry}, + 'state': state, + 'redirect_url': kEbRedirectUri, + 'psu_type': psuType, + 'language': ?language, + }); + return EbAuthorization.fromJson(json); + } + + Future createSession(String code) async { + final json = await _post('/sessions', {'code': code}); + return EbSession.fromJson(json); + } + + Future getSession(String sessionId) async { + final json = await _get('/sessions/$sessionId'); + return EbSession.fromJson(json); + } + + Future deleteSession(String sessionId) async { + await _delete('/sessions/$sessionId'); + } + + Future> getBalances(String accountUid) async { + final json = await _get('/accounts/$accountUid/balances'); + return ((json['balances'] as List?) ?? const []) + .map((e) => EbBalance.fromJson(e as Map)) + .toList(); + } + + Future getTransactions( + String accountUid, { + DateTime? dateFrom, + DateTime? dateTo, + String? continuationKey, + String? transactionStatus, + }) async { + final json = await _get('/accounts/$accountUid/transactions', { + if (dateFrom != null) 'date_from': _formatDate(dateFrom), + if (dateTo != null) 'date_to': _formatDate(dateTo), + 'continuation_key': ?continuationKey, + 'transaction_status': ?transactionStatus, + }); + return EbTransactionsPage.fromJson(json); + } +} diff --git a/lib/services/banking/enable_banking_exception.dart b/lib/services/banking/enable_banking_exception.dart new file mode 100644 index 00000000..4441c596 --- /dev/null +++ b/lib/services/banking/enable_banking_exception.dart @@ -0,0 +1,20 @@ +/// Error surfaced by the Enable Banking REST API (HTTP status >= 400). +/// +/// [statusCode] lets callers detect an expired/revoked consent (401) to +/// mark the connection as `EXPIRED` (see Step 14 of the implementation +/// plan) instead of failing the whole sync. +class EnableBankingException implements Exception { + final int? statusCode; + final String? error; + final String? message; + + const EnableBankingException({this.statusCode, this.error, this.message}); + + /// True when the consent/session is no longer valid. + bool get isUnauthorized => statusCode == 401; + + @override + String toString() => + 'EnableBankingException(statusCode: $statusCode, error: $error, ' + 'message: $message)'; +} diff --git a/test/services/banking/enable_banking_api_test.dart b/test/services/banking/enable_banking_api_test.dart new file mode 100644 index 00000000..dce8ddeb --- /dev/null +++ b/test/services/banking/enable_banking_api_test.dart @@ -0,0 +1,243 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:sossoldi/services/banking/enable_banking_api.dart'; +import 'package:sossoldi/services/banking/enable_banking_auth.dart'; +import 'package:sossoldi/services/banking/enable_banking_config.dart'; +import 'package:sossoldi/services/banking/enable_banking_credentials_store.dart'; +import 'package:sossoldi/services/banking/enable_banking_exception.dart'; + +/// Bypasses credential/JWT signing entirely: the API client only needs a +/// valid bearer token string, not a real signature. +class _FakeAuth extends EnableBankingAuth { + @override + Future getValidToken(EnableBankingCredentialsStore store) async => + 'test-token'; +} + +EnableBankingApi _apiWith(MockClientHandler handler) => EnableBankingApi( + auth: _FakeAuth(), + store: const EnableBankingCredentialsStore(), + client: MockClient(handler), +); + +http.Response _json(Object body, {int statusCode = 200}) => http.Response( + jsonEncode(body), + statusCode, + headers: {'content-type': 'application/json'}, +); + +void main() { + group('EnableBankingApi', () { + test('getAspsps sends auth/query headers and parses the list', () async { + late http.Request captured; + final api = _apiWith((request) async { + captured = request; + return _json({ + 'aspsps': [ + {'name': 'Test Bank', 'country': 'IT'}, + ], + }); + }); + + final aspsps = await api.getAspsps(country: 'IT'); + + expect(captured.method, 'GET'); + expect(captured.url.path, '/aspsps'); + expect(captured.url.queryParameters['country'], 'IT'); + expect(captured.url.queryParameters['psu_type'], 'personal'); + expect(captured.headers['Authorization'], 'Bearer test-token'); + expect(captured.headers['Content-Type'], 'application/json'); + expect(captured.headers['Accept'], 'application/json'); + expect(aspsps, hasLength(1)); + expect(aspsps.single.name, 'Test Bank'); + }); + + test( + 'startAuthorization posts scope, redirect_url and UTC valid_until', + () async { + late http.Request captured; + final api = _apiWith((request) async { + captured = request; + return _json({ + 'url': 'https://bank.example/consent', + 'authorization_id': 'auth-1', + 'psu_id_hash': 'hash', + }); + }); + + final validUntil = DateTime.utc(2026, 8, 1, 12); + final authorization = await api.startAuthorization( + aspspName: 'Test Bank', + aspspCountry: 'IT', + state: 'csrf-state', + validUntil: validUntil, + ); + + expect(captured.method, 'POST'); + expect(captured.url.path, '/auth'); + + final body = jsonDecode(captured.body) as Map; + expect(body['access'], { + 'valid_until': validUntil.toIso8601String(), + 'balances': true, + 'transactions': true, + }); + expect(body['aspsp'], {'name': 'Test Bank', 'country': 'IT'}); + expect(body['state'], 'csrf-state'); + expect(body['redirect_url'], kEbRedirectUri); + expect(body['psu_type'], 'personal'); + expect(body.containsKey('language'), isFalse); + expect(authorization.url, 'https://bank.example/consent'); + expect(authorization.authorizationId, 'auth-1'); + }, + ); + + test('createSession posts the code and parses the session', () async { + late http.Request captured; + final api = _apiWith((request) async { + captured = request; + return _json({ + 'session_id': 'sess-1', + 'accounts': [], + 'aspsp': {'name': 'Test Bank', 'country': 'IT'}, + 'access': {'valid_until': '2026-12-31T00:00:00.000Z'}, + }); + }); + + final session = await api.createSession('auth-code'); + + expect(captured.method, 'POST'); + expect(captured.url.path, '/sessions'); + expect(jsonDecode(captured.body), {'code': 'auth-code'}); + expect(session.sessionId, 'sess-1'); + expect(session.aspspName, 'Test Bank'); + }); + + test('getSession issues a GET to /sessions/{id}', () async { + late http.Request captured; + final api = _apiWith((request) async { + captured = request; + return _json({ + 'session_id': 'sess-1', + 'accounts': [], + 'aspsp': {'name': 'Test Bank', 'country': 'IT'}, + 'access': {'valid_until': '2026-12-31T00:00:00.000Z'}, + }); + }); + + final session = await api.getSession('sess-1'); + + expect(captured.method, 'GET'); + expect(captured.url.path, '/sessions/sess-1'); + expect(session.sessionId, 'sess-1'); + }); + + test('deleteSession issues a DELETE to /sessions/{id}', () async { + late http.Request captured; + final api = _apiWith((request) async { + captured = request; + return http.Response('', 204); + }); + + await api.deleteSession('sess-1'); + + expect(captured.method, 'DELETE'); + expect(captured.url.path, '/sessions/sess-1'); + }); + + test('getBalances parses the balances list', () async { + final api = _apiWith( + (request) async => _json({ + 'balances': [ + { + 'name': 'closingBooked', + 'balance_amount': {'amount': '100.00', 'currency': 'EUR'}, + 'balance_type': 'closingBooked', + }, + ], + }), + ); + + final balances = await api.getBalances('acc-uid'); + + expect(balances, hasLength(1)); + expect(balances.single.balanceAmount.amount, 100.0); + expect(balances.single.balanceAmount.currency, 'EUR'); + }); + + test('getTransactions formats dates and forwards continuation_key, ' + 'parses the envelope', () async { + late http.Request captured; + final api = _apiWith((request) async { + captured = request; + return _json({ + 'transactions': [ + { + 'status': 'BOOK', + 'transaction_amount': {'amount': '12.50', 'currency': 'EUR'}, + 'credit_debit_indicator': 'CRDT', + }, + ], + 'continuation_key': 'next-page', + }); + }); + + final page = await api.getTransactions( + 'acc-uid', + dateFrom: DateTime.utc(2026, 1, 5), + dateTo: DateTime.utc(2026, 1, 31), + continuationKey: 'prev-page', + transactionStatus: 'BOOK', + ); + + expect(captured.url.path, '/accounts/acc-uid/transactions'); + expect(captured.url.queryParameters['date_from'], '2026-01-05'); + expect(captured.url.queryParameters['date_to'], '2026-01-31'); + expect(captured.url.queryParameters['continuation_key'], 'prev-page'); + expect(captured.url.queryParameters['transaction_status'], 'BOOK'); + expect(page.continuationKey, 'next-page'); + expect(page.transactions, hasLength(1)); + expect(page.transactions.single.signedAmount, 12.5); + }); + + test( + 'throws EnableBankingException with the decoded error body on 4xx', + () async { + final api = _apiWith( + (request) async => _json({ + 'error': 'invalid_request', + 'message': 'bad country', + }, statusCode: 400), + ); + + await expectLater( + () => api.getAspsps(country: 'XX'), + throwsA( + isA() + .having((e) => e.statusCode, 'statusCode', 400) + .having((e) => e.error, 'error', 'invalid_request') + .having((e) => e.message, 'message', 'bad country'), + ), + ); + }, + ); + + test('flags a 401 response as unauthorized', () async { + final api = _apiWith((request) async => http.Response('', 401)); + + await expectLater( + () => api.getBalances('acc-uid'), + throwsA( + isA().having( + (e) => e.isUnauthorized, + 'isUnauthorized', + isTrue, + ), + ), + ); + }); + }); +} From 9386e3ab9b059d2628b012b858e4ff869a210b27 Mon Sep 17 00:00:00 2001 From: Alessandro Mariani Date: Sun, 30 Aug 2026 14:34:24 +0200 Subject: [PATCH 08/15] fix(android): grant release builds network access --- .github/workflows/ci-cd.yml | 7 +++++++ android/app/src/main/AndroidManifest.xml | 3 +++ .../android_release_manifest_test.dart | 19 +++++++++++++++++++ 3 files changed, 29 insertions(+) create mode 100644 test/services/banking/android_release_manifest_test.dart diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 5751b813..ccf2c101 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -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" diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index a4a9f638..19c86b08 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,9 @@ + + + diff --git a/test/services/banking/android_release_manifest_test.dart b/test/services/banking/android_release_manifest_test.dart new file mode 100644 index 00000000..b8572379 --- /dev/null +++ b/test/services/banking/android_release_manifest_test.dart @@ -0,0 +1,19 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('main Android manifest grants Internet access to release builds', () { + final manifest = File( + 'android/app/src/main/AndroidManifest.xml', + ).readAsStringSync(); + + expect( + manifest, + contains( + '', + ), + ); + }); +} From 7c2c6e1ad29c0fbb89396040e609f1c25102e3f4 Mon Sep 17 00:00:00 2001 From: Alessandro Mariani Date: Sun, 30 Aug 2026 14:34:32 +0200 Subject: [PATCH 09/15] fix(banking): align application and session API contracts --- lib/services/banking/enable_banking_api.dart | 46 +++++++- lib/services/banking/enable_banking_auth.dart | 14 ++- .../banking/enable_banking_config.dart | 32 ++++- .../banking/models/eb_application.dart | 55 +++++++++ lib/services/banking/models/eb_session.dart | 3 +- .../banking/models/eb_session_details.dart | 108 +++++++++++++++++ test/fixtures/eb_application.json | 10 ++ test/fixtures/eb_get_session.json | 22 ++++ test/services/banking/eb_models_test.dart | 58 +++++++++ .../banking/enable_banking_api_test.dart | 111 +++++++++++++++++- .../banking/enable_banking_auth_test.dart | 18 ++- .../banking/enable_banking_config_test.dart | 13 ++ 12 files changed, 468 insertions(+), 22 deletions(-) create mode 100644 lib/services/banking/models/eb_application.dart create mode 100644 lib/services/banking/models/eb_session_details.dart create mode 100644 test/fixtures/eb_application.json create mode 100644 test/fixtures/eb_get_session.json diff --git a/lib/services/banking/enable_banking_api.dart b/lib/services/banking/enable_banking_api.dart index d1f5904d..4134c7ba 100644 --- a/lib/services/banking/enable_banking_api.dart +++ b/lib/services/banking/enable_banking_api.dart @@ -7,9 +7,11 @@ import 'enable_banking_config.dart'; import 'enable_banking_credentials_store.dart'; import 'enable_banking_exception.dart'; import 'models/aspsp.dart'; +import 'models/eb_application.dart'; import 'models/eb_auth.dart'; import 'models/eb_balance.dart'; import 'models/eb_session.dart'; +import 'models/eb_session_details.dart'; import 'models/eb_transactions_page.dart'; const _kBaseUrl = 'https://api.enablebanking.com'; @@ -40,12 +42,15 @@ class EnableBankingApi { _store = store, _client = client ?? http.Client(); - Future> _headers() async => { - 'Authorization': 'Bearer ${await _auth.getValidToken(_store)}', + Map _headersForToken(String token) => { + 'Authorization': 'Bearer $token', 'Content-Type': 'application/json', 'Accept': 'application/json', }; + Future> _headers() async => + _headersForToken(await _auth.getValidToken(_store)); + void _check(http.Response response) { if (response.statusCode < 400) return; @@ -74,6 +79,13 @@ class EnableBankingApi { return jsonDecode(response.body) as Map; } + Future> _getWithToken(String path, String token) async { + final uri = Uri.parse('$_kBaseUrl$path'); + final response = await _client.get(uri, headers: _headersForToken(token)); + _check(response); + return jsonDecode(response.body) as Map; + } + Future> _post( String path, Map body, @@ -95,11 +107,11 @@ class EnableBankingApi { } Future> getAspsps({ - required String country, + String? country, String psuType = 'personal', }) async { final json = await _get('/aspsps', { - 'country': country, + 'country': ?country, 'psu_type': psuType, }); return ((json['aspsps'] as List?) ?? const []) @@ -107,6 +119,28 @@ class EnableBankingApi { .toList(); } + Future getApplication() async { + final json = await _get('/application'); + return EbApplication.fromJson(json); + } + + /// Verifies a candidate app-id/key pair before it is persisted. + Future verifyApplicationCredentials({ + required String appId, + required String privateKeyPem, + }) async { + final token = _auth.buildJwt(appId: appId, privateKeyPem: privateKeyPem); + final json = await _getWithToken('/application', token); + final application = EbApplication.fromJson(json); + if (application.kid != appId) { + throw const EnableBankingException( + message: 'The application returned a different key ID', + ); + } + _auth.clearCache(); + return application; + } + Future startAuthorization({ required String aspspName, required String aspspCountry, @@ -135,9 +169,9 @@ class EnableBankingApi { return EbSession.fromJson(json); } - Future getSession(String sessionId) async { + Future getSession(String sessionId) async { final json = await _get('/sessions/$sessionId'); - return EbSession.fromJson(json); + return EbSessionDetails.fromJson(json); } Future deleteSession(String sessionId) async { diff --git a/lib/services/banking/enable_banking_auth.dart b/lib/services/banking/enable_banking_auth.dart index 812ebc50..46f4144c 100644 --- a/lib/services/banking/enable_banking_auth.dart +++ b/lib/services/banking/enable_banking_auth.dart @@ -29,6 +29,11 @@ class EnableBankingAuth { String? _cachedToken; DateTime? _cachedExpiry; + void clearCache() { + _cachedToken = null; + _cachedExpiry = null; + } + /// [tokenTtl] and [refreshMargin] default to the production values; they /// (and [now]) are only overridden in tests to make cache expiry /// deterministic without real waiting. @@ -78,17 +83,16 @@ class EnableBankingAuth { return _cachedToken!; } - final config = await store.readConfig(); - final privateKeyPem = await store.readPrivateKey(); - if (config == null || privateKeyPem == null) { + final credentials = await store.readCredentials(); + if (credentials == null) { throw const EnableBankingAuthException( 'Enable Banking credentials are not configured', ); } final token = buildJwt( - appId: config.appId, - privateKeyPem: privateKeyPem, + appId: credentials.config.appId, + privateKeyPem: credentials.privateKeyPem, ttl: _tokenTtl, ); diff --git a/lib/services/banking/enable_banking_config.dart b/lib/services/banking/enable_banking_config.dart index 02a9d8b0..1c32500d 100644 --- a/lib/services/banking/enable_banking_config.dart +++ b/lib/services/banking/enable_banking_config.dart @@ -7,11 +7,23 @@ enum EnableBankingEnvironment { production, sandbox; + static EnableBankingEnvironment fromApi(String value) => + switch (value.toUpperCase()) { + 'PRODUCTION' => EnableBankingEnvironment.production, + 'SANDBOX' => EnableBankingEnvironment.sandbox, + _ => throw EnableBankingException( + message: 'Unknown Enable Banking environment: $value', + ), + }; + static EnableBankingEnvironment fromJson(String value) => - EnableBankingEnvironment.values.firstWhere( - (e) => e.name == value, - orElse: () => EnableBankingEnvironment.production, - ); + switch (value.toLowerCase()) { + 'production' => EnableBankingEnvironment.production, + 'sandbox' => EnableBankingEnvironment.sandbox, + _ => throw EnableBankingException( + message: 'Unknown stored Enable Banking environment: $value', + ), + }; String toJson() => name; } @@ -25,12 +37,16 @@ class EnableBankingConfig { final EnableBankingEnvironment environment; final String redirectUri; final String? defaultCountry; + final List supportedCountries; + final List redirectUrls; const EnableBankingConfig({ required this.appId, this.environment = EnableBankingEnvironment.production, this.redirectUri = kEbRedirectUri, this.defaultCountry, + this.supportedCountries = const [], + this.redirectUrls = const [], }); /// Enable Banking serves the same host for both production and sandbox; @@ -50,6 +66,12 @@ class EnableBankingConfig { ), redirectUri: json['redirect_uri'] as String? ?? kEbRedirectUri, defaultCountry: json['default_country'] as String?, + supportedCountries: ((json['supported_countries'] as List?) ?? const []) + .map((value) => value as String) + .toList(growable: false), + redirectUrls: ((json['redirect_urls'] as List?) ?? const []) + .map((value) => value as String) + .toList(growable: false), ); } catch (e) { throw EnableBankingException( @@ -63,5 +85,7 @@ class EnableBankingConfig { 'environment': environment.toJson(), 'redirect_uri': redirectUri, 'default_country': defaultCountry, + 'supported_countries': supportedCountries, + 'redirect_urls': redirectUrls, }; } diff --git a/lib/services/banking/models/eb_application.dart b/lib/services/banking/models/eb_application.dart new file mode 100644 index 00000000..f64003be --- /dev/null +++ b/lib/services/banking/models/eb_application.dart @@ -0,0 +1,55 @@ +import '../enable_banking_config.dart'; + +/// Application metadata returned by `GET /application`. +class EbApplication { + final String name; + final String? description; + final String kid; + final EnableBankingEnvironment environment; + final List redirectUrls; + final bool active; + final List countries; + final List services; + + const EbApplication({ + required this.name, + this.description, + required this.kid, + required this.environment, + this.redirectUrls = const [], + required this.active, + this.countries = const [], + this.services = const [], + }); + + static EbApplication fromJson(Map json) { + try { + return EbApplication( + name: json['name'] as String, + description: json['description'] as String?, + kid: json['kid'] as String, + environment: EnableBankingEnvironment.fromApi( + json['environment'] as String, + ), + redirectUrls: ((json['redirect_urls'] as List?) ?? const []) + .map((value) => value as String) + .toList(growable: false), + active: json['active'] as bool, + countries: + ((json['countries'] as List?) ?? const []) + .map((value) => (value as String).toUpperCase()) + .toSet() + .toList(growable: false) + ..sort(), + services: + ((json['services'] as List?) ?? const []) + .map((value) => (value as String).toUpperCase()) + .toSet() + .toList(growable: false) + ..sort(), + ); + } catch (error) { + throw FormatException('Malformed Enable Banking application: $error'); + } + } +} diff --git a/lib/services/banking/models/eb_session.dart b/lib/services/banking/models/eb_session.dart index 57b0ded0..981848f6 100644 --- a/lib/services/banking/models/eb_session.dart +++ b/lib/services/banking/models/eb_session.dart @@ -1,7 +1,6 @@ import 'eb_account.dart'; -/// An authorized session as returned by `POST /sessions` and -/// `GET /sessions/{id}`. +/// An authorized session as returned by `POST /sessions`. /// /// [aspspName]/[aspspCountry] come from the nested `aspsp` object and /// [validUntil] from `access.valid_until`. diff --git a/lib/services/banking/models/eb_session_details.dart b/lib/services/banking/models/eb_session_details.dart new file mode 100644 index 00000000..de9091b5 --- /dev/null +++ b/lib/services/banking/models/eb_session_details.dart @@ -0,0 +1,108 @@ +enum EbSessionStatus { + authorized, + cancelled, + closed, + expired, + invalid, + pendingAuthorization, + returnedFromBank, + revoked; + + static EbSessionStatus fromApi(String value) => switch (value) { + 'AUTHORIZED' => EbSessionStatus.authorized, + 'CANCELLED' => EbSessionStatus.cancelled, + 'CLOSED' => EbSessionStatus.closed, + 'EXPIRED' => EbSessionStatus.expired, + 'INVALID' => EbSessionStatus.invalid, + 'PENDING_AUTHORIZATION' => EbSessionStatus.pendingAuthorization, + 'RETURNED_FROM_BANK' => EbSessionStatus.returnedFromBank, + 'REVOKED' => EbSessionStatus.revoked, + _ => throw FormatException('Unknown session status: $value'), + }; +} + +class EbSessionAccount { + final String uid; + final String identificationHash; + final List identificationHashes; + + const EbSessionAccount({ + required this.uid, + required this.identificationHash, + this.identificationHashes = const [], + }); + + static EbSessionAccount fromJson(Map json) => + EbSessionAccount( + uid: json['uid'] as String, + identificationHash: json['identification_hash'] as String, + identificationHashes: + ((json['identification_hashes'] as List?) ?? const []) + .map((value) => value as String) + .toList(growable: false), + ); +} + +/// Session state returned by `GET /sessions/{session_id}`. +/// +/// This response intentionally has its own model: unlike `POST /sessions`, +/// `accounts` contains UIDs and the response does not contain `session_id`. +class EbSessionDetails { + final EbSessionStatus status; + final List accountUids; + final List accountsData; + final String aspspName; + final String aspspCountry; + final String psuType; + final String? psuIdHash; + final DateTime validUntil; + final DateTime created; + final DateTime? authorized; + final DateTime? closed; + + const EbSessionDetails({ + required this.status, + this.accountUids = const [], + this.accountsData = const [], + required this.aspspName, + required this.aspspCountry, + required this.psuType, + this.psuIdHash, + required this.validUntil, + required this.created, + this.authorized, + this.closed, + }); + + static EbSessionDetails fromJson(Map json) { + try { + final aspsp = json['aspsp'] as Map; + final access = json['access'] as Map; + return EbSessionDetails( + status: EbSessionStatus.fromApi(json['status'] as String), + accountUids: ((json['accounts'] as List?) ?? const []) + .map((value) => value as String) + .toList(growable: false), + accountsData: ((json['accounts_data'] as List?) ?? const []) + .map( + (value) => + EbSessionAccount.fromJson(value as Map), + ) + .toList(growable: false), + aspspName: aspsp['name'] as String, + aspspCountry: aspsp['country'] as String, + psuType: json['psu_type'] as String, + psuIdHash: json['psu_id_hash'] as String?, + validUntil: DateTime.parse(access['valid_until'] as String), + created: DateTime.parse(json['created'] as String), + authorized: _optionalDate(json['authorized']), + closed: _optionalDate(json['closed']), + ); + } catch (error) { + throw FormatException('Malformed Enable Banking session: $error'); + } + } + + static DateTime? _optionalDate(Object? value) => + value == null ? null : DateTime.parse(value as String); +} diff --git a/test/fixtures/eb_application.json b/test/fixtures/eb_application.json new file mode 100644 index 00000000..5148e4fd --- /dev/null +++ b/test/fixtures/eb_application.json @@ -0,0 +1,10 @@ +{ + "name": "Sossoldi test", + "description": "Test application", + "kid": "app-123", + "environment": "PRODUCTION", + "redirect_urls": ["sossoldi://eb-callback"], + "active": true, + "countries": ["IT", "GB", "it"], + "services": ["AIS"] +} diff --git a/test/fixtures/eb_get_session.json b/test/fixtures/eb_get_session.json new file mode 100644 index 00000000..892bc615 --- /dev/null +++ b/test/fixtures/eb_get_session.json @@ -0,0 +1,22 @@ +{ + "access": { + "valid_until": "2026-12-01T12:00:00.000Z" + }, + "accounts": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "accounts_data": [ + { + "uid": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "identification_hash": "primary-hash", + "identification_hashes": ["primary-hash", "alternate-hash"] + } + ], + "aspsp": { + "country": "FI", + "name": "Nordea" + }, + "authorized": "2026-08-01T12:00:00.000Z", + "created": "2026-08-01T11:55:00.000Z", + "psu_id_hash": "psu-hash", + "psu_type": "business", + "status": "AUTHORIZED" +} diff --git a/test/services/banking/eb_models_test.dart b/test/services/banking/eb_models_test.dart index 4e2b13e2..5d2c25b7 100644 --- a/test/services/banking/eb_models_test.dart +++ b/test/services/banking/eb_models_test.dart @@ -4,9 +4,11 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:sossoldi/services/banking/models/aspsp.dart'; +import 'package:sossoldi/services/banking/models/eb_application.dart'; import 'package:sossoldi/services/banking/models/eb_account.dart'; import 'package:sossoldi/services/banking/models/eb_balance.dart'; import 'package:sossoldi/services/banking/models/eb_session.dart'; +import 'package:sossoldi/services/banking/models/eb_session_details.dart'; import 'package:sossoldi/services/banking/models/eb_transactions_page.dart'; Map _loadJson(String name) => @@ -14,6 +16,33 @@ Map _loadJson(String name) => as Map; void main() { + group('EbApplication.fromJson', () { + test('parses server-derived environment and available countries', () { + final application = EbApplication.fromJson( + _loadJson('eb_application.json'), + ); + + expect(application.kid, 'app-123'); + expect(application.environment.name, 'production'); + expect(application.active, isTrue); + expect(application.countries, ['GB', 'IT']); + expect(application.redirectUrls, ['sossoldi://eb-callback']); + expect(application.services, ['AIS']); + }); + + test('rejects an unknown server environment', () { + expect( + () => EbApplication.fromJson({ + 'name': 'Broken', + 'kid': 'app-1', + 'environment': 'STAGING', + 'active': true, + }), + throwsFormatException, + ); + }); + }); + group('Aspsp.fromJson', () { test('parses fields including psu_types and nested sandbox', () { final aspsp = Aspsp.fromJson(_loadJson('eb_aspsp.json')); @@ -122,4 +151,33 @@ void main() { expect(session.accounts.first.iban, 'IT60X0542811101000000123456'); }); }); + + group('EbSessionDetails.fromJson', () { + test('parses the GET session response without a session_id', () { + final session = EbSessionDetails.fromJson( + _loadJson('eb_get_session.json'), + ); + + expect(session.status, EbSessionStatus.authorized); + expect(session.accountUids, ['497f6eca-6276-4993-bfeb-53cbbbba6f08']); + expect(session.accountsData.single.identificationHash, 'primary-hash'); + expect(session.accountsData.single.identificationHashes, [ + 'primary-hash', + 'alternate-hash', + ]); + expect(session.aspspName, 'Nordea'); + expect(session.aspspCountry, 'FI'); + expect(session.psuType, 'business'); + expect(session.psuIdHash, 'psu-hash'); + expect(session.created, DateTime.parse('2026-08-01T11:55:00.000Z')); + expect(session.closed, isNull); + }); + + test('rejects an unknown session status', () { + final json = _loadJson('eb_get_session.json'); + json['status'] = 'UNKNOWN'; + + expect(() => EbSessionDetails.fromJson(json), throwsFormatException); + }); + }); } diff --git a/test/services/banking/enable_banking_api_test.dart b/test/services/banking/enable_banking_api_test.dart index dce8ddeb..0e5cfe52 100644 --- a/test/services/banking/enable_banking_api_test.dart +++ b/test/services/banking/enable_banking_api_test.dart @@ -12,6 +12,13 @@ import 'package:sossoldi/services/banking/enable_banking_exception.dart'; /// Bypasses credential/JWT signing entirely: the API client only needs a /// valid bearer token string, not a real signature. class _FakeAuth extends EnableBankingAuth { + @override + String buildJwt({ + required String appId, + required String privateKeyPem, + Duration ttl = const Duration(hours: 1), + }) => 'candidate-token'; + @override Future getValidToken(EnableBankingCredentialsStore store) async => 'test-token'; @@ -55,6 +62,94 @@ void main() { expect(aspsps.single.name, 'Test Bank'); }); + test( + 'getAspsps can fetch all countries without a country filter', + () async { + late http.Request captured; + final api = _apiWith((request) async { + captured = request; + return _json({'aspsps': []}); + }); + + await api.getAspsps(); + + expect(captured.url.queryParameters.containsKey('country'), isFalse); + expect(captured.url.queryParameters['psu_type'], 'personal'); + }, + ); + + test( + 'getApplication parses environment, redirects and countries', + () async { + final api = _apiWith( + (request) async => _json({ + 'name': 'Sossoldi', + 'kid': 'app-123', + 'environment': 'PRODUCTION', + 'redirect_urls': [kEbRedirectUri], + 'active': true, + 'countries': ['IT', 'GB'], + 'services': ['AIS'], + }), + ); + + final application = await api.getApplication(); + + expect(application.kid, 'app-123'); + expect(application.environment, EnableBankingEnvironment.production); + expect(application.countries, ['GB', 'IT']); + }, + ); + + test( + 'verifyApplicationCredentials uses the candidate JWT before save', + () async { + late http.Request captured; + final api = _apiWith((request) async { + captured = request; + return _json({ + 'name': 'Sossoldi', + 'kid': 'app-123', + 'environment': 'PRODUCTION', + 'redirect_urls': [kEbRedirectUri], + 'active': true, + 'countries': ['IT'], + 'services': ['AIS'], + }); + }); + + await api.verifyApplicationCredentials( + appId: 'app-123', + privateKeyPem: 'candidate-private-key', + ); + + expect(captured.url.path, '/application'); + expect(captured.headers['Authorization'], 'Bearer candidate-token'); + }, + ); + + test('verifyApplicationCredentials rejects a different returned kid', () { + final api = _apiWith( + (request) async => _json({ + 'name': 'Other', + 'kid': 'other-app', + 'environment': 'PRODUCTION', + 'redirect_urls': [kEbRedirectUri], + 'active': true, + 'countries': ['IT'], + 'services': ['AIS'], + }), + ); + + expect( + () => api.verifyApplicationCredentials( + appId: 'app-123', + privateKeyPem: 'candidate-private-key', + ), + throwsA(isA()), + ); + }); + test( 'startAuthorization posts scope, redirect_url and UTC valid_until', () async { @@ -121,10 +216,19 @@ void main() { final api = _apiWith((request) async { captured = request; return _json({ - 'session_id': 'sess-1', - 'accounts': [], + 'status': 'AUTHORIZED', + 'accounts': ['account-uid'], + 'accounts_data': [ + { + 'uid': 'account-uid', + 'identification_hash': 'account-hash', + 'identification_hashes': ['account-hash'], + }, + ], 'aspsp': {'name': 'Test Bank', 'country': 'IT'}, 'access': {'valid_until': '2026-12-31T00:00:00.000Z'}, + 'created': '2026-08-01T00:00:00.000Z', + 'psu_type': 'personal', }); }); @@ -132,7 +236,8 @@ void main() { expect(captured.method, 'GET'); expect(captured.url.path, '/sessions/sess-1'); - expect(session.sessionId, 'sess-1'); + expect(session.accountUids, ['account-uid']); + expect(session.accountsData.single.identificationHash, 'account-hash'); }); test('deleteSession issues a DELETE to /sessions/{id}', () async { diff --git a/test/services/banking/enable_banking_auth_test.dart b/test/services/banking/enable_banking_auth_test.dart index 01a4cc63..02aad00d 100644 --- a/test/services/banking/enable_banking_auth_test.dart +++ b/test/services/banking/enable_banking_auth_test.dart @@ -55,9 +55,9 @@ class _CountingCredentialsStore extends EnableBankingCredentialsStore { int readConfigCalls = 0; @override - Future readConfig() async { + Future readCredentials() async { readConfigCalls++; - return super.readConfig(); + return super.readCredentials(); } } @@ -159,5 +159,19 @@ void main() { expect(store.readConfigCalls, 2); }); + + test( + 'clearCache forces credentials to be read again after rotation', + () async { + final store = await storeWithCredentials(); + final auth = EnableBankingAuth(); + + await auth.getValidToken(store); + auth.clearCache(); + await auth.getValidToken(store); + + expect(store.readConfigCalls, 2); + }, + ); }); } diff --git a/test/services/banking/enable_banking_config_test.dart b/test/services/banking/enable_banking_config_test.dart index fef9fbb2..d3c06b99 100644 --- a/test/services/banking/enable_banking_config_test.dart +++ b/test/services/banking/enable_banking_config_test.dart @@ -34,6 +34,19 @@ void main() { ); }); + test( + 'rejects an unknown stored environment instead of assuming production', + () { + expect( + () => EnableBankingConfig.fromJson({ + 'app_id': 'app-1', + 'environment': 'staging', + }), + throwsA(isA()), + ); + }, + ); + test('throws EnableBankingException instead of a raw TypeError when ' 'app_id is the wrong type (corrupted/incompatible stored blob)', () { expect( From bbda8fd082a2b0f39b0e5e067554313aecf5d7c7 Mon Sep 17 00:00:00 2001 From: Alessandro Mariani Date: Sun, 30 Aug 2026 14:34:37 +0200 Subject: [PATCH 10/15] fix(banking): verify and atomically store credentials --- lib/providers/banking_provider.dart | 35 ++- .../enable_banking_credentials_service.dart | 79 +++++++ .../enable_banking_credentials_store.dart | 132 +++++++++--- ...able_banking_credentials_service_test.dart | 202 ++++++++++++++++++ ...enable_banking_credentials_store_test.dart | 38 ++++ 5 files changed, 449 insertions(+), 37 deletions(-) create mode 100644 lib/services/banking/enable_banking_credentials_service.dart create mode 100644 test/services/banking/enable_banking_credentials_service_test.dart diff --git a/lib/providers/banking_provider.dart b/lib/providers/banking_provider.dart index 4305dcca..4391049c 100644 --- a/lib/providers/banking_provider.dart +++ b/lib/providers/banking_provider.dart @@ -1,6 +1,9 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; +import '../services/banking/enable_banking_api.dart'; +import '../services/banking/enable_banking_auth.dart'; import '../services/banking/enable_banking_config.dart'; +import '../services/banking/enable_banking_credentials_service.dart'; import '../services/banking/enable_banking_credentials_store.dart'; part 'banking_provider.g.dart'; @@ -9,6 +12,22 @@ part 'banking_provider.g.dart'; EnableBankingCredentialsStore enableBankingCredentialsStore(Ref ref) => const EnableBankingCredentialsStore(); +@Riverpod(keepAlive: true) +EnableBankingAuth enableBankingAuth(Ref ref) => EnableBankingAuth(); + +@Riverpod(keepAlive: true) +EnableBankingApi enableBankingApi(Ref ref) => EnableBankingApi( + auth: ref.watch(enableBankingAuthProvider), + store: ref.watch(enableBankingCredentialsStoreProvider), +); + +@Riverpod(keepAlive: true) +EnableBankingCredentialsService enableBankingCredentialsService(Ref ref) => + EnableBankingCredentialsService( + api: ref.watch(enableBankingApiProvider), + store: ref.watch(enableBankingCredentialsStoreProvider), + ); + @Riverpod(keepAlive: true) class EnableBankingSettings extends _$EnableBankingSettings { @override @@ -29,13 +48,15 @@ class EnableBankingSettings extends _$EnableBankingSettings { }) async { state = const AsyncLoading(); state = await AsyncValue.guard(() async { - final store = ref.read(enableBankingCredentialsStoreProvider); - await store.saveCredentials( - appId: appId, - privateKeyPem: privateKeyPem, - config: config, - ); - return store.readConfig(); + final saved = await ref + .read(enableBankingCredentialsServiceProvider) + .saveVerifiedCredentials( + appId: appId, + privateKeyPem: privateKeyPem, + redirectUri: config.redirectUri, + defaultCountry: config.defaultCountry, + ); + return saved; }); } diff --git a/lib/services/banking/enable_banking_credentials_service.dart b/lib/services/banking/enable_banking_credentials_service.dart new file mode 100644 index 00000000..6be6a883 --- /dev/null +++ b/lib/services/banking/enable_banking_credentials_service.dart @@ -0,0 +1,79 @@ +import 'package:flutter/foundation.dart'; + +import 'enable_banking_api.dart'; +import 'enable_banking_config.dart'; +import 'enable_banking_credentials_store.dart'; +import 'enable_banking_exception.dart'; + +/// Validates a candidate credential pair remotely before replacing the +/// previously working credential set. +class EnableBankingCredentialsService { + final EnableBankingApi _api; + final EnableBankingCredentialsStore _store; + final bool _allowSandbox; + + const EnableBankingCredentialsService({ + required EnableBankingApi api, + required EnableBankingCredentialsStore store, + bool allowSandbox = !kReleaseMode, + }) : _api = api, + _store = store, + _allowSandbox = allowSandbox; + + Future saveVerifiedCredentials({ + required String appId, + required String privateKeyPem, + String redirectUri = kEbRedirectUri, + String? defaultCountry, + }) async { + final application = await _api.verifyApplicationCredentials( + appId: appId, + privateKeyPem: privateKeyPem, + ); + + if (!application.active) { + throw const EnableBankingException( + message: 'The Enable Banking application is not active', + ); + } + if (!application.services.contains('AIS')) { + throw const EnableBankingException( + message: 'The Enable Banking application does not provide AIS', + ); + } + if (!_allowSandbox && + application.environment == EnableBankingEnvironment.sandbox) { + throw const EnableBankingException( + message: 'Sandbox credentials are not allowed in release builds', + ); + } + if (!application.redirectUrls.contains(redirectUri)) { + throw const EnableBankingException( + message: 'Redirect URI is not registered for this application', + ); + } + + final normalizedDefaultCountry = defaultCountry?.toUpperCase(); + if (normalizedDefaultCountry != null && + !application.countries.contains(normalizedDefaultCountry)) { + throw const EnableBankingException( + message: 'Default country is not enabled for this application', + ); + } + + final config = EnableBankingConfig( + appId: appId, + environment: application.environment, + redirectUri: redirectUri, + defaultCountry: normalizedDefaultCountry, + supportedCountries: application.countries, + redirectUrls: application.redirectUrls, + ); + await _store.saveCredentials( + appId: appId, + privateKeyPem: privateKeyPem, + config: config, + ); + return config; + } +} diff --git a/lib/services/banking/enable_banking_credentials_store.dart b/lib/services/banking/enable_banking_credentials_store.dart index 6a9809ce..b8429981 100644 --- a/lib/services/banking/enable_banking_credentials_store.dart +++ b/lib/services/banking/enable_banking_credentials_store.dart @@ -3,14 +3,27 @@ import 'dart:convert'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'enable_banking_config.dart'; +import 'enable_banking_exception.dart'; -const _kAppIdKey = 'eb_app_id'; -const _kPrivateKeyPemKey = 'eb_private_key_pem'; -const _kConfigJsonKey = 'eb_config_json'; +const _kCredentialSetKey = 'eb_credentials_v1'; +const _kLegacyAppIdKey = 'eb_app_id'; +const _kLegacyPrivateKeyPemKey = 'eb_private_key_pem'; +const _kLegacyConfigJsonKey = 'eb_config_json'; -/// Encrypted persistence for the user's BYOC Enable Banking credentials -/// (Keychain on iOS/macOS, Keystore-backed EncryptedSharedPreferences on -/// Android). The private key is never logged nor exposed beyond this store. +class EnableBankingCredentials { + final EnableBankingConfig config; + final String privateKeyPem; + + const EnableBankingCredentials({ + required this.config, + required this.privateKeyPem, + }); +} + +/// Encrypted persistence for a verified BYOC credential set. +/// +/// Configuration and private key are stored in one versioned value so readers +/// cannot observe a torn app-id/key pair after an interrupted write. class EnableBankingCredentialsStore { final FlutterSecureStorage _storage; @@ -29,37 +42,96 @@ class EnableBankingCredentialsStore { required String privateKeyPem, required EnableBankingConfig config, }) async { - await _storage.write(key: _kAppIdKey, value: appId); - await _storage.write(key: _kPrivateKeyPemKey, value: privateKeyPem); - await _storage.write( - key: _kConfigJsonKey, - value: jsonEncode(config.toJson()), - ); + if (config.appId != appId) { + throw const EnableBankingException( + message: 'Credential app ID does not match its configuration', + ); + } + final encoded = jsonEncode({ + 'version': 1, + 'config': config.toJson(), + 'private_key_pem': privateKeyPem, + }); + await _storage.write(key: _kCredentialSetKey, value: encoded); + await _clearLegacyKeys(); } - Future readConfig() async { - final appId = await _storage.read(key: _kAppIdKey); - if (appId == null) { - return null; - } - final configJson = await _storage.read(key: _kConfigJsonKey); - if (configJson == null) { - return EnableBankingConfig(appId: appId); + Future readCredentials() async { + final encoded = await _storage.read(key: _kCredentialSetKey); + if (encoded == null) return _readLegacyCredentials(); + + try { + final json = jsonDecode(encoded) as Map; + if (json['version'] != 1) { + throw const FormatException('unsupported credential version'); + } + final config = EnableBankingConfig.fromJson( + json['config'] as Map, + ); + final privateKeyPem = json['private_key_pem'] as String; + if (privateKeyPem.trim().isEmpty) { + throw const FormatException('empty private key'); + } + return EnableBankingCredentials( + config: config, + privateKeyPem: privateKeyPem, + ); + } catch (error) { + throw EnableBankingException( + message: 'Malformed Enable Banking credential set: $error', + ); } - return EnableBankingConfig.fromJson( - jsonDecode(configJson) as Map, - ); } - Future readPrivateKey() => _storage.read(key: _kPrivateKeyPemKey); + Future readConfig() async => + (await readCredentials())?.config; - Future hasCredentials() async => - await _storage.containsKey(key: _kAppIdKey) && - await _storage.containsKey(key: _kPrivateKeyPemKey); + Future readPrivateKey() async => + (await readCredentials())?.privateKeyPem; + + Future hasCredentials() async { + try { + return await readCredentials() != null; + } on EnableBankingException { + return false; + } + } Future clear() async { - await _storage.delete(key: _kAppIdKey); - await _storage.delete(key: _kPrivateKeyPemKey); - await _storage.delete(key: _kConfigJsonKey); + await _storage.delete(key: _kCredentialSetKey); + await _clearLegacyKeys(); + } + + Future _readLegacyCredentials() async { + final appId = await _storage.read(key: _kLegacyAppIdKey); + final privateKeyPem = await _storage.read(key: _kLegacyPrivateKeyPemKey); + if (appId == null && privateKeyPem == null) return null; + if (appId == null || privateKeyPem == null) { + throw const EnableBankingException( + message: 'Incomplete legacy Enable Banking credentials', + ); + } + + final configJson = await _storage.read(key: _kLegacyConfigJsonKey); + final config = configJson == null + ? EnableBankingConfig(appId: appId) + : EnableBankingConfig.fromJson( + jsonDecode(configJson) as Map, + ); + if (config.appId != appId) { + throw const EnableBankingException( + message: 'Legacy credential app IDs do not match', + ); + } + return EnableBankingCredentials( + config: config, + privateKeyPem: privateKeyPem, + ); + } + + Future _clearLegacyKeys() async { + await _storage.delete(key: _kLegacyAppIdKey); + await _storage.delete(key: _kLegacyPrivateKeyPemKey); + await _storage.delete(key: _kLegacyConfigJsonKey); } } diff --git a/test/services/banking/enable_banking_credentials_service_test.dart b/test/services/banking/enable_banking_credentials_service_test.dart new file mode 100644 index 00000000..7a4c0af9 --- /dev/null +++ b/test/services/banking/enable_banking_credentials_service_test.dart @@ -0,0 +1,202 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sossoldi/services/banking/enable_banking_api.dart'; +import 'package:sossoldi/services/banking/enable_banking_auth.dart'; +import 'package:sossoldi/services/banking/enable_banking_config.dart'; +import 'package:sossoldi/services/banking/enable_banking_credentials_service.dart'; +import 'package:sossoldi/services/banking/enable_banking_credentials_store.dart'; +import 'package:sossoldi/services/banking/enable_banking_exception.dart'; +import 'package:sossoldi/services/banking/models/eb_application.dart'; + +class _FakeApplicationApi extends EnableBankingApi { + _FakeApplicationApi(this.application, {this.failure}) + : super( + auth: EnableBankingAuth(), + store: const EnableBankingCredentialsStore(), + ); + + final EbApplication application; + final Object? failure; + int calls = 0; + + @override + Future verifyApplicationCredentials({ + required String appId, + required String privateKeyPem, + }) async { + calls++; + if (failure case final failure?) throw failure; + return application; + } +} + +const _productionApplication = EbApplication( + name: 'Sossoldi', + kid: 'app-123', + environment: EnableBankingEnvironment.production, + redirectUrls: [kEbRedirectUri], + active: true, + countries: ['GB', 'IT'], + services: ['AIS'], +); + +void main() { + setUp(() { + FlutterSecureStorage.setMockInitialValues({}); + }); + + test( + 'persists server-derived environment and countries after verification', + () async { + final store = const EnableBankingCredentialsStore(); + final api = _FakeApplicationApi(_productionApplication); + final service = EnableBankingCredentialsService( + api: api, + store: store, + allowSandbox: false, + ); + + final config = await service.saveVerifiedCredentials( + appId: 'app-123', + privateKeyPem: 'private-key', + defaultCountry: 'it', + ); + + expect(api.calls, 1); + expect(config.environment, EnableBankingEnvironment.production); + expect(config.defaultCountry, 'IT'); + expect(config.supportedCountries, ['GB', 'IT']); + expect(config.redirectUrls, [kEbRedirectUri]); + expect((await store.readCredentials())?.privateKeyPem, 'private-key'); + }, + ); + + test( + 'does not replace working credentials when remote verification fails', + () async { + final store = const EnableBankingCredentialsStore(); + await store.saveCredentials( + appId: 'old-app', + privateKeyPem: 'old-private-key', + config: const EnableBankingConfig(appId: 'old-app'), + ); + final service = EnableBankingCredentialsService( + api: _FakeApplicationApi( + _productionApplication, + failure: const EnableBankingException(message: 'invalid credentials'), + ), + store: store, + allowSandbox: false, + ); + + await expectLater( + () => service.saveVerifiedCredentials( + appId: 'app-123', + privateKeyPem: 'bad-private-key', + ), + throwsA(isA()), + ); + + final credentials = await store.readCredentials(); + expect(credentials?.config.appId, 'old-app'); + expect(credentials?.privateKeyPem, 'old-private-key'); + }, + ); + + test('rejects sandbox credentials when sandbox is disabled', () async { + const sandbox = EbApplication( + name: 'Sandbox', + kid: 'app-123', + environment: EnableBankingEnvironment.sandbox, + redirectUrls: [kEbRedirectUri], + active: true, + countries: ['IT'], + services: ['AIS'], + ); + final service = EnableBankingCredentialsService( + api: _FakeApplicationApi(sandbox), + store: const EnableBankingCredentialsStore(), + allowSandbox: false, + ); + + await expectLater( + () => service.saveVerifiedCredentials( + appId: 'app-123', + privateKeyPem: 'private-key', + ), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('Sandbox'), + ), + ), + ); + }); + + test('rejects inactive or non-AIS applications', () async { + const inactive = EbApplication( + name: 'Inactive', + kid: 'app-123', + environment: EnableBankingEnvironment.production, + redirectUrls: [kEbRedirectUri], + active: false, + countries: ['IT'], + services: ['AIS'], + ); + const paymentOnly = EbApplication( + name: 'PIS only', + kid: 'app-123', + environment: EnableBankingEnvironment.production, + redirectUrls: [kEbRedirectUri], + active: true, + countries: ['IT'], + services: ['PIS'], + ); + + for (final application in [inactive, paymentOnly]) { + final service = EnableBankingCredentialsService( + api: _FakeApplicationApi(application), + store: const EnableBankingCredentialsStore(), + ); + await expectLater( + () => service.saveVerifiedCredentials( + appId: 'app-123', + privateKeyPem: 'private-key', + ), + throwsA(isA()), + ); + } + }); + + test( + 'rejects an unregistered redirect or unavailable default country', + () async { + final redirectService = EnableBankingCredentialsService( + api: _FakeApplicationApi(_productionApplication), + store: const EnableBankingCredentialsStore(), + ); + await expectLater( + () => redirectService.saveVerifiedCredentials( + appId: 'app-123', + privateKeyPem: 'private-key', + redirectUri: 'https://unregistered.example/callback', + ), + throwsA(isA()), + ); + + final countryService = EnableBankingCredentialsService( + api: _FakeApplicationApi(_productionApplication), + store: const EnableBankingCredentialsStore(), + ); + await expectLater( + () => countryService.saveVerifiedCredentials( + appId: 'app-123', + privateKeyPem: 'private-key', + defaultCountry: 'FR', + ), + throwsA(isA()), + ); + }, + ); +} diff --git a/test/services/banking/enable_banking_credentials_store_test.dart b/test/services/banking/enable_banking_credentials_store_test.dart index 76062ef7..4bd0731f 100644 --- a/test/services/banking/enable_banking_credentials_store_test.dart +++ b/test/services/banking/enable_banking_credentials_store_test.dart @@ -42,10 +42,48 @@ void main() { expect(readConfig!.appId, 'app-123'); expect(readConfig.environment, EnableBankingEnvironment.sandbox); expect(readConfig.defaultCountry, 'IT'); + expect(readConfig.supportedCountries, isEmpty); expect(readConfig.redirectUri, kEbRedirectUri); expect(readConfig.baseUrl, 'https://api.enablebanking.com'); }); + test('rejects mismatched app IDs before changing storage', () async { + final store = const EnableBankingCredentialsStore(); + + await expectLater( + () => store.saveCredentials( + appId: 'app-123', + privateKeyPem: 'pem', + config: const EnableBankingConfig(appId: 'different-app'), + ), + throwsA(isA()), + ); + + expect(await store.hasCredentials(), isFalse); + }); + + test('treats a partial legacy credential pair as invalid', () async { + FlutterSecureStorage.setMockInitialValues({'eb_app_id': 'app-123'}); + final store = const EnableBankingCredentialsStore(); + + expect(await store.hasCredentials(), isFalse); + await expectLater(store.readCredentials, throwsA(isA())); + }); + + test('reads a complete legacy pair without mixing app IDs', () async { + FlutterSecureStorage.setMockInitialValues({ + 'eb_app_id': 'legacy-app', + 'eb_private_key_pem': 'legacy-key', + 'eb_config_json': '{"app_id":"legacy-app"}', + }); + final store = const EnableBankingCredentialsStore(); + + final credentials = await store.readCredentials(); + + expect(credentials?.config.appId, 'legacy-app'); + expect(credentials?.privateKeyPem, 'legacy-key'); + }); + test('clear removes appId, private key and config', () async { final store = const EnableBankingCredentialsStore(); await store.saveCredentials( From e22fd56c3c2f4e92f55a98d064a4648251fe3f40 Mon Sep 17 00:00:00 2001 From: Alessandro Mariani Date: Sun, 30 Aug 2026 14:47:31 +0200 Subject: [PATCH 11/15] fix(ci): resolve dependencies with project Flutter version --- pubspec.lock | 94 ++++++++++++++++++++++++++-------------------------- 1 file changed, 47 insertions(+), 47 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 89c86d60..fc7054b5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,10 +5,10 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: "5b7468c326d2f8a4f630056404ca0d291ade42918f4a3c6233618e724f39da8e" + sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d url: "https://pub.dev" source: hosted - version: "92.0.0" + version: "91.0.0" adaptive_number: dependency: transitive description: @@ -21,34 +21,34 @@ packages: dependency: transitive description: name: analysis_server_plugin - sha256: "44adba4d74a2541173bad4c11531d2a4d22810c29c5ddb458a38e9f4d0e5eac7" + sha256: "26844e7f977087567135d62532b67d5639fe206c5194c3f410ba75e1a04a2747" url: "https://pub.dev" source: hosted - version: "0.3.4" + version: "0.3.3" analyzer: dependency: transitive description: name: analyzer - sha256: "70e4b1ef8003c64793a9e268a551a82869a8a96f39deb73dea28084b0e8bf75e" + sha256: a40a0cee526a7e1f387c6847bd8a5ccbf510a75952ef8a28338e989558072cb0 url: "https://pub.dev" source: hosted - version: "9.0.0" + version: "8.4.0" analyzer_buffer: dependency: transitive description: name: analyzer_buffer - sha256: ff4bd291778c7417fe53fe24ee0d0a1f1ffe281a2d4ea887e7094f16e36eace7 + sha256: aba2f75e63b3135fd1efaa8b6abefe1aa6e41b6bd9806221620fa48f98156033 url: "https://pub.dev" source: hosted - version: "0.3.0" + version: "0.1.11" analyzer_plugin: dependency: "direct main" description: name: analyzer_plugin - sha256: "6645a029da947ffd823d98118f385d4bd26b54eb069c006b22e0b94e451814b5" + sha256: "08cfefa90b4f4dd3b447bda831cecf644029f9f8e22820f6ee310213ebe2dd53" url: "https://pub.dev" source: hosted - version: "0.13.11" + version: "0.13.10" ansicolor: dependency: transitive description: @@ -149,10 +149,10 @@ packages: dependency: "direct dev" description: name: build_runner - sha256: "5367e521935b102bdf1e735d2aab461e36b2edca6517662d088dd04cc39f8d16" + sha256: fae5c4fc1a73b7dee94b6c54e86101e5030a8e42112e61203f464495011a20a1 url: "https://pub.dev" source: hosted - version: "2.15.1" + version: "2.12.1" built_collection: dependency: transitive description: @@ -173,10 +173,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" checked_yaml: dependency: transitive description: @@ -482,10 +482,10 @@ packages: dependency: "direct main" description: name: flutter_riverpod - sha256: "4e166be88e1dbbaa34a280bdb744aeae73b7ef25fdf8db7a3bb776760a3648e2" + sha256: "38ec6c303e2c83ee84512f5fc2a82ae311531021938e63d7137eccc107bf3c02" url: "https://pub.dev" source: hosted - version: "3.3.1" + version: "3.1.0" flutter_secure_storage: dependency: "direct main" description: @@ -556,10 +556,10 @@ packages: dependency: "direct dev" description: name: freezed - sha256: f23ea33b3863f119b58ed1b586e881a46bd28715ddcc4dbc33104524e3434131 + sha256: "13065f10e135263a4f5a4391b79a8efc5fb8106f8dd555a9e49b750b45393d77" url: "https://pub.dev" source: hosted - version: "3.2.5" + version: "3.2.3" freezed_annotation: dependency: transitive description: @@ -700,18 +700,18 @@ packages: dependency: transitive description: name: json_annotation - sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" url: "https://pub.dev" source: hosted - version: "4.11.0" + version: "4.9.0" json_serializable: dependency: "direct dev" description: name: json_serializable - sha256: "44729f5c45748e6748f6b9a57ab8f7e4336edc8ae41fc295070e3814e616a6c0" + sha256: c5b2ee75210a0f263c6c7b9eeea80553dbae96ea1bf57f02484e806a3ffdffa3 url: "https://pub.dev" source: hosted - version: "6.13.0" + version: "6.11.2" leak_tracker: dependency: transitive description: @@ -796,18 +796,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" meta: dependency: transitive description: @@ -1068,42 +1068,42 @@ packages: dependency: transitive description: name: riverpod - sha256: "8c22216be8ad3ef2b44af3a329693558c98eca7b8bd4ef495c92db0bba279f83" + sha256: "16ff608d21e8ea64364f2b7c049c94a02ab81668f78845862b6e88b71dd4935a" url: "https://pub.dev" source: hosted - version: "3.2.1" + version: "3.1.0" riverpod_analyzer_utils: dependency: transitive description: name: riverpod_analyzer_utils - sha256: e55bc08c084a424e1bbdc303fe8ea75daafe4269b68fd0e0f6f1678413715b66 + sha256: "947b05d04c52a546a2ac6b19ef2a54b08520ff6bdf9f23d67957a4c8df1c3bc0" url: "https://pub.dev" source: hosted - version: "1.0.0-dev.9" + version: "1.0.0-dev.8" riverpod_annotation: dependency: "direct main" description: name: riverpod_annotation - sha256: "16471a1260b94e939394d78f1c63a9350936ac4a68c9fbdab40be47268c0b04f" + sha256: cc1474bc2df55ec3c1da1989d139dcef22cd5e2bd78da382e867a69a8eca2e46 url: "https://pub.dev" source: hosted - version: "4.0.2" + version: "4.0.0" riverpod_generator: dependency: "direct dev" description: name: riverpod_generator - sha256: "6f9220534d7a353b53c875ea191a84d28cb4e52ac420a66a1bd7318329d977b0" + sha256: e43b1537229cc8f487f09b0c20d15dba840acbadcf5fc6dad7ad5e8ab75950dc url: "https://pub.dev" source: hosted - version: "4.0.3" + version: "4.0.0+1" riverpod_lint: dependency: "direct dev" description: name: riverpod_lint - sha256: "64e8debf5b719a37d48b9785dd595d34133fdcd84b8fd07157a621c54ab2156f" + sha256: "4d2eb0d19bbe7e3323bd0ce4553b2e6170d161a13914bfdd85a3612329edcb43" url: "https://pub.dev" source: hosted - version: "3.1.3" + version: "3.1.0" shared_preferences: dependency: "direct main" description: @@ -1209,10 +1209,10 @@ packages: dependency: transitive description: name: source_helper - sha256: "4227d54ceefd0bb8ca4c8fcb96e1719dc53f1ee1b6e2ca9d7a6069da160e4eae" + sha256: "6a3c6cc82073a8797f8c4dc4572146114a39652851c157db37e964d9c7038723" url: "https://pub.dev" source: hosted - version: "1.3.12" + version: "1.3.8" source_map_stack_trace: dependency: transitive description: @@ -1345,10 +1345,10 @@ packages: dependency: transitive description: name: synchronized - sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5" + sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 url: "https://pub.dev" source: hosted - version: "3.4.0+1" + version: "3.4.0" term_glyph: dependency: transitive description: @@ -1361,26 +1361,26 @@ packages: dependency: "direct dev" description: name: test - sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" + sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" url: "https://pub.dev" source: hosted - version: "1.30.0" + version: "1.26.3" test_api: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.7" test_core: dependency: transitive description: name: test_core - sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" + sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" url: "https://pub.dev" source: hosted - version: "0.6.16" + version: "0.6.12" timezone: dependency: "direct main" description: @@ -1574,5 +1574,5 @@ packages: source: hosted version: "2.2.4" sdks: - dart: ">=3.11.0 <4.0.0" + dart: ">=3.10.7 <4.0.0" flutter: ">=3.38.4" From cb9ff08d2f00d5ea8887431c4e27b44840135b83 Mon Sep 17 00:00:00 2001 From: Alessandro Mariani Date: Sun, 30 Aug 2026 14:54:35 +0200 Subject: [PATCH 12/15] fix(ci): build Android artifacts without release secrets --- .github/workflows/ci-cd.yml | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index ccf2c101..b015aed0 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -62,17 +62,30 @@ jobs: - name: Run tests run: flutter test --concurrency=1 - - name: Dump keystore - if: github.event_name == 'push' - uses: timheuer/base64-to-file@v1.2.4 - with: - fileName: keystore.jks - fileDir: android/app - encodedString: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} - - - name: Create key.properties + - name: Prepare Android signing if: github.event_name == 'push' run: | + if [ -n "$ANDROID_KEYSTORE_BASE64$ANDROID_KEYSTORE_PASSWORD$ANDROID_KEY_ALIAS$ANDROID_KEY_PASSWORD" ]; then + : "${ANDROID_KEYSTORE_BASE64:?Incomplete Android signing configuration}" + : "${ANDROID_KEYSTORE_PASSWORD:?Incomplete Android signing configuration}" + : "${ANDROID_KEY_ALIAS:?Incomplete Android signing configuration}" + : "${ANDROID_KEY_PASSWORD:?Incomplete Android signing configuration}" + printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 --decode > android/app/keystore.jks + else + keytool -genkeypair \ + -keystore android/app/keystore.jks \ + -storetype JKS \ + -storepass ci-password \ + -alias ci \ + -keypass ci-password \ + -keyalg RSA \ + -dname "CN=Sossoldi CI" \ + -validity 1 + ANDROID_KEYSTORE_PASSWORD=ci-password + ANDROID_KEY_ALIAS=ci + ANDROID_KEY_PASSWORD=ci-password + fi + cat > android/key.properties < Date: Sun, 6 Sep 2026 11:23:06 +0200 Subject: [PATCH 13/15] fix(banking): avoid competing deep-link handlers --- android/app/src/main/AndroidManifest.xml | 3 +++ ios/Runner/Info.plist | 2 ++ .../banking/android_release_manifest_test.dart | 9 +++++++++ .../ios_deeplink_configuration_test.dart | 17 +++++++++++++++++ 4 files changed, 31 insertions(+) create mode 100644 test/services/banking/ios_deeplink_configuration_test.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 19c86b08..6b32850f 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -38,6 +38,9 @@ android:name="io.flutter.embedding.android.NormalTheme" android:resource="@style/NormalTheme" /> + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 0bc075d0..6375ebc0 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -35,6 +35,8 @@ CFBundleVersion $(FLUTTER_BUILD_NUMBER) + FlutterDeepLinkingEnabled + LSRequiresIPhoneOS LSSupportsOpeningDocumentsInPlace diff --git a/test/services/banking/android_release_manifest_test.dart b/test/services/banking/android_release_manifest_test.dart index b8572379..f1e48237 100644 --- a/test/services/banking/android_release_manifest_test.dart +++ b/test/services/banking/android_release_manifest_test.dart @@ -16,4 +16,13 @@ void main() { ), ); }); + + test('app_links is the only Android deep-link handler', () { + final manifest = File( + 'android/app/src/main/AndroidManifest.xml', + ).readAsStringSync(); + + expect(manifest, contains('android:name="flutter_deeplinking_enabled"')); + expect(manifest, contains('android:value="false"')); + }); } diff --git a/test/services/banking/ios_deeplink_configuration_test.dart b/test/services/banking/ios_deeplink_configuration_test.dart new file mode 100644 index 00000000..c80da51c --- /dev/null +++ b/test/services/banking/ios_deeplink_configuration_test.dart @@ -0,0 +1,17 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('app_links is the only iOS deep-link handler', () { + final infoPlist = File('ios/Runner/Info.plist').readAsStringSync(); + + expect( + infoPlist, + contains( + 'FlutterDeepLinkingEnabled\n' + '\t', + ), + ); + }); +} From ec6ae809b64c329e4f9a2e929a4c5db25f867967 Mon Sep 17 00:00:00 2001 From: Alessandro Mariani Date: Wed, 9 Sep 2026 09:05:23 +0200 Subject: [PATCH 14/15] fix(banking): preserve date filters and restore CI signing --- .github/workflows/ci-cd.yml | 31 ++++++------------- lib/services/banking/enable_banking_api.dart | 8 ++--- .../banking/enable_banking_api_test.dart | 29 +++++++++++++++++ 3 files changed, 42 insertions(+), 26 deletions(-) diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index b015aed0..ccf2c101 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -62,30 +62,17 @@ jobs: - name: Run tests run: flutter test --concurrency=1 - - name: Prepare Android signing + - name: Dump keystore if: github.event_name == 'push' - run: | - if [ -n "$ANDROID_KEYSTORE_BASE64$ANDROID_KEYSTORE_PASSWORD$ANDROID_KEY_ALIAS$ANDROID_KEY_PASSWORD" ]; then - : "${ANDROID_KEYSTORE_BASE64:?Incomplete Android signing configuration}" - : "${ANDROID_KEYSTORE_PASSWORD:?Incomplete Android signing configuration}" - : "${ANDROID_KEY_ALIAS:?Incomplete Android signing configuration}" - : "${ANDROID_KEY_PASSWORD:?Incomplete Android signing configuration}" - printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 --decode > android/app/keystore.jks - else - keytool -genkeypair \ - -keystore android/app/keystore.jks \ - -storetype JKS \ - -storepass ci-password \ - -alias ci \ - -keypass ci-password \ - -keyalg RSA \ - -dname "CN=Sossoldi CI" \ - -validity 1 - ANDROID_KEYSTORE_PASSWORD=ci-password - ANDROID_KEY_ALIAS=ci - ANDROID_KEY_PASSWORD=ci-password - fi + uses: timheuer/base64-to-file@v1.2.4 + with: + fileName: keystore.jks + fileDir: android/app + encodedString: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + - name: Create key.properties + if: github.event_name == 'push' + run: | cat > android/key.properties < getTransactions( String accountUid, { DateTime? dateFrom, diff --git a/test/services/banking/enable_banking_api_test.dart b/test/services/banking/enable_banking_api_test.dart index 0e5cfe52..6554601b 100644 --- a/test/services/banking/enable_banking_api_test.dart +++ b/test/services/banking/enable_banking_api_test.dart @@ -3,6 +3,8 @@ import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; +import 'package:timezone/data/latest.dart' as tzdata; +import 'package:timezone/timezone.dart' as tz; import 'package:sossoldi/services/banking/enable_banking_api.dart'; import 'package:sossoldi/services/banking/enable_banking_auth.dart'; import 'package:sossoldi/services/banking/enable_banking_config.dart'; @@ -308,6 +310,33 @@ void main() { expect(page.transactions.single.signedAmount, 12.5); }); + test('getTransactions preserves calendar dates across timezones', () async { + tzdata.initializeTimeZones(); + late http.Request captured; + final api = _apiWith((request) async { + captured = request; + return _json({'transactions': []}); + }); + + final cases = [ + (2026, 1, 1, '2026-01-01'), + (2026, 9, 1, '2026-09-01'), + (2028, 2, 29, '2028-02-29'), + ]; + for (final zone in ['Europe/Rome', 'America/Los_Angeles', 'UTC']) { + final location = tz.getLocation(zone); + for (final (year, month, day, expected) in cases) { + final from = tz.TZDateTime(location, year, month, day); + final to = tz.TZDateTime(location, year, month, day, 23, 30); + await api.getTransactions('acc-uid', dateFrom: from, dateTo: to); + + final query = captured.url.queryParameters; + expect(query['date_from'], expected, reason: zone); + expect(query['date_to'], expected, reason: zone); + } + } + }); + test( 'throws EnableBankingException with the decoded error body on 4xx', () async { From 5660478b26325d5f67a59aa07036b259e9e1c9e5 Mon Sep 17 00:00:00 2001 From: Alessandro Mariani Date: Thu, 10 Sep 2026 08:33:25 +0200 Subject: [PATCH 15/15] refactor(banking): separate provider capabilities and Enable Banking adapters --- lib/providers/banking_provider.dart | 71 +-- .../banking/bank_account_data_source.dart | 12 + .../banking/bank_consent_service.dart | 14 + lib/services/banking/bank_institution.dart | 17 + .../banking/bank_institution_directory.dart | 7 + lib/services/banking/banking_account.dart | 16 + .../banking/banking_authorization.dart | 21 + lib/services/banking/banking_balance.dart | 16 + lib/services/banking/banking_connection.dart | 29 ++ lib/services/banking/banking_exception.dart | 13 + lib/services/banking/banking_money.dart | 12 + lib/services/banking/banking_provider.dart | 12 + lib/services/banking/banking_reference.dart | 28 ++ lib/services/banking/banking_transaction.dart | 40 ++ .../enable_banking_account_data_source.dart | 50 ++ .../enable_banking_account_mapper.dart | 21 + .../enable_banking_api.dart | 35 +- .../enable_banking_auth.dart | 0 .../enable_banking_config.dart | 0 .../enable_banking_consent_mapper.dart | 61 +++ .../enable_banking_consent_service.dart | 43 ++ .../enable_banking_credentials_service.dart | 0 .../enable_banking_credentials_store.dart | 0 .../enable_banking_data_mapper.dart | 58 +++ .../enable_banking_dependencies.dart | 71 +++ .../enable_banking/enable_banking_errors.dart | 40 ++ .../enable_banking_exception.dart | 0 .../enable_banking_institution_directory.dart | 20 + .../enable_banking_institution_mapper.dart | 25 + .../enable_banking_provider.dart | 25 + .../enable_banking_reference.dart | 10 + .../{ => enable_banking}/models/aspsp.dart | 0 .../models/eb_account.dart | 4 + .../enable_banking/models/eb_amount.dart | 22 + .../models/eb_application.dart | 0 .../{ => enable_banking}/models/eb_auth.dart | 0 .../enable_banking/models/eb_balance.dart | 67 +++ .../models/eb_session.dart | 0 .../models/eb_session_details.dart | 0 .../models/eb_transaction.dart | 9 +- .../models/eb_transactions_page.dart | 2 +- lib/services/banking/models/eb_amount.dart | 15 - lib/services/banking/models/eb_balance.dart | 22 - .../banking/banking_boundary_test.dart | 114 +++++ .../banking/banking_capabilities_test.dart | 456 ++++++++++++++++++ test/services/banking/eb_models_test.dart | 14 +- .../banking/enable_banking_api_test.dart | 10 +- .../banking/enable_banking_auth_test.dart | 6 +- .../banking/enable_banking_config_test.dart | 4 +- ...able_banking_credentials_service_test.dart | 14 +- ...enable_banking_credentials_store_test.dart | 4 +- 51 files changed, 1395 insertions(+), 135 deletions(-) create mode 100644 lib/services/banking/bank_account_data_source.dart create mode 100644 lib/services/banking/bank_consent_service.dart create mode 100644 lib/services/banking/bank_institution.dart create mode 100644 lib/services/banking/bank_institution_directory.dart create mode 100644 lib/services/banking/banking_account.dart create mode 100644 lib/services/banking/banking_authorization.dart create mode 100644 lib/services/banking/banking_balance.dart create mode 100644 lib/services/banking/banking_connection.dart create mode 100644 lib/services/banking/banking_exception.dart create mode 100644 lib/services/banking/banking_money.dart create mode 100644 lib/services/banking/banking_provider.dart create mode 100644 lib/services/banking/banking_reference.dart create mode 100644 lib/services/banking/banking_transaction.dart create mode 100644 lib/services/banking/enable_banking/enable_banking_account_data_source.dart create mode 100644 lib/services/banking/enable_banking/enable_banking_account_mapper.dart rename lib/services/banking/{ => enable_banking}/enable_banking_api.dart (86%) rename lib/services/banking/{ => enable_banking}/enable_banking_auth.dart (100%) rename lib/services/banking/{ => enable_banking}/enable_banking_config.dart (100%) create mode 100644 lib/services/banking/enable_banking/enable_banking_consent_mapper.dart create mode 100644 lib/services/banking/enable_banking/enable_banking_consent_service.dart rename lib/services/banking/{ => enable_banking}/enable_banking_credentials_service.dart (100%) rename lib/services/banking/{ => enable_banking}/enable_banking_credentials_store.dart (100%) create mode 100644 lib/services/banking/enable_banking/enable_banking_data_mapper.dart create mode 100644 lib/services/banking/enable_banking/enable_banking_dependencies.dart create mode 100644 lib/services/banking/enable_banking/enable_banking_errors.dart rename lib/services/banking/{ => enable_banking}/enable_banking_exception.dart (100%) create mode 100644 lib/services/banking/enable_banking/enable_banking_institution_directory.dart create mode 100644 lib/services/banking/enable_banking/enable_banking_institution_mapper.dart create mode 100644 lib/services/banking/enable_banking/enable_banking_provider.dart create mode 100644 lib/services/banking/enable_banking/enable_banking_reference.dart rename lib/services/banking/{ => enable_banking}/models/aspsp.dart (100%) rename lib/services/banking/{ => enable_banking}/models/eb_account.dart (85%) create mode 100644 lib/services/banking/enable_banking/models/eb_amount.dart rename lib/services/banking/{ => enable_banking}/models/eb_application.dart (100%) rename lib/services/banking/{ => enable_banking}/models/eb_auth.dart (100%) create mode 100644 lib/services/banking/enable_banking/models/eb_balance.dart rename lib/services/banking/{ => enable_banking}/models/eb_session.dart (100%) rename lib/services/banking/{ => enable_banking}/models/eb_session_details.dart (100%) rename lib/services/banking/{ => enable_banking}/models/eb_transaction.dart (86%) rename lib/services/banking/{ => enable_banking}/models/eb_transactions_page.dart (90%) delete mode 100644 lib/services/banking/models/eb_amount.dart delete mode 100644 lib/services/banking/models/eb_balance.dart create mode 100644 test/services/banking/banking_boundary_test.dart create mode 100644 test/services/banking/banking_capabilities_test.dart diff --git a/lib/providers/banking_provider.dart b/lib/providers/banking_provider.dart index 4391049c..b24c92e9 100644 --- a/lib/providers/banking_provider.dart +++ b/lib/providers/banking_provider.dart @@ -1,71 +1,24 @@ +// dart format width=400 + import 'package:riverpod_annotation/riverpod_annotation.dart'; -import '../services/banking/enable_banking_api.dart'; -import '../services/banking/enable_banking_auth.dart'; -import '../services/banking/enable_banking_config.dart'; -import '../services/banking/enable_banking_credentials_service.dart'; -import '../services/banking/enable_banking_credentials_store.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) -EnableBankingCredentialsStore enableBankingCredentialsStore(Ref ref) => - const EnableBankingCredentialsStore(); - -@Riverpod(keepAlive: true) -EnableBankingAuth enableBankingAuth(Ref ref) => EnableBankingAuth(); +BankingProvider bankingService(Ref ref) => EnableBankingProvider(ref.watch(enableBankingApiProvider)); @Riverpod(keepAlive: true) -EnableBankingApi enableBankingApi(Ref ref) => EnableBankingApi( - auth: ref.watch(enableBankingAuthProvider), - store: ref.watch(enableBankingCredentialsStoreProvider), -); +domain.BankInstitutionDirectory bankInstitutionDirectory(Ref ref) => ref.watch(bankingServiceProvider).institutions; @Riverpod(keepAlive: true) -EnableBankingCredentialsService enableBankingCredentialsService(Ref ref) => - EnableBankingCredentialsService( - api: ref.watch(enableBankingApiProvider), - store: ref.watch(enableBankingCredentialsStoreProvider), - ); +domain.BankConsentService bankConsentService(Ref ref) => ref.watch(bankingServiceProvider).consent; @Riverpod(keepAlive: true) -class EnableBankingSettings extends _$EnableBankingSettings { - @override - Future build() async { - final store = ref.watch(enableBankingCredentialsStoreProvider); - return store.readConfig(); - } - - Future hasCredentials() async { - final store = ref.watch(enableBankingCredentialsStoreProvider); - return store.hasCredentials(); - } - - Future save({ - required String appId, - required String privateKeyPem, - required EnableBankingConfig config, - }) async { - state = const AsyncLoading(); - state = await AsyncValue.guard(() async { - final saved = await ref - .read(enableBankingCredentialsServiceProvider) - .saveVerifiedCredentials( - appId: appId, - privateKeyPem: privateKeyPem, - redirectUri: config.redirectUri, - defaultCountry: config.defaultCountry, - ); - return saved; - }); - } - - Future clear() async { - state = const AsyncLoading(); - state = await AsyncValue.guard(() async { - final store = ref.read(enableBankingCredentialsStoreProvider); - await store.clear(); - return null; - }); - } -} +domain.BankAccountDataSource bankAccountDataSource(Ref ref) => ref.watch(bankingServiceProvider).accountData; diff --git a/lib/services/banking/bank_account_data_source.dart b/lib/services/banking/bank_account_data_source.dart new file mode 100644 index 00000000..8b763149 --- /dev/null +++ b/lib/services/banking/bank_account_data_source.dart @@ -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 getAccount(BankingAccountReference reference); + Future> getBalances(BankingAccountReference reference); + Future getTransactions(BankingAccountReference reference, {BankingTransactionQuery query = const BankingTransactionQuery()}); +} diff --git a/lib/services/banking/bank_consent_service.dart b/lib/services/banking/bank_consent_service.dart new file mode 100644 index 00000000..1ce2b63c --- /dev/null +++ b/lib/services/banking/bank_consent_service.dart @@ -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 startAuthorization(BankingAuthorizationRequest request); + + /// Exchanges an authorization code after the application lifecycle has validated and accepted the callback. + Future createConnection(String authorizationCode); + Future getConnection(BankingConnectionReference reference); + Future revokeConnection(BankingConnectionReference reference); +} diff --git a/lib/services/banking/bank_institution.dart b/lib/services/banking/bank_institution.dart new file mode 100644 index 00000000..9ae581b1 --- /dev/null +++ b/lib/services/banking/bank_institution.dart @@ -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 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 customerTypes = const {}, this.maximumConsentDuration, this.isSandbox = false, this.isBeta = false}) : customerTypes = Set.unmodifiable(customerTypes); +} diff --git a/lib/services/banking/bank_institution_directory.dart b/lib/services/banking/bank_institution_directory.dart new file mode 100644 index 00000000..556ebddd --- /dev/null +++ b/lib/services/banking/bank_institution_directory.dart @@ -0,0 +1,7 @@ +// dart format width=400 + +import 'bank_institution.dart'; + +abstract interface class BankInstitutionDirectory { + Future> getInstitutions({String? country, BankingCustomerType customerType = BankingCustomerType.personal}); +} diff --git a/lib/services/banking/banking_account.dart b/lib/services/banking/banking_account.dart new file mode 100644 index 00000000..3fbad07b --- /dev/null +++ b/lib/services/banking/banking_account.dart @@ -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}); +} diff --git a/lib/services/banking/banking_authorization.dart b/lib/services/banking/banking_authorization.dart new file mode 100644 index 00000000..8c35e6df --- /dev/null +++ b/lib/services/banking/banking_authorization.dart @@ -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}); +} diff --git a/lib/services/banking/banking_balance.dart b/lib/services/banking/banking_balance.dart new file mode 100644 index 00000000..ba36ca6b --- /dev/null +++ b/lib/services/banking/banking_balance.dart @@ -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}); +} diff --git a/lib/services/banking/banking_connection.dart b/lib/services/banking/banking_connection.dart new file mode 100644 index 00000000..bf73c13b --- /dev/null +++ b/lib/services/banking/banking_connection.dart @@ -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 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 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 accounts; + + BankingConnectionResult({required this.connection, required List accounts}) : accounts = List.unmodifiable(accounts); +} diff --git a/lib/services/banking/banking_exception.dart b/lib/services/banking/banking_exception.dart new file mode 100644 index 00000000..5ad6e2eb --- /dev/null +++ b/lib/services/banking/banking_exception.dart @@ -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})'; +} diff --git a/lib/services/banking/banking_money.dart b/lib/services/banking/banking_money.dart new file mode 100644 index 00000000..10920bff --- /dev/null +++ b/lib/services/banking/banking_money.dart @@ -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'); + } + } +} diff --git a/lib/services/banking/banking_provider.dart b/lib/services/banking/banking_provider.dart new file mode 100644 index 00000000..0e778c00 --- /dev/null +++ b/lib/services/banking/banking_provider.dart @@ -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; +} diff --git a/lib/services/banking/banking_reference.dart b/lib/services/banking/banking_reference.dart new file mode 100644 index 00000000..79f5aa9d --- /dev/null +++ b/lib/services/banking/banking_reference.dart @@ -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 identityKeys; + + BankingAccountReference({required this.providerId, required this.remoteId, Iterable 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); +} diff --git a/lib/services/banking/banking_transaction.dart b/lib/services/banking/banking_transaction.dart new file mode 100644 index 00000000..2e80c1fb --- /dev/null +++ b/lib/services/banking/banking_transaction.dart @@ -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 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 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 transactions; + final String? nextCursor; + + BankingTransactionsPage({required List transactions, this.nextCursor}) : transactions = List.unmodifiable(transactions); +} diff --git a/lib/services/banking/enable_banking/enable_banking_account_data_source.dart b/lib/services/banking/enable_banking/enable_banking_account_data_source.dart new file mode 100644 index 00000000..3f627356 --- /dev/null +++ b/lib/services/banking/enable_banking/enable_banking_account_data_source.dart @@ -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 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> getBalances(BankingAccountReference reference) => withEnableBankingErrors(() async { + checkEnableBankingReference(reference.providerId, reference.remoteId); + return List.unmodifiable((await _api.getBalances(reference.remoteId)).map(_data.balance)); + }); + + @override + Future 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)); + }); +} diff --git a/lib/services/banking/enable_banking/enable_banking_account_mapper.dart b/lib/services/banking/enable_banking/enable_banking_account_mapper.dart new file mode 100644 index 00000000..93c40685 --- /dev/null +++ b/lib/services/banking/enable_banking/enable_banking_account_mapper.dart @@ -0,0 +1,21 @@ +// dart format width=400 + +import '../banking_account.dart'; +import '../banking_reference.dart'; +import 'enable_banking_errors.dart'; +import 'models/eb_account.dart'; + +class EnableBankingAccountMapper { + const EnableBankingAccountMapper(); + + BankingAccount account(EbAccount value) => BankingAccount( + reference: BankingAccountReference(providerId: enableBankingId, remoteId: value.uid, identityKeys: [if (value.identificationHash != null) value.identificationHash!, ...value.identificationHashes]), + iban: value.iban, + name: value.name, + details: value.details, + currency: value.currency, + product: value.product, + accountType: value.cashAccountType, + usage: value.usage, + ); +} diff --git a/lib/services/banking/enable_banking_api.dart b/lib/services/banking/enable_banking/enable_banking_api.dart similarity index 86% rename from lib/services/banking/enable_banking_api.dart rename to lib/services/banking/enable_banking/enable_banking_api.dart index 04428f1d..47559a01 100644 --- a/lib/services/banking/enable_banking_api.dart +++ b/lib/services/banking/enable_banking/enable_banking_api.dart @@ -7,6 +7,7 @@ import 'enable_banking_config.dart'; import 'enable_banking_credentials_store.dart'; import 'enable_banking_exception.dart'; import 'models/aspsp.dart'; +import 'models/eb_account.dart'; import 'models/eb_application.dart'; import 'models/eb_auth.dart'; import 'models/eb_balance.dart'; @@ -113,7 +114,7 @@ class EnableBankingApi { 'country': ?country, 'psu_type': psuType, }); - return ((json['aspsps'] as List?) ?? const []) + return (json['aspsps'] as List) .map((e) => Aspsp.fromJson(e as Map)) .toList(); } @@ -169,17 +170,26 @@ class EnableBankingApi { } Future getSession(String sessionId) async { - final json = await _get('/sessions/$sessionId'); + final json = await _get('/sessions/${Uri.encodeComponent(sessionId)}'); return EbSessionDetails.fromJson(json); } Future deleteSession(String sessionId) async { - await _delete('/sessions/$sessionId'); + await _delete('/sessions/${Uri.encodeComponent(sessionId)}'); + } + + Future getAccount(String accountUid) async { + final json = await _get( + '/accounts/${Uri.encodeComponent(accountUid)}/details', + ); + return EbAccount.fromJson(json); } Future> getBalances(String accountUid) async { - final json = await _get('/accounts/$accountUid/balances'); - return ((json['balances'] as List?) ?? const []) + final json = await _get( + '/accounts/${Uri.encodeComponent(accountUid)}/balances', + ); + return (json['balances'] as List) .map((e) => EbBalance.fromJson(e as Map)) .toList(); } @@ -192,12 +202,15 @@ class EnableBankingApi { String? continuationKey, String? transactionStatus, }) async { - final json = await _get('/accounts/$accountUid/transactions', { - if (dateFrom != null) 'date_from': _formatDate(dateFrom), - if (dateTo != null) 'date_to': _formatDate(dateTo), - 'continuation_key': ?continuationKey, - 'transaction_status': ?transactionStatus, - }); + final json = await _get( + '/accounts/${Uri.encodeComponent(accountUid)}/transactions', + { + if (dateFrom != null) 'date_from': _formatDate(dateFrom), + if (dateTo != null) 'date_to': _formatDate(dateTo), + 'continuation_key': ?continuationKey, + 'transaction_status': ?transactionStatus, + }, + ); return EbTransactionsPage.fromJson(json); } } diff --git a/lib/services/banking/enable_banking_auth.dart b/lib/services/banking/enable_banking/enable_banking_auth.dart similarity index 100% rename from lib/services/banking/enable_banking_auth.dart rename to lib/services/banking/enable_banking/enable_banking_auth.dart diff --git a/lib/services/banking/enable_banking_config.dart b/lib/services/banking/enable_banking/enable_banking_config.dart similarity index 100% rename from lib/services/banking/enable_banking_config.dart rename to lib/services/banking/enable_banking/enable_banking_config.dart diff --git a/lib/services/banking/enable_banking/enable_banking_consent_mapper.dart b/lib/services/banking/enable_banking/enable_banking_consent_mapper.dart new file mode 100644 index 00000000..cc3a6f5d --- /dev/null +++ b/lib/services/banking/enable_banking/enable_banking_consent_mapper.dart @@ -0,0 +1,61 @@ +// dart format width=400 + +import '../banking_authorization.dart'; +import '../banking_connection.dart'; +import '../banking_reference.dart'; +import 'enable_banking_account_mapper.dart'; +import 'enable_banking_errors.dart'; +import 'enable_banking_institution_mapper.dart'; +import 'models/aspsp.dart'; +import 'models/eb_auth.dart'; +import 'models/eb_session.dart'; +import 'models/eb_session_details.dart'; + +class EnableBankingConsentMapper { + final _institutions = const EnableBankingInstitutionMapper(); + final _accounts = const EnableBankingAccountMapper(); + + const EnableBankingConsentMapper(); + + BankingAuthorization authorization(EbAuthorization value) { + final uri = Uri.parse(value.url); + if (uri.scheme != 'https' || uri.host.isEmpty || uri.userInfo.isNotEmpty) throw const FormatException('Invalid authorization URL'); + return BankingAuthorization(providerId: enableBankingId, authorizationId: value.authorizationId, uri: uri); + } + + BankingConnectionResult created(EbSession value) { + final accounts = value.accounts.map(_accounts.account).toList(); + final connection = BankingConnection( + reference: BankingConnectionReference(providerId: enableBankingId, remoteId: value.sessionId), + institution: _institutions.institution(Aspsp(name: value.aspspName, country: value.aspspCountry, psuTypes: [if (value.psuType != null) value.psuType!])), + validUntil: value.validUntil, + status: BankingConnectionStatus.active, + accounts: accounts.map((account) => account.reference).toList(), + ); + return BankingConnectionResult(connection: connection, accounts: accounts); + } + + BankingConnection connection(EbSessionDetails value, BankingConnectionReference reference) { + final identities = { + for (final account in value.accountsData) account.uid: {account.identificationHash, ...account.identificationHashes}, + }; + return BankingConnection( + reference: reference, + institution: _institutions.institution(Aspsp(name: value.aspspName, country: value.aspspCountry, psuTypes: [value.psuType])), + validUntil: value.validUntil, + status: switch (value.status) { + EbSessionStatus.authorized => BankingConnectionStatus.active, + EbSessionStatus.pendingAuthorization || EbSessionStatus.returnedFromBank => BankingConnectionStatus.pending, + EbSessionStatus.cancelled => BankingConnectionStatus.cancelled, + EbSessionStatus.closed => BankingConnectionStatus.closed, + EbSessionStatus.expired => BankingConnectionStatus.expired, + EbSessionStatus.invalid => BankingConnectionStatus.invalid, + EbSessionStatus.revoked => BankingConnectionStatus.revoked, + }, + accounts: [for (final uid in value.accountUids) BankingAccountReference(providerId: enableBankingId, remoteId: uid, identityKeys: identities[uid] ?? const {})], + createdAt: value.created, + authorizedAt: value.authorized, + closedAt: value.closed, + ); + } +} diff --git a/lib/services/banking/enable_banking/enable_banking_consent_service.dart b/lib/services/banking/enable_banking/enable_banking_consent_service.dart new file mode 100644 index 00000000..e78215db --- /dev/null +++ b/lib/services/banking/enable_banking/enable_banking_consent_service.dart @@ -0,0 +1,43 @@ +// dart format width=400 + +import '../bank_consent_service.dart'; +import '../banking_authorization.dart'; +import '../banking_connection.dart'; +import '../banking_exception.dart'; +import '../banking_reference.dart'; +import 'enable_banking_api.dart'; +import 'enable_banking_consent_mapper.dart'; +import 'enable_banking_errors.dart'; +import 'enable_banking_reference.dart'; + +class EnableBankingConsentService implements BankConsentService { + final EnableBankingApi _api; + final _mapper = const EnableBankingConsentMapper(); + + EnableBankingConsentService(this._api); + + @override + Future startAuthorization(BankingAuthorizationRequest request) => withEnableBankingErrors(() async { + checkEnableBankingReference(request.institution.providerId, request.institution.id); + if (request.state.trim().isEmpty) throw const BankingException(providerId: enableBankingId, failure: BankingFailure.rejected); + return _mapper.authorization(await _api.startAuthorization(aspspName: request.institution.name, aspspCountry: request.institution.country, state: request.state, validUntil: request.validUntil, psuType: request.customerType.name, language: request.language)); + }); + + @override + Future createConnection(String authorizationCode) => withEnableBankingErrors(() async { + if (authorizationCode.trim().isEmpty) throw const BankingException(providerId: enableBankingId, failure: BankingFailure.rejected); + return _mapper.created(await _api.createSession(authorizationCode)); + }); + + @override + Future getConnection(BankingConnectionReference reference) => withEnableBankingErrors(() async { + checkEnableBankingReference(reference.providerId, reference.remoteId); + return _mapper.connection(await _api.getSession(reference.remoteId), reference); + }); + + @override + Future revokeConnection(BankingConnectionReference reference) => withEnableBankingErrors(() async { + checkEnableBankingReference(reference.providerId, reference.remoteId); + await _api.deleteSession(reference.remoteId); + }); +} diff --git a/lib/services/banking/enable_banking_credentials_service.dart b/lib/services/banking/enable_banking/enable_banking_credentials_service.dart similarity index 100% rename from lib/services/banking/enable_banking_credentials_service.dart rename to lib/services/banking/enable_banking/enable_banking_credentials_service.dart diff --git a/lib/services/banking/enable_banking_credentials_store.dart b/lib/services/banking/enable_banking/enable_banking_credentials_store.dart similarity index 100% rename from lib/services/banking/enable_banking_credentials_store.dart rename to lib/services/banking/enable_banking/enable_banking_credentials_store.dart diff --git a/lib/services/banking/enable_banking/enable_banking_data_mapper.dart b/lib/services/banking/enable_banking/enable_banking_data_mapper.dart new file mode 100644 index 00000000..50f3e2b2 --- /dev/null +++ b/lib/services/banking/enable_banking/enable_banking_data_mapper.dart @@ -0,0 +1,58 @@ +// dart format width=400 + +import '../banking_balance.dart'; +import '../banking_money.dart'; +import '../banking_transaction.dart'; +import 'models/eb_amount.dart'; +import 'models/eb_balance.dart'; +import 'models/eb_transaction.dart'; +import 'models/eb_transactions_page.dart'; + +class EnableBankingDataMapper { + const EnableBankingDataMapper(); + + BankingMoney money(EbAmount value) => BankingMoney(decimalAmount: value.exactAmount, currency: value.currency); + + BankingBalance balance(EbBalance value) => BankingBalance( + name: value.name, + amount: money(value.balanceAmount), + kind: switch (value.balanceType) { + 'CLBD' => BankingBalanceKind.closingBooked, + 'ITBD' => BankingBalanceKind.interimBooked, + 'CLAV' || 'ITAV' || 'FWAV' => BankingBalanceKind.available, + _ => BankingBalanceKind.other, + }, + referenceDate: value.referenceDate, + observedAt: value.lastChangeDateTime, + lastCommittedEntryId: value.lastCommittedTransaction, + ); + + BankingTransaction transaction(EbTransaction value) { + final amount = money(value.transactionAmount); + if (amount.decimalAmount.startsWith('-')) throw const FormatException('Negative transaction magnitude'); + return BankingTransaction( + entryId: value.entryReference, + transactionId: value.transactionId, + status: switch (value.status) { + 'BOOK' => BankingTransactionStatus.booked, + 'PDNG' => BankingTransactionStatus.pending, + _ => BankingTransactionStatus.unknown, + }, + direction: switch (value.creditDebitIndicator) { + 'CRDT' => BankingDirection.credit, + 'DBIT' => BankingDirection.debit, + _ => BankingDirection.unknown, + }, + amount: amount, + bookingDate: value.bookingDate, + valueDate: value.valueDate, + transactionDate: value.transactionDate, + creditorName: value.creditorName, + debtorName: value.debtorName, + note: value.note, + remittanceInformation: value.remittanceInformation, + ); + } + + BankingTransactionsPage transactions(EbTransactionsPage value) => BankingTransactionsPage(transactions: value.transactions.map(transaction).toList(), nextCursor: value.continuationKey?.trim().isEmpty == true ? null : value.continuationKey); +} diff --git a/lib/services/banking/enable_banking/enable_banking_dependencies.dart b/lib/services/banking/enable_banking/enable_banking_dependencies.dart new file mode 100644 index 00000000..de972b16 --- /dev/null +++ b/lib/services/banking/enable_banking/enable_banking_dependencies.dart @@ -0,0 +1,71 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'enable_banking_api.dart'; +import 'enable_banking_auth.dart'; +import 'enable_banking_config.dart'; +import 'enable_banking_credentials_service.dart'; +import 'enable_banking_credentials_store.dart'; + +part 'enable_banking_dependencies.g.dart'; + +@Riverpod(keepAlive: true) +EnableBankingCredentialsStore enableBankingCredentialsStore(Ref ref) => + const EnableBankingCredentialsStore(); + +@Riverpod(keepAlive: true) +EnableBankingAuth enableBankingAuth(Ref ref) => EnableBankingAuth(); + +@Riverpod(keepAlive: true) +EnableBankingApi enableBankingApi(Ref ref) => EnableBankingApi( + auth: ref.watch(enableBankingAuthProvider), + store: ref.watch(enableBankingCredentialsStoreProvider), +); + +@Riverpod(keepAlive: true) +EnableBankingCredentialsService enableBankingCredentialsService(Ref ref) => + EnableBankingCredentialsService( + api: ref.watch(enableBankingApiProvider), + store: ref.watch(enableBankingCredentialsStoreProvider), + ); + +@Riverpod(keepAlive: true) +class EnableBankingSettings extends _$EnableBankingSettings { + @override + Future build() async { + final store = ref.watch(enableBankingCredentialsStoreProvider); + return store.readConfig(); + } + + Future hasCredentials() async { + final store = ref.watch(enableBankingCredentialsStoreProvider); + return store.hasCredentials(); + } + + Future save({ + required String appId, + required String privateKeyPem, + required EnableBankingConfig config, + }) async { + state = const AsyncLoading(); + state = await AsyncValue.guard(() async { + final saved = await ref + .read(enableBankingCredentialsServiceProvider) + .saveVerifiedCredentials( + appId: appId, + privateKeyPem: privateKeyPem, + redirectUri: config.redirectUri, + defaultCountry: config.defaultCountry, + ); + return saved; + }); + } + + Future clear() async { + state = const AsyncLoading(); + state = await AsyncValue.guard(() async { + final store = ref.read(enableBankingCredentialsStoreProvider); + await store.clear(); + return null; + }); + } +} diff --git a/lib/services/banking/enable_banking/enable_banking_errors.dart b/lib/services/banking/enable_banking/enable_banking_errors.dart new file mode 100644 index 00000000..44ae1c95 --- /dev/null +++ b/lib/services/banking/enable_banking/enable_banking_errors.dart @@ -0,0 +1,40 @@ +// dart format width=400 + +import 'dart:async'; +import 'dart:io'; + +import 'package:http/http.dart' as http; + +import '../banking_exception.dart'; +import 'enable_banking_auth.dart'; +import 'enable_banking_exception.dart'; + +const enableBankingId = 'enable_banking'; + +Future withEnableBankingErrors(Future Function() operation) async { + const id = enableBankingId; + try { + return await operation(); + } on EnableBankingException catch (error) { + final failure = switch (error.statusCode) { + 401 => BankingFailure.authentication, + 403 => BankingFailure.forbidden, + 429 => BankingFailure.rateLimited, + int code when code >= 500 => BankingFailure.unavailable, + _ => BankingFailure.rejected, + }; + throw BankingException(providerId: id, failure: failure); + } on EnableBankingAuthException { + throw const BankingException(providerId: id, failure: BankingFailure.authentication); + } on TimeoutException { + throw const BankingException(providerId: id, failure: BankingFailure.unavailable); + } on http.ClientException { + throw const BankingException(providerId: id, failure: BankingFailure.unavailable); + } on SocketException { + throw const BankingException(providerId: id, failure: BankingFailure.unavailable); + } on FormatException { + throw const BankingException(providerId: id, failure: BankingFailure.invalidResponse); + } on TypeError { + throw const BankingException(providerId: id, failure: BankingFailure.invalidResponse); + } +} diff --git a/lib/services/banking/enable_banking_exception.dart b/lib/services/banking/enable_banking/enable_banking_exception.dart similarity index 100% rename from lib/services/banking/enable_banking_exception.dart rename to lib/services/banking/enable_banking/enable_banking_exception.dart diff --git a/lib/services/banking/enable_banking/enable_banking_institution_directory.dart b/lib/services/banking/enable_banking/enable_banking_institution_directory.dart new file mode 100644 index 00000000..7292b5fe --- /dev/null +++ b/lib/services/banking/enable_banking/enable_banking_institution_directory.dart @@ -0,0 +1,20 @@ +// dart format width=400 + +import '../bank_institution.dart'; +import '../bank_institution_directory.dart'; +import 'enable_banking_api.dart'; +import 'enable_banking_errors.dart'; +import 'enable_banking_institution_mapper.dart'; + +class EnableBankingInstitutionDirectory implements BankInstitutionDirectory { + final EnableBankingApi _api; + final _mapper = const EnableBankingInstitutionMapper(); + + EnableBankingInstitutionDirectory(this._api); + + @override + Future> getInstitutions({String? country, BankingCustomerType customerType = BankingCustomerType.personal}) => withEnableBankingErrors(() async { + final values = await _api.getAspsps(country: country, psuType: customerType.name); + return List.unmodifiable(values.map(_mapper.institution)); + }); +} diff --git a/lib/services/banking/enable_banking/enable_banking_institution_mapper.dart b/lib/services/banking/enable_banking/enable_banking_institution_mapper.dart new file mode 100644 index 00000000..cba8d6f0 --- /dev/null +++ b/lib/services/banking/enable_banking/enable_banking_institution_mapper.dart @@ -0,0 +1,25 @@ +// dart format width=400 + +import 'dart:convert'; + +import '../bank_institution.dart'; +import 'models/aspsp.dart'; +import 'enable_banking_errors.dart'; + +class EnableBankingInstitutionMapper { + static const providerId = enableBankingId; + + const EnableBankingInstitutionMapper(); + + BankInstitution institution(Aspsp value) => BankInstitution( + providerId: providerId, + id: jsonEncode([value.country, value.name]), + name: value.name, + country: value.country, + logoUri: value.logo == null ? null : Uri.tryParse(value.logo!), + customerTypes: {if (value.psuTypes.contains('personal')) BankingCustomerType.personal, if (value.psuTypes.contains('business')) BankingCustomerType.business}, + maximumConsentDuration: value.maximumConsentValidity == null ? null : Duration(seconds: value.maximumConsentValidity!), + isSandbox: value.sandbox != null, + isBeta: value.beta, + ); +} diff --git a/lib/services/banking/enable_banking/enable_banking_provider.dart b/lib/services/banking/enable_banking/enable_banking_provider.dart new file mode 100644 index 00000000..637ae615 --- /dev/null +++ b/lib/services/banking/enable_banking/enable_banking_provider.dart @@ -0,0 +1,25 @@ +// dart format width=400 + +import '../bank_institution_directory.dart'; +import '../bank_consent_service.dart'; +import '../bank_account_data_source.dart'; +import '../banking_provider.dart'; +import 'enable_banking_api.dart'; +import 'enable_banking_errors.dart'; +import 'enable_banking_institution_directory.dart'; +import 'enable_banking_consent_service.dart'; +import 'enable_banking_account_data_source.dart'; + +class EnableBankingProvider implements BankingProvider { + @override + String get id => enableBankingId; + + @override + final BankInstitutionDirectory institutions; + @override + final BankConsentService consent; + @override + final BankAccountDataSource accountData; + + EnableBankingProvider(EnableBankingApi api) : institutions = EnableBankingInstitutionDirectory(api), consent = EnableBankingConsentService(api), accountData = EnableBankingAccountDataSource(api); +} diff --git a/lib/services/banking/enable_banking/enable_banking_reference.dart b/lib/services/banking/enable_banking/enable_banking_reference.dart new file mode 100644 index 00000000..dddbecd1 --- /dev/null +++ b/lib/services/banking/enable_banking/enable_banking_reference.dart @@ -0,0 +1,10 @@ +// dart format width=400 + +import '../banking_exception.dart'; +import 'enable_banking_errors.dart'; + +void checkEnableBankingReference(String providerId, String remoteId) { + if (providerId != enableBankingId || remoteId.trim().isEmpty || remoteId == '.' || remoteId == '..') { + throw const BankingException(providerId: enableBankingId, failure: BankingFailure.rejected); + } +} diff --git a/lib/services/banking/models/aspsp.dart b/lib/services/banking/enable_banking/models/aspsp.dart similarity index 100% rename from lib/services/banking/models/aspsp.dart rename to lib/services/banking/enable_banking/models/aspsp.dart diff --git a/lib/services/banking/models/eb_account.dart b/lib/services/banking/enable_banking/models/eb_account.dart similarity index 85% rename from lib/services/banking/models/eb_account.dart rename to lib/services/banking/enable_banking/models/eb_account.dart index db22d783..3753cc3d 100644 --- a/lib/services/banking/models/eb_account.dart +++ b/lib/services/banking/enable_banking/models/eb_account.dart @@ -12,6 +12,7 @@ class EbAccount { final String? cashAccountType; final String? usage; final String? identificationHash; + final List identificationHashes; const EbAccount({ required this.uid, @@ -23,6 +24,7 @@ class EbAccount { this.cashAccountType, this.usage, this.identificationHash, + this.identificationHashes = const [], }); static EbAccount fromJson(Map json) => EbAccount( @@ -35,5 +37,7 @@ class EbAccount { cashAccountType: json['cash_account_type'] as String?, usage: json['usage'] as String?, identificationHash: json['identification_hash'] as String?, + identificationHashes: ((json['identification_hashes'] as List?) ?? const []) + .cast(), ); } diff --git a/lib/services/banking/enable_banking/models/eb_amount.dart b/lib/services/banking/enable_banking/models/eb_amount.dart new file mode 100644 index 00000000..300f8a5d --- /dev/null +++ b/lib/services/banking/enable_banking/models/eb_amount.dart @@ -0,0 +1,22 @@ +/// Monetary amount as returned by Enable Banking. +/// +/// The API decimal string is retained for exact arithmetic; [amount] is a legacy projection. +class EbAmount { + final num amount; + final String currency; + final String? decimalAmount; + + const EbAmount({ + required this.amount, + required this.currency, + this.decimalAmount, + }); + + String get exactAmount => decimalAmount ?? amount.toString(); + + static EbAmount fromJson(Map json) => EbAmount( + amount: num.parse(json['amount'] as String), + currency: json['currency'] as String, + decimalAmount: json['amount'] as String, + ); +} diff --git a/lib/services/banking/models/eb_application.dart b/lib/services/banking/enable_banking/models/eb_application.dart similarity index 100% rename from lib/services/banking/models/eb_application.dart rename to lib/services/banking/enable_banking/models/eb_application.dart diff --git a/lib/services/banking/models/eb_auth.dart b/lib/services/banking/enable_banking/models/eb_auth.dart similarity index 100% rename from lib/services/banking/models/eb_auth.dart rename to lib/services/banking/enable_banking/models/eb_auth.dart diff --git a/lib/services/banking/enable_banking/models/eb_balance.dart b/lib/services/banking/enable_banking/models/eb_balance.dart new file mode 100644 index 00000000..264a7812 --- /dev/null +++ b/lib/services/banking/enable_banking/models/eb_balance.dart @@ -0,0 +1,67 @@ +import 'eb_amount.dart'; + +DateTime? _balanceDate(Object? raw, {bool timestamp = false}) { + if (raw == null) return null; + if (raw is! String) throw const FormatException('Invalid balance date'); + if (!timestamp && !RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(raw)) { + throw const FormatException('Invalid balance reference date'); + } + if (timestamp && + !RegExp( + r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$', + ).hasMatch(raw)) { + throw const FormatException('Balance timestamp requires a timezone'); + } + final calendar = DateTime.tryParse(raw.substring(0, 10)); + if (calendar == null || + calendar.toIso8601String().substring(0, 10) != raw.substring(0, 10)) { + throw const FormatException('Invalid balance calendar date'); + } + if (timestamp && + (int.parse(raw.substring(11, 13)) > 23 || + int.parse(raw.substring(14, 16)) > 59 || + int.parse(raw.substring(17, 19)) > 59)) { + throw const FormatException('Invalid balance time'); + } + final date = DateTime.tryParse(raw); + if (date == null || + (!timestamp && date.toIso8601String().substring(0, 10) != raw)) { + throw const FormatException('Invalid balance date'); + } + return timestamp + ? date.toUtc() + : DateTime.utc(date.year, date.month, date.day); +} + +/// An account balance as returned by `GET /accounts/{uid}/balances`. +class EbBalance { + final String name; + final EbAmount balanceAmount; + final String? balanceType; + final DateTime? referenceDate; + final DateTime? lastChangeDateTime; + final String? lastCommittedTransaction; + + const EbBalance({ + required this.name, + required this.balanceAmount, + this.balanceType, + this.referenceDate, + this.lastChangeDateTime, + this.lastCommittedTransaction, + }); + + static EbBalance fromJson(Map json) => EbBalance( + name: json['name'] as String, + balanceAmount: EbAmount.fromJson( + json['balance_amount'] as Map, + ), + balanceType: json['balance_type'] as String?, + referenceDate: _balanceDate(json['reference_date']), + lastChangeDateTime: _balanceDate( + json['last_change_date_time'], + timestamp: true, + ), + lastCommittedTransaction: json['last_committed_transaction'] as String?, + ); +} diff --git a/lib/services/banking/models/eb_session.dart b/lib/services/banking/enable_banking/models/eb_session.dart similarity index 100% rename from lib/services/banking/models/eb_session.dart rename to lib/services/banking/enable_banking/models/eb_session.dart diff --git a/lib/services/banking/models/eb_session_details.dart b/lib/services/banking/enable_banking/models/eb_session_details.dart similarity index 100% rename from lib/services/banking/models/eb_session_details.dart rename to lib/services/banking/enable_banking/models/eb_session_details.dart diff --git a/lib/services/banking/models/eb_transaction.dart b/lib/services/banking/enable_banking/models/eb_transaction.dart similarity index 86% rename from lib/services/banking/models/eb_transaction.dart rename to lib/services/banking/enable_banking/models/eb_transaction.dart index 8c2e4766..d0b91b64 100644 --- a/lib/services/banking/models/eb_transaction.dart +++ b/lib/services/banking/enable_banking/models/eb_transaction.dart @@ -2,7 +2,14 @@ import 'eb_amount.dart'; DateTime? _parseDate(Object? value) { if (value == null) return null; - return DateTime.tryParse(value as String); + if (value is! String || !RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(value)) { + throw const FormatException('Invalid bank transaction date'); + } + final date = DateTime.tryParse(value); + if (date == null || date.toIso8601String().substring(0, 10) != value) { + throw const FormatException('Invalid bank transaction date'); + } + return date; } /// A single transaction as returned by `GET /accounts/{uid}/transactions`. diff --git a/lib/services/banking/models/eb_transactions_page.dart b/lib/services/banking/enable_banking/models/eb_transactions_page.dart similarity index 90% rename from lib/services/banking/models/eb_transactions_page.dart rename to lib/services/banking/enable_banking/models/eb_transactions_page.dart index e4bca986..50f6cfde 100644 --- a/lib/services/banking/models/eb_transactions_page.dart +++ b/lib/services/banking/enable_banking/models/eb_transactions_page.dart @@ -14,7 +14,7 @@ class EbTransactionsPage { static EbTransactionsPage fromJson(Map json) => EbTransactionsPage( - transactions: ((json['transactions'] as List?) ?? const []) + transactions: (json['transactions'] as List) .map((e) => EbTransaction.fromJson(e as Map)) .toList(), continuationKey: json['continuation_key'] as String?, diff --git a/lib/services/banking/models/eb_amount.dart b/lib/services/banking/models/eb_amount.dart deleted file mode 100644 index a55e30a2..00000000 --- a/lib/services/banking/models/eb_amount.dart +++ /dev/null @@ -1,15 +0,0 @@ -/// Monetary amount as returned by Enable Banking. -/// -/// ⚠️ The API serializes [amount] as a STRING (e.g. `"10.33"`), so it must be -/// parsed with [num.parse]. -class EbAmount { - final num amount; - final String currency; - - const EbAmount({required this.amount, required this.currency}); - - static EbAmount fromJson(Map json) => EbAmount( - amount: num.parse(json['amount'] as String), - currency: json['currency'] as String, - ); -} diff --git a/lib/services/banking/models/eb_balance.dart b/lib/services/banking/models/eb_balance.dart deleted file mode 100644 index 9e587a31..00000000 --- a/lib/services/banking/models/eb_balance.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'eb_amount.dart'; - -/// An account balance as returned by `GET /accounts/{uid}/balances`. -class EbBalance { - final String name; - final EbAmount balanceAmount; - final String? balanceType; - - const EbBalance({ - required this.name, - required this.balanceAmount, - this.balanceType, - }); - - static EbBalance fromJson(Map json) => EbBalance( - name: json['name'] as String, - balanceAmount: EbAmount.fromJson( - json['balance_amount'] as Map, - ), - balanceType: json['balance_type'] as String?, - ); -} diff --git a/test/services/banking/banking_boundary_test.dart b/test/services/banking/banking_boundary_test.dart new file mode 100644 index 00000000..bc43ad04 --- /dev/null +++ b/test/services/banking/banking_boundary_test.dart @@ -0,0 +1,114 @@ +// dart format width=400 + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:sossoldi/providers/banking_provider.dart'; +import 'package:sossoldi/services/banking/bank_institution.dart'; +import 'package:sossoldi/services/banking/banking_exception.dart'; +import 'package:sossoldi/services/banking/bank_institution_directory.dart' as domain; +import 'package:sossoldi/services/banking/banking_provider.dart'; +import 'package:sossoldi/services/banking/enable_banking/enable_banking_api.dart'; +import 'package:sossoldi/services/banking/enable_banking/enable_banking_auth.dart'; +import 'package:sossoldi/services/banking/enable_banking/enable_banking_credentials_store.dart'; +import 'package:sossoldi/services/banking/enable_banking/enable_banking_provider.dart'; + +class _Auth extends EnableBankingAuth { + @override + Future getValidToken(EnableBankingCredentialsStore store) async => 'test'; +} + +class _AlternativeBanking implements domain.BankInstitutionDirectory { + final id = 'alternative'; + + @override + Future> getInstitutions({String? country, BankingCustomerType customerType = BankingCustomerType.personal}) async => [BankInstitution(providerId: id, id: 'bank', name: 'Alternative', country: country ?? 'IT')]; +} + +BankingProvider _provider(MockClientHandler handler) => EnableBankingProvider(EnableBankingApi(auth: _Auth(), store: const EnableBankingCredentialsStore(), client: MockClient(handler))); + +void main() { + test('app provider can be replaced without Enable Banking credentials', () async { + final container = ProviderContainer(overrides: [bankInstitutionDirectoryProvider.overrideWithValue(_AlternativeBanking())]); + addTearDown(container.dispose); + final service = container.read(bankInstitutionDirectoryProvider); + expect((await service.getInstitutions()).single.providerId, 'alternative'); + expect((await service.getInstitutions(country: 'GB')).single.country, 'GB'); + }); + + test('real client maps institution metadata and forwards generic filters', () async { + final service = _provider((request) async { + expect(request.url.path, '/aspsps'); + expect(request.url.queryParameters, {'country': 'IT', 'psu_type': 'business'}); + return http.Response( + jsonEncode({ + 'aspsps': [ + { + 'name': 'Bank', + 'country': 'IT', + 'logo': 'https://example.com/logo.png', + 'psu_types': ['personal', 'business', 'future'], + 'maximum_consent_validity': 3600, + 'beta': true, + 'sandbox': {'users': []}, + }, + {'name': 'Bank', 'country': 'GB'}, + ], + }), + 200, + ); + }); + final banks = await service.institutions.getInstitutions(country: 'IT', customerType: BankingCustomerType.business); + expect(banks.first.providerId, service.id); + expect(banks.first.id, isNot(banks.last.id)); + expect(banks.first.name, 'Bank'); + expect(banks.first.logoUri, Uri.parse('https://example.com/logo.png')); + expect(banks.first.maximumConsentDuration, const Duration(hours: 1)); + expect(banks.first.customerTypes, {BankingCustomerType.personal, BankingCustomerType.business}); + expect(banks.first.isSandbox, isTrue); + expect(banks.first.isBeta, isTrue); + expect(banks.last.maximumConsentDuration, isNull); + expect(banks.last.customerTypes, isEmpty); + expect(() => banks.clear(), throwsUnsupportedError); + expect(() => banks.first.customerTypes.clear(), throwsUnsupportedError); + }); + + for (final entry in {401: BankingFailure.authentication, 403: BankingFailure.forbidden, 429: BankingFailure.rateLimited, 503: BankingFailure.unavailable, 400: BankingFailure.rejected}.entries) { + test('HTTP ${entry.key} produces a generic error without response data', () async { + final service = _provider((_) async => http.Response('{"message":"sensitive-response"}', entry.key)); + await expectLater(service.institutions.getInstitutions(), throwsA(isA().having((e) => e.failure, 'failure', entry.value).having((e) => e.toString(), 'diagnostic', isNot(contains('sensitive-response'))))); + }); + } + + for (final body in ['invalid json', '{"aspsps":[{"name":42}]}']) { + test('malformed payload becomes invalidResponse: $body', () async { + final service = _provider((_) async => http.Response(body, 200)); + await expectLater(service.institutions.getInstitutions(), throwsA(isA().having((e) => e.failure, 'failure', BankingFailure.invalidResponse))); + }); + } + + test('transport errors are provider independent', () async { + final service = _provider((_) async => throw http.ClientException('network')); + await expectLater(service.institutions.getInstitutions(), throwsA(isA().having((e) => e.failure, 'failure', BankingFailure.unavailable))); + }); + + for (final error in [const EnableBankingAuthException('missing credentials'), const SocketException('network'), TimeoutException('timeout')]) { + test('maps ${error.runtimeType} without leaking implementation types', () async { + final service = _provider((_) async => throw error); + final failure = error is EnableBankingAuthException ? BankingFailure.authentication : BankingFailure.unavailable; + await expectLater(service.institutions.getInstitutions(), throwsA(isA().having((e) => e.failure, 'failure', failure))); + }); + } + + test('only the composition root can import the Enable Banking module', () { + for (final entity in Directory('lib').listSync(recursive: true).whereType()) { + if (!entity.path.endsWith('.dart') || entity.path.endsWith('.g.dart') || entity.path.contains('/banking/enable_banking/') || entity.path == 'lib/providers/banking_provider.dart') continue; + expect(RegExp(r'''(?:import|export)\s+['"][^'"]*enable_banking/''').hasMatch(entity.readAsStringSync()), isFalse, reason: entity.path); + } + }); +} diff --git a/test/services/banking/banking_capabilities_test.dart b/test/services/banking/banking_capabilities_test.dart new file mode 100644 index 00000000..577c9796 --- /dev/null +++ b/test/services/banking/banking_capabilities_test.dart @@ -0,0 +1,456 @@ +// dart format width=400 + +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:sossoldi/providers/banking_provider.dart' as providers; +import 'package:sossoldi/services/banking/bank_account_data_source.dart'; +import 'package:sossoldi/services/banking/bank_consent_service.dart'; +import 'package:sossoldi/services/banking/bank_institution.dart'; +import 'package:sossoldi/services/banking/bank_institution_directory.dart'; +import 'package:sossoldi/services/banking/banking_account.dart'; +import 'package:sossoldi/services/banking/banking_authorization.dart'; +import 'package:sossoldi/services/banking/banking_balance.dart'; +import 'package:sossoldi/services/banking/banking_connection.dart'; +import 'package:sossoldi/services/banking/banking_exception.dart'; +import 'package:sossoldi/services/banking/banking_money.dart'; +import 'package:sossoldi/services/banking/banking_provider.dart'; +import 'package:sossoldi/services/banking/banking_reference.dart'; +import 'package:sossoldi/services/banking/banking_transaction.dart'; +import 'package:sossoldi/services/banking/enable_banking/enable_banking_api.dart'; +import 'package:sossoldi/services/banking/enable_banking/enable_banking_auth.dart'; +import 'package:sossoldi/services/banking/enable_banking/enable_banking_credentials_store.dart'; +import 'package:sossoldi/services/banking/enable_banking/enable_banking_provider.dart'; + +class _Auth extends EnableBankingAuth { + @override + Future getValidToken(EnableBankingCredentialsStore store) async => 'test'; +} + +BankingProvider _provider(MockClientHandler handler) { + final client = MockClient(handler); + addTearDown(client.close); + return EnableBankingProvider(EnableBankingApi(auth: _Auth(), store: const EnableBankingCredentialsStore(), client: client)); +} + +http.Response _json(Object value, {int status = 200}) => http.Response(jsonEncode(value), status); +Map _fixture(String name) => jsonDecode(File('test/fixtures/$name.json').readAsStringSync()) as Map; +final _account = BankingAccountReference(providerId: 'enable_banking', remoteId: 'acc-uid-123'); +const _connection = BankingConnectionReference(providerId: 'enable_banking', remoteId: 'session'); +Matcher _failure(BankingFailure failure) => isA().having((error) => error.failure, 'failure', failure); + +class _Directory implements BankInstitutionDirectory { + @override + Future> getInstitutions({String? country, BankingCustomerType customerType = BankingCustomerType.personal}) async => [BankInstitution(providerId: 'alternative', id: 'bank', name: 'Alternative', country: country ?? 'IT')]; +} + +class _Data implements BankAccountDataSource { + @override + Future getAccount(BankingAccountReference reference) async => BankingAccount(reference: reference, name: 'Alternative account', currency: 'EUR'); + @override + Future> getBalances(BankingAccountReference reference) async => [ + BankingBalance( + name: 'Booked', + amount: BankingMoney(decimalAmount: '232.75', currency: 'EUR'), + kind: BankingBalanceKind.interimBooked, + ), + ]; + @override + Future getTransactions(BankingAccountReference reference, {BankingTransactionQuery query = const BankingTransactionQuery()}) async => BankingTransactionsPage(transactions: []); +} + +class _Consent implements BankConsentService { + BankingConnectionStatus status = BankingConnectionStatus.active; + @override + Future startAuthorization(BankingAuthorizationRequest request) async => BankingAuthorization(providerId: 'alternative', authorizationId: 'auth', uri: Uri.https('example.com', '/consent')); + @override + Future createConnection(String authorizationCode) async => BankingConnectionResult( + connection: await getConnection(const BankingConnectionReference(providerId: 'alternative', remoteId: 'session')), + accounts: [], + ); + @override + Future getConnection(BankingConnectionReference reference) async => BankingConnection(reference: reference, institution: (await _Directory().getInstitutions()).single, validUntil: DateTime.utc(2027), status: status, accounts: []); + @override + Future revokeConnection(BankingConnectionReference reference) async { + status = BankingConnectionStatus.revoked; + } +} + +class _Alternative implements BankingProvider { + @override + String get id => 'alternative'; + @override + final institutions = _Directory(); + @override + final consent = _Consent(); + @override + final accountData = _Data(); +} + +void main() { + test('composition exposes three replaceable capabilities without EB credentials', () async { + final alternative = _Alternative(); + final container = ProviderContainer(overrides: [providers.bankingServiceProvider.overrideWithValue(alternative)]); + addTearDown(container.dispose); + final directory = container.read(providers.bankInstitutionDirectoryProvider); + final consent = container.read(providers.bankConsentServiceProvider); + final data = container.read(providers.bankAccountDataSourceProvider); + expect(directory, same(alternative.institutions)); + expect(consent, same(alternative.consent)); + expect(data, same(alternative.accountData)); + final bank = (await directory.getInstitutions()).single; + expect((await consent.startAuthorization(BankingAuthorizationRequest(institution: bank, validUntil: DateTime.utc(2027), state: 'state'))).providerId, 'alternative'); + final result = await consent.createConnection('alternative-code'); + expect(result.connection.status, BankingConnectionStatus.active); + final account = BankingAccountReference(providerId: 'alternative', remoteId: 'account'); + expect((await data.getAccount(account)).name, 'Alternative account'); + expect((await data.getBalances(account)).single.amount.decimalAmount, '232.75'); + expect((await data.getTransactions(account)).transactions, isEmpty); + await consent.revokeConnection(result.connection.reference); + expect((await consent.getConnection(result.connection.reference)).status, BankingConnectionStatus.revoked); + expect(data, isNot(isA())); + }); + + test('data dependency can be replaced independently without constructing the provider', () async { + final container = ProviderContainer(overrides: [providers.bankAccountDataSourceProvider.overrideWithValue(_Data()), providers.bankingServiceProvider.overrideWith((ref) => throw StateError('Unexpected provider access'))]); + addTearDown(container.dispose); + final data = container.read(providers.bankAccountDataSourceProvider); + expect((await data.getBalances(_account)).single.amount.decimalAmount, '232.75'); + }); + + test('authorization uses only consent capability and preserves state and inputs', () async { + final provider = _provider((request) async { + expect(request.method, 'POST'); + expect(request.url.path, '/auth'); + final body = jsonDecode(request.body) as Map; + expect(body['aspsp'], {'name': 'Test Bank', 'country': 'IT'}); + expect(body['state'], 'stored-state'); + expect(body['psu_type'], 'business'); + expect(body['language'], 'it'); + expect(body['access']['valid_until'], '2027-01-01T00:00:00.000Z'); + expect(body['redirect_url'], 'sossoldi://eb-callback'); + return _json({'url': 'https://example.com/consent', 'authorization_id': 'auth', 'psu_id_hash': 'internal'}); + }); + final consent = provider.consent; + expect(consent, isNot(isA())); + final bank = BankInstitution(providerId: provider.id, id: 'bank', name: 'Test Bank', country: 'IT'); + final result = await consent.startAuthorization(BankingAuthorizationRequest(institution: bank, validUntil: DateTime.utc(2027), state: 'stored-state', customerType: BankingCustomerType.business, language: 'it')); + expect(result.authorizationId, 'auth'); + expect(result.uri, Uri.parse('https://example.com/consent')); + }); + + test('creation returns account details and rereading preserves reference-only semantics', () async { + final provider = _provider((request) async { + if (request.method == 'POST') { + expect(request.url.path, '/sessions'); + expect(jsonDecode(request.body), {'code': 'verified-code'}); + return _json(_fixture('eb_session')); + } + expect(request.url.path, '/sessions/sess-789'); + return _json(_fixture('eb_get_session')); + }); + final result = await provider.consent.createConnection('verified-code'); + expect(result.connection.reference.remoteId, 'sess-789'); + expect(result.accounts.single.name, 'Main Account'); + expect(result.accounts.single.reference.identityKeys, {'idhash-abc'}); + expect(result.connection.accounts.single, result.accounts.single.reference); + final reread = await provider.consent.getConnection(result.connection.reference); + expect(reread.reference, result.connection.reference); + expect(reread.status, BankingConnectionStatus.active); + expect(reread.institution.name, 'Nordea'); + expect(reread.accounts.single.identityKeys, {'primary-hash', 'alternate-hash'}); + expect(reread.isExpiredAt(reread.validUntil), isTrue); + expect(reread.isExpiredAt(reread.validUntil.subtract(const Duration(seconds: 1))), isFalse); + expect(() => result.accounts.clear(), throwsUnsupportedError); + expect(() => reread.accounts.single.identityKeys.clear(), throwsUnsupportedError); + }); + + for (final code in ['', ' ']) { + test('empty authorization code is rejected before HTTP: "$code"', () async { + var requests = 0; + final service = _provider((_) async { + requests++; + return _json({}); + }).consent; + await expectLater(service.createConnection(code), throwsA(_failure(BankingFailure.rejected))); + expect(requests, 0); + }); + } + + for (final entry in { + 'AUTHORIZED': BankingConnectionStatus.active, + 'PENDING_AUTHORIZATION': BankingConnectionStatus.pending, + 'RETURNED_FROM_BANK': BankingConnectionStatus.pending, + 'CANCELLED': BankingConnectionStatus.cancelled, + 'CLOSED': BankingConnectionStatus.closed, + 'EXPIRED': BankingConnectionStatus.expired, + 'INVALID': BankingConnectionStatus.invalid, + 'REVOKED': BankingConnectionStatus.revoked, + }.entries) { + test('maps connection state ${entry.key}', () async { + final service = _provider((_) async => _json({..._fixture('eb_get_session'), 'status': entry.key})).consent; + expect((await service.getConnection(_connection)).status, entry.value); + }); + } + + test('unknown connection state fails closed', () async { + final service = _provider((_) async => _json({..._fixture('eb_get_session'), 'status': 'FUTURE'})).consent; + await expectLater(service.getConnection(_connection), throwsA(_failure(BankingFailure.invalidResponse))); + }); + + test('remote revoke propagates errors instead of reporting local success', () async { + var requests = 0; + final service = _provider((request) async { + requests++; + expect(request.method, 'DELETE'); + expect(request.url.path, '/sessions/session'); + return _json({}, status: requests == 1 ? 200 : 503); + }).consent; + await service.revokeConnection(_connection); + await expectLater(service.revokeConnection(_connection), throwsA(_failure(BankingFailure.unavailable))); + }); + + test('foreign provider references cannot invoke any remote operation', () async { + var requests = 0; + final provider = _provider((_) async { + requests++; + return _json({}); + }); + const connection = BankingConnectionReference(providerId: 'other', remoteId: 'session'); + final account = BankingAccountReference(providerId: 'other', remoteId: 'account'); + final bank = BankInstitution(providerId: 'other', id: 'bank', name: 'Bank', country: 'IT'); + for (final operation in Function()>[ + () => provider.consent.startAuthorization(BankingAuthorizationRequest(institution: bank, validUntil: DateTime.utc(2027), state: 'state')), + () => provider.consent.getConnection(connection), + () => provider.consent.revokeConnection(connection), + () => provider.accountData.getAccount(account), + () => provider.accountData.getBalances(account), + () => provider.accountData.getTransactions(account), + ]) { + await expectLater(operation(), throwsA(_failure(BankingFailure.rejected))); + } + expect(requests, 0); + }); + + test('account detail request preserves all known reconnect identities', () async { + final service = _provider((request) async { + expect(request.url.path, '/accounts/acc-uid-123/details'); + return _json({ + ..._fixture('eb_account'), + 'uid': _account.remoteId, + 'identification_hashes': ['alternate', 'idhash-abc'], + }); + }).accountData; + final account = await service.getAccount(_account); + expect(account.reference, _account); + expect(account.reference.identityKeys, contains('alternate')); + expect(account.iban, isNotNull); + }); + + test('account response for another UID is rejected', () async { + final service = _provider((_) async => _json({..._fixture('eb_account'), 'uid': 'other'})).accountData; + await expectLater(service.getAccount(_account), throwsA(_failure(BankingFailure.invalidResponse))); + }); + + test('balance projection preserves precision, kind and reconciliation anchor', () async { + final service = _provider( + (_) async => _json({ + 'balances': [ + { + 'name': 'Booked', + 'balance_amount': {'amount': '9007199254740993.01', 'currency': 'EUR'}, + 'balance_type': 'ITBD', + 'reference_date': '2026-09-09', + 'last_change_date_time': '2026-09-09T10:00:00Z', + 'last_committed_transaction': 'entry', + }, + { + 'name': 'Available', + 'balance_amount': {'amount': '-10.10', 'currency': 'EUR'}, + 'balance_type': 'ITAV', + }, + { + 'name': 'Other', + 'balance_amount': {'amount': '10', 'currency': 'EUR'}, + 'balance_type': 'FUTURE', + }, + ], + }), + ).accountData; + final balances = await service.getBalances(_account); + expect(balances.first.amount.decimalAmount, '9007199254740993.01'); + expect(balances.first.kind, BankingBalanceKind.interimBooked); + expect(balances.first.lastCommittedEntryId, 'entry'); + expect(balances.first.observedAt, DateTime.utc(2026, 9, 9, 10)); + expect(balances[1].kind, BankingBalanceKind.available); + expect(balances[1].amount.decimalAmount, '-10.10'); + expect(balances[2].kind, BankingBalanceKind.other); + expect(balances[2].observedAt, isNull); + expect(() => balances.clear(), throwsUnsupportedError); + }); + + test('transaction filters, pagination, separate IDs and exact amount cross the boundary', () async { + final service = _provider((request) async { + expect(request.url.queryParameters, {'date_from': '2026-01-01', 'date_to': '2026-01-31', 'continuation_key': 'opaque+/cursor', 'transaction_status': 'BOOK'}); + return _json({ + 'transactions': [ + { + 'entry_reference': 'entry', + 'transaction_id': 'transaction', + 'status': 'BOOK', + 'booking_date': '2026-01-02', + 'credit_debit_indicator': 'DBIT', + 'transaction_amount': {'amount': '9007199254740993.01', 'currency': 'EUR'}, + 'remittance_information': ['invoice'], + }, + { + 'status': 'FUTURE', + 'credit_debit_indicator': 'FUTURE', + 'transaction_amount': {'amount': '1.00', 'currency': 'EUR'}, + }, + ], + 'continuation_key': 'next', + }); + }).accountData; + final page = await service.getTransactions( + _account, + query: BankingTransactionQuery(dateFrom: DateTime(2026, 1, 1), dateTo: DateTime(2026, 1, 31), cursor: 'opaque+/cursor', status: BankingTransactionStatus.booked), + ); + expect(page.nextCursor, 'next'); + expect(page.transactions.first.entryId, 'entry'); + expect(page.transactions.first.transactionId, 'transaction'); + expect(page.transactions.first.amount.decimalAmount, '9007199254740993.01'); + expect(page.transactions.first.direction, BankingDirection.debit); + expect(page.transactions.last.direction, BankingDirection.unknown); + expect(page.transactions.last.status, BankingTransactionStatus.unknown); + expect(() => page.transactions.clear(), throwsUnsupportedError); + expect(() => page.transactions.first.remittanceInformation.clear(), throwsUnsupportedError); + }); + + for (final record in [ + {'booking_date': '2026-02-30', 'amount': '1.00'}, + {'booking_date': 'not-a-date', 'amount': '1.00'}, + {'booking_date': '2026-01-01', 'amount': 'NaN'}, + {'booking_date': '2026-01-01', 'amount': '-1.00'}, + ]) { + test('invalid transaction cannot silently disappear: $record', () async { + final service = _provider( + (_) async => _json({ + 'transactions': [ + { + 'status': 'BOOK', + 'booking_date': record['booking_date'], + 'credit_debit_indicator': 'DBIT', + 'transaction_amount': {'amount': record['amount'], 'currency': 'EUR'}, + }, + ], + }), + ).accountData; + await expectLater(service.getTransactions(_account), throwsA(_failure(BankingFailure.invalidResponse))); + }); + } + + test('missing transaction envelope fails instead of reporting no transactions', () async { + final service = _provider((_) async => _json({})).accountData; + await expectLater(service.getTransactions(_account), throwsA(_failure(BankingFailure.invalidResponse))); + }); + + test('invalid filter is rejected without an HTTP call', () async { + var requests = 0; + final service = _provider((_) async { + requests++; + return _json({}); + }).accountData; + await expectLater(service.getTransactions(_account, query: const BankingTransactionQuery(status: BankingTransactionStatus.unknown)), throwsA(_failure(BankingFailure.rejected))); + await expectLater( + service.getTransactions( + _account, + query: BankingTransactionQuery(dateFrom: DateTime(2026, 2), dateTo: DateTime(2026, 1)), + ), + throwsA(_failure(BankingFailure.rejected)), + ); + expect(requests, 0); + }); + + test('opaque remote identifiers cannot inject extra URL path segments', () async { + final service = _provider((request) async { + expect(request.url.pathSegments, ['sessions', 'opaque/part?query']); + expect(request.url.hasQuery, isFalse); + return _json({}); + }).consent; + await service.revokeConnection(const BankingConnectionReference(providerId: 'enable_banking', remoteId: 'opaque/part?query')); + }); + + test('unsafe authorization URL is not exposed to the app', () async { + final service = _provider((_) async => _json({'url': 'http://example.com/consent', 'authorization_id': 'auth', 'psu_id_hash': 'internal'})).consent; + final bank = BankInstitution(providerId: 'enable_banking', id: 'bank', name: 'Bank', country: 'IT'); + await expectLater(service.startAuthorization(BankingAuthorizationRequest(institution: bank, validUntil: DateTime.utc(2027), state: 'state')), throwsA(_failure(BankingFailure.invalidResponse))); + }); + + test('empty state and unsafe references are refused before networking', () async { + var requests = 0; + final provider = _provider((_) async { + requests++; + return _json({}); + }); + final bank = BankInstitution(providerId: provider.id, id: 'bank', name: 'Bank', country: 'IT'); + await expectLater(provider.consent.startAuthorization(BankingAuthorizationRequest(institution: bank, validUntil: DateTime.utc(2027), state: '')), throwsA(_failure(BankingFailure.rejected))); + for (final id in ['', ' ', '.', '..']) { + await expectLater(provider.consent.getConnection(BankingConnectionReference(providerId: provider.id, remoteId: id)), throwsA(_failure(BankingFailure.rejected))); + await expectLater(provider.accountData.getBalances(BankingAccountReference(providerId: provider.id, remoteId: id)), throwsA(_failure(BankingFailure.rejected))); + } + expect(requests, 0); + }); + + test('missing balance list and malformed timestamp fail instead of producing a usable balance', () async { + for (final body in [ + {}, + { + 'balances': [ + { + 'name': 'Booked', + 'balance_amount': {'amount': '1.00', 'currency': 'EUR'}, + 'last_change_date_time': '2026-02-30T00:00:00Z', + }, + ], + }, + ]) { + final service = _provider((_) async => _json(body)).accountData; + await expectLater(service.getBalances(_account), throwsA(_failure(BankingFailure.invalidResponse))); + } + }); + + test('authentication failure reading data is not treated as an expired consent', () async { + final service = _provider((_) async => _json({'message': 'private detail'}, status: 401)).accountData; + await expectLater(service.getBalances(_account), throwsA(_failure(BankingFailure.authentication))); + }); + + test('provider is part of remote identity and account identities are copied', () { + final identities = ['primary']; + final first = BankingAccountReference(providerId: 'one', remoteId: 'account', identityKeys: identities); + identities.add('mutated'); + final same = BankingAccountReference(providerId: 'one', remoteId: 'account', identityKeys: ['alternative']); + expect(first.identityKeys, {'primary'}); + expect(first, same); + expect(first.hashCode, same.hashCode); + expect(first, isNot(BankingAccountReference(providerId: 'two', remoteId: 'account'))); + expect(const BankingConnectionReference(providerId: 'one', remoteId: 'session'), isNot(const BankingConnectionReference(providerId: 'two', remoteId: 'session'))); + }); + + test('banking domain remains independent of frameworks and persistence', () { + for (final file in Directory('lib/services/banking').listSync().whereType().where((file) => file.path.endsWith('.dart'))) { + final imports = RegExp(r'''import\s+['"]([^'"]+)['"]''').allMatches(file.readAsStringSync()).map((match) => match.group(1)!); + expect(imports.any((path) => path.startsWith('package:') || path.contains('enable_banking/') || path.contains('database/') || path.contains('providers/')), isFalse, reason: file.path); + } + for (final name in ['enable_banking_institution_directory.dart', 'enable_banking_consent_service.dart', 'enable_banking_account_data_source.dart']) { + final source = File('lib/services/banking/enable_banking/$name').readAsStringSync(); + expect(source.contains('database/'), isFalse); + expect(source.contains('banking_provider.dart'), isFalse); + expect(source.contains('enable_banking_credentials_store.dart'), isFalse); + } + }); +} diff --git a/test/services/banking/eb_models_test.dart b/test/services/banking/eb_models_test.dart index 5d2c25b7..b832304f 100644 --- a/test/services/banking/eb_models_test.dart +++ b/test/services/banking/eb_models_test.dart @@ -3,13 +3,13 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; -import 'package:sossoldi/services/banking/models/aspsp.dart'; -import 'package:sossoldi/services/banking/models/eb_application.dart'; -import 'package:sossoldi/services/banking/models/eb_account.dart'; -import 'package:sossoldi/services/banking/models/eb_balance.dart'; -import 'package:sossoldi/services/banking/models/eb_session.dart'; -import 'package:sossoldi/services/banking/models/eb_session_details.dart'; -import 'package:sossoldi/services/banking/models/eb_transactions_page.dart'; +import 'package:sossoldi/services/banking/enable_banking/models/aspsp.dart'; +import 'package:sossoldi/services/banking/enable_banking/models/eb_application.dart'; +import 'package:sossoldi/services/banking/enable_banking/models/eb_account.dart'; +import 'package:sossoldi/services/banking/enable_banking/models/eb_balance.dart'; +import 'package:sossoldi/services/banking/enable_banking/models/eb_session.dart'; +import 'package:sossoldi/services/banking/enable_banking/models/eb_session_details.dart'; +import 'package:sossoldi/services/banking/enable_banking/models/eb_transactions_page.dart'; Map _loadJson(String name) => jsonDecode(File('test/fixtures/$name').readAsStringSync()) diff --git a/test/services/banking/enable_banking_api_test.dart b/test/services/banking/enable_banking_api_test.dart index 6554601b..7fa6d025 100644 --- a/test/services/banking/enable_banking_api_test.dart +++ b/test/services/banking/enable_banking_api_test.dart @@ -5,11 +5,11 @@ import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:timezone/data/latest.dart' as tzdata; import 'package:timezone/timezone.dart' as tz; -import 'package:sossoldi/services/banking/enable_banking_api.dart'; -import 'package:sossoldi/services/banking/enable_banking_auth.dart'; -import 'package:sossoldi/services/banking/enable_banking_config.dart'; -import 'package:sossoldi/services/banking/enable_banking_credentials_store.dart'; -import 'package:sossoldi/services/banking/enable_banking_exception.dart'; +import 'package:sossoldi/services/banking/enable_banking/enable_banking_api.dart'; +import 'package:sossoldi/services/banking/enable_banking/enable_banking_auth.dart'; +import 'package:sossoldi/services/banking/enable_banking/enable_banking_config.dart'; +import 'package:sossoldi/services/banking/enable_banking/enable_banking_credentials_store.dart'; +import 'package:sossoldi/services/banking/enable_banking/enable_banking_exception.dart'; /// Bypasses credential/JWT signing entirely: the API client only needs a /// valid bearer token string, not a real signature. diff --git a/test/services/banking/enable_banking_auth_test.dart b/test/services/banking/enable_banking_auth_test.dart index 02aad00d..4b86f97c 100644 --- a/test/services/banking/enable_banking_auth_test.dart +++ b/test/services/banking/enable_banking_auth_test.dart @@ -1,9 +1,9 @@ import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:sossoldi/services/banking/enable_banking_auth.dart'; -import 'package:sossoldi/services/banking/enable_banking_config.dart'; -import 'package:sossoldi/services/banking/enable_banking_credentials_store.dart'; +import 'package:sossoldi/services/banking/enable_banking/enable_banking_auth.dart'; +import 'package:sossoldi/services/banking/enable_banking/enable_banking_config.dart'; +import 'package:sossoldi/services/banking/enable_banking/enable_banking_credentials_store.dart'; const _testAppId = 'test-app-id'; diff --git a/test/services/banking/enable_banking_config_test.dart b/test/services/banking/enable_banking_config_test.dart index d3c06b99..1d27214b 100644 --- a/test/services/banking/enable_banking_config_test.dart +++ b/test/services/banking/enable_banking_config_test.dart @@ -1,6 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:sossoldi/services/banking/enable_banking_config.dart'; -import 'package:sossoldi/services/banking/enable_banking_exception.dart'; +import 'package:sossoldi/services/banking/enable_banking/enable_banking_config.dart'; +import 'package:sossoldi/services/banking/enable_banking/enable_banking_exception.dart'; void main() { group('EnableBankingConfig.fromJson', () { diff --git a/test/services/banking/enable_banking_credentials_service_test.dart b/test/services/banking/enable_banking_credentials_service_test.dart index 7a4c0af9..e7b58dbd 100644 --- a/test/services/banking/enable_banking_credentials_service_test.dart +++ b/test/services/banking/enable_banking_credentials_service_test.dart @@ -1,12 +1,12 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:sossoldi/services/banking/enable_banking_api.dart'; -import 'package:sossoldi/services/banking/enable_banking_auth.dart'; -import 'package:sossoldi/services/banking/enable_banking_config.dart'; -import 'package:sossoldi/services/banking/enable_banking_credentials_service.dart'; -import 'package:sossoldi/services/banking/enable_banking_credentials_store.dart'; -import 'package:sossoldi/services/banking/enable_banking_exception.dart'; -import 'package:sossoldi/services/banking/models/eb_application.dart'; +import 'package:sossoldi/services/banking/enable_banking/enable_banking_api.dart'; +import 'package:sossoldi/services/banking/enable_banking/enable_banking_auth.dart'; +import 'package:sossoldi/services/banking/enable_banking/enable_banking_config.dart'; +import 'package:sossoldi/services/banking/enable_banking/enable_banking_credentials_service.dart'; +import 'package:sossoldi/services/banking/enable_banking/enable_banking_credentials_store.dart'; +import 'package:sossoldi/services/banking/enable_banking/enable_banking_exception.dart'; +import 'package:sossoldi/services/banking/enable_banking/models/eb_application.dart'; class _FakeApplicationApi extends EnableBankingApi { _FakeApplicationApi(this.application, {this.failure}) diff --git a/test/services/banking/enable_banking_credentials_store_test.dart b/test/services/banking/enable_banking_credentials_store_test.dart index 4bd0731f..a28d6480 100644 --- a/test/services/banking/enable_banking_credentials_store_test.dart +++ b/test/services/banking/enable_banking_credentials_store_test.dart @@ -1,7 +1,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:sossoldi/services/banking/enable_banking_config.dart'; -import 'package:sossoldi/services/banking/enable_banking_credentials_store.dart'; +import 'package:sossoldi/services/banking/enable_banking/enable_banking_config.dart'; +import 'package:sossoldi/services/banking/enable_banking/enable_banking_credentials_store.dart'; void main() { setUp(() {