Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .monorepo-source.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
79 changes: 79 additions & 0 deletions build/blik-reconciliation.test.mjs
Original file line number Diff line number Diff line change
@@ -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'));
});
2 changes: 1 addition & 1 deletion functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
177 changes: 142 additions & 35 deletions inc/checkout-context.php
Original file line number Diff line number Diff line change
Expand Up @@ -734,43 +734,88 @@ 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' ) ) ) {
throw new \RuntimeException( 'The BLIK order is not in a status that allows payment completion.' );
}

$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' ) ),
Expand Down Expand Up @@ -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',
Expand All @@ -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 ) ) {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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() ) {
Expand All @@ -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(
Expand All @@ -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' ),
Expand Down Expand Up @@ -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',
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading
Loading