Fix Stripe authorization lifecycle timing
This commit is contained in:
1 parent
21038d68ca
commit
61ea299b0e
5 files changed
+157
-19
No files matched your search
@@ -219,10 +219,12 @@ card is actually charged for the first time here) before moving to
|
|||||||
no sellable offer, margin no longer holds, fraud check fails, or the
|
no sellable offer, margin no longer holds, fraud check fails, or the
|
||||||
supplier submission itself fails — lands the order in `bsc-exception`
|
supplier submission itself fails — lands the order in `bsc-exception`
|
||||||
("Needs Review"), which is the review queue: just a filterable
|
("Needs Review"), which is the review queue: just a filterable
|
||||||
WooCommerce order status, not a separate table. Because nothing is
|
WooCommerce order status, not a separate table. Because nothing is charged
|
||||||
charged until capture, most failures are a **void** (`AuditTrail::record_void()`,
|
until capture, most failures request a zero-cost authorization release rather
|
||||||
zero cost) rather than a refund — a refund only happens for the rarer
|
than a refund. They remain in **Needs Review** until the Stripe order note or
|
||||||
case of something going wrong *after* a real capture.
|
dashboard confirms release; only then should `AuditTrail::record_void()` be
|
||||||
|
recorded. A refund is reserved for the rarer case of something going wrong
|
||||||
|
*after* a real capture.
|
||||||
|
|
||||||
`wp bookstore force-offer-status <isbn13> <active|stale|discontinued>` (see
|
`wp bookstore force-offer-status <isbn13> <active|stale|discontinued>` (see
|
||||||
`wp bookstore help`) forces a specific ISBN's synthetic offer into a
|
`wp bookstore help`) forces a specific ISBN's synthetic offer into a
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ defined('ABSPATH') || exit;
|
|||||||
/**
|
/**
|
||||||
* bsc_order_audit: one row per order line, written once at checkout
|
* bsc_order_audit: one row per order line, written once at checkout
|
||||||
* (CheckoutRecheck::run_checks()) and updated once — either on refund
|
* (CheckoutRecheck::run_checks()) and updated once — either on refund
|
||||||
* (record_refund(), a post-capture reversal) or on void (record_void(),
|
* (record_refund(), a post-capture reversal) or after a confirmed void
|
||||||
* a pre-capture supplier-order failure — the far more common case under
|
* (record_void()). Requesting cancellation alone is not confirmation: the
|
||||||
* the preauth-then-capture flow). Style mirrors Catalog\SupplierOffer
|
* Stripe status-change hook exposes no success result, so callers must
|
||||||
* exactly.
|
* reconcile the order note/dashboard before recording a void. Style mirrors
|
||||||
|
* Catalog\SupplierOffer exactly.
|
||||||
*/
|
*/
|
||||||
class AuditTrail
|
class AuditTrail
|
||||||
{
|
{
|
||||||
@@ -68,7 +69,7 @@ class AuditTrail
|
|||||||
* against the expectation this row already captured at checkout time.
|
* against the expectation this row already captured at checkout time.
|
||||||
* Only reachable now for a refund *after* a real capture (e.g. a
|
* Only reachable now for a refund *after* a real capture (e.g. a
|
||||||
* manual admin refund post-fulfillment) — a failed supplier order
|
* manual admin refund post-fulfillment) — a failed supplier order
|
||||||
* before capture goes through record_void() instead, below.
|
* before capture uses record_void() only after Stripe confirms release.
|
||||||
*/
|
*/
|
||||||
public static function record_refund(int $order_id): void
|
public static function record_refund(int $order_id): void
|
||||||
{
|
{
|
||||||
@@ -83,10 +84,9 @@ class AuditTrail
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Preauth-then-capture rework: a failed supplier order before capture
|
* A confirmed pre-capture authorization release is genuinely zero cost,
|
||||||
* reverses the hold (Order\SupplierOrderJob::fail_and_release())
|
* unlike the processing-fee loss record_refund() models. Do not call this
|
||||||
* rather than refunding a charge that was never made — genuinely
|
* merely because cancellation was requested.
|
||||||
* zero cost, not the processing-fee loss record_refund() models.
|
|
||||||
*/
|
*/
|
||||||
public static function record_void(int $order_id): void
|
public static function record_void(int $order_id): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -28,17 +28,20 @@ class SupplierOrderJob
|
|||||||
{
|
{
|
||||||
public static function register(): void
|
public static function register(): void
|
||||||
{
|
{
|
||||||
add_action('woocommerce_order_status_on-hold', [self::class, 'accept_stripe_authorization']);
|
// Stripe fires this only after it has persisted the transaction ID,
|
||||||
|
// captured flag, and On hold status. The generic On hold transition
|
||||||
|
// fires too early inside Stripe's process_response() and can race the
|
||||||
|
// save of `_stripe_charge_captured=no`.
|
||||||
|
add_action('wc_gateway_stripe_process_response', [self::class, 'accept_stripe_authorization'], 10, 2);
|
||||||
add_action('woocommerce_order_status_' . OrderStatuses::AUTHORIZED, [self::class, 'schedule_for_order']);
|
add_action('woocommerce_order_status_' . OrderStatuses::AUTHORIZED, [self::class, 'schedule_for_order']);
|
||||||
add_action('bsc_submit_supplier_order', [self::class, 'run']);
|
add_action('bsc_submit_supplier_order', [self::class, 'run']);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Promote only manually authorized orders from the official Stripe gateway. */
|
/** Promote only manually authorized orders from the official Stripe gateway. */
|
||||||
public static function accept_stripe_authorization(int $order_id): void
|
public static function accept_stripe_authorization(object $response, \WC_Order $order): void
|
||||||
{
|
{
|
||||||
$order = wc_get_order($order_id);
|
|
||||||
if (
|
if (
|
||||||
!$order
|
!$order->has_status('on-hold')
|
||||||
|| !self::is_stripe_order($order)
|
|| !self::is_stripe_order($order)
|
||||||
|| $order->get_meta('_stripe_charge_captured') !== 'no'
|
|| $order->get_meta('_stripe_charge_captured') !== 'no'
|
||||||
) {
|
) {
|
||||||
@@ -160,9 +163,17 @@ class SupplierOrderJob
|
|||||||
*/
|
*/
|
||||||
private static function fail_and_release(\WC_Order $order, string $reason): void
|
private static function fail_and_release(\WC_Order $order, string $reason): void
|
||||||
{
|
{
|
||||||
|
$order->update_meta_data('_bsc_stripe_release_requested', 'yes');
|
||||||
|
$order->save();
|
||||||
$order->update_status('cancelled', $reason . ' Releasing the Stripe authorization.');
|
$order->update_status('cancelled', $reason . ' Releasing the Stripe authorization.');
|
||||||
AuditTrail::record_void($order->get_id());
|
// Stripe's cancelled-status handler performs the API call but exposes
|
||||||
$order->update_status(OrderStatuses::EXCEPTION, 'Stripe authorization release requested; order requires review.');
|
// no success result to third-party callbacks. Do not record a
|
||||||
|
// confirmed zero-cost void here; a failed API call must remain visible
|
||||||
|
// for reconciliation instead of being reported as successful.
|
||||||
|
$order->update_status(
|
||||||
|
OrderStatuses::EXCEPTION,
|
||||||
|
'Stripe authorization release requested; verify the Stripe order note or dashboard before closing review.'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function is_stripe_order(\WC_Order $order): bool
|
private static function is_stripe_order(\WC_Order $order): bool
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Bookstore\Core\Tests\Unit\Order;
|
||||||
|
|
||||||
|
use Bookstore\Core\Order\OrderStatuses;
|
||||||
|
use Bookstore\Core\Order\SupplierOrderJob;
|
||||||
|
use Bookstore\Core\Tests\TestCase;
|
||||||
|
use Brain\Monkey\Actions;
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
use Mockery;
|
||||||
|
|
||||||
|
class SupplierOrderJobTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_registers_after_stripe_has_saved_its_response(): void
|
||||||
|
{
|
||||||
|
Actions\expectAdded('wc_gateway_stripe_process_response')
|
||||||
|
->with([SupplierOrderJob::class, 'accept_stripe_authorization'], 10, 2)
|
||||||
|
->once();
|
||||||
|
Actions\expectAdded('woocommerce_order_status_' . OrderStatuses::AUTHORIZED)->once();
|
||||||
|
Actions\expectAdded('bsc_submit_supplier_order')->once();
|
||||||
|
|
||||||
|
SupplierOrderJob::register();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_promotes_a_saved_uncaptured_stripe_authorization(): void
|
||||||
|
{
|
||||||
|
Functions\expect('apply_filters')->once()->andReturn([
|
||||||
|
'pass' => true,
|
||||||
|
'reason' => 'test pass',
|
||||||
|
]);
|
||||||
|
$order = Mockery::mock(\WC_Order::class);
|
||||||
|
$order->shouldReceive('has_status')->with('on-hold')->once()->andReturn(true);
|
||||||
|
$order->shouldReceive('get_payment_method')->once()->andReturn('stripe');
|
||||||
|
$order->shouldReceive('get_meta')->with('_stripe_charge_captured')->once()->andReturn('no');
|
||||||
|
$order->shouldReceive('add_order_note')->once();
|
||||||
|
$order->shouldReceive('update_status')
|
||||||
|
->with(OrderStatuses::AUTHORIZED, Mockery::type('string'))
|
||||||
|
->once();
|
||||||
|
|
||||||
|
SupplierOrderJob::accept_stripe_authorization((object) ['captured' => false], $order);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_ignores_an_on_hold_stripe_order_without_persisted_uncaptured_state(): void
|
||||||
|
{
|
||||||
|
$order = Mockery::mock(\WC_Order::class);
|
||||||
|
$order->shouldReceive('has_status')->with('on-hold')->once()->andReturn(true);
|
||||||
|
$order->shouldReceive('get_payment_method')->once()->andReturn('stripe');
|
||||||
|
$order->shouldReceive('get_meta')->with('_stripe_charge_captured')->once()->andReturn('');
|
||||||
|
$order->shouldNotReceive('update_status');
|
||||||
|
|
||||||
|
SupplierOrderJob::accept_stripe_authorization((object) [], $order);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_ignores_non_authorization_stripe_responses(): void
|
||||||
|
{
|
||||||
|
$order = Mockery::mock(\WC_Order::class);
|
||||||
|
$order->shouldReceive('has_status')->with('on-hold')->once()->andReturn(false);
|
||||||
|
$order->shouldNotReceive('get_payment_method');
|
||||||
|
$order->shouldNotReceive('get_meta');
|
||||||
|
$order->shouldNotReceive('update_status');
|
||||||
|
|
||||||
|
SupplierOrderJob::accept_stripe_authorization((object) ['captured' => true], $order);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_capture_success_requires_stripes_persisted_confirmation(): void
|
||||||
|
{
|
||||||
|
$order = Mockery::mock(\WC_Order::class);
|
||||||
|
$order->shouldReceive('update_status')->with('processing', Mockery::type('string'))->once();
|
||||||
|
$order->shouldReceive('get_id')->once()->andReturn(42);
|
||||||
|
|
||||||
|
$reloaded = Mockery::mock(\WC_Order::class);
|
||||||
|
$reloaded->shouldReceive('get_meta')->with('_stripe_charge_captured')->once()->andReturn('yes');
|
||||||
|
$reloaded->shouldReceive('update_status')
|
||||||
|
->with(OrderStatuses::SUPPLIER_ORDERED, Mockery::type('string'))
|
||||||
|
->once();
|
||||||
|
Functions\expect('wc_get_order')->with(42)->once()->andReturn($reloaded);
|
||||||
|
|
||||||
|
$this->invokePrivate('capture_and_complete', $order);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_capture_without_stripe_confirmation_routes_to_review(): void
|
||||||
|
{
|
||||||
|
$order = Mockery::mock(\WC_Order::class);
|
||||||
|
$order->shouldReceive('update_status')->with('processing', Mockery::type('string'))->once();
|
||||||
|
$order->shouldReceive('get_id')->once()->andReturn(43);
|
||||||
|
|
||||||
|
$reloaded = Mockery::mock(\WC_Order::class);
|
||||||
|
$reloaded->shouldReceive('get_meta')->with('_stripe_charge_captured')->once()->andReturn('no');
|
||||||
|
$reloaded->shouldReceive('update_status')
|
||||||
|
->with(OrderStatuses::EXCEPTION, Mockery::type('string'))
|
||||||
|
->once();
|
||||||
|
Functions\expect('wc_get_order')->with(43)->once()->andReturn($reloaded);
|
||||||
|
|
||||||
|
$this->invokePrivate('capture_and_complete', $order);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_release_is_recorded_as_requested_not_as_a_confirmed_void(): void
|
||||||
|
{
|
||||||
|
$order = Mockery::mock(\WC_Order::class);
|
||||||
|
$order->shouldReceive('update_meta_data')
|
||||||
|
->with('_bsc_stripe_release_requested', 'yes')
|
||||||
|
->once();
|
||||||
|
$order->shouldReceive('save')->once();
|
||||||
|
$order->shouldReceive('update_status')->with('cancelled', Mockery::type('string'))->once();
|
||||||
|
$order->shouldReceive('update_status')
|
||||||
|
->with(OrderStatuses::EXCEPTION, Mockery::on(
|
||||||
|
static fn (string $note): bool => str_contains($note, 'verify')
|
||||||
|
))
|
||||||
|
->once();
|
||||||
|
|
||||||
|
$this->invokePrivate('fail_and_release', $order, 'Unavailable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function invokePrivate(string $method, mixed ...$arguments): mixed
|
||||||
|
{
|
||||||
|
$reflection = new \ReflectionMethod(SupplierOrderJob::class, $method);
|
||||||
|
return $reflection->invoke(null, ...$arguments);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,12 @@ if (!class_exists(\WC_Cart::class)) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!class_exists(\WC_Order::class)) {
|
||||||
|
class WC_Order
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!class_exists(\WP_Error::class)) {
|
if (!class_exists(\WP_Error::class)) {
|
||||||
class WP_Error
|
class WP_Error
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in new issue
Block a user