- 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);
|
||||
}
|
||||
|
||||
String hash = computeHash(new ArrayList<>(params.values()), cfg.getHashSeparator(), cfg.getSecretKey());
|
||||
params.put("Hash", hash);
|
||||
|
||||
String base = cfg.isSandbox() ? SANDBOX_URL : PROD_URL;
|
||||
StringBuilder query = new StringBuilder();
|
||||
for (Map.Entry<String, String> e : params.entrySet()) {
|
||||
if (query.length() > 0) {
|
||||
query.append('&');
|
||||
}
|
||||
query.append(enc(e.getKey())).append('=').append(enc(e.getValue()));
|
||||
}
|
||||
return base + "?" + query;
|
||||
/**
|
||||
* Podpisane pola formularza startu transakcji w kolejnosci wymaganej przez dokumentacje
|
||||
* (ServiceID, OrderID, Amount, Description, CustomerEmail) - ta sama kolejnosc wchodzi do skrotu.
|
||||
* Formularz wysyla przegladarka metoda POST, wiec podpis nie trafia do paska adresu ani historii.
|
||||
*/
|
||||
public AutoPayStartForm startForm(PaymentConfig cfg, String orderId, BigDecimal amount,
|
||||
String description, String customerEmail) {
|
||||
String amountStr = formatAmount(amount);
|
||||
String hash = hash(cfg, List.of(cfg.getServiceId(), orderId, amountStr, description,
|
||||
customerEmail == null ? "" : customerEmail));
|
||||
String gatewayUrl = cfg.isSandbox() ? SANDBOX_URL : PROD_URL;
|
||||
return new AutoPayStartForm(gatewayUrl, cfg.getServiceId(), orderId, amountStr, description,
|
||||
customerEmail, hash);
|
||||
}
|
||||
|
||||
/** Weryfikacja podpisu w powiadomieniu ITN (przelicza hash z wszystkich pol poza Hash + klucz). */
|
||||
public boolean verifyItn(Map<String, String> params, PaymentConfig cfg) {
|
||||
if (params == null || cfg == null || cfg.getSecretKey() == null) {
|
||||
return false;
|
||||
}
|
||||
String provided = params.get("Hash");
|
||||
if (provided == null || provided.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
List<String> values = new ArrayList<>();
|
||||
for (Map.Entry<String, String> e : params.entrySet()) {
|
||||
if (!"Hash".equalsIgnoreCase(e.getKey())) {
|
||||
values.add(e.getValue());
|
||||
}
|
||||
}
|
||||
String expected = computeHash(values, cfg.getHashSeparator(), cfg.getSecretKey());
|
||||
return MessageDigest.isEqual(
|
||||
expected.getBytes(StandardCharsets.UTF_8),
|
||||
provided.trim().toLowerCase().getBytes(StandardCharsets.UTF_8));
|
||||
/** Kwota w formacie 0.00 (wymagane dokladnie dwa miejsca po przecinku). */
|
||||
public static String formatAmount(BigDecimal amount) {
|
||||
return amount.setScale(2, RoundingMode.HALF_UP).toPlainString();
|
||||
}
|
||||
|
||||
/** SHA-256 (hex, malymi literami) z wartosci polaczonych separatorem i doklejonego klucza. */
|
||||
public String computeHash(List<String> values, String separator, String key) {
|
||||
List<String> parts = new ArrayList<>(values);
|
||||
parts.add(key);
|
||||
String data = String.join(separator, parts);
|
||||
/** SHA-256 (hex) z niepustych wartosci polaczonych separatorem i doklejonego klucza. */
|
||||
public String hash(PaymentConfig cfg, List<String> values) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (String value : values) {
|
||||
if (value == null || value.isEmpty()) {
|
||||
continue; // puste pole opcjonalne: pomijamy bez separatora
|
||||
}
|
||||
sb.append(value).append(cfg.getHashSeparator());
|
||||
}
|
||||
sb.append(cfg.getSecretKey());
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] bytes = digest.digest(data.getBytes(StandardCharsets.UTF_8));
|
||||
byte[] bytes = MessageDigest.getInstance("SHA-256").digest(sb.toString().getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder hex = new StringBuilder(bytes.length * 2);
|
||||
for (byte b : bytes) {
|
||||
hex.append(Character.forDigit((b >> 4) & 0xF, 16));
|
||||
@@ -97,7 +65,16 @@ public class AutoPayService {
|
||||
}
|
||||
}
|
||||
|
||||
private static String enc(String value) {
|
||||
return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8);
|
||||
/**
|
||||
* Porownanie skrotow odporne na atak czasowy (weryfikacja podpisu ITN) - String.equals konczy
|
||||
* sie przy pierwszej roznicy i zdradza liczbe zgodnych znakow.
|
||||
*/
|
||||
public static boolean hashMatches(String expected, String actual) {
|
||||
if (expected == null || actual == null) {
|
||||
return false;
|
||||
}
|
||||
return MessageDigest.isEqual(
|
||||
expected.toLowerCase().getBytes(StandardCharsets.UTF_8),
|
||||
actual.trim().toLowerCase().getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package pl.polskalokalnie.payment;
|
||||
|
||||
/**
|
||||
* Podpisane pola formularza startu transakcji. Frontend wysyla je metoda POST na {@code gatewayUrl}
|
||||
* (bramka AutoPay przyjmuje {@code application/x-www-form-urlencoded}).
|
||||
*/
|
||||
public record AutoPayStartForm(
|
||||
String gatewayUrl,
|
||||
String serviceId,
|
||||
String orderId,
|
||||
String amount,
|
||||
String description,
|
||||
String customerEmail,
|
||||
String hash
|
||||
) {
|
||||
}
|
||||
@@ -1,57 +1,35 @@
|
||||
package pl.polskalokalnie.promotion;
|
||||
|
||||
import java.util.Map;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import pl.polskalokalnie.payment.AutoPayService;
|
||||
import pl.polskalokalnie.payment.PaymentConfig;
|
||||
import pl.polskalokalnie.payment.PaymentConfigService;
|
||||
|
||||
/**
|
||||
* Publiczny endpoint powiadomien AutoPay (ITN - Instant Transaction Notification).
|
||||
* Weryfikuje podpis i oznacza zamowienie jako oplacone.
|
||||
*
|
||||
* UWAGA: realny ITN AutoPay przesyla dane w okreslonym formacie (XML/base64) - ten scaffold przyjmuje
|
||||
* parametry form/query i weryfikuje hash; przy podlaczeniu produkcyjnym nalezy dopasowac parsowanie
|
||||
* i format odpowiedzi potwierdzajacej wg aktualnej dokumentacji AutoPay.
|
||||
* Bramka nie ma tokenu JWT - uwierzytelnienie nadawcy opiera sie wylacznie na podpisie liczonym
|
||||
* kluczem konfiguracyjnym, weryfikowanym w {@link AutoPayItnService}. Adres ITN ustawia sie w panelu
|
||||
* AutoPay: Ustawienia serwisu -> Konfiguracja techniczna serwisu -> Edytuj -> "Adres ITN"
|
||||
* (pole widac dopiero w formularzu edycji): https://polskalokalnie.pl/api/payments/autopay/itn
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/payments/autopay")
|
||||
public class AutoPayItnController {
|
||||
|
||||
private final AutoPayService autoPayService;
|
||||
private final PaymentConfigService paymentConfigService;
|
||||
private final PromotionService promotionService;
|
||||
private final AutoPayItnService itnService;
|
||||
|
||||
public AutoPayItnController(AutoPayService autoPayService,
|
||||
PaymentConfigService paymentConfigService,
|
||||
PromotionService promotionService) {
|
||||
this.autoPayService = autoPayService;
|
||||
this.paymentConfigService = paymentConfigService;
|
||||
this.promotionService = promotionService;
|
||||
public AutoPayItnController(AutoPayItnService itnService) {
|
||||
this.itnService = itnService;
|
||||
}
|
||||
|
||||
@PostMapping("/itn")
|
||||
public ResponseEntity<String> itn(@RequestParam Map<String, String> params) {
|
||||
PaymentConfig cfg = paymentConfigService.get();
|
||||
if (!autoPayService.verifyItn(params, cfg)) {
|
||||
return ResponseEntity.badRequest().body("ERROR: invalid hash");
|
||||
}
|
||||
String orderId = params.get("OrderID");
|
||||
if (orderId != null && isSuccess(params)) {
|
||||
promotionService.markPaidByExternalId(orderId, params.get("Amount"));
|
||||
}
|
||||
return ResponseEntity.ok("OK");
|
||||
}
|
||||
|
||||
// Oplacone jest tylko zamowienie z jawnym, koncowym statusem SUCCESS. PENDING to platnosc
|
||||
// rozpoczeta, ktora moze jeszcze sie nie udac, a brak statusu nie moze oznaczac sukcesu.
|
||||
static boolean isSuccess(Map<String, String> params) {
|
||||
String status = params.getOrDefault("PaymentStatus",
|
||||
params.getOrDefault("Status", params.get("paymentStatus")));
|
||||
return status != null && status.trim().equalsIgnoreCase("SUCCESS");
|
||||
/** Notyfikacja o statusie transakcji. Odpowiedz: podpisany XML z CONFIRMED/NOTCONFIRMED. */
|
||||
@PostMapping(value = "/itn", produces = MediaType.APPLICATION_XML_VALUE)
|
||||
public ResponseEntity<String> itn(@RequestParam("transactions") String transactions) {
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.APPLICATION_XML)
|
||||
.body(itnService.handle(transactions));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
package pl.polskalokalnie.promotion;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import pl.polskalokalnie.payment.AutoPayItnParser;
|
||||
import pl.polskalokalnie.payment.AutoPayService;
|
||||
import pl.polskalokalnie.payment.PaymentConfig;
|
||||
import pl.polskalokalnie.payment.PaymentConfigService;
|
||||
|
||||
/**
|
||||
* Obsluga notyfikacji ITN z AutoPay - jedyne miejsce, w ktorym zamowienie zostaje uznane za oplacone
|
||||
* (wg rozwiazania dzialajacego produkcyjnie w Gieldzie Komunalnej).
|
||||
*
|
||||
* Kolejnosc sprawdzen (fail-closed - kazdy niespelniony warunek daje NOTCONFIRMED):
|
||||
* 1. podpis calej notyfikacji liczony kluczem konfiguracyjnym (uwierzytelnia nadawce),
|
||||
* 2. zgodnosc identyfikatora serwisu,
|
||||
* 3. istnienie zamowienia o danym OrderID,
|
||||
* 4. zgodnosc kwoty i waluty z zamowieniem (obrona przed manipulacja kwota),
|
||||
* 5. idempotencja - powtorzona notyfikacja nie nalicza drugi raz.
|
||||
*
|
||||
* AutoPay ponawia notyfikacje az do potwierdzenia, wiec powtorki sa normalnym trybem pracy.
|
||||
*/
|
||||
@Service
|
||||
public class AutoPayItnService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AutoPayItnService.class);
|
||||
private static final String CONFIRMED = "CONFIRMED";
|
||||
private static final String NOT_CONFIRMED = "NOTCONFIRMED";
|
||||
private static final String NL = "\n";
|
||||
|
||||
private final PaymentConfigService paymentConfigService;
|
||||
private final AutoPayService autoPayService;
|
||||
private final PromotionOrderRepository orderRepository;
|
||||
private final PromotionService promotionService;
|
||||
|
||||
public AutoPayItnService(PaymentConfigService paymentConfigService, AutoPayService autoPayService,
|
||||
PromotionOrderRepository orderRepository, PromotionService promotionService) {
|
||||
this.paymentConfigService = paymentConfigService;
|
||||
this.autoPayService = autoPayService;
|
||||
this.orderRepository = orderRepository;
|
||||
this.promotionService = promotionService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Przetwarza notyfikacje i buduje podpisana odpowiedz XML wymagana przez AutoPay.
|
||||
*
|
||||
* @param transactionsBase64 zawartosc parametru {@code transactions} (XML w base64)
|
||||
*/
|
||||
@Transactional
|
||||
public String handle(String transactionsBase64) {
|
||||
PaymentConfig cfg = paymentConfigService.get();
|
||||
AutoPayItnParser.Itn itn = AutoPayItnParser.parse(decode(transactionsBase64));
|
||||
|
||||
boolean authentic = verifySignature(cfg, itn);
|
||||
if (!authentic) {
|
||||
// Bez szczegolow - to potencjalna proba podszycia sie pod bramke.
|
||||
log.warn("Odrzucono notyfikacje ITN AutoPay: niepoprawny podpis lub identyfikator serwisu");
|
||||
}
|
||||
|
||||
List<Confirmation> confirmations = new ArrayList<>();
|
||||
for (AutoPayItnParser.ItnTransaction tx : itn.transactions()) {
|
||||
String result = authentic ? process(tx) : NOT_CONFIRMED;
|
||||
confirmations.add(new Confirmation(tx.orderId(), result));
|
||||
}
|
||||
return response(cfg, confirmations);
|
||||
}
|
||||
|
||||
private boolean verifySignature(PaymentConfig cfg, AutoPayItnParser.Itn itn) {
|
||||
if (!cfg.isLive() || !cfg.getServiceId().trim().equals(itn.serviceId())) {
|
||||
return false;
|
||||
}
|
||||
String expected = autoPayService.hash(cfg, itn.signedValues());
|
||||
return AutoPayService.hashMatches(expected, itn.hash());
|
||||
}
|
||||
|
||||
private String process(AutoPayItnParser.ItnTransaction tx) {
|
||||
Optional<PromotionOrder> found = orderRepository.findByExternalId(tx.orderId());
|
||||
if (found.isEmpty()) {
|
||||
log.warn("Notyfikacja ITN dla nieznanego OrderID: {}", tx.orderId());
|
||||
return NOT_CONFIRMED;
|
||||
}
|
||||
PromotionOrder order = found.get();
|
||||
|
||||
if (!tx.success()) {
|
||||
// PENDING / FAILURE: potwierdzamy odbior (inaczej bramka ponawialaby bez konca),
|
||||
// ale niczego nie naliczamy.
|
||||
if ("FAILURE".equalsIgnoreCase(tx.paymentStatus()) && order.getStatus() == OrderStatus.PENDING) {
|
||||
order.setStatus(OrderStatus.FAILED);
|
||||
orderRepository.save(order);
|
||||
}
|
||||
return CONFIRMED;
|
||||
}
|
||||
|
||||
// Idempotencja: powtorzona notyfikacja o oplaconym zamowieniu jest potwierdzana,
|
||||
// ale nie nalicza drugi raz kredytow ani wyroznienia.
|
||||
if (order.getStatus() == OrderStatus.PAID) {
|
||||
return CONFIRMED;
|
||||
}
|
||||
|
||||
if (!amountMatches(order.getAmount(), tx.amount()) || !"PLN".equalsIgnoreCase(tx.currency())) {
|
||||
log.warn("Notyfikacja ITN z niezgodna kwota/waluta dla OrderID {}: oczekiwano {} PLN, otrzymano {} {}",
|
||||
tx.orderId(), order.getAmount(), tx.amount(), tx.currency());
|
||||
return NOT_CONFIRMED;
|
||||
}
|
||||
|
||||
promotionService.applyPaid(order);
|
||||
log.info("AutoPay: oplacono zamowienie promowania, OrderID {}, zamowienie {}", tx.orderId(), order.getId());
|
||||
return CONFIRMED;
|
||||
}
|
||||
|
||||
/** Porownanie kwot wartoscia liczbowa (a nie tekstem) - "49.00" i "49.0" to ta sama kwota. */
|
||||
static boolean amountMatches(BigDecimal expected, String actual) {
|
||||
if (expected == null || actual == null) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return expected.compareTo(new BigDecimal(actual)) == 0;
|
||||
} catch (NumberFormatException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private record Confirmation(String orderId, String result) {
|
||||
}
|
||||
|
||||
/** Odpowiedz wymagana przez AutoPay: XML z potwierdzeniami i wlasnym podpisem. */
|
||||
private String response(PaymentConfig cfg, List<Confirmation> confirmations) {
|
||||
String serviceId = cfg.getServiceId() == null ? "" : cfg.getServiceId().trim();
|
||||
// Podpis odpowiedzi: serviceID, a nastepnie dla kazdej transakcji orderID i confirmation.
|
||||
List<String> values = new ArrayList<>();
|
||||
values.add(serviceId);
|
||||
for (Confirmation c : confirmations) {
|
||||
values.add(c.orderId());
|
||||
values.add(c.result());
|
||||
}
|
||||
String hash = autoPayService.hash(cfg, values);
|
||||
|
||||
StringBuilder entries = new StringBuilder();
|
||||
for (Confirmation c : confirmations) {
|
||||
entries.append(" <transactionConfirmed>").append(NL)
|
||||
.append(" <orderID>").append(escape(c.orderId())).append("</orderID>").append(NL)
|
||||
.append(" <confirmation>").append(c.result()).append("</confirmation>").append(NL)
|
||||
.append(" </transactionConfirmed>").append(NL);
|
||||
}
|
||||
|
||||
return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" + NL
|
||||
+ "<confirmationList>" + NL
|
||||
+ " <serviceID>" + escape(serviceId) + "</serviceID>" + NL
|
||||
+ " <transactionsConfirmations>" + NL
|
||||
+ entries
|
||||
+ " </transactionsConfirmations>" + NL
|
||||
+ " <hash>" + hash + "</hash>" + NL
|
||||
+ "</confirmationList>";
|
||||
}
|
||||
|
||||
private static String decode(String base64) {
|
||||
try {
|
||||
return new String(Base64.getDecoder().decode(base64.trim()), StandardCharsets.UTF_8);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowa notyfikacja ITN");
|
||||
}
|
||||
}
|
||||
|
||||
private static String escape(String value) {
|
||||
return value == null ? "" : value.replace("&", "&").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("-", "");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user