ALLOWED_PAYMENT_TYPES = Arrays.asList("PANENTRY");
+
+ @GET
+ @Produces(MediaType.APPLICATION_JSON)
+ public Response getCaptureContext(
+ @QueryParam("amount") String amount, // required; must match the pattern in CyberSourceUtil
+ @QueryParam("currency") String currency, // optional; defaults to USD
+ @QueryParam("invoice_number") String invoiceNumber // optional; logged with reference number for traceability
+ ) {
+ amount = CyberSourceUtil.validateAmountParam(amount);
+ currency = CyberSourceUtil.validateCurrencyParam(currency);
+ invoiceNumber = CyberSourceUtil.validateInvoiceNumber(invoiceNumber);
+
+ String referenceNumber = CyberSourceUtil.generateReferenceNumber();
+ CyberSourceLogger.logPaymentEvent("capture-context", getRequestingUser(), referenceNumber, amount, currency, invoiceNumber);
+
+ JSONObject config = CyberSourceUtil.readConfig();
+ String localhost = getLocalhostUrl();
+
+ GenerateUnifiedCheckoutV1CaptureContextRequest requestObj = new GenerateUnifiedCheckoutV1CaptureContextRequest();
+ requestObj.clientVersion(CLIENT_VERSION);
+ requestObj.targetOrigins(Arrays.asList(localhost));
+ requestObj.allowedCardNetworks(ALLOWED_CARD_NETWORKS);
+ requestObj.allowedPaymentTypes(ALLOWED_PAYMENT_TYPES);
+ requestObj.country("US");
+ requestObj.locale("en_US");
+
+ Ucv1sessionsCaptureMandate captureMandate = new Ucv1sessionsCaptureMandate();
+ captureMandate.billingType("FULL");
+ captureMandate.requestEmail(true);
+ captureMandate.requestPhone(false);
+ captureMandate.requestShipping(false);
+ captureMandate.showAcceptedNetworkIcons(true);
+ captureMandate.showConfirmationStep(true);
+ requestObj.captureMandate(captureMandate);
+
+ // In v1, orderInformation moved under the new top-level `data` object.
+ Ucv1sessionsDataOrderInformationAmountDetails amountDetails = new Ucv1sessionsDataOrderInformationAmountDetails();
+ amountDetails.totalAmount(amount);
+ amountDetails.currency(currency);
+ Ucv1sessionsDataOrderInformation orderInformation = new Ucv1sessionsDataOrderInformation();
+ orderInformation.amountDetails(amountDetails);
+ Ucv1sessionsData data = new Ucv1sessionsData();
+ data.orderInformation(orderInformation);
+ requestObj.data(data);
+
+ Ucv1sessionsCompleteMandate completeMandate = new Ucv1sessionsCompleteMandate();
+ completeMandate.setType("CAPTURE");
+ completeMandate.setDecisionManager(false);
+ requestObj.setCompleteMandate(completeMandate);
+
+ try {
+ MerchantConfig merchantConfig = CyberSourceUtil.buildMerchantConfig(config);
+ ApiClient apiClient = new ApiClient();
+ apiClient.merchantConfig = merchantConfig;
+
+ UnifiedCheckoutV1CaptureContextApi apiInstance = new UnifiedCheckoutV1CaptureContextApi(apiClient);
+ String captureContextJwt = apiInstance.generateUnifiedCheckoutV1CaptureContext(requestObj);
+
+ // Per CyberSource docs, the JS library URL and its SRI hash must be read
+ // out of the capture context JWT itself (ctx[0].data.clientLibrary /
+ // clientLibraryIntegrity) rather than hardcoded/guessed, since they are
+ // unique to each transaction and can change without notice.
+ JSONObject ctxData = CyberSourceUtil.decodeJwtPayload(captureContextJwt)
+ .getJSONArray("ctx").getJSONObject(0).getJSONObject("data");
+
+ JSONObject responseJson = new JSONObject()
+ .put("captureContext", captureContextJwt)
+ .put("referenceNumber", referenceNumber)
+ .put("scriptUrl", ctxData.getString("clientLibrary"))
+ .put("scriptIntegrity", ctxData.optString("clientLibraryIntegrity", null));
+
+ return Response.ok(responseJson.toString()).build();
+ }
+ catch (ApiException e) {
+ LOG.error("CyberSource capture-context API error for reference " + referenceNumber + ": HTTP " + e.getCode() + " " + e.getResponseBody(), e);
+ throw new WdkRuntimeException("Unable to generate CyberSource capture context", e);
+ }
+ catch (Exception e) {
+ throw new WdkRuntimeException("Unable to generate CyberSource capture context", e);
+ }
+ }
+
+ private String getLocalhostUrl() {
+ String localhost = getWdkModel().getProperties().get(LOCALHOST_PROP_KEY);
+ if (localhost == null) {
+ throw new WdkRuntimeException(new WdkModelException("model.prop must contain the property: " + LOCALHOST_PROP_KEY));
+ }
+ return localhost;
+ }
+
+}
diff --git a/Model/src/main/java/org/eupathdb/common/service/CyberSourceFormService.java b/Model/src/main/java/org/eupathdb/common/service/CyberSourceFormService.java
index bdb051ea..9e06b284 100644
--- a/Model/src/main/java/org/eupathdb/common/service/CyberSourceFormService.java
+++ b/Model/src/main/java/org/eupathdb/common/service/CyberSourceFormService.java
@@ -39,7 +39,7 @@
/**
* The single GET endpoint takes a payment amount and currency and returns a
* JSON object where the keys/values represent all the form input fields
- * required by CyberSource to being their checkout sequence. Web client code
+ * required by CyberSource to begin their checkout sequence. Web client code
* is responsible for converting this object to a form and submitting it to
* the appropriate CyberSource endpoint.
*/
@@ -68,7 +68,7 @@ public class CyberSourceFormService extends AbstractWdkService {
public Response generateCyberSourceForm(
@QueryParam("amount") String amount, // required; must match the pattern above
@QueryParam("currency") String currency, // optional; defaults to USD
- @QueryParam("invoice_number") String invoiceNumber // optional; logged with reference number for trackability
+ @QueryParam("invoice_number") String invoiceNumber // optional; logged with reference number for traceability
) {
// validate and massage amount and currency params
@@ -171,7 +171,7 @@ private static String getUTCDateTime() {
return sdf.format(new Date());
}
- private static JSONObject readConfig() {
+ static JSONObject readConfig() {
try (Reader in = new FileReader(CONFIG_FILE_LOCATION)) {
return new JSONObject(IoUtil.readAllChars(in));
}
diff --git a/Model/src/main/java/org/eupathdb/common/service/CyberSourceLogger.java b/Model/src/main/java/org/eupathdb/common/service/CyberSourceLogger.java
new file mode 100644
index 00000000..9c21ea74
--- /dev/null
+++ b/Model/src/main/java/org/eupathdb/common/service/CyberSourceLogger.java
@@ -0,0 +1,21 @@
+package org.eupathdb.common.service;
+
+import org.apache.log4j.Logger;
+import org.gusdb.wdk.model.user.User;
+
+public class CyberSourceLogger {
+
+ private static final Logger LOG = Logger.getLogger(CyberSourceLogger.class);
+
+ static void logPaymentEvent(String stage, User requestingUser, String referenceNumber, String amount, String currency, String invoiceNumber) {
+ LOG.info("\t" + String.join("\t",
+ stage,
+ String.valueOf(requestingUser.getUserId()),
+ "guest=" + requestingUser.isGuest(),
+ referenceNumber,
+ amount,
+ currency,
+ invoiceNumber
+ ));
+ }
+}
diff --git a/Model/src/main/java/org/eupathdb/common/service/CyberSourcePaymentService.java b/Model/src/main/java/org/eupathdb/common/service/CyberSourcePaymentService.java
new file mode 100644
index 00000000..8b0f3eea
--- /dev/null
+++ b/Model/src/main/java/org/eupathdb/common/service/CyberSourcePaymentService.java
@@ -0,0 +1,110 @@
+package org.eupathdb.common.service;
+
+import javax.ws.rs.BadRequestException;
+import javax.ws.rs.Consumes;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import javax.ws.rs.Produces;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+
+import org.apache.log4j.Logger;
+import org.gusdb.wdk.model.WdkRuntimeException;
+import org.gusdb.wdk.service.service.AbstractWdkService;
+import org.json.JSONObject;
+
+import com.cybersource.authsdk.core.MerchantConfig;
+
+import Api.PaymentsApi;
+import Invokers.ApiClient;
+import Invokers.ApiException;
+import Model.CreatePaymentRequest;
+import Model.PtsV2PaymentsPost201Response;
+import Model.Ptsv2paymentsClientReferenceInformation;
+import Model.Ptsv2paymentsOrderInformation;
+import Model.Ptsv2paymentsOrderInformationAmountDetails;
+import Model.Ptsv2paymentsTokenInformation;
+
+/**
+ * Takes the transient token returned by the Unified Checkout JS widget (once
+ * the donor has entered their payment info in CyberSource's embedded iframe)
+ * along with the amount/currency/reference-number originally used to build
+ * the capture context, and performs the actual server-to-server authorize +
+ * capture ("sale") against CyberSource's Payments API. Card data is never
+ * present in this request; the transient token is an opaque, short-lived
+ * (~15 min) reference to it.
+ */
+@Path("payment-process")
+public class CyberSourcePaymentService extends AbstractWdkService {
+
+ private static final Logger LOG = Logger.getLogger(CyberSourcePaymentService.class);
+
+ @POST
+ @Consumes(MediaType.APPLICATION_JSON)
+ @Produces(MediaType.APPLICATION_JSON)
+ public Response processPayment(String body) {
+
+ JSONObject input = parseInput(body);
+
+ String amount = CyberSourceUtil.validateAmountParam(input.optString("amount", null));
+ String currency = CyberSourceUtil.validateCurrencyParam(input.optString("currency", null));
+ String invoiceNumber = CyberSourceUtil.validateInvoiceNumber(input.optString("invoiceNumber", null));
+ String referenceNumber = CyberSourceUtil.validateReferenceNumber(input.optString("referenceNumber", null));
+ String transientToken = CyberSourceUtil.validateTransientToken(input.optString("transientToken", null));
+
+ CyberSourceLogger.logPaymentEvent("payment-process", getRequestingUser(), referenceNumber, amount, currency, invoiceNumber);
+
+ JSONObject config = CyberSourceUtil.readConfig();
+
+ CreatePaymentRequest requestObj = new CreatePaymentRequest();
+
+ Ptsv2paymentsClientReferenceInformation clientReferenceInformation = new Ptsv2paymentsClientReferenceInformation();
+ clientReferenceInformation.code(referenceNumber);
+ requestObj.clientReferenceInformation(clientReferenceInformation);
+
+ Ptsv2paymentsOrderInformation orderInformation = new Ptsv2paymentsOrderInformation();
+ Ptsv2paymentsOrderInformationAmountDetails amountDetails = new Ptsv2paymentsOrderInformationAmountDetails();
+ amountDetails.totalAmount(amount);
+ amountDetails.currency(currency);
+ orderInformation.amountDetails(amountDetails);
+ requestObj.orderInformation(orderInformation);
+
+ Ptsv2paymentsTokenInformation tokenInformation = new Ptsv2paymentsTokenInformation();
+ tokenInformation.transientTokenJwt(transientToken);
+ requestObj.tokenInformation(tokenInformation);
+
+ try {
+ MerchantConfig merchantConfig = CyberSourceUtil.buildMerchantConfig(config);
+ ApiClient apiClient = new ApiClient();
+ apiClient.merchantConfig = merchantConfig;
+
+ PaymentsApi apiInstance = new PaymentsApi(apiClient);
+ PtsV2PaymentsPost201Response result = apiInstance.createPayment(requestObj);
+
+ LOG.info("CyberSource payment result\t" + referenceNumber + "\t" + result.getStatus() + "\t" + result.getId());
+
+ JSONObject responseJson = new JSONObject()
+ .put("status", result.getStatus())
+ .put("transactionId", result.getId())
+ .put("referenceNumber", referenceNumber);
+
+ return Response.ok(responseJson.toString()).build();
+ }
+ catch (ApiException e) {
+ LOG.error("CyberSource payment API error for reference " + referenceNumber + ": HTTP " + e.getCode() + " " + e.getResponseBody(), e);
+ throw new WdkRuntimeException("Unable to process CyberSource payment", e);
+ }
+ catch (Exception e) {
+ throw new WdkRuntimeException("Unable to process CyberSource payment", e);
+ }
+ }
+
+ private static JSONObject parseInput(String body) {
+ try {
+ return new JSONObject(body);
+ }
+ catch (Exception e) {
+ throw new BadRequestException("Request body must be a valid JSON object.");
+ }
+ }
+}
diff --git a/Model/src/main/java/org/eupathdb/common/service/CyberSourceUtil.java b/Model/src/main/java/org/eupathdb/common/service/CyberSourceUtil.java
new file mode 100644
index 00000000..cc56cccb
--- /dev/null
+++ b/Model/src/main/java/org/eupathdb/common/service/CyberSourceUtil.java
@@ -0,0 +1,193 @@
+package org.eupathdb.common.service;
+
+import java.io.FileReader;
+import java.io.IOException;
+import java.io.Reader;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.Date;
+import java.util.Properties;
+import java.util.Random;
+import java.util.regex.Pattern;
+
+import javax.ws.rs.BadRequestException;
+
+import org.gusdb.fgputil.IoUtil;
+import org.gusdb.wdk.model.WdkRuntimeException;
+import org.json.JSONObject;
+
+import com.cybersource.authsdk.core.ConfigException;
+import com.cybersource.authsdk.core.MerchantConfig;
+
+/**
+ * Shared config/validation/logging support for the Unified Checkout capture
+ * context and payment-processing services. The config file at
+ * {@link #CONFIG_FILE_LOCATION} is expected to contain the following keys.
+ * merchant_id and run_environment come from CyberSource Business Center; the
+ * key_* fields describe the P12 key file (generated in Business Center under
+ * Payment Configuration -> Key Management -> API Keys -> JWT) used to sign
+ * the JWT for authentication. The response_mle_* fields are optional and, if
+ * present, describe a *separate* P12 key pair (Business Center -> Key
+ * Management -> API Keys -> REST - API Response MLE) used to decrypt
+ * CyberSource's encrypted API responses; enable_request_mle optionally turns
+ * on encryption of our outgoing requests, using a cert normally
+ * auto-extracted from the auth P12 above (override via
+ * request_mle_cert_path/request_mle_key_alias if that P12 wasn't generated
+ * with an embedded MLE cert). CyberSource is making the updated MLE version
+ * mandatory across the platform by September 2026, so both should be
+ * configured before then even though they're optional here:
+ *
+ *
+ * {
+ * "merchant_id": "...",
+ * "run_environment": "apitest.cybersource.com", // or "api.cybersource.com" in production
+ * "keys_directory": "/usr/local/tomcat_instances/shared",
+ * "key_file_name": "...", // .p12 filename, without the .p12 extension
+ * "key_alias": "...",
+ * "key_password": "...",
+ * "enable_request_mle": true,
+ * "request_mle_cert_path": "...", // optional override
+ * "request_mle_key_alias": "...", // optional override
+ * "response_mle_key_path": "...", // path to the separate response-MLE .p12
+ * "response_mle_key_password": "...",
+ * "response_mle_kid": "..." // optional; required only for non-P12 key formats
+ * }
+ *
+ */
+class CyberSourceUtil {
+
+ // location of file containing cybersource REST API credentials
+ //private static final String CONFIG_FILE_LOCATION = "/usr/local/tomcat_instances/shared/.cybersource.config.json";
+ private static final String CONFIG_FILE_LOCATION = "/home/rdoherty/cybersource/.cybersource.config.json";
+
+ // regex to recognize proper amount values
+ private static final Pattern MONEY_PATTERN = Pattern.compile("^[0-9]+(\\.[0-9][0-9])?$");
+
+ // regex to recognize proper invoice/reference numbers
+ private static final Pattern INVOICE_NUMBER_PATTERN = Pattern.compile("^[0-9A-Za-z\\-]+$");
+
+ // will appear in log when invoice param not sent or empty
+ static final String INVOICE_NOT_SPECIFIED = "Not_Specified";
+
+ private static final String TEST_RUN_ENVIRONMENT = "apitest.cybersource.com";
+
+ static String validateAmountParam(String amount) {
+ if (amount == null || !MONEY_PATTERN.matcher(amount).matches()) {
+ throw new BadRequestException("'amount' parameter is required and represent a numeric payment amount in US Dollars.");
+ }
+ return amount.indexOf(".") == -1 ? amount + ".00" : amount;
+ }
+
+ // TODO: in the future we will probably support multiple currencies
+ static String validateCurrencyParam(String currency) {
+ if (currency != null && !currency.toUpperCase().equals("USD")) {
+ throw new BadRequestException("'currency' parameter, if passed, must be 'USD'; other currencies are not yet supported");
+ }
+ return "USD";
+ }
+
+ static String validateInvoiceNumber(String invoiceNumber) {
+ if (invoiceNumber == null || invoiceNumber.isBlank()) {
+ return INVOICE_NOT_SPECIFIED;
+ }
+ if (INVOICE_NUMBER_PATTERN.matcher(invoiceNumber).matches()) {
+ return invoiceNumber;
+ }
+ throw new BadRequestException("'invoice_number' parameter is malformed; only alphanumeric and hyphen characters are allowed");
+ }
+
+ static String validateReferenceNumber(String referenceNumber) {
+ if (referenceNumber == null || !INVOICE_NUMBER_PATTERN.matcher(referenceNumber).matches()) {
+ throw new BadRequestException("'reference_number' parameter is required and must match the value returned by the capture-context request.");
+ }
+ return referenceNumber;
+ }
+
+ static String validateTransientToken(String transientToken) {
+ if (transientToken == null || transientToken.isBlank()) {
+ throw new BadRequestException("'transient_token' parameter is required.");
+ }
+ return transientToken;
+ }
+
+ // reference number for tracking (add 5 random digits at each ms)
+ static String generateReferenceNumber() {
+ return String.valueOf(new Date().getTime()) +
+ String.format("%05d", new Random().nextInt(100000));
+ }
+
+ static JSONObject readConfig() {
+ try (Reader in = new FileReader(CONFIG_FILE_LOCATION)) {
+ return new JSONObject(IoUtil.readAllChars(in));
+ }
+ catch (IOException e) {
+ throw new WdkRuntimeException("Unable to read/parse config file at: " + CONFIG_FILE_LOCATION, e);
+ }
+ }
+
+ static boolean isTestEnvironment(JSONObject config) {
+ return TEST_RUN_ENVIRONMENT.equals(config.getString("run_environment"));
+ }
+
+ /**
+ * Decodes the (unencrypted, base64url) payload segment of a JWT without
+ * verifying its signature. Used to pull the {@code clientLibrary} /
+ * {@code clientLibraryIntegrity} values out of the capture-context JWT;
+ * per CyberSource's docs these must NOT be hardcoded/guessed on our end,
+ * since they're unique per transaction:
+ * https://developer.cybersource.com/docs/cybs/en-us/unified-checkout/developer/all/rest/unified-checkout/uc-getting-started-cs-setup-intro/uc-getting-started-cs-js-library-intro.html
+ */
+ static JSONObject decodeJwtPayload(String jwt) {
+ String[] parts = jwt.split("\\.");
+ if (parts.length < 2) {
+ throw new WdkRuntimeException("Malformed JWT returned by CyberSource (expected header.payload.signature)");
+ }
+ byte[] decoded = Base64.getUrlDecoder().decode(parts[1]);
+ return new JSONObject(new String(decoded, StandardCharsets.UTF_8));
+ }
+
+ static MerchantConfig buildMerchantConfig(JSONObject config) {
+ Properties props = new Properties();
+ // JWT auth signed with a P12 key pair (HTTP Signature is deprecated).
+ props.setProperty("authenticationType", "jwt");
+ props.setProperty("merchantID", config.getString("merchant_id"));
+ props.setProperty("runEnvironment", config.getString("run_environment"));
+ props.setProperty("keysDirectory", config.getString("keys_directory"));
+ props.setProperty("keyFileName", config.getString("key_file_name"));
+ props.setProperty("keyAlias", config.getString("key_alias"));
+ props.setProperty("keyPass", config.getString("key_password"));
+ props.setProperty("enableLog", "false");
+
+ // Request MLE: encrypts the JSON we send to CyberSource. The cert is
+ // auto-extracted from the auth P12 above (via the default
+ // "CyberSource_SJC_US" alias) unless overridden below.
+ if (config.optBoolean("enable_request_mle", false)) {
+ props.setProperty("enableRequestMLEForOptionalApisGlobally", "true");
+ if (config.has("request_mle_cert_path")) {
+ props.setProperty("mleForRequestPublicCertPath", config.getString("request_mle_cert_path"));
+ }
+ if (config.has("request_mle_key_alias")) {
+ props.setProperty("requestMleKeyAlias", config.getString("request_mle_key_alias"));
+ }
+ }
+
+ // Response MLE: decrypts CyberSource's JSON responses using the
+ // dedicated "REST - API Response MLE" P12 key pair (separate from the
+ // auth key pair above).
+ if (config.has("response_mle_key_path")) {
+ props.setProperty("enableResponseMleGlobally", "true");
+ props.setProperty("responseMlePrivateKeyFilePath", config.getString("response_mle_key_path"));
+ props.setProperty("responseMlePrivateKeyFilePassword", config.getString("response_mle_key_password"));
+ if (config.has("response_mle_kid")) {
+ props.setProperty("responseMleKID", config.getString("response_mle_kid"));
+ }
+ }
+
+ try {
+ return new MerchantConfig(props);
+ }
+ catch (ConfigException e) {
+ throw new WdkRuntimeException("Invalid CyberSource merchant configuration", e);
+ }
+ }
+}
diff --git a/Model/src/main/java/org/eupathdb/common/service/EuPathServiceApplication.java b/Model/src/main/java/org/eupathdb/common/service/EuPathServiceApplication.java
index 29a0f0b1..43466b59 100644
--- a/Model/src/main/java/org/eupathdb/common/service/EuPathServiceApplication.java
+++ b/Model/src/main/java/org/eupathdb/common/service/EuPathServiceApplication.java
@@ -27,6 +27,8 @@ public Set> getClasses() {
.add(BlastFormInternalValuesService.class)
.add(UserProfileVocabulariesService.class)
.add(CyberSourceFormService.class)
+ .add(CyberSourceCaptureContextService.class)
+ .add(CyberSourcePaymentService.class)
.add(RawFileDownloadService.class)
.add(ConfigurableRedirectsService.class)
.add(PubMedProxyService.class)