From 56af0f921b42be5ebb55c7475da52770739fae68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Tue, 6 Oct 2026 11:28:53 +0200 Subject: [PATCH] fix: stop caching rejected voucher checks, document response codes --- docs/bpn-xml-api-reference.md | 12 +++- src/Service/VoucherValidator.php | 14 ++++- tests/Service/VoucherValidatorTest.php | 79 ++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 tests/Service/VoucherValidatorTest.php diff --git a/docs/bpn-xml-api-reference.md b/docs/bpn-xml-api-reference.md index 10c43714..cd5ed303 100644 --- a/docs/bpn-xml-api-reference.md +++ b/docs/bpn-xml-api-reference.md @@ -424,7 +424,17 @@ fehlerhaft · 1003 Status im Vorgang nicht korrekt · 1011-1018 Zahlungsabgleich 605 Empfängername · 606 Betrag fehlt · 607/608 Betrag kleiner/größer als #1# Euro) · 610/611 Buchungsart · 612/613 Gutscheinart · 614/615 Gutscheinstamm · 616 Kapazität nicht ausreichend · 617 Rechnungsempfänger fehlt · 618 Zahlungsart fehlt · 619/620 Agentur-ID · -621 Gutscheindaten fehlen · 622 Einlösecode fehlt · 630-632 Aktionsgutschein +621 Gutscheindaten fehlen · 622 Einlösecode fehlt · 630 Aktionsgutschein fehlt · +631 Aktionsgutschein mit Code #1# nicht gefunden · 632 Aktionsgutschein für geprüfte Reise ungültig · +**633 Aktionsgutschein vom Buchungszeitraum ungültig** · **634 Aktionsgutschein vom Reisezeitraum +ungültig** + +633 and 634 are not in the PDF; both were measured on `AKTIONSGUTSCHEIN` on 2026-10-06. 633 means +the request falls outside the promo's booking period (`GutscheinAktion.BuchungDtVon`/`BuchungDtBis`): +a test promo whose booking period was being edited answered 633 and was accepted once the period +covered the request date. 634 refers to the travel period (`ReiseDtVon`/`ReiseDtBis`). It was +returned on prod for a promo whose stored travel period did contain the travel's `TerminVon`/ +`TerminBis`. What it actually compares has not been established. ## Applying this: the teamer placeholder booking diff --git a/src/Service/VoucherValidator.php b/src/Service/VoucherValidator.php index d871630c..afbdce7e 100644 --- a/src/Service/VoucherValidator.php +++ b/src/Service/VoucherValidator.php @@ -13,6 +13,10 @@ use Psr\Log\LoggerInterface; use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\ItemInterface; +/** + * Checks voucher codes with BusPro. Only accepted codes are cached: a rejection is asked again on + * the next attempt, so a code fixed in BusPro (or a passing outage) is picked up immediately. + */ class VoucherValidator { private const CACHE_TTL = 900; // 15 minutes @@ -30,13 +34,15 @@ class VoucherValidator $cacheKey = sprintf('voucher.purchase.%s', md5($redemptionCode)); try { - return $this->cache->get($cacheKey, function (ItemInterface $item) use ($redemptionCode) { + return $this->cache->get($cacheKey, function (ItemInterface $item, bool &$save) use ($redemptionCode) { $item->expiresAfter(self::CACHE_TTL); try { $result = $this->voucherClient->validatePurchaseVoucher($redemptionCode); if ($result instanceof Notification) { + $save = false; + return new PurchaseVoucherValidationResult( isValid: false, errorMessage: $result->message, @@ -49,6 +55,7 @@ class VoucherValidator voucher: $result ); } catch (ApiClientException $e) { + $save = false; $this->logger->error('Purchase voucher validation failed', [ 'redemptionCode' => $redemptionCode, 'error' => $e->getMessage(), @@ -85,13 +92,15 @@ class VoucherValidator ); try { - return $this->cache->get($cacheKey, function (ItemInterface $item) use ($promoCode, $travelId, $participantPrice) { + return $this->cache->get($cacheKey, function (ItemInterface $item, bool &$save) use ($promoCode, $travelId, $participantPrice) { $item->expiresAfter(self::CACHE_TTL); try { $result = $this->voucherClient->validatePromoVoucher($promoCode, $travelId, $participantPrice); if ($result instanceof Notification) { + $save = false; + return new PromoVoucherValidationResult( isValid: false, errorMessage: $result->message, @@ -104,6 +113,7 @@ class VoucherValidator voucher: $result ); } catch (ApiClientException $e) { + $save = false; $this->logger->error('Promo voucher validation failed', [ 'promoCode' => $promoCode, 'travelId' => $travelId, diff --git a/tests/Service/VoucherValidatorTest.php b/tests/Service/VoucherValidatorTest.php new file mode 100644 index 00000000..78dfc097 --- /dev/null +++ b/tests/Service/VoucherValidatorTest.php @@ -0,0 +1,79 @@ +createMock(VoucherClient::class); + $client->expects(self::once())->method('validatePromoVoucher') + ->willReturn(new PromoVoucher('R-TEST26', 'Testen', PromoVoucher::APPLICABILITY_PER_PERSON, 9.99, 0.0, 0.0)); + + $validator = new VoucherValidator($client, new ArrayAdapter(), new NullLogger()); + + self::assertTrue($validator->validatePromoVoucher('R-TEST26', 12497, 532.8)->isValid); + self::assertTrue($validator->validatePromoVoucher('R-TEST26', 12497, 532.8)->isValid); + } + + public function testRejectedPromoCodeIsAskedAgain(): void + { + // The code is fixed in BusPro between the two attempts + $client = $this->createMock(VoucherClient::class); + $client->expects(self::exactly(2))->method('validatePromoVoucher') + ->willReturnOnConsecutiveCalls( + new Notification(633, 'Aktionsgutschein vom Buchungszeitraum ungültig'), + new PromoVoucher('R-TEST26', 'Testen', PromoVoucher::APPLICABILITY_PER_PERSON, 9.99, 0.0, 0.0), + ); + + $validator = new VoucherValidator($client, new ArrayAdapter(), new NullLogger()); + + $rejected = $validator->validatePromoVoucher('R-TEST26', 12497, 532.8); + self::assertFalse($rejected->isValid); + self::assertSame(633, $rejected->errorCode); + + self::assertTrue($validator->validatePromoVoucher('R-TEST26', 12497, 532.8)->isValid); + } + + public function testFailedPromoCheckIsAskedAgain(): void + { + $client = $this->createMock(VoucherClient::class); + $client->expects(self::exactly(2))->method('validatePromoVoucher') + ->willReturnOnConsecutiveCalls( + self::throwException(new ApiClientException('Connection refused')), + new PromoVoucher('R-TEST26', 'Testen', PromoVoucher::APPLICABILITY_PER_PERSON, 9.99, 0.0, 0.0), + ); + + $validator = new VoucherValidator($client, new ArrayAdapter(), new NullLogger()); + + self::assertFalse($validator->validatePromoVoucher('R-TEST26', 12497, 532.8)->isValid); + self::assertTrue($validator->validatePromoVoucher('R-TEST26', 12497, 532.8)->isValid); + } + + public function testRejectedPurchaseCodeIsAskedAgain(): void + { + $client = $this->createMock(VoucherClient::class); + $client->expects(self::exactly(2))->method('validatePurchaseVoucher') + ->willReturnOnConsecutiveCalls( + new Notification(995, 'Gutschein nicht gefunden'), + new PurchaseVoucher('80190', '1F7V6PADRZ', 1, 100.0, PurchaseVoucher::TYPE_PURCHASE), + ); + + $validator = new VoucherValidator($client, new ArrayAdapter(), new NullLogger()); + + self::assertFalse($validator->validatePurchaseVoucher('1F7V6PADRZ')->isValid); + self::assertTrue($validator->validatePurchaseVoucher('1F7V6PADRZ')->isValid); + } +}