diff --git a/.monorepo-source.json b/.monorepo-source.json index 5797e22..411f7f3 100644 --- a/.monorepo-source.json +++ b/.monorepo-source.json @@ -1,7 +1,7 @@ { "productId": "funkycommerce-headless", "repository": "coded-letter/superfunky-theme", - "source": "coded-letter/coded-letter-monorepo@1b9eab75b17cfb62a043955b858ca846d00ac745", + "source": "coded-letter/coded-letter-monorepo@186e3b2f333d2e6d20186df68628cb32b4bead1e", "sourcePath": "workspace/backend/wordpress/themes/free/funkycommerce-headless", "installSlug": "funkycommerce-headless", "kind": "theme", diff --git a/build/blik-reconciliation.test.mjs b/build/blik-reconciliation.test.mjs new file mode 100644 index 0000000..36a8ed6 --- /dev/null +++ b/build/blik-reconciliation.test.mjs @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; +import { Engine } from 'php-parser'; + +const checkoutContext = await readFile( + new URL('../inc/checkout-context.php', import.meta.url), + 'utf8', +); +const fallback = checkoutContext.match( + /function funkycommerce_process_verified_blik_charge\([\s\S]*?\n}\n/, +)?.[0]; + +test('BLIK fallback does not let a Woo Stripe payment lock block verified completion', () => { + assert.ok(fallback, 'BLIK charge fallback is present'); + assert.doesNotMatch(fallback, /get_order_existing_payment_lock|is_order_payment_locked/); + assert.match(fallback, /'blik' !== \( \$charge->payment_method_details->type \?\? '' \)/); +}); + +test('BLIK fallback accepts Stripe charge identifiers without assuming a ch_ prefix', () => { + assert.ok(fallback, 'BLIK charge fallback is present'); + assert.match(fallback, /\$charge_id\s*=\s*is_object\(\s*\$charge\s*\)\s*\?\s*\(string\)\s*\( \$charge->id \?\? '' \)/); + assert.match(fallback, /'' === \$charge_id/); + assert.match(fallback, /'charge' !== \( \$charge->object \?\? '' \)/); + assert.match(fallback, /'succeeded' !== \( \$charge->status \?\? '' \)/); + assert.match(fallback, /true !== \( \$charge->captured \?\? false \)/); + assert.match(fallback, /true !== \( \$charge->paid \?\? false \)/); + assert.match(fallback, /'succeeded' !== \( \$intent->status \?\? '' \)/); + assert.match(fallback, /\$charge_amount !== \$intent_amount/); + assert.match(fallback, /\$captured_amount !== \$intent_amount/); + assert.match(fallback, /strtolower\( \(string\) \( \$charge->currency \?\? '' \) \) !== strtolower\( \(string\) \( \$intent->currency \?\? '' \) \)/); + assert.match(fallback, /'manual_review' === \( \$charge->outcome->type \?\? '' \)/); + assert.match(fallback, /hash_equals\(\s*\(string\) \( \$intent->id \?\? '' \),\s*\(string\) \( \$charge->payment_intent \?\? '' \)\s*\)/); + assert.doesNotMatch(fallback, /ch_/); +}); + +test('BLIK fallback completes verified captured charges when Stripe gateway handler no-ops', () => { + assert.ok(fallback, 'BLIK charge fallback is present'); + assert.match(fallback, /\$gateway->process_response\(\s*\$charge,\s*\$order\s*\)/); + assert.match(fallback, /catch \( \\Throwable \$error \)/); + assert.match(fallback, /WC_Stripe_Helper::get_order_by_charge_id/); + assert.match(fallback, /catch \( \\Throwable \$sync_error \)/); + assert.match(fallback, /\$order->payment_complete\(\s*\$charge_id\s*\)/); + assert.match(fallback, /\$order->is_paid\(\)/); +}); + +test('BLIK reconciliation reuses WooCommerce Stripe native webhook processing', () => { + assert.match(checkoutContext, /WC_Stripe_Webhook_Handler/); + assert.match(checkoutContext, /process_payment_intent/); + assert.match(checkoutContext, /WC_Stripe_API::retrieve/); + assert.doesNotMatch(checkoutContext, /FUNKYCOMMERCE_STRIPE_BLIK_WEBHOOK_SECRET|\/stripe\/blik-webhook/); +}); + +test('new BLIK retries use Stripe webhooks instead of Action Scheduler', () => { + assert.doesNotMatch(checkoutContext, /as_schedule_single_action|wp_schedule_single_event|funkycommerce_schedule_blik_reconciliation_retry/); + assert.match(checkoutContext, /function funkycommerce_reconcile_blik_order_retry/); + assert.match(checkoutContext, /add_action\(\s*'funkycommerce_reconcile_blik_order_retry'/); + assert.match(checkoutContext, /\$attempt > 10/); + assert.match(checkoutContext, /in_array\( \$intent_status, array\( 'succeeded', 'processing' \), true \)/); + const legacyRetry = checkoutContext.match( + /function funkycommerce_reconcile_blik_order_retry\([\s\S]*?\n}\n/, + )?.[0]; + assert.ok(legacyRetry, 'legacy queued retry callback remains available'); + assert.doesNotMatch(legacyRetry, /schedule_single_action|schedule_single_event/); +}); + +test('the legacy BLIK retry callback remains registered for already-queued actions', async () => { + const parser = new Engine({ parser: { suppressErrors: false } }); + const source = await readFile( + new URL('../inc/checkout-context.php', import.meta.url), + 'utf8', + ); + const ast = parser.parseCode(source); + const topLevelFunctions = ast.children + .filter((node) => node.kind === 'function') + .map((node) => node.name?.name); + + assert.ok(topLevelFunctions.includes('funkycommerce_reconcile_blik_order_retry')); +}); diff --git a/functions.php b/functions.php index 3594a4b..36d2b87 100644 --- a/functions.php +++ b/functions.php @@ -9,7 +9,7 @@ exit; } -define( 'FUNKYCOMMERCE_HEADLESS_VERSION', '1.2.59' ); +define( 'FUNKYCOMMERCE_HEADLESS_VERSION', '1.2.65' ); /** * Whether Superfunky Pro is active and licensed. diff --git a/inc/checkout-context.php b/inc/checkout-context.php index 25de21a..f825235 100644 --- a/inc/checkout-context.php +++ b/inc/checkout-context.php @@ -734,15 +734,29 @@ function funkycommerce_checkout_order_from_credentials( $order_id, $order_key, $ * Complete a verified BLIK charge through Woo Stripe's native gateway response handler. */ function funkycommerce_process_verified_blik_charge( $order, $intent ) { - $charge = $intent->latest_charge ?? null; + $charge = $intent->latest_charge ?? null; + $charge_id = is_object( $charge ) ? (string) ( $charge->id ?? '' ) : ''; + $intent_amount = (int) ( $intent->amount ?? -1 ); + $charge_amount = is_object( $charge ) ? (int) ( $charge->amount ?? -1 ) : -1; + $captured_amount = is_object( $charge ) ? (int) ( $charge->amount_captured ?? -1 ) : -1; if ( ! is_object( $charge ) || 'charge' !== ( $charge->object ?? '' ) || 'succeeded' !== ( $charge->status ?? '' ) - || 0 !== strpos( (string) ( $charge->id ?? '' ), 'ch_' ) + || 'blik' !== ( $charge->payment_method_details->type ?? '' ) + || '' === $charge_id || ! hash_equals( (string) ( $intent->id ?? '' ), (string) ( $charge->payment_intent ?? '' ) ) + || 'succeeded' !== ( $intent->status ?? '' ) + || true !== ( $charge->captured ?? false ) + || true !== ( $charge->paid ?? false ) + || $intent_amount < 0 + || $charge_amount !== $intent_amount + || $captured_amount !== $intent_amount + || strtolower( (string) ( $charge->currency ?? '' ) ) !== strtolower( (string) ( $intent->currency ?? '' ) ) + || ! empty( $charge->review ) + || 'manual_review' === ( $charge->outcome->type ?? '' ) ) { - throw new \RuntimeException( 'Stripe did not return a succeeded charge for the verified BLIK intent.' ); + throw new \RuntimeException( 'Stripe did not return a captured, paid BLIK charge eligible for automatic completion.' ); } if ( ! $order->has_status( array( 'pending', 'failed', 'on-hold' ) ) ) { @@ -750,27 +764,58 @@ function funkycommerce_process_verified_blik_charge( $order, $intent ) { } $order_helper = \WC_Stripe_Order_Helper::get_instance(); - if ( - method_exists( $order_helper, 'is_order_payment_locked' ) - && $order_helper->is_order_payment_locked( $order ) - ) { - throw new \RuntimeException( 'The BLIK order is already locked for Stripe payment processing.' ); - } $gateways = WC()->payment_gateways()->payment_gateways(); $gateway = $gateways['stripe'] ?? null; - if ( ! $gateway instanceof \WC_Payment_Gateway || ! is_callable( array( $gateway, 'process_response' ) ) ) { - throw new \RuntimeException( 'WooCommerce Stripe gateway cannot process the verified BLIK charge.' ); + + if ( class_exists( 'WC_Stripe_Helper' ) && is_callable( array( 'WC_Stripe_Helper', 'get_order_by_charge_id' ) ) ) { + $charge_order = \WC_Stripe_Helper::get_order_by_charge_id( $charge_id ); + if ( $charge_order instanceof \WC_Order && (int) $charge_order->get_id() !== (int) $order->get_id() ) { + throw new \RuntimeException( 'The verified BLIK charge is already linked to a different WooCommerce order.' ); + } } $charge->is_webhook_response = true; - $gateway->process_response( $charge, $order ); + $gateway_error = null; + if ( $gateway instanceof \WC_Payment_Gateway && is_callable( array( $gateway, 'process_response' ) ) ) { + try { + $gateway->process_response( $charge, $order ); + } catch ( \Throwable $error ) { + $gateway_error = $error; + error_log( sprintf( 'WooCommerce Stripe charge handler failed for verified BLIK order %d: %s', $order->get_id(), $error->getMessage() ) ); + } + } else { + $gateway_error = new \RuntimeException( 'WooCommerce Stripe gateway response handler is unavailable.' ); + error_log( sprintf( 'WooCommerce Stripe gateway response handler is unavailable for verified BLIK order %d.', $order->get_id() ) ); + } + + $order = wc_get_order( $order->get_id() ); + if ( ! $order->is_paid() ) { + if ( is_callable( array( $order_helper, 'sync_stripe_charge_captured' ) ) ) { + try { + $order_helper->sync_stripe_charge_captured( $order, $charge ); + } catch ( \Throwable $sync_error ) { + error_log( sprintf( 'WooCommerce Stripe charge metadata sync failed for verified BLIK order %d: %s', $order->get_id(), $sync_error->getMessage() ) ); + } + } + $order->add_order_note( __( 'WooCommerce Stripe did not complete this order in its charge handler. The theme completed it using the verified, captured Stripe BLIK charge.', 'funkycommerce-headless' ) ); + $order->payment_complete( $charge_id ); + $order = wc_get_order( $order->get_id() ); + } + if ( ! $order->is_paid() ) { + throw new \RuntimeException( + sprintf( + 'WooCommerce did not mark the verified, captured BLIK charge as paid.%s', + $gateway_error ? ' Stripe gateway error: ' . $gateway_error->getMessage() : '' + ) + ); + } } /** - * Reconcile a BLIK intent through Woo Stripe's own verified webhook processing path. + * Reconcile an intent retrieved directly from Stripe using WooCommerce Stripe's native webhook handler. */ -function funkycommerce_reconcile_blik_order( \WP_REST_Request $request ) { +function funkycommerce_reconcile_blik_order( \WP_REST_Request $request, $retry_attempt = 0, $expected_intent_id = '' ) { $order = funkycommerce_checkout_order_from_credentials( $request['id'], sanitize_text_field( (string) $request->get_param( 'key' ) ), @@ -802,7 +847,6 @@ function funkycommerce_reconcile_blik_order( \WP_REST_Request $request ) { if ( ! class_exists( 'WC_Stripe_API' ) || ! class_exists( 'WC_Stripe_Order_Helper' ) - || ! class_exists( 'WC_Stripe_Webhook_Handler' ) ) { return new \WP_Error( 'funkycommerce_stripe_unavailable', @@ -822,6 +866,16 @@ function funkycommerce_reconcile_blik_order( \WP_REST_Request $request ) { array( 'status' => 409 ) ); } + if ( '' !== $expected_intent_id && ! hash_equals( (string) $expected_intent_id, $intent_id ) ) { + error_log( sprintf( 'FunkyCommerce skipped a stale BLIK reconciliation retry for order %d.', $order->get_id() ) ); + return rest_ensure_response( + array( + 'payment_status' => 'pending', + 'intent_status' => 'unknown', + 'order_status' => $order->get_status(), + ) + ); + } $intent = \WC_Stripe_API::retrieve( 'payment_intents/' . rawurlencode( $intent_id ) . '?expand[]=latest_charge' ); if ( is_wp_error( $intent ) || ! is_object( $intent ) || ! empty( $intent->error ) ) { @@ -856,17 +910,21 @@ function funkycommerce_reconcile_blik_order( \WP_REST_Request $request ) { } if ( $event_type ) { - $notification = (object) array( - 'type' => $event_type, - 'data' => (object) array( 'object' => $intent ), - ); - $handler = new \WC_Stripe_Webhook_Handler(); $handler_error = null; $deferred = false; - try { - $deferred = true === $handler->process_payment_intent( $notification ); - } catch ( \Throwable $error ) { - $handler_error = $error; + if ( class_exists( 'WC_Stripe_Webhook_Handler' ) ) { + $notification = (object) array( + 'type' => $event_type, + 'data' => (object) array( 'object' => $intent ), + ); + $handler = new \WC_Stripe_Webhook_Handler(); + try { + $deferred = true === $handler->process_payment_intent( $notification ); + } catch ( \Throwable $error ) { + $handler_error = $error; + } + } else { + $handler_error = new \RuntimeException( 'WooCommerce Stripe webhook handler is unavailable.' ); } $order = wc_get_order( $order->get_id() ); // A deferred webhook result is not proof that a succeeded intent completed the order. @@ -897,17 +955,6 @@ function funkycommerce_reconcile_blik_order( \WP_REST_Request $request ) { } } catch ( \Throwable $error ) { error_log( sprintf( 'FunkyCommerce BLIK reconciliation failed for order %d: %s', $order->get_id(), $error->getMessage() ) ); - funkycommerce_emit_notification( - 'theme.crypto_payment_failed', - __( 'BLIK order reconciliation needs attention', 'funkycommerce-headless' ), - __( 'WooCommerce could not finish updating this BLIK order from Stripe payment status. Review Stripe and the order before taking further payment action.', 'funkycommerce-headless' ), - array( - __( 'Order ID', 'funkycommerce-headless' ) => $order->get_id(), - __( 'Gateway', 'funkycommerce-headless' ) => 'stripe-blik', - __( 'Intent status', 'funkycommerce-headless' ) => $intent_status, - ), - $order->get_edit_order_url() - ); if ( $intent_matches_order && 'succeeded' === $intent_status ) { $order = wc_get_order( $order->get_id() ); if ( $order->is_paid() ) { @@ -919,6 +966,19 @@ function funkycommerce_reconcile_blik_order( \WP_REST_Request $request ) { ) ); } + if ( 0 === absint( $retry_attempt ) ) { + funkycommerce_emit_notification( + 'theme.crypto_payment_failed', + __( 'BLIK order reconciliation needs attention', 'funkycommerce-headless' ), + __( 'WooCommerce could not finish updating this BLIK order from Stripe payment status. Review Stripe and the order before taking further payment action.', 'funkycommerce-headless' ), + array( + __( 'Order ID', 'funkycommerce-headless' ) => $order->get_id(), + __( 'Gateway', 'funkycommerce-headless' ) => 'stripe-blik', + __( 'Intent status', 'funkycommerce-headless' ) => $intent_status, + ), + $order->get_edit_order_url() + ); + } return rest_ensure_response( array( @@ -929,6 +989,19 @@ function funkycommerce_reconcile_blik_order( \WP_REST_Request $request ) { ) ); } + if ( 0 === absint( $retry_attempt ) ) { + funkycommerce_emit_notification( + 'theme.crypto_payment_failed', + __( 'BLIK order reconciliation needs attention', 'funkycommerce-headless' ), + __( 'WooCommerce could not finish updating this BLIK order from Stripe payment status. Review Stripe and the order before taking further payment action.', 'funkycommerce-headless' ), + array( + __( 'Order ID', 'funkycommerce-headless' ) => $order->get_id(), + __( 'Gateway', 'funkycommerce-headless' ) => 'stripe-blik', + __( 'Intent status', 'funkycommerce-headless' ) => $intent_status, + ), + $order->get_edit_order_url() + ); + } return new \WP_Error( 'funkycommerce_blik_reconciliation_failed', __( 'WooCommerce could not reconcile the verified BLIK payment.', 'funkycommerce-headless' ), @@ -973,6 +1046,40 @@ function funkycommerce_reconcile_blik_order( \WP_REST_Request $request ) { ); } +/** + * Process legacy queued BLIK retries once; new retries are delivered by Stripe webhooks. + */ +function funkycommerce_reconcile_blik_order_retry( $order_id, $intent_id, $attempt ) { + $order_id = absint( $order_id ); + $attempt = absint( $attempt ); + $order = wc_get_order( $order_id ); + if ( + ! $order instanceof \WC_Order + || 'stripe_blik' !== $order->get_payment_method() + || $order->is_paid() + || ! is_string( $intent_id ) + || ( '' !== $intent_id && 0 !== strpos( $intent_id, 'pi_' ) ) + || $attempt < 1 + || $attempt > 10 + ) { + return; + } + + $request = new \WP_REST_Request( 'POST', sprintf( '/funkycommerce/v1/orders/%d/reconcile-blik', $order_id ) ); + $request->set_param( 'id', $order_id ); + $request->set_param( 'key', $order->get_order_key() ); + $request->set_param( 'email', $order->get_billing_email() ); + try { + $result = funkycommerce_reconcile_blik_order( $request, $attempt, $intent_id ); + if ( is_wp_error( $result ) ) { + error_log( sprintf( 'FunkyCommerce BLIK retry %d failed for order %d: %s', $attempt, $order_id, $result->get_error_message() ) ); + } + } catch ( \Throwable $error ) { + error_log( sprintf( 'FunkyCommerce BLIK retry %d failed for order %d: %s', $attempt, $order_id, $error->getMessage() ) ); + } +} +add_action( 'funkycommerce_reconcile_blik_order_retry', 'funkycommerce_reconcile_blik_order_retry', 10, 3 ); + function funkycommerce_register_blik_reconciliation_rest() { register_rest_route( 'funkycommerce/v1', diff --git a/package-lock.json b/package-lock.json index 2e80a7b..7b02725 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "funkycommerce-headless-theme", - "version": "1.2.59", + "version": "1.2.65", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "funkycommerce-headless-theme", - "version": "1.2.59", + "version": "1.2.65", "devDependencies": { "autoprefixer": "^10.4.20", "esbuild": "^0.28.2", diff --git a/package.json b/package.json index 3c1e0b4..9a08668 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "funkycommerce-headless-theme", "private": true, - "version": "1.2.59", + "version": "1.2.65", "description": "Tailwind/build tooling for the FunkyCommerce Headless WordPress theme's native frontend assets (block templates, template parts, loader, and Spotify slot).", "scripts": { "build:css": "tailwindcss -i ./assets/css/theme-source.css -o ./assets/dist/theme.css --minify", diff --git a/readme.txt b/readme.txt index 727ddeb..923a984 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: headless, woocommerce, wpgraphql, full-site-editing Requires at least: 6.7 Tested up to: 6.8 Requires PHP: 7.4 -Stable tag: 1.2.59 +Stable tag: 1.2.65 License: GPL-2.0-or-later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -50,6 +50,36 @@ If Stripe confirms payment but the order still cannot be reconciled automaticall checkout reports the payment as processing, not as a failed payment, and warns the customer not to pay again while the order is reviewed. +Stripe's native WooCommerce webhook remains the event-driven source of truth. Ensure +the webhook configured in WooCommerce Stripe is healthy and includes the payment +events required by that plugin. The theme does not register a second webhook endpoint +or require a second signing secret; its checkout reconciliation also retrieves the +PaymentIntent through WooCommerce Stripe and applies the plugin's native handler. + +== 1.2.65 == + +* Uses WooCommerce Stripe's configured webhook as the only asynchronous event source; no separate theme webhook secret or endpoint is required. +* Removes new Action Scheduler BLIK retries and prevents Stripe payment locks or charge metadata-sync errors from blocking verified WooCommerce completion. + +== 1.2.64 == + +* Completes an order through WooCommerce core when Stripe's charge response handler throws or does not update it, after verifying the charge matches the succeeded PaymentIntent's amount and currency; manual-review and charge-already-used cases remain held. +* Caps server-side BLIK reconciliation at five one-minute retries. + +== 1.2.63 == + +== 1.2.62 == + +* Accepts Stripe's current BLIK charge identifier format while still requiring a succeeded charge tied to the verified order PaymentIntent. + +== 1.2.61 == + +* Retries verified successful BLIK order reconciliation server-side for up to ten minutes so temporary Stripe payment locks or delayed processing no longer depend on the shopper's browser remaining open. + +== 1.2.60 == + +* Fixes the BLIK recovery path to inspect Stripe payment locks through WooCommerce Stripe's public API, allowing verified successful charges to complete instead of failing on a protected-method call. + == 1.2.59 == * Completes Stripe-confirmed BLIK orders that remain pending when WooCommerce Stripe defers webhook processing. diff --git a/style.css b/style.css index 23405c5..aedf2fb 100644 --- a/style.css +++ b/style.css @@ -7,7 +7,7 @@ Description: A minimal, content-first block theme for polished WordPress preview Requires at least: 6.7 Tested up to: 6.8 Requires PHP: 7.4 -Version: 1.2.59 +Version: 1.2.65 Update URI: https://github.com/coded-letter/superfunky-theme License: GPL-2.0-or-later License URI: https://www.gnu.org/licenses/gpl-2.0.html