Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
8f5a541
feat: add TransaccionCompletaMallController for handling mall transac…
victormendoza96 Mar 30, 2026
5bef76b
feat: add loading button styles and functionality for form submissions
victormendoza96 Mar 30, 2026
c0a189b
feat: create MallDetailSession class for handling mall transaction de…
victormendoza96 Mar 30, 2026
f4de6ec
feat: remove error logging in exception handler of TransaccionComplet…
victormendoza96 Mar 30, 2026
da5d5d3
feat: add form loading script to layout for improved user experience
victormendoza96 Mar 30, 2026
60cb21f
feat: refine text and formatting in transaction form for clarity and …
victormendoza96 Mar 30, 2026
c6b6a8a
feat: add transaction form for mall payments with card details and va…
victormendoza96 Mar 30, 2026
2e37190
feat: add create transaction page for mall payments with detailed ste…
victormendoza96 Mar 30, 2026
463bbe9
feat: add installments consultation page for mall transactions with f…
victormendoza96 Mar 30, 2026
10765ee
feat: add confirmation page for mall transactions with request and re…
victormendoza96 Mar 30, 2026
66c7cc2
feat: add refund page for mall transactions with request and response…
victormendoza96 Mar 30, 2026
661e477
feat: add status page for mall transactions with request and response…
victormendoza96 Mar 30, 2026
33b3209
feat: remove unnecessary default case in navigation switch statement
victormendoza96 Mar 30, 2026
bfce824
feat: simplify navigation label creation using switch expression
victormendoza96 Mar 30, 2026
c8b7700
feat: update transaction commit details to use optional parameters
victormendoza96 Mar 30, 2026
d2fc83d
feat: add MallFullTransaction instance variable to TransaccionComplet…
victormendoza96 Mar 31, 2026
6021978
feat: add CSRF token input to mall transaction forms and fix label fo…
victormendoza96 Mar 31, 2026
4354dd3
feat: improve formatting and readability of the transaction form HTML
victormendoza96 Mar 31, 2026
8e777a4
feat: fix label formatting for optional fields in installments and tr…
victormendoza96 Mar 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
package cl.transbank.webpay.example.controllers;

import cl.transbank.common.IntegrationApiKeys;
import cl.transbank.common.IntegrationCommerceCodes;
import cl.transbank.common.IntegrationType;
import cl.transbank.model.MallTransactionCreateDetails;
import cl.transbank.webpay.common.WebpayOptions;
import cl.transbank.webpay.exception.*;
import cl.transbank.webpay.transaccioncompleta.MallFullTransaction;
import cl.transbank.webpay.transaccioncompleta.model.MallTransactionCommitDetails;
import cl.transbank.webpay.transaccioncompleta.responses.MallFullTransactionInstallmentsDetails;
import cl.transbank.webpay.example.models.MallDetailSession;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.log4j.Log4j2;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;

import java.io.IOException;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

