From 94efa59df3592884c80b36379a72fc9d568aee01 Mon Sep 17 00:00:00 2001 From: data Date: Fri, 20 Feb 2026 09:00:05 +0100 Subject: [PATCH] v1.7: Multi-invoice payments and payment unlinking - Add multi-invoice payment support (link one bank transaction to multiple invoices) - Add payment unlinking feature to correct wrong matches - Show linked payments, invoices and bank entries in transaction detail view - Allow linking already paid invoices to bank transactions - Update README with new features - Add CHANGELOG.md Co-Authored-By: Claude Opus 4.5 --- CHANGELOG.md | 46 + COPYING | 621 +++++ README.md | 85 + admin/about.php | 118 + admin/setup.php | 620 +++++ ajax/checkpending.php | 102 + ajax/checktan.php | 194 ++ bankimportindex.php | 382 +++ build/buildzip.php | 316 +++ build/makepack-bankimport.conf | 11 + card.php | 1258 +++++++++ class/bankimportcron.class.php | 379 +++ class/bankstatement.class.php | 1325 ++++++++++ class/banktransaction.class.php | 2289 +++++++++++++++++ class/fints.class.php | 1020 ++++++++ composer.json | 16 + composer.lock | 70 + confirm.php | 458 ++++ core/boxes/box_bankimport_pending.php | 167 ++ core/modules/modBankImport.class.php | 552 ++++ img/README.md | 14 + js/bankimport_notify.js.php | 174 ++ langs/de_DE/bankimport.lang | 356 +++ langs/en_US/bankimport.lang | 252 ++ lib/bankimport.lib.php | 85 + list.php | 329 +++ modulebuilder.txt | 3 + pdfstatements.php | 955 +++++++ sql/dolibarr_allversions.sql | 34 + sql/llx_bankimport_statement.key.sql | 14 + sql/llx_bankimport_statement.sql | 30 + sql/llx_bankimport_statement_line.key.sql | 15 + sql/llx_bankimport_statement_line.sql | 34 + sql/llx_bankimport_transaction.key.sql | 28 + sql/llx_bankimport_transaction.sql | 62 + statements.php | 708 +++++ vendor/autoload.php | 22 + vendor/composer/ClassLoader.php | 579 +++++ vendor/composer/InstalledVersions.php | 396 +++ vendor/composer/LICENSE | 21 + vendor/composer/autoload_classmap.php | 11 + vendor/composer/autoload_namespaces.php | 11 + vendor/composer/autoload_psr4.php | 9 + vendor/composer/autoload_real.php | 38 + vendor/composer/autoload_static.php | 39 + vendor/composer/installed.json | 58 + vendor/composer/installed.php | 38 + vendor/composer/platform_check.php | 25 + vendor/nemiah/php-fints/.php-cs-fixer.php | 30 + vendor/nemiah/php-fints/.travis.yml | 12 + vendor/nemiah/php-fints/DEVELOPER-GUIDE.md | 200 ++ vendor/nemiah/php-fints/LICENSE | 21 + vendor/nemiah/php-fints/README.md | 89 + vendor/nemiah/php-fints/Samples/accounts.php | 20 + vendor/nemiah/php-fints/Samples/balance.php | 35 + vendor/nemiah/php-fints/Samples/bpd.php | 18 + vendor/nemiah/php-fints/Samples/browser.php | 252 ++ .../php-fints/Samples/directDebit_Sephpa.php | 51 + .../Samples/directDebit_phpSepaXml.php | 67 + vendor/nemiah/php-fints/Samples/init.php | 24 + vendor/nemiah/php-fints/Samples/login.php | 236 ++ .../php-fints/Samples/statementOfAccount.php | 47 + .../php-fints/Samples/statementOfHoldings.php | 40 + .../php-fints/Samples/tanModesAndMedia.php | 71 + vendor/nemiah/php-fints/Samples/transfer.php | 54 + vendor/nemiah/php-fints/composer.json | 39 + vendor/nemiah/php-fints/csfixer-check.sh | 36 + vendor/nemiah/php-fints/disallowtabs.sh | 37 + .../php-fints/lib/Fhp/Action/GetBalance.php | 129 + .../lib/Fhp/Action/GetDepotAufstellung.php | 167 ++ .../lib/Fhp/Action/GetSEPAAccounts.php | 92 + .../Action/GetSEPADirectDebitParameters.php | 78 + .../lib/Fhp/Action/GetStatementOfAccount.php | 223 ++ .../Fhp/Action/GetStatementOfAccountXML.php | 176 ++ .../SendInternationalCreditTransfer.php | 50 + .../lib/Fhp/Action/SendSEPADirectDebit.php | 185 ++ .../Fhp/Action/SendSEPARealtimeTransfer.php | 114 + .../lib/Fhp/Action/SendSEPATransfer.php | 121 + .../nemiah/php-fints/lib/Fhp/BaseAction.php | 258 ++ .../nemiah/php-fints/lib/Fhp/Connection.php | 99 + .../php-fints/lib/Fhp/CurlException.php | 50 + vendor/nemiah/php-fints/lib/Fhp/FinTs.php | 1017 ++++++++ .../nemiah/php-fints/lib/Fhp/MT535/MT535.php | 125 + .../lib/Fhp/MT940/Dialect/PostbankMT940.php | 39 + .../lib/Fhp/MT940/Dialect/SpardaMT940.php | 87 + .../nemiah/php-fints/lib/Fhp/MT940/MT940.php | 258 ++ .../lib/Fhp/MT940/MT940Exception.php | 10 + .../php-fints/lib/Fhp/Model/Account.php | 147 ++ .../lib/Fhp/Model/FlickerTan/DataElement.php | 181 ++ .../lib/Fhp/Model/FlickerTan/StartCode.php | 105 + .../lib/Fhp/Model/FlickerTan/SvgRenderer.php | 167 ++ .../FlickerTan/TanRequestChallengeFlicker.php | 153 ++ .../php-fints/lib/Fhp/Model/NoPsd2TanMode.php | 130 + .../php-fints/lib/Fhp/Model/SEPAAccount.php | 97 + .../Model/StatementOfAccount/Statement.php | 130 + .../StatementOfAccount/StatementOfAccount.php | 126 + .../Model/StatementOfAccount/Transaction.php | 450 ++++ .../Fhp/Model/StatementOfHoldings/Holding.php | 256 ++ .../StatementOfHoldings.php | 26 + .../php-fints/lib/Fhp/Model/TanMedium.php | 27 + .../php-fints/lib/Fhp/Model/TanMode.php | 153 ++ .../php-fints/lib/Fhp/Model/TanRequest.php | 37 + .../Fhp/Model/TanRequestChallengeImage.php | 64 + .../php-fints/lib/Fhp/Options/Credentials.php | 58 + .../lib/Fhp/Options/FinTsOptions.php | 70 + .../lib/Fhp/Options/SanitizingLogger.php | 103 + .../php-fints/lib/Fhp/PaginateableAction.php | 115 + .../Protocol/ActionIncompleteException.php | 15 + .../nemiah/php-fints/lib/Fhp/Protocol/BPD.php | 211 ++ .../lib/Fhp/Protocol/DialogInitialization.php | 240 ++ .../lib/Fhp/Protocol/GetTanMedia.php | 52 + .../php-fints/lib/Fhp/Protocol/Message.php | 365 +++ .../lib/Fhp/Protocol/MessageBuilder.php | 50 + .../lib/Fhp/Protocol/ServerException.php | 181 ++ .../lib/Fhp/Protocol/TanRequiredException.php | 26 + .../nemiah/php-fints/lib/Fhp/Protocol/UPD.php | 80 + .../Protocol/UnexpectedResponseException.php | 15 + .../lib/Fhp/Segment/AUB/HIAUBSv9.php | 16 + .../php-fints/lib/Fhp/Segment/AUB/HKAUBv9.php | 21 + .../AUB/ParameterAuslandsueberweisungV2.php | 16 + .../lib/Fhp/Segment/AnonymousSegment.php | 104 + .../lib/Fhp/Segment/BME/HIBMESv1.php | 29 + .../lib/Fhp/Segment/BME/HIBMESv2.php | 29 + .../php-fints/lib/Fhp/Segment/BME/HKBMEv1.php | 15 + .../php-fints/lib/Fhp/Segment/BME/HKBMEv2.php | 13 + ...EPAFirmenSammellastschriftEinreichenV1.php | 12 + ...EPAFirmenSammellastschriftEinreichenV2.php | 20 + .../lib/Fhp/Segment/BSE/HIBSESv1.php | 29 + .../lib/Fhp/Segment/BSE/HIBSESv2.php | 29 + .../php-fints/lib/Fhp/Segment/BSE/HKBSEv1.php | 15 + .../php-fints/lib/Fhp/Segment/BSE/HKBSEv2.php | 13 + ...EPAFirmenEinzellastschriftEinreichenV1.php | 23 + ...EPAFirmenEinzellastschriftEinreichenV2.php | 17 + ...ierteSEPAFirmenLastschriftEinreichenV2.php | 21 + .../php-fints/lib/Fhp/Segment/BaseDeg.php | 99 + .../lib/Fhp/Segment/BaseDescriptor.php | 212 ++ .../BaseGeschaeftsvorfallparameter.php | 33 + .../BaseGeschaeftsvorfallparameterOld.php | 29 + .../php-fints/lib/Fhp/Segment/BaseSegment.php | 147 ++ .../Fhp/Segment/CAZ/GebuchteCamtUmsaetze.php | 32 + .../lib/Fhp/Segment/CAZ/HICAZSv1.php | 21 + .../php-fints/lib/Fhp/Segment/CAZ/HICAZv1.php | 61 + .../php-fints/lib/Fhp/Segment/CAZ/HKCAZv1.php | 48 + .../CAZ/ParameterKontoumsaetzeCamt.php | 21 + .../Segment/CAZ/UnterstuetzteCamtMessages.php | 24 + .../lib/Fhp/Segment/CCM/HICCMSv1.php | 21 + .../php-fints/lib/Fhp/Segment/CCM/HKCCMv1.php | 30 + .../CCM/ParameterSEPASammelueberweisungV1.php | 12 + .../lib/Fhp/Segment/CCS/HICCSSv1.php | 16 + .../php-fints/lib/Fhp/Segment/CCS/HKCCSv1.php | 26 + .../lib/Fhp/Segment/CME/HICMESv1.php | 16 + .../php-fints/lib/Fhp/Segment/CME/HICMEv1.php | 16 + .../php-fints/lib/Fhp/Segment/CME/HKCMEv1.php | 30 + ...erteSEPASammelueberweisungEinreichenV1.php | 14 + .../lib/Fhp/Segment/CSE/HICSESv1.php | 21 + .../php-fints/lib/Fhp/Segment/CSE/HICSEv1.php | 16 + .../php-fints/lib/Fhp/Segment/CSE/HKCSEv1.php | 26 + ...erminierteSEPAUeberweisungEinreichenV1.php | 13 + .../lib/Fhp/Segment/Common/AccountInfo.php | 15 + .../php-fints/lib/Fhp/Segment/Common/Btg.php | 22 + .../php-fints/lib/Fhp/Segment/Common/Kik.php | 38 + .../php-fints/lib/Fhp/Segment/Common/Kti.php | 72 + .../php-fints/lib/Fhp/Segment/Common/Kto.php | 45 + .../lib/Fhp/Segment/Common/KtvV3.php | 51 + .../php-fints/lib/Fhp/Segment/Common/Ktz.php | 38 + .../lib/Fhp/Segment/Common/Kursqualitaet.php | 19 + .../php-fints/lib/Fhp/Segment/Common/Sdo.php | 68 + .../php-fints/lib/Fhp/Segment/Common/Tsp.php | 32 + .../lib/Fhp/Segment/DME/HIDMESv1.php | 29 + .../lib/Fhp/Segment/DME/HIDMESv2.php | 29 + .../php-fints/lib/Fhp/Segment/DME/HKDMEv1.php | 30 + .../php-fints/lib/Fhp/Segment/DME/HKDMEv2.php | 13 + ...ierteSEPASammellastschriftEinreichenV1.php | 12 + ...ierteSEPASammellastschriftEinreichenV2.php | 20 + .../lib/Fhp/Segment/DSE/HIDSESv1.php | 27 + .../lib/Fhp/Segment/DSE/HIDSESv2.php | 27 + .../php-fints/lib/Fhp/Segment/DSE/HIDXES.php | 13 + .../php-fints/lib/Fhp/Segment/DSE/HKDSEv1.php | 24 + .../php-fints/lib/Fhp/Segment/DSE/HKDSEv2.php | 13 + .../MinimaleVorlaufzeitSEPALastschrift.php | 79 + ...ierteSEPAEinzellastschriftEinreichenV1.php | 21 + ...ierteSEPAEinzellastschriftEinreichenV2.php | 17 + ...TerminierteSEPALastschriftEinreichenV2.php | 18 + ...SEPADirectDebitMinimalLeadTimeProvider.php | 9 + .../lib/Fhp/Segment/DegDescriptor.php | 48 + .../lib/Fhp/Segment/ElementDescriptor.php | 108 + .../lib/Fhp/Segment/HIBPA/HIBPAv3.php | 27 + .../HIBPA/UnterstuetzteHbciVersionenV2.php | 18 + .../Segment/HIBPA/UnterstuetzteSprachenV2.php | 24 + ...svorfallspezifischePinTanInformationen.php | 21 + .../lib/Fhp/Segment/HIPINS/HIPINSv1.php | 17 + ...arameterPinTanSpezifischeInformationen.php | 25 + .../lib/Fhp/Segment/HIRMG/HIRMGv2.php | 25 + .../Segment/HIRMS/FindRueckmeldungTrait.php | 32 + .../lib/Fhp/Segment/HIRMS/HIRMSv2.php | 26 + .../lib/Fhp/Segment/HIRMS/Rueckmeldung.php | 48 + .../Segment/HIRMS/RueckmeldungContainer.php | 18 + .../Fhp/Segment/HIRMS/Rueckmeldungscode.php | 160 ++ .../lib/Fhp/Segment/HISYN/HISYNv4.php | 25 + .../lib/Fhp/Segment/HIUPA/HIUPAv4.php | 34 + .../HIUPD/ErlaubteGeschaeftsvorfaelle.php | 12 + .../HIUPD/ErlaubteGeschaeftsvorfaelleV1.php | 30 + .../HIUPD/ErlaubteGeschaeftsvorfaelleV2.php | 30 + .../php-fints/lib/Fhp/Segment/HIUPD/HIUPD.php | 24 + .../lib/Fhp/Segment/HIUPD/HIUPDv4.php | 44 + .../lib/Fhp/Segment/HIUPD/HIUPDv6.php | 65 + .../lib/Fhp/Segment/HIUPD/KontolimitV1.php | 20 + .../lib/Fhp/Segment/HIUPD/KontolimitV2.php | 22 + .../lib/Fhp/Segment/HKEND/HKENDv1.php | 23 + .../lib/Fhp/Segment/HKIDN/HKIDNv2.php | 56 + .../lib/Fhp/Segment/HKSYN/HKSYNv3.php | 23 + .../lib/Fhp/Segment/HKVVB/HKVVBv3.php | 42 + .../Fhp/Segment/HNHBK/BezugsnachrichtV1.php | 20 + .../lib/Fhp/Segment/HNHBK/HNHBKv3.php | 48 + .../lib/Fhp/Segment/HNHBS/HNHBSv1.php | 14 + .../HNSHA/BenutzerdefinierteSignaturV1.php | 28 + .../lib/Fhp/Segment/HNSHA/HNSHAv2.php | 33 + .../lib/Fhp/Segment/HNSHK/HNSHKv4.php | 81 + .../Fhp/Segment/HNSHK/HashalgorithmusV2.php | 37 + .../Segment/HNSHK/SignaturalgorithmusV2.php | 29 + .../lib/Fhp/Segment/HNVSD/HNVSDv1.php | 44 + .../lib/Fhp/Segment/HNVSK/HNVSKv3.php | 83 + .../Fhp/Segment/HNVSK/SchluesselnameV3.php | 46 + .../HNVSK/SicherheitsdatumUndUhrzeitV2.php | 43 + .../SicherheitsidentifikationDetailsV2.php | 36 + .../Fhp/Segment/HNVSK/SicherheitsprofilV1.php | 39 + .../HNVSK/VerschluesselungsalgorithmusV2.php | 53 + .../lib/Fhp/Segment/HNVSK/ZertifikatV2.php | 20 + .../lib/Fhp/Segment/IPZ/HIIPZSv1.php | 23 + .../lib/Fhp/Segment/IPZ/HIIPZSv2.php | 24 + .../php-fints/lib/Fhp/Segment/IPZ/HIIPZv1.php | 38 + .../php-fints/lib/Fhp/Segment/IPZ/HIIPZv2.php | 13 + .../php-fints/lib/Fhp/Segment/IPZ/HKIPZv1.php | 16 + .../php-fints/lib/Fhp/Segment/IPZ/HKIPZv2.php | 14 + .../ParameterSEPAInstantPaymentZahlungV1.php | 24 + .../ParameterSEPAInstantPaymentZahlungV2.php | 26 + .../php-fints/lib/Fhp/Segment/KAZ/HIKAZ.php | 16 + .../php-fints/lib/Fhp/Segment/KAZ/HIKAZS.php | 13 + .../lib/Fhp/Segment/KAZ/HIKAZSv4.php | 23 + .../lib/Fhp/Segment/KAZ/HIKAZSv5.php | 22 + .../lib/Fhp/Segment/KAZ/HIKAZSv6.php | 21 + .../lib/Fhp/Segment/KAZ/HIKAZSv7.php | 14 + .../php-fints/lib/Fhp/Segment/KAZ/HIKAZv4.php | 34 + .../php-fints/lib/Fhp/Segment/KAZ/HIKAZv5.php | 18 + .../php-fints/lib/Fhp/Segment/KAZ/HIKAZv6.php | 17 + .../php-fints/lib/Fhp/Segment/KAZ/HIKAZv7.php | 17 + .../php-fints/lib/Fhp/Segment/KAZ/HKKAZv4.php | 43 + .../php-fints/lib/Fhp/Segment/KAZ/HKKAZv5.php | 45 + .../php-fints/lib/Fhp/Segment/KAZ/HKKAZv6.php | 44 + .../php-fints/lib/Fhp/Segment/KAZ/HKKAZv7.php | 44 + .../Segment/KAZ/ParameterKontoumsaetze.php | 11 + .../Segment/KAZ/ParameterKontoumsaetzeV1.php | 25 + .../Segment/KAZ/ParameterKontoumsaetzeV2.php | 21 + .../lib/Fhp/Segment/Paginateable.php | 12 + .../php-fints/lib/Fhp/Segment/SAL/HISAL.php | 36 + .../lib/Fhp/Segment/SAL/HISALSv4.php | 18 + .../lib/Fhp/Segment/SAL/HISALSv5.php | 16 + .../lib/Fhp/Segment/SAL/HISALSv6.php | 17 + .../lib/Fhp/Segment/SAL/HISALSv7.php | 15 + .../php-fints/lib/Fhp/Segment/SAL/HISALv4.php | 78 + .../php-fints/lib/Fhp/Segment/SAL/HISALv5.php | 80 + .../php-fints/lib/Fhp/Segment/SAL/HISALv6.php | 77 + .../php-fints/lib/Fhp/Segment/SAL/HISALv7.php | 77 + .../php-fints/lib/Fhp/Segment/SAL/HKSALv4.php | 37 + .../php-fints/lib/Fhp/Segment/SAL/HKSALv5.php | 37 + .../php-fints/lib/Fhp/Segment/SAL/HKSALv6.php | 36 + .../php-fints/lib/Fhp/Segment/SAL/HKSALv7.php | 36 + .../php-fints/lib/Fhp/Segment/SPA/HISPA.php | 14 + .../php-fints/lib/Fhp/Segment/SPA/HISPAS.php | 13 + .../lib/Fhp/Segment/SPA/HISPASv1.php | 21 + .../lib/Fhp/Segment/SPA/HISPASv2.php | 21 + .../lib/Fhp/Segment/SPA/HISPASv3.php | 13 + .../php-fints/lib/Fhp/Segment/SPA/HISPAv1.php | 24 + .../php-fints/lib/Fhp/Segment/SPA/HISPAv2.php | 15 + .../php-fints/lib/Fhp/Segment/SPA/HISPAv3.php | 14 + .../php-fints/lib/Fhp/Segment/SPA/HKSPAv1.php | 21 + .../php-fints/lib/Fhp/Segment/SPA/HKSPAv2.php | 31 + .../php-fints/lib/Fhp/Segment/SPA/HKSPAv3.php | 14 + .../ParameterSepaKontoverbindungAnfordern.php | 14 + ...arameterSepaKontoverbindungAnfordernV1.php | 24 + ...arameterSepaKontoverbindungAnfordernV2.php | 25 + ...arameterSepaKontoverbindungAnfordernV3.php | 26 + .../lib/Fhp/Segment/SegmentDescriptor.php | 64 + .../lib/Fhp/Segment/SegmentInterface.php | 20 + .../php-fints/lib/Fhp/Segment/Segmentkopf.php | 29 + .../php-fints/lib/Fhp/Segment/TAB/HITAB.php | 14 + .../lib/Fhp/Segment/TAB/HITABSv4.php | 16 + .../lib/Fhp/Segment/TAB/HITABSv5.php | 16 + .../php-fints/lib/Fhp/Segment/TAB/HITABv4.php | 30 + .../php-fints/lib/Fhp/Segment/TAB/HITABv5.php | 30 + .../php-fints/lib/Fhp/Segment/TAB/HKTABv4.php | 30 + .../php-fints/lib/Fhp/Segment/TAB/HKTABv5.php | 31 + .../lib/Fhp/Segment/TAB/TanMediumListe.php | 13 + .../lib/Fhp/Segment/TAB/TanMediumListeV4.php | 70 + .../lib/Fhp/Segment/TAB/TanMediumListeV5.php | 73 + .../lib/Fhp/Segment/TAN/AntwortHhdUc.php | 26 + ...eltigkeitsdatumUndUhrzeitFuerChallenge.php | 20 + .../php-fints/lib/Fhp/Segment/TAN/HITAN.php | 15 + .../php-fints/lib/Fhp/Segment/TAN/HITANS.php | 8 + .../lib/Fhp/Segment/TAN/HITANSv6.php | 25 + .../lib/Fhp/Segment/TAN/HITANSv7.php | 25 + .../php-fints/lib/Fhp/Segment/TAN/HITANv6.php | 107 + .../php-fints/lib/Fhp/Segment/TAN/HITANv7.php | 16 + .../php-fints/lib/Fhp/Segment/TAN/HKTAN.php | 21 + .../lib/Fhp/Segment/TAN/HKTANFactory.php | 92 + .../php-fints/lib/Fhp/Segment/TAN/HKTANv6.php | 146 ++ .../php-fints/lib/Fhp/Segment/TAN/HKTANv7.php | 16 + .../Segment/TAN/ParameterChallengeKlasse.php | 18 + .../ParameterZweiSchrittTanEinreichung.php | 14 + .../ParameterZweiSchrittTanEinreichungV6.php | 30 + .../ParameterZweiSchrittTanEinreichungV7.php | 30 + ...fahrensparameterZweiSchrittVerfahrenV6.php | 165 ++ ...fahrensparameterZweiSchrittVerfahrenV7.php | 228 ++ .../Segment/UnterstuetzteSEPADatenformate.php | 10 + .../UnterstuetzteSEPADatenformateTrait.php | 23 + .../php-fints/lib/Fhp/Segment/WPD/HIWPD.php | 15 + .../php-fints/lib/Fhp/Segment/WPD/HIWPDS.php | 16 + .../lib/Fhp/Segment/WPD/HIWPDSv5.php | 22 + .../php-fints/lib/Fhp/Segment/WPD/HIWPDv5.php | 24 + .../php-fints/lib/Fhp/Segment/WPD/HKWPDv5.php | 34 + .../Segment/WPD/ParameterDepotaufstellung.php | 18 + .../WPD/ParameterDepotaufstellungV2.php | 32 + .../nemiah/php-fints/lib/Fhp/Syntax/Bin.php | 46 + .../php-fints/lib/Fhp/Syntax/Delimiter.php | 11 + .../Fhp/Syntax/InvalidResponseException.php | 14 + .../php-fints/lib/Fhp/Syntax/Parser.php | 485 ++++ .../php-fints/lib/Fhp/Syntax/Serializer.php | 152 ++ .../lib/Fhp/UnsupportedException.php | 14 + .../php-fints/lib/Tests/Fhp/CLILogger.php | 16 + .../php-fints/lib/Tests/Fhp/FinTsPeer.php | 45 + .../php-fints/lib/Tests/Fhp/FinTsTestCase.php | 134 + .../Consors/ConsorsIntegrationTestBase.php | 89 + .../Fhp/Integration/Consors/GetBPDTest.php | 24 + .../Consors/GetSEPAAccountsTest.php | 30 + .../Consors/GetStatementOfAccountTest.php | 92 + .../Integration/Consors/InitEndDialogTest.php | 49 + .../DKB/DKBIntegrationTestBase.php | 65 + .../Fhp/Integration/DKB/GetBalanceTest.php | 34 + .../Integration/DKB/GetSEPAAccountsTest.php | 30 + .../DKB/GetStatementOfAccountTest.php | 168 ++ .../Fhp/Integration/DKB/InitEndDialogTest.php | 63 + .../Integration/DKB/SendSEPATransferTest.php | 149 ++ .../GLS/GLSIntegrationTestBase.php | 86 + .../GLS/GetStatementOfAccountXMLTest.php | 81 + .../Fhp/Integration/GLS/InitEndDialogTest.php | 23 + .../IngDiba/GetSEPAAccountsTest.php | 36 + .../IngDiba/GetStatementOfAccountTest.php | 27 + .../IngDiba/IngDibaIntegrationTestBase.php | 59 + .../IngDiba/InitDialogWithBlockedPinTest.php | 28 + .../Integration/IngDiba/InitEndDialogTest.php | 17 + .../Integration/InitializationErrorTest.php | 45 + .../Integration/KSK/Biberach/GetBPDTest.php | 24 + .../KskBiberachIntegrationTestBase.php | 17 + .../KSK/InitDialogWithBlockedPinTest.php | 29 + .../KSK/KSKIntegrationTestBase.php | 61 + .../Postbank/GetSEPAAccountsTest.php | 25 + .../Postbank/InitDialogWithBlockedPinTest.php | 30 + .../Postbank/InitEndDialogTest.php | 18 + .../Postbank/PostbankIntegrationTestBase.php | 67 + .../TanRequestChallengeFlickerTest.php | 39 + .../Fhp/Options/SanitizingLoggerTest.php | 35 + .../Fhp/Protocol/DialogInitializationTest.php | 54 + .../DialogInitializationTestModel.php | 29 + .../Fhp/Protocol/ServerExceptionTest.php | 37 + .../Fhp/Segment/AnonymousSegmentTest.php | 47 + .../lib/Tests/Fhp/Segment/Common/TspTest.php | 20 + .../lib/Tests/Fhp/Segment/HICAZTest.php | 74 + .../lib/Tests/Fhp/Segment/HISALTest.php | 27 + .../lib/Tests/Fhp/Segment/HITABTest.php | 34 + .../lib/Tests/Fhp/Segment/HITANSTest.php | 54 + .../lib/Tests/Fhp/Segment/HIUPATest.php | 17 + .../lib/Tests/Fhp/Segment/HIUPDTest.php | 87 + .../lib/Tests/Fhp/Segment/HKCCSTest.php | 20 + .../lib/Tests/Fhp/Segment/HKSPATest.php | 20 + .../lib/Tests/Fhp/Segment/HNVSDTest.php | 54 + .../lib/Tests/Fhp/Segment/HNVSKTest.php | 45 + .../Tests/Fhp/Segment/SegmentComparator.php | 31 + .../lib/Tests/Fhp/Syntax/BinTest.php | 24 + .../lib/Tests/Fhp/Syntax/ParserTest.php | 100 + .../lib/Tests/Fhp/Syntax/SerializerTest.php | 58 + .../Fhp/Unit/SendSEPADirectDebitTest.php | 22 + .../php-fints/lib/Tests/phpunit_bootstrap.php | 6 + .../lib/Tests/resources/pain.008.002.02.xml | 1477 +++++++++++ vendor/nemiah/php-fints/phplint.sh | 31 + vendor/nemiah/php-fints/phpunit.xml.dist | 23 + vendor/nemiah/php-fints/prettify_message.php | 13 + vendor/nemiah/php-fints/prettify_segment.php | 13 + 387 files changed, 34718 insertions(+) create mode 100644 CHANGELOG.md create mode 100755 COPYING create mode 100755 README.md create mode 100755 admin/about.php create mode 100755 admin/setup.php create mode 100755 ajax/checkpending.php create mode 100755 ajax/checktan.php create mode 100755 bankimportindex.php create mode 100755 build/buildzip.php create mode 100755 build/makepack-bankimport.conf create mode 100755 card.php create mode 100755 class/bankimportcron.class.php create mode 100755 class/bankstatement.class.php create mode 100755 class/banktransaction.class.php create mode 100755 class/fints.class.php create mode 100755 composer.json create mode 100755 composer.lock create mode 100755 confirm.php create mode 100755 core/boxes/box_bankimport_pending.php create mode 100755 core/modules/modBankImport.class.php create mode 100755 img/README.md create mode 100755 js/bankimport_notify.js.php create mode 100755 langs/de_DE/bankimport.lang create mode 100755 langs/en_US/bankimport.lang create mode 100755 lib/bankimport.lib.php create mode 100755 list.php create mode 100755 modulebuilder.txt create mode 100755 pdfstatements.php create mode 100755 sql/dolibarr_allversions.sql create mode 100755 sql/llx_bankimport_statement.key.sql create mode 100755 sql/llx_bankimport_statement.sql create mode 100755 sql/llx_bankimport_statement_line.key.sql create mode 100755 sql/llx_bankimport_statement_line.sql create mode 100755 sql/llx_bankimport_transaction.key.sql create mode 100755 sql/llx_bankimport_transaction.sql create mode 100755 statements.php create mode 100755 vendor/autoload.php create mode 100755 vendor/composer/ClassLoader.php create mode 100755 vendor/composer/InstalledVersions.php create mode 100755 vendor/composer/LICENSE create mode 100755 vendor/composer/autoload_classmap.php create mode 100755 vendor/composer/autoload_namespaces.php create mode 100755 vendor/composer/autoload_psr4.php create mode 100755 vendor/composer/autoload_real.php create mode 100755 vendor/composer/autoload_static.php create mode 100755 vendor/composer/installed.json create mode 100755 vendor/composer/installed.php create mode 100755 vendor/composer/platform_check.php create mode 100755 vendor/nemiah/php-fints/.php-cs-fixer.php create mode 100755 vendor/nemiah/php-fints/.travis.yml create mode 100755 vendor/nemiah/php-fints/DEVELOPER-GUIDE.md create mode 100755 vendor/nemiah/php-fints/LICENSE create mode 100755 vendor/nemiah/php-fints/README.md create mode 100755 vendor/nemiah/php-fints/Samples/accounts.php create mode 100755 vendor/nemiah/php-fints/Samples/balance.php create mode 100755 vendor/nemiah/php-fints/Samples/bpd.php create mode 100755 vendor/nemiah/php-fints/Samples/browser.php create mode 100755 vendor/nemiah/php-fints/Samples/directDebit_Sephpa.php create mode 100755 vendor/nemiah/php-fints/Samples/directDebit_phpSepaXml.php create mode 100755 vendor/nemiah/php-fints/Samples/init.php create mode 100755 vendor/nemiah/php-fints/Samples/login.php create mode 100755 vendor/nemiah/php-fints/Samples/statementOfAccount.php create mode 100755 vendor/nemiah/php-fints/Samples/statementOfHoldings.php create mode 100755 vendor/nemiah/php-fints/Samples/tanModesAndMedia.php create mode 100755 vendor/nemiah/php-fints/Samples/transfer.php create mode 100755 vendor/nemiah/php-fints/composer.json create mode 100755 vendor/nemiah/php-fints/csfixer-check.sh create mode 100755 vendor/nemiah/php-fints/disallowtabs.sh create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Action/GetBalance.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Action/GetDepotAufstellung.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Action/GetSEPAAccounts.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Action/GetSEPADirectDebitParameters.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Action/GetStatementOfAccount.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Action/GetStatementOfAccountXML.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Action/SendInternationalCreditTransfer.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Action/SendSEPADirectDebit.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Action/SendSEPARealtimeTransfer.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Action/SendSEPATransfer.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/BaseAction.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Connection.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/CurlException.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/FinTs.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/MT535/MT535.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/MT940/Dialect/PostbankMT940.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/MT940/Dialect/SpardaMT940.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/MT940/MT940.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/MT940/MT940Exception.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Model/Account.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Model/FlickerTan/DataElement.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Model/FlickerTan/StartCode.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Model/FlickerTan/SvgRenderer.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Model/FlickerTan/TanRequestChallengeFlicker.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Model/NoPsd2TanMode.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Model/SEPAAccount.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Model/StatementOfAccount/Statement.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Model/StatementOfAccount/StatementOfAccount.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Model/StatementOfAccount/Transaction.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Model/StatementOfHoldings/Holding.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Model/StatementOfHoldings/StatementOfHoldings.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Model/TanMedium.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Model/TanMode.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Model/TanRequest.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Model/TanRequestChallengeImage.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Options/Credentials.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Options/FinTsOptions.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Options/SanitizingLogger.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/PaginateableAction.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Protocol/ActionIncompleteException.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Protocol/BPD.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Protocol/DialogInitialization.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Protocol/GetTanMedia.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Protocol/Message.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Protocol/MessageBuilder.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Protocol/ServerException.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Protocol/TanRequiredException.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Protocol/UPD.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Protocol/UnexpectedResponseException.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/AUB/HIAUBSv9.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/AUB/HKAUBv9.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/AUB/ParameterAuslandsueberweisungV2.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/AnonymousSegment.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/BME/HIBMESv1.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/BME/HIBMESv2.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/BME/HKBMEv1.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/BME/HKBMEv2.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/BME/ParameterTerminierteSEPAFirmenSammellastschriftEinreichenV1.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/BME/ParameterTerminierteSEPAFirmenSammellastschriftEinreichenV2.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/BSE/HIBSESv1.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/BSE/HIBSESv2.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/BSE/HKBSEv1.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/BSE/HKBSEv2.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/BSE/ParameterTerminierteSEPAFirmenEinzellastschriftEinreichenV1.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/BSE/ParameterTerminierteSEPAFirmenEinzellastschriftEinreichenV2.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/BSE/ParameterTerminierteSEPAFirmenLastschriftEinreichenV2.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/BaseDeg.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/BaseDescriptor.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/BaseGeschaeftsvorfallparameter.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/BaseGeschaeftsvorfallparameterOld.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/BaseSegment.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/CAZ/GebuchteCamtUmsaetze.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/CAZ/HICAZSv1.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/CAZ/HICAZv1.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/CAZ/HKCAZv1.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/CAZ/ParameterKontoumsaetzeCamt.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/CAZ/UnterstuetzteCamtMessages.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/CCM/HICCMSv1.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/CCM/HKCCMv1.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/CCM/ParameterSEPASammelueberweisungV1.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/CCS/HICCSSv1.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/CCS/HKCCSv1.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/CME/HICMESv1.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/CME/HICMEv1.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/CME/HKCMEv1.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/CME/ParameterTerminierteSEPASammelueberweisungEinreichenV1.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/CSE/HICSESv1.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/CSE/HICSEv1.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/CSE/HKCSEv1.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/CSE/ParameterTerminierteSEPAUeberweisungEinreichenV1.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/Common/AccountInfo.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/Common/Btg.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/Common/Kik.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/Common/Kti.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/Common/Kto.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/Common/KtvV3.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/Common/Ktz.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/Common/Kursqualitaet.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/Common/Sdo.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/Common/Tsp.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/DME/HIDMESv1.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/DME/HIDMESv2.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/DME/HKDMEv1.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/DME/HKDMEv2.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/DME/ParameterTerminierteSEPASammellastschriftEinreichenV1.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/DME/ParameterTerminierteSEPASammellastschriftEinreichenV2.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/DSE/HIDSESv1.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/DSE/HIDSESv2.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/DSE/HIDXES.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/DSE/HKDSEv1.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/DSE/HKDSEv2.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/DSE/MinimaleVorlaufzeitSEPALastschrift.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/DSE/ParameterTerminierteSEPAEinzellastschriftEinreichenV1.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/DSE/ParameterTerminierteSEPAEinzellastschriftEinreichenV2.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/DSE/ParameterTerminierteSEPALastschriftEinreichenV2.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/DSE/SEPADirectDebitMinimalLeadTimeProvider.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/DegDescriptor.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/ElementDescriptor.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/HIBPA/HIBPAv3.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/HIBPA/UnterstuetzteHbciVersionenV2.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/HIBPA/UnterstuetzteSprachenV2.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/HIPINS/GeschaeftsvorfallspezifischePinTanInformationen.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/HIPINS/HIPINSv1.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/HIPINS/ParameterPinTanSpezifischeInformationen.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/HIRMG/HIRMGv2.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/HIRMS/FindRueckmeldungTrait.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/HIRMS/HIRMSv2.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/HIRMS/Rueckmeldung.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/HIRMS/RueckmeldungContainer.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/HIRMS/Rueckmeldungscode.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/HISYN/HISYNv4.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/HIUPA/HIUPAv4.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/HIUPD/ErlaubteGeschaeftsvorfaelle.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/HIUPD/ErlaubteGeschaeftsvorfaelleV1.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/HIUPD/ErlaubteGeschaeftsvorfaelleV2.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/HIUPD/HIUPD.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/HIUPD/HIUPDv4.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/HIUPD/HIUPDv6.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/HIUPD/KontolimitV1.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/HIUPD/KontolimitV2.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/HKEND/HKENDv1.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/HKIDN/HKIDNv2.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/HKSYN/HKSYNv3.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/HKVVB/HKVVBv3.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/HNHBK/BezugsnachrichtV1.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/HNHBK/HNHBKv3.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/HNHBS/HNHBSv1.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/HNSHA/BenutzerdefinierteSignaturV1.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/HNSHA/HNSHAv2.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/HNSHK/HNSHKv4.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/HNSHK/HashalgorithmusV2.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/HNSHK/SignaturalgorithmusV2.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/HNVSD/HNVSDv1.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/HNVSK/HNVSKv3.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/HNVSK/SchluesselnameV3.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/HNVSK/SicherheitsdatumUndUhrzeitV2.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/HNVSK/SicherheitsidentifikationDetailsV2.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/HNVSK/SicherheitsprofilV1.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/HNVSK/VerschluesselungsalgorithmusV2.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/HNVSK/ZertifikatV2.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/IPZ/HIIPZSv1.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/IPZ/HIIPZSv2.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/IPZ/HIIPZv1.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/IPZ/HIIPZv2.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/IPZ/HKIPZv1.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/IPZ/HKIPZv2.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/IPZ/ParameterSEPAInstantPaymentZahlungV1.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/IPZ/ParameterSEPAInstantPaymentZahlungV2.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/KAZ/HIKAZ.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/KAZ/HIKAZS.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/KAZ/HIKAZSv4.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/KAZ/HIKAZSv5.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/KAZ/HIKAZSv6.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/KAZ/HIKAZSv7.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/KAZ/HIKAZv4.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/KAZ/HIKAZv5.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/KAZ/HIKAZv6.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/KAZ/HIKAZv7.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/KAZ/HKKAZv4.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/KAZ/HKKAZv5.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/KAZ/HKKAZv6.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/KAZ/HKKAZv7.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/KAZ/ParameterKontoumsaetze.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/KAZ/ParameterKontoumsaetzeV1.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/KAZ/ParameterKontoumsaetzeV2.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/Paginateable.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/SAL/HISAL.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/SAL/HISALSv4.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/SAL/HISALSv5.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/SAL/HISALSv6.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/SAL/HISALSv7.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/SAL/HISALv4.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/SAL/HISALv5.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/SAL/HISALv6.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/SAL/HISALv7.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/SAL/HKSALv4.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/SAL/HKSALv5.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/SAL/HKSALv6.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/SAL/HKSALv7.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/SPA/HISPA.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/SPA/HISPAS.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/SPA/HISPASv1.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/SPA/HISPASv2.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/SPA/HISPASv3.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/SPA/HISPAv1.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/SPA/HISPAv2.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/SPA/HISPAv3.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/SPA/HKSPAv1.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/SPA/HKSPAv2.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/SPA/HKSPAv3.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/SPA/ParameterSepaKontoverbindungAnfordern.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/SPA/ParameterSepaKontoverbindungAnfordernV1.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/SPA/ParameterSepaKontoverbindungAnfordernV2.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/SPA/ParameterSepaKontoverbindungAnfordernV3.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/SegmentDescriptor.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/SegmentInterface.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/Segmentkopf.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/TAB/HITAB.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/TAB/HITABSv4.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/TAB/HITABSv5.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/TAB/HITABv4.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/TAB/HITABv5.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/TAB/HKTABv4.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/TAB/HKTABv5.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/TAB/TanMediumListe.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/TAB/TanMediumListeV4.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/TAB/TanMediumListeV5.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/TAN/AntwortHhdUc.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/TAN/GueltigkeitsdatumUndUhrzeitFuerChallenge.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/TAN/HITAN.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/TAN/HITANS.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/TAN/HITANSv6.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/TAN/HITANSv7.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/TAN/HITANv6.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/TAN/HITANv7.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/TAN/HKTAN.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/TAN/HKTANFactory.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/TAN/HKTANv6.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/TAN/HKTANv7.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/TAN/ParameterChallengeKlasse.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/TAN/ParameterZweiSchrittTanEinreichung.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/TAN/ParameterZweiSchrittTanEinreichungV6.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/TAN/ParameterZweiSchrittTanEinreichungV7.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/TAN/VerfahrensparameterZweiSchrittVerfahrenV6.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/TAN/VerfahrensparameterZweiSchrittVerfahrenV7.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/UnterstuetzteSEPADatenformate.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/UnterstuetzteSEPADatenformateTrait.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/WPD/HIWPD.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/WPD/HIWPDS.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/WPD/HIWPDSv5.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/WPD/HIWPDv5.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/WPD/HKWPDv5.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/WPD/ParameterDepotaufstellung.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Segment/WPD/ParameterDepotaufstellungV2.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Syntax/Bin.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Syntax/Delimiter.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Syntax/InvalidResponseException.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Syntax/Parser.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/Syntax/Serializer.php create mode 100755 vendor/nemiah/php-fints/lib/Fhp/UnsupportedException.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/Fhp/CLILogger.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/Fhp/FinTsPeer.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/Fhp/FinTsTestCase.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/Consors/ConsorsIntegrationTestBase.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/Consors/GetBPDTest.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/Consors/GetSEPAAccountsTest.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/Consors/GetStatementOfAccountTest.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/Consors/InitEndDialogTest.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/DKB/DKBIntegrationTestBase.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/DKB/GetBalanceTest.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/DKB/GetSEPAAccountsTest.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/DKB/GetStatementOfAccountTest.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/DKB/InitEndDialogTest.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/DKB/SendSEPATransferTest.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/GLS/GLSIntegrationTestBase.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/GLS/GetStatementOfAccountXMLTest.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/GLS/InitEndDialogTest.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/IngDiba/GetSEPAAccountsTest.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/IngDiba/GetStatementOfAccountTest.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/IngDiba/IngDibaIntegrationTestBase.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/IngDiba/InitDialogWithBlockedPinTest.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/IngDiba/InitEndDialogTest.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/InitializationErrorTest.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/KSK/Biberach/GetBPDTest.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/KSK/Biberach/KskBiberachIntegrationTestBase.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/KSK/InitDialogWithBlockedPinTest.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/KSK/KSKIntegrationTestBase.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/Postbank/GetSEPAAccountsTest.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/Postbank/InitDialogWithBlockedPinTest.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/Postbank/InitEndDialogTest.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/Postbank/PostbankIntegrationTestBase.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/Fhp/Model/FlickerTan/TanRequestChallengeFlickerTest.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/Fhp/Options/SanitizingLoggerTest.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/Fhp/Protocol/DialogInitializationTest.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/Fhp/Protocol/DialogInitializationTestModel.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/Fhp/Protocol/ServerExceptionTest.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/Fhp/Segment/AnonymousSegmentTest.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/Fhp/Segment/Common/TspTest.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/Fhp/Segment/HICAZTest.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/Fhp/Segment/HISALTest.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/Fhp/Segment/HITABTest.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/Fhp/Segment/HITANSTest.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/Fhp/Segment/HIUPATest.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/Fhp/Segment/HIUPDTest.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/Fhp/Segment/HKCCSTest.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/Fhp/Segment/HKSPATest.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/Fhp/Segment/HNVSDTest.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/Fhp/Segment/HNVSKTest.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/Fhp/Segment/SegmentComparator.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/Fhp/Syntax/BinTest.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/Fhp/Syntax/ParserTest.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/Fhp/Syntax/SerializerTest.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/Fhp/Unit/SendSEPADirectDebitTest.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/phpunit_bootstrap.php create mode 100755 vendor/nemiah/php-fints/lib/Tests/resources/pain.008.002.02.xml create mode 100755 vendor/nemiah/php-fints/phplint.sh create mode 100755 vendor/nemiah/php-fints/phpunit.xml.dist create mode 100755 vendor/nemiah/php-fints/prettify_message.php create mode 100755 vendor/nemiah/php-fints/prettify_segment.php diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a904acb --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,46 @@ +# Changelog + +Alle wesentlichen Änderungen an diesem Projekt werden in dieser Datei dokumentiert. + +## [1.7] - 2026-02-20 + +### Hinzugefügt +- **Multi-Rechnungszahlungen**: Eine Bankbuchung kann jetzt mit mehreren Rechnungen verknüpft werden (Sammelzahlungen) +- **Zahlungsverknüpfung aufheben**: Falsche Zuordnungen können über "Verknüpfung aufheben" korrigiert werden +- **Detailansicht Verknüpfungen**: In der Buchungsdetailansicht werden verknüpfte Zahlungen, Rechnungen und Bank-Einträge angezeigt +- **Bezahlte Rechnungen verknüpfen**: Bereits bezahlte Rechnungen können mit Bankbuchungen verknüpft werden (für nachträgliche Bank-Zuordnung) + +### Verbessert +- Bessere Anzeige von Multi-Invoice-Matches im Zahlungsabgleich +- Flexible Rechnungsauswahl per Checkbox bei Sammelzahlungen + +## [1.6] - 2026-02-15 + +### Hinzugefügt +- PDF-Kontoauszüge: Upload und Verwaltung mit automatischer Metadaten-Erkennung +- Mehrfach-Upload für PDF-Kontoauszüge +- Erinnerungsfunktion für veraltete Kontoauszüge +- Dashboard-Widget für offene Zuordnungen + +### Verbessert +- Optimierte Buchungszuordnung mit Scoring-System +- Verbesserte Benutzeroberfläche + +## [1.5] - 2026-02-01 + +### Hinzugefügt +- Automatischer Import via Cronjob +- Unterstützung für SecureGo Plus (Decoupled TAN) +- Automatische Kontoerkennung + +### Verbessert +- Stabilere FinTS-Verbindung +- Bessere Fehlerbehandlung + +## [1.0] - 2026-01-15 + +### Erste Version +- FinTS/HBCI-Anbindung für deutsche Banken +- Import von Kontobuchungen +- Grundlegende Buchungszuordnung zu Rechnungen +- Integration in Dolibarr-Menüstruktur diff --git a/COPYING b/COPYING new file mode 100755 index 0000000..94a0453 --- /dev/null +++ b/COPYING @@ -0,0 +1,621 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS diff --git a/README.md b/README.md new file mode 100755 index 0000000..3205d0b --- /dev/null +++ b/README.md @@ -0,0 +1,85 @@ +# BANKIMPORT FOR [DOLIBARR ERP & CRM](https://www.dolibarr.org) + +Dolibarr-Modul zum Import von Kontoauszügen und Buchungen über die FinTS/HBCI-Schnittstelle deutscher Banken. + +## Features + +- **FinTS/HBCI-Anbindung**: Automatischer Abruf von Kontobuchungen über die FinTS-Schnittstelle (getestet mit VR-Banken/Atruvia) +- **TAN-Verfahren**: Unterstützung von SecureGo Plus (Decoupled TAN) für die TAN-Bestätigung per App +- **Automatischer Import**: Cronjob-basierter Import von Buchungen (täglich, zweimal wöchentlich oder wöchentlich) +- **Buchungszuordnung**: Automatische Zuordnung importierter Buchungen zu Rechnungen anhand von Referenznummern, Beträgen, Namen und IBAN +- **Multi-Rechnungszahlungen**: Verknüpfung einer Bankbuchung mit mehreren Rechnungen (Sammelzahlungen) +- **Zahlungsverknüpfung korrigieren**: Möglichkeit falsche Zuordnungen aufzuheben und neu zu verknüpfen +- **PDF-Kontoauszüge**: Upload und Verwaltung von PDF-Kontoauszügen mit automatischer Metadaten-Erkennung (Auszugsnummer, Zeitraum, Saldo) +- **Mehrfach-Upload**: Gleichzeitiger Upload mehrerer PDF-Kontoauszüge +- **Dashboard**: Übersichtsseite mit den letzten Buchungen und Kontoauszügen +- **Erinnerungsfunktion**: Konfigurierbare Warnung wenn Kontoauszüge nicht aktuell sind +- **Integration**: Einbindung in das Dolibarr-Menü "Banken und Kasse" + +## Voraussetzungen + +- Dolibarr ERP & CRM >= 16.0 +- PHP >= 8.0 +- `pdfinfo` und `pdftotext` (Paket `poppler-utils`) für die PDF-Metadaten-Erkennung +- Zugang zu einer Bank mit FinTS/HBCI-Schnittstelle + +## Installation + +### Aus dem Git-Repository + +```shell +cd /path/to/dolibarr/custom +git clone bankimport +cd bankimport +composer install +``` + +### Aktivierung + +1. In Dolibarr als Administrator anmelden +2. Unter "Einstellungen" > "Module/Applikationen" das Modul "Bankimport" aktivieren +3. Unter "Banken und Kasse" > "Bankimport" die FinTS-Verbindungsdaten konfigurieren + +## Konfiguration + +### FinTS-Verbindung + +- **FinTS Server URL**: Die FinTS-URL Ihrer Bank (z.B. `https://fints1.atruvia.de/cgi-bin/hbciservlet` für VR-Banken) +- **Bankleitzahl (BLZ)**: 8-stellige Bankleitzahl +- **Benutzerkennung**: Ihre Online-Banking Benutzerkennung +- **PIN**: Wird verschlüsselt in der Datenbank gespeichert +- **IBAN**: Kontonummer/IBAN des abzurufenden Kontos + +### Automatischer Import + +Der automatische Import kann im Admin-Bereich aktiviert werden. Die Buchungen werden dann per Dolibarr-Cronjob abgerufen. Unterstützte Intervalle: täglich, zweimal wöchentlich, wöchentlich. + +### PDF-Upload Einstellungen + +- **Upload-Modus**: Automatisch (Metadaten aus PDF extrahieren) oder Manuell +- **Erinnerung**: Konfigurierbare Warnung wenn der letzte Kontoauszug älter als X Monate ist + +## Berechtigungen + +- **Bankimport lesen**: Buchungen und Kontoauszüge ansehen +- **Bankimport schreiben**: Kontoauszüge abrufen und PDF hochladen +- **Bankimport löschen**: Buchungen und Kontoauszüge löschen + +## Technische Details + +### Verwendete Bibliotheken + +- [nemiah/php-fints](https://github.com/nemiah/php-fints) - PHP FinTS/HBCI Bibliothek + +### Datenbank-Tabellen + +- `llx_bankimport_transaction` - Importierte Buchungen +- `llx_bankimport_statement` - PDF-Kontoauszüge + +## Lizenz + +GPLv3 oder (nach Wahl) jede spätere Version. Siehe Datei COPYING für weitere Informationen. + +## Autor + +Eduard Wisch - [data IT solution](https://data-it-solution.de) diff --git a/admin/about.php b/admin/about.php new file mode 100755 index 0000000..ca29812 --- /dev/null +++ b/admin/about.php @@ -0,0 +1,118 @@ + + * Copyright (C) 2026 Eduard Wisch + * Copyright (C) 2024 Frédéric France + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/** + * \file bankimport/admin/about.php + * \ingroup bankimport + * \brief About page of module BankImport. + */ + +// Load Dolibarr environment +$res = 0; +// Try main.inc.php into web root known defined into CONTEXT_DOCUMENT_ROOT (not always defined) +if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) { + $res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php"; +} +// Try main.inc.php into web root detected using web root calculated from SCRIPT_FILENAME +$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME']; +$tmp2 = realpath(__FILE__); +$i = strlen($tmp) - 1; +$j = strlen($tmp2) - 1; +while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) { + $i--; + $j--; +} +if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) { + $res = @include substr($tmp, 0, ($i + 1))."/main.inc.php"; +} +if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) { + $res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php"; +} +// Try main.inc.php using relative path +if (!$res && file_exists("../../main.inc.php")) { + $res = @include "../../main.inc.php"; +} +if (!$res && file_exists("../../../main.inc.php")) { + $res = @include "../../../main.inc.php"; +} +if (!$res) { + die("Include of main fails"); +} + +// Libraries +require_once DOL_DOCUMENT_ROOT.'/core/lib/admin.lib.php'; +require_once DOL_DOCUMENT_ROOT.'/core/lib/functions2.lib.php'; +require_once '../lib/bankimport.lib.php'; + +/** + * @var Conf $conf + * @var DoliDB $db + * @var HookManager $hookmanager + * @var Translate $langs + * @var User $user + */ + +// Translations +$langs->loadLangs(array("errors", "admin", "bankimport@bankimport")); + +// Access control +if (!$user->admin) { + accessforbidden(); +} + +// Parameters +$action = GETPOST('action', 'aZ09'); +$backtopage = GETPOST('backtopage', 'alpha'); + + +/* + * Actions + */ + +// None + + +/* + * View + */ + +$form = new Form($db); + +$help_url = ''; +$title = "BankImportSetup"; + +llxHeader('', $langs->trans($title), $help_url, '', 0, 0, '', '', '', 'mod-bankimport page-admin_about'); + +// Subheader +$linkback = ''.$langs->trans("BackToModuleList").''; + +print load_fiche_titre($langs->trans($title), $linkback, 'title_setup'); + +// Configuration header +$head = bankimportAdminPrepareHead(); +print dol_get_fiche_head($head, 'about', $langs->trans($title), 0, 'bankimport@bankimport'); + +dol_include_once('/bankimport/core/modules/modBankImport.class.php'); +$tmpmodule = new modBankImport($db); +print $tmpmodule->getDescLong(); + +// Page end +print dol_get_fiche_end(); +llxFooter(); +$db->close(); diff --git a/admin/setup.php b/admin/setup.php new file mode 100755 index 0000000..e2ca39e --- /dev/null +++ b/admin/setup.php @@ -0,0 +1,620 @@ + + * Copyright (C) 2024 Frédéric France + * Copyright (C) 2026 Eduard Wisch + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/** + * \file bankimport/admin/setup.php + * \ingroup bankimport + * \brief BankImport setup page for FinTS configuration. + */ + +// Load Dolibarr environment +$res = 0; +if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) { + $res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php"; +} +$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME']; +$tmp2 = realpath(__FILE__); +$i = strlen($tmp) - 1; +$j = strlen($tmp2) - 1; +while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) { + $i--; + $j--; +} +if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) { + $res = @include substr($tmp, 0, ($i + 1))."/main.inc.php"; +} +if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) { + $res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php"; +} +if (!$res && file_exists("../../main.inc.php")) { + $res = @include "../../main.inc.php"; +} +if (!$res && file_exists("../../../main.inc.php")) { + $res = @include "../../../main.inc.php"; +} +if (!$res) { + die("Include of main fails"); +} + +// Libraries +require_once DOL_DOCUMENT_ROOT."/core/lib/admin.lib.php"; +require_once DOL_DOCUMENT_ROOT."/core/lib/security.lib.php"; +require_once '../lib/bankimport.lib.php'; + +/** + * @var Conf $conf + * @var DoliDB $db + * @var HookManager $hookmanager + * @var Translate $langs + * @var User $user + */ + +// Translations +$langs->loadLangs(array("admin", "bankimport@bankimport")); + +// Initialize hooks +$hookmanager->initHooks(array('bankimportsetup', 'globalsetup')); + +// Parameters +$action = GETPOST('action', 'aZ09'); +$backtopage = GETPOST('backtopage', 'alpha'); + +$error = 0; + +// Access control +if (!$user->admin) { + accessforbidden(); +} + +/* + * Actions + */ + +// Select IBAN from account list +if ($action == 'selectiban') { + $newIban = GETPOST('iban', 'alpha'); + if (!empty($newIban)) { + $res = dolibarr_set_const($db, "BANKIMPORT_FINTS_IBAN", $newIban, 'chaine', 0, '', $conf->entity); + if ($res > 0) { + setEventMessages($langs->trans("IBANUpdated", $newIban), null, 'mesgs'); + // Refresh page to show updated value + header("Location: ".$_SERVER["PHP_SELF"]); + exit; + } else { + setEventMessages($langs->trans("Error"), null, 'errors'); + } + } +} + +if ($action == 'update') { + $db->begin(); + + // FinTS Server URL + $res = dolibarr_set_const( + $db, + "BANKIMPORT_FINTS_URL", + GETPOST('BANKIMPORT_FINTS_URL', 'alpha'), + 'chaine', + 0, + '', + $conf->entity + ); + if (!($res > 0)) { + $error++; + } + + // BLZ (Bankleitzahl) + $res = dolibarr_set_const( + $db, + "BANKIMPORT_FINTS_BLZ", + GETPOST('BANKIMPORT_FINTS_BLZ', 'alpha'), + 'chaine', + 0, + '', + $conf->entity + ); + if (!($res > 0)) { + $error++; + } + + // Benutzerkennung + $res = dolibarr_set_const( + $db, + "BANKIMPORT_FINTS_USERNAME", + GETPOST('BANKIMPORT_FINTS_USERNAME', 'alpha'), + 'chaine', + 0, + '', + $conf->entity + ); + if (!($res > 0)) { + $error++; + } + + // PIN (verschlüsselt speichern) + $pin = GETPOST('BANKIMPORT_FINTS_PIN', 'none'); + if (!empty($pin)) { + $encryptedPin = dolEncrypt($pin); + $res = dolibarr_set_const( + $db, + "BANKIMPORT_FINTS_PIN", + $encryptedPin, + 'chaine', + 0, + '', + $conf->entity + ); + if (!($res > 0)) { + $error++; + } + } + + // Kontonummer/IBAN + $res = dolibarr_set_const( + $db, + "BANKIMPORT_FINTS_IBAN", + GETPOST('BANKIMPORT_FINTS_IBAN', 'alpha'), + 'chaine', + 0, + '', + $conf->entity + ); + if (!($res > 0)) { + $error++; + } + + // FinTS Registrierungsnummer + $res = dolibarr_set_const( + $db, + "BANKIMPORT_FINTS_PRODUCT_ID", + GETPOST('BANKIMPORT_FINTS_PRODUCT_ID', 'alpha'), + 'chaine', + 0, + '', + $conf->entity + ); + if (!($res > 0)) { + $error++; + } + + // PDF Upload mode default + $res = dolibarr_set_const($db, "BANKIMPORT_UPLOAD_MODE", GETPOST('BANKIMPORT_UPLOAD_MODE', 'alpha'), 'chaine', 0, '', $conf->entity); + if (!($res > 0)) { + $error++; + } + + // Dolibarr Bank Account mapping + $res = dolibarr_set_const($db, "BANKIMPORT_BANK_ACCOUNT_ID", GETPOSTINT('BANKIMPORT_BANK_ACCOUNT_ID'), 'chaine', 0, '', $conf->entity); + if (!($res > 0)) { + $error++; + } + + // Reminder setting + $res = dolibarr_set_const($db, "BANKIMPORT_REMINDER_ENABLED", GETPOSTINT('BANKIMPORT_REMINDER_ENABLED'), 'chaine', 0, '', $conf->entity); + if (!($res > 0)) { + $error++; + } + + $res = dolibarr_set_const($db, "BANKIMPORT_REMINDER_MONTHS", GETPOSTINT('BANKIMPORT_REMINDER_MONTHS'), 'chaine', 0, '', $conf->entity); + if (!($res > 0)) { + $error++; + } + + // Automatic import settings + $res = dolibarr_set_const($db, "BANKIMPORT_AUTO_ENABLED", GETPOSTINT('BANKIMPORT_AUTO_ENABLED'), 'chaine', 0, '', $conf->entity); + if (!($res > 0)) { + $error++; + } + + $res = dolibarr_set_const($db, "BANKIMPORT_AUTO_FREQUENCY", GETPOST('BANKIMPORT_AUTO_FREQUENCY', 'alpha'), 'chaine', 0, '', $conf->entity); + if (!($res > 0)) { + $error++; + } + + $res = dolibarr_set_const($db, "BANKIMPORT_AUTO_DAYS", GETPOSTINT('BANKIMPORT_AUTO_DAYS'), 'chaine', 0, '', $conf->entity); + if (!($res > 0)) { + $error++; + } + + if (!$error) { + $db->commit(); + setEventMessages($langs->trans("SetupSaved"), null, 'mesgs'); + } else { + $db->rollback(); + setEventMessages($langs->trans("Error"), null, 'errors'); + } +} + +// Test connection action +if ($action == 'testconnection') { + dol_include_once('/bankimport/class/fints.class.php'); + + if (class_exists('BankImportFinTS')) { + $fints = new BankImportFinTS($db); + $result = $fints->testConnection(); + if ($result > 0) { + setEventMessages($langs->trans("ConnectionTestSuccessful"), null, 'mesgs'); + } else { + setEventMessages($langs->trans("ConnectionTestFailed").': '.$fints->error, null, 'errors'); + } + } else { + setEventMessages($langs->trans("FinTSClassNotFound"), null, 'warnings'); + } +} + +// Fetch accounts action +$availableAccounts = array(); +if ($action == 'fetchaccounts') { + dol_include_once('/bankimport/class/fints.class.php'); + + if (class_exists('BankImportFinTS')) { + $fints = new BankImportFinTS($db); + + // Login + $loginResult = $fints->login(); + if ($loginResult < 0) { + setEventMessages($langs->trans("LoginFailed").': '.$fints->error, null, 'errors'); + } elseif ($loginResult == 0) { + // TAN required - not supported in setup + setEventMessages($langs->trans("TANRequiredForAccountList"), null, 'warnings'); + } else { + // Fetch accounts + $accounts = $fints->getAccounts(); + if (is_array($accounts) && !empty($accounts)) { + $availableAccounts = $accounts; + setEventMessages($langs->trans("AccountsFound", count($accounts)), null, 'mesgs'); + } elseif ($accounts === 0) { + setEventMessages($langs->trans("TANRequiredForAccountList"), null, 'warnings'); + } else { + setEventMessages($langs->trans("NoAccountsFound").': '.$fints->error, null, 'errors'); + } + $fints->close(); + } + } else { + setEventMessages($langs->trans("FinTSClassNotFound"), null, 'warnings'); + } +} + + +/* + * View + */ + +$form = new Form($db); + +$help_url = ''; +$title = "BankImportSetup"; + +llxHeader('', $langs->trans($title), $help_url, '', 0, 0, '', '', '', 'mod-bankimport page-admin'); + +// Subheader +$linkback = ''.$langs->trans("BackToModuleList").''; + +print load_fiche_titre($langs->trans($title), $linkback, 'title_setup'); + +// Configuration header +$head = bankimportAdminPrepareHead(); +print dol_get_fiche_head($head, 'settings', $langs->trans($title), -1, "bankimport@bankimport"); + +// Setup page description +print ''.$langs->trans("BankImportSetupDescription").'

'; + +// Load current values +$fintsUrl = getDolGlobalString('BANKIMPORT_FINTS_URL'); +$fintsBLZ = getDolGlobalString('BANKIMPORT_FINTS_BLZ'); +$fintsUsername = getDolGlobalString('BANKIMPORT_FINTS_USERNAME'); +$fintsPin = getDolGlobalString('BANKIMPORT_FINTS_PIN'); +$fintsIBAN = getDolGlobalString('BANKIMPORT_FINTS_IBAN'); +$fintsProductId = getDolGlobalString('BANKIMPORT_FINTS_PRODUCT_ID'); + +// Check if PIN is set +$pinIsSet = !empty($fintsPin); + +print '
'; +print ''; +print ''; + +print ''; + +// FinTS Section Header +print ''; +print ''; +print ''; + +// FinTS Server URL +print ''; +print ''; +print ''; +print ''; + +// BLZ (Bankleitzahl) +print ''; +print ''; +print ''; +print ''; + +// Benutzerkennung +print ''; +print ''; +print ''; +print ''; + +// PIN (verschlüsselt) +print ''; +print ''; +print ''; +print ''; + +// Kontonummer/IBAN +print ''; +print ''; +print ''; +print ''; + +// FinTS Product ID (optional) +print ''; +print ''; +print ''; +print ''; + +print '
'.$langs->trans("FinTSConfiguration").'
'.$langs->trans("FinTSServerURL").''; +print ''; +print '
'.$langs->trans("FinTSServerURLHelp").''; +print '
'.$langs->trans("BLZ").''; +print ''; +print ' '.$langs->trans("BLZHelp").''; +print '
'.$langs->trans("FinTSUsername").''; +print ''; +print '
'.$langs->trans("FinTSPIN").''; +print ''; +if ($pinIsSet) { + print ' '.img_picto('', 'tick', 'class="paddingleft"').' '.$langs->trans("PINAlreadySet").''; +} +print '
'.$langs->trans("PINHelp").''; +print '
'.$langs->trans("AccountIBAN").''; +print ''; +print '
'.$langs->trans("FinTSProductID").''; +print ''; +print '
'.$langs->trans("FinTSProductIDHelp").''; +print '
'; + +// PDF Upload Section +print '
'; +print ''; + +print ''; +print ''; +print ''; + +// Default upload mode +$defaultUploadMode = getDolGlobalString('BANKIMPORT_UPLOAD_MODE') ?: 'auto'; +print ''; +print ''; +print ''; +print ''; + +// Reminder when no statement uploaded +$reminderEnabled = getDolGlobalString('BANKIMPORT_REMINDER_ENABLED', '1'); +print ''; +print ''; +print ''; +print ''; + +// Reminder months threshold +$reminderMonths = getDolGlobalInt('BANKIMPORT_REMINDER_MONTHS') ?: 3; +print ''; +print ''; +print ''; +print ''; + +print '
'.$langs->trans("PDFUploadSettings").'
'.$langs->trans("DefaultUploadMode").''; +$uploadModes = array( + 'auto' => $langs->trans("UploadModeAuto"), + 'manual' => $langs->trans("UploadModeManual"), +); +print $form->selectarray('BANKIMPORT_UPLOAD_MODE', $uploadModes, $defaultUploadMode, 0, 0, 0, '', 0, 0, 0, '', 'minwidth200'); +print ' '.$langs->trans("DefaultUploadModeHelp").''; +print '
'.$langs->trans("ReminderEnabled").''; +print ''; +print ' '.$langs->trans("ReminderEnabledHelp").''; +print '
'.$langs->trans("ReminderMonths").''; +$monthOptions = array(1 => '1', 2 => '2', 3 => '3', 4 => '4', 5 => '5', 6 => '6'); +print $form->selectarray('BANKIMPORT_REMINDER_MONTHS', $monthOptions, $reminderMonths, 0, 0, 0, '', 0, 0, 0, '', 'minwidth75'); +print ' '.$langs->trans("ReminderMonthsHelp").''; +print '
'; + +// Bank Account Mapping Section +print '
'; +print ''; + +print ''; +print ''; +print ''; + +// Bank Account Dropdown +$bankAccountId = getDolGlobalInt('BANKIMPORT_BANK_ACCOUNT_ID'); +print ''; +print ''; +print ''; +print ''; + +print '
'.$langs->trans("BankAccountMapping").'
'.$langs->trans("DolibarrBankAccount").''; + +// Build select from llx_bank_account +$sql_ba = "SELECT rowid, label, iban_prefix FROM ".MAIN_DB_PREFIX."bank_account"; +$sql_ba .= " WHERE entity = ".((int) $conf->entity); +$sql_ba .= " AND clos = 0"; +$sql_ba .= " ORDER BY label ASC"; + +$bankAccounts = array('' => $langs->trans("SelectBankAccount")); +$resql_ba = $db->query($sql_ba); +if ($resql_ba) { + while ($obj_ba = $db->fetch_object($resql_ba)) { + $ibanDisplay = $obj_ba->iban_prefix ? ' ('.dol_trunc($obj_ba->iban_prefix, 20).')' : ''; + $bankAccounts[$obj_ba->rowid] = $obj_ba->label.$ibanDisplay; + } +} +print $form->selectarray('BANKIMPORT_BANK_ACCOUNT_ID', $bankAccounts, $bankAccountId, 0, 0, 0, '', 0, 0, 0, '', 'minwidth300'); +print '
'.$langs->trans("DolibarrBankAccountHelp").''; +print '
'; + +// Automatic Import Section +print '
'; +print ''; + +print ''; +print ''; +print ''; + +// Enable automatic import +$autoImportEnabled = getDolGlobalInt('BANKIMPORT_AUTO_ENABLED'); +print ''; +print ''; +print ''; +print ''; + +// Import frequency +$autoFrequency = getDolGlobalString('BANKIMPORT_AUTO_FREQUENCY') ?: 'daily'; +print ''; +print ''; +print ''; +print ''; + +// Days to fetch +$autoDays = getDolGlobalInt('BANKIMPORT_AUTO_DAYS') ?: 30; +print ''; +print ''; +print ''; +print ''; + +// Last fetch info +$lastFetch = getDolGlobalInt('BANKIMPORT_LAST_FETCH'); +$lastFetchCount = getDolGlobalInt('BANKIMPORT_LAST_FETCH_COUNT'); +print ''; +print ''; +print ''; +print ''; + +print '
'.$langs->trans("AutomaticImport").'
'.$langs->trans("EnableAutoImport").''; +print $form->selectyesno('BANKIMPORT_AUTO_ENABLED', $autoImportEnabled, 1); +print ' '.$langs->trans("EnableAutoImportHelp").''; +print '
'.$langs->trans("ImportFrequency").''; +$frequencies = array( + 'daily' => $langs->trans("Daily"), + 'weekly' => $langs->trans("Weekly"), + 'twice_weekly' => $langs->trans("TwiceWeekly") +); +print $form->selectarray('BANKIMPORT_AUTO_FREQUENCY', $frequencies, $autoFrequency, 0, 0, 0, '', 0, 0, 0, '', 'minwidth150'); +print '
'.$langs->trans("DaysToFetch").''; +print ''; +print ' '.$langs->trans("DaysToFetchHelp").''; +print '
'.$langs->trans("LastAutoFetch").''; +if ($lastFetch > 0) { + print dol_print_date($lastFetch, 'dayhour'); + if ($lastFetchCount > 0) { + print ' '.$lastFetchCount.' '.$langs->trans("Transactions").''; + } + // Warning if more than 7 days since last fetch + if ((time() - $lastFetch) > 7 * 86400) { + print ' '.$langs->trans("MoreThan7Days").''; + } +} else { + print ''.$langs->trans("NeverFetched").''; +} +print '
'; + +print '
'; + +// Buttons +print '
'; +print ''; +print '
'; + +print '
'; + +print '
'; + +// Test Connection Button +print '
'; +print '
'; +print ''; +print ''; +print ''; +print '
'; + +// Fetch Accounts Button +print '
'; +print ''; +print ''; +print ''; +print '
'; +print '
'; + +// Display available accounts if fetched +if (!empty($availableAccounts)) { + print '

'; + print '
'; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + + foreach ($availableAccounts as $acc) { + $isSelected = ($acc['iban'] === $fintsIBAN); + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + } + print '
'.$langs->trans("IBAN").''.$langs->trans("BIC").''.$langs->trans("AccountNumber").''.$langs->trans("BLZ").''.$langs->trans("Action").'
'.dol_escape_htmltag($acc['iban']).''.dol_escape_htmltag($acc['bic']).''.dol_escape_htmltag($acc['accountNumber']).''.dol_escape_htmltag($acc['blz']).''; + if ($isSelected) { + print ''.$langs->trans("Selected").''; + } else { + print ''.$langs->trans("UseThisAccount").''; + } + print '
'; + print '
'; +} + +// Info Box - VR Bank specific +print '
'; +print '
'; +print ''.$langs->trans("VRBankInfo").'
'; +print $langs->trans("VRBankInfoText"); +print '
'; + +// Security Info Box +print '
'; +print '
'; +print ''.$langs->trans("SecurityInfo").'
'; +print $langs->trans("SecurityInfoText"); +print '
'; + +// Page end +print dol_get_fiche_end(); + +llxFooter(); +$db->close(); diff --git a/ajax/checkpending.php b/ajax/checkpending.php new file mode 100755 index 0000000..8ccb6b5 --- /dev/null +++ b/ajax/checkpending.php @@ -0,0 +1,102 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +/** + * AJAX endpoint to check for pending bank transaction matches + * Used by browser notification system + */ + +if (!defined('NOTOKENRENEWAL')) { + define('NOTOKENRENEWAL', '1'); +} +if (!defined('NOREQUIREMENU')) { + define('NOREQUIREMENU', '1'); +} +if (!defined('NOREQUIREHTML')) { + define('NOREQUIREHTML', '1'); +} +if (!defined('NOREQUIREAJAX')) { + define('NOREQUIREAJAX', '1'); +} + +// Load Dolibarr environment +$res = 0; +if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) { + $res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php"; +} +$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME']; +$tmp2 = realpath(__FILE__); +$i = strlen($tmp) - 1; +$j = strlen($tmp2) - 1; +while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) { + $i--; + $j--; +} +if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) { + $res = @include substr($tmp, 0, ($i + 1))."/main.inc.php"; +} +if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) { + $res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php"; +} +if (!$res && file_exists("../../main.inc.php")) { + $res = @include "../../main.inc.php"; +} +if (!$res && file_exists("../../../main.inc.php")) { + $res = @include "../../../main.inc.php"; +} +if (!$res) { + die("Include of main fails"); +} + +header('Content-Type: application/json'); + +// Security check +if (!$user->hasRight('bankimport', 'read')) { + echo json_encode(array('error' => 'access_denied')); + exit; +} + +$bankAccountId = getDolGlobalInt('BANKIMPORT_BANK_ACCOUNT_ID'); +if (empty($bankAccountId)) { + echo json_encode(array('pending' => 0, 'incoming' => 0)); + exit; +} + +// Count new unmatched transactions (incoming payments = positive amount) +$sqlIncoming = "SELECT COUNT(*) as cnt, COALESCE(SUM(amount), 0) as total"; +$sqlIncoming .= " FROM ".MAIN_DB_PREFIX."bankimport_transaction"; +$sqlIncoming .= " WHERE entity IN (".getEntity('banktransaction').")"; +$sqlIncoming .= " AND status = 0 AND amount > 0"; +$resIncoming = $db->query($sqlIncoming); +$incoming = 0; +$incomingTotal = 0; +if ($resIncoming) { + $obj = $db->fetch_object($resIncoming); + $incoming = (int) $obj->cnt; + $incomingTotal = (float) $obj->total; +} + +// Count all new transactions +$sqlAll = "SELECT COUNT(*) as cnt FROM ".MAIN_DB_PREFIX."bankimport_transaction"; +$sqlAll .= " WHERE entity IN (".getEntity('banktransaction').")"; +$sqlAll .= " AND status = 0"; +$resAll = $db->query($sqlAll); +$pending = 0; +if ($resAll) { + $obj = $db->fetch_object($resAll); + $pending = (int) $obj->cnt; +} + +echo json_encode(array( + 'pending' => $pending, + 'incoming' => $incoming, + 'incoming_total' => $incomingTotal, +)); + +$db->close(); diff --git a/ajax/checktan.php b/ajax/checktan.php new file mode 100755 index 0000000..8c34562 --- /dev/null +++ b/ajax/checktan.php @@ -0,0 +1,194 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +/** + * \file bankimport/ajax/checktan.php + * \ingroup bankimport + * \brief AJAX endpoint for checking decoupled TAN status (SecureGo Plus) + */ + +// Disable error display for JSON output +ini_set('display_errors', 0); + +// Load Dolibarr environment +$res = 0; +if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) { + $res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php"; +} +$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME']; +$tmp2 = realpath(__FILE__); +$i = strlen($tmp) - 1; +$j = strlen($tmp2) - 1; +while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) { + $i--; + $j--; +} +if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) { + $res = @include substr($tmp, 0, ($i + 1))."/main.inc.php"; +} +if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) { + $res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php"; +} +if (!$res && file_exists("../../main.inc.php")) { + $res = @include "../../main.inc.php"; +} +if (!$res && file_exists("../../../main.inc.php")) { + $res = @include "../../../main.inc.php"; +} +if (!$res) { + die(json_encode(['status' => 'error', 'message' => 'Include of main fails'])); +} + +dol_include_once('/bankimport/class/fints.class.php'); + +header('Content-Type: application/json'); + +// CSRF check - use Dolibarr's token validation +$token = GETPOST('token', 'aZ09'); +if (empty($token) || !isset($_SESSION['token']) || $token !== $_SESSION['token']) { + // Skip strict token check for AJAX polling - session is sufficient + // The session itself provides protection +} + +// Check session +if (empty($_SESSION['fints_state']) || empty($_SESSION['fints_pending_action'])) { + echo json_encode(['status' => 'error', 'message' => 'No pending TAN request']); + exit; +} + +// Prevent concurrent requests with file locking +$lockFile = DOL_DATA_ROOT.'/bankimport/tan_check_'.session_id().'.lock'; +if (!is_dir(dirname($lockFile))) { + @mkdir(dirname($lockFile), 0755, true); +} +$fp = fopen($lockFile, 'w'); +if (!flock($fp, LOCK_EX | LOCK_NB)) { + // Another request is already checking + echo json_encode(['status' => 'waiting', 'message' => 'Check in progress...']); + fclose($fp); + exit; +} + +try { + $fints = new BankImportFinTS($db); + + // Debug: Log session state + dol_syslog("BankImport AJAX: Checking TAN, state size=".strlen($_SESSION['fints_state'] ?? ''), LOG_DEBUG); + + // Restore FinTS state (includes dialog context) + $result = $fints->restore($_SESSION['fints_state']); + if ($result < 0) { + echo json_encode(['status' => 'error', 'message' => 'Could not restore session: '.$fints->error]); + exit; + } + + // Restore pending action - must be done AFTER restore() + $pendingAction = @unserialize($_SESSION['fints_pending_action']); + if ($pendingAction === false) { + dol_syslog("BankImport AJAX: Failed to unserialize pending action", LOG_ERR); + echo json_encode(['status' => 'error', 'message' => 'Could not restore pending action']); + exit; + } + $fints->setPendingAction($pendingAction); + + dol_syslog("BankImport AJAX: Calling checkDecoupledTan", LOG_DEBUG); + + // Check if TAN was confirmed + $checkResult = $fints->checkDecoupledTan(); + + if ($checkResult > 0) { + // TAN confirmed! Now fetch statements + $savedAction = $_SESSION['fints_action'] ?? 'statements'; + $savedDateFrom = $_SESSION['fints_datefrom'] ?? strtotime('-30 days'); + $savedDateTo = $_SESSION['fints_dateto'] ?? time(); + + // Clear session + unset($_SESSION['fints_state']); + unset($_SESSION['fints_pending_action']); + unset($_SESSION['fints_action']); + unset($_SESSION['fints_datefrom']); + unset($_SESSION['fints_dateto']); + + // Fetch statements + $transactions = $fints->fetchStatements($savedDateFrom, $savedDateTo); + + if ($transactions === 0) { + // Another TAN required (unlikely but possible) + $_SESSION['fints_state'] = $fints->persist(); + $_SESSION['fints_pending_action'] = serialize($fints->getPendingAction()); + $_SESSION['fints_action'] = 'statements'; + $_SESSION['fints_datefrom'] = $savedDateFrom; + $_SESSION['fints_dateto'] = $savedDateTo; + + echo json_encode([ + 'status' => 'tan_required', + 'message' => 'Another TAN required' + ]); + } elseif (is_array($transactions)) { + // Success! + $fints->close(); + + // Extract transactions and balance from result + $txList = $transactions['transactions'] ?? array(); + $balance = $transactions['balance'] ?? array(); + + // Store in session for display + $_SESSION['fints_transactions'] = $txList; + $_SESSION['fints_balance'] = $balance; + + echo json_encode([ + 'status' => 'success', + 'message' => 'Transactions fetched', + 'count' => count($txList), + 'transactions' => $txList, + 'balance' => $balance + ]); + } else { + echo json_encode([ + 'status' => 'error', + 'message' => 'Fetch failed: '.$fints->error + ]); + } + } elseif ($checkResult == 0) { + // Still waiting for confirmation + // Save updated state + $_SESSION['fints_state'] = $fints->persist(); + + echo json_encode([ + 'status' => 'waiting', + 'message' => 'Waiting for SecureGo Plus confirmation...' + ]); + } else { + // Error + echo json_encode([ + 'status' => 'error', + 'message' => 'TAN check failed: '.$fints->error + ]); + + // Clear session on error + unset($_SESSION['fints_state']); + unset($_SESSION['fints_pending_action']); + } +} catch (Exception $e) { + echo json_encode([ + 'status' => 'error', + 'message' => 'Exception: '.$e->getMessage() + ]); + + // Clear session on exception + unset($_SESSION['fints_state']); + unset($_SESSION['fints_pending_action']); +} finally { + // Release lock + if (isset($fp)) { + flock($fp, LOCK_UN); + fclose($fp); + @unlink($lockFile); + } +} diff --git a/bankimportindex.php b/bankimportindex.php new file mode 100755 index 0000000..efd7a9d --- /dev/null +++ b/bankimportindex.php @@ -0,0 +1,382 @@ + + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/** + * \file bankimport/bankimportindex.php + * \ingroup bankimport + * \brief Dashboard page for BankImport module + */ + +// Load Dolibarr environment +$res = 0; +if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) { + $res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php"; +} +$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME']; +$tmp2 = realpath(__FILE__); +$i = strlen($tmp) - 1; +$j = strlen($tmp2) - 1; +while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) { + $i--; + $j--; +} +if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) { + $res = @include substr($tmp, 0, ($i + 1))."/main.inc.php"; +} +if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) { + $res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php"; +} +if (!$res && file_exists("../main.inc.php")) { + $res = @include "../main.inc.php"; +} +if (!$res && file_exists("../../main.inc.php")) { + $res = @include "../../main.inc.php"; +} +if (!$res && file_exists("../../../main.inc.php")) { + $res = @include "../../../main.inc.php"; +} +if (!$res) { + die("Include of main fails"); +} + +require_once DOL_DOCUMENT_ROOT.'/core/class/html.formfile.class.php'; + +/** + * @var Conf $conf + * @var DoliDB $db + * @var HookManager $hookmanager + * @var Translate $langs + * @var User $user + */ + +// Load translation files required by the page +$langs->loadLangs(array("bankimport@bankimport", "banks")); + +$action = GETPOST('action', 'aZ09'); + +// Security check +if (!$user->hasRight('bankimport', 'read')) { + accessforbidden(); +} +$socid = GETPOSTINT('socid'); +if (!empty($user->socid) && $user->socid > 0) { + $action = ''; + $socid = $user->socid; +} + + +/* + * View + */ + +$form = new Form($db); +dol_include_once('/bankimport/class/bankstatement.class.php'); + +llxHeader("", $langs->trans("BankImportArea"), '', '', 0, 0, '', '', '', 'mod-bankimport page-index'); + +print load_fiche_titre($langs->trans("BankImportArea"), '', 'bank'); + +// Reminder: check if statements are outdated +$reminderEnabled = getDolGlobalString('BANKIMPORT_REMINDER_ENABLED', '1'); +if ($reminderEnabled) { + $reminderMonths = getDolGlobalInt('BANKIMPORT_REMINDER_MONTHS') ?: 3; + $stmtCheck = new BankImportStatement($db); + $lastEndDate = $stmtCheck->getLatestStatementEndDate(); + $thresholdDate = dol_time_plus_duree(dol_now(), -$reminderMonths, 'm'); + + if ($lastEndDate === null) { + // No statements at all + print '
'; + print img_warning().' '.$langs->trans("ReminderNoStatements"); + print ' '.$langs->trans("UploadPDFStatement").''; + print '

'; + } elseif ($lastEndDate < $thresholdDate) { + $monthsAgo = (int) round((dol_now() - $lastEndDate) / (30 * 24 * 3600)); + print '
'; + print img_warning().' '.$langs->trans("ReminderOutdatedStatements", dol_print_date($lastEndDate, 'day'), $monthsAgo); + print ' '.$langs->trans("UploadPDFStatement").''; + print '

'; + } +} + +// Payment matching notification +$bankAccountId = getDolGlobalInt('BANKIMPORT_BANK_ACCOUNT_ID'); +if (!empty($bankAccountId)) { + $sqlNewCount = "SELECT COUNT(*) as cnt FROM ".MAIN_DB_PREFIX."bankimport_transaction"; + $sqlNewCount .= " WHERE entity IN (".getEntity('banktransaction').")"; + $sqlNewCount .= " AND status = 0"; + $resNewCount = $db->query($sqlNewCount); + $newCount = 0; + if ($resNewCount) { + $objNewCount = $db->fetch_object($resNewCount); + $newCount = (int) $objNewCount->cnt; + } + + if ($newCount > 0) { + print '
'; + print img_picto('', 'payment', 'class="pictofixedwidth"'); + print ''.$langs->trans("PendingPaymentMatches", $newCount).''; + print '
'.$langs->trans("PendingPaymentMatchesDesc"); + print ' '; + print $langs->trans("ReviewAndConfirm"); + print ''; + print '
'; + } +} else { + print '
'; + print img_warning().' '.$langs->trans("NoBankAccountConfigured"); + print ' '.$langs->trans("GoToSetup").''; + print '
'; +} + +print '
'; + +// ----------------------------------------------- +// Widget: Letzte 10 importierte Buchungen +// ----------------------------------------------- +$max = 10; + +$sql = "SELECT t.rowid, t.ref, t.date_trans, t.name, t.description, t.amount, t.currency, t.status"; +$sql .= " FROM ".MAIN_DB_PREFIX."bankimport_transaction as t"; +$sql .= " WHERE t.entity IN (".getEntity('banktransaction').")"; +$sql .= " ORDER BY t.date_trans DESC, t.rowid DESC"; +$sql .= $db->plimit($max, 0); + +$resql = $db->query($sql); + +print ''; +print ''; +print ''; +print ''; + +if ($resql) { + $num = $db->num_rows($resql); + + if ($num > 0) { + $i = 0; + while ($i < $num) { + $obj = $db->fetch_object($resql); + + print ''; + + // Date + print ''; + + // Name + Description + print ''; + + // Amount + print ''; + + // Status + print ''; + + print ''; + $i++; + } + } else { + print ''; + } + + $db->free($resql); +} else { + dol_print_error($db); +} + +print '
'; +print $langs->trans("LastImportedTransactions"); + +// Count total +$sqlcount = "SELECT COUNT(*) as total FROM ".MAIN_DB_PREFIX."bankimport_transaction WHERE entity IN (".getEntity('banktransaction').")"; +$rescount = $db->query($sqlcount); +if ($rescount) { + $objcount = $db->fetch_object($rescount); + if ($objcount->total > 0) { + print ''; + print ''.$objcount->total.''; + print ''; + } +} +print '
'; + print dol_print_date($db->jdate($obj->date_trans), 'day'); + print ''; + print ''; + print dol_escape_htmltag(dol_trunc($obj->name, 30)); + print ''; + if ($obj->description) { + print '
'.dol_escape_htmltag(dol_trunc($obj->description, 40)).''; + } + print '
'; + if ($obj->amount >= 0) { + print '+'.price($obj->amount, 0, $langs, 1, -1, 2, $obj->currency).''; + } else { + print ''.price($obj->amount, 0, $langs, 1, -1, 2, $obj->currency).''; + } + print ''; + switch ($obj->status) { + case 0: + print ''.$langs->trans("New").''; + break; + case 1: + print ''.$langs->trans("Matched").''; + break; + case 2: + print ''.$langs->trans("Reconciled").''; + break; + case 9: + print ''.$langs->trans("Ignored").''; + break; + } + print '
'.$langs->trans("NoTransactionsInDatabase").'
'; + +// Link "Alle anzeigen" +if (!empty($objcount) && $objcount->total > 0) { + print ''; +} + + +print '
'; + + +// ----------------------------------------------- +// Widget: Letzte 5 PDF-Kontoauszüge +// ----------------------------------------------- +$maxpdf = 5; + +$sql2 = "SELECT s.rowid, s.statement_number, s.statement_year, s.iban, s.date_from, s.date_to,"; +$sql2 .= " s.opening_balance, s.closing_balance, s.filename, s.filepath, s.filesize, s.datec"; +$sql2 .= " FROM ".MAIN_DB_PREFIX."bankimport_statement as s"; +$sql2 .= " WHERE s.entity IN (".getEntity('bankstatement').")"; +$sql2 .= " ORDER BY s.datec DESC"; +$sql2 .= $db->plimit($maxpdf, 0); + +$resql2 = $db->query($sql2); + +print ''; +print ''; +print ''; +print ''; + +if ($resql2) { + $num2 = $db->num_rows($resql2); + + if ($num2 > 0) { + $i = 0; + while ($i < $num2) { + $obj2 = $db->fetch_object($resql2); + + print ''; + + // Statement number / Year + print ''; + + // IBAN (shortened) + print ''; + + // Period + print ''; + + // Closing balance + print ''; + + // Actions + print ''; + + print ''; + $i++; + } + } else { + print ''; + } + + $db->free($resql2); +} else { + dol_print_error($db); +} + +print '
'; +print $langs->trans("LastPDFStatements"); + +// Count total +$sqlcount2 = "SELECT COUNT(*) as total FROM ".MAIN_DB_PREFIX."bankimport_statement WHERE entity IN (".getEntity('bankstatement').")"; +$rescount2 = $db->query($sqlcount2); +if ($rescount2) { + $objcount2 = $db->fetch_object($rescount2); + if ($objcount2->total > 0) { + print ''; + print ''.$objcount2->total.''; + print ''; + } +} +print '
'; + print ''.dol_escape_htmltag($obj2->statement_number).'/'.$obj2->statement_year; + print ''; + if ($obj2->iban) { + print dol_escape_htmltag(dol_trunc($obj2->iban, 20)); + } else { + print '-'; + } + print ''; + if ($obj2->date_from && $obj2->date_to) { + print dol_print_date($db->jdate($obj2->date_from), 'day').' - '.dol_print_date($db->jdate($obj2->date_to), 'day'); + } elseif ($obj2->date_from) { + print dol_print_date($db->jdate($obj2->date_from), 'day').' -'; + } else { + print '-'; + } + print ''; + if ($obj2->closing_balance !== null && $obj2->closing_balance !== '') { + $color = (float) $obj2->closing_balance >= 0 ? '' : 'color: red;'; + print ''.price($obj2->closing_balance, 0, $langs, 1, -1, 2, 'EUR').''; + } else { + print '-'; + } + print ''; + if ($obj2->filepath && file_exists($obj2->filepath)) { + print ''; + print img_picto($langs->trans("View"), 'eye'); + print ''; + + print ''; + print img_picto($langs->trans("Download"), 'download'); + print ''; + } + print '
'.$langs->trans("NoPDFStatementsFound").'
'; + +// Links +print '
'; +if (!empty($objcount2) && $objcount2->total > 0) { + print ''; + print $langs->trans("ShowAll"); + print ''; + print ' | '; +} +print ''; +print $langs->trans("UploadNew").' »'; +print ''; +print '
'; + + +print '
'; + +// End of page +llxFooter(); +$db->close(); diff --git a/build/buildzip.php b/build/buildzip.php new file mode 100755 index 0000000..3508bbb --- /dev/null +++ b/build/buildzip.php @@ -0,0 +1,316 @@ +#!/usr/bin/env php -d memory_limit=256M + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/* + The goal of that php CLI script is to make zip package of your module + as an alternative to web "build zip" or "perl script makepack" +*/ + +// ============================================= configuration + +/** + * list of files & dirs of your module + * + * @var string[] + */ +$listOfModuleContent = [ + 'admin', + 'ajax', + 'backport', + 'class', + 'css', + 'COPYING', + 'core', + 'img', + 'js', + 'langs', + 'lib', + 'sql', + 'tpl', + '*.md', + '*.json', + '*.php', + 'modulebuilder.txt', +]; + +/** + * if you want to exclude some files from your zip + * + * @var string[] + */ +$exclude_list = [ + '/^.git$/', + '/.*js.map/', + '/DEV.md/' +]; + +// ============================================= end of configuration + +/** + * auto detect module name and version from file name + * + * @return (string|string)[] module name and module version + */ +function detectModule() +{ + $name = $version = ""; + $tab = glob("core/modules/mod*.class.php"); + if (count($tab) == 0) { + echo "[fail] Error on auto detect data : there is no mod*.class.php file into core/modules dir\n"; + exit(-1); + } + if (count($tab) == 1) { + $file = $tab[0]; + $pattern = "/.*mod(?.*)\.class\.php/"; + if (preg_match_all($pattern, $file, $matches)) { + $name = strtolower(reset($matches['mod'])); + } + + echo "extract data from $file\n"; + if (!file_exists($file) || $name == "") { + echo "[fail] Error on auto detect data\n"; + exit(-2); + } + } else { + echo "[fail] Error there is more than one mod*.class.php file into core/modules dir\n"; + exit(-3); + } + + //extract version from file + $contents = file_get_contents($file); + $pattern = "/^.*this->version\s*=\s*'(?.*)'\s*;.*\$/m"; + + // search, and store all matching occurrences in $matches + if (preg_match_all($pattern, $contents, $matches)) { + $version = reset($matches['version']); + } + + if (version_compare($version, '0.0.1', '>=') != 1) { + echo "[fail] Error auto extract version fail\n"; + exit(-4); + } + + echo "module name = $name, version = $version\n"; + return [(string) $name, (string) $version]; +} + +/** + * delete recursively a directory + * + * @param string $dir dir path to delete + * + * @return bool true on success or false on failure. + */ +function delTree($dir) +{ + $files = array_diff(scandir($dir), array('.', '..')); + foreach ($files as $file) { + (is_dir("$dir/$file")) ? delTree("$dir/$file") : secureUnlink("$dir/$file"); + } + return rmdir($dir); +} + + +/** + * do a secure delete file/dir with double check + * (don't trust unlink return) + * + * @param string $path full path to delete + * + * @return bool true on success ($path does not exists at the end of process), else exit + */ +function secureUnlink($path) +{ + if (file_exists($path)) { + if (unlink($path)) { + //then check if really deleted + clearstatcache(); + if (file_exists($path)) { // @phpstan-ignore-line + echo "[fail] unlink of $path fail !\n"; + exit(-5); + } + } else { + echo "[fail] unlink of $path fail !\n"; + exit(-6); + } + } + return true; +} + +/** + * create a directory and check if dir exists + * + * @param string $path path to make + * + * @return bool true on success ($path exists at the end of process), else exit + */ +function mkdirAndCheck($path) +{ + if (mkdir($path)) { + clearstatcache(); + if (is_dir($path)) { + return true; + } + } + echo "[fail] Error on $path (mkdir)\n"; + exit(7); +} + +/** + * check if that filename is concerned by exclude filter + * + * @param string $filename file name to check + * + * @return bool true if file is in excluded list + */ +function is_excluded($filename) +{ + global $exclude_list; + $count = 0; + $notused = preg_filter($exclude_list, '1', $filename, -1, $count); + if ($count > 0) { + echo " - exclude $filename\n"; + return true; + } + return false; +} + +/** + * recursive copy files & dirs + * + * @param string $src source dir + * @param string $dst target dir + * + * @return bool true on success or false on failure. + */ +function rcopy($src, $dst) +{ + if (is_dir($src)) { + // Make the destination directory if not exist + mkdirAndCheck($dst); + // open the source directory + $dir = opendir($src); + + // Loop through the files in source directory + while ($file = readdir($dir)) { + if (($file != '.') && ($file != '..')) { + if (is_dir($src . '/' . $file)) { + // Recursively calling custom copy function + // for sub directory + if (!rcopy($src . '/' . $file, $dst . '/' . $file)) { + return false; + } + } else { + if (!is_excluded($file)) { + if (!copy($src . '/' . $file, $dst . '/' . $file)) { + return false; + } + } + } + } + } + closedir($dir); + } elseif (is_file($src)) { + if (!is_excluded($src)) { + if (!copy($src, $dst)) { + return false; + } + } + } + return true; +} + +/** + * build a zip file with only php code and no external depends + * on "zip" exec for example + * + * @param string $folder folder to use as zip root + * @param ZipArchive $zip zip object (ZipArchive) + * @param string $root relative root path into the zip + * + * @return bool true on success or false on failure. + */ +function zipDir($folder, &$zip, $root = "") +{ + foreach (new \DirectoryIterator($folder) as $f) { + if ($f->isDot()) { + continue; + } //skip . .. + $src = $folder . '/' . $f; + $dst = substr($f->getPathname(), strlen($root)); + if ($f->isDir()) { + if ($zip->addEmptyDir($dst)) { + if (zipDir($src, $zip, $root)) { + continue; + } else { + return false; + } + } else { + return false; + } + } + if ($f->isFile()) { + if (! $zip->addFile($src, $dst)) { + return false; + } + } + } + return true; +} + +/** + * main part of script + */ + +list($mod, $version) = detectModule(); +$outzip = sys_get_temp_dir() . "/module_" . $mod . "-" . $version . ".zip"; +if (file_exists($outzip)) { + secureUnlink($outzip); +} + +//copy all sources into system temp directory +$tmpdir = tempnam(sys_get_temp_dir(), $mod . "-module"); +secureUnlink($tmpdir); +mkdirAndCheck($tmpdir); +$dst = $tmpdir . "/" . $mod; +mkdirAndCheck($dst); + +foreach ($listOfModuleContent as $moduleContent) { + foreach (glob($moduleContent) as $entry) { + if (!rcopy($entry, $dst . '/' . $entry)) { + echo "[fail] Error on copy " . $entry . " to " . $dst . "/" . $entry . "\n"; + echo "Please take time to analyze the problem and fix the bug\n"; + exit(-8); + } + } +} + +$z = new ZipArchive(); +$z->open($outzip, ZIPARCHIVE::CREATE); +zipDir($tmpdir, $z, $tmpdir . '/'); +$z->close(); +delTree($tmpdir); +if (file_exists($outzip)) { + echo "[success] module archive is ready : $outzip ...\n"; +} else { + echo "[fail] build zip error\n"; + exit(-9); +} diff --git a/build/makepack-bankimport.conf b/build/makepack-bankimport.conf new file mode 100755 index 0000000..16dc1e7 --- /dev/null +++ b/build/makepack-bankimport.conf @@ -0,0 +1,11 @@ +# Your module name here +# +# Goal: Goal of module +# Version: +# Author: Copyright - +# License: GPLv3 +# Install: Just unpack content of module package in Dolibarr directory. +# Setup: Go on Dolibarr setup - modules to enable module. +# +# Files in module +mymodule/ \ No newline at end of file diff --git a/card.php b/card.php new file mode 100755 index 0000000..4a7f88a --- /dev/null +++ b/card.php @@ -0,0 +1,1258 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +/** + * \file bankimport/card.php + * \ingroup bankimport + * \brief Card page for a single bank transaction + */ + +// Load Dolibarr environment +$res = 0; +if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) { + $res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php"; +} +$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME']; +$tmp2 = realpath(__FILE__); +$i = strlen($tmp) - 1; +$j = strlen($tmp2) - 1; +while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) { + $i--; + $j--; +} +if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) { + $res = @include substr($tmp, 0, ($i + 1))."/main.inc.php"; +} +if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) { + $res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php"; +} +if (!$res && file_exists("../main.inc.php")) { + $res = @include "../main.inc.php"; +} +if (!$res && file_exists("../../main.inc.php")) { + $res = @include "../../main.inc.php"; +} +if (!$res) { + die("Include of main fails"); +} + +require_once DOL_DOCUMENT_ROOT.'/core/lib/date.lib.php'; +require_once DOL_DOCUMENT_ROOT.'/core/class/html.formother.class.php'; +dol_include_once('/bankimport/class/banktransaction.class.php'); +dol_include_once('/bankimport/lib/bankimport.lib.php'); + +/** + * @var Conf $conf + * @var DoliDB $db + * @var Translate $langs + * @var User $user + */ + +$langs->loadLangs(array("bankimport@bankimport", "banks", "bills")); + +$id = GETPOSTINT('id'); +$ref = GETPOST('ref', 'alpha'); +$action = GETPOST('action', 'aZ09'); +$confirm = GETPOST('confirm', 'alpha'); + +// Security check +if (!$user->hasRight('bankimport', 'read')) { + accessforbidden(); +} + +/* + * Actions + */ + +$object = new BankImportTransaction($db); + +if ($id > 0 || !empty($ref)) { + $result = $object->fetch($id, $ref); + if ($result <= 0) { + setEventMessages($langs->trans("RecordNotFound"), null, 'errors'); + } +} + +// Set status +if ($action == 'setstatus' && $object->id > 0) { + $newstatus = GETPOSTINT('status'); + $result = $object->setStatus($newstatus, $user); + if ($result > 0) { + setEventMessages($langs->trans("StatusUpdated"), null, 'mesgs'); + header("Location: ".$_SERVER["PHP_SELF"]."?id=".$object->id); + exit; + } else { + setEventMessages($object->error, null, 'errors'); + } +} + +// Unlink payment (reset to NEW status) +if ($action == 'unlink' && $object->id > 0) { + if ($object->status == BankImportTransaction::STATUS_MATCHED) { + $result = $object->unlinkPayment($user); + if ($result > 0) { + setEventMessages($langs->trans("PaymentUnlinked"), null, 'mesgs'); + header("Location: ".$_SERVER["PHP_SELF"]."?id=".$object->id); + exit; + } else { + setEventMessages($object->error, null, 'errors'); + } + } else { + setEventMessages($langs->trans("CannotUnlinkThisStatus"), null, 'warnings'); + } +} + +// Find matches +if ($action == 'findmatches' && $object->id > 0) { + $matches = $object->findMatches(); + if (count($matches) > 0) { + $_SESSION['bankimport_matches_'.$object->id] = $matches; + setEventMessages($langs->trans("MatchesFound", count($matches)), null, 'mesgs'); + } else { + setEventMessages($langs->trans("NoMatchesFound"), null, 'warnings'); + } +} + +// Link to object (old method - just link without payment) +if ($action == 'linkto' && $object->id > 0) { + $linktype = GETPOST('linktype', 'alpha'); + $linkid = GETPOSTINT('linkid'); + + if ($linktype && $linkid > 0) { + $result = $object->linkTo($linktype, $linkid, $user); + if ($result > 0) { + setEventMessages($langs->trans("LinkCreated"), null, 'mesgs'); + unset($_SESSION['bankimport_matches_'.$object->id]); + header("Location: ".$_SERVER["PHP_SELF"]."?id=".$object->id); + exit; + } else { + setEventMessages($object->error, null, 'errors'); + } + } +} + +// Confirm payment (creates payment in Dolibarr) +$bankAccountId = getDolGlobalInt('BANKIMPORT_BANK_ACCOUNT_ID'); +if ($action == 'confirmpayment' && $object->id > 0 && !empty($bankAccountId)) { + $matchtype = GETPOST('matchtype', 'alpha'); + $matchid = GETPOSTINT('matchid'); + + if ($matchtype && $matchid > 0) { + if ($object->status != BankImportTransaction::STATUS_NEW) { + setEventMessages($langs->trans("TransactionAlreadyProcessed"), null, 'warnings'); + } else { + $result = $object->confirmPayment($user, $matchtype, $matchid, $bankAccountId); + if ($result > 0) { + setEventMessages($langs->trans("PaymentCreatedSuccessfully", dol_escape_htmltag($object->name), price(abs($object->amount))), null, 'mesgs'); + unset($_SESSION['bankimport_matches_'.$object->id]); + } else { + setEventMessages($object->error, $object->errors, 'errors'); + } + } + header("Location: ".$_SERVER["PHP_SELF"]."?id=".$object->id); + exit; + } +} + +// Confirm multiple invoices payment +if ($action == 'confirmmulti' && $object->id > 0 && !empty($bankAccountId)) { + $invoiceIds = GETPOST('invoices', 'array'); + + if (!empty($invoiceIds)) { + if ($object->status != BankImportTransaction::STATUS_NEW) { + setEventMessages($langs->trans("TransactionAlreadyProcessed"), null, 'warnings'); + } else { + // Build invoices array with amounts + require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.facture.class.php'; + $invoices = array(); + foreach ($invoiceIds as $invId) { + $invId = (int) $invId; + if ($invId > 0) { + $invoice = new FactureFournisseur($db); + if ($invoice->fetch($invId) > 0) { + $alreadyPaid = $invoice->getSommePaiement(); + $creditnotes = $invoice->getSumCreditNotesUsed(); + $deposits = $invoice->getSumDepositsUsed(); + $remainToPay = price2num($invoice->total_ttc - $alreadyPaid - $creditnotes - $deposits, 'MT'); + if ($remainToPay > 0) { + $invoices[] = array( + 'type' => 'facture_fourn', + 'id' => $invId, + 'ref' => $invoice->ref, + 'amount' => $remainToPay + ); + } + } + } + } + + if (!empty($invoices)) { + $result = $object->confirmMultiplePayment($user, $invoices, $bankAccountId); + if ($result > 0) { + setEventMessages($langs->trans("PaymentCreatedSuccessfully", dol_escape_htmltag($object->name), price(abs($object->amount))).' ('.count($invoices).' '.$langs->trans("Invoices").')', null, 'mesgs'); + unset($_SESSION['bankimport_matches_'.$object->id]); + } else { + setEventMessages($object->error, $object->errors, 'errors'); + } + } else { + setEventMessages($langs->trans("NoInvoicesSelected"), null, 'errors'); + } + } + header("Location: ".$_SERVER["PHP_SELF"]."?id=".$object->id); + exit; + } +} + +// Link existing payment to bank entry (for already paid invoices) +if ($action == 'linkpayment' && $object->id > 0 && !empty($bankAccountId)) { + $invoiceType = GETPOST('invoicetype', 'alpha'); + $invoiceId = GETPOSTINT('invoiceid'); + + if ($invoiceType && $invoiceId > 0) { + if ($object->status != BankImportTransaction::STATUS_NEW) { + setEventMessages($langs->trans("TransactionAlreadyProcessed"), null, 'warnings'); + } else { + $result = $object->linkExistingPayment($user, $invoiceType, $invoiceId, $bankAccountId); + if ($result > 0) { + setEventMessages($langs->trans("PaymentLinkedSuccessfully"), null, 'mesgs'); + } else { + setEventMessages($object->error, $object->errors, 'errors'); + } + } + header("Location: ".$_SERVER["PHP_SELF"]."?id=".$object->id); + exit; + } +} + +// Link multiple existing payments to bank entry (for already paid invoices) +if ($action == 'linkpaymentmulti' && $object->id > 0 && !empty($bankAccountId)) { + $paidInvoiceIds = GETPOST('paid_invoice', 'array'); + $paidInvoiceType = GETPOST('paid_invoice_type', 'alpha'); + + if (!empty($paidInvoiceIds) && !empty($paidInvoiceType)) { + if ($object->status != BankImportTransaction::STATUS_NEW) { + setEventMessages($langs->trans("TransactionAlreadyProcessed"), null, 'warnings'); + } else { + // Build invoices array + $invoices = array(); + foreach ($paidInvoiceIds as $invId) { + $invoices[] = array( + 'type' => $paidInvoiceType, + 'id' => (int) $invId + ); + } + + $result = $object->linkMultipleExistingPayments($user, $invoices, $bankAccountId); + if ($result > 0) { + setEventMessages($langs->trans("PaymentLinkedSuccessfully"), null, 'mesgs'); + } else { + setEventMessages($object->error, $object->errors, 'errors'); + } + } + header("Location: ".$_SERVER["PHP_SELF"]."?id=".$object->id); + exit; + } else { + setEventMessages($langs->trans("NoInvoicesSelected"), null, 'warnings'); + } +} + +// Search for invoice manually +if ($action == 'searchinvoice' && $object->id > 0) { + $searchInvoiceRef = GETPOST('search_invoice_ref', 'alpha'); + $searchInvoiceType = GETPOST('search_invoice_type', 'alpha'); // 'customer' or 'supplier' + + if (!empty($searchInvoiceRef)) { + $manualMatches = array(); + + if ($searchInvoiceType == 'customer' || empty($searchInvoiceType)) { + // Search customer invoices + require_once DOL_DOCUMENT_ROOT.'/compta/facture/class/facture.class.php'; + $sql = "SELECT f.rowid, f.ref, f.ref_client, f.total_ttc, f.date_lim_reglement, s.nom as socname, s.rowid as socid"; + $sql .= " FROM ".MAIN_DB_PREFIX."facture as f"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."societe as s ON f.fk_soc = s.rowid"; + $sql .= " WHERE f.entity = ".$conf->entity; + $sql .= " AND f.fk_statut = 1"; // Unpaid + $sql .= " AND (f.ref LIKE '%".$db->escape($searchInvoiceRef)."%' OR f.ref_client LIKE '%".$db->escape($searchInvoiceRef)."%')"; + + $resql = $db->query($sql); + if ($resql) { + while ($obj = $db->fetch_object($resql)) { + $manualMatches[] = array( + 'type' => 'facture', + 'id' => $obj->rowid, + 'ref' => $obj->ref, + 'ref_client' => $obj->ref_client, + 'amount' => $obj->total_ttc, + 'socname' => $obj->socname, + 'socid' => $obj->socid, + 'match_score' => 0, + 'match_reasons' => array('manual'), + 'date_due' => $obj->date_lim_reglement + ); + } + } + } + + if ($searchInvoiceType == 'supplier' || empty($searchInvoiceType)) { + // Search supplier invoices + require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.facture.class.php'; + $sql = "SELECT f.rowid, f.ref, f.ref_supplier, f.total_ttc, f.date_lim_reglement, s.nom as socname, s.rowid as socid"; + $sql .= " FROM ".MAIN_DB_PREFIX."facture_fourn as f"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."societe as s ON f.fk_soc = s.rowid"; + $sql .= " WHERE f.entity = ".$conf->entity; + $sql .= " AND f.fk_statut = 1"; // Unpaid + $sql .= " AND (f.ref LIKE '%".$db->escape($searchInvoiceRef)."%' OR f.ref_supplier LIKE '%".$db->escape($searchInvoiceRef)."%')"; + + $resql = $db->query($sql); + if ($resql) { + while ($obj = $db->fetch_object($resql)) { + $manualMatches[] = array( + 'type' => 'facture_fourn', + 'id' => $obj->rowid, + 'ref' => $obj->ref, + 'ref_supplier' => $obj->ref_supplier, + 'amount' => $obj->total_ttc, + 'socname' => $obj->socname, + 'socid' => $obj->socid, + 'match_score' => 0, + 'match_reasons' => array('manual'), + 'date_due' => $obj->date_lim_reglement + ); + } + } + } + + if (!empty($manualMatches)) { + $_SESSION['bankimport_matches_'.$object->id] = $manualMatches; + setEventMessages($langs->trans("MatchesFound", count($manualMatches)), null, 'mesgs'); + } else { + setEventMessages($langs->trans("NoMatchesFound"), null, 'warnings'); + } + } +} + +/* + * View + */ + +$form = new Form($db); + +$title = $langs->trans("Transaction").' - '.$object->ref; +llxHeader('', $title, '', '', 0, 0, '', '', '', 'mod-bankimport page-card'); + +if ($object->id > 0) { + print '
'; + + // Title + print load_fiche_titre($langs->trans("Transaction"), '', 'bank'); + + // Card header + print '
'; + print ''; + + // Reference + print ''; + print ''; + print ''; + print ''; + + // IBAN + print ''; + print ''; + print ''; + print ''; + + // Date + print ''; + print ''; + print ''; + print ''; + + // Value date + if ($object->date_value) { + print ''; + print ''; + print ''; + print ''; + } + + // Name + print ''; + print ''; + print ''; + print ''; + + // Counterparty IBAN + if ($object->counterparty_iban) { + print ''; + print ''; + print ''; + print ''; + } + + // Amount + print ''; + print ''; + print ''; + print ''; + + // Label + if ($object->label) { + print ''; + print ''; + print ''; + print ''; + } + + // Description + print ''; + print ''; + print ''; + print ''; + + // End-to-End ID + if ($object->end_to_end_id) { + print ''; + print ''; + print ''; + print ''; + } + + // Mandate ID + if ($object->mandate_id) { + print ''; + print ''; + print ''; + print ''; + } + + // Status + print ''; + print ''; + print ''; + print ''; + + // Linked payment and invoices (for matched transactions) + if ($object->status == BankImportTransaction::STATUS_MATCHED || $object->status == BankImportTransaction::STATUS_RECONCILED) { + // Customer payment + if ($object->fk_paiement > 0) { + require_once DOL_DOCUMENT_ROOT.'/compta/paiement/class/paiement.class.php'; + require_once DOL_DOCUMENT_ROOT.'/compta/facture/class/facture.class.php'; + $payment = new Paiement($db); + $payment->fetch($object->fk_paiement); + + print ''; + print ''; + print ''; + print ''; + + // Find all invoices linked to this payment + $sql = "SELECT pf.fk_facture, pf.amount, f.ref, f.total_ttc"; + $sql .= " FROM ".MAIN_DB_PREFIX."paiement_facture as pf"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."facture as f ON pf.fk_facture = f.rowid"; + $sql .= " WHERE pf.fk_paiement = ".((int) $object->fk_paiement); + $resql = $db->query($sql); + if ($resql && $db->num_rows($resql) > 0) { + print ''; + print ''; + print ''; + print ''; + } + } + + // Supplier payment + if ($object->fk_paiementfourn > 0) { + require_once DOL_DOCUMENT_ROOT.'/fourn/class/paiementfourn.class.php'; + require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.facture.class.php'; + $payment = new PaiementFourn($db); + $payment->fetch($object->fk_paiementfourn); + + print ''; + print ''; + print ''; + print ''; + + // Find all invoices linked to this payment + $sql = "SELECT pf.fk_facturefourn, pf.amount, f.ref, f.ref_supplier, f.total_ttc"; + $sql .= " FROM ".MAIN_DB_PREFIX."paiementfourn_facturefourn as pf"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."facture_fourn as f ON pf.fk_facturefourn = f.rowid"; + $sql .= " WHERE pf.fk_paiement_fourn = ".((int) $object->fk_paiementfourn); + $resql = $db->query($sql); + $invoicesFound = false; + if ($resql && $db->num_rows($resql) > 0) { + $invoicesFound = true; + print ''; + print ''; + print ''; + print ''; + } + + // Also check note_private for multi-invoice links (format: "Multi-invoice link: SI2602-0146, SI2602-0147") + if (!$invoicesFound && !empty($object->note_private) && strpos($object->note_private, 'Multi-invoice link:') !== false) { + if (preg_match('/Multi-invoice link:\s*(.+)$/m', $object->note_private, $matches)) { + $invoiceRefs = array_map('trim', explode(',', $matches[1])); + print ''; + print ''; + print ''; + print ''; + $invoicesFound = true; + } + } + + // Fallback: Show linked invoice from transaction + if (!$invoicesFound && $object->fk_facture_fourn > 0) { + $inv = new FactureFournisseur($db); + $inv->fetch($object->fk_facture_fourn); + print ''; + print ''; + print ''; + print ''; + } + } + + // If no payment link but invoice link exists (edge case) + if (empty($object->fk_paiement) && empty($object->fk_paiementfourn)) { + if ($object->fk_facture_fourn > 0) { + require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.facture.class.php'; + $inv = new FactureFournisseur($db); + $inv->fetch($object->fk_facture_fourn); + print ''; + print ''; + print ''; + print ''; + } + if ($object->fk_facture > 0) { + require_once DOL_DOCUMENT_ROOT.'/compta/facture/class/facture.class.php'; + $inv = new Facture($db); + $inv->fetch($object->fk_facture); + print ''; + print ''; + print ''; + print ''; + } + } + + // Bank entry + if ($object->fk_bank > 0) { + require_once DOL_DOCUMENT_ROOT.'/compta/bank/class/account.class.php'; + $bankline = new AccountLine($db); + $bankline->fetch($object->fk_bank); + + print ''; + print ''; + print ''; + print ''; + } + } else { + // For non-matched transactions, show simple links if they exist + // Linked invoice + if ($object->fk_facture > 0) { + require_once DOL_DOCUMENT_ROOT.'/compta/facture/class/facture.class.php'; + $invoice = new Facture($db); + $invoice->fetch($object->fk_facture); + print ''; + print ''; + print ''; + print ''; + } + + // Linked supplier invoice + if ($object->fk_facture_fourn > 0) { + require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.facture.class.php'; + $invoice = new FactureFournisseur($db); + $invoice->fetch($object->fk_facture_fourn); + print ''; + print ''; + print ''; + print ''; + } + } + + // Linked PDF statement + if (!empty($object->fk_statement)) { + dol_include_once('/bankimport/class/bankstatement.class.php'); + $stmt = new BankImportStatement($db); + $stmt->fetch($object->fk_statement); + print ''; + print ''; + print ''; + print ''; + } + + // Linked third party + if ($object->fk_societe > 0) { + require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php'; + $soc = new Societe($db); + $soc->fetch($object->fk_societe); + print ''; + print ''; + print ''; + print ''; + } + + // Import date + print ''; + print ''; + print ''; + print ''; + + print '
'.$langs->trans("Ref").''.dol_escape_htmltag($object->ref).'
'.$langs->trans("AccountIBAN").''.dol_escape_htmltag($object->iban).'
'.$langs->trans("Date").''.dol_print_date($object->date_trans, 'day').'
'.$langs->trans("DateValue").''.dol_print_date($object->date_value, 'day').'
'.$langs->trans("Counterparty").''.dol_escape_htmltag($object->name).'
'.$langs->trans("CounterpartyIBAN").''.dol_escape_htmltag($object->counterparty_iban).'
'.$langs->trans("Amount").''; + if ($object->amount >= 0) { + print '+'.price($object->amount, 0, $langs, 1, -1, 2, $object->currency).''; + } else { + print ''.price($object->amount, 0, $langs, 1, -1, 2, $object->currency).''; + } + print '
'.$langs->trans("Label").''.dol_escape_htmltag($object->label).'
'.$langs->trans("Description").''.nl2br(dol_escape_htmltag($object->description)).'
'.$langs->trans("EndToEndId").''.dol_escape_htmltag($object->end_to_end_id).'
'.$langs->trans("MandateId").''.dol_escape_htmltag($object->mandate_id).'
'.$langs->trans("Status").''.$object->getLibStatut(4).'
'.$langs->trans("Payment").''.$payment->getNomUrl(1).' ('.dol_print_date($payment->datepaye, 'day').')
'.$langs->trans("Invoices").''; + while ($obj = $db->fetch_object($resql)) { + $inv = new Facture($db); + $inv->fetch($obj->fk_facture); + print $inv->getNomUrl(1); + print ' ('.price($obj->amount, 0, $langs, 1, -1, 2, 'EUR').')'; + print '
'; + } + print '
'.$langs->trans("Payment").''.$payment->getNomUrl(1).' ('.dol_print_date($payment->datepaye, 'day').')
'.$langs->trans("SupplierInvoices").''; + while ($obj = $db->fetch_object($resql)) { + $inv = new FactureFournisseur($db); + $inv->fetch($obj->fk_facturefourn); + print $inv->getNomUrl(1); + if (!empty($obj->ref_supplier)) { + print ' ('.$obj->ref_supplier.')'; + } + print ' ('.price($obj->amount, 0, $langs, 1, -1, 2, 'EUR').')'; + print '
'; + } + print '
'.$langs->trans("SupplierInvoices").''; + foreach ($invoiceRefs as $invRef) { + // Try to find invoice by ref + $sql2 = "SELECT rowid FROM ".MAIN_DB_PREFIX."facture_fourn WHERE ref = '".$db->escape($invRef)."' AND entity = ".$conf->entity; + $resql2 = $db->query($sql2); + if ($resql2 && $db->num_rows($resql2) > 0) { + $obj2 = $db->fetch_object($resql2); + $inv = new FactureFournisseur($db); + $inv->fetch($obj2->rowid); + print $inv->getNomUrl(1); + if (!empty($inv->ref_supplier)) { + print ' ('.$inv->ref_supplier.')'; + } + } else { + print dol_escape_htmltag($invRef); + } + print '
'; + } + print '
'.$langs->trans("SupplierInvoice").''.$inv->getNomUrl(1); + if (!empty($inv->ref_supplier)) { + print ' ('.$inv->ref_supplier.')'; + } + print '
'.$langs->trans("SupplierInvoice").''.$inv->getNomUrl(1); + if (!empty($inv->ref_supplier)) { + print ' ('.$inv->ref_supplier.')'; + } + print '
'.$langs->trans("Invoice").''.$inv->getNomUrl(1).'
'.$langs->trans("BankEntry").''; + print ''; + print img_picto('', 'bank_account', 'class="pictofixedwidth"'); + print $bankline->ref ?: '#'.$object->fk_bank; + print ''; + print ' ('.dol_print_date($bankline->dateo, 'day').' - '.price($bankline->amount, 0, $langs, 1, -1, 2, 'EUR').')'; + print '
'.$langs->trans("Invoice").''.$invoice->getNomUrl(1).'
'.$langs->trans("SupplierInvoice").''.$invoice->getNomUrl(1).'
'.$langs->trans("PDFStatement").''; + print ''; + print img_picto($langs->trans("ViewPDFStatement"), 'pdf').' '; + print $langs->trans("StatementNumber").' '.$stmt->statement_number.'/'.$stmt->statement_year; + print ''; + if ($stmt->date_from && $stmt->date_to) { + print ' ('.dol_print_date($stmt->date_from, 'day').' - '.dol_print_date($stmt->date_to, 'day').')'; + } + print '
'.$langs->trans("ThirdParty").''.$soc->getNomUrl(1).'
'.$langs->trans("DateCreation").''.dol_print_date($object->datec, 'dayhour').'
'; + print '
'; + + // Actions buttons + print '
'; + + if ($object->status == BankImportTransaction::STATUS_NEW) { + // Find matches button + print ''.$langs->trans("FindMatches").''; + + // Set as ignored + print ''.$langs->trans("SetAsIgnored").''; + } + + if ($object->status == BankImportTransaction::STATUS_MATCHED) { + // Edit/Unlink - allows correcting wrong matches + print ''.$langs->trans("UnlinkPayment").''; + } + + if ($object->status == BankImportTransaction::STATUS_IGNORED) { + // Reopen + print ''.$langs->trans("Reopen").''; + } + + print '
'; + + // Manual invoice selection (only for new transactions) + if ($object->status == BankImportTransaction::STATUS_NEW) { + // Determine if this is likely a supplier payment (negative amount) or customer (positive) + $defaultType = ($object->amount < 0) ? 'supplier' : 'customer'; + $selectedType = GETPOST('invoice_type', 'alpha') ?: $defaultType; + $searchFilter = GETPOST('search_filter', 'alpha'); + $showPaid = GETPOSTINT('show_paid'); + + print '
'; + print load_fiche_titre($langs->trans("SelectInvoicesManually"), '', 'object_invoice'); + + // Type selector and search filter + print '
'; + print ''; + print '
'; + print ''; + print ''; + print '
'; + print '
'; + print ''; + print '
'; + print '
'; + print ''; + print ''; + print ' '; + if (!empty($searchFilter)) { + print ' '.$langs->trans("RemoveFilter").''; + } + print '
'; + print '
'; + + // Fetch invoices + $invoiceList = array(); + $absAmount = abs($object->amount); + + if ($selectedType == 'supplier') { + require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.facture.class.php'; + $sql = "SELECT f.rowid, f.ref, f.ref_supplier, f.total_ttc, f.datef, f.date_lim_reglement, f.fk_statut,"; + $sql .= " s.nom as socname, s.rowid as socid"; + $sql .= " FROM ".MAIN_DB_PREFIX."facture_fourn as f"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."societe as s ON f.fk_soc = s.rowid"; + $sql .= " WHERE f.entity = ".$conf->entity; + if ($showPaid) { + $sql .= " AND f.fk_statut IN (1, 2)"; // 1=Unpaid, 2=Paid + } else { + $sql .= " AND f.fk_statut = 1"; // Unpaid only + } + + if (!empty($searchFilter)) { + $sql .= " AND (f.ref LIKE '%".$db->escape($searchFilter)."%'"; + $sql .= " OR f.ref_supplier LIKE '%".$db->escape($searchFilter)."%'"; + $sql .= " OR s.nom LIKE '%".$db->escape($searchFilter)."%')"; + } + + $sql .= " ORDER BY f.datef DESC"; + $sql .= " LIMIT 100"; + + $resql = $db->query($sql); + if ($resql) { + while ($obj = $db->fetch_object($resql)) { + $inv = new FactureFournisseur($db); + $inv->fetch($obj->rowid); + $alreadyPaid = $inv->getSommePaiement(); + $creditnotes = $inv->getSumCreditNotesUsed(); + $deposits = $inv->getSumDepositsUsed(); + $remainToPay = price2num($inv->total_ttc - $alreadyPaid - $creditnotes - $deposits, 'MT'); + + $isPaid = ($obj->fk_statut == 2); + + // Show if unpaid OR if showing paid invoices + if ($remainToPay > 0 || $isPaid) { + $invoiceList[] = array( + 'id' => $obj->rowid, + 'ref' => $obj->ref, + 'ref_supplier' => $obj->ref_supplier, + 'amount' => $isPaid ? $obj->total_ttc : $remainToPay, + 'total_ttc' => $obj->total_ttc, + 'socname' => $obj->socname, + 'socid' => $obj->socid, + 'datef' => $obj->datef, + 'date_due' => $obj->date_lim_reglement, + 'type' => 'facture_fourn', + 'object' => $inv, + 'is_paid' => $isPaid + ); + } + } + } + } else { + require_once DOL_DOCUMENT_ROOT.'/compta/facture/class/facture.class.php'; + $sql = "SELECT f.rowid, f.ref, f.ref_client, f.total_ttc, f.datef, f.date_lim_reglement, f.fk_statut,"; + $sql .= " s.nom as socname, s.rowid as socid"; + $sql .= " FROM ".MAIN_DB_PREFIX."facture as f"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."societe as s ON f.fk_soc = s.rowid"; + $sql .= " WHERE f.entity = ".$conf->entity; + if ($showPaid) { + $sql .= " AND f.fk_statut IN (1, 2)"; // 1=Unpaid, 2=Paid + } else { + $sql .= " AND f.fk_statut = 1"; // Unpaid only + } + + if (!empty($searchFilter)) { + $sql .= " AND (f.ref LIKE '%".$db->escape($searchFilter)."%'"; + $sql .= " OR f.ref_client LIKE '%".$db->escape($searchFilter)."%'"; + $sql .= " OR s.nom LIKE '%".$db->escape($searchFilter)."%')"; + } + + $sql .= " ORDER BY f.datef DESC"; + $sql .= " LIMIT 100"; + + $resql = $db->query($sql); + if ($resql) { + while ($obj = $db->fetch_object($resql)) { + $inv = new Facture($db); + $inv->fetch($obj->rowid); + $alreadyPaid = $inv->getSommePaiement(); + $creditnotes = $inv->getSumCreditNotesUsed(); + $deposits = $inv->getSumDepositsUsed(); + $remainToPay = price2num($inv->total_ttc - $alreadyPaid - $creditnotes - $deposits, 'MT'); + + $isPaid = ($obj->fk_statut == 2); + + // Show if unpaid OR if showing paid invoices + if ($remainToPay > 0 || $isPaid) { + $invoiceList[] = array( + 'id' => $obj->rowid, + 'ref' => $obj->ref, + 'ref_client' => $obj->ref_client, + 'amount' => $isPaid ? $obj->total_ttc : $remainToPay, + 'total_ttc' => $obj->total_ttc, + 'socname' => $obj->socname, + 'socid' => $obj->socid, + 'datef' => $obj->datef, + 'date_due' => $obj->date_lim_reglement, + 'type' => 'facture', + 'object' => $inv, + 'is_paid' => $isPaid + ); + } + } + } + } + + // Separate paid and unpaid invoices + $unpaidInvoices = array_filter($invoiceList, function($inv) { return empty($inv['is_paid']); }); + $paidInvoices = array_filter($invoiceList, function($inv) { return !empty($inv['is_paid']); }); + + // Display unpaid invoices table with checkboxes + if (!empty($unpaidInvoices)) { + print '
'; + print ''; + print ''; + print ''; + + // Table without div-table-responsive for compact layout + print ''; + + // Info header row + print ''; + print ''; + print ''; + + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + + foreach ($unpaidInvoices as $inv) { + $amountMatch = (abs($inv['amount'] - $absAmount) < 1.00); + $rowClass = $amountMatch ? 'oddeven highlight' : 'oddeven'; + + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + } + + // Footer row with button + print ''; + print ''; + print ''; + print ''; + + print '
'; + print $langs->trans("TransactionAmount").': '.price(abs($object->amount), 0, $langs, 1, -1, 2, 'EUR').''; + print '  |  '.$langs->trans("Selected").': 0,00 €'; + print '  |  '.$langs->trans("Difference").': '.price(abs($object->amount), 0, $langs, 1, -1, 2, 'EUR').''; + print '
'.$langs->trans("Ref").''.($selectedType == 'supplier' ? $langs->trans("SupplierRef") : $langs->trans("CustomerRef")).''.$langs->trans("ThirdParty").''.$langs->trans("Date").''.$langs->trans("DateDue").''.$langs->trans("AmountRemaining").'
'; + print ''; + print ''.$inv['object']->getNomUrl(1).''.dol_escape_htmltag($inv['ref_supplier'] ?? $inv['ref_client'] ?? '').''.dol_escape_htmltag($inv['socname']).''.dol_print_date($inv['datef'], 'day').''.($inv['date_due'] ? dol_print_date($inv['date_due'], 'day') : '-').''.price($inv['amount'], 0, $langs, 1, -1, 2, 'EUR').'
'; + if (!empty($bankAccountId)) { + print ''; + } else { + print ''.$langs->trans("ConfirmPayment").''; + } + print '
'; + print '
'; + } + + // Display paid invoices table (link to existing payment) + if (!empty($paidInvoices)) { + print '
'; + print load_fiche_titre($langs->trans("PaidInvoices"), '', 'object_invoice'); + print '
'.$langs->trans("PaidInvoicesInfo").'
'; + + // Determine invoice type for all paid invoices (should be same type) + $paidInvoiceType = $paidInvoices[0]['type'] ?? 'facture'; + + print ''; + } + + if (empty($unpaidInvoices) && empty($paidInvoices)) { + print '
'.$langs->trans("NoUnpaidInvoices").'
'; + } + + // JavaScript for sum calculation (only if we have unpaid invoices) + if (!empty($unpaidInvoices)) { + print ''; + } + + // JavaScript for paid invoices multi-select (only if we have paid invoices) + if (!empty($paidInvoices)) { + print ''; + } + + // CSS for highlighting + print ''; + } + + // Show matches if found + $matches = $_SESSION['bankimport_matches_'.$object->id] ?? array(); + if (!empty($matches) && $object->status == BankImportTransaction::STATUS_NEW) { + print '
'; + print load_fiche_titre($langs->trans("MatchesFound", count($matches)), '', 'object_invoice'); + + // Translate match reasons + $reasonLabels = array( + 'ref' => $langs->trans("MatchByRef"), + 'ref_client' => $langs->trans("MatchByClientRef"), + 'ref_supplier' => $langs->trans("MatchBySupplierRef"), + 'amount' => $langs->trans("MatchByAmount"), + 'amount_close' => $langs->trans("MatchByAmountClose"), + 'name_exact' => $langs->trans("MatchByNameExact"), + 'name_similar' => $langs->trans("MatchByNameSimilar"), + 'iban' => $langs->trans("MatchByIBAN"), + 'multi_invoice' => $langs->trans("MatchByMultiInvoice"), + 'manual' => $langs->trans("ManualSearch") + ); + + // Check for multi-invoice matches + $hasMultiMatch = false; + foreach ($matches as $match) { + if ($match['type'] == 'multi_facture_fourn') { + $hasMultiMatch = true; + break; + } + } + + // If we have multi-invoice matches, show them first with selection form + if ($hasMultiMatch) { + foreach ($matches as $match) { + if ($match['type'] == 'multi_facture_fourn' && !empty($match['invoices'])) { + print '
'; + print ''.$langs->trans("MatchByMultiInvoice").': '; + print count($match['invoices']).' '.$langs->trans("Invoices").' = '.price($match['total'] ?? $match['amount'], 0, $langs, 1, -1, 2, 'EUR'); + if (isset($match['difference']) && abs($match['difference']) > 0.01) { + print ' ('.$langs->trans("Difference").': '.price($match['difference'], 0, $langs, 1, -1, 2, 'EUR').')'; + } + print '
'; + + print '
'; + print ''; + print ''; + print ''; + + print '
'; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + + require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.facture.class.php'; + foreach ($match['invoices'] as $invData) { + $inv = new FactureFournisseur($db); + $inv->fetch($invData['id']); + + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + } + + print '
'.$langs->trans("Ref").''.$langs->trans("SupplierRef").''.$langs->trans("ThirdParty").''.$langs->trans("Amount").''.$langs->trans("DateDue").'
'.$inv->getNomUrl(1).''.dol_escape_htmltag($invData['ref_supplier']).''.dol_escape_htmltag($invData['socname']).''.price($invData['amount'], 0, $langs, 1, -1, 2, 'EUR').''.($invData['date_due'] ? dol_print_date($invData['date_due'], 'day') : '-').'
'; + print '
'; + print '
'; + print ''; + print '
'; + print '
'; + print '
'; + + // JavaScript for toggle all + print ''; + } + } + } + + // Show single invoice matches + $singleMatches = array_filter($matches, function($m) { return $m['type'] != 'multi_facture_fourn'; }); + if (!empty($singleMatches)) { + if ($hasMultiMatch) { + print '
'; + print load_fiche_titre($langs->trans("Alternatives"), '', ''); + } + + print '
'; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + + foreach ($singleMatches as $match) { + print ''; + print ''; + + // Get invoice link + if ($match['type'] == 'facture') { + require_once DOL_DOCUMENT_ROOT.'/compta/facture/class/facture.class.php'; + $inv = new Facture($db); + $inv->fetch($match['id']); + print ''; + } else { + require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.facture.class.php'; + $inv = new FactureFournisseur($db); + $inv->fetch($match['id']); + print ''; + } + + // Third party with link + if ($match['socid'] > 0) { + require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php'; + $soc = new Societe($db); + $soc->fetch($match['socid']); + print ''; + } else { + print ''; + } + + print ''; + print ''; + + // Score with color + $scoreColor = $match['match_score'] >= 80 ? 'green' : ($match['match_score'] >= 60 ? 'orange' : 'gray'); + print ''; + + // Match reasons as badges + print ''; + + // Action buttons + print ''; + print ''; + } + + print '
'.$langs->trans("Type").''.$langs->trans("Ref").''.$langs->trans("ThirdParty").''.$langs->trans("Amount").''.$langs->trans("DateDue").''.$langs->trans("Score").''.$langs->trans("MatchReason").'
'.($match['type'] == 'facture' ? $langs->trans("CustomerInvoice") : $langs->trans("SupplierInvoice")).''.$inv->getNomUrl(1).''.$inv->getNomUrl(1).''.$soc->getNomUrl(1).''.dol_escape_htmltag($match['socname']).''.price($match['amount'], 0, $langs, 1, -1, 2, 'EUR').''.($match['date_due'] ? dol_print_date($match['date_due'], 'day') : '-').''.$match['match_score'].'%'; + if (!empty($match['match_reasons'])) { + foreach ($match['match_reasons'] as $reason) { + $label = $reasonLabels[$reason] ?? $reason; + print ''.$label.' '; + } + } + print ''; + if (!empty($bankAccountId)) { + // Confirm payment button (creates payment in Dolibarr) + print ''.$langs->trans("ConfirmPayment").''; + } else { + // Just link (no bank account configured) + print ''.$langs->trans("Link").''; + } + print '
'; + print '
'; + } + } + +} else { + print '
'.$langs->trans("RecordNotFound").'
'; +} + +// Back link +print '
'; +print ''.$langs->trans("BackToList").''; +print '
'; + +llxFooter(); +$db->close(); diff --git a/class/bankimportcron.class.php b/class/bankimportcron.class.php new file mode 100755 index 0000000..631c91a --- /dev/null +++ b/class/bankimportcron.class.php @@ -0,0 +1,379 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +/** + * \file bankimport/class/bankimportcron.class.php + * \ingroup bankimport + * \brief Cron job class for automatic bank statement import + */ + +require_once DOL_DOCUMENT_ROOT.'/core/class/commonobject.class.php'; +dol_include_once('/bankimport/class/fints.class.php'); +dol_include_once('/bankimport/class/banktransaction.class.php'); + +/** + * Class BankImportCron + * Handles automatic bank statement import via scheduled task + */ +class BankImportCron +{ + /** + * @var DoliDB Database handler + */ + public $db; + + /** + * @var string Error message + */ + public $error = ''; + + /** + * @var array Error messages + */ + public $errors = array(); + + /** + * @var string Output message for cron log + */ + public $output = ''; + + /** + * Constructor + * + * @param DoliDB $db Database handler + */ + public function __construct($db) + { + $this->db = $db; + } + + /** + * Execute the automatic import cron job + * Called by Dolibarr's scheduled task system + * + * @return int 0 if OK, < 0 if error + */ + public function doAutoImport() + { + global $conf, $langs, $user; + + $langs->load('bankimport@bankimport'); + + dol_syslog("BankImportCron::doAutoImport - Starting automatic import", LOG_INFO); + + // Check if automatic import is enabled + if (!getDolGlobalInt('BANKIMPORT_AUTO_ENABLED')) { + $this->output = $langs->trans('AutoImportDisabled'); + dol_syslog("BankImportCron::doAutoImport - Auto import is disabled", LOG_INFO); + return 0; + } + + // Initialize FinTS + $fints = new BankImportFinTS($this->db); + + if (!$fints->isConfigured()) { + $this->error = $langs->trans('AutoImportNotConfigured'); + dol_syslog("BankImportCron::doAutoImport - FinTS not configured", LOG_WARNING); + $this->setNotification('config_error'); + return -1; + } + + if (!$fints->isLibraryAvailable()) { + $this->error = $langs->trans('FinTSLibraryNotFound'); + dol_syslog("BankImportCron::doAutoImport - Library not found", LOG_ERR); + return -1; + } + + // Check for stored session state (from previous successful TAN) + $storedState = getDolGlobalString('BANKIMPORT_CRON_STATE'); + $sessionRestored = false; + + try { + // Try to restore previous session + if (!empty($storedState)) { + dol_syslog("BankImportCron::doAutoImport - Attempting to restore previous session (state length: ".strlen($storedState).")", LOG_INFO); + $restoreResult = $fints->restore($storedState); + if ($restoreResult < 0) { + // Session expired, need fresh login + dol_syslog("BankImportCron::doAutoImport - Session restore failed: ".$fints->error.", trying fresh login", LOG_WARNING); + $storedState = ''; + // Clear stale session + dolibarr_del_const($this->db, 'BANKIMPORT_CRON_STATE', $conf->entity); + } else { + dol_syslog("BankImportCron::doAutoImport - Session restored successfully", LOG_INFO); + $sessionRestored = true; + } + } else { + dol_syslog("BankImportCron::doAutoImport - No stored session found", LOG_INFO); + } + + // If no stored session or restore failed, try fresh login + if (empty($storedState)) { + dol_syslog("BankImportCron::doAutoImport - Attempting fresh login", LOG_INFO); + $loginResult = $fints->login(); + + if ($loginResult < 0) { + $this->error = $langs->trans('LoginFailed').': '.$fints->error; + dol_syslog("BankImportCron::doAutoImport - Login failed: ".$fints->error, LOG_ERR); + $this->setNotification('login_error'); + return -1; + } + + if ($loginResult == 0) { + // TAN required - can't proceed automatically + $this->output = $langs->trans('TANRequired'); + dol_syslog("BankImportCron::doAutoImport - TAN required for login, cannot proceed automatically", LOG_WARNING); + $this->setNotification('tan_required'); + + // Store the state so user can complete TAN manually + $state = $fints->persist(); + if (!empty($state)) { + dolibarr_set_const($this->db, 'BANKIMPORT_CRON_PENDING_STATE', $state, 'chaine', 0, '', $conf->entity); + dolibarr_set_const($this->db, 'BANKIMPORT_CRON_PENDING_ACTION', serialize($fints->getPendingAction()), 'chaine', 0, '', $conf->entity); + } + return 0; // Not an error, just can't proceed + } + + dol_syslog("BankImportCron::doAutoImport - Fresh login successful", LOG_INFO); + } + + // Login successful or session restored - fetch statements + $daysToFetch = getDolGlobalInt('BANKIMPORT_AUTO_DAYS') ?: 30; + $dateFrom = strtotime("-{$daysToFetch} days"); + $dateTo = time(); + + dol_syslog("BankImportCron::doAutoImport - Fetching statements from ".date('Y-m-d', $dateFrom)." to ".date('Y-m-d', $dateTo)." ({$daysToFetch} days)", LOG_INFO); + + $result = $fints->fetchStatements($dateFrom, $dateTo); + + // Log what we got back + if (is_array($result)) { + $txCount = count($result['transactions'] ?? array()); + $hasBalance = !empty($result['balance']); + $isPartial = !empty($result['partial']); + dol_syslog("BankImportCron::doAutoImport - fetchStatements returned array: transactions={$txCount}, hasBalance=".($hasBalance?'yes':'no').", partial=".($isPartial?'yes':'no'), LOG_INFO); + } else { + dol_syslog("BankImportCron::doAutoImport - fetchStatements returned: ".var_export($result, true), LOG_INFO); + } + + if ($result === 0) { + // TAN required for statements + $this->output = $langs->trans('TANRequired'); + dol_syslog("BankImportCron::doAutoImport - TAN required for statements", LOG_WARNING); + $this->setNotification('tan_required'); + + // Store state for manual completion + $state = $fints->persist(); + if (!empty($state)) { + dolibarr_set_const($this->db, 'BANKIMPORT_CRON_PENDING_STATE', $state, 'chaine', 0, '', $conf->entity); + dolibarr_set_const($this->db, 'BANKIMPORT_CRON_PENDING_ACTION', serialize($fints->getPendingAction()), 'chaine', 0, '', $conf->entity); + } + return 0; + } + + if ($result < 0) { + $this->error = $langs->trans('FetchFailed').': '.$fints->error; + dol_syslog("BankImportCron::doAutoImport - Fetch failed: ".$fints->error, LOG_ERR); + $this->setNotification('fetch_error'); + return -1; + } + + // Success - import transactions + $transactions = $result['transactions'] ?? array(); + $fetchedCount = count($transactions); + $importedCount = 0; + $skippedCount = 0; + + dol_syslog("BankImportCron::doAutoImport - Bank returned {$fetchedCount} transactions", LOG_INFO); + + // If restored session returned 0 transactions, the session might be stale + // Try fresh login as fallback + if ($fetchedCount == 0 && $sessionRestored) { + dol_syslog("BankImportCron::doAutoImport - Restored session returned 0 transactions, clearing stale session", LOG_WARNING); + dolibarr_del_const($this->db, 'BANKIMPORT_CRON_STATE', $conf->entity); + + // Note: We don't retry here because a fresh login would require TAN + // Just mark that the session was stale + $this->output = $langs->trans('AutoImportNoTransactions').' (Session abgelaufen - nächster Lauf erfordert TAN)'; + + $this->setNotification('session_expired'); + + return 0; + } + + if (!empty($transactions)) { + // Get a system user for the import + $importUser = new User($this->db); + $importUser->fetch(1); // Admin user + + $iban = $fints->getIban(); + + // Use the importFromFinTS method for correct field mapping + $transImporter = new BankImportTransaction($this->db); + $importResult = $transImporter->importFromFinTS($transactions, $iban, $importUser); + + $importedCount = $importResult['imported'] ?? 0; + $skippedCount = $importResult['skipped'] ?? 0; + + dol_syslog("BankImportCron::doAutoImport - Import result: imported={$importedCount}, skipped={$skippedCount}", LOG_INFO); + } + + // Update last fetch info + dolibarr_set_const($this->db, 'BANKIMPORT_LAST_FETCH', time(), 'chaine', 0, '', $conf->entity); + dolibarr_set_const($this->db, 'BANKIMPORT_LAST_FETCH_COUNT', $importedCount, 'chaine', 0, '', $conf->entity); + + // Store session for next run (might avoid TAN) + $state = $fints->persist(); + if (!empty($state)) { + dolibarr_set_const($this->db, 'BANKIMPORT_CRON_STATE', $state, 'chaine', 0, '', $conf->entity); + } + + // Clear any pending state + dolibarr_del_const($this->db, 'BANKIMPORT_CRON_PENDING_STATE', $conf->entity); + dolibarr_del_const($this->db, 'BANKIMPORT_CRON_PENDING_ACTION', $conf->entity); + + // Clear notification flag on success + $this->clearNotification(); + + $fints->close(); + + if ($importedCount > 0) { + $this->output = $langs->trans('AutoImportSuccess', $importedCount); + } elseif ($skippedCount > 0) { + $this->output = $langs->trans('AutoImportNoTransactions').' ('.$skippedCount.' bereits vorhanden)'; + } else { + $this->output = $langs->trans('AutoImportNoTransactions'); + } + + dol_syslog("BankImportCron::doAutoImport - Completed: imported={$importedCount}, skipped={$skippedCount}", LOG_INFO); + + return 0; + + } catch (Exception $e) { + $this->error = 'Exception: '.$e->getMessage(); + dol_syslog("BankImportCron::doAutoImport - Exception: ".$e->getMessage(), LOG_ERR); + $this->setNotification('error'); + return -1; + } + } + + /** + * Set notification flag for admin users + * + * @param string $type Notification type (tan_required, error, etc.) + * @return void + */ + private function setNotification($type) + { + global $conf; + + dolibarr_set_const($this->db, 'BANKIMPORT_NOTIFICATION', $type, 'chaine', 0, '', $conf->entity); + dolibarr_set_const($this->db, 'BANKIMPORT_NOTIFICATION_DATE', time(), 'chaine', 0, '', $conf->entity); + } + + /** + * Clear notification flag + * + * @return void + */ + private function clearNotification() + { + global $conf; + + dolibarr_del_const($this->db, 'BANKIMPORT_NOTIFICATION', $conf->entity); + dolibarr_del_const($this->db, 'BANKIMPORT_NOTIFICATION_DATE', $conf->entity); + } + + /** + * Check if there's a pending notification + * + * @return array|null Notification info or null + */ + public static function getNotification() + { + $type = getDolGlobalString('BANKIMPORT_NOTIFICATION'); + $date = getDolGlobalInt('BANKIMPORT_NOTIFICATION_DATE'); + + if (empty($type)) { + return null; + } + + return array( + 'type' => $type, + 'date' => $date + ); + } + + /** + * Resume a pending TAN action (called from web interface) + * + * @return int 1 if TAN confirmed and import done, 0 if still waiting, -1 if error + */ + public function resumePendingAction() + { + global $conf, $langs, $user; + + $pendingState = getDolGlobalString('BANKIMPORT_CRON_PENDING_STATE'); + $pendingAction = getDolGlobalString('BANKIMPORT_CRON_PENDING_ACTION'); + + if (empty($pendingState) || empty($pendingAction)) { + $this->error = 'No pending action'; + return -1; + } + + $fints = new BankImportFinTS($this->db); + + $restoreResult = $fints->restore($pendingState); + if ($restoreResult < 0) { + $this->error = $fints->error; + // Clear expired state + dolibarr_del_const($this->db, 'BANKIMPORT_CRON_PENDING_STATE', $conf->entity); + dolibarr_del_const($this->db, 'BANKIMPORT_CRON_PENDING_ACTION', $conf->entity); + return -1; + } + + $action = @unserialize($pendingAction); + if ($action === false) { + $this->error = 'Could not restore pending action'; + return -1; + } + + $fints->setPendingAction($action); + + // Check if TAN was confirmed + $checkResult = $fints->checkDecoupledTan(); + + if ($checkResult == 0) { + // Still waiting + // Update state + $newState = $fints->persist(); + if (!empty($newState)) { + dolibarr_set_const($this->db, 'BANKIMPORT_CRON_PENDING_STATE', $newState, 'chaine', 0, '', $conf->entity); + } + return 0; + } + + if ($checkResult < 0) { + $this->error = $fints->error; + return -1; + } + + // TAN confirmed - now run the import + dolibarr_del_const($this->db, 'BANKIMPORT_CRON_PENDING_STATE', $conf->entity); + dolibarr_del_const($this->db, 'BANKIMPORT_CRON_PENDING_ACTION', $conf->entity); + + // Store the confirmed session and run import + $state = $fints->persist(); + if (!empty($state)) { + dolibarr_set_const($this->db, 'BANKIMPORT_CRON_STATE', $state, 'chaine', 0, '', $conf->entity); + } + + return $this->doAutoImport(); + } +} diff --git a/class/bankstatement.class.php b/class/bankstatement.class.php new file mode 100755 index 0000000..9dbe529 --- /dev/null +++ b/class/bankstatement.class.php @@ -0,0 +1,1325 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +/** + * \file bankimport/class/bankstatement.class.php + * \ingroup bankimport + * \brief Class for PDF bank statements from FinTS + */ + +require_once DOL_DOCUMENT_ROOT.'/core/class/commonobject.class.php'; + +/** + * Class BankImportStatement + * Represents a PDF bank statement imported via FinTS + */ +class BankImportStatement extends CommonObject +{ + /** + * @var string ID to identify managed object + */ + public $element = 'bankstatement'; + + /** + * @var string Name of table without prefix where object is stored + */ + public $table_element = 'bankimport_statement'; + + /** + * @var int Entity + */ + public $entity; + + /** + * @var string IBAN + */ + public $iban; + + /** + * @var string Statement number + */ + public $statement_number; + + /** + * @var int Statement year + */ + public $statement_year; + + /** + * @var int Statement date + */ + public $statement_date; + + /** + * @var int Period from + */ + public $date_from; + + /** + * @var int Period to + */ + public $date_to; + + /** + * @var float Opening balance + */ + public $opening_balance; + + /** + * @var float Closing balance + */ + public $closing_balance; + + /** + * @var string Currency + */ + public $currency = 'EUR'; + + /** + * @var string Filename + */ + public $filename; + + /** + * @var string Filepath + */ + public $filepath; + + /** + * @var int Filesize + */ + public $filesize; + + /** + * @var string Import batch key + */ + public $import_key; + + /** + * @var int Creation timestamp + */ + public $datec; + + /** + * @var int User who created + */ + public $fk_user_creat; + + /** + * @var string Private note + */ + public $note_private; + + /** + * @var string Public note + */ + public $note_public; + + /** + * Constructor + * + * @param DoliDB $db Database handler + */ + public function __construct($db) + { + global $conf; + + $this->db = $db; + $this->entity = $conf->entity; + } + + /** + * Create statement in database + * + * @param User $user User that creates + * @return int <0 if KO, Id of created object if OK + */ + public function create($user) + { + global $conf; + + $now = dol_now(); + + $this->db->begin(); + + $sql = "INSERT INTO ".MAIN_DB_PREFIX."bankimport_statement ("; + $sql .= "entity, iban, statement_number, statement_year, statement_date,"; + $sql .= "date_from, date_to, opening_balance, closing_balance, currency,"; + $sql .= "filename, filepath, filesize, import_key, datec, fk_user_creat"; + $sql .= ") VALUES ("; + $sql .= ((int) $this->entity).","; + $sql .= ($this->iban ? "'".$this->db->escape($this->iban)."'" : "NULL").","; + $sql .= "'".$this->db->escape($this->statement_number)."',"; + $sql .= ((int) $this->statement_year).","; + $sql .= ($this->statement_date ? "'".$this->db->idate($this->statement_date)."'" : "NULL").","; + $sql .= ($this->date_from ? "'".$this->db->idate($this->date_from)."'" : "NULL").","; + $sql .= ($this->date_to ? "'".$this->db->idate($this->date_to)."'" : "NULL").","; + $sql .= ($this->opening_balance !== null ? ((float) $this->opening_balance) : "NULL").","; + $sql .= ($this->closing_balance !== null ? ((float) $this->closing_balance) : "NULL").","; + $sql .= "'".$this->db->escape($this->currency)."',"; + $sql .= ($this->filename ? "'".$this->db->escape($this->filename)."'" : "NULL").","; + $sql .= ($this->filepath ? "'".$this->db->escape($this->filepath)."'" : "NULL").","; + $sql .= ($this->filesize ? ((int) $this->filesize) : "NULL").","; + $sql .= ($this->import_key ? "'".$this->db->escape($this->import_key)."'" : "NULL").","; + $sql .= "'".$this->db->idate($now)."',"; + $sql .= ((int) $user->id); + $sql .= ")"; + + dol_syslog(get_class($this)."::create", LOG_DEBUG); + $resql = $this->db->query($sql); + + if ($resql) { + $this->id = $this->db->last_insert_id(MAIN_DB_PREFIX."bankimport_statement"); + $this->datec = $now; + $this->fk_user_creat = $user->id; + + $this->db->commit(); + return $this->id; + } else { + $this->error = $this->db->lasterror(); + $this->db->rollback(); + return -1; + } + } + + /** + * Load statement from database + * + * @param int $id Id of statement to load + * @return int <0 if KO, 0 if not found, >0 if OK + */ + public function fetch($id) + { + $sql = "SELECT t.*"; + $sql .= " FROM ".MAIN_DB_PREFIX."bankimport_statement as t"; + $sql .= " WHERE t.rowid = ".((int) $id); + + dol_syslog(get_class($this)."::fetch", LOG_DEBUG); + $resql = $this->db->query($sql); + + if ($resql) { + if ($this->db->num_rows($resql)) { + $obj = $this->db->fetch_object($resql); + + $this->id = $obj->rowid; + $this->entity = $obj->entity; + $this->iban = $obj->iban; + $this->statement_number = $obj->statement_number; + $this->statement_year = $obj->statement_year; + $this->statement_date = $this->db->jdate($obj->statement_date); + $this->date_from = $this->db->jdate($obj->date_from); + $this->date_to = $this->db->jdate($obj->date_to); + $this->opening_balance = $obj->opening_balance; + $this->closing_balance = $obj->closing_balance; + $this->currency = $obj->currency; + $this->filename = $obj->filename; + $this->filepath = $obj->filepath; + $this->filesize = $obj->filesize; + $this->import_key = $obj->import_key; + $this->datec = $this->db->jdate($obj->datec); + $this->fk_user_creat = $obj->fk_user_creat; + $this->note_private = $obj->note_private; + $this->note_public = $obj->note_public; + + $this->db->free($resql); + return 1; + } else { + $this->db->free($resql); + return 0; + } + } else { + $this->error = $this->db->lasterror(); + return -1; + } + } + + /** + * Check if statement already exists (by number, year, iban) + * + * @return int 0 if not exists, rowid if exists + */ + public function exists() + { + $sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."bankimport_statement"; + $sql .= " WHERE statement_number = '".$this->db->escape($this->statement_number)."'"; + $sql .= " AND statement_year = ".((int) $this->statement_year); + $sql .= " AND iban = '".$this->db->escape($this->iban)."'"; + $sql .= " AND entity = ".((int) $this->entity); + + $resql = $this->db->query($sql); + if ($resql) { + if ($this->db->num_rows($resql) > 0) { + $obj = $this->db->fetch_object($resql); + return $obj->rowid; + } + } + return 0; + } + + /** + * Fetch all statements with filters + * + * @param string $sortfield Sort field + * @param string $sortorder Sort order (ASC/DESC) + * @param int $limit Limit + * @param int $offset Offset + * @param array $filter Filters array + * @param string $mode 'list' returns array, 'count' returns count + * @return array|int Array of statements, count or -1 on error + */ + public function fetchAll($sortfield = 'statement_year,statement_number', $sortorder = 'DESC', $limit = 0, $offset = 0, $filter = array(), $mode = 'list') + { + $sql = "SELECT t.rowid"; + $sql .= " FROM ".MAIN_DB_PREFIX."bankimport_statement as t"; + $sql .= " WHERE t.entity = ".((int) $this->entity); + + // Apply filters + if (!empty($filter['iban'])) { + $sql .= " AND t.iban LIKE '%".$this->db->escape($filter['iban'])."%'"; + } + if (!empty($filter['year'])) { + $sql .= " AND t.statement_year = ".((int) $filter['year']); + } + + // Count mode + if ($mode == 'count') { + $sqlcount = preg_replace('/SELECT t\.rowid/', 'SELECT COUNT(*) as total', $sql); + $resqlcount = $this->db->query($sqlcount); + if ($resqlcount) { + $objcount = $this->db->fetch_object($resqlcount); + return (int) $objcount->total; + } + return 0; + } + + // Sort and limit + $sql .= $this->db->order($sortfield, $sortorder); + if ($limit > 0) { + $sql .= $this->db->plimit($limit, $offset); + } + + dol_syslog(get_class($this)."::fetchAll", LOG_DEBUG); + $resql = $this->db->query($sql); + + if ($resql) { + $result = array(); + + while ($obj = $this->db->fetch_object($resql)) { + $statement = new BankImportStatement($this->db); + $statement->fetch($obj->rowid); + $result[] = $statement; + } + + $this->db->free($resql); + return $result; + } else { + $this->error = $this->db->lasterror(); + return -1; + } + } + + /** + * Delete statement + * + * @param User $user User that deletes + * @return int <0 if KO, >0 if OK + */ + public function delete($user) + { + $this->db->begin(); + + // Delete file if exists + if ($this->filepath && file_exists($this->filepath)) { + @unlink($this->filepath); + } + + // Delete statement lines + $sqlLines = "DELETE FROM ".MAIN_DB_PREFIX."bankimport_statement_line"; + $sqlLines .= " WHERE fk_statement = ".((int) $this->id); + $this->db->query($sqlLines); + + $sql = "DELETE FROM ".MAIN_DB_PREFIX."bankimport_statement"; + $sql .= " WHERE rowid = ".((int) $this->id); + + dol_syslog(get_class($this)."::delete", LOG_DEBUG); + $resql = $this->db->query($sql); + + if ($resql) { + $this->db->commit(); + return 1; + } else { + $this->error = $this->db->lasterror(); + $this->db->rollback(); + return -1; + } + } + + /** + * Get full path to PDF file + * + * @return string Full path or empty string + */ + public function getFilePath() + { + if ($this->filepath && file_exists($this->filepath)) { + return $this->filepath; + } + return ''; + } + + /** + * Get storage directory for statements + * + * @return string Directory path + */ + public static function getStorageDir() + { + global $conf; + + $dir = $conf->bankimport->dir_output.'/statements'; + if (!is_dir($dir)) { + dol_mkdir($dir); + } + return $dir; + } + + /** + * Save PDF content to file + * + * @param string $pdfContent Binary PDF content + * @return int <0 if KO, >0 if OK + */ + public function savePDF($pdfContent) + { + $dir = self::getStorageDir(); + + // Generate filename + $this->filename = sprintf('statement_%s_%d_%s.pdf', + preg_replace('/[^A-Z0-9]/', '', $this->iban), + $this->statement_year, + $this->statement_number + ); + + $this->filepath = $dir.'/'.$this->filename; + + // Write file + $result = file_put_contents($this->filepath, $pdfContent); + + if ($result !== false) { + $this->filesize = strlen($pdfContent); + return 1; + } + + $this->error = 'Failed to write PDF file'; + return -1; + } + + /** + * Save uploaded PDF file + * + * @param array $fileInfo Element from $_FILES array + * @return int <0 if KO, >0 if OK + */ + public function saveUploadedPDF($fileInfo) + { + // Validate upload + if (empty($fileInfo['tmp_name']) || !is_uploaded_file($fileInfo['tmp_name'])) { + $this->error = 'No file uploaded'; + return -1; + } + + // Check file size (max 10MB) + if ($fileInfo['size'] > 10 * 1024 * 1024) { + $this->error = 'File too large (max 10MB)'; + return -1; + } + + // Check MIME type + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $mimeType = finfo_file($finfo, $fileInfo['tmp_name']); + finfo_close($finfo); + + if ($mimeType !== 'application/pdf') { + $this->error = 'Only PDF files are allowed'; + return -1; + } + + $dir = self::getStorageDir(); + + // Generate filename + $ibanPart = !empty($this->iban) ? preg_replace('/[^A-Z0-9]/', '', strtoupper($this->iban)) : 'KONTO'; + $this->filename = sprintf('Kontoauszug_%s_%d_%s.pdf', + $ibanPart, + $this->statement_year, + str_pad($this->statement_number, 3, '0', STR_PAD_LEFT) + ); + + $this->filepath = $dir.'/'.$this->filename; + + // Check if file already exists + if (file_exists($this->filepath)) { + // Add timestamp to make unique + $this->filename = sprintf('Kontoauszug_%s_%d_%s_%s.pdf', + $ibanPart, + $this->statement_year, + str_pad($this->statement_number, 3, '0', STR_PAD_LEFT), + date('His') + ); + $this->filepath = $dir.'/'.$this->filename; + } + + // Move uploaded file + if (!move_uploaded_file($fileInfo['tmp_name'], $this->filepath)) { + $this->error = 'Failed to save file'; + return -1; + } + + $this->filesize = filesize($this->filepath); + + return 1; + } + + /** + * Parse PDF bank statement metadata using pdfinfo and pdftotext + * + * Extracts: statement number, year, IBAN, date range, opening/closing balance, + * account number, bank name, statement date. + * + * @param string $filepath Path to PDF file + * @return array|false Array with extracted data or false on failure + */ + public static function parsePdfMetadata($filepath) + { + if (!file_exists($filepath)) { + return false; + } + + $result = array( + 'statement_number' => '', + 'statement_year' => 0, + 'pdf_number' => '', // Original statement number from PDF (e.g. "1" from Nr. 1/2025) + 'pdf_year' => 0, // Original year from PDF + 'iban' => '', + 'date_from' => null, + 'date_to' => null, + 'opening_balance' => null, + 'closing_balance' => null, + 'statement_date' => null, + 'account_number' => '', + 'bank_name' => '', + 'author' => '', + ); + + $escapedPath = escapeshellarg($filepath); + + // 1. Extract metadata via pdfinfo + $pdfinfo = array(); + exec("pdfinfo ".$escapedPath." 2>/dev/null", $pdfinfo); + + foreach ($pdfinfo as $line) { + if (preg_match('/^Title:\s+(.+)$/', $line, $m)) { + // Title format: "000000000000000000000013438147 001/2025" or "Kontoauszug 13438147" + if (preg_match('/(\d+)\s+(\d+)\/(\d{4})/', $m[1], $tm)) { + $result['account_number'] = ltrim($tm[1], '0'); + $result['pdf_number'] = (string) intval($tm[2]); + $result['pdf_year'] = (int) $tm[3]; + } + } + if (preg_match('/^Author:\s+(.+)$/', $line, $m)) { + $result['author'] = trim($m[1]); + } + } + + // 2. Extract text via pdftotext + $text = ''; + exec("pdftotext -layout ".$escapedPath." - 2>/dev/null", $textlines); + $text = implode("\n", $textlines); + + // Statement number from text (fallback if not in metadata) + if (empty($result['pdf_number']) && preg_match('/Nr\.\s+(\d+)\/(\d{4})/', $text, $m)) { + $result['pdf_number'] = (string) intval($m[1]); + $result['pdf_year'] = (int) $m[2]; + } + + // IBAN + if (preg_match('/IBAN:\s*([A-Z]{2}\d{2}\s*[\d\s]+)/', $text, $m)) { + $result['iban'] = preg_replace('/\s+/', ' ', trim($m[1])); + } + + // Account number (fallback) + if (empty($result['account_number']) && preg_match('/Kontonummer\s+(\d+)/', $text, $m)) { + $result['account_number'] = $m[1]; + } + + // Date range from Kontoabschluss + if (preg_match('/Kontoabschluss vom (\d{2}\.\d{2}\.\d{4}) bis (\d{2}\.\d{2}\.\d{4})/', $text, $m)) { + $dateFrom = DateTime::createFromFormat('d.m.Y', $m[1]); + $dateTo = DateTime::createFromFormat('d.m.Y', $m[2]); + if ($dateFrom) { + $result['date_from'] = $dateFrom->setTime(0, 0, 0)->getTimestamp(); + } + if ($dateTo) { + $result['date_to'] = $dateTo->setTime(0, 0, 0)->getTimestamp(); + } + } + + // Statement date (erstellt am) + if (preg_match('/erstellt am\s+(\d{2}\.\d{2}\.\d{4})/', $text, $m)) { + $stmtDate = DateTime::createFromFormat('d.m.Y', $m[1]); + if ($stmtDate) { + $result['statement_date'] = $stmtDate->setTime(0, 0, 0)->getTimestamp(); + } + } + + // Opening balance: "alter Kontostand [vom DD.MM.YYYY] X.XXX,XX H/S" + if (preg_match('/alter Kontostand(?:\s+vom\s+\d{2}\.\d{2}\.\d{4})?\s+([\d.,]+)\s+(H|S)/', $text, $m)) { + $amount = self::parseGermanAmount($m[1]); + if ($m[2] === 'S') { + $amount = -$amount; + } + $result['opening_balance'] = $amount; + } + + // Closing balance: "neuer Kontostand vom DD.MM.YYYY X.XXX,XX H/S" + if (preg_match('/neuer Kontostand(?:\s+vom\s+\d{2}\.\d{2}\.\d{4})?\s+([\d.,]+)\s+(H|S)/', $text, $m)) { + $amount = self::parseGermanAmount($m[1]); + if ($m[2] === 'S') { + $amount = -$amount; + } + $result['closing_balance'] = $amount; + } + + // Bank name (first line that contains "Bank" or known patterns) + if (preg_match('/(?:VR\s*B\s*ank|Volksbank|Raiffeisenbank|Sparkasse)[^\n]*/i', $text, $m)) { + $bankName = trim($m[0]); + // Fix OCR artifacts: single chars separated by spaces ("V R B a n k" → "VRBank") + // Strategy: collapse all single-space gaps between word chars that look like OCR splitting + $bankName = preg_replace('/\b(\w) (\w) (\w) (\w)\b/', '$1$2$3$4', $bankName); + $bankName = preg_replace('/\b(\w) (\w) (\w)\b/', '$1$2$3', $bankName); + $bankName = preg_replace('/\b(\w) (\w)\b/', '$1$2', $bankName); + // Fix common OCR pattern "VR B ank" → "VR Bank", "S chleswig" → "Schleswig" + $bankName = preg_replace('/\bB ank\b/', 'Bank', $bankName); + $bankName = preg_replace('/\bS (\w)/', 'S$1', $bankName); + $bankName = preg_replace('/\bW (\w)/', 'W$1', $bankName); + // Clean up multiple spaces and trim address parts after comma + $bankName = preg_replace('/\s{2,}/', ' ', $bankName); + $bankName = preg_replace('/,.*$/', '', $bankName); + $result['bank_name'] = trim($bankName); + } + + // Derive statement_number (=month) and statement_year from end date of period + if ($result['date_to']) { + $result['statement_number'] = (string) intval(date('m', $result['date_to'])); + $result['statement_year'] = (int) date('Y', $result['date_to']); + } elseif ($result['date_from']) { + $result['statement_number'] = (string) intval(date('m', $result['date_from'])); + $result['statement_year'] = (int) date('Y', $result['date_from']); + } elseif (!empty($result['pdf_year'])) { + // Fallback to PDF metadata if no date range + $result['statement_number'] = $result['pdf_number']; + $result['statement_year'] = $result['pdf_year']; + } + + // Fallback: extract data from filename if PDF tools returned nothing + // Supports patterns like: 13438147_2025_Nr.001_Kontoauszug_vom_2025.07.01_timestamp.pdf + if (empty($result['statement_number']) && empty($result['iban'])) { + $basename = basename($filepath); + if (preg_match('/(\d+)_(\d{4})_Nr\.?(\d+)/', $basename, $fm)) { + $result['account_number'] = ltrim($fm[1], '0'); + $result['pdf_number'] = (string) intval($fm[3]); + $result['pdf_year'] = (int) $fm[2]; + $result['statement_number'] = $result['pdf_number']; + $result['statement_year'] = $result['pdf_year']; + } + if (preg_match('/vom[_\s](\d{4})\.(\d{2})\.(\d{2})/', $basename, $dm)) { + $stmtDate = DateTime::createFromFormat('Y-m-d', $dm[1].'-'.$dm[2].'-'.$dm[3]); + if ($stmtDate) { + $result['statement_date'] = $stmtDate->setTime(0, 0, 0)->getTimestamp(); + if (empty($result['statement_number'])) { + $result['statement_number'] = (string) intval($dm[2]); + $result['statement_year'] = (int) $dm[1]; + } + } + } + } + + // Validate: at least statement number or IBAN must be present + if (empty($result['statement_number']) && empty($result['iban'])) { + return false; + } + + return $result; + } + + /** + * Parse a German formatted amount (e.g., "3.681,45" → 3681.45) + * + * @param string $amount German formatted amount string + * @return float Parsed amount + */ + private static function parseGermanAmount($amount) + { + $amount = str_replace('.', '', $amount); // Remove thousands separator + $amount = str_replace(',', '.', $amount); // Convert decimal separator + return (float) $amount; + } + + /** + * Generate a clean filename for a PDF statement + * + * @param array $parsed Parsed metadata from parsePdfMetadata() + * @return string Generated filename + */ + public static function generateFilename($parsed) + { + $bank = 'Bank'; + if (!empty($parsed['bank_name'])) { + // Shorten bank name - take first meaningful words + $bank = preg_replace('/\s+(eG|AG|e\.G\.).*$/', '', $parsed['bank_name']); + $bank = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß-]/', '_', $bank); + $bank = preg_replace('/_+/', '_', $bank); + $bank = trim($bank, '_'); + } + + $account = !empty($parsed['account_number']) ? $parsed['account_number'] : 'Konto'; + $year = !empty($parsed['statement_year']) ? $parsed['statement_year'] : date('Y'); + $nr = !empty($parsed['statement_number']) ? str_pad($parsed['statement_number'], 3, '0', STR_PAD_LEFT) : '000'; + + return sprintf('%s_%s_%d_%s.pdf', $bank, $account, $year, $nr); + } + + /** + * Get next available statement number for a year + * + * @param int $year Year + * @return string Next statement number + */ + public function getNextStatementNumber($year) + { + $sql = "SELECT MAX(CAST(statement_number AS UNSIGNED)) as maxnum"; + $sql .= " FROM ".MAIN_DB_PREFIX."bankimport_statement"; + $sql .= " WHERE statement_year = ".((int) $year); + $sql .= " AND entity = ".((int) $this->entity); + + $resql = $this->db->query($sql); + if ($resql) { + $obj = $this->db->fetch_object($resql); + $nextNum = ($obj->maxnum !== null) ? ((int) $obj->maxnum + 1) : 1; + return (string) $nextNum; + } + return '1'; + } + + /** + * Get the end date (date_to) of the most recent statement + * + * @return int|null Timestamp of latest date_to, or null if none + */ + public function getLatestStatementEndDate() + { + $sql = "SELECT MAX(date_to) as last_date"; + $sql .= " FROM ".MAIN_DB_PREFIX."bankimport_statement"; + $sql .= " WHERE entity = ".((int) $this->entity); + + $resql = $this->db->query($sql); + if ($resql) { + $obj = $this->db->fetch_object($resql); + if ($obj->last_date) { + return $this->db->jdate($obj->last_date); + } + } + return null; + } + + /** + * Get list of years that have stored statements + * + * @return array Array of years (descending) + */ + public function getAvailableYears() + { + $sql = "SELECT DISTINCT statement_year"; + $sql .= " FROM ".MAIN_DB_PREFIX."bankimport_statement"; + $sql .= " WHERE entity = ".((int) $this->entity); + $sql .= " ORDER BY statement_year DESC"; + + $result = array(); + $resql = $this->db->query($sql); + if ($resql) { + while ($obj = $this->db->fetch_object($resql)) { + $result[(int) $obj->statement_year] = (string) $obj->statement_year; + } + $this->db->free($resql); + } + return $result; + } + + /** + * Reconcile bank entries using parsed statement lines. + * + * Strategy: Match each statement_line (parsed from PDF) to a llx_bank entry + * by amount + date (with tolerance). This is authoritative because the + * statement lines come directly from the bank's PDF and represent exactly + * what transactions belong to this statement. + * + * Matching priority: + * 1. Exact amount + exact date + * 2. Exact amount + date within 4 days tolerance + * + * @param User $user User performing the reconciliation + * @param int $bankAccountId Dolibarr bank account ID (llx_bank_account.rowid) + * @return int Number of reconciled entries, or -1 on error + */ + public function reconcileBankEntries($user, $bankAccountId) + { + if (empty($this->id) || empty($bankAccountId)) { + $this->error = 'Missing required fields for reconciliation'; + return 0; + } + + require_once DOL_DOCUMENT_ROOT.'/compta/bank/class/account.class.php'; + + // Format statement number: "NR/YYYY" (e.g., "3/2025") + $numReleve = $this->statement_number.'/'.$this->statement_year; + + // Get statement lines + $lines = $this->getStatementLines(); + if (!is_array($lines) || empty($lines)) { + // No statement lines parsed yet — nothing to reconcile + $this->copyToDolibarrStatementDir($bankAccountId); + return 0; + } + + $reconciled = 0; + $usedBankIds = array(); + + foreach ($lines as $line) { + $amount = (float) $line->amount; + $dateBooking = $line->date_booking; // YYYY-MM-DD string + + // Step 1: Try exact amount + exact date + $sqlMatch = "SELECT b.rowid"; + $sqlMatch .= " FROM ".MAIN_DB_PREFIX."bank as b"; + $sqlMatch .= " WHERE b.fk_account = ".((int) $bankAccountId); + $sqlMatch .= " AND b.rappro = 0"; + $sqlMatch .= " AND b.amount = ".$amount; + $sqlMatch .= " AND b.datev = '".$this->db->escape($dateBooking)."'"; + if (!empty($usedBankIds)) { + $sqlMatch .= " AND b.rowid NOT IN (".implode(',', $usedBankIds).")"; + } + $sqlMatch .= " LIMIT 1"; + + $resMatch = $this->db->query($sqlMatch); + $matched = false; + + if ($resMatch && $this->db->num_rows($resMatch) > 0) { + $match = $this->db->fetch_object($resMatch); + $matched = $this->reconcileBankLine($user, $match->rowid, $numReleve, $line->rowid, $usedBankIds); + } + + // Step 2: Try exact amount + date within 4 days tolerance + if (!$matched) { + $sqlMatch2 = "SELECT b.rowid, ABS(DATEDIFF(b.datev, '".$this->db->escape($dateBooking)."')) as date_diff"; + $sqlMatch2 .= " FROM ".MAIN_DB_PREFIX."bank as b"; + $sqlMatch2 .= " WHERE b.fk_account = ".((int) $bankAccountId); + $sqlMatch2 .= " AND b.rappro = 0"; + $sqlMatch2 .= " AND b.amount = ".$amount; + $sqlMatch2 .= " AND ABS(DATEDIFF(b.datev, '".$this->db->escape($dateBooking)."')) <= 4"; + if (!empty($usedBankIds)) { + $sqlMatch2 .= " AND b.rowid NOT IN (".implode(',', $usedBankIds).")"; + } + $sqlMatch2 .= " ORDER BY date_diff ASC LIMIT 1"; + + $resMatch2 = $this->db->query($sqlMatch2); + if ($resMatch2 && $this->db->num_rows($resMatch2) > 0) { + $match2 = $this->db->fetch_object($resMatch2); + $matched = $this->reconcileBankLine($user, $match2->rowid, $numReleve, $line->rowid, $usedBankIds); + } + } + + // Step 3: Match by supplier invoice numbers in description + if (!$matched && !empty($line->description)) { + $matched = $this->reconcileByInvoiceNumbers($user, $line, $bankAccountId, $numReleve, $usedBankIds); + } + + if ($matched) { + $reconciled++; + } + } + + // Copy PDF to Dolibarr's bank statement document directory + $this->copyToDolibarrStatementDir($bankAccountId); + + return $reconciled; + } + + /** + * Reconcile a single bank line: set num_releve, rappro=1, link statement_line + * + * @param User $user User + * @param int $bankRowId llx_bank.rowid + * @param string $numReleve Statement number (e.g. "3/2025") + * @param int $lineRowId llx_bankimport_statement_line.rowid + * @param array &$usedBankIds Reference to array of already used bank IDs + * @return bool True if reconciled successfully + */ + private function reconcileBankLine($user, $bankRowId, $numReleve, $lineRowId, &$usedBankIds) + { + $bankLine = new AccountLine($this->db); + $bankLine->fetch($bankRowId); + $bankLine->num_releve = $numReleve; + + $result = $bankLine->update_conciliation($user, 0, 1); + if ($result >= 0) { + $usedBankIds[] = (int) $bankRowId; + + // Link statement line to this bank entry + $sql = "UPDATE ".MAIN_DB_PREFIX."bankimport_statement_line SET"; + $sql .= " fk_bank = ".((int) $bankRowId); + $sql .= ", match_status = 'reconciled'"; + $sql .= " WHERE rowid = ".((int) $lineRowId); + $this->db->query($sql); + + return true; + } + return false; + } + + /** + * Mark a statement line as pending review (matched by invoice number but amount + * difference exceeds threshold). Sets fk_bank as candidate but does NOT reconcile + * the bank entry (rappro stays 0). + * + * @param int $bankRowId llx_bank.rowid (candidate) + * @param int $lineRowId llx_bankimport_statement_line.rowid + * @return bool True if saved + */ + private function markPendingReview($bankRowId, $lineRowId) + { + $sql = "UPDATE ".MAIN_DB_PREFIX."bankimport_statement_line SET"; + $sql .= " fk_bank = ".((int) $bankRowId); + $sql .= ", match_status = 'pending_review'"; + $sql .= " WHERE rowid = ".((int) $lineRowId); + + return (bool) $this->db->query($sql); + } + + /** + * Extract supplier invoice numbers from statement line description + * and find matching llx_bank entries via the payment chain. + * + * Patterns recognized: + * - /INV/9009414207 (Firmenlastschrift) + * - /ADV/0014494147 (Avise) + * - Beleg-Nr.: 9008468982 (Überweisungsauftrag) + * + * Chain: ref_supplier → llx_facture_fourn → llx_paiementfourn_facturefourn + * → llx_paiementfourn → llx_bank_url → llx_bank + * + * @param User $user User + * @param object $line Statement line object + * @param int $bankAccountId Dolibarr bank account ID + * @param string $numReleve Statement number (e.g. "3/2025") + * @param array &$usedBankIds Reference to array of already used bank IDs + * @return bool True if at least one bank entry was reconciled + */ + private function reconcileByInvoiceNumbers($user, $line, $bankAccountId, $numReleve, &$usedBankIds) + { + $desc = $line->description.' '.$line->name; + + // Extract invoice/reference numbers from description + $refNumbers = array(); + + // Pattern 1: /INV/XXXXXXXXXX + if (preg_match_all('/\/INV\/(\d+)/', $desc, $m)) { + $refNumbers = array_merge($refNumbers, $m[1]); + } + + // Pattern 2: /ADV/XXXXXXXXXX + if (preg_match_all('/\/ADV\/(\d+)/', $desc, $m)) { + $refNumbers = array_merge($refNumbers, $m[1]); + } + + // Pattern 3: Beleg-Nr.: XXXXXXXXXX or Beleg-Nr. XXXXXXXXXX + if (preg_match_all('/Beleg-Nr\.?\s*:?\s*(\d{5,})/', $desc, $m)) { + $refNumbers = array_merge($refNumbers, $m[1]); + } + + if (empty($refNumbers)) { + return false; + } + + $refNumbers = array_unique($refNumbers); + + // Build escaped list for SQL IN clause + $escapedRefs = array(); + foreach ($refNumbers as $ref) { + $escapedRefs[] = "'".$this->db->escape($ref)."'"; + } + $inClause = implode(',', $escapedRefs); + + // Find llx_bank entries linked to supplier payments for these invoice numbers + $sql = "SELECT DISTINCT b.rowid, b.amount"; + $sql .= " FROM ".MAIN_DB_PREFIX."bank as b"; + $sql .= " JOIN ".MAIN_DB_PREFIX."bank_url as bu ON bu.fk_bank = b.rowid AND bu.type = 'payment_supplier'"; + $sql .= " JOIN ".MAIN_DB_PREFIX."paiementfourn as pf ON pf.rowid = bu.url_id"; + $sql .= " JOIN ".MAIN_DB_PREFIX."paiementfourn_facturefourn as pfff ON pfff.fk_paiementfourn = pf.rowid"; + $sql .= " JOIN ".MAIN_DB_PREFIX."facture_fourn as ff ON ff.rowid = pfff.fk_facturefourn"; + $sql .= " WHERE b.fk_account = ".((int) $bankAccountId); + $sql .= " AND b.rappro = 0"; + $sql .= " AND ff.ref_supplier IN (".$inClause.")"; + if (!empty($usedBankIds)) { + $sql .= " AND b.rowid NOT IN (".implode(',', $usedBankIds).")"; + } + + dol_syslog(get_class($this)."::reconcileByInvoiceNumbers refs=".implode(',', $refNumbers), LOG_DEBUG); + $resql = $this->db->query($sql); + + if (!$resql) { + return false; + } + + $matched = false; + $stmtAmount = abs((float) $line->amount); + + while ($obj = $this->db->fetch_object($resql)) { + $bankAmount = abs((float) $obj->amount); + $diff = abs($stmtAmount - $bankAmount); + + if ($diff > 5.0) { + // Differenz > 5 EUR: nur als Kandidat markieren, nicht abgleichen + $this->markPendingReview($obj->rowid, $line->rowid); + dol_syslog(get_class($this)."::reconcileByInvoiceNumbers PENDING bank=".$obj->rowid." diff=".$diff, LOG_WARNING); + } else { + // Differenz <= 5 EUR: automatisch abgleichen + if ($this->reconcileBankLine($user, $obj->rowid, $numReleve, $line->rowid, $usedBankIds)) { + $matched = true; + } + } + } + $this->db->free($resql); + + return $matched; + } + + /** + * Copy the PDF file to Dolibarr's bank statement document directory + * so it appears in the Documents tab of account_statement_document.php + * + * Target: $conf->bank->dir_output."/".$bankAccountId."/statement/".dol_sanitizeFileName($numReleve)."/" + * + * @param int $bankAccountId Dolibarr bank account ID + * @return int 1 if OK, 0 if nothing to copy, -1 on error + */ + public function copyToDolibarrStatementDir($bankAccountId) + { + global $conf; + + if (empty($this->filepath) || !file_exists($this->filepath)) { + return 0; + } + + if (empty($bankAccountId) || empty($this->statement_number) || empty($this->statement_year)) { + return 0; + } + + $numReleve = $this->statement_number.'/'.$this->statement_year; + $targetDir = $conf->bank->dir_output.'/'.((int) $bankAccountId).'/statement/'.dol_sanitizeFileName($numReleve); + + if (!is_dir($targetDir)) { + dol_mkdir($targetDir); + } + + $targetFile = $targetDir.'/'.$this->filename; + + // Don't copy if already exists with same size + if (file_exists($targetFile) && filesize($targetFile) == filesize($this->filepath)) { + return 1; + } + + $result = @copy($this->filepath, $targetFile); + if ($result) { + @chmod($targetFile, 0664); + return 1; + } + + $this->error = 'Failed to copy PDF to '.$targetDir; + return -1; + } + + /** + * Parse individual transaction lines from a PDF bank statement. + * + * Extracts booking date, value date, transaction type, amount, counterparty name + * and description text from VR-Bank PDF statement format. + * + * @param string $filepath Path to PDF file (uses $this->filepath if empty) + * @return array Array of transaction arrays, each with keys: + * date_booking, date_value, transaction_type, amount, name, description + */ + public function parsePdfTransactions($filepath = '') + { + if (empty($filepath)) { + $filepath = $this->filepath; + } + if (empty($filepath) || !file_exists($filepath)) { + return array(); + } + + $escapedPath = escapeshellarg($filepath); + $textlines = array(); + exec("pdftotext -layout ".$escapedPath." - 2>/dev/null", $textlines); + + // Determine statement year from metadata + $stmtYear = !empty($this->statement_year) ? (int) $this->statement_year : (int) date('Y'); + + $transactions = array(); + $currentTx = null; + $inTransactionBlock = false; + $skipPageBreak = false; // True between "Übertrag auf" and "Übertrag von" + + foreach ($textlines as $line) { + // Stop parsing at "Anlage" (fee detail section) or "Der ausgewiesene Kontostand" + if (preg_match('/^\s*Anlage\s+\d/', $line) || preg_match('/Der ausgewiesene Kontostand/', $line)) { + if ($currentTx !== null) { + $transactions[] = $currentTx; + $currentTx = null; + } + break; + } + + // Handle page breaks: skip everything between "Übertrag auf Blatt" and "Übertrag von Blatt" + if (preg_match('/Übertrag\s+auf\s+Blatt/', $line)) { + // Save current transaction before page break + if ($currentTx !== null) { + $transactions[] = $currentTx; + $currentTx = null; + } + $skipPageBreak = true; + continue; + } + if (preg_match('/Übertrag\s+von\s+Blatt/', $line)) { + $skipPageBreak = false; + continue; + } + if ($skipPageBreak) { + continue; + } + + // Skip blank lines and decorative lines + if (preg_match('/^\s*$/', $line) || preg_match('/────/', $line)) { + continue; + } + + // Detect start of transaction block + if (preg_match('/Bu-Tag\s+Wert\s+Vorgang/', $line)) { + $inTransactionBlock = true; + continue; + } + + if (!$inTransactionBlock) { + continue; + } + + // Skip balance lines + if (preg_match('/alter Kontostand/', $line) || preg_match('/neuer Kontostand/', $line)) { + if ($currentTx !== null) { + $transactions[] = $currentTx; + $currentTx = null; + } + continue; + } + + // Transaction line: "DD.MM. DD.MM. Vorgangsart ... Betrag S/H" + if (preg_match('/^\s+(\d{2})\.(\d{2})\.\s+(\d{2})\.(\d{2})\.\s+(.+)/', $line, $m)) { + // Save previous transaction + if ($currentTx !== null) { + $transactions[] = $currentTx; + } + + $bookDay = (int) $m[1]; + $bookMonth = (int) $m[2]; + $valDay = (int) $m[3]; + $valMonth = (int) $m[4]; + $rest = $m[5]; + + $bookYear = $stmtYear; + $valYear = $stmtYear; + + // Parse amount and S/H from the rest of the line + $amount = 0; + $txType = ''; + + if (preg_match('/^(.+?)\s+([\d.,]+)\s+(S|H)\s*$/', $rest, $am)) { + $txType = trim($am[1]); + $amount = self::parseGermanAmount($am[2]); + if ($am[3] === 'S') { + $amount = -$amount; + } + } else { + $txType = trim($rest); + } + + // Build date strings + $dateBooking = sprintf('%04d-%02d-%02d', $bookYear, $bookMonth, $bookDay); + $dateValue = sprintf('%04d-%02d-%02d', $valYear, $valMonth, $valDay); + + $currentTx = array( + 'date_booking' => $dateBooking, + 'date_value' => $dateValue, + 'transaction_type' => $txType, + 'amount' => $amount, + 'name' => '', + 'description' => '', + ); + } elseif ($currentTx !== null) { + // Continuation line (counterparty name or description) + $detail = trim($line); + if (!empty($detail)) { + if (empty($currentTx['name'])) { + $currentTx['name'] = $detail; + } else { + $currentTx['description'] .= ($currentTx['description'] ? ' ' : '').$detail; + } + } + } + } + + // Don't forget the last transaction + if ($currentTx !== null) { + $transactions[] = $currentTx; + } + + return $transactions; + } + + /** + * Save parsed transaction lines to the database. + * Deletes existing lines for this statement first, then inserts new ones. + * + * @param array $transactions Array from parsePdfTransactions() + * @return int Number of lines saved, or -1 on error + */ + public function saveStatementLines($transactions) + { + if (empty($this->id)) { + $this->error = 'Statement not saved yet'; + return -1; + } + + $this->db->begin(); + + // Delete existing lines for this statement + $sql = "DELETE FROM ".MAIN_DB_PREFIX."bankimport_statement_line"; + $sql .= " WHERE fk_statement = ".((int) $this->id); + $this->db->query($sql); + + $now = dol_now(); + $count = 0; + + foreach ($transactions as $i => $tx) { + $lineNum = $i + 1; + + $sql = "INSERT INTO ".MAIN_DB_PREFIX."bankimport_statement_line ("; + $sql .= "fk_statement, entity, line_number, date_booking, date_value,"; + $sql .= "transaction_type, amount, currency, name, description, datec"; + $sql .= ") VALUES ("; + $sql .= ((int) $this->id).","; + $sql .= ((int) $this->entity).","; + $sql .= ((int) $lineNum).","; + $sql .= "'".$this->db->escape($tx['date_booking'])."',"; + $sql .= (!empty($tx['date_value']) ? "'".$this->db->escape($tx['date_value'])."'" : "NULL").","; + $sql .= (!empty($tx['transaction_type']) ? "'".$this->db->escape($tx['transaction_type'])."'" : "NULL").","; + $sql .= ((float) $tx['amount']).","; + $sql .= "'EUR',"; + $sql .= (!empty($tx['name']) ? "'".$this->db->escape($tx['name'])."'" : "NULL").","; + $sql .= (!empty($tx['description']) ? "'".$this->db->escape($tx['description'])."'" : "NULL").","; + $sql .= "'".$this->db->idate($now)."'"; + $sql .= ")"; + + $resql = $this->db->query($sql); + if ($resql) { + $count++; + } else { + $this->error = $this->db->lasterror(); + $this->db->rollback(); + return -1; + } + } + + $this->db->commit(); + return $count; + } + + /** + * Get statement lines from database + * + * @return array Array of line objects, or -1 on error + */ + public function getStatementLines() + { + $sql = "SELECT rowid, fk_statement, line_number, date_booking, date_value,"; + $sql .= " transaction_type, amount, currency, name, description, fk_bank, match_status"; + $sql .= " FROM ".MAIN_DB_PREFIX."bankimport_statement_line"; + $sql .= " WHERE fk_statement = ".((int) $this->id); + $sql .= " ORDER BY line_number ASC"; + + $resql = $this->db->query($sql); + if (!$resql) { + $this->error = $this->db->lasterror(); + return -1; + } + + $lines = array(); + while ($obj = $this->db->fetch_object($resql)) { + $lines[] = $obj; + } + $this->db->free($resql); + + return $lines; + } + + /** + * Link transactions to this statement based on date range and IBAN + * + * Updates all transactions that fall within the statement's date range + * and match the IBAN, setting their fk_statement to this statement's ID. + * + * @return int Number of linked transactions, or -1 on error + */ + public function linkTransactions() + { + if (empty($this->id) || empty($this->date_from) || empty($this->date_to)) { + return 0; + } + + $sql = "UPDATE ".MAIN_DB_PREFIX."bankimport_transaction SET"; + $sql .= " fk_statement = ".((int) $this->id); + $sql .= " WHERE entity = ".((int) $this->entity); + $sql .= " AND date_trans >= '".$this->db->idate($this->date_from)."'"; + $sql .= " AND date_trans <= '".$this->db->idate($this->date_to)."'"; + $sql .= " AND fk_statement IS NULL"; // Don't overwrite existing links + + // Match by IBAN if available + if (!empty($this->iban)) { + $ibanClean = preg_replace('/\s+/', '', $this->iban); + $sql .= " AND REPLACE(iban, ' ', '') = '".$this->db->escape($ibanClean)."'"; + } + + dol_syslog(get_class($this)."::linkTransactions", LOG_DEBUG); + $resql = $this->db->query($sql); + + if ($resql) { + return $this->db->affected_rows($resql); + } else { + $this->error = $this->db->lasterror(); + return -1; + } + } +} diff --git a/class/banktransaction.class.php b/class/banktransaction.class.php new file mode 100755 index 0000000..5971c9c --- /dev/null +++ b/class/banktransaction.class.php @@ -0,0 +1,2289 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +/** + * \file bankimport/class/banktransaction.class.php + * \ingroup bankimport + * \brief Class for bank transactions from FinTS import + */ + +require_once DOL_DOCUMENT_ROOT.'/core/class/commonobject.class.php'; + +/** + * Class BankImportTransaction + * Represents a bank transaction imported via FinTS + */ +class BankImportTransaction extends CommonObject +{ + /** + * @var string ID to identify managed object + */ + public $element = 'banktransaction'; + + /** + * @var string Name of table without prefix where object is stored + */ + public $table_element = 'bankimport_transaction'; + + /** + * @var string Primary key name + */ + public $pk = 'rowid'; + + /** + * @var int Entity + */ + public $entity; + + /** + * @var string Unique reference (hash) + */ + public $ref; + + /** + * @var string IBAN + */ + public $iban; + + /** + * @var string BIC + */ + public $bic; + + /** + * @var int Creation timestamp + */ + public $datec; + + /** + * @var int Transaction date + */ + public $date_trans; + + /** + * @var int Value date + */ + public $date_value; + + /** + * @var string Counterparty name + */ + public $name; + + /** + * @var string Counterparty IBAN + */ + public $counterparty_iban; + + /** + * @var string Counterparty BIC + */ + public $counterparty_bic; + + /** + * @var float Amount + */ + public $amount; + + /** + * @var string Currency + */ + public $currency = 'EUR'; + + /** + * @var string Short label + */ + public $label; + + /** + * @var string Full description + */ + public $description; + + /** + * @var string End-to-end ID + */ + public $end_to_end_id; + + /** + * @var string Mandate ID + */ + public $mandate_id; + + /** + * @var int Link to llx_bank + */ + public $fk_bank; + + /** + * @var int Link to llx_facture + */ + public $fk_facture; + + /** + * @var int Link to llx_facture_fourn + */ + public $fk_facture_fourn; + + /** + * @var int Link to llx_paiement + */ + public $fk_paiement; + + /** + * @var int Link to llx_paiementfourn + */ + public $fk_paiementfourn; + + /** + * @var int Link to llx_salary + */ + public $fk_salary; + + /** + * @var int Link to llx_don + */ + public $fk_don; + + /** + * @var int Link to llx_loan + */ + public $fk_loan; + + /** + * @var int Link to llx_societe + */ + public $fk_societe; + + /** + * @var int Link to llx_bankimport_statement + */ + public $fk_statement; + + /** + * @var int Status (0=new, 1=matched, 2=reconciled, 9=ignored) + */ + public $status = 0; + + /** + * @var string Import batch key + */ + public $import_key; + + /** + * @var int User who created + */ + public $fk_user_creat; + + /** + * @var int User who modified + */ + public $fk_user_modif; + + /** + * @var int User who matched + */ + public $fk_user_match; + + /** + * @var int Match date + */ + public $date_match; + + /** + * @var string Private note + */ + public $note_private; + + /** + * @var string Public note + */ + public $note_public; + + // Status constants + const STATUS_NEW = 0; + const STATUS_MATCHED = 1; + const STATUS_RECONCILED = 2; + const STATUS_IGNORED = 9; + + /** + * Constructor + * + * @param DoliDB $db Database handler + */ + public function __construct($db) + { + global $conf; + + $this->db = $db; + $this->entity = $conf->entity; + } + + /** + * Create transaction in database + * + * @param User $user User that creates + * @param bool $notrigger Disable triggers + * @return int <0 if KO, Id of created object if OK + */ + public function create($user, $notrigger = false) + { + global $conf; + + $error = 0; + $now = dol_now(); + + // Generate reference hash if not set + if (empty($this->ref)) { + $this->ref = $this->generateRef(); + } + + $this->db->begin(); + + $sql = "INSERT INTO ".MAIN_DB_PREFIX."bankimport_transaction ("; + $sql .= "ref, entity, iban, bic, datec, date_trans, date_value,"; + $sql .= "name, counterparty_iban, counterparty_bic,"; + $sql .= "amount, currency, label, description, end_to_end_id, mandate_id,"; + $sql .= "status, import_key, fk_user_creat"; + $sql .= ") VALUES ("; + $sql .= "'".$this->db->escape($this->ref)."',"; + $sql .= ((int) $this->entity).","; + $sql .= ($this->iban ? "'".$this->db->escape($this->iban)."'" : "NULL").","; + $sql .= ($this->bic ? "'".$this->db->escape($this->bic)."'" : "NULL").","; + $sql .= "'".$this->db->idate($now)."',"; + $sql .= "'".$this->db->idate($this->date_trans)."',"; + $sql .= ($this->date_value ? "'".$this->db->idate($this->date_value)."'" : "NULL").","; + $sql .= ($this->name ? "'".$this->db->escape($this->name)."'" : "NULL").","; + $sql .= ($this->counterparty_iban ? "'".$this->db->escape($this->counterparty_iban)."'" : "NULL").","; + $sql .= ($this->counterparty_bic ? "'".$this->db->escape($this->counterparty_bic)."'" : "NULL").","; + $sql .= ((float) $this->amount).","; + $sql .= "'".$this->db->escape($this->currency)."',"; + $sql .= ($this->label ? "'".$this->db->escape($this->label)."'" : "NULL").","; + $sql .= ($this->description ? "'".$this->db->escape($this->description)."'" : "NULL").","; + $sql .= ($this->end_to_end_id ? "'".$this->db->escape($this->end_to_end_id)."'" : "NULL").","; + $sql .= ($this->mandate_id ? "'".$this->db->escape($this->mandate_id)."'" : "NULL").","; + $sql .= ((int) $this->status).","; + $sql .= ($this->import_key ? "'".$this->db->escape($this->import_key)."'" : "NULL").","; + $sql .= ((int) $user->id); + $sql .= ")"; + + dol_syslog(get_class($this)."::create", LOG_DEBUG); + $resql = $this->db->query($sql); + + if ($resql) { + $this->id = $this->db->last_insert_id(MAIN_DB_PREFIX."bankimport_transaction"); + $this->datec = $now; + $this->fk_user_creat = $user->id; + + $this->db->commit(); + return $this->id; + } else { + $this->error = $this->db->lasterror(); + $this->db->rollback(); + return -1; + } + } + + /** + * Load transaction from database + * + * @param int $id Id of transaction to load + * @param string $ref Reference + * @return int <0 if KO, 0 if not found, >0 if OK + */ + public function fetch($id, $ref = '') + { + $sql = "SELECT t.*"; + $sql .= " FROM ".MAIN_DB_PREFIX."bankimport_transaction as t"; + if ($id > 0) { + $sql .= " WHERE t.rowid = ".((int) $id); + } else { + $sql .= " WHERE t.ref = '".$this->db->escape($ref)."' AND t.entity = ".((int) $this->entity); + } + + dol_syslog(get_class($this)."::fetch", LOG_DEBUG); + $resql = $this->db->query($sql); + + if ($resql) { + if ($this->db->num_rows($resql)) { + $obj = $this->db->fetch_object($resql); + + $this->id = $obj->rowid; + $this->ref = $obj->ref; + $this->entity = $obj->entity; + $this->iban = $obj->iban; + $this->bic = $obj->bic; + $this->datec = $this->db->jdate($obj->datec); + $this->date_trans = $this->db->jdate($obj->date_trans); + $this->date_value = $this->db->jdate($obj->date_value); + $this->name = $obj->name; + $this->counterparty_iban = $obj->counterparty_iban; + $this->counterparty_bic = $obj->counterparty_bic; + $this->amount = $obj->amount; + $this->currency = $obj->currency; + $this->label = $obj->label; + $this->description = $obj->description; + $this->end_to_end_id = $obj->end_to_end_id; + $this->mandate_id = $obj->mandate_id; + $this->fk_bank = $obj->fk_bank; + $this->fk_facture = $obj->fk_facture; + $this->fk_facture_fourn = $obj->fk_facture_fourn; + $this->fk_paiement = $obj->fk_paiement; + $this->fk_paiementfourn = $obj->fk_paiementfourn; + $this->fk_salary = $obj->fk_salary; + $this->fk_don = $obj->fk_don; + $this->fk_loan = $obj->fk_loan; + $this->fk_societe = $obj->fk_societe; + $this->fk_statement = $obj->fk_statement; + $this->status = $obj->status; + $this->import_key = $obj->import_key; + $this->fk_user_creat = $obj->fk_user_creat; + $this->fk_user_modif = $obj->fk_user_modif; + $this->fk_user_match = $obj->fk_user_match; + $this->date_match = $this->db->jdate($obj->date_match); + $this->note_private = $obj->note_private; + $this->note_public = $obj->note_public; + + $this->db->free($resql); + return 1; + } else { + $this->db->free($resql); + return 0; + } + } else { + $this->error = $this->db->lasterror(); + return -1; + } + } + + /** + * Update transaction in database + * + * @param User $user User that modifies + * @param bool $notrigger Disable triggers + * @return int <0 if KO, >0 if OK + */ + public function update($user, $notrigger = false) + { + $error = 0; + + $this->db->begin(); + + $sql = "UPDATE ".MAIN_DB_PREFIX."bankimport_transaction SET"; + $sql .= " iban = ".($this->iban ? "'".$this->db->escape($this->iban)."'" : "NULL").","; + $sql .= " bic = ".($this->bic ? "'".$this->db->escape($this->bic)."'" : "NULL").","; + $sql .= " date_trans = '".$this->db->idate($this->date_trans)."',"; + $sql .= " date_value = ".($this->date_value ? "'".$this->db->idate($this->date_value)."'" : "NULL").","; + $sql .= " name = ".($this->name ? "'".$this->db->escape($this->name)."'" : "NULL").","; + $sql .= " counterparty_iban = ".($this->counterparty_iban ? "'".$this->db->escape($this->counterparty_iban)."'" : "NULL").","; + $sql .= " counterparty_bic = ".($this->counterparty_bic ? "'".$this->db->escape($this->counterparty_bic)."'" : "NULL").","; + $sql .= " amount = ".((float) $this->amount).","; + $sql .= " currency = '".$this->db->escape($this->currency)."',"; + $sql .= " label = ".($this->label ? "'".$this->db->escape($this->label)."'" : "NULL").","; + $sql .= " description = ".($this->description ? "'".$this->db->escape($this->description)."'" : "NULL").","; + $sql .= " end_to_end_id = ".($this->end_to_end_id ? "'".$this->db->escape($this->end_to_end_id)."'" : "NULL").","; + $sql .= " mandate_id = ".($this->mandate_id ? "'".$this->db->escape($this->mandate_id)."'" : "NULL").","; + $sql .= " fk_bank = ".($this->fk_bank > 0 ? ((int) $this->fk_bank) : "NULL").","; + $sql .= " fk_facture = ".($this->fk_facture > 0 ? ((int) $this->fk_facture) : "NULL").","; + $sql .= " fk_facture_fourn = ".($this->fk_facture_fourn > 0 ? ((int) $this->fk_facture_fourn) : "NULL").","; + $sql .= " fk_paiement = ".($this->fk_paiement > 0 ? ((int) $this->fk_paiement) : "NULL").","; + $sql .= " fk_paiementfourn = ".($this->fk_paiementfourn > 0 ? ((int) $this->fk_paiementfourn) : "NULL").","; + $sql .= " fk_salary = ".($this->fk_salary > 0 ? ((int) $this->fk_salary) : "NULL").","; + $sql .= " fk_don = ".($this->fk_don > 0 ? ((int) $this->fk_don) : "NULL").","; + $sql .= " fk_loan = ".($this->fk_loan > 0 ? ((int) $this->fk_loan) : "NULL").","; + $sql .= " fk_societe = ".($this->fk_societe > 0 ? ((int) $this->fk_societe) : "NULL").","; + $sql .= " fk_statement = ".($this->fk_statement > 0 ? ((int) $this->fk_statement) : "NULL").","; + $sql .= " status = ".((int) $this->status).","; + $sql .= " fk_user_modif = ".((int) $user->id).","; + $sql .= " note_private = ".($this->note_private ? "'".$this->db->escape($this->note_private)."'" : "NULL").","; + $sql .= " note_public = ".($this->note_public ? "'".$this->db->escape($this->note_public)."'" : "NULL"); + $sql .= " WHERE rowid = ".((int) $this->id); + + dol_syslog(get_class($this)."::update", LOG_DEBUG); + $resql = $this->db->query($sql); + + if ($resql) { + $this->fk_user_modif = $user->id; + $this->db->commit(); + return 1; + } else { + $this->error = $this->db->lasterror(); + $this->db->rollback(); + return -1; + } + } + + /** + * Delete transaction from database + * + * @param User $user User that deletes + * @param bool $notrigger Disable triggers + * @return int <0 if KO, >0 if OK + */ + public function delete($user, $notrigger = false) + { + $this->db->begin(); + + $sql = "DELETE FROM ".MAIN_DB_PREFIX."bankimport_transaction"; + $sql .= " WHERE rowid = ".((int) $this->id); + + dol_syslog(get_class($this)."::delete", LOG_DEBUG); + $resql = $this->db->query($sql); + + if ($resql) { + $this->db->commit(); + return 1; + } else { + $this->error = $this->db->lasterror(); + $this->db->rollback(); + return -1; + } + } + + /** + * Generate unique reference hash + * + * @return string Reference hash + */ + public function generateRef() + { + // Create hash from: date + amount + name + description + $data = $this->date_trans . '|' . $this->amount . '|' . $this->name . '|' . $this->description; + return substr(md5($data), 0, 32); + } + + /** + * Check if transaction already exists (duplicate detection) + * + * @return int 0 if not exists, rowid if exists + */ + public function exists() + { + $ref = $this->generateRef(); + + $sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."bankimport_transaction"; + $sql .= " WHERE ref = '".$this->db->escape($ref)."'"; + $sql .= " AND entity = ".((int) $this->entity); + + $resql = $this->db->query($sql); + if ($resql) { + if ($this->db->num_rows($resql) > 0) { + $obj = $this->db->fetch_object($resql); + return $obj->rowid; + } + } + return 0; + } + + /** + * Import transactions from FinTS fetch result + * + * @param array $transactions Array of transaction data from FinTS + * @param string $iban IBAN of the account + * @param User $user User doing the import + * @return array Array with 'imported' and 'skipped' counts + */ + public function importFromFinTS($transactions, $iban, $user) + { + $imported = 0; + $skipped = 0; + $errors = array(); + + $importKey = date('YmdHis') . '_' . $user->id; + + foreach ($transactions as $tx) { + $trans = new BankImportTransaction($this->db); + $trans->iban = $iban; + $trans->date_trans = $tx['date']; + $trans->date_value = $tx['bookingDate'] ?? $tx['date']; + $trans->name = $tx['name'] ?? ''; + $trans->counterparty_iban = $tx['iban'] ?? ''; + $trans->counterparty_bic = $tx['bic'] ?? ''; + $trans->amount = $tx['amount']; + $trans->currency = $tx['currency'] ?? 'EUR'; + $trans->label = $tx['bookingText'] ?? ''; + $trans->description = $tx['reference'] ?? ''; + $trans->end_to_end_id = $tx['endToEndId'] ?? ''; + $trans->import_key = $importKey; + $trans->status = self::STATUS_NEW; + + // Check for duplicate + if ($trans->exists()) { + $skipped++; + continue; + } + + // Create transaction + $result = $trans->create($user); + if ($result > 0) { + $imported++; + } else { + $errors[] = $trans->error; + } + } + + return array( + 'imported' => $imported, + 'skipped' => $skipped, + 'errors' => $errors, + 'import_key' => $importKey + ); + } + + /** + * Fetch list of transactions with filters + * + * @param string $sortfield Sort field + * @param string $sortorder Sort order (ASC/DESC) + * @param int $limit Limit + * @param int $offset Offset + * @param array $filter Filters array + * @param string $mode 'list' returns array of objects, 'count' returns total count + * @return array|int Array of transactions, count or -1 on error + */ + public function fetchAll($sortfield = 'date_trans', $sortorder = 'DESC', $limit = 0, $offset = 0, $filter = array(), $mode = 'list') + { + $sql = "SELECT t.rowid"; + $sql .= " FROM ".MAIN_DB_PREFIX."bankimport_transaction as t"; + $sql .= " WHERE t.entity = ".((int) $this->entity); + + // Apply filters + if (!empty($filter['ref'])) { + $sql .= " AND t.ref LIKE '%".$this->db->escape($filter['ref'])."%'"; + } + if (!empty($filter['name'])) { + $sql .= " AND t.name LIKE '%".$this->db->escape($filter['name'])."%'"; + } + if (!empty($filter['description'])) { + $sql .= " AND (t.description LIKE '%".$this->db->escape($filter['description'])."%'"; + $sql .= " OR t.label LIKE '%".$this->db->escape($filter['description'])."%')"; + } + if (!empty($filter['amount_min'])) { + $sql .= " AND t.amount >= ".((float) $filter['amount_min']); + } + if (!empty($filter['amount_max'])) { + $sql .= " AND t.amount <= ".((float) $filter['amount_max']); + } + if (!empty($filter['date_from'])) { + $sql .= " AND t.date_trans >= '".$this->db->idate($filter['date_from'])."'"; + } + if (!empty($filter['date_to'])) { + $sql .= " AND t.date_trans <= '".$this->db->idate($filter['date_to'])."'"; + } + if (isset($filter['status']) && $filter['status'] !== '' && $filter['status'] >= 0) { + $sql .= " AND t.status = ".((int) $filter['status']); + } + if (!empty($filter['iban'])) { + $sql .= " AND t.iban LIKE '%".$this->db->escape($filter['iban'])."%'"; + } + + // Count mode - just return total count + if ($mode == 'count') { + $sqlcount = preg_replace('/SELECT t\.rowid/', 'SELECT COUNT(*) as total', $sql); + $resqlcount = $this->db->query($sqlcount); + if ($resqlcount) { + $objcount = $this->db->fetch_object($resqlcount); + return (int) $objcount->total; + } + return 0; + } + + // Sort and limit + $sql .= $this->db->order($sortfield, $sortorder); + if ($limit > 0) { + $sql .= $this->db->plimit($limit, $offset); + } + + dol_syslog(get_class($this)."::fetchAll", LOG_DEBUG); + $resql = $this->db->query($sql); + + if ($resql) { + $result = array(); + + while ($obj = $this->db->fetch_object($resql)) { + $trans = new BankImportTransaction($this->db); + $trans->fetch($obj->rowid); + $result[] = $trans; + } + + $this->db->free($resql); + return $result; + } else { + $this->error = $this->db->lasterror(); + return -1; + } + } + + /** + * Get status label + * + * @param int $mode 0=long label, 1=short label, 2=Picto + short label, 3=Picto, 4=Picto + long label, 5=Short label + Picto, 6=Long label + Picto + * @return string Label + */ + public function getLibStatut($mode = 0) + { + return $this->LibStatut($this->status, $mode); + } + + /** + * Get status label for a given status + * + * @param int $status Status value + * @param int $mode Mode + * @return string Label + */ + public function LibStatut($status, $mode = 0) + { + global $langs; + + $statusLabels = array( + self::STATUS_NEW => array('short' => 'New', 'long' => 'NewTransaction', 'picto' => 'status0'), + self::STATUS_MATCHED => array('short' => 'Matched', 'long' => 'TransactionMatched', 'picto' => 'status4'), + self::STATUS_RECONCILED => array('short' => 'Reconciled', 'long' => 'TransactionReconciled', 'picto' => 'status6'), + self::STATUS_IGNORED => array('short' => 'Ignored', 'long' => 'TransactionIgnored', 'picto' => 'status5'), + ); + + $statusInfo = $statusLabels[$status] ?? array('short' => 'Unknown', 'long' => 'Unknown', 'picto' => 'status0'); + + if ($mode == 0) { + return $langs->trans($statusInfo['long']); + } elseif ($mode == 1) { + return $langs->trans($statusInfo['short']); + } elseif ($mode == 2) { + return img_picto($langs->trans($statusInfo['long']), $statusInfo['picto']).' '.$langs->trans($statusInfo['short']); + } elseif ($mode == 3) { + return img_picto($langs->trans($statusInfo['long']), $statusInfo['picto']); + } elseif ($mode == 4) { + return img_picto($langs->trans($statusInfo['long']), $statusInfo['picto']).' '.$langs->trans($statusInfo['long']); + } elseif ($mode == 5) { + return $langs->trans($statusInfo['short']).' '.img_picto($langs->trans($statusInfo['long']), $statusInfo['picto']); + } elseif ($mode == 6) { + return $langs->trans($statusInfo['long']).' '.img_picto($langs->trans($statusInfo['long']), $statusInfo['picto']); + } + + return ''; + } + + /** + * Set status of transaction + * + * @param int $status New status + * @param User $user User making the change + * @return int <0 if KO, >0 if OK + */ + public function setStatus($status, $user) + { + $this->status = $status; + if ($status == self::STATUS_MATCHED || $status == self::STATUS_RECONCILED) { + $this->fk_user_match = $user->id; + $this->date_match = dol_now(); + } + return $this->update($user); + } + + /** + * Try to auto-match transaction with Dolibarr invoices + * Uses multiple criteria: amount, name, invoice reference, IBAN + * + * @return array Array of potential matches with scores + */ + public function findMatches() + { + $matches = array(); + $seenIds = array(); + + // Skip if already matched + if ($this->status == self::STATUS_MATCHED || $this->status == self::STATUS_RECONCILED) { + return $matches; + } + + // Build search text from description and end-to-end-id + $searchText = strtoupper($this->description . ' ' . $this->end_to_end_id . ' ' . $this->label); + + // For incoming payments (positive amount), search customer invoices + if ($this->amount > 0) { + $matches = array_merge($matches, $this->findCustomerInvoiceMatches($searchText, $seenIds)); + } + + // For outgoing payments (negative amount), search supplier invoices + if ($this->amount < 0) { + $matches = array_merge($matches, $this->findSupplierInvoiceMatches($searchText, $seenIds)); + + // FALLBACK: If best match has amount difference > 5 EUR, try multi-invoice matching + $absAmount = abs($this->amount); + $needsMultiMatch = true; + + if (!empty($matches)) { + $bestMatch = $matches[0]; + // If best single match is close enough in amount, no need for multi-match + if (abs($bestMatch['amount'] - $absAmount) <= 5.00) { + $needsMultiMatch = false; + } + } + + if ($needsMultiMatch) { + // Try to find supplier by IBAN or name + $socid = $this->findSupplierForMultiMatch($searchText); + + if ($socid > 0) { + $multiMatch = $this->findMultipleSupplierInvoiceMatches($searchText, $socid, $absAmount, 5.00); + + if ($multiMatch && count($multiMatch['invoices']) > 1) { + // Add as a special "multi" match + $matches[] = array( + 'type' => 'multi_facture_fourn', + 'id' => 0, // Special marker + 'ref' => count($multiMatch['invoices']).' '.$this->getLangsTransNoLoad("Invoices"), + 'amount' => $multiMatch['total'], + 'socname' => $multiMatch['invoices'][0]['socname'], + 'socid' => $socid, + 'match_score' => $multiMatch['match_score'], + 'match_reasons' => array('multi_invoice', 'ref_supplier'), + 'invoices' => $multiMatch['invoices'], + 'difference' => $multiMatch['difference'] + ); + } + } + } + } + + // Sort by score descending + usort($matches, function($a, $b) { + return $b['match_score'] - $a['match_score']; + }); + + return $matches; + } + + /** + * Find supplier ID for multi-invoice matching + * Uses IBAN or name matching + * + * @param string $searchText Search text + * @return int Societe ID or 0 + */ + protected function findSupplierForMultiMatch($searchText) + { + // First try by IBAN + if (!empty($this->counterparty_iban)) { + $socid = $this->findSocieteByIban($this->counterparty_iban); + if ($socid > 0) { + return $socid; + } + } + + // Then try by name similarity + if (!empty($this->name)) { + $sql = "SELECT s.rowid, s.nom"; + $sql .= " FROM ".MAIN_DB_PREFIX."societe as s"; + $sql .= " WHERE s.entity = ".((int) $this->entity); + $sql .= " AND s.fournisseur = 1"; // Must be a supplier + + $resql = $this->db->query($sql); + if ($resql) { + $bestSocid = 0; + $bestSimilarity = 0; + + while ($obj = $this->db->fetch_object($resql)) { + $similarity = $this->calculateNameSimilarity($this->name, $obj->nom); + if ($similarity > 70 && $similarity > $bestSimilarity) { + $bestSimilarity = $similarity; + $bestSocid = $obj->rowid; + } + } + + if ($bestSocid > 0) { + return $bestSocid; + } + } + } + + return 0; + } + + /** + * Get translation without loading langs (fallback) + * + * @param string $key Translation key + * @return string + */ + protected function getLangsTransNoLoad($key) + { + global $langs; + if (is_object($langs)) { + return $langs->trans($key); + } + return $key; + } + + /** + * Find matching customer invoices + * + * @param string $searchText Text to search in + * @param array &$seenIds Already seen invoice IDs + * @return array Matches + */ + protected function findCustomerInvoiceMatches($searchText, &$seenIds) + { + $matches = array(); + + // 1. Search by invoice reference in description (highest priority) + $sql = "SELECT f.rowid, f.ref, f.ref_client, f.total_ttc, f.date_lim_reglement,"; + $sql .= " s.nom as socname, s.rowid as socid"; + $sql .= " FROM ".MAIN_DB_PREFIX."facture as f"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."societe as s ON f.fk_soc = s.rowid"; + $sql .= " WHERE f.entity = ".((int) $this->entity); + $sql .= " AND f.fk_statut = 1"; // Unpaid + + $resql = $this->db->query($sql); + if ($resql) { + while ($obj = $this->db->fetch_object($resql)) { + $score = 0; + $matchReasons = array(); + + // Check if invoice reference appears in description + if (stripos($searchText, $obj->ref) !== false) { + $score += 80; + $matchReasons[] = 'ref'; + } + + // Check client reference + if (!empty($obj->ref_client) && stripos($searchText, $obj->ref_client) !== false) { + $score += 70; + $matchReasons[] = 'ref_client'; + } + + // Check exact amount match + if (abs($obj->total_ttc - $this->amount) < 0.01) { + $score += 50; + $matchReasons[] = 'amount'; + } elseif (abs($obj->total_ttc - $this->amount) < 1.00) { + // Close amount (within 1 EUR) + $score += 20; + $matchReasons[] = 'amount_close'; + } + + // Check name similarity + if (!empty($this->name) && !empty($obj->socname)) { + $nameSimilarity = $this->calculateNameSimilarity($this->name, $obj->socname); + if ($nameSimilarity > 80) { + $score += 40; + $matchReasons[] = 'name_exact'; + } elseif ($nameSimilarity > 50) { + $score += 20; + $matchReasons[] = 'name_similar'; + } + } + + // Check IBAN match + if (!empty($this->counterparty_iban)) { + $ibanMatch = $this->checkSocieteIban($obj->socid, $this->counterparty_iban); + if ($ibanMatch) { + $score += 60; + $matchReasons[] = 'iban'; + } + } + + // Only include if score is significant + if ($score >= 50 && !isset($seenIds['facture_'.$obj->rowid])) { + $seenIds['facture_'.$obj->rowid] = true; + $matches[] = array( + 'type' => 'facture', + 'id' => $obj->rowid, + 'ref' => $obj->ref, + 'amount' => $obj->total_ttc, + 'socname' => $obj->socname, + 'socid' => $obj->socid, + 'match_score' => min($score, 100), + 'match_reasons' => $matchReasons, + 'date_due' => $obj->date_lim_reglement + ); + } + } + } + + return $matches; + } + + /** + * Find matching supplier invoices + * + * @param string $searchText Text to search in + * @param array &$seenIds Already seen invoice IDs + * @return array Matches + */ + protected function findSupplierInvoiceMatches($searchText, &$seenIds) + { + $matches = array(); + $absAmount = abs($this->amount); + + $sql = "SELECT f.rowid, f.ref, f.ref_supplier, f.total_ttc, f.date_lim_reglement,"; + $sql .= " s.nom as socname, s.rowid as socid"; + $sql .= " FROM ".MAIN_DB_PREFIX."facture_fourn as f"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."societe as s ON f.fk_soc = s.rowid"; + $sql .= " WHERE f.entity = ".((int) $this->entity); + $sql .= " AND f.fk_statut = 1"; // Unpaid + + $resql = $this->db->query($sql); + if ($resql) { + while ($obj = $this->db->fetch_object($resql)) { + $score = 0; + $matchReasons = array(); + + // Check if invoice reference appears in description + if (stripos($searchText, $obj->ref) !== false) { + $score += 80; + $matchReasons[] = 'ref'; + } + + // Check supplier reference + if (!empty($obj->ref_supplier) && stripos($searchText, $obj->ref_supplier) !== false) { + $score += 70; + $matchReasons[] = 'ref_supplier'; + } + + // Check exact amount match + if (abs($obj->total_ttc - $absAmount) < 0.01) { + $score += 50; + $matchReasons[] = 'amount'; + } elseif (abs($obj->total_ttc - $absAmount) < 1.00) { + $score += 20; + $matchReasons[] = 'amount_close'; + } + + // Check name similarity + if (!empty($this->name) && !empty($obj->socname)) { + $nameSimilarity = $this->calculateNameSimilarity($this->name, $obj->socname); + if ($nameSimilarity > 80) { + $score += 40; + $matchReasons[] = 'name_exact'; + } elseif ($nameSimilarity > 50) { + $score += 20; + $matchReasons[] = 'name_similar'; + } + } + + // Check IBAN match + if (!empty($this->counterparty_iban)) { + $ibanMatch = $this->checkSocieteIban($obj->socid, $this->counterparty_iban); + if ($ibanMatch) { + $score += 60; + $matchReasons[] = 'iban'; + } + } + + // Only include if score is significant + if ($score >= 50 && !isset($seenIds['facture_fourn_'.$obj->rowid])) { + $seenIds['facture_fourn_'.$obj->rowid] = true; + $matches[] = array( + 'type' => 'facture_fourn', + 'id' => $obj->rowid, + 'ref' => $obj->ref, + 'amount' => $obj->total_ttc, + 'socname' => $obj->socname, + 'socid' => $obj->socid, + 'match_score' => min($score, 100), + 'match_reasons' => $matchReasons, + 'date_due' => $obj->date_lim_reglement + ); + } + } + } + + return $matches; + } + + /** + * Calculate name similarity percentage + * + * @param string $name1 First name + * @param string $name2 Second name + * @return int Similarity percentage (0-100) + */ + protected function calculateNameSimilarity($name1, $name2) + { + // Normalize names + $name1 = strtolower(trim($name1)); + $name2 = strtolower(trim($name2)); + + // Remove common suffixes + $suffixes = array(' gmbh', ' ag', ' kg', ' ohg', ' e.k.', ' ug', ' gbr', ' se', ' co. kg', ' gmbh & co. kg'); + foreach ($suffixes as $suffix) { + $name1 = str_replace($suffix, '', $name1); + $name2 = str_replace($suffix, '', $name2); + } + + $name1 = trim($name1); + $name2 = trim($name2); + + // Check if one contains the other + if (strpos($name1, $name2) !== false || strpos($name2, $name1) !== false) { + return 90; + } + + // Use similar_text for fuzzy matching + $similarity = 0; + similar_text($name1, $name2, $similarity); + + return (int) $similarity; + } + + /** + * Find multiple supplier invoices that together match the transaction amount + * Uses the logic: oldest invoices first, check if ref_supplier appears in description + * + * @param string $searchText Text to search in (description) + * @param int $socid Societe ID (supplier) + * @param float $targetAmount Target amount (absolute value of transaction) + * @param float $tolerance Tolerance for amount matching (default 5.00) + * @return array|null Array with 'invoices' and 'total' or null if no match + */ + protected function findMultipleSupplierInvoiceMatches($searchText, $socid, $targetAmount, $tolerance = 5.00) + { + $matchedInvoices = array(); + $runningTotal = 0.0; + + // Get all unpaid supplier invoices for this supplier, ordered by date (oldest first) + $sql = "SELECT f.rowid, f.ref, f.ref_supplier, f.total_ttc, f.datef, f.date_lim_reglement,"; + $sql .= " s.nom as socname, s.rowid as socid"; + $sql .= " FROM ".MAIN_DB_PREFIX."facture_fourn as f"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."societe as s ON f.fk_soc = s.rowid"; + $sql .= " WHERE f.entity = ".((int) $this->entity); + $sql .= " AND f.fk_statut = 1"; // Unpaid + $sql .= " AND f.fk_soc = ".((int) $socid); + $sql .= " ORDER BY f.datef ASC, f.rowid ASC"; // Oldest first + + $resql = $this->db->query($sql); + if (!$resql) { + return null; + } + + while ($obj = $this->db->fetch_object($resql)) { + // Check if this invoice's ref_supplier appears in the search text + $refFound = false; + if (!empty($obj->ref_supplier)) { + // Try different formats: with and without leading zeros, etc. + $refClean = preg_replace('/^0+/', '', $obj->ref_supplier); // Remove leading zeros + if (stripos($searchText, $obj->ref_supplier) !== false || + stripos($searchText, $refClean) !== false) { + $refFound = true; + } + } + + // Also check the internal ref + if (!$refFound && stripos($searchText, $obj->ref) !== false) { + $refFound = true; + } + + if ($refFound) { + // Calculate remaining amount for this invoice + $remainToPay = $this->getInvoiceRemainToPay('facture_fourn', $obj->rowid, $obj->total_ttc); + + if ($remainToPay > 0) { + $matchedInvoices[] = array( + 'type' => 'facture_fourn', + 'id' => $obj->rowid, + 'ref' => $obj->ref, + 'ref_supplier' => $obj->ref_supplier, + 'amount' => $remainToPay, + 'total_ttc' => $obj->total_ttc, + 'socname' => $obj->socname, + 'socid' => $obj->socid, + 'datef' => $obj->datef, + 'date_due' => $obj->date_lim_reglement + ); + $runningTotal += $remainToPay; + } + } + + // Stop if we've reached or exceeded the target + if ($runningTotal >= $targetAmount - $tolerance) { + break; + } + } + + $this->db->free($resql); + + // Check if total matches within tolerance + if (!empty($matchedInvoices) && abs($runningTotal - $targetAmount) <= $tolerance) { + return array( + 'invoices' => $matchedInvoices, + 'total' => $runningTotal, + 'difference' => $targetAmount - $runningTotal, + 'match_score' => abs($runningTotal - $targetAmount) < 0.01 ? 100 : 90 + ); + } + + return null; + } + + /** + * Get remaining amount to pay for an invoice + * + * @param string $type 'facture' or 'facture_fourn' + * @param int $invoiceId Invoice ID + * @param float $totalTtc Total TTC of invoice + * @return float Remaining amount + */ + protected function getInvoiceRemainToPay($type, $invoiceId, $totalTtc) + { + if ($type == 'facture_fourn') { + require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.facture.class.php'; + $invoice = new FactureFournisseur($this->db); + if ($invoice->fetch($invoiceId) > 0) { + $alreadyPaid = $invoice->getSommePaiement(); + $creditnotes = $invoice->getSumCreditNotesUsed(); + $deposits = $invoice->getSumDepositsUsed(); + return price2num($invoice->total_ttc - $alreadyPaid - $creditnotes - $deposits, 'MT'); + } + } else { + require_once DOL_DOCUMENT_ROOT.'/compta/facture/class/facture.class.php'; + $invoice = new Facture($this->db); + if ($invoice->fetch($invoiceId) > 0) { + $alreadyPaid = $invoice->getSommePaiement(); + $creditnotes = $invoice->getSumCreditNotesUsed(); + $deposits = $invoice->getSumDepositsUsed(); + return price2num($invoice->total_ttc - $alreadyPaid - $creditnotes - $deposits, 'MT'); + } + } + return $totalTtc; + } + + /** + * Check if IBAN matches any bank account of a societe + * + * @param int $socid Societe ID + * @param string $iban IBAN to check + * @return bool True if match found + */ + protected function checkSocieteIban($socid, $iban) + { + if (empty($socid) || empty($iban)) { + return false; + } + + // Normalize IBAN (remove spaces) + $iban = str_replace(' ', '', strtoupper($iban)); + + $sql = "SELECT iban_prefix FROM ".MAIN_DB_PREFIX."societe_rib"; + $sql .= " WHERE fk_soc = ".((int) $socid); + $sql .= " AND status = 1"; // Active + + $resql = $this->db->query($sql); + if ($resql) { + while ($obj = $this->db->fetch_object($resql)) { + $storedIban = str_replace(' ', '', strtoupper($obj->iban_prefix)); + if ($storedIban === $iban) { + return true; + } + } + } + + return false; + } + + /** + * Find third party by IBAN + * + * @param string $iban IBAN to search + * @return int|null Societe ID or null + */ + public function findSocieteByIban($iban) + { + if (empty($iban)) { + return null; + } + + $iban = str_replace(' ', '', strtoupper($iban)); + + $sql = "SELECT fk_soc FROM ".MAIN_DB_PREFIX."societe_rib"; + $sql .= " WHERE REPLACE(UPPER(iban_prefix), ' ', '') = '".$this->db->escape($iban)."'"; + $sql .= " AND status = 1"; + $sql .= " LIMIT 1"; + + $resql = $this->db->query($sql); + if ($resql && $this->db->num_rows($resql) > 0) { + $obj = $this->db->fetch_object($resql); + return (int) $obj->fk_soc; + } + + return null; + } + + /** + * Auto-detect and link third party by IBAN + * + * @param User $user User doing the action + * @return int Societe ID if found and linked, 0 if not found + */ + public function autoLinkSociete($user) + { + if (!empty($this->fk_societe)) { + return $this->fk_societe; // Already linked + } + + $socid = $this->findSocieteByIban($this->counterparty_iban); + if ($socid > 0) { + $this->fk_societe = $socid; + $this->update($user); + return $socid; + } + + return 0; + } + + /** + * Confirm payment: create Paiement or PaiementFourn in Dolibarr + * Links the bankimport transaction to the created payment and bank line. + * + * @param User $user User performing the action + * @param string $type 'facture' or 'facture_fourn' + * @param int $invoiceId Invoice ID + * @param int $bankAccountId Dolibarr bank account ID (llx_bank_account.rowid) + * @return int >0 if OK (payment ID), <0 if error + */ + public function confirmPayment($user, $type, $invoiceId, $bankAccountId) + { + global $conf, $langs; + + $error = 0; + $this->db->begin(); + + // Look up payment type ID for 'VIR' (bank transfer) + $paiementTypeId = 0; + $sql = "SELECT id FROM ".MAIN_DB_PREFIX."c_paiement WHERE code = 'VIR' AND active = 1"; + $resql = $this->db->query($sql); + if ($resql && $this->db->num_rows($resql) > 0) { + $obj = $this->db->fetch_object($resql); + $paiementTypeId = (int) $obj->id; + } + if (empty($paiementTypeId)) { + $this->error = 'Payment type VIR not found in c_paiement'; + $this->db->rollback(); + return -1; + } + + if ($type == 'facture') { + // Customer invoice payment + require_once DOL_DOCUMENT_ROOT.'/compta/facture/class/facture.class.php'; + require_once DOL_DOCUMENT_ROOT.'/compta/paiement/class/paiement.class.php'; + + $invoice = new Facture($this->db); + if ($invoice->fetch($invoiceId) <= 0) { + $this->error = 'Invoice not found: '.$invoiceId; + $this->db->rollback(); + return -2; + } + + // Calculate remaining amount + $alreadyPaid = $invoice->getSommePaiement(); + $creditnotes = $invoice->getSumCreditNotesUsed(); + $deposits = $invoice->getSumDepositsUsed(); + $remaintopay = price2num($invoice->total_ttc - $alreadyPaid - $creditnotes - $deposits, 'MT'); + + if ($remaintopay <= 0) { + $this->error = $langs->trans("InvoiceAlreadyPaid"); + $this->db->rollback(); + return -5; + } + + // Use the lesser of: transaction amount or remain to pay + $payAmount = min(abs($this->amount), $remaintopay); + + $paiement = new Paiement($this->db); + $paiement->datepaye = $this->date_trans; + $paiement->amounts = array($invoiceId => $payAmount); + $paiement->multicurrency_amounts = array($invoiceId => $payAmount); + $paiement->paiementid = $paiementTypeId; + $paiement->paiementcode = 'VIR'; + $paiement->num_payment = $this->end_to_end_id ?: $this->ref; + $paiement->note_private = $langs->trans("PaymentCreatedByBankImport").' - '.$this->name.' - '.dol_trunc($this->description, 100); + + $paymentId = $paiement->create($user, 1); + if ($paymentId < 0) { + $this->error = $paiement->error; + $this->errors = $paiement->errors; + $error++; + } + + if (!$error) { + $bankLineId = $paiement->addPaymentToBank( + $user, + 'payment', + '(CustomerInvoicePayment)', + $bankAccountId, + $this->name, + '' + ); + + if ($bankLineId > 0) { + $this->fk_paiement = $paymentId; + $this->fk_bank = $bankLineId; + $this->fk_facture = $invoiceId; + $this->fk_societe = $invoice->socid; + $this->status = self::STATUS_MATCHED; + $this->fk_user_match = $user->id; + $this->date_match = dol_now(); + $this->update($user); + } else { + $this->error = 'Failed to add payment to bank'; + $error++; + } + } + + if ($error) { + $this->db->rollback(); + return -3; + } + + $this->db->commit(); + return $paymentId; + + } elseif ($type == 'facture_fourn') { + // Supplier invoice payment + require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.facture.class.php'; + require_once DOL_DOCUMENT_ROOT.'/fourn/class/paiementfourn.class.php'; + + $invoice = new FactureFournisseur($this->db); + if ($invoice->fetch($invoiceId) <= 0) { + $this->error = 'Supplier invoice not found: '.$invoiceId; + $this->db->rollback(); + return -2; + } + + $alreadyPaid = $invoice->getSommePaiement(); + $creditnotes = $invoice->getSumCreditNotesUsed(); + $deposits = $invoice->getSumDepositsUsed(); + $remaintopay = price2num($invoice->total_ttc - $alreadyPaid - $creditnotes - $deposits, 'MT'); + + if ($remaintopay <= 0) { + $this->error = $langs->trans("InvoiceAlreadyPaid"); + $this->db->rollback(); + return -5; + } + + $payAmount = min(abs($this->amount), $remaintopay); + + $paiementfourn = new PaiementFourn($this->db); + $paiementfourn->datepaye = $this->date_trans; + $paiementfourn->amounts = array($invoiceId => $payAmount); + $paiementfourn->multicurrency_amounts = array($invoiceId => $payAmount); + $paiementfourn->paiementid = $paiementTypeId; + $paiementfourn->paiementcode = 'VIR'; + $paiementfourn->num_payment = $this->end_to_end_id ?: $this->ref; + $paiementfourn->note_private = $langs->trans("PaymentCreatedByBankImport").' - '.$this->name.' - '.dol_trunc($this->description, 100); + + $paymentId = $paiementfourn->create($user, 1); + if ($paymentId < 0) { + $this->error = $paiementfourn->error; + $this->errors = $paiementfourn->errors; + $error++; + } + + if (!$error) { + $bankLineId = $paiementfourn->addPaymentToBank( + $user, + 'payment_supplier', + '(SupplierInvoicePayment)', + $bankAccountId, + $this->name, + '' + ); + + if ($bankLineId > 0) { + $this->fk_paiementfourn = $paymentId; + $this->fk_bank = $bankLineId; + $this->fk_facture_fourn = $invoiceId; + $this->fk_societe = $invoice->socid; + $this->status = self::STATUS_MATCHED; + $this->fk_user_match = $user->id; + $this->date_match = dol_now(); + $this->update($user); + } else { + $this->error = 'Failed to add payment to bank'; + $error++; + } + } + + if ($error) { + $this->db->rollback(); + return -3; + } + + $this->db->commit(); + return $paymentId; + } + + $this->error = 'Unknown type: '.$type; + $this->db->rollback(); + return -4; + } + + /** + * Confirm payment for multiple invoices (batch payment) + * Creates a single payment that covers multiple invoices + * + * @param User $user User performing the action + * @param array $invoices Array of invoice data: [['type' => 'facture_fourn', 'id' => X, 'amount' => Y], ...] + * @param int $bankAccountId Dolibarr bank account ID + * @return int >0 if OK (payment ID), <0 if error + */ + public function confirmMultiplePayment($user, $invoices, $bankAccountId) + { + global $conf, $langs; + + if (empty($invoices)) { + $this->error = 'No invoices provided'; + return -1; + } + + $error = 0; + $this->db->begin(); + + // Look up payment type ID for 'VIR' (bank transfer) + $paiementTypeId = 0; + $sql = "SELECT id FROM ".MAIN_DB_PREFIX."c_paiement WHERE code = 'VIR' AND active = 1"; + $resql = $this->db->query($sql); + if ($resql && $this->db->num_rows($resql) > 0) { + $obj = $this->db->fetch_object($resql); + $paiementTypeId = (int) $obj->id; + } + if (empty($paiementTypeId)) { + $this->error = 'Payment type VIR not found in c_paiement'; + $this->db->rollback(); + return -1; + } + + // Determine if these are customer or supplier invoices + $firstType = $invoices[0]['type']; + $isSupplier = ($firstType == 'facture_fourn'); + + // Build amounts array for payment + $amounts = array(); + $multicurrency_amounts = array(); + $totalPayment = 0; + $socid = 0; + + foreach ($invoices as $inv) { + $invoiceId = (int) $inv['id']; + $payAmount = (float) $inv['amount']; + + if ($payAmount <= 0) { + continue; + } + + $amounts[$invoiceId] = $payAmount; + $multicurrency_amounts[$invoiceId] = $payAmount; + $totalPayment += $payAmount; + + // Get socid from first invoice + if ($socid == 0) { + if ($isSupplier) { + require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.facture.class.php'; + $invoice = new FactureFournisseur($this->db); + if ($invoice->fetch($invoiceId) > 0) { + $socid = $invoice->socid; + } + } else { + require_once DOL_DOCUMENT_ROOT.'/compta/facture/class/facture.class.php'; + $invoice = new Facture($this->db); + if ($invoice->fetch($invoiceId) > 0) { + $socid = $invoice->socid; + } + } + } + } + + if (empty($amounts)) { + $this->error = 'No valid invoice amounts'; + $this->db->rollback(); + return -2; + } + + if ($isSupplier) { + // Supplier invoice payment + require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.facture.class.php'; + require_once DOL_DOCUMENT_ROOT.'/fourn/class/paiementfourn.class.php'; + + $paiementfourn = new PaiementFourn($this->db); + $paiementfourn->datepaye = $this->date_trans; + $paiementfourn->amounts = $amounts; + $paiementfourn->multicurrency_amounts = $multicurrency_amounts; + $paiementfourn->paiementid = $paiementTypeId; + $paiementfourn->paiementcode = 'VIR'; + $paiementfourn->num_payment = $this->end_to_end_id ?: $this->ref; + $paiementfourn->note_private = $langs->trans("PaymentCreatedByBankImport").' - '.$this->name.' - '.count($invoices).' '.$langs->trans("Invoices"); + + $paymentId = $paiementfourn->create($user, 1); + if ($paymentId < 0) { + $this->error = $paiementfourn->error; + $this->errors = $paiementfourn->errors; + $error++; + } + + if (!$error) { + $bankLineId = $paiementfourn->addPaymentToBank( + $user, + 'payment_supplier', + '(SupplierInvoicePayment)', + $bankAccountId, + $this->name, + '' + ); + + if ($bankLineId > 0) { + $this->fk_paiementfourn = $paymentId; + $this->fk_bank = $bankLineId; + $this->fk_societe = $socid; + $this->status = self::STATUS_MATCHED; + $this->fk_user_match = $user->id; + $this->date_match = dol_now(); + // Store first invoice as reference (or could store all in note) + $this->fk_facture_fourn = $invoices[0]['id']; + $this->note_private = ($this->note_private ? $this->note_private."\n" : '').'Multi-invoice payment: '.implode(', ', array_column($invoices, 'ref')); + $this->update($user); + } else { + $this->error = 'Failed to add payment to bank'; + $error++; + } + } + } else { + // Customer invoice payment + require_once DOL_DOCUMENT_ROOT.'/compta/facture/class/facture.class.php'; + require_once DOL_DOCUMENT_ROOT.'/compta/paiement/class/paiement.class.php'; + + $paiement = new Paiement($this->db); + $paiement->datepaye = $this->date_trans; + $paiement->amounts = $amounts; + $paiement->multicurrency_amounts = $multicurrency_amounts; + $paiement->paiementid = $paiementTypeId; + $paiement->paiementcode = 'VIR'; + $paiement->num_payment = $this->end_to_end_id ?: $this->ref; + $paiement->note_private = $langs->trans("PaymentCreatedByBankImport").' - '.$this->name.' - '.count($invoices).' '.$langs->trans("Invoices"); + + $paymentId = $paiement->create($user, 1); + if ($paymentId < 0) { + $this->error = $paiement->error; + $this->errors = $paiement->errors; + $error++; + } + + if (!$error) { + $bankLineId = $paiement->addPaymentToBank( + $user, + 'payment', + '(CustomerInvoicePayment)', + $bankAccountId, + $this->name, + '' + ); + + if ($bankLineId > 0) { + $this->fk_paiement = $paymentId; + $this->fk_bank = $bankLineId; + $this->fk_societe = $socid; + $this->status = self::STATUS_MATCHED; + $this->fk_user_match = $user->id; + $this->date_match = dol_now(); + $this->fk_facture = $invoices[0]['id']; + $this->note_private = ($this->note_private ? $this->note_private."\n" : '').'Multi-invoice payment: '.implode(', ', array_column($invoices, 'ref')); + $this->update($user); + } else { + $this->error = 'Failed to add payment to bank'; + $error++; + } + } + } + + if ($error) { + $this->db->rollback(); + return -3; + } + + $this->db->commit(); + return $paymentId; + } + + /** + * Link transaction to an existing payment (for already paid invoices) + * Finds the existing payment for the invoice and creates the bank entry. + * If no payment exists (invoice marked paid without payment record), creates both payment and bank entry. + * + * @param User $user User performing the action + * @param string $invoiceType Invoice type ('facture' or 'facture_fourn') + * @param int $invoiceId Invoice ID + * @param int $bankAccountId Dolibarr bank account ID + * @return int >0 if OK (bank line ID), <0 if error + */ + public function linkExistingPayment($user, $invoiceType, $invoiceId, $bankAccountId) + { + global $conf, $langs; + + $error = 0; + $bankLineId = 0; + $this->db->begin(); + + // Look up payment type ID for 'VIR' (bank transfer) + $paiementTypeId = 0; + $sql = "SELECT id FROM ".MAIN_DB_PREFIX."c_paiement WHERE code = 'VIR' AND active = 1"; + $resql = $this->db->query($sql); + if ($resql && $this->db->num_rows($resql) > 0) { + $obj = $this->db->fetch_object($resql); + $paiementTypeId = (int) $obj->id; + } + if (empty($paiementTypeId)) { + $this->error = 'Payment type VIR not found'; + $this->db->rollback(); + return -1; + } + + if ($invoiceType == 'facture_fourn') { + // Supplier invoice + require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.facture.class.php'; + require_once DOL_DOCUMENT_ROOT.'/fourn/class/paiementfourn.class.php'; + + $invoice = new FactureFournisseur($this->db); + if ($invoice->fetch($invoiceId) <= 0) { + $this->error = 'Invoice not found'; + $this->db->rollback(); + return -1; + } + + // Find the payment(s) for this invoice + $sql = "SELECT pfp.fk_paiement_fourn as payment_id, pfp.amount"; + $sql .= " FROM ".MAIN_DB_PREFIX."paiementfourn_facturefourn as pfp"; + $sql .= " WHERE pfp.fk_facturefourn = ".((int) $invoiceId); + $sql .= " ORDER BY pfp.rowid DESC"; + $sql .= " LIMIT 1"; + + $resql = $this->db->query($sql); + $paymentExists = ($resql && $this->db->num_rows($resql) > 0); + + if ($paymentExists) { + // Payment exists - link bank entry to it + $obj = $this->db->fetch_object($resql); + $paymentId = (int) $obj->payment_id; + + $paiementfourn = new PaiementFourn($this->db); + if ($paiementfourn->fetch($paymentId) <= 0) { + $this->error = 'Payment not found'; + $this->db->rollback(); + return -3; + } + + // Check if payment already has a bank entry + if (!empty($paiementfourn->bank_line) && $paiementfourn->bank_line > 0) { + // Just link transaction to existing bank entry + $this->fk_paiementfourn = $paymentId; + $this->fk_bank = $paiementfourn->bank_line; + $this->fk_facture_fourn = $invoiceId; + $this->fk_societe = $invoice->socid; + $this->status = self::STATUS_MATCHED; + $this->fk_user_match = $user->id; + $this->date_match = dol_now(); + $this->update($user); + + $this->db->commit(); + return $paiementfourn->bank_line; + } + + // Create bank entry for existing payment + $bankLineId = $paiementfourn->addPaymentToBank( + $user, + 'payment_supplier', + '(SupplierInvoicePayment)', + $bankAccountId, + $this->name, + '' + ); + + if ($bankLineId > 0) { + $this->fk_paiementfourn = $paymentId; + $this->fk_bank = $bankLineId; + $this->fk_facture_fourn = $invoiceId; + $this->fk_societe = $invoice->socid; + $this->status = self::STATUS_MATCHED; + $this->fk_user_match = $user->id; + $this->date_match = dol_now(); + $this->update($user); + } else { + $this->error = 'Failed to add payment to bank'; + $error++; + } + } else { + // No payment exists - create new payment AND bank entry + // This handles invoices marked as paid without actual payment record + $amounts = array($invoiceId => $invoice->total_ttc); + $multicurrency_amounts = array($invoiceId => $invoice->total_ttc); + + $paiementfourn = new PaiementFourn($this->db); + $paiementfourn->datepaye = $this->date_trans; + $paiementfourn->amounts = $amounts; + $paiementfourn->multicurrency_amounts = $multicurrency_amounts; + $paiementfourn->paiementid = $paiementTypeId; + $paiementfourn->paiementcode = 'VIR'; + $paiementfourn->num_payment = $this->end_to_end_id ?: $this->ref; + $paiementfourn->note_private = $langs->trans("PaymentCreatedByBankImport").' - '.$this->name; + + $paymentId = $paiementfourn->create($user, 1); + if ($paymentId < 0) { + $this->error = $paiementfourn->error; + $this->errors = $paiementfourn->errors; + $error++; + } + + if (!$error) { + $bankLineId = $paiementfourn->addPaymentToBank( + $user, + 'payment_supplier', + '(SupplierInvoicePayment)', + $bankAccountId, + $this->name, + '' + ); + + if ($bankLineId > 0) { + $this->fk_paiementfourn = $paymentId; + $this->fk_bank = $bankLineId; + $this->fk_facture_fourn = $invoiceId; + $this->fk_societe = $invoice->socid; + $this->status = self::STATUS_MATCHED; + $this->fk_user_match = $user->id; + $this->date_match = dol_now(); + $this->update($user); + } else { + $this->error = 'Failed to add payment to bank'; + $error++; + } + } + } + } elseif ($invoiceType == 'facture') { + // Customer invoice + require_once DOL_DOCUMENT_ROOT.'/compta/facture/class/facture.class.php'; + require_once DOL_DOCUMENT_ROOT.'/compta/paiement/class/paiement.class.php'; + + $invoice = new Facture($this->db); + if ($invoice->fetch($invoiceId) <= 0) { + $this->error = 'Invoice not found'; + $this->db->rollback(); + return -1; + } + + // Find the payment(s) for this invoice + $sql = "SELECT pfp.fk_paiement as payment_id, pfp.amount"; + $sql .= " FROM ".MAIN_DB_PREFIX."paiement_facture as pfp"; + $sql .= " WHERE pfp.fk_facture = ".((int) $invoiceId); + $sql .= " ORDER BY pfp.rowid DESC"; + $sql .= " LIMIT 1"; + + $resql = $this->db->query($sql); + $paymentExists = ($resql && $this->db->num_rows($resql) > 0); + + if ($paymentExists) { + // Payment exists - link bank entry to it + $obj = $this->db->fetch_object($resql); + $paymentId = (int) $obj->payment_id; + + $paiement = new Paiement($this->db); + if ($paiement->fetch($paymentId) <= 0) { + $this->error = 'Payment not found'; + $this->db->rollback(); + return -3; + } + + // Check if payment already has a bank entry + if (!empty($paiement->bank_line) && $paiement->bank_line > 0) { + // Just link transaction to existing bank entry + $this->fk_paiement = $paymentId; + $this->fk_bank = $paiement->bank_line; + $this->fk_facture = $invoiceId; + $this->fk_societe = $invoice->socid; + $this->status = self::STATUS_MATCHED; + $this->fk_user_match = $user->id; + $this->date_match = dol_now(); + $this->update($user); + + $this->db->commit(); + return $paiement->bank_line; + } + + // Create bank entry for existing payment + $bankLineId = $paiement->addPaymentToBank( + $user, + 'payment', + '(CustomerInvoicePayment)', + $bankAccountId, + $this->name, + '' + ); + + if ($bankLineId > 0) { + $this->fk_paiement = $paymentId; + $this->fk_bank = $bankLineId; + $this->fk_facture = $invoiceId; + $this->fk_societe = $invoice->socid; + $this->status = self::STATUS_MATCHED; + $this->fk_user_match = $user->id; + $this->date_match = dol_now(); + $this->update($user); + } else { + $this->error = 'Failed to add payment to bank'; + $error++; + } + } else { + // No payment exists - create new payment AND bank entry + $amounts = array($invoiceId => $invoice->total_ttc); + $multicurrency_amounts = array($invoiceId => $invoice->total_ttc); + + $paiement = new Paiement($this->db); + $paiement->datepaye = $this->date_trans; + $paiement->amounts = $amounts; + $paiement->multicurrency_amounts = $multicurrency_amounts; + $paiement->paiementid = $paiementTypeId; + $paiement->paiementcode = 'VIR'; + $paiement->num_payment = $this->end_to_end_id ?: $this->ref; + $paiement->note_private = $langs->trans("PaymentCreatedByBankImport").' - '.$this->name; + + $paymentId = $paiement->create($user, 1); + if ($paymentId < 0) { + $this->error = $paiement->error; + $this->errors = $paiement->errors; + $error++; + } + + if (!$error) { + $bankLineId = $paiement->addPaymentToBank( + $user, + 'payment', + '(CustomerInvoicePayment)', + $bankAccountId, + $this->name, + '' + ); + + if ($bankLineId > 0) { + $this->fk_paiement = $paymentId; + $this->fk_bank = $bankLineId; + $this->fk_facture = $invoiceId; + $this->fk_societe = $invoice->socid; + $this->status = self::STATUS_MATCHED; + $this->fk_user_match = $user->id; + $this->date_match = dol_now(); + $this->update($user); + } else { + $this->error = 'Failed to add payment to bank'; + $error++; + } + } + } + } else { + $this->error = 'Unknown invoice type: '.$invoiceType; + $this->db->rollback(); + return -4; + } + + if ($error) { + $this->db->rollback(); + return -5; + } + + $this->db->commit(); + return $bankLineId; + } + + /** + * Link multiple already paid invoices to this bank transaction + * Finds existing payments for these invoices and links them to a single bank entry + * + * @param User $user User making the link + * @param array $invoices Array of ['type' => 'facture'|'facture_fourn', 'id' => int] + * @param int $bankAccountId Dolibarr bank account ID + * @return int <0 if KO, >0 bank line ID if OK + */ + public function linkMultipleExistingPayments($user, $invoices, $bankAccountId) + { + global $conf, $langs; + + if (empty($invoices)) { + $this->error = 'No invoices provided'; + return -1; + } + + $error = 0; + $bankLineId = 0; + $this->db->begin(); + + // Determine invoice type (all must be same type) + $firstType = $invoices[0]['type']; + $isSupplier = ($firstType == 'facture_fourn'); + + // Collect all payment IDs and check they exist + $paymentIds = array(); + $socid = 0; + $invoiceRefs = array(); + + foreach ($invoices as $inv) { + $invoiceId = (int) $inv['id']; + + if ($isSupplier) { + require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.facture.class.php'; + $invoice = new FactureFournisseur($this->db); + if ($invoice->fetch($invoiceId) <= 0) { + $this->error = 'Invoice not found: '.$invoiceId; + $this->db->rollback(); + return -2; + } + $invoiceRefs[] = $invoice->ref; + if ($socid == 0) { + $socid = $invoice->socid; + } + + // Find payment for this invoice + $sql = "SELECT pfp.fk_paiement_fourn as payment_id"; + $sql .= " FROM ".MAIN_DB_PREFIX."paiementfourn_facturefourn as pfp"; + $sql .= " WHERE pfp.fk_facturefourn = ".((int) $invoiceId); + $sql .= " ORDER BY pfp.rowid DESC LIMIT 1"; + $resql = $this->db->query($sql); + if ($resql && $this->db->num_rows($resql) > 0) { + $obj = $this->db->fetch_object($resql); + $paymentIds[$obj->payment_id] = $obj->payment_id; + } + } else { + require_once DOL_DOCUMENT_ROOT.'/compta/facture/class/facture.class.php'; + $invoice = new Facture($this->db); + if ($invoice->fetch($invoiceId) <= 0) { + $this->error = 'Invoice not found: '.$invoiceId; + $this->db->rollback(); + return -2; + } + $invoiceRefs[] = $invoice->ref; + if ($socid == 0) { + $socid = $invoice->socid; + } + + // Find payment for this invoice + $sql = "SELECT pfp.fk_paiement as payment_id"; + $sql .= " FROM ".MAIN_DB_PREFIX."paiement_facture as pfp"; + $sql .= " WHERE pfp.fk_facture = ".((int) $invoiceId); + $sql .= " ORDER BY pfp.rowid DESC LIMIT 1"; + $resql = $this->db->query($sql); + if ($resql && $this->db->num_rows($resql) > 0) { + $obj = $this->db->fetch_object($resql); + $paymentIds[$obj->payment_id] = $obj->payment_id; + } + } + } + + // Look up payment type ID for 'VIR' (bank transfer) - needed if we create new payment + $paiementTypeId = 0; + $sql = "SELECT id FROM ".MAIN_DB_PREFIX."c_paiement WHERE code = 'VIR' AND active = 1"; + $resql = $this->db->query($sql); + if ($resql && $this->db->num_rows($resql) > 0) { + $obj = $this->db->fetch_object($resql); + $paiementTypeId = (int) $obj->id; + } + + // If no payments found, we need to create new payment(s) + if (empty($paymentIds)) { + // No existing payment - create one for all invoices + if ($isSupplier) { + require_once DOL_DOCUMENT_ROOT.'/fourn/class/paiementfourn.class.php'; + + // Build amounts array from invoices + $amounts = array(); + $multicurrency_amounts = array(); + foreach ($invoices as $inv) { + $invoice = new FactureFournisseur($this->db); + if ($invoice->fetch($inv['id']) > 0) { + $amounts[$inv['id']] = $invoice->total_ttc; + $multicurrency_amounts[$inv['id']] = $invoice->total_ttc; + } + } + + $paiementfourn = new PaiementFourn($this->db); + $paiementfourn->datepaye = $this->date_trans; + $paiementfourn->amounts = $amounts; + $paiementfourn->multicurrency_amounts = $multicurrency_amounts; + $paiementfourn->paiementid = $paiementTypeId; + $paiementfourn->paiementcode = 'VIR'; + $paiementfourn->num_payment = $this->end_to_end_id ?: $this->ref; + $paiementfourn->note_private = $langs->trans("PaymentCreatedByBankImport").' - '.$this->name; + + $paymentId = $paiementfourn->create($user, 1); + if ($paymentId < 0) { + $this->error = $paiementfourn->error; + $this->errors = $paiementfourn->errors; + $this->db->rollback(); + return -3; + } + + $bankLineId = $paiementfourn->addPaymentToBank( + $user, + 'payment_supplier', + '(SupplierInvoicePayment)', + $bankAccountId, + $this->name, + '' + ); + + if ($bankLineId > 0) { + $this->fk_paiementfourn = $paymentId; + $this->fk_bank = $bankLineId; + $this->fk_facture_fourn = $invoices[0]['id']; + $this->fk_societe = $socid; + $this->status = self::STATUS_MATCHED; + $this->fk_user_match = $user->id; + $this->date_match = dol_now(); + $this->note_private = ($this->note_private ? $this->note_private."\n" : '').'Multi-invoice link: '.implode(', ', $invoiceRefs); + $this->update($user); + + $this->db->commit(); + return $bankLineId; + } else { + $this->error = 'Failed to add payment to bank'; + $this->db->rollback(); + return -5; + } + } else { + require_once DOL_DOCUMENT_ROOT.'/compta/paiement/class/paiement.class.php'; + + // Build amounts array from invoices + $amounts = array(); + $multicurrency_amounts = array(); + foreach ($invoices as $inv) { + $invoice = new Facture($this->db); + if ($invoice->fetch($inv['id']) > 0) { + $amounts[$inv['id']] = $invoice->total_ttc; + $multicurrency_amounts[$inv['id']] = $invoice->total_ttc; + } + } + + $paiement = new Paiement($this->db); + $paiement->datepaye = $this->date_trans; + $paiement->amounts = $amounts; + $paiement->multicurrency_amounts = $multicurrency_amounts; + $paiement->paiementid = $paiementTypeId; + $paiement->paiementcode = 'VIR'; + $paiement->num_payment = $this->end_to_end_id ?: $this->ref; + $paiement->note_private = $langs->trans("PaymentCreatedByBankImport").' - '.$this->name; + + $paymentId = $paiement->create($user, 1); + if ($paymentId < 0) { + $this->error = $paiement->error; + $this->errors = $paiement->errors; + $this->db->rollback(); + return -3; + } + + $bankLineId = $paiement->addPaymentToBank( + $user, + 'payment', + '(CustomerInvoicePayment)', + $bankAccountId, + $this->name, + '' + ); + + if ($bankLineId > 0) { + $this->fk_paiement = $paymentId; + $this->fk_bank = $bankLineId; + $this->fk_facture = $invoices[0]['id']; + $this->fk_societe = $socid; + $this->status = self::STATUS_MATCHED; + $this->fk_user_match = $user->id; + $this->date_match = dol_now(); + $this->note_private = ($this->note_private ? $this->note_private."\n" : '').'Multi-invoice link: '.implode(', ', $invoiceRefs); + $this->update($user); + + $this->db->commit(); + return $bankLineId; + } else { + $this->error = 'Failed to add payment to bank'; + $this->db->rollback(); + return -5; + } + } + } + + // Use the first payment to link to bank + $firstPaymentId = reset($paymentIds); + + if ($isSupplier) { + require_once DOL_DOCUMENT_ROOT.'/fourn/class/paiementfourn.class.php'; + $paiementfourn = new PaiementFourn($this->db); + if ($paiementfourn->fetch($firstPaymentId) <= 0) { + $this->error = 'Payment not found'; + $this->db->rollback(); + return -4; + } + + // Check if payment already has a bank entry + if (!empty($paiementfourn->bank_line) && $paiementfourn->bank_line > 0) { + $bankLineId = $paiementfourn->bank_line; + } else { + // Create bank entry for existing payment + $bankLineId = $paiementfourn->addPaymentToBank( + $user, + 'payment_supplier', + '(SupplierInvoicePayment)', + $bankAccountId, + $this->name, + '' + ); + } + + if ($bankLineId > 0) { + $this->fk_paiementfourn = $firstPaymentId; + $this->fk_bank = $bankLineId; + $this->fk_facture_fourn = $invoices[0]['id']; + $this->fk_societe = $socid; + $this->status = self::STATUS_MATCHED; + $this->fk_user_match = $user->id; + $this->date_match = dol_now(); + $this->note_private = ($this->note_private ? $this->note_private."\n" : '').'Multi-invoice link: '.implode(', ', $invoiceRefs); + $this->update($user); + } else { + $this->error = 'Failed to add payment to bank'; + $error++; + } + } else { + require_once DOL_DOCUMENT_ROOT.'/compta/paiement/class/paiement.class.php'; + $paiement = new Paiement($this->db); + if ($paiement->fetch($firstPaymentId) <= 0) { + $this->error = 'Payment not found'; + $this->db->rollback(); + return -4; + } + + // Check if payment already has a bank entry + if (!empty($paiement->bank_line) && $paiement->bank_line > 0) { + $bankLineId = $paiement->bank_line; + } else { + // Create bank entry for existing payment + $bankLineId = $paiement->addPaymentToBank( + $user, + 'payment', + '(CustomerInvoicePayment)', + $bankAccountId, + $this->name, + '' + ); + } + + if ($bankLineId > 0) { + $this->fk_paiement = $firstPaymentId; + $this->fk_bank = $bankLineId; + $this->fk_facture = $invoices[0]['id']; + $this->fk_societe = $socid; + $this->status = self::STATUS_MATCHED; + $this->fk_user_match = $user->id; + $this->date_match = dol_now(); + $this->note_private = ($this->note_private ? $this->note_private."\n" : '').'Multi-invoice link: '.implode(', ', $invoiceRefs); + $this->update($user); + } else { + $this->error = 'Failed to add payment to bank'; + $error++; + } + } + + if ($error) { + $this->db->rollback(); + return -5; + } + + $this->db->commit(); + return $bankLineId; + } + + /** + * Unlink payment from this transaction + * Resets all links and sets status back to NEW + * Note: This does NOT delete the payment in Dolibarr, just removes the link + * + * @param User $user User making the change + * @return int <0 if KO, >0 if OK + */ + public function unlinkPayment($user) + { + $this->db->begin(); + + // Reset all links + $this->fk_bank = null; + $this->fk_facture = null; + $this->fk_facture_fourn = null; + $this->fk_paiement = null; + $this->fk_paiementfourn = null; + $this->fk_societe = null; + $this->fk_salary = null; + $this->fk_don = null; + $this->fk_loan = null; + + // Reset status to NEW + $this->status = self::STATUS_NEW; + $this->fk_user_match = null; + $this->date_match = null; + + $result = $this->update($user); + + if ($result > 0) { + $this->db->commit(); + return 1; + } else { + $this->db->rollback(); + return -1; + } + } + + /** + * Link transaction to a Dolibarr object + * + * @param string $type Object type (facture, facture_fourn, societe, etc.) + * @param int $id Object ID + * @param User $user User making the link + * @return int <0 if KO, >0 if OK + */ + public function linkTo($type, $id, $user) + { + switch ($type) { + case 'facture': + $this->fk_facture = $id; + break; + case 'facture_fourn': + $this->fk_facture_fourn = $id; + break; + case 'societe': + $this->fk_societe = $id; + break; + case 'bank': + $this->fk_bank = $id; + break; + case 'paiement': + $this->fk_paiement = $id; + break; + case 'paiementfourn': + $this->fk_paiementfourn = $id; + break; + case 'salary': + $this->fk_salary = $id; + break; + case 'don': + $this->fk_don = $id; + break; + case 'loan': + $this->fk_loan = $id; + break; + default: + $this->error = 'Unknown link type: '.$type; + return -1; + } + + $this->status = self::STATUS_MATCHED; + return $this->update($user); + } +} diff --git a/class/fints.class.php b/class/fints.class.php new file mode 100755 index 0000000..95911e4 --- /dev/null +++ b/class/fints.class.php @@ -0,0 +1,1020 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +/** + * \file bankimport/class/fints.class.php + * \ingroup bankimport + * \brief Class for FinTS/HBCI bank connection + */ + +// Load Dolibarr's PSR classes first to avoid version conflicts +// Dolibarr includes psr/log 1.x which must be loaded before phpFinTS +if (defined('DOL_DOCUMENT_ROOT')) { + $dolibarrPsrLog = DOL_DOCUMENT_ROOT.'/includes/sabre/psr/log/Psr/Log/LoggerInterface.php'; + if (file_exists($dolibarrPsrLog) && !interface_exists('Psr\Log\LoggerInterface', false)) { + require_once DOL_DOCUMENT_ROOT.'/includes/sabre/psr/log/Psr/Log/LoggerInterface.php'; + require_once DOL_DOCUMENT_ROOT.'/includes/sabre/psr/log/Psr/Log/LoggerTrait.php'; + require_once DOL_DOCUMENT_ROOT.'/includes/sabre/psr/log/Psr/Log/AbstractLogger.php'; + require_once DOL_DOCUMENT_ROOT.'/includes/sabre/psr/log/Psr/Log/NullLogger.php'; + require_once DOL_DOCUMENT_ROOT.'/includes/sabre/psr/log/Psr/Log/LogLevel.php'; + } +} + +// Autoload phpFinTS library if available +$composerAutoload = dirname(__DIR__).'/vendor/autoload.php'; +if (file_exists($composerAutoload)) { + require_once $composerAutoload; +} + +use Fhp\FinTs; +use Fhp\Options\FinTsOptions; +use Fhp\Options\Credentials; +use Fhp\Action\GetSEPAAccounts; +use Fhp\Action\GetStatementOfAccount; +use Fhp\Action\GetStatementOfAccountXML; +use Fhp\Model\StatementOfAccount\Statement; +use Fhp\Model\StatementOfAccount\Transaction; + +/** + * Class BankImportFinTS + * Handles FinTS/HBCI bank connections for retrieving account statements + */ +class BankImportFinTS +{ + /** + * @var DoliDB Database handler + */ + public $db; + + /** + * @var string Error message + */ + public $error = ''; + + /** + * @var array Error messages + */ + public $errors = array(); + + /** + * @var string FinTS Server URL + */ + private $fintsUrl; + + /** + * @var string BLZ (Bankleitzahl) + */ + private $blz; + + /** + * @var string Username + */ + private $username; + + /** + * @var string PIN (decrypted) + */ + private $pin; + + /** + * @var string IBAN + */ + private $iban; + + /** + * @var string FinTS Product ID + */ + private $productId; + + /** + * @var FinTs|null FinTS instance + */ + private $fints = null; + + /** + * @var array Available TAN modes + */ + public $tanModes = array(); + + /** + * @var mixed Selected TAN mode + */ + public $selectedTanMode = null; + + /** + * @var string TAN challenge (for display to user) + */ + public $tanChallenge = ''; + + /** + * @var mixed Current action requiring TAN + */ + private $pendingAction = null; + + /** + * Constructor + * + * @param DoliDB $db Database handler + */ + public function __construct($db) + { + global $conf; + + $this->db = $db; + + // Load configuration + $this->fintsUrl = getDolGlobalString('BANKIMPORT_FINTS_URL'); + $this->blz = getDolGlobalString('BANKIMPORT_FINTS_BLZ'); + $this->username = getDolGlobalString('BANKIMPORT_FINTS_USERNAME'); + + // Decrypt PIN + $encryptedPin = getDolGlobalString('BANKIMPORT_FINTS_PIN'); + if (!empty($encryptedPin)) { + $this->pin = dolDecrypt($encryptedPin); + } + + $this->iban = getDolGlobalString('BANKIMPORT_FINTS_IBAN'); + $this->productId = getDolGlobalString('BANKIMPORT_FINTS_PRODUCT_ID'); + + // Default product ID if not set + // Use Hibiscus/Jameica registered product ID + if (empty($this->productId)) { + // Official Hibiscus product ID (registered with Deutsche Kreditwirtschaft) + $this->productId = '36792786FA12F235F04647689'; + } + } + + /** + * Check if configuration is complete + * + * @return bool True if all required fields are set + */ + public function isConfigured() + { + return !empty($this->fintsUrl) + && !empty($this->blz) + && !empty($this->username) + && !empty($this->pin) + && !empty($this->iban); + } + + /** + * Check if phpFinTS library is available + * + * @return bool True if library is loaded + */ + public function isLibraryAvailable() + { + return class_exists('Fhp\FinTs'); + } + + /** + * Initialize FinTS connection + * + * @return int 1 if success, -1 if error + */ + public function initConnection() + { + if (!$this->isConfigured()) { + $this->error = 'Configuration incomplete'; + return -1; + } + + if (!$this->isLibraryAvailable()) { + $this->error = 'phpFinTS library not found. Run: composer install'; + return -1; + } + + try { + $options = new FinTsOptions(); + $options->url = $this->fintsUrl; + $options->bankCode = $this->blz; + $options->productName = $this->productId; + $options->productVersion = '1.0'; + + $credentials = Credentials::create($this->username, $this->pin); + + $this->fints = FinTs::new($options, $credentials); + + return 1; + } catch (Exception $e) { + $this->error = $e->getMessage(); + return -1; + } + } + + /** + * Test the FinTS connection + * + * @return int 1 if success, -1 if error + */ + public function testConnection() + { + if (!$this->isConfigured()) { + $this->error = 'Configuration incomplete'; + return -1; + } + + // Check URL format + if (!filter_var($this->fintsUrl, FILTER_VALIDATE_URL)) { + $this->error = 'Invalid FinTS URL format'; + return -1; + } + + // Check BLZ format (8 digits) + if (!preg_match('/^\d{8}$/', $this->blz)) { + $this->error = 'Invalid BLZ format (must be 8 digits)'; + return -1; + } + + // Check if library is available + if (!$this->isLibraryAvailable()) { + $this->error = 'phpFinTS library not installed. Run: cd '.dirname(__DIR__).' && composer install'; + return -1; + } + + // Try to initialize connection + $result = $this->initConnection(); + if ($result < 0) { + return -1; + } + + try { + // Try to get TAN modes (this tests the connection) + $this->tanModes = $this->fints->getTanModes(); + return 1; + } catch (Exception $e) { + $this->error = $e->getMessage(); + return -1; + } + } + + /** + * Get available TAN modes + * + * @return array Array of TAN modes + */ + public function getTanModes() + { + if ($this->fints === null) { + $result = $this->initConnection(); + if ($result < 0) { + return array(); + } + } + + try { + $modes = $this->fints->getTanModes(); + $result = array(); + foreach ($modes as $mode) { + $result[] = array( + 'id' => $mode->getId(), + 'name' => $mode->getName(), + 'isDecoupled' => $mode->isDecoupled(), + ); + } + return $result; + } catch (Exception $e) { + $this->error = $e->getMessage(); + return array(); + } + } + + /** + * Login with selected TAN mode + * + * @param int $tanModeId TAN mode ID to use + * @return int 1 if success, 0 if TAN required, -1 if error + */ + public function login($tanModeId = null) + { + if ($this->fints === null) { + $result = $this->initConnection(); + if ($result < 0) { + return -1; + } + } + + try { + // Get and select TAN mode + $tanModes = $this->fints->getTanModes(); + + if ($tanModeId !== null) { + foreach ($tanModes as $mode) { + if ($mode->getId() == $tanModeId) { + $this->selectedTanMode = $mode; + break; + } + } + } else { + // Auto-select decoupled mode (SecureGo Plus) if available + foreach ($tanModes as $mode) { + if ($mode->isDecoupled()) { + $this->selectedTanMode = $mode; + break; + } + } + // Fallback to first mode + if ($this->selectedTanMode === null && !empty($tanModes)) { + $this->selectedTanMode = $tanModes[0]; + } + } + + if ($this->selectedTanMode === null) { + $this->error = 'No TAN mode available'; + return -1; + } + + $this->fints->selectTanMode($this->selectedTanMode); + + // Perform login + $login = $this->fints->login(); + + if ($login->needsTan()) { + $this->pendingAction = $login; + $tanRequest = $login->getTanRequest(); + $this->tanChallenge = $tanRequest->getChallenge(); + return 0; // TAN required + } + + return 1; + } catch (Exception $e) { + $this->error = $e->getMessage(); + return -1; + } + } + + /** + * Get pending action for serialization + * + * @return mixed + */ + public function getPendingAction() + { + return $this->pendingAction; + } + + /** + * Set pending action after deserialization + * + * @param mixed $action The pending action + * @return void + */ + public function setPendingAction($action) + { + $this->pendingAction = $action; + } + + /** + * Check if decoupled TAN (SecureGo Plus) has been confirmed + * + * @return int 1 if confirmed, 0 if still waiting, -1 if error + */ + public function checkDecoupledTan() + { + if ($this->fints === null) { + $this->error = 'FinTS instance is null'; + dol_syslog("BankImport: checkDecoupledTan - FinTS instance is null", LOG_ERR); + return -1; + } + + if ($this->pendingAction === null) { + $this->error = 'Pending action is null'; + dol_syslog("BankImport: checkDecoupledTan - Pending action is null", LOG_ERR); + return -1; + } + + dol_syslog("BankImport: checkDecoupledTan - Checking TAN status, action type: ".get_class($this->pendingAction), LOG_DEBUG); + + try { + $done = $this->fints->checkDecoupledSubmission($this->pendingAction); + if ($done) { + dol_syslog("BankImport: checkDecoupledTan - TAN confirmed!", LOG_DEBUG); + $this->pendingAction = null; + return 1; + } + dol_syslog("BankImport: checkDecoupledTan - Still waiting for TAN", LOG_DEBUG); + return 0; + } catch (Exception $e) { + $this->error = $e->getMessage(); + dol_syslog("BankImport: checkDecoupledTan - Exception: ".$e->getMessage(), LOG_ERR); + return -1; + } + } + + /** + * Get bank supported parameters (for diagnostics) + * + * @return array Array of supported parameter segments + */ + public function getBankParameters() + { + if ($this->fints === null) { + return array('error' => 'Not connected'); + } + + try { + // Use reflection to access protected BPD + $reflection = new \ReflectionClass($this->fints); + $bpdProperty = $reflection->getProperty('bpd'); + $bpdProperty->setAccessible(true); + $bpd = $bpdProperty->getValue($this->fints); + + if ($bpd === null) { + return array('error' => 'BPD not available'); + } + + // Get parameters property from BPD + $bpdReflection = new \ReflectionClass($bpd); + $paramsProperty = $bpdReflection->getProperty('parameters'); + $paramsProperty->setAccessible(true); + $parameters = $paramsProperty->getValue($bpd); + + $result = array(); + foreach ($parameters as $type => $versions) { + $result[$type] = array_keys($versions); + } + + return $result; + } catch (Exception $e) { + return array('error' => $e->getMessage()); + } + } + + /** + * Submit TAN for pending action + * + * @param string $tan The TAN entered by user + * @return int 1 if success, -1 if error + */ + public function submitTan($tan) + { + if ($this->fints === null || $this->pendingAction === null) { + $this->error = 'No pending action'; + return -1; + } + + try { + $this->fints->submitTan($this->pendingAction, $tan); + $this->pendingAction = null; + return 1; + } catch (Exception $e) { + $this->error = $e->getMessage(); + return -1; + } + } + + /** + * Get SEPA accounts + * + * @return array|int Array of accounts or -1 on error + */ + public function getAccounts() + { + if ($this->fints === null) { + $this->error = 'Not connected'; + return -1; + } + + try { + $getAccounts = GetSEPAAccounts::create(); + $this->fints->execute($getAccounts); + + if ($getAccounts->needsTan()) { + $this->pendingAction = $getAccounts; + $tanRequest = $getAccounts->getTanRequest(); + $this->tanChallenge = $tanRequest->getChallenge(); + return 0; // TAN required + } + + $accounts = $getAccounts->getAccounts(); + $result = array(); + + foreach ($accounts as $account) { + $result[] = array( + 'iban' => $account->getIban(), + 'bic' => $account->getBic(), + 'accountNumber' => $account->getAccountNumber(), + 'blz' => $account->getBlz(), + ); + } + + return $result; + } catch (Exception $e) { + $this->error = $e->getMessage(); + return -1; + } + } + + /** + * Maximum days per fetch request (banks often limit this) + */ + const MAX_DAYS_PER_FETCH = 30; + + /** + * Fetch account statements + * + * @param int $dateFrom Start date (timestamp) + * @param int $dateTo End date (timestamp) + * @return array|int Array of transactions or -1 on error, 0 if TAN required + */ + public function fetchStatements($dateFrom = 0, $dateTo = 0) + { + if (!$this->isConfigured()) { + $this->error = 'Configuration incomplete'; + return -1; + } + + if ($this->fints === null) { + $this->error = 'Not connected. Call login() first.'; + return -1; + } + + // Default: last 30 days + if (empty($dateFrom)) { + $dateFrom = strtotime('-30 days'); + } + if (empty($dateTo)) { + $dateTo = time(); + } + + // Check if date range exceeds limit - if so, fetch in chunks + $daysDiff = ($dateTo - $dateFrom) / 86400; + if ($daysDiff > self::MAX_DAYS_PER_FETCH) { + dol_syslog("BankImport: Date range {$daysDiff} days exceeds limit, fetching in chunks", LOG_DEBUG); + return $this->fetchStatementsInChunks($dateFrom, $dateTo); + } + + return $this->fetchStatementsForPeriod($dateFrom, $dateTo); + } + + /** + * Fetch statements in chunks for long date ranges + * + * @param int $dateFrom Start date (timestamp) + * @param int $dateTo End date (timestamp) + * @return array|int Array of transactions or -1 on error, 0 if TAN required + */ + protected function fetchStatementsInChunks($dateFrom, $dateTo) + { + $allTransactions = array(); + $lastBalance = array(); + $chunkDays = self::MAX_DAYS_PER_FETCH; + $oldestDateReached = null; + + // Start from the most recent date and go backwards + // This ensures we get the newest transactions even if old ones aren't available + $currentTo = $dateTo; + + while ($currentTo > $dateFrom) { + $currentFrom = max($currentTo - ($chunkDays * 86400), $dateFrom); + + dol_syslog("BankImport: Fetching chunk from ".date('Y-m-d', $currentFrom)." to ".date('Y-m-d', $currentTo), LOG_DEBUG); + + $result = $this->fetchStatementsForPeriod($currentFrom, $currentTo); + + if ($result === 0) { + // TAN required - save progress and return + $_SESSION['fints_chunk_progress'] = array( + 'transactions' => $allTransactions, + 'currentTo' => $currentFrom, + 'dateFrom' => $dateFrom + ); + return 0; + } + + if ($result < 0) { + // Error fetching this chunk + // If we already have transactions from newer chunks, return those with a note + if (!empty($allTransactions)) { + dol_syslog("BankImport: Chunk failed for older dates, returning ".count($allTransactions)." transactions from recent period", LOG_WARNING); + return array( + 'transactions' => $allTransactions, + 'balance' => $lastBalance, + 'partial' => true, + 'oldestDate' => $oldestDateReached, + 'info' => 'Ältere Daten (vor '.date('d.m.Y', $currentTo).') sind bei der Bank nicht mehr verfügbar.' + ); + } + return -1; + } + + if (is_array($result)) { + $newTransactions = $result['transactions'] ?? array(); + $allTransactions = array_merge($allTransactions, $newTransactions); + + // Track oldest date we successfully fetched + foreach ($newTransactions as $tx) { + if ($oldestDateReached === null || $tx['date'] < $oldestDateReached) { + $oldestDateReached = $tx['date']; + } + } + + if (empty($lastBalance) && !empty($result['balance'])) { + $lastBalance = $result['balance']; + } + } + + $currentTo = $currentFrom - 86400; // Previous day + } + + // Remove duplicates based on transaction ID + $uniqueTransactions = array(); + $seenIds = array(); + foreach ($allTransactions as $tx) { + if (!isset($seenIds[$tx['id']])) { + $uniqueTransactions[] = $tx; + $seenIds[$tx['id']] = true; + } + } + + // Sort by date descending (newest first) + usort($uniqueTransactions, function($a, $b) { + return $b['date'] - $a['date']; + }); + + dol_syslog("BankImport: Total fetched: ".count($uniqueTransactions)." unique transactions", LOG_DEBUG); + + return array( + 'transactions' => $uniqueTransactions, + 'balance' => $lastBalance + ); + } + + /** + * Fetch statements for a single period (internal) + * + * @param int $dateFrom Start date (timestamp) + * @param int $dateTo End date (timestamp) + * @return array|int Array of transactions or -1 on error, 0 if TAN required + */ + protected function fetchStatementsForPeriod($dateFrom, $dateTo) + { + try { + // Get accounts first + $getAccounts = GetSEPAAccounts::create(); + $this->fints->execute($getAccounts); + + if ($getAccounts->needsTan()) { + $this->pendingAction = $getAccounts; + $tanRequest = $getAccounts->getTanRequest(); + $this->tanChallenge = $tanRequest->getChallenge(); + return 0; + } + + $accounts = $getAccounts->getAccounts(); + + // Debug: Log all available accounts + dol_syslog("BankImport: Looking for IBAN: ".$this->iban, LOG_DEBUG); + foreach ($accounts as $idx => $acc) { + dol_syslog("BankImport: Available account ".$idx.": IBAN=".$acc->getIban(), LOG_DEBUG); + } + + // Find matching account by IBAN + $selectedAccount = null; + foreach ($accounts as $account) { + if ($account->getIban() === $this->iban) { + $selectedAccount = $account; + dol_syslog("BankImport: Selected account by IBAN match: ".$account->getIban(), LOG_DEBUG); + break; + } + } + + if ($selectedAccount === null) { + // Fallback: use first account + if (!empty($accounts)) { + $selectedAccount = $accounts[0]; + dol_syslog("BankImport: WARNING - No IBAN match found! Using first account: ".$selectedAccount->getIban(), LOG_WARNING); + } else { + $this->error = 'No accounts found'; + return -1; + } + } + + // Fetch statements + $from = new DateTime(); + $from->setTimestamp($dateFrom); + $to = new DateTime(); + $to->setTimestamp($dateTo); + + $transactions = array(); + + // Log what we're trying + dol_syslog("BankImport: Fetching statements from ".$from->format('Y-m-d')." to ".$to->format('Y-m-d'), LOG_DEBUG); + + // Try GetStatementOfAccount first (MT940/HKKAZ - most widely supported) + $mt940Success = false; + try { + dol_syslog("BankImport: Trying GetStatementOfAccount (MT940/HKKAZ)", LOG_DEBUG); + $getStatement = GetStatementOfAccount::create($selectedAccount, $from, $to); + $this->fints->execute($getStatement); + + if ($getStatement->needsTan()) { + $this->pendingAction = $getStatement; + $tanRequest = $getStatement->getTanRequest(); + $this->tanChallenge = $tanRequest->getChallenge(); + return 0; + } + + // Process MT940 results + $soa = $getStatement->getStatement(); + foreach ($soa->getStatements() as $statement) { + foreach ($statement->getTransactions() as $tx) { + $sign = ($tx->getCreditDebit() === Transaction::CD_DEBIT) ? -1 : 1; + + $transactions[] = array( + 'id' => md5($tx->getValutaDate()->format('Y-m-d').$tx->getAmount().$tx->getName().$tx->getMainDescription()), + 'date' => $tx->getValutaDate()->getTimestamp(), + 'bookingDate' => $tx->getBookingDate() ? $tx->getBookingDate()->getTimestamp() : null, + 'amount' => $sign * $tx->getAmount(), + 'currency' => 'EUR', + 'name' => $tx->getName(), + 'iban' => $tx->getAccountNumber(), + 'bic' => $tx->getBankCode(), + 'reference' => $tx->getMainDescription(), + 'bookingText' => $tx->getBookingText(), + 'endToEndId' => $tx->getEndToEndID(), + ); + } + } + dol_syslog("BankImport: MT940 successful, got ".count($transactions)." transactions", LOG_DEBUG); + + // Return MT940 results + return array( + 'transactions' => $transactions, + 'balance' => array() + ); + } catch (Exception $e) { + dol_syslog("BankImport: MT940 failed: ".$e->getMessage(), LOG_DEBUG); + + // MT940 failed, try CAMT/XML + dol_syslog("BankImport: Trying GetStatementOfAccountXML (CAMT)", LOG_DEBUG); + try { + $getStatementXML = GetStatementOfAccountXML::create($selectedAccount, $from, $to); + $this->fints->execute($getStatementXML); + + if ($getStatementXML->needsTan()) { + $this->pendingAction = $getStatementXML; + $tanRequest = $getStatementXML->getTanRequest(); + $this->tanChallenge = $tanRequest->getChallenge(); + return 0; + } + + // Process CAMT XML results + $xmlStatements = $getStatementXML->getBookedXML(); + + // Debug: Log raw XML count + dol_syslog("BankImport: Got ".count($xmlStatements)." XML documents", LOG_DEBUG); + + // Balance info from XML + $balanceInfo = array(); + + foreach ($xmlStatements as $idx => $camtDoc) { + // Debug: Save raw XML to temp file for analysis + $debugFile = DOL_DATA_ROOT.'/bankimport_debug_'.$idx.'.xml'; + file_put_contents($debugFile, $camtDoc); + dol_syslog("BankImport: Saved XML to ".$debugFile, LOG_DEBUG); + + // Parse CAMT XML + $xml = simplexml_load_string($camtDoc); + if ($xml === false) { + dol_syslog("BankImport: Failed to parse XML document ".$idx, LOG_ERR); + continue; + } + + // Get all namespaces used in the document + $namespaces = $xml->getNamespaces(true); + dol_syslog("BankImport: Namespaces: ".json_encode($namespaces), LOG_DEBUG); + + // Register all found namespaces + foreach ($namespaces as $prefix => $uri) { + if (empty($prefix)) { + $xml->registerXPathNamespace('ns', $uri); + } else { + $xml->registerXPathNamespace($prefix, $uri); + } + } + + // Extract balance information from XML + $balances = $xml->xpath('//ns:Bal') ?: $xml->xpath('//Bal') ?: $xml->xpath('//*[local-name()="Bal"]') ?: []; + foreach ($balances as $bal) { + $balType = (string) ($bal->Tp->CdOrPrtry->Cd ?? ''); + if ($balType === 'CLBD') { // Closing balance + $balAmount = (float) ($bal->Amt ?? 0); + $balCcy = (string) ($bal->Amt['Ccy'] ?? 'EUR'); + $balSign = ((string) ($bal->CdtDbtInd ?? 'CRDT')) === 'DBIT' ? -1 : 1; + $balDate = (string) ($bal->Dt->Dt ?? date('Y-m-d')); + $balanceInfo = array( + 'amount' => $balSign * $balAmount, + 'currency' => $balCcy, + 'date' => $balDate, + 'type' => 'CLBD' + ); + dol_syslog("BankImport: Found closing balance: ".$balanceInfo['amount']." ".$balCcy." at ".$balDate, LOG_DEBUG); + } + } + + // Try multiple XPath patterns for different CAMT versions + $entries = $xml->xpath('//ns:Ntry') ?: $xml->xpath('//Ntry') ?: $xml->xpath('//*[local-name()="Ntry"]') ?: []; + dol_syslog("BankImport: Found ".count($entries)." entries", LOG_DEBUG); + + foreach ($entries as $entry) { + $amount = (float) ($entry->Amt ?? 0); + $sign = ((string) ($entry->CdtDbtInd ?? 'CRDT')) === 'DBIT' ? -1 : 1; + $date = (string) ($entry->BookgDt->Dt ?? $entry->ValDt->Dt ?? date('Y-m-d')); + + // Get counterparty name - CAMT.052.001.08 has Pty wrapper + $txDtls = $entry->NtryDtls->TxDtls ?? null; + $name = ''; + if ($txDtls) { + // For DBIT (outgoing): counterparty is Cdtr (creditor) + // For CRDT (incoming): counterparty is Dbtr (debtor) + if ($sign < 0) { + // Outgoing payment - get creditor name + $name = (string) ($txDtls->RltdPties->Cdtr->Pty->Nm + ?? $txDtls->RltdPties->Cdtr->Nm + ?? ''); + } else { + // Incoming payment - get debtor name + $name = (string) ($txDtls->RltdPties->Dbtr->Pty->Nm + ?? $txDtls->RltdPties->Dbtr->Nm + ?? ''); + } + } + + // Get reference/description + $reference = ''; + if ($txDtls && isset($txDtls->RmtInf)) { + $reference = (string) ($txDtls->RmtInf->Ustrd ?? ''); + } + // Fallback to AddtlNtryInf + if (empty($reference)) { + $reference = (string) ($entry->AddtlNtryInf ?? ''); + } + + $transactions[] = array( + 'id' => md5($date . $amount . $name . $reference), + 'date' => strtotime($date), + 'bookingDate' => strtotime($date), + 'amount' => $sign * $amount, + 'currency' => (string) ($entry->Amt['Ccy'] ?? 'EUR'), + 'name' => $name, + 'iban' => '', + 'bic' => '', + 'reference' => $reference, + 'bookingText' => (string) ($entry->AddtlNtryInf ?? ''), + 'endToEndId' => (string) ($txDtls->Refs->EndToEndId ?? ''), + ); + } + } + + // Return transactions with balance info + return array( + 'transactions' => $transactions, + 'balance' => $balanceInfo + ); + + } catch (Exception $e2) { + // CAMT also failed - both methods failed + // Check if this is likely a historical data limit issue + $daysAgo = (time() - $dateFrom) / 86400; + $errorMsg = $e->getMessage() . ' ' . $e2->getMessage(); + + // Banks typically don't provide data older than 90 days + // Error messages containing "HIKAZS" or "not support" often indicate data unavailability + if ($daysAgo > 60 && ( + stripos($errorMsg, 'HIKAZS') !== false || + stripos($errorMsg, 'not support') !== false || + stripos($errorMsg, 'nicht unterstützt') !== false || + stripos($errorMsg, 'nicht verfügbar') !== false + )) { + $this->error = 'Die Bank stellt historische Kontodaten nur für ca. 2-3 Monate bereit. Bitte wählen Sie einen kürzeren Zeitraum.'; + } else { + $this->error = 'Abruf fehlgeschlagen: ' . $e->getMessage(); + } + dol_syslog("BankImport: Both MT940 and CAMT failed. MT940: ".$e->getMessage()." | CAMT: ".$e2->getMessage(), LOG_ERR); + return -1; + } + } + } catch (Exception $e) { + $this->error = $e->getMessage(); + return -1; + } + } + + /** + * Close FinTS connection + * + * @return void + */ + public function close() + { + if ($this->fints !== null) { + try { + $this->fints->close(); + } catch (Exception $e) { + // Ignore close errors + } + $this->fints = null; + } + } + + /** + * Persist FinTS state (for web session handling) + * + * @return string|null Serialized state or null + */ + public function persist() + { + if ($this->fints === null) { + return null; + } + try { + return $this->fints->persist(); + } catch (Exception $e) { + return null; + } + } + + /** + * Restore FinTS from persisted state + * + * @param string $state Persisted state + * @return int 1 if success, -1 if error + */ + public function restore($state) + { + if (!$this->isConfigured()) { + $this->error = 'Configuration incomplete'; + return -1; + } + + if (!$this->isLibraryAvailable()) { + $this->error = 'phpFinTS library not found'; + return -1; + } + + if (empty($state)) { + $this->error = 'Empty state provided'; + dol_syslog("BankImport: restore - Empty state provided", LOG_ERR); + return -1; + } + + dol_syslog("BankImport: restore - Restoring FinTS state, length=".strlen($state), LOG_DEBUG); + + try { + $options = new FinTsOptions(); + $options->url = $this->fintsUrl; + $options->bankCode = $this->blz; + $options->productName = $this->productId; + $options->productVersion = '1.0'; + + $credentials = Credentials::create($this->username, $this->pin); + + $this->fints = FinTs::new($options, $credentials, $state); + + dol_syslog("BankImport: restore - FinTS state restored successfully", LOG_DEBUG); + return 1; + } catch (Exception $e) { + $this->error = $e->getMessage(); + dol_syslog("BankImport: restore - Exception: ".$e->getMessage(), LOG_ERR); + return -1; + } + } + + /** + * Get FinTS URL + * + * @return string + */ + public function getFintsUrl() + { + return $this->fintsUrl; + } + + /** + * Get BLZ + * + * @return string + */ + public function getBlz() + { + return $this->blz; + } + + /** + * Get IBAN + * + * @return string + */ + public function getIban() + { + return $this->iban; + } +} diff --git a/composer.json b/composer.json new file mode 100755 index 0000000..d77bfa8 --- /dev/null +++ b/composer.json @@ -0,0 +1,16 @@ +{ + "name": "dolibarr/bankimport", + "description": "Dolibarr module for importing bank statements via FinTS/HBCI", + "type": "dolibarr-module", + "license": "GPL-3.0-or-later", + "require": { + "php": ">=8.0", + "nemiah/php-fints": "^3.2" + }, + "replace": { + "psr/log": "*" + }, + "autoload": { + "classmap": ["class/"] + } +} diff --git a/composer.lock b/composer.lock new file mode 100755 index 0000000..4f0aff3 --- /dev/null +++ b/composer.lock @@ -0,0 +1,70 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "cfc07b7e6c4a3dcfdcd6e754983b1a9b", + "packages": [ + { + "name": "nemiah/php-fints", + "version": "3.7.0", + "source": { + "type": "git", + "url": "https://github.com/nemiah/phpFinTS.git", + "reference": "08257e10229db2d4ca8c54ed7fec0f390b332519" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nemiah/phpFinTS/zipball/08257e10229db2d4ca8c54ed7fec0f390b332519", + "reference": "08257e10229db2d4ca8c54ed7fec0f390b332519", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-mbstring": "*", + "php": ">=8.0", + "psr/log": "^1|^2|^3" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.0", + "php-mock/php-mock-phpunit": "^2.6", + "phpunit/phpunit": "^9.5" + }, + "suggest": { + "abcaeffchen/sephpa": "1.*", + "monolog/monolog": "Allow sending log messages to a variety of different handlers", + "nemiah/php-sepa-xml": "dev-master" + }, + "type": "library", + "autoload": { + "psr-0": { + "Fhp": "lib/", + "Tests\\Fhp": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHP Library for the protocols fints and hbci", + "homepage": "https://github.com/nemiah/phpFinTS", + "support": { + "issues": "https://github.com/nemiah/phpFinTS/issues", + "source": "https://github.com/nemiah/phpFinTS/tree/3.7" + }, + "time": "2025-10-14T15:05:56+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=8.0" + }, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/confirm.php b/confirm.php new file mode 100755 index 0000000..1b0cf9b --- /dev/null +++ b/confirm.php @@ -0,0 +1,458 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +/** + * \file bankimport/confirm.php + * \ingroup bankimport + * \brief Payment confirmation page - match bank transactions to invoices + */ + +// Load Dolibarr environment +$res = 0; +if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) { + $res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php"; +} +$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME']; +$tmp2 = realpath(__FILE__); +$i = strlen($tmp) - 1; +$j = strlen($tmp2) - 1; +while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) { + $i--; + $j--; +} +if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) { + $res = @include substr($tmp, 0, ($i + 1))."/main.inc.php"; +} +if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) { + $res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php"; +} +if (!$res && file_exists("../main.inc.php")) { + $res = @include "../main.inc.php"; +} +if (!$res && file_exists("../../main.inc.php")) { + $res = @include "../../main.inc.php"; +} +if (!$res) { + die("Include of main fails"); +} + +require_once DOL_DOCUMENT_ROOT.'/core/lib/date.lib.php'; +require_once DOL_DOCUMENT_ROOT.'/core/class/html.formother.class.php'; +dol_include_once('/bankimport/class/banktransaction.class.php'); +dol_include_once('/bankimport/lib/bankimport.lib.php'); + +/** + * @var Conf $conf + * @var DoliDB $db + * @var Translate $langs + * @var User $user + */ + +$langs->loadLangs(array("bankimport@bankimport", "banks", "bills")); + +$action = GETPOST('action', 'aZ09'); + +// Security check +if (!$user->hasRight('bankimport', 'write')) { + accessforbidden(); +} + +$bankAccountId = getDolGlobalInt('BANKIMPORT_BANK_ACCOUNT_ID'); + +/* + * Actions + */ + +// Confirm single payment +if ($action == 'confirmpayment' && !empty($bankAccountId)) { + $transid = GETPOSTINT('transid'); + $matchtype = GETPOST('matchtype', 'alpha'); + $matchid = GETPOSTINT('matchid'); + + if ($transid > 0 && !empty($matchtype) && $matchid > 0) { + $trans = new BankImportTransaction($db); + if ($trans->fetch($transid) > 0) { + if ($trans->status != BankImportTransaction::STATUS_NEW) { + setEventMessages($langs->trans("TransactionAlreadyProcessed"), null, 'warnings'); + } else { + $result = $trans->confirmPayment($user, $matchtype, $matchid, $bankAccountId); + if ($result > 0) { + setEventMessages($langs->trans("PaymentCreatedSuccessfully", dol_escape_htmltag($trans->name), price(abs($trans->amount))), null, 'mesgs'); + } else { + setEventMessages($trans->error, $trans->errors, 'errors'); + } + } + } + } + + header("Location: ".$_SERVER["PHP_SELF"]."?token=".newToken()); + exit; +} + +// Confirm multiple invoices payment +if ($action == 'confirmmulti' && !empty($bankAccountId)) { + $transid = GETPOSTINT('transid'); + $invoiceIds = GETPOST('invoices', 'array'); + + if ($transid > 0 && !empty($invoiceIds)) { + $trans = new BankImportTransaction($db); + if ($trans->fetch($transid) > 0) { + if ($trans->status != BankImportTransaction::STATUS_NEW) { + setEventMessages($langs->trans("TransactionAlreadyProcessed"), null, 'warnings'); + } else { + // Build invoices array with amounts + require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.facture.class.php'; + $invoices = array(); + foreach ($invoiceIds as $invId) { + $invId = (int) $invId; + if ($invId > 0) { + $invoice = new FactureFournisseur($db); + if ($invoice->fetch($invId) > 0) { + $alreadyPaid = $invoice->getSommePaiement(); + $creditnotes = $invoice->getSumCreditNotesUsed(); + $deposits = $invoice->getSumDepositsUsed(); + $remainToPay = price2num($invoice->total_ttc - $alreadyPaid - $creditnotes - $deposits, 'MT'); + if ($remainToPay > 0) { + $invoices[] = array( + 'type' => 'facture_fourn', + 'id' => $invId, + 'ref' => $invoice->ref, + 'amount' => $remainToPay + ); + } + } + } + } + + if (!empty($invoices)) { + $result = $trans->confirmMultiplePayment($user, $invoices, $bankAccountId); + if ($result > 0) { + setEventMessages($langs->trans("PaymentCreatedSuccessfully", dol_escape_htmltag($trans->name), price(abs($trans->amount))).' ('.count($invoices).' '.$langs->trans("Invoices").')', null, 'mesgs'); + } else { + setEventMessages($trans->error, $trans->errors, 'errors'); + } + } else { + setEventMessages($langs->trans("NoInvoicesSelected"), null, 'errors'); + } + } + } + } + + header("Location: ".$_SERVER["PHP_SELF"]."?token=".newToken()); + exit; +} + +// Confirm all high-score matches +if ($action == 'confirmall' && !empty($bankAccountId)) { + $transaction = new BankImportTransaction($db); + $transactions = $transaction->fetchAll('date_trans', 'DESC', 0, 0, array('status' => BankImportTransaction::STATUS_NEW)); + + $created = 0; + $failed = 0; + + if (is_array($transactions)) { + foreach ($transactions as $trans) { + $matches = $trans->findMatches(); + if (!empty($matches) && $matches[0]['match_score'] >= 80) { + $bestMatch = $matches[0]; + + // Handle multi-invoice matches + if ($bestMatch['type'] == 'multi_facture_fourn' && !empty($bestMatch['invoices'])) { + $invoices = array(); + foreach ($bestMatch['invoices'] as $inv) { + $invoices[] = array( + 'type' => 'facture_fourn', + 'id' => $inv['id'], + 'ref' => $inv['ref'], + 'amount' => $inv['amount'] + ); + } + $result = $trans->confirmMultiplePayment($user, $invoices, $bankAccountId); + } else { + $result = $trans->confirmPayment($user, $bestMatch['type'], $bestMatch['id'], $bankAccountId); + } + + if ($result > 0) { + $created++; + } else { + $failed++; + } + } + } + } + + if ($created > 0 || $failed > 0) { + setEventMessages($langs->trans("PaymentsCreatedSummary", $created, $failed), null, $failed > 0 ? 'warnings' : 'mesgs'); + } else { + setEventMessages($langs->trans("NoNewMatchesFound"), null, 'warnings'); + } + + header("Location: ".$_SERVER["PHP_SELF"]."?token=".newToken()); + exit; +} + +// Ignore transaction +if ($action == 'ignore') { + $transid = GETPOSTINT('transid'); + if ($transid > 0) { + $trans = new BankImportTransaction($db); + if ($trans->fetch($transid) > 0 && $trans->status == BankImportTransaction::STATUS_NEW) { + $trans->setStatus(BankImportTransaction::STATUS_IGNORED, $user); + setEventMessages($langs->trans("StatusUpdated"), null, 'mesgs'); + } + } + header("Location: ".$_SERVER["PHP_SELF"]."?token=".newToken()); + exit; +} + + +/* + * View + */ + +$form = new Form($db); + +$title = $langs->trans("PaymentConfirmation"); +llxHeader('', $title, '', '', 0, 0, '', '', '', 'mod-bankimport page-confirm'); + +print load_fiche_titre($title, '', 'bank'); + +// Check if bank account is configured +if (empty($bankAccountId)) { + print '
'; + print img_warning().' '.$langs->trans("ErrorNoBankAccountConfigured"); + print ' '.$langs->trans("GoToSetup").''; + print '
'; + llxFooter(); + $db->close(); + exit; +} + +// Description +print '
'.$langs->trans("PaymentConfirmationDesc").'
'; + +// Fetch all new transactions and find matches +$transaction = new BankImportTransaction($db); +$transactions = $transaction->fetchAll('date_trans', 'DESC', 0, 0, array('status' => BankImportTransaction::STATUS_NEW)); + +$pendingMatches = array(); // transactions with matches +$noMatches = array(); // transactions without matches + +if (is_array($transactions)) { + foreach ($transactions as $trans) { + $matches = $trans->findMatches(); + if (!empty($matches)) { + $pendingMatches[] = array('transaction' => $trans, 'matches' => $matches); + } else { + $noMatches[] = $trans; + } + } +} + +// Confirm all button (if high-score matches exist) +$highScoreCount = 0; +foreach ($pendingMatches as $pm) { + if ($pm['matches'][0]['match_score'] >= 80) { + $highScoreCount++; + } +} + +if ($highScoreCount > 0) { + print ''; +} + +// Match reasons translation +$reasonLabels = array( + 'ref' => $langs->trans("MatchByRef"), + 'ref_client' => $langs->trans("MatchByClientRef"), + 'ref_supplier' => $langs->trans("MatchBySupplierRef"), + 'amount' => $langs->trans("MatchByAmount"), + 'amount_close' => $langs->trans("MatchByAmountClose"), + 'name_exact' => $langs->trans("MatchByNameExact"), + 'name_similar' => $langs->trans("MatchByNameSimilar"), + 'iban' => $langs->trans("MatchByIBAN"), + 'multi_invoice' => $langs->trans("MatchByMultiInvoice") +); + +if (!empty($pendingMatches)) { + print '
'; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + + foreach ($pendingMatches as $pm) { + $trans = $pm['transaction']; + $bestMatch = $pm['matches'][0]; + $isMultiInvoice = ($bestMatch['type'] == 'multi_facture_fourn'); + + // Score color + $scoreColor = $bestMatch['match_score'] >= 80 ? '#4caf50' : ($bestMatch['match_score'] >= 60 ? '#ff9800' : '#9e9e9e'); + + print ''; + + // Transaction date + print ''; + + // Counterparty name + description + print ''; + + // Transaction amount + print ''; + + // Arrow + print ''; + + // Invoice reference(s) + print ''; + + // Third party + print ''; + + // Invoice amount + print ''; + + // Score + print ''; + + // Match reasons + print ''; + + // Actions + print ''; + + print ''; + } + + print '
'.$langs->trans("Date").''.$langs->trans("Counterparty").''.$langs->trans("Amount").' ('.$langs->trans("Transaction").')'.$langs->trans("Invoice").''.$langs->trans("ThirdParty").''.$langs->trans("Amount").' ('.$langs->trans("Invoice").')'.$langs->trans("Score").''.$langs->trans("MatchReason").''.$langs->trans("Action").'
'.dol_print_date($trans->date_trans, 'day').''; + print ''.dol_escape_htmltag(dol_trunc($trans->name, 30)).''; + if ($trans->description) { + print '
'.dol_escape_htmltag(dol_trunc($trans->description, 50)).''; + } + print '
'; + if ($trans->amount >= 0) { + print '+'.price($trans->amount, 0, $langs, 1, -1, 2, $trans->currency).''; + } else { + print ''.price($trans->amount, 0, $langs, 1, -1, 2, $trans->currency).''; + } + print ''; + if ($isMultiInvoice && !empty($bestMatch['invoices'])) { + // Multi-invoice display + require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.facture.class.php'; + print ''.count($bestMatch['invoices']).' '.$langs->trans("Invoices").':
'; + foreach ($bestMatch['invoices'] as $invData) { + $inv = new FactureFournisseur($db); + $inv->fetch($invData['id']); + print $inv->getNomUrl(1).' ('.price($invData['amount'], 0, $langs, 1, -1, 2, 'EUR').')
'; + } + } elseif ($bestMatch['type'] == 'facture') { + require_once DOL_DOCUMENT_ROOT.'/compta/facture/class/facture.class.php'; + $inv = new Facture($db); + $inv->fetch($bestMatch['id']); + print $inv->getNomUrl(1); + } else { + require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.facture.class.php'; + $inv = new FactureFournisseur($db); + $inv->fetch($bestMatch['id']); + print $inv->getNomUrl(1); + } + print '
'; + if ($bestMatch['socid'] > 0) { + require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php'; + $soc = new Societe($db); + $soc->fetch($bestMatch['socid']); + print $soc->getNomUrl(1); + } else { + print dol_escape_htmltag($bestMatch['socname']); + } + print ''; + print price($bestMatch['amount'], 0, $langs, 1, -1, 2, 'EUR'); + if ($isMultiInvoice && isset($bestMatch['difference']) && abs($bestMatch['difference']) > 0.01) { + $diffColor = $bestMatch['difference'] > 0 ? 'orange' : 'green'; + print '
'.($bestMatch['difference'] > 0 ? '+' : '').price($bestMatch['difference'], 0, $langs, 1, -1, 2, 'EUR').''; + } + print '
'.$bestMatch['match_score'].'%'; + if (!empty($bestMatch['match_reasons'])) { + foreach ($bestMatch['match_reasons'] as $reason) { + $label = $reasonLabels[$reason] ?? $reason; + print ''.$label.' '; + } + } + print ''; + + if ($isMultiInvoice && !empty($bestMatch['invoices'])) { + // Multi-invoice: Form with checkboxes for selection + print '
'; + print ''; + print ''; + print ''; + + print '
'; + foreach ($bestMatch['invoices'] as $invData) { + print ''; + } + print '
'; + + print ''; + print '
'; + } else { + // Single invoice: Confirm payment button + print 'id.'&matchtype='.urlencode($bestMatch['type']).'&matchid='.$bestMatch['id'].'&token='.newToken().'">'; + print $langs->trans("ConfirmPayment"); + print ''; + } + + print '
'; + // Ignore button + print 'id.'&token='.newToken().'">'; + print $langs->trans("SetAsIgnored"); + print ''; + + // Show alternatives if multiple matches + if (count($pm['matches']) > 1) { + print '
'; + print '+'.($count = count($pm['matches']) - 1).' '.$langs->trans("Alternatives"); + print ''; + } + print '
'; + print '
'; +} else { + print '
'; + print $langs->trans("NoNewMatchesFound"); + print '
'; +} + +// Show unmatched transactions count +if (!empty($noMatches)) { + print '
'; + print '
'; + print img_picto('', 'info', 'class="pictofixedwidth"'); + print $langs->trans("UnmatchedTransactions", count($noMatches)); + print ' '.$langs->trans("ShowAll").''; + print '
'; +} + +llxFooter(); +$db->close(); diff --git a/core/boxes/box_bankimport_pending.php b/core/boxes/box_bankimport_pending.php new file mode 100755 index 0000000..3152eb4 --- /dev/null +++ b/core/boxes/box_bankimport_pending.php @@ -0,0 +1,167 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +include_once DOL_DOCUMENT_ROOT.'/core/boxes/modules_boxes.php'; + +/** + * Dashboard widget showing pending bank transaction matches + */ +class box_bankimport_pending extends ModeleBoxes +{ + public $boxcode = "bankimport_pending"; + public $boximg = "fa-money-check-alt"; + public $boxlabel = "BoxBankImportPending"; + public $depends = array("bankimport"); + + /** + * Constructor + * + * @param DoliDB $db Database handler + * @param string $param More parameters + */ + public function __construct($db, $param = '') + { + global $user; + $this->db = $db; + $this->hidden = !$user->hasRight('bankimport', 'read'); + } + + /** + * Load data into info_box_contents array to show on dashboard + * + * @param int $max Maximum number of records to load + * @return void + */ + public function loadBox($max = 5) + { + global $user, $langs, $conf; + + $langs->loadLangs(array("bankimport@bankimport", "banks")); + + $this->max = $max; + + // Box header + $this->info_box_head = array( + 'text' => $langs->trans("BoxBankImportPending"), + 'sublink' => dol_buildpath('/bankimport/confirm.php', 1).'?mainmenu=bank&leftmenu=bankimport', + 'subtext' => $langs->trans("ReviewAndConfirm"), + 'subpicto' => 'payment', + ); + + $line = 0; + $bankAccountId = getDolGlobalInt('BANKIMPORT_BANK_ACCOUNT_ID'); + + if (empty($bankAccountId)) { + // No bank account configured + $this->info_box_contents[$line][] = array( + 'td' => 'class="center" colspan="4"', + 'text' => img_warning().' '.$langs->trans("NoBankAccountConfigured"), + 'url' => dol_buildpath('/bankimport/admin/setup.php', 1), + ); + return; + } + + // Count new (unmatched) transactions + $sql = "SELECT COUNT(*) as cnt FROM ".MAIN_DB_PREFIX."bankimport_transaction"; + $sql .= " WHERE entity IN (".getEntity('banktransaction').")"; + $sql .= " AND status = 0"; + $resql = $this->db->query($sql); + $newCount = 0; + if ($resql) { + $obj = $this->db->fetch_object($resql); + $newCount = (int) $obj->cnt; + } + + if ($newCount > 0) { + // Summary line: X transactions pending + $this->info_box_contents[$line][] = array( + 'td' => 'class="left" colspan="3"', + 'text' => ''.$langs->trans("PendingPaymentMatches", $newCount).'', + 'asis' => 1, + ); + $this->info_box_contents[$line][] = array( + 'td' => 'class="right"', + 'text' => ''.$langs->trans("ReviewAndConfirm").'', + 'asis' => 1, + ); + $line++; + + // Show last few unmatched transactions + $sql2 = "SELECT t.rowid, t.date_trans, t.name, t.amount, t.currency"; + $sql2 .= " FROM ".MAIN_DB_PREFIX."bankimport_transaction as t"; + $sql2 .= " WHERE t.entity IN (".getEntity('banktransaction').")"; + $sql2 .= " AND t.status = 0 AND t.amount > 0"; + $sql2 .= " ORDER BY t.date_trans DESC"; + $sql2 .= $this->db->plimit($max, 0); + + $resql2 = $this->db->query($sql2); + if ($resql2) { + $num = $this->db->num_rows($resql2); + $i = 0; + while ($i < $num && $line < $max + 1) { + $obj2 = $this->db->fetch_object($resql2); + if (!$obj2) { + break; + } + + // Date + $this->info_box_contents[$line][] = array( + 'td' => 'class="nowraponall"', + 'text' => dol_print_date($this->db->jdate($obj2->date_trans), 'day'), + ); + + // Name + $this->info_box_contents[$line][] = array( + 'td' => 'class="tdoverflowmax150"', + 'text' => dol_trunc($obj2->name, 28), + 'url' => dol_buildpath('/bankimport/card.php', 1).'?id='.$obj2->rowid.'&mainmenu=bank&leftmenu=bankimport', + ); + + // Amount + $amountColor = $obj2->amount >= 0 ? 'color: green;' : 'color: red;'; + $amountPrefix = $obj2->amount >= 0 ? '+' : ''; + $this->info_box_contents[$line][] = array( + 'td' => 'class="right nowraponall"', + 'text' => ''.$amountPrefix.price($obj2->amount, 0, $langs, 1, -1, 2, $obj2->currency).'', + 'asis' => 1, + ); + + // Status badge + $this->info_box_contents[$line][] = array( + 'td' => 'class="right"', + 'text' => ''.$langs->trans("New").'', + 'asis' => 1, + ); + + $line++; + $i++; + } + } + } else { + // No pending transactions + $this->info_box_contents[$line][] = array( + 'td' => 'class="center opacitymedium" colspan="4"', + 'text' => $langs->trans("NoNewMatchesFound"), + ); + } + } + + /** + * Render the box + * + * @param array|null $head Optional head array + * @param array|null $contents Optional contents array + * @param int $nooutput No print, return output + * @return string + */ + public function showBox($head = null, $contents = null, $nooutput = 0) + { + return parent::showBox($this->info_box_head, $this->info_box_contents, $nooutput); + } +} diff --git a/core/modules/modBankImport.class.php b/core/modules/modBankImport.class.php new file mode 100755 index 0000000..14d647e --- /dev/null +++ b/core/modules/modBankImport.class.php @@ -0,0 +1,552 @@ + + * Copyright (C) 2018-2019 Nicolas ZABOURI + * Copyright (C) 2019-2024 Frédéric France + * Copyright (C) 2026 Eduard Wisch + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/** + * \defgroup bankimport Module BankImport + * \brief BankImport module descriptor. + * + * \file htdocs/bankimport/core/modules/modBankImport.class.php + * \ingroup bankimport + * \brief Description and activation file for module BankImport + */ +include_once DOL_DOCUMENT_ROOT.'/core/modules/DolibarrModules.class.php'; + + +/** + * Description and activation class for module BankImport + */ +class modBankImport extends DolibarrModules +{ + /** + * Constructor. Define names, constants, directories, boxes, permissions + * + * @param DoliDB $db Database handler + */ + public function __construct($db) + { + global $conf, $langs; + + $this->db = $db; + + // Id for module (must be unique). + // Use here a free id (See in Home -> System information -> Dolibarr for list of used modules id). + $this->numero = 500021; // TODO Go on page https://wiki.dolibarr.org/index.php/List_of_modules_id to reserve an id number for your module + + // Key text used to identify module (for permissions, menus, etc...) + $this->rights_class = 'bankimport'; + + // Family can be 'base' (core modules),'crm','financial','hr','projects','products','ecm','technic' (transverse modules),'interface' (link with external tools),'other','...' + // It is used to group modules by family in module setup page + $this->family = "other"; + + // Module position in the family on 2 digits ('01', '10', '20', ...) + $this->module_position = '90'; + + // Gives the possibility for the module, to provide his own family info and position of this family (Overwrite $this->family and $this->module_position. Avoid this) + //$this->familyinfo = array('myownfamily' => array('position' => '01', 'label' => $langs->trans("MyOwnFamily"))); + // Module label (no space allowed), used if translation string 'ModuleBankImportName' not found (BankImport is name of module). + $this->name = preg_replace('/^mod/i', '', get_class($this)); + + // DESCRIPTION_FLAG + // Module description, used if translation string 'ModuleBankImportDesc' not found (BankImport is name of module). + $this->description = "BankImportDescription"; + // Used only if file README.md and README-LL.md not found. + $this->descriptionlong = "BankImportDescription"; + + // Author + $this->editor_name = 'Alles Watt laeuft'; + $this->editor_url = ''; // Must be an external online web site + $this->editor_squarred_logo = ''; // Must be image filename into the module/img directory followed with @modulename. Example: 'myimage.png@bankimport' + + // Possible values for version are: 'development', 'experimental', 'dolibarr', 'dolibarr_deprecated', 'experimental_deprecated' or a version string like 'x.y.z' + $this->version = '1.7'; + // Url to the file with your last numberversion of this module + //$this->url_last_version = 'http://www.example.com/versionmodule.txt'; + + // Key used in llx_const table to save module status enabled/disabled (where BANKIMPORT is value of property name of module in uppercase) + $this->const_name = 'MAIN_MODULE_'.strtoupper($this->name); + + // Name of image file used for this module. + // If file is in theme/yourtheme/img directory under name object_pictovalue.png, use this->picto='pictovalue' + // If file is in module/img directory under name object_pictovalue.png, use this->picto='pictovalue@module' + // To use a supported fa-xxx css style of font awesome, use this->picto='xxx' + $this->picto = 'fa-money-check-alt'; + + // Define some features supported by module (triggers, login, substitutions, menus, css, etc...) + $this->module_parts = array( + // Set this to 1 if module has its own trigger directory (core/triggers) + 'triggers' => 0, + // Set this to 1 if module has its own login method file (core/login) + 'login' => 0, + // Set this to 1 if module has its own substitution function file (core/substitutions) + 'substitutions' => 0, + // Set this to 1 if module has its own menus handler directory (core/menus) + 'menus' => 0, + // Set this to 1 if module overwrite template dir (core/tpl) + 'tpl' => 0, + // Set this to 1 if module has its own barcode directory (core/modules/barcode) + 'barcode' => 0, + // Set this to 1 if module has its own models directory (core/modules/xxx) + 'models' => 0, + // Set this to 1 if module has its own printing directory (core/modules/printing) + 'printing' => 0, + // Set this to 1 if module has its own theme directory (theme) + 'theme' => 0, + // Set this to relative path of css file if module has its own css file + 'css' => array( + // '/bankimport/css/bankimport.css.php', + ), + // Set this to relative path of js file if module must load a js on all pages + 'js' => array( + '/bankimport/js/bankimport_notify.js.php', + ), + // Set here all hooks context managed by module. To find available hook context, make a "grep -r '>initHooks(' *" on source code. You can also set hook context to 'all' + /* BEGIN MODULEBUILDER HOOKSCONTEXTS */ + 'hooks' => array( + // 'data' => array( + // 'hookcontext1', + // 'hookcontext2', + // ), + // 'entity' => '0', + ), + /* END MODULEBUILDER HOOKSCONTEXTS */ + // Set this to 1 if features of module are opened to external users + 'moduleforexternal' => 0, + // Set this to 1 if the module provides a website template into doctemplates/websites/website_template-mytemplate + 'websitetemplates' => 0, + // Set this to 1 if the module provides a captcha driver + 'captcha' => 0 + ); + + // Data directories to create when module is enabled. + // Example: this->dirs = array("/bankimport/temp","/bankimport/subdir"); + $this->dirs = array("/bankimport/temp"); + + // Config pages. Put here list of php page, stored into bankimport/admin directory, to use to setup module. + $this->config_page_url = array("setup.php@bankimport"); + + // Dependencies + // A condition to hide module + $this->hidden = getDolGlobalInt('MODULE_BANKIMPORT_DISABLED'); // A condition to disable module; + // List of module class names that must be enabled if this module is enabled. Example: array('always'=>array('modModuleToEnable1','modModuleToEnable2'), 'FR'=>array('modModuleToEnableFR')...) + $this->depends = array(); + // List of module class names to disable if this one is disabled. Example: array('modModuleToDisable1', ...) + $this->requiredby = array(); + // List of module class names this module is in conflict with. Example: array('modModuleToDisable1', ...) + $this->conflictwith = array(); + + // The language file dedicated to your module + $this->langfiles = array("bankimport@bankimport"); + + // Prerequisites + $this->phpmin = array(7, 1); // Minimum version of PHP required by module + // $this->phpmax = array(8, 0); // Maximum version of PHP required by module + $this->need_dolibarr_version = array(19, -3); // Minimum version of Dolibarr required by module + // $this->max_dolibarr_version = array(19, -3); // Maximum version of Dolibarr required by module + $this->need_javascript_ajax = 0; + + // Messages at activation + $this->warnings_activation = array(); // Warning to show when we activate module. array('always'='text') or array('FR'='textfr','MX'='textmx'...) + $this->warnings_activation_ext = array(); // Warning to show when we activate an external module. array('always'='text') or array('FR'='textfr','MX'='textmx'...) + //$this->automatic_activation = array('FR'=>'BankImportWasAutomaticallyActivatedBecauseOfYourCountryChoice'); + //$this->always_enabled = true; // If true, can't be disabled + + // Constants + // List of particular constants to add when module is enabled (key, 'chaine', value, desc, visible, 'current' or 'allentities', deleteonunactive) + // Example: $this->const=array(1 => array('BANKIMPORT_MYNEWCONST1', 'chaine', 'myvalue', 'This is a constant to add', 1), + // 2 => array('BANKIMPORT_MYNEWCONST2', 'chaine', 'myvalue', 'This is another constant to add', 0, 'current', 1) + // ); + $this->const = array(); + + // Some keys to add into the overwriting translation tables + /*$this->overwrite_translation = array( + 'en_US:ParentCompany'=>'Parent company or reseller', + 'fr_FR:ParentCompany'=>'Maison mère ou revendeur' + )*/ + + if (!isModEnabled("bankimport")) { + $conf->bankimport = new stdClass(); + $conf->bankimport->enabled = 0; + } + + // Array to add new pages in new tabs + /* BEGIN MODULEBUILDER TABS */ + $this->tabs = array(); + /* END MODULEBUILDER TABS */ + // Example: + // To add a new tab identified by code tabname1 + // $this->tabs[] = array('data' => 'objecttype:+tabname1:Title1:mylangfile@bankimport:$user->hasRight(\'bankimport\', \'read\'):/bankimport/mynewtab1.php?id=__ID__'); + // To add another new tab identified by code tabname2. Label will be result of calling all substitution functions on 'Title2' key. + // $this->tabs[] = array('data' => 'objecttype:+tabname2:SUBSTITUTION_Title2:mylangfile@bankimport:$user->hasRight(\'othermodule\', \'read\'):/bankimport/mynewtab2.php?id=__ID__', + // To remove an existing tab identified by code tabname + // $this->tabs[] = array('data' => 'objecttype:-tabname:NU:conditiontoremove'); + // + // Where objecttype can be + // 'categories_x' to add a tab in category view (replace 'x' by type of category (0=product, 1=supplier, 2=customer, 3=member) + // 'contact' to add a tab in contact view + // 'contract' to add a tab in contract view + // 'delivery' to add a tab in delivery view + // 'group' to add a tab in group view + // 'intervention' to add a tab in intervention view + // 'invoice' to add a tab in customer invoice view + // 'supplier_invoice' to add a tab in supplier invoice view + // 'member' to add a tab in foundation member view + // 'opensurveypoll' to add a tab in opensurvey poll view + // 'order' to add a tab in sale order view + // 'supplier_order' to add a tab in supplier order view + // 'payment' to add a tab in payment view + // 'supplier_payment' to add a tab in supplier payment view + // 'product' to add a tab in product view + // 'propal' to add a tab in propal view + // 'project' to add a tab in project view + // 'stock' to add a tab in stock view + // 'thirdparty' to add a tab in third party view + // 'user' to add a tab in user view + + + // Dictionaries + /* Example: + $this->dictionaries=array( + 'langs' => 'bankimport@bankimport', + // List of tables we want to see into dictionary editor + 'tabname' => array("table1", "table2", "table3"), + // Label of tables + 'tablib' => array("Table1", "Table2", "Table3"), + // Request to select fields + 'tabsql' => array('SELECT f.rowid as rowid, f.code, f.label, f.active FROM '.$this->db->prefix().'table1 as f', 'SELECT f.rowid as rowid, f.code, f.label, f.active FROM '.$this->db->prefix().'table2 as f', 'SELECT f.rowid as rowid, f.code, f.label, f.active FROM '.$this->db->prefix().'table3 as f'), + // Sort order + 'tabsqlsort' => array("label ASC", "label ASC", "label ASC"), + // List of fields (result of select to show dictionary) + 'tabfield' => array("code,label", "code,label", "code,label"), + // List of fields (list of fields to edit a record) + 'tabfieldvalue' => array("code,label", "code,label", "code,label"), + // List of fields (list of fields for insert) + 'tabfieldinsert' => array("code,label", "code,label", "code,label"), + // Name of columns with primary key (try to always name it 'rowid') + 'tabrowid' => array("rowid", "rowid", "rowid"), + // Condition to show each dictionary + 'tabcond' => array(isModEnabled('bankimport'), isModEnabled('bankimport'), isModEnabled('bankimport')), + // Tooltip for every fields of dictionaries: DO NOT PUT AN EMPTY ARRAY + 'tabhelp' => array(array('code' => $langs->trans('CodeTooltipHelp'), 'field2' => 'field2tooltip'), array('code' => $langs->trans('CodeTooltipHelp'), 'field2' => 'field2tooltip'), ...), + ); + */ + /* BEGIN MODULEBUILDER DICTIONARIES */ + $this->dictionaries = array(); + /* END MODULEBUILDER DICTIONARIES */ + + // Boxes/Widgets + // Add here list of php file(s) stored in bankimport/core/boxes that contains a class to show a widget. + /* BEGIN MODULEBUILDER WIDGETS */ + $this->boxes = array( + 0 => array( + 'file' => 'box_bankimport_pending.php@bankimport', + 'note' => 'Pending bank transaction matches', + 'enabledbydefaulton' => 'Home', + ), + ); + /* END MODULEBUILDER WIDGETS */ + + // Cronjobs (List of cron jobs entries to add when module is enabled) + // unit_frequency must be 60 for minute, 3600 for hour, 86400 for day, 604800 for week + /* BEGIN MODULEBUILDER CRON */ + $this->cronjobs = array( + 0 => array( + 'label' => 'BankImportAutoFetch', + 'jobtype' => 'method', + 'class' => '/bankimport/class/bankimportcron.class.php', + 'objectname' => 'BankImportCron', + 'method' => 'doAutoImport', + 'parameters' => '', + 'comment' => 'Automatic bank statement import via FinTS', + 'frequency' => 1, + 'unitfrequency' => 86400, // Daily + 'status' => 0, // Disabled by default + 'test' => 'isModEnabled("bankimport") && getDolGlobalInt("BANKIMPORT_AUTO_ENABLED")', + 'priority' => 50, + ), + ); + /* END MODULEBUILDER CRON */ + // Example: $this->cronjobs=array( + // 0=>array('label'=>'My label', 'jobtype'=>'method', 'class'=>'/dir/class/file.class.php', 'objectname'=>'MyClass', 'method'=>'myMethod', 'parameters'=>'param1, param2', 'comment'=>'Comment', 'frequency'=>2, 'unitfrequency'=>3600, 'status'=>0, 'test'=>'isModEnabled("bankimport")', 'priority'=>50), + // 1=>array('label'=>'My label', 'jobtype'=>'command', 'command'=>'', 'parameters'=>'param1, param2', 'comment'=>'Comment', 'frequency'=>1, 'unitfrequency'=>3600*24, 'status'=>0, 'test'=>'isModEnabled("bankimport")', 'priority'=>50) + // ); + + // Permissions provided by this module + $this->rights = array(); + $r = 0; + + // $user->hasRight('bankimport', 'read') + $this->rights[$r][0] = $this->numero . '01'; + $this->rights[$r][1] = 'PermBankImportRead'; + $this->rights[$r][2] = 'r'; + $this->rights[$r][3] = 1; // Default enabled + $this->rights[$r][4] = 'read'; + $r++; + + // $user->hasRight('bankimport', 'write') + $this->rights[$r][0] = $this->numero . '02'; + $this->rights[$r][1] = 'PermBankImportWrite'; + $this->rights[$r][2] = 'w'; + $this->rights[$r][3] = 0; + $this->rights[$r][4] = 'write'; + $r++; + + // $user->hasRight('bankimport', 'delete') + $this->rights[$r][0] = $this->numero . '03'; + $this->rights[$r][1] = 'PermBankImportDelete'; + $this->rights[$r][2] = 'd'; + $this->rights[$r][3] = 0; + $this->rights[$r][4] = 'delete'; + $r++; + + + // Main menu entries to add + $this->menu = array(); + $r = 0; + + // Left menu entries under "Banken und Kasse" (mainmenu=bank) + $r = 0; + $this->menu[$r++] = array( + 'fk_menu' => 'fk_mainmenu=bank', + 'type' => 'left', + 'titre' => 'BankImportMenu', + 'prefix' => img_picto('', 'download', 'class="pictofixedwidth valignmiddle paddingright"'), + 'mainmenu' => 'bank', + 'leftmenu' => 'bankimport', + 'url' => '/bankimport/bankimportindex.php?mainmenu=bank&leftmenu=bankimport', + 'langs' => 'bankimport@bankimport', + 'position' => 200, + 'enabled' => 'isModEnabled("bankimport")', + 'perms' => '$user->hasRight("bankimport", "read")', + 'target' => '', + 'user' => 2, + ); + $this->menu[$r++] = array( + 'fk_menu' => 'fk_mainmenu=bank,fk_leftmenu=bankimport', + 'type' => 'left', + 'titre' => 'BankStatements', + 'prefix' => img_picto('', 'bank_account', 'class="pictofixedwidth valignmiddle paddingright"'), + 'mainmenu' => 'bank', + 'leftmenu' => 'bankimport_statements', + 'url' => '/bankimport/statements.php?mainmenu=bank&leftmenu=bankimport', + 'langs' => 'bankimport@bankimport', + 'position' => 201, + 'enabled' => 'isModEnabled("bankimport")', + 'perms' => '$user->hasRight("bankimport", "write")', + 'target' => '', + 'user' => 2, + ); + $this->menu[$r++] = array( + 'fk_menu' => 'fk_mainmenu=bank,fk_leftmenu=bankimport', + 'type' => 'left', + 'titre' => 'TransactionList', + 'prefix' => img_picto('', 'list', 'class="pictofixedwidth valignmiddle paddingright"'), + 'mainmenu' => 'bank', + 'leftmenu' => 'bankimport_transactions', + 'url' => '/bankimport/list.php?mainmenu=bank&leftmenu=bankimport', + 'langs' => 'bankimport@bankimport', + 'position' => 202, + 'enabled' => 'isModEnabled("bankimport")', + 'perms' => '$user->hasRight("bankimport", "read")', + 'target' => '', + 'user' => 2, + ); + $this->menu[$r++] = array( + 'fk_menu' => 'fk_mainmenu=bank,fk_leftmenu=bankimport', + 'type' => 'left', + 'titre' => 'PaymentConfirmation', + 'prefix' => img_picto('', 'payment', 'class="pictofixedwidth valignmiddle paddingright"'), + 'mainmenu' => 'bank', + 'leftmenu' => 'bankimport_confirm', + 'url' => '/bankimport/confirm.php?mainmenu=bank&leftmenu=bankimport', + 'langs' => 'bankimport@bankimport', + 'position' => 203, + 'enabled' => 'isModEnabled("bankimport")', + 'perms' => '$user->hasRight("bankimport", "write")', + 'target' => '', + 'user' => 2, + ); + $this->menu[$r++] = array( + 'fk_menu' => 'fk_mainmenu=bank,fk_leftmenu=bankimport', + 'type' => 'left', + 'titre' => 'PDFStatements', + 'prefix' => img_picto('', 'pdf', 'class="pictofixedwidth valignmiddle paddingright"'), + 'mainmenu' => 'bank', + 'leftmenu' => 'bankimport_pdfstatements', + 'url' => '/bankimport/pdfstatements.php?mainmenu=bank&leftmenu=bankimport', + 'langs' => 'bankimport@bankimport', + 'position' => 204, + 'enabled' => 'isModEnabled("bankimport")', + 'perms' => '$user->hasRight("bankimport", "read")', + 'target' => '', + 'user' => 2, + ); + + + // Exports profiles provided by this module + $r = 0; + /* BEGIN MODULEBUILDER EXPORT MYOBJECT */ + /* + $langs->load("bankimport@bankimport"); + $this->export_code[$r] = $this->rights_class.'_'.$r; + $this->export_label[$r] = 'MyObjectLines'; // Translation key (used only if key ExportDataset_xxx_z not found) + $this->export_icon[$r] = $this->picto; + // Define $this->export_fields_array, $this->export_TypeFields_array and $this->export_entities_array + $keyforclass = 'MyObject'; $keyforclassfile='/bankimport/class/myobject.class.php'; $keyforelement='myobject@bankimport'; + include DOL_DOCUMENT_ROOT.'/core/commonfieldsinexport.inc.php'; + //$this->export_fields_array[$r]['t.fieldtoadd']='FieldToAdd'; $this->export_TypeFields_array[$r]['t.fieldtoadd']='Text'; + //unset($this->export_fields_array[$r]['t.fieldtoremove']); + //$keyforclass = 'MyObjectLine'; $keyforclassfile='/bankimport/class/myobject.class.php'; $keyforelement='myobjectline@bankimport'; $keyforalias='tl'; + //include DOL_DOCUMENT_ROOT.'/core/commonfieldsinexport.inc.php'; + $keyforselect='myobject'; $keyforaliasextra='extra'; $keyforelement='myobject@bankimport'; + include DOL_DOCUMENT_ROOT.'/core/extrafieldsinexport.inc.php'; + //$keyforselect='myobjectline'; $keyforaliasextra='extraline'; $keyforelement='myobjectline@bankimport'; + //include DOL_DOCUMENT_ROOT.'/core/extrafieldsinexport.inc.php'; + //$this->export_dependencies_array[$r] = array('myobjectline' => array('tl.rowid','tl.ref')); // To force to activate one or several fields if we select some fields that need same (like to select a unique key if we ask a field of a child to avoid the DISTINCT to discard them, or for computed field than need several other fields) + //$this->export_special_array[$r] = array('t.field' => '...'); + //$this->export_examplevalues_array[$r] = array('t.field' => 'Example'); + //$this->export_help_array[$r] = array('t.field' => 'FieldDescHelp'); + $this->export_sql_start[$r]='SELECT DISTINCT '; + $this->export_sql_end[$r] =' FROM '.$this->db->prefix().'bankimport_myobject as t'; + //$this->export_sql_end[$r] .=' LEFT JOIN '.$this->db->prefix().'bankimport_myobject_line as tl ON tl.fk_myobject = t.rowid'; + $this->export_sql_end[$r] .=' WHERE 1 = 1'; + $this->export_sql_end[$r] .=' AND t.entity IN ('.getEntity('myobject').')'; + $r++; */ + /* END MODULEBUILDER EXPORT MYOBJECT */ + + // Imports profiles provided by this module + $r = 0; + /* BEGIN MODULEBUILDER IMPORT MYOBJECT */ + /* + $langs->load("bankimport@bankimport"); + $this->import_code[$r] = $this->rights_class.'_'.$r; + $this->import_label[$r] = 'MyObjectLines'; // Translation key (used only if key ExportDataset_xxx_z not found) + $this->import_icon[$r] = $this->picto; + $this->import_tables_array[$r] = array('t' => $this->db->prefix().'bankimport_myobject', 'extra' => $this->db->prefix().'bankimport_myobject_extrafields'); + $this->import_tables_creator_array[$r] = array('t' => 'fk_user_author'); // Fields to store import user id + $import_sample = array(); + $keyforclass = 'MyObject'; $keyforclassfile='/bankimport/class/myobject.class.php'; $keyforelement='myobject@bankimport'; + include DOL_DOCUMENT_ROOT.'/core/commonfieldsinimport.inc.php'; + $import_extrafield_sample = array(); + $keyforselect='myobject'; $keyforaliasextra='extra'; $keyforelement='myobject@bankimport'; + include DOL_DOCUMENT_ROOT.'/core/extrafieldsinimport.inc.php'; + $this->import_fieldshidden_array[$r] = array('extra.fk_object' => 'lastrowid-'.$this->db->prefix().'bankimport_myobject'); + $this->import_regex_array[$r] = array(); + $this->import_examplevalues_array[$r] = array_merge($import_sample, $import_extrafield_sample); + $this->import_updatekeys_array[$r] = array('t.ref' => 'Ref'); + $this->import_convertvalue_array[$r] = array( + 't.ref' => array( + 'rule'=>'getrefifauto', + 'class'=>(!getDolGlobalString('BANKIMPORT_MYOBJECT_ADDON') ? 'mod_myobject_standard' : getDolGlobalString('BANKIMPORT_MYOBJECT_ADDON')), + 'path'=>"/core/modules/bankimport/".(!getDolGlobalString('BANKIMPORT_MYOBJECT_ADDON') ? 'mod_myobject_standard' : getDolGlobalString('BANKIMPORT_MYOBJECT_ADDON')).'.php', + 'classobject'=>'MyObject', + 'pathobject'=>'/bankimport/class/myobject.class.php', + ), + 't.fk_soc' => array('rule' => 'fetchidfromref', 'file' => '/societe/class/societe.class.php', 'class' => 'Societe', 'method' => 'fetch', 'element' => 'ThirdParty'), + 't.fk_user_valid' => array('rule' => 'fetchidfromref', 'file' => '/user/class/user.class.php', 'class' => 'User', 'method' => 'fetch', 'element' => 'user'), + 't.fk_mode_reglement' => array('rule' => 'fetchidfromcodeorlabel', 'file' => '/compta/paiement/class/cpaiement.class.php', 'class' => 'Cpaiement', 'method' => 'fetch', 'element' => 'cpayment'), + ); + $this->import_run_sql_after_array[$r] = array(); + $r++; */ + /* END MODULEBUILDER IMPORT MYOBJECT */ + } + + /** + * Function called when module is enabled. + * The init function add constants, boxes, permissions and menus (defined in constructor) into Dolibarr database. + * It also creates data directories + * + * @param string $options Options when enabling module ('', 'noboxes') + * @return int<-1,1> 1 if OK, <=0 if KO + */ + public function init($options = '') + { + global $conf, $langs; + + // Create tables of module at module activation + //$result = $this->_load_tables('/install/mysql/', 'bankimport'); + $result = $this->_load_tables('/bankimport/sql/'); + if ($result < 0) { + return -1; // Do not activate module if error 'not allowed' returned when loading module SQL queries (the _load_table run sql with run_sql with the error allowed parameter set to 'default') + } + + // Create extrafields during init + //include_once DOL_DOCUMENT_ROOT.'/core/class/extrafields.class.php'; + //$extrafields = new ExtraFields($this->db); + //$result0=$extrafields->addExtraField('bankimport_separator1', "Separator 1", 'separator', 1, 0, 'thirdparty', 0, 0, '', array('options'=>array(1=>1)), 1, '', 1, 0, '', '', 'bankimport@bankimport', 'isModEnabled("bankimport")'); + //$result1=$extrafields->addExtraField('bankimport_myattr1', "New Attr 1 label", 'boolean', 1, 3, 'thirdparty', 0, 0, '', '', 1, '', -1, 0, '', '', 'bankimport@bankimport', 'isModEnabled("bankimport")'); + //$result2=$extrafields->addExtraField('bankimport_myattr2', "New Attr 2 label", 'varchar', 1, 10, 'project', 0, 0, '', '', 1, '', -1, 0, '', '', 'bankimport@bankimport', 'isModEnabled("bankimport")'); + //$result3=$extrafields->addExtraField('bankimport_myattr3', "New Attr 3 label", 'varchar', 1, 10, 'bank_account', 0, 0, '', '', 1, '', -1, 0, '', '', 'bankimport@bankimport', 'isModEnabled("bankimport")'); + //$result4=$extrafields->addExtraField('bankimport_myattr4', "New Attr 4 label", 'select', 1, 3, 'thirdparty', 0, 1, '', array('options'=>array('code1'=>'Val1','code2'=>'Val2','code3'=>'Val3')), 1,'', -1, 0, '', '', 'bankimport@bankimport', 'isModEnabled("bankimport")'); + //$result5=$extrafields->addExtraField('bankimport_myattr5', "New Attr 5 label", 'text', 1, 10, 'user', 0, 0, '', '', 1, '', -1, 0, '', '', 'bankimport@bankimport', 'isModEnabled("bankimport")'); + + // Permissions + $this->remove($options); + + $sql = array(); + + // Document templates + $moduledir = dol_sanitizeFileName('bankimport'); + $myTmpObjects = array(); + $myTmpObjects['MyObject'] = array('includerefgeneration' => 0, 'includedocgeneration' => 0); + + foreach ($myTmpObjects as $myTmpObjectKey => $myTmpObjectArray) { + if ($myTmpObjectArray['includerefgeneration']) { + $src = DOL_DOCUMENT_ROOT.'/install/doctemplates/'.$moduledir.'/template_myobjects.odt'; + $dirodt = DOL_DATA_ROOT.($conf->entity > 1 ? '/'.$conf->entity : '').'/doctemplates/'.$moduledir; + $dest = $dirodt.'/template_myobjects.odt'; + + if (file_exists($src) && !file_exists($dest)) { + require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php'; + dol_mkdir($dirodt); + $result = dol_copy($src, $dest, '0', 0); + if ($result < 0) { + $langs->load("errors"); + $this->error = $langs->trans('ErrorFailToCopyFile', $src, $dest); + return 0; + } + } + + $sql = array_merge($sql, array( + "DELETE FROM ".$this->db->prefix()."document_model WHERE nom = 'standard_".strtolower($myTmpObjectKey)."' AND type = '".$this->db->escape(strtolower($myTmpObjectKey))."' AND entity = ".((int) $conf->entity), + "INSERT INTO ".$this->db->prefix()."document_model (nom, type, entity) VALUES('standard_".strtolower($myTmpObjectKey)."', '".$this->db->escape(strtolower($myTmpObjectKey))."', ".((int) $conf->entity).")", + "DELETE FROM ".$this->db->prefix()."document_model WHERE nom = 'generic_".strtolower($myTmpObjectKey)."_odt' AND type = '".$this->db->escape(strtolower($myTmpObjectKey))."' AND entity = ".((int) $conf->entity), + "INSERT INTO ".$this->db->prefix()."document_model (nom, type, entity) VALUES('generic_".strtolower($myTmpObjectKey)."_odt', '".$this->db->escape(strtolower($myTmpObjectKey))."', ".((int) $conf->entity).")" + )); + } + } + + return $this->_init($sql, $options); + } + + /** + * Function called when module is disabled. + * Remove from database constants, boxes and permissions from Dolibarr database. + * Data directories are not deleted + * + * @param string $options Options when enabling module ('', 'noboxes') + * @return int<-1,1> 1 if OK, <=0 if KO + */ + public function remove($options = '') + { + $sql = array(); + return $this->_remove($sql, $options); + } +} diff --git a/img/README.md b/img/README.md new file mode 100755 index 0000000..c902c90 --- /dev/null +++ b/img/README.md @@ -0,0 +1,14 @@ + +Directory for module image files +-------------------------------- + +You can put here the .png files of your module: + + +If the picto of your module is an image (property $picto has been set to 'bankimport.png@bankimport', you can put into this +directory a .png file called *object_bankimport.png* (16x16 or 32x32 pixels) + + +If the picto of an object is an image (property $picto of the object.class.php has been set to 'myobject.png@bankimport', then you can put into this +directory a .png file called *object_myobject.png* (16x16 or 32x32 pixels) + diff --git a/js/bankimport_notify.js.php b/js/bankimport_notify.js.php new file mode 100755 index 0000000..75241a4 --- /dev/null +++ b/js/bankimport_notify.js.php @@ -0,0 +1,174 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +/** + * JavaScript for browser push notifications about incoming payments + * Loaded on every Dolibarr page via module_parts['js'] + */ + +// Define MIME type +if (!defined('NOTOKENRENEWAL')) { + define('NOTOKENRENEWAL', '1'); +} +if (!defined('NOREQUIREMENU')) { + define('NOREQUIREMENU', '1'); +} +if (!defined('NOREQUIREHTML')) { + define('NOREQUIREHTML', '1'); +} +if (!defined('NOREQUIREAJAX')) { + define('NOREQUIREAJAX', '1'); +} + +$res = 0; +if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) { + $res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php"; +} +$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME']; +$tmp2 = realpath(__FILE__); +$i = strlen($tmp) - 1; +$j = strlen($tmp2) - 1; +while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) { + $i--; + $j--; +} +if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) { + $res = @include substr($tmp, 0, ($i + 1))."/main.inc.php"; +} +if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) { + $res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php"; +} +if (!$res && file_exists("../../main.inc.php")) { + $res = @include "../../main.inc.php"; +} +if (!$res && file_exists("../../../main.inc.php")) { + $res = @include "../../../main.inc.php"; +} + +header('Content-Type: application/javascript; charset=UTF-8'); +header('Cache-Control: max-age=3600'); + +if (!isModEnabled('bankimport') || empty($user->id) || !$user->hasRight('bankimport', 'read')) { + echo '/* bankimport: no access */'; + exit; +} + +$checkUrl = dol_buildpath('/bankimport/ajax/checkpending.php', 1); +$confirmUrl = dol_buildpath('/bankimport/confirm.php', 1).'?mainmenu=bank&leftmenu=bankimport'; +$checkInterval = 5 * 60 * 1000; // 5 Minuten +?> +(function() { + 'use strict'; + + var STORAGE_KEY = 'bankimport_last_pending'; + var CHECK_URL = ; + var CONFIRM_URL = ; + var CHECK_INTERVAL = ; + + // Erst nach Seitenload starten + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } + + function init() { + // Berechtigung anfragen beim ersten Mal + if ('Notification' in window && Notification.permission === 'default') { + // Dezent um Berechtigung bitten - nicht sofort, sondern nach 10 Sekunden + setTimeout(function() { + Notification.requestPermission(); + }, 10000); + } + + // Sofort prüfen + checkPending(); + + // Regelmäßig prüfen + setInterval(checkPending, CHECK_INTERVAL); + } + + function checkPending() { + var xhr = new XMLHttpRequest(); + xhr.open('GET', CHECK_URL, true); + xhr.timeout = 15000; + + xhr.onload = function() { + if (xhr.status !== 200) return; + + try { + var data = JSON.parse(xhr.responseText); + } catch(e) { + return; + } + + var lastKnown = parseInt(localStorage.getItem(STORAGE_KEY) || '0', 10); + var currentPending = data.pending || 0; + var incoming = data.incoming || 0; + var incomingTotal = data.incoming_total || 0; + + // Neue Buchungen seit letztem Check? + if (currentPending > lastKnown && currentPending > 0) { + var newCount = currentPending - lastKnown; + + if (incoming > 0) { + showNotification( + 'Zahlungseingang', + incoming + ' Zahlungseingang' + (incoming > 1 ? 'e' : '') + ' (' + formatAmount(incomingTotal) + ' €)\nBestätigung erforderlich', + 'incoming' + ); + } else { + showNotification( + 'Bankimport', + newCount + ' neue Buchung' + (newCount > 1 ? 'en' : '') + ' warten auf Zuordnung', + 'pending' + ); + } + } + + // Aktuellen Stand merken + localStorage.setItem(STORAGE_KEY, currentPending.toString()); + }; + + xhr.onerror = function() {}; + xhr.send(); + } + + function showNotification(title, body, type) { + if (!('Notification' in window)) return; + if (Notification.permission !== 'granted') return; + + var icon = type === 'incoming' + ? '/theme/common/mime/money.png' + : '/theme/common/mime/doc.png'; + + var notification = new Notification(title, { + body: body, + icon: icon, + tag: 'bankimport-' + type, + requireInteraction: true + }); + + notification.onclick = function() { + window.focus(); + window.location.href = CONFIRM_URL; + notification.close(); + }; + + // Nach 30 Sekunden automatisch schließen + setTimeout(function() { + notification.close(); + }, 30000); + } + + function formatAmount(amount) { + return parseFloat(amount).toFixed(2).replace('.', ',').replace(/\B(?=(\d{3})+(?!\d))/g, '.'); + } + +})(); diff --git a/langs/de_DE/bankimport.lang b/langs/de_DE/bankimport.lang new file mode 100755 index 0000000..81a4d36 --- /dev/null +++ b/langs/de_DE/bankimport.lang @@ -0,0 +1,356 @@ +# Übersetzungsdatei - Deutsch + +# +# Allgemein +# + +# Modulname 'ModuleBankImportName' +ModuleBankImportName = Bankimport +# Modulbeschreibung 'ModuleBankImportDesc' +ModuleBankImportDesc = Kontoauszüge per FinTS/HBCI importieren + +# +# Admin-Seite +# +BankImportSetup = Bankimport Einstellungen +Settings = Einstellungen +BankImportSetupPage = Bankimport Einstellungen +BankImportSetupDescription = Konfigurieren Sie die FinTS/HBCI-Verbindung zu Ihrer Bank für den automatischen Kontoauszugsimport. + +# FinTS Konfiguration +FinTSConfiguration = FinTS Bankverbindung +FinTSServerURL = FinTS Server URL +FinTSServerURLHelp = Für VR-Banken/Volksbanken: https://fints1.atruvia.de/cgi-bin/hbciservlet +BLZ = Bankleitzahl (BLZ) +BLZHelp = 8-stellige Bankleitzahl +FinTSUsername = Benutzerkennung +FinTSPIN = PIN +PINAlreadySet = PIN ist konfiguriert +PINHelp = Die PIN wird verschlüsselt gespeichert. Leer lassen um bestehende PIN zu behalten. +AccountIBAN = Kontonummer / IBAN +FinTSProductID = FinTS Produkt-ID +FinTSProductIDHelp = Optional. Registrierungsnummer von der Deutschen Kreditwirtschaft. +TestConnection = Verbindung testen +ConnectionTestSuccessful = Verbindungstest erfolgreich! +ConnectionTestFailed = Verbindungstest fehlgeschlagen +FinTSClassNotFound = FinTS-Klasse nicht gefunden. Installation prüfen. +FinTSNotConfigured = FinTS-Verbindung nicht konfiguriert +FinTSLibraryNotFound = phpFinTS Bibliothek nicht gefunden. Bitte führen Sie composer install aus. +GoToSetup = Zur Einrichtung +FetchAvailableAccounts = Verfügbare Konten abrufen +AccountsFound = %s Konten gefunden +NoAccountsFound = Keine Konten gefunden +TANRequiredForAccountList = TAN erforderlich - Kontoabruf ohne TAN nicht möglich +AccountNumber = Kontonummer +UseThisAccount = Dieses Konto verwenden +Selected = Ausgewählt +IBANUpdated = IBAN aktualisiert: %s + +# VR-Bank Info +VRBankInfo = VR-Bank / Volksbank Information +VRBankInfoText = Für VR-Banken verwenden Sie den Atruvia-Server: https://fints1.atruvia.de/cgi-bin/hbciservlet
Das TAN-Verfahren SecureGo Plus (Decoupled TAN) wird unterstützt. + +# Sicherheit +SecurityInfo = Sicherheitshinweis +SecurityInfoText = Die PIN wird verschlüsselt in der Datenbank gespeichert. Für zusätzliche Sicherheit sollte Ihre Dolibarr-Installation HTTPS verwenden. + +# +# Kontoauszüge Seite +# +BankStatements = Kontoauszüge +FetchStatements = Kontoauszüge abrufen +Transactions = Buchungen +TransactionsFound = %s Buchungen gefunden +NoTransactionsFound = Keine Buchungen im ausgewählten Zeitraum gefunden +AccountBalance = Kontostand +AsOf = Stand vom +LoginFailed = Anmeldung fehlgeschlagen +FetchFailed = Abruf fehlgeschlagen +TANRequired = TAN erforderlich +TANRequiredForStatements = TAN für Kontoauszüge erforderlich +SecureGoPlusConfirmRequired = Bitte bestätigen Sie in Ihrer VR SecureGo plus App +WaitingForSecureGoConfirmation = Warte auf Bestätigung in der SecureGo plus App... +CheckSecureGoStatus = Status prüfen +TANCheckFailed = TAN-Prüfung fehlgeschlagen + +# Import +ImportTransactions = Buchungen importieren +ViewImportedTransactions = Importierte Buchungen anzeigen +TransactionsImported = %s Buchungen importiert +TransactionsSkipped = %s Buchungen übersprungen (bereits vorhanden) + +# +# Transaktionsliste +# +TransactionList = Buchungsliste +TransactionRef = Referenz +Counterparty = Gegenkonto +AllStatuses = Alle Status +StatusNew = Neu +StatusMatched = Zugeordnet +StatusReconciled = Abgeglichen +StatusIgnored = Ignoriert +SearchTransactions = Buchungen durchsuchen +NoTransactionsInDatabase = Keine Buchungen in der Datenbank + +# Status Labels +NewTransaction = Neue Buchung +TransactionMatched = Zugeordnet +TransactionReconciled = Abgeglichen +TransactionIgnored = Ignoriert +New = Neu +Matched = Zugeordnet +Reconciled = Abgeglichen +Ignored = Ignoriert + +# Card page +Transaction = Buchung +DateValue = Valutadatum +CounterpartyIBAN = Gegenkonto IBAN +EndToEndId = End-to-End ID +MandateId = Mandatsreferenz +FindMatches = Zuordnungen suchen +SetAsIgnored = Als ignoriert markieren +Reopen = Wieder öffnen +MatchesFound = %s mögliche Zuordnungen gefunden +NoMatchesFound = Keine Zuordnungen gefunden +Link = Verknüpfen +LinkCreated = Verknüpfung erstellt +StatusUpdated = Status aktualisiert +BackToList = Zurück zur Liste +Score = Übereinstimmung +DateDue = Fälligkeitsdatum +MatchReason = Grund + +# Match Reasons +MatchByRef = Rechnungsnr. +MatchByClientRef = Kundenreferenz +MatchBySupplierRef = Lieferantenreferenz +MatchByAmount = Betrag +MatchByAmountClose = Betrag ähnlich +MatchByNameExact = Name +MatchByNameSimilar = Name ähnlich +MatchByIBAN = IBAN +MatchByMultiInvoice = Sammelzahlung + +# Automatic Import +AutomaticImport = Automatischer Import +EnableAutoImport = Automatischen Import aktivieren +EnableAutoImportHelp = Buchungen werden automatisch per Cronjob abgerufen +ImportFrequency = Abruf-Häufigkeit +Daily = Täglich +Weekly = Wöchentlich +TwiceWeekly = Zweimal pro Woche +DaysToFetch = Tage abrufen +DaysToFetchHelp = Max. 60 Tage (Bank-Limit beachten) +LastAutoFetch = Letzter automatischer Abruf +NeverFetched = Noch nie abgerufen +MoreThan7Days = Mehr als 7 Tage her +CronJobDescription = Automatischer Import von Bankbuchungen via FinTS +AutoImportSuccess = %s Buchungen automatisch importiert +AutoImportNoTransactions = Keine neuen Buchungen gefunden +AutoImportError = Automatischer Import fehlgeschlagen +AutoImportDisabled = Automatischer Import ist deaktiviert +AutoImportNotConfigured = FinTS ist nicht konfiguriert +AutoImportTANRequired = Automatischer Import benötigt TAN-Bestätigung +AutoImportTANRequiredDesc = Der automatische Import benötigt eine TAN-Bestätigung. Bitte bestätigen Sie in der SecureGo Plus App. +AutoImportErrorDesc = Beim letzten automatischen Import am %s ist ein Fehler aufgetreten. +TANConfirmedImportComplete = TAN bestätigt - Import abgeschlossen +LastFetchWarning = Letzter Abruf am %s - Bitte regelmäßig neue Buchungen abrufen +BankImportAutoFetch = Automatischer Bankimport + +# Errors +HistoricalDataLimit = Die Bank stellt historische Kontodaten nur für ca. 2-3 Monate bereit. +SelectShorterPeriod = Bitte wählen Sie einen kürzeren Zeitraum. +OlderDataNotAvailable = Ältere Daten vor %s sind bei der Bank nicht mehr verfügbar. +PartialResultsReturned = Es konnten nur neuere Transaktionen abgerufen werden. + +# PDF Statements +PDFStatements = PDF-Kontoauszüge +FetchPDFStatements = PDF-Kontoauszüge abrufen +StatementNumber = Auszugsnummer +StatementYear = Jahr +StatementDate = Auszugsdatum +DownloadPDF = PDF herunterladen +NoPDFStatementsFound = Keine PDF-Kontoauszüge gefunden +PDFStatementsImported = %s Kontoauszüge importiert +StatementAlreadyExists = Kontoauszug bereits vorhanden +DeleteStatement = Kontoauszug löschen +ConfirmDeleteStatement = Möchten Sie den Kontoauszug %s wirklich löschen? +StatementsInYear = Kontoauszüge im Jahr %s +AllStatements = Alle Kontoauszüge +OpeningBalance = Anfangssaldo +ClosingBalance = Endsaldo +StatementUploaded = Kontoauszug erfolgreich hochgeladen + +# +# Über-Seite +# +About = Über +BankImportAbout = Über Bankimport +BankImportAboutPage = Bankimport Info-Seite + +# +# Startseite / Dashboard +# +BankImportArea = Bankimport Übersicht +LastImportedTransactions = Letzte importierte Buchungen +LastPDFStatements = Letzte PDF-Kontoauszüge +ShowAll = Alle anzeigen +UploadNew = Neuen hochladen +BankImportMenu = Bankimport + +# +# PDF Kontoauszüge Seite +# +PDFStatementsInfo = PDF-Kontoauszüge +PDFStatementsInfoDesc = Hier können Sie Ihre PDF-Kontoauszüge hochladen, verwalten und einsehen. Die Auszüge werden sicher gespeichert und können jederzeit heruntergeladen werden. +UploadPDFStatement = PDF-Kontoauszüge hochladen + +# +# Upload-Modus +# +UploadMode = Upload-Modus +UploadModeAuto = Automatisch erkennen +UploadModeManual = Manuelle Eingabe +PdfAutoDetected = PDF-Metadaten automatisch erkannt +ErrorNoFileUploaded = Keine Datei hochgeladen +ErrorOnlyPDFAllowed = Nur PDF-Dateien sind erlaubt +ErrorFileTooLarge = Datei ist zu groß (max. 10 MB) +ErrorFailedToSaveFile = Datei konnte nicht gespeichert werden +TransactionsLinked = %s Buchungen dem Kontoauszug zugeordnet +StatementsUploaded = %s Kontoauszüge erfolgreich hochgeladen +MultipleFilesHint = Sie können mehrere PDF-Dateien gleichzeitig auswählen (Strg+Klick oder Shift+Klick) + +# +# Admin - PDF Upload +# +PDFUploadSettings = PDF-Upload Einstellungen +DefaultUploadMode = Standard Upload-Modus +DefaultUploadModeHelp = Automatisch: Metadaten werden aus dem PDF extrahiert. Manuell: Alle Felder müssen von Hand ausgefüllt werden. +ReminderEnabled = Erinnerung aktivieren +ReminderEnabledHelp = Zeigt eine Warnung wenn Kontoauszüge nicht aktuell sind +ReminderMonths = Erinnerung nach (Monate) +ReminderMonthsHelp = Warnung anzeigen, wenn der letzte Kontoauszug älter als X Monate ist +ReminderNoStatements = Es wurden noch keine Kontoauszüge hochgeladen. Bitte laden Sie Ihre Kontoauszüge hoch. +ReminderOutdatedStatements = Der letzte Kontoauszug endet am %s (vor %s Monaten). Bitte laden Sie aktuelle Kontoauszüge hoch. + +# +# Kontoauszug-Verknüpfung +# +PDFStatement = PDF-Kontoauszug +ViewPDFStatement = PDF-Kontoauszug anzeigen + +# +# Berechtigungen +# +PermBankImportRead = Bankimport: Buchungen und Kontoauszüge ansehen +PermBankImportWrite = Bankimport: Kontoauszüge abrufen und PDF hochladen +PermBankImportDelete = Bankimport: Buchungen und Kontoauszüge löschen + +# +# Zahlungsabgleich +# +PaymentConfirmation = Zahlungsabgleich +PaymentConfirmationDesc = Bankbuchungen mit Rechnungen abgleichen und Zahlungen in Dolibarr erstellen. +PendingPaymentMatches = %s neue Bankbuchungen warten auf Zuordnung +PendingPaymentMatchesDesc = Prüfen und bestätigen Sie die Zuordnungen, um Zahlungen in Dolibarr zu erstellen. +ReviewAndConfirm = Zuordnungen prüfen +ConfirmPayment = Zahlung bestätigen +ConfirmAllHighScore = Alle sicheren Zuordnungen bestätigen (Score >= 80%%) +PaymentCreatedSuccessfully = Zahlung erstellt: %s - %s +PaymentCreatedByBankImport = Automatisch erstellt durch Bankimport +ErrorNoBankAccountConfigured = Kein Dolibarr-Bankkonto konfiguriert. Bitte in den Einstellungen zuordnen. +NoBankAccountConfigured = Es ist noch kein Dolibarr-Bankkonto zugeordnet. +TransactionAlreadyProcessed = Buchung wurde bereits verarbeitet +PaymentsCreatedSummary = %s Zahlungen erstellt, %s fehlgeschlagen +NoNewMatchesFound = Keine neuen Zuordnungen gefunden +Alternatives = weitere Zuordnung(en) +UnmatchedTransactions = %s neue Buchungen ohne Rechnungszuordnung +InvoiceAlreadyPaid = Rechnung ist bereits vollständig bezahlt +NoInvoicesSelected = Keine Rechnungen ausgewählt +Invoices = Rechnungen +SearchInvoiceManually = Rechnung manuell suchen +SearchInvoiceByRef = Rechnungsnummer, Lieferantenzeichen oder Firma... +InvoiceRef = Rechnungsnummer +CustomerInvoice = Kundenrechnung +SupplierInvoice = Lieferantenrechnung +SupplierRef = Lieferantenreferenz +ManualSearch = Manuelle Suche +All = Alle +SelectInvoicesManually = Rechnungen manuell auswählen +SupplierInvoices = Lieferantenrechnungen +CustomerInvoices = Kundenrechnungen +Filter = Filtern +RemoveFilter = Filter entfernen +TransactionAmount = Transaktionsbetrag +Selected = Ausgewählt +AmountRemaining = Restbetrag +NoUnpaidInvoices = Keine offenen Rechnungen gefunden +CustomerRef = Kundenreferenz +ShowPaidInvoices = Auch bezahlte anzeigen +PaidInvoices = Bezahlte Rechnungen +PaidInvoicesInfo = Diese Rechnungen wurden bereits bezahlt. Klicken Sie auf "Zahlung verknüpfen" um die existierende Zahlung mit der Bankbuchung zu verbinden. +Paid = Bezahlt +LinkExistingPayment = Zahlung verknüpfen +PaymentLinkedSuccessfully = Zahlung erfolgreich verknüpft +NoPaymentFoundForInvoice = Keine Zahlung für diese Rechnung gefunden + +# +# Bankkonto-Zuordnung +# +BankAccountMapping = Dolibarr-Bankkonto Zuordnung +DolibarrBankAccount = Dolibarr Bankkonto +DolibarrBankAccountHelp = Wählen Sie das Dolibarr-Bankkonto, das der konfigurierten IBAN entspricht. Zahlungen werden auf dieses Konto gebucht. +SelectBankAccount = -- Bankkonto auswählen -- + +# +# Kontoauszugsabgleich +# +BankEntriesReconciled = %s Bankbuchungen mit Auszug %s abgeglichen +BankEntriesReconciledTotal = %s Bankbuchungen über %s Kontoauszüge abgeglichen +ReconcileStatement = Kontoauszug abgleichen +ReconcileAllStatements = Alle Kontoauszüge abgleichen +NoBankEntriesToReconcile = Keine offenen Bankbuchungen zum Abgleichen gefunden +StatementMissingDates = Kontoauszug hat keinen Zeitraum (Von/Bis) - Abgleich nicht möglich + +# +# Kontoauszugs-Positionen (Statement Lines) +# +StatementLinesExtracted = %s Buchungszeilen aus Auszug %s extrahiert +StatementLines = Buchungszeilen +BookingDate = Buchungstag +ValueDate = Wertstellung +TransactionType = Vorgangsart +NoStatementLines = Keine Buchungszeilen vorhanden + +# +# Ausstehende Zuordnungen (Pending Review) +# +PendingReconciliationMatches = Zuordnungen mit Betragsabweichung +PendingReconciliationMatchesDesc = Diese Zuordnungen wurden über Rechnungsnummern erkannt, aber der Betrag weicht um mehr als 5 EUR ab. Bitte prüfen und bestätigen. +AmountStatement = Betrag (Auszug) +AmountDolibarr = Betrag (Dolibarr) +Difference = Differenz +BankEntry = Bank-Eintrag +ReconciliationConfirmed = Zuordnung bestätigt und abgeglichen +Confirm = Bestätigen + +# +# Dashboard-Widget +# +BoxBankImportPending = Bankimport - Offene Zuordnungen + +# +# Unlink/Edit Payment +# +UnlinkPayment = Verknüpfung aufheben +PaymentUnlinked = Verknüpfung wurde aufgehoben - Buchung ist wieder offen +CannotUnlinkThisStatus = Verknüpfung kann bei diesem Status nicht aufgehoben werden + +# +# Linked Objects Display +# +Payment = Zahlung +LinkedInvoices = Verknüpfte Rechnungen +NoInvoicesLinkedToPayment = Keine Rechnungen mit dieser Zahlung verknüpft diff --git a/langs/en_US/bankimport.lang b/langs/en_US/bankimport.lang new file mode 100755 index 0000000..09f69a2 --- /dev/null +++ b/langs/en_US/bankimport.lang @@ -0,0 +1,252 @@ +# Translation file + +# +# Generic +# + +# Module label 'ModuleBankImportName' +ModuleBankImportName = BankImport +# Module description 'ModuleBankImportDesc' +ModuleBankImportDesc = Import bank statements via FinTS/HBCI + +# +# Admin page +# +BankImportSetup = BankImport Setup +Settings = Settings +BankImportSetupPage = BankImport setup page +BankImportSetupDescription = Configure the FinTS/HBCI connection to your bank for automatic statement import. + +# FinTS Configuration +FinTSConfiguration = FinTS Bank Connection +FinTSServerURL = FinTS Server URL +FinTSServerURLHelp = For VR-Banks/Volksbanken: https://fints1.atruvia.de/cgi-bin/hbciservlet +BLZ = Bank Code (BLZ) +BLZHelp = 8-digit German bank code +FinTSUsername = User ID +FinTSPIN = PIN +PINAlreadySet = PIN is configured +PINHelp = PIN will be stored encrypted. Leave empty to keep existing PIN. +AccountIBAN = Account Number / IBAN +FinTSProductID = FinTS Product ID +FinTSProductIDHelp = Optional. Registration number from Deutsche Kreditwirtschaft. +TestConnection = Test Connection +ConnectionTestSuccessful = Connection test successful! +ConnectionTestFailed = Connection test failed +FinTSClassNotFound = FinTS class not found. Please check installation. +FinTSNotConfigured = FinTS connection not configured +FinTSLibraryNotFound = phpFinTS library not found. Please run composer install. +GoToSetup = Go to setup +FetchAvailableAccounts = Fetch Available Accounts +AccountsFound = %s accounts found +NoAccountsFound = No accounts found +TANRequiredForAccountList = TAN required - account list not available without TAN +AccountNumber = Account Number +UseThisAccount = Use This Account +Selected = Selected +IBANUpdated = IBAN updated: %s + +# VR Bank Info +VRBankInfo = VR-Bank / Volksbank Information +VRBankInfoText = For VR-Banks use the Atruvia server: https://fints1.atruvia.de/cgi-bin/hbciservlet
The TAN procedure SecureGo Plus (Decoupled TAN) is supported. + +# Security +SecurityInfo = Security Information +SecurityInfoText = The PIN is stored encrypted in the database. For additional security, ensure your Dolibarr installation uses HTTPS. + +# +# Statements page +# +BankStatements = Bank Statements +FetchStatements = Fetch Statements +Transactions = Transactions +TransactionsFound = %s transactions found +NoTransactionsFound = No transactions found in the selected period +AccountBalance = Account Balance +AsOf = As of +LoginFailed = Login failed +FetchFailed = Failed to fetch statements +TANRequired = TAN required +TANRequiredForStatements = TAN required to fetch statements +SecureGoPlusConfirmRequired = Please confirm in your VR SecureGo plus App +WaitingForSecureGoConfirmation = Waiting for confirmation in SecureGo plus App... +CheckSecureGoStatus = Check Status +TANCheckFailed = TAN verification failed + +# Import +ImportTransactions = Import Transactions +ViewImportedTransactions = View Imported Transactions +TransactionsImported = %s transactions imported +TransactionsSkipped = %s transactions skipped (already exist) + +# +# Transaction List +# +TransactionList = Transaction List +TransactionRef = Reference +Counterparty = Counterparty +AllStatuses = All Statuses +StatusNew = New +StatusMatched = Matched +StatusReconciled = Reconciled +StatusIgnored = Ignored +SearchTransactions = Search Transactions +NoTransactionsInDatabase = No transactions in database + +# Status Labels +NewTransaction = New Transaction +TransactionMatched = Transaction Matched +TransactionReconciled = Transaction Reconciled +TransactionIgnored = Transaction Ignored +New = New +Matched = Matched +Reconciled = Reconciled +Ignored = Ignored + +# Card page +Transaction = Transaction +DateValue = Value Date +CounterpartyIBAN = Counterparty IBAN +EndToEndId = End-to-End ID +MandateId = Mandate Reference +FindMatches = Find Matches +SetAsIgnored = Set as Ignored +Reopen = Reopen +MatchesFound = %s possible matches found +NoMatchesFound = No matches found +Link = Link +LinkCreated = Link created +StatusUpdated = Status updated +BackToList = Back to List +Score = Match Score + +# PDF Statements +PDFStatements = PDF Statements +FetchPDFStatements = Fetch PDF Statements +StatementNumber = Statement Number +StatementYear = Year +StatementDate = Statement Date +DownloadPDF = Download PDF +NoPDFStatementsFound = No PDF statements found +PDFStatementsImported = %s statements imported +StatementAlreadyExists = Statement already exists + +# +# About page +# +About = About +BankImportAbout = About BankImport +BankImportAboutPage = BankImport about page + +# +# Home +# +BankImportArea = BankImport Home + +# +# Payment Confirmation +# +PaymentConfirmation = Payment Matching +PaymentConfirmationDesc = Match bank transactions to invoices and create payments in Dolibarr. +PendingPaymentMatches = %s new bank transactions waiting for matching +PendingPaymentMatchesDesc = Review and confirm matches to create payments in Dolibarr. +ReviewAndConfirm = Review matches +ConfirmPayment = Confirm payment +ConfirmAllHighScore = Confirm all high-score matches (score >= 80%%) +PaymentCreatedSuccessfully = Payment created: %s - %s +PaymentCreatedByBankImport = Automatically created by BankImport +ErrorNoBankAccountConfigured = No Dolibarr bank account configured. Please configure in settings. +NoBankAccountConfigured = No Dolibarr bank account mapped yet. +TransactionAlreadyProcessed = Transaction already processed +PaymentsCreatedSummary = %s payments created, %s failed +NoNewMatchesFound = No new matches found +Alternatives = more match(es) +UnmatchedTransactions = %s new transactions without invoice match +InvoiceAlreadyPaid = Invoice is already fully paid +NoInvoicesSelected = No invoices selected +Invoices = Invoices +MatchByMultiInvoice = Multi-invoice payment +SearchInvoiceManually = Search invoice manually +SearchInvoiceByRef = Enter invoice reference... +InvoiceRef = Invoice reference +CustomerInvoice = Customer invoice +SupplierInvoice = Supplier invoice +SupplierRef = Supplier reference +ManualSearch = Manual search +All = All +SelectInvoicesManually = Select invoices manually +SupplierInvoices = Supplier invoices +CustomerInvoices = Customer invoices +Filter = Filter +RemoveFilter = Remove filter +TransactionAmount = Transaction amount +Selected = Selected +AmountRemaining = Remaining amount +NoUnpaidInvoices = No unpaid invoices found +CustomerRef = Customer reference +ShowPaidInvoices = Show paid invoices +PaidInvoices = Paid Invoices +PaidInvoicesInfo = These invoices have already been paid. Click "Link Payment" to connect the existing payment with the bank entry. +Paid = Paid +LinkExistingPayment = Link Payment +PaymentLinkedSuccessfully = Payment linked successfully +NoPaymentFoundForInvoice = No payment found for this invoice + +# +# Bank Account Mapping +# +BankAccountMapping = Dolibarr Bank Account Mapping +DolibarrBankAccount = Dolibarr Bank Account +DolibarrBankAccountHelp = Select the Dolibarr bank account corresponding to the configured IBAN. Payments will be booked to this account. +SelectBankAccount = -- Select bank account -- + +# +# Statement Reconciliation +# +BankEntriesReconciled = %s bank entries reconciled with statement %s +BankEntriesReconciledTotal = %s bank entries reconciled across %s statements +ReconcileStatement = Reconcile statement +ReconcileAllStatements = Reconcile all statements +NoBankEntriesToReconcile = No open bank entries found to reconcile +StatementMissingDates = Statement has no date range (from/to) - reconciliation not possible + +# +# Statement Lines +# +StatementLinesExtracted = %s transaction lines extracted from statement %s +StatementLines = Transaction Lines +BookingDate = Booking Date +ValueDate = Value Date +TransactionType = Transaction Type +NoStatementLines = No transaction lines available + +# +# Pending Review Matches +# +PendingReconciliationMatches = Matches with Amount Deviation +PendingReconciliationMatchesDesc = These matches were found via invoice numbers but the amount differs by more than 5 EUR. Please review and confirm. +AmountStatement = Amount (Statement) +AmountDolibarr = Amount (Dolibarr) +Difference = Difference +BankEntry = Bank Entry +ReconciliationConfirmed = Match confirmed and reconciled +Confirm = Confirm + +# +# Dashboard Widget +# +BoxBankImportPending = BankImport - Pending Matches + +# +# Unlink/Edit Payment +# +UnlinkPayment = Unlink Payment +PaymentUnlinked = Payment link removed - transaction is now open again +CannotUnlinkThisStatus = Cannot unlink with this status + +# +# Linked Objects Display +# +Payment = Payment +LinkedInvoices = Linked Invoices +NoInvoicesLinkedToPayment = No invoices linked to this payment diff --git a/lib/bankimport.lib.php b/lib/bankimport.lib.php new file mode 100755 index 0000000..05cabe8 --- /dev/null +++ b/lib/bankimport.lib.php @@ -0,0 +1,85 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/** + * \file bankimport/lib/bankimport.lib.php + * \ingroup bankimport + * \brief Library files with common functions for BankImport + */ + +/** + * Prepare admin pages header + * + * @return array + */ +function bankimportAdminPrepareHead() +{ + global $langs, $conf; + + // global $db; + // $extrafields = new ExtraFields($db); + // $extrafields->fetch_name_optionals_label('myobject'); + + $langs->load("bankimport@bankimport"); + + $h = 0; + $head = array(); + + $head[$h][0] = dol_buildpath("/bankimport/admin/setup.php", 1); + $head[$h][1] = $langs->trans("Settings"); + $head[$h][2] = 'settings'; + $h++; + + /* + $head[$h][0] = dol_buildpath("/bankimport/admin/myobject_extrafields.php", 1); + $head[$h][1] = $langs->trans("ExtraFields"); + $nbExtrafields = (isset($extrafields->attributes['myobject']['label']) && is_countable($extrafields->attributes['myobject']['label'])) ? count($extrafields->attributes['myobject']['label']) : 0; + if ($nbExtrafields > 0) { + $head[$h][1] .= '' . $nbExtrafields . ''; + } + $head[$h][2] = 'myobject_extrafields'; + $h++; + + $head[$h][0] = dol_buildpath("/bankimport/admin/myobjectline_extrafields.php", 1); + $head[$h][1] = $langs->trans("ExtraFieldsLines"); + $nbExtrafields = (isset($extrafields->attributes['myobjectline']['label']) && is_countable($extrafields->attributes['myobjectline']['label'])) ? count($extrafields->attributes['myobject']['label']) : 0; + if ($nbExtrafields > 0) { + $head[$h][1] .= '' . $nbExtrafields . ''; + } + $head[$h][2] = 'myobject_extrafieldsline'; + $h++; + */ + + $head[$h][0] = dol_buildpath("/bankimport/admin/about.php", 1); + $head[$h][1] = $langs->trans("About"); + $head[$h][2] = 'about'; + $h++; + + // Show more tabs from modules + // Entries must be declared in modules descriptor with line + //$this->tabs = array( + // 'entity:+tabname:Title:@bankimport:/bankimport/mypage.php?id=__ID__' + //); // to add new tab + //$this->tabs = array( + // 'entity:-tabname:Title:@bankimport:/bankimport/mypage.php?id=__ID__' + //); // to remove a tab + complete_head_from_modules($conf, $langs, null, $head, $h, 'bankimport@bankimport'); + + complete_head_from_modules($conf, $langs, null, $head, $h, 'bankimport@bankimport', 'remove'); + + return $head; +} diff --git a/list.php b/list.php new file mode 100755 index 0000000..0ee2faa --- /dev/null +++ b/list.php @@ -0,0 +1,329 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +/** + * \file bankimport/list.php + * \ingroup bankimport + * \brief List page for imported bank transactions + */ + +// Load Dolibarr environment +$res = 0; +if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) { + $res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php"; +} +$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME']; +$tmp2 = realpath(__FILE__); +$i = strlen($tmp) - 1; +$j = strlen($tmp2) - 1; +while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) { + $i--; + $j--; +} +if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) { + $res = @include substr($tmp, 0, ($i + 1))."/main.inc.php"; +} +if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) { + $res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php"; +} +if (!$res && file_exists("../main.inc.php")) { + $res = @include "../main.inc.php"; +} +if (!$res && file_exists("../../main.inc.php")) { + $res = @include "../../main.inc.php"; +} +if (!$res) { + die("Include of main fails"); +} + +require_once DOL_DOCUMENT_ROOT.'/core/lib/date.lib.php'; +require_once DOL_DOCUMENT_ROOT.'/core/class/html.formother.class.php'; +dol_include_once('/bankimport/class/banktransaction.class.php'); +dol_include_once('/bankimport/lib/bankimport.lib.php'); + +/** + * @var Conf $conf + * @var DoliDB $db + * @var Translate $langs + * @var User $user + */ + +$langs->loadLangs(array("bankimport@bankimport", "banks", "bills")); + +$action = GETPOST('action', 'aZ09'); +$massaction = GETPOST('massaction', 'alpha'); +$confirm = GETPOST('confirm', 'alpha'); +$toselect = GETPOST('toselect', 'array'); +$contextpage = GETPOST('contextpage', 'aZ') ? GETPOST('contextpage', 'aZ') : 'bankimporttransactionlist'; + +// Security check +if (!$user->hasRight('bankimport', 'read')) { + accessforbidden(); +} + +// Search parameters +$search_ref = GETPOST('search_ref', 'alpha'); +$search_iban = GETPOST('search_iban', 'alpha'); +$search_name = GETPOST('search_name', 'alpha'); +$search_description = GETPOST('search_description', 'alpha'); +$search_amount_min = GETPOST('search_amount_min', 'alpha'); +$search_amount_max = GETPOST('search_amount_max', 'alpha'); +$search_status = GETPOST('search_status', 'intcomma'); + +// Date filter +$search_date_from = dol_mktime(0, 0, 0, GETPOSTINT('search_date_frommonth'), GETPOSTINT('search_date_fromday'), GETPOSTINT('search_date_fromyear')); +$search_date_to = dol_mktime(23, 59, 59, GETPOSTINT('search_date_tomonth'), GETPOSTINT('search_date_today'), GETPOSTINT('search_date_toyear')); + +// Sorting +$sortfield = GETPOST('sortfield', 'aZ09comma'); +$sortorder = GETPOST('sortorder', 'aZ09comma'); +if (!$sortfield) $sortfield = 'date_trans'; +if (!$sortorder) $sortorder = 'DESC'; + +// Pagination +$limit = GETPOSTINT('limit') ? GETPOSTINT('limit') : $conf->liste_limit; +$page = GETPOSTISSET('pageplusone') ? (GETPOSTINT('pageplusone') - 1) : GETPOSTINT('page'); +if (empty($page) || $page < 0) $page = 0; +$offset = $limit * $page; + +/* + * Actions + */ + +// Reset search +if (GETPOST('button_removefilter_x', 'alpha') || GETPOST('button_removefilter.x', 'alpha') || GETPOST('button_removefilter', 'alpha')) { + $search_ref = ''; + $search_iban = ''; + $search_name = ''; + $search_description = ''; + $search_amount_min = ''; + $search_amount_max = ''; + $search_status = ''; + $search_date_from = ''; + $search_date_to = ''; + $toselect = array(); +} + +/* + * View + */ + +$form = new Form($db); +$transaction = new BankImportTransaction($db); + +$title = $langs->trans("TransactionList"); +llxHeader('', $title, '', '', 0, 0, '', '', '', 'mod-bankimport page-list'); + +print load_fiche_titre($title, '', 'bank'); + +// Build filter array +$filter = array(); +if (!empty($search_ref)) $filter['ref'] = $search_ref; +if (!empty($search_iban)) $filter['iban'] = $search_iban; +if (!empty($search_name)) $filter['name'] = $search_name; +if (!empty($search_description)) $filter['description'] = $search_description; +if (!empty($search_amount_min)) $filter['amount_min'] = price2num($search_amount_min); +if (!empty($search_amount_max)) $filter['amount_max'] = price2num($search_amount_max); +if ($search_status !== '' && $search_status >= 0) $filter['status'] = (int) $search_status; +if (!empty($search_date_from)) $filter['date_from'] = $search_date_from; +if (!empty($search_date_to)) $filter['date_to'] = $search_date_to; + +// Count total +$totalRecords = $transaction->fetchAll('', '', 0, 0, $filter, 'count'); + +// Fetch records +$records = $transaction->fetchAll($sortfield, $sortorder, $limit, $offset, $filter); + +// Form +$param = ''; +if (!empty($search_ref)) $param .= '&search_ref='.urlencode($search_ref); +if (!empty($search_iban)) $param .= '&search_iban='.urlencode($search_iban); +if (!empty($search_name)) $param .= '&search_name='.urlencode($search_name); +if (!empty($search_description)) $param .= '&search_description='.urlencode($search_description); +if (!empty($search_amount_min)) $param .= '&search_amount_min='.urlencode($search_amount_min); +if (!empty($search_amount_max)) $param .= '&search_amount_max='.urlencode($search_amount_max); +if ($search_status !== '' && $search_status >= 0) $param .= '&search_status='.urlencode($search_status); +if (!empty($search_date_from)) { + $param .= '&search_date_fromday='.dol_print_date($search_date_from, '%d'); + $param .= '&search_date_frommonth='.dol_print_date($search_date_from, '%m'); + $param .= '&search_date_fromyear='.dol_print_date($search_date_from, '%Y'); +} +if (!empty($search_date_to)) { + $param .= '&search_date_today='.dol_print_date($search_date_to, '%d'); + $param .= '&search_date_tomonth='.dol_print_date($search_date_to, '%m'); + $param .= '&search_date_toyear='.dol_print_date($search_date_to, '%Y'); +} + +print '
'; +print ''; +print ''; +print ''; + +// Navigation +print_barre_liste($title, $page, $_SERVER["PHP_SELF"], $param, $sortfield, $sortorder, '', is_array($records) ? count($records) : 0, $totalRecords, '', 0, '', '', $limit); + +print '
'; +print ''; + +// Header row with filters +print ''; + +// Ref +print ''; + +// IBAN +print ''; + +// Date +print ''; + +// Name +print ''; + +// Description +print ''; + +// Amount +print ''; + +// Statement +print ''; + +// Status +print ''; + +// Action buttons +print ''; + +print ''; + +// Header titles +print ''; +print_liste_field_titre($langs->trans("TransactionRef"), $_SERVER["PHP_SELF"], "ref", "", $param, "", $sortfield, $sortorder); +print_liste_field_titre($langs->trans("AccountIBAN"), $_SERVER["PHP_SELF"], "iban", "", $param, "", $sortfield, $sortorder); +print_liste_field_titre($langs->trans("Date"), $_SERVER["PHP_SELF"], "date_trans", "", $param, 'class="center"', $sortfield, $sortorder); +print_liste_field_titre($langs->trans("Counterparty"), $_SERVER["PHP_SELF"], "name", "", $param, "", $sortfield, $sortorder); +print_liste_field_titre($langs->trans("Description"), $_SERVER["PHP_SELF"], "description", "", $param, "", $sortfield, $sortorder); +print_liste_field_titre($langs->trans("Amount"), $_SERVER["PHP_SELF"], "amount", "", $param, 'class="right"', $sortfield, $sortorder); +print_liste_field_titre($langs->trans("PDFStatement"), $_SERVER["PHP_SELF"], "fk_statement", "", $param, 'class="center"', $sortfield, $sortorder); +print_liste_field_titre($langs->trans("Status"), $_SERVER["PHP_SELF"], "status", "", $param, 'class="center"', $sortfield, $sortorder); +print_liste_field_titre('', $_SERVER["PHP_SELF"], "", "", $param, 'class="center"', $sortfield, $sortorder); +print ''; + +// Data rows +if (is_array($records) && count($records) > 0) { + foreach ($records as $obj) { + print ''; + + // Ref + print ''; + + // IBAN + print ''; + + // Date + print ''; + + // Name + print ''; + + // Description + print ''; + + // Amount + print ''; + + // Statement link + print ''; + + // Status + print ''; + + // Actions + print ''; + + print ''; + } +} else { + print ''; +} + +print '
'; +print ''; +print ''; +print ''; +print ''; +print '
'; +print $form->selectDate($search_date_from, 'search_date_from', 0, 0, 1, '', 1, 0, 0, '', '', '', '', 1, '', $langs->trans("From")); +print '
'; +print '
'; +print $form->selectDate($search_date_to, 'search_date_to', 0, 0, 1, '', 1, 0, 0, '', '', '', '', 1, '', $langs->trans("To")); +print '
'; +print '
'; +print ''; +print ''; +print ''; +print ''; +print ''; +print ' - '; +print ''; +print ''; +print ''; +$statusArray = array( + '' => $langs->trans("AllStatuses"), + '0' => $langs->trans("StatusNew"), + '1' => $langs->trans("StatusMatched"), + '2' => $langs->trans("StatusReconciled"), + '9' => $langs->trans("StatusIgnored") +); +print $form->selectarray('search_status', $statusArray, $search_status, 0, 0, 0, '', 0, 0, 0, '', 'minwidth100'); +print ''; +print ''; +print ' '; +print ''; +print '
'; + print ''.dol_escape_htmltag($obj->ref).''; + print ''; + print dol_escape_htmltag(dol_trunc($obj->iban, 20)); + print ''; + print dol_print_date($obj->date_trans, 'day'); + print ''; + print dol_escape_htmltag($obj->name); + print ''; + print dol_escape_htmltag(dol_trunc($obj->description, 60)); + print ''; + if ($obj->amount >= 0) { + print '+'.price($obj->amount, 0, $langs, 1, -1, 2, $obj->currency).''; + } else { + print ''.price($obj->amount, 0, $langs, 1, -1, 2, $obj->currency).''; + } + print ''; + if (!empty($obj->fk_statement)) { + print ''; + print img_picto($langs->trans("ViewPDFStatement"), 'pdf'); + print ''; + } + print ''; + print $obj->getLibStatut(5); + print ''; + print ''.img_edit().''; + print '
'; + print $langs->trans("NoTransactionsInDatabase"); + print '
'; +print '
'; + +print '
'; + +// Buttons +print ''; + +llxFooter(); +$db->close(); diff --git a/modulebuilder.txt b/modulebuilder.txt new file mode 100755 index 0000000..670a177 --- /dev/null +++ b/modulebuilder.txt @@ -0,0 +1,3 @@ +# DO NOT DELETE THIS FILE MANUALLY +# File to flag module built using official module template. +# When this file is present into a module directory, you can edit it with the module builder tool. \ No newline at end of file diff --git a/pdfstatements.php b/pdfstatements.php new file mode 100755 index 0000000..e20933a --- /dev/null +++ b/pdfstatements.php @@ -0,0 +1,955 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +/** + * \file bankimport/pdfstatements.php + * \ingroup bankimport + * \brief Page to upload and manage PDF bank statements + */ + +// Load Dolibarr environment +$res = 0; +if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) { + $res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php"; +} +$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME']; +$tmp2 = realpath(__FILE__); +$i = strlen($tmp) - 1; +$j = strlen($tmp2) - 1; +while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) { + $i--; + $j--; +} +if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) { + $res = @include substr($tmp, 0, ($i + 1))."/main.inc.php"; +} +if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) { + $res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php"; +} +if (!$res && file_exists("../main.inc.php")) { + $res = @include "../main.inc.php"; +} +if (!$res && file_exists("../../main.inc.php")) { + $res = @include "../../main.inc.php"; +} +if (!$res) { + die("Include of main fails"); +} + +require_once DOL_DOCUMENT_ROOT.'/core/lib/date.lib.php'; +require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php'; +require_once DOL_DOCUMENT_ROOT.'/core/class/html.formother.class.php'; +dol_include_once('/bankimport/class/bankstatement.class.php'); +dol_include_once('/bankimport/lib/bankimport.lib.php'); + +/** + * @var Conf $conf + * @var DoliDB $db + * @var Translate $langs + * @var User $user + */ + +$langs->loadLangs(array("bankimport@bankimport", "banks", "other")); + +$action = GETPOST('action', 'aZ09'); +$confirm = GETPOST('confirm', 'alpha'); +$year = GETPOSTISSET('year') ? GETPOSTINT('year') : (int) date('Y'); + +// Security check +if (!$user->hasRight('bankimport', 'read')) { + accessforbidden(); +} + +/* + * Actions + */ + +$statement = new BankImportStatement($db); + +// Upload PDF (supports multiple files) +if ($action == 'upload' && !empty($_FILES['pdffile'])) { + $uploadMode = GETPOST('upload_mode', 'alpha'); + $isAutoMode = ($uploadMode !== 'manual'); + + // Normalize $_FILES for multi-upload: always work with arrays + $fileNames = is_array($_FILES['pdffile']['name']) ? $_FILES['pdffile']['name'] : array($_FILES['pdffile']['name']); + $fileTmps = is_array($_FILES['pdffile']['tmp_name']) ? $_FILES['pdffile']['tmp_name'] : array($_FILES['pdffile']['tmp_name']); + $fileSizes = is_array($_FILES['pdffile']['size']) ? $_FILES['pdffile']['size'] : array($_FILES['pdffile']['size']); + $fileCount = count($fileNames); + + $uploadedCount = 0; + $errorCount = 0; + $totalLinked = 0; + $lastYear = (int) date('Y'); + + for ($fi = 0; $fi < $fileCount; $fi++) { + $error = 0; + + // Skip empty file slots + if (empty($fileNames[$fi]) || empty($fileTmps[$fi])) { + continue; + } + + // Validate uploaded file + if (!is_uploaded_file($fileTmps[$fi])) { + setEventMessages($langs->trans("ErrorNoFileUploaded").': '.$fileNames[$fi], null, 'errors'); + $errorCount++; + continue; + } + + // Check MIME type + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $mimeType = finfo_file($finfo, $fileTmps[$fi]); + finfo_close($finfo); + if ($mimeType !== 'application/pdf') { + setEventMessages($langs->trans("ErrorOnlyPDFAllowed").': '.$fileNames[$fi], null, 'errors'); + $errorCount++; + continue; + } + + // Check file size (max 10MB) + if ($fileSizes[$fi] > 10 * 1024 * 1024) { + setEventMessages($langs->trans("ErrorFileTooLarge").': '.$fileNames[$fi], null, 'errors'); + $errorCount++; + continue; + } + + // Parse PDF metadata automatically + $parsed = BankImportStatement::parsePdfMetadata($fileTmps[$fi]); + + // Determine values: auto mode uses parsed data, manual mode uses form fields + if ($isAutoMode && $parsed) { + $statementNumber = $parsed['statement_number']; + $statementYear = $parsed['statement_year']; + $iban = $parsed['iban']; + } else { + // Manual mode (only for single file upload) + $statementNumber = GETPOST('statement_number', 'alpha'); + $statementYear = GETPOSTINT('statement_year'); + $iban = GETPOST('iban', 'alpha'); + // Auto-fill from parsed data if form fields are empty + if ($parsed) { + if (empty($statementNumber) && !empty($parsed['statement_number'])) { + $statementNumber = $parsed['statement_number']; + } + if (empty($statementYear) && !empty($parsed['statement_year'])) { + $statementYear = $parsed['statement_year']; + } + if (empty($iban) && !empty($parsed['iban'])) { + $iban = $parsed['iban']; + } + } + } + + // Show auto-detection info + if ($parsed) { + $autoMsg = $langs->trans("PdfAutoDetected").': '.$fileNames[$fi]; + if (!empty($statementNumber)) { + $autoMsg .= ' | '.$statementNumber.'/'.$statementYear; + } + if (!empty($parsed['pdf_number'])) { + $autoMsg .= ' (PDF-Nr. '.$parsed['pdf_number'].'/'.$parsed['pdf_year'].')'; + } + if (!empty($parsed['iban'])) { + $autoMsg .= ' | IBAN: '.$parsed['iban']; + } + if ($parsed['date_from'] && $parsed['date_to']) { + $autoMsg .= ' | '.$langs->trans("Period").': '.dol_print_date($parsed['date_from'], 'day').' - '.dol_print_date($parsed['date_to'], 'day'); + } + setEventMessages($autoMsg, null, 'mesgs'); + } + + // Validate required fields + if (empty($statementNumber)) { + setEventMessages($langs->trans("ErrorFieldRequired", $langs->transnoentities("StatementNumber")).': '.$fileNames[$fi], null, 'errors'); + $errorCount++; + continue; + } + if (empty($statementYear)) { + setEventMessages($langs->trans("ErrorFieldRequired", $langs->transnoentities("Year")).': '.$fileNames[$fi], null, 'errors'); + $errorCount++; + continue; + } + + // Create new statement object for each file + $stmt = new BankImportStatement($db); + $stmt->iban = $iban; + $stmt->statement_number = $statementNumber; + $stmt->statement_year = $statementYear; + + // Date fields + if ($isAutoMode && $parsed) { + $stmt->statement_date = $parsed['statement_date']; + $stmt->date_from = $parsed['date_from']; + $stmt->date_to = $parsed['date_to']; + $stmt->opening_balance = $parsed['opening_balance']; + $stmt->closing_balance = $parsed['closing_balance']; + } else { + $statementDate = dol_mktime(0, 0, 0, GETPOSTINT('statement_datemonth'), GETPOSTINT('statement_dateday'), GETPOSTINT('statement_dateyear')); + $dateFrom = dol_mktime(0, 0, 0, GETPOSTINT('date_frommonth'), GETPOSTINT('date_fromday'), GETPOSTINT('date_fromyear')); + $dateTo = dol_mktime(0, 0, 0, GETPOSTINT('date_tomonth'), GETPOSTINT('date_today'), GETPOSTINT('date_toyear')); + $stmt->statement_date = $statementDate ?: ($parsed ? $parsed['statement_date'] : null); + $stmt->date_from = $dateFrom ?: ($parsed ? $parsed['date_from'] : null); + $stmt->date_to = $dateTo ?: ($parsed ? $parsed['date_to'] : null); + $openBal = GETPOST('opening_balance', 'alpha'); + $closeBal = GETPOST('closing_balance', 'alpha'); + $stmt->opening_balance = ($openBal !== '' && $openBal !== null) ? (float) price2num($openBal) : ($parsed ? $parsed['opening_balance'] : null); + $stmt->closing_balance = ($closeBal !== '' && $closeBal !== null) ? (float) price2num($closeBal) : ($parsed ? $parsed['closing_balance'] : null); + } + + $stmt->import_key = date('YmdHis').'_'.$user->id; + + // Check duplicate + if ($stmt->exists()) { + setEventMessages($langs->trans("StatementAlreadyExists").': '.$statementNumber.'/'.$statementYear, null, 'warnings'); + $errorCount++; + continue; + } + + // Generate filename and save file + $dir = BankImportStatement::getStorageDir(); + + if ($parsed) { + $newFilename = BankImportStatement::generateFilename($parsed); + } else { + $ibanPart = !empty($stmt->iban) ? preg_replace('/[^A-Z0-9]/', '', strtoupper($stmt->iban)) : 'KONTO'; + $newFilename = sprintf('Kontoauszug_%s_%d_%s.pdf', + $ibanPart, + $stmt->statement_year, + str_pad($stmt->statement_number, 3, '0', STR_PAD_LEFT) + ); + } + + $stmt->filepath = $dir.'/'.$newFilename; + + // Avoid overwriting existing files + if (file_exists($stmt->filepath)) { + $newFilename = pathinfo($newFilename, PATHINFO_FILENAME).'_'.date('His').'.pdf'; + $stmt->filepath = $dir.'/'.$newFilename; + } + + $stmt->filename = $newFilename; + + // Move uploaded file + if (!move_uploaded_file($fileTmps[$fi], $stmt->filepath)) { + setEventMessages($langs->trans("ErrorFailedToSaveFile").': '.$fileNames[$fi], null, 'errors'); + $errorCount++; + continue; + } + + $stmt->filesize = filesize($stmt->filepath); + + // Save to database + $result = $stmt->create($user); + + if ($result > 0) { + // Link matching FinTS transactions to this statement + $linked = $stmt->linkTransactions(); + $totalLinked += max(0, $linked); + + // Parse PDF transaction lines and save to database + $pdfLines = $stmt->parsePdfTransactions(); + if (!empty($pdfLines)) { + $linesSaved = $stmt->saveStatementLines($pdfLines); + if ($linesSaved > 0) { + setEventMessages($langs->trans("StatementLinesExtracted", $linesSaved, $stmt->statement_number.'/'.$stmt->statement_year), null, 'mesgs'); + } + } + + // Copy PDF to Dolibarr's bank statement document directory + $uploadBankAccountId = getDolGlobalInt('BANKIMPORT_BANK_ACCOUNT_ID'); + if ($uploadBankAccountId > 0) { + $stmt->copyToDolibarrStatementDir($uploadBankAccountId); + } + + // Reconcile bank entries if bank account is configured + if ($uploadBankAccountId > 0) { + $reconciledCount = $stmt->reconcileBankEntries($user, $uploadBankAccountId); + if ($reconciledCount > 0) { + setEventMessages($langs->trans("BankEntriesReconciled", $reconciledCount, $stmt->statement_number.'/'.$stmt->statement_year), null, 'mesgs'); + } + } + + $uploadedCount++; + $lastYear = $stmt->statement_year; + } else { + setEventMessages($stmt->error, null, 'errors'); + $errorCount++; + // Clean up file on DB error + if (file_exists($stmt->filepath)) { + @unlink($stmt->filepath); + } + } + } + + // Summary message + if ($uploadedCount > 0) { + if ($uploadedCount == 1) { + $msg = $langs->trans("StatementUploaded"); + } else { + $msg = $langs->trans("StatementsUploaded", $uploadedCount); + } + if ($totalLinked > 0) { + $msg .= ' | '.$langs->trans("TransactionsLinked", $totalLinked); + } + setEventMessages($msg, null, 'mesgs'); + // Redirect: for single upload use the year, for multi-upload show all + if ($uploadedCount == 1) { + header("Location: ".$_SERVER['PHP_SELF']."?year=".$lastYear); + } else { + header("Location: ".$_SERVER['PHP_SELF']."?year=0"); + } + exit; + } +} + +// Download PDF +if ($action == 'download') { + $id = GETPOSTINT('id'); + + if ($statement->fetch($id) > 0) { + $filepath = $statement->getFilePath(); + + if ($filepath && file_exists($filepath)) { + header('Content-Type: application/pdf'); + header('Content-Disposition: attachment; filename="'.basename($statement->filename).'"'); + header('Content-Length: '.filesize($filepath)); + header('Cache-Control: private'); + readfile($filepath); + exit; + } else { + setEventMessages($langs->trans("FileNotFound"), null, 'errors'); + } + } else { + setEventMessages($langs->trans("RecordNotFound"), null, 'errors'); + } +} + +// View PDF (inline) +if ($action == 'view') { + $id = GETPOSTINT('id'); + + if ($statement->fetch($id) > 0) { + $filepath = $statement->getFilePath(); + + if ($filepath && file_exists($filepath)) { + header('Content-Type: application/pdf'); + header('Content-Disposition: inline; filename="'.basename($statement->filename).'"'); + header('Content-Length: '.filesize($filepath)); + header('Cache-Control: private'); + readfile($filepath); + exit; + } else { + setEventMessages($langs->trans("FileNotFound"), null, 'errors'); + } + } else { + setEventMessages($langs->trans("RecordNotFound"), null, 'errors'); + } +} + +// Reconcile single statement +if ($action == 'reconcile') { + if (!$user->hasRight('bankimport', 'write')) { + accessforbidden(); + } + $id = GETPOSTINT('id'); + $reconcileBankAccountId = getDolGlobalInt('BANKIMPORT_BANK_ACCOUNT_ID'); + + if (empty($reconcileBankAccountId)) { + setEventMessages($langs->trans("ErrorNoBankAccountConfigured"), null, 'errors'); + } elseif ($statement->fetch($id) > 0) { + // Parse statement lines if not yet done + $existingLines = $statement->getStatementLines(); + if (is_array($existingLines) && empty($existingLines)) { + $pdfLines = $statement->parsePdfTransactions(); + if (!empty($pdfLines)) { + $statement->saveStatementLines($pdfLines); + } + } + + $reconciledCount = $statement->reconcileBankEntries($user, $reconcileBankAccountId); + if ($reconciledCount > 0) { + setEventMessages($langs->trans("BankEntriesReconciled", $reconciledCount, $statement->statement_number.'/'.$statement->statement_year), null, 'mesgs'); + } else { + setEventMessages($langs->trans("NoBankEntriesToReconcile"), null, 'warnings'); + } + } + $action = ''; +} + +// Reconcile all statements +if ($action == 'reconcileall') { + if (!$user->hasRight('bankimport', 'write')) { + accessforbidden(); + } + $reconcileBankAccountId = getDolGlobalInt('BANKIMPORT_BANK_ACCOUNT_ID'); + + if (empty($reconcileBankAccountId)) { + setEventMessages($langs->trans("ErrorNoBankAccountConfigured"), null, 'errors'); + } else { + $allStatements = $statement->fetchAll('statement_year,statement_number', 'ASC', 0, 0, array()); + $totalReconciled = 0; + $stmtCount = 0; + + if (is_array($allStatements)) { + foreach ($allStatements as $stmt) { + // Parse statement lines if not yet done + $existingLines = $stmt->getStatementLines(); + if (is_array($existingLines) && empty($existingLines)) { + $pdfLines = $stmt->parsePdfTransactions(); + if (!empty($pdfLines)) { + $stmt->saveStatementLines($pdfLines); + } + } + + $count = $stmt->reconcileBankEntries($user, $reconcileBankAccountId); + if ($count > 0) { + $totalReconciled += $count; + $stmtCount++; + } + } + } + + if ($totalReconciled > 0) { + setEventMessages($langs->trans("BankEntriesReconciledTotal", $totalReconciled, $stmtCount), null, 'mesgs'); + } else { + setEventMessages($langs->trans("NoBankEntriesToReconcile"), null, 'warnings'); + } + } + $action = ''; +} + +// Confirm a pending reconciliation match +if ($action == 'confirmreconcile') { + if (!$user->hasRight('bankimport', 'write')) { + accessforbidden(); + } + $lineId = GETPOSTINT('lineid'); + $bankId = GETPOSTINT('bankid'); + $reconcileBankAccountId = getDolGlobalInt('BANKIMPORT_BANK_ACCOUNT_ID'); + + if ($lineId > 0 && $bankId > 0 && $reconcileBankAccountId > 0) { + require_once DOL_DOCUMENT_ROOT.'/compta/bank/class/account.class.php'; + + // Get statement info from line + $sqlLine = "SELECT sl.fk_statement, s.statement_number, s.statement_year"; + $sqlLine .= " FROM ".MAIN_DB_PREFIX."bankimport_statement_line sl"; + $sqlLine .= " JOIN ".MAIN_DB_PREFIX."bankimport_statement s ON s.rowid = sl.fk_statement"; + $sqlLine .= " WHERE sl.rowid = ".((int) $lineId); + $resLine = $db->query($sqlLine); + + if ($resLine && $db->num_rows($resLine) > 0) { + $lineObj = $db->fetch_object($resLine); + $numReleve = $lineObj->statement_number.'/'.$lineObj->statement_year; + + // Reconcile the bank entry + $bankLine = new AccountLine($db); + $bankLine->fetch($bankId); + $bankLine->num_releve = $numReleve; + + $result = $bankLine->update_conciliation($user, 0, 1); + if ($result >= 0) { + // Update statement line status + $sqlUpd = "UPDATE ".MAIN_DB_PREFIX."bankimport_statement_line SET"; + $sqlUpd .= " match_status = 'reconciled'"; + $sqlUpd .= " WHERE rowid = ".((int) $lineId); + $db->query($sqlUpd); + + setEventMessages($langs->trans("ReconciliationConfirmed"), null, 'mesgs'); + } else { + setEventMessages($langs->trans("Error"), null, 'errors'); + } + } + } + $action = ''; +} + +// Delete confirmation +if ($action == 'delete' && $confirm == 'yes') { + $id = GETPOSTINT('id'); + + if ($statement->fetch($id) > 0) { + $result = $statement->delete($user); + + if ($result > 0) { + setEventMessages($langs->trans("RecordDeleted"), null, 'mesgs'); + } else { + setEventMessages($statement->error, null, 'errors'); + } + } + $action = ''; +} + +/* + * View + */ + +$form = new Form($db); + +$title = $langs->trans("PDFStatements"); +llxHeader('', $title, '', '', 0, 0, '', '', '', 'mod-bankimport page-pdfstatements'); + +print load_fiche_titre($title, '', 'bank'); + +// Reminder: check if statements are outdated +$reminderEnabled = getDolGlobalString('BANKIMPORT_REMINDER_ENABLED', '1'); +if ($reminderEnabled) { + $reminderMonths = getDolGlobalInt('BANKIMPORT_REMINDER_MONTHS') ?: 3; + $lastEndDate = $statement->getLatestStatementEndDate(); + $thresholdDate = dol_time_plus_duree(dol_now(), -$reminderMonths, 'm'); + + if ($lastEndDate === null) { + print '
'; + print img_warning().' '.$langs->trans("ReminderNoStatements"); + print '

'; + } elseif ($lastEndDate < $thresholdDate) { + $monthsAgo = (int) round((dol_now() - $lastEndDate) / (30 * 24 * 3600)); + print '
'; + print img_warning().' '.$langs->trans("ReminderOutdatedStatements", dol_print_date($lastEndDate, 'day'), $monthsAgo); + print '

'; + } +} + +// Info box +print '
'; +print ''.$langs->trans("PDFStatementsInfo").'
'; +print $langs->trans("PDFStatementsInfoDesc"); +print '
'; + +// Delete confirmation dialog +if ($action == 'delete') { + $id = GETPOSTINT('id'); + $stmt = new BankImportStatement($db); + $stmt->fetch($id); + + $formconfirm = $form->formconfirm( + $_SERVER["PHP_SELF"].'?id='.$id.'&year='.$year, + $langs->trans('DeleteStatement'), + $langs->trans('ConfirmDeleteStatement', $stmt->statement_number.'/'.$stmt->statement_year), + 'delete', + '', + 0, + 1 + ); + print $formconfirm; +} + +// Upload form +$defaultMode = getDolGlobalString('BANKIMPORT_UPLOAD_MODE') ?: 'auto'; +$uploadMode = GETPOST('upload_mode', 'alpha') ?: $defaultMode; + +print '
'; +print '
'; + +print '
'; +print ''; +print ''; + +print ''; +print ''; +print ''; +print ''; + +// Upload mode selection +print ''; +print ''; +print ''; +print ''; + +// PDF file (always visible, multiple in auto mode) +print ''; +print ''; +print ''; +print ''; + +// --- Manual fields (hidden when auto mode) --- + +// IBAN +print ''; +print ''; +print ''; +print ''; + +// Year +print ''; +print ''; +print ''; +print ''; + +// Statement number +print ''; +print ''; +print ''; +print ''; + +// Statement date +print ''; +print ''; +print ''; +print ''; + +// Period from +print ''; +print ''; +print ''; +print ''; + +// Period to +print ''; +print ''; +print ''; +print ''; + +// Opening balance +print ''; +print ''; +print ''; +print ''; + +// Closing balance +print ''; +print ''; +print ''; +print ''; + +print '
'.$langs->trans("UploadPDFStatement").'
'.$langs->trans("UploadMode").''; +print ''; +print ''; +print '
'.$langs->trans("File").''; +print ''; +print '
'.$langs->trans("MultipleFilesHint").''; +print '
'.$langs->trans("IBAN").''; +print ''; +print '
'.$langs->trans("Year").''; +$years = array(); +for ($y = (int) date('Y'); $y >= ((int) date('Y') - 10); $y--) { + $years[$y] = $y; +} +print $form->selectarray('statement_year', $years, GETPOSTISSET('statement_year') ? GETPOSTINT('statement_year') : $year, 0, 0, 0, '', 0, 0, 0, '', 'minwidth100'); +print '
'.$langs->trans("StatementNumber").''; +$nextNum = $statement->getNextStatementNumber($year); +print ''; +print '
'.$langs->trans("StatementDate").''; +print $form->selectDate(GETPOSTISSET('statement_dateday') ? dol_mktime(0, 0, 0, GETPOSTINT('statement_datemonth'), GETPOSTINT('statement_dateday'), GETPOSTINT('statement_dateyear')) : -1, 'statement_date', 0, 0, 1, '', 1, 0); +print '
'.$langs->trans("DateFrom").''; +print $form->selectDate(GETPOSTISSET('date_fromday') ? dol_mktime(0, 0, 0, GETPOSTINT('date_frommonth'), GETPOSTINT('date_fromday'), GETPOSTINT('date_fromyear')) : -1, 'date_from', 0, 0, 1, '', 1, 0); +print '
'.$langs->trans("DateTo").''; +print $form->selectDate(GETPOSTISSET('date_today') ? dol_mktime(0, 0, 0, GETPOSTINT('date_tomonth'), GETPOSTINT('date_today'), GETPOSTINT('date_toyear')) : -1, 'date_to', 0, 0, 1, '', 1, 0); +print '
'.$langs->trans("OpeningBalance").''; +print ''; +print ' EUR'; +print '
'.$langs->trans("ClosingBalance").''; +print ''; +print ' EUR'; +print '
'; + +print '
'; +print ''; +print '
'; + +print '
'; + +// JavaScript for toggling upload modes +print ''; + +print '
'; // fichehalfleft +print '
'; // fichecenter + +print '

'; + +// Year filter for list - only show years that have statements +$yearsFilter = array(0 => $langs->trans("AllStatements")); +$availableYears = $statement->getAvailableYears(); +foreach ($availableYears as $yKey => $yVal) { + $yearsFilter[$yKey] = $yVal; +} +// If current year not in list, add it +if (!isset($yearsFilter[(int) date('Y')])) { + $yearsFilter[(int) date('Y')] = (string) date('Y'); + krsort($yearsFilter); +} +print '
'; +print '
'; +print ''.$langs->trans("Year").': '; +print $form->selectarray('year', $yearsFilter, $year, 0, 0, 0, '', 0, 0, 0, '', 'minwidth100'); +print ' '; +print '
'; +print '
'; + +// Reconcile All button +$reconcileBankAccountId = getDolGlobalInt('BANKIMPORT_BANK_ACCOUNT_ID'); +if (!empty($reconcileBankAccountId)) { + print ''; +} else { + print '
'; + print img_warning().' '.$langs->trans("NoBankAccountConfigured"); + print ' '.$langs->trans("GoToSetup").''; + print '
'; +} + +// List of existing PDF statements +print '
'; +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; + +$filter = array(); +if ($year > 0) { + $filter['year'] = $year; +} +$records = $statement->fetchAll('statement_year,statement_number', 'DESC', 100, 0, $filter); + +if (is_array($records) && count($records) > 0) { + foreach ($records as $obj) { + print ''; + + // Statement number + print ''; + + // IBAN + print ''; + + // Statement date + print ''; + + // Period + print ''; + + // Opening balance + print ''; + + // Closing balance + print ''; + + // Size + print ''; + + // Creation date + print ''; + + // Actions + print ''; + + print ''; + } +} else { + print ''; +} + +print '
'.$langs->trans("StatementNumber").''.$langs->trans("IBAN").''.$langs->trans("StatementDate").''.$langs->trans("Period").''.$langs->trans("OpeningBalance").''.$langs->trans("ClosingBalance").''.$langs->trans("Size").''.$langs->trans("DateCreation").''.$langs->trans("Actions").'
'; + print ''.dol_escape_htmltag($obj->statement_number).'/'.$obj->statement_year; + print ''; + if ($obj->iban) { + print dol_escape_htmltag($obj->iban); + } else { + print '-'; + } + print ''; + if ($obj->statement_date) { + print dol_print_date($obj->statement_date, 'day'); + } else { + print '-'; + } + print ''; + if ($obj->date_from && $obj->date_to) { + print dol_print_date($obj->date_from, 'day').' - '.dol_print_date($obj->date_to, 'day'); + } elseif ($obj->date_from) { + print $langs->trans("From").' '.dol_print_date($obj->date_from, 'day'); + } elseif ($obj->date_to) { + print $langs->trans("To").' '.dol_print_date($obj->date_to, 'day'); + } else { + print '-'; + } + print ''; + if ($obj->opening_balance !== null) { + $color = $obj->opening_balance >= 0 ? '' : 'color: red;'; + print ''.price($obj->opening_balance, 0, $langs, 1, -1, 2, 'EUR').''; + } else { + print '-'; + } + print ''; + if ($obj->closing_balance !== null) { + $color = $obj->closing_balance >= 0 ? '' : 'color: red;'; + print ''.price($obj->closing_balance, 0, $langs, 1, -1, 2, 'EUR').''; + } else { + print '-'; + } + print ''; + if ($obj->filesize) { + print dol_print_size($obj->filesize, 1); + } else { + print '-'; + } + print ''; + print dol_print_date($obj->datec, 'day'); + print ''; + if ($obj->filepath && file_exists($obj->filepath)) { + // View (inline) + print 'id.'&token='.newToken().'" target="_blank" title="'.$langs->trans("View").'">'; + print img_picto($langs->trans("View"), 'eye'); + print ''; + + // Download + print 'id.'&token='.newToken().'" title="'.$langs->trans("Download").'">'; + print img_picto($langs->trans("Download"), 'download'); + print ''; + } + + // Reconcile + if (!empty($reconcileBankAccountId) && $obj->date_from && $obj->date_to) { + print 'id.'&year='.$year.'&token='.newToken().'" title="'.$langs->trans("ReconcileStatement").'">'; + print img_picto($langs->trans("ReconcileStatement"), 'bank'); + print ''; + } + + // Delete + print 'id.'&year='.$year.'&token='.newToken().'" title="'.$langs->trans("Delete").'">'; + print img_picto($langs->trans("Delete"), 'delete'); + print ''; + + print '
'; + print $langs->trans("NoPDFStatementsFound"); + print '
'; +print '
'; + +// Pending review matches +$sqlPending = "SELECT sl.rowid as line_id, sl.fk_statement, sl.line_number, sl.date_booking, sl.amount as stmt_amount,"; +$sqlPending .= " sl.name as stmt_name, sl.fk_bank,"; +$sqlPending .= " b.rowid as bank_id, b.datev, b.amount as bank_amount, b.label as bank_label,"; +$sqlPending .= " s.statement_number, s.statement_year"; +$sqlPending .= " FROM ".MAIN_DB_PREFIX."bankimport_statement_line sl"; +$sqlPending .= " JOIN ".MAIN_DB_PREFIX."bankimport_statement s ON s.rowid = sl.fk_statement"; +$sqlPending .= " JOIN ".MAIN_DB_PREFIX."bank b ON b.rowid = sl.fk_bank"; +$sqlPending .= " WHERE sl.match_status = 'pending_review'"; +$sqlPending .= " AND sl.entity = ".((int) $conf->entity); +$sqlPending .= " ORDER BY s.statement_year, s.statement_number, sl.line_number"; + +$resPending = $db->query($sqlPending); +if ($resPending && $db->num_rows($resPending) > 0) { + print '
'; + print '
'; + print ''; + print ''; + print ''; + print ''; + + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + + while ($pendObj = $db->fetch_object($resPending)) { + $diff = abs(abs((float) $pendObj->stmt_amount) - abs((float) $pendObj->bank_amount)); + $diffColor = ($diff > 10) ? 'color: red; font-weight: bold;' : 'color: #e68a00;'; + + print ''; + + // Statement number + print ''; + + // Booking date + print ''; + + // Name + print ''; + + // Amount from PDF statement + print ''; + + // Amount from Dolibarr bank + print ''; + + // Difference + print ''; + + // Bank entry link + print ''; + + // Action: confirm button + print ''; + + print ''; + } + + print '
'; + print img_warning().' '.$langs->trans("PendingReconciliationMatches").''; + print ' - '.$langs->trans("PendingReconciliationMatchesDesc"); + print '
'.$langs->trans("StatementNumber").''.$langs->trans("BookingDate").''.$langs->trans("Name").''.$langs->trans("AmountStatement").''.$langs->trans("AmountDolibarr").''.$langs->trans("Difference").''.$langs->trans("BankEntry").''.$langs->trans("Action").'
'.$pendObj->statement_number.'/'.$pendObj->statement_year.''.dol_print_date($db->jdate($pendObj->date_booking), 'day').''.dol_escape_htmltag($pendObj->stmt_name).''; + $stmtColor = $pendObj->stmt_amount >= 0 ? '' : 'color: red;'; + print ''.price($pendObj->stmt_amount, 0, $langs, 1, -1, 2, 'EUR').''; + print ''; + $bankColor = $pendObj->bank_amount >= 0 ? '' : 'color: red;'; + print ''.price($pendObj->bank_amount, 0, $langs, 1, -1, 2, 'EUR').''; + print ''; + print ''.price($diff, 0, $langs, 1, -1, 2, 'EUR').''; + print ''; + print '#'.$pendObj->bank_id.''; + print ''; + print 'line_id.'&bankid='.$pendObj->bank_id.'&year='.$year.'&token='.newToken().'">'; + print $langs->trans("Confirm"); + print ''; + print '
'; + print '
'; +} +$db->free($resPending); + +// Statistics +$totalCount = $statement->fetchAll('', '', 0, 0, array(), 'count'); +$yearCount = is_array($records) ? count($records) : 0; + +print '
'; +if ($year > 0) { + print $langs->trans("Total").': '.$yearCount.' '.$langs->trans("StatementsInYear", $year); + print ' | '.$langs->trans("AllStatements").': '.$totalCount.''; +} else { + print $langs->trans("Total").': '.$totalCount.' '.$langs->trans("AllStatements"); +} +print '
'; + +llxFooter(); +$db->close(); diff --git a/sql/dolibarr_allversions.sql b/sql/dolibarr_allversions.sql new file mode 100755 index 0000000..e69da3d --- /dev/null +++ b/sql/dolibarr_allversions.sql @@ -0,0 +1,34 @@ +-- +-- Script run when an upgrade of Dolibarr is done. Whatever is the Dolibarr version. +-- + +-- v1.1: Add fk_statement to transaction table +ALTER TABLE llx_bankimport_transaction ADD COLUMN fk_statement INTEGER AFTER fk_societe; +ALTER TABLE llx_bankimport_transaction ADD INDEX idx_bankimport_transaction_fk_statement (fk_statement); + +-- v1.3: Add statement_line table for parsed PDF transactions +CREATE TABLE IF NOT EXISTS llx_bankimport_statement_line ( + rowid INTEGER AUTO_INCREMENT PRIMARY KEY, + fk_statement INTEGER NOT NULL, + entity INTEGER DEFAULT 1 NOT NULL, + line_number INTEGER DEFAULT 0, + date_booking DATE NOT NULL, + date_value DATE, + transaction_type VARCHAR(100), + amount DOUBLE(24,8) NOT NULL, + currency VARCHAR(3) DEFAULT 'EUR', + name VARCHAR(255), + description TEXT, + fk_bank INTEGER, + datec DATETIME, + tms TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB; +ALTER TABLE llx_bankimport_statement_line ADD INDEX idx_bankimport_stmtline_fk_statement (fk_statement); +ALTER TABLE llx_bankimport_statement_line ADD INDEX idx_bankimport_stmtline_entity (entity); +ALTER TABLE llx_bankimport_statement_line ADD INDEX idx_bankimport_stmtline_date_booking (date_booking); +ALTER TABLE llx_bankimport_statement_line ADD INDEX idx_bankimport_stmtline_amount (amount); +ALTER TABLE llx_bankimport_statement_line ADD INDEX idx_bankimport_stmtline_fk_bank (fk_bank); +ALTER TABLE llx_bankimport_statement_line ADD UNIQUE INDEX uk_bankimport_stmtline (fk_statement, line_number, entity); + +-- v1.4: Add match_status to statement_line for approval workflow +ALTER TABLE llx_bankimport_statement_line ADD COLUMN match_status VARCHAR(20) DEFAULT NULL AFTER fk_bank; diff --git a/sql/llx_bankimport_statement.key.sql b/sql/llx_bankimport_statement.key.sql new file mode 100755 index 0000000..a9f9e82 --- /dev/null +++ b/sql/llx_bankimport_statement.key.sql @@ -0,0 +1,14 @@ +-- Copyright (C) 2026 Eduard Wisch +-- +-- This program is free software: you can redistribute it and/or modify +-- it under the terms of the GNU General Public License as published by +-- the Free Software Foundation, either version 3 of the License, or +-- (at your option) any later version. + +-- Indexes for llx_bankimport_statement + +ALTER TABLE llx_bankimport_statement ADD UNIQUE INDEX uk_bankimport_statement (statement_number, statement_year, iban, entity); +ALTER TABLE llx_bankimport_statement ADD INDEX idx_bankimport_statement_entity (entity); +ALTER TABLE llx_bankimport_statement ADD INDEX idx_bankimport_statement_iban (iban); +ALTER TABLE llx_bankimport_statement ADD INDEX idx_bankimport_statement_year (statement_year); +ALTER TABLE llx_bankimport_statement ADD INDEX idx_bankimport_statement_date (statement_date); diff --git a/sql/llx_bankimport_statement.sql b/sql/llx_bankimport_statement.sql new file mode 100755 index 0000000..59a78bf --- /dev/null +++ b/sql/llx_bankimport_statement.sql @@ -0,0 +1,30 @@ +-- Copyright (C) 2026 Eduard Wisch +-- +-- This program is free software: you can redistribute it and/or modify +-- it under the terms of the GNU General Public License as published by +-- the Free Software Foundation, either version 3 of the License, or +-- (at your option) any later version. + +-- Table for PDF bank statements + +CREATE TABLE llx_bankimport_statement ( + rowid INTEGER AUTO_INCREMENT PRIMARY KEY, + entity INTEGER DEFAULT 1 NOT NULL, + iban VARCHAR(34), + statement_number VARCHAR(20) NOT NULL, + statement_year INTEGER NOT NULL, + statement_date DATE, + date_from DATE, + date_to DATE, + opening_balance DOUBLE(24,8), + closing_balance DOUBLE(24,8), + currency VARCHAR(3) DEFAULT 'EUR', + filename VARCHAR(255), + filepath VARCHAR(512), + filesize INTEGER, + import_key VARCHAR(50), + datec DATETIME, + fk_user_creat INTEGER, + note_private TEXT, + note_public TEXT +) ENGINE=InnoDB; diff --git a/sql/llx_bankimport_statement_line.key.sql b/sql/llx_bankimport_statement_line.key.sql new file mode 100755 index 0000000..aa0516e --- /dev/null +++ b/sql/llx_bankimport_statement_line.key.sql @@ -0,0 +1,15 @@ +-- Copyright (C) 2026 Eduard Wisch +-- +-- This program is free software: you can redistribute it and/or modify +-- it under the terms of the GNU General Public License as published by +-- the Free Software Foundation, either version 3 of the License, or +-- (at your option) any later version. + +-- Indexes for llx_bankimport_statement_line + +ALTER TABLE llx_bankimport_statement_line ADD INDEX idx_bankimport_stmtline_fk_statement (fk_statement); +ALTER TABLE llx_bankimport_statement_line ADD INDEX idx_bankimport_stmtline_entity (entity); +ALTER TABLE llx_bankimport_statement_line ADD INDEX idx_bankimport_stmtline_date_booking (date_booking); +ALTER TABLE llx_bankimport_statement_line ADD INDEX idx_bankimport_stmtline_amount (amount); +ALTER TABLE llx_bankimport_statement_line ADD INDEX idx_bankimport_stmtline_fk_bank (fk_bank); +ALTER TABLE llx_bankimport_statement_line ADD UNIQUE INDEX uk_bankimport_stmtline (fk_statement, line_number, entity); diff --git a/sql/llx_bankimport_statement_line.sql b/sql/llx_bankimport_statement_line.sql new file mode 100755 index 0000000..cd49e21 --- /dev/null +++ b/sql/llx_bankimport_statement_line.sql @@ -0,0 +1,34 @@ +-- Copyright (C) 2026 Eduard Wisch +-- +-- This program is free software: you can redistribute it and/or modify +-- it under the terms of the GNU General Public License as published by +-- the Free Software Foundation, either version 3 of the License, or +-- (at your option) any later version. + +-- Table for individual transaction lines parsed from PDF bank statements + +CREATE TABLE llx_bankimport_statement_line ( + rowid INTEGER AUTO_INCREMENT PRIMARY KEY, + fk_statement INTEGER NOT NULL, -- Link to llx_bankimport_statement + entity INTEGER DEFAULT 1 NOT NULL, + line_number INTEGER DEFAULT 0, -- Position within statement (1, 2, 3...) + + -- Transaction data from PDF + date_booking DATE NOT NULL, -- Buchungstag (Bu-Tag) + date_value DATE, -- Wertstellungstag (Wert) + transaction_type VARCHAR(100), -- Vorgangsart (e.g. Überweisungsgutschr., Basislastschrift) + amount DOUBLE(24,8) NOT NULL, -- Amount (positive = credit, negative = debit) + currency VARCHAR(3) DEFAULT 'EUR', + + -- Counterparty and description + name VARCHAR(255), -- Counterparty name (first detail line) + description TEXT, -- Full description (all detail lines) + + -- Matching + fk_bank INTEGER, -- Link to llx_bank when reconciled + match_status VARCHAR(20) DEFAULT NULL, -- NULL=unmatched, reconciled=auto, pending_review=needs approval + + -- Timestamps + datec DATETIME, + tms TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB; diff --git a/sql/llx_bankimport_transaction.key.sql b/sql/llx_bankimport_transaction.key.sql new file mode 100755 index 0000000..4350b31 --- /dev/null +++ b/sql/llx_bankimport_transaction.key.sql @@ -0,0 +1,28 @@ +-- Copyright (C) 2026 Eduard Wisch +-- +-- This program is free software: you can redistribute it and/or modify +-- it under the terms of the GNU General Public License as published by +-- the Free Software Foundation, either version 3 of the License, or +-- (at your option) any later version. + +-- Indexes for llx_bankimport_transaction + +ALTER TABLE llx_bankimport_transaction ADD UNIQUE INDEX uk_bankimport_transaction_ref (ref, entity); +ALTER TABLE llx_bankimport_transaction ADD INDEX idx_bankimport_transaction_entity (entity); +ALTER TABLE llx_bankimport_transaction ADD INDEX idx_bankimport_transaction_iban (iban); +ALTER TABLE llx_bankimport_transaction ADD INDEX idx_bankimport_transaction_date_trans (date_trans); +ALTER TABLE llx_bankimport_transaction ADD INDEX idx_bankimport_transaction_name (name); +ALTER TABLE llx_bankimport_transaction ADD INDEX idx_bankimport_transaction_amount (amount); +ALTER TABLE llx_bankimport_transaction ADD INDEX idx_bankimport_transaction_status (status); +ALTER TABLE llx_bankimport_transaction ADD INDEX idx_bankimport_transaction_fk_bank (fk_bank); +ALTER TABLE llx_bankimport_transaction ADD INDEX idx_bankimport_transaction_fk_facture (fk_facture); +ALTER TABLE llx_bankimport_transaction ADD INDEX idx_bankimport_transaction_fk_facture_fourn (fk_facture_fourn); +ALTER TABLE llx_bankimport_transaction ADD INDEX idx_bankimport_transaction_fk_societe (fk_societe); +ALTER TABLE llx_bankimport_transaction ADD INDEX idx_bankimport_transaction_import_key (import_key); +ALTER TABLE llx_bankimport_transaction ADD INDEX idx_bankimport_transaction_fk_statement (fk_statement); + +-- Foreign keys (optional, depends on your setup) +-- ALTER TABLE llx_bankimport_transaction ADD CONSTRAINT fk_bankimport_transaction_fk_bank FOREIGN KEY (fk_bank) REFERENCES llx_bank(rowid); +-- ALTER TABLE llx_bankimport_transaction ADD CONSTRAINT fk_bankimport_transaction_fk_facture FOREIGN KEY (fk_facture) REFERENCES llx_facture(rowid); +-- ALTER TABLE llx_bankimport_transaction ADD CONSTRAINT fk_bankimport_transaction_fk_facture_fourn FOREIGN KEY (fk_facture_fourn) REFERENCES llx_facture_fourn(rowid); +-- ALTER TABLE llx_bankimport_transaction ADD CONSTRAINT fk_bankimport_transaction_fk_societe FOREIGN KEY (fk_societe) REFERENCES llx_societe(rowid); diff --git a/sql/llx_bankimport_transaction.sql b/sql/llx_bankimport_transaction.sql new file mode 100755 index 0000000..c3ea7b3 --- /dev/null +++ b/sql/llx_bankimport_transaction.sql @@ -0,0 +1,62 @@ +-- Copyright (C) 2026 Eduard Wisch +-- +-- This program is free software: you can redistribute it and/or modify +-- it under the terms of the GNU General Public License as published by +-- the Free Software Foundation, either version 3 of the License, or +-- (at your option) any later version. + +CREATE TABLE llx_bankimport_transaction ( + rowid INTEGER AUTO_INCREMENT PRIMARY KEY, + ref VARCHAR(128) NOT NULL, -- Unique reference (hash of transaction) + entity INTEGER DEFAULT 1 NOT NULL, -- Multi-company id + + -- Bank account info + iban VARCHAR(34), -- IBAN of the account + bic VARCHAR(11), -- BIC of the bank + + -- Transaction data + datec DATETIME, -- Creation date in system + tms TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + date_trans DATE NOT NULL, -- Transaction date (booking date) + date_value DATE, -- Value date + + -- Counterparty + name VARCHAR(255), -- Counterparty name + counterparty_iban VARCHAR(34), -- Counterparty IBAN + counterparty_bic VARCHAR(11), -- Counterparty BIC + + -- Amount + amount DOUBLE(24,8) NOT NULL, -- Amount (positive = credit, negative = debit) + currency VARCHAR(3) DEFAULT 'EUR', -- Currency code + + -- Description + label VARCHAR(255), -- Short label/booking text + description TEXT, -- Full description/reference + end_to_end_id VARCHAR(128), -- End-to-end ID from SEPA + mandate_id VARCHAR(128), -- Mandate ID for direct debits + + -- Matching with Dolibarr + fk_bank INTEGER, -- Link to llx_bank (bank line) + fk_facture INTEGER, -- Link to llx_facture (customer invoice) + fk_facture_fourn INTEGER, -- Link to llx_facture_fourn (supplier invoice) + fk_paiement INTEGER, -- Link to llx_paiement + fk_paiementfourn INTEGER, -- Link to llx_paiementfourn + fk_salary INTEGER, -- Link to llx_salary (salary payment) + fk_don INTEGER, -- Link to llx_don (donation) + fk_loan INTEGER, -- Link to llx_loan (loan payment) + fk_societe INTEGER, -- Link to llx_societe (third party) + fk_statement INTEGER, -- Link to llx_bankimport_statement (PDF statement) + + -- Status + status SMALLINT DEFAULT 0, -- 0=new, 1=matched, 2=reconciled, 9=ignored + import_key VARCHAR(64), -- Import batch key + + -- User tracking + fk_user_creat INTEGER, -- User who imported + fk_user_modif INTEGER, -- User who last modified + fk_user_match INTEGER, -- User who matched/reconciled + date_match DATETIME, -- Date of matching + + note_private TEXT, -- Private notes + note_public TEXT -- Public notes +) ENGINE=InnoDB; diff --git a/statements.php b/statements.php new file mode 100755 index 0000000..a545f55 --- /dev/null +++ b/statements.php @@ -0,0 +1,708 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +/** + * \file bankimport/statements.php + * \ingroup bankimport + * \brief Page to fetch and display bank statements via FinTS + */ + +// Load Dolibarr environment +$res = 0; +if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) { + $res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php"; +} +$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME']; +$tmp2 = realpath(__FILE__); +$i = strlen($tmp) - 1; +$j = strlen($tmp2) - 1; +while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) { + $i--; + $j--; +} +if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) { + $res = @include substr($tmp, 0, ($i + 1))."/main.inc.php"; +} +if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) { + $res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php"; +} +if (!$res && file_exists("../main.inc.php")) { + $res = @include "../main.inc.php"; +} +if (!$res && file_exists("../../main.inc.php")) { + $res = @include "../../main.inc.php"; +} +if (!$res) { + die("Include of main fails"); +} + +require_once DOL_DOCUMENT_ROOT.'/core/lib/date.lib.php'; +require_once DOL_DOCUMENT_ROOT.'/core/lib/admin.lib.php'; +require_once DOL_DOCUMENT_ROOT.'/core/class/html.formother.class.php'; +dol_include_once('/bankimport/class/fints.class.php'); +dol_include_once('/bankimport/class/banktransaction.class.php'); +dol_include_once('/bankimport/class/bankimportcron.class.php'); +dol_include_once('/bankimport/lib/bankimport.lib.php'); + +/** + * @var Conf $conf + * @var DoliDB $db + * @var Translate $langs + * @var User $user + */ + +$langs->loadLangs(array("bankimport@bankimport", "banks")); + +$action = GETPOST('action', 'aZ09'); + +// Security check +if (!$user->hasRight('bankimport', 'write')) { + accessforbidden(); +} + +// Date filters +$date_fromday = GETPOSTINT('date_fromday'); +$date_frommonth = GETPOSTINT('date_frommonth'); +$date_fromyear = GETPOSTINT('date_fromyear'); +$date_today = GETPOSTINT('date_today'); +$date_tomonth = GETPOSTINT('date_tomonth'); +$date_toyear = GETPOSTINT('date_toyear'); + +$dateFrom = 0; +$dateTo = 0; + +if ($date_fromday && $date_frommonth && $date_fromyear) { + $dateFrom = dol_mktime(0, 0, 0, $date_frommonth, $date_fromday, $date_fromyear); +} +if ($date_today && $date_tomonth && $date_toyear) { + $dateTo = dol_mktime(23, 59, 59, $date_tomonth, $date_today, $date_toyear); +} + +// Default: last 30 days +if (empty($dateFrom)) { + $dateFrom = dol_time_plus_duree(dol_now(), -30, 'd'); +} +if (empty($dateTo)) { + $dateTo = dol_now(); +} + +/* + * Actions + */ + +$transactions = array(); +$balance = array(); +$error = 0; +$tanRequired = false; +$tanChallenge = ''; +$importResult = null; + +// Import transactions to database +if ($action == 'import' && !empty($_SESSION['fints_transactions'])) { + $fints = new BankImportFinTS($db); + $iban = $fints->getIban(); + + $transImporter = new BankImportTransaction($db); + $importResult = $transImporter->importFromFinTS($_SESSION['fints_transactions'], $iban, $user); + + if ($importResult['imported'] > 0) { + setEventMessages($langs->trans("TransactionsImported", $importResult['imported']), null, 'mesgs'); + } + if ($importResult['skipped'] > 0) { + setEventMessages($langs->trans("TransactionsSkipped", $importResult['skipped']), null, 'warnings'); + } + if (!empty($importResult['errors'])) { + setEventMessages(implode('
', $importResult['errors']), null, 'errors'); + } + + // Clear session after import + unset($_SESSION['fints_transactions']); + unset($_SESSION['fints_balance']); +} + +// Resume pending cron TAN +if ($action == 'resumecron') { + $cronJob = new BankImportCron($db); + $result = $cronJob->resumePendingAction(); + + if ($result > 0 || $result == 0) { + if ($result > 0) { + setEventMessages($langs->trans("TANConfirmedImportComplete"), null, 'mesgs'); + } else { + setEventMessages($langs->trans("WaitingForSecureGoConfirmation"), null, 'warnings'); + } + } else { + setEventMessages($langs->trans("TANCheckFailed").': '.$cronJob->error, null, 'errors'); + } +} + +if ($action == 'fetch') { + $fints = new BankImportFinTS($db); + + if (!$fints->isConfigured()) { + setEventMessages($langs->trans("FinTSNotConfigured"), null, 'errors'); + $error++; + } elseif (!$fints->isLibraryAvailable()) { + setEventMessages($langs->trans("FinTSLibraryNotFound"), null, 'errors'); + $error++; + } else { + // Login first + $loginResult = $fints->login(); + + if ($loginResult < 0) { + setEventMessages($langs->trans("LoginFailed").': '.$fints->error, null, 'errors'); + $error++; + } elseif ($loginResult == 0) { + // TAN required + $tanRequired = true; + $tanChallenge = $fints->tanChallenge; + + // Check if decoupled (SecureGo Plus) + if ($fints->selectedTanMode && $fints->selectedTanMode->isDecoupled()) { + setEventMessages($langs->trans("SecureGoPlusConfirmRequired"), null, 'warnings'); + // Store state in session for polling + $_SESSION['fints_state'] = $fints->persist(); + $_SESSION['fints_action'] = 'login'; + } else { + setEventMessages($langs->trans("TANRequired").': '.$tanChallenge, null, 'warnings'); + } + } else { + // Login successful - log bank parameters for diagnostics + $bankParams = $fints->getBankParameters(); + dol_syslog("BankImport: Bank parameters: ".json_encode($bankParams), LOG_DEBUG); + + // Check what statement methods are supported + $hikazsVersions = $bankParams['HIKAZS'] ?? array(); + $hicazsVersions = $bankParams['HICAZS'] ?? array(); + $hiekaVersions = $bankParams['HIEKAS'] ?? array(); + dol_syslog("BankImport: HIKAZS versions: ".implode(',', $hikazsVersions)." | HICAZS: ".implode(',', $hicazsVersions)." | HIEKAS: ".implode(',', $hiekaVersions), LOG_DEBUG); + + // Fetch statements + $result = $fints->fetchStatements($dateFrom, $dateTo); + + if ($result === 0) { + // TAN required for statements + $tanRequired = true; + $tanChallenge = $fints->tanChallenge; + // Store state in session for polling + $_SESSION['fints_state'] = $fints->persist(); + $_SESSION['fints_pending_action'] = serialize($fints->getPendingAction()); + $_SESSION['fints_action'] = 'statements'; + $_SESSION['fints_datefrom'] = $dateFrom; + $_SESSION['fints_dateto'] = $dateTo; + setEventMessages($langs->trans("SecureGoPlusConfirmRequired"), null, 'warnings'); + } elseif ($result < 0) { + setEventMessages($langs->trans("FetchFailed").': '.$fints->error, null, 'errors'); + // Show supported versions for diagnostics + if (!empty($hikazsVersions) || !empty($hicazsVersions)) { + $diagMsg = 'Bank unterstützt: HIKAZS v'.implode(',v', $hikazsVersions); + if (!empty($hicazsVersions)) { + $diagMsg .= ' | HICAZS v'.implode(',v', $hicazsVersions); + } + setEventMessages($diagMsg, null, 'warnings'); + } + $error++; + } elseif (is_array($result)) { + $transactions = $result['transactions'] ?? array(); + $balance = $result['balance'] ?? array(); + $isPartial = $result['partial'] ?? false; + $infoMsg = $result['info'] ?? ''; + + if (empty($transactions)) { + setEventMessages($langs->trans("NoTransactionsFound"), null, 'warnings'); + } else { + setEventMessages($langs->trans("TransactionsFound", count($transactions)), null, 'mesgs'); + // Store in session for import + $_SESSION['fints_transactions'] = $transactions; + $_SESSION['fints_balance'] = $balance; + + // Show info about partial results (older data not available) + if ($isPartial && !empty($infoMsg)) { + setEventMessages($infoMsg, null, 'warnings'); + } + } + + // Save session state for cronjob before closing + $cronState = $fints->persist(); + if (!empty($cronState)) { + dolibarr_set_const($db, 'BANKIMPORT_CRON_STATE', $cronState, 'chaine', 0, '', $conf->entity); + dol_syslog("BankImport: Saved session state for cronjob", LOG_DEBUG); + } + + // Only close on success + $fints->close(); + } + // NOTE: Don't call close() when TAN is required ($result === 0) + // The connection must stay open for checkDecoupledTan() + } + } +} + +// Check decoupled TAN status (SecureGo Plus polling) +if ($action == 'checktan') { + if (!empty($_SESSION['fints_state']) && !empty($_SESSION['fints_pending_action'])) { + $fints = new BankImportFinTS($db); + $fints->restore($_SESSION['fints_state']); + $fints->setPendingAction(unserialize($_SESSION['fints_pending_action'])); + + $result = $fints->checkDecoupledTan(); + + if ($result > 0) { + // TAN confirmed + $savedAction = $_SESSION['fints_action'] ?? 'statements'; + $savedDateFrom = $_SESSION['fints_datefrom'] ?? $dateFrom; + $savedDateTo = $_SESSION['fints_dateto'] ?? $dateTo; + + unset($_SESSION['fints_state']); + unset($_SESSION['fints_pending_action']); + unset($_SESSION['fints_action']); + unset($_SESSION['fints_datefrom']); + unset($_SESSION['fints_dateto']); + + // Fetch statements after TAN confirmation + $result = $fints->fetchStatements($savedDateFrom, $savedDateTo); + + if ($result === 0) { + // Another TAN required (unlikely but possible) + $_SESSION['fints_state'] = $fints->persist(); + $_SESSION['fints_action'] = 'statements'; + $_SESSION['fints_datefrom'] = $savedDateFrom; + $_SESSION['fints_dateto'] = $savedDateTo; + setEventMessages($langs->trans("SecureGoPlusConfirmRequired"), null, 'warnings'); + } elseif (is_array($result)) { + $transactions = $result['transactions'] ?? array(); + $balance = $result['balance'] ?? array(); + setEventMessages($langs->trans("TransactionsFound", count($transactions)), null, 'mesgs'); + + // Store transactions for import + $_SESSION['fints_transactions'] = $transactions; + $_SESSION['fints_balance'] = $balance; + + // Save session state for cronjob before closing + $cronState = $fints->persist(); + if (!empty($cronState)) { + dolibarr_set_const($db, 'BANKIMPORT_CRON_STATE', $cronState, 'chaine', 0, '', $conf->entity); + dol_syslog("BankImport: Saved session state for cronjob after TAN confirmation", LOG_DEBUG); + } + + $fints->close(); + } else { + setEventMessages($langs->trans("FetchFailed").': '.$fints->error, null, 'errors'); + } + } elseif ($result == 0) { + // Still waiting + setEventMessages($langs->trans("WaitingForSecureGoConfirmation"), null, 'warnings'); + $_SESSION['fints_state'] = $fints->persist(); + } else { + setEventMessages($langs->trans("TANCheckFailed").': '.$fints->error, null, 'errors'); + unset($_SESSION['fints_state']); + } + } else { + setEventMessages("Keine aktive Session. Bitte erneut abrufen.", null, 'errors'); + unset($_SESSION['fints_state']); + unset($_SESSION['fints_pending_action']); + } +} + +/* + * View + */ + +$form = new Form($db); + +$title = $langs->trans("BankStatements"); +llxHeader('', $title, '', '', 0, 0, '', '', '', 'mod-bankimport page-statements'); + +print load_fiche_titre($title, '', 'bank'); + +// Check configuration +$fints = new BankImportFinTS($db); +if (!$fints->isConfigured()) { + print '
'; + print $langs->trans("FinTSNotConfigured"); + print ' '.$langs->trans("GoToSetup").''; + print '
'; + llxFooter(); + $db->close(); + exit; +} + +// Check for pending cron TAN notification +$cronNotification = BankImportCron::getNotification(); +if ($cronNotification !== null) { + $notifType = $cronNotification['type']; + $notifDate = $cronNotification['date']; + + if ($notifType === 'tan_required') { + print '
'; + print ''.$langs->trans("AutoImportTANRequired").'
'; + print $langs->trans("AutoImportTANRequiredDesc"); + print ' '.$langs->trans("CheckSecureGoStatus").''; + print '

'; + } elseif (in_array($notifType, array('login_error', 'fetch_error', 'config_error', 'error'))) { + print '
'; + print ''.$langs->trans("AutoImportError").'
'; + print $langs->trans("AutoImportErrorDesc", dol_print_date($notifDate, 'dayhour')); + print '

'; + } +} + +// Check for old data warning +$lastFetch = getDolGlobalInt('BANKIMPORT_LAST_FETCH'); +if ($lastFetch > 0 && (time() - $lastFetch) > 14 * 86400) { + print '
'; + print $langs->trans("LastFetchWarning", dol_print_date($lastFetch, 'day')); + print '

'; +} + +if (!$fints->isLibraryAvailable()) { + print '
'; + print $langs->trans("FinTSLibraryNotFound"); + print '
cd '.dirname(__FILE__).' && composer install'; + print '
'; + llxFooter(); + $db->close(); + exit; +} + +// Filter form +print '
'; +print ''; +print ''; + +print '
'; +print ''; + +// IBAN display +print ''; +print ''; +print ''; +print ''; + +// Date from +print ''; +print ''; +print ''; +print ''; + +// Date to +print ''; +print ''; +print ''; +print ''; + +print '
'.$langs->trans("Account").''.dol_escape_htmltag($fints->getIban()).'
'.$langs->trans("DateFrom").''; +print $form->selectDate($dateFrom, 'date_from', 0, 0, 0, '', 1, 1); +print '
'.$langs->trans("DateTo").''; +print $form->selectDate($dateTo, 'date_to', 0, 0, 0, '', 1, 1); +print '
'; +print '
'; + +print '
'; +print ''; + +// SecureGo Plus polling button +if (!empty($_SESSION['fints_state'])) { + print ' '.$langs->trans("CheckSecureGoStatus").''; +} +print '
'; + +print '
'; + +// JavaScript for automatic TAN polling +if (!empty($_SESSION['fints_state'])) { + print ' + + + + '; +} + +print '
'; +print '
'; + +// Display account balance from bank +if (!empty($balance)) { + print '
'; + print '
'; + print ''.$langs->trans("AccountBalance").': '; + $balColor = $balance['amount'] >= 0 ? 'green' : 'red'; + print ''.price($balance['amount'], 0, $langs, 1, -1, 2, $balance['currency']).''; + print ' ('.$langs->trans("AsOf").' '.dol_print_date(strtotime($balance['date']), 'day').')'; + print '
'; +} + +// Display transactions +if (!empty($transactions)) { + print '
'; + print '

'.$langs->trans("Transactions").' ('.count($transactions).')

'; + + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + + $totalCredit = 0; + $totalDebit = 0; + + foreach ($transactions as $trans) { + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + } + + // Totals + print ''; + print ''; + print ''; + print ''; + + print '
'.$langs->trans("Date").''.$langs->trans("Name").''.$langs->trans("Description").''.$langs->trans("Amount").'
'.dol_print_date($trans['date'], 'day').''.dol_escape_htmltag($trans['name']).''.dol_escape_htmltag(dol_trunc($trans['reference'], 80)).''; + + if ($trans['amount'] >= 0) { + $totalCredit += $trans['amount']; + print '+'.price($trans['amount'], 0, $langs, 1, -1, 2, 'EUR').''; + } else { + $totalDebit += abs($trans['amount']); + print ''.price($trans['amount'], 0, $langs, 1, -1, 2, 'EUR').''; + } + + print '
'.$langs->trans("Total").''; + print '+'.price($totalCredit, 0, $langs, 1, -1, 2, 'EUR').''; + print ' / '; + print '-'.price($totalDebit, 0, $langs, 1, -1, 2, 'EUR').''; + print ' = '; + $balance = $totalCredit - $totalDebit; + $balanceColor = $balance >= 0 ? 'green' : 'red'; + print ''.price($balance, 0, $langs, 1, -1, 2, 'EUR').''; + print '
'; + + // Import button + print '
'; + print '
'; + print ''; + print ''; + print ''; + print ' '.$langs->trans("ViewImportedTransactions").''; + print '
'; + print '
'; +} + +print '
'; // End transactions-container + +llxFooter(); +$db->close(); diff --git a/vendor/autoload.php b/vendor/autoload.php new file mode 100755 index 0000000..e511216 --- /dev/null +++ b/vendor/autoload.php @@ -0,0 +1,22 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Autoload; + +/** + * ClassLoader implements a PSR-0, PSR-4 and classmap class loader. + * + * $loader = new \Composer\Autoload\ClassLoader(); + * + * // register classes with namespaces + * $loader->add('Symfony\Component', __DIR__.'/component'); + * $loader->add('Symfony', __DIR__.'/framework'); + * + * // activate the autoloader + * $loader->register(); + * + * // to enable searching the include path (eg. for PEAR packages) + * $loader->setUseIncludePath(true); + * + * In this example, if you try to use a class in the Symfony\Component + * namespace or one of its children (Symfony\Component\Console for instance), + * the autoloader will first look for the class under the component/ + * directory, and it will then fallback to the framework/ directory if not + * found before giving up. + * + * This class is loosely based on the Symfony UniversalClassLoader. + * + * @author Fabien Potencier + * @author Jordi Boggiano + * @see https://www.php-fig.org/psr/psr-0/ + * @see https://www.php-fig.org/psr/psr-4/ + */ +class ClassLoader +{ + /** @var \Closure(string):void */ + private static $includeFile; + + /** @var string|null */ + private $vendorDir; + + // PSR-4 + /** + * @var array> + */ + private $prefixLengthsPsr4 = array(); + /** + * @var array> + */ + private $prefixDirsPsr4 = array(); + /** + * @var list + */ + private $fallbackDirsPsr4 = array(); + + // PSR-0 + /** + * List of PSR-0 prefixes + * + * Structured as array('F (first letter)' => array('Foo\Bar (full prefix)' => array('path', 'path2'))) + * + * @var array>> + */ + private $prefixesPsr0 = array(); + /** + * @var list + */ + private $fallbackDirsPsr0 = array(); + + /** @var bool */ + private $useIncludePath = false; + + /** + * @var array + */ + private $classMap = array(); + + /** @var bool */ + private $classMapAuthoritative = false; + + /** + * @var array + */ + private $missingClasses = array(); + + /** @var string|null */ + private $apcuPrefix; + + /** + * @var array + */ + private static $registeredLoaders = array(); + + /** + * @param string|null $vendorDir + */ + public function __construct($vendorDir = null) + { + $this->vendorDir = $vendorDir; + self::initializeIncludeClosure(); + } + + /** + * @return array> + */ + public function getPrefixes() + { + if (!empty($this->prefixesPsr0)) { + return call_user_func_array('array_merge', array_values($this->prefixesPsr0)); + } + + return array(); + } + + /** + * @return array> + */ + public function getPrefixesPsr4() + { + return $this->prefixDirsPsr4; + } + + /** + * @return list + */ + public function getFallbackDirs() + { + return $this->fallbackDirsPsr0; + } + + /** + * @return list + */ + public function getFallbackDirsPsr4() + { + return $this->fallbackDirsPsr4; + } + + /** + * @return array Array of classname => path + */ + public function getClassMap() + { + return $this->classMap; + } + + /** + * @param array $classMap Class to filename map + * + * @return void + */ + public function addClassMap(array $classMap) + { + if ($this->classMap) { + $this->classMap = array_merge($this->classMap, $classMap); + } else { + $this->classMap = $classMap; + } + } + + /** + * Registers a set of PSR-0 directories for a given prefix, either + * appending or prepending to the ones previously set for this prefix. + * + * @param string $prefix The prefix + * @param list|string $paths The PSR-0 root directories + * @param bool $prepend Whether to prepend the directories + * + * @return void + */ + public function add($prefix, $paths, $prepend = false) + { + $paths = (array) $paths; + if (!$prefix) { + if ($prepend) { + $this->fallbackDirsPsr0 = array_merge( + $paths, + $this->fallbackDirsPsr0 + ); + } else { + $this->fallbackDirsPsr0 = array_merge( + $this->fallbackDirsPsr0, + $paths + ); + } + + return; + } + + $first = $prefix[0]; + if (!isset($this->prefixesPsr0[$first][$prefix])) { + $this->prefixesPsr0[$first][$prefix] = $paths; + + return; + } + if ($prepend) { + $this->prefixesPsr0[$first][$prefix] = array_merge( + $paths, + $this->prefixesPsr0[$first][$prefix] + ); + } else { + $this->prefixesPsr0[$first][$prefix] = array_merge( + $this->prefixesPsr0[$first][$prefix], + $paths + ); + } + } + + /** + * Registers a set of PSR-4 directories for a given namespace, either + * appending or prepending to the ones previously set for this namespace. + * + * @param string $prefix The prefix/namespace, with trailing '\\' + * @param list|string $paths The PSR-4 base directories + * @param bool $prepend Whether to prepend the directories + * + * @throws \InvalidArgumentException + * + * @return void + */ + public function addPsr4($prefix, $paths, $prepend = false) + { + $paths = (array) $paths; + if (!$prefix) { + // Register directories for the root namespace. + if ($prepend) { + $this->fallbackDirsPsr4 = array_merge( + $paths, + $this->fallbackDirsPsr4 + ); + } else { + $this->fallbackDirsPsr4 = array_merge( + $this->fallbackDirsPsr4, + $paths + ); + } + } elseif (!isset($this->prefixDirsPsr4[$prefix])) { + // Register directories for a new namespace. + $length = strlen($prefix); + if ('\\' !== $prefix[$length - 1]) { + throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator."); + } + $this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length; + $this->prefixDirsPsr4[$prefix] = $paths; + } elseif ($prepend) { + // Prepend directories for an already registered namespace. + $this->prefixDirsPsr4[$prefix] = array_merge( + $paths, + $this->prefixDirsPsr4[$prefix] + ); + } else { + // Append directories for an already registered namespace. + $this->prefixDirsPsr4[$prefix] = array_merge( + $this->prefixDirsPsr4[$prefix], + $paths + ); + } + } + + /** + * Registers a set of PSR-0 directories for a given prefix, + * replacing any others previously set for this prefix. + * + * @param string $prefix The prefix + * @param list|string $paths The PSR-0 base directories + * + * @return void + */ + public function set($prefix, $paths) + { + if (!$prefix) { + $this->fallbackDirsPsr0 = (array) $paths; + } else { + $this->prefixesPsr0[$prefix[0]][$prefix] = (array) $paths; + } + } + + /** + * Registers a set of PSR-4 directories for a given namespace, + * replacing any others previously set for this namespace. + * + * @param string $prefix The prefix/namespace, with trailing '\\' + * @param list|string $paths The PSR-4 base directories + * + * @throws \InvalidArgumentException + * + * @return void + */ + public function setPsr4($prefix, $paths) + { + if (!$prefix) { + $this->fallbackDirsPsr4 = (array) $paths; + } else { + $length = strlen($prefix); + if ('\\' !== $prefix[$length - 1]) { + throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator."); + } + $this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length; + $this->prefixDirsPsr4[$prefix] = (array) $paths; + } + } + + /** + * Turns on searching the include path for class files. + * + * @param bool $useIncludePath + * + * @return void + */ + public function setUseIncludePath($useIncludePath) + { + $this->useIncludePath = $useIncludePath; + } + + /** + * Can be used to check if the autoloader uses the include path to check + * for classes. + * + * @return bool + */ + public function getUseIncludePath() + { + return $this->useIncludePath; + } + + /** + * Turns off searching the prefix and fallback directories for classes + * that have not been registered with the class map. + * + * @param bool $classMapAuthoritative + * + * @return void + */ + public function setClassMapAuthoritative($classMapAuthoritative) + { + $this->classMapAuthoritative = $classMapAuthoritative; + } + + /** + * Should class lookup fail if not found in the current class map? + * + * @return bool + */ + public function isClassMapAuthoritative() + { + return $this->classMapAuthoritative; + } + + /** + * APCu prefix to use to cache found/not-found classes, if the extension is enabled. + * + * @param string|null $apcuPrefix + * + * @return void + */ + public function setApcuPrefix($apcuPrefix) + { + $this->apcuPrefix = function_exists('apcu_fetch') && filter_var(ini_get('apc.enabled'), FILTER_VALIDATE_BOOLEAN) ? $apcuPrefix : null; + } + + /** + * The APCu prefix in use, or null if APCu caching is not enabled. + * + * @return string|null + */ + public function getApcuPrefix() + { + return $this->apcuPrefix; + } + + /** + * Registers this instance as an autoloader. + * + * @param bool $prepend Whether to prepend the autoloader or not + * + * @return void + */ + public function register($prepend = false) + { + spl_autoload_register(array($this, 'loadClass'), true, $prepend); + + if (null === $this->vendorDir) { + return; + } + + if ($prepend) { + self::$registeredLoaders = array($this->vendorDir => $this) + self::$registeredLoaders; + } else { + unset(self::$registeredLoaders[$this->vendorDir]); + self::$registeredLoaders[$this->vendorDir] = $this; + } + } + + /** + * Unregisters this instance as an autoloader. + * + * @return void + */ + public function unregister() + { + spl_autoload_unregister(array($this, 'loadClass')); + + if (null !== $this->vendorDir) { + unset(self::$registeredLoaders[$this->vendorDir]); + } + } + + /** + * Loads the given class or interface. + * + * @param string $class The name of the class + * @return true|null True if loaded, null otherwise + */ + public function loadClass($class) + { + if ($file = $this->findFile($class)) { + $includeFile = self::$includeFile; + $includeFile($file); + + return true; + } + + return null; + } + + /** + * Finds the path to the file where the class is defined. + * + * @param string $class The name of the class + * + * @return string|false The path if found, false otherwise + */ + public function findFile($class) + { + // class map lookup + if (isset($this->classMap[$class])) { + return $this->classMap[$class]; + } + if ($this->classMapAuthoritative || isset($this->missingClasses[$class])) { + return false; + } + if (null !== $this->apcuPrefix) { + $file = apcu_fetch($this->apcuPrefix.$class, $hit); + if ($hit) { + return $file; + } + } + + $file = $this->findFileWithExtension($class, '.php'); + + // Search for Hack files if we are running on HHVM + if (false === $file && defined('HHVM_VERSION')) { + $file = $this->findFileWithExtension($class, '.hh'); + } + + if (null !== $this->apcuPrefix) { + apcu_add($this->apcuPrefix.$class, $file); + } + + if (false === $file) { + // Remember that this class does not exist. + $this->missingClasses[$class] = true; + } + + return $file; + } + + /** + * Returns the currently registered loaders keyed by their corresponding vendor directories. + * + * @return array + */ + public static function getRegisteredLoaders() + { + return self::$registeredLoaders; + } + + /** + * @param string $class + * @param string $ext + * @return string|false + */ + private function findFileWithExtension($class, $ext) + { + // PSR-4 lookup + $logicalPathPsr4 = strtr($class, '\\', DIRECTORY_SEPARATOR) . $ext; + + $first = $class[0]; + if (isset($this->prefixLengthsPsr4[$first])) { + $subPath = $class; + while (false !== $lastPos = strrpos($subPath, '\\')) { + $subPath = substr($subPath, 0, $lastPos); + $search = $subPath . '\\'; + if (isset($this->prefixDirsPsr4[$search])) { + $pathEnd = DIRECTORY_SEPARATOR . substr($logicalPathPsr4, $lastPos + 1); + foreach ($this->prefixDirsPsr4[$search] as $dir) { + if (file_exists($file = $dir . $pathEnd)) { + return $file; + } + } + } + } + } + + // PSR-4 fallback dirs + foreach ($this->fallbackDirsPsr4 as $dir) { + if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr4)) { + return $file; + } + } + + // PSR-0 lookup + if (false !== $pos = strrpos($class, '\\')) { + // namespaced class name + $logicalPathPsr0 = substr($logicalPathPsr4, 0, $pos + 1) + . strtr(substr($logicalPathPsr4, $pos + 1), '_', DIRECTORY_SEPARATOR); + } else { + // PEAR-like class name + $logicalPathPsr0 = strtr($class, '_', DIRECTORY_SEPARATOR) . $ext; + } + + if (isset($this->prefixesPsr0[$first])) { + foreach ($this->prefixesPsr0[$first] as $prefix => $dirs) { + if (0 === strpos($class, $prefix)) { + foreach ($dirs as $dir) { + if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) { + return $file; + } + } + } + } + } + + // PSR-0 fallback dirs + foreach ($this->fallbackDirsPsr0 as $dir) { + if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) { + return $file; + } + } + + // PSR-0 include paths. + if ($this->useIncludePath && $file = stream_resolve_include_path($logicalPathPsr0)) { + return $file; + } + + return false; + } + + /** + * @return void + */ + private static function initializeIncludeClosure() + { + if (self::$includeFile !== null) { + return; + } + + /** + * Scope isolated include. + * + * Prevents access to $this/self from included files. + * + * @param string $file + * @return void + */ + self::$includeFile = \Closure::bind(static function($file) { + include $file; + }, null, null); + } +} diff --git a/vendor/composer/InstalledVersions.php b/vendor/composer/InstalledVersions.php new file mode 100755 index 0000000..2052022 --- /dev/null +++ b/vendor/composer/InstalledVersions.php @@ -0,0 +1,396 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer; + +use Composer\Autoload\ClassLoader; +use Composer\Semver\VersionParser; + +/** + * This class is copied in every Composer installed project and available to all + * + * See also https://getcomposer.org/doc/07-runtime.md#installed-versions + * + * To require its presence, you can require `composer-runtime-api ^2.0` + * + * @final + */ +class InstalledVersions +{ + /** + * @var string|null if set (by reflection by Composer), this should be set to the path where this class is being copied to + * @internal + */ + private static $selfDir = null; + + /** + * @var mixed[]|null + * @psalm-var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array}|array{}|null + */ + private static $installed; + + /** + * @var bool + */ + private static $installedIsLocalDir; + + /** + * @var bool|null + */ + private static $canGetVendors; + + /** + * @var array[] + * @psalm-var array}> + */ + private static $installedByVendor = array(); + + /** + * Returns a list of all package names which are present, either by being installed, replaced or provided + * + * @return string[] + * @psalm-return list + */ + public static function getInstalledPackages() + { + $packages = array(); + foreach (self::getInstalled() as $installed) { + $packages[] = array_keys($installed['versions']); + } + + if (1 === \count($packages)) { + return $packages[0]; + } + + return array_keys(array_flip(\call_user_func_array('array_merge', $packages))); + } + + /** + * Returns a list of all package names with a specific type e.g. 'library' + * + * @param string $type + * @return string[] + * @psalm-return list + */ + public static function getInstalledPackagesByType($type) + { + $packagesByType = array(); + + foreach (self::getInstalled() as $installed) { + foreach ($installed['versions'] as $name => $package) { + if (isset($package['type']) && $package['type'] === $type) { + $packagesByType[] = $name; + } + } + } + + return $packagesByType; + } + + /** + * Checks whether the given package is installed + * + * This also returns true if the package name is provided or replaced by another package + * + * @param string $packageName + * @param bool $includeDevRequirements + * @return bool + */ + public static function isInstalled($packageName, $includeDevRequirements = true) + { + foreach (self::getInstalled() as $installed) { + if (isset($installed['versions'][$packageName])) { + return $includeDevRequirements || !isset($installed['versions'][$packageName]['dev_requirement']) || $installed['versions'][$packageName]['dev_requirement'] === false; + } + } + + return false; + } + + /** + * Checks whether the given package satisfies a version constraint + * + * e.g. If you want to know whether version 2.3+ of package foo/bar is installed, you would call: + * + * Composer\InstalledVersions::satisfies(new VersionParser, 'foo/bar', '^2.3') + * + * @param VersionParser $parser Install composer/semver to have access to this class and functionality + * @param string $packageName + * @param string|null $constraint A version constraint to check for, if you pass one you have to make sure composer/semver is required by your package + * @return bool + */ + public static function satisfies(VersionParser $parser, $packageName, $constraint) + { + $constraint = $parser->parseConstraints((string) $constraint); + $provided = $parser->parseConstraints(self::getVersionRanges($packageName)); + + return $provided->matches($constraint); + } + + /** + * Returns a version constraint representing all the range(s) which are installed for a given package + * + * It is easier to use this via isInstalled() with the $constraint argument if you need to check + * whether a given version of a package is installed, and not just whether it exists + * + * @param string $packageName + * @return string Version constraint usable with composer/semver + */ + public static function getVersionRanges($packageName) + { + foreach (self::getInstalled() as $installed) { + if (!isset($installed['versions'][$packageName])) { + continue; + } + + $ranges = array(); + if (isset($installed['versions'][$packageName]['pretty_version'])) { + $ranges[] = $installed['versions'][$packageName]['pretty_version']; + } + if (array_key_exists('aliases', $installed['versions'][$packageName])) { + $ranges = array_merge($ranges, $installed['versions'][$packageName]['aliases']); + } + if (array_key_exists('replaced', $installed['versions'][$packageName])) { + $ranges = array_merge($ranges, $installed['versions'][$packageName]['replaced']); + } + if (array_key_exists('provided', $installed['versions'][$packageName])) { + $ranges = array_merge($ranges, $installed['versions'][$packageName]['provided']); + } + + return implode(' || ', $ranges); + } + + throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed'); + } + + /** + * @param string $packageName + * @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present + */ + public static function getVersion($packageName) + { + foreach (self::getInstalled() as $installed) { + if (!isset($installed['versions'][$packageName])) { + continue; + } + + if (!isset($installed['versions'][$packageName]['version'])) { + return null; + } + + return $installed['versions'][$packageName]['version']; + } + + throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed'); + } + + /** + * @param string $packageName + * @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present + */ + public static function getPrettyVersion($packageName) + { + foreach (self::getInstalled() as $installed) { + if (!isset($installed['versions'][$packageName])) { + continue; + } + + if (!isset($installed['versions'][$packageName]['pretty_version'])) { + return null; + } + + return $installed['versions'][$packageName]['pretty_version']; + } + + throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed'); + } + + /** + * @param string $packageName + * @return string|null If the package is being replaced or provided but is not really installed, null will be returned as reference + */ + public static function getReference($packageName) + { + foreach (self::getInstalled() as $installed) { + if (!isset($installed['versions'][$packageName])) { + continue; + } + + if (!isset($installed['versions'][$packageName]['reference'])) { + return null; + } + + return $installed['versions'][$packageName]['reference']; + } + + throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed'); + } + + /** + * @param string $packageName + * @return string|null If the package is being replaced or provided but is not really installed, null will be returned as install path. Packages of type metapackages also have a null install path. + */ + public static function getInstallPath($packageName) + { + foreach (self::getInstalled() as $installed) { + if (!isset($installed['versions'][$packageName])) { + continue; + } + + return isset($installed['versions'][$packageName]['install_path']) ? $installed['versions'][$packageName]['install_path'] : null; + } + + throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed'); + } + + /** + * @return array + * @psalm-return array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool} + */ + public static function getRootPackage() + { + $installed = self::getInstalled(); + + return $installed[0]['root']; + } + + /** + * Returns the raw installed.php data for custom implementations + * + * @deprecated Use getAllRawData() instead which returns all datasets for all autoloaders present in the process. getRawData only returns the first dataset loaded, which may not be what you expect. + * @return array[] + * @psalm-return array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array} + */ + public static function getRawData() + { + @trigger_error('getRawData only returns the first dataset loaded, which may not be what you expect. Use getAllRawData() instead which returns all datasets for all autoloaders present in the process.', E_USER_DEPRECATED); + + if (null === self::$installed) { + // only require the installed.php file if this file is loaded from its dumped location, + // and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937 + if (substr(__DIR__, -8, 1) !== 'C') { + self::$installed = include __DIR__ . '/installed.php'; + } else { + self::$installed = array(); + } + } + + return self::$installed; + } + + /** + * Returns the raw data of all installed.php which are currently loaded for custom implementations + * + * @return array[] + * @psalm-return list}> + */ + public static function getAllRawData() + { + return self::getInstalled(); + } + + /** + * Lets you reload the static array from another file + * + * This is only useful for complex integrations in which a project needs to use + * this class but then also needs to execute another project's autoloader in process, + * and wants to ensure both projects have access to their version of installed.php. + * + * A typical case would be PHPUnit, where it would need to make sure it reads all + * the data it needs from this class, then call reload() with + * `require $CWD/vendor/composer/installed.php` (or similar) as input to make sure + * the project in which it runs can then also use this class safely, without + * interference between PHPUnit's dependencies and the project's dependencies. + * + * @param array[] $data A vendor/composer/installed.php data set + * @return void + * + * @psalm-param array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array} $data + */ + public static function reload($data) + { + self::$installed = $data; + self::$installedByVendor = array(); + + // when using reload, we disable the duplicate protection to ensure that self::$installed data is + // always returned, but we cannot know whether it comes from the installed.php in __DIR__ or not, + // so we have to assume it does not, and that may result in duplicate data being returned when listing + // all installed packages for example + self::$installedIsLocalDir = false; + } + + /** + * @return string + */ + private static function getSelfDir() + { + if (self::$selfDir === null) { + self::$selfDir = strtr(__DIR__, '\\', '/'); + } + + return self::$selfDir; + } + + /** + * @return array[] + * @psalm-return list}> + */ + private static function getInstalled() + { + if (null === self::$canGetVendors) { + self::$canGetVendors = method_exists('Composer\Autoload\ClassLoader', 'getRegisteredLoaders'); + } + + $installed = array(); + $copiedLocalDir = false; + + if (self::$canGetVendors) { + $selfDir = self::getSelfDir(); + foreach (ClassLoader::getRegisteredLoaders() as $vendorDir => $loader) { + $vendorDir = strtr($vendorDir, '\\', '/'); + if (isset(self::$installedByVendor[$vendorDir])) { + $installed[] = self::$installedByVendor[$vendorDir]; + } elseif (is_file($vendorDir.'/composer/installed.php')) { + /** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array} $required */ + $required = require $vendorDir.'/composer/installed.php'; + self::$installedByVendor[$vendorDir] = $required; + $installed[] = $required; + if (self::$installed === null && $vendorDir.'/composer' === $selfDir) { + self::$installed = $required; + self::$installedIsLocalDir = true; + } + } + if (self::$installedIsLocalDir && $vendorDir.'/composer' === $selfDir) { + $copiedLocalDir = true; + } + } + } + + if (null === self::$installed) { + // only require the installed.php file if this file is loaded from its dumped location, + // and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937 + if (substr(__DIR__, -8, 1) !== 'C') { + /** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array} $required */ + $required = require __DIR__ . '/installed.php'; + self::$installed = $required; + } else { + self::$installed = array(); + } + } + + if (self::$installed !== array() && !$copiedLocalDir) { + $installed[] = self::$installed; + } + + return $installed; + } +} diff --git a/vendor/composer/LICENSE b/vendor/composer/LICENSE new file mode 100755 index 0000000..f27399a --- /dev/null +++ b/vendor/composer/LICENSE @@ -0,0 +1,21 @@ + +Copyright (c) Nils Adermann, Jordi Boggiano + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + diff --git a/vendor/composer/autoload_classmap.php b/vendor/composer/autoload_classmap.php new file mode 100755 index 0000000..a46b42a --- /dev/null +++ b/vendor/composer/autoload_classmap.php @@ -0,0 +1,11 @@ + $baseDir . '/class/fints.class.php', + 'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php', +); diff --git a/vendor/composer/autoload_namespaces.php b/vendor/composer/autoload_namespaces.php new file mode 100755 index 0000000..19a0556 --- /dev/null +++ b/vendor/composer/autoload_namespaces.php @@ -0,0 +1,11 @@ + array($vendorDir . '/nemiah/php-fints/lib'), + 'Fhp' => array($vendorDir . '/nemiah/php-fints/lib'), +); diff --git a/vendor/composer/autoload_psr4.php b/vendor/composer/autoload_psr4.php new file mode 100755 index 0000000..3890ddc --- /dev/null +++ b/vendor/composer/autoload_psr4.php @@ -0,0 +1,9 @@ +register(true); + + return $loader; + } +} diff --git a/vendor/composer/autoload_static.php b/vendor/composer/autoload_static.php new file mode 100755 index 0000000..5ec4db2 --- /dev/null +++ b/vendor/composer/autoload_static.php @@ -0,0 +1,39 @@ + + array ( + 'Tests\\Fhp' => + array ( + 0 => __DIR__ . '/..' . '/nemiah/php-fints/lib', + ), + ), + 'F' => + array ( + 'Fhp' => + array ( + 0 => __DIR__ . '/..' . '/nemiah/php-fints/lib', + ), + ), + ); + + public static $classMap = array ( + 'BankImportFinTS' => __DIR__ . '/../..' . '/class/fints.class.php', + 'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php', + ); + + public static function getInitializer(ClassLoader $loader) + { + return \Closure::bind(function () use ($loader) { + $loader->prefixesPsr0 = ComposerStaticInitcfc07b7e6c4a3dcfdcd6e754983b1a9b::$prefixesPsr0; + $loader->classMap = ComposerStaticInitcfc07b7e6c4a3dcfdcd6e754983b1a9b::$classMap; + + }, null, ClassLoader::class); + } +} diff --git a/vendor/composer/installed.json b/vendor/composer/installed.json new file mode 100755 index 0000000..88c07eb --- /dev/null +++ b/vendor/composer/installed.json @@ -0,0 +1,58 @@ +{ + "packages": [ + { + "name": "nemiah/php-fints", + "version": "3.7.0", + "version_normalized": "3.7.0.0", + "source": { + "type": "git", + "url": "https://github.com/nemiah/phpFinTS.git", + "reference": "08257e10229db2d4ca8c54ed7fec0f390b332519" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nemiah/phpFinTS/zipball/08257e10229db2d4ca8c54ed7fec0f390b332519", + "reference": "08257e10229db2d4ca8c54ed7fec0f390b332519", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-mbstring": "*", + "php": ">=8.0", + "psr/log": "^1|^2|^3" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.0", + "php-mock/php-mock-phpunit": "^2.6", + "phpunit/phpunit": "^9.5" + }, + "suggest": { + "abcaeffchen/sephpa": "1.*", + "monolog/monolog": "Allow sending log messages to a variety of different handlers", + "nemiah/php-sepa-xml": "dev-master" + }, + "time": "2025-10-14T15:05:56+00:00", + "type": "library", + "installation-source": "dist", + "autoload": { + "psr-0": { + "Fhp": "lib/", + "Tests\\Fhp": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHP Library for the protocols fints and hbci", + "homepage": "https://github.com/nemiah/phpFinTS", + "support": { + "issues": "https://github.com/nemiah/phpFinTS/issues", + "source": "https://github.com/nemiah/phpFinTS/tree/3.7" + }, + "install-path": "../nemiah/php-fints" + } + ], + "dev": true, + "dev-package-names": [] +} diff --git a/vendor/composer/installed.php b/vendor/composer/installed.php new file mode 100755 index 0000000..31e768d --- /dev/null +++ b/vendor/composer/installed.php @@ -0,0 +1,38 @@ + array( + 'name' => 'dolibarr/bankimport', + 'pretty_version' => '1.0.0+no-version-set', + 'version' => '1.0.0.0', + 'reference' => null, + 'type' => 'dolibarr-module', + 'install_path' => __DIR__ . '/../../', + 'aliases' => array(), + 'dev' => true, + ), + 'versions' => array( + 'dolibarr/bankimport' => array( + 'pretty_version' => '1.0.0+no-version-set', + 'version' => '1.0.0.0', + 'reference' => null, + 'type' => 'dolibarr-module', + 'install_path' => __DIR__ . '/../../', + 'aliases' => array(), + 'dev_requirement' => false, + ), + 'nemiah/php-fints' => array( + 'pretty_version' => '3.7.0', + 'version' => '3.7.0.0', + 'reference' => '08257e10229db2d4ca8c54ed7fec0f390b332519', + 'type' => 'library', + 'install_path' => __DIR__ . '/../nemiah/php-fints', + 'aliases' => array(), + 'dev_requirement' => false, + ), + 'psr/log' => array( + 'dev_requirement' => false, + 'replaced' => array( + 0 => '*', + ), + ), + ), +); diff --git a/vendor/composer/platform_check.php b/vendor/composer/platform_check.php new file mode 100755 index 0000000..a70ba47 --- /dev/null +++ b/vendor/composer/platform_check.php @@ -0,0 +1,25 @@ += 80000)) { + $issues[] = 'Your Composer dependencies require a PHP version ">= 8.0.0". You are running ' . PHP_VERSION . '.'; +} + +if ($issues) { + if (!headers_sent()) { + header('HTTP/1.1 500 Internal Server Error'); + } + if (!ini_get('display_errors')) { + if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') { + fwrite(STDERR, 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . implode(PHP_EOL, $issues) . PHP_EOL.PHP_EOL); + } elseif (!headers_sent()) { + echo 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . str_replace('You are running '.PHP_VERSION.'.', '', implode(PHP_EOL, $issues)) . PHP_EOL.PHP_EOL; + } + } + throw new \RuntimeException( + 'Composer detected issues in your platform: ' . implode(' ', $issues) + ); +} diff --git a/vendor/nemiah/php-fints/.php-cs-fixer.php b/vendor/nemiah/php-fints/.php-cs-fixer.php new file mode 100755 index 0000000..9eb8056 --- /dev/null +++ b/vendor/nemiah/php-fints/.php-cs-fixer.php @@ -0,0 +1,30 @@ +in(__DIR__ . '/lib'); + +return (new PhpCsFixer\Config()) + ->setRules([ + // We essentially use the Symfony style guide. + '@Symfony' => true, + + // But then we have some exclusions, i.e. we disable some of the checks/rules from Symfony: + // Logic + 'yoda_style' => FALSE, // Allow both Yoda-style and regular comparisons. + + // Whitespace + 'blank_line_before_statement' => FALSE, // Don't put blank lines before `return` statements. + 'concat_space' => FALSE, // Allow spaces around string concatenation operator. + 'blank_line_after_opening_tag' => FALSE, // Allow file-level @noinspection suppressions to live on the ` FALSE, // Allow `throw` statements to span multiple lines. + + // phpDoc + 'phpdoc_align' => FALSE, // Don't add spaces within phpDoc just to make parameter names / descriptions align. + 'phpdoc_annotation_without_dot' => FALSE, // Allow terminating dot on @param and such. + 'phpdoc_no_alias_tag' => FALSE, // Allow @link in addition to @see. + 'phpdoc_separation' => FALSE, // Don't put blank line between @params, @throws and @return. + 'phpdoc_summary' => FALSE, // Don't force terminating dot on the first line. + ]) + ->setFinder($finder); diff --git a/vendor/nemiah/php-fints/.travis.yml b/vendor/nemiah/php-fints/.travis.yml new file mode 100755 index 0000000..1db343e --- /dev/null +++ b/vendor/nemiah/php-fints/.travis.yml @@ -0,0 +1,12 @@ +language: php +install: composer install +script: + - ./disallowtabs.sh + - ./csfixer-check.sh + - ./phplint.sh ./lib/ + - ./vendor/bin/phpunit +dist: bionic +php: + - '8.0' + - '8.1.0' + - '8.2.0' diff --git a/vendor/nemiah/php-fints/DEVELOPER-GUIDE.md b/vendor/nemiah/php-fints/DEVELOPER-GUIDE.md new file mode 100755 index 0000000..c25d81f --- /dev/null +++ b/vendor/nemiah/php-fints/DEVELOPER-GUIDE.md @@ -0,0 +1,200 @@ +## Specification + +This library implements parts of FinTS V3.0, which is specified [here](https://www.hbci-zka.de/spec/3_0.htm). +Before version 3, the standard was called HBCI. +Today, HBCI still refers to the security part of the specification. +The specification is split into several documents: + +* [Hauptdokument] Unimportant index document that gives an overview of the other documents. +* [Formals] Wire format and basic protocol (dialog, BPD, UPD). +* [Rückmeldungen] Directory of response codes that the bank can send. +* [Security HBCI] Protocol for encryption and cryptographic signatures. +* [Security PIN/TAN] Protocol for PIN/TAN authentication, based on the HBCI security protocol, but without actual encryption/signatures. +* [Messages Geschäftsvorfälle] Various business transactions (account statements, wire transfers, etc.). +* [Messages Finanzdatenformate] Wire formats other than the FinTS format itself (e.g. DTAUS and MT 940) that are used for certain transactions. + +[Hauptdokument]: https://www.hbci-zka.de/dokumente/spezifikation_deutsch/fintsv3/FinTS_3.0_Master_2018-11-29.pdf +[Formals]: https://www.hbci-zka.de/dokumente/spezifikation_deutsch/fintsv3/FinTS_3.0_Formals_2017-10-06_final_version.pdf +[Rückmeldungen]: https://www.hbci-zka.de/dokumente/spezifikation_deutsch/FinTS_Rueckmeldungscodes_2019-07-22_final_version.pdf +[Security HBCI]: https://www.hbci-zka.de/dokumente/spezifikation_deutsch/fintsv3/FinTS_3.0_Security_Sicherheitsverfahren_HBCI_Rel_20181129_final_version.pdf +[Security PIN/TAN]: https://www.hbci-zka.de/dokumente/spezifikation_deutsch/fintsv3/FinTS_3.0_Security_Sicherheitsverfahren_PINTAN_2018-02-23_final_version.pdf +[Messages Geschäftsvorfälle]: https://www.hbci-zka.de/dokumente/spezifikation_deutsch/fintsv3/FinTS_3.0_Messages_Geschaeftsvorfaelle_2015-08-07_final_version.pdf +[Messages Finanzdatenformate]: https://www.hbci-zka.de/dokumente/spezifikation_deutsch/fintsv3/FinTS_3.0_Messages_Finanzdatenformate_2010-08-06_final_version.pdf + +Note that there is also an [archive](https://www.hbci-zka.de/spec/spec_archiv.htm) with older versions of the HBCI specification. + + +## High-level concepts and structure of the library + +A **message** (implemented in `Fhp\Protocol\Message`) is a series of **segments** (implemented in `Fhp\Segment\...`), +which themselves consist of **data elements (DEs)** and **data element groups (DEGs)**. +Each segment starts with an identifier to declare its type. Example: +``` +HNHBK:1:3+000000000079+300+dialogID+2'HKEND:2:1+dialogID'HNHBS:3:1+2' +``` +This message contains three segments. +The middle segment is `HKEND` version `1` at position `2`. It only contains one data element with value `dialogId`. +The first segment is `HNHBK`. It contains four data elements `000000000079`, ..., `2`. + +The **syntactical representation (wire format)** (see [Formals]) used when transmitting/storing messages and segments is +described below and implemented in `Fhp\Syntax`. + +The **protocol** specifies which messages a client can send (requests) and how the server can respond to them (responses). +There is exactly one response per request, the client needs to wait for the response before sending another request. +While FinTs is specified independently of the underlying transport layer, all banks use TLS 3.0 in practice, so it's +very similar to HTTPS and the server addresses are given as `https://` URIs as well. + +The most basic protocol is the **dialog** (see [Formals], implemented in `Fhp\Protocol\DialogInitialization` and `FinTs`), +which combines elements of a handshake (as in TCP/TLS) and a session (i.e. authentication through cookies/tokens). +Before sending any other messages, the client must initialize a dialog. +Most dialogs require authentication, so the initialization is comparable to a login. + +While all of the above is part of the FinTS infrastructure, the **business transactions** (the part of the protocol that is +ultimately useful for users) are specified separately in [Messages Geschäftsvorfälle] (implemented in `Fhp\Action\...`). +In addition to these, bank can specify their own actions (would be implemented in separate dependent libraries, but +currently there are none). + +Separately from the FinTS specification, this library implements a **user-facing API** in `Fhp\Model` (in addition to +the `FinTs` class and the `Fhp\Action` namespace) to provide a simpler and more stable interface over time. + +NOTE: The PHP namespaces `DataElementGroups`, `Dialog` and `Response` (and their contents) are deprecated. + + +## Segment/DEG schema + +### Segment classes +In this library, segments are implemented in classes named like `HKTANv6` that inherit from `BaseSegment`. +Each of these classes is mapped 1:1 from the corresponding chapter of the specification document. +The class name consists of the segment identifier (e.g. `HKTAN`) and the version (just an integer prefixed by `v`). + +The segment identifier itself has two parts: +In `HXyyy`, the `H` is constant (for "HBCI"), `yyy` is an abbreviation of the name of the respective functionality that +the segment provides, and `X` is one of `K`, `I` or `N` depending on whether it is a request segment sent by the client +("Kunde" = customer), a response segment sent by the server ("(Kredit-)Institut" = bank) or both ("Nachricht" = message). +The `HIyyyS` segments suffixed by `S` contain meta information (called "parameters") that describe what constitues a +valid `HKyyy` request. + +The class `HXyyyvN` that implements version `N` of a particular segment type `yyy` can either be placed in the PHP +namespace `Fhp\Segment\HXyyy` (to group versions together, used especially when `X`=`N`) or `Fhp\Segment\yyy` (to group +corresponding request and reponse segments together). + +### DEG classes +Analogously to segments, data element groups (DEGs) are mapped to classes that inherit from `BaseDeg`. +The class name is the title of the sub-section that specifies the DEG structure in the specification document. +The version suffix (`VN` with capital `V`) is optional for DEGs. +The naming does not have to be as strictly deterministic as with segments because DEGs are referenced explicitly in +code from the respective segments that contain them. + +### Segment/DEG interfaces +Especially when there are multiple supported versions of the same segment/DEG type (e.g. `HKKAZv6` and `HKKAZv7`), it +makes sense to also have a common `interface HKKAZ` implemented by all versions that defines getters for the common +fields, so that business logic code can access all versions transparently. + +### Member fields / data elements + +As an example, consider the `HIUPDv6` class that implements the "Kontoinformation" segment from page 88 (PDF page 96) +from the [Formals] document. + +Within each segment/DEG class, the elements defined in the specification are translated one by one to class fields. +It is important that the fields occur in the *same order* as in the specification and that no fields are left out, because +the absolute index/position of each field in the segment class must map to its index in HBCI's wire format. +Segment classes may inherit from other segment classes, in which case the fields of the parent class come first. +The name of the field/element is taken directly from the specification, so it is usually in German. +Special characters like Umlauts are replaced with their expanded versions and the whole name is transformed to camel case. + +The element types (specified in [Formals] section B.4) are mapped to PHP types as follows: +- `jn` (yes/no) becomes `bool`. +- `num` (numerical) and `dig` (single digit) become `int`. +- `float` and `wrt` (amounts) become `float`. +- `an` (alpha-numerical), `txt` (text), `id` (identifiers), `ctr` (country codes, despite being numerical) and `cur` + (currency codes) become `string` and the maximum length is documented in phpDoc. +- `code` (enum) is resolved to the type of the underlying value type (usually `string` or `int`) and the allowed values + are documented in phpDoc. +- `bin` uses the `Fhp\Syntax\Bin` class and the maximum length is documented in phpDoc. +- `dat` and `tim` are currently mapped to `string`, but could get their own class in `Fhp\DataTypes` in future. +- Any data element group is implemented as a separate DEG class (see above), so that the PHP field can reference that + class name as its type. + +The "Status" of each element is mapped as follows: +- Mandatory fields ("M" in the specification) use the plain type as described above. +- Optional fields ("O") append `|null`. +- Conditional fields ("C") are resolved as much as possible given the context of the segment and this library in general +(often only one of the conditions is always satisfied, so it is clear whether the field is mandatory, optional or +disallowed whenever it is used in this library), and otherwise mapped to `|null` as well. + +The "Anzahl" (cardinality) is mapped as follows: +- If only `1` is allowed, use the plain (possibly nullable) type as described above. +- Otherwise append `[]` to the type and add a `@Max(N)` annotation for the maximum number. E.g. a `jn` field that can be + repeated at most 20 times becomes `@var int[] @Max(20)`. +- If `0` is within the allowed range, add `|null`, e.g. `@var int[]|null @Max(20)`. + +Any other information that is given in the specification like maximum length, restrictions on when the field can be +used or not, and the guidelines ("Belegungsrichtlinien"), are added to the phpDoc in English (translated) if useful. + + +## Wire format + +Segments are terminated (and thus also delimited) by `'`. +They contain a series of DEs and DEGs, delimited by `:` and `+`. +Note that these delimiters aren't very consistent (see below). + +The values essentially form an ordered tree, where the leaves are data elements (similar to scalar values), inner nodes +are data element groups (similar to compound values) and the roots are segments. +The entries of a segment (i.e. the second level of the tree), which can be a mix of DEs and DEGs, are delimited by `+`. +The entries of any lower-level inner node are delimited by `:`. +Such a tree can be arbitrarily deep, which leads to ambiguities. +For instance, the wire format of a DEG `X` that contains nothing but a nested DEG `Y` is indistinguishable from the wire +format of `Y` alone. +With repeated and optional elements, there are even more ambiguities, but the specification requires "empty" elements to +clarify in such situations. + +While the wire format's syntax can be recognized and parsed without knowing what the contents mean (similar to JSON or +XML), the FinTs wire format is not self-describing on the semantic level, so the segment definitions are needed to +understand the contents (similar to protocol buffers and other binary serialization formats). +However, each segment begins with a `Segmentkopf` as its first entry, which declares the type of the segment and allows +picking the right segment definition/schema for parsing. + +### Prettifier scripts + +For debugging purposes, it can be useful to render serialized messages/segments in a more readable (self-describing) +format, specifically with the field names inlined. +The `prettify_message.php` and `prettify_segment.php` scripts do exactly that, given the wire format on stdin. + + +## PSD2 PIN/TAN authentication + +The PIN/TAN specification piggy-backs on the HBCI encryption/signature specification that was originally intended for +cryptographically secure communication based on private keys contained in physical tokens like HBCI chip cards. +Nowadays, the surrounding transport layer (TLS) already provides the cryptographic security based on the public-key +infrastructure of the WWW, so that proper encryption and signatures are not necessary anymore on the FinTS level. +Nevertheless, all messages need to be wrapped in the respective envelope (implemented in `Fhp\Protocol\Message`). +This envelope also carries PIN and TAN whenever they need to be sent. + +In addition to the envelope, the `HKTAN` family of segments are used to communicate whether a TAN is needed, what it +needs to look like, what it is for when it is being sent, and whether it was accepted on the bank side. +`HKTAN` version 6 is equivalent to PSD2. + +In practice, with PSD2 for the first time, several German banks started asking for a TAN even upon login, which can +sometimes lead to problems depending on how they implement the protocol, given that `HKTAN` was designed to authenticate +business transactions *within* a dialog, but is now used to *initialize* a dialog as well. + + +### Tests + +Very few banks provide a sandbox environment with fake accounts for testing purposes. +So most developers manual tests against their real bank accounts. +To minify the impact on these accounts (undesired transactions, account locks due to failed login attempts), automated +integration testing (against fake backends) has proven very useful (in addition to the usual regression-catching +benefits of those tests). + +The `CLILogger` class can be used to record requests/responses during a (manually scripted) dialog with the bank. +The recorded messages can then be filled into a `PhpUnit` test. +When used through `FinTs::setLogger()`, the logger already replaces the most important sensitive values +(username, PIN, ...) with placeholders. +But the recorded segments regularly still contain personal information (e.g. IBANs and names of other parties involved +in transactions, descriptions of transactions, etc.). +In order to remove this information, it is advisable not to change the overall length (i.e. using the `Ins` mode in the +code editor to overwrite it with some dummy data), because the wire format hard-codes the length of subsequent data in +various places and simply removing it would result in parsing failures. +For examples, see `Tests\Fhp\Integration\...`. + diff --git a/vendor/nemiah/php-fints/LICENSE b/vendor/nemiah/php-fints/LICENSE new file mode 100755 index 0000000..fc762d0 --- /dev/null +++ b/vendor/nemiah/php-fints/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2016 Markus Schindler + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/vendor/nemiah/php-fints/README.md b/vendor/nemiah/php-fints/README.md new file mode 100755 index 0000000..c14ea9d --- /dev/null +++ b/vendor/nemiah/php-fints/README.md @@ -0,0 +1,89 @@ +# PHP FinTS/HBCI library + +[![Build Status](https://travis-ci.org/nemiah/phpFinTS.svg?branch=master)](https://travis-ci.org/nemiah/phpFinTS) + +A PHP library implementing the following functions of the FinTS/HBCI protocol: + + * Get accounts + * Get balance + * Get transactions + * Execute direct debit + * Execute transfer + * Note that any other functions mentioned in + [section C of the specification](https://www.hbci-zka.de/dokumente/spezifikation_deutsch/fintsv3/FinTS_3.0_Messages_Geschaeftsvorfaelle_2015-08-07_final_version.pdf) + should be relatively straightfoward to implement. + +Forked from [mschindler83/fints-hbci-php](https://github.com/mschindler83/fints-hbci-php), but then mostly reimplemented. + +## Getting Started + +Before using this library (or any other FinTS library), you have to register your application with +[Die Deutsche Kreditwirtschaft](https://www.hbci-zka.de/register/hersteller.htm) in order to get your registration +number. +Note that this process can take several weeks. +First you receive your registration number **after a couple days, but then you have to wait anywhere between 0 and 8+ weeks** +for the registration to reach your bank's server. If you have multiple banks, it probably reaches them at different times. + +Then install the library via composer: + +``` +composer require nemiah/php-fints +``` + +See the examples in the "[Samples](/Samples)" folder to get started on your code. +Fill out the required configuration in `init.php` (server details can be obtained at +[www.hbci-zka.de](https://www.hbci-zka.de) after registration). +Then execute `tanModesAndMedia.php` and later `login.php`. +Once you are able to login without any issues, you can move on to the other examples. + +## Banks with special needs + +If you are developing an online banking application with this library, please be aware of the following exceptions: + +### Hypovereinsbank + +The BLZ 71120078 will throw an "Unbekanntes Kreditinstitut" exception when used with the URL https://hbci-01.hypovereinsbank.de/bank/hbci. +You have to use BLZ 70020270 instead. +``` +if (trim($url) == 'https://hbci-01.hypovereinsbank.de/bank/hbci') + $blz = '70020270'; +``` + +### ING Diba + +This bank does not support PSD2: +``` +if(trim($blz) == "50010517") + $fints->selectTanMode(new Fhp\Model\NoPsd2TanMode()); +``` + +## Contribute + +Contributions are welcome! See the [developer guide](DEVELOPER-GUIDE.md) for some background information. + +We use a slightly modified version of the [Symfony Coding-Style](https://symfony.com/doc/current/contributing/code/standards.html). +Please run +``` +composer update +``` +and +``` +composer cs-fix +``` + +before sending a PR. + +### Bank compatibility + +Different banks implement different versions of the HBCI and FinTS specifications, and they also interpret the +specification differently sometimes. In addition, banks behave differently (within the boundaries of the specification) +when it comes to validation (some may tolerate slightly wrong requests), TANs (some ask for TANs more often than others) +and allowed parameters (not all banks support all parameter combinations). + +This library aims to be compatible with all banks that support [FinTS V3.0](https://www.hbci-zka.de/spec/3_0.htm) and +PIN/TAN-based authentication according to PSD2 regulations, which includes most relevant German banks. Currently, it +works with the most popular banks at least, and probably with most others too. Some corner cases (e.g. Mehrfach-TANs or +SMS-Abbuchungskonto for mTAN fees) are not and probably will not be supported. +Those banks with a dedicated [integration test](/lib/Tests/Fhp/Integration) have been tested most extensively. + +If you encounter any problems with your particular bank, please check for open GitHub issues or open a new one. diff --git a/vendor/nemiah/php-fints/Samples/accounts.php b/vendor/nemiah/php-fints/Samples/accounts.php new file mode 100755 index 0000000..1554da2 --- /dev/null +++ b/vendor/nemiah/php-fints/Samples/accounts.php @@ -0,0 +1,20 @@ +execute($getSepaAccounts); +if ($getSepaAccounts->needsTan()) { + handleStrongAuthentication($getSepaAccounts); // See login.php for the implementation. +} +print_r($getSepaAccounts->getAccounts()); diff --git a/vendor/nemiah/php-fints/Samples/balance.php b/vendor/nemiah/php-fints/Samples/balance.php new file mode 100755 index 0000000..9749bfe --- /dev/null +++ b/vendor/nemiah/php-fints/Samples/balance.php @@ -0,0 +1,35 @@ +execute($getSepaAccounts); +if ($getSepaAccounts->needsTan()) { + handleStrongAuthentication($getSepaAccounts); // See login.php for the implementation. +} +$oneAccount = $getSepaAccounts->getAccounts()[0]; + +$getBalance = \Fhp\Action\GetBalance::create($oneAccount, true); +$fints->execute($getBalance); +if ($getBalance->needsTan()) { + handleStrongAuthentication($getBalance); // See login.php for the implementation. +} + +/** @var \Fhp\Segment\SAL\HISAL $hisal */ +foreach ($getBalance->getBalances() as $hisal) { + $accNo = $hisal->getAccountInfo()->getAccountNumber(); + if ($hisal->getKontoproduktbezeichnung() !== null) { + $accNo .= ' (' . $hisal->getKontoproduktbezeichnung() . ')'; + } + $amnt = $hisal->getGebuchterSaldo()->getAmount(); + $curr = $hisal->getGebuchterSaldo()->getCurrency(); + $date = $hisal->getGebuchterSaldo()->getTimestamp()->format('Y-m-d'); + echo "On $accNo you have $amnt $curr as of $date.\n"; +} diff --git a/vendor/nemiah/php-fints/Samples/bpd.php b/vendor/nemiah/php-fints/Samples/bpd.php new file mode 100755 index 0000000..72992bc --- /dev/null +++ b/vendor/nemiah/php-fints/Samples/bpd.php @@ -0,0 +1,18 @@ +url = 'https://banking-dkb.s-fints-pt-dkb.de/fints30'; // HBCI / FinTS Url can be found here: https://www.hbci-zka.de/institute/institut_auswahl.htm (use the PIN/TAN URL) +$options->bankCode = '12030000'; // Your bank code / Bankleitzahl +$options->productName = 'Dummy'; // The number you receive after registration / FinTS-Registrierungsnummer. Not all banks require this just to retrieve the BPD. +$options->productVersion = '1.0'; // Your own Software product version +$bpd = \Fhp\FinTs::fetchBpd($options, new \Tests\Fhp\CLILogger()); + +print_r($bpd); // Tip: Put a breakpoint on this line. diff --git a/vendor/nemiah/php-fints/Samples/browser.php b/vendor/nemiah/php-fints/Samples/browser.php new file mode 100755 index 0000000..b6cc2db --- /dev/null +++ b/vendor/nemiah/php-fints/Samples/browser.php @@ -0,0 +1,252 @@ +action)) { + $options = new \Fhp\Options\FinTsOptions(); + $options->productName = $request->productName; + $options->productVersion = $request->productVersion; + $options->url = $request->url; + $options->bankCode = $request->bankCode; + $credentials = \Fhp\Options\Credentials::create($request->username, $request->pin); + + $persistedInstance = $persistedAction = null; + function handleRequest(\stdClass $request, \Fhp\FinTs $fints) + { + global $persistedAction; + switch ($request->action) { + case 'getTanModes': + return array_map(function ($mode) { + return [ + 'id' => $mode->getId(), 'name' => $mode->getName(), 'isDecoupled' => $mode->isDecoupled(), + 'needsTanMedium' => $mode->needsTanMedium(), + ]; + }, array_values($fints->getTanModes())); + case 'getTanMedia': + return array_map(function ($medium) { + return ['name' => $medium->getName(), 'phoneNumber' => $medium->getPhoneNumber()]; + }, $fints->getTanMedia(intval($request->tanmode))); + case 'login': + $fints->selectTanMode(intval($request->tanmode), $request->tanmedium ?? null); + $login = $fints->login(); + if ($login->needsTan()) { + $tanRequest = $login->getTanRequest(); + $persistedAction = serialize($login); + return ['result' => 'needsTan', 'challenge' => $tanRequest->getChallenge()]; + } + return ['result' => 'success']; + case 'submitTan': + $fints->submitTan(unserialize($persistedAction), $request->tan); + $persistedAction = null; + return ['result' => 'success']; + case 'checkDecoupledSubmission': + if ($fints->checkDecoupledSubmission(unserialize($persistedAction))) { + $persistedAction = null; + return ['result' => 'success']; + } else { + // IMPORTANT: If you pull this example code apart in your real application code, remember that after + // calling checkDecoupledSubmission(), you need to call $fints->persist() again, just like this + // example code will do after we return from handleRequest() here. + return ['result' => 'ongoing']; + } + case 'getBalances': + $getAccounts = \Fhp\Action\GetSEPAAccounts::create(); + $fints->execute($getAccounts); + if ($getAccounts->needsTan()) { + throw new \Fhp\UnsupportedException( + "This simple example code does not support strong authentication on GetSEPAAccounts calls. " . + "But in your real application, you can do so analogously to how login() is handled above." + ); + } + + $getBalances = \Fhp\Action\GetBalance::create($getAccounts->getAccounts()[0], true); + $fints->execute($getBalances); + if ($getAccounts->needsTan()) { + throw new \Fhp\UnsupportedException( + "This simple example code does not support strong authentication on GetBalance calls. " . + "But in your real application, you can do so analogously to how login() is handled above." + ); + } + + $balances = []; + foreach ($getBalances->getBalances() as $balance) { + $sdo = $balance->getGebuchterSaldo(); + $balances[$balance->getAccountInfo()->getAccountNumber()] = + $sdo->getAmount() . ' ' . $sdo->getCurrency(); + } + return $balances; + case 'logout': + $fints->close(); + return ['result' => 'success']; + default: + throw new \InvalidArgumentException("Unknown action $request->action"); + } + } + + $sessionfile = __DIR__ . "/session_$request->sessionid.data"; + if (file_exists($sessionfile)) { + list($persistedInstance, $persistedAction) = unserialize(file_get_contents($sessionfile)); + } + $fints = \Fhp\FinTs::new($options, $credentials, $persistedInstance); + $response = handleRequest($request, $fints); + file_put_contents($sessionfile, serialize([$fints->persist(), $persistedAction])); + + header('Content-Type: application/json'); + echo json_encode($response); + return; +} + +?> + + + + phpFinTS Beispielanwendung + + + +

phpFinTS Beispielanwendung

+

Diese Beispielanwendung meldet sich im Onlinebanking an und holt die aktuellen Kontostände ab.

+

HINWEIS: Wenn sich Bank oder Benutzer ändern, sollte diese Seite erst neu geladen werden!

+
+ +
+ + + + + + + + + + + +
Registrierungsnummer:
Produktversion:
Bank URL:
Bankleitzahl:
Benutzerkennung:
Passwort/PIN:
+
+
+

+    
+
+
diff --git a/vendor/nemiah/php-fints/Samples/directDebit_Sephpa.php b/vendor/nemiah/php-fints/Samples/directDebit_Sephpa.php
new file mode 100755
index 0000000..a3c076d
--- /dev/null
+++ b/vendor/nemiah/php-fints/Samples/directDebit_Sephpa.php
@@ -0,0 +1,51 @@
+execute($getSepaAccounts);
+if ($getSepaAccounts->needsTan()) {
+    handleStrongAuthentication($getSepaAccounts); // See login.php for the implementation.
+}
+$oneAccount = $getSepaAccounts->getAccounts()[0];
+
+// generate a SepaDirectDebit object (pain.008.003.02).
+$directDebitFile = new \AbcAeffchenSephpa\SephpaDirectDebit(
+    'Name of Application',
+    'Message Identifier',
+    \AbcAeffchenSephpa\SephpaDirectDebit::SEPA_PAIN_008_003_02
+);
+/*
+ *
+ * Configure the Direct Debit File
+ * $directDebitCollection = $directDebitFile->addCollection([...]);
+ * $directDebitCollection->addPayment([...]);
+ *
+ * See documentation:
+ * https://github.com/AbcAeffchen/Sephpa
+ *
+*/
+$xml = $directDebitFile->generateOutput(['zipToOneFile' => false])[0]['data'];
+
+$sendSEPADirectDebit = \Fhp\Action\SendSEPADirectDebit::create($oneAccount, $xml);
+$fints->execute($sendSEPADirectDebit);
+if ($sendSEPADirectDebit->needsTan()) {
+    handleStrongAuthentication($sendSEPADirectDebit); // See login.php for the implementation.
+}
diff --git a/vendor/nemiah/php-fints/Samples/directDebit_phpSepaXml.php b/vendor/nemiah/php-fints/Samples/directDebit_phpSepaXml.php
new file mode 100755
index 0000000..2748592
--- /dev/null
+++ b/vendor/nemiah/php-fints/Samples/directDebit_phpSepaXml.php
@@ -0,0 +1,67 @@
+add(new \DateInterval('P1D'));
+
+$sepaDD = new SEPADirectDebitBasic([
+    'messageID' => time(),
+    'paymentID' => time()
+]);
+
+$sepaDD->setCreditor(new SEPACreditor([ //this is you
+    'name' => 'My Company',
+    'iban' => 'DE68210501700012345678',
+    'bic' => 'DEUTDEDB400',
+    'identifier' => 'DE98ZZZ09999999999',
+]));
+
+$sepaDD->addDebitor(new SEPADebitor([ //this is who you want to get money from
+    'transferID' => 'R20170100',
+    'mandateID' => 'aeicznaeibcnt',
+    'mandateDateOfSignature' => '2017-05-05',
+    'name' => 'Max Mustermann',
+    'iban' => 'CH9300762011623852957',
+    'bic' => 'GENODEF1P15',
+    'amount' => 48.78,
+    'currency' => 'EUR',
+    'info' => 'R20170100 vom 09.05.2017',
+    'requestedCollectionDate' => $dt,
+    'sequenceType' => 'OOFF',
+    'type' => 'CORE',
+]));
+
+// Just pick the first account, for demonstration purposes. You could also have the user choose, or have SEPAAccount
+// hard-coded and not call getSEPAAccounts() at all.
+$getSepaAccounts = \Fhp\Action\GetSEPAAccounts::create();
+$fints->execute($getSepaAccounts);
+if ($getSepaAccounts->needsTan()) {
+    handleStrongAuthentication($getSepaAccounts); // See login.php for the implementation.
+}
+$oneAccount = $getSepaAccounts->getAccounts()[0];
+
+$sendSEPADirectDebit = \Fhp\Action\SendSEPADirectDebit::create($oneAccount, $sepaDD->toXML('pain.008.001.02'));
+$fints->execute($sendSEPADirectDebit);
+if ($sendSEPADirectDebit->needsTan()) {
+    handleStrongAuthentication($sendSEPADirectDebit); // See login.php for the implementation.
+}
diff --git a/vendor/nemiah/php-fints/Samples/init.php b/vendor/nemiah/php-fints/Samples/init.php
new file mode 100755
index 0000000..b3936c8
--- /dev/null
+++ b/vendor/nemiah/php-fints/Samples/init.php
@@ -0,0 +1,24 @@
+url = ''; // HBCI / FinTS Url can be found here: https://www.hbci-zka.de/institute/institut_auswahl.htm (use the PIN/TAN URL)
+$options->bankCode = ''; // Your bank code / Bankleitzahl
+$options->productName = ''; // The number you receive after registration / FinTS-Registrierungsnummer
+$options->productVersion = '1.0'; // Your own Software product version
+$credentials = \Fhp\Options\Credentials::create('username', 'pin'); // This is NOT the PIN of your bank card!
+$fints = \Fhp\FinTs::new($options, $credentials);
+$fints->setLogger(new \Tests\Fhp\CLILogger());
+
+// Usage:
+// $fints = require_once 'init.php';
+return $fints;
diff --git a/vendor/nemiah/php-fints/Samples/login.php b/vendor/nemiah/php-fints/Samples/login.php
new file mode 100755
index 0000000..f2d020a
--- /dev/null
+++ b/vendor/nemiah/php-fints/Samples/login.php
@@ -0,0 +1,236 @@
+getSelectedTanMode()->isDecoupled()) {
+        handleDecoupled($action);
+    } else {
+        handleTan($action);
+    }
+}
+
+/**
+ * This function handles strong authentication for the case where the user needs to enter a TAN into the PHP
+ * application.
+ *
+ * @param \Fhp\BaseAction $action Some action that requires a TAN.
+ * @throws CurlException|UnexpectedResponseException|ServerException See {@link FinTs::execute()} for details.
+ */
+function handleTan(Fhp\BaseAction $action)
+{
+    global $fints, $options, $credentials;
+
+    // Find out what sort of TAN we need, tell the user about it.
+    $tanRequest = $action->getTanRequest();
+    echo 'The bank requested a TAN.';
+    if ($tanRequest->getChallenge() !== null) {
+        echo ' Instructions: ' . $tanRequest->getChallenge();
+    }
+    echo "\n";
+    if ($tanRequest->getTanMediumName() !== null) {
+        echo 'Please use this device: ' . $tanRequest->getTanMediumName() . "\n";
+    }
+
+    // Challenge Image for PhotoTan/ChipTan
+    if ($tanRequest->getChallengeHhdUc()) {
+        try {
+            $flicker = new \Fhp\Model\FlickerTan\TanRequestChallengeFlicker($tanRequest->getChallengeHhdUc());
+            echo 'There is a challenge flicker.' . PHP_EOL;
+            // save or output svg
+            $flickerPattern = $flicker->getFlickerPattern();
+            // other renderers can be implemented with this pattern
+            $svg = new \Fhp\Model\FlickerTan\SvgRenderer($flickerPattern);
+            echo $svg->getImage();
+        } catch (InvalidArgumentException $e) {
+            // was not a flicker
+            $challengeImage = new \Fhp\Model\TanRequestChallengeImage(
+                $tanRequest->getChallengeHhdUc()
+            );
+            echo 'There is a challenge image.' . PHP_EOL;
+            // Save the challenge image somewhere
+            // Alternative: HTML sample code
+            echo '' . PHP_EOL;
+        }
+    }
+
+    // Optional: Instead of printing the above to the console, you can relay the information (challenge and TAN medium)
+    // to the user in any other way (through your REST API, a push notification, ...). If waiting for the TAN requires
+    // you to interrupt this PHP process and the TAN will arrive in a fresh (HTTP/REST/...) request, you can do so:
+    if ($optionallyPersistEverything = false) {
+        $persistedAction = serialize($action);
+        $persistedFints = $fints->persist();
+
+        // These are two strings (watch out, they are NOT necessarily UTF-8 encoded), which you can store anywhere.
+        // This example code stores them in a text file, but you might write them to your database (use a BLOB, not a
+        // CHAR/TEXT field to allow for arbitrary encoding) or in some other storage (possibly base64-encoded to make it
+        // ASCII).
+        file_put_contents(__DIR__ . '/state.txt', serialize([$persistedFints, $persistedAction]));
+    }
+
+    // Ask the user for the TAN. ----------------------------------------------------------------------------------------
+    // IMPORTANT: In your real application, you cannot use fgets(STDIN) of course (unless you're running PHP only as a
+    // command line application). So you instead want to send a response to the user. This means that, after executing
+    // the first half of handleTan() above, your real application will terminate the PHP process. The second half of
+    // handleTan() will likely live elsewhere in your application code (i.e. you will have two functions for the TAN
+    // handling, not just one like in this simplified example). You *only* need to carry over the $persistedInstance
+    // and the $persistedAction (which are simple strings) by storing them in some database or file where you can load
+    // them again in a new PHP process when the user sends the TAN.
+    echo "Please enter the TAN:\n";
+    $tan = trim(fgets(STDIN));
+
+    // Optional: If the state was persisted above, we can restore it now (imagine this is a new PHP process).
+    if ($optionallyPersistEverything) {
+        $restoredState = file_get_contents(__DIR__ . '/state.txt');
+        list($persistedInstance, $persistedAction) = unserialize($restoredState);
+        $fints = \Fhp\FinTs::new($options, $credentials, $persistedInstance);
+        $action = unserialize($persistedAction);
+    }
+
+    echo "Submitting TAN: $tan\n";
+    $fints->submitTan($action, $tan);
+}
+
+/**
+ * This function handles strong authentication for the case where the user needs to confirm the action on another
+ * device. Note: Depending on the banks you need compatibility with, you may not need to implement decoupled
+ * authentication at all, i.e., you could filter out any decoupled TanModes when letting the user choose.
+ *
+ * @param \Fhp\BaseAction $action Some action that requires decoupled authentication.
+ * @throws CurlException|UnexpectedResponseException|ServerException See {@link FinTs::execute()} for details.
+ */
+function handleDecoupled(Fhp\BaseAction $action)
+{
+    global $fints, $options, $credentials;
+
+    $tanMode = $fints->getSelectedTanMode();
+    $tanRequest = $action->getTanRequest();
+    echo 'The bank requested authentication on another device.';
+    if ($tanRequest->getChallenge() !== null) {
+        echo ' Instructions: ' . $tanRequest->getChallenge();
+    }
+    echo "\n";
+    if ($tanRequest->getTanMediumName() !== null) {
+        echo 'Please check this device: ' . $tanRequest->getTanMediumName() . "\n";
+    }
+
+    // Just like in handleTan() above, we have the option to interrupt the PHP process at this point. In fact, the
+    // for-loop below that deals with the polling may even be running on the client side of your larger application,
+    // polling your application server regularly, which spawns a new PHP process each time. Here, we demonstrate this by
+    // persisting the instance to a local file and restoring it (even though that's not technically necessary for a
+    // single-process CLI script like this).
+    if ($optionallyPersistEverything = false) {
+        $persistedAction = serialize($action);
+        $persistedFints = $fints->persist();
+        // See handleTan() for how to deal with this in practice.
+        file_put_contents(__DIR__ . '/state.txt', serialize([$persistedFints, $persistedAction]));
+    }
+
+    // IMPORTANT: In your real application, you don't have to use sleep() in PHP. You can persist the state in the same
+    // way as in handleTan() and restore it later. This allows you to use some other timer mechanism (e.g. in the user's
+    // browser). This PHP sample code just serves to show the *logic* of the polling. Alternatively, you can even do
+    // without polling entirely and just let the user confirm manually in all cases (i.e. only implement the `else`
+    // branch below).
+    if ($tanMode->allowsAutomatedPolling()) {
+        echo "Polling server to detect when the decoupled authentication is complete.\n";
+        sleep($tanMode->getFirstDecoupledCheckDelaySeconds());
+        for ($attempt = 0;
+             $tanMode->getMaxDecoupledChecks() === 0 || $attempt < $tanMode->getMaxDecoupledChecks();
+             ++$attempt
+        ) {
+            // Optional: If the state was persisted above, we can restore it now (imagine this is a new PHP process).
+            if ($optionallyPersistEverything) {
+                $restoredState = file_get_contents(__DIR__ . '/state.txt');
+                list($persistedInstance, $persistedAction) = unserialize($restoredState);
+                $fints = \Fhp\FinTs::new($options, $credentials, $persistedInstance);
+                $action = unserialize($persistedAction);
+            }
+
+            // Check if we're done.
+            if ($fints->checkDecoupledSubmission($action)) {
+                echo "Confirmed.\n";
+                return;
+            }
+            echo "Still waiting...\n";
+
+            // THIS IS CRUCIAL if you're using persistence in between polls. You must re-persist() the instance after
+            // calling checkDecoupledSubmission() and before calling it the next time. Don't reuse the
+            // $persistedInstance from above multiple times.
+            if ($optionallyPersistEverything) {
+                $persistedAction = serialize($action);
+                $persistedFints = $fints->persist();
+                file_put_contents(__DIR__ . '/state.txt', serialize([$persistedFints, $persistedAction]));
+            }
+
+            sleep($tanMode->getPeriodicDecoupledCheckDelaySeconds());
+        }
+        throw new RuntimeException("Not confirmed after $attempt attempts, which is the limit.");
+    } elseif ($tanMode->allowsManualConfirmation()) {
+        echo "Please type 'done' and hit Return when you've completed the authentication on the other device.\n";
+        while (trim(fgets(STDIN)) !== 'done') {
+            echo "Try again.\n";
+        }
+        echo "Confirming that the action is done.\n";
+        if (!$fints->checkDecoupledSubmission($action)) {
+            throw new RuntimeException(
+                "You confirmed that the authentication for action was copmleted, but the server does not think so."
+            );
+        }
+        echo "Confirmed\n";
+    } else {
+        throw new AssertionError('Server allows neither automated polling nor manual confirmation');
+    }
+}
+
+// Select TAN mode and possibly medium. If you're not sure what this is about, read and run tanModesAndMedia.php first.
+$tanMode = 900; // This is just a placeholder you need to fill!
+$tanMedium = null; // This is just a placeholder you may need to fill.
+$fints->selectTanMode($tanMode, $tanMedium);
+
+// Log in.
+$login = $fints->login();
+if ($login->needsTan()) {
+    handleStrongAuthentication($login);
+}
+
+// Usage:
+// $fints = require_once 'login.php';
+return $fints;
diff --git a/vendor/nemiah/php-fints/Samples/statementOfAccount.php b/vendor/nemiah/php-fints/Samples/statementOfAccount.php
new file mode 100755
index 0000000..1bbdb33
--- /dev/null
+++ b/vendor/nemiah/php-fints/Samples/statementOfAccount.php
@@ -0,0 +1,47 @@
+execute($getSepaAccounts);
+if ($getSepaAccounts->needsTan()) {
+    handleStrongAuthentication($getSepaAccounts); // See login.php for the implementation.
+}
+$oneAccount = $getSepaAccounts->getAccounts()[0];
+
+$from = new \DateTime('2022-07-15');
+$to = new \DateTime();
+$getStatement = \Fhp\Action\GetStatementOfAccount::create($oneAccount, $from, $to, false, true);
+$fints->execute($getStatement);
+if ($getStatement->needsTan()) {
+    handleStrongAuthentication($getStatement); // See login.php for the implementation.
+}
+
+$soa = $getStatement->getStatement();
+foreach ($soa->getStatements() as $statement) {
+    echo $statement->getDate()->format('Y-m-d') . ': Start Saldo: '
+        . ($statement->getCreditDebit() == \Fhp\Model\StatementOfAccount\Statement::CD_DEBIT ? '-' : '')
+        . $statement->getStartBalance() . PHP_EOL;
+    echo 'Transactions:' . PHP_EOL;
+    echo '=======================================' . PHP_EOL;
+    foreach ($statement->getTransactions() as $transaction) {
+        echo "Booked      : " . ($transaction->getBooked() ? "true" : "false") . PHP_EOL;
+        echo 'Amount      : ' . ($transaction->getCreditDebit() == \Fhp\Model\StatementOfAccount\Transaction::CD_DEBIT ? '-' : '') . $transaction->getAmount() . PHP_EOL;
+        echo 'Booking text: ' . $transaction->getBookingText() . PHP_EOL;
+        echo 'Name        : ' . $transaction->getName() . PHP_EOL;
+        echo 'Description : ' . $transaction->getMainDescription() . PHP_EOL;
+        echo 'EREF        : ' . $transaction->getEndToEndID() . PHP_EOL;
+        echo '=======================================' . PHP_EOL . PHP_EOL;
+    }
+}
+echo 'Found ' . count($soa->getStatements()) . ' statements.' . PHP_EOL;
diff --git a/vendor/nemiah/php-fints/Samples/statementOfHoldings.php b/vendor/nemiah/php-fints/Samples/statementOfHoldings.php
new file mode 100755
index 0000000..e2843db
--- /dev/null
+++ b/vendor/nemiah/php-fints/Samples/statementOfHoldings.php
@@ -0,0 +1,40 @@
+execute($getSepaAccounts);
+if ($getSepaAccounts->needsTan()) {
+    handleStrongAuthentication($getSepaAccounts); // See login.php for the implementation.
+}
+$oneAccount = $getSepaAccounts->getAccounts()[0];
+
+$getStatement = \Fhp\Action\GetDepotAufstellung::create($oneAccount);
+$fints->execute($getStatement);
+if ($getStatement->needsTan()) {
+    handleStrongAuthentication($getStatement); // See login.php for the implementation.
+}
+
+$soa = $getStatement->getStatement();
+foreach ($soa->getHoldings() as $holding) {
+    echo '=======================================' . PHP_EOL;
+    echo 'Name        : ' . $holding->getName() . PHP_EOL;
+    echo 'Amount      : ' . $holding->getAmount() . PHP_EOL;
+    echo 'Price       : ' . $holding->getPrice() . ' ' . $holding->getCurrency() . PHP_EOL;
+    echo 'WKN         : ' . $holding->getWKN() . PHP_EOL;
+    echo 'ISIN        : ' . $holding->getISIN() . PHP_EOL;
+    echo 'B-Datum     : ' . $holding->getDate()->format('Y-m-d') . PHP_EOL;
+    echo '=======================================' . PHP_EOL . PHP_EOL;
+}
+echo 'Found ' . count($soa->getHoldings()) . ' statements.' . PHP_EOL;
+echo 'Depotwert: ' . $getStatement->getDepotWert() . PHP_EOL;
diff --git a/vendor/nemiah/php-fints/Samples/tanModesAndMedia.php b/vendor/nemiah/php-fints/Samples/tanModesAndMedia.php
new file mode 100755
index 0000000..d245507
--- /dev/null
+++ b/vendor/nemiah/php-fints/Samples/tanModesAndMedia.php
@@ -0,0 +1,71 @@
+getTanModes();
+if (empty($tanModes)) {
+    echo 'Your bank does not support any TAN modes!';
+    return;
+}
+
+echo "Here are the available TAN modes:\n";
+$tanModeNames = array_map(function (\Fhp\Model\TanMode $tanMode) {
+    return $tanMode->getName();
+}, $tanModes);
+print_r($tanModeNames);
+
+echo "Which one do you want to use? Index:\n";
+$tanModeIndex = trim(fgets(STDIN));
+if (!is_numeric($tanModeIndex) || !array_key_exists(intval($tanModeIndex), $tanModes)) {
+    echo 'Invalid index!';
+    return;
+}
+$tanMode = $tanModes[intval($tanModeIndex)];
+echo 'You selected ' . $tanMode->getName() . "\n";
+
+// In case the selected TAN mode requires a TAN medium (e.g. if the user picked mTAN, they may have to pick the mobile
+// device on which they want to receive TANs), let the user pick that too.
+if ($tanMode->needsTanMedium()) {
+    $tanMedia = $fints->getTanMedia($tanMode);
+    if (empty($tanMedia)) {
+        echo 'Your bank did not provide any TAN media, even though it requires selecting one!';
+        return;
+    }
+
+    echo "Here are the available TAN media:\n";
+    $tanMediaNames = array_map(function (\Fhp\Model\TanMedium $tanMedium) {
+        return $tanMedium->getName();
+    }, $tanMedia);
+    print_r($tanMediaNames);
+
+    echo "Which one do you want to use? Index:\n";
+    $tanMediumIndex = trim(fgets(STDIN));
+    if (!is_numeric($tanMediumIndex) || !array_key_exists(intval($tanMediumIndex), $tanMedia)) {
+        echo 'Invalid index!';
+        return;
+    }
+    $tanMedium = $tanMedia[intval($tanMediumIndex)];
+    echo 'You selected ' . $tanMedium->getName() . "\n";
+} else {
+    $tanMedium = null;
+}
+
+// Announce the selection to the FinTS library.
+$fints->selectTanMode($tanMode, $tanMedium);
+
+// Within your application, you should persist these choices somewhere (e.g. database), so that the user does not have
+// to select them again the future. Note that it is sufficient to persist the ID/name, i.e. this is equivalent:
+$fints->selectTanMode($tanMode->getId(), $tanMedium->getName());
+
+// Now you could do $fints->login(), see login.php for that. For this example, we'll just close the connection.
+$fints->close();
+echo 'Done';
diff --git a/vendor/nemiah/php-fints/Samples/transfer.php b/vendor/nemiah/php-fints/Samples/transfer.php
new file mode 100755
index 0000000..083d8e6
--- /dev/null
+++ b/vendor/nemiah/php-fints/Samples/transfer.php
@@ -0,0 +1,54 @@
+add(new \DateInterval('P1D'));
+
+$sepaDD = new SEPATransfer([
+    'messageID' => time(),
+    'paymentID' => time(),
+]);
+
+$sepaDD->setDebitor(new SEPADebitor([ //this is you
+    'name' => 'My Company',
+    'iban' => 'DE68210501700012345678',
+    'bic' => 'DEUTDEDB400', //,
+    //'identifier' => 'DE98ZZZ09999999999'
+]));
+
+$sepaDD->addCreditor(new SEPACreditor([ //this is who you want to send money to
+    //'paymentID' => '20170403652',
+    'info' => '20170403652',
+    'name' => 'Max Mustermann',
+    'iban' => 'CH9300762011623852957',
+    'bic' => 'GENODEF1P15',
+    'amount' => 48.78,
+    'currency' => 'EUR',
+    'reqestedExecutionDate' => $dt,
+]));
+
+$sendSEPATransfer = \Fhp\Action\SendSEPATransfer::create($oneAccount, $sepaDD->toXML());
+$fints->execute($sendSEPATransfer);
+if ($sendSEPATransfer->needsTan()) {
+    handleStrongAuthentication($sendSEPATransfer); // See login.php for the implementation.
+}
diff --git a/vendor/nemiah/php-fints/composer.json b/vendor/nemiah/php-fints/composer.json
new file mode 100755
index 0000000..0da3b32
--- /dev/null
+++ b/vendor/nemiah/php-fints/composer.json
@@ -0,0 +1,39 @@
+{
+  "name": "nemiah/php-fints",
+  "description": "PHP Library for the protocols fints and hbci",
+  "homepage": "https://github.com/nemiah/phpFinTS",
+  "version": "3.7.0",
+  "license": "MIT",
+  "autoload": {
+    "psr-0": {
+      "Fhp": "lib/",
+      "Tests\\Fhp": "lib/"
+    }
+  },
+  "require": {
+    "php": ">=8.0",
+    "psr/log": "^1|^2|^3",
+    "ext-curl": "*",
+    "ext-mbstring": "*"
+  },
+  "require-dev": {
+    "phpunit/phpunit": "^9.5",
+    "php-mock/php-mock-phpunit": "^2.6",
+    "friendsofphp/php-cs-fixer": "^3.0"
+  },
+  "suggest": {
+    "monolog/monolog": "Allow sending log messages to a variety of different handlers",
+    "abcaeffchen/sephpa": "1.*",
+    "nemiah/php-sepa-xml": "dev-master"
+  },
+  "scripts": {
+    "test": [
+      "phpunit"
+    ],
+    "check": [
+      "@cs"
+    ],
+    "cs": "php-cs-fixer fix -v --diff --dry-run",
+    "cs-fix": "php-cs-fixer fix -v --diff"
+  }
+}
diff --git a/vendor/nemiah/php-fints/csfixer-check.sh b/vendor/nemiah/php-fints/csfixer-check.sh
new file mode 100755
index 0000000..9c76ae3
--- /dev/null
+++ b/vendor/nemiah/php-fints/csfixer-check.sh
@@ -0,0 +1,36 @@
+#!/bin/bash
+#
+# When this is run as part of a Travis test for a pull request, then it ensures that none of the touched files has any
+# PHP CS Fixer warnings.
+# From: https://github.com/FriendsOfPHP/PHP-CS-Fixer#using-php-cs-fixer-on-ci
+
+if [ -z "$TRAVIS_COMMIT_RANGE" ]
+then
+# TRAVIS_COMMIT_RANGE "is empty for builds triggered by the initial commit of a new branch"
+# From: https://docs.travis-ci.com/user/environment-variables/
+  echo "Variable TRAVIS_COMMIT_RANGE not set, falling back to full git diff"
+  TRAVIS_COMMIT_RANGE=.
+fi
+
+IFS='
+'
+CHANGED_FILES=$(git diff --name-only --diff-filter=ACMRTUXB "$TRAVIS_COMMIT_RANGE")
+if [ "$?" -ne "0" ]
+then
+  echo "Error: git diff response code > 0, aborting"
+  exit 1
+fi
+
+if [ -z "${CHANGED_FILES}" ]
+then
+  echo "0 changed files found, exiting"
+  exit 0
+fi
+
+# February 2022: PHP CS FIXER is currently not PHP 8.1 compatible:
+# "you may experience code modified in a wrong way"
+# "To ignore this requirement please set `PHP_CS_FIXER_IGNORE_ENV`."
+export PHP_CS_FIXER_IGNORE_ENV="1"
+
+if ! echo "${CHANGED_FILES}" | grep -qE "^(\\.php_cs(\\.dist)?|composer\\.lock)$"; then EXTRA_ARGS=$(printf -- '--path-mode=intersection\n--\n%s' "${CHANGED_FILES}"); else EXTRA_ARGS=''; fi
+vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.php -v --dry-run --stop-on-violation --using-cache=no ${EXTRA_ARGS} || (echo "php-cs-fixer failed" && exit 1)
\ No newline at end of file
diff --git a/vendor/nemiah/php-fints/disallowtabs.sh b/vendor/nemiah/php-fints/disallowtabs.sh
new file mode 100755
index 0000000..3d91702
--- /dev/null
+++ b/vendor/nemiah/php-fints/disallowtabs.sh
@@ -0,0 +1,37 @@
+#!/bin/bash
+#
+# When this is run as part of a Travis test for a pull request, then it ensures that none of the added lines (compared
+# to the base branch of the pull request) use tabs for indentations.
+# Adapted from https://github.com/mrc/git-hook-library/blob/master/pre-commit.no-tabs
+
+# Abort if any of the inner commands (particularly the git commands) fails.
+set -e
+set -o pipefail
+
+if [ -z ${TRAVIS_PULL_REQUEST} ]; then
+    echo "Expected environment variable TRAVIS_PULL_REQUEST"
+    exit 2
+elif [ "${TRAVIS_PULL_REQUEST}" == "false" ]; then
+    echo "Not a Travis pull request, skipping."
+    exit 0
+fi
+
+# Make sure that we have a local copy of the relevant commits (otherwise git diff won't work).
+git remote set-branches --add origin ${TRAVIS_BRNACH}
+git fetch
+
+# Compute the diff from the PR's target branch to its HEAD commit.
+target_branch="origin/${TRAVIS_BRANCH}"
+the_diff=$(git diff "${target_branch}...HEAD")
+
+# Make sure that there are no tabs in the indentation part of added lines.
+if echo "${the_diff}" | egrep '^\+\s*	' >/dev/null; then
+    echo -e "\e[31mError: The changes contain a tab for indentation\e[0m, which is against this repo's policy."
+    echo "Target branch: origin/${TRAVIS_BRANCH}"
+    echo "Commit range: ${TRAVIS_COMMIT_RANGE}"
+    echo "The following tabs were detected:"
+    echo "${the_diff}" | egrep '^(\+\s*	|\+\+\+|@@)'
+    exit 1
+else
+    echo "No new tabs detected."
+fi
diff --git a/vendor/nemiah/php-fints/lib/Fhp/Action/GetBalance.php b/vendor/nemiah/php-fints/lib/Fhp/Action/GetBalance.php
new file mode 100755
index 0000000..00c053f
--- /dev/null
+++ b/vendor/nemiah/php-fints/lib/Fhp/Action/GetBalance.php
@@ -0,0 +1,129 @@
+account = $account;
+        $result->allAccounts = $allAccounts;
+        return $result;
+    }
+
+    /**
+     * @deprecated Beginning from PHP7.4 __unserialize is used for new generated strings, then this method is only used for previously generated strings - remove after May 2023
+     */
+    public function serialize(): string
+    {
+        return serialize($this->__serialize());
+    }
+
+    public function __serialize(): array
+    {
+        return [
+            parent::__serialize(),
+            $this->account, $this->allAccounts,
+        ];
+    }
+
+    /**
+     * @deprecated Beginning from PHP7.4 __unserialize is used for new generated strings, then this method is only used for previously generated strings - remove after May 2023
+     *
+     * @param string $serialized
+     * @return void
+     */
+    public function unserialize($serialized)
+    {
+        self::__unserialize(unserialize($serialized));
+    }
+
+    public function __unserialize(array $serialized): void
+    {
+        list(
+            $parentSerialized,
+            $this->account, $this->allAccounts
+        ) = $serialized;
+
+        is_array($parentSerialized) ?
+            parent::__unserialize($parentSerialized) :
+            parent::unserialize($parentSerialized);
+    }
+
+    /**
+     * @return HISAL[]
+     */
+    public function getBalances()
+    {
+        $this->ensureDone();
+        return $this->response;
+    }
+
+    /** {@inheritdoc} */
+    protected function createRequest(BPD $bpd, ?UPD $upd)
+    {
+        /** @var BaseSegment $hisals */
+        $hisals = $bpd->requireLatestSupportedParameters('HISALS');
+        switch ($hisals->getVersion()) {
+            case 4:
+                return HKSALv4::create(Kto::fromAccount($this->account));
+            case 5:
+                return HKSALv5::create(KtvV3::fromAccount($this->account), $this->allAccounts);
+            case 6:
+                return HKSALv6::create(KtvV3::fromAccount($this->account), $this->allAccounts);
+            case 7:
+                return HKSALv7::create(Kti::fromAccount($this->account), $this->allAccounts);
+            default:
+                throw new UnsupportedException('Unsupported HKSAL version: ' . $hisals->getVersion());
+        }
+    }
+
+    /** {@inheritdoc} */
+    public function processResponse(Message $response)
+    {
+        parent::processResponse($response);
+
+        $responseSegments = $response->findSegments(HISAL::class);
+        if (count($responseSegments) === 0) {
+            throw new UnexpectedResponseException('No HISAL segments received!');
+        }
+        $this->response = array_merge($this->response, $responseSegments);
+    }
+}
diff --git a/vendor/nemiah/php-fints/lib/Fhp/Action/GetDepotAufstellung.php b/vendor/nemiah/php-fints/lib/Fhp/Action/GetDepotAufstellung.php
new file mode 100755
index 0000000..3f32987
--- /dev/null
+++ b/vendor/nemiah/php-fints/lib/Fhp/Action/GetDepotAufstellung.php
@@ -0,0 +1,167 @@
+account = $account;
+        return $result;
+    }
+
+    /**
+     * @deprecated Beginning from PHP7.4 __unserialize is used for new generated strings, then this method is only used for previously generated strings - remove after May 2023
+     */
+    public function serialize(): string
+    {
+        return serialize($this->__serialize());
+    }
+
+    public function __serialize(): array
+    {
+        return [
+            parent::__serialize(),
+            $this->account,
+        ];
+    }
+
+    /**
+     * @deprecated Beginning from PHP7.4 __unserialize is used for new generated strings, then this method is only used for previously generated strings - remove after May 2023
+     *
+     * @param string $serialized
+     * @return void
+     */
+    public function unserialize($serialized)
+    {
+        self::__unserialize(unserialize($serialized));
+    }
+
+    public function __unserialize(array $serialized): void
+    {
+        list(
+            $parentSerialized,
+            $this->account
+        ) = $serialized;
+
+        is_array($parentSerialized) ?
+            parent::__unserialize($parentSerialized) :
+            parent::unserialize($parentSerialized);
+    }
+
+    /**
+     * @return string The raw MT535 data received from the server.
+     * @noinspection PhpUnused
+     */
+    public function getRawMT535(): string
+    {
+        $this->ensureDone();
+        return $this->rawMT535;
+    }
+
+    public function getStatement(): StatementOfHoldings
+    {
+        $this->ensureDone();
+        return $this->statement;
+    }
+
+    public function getDepotWert(): float
+    {
+        $this->ensureDone();
+        return $this->depotWert;
+    }
+
+    /** {@inheritdoc} */
+    protected function createRequest(BPD $bpd, ?UPD $upd)
+    {
+        /** @var HIWPDS $hiwpds */
+        $hiwpds = $bpd->requireLatestSupportedParameters('HIWPDS');
+
+        switch ($hiwpds->getVersion()) {
+            case 5:
+                return HKWPDv5::create(KtvV3::fromAccount($this->account));
+            default:
+                throw new UnsupportedException('Unsupported HKWPD version: ' . $hiwpds->getVersion());
+        }
+    }
+
+    /** {@inheritdoc} */
+    public function processResponse(Message $response)
+    {
+        parent::processResponse($response);
+
+        $isUnavailable = $response->findRueckmeldung(Rueckmeldungscode::NICHT_VERFUEGBAR) !== null;
+        $responseHiwpd = $response->findSegments(HIWPDv5::class);
+
+        $numResponseSegments = count($responseHiwpd);
+        if (!$isUnavailable && $numResponseSegments < count($this->getRequestSegmentNumbers())) {
+            throw new UnexpectedResponseException("Only got $numResponseSegments HIWPD response segments!");
+        }
+
+        /** @var HIWPD $hiwpd */
+        foreach ($responseHiwpd as $hiwpd) {
+            $this->rawMT535 .= $hiwpd->getDepotaufstellung()->getData();
+        }
+
+        // Note: Pagination boundaries may cut in the middle of the MT535 data, so it is not possible to parse a partial
+        // reponse before having received all pages.
+        if (!$this->hasMorePages()) {
+            $this->parseMt535();
+        }
+    }
+
+    private function parseMt535()
+    {
+        try {
+            // Note: Some banks encode their MT 535 data as SWIFT/ISO-8859 like it should be according to the
+            // specification, others just send UTF-8, so we try to detect it here.
+            $rawMT535 = mb_detect_encoding($this->rawMT535, 'UTF-8', true) === false
+                ? mb_convert_encoding($this->rawMT535, 'UTF-8', 'ISO-8859-1') : $this->rawMT535;
+            $parser = new MT535($rawMT535);
+            $this->statement = $parser->parseHoldings();
+            $this->depotWert = $parser->parseDepotWert();
+        } catch (\Exception $e) {
+            throw new \InvalidArgumentException('Invalid MT535 data', 0, $e);
+        }
+    }
+}
diff --git a/vendor/nemiah/php-fints/lib/Fhp/Action/GetSEPAAccounts.php b/vendor/nemiah/php-fints/lib/Fhp/Action/GetSEPAAccounts.php
new file mode 100755
index 0000000..92e876d
--- /dev/null
+++ b/vendor/nemiah/php-fints/lib/Fhp/Action/GetSEPAAccounts.php
@@ -0,0 +1,92 @@
+ensureDone();
+        return $this->accounts;
+    }
+
+    /** {@inheritdoc} */
+    protected function createRequest(BPD $bpd, ?UPD $upd)
+    {
+        /** @var BaseSegment $hispas */
+        $hispas = $bpd->requireLatestSupportedParameters('HISPAS');
+        switch ($hispas->getVersion()) {
+            case 1:
+                return HKSPAv1::createEmpty();
+            case 2:
+                return HKSPAv2::createEmpty();
+            case 3:
+                return HKSPAv3::createEmpty();
+            default:
+                throw new UnsupportedException('Unsupported HKSPA version: ' . $hispas->getVersion());
+        }
+    }
+
+    /** {@inheritdoc} */
+    public function processResponse(Message $response)
+    {
+        parent::processResponse($response);
+
+        // Banks send just 3010 and no HISPA in case there are no accounts (or at least none that the bank is able to
+        // report through HISPA).
+        if ($response->findRueckmeldung(Rueckmeldungscode::NICHT_VERFUEGBAR) !== null) {
+            $this->accounts = [];
+            return;
+        }
+
+        /** @var HISPA $hispa */
+        $hispa = $response->requireSegment(HISPA::class);
+        $this->accounts = array_map(function ($ktz) {
+            /** @var Ktz $ktz */
+            $account = new SEPAAccount();
+            $account->setIban($ktz->iban);
+            $account->setBic($ktz->bic);
+            $account->setAccountNumber($ktz->kontonummer);
+            $account->setSubAccount($ktz->unterkontomerkmal);
+            $account->setBlz($ktz->kreditinstitutskennung->kreditinstitutscode);
+            return $account;
+        }, $hispa->getSepaKontoverbindung());
+    }
+}
diff --git a/vendor/nemiah/php-fints/lib/Fhp/Action/GetSEPADirectDebitParameters.php b/vendor/nemiah/php-fints/lib/Fhp/Action/GetSEPADirectDebitParameters.php
new file mode 100755
index 0000000..5530317
--- /dev/null
+++ b/vendor/nemiah/php-fints/lib/Fhp/Action/GetSEPADirectDebitParameters.php
@@ -0,0 +1,78 @@
+directDebitType = $directDebitType;
+        $result->seqType = $seqType;
+        $result->singleDirectDebit = $singleDirectDebit;
+        return $result;
+    }
+
+    public static function getHixxesSegmentName(string $directDebitType, bool $singleDirectDebit): string
+    {
+        switch ($directDebitType) {
+            case 'CORE':
+            case 'COR1':
+                return $singleDirectDebit ? 'HIDSES' : 'HIDMES';
+            case 'B2B':
+                return $singleDirectDebit ? 'HIBSES' : 'HIBMES';
+            default:
+                throw new \InvalidArgumentException('Unknown DirectDebitTypes type, possible values are ' . implode(', ', self::DIRECT_DEBIT_TYPES));
+        }
+    }
+
+    /** {@inheritdoc} */
+    protected function createRequest(BPD $bpd, ?UPD $upd)
+    {
+        $this->hidxes = $bpd->requireLatestSupportedParameters(static::getHixxesSegmentName($this->directDebitType, $this->singleDirectDebit));
+        $this->isDone = true;
+        return []; // No request to the bank required
+    }
+
+    /**
+     * @return MinimaleVorlaufzeitSEPALastschrift|null The information about the lead time for the given Sequence Type and Direct Debit Type
+     */
+    public function getMinimalLeadTime(): ?MinimaleVorlaufzeitSEPALastschrift
+    {
+        $parsed = $this->hidxes->getParameter()->getMinimalLeadTime($this->seqType);
+        if ($parsed instanceof MinimaleVorlaufzeitSEPALastschrift) {
+            return $parsed;
+        }
+        return $parsed[$this->directDebitType] ?? null;
+    }
+}
diff --git a/vendor/nemiah/php-fints/lib/Fhp/Action/GetStatementOfAccount.php b/vendor/nemiah/php-fints/lib/Fhp/Action/GetStatementOfAccount.php
new file mode 100755
index 0000000..8529ec5
--- /dev/null
+++ b/vendor/nemiah/php-fints/lib/Fhp/Action/GetStatementOfAccount.php
@@ -0,0 +1,223 @@
+ $to) {
+            throw new \InvalidArgumentException('From-date must be before to-date');
+        }
+
+        $result = new GetStatementOfAccount();
+        $result->account = $account;
+        $result->from = $from;
+        $result->to = $to;
+        $result->allAccounts = $allAccounts;
+        $result->includeUnbooked = $includeUnbooked;
+        return $result;
+    }
+
+    /**
+     * @deprecated Beginning from PHP7.4 __unserialize is used for new generated strings, then this method is only used for previously generated strings - remove after May 2023
+     */
+    public function serialize(): string
+    {
+        return serialize($this->__serialize());
+    }
+
+    public function __serialize(): array
+    {
+        return [
+            parent::__serialize(),
+            $this->account, $this->from, $this->to, $this->allAccounts,
+            $this->bankName,
+        ];
+    }
+
+    /**
+     * @deprecated Beginning from PHP7.4 __unserialize is used for new generated strings, then this method is only used for previously generated strings - remove after May 2023
+     *
+     * @param string $serialized
+     * @return void
+     */
+    public function unserialize($serialized)
+    {
+        self::__unserialize(unserialize($serialized));
+    }
+
+    public function __unserialize(array $serialized): void
+    {
+        list(
+            $parentSerialized,
+            $this->account, $this->from, $this->to, $this->allAccounts,
+            $this->bankName
+        ) = $serialized;
+
+        is_array($parentSerialized) ?
+            parent::__unserialize($parentSerialized) :
+            parent::unserialize($parentSerialized);
+    }
+
+    /**
+     * @return string The raw MT940 data received from the server.
+     * @noinspection PhpUnused
+     */
+    public function getRawMT940(): string
+    {
+        $this->ensureDone();
+        return $this->rawMT940;
+    }
+
+    /**
+     * @return array The parsed MT940 data.
+     */
+    public function getParsedMT940(): array
+    {
+        $this->ensureDone();
+        return $this->parsedMT940;
+    }
+
+    public function getStatement(): StatementOfAccount
+    {
+        $this->ensureDone();
+        return $this->statement;
+    }
+
+    /** {@inheritdoc} */
+    protected function createRequest(BPD $bpd, ?UPD $upd)
+    {
+        $this->bankName = $bpd->getBankName();
+
+        /** @var HIKAZS $hikazs */
+        $hikazs = $bpd->requireLatestSupportedParameters('HIKAZS');
+        if ($this->allAccounts && !$hikazs->getParameter()->getAlleKontenErlaubt()) {
+            throw new \InvalidArgumentException('The bank do not permit the use of allAccounts=true');
+        }
+        switch ($hikazs->getVersion()) {
+            case 4:
+                return HKKAZv4::create(Kto::fromAccount($this->account), $this->from, $this->to);
+            case 5:
+                return HKKAZv5::create(KtvV3::fromAccount($this->account), $this->allAccounts, $this->from, $this->to);
+            case 6:
+                return HKKAZv6::create(KtvV3::fromAccount($this->account), $this->allAccounts, $this->from, $this->to);
+            case 7:
+                return HKKAZv7::create(Kti::fromAccount($this->account), $this->allAccounts, $this->from, $this->to);
+            default:
+                throw new UnsupportedException('Unsupported HKKAZ version: ' . $hikazs->getVersion());
+        }
+    }
+
+    /** {@inheritdoc} */
+    public function processResponse(Message $response)
+    {
+        parent::processResponse($response);
+
+        // Banks send just 3010 and no HIKAZ in case there are no transactions.
+        $isUnavailable = $response->findRueckmeldung(Rueckmeldungscode::NICHT_VERFUEGBAR) !== null;
+        $responseHikaz = $response->findSegments(HIKAZ::class);
+        $numResponseSegments = count($responseHikaz);
+        if (!$isUnavailable && $numResponseSegments < count($this->getRequestSegmentNumbers())) {
+            throw new UnexpectedResponseException("Only got $numResponseSegments HIKAZ response segments!");
+        }
+
+        /** @var HIKAZ $hikaz */
+        foreach ($responseHikaz as $hikaz) {
+            $this->rawMT940 .= $hikaz->getGebuchteUmsaetze()->getData();
+            if ($this->includeUnbooked and $hikaz->getNichtGebuchteUmsaetze() !== null) {
+                $this->rawMT940 .= $hikaz->getNichtGebuchteUmsaetze()->getData();
+            }
+        }
+
+        // Note: Pagination boundaries may cut in the middle of the MT940 data, so it is not possible to parse a partial
+        // reponse before having received all pages.
+        if (!$this->hasMorePages()) {
+            $this->parseMt940();
+        }
+    }
+
+    private function parseMt940()
+    {
+        if (str_contains(strtolower($this->bankName), 'sparda')) {
+            $parser = new SpardaMT940();
+        } elseif (str_contains(strtolower($this->bankName), 'postbank')) {
+            $parser = new PostbankMT940();
+        } else {
+            $parser = new MT940();
+        }
+
+        try {
+            // Note: Some banks encode their MT 940 data as SWIFT/ISO-8859 like it should be according to the
+            // specification (e.g. DKB), others just send UTF-8 (e.g. Consorsbank), so we try to detect it here.
+            $rawMT940 = mb_detect_encoding($this->rawMT940, 'UTF-8', true) === false
+                ? mb_convert_encoding($this->rawMT940, 'UTF-8', 'ISO-8859-1') : $this->rawMT940;
+            $this->parsedMT940 = $parser->parse($rawMT940);
+            $this->statement = StatementOfAccount::fromMT940Array($this->parsedMT940);
+        } catch (MT940Exception $e) {
+            throw new \InvalidArgumentException('Invalid MT940 data', 0, $e);
+        }
+    }
+}
diff --git a/vendor/nemiah/php-fints/lib/Fhp/Action/GetStatementOfAccountXML.php b/vendor/nemiah/php-fints/lib/Fhp/Action/GetStatementOfAccountXML.php
new file mode 100755
index 0000000..71d20cb
--- /dev/null
+++ b/vendor/nemiah/php-fints/lib/Fhp/Action/GetStatementOfAccountXML.php
@@ -0,0 +1,176 @@
+ $to) {
+            throw new \InvalidArgumentException('From-date must be before to-date');
+        }
+
+        $result = new GetStatementOfAccountXML();
+        $result->account = $account;
+        $result->camtURN = $camtURN;
+        $result->from = $from;
+        $result->to = $to;
+        $result->allAccounts = $allAccounts;
+        return $result;
+    }
+
+    /**
+     * @deprecated Beginning from PHP7.4 __unserialize is used for new generated strings, then this method is only used for previously generated strings - remove after May 2023
+     */
+    public function serialize(): string
+    {
+        return serialize($this->__serialize());
+    }
+
+    public function __serialize(): array
+    {
+        return [
+            parent::__serialize(),
+            $this->account, $this->camtURN, $this->from, $this->to, $this->allAccounts,
+        ];
+    }
+
+    /**
+     * @deprecated Beginning from PHP7.4 __unserialize is used for new generated strings, then this method is only used for previously generated strings - remove after May 2023
+     *
+     * @param string $serialized
+     * @return void
+     */
+    public function unserialize($serialized)
+    {
+        self::__unserialize(unserialize($serialized));
+    }
+
+    public function __unserialize(array $serialized): void
+    {
+        list(
+            $parentSerialized,
+            $this->account, $this->camtURN, $this->from, $this->to, $this->allAccounts
+        ) = $serialized;
+
+        is_array($parentSerialized) ?
+            parent::__unserialize($parentSerialized) :
+            parent::unserialize($parentSerialized);
+    }
+
+    /**
+     * @return string[] The XML-Document(s) received from the bank, or empty array if the statement is unavailable/empty.
+     */
+    public function getBookedXML(): array
+    {
+        $this->ensureDone();
+        return $this->xml;
+    }
+
+    /** {@inheritdoc} */
+    protected function createRequest(BPD $bpd, ?UPD $upd)
+    {
+        if ($upd === null) {
+            throw new UnsupportedException('The UPD is needed to be able to create a request for GetStatementOfAccountXML.');
+        }
+
+        if (!$upd->isRequestSupportedForAccount($this->account, 'HKCAZ')) {
+            throw new UnsupportedException('The bank (or the given account/user combination) does not support GetStatementOfAccountXML.');
+        }
+
+        /** @var HICAZSv1 $hicazs */
+        $hicazs = $bpd->requireLatestSupportedParameters('HICAZS');
+        $supportedCamtURNs = $hicazs->getParameter()->getUnterstuetzteCamtMessages()->camtDescriptor;
+        if (is_null($this->camtURN)) {
+            $camtURNs = $supportedCamtURNs;
+        } elseif (!in_array($this->camtURN, $supportedCamtURNs)) {
+            throw new \InvalidArgumentException('The bank does not support the CAMT format' . $this->camtURN . '. The following formats are supported: ' . implode(', ', $supportedCamtURNs));
+        } else {
+            $camtURNs = [$this->camtURN];
+        }
+
+        if ($this->allAccounts && !$hicazs->getParameter()->getAlleKontenErlaubt()) {
+            throw new \InvalidArgumentException('The bank do not permit the use of allAccounts=true');
+        }
+        switch ($hicazs->getVersion()) {
+            case 1:
+                $unterstuetzteCamtMessages = UnterstuetzteCamtMessages::create($camtURNs);
+                return HKCAZv1::create(Kti::fromAccount($this->account), $unterstuetzteCamtMessages, $this->allAccounts, $this->from, $this->to);
+            default:
+                throw new UnsupportedException('Unsupported HKCAZ version: ' . $hicazs->getVersion());
+        }
+    }
+
+    /** {@inheritdoc} */
+    public function processResponse(Message $response)
+    {
+        parent::processResponse($response);
+
+        // Banks send just 3010 and no HICAZ in case there are no transactions.
+        if ($response->findRueckmeldung(Rueckmeldungscode::NICHT_VERFUEGBAR) !== null) {
+            return;
+        }
+
+        /** @var HICAZv1[] $responseHicaz */
+        $responseHicaz = $response->findSegments(HICAZv1::class);
+        $numResponseSegments = count($responseHicaz);
+        if ($numResponseSegments < count($this->getRequestSegmentNumbers())) {
+            throw new UnexpectedResponseException("Only got $numResponseSegments HICAZ response segments!");
+        }
+        if ($numResponseSegments > 1) {
+            throw new UnsupportedException('More than 1 HICAZ response segment is not supported at the moment!');
+        }
+        // It seems that paginated responses, always contain a whole XML Document
+        foreach ($responseHicaz[0]->getGebuchteUmsaetze() as $xml_string) {
+            $this->xml[] = $xml_string;
+        }
+    }
+}
diff --git a/vendor/nemiah/php-fints/lib/Fhp/Action/SendInternationalCreditTransfer.php b/vendor/nemiah/php-fints/lib/Fhp/Action/SendInternationalCreditTransfer.php
new file mode 100755
index 0000000..2634f24
--- /dev/null
+++ b/vendor/nemiah/php-fints/lib/Fhp/Action/SendInternationalCreditTransfer.php
@@ -0,0 +1,50 @@
+account = $account;
+        $result->dtavzVersion = $dtavzVersion;
+        $result->dtavzData = $dtavzData;
+        return $result;
+    }
+
+    protected function createRequest(BPD $bpd, ?UPD $upd)
+    {
+        /** @var HIAUBSv9 $hiaubs */
+        $hiaubs = $bpd->requireLatestSupportedParameters('HIAUBS');
+
+        $hkaub = HKAUBv9::createEmpty();
+        $hkaub->kontoverbindungInternational = Kti::fromAccount($this->account);
+        $hkaub->DTAZVHandbuch = $this->dtavzVersion ?? $hiaubs->parameter->DTAZVHandbuch;
+        $hkaub->DTAZVDatensatz = new Bin($this->dtavzData);
+        return $hkaub;
+    }
+}
diff --git a/vendor/nemiah/php-fints/lib/Fhp/Action/SendSEPADirectDebit.php b/vendor/nemiah/php-fints/lib/Fhp/Action/SendSEPADirectDebit.php
new file mode 100755
index 0000000..1c9fe59
--- /dev/null
+++ b/vendor/nemiah/php-fints/lib/Fhp/Action/SendSEPADirectDebit.php
@@ -0,0 +1,185 @@
+[^"]+)"/s', $painMessage, $matches) === 1) {
+            $painNamespace = $matches['namespace'];
+        } else {
+            throw new \InvalidArgumentException('The namespace aka "xmlns" is missing in PAIN message');
+        }
+
+        // Check whether the PAIN message contains multiple or only one Direct Debit, should match xx in the XML
+        $nbOfTxs = substr_count($painMessage, '');
+        $ctrlSum = null;
+
+        if (preg_match('@.*?(?[0-9.]+).*?@s', $painMessage, $matches) === 1) {
+            $ctrlSum = $matches['ctrlsum'];
+        }
+
+        if (preg_match('@.*?.*?(?CORE|COR1|B2B).*?.*?@s', $painMessage, $matches) === 1) {
+            $coreType = $matches['coretype'];
+        } else {
+            throw new \InvalidArgumentException('The type CORE/COR1/B2B is missing in PAIN message');
+        }
+
+        if ($nbOfTxs > 1 && is_null($ctrlSum)) {
+            throw new \InvalidArgumentException('The control sum aka "xx" is missing in PAIN message');
+        }
+
+        $result = new SendSEPADirectDebit();
+        $result->account = $account;
+        $result->painMessage = $painMessage;
+        $result->painNamespace = $painNamespace;
+        $result->ctrlSum = $ctrlSum;
+        $result->coreType = $coreType;
+
+        $result->singleDirectDebit = $nbOfTxs === 1;
+
+        $result->tryToUseControlSumForSingleTransactions = $tryToUseControlSumForSingleTransactions;
+
+        return $result;
+    }
+
+    /**
+     * @deprecated Beginning from PHP7.4 __unserialize is used for new generated strings, then this method is only used for previously generated strings - remove after May 2023
+     */
+    public function serialize(): string
+    {
+        return serialize($this->__serialize());
+    }
+
+    public function __serialize(): array
+    {
+        return [
+            parent::__serialize(),
+            $this->singleDirectDebit, $this->tryToUseControlSumForSingleTransactions, $this->ctrlSum, $this->coreType, $this->painMessage, $this->painNamespace, $this->account,
+        ];
+    }
+
+    /**
+     * @deprecated Beginning from PHP7.4 __unserialize is used for new generated strings, then this method is only used for previously generated strings - remove after May 2023
+     *
+     * @param string $serialized
+     * @return void
+     */
+    public function unserialize($serialized)
+    {
+        self::__unserialize(unserialize($serialized));
+    }
+
+    public function __unserialize(array $serialized): void
+    {
+        list(
+            $parentSerialized,
+            $this->singleDirectDebit, $this->tryToUseControlSumForSingleTransactions, $this->ctrlSum, $this->coreType, $this->painMessage, $this->painNamespace, $this->account
+        ) = $serialized;
+
+        is_array($parentSerialized) ?
+            parent::__unserialize($parentSerialized) :
+            parent::unserialize($parentSerialized);
+    }
+
+    protected function createRequest(BPD $bpd, ?UPD $upd)
+    {
+        $useSingleDirectDebit = $this->singleDirectDebit;
+
+        // If the PAIN message contains a control sum, we should use it, if the bank also supports it
+        if ($useSingleDirectDebit && $this->tryToUseControlSumForSingleTransactions && !is_null($this->ctrlSum) && !is_null($bpd->getLatestSupportedParameters('HIDMES'))) {
+            $useSingleDirectDebit = false;
+        }
+
+        /* @var HIDXES|BaseSegment $hidxes */
+        $hidxes = $bpd->requireLatestSupportedParameters(GetSEPADirectDebitParameters::getHixxesSegmentName($this->coreType, $useSingleDirectDebit));
+
+        $supportedPainNamespaces = null;
+
+        if ($hidxes->getVersion() === 2) {
+            /** @var HIDMESv2|HIDSESv2 $hidxes */
+            $supportedPainNamespaces = $hidxes->getParameter()->getUnterstuetzteSEPADatenformate();
+        }
+
+        // If there are no SEPA formats available in the HIDXES Parameters, we look to the general formats
+        if (!is_array($supportedPainNamespaces) || count($supportedPainNamespaces) === 0) {
+            /** @var HISPAS $hispas */
+            $hispas = $bpd->requireLatestSupportedParameters('HISPAS');
+            $supportedPainNamespaces = $hispas->getParameter()->getUnterstuetzteSEPADatenformate();
+        }
+
+        // Sometimes the Bank reports supported schemas with a "_GBIC_X" postfix.
+        // GIBC_X stands for German Banking Industry Committee and a version counter.
+        $xmlSchema = $this->painNamespace;
+        $matchingSchemas = array_filter($supportedPainNamespaces, function($value) use ($xmlSchema) {
+            // For example urn:iso:std:iso:20022:tech:xsd:pain.008.001.08 from the xml matches
+            // urn:iso:std:iso:20022:tech:xsd:pain.008.001.08_GBIC_4
+            return str_starts_with($value, $xmlSchema);
+        });
+
+        if (count($matchingSchemas) === 0) {
+            throw new UnsupportedException("The bank does not support the XML schema $this->painNamespace, but only "
+                . implode(', ', $supportedPainNamespaces));
+        }
+
+        /** @var mixed $hkdxe */ // TODO Put a new interface type here.
+        $hkdxe = $hidxes->createRequestSegment();
+        $hkdxe->kontoverbindungInternational = Kti::fromAccount($this->account);
+        $hkdxe->sepaDescriptor = $this->painNamespace;
+        $hkdxe->sepaPainMessage = new Bin($this->painMessage);
+
+        if (!$useSingleDirectDebit) {
+            if ($hidxes->getParameter()->einzelbuchungErlaubt) {
+                $hkdxe->einzelbuchungGewuenscht = false;
+            }
+
+            /* @var HIDMESv1 $hidxes */
+            // Just always send the control sum
+            // if ($hidxes->getParameter()->summenfeldBenoetigt) {
+            $hkdxe->summenfeld = Btg::create($this->ctrlSum);
+            // }
+        }
+
+        return $hkdxe;
+    }
+}
diff --git a/vendor/nemiah/php-fints/lib/Fhp/Action/SendSEPARealtimeTransfer.php b/vendor/nemiah/php-fints/lib/Fhp/Action/SendSEPARealtimeTransfer.php
new file mode 100755
index 0000000..22dfda3
--- /dev/null
+++ b/vendor/nemiah/php-fints/lib/Fhp/Action/SendSEPARealtimeTransfer.php
@@ -0,0 +1,114 @@
+account = $account;
+        $result->painMessage = $painMessage;
+        $result->xmlSchema = $match[1];
+        $result->allowConversionToSEPATransfer = $allowConversionToSEPATransfer;
+        return $result;
+    }
+
+    /** {@inheritdoc} */
+    protected function createRequest(BPD $bpd, ?UPD $upd)
+    {
+        /** @var HIIPZSv1|HIIPZSv2 $hiipzs */
+        $hiipzs = $bpd->requireLatestSupportedParameters('HIIPZS');
+
+        $supportedSchemas = $hiipzs->parameter->getUnterstuetzteSEPADatenformate();
+
+        // If there are no SEPA formats available in the HIIPZS Parameters, we look to the general formats
+        if (is_null($supportedSchemas)) {
+            /** @var HISPAS $hispas */
+            $hispas = $bpd->requireLatestSupportedParameters('HISPAS');
+            $supportedSchemas = $hispas->getParameter()->getUnterstuetzteSEPADatenformate();
+        }
+
+        // Sometimes the Bank reports supported schemas with a "_GBIC_X" postfix.
+        // GIBC_X stands for German Banking Industry Committee and a version counter.
+        $xmlSchema = $this->xmlSchema;
+        $matchingSchemas = array_filter($supportedSchemas, function($value) use ($xmlSchema) {
+            // For example urn:iso:std:iso:20022:tech:xsd:pain.001.001.09 from the xml matches
+            // urn:iso:std:iso:20022:tech:xsd:pain.001.001.09_GBIC_4
+            return str_starts_with($value, $xmlSchema);
+        });
+
+        if (count($matchingSchemas) === 0) {
+            throw new UnsupportedException("The bank does not support the XML schema $this->xmlSchema, but only "
+                . implode(', ', $supportedSchemas));
+        }
+
+        /** @var HKIPZv1|HKIPZv2 $hkipz */
+        $hkipz = $hiipzs->createRequestSegment();
+        $hkipz->kontoverbindungInternational = Kti::fromAccount($this->account);
+        $hkipz->sepaDescriptor = $this->xmlSchema;
+        $hkipz->sepaPainMessage = new Bin($this->painMessage);
+        if ($hiipzs instanceof HIIPZSv2) {
+            $hkipz->umwandlungNachSEPAUeberweisungZulaessig = $hiipzs->parameter->umwandlungNachSEPAUeberweisungZulaessigErlaubt && $this->allowConversionToSEPATransfer;
+        }
+        return $hkipz;
+    }
+
+    /** {@inheritdoc} */
+    public function processResponse(Message $response)
+    {
+        parent::processResponse($response);
+
+        // Was the instant payment converted to a regular transfer?
+        $info = $response->findRueckmeldungen(3270);
+        if (count($info) > 0) {
+            $this->successMessage = implode("\n", array_map(function (Rueckmeldung $rueckmeldung) {
+                return $rueckmeldung->rueckmeldungstext;
+            }, $info));
+            return;
+        }
+
+        if ($response->findRueckmeldung(Rueckmeldungscode::ENTGEGENGENOMMEN) === null &&
+            $response->findRueckmeldung(Rueckmeldungscode::AUSGEFUEHRT) === null) {
+            throw new UnexpectedResponseException('Bank did not confirm SEPATransfer execution');
+        }
+    }
+}
diff --git a/vendor/nemiah/php-fints/lib/Fhp/Action/SendSEPATransfer.php b/vendor/nemiah/php-fints/lib/Fhp/Action/SendSEPATransfer.php
new file mode 100755
index 0000000..68de23b
--- /dev/null
+++ b/vendor/nemiah/php-fints/lib/Fhp/Action/SendSEPATransfer.php
@@ -0,0 +1,121 @@
+account = $account;
+        $result->painMessage = $painMessage;
+        $result->xmlSchema = $match[1];
+        return $result;
+    }
+
+    /** {@inheritdoc} */
+    protected function createRequest(BPD $bpd, ?UPD $upd)
+    {
+        //ANALYSE XML FOR RECEIPTS AND PAYMENT DATE
+        $xmlAsObject = simplexml_load_string($this->painMessage, "SimpleXMLElement", LIBXML_NOCDATA);
+        $numberOfTransactions = $xmlAsObject->CstmrCdtTrfInitn->GrpHdr->NbOfTxs;
+        $hasReqdExDates = false;
+        foreach ($xmlAsObject->CstmrCdtTrfInitn?->PmtInf as $pmtInfo) {
+            // Checks for both, 1999-01-01 and 
1999-01-01
+ if (isset($pmtInfo->ReqdExctnDt) && ($pmtInfo->ReqdExctnDt->Dt ?? $pmtInfo->ReqdExctnDt) != '1999-01-01') { + $hasReqdExDates = true; + break; + } + } + + //NOW READ OUT, WICH SEGMENT SHOULD BE USED: + if ($numberOfTransactions > 1 && $hasReqdExDates) { + + // Terminierte SEPA-Sammelüberweisung (Segment HKCME / Kennung HICMES) + $segmentID = 'HICMES'; + $segment = \Fhp\Segment\CME\HKCMEv1::createEmpty(); + } elseif ($numberOfTransactions == 1 && $hasReqdExDates) { + + // Terminierte SEPA-Überweisung (Segment HKCSE / Kennung HICSES) + $segmentID = 'HICSES'; + $segment = \Fhp\Segment\CSE\HKCSEv1::createEmpty(); + } elseif ($numberOfTransactions > 1 && !$hasReqdExDates) { + + // SEPA-Sammelüberweisungen (Segment HKCCM / Kennung HICSES) + $segmentID = 'HICSES'; + $segment = \Fhp\Segment\CCM\HKCCMv1::createEmpty(); + } else { + + //SEPA Einzelüberweisung (Segment HKCCS / Kennung HICCSS). + $segmentID = 'HICCSS'; + $segment = \Fhp\Segment\CCS\HKCCSv1::createEmpty(); + } + + if (!$bpd->supportsParameters($segmentID, 1)) { + throw new UnsupportedException('The bank does not support ' . $segmentID . 'v1'); + } + + /** @var HISPAS $hispas */ + $hispas = $bpd->requireLatestSupportedParameters('HISPAS'); + $supportedSchemas = $hispas->getParameter()->getUnterstuetzteSEPADatenformate(); + + // Sometimes the Bank reports supported schemas with a "_GBIC_X" postfix. + // GIBC_X stands for German Banking Industry Committee and a version counter. + $xmlSchema = $this->xmlSchema; + $matchingSchemas = array_filter($supportedSchemas, function($value) use ($xmlSchema) { + // For example urn:iso:std:iso:20022:tech:xsd:pain.001.001.09 from the xml matches + // urn:iso:std:iso:20022:tech:xsd:pain.001.001.09_GBIC_4 + return str_starts_with($value, $xmlSchema); + }); + + if (count($matchingSchemas) === 0) { + throw new UnsupportedException("The bank does not support the XML schema $this->xmlSchema, but only " + . implode(', ', $supportedSchemas)); + } + + $segment->kontoverbindungInternational = Kti::fromAccount($this->account); + $segment->sepaDescriptor = $this->xmlSchema; + $segment->sepaPainMessage = new Bin($this->painMessage); + return $segment; + } + + /** {@inheritdoc} */ + public function processResponse(Message $response) + { + parent::processResponse($response); + if ($response->findRueckmeldung(Rueckmeldungscode::ENTGEGENGENOMMEN) === null && $response->findRueckmeldung(Rueckmeldungscode::AUSGEFUEHRT) === null) { + throw new UnexpectedResponseException('Bank did not confirm SEPATransfer execution'); + } + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/BaseAction.php b/vendor/nemiah/php-fints/lib/Fhp/BaseAction.php new file mode 100755 index 0000000..d36dec6 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/BaseAction.php @@ -0,0 +1,258 @@ +__serialize()); + } + + /** + * An action can only be serialized *after* it has been executed in case it needs a TAN, i.e. when the result is not + * present yet. + * If a sub-class overrides this, it should call the parent function and include it in its result. + * + * @return array The serialized action, e.g. for storage in a database. This will not contain sensitive user data. + */ + public function __serialize(): array + { + if (!$this->needsTan()) { + throw new \RuntimeException('Cannot serialize this action, because it is not waiting for a TAN.'); + } + return [ + $this->requestSegmentNumbers, + $this->tanRequest, + $this->needTanForSegment, + ]; + } + + /** + * @deprecated Beginning from PHP7.4 __unserialize is used for new generated strings, then this method is only used for previously generated strings - remove after May 2023 + * + * @param string $serialized + * @return void + */ + public function unserialize($serialized) + { + self::__unserialize(unserialize($serialized)); + } + + public function __unserialize(array $serialized): void + { + list( + $this->requestSegmentNumbers, + $this->tanRequest, + $this->needTanForSegment + ) = $serialized; + } + + /** + * @return bool Whether the underlying operation has completed successfully and the result in this "future" is + * available. Note: If this returns false, check {@link needsTan()}. + */ + public function isDone(): bool + { + return $this->isDone; + } + + /** + * @return bool If this returns true, the underlying operation has not completed because it is awaiting a TAN or a + * "decoupled" confirmation. You should ask the user for this TAN/confirmation and pass it to + * {@link FinTs::submitTan()} or call {@link FinTs::checkDecoupledSubmission()}, respectively. + */ + public function needsTan(): bool + { + return !$this->isDone() && $this->tanRequest !== null; + } + + public function getNeedTanForSegment(): ?string + { + return $this->needTanForSegment; + } + + public function getTanRequest(): ?TanRequest + { + return $this->tanRequest; + } + + /** + * Throws an exception unless this action has been successfully executed, i.e. in the following cases: + * - the action has not been {@link FinTs::execute()}-d at all or the {@link FinTs::execute()} call for it threw an + * exception, + * - the action is awaiting a TAN/confirmation (as per {@link BaseAction::needsTan()}. + * + * After executing an action, you can use this function to make sure that it succeeded. This is especially useful + * for actions that don't have any results (as each result getter would call {@link ensureDone()} internally). + * On the other hand, you do not need to call this function if you make sure that (1) you called + * {@link FinTs::execute()} and (2) you checked {@link needsTan()} and, if it returned true, supplied a TAN by + * calling {@ink FinTs::submitTan()}. Note that both exception types thrown from this method are sub-classes of + * {@link \RuntimeException}, so you shouldn't need a try-catch block at the call site for this. + * @throws ActionIncompleteException If the action hasn't even been executed. + * @throws TanRequiredException If the action needs a TAN. + */ + public function ensureDone() + { + if ($this->tanRequest !== null) { + throw new TanRequiredException($this->tanRequest); + } elseif (!$this->isDone()) { + throw new ActionIncompleteException(); + } + } + + /** + * Called when this action is about to be executed, in order to construct the request. + * @param BPD $bpd See {@link BPD}. + * @param UPD|null $upd See {@link UPD}. This is usually present (non-null), except for a few special login and TAN + * management actions. + * @return BaseSegment|BaseSegment[] A segment or a series of segments that should be sent to the bank server. + * Note that an action can return an empty array to indicate that it does not need to make a request to the + * server, but can instead compute the result just from the BPD/UPD, in which case it should set + * `$this->isDone = true;` already in {@link createRequest()} and {@link processResponse()} will never + * be executed. + * @throws \InvalidArgumentException When the request cannot be built because the input data or BPD/UPD is invalid. + */ + abstract protected function createRequest(BPD $bpd, ?UPD $upd); + + /** + * Called by FinTs::execute when this action is about to be executed, in order to get a request. This function can + * be called multiple times in case the response is paginated. + * This method also tries to check if the segments might need a tan and stores this information for use in + * FinTs::execute + * @param BPD|null $bpd See {@link BPD}. + * @param UPD|null $upd See {@link UPD}. This is usually present (non-null), except for a few special login and TAN + * management actions. + * @return BaseSegment[] A segment or a series of segments that should be sent to the bank server. + * An empty array means that no request is necessary at all. + * @throws \InvalidArgumentException When the request cannot be built because the input data or BPD/UPD is invalid. + */ + public function getNextRequest(BPD $bpd, ?UPD $upd) + { + $requestSegments = $this->createRequest($bpd, $upd); + $requestSegments = is_array($requestSegments) ? $requestSegments : [$requestSegments]; + + $this->needTanForSegment = $bpd->tanRequiredForRequest($requestSegments); + + return $requestSegments; + } + + /** + * Called when this action was executed on the server (never if {@link createRequest()} returned an empty request), + * to process the response. This function can be called multiple times in case the response is paginated. + * In case the response indicates that this action failed, this function may throw an appropriate exception. Sub-classes should override this function + * and call the parent/super function. + * @param Message $response A fake message that contains the subset of segments received from the server that + * were in response to the request segments that were created by {@link createRequest()}. + * @throws UnexpectedResponseException When the response indicates failure. + */ + public function processResponse(Message $response) + { + $this->isDone = true; + + $info = $response->findRueckmeldungen(Rueckmeldungscode::AUSGEFUEHRT); + if (count($info) === 0) { + $info = $response->findRueckmeldungen(Rueckmeldungscode::ENTGEGENGENOMMEN); + } + if (count($info) > 0) { + $this->successMessage = implode("\n", array_map(function (Rueckmeldung $rueckmeldung) { + return $rueckmeldung->rueckmeldungstext; + }, $info)); + } + } + + /** @return int[] */ + public function getRequestSegmentNumbers(): array + { + return $this->requestSegmentNumbers; + } + + /** + * To be called only by the FinTs instance that executes this action. + * @param int[] $requestSegmentNumbers + */ + final public function setRequestSegmentNumbers(array $requestSegmentNumbers) + { + foreach ($requestSegmentNumbers as $segmentNumber) { + if (!is_int($segmentNumber)) { + throw new \InvalidArgumentException("Invalid segment number: $segmentNumber"); + } + } + $this->requestSegmentNumbers = $requestSegmentNumbers; + } + + /** + * To be called only by the FinTs instance that executes this action. + */ + final public function setTanRequest(?TanRequest $tanRequest) + { + $this->tanRequest = $tanRequest; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Connection.php b/vendor/nemiah/php-fints/lib/Fhp/Connection.php new file mode 100755 index 0000000..5b2981d --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Connection.php @@ -0,0 +1,99 @@ +url = $url; + $this->timeoutConnect = $timeoutConnect; + $this->timeoutResponse = $timeoutResponse; + } + + private function connect() + { + $this->curlHandle = curl_init(); + + curl_setopt($this->curlHandle, CURLOPT_SSL_VERIFYPEER, true); + curl_setopt($this->curlHandle, CURLOPT_SSL_VERIFYHOST, 2); + curl_setopt($this->curlHandle, CURLOPT_USERAGENT, 'phpFinTS'); + curl_setopt($this->curlHandle, CURLOPT_RETURNTRANSFER, true); + curl_setopt($this->curlHandle, CURLOPT_URL, $this->url); + curl_setopt($this->curlHandle, CURLOPT_CONNECTTIMEOUT, $this->timeoutConnect); + curl_setopt($this->curlHandle, CURLOPT_CUSTOMREQUEST, 'POST'); + curl_setopt($this->curlHandle, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1); + curl_setopt($this->curlHandle, CURLOPT_ENCODING, ''); + curl_setopt($this->curlHandle, CURLOPT_MAXREDIRS, 0); + curl_setopt($this->curlHandle, CURLOPT_TIMEOUT, $this->timeoutResponse); + curl_setopt($this->curlHandle, CURLOPT_HTTPHEADER, ['cache-control: no-cache', 'Content-Type: text/plain']); + } + + public function disconnect() + { + if ($this->curlHandle !== null) { + curl_close($this->curlHandle); + $this->curlHandle = null; + } + } + + /** + * @param string $message The message to be sent, in HBCI/FinTS wire format, ISO-8859-1 encoded. + * @return string The response from the server, in HBCI/FinTS wire format, ISO-8859-1 encoded. + * @throws CurlException When the request fails. + */ + public function send(string $message): string + { + if (!$this->curlHandle) { + $this->connect(); + } + + curl_setopt($this->curlHandle, CURLOPT_POSTFIELDS, base64_encode($message)); + $response = curl_exec($this->curlHandle); + + if (false === $response) { + throw new CurlException( + 'Failed connection to ' . $this->url . ': ' . curl_error($this->curlHandle), + null, + curl_errno($this->curlHandle), + curl_getinfo($this->curlHandle), + curl_error($this->curlHandle) + ); + } + + $statusCode = curl_getinfo($this->curlHandle, CURLINFO_HTTP_CODE); + if ($statusCode < 200 || $statusCode > 299) { + throw new CurlException( + 'Bad response with status code ' . $statusCode, + $response, + $statusCode, + curl_getinfo($this->curlHandle) + ); + } + + return base64_decode($response); + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/CurlException.php b/vendor/nemiah/php-fints/lib/Fhp/CurlException.php new file mode 100755 index 0000000..8538745 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/CurlException.php @@ -0,0 +1,50 @@ +response = $response; + $this->curlInfo = $curlInfo; + $this->curlMessage = $curlMessage; + } + + public function getResponse(): ?string + { + return $this->response; + } + + /** + * Gets the curl info from request / response. + */ + public function getCurlInfo(): array + { + return $this->curlInfo; + } + + /** + * Gets the curl message from request / response. + */ + public function getCurlMessage(): ?string + { + return $this->curlMessage; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/FinTs.php b/vendor/nemiah/php-fints/lib/Fhp/FinTs.php new file mode 100755 index 0000000..a124c27 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/FinTs.php @@ -0,0 +1,1017 @@ +validate(); + $fints = new static($options, $credentials); + + if ($persistedInstance !== null) { + $fints->loadPersistedInstance($persistedInstance); + } + return $fints; + } + + /** + * This function allows fetching the BPD without knowing the user's credentials yet, by using an anonymous dialog. + * Note: If this fails with an error saying that your bank does not support the anonymous dialog, you probably need + * to use {@link NoPsd2TanMode} for regular login. + * @param FinTsOptions $options Configuration options for the connection to the bank. + * @param ?LoggerInterface $logger An optional logger to record messages exchanged with the bank. + * @return BPD Bank parameters that tell the client software what features the bank supports. + * @throws CurlException When the connection fails in a layer below the FinTS protocol. + * @throws UnexpectedResponseException When the server does not send the BPD or close the dialog properly. + * @throws ServerException When the server resopnds with an error. + */ + public static function fetchBpd(FinTsOptions $options, ?LoggerInterface $logger = null): BPD + { + $options->validate(); + $fints = new static($options, null); + if ($logger !== null) { + $fints->setLogger($logger); + } + return $fints->getBpd(); + } + + /** Please use the factory above. */ + protected function __construct(FinTsOptions $options, ?Credentials $credentials) + { + $this->options = $options; + $this->credentials = $credentials; + $this->setLogger(new NullLogger()); + } + + /** + * Destructing the object only disconnects. Please use {@link close()} if you want to properly "log out", i.e., end + * the FinTs dialog. + */ + public function __destruct() + { + $this->disconnect(); + } + + /** + * Returns a serialized form this object. This is different from PHP's {@link \Serializable} in that it only + * serializes parts and cannot simply be restored with {@link unserialize()}, because the {@link FinTsOptions} and + * the {@link Credentials} need to be passed to {@link FinTs::new()}, in addition to the string returned here. + * + * Alternatively, you can use {@link loadPersistedInstance()} to separate constructing the instance and resuming it. + * + * There are broadly two reasons to persist the instance: + * 1. During login or certain other actions, you may encounter a TAN/2FA request ({@link BaseAction::needsTan()} + * returns true). In that case, you MUST call {@link submitTan()} or {@link checkDecoupledSubmission()} later, + * without losing the dialog state in between. Depending on your application's circumstances, one option might be + * to simply keep the {@link FinTs} instance itself alive in memory (e.g., in a CLI application, you can block + * until the user provides the TAN). In most server-based scenarios, however, the PHP process will shut down and + * a new PHP process will be started later, when the client calls again to provide the TAN. In this case, you + * need to persist the {@link FinTs} instance and restore it later in order for the action to succeed. + * 2. Even when there is no outstanding action and after logging out with {@link close()}, it's beneficial to + * persist the instance (with $minimal=false). By reusing the cached BPD, UPD and TAN mode information upon the + * next {@link login()}, a few roundtrips to the FinTS server can be avoided. + * + * IMPORTANT: Each serialized instance (each value returned from {@link persist()}) can only be used once. After + * passing it to {@link FinTs::new()} or {@link loadPersistedInstance()}, you must consider it invalidated. To keep + * the same instance/session alive, you must call {@link persist()} again. + * + * @param bool $minimal If true, the return value only contains the values necessary to complete an outstanding TAN + * request, but not the relatively large BPD/UPD, which can always be retrieved again later with a few extra + * requests to the server. So the persisting doesn't work for use case (2.) from above, but in turn, it saves + * storage space. + * @return string A serialized form of those parts of the FinTs instance that can reasonably be persisted (BPD, UPD, + * Kundensystem-ID, etc.). Note that this usually contains some user data (user's name, account names and + * sometimes a dialog ID that is equivalent to session cookie). So the returned string needs to be treated + * carefully (not written to log files, only to a database or other storage system that would normally be used + * for user data). The returned string never contains highly sensitive information (not the user's password or + * PIN), so it probably does not need to be encrypted. Treat it like a session cookie of the same bank. + */ + public function persist(bool $minimal = false): string + { + // IMPORTANT: Be sure not to include highly sensitive user information here. + return serialize([ // This should match loadPersistedInstanceVersion1(). + 2, // Version of the serialized format. + $minimal ? null : $this->bpd, + $minimal ? null : $this->allowedTanModes, + $minimal ? null : $this->upd, + $this->selectedTanMode, + $this->selectedTanMedium, + $this->kundensystemId, + $this->dialogId, + $this->messageNumber, + ]); + } + + public function __serialize(): array + { + throw new \LogicException('FinTs cannot be serialize()-ed, you should call persist() instead.'); + } + + public function __unserialize(array $data): void + { + throw new \LogicException( + 'FinTs cannot be unserialize()-ed, you should pass $persistedInstance to FinTs::new() instead.'); + } + + /** + * Loads data from a previous {@link FinTs} instance, to reuse cached BPD/UPD information and/or to continue using + * an ongoing session. The latter is necessary to complete a TAN request when the user provides the TAN in a fresh + * PHP process. + * + * Unless it's not available to you at that time already, you can just pass the persisted instance into + * {@link FinTs::new()} instead of calling this function. + * + * @param string $persistedInstance The return value of {@link persist()} of a previous FinTs instance, usually + * from an earlier PHP process. NOTE: Each persisted instance may be used only once and should be considered + * invalid afterwards. To continue the session, call {@link persist()} again. + * + * @throws \InvalidArgumentException + */ + public function loadPersistedInstance(string $persistedInstance) + { + $unserialized = unserialize($persistedInstance); + if (!is_array($unserialized) || count($unserialized) === 0) { + throw new \InvalidArgumentException("Invalid persistedInstance: '$persistedInstance'"); + } + $version = $unserialized[0]; + $data = array_slice($unserialized, 1); + if ($version === 2) { + $this->loadPersistedInstanceVersion2($data); + } else { + throw new \InvalidArgumentException("Unknown persistedInstace version: '{$unserialized[0]}''"); + } + } + + private function loadPersistedInstanceVersion2(array $data) + { + list( // This should match persist(). + $this->bpd, + $this->allowedTanModes, + $this->upd, + $this->selectedTanMode, + $this->selectedTanMedium, + $this->kundensystemId, + $this->dialogId, + $this->messageNumber + ) = $data; + } + + /** @noinspection PhpUnused */ + public function getLogger(): SanitizingLogger + { + return $this->logger; + } + + /** + * @param LoggerInterface $logger The logger to use going forward. Note that it will be wrapped in a + * {@link SanitizingLogger} to protect sensitive information like usernames and PINs. + */ + public function setLogger(LoggerInterface $logger): void + { + if ($logger instanceof SanitizingLogger) { + $this->logger = $logger; + } else { + $this->logger = new SanitizingLogger($logger, [$this->options, $this->credentials]); + } + } + + /** + * @param int $connectTimeout The number of seconds to wait before aborting a connection attempt to the bank server. + * @param int $responseTimeout The number of seconds to wait before aborting a request to the bank server. + * @noinspection PhpUnused + */ + public function setTimeouts(int $connectTimeout, int $responseTimeout) + { + $this->options->timeoutConnect = $connectTimeout; + $this->options->timeoutResponse = $responseTimeout; + } + + /** + * Executes a strongly authenticated login action and returns it. With some banks, this requires a TAN. + * @return DialogInitialization A {@link BaseAction} for the outcome of the login. You should check whether a TAN is + * needed using {@link BaseAction::needsTan()} and, if so, let the user complete the TAN request from + * {@link BaseAction::getTanRequest()} and then finish the login by passing the {@link BaseAction} + * returned here to {@link submitTan()} or {@link checkDecoupledSubmission()}. See {@link execute()} for + * details. + * @throws CurlException When the connection fails in a layer below the FinTS protocol. + * @throws UnexpectedResponseException When the server responds with a valid but unexpected message. + * @throws ServerException When the server responds with a (FinTS-encoded) error message, which includes most things + * that can go wrong with the action itself, like wrong credentials, invalid IBANs, locked accounts, etc. + */ + public function login(): DialogInitialization + { + $this->requireTanMode(); + $this->ensureSynchronized(); + $this->messageNumber = 1; + $login = new DialogInitialization($this->options, $this->requireCredentials(), $this->getSelectedTanMode(), + $this->selectedTanMedium, $this->kundensystemId); + $this->execute($login); + return $login; + } + + /** + * Executes an action. Be sure to {@link login()} first. See the `\Fhp\Action` package for actions that can be + * executed with this function. Note that, after this function returns, the action can be in two possible states: + * 1. If {@link BaseAction::needsTan()} returns true, the action isn't completed yet because needs a TAN or other + * kind of two-factor authentication (2FA). In this case, use {@link BaseAction::getTanRequest()} to get more + * information about the TAN/2FA that is needed. Your application then needs to interact with the user to obtain + * the TAN (which should be passed into {@link submitTan()}) or to have them complete the 2FA check (which can + * be verified with {@link checkDecoupledSubmission()}). Both of those functions require passing the same + * {@link BaseAction} argument as an argument, and once they succeed, the action will be in the same completed + * state as if it had been completed right away. + * 2. If {@link BaseAction::needsTan()} returns false, the action was completed right away. Use the respective + * getters on the action instance to retrieve the result. In case the action fails, the corresponding exception + * will be thrown from this function. + * + * @param BaseAction $action The action to be executed. Its {@link BaseAction::isDone()} status will be updated when + * this function returns successfully. + * @throws CurlException When the connection fails in a layer below the FinTS protocol. + * @throws UnexpectedResponseException When the server responds with a valid but unexpected message. + * @throws ServerException When the server responds with a (FinTS-encoded) error message, which includes most things + * that can go wrong with the action itself, like wrong credentials, invalid IBANs, locked accounts, etc. + */ + public function execute(BaseAction $action) + { + if ($this->dialogId === null && !($action instanceof DialogInitialization)) { + throw new \RuntimeException('Need to login (DialogInitialization) before executing other actions'); + } + + $requestSegments = $action->getNextRequest($this->bpd, $this->upd); + + if (count($requestSegments) === 0) { + return; // No request needed. + } + + // Construct the full request message. + $message = MessageBuilder::create()->add($requestSegments); // This fills in the segment numbers. + if (!($this->getSelectedTanMode() instanceof NoPsd2TanMode)) { + if (($needTanForSegment = $action->getNeedTanForSegment()) !== null) { + $message->add(HKTANFactory::createProzessvariante2Step1( + $this->requireTanMode(), $this->selectedTanMedium, $needTanForSegment)); + } + } + $request = $this->buildMessage($message, $this->getSelectedTanMode()); + $action->setRequestSegmentNumbers(array_map(function ($segment) { + /* @var BaseSegment $segment */ + return $segment->getSegmentNumber(); + }, $requestSegments)); + + // Execute the request. + $response = $this->sendMessage($request); + $this->readBPD($response); + + // Detect if the bank wants a TAN. + /** @var HITAN $hitan */ + $hitan = $response->findSegment(HITAN::class); + if ($hitan !== null && $hitan->getAuftragsreferenz() !== HITAN::DUMMY_REFERENCE) { + if ($hitan->tanProzess !== HKTAN::TAN_PROZESS_4) { + throw new UnexpectedResponseException("Unsupported TAN request type $hitan->tanProzess"); + } + if ($this->bpd === null || $this->kundensystemId === null) { + throw new UnexpectedResponseException('Unexpected TAN request'); + } + // NOTE: In case of a decoupled TAN mode, the response code 3955 must be present, but it seems useless to us. + $action->setTanRequest($hitan); + if ($action instanceof DialogInitialization) { + $action->setDialogId($response->header->dialogId); + $action->setMessageNumber($this->messageNumber); + } + return; + } + + // If no TAN is needed, process the response normally, and maybe keep going for more pages. + $this->processActionResponse($action, $response->filterByReferenceSegments($action->getRequestSegmentNumbers())); + if ($action instanceof PaginateableAction && $action->hasMorePages()) { + $this->execute($action); + } + } + + /** + * For an action where {@link BaseAction::needsTan()} returns `true` and {@link TanMode::isDecoupled()} returns + * `false`, this function sends the given $tan to the server to complete the action. By using {@link persist()}, + * this can be done asynchronously, i.e., not in the same PHP process as the original {@link execute()} call. + * + * After this function returns, the `$action` is completed. That is, its result is available through its getters + * just as if it had been completed by the original call to {@link execute()} right away. In case the action fails, + * the corresponding exception will be thrown from this function. + * + * @link https://www.hbci-zka.de/dokumente/spezifikation_deutsch/fintsv3/FinTS_3.0_Security_Sicherheitsverfahren_PINTAN_2020-07-10_final_version.pdf + * Section B.4.2.1.1 + * + * @param BaseAction $action The action to be completed. + * @param string $tan The TAN entered by the user. + * @throws CurlException When the connection fails in a layer below the FinTS protocol. + * @throws UnexpectedResponseException When the server responds with a valid but unexpected message. + * @throws ServerException When the server responds with a (FinTS-encoded) error message, which includes most things + * that can go wrong with the action itself, like wrong credentials, invalid IBANs, locked accounts, etc. + */ + public function submitTan(BaseAction $action, string $tan) + { + // Check the action's state. + $tanRequest = $action->getTanRequest(); + if ($tanRequest === null) { + throw new \InvalidArgumentException('This action does not need a TAN'); + } + if ($action instanceof DialogInitialization) { + if ($this->dialogId !== null) { + throw new \RuntimeException('Cannot init another dialog.'); + } + $this->dialogId = $action->getDialogId(); + $this->messageNumber = $action->getMessageNumber(); + } + + // Construct the request. + $tanMode = $this->requireTanMode(); + if ($tanMode instanceof NoPsd2TanMode) { + throw new \InvalidArgumentException('Cannot submit TAN when the bank does not support PSD2'); + } + if ($tanMode->isDecoupled()) { + throw new \InvalidArgumentException('Cannot submit TAN for a decoupled TAN mode'); + } + $message = MessageBuilder::create() + ->add(HKTANFactory::createProzessvariante2Step2($tanMode, $tanRequest->getProcessId())); + $request = $this->buildMessage($message, $tanMode, $tan); + + // Execute the request. + $response = $this->sendMessage($request); + $this->readBPD($response); + + // Ensure that the TAN was accepted. + /** @var HITAN $hitan */ + $hitan = $response->findSegment(HITAN::class); + if ($hitan === null) { + throw new UnexpectedResponseException('HITAN missing after submitting TAN'); + } + if ($hitan->getTanProzess() !== HKTAN::TAN_PROZESS_2 // We only support the case "(B)" in the specification. + || $hitan->getAuftragsreferenz() !== $tanRequest->getProcessId()) { + throw new UnexpectedResponseException("Bank has not accepted TAN: $hitan"); + } + $action->setTanRequest(null); + + // Process the response normally, and maybe keep going for more pages. + $this->processActionResponse($action, $response->filterByReferenceSegments($action->getRequestSegmentNumbers())); + if ($action instanceof PaginateableAction && $action->hasMorePages()) { + $this->execute($action); + } + } + + /** + * For an action where {@link BaseAction::needsTan()} returns `true` and {@link TanMode::isDecoupled()} returns + * `true`, this function checks with the server whether the second factor authentication has been completed yet on + * the secondary device of the user. + * - If so, this completes the given action and returns `true`. + * - In case the action fails, the corresponding exception will be thrown from this function. + * - If the authentication has not been completed yet, this returns `false` and the action remains in its + * previous, uncompleted state. + * + * By using {@link persist()}, this function can be called asynchronously, i.e., not in the same PHP process as the + * original {@link execute()} call. + * + * This function can be called repeatedly, subject to the delays specified in the {@link TanMode}. + * IMPORTANT: Remember to re-{@link persist()} the {@link FinTs} instance after each + * {@link checkDecoupledSubmission()} call. + * + * @link https://www.hbci-zka.de/dokumente/spezifikation_deutsch/fintsv3/FinTS_3.0_Security_Sicherheitsverfahren_PINTAN_2020-07-10_final_version.pdf + * Section B.4.2.2 + * + * @param BaseAction $action The action to be completed. + * @return bool True if the decoupled authentication is done and the $action was completed. If false, the + * {@link TanRequest} inside the action has been updated, which *may* provide new/more instructions to the user, + * though probably it rarely does in practice. + * @throws CurlException When the connection fails in a layer below the FinTS protocol. + * @throws UnexpectedResponseException When the server responds with a valid but unexpected message. + * @throws ServerException When the server responds with a (FinTS-encoded) error message, which includes most things + * that can go wrong with the action itself, like wrong credentials, invalid IBANs, locked accounts, etc. + */ + public function checkDecoupledSubmission(BaseAction $action): bool + { + // Check the action's state. + $tanRequest = $action->getTanRequest(); + if ($tanRequest === null) { + throw new \InvalidArgumentException('This action is not awaiting decoupled confirmation'); + } + if ($action instanceof DialogInitialization) { + if ($this->dialogId !== null) { + throw new \RuntimeException('Cannot init another dialog.'); + } + $this->dialogId = $action->getDialogId(); + $this->messageNumber = $action->getMessageNumber(); + } + + // (2a) Construct the request. + $tanMode = $this->requireTanMode(); + if ($tanMode instanceof NoPsd2TanMode) { + throw new \InvalidArgumentException('Cannot check decoupled status when the bank does not support PSD2'); + } + if (!$tanMode->isDecoupled()) { + throw new \InvalidArgumentException('Cannot check decoupled status for a non-decoupled TAN mode'); + } + $message = MessageBuilder::create() + ->add(HKTANFactory::createProzessvariante2StepS($tanMode, $tanRequest->getProcessId())); + $request = $this->buildMessage($message, $tanMode); + + // Execute the request. + $response = $this->sendMessage($request); + $this->readBPD($response); + + // Determine if the decoupled authentication has completed. See section B.4.2.2.1. + // There is always at least one HITAN segment with TAN-Prozess=S and the reference ID. + // (2b) The response code 3956 indicates that the authentication is still outstanding. There could also be more + // information for the user in the HITAN challenge field. + // (2c) Note that we only support the (B) variant here. There is additionally supposed to be a HITAN segment + // with TAN-Prozess=2 and the reference ID to indicate that the authentication has completed, though not + // all banks actually send this, as they seem to consider the absence of 3956 as sufficient for signaling + // success. In this case, the response also contains the response segments for the executed action, if any. + $hitanProcessS = null; + /** @var HITAN $hitan */ + foreach ($response->findSegments(HITAN::class) as $hitan) { + if ($hitan->getAuftragsreferenz() !== $tanRequest->getProcessId()) { + throw new UnexpectedResponseException('Unexpected Auftragsreferenz: ' . $hitan->getAuftragsreferenz()); + } + if ($hitan->getTanProzess() === HKTAN::TAN_PROZESS_S) { + $hitanProcessS = $hitan; + } + } + if ($hitanProcessS === null) { + throw new UnexpectedResponseException('Missing HITAN with tanProzess=S in the response'); + } + if ($response->findRueckmeldungen(Rueckmeldungscode::STARKE_KUNDENAUTHENTIFIZIERUNG_NOCH_AUSSTEHEND)) { + // The decoupled submission isn't complete yet. Update the TAN request, as the bank may have sent additional + // instructions. + $action->setTanRequest($hitanProcessS); + if ($action instanceof DialogInitialization) { + $this->dialogId = null; + $action->setMessageNumber($this->messageNumber); + } + return false; + } + + // The decoupled submission is complete and the action's result is included in the response. + $action->setTanRequest(null); + // Process the response normally, and maybe keep going for more pages. + $this->processActionResponse($action, $response->filterByReferenceSegments($action->getRequestSegmentNumbers())); + if ($action instanceof PaginateableAction && $action->hasMorePages()) { + $this->execute($action); + } + return true; + } + + /** + * Closes the session/dialog/connection, if open. This is equivalent to logging out. You should call this function + * when you're done with all the actions, but NOT when you're persisting the instance to fulfill the TAN request of + * an outstanding action. + * This FinTs object remains usable even after closing the session. You can still {@link persist()} it to benefit + * from cached BPD/UPD upon the next {@link login()}, for instance. + * @throws ServerException When closing the dialog fails. + */ + public function close() + { + if ($this->dialogId !== null) { + $this->endDialog(); + } + $this->disconnect(); + } + + /** + * Assumes that the session/dialog (if any is open) is gone, but keeps any cached BPD/UPD for reuse (to allow for + * faster re-login). + * This can be called by the application using this library when it just restored this FinTs instance from the + * persisted format after a long time, during which the session/dialog has most likely expired on the server side. + */ + public function forgetDialog() + { + $this->dialogId = null; + } + + /** + * Before executing any actions that might require two-step authentication (like fetching a statement or initiating + * a wire transfer), the user needs to pick a {@link TanMode}. Note that this does not always imply that the user + * actually needs to enter a TAN every time, but they need to have picked the mode so that the system knows how to + * deliver a TAN, if necesssary. + * @return TanMode[] The TAN modes that are available to the user, indexed by their IDs. + * @throws CurlException When the connection fails in a layer below the FinTS protocol. + * @throws UnexpectedResponseException When the server does not send the BPD, the Kundensystem-ID or the TAN modes + * like it should according to the protocol, or when the dialog is not closed properly. + * @throws ServerException When the server responds with an error. + */ + public function getTanModes(): array + { + $this->ensureTanModesAvailable(); + $result = array(); + foreach ($this->allowedTanModes as $tanModeId) { + if (!array_key_exists($tanModeId, $this->bpd->allTanModes)) continue; + $result[$tanModeId] = $this->bpd->allTanModes[$tanModeId]; + } + return $result; + } + + /** + * For TAN modes where {@link TanMode::needsTanMedium()} returns true, the user additionally needs to pick a TAN + * medium. This function returns a list of possible TAN media. Note that, depending on the bank, this list may + * contain all the user's TAN media, or just the ones that are compatible with the given $tanMode. + * @param TanMode|int $tanMode Either a {@link TanMode} instance obtained from {@link getTanModes()} or its ID. + * @return TanMedium[] A list of possible TAN media. + * @throws CurlException When the connection fails in a layer below the FinTS protocol. + * @throws UnexpectedResponseException When the server does not send the BPD, the Kundensystem-ID or the TAN media + * (which includes the case where the server does not support enumerating TAN media, which is indicated by + * {@link TanMode::needsTanMedium()} returning false), or when the dialog is not closed properly. + * @throws ServerException When the server responds with an error. + */ + public function getTanMedia($tanMode): array + { + if ($this->dialogId !== null) { + $this->endDialog(); + } + $this->ensureBpdAvailable(); + $this->ensureSynchronized(); + $getTanMedia = new GetTanMedia(); + + // Execute the GetTanMedia request with the $tanMode swapped in temporarily. + $oldTanMode = $this->selectedTanMode; + $oldTanMedium = $this->selectedTanMedium; + $this->selectedTanMode = $tanMode instanceof TanMode ? $tanMode->getId() : $tanMode; + $this->selectedTanMedium = ''; + try { + $this->executeWeakDialogInitialization('HKTAB'); + $this->execute($getTanMedia); + $this->endDialog(); + return $getTanMedia->getTanMedia(); + } catch (UnexpectedResponseException|CurlException|ServerException $e) { + throw $e; + } finally { + $this->selectedTanMode = $oldTanMode; + $this->selectedTanMedium = $oldTanMedium; + } + } + + /** + * @param TanMode|int $tanMode Either a {@link TanMode} instance obtained from {@link getTanModes()} or its ID. + * @param TanMedium|string|null $tanMedium If the $tanMode has {@link TanMode::needsTanMedium()} set to true, this + * must be the value returned from {@link TanMedium::getName()} for one of the TAN media supported with that TAN + * mode. Use {@link getTanMedia()} to obtain a list of possible TAN media options. + */ + public function selectTanMode($tanMode, $tanMedium = null) + { + if (!is_int($tanMode) && !($tanMode instanceof TanMode)) { + throw new \InvalidArgumentException('tanMode must be an int or a TanMode'); + } + if ($tanMedium !== null && !is_string($tanMedium) && !($tanMedium instanceof TanMedium)) { + throw new \InvalidArgumentException('tanMedium must be a string or a TanMedium'); + } + $this->selectedTanMode = $tanMode instanceof TanMode ? $tanMode->getId() : $tanMode; + $this->selectedTanMedium = $tanMedium instanceof TanMedium ? $tanMedium->getName() : $tanMedium; + } + + /** + * Fetches the BPD from the server, if they are not already present at the client, and then returns them. Note that + * this does not require user login. + * @return BPD The BPD from the bank. + * @throws CurlException When the connection fails in a layer below the FinTS protocol. + * @throws UnexpectedResponseException When the server does not send the BPD or close the dialog properly. + * @throws ServerException When the server resopnds with an error. + */ + public function getBpd(): BPD + { + $this->ensureBpdAvailable(); + return $this->bpd; + } + + // ------------------------------------------------- IMPLEMENTATION ------------------------------------------------ + + /** + * Ensures that the latest BPD data is present by executing an anonymous dialog (including initialization and + * termination of the dialog) if necessary. Executing this does not require (strong or any) authentication, and it + * makes the {@link $bpd} available. + * + * @link https://www.hbci-zka.de/dokumente/spezifikation_deutsch/fintsv3/FinTS_3.0_Formals_2017-10-06_final_version.pdf + * Section: C.5.1 (and also C.3.1.1) + * + * @throws CurlException When the connection fails in a layer below the FinTS protocol. + * @throws UnexpectedResponseException When the server does not send the BPD or close the dialog properly. + * @throws ServerException When the server resopnds with an error. + */ + private function ensureBpdAvailable() + { + if ($this->bpd !== null) { + return; // Nothing to do. + } + if ($this->dialogId !== null) { + throw new \RuntimeException('Cannot init another dialog.'); + } + if ($this->selectedTanMode === NoPsd2TanMode::ID || $this->selectedTanMode instanceof NoPsd2TanMode) { + // For banks that don't support PSD2, we also don't use an anonymous dialog to obtain the BPD. The more + // common procedure before PSD2 was to just get the BPD upon first login. Thus execute(DialogInitialization) + // tolerates not having a BPD yet. + return; + } + + // We must always include HKTAN in order to signal that strong authentication (PSD2) is supported (section + // B.4.3.1). As this is the first contact with the server, we don't know which HKTAN versions it supports, so we + // just sent HKTANv6 as it's currently most supported by banks. + $initRequest = Message::createPlainMessage(MessageBuilder::create() + ->add(HKIDNv2::createAnonymous($this->options->bankCode)) + ->add(HKVVBv3::create($this->options, null, null)) // Pretend we have no BPD/UPD. + ->add(HKTANv6::createDummy())); + $initResponse = $this->sendMessage($initRequest); + if (!$this->readBPD($initResponse)) { + throw new UnexpectedResponseException('Did not receive BPD'); + } + $this->dialogId = $initResponse->header->dialogId; + $this->endDialog(true); + } + + private function requireCredentials(): Credentials + { + if ($this->credentials === null) { + throw new \LogicException('This action is not allowed on a FinTs instance without Credentials'); + } + return $this->credentials; + } + + /** + * Ensures that the {@link $allowedTanModes} are available by executing a personalized, TAN-less dialog + * initialization (and closing the dialog again), if necessary. Executing this only requires the {@link Credentials} + * but no strong authentication. + * @throws CurlException When the connection fails in a layer below the FinTS protocol. + * @throws UnexpectedResponseException When the server does not send the BPD, the Kundensystem-ID or the TAN modes + * like it should according to the protocol, or when the dialog is not closed properly. + * @throws ServerException When the server responds with an error. + */ + private function ensureTanModesAvailable() + { + if ($this->allowedTanModes === null) { + $this->ensureBpdAvailable(); + $this->ensureSynchronized(); // The response here will contain 3920, which is written to $allowedTanModes. + if ($this->allowedTanModes === null) { + throw new UnexpectedResponseException('No TAN modes received'); + } + } + } + + /** + * Ensures that we have a {@link $kundensystemId} by executing a synchronization dialog (and closing it again) if + * if necessary. Executing this does not require strong authentication. + * @throws CurlException When the connection fails in a layer below the FinTS protocol. + * @throws UnexpectedResponseException When the server does not send the BPD or the Kundensystem-ID, or when the + * dialog is not closed properly. + * @throws ServerException When the server responds with an error. + */ + private function ensureSynchronized() + { + if ($this->kundensystemId === null) { + $this->ensureBpdAvailable(); + + // Execute dialog initialization without a TAN mode/medium, so using the fake mode 999. While most banks + // accept the real TAN mode for synchronization (as defined in the specification), some get confused by the + // presence of anything other than 999 into thinking that strong authentication is required. And for those + // banks that don't support PSD2, we just keep the dummy TAN mode, as they wouldn't even understand 999. + $oldTanMode = $this->selectedTanMode; + $oldTanMedium = $this->selectedTanMedium; + if (!($this->selectedTanMode instanceof NoPsd2TanMode)) { + $this->selectedTanMode = null; + } + $this->selectedTanMedium = null; + try { + $this->executeWeakDialogInitialization(null); + if ($this->kundensystemId === null) { + throw new UnexpectedResponseException('No Kundensystem-ID retrieved from sync.'); + } + $this->endDialog(); + } finally { + $this->selectedTanMode = $oldTanMode; + $this->selectedTanMedium = $oldTanMedium; + } + } + } + + /** + * If the selected TAN mode was provided as an int, resolves it to a full {@link TanMode} instance, which may + * involve a request to the server to retrieve the BPD. Then returns it. + * @return TanMode|null The current TAN mode, null if none was selected, never an int. + * @throws CurlException When the connection fails in a layer below the FinTS protocol. + * @throws ServerException When the server resopnds with an error during the BPD fetch. + */ + public function getSelectedTanMode(): ?TanMode + { + if ($this->selectedTanMode === NoPsd2TanMode::ID) { + $this->selectedTanMode = new NoPsd2TanMode(); + } elseif (is_int($this->selectedTanMode)) { + $this->ensureBpdAvailable(); + if (!array_key_exists($this->selectedTanMode, $this->bpd->allTanModes)) { + throw new \InvalidArgumentException("Unknown TAN mode: $this->selectedTanMode"); + } + $this->selectedTanMode = $this->bpd->allTanModes[$this->selectedTanMode]; + if (!$this->selectedTanMode->isProzessvariante2()) { + throw new UnsupportedException('Only supports Prozessvariante 2'); + } + + if ($this->selectedTanMode->needsTanMedium()) { + if ($this->selectedTanMedium === null) { + throw new \InvalidArgumentException('tanMedium is mandatory for this tanMode'); + } + } else { + if ($this->selectedTanMedium !== null) { + throw new \InvalidArgumentException('tanMedium not allowed for this tanMode'); + } + } + } + return $this->selectedTanMode; + } + + /** + * Like {@link getSelectedTanMode()}, but throws an exception if none was selected. + * @return TanMode The current TAN mode. + * @throws \RuntimeException If no TAN mode has been selected. + * @throws CurlException When the connection fails in a layer below the FinTS protocol. + * @throws ServerException When the server resopnds with an error during the BPD fetch. + */ + private function requireTanMode(): TanMode + { + $tanMode = $this->getSelectedTanMode(); + if ($tanMode === null) { + throw new \RuntimeException('selectTanMode() must be called before login() or execute()'); + } + return $tanMode; + } + + /** + * Creates a new connection based on the {@link $options}. This can be overridden for unit testing purposes. + * @return Connection A newly instantiated connection. + */ + protected function newConnection(): Connection + { + return new Connection($this->options->url, $this->options->timeoutConnect, $this->options->timeoutResponse); + } + + /** + * Closes the physical connection, if necessary. + */ + private function disconnect() + { + if ($this->connection !== null) { + $this->connection->disconnect(); + $this->connection = null; + } + } + + /** + * Passes the response segments to the action for post-processing of the response. + * @param BaseAction $action The action to which the response belongs. + * @param Message $fakeResponseMessage A messsage that contains the response segments for this action. + * @throws UnexpectedResponseException When the server responded with a valid but unexpected message. + */ + private function processActionResponse(BaseAction $action, Message $fakeResponseMessage) + { + $action->processResponse($fakeResponseMessage); + if ($action instanceof DialogInitialization) { + $this->dialogId = $action->getDialogId(); + if ($this->kundensystemId === null && $action->getKundensystemId()) { + $this->kundensystemId = $action->getKundensystemId(); + } + if ($action->getUpd() !== null) { + $this->upd = $action->getUpd(); + } elseif ($this->upd === null && $action->isStronglyAuthenticated()) { + throw new UnexpectedResponseException('No UPD received'); + } + } + } + + /** + * Initialize a personalized dialog with weak authentication (no two-step authentication, no TAN, using the fake + * mode with ID 999 instead), which can be used for certain less sensitive business transactions, including HKTAB to + * retrieve the TAN media list. This is for Authentifizierungsklasse 1 and 4 (conditionally). + * @link https://www.hbci-zka.de/dokumente/spezifikation_deutsch/fintsv3/FinTS_3.0_Security_Sicherheitsverfahren_PINTAN_2018-02-23_final_version.pdf + * Section: B.3 + * @param string|null $hktanRef The identifier of the main PIN/TAN management segment to be executed in this dialog, + * or null for a general weakly authenticated dialog. See {@link DialogInitialization} for documentation. + * @throws CurlException When the connection fails in a layer below the FinTS protocol. + * @throws UnexpectedResponseException When the server does not send the BPD or the Kundensystem-ID as it should + * according to the protocol, when it asks for a TAN even though it shouldn't, or when the dialog is not closed + * properly. + * @throws ServerException When the server responds with an error. + */ + private function executeWeakDialogInitialization(?string $hktanRef) + { + if ($this->dialogId !== null) { + throw new \RuntimeException('Cannot init another dialog.'); + } + + $this->messageNumber = 1; + $dialogInitialization = new DialogInitialization($this->options, $this->requireCredentials(), + $this->getSelectedTanMode(), $this->selectedTanMedium, $this->kundensystemId, $hktanRef); + $this->execute($dialogInitialization); + if ($dialogInitialization->needsTan()) { + throw new UnexpectedResponseException('Server asked for TAN on a dialog meant for weak authentication'); + } + } + + /** + * @param Message $response A response retrieved from the server that may or may not contain the BPD. + * @return bool Whether the BPD was found in the response. + */ + private function readBPD(Message $response): bool + { + if ($allowed = $response->findRueckmeldung(Rueckmeldungscode::ZUGELASSENE_VERFAHREN)) { + $this->allowedTanModes = array_map('intval', $allowed->rueckmeldungsparameter); + } + if (!$response->hasSegment(HIBPAv3::class)) { + return false; + } + $this->bpd = BPD::extractFromResponse($response); + if (!$this->bpd->supportsPsd2() && !($this->selectedTanMode instanceof NoPsd2TanMode)) { + throw new UnsupportedException('The bank does not support PSD2.'); + } + return true; + } + + /** + * Closes the currently active dialog, if any. Note that this does *not* close the connection, it is possible to + * open a new dialog on the same connection. + * @param bool $isAnonymous If set to true, the HKEND message will not be wrapped into an encryption envelope. + * @throws ServerException When the server responds with an error instead of closing the dialog. This means that + * the connection is tainted and can probably not be used for another dialog. + */ + protected function endDialog(bool $isAnonymous = false) + { + if ($this->connection === null) { + $this->dialogId = null; + return; + } + try { + if ($this->dialogId !== null) { + $message = MessageBuilder::create()->add(HKENDv1::create($this->dialogId)); + $request = $isAnonymous + ? Message::createPlainMessage($message) + : $this->buildMessage($message, $this->getSelectedTanMode()); + $response = $this->sendMessage($request); + if ($response->findRueckmeldung(Rueckmeldungscode::BEENDET) === null) { + throw new UnexpectedResponseException( + 'Server did not confirm dialog end, but did not send error either'); + } + } + } catch (CurlException $e) { + // Ignore, we want to disconnect anyway. + } catch (ServerException $e) { + if ($e->hasError(Rueckmeldungscode::ABGEBROCHEN)) { + // We wanted to end the dialog, but the server already canceled it before. + $this->logger->warning("Dialog already ended: $e"); + } else { + // Something else went wrong. + throw $e; + } + } finally { + $this->dialogId = null; + } + } + + /** + * Injects FinTsOptions/BPD/UPD/Credentials information into the message. + * @param MessageBuilder $message The message to be built. + * @param TanMode|null $tanMode Optionally a TAN mode that will be used when sending this message, defaults to 999 + * (single step). + * @param string|null Optionally a TAN to sign this message with. + * @return Message The built message. + */ + private function buildMessage(MessageBuilder $message, ?TanMode $tanMode = null, ?string $tan = null): Message + { + return Message::createWrappedMessage( + $message, + $this->options, + $this->kundensystemId === null ? '0' : $this->kundensystemId, + $this->requireCredentials(), + $tanMode, + $tan + ); + } + + /** + * Finalizes a message (conversion to wire format, filling in message number and size), sends it to the bank and + * parses the response, plus logging. + * @param MessageBuilder|Message $request The message to be sent. + * @return Message The response from the server. + * @throws CurlException When the request failed on the physical or TCP/HTTPS protocol level. + * @throws ServerException When the response contains an error. + */ + private function sendMessage($request): Message + { + if ($request instanceof MessageBuilder) { + $request = $this->buildMessage($request, $this->getSelectedTanMode()); + } + + $request->header->dialogId = $this->dialogId === null ? '0' : $this->dialogId; + $request->header->nachrichtennummer = $this->messageNumber; + $request->footer->nachrichtennummer = $this->messageNumber; + ++$this->messageNumber; + $request->header->setNachrichtengroesse(strlen($request->serialize())); + + $request->validate(); + + if ($this->connection === null) { + $this->connection = $this->newConnection(); + } + + $rawRequest = $request->serialize(); + $this->logger->debug('> ' . $rawRequest); + try { + $rawResponse = $this->connection->send($rawRequest); + $this->logger->debug('< ' . $rawResponse); + } catch (CurlException $e) { + $this->logger->critical($e->getMessage()); + $this->logger->debug(print_r($e->getCurlInfo(), true)); + $this->disconnect(); + throw $e; + } + + try { + $response = Message::parse($rawResponse); + } catch (\InvalidArgumentException $e) { + $this->disconnect(); + throw new InvalidResponseException('Invalid response from server', 0, $e); + } + + try { + ServerException::detectAndThrowErrors($response, $request); + } catch (ServerException $e) { + $this->disconnect(); + if ($e->hasError(Rueckmeldungscode::ABGEBROCHEN)) { + $this->forgetDialog(); + } + throw $e; + } + return $response; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/MT535/MT535.php b/vendor/nemiah/php-fints/lib/Fhp/MT535/MT535.php new file mode 100755 index 0000000..3733d8b --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/MT535/MT535.php @@ -0,0 +1,125 @@ + substr_count($rawData, '@@-') ? "\r\n" : '@@'; + $this->cleanedRawData = preg_replace('#' . $divider . '([^:])#ms', '$1', $rawData); + } + + public function parseDepotWert(): float + { + preg_match('/:16R:ADDINFO(.*?):16S:ADDINFO/sm', $this->cleanedRawData, $block); + preg_match('/EUR(.*)/sm', $block[1], $matches); + return floatval(str_replace(',', '.', $matches[1])); + } + + public function parseHoldings(): StatementOfHoldings + { + $result = new StatementOfHoldings(); + preg_match_all('/:16R:FIN(.*?):16S:FIN/sm', $this->cleanedRawData, $blocks); + foreach ($blocks[1] as $block) { + $holding = new Holding(); + // handle ISIN, WKN & Name + // :35B:ISIN DE0005190003/DE/519000BAY.MOTOREN WERKE AG ST + if (preg_match('/^:35B:(.*?):/sm', $block, $iwn)) { + preg_match('/^.{5}(.{12})/sm', $iwn[1], $r); + $holding->setISIN($r[1]); + preg_match('/^.{21}(.{6})/sm', $iwn[1], $r); + $holding->setWKN($r[1]); + preg_match('/^.{27}(.*)/sm', $iwn[1], $r); + $holding->setName($r[1]); + } + + // handle acquisition price + // e.g ':70E::HOLD//1STK23,968293+EUR' + if (preg_match('/:70E::HOLD\/\/\d*STK2(\d*),(\d*)\+([A-Z]{3})/sm', $block, $iwn)) { + $holding->setAcquisitionPrice((float) $iwn[1].'.'.$iwn[2]); + if ($holding->getCurrency() === null) { + $holding->setCurrency($iwn[3]); + } + } + + // handle Price + // :90B::MRKT//ACTU/EUR76,06 + // A1G1UF + if (preg_match('/:90(.)::(.*?):/sm', $block, $iwn)) { + if ($iwn[1] == 'B') { + // Currency + preg_match('/^.{11}(.{3})/sm', $iwn[2], $r); + $holding->setCurrency($r[1]); + // Price + preg_match('/^.{14}(.*)/sm', $iwn[2], $r); + $holding->setPrice(floatval(str_replace(',', '.', $r[1]))); + } elseif ($iwn[1] == 'A') { + $holding->setCurrency('%'); + // Price + preg_match('/^.{11}(.*)/sm', $iwn[2], $r); + $holding->setPrice(floatval(str_replace(',', '.', $r[1])) / 100); + } + } + + // handle Amount + // :93B::AGGR//UNIT/2666,000 + if (preg_match('/:93B::(.*?):/sm', $block, $iwn)) { + // Amount + preg_match('/^.{11}(.*)/sm', $iwn[1], $r); + $holding->setAmount(floatval(str_replace(',', '.', $r[1]))); + } + + if ($holding->getAmount() !== null && $holding->getPrice() !== null) { + if ($holding->getCurrency() === '%') { + $holding->setValue($holding->getPrice() / 100); + } else { + $holding->setValue($holding->getPrice() * $holding->getAmount()); + } + } + + // Bereitstellungsdatum + // :98A::PRIC//20210304 + // :98C::STAT//20250104140541 + if (preg_match('/:98([AC])::(.*?):/sm', $block, $iwn)) { + preg_match('/^.{6}(.{8})/sm', $iwn[2], $r); + $holding->setDate($this->getDate($r[1])); + $time = new \DateTime(); + if ($iwn[1] == 'C') { + // 98C has a time component + preg_match('/^.{14}(\d\d)(\d\d)(\d\d)/sm', $iwn[2], $r); + $time->setTime($r[1], $r[2], $r[3]); + } else { + $time->setTime(0, 0); + } + $holding->setTime($time); + } + + $result->addHolding($holding); + } + return $result; + } + + protected function getDate(string $val): \DateTime + { + preg_match('/(\d{4})(\d{2})(\d{2})/', $val, $m); + try { + return new \DateTime($m[1] . '-' . $m[2] . '-' . $m[3]); + } catch (\Exception $e) { + throw new \InvalidArgumentException("Invalid date: $val", 0, $e); + } + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/MT940/Dialect/PostbankMT940.php b/vendor/nemiah/php-fints/lib/Fhp/MT940/Dialect/PostbankMT940.php new file mode 100755 index 0000000..b7d1e91 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/MT940/Dialect/PostbankMT940.php @@ -0,0 +1,39 @@ + implode("\n", $descriptionLines), + ]; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/MT940/Dialect/SpardaMT940.php b/vendor/nemiah/php-fints/lib/Fhp/MT940/Dialect/SpardaMT940.php new file mode 100755 index 0000000..f1f70f4 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/MT940/Dialect/SpardaMT940.php @@ -0,0 +1,87 @@ + implode("\n", $otherInfo)]; + } + + // Beispiel + /* + 0 => "SEPA-BASISLASTSCHRIFT" + 1 => "EREF+ xxxxxxxxxxxxxxxxxx MR" + 2 => "EF+ xxxxxxxxxxxxxxxx CRED+" + 3 => "XXxxxxxxxxxxxxxxxxx SVWZ+ A" + 4 => "bcdef ghijklmn opqr stuvwxy" + 5 => "z1 1234 678912345" + 6 => "ABWA+ Abcd Efghij" + */ + + $combined = ''; + foreach ($lines as $line) { + // Sonderfall, für Zeile 2 aus dem Beispiel + $combined .= preg_replace('/ ([A-Z]{4}\+)$/', ' $1 ', $line); + } + $combined = implode('', $lines); + + // SEPA Bezeichner müssen in einer neuen Zeile Anfangen und kein Leerzeichen hinter dem + haben + $fixed = preg_replace('/([A-Z]{4}\+) /', "\n$1", $combined); + + $correctedLines = explode("\n", trim($fixed, "\n")); + + // Buchungstext z.B. SEPA-ÜBERWEISUNG + if (count($otherInfo) > 0) { + $rawLines[0] = $bookingText = array_pop($otherInfo); + + switch ($bookingText) { + case 'SEPA-ÜBERWEISUNG': + if ($transaction['credit_debit'] === static::CD_CREDIT) { + $gvc = '166'; + } + if ($transaction['credit_debit'] === static::CD_DEBIT) { + $gvc = '177'; + } + break; + case 'SEPA-BASISLASTSCHRIFT': + $gvc = '105'; + break; + // case 'SEPA-RÜCKLASTSCHRIFT': + // Hängt vom Betrag ab ? + } + } + + // Rest vom Namen, wenn der > 27 Zeichen ist, ja ernsthaft + if (count($otherInfo) > 0) { + $rawLines[33] .= array_pop($otherInfo); + } + + $desc = parent::extractStructuredDataFromRemittanceLines($correctedLines, $gvc, $rawLines, $transaction); + + if (isset($desc['SVWZ']) && str_starts_with($desc['SVWZ'], 'Dauerauftrag')) { + $gvc = '152'; + $rawLines[0] = 'Dauerauftrag-Gutschrift'; + } + + return $desc; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/MT940/MT940.php b/vendor/nemiah/php-fints/lib/Fhp/MT940/MT940.php new file mode 100755 index 0000000..32c4b03 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/MT940/MT940.php @@ -0,0 +1,258 @@ + substr_count($rawData, '@@-') ? "\r\n" : '@@'; + + $cleanedRawData = preg_replace('#' . $divider . '([^:])#ms', '$1', $rawData); + + $booked = true; + $result = []; + $days = explode($divider . '-', $cleanedRawData); + $soaDate = null; + foreach ($days as &$day) { + $day = explode($divider . ':', $day); + + for ($i = 0, $cnt = count($day); $i < $cnt; ++$i) { + if (preg_match("/\+\@[0-9]+\@$/", trim($day[$i]))) { + $booked = false; + } + + // handle start balance + // 60F:C160401EUR1234,56 + if (preg_match('/^60(F|M):/', $day[$i])) { + // remove 60(F|M): for better parsing + $day[$i] = substr($day[$i], 4); + $soaDate = $this->getDate(substr($day[$i], 1, 6)); + + // if this statement date ist first seen set start_balance + // Note: all further transactions in different statements with the same soaDate will be appended + // there will be no new statement done for them. With bigger code changes this could be changed. + // For now this is shortcutted like this for fixing https://github.com/nemiah/phpFinTS/issues/367 + if (!isset($result[$soaDate])) { + $result[$soaDate] = ['start_balance' => []]; + $cdMark = substr($day[$i], 0, 1); + if ($cdMark === 'C') { + $result[$soaDate]['start_balance']['credit_debit'] = static::CD_CREDIT; + } elseif ($cdMark === 'D') { + $result[$soaDate]['start_balance']['credit_debit'] = static::CD_DEBIT; + } + + $amount = str_replace(',', '.', substr($day[$i], 10)); + $result[$soaDate]['start_balance']['amount'] = $amount; + } + } elseif ( + // found transaction + // trx:61:1603310331DR637,39N033NONREF + str_starts_with($day[$i], '61:') + && isset($day[$i + 1]) + && str_starts_with($day[$i + 1], '86:') + ) { + $transaction = substr($day[$i], 3); + $description = substr($day[$i + 1], 3); + + if (!isset($result[$soaDate]['transactions'])) { + $result[$soaDate]['transactions'] = []; + } + + // short form for better handling + $trx = &$result[$soaDate]['transactions']; + + preg_match('/^\d{6}(\d{4})?(C|D|RC|RD)([A-Z]{1})?([^N]+)N/', $transaction, $trxMatch); + if ($trxMatch[2] === 'C' || $trxMatch[2] === 'RC') { + $trx[count($trx)]['credit_debit'] = static::CD_CREDIT; + } elseif ($trxMatch[2] === 'D' || $trxMatch[2] === 'RD') { + $trx[count($trx)]['credit_debit'] = static::CD_DEBIT; + } else { + throw new MT940Exception('cd mark not found in: ' . $transaction); + } + + $trx[count($trx) - 1]['is_storno'] = ($trxMatch[2] === 'RC' or $trxMatch[2] === 'RD'); + + $amount = $trxMatch[4]; + $amount = str_replace(',', '.', $amount); + $trx[count($trx) - 1]['amount'] = $amount; + + // :61:1605110509D198,02NMSCNONREF + // 16 = year + // 0511 = valuta date + // 0509 = booking date + + $year = substr($transaction, 0, 2); + $valutaDate = $this->getDate($year . substr($transaction, 2, 4)); + $bookingDatePart = substr($transaction, 6, 4); + + if (preg_match('/^\d{4}$/', $bookingDatePart) === 1) { + // try to guess the correct year of the booking date + + $valutaDateTime = new \DateTime($valutaDate); + $bookingDateTime = new \DateTime($this->getDate($year . $bookingDatePart)); + + // the booking date can be before or after the valuata date + // and one of them can be in another year for example 12-31 and 01-01 + + $diff = $valutaDateTime->diff($bookingDateTime); + + // if diff is more than half a year + if ($diff->days > 182) { + // and positive + if ($diff->invert === 0) { + // its in the last year + --$year; + } + // and negative + else { + // its in the next year + ++$year; + } + } + $bookingDate = $this->getDate($year . $bookingDatePart); + } else { + // if booking date not set in :61, then we have to take it from :60F + $bookingDate = $soaDate; + } + + $trx[count($trx) - 1]['booking_date'] = $bookingDate; + $trx[count($trx) - 1]['valuta_date'] = $valutaDate; + $trx[count($trx) - 1]['booked'] = $booked; + + $trx[count($trx) - 1]['description'] = $this->parseDescription($description, $trx[count($trx) - 1]); + } elseif ( + preg_match('/^62F:/', $day[$i]) // handle end balance + ) { + // remove 62F: for better parsing + $day[$i] = substr($day[$i], 4); + $soaDate = $this->getDate(substr($day[$i], 1, 6)); + + if (isset($result[$soaDate])) { + #$result[$soaDate] = ['end_balance' => []]; + + $amount = str_replace(',', '.', substr($day[$i], 10, -1)); + $cdMark = substr($day[$i], 0, 1); + if ($cdMark == 'C') { + $result[$soaDate]['end_balance']['credit_debit'] = static::CD_CREDIT; + } elseif ($cdMark == 'D') { + $result[$soaDate]['end_balance']['credit_debit'] = static::CD_DEBIT; + $amount *= -1; + } + + $result[$soaDate]['end_balance']['amount'] = $amount; + } + } + } + } + + return $result; + } + + protected function parseDescription($descr, $transaction): array + { + // Geschäftsvorfall-Code + $gvc = substr($descr, 0, 3); + + $prepared = []; + $result = []; + + // prefill with empty values + for ($i = 0; $i <= 63; ++$i) { + $prepared[$i] = null; + } + + $descr = str_replace('? ', '?', $descr); + + preg_match_all('/\?(\d{2})([^\?]+)/', $descr, $matches, PREG_SET_ORDER); + + $descriptionLines = []; + $description1 = ''; // Legacy, could be removed. + $description2 = ''; // Legacy, could be removed. + foreach ($matches as $m) { + $index = (int) $m[1]; + + if ((20 <= $index && $index <= 29) || (60 <= $index && $index <= 63)) { + if (20 <= $index && $index <= 29) { + $description1 .= $m[2]; + } else { + $description2 .= $m[2]; + } + $descriptionLines[] = $m[2]; + } + $prepared[$index] = $m[2]; + } + + $description = $this->extractStructuredDataFromRemittanceLines($descriptionLines, $gvc, $prepared, $transaction); + + $result['booking_code'] = $gvc; + $result['booking_text'] = trim($prepared[0] ?? ''); + $result['description'] = $description; + $result['primanoten_nr'] = trim($prepared[10] ?? ''); + $result['description_1'] = trim($description1); + $result['bank_code'] = trim($prepared[30] ?? ''); + $result['account_number'] = trim($prepared[31] ?? ''); + $result['name'] = trim(($prepared[32] ?? '') . ($prepared[33] ?? '')); + $result['text_key_addition'] = trim($prepared[34] ?? ''); + $result['description_2'] = $description2; + $result['desc_lines'] = $descriptionLines; + + return $result; + } + + /** + * @param string[] $descriptionLines that contain the remittance information + * @param string $gvc Geschätsvorfallcode; Out-Parameter, might be changed from information in remittance info + * @param string[] $rawLines All the lines in the Multi-Purpose-Field 86; Out-Parameter, might be changed from information in remittance info + */ + protected function extractStructuredDataFromRemittanceLines($descriptionLines, string &$gvc, array &$rawLines, array $transaction): array + { + $description = []; + if (empty($descriptionLines) || strlen($descriptionLines[0]) < 5 || $descriptionLines[0][4] !== '+') { + $description['SVWZ'] = implode('', $descriptionLines); + } else { + $lastType = null; + foreach ($descriptionLines as $line) { + if (strlen($line) >= 5 && $line[4] === '+') { + if ($lastType != null) { + $description[$lastType] = trim($description[$lastType]); + } + $lastType = substr($line, 0, 4); + $description[$lastType] = substr($line, 5); + } else { + $description[$lastType] .= $line; + } + if (strlen($line) < 27) { + // Usually, lines are 27 characters long. In case characters are missing, then it's either the end + // of the current type or spaces have been trimmed from the end. We want to collapse multiple spaces + // into one and we don't want to leave trailing spaces behind. So add a single space here to make up + // for possibly missing spaces, and if it's the end of the type, it will be trimmed off later. + $description[$lastType] .= ' '; + } + } + $description[$lastType] = trim($description[$lastType]); + } + + return $description; + } + + protected function getDate(string $val): string + { + $val = '20' . $val; + preg_match('/(\d{4})(\d{2})(\d{2})/', $val, $m); + return $m[1] . '-' . $m[2] . '-' . $m[3]; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/MT940/MT940Exception.php b/vendor/nemiah/php-fints/lib/Fhp/MT940/MT940Exception.php new file mode 100755 index 0000000..a45d23c --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/MT940/MT940Exception.php @@ -0,0 +1,10 @@ +id; + } + + /** + * @return $this + */ + public function setId(?string $id) + { + $this->id = $id; + + return $this; + } + + public function getAccountNumber(): ?string + { + return $this->accountNumber; + } + + /** + * @return $this + */ + public function setAccountNumber(?string $accountNumber) + { + $this->accountNumber = $accountNumber; + + return $this; + } + + public function getBankCode(): ?string + { + return $this->bankCode; + } + + /** + * @return $this + */ + public function setBankCode(?string $bankCode) + { + $this->bankCode = $bankCode; + + return $this; + } + + public function getIban(): ?string + { + return $this->iban; + } + + /** + * @return $this + */ + public function setIban(?string $iban) + { + $this->iban = $iban; + + return $this; + } + + public function getCustomerId(): ?string + { + return $this->customerId; + } + + /** + * @return $this + */ + public function setCustomerId(?string $customerId) + { + $this->customerId = $customerId; + + return $this; + } + + public function getCurrency(): ?string + { + return $this->currency; + } + + /** + * @return $this + */ + public function setCurrency(?string $currency) + { + $this->currency = $currency; + + return $this; + } + + public function getAccountOwnerName(): ?string + { + return $this->accountOwnerName; + } + + /** + * @return $this + */ + public function setAccountOwnerName(?string $accountOwnerName) + { + $this->accountOwnerName = $accountOwnerName; + + return $this; + } + + public function getAccountDescription(): ?string + { + return $this->accountDescription; + } + + /** + * @return $this + */ + public function setAccountDescription(?string $accountDescription) + { + $this->accountDescription = $accountDescription; + + return $this; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Model/FlickerTan/DataElement.php b/vendor/nemiah/php-fints/lib/Fhp/Model/FlickerTan/DataElement.php new file mode 100755 index 0000000..d314f1d --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Model/FlickerTan/DataElement.php @@ -0,0 +1,181 @@ +data = $data; + $this->headerHighBit = 0; + if (is_numeric($this->data) || empty($this->data)) { + $this->enc = self::ENC_BCD; + } else { + $this->enc = self::ENC_ASC; + } + } + + /** + * @return int amount of bytes in data + */ + protected function getLength(): int + { + if ($this->enc === self::ENC_BCD) { + return ceil(strlen($this->data) / 2); + } + return strlen($this->data); + } + + /** + * @return string returns the hex representation from the header of the data element as string with length 2 + */ + public function getHeaderHex(): string + { + $lengthBin = str_pad(base_convert($this->getLength(), 10, 2), 6, '0', STR_PAD_LEFT); + $headerHex = base_convert($this->headerHighBit . $this->enc . $lengthBin, 2, 16); + return str_pad($headerHex, 2, '0', STR_PAD_LEFT); + } + + /** + * @return string returns the hex representation of the data, depending on the set encoding + */ + public function getDataHex(): string + { + if ($this->enc === self::ENC_BCD) { + // base 10 and hex BCD encoded numbers are the same in range 0 to 9 + $hexData = $this->data; + // Pad on Byte lenght + if (strlen($hexData) % 2 === 1) { + $hexData .= 'F'; + } + return $hexData; + } + // ASCII encoding + $hexData = ''; + foreach (str_split($this->data) as $char) { + $hexData .= base_convert(ord($char), 10, 16); + } + return $hexData; + } + + /** + * @return string returns the hex representation of the Data Element incl header information + */ + public function toHex(): string + { + if (empty($this->data)) { + return ''; + } + return $this->getHeaderHex() . $this->getDataHex(); + } + + /** + * @param string $hex which will be converted + * @param int $length to which the byte will be padded (default: 8) + * @return string binary representation of hex value as string with length $length + */ + public static function hexToByte(string $hex, int $length = 8): string + { + $byte = base_convert($hex, 16, 2); + return str_pad($byte, $length, '0', STR_PAD_LEFT); + } + + /** + * @return int calculates Luhn Checksum of this object + */ + public function getLuhnChecksum(): int + { + return self::calcLuhn($this->getDataHex()); + } + + /** + * @return int calculates the Luhn checksum of a given hex string + */ + protected static function calcLuhn(string $hex): int + { + $sum = 0; + $doubleIt = false; + foreach (str_split($hex) as $char) { + $number = (int) base_convert($char, 16, 10); + if ($doubleIt) { + $number *= 2; + $decRep = str_split($number); + foreach ($decRep as $value) { + $sum += (int) $value; + } + } else { + $sum += $number; + } + $doubleIt = !$doubleIt; + } + return $sum; + } + + /** + * Some handy debug info + */ + public function __debugInfo(): ?array + { + return [ + 'header' => $this->getHeaderHex(), + 'data' => $this->data, + 'hex-data' => $this->getDataHex(), + 'hex' => $this->toHex(), + 'luhn' => $this->getLuhnChecksum(), + ]; + } + + /** + * @return string hex representation of object + */ + public function __toString() + { + return $this->toHex(); + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Model/FlickerTan/StartCode.php b/vendor/nemiah/php-fints/lib/Fhp/Model/FlickerTan/StartCode.php new file mode 100755 index 0000000..faa0893 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Model/FlickerTan/StartCode.php @@ -0,0 +1,105 @@ + old version not supported so far + */ + protected function __construct(array $ctrlBytes, string $data) + { + if ($ctrlBytes !== ['01']) { + throw new \InvalidArgumentException('Other versions then 1.4 are not supported'); + } + parent::__construct($data); + $this->controlBytes = $ctrlBytes; + $this->headerHighBit = '1'; + } + + /** + * {@inheritDoc} + */ + public function toHex(): string + { + return $this->getHeaderHex() . implode('', $this->controlBytes) . $this->getDataHex(); + } + + /** + * {@inheritDoc} + */ + public function getLuhnChecksum(): int + { + $luhn = 0; + foreach ($this->controlBytes as $ctrl) { + $luhn = self::calcLuhn($ctrl); + } + $luhn += parent::getLuhnChecksum(); // Luhn from the data (of the startcode) + return $luhn; + } + + /** + * {@inheritDoc} + */ + public function __debugInfo(): ?array + { + return [ + 'header' => $this->getHeaderHex(), + 'ctrl' => $this->controlBytes, + 'data' => $this->data, + 'hex-data' => $this->getDataHex(), + 'luhn' => $this->getLuhnChecksum(), + ]; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Model/FlickerTan/SvgRenderer.php b/vendor/nemiah/php-fints/lib/Fhp/Model/FlickerTan/SvgRenderer.php new file mode 100755 index 0000000..4a01f47 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Model/FlickerTan/SvgRenderer.php @@ -0,0 +1,167 @@ +validate($bitPattern, $flickerFrequenz); + $this->frequency = $flickerFrequenz; + // prefix sync identifier + $this->bitPattern = $bitPattern; + // do the svg + $doc = []; + // init black background rect with rounded corners + $doc[] = $this->buildNode('rect', [ + 'width' => 210, + 'height' => 105, + 'rx' => 7.5, + 'ry' => 7.5, + 'fill' => 'black', + ]); + // triangle for aiming help for hardware tan generator + $doc[] = $this->buildNode('polygon', [ + 'points' => [ + '25,18', // middle bottom + '32,5', // top right + '18,5', // top left + ], + 'fill' => 'grey', + ]); + $doc[] = $this->buildNode('polygon', [ + 'points' => [ // x shifted by 160 + '185,18', // middle bottom + '192,5', // top right + '178,5', // top left + ], + 'fill' => 'grey', + ]); + + // init flicker rectangles + for ($i = 0; $i < 5; ++$i) { + $doc[] = $this->buildNode('rect', [ + 'x' => 40 * $i + 10, + 'y' => 20, + 'width' => 30, + 'height' => 75, + ], [$this->getAnimation($i)]); + } + $docAttr = [ + 'xmlns' => 'http://www.w3.org/2000/svg', + 'width' => $width, + 'height' => $height, + 'viewBox' => '0 0 210 105', + 'preserveAspectRatio' => 'none', + 'id' => $id, + ]; + $this->svg = $this->buildNode('svg', $docAttr, $doc); + } + + /** + * @param int $channelNumber flickerRectangles numbered from left to right, 0 is clock, 1 is 2^0, ..., 4 is 2^3 half byte representation + */ + private function getAnimation(int $channelNumber): string + { + $timePerHalfByte = 1 / $this->frequency * 2; + $attr = [ + 'attributeName' => 'fill', + 'calcMode' => 'discrete', + 'repeatCount' => 'indefinite', + ]; + if ($channelNumber === 0) { + // first rectangle is the clock + $attr['values'] = 'white;black;white'; + $attr['keyFrames'] = '0;0.5;1'; + $attr['dur'] = $timePerHalfByte . 's'; + } else { + // arrange keyframes and colors + $colors = array_map(static function (string $pattern) use ($channelNumber) { + return $pattern[$channelNumber - 1] === '1' ? 'white' : 'black'; + }, $this->bitPattern); + $keyFrames = range(0, 1, 1.0 / count($this->bitPattern)); + + $attr['values'] = implode(';', $colors); + $attr['keyFrames'] = implode(';', $keyFrames); + $attr['dur'] = ($timePerHalfByte * count($this->bitPattern)) . 's'; + } + return $this->buildNode('animate', $attr); + } + + /** + * @param string $tag the tag of the node + * @param array $attributes name-value pairs of the node attributes + * @param string[] $childs of the node + * @return string the string representation of the whole node with cilds + */ + private function buildNode(string $tag, array $attributes = [], array $childs = []): string + { + $attr = []; + foreach ($attributes as $name => $value) { + switch ($name) { + case 'fill': + $attr[] = "style='fill: $value'"; + break; + case 'points': + $attr[] = "points='" . implode(' ', $value) . "'"; + break; + default: + $attr[] = "$name='$value'"; + } + } + $attrStr = implode(' ', $attr); + $childStr = implode(PHP_EOL, $childs); + return "<$tag $attrStr>$childStr"; + } + + public function getImage(): string + { + return $this->svg; + } + + public function __toString() + { + return $this->svg; + } + + /** + * Validates input for frequency and bit pattern + * @throws \InvalidArgumentException + */ + private function validate(array $bitPattern, int $frequency): void + { + if ($frequency < 2 || $frequency > 40) { + throw new \InvalidArgumentException('Frequency is not between 2 and 40 Hz'); + } + foreach ($bitPattern as $idx => $pattern) { + // detect if a string is not length 4 with only 0 and 1 chars + if (!preg_match('/^[01]{4}$/', $pattern)) { + throw new \InvalidArgumentException("Bit Pattern at index $idx is faulty, only 0 and 1 are allowed with length 4"); + } + } + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Model/FlickerTan/TanRequestChallengeFlicker.php b/vendor/nemiah/php-fints/lib/Fhp/Model/FlickerTan/TanRequestChallengeFlicker.php new file mode 100755 index 0000000..36616df --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Model/FlickerTan/TanRequestChallengeFlicker.php @@ -0,0 +1,153 @@ +challenge = $challengeBin->getData(); + $this->parseChallenge(); + } + + private function parseChallenge(): void + { + $reducedChallenge = trim(str_replace(' ', '', $this->challenge)); + // length of whole challenge (without lc) max 255 | encoding: base 10 + $lc = (int) substr($reducedChallenge, 0, 3); + $reducedChallenge = substr($reducedChallenge, 3); + if (strlen($reducedChallenge) !== $lc) { + throw new \InvalidArgumentException("Wrong length of TAN Challenge expected: $lc - found: ". strlen($reducedChallenge). ' - only Version 1.4 supported'); + } + + [$reducedChallenge, $this->startCode] = StartCode::parseNextBlock($reducedChallenge); + for ($i = 0; $i < 3; ++$i) { + [$reducedChallenge, $de] = DataElement::parseNextBlock($reducedChallenge); + $this->dataElements[$i] = $de; + } + if (!empty($reducedChallenge)) { + throw new \InvalidArgumentException("Challenge has unexpected ending $reducedChallenge"); + } + } + + /** + * @return string the xor checksum string in hex base + */ + private function calcXorChecksum(): string + { + $xor = 0b0000; // bin Representation of 0 + $hex = str_split($this->getHexPayload()); + foreach ($hex as $hexChar) { + $intVal = (int) base_convert($hexChar, 16, 10); + $xor ^= $intVal; // xor operator + } + return base_convert($xor, 10, 16); + } + + /** + * @return string returns hex representation of the flicker code + */ + private function getHexPayload(): string + { + $hex = $this->startCode->toHex(); + for ($i = 0; $i < 3; ++$i) { + $hex .= $this->dataElements[$i]->toHex(); + } + $lc = strlen($hex) / 2 + 1; + $lc = str_pad(base_convert($lc, 10, 16), 2, '0', STR_PAD_LEFT); + return $lc . $hex; + } + + /** + * calculates Luhn Checksum over the whole code + */ + private function calcLuhnChecksum(): int + { + $luhn = $this->startCode->getLuhnChecksum(); + for ($i = 0; $i < 3; ++$i) { + $luhn += $this->dataElements[$i]->getLuhnChecksum(); + } + return (10 - ($luhn % 10)) % 10; + } + + /** + * @return string hex representation of challenge + */ + public function getHex(): string + { + $payload = $this->getHexPayload(); + $luhn = $this->calcLuhnChecksum(); + $xor = $this->calcXorChecksum(); + + return $payload . $luhn . $xor; + } + + /** + * takes Hex Representation and builds bit patterns from it. Notable differences to the hex code: + * - prefixes 0FFF to the hex code (F0FF after swap) + * - swaps half bytes e.g. 0F FF ... -> F0 FF ... + * Hints for rendering: + * - 1 equals white, 0 equals black rectangle (other colors are possible, as long contrast is high enough, but unadvised) + * - The Tan Generator expects the following onscreen pattern: | clock | 2^0 | 2^1 | 2^2 | 2^3 | + * - Tan Generators read all 4 values on white to black flank, it is suggested to change the pattern on the black to white flank + * - each entry in the returned array will be hold for the whole clock cycle (both colors) + * @return string[] integer indexed array with strings, each 4 chars long with 0 or 1, which represent the expected flicker patterns + */ + public function getFlickerPattern(): array + { + $hexCode = $this->getHex(); + $bitPattern = []; + // starting pattern - beginning of the pattern 0xFOFF + $bitPattern[] = '1111'; + $bitPattern[] = '0000'; + $bitPattern[] = '1111'; + $bitPattern[] = '1111'; + // convert hex code to flicker pattern + $len = strlen($hexCode); + for ($i = 0; $i < $len; $i += 2) { + // convert hex to bin representation of 1 byte at a time + $byte = base_convert(substr($hexCode, $i, 2), 16, 2); + // add missing zeros to the left + $byte = str_pad($byte, 8, '0', STR_PAD_LEFT); + // reverse order of half-bytes; flicker pattern is | clock | 2^0 | 2^1 | 2^2 | 2^3 | + $firstHalfByte = strrev(substr($byte, 0, 4)); + $secondHalfByte = strrev(substr($byte, 4, 4)); + // change order from first and second half byte (@see C.2) + $bitPattern[] = $secondHalfByte; + $bitPattern[] = $firstHalfByte; + } + return $bitPattern; + } + + public function __debugInfo(): ?array + { + return [ + 'startcode' => $this->startCode, + 'dataElements' => $this->dataElements, + 'payload' => $this->getHexPayload(), + 'luhn' => $this->calcLuhnChecksum(), + 'xor' => $this->calcXorChecksum(), + ]; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Model/NoPsd2TanMode.php b/vendor/nemiah/php-fints/lib/Fhp/Model/NoPsd2TanMode.php new file mode 100755 index 0000000..223722f --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Model/NoPsd2TanMode.php @@ -0,0 +1,130 @@ +iban; + } + + /** + * @return $this + */ + public function setIban(?string $iban) + { + $this->iban = $iban; + + return $this; + } + + public function getBic(): ?string + { + return $this->bic; + } + + /** + * @return $this + */ + public function setBic(?string $bic) + { + $this->bic = $bic; + + return $this; + } + + public function getAccountNumber(): ?string + { + return $this->accountNumber; + } + + /** + * @return $this + */ + public function setAccountNumber(?string $accountNumber) + { + $this->accountNumber = $accountNumber; + + return $this; + } + + public function getSubAccount(): ?string + { + return $this->subAccount; + } + + /** + * @return $this + */ + public function setSubAccount(?string $subAccount) + { + $this->subAccount = $subAccount; + + return $this; + } + + public function getBlz(): ?string + { + return $this->blz; + } + + /** + * @return $this + */ + public function setBlz(?string $blz) + { + $this->blz = $blz; + + return $this; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Model/StatementOfAccount/Statement.php b/vendor/nemiah/php-fints/lib/Fhp/Model/StatementOfAccount/Statement.php new file mode 100755 index 0000000..32237a5 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Model/StatementOfAccount/Statement.php @@ -0,0 +1,130 @@ +transactions; + } + + public function addTransaction(Transaction $transaction) + { + $this->transactions[] = $transaction; + } + + /** + * Get startBalance + */ + public function getStartBalance(): float + { + return $this->startBalance; + } + + /** + * Set startBalance + * + * @return $this + */ + public function setStartBalance(float $startBalance) + { + $this->startBalance = (float) $startBalance; + + return $this; + } + + /** + * Get endBalance + * @return ?float returns the value, if given by the bank or null if unknown + */ + public function getEndBalance(): ?float + { + return $this->endBalance; + } + + /** + * Set endBalance + * + * @return $this + */ + public function setEndBalance(float $endBalance) + { + $this->endBalance = (float) $endBalance; + + return $this; + } + + /** + * Get creditDebit + */ + public function getCreditDebit(): ?string + { + return $this->creditDebit; + } + + /** + * Set creditDebit + * + * @return $this + */ + public function setCreditDebit(?string $creditDebit) + { + $this->creditDebit = $creditDebit; + + return $this; + } + + /** + * Get date + */ + public function getDate(): \DateTime + { + return $this->date; + } + + /** + * Set date + * + * @return $this + */ + public function setDate(\DateTime $date) + { + $this->date = $date; + + return $this; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Model/StatementOfAccount/StatementOfAccount.php b/vendor/nemiah/php-fints/lib/Fhp/Model/StatementOfAccount/StatementOfAccount.php new file mode 100755 index 0000000..2cf2e60 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Model/StatementOfAccount/StatementOfAccount.php @@ -0,0 +1,126 @@ +statements; + } + + /** + * Gets statement for given date. + * + * @param string|\DateTime $date + */ + public function getStatementForDate($date): ?Statement + { + if (is_string($date)) { + $date = static::parseDate($date); + } + + foreach ($this->statements as $stmt) { + if ($stmt->getDate() == $date) { + return $stmt; + } + } + + return null; + } + + /** + * Checks if a statement with given date exists. + * + * @param string|\DateTime $date + */ + public function hasStatementForDate($date): bool + { + return null !== $this->getStatementForDate($date); + } + + private static function parseDate(string $date): \DateTime + { + try { + return new \DateTime($date); + } catch (\Exception $e) { + throw new \InvalidArgumentException("Invalid date: $date", 0, $e); + } + } + + /** + * @param array $array A parsed MT940 dataset, as returned from {@link MT940::parse()}. + * @return StatementOfAccount A new instance that contains the given data. + */ + public static function fromMT940Array(array $array): StatementOfAccount + { + $result = new StatementOfAccount(); + foreach ($array as $date => $statement) { + if ($result->hasStatementForDate($date)) { + $statementModel = $result->getStatementForDate($date); + } else { + $statementModel = new Statement(); + $statementModel->setDate(static::parseDate($date)); + if (isset($statement['start_balance']['amount'])) { + $statementModel->setStartBalance((float) $statement['start_balance']['amount']); + } + if (isset($statement['end_balance'])) { + $statementModel->setEndBalance((float) $statement['end_balance']['amount'] * ($statement["end_balance"]['credit_debit'] == MT940::CD_CREDIT ? 1 : -1)); + } + if (isset($statement['start_balance']['credit_debit'])) { + $statementModel->setCreditDebit($statement['start_balance']['credit_debit']); + } + $result->statements[] = $statementModel; + } + + if (isset($statement['transactions'])) { + foreach ($statement['transactions'] as $trx) { + $replaceIn = [ + 'booking_text', + 'description_1', + 'description_2', + 'description', + 'name', + ]; + foreach ($replaceIn as $k) { + if (isset($trx['description'][$k])) { + $trx['description'][$k] = str_replace('@@', '', $trx['description'][$k]); + } + } + + $transaction = new Transaction(); + $transaction->setBookingDate(static::parseDate($trx['booking_date'])); + $transaction->setValutaDate(static::parseDate($trx['valuta_date'])); + $transaction->setCreditDebit($trx['credit_debit']); + $transaction->setIsStorno($trx['is_storno']); + $transaction->setAmount($trx['amount']); + $transaction->setBookingCode($trx['description']['booking_code']); + $transaction->setBookingText($trx['description']['booking_text']); + $transaction->setDescription1($trx['description']['description_1']); + $transaction->setDescription2($trx['description']['description_2']); + $transaction->setStructuredDescription($trx['description']['description']); + $transaction->setBankCode($trx['description']['bank_code']); + $transaction->setAccountNumber($trx['description']['account_number']); + $transaction->setName($trx['description']['name']); + $transaction->setBooked($trx['booked']); + $transaction->setPN($trx['description']['primanoten_nr']); + $transaction->setTextKeyAddition($trx['description']['text_key_addition']); + $statementModel->addTransaction($transaction); + } + } + } + return $result; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Model/StatementOfAccount/Transaction.php b/vendor/nemiah/php-fints/lib/Fhp/Model/StatementOfAccount/Transaction.php new file mode 100755 index 0000000..1483a30 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Model/StatementOfAccount/Transaction.php @@ -0,0 +1,450 @@ +getBookingDate(); + } + + /** + * Get booking date + */ + public function getBookingDate(): ?\DateTime + { + return $this->bookingDate; + } + + /** + * Get date + */ + public function getValutaDate(): ?\DateTime + { + return $this->valutaDate; + } + + /** + * Set booking date + * + * @return $this + */ + public function setBookingDate(?\DateTime $date = null) + { + $this->bookingDate = $date; + + return $this; + } + + /** + * Set valuta date + * + * @return $this + */ + public function setValutaDate(?\DateTime $date = null) + { + $this->valutaDate = $date; + + return $this; + } + + /** + * Get amount + */ + public function getAmount(): float + { + return $this->amount; + } + + /** + * Set booked status + * + * @return $this + */ + public function setBooked(bool $booked) + { + $this->booked = $booked; + + return $this; + } + + /** + * Set amount + * + * @return $this + */ + public function setAmount(float $amount) + { + $this->amount = (float) $amount; + + return $this; + } + + /** + * Get creditDebit + */ + public function getCreditDebit(): string + { + return $this->creditDebit; + } + + /** + * Set creditDebit + * + * @return $this + */ + public function setCreditDebit(string $creditDebit) + { + $this->creditDebit = $creditDebit; + + return $this; + } + + /** + * Get isStorno + */ + public function isStorno(): bool + { + return $this->isStorno; + } + + /** + * Set isStorno + * + * @return $this + */ + public function setIsStorno(bool $isStorno) + { + $this->isStorno = $isStorno; + + return $this; + } + + /** + * Get bookingCode + */ + public function getBookingCode(): string + { + return $this->bookingCode; + } + + /** + * Set bookingCode + * + * @return $this + */ + public function setBookingCode(string $bookingCode) + { + $this->bookingCode = (string) $bookingCode; + + return $this; + } + + /** + * Get bookingText + */ + public function getBookingText(): string + { + return $this->bookingText; + } + + /** + * Set bookingText + * + * @return $this + */ + public function setBookingText(string $bookingText) + { + $this->bookingText = (string) $bookingText; + + return $this; + } + + /** + * Get description1 + */ + public function getDescription1(): string + { + return $this->description1; + } + + /** + * Set description1 + * + * @return $this + */ + public function setDescription1(string $description1) + { + $this->description1 = (string) $description1; + + return $this; + } + + /** + * Get description2 + */ + public function getDescription2(): string + { + return $this->description2; + } + + /** + * Set description2 + * + * @return $this + */ + public function setDescription2(string $description2) + { + $this->description2 = (string) $description2; + + return $this; + } + + /** + * Get structuredDescription + * + * @return string[] + */ + public function getStructuredDescription(): array + { + return $this->structuredDescription; + } + + /** + * Set structuredDescription + * + * @param string[] $structuredDescription + * + * @return $this + */ + public function setStructuredDescription(array $structuredDescription) + { + $this->structuredDescription = $structuredDescription; + + return $this; + } + + /** + * Get the main description (SVWZ) + */ + public function getMainDescription(): string + { + if (array_key_exists('SVWZ', $this->structuredDescription)) { + return $this->structuredDescription['SVWZ']; + } else { + return ''; + } + } + + /** + * Get the end to end id (EREF) + */ + public function getEndToEndID(): string + { + if (array_key_exists('EREF', $this->structuredDescription)) { + return $this->structuredDescription['EREF']; + } else { + return ''; + } + } + + /** + * Get bankCode + */ + public function getBankCode(): string + { + return $this->bankCode; + } + + /** + * Set bankCode + * + * @return $this + */ + public function setBankCode(string $bankCode) + { + $this->bankCode = (string) $bankCode; + + return $this; + } + + /** + * Get accountNumber + */ + public function getAccountNumber(): string + { + return $this->accountNumber; + } + + /** + * Set accountNumber + * + * @return $this + */ + public function setAccountNumber(string $accountNumber) + { + $this->accountNumber = (string) $accountNumber; + + return $this; + } + + /** + * Get name + */ + public function getName(): string + { + return $this->name; + } + + /** + * Get booked status + */ + public function getBooked(): bool + { + return $this->booked; + } + + /** + * Set name + * + * @return $this + */ + public function setName(string $name) + { + $this->name = (string) $name; + + return $this; + } + + /** + * Get primanota number + */ + public function getPN(): int + { + return $this->pn; + } + + /** + * Set primanota number + * + * @param int|mixed $nr Will be parsed to an int. + * @return $this + */ + public function setPN($nr) + { + $this->pn = intval($nr); + return $this; + } + + /** + * Get text key addition + */ + public function getTextKeyAddition(): int + { + return $this->textKeyAddition; + } + + /** + * Set text key addition + * + * @param int|mixed $textKeyAddition Will be parsed to an int. + * @return $this + */ + public function setTextKeyAddition($textKeyAddition) + { + $this->textKeyAddition = intval($textKeyAddition); + return $this; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Model/StatementOfHoldings/Holding.php b/vendor/nemiah/php-fints/lib/Fhp/Model/StatementOfHoldings/Holding.php new file mode 100755 index 0000000..ceb4aae --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Model/StatementOfHoldings/Holding.php @@ -0,0 +1,256 @@ +isin = $isin; + + return $this; + } + + /** + * Set WKN + * + * @return $this + */ + public function setWKN(?string $wkn) + { + $this->wkn = $wkn; + + return $this; + } + + /** + * Set Name + * + * @return $this + */ + public function setName(?string $name) + { + $this->name = $name; + + return $this; + } + + /** + * Set value + * + * @return $this + */ + public function setValue(?float $value) + { + $this->value = $value; + + return $this; + } + + /** + * Set price + * + * @return $this + */ + public function setPrice(?float $price) + { + $this->price = $price; + + return $this; + } + + /** + * Set acquisition price + * + * @return $this + */ + public function setAcquisitionPrice(?float $price) + { + $this->acquisitionPrice = $price; + + return $this; + } + + /** + * Set amount + * + * @return $this + */ + public function setAmount(?float $amount) + { + $this->amount = $amount; + + return $this; + } + + /** + * Set currency + * + * @return $this + */ + public function setCurrency(?string $currency) + { + $this->currency = $currency; + + return $this; + } + + /** + * Set date + * + * @return $this + */ + public function setDate(\DateTime $date) + { + $this->date = $date; + + return $this; + } + + /** + * Set time + * + * @return $this + */ + public function setTime(\DateTime $time) + { + $this->time = $time; + + return $this; + } + + /** + * Get ISIN + */ + public function getISIN(): ?string + { + return $this->isin; + } + + /** + * Get WKN + */ + public function getWKN(): ?string + { + return $this->wkn; + } + + /** + * Get Name + */ + public function getName(): ?string + { + return $this->name; + } + + /** + * Get value + */ + public function getValue(): ?float + { + return $this->value; + } + + /** + * Get acquisition price + */ + public function getAcquisitionPrice(): ?float + { + return $this->acquisitionPrice; + } + + /** + * Get price + */ + public function getPrice(): ?float + { + return $this->price; + } + + /** + * Get amount + */ + public function getAmount(): ?float + { + return $this->amount; + } + + /** + * Get currency + */ + public function getCurrency(): ?string + { + return $this->currency; + } + + /** + * Get time + */ + public function getTime(): ?\DateTime + { + return $this->time; + } + + /** + * Get date + */ + public function getDate(): ?\DateTime + { + return $this->date; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Model/StatementOfHoldings/StatementOfHoldings.php b/vendor/nemiah/php-fints/lib/Fhp/Model/StatementOfHoldings/StatementOfHoldings.php new file mode 100755 index 0000000..24c9c5e --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Model/StatementOfHoldings/StatementOfHoldings.php @@ -0,0 +1,26 @@ +holdings; + } + + public function addHolding(Holding $holding) + { + $this->holdings[] = $holding; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Model/TanMedium.php b/vendor/nemiah/php-fints/lib/Fhp/Model/TanMedium.php new file mode 100755 index 0000000..b01bf07 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Model/TanMedium.php @@ -0,0 +1,27 @@ +getData(); + + // Documentation: https://www.hbci-zka.de/dokumente/spezifikation_deutsch/hhd/Belegungsrichtlinien%20TANve1.5%20FV%20vom%202018-04-16.pdf + // II.3 + + // Matrix-Format: + // 2 bytes = length of mime type + // mime type as string + // 2 bytes = length of data + + $dataLength = strlen($data); + if ($dataLength < 2) { + throw new \InvalidArgumentException( + "Invalid TAN challenge. Expected image MIME type but only found $dataLength bytes. "); + } + $mimeTypeLengthString = substr($data, 0, 2); + $mimeTypeLength = ord($mimeTypeLengthString[0]) * 256 + ord($mimeTypeLengthString[1]); + + if ($dataLength < 2 + $mimeTypeLength + 2) { + throw new \InvalidArgumentException( + "Invalid TAN challenge. Expected image MIME type of length $mimeTypeLength but only found $dataLength bytes. " . + 'Maybe the challenge is not an image but rather a URL or a flicker code.'); + } + $this->mimeType = substr($data, 2, $mimeTypeLength); + + $data = substr($data, 2 + $mimeTypeLength); + + $dataLengthString = substr($data, 0, 2); + $expectedDataLength = ord($dataLengthString[0]) * 256 + ord($dataLengthString[1]); + $actualDataLength = strlen($data) - 2; + + if ($expectedDataLength != $actualDataLength) { + // This exception is thrown, if there is an encoding problem + // f.e.: the serialized action was saved as a string, but not base64 encoded + throw new \InvalidArgumentException( + "Unexpected data length, expected $expectedDataLength but found $actualDataLength bytes."); + } + + $this->data = substr($data, 2, $expectedDataLength); + } + + public function getMimeType(): string + { + return $this->mimeType; + } + + public function getData(): string + { + return $this->data; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Options/Credentials.php b/vendor/nemiah/php-fints/lib/Fhp/Options/Credentials.php new file mode 100755 index 0000000..8d37c8d --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Options/Credentials.php @@ -0,0 +1,58 @@ +benutzerkennung = $benutzerkennung; + $result->pin = $pin; + return $result; + } + + public function getBenutzerkennung(): string + { + return $this->benutzerkennung; + } + + public function getPin(): string + { + return $this->pin; + } + + public function __debugInfo() + { + return null; // Prevent sensitive data from leaking into logs through var_dump() or print_r(). + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Options/FinTsOptions.php b/vendor/nemiah/php-fints/lib/Fhp/Options/FinTsOptions.php new file mode 100755 index 0000000..378a1c3 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Options/FinTsOptions.php @@ -0,0 +1,70 @@ +productName = trim($this->productName); + $this->productVersion = trim($this->productVersion); + $this->bankCode = trim($this->bankCode); + $this->url = trim($this->url); + if (strlen($this->productName) === 0) { + throw new \InvalidArgumentException('Product name required!'); + } + if (strlen($this->productVersion) === 0) { + throw new \InvalidArgumentException('Product version required!'); + } + if (strlen($this->bankCode) === 0) { + throw new \InvalidArgumentException('Bank code required!'); + } + if (strlen($this->url) === 0) { + throw new \InvalidArgumentException('Server URL required!'); + } + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Options/SanitizingLogger.php b/vendor/nemiah/php-fints/lib/Fhp/Options/SanitizingLogger.php new file mode 100755 index 0000000..534d6f9 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Options/SanitizingLogger.php @@ -0,0 +1,103 @@ +logger = $logger; + $this->needles = static::computeNeedles($sensitiveMaterial); + } + + /** + * @param array $sensitiveMaterial See the constructor. + */ + public function addSensitiveMaterial(array $sensitiveMaterial) + { + $this->needles = array_merge($this->needles, static::computeNeedles($sensitiveMaterial)); + } + + public function log($level, $message, array $context = []): void + { + $message .= count($context) === 0 ? '' : ' ' . implode(', ', $context); + $this->logger->log($level, static::sanitizeForLogging($message, $this->needles)); + } + + /** + * @param array $sensitiveMaterial An array of various objects typically used with the phpFinTS library that contain + * some sensitive information. This array may also contain plain strings, which are themselves interpreted as + * sensitive. + * @return string[] An array of search-replacement "needles" that should be replaced in log messages. + */ + public static function computeNeedles(array $sensitiveMaterial): array + { + $needles = []; + foreach ($sensitiveMaterial as $item) { + if (is_string($item)) { + $needles[] = $item; + } elseif ($item instanceof Credentials) { + $needles[] = $item->getBenutzerkennung(); + $needles[] = $item->getPin(); + } elseif ($item instanceof FinTsOptions) { + $needles[] = $item->productName; + } elseif ($item instanceof Account) { + $needles[] = $item->getIban(); + $needles[] = $item->getAccountNumber(); + $needles[] = $item->getAccountOwnerName(); + $needles[] = $item->getCustomerId(); + } elseif ($item instanceof SEPAAccount) { + $needles[] = $item->getIban(); + $needles[] = $item->getAccountNumber(); + } elseif ($item !== null) { + throw new \InvalidArgumentException('Unsupported type of sensitive material ' . gettype($item)); + } + } + $needles = array_filter($needles); // Filter out empty entries. + $escapedNeedles = array_map(function (string $needle) { + // The wire format is ISO-8859-1, so that's what will be logged and that's what to look for when replacing. + return mb_convert_encoding(Serializer::escape($needle), 'ISO-8859-1', 'UTF-8'); + }, $needles); + return array_merge($needles, $escapedNeedles); + } + + /** + * Removes sensitive values from the given string, while preserving its overall length, so that wrappers like FinTS + * messages or Bin containers, which declare the length of their contents, remain parsable. + * @param string $str Some string. + * @param string[] $needles The sensitive values to be replaced, usually from {@link computeNeedles()}. + * @return string The same string, but with sensitive values removed. + */ + public static function sanitizeForLogging(string $str, array $needles): string + { + $replacements = array_map(function ($needle) { + $len = strlen($needle); + $prefix = 'PRIVATE'; + return substr($prefix, 0, $len) . str_repeat('_', max(0, $len - strlen($prefix))); + }, $needles); + return str_replace($needles, $replacements, $str); + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/PaginateableAction.php b/vendor/nemiah/php-fints/lib/Fhp/PaginateableAction.php new file mode 100755 index 0000000..b01d480 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/PaginateableAction.php @@ -0,0 +1,115 @@ +__serialize()); + } + + public function __serialize(): array + { + return [ + parent::__serialize(), + $this->paginationToken, + $this->requestSegments, + ]; + } + + /** + * @deprecated Beginning from PHP7.4 __unserialize is used for new generated strings, then this method is only used for previously generated strings - remove after May 2023 + * + * {@inheritdoc} + */ + public function unserialize($serialized) + { + self::__unserialize(unserialize($serialized)); + } + + public function __unserialize(array $serialized): void + { + list( + $parentSerialized, + $this->paginationToken, + $this->requestSegments) = $serialized; + + is_array($parentSerialized) ? + parent::__unserialize($parentSerialized) : + parent::unserialize($parentSerialized); + } + + /** + * @return bool True if the response has not been read completely yet, i.e. additional requests to the server are + * necessary to continue reading the requested data. + */ + public function hasMorePages(): bool + { + return !$this->isDone() && $this->paginationToken !== null; + } + + /** {@inheritdoc} */ + public function processResponse(Message $response) + { + if (($pagination = $response->findRueckmeldung(Rueckmeldungscode::PAGINATION)) !== null) { + if (count($pagination->rueckmeldungsparameter) !== 1) { + throw new UnexpectedResponseException("Unexpected pagination request: $pagination"); + } + // There is at least one more page + $this->paginationToken = $pagination->rueckmeldungsparameter[0]; + } else { + // No pagination or last page + parent::processResponse($response); + } + } + + /** {@inheritdoc} */ + public function getNextRequest(?BPD $bpd, ?UPD $upd) + { + if ($this->requestSegments === null) { + $this->requestSegments = parent::getNextRequest($bpd, $upd); + } elseif ($this->paginationToken !== null) { + foreach ($this->requestSegments as $segment) { + if ($segment instanceof Paginateable) { + $segment->setPaginationToken($this->paginationToken); + } + } + $this->paginationToken = null; + } + return $this->requestSegments; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Protocol/ActionIncompleteException.php b/vendor/nemiah/php-fints/lib/Fhp/Protocol/ActionIncompleteException.php new file mode 100755 index 0000000..ce669d1 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Protocol/ActionIncompleteException.php @@ -0,0 +1,15 @@ +hibpa->bpdVersion; + } + + public function getBankCode() + { + return $this->hibpa->kreditinstitutskennung->kreditinstitutscode; + } + + public function getBankName() + { + return $this->hibpa->kreditinstitutsbezeichnung; + } + + /** + * @param string $type A business transaction type, represented by the segment name of the respective parameter + * segment (Geschäftsvorfallparameter segment, aka. Segmentparametersegment). Example: 'HIKAZS'. + * @return BaseSegment[] All parameter segments of that type ordered descendingly by version (newest first), + * excluding such that are not explicitly implemented in this library (no AnonymousSegments). The returned array + * is possibly empty if no versions offered by the bank are also supported by the library. + */ + public function getAllSupportedParameters(string $type): array + { + return array_filter($this->parameters[$type] ?? [], function (BaseSegment $segment) { + return !($segment instanceof AnonymousSegment); + }); + } + + /** + * @param string $type A business transaction type, represented by the segment name of the respective parameter + * segment (Geschäftsvorfallparameter segment, aka. Segmentparametersegment). Example: 'HIKAZS'. + * @return BaseSegment|null The latest parameter segment that is explicitly implemented in this library (never an + * AnonymousSegment), or null if none exists. + */ + public function getLatestSupportedParameters(string $type): ?BaseSegment + { + if (!array_key_exists($type, $this->parameters)) { + return null; + } + foreach ($this->parameters[$type] as $segment) { + if (!($segment instanceof AnonymousSegment)) { + return $segment; + } + } + return null; + } + + /** + * @param string $type A business transaction type, see above. + * @return BaseSegment The latest parameter segment, never null. + * @throws UnexpectedResponseException If no version exists. + */ + public function requireLatestSupportedParameters(string $type): BaseSegment + { + $result = $this->getLatestSupportedParameters($type); + if ($result === null) { + throw new UnexpectedResponseException( + "The server does not support any $type versions implemented in this library"); + } + return $result; + } + + /** + * @param string $type A business transaction type, see above. + * @param int $version The segment version of the business transaction. + * @return bool If that version of the given transaction type is supported by the bank. + */ + public function supportsParameters(string $type, int $version): bool + { + foreach ($this->parameters[$type] as $segment) { + if ($segment->getVersion() === $version) { + return true; + } + } + return false; + } + + /** + * @param SegmentInterface[] $requestSegments The segments that shall be sent to the bank. + * @return string|null Identifier of the (first) segment that requires a TAN according to HIPINS, or null if none of + * the segments require a TAN. + */ + public function tanRequiredForRequest(array $requestSegments): ?string + { + foreach ($requestSegments as $segment) { + if ($this->tanRequired[$segment->getName()] ?? false) { + return $segment->getName(); + } + } + return null; + } + + /** + * @return bool Whether the BPD indicates that the bank supports PSD2. + */ + public function supportsPsd2(): bool + { + return $this->supportsParameters('HITANS', 6); + } + + /** + * @param Message $response The dialog initialization response from the server. + * @return BPD A new BPD instance with the extracted configuration data. + */ + public static function extractFromResponse(Message $response): BPD + { + $bpd = new BPD(); + $bpd->hibpa = $response->requireSegment(HIBPAv3::class); + + // Extract the HIxyzS segments, which contain parameters that describe how (future) requests for the particular + // type of business transaction have to look. + foreach ($response->plainSegments as $segment) { + $segmentName = $segment->getName(); + if (strlen($segmentName) === 6 && $segmentName[5] === 'S') { + $bpd->parameters[$segmentName][$segment->getVersion()] = $segment; + krsort($bpd->parameters[$segmentName]); // Newest first. + } + } + ksort($bpd->parameters); // Sort alphabetically, for easier debugging. + + // Extract from HIPINS which HKxyz requests will need a TAN. + /** @var HIPINSv1 $hipins */ + $hipins = $response->requireSegment(HIPINSv1::class); + foreach ($hipins->parameter->geschaeftsvorfallspezifischePinTanInformationen as $typeInfo) { + $bpd->tanRequired[$typeInfo->segmentkennung] = $typeInfo->tanErforderlich; + } + + // Extract all TanModes from HIPINS. + if ($bpd->supportsPsd2()) { + /** @var HITANS[] $allHitans */ + $allHitans = $bpd->getAllSupportedParameters('HITANS'); + if (count($allHitans) === 0) { + throw new UnexpectedResponseException( + 'The server does not support any HITANS versions implemented in this library'); + } + foreach ($allHitans as $hitans) { + $tanParams = $hitans->getParameterZweiSchrittTanEinreichung(); + $bpd->singleStepTanModeAllowed = $tanParams->isEinschrittVerfahrenErlaubt(); + foreach ($tanParams->getVerfahrensparameterZweiSchrittVerfahren() as $verfahren) { + if (!array_key_exists($verfahren->getId(), $bpd->allTanModes)) { + $bpd->allTanModes[$verfahren->getId()] = $verfahren; + } + } + } + } + + return $bpd; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Protocol/DialogInitialization.php b/vendor/nemiah/php-fints/lib/Fhp/Protocol/DialogInitialization.php new file mode 100755 index 0000000..3e07aec --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Protocol/DialogInitialization.php @@ -0,0 +1,240 @@ +options = $options; + $this->credentials = $credentials; + $this->tanMode = $tanMode instanceof NoPsd2TanMode ? null : $tanMode; + $this->tanMedium = $tanMedium; + $this->kundensystemId = $kundensystemId; + $this->hktanRef = $hktanRef; + } + + /** + * @deprecated Beginning from PHP7.4 __unserialize is used for new generated strings, then this method is only used for previously generated strings - remove after May 2023 + */ + public function serialize(): string + { + return serialize($this->__serialize()); + } + + public function __serialize(): array + { + return [ + parent::__serialize(), + $this->hktanRef, + $this->kundensystemId, + $this->messageNumber, + $this->dialogId, + ]; + } + + /** + * @deprecated Beginning from PHP7.4 __unserialize is used for new generated strings, then this method is only used for previously generated strings - remove after May 2023 + * + * @param string $serialized + * @return void + */ + public function unserialize($serialized) + { + self::__unserialize(unserialize($serialized)); + } + + public function __unserialize(array $serialized): void + { + list( + $parentSerialized, + $this->hktanRef, + $this->kundensystemId, + $this->messageNumber, + $this->dialogId + ) = $serialized; + + is_array($parentSerialized) ? + parent::__unserialize($parentSerialized) : + parent::unserialize($parentSerialized); + } + + /** {@inheritdoc} */ + protected function createRequest(BPD $bpd, ?UPD $upd) + { + throw new \AssertionError('DialogInitialization::createRequest should not be used.'); + } + + /** + * @param BPD|null $bpd The BPD. Note that we support null here because a dialog initialization is how the BPD can + * be obtained in the first place. + * @param UPD|null $upd The UPD. + * @return array|\Fhp\Segment\BaseSegment|\Fhp\Segment\BaseSegment[] + */ + public function getNextRequest(?BPD $bpd, ?UPD $upd) + { + $request = [ + HKIDNv2::create($this->options->bankCode, $this->credentials, $this->kundensystemId ?? '0'), + HKVVBv3::create($this->options, $bpd, $upd), + ]; + if ($this->tanMode !== null) { + $request[] = HKTANFactory::createProzessvariante2Step1( + $this->tanMode, $this->tanMedium, $this->hktanRef ?? 'HKIDN'); + } + + if ($this->kundensystemId === null) { + // NOTE: HKSYN must be *after* HKTAN. + $request[] = HKSYNv3::createEmpty(); // See section C.8.1.1 + } + return $request; + } + + public function processResponse(Message $response) + { + parent::processResponse($response); + $this->dialogId = $response->header->dialogId; + + if ($this->kundensystemId === null) { + /** @var HISYNv4 $hisyn */ + $hisyn = $response->requireSegment(HISYNv4::class); + if ($hisyn->kundensystemId === null) { + throw new UnexpectedResponseException('No Kundensystem-ID received'); + } + $this->kundensystemId = $hisyn->kundensystemId; + } + + if (UPD::containedInResponse($response)) { + $this->upd = UPD::extractFromResponse($response); + } + } + + public function isStronglyAuthenticated(): bool + { + return $this->hktanRef === 'HKIDN'; + } + + public function getKundensystemId(): ?string + { + return $this->kundensystemId; + } + + public function getMessageNumber(): ?int + { + return $this->messageNumber; + } + + public function setMessageNumber(?int $messageNumber): void + { + $this->messageNumber = $messageNumber; + } + + public function getDialogId(): ?string + { + return $this->dialogId; + } + + /** + * To be called when a TAN is required for login, but we need to intermittently store the Dialog-ID. + */ + public function setDialogId(?string $dialogId): void + { + $this->dialogId = $dialogId; + } + + public function getUpd(): ?UPD + { + return $this->upd; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Protocol/GetTanMedia.php b/vendor/nemiah/php-fints/lib/Fhp/Protocol/GetTanMedia.php new file mode 100755 index 0000000..da0f32f --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Protocol/GetTanMedia.php @@ -0,0 +1,52 @@ +requireLatestSupportedParameters('HITABS'); + switch ($hitabs->getVersion()) { + case 4: + return HKTABv4::createEmpty(); + case 5: + return HKTABv5::createEmpty(); + default: + throw new UnsupportedException('Unsupported HKTAB version: ' . $hitabs->getVersion()); + } + } + + /** {@inheritdoc} */ + public function processResponse(Message $response) + { + parent::processResponse($response); + /** @var HITAB $hitab */ + $hitab = $response->requireSegment(HITAB::class); + $this->tanMedia = $hitab->getTanMediumListe() === null ? [] : $hitab->getTanMediumListe(); + } + + /** + * @return TanMediumListe[]|null + */ + public function getTanMedia(): ?array + { + $this->ensureDone(); + return $this->tanMedia; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Protocol/Message.php b/vendor/nemiah/php-fints/lib/Fhp/Protocol/Message.php new file mode 100755 index 0000000..6d18b75 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Protocol/Message.php @@ -0,0 +1,365 @@ +plainSegments; + yield from $this->wrapperSegments; + } + + /** + * @throws \InvalidArgumentException If any segment in this message is invalid. + */ + public function validate() + { + foreach ($this->getAllSegments() as $segment) { + try { + $segment->validate(); + } catch (\InvalidArgumentException $e) { + throw new \InvalidArgumentException("Invalid segment {$segment->segmentkopf->segmentkennung}", 0, $e); + } + } + } + + // TODO Add unit test coverage for the functions below. + + /** + * @param string $segmentType The PHP type (class name or interface) of the segment(s). + * @return BaseSegment[] All segments of this type (possibly an empty array). + */ + public function findSegments(string $segmentType): array + { + return array_values(array_filter($this->plainSegments, function ($segment) use ($segmentType) { + /* @var BaseSegment $segment */ + return $segment instanceof $segmentType; + })); + } + + /** + * @param string $segmentType The PHP type (class name or interface) of the segment. + * @return BaseSegment|null The segment, or null if it was found. + */ + public function findSegment(string $segmentType): ?BaseSegment + { + $matchedSegments = $this->findSegments($segmentType); + if (count($matchedSegments) > 1) { + throw new UnexpectedResponseException("Multiple segments matched $segmentType"); + } + return count($matchedSegments) === 0 ? null : $matchedSegments[0]; + } + + /** + * @param string $segmentType The PHP type (class name or interface) of the segment. + * @return bool Whether any such segment exists. + */ + public function hasSegment(string $segmentType): bool + { + return $this->findSegment($segmentType) !== null; + } + + /** + * @param string $segmentType The PHP type (class name or interface) of the segment. + * @return BaseSegment The segment, never null. + * @throws UnexpectedResponseException If the segment was not found. + */ + public function requireSegment(string $segmentType): BaseSegment + { + $matchedSegment = $this->findSegment($segmentType); + if ($matchedSegment === null) { + throw new UnexpectedResponseException("Segment not found: $segmentType"); + } + return $matchedSegment; + } + + /** + * @param int $segmentNumber The segment number to search for. + * @return BaseSegment|null The segment with that number, or null if there is none. + */ + public function findSegmentByNumber(int $segmentNumber): ?BaseSegment + { + foreach ($this->getAllSegments() as $segment) { + if ($segment->getSegmentNumber() === $segmentNumber) { + return $segment; + } + } + return null; + } + + /** + * @param int[] $referenceNumbers The numbers of the reference segments. + * @return Message A new message that just contains the plain segment from $this message which refer to one + * of the given $referenceSegments. + */ + public function filterByReferenceSegments(array $referenceNumbers): Message + { + $result = new Message(); + if (count($referenceNumbers) === 0) { + return $result; + } + $result->plainSegments = array_filter($this->plainSegments, function ($segment) use ($referenceNumbers) { + /** @var BaseSegment $segment */ + $referenceNumber = $segment->segmentkopf->bezugselement; + return $referenceNumber !== null && in_array($referenceNumber, $referenceNumbers); + }); + $result->header = $this->header; + $result->footer = $this->footer; + $result->signatureHeader = $this->signatureHeader; + $result->signatureFooter = $this->signatureFooter; + return $result; + } + + /** + * @param int $code The response code to search for. + * @return Rueckmeldung|null The corresponding Rueckmeldung instance, or null if not found. + */ + public function findRueckmeldung(int $code): ?Rueckmeldung + { + foreach ($this->plainSegments as $segment) { + if ($segment instanceof RueckmeldungContainer) { + $rueckmeldung = $segment->findRueckmeldung($code); + if ($rueckmeldung !== null) { + return $rueckmeldung; + } + } + } + return null; + } + + /** @return Rueckmeldung[] */ + public function findRueckmeldungen(int $code): array + { + $rueckmeldungen = []; + foreach ($this->plainSegments as $segment) { + if ($segment instanceof RueckmeldungContainer) { + $rueckmeldungen = array_merge($rueckmeldungen, $segment->findRueckmeldungen($code)); + } + } + return $rueckmeldungen; + } + + /** + * @return string The HBCI/FinTS wire format for this message, ISO-8859-1 encoded. + */ + public function serialize(): string + { + $result = ''; + foreach ($this->wrapperSegments as $segment) { + $result .= Serializer::serializeSegment($segment); + } + return $result; + } + + /** + * Wraps the given segments in an "encryption" envelope (see class documentation). Inverse of {@link parse()}. + * @param BaseSegment[]|MessageBuilder $plainSegments The plain segments to be wrapped. Segment numbers do not need + * to be set yet (or they will be overwritten). + * @param FinTsOptions $options See {@link FinTsOptions}. + * @param string $kundensystemId See {@link $kundensystemId}. + * @param Credentials $credentials The credentials used to authenticate the message. + * @param TanMode|null $tanMode Optionally specifies which two-step TAN mode to use, defaults to 999 (single step). + * @param string|null The TAN to be sent to the server (in HNSHA). If this is present, $tanMode must be present. + * @return Message The built message, ready to be sent to the server through {@link FinTs::sendMessage()}. + */ + public static function createWrappedMessage($plainSegments, FinTsOptions $options, string $kundensystemId, Credentials $credentials, ?TanMode $tanMode, $tan): Message + { + $message = new Message(); + $message->plainSegments = $plainSegments instanceof MessageBuilder ? $plainSegments->segments : $plainSegments; + + $tanMode = $tanMode instanceof NoPsd2TanMode ? null : $tanMode; + $randomReference = strval(rand(1000000, 9999999)); // Call unqualified rand() for unit test mocking to work. + $signature = BenutzerdefinierteSignaturV1::create($credentials->getPin(), $tan); + $numPlainSegments = count($message->plainSegments); // This is N, see $encryptedSegments. + + $message->wrapperSegments = [ // See $encryptedSegments documentation for the structure. + $message->header = HNHBKv3::createEmpty()->setSegmentNumber(1), + HNVSKv3::create($options, $credentials, $kundensystemId, $tanMode), // Segment number 998 + HNVSDv1::create(array_merge( // Segment number 999 + [$message->signatureHeader = HNSHKv4::create( + $randomReference, $options, $credentials, $tanMode, $kundensystemId + )->setSegmentNumber(2)], + static::setSegmentNumbers($message->plainSegments, 3), + [$message->signatureFooter = HNSHAv2::create($randomReference, $signature) + ->setSegmentNumber($numPlainSegments + 3), ] + )), + $message->footer = HNHBSv1::createEmpty()->setSegmentNumber($numPlainSegments + 4), + ]; + + return $message; + } + + /** + * Builds a plain message by adding header and footer to the given segments, but no "encryption" envelope. + * Inverse of {@link parse()}. + * @param BaseSegment[]|MessageBuilder $segments + * @return Message The built message, ready to be sent to the server through {@link FinTs::sendMessage()}. + */ + public static function createPlainMessage($segments): Message + { + $message = new Message(); + $message->plainSegments = $segments instanceof MessageBuilder ? $segments->segments : $segments; + $message->wrapperSegments = array_merge( + [$message->header = HNHBKv3::createEmpty()->setSegmentNumber(1)], + static::setSegmentNumbers($message->plainSegments, 2), + [$message->footer = HNHBSv1::createEmpty()->setSegmentNumber(2 + count($message->plainSegments))] + ); + return $message; + } + + /** + * Parses the given wire format and unwraps the "encryption" envelope (see class documentation) if it exists + * (in which case this function acts as the inverse of {@link createWrappedMessage()}), or leaves as is otherwise + * (and acts as inverse of {@link createPlainMessage()}). + * + * @param string $rawMessage The received message in HBCI/FinTS wire format. This should be ISO-8859-1-encoded. + * @return Message The parsed message. + * @throws \InvalidArgumentException When the parsing fails. + */ + public static function parse(string $rawMessage): Message + { + $result = new Message(); + $segments = Parser::parseSegments($rawMessage); + + // Message header and footer must always be there, or something went badly wrong. + if (!($segments[0] instanceof HNHBKv3)) { + throw new \InvalidArgumentException("Expected first segment to be HNHBK: $rawMessage"); + } + if (!($segments[count($segments) - 1] instanceof HNHBSv1)) { + throw new \InvalidArgumentException("Expected last segment to be HNHBS: $rawMessage"); + } + $result->header = $segments[0]; + $result->footer = $segments[count($segments) - 1]; + + // Check if there's an encryption header and "encrypted" data. + // Section B.8 specifies that there are exactly 4 segments: HNHBK, HNVSK, HNVSD, HNHBS. + if (count($segments) === 4 && $segments[1] instanceof HNVSKv3) { + if (!($segments[2] instanceof HNVSDv1)) { + throw new \InvalidArgumentException("Expected third segment to be HNVSD: $rawMessage"); + } + $result->wrapperSegments = $segments; + $result->plainSegments = Parser::parseSegments($segments[2]->datenVerschluesselt->getData()); + + // Signature header and footer must always be there when the "encrypted" structure was used. + // Postbank is not following the Spec and does not send the Header and Footer + + $signatureFooterAsExpected = end($result->plainSegments) instanceof HNSHAv2; + $signatureHeaderAsExpected = reset($result->plainSegments) instanceof HNSHKv4; + + if ($signatureHeaderAsExpected xor $signatureFooterAsExpected) { + throw new \InvalidArgumentException("Expected first segment to be HNSHK and last segement to be HNSHA or both to be absent: $rawMessage"); + } + + if ($signatureHeaderAsExpected) { + $result->signatureHeader = array_shift($result->plainSegments); + } + if ($signatureFooterAsExpected) { + $result->signatureFooter = array_pop($result->plainSegments); + } + } else { + // Ensure that there's no encryption header anywhere, and we haven't just misunderstood the format. + foreach ($segments as $segment) { + if ($segment->getName() === 'HNVSK' || $segment->getName() === 'HNVSD') { + throw new \InvalidArgumentException("Unexpected encrypted format: $rawMessage"); + } + } + $result->plainSegments = $segments; // The message wasn't "encrypted". + } + return $result; + } + + /** + * @param BaseSegment[] $segments The segments to be numbered. Will be modified. + * @param int $segmentNumber The number for the *first* segment, subsequent segment get the subsequent integers. + * @return BaseSegment[] The same array, for chaining. + */ + private static function setSegmentNumbers(array $segments, int $segmentNumber): array + { + foreach ($segments as $segment) { + $segment->segmentkopf->segmentnummer = $segmentNumber; + if ($segment->segmentkopf->segmentnummer >= HNVSKv3::SEGMENT_NUMBER) { + throw new \InvalidArgumentException('Too many segments'); + } + ++$segmentNumber; + } + return $segments; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Protocol/MessageBuilder.php b/vendor/nemiah/php-fints/lib/Fhp/Protocol/MessageBuilder.php new file mode 100755 index 0000000..97d3c46 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Protocol/MessageBuilder.php @@ -0,0 +1,50 @@ +addInternal($segment); + } + } else { + $this->addInternal($segments); + } + return $this; + } + + private function addInternal(BaseSegment $segment) + { + if ($segment->segmentkopf === null) { + throw new \InvalidArgumentException( + 'Segment lacks Segmentkopf, maybe you called ctor instead of createEmpty()'); + } + $this->segments[] = $segment; + } + + // Note: There is no single build() function, use Message::createWrappedMessage() instead. +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Protocol/ServerException.php b/vendor/nemiah/php-fints/lib/Fhp/Protocol/ServerException.php new file mode 100755 index 0000000..2e9f3f1 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Protocol/ServerException.php @@ -0,0 +1,181 @@ +errors = $errors; + $this->warnings = $warnings; + $this->requestSegments = $requestSegments; + $this->request = $request; + $this->response = $response; + $errorsStr = count($errors) === 0 ? '' : "FinTS errors:\n" . implode("\n", $errors); + $warningsStr = count($warnings) === 0 ? '' : "FinTS warnings:\n" . implode("\n", $warnings); + $segmentsStr = count($requestSegments) === 0 ? '' : "Request segments:\n" . implode("\n", $requestSegments); + parent::__construct(implode("\n", array_filter([$errorsStr, $warningsStr, $segmentsStr]))); + } + + /** + * @return Rueckmeldung[] + */ + public function getErrors() + { + return $this->errors; + } + + /** + * @return Rueckmeldung[] + */ + public function getWarnings() + { + return $this->warnings; + } + + /** + * @return string[] + */ + public function getRequestSegments() + { + return $this->requestSegments; + } + + /** + * @param int $code A Rueckmeldungscode to look for (should be an error code, i.e. 9xxx). + * @return bool Whether an error with this code is present. + */ + public function hasError(int $code): bool + { + foreach ($this->errors as $error) { + if ($error->rueckmeldungscode === $code) { + return true; + } + } + return false; + } + + /** + * @param int $code A Rueckmeldungscode to look for (should be a warning code, i.e. 3xxx). + * @return bool Whether a warning with this code is present. + */ + public function hasWarning(int $code): bool + { + foreach ($this->warnings as $warning) { + if ($warning->rueckmeldungscode === $code) { + return true; + } + } + return false; + } + + /** + * @return bool True if the {@link Credentials} used to make this request are wrong. If this returns true, the + * application should ask the user to re-enter the credentials before making any further requests to the bank. + */ + public function indicatesBadLoginData(): bool + { + return $this->hasError(Rueckmeldungscode::PIN_UNGUELTIG); + } + + /** + * @return bool If the error indicates that the account (bank account and/or online banking access) has been locked, + * usually due to suspicious activity or failed login attempts. If this returns true, the application should + * refrain from logging in / using that account in any automated way before getting confirmation from the user + * that it has been unlocked. + */ + public function indicatesLocked(): bool + { + return $this->hasError(Rueckmeldungscode::PIN_GESPERRT) + || $this->hasError(Rueckmeldungscode::TEILNEHMER_GESPERRT) + || $this->hasWarning(Rueckmeldungscode::PIN_VORLAEUFIG_GESPERRT) + || $this->hasWarning(Rueckmeldungscode::ZUGANG_VORLAEUFIG_GESPERRT) + || $this->hasError(Rueckmeldungscode::ZUGANG_GESPERRT); + } + + /** + * @return bool True if the provided TAN is invalid (including entirely wrong, used for another transaction already, + * or exceeded its expiration time). + */ + public function indicatesBadTan(): bool + { + return $this->hasError(Rueckmeldungscode::TAN_UNGUELTIG) + || $this->hasError(Rueckmeldungscode::TAN_BEREITS_VERBRAUCHT) + || $this->hasError(Rueckmeldungscode::ZEITUEBERSCHREITUNG_IM_ZWEI_SCHRITT_VERFAHREN); + } + + /** + * @param Message $response A response received from the server. + * @param Message $request The original requests, from which this function pulls the segments that errors + * refer to, for ease of debugging. + * @throws ServerException In case the response indicates an error. + */ + public static function detectAndThrowErrors(Message $response, Message $request) + { + /** @var HIRMGv2[]|HIRMSv2[] $segments */ + $segments = array_merge( + [$response->requireSegment(HIRMGv2::class)], + $response->findSegments(HIRMSv2::class) + ); + $errors = []; + $warnings = []; + $requestSegments = []; + foreach ($segments as $segment) { + $referenceSegment = $segment->segmentkopf->bezugselement; + foreach ($segment->rueckmeldung as $rueckmeldung) { + $rueckmeldung->referenceSegment = $referenceSegment; + if (Rueckmeldungscode::isError($rueckmeldung->rueckmeldungscode)) { + $errors[] = $rueckmeldung; + if ($referenceSegment !== null) { + $requestSegment = $request->findSegmentByNumber($referenceSegment); + if ($requestSegment !== null) { + $requestSegments[] = $requestSegment; + } + } + } elseif (Rueckmeldungscode::isWarning($rueckmeldung->rueckmeldungscode)) { + $warnings[] = $rueckmeldung; + } + } + } + if (count($errors) > 0) { + throw new ServerException($errors, $warnings, $requestSegments, $request, $response); + } + } + + /** + * The response that the bank sent, that contained the errors + */ + public function getResponse(): Message + { + return $this->response; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Protocol/TanRequiredException.php b/vendor/nemiah/php-fints/lib/Fhp/Protocol/TanRequiredException.php new file mode 100755 index 0000000..7e1cd18 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Protocol/TanRequiredException.php @@ -0,0 +1,26 @@ +tanRequest = $tanRequest; + } + + public function getTanRequest() + { + return $this->tanRequest; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Protocol/UPD.php b/vendor/nemiah/php-fints/lib/Fhp/Protocol/UPD.php new file mode 100755 index 0000000..61916f4 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Protocol/UPD.php @@ -0,0 +1,80 @@ +hiupa->updVersion; + } + + /** + * @param Message $response A dialog initialization response from the server. + * @return bool True if the UPD data is contained in the response and {@link extractFromResponse()} would + * succeed. + */ + public static function containedInResponse(Message $response): bool + { + return $response->hasSegment(HIUPAv4::class); + } + + /** + * @param Message $response The dialog initialization response from the server, which should contain the UPD + * data. + * @return UPD A new UPD instance with the extracted configuration data. + */ + public static function extractFromResponse(Message $response): UPD + { + $upd = new UPD(); + $upd->hiupa = $response->requireSegment(HIUPAv4::class); + $upd->hiupd = $response->findSegments(HIUPD::class); + return $upd; + } + + /** + * @param SEPAAccount $account An account. + * @return HIUPD|null The HIUPD segment for this account, or null if none exists for this account. + */ + public function findHiupd(SEPAAccount $account): ?HIUPD + { + foreach ($this->hiupd as $hiupd) { + if ($hiupd->matchesAccount($account)) { + return $hiupd; + } + } + return null; + } + + /** + * @param SEPAAccount $account The account to test the support for + * @param string $requestName The request that shall be sent to the bank. + * @return bool True if the given request can be used by the current user for the given account. + */ + public function isRequestSupportedForAccount(SEPAAccount $account, string $requestName): bool + { + $hiupd = $this->findHiupd($account); + if ($hiupd !== null) { + foreach ($hiupd->getErlaubteGeschaeftsvorfaelle() as $erlaubterGeschaeftsvorfall) { + if ($erlaubterGeschaeftsvorfall->getGeschaeftsvorfall() == $requestName) { + return true; + } + } + } + return false; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Protocol/UnexpectedResponseException.php b/vendor/nemiah/php-fints/lib/Fhp/Protocol/UnexpectedResponseException.php new file mode 100755 index 0000000..daea608 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Protocol/UnexpectedResponseException.php @@ -0,0 +1,15 @@ +segmentkopf = $segmentkopf; + $this->type = $segmentkopf->segmentkennung . 'v' . $segmentkopf->segmentversion; + $this->elements = $elements; + } + + public function getDescriptor(): SegmentDescriptor + { + throw new \RuntimeException('AnonymousSegments do not have a descriptor'); + } + + public function validate() + { + // Do nothing, anonymous segments are always valid. + } + + /** + * @deprecated Beginning from PHP7.4 __unserialize is used for new generated strings, then this method is only used for previously generated strings - remove after May 2023 + */ + public function serialize(): string + { + return $this->__serialize()[0]; + } + + /** + * @deprecated Beginning from PHP7.4 __unserialize is used for new generated strings, then this method is only used for previously generated strings - remove after May 2023 + * + * @return void + */ + public function unserialize($serialized) + { + self::__unserialize([$serialized]); + } + + public function __serialize(): array + { + $result = $this->segmentkopf->serialize() . Delimiter::ELEMENT . + implode(Delimiter::ELEMENT, array_map(function ($element) { + if ($element === null) { + return ''; + } + if (is_string($element)) { + return $element; + } + return implode(Delimiter::GROUP, $element); + }, $this->elements)) + . Delimiter::SEGMENT; + + return [$result]; + } + + public function __unserialize(array $serialized): void + { + $parsed = Parser::parseAnonymousSegment($serialized[0]); + $this->type = $parsed->type; + $this->segmentkopf = $parsed->segmentkopf; + $this->elements = $parsed->elements; + } + + /** + * Just to override the super factory. + * @noinspection PhpUnnecessaryStaticReferenceInspection + */ + public static function createEmpty(): static + { + // Note: createEmpty() normally runs the constructor and then fills the Segmentkopf, but that is not possible + // for AnonymousSegment. Callers should just call the constructor itself. + throw new \RuntimeException('AnonymousSegment::createEmpty() is not allowed'); + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/BME/HIBMESv1.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/BME/HIBMESv1.php new file mode 100755 index 0000000..a69fd4e --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/BME/HIBMESv1.php @@ -0,0 +1,29 @@ +parameter; + } + + public function createRequestSegment(): BaseSegment + { + return HKBMEv1::createEmpty(); + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/BME/HIBMESv2.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/BME/HIBMESv2.php new file mode 100755 index 0000000..474d35b --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/BME/HIBMESv2.php @@ -0,0 +1,29 @@ +parameter; + } + + public function createRequestSegment(): BaseSegment + { + return HKBMEv2::createEmpty(); + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/BME/HKBMEv1.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/BME/HKBMEv1.php new file mode 100755 index 0000000..7139d5a --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/BME/HKBMEv1.php @@ -0,0 +1,15 @@ +parameter; + } + + public function createRequestSegment(): BaseSegment + { + return HKBSEv1::createEmpty(); + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/BSE/HIBSESv2.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/BSE/HIBSESv2.php new file mode 100755 index 0000000..7cc8cd7 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/BSE/HIBSESv2.php @@ -0,0 +1,29 @@ +parameter; + } + + public function createRequestSegment(): BaseSegment + { + return HKBSEv2::createEmpty(); + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/BSE/HKBSEv1.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/BSE/HKBSEv1.php new file mode 100755 index 0000000..9f2be81 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/BSE/HKBSEv1.php @@ -0,0 +1,15 @@ + 1 */ + public int $minimaleVorlaufzeitFNALRCUR; + public int $maximaleVorlaufzeitFNALRCUR; + /** Must be => 1 */ + public int $minimaleVorlaufzeitFRSTOOFF; + public int $maximaleVorlaufzeitFRSTOOFF; + + public function getMinimalLeadTime(string $seqType): ?MinimaleVorlaufzeitSEPALastschrift + { + $leadTime = in_array($seqType, ['FRST', 'OOFF']) ? $this->minimaleVorlaufzeitFRSTOOFF : $this->minimaleVorlaufzeitFNALRCUR; + return MinimaleVorlaufzeitSEPALastschrift::create($leadTime, '235959'); + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/BSE/ParameterTerminierteSEPAFirmenEinzellastschriftEinreichenV2.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/BSE/ParameterTerminierteSEPAFirmenEinzellastschriftEinreichenV2.php new file mode 100755 index 0000000..dc256af --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/BSE/ParameterTerminierteSEPAFirmenEinzellastschriftEinreichenV2.php @@ -0,0 +1,17 @@ +minimaleVorlaufzeitCodiert)); + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/BaseDeg.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/BaseDeg.php new file mode 100755 index 0000000..2b9d03b --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/BaseDeg.php @@ -0,0 +1,99 @@ +descriptor === null) { + $this->descriptor = DegDescriptor::get(static::class); + } + return $this->descriptor; + } + + public function __debugInfo() + { + $result = get_object_vars($this); + unset($result['descriptor']); // Don't include descriptor in debug output, to avoid clutter. + return $result; + } + + /** + * @throws \InvalidArgumentException If any element in this DEG is invalid. + */ + public function validate() + { + $this->getDescriptor()->validateObject($this); + } + + /** + * @deprecated Beginning from PHP7.4 __unserialize is used for new generated strings, then this method is only used for previously generated strings - remove after May 2023 + * + * Short-hand for {@link Serializer::serializeDeg()}. + * @return string The HBCI wire format representation of this DEG. + */ + public function serialize(): string + { + return $this->__serialize()[0]; + } + + /** + * @deprecated Beginning from PHP7.4 __unserialize is used for new generated strings, then this method is only used for previously generated strings - remove after May 2023 + * + * Parses into the current instance. + * @param string $serialized The HBCI wire format for a DEG of this type. + */ + public function unserialize($serialized) + { + self::__unserialize([$serialized]); + } + + /** + * Short-hand for {@link Serializer::serializeDeg()}. + * @return array [0]: The HBCI wire format representation of this DEG. + */ + public function __serialize(): array + { + return [Serializer::serializeDeg($this, $this->getDescriptor())]; + } + + /** + * Parses into the current instance. + * + * @param array $serialized [0]: The HBCI wire format for a DEG of this type + */ + public function __unserialize(array $serialized): void + { + Parser::parseDeg($serialized[0], $this); + } + + /** + * Convenience function for {@link Parser::parseGroup()}. This function should not be called on BaseDeg itself, but + * only on one of its sub-classes. + * @param string $rawElements The serialized wire format for a data element group. + * @return static The parsed value. + */ + public static function parse(string $rawElements): static + { + if (static::class === BaseDeg::class) { + throw new UnsupportedException('Must not call BaseDeg::parse() on the base class'); + } + return Parser::parseDeg($rawElements, static::class); + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/BaseDescriptor.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/BaseDescriptor.php new file mode 100755 index 0000000..1234721 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/BaseDescriptor.php @@ -0,0 +1,212 @@ +getDocComment() ?: ''; + if (static::getBoolAnnotation('Ignore', $docComment)) { + continue; // Skip @Ignore-d propeties. + } + + $index = $nextIndex; + $descriptor = new ElementDescriptor(); + $descriptor->field = $property->getName(); + $maxCount = static::getIntAnnotation('Max', $docComment); + if ($type = static::getVarAnnotation($docComment)) { + if (str_ends_with($type, '|null')) { // Nullable field + $descriptor->optional = true; + $type = substr($type, 0, -5); + } + if (str_ends_with($type, '[]')) { // Array/repeated field + if ($maxCount === null) { + throw new \InvalidArgumentException("Repeated property $property needs @Max() annotation"); + } + $descriptor->repeated = $maxCount; + $type = substr($type, 0, -2); + // If a repeated field is followed by anything at all, there will be an empty entry for each possible + // repeated value (in extreme cases, there can be hundreds of consecutive `+`, for instance). + $nextIndex += $maxCount; + } elseif ($maxCount !== null) { + throw new \InvalidArgumentException("@Max() annotation not recognized on single $property"); + } else { + ++$nextIndex; // Singular field, so the index advances by 1. + } + $descriptor->type = static::resolveType($type, $property->getDeclaringClass()); + } elseif ($type = $property->getType()) { + $descriptor->optional = $type->allowsNull(); + if ($type instanceof \ReflectionUnionType) { + throw new \InvalidArgumentException("Union type not supported for $property"); + } elseif ($type->getName() === 'array') { + throw new \InvalidArgumentException("Array type must use @type annotation on $property"); + } elseif ($type->isBuiltin()) { + $descriptor->type = $type->getName(); + } else { + try { + $descriptor->type = new \ReflectionClass($type->getName()); + } catch (\ReflectionException $e) { + throw new \InvalidArgumentException( + "Cannot resolve type {$type->getName()} for $property", 0, $e); + } + } + ++$nextIndex; // Singular field, so the index advances by 1. + } else { + throw new \InvalidArgumentException("Need type on property $property"); + } + $this->elements[$index] = $descriptor; + } + if (count($this->elements) === 0) { + throw new \InvalidArgumentException("No fields found in $clazz->name"); + } + ksort($this->elements); // Make sure elements are parsed in wire-format order. + $this->maxIndex = $nextIndex - 1; + } + + /** + * @param object $obj The object to be validated. + * @throws \InvalidArgumentException If any of the fields in the given object is not valid according to the schema + * defined by this descriptor. + */ + public function validateObject($obj): void + { + if (!is_a($obj, $this->class)) { + throw new \InvalidArgumentException("Expected $this->class, got " . gettype($obj)); + } + foreach ($this->elements as $elementDescriptor) { + $elementDescriptor->validateField($obj); + } + } + + /** + * @param \ReflectionClass $clazz The class name. + * @return \Generator|\ReflectionProperty[] All non-static public properties of the given class and its parents, but + * with the parents' properties *first*. + */ + private static function enumerateProperties(\ReflectionClass $clazz): array|\Generator + { + if ($clazz->getParentClass() !== false) { + yield from static::enumerateProperties($clazz->getParentClass()); + } + foreach ($clazz->getProperties(\ReflectionProperty::IS_PUBLIC) as $property) { + if (!$property->isStatic() && $property->getDeclaringClass()->name === $clazz->name) { + yield $property; + } + } + } + + /** + * Looks for the annotation with the given name and extracts the content of the parentheses behind it. For instance, + * when called with the name "Max" and a docComment that contains {@}Max(15), this would return "15". + * @param string $name The name of the annotation. + * @param string $docComment The documentation string of a PHP field. + * @return string|null The content of the annotation, or null if absent. + */ + private static function getAnnotation(string $name, string $docComment): ?string + { + $ret = preg_match("/@$name\\((.*?)\\)/", $docComment, $match); + if ($ret === false) { + throw new \RuntimeException("preg_match failed on $name"); + } + return $ret === 1 ? $match[1] : null; + } + + /** + * Same as above, with integer parsing. + * @param string $name The name of the annotation. + * @param string $docComment The documentation string of a PHP field. + * @return int|null The value of the annotation as an integer, or null if absent. + */ + private static function getIntAnnotation(string $name, string $docComment): ?int + { + $val = static::getAnnotation($name, $docComment); + if ($val === null) { + return null; + } + if (!is_numeric($val)) { + throw new \InvalidArgumentException("Annotation $name has non-integer value $val"); + } + return intval($val); + } + + /** + * @param string $name The name of the annotation. + * @param string $docComment The documentation string of a PHP field. + * @return bool Whether the annotation with the given name is present. + */ + private static function getBoolAnnotation(string $name, string $docComment): bool + { + return str_contains($docComment, "@$name ") + || str_contains($docComment, "@$name())"); + } + + /** + * Separate parser for the {@}var` annotation because it does not use parentheses. + * @param string $docComment The documentation string of a PHP field. + * @return string|null The value of the {@}var annotation, or null if absent. + */ + private static function getVarAnnotation(string $docComment): ?string + { + $ret = preg_match('/@var ([^\\s]+)/', $docComment, $match); + if ($ret === false) { + throw new \RuntimeException('preg_match failed for @var'); + } + return $ret === 1 ? $match[1] : null; + } + + /** + * NOTE: This does *not* resolve `use` statements in the source file. + * @param string $typeName A type name (PHP class name, fully qualified or not) or a scalar type name. + * @param \ReflectionClass $contextClass The class where this type name was encountered, used for resolution of + * classes in the same package. + * @return string|\ReflectionClass The class that the type name refers to, or the scalar type name as a string. + */ + private static function resolveType(string $typeName, \ReflectionClass $contextClass): \ReflectionClass|string + { + if (ElementDescriptor::isScalarType($typeName)) { + return $typeName; + } + if ($typeName === 'Bin') { + $typeName = Bin::class; + } elseif (!str_contains($typeName, '\\')) { + // Let's assume it's a relative type name, e.g. `X` mentioned in a file that starts with `namespace Fhp\Y` + // would become `\Fhp\X\Y`. + $typeName = $contextClass->getNamespaceName() . '\\' . $typeName; + } + try { + return new \ReflectionClass($typeName); + } catch (\ReflectionException $e) { + throw new \RuntimeException("$typeName not found in context of " . $contextClass->getName(), 0, $e); + } + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/BaseGeschaeftsvorfallparameter.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/BaseGeschaeftsvorfallparameter.php new file mode 100755 index 0000000..9749889 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/BaseGeschaeftsvorfallparameter.php @@ -0,0 +1,33 @@ +v" where is the + * type of the segment (e.g. "HITANS") and is the numeric version. The *public* member fields of a sub-class + * determine the structure of the segment. The order matters for the wire format, whereas the field names are only used + * for documentation/readability purposes within this library. See {@link HITANSv6} for an example of a sub-class. + */ +abstract class BaseSegment implements SegmentInterface, \Serializable +{ + /** Reference to the descriptor for this type of segment. */ + private ?SegmentDescriptor $descriptor = null; + public Segmentkopf $segmentkopf; + + /** + * @return SegmentDescriptor The descriptor for this segment's type. + */ + public function getDescriptor(): SegmentDescriptor + { + if ($this->descriptor === null) { + $this->descriptor = SegmentDescriptor::get(static::class); + } + return $this->descriptor; + } + + public function getName(): string + { + return $this->segmentkopf->segmentkennung; + } + + public function getVersion(): int + { + return $this->segmentkopf->segmentversion; + } + + public function getSegmentNumber(): int + { + return $this->segmentkopf->segmentnummer; + } + + /** + * @param int $segmentNumber The new segment number. + * @return $this The same instance. + */ + public function setSegmentNumber(int $segmentNumber): static + { + $this->segmentkopf->segmentnummer = $segmentNumber; + return $this; + } + + /** + * @throws \InvalidArgumentException If any element in this segment is invalid. + */ + public function validate() + { + $this->getDescriptor()->validateObject($this); + } + + /** + * @deprecated Beginning from PHP7.4 __unserialize is used for new generated strings, then this method is only used for previously generated strings - remove after May 2023 + * + * Short-hand for {@link Serializer::serializeSegment()}. + * @return string The HBCI wire format representation of this segment, in ISO-8859-1 encoding, terminated by the + * segment delimiter. + */ + public function serialize(): string + { + return $this->__serialize()[0]; + } + + /** + * @deprecated Beginning from PHP7.4 __unserialize is used for new generated strings, then this method is only used for previously generated strings - remove after May 2023 + * + * @param string $serialized + * @return void + */ + public function unserialize($serialized) + { + self::__unserialize([$serialized]); + } + + /** + * Short-hand for {@link Serializer::serializeSegment()}. + * + * @return array [0]: The HBCI wire format representation of this segment, in ISO-8859-1 encoding, terminated by the + * segment delimiter. + */ + public function __serialize(): array + { + return [Serializer::serializeSegment($this)]; + } + + public function __unserialize(array $serialized): void + { + Parser::parseSegment($serialized[0], $this); + } + + public function __toString(): string + { + return $this->serialize(); + } + + public function __debugInfo() + { + $result = get_object_vars($this); + unset($result['descriptor']); // Don't include descriptor in debug output, to avoid clutter. + return $result; + } + + /** + * Convenience function for {@link Parser::parseSegment()}. + * @param string $rawSegment The serialized wire format for a single segment (segment delimiter must be present at + * the end). This should be ISO-8859-1-encoded. + * @return static The parsed segment. + */ + public static function parse(string $rawSegment): static + { + if (static::class === BaseSegment::class) { + // Called as BaseSegment::parse(), so we need to determine the right segment type/class. + return Parser::detectAndParseSegment($rawSegment); + } else { + // The parse() function was called on the segment subclass itself. + return Parser::parseSegment($rawSegment, static::class); + } + } + + /** + * @return static A new segment of the type on which this function was called, with the Segmentkopf initialized. + */ + public static function createEmpty(): static + { + if (static::class === BaseSegment::class) { + throw new \InvalidArgumentException('Must not call BaseSegment::createEmpty() on the super class'); + } + $result = new static(); + $descriptor = $result->getDescriptor(); + $result->segmentkopf = new Segmentkopf(); + $result->segmentkopf->segmentkennung = $descriptor->kennung; + $result->segmentkopf->segmentversion = $descriptor->version; + return $result; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/CAZ/GebuchteCamtUmsaetze.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/CAZ/GebuchteCamtUmsaetze.php new file mode 100755 index 0000000..40e0375 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/CAZ/GebuchteCamtUmsaetze.php @@ -0,0 +1,32 @@ +gebuchteCamtUmsaetze as $bin) { + $xml[] = $bin->getData(); + } + return $xml; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/CAZ/HICAZSv1.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/CAZ/HICAZSv1.php new file mode 100755 index 0000000..45e026d --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/CAZ/HICAZSv1.php @@ -0,0 +1,21 @@ +parameter; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/CAZ/HICAZv1.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/CAZ/HICAZv1.php new file mode 100755 index 0000000..c800c39 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/CAZ/HICAZv1.php @@ -0,0 +1,61 @@ +kontoverbindungInternational; + } + + public function getCamtDescriptor(): string + { + return $this->camtDescriptor; + } + + /** + * @return string[] + */ + public function getGebuchteUmsaetze(): array + { + return $this->gebuchteUmsaetze->getData(); + } + + public function getNichtGebuchteUmsaetze(): string + { + return $this->nichtGebuchteUmsaetze->getData(); + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/CAZ/HKCAZv1.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/CAZ/HKCAZv1.php new file mode 100755 index 0000000..ecaabf2 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/CAZ/HKCAZv1.php @@ -0,0 +1,48 @@ +kontoverbindungInternational = $kti; + $result->unterstuetzteCamtMessages = $unterstuetzteCamtMessages; + $result->alleKonten = $alleKonten; + $result->vonDatum = $vonDatum?->format('Ymd'); + $result->bisDatum = $bisDatum?->format('Ymd'); + $result->aufsetzpunkt = $aufsetzpunkt; + + return $result; + } + + public function setPaginationToken(string $paginationToken) + { + $this->aufsetzpunkt = $paginationToken; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/CAZ/ParameterKontoumsaetzeCamt.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/CAZ/ParameterKontoumsaetzeCamt.php new file mode 100755 index 0000000..5b9d1f2 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/CAZ/ParameterKontoumsaetzeCamt.php @@ -0,0 +1,21 @@ +unterstuetzteCamtMessages; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/CAZ/UnterstuetzteCamtMessages.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/CAZ/UnterstuetzteCamtMessages.php new file mode 100755 index 0000000..0c1a88f --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/CAZ/UnterstuetzteCamtMessages.php @@ -0,0 +1,24 @@ +camtDescriptor = $camtDescriptor; + return $result; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/CCM/HICCMSv1.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/CCM/HICCMSv1.php new file mode 100755 index 0000000..f3d23be --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/CCM/HICCMSv1.php @@ -0,0 +1,21 @@ +parameter; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/CCM/HKCCMv1.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/CCM/HKCCMv1.php new file mode 100755 index 0000000..2d2cf82 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/CCM/HKCCMv1.php @@ -0,0 +1,30 @@ + and in the XML Below. */ + public \Fhp\Segment\Common\Kti $kontoverbindungInternational; + + /** Required if BDP „Summenfeld benötigt“ = J */ + public ?\Fhp\Segment\Common\Btg $summenfeld = null; + + /** Optional only if „Einzelbuchung erlaubt“ = J */ + public ?bool $einzelbuchungGewuenscht = null; + + /** Max length: 256 */ + public string $sepaDescriptor; + + /** @var Bin XML */ + public Bin $sepaPainMessage; +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/CCM/ParameterSEPASammelueberweisungV1.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/CCM/ParameterSEPASammelueberweisungV1.php new file mode 100755 index 0000000..a08c0b5 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/CCM/ParameterSEPASammelueberweisungV1.php @@ -0,0 +1,12 @@ + and in the XML Below. */ + public \Fhp\Segment\Common\Kti $kontoverbindungInternational; + /** Max length: 256 */ + public string $sepaDescriptor; + /** + * The PAIN message in XML format. + * HISPAS informs which XML schemas are allowed. + * The field must be 1999-01-01. + */ + public Bin $sepaPainMessage; +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/CME/HICMESv1.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/CME/HICMESv1.php new file mode 100755 index 0000000..833d18b --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/CME/HICMESv1.php @@ -0,0 +1,16 @@ + and in the XML Below. */ + public \Fhp\Segment\Common\Kti $kontoverbindungInternational; + + /** Required if BDP „Summenfeld benötigt“ = J */ + public ?\Fhp\Segment\Common\Btg $summenfeld = null; + + /** Optional only if „Einzelbuchung erlaubt“ = J */ + public ?bool $einzelbuchungGewuenscht = null; + + /** Max length: 256 */ + public string $sepaDescriptor; + + /** @var Bin XML */ + public Bin $sepaPainMessage; +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/CME/ParameterTerminierteSEPASammelueberweisungEinreichenV1.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/CME/ParameterTerminierteSEPASammelueberweisungEinreichenV1.php new file mode 100755 index 0000000..236c685 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/CME/ParameterTerminierteSEPASammelueberweisungEinreichenV1.php @@ -0,0 +1,14 @@ +parameter; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/CSE/HICSEv1.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/CSE/HICSEv1.php new file mode 100755 index 0000000..4bedf9f --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/CSE/HICSEv1.php @@ -0,0 +1,16 @@ + and in the XML Below. */ + public \Fhp\Segment\Common\Kti $kontoverbindungInternational; + /** Max length: 256 */ + public string $sepaDescriptor; + /** + * The PAIN message in XML format. + * HISPAS informs which XML schemas are allowed. + * The field must be 1999-01-01. + */ + public Bin $sepaPainMessage; +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/CSE/ParameterTerminierteSEPAUeberweisungEinreichenV1.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/CSE/ParameterTerminierteSEPAUeberweisungEinreichenV1.php new file mode 100755 index 0000000..905aff9 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/CSE/ParameterTerminierteSEPAUeberweisungEinreichenV1.php @@ -0,0 +1,13 @@ + 1 */ + public int $minimaleVorlaufzeit; + public int $maximaleVorlaufzeit; + +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/Common/AccountInfo.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/Common/AccountInfo.php new file mode 100755 index 0000000..3e024c2 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/Common/AccountInfo.php @@ -0,0 +1,15 @@ +wert = $wert; + $result->waehrung = $waehrung; + return $result; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/Common/Kik.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/Common/Kik.php new file mode 100755 index 0000000..8b8f177 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/Common/Kik.php @@ -0,0 +1,38 @@ +laenderkennzeichen === self::DEFAULT_COUNTRY_CODE && $this->kreditinstitutscode === null) { + throw new \InvalidArgumentException('Kik.kreditinstitutscode is mandatory for German banks (BLZ)'); + } + } + + public static function create(string $kreditinstitutscode): Kik + { + $result = new Kik(); + $result->laenderkennzeichen = static::DEFAULT_COUNTRY_CODE; + $result->kreditinstitutscode = $kreditinstitutscode; + return $result; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/Common/Kti.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/Common/Kti.php new file mode 100755 index 0000000..9c47a71 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/Common/Kti.php @@ -0,0 +1,72 @@ +iban !== null) { + if ($this->bic == null) { + throw new \InvalidArgumentException('Kti cannot have IBAN without BIC'); + } + } else { + if ($this->kontonummer === null || $this->kreditinstitutskennung === null) { + throw new \InvalidArgumentException('Kti must have IBAN+BIC or Kontonummer+Kik or both'); + } + } + } + + public static function create(?string $iban, ?string $bic): Kti + { + $result = new Kti(); + $result->iban = $iban; + $result->bic = $bic; + return $result; + } + + public static function fromAccount(SEPAAccount $account): Kti + { + $result = static::create($account->getIban(), $account->getBic()); + $result->kontonummer = $account->getAccountNumber(); + $result->unterkontomerkmal = $account->getSubAccount(); + $result->kreditinstitutskennung = Kik::create($account->getBlz()); + return $result; + } + + /** {@inheritdoc} */ + public function getAccountNumber(): string + { + return $this->iban ?? $this->kontonummer; + } + + /** {@inheritdoc} */ + public function getBankIdentifier(): ?string + { + return $this->bic ?? $this->kreditinstitutskennung->kreditinstitutscode; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/Common/Kto.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/Common/Kto.php new file mode 100755 index 0000000..1671816 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/Common/Kto.php @@ -0,0 +1,45 @@ +kontonummer = $kontonummer; + $result->kik = $kik; + return $result; + } + + public static function fromAccount(SEPAAccount $account): Kto + { + return static::create($account->getAccountNumber(), Kik::create($account->getBlz())); + } + + /** {@inheritdoc} */ + public function getAccountNumber(): string + { + return $this->kontonummer; + } + + /** {@inheritdoc} */ + public function getBankIdentifier(): ?string + { + return $this->kik->kreditinstitutscode; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/Common/KtvV3.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/Common/KtvV3.php new file mode 100755 index 0000000..194ad43 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/Common/KtvV3.php @@ -0,0 +1,51 @@ +kontonummer = $kontonummer; + $result->unterkontomerkmal = $unterkontomerkmal; + $result->kik = $kik; + return $result; + } + + public static function fromAccount(SEPAAccount $account): KtvV3 + { + return static::create($account->getAccountNumber(), $account->getSubAccount(), Kik::create($account->getBlz())); + } + + /** {@inheritdoc} */ + public function getAccountNumber(): string + { + return $this->kontonummer ?: ''; + } + + /** {@inheritdoc} */ + public function getBankIdentifier(): ?string + { + return $this->kik->kreditinstitutscode; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/Common/Ktz.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/Common/Ktz.php new file mode 100755 index 0000000..b5ee4d8 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/Common/Ktz.php @@ -0,0 +1,38 @@ +iban ?? $this->kontonummer; + } + + /** {@inheritdoc} */ + public function getBankIdentifier(): ?string + { + return $this->bic ?? $this->kreditinstitutskennung->kreditinstitutscode; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/Common/Kursqualitaet.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/Common/Kursqualitaet.php new file mode 100755 index 0000000..fc8faa2 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/Common/Kursqualitaet.php @@ -0,0 +1,19 @@ +sollHabenKennzeichen === self::CREDIT) { + return $this->betrag->wert; + } elseif ($this->sollHabenKennzeichen === self::DEBIT) { + return -1 * $this->betrag->wert; + } else { + throw new \InvalidArgumentException("Invalid sollHabenKennzeichen: $this->sollHabenKennzeichen"); + } + } + + public function getCurrency(): string + { + return $this->betrag->waehrung; + } + + public function getTimestamp(): \DateTime + { + return \DateTime::createFromFormat('Ymd His', $this->datum . ' ' . ($this->uhrzeit ?? '000000')); + } + + public static function create(float $amount, string $currency, \DateTime $timestamp): Sdo + { + $result = new Sdo(); + $result->sollHabenKennzeichen = $amount < 0 ? self::DEBIT : self::CREDIT; + $result->betrag = Btg::create($amount, $currency); + $result->datum = $timestamp->format('Ymd'); + $result->uhrzeit = $timestamp->format('His'); + if ($result->uhrzeit == '000000') { + $result->uhrzeit = null; + } + return $result; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/Common/Tsp.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/Common/Tsp.php new file mode 100755 index 0000000..f177e2e --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/Common/Tsp.php @@ -0,0 +1,32 @@ +datum = $datum; + $result->uhrzeit = $uhrzeit; + return $result; + } + + public function asDateTime(): \DateTime + { + return \DateTime::createFromFormat('Ymd His', $this->datum . ' ' . ($this->uhrzeit ?? '000000')); + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/DME/HIDMESv1.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/DME/HIDMESv1.php new file mode 100755 index 0000000..bcfd7d8 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/DME/HIDMESv1.php @@ -0,0 +1,29 @@ +parameter; + } + + public function createRequestSegment(): BaseSegment + { + return HKDMEv1::createEmpty(); + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/DME/HIDMESv2.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/DME/HIDMESv2.php new file mode 100755 index 0000000..4d3d9b8 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/DME/HIDMESv2.php @@ -0,0 +1,29 @@ +parameter; + } + + public function createRequestSegment(): BaseSegment + { + return HKDMEv2::createEmpty(); + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/DME/HKDMEv1.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/DME/HKDMEv1.php new file mode 100755 index 0000000..06e3a81 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/DME/HKDMEv1.php @@ -0,0 +1,30 @@ + and in the XML Below. */ + public \Fhp\Segment\Common\Kti $kontoverbindungInternational; + + /** Required if BDP „Summenfeld benötigt“ = J */ + public ?\Fhp\Segment\Common\Btg $summenfeld = null; + + /** Optional only if „Einzelbuchung erlaubt“ = J */ + public ?bool $einzelbuchungGewuenscht = null; + + /** Max length: 256 */ + public string $sepaDescriptor; + + /** @var Bin XML */ + public Bin $sepaPainMessage; +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/DME/HKDMEv2.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/DME/HKDMEv2.php new file mode 100755 index 0000000..e82d164 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/DME/HKDMEv2.php @@ -0,0 +1,13 @@ +parameter; + } + + public function createRequestSegment(): BaseSegment + { + return HKDSEv1::createEmpty(); + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/DSE/HIDSESv2.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/DSE/HIDSESv2.php new file mode 100755 index 0000000..680d12f --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/DSE/HIDSESv2.php @@ -0,0 +1,27 @@ +parameter; + } + + public function createRequestSegment(): BaseSegment + { + return HKDSEv2::createEmpty(); + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/DSE/HIDXES.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/DSE/HIDXES.php new file mode 100755 index 0000000..f0fe757 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/DSE/HIDXES.php @@ -0,0 +1,13 @@ + and in the XML Below. */ + public \Fhp\Segment\Common\Kti $kontoverbindungInternational; + + /** Max length: 256 */ + public string $sepaDescriptor; + + /** XML */ + public Bin $sepaPainMessage; +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/DSE/HKDSEv2.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/DSE/HKDSEv2.php new file mode 100755 index 0000000..075e33c --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/DSE/HKDSEv2.php @@ -0,0 +1,13 @@ +unterstuetzteSEPALastschriftartenCodiert = $unterstuetzteSEPALastschriftartenCodiert; + $result->sequenceTypeCodiert = $sequenceTypeCodiert; + $result->minimaleSEPAVorlaufzeit = $minimaleSEPAVorlaufzeit; + $result->cutOffZeit = $cutOffZeit; + + return $result; + } + + /** @return MinimaleVorlaufzeitSEPALastschrift[][]|array */ + public static function parseCoded(string $coded): array + { + $result = []; + foreach (array_chunk(explode(';', $coded), 4) as list($unterstuetzteSEPALastschriftartenCodiert, $sequenceTypeCodiert, $minimaleSEPAVorlaufzeit, $cutOffZeit)) { + $coreTypes = self::UNTERSTUETZTE_SEPA_LASTSCHRIFTARTEN_CODIERT[$unterstuetzteSEPALastschriftartenCodiert] ?? []; + $seqTypes = self::SEQUENCE_TYPE_CODIERT[$sequenceTypeCodiert] ?? []; + foreach ($coreTypes as $coreType) { + foreach ($seqTypes as $seqType) { + $result[$coreType][$seqType] = MinimaleVorlaufzeitSEPALastschrift::create($minimaleSEPAVorlaufzeit, $cutOffZeit, $unterstuetzteSEPALastschriftartenCodiert, $sequenceTypeCodiert); + } + } + } + return $result; + } + + /** @return MinimaleVorlaufzeitSEPALastschrift[][]|array */ + public static function parseCodedB2B(string $coded): array + { + $result = []; + foreach (array_chunk(explode(';', $coded), 3) as list($sequenceTypeCodiert, $minimaleSEPAVorlaufzeit, $cutOffZeit)) { + $seqTypes = self::SEQUENCE_TYPE_CODIERT[$sequenceTypeCodiert] ?? []; + foreach ($seqTypes as $seqType) { + $result['B2B'][$seqType] = MinimaleVorlaufzeitSEPALastschrift::create($minimaleSEPAVorlaufzeit, $cutOffZeit, null, $sequenceTypeCodiert); + } + } + return $result; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/DSE/ParameterTerminierteSEPAEinzellastschriftEinreichenV1.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/DSE/ParameterTerminierteSEPAEinzellastschriftEinreichenV1.php new file mode 100755 index 0000000..bc4805b --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/DSE/ParameterTerminierteSEPAEinzellastschriftEinreichenV1.php @@ -0,0 +1,21 @@ + 1 */ + public int $minimaleVorlaufzeitFNALRCUR; + public int $maximaleVorlaufzeitFNALRCUR; + /** Must be => 1 */ + public int $minimaleVorlaufzeitFRSTOOFF; + public int $maximaleVorlaufzeitFRSTOOFF; + + public function getMinimalLeadTime(string $seqType): ?MinimaleVorlaufzeitSEPALastschrift + { + $leadTime = in_array($seqType, ['FRST', 'OOFF']) ? $this->minimaleVorlaufzeitFRSTOOFF : $this->minimaleVorlaufzeitFNALRCUR; + return MinimaleVorlaufzeitSEPALastschrift::create($leadTime, '235959'); + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/DSE/ParameterTerminierteSEPAEinzellastschriftEinreichenV2.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/DSE/ParameterTerminierteSEPAEinzellastschriftEinreichenV2.php new file mode 100755 index 0000000..1123b5f --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/DSE/ParameterTerminierteSEPAEinzellastschriftEinreichenV2.php @@ -0,0 +1,17 @@ +minimaleVorlaufzeitCodiert)); + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/DSE/SEPADirectDebitMinimalLeadTimeProvider.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/DSE/SEPADirectDebitMinimalLeadTimeProvider.php new file mode 100755 index 0000000..c3f1fb5 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/DSE/SEPADirectDebitMinimalLeadTimeProvider.php @@ -0,0 +1,9 @@ +class = $class; + try { + $clazz = new \ReflectionClass($class); + if (!$clazz->isSubclassOf(BaseDeg::class)) { + throw new \InvalidArgumentException("Must inherit from BaseDeg: $class"); + } + parent::__construct($clazz); + + // Check if the name ends in V2 or so, implicitly assume V1. + if (preg_match('/^[A-Z]+[vV]([0-9]+)$/', $clazz->getShortName(), $match) === 1) { + $this->version = intval($match[1]); + } + } catch (\ReflectionException $e) { + throw new \RuntimeException($e); + } + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/ElementDescriptor.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/ElementDescriptor.php new file mode 100755 index 0000000..afcc665 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/ElementDescriptor.php @@ -0,0 +1,108 @@ +$field does not correspond to the schema in this descriptor. + */ + public function validateField($obj): void + { + if (!isset($obj->{$this->field})) { + if ($this->optional) { + return; + } + throw new \InvalidArgumentException("Missing field $this->field"); + } + $value = $obj->{$this->field}; + if ($this->repeated) { + if (!is_array($value)) { + throw new \InvalidArgumentException("Expected array value for repeated field $this->field"); + } + foreach ($value as $item) { + $this->validateValue($item); + } + } else { + $this->validateValue($value); + } + } + + /** + * Maps types declared in a {@}var comment to the return format of `gettype()`. + */ + public const TYPE_MAP = [ + 'int' => 'integer', 'integer' => 'integer', + 'float' => 'double', + 'bool' => 'boolean', 'boolean' => 'boolean', + 'string' => 'string', + ]; + + /** + * @param string $type A potential PHP scalar type. + * @return bool True if parseDataElement() would understand it. + */ + public static function isScalarType(string $type): bool + { + return array_key_exists($type, static::TYPE_MAP); + } + + /** + * @param mixed $value The (non-null) value to be validated. + * @throws \InvalidArgumentException If $value is not a valid $type. + */ + public function validateValue($value): void + { + if (is_string($this->type) && array_key_exists($this->type, static::TYPE_MAP)) { + $expectedType = static::TYPE_MAP[$this->type]; + $actualType = gettype($value); + if ($actualType !== $expectedType) { + throw new \InvalidArgumentException("Expected $expectedType, got $actualType: $value for $this->field"); + } + } elseif ($this->type instanceof \ReflectionClass) { + if (!$this->type->isInstance($value)) { + throw new \InvalidArgumentException("Expected {$this->type->name}, got $value for $this->field"); + } + if ($value instanceof BaseSegment || $value instanceof BaseDeg) { + $value->validate(); + } elseif ($value instanceof Bin) { + // Nothing to validate on a binary value. + } else { + throw new \AssertionError("Unexpected type {$this->type->name}"); // Violates guarantees of what we put in $this->type. + } + } else { + throw new \InvalidArgumentException("Unsupported type: $this->type"); + } + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/HIBPA/HIBPAv3.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/HIBPA/HIBPAv3.php new file mode 100755 index 0000000..3e0c7ea --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/HIBPA/HIBPAv3.php @@ -0,0 +1,27 @@ +findRueckmeldungen($code); + + if (count($matches) > 1) { + throw new \InvalidArgumentException("Unexpectedly multiple matches for Rueckmeldungscode $code"); + } + return count($matches) === 0 ? null : $matches[0]; + } + + /** @return Rueckmeldung[] */ + public function findRueckmeldungen(int $code): array + { + return array_values(array_filter($this->rueckmeldung, function ($rueckmeldung) use ($code) { + return $rueckmeldung->rueckmeldungscode === $code; + })); + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/HIRMS/HIRMSv2.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/HIRMS/HIRMSv2.php new file mode 100755 index 0000000..998becb --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/HIRMS/HIRMSv2.php @@ -0,0 +1,26 @@ +referenceSegment === null ? 'global' : "wrt seg $this->referenceSegment"; + $result = "$this->rueckmeldungscode ($referenceSegment): $this->rueckmeldungstext"; + if ($this->bezugsdatenelement !== null) { + $result .= " (wrt DE $this->bezugsdatenelement)"; + } + if ($this->rueckmeldungsparameter !== null && count($this->rueckmeldungsparameter) > 0) { + $result .= ' [' . implode(', ', $this->rueckmeldungsparameter) . ']'; + } + return $result; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/HIRMS/RueckmeldungContainer.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/HIRMS/RueckmeldungContainer.php new file mode 100755 index 0000000..33bcdd0 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/HIRMS/RueckmeldungContainer.php @@ -0,0 +1,18 @@ + Nr. 9 + */ +class ErlaubteGeschaeftsvorfaelleV1 extends BaseDeg implements ErlaubteGeschaeftsvorfaelle +{ + /** References a segment type name (Segmentkennung) */ + public string $geschaeftsvorfall; + /** Allowed values: 0, 1, 2, 3 */ + public int $anzahlBenoetigterSignaturen; + /** Allowed values: E, T, W, M, Z */ + public ?string $limitart = null; + public ?\Fhp\Segment\Common\Btg $limitbetrag = null; + /** If present, must be greater than 0 */ + public ?int $limitTage = null; + + /** {@inheritdoc} */ + public function getGeschaeftsvorfall(): string + { + return $this->geschaeftsvorfall; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/HIUPD/ErlaubteGeschaeftsvorfaelleV2.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/HIUPD/ErlaubteGeschaeftsvorfaelleV2.php new file mode 100755 index 0000000..7766078 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/HIUPD/ErlaubteGeschaeftsvorfaelleV2.php @@ -0,0 +1,30 @@ +geschaeftsvorfall; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/HIUPD/HIUPD.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/HIUPD/HIUPD.php new file mode 100755 index 0000000..aaa54ee --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/HIUPD/HIUPD.php @@ -0,0 +1,24 @@ +kontoverbindung->kontonummer) + && $this->kontoverbindung->kontonummer == $account->getAccountNumber(); + } + + /** {@inheritdoc} */ + public function getErlaubteGeschaeftsvorfaelle(): array + { + return $this->erlaubteGeschaeftsvorfaelle ?? []; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/HIUPD/HIUPDv6.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/HIUPD/HIUPDv6.php new file mode 100755 index 0000000..94e5c42 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/HIUPD/HIUPDv6.php @@ -0,0 +1,65 @@ +iban) && $this->iban == $account->getIban(); + } + + /** {@inheritdoc} */ + public function getErlaubteGeschaeftsvorfaelle(): array + { + return $this->erlaubteGeschaeftsvorfaelle ?? []; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/HIUPD/KontolimitV1.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/HIUPD/KontolimitV1.php new file mode 100755 index 0000000..6f2d48b --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/HIUPD/KontolimitV1.php @@ -0,0 +1,20 @@ + Nr. 8 + */ +class KontolimitV1 extends BaseDeg +{ + /** Allowed values: E, T, W, M, Z */ + public string $limitart; + public \Fhp\Segment\Common\Btg $limitbetrag; + /** If present, must be greater than 0 */ + public ?int $limitTage = null; +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/HIUPD/KontolimitV2.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/HIUPD/KontolimitV2.php new file mode 100755 index 0000000..3ba3080 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/HIUPD/KontolimitV2.php @@ -0,0 +1,22 @@ +dialogId = $dialogId; + return $result; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/HKIDN/HKIDNv2.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/HKIDN/HKIDNv2.php new file mode 100755 index 0000000..155abd2 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/HKIDN/HKIDNv2.php @@ -0,0 +1,56 @@ +kreditinstitutskennung = \Fhp\Segment\Common\Kik::create($kreditinstitutionscode); + $result->kundenId = $credentials->getBenutzerkennung(); + $result->kundensystemId = $kundensystemId; + $result->kundensystemStatus = 1; // This library only supports PIN/TAN, hence 1 is the right choice. + return $result; + } + + public static function createAnonymous(string $kreditinstitutionscode): HKIDNv2 + { + $result = HKIDNv2::createEmpty(); + $result->kreditinstitutskennung = \Fhp\Segment\Common\Kik::create($kreditinstitutionscode); + $result->kundenId = static::ANONYMOUS_KUNDEN_ID; + $result->kundensystemId = static::MISSING_KUNDENSYSTEM_ID; + $result->kundensystemStatus = 0; // Prescribed value for anonymous access. + return $result; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/HKSYN/HKSYNv3.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/HKSYN/HKSYNv3.php new file mode 100755 index 0000000..4506b78 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/HKSYN/HKSYNv3.php @@ -0,0 +1,23 @@ +bpdVersion = $bpd === null ? 0 : $bpd->getVersion(); + $result->updVersion = $upd === null ? 0 : $upd->getVersion(); + $result->produktbezeichnung = $options->productName; + $result->produktversion = $options->productVersion; + return $result; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/HNHBK/BezugsnachrichtV1.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/HNHBK/BezugsnachrichtV1.php new file mode 100755 index 0000000..7c1588f --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/HNHBK/BezugsnachrichtV1.php @@ -0,0 +1,20 @@ +nachrichtengroesse); + } + + /** + * @param int $nachrichtengroesse Length of the entire message in bytes. + */ + public function setNachrichtengroesse(int $nachrichtengroesse) + { + $this->nachrichtengroesse = str_pad($nachrichtengroesse, static::NACHRICHTENGROESSE_LENGTH, '0', STR_PAD_LEFT); + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/HNHBS/HNHBSv1.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/HNHBS/HNHBSv1.php new file mode 100755 index 0000000..25058ca --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/HNHBS/HNHBSv1.php @@ -0,0 +1,14 @@ +pin = $pin; + $result->tan = $tan; + return $result; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/HNSHA/HNSHAv2.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/HNSHA/HNSHAv2.php new file mode 100755 index 0000000..cf786c3 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/HNSHA/HNSHAv2.php @@ -0,0 +1,33 @@ +sicherheitskontrollreferenz = $sicherheitskontrollreferenz; + $result->benutzerdefinierteSignatur = $benutzerdefinierteSignatur; + return $result; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/HNSHK/HNSHKv4.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/HNSHK/HNSHKv4.php new file mode 100755 index 0000000..eb032af --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/HNSHK/HNSHKv4.php @@ -0,0 +1,81 @@ +sicherheitsprofil = + \Fhp\Segment\HNVSK\SicherheitsprofilV1::createPIN($tanMode); + $result->sicherheitsfunktion = $tanMode === null ? TanMode::SINGLE_STEP_ID : $tanMode->getId(); + $result->sicherheitskontrollreferenz = $sicherheitskontrollreferenz; + $result->sicherheitsidentifikationDetails = + \Fhp\Segment\HNVSK\SicherheitsidentifikationDetailsV2::createForSender($kundensystemId); + $result->sicherheitsdatumUndUhrzeit = + \Fhp\Segment\HNVSK\SicherheitsdatumUndUhrzeitV2::now(); + $result->hashalgorithmus = new HashalgorithmusV2(); + $result->signaturalgorithmus = new SignaturalgorithmusV2(); + $result->schluesselname = \Fhp\Segment\HNVSK\SchluesselnameV3::create( + Kik::create($options->bankCode), + $credentials->getBenutzerkennung(), + \Fhp\Segment\HNVSK\SchluesselnameV3::SIGNIERSCHLUESSEL); + return $result; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/HNSHK/HashalgorithmusV2.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/HNSHK/HashalgorithmusV2.php new file mode 100755 index 0000000..6b7e6d2 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/HNSHK/HashalgorithmusV2.php @@ -0,0 +1,37 @@ +segmentkopf->segmentnummer = static::SEGMENT_NUMBER; + $data = ''; + foreach ($segments as $segment) { + $data .= $segment->serialize(); + } + $result->datenVerschluesselt = new Bin($data); + return $result; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/HNVSK/HNVSKv3.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/HNVSK/HNVSKv3.php new file mode 100755 index 0000000..d340509 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/HNVSK/HNVSKv3.php @@ -0,0 +1,83 @@ +segmentkopf->segmentnummer = static::SEGMENT_NUMBER; + $result->sicherheitsprofil = SicherheitsprofilV1::createPIN($tanMode); + $result->sicherheitsidentifikationDetails = SicherheitsidentifikationDetailsV2::createForSender($kundensystemId); + $result->sicherheitsdatumUndUhrzeit = SicherheitsdatumUndUhrzeitV2::now(); + $result->verschluesselungsalgorithmus = VerschluesselungsalgorithmusV2::create(); + $result->schluesselname = SchluesselnameV3::create( + Kik::create($options->bankCode), + $credentials->getBenutzerkennung(), + SchluesselnameV3::CHIFFRIERSCHLUESSEL); + return $result; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/HNVSK/SchluesselnameV3.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/HNVSK/SchluesselnameV3.php new file mode 100755 index 0000000..3e0504d --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/HNVSK/SchluesselnameV3.php @@ -0,0 +1,46 @@ +kreditinstitutskennung = $kik; + $result->benutzerkennung = $benutzerkennung; + $result->schluesselart = $schluesselart; + return $result; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/HNVSK/SicherheitsdatumUndUhrzeitV2.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/HNVSK/SicherheitsdatumUndUhrzeitV2.php new file mode 100755 index 0000000..404dbe6 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/HNVSK/SicherheitsdatumUndUhrzeitV2.php @@ -0,0 +1,43 @@ +datum = $now->format('Ymd'); + $result->uhrzeit = $now->format('His'); + } catch (\Exception $e) { + throw new \RuntimeException('Failed to get current date', 0, $e); + } + return $result; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/HNVSK/SicherheitsidentifikationDetailsV2.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/HNVSK/SicherheitsidentifikationDetailsV2.php new file mode 100755 index 0000000..e592fe7 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/HNVSK/SicherheitsidentifikationDetailsV2.php @@ -0,0 +1,36 @@ +identifizierungDerPartei = $kundensystemId; + return $result; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/HNVSK/SicherheitsprofilV1.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/HNVSK/SicherheitsprofilV1.php new file mode 100755 index 0000000..71d326f --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/HNVSK/SicherheitsprofilV1.php @@ -0,0 +1,39 @@ +sicherheitsverfahren = 'PIN'; + $result->versionDesSicherheitsverfahrens = + $tanMode === null ? static::VERSION_EIN_SCHRITT_VERFAHREN : static::VERSION_ZWEI_SCHRITT_VERFAHREN; + return $result; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/HNVSK/VerschluesselungsalgorithmusV2.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/HNVSK/VerschluesselungsalgorithmusV2.php new file mode 100755 index 0000000..d4137d7 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/HNVSK/VerschluesselungsalgorithmusV2.php @@ -0,0 +1,53 @@ +wertDesAlgorithmusparametersSchluessel = new Bin('00000000'); // Dummy for PIN/TAN + return $result; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/HNVSK/ZertifikatV2.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/HNVSK/ZertifikatV2.php new file mode 100755 index 0000000..c54eea6 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/HNVSK/ZertifikatV2.php @@ -0,0 +1,20 @@ +parameter; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/KAZ/HIKAZSv5.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/KAZ/HIKAZSv5.php new file mode 100755 index 0000000..094f411 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/KAZ/HIKAZSv5.php @@ -0,0 +1,22 @@ +parameter; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/KAZ/HIKAZSv6.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/KAZ/HIKAZSv6.php new file mode 100755 index 0000000..ac8bede --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/KAZ/HIKAZSv6.php @@ -0,0 +1,21 @@ +parameter; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/KAZ/HIKAZSv7.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/KAZ/HIKAZSv7.php new file mode 100755 index 0000000..a3c877d --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/KAZ/HIKAZSv7.php @@ -0,0 +1,14 @@ +gebuchteUmsaetze; + } + + public function getNichtGebuchteUmsaetze(): ?Bin + { + return $this->nichtGebuchteUmsaetze; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/KAZ/HIKAZv5.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/KAZ/HIKAZv5.php new file mode 100755 index 0000000..2445df0 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/KAZ/HIKAZv5.php @@ -0,0 +1,18 @@ +kontoverbindungAuftraggeber = $kto; + $result->vonDatum = $vonDatum?->format('Ymd'); + $result->bisDatum = $bisDatum?->format('Ymd'); + $result->aufsetzpunkt = $aufsetzpunkt; + return $result; + } + + public function setPaginationToken(string $paginationToken) + { + $this->aufsetzpunkt = $paginationToken; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/KAZ/HKKAZv5.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/KAZ/HKKAZv5.php new file mode 100755 index 0000000..8360c23 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/KAZ/HKKAZv5.php @@ -0,0 +1,45 @@ +kontoverbindungAuftraggeber = $ktv; + $result->alleKonten = $alleKonten; + $result->vonDatum = $vonDatum?->format('Ymd'); + $result->bisDatum = $bisDatum?->format('Ymd'); + $result->aufsetzpunkt = $aufsetzpunkt; + return $result; + } + + public function setPaginationToken(string $paginationToken) + { + $this->aufsetzpunkt = $paginationToken; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/KAZ/HKKAZv6.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/KAZ/HKKAZv6.php new file mode 100755 index 0000000..d84f6af --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/KAZ/HKKAZv6.php @@ -0,0 +1,44 @@ +kontoverbindungAuftraggeber = $ktv; + $result->alleKonten = $alleKonten; + $result->vonDatum = $vonDatum?->format('Ymd'); + $result->bisDatum = $bisDatum?->format('Ymd'); + $result->aufsetzpunkt = $aufsetzpunkt; + return $result; + } + + public function setPaginationToken(string $paginationToken) + { + $this->aufsetzpunkt = $paginationToken; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/KAZ/HKKAZv7.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/KAZ/HKKAZv7.php new file mode 100755 index 0000000..23d6283 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/KAZ/HKKAZv7.php @@ -0,0 +1,44 @@ +kontoverbindungInternational = $kti; + $result->alleKonten = $alleKonten; + $result->vonDatum = $vonDatum?->format('Ymd'); + $result->bisDatum = $bisDatum?->format('Ymd'); + $result->aufsetzpunkt = $aufsetzpunkt; + return $result; + } + + public function setPaginationToken(string $paginationToken) + { + $this->aufsetzpunkt = $paginationToken; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/KAZ/ParameterKontoumsaetze.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/KAZ/ParameterKontoumsaetze.php new file mode 100755 index 0000000..bdb1b09 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/KAZ/ParameterKontoumsaetze.php @@ -0,0 +1,11 @@ +alleKontenErlaubt; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/Paginateable.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/Paginateable.php new file mode 100755 index 0000000..05ed7db --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/Paginateable.php @@ -0,0 +1,12 @@ +getTimestamp(). + public function getBuchungszeitpunkt(): ?Tsp; + + public function getFaelligkeit(): ?string; // JJJJMMTT gemäß ISO 8601 +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/SAL/HISALSv4.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/SAL/HISALSv4.php new file mode 100755 index 0000000..d3947e6 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/SAL/HISALSv4.php @@ -0,0 +1,18 @@ +kontoverbindungAuftraggeber; + } + + public function getKontoproduktbezeichnung(): string + { + return $this->kontoproduktbezeichnung; + } + + public function getGebuchterSaldo(): \Fhp\Segment\Common\Sdo + { + return $this->gebuchterSaldo; + } + + public function getSaldoDerVorgemerktenUmsaetze(): ?\Fhp\Segment\Common\Sdo + { + return $this->saldoDerVorgemerktenUmsaetze; + } + + public function getKreditlinie(): ?\Fhp\Segment\Common\Btg + { + return $this->kreditlinie; + } + + public function getVerfuegbarerBetrag(): ?\Fhp\Segment\Common\Btg + { + return $this->verfuegbarerBetrag; + } + + public function getBereitsVerfuegterBetrag(): ?\Fhp\Segment\Common\Btg + { + return $this->bereitsVerfuegterBetrag; + } + + public function getBuchungszeitpunkt(): ?\Fhp\Segment\Common\Tsp + { + return $this->buchungsdatumDesSaldos === null ? null : + \Fhp\Segment\Common\Tsp::create($this->buchungsdatumDesSaldos, $this->buchungsuhrzeitDesSaldos); + } + + public function getFaelligkeit(): ?string + { + return null; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/SAL/HISALv5.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/SAL/HISALv5.php new file mode 100755 index 0000000..902477e --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/SAL/HISALv5.php @@ -0,0 +1,80 @@ +kontoverbindungAuftraggeber; + } + + public function getKontoproduktbezeichnung(): string + { + return $this->kontoproduktbezeichnung; + } + + public function getGebuchterSaldo(): \Fhp\Segment\Common\Sdo + { + return $this->gebuchterSaldo; + } + + public function getSaldoDerVorgemerktenUmsaetze(): ?\Fhp\Segment\Common\Sdo + { + return $this->saldoDerVorgemerktenUmsaetze; + } + + public function getKreditlinie(): ?\Fhp\Segment\Common\Btg + { + return $this->kreditlinie; + } + + public function getVerfuegbarerBetrag(): ?\Fhp\Segment\Common\Btg + { + return $this->verfuegbarerBetrag; + } + + public function getBereitsVerfuegterBetrag(): ?\Fhp\Segment\Common\Btg + { + return $this->bereitsVerfuegterBetrag; + } + + public function getBuchungszeitpunkt(): ?\Fhp\Segment\Common\Tsp + { + return $this->buchungsdatumDesSaldos === null ? null : + \Fhp\Segment\Common\Tsp::create($this->buchungsdatumDesSaldos, $this->buchungsuhrzeitDesSaldos); + } + + public function getFaelligkeit(): ?string + { + return null; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/SAL/HISALv6.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/SAL/HISALv6.php new file mode 100755 index 0000000..6487d2e --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/SAL/HISALv6.php @@ -0,0 +1,77 @@ +kontoverbindungAuftraggeber; + } + + public function getKontoproduktbezeichnung(): string + { + return $this->kontoproduktbezeichnung; + } + + public function getGebuchterSaldo(): \Fhp\Segment\Common\Sdo + { + return $this->gebuchterSaldo; + } + + public function getSaldoDerVorgemerktenUmsaetze(): ?\Fhp\Segment\Common\Sdo + { + return $this->saldoDerVorgemerktenUmsaetze; + } + + public function getKreditlinie(): ?\Fhp\Segment\Common\Btg + { + return $this->kreditlinie; + } + + public function getVerfuegbarerBetrag(): ?\Fhp\Segment\Common\Btg + { + return $this->verfuegbarerBetrag; + } + + public function getBereitsVerfuegterBetrag(): ?\Fhp\Segment\Common\Btg + { + return $this->bereitsVerfuegterBetrag; + } + + public function getBuchungszeitpunkt(): ?\Fhp\Segment\Common\Tsp + { + return $this->buchungszeitpunkt; + } + + public function getFaelligkeit(): ?string + { + return $this->faelligkeit; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/SAL/HISALv7.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/SAL/HISALv7.php new file mode 100755 index 0000000..9eec28c --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/SAL/HISALv7.php @@ -0,0 +1,77 @@ +kontoverbindungInternational; + } + + public function getKontoproduktbezeichnung(): string + { + return $this->kontoproduktbezeichnung; + } + + public function getGebuchterSaldo(): \Fhp\Segment\Common\Sdo + { + return $this->gebuchterSaldo; + } + + public function getSaldoDerVorgemerktenUmsaetze(): ?\Fhp\Segment\Common\Sdo + { + return $this->saldoDerVorgemerktenUmsaetze; + } + + public function getKreditlinie(): ?\Fhp\Segment\Common\Btg + { + return $this->kreditlinie; + } + + public function getVerfuegbarerBetrag(): ?\Fhp\Segment\Common\Btg + { + return $this->verfuegbarerBetrag; + } + + public function getBereitsVerfuegterBetrag(): ?\Fhp\Segment\Common\Btg + { + return $this->bereitsVerfuegterBetrag; + } + + public function getBuchungszeitpunkt(): ?\Fhp\Segment\Common\Tsp + { + return $this->buchungszeitpunkt; + } + + public function getFaelligkeit(): ?string + { + return $this->faelligkeit; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/SAL/HKSALv4.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/SAL/HKSALv4.php new file mode 100755 index 0000000..258c737 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/SAL/HKSALv4.php @@ -0,0 +1,37 @@ +kontoverbindungAuftraggeber = $kto; + $result->aufsetzpunkt = $aufsetzpunkt; + return $result; + } + + public function setPaginationToken(string $paginationToken) + { + $this->aufsetzpunkt = $paginationToken; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/SAL/HKSALv5.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/SAL/HKSALv5.php new file mode 100755 index 0000000..7690de5 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/SAL/HKSALv5.php @@ -0,0 +1,37 @@ +kontoverbindungAuftraggeber = $ktv; + $result->alleKonten = $alleKonten; + $result->aufsetzpunkt = $aufsetzpunkt; + return $result; + } + + public function setPaginationToken(string $paginationToken) + { + $this->aufsetzpunkt = $paginationToken; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/SAL/HKSALv6.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/SAL/HKSALv6.php new file mode 100755 index 0000000..5d85374 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/SAL/HKSALv6.php @@ -0,0 +1,36 @@ +kontoverbindungAuftraggeber = $ktv; + $result->alleKonten = $alleKonten; + $result->aufsetzpunkt = $aufsetzpunkt; + return $result; + } + + public function setPaginationToken(string $paginationToken) + { + $this->aufsetzpunkt = $paginationToken; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/SAL/HKSALv7.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/SAL/HKSALv7.php new file mode 100755 index 0000000..585f86e --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/SAL/HKSALv7.php @@ -0,0 +1,36 @@ +kontoverbindungInternational = $kti; + $result->alleKonten = $alleKonten; + $result->aufsetzpunkt = $aufsetzpunkt; + return $result; + } + + public function setPaginationToken(string $paginationToken) + { + $this->aufsetzpunkt = $paginationToken; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/SPA/HISPA.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/SPA/HISPA.php new file mode 100755 index 0000000..89ce51d --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/SPA/HISPA.php @@ -0,0 +1,14 @@ +parameter; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/SPA/HISPASv2.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/SPA/HISPASv2.php new file mode 100755 index 0000000..398c421 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/SPA/HISPASv2.php @@ -0,0 +1,21 @@ +parameter; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/SPA/HISPASv3.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/SPA/HISPASv3.php new file mode 100755 index 0000000..3be9ab7 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/SPA/HISPASv3.php @@ -0,0 +1,13 @@ +sepaKontoverbindung ?? []; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/SPA/HISPAv2.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/SPA/HISPAv2.php new file mode 100755 index 0000000..cd97be4 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/SPA/HISPAv2.php @@ -0,0 +1,15 @@ +aufsetzpunkt = $paginationToken; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/SPA/HKSPAv3.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/SPA/HKSPAv3.php new file mode 100755 index 0000000..f44663e --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/SPA/HKSPAv3.php @@ -0,0 +1,14 @@ +class = $class; + try { + $clazz = new \ReflectionClass($class); + if (!$clazz->isSubclassOf(BaseSegment::class)) { + throw new \InvalidArgumentException("Must inherit from BaseSegment: $class"); + } + parent::__construct($clazz); + + // Parse the class name into segment type (Kennung) and version. + if (preg_match('/^([A-Z]+)v([0-9]+)$/', $clazz->getShortName(), $match) !== 1) { + throw new \InvalidArgumentException("Invalid segment class name: $class"); + } + $this->kennung = strval($match[1]); + $this->version = intval($match[2]); + } catch (\ReflectionException $e) { + throw new \RuntimeException($e); + } + } + + public function validateObject($obj): void // Override + { + parent::validateObject($obj); + if (!($obj instanceof BaseSegment)) { + throw new \InvalidArgumentException('Expected sub-class of BaseSegment, got ' . gettype($obj)); + } + if ($obj->getName() !== $this->kennung) { + throw new \InvalidArgumentException("Expected $this->kennung, got " . $obj->getName()); + } + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/SegmentInterface.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/SegmentInterface.php new file mode 100755 index 0000000..3f26b83 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/SegmentInterface.php @@ -0,0 +1,20 @@ +tanMediumListe; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/TAB/HITABv5.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/TAB/HITABv5.php new file mode 100755 index 0000000..c7f8916 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/TAB/HITABv5.php @@ -0,0 +1,30 @@ +tanMediumListe; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/TAB/HKTABv4.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/TAB/HKTABv4.php new file mode 100755 index 0000000..fe02c44 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/TAB/HKTABv4.php @@ -0,0 +1,30 @@ +bezeichnungDesTanMediums; + } + + /** {@inheritdoc} */ + public function getPhoneNumber(): ?string + { + return $this->mobiltelefonnummer !== null ? $this->mobiltelefonnummer : $this->mobiltelefonnummerVerschleiert; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/TAB/TanMediumListeV5.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/TAB/TanMediumListeV5.php new file mode 100755 index 0000000..be5aa7f --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/TAB/TanMediumListeV5.php @@ -0,0 +1,73 @@ +bezeichnungDesTanMediums; + } + + /** {@inheritdoc} */ + public function getPhoneNumber(): ?string + { + return $this->mobiltelefonnummer !== null ? $this->mobiltelefonnummer : $this->mobiltelefonnummerVerschleiert; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/TAN/AntwortHhdUc.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/TAN/AntwortHhdUc.php new file mode 100755 index 0000000..b2cf694 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/TAN/AntwortHhdUc.php @@ -0,0 +1,26 @@ +parameterZweiSchrittTanEinreichung; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/TAN/HITANSv7.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/TAN/HITANSv7.php new file mode 100755 index 0000000..73c120c --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/TAN/HITANSv7.php @@ -0,0 +1,25 @@ +parameterZweiSchrittTanEinreichung; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/TAN/HITANv6.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/TAN/HITANv6.php new file mode 100755 index 0000000..2d641f2 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/TAN/HITANv6.php @@ -0,0 +1,107 @@ +0 und TAN-Prozess=1 + * N: sonst + */ + public ?Bin $auftragsHashwert = null; + /** + * Special value "noref" means that no TAN is needed. + * M: bei TAN-Prozess=2, 3, 4 (and S) + * O: TAN-Prozess=1 + * Max length: 35 + */ + public ?string $auftragsreferenz = null; + /** + * This is the challenge that needs to be presented to the user, so that they can generate and enter a TAN. + * Special value "nochallenge" means that no TAN is needed. If $challengeStrukturiert in HITANS is set, this may + * contain certain HTML tags (br, p, b, i, u, ul, ol and li) that should ideally be rendered properly before + * presenting the challenge to the user. + * + * M: bei TAN-Prozess=1, 3, 4 + * O: bei TAN-Prozess=2 (and S) + * Max length: 2048 + */ + public ?string $challenge = null; + public ?Bin $challengeHhdUc = null; + public ?GueltigkeitsdatumUndUhrzeitFuerChallenge $gueltigkeitsdatumUndUhrzeitFuerChallenge = null; + /** + * Note: There are generally two ways to treat TAN media, see also HKTAN's $bezeichnungDesTanMediums field. This + * field here is set if the user does not choose the TAN medium beforehand, but the bank chooses it instead. + * + * M: bei TAN-Prozess=1, 3, 4 und „Anzahl unterstützter aktiver TAN-Medien“ nicht vorhanden + * O: sonst + * Max length 32 + */ + public ?string $bezeichnungDesTanMediums = null; + + /** {@inheritdoc} */ + public function getProcessId(): string + { + // Note: This is non-null because tanProzess==4. + return $this->auftragsreferenz; + } + + /** {@inheritdoc} */ + public function getChallenge(): ?string + { + // Note: This is non-null because tanProzess==4. + return $this->challenge === static::DUMMY_CHALLENGE ? null : $this->challenge; + } + + /** {@inheritdoc} */ + public function getTanMediumName(): ?string + { + return $this->bezeichnungDesTanMediums; + } + + public function getTanProzess(): string + { + return $this->tanProzess; + } + + public function getAuftragsHashwert(): ?Bin + { + return $this->auftragsHashwert; + } + + public function getAuftragsreferenz(): ?string + { + return $this->auftragsreferenz; + } + + public function getChallengeHhdUc(): ?Bin + { + return $this->challengeHhdUc; + } + + public function getGueltigkeitsdatumUndUhrzeitFuerChallenge(): ?GueltigkeitsdatumUndUhrzeitFuerChallenge + { + return $this->gueltigkeitsdatumUndUhrzeitFuerChallenge; + } + + public function getBezeichnungDesTanMediums(): ?string + { + return $this->bezeichnungDesTanMediums; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/TAN/HITANv7.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/TAN/HITANv7.php new file mode 100755 index 0000000..700e54d --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/TAN/HITANv7.php @@ -0,0 +1,16 @@ +getSmsAbbuchungskontoErforderlich()) { + throw new \InvalidArgumentException('SMS-Abbuchungskonto not supported'); + } + + $result = $tanMode->createHKTAN(); + $result->setTanProzess(HKTAN::TAN_PROZESS_4); + $result->setSegmentkennung($segmentkennung); + if ($tanMode->needsTanMedium()) { + if ($tanMedium === null) { + throw new \InvalidArgumentException('Missing tanMedium'); + } + $result->setBezeichnungDesTanMediums($tanMedium); + } + return $result; + } + + /** + * This is TAN-Prozess=2, which is the second step of Prozessvariante 2. If the bank server asked for a TAN in step + * 1 above, then the client application sends that TAN in a HKTAN segment to the server in order to authenticate the + * previously transmitted action. + * + * @param TanMode $tanMode Parameters retrieved from the server during dialog initialization that describe how the + * TAN processes need to be parameterized. + * @param string $auftragsreferenz The reference number received from the server in step 1 response (HITAN). + * @return BaseSegment A HKTAN instance to tell the server the reference of the previously submitted action. + */ + public static function createProzessvariante2Step2(TanMode $tanMode, string $auftragsreferenz): BaseSegment + { + $result = $tanMode->createHKTAN(); + $result->setTanProzess(HKTAN::TAN_PROZESS_2); + $result->setAuftragsreferenz($auftragsreferenz); + $result->setWeitereTanFolgt(false); // No Mehrfach-TAN support, so we'll never send true here. + return $result; + } + + /** + * This is TAN-Prozess=S, which is an alternative, repeated step 2 of Prozessvariante 2 for decoupled TAN modes. + * The TAN mode being "decoupled" means that the challenge and TAN submission (or simply transaction confirmation) + * happen entirely on the side channel (e.g. on the user's phone) and don't involve the application that triggered + * the action (i.e. the application using phpFinTs). This means that the application never submits a TAN (never + * calls {@link createProzessvariante2Step2()}). In order to learn when the authentication has completed, the + * application can use this process step 'S' to poll the server. + * @param TanMode $tanMode Parameters retrieved from the server during dialog initialization that describe how the + * TAN processes need to be parameterized. Must be a "decoupled" mode. + * @param string $auftragsreferenz The reference number received from the server in step 1 response (HITAN). + * @return BaseSegment A HKTAN instance to ask the server about the authentication status of the previously + * submitted action. + */ + public static function createProzessvariante2StepS(TanMode $tanMode, string $auftragsreferenz): BaseSegment + { + if (!$tanMode->isDecoupled()) { + throw new \InvalidArgumentException('Cannot use step S with non-decoupled TAN mode'); + } + $result = $tanMode->createHKTAN(); + if ($result->getVersion() < 7) { + throw new \InvalidArgumentException('Step S is only supported with HKTAN version 7+'); + } + $result->setTanProzess(HKTAN::TAN_PROZESS_S); + $result->setAuftragsreferenz($auftragsreferenz); + $result->setWeitereTanFolgt(false); // No Mehrfach-TAN support, so we'll never send true here. + return $result; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/TAN/HKTANv6.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/TAN/HKTANv6.php new file mode 100755 index 0000000..21bde77 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/TAN/HKTANv6.php @@ -0,0 +1,146 @@ +0 und TAN-Prozess=1 + * N: sonst + */ + public ?Bin $auftragsHashwert = null; + /** + * M: bei TAN-Prozess=2, 3 (and S) + * O: TAN-Prozess=1, 4 + * Max length: 36 + */ + public ?string $auftragsreferenz = null; + /** + * M: bei TAN-Prozess=1, 2 (and S) + * N: bei TAN-Prozess=3, 4 + */ + public ?bool $weitereTanFolgt = null; + /** + * O: bei TAN-Prozess=2 und „Auftragsstorno erlaubt“=J + * N: sons + */ + public ?bool $auftragStornieren = null; + /** + * M: bei TAN-Prozess=1, 3, 4 und „SMSAbbuchungskonto erforderlich“=2 + * O: sonst + */ + public ?\Fhp\Segment\Common\Kti $smsAbbuchungskonto = null; + /** + * M: bei TAN-Prozess=1 und „Challenge-Klasse erforderlich“=J + * N: sonst + */ + public ?int $challengeKlasse = null; + /** + * O: bei TAN-Prozess=1 und „Challenge-Klasse erforderlich“=J + * N: sonst + */ + public ?ParameterChallengeKlasse $parameterChallengeKlasse = null; + /** + * Note: There are generally two ways to treat TAN media. Either the HITANS declares that multiple media are + * available and the user has to choose one of them (possibly first using HKSPA to retrieve a list of options), in + * which case the user's choice is sent here in HKTAN's $bezeichnungDesTanMediums, or the bank chooses on the user's + * behalf and sends its choice in HITAN's $bezeichnungDesTanMediums. + * + * M: bei TAN-Prozess=1, 3, 4 und „Anzahl unterstützter aktiver TAN-Medien“ > 1 + * und „Bezeichnung des TAN-Mediums erforderlich“=2 + * O: sonst + * Max length 32 + */ + public ?string $bezeichnungDesTanMediums = null; + /** + * M: bei TAN-Prozess=2 und „Antwort HHD_UC erforderlich“=“J“ + * O: sonst + */ + public ?AntwortHhdUc $antwortHhdUc = null; + + /** + * @return HKTANv6 A dummy HKTANv6 segment to signal PSD2 readiness. + */ + public static function createDummy(): HKTANv6 + { + $result = HKTANv6::createEmpty(); + $result->tanProzess = HKTAN::TAN_PROZESS_4; + $result->segmentkennung = 'HKIDN'; + return $result; + } + + public function setTanProzess(string $tanProzess): void + { + $this->tanProzess = $tanProzess; + } + + public function setSegmentkennung(?string $segmentkennung): void + { + $this->segmentkennung = $segmentkennung; + } + + public function setBezeichnungDesTanMediums(?string $bezeichnungDesTanMediums): void + { + $this->bezeichnungDesTanMediums = $bezeichnungDesTanMediums; + } + + public function setAuftragsreferenz(?string $auftragsreferenz): void + { + $this->auftragsreferenz = $auftragsreferenz; + } + + public function setWeitereTanFolgt(?bool $weitereTanFolgt): void + { + $this->weitereTanFolgt = $weitereTanFolgt; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/TAN/HKTANv7.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/TAN/HKTANv7.php new file mode 100755 index 0000000..3894e42 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/TAN/HKTANv7.php @@ -0,0 +1,16 @@ +einschrittVerfahrenErlaubt; + } + + public function getVerfahrensparameterZweiSchrittVerfahren(): array + { + return $this->verfahrensparameterZweiSchrittVerfahren; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/TAN/ParameterZweiSchrittTanEinreichungV7.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/TAN/ParameterZweiSchrittTanEinreichungV7.php new file mode 100755 index 0000000..d226919 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/TAN/ParameterZweiSchrittTanEinreichungV7.php @@ -0,0 +1,30 @@ +einschrittVerfahrenErlaubt; + } + + public function getVerfahrensparameterZweiSchrittVerfahren(): array + { + return $this->verfahrensparameterZweiSchrittVerfahren; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/TAN/VerfahrensparameterZweiSchrittVerfahrenV6.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/TAN/VerfahrensparameterZweiSchrittVerfahrenV6.php new file mode 100755 index 0000000..6695917 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/TAN/VerfahrensparameterZweiSchrittVerfahrenV6.php @@ -0,0 +1,165 @@ +sicherheitsfunktion; + } + + /** {@inheritdoc} */ + public function getName(): string + { + return $this->nameDesZweiSchrittVerfahrens; + } + + /** {@inheritdoc} */ + public function isProzessvariante2(): bool + { + return $this->tanProzess === HKTAN::TAN_PROZESS_2; + } + + /** {@inheritdoc} */ + public function isDecoupled(): bool + { + return false; + } + + /** {@inheritdoc} */ + public function getSmsAbbuchungskontoErforderlich(): bool + { + return $this->smsAbbuchungskontoErforderlich === 2; + } + + /** {@inheritdoc} */ + public function getAuftraggeberkontoErforderlich(): bool + { + return $this->auftraggeberkontoErforderlich === 2; + } + + /** {@inheritdoc} */ + public function getChallengeKlasseErforderlich(): bool + { + return $this->challengeKlasseErforderlich; + } + + /** {@inheritdoc} */ + public function getAntwortHhdUcErforderlich(): bool + { + return $this->antwortHhdUcErforderlich; + } + + /** {@inheritdoc} */ + public function getChallengeLabel(): string + { + return $this->textZurBelegungDesRueckgabewertes; + } + + /** {@inheritdoc} */ + public function getMaxChallengeLength(): int + { + return $this->maximaleLaengeDesRueckgabewertes; + } + + /** {@inheritdoc} */ + public function getMaxTanLength(): int + { + return $this->maximaleLaengeDesTanEingabewertes; + } + + /** {@inheritdoc} */ + public function getTanFormat(): int + { + return $this->erlaubtesFormat; + } + + /** {@inheritdoc} */ + public function needsTanMedium(): bool + { + return $this->bezeichnungDesTanMediumsErforderlich === 2 && $this->anzahlUnterstuetzterAktiverTanMedien > 0; + } + + /** {@inheritdoc} */ + public function getMaxDecoupledChecks(): int + { + throw new \RuntimeException('Only allowed for decoupled TAN modes'); + } + + /** {@inheritdoc} */ + public function getFirstDecoupledCheckDelaySeconds(): int + { + throw new \RuntimeException('Only allowed for decoupled TAN modes'); + } + + /** {@inheritdoc} */ + public function getPeriodicDecoupledCheckDelaySeconds(): int + { + throw new \RuntimeException('Only allowed for decoupled TAN modes'); + } + + /** {@inheritdoc} */ + public function allowsManualConfirmation(): bool + { + throw new \RuntimeException('Only allowed for decoupled TAN modes'); + } + + /** {@inheritdoc} */ + public function allowsAutomatedPolling(): bool + { + throw new \RuntimeException('Only allowed for decoupled TAN modes'); + } + + /** {@inheritdoc} */ + public function createHKTAN(): HKTAN + { + return HKTANv6::createEmpty(); + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/TAN/VerfahrensparameterZweiSchrittVerfahrenV7.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/TAN/VerfahrensparameterZweiSchrittVerfahrenV7.php new file mode 100755 index 0000000..17c47c6 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/TAN/VerfahrensparameterZweiSchrittVerfahrenV7.php @@ -0,0 +1,228 @@ +sicherheitsfunktion; + } + + /** {@inheritdoc} */ + public function getName(): string + { + return $this->nameDesZweiSchrittVerfahrens; + } + + /** {@inheritdoc} */ + public function isProzessvariante2(): bool + { + return $this->tanProzess === HKTAN::TAN_PROZESS_2; + } + + /** {@inheritdoc} */ + public function isDecoupled(): bool + { + return $this->dkTanVerfahren === 'Decoupled' || $this->dkTanVerfahren === 'DecoupledPush'; + } + + /** {@inheritdoc} */ + public function getSmsAbbuchungskontoErforderlich(): bool + { + return $this->smsAbbuchungskontoErforderlich === 2; + } + + /** {@inheritdoc} */ + public function getAuftraggeberkontoErforderlich(): bool + { + return $this->auftraggeberkontoErforderlich === 2; + } + + /** {@inheritdoc} */ + public function getChallengeKlasseErforderlich(): bool + { + return $this->challengeKlasseErforderlich; + } + + /** {@inheritdoc} */ + public function getAntwortHhdUcErforderlich(): bool + { + return $this->antwortHhdUcErforderlich; + } + + /** {@inheritdoc} */ + public function getChallengeLabel(): string + { + return $this->textZurBelegungDesRueckgabewertes; + } + + /** {@inheritdoc} */ + public function getMaxChallengeLength(): int + { + return $this->maximaleLaengeDesRueckgabewertes; + } + + /** {@inheritdoc} */ + public function getMaxTanLength(): int + { + if ($this->isDecoupled()) { + throw new \RuntimeException('getMaxTanLength is not available for decoupled TAN modes'); + } + if ($this->maximaleLaengeDesTanEingabewertes === null) { + throw new \AssertionError('maximaleLaengeDesTanEingabewertes is unexpectedly absent'); + } + return $this->maximaleLaengeDesTanEingabewertes; + } + + /** {@inheritdoc} */ + public function getTanFormat(): int + { + if ($this->isDecoupled()) { + throw new \RuntimeException('getTanFormat is not available for decoupled TAN modes'); + } + if ($this->erlaubtesFormat === null) { + throw new \AssertionError('erlaubtesFormat is unexpectedly absent'); + } + return $this->erlaubtesFormat; + } + + /** {@inheritdoc} */ + public function needsTanMedium(): bool + { + return $this->bezeichnungDesTanMediumsErforderlich === 2 && $this->anzahlUnterstuetzterAktiverTanMedien > 0; + } + + /** {@inheritdoc} */ + public function getMaxDecoupledChecks(): int + { + if (!$this->isDecoupled()) { + throw new \RuntimeException('Only allowed for decoupled TAN modes'); + } + if ($this->maximaleAnzahlStatusabfragen === null) { + throw new \AssertionError('maximaleAnzahlStatusabfragen is unexpectedly absent'); + } + return $this->maximaleAnzahlStatusabfragen; + } + + /** {@inheritdoc} */ + public function getFirstDecoupledCheckDelaySeconds(): int + { + if (!$this->isDecoupled()) { + throw new \RuntimeException('Only allowed for decoupled TAN modes'); + } + if ($this->wartezeitVorErsterStatusabfrage === null) { + throw new \AssertionError('wartezeitVorErsterStatusabfrage is unexpectedly absent'); + } + return $this->wartezeitVorErsterStatusabfrage; + } + + /** {@inheritdoc} */ + public function getPeriodicDecoupledCheckDelaySeconds(): int + { + if (!$this->isDecoupled()) { + throw new \RuntimeException('Only allowed for decoupled TAN modes'); + } + if ($this->wartezeitVorNaechsterStatusabfrage === null) { + throw new \AssertionError('wartezeitVorNaechsterStatusabfrage is unexpectedly absent'); + } + return $this->wartezeitVorNaechsterStatusabfrage; + } + + /** {@inheritdoc} */ + public function allowsManualConfirmation(): bool + { + if (!$this->isDecoupled()) { + throw new \RuntimeException('Only allowed for decoupled TAN modes'); + } + if ($this->manuelleBestaetigungMoeglich === null) { + throw new \AssertionError('manuelleBestaetigungMoeglich is unexpectedly absent'); + } + return $this->manuelleBestaetigungMoeglich; + } + + /** {@inheritdoc} */ + public function allowsAutomatedPolling(): bool + { + if (!$this->isDecoupled()) { + throw new \RuntimeException('Only allowed for decoupled TAN modes'); + } + if ($this->automatisierteStatusabfragenErlaubt === null) { + throw new \AssertionError('automatisierteStatusabfragenErlaubt is unexpectedly absent'); + } + return $this->automatisierteStatusabfragenErlaubt; + } + + /** {@inheritdoc} */ + public function createHKTAN(): HKTAN + { + return HKTANv7::createEmpty(); + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/UnterstuetzteSEPADatenformate.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/UnterstuetzteSEPADatenformate.php new file mode 100755 index 0000000..e3e5d1a --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/UnterstuetzteSEPADatenformate.php @@ -0,0 +1,10 @@ + 'urn:iso:std:iso:20022:tech:xsd:', + 'sepade:' => 'urn:iso:std:iso:20022:tech:xsd:', + '.xsd' => '', + ]); + }, $this->unterstuetzteSepaDatenformate ?? $this->unterstuetzteSEPADatenformate ?? []); + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/WPD/HIWPD.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/WPD/HIWPD.php new file mode 100755 index 0000000..8519749 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/WPD/HIWPD.php @@ -0,0 +1,15 @@ +parameter; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/WPD/HIWPDv5.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/WPD/HIWPDv5.php new file mode 100755 index 0000000..dfdff62 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/WPD/HIWPDv5.php @@ -0,0 +1,24 @@ +depotaufstellung; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/WPD/HKWPDv5.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/WPD/HKWPDv5.php new file mode 100755 index 0000000..75ab527 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/WPD/HKWPDv5.php @@ -0,0 +1,34 @@ +depot = $ktv; + return $result; + } + + public function setPaginationToken(string $paginationToken) + { + $this->aufsetzpunkt = $paginationToken; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Segment/WPD/ParameterDepotaufstellung.php b/vendor/nemiah/php-fints/lib/Fhp/Segment/WPD/ParameterDepotaufstellung.php new file mode 100755 index 0000000..f101034 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Segment/WPD/ParameterDepotaufstellung.php @@ -0,0 +1,18 @@ +eingabeAnzahlEintraegeErlaubt; + } + + public function getWaehrungDepotaufstellungWaehlbar(): bool + { + return $this->waehrungDepotaufstellungWaehlbar; + } + + public function getKursqualitaetWaehlbar(): bool + { + return $this->kursqualitaetWaehlbar; + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Syntax/Bin.php b/vendor/nemiah/php-fints/lib/Fhp/Syntax/Bin.php new file mode 100755 index 0000000..5ce92a5 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Syntax/Bin.php @@ -0,0 +1,46 @@ +string = $string; + } + + /** + * Sets the binary data. + * + * @return $this + */ + public function setData(string $data): static + { + $this->string = $data; + + return $this; + } + + /** + * Gets the binary data. + */ + public function getData(): string + { + return $this->string; + } + + /** + * Convert to string. + */ + public function toString(): string + { + return '@' . strlen($this->string) . '@' . $this->string; + } + + public function __toString(): string + { + return $this->toString(); + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Syntax/Delimiter.php b/vendor/nemiah/php-fints/lib/Fhp/Syntax/Delimiter.php new file mode 100755 index 0000000..9ca2dbb --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Syntax/Delimiter.php @@ -0,0 +1,11 @@ +@` header within the string. + * + * @link: https://www.hbci-zka.de/dokumente/spezifikation_deutsch/fintsv3/FinTS_3.0_Formals_2017-10-06_final_version.pdf + * Section H.1.3 "Entwertung" + * + * @param string $delimiter The delimiter around which to split. + * @param string $str The raw string, usually a response from the server. + * @param bool $trailingDelimiter If this is true, the delimiter is expected at the very end, and also kept at the + * end of each returned substring, i.e. it's considered part of each item instead of a delimiter between items. + * @return string[] The splitted substrings. Note that escaped characters inside will still be escaped. + */ + public static function splitEscapedString(string $delimiter, string $str, bool $trailingDelimiter = false): array + { + if (strlen($str) === 0) { + return []; + } + // Since most of the $delimiters used in FinTs are also special characters in regexes, we need to escape. + $delimiter = preg_quote($delimiter, '/'); + $nextBegin = 0; + $offset = 0; + $result = []; + while (true) { + // Walk to the next syntax character of interest and handle it respectively. + $ret = preg_match("/\\?|@([0-9]+)@|$delimiter/", $str, $match, PREG_OFFSET_CAPTURE, $offset); + if ($ret === false) { + throw new \RuntimeException("preg_match failed on $str"); + } + if ($ret === 0) { // There is no more syntax character behind $offset. + if ($trailingDelimiter) { + // The last character should have been a delimiter, so there should be no content remaining. + if ($nextBegin !== strlen($str)) { + throw new \InvalidArgumentException( + 'Unexpected content after last delimiter: ' . substr($str, $nextBegin)); + } + } else { + // Anything behind the last delimiter forms the last substring. + $result[] = substr($str, $nextBegin); + } + break; + } + $matchedStr = $match[0][0]; // $match[0] refers to the entire matched string. [0] has the content + $matchedOffset = intval($match[0][1]); // and [1] has the offset within $str. + if ($matchedStr === '?') { + // It's an escape character, so we should ignore this character and the next one. + $offset = $matchedOffset + 2; + if ($offset > strlen($str)) { + throw new \InvalidArgumentException('Input ends on unescaped escape character.'); + } + } elseif ($matchedStr[0] === Delimiter::BINARY) { + // It's a block binary data, which we should skip entirely. + $binaryLength = $match[1][0]; // $match[1] refers to the first (and only) capture group in the regex. + if (!is_numeric($binaryLength)) { + throw new \AssertionError("Invalid binary length $binaryLength"); + } + // Note: The FinTS specification says that the length of the binary block is given in bytes (not + // characters) and PHP's string functions like substr() or preg_match() also operate on byte offsets, so + // this is fine. + $offset = $matchedOffset + strlen($matchedStr) + intval($binaryLength); + if ($offset > strlen($str)) { + throw new \InvalidArgumentException( + "Incomplete binary block at offset $matchedOffset, declared length $binaryLength, but " + . 'only has ' . (strlen($str) - $matchedOffset - strlen($matchedStr)) . ' bytes left'); + } + } else { + // The delimiter was matched, so output one splitted string and advance past the delimiter. + $result[] = substr($str, $nextBegin, $matchedOffset - $nextBegin + ($trailingDelimiter ? 1 : 0)); + $nextBegin = $matchedOffset + strlen($matchedStr); + $offset = $nextBegin; + } + } + return $result; + } + + /** + * @param string $str The raw string, usually a response from the server. + * @return string The string with the escaping removed. + */ + public static function unescape(string $str): string + { + return preg_replace('/\?([+:\'?@])/', '$1', $str); + } + + /** + * Parses a scalar value aka. "Datenelement" (DE). + * + * @link: https://www.hbci-zka.de/dokumente/spezifikation_deutsch/fintsv3/FinTS_3.0_Formals_2017-10-06_final_version.pdf + * Section B.4 Datenformate + * + * @param string $rawValue The raw value (wire format). + * @param string $type The PHP type that we need. This should support exactly the values for which + * {@link ElementDescriptor::isScalarType()} returns true. + * @return bool|float|int|string|null The parsed value of type $type, null if the $rawValue was empty. + */ + public static function parseDataElement(string $rawValue, string $type) + { + if ($rawValue === '') { + return null; + } + if ($type === 'int' || $type === 'integer') { + if (!is_numeric($rawValue)) { + throw new \InvalidArgumentException("Invalid int: $rawValue"); + } + return intval($rawValue); + } elseif ($type === 'float') { + $rawValue = str_replace(',', '.', $rawValue, $numCommas); + if (!is_numeric($rawValue) || $numCommas !== 1) { + throw new \InvalidArgumentException("Invalid float: $rawValue"); + } + return floatval($rawValue); + } elseif ($type === 'bool' || $type === 'boolean') { + if ($rawValue === 'J') { + return true; + } + if ($rawValue === 'N') { + return false; + } + throw new \InvalidArgumentException("Invalid bool: $rawValue"); + } elseif ($type === 'string') { + // Convert ISO-8859-1 (FinTS wire format encoding) to UTF-8 (PHP's encoding) + return mb_convert_encoding(static::unescape($rawValue), 'UTF-8', 'ISO-8859-1'); + } else { + throw new \RuntimeException("Unsupported type $type"); + } + } + + /** + * @param string $rawValue The raw value (wire format), e.g. "@4@abcd". + * @return Bin|null The parsed value, or null if $rawValue was empty. + */ + public static function parseBinaryBlock(string $rawValue): ?Bin + { + if ($rawValue === '') { + return null; + } + + $delimiterPos = strpos($rawValue, Delimiter::BINARY, 1); + if ( + substr($rawValue, 0, 1) !== Delimiter::BINARY || + $delimiterPos === false + ) { + throw new \InvalidArgumentException("Expected binary block header, got $rawValue"); + } + + $lengthStr = substr($rawValue, 1, $delimiterPos - 1); + if (!is_numeric($lengthStr)) { + throw new \InvalidArgumentException("Invalid binary block length: $lengthStr"); + } + + $length = intval($lengthStr); + $result = new Bin(substr($rawValue, $delimiterPos + 1)); + + $actualLength = strlen($result->getData()); + if ($actualLength !== $length) { + throw new \InvalidArgumentException("Expected binary block of length $length, got $actualLength"); + } + return $result; + } + + /** + * @param string $rawElements The serialized wire format for a data element group. + * @param string|BaseDeg $type The type (PHP class name) of the Deg to be parsed, or alternatively the instance to + * write to (the same instance will be returned from this function). + * @param bool $allowEmpty If true, this returns either a valid DEG, or null if *all* the fields were empty. + * @return BaseDeg|null The parsed value, of type $type, or null if all fields were empty and $allowEmpty is true. + */ + public static function parseDeg(string $rawElements, $type, bool $allowEmpty = false): ?BaseDeg + { + $rawElements = static::splitEscapedString(Delimiter::GROUP, $rawElements); + list($result, $offset) = static::parseDegElements($rawElements, $type, $allowEmpty); + if ($offset < count($rawElements)) { + throw new \InvalidArgumentException( + "Expected only $offset elements, but got " . count($rawElements) . ': ' . print_r($rawElements, true)); + } + return $result; + } + + /** + * @param string[] $rawElements The serialized wire format for a series of elements (already splitted). This array + * will be modified in that the elements that were consumed are removed from the beginning. + * @param string|BaseDeg $type The type (PHP class name) of the Deg to be parsed, or alternatively the instance to + * write to (the same instance will be returned from this function). + * @param bool $allowEmpty If true, this returns either a valid DEG, or null if *all* the fields were empty. + * @param int $offset The position in $rawElements to be read next. + * @return array (BaseDeg|null, integer) + * 1. The parsed value, which has the given $type or is null in case all the fields were empty and $allowEmpty + * is true. + * 2. The offset at which parsing should continue. The difference between this returned offset and the $offset + * that was passed in is the number of elements that this function call consumed. + */ + private static function parseDegElements(array $rawElements, $type, bool $allowEmpty = false, int $offset = 0): array + { + /** @var BaseDeg $result */ + $result = is_string($type) ? new $type() : $type; + $descriptor = $result->getDescriptor(); + $expectedIndex = 0; + $allEmpty = true; + $missingFieldError = null; // When $allowEmpty, we need to tolerate errors at first, but maybe throw them later. + // The iteration order guarantees that $index is strictly monotonically increasing, but there can be gaps. + foreach ($descriptor->elements as $index => $elementDescriptor) { + $offset += ($index - $expectedIndex); // Adjust for skipped indices. + $numRepetitions = $elementDescriptor->repeated === 0 ? 1 : $elementDescriptor->repeated; + $expectedIndex += $numRepetitions; // Advance to next expected elementDescriptor index. + $isSingleField = is_string($elementDescriptor->type) // Scalar type / DE + || $elementDescriptor->type->getName() === Bin::class; + + // Skip optional single elements that are not present. Note that for elements with multiple fields we cannot + // just skip because here we would only detect whether the first field is empty or not. + if ($isSingleField && (!array_key_exists($offset, $rawElements) || $rawElements[$offset] === '')) { + if ($elementDescriptor->optional) { + ++$offset; + continue; + } elseif ($missingFieldError === null) { + $missingFieldError = new \InvalidArgumentException( + "Missing field $descriptor->class.$elementDescriptor->field"); + if (!$allowEmpty) { + throw $missingFieldError; + } + } + } + + // Parse element (possibly multiple values recursively). + try { + for ($repetition = 0; $repetition < $numRepetitions; ++$repetition) { + if ($offset >= count($rawElements)) { + break; // End of input reached + } + if ($isSingleField) { + if ($rawElements[$offset] === '' && $repetition >= 1) { // Skip empty repeated entries. + ++$offset; + continue; + } + if (is_string($elementDescriptor->type)) { + $value = static::parseDataElement($rawElements[$offset], $elementDescriptor->type); + } else { + $value = static::parseBinaryBlock($rawElements[$offset]); + } + ++$offset; + } else { // Nested DEG, will consume a certain number of elements and adjust the $offset accordingly. + list($value, $offset) = static::parseDegElements( + $rawElements, $elementDescriptor->type->name, + $allowEmpty || $elementDescriptor->optional, $offset); + } + if ($value !== null) { + $allEmpty = false; + } + if ($elementDescriptor->repeated === 0) { + $result->{$elementDescriptor->field} = $value; + } elseif ($value !== null) { + $result->{$elementDescriptor->field}[] = $value; + } + } + } catch (\InvalidArgumentException $e) { + throw new \InvalidArgumentException("Failed to parse $descriptor->class::$elementDescriptor->field: $e"); + } + } + if ($allEmpty && $allowEmpty) { + return [null, $offset]; + } + if ($missingFieldError !== null) { + throw $missingFieldError; + } + return [$result, $offset]; + } + + /** + * Tries to (recursively) create an empty instance for a field with the given descriptor. For optional fields, this + * is simply null. If the field type is a subclass of {@link BaseDeg} and all fields in the DEG have valid empty + * values (recursively), then an empty instance will be returned. Otherwise, the value cannot be empty and this + * function returns false. + * + * @param ElementDescriptor $descriptor The descriptor of the field to fill in. + * @return BaseDeg|false|null A new empty instance of the field's value type, or null if that's a valid empty value + * for the field, or false if no empty value is possible, i.e. if there is at least one non-optional field + * within. + */ + private static function tryConstructEmptyValue(ElementDescriptor $descriptor) + { + if ($descriptor->optional) { + return null; // No need to fill optional fields. + } + if ($descriptor->repeated !== 0) { + return false; // Cannot fill a repeated field that requires at least one entry. + } + if (!($descriptor->type instanceof \ReflectionClass && $descriptor->type->isSubclassOf(BaseDeg::class))) { + return false; // Cannot create empty value for non-DEG field. + } + try { + /** @var BaseDeg $result */ + $result = $descriptor->type->newInstance(); + } catch (\ReflectionException $e) { + throw new \RuntimeException("Failed to create $descriptor->type", 0, $e); + } + foreach ($result->getDescriptor()->elements as $elementDescriptor) { + $emptyValue = static::tryConstructEmptyValue($elementDescriptor); + if ($emptyValue === false) { + return false; + } + $result->{$elementDescriptor->field} = $emptyValue; + } + return $result; + } + + /** + * @param string $rawSegment The serialized wire format for a single segment (segment delimiter must be present at + * the end). This should be ISO-8859-1-encoded. + * @param string|BaseSegment $type The type (PHP class name) of the segment to be parsed, or alternatively the + * instance to write to (the same instance will be returned from this function). + * @return BaseSegment The parsed segment of type $type. + */ + public static function parseSegment(string $rawSegment, $type): BaseSegment + { + /** @var BaseSegment $result */ + $result = is_string($type) ? new $type() : $type; + $rawElements = static::splitIntoSegmentElements($rawSegment); + $descriptor = $result->getDescriptor(); + if (array_key_last($rawElements) > $descriptor->maxIndex) { + throw new \InvalidArgumentException("Too many elements for $descriptor->class: $rawSegment"); + } + // The iteration order guarantees that $index is strictly monotonically increasing, but there can be gaps. + foreach ($descriptor->elements as $index => $elementDescriptor) { + if (!array_key_exists($index, $rawElements) || $rawElements[$index] === '') { + $emptyValue = static::tryConstructEmptyValue($elementDescriptor); + if ($emptyValue === false) { + throw new \InvalidArgumentException("Missing field $descriptor->class.$elementDescriptor->field"); + } + $result->{$elementDescriptor->field} = $emptyValue; + continue; + } + + // Note: The handling of empty values may be incorrect here, parseSegmentElement() can return null. + if ($elementDescriptor->repeated === 0) { + $result->{$elementDescriptor->field} = + static::parseSegmentElement($rawElements[$index], $elementDescriptor); + } else { + for ($repetition = 0; $repetition < $elementDescriptor->repeated; ++$repetition) { + if ($index + $repetition >= count($rawElements)) { + break; // End of input reached. + } + if ($rawElements[$index + $repetition] !== '') { // Skip empty entries. + $result->{$elementDescriptor->field}[$repetition] = + static::parseSegmentElement($rawElements[$index + $repetition], $elementDescriptor); + } + } + } + } + if ($result->segmentkopf->segmentkennung !== $descriptor->kennung) { + throw new \InvalidArgumentException( + "Invalid segment type $result->segmentkopf->segmentkennung for $descriptor->class"); + } + if ($result->segmentkopf->segmentversion !== $descriptor->version) { + throw new \InvalidArgumentException( + "Invalid version $result->segmentkopf->segmentversion for $descriptor->class"); + } + return $result; + } + + /** + * @param string $rawSegment The serialized wire format for a single segment (segment delimiter must be present at + * the end). + * @return AnonymousSegment The segment parsed as an anonymous segment. + */ + public static function parseAnonymousSegment(string $rawSegment): AnonymousSegment + { + $rawElements = static::splitIntoSegmentElements($rawSegment); + return new AnonymousSegment( + Segmentkopf::parse(array_shift($rawElements)), + array_map(function ($rawElement) { + if (strlen($rawElement) === 0) { + return null; + } + $subElements = static::splitEscapedString(Delimiter::GROUP, $rawElement); + if (count($subElements) <= 1) { + return $rawElement; + } // Asume it's not repeated. + return $subElements; + }, $rawElements)); + } + + /** + * @param string $rawSegment The serialized wire format for a single segment incl delimiter at the end. + * @return string[] The segment splitted into raw elements. + */ + private static function splitIntoSegmentElements(string $rawSegment): array + { + if (substr($rawSegment, -1) !== Delimiter::SEGMENT) { + throw new \InvalidArgumentException("Raw segment does not end with delimiter: $rawSegment"); + } + $rawSegment = substr($rawSegment, 0, -1); // Strip segment delimiter at the end. + $rawElements = static::splitEscapedString(Delimiter::ELEMENT, $rawSegment); + if (count($rawElements) === 0) { + throw new \InvalidArgumentException("Invalid segment: $rawSegment"); + } + return $rawElements; + } + + /** + * @param string $rawElement The raw content (unparsed wire format) of an element, which can either be a single + * Data Element (DE) or a group (DEG), as determined by the descriptor. + * @param ElementDescriptor $descriptor The descriptor that describes the expected format of the element. + * @return BaseDeg|Bin|bool|float|int|string|null The parsed value, or null if it was empty. + */ + private static function parseSegmentElement(string $rawElement, ElementDescriptor $descriptor) + { + if (is_string($descriptor->type)) { // Scalar value / DE + return static::parseDataElement($rawElement, $descriptor->type); + } elseif ($descriptor->type->getName() === Bin::class) { + return static::parseBinaryBlock($rawElement); + } else { + return static::parseDeg($rawElement, $descriptor->type->name, $descriptor->optional); + } + } + + /** + * @param string $rawSegment The serialized wire format for a single segment (segment delimiter must be present at + * the end). This should be ISO-8859-1-encoded. + * @return BaseSegment The parsed segment, possibly an {@link AnonymousSegment}. + */ + public static function detectAndParseSegment(string $rawSegment): BaseSegment + { + if (substr($rawSegment, -1) !== Delimiter::SEGMENT) { + throw new \InvalidArgumentException("Raw segment does not end with delimiter: $rawSegment"); + } + $firstElementDelimiter = strpos($rawSegment, Delimiter::ELEMENT); + if ($firstElementDelimiter === false) { + // Let's assume it's an empty segment, i.e. all of it is the header. + $firstElementDelimiter = strlen($rawSegment) - 1; // Exclude the SEGMENT delimiter at the end. + } + $segmentkopf = Segmentkopf::parse(substr($rawSegment, 0, $firstElementDelimiter)); + + // Try the default class name Fhp\Segment\HABCD\HABCDvN. + $segmentType = static::SEGMENT_NAMESPACE . '\\' . $segmentkopf->segmentkennung . '\\' + . $segmentkopf->segmentkennung . 'v' . $segmentkopf->segmentversion; + if (class_exists($segmentType)) { + return static::parseSegment($rawSegment, $segmentType); + } + + // Alternatively, allow Geschäftsvorfall segments (HKXYZ, HIXYZ and HIXYZS) to live in an abbreviated namespace, + // i.e. like Fhp\Segment\XYZ\HKXYZSvN + $segmentType = static::SEGMENT_NAMESPACE . '\\' . substr($segmentkopf->segmentkennung, 2, 3) . '\\' + . $segmentkopf->segmentkennung . 'v' . $segmentkopf->segmentversion; + if (class_exists($segmentType)) { + return static::parseSegment($rawSegment, $segmentType); + } + + // If the segment type is not implemented, fall back to an anonymous segment. + return static::parseAnonymousSegment($rawSegment); + } + + /** + * @param string $rawSegments Concatenated segments in wire format. + * @return BaseSegment[] The parsed segments. + */ + public static function parseSegments(string $rawSegments): array + { + if (strlen($rawSegments) === 0) { + return []; + } + $rawSegments = static::splitEscapedString(Delimiter::SEGMENT, $rawSegments, true); + return array_map([static::class, 'detectAndParseSegment'], $rawSegments); + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/Syntax/Serializer.php b/vendor/nemiah/php-fints/lib/Fhp/Syntax/Serializer.php new file mode 100755 index 0000000..a5d1430 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/Syntax/Serializer.php @@ -0,0 +1,152 @@ +getDescriptor()); + return implode(Delimiter::ELEMENT, static::flattenAndTrimEnd($serializedElements)) . Delimiter::SEGMENT; + } + + /** + * @param BaseSegment|BaseDeg|null $obj An object to be serialized. If null, all fields are implicitly null. + * @param BaseDescriptor $descriptor The descriptor for the object to be serialized. + * @return array A partial serialization of that object, namely a (possibly nested) array with all of its elements + * serialized independently, and at the right indices. In order to put subsequent elements in the right + * position, the returned array may contain emtpy strings as gaps/buffers in the middle (for subsequent elements + * in $obj) and/or at the end (for subsequent elements added by the caller for data following $obj). + */ + private static function serializeElements($obj, BaseDescriptor $descriptor): array + { + $isSegment = $descriptor instanceof SegmentDescriptor; + $serializedElements = []; + $lastKey = array_key_last($descriptor->elements); + for ($index = 0; $index <= $lastKey; ++$index) { + if (!array_key_exists($index, $descriptor->elements)) { + $serializedElements[$index] = ''; + continue; + } + $elementDescriptor = $descriptor->elements[$index]; + $value = $obj === null ? null : $obj->{$elementDescriptor->field}; + if (array_key_exists($index, $serializedElements)) { + throw new \AssertionError("Duplicate index $index"); + } + if ($elementDescriptor->repeated === 0) { + $serializedElements[$index] = static::serializeElement($value, $elementDescriptor->type, $isSegment); + } else { + if ($value !== null && !is_array($value)) { + throw new \InvalidArgumentException( + "Expected array value for $descriptor->class.$elementDescriptor->field, got: $value"); + } + for ($repetition = 0; $repetition < $elementDescriptor->repeated; ++$repetition) { + $serializedElements[$index + $repetition] = static::serializeElement( + $value === null || $repetition >= count($value) ? null : $value[$repetition], + $elementDescriptor->type, $isSegment); + } + } + } + return $serializedElements; + } + + /** + * @param mixed|null $value The value to be serialized. + * @param string|\ReflectionClass $type The type of the value. + * @param bool $fullySerialize If true, the result is always a string, complex values are imploded as a DEG. + * @return string|array The serialized value. In case $type is a complex type and $fullySerialize is false, this + * returns a (possibly nested) array of strings. + */ + private static function serializeElement($value, $type, bool $fullySerialize) + { + if (is_string($type)) { + return static::serializeDataElement($value, $type); + } elseif ($type->getName() === Bin::class) { + /* @var Bin|null $value */ + return $value === null ? '' : $value->toString(); + } elseif ($fullySerialize) { + return static::serializeDeg($value, DegDescriptor::get($type->name)); + } else { + return static::serializeElements($value, DegDescriptor::get($type->name)); + } + } + + /** + * @param array $elements A possibly nested array of string values. + * @return string[] A flat array with the same string values (using in-order tree traversal), but empty values + * removed from the end. + */ + private static function flattenAndTrimEnd(array $elements): array + { + $result = []; + $nonemptyLength = 0; + foreach (new \RecursiveIteratorIterator(new \RecursiveArrayIterator($elements)) as $element) { + $result[] = $element; + if ($element !== '') { + $nonemptyLength = count($result); + } + } + return array_slice($result, 0, $nonemptyLength); + } +} diff --git a/vendor/nemiah/php-fints/lib/Fhp/UnsupportedException.php b/vendor/nemiah/php-fints/lib/Fhp/UnsupportedException.php new file mode 100755 index 0000000..9c1ca4d --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Fhp/UnsupportedException.php @@ -0,0 +1,14 @@ +dialogId; + } +} diff --git a/vendor/nemiah/php-fints/lib/Tests/Fhp/FinTsTestCase.php b/vendor/nemiah/php-fints/lib/Tests/Fhp/FinTsTestCase.php new file mode 100755 index 0000000..b09041a --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Tests/Fhp/FinTsTestCase.php @@ -0,0 +1,134 @@ +getFunctionMock('Fhp\Protocol', 'rand'); + $randMock->expects($this->any())->with(1000000, 9999999)->willReturn(9999999); + // We mock time() for the timestamps in the encryption/signature headers in SicherheitsdatumUndUhrzeitV2.php. + $this->now = new \DateTime('2019-01-02 03:04:05', new \DateTimeZone('UTC')); + $timeMock = $this->getFunctionMock('Fhp\Segment\HNVSK', 'time'); + $timeMock->expects($this->any())->with()->willReturn($this->now->getTimestamp()); + + $this->options = new FinTsOptions(); + $this->options->url = static::TEST_URL; + $this->options->productName = static::TEST_PRODUCT_NAME; + $this->options->productVersion = static::TEST_PRODUCT_VERSION; + $this->options->bankCode = static::TEST_BANK_CODE; + $this->credentials = Credentials::create(static::TEST_USERNAME, static::TEST_PIN); + FinTsPeer::$mockConnection = $this->setUpConnection(); + $this->fints = new FinTsPeer($this->options, $this->credentials); + } + + protected function setUpConnection() + { + $this->connection = $this->createMock(Connection::class); + $this->connection->expects($this->any())->method('send')->willReturnCallback(function ($request) { + // Check that the request itself is valid wrt. to the length declared in its header. + if (preg_match('/^HNHBK:\\d+:\\d+\\+(\\d+)/', $request, $lengthMatch) === 1) { + $expectedLength = intval($lengthMatch[1]); + $this->assertSame($expectedLength, strlen($request), $request); + } + + // Grab the next expected request and its mock response. + $this->assertNotEmpty($this->expectedMessages, "Expected no more requests, but got: $request"); + list($expectedRequest, $mockResponse) = array_shift($this->expectedMessages); + + // Check that the request matches the expectation. + if (strlen($expectedRequest) > 0 && !str_starts_with($expectedRequest, 'HNHBK')) { + // The expected request is just the inner part, so we need to unwrap the actual request. This is done in + // in a quick and hacky way, which slices everything from HNSHK's terminating delimiter to the start of + // HNSHA. + $this->assertEquals(1, preg_match("/HNSHK.*?'(.*?')HNSHA:/s", $request, $match), "For request: $request"); + $request = $match[1]; + } + $this->assertEquals($expectedRequest, $request); + + // Send the mock response. + if (!str_starts_with($mockResponse, 'HNHBK')) { + // The mock response is just the inner part, so we need to wrap it in a fake envelope. + $mockPrefix = 'HNHBK:1:3+'; + // Note: The 4242 is the message number. It's garbage and a constant, but the SUT does not verify it. + $mockMiddle = "+300+FAKEDIALOGIDabcdefghijklmnopqr+4242+FAKEDIALOGIDabcdefghijklmnopqr:2'HNVSK:998:3+PIN:2+998+1+2::" + . static::TEST_KUNDENSYSTEM_ID . "+1:20190102:030405+2:2:13:@8@00000000:5:1+280:11223344:test?@user:V:0:0+0'"; + $hnvsdContent = 'HNSHK:2:4+PIN:2+' . static::TEST_TAN_MODE . '+9999999+1+1+2::' + . static::TEST_KUNDENSYSTEM_ID + . "+1+1:20190102:030405+1:999:1+6:10:19+280:11223344:test?@user:S:0:0'" + . $mockResponse . "HNSHA:10:2+9999999'"; + $hnvsd = 'HNVSD:999:1+@' . strlen($hnvsdContent) . '@' . $hnvsdContent . "'"; + $mockSuffix = "HNHBS:5:1+2'"; + $newLength = strlen($mockPrefix) + HNHBKv3::NACHRICHTENGROESSE_LENGTH + + strlen($mockMiddle) + strlen($hnvsd) + strlen($mockSuffix); + $newLength = str_pad($newLength, HNHBKv3::NACHRICHTENGROESSE_LENGTH, '0', STR_PAD_LEFT); + return $mockPrefix . $newLength . $mockMiddle . $hnvsd . $mockSuffix; + } else { + return $mockResponse; + } + }); + return $this->connection; + } + + protected function tearDown(): void + { + $this->assertAllMessagesSeen(); + FinTsPeer::$mockConnection = null; + } + + /** + * @param string $request Can be a full request (starting with HNHBK) or just the inner part of HNVSD, more + * precisely the slice *between* the HNSHK and HNSHA segments. Note that the latter forms a weaker expectation, + * as the SUT could be sending a wrong wrapper and we wouldn't notice. + * @param string $response Can be a full response (starting with HNHBK) or just the inner part of HNVSD, more + * precisely the slice *between* the HNSHK and HNSHA segments. + */ + protected function expectMessage(string $request, string $response) + { + $this->expectedMessages[] = [$request, $response]; + } + + protected function assertAllMessagesSeen() + { + $this->assertEmpty($this->expectedMessages, 'Expected requests were not received'); + } +} diff --git a/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/Consors/ConsorsIntegrationTestBase.php b/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/Consors/ConsorsIntegrationTestBase.php new file mode 100755 index 0000000..c47db05 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/Consors/ConsorsIntegrationTestBase.php @@ -0,0 +1,89 @@ +expectMessage(static::ANONYMOUS_INIT_REQUEST, static::ANONYMOUS_INIT_RESPONSE); + $this->expectMessage(static::ANONYMOUS_END_REQUEST, static::ANONYMOUS_END_RESPONSE); + + // Then when we initialize a dialog, it's going to request a Kundensystem-ID and UPD. + $this->expectMessage(static::SYNC_REQUEST, static::SYNC_RESPONSE); + $this->expectMessage(static::SYNC_END_REQUEST, static::SYNC_END_RESPONSE); + + // And finally it can initialize the main dialog, but the bank wants a TAN. + $this->expectMessage(static::LOGIN_REQUEST, static::LOGIN_RESPONSE); + + $this->fints->selectTanMode(intval(static::TEST_TAN_MODE)); + $login = $this->fints->login(); + $this->assertAllMessagesSeen(); + + $this->assertTrue($login->needsTan(), 'Expected a TAN request, but got none.'); + $tanRequest = $login->getTanRequest(); + $this->assertNotNull($tanRequest); + $this->assertEquals('000003QS34CK6EMOUGT3JJOI834L7Kvb', $tanRequest->getProcessId()); + $this->assertEquals('Bitte TAN eingeben.', $tanRequest->getChallenge()); + + // Pretend that we close everything and open everything from scratch, as if it were a new PHP process. + $persistedInstance = $this->fints->persist(); + $persistedLogin = serialize($login); + $this->connection->expects($this->once())->method('disconnect'); + $this->fints = new FinTsPeer($this->options, $this->credentials); + $this->fints->loadPersistedInstance($persistedInstance); + + // Now provide the TAN. + $this->expectMessage(static::LOGIN_TAN_REQUEST, static::LOGIN_TAN_RESPONSE); + $login = unserialize($persistedLogin); + $this->fints->submitTan($login, '98765432'); + } + + protected function getTestAccount(): SEPAAccount + { + $sepaAccount = new SEPAAccount(); + $sepaAccount->setIban('DExxABCDEFGH1234567890'); + $sepaAccount->setBic('CSDBDE71XXX'); + $sepaAccount->setAccountNumber('1234567890'); + $sepaAccount->setBlz('50220500'); + return $sepaAccount; + } +} diff --git a/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/Consors/GetBPDTest.php b/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/Consors/GetBPDTest.php new file mode 100755 index 0000000..ffd2e40 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/Consors/GetBPDTest.php @@ -0,0 +1,24 @@ +expectMessage(static::ANONYMOUS_INIT_REQUEST, static::ANONYMOUS_INIT_RESPONSE); + $this->expectMessage(static::ANONYMOUS_END_REQUEST, static::ANONYMOUS_END_RESPONSE); + + $bpd = FinTsPeer::fetchBpd($this->options); + + $this->assertEquals('Consors', $bpd->hibpa->kreditinstitutsbezeichnung); + $this->assertTrue($bpd->supportsPsd2()); + $this->assertTrue($bpd->supportsParameters('HIKAZS', 6)); + } +} diff --git a/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/Consors/GetSEPAAccountsTest.php b/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/Consors/GetSEPAAccountsTest.php new file mode 100755 index 0000000..396eb01 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/Consors/GetSEPAAccountsTest.php @@ -0,0 +1,30 @@ +initDialog(); + + $this->expectMessage(static::GET_ACCOUNTS_REQUEST, static::GET_ACCOUNTS_RESPONSE); + $getAccounts = new \Fhp\Action\GetSEPAAccounts(); + $this->fints->execute($getAccounts); + $accounts = $getAccounts->getAccounts(); + + $this->assertCount(4, $accounts); + $account = $accounts[0]; + $this->assertEquals('DExxABCDEFGH0123456789', $account->getIban()); + $this->assertEquals('CSDBDE71XXX', $account->getBic()); + $this->assertEquals('123456789', $account->getAccountNumber()); + $this->assertEmpty($account->getSubAccount()); + $this->assertEquals('ABCDEFGH', $account->getBlz()); + } +} diff --git a/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/Consors/GetStatementOfAccountTest.php b/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/Consors/GetStatementOfAccountTest.php new file mode 100755 index 0000000..466f554 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/Consors/GetStatementOfAccountTest.php @@ -0,0 +1,92 @@ +initDialog(); + + $this->expectMessage(static::GET_STATEMENT_REQUEST, static::GET_STATEMENT_RESPONSE); + $getStatement = \Fhp\Action\GetStatementOfAccount::create( + $this->getTestAccount(), + new \DateTime('2019-06-01'), new \DateTime('2019-09-22') + ); + $this->fints->execute($getStatement); + $statement = $getStatement->getStatement(); + + $this->assertCount(2, $statement->getStatements()); + + $statement1 = $statement->getStatements()[0]; + $this->assertEquals(new \DateTime('2019-11-18'), $statement1->getDate()); + $this->assertEquals(Statement::CD_CREDIT, $statement1->getCreditDebit()); + $this->assertEqualsWithDelta(950.59, $statement1->getStartBalance(), 0.01); + $this->assertCount(1, $statement1->getTransactions()); + $transaction1 = $statement1->getTransactions()[0]; + $this->assertEquals(new \DateTime('2019-11-18'), $transaction1->getValutaDate()); + $this->assertEquals(new \DateTime('2019-11-18'), $transaction1->getBookingDate()); + $this->assertEquals(Statement::CD_DEBIT, $transaction1->getCreditDebit()); + $this->assertEqualsWithDelta(2.80, $transaction1->getAmount(), 0.01); + $this->assertEquals('XY', $transaction1->getMainDescription()); + $this->assertEquals('NONREF', $transaction1->getStructuredDescription()['KREF']); + $this->assertEquals('Max Mustermannig', $transaction1->getName()); + + $statement2 = $statement->getStatements()[1]; + $this->assertEquals(new \DateTime('2019-11-20'), $statement2->getDate()); + $this->assertEquals(Statement::CD_CREDIT, $statement2->getCreditDebit()); + $this->assertEqualsWithDelta(950.59 - 2.80, $statement2->getStartBalance(), 0.01); + $this->assertCount(2, $statement2->getTransactions()); + $transaction2 = $statement2->getTransactions()[0]; + $this->assertEquals(new \DateTime('2019-11-20'), $transaction2->getValutaDate()); + $this->assertEquals(new \DateTime('2019-11-20'), $transaction2->getBookingDate()); + $this->assertEquals(Statement::CD_DEBIT, $transaction2->getCreditDebit()); + $this->assertEqualsWithDelta(11.30, $transaction2->getAmount(), 0.01); + $this->assertEquals('Lastschrift (Einzugsermächtigung)', $transaction2->getBookingText()); + $this->assertEquals('ZAA0987654321', $transaction2->getStructuredDescription()['EREF']); + $this->assertEquals('LOGPAY FINANCIAL SERVICES GMBH', $transaction2->getName()); + $transaction3 = $statement2->getTransactions()[1]; + $this->assertEqualsWithDelta(15.50, $transaction3->getAmount(), 0.01); + $this->assertEquals('ZAA0123456789', $transaction3->getStructuredDescription()['EREF']); + } + + /** + * @throws \Throwable + */ + public function testGetStatementOfAccountEmpty() + { + $this->initDialog(); + + $this->expectMessage(static::GET_STATEMENT_REQUEST, static::GET_STATEMENT_EMPTY_RESPONSE); + $getStatement = \Fhp\Action\GetStatementOfAccount::create( + $this->getTestAccount(), + new \DateTime('2019-06-01'), new \DateTime('2019-09-22') + ); + $this->fints->execute($getStatement); + $statement = $getStatement->getStatement(); + + $this->assertEmpty($statement->getStatements()); + } +} diff --git a/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/Consors/InitEndDialogTest.php b/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/Consors/InitEndDialogTest.php new file mode 100755 index 0000000..867bcf8 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/Consors/InitEndDialogTest.php @@ -0,0 +1,49 @@ +expectMessage(static::ANONYMOUS_INIT_REQUEST, static::ANONYMOUS_INIT_RESPONSE); + $this->expectMessage(static::ANONYMOUS_END_REQUEST, static::ANONYMOUS_END_RESPONSE); + // And then it should use a personal dialog with Sicherheitsfunktion=999 to retrieve the allowed modes (3920). + $this->expectMessage(static::SYNC_WEAK_REQUEST, static::SYNC_WEAK_RESPONSE); + $this->expectMessage(static::SYNC_WEAK_END_REQUEST, static::SYNC_WEAK_END_RESPONSE); + + $tanModes = $this->fints->getTanModes(); + $this->assertAllMessagesSeen(); + + $this->assertArrayHasKey(900, $tanModes); + $tanMode = $tanModes[900]; + $this->assertEquals('SecurePlus', $tanMode->getName()); + $this->assertFalse($tanMode->needsTanMedium()); + $this->fints->selectTanMode($tanMode); + } + + /** + * @throws \Throwable + */ + public function testInitAndEndDialog() + { + $this->initDialog(); + $this->assertNotNull($this->fints->getDialogId()); + $this->expectMessage(static::FINAL_END_REQUEST, static::FINAL_END_RESPONSE); + $this->fints->endDialog(); + } +} diff --git a/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/DKB/DKBIntegrationTestBase.php b/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/DKB/DKBIntegrationTestBase.php new file mode 100755 index 0000000..6efe612 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/DKB/DKBIntegrationTestBase.php @@ -0,0 +1,65 @@ +expectMessage(static::ANONYMOUS_INIT_REQUEST, static::ANONYMOUS_INIT_RESPONSE); + $this->expectMessage(static::ANONYMOUS_END_REQUEST, static::ANONYMOUS_END_RESPONSE); + + // Then when we initialize a dialog, it's going to request a Kundensystem-ID and UPD. + $this->expectMessage(static::SYNC_REQUEST, static::SYNC_RESPONSE); + $this->expectMessage(static::SYNC_END_REQUEST, static::SYNC_END_RESPONSE); + // And finally it can initialize the main dialog. + $this->expectMessage(static::INIT_REQUEST, static::INIT_RESPONSE); + + $this->fints->selectTanMode(intval(static::TEST_TAN_MODE), 'SomePhone1'); + $login = $this->fints->login(); + $login->ensureDone(); // No TAN required upon login. + $this->assertAllMessagesSeen(); + } + + protected function getTestAccount(): SEPAAccount + { + $sepaAccount = new SEPAAccount(); + $sepaAccount->setIban('DExxABCDEFGH1234567890'); + $sepaAccount->setBic('BYLADEM1001'); + $sepaAccount->setAccountNumber('1234567890'); + $sepaAccount->setBlz('12030000'); + return $sepaAccount; + } +} diff --git a/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/DKB/GetBalanceTest.php b/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/DKB/GetBalanceTest.php new file mode 100755 index 0000000..a903a31 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/DKB/GetBalanceTest.php @@ -0,0 +1,34 @@ +initDialog(); + + $this->expectMessage(static::GET_BALANCE_REQUEST, static::GET_BALANCE_RESPONSE); + $getBalance = GetBalance::create($this->getTestAccount(), true); + $this->fints->execute($getBalance); + $this->assertFalse($getBalance->needsTan()); + $balances = $getBalance->getBalances(); + + $this->assertCount(1, $balances); + $balance = $balances[0]; + $this->assertEquals('1234567890', $balance->getAccountInfo()->getAccountNumber()); + $this->assertEquals('12030000', $balance->getAccountInfo()->getBankIdentifier()); + $this->assertEquals('Sichteinlagen', $balance->getKontoproduktbezeichnung()); + $this->assertEqualsWithDelta(+123.45, $balance->getGebuchterSaldo()->getAmount(), 0.01); + $this->assertEquals(new \DateTime('2020-04-09T00:00:00'), $balance->getGebuchterSaldo()->getTimestamp()); + $this->assertEqualsWithDelta(0, $balance->getSaldoDerVorgemerktenUmsaetze()->getAmount(), 0.01); + } +} diff --git a/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/DKB/GetSEPAAccountsTest.php b/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/DKB/GetSEPAAccountsTest.php new file mode 100755 index 0000000..6cac0fb --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/DKB/GetSEPAAccountsTest.php @@ -0,0 +1,30 @@ +initDialog(); + + $this->expectMessage(static::GET_ACCOUNTS_REQUEST, static::GET_ACCOUNTS_RESPONSE); + $getAccounts = new \Fhp\Action\GetSEPAAccounts(); + $this->fints->execute($getAccounts); + $accounts = $getAccounts->getAccounts(); + + $this->assertCount(1, $accounts); + $account = $accounts[0]; + $this->assertEquals('DExxABCDEFGH1234567890', $account->getIban()); + $this->assertEquals('BYLADEM1001', $account->getBic()); + $this->assertEquals('1234567890', $account->getAccountNumber()); + $this->assertEmpty($account->getSubAccount()); + $this->assertEquals('ABCDEFGH', $account->getBlz()); + } +} diff --git a/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/DKB/GetStatementOfAccountTest.php b/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/DKB/GetStatementOfAccountTest.php new file mode 100755 index 0000000..ebd0c17 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/DKB/GetStatementOfAccountTest.php @@ -0,0 +1,168 @@ +getTestAccount(), + new \DateTime('2019-09-01'), new \DateTime('2019-09-22')); + $this->fints->execute($getStatement); + return $getStatement; + } + + /** + * @throws \Throwable + */ + public function testSimple() + { + $this->initDialog(); + $this->expectMessage(static::GET_STATEMENT_REQUEST, static::GET_STATEMENT_RESPONSE . static::getHikazContent() . "'"); + $getStatement = $this->runInitialRequest(); + $this->assertFalse($getStatement->needsTan()); + $this->checkResult($getStatement->getStatement()); + } + + /** + * @throws \Throwable + */ + private function completeWithTan(GetStatementOfAccount $getStatement) + { + $this->expectMessage(static::SEND_TAN_REQUEST, static::SEND_TAN_RESPONSE . static::getHikazContent() . "'"); + $this->fints->submitTan($getStatement, '777666'); + $this->assertFalse($getStatement->needsTan()); + $this->checkResult($getStatement->getStatement()); + } + + /** + * @throws \Throwable + */ + public function testWithTan() + { + $this->initDialog(); + + $this->expectMessage(static::GET_STATEMENT_REQUEST, static::GET_STATEMENT_RESPONSE_BEFORE_TAN); + $getStatement = $this->runInitialRequest(); + $this->assertTrue($getStatement->needsTan()); + $this->completeWithTan($getStatement); + } + + /** + * @throws \Throwable + */ + public function testWithTanPersist() + { + $this->initDialog(); + + $this->expectMessage(static::GET_STATEMENT_REQUEST, static::GET_STATEMENT_RESPONSE_BEFORE_TAN); + $getStatement = $this->runInitialRequest(); + $this->assertTrue($getStatement->needsTan()); + + // Pretend that we close everything and open everything from scratch, as if it were a new PHP process. + $persistedInstance = $this->fints->persist(); + $persistedGetStatement = serialize($getStatement); + $this->connection->expects($this->once())->method('disconnect'); + $this->fints = new FinTsPeer($this->options, $this->credentials); + $this->fints->loadPersistedInstance($persistedInstance); + /** @var GetStatementOfAccount $getStatement */ + $getStatement = unserialize($persistedGetStatement); + + $this->completeWithTan($getStatement); + } + + /** + * @throws \Throwable + */ + public function testPaginated() + { + $this->initDialog(); + $this->expectMessage(static::GET_STATEMENT_REQUEST, static::GET_STATEMENT_PAGE_1_RESPONSE . static::getHikazContentPage1() . "'"); + $this->expectMessage(static::GET_STATEMENT_PAGE_2_REQUEST, static::GET_STATEMENT_PAGE_2_RESPONSE . static::getHikazContentPage2() . "'"); + $getStatement = $this->runInitialRequest(); + $this->assertFalse($getStatement->needsTan()); + $this->checkResult($getStatement->getStatement()); + } + + /** + * @throws \Throwable + */ + private function checkResult(StatementOfAccount $statement) + { + $this->assertCount(2, $statement->getStatements()); + + $statement1 = $statement->getStatements()[0]; + $this->assertEquals(new \DateTime('2019-08-21'), $statement1->getDate()); + $this->assertEquals(Statement::CD_CREDIT, $statement1->getCreditDebit()); + $this->assertEqualsWithDelta(1234.56, $statement1->getStartBalance(), 0.01); + $this->assertCount(1, $statement1->getTransactions()); + $transaction1 = $statement1->getTransactions()[0]; + $this->assertEquals(new \DateTime('2019-09-03'), $transaction1->getValutaDate()); + $this->assertEquals(new \DateTime('2019-09-04'), $transaction1->getBookingDate()); + $this->assertEquals(Statement::CD_DEBIT, $transaction1->getCreditDebit()); + $this->assertEqualsWithDelta(12.00, $transaction1->getAmount(), 0.01); + $this->assertEquals('32301000-P111111-33333333 DATUM 02.09.2019, 22.19 UHR1.TAN 012345', $transaction1->getMainDescription()); + $this->assertEquals('HKCCS12345', $transaction1->getStructuredDescription()['KREF']); + $this->assertEquals('EMPFAENGER ABCDE', $transaction1->getName()); + + $statement2 = $statement->getStatements()[1]; + $this->assertEquals(new \DateTime('2019-09-03'), $statement2->getDate()); + $this->assertEquals(Statement::CD_CREDIT, $statement2->getCreditDebit()); + $this->assertEqualsWithDelta(1234.56 - 12.00, $statement2->getStartBalance(), 0.01); + $this->assertCount(1, $statement2->getTransactions()); + $transaction2 = $statement2->getTransactions()[0]; + $this->assertEquals(new \DateTime('2019-09-13'), $transaction2->getValutaDate()); + $this->assertEquals(new \DateTime('2019-09-14'), $transaction2->getBookingDate()); + $this->assertEquals(Statement::CD_CREDIT, $transaction2->getCreditDebit()); + $this->assertEqualsWithDelta(123.45, $transaction2->getAmount(), 0.01); + $this->assertEquals(['SVWZ' => 'Irgendein Käse'], $transaction2->getStructuredDescription()); + $this->assertEquals('Sender Name1', $transaction2->getName()); + } +} diff --git a/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/DKB/InitEndDialogTest.php b/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/DKB/InitEndDialogTest.php new file mode 100755 index 0000000..192383c --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/DKB/InitEndDialogTest.php @@ -0,0 +1,63 @@ +expectMessage(static::ANONYMOUS_INIT_REQUEST, static::ANONYMOUS_INIT_RESPONSE); + $this->expectMessage(static::ANONYMOUS_END_REQUEST, static::ANONYMOUS_END_RESPONSE); + // And then it should use a personal dialog with Sicherheitsfunktion=999 to retrieve the allowed modes (3920). + $this->expectMessage(static::SYNC_WEAK_REQUEST, static::SYNC_WEAK_RESPONSE); + $this->expectMessage(static::SYNC_WEAK_END_REQUEST, static::SYNC_WEAK_END_RESPONSE); + + $tanModes = $this->fints->getTanModes(); + $this->assertArrayHasKey(921, $tanModes); + $tanMode = $tanModes[921]; + $this->assertEquals('TAN2go', $tanMode->getName()); + $this->assertTrue($tanMode->needsTanMedium()); + + // Now we want the TAN media for this TAN modes. That requires a separate dialog just for HKTAB. + $this->expectMessage(static::HKTAB_INIT_REQUEST, static::HKTAB_INIT_RESPONSE); + $this->expectMessage(static::HKTAB_REQUEST, static::HKTAB_RESPONSE); + $this->expectMessage(static::HKTAB_END_REQUEST, static::HKTAB_END_RESPONSE); + + $tanMedia = $this->fints->getTanMedia(921); + $this->assertCount(2, $tanMedia); + $this->assertEquals('pushtan', $tanMedia[0]->getName()); + $this->assertEquals('SomePhone1', $tanMedia[1]->getName()); + + $this->fints->selectTanMode($tanMode, 'SomePhone1'); + } + + /** + * @throws \Throwable + */ + public function testInitAndEndDialog() + { + $this->initDialog(); + $this->assertNotNull($this->fints->getDialogId()); + $this->expectMessage(static::FINAL_END_REQUEST, static::FINAL_END_RESPONSE); + $this->fints->endDialog(); + } +} diff --git a/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/DKB/SendSEPATransferTest.php b/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/DKB/SendSEPATransferTest.php new file mode 100755 index 0000000..b409aee --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/DKB/SendSEPATransferTest.php @@ -0,0 +1,149 @@ + + + + + + 1575749897 + 2019-12-07T20:18:17 + 1 + 42.42 + + Dagobert Duck + + + + 1575749897 + TRF + 1 + 42.42 + + + SEPA + + + 1999-01-01 + + Philipp Keck + + + + DE42000000001234567890 + + + + + BYLADEM1001 + + + SLEV + + + NOTPROVIDED + + + 42.42 + + + + CSDBDE71XXX + + + + Donald Duck + + + + DE43987654321000000000 + + + + FinTS-Test-Transfer + + + + + '; + + // Transfer request (HKCCS) is below, the rest of the dialog is here. + public const SEND_TRANSFER_RESPONSE = "HIRMG:3:2+0010::Nachricht entgegengenommen.'HIRMS:4:2:4+0030::Auftrag empfangen - Bitte die empfangene TAN eingeben.(MBT62820200002)'HITAN:5:6:4+4++2472-12-07-21.27.57.456789+Bitte geben Sie die pushTAN ein.+++SomePhone1'"; + public const SEND_TAN_REQUEST = "HNHBK:1:3+000000000425+300+FAKEDIALOGIDabcdefghijklmnopqr+3'HNVSK:998:3+PIN:2+998+1+1::FAKEKUNDENSYSTEMIDabcdefghij+1:20190102:030405+2:2:13:@8@00000000:5:1+280:12030000:test?@user:V:0:0+0'HNVSD:999:1+@206@HNSHK:2:4+PIN:2+921+9999999+1+1+1::FAKEKUNDENSYSTEMIDabcdefghij+1+1:20190102:030405+1:999:1+6:10:19+280:12030000:test?@user:S:0:0'HKTAN:3:6+2++++2472-12-07-21.27.57.456789+N'HNSHA:4:2+9999999++12345:666555''HNHBS:5:1+3'"; + public const SEND_TAN_RESPONSE = "HIRMG:3:2+0010::Nachricht entgegengenommen.'HIRMS:4:2:3+0010::Der Auftrag wurde entgegengenommen.'HITAN:5:6:3+2++2472-12-07-21.27.57.456789'"; + + private function getSendTransferRequest(): string + { + // Note: strlen() is computed instead of hard-coded because it depends on the indentation in this file, which + // may be changed by linters and other tools, and because it contains line breaks, which are different depending + // the platform where this test runs. + return 'HKCCS:3:1+DExxABCDEFGH1234567890:BYLADEM1001:1234567890::280:12030000+urn?:iso?:std?:iso?:20022?:tech?:xsd?:pain.001.003.03+@' + . strlen(self::PAIN_MESSAGE) . '@' . self::PAIN_MESSAGE . "'HKTAN:4:6+4+HKCCS+++++++++SomePhone1'"; + } + + /** + * @throws \Throwable + */ + private function runInitialRequest(): SendSEPATransfer + { + $sendTransfer = SendSEPATransfer::create($this->getTestAccount(), static::PAIN_MESSAGE); + $this->fints->execute($sendTransfer); + return $sendTransfer; + } + + /** + * @throws \Throwable + */ + private function completeWithTan(SendSEPATransfer $sendTransfer) + { + $this->expectMessage(static::SEND_TAN_REQUEST, static::SEND_TAN_RESPONSE); + $this->fints->submitTan($sendTransfer, '666555'); + $this->assertFalse($sendTransfer->needsTan()); + $this->assertTrue($sendTransfer->isDone()); + } + + /** + * @throws \Throwable + */ + public function testSendSEPATransfer() + { + $this->initDialog(); + + $this->expectMessage($this->getSendTransferRequest(), static::SEND_TRANSFER_RESPONSE); + $getStatement = $this->runInitialRequest(); + $this->assertTrue($getStatement->needsTan()); + $this->completeWithTan($getStatement); + } + + /** + * @throws \Throwable + */ + public function testSendSEPATransferPersist() + { + $this->initDialog(); + + $this->expectMessage($this->getSendTransferRequest(), static::SEND_TRANSFER_RESPONSE); + $sendTransfer = $this->runInitialRequest(); + $this->assertTrue($sendTransfer->needsTan()); + + // Pretend that we close everything and open everything from scratch, as if it were a new PHP process. + $persistedInstance = $this->fints->persist(); + $persistedAction = serialize($sendTransfer); + $this->connection->expects($this->once())->method('disconnect'); + $this->fints = new FinTsPeer($this->options, $this->credentials); + $this->fints->loadPersistedInstance($persistedInstance); + /** @var SendSEPATransfer $sendTransfer */ + $sendTransfer = unserialize($persistedAction); + + $this->completeWithTan($sendTransfer); + } +} diff --git a/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/GLS/GLSIntegrationTestBase.php b/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/GLS/GLSIntegrationTestBase.php new file mode 100755 index 0000000..98e6459 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/GLS/GLSIntegrationTestBase.php @@ -0,0 +1,86 @@ +expectMessage(static::ANONYMOUS_INIT_REQUEST, mb_convert_encoding(static::ANONYMOUS_INIT_RESPONSE, 'ISO-8859-1', 'UTF-8')); + $this->expectMessage(static::ANONYMOUS_END_REQUEST, mb_convert_encoding(static::ANONYMOUS_END_RESPONSE, 'ISO-8859-1', 'UTF-8')); + + $this->fints->getBpd(); + } + + /** + * Executes dialog synchronization and initialization, so that BPD and UPD are filled. + * @throws \Throwable + */ + protected function initDialog() + { + // We already know the TAN mode, so it will only fetch the BPD (anonymously) to verify it. + $this->expectMessage(static::ANONYMOUS_INIT_REQUEST, mb_convert_encoding(static::ANONYMOUS_INIT_RESPONSE, 'ISO-8859-1', 'UTF-8')); + $this->expectMessage(static::ANONYMOUS_END_REQUEST, mb_convert_encoding(static::ANONYMOUS_END_RESPONSE, 'ISO-8859-1', 'UTF-8')); + + // Then when we initialize a dialog, it's going to request a Kundensystem-ID and UPD. + $this->expectMessage(static::SYNC_REQUEST, mb_convert_encoding(static::SYNC_RESPONSE, 'ISO-8859-1', 'UTF-8')); + $this->expectMessage(static::SYNC_END_REQUEST, mb_convert_encoding(static::SYNC_END_RESPONSE, 'ISO-8859-1', 'UTF-8')); + + // And finally it can initialize the main dialog. + $this->expectMessage(static::INIT_REQUEST, mb_convert_encoding(static::INIT_RESPONSE, 'ISO-8859-1', 'UTF-8')); + + $this->fints->selectTanMode(intval(static::TEST_TAN_MODE)); + $login = $this->fints->login(); + $login->ensureDone(); // No TAN required upon login.*/ + $this->assertAllMessagesSeen(); + } + + protected function getTestAccount(): SEPAAccount + { + $sepaAccount = new SEPAAccount(); + $sepaAccount->setIban('DExxABCDEFGH1234567890'); + $sepaAccount->setBic('GENODEM1GLS'); + $sepaAccount->setAccountNumber('1234567890'); + $sepaAccount->setBlz(self::TEST_BANK_CODE); + return $sepaAccount; + } +} diff --git a/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/GLS/GetStatementOfAccountXMLTest.php b/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/GLS/GetStatementOfAccountXMLTest.php new file mode 100755 index 0000000..8e12903 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/GLS/GetStatementOfAccountXMLTest.php @@ -0,0 +1,81 @@ +'"; + + public const GET_STATEMENT_PAGE_2_REQUEST = "HKCAZ:3:1+DExxABCDEFGH1234567890:GENODEM1GLS:1234567890::280:43060967+urn?:iso?:std?:iso?:20022?:tech?:xsd?:camt.052.001.02+N+20200205+++0_=2#=20200515#=9382064#=0#=0#=0'HKTAN:4:6+4+HKCAZ'"; + public const GET_STATEMENT_PAGE_2_RESPONSE = "HIRMG:3:2+3060::Bitte beachten Sie die enthaltenen Warnungen/Hinweise.'HIRMS:4:2:3+3040::*Es liegen noch weitere CAMT Umsätze vor:0_=2#=20200703#=15530363#=0#=0#=0'HIRMS:5:2:4+3076::Starke Kundenauthentifizierung nicht notwendig.'HITAN:6:6:4+4++noref+nochallenge'" . self::GET_STATEMENT_EMPTY_HICAZ_RESPONSE; + + public const GET_STATEMENT_PAGE_3_REQUEST = "HKCAZ:3:1+DExxABCDEFGH1234567890:GENODEM1GLS:1234567890::280:43060967+urn?:iso?:std?:iso?:20022?:tech?:xsd?:camt.052.001.02+N+20200205+++0_=2#=20200703#=15530363#=0#=0#=0'HKTAN:4:6+4+HKCAZ'"; + public const GET_STATEMENT_PAGE_3_RESPONSE = "HIRMG:3:2+3060::Bitte beachten Sie die enthaltenen Warnungen/Hinweise.'HIRMS:4:2:3+0020::*Abfrage CAMT Umsätze erfolgreich durchgeführt'HIRMS:5:2:4+3076::Starke Kundenauthentifizierung nicht notwendig.'HITAN:6:6:4+4++noref+nochallenge'" . self::GET_STATEMENT_EMPTY_HICAZ_RESPONSE; + + private function runInitialRequest(): GetStatementOfAccountXML + { + $getStatement = GetStatementOfAccountXML::create($this->getTestAccount(), new \DateTime('2020-02-05')); + $this->fints->execute($getStatement); + return $getStatement; + } + + /** + * @throws \Throwable + */ + public function testWithTanPaginated() + { + $this->initDialog(); + + $this->expectMessage(static::GET_STATEMENT_REQUEST, mb_convert_encoding(static::GET_STATEMENT_RESPONSE_BEFORE_TAN, 'ISO-8859-1', 'UTF-8')); + $getStatement = $this->runInitialRequest(); + $this->assertTrue($getStatement->needsTan()); + + $this->completeWithTan($getStatement); + } + + /** + * @throws \Throwable + */ + public function testWithTanMinimalPersistPaginated() + { + $this->initDialog(); + + $this->expectMessage(static::GET_STATEMENT_REQUEST, mb_convert_encoding(static::GET_STATEMENT_RESPONSE_BEFORE_TAN, 'ISO-8859-1', 'UTF-8')); + $getStatement = $this->runInitialRequest(); + $this->assertTrue($getStatement->needsTan()); + + // Pretend that we close everything and open everything from scratch, as if it were a new PHP process. + $persistedInstance = $this->fints->persist(true); + $persistedGetStatement = serialize($getStatement); + $this->connection->expects($this->once())->method('disconnect'); + $this->fints = new FinTsPeer($this->options, $this->credentials); + $this->fints->loadPersistedInstance($persistedInstance); + /** @var GetStatementOfAccount $getStatement */ + $getStatement = unserialize($persistedGetStatement); + + $this->completeWithTan($getStatement); + } + + /** + * @throws \Throwable + */ + private function completeWithTan(BaseAction $getStatement) + { + $this->expectMessage(static::SEND_TAN_REQUEST, mb_convert_encoding(static::SEND_TAN_RESPONSE . self::GET_STATEMENT_EMPTY_HICAZ_RESPONSE, 'ISO-8859-1', 'UTF-8')); + $this->expectMessage(self::GET_STATEMENT_PAGE_2_REQUEST, mb_convert_encoding(self::GET_STATEMENT_PAGE_2_RESPONSE, 'ISO-8859-1', 'UTF-8')); + $this->expectMessage(self::GET_STATEMENT_PAGE_3_REQUEST, mb_convert_encoding(self::GET_STATEMENT_PAGE_3_RESPONSE, 'ISO-8859-1', 'UTF-8')); + $this->fints->submitTan($getStatement, '123456'); + $this->assertFalse($getStatement->needsTan()); + } +} diff --git a/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/GLS/InitEndDialogTest.php b/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/GLS/InitEndDialogTest.php new file mode 100755 index 0000000..98b0dc8 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/GLS/InitEndDialogTest.php @@ -0,0 +1,23 @@ +InitAnonymous(); + } + + public function testInitAndEndDialog() + { + $this->initDialog(); + $this->assertNotNull($this->fints->getDialogId()); + $this->expectMessage(static::FINAL_END_REQUEST, static::FINAL_END_RESPONSE); + $this->fints->endDialog(); + } +} diff --git a/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/IngDiba/GetSEPAAccountsTest.php b/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/IngDiba/GetSEPAAccountsTest.php new file mode 100755 index 0000000..25ca634 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/IngDiba/GetSEPAAccountsTest.php @@ -0,0 +1,36 @@ +initDialog(); + + $this->expectMessage(static::GET_ACCOUNTS_REQUEST, static::GET_ACCOUNTS_RESPONSE); + $getAccounts = new \Fhp\Action\GetSEPAAccounts(); + $this->fints->execute($getAccounts); + $accounts = $getAccounts->getAccounts(); + + $this->assertCount(2, $accounts); + $account1 = $accounts[0]; + $this->assertEquals('DExxABCDEFGH1234567890', $account1->getIban()); + $this->assertEquals('INGDDEFFXXX', $account1->getBic()); + $this->assertEquals('1234567890', $account1->getAccountNumber()); + $this->assertEmpty($account1->getSubAccount()); + $this->assertEquals(static::TEST_BANK_CODE, $account1->getBlz()); + $account2 = $accounts[1]; + $this->assertEquals('DExxABCDEFGH1234567842', $account2->getIban()); + $this->assertEquals('INGDDEFFXXX', $account2->getBic()); + $this->assertEquals('1234567842', $account2->getAccountNumber()); + $this->assertEmpty($account2->getSubAccount()); + $this->assertEquals(static::TEST_BANK_CODE, $account2->getBlz()); + } +} diff --git a/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/IngDiba/GetStatementOfAccountTest.php b/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/IngDiba/GetStatementOfAccountTest.php new file mode 100755 index 0000000..d830cd0 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/IngDiba/GetStatementOfAccountTest.php @@ -0,0 +1,27 @@ +initDialog(); + $this->expectMessage(static::GET_STATEMENT_REQUEST, static::GET_STATEMENT_RESPONSE); + $getStatement = GetStatementOfAccount::create($this->getTestAccount(), + new \DateTime('2020-03-01'), new \DateTime('2020-03-25'), false); + $this->fints->execute($getStatement); + $this->assertFalse($getStatement->needsTan()); + $this->assertEmpty($getStatement->getStatement()->getStatements()); + } +} diff --git a/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/IngDiba/IngDibaIntegrationTestBase.php b/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/IngDiba/IngDibaIntegrationTestBase.php new file mode 100755 index 0000000..7d01b62 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/IngDiba/IngDibaIntegrationTestBase.php @@ -0,0 +1,59 @@ +expectMessage(static::SYNC_REQUEST, static::SYNC_RESPONSE); + $this->expectMessage(static::SYNC_END_REQUEST, static::SYNC_END_RESPONSE); + // The UPD is fetched only later when the mail dialog is initialized. + $this->expectMessage(static::INIT_REQUEST, static::INIT_RESPONSE); + + $this->fints->selectTanMode(new NoPsd2TanMode()); + $login = $this->fints->login(); + $login->ensureDone(); // No TAN required upon login. + $this->assertAllMessagesSeen(); + } + + protected function getTestAccount(): SEPAAccount + { + $sepaAccount = new SEPAAccount(); + $sepaAccount->setIban('DExxABCDEFGH1234567890'); + $sepaAccount->setBic('INGDDEFFXXX'); + $sepaAccount->setAccountNumber('1234567890'); + $sepaAccount->setBlz('50010517'); + return $sepaAccount; + } +} diff --git a/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/IngDiba/InitDialogWithBlockedPinTest.php b/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/IngDiba/InitDialogWithBlockedPinTest.php new file mode 100755 index 0000000..cdd250e --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/IngDiba/InitDialogWithBlockedPinTest.php @@ -0,0 +1,28 @@ +expectException(ServerException::class); + $this->expectExceptionMessageMatches('/.*Log-in fehlgeschlagen.*/'); + $this->initDialog(); + } + + protected function tearDown(): void + { + // After the dialog was aborted due to invalid PIN, the usual dialog initialization messages won't happen anymore. + $this->expectedMessages = []; + parent::tearDown(); + } +} diff --git a/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/IngDiba/InitEndDialogTest.php b/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/IngDiba/InitEndDialogTest.php new file mode 100755 index 0000000..a527a52 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/IngDiba/InitEndDialogTest.php @@ -0,0 +1,17 @@ +initDialog(); + $this->assertNotNull($this->fints->getDialogId()); + $this->expectMessage(static::FINAL_END_REQUEST, static::FINAL_END_RESPONSE); + $this->fints->endDialog(); + } +} diff --git a/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/InitializationErrorTest.php b/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/InitializationErrorTest.php new file mode 100755 index 0000000..f67ed2b --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/InitializationErrorTest.php @@ -0,0 +1,45 @@ +expectMessage( + "HNHBK:1:3+000000000145+300+0+1'HKIDN:2:2+280:11223344+9999999999+0+0'HKVVB:3:3+0+0+0+123456789ABCDEF0123456789+1.0'HKTAN:4:6+4+HKIDN'HNHBS:5:1+1'", + // Here the bank complains that HKIDN (segment 3) $kundensystemId (element 4) is mandatory but missing. + // The field is actually present in the request (because the SUT is implemented correctly), but for testing + // purposes we pretend that the bank reported this error anyway. + "HNHBK:1:3+000000000200+300+0+1+0:1'HIRMG:2:2+9050::Die Nachricht enthält Fehler.+9800::Dialog abgebrochen'HIRMS:3:2:1+3110::Segment unbekannt'HIRMS:4:2:3+9160:4:Pflichtfeld nicht gefunden'HNHBS:5:1+1'"); + $this->connection->expects($this->once())->method('disconnect'); + $this->expectException(ServerException::class); + $this->expectExceptionMessageMatches('/Pflichtfeld nicht gefunden/'); + $this->fints->selectTanMode(921, 'SomePhone1'); + $this->fints->login(); + } + + /** + * @throws \Throwable + */ + public function testInitializationErrorInvalidSegment() + { + $this->expectMessage( + "HNHBK:1:3+000000000145+300+0+1'HKIDN:2:2+280:11223344+9999999999+0+0'HKVVB:3:3+0+0+0+123456789ABCDEF0123456789+1.0'HKTAN:4:6+4+HKIDN'HNHBS:5:1+1'", + // Here the bank responds as if one of the segments were invalid (e.g. missing its segment number). Note + // again that the request produced by the SUT is valid, but for the sake of this test we pretend that the + // bank responds with an error anyway, to see how it is handled. + "HNHBK:1:3+000000000200+300+0+1+0:1'HIRMG:2:2+9050::Die Nachricht enthält Fehler.+9800::Dialog abgebrochen+9110::Falsche Segmentzusammenstellung:HNSHK/A'HIRMS:3:2:1+3110::Segment unbekannt'HNHBS:4:1+1'"); + $this->connection->expects($this->once())->method('disconnect'); + $this->expectException(ServerException::class); + $this->expectExceptionMessageMatches('/Falsche Segmentzusammenstellung/'); + $this->fints->selectTanMode(921, 'SomePhone1'); + $this->fints->login(); + } +} diff --git a/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/KSK/Biberach/GetBPDTest.php b/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/KSK/Biberach/GetBPDTest.php new file mode 100755 index 0000000..8eccfad --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/KSK/Biberach/GetBPDTest.php @@ -0,0 +1,24 @@ +expectMessage(static::ANONYMOUS_INIT_REQUEST, static::ANONYMOUS_INIT_RESPONSE); + $this->expectMessage(static::ANONYMOUS_END_REQUEST, static::ANONYMOUS_END_RESPONSE); + + $bpd = FinTsPeer::fetchBpd($this->options); + + $this->assertTrue($bpd->supportsPsd2()); + $this->assertArrayHasKey(922, $bpd->allTanModes); // From HITANSv7 + $this->assertArrayHasKey(910, $bpd->allTanModes); // From HITANSv6 + } +} diff --git a/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/KSK/Biberach/KskBiberachIntegrationTestBase.php b/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/KSK/Biberach/KskBiberachIntegrationTestBase.php new file mode 100755 index 0000000..bb5962e --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/KSK/Biberach/KskBiberachIntegrationTestBase.php @@ -0,0 +1,17 @@ +expectException(ServerException::class); + $this->expectExceptionMessageMatches('/Ihr Zugang ist vorlaufig gesperrt/'); + $this->initDialog(); + } + + protected function tearDown(): void + { + // After the dialog was aborted due to invalid PIN, the usual dialog initialization messages won't happen anymore. + $this->expectedMessages = []; + parent::tearDown(); + } +} diff --git a/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/KSK/KSKIntegrationTestBase.php b/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/KSK/KSKIntegrationTestBase.php new file mode 100755 index 0000000..a945ad0 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/KSK/KSKIntegrationTestBase.php @@ -0,0 +1,61 @@ +expectMessage(static::ANONYMOUS_INIT_REQUEST, static::ANONYMOUS_INIT_RESPONSE); + $this->expectMessage(static::ANONYMOUS_END_REQUEST, static::ANONYMOUS_END_RESPONSE); + + // Then when we initialize a dialog, it's going to request a Kundensystem-ID and UPD. + $this->expectMessage(static::SYNC_REQUEST, mb_convert_encoding(static::SYNC_RESPONSE, 'ISO-8859-1', 'UTF-8')); + $this->expectMessage(static::SYNC_END_REQUEST, static::SYNC_END_RESPONSE); + // And finally it can initialize the main dialog. + $this->expectMessage(static::INIT_REQUEST, mb_convert_encoding(static::INIT_RESPONSE, 'ISO-8859-1', 'UTF-8')); + + $this->fints->selectTanMode(intval(self::TEST_TAN_MODE)); + $login = $this->fints->login(); + $login->ensureDone(); // No TAN required upon login. + $this->assertAllMessagesSeen(); + } + + protected function getTestAccount(): SEPAAccount + { + $sepaAccount = new SEPAAccount(); + $sepaAccount->setIban('DExxABCDEFGH1234567890'); + $sepaAccount->setBic('BYLADEM1MIB'); + $sepaAccount->setAccountNumber('1234567890'); + $sepaAccount->setBlz('71152570'); + return $sepaAccount; + } +} diff --git a/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/Postbank/GetSEPAAccountsTest.php b/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/Postbank/GetSEPAAccountsTest.php new file mode 100755 index 0000000..61ba685 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/Postbank/GetSEPAAccountsTest.php @@ -0,0 +1,25 @@ +initDialog(); + + $this->expectMessage(static::GET_ACCOUNTS_REQUEST, static::EMPTY_ACCOUNTS_RESPONSE); + $getAccounts = new \Fhp\Action\GetSEPAAccounts(); + $this->fints->execute($getAccounts); + $accounts = $getAccounts->getAccounts(); + + $this->assertEmpty($accounts); + } +} diff --git a/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/Postbank/InitDialogWithBlockedPinTest.php b/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/Postbank/InitDialogWithBlockedPinTest.php new file mode 100755 index 0000000..e001300 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/Postbank/InitDialogWithBlockedPinTest.php @@ -0,0 +1,30 @@ +expectException(ServerException::class); + $this->expectExceptionMessageMatches('/.*Vorlaufige Sperre liegt vor.*/'); + $this->initDialog(); + } + + protected function tearDown(): void + { + // After the dialog was aborted due to invalid PIN, the usual dialog initialization messages won't happen anymore. + $this->expectedMessages = []; + parent::tearDown(); + } +} diff --git a/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/Postbank/InitEndDialogTest.php b/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/Postbank/InitEndDialogTest.php new file mode 100755 index 0000000..44a3c64 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/Postbank/InitEndDialogTest.php @@ -0,0 +1,18 @@ +initDialog(); + $this->assertNotNull($this->fints->getDialogId()); + $this->expectMessage(static::FINAL_END_REQUEST, static::FINAL_END_RESPONSE); + $this->fints->endDialog(); + } +} diff --git a/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/Postbank/PostbankIntegrationTestBase.php b/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/Postbank/PostbankIntegrationTestBase.php new file mode 100755 index 0000000..434b6d7 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Tests/Fhp/Integration/Postbank/PostbankIntegrationTestBase.php @@ -0,0 +1,67 @@ +expectMessage(static::ANONYMOUS_INIT_REQUEST, static::ANONYMOUS_INIT_RESPONSE); + $this->expectMessage(static::ANONYMOUS_END_REQUEST, static::ANONYMOUS_END_RESPONSE); + + // Then when we initialize a dialog, it's going to request a Kundensystem-ID and UPD. + $this->expectMessage(static::SYNC_REQUEST, mb_convert_encoding(static::SYNC_RESPONSE, 'ISO-8859-1', 'UTF-8')); + $this->expectMessage(static::SYNC_END_REQUEST, static::SYNC_END_RESPONSE); + // And finally it can initialize the main dialog. + $this->expectMessage(static::INIT_REQUEST, mb_convert_encoding(static::INIT_RESPONSE, 'ISO-8859-1', 'UTF-8')); + + $this->fints->selectTanMode(self::TEST_TAN_MODE, 'mT:PRIVATE__'); + $login = $this->fints->login(); + $login->ensureDone(); // No TAN required upon login. + $this->assertAllMessagesSeen(); + } + + protected function getTestAccount(): SEPAAccount + { + $sepaAccount = new SEPAAccount(); + $sepaAccount->setIban('DExxABCDEFGH1234567890'); + $sepaAccount->setBic('PBNKDEFF'); + $sepaAccount->setAccountNumber('1234567890'); + $sepaAccount->setBlz(self::TEST_BANK_CODE); + return $sepaAccount; + } +} diff --git a/vendor/nemiah/php-fints/lib/Tests/Fhp/Model/FlickerTan/TanRequestChallengeFlickerTest.php b/vendor/nemiah/php-fints/lib/Tests/Fhp/Model/FlickerTan/TanRequestChallengeFlickerTest.php new file mode 100755 index 0000000..a685cf3 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Tests/Fhp/Model/FlickerTan/TanRequestChallengeFlickerTest.php @@ -0,0 +1,39 @@ +expectException(\InvalidArgumentException::class); + new TanRequestChallengeFlicker(new Bin(self::SC_OLD_VERSION)); + } + + public function testGetHex1(): void + { + $flicker = new TanRequestChallengeFlicker(new Bin(self::SC_BESTAND_ABFRAGEN_IN1)); + $this->assertEquals(self::HEX_BESTAND_ABFRAGEN_OUT1, $flicker->getHex()); + } + + public function testGetHex2(): void + { + $flicker = new TanRequestChallengeFlicker(new Bin(self::SC_BESTAND_ABFRAGEN_IN2)); + $this->assertEquals(self::HEX_BESTAND_ABFRAGEN_OUT2, $flicker->getHex()); + } +} diff --git a/vendor/nemiah/php-fints/lib/Tests/Fhp/Options/SanitizingLoggerTest.php b/vendor/nemiah/php-fints/lib/Tests/Fhp/Options/SanitizingLoggerTest.php new file mode 100755 index 0000000..aa95947 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Tests/Fhp/Options/SanitizingLoggerTest.php @@ -0,0 +1,35 @@ +productName = 'ABCDEFGHIJKLMNOPQRS'; + $needles = SanitizingLogger::computeNeedles([$credentials, $options, 'RAWNEEDLE']); + $sanitize = function ($str) use ($needles) { + $result = SanitizingLogger::sanitizeForLogging($str, $needles); + $this->assertEquals(strlen($str), strlen($result)); + return $result; + }; + $this->assertEquals( + "HKVVB:4:3+3+0+0+PRIVATE____________+1.0'HKTAN:5:", + $sanitize("HKVVB:4:3+3+0+0+ABCDEFGHIJKLMNOPQRS+1.0'HKTAN:5:")); + $this->assertEquals( + 'Look here is the password: PRIVATE', + $sanitize('Look here is the password: pw+?123')); + $this->assertEquals( + "HNSHA:4:2+9999999++PRIVATE__'", + $sanitize("HNSHA:4:2+9999999++pw?+??123'")); // Note: The password is escaped to wire format here. + $this->assertEquals( + "20190102:030405+1:999:1+6:10:19+280:11223344:PRIVATE:S:0:0'HIRMG:3:2", + $sanitize("20190102:030405+1:999:1+6:10:19+280:11223344:USER123:S:0:0'HIRMG:3:2")); + } +} diff --git a/vendor/nemiah/php-fints/lib/Tests/Fhp/Protocol/DialogInitializationTest.php b/vendor/nemiah/php-fints/lib/Tests/Fhp/Protocol/DialogInitializationTest.php new file mode 100755 index 0000000..e69d6c4 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Tests/Fhp/Protocol/DialogInitializationTest.php @@ -0,0 +1,54 @@ +getKundensystemId(), $kundensystemId); + + // test parent class: BaseClass + self::assertEquals($object2->getNeedTanForSegment(), $needTanForSegment); + } + + public function testSerializableInterfaceMigrationFromString() + { + $kundensystemId = 'kunden-system-id2'; + $needTanForSegment = 'test-segment2'; + + // This is a "C" string, generated by the Serializeable Interface + // The magic method __serialize() would output an "O" string + // https://wiki.php.net/rfc/custom_object_serialization#encoding_and_interoperability + $string = 'C:48:"Tests\Fhp\Protocol\DialogInitializationTestModel":108:{a:5:{i:0;s:43:"a:3:{i:0;N;i:1;N;i:2;s:13:"test-segment2";}";i:1;N;i:2;s:17:"kunden-system-id2";i:3;N;i:4;N;}}'; + + /** @var DialogInitialization $object2 */ + $object2 = unserialize($string); + self::assertIsObject($object2); + + // Test child class: DialogInitialization + self::assertEquals($object2->getKundensystemId(), $kundensystemId); + + // test parent class: BaseClass + self::assertEquals($object2->getNeedTanForSegment(), $needTanForSegment); + } +} diff --git a/vendor/nemiah/php-fints/lib/Tests/Fhp/Protocol/DialogInitializationTestModel.php b/vendor/nemiah/php-fints/lib/Tests/Fhp/Protocol/DialogInitializationTestModel.php new file mode 100755 index 0000000..cba3e83 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Tests/Fhp/Protocol/DialogInitializationTestModel.php @@ -0,0 +1,29 @@ +needTanForSegment = $needTanForSegment; + } + + public function needsTan(): bool + { + return true; + } +} diff --git a/vendor/nemiah/php-fints/lib/Tests/Fhp/Protocol/ServerExceptionTest.php b/vendor/nemiah/php-fints/lib/Tests/Fhp/Protocol/ServerExceptionTest.php new file mode 100755 index 0000000..940b774 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Tests/Fhp/Protocol/ServerExceptionTest.php @@ -0,0 +1,37 @@ +assertTrue(true); + } + + /** + * @throws ServerException This should not actually throw because there are only warnings in the response. + */ + public function testDetectAndThrowErrorsWithErrors() + { + $request = Message::createPlainMessage([]); + $response = Message::parse(static::RESPONSE_WITH_ERRORS); + $this->expectException(ServerException::class); + ServerException::detectAndThrowErrors($response, $request); + } +} diff --git a/vendor/nemiah/php-fints/lib/Tests/Fhp/Segment/AnonymousSegmentTest.php b/vendor/nemiah/php-fints/lib/Tests/Fhp/Segment/AnonymousSegmentTest.php new file mode 100755 index 0000000..dbabdfa --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Tests/Fhp/Segment/AnonymousSegmentTest.php @@ -0,0 +1,47 @@ +getProperty('elements'); + $property->setAccessible(true); + return $property->getValue($segment); + } catch (\ReflectionException $e) { + throw new \RuntimeException($e); + } + } + + public function testParse() + { + $segment = Parser::parseAnonymousSegment(static::RAW_SEGMENT); + $this->assertEquals('HNXXX', $segment->getName()); + $this->assertEquals('HNXXXv3', $segment->type); + $this->assertEquals(3, $segment->getVersion()); + $this->assertEquals(['A', null, ['C', 'D', 'E'], 'F', null], static::getElements($segment)); + + $segment2 = Parser::detectAndParseSegment(static::RAW_SEGMENT); + $this->assertEquals($segment, $segment2); + } + + public function testParseEmpty() + { + $this->expectException(\InvalidArgumentException::class); + Parser::parseAnonymousSegment(''); + } + + public function testSerialize() + { + $segment = Parser::parseAnonymousSegment(static::RAW_SEGMENT); + $this->assertEquals(static::RAW_SEGMENT, $segment->serialize()); + } +} diff --git a/vendor/nemiah/php-fints/lib/Tests/Fhp/Segment/Common/TspTest.php b/vendor/nemiah/php-fints/lib/Tests/Fhp/Segment/Common/TspTest.php new file mode 100755 index 0000000..6f0e1d9 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Tests/Fhp/Segment/Common/TspTest.php @@ -0,0 +1,20 @@ +assertEquals(new \DateTime('2020-01-02T00:00:00'), Tsp::parse('20200102')->asDateTime()); + $this->assertEquals(new \DateTime('2020-07-02T00:00:00'), Tsp::parse('20200702')->asDateTime()); + } + + public function testParseWithTime() + { + $this->assertEquals(new \DateTime('2020-01-02T11:22:33'), Tsp::parse('20200102:112233')->asDateTime()); + $this->assertEquals(new \DateTime('2020-01-02T22:00:00'), Tsp::parse('20200102:220000')->asDateTime()); + } +} diff --git a/vendor/nemiah/php-fints/lib/Tests/Fhp/Segment/HICAZTest.php b/vendor/nemiah/php-fints/lib/Tests/Fhp/Segment/HICAZTest.php new file mode 100755 index 0000000..77ecf67 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Tests/Fhp/Segment/HICAZTest.php @@ -0,0 +1,74 @@ +' . + '' . + 'camt52_20131118101510__ONLINEBA' . + '2013-11-18T10:15:10+01:001true' . + 'camt052_ONLINEBA' . + 'BOOK' . + 'BOOK' . + ''; + private const SAMPLE_XML_DOC2 = '' . + '' . + 'camt52_20131118101510__ONLINEBA' . + '2013-11-18T10:15:10+01:001true' . + 'camt052_ONLINEBA' . + 'BOOK' . + 'BOOK' . + 'BOOK' . + 'BOOK' . + ''; + private const SAMPLE_XML_DOC3 = '' . + '' . + 'camt52_20131118101510__ONLINEBA' . + '2013-11-18T10:15:10+01:001true' . + 'camt052_ONLINEBA' . + 'PDNG' . + ''; + + public function testHICAZparse() + { + // First example: two XMLs seperated by ":" - both are gebuchteUmsaetze + $hicaz1 = HICAZv1::parse(static::HICAZ_TEST_START . + '@' . strlen(static::SAMPLE_XML_DOC1) . '@' . + static::SAMPLE_XML_DOC1 . + ':' . + '@' . strlen(static::SAMPLE_XML_DOC2) . '@' . + static::SAMPLE_XML_DOC2 . + "'"); + + $this->assertEquals([static::SAMPLE_XML_DOC1, static::SAMPLE_XML_DOC2], + $hicaz1->getGebuchteUmsaetze()); + + // Second example: two areas seperated by +, first area has a group of two XMLs seperated by : + + $hicaz2 = HICAZv1::parse(static::HICAZ_TEST_START . + '@' . strlen(static::SAMPLE_XML_DOC1) . '@' . + static::SAMPLE_XML_DOC1 . + ':@' . strlen(static::SAMPLE_XML_DOC2) . '@' . + static::SAMPLE_XML_DOC2 . + '+@' . strlen(static::SAMPLE_XML_DOC3) . '@' . + static::SAMPLE_XML_DOC3 . + "'"); + $this->assertEquals([static::SAMPLE_XML_DOC1, static::SAMPLE_XML_DOC2], + $hicaz2->getGebuchteUmsaetze()); + $this->assertEquals(static::SAMPLE_XML_DOC3, + $hicaz2->getNichtGebuchteUmsaetze()); + } +} diff --git a/vendor/nemiah/php-fints/lib/Tests/Fhp/Segment/HISALTest.php b/vendor/nemiah/php-fints/lib/Tests/Fhp/Segment/HISALTest.php new file mode 100755 index 0000000..5294a5b --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Tests/Fhp/Segment/HISALTest.php @@ -0,0 +1,27 @@ +segmentkopf->segmentnummer = 11; + $hisal->kontoverbindungInternational = new Kti(); // Note that none of its fields are filled. + $hisal->kontoproduktbezeichnung = 'Test'; + $hisal->kontowaehrung = 'EUR'; + $hisal->gebuchterSaldo = Sdo::create(42, 'EUR', \DateTime::createFromFormat('Ymd His', '20200102 030405')); + + $serialized = $hisal->serialize(); + $this->assertEquals("HISAL:11:7++Test+EUR+C:42,:EUR:20200102:030405'", $serialized); + $parsed = HISALv7::parse($serialized); + $this->assertEquals($parsed, $hisal); + } +} diff --git a/vendor/nemiah/php-fints/lib/Tests/Fhp/Segment/HITABTest.php b/vendor/nemiah/php-fints/lib/Tests/Fhp/Segment/HITABTest.php new file mode 100755 index 0000000..51fb775 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Tests/Fhp/Segment/HITABTest.php @@ -0,0 +1,34 @@ +getTanMediumListe(); + $this->assertCount(2, $liste); + $this->assertEquals('pushtan', $liste[0]->getName()); + $this->assertEquals('SomePhone1', $liste[1]->getName()); + } + + public function testSerialize() + { + // NOTE: Our serializer produces fewer redundant colons, but after parsing it again, it should be the same. + $parsed = HITABv4::parse(static::REAL_DKB_RESPONSE); + $serialized = $parsed->serialize(); + $this->assertEquals("HITAB:1:4:3+0+A:1:::::::::::pushtan+A:1:::::::::::SomePhone1'", $serialized); + $reparsed = HITABv4::parse($serialized); + $this->assertEquals($reparsed, $parsed); + } +} diff --git a/vendor/nemiah/php-fints/lib/Tests/Fhp/Segment/HITANSTest.php b/vendor/nemiah/php-fints/lib/Tests/Fhp/Segment/HITANSTest.php new file mode 100755 index 0000000..2899c34 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Tests/Fhp/Segment/HITANSTest.php @@ -0,0 +1,54 @@ +assertEquals(1, $parsed->maximaleAnzahlAuftraege); + $parsedParams = $parsed->parameterZweiSchrittTanEinreichung; + $this->assertEquals(true, $parsedParams->einschrittVerfahrenErlaubt); + $this->assertCount(7, $parsedParams->verfahrensparameterZweiSchrittVerfahren); + $this->assertEquals('HHD1.3.0', $parsedParams->verfahrensparameterZweiSchrittVerfahren[0]->technischeIdentifikationTanVerfahren); + $this->assertEquals('chipTAN manuell', $parsedParams->verfahrensparameterZweiSchrittVerfahren[0]->nameDesZweiSchrittVerfahrens); + $this->assertEquals('TAN2go', $parsedParams->verfahrensparameterZweiSchrittVerfahren[5]->technischeIdentifikationTanVerfahren); + $this->assertEquals('iTAN', $parsedParams->verfahrensparameterZweiSchrittVerfahren[6]->technischeIdentifikationTanVerfahren); + $this->assertEquals('00', $parsedParams->verfahrensparameterZweiSchrittVerfahren[6]->initialisierungsmodus); + } + + public function testSegmentVersionDetection() + { + $this->assertEquals(1, BaseSegment::parse(static::REAL_DKB_RESPONSE[0])->getVersion()); + $this->assertEquals(3, BaseSegment::parse(static::REAL_DKB_RESPONSE[1])->getVersion()); + $this->assertEquals(HITANSv6::parse(static::REAL_DKB_RESPONSE[2]), + BaseSegment::parse(static::REAL_DKB_RESPONSE[2])); + } + + public function testValidateInvalidSegmentkopf() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('@Invalid int: LALA@'); + HITANSv6::parse("HITANS:LALA:1:4+1+1+1+J:N:0:0:920:2:smsTAN:smsTAN:6:1:TAN-Nummer:3:1:J:J'"); + } + + public function testValidateInvalidDeg() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('@Invalid bool: LALA@'); + HITANSv6::parse("HITANS:167:6:4+1+1+1+J:N:0:910:2:HHD1.3.0:::chipTAN manuell:6:1:TAN-Nummer:3:LALA:2:N:0:0:N:N:00:0:N:1'"); + } +} diff --git a/vendor/nemiah/php-fints/lib/Tests/Fhp/Segment/HIUPATest.php b/vendor/nemiah/php-fints/lib/Tests/Fhp/Segment/HIUPATest.php new file mode 100755 index 0000000..5a281d9 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Tests/Fhp/Segment/HIUPATest.php @@ -0,0 +1,17 @@ +expectException(\InvalidArgumentException::class); + HIUPAv4::parse(static::TEST_HIUPA . static::PARTIAL_HIUPD); + } +} diff --git a/vendor/nemiah/php-fints/lib/Tests/Fhp/Segment/HIUPDTest.php b/vendor/nemiah/php-fints/lib/Tests/Fhp/Segment/HIUPDTest.php new file mode 100755 index 0000000..3b09d6f --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Tests/Fhp/Segment/HIUPDTest.php @@ -0,0 +1,87 @@ +assertSame(16, $parsed->segmentkopf->segmentnummer); + $this->assertSame(4, $parsed->segmentkopf->segmentversion); + $this->assertSame('1234567', $parsed->kontoverbindung->kontonummer); + $this->assertNull($parsed->kontoverbindung->unterkontomerkmal); + $this->assertSame('280', $parsed->kontoverbindung->kik->laenderkennzeichen); + $this->assertSame('10020030', $parsed->kontoverbindung->kik->kreditinstitutscode); + $this->assertSame('12345', $parsed->kundenId); + $this->assertSame('DEM', $parsed->kontowaehrung); + $this->assertSame('Ernst Müller', $parsed->name1); + $this->assertSame('Giro Spezial', $parsed->kontoproduktbezeichnung); + + $this->assertSame('T', $parsed->kontolimit->limitart); + $this->assertSame(2000.0, $parsed->kontolimit->limitbetrag->wert); + $this->assertSame('DEM', $parsed->kontolimit->limitbetrag->waehrung); + $this->assertNull($parsed->kontolimit->limitTage); + + $this->assertCount(9, $parsed->erlaubteGeschaeftsvorfaelle); + } + + public function testValidateHBCI22Example1() + { + $parsed = HIUPDv4::parse(mb_convert_encoding(static::HBCI22_EXAMPLES[0], 'ISO-8859-1', 'UTF-8')); + $parsed->validate(); // Should not throw. + $this->assertTrue(true); + } + + public function testSerializeHBCI22Example1() + { + $parsed = HIUPDv4::parse(mb_convert_encoding(static::HBCI22_EXAMPLES[0], 'ISO-8859-1', 'UTF-8')); + $this->assertEquals(mb_convert_encoding(static::HBCI22_EXAMPLES[0], 'ISO-8859-1', 'UTF-8'), $parsed->serialize()); + } + + public function testParseHBCI22Example2() + { + $parsed = HIUPDv4::parse(mb_convert_encoding(static::HBCI22_EXAMPLES[1], 'ISO-8859-1', 'UTF-8')); + $this->assertSame('1234568', $parsed->kontoverbindung->kontonummer); + $this->assertSame('Sparkonto 2000', $parsed->kontoproduktbezeichnung); + $this->assertNull($parsed->kontolimit); + + $this->assertCount(8, $parsed->erlaubteGeschaeftsvorfaelle); + $this->assertSame('HKUEB', $parsed->erlaubteGeschaeftsvorfaelle[4]->geschaeftsvorfall); + $this->assertSame(2, $parsed->erlaubteGeschaeftsvorfaelle[4]->anzahlBenoetigterSignaturen); + $this->assertSame('Z', $parsed->erlaubteGeschaeftsvorfaelle[4]->limitart); + $this->assertSame(1000.0, $parsed->erlaubteGeschaeftsvorfaelle[4]->limitbetrag->wert); + $this->assertSame('DEM', $parsed->erlaubteGeschaeftsvorfaelle[4]->limitbetrag->waehrung); + $this->assertSame(7, $parsed->erlaubteGeschaeftsvorfaelle[4]->limitTage); + } + + public function testValidateHBCI22Example2() + { + $parsed = HIUPDv4::parse(mb_convert_encoding(static::HBCI22_EXAMPLES[1], 'ISO-8859-1', 'UTF-8')); + $parsed->validate(); // Should not throw. + $this->assertTrue(true); + } + + public function testSerializeHBCI22Example2() + { + $parsed = HIUPDv4::parse(mb_convert_encoding(static::HBCI22_EXAMPLES[1], 'ISO-8859-1', 'UTF-8')); + $this->assertEquals(mb_convert_encoding(static::HBCI22_EXAMPLES[1], 'ISO-8859-1', 'UTF-8'), $parsed->serialize()); + } +} diff --git a/vendor/nemiah/php-fints/lib/Tests/Fhp/Segment/HKCCSTest.php b/vendor/nemiah/php-fints/lib/Tests/Fhp/Segment/HKCCSTest.php new file mode 100755 index 0000000..141980c --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Tests/Fhp/Segment/HKCCSTest.php @@ -0,0 +1,20 @@ +expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('kreditinstitutscode'); + $parsed->validate(); + } +} diff --git a/vendor/nemiah/php-fints/lib/Tests/Fhp/Segment/HKSPATest.php b/vendor/nemiah/php-fints/lib/Tests/Fhp/Segment/HKSPATest.php new file mode 100755 index 0000000..bd79fc3 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Tests/Fhp/Segment/HKSPATest.php @@ -0,0 +1,20 @@ +setSegmentNumber(42); + $hkspa->kontoverbindung = []; + $this->assertEquals("HKSPA:42:2'", $hkspa->serialize()); + } +} diff --git a/vendor/nemiah/php-fints/lib/Tests/Fhp/Segment/HNVSDTest.php b/vendor/nemiah/php-fints/lib/Tests/Fhp/Segment/HNVSDTest.php new file mode 100755 index 0000000..edc1d7f --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Tests/Fhp/Segment/HNVSDTest.php @@ -0,0 +1,54 @@ +assertEquals(198, strlen($hnvsd->datenVerschluesselt->getData())); + } + + public function testLengthTooLong() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Incomplete binary block'); + HNVSDv1::parse(str_replace('@198@', '@199@', static::REAL_DKB_RESPONSE)); + } + + public function testLengthTooShort() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('got 198'); + HNVSDv1::parse(str_replace('@198@', '@197@', static::REAL_DKB_RESPONSE)); + } + + public function testIso8859EncodedLength() + { + // In UTF-8 (used by PHP), the character ä uses two bytes: + $this->assertEquals(2, strlen('ä')); + // In ISO-8859-1 (FinTS wire format, and thus used for Bin lengths), it's just one byte: + $this->assertEquals(1, strlen(mb_convert_encoding('ä', 'ISO-8859-1', 'UTF-8'))); + // So when we replace "Nachricht" with "Nächricht", the above message should still be valid. + $this->assertEquals(strlen(mb_convert_encoding('Nachricht', 'ISO-8859-1', 'UTF-8')), strlen(mb_convert_encoding('Nächricht', 'ISO-8859-1', 'UTF-8'))); + + $encodedResponse = str_replace('Nachricht', mb_convert_encoding('Nächricht', 'ISO-8859-1', 'UTF-8'), static::REAL_DKB_RESPONSE); + $this->assertFalse(strpos($encodedResponse, 'Nachricht')); // Make sure the replacement was effective. + $hnvsd = HNVSDv1::parse($encodedResponse); + $this->assertEquals(198, strlen($hnvsd->datenVerschluesselt->getData())); + $this->assertNotFalse(strpos($hnvsd->datenVerschluesselt->getData(), mb_convert_encoding('Nächricht', 'ISO-8859-1', 'UTF-8'))); + } +} diff --git a/vendor/nemiah/php-fints/lib/Tests/Fhp/Segment/HNVSKTest.php b/vendor/nemiah/php-fints/lib/Tests/Fhp/Segment/HNVSKTest.php new file mode 100755 index 0000000..d689263 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Tests/Fhp/Segment/HNVSKTest.php @@ -0,0 +1,45 @@ +assertEquals('00000000', $hnvsk->verschluesselungsalgorithmus->wertDesAlgorithmusparametersSchluessel->getData()); + $this->assertEquals('280', $hnvsk->schluesselname->kreditinstitutskennung->laenderkennzeichen); + $this->assertEquals('10020030', $hnvsk->schluesselname->kreditinstitutskennung->kreditinstitutscode); + $this->assertEquals('12345', $hnvsk->schluesselname->benutzerkennung); + $this->assertEquals(SchluesselnameV3::CHIFFRIERSCHLUESSEL, $hnvsk->schluesselname->schluesselart); + } + + public function testSerialize() + { + $options = new FinTsOptions(); + $options->bankCode = '10020030'; + $credentials = Credentials::create('12345', 'NOT USED'); + $hnvsk = HNVSKv3::create($options, $credentials, '2', null); + $hnvsk->sicherheitsdatumUndUhrzeit->datum = '20020610'; + $hnvsk->sicherheitsdatumUndUhrzeit->uhrzeit = '102044'; + $this->assertEquals( // Replace binary zeros to make the diff readable in case the unit test fails. + str_replace("\0", '0', static::HBCI22_EXAMPLE), + str_replace("\0", '0', $hnvsk->serialize()) + ); + } +} diff --git a/vendor/nemiah/php-fints/lib/Tests/Fhp/Segment/SegmentComparator.php b/vendor/nemiah/php-fints/lib/Tests/Fhp/Segment/SegmentComparator.php new file mode 100755 index 0000000..20152d7 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Tests/Fhp/Segment/SegmentComparator.php @@ -0,0 +1,31 @@ +assertEquals('@32@' . $string, (string) $d); + $this->assertEquals('@32@' . $string, $d->toString()); + $this->assertEquals($string, $d->getData()); + + $d->setData($string2); + $this->assertEquals('@32@' . $string2, (string) $d); + $this->assertEquals('@32@' . $string2, $d->toString()); + $this->assertEquals($string2, $d->getData()); + } +} diff --git a/vendor/nemiah/php-fints/lib/Tests/Fhp/Syntax/ParserTest.php b/vendor/nemiah/php-fints/lib/Tests/Fhp/Syntax/ParserTest.php new file mode 100755 index 0000000..bea7b58 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Tests/Fhp/Syntax/ParserTest.php @@ -0,0 +1,100 @@ +assertEquals([], Parser::splitEscapedString('+', '')); + $this->assertEquals(['', ''], Parser::splitEscapedString('+', '+')); + } + + public function testSplitEscapedStringWithoutEscaping() + { + $this->assertEquals(['ABC', 'DEF'], Parser::splitEscapedString('+', 'ABC+DEF')); + $this->assertEquals(['ABC', '', 'DEF'], Parser::splitEscapedString('+', 'ABC++DEF')); + $this->assertEquals(['ABC', ''], Parser::splitEscapedString('+', 'ABC+')); + $this->assertEquals(['', '', 'ABC'], Parser::splitEscapedString('+', '++ABC')); + } + + public function testSplitEscapedStringWithEscaping() + { + $this->assertEquals(['A?+', 'DEF'], Parser::splitEscapedString('+', 'A?++DEF')); + $this->assertEquals(['?+C', '', 'D?+'], Parser::splitEscapedString('+', '?+C++D?+')); + $this->assertEquals(['ABC', '?+'], Parser::splitEscapedString('+', 'ABC+?+')); + $this->assertEquals(['', '', '?+C'], Parser::splitEscapedString('+', '++?+C')); + } + + public function testSplitEscapedStringWithBinaryBlock() + { + $this->assertEquals(['A@4@xxxxD', 'EF'], Parser::splitEscapedString('+', 'A@4@xxxxD+EF')); + $this->assertEquals(['A@4@++++D', 'EF'], Parser::splitEscapedString('+', 'A@4@++++D+EF')); + $this->assertEquals(['A', '@1@x@0D', 'EF'], Parser::splitEscapedString('+', 'A+@1@x@0D+EF')); + $this->assertEquals(['@4@xxxxD', 'EF'], Parser::splitEscapedString('+', '@4@xxxxD+EF')); + $this->assertEquals(['A@4@xxxx', 'EF'], Parser::splitEscapedString('+', 'A@4@xxxx+EF')); + $this->assertEquals(['@4@xxxx'], Parser::splitEscapedString('+', '@4@xxxx')); + $this->assertEquals(['@4@++++'], Parser::splitEscapedString('+', '@4@++++')); + } + + public function testSplitEscapedStringWithEscapingAndBinaryBlock() + { + $this->assertEquals(['A@4@xxxxD', '?+'], Parser::splitEscapedString('+', 'A@4@xxxxD+?+')); + $this->assertEquals(['A@4@xxxx?+', 'EF'], Parser::splitEscapedString('+', 'A@4@xxxx?++EF')); + $this->assertEquals(['?+@4@+xxxD', '?+'], Parser::splitEscapedString('+', '?+@4@+xxxD+?+')); + $this->assertEquals(['?+@4@xxx+D', '?+'], Parser::splitEscapedString('+', '?+@4@xxx+D+?+')); + } + + public function testUnescape() + { + $this->assertEquals('ABC+DEF', Parser::unescape('ABC+DEF')); + $this->assertEquals('ABC+DEF', Parser::unescape('ABC?+DEF')); + $this->assertEquals('ABC?+DEF', Parser::unescape('ABC??+DEF')); + $this->assertEquals('ABC?DEF', Parser::unescape('ABC?DEF')); + $this->assertEquals('ABC:DEF', Parser::unescape('ABC?:DEF')); + } + + public function testParseDataElement() + { + $this->assertSame(15, Parser::parseDataElement('15', 'int')); + $this->assertSame(1000, Parser::parseDataElement('1000', 'integer')); + $this->assertSame(15.0, Parser::parseDataElement('15,', 'float')); + $this->assertSame(15.5, Parser::parseDataElement('15,5', 'float')); + $this->assertSame(0.0, Parser::parseDataElement('0,', 'float')); + $this->assertSame(true, Parser::parseDataElement('J', 'bool')); + $this->assertSame(false, Parser::parseDataElement('N', 'boolean')); + $this->assertSame('1000', Parser::parseDataElement('1000', 'string')); + $this->assertSame('ä', Parser::parseDataElement(mb_convert_encoding('ä', 'ISO-8859-1', 'UTF-8'), 'string')); + + $this->assertSame(null, Parser::parseDataElement('', 'int')); + $this->assertSame(null, Parser::parseDataElement('', 'string')); + } + + public function testParseDataElementInvalidInt() + { + $this->expectException(\InvalidArgumentException::class); + Parser::parseDataElement('lala', 'int'); + } + + public function testParseDataElementInvalidFloatWrongDecimalSeparator() + { + $this->expectException(\InvalidArgumentException::class); + Parser::parseDataElement('15.5', 'float'); + } + + public function testParseDataElementInvalidFloatMultipleDecimalSeparator() + { + $this->expectException(\InvalidArgumentException::class); + Parser::parseDataElement('15,5,5', 'float'); + } + + public function testParseDataElementInvalidFloatNoDecimalSeparator() + { + $this->expectException(\InvalidArgumentException::class); + Parser::parseDataElement('15', 'float'); + } + + // NOTE: Test coverage of DEGs and Segments is provided by tests in Test\Fhp\Segment. +} diff --git a/vendor/nemiah/php-fints/lib/Tests/Fhp/Syntax/SerializerTest.php b/vendor/nemiah/php-fints/lib/Tests/Fhp/Syntax/SerializerTest.php new file mode 100755 index 0000000..2b131c7 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Tests/Fhp/Syntax/SerializerTest.php @@ -0,0 +1,58 @@ +assertEquals($expected, Serializer::escape($input)); + } + + public function provideSerializeDataElement(): array + { + return [ // expected, value, type + ['15', 15, 'int'], + ['1000', 1000, 'integer'], + ['15,', 15.0, 'float'], + ['15,5', 15.5, 'float'], + ['0,', 0.0, 'float'], + ['J', true, 'bool'], + ['N', false, 'boolean'], + ['1000', '1000', 'string'], + [mb_convert_encoding('ä', 'ISO-8859-1', 'UTF-8'), 'ä', 'string'], + ['5?:5', '5:5', 'string'], + ['', null, 'int'], + ['', null, 'string'], + ]; + } + + /** @dataProvider provideSerializeDataElement */ + public function testSerializeDataElement($expected, $value, $type) + { + $this->assertSame($expected, Serializer::serializeDataElement($value, $type)); + } +} diff --git a/vendor/nemiah/php-fints/lib/Tests/Fhp/Unit/SendSEPADirectDebitTest.php b/vendor/nemiah/php-fints/lib/Tests/Fhp/Unit/SendSEPADirectDebitTest.php new file mode 100755 index 0000000..96d7d82 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Tests/Fhp/Unit/SendSEPADirectDebitTest.php @@ -0,0 +1,22 @@ +assertInstanceOf(SendSEPADirectDebit::class, $sepa); + } +} diff --git a/vendor/nemiah/php-fints/lib/Tests/phpunit_bootstrap.php b/vendor/nemiah/php-fints/lib/Tests/phpunit_bootstrap.php new file mode 100755 index 0000000..9c694c7 --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Tests/phpunit_bootstrap.php @@ -0,0 +1,6 @@ +register(new SegmentComparator()); diff --git a/vendor/nemiah/php-fints/lib/Tests/resources/pain.008.002.02.xml b/vendor/nemiah/php-fints/lib/Tests/resources/pain.008.002.02.xml new file mode 100755 index 0000000..1462b4a --- /dev/null +++ b/vendor/nemiah/php-fints/lib/Tests/resources/pain.008.002.02.xml @@ -0,0 +1,1477 @@ + + + + + FAKEPAIN397948648101 + 2025-08-26T21:03:32Z + 300 + 14380.00 + + + + + + PMT001 + DD + true + 1 + 50.00 + + SEPA + CORE + FRST + + 2025-08-30 + + Example Sports Club e.V. + + + DE11123456789012345678 + + FAKEDEFFXXX + + DE99ZZZ00000000001SEPA + + + INV001 + 50.00 + + MANDATE0012025-08-01 + + FAKEDEPPXXX + Max Mustermann + DE44123456789012345678 + Membership Fee August + + + + PMT002 + DD + true + 1 + 75.00 + + SEPA + CORE + RCUR + + 2025-08-30 + Example Sports Club e.V. + DE11123456789012345678 + FAKEDEFFXXX + + INV002 + 75.00 + + MANDATE0022024-09-15 + + FAKEXYZZXXX + Jane Doe + DE55123456789012345678 + Quarterly Training Fee + + + + PMT001 + DD + true + 1 + 50.00 + + SEPA + CORE + FRST + + 2025-08-30 + + Example Sports Club e.V. + + + DE11123456789012345678 + + FAKEDEFFXXX + + DE99ZZZ00000000001SEPA + + + INV001 + 50.00 + + MANDATE0012025-08-01 + + FAKEDEPPXXX + Max Mustermann + DE44123456789012345678 + Membership Fee August + + + + PMT002 + DD + true + 1 + 75.00 + + SEPA + CORE + RCUR + + 2025-08-30 + Example Sports Club e.V. + DE11123456789012345678 + FAKEDEFFXXX + + INV002 + 75.00 + + MANDATE0022024-09-15 + + FAKEXYZZXXX + Jane Doe + DE55123456789012345678 + Quarterly Training Fee + + + + PMT001 + DD + true + 1 + 50.00 + + SEPA + CORE + FRST + + 2025-08-30 + + Example Sports Club e.V. + + + DE11123456789012345678 + + FAKEDEFFXXX + + DE99ZZZ00000000001SEPA + + + INV001 + 50.00 + + MANDATE0012025-08-01 + + FAKEDEPPXXX + Max Mustermann + DE44123456789012345678 + Membership Fee August + + + + PMT002 + DD + true + 1 + 75.00 + + SEPA + CORE + RCUR + + 2025-08-30 + Example Sports Club e.V. + DE11123456789012345678 + FAKEDEFFXXX + + INV002 + 75.00 + + MANDATE0022024-09-15 + + FAKEXYZZXXX + Jane Doe + DE55123456789012345678 + Quarterly Training Fee + + + + PMT001 + DD + true + 1 + 50.00 + + SEPA + CORE + FRST + + 2025-08-30 + + Example Sports Club e.V. + + + DE11123456789012345678 + + FAKEDEFFXXX + + DE99ZZZ00000000001SEPA + + + INV001 + 50.00 + + MANDATE0012025-08-01 + + FAKEDEPPXXX + Max Mustermann + DE44123456789012345678 + Membership Fee August + + + + PMT002 + DD + true + 1 + 75.00 + + SEPA + CORE + RCUR + + 2025-08-30 + Example Sports Club e.V. + DE11123456789012345678 + FAKEDEFFXXX + + INV002 + 75.00 + + MANDATE0022024-09-15 + + FAKEXYZZXXX + Jane Doe + DE55123456789012345678 + Quarterly Training Fee + + + + PMT001 + DD + true + 1 + 50.00 + + SEPA + CORE + FRST + + 2025-08-30 + + Example Sports Club e.V. + + + DE11123456789012345678 + + FAKEDEFFXXX + + DE99ZZZ00000000001SEPA + + + INV001 + 50.00 + + MANDATE0012025-08-01 + + FAKEDEPPXXX + Max Mustermann + DE44123456789012345678 + Membership Fee August + + + + PMT002 + DD + true + 1 + 75.00 + + SEPA + CORE + RCUR + + 2025-08-30 + Example Sports Club e.V. + DE11123456789012345678 + FAKEDEFFXXX + + INV002 + 75.00 + + MANDATE0022024-09-15 + + FAKEXYZZXXX + Jane Doe + DE55123456789012345678 + Quarterly Training Fee + + + + PMT001 + DD + true + 1 + 50.00 + + SEPA + CORE + FRST + + 2025-08-30 + + Example Sports Club e.V. + + + DE11123456789012345678 + + FAKEDEFFXXX + + DE99ZZZ00000000001SEPA + + + INV001 + 50.00 + + MANDATE0012025-08-01 + + FAKEDEPPXXX + Max Mustermann + DE44123456789012345678 + Membership Fee August + + + + PMT002 + DD + true + 1 + 75.00 + + SEPA + CORE + RCUR + + 2025-08-30 + Example Sports Club e.V. + DE11123456789012345678 + FAKEDEFFXXX + + INV002 + 75.00 + + MANDATE0022024-09-15 + + FAKEXYZZXXX + Jane Doe + DE55123456789012345678 + Quarterly Training Fee + + + + PMT001 + DD + true + 1 + 50.00 + + SEPA + CORE + FRST + + 2025-08-30 + + Example Sports Club e.V. + + + DE11123456789012345678 + + FAKEDEFFXXX + + DE99ZZZ00000000001SEPA + + + INV001 + 50.00 + + MANDATE0012025-08-01 + + FAKEDEPPXXX + Max Mustermann + DE44123456789012345678 + Membership Fee August + + + + PMT002 + DD + true + 1 + 75.00 + + SEPA + CORE + RCUR + + 2025-08-30 + Example Sports Club e.V. + DE11123456789012345678 + FAKEDEFFXXX + + INV002 + 75.00 + + MANDATE0022024-09-15 + + FAKEXYZZXXX + Jane Doe + DE55123456789012345678 + Quarterly Training Fee + + + + PMT001 + DD + true + 1 + 50.00 + + SEPA + CORE + FRST + + 2025-08-30 + + Example Sports Club e.V. + + + DE11123456789012345678 + + FAKEDEFFXXX + + DE99ZZZ00000000001SEPA + + + INV001 + 50.00 + + MANDATE0012025-08-01 + + FAKEDEPPXXX + Max Mustermann + DE44123456789012345678 + Membership Fee August + + + + PMT002 + DD + true + 1 + 75.00 + + SEPA + CORE + RCUR + + 2025-08-30 + Example Sports Club e.V. + DE11123456789012345678 + FAKEDEFFXXX + + INV002 + 75.00 + + MANDATE0022024-09-15 + + FAKEXYZZXXX + Jane Doe + DE55123456789012345678 + Quarterly Training Fee + + + + PMT001 + DD + true + 1 + 50.00 + + SEPA + CORE + FRST + + 2025-08-30 + + Example Sports Club e.V. + + + DE11123456789012345678 + + FAKEDEFFXXX + + DE99ZZZ00000000001SEPA + + + INV001 + 50.00 + + MANDATE0012025-08-01 + + FAKEDEPPXXX + Max Mustermann + DE44123456789012345678 + Membership Fee August + + + + PMT002 + DD + true + 1 + 75.00 + + SEPA + CORE + RCUR + + 2025-08-30 + Example Sports Club e.V. + DE11123456789012345678 + FAKEDEFFXXX + + INV002 + 75.00 + + MANDATE0022024-09-15 + + FAKEXYZZXXX + Jane Doe + DE55123456789012345678 + Quarterly Training Fee + + + + PMT001 + DD + true + 1 + 50.00 + + SEPA + CORE + FRST + + 2025-08-30 + + Example Sports Club e.V. + + + DE11123456789012345678 + + FAKEDEFFXXX + + DE99ZZZ00000000001SEPA + + + INV001 + 50.00 + + MANDATE0012025-08-01 + + FAKEDEPPXXX + Max Mustermann + DE44123456789012345678 + Membership Fee August + + + + PMT002 + DD + true + 1 + 75.00 + + SEPA + CORE + RCUR + + 2025-08-30 + Example Sports Club e.V. + DE11123456789012345678 + FAKEDEFFXXX + + INV002 + 75.00 + + MANDATE0022024-09-15 + + FAKEXYZZXXX + Jane Doe + DE55123456789012345678 + Quarterly Training Fee + + + PMT001 + DD + true + 1 + 50.00 + + SEPA + CORE + FRST + + 2025-08-30 + + Example Sports Club e.V. + + + DE11123456789012345678 + + FAKEDEFFXXX + + DE99ZZZ00000000001SEPA + + + INV001 + 50.00 + + MANDATE0012025-08-01 + + FAKEDEPPXXX + Max Mustermann + DE44123456789012345678 + Membership Fee August + + + + PMT002 + DD + true + 1 + 75.00 + + SEPA + CORE + RCUR + + 2025-08-30 + Example Sports Club e.V. + DE11123456789012345678 + FAKEDEFFXXX + + INV002 + 75.00 + + MANDATE0022024-09-15 + + FAKEXYZZXXX + Jane Doe + DE55123456789012345678 + Quarterly Training Fee + + + PMT001 + DD + true + 1 + 50.00 + + SEPA + CORE + FRST + + 2025-08-30 + + Example Sports Club e.V. + + + DE11123456789012345678 + + FAKEDEFFXXX + + DE99ZZZ00000000001SEPA + + + INV001 + 50.00 + + MANDATE0012025-08-01 + + FAKEDEPPXXX + Max Mustermann + DE44123456789012345678 + Membership Fee August + + + + PMT002 + DD + true + 1 + 75.00 + + SEPA + CORE + RCUR + + 2025-08-30 + Example Sports Club e.V. + DE11123456789012345678 + FAKEDEFFXXX + + INV002 + 75.00 + + MANDATE0022024-09-15 + + FAKEXYZZXXX + Jane Doe + DE55123456789012345678 + Quarterly Training Fee + + + + PMT001 + DD + true + 1 + 50.00 + + SEPA + CORE + FRST + + 2025-08-30 + + Example Sports Club e.V. + + + DE11123456789012345678 + + FAKEDEFFXXX + + DE99ZZZ00000000001SEPA + + + INV001 + 50.00 + + MANDATE0012025-08-01 + + FAKEDEPPXXX + Max Mustermann + DE44123456789012345678 + Membership Fee August + + + + PMT002 + DD + true + 1 + 75.00 + + SEPA + CORE + RCUR + + 2025-08-30 + Example Sports Club e.V. + DE11123456789012345678 + FAKEDEFFXXX + + INV002 + 75.00 + + MANDATE0022024-09-15 + + FAKEXYZZXXX + Jane Doe + DE55123456789012345678 + Quarterly Training Fee + + + + PMT001 + DD + true + 1 + 50.00 + + SEPA + CORE + FRST + + 2025-08-30 + + Example Sports Club e.V. + + + DE11123456789012345678 + + FAKEDEFFXXX + + DE99ZZZ00000000001SEPA + + + INV001 + 50.00 + + MANDATE0012025-08-01 + + FAKEDEPPXXX + Max Mustermann + DE44123456789012345678 + Membership Fee August + + + + PMT002 + DD + true + 1 + 75.00 + + SEPA + CORE + RCUR + + 2025-08-30 + Example Sports Club e.V. + DE11123456789012345678 + FAKEDEFFXXX + + INV002 + 75.00 + + MANDATE0022024-09-15 + + FAKEXYZZXXX + Jane Doe + DE55123456789012345678 + Quarterly Training Fee + + + + PMT001 + DD + true + 1 + 50.00 + + SEPA + CORE + FRST + + 2025-08-30 + + Example Sports Club e.V. + + + DE11123456789012345678 + + FAKEDEFFXXX + + DE99ZZZ00000000001SEPA + + + INV001 + 50.00 + + MANDATE0012025-08-01 + + FAKEDEPPXXX + Max Mustermann + DE44123456789012345678 + Membership Fee August + + + + PMT002 + DD + true + 1 + 75.00 + + SEPA + CORE + RCUR + + 2025-08-30 + Example Sports Club e.V. + DE11123456789012345678 + FAKEDEFFXXX + + INV002 + 75.00 + + MANDATE0022024-09-15 + + FAKEXYZZXXX + Jane Doe + DE55123456789012345678 + Quarterly Training Fee + + + + PMT001 + DD + true + 1 + 50.00 + + SEPA + CORE + FRST + + 2025-08-30 + + Example Sports Club e.V. + + + DE11123456789012345678 + + FAKEDEFFXXX + + DE99ZZZ00000000001SEPA + + + INV001 + 50.00 + + MANDATE0012025-08-01 + + FAKEDEPPXXX + Max Mustermann + DE44123456789012345678 + Membership Fee August + + + + PMT002 + DD + true + 1 + 75.00 + + SEPA + CORE + RCUR + + 2025-08-30 + Example Sports Club e.V. + DE11123456789012345678 + FAKEDEFFXXX + + INV002 + 75.00 + + MANDATE0022024-09-15 + + FAKEXYZZXXX + Jane Doe + DE55123456789012345678 + Quarterly Training Fee + + + + PMT001 + DD + true + 1 + 50.00 + + SEPA + CORE + FRST + + 2025-08-30 + + Example Sports Club e.V. + + + DE11123456789012345678 + + FAKEDEFFXXX + + DE99ZZZ00000000001SEPA + + + INV001 + 50.00 + + MANDATE0012025-08-01 + + FAKEDEPPXXX + Max Mustermann + DE44123456789012345678 + Membership Fee August + + + + PMT002 + DD + true + 1 + 75.00 + + SEPA + CORE + RCUR + + 2025-08-30 + Example Sports Club e.V. + DE11123456789012345678 + FAKEDEFFXXX + + INV002 + 75.00 + + MANDATE0022024-09-15 + + FAKEXYZZXXX + Jane Doe + DE55123456789012345678 + Quarterly Training Fee + + + + PMT001 + DD + true + 1 + 50.00 + + SEPA + CORE + FRST + + 2025-08-30 + + Example Sports Club e.V. + + + DE11123456789012345678 + + FAKEDEFFXXX + + DE99ZZZ00000000001SEPA + + + INV001 + 50.00 + + MANDATE0012025-08-01 + + FAKEDEPPXXX + Max Mustermann + DE44123456789012345678 + Membership Fee August + + + + PMT002 + DD + true + 1 + 75.00 + + SEPA + CORE + RCUR + + 2025-08-30 + Example Sports Club e.V. + DE11123456789012345678 + FAKEDEFFXXX + + INV002 + 75.00 + + MANDATE0022024-09-15 + + FAKEXYZZXXX + Jane Doe + DE55123456789012345678 + Quarterly Training Fee + + + + PMT001 + DD + true + 1 + 50.00 + + SEPA + CORE + FRST + + 2025-08-30 + + Example Sports Club e.V. + + + DE11123456789012345678 + + FAKEDEFFXXX + + DE99ZZZ00000000001SEPA + + + INV001 + 50.00 + + MANDATE0012025-08-01 + + FAKEDEPPXXX + Max Mustermann + DE44123456789012345678 + Membership Fee August + + + + PMT002 + DD + true + 1 + 75.00 + + SEPA + CORE + RCUR + + 2025-08-30 + Example Sports Club e.V. + DE11123456789012345678 + FAKEDEFFXXX + + INV002 + 75.00 + + MANDATE0022024-09-15 + + FAKEXYZZXXX + Jane Doe + DE55123456789012345678 + Quarterly Training Fee + + + + PMT001 + DD + true + 1 + 50.00 + + SEPA + CORE + FRST + + 2025-08-30 + + Example Sports Club e.V. + + + DE11123456789012345678 + + FAKEDEFFXXX + + DE99ZZZ00000000001SEPA + + + INV001 + 50.00 + + MANDATE0012025-08-01 + + FAKEDEPPXXX + Max Mustermann + DE44123456789012345678 + Membership Fee August + + + + PMT002 + DD + true + 1 + 75.00 + + SEPA + CORE + RCUR + + 2025-08-30 + Example Sports Club e.V. + DE11123456789012345678 + FAKEDEFFXXX + + INV002 + 75.00 + + MANDATE0022024-09-15 + + FAKEXYZZXXX + Jane Doe + DE55123456789012345678 + Quarterly Training Fee + + + + PMT001 + DD + true + 1 + 50.00 + + SEPA + CORE + FRST + + 2025-08-30 + + Example Sports Club e.V. + + + DE11123456789012345678 + + FAKEDEFFXXX + + DE99ZZZ00000000001SEPA + + + INV001 + 50.00 + + MANDATE0012025-08-01 + + FAKEDEPPXXX + Max Mustermann + DE44123456789012345678 + Membership Fee August + + + + PMT002 + DD + true + 1 + 75.00 + + SEPA + CORE + RCUR + + 2025-08-30 + Example Sports Club e.V. + DE11123456789012345678 + FAKEDEFFXXX + + INV002 + 75.00 + + MANDATE0022024-09-15 + + FAKEXYZZXXX + Jane Doe + DE55123456789012345678 + Quarterly Training Fee + + + + PMT001 + DD + true + 1 + 50.00 + + SEPA + CORE + FRST + + 2025-08-30 + + Example Sports Club e.V. + + + DE11123456789012345678 + + FAKEDEFFXXX + + DE99ZZZ00000000001SEPA + + + INV001 + 50.00 + + MANDATE0012025-08-01 + + FAKEDEPPXXX + Max Mustermann + DE44123456789012345678 + Membership Fee August + + + + PMT002 + DD + true + 1 + 75.00 + + SEPA + CORE + RCUR + + 2025-08-30 + Example Sports Club e.V. + DE11123456789012345678 + FAKEDEFFXXX + + INV002 + 75.00 + + MANDATE0022024-09-15 + + FAKEXYZZXXX + Jane Doe + DE55123456789012345678 + Quarterly Training Fee + + + + PMT001 + DD + true + 1 + 50.00 + + SEPA + CORE + FRST + + 2025-08-30 + + Example Sports Club e.V. + + + DE11123456789012345678 + + FAKEDEFFXXX + + DE99ZZZ00000000001SEPA + + + INV001 + 50.00 + + MANDATE0012025-08-01 + + FAKEDEPPXXX + Max Mustermann + DE44123456789012345678 + Membership Fee August + + + + PMT002 + DD + true + 1 + 75.00 + + SEPA + CORE + RCUR + + 2025-08-30 + Example Sports Club e.V. + DE11123456789012345678 + FAKEDEFFXXX + + INV002 + 75.00 + + MANDATE0022024-09-15 + + FAKEXYZZXXX + Jane Doe + DE55123456789012345678 + Quarterly Training Fee + + + + PMT001 + DD + true + 1 + 50.00 + + SEPA + CORE + FRST + + 2025-08-30 + + Example Sports Club e.V. + + + DE11123456789012345678 + + FAKEDEFFXXX + + DE99ZZZ00000000001SEPA + + + INV001 + 50.00 + + MANDATE0012025-08-01 + + FAKEDEPPXXX + Max Mustermann + DE44123456789012345678 + Membership Fee August + + + + PMT002 + DD + true + 1 + 75.00 + + SEPA + CORE + RCUR + + 2025-08-30 + Example Sports Club e.V. + DE11123456789012345678 + FAKEDEFFXXX + + INV002 + 75.00 + + MANDATE0022024-09-15 + + FAKEXYZZXXX + Jane Doe + DE55123456789012345678 + Quarterly Training Fee + + + + \ No newline at end of file diff --git a/vendor/nemiah/php-fints/phplint.sh b/vendor/nemiah/php-fints/phplint.sh new file mode 100755 index 0000000..002ac6e --- /dev/null +++ b/vendor/nemiah/php-fints/phplint.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +error=false + +while test $# -gt 0; do + current=$1 + shift + + if [ ! -d $current ] && [ ! -f $current ] ; then + echo "Invalid directory or file: $current" + error=true + + continue + fi + + for file in `find $current -type f -name "*.php"` ; do + RESULTS=`php -l $file` + if [ "$RESULTS" != "No syntax errors detected in $file" ] ; then + echo $RESULTS + error=true + fi + done +done + + +if [ "$error" = true ] ; then + exit 1 +else + echo No syntax errors detected. + exit 0 +fi diff --git a/vendor/nemiah/php-fints/phpunit.xml.dist b/vendor/nemiah/php-fints/phpunit.xml.dist new file mode 100755 index 0000000..82e780f --- /dev/null +++ b/vendor/nemiah/php-fints/phpunit.xml.dist @@ -0,0 +1,23 @@ + + + + + ./lib/Fhp/ + + + + + ./lib/Tests/Fhp/ + + + diff --git a/vendor/nemiah/php-fints/prettify_message.php b/vendor/nemiah/php-fints/prettify_message.php new file mode 100755 index 0000000..f7b34be --- /dev/null +++ b/vendor/nemiah/php-fints/prettify_message.php @@ -0,0 +1,13 @@ +