Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
96.57% covered (success)
96.57%
478 / 495
93.31% covered (success)
93.31%
237 / 254
14.46% covered (danger)
14.46%
96 / 664
73.17% covered (warning)
73.17%
30 / 41
CRAP
0.00% covered (danger)
0.00%
0 / 1
WC_Gateway_Monokulo
96.57% covered (success)
96.57%
478 / 495
93.31% covered (success)
93.31%
237 / 254
14.46% covered (danger)
14.46%
96 / 664
82.93% covered (warning)
82.93%
34 / 41
12408.65
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 init_form_fields
100.00% covered (success)
100.00%
63 / 63
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_available
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
5 / 5
66.67% covered (warning)
66.67%
2 / 3
100.00% covered (success)
100.00%
1 / 1
3.33
 has_credentials
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
5 / 5
50.00% covered (danger)
50.00%
2 / 4
100.00% covered (success)
100.00%
1 / 1
4.12
 needs_reconnect
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 settings_need_reconnect
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
12 / 12
23.53% covered (danger)
23.53%
4 / 17
100.00% covered (success)
100.00%
1 / 1
22.10
 render_reconnect_notice
91.67% covered (success)
91.67%
11 / 12
80.00% covered (warning)
80.00%
4 / 5
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
 process_payment
93.75% covered (success)
93.75%
15 / 16
66.67% covered (warning)
66.67%
2 / 3
50.00% covered (danger)
50.00%
1 / 2
0.00% covered (danger)
0.00%
0 / 1
2.50
 create_monokulo_order
100.00% covered (success)
100.00%
60 / 60
94.44% covered (success)
94.44%
17 / 18
30.00% covered (danger)
30.00%
6 / 20
100.00% covered (success)
100.00%
1 / 1
36.78
 idempotency_key
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 format_amount
100.00% covered (success)
100.00%
4 / 4
83.33% covered (warning)
83.33%
5 / 6
66.67% covered (warning)
66.67%
2 / 3
100.00% covered (success)
100.00%
1 / 1
3.33
 customer_message_for_status
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
7 / 7
80.00% covered (warning)
80.00%
4 / 5
100.00% covered (success)
100.00%
1 / 1
4.13
 hint_for_status
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
11 / 11
50.00% covered (danger)
50.00%
5 / 10
100.00% covered (success)
100.00%
1 / 1
10.50
 get_orders_endpoint_url
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_checkout_url
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_control_plane_base_url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_connect_return_url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 connect_nonce_transient_key
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 build_connect_start_url
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 generate_monokulo_connect_html
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
8 / 8
50.00% covered (danger)
50.00%
3 / 6
100.00% covered (success)
100.00%
1 / 1
6.00
 validate_monokulo_connect_field
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 handle_connect_return
40.00% covered (danger)
40.00%
2 / 5
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2.86
 process_connect_return
100.00% covered (success)
100.00%
20 / 20
95.45% covered (success)
95.45%
21 / 22
3.75% covered (danger)
3.75%
6 / 160
100.00% covered (success)
100.00%
1 / 1
99.17
 call_connect_finish
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
13 / 13
22.22% covered (danger)
22.22%
4 / 18
100.00% covered (success)
100.00%
1 / 1
30.05
 get_webhook_receiver_url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 handle_webhook
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 process_webhook_request
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
21 / 21
2.33% covered (danger)
2.33%
6 / 257
100.00% covered (success)
100.00%
1 / 1
123.72
 verify_webhook_signature
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
12 / 12
30.00% covered (danger)
30.00%
6 / 20
100.00% covered (success)
100.00%
1 / 1
18.35
 find_order_by_order_id
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
6 / 6
50.00% covered (danger)
50.00%
2 / 4
100.00% covered (success)
100.00%
1 / 1
4.12
 event_already_applied
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 mark_event_applied
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 apply_webhook_event
100.00% covered (success)
100.00%
34 / 34
91.67% covered (success)
91.67%
11 / 12
57.14% covered (warning)
57.14%
4 / 7
100.00% covered (success)
100.00%
1 / 1
8.83
 apply_order_status_event
90.91% covered (success)
90.91%
20 / 22
92.31% covered (success)
92.31%
12 / 13
41.67% covered (danger)
41.67%
5 / 12
0.00% covered (danger)
0.00%
0 / 1
16.73
 build_settings_url
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 maybe_render_connect_notice
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
17 / 17
6.25% covered (danger)
6.25%
4 / 64
100.00% covered (success)
100.00%
1 / 1
60.73
 trace_id
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 adopt_traceparent
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
7 / 7
80.00% covered (warning)
80.00%
4 / 5
100.00% covered (success)
100.00%
1 / 1
4.13
 traceparent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 log
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
3 / 3
50.00% covered (danger)
50.00%
1 / 2
100.00% covered (success)
100.00%
1 / 1
2.50
 maybe_forward
