- 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:
@@ -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.BigDecimal;
|
||||||
import java.math.RoundingMode;
|
import java.math.RoundingMode;
|
||||||
import java.net.URLEncoder;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
|
||||||
import org.springframework.stereotype.Service;
|
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
|
* Regula podpisu z dokumentacji: sklejane sa WARTOSCI pol (bez nazw) w kolejnosci z listy
|
||||||
* separatorem i klucza wspolpracy) oraz weryfikuje podpis w powiadomieniu ITN.
|
* 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,
|
||||||
* UWAGA: dokladna kolejnosc parametrow i format ITN nalezy potwierdzic z aktualna dokumentacja
|
* Amount=1.50, klucz=2test2 daje SHA256("2|100|1.50|2test2").
|
||||||
* AutoPay przy podlaczaniu produkcyjnych kluczy - metody podpisu sa tu wydzielone, by latwo dopasowac.
|
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
public class AutoPayService {
|
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 PROD_URL = "https://pay.autopay.eu/payment";
|
||||||
private static final String SANDBOX_URL = "https://testpay.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,
|
* Podpisane pola formularza startu transakcji w kolejnosci wymaganej przez dokumentacje
|
||||||
String description, String customerEmail, String returnUrl) {
|
* (ServiceID, OrderID, Amount, Description, CustomerEmail) - ta sama kolejnosc wchodzi do skrotu.
|
||||||
String amountStr = amount.setScale(2, RoundingMode.HALF_UP).toPlainString();
|
* Formularz wysyla przegladarka metoda POST, wiec podpis nie trafia do paska adresu ani historii.
|
||||||
|
*/
|
||||||
// Kolejnosc parametrow ma znaczenie dla podpisu.
|
public AutoPayStartForm startForm(PaymentConfig cfg, String orderId, BigDecimal amount,
|
||||||
Map<String, String> params = new LinkedHashMap<>();
|
String description, String customerEmail) {
|
||||||
params.put("ServiceID", cfg.getServiceId());
|
String amountStr = formatAmount(amount);
|
||||||
params.put("OrderID", orderId);
|
String hash = hash(cfg, List.of(cfg.getServiceId(), orderId, amountStr, description,
|
||||||
params.put("Amount", amountStr);
|
customerEmail == null ? "" : customerEmail));
|
||||||
params.put("Currency", "PLN");
|
String gatewayUrl = cfg.isSandbox() ? SANDBOX_URL : PROD_URL;
|
||||||
params.put("Description", description == null ? "" : description);
|
return new AutoPayStartForm(gatewayUrl, cfg.getServiceId(), orderId, amountStr, description,
|
||||||
params.put("CustomerEmail", customerEmail == null ? "" : customerEmail);
|
customerEmail, hash);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Weryfikacja podpisu w powiadomieniu ITN (przelicza hash z wszystkich pol poza Hash + klucz). */
|
/** Kwota w formacie 0.00 (wymagane dokladnie dwa miejsca po przecinku). */
|
||||||
public boolean verifyItn(Map<String, String> params, PaymentConfig cfg) {
|
public static String formatAmount(BigDecimal amount) {
|
||||||
if (params == null || cfg == null || cfg.getSecretKey() == null) {
|
return amount.setScale(2, RoundingMode.HALF_UP).toPlainString();
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** SHA-256 (hex, malymi literami) z wartosci polaczonych separatorem i doklejonego klucza. */
|
/** SHA-256 (hex) z niepustych wartosci polaczonych separatorem i doklejonego klucza. */
|
||||||
public String computeHash(List<String> values, String separator, String key) {
|
public String hash(PaymentConfig cfg, List<String> values) {
|
||||||
List<String> parts = new ArrayList<>(values);
|
StringBuilder sb = new StringBuilder();
|
||||||
parts.add(key);
|
for (String value : values) {
|
||||||
String data = String.join(separator, parts);
|
if (value == null || value.isEmpty()) {
|
||||||
|
continue; // puste pole opcjonalne: pomijamy bez separatora
|
||||||
|
}
|
||||||
|
sb.append(value).append(cfg.getHashSeparator());
|
||||||
|
}
|
||||||
|
sb.append(cfg.getSecretKey());
|
||||||
try {
|
try {
|
||||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
byte[] bytes = MessageDigest.getInstance("SHA-256").digest(sb.toString().getBytes(StandardCharsets.UTF_8));
|
||||||
byte[] bytes = digest.digest(data.getBytes(StandardCharsets.UTF_8));
|
|
||||||
StringBuilder hex = new StringBuilder(bytes.length * 2);
|
StringBuilder hex = new StringBuilder(bytes.length * 2);
|
||||||
for (byte b : bytes) {
|
for (byte b : bytes) {
|
||||||
hex.append(Character.forDigit((b >> 4) & 0xF, 16));
|
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;
|
package pl.polskalokalnie.promotion;
|
||||||
|
|
||||||
import java.util.Map;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
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).
|
* 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
|
* Bramka nie ma tokenu JWT - uwierzytelnienie nadawcy opiera sie wylacznie na podpisie liczonym
|
||||||
* parametry form/query i weryfikuje hash; przy podlaczeniu produkcyjnym nalezy dopasowac parsowanie
|
* kluczem konfiguracyjnym, weryfikowanym w {@link AutoPayItnService}. Adres ITN ustawia sie w panelu
|
||||||
* i format odpowiedzi potwierdzajacej wg aktualnej dokumentacji AutoPay.
|
* AutoPay: Ustawienia serwisu -> Konfiguracja techniczna serwisu -> Edytuj -> "Adres ITN"
|
||||||
|
* (pole widac dopiero w formularzu edycji): https://polskalokalnie.pl/api/payments/autopay/itn
|
||||||
*/
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/payments/autopay")
|
@RequestMapping("/api/payments/autopay")
|
||||||
public class AutoPayItnController {
|
public class AutoPayItnController {
|
||||||
|
|
||||||
private final AutoPayService autoPayService;
|
private final AutoPayItnService itnService;
|
||||||
private final PaymentConfigService paymentConfigService;
|
|
||||||
private final PromotionService promotionService;
|
|
||||||
|
|
||||||
public AutoPayItnController(AutoPayService autoPayService,
|
public AutoPayItnController(AutoPayItnService itnService) {
|
||||||
PaymentConfigService paymentConfigService,
|
this.itnService = itnService;
|
||||||
PromotionService promotionService) {
|
|
||||||
this.autoPayService = autoPayService;
|
|
||||||
this.paymentConfigService = paymentConfigService;
|
|
||||||
this.promotionService = promotionService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/itn")
|
/** Notyfikacja o statusie transakcji. Odpowiedz: podpisany XML z CONFIRMED/NOTCONFIRMED. */
|
||||||
public ResponseEntity<String> itn(@RequestParam Map<String, String> params) {
|
@PostMapping(value = "/itn", produces = MediaType.APPLICATION_XML_VALUE)
|
||||||
PaymentConfig cfg = paymentConfigService.get();
|
public ResponseEntity<String> itn(@RequestParam("transactions") String transactions) {
|
||||||
if (!autoPayService.verifyItn(params, cfg)) {
|
return ResponseEntity.ok()
|
||||||
return ResponseEntity.badRequest().body("ERROR: invalid hash");
|
.contentType(MediaType.APPLICATION_XML)
|
||||||
}
|
.body(itnService.handle(transactions));
|
||||||
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");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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("&", "&").replace("<", "<").replace(">", ">");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import org.springframework.web.bind.annotation.PostMapping;
|
|||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import pl.polskalokalnie.payment.AutoPayStartForm;
|
||||||
|
|
||||||
/** Publiczne (dla zalogowanych) operacje promowania: oferta planow/pakietow, platnosc, kredyty. */
|
/** Publiczne (dla zalogowanych) operacje promowania: oferta planow/pakietow, platnosc, kredyty. */
|
||||||
@RestController
|
@RestController
|
||||||
@@ -51,6 +52,11 @@ public class PromotionController {
|
|||||||
return new OrderStatusResponse(promotionService.orderStatus(authentication.getName(), id));
|
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) {
|
public record CreditsResponse(int credits) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import org.springframework.web.server.ResponseStatusException;
|
|||||||
import pl.polskalokalnie.listing.ListingRepository;
|
import pl.polskalokalnie.listing.ListingRepository;
|
||||||
import pl.polskalokalnie.listing.PropertyListing;
|
import pl.polskalokalnie.listing.PropertyListing;
|
||||||
import pl.polskalokalnie.payment.AutoPayService;
|
import pl.polskalokalnie.payment.AutoPayService;
|
||||||
|
import pl.polskalokalnie.payment.AutoPayStartForm;
|
||||||
import pl.polskalokalnie.payment.PaymentConfig;
|
import pl.polskalokalnie.payment.PaymentConfig;
|
||||||
import pl.polskalokalnie.payment.PaymentConfigService;
|
import pl.polskalokalnie.payment.PaymentConfigService;
|
||||||
import pl.polskalokalnie.user.AppUser;
|
import pl.polskalokalnie.user.AppUser;
|
||||||
@@ -27,7 +28,6 @@ public class PromotionService {
|
|||||||
private final ListingRepository listingRepository;
|
private final ListingRepository listingRepository;
|
||||||
private final PaymentConfigService paymentConfigService;
|
private final PaymentConfigService paymentConfigService;
|
||||||
private final AutoPayService autoPayService;
|
private final AutoPayService autoPayService;
|
||||||
private final String baseUrl;
|
|
||||||
private final boolean autoConfirmPayments;
|
private final boolean autoConfirmPayments;
|
||||||
|
|
||||||
public PromotionService(PromotionPlanRepository planRepository,
|
public PromotionService(PromotionPlanRepository planRepository,
|
||||||
@@ -37,7 +37,6 @@ public class PromotionService {
|
|||||||
ListingRepository listingRepository,
|
ListingRepository listingRepository,
|
||||||
PaymentConfigService paymentConfigService,
|
PaymentConfigService paymentConfigService,
|
||||||
AutoPayService autoPayService,
|
AutoPayService autoPayService,
|
||||||
@Value("${app.public-base-url:http://localhost}") String baseUrl,
|
|
||||||
@Value("${app.payments.auto-confirm:false}") boolean autoConfirmPayments) {
|
@Value("${app.payments.auto-confirm:false}") boolean autoConfirmPayments) {
|
||||||
this.planRepository = planRepository;
|
this.planRepository = planRepository;
|
||||||
this.packageRepository = packageRepository;
|
this.packageRepository = packageRepository;
|
||||||
@@ -46,7 +45,6 @@ public class PromotionService {
|
|||||||
this.listingRepository = listingRepository;
|
this.listingRepository = listingRepository;
|
||||||
this.paymentConfigService = paymentConfigService;
|
this.paymentConfigService = paymentConfigService;
|
||||||
this.autoPayService = autoPayService;
|
this.autoPayService = autoPayService;
|
||||||
this.baseUrl = baseUrl == null ? "" : baseUrl.replaceAll("/+$", "");
|
|
||||||
this.autoConfirmPayments = autoConfirmPayments;
|
this.autoConfirmPayments = autoConfirmPayments;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,10 +100,38 @@ public class PromotionService {
|
|||||||
applyPaid(order);
|
applyPaid(order);
|
||||||
return CheckoutResponse.paid(order.getId());
|
return CheckoutResponse.paid(order.getId());
|
||||||
}
|
}
|
||||||
String returnUrl = baseUrl + "/konto/moje-oferty?platnosc=" + order.getExternalId();
|
// Wewnetrzna strona /platnosc/{id} pobiera podpisane pola i sama wysyla formularz POST
|
||||||
String url = autoPayService.buildRedirectUrl(cfg, order.getExternalId(), order.getAmount(),
|
// do bramki - podpis nie trafia do paska adresu, historii ani naglowka Referer.
|
||||||
order.getItemName(), email, returnUrl);
|
return CheckoutResponse.redirect(order.getId(), "/platnosc/" + order.getId());
|
||||||
return CheckoutResponse.redirect(order.getId(), url);
|
}
|
||||||
|
|
||||||
|
/** 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
|
@Transactional
|
||||||
@@ -133,26 +159,6 @@ public class PromotionService {
|
|||||||
orderRepository.save(order);
|
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
|
@Transactional
|
||||||
public int promoteWithCredit(String email, CreditPromoteRequest request) {
|
public int promoteWithCredit(String email, CreditPromoteRequest request) {
|
||||||
AppUser user = userRepository.findByEmailIgnoreCase(email)
|
AppUser user = userRepository.findByEmailIgnoreCase(email)
|
||||||
@@ -308,7 +314,8 @@ public class PromotionService {
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Losowy OrderID (32 znaki hex) - nieprzewidywalny, w limicie dlugosci pola. */
|
||||||
private static String generateExternalId() {
|
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 static org.mockito.Mockito.when;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
@@ -31,7 +30,7 @@ class PaymentSecurityTest {
|
|||||||
private PromotionService service(boolean autoConfirm) {
|
private PromotionService service(boolean autoConfirm) {
|
||||||
return new PromotionService(mock(PromotionPlanRepository.class), packageRepository, orderRepository,
|
return new PromotionService(mock(PromotionPlanRepository.class), packageRepository, orderRepository,
|
||||||
userRepository, mock(ListingRepository.class), paymentConfigService, mock(AutoPayService.class),
|
userRepository, mock(ListingRepository.class), paymentConfigService, mock(AutoPayService.class),
|
||||||
"http://localhost", autoConfirm);
|
autoConfirm);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -65,20 +64,12 @@ class PaymentSecurityTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void itnCountsOnlyExplicitSuccessAsPaid() {
|
void paymentDescriptionUsesOnlyCharactersAllowedByGateway() {
|
||||||
// Regresja: PENDING oraz brak statusu byly traktowane jak oplacona platnosc.
|
PromotionOrder order = new PromotionOrder();
|
||||||
assertThat(AutoPayItnController.isSuccess(Map.of("PaymentStatus", "SUCCESS"))).isTrue();
|
order.setKind(PromotionKind.PLAN);
|
||||||
assertThat(AutoPayItnController.isSuccess(Map.of("PaymentStatus", "PENDING"))).isFalse();
|
order.setListingId(42L);
|
||||||
assertThat(AutoPayItnController.isSuccess(Map.of("PaymentStatus", "FAILURE"))).isFalse();
|
assertThat(PromotionService.paymentDescription(order))
|
||||||
assertThat(AutoPayItnController.isSuccess(Map.of("OrderID", "PL-1"))).isFalse();
|
.isEqualTo("Promowanie ogloszenia nr 42")
|
||||||
}
|
.matches("[A-Za-z0-9 .:,-]{1,79}");
|
||||||
|
|
||||||
@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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { AdminPromotionView } from './admin/promotion';
|
|||||||
import { AdminSettingsView } from './admin/settings';
|
import { AdminSettingsView } from './admin/settings';
|
||||||
import { TermsPage, PrivacyPolicyPage, CookieBanner } from './legal';
|
import { TermsPage, PrivacyPolicyPage, CookieBanner } from './legal';
|
||||||
import { SiteFooter } from './Footer';
|
import { SiteFooter } from './Footer';
|
||||||
import { ActivationPage, PasswordResetPage, PhoneOtpModal } from './AuthPages';
|
import { ActivationPage, PasswordResetPage, PaymentRedirectPage, PhoneOtpModal } from './AuthPages';
|
||||||
|
|
||||||
const heroImage = new URL('./assets/hero-interior.png', import.meta.url).href;
|
const heroImage = new URL('./assets/hero-interior.png', import.meta.url).href;
|
||||||
const cityImage = new URL('./assets/city-panorama.png', import.meta.url).href;
|
const cityImage = new URL('./assets/city-panorama.png', import.meta.url).href;
|
||||||
@@ -3668,6 +3668,7 @@ function App() {
|
|||||||
|
|
||||||
{/* Trasy wymagajace zalogowania */}
|
{/* Trasy wymagajace zalogowania */}
|
||||||
<Route element={<ProtectedRoute />}>
|
<Route element={<ProtectedRoute />}>
|
||||||
|
<Route path={ROUTES.payment} element={<PaymentRedirectPage />} />
|
||||||
<Route path={ROUTES.guideAdd} element={<GuideAddArticlePage />} />
|
<Route path={ROUTES.guideAdd} element={<GuideAddArticlePage />} />
|
||||||
<Route path={ROUTES.account} element={<AccountDashboardPage savedSearches={savedSearchesState} onOpenSavedSearch={openSavedSearch} />} />
|
<Route path={ROUTES.account} element={<AccountDashboardPage savedSearches={savedSearchesState} onOpenSavedSearch={openSavedSearch} />} />
|
||||||
<Route
|
<Route
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { useEffect, useRef, useState, type FormEvent, type ReactNode } from 'react';
|
import { useEffect, useRef, useState, type FormEvent, type ReactNode } from 'react';
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||||
import { ROUTES } from './routes';
|
import { ROUTES } from './routes';
|
||||||
import { useAuth } from './auth';
|
import { apiFetch, useAuth } from './auth';
|
||||||
|
|
||||||
const errMsg = (e: unknown) => (e instanceof Error ? e.message : 'Wystąpił błąd. Spróbuj ponownie.');
|
const errMsg = (e: unknown) => (e instanceof Error ? e.message : 'Wystąpił błąd. Spróbuj ponownie.');
|
||||||
|
|
||||||
@@ -65,6 +65,76 @@ export function ActivationPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AutoPayStartForm = {
|
||||||
|
gatewayUrl: string;
|
||||||
|
serviceId: string;
|
||||||
|
orderId: string;
|
||||||
|
amount: string;
|
||||||
|
description: string;
|
||||||
|
customerEmail: string | null;
|
||||||
|
hash: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// /platnosc/:orderId - przekierowanie do bramki AutoPay. Transakcje startuje sie formularzem POST,
|
||||||
|
// wiec podpisane pola pobieramy z API i wysylamy automatycznie. Podpis nie trafia do paska adresu,
|
||||||
|
// historii przegladarki ani naglowka Referer.
|
||||||
|
export function PaymentRedirectPage() {
|
||||||
|
const { orderId } = useParams<{ orderId: string }>();
|
||||||
|
const [form, setForm] = useState<AutoPayStartForm | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
|
const ran = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (ran.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ran.current = true;
|
||||||
|
apiFetch<AutoPayStartForm>(`/promotion/order/${encodeURIComponent(orderId ?? '')}/autopay-form`)
|
||||||
|
.then(setForm)
|
||||||
|
.catch((e) => setError(errMsg(e)));
|
||||||
|
}, [orderId]);
|
||||||
|
|
||||||
|
// Automatyczna wysylka formularza po pobraniu podpisanych pol.
|
||||||
|
useEffect(() => {
|
||||||
|
if (form) {
|
||||||
|
formRef.current?.submit();
|
||||||
|
}
|
||||||
|
}, [form]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<AuthStatusCard>
|
||||||
|
<div className="auth-status-icon err">!</div>
|
||||||
|
<h1>Nie udało się rozpocząć płatności</h1>
|
||||||
|
<p className="auth-status-lead">{error}</p>
|
||||||
|
<Link className="auth-status-btn" to={ROUTES.accountListings}>Wróć do moich ofert</Link>
|
||||||
|
</AuthStatusCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthStatusCard>
|
||||||
|
<h1>Przekierowanie do płatności</h1>
|
||||||
|
<p className="auth-status-lead">
|
||||||
|
{form ? 'Za chwilę nastąpi przekierowanie do AutoPay...' : 'Przygotowujemy bezpieczną płatność...'}
|
||||||
|
</p>
|
||||||
|
{form && (
|
||||||
|
<form ref={formRef} method="POST" action={form.gatewayUrl}>
|
||||||
|
<input type="hidden" name="ServiceID" value={form.serviceId} />
|
||||||
|
<input type="hidden" name="OrderID" value={form.orderId} />
|
||||||
|
<input type="hidden" name="Amount" value={form.amount} />
|
||||||
|
<input type="hidden" name="Description" value={form.description} />
|
||||||
|
{form.customerEmail && <input type="hidden" name="CustomerEmail" value={form.customerEmail} />}
|
||||||
|
<input type="hidden" name="Hash" value={form.hash} />
|
||||||
|
{/* Zapasowy przycisk, gdyby automatyczna wysylka nie zadzialala. */}
|
||||||
|
<button className="auth-status-btn" type="submit">Kontynuuj do płatności</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</AuthStatusCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// /reset-hasla/:token - ustawienie nowego hasla.
|
// /reset-hasla/:token - ustawienie nowego hasla.
|
||||||
export function PasswordResetPage() {
|
export function PasswordResetPage() {
|
||||||
const { token } = useParams<{ token: string }>();
|
const { token } = useParams<{ token: string }>();
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ export function AdminPromotionView({ Icon }: { Icon: IconComponent }) {
|
|||||||
<span className="mkt-config-icon"><Icon name="credit-card" /></span>
|
<span className="mkt-config-icon"><Icon name="credit-card" /></span>
|
||||||
<h2>Bramka szybkich płatności - AutoPay</h2>
|
<h2>Bramka szybkich płatności - AutoPay</h2>
|
||||||
</div>
|
</div>
|
||||||
<p className="banner-slot-hint"><Icon name="lock" /> Bez danych dostępowych i włączonej bramki zakup promowania jest niedostępny.</p>
|
<p className="banner-slot-hint"><Icon name="lock" /> Bez danych dostępowych i włączonej bramki zakup promowania jest niedostępny. Adres ITN w panelu AutoPay (Ustawienia serwisu → Konfiguracja techniczna → Edytuj): {window.location.origin}/api/payments/autopay/itn</p>
|
||||||
<div className="mkt-config-grid">
|
<div className="mkt-config-grid">
|
||||||
<label>ServiceID
|
<label>ServiceID
|
||||||
<input value={pay.serviceId} onChange={(e) => setPay({ ...pay, serviceId: e.target.value })} placeholder="np. 123456" />
|
<input value={pay.serviceId} onChange={(e) => setPay({ ...pay, serviceId: e.target.value })} placeholder="np. 123456" />
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ export const ROUTES = {
|
|||||||
privacy: '/polityka-prywatnosci',
|
privacy: '/polityka-prywatnosci',
|
||||||
activation: '/aktywacja/:token',
|
activation: '/aktywacja/:token',
|
||||||
passwordReset: '/reset-hasla/:token',
|
passwordReset: '/reset-hasla/:token',
|
||||||
|
// Przekierowanie do bramki AutoPay (podpisany formularz POST) dla zamowienia promowania.
|
||||||
|
payment: '/platnosc/:orderId',
|
||||||
notifications: '/powiadomienia',
|
notifications: '/powiadomienia',
|
||||||
favorites: '/ulubione',
|
favorites: '/ulubione',
|
||||||
messages: '/wiadomosci',
|
messages: '/wiadomosci',
|
||||||
@@ -123,6 +125,11 @@ export function newConversationPath(listingId: number): string {
|
|||||||
return `/wiadomosci/nowa/${listingId}`;
|
return `/wiadomosci/nowa/${listingId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Przekierowanie do bramki platnosci dla zamowienia promowania (adres zwraca tez backend przy checkout).
|
||||||
|
export function paymentPath(orderId: number): string {
|
||||||
|
return `/platnosc/${orderId}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Dedykowana strona edycji wlasnego ogloszenia.
|
// Dedykowana strona edycji wlasnego ogloszenia.
|
||||||
export function listingEditPath(id: number): string {
|
export function listingEditPath(id: number): string {
|
||||||
return `/edytuj-ogloszenie/${id}`;
|
return `/edytuj-ogloszenie/${id}`;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test, expect, type Page } from '@playwright/test';
|
import { test, expect, type Page } from '@playwright/test';
|
||||||
import { ROUTES, negotiationPath, ADMIN_CONVERSATION_SLUG, conversationPath, newConversationPath } from '../src/routes';
|
import { ROUTES, negotiationPath, ADMIN_CONVERSATION_SLUG, conversationPath, newConversationPath, paymentPath } from '../src/routes';
|
||||||
|
|
||||||
// Standard projektu (AGENTS.md): kazdy widok ma wlasny URL obslugiwany przez React Router,
|
// Standard projektu (AGENTS.md): kazdy widok ma wlasny URL obslugiwany przez React Router,
|
||||||
// a nawigacja odbywa sie przez <Link>/<NavLink>. Te testy pilnuja tej zasady.
|
// a nawigacja odbywa sie przez <Link>/<NavLink>. Te testy pilnuja tej zasady.
|
||||||
@@ -34,6 +34,7 @@ const PROTECTED_ROUTES = [
|
|||||||
ROUTES.messages,
|
ROUTES.messages,
|
||||||
conversationPath(ADMIN_CONVERSATION_SLUG),
|
conversationPath(ADMIN_CONVERSATION_SLUG),
|
||||||
newConversationPath(1),
|
newConversationPath(1),
|
||||||
|
paymentPath(1),
|
||||||
ROUTES.add,
|
ROUTES.add,
|
||||||
ROUTES.admin,
|
ROUTES.admin,
|
||||||
`${ROUTES.admin}/leady`,
|
`${ROUTES.admin}/leady`,
|
||||||
|
|||||||
Reference in New Issue
Block a user