- 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.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);
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
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;
|
||||
/** 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();
|
||||
}
|
||||
|
||||
/** 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;
|
||||
/** 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
|
||||
}
|
||||
String provided = params.get("Hash");
|
||||
if (provided == null || provided.isBlank()) {
|
||||
return false;
|
||||
sb.append(value).append(cfg.getHashSeparator());
|
||||
}
|
||||
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. */
|
||||
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);
|
||||
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("&", "&").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.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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import { AdminPromotionView } from './admin/promotion';
|
||||
import { AdminSettingsView } from './admin/settings';
|
||||
import { TermsPage, PrivacyPolicyPage, CookieBanner } from './legal';
|
||||
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 cityImage = new URL('./assets/city-panorama.png', import.meta.url).href;
|
||||
@@ -3668,6 +3668,7 @@ function App() {
|
||||
|
||||
{/* Trasy wymagajace zalogowania */}
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route path={ROUTES.payment} element={<PaymentRedirectPage />} />
|
||||
<Route path={ROUTES.guideAdd} element={<GuideAddArticlePage />} />
|
||||
<Route path={ROUTES.account} element={<AccountDashboardPage savedSearches={savedSearchesState} onOpenSavedSearch={openSavedSearch} />} />
|
||||
<Route
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { useEffect, useRef, useState, type FormEvent, type ReactNode } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
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.');
|
||||
|
||||
@@ -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.
|
||||
export function PasswordResetPage() {
|
||||
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>
|
||||
<h2>Bramka szybkich płatności - AutoPay</h2>
|
||||
</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">
|
||||
<label>ServiceID
|
||||
<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',
|
||||
activation: '/aktywacja/:token',
|
||||
passwordReset: '/reset-hasla/:token',
|
||||
// Przekierowanie do bramki AutoPay (podpisany formularz POST) dla zamowienia promowania.
|
||||
payment: '/platnosc/:orderId',
|
||||
notifications: '/powiadomienia',
|
||||
favorites: '/ulubione',
|
||||
messages: '/wiadomosci',
|
||||
@@ -123,6 +125,11 @@ export function newConversationPath(listingId: number): string {
|
||||
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.
|
||||
export function listingEditPath(id: number): string {
|
||||
return `/edytuj-ogloszenie/${id}`;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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,
|
||||
// a nawigacja odbywa sie przez <Link>/<NavLink>. Te testy pilnuja tej zasady.
|
||||
@@ -34,6 +34,7 @@ const PROTECTED_ROUTES = [
|
||||
ROUTES.messages,
|
||||
conversationPath(ADMIN_CONVERSATION_SLUG),
|
||||
newConversationPath(1),
|
||||
paymentPath(1),
|
||||
ROUTES.add,
|
||||
ROUTES.admin,
|
||||
`${ROUTES.admin}/leady`,
|
||||
|
||||
Reference in New Issue
Block a user