diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..182c67c7 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,33 @@ +# .github/workflows/tests.yml +name: tests + +on: + push: + branches: + - master + pull_request: + +jobs: + phpunit: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + php: [ '8.0', '8.1', '8.2', '8.3', '8.4' ] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: mbstring + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist --no-progress + + - name: Run PHPUnit + run: ./vendor/bin/phpunit diff --git a/Samples/browser.php b/Samples/browser.php index 3a16b5ce..b6cc2db4 100644 --- a/Samples/browser.php +++ b/Samples/browser.php @@ -58,18 +58,37 @@ function handleRequest(\stdClass $request, \Fhp\FinTs $fints) 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); // We assume that needsTan() is always false here. + $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); // We assume that needsTan() is always false here. + $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(); diff --git a/Samples/login.php b/Samples/login.php index 11f844f0..f2d020a7 100644 --- a/Samples/login.php +++ b/Samples/login.php @@ -32,7 +32,7 @@ * on its user interfaces. The implementation does not have to be in a single function like this -- it can be inlined * with the calling code, or live elsewhere. The TAN/confirmation can be obtained while the same PHP script is still * running (i.e. handleStrongAuthentication() is a blocking function that only returns once the authentication is done, - * which is useful for a CLI application), but it is also possible to interrupt the PHP execution entirely while asking + * which is useful for a CLI application), but it is also possible to interrupt the PHP process entirely while asking * for the TAN/confirmation and resume it later (which is useful for a web application). * * @param \Fhp\BaseAction $action Some action that requires strong authentication. @@ -51,6 +51,7 @@ function handleStrongAuthentication(\Fhp\BaseAction $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. */ @@ -93,7 +94,7 @@ function handleTan(Fhp\BaseAction $action) // 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 execution and the TAN will arrive in a fresh (HTTP/REST/...) request, you can do so: + // 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(); @@ -116,7 +117,7 @@ function handleTan(Fhp\BaseAction $action) 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 execution). + // 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); @@ -130,14 +131,15 @@ function handleTan(Fhp\BaseAction $action) /** * 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. + * 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; + global $fints, $options, $credentials; $tanMode = $fints->getSelectedTanMode(); $tanRequest = $action->getTanRequest(); @@ -150,6 +152,18 @@ function handleDecoupled(Fhp\BaseAction $action) 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 @@ -162,22 +176,44 @@ function handleDecoupled(Fhp\BaseAction $action) $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()) { - do { - 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"; - } while (!$fints->checkDecoupledSubmission($action)); + 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'); diff --git a/lib/Fhp/Action/SendSEPADirectDebit.php b/lib/Fhp/Action/SendSEPADirectDebit.php index 6452c508..1c9fe599 100644 --- a/lib/Fhp/Action/SendSEPADirectDebit.php +++ b/lib/Fhp/Action/SendSEPADirectDebit.php @@ -55,11 +55,11 @@ public static function create(SEPAAccount $account, string $painMessage, bool $t $nbOfTxs = substr_count($painMessage, ''); $ctrlSum = null; - if (preg_match('@.*(?[.0-9]+).*@s', $painMessage, $matches) === 1) { + if (preg_match('@.*?(?[0-9.]+).*?@s', $painMessage, $matches) === 1) { $ctrlSum = $matches['ctrlsum']; } - if (preg_match('@.*.*(?CORE|COR1|B2B).*.*@s', $painMessage, $matches) === 1) { + 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'); @@ -138,17 +138,26 @@ protected function createRequest(BPD $bpd, ?UPD $upd) if ($hidxes->getVersion() === 2) { /** @var HIDMESv2|HIDSESv2 $hidxes */ - $supportedPainNamespaces = $hidxes->getParameter()->unterstuetzteSEPADatenformate; + $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(); + $supportedPainNamespaces = $hispas->getParameter()->getUnterstuetzteSEPADatenformate(); } - if (!in_array($this->painNamespace, $supportedPainNamespaces)) { + // 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)); } diff --git a/lib/Fhp/Action/SendSEPARealtimeTransfer.php b/lib/Fhp/Action/SendSEPARealtimeTransfer.php index 1180124f..22dfda31 100644 --- a/lib/Fhp/Action/SendSEPARealtimeTransfer.php +++ b/lib/Fhp/Action/SendSEPARealtimeTransfer.php @@ -58,10 +58,25 @@ protected function createRequest(BPD $bpd, ?UPD $upd) /** @var HIIPZSv1|HIIPZSv2 $hiipzs */ $hiipzs = $bpd->requireLatestSupportedParameters('HIIPZS'); - /** @var HISPAS $hispas */ - $hispas = $bpd->requireLatestSupportedParameters('HISPAS'); - $supportedSchemas = $hispas->getParameter()->getUnterstuetzteSepaDatenformate(); - if (!in_array($this->xmlSchema, $supportedSchemas)) { + $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)); } diff --git a/lib/Fhp/Action/SendSEPATransfer.php b/lib/Fhp/Action/SendSEPATransfer.php index e5840dfc..0bac220f 100644 --- a/lib/Fhp/Action/SendSEPATransfer.php +++ b/lib/Fhp/Action/SendSEPATransfer.php @@ -33,12 +33,12 @@ class SendSEPATransfer extends BaseAction * to create this. * @return SendSEPATransfer A new action for executing this the given PAIN message. */ - public static function create(SEPAAccount $account, string $painMessage): SendSEPATransfer + public static function create(SEPAAccount $account, string $painMessage): static { if (preg_match('/xmlns="(.*?)"/', $painMessage, $match) === false) { throw new \InvalidArgumentException('xmlns not found in the PAIN message'); } - $result = new SendSEPATransfer(); + $result = new static(); $result->account = $account; $result->painMessage = $painMessage; $result->xmlSchema = $match[1]; @@ -55,7 +55,19 @@ protected function createRequest(BPD $bpd, ?UPD $upd) $hasReqdExDates = false; $batchBooking = false; foreach ($xmlAsObject->CstmrCdtTrfInitn?->PmtInf as $pmtInfo) { - if (isset($pmtInfo->ReqdExctnDt) && $pmtInfo->ReqdExctnDt != '1999-01-01') $hasReqdExDates = true; + $CtrlSum += (float)$pmtInfo->CtrlSum; + // Checks for both, 1999-01-01 and
1999-01-01
+ if (isset($pmtInfo->ReqdExctnDt) && ($pmtInfo->ReqdExctnDt->Dt ?? $pmtInfo->ReqdExctnDt) != '1999-01-01') $hasReqdExDates = true; + if (isset($pmtInfo->BtchBookg)) $batchBooking = (string)$pmtInfo->BtchBookg == 'true'; + } + + //CHECK IF $hasReqdExDates and set other to tomorrow + if ($hasReqdExDates) { + foreach ($xmlAsObject->CstmrCdtTrfInitn?->PmtInf as $pmtInfo) { + if (isset($pmtInfo->ReqdExctnDt) && $pmtInfo->ReqdExctnDt == '1999-01-01') { + throw new UnsupportedException('Terminierte SEPA-Sammelüberweisung (Segment HKCME / Kennung HICMES) requires all entries to be in future'); + } + } if (isset($pmtInfo->BtchBookg)) $batchBooking = (string)$pmtInfo->BtchBookg == 'true'; } @@ -101,9 +113,19 @@ protected function createRequest(BPD $bpd, ?UPD $upd) } /** @var HISPAS $hispas */ - $parameters = $bpd->requireLatestSupportedParameters('HISPAS'); - $supportedSchemas = $parameters->getParameter()->getUnterstuetzteSepaDatenformate(); - if (!in_array($this->xmlSchema, $supportedSchemas)) { + $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)); } diff --git a/lib/Fhp/Action/SendSEPATransferVoP.php b/lib/Fhp/Action/SendSEPATransferVoP.php new file mode 100644 index 00000000..4624efe9 --- /dev/null +++ b/lib/Fhp/Action/SendSEPATransferVoP.php @@ -0,0 +1,151 @@ +vopIsPending) { + $this->hkvpp->pollingId = $this->hivpp->pollingId; + $this->hkvpp->aufsetzpunkt = $this->paginationToken; + return $this->hkvpp; + } + + $requestSegment = parent::createRequest($bpd, $upd); + $requestSegments = [$requestSegment]; + + if ($this->vopNeedsConfirmation && $this->vopConfirmed) { + + $hkvpa = HKVPAv1::createEmpty(); + $hkvpa->vopId = $this->hivpp->vopId; + return [$hkvpa, $requestSegment]; + } + + // Check if VoP is supported by the bank + + /** @var HIVPPSv1 $hivpps */ + if ($hivpps = $bpd->getLatestSupportedParameters('HIVPPS')) { + // Check if the request segment is in the list of VoP-supported segments + if (in_array($requestSegment->getName(), $hivpps->parameter->vopPflichtigerZahlungsverkehrsauftrag)) { + + $this->vopRequired = true; + + // Send VoP confirmation + if ($this->needsConfirmation() && $this->hivpp?->vopId) { + $hkvpp = HKVPAv1::createEmpty(); + $hkvpp->vopId = $this->hivpp->vopId; + $requestSegments = [$hkvpp, $requestSegment]; + } else { + // Ask for VoP + $this->hkvpp = $hkvpp = HKVPPv1::createEmpty(); + + // For now just pretend we support all formats + $supportedFormats = explode(';', $hivpps->parameter->unterstuetztePaymentStatusReportDatenformate); + $hkvpp->unterstuetztePaymentStatusReports->paymentStatusReportDescriptor = $supportedFormats; + + // VoP before the transfer request + $requestSegments = [$hkvpp, $requestSegment]; + } + } + } + + return $requestSegments; + } + + public function processResponse(Message $response) + { + $this->vopIsPending = false; + $this->hivpp = $response->findSegment(HIVPPv1::class); + + // The Bank does not want a separate HKVPA ("VoP Ausführungsauftrag"). + if ($response->findRueckmeldung(Rueckmeldungscode::VOP_AUSFUEHRUNGSAUFTRAG_NICHT_BENOETIGT) !== null) { + $this->vopRequired = false; + $this->vopNeedsConfirmation = false; + parent::processResponse($response); + return; + } + + if ($response->findRueckmeldung(Rueckmeldungscode::VOP_NAMENSABGLEICH_IST_NOCH_IN_BEARBEITUNG) !== null) { + $this->vopIsPending = true; + $this->vopNeedsConfirmation = false; + return; + } + + if (($pagination = $response->findRueckmeldung(Rueckmeldungscode::PAGINATION)) !== null) { + $this->paginationToken = $pagination->rueckmeldungsparameter[0]; + } + + if ( + $response->findRueckmeldung(Rueckmeldungscode::VOP_KEINE_NAMENSABWEICHUNG) !== null + // The bank has discarded the request, and wants us to resend it with a HKVPA + // This can happen even if the name matches. + || $response->findRueckmeldung(Rueckmeldungscode::FREIGABE_KANN_NICHT_ERTEILT_WERDEN) !== null + // The user needs to check the result of the name check. + // This can be sent by the bank even if the name matches. + || $response->findRueckmeldung(Rueckmeldungscode::VOP_ERGEBNIS_NAMENSABGLEICH_PRUEFEN) !== null + ) { + $this->vopNeedsConfirmation = true; + // Is the result already available? + if (!$this->hivpp->vopId) { + $this->vopIsPending = true; + } + return; + } + + // The bank accepted the request as is. + if ($response->findRueckmeldung(Rueckmeldungscode::ENTGEGENGENOMMEN) !== null || $response->findRueckmeldung(Rueckmeldungscode::AUSGEFUEHRT) !== null) { + $this->vopRequired = false; + parent::processResponse($response); + return; + } + + throw new UnsupportedException('Unexpected state in VoP process'); + } + + public function needsTime() + { + return $this->vopIsPending; + } + + public function needsConfirmation() + { + return $this->vopNeedsConfirmation; + } + + public function setConfirmed() + { + $this->vopConfirmed = true; + } +} \ No newline at end of file diff --git a/lib/Fhp/BaseAction.php b/lib/Fhp/BaseAction.php index d36dec6a..93662fa0 100644 --- a/lib/Fhp/BaseAction.php +++ b/lib/Fhp/BaseAction.php @@ -144,6 +144,16 @@ public function getTanRequest(): ?TanRequest return $this->tanRequest; } + public function needsConfirmation() + { + return false; + } + + public function needsTime() + { + return false; + } + /** * 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 diff --git a/lib/Fhp/FinTs.php b/lib/Fhp/FinTs.php index fe6d5d63..30710e33 100644 --- a/lib/Fhp/FinTs.php +++ b/lib/Fhp/FinTs.php @@ -32,7 +32,7 @@ /** * This is the main class of this library. Please see the Samples directory for how to use it. - * This class is not thread-safe, do not call its funtions concurrently. + * This class is not thread-safe. Do not call its funtions concurrently. */ class FinTs { @@ -50,7 +50,7 @@ class FinTs /** @var string|null This is a {@link TanMedium::getName()}, but we don't have the {@link TanMedium} instance. */ private $selectedTanMedium; - // State that persists across physical connections, dialogs and even PHP executions. + // State that persists across physical connections, dialogs and even PHP processes. /** @var BPD|null */ private $bpd; /** @var int[]|null The IDs of the {@link TanMode}s from the BPD which the user is allowed to use. */ @@ -74,8 +74,8 @@ class FinTs * @param Credentials $credentials Authentication information for the user. Note: This library does not support * anonymous connections, so the credentials are mandatory. * @param string|null $persistedInstance The return value of {@link persist()} of a previous FinTs instance, - * usually from an earlier PHP execution. Passing this in here saves 1-2 dialogs that are normally made with the - * bank to obtain the BPD and Kundensystem-ID. + * 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. */ public static function new(FinTsOptions $options, Credentials $credentials, ?string $persistedInstance = null): FinTs { @@ -89,7 +89,7 @@ public static function new(FinTsOptions $options, Credentials $credentials, ?str } /** - * This function allows to fetch the BPD without knowing the user's credentials yet, by using an anonymous dialog. + * 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. @@ -118,9 +118,8 @@ protected function __construct(FinTsOptions $options, ?Credentials $credentials) } /** - * Destructing the object only disconnects. Please use {@link close()} if you want to properly "log out", i.e. end - * the FinTs dialog. On the other hand, do *not* close in case you have serialized the FinTs instance and intend - * to resume it later due to a TAN request. + * 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() { @@ -128,24 +127,38 @@ public function __destruct() } /** - * Returns a serialized form of parts of this object. This is different from PHP's `\Serializable` in that it only - * serializes parts and cannot simply be restored with `unserialize()` because the `FinTsOptions` and the - * `Credentials` need to be passed to FinTs::new() in addition to the string returned here. + * 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. + * Alternatively, you can use {@link loadPersistedInstance()} to separate constructing the instance and resuming it. * - * NOTE: Unless you're persisting this object to complete a TAN request later on, you probably want to log the user - * out first by calling {@link close()}. + * 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. * - * @param bool $minimal If true, the return value only contains only those values that are 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. + * 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 + * 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. + * 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 { @@ -175,11 +188,16 @@ public function __unserialize(array $data): void } /** - * Use this to continue a previous FinTs Instance, for example after a TAN was needed and PHP execution was ended to - * obtain it from the user. + * 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 execution. + * 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 */ @@ -245,8 +263,10 @@ public function setTimeouts(int $connectTimeout, int $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, finish the login by passing the {@link BaseAction} - * returned here to {@link submitTan()} or {@link checkDecoupledSubmission()}. + * 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 @@ -265,10 +285,18 @@ public function login(): DialogInitialization /** * 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 result of the action is stored inside - * the action itself, so you need to check {@link BaseAction::needsTan()} to see if it needs a TAN before being - * completed and use its getters in order to obtain the result. In case the action fails, the corresponding - * exception will be thrown from this function. + * 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. @@ -292,8 +320,8 @@ public function execute(BaseAction $action) $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)); + $hktan = HKTANFactory::createProzessvariante2Step1($this->requireTanMode(), $this->selectedTanMedium, $needTanForSegment); + $message->add($hktan); } } $request = $this->buildMessage($message, $this->getSelectedTanMode()); @@ -326,7 +354,11 @@ public function execute(BaseAction $action) } // If no TAN is needed, process the response normally, and maybe keep going for more pages. - $this->processActionResponse($action, $response->filterByReferenceSegments($action->getRequestSegmentNumbers())); + $requestSegmentsNumbers = $action->getRequestSegmentNumbers(); + if (isset($hktan)) { + $requestSegmentsNumbers[] = $hktan->getSegmentNumber(); + } + $this->processActionResponse($action, $response->filterByReferenceSegments($requestSegmentsNumbers)); if ($action instanceof PaginateableAction && $action->hasMorePages()) { $this->execute($action); } @@ -334,8 +366,12 @@ public function execute(BaseAction $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 in order to complete the action. This can be done - * asynchronously, i.e. not in the same PHP process as the original {@link execute()} call. + * `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 @@ -400,10 +436,18 @@ public function submitTan(BaseAction $action, string $tan) /** * 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`, otherwise it - * returns `false` and the action remains in its previous, uncompleted state. - * This function can be called asynchronously, i.e. not in the same PHP process as the original {@link execute()} - * call, and also repeatedly subject to the delays specified in the {@link TanMode}. + * 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 @@ -491,7 +535,11 @@ public function checkDecoupledSubmission(BaseAction $action): bool } /** - * Closes open dialog/connection if any. This instance remains usable. + * 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() @@ -503,9 +551,10 @@ public function close() } /** - * Assumes that the dialog (if any is open) is gone. 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, so that the dialog/session has - * most likely been closed at the server side already. + * 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() { diff --git a/lib/Fhp/Segment/BME/ParameterTerminierteSEPAFirmenSammellastschriftEinreichenV2.php b/lib/Fhp/Segment/BME/ParameterTerminierteSEPAFirmenSammellastschriftEinreichenV2.php index 34f76f12..e4ed651d 100644 --- a/lib/Fhp/Segment/BME/ParameterTerminierteSEPAFirmenSammellastschriftEinreichenV2.php +++ b/lib/Fhp/Segment/BME/ParameterTerminierteSEPAFirmenSammellastschriftEinreichenV2.php @@ -3,9 +3,13 @@ namespace Fhp\Segment\BME; use Fhp\Segment\BSE\ParameterTerminierteSEPAFirmenLastschriftEinreichenV2; +use Fhp\Segment\UnterstuetzteSEPADatenformate; +use Fhp\Segment\UnterstuetzteSEPADatenformateTrait; -class ParameterTerminierteSEPAFirmenSammellastschriftEinreichenV2 extends ParameterTerminierteSEPAFirmenLastschriftEinreichenV2 +class ParameterTerminierteSEPAFirmenSammellastschriftEinreichenV2 extends ParameterTerminierteSEPAFirmenLastschriftEinreichenV2 implements UnterstuetzteSEPADatenformate { + use UnterstuetzteSEPADatenformateTrait; + public int $maximaleAnzahlDirectDebitTransferTransactionInformation; public bool $summenfeldBenoetigt; public bool $einzelbuchungErlaubt; diff --git a/lib/Fhp/Segment/BSE/ParameterTerminierteSEPAFirmenEinzellastschriftEinreichenV2.php b/lib/Fhp/Segment/BSE/ParameterTerminierteSEPAFirmenEinzellastschriftEinreichenV2.php index ee09677d..dc256afe 100644 --- a/lib/Fhp/Segment/BSE/ParameterTerminierteSEPAFirmenEinzellastschriftEinreichenV2.php +++ b/lib/Fhp/Segment/BSE/ParameterTerminierteSEPAFirmenEinzellastschriftEinreichenV2.php @@ -2,8 +2,13 @@ namespace Fhp\Segment\BSE; -class ParameterTerminierteSEPAFirmenEinzellastschriftEinreichenV2 extends ParameterTerminierteSEPAFirmenLastschriftEinreichenV2 +use Fhp\Segment\UnterstuetzteSEPADatenformate; +use Fhp\Segment\UnterstuetzteSEPADatenformateTrait; + +class ParameterTerminierteSEPAFirmenEinzellastschriftEinreichenV2 extends ParameterTerminierteSEPAFirmenLastschriftEinreichenV2 implements UnterstuetzteSEPADatenformate { + use UnterstuetzteSEPADatenformateTrait; + /** Max Length: 4096 */ public ?string $zulaessigePurposecodes = null; diff --git a/lib/Fhp/Segment/DME/ParameterTerminierteSEPASammellastschriftEinreichenV2.php b/lib/Fhp/Segment/DME/ParameterTerminierteSEPASammellastschriftEinreichenV2.php index 02070597..ebaa9f46 100644 --- a/lib/Fhp/Segment/DME/ParameterTerminierteSEPASammellastschriftEinreichenV2.php +++ b/lib/Fhp/Segment/DME/ParameterTerminierteSEPASammellastschriftEinreichenV2.php @@ -3,9 +3,13 @@ namespace Fhp\Segment\DME; use Fhp\Segment\DSE\ParameterTerminierteSEPALastschriftEinreichenV2; +use Fhp\Segment\UnterstuetzteSEPADatenformate; +use Fhp\Segment\UnterstuetzteSEPADatenformateTrait; -class ParameterTerminierteSEPASammellastschriftEinreichenV2 extends ParameterTerminierteSEPALastschriftEinreichenV2 +class ParameterTerminierteSEPASammellastschriftEinreichenV2 extends ParameterTerminierteSEPALastschriftEinreichenV2 implements UnterstuetzteSEPADatenformate { + use UnterstuetzteSEPADatenformateTrait; + public int $maximaleAnzahlDirectDebitTransferTransactionInformation; public bool $summenfeldBenoetigt; public bool $einzelbuchungErlaubt; diff --git a/lib/Fhp/Segment/DSE/HIDSESv1.php b/lib/Fhp/Segment/DSE/HIDSESv1.php index 9e77934c..9ee8b5b5 100644 --- a/lib/Fhp/Segment/DSE/HIDSESv1.php +++ b/lib/Fhp/Segment/DSE/HIDSESv1.php @@ -15,7 +15,7 @@ class HIDSESv1 extends BaseGeschaeftsvorfallparameter implements HIDXES { public ParameterTerminierteSEPAEinzellastschriftEinreichenV1 $parameter; - public function getParameter(): SEPADirectDebitMinimalLeadTimeProvider + public function getParameter(): ParameterTerminierteSEPAEinzellastschriftEinreichenV1 { return $this->parameter; } diff --git a/lib/Fhp/Segment/DSE/HIDSESv2.php b/lib/Fhp/Segment/DSE/HIDSESv2.php index 17f45000..680d12f4 100644 --- a/lib/Fhp/Segment/DSE/HIDSESv2.php +++ b/lib/Fhp/Segment/DSE/HIDSESv2.php @@ -15,7 +15,7 @@ class HIDSESv2 extends BaseGeschaeftsvorfallparameter implements HIDXES { public ParameterTerminierteSEPAEinzellastschriftEinreichenV2 $parameter; - public function getParameter(): SEPADirectDebitMinimalLeadTimeProvider + public function getParameter(): ParameterTerminierteSEPAEinzellastschriftEinreichenV2 { return $this->parameter; } diff --git a/lib/Fhp/Segment/DSE/ParameterTerminierteSEPAEinzellastschriftEinreichenV2.php b/lib/Fhp/Segment/DSE/ParameterTerminierteSEPAEinzellastschriftEinreichenV2.php index f6a6f629..1123b5f1 100644 --- a/lib/Fhp/Segment/DSE/ParameterTerminierteSEPAEinzellastschriftEinreichenV2.php +++ b/lib/Fhp/Segment/DSE/ParameterTerminierteSEPAEinzellastschriftEinreichenV2.php @@ -2,8 +2,13 @@ namespace Fhp\Segment\DSE; -class ParameterTerminierteSEPAEinzellastschriftEinreichenV2 extends ParameterTerminierteSEPALastschriftEinreichenV2 +use Fhp\Segment\UnterstuetzteSEPADatenformate; +use Fhp\Segment\UnterstuetzteSEPADatenformateTrait; + +class ParameterTerminierteSEPAEinzellastschriftEinreichenV2 extends ParameterTerminierteSEPALastschriftEinreichenV2 implements UnterstuetzteSEPADatenformate { + use UnterstuetzteSEPADatenformateTrait; + /** Max Length: 4096 */ public ?string $zulaessigePurposecodes = null; diff --git a/lib/Fhp/Segment/HIRMS/Rueckmeldungscode.php b/lib/Fhp/Segment/HIRMS/Rueckmeldungscode.php index 7a53a5e2..de648f04 100644 --- a/lib/Fhp/Segment/HIRMS/Rueckmeldungscode.php +++ b/lib/Fhp/Segment/HIRMS/Rueckmeldungscode.php @@ -79,6 +79,16 @@ public static function isError(int $code): bool */ public const PAGINATION = 3040; + public const VOP_KEINE_NAMENSABWEICHUNG = 25; + + public const VOP_ERGEBNIS_NAMENSABGLEICH_PRUEFEN = 3090; + + public const VOP_AUSFUEHRUNGSAUFTRAG_NICHT_BENOETIGT = 3091; + + public const VOP_NAMENSABGLEICH_IST_NOCH_IN_BEARBEITUNG = 3093; + + public const VOP_NAMENSABGLEICH_IST_KOMPLETT = 3094; + /** * Zugelassene Ein- und Zwei-Schritt-Verfahren für den Benutzer (+ Rückmeldungsparameter). * The parameters reference the VerfahrensparameterZweiSchrittVerfahren.sicherheitsfunktion values (900..997) from @@ -99,6 +109,14 @@ public static function isError(int $code): bool */ public const ZUGANG_VORLAEUFIG_GESPERRT = 3938; + /** + * Der eingereichte HKTAN ist entwertet und der Auftrag (nach + * vollständiger Übermittlung des Prüfergebnisses) soll erneut mit einem neuen + * HKTAN in Verbindung mit einem HKVPA eingereicht werden, sofern der + * Kunde die Ausführung weiterhin wünscht. + */ + public const FREIGABE_KANN_NICHT_ERTEILT_WERDEN = 3945; + /** * Starke Kundenauthentifizierung noch ausstehend. * Indicates that the decoupled authentication is still outstanding. diff --git a/lib/Fhp/Segment/IPZ/HIIPZSv2.php b/lib/Fhp/Segment/IPZ/HIIPZSv2.php index 18a55f34..e78c92ba 100644 --- a/lib/Fhp/Segment/IPZ/HIIPZSv2.php +++ b/lib/Fhp/Segment/IPZ/HIIPZSv2.php @@ -2,7 +2,7 @@ namespace Fhp\Segment\IPZ; -use Fhp\lib\Fhp\Segment\IPZ\ParameterSEPAInstantPaymentZahlungV2; +use Fhp\Segment\IPZ\ParameterSEPAInstantPaymentZahlungV2; use Fhp\Segment\BaseGeschaeftsvorfallparameter; use Fhp\Segment\BaseSegment; diff --git a/lib/Fhp/Segment/IPZ/ParameterSEPAInstantPaymentZahlungV1.php b/lib/Fhp/Segment/IPZ/ParameterSEPAInstantPaymentZahlungV1.php index a323cd94..9ae83ab2 100644 --- a/lib/Fhp/Segment/IPZ/ParameterSEPAInstantPaymentZahlungV1.php +++ b/lib/Fhp/Segment/IPZ/ParameterSEPAInstantPaymentZahlungV1.php @@ -3,6 +3,8 @@ namespace Fhp\Segment\IPZ; use Fhp\Segment\BaseDeg; +use Fhp\Segment\UnterstuetzteSEPADatenformate; +use Fhp\Segment\UnterstuetzteSEPADatenformateTrait; /** * Parameter SEPA-Instant Payment Zahlung (Version 1) @@ -10,8 +12,10 @@ * @link https://www.hbci-zka.de/dokumente/spezifikation_deutsch/fintsv3/FinTS_3.0_Messages_Geschaeftsvorfaelle_2022-04-15_final_version.pdf * Section D */ -class ParameterSEPAInstantPaymentZahlungV1 extends BaseDeg +class ParameterSEPAInstantPaymentZahlungV1 extends BaseDeg implements UnterstuetzteSEPADatenformate { + use UnterstuetzteSEPADatenformateTrait; + /** Max Length: 4096 */ public ?string $zulaessigePurposecodes = null; diff --git a/lib/Fhp/Segment/IPZ/ParameterSEPAInstantPaymentZahlungV2.php b/lib/Fhp/Segment/IPZ/ParameterSEPAInstantPaymentZahlungV2.php index 70a290b5..4e40e3e4 100644 --- a/lib/Fhp/Segment/IPZ/ParameterSEPAInstantPaymentZahlungV2.php +++ b/lib/Fhp/Segment/IPZ/ParameterSEPAInstantPaymentZahlungV2.php @@ -1,8 +1,10 @@ 'urn:iso:std:iso:20022:tech:xsd:', '.xsd' => '', ]); - }, $this->unterstuetzteSepaDatenformate); + }, $this->unterstuetzteSepaDatenformate ?? $this->unterstuetzteSEPADatenformate ?? []); } } diff --git a/lib/Fhp/Segment/VPP/ErgebnisVopPruefungEinzeltransaktion.php b/lib/Fhp/Segment/VPP/ErgebnisVopPruefungEinzeltransaktion.php new file mode 100644 index 00000000..41783426 --- /dev/null +++ b/lib/Fhp/Segment/VPP/ErgebnisVopPruefungEinzeltransaktion.php @@ -0,0 +1,21 @@ +unterstuetztePaymentStatusReports = new UnterstuetztePaymentStatusReports(); + return $hkvpp; + } +} \ No newline at end of file diff --git a/lib/Fhp/Segment/VPP/ParameterNamensabgleichPruefauftrag.php b/lib/Fhp/Segment/VPP/ParameterNamensabgleichPruefauftrag.php new file mode 100644 index 00000000..8eb0aeea --- /dev/null +++ b/lib/Fhp/Segment/VPP/ParameterNamensabgleichPruefauftrag.php @@ -0,0 +1,24 @@ +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('DE00ABCDEFGH1234567890'); + $sepaAccount->setBic('ABCDEFGHIJK'); + $sepaAccount->setAccountNumber('1234567890'); + $sepaAccount->setBlz(self::TEST_BANK_CODE); + return $sepaAccount; + } +} diff --git a/lib/Tests/Fhp/Integration/Atruvia/SendTransferVoPTest.php b/lib/Tests/Fhp/Integration/Atruvia/SendTransferVoPTest.php new file mode 100644 index 00000000..43972043 --- /dev/null +++ b/lib/Tests/Fhp/Integration/Atruvia/SendTransferVoPTest.php @@ -0,0 +1,98 @@ +' . "\n" . 'M12345678902025-10-10T12:52:56+02:00110.00PRIVATE__________________P12345678TRF110.00SEPA
1999-01-01
PRIVATE__________________DE00ABCDEFGH1234567890ABCDEFGHIJKSLEVNOTPROVIDED10.00EmpfängerDE00ABCDEFGH1234567890Testüberweisung
'; + + public const SEND_TRANSFER_REQUEST = + "HKVPP:3:1+urn?:iso?:std?:iso?:20022?:tech?:xsd?:pain.002.001.10'HKCCS:4:1+DE00ABCDEFGH1234567890:ABCDEFGHIJK:1234567890::280:11223344+urn?:iso?:std?:iso?:20022?:tech?:xsd?:pain.001.001.09+@1161@" . + self::XML_PAYLOAD . + "'HKTAN:5:7+4+HKCCS'" + ; + public const SEND_TRANSFER_RESPONSE = "HIRMG:3:2+3060::Bitte beachten Sie die enthaltenen Warnungen/Hinweise.+3905::Es wurde keine Challenge erzeugt.'HIRMS:4:2:3+3040::Es liegen weitere Informationen vor.:staticscrollref'HIRMS:5:2:5+3945::Freigabe ohne VOP-Bestätigung nicht möglich.'HIVPP:6:1:3+++@36@c0f5c2a4-ebb7-4e72-be44-c68742177a2b+++++2'"; + + public const POLL_VOP_REPORT_REQUEST = "HKVPP:3:1+urn?:iso?:std?:iso?:20022?:tech?:xsd?:pain.002.001.10+@36@c0f5c2a4-ebb7-4e72-be44-c68742177a2b++staticscrollref'"; + + public const POLL_VOP_REPORT_MATCH_RESPONSE = "HIRMG:3:2+0010::Nachricht entgegengenommen.'HIRMS:4:2:3+0020::Auftrag ausgeführt.+0025::Keine Namensabweichung.'HIVPP:5:1:3+@36@5e3b5c99-df27-4d42-835b-18b35d0c66ff+++urn?:iso?:std?:iso?:20022?:tech?:xsd?:pain.002.001.10+@3616@UTF8XMLPAYLOAD'"; + + public const POLL_VOP_REPORT_MATCH_RESPONSE_XML_PAYLOAD = "ATRUVIA-20251013-125258-XXXXXXXXXXXXXXXX2025-10-13T11:36:04.201+02:00ABCDEFGHIJKM1234567890pain.001.001.091100.00RCVCRCVC Der von Ihnen eingegebene Name des Zahlungsempfängers stimmt mit dem für diese IBANRCVC hinterlegten Namen bei der Zahlungsempfängerbank überein.RVMC Der von Ihnen eingegebene Name des Zahlungsempfängers stimmt nahezu mit dem für diese IBANRVMC hinterlegten Namen bei der Zahlungsempfängerbank überein. Die Autorisierung der ZahlungRVMC kann dazu führen, dass das Geld auf ein Konto überwiesen wird, dessen Inhaber nichtRVMC der von Ihnen angegebene Empfänger ist. In diesem Fall haften die Zahlungsdienstleister nicht fürRVMC die Folgen der fehlenden Übereinstimmung, insbesondere besteht kein Anspruch auf Rückerstattung.RVNM Der von Ihnen eingegebene Name des Zahlungsempfängers stimmt nicht mit dem für diese IBAN hinter-RVNM legten Namen bei der Zahlungsempfängerbank überein. Bitte prüfen Sie den Empfängernamen. Die Autori-RVNM sierung der Zahlung kann dazu führen, dass das Geld auf ein Konto überwiesen wird, dessen InhaberRVNM nicht der von Ihnen angegebene Empfänger ist. In diesem Fall haften die Zahlungsdienstleister nichtRVNM für die Folgen der fehlenden Übereinstimmung, insbesondere besteht kein Anspruch auf Rückerstattung.RVNA Der von Ihnen eingegebene Name des Zahlungsempfängers konnte nicht mit dem für diese IBAN hinter-RVNA legten Namen bei der Zahlungsempfängerbank abgeglichen werden (z.B. technischer Fehler). Die Autori-RVNA sierung der Zahlung kann dazu führen, dass das Geld auf ein Konto überwiesen wird, dessen InhaberRVNA nicht der von Ihnen angegebene Empfänger ist. In diesem Fall haften die Zahlungsdienstleister nichtRVNA für die Folgen der fehlenden Übereinstimmung, insbesondere besteht kein Anspruch auf Rückerstattung.1RCVC0RVMC0RVNM0RVNA176034816211RCVC0RVMC0RVNM0RVNANOTPROVIDEDRCVCTestempfängerDE00ABCDEFGH1234567890"; + + public const POLL_VOP_REPORT_NO_MATCH_RESPONSE = "HIRMG:3:2+3060::Bitte beachten Sie die enthaltenen Warnungen/Hinweise.'HIRMS:4:2:3+3090::Ergebnis des Namensabgleichs prüfen.'HIVPP:5:1:3+@36@5e3b5c99-df27-4d42-835b-18b35d0c66ff+++urn?:iso?:std?:iso?:20022?:tech?:xsd?:pain.002.001.10+@3600@UTF8XMLPAYLOAD++Bei mindestens einem Zahlungsempfänger stimmt der Name mit dem für diese IBAN bei der Zahlungsempfängerbank hinterlegten Namen nicht oder nur nahezu überein.
Alternativ konnte der Name des Zahlungsempfängers nicht mit dem bei der Zahlungsempfängerbank hinterlegten Namen abgeglichen werden.

