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 '