Skip to content

feat: add WP-Cron reconciliation job for pending payments#2

Open
feelautom wants to merge 4 commits intowearestancer:mainfrom
feelautom:feat/reconciliation-cron
Open

feat: add WP-Cron reconciliation job for pending payments#2
feelautom wants to merge 4 commits intowearestancer:mainfrom
feelautom:feat/reconciliation-cron

Conversation

@feelautom
Copy link
Contributor

Problem

Stancer does not support webhooks. When a customer completes payment on the Stancer hosted page and is redirected back, the receipt_page() handler updates the WooCommerce order status correctly. However, if the redirect never happens (browser closed, network error, session expired), the local payment record stays in pending status indefinitely and the WooCommerce order is never completed — even though the payment was captured by Stancer.

This affects any shop running in redirect (pip) mode and means orders can stay stuck in "Pending payment" forever without any operator intervention.

Solution

Add a WP-Cron job that runs every 15 minutes and polls the Stancer API for all locally-pending payments older than 15 minutes, then updates the WooCommerce order accordingly.

Implementation

New file: includes/class-stancer-cron.php

Class WC_Stancer_Cron with:

  • add_schedule() — registers a custom stancer_fifteen_minutes cron interval (15 min) via the cron_schedules filter
  • schedule() / unschedule() — called on plugin activation / deactivation to register or clear the scheduled event
  • reconcile() — queries wc_stancer_payment for rows with status = pending and datetime_created <= NOW() - 15 min, then calls process_row() for each
  • process_row() — fetches the real payment status from the Stancer API, updates the local record via mark_as(), and updates the WooCommerce order:
    • to_capture, capture, capture_sent, captured$order->payment_complete()
    • refused, failed, canceled, expired$order->update_status('failed')
    • Other statuses → logged at debug level, no action

Status values from the API are normalized to string immediately after retrieval (compatible with both enum return — PHP 8.1+ SDK — and plain string return).

All operations are wrapped in a try/catch that logs errors to the WooCommerce logger under the stancer-cron source.

Modified: includes/class-stancer.php

  • load_actions(): registers the cron callback via add_action( WC_Stancer_Cron::HOOK, ... )
  • load_filters(): registers the custom schedule via add_filter( 'cron_schedules', ... )

Modified: stancer.php

  • require_once of class-stancer-cron.php (needed before activation hook fires, before the composer classmap is active)
  • register_activation_hookWC_Stancer_Cron::schedule()
  • register_deactivation_hookWC_Stancer_Cron::unschedule()

Notes

  • The 15-minute threshold avoids interfering with in-progress redirect flows
  • The job is idempotent: payment_complete() and update_status() are no-ops on already-finalized orders
  • The wc_stancer_payment table is already populated on payment creation, so no schema changes are required
  • All user-facing strings are wrapped in __() for i18n

Stancer does not support webhooks, so payment status changes are not
pushed automatically. This commit adds a WP-Cron task that runs every
15 minutes and polls the Stancer API for payments still recorded as
"pending" locally.

- includes/class-stancer-cron.php: new class with schedule/unschedule
  static helpers, custom 15-minute cron interval, and reconcile() method
  that queries wc_stancer_payment for stale pending rows, fetches their
  real status from the API, and updates the WooCommerce order accordingly
  (payment_complete for captured statuses, update_status('failed') for
  refused/failed/canceled/expired).
- includes/class-stancer.php: register the cron hook in load_actions()
  and the custom schedule in load_filters().
- stancer.php: require the cron class early, register activation and
  deactivation hooks to schedule/unschedule the event.
mark_as() expects a string, and switch cases now use ->value to compare
string-to-string. The normalization handles both enum returns (PHP 8.1+
SDK) and plain string returns for forward/backward compatibility.
Copy link
Contributor

@sderen-stancer sderen-stancer left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hello,
Thank you for the interest you took in our module.
This is greatly appreciated, here are some request for improvement of your MR.
Let us know if you want to make the proposed change,
In either case, we will include your branch in a following release, as this is a great feature.
Sincerely
Sébastien Deren.

stancer.php Outdated
define( 'STANCER_DIRECTORY_PATH', plugin_dir_path( STANCER_FILE ) );

require_once STANCER_DIRECTORY_PATH . '/vendor/autoload.php';
require_once STANCER_DIRECTORY_PATH . 'includes/class-stancer-cron.php';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

with the vendor/autoload.php requirement we don't need to require invidual classes, composer autoloader handle that for us.