Eine nicht mögliche Empfängerüberprüfung kann auftreten, wenn ein technisches Problem vorliegt, die Empfängerbank diesen Service nicht anbietet oder eine Prüfung für das Empfängerkonto nicht möglich ist.

Wichtiger Hinweis?: Die Überweisung wird ohne Korrektur ausgeführt.

Dies kann dazu führen, dass das Geld auf ein Konto überwiesen wird, dessen Inhaber nicht der von Ihnen angegebene Empfänger ist.
In diesem Fall haftet die Bank nicht für die Folgen der fehlenden Übereinstimmung, insbesondere besteht kein Anspruch auf Rückerstattung.

Eine Haftung der an der Ausführung der Überweisung beteiligten Zahlungsdienstleister ist ebenfalls ausgeschlossen.'"; + + public const POLL_VOP_REPORT_NO_MATCH_RESPONSE_XML_PAYLOAD = "ATRUVIA-20251010-125258-X2025-10-10T12:52:58.283+02:00ABCDEFGHIJKM1234567890pain.001.001.09110.00RVCMRCVC Der von Ihnen eingegebene Name des Zahlungsempfängers stimmt mit dem für diese IBANRCVC hinterlegten Namen bei der Zahlungsempfängerbank überein.RVMC Der von Ihnen eingegebene Name des Zahlungsempfängers stimmt nahezu mit dem für diese IBANRVMC hinterlegten Namen bei der Zahlungsempfängerbank überein. Die Autorisierung der ZahlungRVMC kann dazu führen, dass das Geld auf ein Konto überwiesen wird, dessen Inhaber nichtRVMC der von Ihnen angegebene Empfänger ist. In diesem Fall haften die Zahlungsdienstleister nicht fürRVMC die Folgen der fehlenden Übereinstimmung, insbesondere besteht kein Anspruch auf Rückerstattung.RVNM Der von Ihnen eingegebene Name des Zahlungsempfängers stimmt nicht mit dem für diese IBAN hinter-RVNM legten Namen bei der Zahlungsempfängerbank überein. Bitte prüfen Sie den Empfängernamen. Die Autori-RVNM sierung der Zahlung kann dazu führen, dass das Geld auf ein Konto überwiesen wird, dessen InhaberRVNM nicht der von Ihnen angegebene Empfänger ist. In diesem Fall haften die Zahlungsdienstleister nichtRVNM für die Folgen der fehlenden Übereinstimmung, insbesondere besteht kein Anspruch auf Rückerstattung.RVNA Der von Ihnen eingegebene Name des Zahlungsempfängers konnte nicht mit dem für diese IBAN hinter-RVNA legten Namen bei der Zahlungsempfängerbank abgeglichen werden (z.B. technischer Fehler). Die Autori-RVNA sierung der Zahlung kann dazu führen, dass das Geld auf ein Konto überwiesen wird, dessen InhaberRVNA nicht der von Ihnen angegebene Empfänger ist. In diesem Fall haften die Zahlungsdienstleister nichtRVNA für die Folgen der fehlenden Übereinstimmung, insbesondere besteht kein Anspruch auf Rückerstattung.0RCVC0RVMC1RVNM0RVNA176009357610RCVC0RVMC1RVNM0RVNANOTPROVIDEDRVNMTestempfängerDE00ABCDEFGH1234567890"; + + public const CONFIRM_VOP_REQUEST = + "HKVPA:3:1+@36@5e3b5c99-df27-4d42-835b-18b35d0c66ff'HKCCS:4:1+DE00ABCDEFGH1234567890:ABCDEFGHIJK:1234567890::280:11223344+urn?:iso?:std?:iso?:20022?:tech?:xsd?:pain.001.001.09+@1161@" . + self::XML_PAYLOAD . + "'HKTAN:5:7+4+HKCCS'" + ; + public const CONFIRM_VOP_RESPONSE = "HIRMG:3:2+3060::Bitte beachten Sie die enthaltenen Warnungen/Hinweise.'HIRMS:4:2:3+0020::Ausführungsbestätigung nach Namensabgleich erhalten.'HIRMS:5:2:5+3955::Sicherheitsfreigabe erfolgt über anderen Kanal.'HITAN:6:7:5+4++1234567890123456789012345678+Bitte bestätigen Sie den Vorgang in Ihrer SecureGo plus App'"; + + public const CHECK_DECOUPLED_SUBMISSION_REQUEST = "HKTAN:3:7+S++++1234567890123456789012345678+N'"; + public const CHECK_DECOUPLED_SUBMISSION_RESPONSE = "HIRMG:3:2+0010::Nachricht entgegengenommen.'HIRMS:4:2:3+0020::*SEPA-Einzelüberweisung erfolgreich+0900::Freigabe erfolgreich'HITAN:5:7:3+S++1234567890123456789012345678'"; + + /** + * @throws \Throwable + */ + protected function testVop(string $requestVopReportResponse) + { + $this->initDialog(); + + $transferAction = $this->getTransferAction(); + + $this->expectMessage(static::SEND_TRANSFER_REQUEST, mb_convert_encoding(static::SEND_TRANSFER_RESPONSE, 'ISO-8859-1', 'UTF-8')); + + $this->fints->execute($transferAction); + + while ($transferAction->needsTime()) { + # As this is a test, we don't need to actually wait. + #$wait = $transferAction->hivpp->wartezeitVorNaechsterAbfrage; + #sleep($wait); + + $this->expectMessage(static::POLL_VOP_REPORT_REQUEST, $requestVopReportResponse); + + $this->fints->execute($transferAction); + } + + if ($transferAction->needsConfirmation()) { + $transferAction->setConfirmed(); + $this->expectMessage(static::CONFIRM_VOP_REQUEST, mb_convert_encoding(static::CONFIRM_VOP_RESPONSE, 'ISO-8859-1', 'UTF-8')); + $this->fints->execute($transferAction); + } + + $tanMode = $this->fints->getSelectedTanMode(); + while ($transferAction->needsTan()) { + + if ($tanMode->isDecoupled()) { + $this->expectMessage(static::CHECK_DECOUPLED_SUBMISSION_REQUEST, mb_convert_encoding(static::CHECK_DECOUPLED_SUBMISSION_RESPONSE, 'ISO-8859-1', 'UTF-8')); + $this->fints->checkDecoupledSubmission($transferAction); + } + } + + $transferAction->ensureDone(); + } + + public function testVopNoMatch() + { + $requestVopReportResponse = str_replace('UTF8XMLPAYLOAD', self::POLL_VOP_REPORT_NO_MATCH_RESPONSE_XML_PAYLOAD, mb_convert_encoding(static::POLL_VOP_REPORT_NO_MATCH_RESPONSE, 'ISO-8859-1', 'UTF-8')); + + $this->testVop($requestVopReportResponse); + } + + public function testVopMatch() + { + $requestVopReportResponse = str_replace('UTF8XMLPAYLOAD', self::POLL_VOP_REPORT_MATCH_RESPONSE_XML_PAYLOAD, mb_convert_encoding(static::POLL_VOP_REPORT_MATCH_RESPONSE, 'ISO-8859-1', 'UTF-8')); + + $this->testVop($requestVopReportResponse); + } + + protected function getTransferAction(): SendSEPATransferVoP + { + $account = $this->getTestAccount(); + return SendSEPATransferVoP::create($account, self::XML_PAYLOAD); + } +} diff --git a/lib/Tests/Fhp/Unit/SendSEPADirectDebitTest.php b/lib/Tests/Fhp/Unit/SendSEPADirectDebitTest.php new file mode 100644 index 00000000..96d7d827 --- /dev/null +++ b/lib/Tests/Fhp/Unit/SendSEPADirectDebitTest.php @@ -0,0 +1,22 @@ +assertInstanceOf(SendSEPADirectDebit::class, $sepa); + } +} diff --git a/lib/Tests/resources/pain.008.002.02.xml b/lib/Tests/resources/pain.008.002.02.xml new file mode 100644 index 00000000..1462b4a7 --- /dev/null +++ b/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