Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
cfc444c
Comply with new processquery API for table queries
ryanrdoherty Jun 10, 2026
902de9f
Merge branch 'master' into table-process-queries
ryanrdoherty Jun 10, 2026
0e18c6d
Comply with new TableField API
ryanrdoherty Jun 10, 2026
86e949d
First steps for converting to cybersource unified checkout
ryanrdoherty Aug 3, 2026
f427e6c
Revert "First steps for converting to cybersource unified checkout"
ryanrdoherty Aug 3, 2026
5133a30
First steps for converting to cybersource unified checkout
ryanrdoherty Aug 3, 2026
026e03d
Merge branch 'master' into unified-checkout
ryanrdoherty Aug 3, 2026
b90adae
Merge branch 'master' into unified-checkout
ryanrdoherty Aug 3, 2026
a2bb41d
Add NCBI key to model.prop
ryanrdoherty Aug 4, 2026
fc3cf59
Merge branch 'master' into unified-checkout
ryanrdoherty Aug 4, 2026
9f54f8c
Move ncbi config to prod
ryanrdoherty Aug 4, 2026
53bf8cc
Merge branch 'master' into unified-checkout
ryanrdoherty Aug 4, 2026
3a9b794
Merge branch 'master' into unified-checkout
ryanrdoherty Aug 11, 2026
a87b456
Set capture context and payment services
ryanrdoherty Aug 12, 2026
3ac59fe
Merge branch 'master' into unified-checkout
ryanrdoherty Aug 18, 2026
fe8828e
Fix logging and some edits on Claude
ryanrdoherty Aug 23, 2026
4696bda
Merge branch 'master' into unified-checkout
ryanrdoherty Aug 23, 2026
2c54f1c
Add new cybersource classes to service application
ryanrdoherty Aug 26, 2026
55983de
Temporarily set config location to dev homedir
ryanrdoherty Aug 26, 2026
5257fdc
Pull client library link details off context and send to client
ryanrdoherty Aug 26, 2026
350e447
Pin to v1 instead of old 0.30 for latest client compatible code
ryanrdoherty Aug 26, 2026
b5561ef
Upgrade to latest cybersource rest client lib
ryanrdoherty Aug 26, 2026
26e9adb
Improve error logging
ryanrdoherty Aug 26, 2026
db1867d
Full upgrade to 1.0.x API
ryanrdoherty Aug 26, 2026
06826fd
Improve log look
ryanrdoherty Aug 28, 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
Expand Up @@ -185,7 +185,7 @@
"level": "info",
"AppenderRef": { "ref": "page-view-log" }
},{
"name": "org.eupathdb.common.service.CyberSourceFormService",
"name": "org.eupathdb.common.service.CyberSourceLogger",
"additivity": "false",
"level": "info",
"AppenderRef": { "ref": "payment-form-request-log" }
Expand Down
6 changes: 6 additions & 0 deletions Model/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,12 @@
<artifactId>Jackfish</artifactId>
</dependency>

<dependency>
<groupId>com.cybersource</groupId>
<artifactId>cybersource-rest-client-java</artifactId>
<version>0.0.93</version>
</dependency>

<!-- Contains context listener used to prevent classloader memory leaks (used by web.xmls) -->
<dependency>
<groupId>se.jiderhamn.classloader-leak-prevention</groupId>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
package org.eupathdb.common.service;

import java.util.Arrays;
import java.util.List;

import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;

import org.apache.log4j.Logger;
import org.gusdb.wdk.model.WdkModelException;
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.UnifiedCheckoutV1CaptureContextApi;
import Invokers.ApiClient;
import Invokers.ApiException;
import Model.GenerateUnifiedCheckoutV1CaptureContextRequest;
import Model.Ucv1sessionsCaptureMandate;
import Model.Ucv1sessionsCompleteMandate;
import Model.Ucv1sessionsData;
import Model.Ucv1sessionsDataOrderInformation;
import Model.Ucv1sessionsDataOrderInformationAmountDetails;

/**
* The single GET endpoint takes a payment amount and currency and returns the
* capture-context JWT that the client-side Unified Checkout JavaScript
* library needs to render its embedded payment form, along with the
* generated reference number (to be echoed back on the follow-up call to
* {@link CyberSourcePaymentService}) and the URL (plus SRI integrity hash) of
* the Unified Checkout JS asset to load, as extracted from the capture
* context JWT itself (CyberSource docs require these NOT be hardcoded, since
* they're unique per transaction).
*/
@Path("payment-form-context")
public class CyberSourceCaptureContextService extends AbstractWdkService {

private static final Logger LOG = Logger.getLogger(CyberSourceCaptureContextService.class);

// model.prop property containing this site's base URL, e.g. https://plasmodb.org
private static final String LOCALHOST_PROP_KEY = "LOCALHOST";

// Pinned to MAJOR.MINOR (the only pinned format the /uc/v1/sessions endpoint
// accepts -- confirmed via its own SDK javadoc and by a live "Invalid Client
// Version '1'" validation error when a bare MAJOR value was tried). This
// still auto-receives patch fixes within v1.0, but won't silently jump to a
// future breaking v2 API. See "Pin to a Version" in CyberSource's
// Server-Side Set Up docs, and the "Version 1 Update Checklist" for what
// changed from v0 (e.g. the v0 Accept(session).unifiedPayments()/up.show()
// API was replaced in v1 by
// VAS.UnifiedCheckout(session)/client.createCheckout()/checkout.mount()).
// Front end must call createCheckout({ autoProcessing: false }) so that
// checkout.mount() resolves with a transient token instead of completing
// the transaction client-side.
private static final String CLIENT_VERSION = "1.0";

private static final List<String> ALLOWED_CARD_NETWORKS = Arrays.asList(
"VISA", "MASTERCARD", "AMEX", "DISCOVER", "DINERSCLUB", "JCB");

private static final List<String> 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;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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));
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
));
}
}
Original file line number Diff line number Diff line change
@@ -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.");
}
}
}
Loading