@Log4j2
@Controller
@RequestMapping("/transaccion-completa-mall")
public class TransaccionCompletaMallController extends BaseController {
private static final String TEMPLATE_FOLDER = "transaccion_completa_mall";
private static final String BASE_URL = "/transaccion-completa-mall";
private static final String PRODUCT = "Webpay Transacción Completa Mall";

private static final String VIEW_INDEX = TEMPLATE_FOLDER + "/index";
private static final String VIEW_CREATE = TEMPLATE_FOLDER + "/create";
private static final String VIEW_INSTALLMENTS = TEMPLATE_FOLDER + "/installments";
private static final String VIEW_COMMIT = TEMPLATE_FOLDER + "/commit";
private static final String VIEW_STATUS = TEMPLATE_FOLDER + "/status";
private static final String VIEW_REFUND = TEMPLATE_FOLDER + "/refund";

private static final String NAV_LABEL_FORM = "Formulario";
private static final String NAV_LABEL_REQUEST = "Petición";
private static final String NAV_LABEL_RESPONSE = "Respuesta";

private static final String ATTR_NAVIGATION = "navigation";
private static final String ATTR_PRODUCT = "product";
private static final String ATTR_BREADCRUMBS = "breadcrumbs";
private static final String ATTR_RESPONSE_DATA = "response_data";
private static final String ATTR_RESPONSE_DATA_JSON = "response_data_json";
private static final String ATTR_REQUEST_TOKEN = "request_token";
private static final String ATTR_ERROR = "error";
private static final String ATTR_ID_QUERY_INSTALLMENTS = "id_query_installments";

private static final String SESSION_DETAILS = "transaccion_completa_mall_details";
private static final String NAV_KEY_REQUEST = "request";
private static final String NAV_KEY_RESPONSE = "response";
private static final String NAV_KEY_FORM = "form";

private static final SecureRandom SECURE_RANDOM = new SecureRandom();

private static final Map<String, String> NAV_INDEX = createNav(NAV_KEY_FORM);
private static final Map<String, String> NAV_CREATE = createNav(NAV_KEY_REQUEST, NAV_KEY_RESPONSE, NAV_KEY_FORM);
private static final Map<String, String> NAV_INSTALLMENTS = createNav(NAV_KEY_REQUEST, NAV_KEY_RESPONSE, NAV_KEY_FORM);
private static final Map<String, String> NAV_COMMIT = createNav(NAV_KEY_REQUEST, NAV_KEY_RESPONSE, NAV_KEY_FORM);
private static final Map<String, String> NAV_STATUS = createNav(NAV_KEY_REQUEST, NAV_KEY_RESPONSE);
private static final Map<String, String> NAV_REFUND = NAV_STATUS;
private final MallFullTransaction tx;


private static Map<String, String> createNav(String... keys) {
Map<String, String> nav = new LinkedHashMap<>();
for (String key : keys) {
String label = switch (key) {
case NAV_KEY_REQUEST -> NAV_LABEL_REQUEST;
case NAV_KEY_RESPONSE -> NAV_LABEL_RESPONSE;
case NAV_KEY_FORM -> NAV_LABEL_FORM;
default -> null;
};
if (label != null) {
nav.put(key, label);
}
}
return nav;
}

public TransaccionCompletaMallController() {
this.tx = new MallFullTransaction(
new WebpayOptions(
IntegrationCommerceCodes.TRANSACCION_COMPLETA_MALL,
IntegrationApiKeys.WEBPAY,
IntegrationType.TEST
)
);
}

private void addProductAndBreadcrumbs(Model model, String label, String url) {
var breadcrumbs = new LinkedHashMap<String, String>();
breadcrumbs.put("Inicio", "/");
breadcrumbs.put(PRODUCT, BASE_URL);
if (label != null) {
breadcrumbs.put(label, url);
}
model.addAttribute(ATTR_PRODUCT, PRODUCT);
model.addAttribute(ATTR_BREADCRUMBS, breadcrumbs);
}

@GetMapping("")
public String index(Model model) {
model.addAttribute(ATTR_NAVIGATION, NAV_INDEX);
addProductAndBreadcrumbs(model, null, null);
return VIEW_INDEX;
}

@PostMapping("/create")
public String create(
HttpServletRequest req,
@RequestParam("number") String number,
Comment thread
victormendoza96 marked this conversation as resolved.
@RequestParam("expiry") String expiry,
@RequestParam("cvc") String cvc,
Model model
) throws TransactionCreateException, IOException {
model.addAttribute(ATTR_NAVIGATION, NAV_CREATE);
addProductAndBreadcrumbs(model, "Crear transacción", BASE_URL + "/create");

String cardNumber = number.replaceAll("\\s+", "");
String[] expiryParts = expiry.split("/");
String month = expiryParts.length > 0 ? expiryParts[0] : "";
String year = expiryParts.length > 1 ? expiryParts[1] : "";
String cardExpiry = year + "/" + month;

String buyOrder = "O-" + getRandomNumber();
String sessionId = "S-" + getRandomNumber();

var sessionDetails = buildSessionDetails(
IntegrationCommerceCodes.TRANSACCION_COMPLETA_MALL_CHILD1,
IntegrationCommerceCodes.TRANSACCION_COMPLETA_MALL_CHILD2
);

var details = MallTransactionCreateDetails.build()
.add(sessionDetails.get(0).getAmount(), sessionDetails.get(0).getCommerceCode(), sessionDetails.get(0).getBuyOrder())
.add(sessionDetails.get(1).getAmount(), sessionDetails.get(1).getCommerceCode(), sessionDetails.get(1).getBuyOrder());

var resp = tx.create(buyOrder, sessionId, cardNumber, cardExpiry, details, Short.parseShort(cvc));
req.getSession().setAttribute(SESSION_DETAILS, sessionDetails);

model.addAttribute(ATTR_RESPONSE_DATA, resp);
model.addAttribute(ATTR_RESPONSE_DATA_JSON, toJson(resp));

return VIEW_CREATE;
}

@PostMapping("/installments")
public String installments(
HttpServletRequest req,
@RequestParam("token") String token,
@RequestParam("installments_number") byte installmentsNumber,
Model model
) throws TransactionInstallmentException, IOException {
model.addAttribute(ATTR_NAVIGATION, NAV_INSTALLMENTS);
addProductAndBreadcrumbs(model, "Consulta de cuotas", BASE_URL + "/installments");

List<MallDetailSession> sessionDetails = getSessionDetails(req);
if (sessionDetails.isEmpty()) {
model.addAttribute(ATTR_ERROR, "Debes crear la transacción antes de consultar cuotas.");
return VIEW_ERROR;
}

var details = MallFullTransactionInstallmentsDetails.build()
.add(sessionDetails.get(0).getCommerceCode(), sessionDetails.get(0).getBuyOrder(), installmentsNumber)
.add(sessionDetails.get(1).getCommerceCode(), sessionDetails.get(1).getBuyOrder(), installmentsNumber);

var resp = tx.installments(token, details);
Long idQueryInstallments = null;
if (resp != null && resp.getResponseList() != null && !resp.getResponseList().isEmpty()) {
idQueryInstallments = resp.getResponseList().get(0).getIdQueryInstallments();
}

model.addAttribute(ATTR_REQUEST_TOKEN, token);
model.addAttribute(ATTR_RESPONSE_DATA, resp);
model.addAttribute(ATTR_RESPONSE_DATA_JSON, toJson(resp));
model.addAttribute(ATTR_ID_QUERY_INSTALLMENTS, idQueryInstallments);

return VIEW_INSTALLMENTS;
}

@GetMapping("/commit")
public String commit(
HttpServletRequest req,
@RequestParam("token") String token,
@RequestParam(value = "idQueryInstallments", required = false) Long idQueryInstallments,
@RequestParam(value = "deferredPeriodIndex", required = false) Byte deferredPeriodIndex,
@RequestParam(value = "gracePeriod", required = false) Boolean gracePeriod,
Model model
) throws TransactionCommitException, IOException {
model.addAttribute(ATTR_NAVIGATION, NAV_COMMIT);
addProductAndBreadcrumbs(model, "Confirmar transacción", BASE_URL + "/commit");

List<MallDetailSession> sessionDetails = getSessionDetails(req);
if (sessionDetails.isEmpty()) {
model.addAttribute(ATTR_ERROR, "Debes crear la transacción antes de confirmar.");
return VIEW_ERROR;
}

boolean safeGracePeriod = gracePeriod != null ? gracePeriod : Boolean.FALSE;

var details = MallTransactionCommitDetails.build()
.add(sessionDetails.get(0).getCommerceCode(), sessionDetails.get(0).getBuyOrder(), idQueryInstallments, deferredPeriodIndex, safeGracePeriod)
.add(sessionDetails.get(1).getCommerceCode(), sessionDetails.get(1).getBuyOrder(), idQueryInstallments, deferredPeriodIndex, safeGracePeriod);

var resp = tx.commit(token, details);

model.addAttribute(ATTR_REQUEST_TOKEN, token);
model.addAttribute(ATTR_RESPONSE_DATA, resp);
model.addAttribute(ATTR_RESPONSE_DATA_JSON, toJson(resp));

return VIEW_COMMIT;
}

@GetMapping("/status")
public String status(
@RequestParam("token") String token,
Model model
) throws TransactionStatusException, IOException {
model.addAttribute(ATTR_NAVIGATION, NAV_STATUS);
addProductAndBreadcrumbs(model, "Estado de transacción", BASE_URL + "/status");

var resp = tx.status(token);
model.addAttribute(ATTR_RESPONSE_DATA, resp);
model.addAttribute(ATTR_RESPONSE_DATA_JSON, toJson(resp));

return VIEW_STATUS;
}

@GetMapping("/refund")
public String refund(
@RequestParam("token") String token,
@RequestParam("buy_order") String buyOrder,
@RequestParam("commerce_code") String commerceCode,
@RequestParam("amount") double amount,
Model model
) throws TransactionRefundException, IOException {
model.addAttribute(ATTR_NAVIGATION, NAV_REFUND);
addProductAndBreadcrumbs(model, "Reembolsar", BASE_URL + "/refund");

var resp = tx.refund(token, buyOrder, commerceCode, amount);
model.addAttribute(ATTR_REQUEST_TOKEN, token);
model.addAttribute(ATTR_RESPONSE_DATA, resp);
model.addAttribute(ATTR_RESPONSE_DATA_JSON, toJson(resp));

return VIEW_REFUND;
}

private List<MallDetailSession> buildSessionDetails(String commerceCode1, String commerceCode2) {
List<MallDetailSession> details = new ArrayList<>();
details.add(new MallDetailSession(
1000.0 + SECURE_RANDOM.nextInt(1001),
commerceCode1,
"O-" + getRandomNumber()
));
details.add(new MallDetailSession(
1000.0 + SECURE_RANDOM.nextInt(1001),
commerceCode2,
"O-" + getRandomNumber()
));
return details;
}

@SuppressWarnings("unchecked")
private List<MallDetailSession> getSessionDetails(HttpServletRequest req) {
Object value = req.getSession().getAttribute(SESSION_DETAILS);
if (value instanceof List<?>) {
return (List<MallDetailSession>) value;
}
return new ArrayList<>();
}

@ExceptionHandler(Exception.class)
public String handleException(Exception e, Model model) {
model.addAttribute(ATTR_ERROR, e.getMessage());
return VIEW_ERROR;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package cl.transbank.webpay.example.models;

import java.io.Serializable;

public class MallDetailSession implements Serializable {
private static final long serialVersionUID = 1L;

private final double amount;
private final String commerceCode;
private final String buyOrder;

public MallDetailSession(double amount, String commerceCode, String buyOrder) {
this.amount = amount;
this.commerceCode = commerceCode;
this.buyOrder = buyOrder;
}

public double getAmount() {
return amount;
}

public String getCommerceCode() {
return commerceCode;
}

public String getBuyOrder() {
return buyOrder;
}
}
29 changes: 29 additions & 0 deletions src/main/resources/static/css/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -1244,3 +1244,32 @@ code {
border: 1px solid #eee;
border-radius: 4px;
}

.loading-button {
display: inline-flex;
align-items: center;
gap: 8px;
}

.loading-button .loading-spinner {
display: none;
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.35);
border-top-color: #ffffff;
border-radius: 50%;
animation: capture-spin 0.7s linear infinite;
}

.loading-button.is-loading .loading-spinner {
display: inline-block;
}

.loading-button.is-disabled {
opacity: 0.7;
cursor: not-allowed;
}

@keyframes capture-spin {
to { transform: rotate(360deg); }
}
18 changes: 18 additions & 0 deletions src/main/resources/static/js/form_loading.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
document.addEventListener('DOMContentLoaded', function () {
const forms = document.querySelectorAll('[data-loading-form="true"]');
const buttons = document.querySelectorAll('[data-loading-button="true"]');

forms.forEach(function (form) {
form.addEventListener('submit', function () {
buttons.forEach(function (btn) {
btn.disabled = true;
btn.classList.add('is-disabled');
});

const btn = form.querySelector('[data-loading-button="true"]');
if (btn) {
btn.classList.add('is-loading');
}
});
});
});
1 change: 1 addition & 0 deletions src/main/resources/templates/layout.html
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
<script th:src="@{/js/sidebar.js}"></script>
<script th:src="@{/js/navigation.js}"></script>
<script th:src="@{/js/highlight.min.js}"></script>
<script th:src="@{/js/form_loading.js}"></script>
<script>
hljs.highlightAll();
</script>
Expand Down
Loading
Loading