diff --git a/README.md b/README.md index 77cbea9..d2eee95 100644 --- a/README.md +++ b/README.md @@ -217,7 +217,7 @@ Prove the refund use case with working platform integrations. | Integration | Description | Status | Maintainer | |-------------|-------------|--------|------------| | [Shopify Refund Guardrail](examples/ecommerce/shopify/) | Complete Shopify app with APort verification | ✅ Active | Community | -| [WooCommerce Plugin](examples/ecommerce/woocommerce/) | WordPress plugin for order/refund verification | 🚧 In Progress | Community | +| [WooCommerce Plugin](examples/ecommerce/woocommerce/) | WordPress plugin for order/refund verification | ✅ Active | Community | | [Stripe Connect Verification](examples/ecommerce/stripe/) | Webhook handler for Stripe Connect payouts | 📋 Planned | Community | ### 🔧 **Developer Experience Tools** diff --git a/examples/ecommerce/woocommerce/README.md b/examples/ecommerce/woocommerce/README.md new file mode 100644 index 0000000..84139f6 --- /dev/null +++ b/examples/ecommerce/woocommerce/README.md @@ -0,0 +1,72 @@ +# APort WooCommerce Guardrail + +WordPress/WooCommerce plugin example that verifies commerce actions with APort before recording order and refund decisions. + +## Features + +- Settings page under **Settings > APort WooCommerce** for API key, base URL, and policy ids. +- Checkout-order verification using `commerce.order.v1` by default. +- Refund verification using `payments.refund.v1` by default. +- Agent id discovery from `X-Agent-ID`, `X-APort-Agent-ID`, or the order meta key `_aport_agent_id`. +- Order notes for allow, deny, and transport-error outcomes. +- Last APort decision, policy, and agent id saved as order metadata. +- Authenticated REST endpoint for preflight refund checks: `POST /wp-json/aport-woocommerce/v1/verify-refund`. +- PHPUnit coverage for agent-id extraction and verification context builders. + +## Install + +1. Copy `examples/ecommerce/woocommerce` into `wp-content/plugins/aport-woocommerce`. +2. Activate **APort WooCommerce Guardrail** in WordPress. +3. Open **Settings > APort WooCommerce**. +4. Add your APort API key and confirm policy ids: + - Orders: `commerce.order.v1` + - Refunds: `payments.refund.v1` + +## Agent identity + +The plugin checks for an agent id in this order: + +1. `X-Agent-ID` request header. +2. `X-APort-Agent-ID` request header. +3. WooCommerce order meta `_aport_agent_id`. + +This keeps the example usable with API-driven checkouts, custom storefronts, and admin-created test orders. + +## Refund preflight + +For custom refund flows, call the REST endpoint before creating the refund: + +```http +POST /wp-json/aport-woocommerce/v1/verify-refund +Content-Type: application/json + +{ + "order_id": 42, + "amount": 12.50, + "agent_id": "agent_123" +} +``` + +Only users with `manage_woocommerce` can call the endpoint. The regular WooCommerce `woocommerce_order_refunded` hook also records a post-refund verification note so manual admin refunds are auditable. + +## Development + +```bash +composer install +composer test +composer lint +``` + +Without Composer dependencies, syntax can still be checked directly: + +```bash +find . -name '*.php' -not -path './vendor/*' -print0 | xargs -0 -n1 php -l +``` + +## Validation checklist + +- Configure a test API key in plugin settings. +- Place a test order with `X-Agent-ID` or `_aport_agent_id`. +- Confirm the order receives an APort order-verification note. +- Call `verify-refund` with a test amount and confirm the JSON decision. +- Create a WooCommerce refund and confirm a refund-verification order note is recorded. diff --git a/examples/ecommerce/woocommerce/aport-woocommerce.php b/examples/ecommerce/woocommerce/aport-woocommerce.php new file mode 100644 index 0000000..2e3c746 --- /dev/null +++ b/examples/ecommerce/woocommerce/aport-woocommerce.php @@ -0,0 +1,36 @@ +

'; + echo esc_html__('APort WooCommerce Guardrail requires WooCommerce to be active.', 'aport-woocommerce'); + echo '

