AutoPay wg rozwiazania z Gieldy Komunalnej
Deploy Polska Lokalnie / deploy (push) Successful in 11s

- ITN: XML w base64 (pole transactions), bezpieczny parser (bez XXE), podpis
  ze stalej listy pol, zgodnosc ServiceID, kwoty i waluty, idempotencja,
  podpisana odpowiedz XML CONFIRMED/NOTCONFIRMED.
- Start transakcji podpisanym formularzem POST ze strony /platnosc/:orderId
  zamiast linku GET z podpisem w adresie; tytul przelewu w ASCII.
- Losowy OrderID (UUID) zamiast Math.random.
- Adres ITN do wpisania w panelu AutoPay widoczny w panelu admina.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-23 13:22:20 +02:00
parent 1c1162c93e
commit 3a555e3b24
14 changed files with 731 additions and 152 deletions
@@ -0,0 +1,143 @@
package pl.polskalokalnie.payment;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
/**
* Parser notyfikacji ITN (XML przekazywany w polu {@code transactions} zakodowany base64).
*
* Dokument pochodzi z sieci, wiec parser jest twardo zabezpieczony przed XXE (wylaczone DTD
* i encje zewnetrzne) - inaczej spreparowana notyfikacja mogla by czytac pliki serwera albo
* wymuszac zadania wychodzace (SSRF).
*/
public final class AutoPayItnParser {
private AutoPayItnParser() {
}
/** Pojedyncza transakcja z notyfikacji wraz z polami wchodzacymi do podpisu. */
public record ItnTransaction(
String orderId,
String remoteId,
String amount,
String currency,
String gatewayId,
String paymentDate,
String paymentStatus,
String paymentStatusDetails
) {
public boolean success() {
return "SUCCESS".equalsIgnoreCase(paymentStatus);
}
/** Pola w kolejnosci, w jakiej wchodza do podpisu notyfikacji. */
List<String> signedValues() {
List<String> values = new ArrayList<>();
values.add(orderId);
values.add(remoteId);
values.add(amount);
values.add(currency);
values.add(gatewayId);
values.add(paymentDate);
values.add(paymentStatus);
values.add(paymentStatusDetails);
return values;
}
}
/** Zawartosc notyfikacji: identyfikator serwisu, lista transakcji i podpis calosci. */
public record Itn(String serviceId, List<ItnTransaction> transactions, String hash) {
/** Wartosci podpisu calej notyfikacji: serviceID, a potem pola kolejnych transakcji. */
public List<String> signedValues() {
List<String> values = new ArrayList<>();
values.add(serviceId);
for (ItnTransaction tx : transactions) {
values.addAll(tx.signedValues());
}
return values;
}
}
public static Itn parse(String xml) {
Document doc = parseSecurely(xml);
Element root = doc.getDocumentElement();
String serviceId = childText(root, "serviceID");
// Podpis jest bezposrednim dzieckiem korzenia (nie elementem transakcji).
String hash = childText(root, "hash");
List<ItnTransaction> transactions = new ArrayList<>();
for (Element tx : elementsByTag(root, "transaction")) {
transactions.add(new ItnTransaction(
childText(tx, "orderID"),
childText(tx, "remoteID"),
childText(tx, "amount"),
childText(tx, "currency"),
childText(tx, "gatewayID"),
childText(tx, "paymentDate"),
childText(tx, "paymentStatus"),
childText(tx, "paymentStatusDetails")));
}
if (serviceId == null || transactions.isEmpty()) {
throw invalid();
}
return new Itn(serviceId, transactions, hash);
}
private static Document parseSecurely(String xml) {
try {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
factory.setXIncludeAware(false);
factory.setExpandEntityReferences(false);
return factory.newDocumentBuilder()
.parse(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8)));
} catch (ParserConfigurationException e) {
throw new IllegalStateException("Nie mozna skonfigurowac bezpiecznego parsera XML", e);
} catch (Exception e) {
throw invalid();
}
}
private static ResponseStatusException invalid() {
return new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowa notyfikacja ITN");
}
/** Tekst bezposredniego dziecka o danej nazwie (bez schodzenia w glab drzewa). */
private static String childText(Element parent, String tag) {
NodeList children = parent.getChildNodes();
for (int i = 0; i < children.getLength(); i++) {
Node node = children.item(i);
if (node.getNodeType() == Node.ELEMENT_NODE && tag.equals(node.getNodeName())) {
String text = node.getTextContent();
return text == null || text.isBlank() ? null : text.trim();
}
}
return null;
}
private static List<Element> elementsByTag(Element root, String tag) {
List<Element> result = new ArrayList<>();
NodeList nodes = root.getElementsByTagName(tag);
for (int i = 0; i < nodes.getLength(); i++) {
result.add((Element) nodes.item(i));
}
return result;
}
}
@@ -2,23 +2,19 @@ package pl.polskalokalnie.payment;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Service;
/**
* Integracja z bramka szybkich platnosci AutoPay (Autopay/Blue Media).
* Integracja z bramka szybkich platnosci AutoPay (Autopay/Blue Media) - wg rozwiazania dzialajacego
* produkcyjnie w Gieldzie Komunalnej.
*
* Buduje przekierowanie na paywall (parametry + podpis Hash = SHA256 z wartosci polaczonych
* separatorem i klucza wspolpracy) oraz weryfikuje podpis w powiadomieniu ITN.
*
* UWAGA: dokladna kolejnosc parametrow i format ITN nalezy potwierdzic z aktualna dokumentacja
* AutoPay przy podlaczaniu produkcyjnych kluczy - metody podpisu sa tu wydzielone, by latwo dopasowac.
* Regula podpisu z dokumentacji: sklejane sa WARTOSCI pol (bez nazw) w kolejnosci z listy
* parametrow, rozdzielone separatorem, a na koncu doklejany jest klucz konfiguracyjny. Puste pola
* opcjonalne sa POMIJANE RAZEM Z SEPARATOREM. Przyklad z dokumentacji: ServiceID=2, OrderID=100,
* Amount=1.50, klucz=2test2 daje SHA256("2|100|1.50|2test2").
*/
@Service
public class AutoPayService {
@@ -26,66 +22,38 @@ public class AutoPayService {
private static final String PROD_URL = "https://pay.autopay.eu/payment";
private static final String SANDBOX_URL = "https://testpay.autopay.eu/payment";
/** Adres paywalla z podpisanymi parametrami transakcji. */
public String buildRedirectUrl(PaymentConfig cfg, String orderId, BigDecimal amount,
String description, String customerEmail, String returnUrl) {
String amountStr = amount.setScale(2, RoundingMode.HALF_UP).toPlainString();
// Kolejnosc parametrow ma znaczenie dla podpisu.
Map<String, String> params = new LinkedHashMap<>();
params.put("ServiceID", cfg.getServiceId());
params.put("OrderID", orderId);
params.put("Amount", amountStr);
params.put("Currency", "PLN");
params.put("Description", description == null ? "" : description);
params.put("CustomerEmail", customerEmail == null ? "" : customerEmail);
if (returnUrl != null && !returnUrl.isBlank()) {
params.put("ReturnURL", returnUrl);
}
String hash = computeHash(new ArrayList<>(params.values()), cfg.getHashSeparator(), cfg.getSecretKey());
params.put("Hash", hash);
String base = cfg.isSandbox() ? SANDBOX_URL : PROD_URL;
StringBuilder query = new StringBuilder();
for (Map.Entry<String, String> e : params.entrySet()) {
if (query.length() > 0) {
query.append('&');
}
query.append(enc(e.getKey())).append('=').append(enc(e.getValue()));
}
return base + "?" + query;
/**
* Podpisane pola formularza startu transakcji w kolejnosci wymaganej przez dokumentacje
* (ServiceID, OrderID, Amount, Description, CustomerEmail) - ta sama kolejnosc wchodzi do skrotu.
* Formularz wysyla przegladarka metoda POST, wiec podpis nie trafia do paska adresu ani historii.
*/
public AutoPayStartForm startForm(PaymentConfig cfg, String orderId, BigDecimal amount,
String description, String customerEmail) {
String amountStr = formatAmount(amount);
String hash = hash(cfg, List.of(cfg.getServiceId(), orderId, amountStr, description,
customerEmail == null ? "" : customerEmail));
String gatewayUrl = cfg.isSandbox() ? SANDBOX_URL : PROD_URL;
return new AutoPayStartForm(gatewayUrl, cfg.getServiceId(), orderId, amountStr, description,
customerEmail, hash);
}
/** Weryfikacja podpisu w powiadomieniu ITN (przelicza hash z wszystkich pol poza Hash + klucz). */
public boolean verifyItn(Map<String, String> params, PaymentConfig cfg) {
if (params == null || cfg == null || cfg.getSecretKey() == null) {
return false;
}
String provided = params.get("Hash");
if (provided == null || provided.isBlank()) {
return false;
}
List<String> values = new ArrayList<>();
for (Map.Entry<String, String> e : params.entrySet()) {
if (!"Hash".equalsIgnoreCase(e.getKey())) {
values.add(e.getValue());
}
}
String expected = computeHash(values, cfg.getHashSeparator(), cfg.getSecretKey());
return MessageDigest.isEqual(
expected.getBytes(StandardCharsets.UTF_8),
provided.trim().toLowerCase().getBytes(StandardCharsets.UTF_8));
/** Kwota w formacie 0.00 (wymagane dokladnie dwa miejsca po przecinku). */
public static String formatAmount(BigDecimal amount) {
return amount.setScale(2, RoundingMode.HALF_UP).toPlainString();
}
/** SHA-256 (hex, malymi literami) z wartosci polaczonych separatorem i doklejonego klucza. */
public String computeHash(List<String> values, String separator, String key) {
List<String> parts = new ArrayList<>(values);
parts.add(key);
String data = String.join(separator, parts);
/** SHA-256 (hex) z niepustych wartosci polaczonych separatorem i doklejonego klucza. */
public String hash(PaymentConfig cfg, List<String> values) {
StringBuilder sb = new StringBuilder();
for (String value : values) {
if (value == null || value.isEmpty()) {
continue; // puste pole opcjonalne: pomijamy bez separatora
}
sb.append(value).append(cfg.getHashSeparator());
}
sb.append(cfg.getSecretKey());
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] bytes = digest.digest(data.getBytes(StandardCharsets.UTF_8));
byte[] bytes = MessageDigest.getInstance("SHA-256").digest(sb.toString().getBytes(StandardCharsets.UTF_8));
StringBuilder hex = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
hex.append(Character.forDigit((b >> 4) & 0xF, 16));
@@ -97,7 +65,16 @@ public class AutoPayService {
}
}
private static String enc(String value) {
return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8);
/**
* Porownanie skrotow odporne na atak czasowy (weryfikacja podpisu ITN) - String.equals konczy
* sie przy pierwszej roznicy i zdradza liczbe zgodnych znakow.
*/
public static boolean hashMatches(String expected, String actual) {
if (expected == null || actual == null) {
return false;
}
return MessageDigest.isEqual(
expected.toLowerCase().getBytes(StandardCharsets.UTF_8),
actual.trim().toLowerCase().getBytes(StandardCharsets.UTF_8));
}
}
@@ -0,0 +1,16 @@
package pl.polskalokalnie.payment;
/**
* Podpisane pola formularza startu transakcji. Frontend wysyla je metoda POST na {@code gatewayUrl}
* (bramka AutoPay przyjmuje {@code application/x-www-form-urlencoded}).
*/
public record AutoPayStartForm(
String gatewayUrl,
String serviceId,
String orderId,
String amount,
String description,
String customerEmail,
String hash
) {
}
@@ -1,57 +1,35 @@
package pl.polskalokalnie.promotion;
import java.util.Map;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import pl.polskalokalnie.payment.AutoPayService;
import pl.polskalokalnie.payment.PaymentConfig;
import pl.polskalokalnie.payment.PaymentConfigService;
/**
* Publiczny endpoint powiadomien AutoPay (ITN - Instant Transaction Notification).
* Weryfikuje podpis i oznacza zamowienie jako oplacone.
*
* UWAGA: realny ITN AutoPay przesyla dane w okreslonym formacie (XML/base64) - ten scaffold przyjmuje
* parametry form/query i weryfikuje hash; przy podlaczeniu produkcyjnym nalezy dopasowac parsowanie
* i format odpowiedzi potwierdzajacej wg aktualnej dokumentacji AutoPay.
* Bramka nie ma tokenu JWT - uwierzytelnienie nadawcy opiera sie wylacznie na podpisie liczonym
* kluczem konfiguracyjnym, weryfikowanym w {@link AutoPayItnService}. Adres ITN ustawia sie w panelu
* AutoPay: Ustawienia serwisu -> Konfiguracja techniczna serwisu -> Edytuj -> "Adres ITN"
* (pole widac dopiero w formularzu edycji): https://polskalokalnie.pl/api/payments/autopay/itn
*/
@RestController
@RequestMapping("/api/payments/autopay")
public class AutoPayItnController {
private final AutoPayService autoPayService;
private final PaymentConfigService paymentConfigService;
private final PromotionService promotionService;
private final AutoPayItnService itnService;
public AutoPayItnController(AutoPayService autoPayService,
PaymentConfigService paymentConfigService,
PromotionService promotionService) {
this.autoPayService = autoPayService;
this.paymentConfigService = paymentConfigService;
this.promotionService = promotionService;
public AutoPayItnController(AutoPayItnService itnService) {
this.itnService = itnService;
}
@PostMapping("/itn")
public ResponseEntity<String> itn(@RequestParam Map<String, String> params) {
PaymentConfig cfg = paymentConfigService.get();
if (!autoPayService.verifyItn(params, cfg)) {
return ResponseEntity.badRequest().body("ERROR: invalid hash");
}
String orderId = params.get("OrderID");
if (orderId != null && isSuccess(params)) {
promotionService.markPaidByExternalId(orderId, params.get("Amount"));
}
return ResponseEntity.ok("OK");
}
// Oplacone jest tylko zamowienie z jawnym, koncowym statusem SUCCESS. PENDING to platnosc
// rozpoczeta, ktora moze jeszcze sie nie udac, a brak statusu nie moze oznaczac sukcesu.
static boolean isSuccess(Map<String, String> params) {
String status = params.getOrDefault("PaymentStatus",
params.getOrDefault("Status", params.get("paymentStatus")));
return status != null && status.trim().equalsIgnoreCase("SUCCESS");
/** Notyfikacja o statusie transakcji. Odpowiedz: podpisany XML z CONFIRMED/NOTCONFIRMED. */
@PostMapping(value = "/itn", produces = MediaType.APPLICATION_XML_VALUE)
public ResponseEntity<String> itn(@RequestParam("transactions") String transactions) {
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_XML)
.body(itnService.handle(transactions));
}
}
@@ -0,0 +1,177 @@
package pl.polskalokalnie.promotion;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import pl.polskalokalnie.payment.AutoPayItnParser;
import pl.polskalokalnie.payment.AutoPayService;
import pl.polskalokalnie.payment.PaymentConfig;
import pl.polskalokalnie.payment.PaymentConfigService;
/**
* Obsluga notyfikacji ITN z AutoPay - jedyne miejsce, w ktorym zamowienie zostaje uznane za oplacone
* (wg rozwiazania dzialajacego produkcyjnie w Gieldzie Komunalnej).
*
* Kolejnosc sprawdzen (fail-closed - kazdy niespelniony warunek daje NOTCONFIRMED):
* 1. podpis calej notyfikacji liczony kluczem konfiguracyjnym (uwierzytelnia nadawce),
* 2. zgodnosc identyfikatora serwisu,
* 3. istnienie zamowienia o danym OrderID,
* 4. zgodnosc kwoty i waluty z zamowieniem (obrona przed manipulacja kwota),
* 5. idempotencja - powtorzona notyfikacja nie nalicza drugi raz.
*
* AutoPay ponawia notyfikacje az do potwierdzenia, wiec powtorki sa normalnym trybem pracy.
*/
@Service
public class AutoPayItnService {
private static final Logger log = LoggerFactory.getLogger(AutoPayItnService.class);
private static final String CONFIRMED = "CONFIRMED";
private static final String NOT_CONFIRMED = "NOTCONFIRMED";
private static final String NL = "\n";
private final PaymentConfigService paymentConfigService;
private final AutoPayService autoPayService;
private final PromotionOrderRepository orderRepository;
private final PromotionService promotionService;
public AutoPayItnService(PaymentConfigService paymentConfigService, AutoPayService autoPayService,
PromotionOrderRepository orderRepository, PromotionService promotionService) {
this.paymentConfigService = paymentConfigService;
this.autoPayService = autoPayService;
this.orderRepository = orderRepository;
this.promotionService = promotionService;
}
/**
* Przetwarza notyfikacje i buduje podpisana odpowiedz XML wymagana przez AutoPay.
*
* @param transactionsBase64 zawartosc parametru {@code transactions} (XML w base64)
*/
@Transactional
public String handle(String transactionsBase64) {
PaymentConfig cfg = paymentConfigService.get();
AutoPayItnParser.Itn itn = AutoPayItnParser.parse(decode(transactionsBase64));
boolean authentic = verifySignature(cfg, itn);
if (!authentic) {
// Bez szczegolow - to potencjalna proba podszycia sie pod bramke.
log.warn("Odrzucono notyfikacje ITN AutoPay: niepoprawny podpis lub identyfikator serwisu");
}
List<Confirmation> confirmations = new ArrayList<>();
for (AutoPayItnParser.ItnTransaction tx : itn.transactions()) {
String result = authentic ? process(tx) : NOT_CONFIRMED;
confirmations.add(new Confirmation(tx.orderId(), result));
}
return response(cfg, confirmations);
}
private boolean verifySignature(PaymentConfig cfg, AutoPayItnParser.Itn itn) {
if (!cfg.isLive() || !cfg.getServiceId().trim().equals(itn.serviceId())) {
return false;
}
String expected = autoPayService.hash(cfg, itn.signedValues());
return AutoPayService.hashMatches(expected, itn.hash());
}
private String process(AutoPayItnParser.ItnTransaction tx) {
Optional<PromotionOrder> found = orderRepository.findByExternalId(tx.orderId());
if (found.isEmpty()) {
log.warn("Notyfikacja ITN dla nieznanego OrderID: {}", tx.orderId());
return NOT_CONFIRMED;
}
PromotionOrder order = found.get();
if (!tx.success()) {
// PENDING / FAILURE: potwierdzamy odbior (inaczej bramka ponawialaby bez konca),
// ale niczego nie naliczamy.
if ("FAILURE".equalsIgnoreCase(tx.paymentStatus()) && order.getStatus() == OrderStatus.PENDING) {
order.setStatus(OrderStatus.FAILED);
orderRepository.save(order);
}
return CONFIRMED;
}
// Idempotencja: powtorzona notyfikacja o oplaconym zamowieniu jest potwierdzana,
// ale nie nalicza drugi raz kredytow ani wyroznienia.
if (order.getStatus() == OrderStatus.PAID) {
return CONFIRMED;
}
if (!amountMatches(order.getAmount(), tx.amount()) || !"PLN".equalsIgnoreCase(tx.currency())) {
log.warn("Notyfikacja ITN z niezgodna kwota/waluta dla OrderID {}: oczekiwano {} PLN, otrzymano {} {}",
tx.orderId(), order.getAmount(), tx.amount(), tx.currency());
return NOT_CONFIRMED;
}
promotionService.applyPaid(order);
log.info("AutoPay: oplacono zamowienie promowania, OrderID {}, zamowienie {}", tx.orderId(), order.getId());
return CONFIRMED;
}
/** Porownanie kwot wartoscia liczbowa (a nie tekstem) - "49.00" i "49.0" to ta sama kwota. */
static boolean amountMatches(BigDecimal expected, String actual) {
if (expected == null || actual == null) {
return false;
}
try {
return expected.compareTo(new BigDecimal(actual)) == 0;
} catch (NumberFormatException e) {
return false;
}
}
private record Confirmation(String orderId, String result) {
}
/** Odpowiedz wymagana przez AutoPay: XML z potwierdzeniami i wlasnym podpisem. */
private String response(PaymentConfig cfg, List<Confirmation> confirmations) {
String serviceId = cfg.getServiceId() == null ? "" : cfg.getServiceId().trim();
// Podpis odpowiedzi: serviceID, a nastepnie dla kazdej transakcji orderID i confirmation.
List<String> values = new ArrayList<>();
values.add(serviceId);
for (Confirmation c : confirmations) {
values.add(c.orderId());
values.add(c.result());
}
String hash = autoPayService.hash(cfg, values);
StringBuilder entries = new StringBuilder();
for (Confirmation c : confirmations) {
entries.append(" <transactionConfirmed>").append(NL)
.append(" <orderID>").append(escape(c.orderId())).append("</orderID>").append(NL)
.append(" <confirmation>").append(c.result()).append("</confirmation>").append(NL)
.append(" </transactionConfirmed>").append(NL);
}
return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" + NL
+ "<confirmationList>" + NL
+ " <serviceID>" + escape(serviceId) + "</serviceID>" + NL
+ " <transactionsConfirmations>" + NL
+ entries
+ " </transactionsConfirmations>" + NL
+ " <hash>" + hash + "</hash>" + NL
+ "</confirmationList>";
}
private static String decode(String base64) {
try {
return new String(Base64.getDecoder().decode(base64.trim()), StandardCharsets.UTF_8);
} catch (IllegalArgumentException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowa notyfikacja ITN");
}
}
private static String escape(String value) {
return value == null ? "" : value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;");
}
}
@@ -9,6 +9,7 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import pl.polskalokalnie.payment.AutoPayStartForm;
/** Publiczne (dla zalogowanych) operacje promowania: oferta planow/pakietow, platnosc, kredyty. */
@RestController
@@ -51,6 +52,11 @@ public class PromotionController {
return new OrderStatusResponse(promotionService.orderStatus(authentication.getName(), id));
}
@GetMapping("/order/{id}/autopay-form")
public AutoPayStartForm autoPayForm(@PathVariable Long id, Authentication authentication) {
return promotionService.autoPayForm(authentication.getName(), id);
}
public record CreditsResponse(int credits) {
}
@@ -12,6 +12,7 @@ import org.springframework.web.server.ResponseStatusException;
import pl.polskalokalnie.listing.ListingRepository;
import pl.polskalokalnie.listing.PropertyListing;
import pl.polskalokalnie.payment.AutoPayService;
import pl.polskalokalnie.payment.AutoPayStartForm;
import pl.polskalokalnie.payment.PaymentConfig;
import pl.polskalokalnie.payment.PaymentConfigService;
import pl.polskalokalnie.user.AppUser;
@@ -27,7 +28,6 @@ public class PromotionService {
private final ListingRepository listingRepository;
private final PaymentConfigService paymentConfigService;
private final AutoPayService autoPayService;
private final String baseUrl;
private final boolean autoConfirmPayments;
public PromotionService(PromotionPlanRepository planRepository,
@@ -37,7 +37,6 @@ public class PromotionService {
ListingRepository listingRepository,
PaymentConfigService paymentConfigService,
AutoPayService autoPayService,
@Value("${app.public-base-url:http://localhost}") String baseUrl,
@Value("${app.payments.auto-confirm:false}") boolean autoConfirmPayments) {
this.planRepository = planRepository;
this.packageRepository = packageRepository;
@@ -46,7 +45,6 @@ public class PromotionService {
this.listingRepository = listingRepository;
this.paymentConfigService = paymentConfigService;
this.autoPayService = autoPayService;
this.baseUrl = baseUrl == null ? "" : baseUrl.replaceAll("/+$", "");
this.autoConfirmPayments = autoConfirmPayments;
}
@@ -102,10 +100,38 @@ public class PromotionService {
applyPaid(order);
return CheckoutResponse.paid(order.getId());
}
String returnUrl = baseUrl + "/konto/moje-oferty?platnosc=" + order.getExternalId();
String url = autoPayService.buildRedirectUrl(cfg, order.getExternalId(), order.getAmount(),
order.getItemName(), email, returnUrl);
return CheckoutResponse.redirect(order.getId(), url);
// Wewnetrzna strona /platnosc/{id} pobiera podpisane pola i sama wysyla formularz POST
// do bramki - podpis nie trafia do paska adresu, historii ani naglowka Referer.
return CheckoutResponse.redirect(order.getId(), "/platnosc/" + order.getId());
}
/** Podpisany formularz startu transakcji AutoPay dla oczekujacego zamowienia wlasciciela. */
public AutoPayStartForm autoPayForm(String email, Long orderId) {
PromotionOrder order = orderRepository.findById(orderId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Zamowienie nie istnieje"));
if (!order.getUserEmail().equalsIgnoreCase(email)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Brak dostepu do zamowienia");
}
if (order.getStatus() != OrderStatus.PENDING) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "To zamówienie nie oczekuje już na płatność");
}
PaymentConfig cfg = paymentConfigService.get();
if (!cfg.isLive()) {
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
"Płatności są chwilowo niedostępne. Spróbuj ponownie później.");
}
return autoPayService.startForm(cfg, order.getExternalId(), order.getAmount(),
paymentDescription(order), order.getUserEmail());
}
/**
* Tytul przelewu. Dokumentacja AutoPay ogranicza pole do 79 znakow i dopuszcza znaki
* alfanumeryczne, {@code .:-,} i spacje - budujemy go sami, bez polskich znakow z nazwy planu.
*/
static String paymentDescription(PromotionOrder order) {
return order.getKind() == PromotionKind.PLAN
? "Promowanie ogloszenia nr " + order.getListingId()
: "Pakiet promowan, zamowienie nr " + order.getId();
}
@Transactional
@@ -133,26 +159,6 @@ public class PromotionService {
orderRepository.save(order);
}
// Wywolywane z callbacku ITN AutoPay po zweryfikowaniu podpisu. Kwota z powiadomienia musi
// zgadzac sie z zamowieniem - inaczej wplata nie pokrywa tego, co mamy wydac.
@Transactional
public void markPaidByExternalId(String externalId, String paidAmount) {
orderRepository.findByExternalId(externalId)
.filter(order -> amountMatches(order.getAmount(), paidAmount))
.ifPresent(this::applyPaid);
}
static boolean amountMatches(BigDecimal expected, String paidAmount) {
if (expected == null || paidAmount == null || paidAmount.isBlank()) {
return false;
}
try {
return expected.compareTo(new BigDecimal(paidAmount.trim())) == 0;
} catch (NumberFormatException ex) {
return false;
}
}
@Transactional
public int promoteWithCredit(String email, CreditPromoteRequest request) {
AppUser user = userRepository.findByEmailIgnoreCase(email)
@@ -308,7 +314,8 @@ public class PromotionService {
return id;
}
/** Losowy OrderID (32 znaki hex) - nieprzewidywalny, w limicie dlugosci pola. */
private static String generateExternalId() {
return "PL" + System.currentTimeMillis() + String.format("%04d", (int) (Math.random() * 10000));
return java.util.UUID.randomUUID().toString().replace("-", "");
}
}
@@ -0,0 +1,205 @@
package pl.polskalokalnie.promotion;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Base64;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import pl.polskalokalnie.payment.AutoPayService;
import pl.polskalokalnie.payment.PaymentConfig;
import pl.polskalokalnie.payment.PaymentConfigService;
/**
* Zabezpieczenia obslugi notyfikacji ITN (scenariusze przeniesione z Gieldy Komunalnej):
* podszycie sie pod bramke, zanizenie kwoty, podmiana waluty, statusy nie-koncowe i powtorki.
*/
class AutoPayItnServiceTest {
private static final String SERVICE_ID = "218886";
private static final String KEY = "sekretny-klucz-hash";
private static final String ORDER_ID = "abc123";
private final AutoPayService autoPayService = new AutoPayService();
private PromotionOrderRepository orderRepository;
private PromotionService promotionService;
private AutoPayItnService service;
private PaymentConfig cfg;
private PromotionOrder order;
@BeforeEach
void setUp() {
orderRepository = mock(PromotionOrderRepository.class);
promotionService = mock(PromotionService.class);
PaymentConfigService configService = mock(PaymentConfigService.class);
cfg = new PaymentConfig();
cfg.setServiceId(SERVICE_ID);
cfg.setSecretKey(KEY);
cfg.setEnabled(true);
when(configService.get()).thenReturn(cfg);
service = new AutoPayItnService(configService, autoPayService, orderRepository, promotionService);
order = new PromotionOrder();
order.setExternalId(ORDER_ID);
order.setAmount(new BigDecimal("49.99"));
when(orderRepository.findByExternalId(ORDER_ID)).thenReturn(Optional.of(order));
}
private String itn(String amount, String currency, String status) {
String hash = autoPayService.hash(cfg, List.of(
SERVICE_ID, ORDER_ID, "remote-1", amount, currency, "21", "20260827120000", status, "OK"));
return encode(xml(amount, currency, status, hash));
}
private String xml(String amount, String currency, String status, String hash) {
return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
+ "<transactionList>"
+ "<serviceID>" + SERVICE_ID + "</serviceID>"
+ "<transactions><transaction>"
+ "<orderID>" + ORDER_ID + "</orderID>"
+ "<remoteID>remote-1</remoteID>"
+ "<amount>" + amount + "</amount>"
+ "<currency>" + currency + "</currency>"
+ "<gatewayID>21</gatewayID>"
+ "<paymentDate>20260827120000</paymentDate>"
+ "<paymentStatus>" + status + "</paymentStatus>"
+ "<paymentStatusDetails>OK</paymentStatusDetails>"
+ "</transaction></transactions>"
+ "<hash>" + hash + "</hash>"
+ "</transactionList>";
}
private static String encode(String xml) {
return Base64.getEncoder().encodeToString(xml.getBytes(StandardCharsets.UTF_8));
}
private static String sha256(String input) throws Exception {
byte[] d = MessageDigest.getInstance("SHA-256").digest(input.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (byte b : d) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
@Test
void hashFollowsDocumentationVector() throws Exception {
PaymentConfig doc = new PaymentConfig();
doc.setSecretKey("2test2");
assertThat(autoPayService.hash(doc, List.of("2", "100", "1.50"))).isEqualTo(sha256("2|100|1.50|2test2"));
// Puste pole opcjonalne pomijane razem z separatorem.
assertThat(autoPayService.hash(doc, List.of("2", "100", "", "1.50"))).isEqualTo(sha256("2|100|1.50|2test2"));
}
@Test
void confirmsValidSuccessNotification() {
String response = service.handle(itn("49.99", "PLN", "SUCCESS"));
assertThat(response).contains("<confirmation>CONFIRMED</confirmation>");
verify(promotionService).applyPaid(order);
}
@Test
void rejectsForgedSignature() {
String response = service.handle(encode(xml("49.99", "PLN", "SUCCESS", "0000000000000000")));
assertThat(response).contains("<confirmation>NOTCONFIRMED</confirmation>");
verify(promotionService, never()).applyPaid(any());
}
@Test
void rejectsSignatureFromDifferentKey() {
PaymentConfig attacker = new PaymentConfig();
attacker.setSecretKey("klucz-atakujacego");
String hash = autoPayService.hash(attacker, List.of(
SERVICE_ID, ORDER_ID, "remote-1", "49.99", "PLN", "21", "20260827120000", "SUCCESS", "OK"));
String response = service.handle(encode(xml("49.99", "PLN", "SUCCESS", hash)));
assertThat(response).contains("NOTCONFIRMED");
verify(promotionService, never()).applyPaid(any());
}
@Test
void pendingPaymentIsAcknowledgedButNotPaid() {
// Regresja: PENDING byl traktowany jak oplacona platnosc.
String response = service.handle(itn("49.99", "PLN", "PENDING"));
assertThat(response).contains("<confirmation>CONFIRMED</confirmation>");
verify(promotionService, never()).applyPaid(any());
}
@Test
void failedPaymentMarksOrderFailed() {
service.handle(itn("49.99", "PLN", "FAILURE"));
assertThat(order.getStatus()).isEqualTo(OrderStatus.FAILED);
verify(promotionService, never()).applyPaid(any());
}
@Test
void rejectsUnderpayment() {
String response = service.handle(itn("0.01", "PLN", "SUCCESS"));
assertThat(response).contains("NOTCONFIRMED");
verify(promotionService, never()).applyPaid(any());
}
@Test
void rejectsWrongCurrency() {
String response = service.handle(itn("49.99", "EUR", "SUCCESS"));
assertThat(response).contains("NOTCONFIRMED");
verify(promotionService, never()).applyPaid(any());
}
@Test
void isIdempotentOnRetry() {
service.handle(itn("49.99", "PLN", "SUCCESS"));
order.setStatus(OrderStatus.PAID); // applyPaid jest zamockowane - odwzorowujemy skutek
String second = service.handle(itn("49.99", "PLN", "SUCCESS"));
assertThat(second).contains("<confirmation>CONFIRMED</confirmation>");
verify(promotionService, times(1)).applyPaid(order);
}
@Test
void rejectsUnknownOrderId() {
when(orderRepository.findByExternalId(ORDER_ID)).thenReturn(Optional.empty());
String response = service.handle(itn("49.99", "PLN", "SUCCESS"));
assertThat(response).contains("NOTCONFIRMED");
verify(promotionService, never()).applyPaid(any());
}
@Test
void rejectsNotificationsWhenGatewayIsNotConfigured() {
String valid = itn("49.99", "PLN", "SUCCESS");
cfg.setEnabled(false);
assertThat(service.handle(valid)).contains("NOTCONFIRMED");
verify(promotionService, never()).applyPaid(any());
}
@Test
void refusesXmlWithExternalEntities() {
String xxe = "<?xml version=\"1.0\"?><!DOCTYPE x [<!ENTITY e SYSTEM \"file:///etc/passwd\">]>"
+ "<transactionList><serviceID>&e;</serviceID></transactionList>";
assertThatThrownBy(() -> service.handle(encode(xxe))).isInstanceOf(ResponseStatusException.class);
}
}
@@ -9,7 +9,6 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.math.BigDecimal;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
@@ -31,7 +30,7 @@ class PaymentSecurityTest {
private PromotionService service(boolean autoConfirm) {
return new PromotionService(mock(PromotionPlanRepository.class), packageRepository, orderRepository,
userRepository, mock(ListingRepository.class), paymentConfigService, mock(AutoPayService.class),
"http://localhost", autoConfirm);
autoConfirm);
}
@Test
@@ -65,20 +64,12 @@ class PaymentSecurityTest {
}
@Test
void itnCountsOnlyExplicitSuccessAsPaid() {
// Regresja: PENDING oraz brak statusu byly traktowane jak oplacona platnosc.
assertThat(AutoPayItnController.isSuccess(Map.of("PaymentStatus", "SUCCESS"))).isTrue();
assertThat(AutoPayItnController.isSuccess(Map.of("PaymentStatus", "PENDING"))).isFalse();
assertThat(AutoPayItnController.isSuccess(Map.of("PaymentStatus", "FAILURE"))).isFalse();
assertThat(AutoPayItnController.isSuccess(Map.of("OrderID", "PL-1"))).isFalse();
}
@Test
void itnAmountMustMatchOrder() {
assertThat(PromotionService.amountMatches(new BigDecimal("49.99"), "49.99")).isTrue();
assertThat(PromotionService.amountMatches(new BigDecimal("49.99"), "49.990")).isTrue();
assertThat(PromotionService.amountMatches(new BigDecimal("49.99"), "0.01")).isFalse();
assertThat(PromotionService.amountMatches(new BigDecimal("49.99"), null)).isFalse();
assertThat(PromotionService.amountMatches(new BigDecimal("49.99"), "abc")).isFalse();
void paymentDescriptionUsesOnlyCharactersAllowedByGateway() {
PromotionOrder order = new PromotionOrder();
order.setKind(PromotionKind.PLAN);
order.setListingId(42L);
assertThat(PromotionService.paymentDescription(order))
.isEqualTo("Promowanie ogloszenia nr 42")
.matches("[A-Za-z0-9 .:,-]{1,79}");
}
}