load_main_gateway_class();
}
public function add_setting_link( $links ) {
array_unshift(
$links,
''
. esc_html__( 'Settings', ACHP_TEXT_DOMAIN )
. ''
);
return $links;
}
public function add_notice_for_woocommerce() {
?>
register( new ACHP_Blocks_Support() );
}
);
}
public static function set_credentials() {
$options = get_option( 'woocommerce_ach_processing_settings' );
$testmode = false;
if ( isset(
$options['testmode'],
$options['test_api_key'],
$options['api_key'],
$options['test_user_name'],
$options['user_name'],
$options['test_password'],
$options['password'],
$options['test_api_endpoint'],
$options['api_endpoint']
) ) {
if ( 'yes' === $options['testmode'] ) {
$testmode = true;
}
self::$apiKey = $testmode ? $options['test_api_key'] : $options['api_key'];
self::$userName = $testmode ? $options['test_user_name'] : $options['user_name'];
self::$passWord = $testmode ? $options['test_password'] : $options['password'];
self::$api_endpoint = $testmode ? $options['test_api_endpoint'] : $options['api_endpoint'];
self::$url = $testmode ? $options['test_api_endpoint'] : $options['api_endpoint'];
} else {
self::$apiKey = '';
self::$userName = '';
self::$passWord = '';
self::$api_endpoint = '';
}
}
public static function generateAccessToken() {
$url = self::$url . 'EnsembleLogin';
if ( empty( self::$userName ) || empty( self::$passWord ) || empty( self::$apiKey ) ) {
wc_get_logger()->warning(
'Cannot generate access token: credentials are not configured.',
[ 'source' => 'ach-processing' ]
);
return false;
}
$remoteResponse = wp_remote_post( $url, [
'headers' => [ 'Content-Type' => 'application/json; charset=utf-8' ],
'body' => wp_json_encode( [
'Apikey' => self::$apiKey,
'Username' => self::$userName,
'Password' => self::$passWord,
] ),
'method' => 'POST',
'data_format' => 'body',
'timeout' => 20,
] );
if ( is_wp_error( $remoteResponse ) ) {
wc_get_logger()->error(
sprintf( 'Token request failed: %s', $remoteResponse->get_error_message() ),
[ 'source' => 'ach-processing' ]
);
return false;
}
$status = wp_remote_retrieve_response_code( $remoteResponse );
$body = wp_remote_retrieve_body( $remoteResponse );
$data = json_decode( $body, true );
// The sandbox returns camelCase ('authenticationToken'); older
// docs and some environments used PascalCase. Accept either so
// a future API flip doesn't silently break auth again.
$token = $data['authenticationToken'] ?? $data['AuthenticationToken'] ?? '';
$expire = $data['expireDate'] ?? $data['ExpireDate'] ?? '';
if ( ! empty( $token ) ) {
return [
'token' => $token,
'expire' => $expire,
];
}
wc_get_logger()->error(
sprintf(
'Token request HTTP %d but no authenticationToken in response. Body: %s',
$status,
wp_json_encode( $data )
),
[ 'source' => 'ach-processing' ]
);
return false;
}
/**
* Refresh the stored access token from the remote API and persist it.
* Returns the new token string on success, false on failure.
*/
public static function refreshAccessToken() {
self::set_credentials();
$accessToken = self::generateAccessToken();
if ( ! $accessToken ) {
return false;
}
update_option( 'achp_access_token', $accessToken['token'] );
update_option( 'achp_access_token_expiry', strtotime( $accessToken['expire'] ) );
return $accessToken['token'];
}
/**
* Return a non-expired access token, refreshing it from the remote API
* if the stored one is missing or within 60 seconds of expiry.
*/
public static function getAccessToken() {
$token = get_option( 'achp_access_token' );
$expiry = (int) get_option( 'achp_access_token_expiry' );
if ( empty( $token ) || $expiry <= ( time() + 60 ) ) {
$refreshed = self::refreshAccessToken();
return $refreshed ? $refreshed : $token; // fall back to stale token rather than sending no auth
}
return $token;
}
public static function curlPost( $methd, $postedValues = [] ) {
$url = self::$url . $methd;
if ( empty( $postedValues ) ) {
return false;
}
$logger = wc_get_logger();
$ctx = [ 'source' => 'ach-processing' ];
$response = self::doAuthenticatedPost( $url, $postedValues, self::getAccessToken() );
// If the API rejected our token with 401, refresh once and retry.
if ( 401 === $response['status'] ) {
$logger->warning(
sprintf( '%s returned HTTP 401 — refreshing token and retrying.', $methd ),
$ctx
);
$fresh = self::refreshAccessToken();
if ( ! $fresh ) {
$logger->error(
sprintf( '%s retry aborted: token refresh failed.', $methd ),
$ctx
);
} else {
$logger->info(
sprintf( '%s retrying with refreshed token.', $methd ),
$ctx
);
$response = self::doAuthenticatedPost( $url, $postedValues, $fresh );
$logger->info(
sprintf( '%s retry response: HTTP %d', $methd, $response['status'] ),
$ctx
);
}
}
$decoded = self::normalizeResponse( json_decode( $response['body'], true ) );
$responseCode = isset( $decoded['status']['responsecode'] ) ? (string) $decoded['status']['responsecode'] : '(none)';
// Always log the HTTP status + parsed responseCode so we can
// trace payment-flow failures. On non-success, also dump the
// full decoded body to diagnose shape mismatches.
$logger->info(
sprintf( 'HTTP %d %s responseCode=%s', $response['status'], $methd, $responseCode ),
$ctx
);
$isOk = 200 === (int) $response['status']
&& is_array( $decoded )
&& isset( $decoded['status']['responsecode'] )
&& 0 === strcasecmp( (string) $decoded['status']['responsecode'], 'Ok' );
if ( ! $isOk ) {
$logger->warning(
sprintf( '%s non-success body: %s', $methd, wp_json_encode( $decoded ) ),
$ctx
);
}
return $decoded;
}
/**
* The ACH Processing sandbox returns `status.responseCode` in
* camelCase, while older docs used PascalCase. Lowercase the keys
* inside `status` so downstream checks can use one canonical form
* (`responsecode`, `message`, `errorcode`) no matter which case
* variant the API ships.
*/
private static function normalizeResponse( $data ) {
if ( ! is_array( $data ) ) {
return $data;
}
if ( isset( $data['status'] ) && is_array( $data['status'] ) ) {
$data['status'] = array_change_key_case( $data['status'], CASE_LOWER );
}
return $data;
}
private static function doAuthenticatedPost( $url, $postedValues, $token ) {
$remoteResponse = wp_remote_post( $url, [
'headers' => [
'Content-Type' => 'application/json',
'Authorization' => 'Bearer ' . $token,
],
'body' => wp_json_encode( $postedValues ),
'method' => 'POST',
'data_format' => 'body',
'timeout' => 30,
] );
if ( is_wp_error( $remoteResponse ) ) {
wc_get_logger()->error(
sprintf( 'API request failed: %s', $remoteResponse->get_error_message() ),
[ 'source' => 'ach-processing' ]
);
return [ 'status' => 0, 'body' => '' ];
}
return [
'status' => (int) wp_remote_retrieve_response_code( $remoteResponse ),
'body' => wp_remote_retrieve_body( $remoteResponse ),
];
}
public static function curlStatusPost( $transactionIds ) {
$default_url = self::$checkStatusUrl;
$options = get_option( 'woocommerce_ach_processing_settings' );
$testmode = isset( $options['testmode'] ) && 'yes' === $options['testmode'];
// Same test/live switch as set_credentials() uses for the
// API key/username/password/api_endpoint pairs — this endpoint
// needs to follow Test Mode exactly like those do, since it's
// just as environment-specific. Previously this was a single
// field regardless of Test Mode, which meant flipping Test Mode
// could leave status checks pointed at the wrong environment's
// endpoint (a live token being sent to a sandbox URL, or vice
// versa) even though the credentials themselves switched
// correctly.
$configured_url = $testmode
? ( $options['test_status_check_api_endpoint'] ?? '' )
: ( $options['status_check_api_endpoint'] ?? '' );
$using_default = empty( $configured_url );
$url = $using_default ? $default_url : $configured_url;
$logger = wc_get_logger();
$ctx = [ 'source' => 'ach-processing' ];
// Make it unambiguous from the log alone which mode, which
// setting, and which URL a poll run actually used — a
// misconfigured (empty, or wrong-environment) setting should be
// loud, not discovered later by cross-referencing settings
// screenshots against log timestamps.
$logger->info(
sprintf(
'Status-check endpoint (Test Mode: %s): %s [%s]',
$testmode ? 'yes' : 'no',
$url,
$using_default
? ( $testmode ? 'default sandbox — test_status_check_api_endpoint is not set' : 'default sandbox — status_check_api_endpoint is not set' )
: 'from configured setting'
),
$ctx
);
// The PollAchPayments endpoint requires the same bearer token as
// every other Ensemble call — the API server logs anonymous calls
// as "IdP2 Authenticate OnChallenge" errors and returns no useful
// body, which downstream looks like "rows came back but nothing
// matched". Refresh + retry once on 401 mirrors curlPost's
// doAuthenticatedPost pattern.
$request = static function ( $bearer ) use ( $url, $transactionIds ) {
return wp_remote_post( $url, [
'headers' => [
'Content-Type' => 'application/json',
'Authorization' => 'Bearer ' . $bearer,
],
'body' => wp_json_encode( $transactionIds ),
'method' => 'POST',
'data_format' => 'body',
'timeout' => 30,
] );
};
// A batch is retried up to MAX_ATTEMPTS times total for anything
// that looks transient — a timeout, a dropped connection, or the
// processor's own server briefly struggling (429/500/502/503/504).
// A 401 is handled separately below (refresh token, not a network
// retry) and doesn't count against this budget, since it isn't
// the kind of failure a delay-and-retry actually helps with.
$max_attempts = 3;
$backoff_seconds = [ 2, 5 ]; // Delay before attempt 2, then attempt 3.
$token = self::getAccessToken();
$attempt = 0;
$response = null;
$status = 0;
while ( true ) {
$attempt++;
$response = $request( $token );
$status = is_wp_error( $response ) ? 0 : (int) wp_remote_retrieve_response_code( $response );
if ( 401 === $status ) {
$logger->warning( 'PollAchPayments returned HTTP 401 — refreshing token and retrying.', $ctx );
$fresh = self::refreshAccessToken();
if ( $fresh ) {
$token = $fresh;
$response = $request( $token );
$status = is_wp_error( $response ) ? 0 : (int) wp_remote_retrieve_response_code( $response );
}
}
$is_transient = is_wp_error( $response )
|| in_array( $status, [ 429, 500, 502, 503, 504 ], true );
if ( ! $is_transient || $attempt >= $max_attempts ) {
break;
}
$delay = $backoff_seconds[ $attempt - 1 ] ?? end( $backoff_seconds );
$logger->warning(
sprintf(
'PollAchPayments attempt %d/%d %s — retrying in %ds.',
$attempt,
$max_attempts,
is_wp_error( $response ) ? $response->get_error_message() : "returned HTTP {$status}",
$delay
),
$ctx
);
sleep( $delay );
}
if ( is_wp_error( $response ) ) {
$logger->error(
sprintf(
'PollAchPayments request failed after %d attempt(s): %s',
$attempt,
$response->get_error_message()
),
$ctx
);
return null;
}
$body = wp_remote_retrieve_body( $response );
$logger->info(
sprintf( 'PollAchPayments HTTP %d, body bytes=%d, attempts=%d', $status, strlen( $body ), $attempt ),
$ctx
);
return json_decode( $body, true );
}
}
new ACHP_Main_Class();
}