feat: add WP-Cron reconciliation job for pending payments#2
feat: add WP-Cron reconciliation job for pending payments#2feelautom wants to merge 4 commits intowearestancer:mainfrom
Conversation
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.
sderen-stancer
left a comment
There was a problem hiding this comment.
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'; |
There was a problem hiding this comment.
with the vendor/autoload.php requirement we don't need to require invidual classes, composer autoloader handle that for us.
includes/class-stancer-cron.php
Outdated
| foreach ( $rows as $row ) { | ||
| $this->process_row( $row, $logger ); | ||
| } |
There was a problem hiding this comment.
We need to be sure that we loaded our Config object properly, before trying to call our API
| 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' ] | |
| ); | |
| } |
| 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; | ||
| } |
There was a problem hiding this comment.
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.
includes/class-stancer-cron.php
Outdated
|
|
||
| // 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 |
There was a problem hiding this comment.
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.
includes/class-stancer-cron.php
Outdated
| : (string) $api_status_raw; | ||
|
|
||
| // Still pending on the API side — wait for next run. | ||
| if ( 'pending' === $api_status ) { |
There was a problem hiding this comment.
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
includes/class-stancer-cron.php
Outdated
| $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 | ||
| ) | ||
| ); |
There was a problem hiding this comment.
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)
| $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.
includes/class-stancer-cron.php
Outdated
| * | ||
| * Schedule / unschedule are called on plugin activation / deactivation. | ||
| * | ||
| * @since 1.4.0 |
There was a problem hiding this comment.
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
| * @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
sderen-stancer
left a comment
There was a problem hiding this comment.
Thank you for your change, We are integrating your PR in our following release, We will inform you as soon as it is released.
|
Bonjour Sébastien,
Merci pour l'intégration, c'est avec grand plaisir d'avoir pu contribuer.
Je me demandais pourquoi vous n'intégriez pas un système de webhook pour les retours de paiement.
Est-ce un problème d'ordre technique ou plutôt un choix délibéré ?
Je me suis aussi permis de publier une librairie Node.js qui répondait plus à mes besoins.
N'hésitez pas à me remonter d'éventuelles remarques si vous prenez le temps de l'analyser, ce serait avec grand plaisir de l'améliorer selon voscritères.
https://www.npmjs.com/package/stancer-node
Cordialement,
Franck
…On Wed, Feb 25, 2026 at 10:52 AM sderen-stancer ***@***.***> wrote:
***@***.**** approved this pull request.
Thank you for your change, We are integrating your PR in our following
release, We will inform you as soon as it is released.
—
Reply to this email directly, view it on GitHub
<#2 (review)>,
or unsubscribe
<https://github.com/notifications/unsubscribe-auth/B5RVWIWUZ4K5GZBTM2S7KT34NVWFHAVCNFSM6AAAAACV3JCCD6VHI2DSMVQWIX3LMV43YUDVNRWFEZLROVSXG5CSMV3GSZLXHMZTQNJTGEYDANZZGI>
.
You are receiving this because you authored the thread.Message ID:
***@***.***>
|
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 inpendingstatus 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.phpClass
WC_Stancer_Cronwith:add_schedule()— registers a customstancer_fifteen_minutescron interval (15 min) via thecron_schedulesfilterschedule()/unschedule()— called on plugin activation / deactivation to register or clear the scheduled eventreconcile()— querieswc_stancer_paymentfor rows withstatus = pendinganddatetime_created <= NOW() - 15 min, then callsprocess_row()for eachprocess_row()— fetches the real payment status from the Stancer API, updates the local record viamark_as(), and updates the WooCommerce order:to_capture,capture,capture_sent,captured→$order->payment_complete()refused,failed,canceled,expired→$order->update_status('failed')Status values from the API are normalized to
stringimmediately 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-cronsource.Modified:
includes/class-stancer.phpload_actions(): registers the cron callback viaadd_action( WC_Stancer_Cron::HOOK, ... )load_filters(): registers the custom schedule viaadd_filter( 'cron_schedules', ... )Modified:
stancer.phprequire_onceofclass-stancer-cron.php(needed before activation hook fires, before the composer classmap is active)register_activation_hook→WC_Stancer_Cron::schedule()register_deactivation_hook→WC_Stancer_Cron::unschedule()Notes
payment_complete()andupdate_status()are no-ops on already-finalized orderswc_stancer_paymenttable is already populated on payment creation, so no schema changes are required__()for i18n