'; + }); + return; + } + + APort_WooCommerce::instance()->init(); +}); diff --git a/examples/ecommerce/woocommerce/composer.json b/examples/ecommerce/woocommerce/composer.json new file mode 100644 index 0000000..0ead785 --- /dev/null +++ b/examples/ecommerce/woocommerce/composer.json @@ -0,0 +1,21 @@ +{ + "name": "aport/woocommerce-guardrail-example", + "description": "WooCommerce plugin example for APort order and refund verification.", + "type": "wordpress-plugin", + "license": "MIT", + "require": { + "php": ">=7.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6" + }, + "autoload": { + "classmap": [ + "includes/" + ] + }, + "scripts": { + "test": "phpunit", + "lint": "find . -name '*.php' -not -path './vendor/*' -print0 | xargs -0 -n1 php -l" + } +} diff --git a/examples/ecommerce/woocommerce/includes/class-aport-woocommerce-client.php b/examples/ecommerce/woocommerce/includes/class-aport-woocommerce-client.php new file mode 100644 index 0000000..21f1b38 --- /dev/null +++ b/examples/ecommerce/woocommerce/includes/class-aport-woocommerce-client.php @@ -0,0 +1,79 @@ +api_key = trim((string) $api_key); + $this->base_url = rtrim(trim((string) $base_url), '/'); + } + + public function verify($policy_id, $agent_id, array $context) + { + if ($this->api_key === '') { + return new WP_Error('aport_missing_api_key', 'APort API key is not configured.'); + } + + if (trim((string) $agent_id) === '') { + return new WP_Error('aport_missing_agent_id', 'Agent id is required for APort verification.'); + } + + $response = wp_remote_post( + $this->base_url . '/v1/verify', + array( + 'timeout' => 10, + 'headers' => array( + 'Authorization' => 'Bearer ' . $this->api_key, + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ), + 'body' => wp_json_encode( + array( + 'policy_id' => $policy_id, + 'agent_id' => $agent_id, + 'context' => $context, + ) + ), + ) + ); + + if (is_wp_error($response)) { + return $response; + } + + $status = (int) wp_remote_retrieve_response_code($response); + $body = json_decode((string) wp_remote_retrieve_body($response), true); + if (!is_array($body)) { + $body = array(); + } + + if ($status < 200 || $status >= 300) { + return new WP_Error( + 'aport_http_error', + sprintf('APort verification failed with HTTP %d.', $status), + array('status' => $status, 'body' => $body) + ); + } + + $allowed = isset($body['allowed']) ? (bool) $body['allowed'] : (isset($body['decision']) && $body['decision'] === 'allow'); + + return array( + 'allowed' => $allowed, + 'decision' => isset($body['decision']) ? (string) $body['decision'] : ($allowed ? 'allow' : 'deny'), + 'reason' => isset($body['reason']) ? (string) $body['reason'] : '', + 'raw' => $body, + ); + } +} diff --git a/examples/ecommerce/woocommerce/includes/class-aport-woocommerce.php b/examples/ecommerce/woocommerce/includes/class-aport-woocommerce.php new file mode 100644 index 0000000..3851d39 --- /dev/null +++ b/examples/ecommerce/woocommerce/includes/class-aport-woocommerce.php @@ -0,0 +1,313 @@ + 'API key', + 'base_url' => 'Base URL', + 'order_policy' => 'Order policy id', + 'refund_policy' => 'Refund policy id', + 'block_refunds' => 'Flag denied refunds', + ); + + foreach ($fields as $field => $label) { + add_settings_field($field, $label, array($this, 'render_field'), 'aport_woocommerce', 'aport_woocommerce_main', array('field' => $field)); + } + } + + public function sanitize_options($options) + { + $options = is_array($options) ? $options : array(); + + return array( + 'api_key' => sanitize_text_field($options['api_key'] ?? ''), + 'base_url' => esc_url_raw($options['base_url'] ?? 'https://api.aport.io'), + 'order_policy' => sanitize_text_field($options['order_policy'] ?? self::ORDER_POLICY), + 'refund_policy' => sanitize_text_field($options['refund_policy'] ?? self::REFUND_POLICY), + 'block_refunds' => !empty($options['block_refunds']) ? '1' : '0', + ); + } + + public function render_field($args) + { + $field = $args['field']; + $options = $this->get_options(); + $name = self::OPTION_KEY . '[' . esc_attr($field) . ']'; + + if ($field === 'block_refunds') { + printf( + '', + esc_attr($name), + checked($options[$field], '1', false), + esc_html__('Add a manual-review order note when a refund verification is denied.', 'aport-woocommerce') + ); + return; + } + + $type = $field === 'api_key' ? 'password' : 'text'; + printf( + '', + esc_attr($type), + esc_attr($name), + esc_attr($options[$field]) + ); + } + + public function render_settings_page() + { + echo '

APort WooCommerce Guardrail

'; + echo '
'; + settings_fields('aport_woocommerce'); + do_settings_sections('aport_woocommerce'); + submit_button(); + echo '
'; + } + + public function verify_checkout_order($order_id, $posted_data, $order) + { + if (!$order && function_exists('wc_get_order')) { + $order = wc_get_order($order_id); + } + + if (!$order) { + return; + } + + $agent_id = self::extract_agent_id($_SERVER, $order); + $context = self::build_order_context($order, 'order.created'); + $this->record_verification($order, $agent_id, $this->get_options()['order_policy'], $context); + } + + public function verify_refund($order_id, $refund_id) + { + if (!function_exists('wc_get_order')) { + return; + } + + $order = wc_get_order($order_id); + $refund = wc_get_order($refund_id); + + if (!$order || !$refund) { + return; + } + + $agent_id = self::extract_agent_id($_SERVER, $order); + $context = self::build_refund_context($order, $refund); + $this->record_verification($order, $agent_id, $this->get_options()['refund_policy'], $context); + } + + public function register_rest_routes() + { + register_rest_route( + 'aport-woocommerce/v1', + '/verify-refund', + array( + 'methods' => 'POST', + 'callback' => array($this, 'rest_verify_refund'), + 'permission_callback' => function () { + return current_user_can('manage_woocommerce'); + }, + ) + ); + } + + public function rest_verify_refund(WP_REST_Request $request) + { + $order_id = absint($request->get_param('order_id')); + $amount = (float) $request->get_param('amount'); + $agent_id = sanitize_text_field($request->get_param('agent_id')); + + if (!function_exists('wc_get_order')) { + return new WP_Error('woocommerce_missing', 'WooCommerce is required.', array('status' => 500)); + } + + $order = wc_get_order($order_id); + if (!$order) { + return new WP_Error('order_not_found', 'Order not found.', array('status' => 404)); + } + + $context = self::build_order_context($order, 'refund.requested'); + $context['refund'] = array( + 'amount' => $amount, + 'currency' => method_exists($order, 'get_currency') ? $order->get_currency() : '', + ); + + $result = $this->record_verification($order, $agent_id, $this->get_options()['refund_policy'], $context); + + if (is_wp_error($result)) { + return $result; + } + + return rest_ensure_response($result); + } + + public static function extract_agent_id(array $server, $order = null) + { + $candidates = array( + $server['HTTP_X_AGENT_ID'] ?? '', + $server['HTTP_X_APORT_AGENT_ID'] ?? '', + ); + + if ($order && method_exists($order, 'get_meta')) { + $candidates[] = $order->get_meta('_aport_agent_id'); + } + + foreach ($candidates as $candidate) { + $candidate = trim((string) $candidate); + if ($candidate !== '') { + return $candidate; + } + } + + return ''; + } + + public static function build_order_context($order, $action) + { + return array( + 'action' => $action, + 'platform' => 'woocommerce', + 'order' => array( + 'id' => self::call($order, 'get_id'), + 'number' => self::call($order, 'get_order_number'), + 'status' => self::call($order, 'get_status'), + 'total' => (float) self::call($order, 'get_total'), + 'currency' => self::call($order, 'get_currency'), + 'customer_id' => self::call($order, 'get_customer_id'), + ), + ); + } + + public static function build_refund_context($order, $refund) + { + $context = self::build_order_context($order, 'refund.created'); + $context['refund'] = array( + 'id' => self::call($refund, 'get_id'), + 'amount' => abs((float) self::call($refund, 'get_amount')), + 'reason' => self::call($refund, 'get_reason'), + 'currency' => self::call($order, 'get_currency'), + ); + + return $context; + } + + private function record_verification($order, $agent_id, $policy_id, array $context) + { + $client = $this->client(); + $result = $client->verify($policy_id, $agent_id, $context); + + if (is_wp_error($result)) { + $this->add_order_note($order, 'APort verification error: ' . $result->get_error_message()); + return $result; + } + + $message = sprintf( + 'APort verification %s for policy %s%s.', + $result['allowed'] ? 'allowed' : 'denied', + $policy_id, + $result['reason'] !== '' ? ': ' . $result['reason'] : '' + ); + + $this->add_order_note($order, $message); + + $options = $this->get_options(); + if (!$result['allowed'] && $policy_id === $options['refund_policy'] && $options['block_refunds'] === '1') { + $this->add_order_note($order, 'APort refund verification was denied. Review this refund before any additional fulfillment or reimbursement.'); + } + + if (method_exists($order, 'update_meta_data')) { + $order->update_meta_data('_aport_last_decision', $result['decision']); + $order->update_meta_data('_aport_last_policy', $policy_id); + $order->update_meta_data('_aport_last_agent_id', $agent_id); + $order->save(); + } + + return $result; + } + + private function add_order_note($order, $message) + { + if (method_exists($order, 'add_order_note')) { + $order->add_order_note($message, false, true); + } + } + + private function client() + { + $options = $this->get_options(); + + return new APort_WooCommerce_Client($options['api_key'], $options['base_url']); + } + + private function get_options() + { + $options = function_exists('get_option') ? get_option(self::OPTION_KEY, array()) : array(); + $options = is_array($options) ? $options : array(); + + return array_merge( + array( + 'api_key' => '', + 'base_url' => 'https://api.aport.io', + 'order_policy' => self::ORDER_POLICY, + 'refund_policy' => self::REFUND_POLICY, + 'block_refunds' => '1', + ), + $options + ); + } + + private static function call($object, $method) + { + return is_object($object) && method_exists($object, $method) ? $object->{$method}() : null; + } +} diff --git a/examples/ecommerce/woocommerce/phpunit.xml b/examples/ecommerce/woocommerce/phpunit.xml new file mode 100644 index 0000000..0975c63 --- /dev/null +++ b/examples/ecommerce/woocommerce/phpunit.xml @@ -0,0 +1,8 @@ + + + + + tests + + + diff --git a/examples/ecommerce/woocommerce/tests/VerificationContextTest.php b/examples/ecommerce/woocommerce/tests/VerificationContextTest.php new file mode 100644 index 0000000..32f7c7a --- /dev/null +++ b/examples/ecommerce/woocommerce/tests/VerificationContextTest.php @@ -0,0 +1,104 @@ +meta['_aport_agent_id'] = 'order-agent'; + + $this->assertSame( + 'header-agent', + APort_WooCommerce::extract_agent_id(array('HTTP_X_AGENT_ID' => ' header-agent '), $order) + ); + } + + public function testExtractAgentIdFallsBackToOrderMeta() + { + $order = new FakeOrder(); + $order->meta['_aport_agent_id'] = 'order-agent'; + + $this->assertSame('order-agent', APort_WooCommerce::extract_agent_id(array(), $order)); + } + + public function testBuildOrderContext() + { + $context = APort_WooCommerce::build_order_context(new FakeOrder(), 'order.created'); + + $this->assertSame('order.created', $context['action']); + $this->assertSame('woocommerce', $context['platform']); + $this->assertSame(42, $context['order']['id']); + $this->assertSame(125.5, $context['order']['total']); + $this->assertSame('USD', $context['order']['currency']); + } + + public function testBuildRefundContext() + { + $context = APort_WooCommerce::build_refund_context(new FakeOrder(), new FakeRefund()); + + $this->assertSame('refund.created', $context['action']); + $this->assertSame(77, $context['refund']['id']); + $this->assertSame(15.25, $context['refund']['amount']); + $this->assertSame('Customer request', $context['refund']['reason']); + } +} + +final class FakeOrder +{ + public $meta = array(); + + public function get_id() + { + return 42; + } + + public function get_order_number() + { + return '100042'; + } + + public function get_status() + { + return 'processing'; + } + + public function get_total() + { + return '125.50'; + } + + public function get_currency() + { + return 'USD'; + } + + public function get_customer_id() + { + return 12; + } + + public function get_meta($key) + { + return $this->meta[$key] ?? ''; + } +} + +final class FakeRefund +{ + public function get_id() + { + return 77; + } + + public function get_amount() + { + return '-15.25'; + } + + public function get_reason() + { + return 'Customer request'; + } +} diff --git a/examples/ecommerce/woocommerce/tests/bootstrap.php b/examples/ecommerce/woocommerce/tests/bootstrap.php new file mode 100644 index 0000000..a83d8b2 --- /dev/null +++ b/examples/ecommerce/woocommerce/tests/bootstrap.php @@ -0,0 +1,17 @@ +