Comment on lines +149 to +151
foreach ( $rows as $row ) {
$this->process_row( $row, $logger );
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to be sure that we loaded our Config object properly, before trying to call our API

Suggested change
foreach ( $rows as $row ) {
$this->process_row( $row, $logger );
}
$config = ( new WC_Stancer_Gateway() )->api_config;
if ( $config->is_configured() ) {
foreach ( $rows as $row ) {
$this->process_row( $row, $logger );
}
} else {
$logger->info(
sprintf( 'Stancer cron: Stancer configuartion is not properly set up, please check your setting page.' ),
[ 'source' => 'stancer-cron' ]
);
}

Comment on lines +212 to +274
switch ( $api_status ) {
case Stancer\Payment\Status::TO_CAPTURE->value:
case Stancer\Payment\Status::CAPTURE->value:
case Stancer\Payment\Status::CAPTURE_SENT->value:
case Stancer\Payment\Status::CAPTURED->value:
if ( $order->needs_payment() ) {
$order->payment_complete( $payment_id );
$order->add_order_note(
sprintf(
// translators: "%s": Stancer payment identifier.
__( 'Payment confirmed via Stancer reconciliation (Transaction ID: %s)', 'stancer' ),
$payment_id
)
);
$logger->info(
sprintf(
'Stancer cron: order %d marked complete (payment %s, status %s).',
$order->get_id(),
$payment_id,
$api_status
),
$context
);
}
break;

case Stancer\Payment\Status::REFUSED->value:
case Stancer\Payment\Status::FAILED->value:
case Stancer\Payment\Status::CANCELED->value:
case Stancer\Payment\Status::EXPIRED->value:
if ( ! $order->has_status( [ 'failed', 'cancelled' ] ) ) {
$order->update_status(
'failed',
sprintf(
// translators: "%1$s": Stancer payment status. "%2$s": Stancer payment identifier.
__( 'Payment %1$s via Stancer (Transaction ID: %2$s)', 'stancer' ),
$api_status,
$payment_id
)
);
$logger->info(
sprintf(
'Stancer cron: order %d marked failed (payment %s, status %s).',
$order->get_id(),
$payment_id,
$api_status
),
$context
);
}
break;

default:
$logger->debug(
sprintf(
'Stancer cron: payment %s has status "%s" — no action taken.',
$payment_id,
$api_status
),
$context
);
break;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some statuses aren't set up, like 'Authorized' . In some cases payments can stay authorized and never get Failed or Captured, (after one week at authorized the payment status cannot change anymore), a good way to not clog this job too much , is to limit the age of payments we get to less than 1 week.


// Normalize to string — compatible whether the SDK returns an enum
// (Stancer\Payment\Status, PHP 8.1+) or a plain string.
$api_status = $api_status_raw instanceof Stancer\Payment\Status
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here you take into account the new and the old SDK (and the use of enum) but below you use a switch case that only work with the latest version of our SDK,
FYI we are working on a release with the new SDK, you can just use the Stancer\Payment\Status as an Enum.

: (string) $api_status_raw;

// Still pending on the API side — wait for next run.
if ( 'pending' === $api_status ) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pending is not a status in our API, it is used as a placeholder in our Woocommerce module, the status that I think you wanted to check is "authorized"

here is a list of status that a payment can have:
"refused" "authorized" "expired" "to_capture" "capture_sent" "captured" "disputed" "canceled"
has found in our redoc

Comment on lines +130 to +140
$threshold = gmdate( 'Y-m-d H:i:s', time() - static::THRESHOLD );

// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}wc_stancer_payment
WHERE status = 'pending'
AND datetime_created <= %s",
$threshold
)
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the SQL request could be more precise, as seen below we don't want payment created more than one week ago, to not overload our cronjob, we also want to check if the authorized payment has been captured (or canceled, failed, or any other non transitionary status)

Suggested change
$threshold = gmdate( 'Y-m-d H:i:s', time() - static::THRESHOLD );
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}wc_stancer_payment
WHERE status = 'pending'
AND datetime_created <= %s",
$threshold
)
);
$week_timestamp = 604800;
$minimum_threshold = gmdate( 'Y-m-d H:i:s', time() - static::THRESHOLD );
$maximum_threshold = gmdate( 'Y-m-d H:i:s', time() - $week_timestamp );
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}wc_stancer_payment
WHERE `status` in ('pending','authorized')
AND `datetime_created` <= %s
AND `datetime_created` >= %s",
[
$minimum_threshold,
$maximum_threshold,
]
)
);

we could make the week_timestamp a const, but I don't think it is necessary.

*
* Schedule / unschedule are called on plugin activation / deactivation.
*
* @since 1.4.0
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since all those function and this classes will be in the next release, we have the habit to use the same versionning convention as the CHANGELOG, and write

Suggested change
* @since 1.4.0
* @since Unreleased

- Remove manual require_once (composer autoloader handles it)
- Check API config before calling Stancer API in reconcile()
- Use Stancer\Payment\Status enum directly in switch cases
- Replace non-existent 'pending' API status with 'authorized'
- Limit SQL query to payments less than 1 week old
- Remove FAILED from failure statuses (not in API)
- Change @SInCE 1.4.0 to @SInCE Unreleased
Copy link
Contributor

@sderen-stancer sderen-stancer left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for your change, We are integrating your PR in our following release, We will inform you as soon as it is released.

@feelautom
Copy link
Contributor Author

feelautom commented Feb 25, 2026 via email

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants