feat: voucher purchase flow

This commit is contained in:
fromme committed 2026-10-01 15:03:27 +02:00
1 parent a628e96d06
commit f3dd122678
70 files changed
+3672 -122

No files matched your search

@@ -93,6 +93,14 @@ class ApiResponseParser extends AbstractParser
return (new PurchaseVoucherParser())->parse($resultNode);
case ApiClient::TYPE_PROMO_VOUCHER:
return (new PromoVoucherParser())->parse($resultNode);
case ApiClient::TYPE_VOUCHER_TEMPLATES:
return (new VoucherTemplatesParser())->parse($resultNode);
case ApiClient::TYPE_VOUCHER_PAYMENT_METHODS:
return (new VoucherPaymentMethodsParser())->parse($resultNode);
case ApiClient::TYPE_VOUCHER_PREVIEW:
return (new VoucherPreviewParser())->parse($resultNode);
case ApiClient::TYPE_VOUCHER_SALE:
return (new VoucherSaleResponseParser())->parse($resultNode);
}
} catch (\InvalidArgumentException $e) {
throw new ResponseParserException('Invalid XML response structure: '.$e->getMessage(), 0, $e);
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\XmlParser;
use App\BusProNet\Model\VoucherPaymentMethod;
use Symfony\Component\DomCrawler\Crawler;
/**
* Parses GUTSCHEINZAHLUNGSARTEN responses.
*
* Expected XML structure (test system, 2026-10-01):
* <ergebnis>
* <satz typ="GUTSCHEINZAHLUNGSARTEN"></satz>
* <zahlungsarten>
* <zahlungsart id="2" bezeichnung="Überweisung" art="UEBERW"></zahlungsart>
* </zahlungsarten>
* </ergebnis>
*/
final class VoucherPaymentMethodsParser extends AbstractParser
{
/**
* @return list<VoucherPaymentMethod>
*/
public function parse(Crawler $node): array
{
$paymentMethods = [];
$node->filterXPath('//zahlungsarten/zahlungsart')->each(function (Crawler $methodNode) use (&$paymentMethods): void {
$paymentMethods[] = new VoucherPaymentMethod(
id: (int) $this->getRequiredAttrValue($methodNode, 'id', 'voucher payment method'),
label: $this->getRequiredAttrValue($methodNode, 'bezeichnung', 'voucher payment method'),
type: $this->getRequiredAttrValue($methodNode, 'art', 'voucher payment method'),
);
});
return $paymentMethods;
}
}
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\XmlParser;
use App\BusProNet\Model\VoucherPreview;
use Symfony\Component\DomCrawler\Crawler;
/**
* Parses GUTSCHEINVORSCHAU responses.
*
* Expected XML structure (test system, 2026-10-01):
* <ergebnis>
* <satz typ="GUTSCHEINVORSCHAU"/>
* <gutscheinpdf>data:application/pdf;base64,JVBERi0xLjQ…</gutscheinpdf>
* </ergebnis>
*/
final class VoucherPreviewParser extends AbstractParser
{
private const string DATA_URI_PREFIX = 'data:application/pdf;base64,';
public function parse(Crawler $node): VoucherPreview
{
$dataUri = trim($this->getStringOrNullValue($node->filterXPath('//gutscheinpdf')) ?? '');
if (!str_starts_with($dataUri, self::DATA_URI_PREFIX)) {
throw new \InvalidArgumentException('Missing or malformed PDF data URI in voucher preview response.');
}
$pdf = base64_decode(substr($dataUri, \strlen(self::DATA_URI_PREFIX)), true);
if (false === $pdf || '' === $pdf) {
throw new \InvalidArgumentException('Undecodable PDF in voucher preview response.');
}
return new VoucherPreview($pdf);
}
}
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\XmlParser;
use App\BusProNet\Model\VoucherSaleResponse;
use Symfony\Component\DomCrawler\Crawler;
/**
* Parses GUTSCHEINBUCHUNG responses that are not a HINWEIS.
*
* Dry run (buchungsart Anfrage, measured on the test system 2026-10-01):
* <ergebnis><satz typ="GUTSCHEINBUCHUNG"></satz><buchung>möglich</buchung></ergebnis>
*
* Purchase (buchungsart Buchung, as documented by the vendor):
* <ergebnis>
* <satz typ="GUTSCHEINBUCHUNG"/>
* <buchung>erfolgt</buchung>
* <idgutschein/><gutscheinnummer/><einloesecode/><preis/><rechnungsnummer/><email/>
* </ergebnis>
*/
final class VoucherSaleResponseParser extends AbstractParser
{
public function parse(Crawler $node): VoucherSaleResponse
{
$status = trim($this->getStringOrNullValue($node->filterXPath('//buchung')) ?? '');
if ('' === $status) {
throw new \InvalidArgumentException('Missing required status in voucher sale response.');
}
return new VoucherSaleResponse(
status: $status,
voucherId: $this->getIntOrNullValue($node->filterXPath('//idgutschein')),
voucherNumber: $this->trimmedOrNull($node->filterXPath('//gutscheinnummer')),
redemptionCode: $this->trimmedOrNull($node->filterXPath('//einloesecode')),
price: $this->getFloatOrNullValue($node->filterXPath('//preis')),
invoiceNumber: $this->trimmedOrNull($node->filterXPath('//rechnungsnummer')),
email: $this->trimmedOrNull($node->filterXPath('//email')),
);
}
private function trimmedOrNull(Crawler $node): ?string
{
$value = trim($this->getStringOrNullValue($node) ?? '');
return '' !== $value ? $value : null;
}
}
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\XmlParser;
use App\BusProNet\Model\VoucherTemplate;
use App\BusProNet\Model\VoucherTemplates;
use Symfony\Component\DomCrawler\Crawler;
/**
* Parses GUTSCHEINVORLAGEN responses.
*
* Expected XML structure (test system, 2026-10-01):
* <ergebnis>
* <satz typ="GUTSCHEINVORLAGEN"></satz>
* <gutscheinminbetrag>10</gutscheinminbetrag>
* <gutscheinmaxbetrag>5000</gutscheinmaxbetrag>
* <gutscheine>
* <gutschein idgutscheinvorlage="3">
* <gutscheinart>K</gutscheinart>
* <bezeichnung>Allgemein</bezeichnung>
* <hinweis></hinweis>
* </gutschein>
* </gutscheine>
* </ergebnis>
*/
final class VoucherTemplatesParser extends AbstractParser
{
public function parse(Crawler $node): VoucherTemplates
{
$minAmount = $this->getStringOrNullValue($node->filterXPath('//gutscheinminbetrag'));
$maxAmount = $this->getStringOrNullValue($node->filterXPath('//gutscheinmaxbetrag'));
if (null === $minAmount || '' === trim($minAmount) || null === $maxAmount || '' === trim($maxAmount)) {
throw new \InvalidArgumentException('Missing voucher amount bounds in voucher templates response.');
}
$templates = [];
$node->filterXPath('//gutscheine/gutschein')->each(function (Crawler $templateNode) use (&$templates): void {
$note = $this->getStringOrNullValue($templateNode->filterXPath('//hinweis'));
$templates[] = new VoucherTemplate(
id: (int) $this->getRequiredAttrValue($templateNode, 'idgutscheinvorlage', 'voucher template'),
type: trim($this->getStringOrNullValue($templateNode->filterXPath('//gutscheinart')) ?? ''),
label: trim($this->getStringOrNullValue($templateNode->filterXPath('//bezeichnung')) ?? ''),
note: null !== $note && '' !== trim($note) ? trim($note) : null,
);
});
return new VoucherTemplates(
// whole euros: the flow only offers whole-euro amounts, so the bounds are kept as such
minAmount: (int) ceil($this->stringToFloat($minAmount)),
maxAmount: (int) floor($this->stringToFloat($maxAmount)),
templates: $templates,
);
}
}