88.89% covered (warning)
88.89%
16 / 18
90.91% covered (success)
90.91%
10 / 11
14.29% covered (danger)
14.29%
2 / 14
0.00% covered (danger)
0.00%
0 / 1
28.67
 flush_forwarded
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2/**
3 * `WC_Gateway_Monokulo`: the WooCommerce-facing shell for Monokulo.
4 *
5 * @package Monokulo
6 */
7
8if ( ! defined( 'ABSPATH' ) ) {
9    exit;
10}
11
12/**
13 * Registers "Monero (via Monokulo)" as a WooCommerce checkout option and
14 * hands each order off to Monokulo at checkout.
15 *
16 * The plugin talks to Monokulo only, never to the engine behind it (which is
17 * private). Connecting (`process_connect_return()`) stores Monokulo's public
18 * address (`endpoint`), the store's public key and its secret key. At
19 * checkout `process_payment()` creates the order with
20 * `POST {endpoint}/pay/{pk}/orders`, authenticated with
21 * `Authorization: Bearer {secret key}`, and sends the customer to Monokulo's
22 * checkout page, `{endpoint}/pay/{pk}/orders/{order_id}`. Monokulo prices
23 * the order into XMR. Payment updates arrive as webhooks the engine sends
24 * straight to this site (`handle_webhook()`), registered for it by Monokulo
25 * during connect.
26 *
27 * The gateway ships disabled by default (`docs/WOOCOMMERCE_ROADMAP.md`
28 * Stage 8): connecting enables it. `is_available()` also keeps it off the
29 * checkout while it has no usable connection, including a connection made
30 * by a plugin version before this one (see `CONNECTION_VERSION`).
31 *
32 * Every real decision - order creation, payment matching, exchange rates,
33 * webhook delivery - lives in the Rust engine (`scanner`) and the
34 * control plane sitting in front of it, per this repo's Stage 7 framing
35 * ("PHP is a thin adapter, not a second implementation of anything"). This
36 * class's job stops at being a faithful WooCommerce citizen: register
37 * correctly, expose the right settings fields, and make one outbound HTTP
38 * call per order. Nothing more belongs here by design - in particular, this
39 * gateway never decides an order is paid (see `process_payment()`'s own doc
40 * comment on why `payment_complete()` is never called here) and never
41 * verifies a payment itself (WBS 1.5.4's webhook receiver's job).
42 */
43class WC_Gateway_Monokulo extends WC_Payment_Gateway {
44
45    /**
46     * The one, fixed address of Monokulo's own hosted service - where the
47     * connect flow starts (`crates/monokulo/src/http/connect.rs`). The
48     * address orders are then created at is the `endpoint` that flow's
49     * `/finish` returns (`$api_base_url`), which is the same Monokulo
50     * instance's configured public address; keeping the two separate lets
51     * an instance serve its connect pages and its checkout from different
52     * addresses (for example a clearnet dashboard and an onion checkout).
53     * For the hosted product there is exactly one such service, so this is
54     * a class constant rather than something a merchant types in - the way
55     * a Stripe or WooCommerce Payments extension hardcodes its own API host.
56     *
57     * **This is a placeholder, deliberately, not a real address**: as of
58     * this step, Monokulo's control plane has no real, decided
59     * production domain yet (checked `docs/WOOCOMMERCE_ROADMAP.md` and
60     * `work_notes.md` directly - neither names one). `cloud.monokulo.example`
61     * is lifted verbatim from the roadmap doc's own illustrative URL (Stage
62     * 6) specifically *because* it's already an obvious non-address -
63     * `.example` is the IANA-reserved TLD that can never resolve to anything
64     * real (RFC 2606) - rather than inventing a new placeholder that could
65     * later be mistaken for a real one. Whoever picks the real production
66     * domain only has to change this one line (and, ideally, drop the filter
67     * override below instead of leaving both).
68     *
69     * @var string
70     */
71    const CONTROL_PLANE_BASE_URL = 'https://cloud.monokulo.example';
72
73    /**
74     * The `platform` path segment this plugin identifies itself as to the
75     * control plane's generic, platform-agnostic connect flow
76     * (`GET /connect/{platform}` etc., see `connect.rs`'s own module doc
77     * comment) - `"woocommerce"`, matching exactly what `control-plane`'s own
78     * tests and `mock-woocommerce`'s driver already use for this platform, so
79     * every `store_connections.platform` row this plugin ever produces is
80     * consistent with what the mock already exercised end to end.
81     */
82    const CONNECT_PLATFORM = 'woocommerce';
83
84    /**
85     * The version of the connection this plugin stores, written to the
86     * `connection_version` setting by `process_connect_return()` whenever
87     * the connect flow succeeds.
88     *
89     * Version `2` is the first where `endpoint` is Monokulo's own public
90     * address. Before it, `/finish` handed plugins the engine's address,
91     * which no longer accepts orders from anything but Monokulo. An install
92     * connected by an older plugin has credentials but no
93     * `connection_version` at all, which is how it is recognised - never by
94     * looking at the URL it holds. Such an install needs a reconnect:
95     * `is_available()` keeps the gateway off the checkout (rather than
96     * letting orders fail) and an admin notice asks the merchant to
97     * reconnect (`render_reconnect_notice()`).
98     *
99     * Bump this whenever a stored connection from an older plugin can no
100     * longer work as-is.
101     *
102     * @var string
103     */
104    const CONNECTION_VERSION = '2';
105
106    /**
107     * How this plugin names itself to Monokulo when it creates an order, in
108     * a `Monokulo-Client` header: Monokulo records the order as the
109     * WooCommerce plugin's (`crates/monokulo/src/http/pay.rs`,
110     * `order_source`). A store has no platform of its own; the order says
111     * where it came from. Keep the version with the plugin header's.
112     */
113    const CLIENT = 'woocommerce/0.1.0';
114
115    /**
116     * The `admin-post.php` action name this gateway registers as `return_url`
117     * for the connect flow - see `get_connect_return_url()`'s own doc comment
118     * for why `admin-post.php`, specifically, is the callback mechanism this
119     * step chose.
120     */
121    const CONNECT_RETURN_ACTION = 'monokulo_connect_return';
122
123    /**
124     * The order-meta key `process_payment()` writes the engine's own
125     * `order_id` under, and `find_order_by_order_id()` (WBS 1.5.4) reads
126     * back to map an incoming webhook to a `WC_Order` - promoted to a
127     * constant, rather than the literal string 1.5.2 originally hardcoded
128     * inline, specifically because this step adds a second, independent call
129     * site that must agree with the first byte-for-byte: a silent drift
130     * between "what `process_payment()` writes" and "what the webhook
131     * receiver queries for" would make every real webhook fail to find its
132     * order, with no error anywhere near either call site to explain why.
133     *
134     * @var string
135     */
136    const META_ORDER_ID = '_monokulo_order_id';
137
138    /**
139     * The order-meta key `event_already_applied()`/`mark_event_applied()`
140     * (WBS 1.5.4) use to record which webhook `event_id`s have already been
141     * applied to a given order - a JSON-encoded array of `evt_...` strings,
142     * not a comma-joined one, purely so `event_id`s (which this codebase
143     * never puts commas in, but nothing guarantees that structurally) can
144     * never be mis-split. See `event_already_applied()`'s own doc comment for
145     * why this exists at all.
146     *
147     * @var string
148     */
149    const META_APPLIED_EVENT_IDS = '_monokulo_applied_webhook_event_ids';
150
151    /**
152     * The `X-Monokulo-Signature` header, as PHP exposes it in `$_SERVER`:
153     * every incoming HTTP header `Name-Like-This` is normalized to
154     * `HTTP_NAME_LIKE_THIS` by PHP's own SAPI before user code ever sees it
155     * (a PHP/CGI convention, not a WordPress one) - named here as a constant
156     * so `handle_webhook()`'s one read of it and this doc comment's own
157     * explanation stay next to each other rather than the transform being
158     * re-derived silently at the call site.
159     *
160     * @var string
161     */
162    const WEBHOOK_SIGNATURE_SERVER_KEY = 'HTTP_X_MONOKULO_SIGNATURE';
163
164    /**
165     * Maps an engine `OrderStatus` (`src/status.rs::OrderStatus`, read
166     * directly - the seven-variant, authoritative enum, not assumed
167     * exhaustive from docs) to the WooCommerce order status this gateway
168     * transitions an order to when that engine status is announced by an
169     * `order.<status>` webhook event. `paid`/`overpaid` are deliberately
170     * **absent** from this table - see `apply_order_status_event()`'s own
171     * doc comment for why those two go through `WC_Order::payment_complete()`
172     * instead of a plain table lookup.
173     *
174     * Each row's reasoning (why *this* WooCommerce status, not some other
175     * plausible one) is documented in full on `apply_order_status_event()`,
176     * not repeated here - this table is the answer, that method's doc
177     * comment is the "why".
178     *
179     * @var array<string, string>
180     */
181    const STATUS_MAP = array(
182        'pending'     => 'pending',
183        'unconfirmed' => 'on-hold',
184        'confirming'  => 'on-hold',
185        'partial'     => 'on-hold',
186        'expired'     => 'cancelled',
187    );
188
189    /**
190     * Monokulo's public address this store talks to, e.g.
191     * `https://pay.example.com` - the `endpoint` `/finish` returned when the
192     * store connected (`connect.rs::FinishResponse::endpoint`). Order
193     * creation and the checkout redirect both go here; the engine behind
194     * Monokulo is never contacted. No trailing slash is needed (stripped
195     * where URLs are built).
196     *
197     * @var string
198     */
199    private $api_base_url;
200
201    /**
202     * The store's public key (`pk_...`) this gateway creates orders under,
203     * saved by the connect flow.
204     *
205     * @var string
206     */
207    private $tenant_public_key;
208
209    /**
210     * The store's secret key (`sk_...`), returned by the connect flow's
211     * `/finish` call (`FinishResponse::secret_token`). Sent as
212     * `Authorization: Bearer ...` when creating orders
213     * (`create_monokulo_order()`), which is how Monokulo knows the order
214     * comes from the store's own server: it skips the per-address rate
215     * limit and is accepted even when the store only allows its checkout on
216     * its verified domains. Never sent anywhere else, and never shown to
217     * customers.
218     *
219     * @var string
220     */
221    private $secret_token;
222
223    /**
224     * The webhook's own signing secret (`whsec_...`), returned by
225     * `call_connect_finish()`'s `/finish` response and saved (since WBS
226     * 1.5.3) under the `webhook_signing_secret` option key - see that
227     * method's own doc comment for why it's saved outside `$this->form_fields`
228     * entirely. Read here, in this step, for the first time: it's the HMAC
229     * key `verify_webhook_signature()` below checks every incoming webhook
230     * delivery against.
231     *
232     * @var string
233     */
234    private $webhook_signing_secret;
235
236    /**
237     * The trace this PHP request belongs to (32 lowercase hex characters,
238     * W3C Trace Context), shared by every gateway instance in the request.
239     * Sent to Monokulo as a `traceparent` header on every call, so
240     * Monokulo's own logs for the call land in the same trace, and written
241     * on every plugin log line, so the two can be matched. An incoming
242     * webhook adopts the engine's trace instead. `null` until first used.
243     *
244     * @var string|null
245     */
246    private static $trace_id = null;
247
248    /**
249     * Warnings and errors waiting to be sent to Monokulo at the end of this
250     * PHP request (the "Send errors to Monokulo" option), at most
251     * `MAX_FORWARDED` of them.
252     *
253     * @var array<int, array{level: string, message: string, trace_id: string}>
254     */
255    private static $forward_queue = array();
256
257    /**
258     * Whether the end-of-request send is already registered.
259     *
260     * @var bool
261     */
262    private static $forward_registered = false;
263
264    /**
265     * Most entries sent per PHP request; Monokulo refuses more.
266     */
267    const MAX_FORWARDED = 20;
268
269    /**
270     * WooCommerce log levels that are forwarded.
271     */
272    const FORWARDED_LEVELS = array( 'warning', 'error', 'critical', 'alert', 'emergency' );
273
274    /**
275     * Sets up the gateway's identity and settings fields.
276     *
277     * WooCommerce instantiates every class registered via the
278     * `woocommerce_payment_gateways` filter (see `monokulo.php`)
279     * with no constructor arguments, so every property this gateway needs
280     * has to be derivable here with no external input beyond WordPress's
281     * own options table (via `get_option()`, populated by
282     * `init_settings()` below) - there is no other injection point at
283     * this stage of WooCommerce's own lifecycle.
284     */
285    public function __construct() {
286        // `id` is the gateway's permanent identifier: it's the array key
287        // WooCommerce's settings are stored under
288        // (`woocommerce_monokulo_settings` in `wp_options`), the
289        // value that ends up as `$order->get_payment_method()` on every
290        // order this gateway processes, and the suffix of the webhook
291        // endpoint WBS 1.5.4 registers (`woocommerce_api_{$this->id}`).
292        // Changing it later would silently orphan every existing order's
293        // payment-method record and any already-registered webhook URL, so
294        // it's treated as fixed from this first step onward, not a cosmetic
295        // label. Chosen to read as "the id", not a version-agnostic English
296        // phrase like "monero" (which a future unrelated Monero gateway
297        // could just as reasonably claim) - `monokulo` names the
298        // actual product per `docs/WOOCOMMERCE_ROADMAP.md`'s own framing of
299        // the hosted service by that name throughout.
300        $this->id = 'monokulo';
301
302        // The Monokulo mark beside the payment method at checkout: the square
303        // version, shipped with the plugin so the shopper's browser loads it
304        // from the store, not from monokulo. Written by `cargo xtask logo`.
305        $this->icon = plugins_url( 'assets/monokulo-icon.svg', dirname( __DIR__ ) . '/monokulo.php' );
306
307        // This gateway never renders its own fields inline at WooCommerce's
308        // checkout - per `docs/WOOCOMMERCE_ROADMAP.md` Stage 7's explicit
309        // integration choice, paying with Monero means redirecting the
310        // customer to Monokulo's own checkout page
311        // (`/pay/{pk}/orders/{order_id}`), not embedding a new widget into
312        // WooCommerce's checkout form. `has_fields = false` is
313        // what tells WooCommerce's checkout template not to reserve any
314        // inline space for this gateway.
315        $this->has_fields = false;
316
317        // `method_title`/`method_description` are what a merchant sees on
318        // WooCommerce's own "Payments" settings list (Settings > Payments) -
319        // distinct from `title`/`description` below, which is what the
320        // *customer* sees at checkout. Conflating the two is a common
321        // mistake in WooCommerce gateway plugins; kept deliberately separate
322        // here even though nothing yet enforces the distinction functionally,
323        // because getting it right from the first version avoids a later
324        // "why does the merchant admin screen say something meant for
325        // customers" bug report.
326        $this->method_title       = __( 'Monokulo', 'monokulo' );
327        $this->method_description = __(
328            'Accept Monero at checkout without ever holding customer funds or your own spend key - Monokulo watches the chain for you and only ever needs a view key. Connect your wallet to enable this gateway for real.',
329            'monokulo'
330        );
331
332        // `supports` is WooCommerce's own mechanism for a gateway to declare
333        // which of its many optional checkout/order features it implements
334        // (refunds, subscriptions, pre-orders, tokenization, etc.) -
335        // WooCommerce's core checkout and admin-order-screen code both
336        // consult this array before offering a feature (e.g. it won't show
337        // a "Refund" button for a gateway that hasn't declared `refunds`),
338        // rather than assuming every gateway implements everything.
339        // `'products'` is the baseline every ordinary one-time-payment
340        // gateway declares (WooCommerce's own bundled gateways all include
341        // it) - it means "this gateway works for a cart containing ordinary
342        // products", as opposed to gateways that only make sense for
343        // recurring subscriptions. Nothing else is declared here
344        // deliberately: refunds specifically would be a false claim right
345        // now (there is no refund flow) and no other `supports` value is
346        // developed as of WBS 1.5.1's own scope.
347        $this->supports = array( 'products' );
348
349        // Populates `$this->form_fields`, the schema `init_settings()` (next)
350        // reads defaults from and WooCommerce's own settings-page renderer
351        // walks to draw the actual HTML form.
352        $this->init_form_fields();
353
354        // Loads whatever's actually been saved in `wp_options` into
355        // `$this->settings`, falling back to each field's own `default` from
356        // `init_form_fields()` above for anything never saved yet (e.g. a
357        // brand new install). This has to run *after* `init_form_fields()`
358        // - there'd be nothing to load defaults from otherwise - and
359        // *before* the `get_option()` calls immediately following it, which
360        // read out of the `$this->settings` array this call populates.
361        $this->init_settings();
362
363        // `title`/`description` are the customer-facing checkout strings -
364        // pulled from saved settings when present, otherwise the same
365        // literal WBS 1.5.1's own acceptance outcome names verbatim
366        // ("Monero (via Monokulo)"), so a fresh install already shows
367        // the right label with zero configuration, while still letting a
368        // merchant override the wording later without a code change.
369        $this->title       = $this->get_option( 'title', __( 'Monero (via Monokulo)', 'monokulo' ) );
370        $this->description = $this->get_option(
371            'description',
372            __( 'Pay with Monero. You will be redirected to a secure Monokulo payment page to complete your purchase.', 'monokulo' )
373        );
374
375        // The connection the connect flow saved. `trim()`, not just a raw
376        // `get_option()` read: a value pasted by hand into the settings form
377        // notoriously picks up leading/trailing whitespace, and an
378        // un-trimmed `pk_...\n` would fail `create_monokulo_order()`'s
379        // outbound call in a way that looks identical to a genuinely wrong
380        // key.
381        $this->api_base_url     = trim( (string) $this->get_option( 'endpoint', '' ) );
382        $this->tenant_public_key = trim( (string) $this->get_option( 'public_key', '' ) );
383        $this->secret_token      = trim( (string) $this->get_option( 'secret_token', '' ) );
384        $this->webhook_signing_secret = trim( (string) $this->get_option( 'webhook_signing_secret', '' ) );
385
386        // No explicit `$this->enabled = $this->get_option( 'enabled' )` line
387        // here, deliberately - checked directly against WooCommerce's own
388        // source (`WC_Payment_Gateway::init_settings()`,
389        // includes/abstracts/abstract-wc-payment-gateway.php) rather than
390        // assumed: that method, called just above, already sets
391        // `$this->enabled` itself from `$this->settings['enabled']`
392        // (`'yes' === $this->settings['enabled'] ? 'yes' : 'no'`) - a second
393        // assignment here would be pure duplication, not a safety net. This
394        // matches the pattern WooCommerce's own bundled gateways use (e.g.
395        // `WC_Gateway_BACS`'s constructor never touches `$this->enabled`
396        // either, for the identical reason - confirmed by reading
397        // `includes/gateways/bacs/class-wc-gateway-bacs.php` directly).
398        //
399        // What actually makes this gateway disabled by default is the
400        // `enabled` field's own `'default' => 'no'` in `init_form_fields()`
401        // below, which `init_settings()` falls back to for a fresh install
402        // with nothing saved yet - and that default is not an oversight this
403        // step left for later, it is WBS 1.5.1's own stated outcome
404        // ("disabled state is fine at this step"). A gateway with no
405        // connected wallet has no `pk_`/`sk_`/`endpoint` to call yet (that
406        // only exists once WBS 1.5.3's connect flow runs), so registering it
407        // pre-enabled would let a merchant offer a payment method that WBS
408        // 1.5.2's `process_payment()` cannot yet honor - exactly the failure
409        // mode a disabled-by-default gateway avoids.
410
411        // The standard WooCommerce hook a gateway registers so that
412        // submitting its own settings form (Settings > Payments > this
413        // gateway) actually persists - WooCommerce fires
414        // `woocommerce_update_options_payment_gateways_{$this->id}` on save,
415        // and `process_admin_options()` (inherited from `WC_Payment_Gateway`,
416        // not overridden here - its default behavior of validating and
417        // saving `$this->form_fields` against submitted POST data is exactly
418        // right for this step's plain checkbox/text fields) is what actually
419        // writes the new values back to `wp_options`. Without this line, the
420        // settings form would render correctly but silently discard every
421        // change on submit.
422        add_action( 'woocommerce_update_options_payment_gateways_' . $this->id, array( $this, 'process_admin_options' ) );
423
424        // The real connect-flow callback (WBS 1.5.3) - see
425        // `get_connect_return_url()`'s own doc comment for why
426        // `admin_post_{action}` specifically is the right WordPress
427        // mechanism for this, and `handle_connect_return()`'s for why
428        // registering it here in the constructor is safe (identical
429        // reasoning to the `woocommerce_update_options_...` registration
430        // immediately above: this constructor only ever runs once
431        // WooCommerce's own `WC_Payment_Gateways::init()` has already fired,
432        // which happens on every request including the one `admin-post.php`
433        // itself bootstraps).
434        add_action( 'admin_post_' . self::CONNECT_RETURN_ACTION, array( $this, 'handle_connect_return' ) );
435
436        // The real webhook receiver (WBS 1.5.4) - WooCommerce's own real,
437        // documented mechanism for a plugin's webhook endpoint, confirmed
438        // directly against the *installed* WooCommerce source before relying
439        // on it (not just its doc comments): `WC()->api_request_url( $id )`
440        // (already used by `get_webhook_receiver_url()` above, since 1.5.3)
441        // builds a URL carrying a `wc-api={id}` query var (or `/wc-api/{id}/`
442        // under pretty permalinks); `src/Internal/Utilities/
443        // LegacyRestApiStub.php::maybe_process_wc_api_query_var()` (hooked on
444        // `parse_request`, read directly) is what actually dispatches that
445        // var, firing `do_action( 'woocommerce_api_' . $api_request )` for
446        // *any* site that has this hook registered - regardless of whether
447        // the separate, full "WooCommerce Legacy REST API" extension is
448        // installed. That file's own doc comment explains why the stub
449        // exists at all: the versioned `/wc-api/v1-3/...` REST endpoints
450        // were genuinely removed from core in WC 9.0, but the plain
451        // `woocommerce_api_{id}` gateway-callback mechanism (what PayPal's
452        // own legacy IPN handler still uses too, per
453        // `wc-deprecated-functions.php::woocommerce_legacy_paypal_ipn()`,
454        // read directly) was deliberately kept working without it - this is
455        // real, current, installed-source-confirmed behavior on this
456        // environment's WooCommerce 11.1.0, not an assumption carried over
457        // from an older WooCommerce version where the whole legacy API was
458        // still in core wholesale.
459        add_action( 'woocommerce_api_' . $this->id, array( $this, 'handle_webhook' ) );
460
461        // Flashes a one-line success/error notice on this gateway's own
462        // settings screen after a connect attempt redirects back to it - see
463        // `maybe_render_connect_notice()`'s own doc comment for why a plain
464        // query-string flag is used here rather than `WC_Admin_Settings::
465        // add_error()` (which only works within the same request that
466        // renders the settings form, and the connect callback is a separate
467        // request).
468        add_action( 'admin_notices', array( $this, 'maybe_render_connect_notice' ) );
469        // The site-wide "please reconnect" notice is registered in
470        // `monokulo.php` instead, so it shows on every admin screen whether
471        // or not WooCommerce has built its gateways on that request.
472    }
473
474    /**
475     * Defines the settings fields WooCommerce renders on this gateway's own
476     * settings screen (Settings > Payments > Monokulo) and that
477     * `init_settings()`/`get_option()` above read defaults from.
478     *
479     * Kept to the minimum this step actually needs, not a placeholder set of
480     * every field a *later* step will eventually want (an endpoint override,
481     * a webhook secret) - those belong to whichever step actually uses them.
482     * Building empty settings fields now, with nothing yet reading or
483     * writing them for real, would be exactly the kind of unrequested scope
484     * this project's own established practice (see `work_notes.md`'s
485     * progress log) has repeatedly flagged and avoided elsewhere.
486     *
487     * `endpoint`/`public_key`/`secret_token` are written by the connect flow
488     * (`process_connect_return()`); they stay visible (the key masked) so a
489     * merchant can see what the store is connected with. Neither
490     * `connection_version` (see `CONNECTION_VERSION`) nor
491     * `webhook_signing_secret` is a form field. No `webhook_signing_secret`
492     * field here, deliberately: unlike the three
493     * fields above, a merchant has no legitimate way to independently know
494     * that value by hand - it's minted fresh by the engine only when a
495     * webhook is registered (`connect.rs::finish`), so it's only ever
496     * written programmatically (`process_connect_return()` below, via a
497     * plain `update_option()` call outside `$this->form_fields` entirely -
498     * confirmed directly against `WC_Settings_API::update_option()`
499     * /`get_option()`, both read/write `$this->settings[$key]` by string key
500     * with no dependency on the key being declared here), never rendered as
501     * an editable field a merchant could accidentally paste a wrong value
502     * into.
503     */
504    public function init_form_fields() {
505        $this->form_fields = array(
506            'enabled'     => array(
507                'title'   => __( 'Enable/Disable', 'monokulo' ),
508                'type'    => 'checkbox',
509                'label'   => __( 'Enable Monero payments via Monokulo', 'monokulo' ),
510                // See the constructor's own comment on `$this->enabled` -
511                // this default is what makes "disabled state is fine at
512                // this step" the actual out-of-the-box behavior, not just
513                // documentation of an intention. WBS 1.5.3's own connect
514                // callback (`process_connect_return()` below) is now a real
515                // second way this flips to `'yes'` - see that method's own
516                // doc comment for why flipping it there, not just leaving it
517                // for the merchant to toggle afterward, is the WBS's own
518                // stated outcome, not an extra liberty this step is taking.
519                'default' => 'no',
520            ),
521            'title'       => array(
522                'title'       => __( 'Title', 'monokulo' ),
523                'type'        => 'text',
524                'description' => __( 'The label the customer sees for this payment method at checkout.', 'monokulo' ),
525                'default'     => __( 'Monero (via Monokulo)', 'monokulo' ),
526                'desc_tip'    => true,
527            ),
528            'description' => array(
529                'title'       => __( 'Description', 'monokulo' ),
530                'type'        => 'textarea',
531                'description' => __( 'The explanatory text the customer sees for this payment method at checkout.', 'monokulo' ),
532                'default'     => __( 'Pay with Monero. You will be redirected to a secure Monokulo payment page to complete your purchase.', 'monokulo' ),
533                'desc_tip'    => true,
534            ),
535
536            'connection'  => array(
537                'title'       => __( 'Connection', 'monokulo' ),
538                'type'        => 'title',
539                'description' => __( 'Click below to connect your Monero wallet through Monokulo - this fills in everything below automatically and enables this gateway.', 'monokulo' ),
540            ),
541
542            // The real WBS 1.5.3 button - see `generate_monokulo_connect_html()`'s
543            // own doc comment for exactly what it renders and why this needs
544            // a genuinely custom field type rather than reusing `'title'`
545            // (whose own `generate_title_html()` renders fixed markup this
546            // step needs to deviate from: a real `<a href=...>` built fresh
547            // on every render, not static text).
548            'connect'     => array(
549                'type'        => 'monokulo_connect',
550                'description' => __( 'You will be sent to Monokulo to sign in (or sign up) and confirm the connection, then returned here automatically.', 'monokulo' ),
551            ),
552
553            'endpoint'    => array(
554                'title'       => __( 'Monokulo address', 'monokulo' ),
555                'type'        => 'text',
556                'description' => __( 'The public address of the Monokulo instance this store uses. Filled in automatically by Connect above.', 'monokulo' ),
557                'default'     => '',
558                'placeholder' => 'https://pay.example.com',
559                'desc_tip'    => true,
560            ),
561            'public_key'  => array(
562                'title'       => __( 'Store public key', 'monokulo' ),
563                'type'        => 'text',
564                'description' => __( 'Your Monokulo store\'s public key (starts with pk_). Filled in automatically by Connect above.', 'monokulo' ),
565                'default'     => '',
566                'placeholder' => 'pk_...',
567                'desc_tip'    => true,
568            ),
569            'forward_errors' => array(
570                'title'       => __( 'Send errors to Monokulo', 'monokulo' ),
571                'type'        => 'checkbox',
572                'label'       => __( 'Also send this plugin\'s warnings and errors to Monokulo\'s Logs page', 'monokulo' ),
573                'description' => __( 'They stay in WooCommerce\'s own logs as well. Sent with your store\'s secret key, next to Monokulo\'s own lines for the same request. Monokulo keeps them only while Diagnostics is turned on in this store\'s settings there.', 'monokulo' ),
574                'default'     => 'no',
575                'desc_tip'    => true,
576            ),
577            'secret_token' => array(
578                'title'       => __( 'Store secret key', 'monokulo' ),
579                'type'        => 'password',
580                'description' => __( 'Your Monokulo store\'s secret key (starts with sk_). Filled in automatically by Connect above. Used to create orders from this site\'s server; never shown to customers.', 'monokulo' ),
581                'default'     => '',
582                'placeholder' => 'sk_...',
583                'desc_tip'    => true,
584            ),
585        );
586    }
587
588    /**
589     * Extends `WC_Payment_Gateway::is_available()` (which only checks
590     * `enabled` and a currency restriction): the gateway is only offered at
591     * checkout while it has a complete, current connection. Without one
592     * `create_monokulo_order()` would fail for the first customer to try it,
593     * so it is better not to offer it at all - including for an install
594     * connected by an older plugin version, which must reconnect first (see
595     * `CONNECTION_VERSION`).
596     *
597     * @return bool
598     */
599    public function is_available() {
600        if ( ! parent::is_available() ) {
601            return false;
602        }
603
604        return $this->has_credentials() && ! $this->needs_reconnect();
605    }
606
607    /**
608     * Whether this gateway has everything order creation needs: Monokulo's
609     * address, the store's public key and its secret key.
610     *
611     * @return bool
612     */
613    private function has_credentials() {
614        return '' !== $this->api_base_url && '' !== $this->tenant_public_key && '' !== $this->secret_token;
615    }
616
617    /**
618     * Whether this install holds a connection made before the current
619     * `CONNECTION_VERSION` and must reconnect. See that constant.
620     *
621     * @return bool
622     */
623    public function needs_reconnect() {
624        return self::settings_need_reconnect( $this->settings );
625    }
626
627    /**
628     * `needs_reconnect()` for a raw settings array (the
629     * `woocommerce_monokulo_settings` option), so `monokulo.php`'s admin
630     * notice can ask without building a gateway. A store that has never
631     * connected at all doesn't "need a reconnect"; it just isn't connected.
632     *
633     * @param mixed $settings The saved settings array, or anything else if
634     *                        nothing was ever saved.
635     * @return bool
636     */
637    public static function settings_need_reconnect( $settings ) {
638        if ( ! is_array( $settings ) ) {
639            return false;
640        }
641        $connected = ! empty( $settings['endpoint'] ) || ! empty( $settings['public_key'] ) || ! empty( $settings['secret_token'] );
642        $version   = isset( $settings['connection_version'] ) ? (string) $settings['connection_version'] : '';
643
644        return $connected && self::CONNECTION_VERSION !== $version;
645    }
646
647    /**
648     * Shows every WooCommerce manager, on every admin screen, that the
649     * store must reconnect before it can take Monero payments again. Hooked
650     * on `admin_notices` from `monokulo.php`.
651     */
652    public static function render_reconnect_notice() {
653        if ( ! current_user_can( 'manage_woocommerce' ) ) {
654            return;
655        }
656        if ( ! self::settings_need_reconnect( get_option( 'woocommerce_monokulo_settings' ) ) ) {
657            return;
658        }
659        $settings_url = admin_url( 'admin.php?page=wc-settings&tab=checkout&section=monokulo' );
660        echo '<div class="notice notice-warning"><p><strong>' .
661            esc_html__( 'Monokulo: please reconnect your Monero wallet.', 'monokulo' ) .
662            '</strong> ' .
663            esc_html__( 'This store was connected by an older version of the Monokulo plugin, which can no longer create orders. Monero payments are hidden at checkout until you reconnect.', 'monokulo' ) .
664            ' <a href="' . esc_url( $settings_url ) . '">' .
665            esc_html__( 'Reconnect now', 'monokulo' ) .
666            '</a></p></div>';
667    }
668
669    /**
670     * WooCommerce calls this - and only this - when a customer places an
671     * order with this gateway selected. Checked directly against
672     * WooCommerce's own real source, not assumed from memory:
673     *
674     * - `WC_Payment_Gateway::process_payment( $order_id )`'s own doc comment
675     *   (`includes/abstracts/abstract-wc-payment-gateway.php`) states the
676     *   contract in full: "When implemented, this should return the success
677     *   and redirect in an array", e.g.
678     *   `array( 'result' => 'success', 'redirect' => $url )`.
679     * - `WC_Gateway_BACS::process_payment()` and `WC_Gateway_COD::
680     *   process_payment()` (`includes/gateways/{bacs,cod}/class-wc-gateway-
681     *   {bacs,cod}.php`) are WooCommerce's own reference implementations of
682     *   that contract - both return exactly
683     *   `array( 'result' => 'success', 'redirect' => $this->get_return_url( $order ) )`.
684     *   This gateway deliberately does **not** call `get_return_url()`
685     *   (WooCommerce's own thank-you page) the way those two do - both of
686     *   them consider the order paid the instant `process_payment()` runs,
687     *   which is never true here. `redirect` only has to be a URL
688     *   WooCommerce's checkout JS will send the browser to, not specifically
689     *   the thank-you page - confirmed by reading `WC_Checkout::
690     *   process_order_payment()` (`includes/class-wc-checkout.php`) directly:
691     *   it reads `$result['redirect']` verbatim, either via `wp_redirect()`
692     *   (classic, non-AJAX submit) or as the `redirect` field of the JSON
693     *   `wp_send_json( $result )` sends back (AJAX/Blocks checkout) - neither
694     *   path constrains the URL's shape or origin. So `redirect` here is
695     *   Monokulo's checkout page, `GET {endpoint}/pay/{pk}/orders/{order_id}`,
696     *   off-site by design.
697     * - Failure is signaled by *throwing*, not by returning
698     *   `array( 'result' => 'fail' )` - confirmed against `WC_Checkout::
699     *   process_checkout()` directly: it calls `process_order_payment()`
700     *   (and therefore this method) from inside its own top-level
701     *   `try { ... } catch ( Exception $e ) { wc_add_notice( $e->getMessage(),
702     *   'error' ); }` block, so a thrown `Exception` becomes exactly the
703     *   customer-facing checkout error WooCommerce already knows how to
704     *   render - no bundled gateway happens to demonstrate this path (BACS/
705     *   COD never fail), but it is the real, source-confirmed mechanism this
706     *   method relies on for every failure branch below.
707     *
708     * Deliberately does **not** call `$order->payment_complete()` or change
709     * the order's status - the order WooCommerce just created is already
710     * `pending`/awaiting payment, and it should stay exactly that until a
711     * real payment is actually observed on-chain. Marking it paid here,
712     * before the customer has even reached Monokulo's checkout page,
713     * would be simply false. That transition is WBS 1.5.4's job (the webhook
714     * receiver + status mapping), not this step's - this method's only job
715     * is "hand off to Monokulo, and tell WooCommerce where to send the
716     * customer next."
717     *
718     * @param int $order_id Order ID.
719     * @return array{result: string, redirect: string}
720     * @throws Exception If the order can't be loaded, this gateway isn't
721     *                    configured, or Monokulo's order-creation call
722     *                    fails or returns something unexpected - see
723     *                    `WC_Checkout::process_checkout()`'s own catch block
724     *                    above for what WooCommerce does with it.
725     */
726    public function process_payment( $order_id ) {
727        $order = wc_get_order( $order_id );
728        if ( ! $order instanceof WC_Order ) {
729            throw new Exception( __( 'Could not load this order to start the Monero payment.', 'monokulo' ) );
730        }
731
732        $monokulo_order = $this->create_monokulo_order( $order );
733
734        // The one point in this gateway's whole flow that ever sees the
735        // mapping between a WC order and Monokulo's order_id - recorded
736        // now, while it's in hand. The webhook receiver needs exactly this
737        // lookup (an incoming delivery carries an order_id, not a WC order
738        // id) to know which order to update.
739        $order->update_meta_data( self::META_ORDER_ID, $monokulo_order['order_id'] );
740        $order->add_order_note(
741            sprintf(
742                /* translators: %s: Monokulo order_id */
743                __( 'Customer redirected to Monokulo checkout for order_id %s.', 'monokulo' ),
744                $monokulo_order['order_id']
745            )
746        );
747        $order->save();
748
749        return array(
750            'result'   => 'success',
751            'redirect' => $this->get_checkout_url( $monokulo_order['order_id'] ),
752        );
753    }
754
755    /**
756     * Creates the order on Monokulo: `POST {endpoint}/pay/{pk}/orders`
757     * (`crates/monokulo/src/http/pay.rs::create_order`) with
758     * `Authorization: Bearer {secret key}`, `Monokulo-Client: woocommerce/…`
759     * (so the order shows as the plugin's) and a JSON body of
760     * `{amount, currency, merchant_order_id}`. Monokulo prices the amount into
761     * XMR with its own exchange rate and returns
762     * `{order_id, address, xmr_amount_piconero, amount, currency,
763     * merchant_order_id, expires_at}`.
764     *
765     * Every failure throws a plain, customer-safe `Exception` (see
766     * `process_payment()` for why throwing is the contract); the detail -
767     * status code and Monokulo's own error message - is logged for the
768     * merchant via `self::log()`, never shown to the customer.
769     *
770     * @param WC_Order $order The order to create a Monokulo order for.
771     * @return array{order_id: string, address: string, xmr_amount_piconero: int,
772     *               amount: string, currency: string, expires_at: int}
773     * @throws Exception See above.
774     */
775    private function create_monokulo_order( WC_Order $order ) {
776        if ( ! $this->has_credentials() || $this->needs_reconnect() ) {
777            // Reachable even though `is_available()` should normally have
778            // kept an unusable gateway off the checkout entirely - e.g. an
779            // already-rendered checkout page submitted after the connection
780            // changed in another tab.
781            throw new Exception(
782                __( 'Monero payments are not fully configured for this store yet. Please contact the store owner.', 'monokulo' )
783            );
784        }
785
786        $request_url = $this->get_orders_endpoint_url();
787        $currency    = $order->get_currency();
788        $amount      = self::format_amount( $order->get_total(), $currency );
789
790        $body = array(
791            'amount'            => $amount,
792            'currency'          => $currency,
793            // The order's own numeric id, not `get_order_number()` - the
794            // latter is filterable (some plugins prefix it, e.g. "WC-1042")
795            // and only ever meant for human display, whereas this value
796            // exists so a merchant can match a Monokulo order back to this
797            // store's order later, and shows on Monokulo's order page.
798            'merchant_order_id' => (string) $order->get_id(),
799            // One key per order and total: a retry after a timeout or a lost
800            // answer gets the Monokulo order the first attempt made, never a
801            // second one with a second address. A changed total is a new
802            // purchase, so it gets a new key.
803            'idempotency_key'   => self::idempotency_key( $order->get_id(), $amount, $currency ),
804        );
805
806        $response = wp_remote_post(
807            $request_url,
808            array(
809                'headers' => array(
810                    'Content-Type'     => 'application/json',
811                    'Authorization'    => 'Bearer ' . $this->secret_token,
812                    'Monokulo-Client'  => self::CLIENT,
813                    'traceparent'      => self::traceparent(),
814                ),
815                'body'    => wp_json_encode( $body ),
816                // An ordinary HTTP round trip (Monokulo prices the order and
817                // asks its engine for an address); 30s is generous headroom,
818                // not a tuned value.
819                'timeout' => 30,
820            )
821        );
822
823        if ( is_wp_error( $response ) ) {
824            $this->log(
825                sprintf( 'Order creation request to %s failed: %s', $request_url, $response->get_error_message() ),
826                'error'
827            );
828            throw new Exception(
829                __( 'Could not reach Monokulo to start this payment. Please try again shortly.', 'monokulo' )
830            );
831        }
832
833        $status_code = (int) wp_remote_retrieve_response_code( $response );
834        $raw_body    = wp_remote_retrieve_body( $response );
835
836        if ( 200 !== $status_code ) {
837            $decoded_error = json_decode( $raw_body, true );
838            $detail        = is_array( $decoded_error ) && isset( $decoded_error['error'] ) ? (string) $decoded_error['error'] : $raw_body;
839            $this->log(
840                sprintf(
841                    'Order creation request to %s returned HTTP %d: %s%s',
842                    $request_url,
843                    $status_code,
844                    $detail,
845                    self::hint_for_status( $status_code )
846                ),
847                'error'
848            );
849            throw new Exception( self::customer_message_for_status( $status_code ) );
850        }
851
852        $decoded = json_decode( $raw_body, true );
853        if ( ! is_array( $decoded ) || empty( $decoded['order_id'] ) ) {
854            $this->log(
855                sprintf( 'Order creation response from %s was not the expected shape: %s', $request_url, $raw_body ),
856                'error'
857            );
858            throw new Exception(
859                __( 'Monokulo returned an unexpected response. Please contact the store.', 'monokulo' )
860            );
861        }
862
863        return $decoded;
864    }
865
866    /**
867     * The idempotency key for one WooCommerce order at one total: what
868     * makes a retried order creation return the order already made.
869     *
870     * @param int|string $order_id The WooCommerce order id.
871     * @param string     $amount   The amount sent, as formatted.
872     * @param string     $currency The currency code.
873     * @return string
874     */
875    public static function idempotency_key( $order_id, $amount, $currency ) {
876        return sprintf( 'wc:%s:%s:%s', $order_id, $amount, $currency );
877    }
878
879    /**
880     * The order total as the plain decimal string Monokulo parses: `.` as
881     * the decimal point, no thousands separator, whatever the site's locale.
882     * Fiat amounts carry two decimal places (Monokulo refuses more); an XMR
883     * amount keeps up to XMR's own twelve.
884     *
885     * @param string|float $total    The order total.
886     * @param string       $currency The order currency.
887     * @return string
888     */
889    private static function format_amount( $total, $currency ) {
890        $decimals = 2;
891        if ( 'XMR' === strtoupper( $currency ) ) {
892            $decimals = function_exists( 'wc_get_price_decimals' ) ? min( 12, max( 2, (int) wc_get_price_decimals() ) ) : 12;
893        }
894        return number_format( (float) $total, $decimals, '.', '' );
895    }
896
897    /**
898     * What the customer sees when Monokulo refuses an order - kept vague on
899     * purpose (the logged detail is for the merchant).
900     *
901     * @param int $status_code Monokulo's HTTP status.
902     * @return string
903     */
904    private static function customer_message_for_status( $status_code ) {
905        if ( 429 === $status_code ) {
906            return __( 'Monero payments are busy right now. Please wait a moment and try again.', 'monokulo' );
907        }
908        if ( 401 === $status_code || 403 === $status_code ) {
909            return __( 'Monero payments are not available for this store right now. Please contact the store owner.', 'monokulo' );
910        }
911        return __( 'Monokulo could not start this payment. Please contact the store or try again.', 'monokulo' );
912    }
913
914    /**
915     * A pointer for the merchant reading the log, for the statuses whose
916     * fix is on their side.
917     *
918     * @param int $status_code Monokulo's HTTP status.
919     * @return string
920     */
921    private static function hint_for_status( $status_code ) {
922        switch ( $status_code ) {
923            case 401:
924                return ' (the store secret key was rejected - reconnect the plugin from its settings page)';
925            case 403:
926                return ' (Monokulo refused the order - check the store\'s verified-domain settings on Monokulo)';
927            case 404:
928                return ' (Monokulo does not know this store\'s public key - reconnect the plugin)';
929            case 429:
930                return ' (rate limited - Monokulo is receiving too many orders from this store)';
931            default:
932                return '';
933        }
934    }
935
936    /**
937     * `{endpoint}/pay/{pk}/orders` - Monokulo's order-creation route.
938     * `rtrim()` absorbs a trailing slash on the stored address.
939     *
940     * @return string
941     */
942    private function get_orders_endpoint_url() {
943        return sprintf(
944            '%s/pay/%s/orders',
945            rtrim( $this->api_base_url, '/' ),
946            rawurlencode( $this->tenant_public_key )
947        );
948    }
949
950    /**
951     * `{endpoint}/pay/{pk}/orders/{order_id}` - Monokulo's checkout page for
952     * the order, where the customer is sent to pay. `rawurlencode()` on
953     * `order_id` is cheap defence against its shape ever changing.
954     *
955     * @param string $order_id Monokulo's order id, from
956     *                         `create_monokulo_order()`'s response.
957     * @return string
958     */
959    private function get_checkout_url( $order_id ) {
960        return sprintf(
961            '%s/pay/%s/orders/%s',
962            rtrim( $this->api_base_url, '/' ),
963            rawurlencode( $this->tenant_public_key ),
964            rawurlencode( $order_id )
965        );
966    }
967
968    /**
969     * The control plane's own base URL, run through
970     * `monokulo_control_plane_base_url` - a filter, not just the bare
971     * constant, for two concrete reasons: it's what lets this plugin's own
972     * tests point `process_connect_return()`/`generate_monokulo_connect_html()`
973     * at a fake control plane (`pre_http_request` alone can't substitute for
974     * the *browser-facing* `GET /connect/...` URL a test never actually
975     * fetches, only asserts the shape of - so the base URL itself has to be
976     * swappable, not just the HTTP call), and it's the one real, sanctioned
977     * escape hatch for anyone running their own control plane instance
978     * (a self-hoster who wants the one-click flow against infrastructure
979     * they run themselves, rather than ours) without forking this file.
980     *
981     * @return string
982     */
983    private function get_control_plane_base_url() {
984        return apply_filters( 'monokulo_control_plane_base_url', self::CONTROL_PLANE_BASE_URL );
985    }
986
987    /**
988     * The WordPress mechanism chosen for `return_url` - the URL the control
989     * plane's browser redirect (step 3 of `connect.rs`'s own module doc
990     * comment) has to land on, on *this* site, in a shape this plugin can
991     * recognize and handle.
992     *
993     * Reasoned from WordPress's own documented conventions for exactly this
994     * shape of problem ("an external service redirects the browser back into
995     * a plugin's own admin screen, plugin does some server-side work, then
996     * sends the browser on"), not invented fresh - three real WordPress
997     * mechanisms fit:
998     *
999     * 1. **A dedicated `admin-post.php?action=...` handler** (chosen here).
1000     *    WordPress core's own documented pattern for "a link or button
1001     *    triggers a server-side action, which redirects somewhere when
1002     *    done" - `wp-admin/admin-post.php` fires `do_action(
1003     *    "admin_post_{$_GET['action']}" )` for a logged-in user (the exact
1004     *    case here - the merchant is coming back into a wp-admin session
1005     *    they were already in when they clicked "Connect"), runs inside the
1006     *    ordinary wp-admin bootstrap (so `current_user_can()`, `admin_url()`,
1007     *    `wp_safe_redirect()` all just work with no extra setup), and needs
1008     *    no new URL-routing surface beyond a plain `add_action()` call - the
1009     *    same shape this class already uses for
1010     *    `woocommerce_update_options_payment_gateways_{$this->id}`.
1011     * 2. **A query var checked on this gateway's own settings-page render.**
1012     *    Rejected: it would mean the connect flow's actual credential-saving
1013     *    side effect happens as an incidental part of *rendering a page*
1014     *    (`generate_monokulo_connect_html()`/`admin_options()`), which is
1015     *    the wrong place for a state-changing action to live - a page render
1016     *    can be triggered more than once (a refresh, a prefetch) in ways a
1017     *    dedicated action handler isn't, and mixing "display the form" with
1018     *    "consume a single-use token" is exactly the kind of GET-with-side-
1019     *    effects WordPress's own admin-notices/admin-post split exists to
1020     *    avoid.
1021     * 3. **A REST API route** (`register_rest_route`). A legitimate
1022     *    alternative real WooCommerce/WordPress extensions do use for this -
1023     *    rejected only because it's strictly more machinery than this step
1024     *    needs (a new namespace, permission callback, route registration)
1025     *    for a URL that only ever has to be recognized by *this* site's own
1026     *    logged-in browser session, which `admin-post.php` already handles
1027     *    with a single `add_action()` call.
1028     *
1029     * This is also exactly the shape real "Connect" style WooCommerce
1030     * gateway plugins are documented to use for their own OAuth-return
1031     * handlers (Stripe's and PayPal's own WooCommerce extensions both route
1032     * their connect callbacks through an `admin-post.php` action rather than
1033     * a REST route or a settings-page query var) - reasoned here from
1034     * WordPress's own documented `admin-post.php` mechanism directly, per
1035     * this step's own brief, since this environment has no way to fetch
1036     * their source live to confirm byte-for-byte.
1037     *
1038     * @return string
1039     */
1040    private function get_connect_return_url() {
1041        return admin_url( 'admin-post.php?action=' . self::CONNECT_RETURN_ACTION );
1042    }
1043
1044    /**
1045     * The transient key this gateway's own single-use connect nonce is
1046     * stored under between `generate_monokulo_connect_html()` (which mints
1047     * it) and `process_connect_return()` (which consumes it exactly once).
1048     * A WordPress transient, not a plain `option`: it's meant to expire on
1049     * its own (`10 * MINUTE_IN_SECONDS`, chosen to match `connect.rs`'s own
1050     * `CONNECT_TOKEN_TTL_SECONDS` exactly - the nonce only ever needs to
1051     * survive the same round trip the connect token itself does, so there's
1052     * no reason for it to outlive that token), which is precisely what
1053     * `set_transient()`'s own expiry argument is for and a plain `option`
1054     * has no built-in equivalent of.
1055     *
1056     * @return string
1057     */
1058    private function connect_nonce_transient_key() {
1059        return 'monokulo_connect_nonce_' . $this->id;
1060    }
1061
1062    /**
1063     * Builds the real `GET {control_plane_base_url}/connect/{platform}?...`
1064     * URL (step 1 of `connect.rs`'s own module doc comment) this gateway
1065     * sends the merchant's browser to.
1066     *
1067     * @param string $nonce A freshly generated, single-use nonce - the
1068     *                       caller is responsible for also having stored it
1069     *                       (`connect_nonce_transient_key()`) before handing
1070     *                       this URL out, so `process_connect_return()` has
1071     *                       something real to check the redirect-back
1072     *                       `nonce` against.
1073     * @return string
1074     */
1075    private function build_connect_start_url( $nonce ) {
1076        return sprintf(
1077            '%s/connect/%s?site_url=%s&return_url=%s&nonce=%s',
1078            rtrim( $this->get_control_plane_base_url(), '/' ),
1079            rawurlencode( self::CONNECT_PLATFORM ),
1080            rawurlencode( home_url( '/' ) ),
1081            rawurlencode( $this->get_connect_return_url() ),
1082            rawurlencode( $nonce )
1083        );
1084    }
1085
1086    /**
1087     * Renders the "Connect your Monero wallet" settings-screen field - a
1088     * genuinely custom `WC_Settings_API` field type (`'monokulo_connect'`,
1089     * dispatched to this method by `generate_settings_html()`'s own
1090     * `method_exists( $this, 'generate_' . $type . '_html' )` check, read
1091     * directly against `abstract-wc-settings-api.php` before writing this),
1092     * not a reuse of the built-in `'title'` type: `generate_title_html()`
1093     * renders fixed, static markup from `$data['title']`/`$data['description']`
1094     * alone, but this field has to build a real `<a href>` fresh on every
1095     * render (a new nonce, a new transient, a URL that depends on whether a
1096     * wallet is already connected) - none of which a static type could do.
1097     *
1098     * Mints a fresh nonce **on every render of this field**, deliberately
1099     * not in the constructor (which every gateway instantiation on every
1100     * front-end/admin request also runs, via `WC_Payment_Gateways::init()`
1101     * on `woocommerce_init` - minting and overwriting the one live nonce on
1102     * every unrelated page load would routinely invalidate an
1103     * already-in-flight connect attempt before the merchant even got back).
1104     * This method only ever runs when this gateway's own settings screen is
1105     * actually being rendered (`WC_Settings_Page::output()` calling
1106     * `$gateway->admin_options()` for the matching `section`), which is
1107     * exactly the point a fresh nonce is actually needed.
1108     *
1109     * @param string $key  Field key (`'connect'`).
1110     * @param array  $data Field data from `init_form_fields()`.
1111     * @return string
1112     */
1113    public function generate_monokulo_connect_html( $key, $data ) {
1114        $field_key = $this->get_field_key( $key );
1115
1116        // Same default-filling `generate_text_html()` itself does (read
1117        // directly) before ever touching `$data['desc_tip']`/
1118        // `$data['description']` - `init_form_fields()`'s own `'connect'`
1119        // entry only sets `type`/`description`, and `get_description_html()`
1120        // below indexes `$data['desc_tip']` unconditionally, which would be
1121        // an undefined-array-key warning on every real render without this.
1122        $data = wp_parse_args(
1123            $data,
1124            array(
1125                'desc_tip'    => false,
1126                'description' => '',
1127            )
1128        );
1129
1130        $nonce = bin2hex( random_bytes( 16 ) );
1131        set_transient( $this->connect_nonce_transient_key(), $nonce, 10 * MINUTE_IN_SECONDS );
1132        $connect_url = $this->build_connect_start_url( $nonce );
1133
1134        ob_start();
1135        ?>
1136        <tr valign="top">
1137            <th scope="row" class="titledesc">
1138                <label for="<?php echo esc_attr( $field_key ); ?>"><?php esc_html_e( 'Connect your Monero wallet', 'monokulo' ); ?></label>
1139            </th>
1140            <td class="forminp">
1141                <?php if ( $this->needs_reconnect() ) : ?>
1142                    <p><strong><?php esc_html_e( 'Reconnect needed: this store was connected by an older plugin version, so Monero payments are hidden at checkout until you reconnect.', 'monokulo' ); ?></strong></p>
1143                <?php elseif ( $this->has_credentials() ) : ?>
1144                    <p>
1145                        <?php esc_html_e( 'Connected as', 'monokulo' ); ?>
1146                        <code><?php echo esc_html( $this->tenant_public_key ); ?></code>
1147                    </p>
1148                <?php endif; ?>
1149                <a href="<?php echo esc_url( $connect_url ); ?>" id="<?php echo esc_attr( $field_key ); ?>" class="button button-primary">
1150                    <?php
1151                    echo $this->has_credentials()
1152                        ? esc_html__( 'Reconnect your Monero wallet', 'monokulo' )
1153                        : esc_html__( 'Connect your Monero wallet', 'monokulo' );
1154                    ?>
1155                </a>
1156                <?php echo $this->get_description_html( $data ); // WPCS: XSS ok. ?>
1157            </td>
1158        </tr>
1159        <?php
1160        return ob_get_clean();
1161    }
1162
1163    /**
1164     * `'connect'`'s own `type` (`monokulo_connect`) has no real form input
1165     * for `process_admin_options()` to read back on save - it only ever
1166     * renders a link. Without this method, `get_field_value()` would fall
1167     * through to `validate_text_field()` on a `null` POST value (checked
1168     * directly, `abstract-wc-settings-api.php::get_field_value()`), which is
1169     * harmless but pointless (nothing should ever read a
1170     * `monokulo_connect` settings key) and, on PHP 8.1+, a `null`-to-string
1171     * coercion warning waiting to happen the moment that fallback path's own
1172     * implementation changes. Returning `''` directly is the explicit,
1173     * intentional no-op this field type actually needs - the *real* save
1174     * path for this field's effect is `process_connect_return()` below, not
1175     * WooCommerce's ordinary settings-form POST at all.
1176     *
1177     * @param string $key   Field key.
1178     * @param mixed  $value Posted value (always unused here).
1179     * @return string Always `''`.
1180     */
1181    public function validate_monokulo_connect_field( $key, $value ) {
1182        return '';
1183    }
1184
1185    /**
1186     * The real `admin_post_{action}` handler WordPress core dispatches to
1187     * (see `get_connect_return_url()`'s own doc comment for why
1188     * `admin-post.php` is the mechanism at all). Deliberately just two
1189     * lines: everything that can actually be tested without WordPress's own
1190     * `exit` getting in the way lives in `process_connect_return()` below,
1191     * which this method calls and then acts on. `wp_safe_redirect()` +
1192     * `exit` immediately after is WordPress core's own documented way to end
1193     * an `admin-post.php` handler - `exit` specifically because a handler
1194     * that fell through to WordPress's own further output after redirecting
1195     * would corrupt the response, not because of anything specific to this
1196     * plugin.
1197     *
1198     * The `current_user_can( 'manage_woocommerce' )` guard exists because
1199     * `admin_post_{action}` (unlike this gateway's own settings screen,
1200     * which WooCommerce's own menu registration already restricts to that
1201     * capability) fires for **any** logged-in user, regardless of role -
1202     * defense in depth on top of the nonce check in
1203     * `process_connect_return()`, which is this leg's real CSRF defense
1204     * (see that method's own doc comment), against a logged-in
1205     * low-privilege account (e.g. a customer with a WordPress login) hitting
1206     * this URL directly rather than a genuine admin who initiated the flow.
1207     */
1208    public function handle_connect_return() {
1209        if ( ! current_user_can( 'manage_woocommerce' ) ) {
1210            wp_die( esc_html__( 'You do not have permission to do this.', 'monokulo' ), '', array( 'response' => 403 ) );
1211        }
1212
1213        $redirect_url = $this->process_connect_return( wp_unslash( $_GET ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
1214        wp_safe_redirect( $redirect_url );
1215        exit;
1216    }
1217
1218    /**
1219     * The real logic behind steps 3-4 of `connect.rs`'s own module doc
1220     * comment: the merchant's browser has just landed back on this site
1221     * carrying `token`/`nonce` query params, and this method has to decide
1222     * whether to trust them, redeem the token for real credentials, and (on
1223     * success) save them and enable the gateway - the WBS's own stated
1224     * outcome for this step ("the gateway becomes enabled"), not left for
1225     * the merchant to separately toggle afterward.
1226     *
1227     * **Split out of `handle_connect_return()` above specifically so this is
1228     * directly unit-testable**: no `wp_safe_redirect()`/`exit` in here, just
1229     * a pure(-ish) function of "the query args a request carried" ->
1230     * "the URL to send the browser to next" (plus the real side effect of
1231     * saving settings on success) - `tests/ConnectFlowTest.php` calls this
1232     * method directly, the same way `tests/ProcessPaymentTest.php` calls
1233     * `process_payment()` directly rather than driving a real HTTP request
1234     * through WordPress's own router.
1235     *
1236     * **The nonce check - the actual CSRF defense this whole leg has**:
1237     * `return_url` is a public, guessable URL (it's this exact method's own
1238     * `admin-post.php` address); anyone could, in principle, cause a
1239     * logged-in merchant's browser to `GET` it with an arbitrary `token`.
1240     * What they can't do is know the *nonce* this plugin itself generated
1241     * and stored server-side in `connect_nonce_transient_key()` at the
1242     * moment the *real* connect attempt started - so a mismatched, missing,
1243     * or already-consumed (the transient is deleted immediately, win or
1244     * lose - single use, not single check) nonce means this request did not
1245     * originate from a connect flow this plugin itself kicked off, and is
1246     * rejected before ever calling `/finish` at all, exactly per this
1247     * step's own brief ("nonce mismatch must be rejected, not silently
1248     * accepted"). `hash_equals()`, not `===`, for the same constant-time
1249     * reasoning `docs/WOOCOMMERCE_WBS.md` already calls out for WBS 1.5.4's
1250     * webhook signature check - a nonce is shorter-lived and lower-value
1251     * than an HMAC key, but there's no real cost to using the safe
1252     * comparison here too, and no argument for `===` being the least bit
1253     * simpler.
1254     *
1255     * **On a failed `/finish` call**: no setting is touched at all - an
1256     * already-connected merchant whose *reconnect* attempt fails (expired
1257     * token, control plane hiccup, etc.) keeps whatever credentials they had
1258     * before, rather than this method clobbering good settings with a
1259     * failure.
1260     *
1261     * @param array $query_args The request's GET query parameters (`token`,
1262     *                            `nonce`), already `wp_unslash()`ed by the
1263     *                            caller - accepted as a plain array, not read
1264     *                            from `$_GET` directly, purely so a test can
1265     *                            hand this method an arbitrary array without
1266     *                            needing to fake superglobals.
1267     * @return string The URL to send the merchant's browser to next - always
1268     *                 this gateway's own settings screen, with a
1269     *                 `monokulo_connected` or
1270     *                 `monokulo_connect_error` flag appended for
1271     *                 `maybe_render_connect_notice()` below to read.
1272     */
1273    public function process_connect_return( array $query_args ) {
1274        $token = isset( $query_args['token'] ) ? sanitize_text_field( $query_args['token'] ) : '';
1275        $nonce = isset( $query_args['nonce'] ) ? sanitize_text_field( $query_args['nonce'] ) : '';
1276
1277        $stored_nonce = get_transient( $this->connect_nonce_transient_key() );
1278        // Single-use regardless of outcome, per this method's own doc
1279        // comment - a stale or already-checked nonce must never be
1280        // re-checkable.
1281        delete_transient( $this->connect_nonce_transient_key() );
1282
1283        if ( '' === $token || '' === $nonce || false === $stored_nonce || ! hash_equals( (string) $stored_nonce, $nonce ) ) {
1284            $this->log( 'Connect return rejected: missing token, or the nonce did not match what this site generated.', 'warning' );
1285            return $this->build_settings_url( array( 'monokulo_connect_error' => 'nonce' ) );
1286        }
1287
1288        $finish_status = 0;
1289        $finish        = $this->call_connect_finish( $token, $finish_status );
1290        if ( null === $finish ) {
1291            // 503: Monokulo isn't ready to connect plugins (no public address
1292            // yet) - worth its own message, since retrying later can work.
1293            $reason = 503 === $finish_status ? 'unavailable' : 'finish';
1294            return $this->build_settings_url( array( 'monokulo_connect_error' => $reason ) );
1295        }
1296
1297        $this->update_option( 'endpoint', $finish['endpoint'] );
1298        $this->update_option( 'public_key', $finish['public_key'] );
1299        $this->update_option( 'secret_token', $finish['secret_token'] );
1300        if ( ! empty( $finish['webhook_signing_secret'] ) ) {
1301            // Deliberately not a declared `form_fields` entry - see
1302            // `init_form_fields()`'s own comment on why. `update_option()`
1303            // (checked directly, `WC_Settings_API::update_option()`) writes
1304            // into `$this->settings[$key]` by plain string key regardless of
1305            // whether that key was ever declared, so this is a real,
1306            // persisted write, not a no-op.
1307            $this->update_option( 'webhook_signing_secret', $finish['webhook_signing_secret'] );
1308        }
1309        // The WBS's own stated outcome for this step: connecting a wallet
1310        // enables the gateway outright, not just fills in fields for the
1311        // merchant to separately flip a switch on afterward.
1312        $this->update_option( 'enabled', 'yes' );
1313        // Marks this connection as made by this plugin version, whose
1314        // `endpoint` is Monokulo's own address - see `CONNECTION_VERSION`.
1315        $this->update_option( 'connection_version', self::CONNECTION_VERSION );
1316
1317        return $this->build_settings_url( array( 'monokulo_connected' => '1' ) );
1318    }
1319
1320    /**
1321     * `POST {control_plane_base_url}/connect/{platform}/finish` (step 4-5 of
1322     * `connect.rs`'s own module doc comment / its `finish` handler, read
1323     * directly): redeems the single-use connect token for real credentials.
1324     *
1325     * **`webhook_url` is sent, deliberately, even though this plugin has no
1326     * webhook-receiving route yet** (that's WBS 1.5.4's job entirely - see
1327     * `get_webhook_receiver_url()`'s own doc comment). Chosen over omitting
1328     * it, for a reason parallel to why `$secret_token` is stored now despite
1329     * nothing reading it yet: `mock-woocommerce::run_connect_flow` - "1.4.2's
1330     * already-proven logic" this step's own brief explicitly says to mirror
1331     * - always registers a webhook as part of the very same connect flow
1332     * (confirmed directly, `mock-woocommerce/src/lib.rs::run_connect_flow_with`,
1333     * not assumed); there is no "connect without a webhook" variant of the
1334     * already-proven protocol to mirror instead. Registering now, pointed at
1335     * a URL nothing yet answers, means WBS 1.5.4 only has to *build the
1336     * receiver* - not also force every merchant who already connected under
1337     * 1.5.3 back through the whole browser-redirect flow a second time just
1338     * to register a webhook that could have been registered the first time
1339     * for free. This is safe to do before 1.5.4 exists: checked directly
1340     * against `src/http/admin.rs::create_webhook` - registration only
1341     * validates the URL's scheme (`http`/`https`), never reachability (SSRF
1342     * validation is explicitly deferred to delivery time, per that
1343     * handler's own comment), so a webhook pointed at a route that
1344     * doesn't respond yet fails to *register* for. Deliveries against it
1345     * will simply fail (and retry, per the engine's own delivery-worker
1346     * policy) until 1.5.4 lands - an acceptable, self-healing gap for a
1347     * feature whose receiver is being built in the very next step of the
1348     * same project, not a real production outage window.
1349     *
1350     * Returns `null` on any failure (transport error, non-200, or a response
1351     * missing any of the three fields this plugin actually needs) - this
1352     * method never throws, unlike `create_monokulo_order()`: this isn't inside
1353     * WooCommerce's own checkout `try`/`catch`, so there's no framework
1354     * mechanism here to catch an `Exception` and turn it into a user-facing
1355     * message; the caller (`process_connect_return()`) is responsible for
1356     * turning a `null` into the right redirect + notice instead.
1357     *
1358     * @param string $token       The single-use connect token from the
1359     *                            `return_url` redirect's own `token` query
1360     *                            param.
1361     * @param int    $status_code Set to the HTTP status `/finish` answered
1362     *                            with (0 on a transport failure), so the
1363     *                            caller can tell "not ready yet" (503) apart.
1364     * @return array{public_key: string, secret_token: string, endpoint: string,
1365     *               webhook_signing_secret?: string}|null
1366     */
1367    private function call_connect_finish( $token, &$status_code = 0 ) {
1368        $url = rtrim( $this->get_control_plane_base_url(), '/' ) . '/connect/' . self::CONNECT_PLATFORM . '/finish';
1369
1370        $response = wp_remote_post(
1371            $url,
1372            array(
1373                'headers' => array(
1374                    'Content-Type'    => 'application/json',
1375                    // Monokulo records the connection with this plugin's version.
1376                    'Monokulo-Client' => self::CLIENT,
1377                    'traceparent'     => self::traceparent(),
1378                ),
1379                'body'    => wp_json_encode(
1380                    array(
1381                        'token'       => $token,
1382                        'webhook_url' => $this->get_webhook_receiver_url(),
1383                    )
1384                ),
1385                // Same generous, untuned headroom `create_monokulo_order()`
1386                // above uses, for the same reason: an ordinary HTTP round
1387                // trip with no reason to be slow, not a value chosen from
1388                // measurement.
1389                'timeout' => 30,
1390            )
1391        );
1392
1393        if ( is_wp_error( $response ) ) {
1394            $this->log( sprintf( 'Connect finish request to %s failed: %s', $url, $response->get_error_message() ), 'error' );
1395            return null;
1396        }
1397
1398        $status_code = (int) wp_remote_retrieve_response_code( $response );
1399        $raw_body    = wp_remote_retrieve_body( $response );
1400
1401        // A bare 401 is the shape of every token-related failure
1402        // `connect.rs::finish` can produce (unknown/expired/already-consumed
1403        // token, a rejected webhook URL, an internal error). The one other
1404        // status is 503 with a JSON `error`: Monokulo has no public address
1405        // configured yet, so it refuses to hand out one. The caller turns
1406        // that into its own notice; the message is logged either way.
1407        if ( 200 !== $status_code ) {
1408            $this->log( sprintf( 'Connect finish request to %s returned HTTP %d: %s', $url, $status_code, $raw_body ), 'error' );
1409            return null;
1410        }
1411
1412        $decoded = json_decode( $raw_body, true );
1413        if ( ! is_array( $decoded ) || empty( $decoded['public_key'] ) || empty( $decoded['secret_token'] ) || empty( $decoded['endpoint'] ) ) {
1414            $this->log( sprintf( 'Connect finish response from %s was not the expected shape: %s', $url, $raw_body ), 'error' );
1415            return null;
1416        }
1417
1418        return $decoded;
1419    }
1420
1421    /**
1422     * The webhook URL this plugin's own (not-yet-built, WBS 1.5.4) receiver
1423     * will eventually answer on - `WC()->api_request_url( $this->id )`,
1424     * WooCommerce's own real, documented mechanism for a plugin's
1425     * `woocommerce_api_{id}` endpoint (confirmed directly,
1426     * `class-woocommerce.php::api_request_url()`, before relying on it - it
1427     * resolves to `{site}/wc-api/{id}/` with pretty permalinks or
1428     * `{site}/?wc-api={id}` without, never anything this plugin has to
1429     * construct by hand). Used only by `call_connect_finish()` above - see
1430     * that method's own doc comment for why registering it now, before
1431     * 1.5.4 builds anything to answer it, is deliberate and safe.
1432     *
1433     * @return string
1434     */
1435    private function get_webhook_receiver_url() {
1436        return WC()->api_request_url( $this->id );
1437    }
1438
1439    /**
1440     * The real `woocommerce_api_{id}` hook target (see the constructor's own
1441     * comment on why this specific mechanism, checked directly against
1442     * WooCommerce's installed source) - WordPress core dispatches to this
1443     * for every incoming delivery to `get_webhook_receiver_url()`'s own URL.
1444     *
1445     * Kept to the same "two lines, all real logic elsewhere" shape
1446     * `handle_connect_return()` above already established, for the identical
1447     * reason: `process_webhook_request()` below is directly unit-testable
1448     * (no PHP superglobals, no `exit`), which this thin wrapper is not - see
1449     * `tests/WebhookReceiverTest.php`'s own class doc comment for how it
1450     * drives this hook's real logic without going through an actual HTTP
1451     * request.
1452     *
1453     * Reads the request body via `file_get_contents( 'php://input' )` -
1454     * **never** `$_POST` - because the signature this method hands to
1455     * `process_webhook_request()` has to be verified against the *exact raw
1456     * bytes* the engine signed (`shared/src/webhook_sign.rs::sign_payload`
1457     * hashes `payload: &[u8]` as sent, read directly). `$_POST` doesn't even
1458     * apply here (the engine sends a raw JSON body, not
1459     * `application/x-www-form-urlencoded`/multipart form fields, so PHP
1460     * never populates `$_POST` for this request at all) - but even if it
1461     * did, going through it would mean verifying a signature against a
1462     * *re-serialized* body PHP itself reconstructed, which can differ from
1463     * the original byte-for-byte (key order, whitespace, numeric formatting)
1464     * even when it decodes to the same logical JSON - exactly the "even
1465     * whitespace-identical-looking JSON can byte-differ after a decode/
1466     * re-encode round trip" failure mode this step's own brief calls out by
1467     * name. `php://input` is the one source that can't have been touched by
1468     * anything in between.
1469     *
1470     * The signature header is read from `$_SERVER` (see
1471     * `WEBHOOK_SIGNATURE_SERVER_KEY`'s own doc comment for the
1472     * `X-Monokulo-Signature` -> `HTTP_X_MONOKULO_SIGNATURE` transform) and
1473     * `wp_unslash()`-ed - the same treatment `handle_connect_return()` above
1474     * already gives `$_GET`, for the identical reason: WordPress's own
1475     * `wp_magic_quotes()` (`wp-includes/load.php`, read directly) backslash-
1476     * escapes `$_SERVER` exactly like `$_GET`/`$_POST`/`$_COOKIE`, not just
1477     * the superglobals an HTML form would populate.
1478     *
1479     * `status_header()`, not `wp_die()`: unlike `handle_connect_return()`'s
1480     * `wp_die()` (whose HTML error page is for a *human* who followed a bad
1481     * link), the only consumer of this response is the engine's own
1482     * delivery worker, which reads nothing but the HTTP status code -
1483     * `attempt_delivery()` in `src/webhook_delivery.rs` (read directly)
1484     * decides `delivered: status.is_success()` and otherwise reschedules a
1485     * retry with backoff, never inspecting the body at all. A bare status
1486     * code is therefore the entire real contract this endpoint has to
1487     * honor; `wp_die()`'s HTML-page machinery would be pure overhead no
1488     * caller of this endpoint ever looks at.
1489     */
1490    public function handle_webhook() {
1491        $raw_body  = (string) file_get_contents( 'php://input' );
1492        $signature = isset( $_SERVER[ self::WEBHOOK_SIGNATURE_SERVER_KEY ] )
1493            ? (string) wp_unslash( $_SERVER[ self::WEBHOOK_SIGNATURE_SERVER_KEY ] ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
1494            : '';
1495        // The engine sends the delivery attempt's trace; the lines this
1496        // webhook logs join it.
1497        self::adopt_traceparent( isset( $_SERVER['HTTP_TRACEPARENT'] ) ? (string) wp_unslash( $_SERVER['HTTP_TRACEPARENT'] ) : '' ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- checked against a strict pattern.
1498
1499        status_header( $this->process_webhook_request( $raw_body, $signature ) );
1500        exit;
1501    }
1502
1503    /**
1504     * The real logic behind an incoming webhook delivery: verify, decode,
1505     * find the order, dedupe, apply. Split out of `handle_webhook()` above
1506     * for the same reason `process_connect_return()` is split out of
1507     * `handle_connect_return()` - directly testable, no request
1508     * superglobals or `exit` involved, a pure(-ish) function of "the raw
1509     * body and header this request carried" -> "the HTTP status code to
1510     * respond with" (plus the real side effect, on success, of updating the
1511     * matched order).
1512     *
1513     * **The four response codes, and why each one specifically** (per this
1514     * step's own brief: "decide the right status code... and document why"):
1515     *
1516     * - **200**: signature verified, payload well-formed, a matching order
1517     *   was found, and the event was applied (or was already applied before
1518     *   - see `event_already_applied()`'s own doc comment for why a
1519     *   duplicate delivery is *also* a 200, not some other code). This is
1520     *   the only status `run_delivery_tick` in `src/webhook_delivery.rs`
1521     *   treats as `delivered: true` - anything else means the engine's
1522     *   delivery worker will retry with backoff (`backoff_seconds()`, read
1523     *   directly) until `max_attempts` is reached, per that file's own doc
1524     *   comment on `run_delivery_tick`.
1525     * - **401 Unauthorized**: signature missing or invalid. Mirrors this
1526     *   *engine's own* convention for exactly this situation - `ApiError::
1527     *   Unauthorized => (StatusCode::UNAUTHORIZED, ...)` in `src/http/
1528     *   mod.rs`, read directly - rather than inventing a different
1529     *   convention on the PHP side for what is, semantically, the identical
1530     *   fact ("this request did not prove it knows the shared secret").
1531     *   **No order lookup or mutation ever happens before this check passes**
1532     *   - the whole point of verifying first is that everything downstream
1533     *   can trust `order_id` came from the engine, not from anyone who
1534     *   found this URL.
1535     * - **400 Bad Request**: the signature is valid but the body isn't the
1536     *   envelope shape `enqueue_webhook_event()` in `src/scanner.rs` always
1537     *   produces (`event`/`event_id`/`order_id`, read directly - every
1538     *   event this engine ever sends has all three). A signed-but-malformed
1539     *   body is a client error on the sender's side, not a "we don't
1540     *   recognize this order" case (404) or an auth failure (401) - 400 is
1541     *   the one of the three that actually means "your request, not our
1542     *   data, is the problem," matching ordinary REST convention.
1543     * - **404 Not Found**: a well-formed, correctly-signed event for a
1544     *   `order_id` with no matching order *on this WordPress site*.
1545     *   Mirrors the engine's own `ApiError::NotFound => StatusCode::
1546     *   NOT_FOUND` convention for "the referenced resource doesn't exist
1547     *   here" (`src/http/mod.rs`, same file as above) - chosen deliberately
1548     *   over a 2xx "swallow it silently" response, because an unknown
1549     *   `order_id` reaching a *correctly-signed* request (it already
1550     *   passed the 401 check, so this webhook's `signing_secret` really is
1551     *   this site's) is a real operational fact worth an operator noticing
1552     *   in logs - a stale webhook registration surviving a database reset,
1553     *   or (in principle) a site's webhook secret having leaked - not
1554     *   something to hide by pretending success. Also deliberately not
1555     *   treated as a reason to retry forever: since `process_payment()`
1556     *   always creates the engine-side order and records its `order_id`
1557     *   (`META_ORDER_ID`) *before* that order can possibly generate any
1558     *   webhook event at all, "the order doesn't exist yet, try again
1559     *   later" is not a real race this endpoint has to accommodate - an
1560     *   unknown `order_id` here means "never will," not "not yet."
1561     *
1562     * @param string $raw_body  The exact raw request body bytes, unmodified.
1563     * @param string $signature The `X-Monokulo-Signature` header value, or
1564     *                            `''` if the header was absent.
1565     * @return int The HTTP status code to respond with.
1566     */
1567    public function process_webhook_request( $raw_body, $signature ) {
1568        if ( ! $this->verify_webhook_signature( $raw_body, $signature ) ) {
1569            $this->log( 'Webhook rejected: missing or invalid X-Monokulo-Signature.', 'warning' );
1570            return 401;
1571        }
1572
1573        $event = json_decode( $raw_body, true );
1574        if ( ! is_array( $event )
1575            || empty( $event['event'] ) || ! is_string( $event['event'] )
1576            || empty( $event['event_id'] ) || ! is_string( $event['event_id'] )
1577            || empty( $event['order_id'] ) || ! is_string( $event['order_id'] )
1578        ) {
1579            $this->log( sprintf( 'Webhook rejected: correctly signed but not the expected envelope shape: %s', $raw_body ), 'error' );
1580            return 400;
1581        }
1582
1583        $order = $this->find_order_by_order_id( $event['order_id'] );
1584        if ( ! $order instanceof WC_Order ) {
1585            $this->log(
1586                sprintf( 'Webhook for order_id %s (event %s) has no matching order on this site.', $event['order_id'], $event['event_id'] ),
1587                'warning'
1588            );
1589            return 404;
1590        }
1591
1592        if ( $this->event_already_applied( $order, $event['event_id'] ) ) {
1593            // Already handled - see event_already_applied()'s own doc
1594            // comment for why this is still a 200, not a rejection.
1595            return 200;
1596        }
1597
1598        $this->apply_webhook_event( $order, $event );
1599        $this->mark_event_applied( $order, $event['event_id'] );
1600        $order->save();
1601
1602        return 200;
1603    }
1604
1605    /**
1606     * How far a delivery's signed timestamp may be from this site's clock,
1607     * either way, for it to be accepted: the same five minutes as
1608     * `SIGNATURE_TOLERANCE_SECS` in `shared/src/webhook_sign.rs`.
1609     */
1610    const SIGNATURE_TOLERANCE_SECS = 300;
1611
1612    /**
1613     * Verifies an `X-Monokulo-Signature` header, `t=<unix time>,v1=<hex>`:
1614     * the time must be within `SIGNATURE_TOLERANCE_SECS` of now, and the
1615     * tag must be HMAC-SHA256, keyed by this gateway's stored
1616     * `webhook_signing_secret`, of `"<time>.<raw body>"`. The time is signed
1617     * with the body, so a captured delivery can't be replayed later.
1618     *
1619     * **`hash_equals()`, never `===`** - this step's own brief states the
1620     * requirement explicitly, and the reasoning is worth restating here, in
1621     * this codebase's own words, rather than only citing the rule: PHP's
1622     * `===` string comparison (like Rust's plain `String`/`&str` equality)
1623     * short-circuits at the first differing byte, so the *time* a rejection
1624     * takes leaks *how many leading bytes were already correct* - the
1625     * textbook byte-at-a-time forgery oracle. `shared/src/webhook_sign.rs::
1626     * verify_signature`'s own doc comment (read directly) states this exact
1627     * concern for the Rust side, which uses `Mac::verify_slice` (backed by
1628     * `subtle`'s constant-time `ct_eq`) for the identical reason; `hash_
1629     * equals()` is PHP's own standard-library equivalent - a comparison
1630     * whose running time does not depend on where or whether the two inputs
1631     * first differ, specifically documented (php.net) as existing for
1632     * "comparing a string to a value the user is not supposed to know",
1633     * which is exactly this situation (an attacker submitting a guessed
1634     * signature against this public endpoint).
1635     *
1636     * **Compared as hex strings, not decoded to raw bytes first** - a
1637     * deliberate difference from the Rust side, not an inconsistency:
1638     * `verify_signature` in `shared/src/webhook_sign.rs` decodes the
1639     * presented hex to raw tag bytes before `verify_slice`, because Rust's
1640     * standard library has no built-in constant-time string comparison to
1641     * reach for directly. PHP's `hash_equals()` *is* a constant-time byte
1642     * comparison, and works identically whether the two strings being
1643     * compared happen to be hex text or raw bytes - comparing the lowercase
1644     * hex `hash_hmac()` already returns is the direct, standard, officially
1645     * documented PHP idiom for verifying an HMAC (the `hash_equals()` manual
1646     * page's own canonical example does exactly this), so there is no
1647     * decode-first step to add here that would buy anything Rust's own
1648     * decode-then-compare doesn't already get for free out of `hash_
1649     * equals()`.
1650     *
1651     * Cross-checked against `shared/src/webhook_sign.rs`'s own fixed
1652     * known-vector test constants in `tests/WebhookSignatureTest.php` - see
1653     * that file for the exact secret/payload/time/header this method must
1654     * accept.
1655     *
1656     * @param string   $raw_body  The exact raw request body bytes.
1657     * @param string   $signature The presented `X-Monokulo-Signature` value.
1658     * @param int|null $now       The current unix time; `time()` if null.
1659     * @return bool
1660     */
1661    private function verify_webhook_signature( $raw_body, $signature, $now = null ) {
1662        if ( '' === $this->webhook_signing_secret || ! is_string( $signature )
1663            || ! preg_match( '/\At=(\d{1,18}),v1=([0-9a-fA-F]{64})\z/', $signature, $parts ) ) {
1664            // No configured secret (gateway never connected, or an admin
1665            // blanked the field by hand), or no well-formed header: an
1666            // unauthenticated request, rejected before any HMAC runs.
1667            return false;
1668        }
1669        if ( abs( ( null === $now ? time() : (int) $now ) - (int) $parts[1] ) > self::SIGNATURE_TOLERANCE_SECS ) {
1670            // Signed too long ago (a replay) or too far ahead.
1671            return false;
1672        }
1673
1674        $expected = hash_hmac( 'sha256', $parts[1] . '.' . $raw_body, $this->webhook_signing_secret );
1675
1676        return hash_equals( $expected, strtolower( $parts[2] ) );
1677    }
1678
1679    /**
1680     * Finds the `WC_Order` an incoming webhook's `order_id` refers to, by
1681     * querying for the `META_ORDER_ID` meta key `process_payment()`
1682     * writes - via `wc_get_orders()` (WooCommerce's own real order-query
1683     * abstraction, `WC_Order_Query` under the hood), **not** a raw SQL query
1684     * against `wp_postmeta`/the HPOS order-meta tables directly, because
1685     * WooCommerce's order storage backend is itself pluggable (the legacy
1686     * custom-post-type store, or High-Performance Order Storage's own
1687     * dedicated tables) and only its own query API is guaranteed to work
1688     * against whichever one a given site actually has active.
1689     *
1690     * **The exact `meta_key`/`meta_value` args used here, confirmed to work
1691     * on *both* backends by reading the real installed source, not
1692     * assumed**: `WC_Data_Store_WP::get_wp_query_args()`
1693     * (`includes/data-stores/class-wc-data-store-wp.php`) passes any
1694     * unrecognized top-level query var - `meta_key`/`meta_value` included -
1695     * straight through to `WP_Query` verbatim, which is what the legacy
1696     * CPT store's `query()` ultimately runs; that store's own `query()`
1697     * (`includes/data-stores/class-wc-order-data-store-cpt.php`) explicitly
1698     * flags a *`meta_query`*-shaped arg (the array form) as `$unsupported_
1699     * args` and fires a `doing_it_wrong()` notice for it - so the array form
1700     * would have been the wrong choice here even though it also happens to
1701     * work on HPOS. On the HPOS side, `OrdersTableQuery::` (`src/Internal/
1702     * DataStores/Orders/OrdersTableQuery.php`) builds its own internal
1703     * meta-query mechanism from exactly this same top-level `meta_key`/
1704     * `meta_value`/`meta_compare` "shortcut" shape (read directly - it
1705     * folds `$this->args['meta_key']`/`['meta_value']` into a `$shortcut_
1706     * meta_query` before handing off to `OrdersTableMetaQuery`), the same
1707     * WP_Query-style convention the legacy store also understands. The
1708     * plain `meta_key`/`meta_value` pair used below is therefore the one
1709     * shape genuinely portable across both backends - not a guess, and not
1710     * the `meta_query` array shape WooCommerce's own CPT store explicitly
1711     * warns is CPT-unsupported.
1712     *
1713     * @param string $order_id The engine's own order id from the webhook
1714     *                            payload.
1715     * @return WC_Order|null
1716     */
1717    private function find_order_by_order_id( $order_id ) {
1718        $orders = wc_get_orders(
1719            array(
1720                'meta_key'   => self::META_ORDER_ID, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
1721                'meta_value' => $order_id, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
1722                'limit'      => 1,
1723                'return'     => 'objects',
1724            )
1725        );
1726
1727        return isset( $orders[0] ) && $orders[0] instanceof WC_Order ? $orders[0] : null;
1728    }
1729
1730    /**
1731     * Whether `$event_id` has already been applied to `$order` -
1732     * `META_APPLIED_EVENT_IDS` order meta, a JSON-encoded array checked with
1733     * `in_array()`.
1734     *
1735     * **Why this exists at all**: `run_delivery_tick` in `src/webhook_
1736     * delivery.rs` (read directly) retries a delivery on anything short of
1737     * a 2xx response, with exponential backoff, up to `max_attempts` - and,
1738     * separately, any real HTTP delivery can also succeed on the engine's
1739     * side while its response is lost in transit (a network blip between
1740     * this server sending `200` and the engine receiving it), which the
1741     * engine's own retry logic cannot distinguish from an outright failure.
1742     * Either way, this receiver *will* see the same `event_id` more than
1743     * once for a perfectly healthy delivery, not just a broken one - and
1744     * `enqueue_webhook_event()` in `src/scanner.rs` (read directly) mints
1745     * exactly one fresh `event_id` per real status *transition*, baking it
1746     * into the stored payload once so every retry of that delivery resends
1747     * the identical id under the identical signature. Skipping a
1748     * already-seen `event_id` is what makes re-applying it a no-op instead
1749     * of, for example, appending the same order note twice or re-running
1750     * `payment_complete()`'s stock-reduction side effects a second time.
1751     *
1752     * @param WC_Order $order    The order to check.
1753     * @param string   $event_id The webhook envelope's own `event_id`.
1754     * @return bool
1755     */
1756    private function event_already_applied( WC_Order $order, $event_id ) {
1757        $applied = json_decode( (string) $order->get_meta( self::META_APPLIED_EVENT_IDS ), true );
1758        return is_array( $applied ) && in_array( $event_id, $applied, true );
1759    }
1760
1761    /**
1762     * Records `$event_id` as applied to `$order` - see
1763     * `event_already_applied()`'s own doc comment for why this exists.
1764     * Appends to the existing list rather than replacing it (an order sees
1765     * at most a handful of transitions over its real lifetime - the seven
1766     * `order.<status>` events plus, rarely, the two double-spend ones - so
1767     * unbounded growth is not a real concern here the way it might be for a
1768     * key logging every request an endpoint ever received).
1769     *
1770     * Deliberately does **not** call `$order->save()` - the caller
1771     * (`process_webhook_request()`) saves once, after every mutation this
1772     * event implies has been applied to the in-memory order object,
1773     * matching this class's existing pattern elsewhere (e.g.
1774     * `process_payment()`'s own single `$order->save()` after both
1775     * `update_meta_data()` and `add_order_note()`).
1776     *
1777     * @param WC_Order $order    The order to record against.
1778     * @param string   $event_id The webhook envelope's own `event_id`.
1779     */
1780    private function mark_event_applied( WC_Order $order, $event_id ) {
1781        $applied   = json_decode( (string) $order->get_meta( self::META_APPLIED_EVENT_IDS ), true );
1782        $applied   = is_array( $applied ) ? $applied : array();
1783        $applied[] = $event_id;
1784        $order->update_meta_data( self::META_APPLIED_EVENT_IDS, wp_json_encode( array_values( array_unique( $applied ) ) ) );
1785    }
1786
1787    /**
1788     * Applies one already-verified, already-deduped webhook event to
1789     * `$order` - the real status-mapping/note-writing decision for every
1790     * event type this engine actually sends.
1791     *
1792     * **The real event catalog, checked directly against every call site in
1793     * `src/scanner.rs`** (grepped for `"order\.` rather than assumed from
1794     * this step's own brief alone) - `enqueue_webhook_event()` is called
1795     * from exactly two places: `recompute_and_notify_in_tx()` (one
1796     * `order.<status>` event per actual status *transition*, for all seven
1797     * `OrderStatus` variants - `src/status.rs`, read directly, confirmed
1798     * exhaustive) and `void_and_notify()`/`unvoid_as_false_positive()`
1799     * (the two double-spend events). Nine event types total; this method
1800     * handles all nine, not just `order.paid`.
1801     *
1802     * **Why the two double-spend events never set a WC status themselves**:
1803     * both `void_and_notify()` and `unvoid_as_false_positive()` (read
1804     * directly, `src/scanner.rs`) call `recompute_and_notify_in_tx()` -
1805     * which enqueues its own `order.<status>` event under its own fresh
1806     * `event_id` whenever the status genuinely changed - *before* enqueueing
1807     * their own `order.double_spend_detected`/`order.double_spend_reversed`
1808     * event, in the same database transaction. That means any real status
1809     * consequence of a void or an un-void is **already** announced,
1810     * correctly, by its own paired `order.<status>` event - handled by
1811     * `apply_order_status_event()` below like any other transition. A
1812     * double-spend event forcing some *second*, independently-guessed
1813     * status change here would either duplicate that (if the two events are
1814     * both delivered and processed) or actively fight it (since delivery
1815     * order between two independently-retried webhook rows is not
1816     * guaranteed - this receiver could see `double_spend_reversed` before
1817     * or after its paired `order.<status>` event). This is also exactly
1818     * what makes "recomputed, not hardcoded back to processing regardless
1819     * of amount" (this step's own brief, for `double_spend_reversed`
1820     * specifically) true *for free*: the real recomputation already
1821     * happened engine-side, against the engine's own authoritative ledger,
1822     * and is delivered as an honest, independent event - this method's only
1823     * job for both double-spend events is to make sure the merchant
1824     * *notices* (a prominent order note), never to re-derive or second-guess
1825     * a status this receiver has no authoritative data to recompute anyway
1826     * (the double-spend payloads carry no amount/confirmation data at all -
1827     * only `order_id`, plus `txid` for the reversal).
1828     *
1829     * @param WC_Order $order The already-matched order.
1830     * @param array    $event The decoded webhook envelope (`event`,
1831     *                         `event_id`, `order_id`, plus whatever
1832     *                         event-specific fields that event type carries).
1833     */
1834    private function apply_webhook_event( WC_Order $order, array $event ) {
1835        $event_type = $event['event'];
1836
1837        $status_by_event_type = array(
1838            'order.pending'     => 'pending',
1839            'order.unconfirmed' => 'unconfirmed',
1840            'order.confirming'  => 'confirming',
1841            'order.paid'        => 'paid',
1842            'order.partial'     => 'partial',
1843            'order.overpaid'    => 'overpaid',
1844            'order.expired'     => 'expired',
1845        );
1846
1847        if ( isset( $status_by_event_type[ $event_type ] ) ) {
1848            $this->apply_order_status_event( $order, $status_by_event_type[ $event_type ] );
1849            return;
1850        }
1851
1852        if ( 'order.double_spend_detected' === $event_type ) {
1853            // No status change here - see this method's own doc comment for
1854            // why. Just the loud note a merchant needs to actually notice
1855            // the fraud case happened, per this step's own brief.
1856            $order->add_order_note(
1857                __(
1858                    'Monokulo: FRAUD ALERT - a payment previously credited to this order was proven to be a double-spend and has been voided by the engine. If this changed the order\'s payment status, that change was announced separately. Please review this order.',
1859                    'monokulo'
1860                )
1861            );
1862            return;
1863        }
1864
1865        if ( 'order.double_spend_reversed' === $event_type ) {
1866            $txid = isset( $event['txid'] ) && is_string( $event['txid'] ) ? $event['txid'] : __( 'unknown', 'monokulo' );
1867            // Likewise no status change here - see this method's own doc
1868            // comment: any real status consequence of the reversal was
1869            // already announced via its own order.<status> event.
1870            $order->add_order_note(
1871                sprintf(
1872                    /* translators: %s: the transaction id the earlier double-spend accusation was about */
1873                    __(
1874                        'Monokulo: an earlier double-spend accusation against this order (transaction %s) was a false positive and has been reversed by the engine. If this changed the order\'s payment status, that change was announced separately.',
1875                        'monokulo'
1876                    ),
1877                    $txid
1878                )
1879            );
1880            return;
1881        }
1882
1883        // An event type this receiver doesn't recognize - logged, never
1884        // fatal. Forward-compatible with a future engine version adding a
1885        // new event type this plugin hasn't been updated for yet; the
1886        // delivery still gets a 200 (it was received and understood as far
1887        // as the envelope goes) rather than looping the engine's retry
1888        // worker forever over something a retry can never fix.
1889        $this->log( sprintf( 'Webhook: unrecognized event type "%s" - envelope accepted, no action taken.', $event_type ), 'warning' );
1890    }
1891
1892    /**
1893     * Applies one `order.<status>` transition event - the engine -> WooCommerce
1894     * status mapping this whole step exists to build, and the reasoning
1895     * behind every row of it.
1896     *
1897     * **`paid`/`overpaid` -> `WC_Order::payment_complete()`, not a plain
1898     * `update_status()` call** (confirmed directly against the installed
1899     * `includes/class-wc-order.php::payment_complete()`, not assumed from
1900     * its name): this is WooCommerce's own canonical, real "a payment has
1901     * been received" mechanism - every bundled and third-party gateway that
1902     * actually receives real money calls this, not a raw status setter. It
1903     * does meaningfully more than set a string: it sets `date_paid` (once,
1904     * the first time it's ever called - `maybe_set_date_paid()`, read
1905     * directly), reduces stock, records a transaction id, fires `woocommerce_
1906     * payment_complete` for every other plugin that hooks it (subscriptions
1907     * renewals, stock-alert plugins, accounting integrations), and -
1908     * critically - **decides `processing` vs. `completed` itself**, via
1909     * `$this->needs_processing()`: an order containing only virtual/
1910     * downloadable items goes straight to `completed` (nothing left for the
1911     * merchant to fulfill), while anything else goes to `processing`. This
1912     * plugin has no principled way to know, order by order, whether that
1913     * distinction applies - hardcoding `processing` unconditionally (which
1914     * this step's own outcome text literally says, "ends with the WC order
1915     * in processing/completed") would be *wrong* for exactly the
1916     * downloadable-goods case that same outcome text's "/completed" already
1917     * anticipates. `payment_complete()` is also idempotent by construction
1918     * (gated on `$this->has_status( OrderStatus::PAYMENT_COMPLETE_STATUSES )`
1919     * - `pending`/`on-hold`/`failed`/`cancelled` - read directly), so a
1920     * duplicate call (which `event_already_applied()`'s dedupe should
1921     * already prevent, but this is real defense in depth, not redundant
1922     * caution) on an order already `processing`/`completed` is a safe no-op,
1923     * not a second stock reduction or a second `date_paid` write.
1924     *
1925     * **`unconfirmed`/`confirming` -> `on-hold`**: both mean "the engine has
1926     * seen the full amount, but doesn't yet trust it enough to treat the
1927     * order as paid" (`src/status.rs::derive_status`, read directly) -
1928     * `unconfirmed` is mempool-only, `confirming` is mined but below the
1929     * tenant's required confirmation depth. WooCommerce's own `on-hold`
1930     * status ("Awaiting payment confirmation" per its own `wc_get_order_
1931     * statuses()` label) is a near-verbatim match for both, and - just as
1932     * importantly - `on-hold` does **not** reduce stock the way `processing`/
1933     * `completed` do (confirmed via `payment_complete()`'s own gate above,
1934     * which only that method's success path ever reaches), which is exactly
1935     * right: nothing should be reserved as sold for a payment this server
1936     * doesn't yet trust. Collapsing both into the same WC bucket rather than
1937     * inventing a distinct status for each is deliberate - a merchant acts
1938     * on these identically ("wait"), and WooCommerce has no built-in status
1939     * more specific than `on-hold` to distinguish them with anyway.
1940     *
1941     * **`partial` -> `on-hold`**, not `pending`: `pending` (a fresh order
1942     * that's never received a single piconero) and `partial` (real,
1943     * on-chain-or-mempool funds have arrived, just not enough of them) are
1944     * genuinely different situations for a merchant - the former needs
1945     * nothing from anyone yet, the latter may need a human to eventually
1946     * decide what to do about an underpayment (there is no automated refund
1947     * path anywhere in this system - `docs/DESIGN.md` Â§3, and `status.rs`'s
1948     * own module doc comment cites the same fact). `on-hold` is
1949     * WooCommerce's own general "needs a look" bucket for exactly this kind
1950     * of ambiguity, and the order note added below says the actual reason,
1951     * so a merchant scanning their on-hold queue isn't left guessing which
1952     * of several possible causes put a given order there.
1953     *
1954     * **`expired` -> `cancelled`**: WooCommerce's own status for "this order
1955     * did not complete and is not going to" - the direct match for an order
1956     * whose payment window has closed with insufficient funds. Per `status.
1957     * rs`'s own comment (read directly): "even a partial payment past the
1958     * deadline surfaces as expired - the funds still exist at the address
1959     * and require manual merchant handling, since no automated refund path
1960     * exists" - restated in this transition's own order note below so that
1961     * fact isn't buried in a status label alone.
1962     *
1963     * **`pending`**: maps to WooCommerce's own `pending` - normally a no-op
1964     * (a fresh order is already `pending`), but reachable as a genuine
1965     * *regression* if a double-spend void removes an order's only payment
1966     * entirely (`derive_status`'s own `total == 0` branch). This is
1967     * deliberately not special-cased away: the webhook is reporting the
1968     * engine's own current, authoritative truth, and an order that no
1969     * longer has any valid payment genuinely isn't paid anymore, however
1970     * unusual that regression is in practice.
1971     *
1972     * Every branch's `$note` is handed to `WC_Order::update_status( $status,
1973     * $note )`, not a separate `add_order_note()` call - confirmed directly
1974     * (`includes/class-wc-order.php::add_status_transition_note()`) that
1975     * WooCommerce folds this text into the *same* order note as its own
1976     * auto-generated "Order status changed from X to Y." (`trim( $transition
1977     * ['note'] . ' ' . $note )`), and skips adding any note at all when the
1978     * status doesn't actually change (e.g. `unconfirmed` following
1979     * `confirming`, both mapping to `on-hold`) - exactly right, since a
1980     * same-bucket transition has nothing new to tell the merchant.
1981     *
1982     * @param WC_Order $order         The already-matched order.
1983     * @param string   $engine_status One of `src/status.rs::OrderStatus`'s
1984     *                                 seven `as_str()` values.
1985     */
1986    private function apply_order_status_event( WC_Order $order, $engine_status ) {
1987        if ( 'paid' === $engine_status || 'overpaid' === $engine_status ) {
1988            if ( 'overpaid' === $engine_status ) {
1989                // payment_complete() has no note parameter of its own (it
1990                // writes a fixed "Payment via ..." note) - this is added as
1991                // a separate note specifically to flag the one fact that
1992                // method can't express: the customer sent more than was
1993                // due, and nothing in this plugin or the engine refunds the
1994                // excess automatically.
1995                $order->add_order_note(
1996                    __(
1997                        'Monokulo: the customer sent more Monero than the amount due. The excess is not automatically refunded - handle any refund manually.',
1998                        'monokulo'
1999                    )
2000                );
2001            }
2002            $order->payment_complete();
2003            return;
2004        }
2005
2006        if ( ! isset( self::STATUS_MAP[ $engine_status ] ) ) {
2007            // Defensive only - every OrderStatus variant is covered by
2008            // either this branch or the paid/overpaid one above, so this
2009            // should be unreachable for a real engine payload. Logged, not
2010            // fatal, for the same forward-compatibility reason unrecognized
2011            // event types are in apply_webhook_event() above.
2012            $this->log( sprintf( 'Webhook: unrecognized engine status "%s" - no WooCommerce status change applied.', $engine_status ), 'warning' );
2013            return;
2014        }
2015
2016        $note = '';
2017        if ( 'partial' === $engine_status ) {
2018            $note = __( 'Monokulo: a partial payment was received - the full amount due has not yet arrived.', 'monokulo' );
2019        } elseif ( 'expired' === $engine_status ) {
2020            $note = __(
2021                'Monokulo: this order\'s payment window has closed without full payment. If any funds were received, they remain at the payment address - there is no automatic refund; handle manually.',
2022                'monokulo'
2023            );
2024        }
2025
2026        $order->update_status( self::STATUS_MAP[ $engine_status ], $note );
2027    }
2028
2029    /**
2030     * This gateway's own settings screen URL
2031     * (`admin.php?page=wc-settings&tab=checkout&section={id}` -
2032     * WooCommerce's standard, stable routing for a single gateway's settings
2033     * panel), with `$extra_args` appended - used by `process_connect_return()`
2034     * to build the redirect target that also carries the
2035     * success/error flag `maybe_render_connect_notice()` reads.
2036     *
2037     * @param array $extra_args Query args to append, e.g.
2038     *                           `array( 'monokulo_connected' => '1' )`.
2039     * @return string
2040     */
2041    private function build_settings_url( array $extra_args ) {
2042        return add_query_arg(
2043            $extra_args,
2044            admin_url( 'admin.php?page=wc-settings&tab=checkout&section=' . $this->id )
2045        );
2046    }
2047
2048    /**
2049     * Flashes a one-line success/error notice on this gateway's own settings
2050     * screen right after a connect attempt redirects back to it - reads the
2051     * plain query-string flags `process_connect_return()` appends
2052     * (`build_settings_url()`) rather than using `WC_Admin_Settings::
2053     * add_error()`, which only accumulates messages within the single
2054     * request that renders the settings form and has no mechanism to carry
2055     * a message across the separate `admin-post.php` request/redirect this
2056     * callback is. This is the same "flag in the redirect target, read back
2057     * on the next page load" pattern real WordPress admin screens
2058     * (including WooCommerce's own core settings pages) use for exactly
2059     * this situation.
2060     *
2061     * Scoped to this gateway's own settings screen specifically (checked via
2062     * `$_GET['page']`/`$_GET['section']`) so this notice never appears on an
2063     * unrelated admin screen just because these query flags happen to still
2064     * be present in the URL (e.g. a bookmarked/shared link).
2065     */
2066    public function maybe_render_connect_notice() {
2067        if ( ! isset( $_GET['page'], $_GET['section'] ) || 'wc-settings' !== $_GET['page'] || $this->id !== $_GET['section'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
2068            return;
2069        }
2070
2071        if ( isset( $_GET['monokulo_connected'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
2072            echo '<div class="notice notice-success is-dismissible"><p>' .
2073                esc_html__( 'Your Monero wallet is connected - Monero payments are now enabled.', 'monokulo' ) .
2074                '</p></div>';
2075        } elseif ( isset( $_GET['monokulo_connect_error'] ) && 'unavailable' === $_GET['monokulo_connect_error'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
2076            echo '<div class="notice notice-error is-dismissible"><p>' .
2077                esc_html__( 'Monokulo is not ready to connect plugins yet: its operator has not set its public address. Please try again later.', 'monokulo' ) .
2078                '</p></div>';
2079        } elseif ( isset( $_GET['monokulo_connect_error'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
2080            echo '<div class="notice notice-error is-dismissible"><p>' .
2081                esc_html__( 'Could not connect your Monero wallet. Please try again.', 'monokulo' ) .
2082                '</p></div>';
2083        }
2084    }
2085
2086    /**
2087     * This PHP request's trace id, made up on first use.
2088     *
2089     * @return string 32 lowercase hex characters.
2090     */
2091    public static function trace_id() {
2092        if ( null === self::$trace_id ) {
2093            self::$trace_id = bin2hex( random_bytes( 16 ) );
2094        }
2095        return self::$trace_id;
2096    }
2097
2098    /**
2099     * Joins the trace named by an incoming `traceparent` header (version 00
2100     * only, the one this plugin also sends). Anything else is ignored and
2101     * the request keeps its own trace.
2102     *
2103     * @param string $traceparent The header value, `''` if absent.
2104     * @return bool Whether the trace was adopted.
2105     */
2106    public static function adopt_traceparent( $traceparent ) {
2107        if ( ! preg_match( '/^00-([0-9a-f]{32})-([0-9a-f]{16})-[0-9a-f]{2}$/D', trim( (string) $traceparent ), $parts ) ) {
2108            return false;
2109        }
2110        if ( str_repeat( '0', 32 ) === $parts[1] || str_repeat( '0', 16 ) === $parts[2] ) {
2111            return false;
2112        }
2113        self::$trace_id = $parts[1];
2114        return true;
2115    }
2116
2117    /**
2118     * A `traceparent` header value for one outgoing call: this request's
2119     * trace, a new span id for the call, sampled.
2120     *
2121     * @return string
2122     */
2123    public static function traceparent() {
2124        return '00-' . self::trace_id() . '-' . bin2hex( random_bytes( 8 ) ) . '-01';
2125    }
2126
2127    /**
2128     * Thin wrapper around `wc_get_logger()` - guarded by `function_exists()`
2129     * only for the same reason the rest of this class never fatals outside a
2130     * real WooCommerce context, not because a live WooCommerce install (the
2131     * only place `process_payment()` can actually run) might lack it.
2132     *
2133     * @param string $message Log message. Deliberately never includes raw
2134     *                         customer/order PII beyond what's already an
2135     *                         order note-worthy fact (order id, order_id).
2136     * @param string $level   A `WC_Log_Levels` level ('info'|'error'|...).
2137     */
2138    private function log( $message, $level = 'info' ) {
2139        $this->maybe_forward( $message, $level );
2140        if ( function_exists( 'wc_get_logger' ) ) {
2141            // The trace id goes in the message too: WooCommerce's file log
2142            // handler writes only the message, not the context.
2143            $trace_id = self::trace_id();
2144            wc_get_logger()->log(
2145                $level,
2146                $message . ' [trace ' . $trace_id . ']',
2147                array(
2148                    'source'   => 'monokulo',
2149                    'trace_id' => $trace_id,
2150                )
2151            );
2152        }
2153    }
2154
2155    /**
2156     * Queues a warning or error for Monokulo when "Send errors to
2157     * Monokulo" is on and the store is connected, and registers the send
2158     * for the end of this PHP request (once), so a page never waits on it.
2159     *
2160     * @param string $message Log message.
2161     * @param string $level   A `WC_Log_Levels` level.
2162     */
2163    private function maybe_forward( $message, $level ) {
2164        if ( 'yes' !== $this->get_option( 'forward_errors', 'no' ) || ! $this->has_credentials() ) {
2165            return;
2166        }
2167        if ( ! in_array( $level, self::FORWARDED_LEVELS, true ) || count( self::$forward_queue ) >= self::MAX_FORWARDED ) {
2168            return;
2169        }
2170        self::$forward_queue[] = array(
2171            'level'    => $level,
2172            'message'  => (string) $message,
2173            'trace_id' => self::trace_id(),
2174        );
2175        if ( ! self::$forward_registered ) {
2176            self::$forward_registered = true;
2177            $url    = rtrim( $this->api_base_url, '/' ) . '/pay/' . rawurlencode( $this->tenant_public_key ) . '/logs';
2178            $secret = $this->secret_token;
2179            register_shutdown_function(
2180                function () use ( $url, $secret ) {
2181                    WC_Gateway_Monokulo::flush_forwarded( $url, $secret );
2182                }
2183            );
2184        }
2185    }
2186
2187    /**
2188     * Sends the queued entries in one request that isn't waited for. A
2189     * failure is ignored: logging it would only queue another entry.
2190     *
2191     * @param string $url    Monokulo's `/pay/{pk}/logs` URL.
2192     * @param string $secret The store's secret key.
2193     * @return array|null The request arguments sent (for tests), or null if nothing was queued.
2194     */
2195    public static function flush_forwarded( $url, $secret ) {
2196        if ( empty( self::$forward_queue ) ) {
2197            return null;
2198        }
2199        $args                = array(
2200            'headers'  => array(
2201                'Content-Type'  => 'application/json',
2202                'Authorization' => 'Bearer ' . $secret,
2203                'traceparent'   => self::traceparent(),
2204            ),
2205            'body'     => wp_json_encode( array( 'entries' => self::$forward_queue ) ),
2206            'timeout'  => 3,
2207            'blocking' => false,
2208        );
2209        self::$forward_queue = array();
2210        wp_remote_post( $url, $args );
2211        return $args;
2212    }
2213}

Branches

Below are the source code lines that represent each code branch as identified by Xdebug. Please note a branch is not necessarily coterminous with a line, a line may contain multiple branches and therefore show up more than once. Please also be aware that some branches may be implicit rather than explicit, e.g. an if statement always has an else as part of its logical flow even if you didn't write one.

WC_Gateway_Monokulo->__construct
300        $this->id = 'monokulo';
301
302        // The Monokulo mark beside the payment method at checkout: the square
303        // version, shipped with the plugin so the shopper's browser loads it
304        // from the store, not from monokulo. Written by `cargo xtask logo`.
305        $this->icon = plugins_url( 'assets/monokulo-icon.svg', dirname( __DIR__ ) . '/monokulo.php' );
306
307        // This gateway never renders its own fields inline at WooCommerce's
308        // checkout - per `docs/WOOCOMMERCE_ROADMAP.md` Stage 7's explicit
309        // integration choice, paying with Monero means redirecting the
310        // customer to Monokulo's own checkout page
311        // (`/pay/{pk}/orders/{order_id}`), not embedding a new widget into
312        // WooCommerce's checkout form. `has_fields = false` is
313        // what tells WooCommerce's checkout template not to reserve any
314        // inline space for this gateway.
315        $this->has_fields = false;
316
317        // `method_title`/`method_description` are what a merchant sees on
318        // WooCommerce's own "Payments" settings list (Settings > Payments) -
319        // distinct from `title`/`description` below, which is what the
320        // *customer* sees at checkout. Conflating the two is a common
321        // mistake in WooCommerce gateway plugins; kept deliberately separate
322        // here even though nothing yet enforces the distinction functionally,
323        // because getting it right from the first version avoids a later
324        // "why does the merchant admin screen say something meant for
325        // customers" bug report.
326        $this->method_title       = __( 'Monokulo', 'monokulo' );
327        $this->method_description = __(
328            'Accept Monero at checkout without ever holding customer funds or your own spend key - Monokulo watches the chain for you and only ever needs a view key. Connect your wallet to enable this gateway for real.',
329            'monokulo'
330        );
331
332        // `supports` is WooCommerce's own mechanism for a gateway to declare
333        // which of its many optional checkout/order features it implements
334        // (refunds, subscriptions, pre-orders, tokenization, etc.) -
335        // WooCommerce's core checkout and admin-order-screen code both
336        // consult this array before offering a feature (e.g. it won't show
337        // a "Refund" button for a gateway that hasn't declared `refunds`),
338        // rather than assuming every gateway implements everything.
339        // `'products'` is the baseline every ordinary one-time-payment
340        // gateway declares (WooCommerce's own bundled gateways all include
341        // it) - it means "this gateway works for a cart containing ordinary
342        // products", as opposed to gateways that only make sense for
343        // recurring subscriptions. Nothing else is declared here
344        // deliberately: refunds specifically would be a false claim right
345        // now (there is no refund flow) and no other `supports` value is
346        // developed as of WBS 1.5.1's own scope.
347        $this->supports = array( 'products' );
348
349        // Populates `$this->form_fields`, the schema `init_settings()` (next)
350        // reads defaults from and WooCommerce's own settings-page renderer
351        // walks to draw the actual HTML form.
352        $this->init_form_fields();
353
354        // Loads whatever's actually been saved in `wp_options` into
355        // `$this->settings`, falling back to each field's own `default` from
356        // `init_form_fields()` above for anything never saved yet (e.g. a
357        // brand new install). This has to run *after* `init_form_fields()`
358        // - there'd be nothing to load defaults from otherwise - and
359        // *before* the `get_option()` calls immediately following it, which
360        // read out of the `$this->settings` array this call populates.
361        $this->init_settings();
362
363        // `title`/`description` are the customer-facing checkout strings -
364        // pulled from saved settings when present, otherwise the same
365        // literal WBS 1.5.1's own acceptance outcome names verbatim
366        // ("Monero (via Monokulo)"), so a fresh install already shows
367        // the right label with zero configuration, while still letting a
368        // merchant override the wording later without a code change.
369        $this->title       = $this->get_option( 'title', __( 'Monero (via Monokulo)', 'monokulo' ) );
370        $this->description = $this->get_option(
371            'description',
372            __( 'Pay with Monero. You will be redirected to a secure Monokulo payment page to complete your purchase.', 'monokulo' )
373        );
374
375        // The connection the connect flow saved. `trim()`, not just a raw
376        // `get_option()` read: a value pasted by hand into the settings form
377        // notoriously picks up leading/trailing whitespace, and an
378        // un-trimmed `pk_...\n` would fail `create_monokulo_order()`'s
379        // outbound call in a way that looks identical to a genuinely wrong
380        // key.
381        $this->api_base_url     = trim( (string) $this->get_option( 'endpoint', '' ) );
382        $this->tenant_public_key = trim( (string) $this->get_option( 'public_key', '' ) );
383        $this->secret_token      = trim( (string) $this->get_option( 'secret_token', '' ) );
384        $this->webhook_signing_secret = trim( (string) $this->get_option( 'webhook_signing_secret', '' ) );
385
386        // No explicit `$this->enabled = $this->get_option( 'enabled' )` line
387        // here, deliberately - checked directly against WooCommerce's own
388        // source (`WC_Payment_Gateway::init_settings()`,
389        // includes/abstracts/abstract-wc-payment-gateway.php) rather than
390        // assumed: that method, called just above, already sets
391        // `$this->enabled` itself from `$this->settings['enabled']`
392        // (`'yes' === $this->settings['enabled'] ? 'yes' : 'no'`) - a second
393        // assignment here would be pure duplication, not a safety net. This
394        // matches the pattern WooCommerce's own bundled gateways use (e.g.
395        // `WC_Gateway_BACS`'s constructor never touches `$this->enabled`
396        // either, for the identical reason - confirmed by reading
397        // `includes/gateways/bacs/class-wc-gateway-bacs.php` directly).
398        //
399        // What actually makes this gateway disabled by default is the
400        // `enabled` field's own `'default' => 'no'` in `init_form_fields()`
401        // below, which `init_settings()` falls back to for a fresh install
402        // with nothing saved yet - and that default is not an oversight this
403        // step left for later, it is WBS 1.5.1's own stated outcome
404        // ("disabled state is fine at this step"). A gateway with no
405        // connected wallet has no `pk_`/`sk_`/`endpoint` to call yet (that
406        // only exists once WBS 1.5.3's connect flow runs), so registering it
407        // pre-enabled would let a merchant offer a payment method that WBS
408        // 1.5.2's `process_payment()` cannot yet honor - exactly the failure
409        // mode a disabled-by-default gateway avoids.
410
411        // The standard WooCommerce hook a gateway registers so that
412        // submitting its own settings form (Settings > Payments > this
413        // gateway) actually persists - WooCommerce fires
414        // `woocommerce_update_options_payment_gateways_{$this->id}` on save,
415        // and `process_admin_options()` (inherited from `WC_Payment_Gateway`,
416        // not overridden here - its default behavior of validating and
417        // saving `$this->form_fields` against submitted POST data is exactly
418        // right for this step's plain checkbox/text fields) is what actually
419        // writes the new values back to `wp_options`. Without this line, the
420        // settings form would render correctly but silently discard every
421        // change on submit.
422        add_action( 'woocommerce_update_options_payment_gateways_' . $this->id, array( $this, 'process_admin_options' ) );
423
424        // The real connect-flow callback (WBS 1.5.3) - see
425        // `get_connect_return_url()`'s own doc comment for why
426        // `admin_post_{action}` specifically is the right WordPress
427        // mechanism for this, and `handle_connect_return()`'s for why
428        // registering it here in the constructor is safe (identical
429        // reasoning to the `woocommerce_update_options_...` registration
430        // immediately above: this constructor only ever runs once
431        // WooCommerce's own `WC_Payment_Gateways::init()` has already fired,
432        // which happens on every request including the one `admin-post.php`
433        // itself bootstraps).
434        add_action( 'admin_post_' . self::CONNECT_RETURN_ACTION, array( $this, 'handle_connect_return' ) );
435
436        // The real webhook receiver (WBS 1.5.4) - WooCommerce's own real,
437        // documented mechanism for a plugin's webhook endpoint, confirmed
438        // directly against the *installed* WooCommerce source before relying
439        // on it (not just its doc comments): `WC()->api_request_url( $id )`
440        // (already used by `get_webhook_receiver_url()` above, since 1.5.3)
441        // builds a URL carrying a `wc-api={id}` query var (or `/wc-api/{id}/`
442        // under pretty permalinks); `src/Internal/Utilities/
443        // LegacyRestApiStub.php::maybe_process_wc_api_query_var()` (hooked on
444        // `parse_request`, read directly) is what actually dispatches that
445        // var, firing `do_action( 'woocommerce_api_' . $api_request )` for
446        // *any* site that has this hook registered - regardless of whether
447        // the separate, full "WooCommerce Legacy REST API" extension is
448        // installed. That file's own doc comment explains why the stub
449        // exists at all: the versioned `/wc-api/v1-3/...` REST endpoints
450        // were genuinely removed from core in WC 9.0, but the plain
451        // `woocommerce_api_{id}` gateway-callback mechanism (what PayPal's
452        // own legacy IPN handler still uses too, per
453        // `wc-deprecated-functions.php::woocommerce_legacy_paypal_ipn()`,
454        // read directly) was deliberately kept working without it - this is
455        // real, current, installed-source-confirmed behavior on this
456        // environment's WooCommerce 11.1.0, not an assumption carried over
457        // from an older WooCommerce version where the whole legacy API was
458        // still in core wholesale.
459        add_action( 'woocommerce_api_' . $this->id, array( $this, 'handle_webhook' ) );
460
461        // Flashes a one-line success/error notice on this gateway's own
462        // settings screen after a connect attempt redirects back to it - see
463        // `maybe_render_connect_notice()`'s own doc comment for why a plain
464        // query-string flag is used here rather than `WC_Admin_Settings::
465        // add_error()` (which only works within the same request that
466        // renders the settings form, and the connect callback is a separate
467        // request).
468        add_action( 'admin_notices', array( $this, 'maybe_render_connect_notice' ) );
469        // The site-wide "please reconnect" notice is registered in
470        // `monokulo.php` instead, so it shows on every admin screen whether
471        // or not WooCommerce has built its gateways on that request.
472    }
WC_Gateway_Monokulo->adopt_traceparent
2106    public static function adopt_traceparent( $traceparent ) {
2107        if ( ! preg_match( '/^00-([0-9a-f]{32})-([0-9a-f]{16})-[0-9a-f]{2}$/D', trim( (string) $traceparent ), $parts ) ) {
2108            return false;
2110        if ( str_repeat( '0', 32 ) === $parts[1] || str_repeat( '0', 16 ) === $parts[2] ) {
2110        if ( str_repeat( '0', 32 ) === $parts[1] || str_repeat( '0', 16 ) === $parts[2] ) {
2110        if ( str_repeat( '0', 32 ) === $parts[1] || str_repeat( '0', 16 ) === $parts[2] ) {
2111            return false;
2113        self::$trace_id = $parts[1];
2114        return true;
2115    }
WC_Gateway_Monokulo->apply_order_status_event
1986    private function apply_order_status_event( WC_Order $order, $engine_status ) {
1987        if ( 'paid' === $engine_status || 'overpaid' === $engine_status ) {
1987        if ( 'paid' === $engine_status || 'overpaid' === $engine_status ) {
1987        if ( 'paid' === $engine_status || 'overpaid' === $engine_status ) {
1988            if ( 'overpaid' === $engine_status ) {
1995                $order->add_order_note(
1996                    __(
1997                        'Monokulo: the customer sent more Monero than the amount due. The excess is not automatically refunded - handle any refund manually.',
1998                        'monokulo'
1999                    )
2000                );
2001            }
2002            $order->payment_complete();
2002            $order->payment_complete();
2003            return;
2006        if ( ! isset( self::STATUS_MAP[ $engine_status ] ) ) {
2012            $this->log( sprintf( 'Webhook: unrecognized engine status "%s" - no WooCommerce status change applied.', $engine_status ), 'warning' );
2013            return;
2016        $note = '';
2017        if ( 'partial' === $engine_status ) {
2017        if ( 'partial' === $engine_status ) {
2018            $note = __( 'Monokulo: a partial payment was received - the full amount due has not yet arrived.', 'monokulo' );
2019        } elseif ( 'expired' === $engine_status ) {
2020            $note = __(
2021                'Monokulo: this order\'s payment window has closed without full payment. If any funds were received, they remain at the payment address - there is no automatic refund; handle manually.',
2022                'monokulo'
2023            );
2024        }
2025
2026        $order->update_status( self::STATUS_MAP[ $engine_status ], $note );
2026        $order->update_status( self::STATUS_MAP[ $engine_status ], $note );
2027    }
WC_Gateway_Monokulo->apply_webhook_event
1834    private function apply_webhook_event( WC_Order $order, array $event ) {
1835        $event_type = $event['event'];
1836
1837        $status_by_event_type = array(
1838            'order.pending'     => 'pending',
1839            'order.unconfirmed' => 'unconfirmed',
1840            'order.confirming'  => 'confirming',
1841            'order.paid'        => 'paid',
1842            'order.partial'     => 'partial',
1843            'order.overpaid'    => 'overpaid',
1844            'order.expired'     => 'expired',
1845        );
1846
1847        if ( isset( $status_by_event_type[ $event_type ] ) ) {
1848            $this->apply_order_status_event( $order, $status_by_event_type[ $event_type ] );
1849            return;
1852        if ( 'order.double_spend_detected' === $event_type ) {
1856            $order->add_order_note(
1857                __(
1858                    'Monokulo: FRAUD ALERT - a payment previously credited to this order was proven to be a double-spend and has been voided by the engine. If this changed the order\'s payment status, that change was announced separately. Please review this order.',
1859                    'monokulo'
1860                )
1861            );
1862            return;
1865        if ( 'order.double_spend_reversed' === $event_type ) {
1866            $txid = isset( $event['txid'] ) && is_string( $event['txid'] ) ? $event['txid'] : __( 'unknown', 'monokulo' );
1866            $txid = isset( $event['txid'] ) && is_string( $event['txid'] ) ? $event['txid'] : __( 'unknown', 'monokulo' );
1866            $txid = isset( $event['txid'] ) && is_string( $event['txid'] ) ? $event['txid'] : __( 'unknown', 'monokulo' );
1866            $txid = isset( $event['txid'] ) && is_string( $event['txid'] ) ? $event['txid'] : __( 'unknown', 'monokulo' );
1866            $txid = isset( $event['txid'] ) && is_string( $event['txid'] ) ? $event['txid'] : __( 'unknown', 'monokulo' );
1866            $txid = isset( $event['txid'] ) && is_string( $event['txid'] ) ? $event['txid'] : __( 'unknown', 'monokulo' );
1867            // Likewise no status change here - see this method's own doc
1868            // comment: any real status consequence of the reversal was
1869            // already announced via its own order.<status> event.
1870            $order->add_order_note(
1871                sprintf(
1872                    /* translators: %s: the transaction id the earlier double-spend accusation was about */
1873                    __(
1874                        'Monokulo: an earlier double-spend accusation against this order (transaction %s) was a false positive and has been reversed by the engine. If this changed the order\'s payment status, that change was announced separately.',
1875                        'monokulo'
1876                    ),
1877                    $txid
1878                )
1879            );
1880            return;
1889        $this->log( sprintf( 'Webhook: unrecognized event type "%s" - envelope accepted, no action taken.', $event_type ), 'warning' );
1890    }
WC_Gateway_Monokulo->build_connect_start_url
1075    private function build_connect_start_url( $nonce ) {
1076        return sprintf(
1077            '%s/connect/%s?site_url=%s&return_url=%s&nonce=%s',
1078            rtrim( $this->get_control_plane_base_url(), '/' ),
1079            rawurlencode( self::CONNECT_PLATFORM ),
1080            rawurlencode( home_url( '/' ) ),
1081            rawurlencode( $this->get_connect_return_url() ),
1082            rawurlencode( $nonce )
1083        );
1084    }
WC_Gateway_Monokulo->build_settings_url
2041    private function build_settings_url( array $extra_args ) {
2042        return add_query_arg(
2043            $extra_args,
2044            admin_url( 'admin.php?page=wc-settings&tab=checkout&section=' . $this->id )
2045        );
2046    }
WC_Gateway_Monokulo->call_connect_finish
1367    private function call_connect_finish( $token, &$status_code = 0 ) {
1368        $url = rtrim( $this->get_control_plane_base_url(), '/' ) . '/connect/' . self::CONNECT_PLATFORM . '/finish';
1369
1370        $response = wp_remote_post(
1371            $url,
1372            array(
1373                'headers' => array(
1374                    'Content-Type'    => 'application/json',
1375                    // Monokulo records the connection with this plugin's version.
1376                    'Monokulo-Client' => self::CLIENT,
1377                    'traceparent'     => self::traceparent(),
1378                ),
1379                'body'    => wp_json_encode(
1380                    array(
1381                        'token'       => $token,
1382                        'webhook_url' => $this->get_webhook_receiver_url(),
1383                    )
1384                ),
1385                // Same generous, untuned headroom `create_monokulo_order()`
1386                // above uses, for the same reason: an ordinary HTTP round
1387                // trip with no reason to be slow, not a value chosen from
1388                // measurement.
1389                'timeout' => 30,
1390            )
1391        );
1392
1393        if ( is_wp_error( $response ) ) {
1394            $this->log( sprintf( 'Connect finish request to %s failed: %s', $url, $response->get_error_message() ), 'error' );
1395            return null;
1398        $status_code = (int) wp_remote_retrieve_response_code( $response );
1399        $raw_body    = wp_remote_retrieve_body( $response );
1400
1401        // A bare 401 is the shape of every token-related failure
1402        // `connect.rs::finish` can produce (unknown/expired/already-consumed
1403        // token, a rejected webhook URL, an internal error). The one other
1404        // status is 503 with a JSON `error`: Monokulo has no public address
1405        // configured yet, so it refuses to hand out one. The caller turns
1406        // that into its own notice; the message is logged either way.
1407        if ( 200 !== $status_code ) {
1408            $this->log( sprintf( 'Connect finish request to %s returned HTTP %d: %s', $url, $status_code, $raw_body ), 'error' );
1409            return null;
1412        $decoded = json_decode( $raw_body, true );
1413        if ( ! is_array( $decoded ) || empty( $decoded['public_key'] ) || empty( $decoded['secret_token'] ) || empty( $decoded['endpoint'] ) ) {
1413        if ( ! is_array( $decoded ) || empty( $decoded['public_key'] ) || empty( $decoded['secret_token'] ) || empty( $decoded['endpoint'] ) ) {
1413        if ( ! is_array( $decoded ) || empty( $decoded['public_key'] ) || empty( $decoded['secret_token'] ) || empty( $decoded['endpoint'] ) ) {
1413        if ( ! is_array( $decoded ) || empty( $decoded['public_key'] ) || empty( $decoded['secret_token'] ) || empty( $decoded['endpoint'] ) ) {
1413        if ( ! is_array( $decoded ) || empty( $decoded['public_key'] ) || empty( $decoded['secret_token'] ) || empty( $decoded['endpoint'] ) ) {
1413        if ( ! is_array( $decoded ) || empty( $decoded['public_key'] ) || empty( $decoded['secret_token'] ) || empty( $decoded['endpoint'] ) ) {
1413        if ( ! is_array( $decoded ) || empty( $decoded['public_key'] ) || empty( $decoded['secret_token'] ) || empty( $decoded['endpoint'] ) ) {
1414            $this->log( sprintf( 'Connect finish response from %s was not the expected shape: %s', $url, $raw_body ), 'error' );
1415            return null;
1418        return $decoded;
1419    }
WC_Gateway_Monokulo->connect_nonce_transient_key
1059        return 'monokulo_connect_nonce_' . $this->id;
1060    }
WC_Gateway_Monokulo->create_monokulo_order
775    private function create_monokulo_order( WC_Order $order ) {
776        if ( ! $this->has_credentials() || $this->needs_reconnect() ) {
776        if ( ! $this->has_credentials() || $this->needs_reconnect() ) {
776        if ( ! $this->has_credentials() || $this->needs_reconnect() ) {
781            throw new Exception(
782                __( 'Monero payments are not fully configured for this store yet. Please contact the store owner.', 'monokulo' )
786        $request_url = $this->get_orders_endpoint_url();
787        $currency    = $order->get_currency();
788        $amount      = self::format_amount( $order->get_total(), $currency );
789
790        $body = array(
791            'amount'            => $amount,
792            'currency'          => $currency,
793            // The order's own numeric id, not `get_order_number()` - the
794            // latter is filterable (some plugins prefix it, e.g. "WC-1042")
795            // and only ever meant for human display, whereas this value
796            // exists so a merchant can match a Monokulo order back to this
797            // store's order later, and shows on Monokulo's order page.
798            'merchant_order_id' => (string) $order->get_id(),
799            // One key per order and total: a retry after a timeout or a lost
800            // answer gets the Monokulo order the first attempt made, never a
801            // second one with a second address. A changed total is a new
802            // purchase, so it gets a new key.
803            'idempotency_key'   => self::idempotency_key( $order->get_id(), $amount, $currency ),
804        );
805
806        $response = wp_remote_post(
807            $request_url,
808            array(
809                'headers' => array(
810                    'Content-Type'     => 'application/json',
811                    'Authorization'    => 'Bearer ' . $this->secret_token,
812                    'Monokulo-Client'  => self::CLIENT,
813                    'traceparent'      => self::traceparent(),
814                ),
815                'body'    => wp_json_encode( $body ),
816                // An ordinary HTTP round trip (Monokulo prices the order and
817                // asks its engine for an address); 30s is generous headroom,
818                // not a tuned value.
819                'timeout' => 30,
820            )
821        );
822
823        if ( is_wp_error( $response ) ) {
824            $this->log(
825                sprintf( 'Order creation request to %s failed: %s', $request_url, $response->get_error_message() ),
826                'error'
827            );
828            throw new Exception(
829                __( 'Could not reach Monokulo to start this payment. Please try again shortly.', 'monokulo' )
833        $status_code = (int) wp_remote_retrieve_response_code( $response );
834        $raw_body    = wp_remote_retrieve_body( $response );
835
836        if ( 200 !== $status_code ) {
837            $decoded_error = json_decode( $raw_body, true );
838            $detail        = is_array( $decoded_error ) && isset( $decoded_error['error'] ) ? (string) $decoded_error['error'] : $raw_body;
838            $detail        = is_array( $decoded_error ) && isset( $decoded_error['error'] ) ? (string) $decoded_error['error'] : $raw_body;
838            $detail        = is_array( $decoded_error ) && isset( $decoded_error['error'] ) ? (string) $decoded_error['error'] : $raw_body;
838            $detail        = is_array( $decoded_error ) && isset( $decoded_error['error'] ) ? (string) $decoded_error['error'] : $raw_body;
838            $detail        = is_array( $decoded_error ) && isset( $decoded_error['error'] ) ? (string) $decoded_error['error'] : $raw_body;
838            $detail        = is_array( $decoded_error ) && isset( $decoded_error['error'] ) ? (string) $decoded_error['error'] : $raw_body;
839            $this->log(
840                sprintf(
841                    'Order creation request to %s returned HTTP %d: %s%s',
842                    $request_url,
843                    $status_code,
844                    $detail,
845                    self::hint_for_status( $status_code )
846                ),
847                'error'
848            );
849            throw new Exception( self::customer_message_for_status( $status_code ) );
852        $decoded = json_decode( $raw_body, true );
853        if ( ! is_array( $decoded ) || empty( $decoded['order_id'] ) ) {
853        if ( ! is_array( $decoded ) || empty( $decoded['order_id'] ) ) {
853        if ( ! is_array( $decoded ) || empty( $decoded['order_id'] ) ) {
854            $this->log(
855                sprintf( 'Order creation response from %s was not the expected shape: %s', $request_url, $raw_body ),
856                'error'
857            );
858            throw new Exception(
859                __( 'Monokulo returned an unexpected response. Please contact the store.', 'monokulo' )
863        return $decoded;
864    }
WC_Gateway_Monokulo->customer_message_for_status
904    private static function customer_message_for_status( $status_code ) {
905        if ( 429 === $status_code ) {
906            return __( 'Monero payments are busy right now. Please wait a moment and try again.', 'monokulo' );
908        if ( 401 === $status_code || 403 === $status_code ) {
908        if ( 401 === $status_code || 403 === $status_code ) {
908        if ( 401 === $status_code || 403 === $status_code ) {
909            return __( 'Monero payments are not available for this store right now. Please contact the store owner.', 'monokulo' );
911        return __( 'Monokulo could not start this payment. Please contact the store or try again.', 'monokulo' );
912    }
WC_Gateway_Monokulo->event_already_applied
1756    private function event_already_applied( WC_Order $order, $event_id ) {
1757        $applied = json_decode( (string) $order->get_meta( self::META_APPLIED_EVENT_IDS ), true );
1758        return is_array( $applied ) && in_array( $event_id, $applied, true );
1758        return is_array( $applied ) && in_array( $event_id, $applied, true );
1758        return is_array( $applied ) && in_array( $event_id, $applied, true );
1759    }
WC_Gateway_Monokulo->find_order_by_order_id
1717    private function find_order_by_order_id( $order_id ) {
1718        $orders = wc_get_orders(
1719            array(
1720                'meta_key'   => self::META_ORDER_ID, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
1721                'meta_value' => $order_id, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
1722                'limit'      => 1,
1723                'return'     => 'objects',
1724            )
1725        );
1726
1727        return isset( $orders[0] ) && $orders[0] instanceof WC_Order ? $orders[0] : null;
1727        return isset( $orders[0] ) && $orders[0] instanceof WC_Order ? $orders[0] : null;
1727        return isset( $orders[0] ) && $orders[0] instanceof WC_Order ? $orders[0] : null;
1727        return isset( $orders[0] ) && $orders[0] instanceof WC_Order ? $orders[0] : null;
1727        return isset( $orders[0] ) && $orders[0] instanceof WC_Order ? $orders[0] : null;
1727        return isset( $orders[0] ) && $orders[0] instanceof WC_Order ? $orders[0] : null;
1728    }
WC_Gateway_Monokulo->flush_forwarded
2195    public static function flush_forwarded( $url, $secret ) {
2196        if ( empty( self::$forward_queue ) ) {
2197            return null;
2201                'Content-Type'  => 'application/json',
2202                'Authorization' => 'Bearer ' . $secret,
2203                'traceparent'   => self::traceparent(),
2204            ),
2205            'body'     => wp_json_encode( array( 'entries' => self::$forward_queue ) ),
2206            'timeout'  => 3,
2207            'blocking' => false,
2208        );
2209        self::$forward_queue = array();
2210        wp_remote_post( $url, $args );
2211        return $args;
2212    }
WC_Gateway_Monokulo->format_amount
889    private static function format_amount( $total, $currency ) {
890        $decimals = 2;
891        if ( 'XMR' === strtoupper( $currency ) ) {
892            $decimals = function_exists( 'wc_get_price_decimals' ) ? min( 12, max( 2, (int) wc_get_price_decimals() ) ) : 12;
892            $decimals = function_exists( 'wc_get_price_decimals' ) ? min( 12, max( 2, (int) wc_get_price_decimals() ) ) : 12;
892            $decimals = function_exists( 'wc_get_price_decimals' ) ? min( 12, max( 2, (int) wc_get_price_decimals() ) ) : 12;
892            $decimals = function_exists( 'wc_get_price_decimals' ) ? min( 12, max( 2, (int) wc_get_price_decimals() ) ) : 12;
893        }
894        return number_format( (float) $total, $decimals, '.', '' );
894        return number_format( (float) $total, $decimals, '.', '' );
895    }
WC_Gateway_Monokulo->generate_monokulo_connect_html
1113    public function generate_monokulo_connect_html( $key, $data ) {
1114        $field_key = $this->get_field_key( $key );
1115
1116        // Same default-filling `generate_text_html()` itself does (read
1117        // directly) before ever touching `$data['desc_tip']`/
1118        // `$data['description']` - `init_form_fields()`'s own `'connect'`
1119        // entry only sets `type`/`description`, and `get_description_html()`
1120        // below indexes `$data['desc_tip']` unconditionally, which would be
1121        // an undefined-array-key warning on every real render without this.
1122        $data = wp_parse_args(
1123            $data,
1124            array(
1125                'desc_tip'    => false,
1126                'description' => '',
1127            )
1128        );
1129
1130        $nonce = bin2hex( random_bytes( 16 ) );
1131        set_transient( $this->connect_nonce_transient_key(), $nonce, 10 * MINUTE_IN_SECONDS );
1132        $connect_url = $this->build_connect_start_url( $nonce );
1133
1134        ob_start();
1135        ?>
1136        <tr valign="top">
1137            <th scope="row" class="titledesc">
1138                <label for="<?php echo esc_attr( $field_key ); ?>"><?php esc_html_e( 'Connect your Monero wallet', 'monokulo' ); ?></label>
1139            </th>
1140            <td class="forminp">
1141                <?php if ( $this->needs_reconnect() ) : ?>
1141                <?php if ( $this->needs_reconnect() ) : ?>
1142                    <p><strong><?php esc_html_e( 'Reconnect needed: this store was connected by an older plugin version, so Monero payments are hidden at checkout until you reconnect.', 'monokulo' ); ?></strong></p>
1143                <?php elseif ( $this->has_credentials() ) : ?>
1144                    <p>
1145                        <?php esc_html_e( 'Connected as', 'monokulo' ); ?>
1146                        <code><?php echo esc_html( $this->tenant_public_key ); ?></code>
1147                    </p>
1148                <?php endif; ?>
1149                <a href="<?php echo esc_url( $connect_url ); ?>" id="<?php echo esc_attr( $field_key ); ?>" class="button button-primary">
1149                <a href="<?php echo esc_url( $connect_url ); ?>" id="<?php echo esc_attr( $field_key ); ?>" class="button button-primary">
1150                    <?php
1151                    echo $this->has_credentials()
1152                        ? esc_html__( 'Reconnect your Monero wallet', 'monokulo' )
1153                        : esc_html__( 'Connect your Monero wallet', 'monokulo' );
1153                        : esc_html__( 'Connect your Monero wallet', 'monokulo' );
1154                    ?>
1155                </a>
1156                <?php echo $this->get_description_html( $data ); // WPCS: XSS ok. ?>
1157            </td>
1158        </tr>
1159        <?php
1160        return ob_get_clean();
1161    }
WC_Gateway_Monokulo->get_checkout_url
959    private function get_checkout_url( $order_id ) {
960        return sprintf(
961            '%s/pay/%s/orders/%s',
962            rtrim( $this->api_base_url, '/' ),
963            rawurlencode( $this->tenant_public_key ),
964            rawurlencode( $order_id )
965        );
966    }
WC_Gateway_Monokulo->get_connect_return_url
1041        return admin_url( 'admin-post.php?action=' . self::CONNECT_RETURN_ACTION );
1042    }
WC_Gateway_Monokulo->get_control_plane_base_url
984        return apply_filters( 'monokulo_control_plane_base_url', self::CONTROL_PLANE_BASE_URL );
985    }
WC_Gateway_Monokulo->get_orders_endpoint_url
945            rtrim( $this->api_base_url, '/' ),
946            rawurlencode( $this->tenant_public_key )
947        );
948    }
WC_Gateway_Monokulo->get_webhook_receiver_url
1436        return WC()->api_request_url( $this->id );
1437    }
WC_Gateway_Monokulo->handle_connect_return
1209        if ( ! current_user_can( 'manage_woocommerce' ) ) {
1210            wp_die( esc_html__( 'You do not have permission to do this.', 'monokulo' ), '', array( 'response' => 403 ) );
1211        }
1212
1213        $redirect_url = $this->process_connect_return( wp_unslash( $_GET ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
1213        $redirect_url = $this->process_connect_return( wp_unslash( $_GET ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
1214        wp_safe_redirect( $redirect_url );
1215        exit;
1216    }
WC_Gateway_Monokulo->handle_webhook
1491        $raw_body  = (string) file_get_contents( 'php://input' );
1492        $signature = isset( $_SERVER[ self::WEBHOOK_SIGNATURE_SERVER_KEY ] )
1493            ? (string) wp_unslash( $_SERVER[ self::WEBHOOK_SIGNATURE_SERVER_KEY ] ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
1492        $signature = isset( $_SERVER[ self::WEBHOOK_SIGNATURE_SERVER_KEY ] )
1493            ? (string) wp_unslash( $_SERVER[ self::WEBHOOK_SIGNATURE_SERVER_KEY ] ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
1494            : '';
1492        $signature = isset( $_SERVER[ self::WEBHOOK_SIGNATURE_SERVER_KEY ] )
1493            ? (string) wp_unslash( $_SERVER[ self::WEBHOOK_SIGNATURE_SERVER_KEY ] ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
1494            : '';
1495        // The engine sends the delivery attempt's trace; the lines this
1496        // webhook logs join it.
1497        self::adopt_traceparent( isset( $_SERVER['HTTP_TRACEPARENT'] ) ? (string) wp_unslash( $_SERVER['HTTP_TRACEPARENT'] ) : '' ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- checked against a strict pattern.
1497        self::adopt_traceparent( isset( $_SERVER['HTTP_TRACEPARENT'] ) ? (string) wp_unslash( $_SERVER['HTTP_TRACEPARENT'] ) : '' ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- checked against a strict pattern.
1497        self::adopt_traceparent( isset( $_SERVER['HTTP_TRACEPARENT'] ) ? (string) wp_unslash( $_SERVER['HTTP_TRACEPARENT'] ) : '' ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- checked against a strict pattern.
1497        self::adopt_traceparent( isset( $_SERVER['HTTP_TRACEPARENT'] ) ? (string) wp_unslash( $_SERVER['HTTP_TRACEPARENT'] ) : '' ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- checked against a strict pattern.
1498
1499        status_header( $this->process_webhook_request( $raw_body, $signature ) );
1500        exit;
1501    }
WC_Gateway_Monokulo->has_credentials
614        return '' !== $this->api_base_url && '' !== $this->tenant_public_key && '' !== $this->secret_token;
614        return '' !== $this->api_base_url && '' !== $this->tenant_public_key && '' !== $this->secret_token;
614        return '' !== $this->api_base_url && '' !== $this->tenant_public_key && '' !== $this->secret_token;
614        return '' !== $this->api_base_url && '' !== $this->tenant_public_key && '' !== $this->secret_token;
614        return '' !== $this->api_base_url && '' !== $this->tenant_public_key && '' !== $this->secret_token;
615    }
WC_Gateway_Monokulo->hint_for_status
921    private static function hint_for_status( $status_code ) {
922        switch ( $status_code ) {
923            case 401:
925            case 403:
927            case 404:
929            case 429:
929            case 429:
924                return ' (the store secret key was rejected - reconnect the plugin from its settings page)';
926                return ' (Monokulo refused the order - check the store\'s verified-domain settings on Monokulo)';
928                return ' (Monokulo does not know this store\'s public key - reconnect the plugin)';
930                return ' (rate limited - Monokulo is receiving too many orders from this store)';
932                return '';
933        }
934    }
WC_Gateway_Monokulo->idempotency_key
875    public static function idempotency_key( $order_id, $amount, $currency ) {
876        return sprintf( 'wc:%s:%s:%s', $order_id, $amount, $currency );
877    }
WC_Gateway_Monokulo->init_form_fields
507                'title'   => __( 'Enable/Disable', 'monokulo' ),
508                'type'    => 'checkbox',
509                'label'   => __( 'Enable Monero payments via Monokulo', 'monokulo' ),
510                // See the constructor's own comment on `$this->enabled` -
511                // this default is what makes "disabled state is fine at
512                // this step" the actual out-of-the-box behavior, not just
513                // documentation of an intention. WBS 1.5.3's own connect
514                // callback (`process_connect_return()` below) is now a real
515                // second way this flips to `'yes'` - see that method's own
516                // doc comment for why flipping it there, not just leaving it
517                // for the merchant to toggle afterward, is the WBS's own
518                // stated outcome, not an extra liberty this step is taking.
519                'default' => 'no',
520            ),
521            'title'       => array(
522                'title'       => __( 'Title', 'monokulo' ),
523                'type'        => 'text',
524                'description' => __( 'The label the customer sees for this payment method at checkout.', 'monokulo' ),
525                'default'     => __( 'Monero (via Monokulo)', 'monokulo' ),
526                'desc_tip'    => true,
527            ),
528            'description' => array(
529                'title'       => __( 'Description', 'monokulo' ),
530                'type'        => 'textarea',
531                'description' => __( 'The explanatory text the customer sees for this payment method at checkout.', 'monokulo' ),
532                'default'     => __( 'Pay with Monero. You will be redirected to a secure Monokulo payment page to complete your purchase.', 'monokulo' ),
533                'desc_tip'    => true,
534            ),
535
536            'connection'  => array(
537                'title'       => __( 'Connection', 'monokulo' ),
538                'type'        => 'title',
539                'description' => __( 'Click below to connect your Monero wallet through Monokulo - this fills in everything below automatically and enables this gateway.', 'monokulo' ),
540            ),
541
542            // The real WBS 1.5.3 button - see `generate_monokulo_connect_html()`'s
543            // own doc comment for exactly what it renders and why this needs
544            // a genuinely custom field type rather than reusing `'title'`
545            // (whose own `generate_title_html()` renders fixed markup this
546            // step needs to deviate from: a real `<a href=...>` built fresh
547            // on every render, not static text).
548            'connect'     => array(
549                'type'        => 'monokulo_connect',
550                'description' => __( 'You will be sent to Monokulo to sign in (or sign up) and confirm the connection, then returned here automatically.', 'monokulo' ),
551            ),
552
553            'endpoint'    => array(
554                'title'       => __( 'Monokulo address', 'monokulo' ),
555                'type'        => 'text',
556                'description' => __( 'The public address of the Monokulo instance this store uses. Filled in automatically by Connect above.', 'monokulo' ),
557                'default'     => '',
558                'placeholder' => 'https://pay.example.com',
559                'desc_tip'    => true,
560            ),
561            'public_key'  => array(
562                'title'       => __( 'Store public key', 'monokulo' ),
563                'type'        => 'text',
564                'description' => __( 'Your Monokulo store\'s public key (starts with pk_). Filled in automatically by Connect above.', 'monokulo' ),
565                'default'     => '',
566                'placeholder' => 'pk_...',
567                'desc_tip'    => true,
568            ),
569            'forward_errors' => array(
570                'title'       => __( 'Send errors to Monokulo', 'monokulo' ),
571                'type'        => 'checkbox',
572                'label'       => __( 'Also send this plugin\'s warnings and errors to Monokulo\'s Logs page', 'monokulo' ),
573                'description' => __( 'They stay in WooCommerce\'s own logs as well. Sent with your store\'s secret key, next to Monokulo\'s own lines for the same request. Monokulo keeps them only while Diagnostics is turned on in this store\'s settings there.', 'monokulo' ),
574                'default'     => 'no',
575                'desc_tip'    => true,
576            ),
577            'secret_token' => array(
578                'title'       => __( 'Store secret key', 'monokulo' ),
579                'type'        => 'password',
580                'description' => __( 'Your Monokulo store\'s secret key (starts with sk_). Filled in automatically by Connect above. Used to create orders from this site\'s server; never shown to customers.', 'monokulo' ),
581                'default'     => '',
582                'placeholder' => 'sk_...',
583                'desc_tip'    => true,
584            ),
585        );
586    }
WC_Gateway_Monokulo->is_available
600        if ( ! parent::is_available() ) {
601            return false;
604        return $this->has_credentials() && ! $this->needs_reconnect();
604        return $this->has_credentials() && ! $this->needs_reconnect();
604        return $this->has_credentials() && ! $this->needs_reconnect();
605    }
WC_Gateway_Monokulo->log
2138    private function log( $message, $level = 'info' ) {
2139        $this->maybe_forward( $message, $level );
2140        if ( function_exists( 'wc_get_logger' ) ) {
2143            $trace_id = self::trace_id();
2144            wc_get_logger()->log(
2145                $level,
2146                $message . ' [trace ' . $trace_id . ']',
2147                array(
2148                    'source'   => 'monokulo',
2149                    'trace_id' => $trace_id,
2150                )
2151            );
2152        }
2153    }
2153    }
WC_Gateway_Monokulo->mark_event_applied
1780    private function mark_event_applied( WC_Order $order, $event_id ) {
1781        $applied   = json_decode( (string) $order->get_meta( self::META_APPLIED_EVENT_IDS ), true );
1782        $applied   = is_array( $applied ) ? $applied : array();
1782        $applied   = is_array( $applied ) ? $applied : array();
1782        $applied   = is_array( $applied ) ? $applied : array();
1782        $applied   = is_array( $applied ) ? $applied : array();
1783        $applied[] = $event_id;
1784        $order->update_meta_data( self::META_APPLIED_EVENT_IDS, wp_json_encode( array_values( array_unique( $applied ) ) ) );
1785    }
WC_Gateway_Monokulo->maybe_forward
2163    private function maybe_forward( $message, $level ) {
2164        if ( 'yes' !== $this->get_option( 'forward_errors', 'no' ) || ! $this->has_credentials() ) {
2164        if ( 'yes' !== $this->get_option( 'forward_errors', 'no' ) || ! $this->has_credentials() ) {
2164        if ( 'yes' !== $this->get_option( 'forward_errors', 'no' ) || ! $this->has_credentials() ) {
2165            return;
2167        if ( ! in_array( $level, self::FORWARDED_LEVELS, true ) || count( self::$forward_queue ) >= self::MAX_FORWARDED ) {
2167        if ( ! in_array( $level, self::FORWARDED_LEVELS, true ) || count( self::$forward_queue ) >= self::MAX_FORWARDED ) {
2167        if ( ! in_array( $level, self::FORWARDED_LEVELS, true ) || count( self::$forward_queue ) >= self::MAX_FORWARDED ) {
2168            return;
2171            'level'    => $level,
2172            'message'  => (string) $message,
2173            'trace_id' => self::trace_id(),
2174        );
2175        if ( ! self::$forward_registered ) {
2176            self::$forward_registered = true;
2177            $url    = rtrim( $this->api_base_url, '/' ) . '/pay/' . rawurlencode( $this->tenant_public_key ) . '/logs';
2178            $secret = $this->secret_token;
2179            register_shutdown_function(
2180                function () use ( $url, $secret ) {
2181                    WC_Gateway_Monokulo::flush_forwarded( $url, $secret );
2182                }
2183            );
2184        }
2185    }
2185    }
WC_Gateway_Monokulo->maybe_render_connect_notice
2067        if ( ! isset( $_GET['page'], $_GET['section'] ) || 'wc-settings' !== $_GET['page'] || $this->id !== $_GET['section'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
2067        if ( ! isset( $_GET['page'], $_GET['section'] ) || 'wc-settings' !== $_GET['page'] || $this->id !== $_GET['section'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
2067        if ( ! isset( $_GET['page'], $_GET['section'] ) || 'wc-settings' !== $_GET['page'] || $this->id !== $_GET['section'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
2067        if ( ! isset( $_GET['page'], $_GET['section'] ) || 'wc-settings' !== $_GET['page'] || $this->id !== $_GET['section'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
2067        if ( ! isset( $_GET['page'], $_GET['section'] ) || 'wc-settings' !== $_GET['page'] || $this->id !== $_GET['section'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
2067        if ( ! isset( $_GET['page'], $_GET['section'] ) || 'wc-settings' !== $_GET['page'] || $this->id !== $_GET['section'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
2067        if ( ! isset( $_GET['page'], $_GET['section'] ) || 'wc-settings' !== $_GET['page'] || $this->id !== $_GET['section'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
2068            return;
2071        if ( isset( $_GET['monokulo_connected'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
2071        if ( isset( $_GET['monokulo_connected'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
2072            echo '<div class="notice notice-success is-dismissible"><p>' .
2073                esc_html__( 'Your Monero wallet is connected - Monero payments are now enabled.', 'monokulo' ) .
2075        } elseif ( isset( $_GET['monokulo_connect_error'] ) && 'unavailable' === $_GET['monokulo_connect_error'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
2075        } elseif ( isset( $_GET['monokulo_connect_error'] ) && 'unavailable' === $_GET['monokulo_connect_error'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
2075        } elseif ( isset( $_GET['monokulo_connect_error'] ) && 'unavailable' === $_GET['monokulo_connect_error'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
2075        } elseif ( isset( $_GET['monokulo_connect_error'] ) && 'unavailable' === $_GET['monokulo_connect_error'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
2076            echo '<div class="notice notice-error is-dismissible"><p>' .
2077                esc_html__( 'Monokulo is not ready to connect plugins yet: its operator has not set its public address. Please try again later.', 'monokulo' ) .
2079        } elseif ( isset( $_GET['monokulo_connect_error'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
2081                esc_html__( 'Could not connect your Monero wallet. Please try again.', 'monokulo' ) .
2082                '</p></div>';
2083        }
2084    }
2084    }
WC_Gateway_Monokulo->needs_reconnect
624        return self::settings_need_reconnect( $this->settings );
625    }
WC_Gateway_Monokulo->process_connect_return
1273    public function process_connect_return( array $query_args ) {
1274        $token = isset( $query_args['token'] ) ? sanitize_text_field( $query_args['token'] ) : '';
1274        $token = isset( $query_args['token'] ) ? sanitize_text_field( $query_args['token'] ) : '';
1274        $token = isset( $query_args['token'] ) ? sanitize_text_field( $query_args['token'] ) : '';
1274        $token = isset( $query_args['token'] ) ? sanitize_text_field( $query_args['token'] ) : '';
1275        $nonce = isset( $query_args['nonce'] ) ? sanitize_text_field( $query_args['nonce'] ) : '';
1275        $nonce = isset( $query_args['nonce'] ) ? sanitize_text_field( $query_args['nonce'] ) : '';
1275        $nonce = isset( $query_args['nonce'] ) ? sanitize_text_field( $query_args['nonce'] ) : '';
1275        $nonce = isset( $query_args['nonce'] ) ? sanitize_text_field( $query_args['nonce'] ) : '';
1276
1277        $stored_nonce = get_transient( $this->connect_nonce_transient_key() );
1278        // Single-use regardless of outcome, per this method's own doc
1279        // comment - a stale or already-checked nonce must never be
1280        // re-checkable.
1281        delete_transient( $this->connect_nonce_transient_key() );
1282
1283        if ( '' === $token || '' === $nonce || false === $stored_nonce || ! hash_equals( (string) $stored_nonce, $nonce ) ) {
1283        if ( '' === $token || '' === $nonce || false === $stored_nonce || ! hash_equals( (string) $stored_nonce, $nonce ) ) {
1283        if ( '' === $token || '' === $nonce || false === $stored_nonce || ! hash_equals( (string) $stored_nonce, $nonce ) ) {
1283        if ( '' === $token || '' === $nonce || false === $stored_nonce || ! hash_equals( (string) $stored_nonce, $nonce ) ) {
1283        if ( '' === $token || '' === $nonce || false === $stored_nonce || ! hash_equals( (string) $stored_nonce, $nonce ) ) {
1283        if ( '' === $token || '' === $nonce || false === $stored_nonce || ! hash_equals( (string) $stored_nonce, $nonce ) ) {
1283        if ( '' === $token || '' === $nonce || false === $stored_nonce || ! hash_equals( (string) $stored_nonce, $nonce ) ) {
1284            $this->log( 'Connect return rejected: missing token, or the nonce did not match what this site generated.', 'warning' );
1285            return $this->build_settings_url( array( 'monokulo_connect_error' => 'nonce' ) );
1288        $finish_status = 0;
1289        $finish        = $this->call_connect_finish( $token, $finish_status );
1290        if ( null === $finish ) {
1293            $reason = 503 === $finish_status ? 'unavailable' : 'finish';
1293            $reason = 503 === $finish_status ? 'unavailable' : 'finish';
1293            $reason = 503 === $finish_status ? 'unavailable' : 'finish';
1293            $reason = 503 === $finish_status ? 'unavailable' : 'finish';
1294            return $this->build_settings_url( array( 'monokulo_connect_error' => $reason ) );
1297        $this->update_option( 'endpoint', $finish['endpoint'] );
1298        $this->update_option( 'public_key', $finish['public_key'] );
1299        $this->update_option( 'secret_token', $finish['secret_token'] );
1300        if ( ! empty( $finish['webhook_signing_secret'] ) ) {
1307            $this->update_option( 'webhook_signing_secret', $finish['webhook_signing_secret'] );
1308        }
1309        // The WBS's own stated outcome for this step: connecting a wallet
1310        // enables the gateway outright, not just fills in fields for the
1311        // merchant to separately flip a switch on afterward.
1312        $this->update_option( 'enabled', 'yes' );
1312        $this->update_option( 'enabled', 'yes' );
1313        // Marks this connection as made by this plugin version, whose
1314        // `endpoint` is Monokulo's own address - see `CONNECTION_VERSION`.
1315        $this->update_option( 'connection_version', self::CONNECTION_VERSION );
1316
1317        return $this->build_settings_url( array( 'monokulo_connected' => '1' ) );
1318    }
WC_Gateway_Monokulo->process_payment
726    public function process_payment( $order_id ) {
727        $order = wc_get_order( $order_id );
728        if ( ! $order instanceof WC_Order ) {
729            throw new Exception( __( 'Could not load this order to start the Monero payment.', 'monokulo' ) );
732        $monokulo_order = $this->create_monokulo_order( $order );
733
734        // The one point in this gateway's whole flow that ever sees the
735        // mapping between a WC order and Monokulo's order_id - recorded
736        // now, while it's in hand. The webhook receiver needs exactly this
737        // lookup (an incoming delivery carries an order_id, not a WC order
738        // id) to know which order to update.
739        $order->update_meta_data( self::META_ORDER_ID, $monokulo_order['order_id'] );
740        $order->add_order_note(
741            sprintf(
742                /* translators: %s: Monokulo order_id */
743                __( 'Customer redirected to Monokulo checkout for order_id %s.', 'monokulo' ),
744                $monokulo_order['order_id']
745            )
746        );
747        $order->save();
748
749        return array(
750            'result'   => 'success',
751            'redirect' => $this->get_checkout_url( $monokulo_order['order_id'] ),
752        );
753    }
WC_Gateway_Monokulo->process_webhook_request
1567    public function process_webhook_request( $raw_body, $signature ) {
1568        if ( ! $this->verify_webhook_signature( $raw_body, $signature ) ) {
1569            $this->log( 'Webhook rejected: missing or invalid X-Monokulo-Signature.', 'warning' );
1570            return 401;
1573        $event = json_decode( $raw_body, true );
1574        if ( ! is_array( $event )
1575            || empty( $event['event'] ) || ! is_string( $event['event'] )
1575            || empty( $event['event'] ) || ! is_string( $event['event'] )
1575            || empty( $event['event'] ) || ! is_string( $event['event'] )
1575            || empty( $event['event'] ) || ! is_string( $event['event'] )
1576            || empty( $event['event_id'] ) || ! is_string( $event['event_id'] )
1576            || empty( $event['event_id'] ) || ! is_string( $event['event_id'] )
1576            || empty( $event['event_id'] ) || ! is_string( $event['event_id'] )
1576            || empty( $event['event_id'] ) || ! is_string( $event['event_id'] )
1577            || empty( $event['order_id'] ) || ! is_string( $event['order_id'] )
1577            || empty( $event['order_id'] ) || ! is_string( $event['order_id'] )
1577            || empty( $event['order_id'] ) || ! is_string( $event['order_id'] )
1577            || empty( $event['order_id'] ) || ! is_string( $event['order_id'] )
1579            $this->log( sprintf( 'Webhook rejected: correctly signed but not the expected envelope shape: %s', $raw_body ), 'error' );
1580            return 400;
1583        $order = $this->find_order_by_order_id( $event['order_id'] );
1584        if ( ! $order instanceof WC_Order ) {
1585            $this->log(
1586                sprintf( 'Webhook for order_id %s (event %s) has no matching order on this site.', $event['order_id'], $event['event_id'] ),
1587                'warning'
1588            );
1589            return 404;
1592        if ( $this->event_already_applied( $order, $event['event_id'] ) ) {
1595            return 200;
1598        $this->apply_webhook_event( $order, $event );
1599        $this->mark_event_applied( $order, $event['event_id'] );
1600        $order->save();
1601
1602        return 200;
1603    }
WC_Gateway_Monokulo->render_reconnect_notice
653        if ( ! current_user_can( 'manage_woocommerce' ) ) {
654            return;
656        if ( ! self::settings_need_reconnect( get_option( 'woocommerce_monokulo_settings' ) ) ) {
657            return;
659        $settings_url = admin_url( 'admin.php?page=wc-settings&tab=checkout&section=monokulo' );
660        echo '<div class="notice notice-warning"><p><strong>' .
661            esc_html__( 'Monokulo: please reconnect your Monero wallet.', 'monokulo' ) .
662            '</strong> ' .
663            esc_html__( 'This store was connected by an older version of the Monokulo plugin, which can no longer create orders. Monero payments are hidden at checkout until you reconnect.', 'monokulo' ) .
664            ' <a href="' . esc_url( $settings_url ) . '">' .
665            esc_html__( 'Reconnect now', 'monokulo' ) .
666            '</a></p></div>';
667    }
WC_Gateway_Monokulo->settings_need_reconnect
637    public static function settings_need_reconnect( $settings ) {
638        if ( ! is_array( $settings ) ) {
639            return false;
641        $connected = ! empty( $settings['endpoint'] ) || ! empty( $settings['public_key'] ) || ! empty( $settings['secret_token'] );
641        $connected = ! empty( $settings['endpoint'] ) || ! empty( $settings['public_key'] ) || ! empty( $settings['secret_token'] );
641        $connected = ! empty( $settings['endpoint'] ) || ! empty( $settings['public_key'] ) || ! empty( $settings['secret_token'] );
641        $connected = ! empty( $settings['endpoint'] ) || ! empty( $settings['public_key'] ) || ! empty( $settings['secret_token'] );
641        $connected = ! empty( $settings['endpoint'] ) || ! empty( $settings['public_key'] ) || ! empty( $settings['secret_token'] );
642        $version   = isset( $settings['connection_version'] ) ? (string) $settings['connection_version'] : '';
642        $version   = isset( $settings['connection_version'] ) ? (string) $settings['connection_version'] : '';
642        $version   = isset( $settings['connection_version'] ) ? (string) $settings['connection_version'] : '';
642        $version   = isset( $settings['connection_version'] ) ? (string) $settings['connection_version'] : '';
643
644        return $connected && self::CONNECTION_VERSION !== $version;
644        return $connected && self::CONNECTION_VERSION !== $version;
644        return $connected && self::CONNECTION_VERSION !== $version;
645    }
WC_Gateway_Monokulo->trace_id
2092        if ( null === self::$trace_id ) {
2093            self::$trace_id = bin2hex( random_bytes( 16 ) );
2094        }
2095        return self::$trace_id;
2095        return self::$trace_id;
2096    }
WC_Gateway_Monokulo->traceparent
2124        return '00-' . self::trace_id() . '-' . bin2hex( random_bytes( 8 ) ) . '-01';
2125    }
WC_Gateway_Monokulo->validate_monokulo_connect_field
1181    public function validate_monokulo_connect_field( $key, $value ) {
1182        return '';
1183    }
WC_Gateway_Monokulo->verify_webhook_signature
1661    private function verify_webhook_signature( $raw_body, $signature, $now = null ) {
1662        if ( '' === $this->webhook_signing_secret || ! is_string( $signature )
1662        if ( '' === $this->webhook_signing_secret || ! is_string( $signature )
1662        if ( '' === $this->webhook_signing_secret || ! is_string( $signature )
1663            || ! preg_match( '/\At=(\d{1,18}),v1=([0-9a-fA-F]{64})\z/', $signature, $parts ) ) {
1663            || ! preg_match( '/\At=(\d{1,18}),v1=([0-9a-fA-F]{64})\z/', $signature, $parts ) ) {
1667            return false;
1669        if ( abs( ( null === $now ? time() : (int) $now ) - (int) $parts[1] ) > self::SIGNATURE_TOLERANCE_SECS ) {
1669        if ( abs( ( null === $now ? time() : (int) $now ) - (int) $parts[1] ) > self::SIGNATURE_TOLERANCE_SECS ) {
1669        if ( abs( ( null === $now ? time() : (int) $now ) - (int) $parts[1] ) > self::SIGNATURE_TOLERANCE_SECS ) {
1669        if ( abs( ( null === $now ? time() : (int) $now ) - (int) $parts[1] ) > self::SIGNATURE_TOLERANCE_SECS ) {
1671            return false;
1674        $expected = hash_hmac( 'sha256', $parts[1] . '.' . $raw_body, $this->webhook_signing_secret );
1675
1676        return hash_equals( $expected, strtolower( $parts[2] ) );
1677    }
{closure:/var/www/html/wp-content/plugins/monokulo/includes/class-wc-gateway-monokulo.php:2180-2182}
2180                function () use ( $url, $secret ) {
2181                    WC_Gateway_Monokulo::flush_forwarded( $url, $secret );
2182                }