| 1 |
<?php |
| 2 |
/** |
| 3 |
* WCPOS sync write surface. |
| 4 |
* |
| 5 |
* @package WCPOS\WooCommercePOS\API\V2 |
| 6 |
*/ |
| 7 |
|
| 8 |
namespace WCPOS\WooCommercePOS\API\V2; |
| 9 |
|
| 10 |
use WCPOS\WooCommercePOS\API\V2\Writers\Collection_Writer_Resolver; |
| 11 |
use WCPOS\WooCommercePOS\Services\Customer_Account_Guard; |
| 12 |
use WCPOS\WooCommercePOS\Services\Tax_Id_Types; |
| 13 |
use WCPOS\WooCommercePOS\Sync\Api; |
| 14 |
use WCPOS\WooCommercePOS\Sync\Collections; |
| 15 |
use WCPOS\WooCommercePOS\Sync\Endpoint_Permissions; |
| 16 |
use WCPOS\WooCommercePOS\Sync\Header_Mirror; |
| 17 |
use WCPOS\WooCommercePOS\Sync\Meta_Normalizer; |
| 18 |
use WCPOS\WooCommercePOS\Sync\Mutation_Store; |
| 19 |
use WCPOS\WooCommercePOS\Sync\Order_Serializer; |
| 20 |
use WCPOS\WooCommercePOS\Sync\Pos_Uuid; |
| 21 |
use WCPOS\WooCommercePOS\Sync\Product_Serializer; |
| 22 |
use WCPOS\WooCommercePOS\Sync\Revision; |
| 23 |
use WCPOS\WooCommercePOS\Sync\Store_Scope; |
| 24 |
use WP_Error; |
| 25 |
use WP_REST_Controller; |
| 26 |
use WP_REST_Request; |
| 27 |
use WP_REST_Response; |
| 28 |
use WP_REST_Server; |
| 29 |
|
| 30 |
// phpcs:disable Squiz.Commenting, Generic.Commenting -- Ported lab documentation is preserved verbatim. |
| 31 |
|
| 32 |
/** |
| 33 |
* The generic server write surface (P1-0) — ONE controller for EVERY collection's |
| 34 |
* writes (guardrail G1), the server half of the client push path. Registered at |
| 35 |
* `POST /{API_NAMESPACE}/push/{collection}`; it dispatches on the envelope's |
| 36 |
* `operation` (not the HTTP verb — the client always POSTs the envelope) and |
| 37 |
* applies each create/update/delete through the collection's Woo write seam: |
| 38 |
* every collection forwards to its `wc/v3` controller, including the nested, |
| 39 |
* parent-aware variation routes. The generic identity, lock, CAS and ack pipeline |
| 40 |
* remains shared across every collection. |
| 41 |
* |
| 42 |
* Identity is the client uuid (DECIDED): the server resolves the record by its |
| 43 |
* `_woocommerce_pos_uuid`, reuses it, and NEVER re-keys. Idempotency + resolution |
| 44 |
* live in an injected mutation store, so the apply logic is unit-testable with a |
| 45 |
* fake store + a stubbed `rest_do_request`. |
| 46 |
*/ |
| 47 |
class Write_Controller extends WP_REST_Controller { |
| 48 |
/** |
| 49 |
* True while wc_rest_check_user_permissions() is re-run for a cleared target. |
| 50 |
* |
| 51 |
* @var bool |
| 52 |
*/ |
| 53 |
private $rejudging_user_target = false; |
| 54 |
|
| 55 |
// Our gate (capability + F13 health); forwarded writes scope the client-tier grant below. |
| 56 |
use Endpoint_Permissions; |
| 57 |
|
| 58 |
|
| 59 |
/** @var mixed Duck-typed mutation store; tests inject an in-memory implementation. */ |
| 60 |
private $store; |
| 61 |
|
| 62 |
|
| 63 |
/** |
| 64 |
* collection => wc/v3 route + how its uuid→id is resolved. ONE table, not |
| 65 |
* per-collection controllers. Only collections whose resolver is correct AND |
| 66 |
* exercised are exposed; the rest stay out until their phase: |
| 67 |
* - orders: RESOLVED — HPOS keeps orders in WooCommerce's own table, so id_type=>'order' |
| 68 |
* resolves via wc_get_orders by the uuid meta (not post meta). This superseded (and |
| 69 |
* replaced) the deleted orders-specific legacy /orders/push. |
| 70 |
* - tax_rates: RESOLVED — intentionally NOT in the uuid write-path. Tax rates are |
| 71 |
* pure-server-pull and have no native meta store, so they are the single principled |
| 72 |
* G1 exception (ADR 0009): they key by their Woo id, not a uuid. Variations carry |
| 73 |
* their parent on create and derive it from the stored object thereafter. |
| 74 |
*/ |
| 75 |
/** |
| 76 |
* The write map — a PROJECTION of the registry's write capability (#421 |
| 77 |
* increments 6+7): route from the write group; id_type and the |
| 78 |
* collection's OWN scalar resolver scope (post_type / taxonomy — what |
| 79 |
* resolve_id_by_uuid gets; never the backfill scan scope) from the |
| 80 |
* identity group. Only rows with BOTH write and identity are pushable; |
| 81 |
* adding one is a registry-row edit. The mutation store's per-kind meta |
| 82 |
* operations (persist_uuid / resolve_id_by_uuid, incl. the two-step term |
| 83 |
* taxonomy re-check and the trash-exclusion live-owner rule) are |
| 84 |
* unchanged — this projection is what FEEDS them. |
| 85 |
*/ |
| 86 |
private static function collections(): array { |
| 87 |
$map = array(); |
| 88 |
foreach ( Collections::with( 'write' ) as $collection => $row ) { |
| 89 |
if ( ! isset( $row['identity'] ) ) { |
| 90 |
continue; // tax_rates-shaped: writeable would need an id-space first |
| 91 |
} |
| 92 |
$entry = array( |
| 93 |
'route' => $row['write']['route'], |
| 94 |
'id_type' => $row['identity']['id_type'], |
| 95 |
); |
| 96 |
if ( isset( $row['identity']['post_type'] ) ) { |
| 97 |
$entry['post_type'] = $row['identity']['post_type']; |
| 98 |
} |
| 99 |
if ( isset( $row['identity']['taxonomy'] ) ) { |
| 100 |
$entry['taxonomy'] = $row['identity']['taxonomy']; |
| 101 |
} |
| 102 |
$map[ $collection ] = $entry; |
| 103 |
} |
| 104 |
return $map; |
| 105 |
} |
| 106 |
|
| 107 |
public function __construct( $store = null ) { |
| 108 |
$this->store = $store ? $store : new Mutation_Store(); |
| 109 |
} |
| 110 |
|
| 111 |
/** Resolve the collection-specific writer for registry metadata. */ |
| 112 |
private function writer( array $meta ) { |
| 113 |
return ( new Collection_Writer_Resolver( $this->store ) )->resolve( $meta ); |
| 114 |
} |
| 115 |
|
| 116 |
public function register_routes(): void { |
| 117 |
register_rest_route( |
| 118 |
Api::ROUTE_NAMESPACE, |
| 119 |
'/push/(?P<collection>[a-z0-9_]+)', |
| 120 |
array( |
| 121 |
'methods' => WP_REST_Server::CREATABLE, |
| 122 |
'callback' => array( $this, 'push' ), |
| 123 |
'permission_callback' => array( $this, 'permissions_check' ), |
| 124 |
'args' => array( 'collection' => array( 'sanitize_callback' => 'sanitize_key' ) ), |
| 125 |
) |
| 126 |
); |
| 127 |
} |
| 128 |
|
| 129 |
/** POST /push/{collection} — apply one mutation envelope idempotently. */ |
| 130 |
public function push( WP_REST_Request $request ) { |
| 131 |
$content_type = strtolower( trim( (string) $request->get_header( 'Content-Type' ) ) ); |
| 132 |
if ( 'application/json' !== trim( explode( ';', $content_type, 2 )[0] ) ) { |
| 133 |
return new WP_Error( 'woo_rxdb_sync_json_required', 'Content-Type must be application/json.', array( 'status' => 415 ) ); |
| 134 |
} |
| 135 |
$collection = (string) ( $request->get_url_params()['collection'] ?? $request->get_param( 'collection' ) ); |
| 136 |
$meta = self::collections()[ $collection ] ?? null; |
| 137 |
if ( null === $meta ) { |
| 138 |
return new WP_Error( 'woo_rxdb_sync_unknown_collection', 'Unknown collection.', array( 'status' => 400 ) ); |
| 139 |
} |
| 140 |
|
| 141 |
$m = $this->envelope( $request ); |
| 142 |
$err = $this->validate_envelope( $m, $collection ); |
| 143 |
if ( $err instanceof WP_Error ) { |
| 144 |
return $err; |
| 145 |
} |
| 146 |
|
| 147 |
// Standard-header MIRROR (ADR 0011) — Idempotency-Key (= mutationId) + If-Match (= baseRevision) are an |
| 148 |
// optional cross-check over the canonical body; 422 on divergence. Same helper as the orders push path. |
| 149 |
$mirror = Header_Mirror::assert( $request, $m['mutationId'], $m['baseRevision'] ); |
| 150 |
if ( is_wp_error( $mirror ) ) { |
| 151 |
return $mirror; |
| 152 |
} |
| 153 |
$fingerprint = $this->envelope_fingerprint( $m ); |
| 154 |
|
| 155 |
// Idempotent replay: a mutationId already APPLIED returns its canonical result. |
| 156 |
$settled = $this->replay_or_conflict( $collection, $meta, $m, $fingerprint ); |
| 157 |
if ( null !== $settled ) { |
| 158 |
return $settled; |
| 159 |
} |
| 160 |
|
| 161 |
// Atomically CLAIM the mutationId before the non-idempotent forward, so two |
| 162 |
// concurrent retries (e.g. a timeout-retry overlapping its own in-flight push) |
| 163 |
// can't both create. The loser replays if it's done, else reports in-progress; |
| 164 |
// a crashed winner's stale reservation is reclaimed after the TTL. |
| 165 |
if ( ! $this->store->reserve( $collection, $m['mutationId'], $m['recordId'], $m['operation'], $fingerprint ) ) { |
| 166 |
$settled = $this->replay_or_conflict( $collection, $meta, $m, $fingerprint ); |
| 167 |
if ( null !== $settled ) { |
| 168 |
return $settled; |
| 169 |
} |
| 170 |
if ( ! $this->reclaim_and_reserve( $collection, $m, $fingerprint ) ) { |
| 171 |
return new WP_REST_Response( |
| 172 |
array( |
| 173 |
'code' => 'woo_rxdb_sync_in_progress', |
| 174 |
'message' => 'Mutation is being applied; retry shortly.', |
| 175 |
), |
| 176 |
409 |
| 177 |
); |
| 178 |
} |
| 179 |
} |
| 180 |
|
| 181 |
// We hold the mutationId reservation (same-mutation idempotency). Now serialise on |
| 182 |
// the RECORD so two DISTINCT mutations on the same collection+uuid can't both |
| 183 |
// read-current → pass the baseRevision compare → forward (a silent lost update): |
| 184 |
// the loser waits, then re-reads the now-updated revision and gets a real 409. |
| 185 |
if ( ! $this->store->acquire_record_lock( $collection, $m['recordId'] ) ) { |
| 186 |
$this->store->release( $m['mutationId'] ); // couldn't serialise in time — let a retry re-claim |
| 187 |
return new WP_REST_Response( |
| 188 |
array( |
| 189 |
'code' => 'woo_rxdb_sync_record_locked', |
| 190 |
'message' => 'Record is being written; retry shortly.', |
| 191 |
), |
| 192 |
409 |
| 193 |
); |
| 194 |
} |
| 195 |
try { |
| 196 |
// Apply, and RELEASE the reservation on any failure so a retry can re-claim |
| 197 |
// immediately (a crash leaves it pending for the TTL reclaim instead). |
| 198 |
$result = $this->apply( $m['operation'], $collection, $meta, $m ); |
| 199 |
} finally { |
| 200 |
$this->store->release_record_lock( $collection, $m['recordId'] ); |
| 201 |
} |
| 202 |
$checkpoint = $this->store->lookup( $collection, $m['mutationId'] ); |
| 203 |
if ( $this->is_failure( $result ) && ! $this->retains_mutation( $result ) && ! in_array( ( $checkpoint['status'] ?? '' ), array( 'poison', 'blocked' ), true ) ) { |
| 204 |
$this->store->release( $m['mutationId'] ); |
| 205 |
} |
| 206 |
return $result; |
| 207 |
} |
| 208 |
|
| 209 |
/** |
| 210 |
* Settle a mutationId that the store already knows about. |
| 211 |
* |
| 212 |
* Rejects an envelope that reuses the id for a different write, re-stamps a poisoned |
| 213 |
* retry, and replays the canonical result of an applied/done mutation. |
| 214 |
* |
| 215 |
* @param string $collection The collection being written to. |
| 216 |
* @param array $meta The collection metadata. |
| 217 |
* @param array $m The mutation envelope. |
| 218 |
* @param string $fingerprint The canonical fingerprint of the envelope. |
| 219 |
* @return WP_Error|WP_REST_Response|null The settled result, or null to keep applying. |
| 220 |
*/ |
| 221 |
private function replay_or_conflict( string $collection, array $meta, array $m, string $fingerprint ) { |
| 222 |
$hit = $this->store->lookup( $collection, $m['mutationId'] ); |
| 223 |
if ( is_array( $hit ) ) { |
| 224 |
$mismatch = $this->replay_target_mismatch( $hit, $collection, $fingerprint ); |
| 225 |
if ( $mismatch ) { |
| 226 |
return $mismatch; |
| 227 |
} |
| 228 |
} |
| 229 |
if ( is_array( $hit ) && 'poison' === ( $hit['status'] ?? '' ) ) { |
| 230 |
return $this->retry_identity_stamp( $meta, $m, $hit ); |
| 231 |
} |
| 232 |
if ( is_array( $hit ) && in_array( ( $hit['status'] ?? '' ), array( 'done', 'applied' ), true ) ) { |
| 233 |
if ( 'applied' === $hit['status'] && ! $this->store->finalize( $m['mutationId'], (int) $hit['remote_id'] ) ) { |
| 234 |
return $this->finalize_error(); |
| 235 |
} |
| 236 |
return $this->replay( $meta, $hit ); |
| 237 |
} |
| 238 |
return null; |
| 239 |
} |
| 240 |
|
| 241 |
/** |
| 242 |
* Reclaim a crashed pending reservation, then atomically claim it again. |
| 243 |
*/ |
| 244 |
private function reclaim_and_reserve( string $collection, array $mutation, string $fingerprint ): bool { |
| 245 |
if ( ! $this->store->reclaim_stale( $mutation['mutationId'], $this->store->reservation_ttl() ) ) { |
| 246 |
return false; |
| 247 |
} |
| 248 |
|
| 249 |
return $this->store->reserve( $collection, $mutation['mutationId'], $mutation['recordId'], $mutation['operation'], $fingerprint ); |
| 250 |
} |
| 251 |
|
| 252 |
/** |
| 253 |
* A stored mutationId must be replayed only for its original envelope. |
| 254 |
* |
| 255 |
* @param array $hit The stored mutation row. |
| 256 |
* @return WP_Error|null An envelope rejection on mismatch, null when aligned. |
| 257 |
*/ |
| 258 |
private function replay_target_mismatch( array $hit, string $collection, string $fingerprint ) { |
| 259 |
$stored_fingerprint = (string) ( $hit['fingerprint'] ?? '' ); |
| 260 |
$stored_collection = (string) ( $hit['collection'] ?? '' ); |
| 261 |
if ( '' !== $stored_fingerprint && hash_equals( $stored_fingerprint, $fingerprint ) && ( '' === $stored_collection || $collection === $stored_collection ) ) { |
| 262 |
return null; |
| 263 |
} |
| 264 |
return new WP_Error( 'woo_rxdb_sync_bad_mutation_id', 'mutationId was already used for a different envelope.', array( 'status' => 422 ) ); |
| 265 |
} |
| 266 |
|
| 267 |
private function envelope_fingerprint( array $envelope ): string { |
| 268 |
return hash( 'sha256', (string) wp_json_encode( $this->sort_envelope_keys( $envelope ) ) ); |
| 269 |
} |
| 270 |
|
| 271 |
private function sort_envelope_keys( array $value ): array { |
| 272 |
if ( array_values( $value ) !== $value ) { |
| 273 |
ksort( $value ); |
| 274 |
} |
| 275 |
foreach ( $value as $key => $item ) { |
| 276 |
if ( is_array( $item ) ) { |
| 277 |
$value[ $key ] = $this->sort_envelope_keys( $item ); |
| 278 |
} |
| 279 |
} |
| 280 |
return $value; |
| 281 |
} |
| 282 |
|
| 283 |
private function apply( string $operation, string $collection, array $meta, array $m ) { |
| 284 |
switch ( $operation ) { |
| 285 |
case 'create': |
| 286 |
return $this->apply_create( $collection, $meta, $m ); |
| 287 |
case 'update': |
| 288 |
return $this->apply_update( $collection, $meta, $m ); |
| 289 |
case 'delete': |
| 290 |
return $this->apply_delete( $collection, $meta, $m ); |
| 291 |
} |
| 292 |
return new WP_Error( 'woo_rxdb_sync_invalid_operation', 'Invalid operation.', array( 'status' => 400 ) ); |
| 293 |
} |
| 294 |
|
| 295 |
private function is_failure( $result ): bool { |
| 296 |
if ( $result instanceof WP_Error ) { |
| 297 |
return true; |
| 298 |
} |
| 299 |
if ( $result instanceof WP_REST_Response ) { |
| 300 |
return $result->get_status() >= 400; |
| 301 |
} |
| 302 |
return false; |
| 303 |
} |
| 304 |
|
| 305 |
private function retains_mutation( $result ): bool { |
| 306 |
return $result instanceof WP_Error |
| 307 |
&& in_array( $result->get_error_code(), array( 'woo_rxdb_sync_finalize_failed', 'woo_rxdb_sync_create_no_id' ), true ); |
| 308 |
} |
| 309 |
|
| 310 |
private function envelope( WP_REST_Request $request ): array { |
| 311 |
// Prefer the parsed JSON body (the pattern push/fixtures controllers use) so a |
| 312 |
// nested `payload` object is read reliably; fall back to get_param otherwise. |
| 313 |
$json = method_exists( $request, 'get_json_params' ) ? $request->get_json_params() : null; |
| 314 |
$src = ( is_array( $json ) && ! empty( $json ) ) ? $json : null; |
| 315 |
$get = static function ( string $key ) use ( $request, $src ) { |
| 316 |
return null !== $src ? ( $src[ $key ] ?? null ) : $request->get_param( $key ); |
| 317 |
}; |
| 318 |
if ( null !== $src ) { |
| 319 |
return $src; |
| 320 |
} |
| 321 |
return array( |
| 322 |
'mutationId' => $get( 'mutationId' ), |
| 323 |
'operation' => $get( 'operation' ), |
| 324 |
'collection' => $get( 'collection' ), |
| 325 |
'recordId' => $get( 'recordId' ), |
| 326 |
'baseRevision' => $get( 'baseRevision' ), |
| 327 |
'payload' => $get( 'payload' ), |
| 328 |
); |
| 329 |
} |
| 330 |
|
| 331 |
private function validate_envelope( array $m, string $path_collection ) { |
| 332 |
$allowed = array( 'mutationId', 'operation', 'collection', 'recordId', 'baseRevision', 'payload', 'force' ); |
| 333 |
if ( array_diff( array_keys( $m ), $allowed ) ) { |
| 334 |
return new WP_Error( 'woo_rxdb_sync_bad_envelope', 'Envelope contains unknown properties.', array( 'status' => 400 ) ); |
| 335 |
} |
| 336 |
if ( ! isset( $m['mutationId'] ) || ! is_string( $m['mutationId'] ) || ! Pos_Uuid::is_uuid( $m['mutationId'] ) ) { |
| 337 |
return new WP_Error( 'woo_rxdb_sync_bad_mutation_id', 'mutationId must be a uuid.', array( 'status' => 400 ) ); |
| 338 |
} |
| 339 |
if ( ! isset( $m['operation'] ) || ! is_string( $m['operation'] ) || ! in_array( $m['operation'], array( 'create', 'update', 'delete' ), true ) ) { |
| 340 |
return new WP_Error( 'woo_rxdb_sync_bad_operation', 'operation must be create|update|delete.', array( 'status' => 400 ) ); |
| 341 |
} |
| 342 |
// Tighten the server to the published envelope contract. The production adapter already |
| 343 |
// sends this field equal to the route, so no legitimate client traffic changes. |
| 344 |
if ( ! isset( $m['collection'] ) || ! is_string( $m['collection'] ) || '' === $m['collection'] || $m['collection'] !== $path_collection ) { |
| 345 |
return new WP_Error( 'woo_rxdb_sync_bad_collection', 'collection must match the path collection.', array( 'status' => 400 ) ); |
| 346 |
} |
| 347 |
if ( ! isset( $m['recordId'] ) || ! is_string( $m['recordId'] ) || ! Pos_Uuid::is_uuid( $m['recordId'] ) ) { |
| 348 |
return new WP_Error( 'woo_rxdb_sync_bad_record_id', 'recordId must be a uuid.', array( 'status' => 400 ) ); |
| 349 |
} |
| 350 |
if ( ! array_key_exists( 'baseRevision', $m ) || ( ! is_string( $m['baseRevision'] ) && null !== $m['baseRevision'] ) ) { |
| 351 |
return new WP_Error( 'woo_rxdb_sync_bad_base_revision', 'baseRevision must be a string or null.', array( 'status' => 400 ) ); |
| 352 |
} |
| 353 |
if ( 'delete' === $m['operation'] ) { |
| 354 |
if ( array_key_exists( 'force', $m ) && ! is_bool( $m['force'] ) ) { |
| 355 |
return new WP_Error( 'woo_rxdb_sync_bad_payload', 'force must be a boolean.', array( 'status' => 400 ) ); |
| 356 |
} |
| 357 |
if ( array_key_exists( 'payload', $m ) ) { |
| 358 |
return new WP_Error( 'woo_rxdb_sync_bad_payload', 'payload is forbidden for delete.', array( 'status' => 400 ) ); |
| 359 |
} |
| 360 |
} else { |
| 361 |
if ( array_key_exists( 'force', $m ) ) { |
| 362 |
return new WP_Error( 'woo_rxdb_sync_bad_payload', 'force is only allowed for delete.', array( 'status' => 400 ) ); |
| 363 |
} |
| 364 |
if ( ! isset( $m['payload'] ) || ! is_array( $m['payload'] ) || ( ! empty( $m['payload'] ) && array_values( $m['payload'] ) === $m['payload'] ) ) { |
| 365 |
return new WP_Error( 'woo_rxdb_sync_bad_payload', 'payload must be an object.', array( 'status' => 400 ) ); |
| 366 |
} |
| 367 |
// A payload that carries its own uuid must agree with recordId — never re-key. |
| 368 |
$payload_uuid = Pos_Uuid::read_valid_uuid_from_meta( |
| 369 |
isset( $m['payload']['meta_data'] ) && is_array( $m['payload']['meta_data'] ) ? $m['payload']['meta_data'] : array() |
| 370 |
); |
| 371 |
if ( '' !== $payload_uuid && $payload_uuid !== $m['recordId'] ) { |
| 372 |
return new WP_Error( 'woo_rxdb_sync_identity_conflict', 'payload uuid disagrees with recordId.', array( 'status' => 422 ) ); |
| 373 |
} |
| 374 |
} |
| 375 |
return null; |
| 376 |
} |
| 377 |
|
| 378 |
private function apply_create( string $collection, array $meta, array $m ) { |
| 379 |
$writer = $this->writer( $meta ); |
| 380 |
$prepared = $writer->prepare_create( $meta, $m['payload'], \Closure::fromCallable( array( $this, 'validate_tax_ids_payload' ) ) ); |
| 381 |
if ( ! is_array( $prepared ) ) { |
| 382 |
return $prepared; |
| 383 |
} |
| 384 |
|
| 385 |
// Born-twice guard: reuse the record that already owns this uuid. |
| 386 |
$existing = $this->store->resolve_id_by_uuid( $meta['id_type'], $m['recordId'], $meta ); |
| 387 |
if ( is_wp_error( $existing ) ) { |
| 388 |
return $existing; |
| 389 |
} |
| 390 |
if ( $existing > 0 ) { |
| 391 |
$valid = $writer->validate_existing_create( $existing, $m['payload'], $prepared ); |
| 392 |
if ( null !== $valid ) { |
| 393 |
return $valid; |
| 394 |
} |
| 395 |
$finalized = $this->checkpoint_and_finalize( $m['mutationId'], $existing, 200 ); |
| 396 |
if ( is_wp_error( $finalized ) ) { |
| 397 |
return $finalized; |
| 398 |
} |
| 399 |
return $this->envelope_document( $this->document_for( $meta, $existing ), $m['recordId'], $meta, $existing, null, $writer ); |
| 400 |
} |
| 401 |
|
| 402 |
$response = $writer->forward( $prepared, \Closure::fromCallable( array( $this, 'forward' ) ) ); |
| 403 |
if ( is_wp_error( $response ) ) { |
| 404 |
return $response; |
| 405 |
} |
| 406 |
$data = $response->get_data(); |
| 407 |
if ( $response->get_status() >= 400 ) { |
| 408 |
return new WP_REST_Response( $data, $response->get_status() ); |
| 409 |
} |
| 410 |
$new_id = (int) ( is_array( $data ) ? ( $data['id'] ?? 0 ) : 0 ); |
| 411 |
if ( $new_id <= 0 ) { |
| 412 |
$this->store->mark_indeterminate( $m['mutationId'], 0, $response->get_status() ); |
| 413 |
return new WP_Error( 'woo_rxdb_sync_create_no_id', 'Create returned no server id.', array( 'status' => 502 ) ); |
| 414 |
} |
| 415 |
|
| 416 |
// Poison checkpoint, UUID persistence, and finalization remain shared here. |
| 417 |
$checkpointed = $this->store->mark_poison( $m['mutationId'], $new_id, $response->get_status() ); |
| 418 |
$writer->persist( 'create_before_identity', $new_id, $m['payload'] ); |
| 419 |
$identity_error = null; |
| 420 |
if ( ! $this->store->persist_uuid( $meta['id_type'], $new_id, $m['recordId'] ) ) { |
| 421 |
$identity_error = new WP_Error( 'woo_rxdb_sync_identity_persistence_failed', 'Unable to persist created record identity.', array( 'status' => 500 ) ); |
| 422 |
} else { |
| 423 |
$resolved = $this->store->resolve_id_by_uuid( $meta['id_type'], $m['recordId'], $meta ); |
| 424 |
if ( is_wp_error( $resolved ) ) { |
| 425 |
$identity_error = $resolved; |
| 426 |
} elseif ( $resolved !== $new_id ) { |
| 427 |
$identity_error = new WP_Error( 'woo_rxdb_sync_identity_persistence_failed', 'Unable to persist created record identity.', array( 'status' => 500 ) ); |
| 428 |
} |
| 429 |
} |
| 430 |
if ( ! $checkpointed ) { |
| 431 |
$this->store->mark_indeterminate( $m['mutationId'], $new_id, $response->get_status() ); |
| 432 |
return $this->finalize_error(); |
| 433 |
} |
| 434 |
if ( $identity_error ) { |
| 435 |
return $identity_error; |
| 436 |
} |
| 437 |
$writer->persist( 'create_after_identity', $new_id, $m['payload'] ); |
| 438 |
if ( ! $this->store->finalize_poison( $m['mutationId'], $new_id ) ) { |
| 439 |
return $this->finalize_error(); |
| 440 |
} |
| 441 |
return $this->envelope_document( $this->document_for( $meta, $new_id ), $m['recordId'], $meta, $new_id, $response->get_status(), $writer ); |
| 442 |
} |
| 443 |
|
| 444 |
/** |
| 445 |
* The pre-CAS post-type capability gate shared by the update and delete paths. |
| 446 |
* |
| 447 |
* Only the WP-post-backed collections carry a Woo capability check of their own; |
| 448 |
* every other collection is gated by the endpoint permission callback alone, so |
| 449 |
* this is a no-op for them. |
| 450 |
* |
| 451 |
* @param array $meta Resolved collection metadata (carries the post_type, if any). |
| 452 |
* @param int $id Resolved record id. |
| 453 |
* @param string $verb Woo permission context: 'edit' or 'delete'. |
| 454 |
* |
| 455 |
* @return WP_Error|null The refusal to return, or null when the write may proceed. |
| 456 |
*/ |
| 457 |
private function post_permission_error( array $meta, int $id, string $verb ): ?WP_Error { |
| 458 |
$post_type = (string) ( $meta['post_type'] ?? '' ); |
| 459 |
if ( ! \in_array( $post_type, array( 'product', 'product_variation', 'shop_coupon' ), true ) |
| 460 |
|| wc_rest_check_post_permissions( $post_type, $verb, $id ) ) { |
| 461 |
return null; |
| 462 |
} |
| 463 |
$status = array( 'status' => rest_authorization_required_code() ); |
| 464 |
if ( 'delete' === $verb ) { |
| 465 |
return new WP_Error( 'woocommerce_rest_cannot_delete', __( 'Sorry, you are not allowed to delete this resource.', 'woocommerce' ), $status ); |
| 466 |
} |
| 467 |
return new WP_Error( 'woocommerce_rest_cannot_edit', __( 'Sorry, you are not allowed to edit this resource.', 'woocommerce' ), $status ); |
| 468 |
} |
| 469 |
|
| 470 |
private function apply_update( string $collection, array $meta, array $m ) { |
| 471 |
$id = $this->store->resolve_id_by_uuid( $meta['id_type'], $m['recordId'], $meta ); |
| 472 |
if ( is_wp_error( $id ) ) { |
| 473 |
return $id; |
| 474 |
} |
| 475 |
if ( 0 === $id ) { |
| 476 |
return new WP_Error( 'woo_rxdb_sync_record_not_found', 'No record for recordId.', array( 'status' => 404 ) ); |
| 477 |
} |
| 478 |
$permission_error = $this->post_permission_error( $meta, $id, 'edit' ); |
| 479 |
if ( $permission_error ) { |
| 480 |
return $permission_error; |
| 481 |
} |
| 482 |
|
| 483 |
$writer = $this->writer( $meta ); |
| 484 |
$prepared = $writer->prepare_update( $meta, $id, $m['payload'], \Closure::fromCallable( array( $this, 'validate_tax_ids_payload' ) ) ); |
| 485 |
if ( ! is_array( $prepared ) ) { |
| 486 |
return $prepared; |
| 487 |
} |
| 488 |
if ( null === $m['baseRevision'] ) { |
| 489 |
return new WP_REST_Response( |
| 490 |
array( |
| 491 |
'code' => 'woo_rxdb_sync_revision_required', |
| 492 |
'message' => 'Updating an existing record requires an If-Match / baseRevision precondition.', |
| 493 |
), |
| 494 |
428 |
| 495 |
); |
| 496 |
} |
| 497 |
|
| 498 |
$current = $this->document_for( $meta, $id ); |
| 499 |
if ( ! ( $current instanceof WP_REST_Response ) || $current->get_status() >= 400 ) { |
| 500 |
return $current; |
| 501 |
} |
| 502 |
$current_bare = is_array( $current->get_data() ) ? $current->get_data() : array(); |
| 503 |
$current_revision = $this->revision_for( $meta, $id, $current_bare ); |
| 504 |
if ( $m['baseRevision'] !== $current_revision ) { |
| 505 |
return new WP_REST_Response( |
| 506 |
array( |
| 507 |
'code' => 'woo_rxdb_sync_conflict', |
| 508 |
'message' => 'baseRevision is stale.', |
| 509 |
'current' => $current->get_data(), |
| 510 |
'currentRevision' => $current_revision, |
| 511 |
), |
| 512 |
409 |
| 513 |
); |
| 514 |
} |
| 515 |
if ( isset( $prepared['context_factory'] ) && is_callable( $prepared['context_factory'] ) ) { |
| 516 |
$late = $prepared['context_factory'](); |
| 517 |
$prepared['payload'] = $late['payload']; |
| 518 |
$prepared['context'] = $late['context']; |
| 519 |
} |
| 520 |
|
| 521 |
$response = $writer->forward( $prepared, \Closure::fromCallable( array( $this, 'forward' ) ) ); |
| 522 |
if ( is_wp_error( $response ) ) { |
| 523 |
return $response; |
| 524 |
} |
| 525 |
if ( $response->get_status() >= 400 ) { |
| 526 |
return new WP_REST_Response( $response->get_data(), $response->get_status() ); |
| 527 |
} |
| 528 |
$data = $response->get_data(); |
| 529 |
$writer->persist( 'update', $id, $m['payload'], $current_bare, is_array( $data ) ? $data : array(), $prepared['context'] ); |
| 530 |
|
| 531 |
$this->store->persist_uuid( $meta['id_type'], $id, $m['recordId'] ); |
| 532 |
$finalized = $this->checkpoint_and_finalize( $m['mutationId'], $id, $response->get_status() ); |
| 533 |
if ( is_wp_error( $finalized ) ) { |
| 534 |
return $finalized; |
| 535 |
} |
| 536 |
return $this->envelope_document( $this->document_for( $meta, $id ), $m['recordId'], $meta, $id, null, $writer ); |
| 537 |
} |
| 538 |
|
| 539 |
private function apply_delete( string $collection, array $meta, array $m ) { |
| 540 |
$id = $this->store->resolve_id_by_uuid( $meta['id_type'], $m['recordId'], $meta ); |
| 541 |
if ( is_wp_error( $id ) ) { |
| 542 |
return $id; |
| 543 |
} |
| 544 |
if ( 0 === $id ) { |
| 545 |
$finalized = $this->checkpoint_and_finalize( $m['mutationId'], 0, 200 ); |
| 546 |
return is_wp_error( $finalized ) ? $finalized : new WP_REST_Response( (object) array(), 200 ); |
| 547 |
} |
| 548 |
$permission_error = $this->post_permission_error( $meta, $id, 'delete' ); |
| 549 |
if ( $permission_error ) { |
| 550 |
return $permission_error; |
| 551 |
} |
| 552 |
if ( null === $m['baseRevision'] ) { |
| 553 |
return new WP_REST_Response( |
| 554 |
array( |
| 555 |
'code' => 'woo_rxdb_sync_precondition_required', |
| 556 |
'message' => 'Deleting an existing record requires an If-Match / baseRevision precondition.', |
| 557 |
), |
| 558 |
428 |
| 559 |
); |
| 560 |
} |
| 561 |
|
| 562 |
$writer = $this->writer( $meta ); |
| 563 |
$current = $this->document_for( $meta, $id ); |
| 564 |
if ( ! ( $current instanceof WP_REST_Response ) || $current->get_status() >= 400 ) { |
| 565 |
return $current; |
| 566 |
} |
| 567 |
$current_bare = is_array( $current->get_data() ) ? $current->get_data() : array(); |
| 568 |
$current_revision = $this->revision_for( $meta, $id, $current_bare ); |
| 569 |
if ( $m['baseRevision'] !== $current_revision ) { |
| 570 |
return new WP_REST_Response( |
| 571 |
array( |
| 572 |
'code' => 'woo_rxdb_sync_conflict', |
| 573 |
'message' => 'baseRevision is stale.', |
| 574 |
'current' => $current->get_data(), |
| 575 |
'currentRevision' => $current_revision, |
| 576 |
), |
| 577 |
409 |
| 578 |
); |
| 579 |
} |
| 580 |
|
| 581 |
$response = $writer->delete( $meta, $id, $m, \Closure::fromCallable( array( $this, 'dispatch_write' ) ), \Closure::fromCallable( array( $this, 'can_forward_delete' ) ) ); |
| 582 |
if ( is_wp_error( $response ) ) { |
| 583 |
return $response; |
| 584 |
} |
| 585 |
if ( $response->get_status() >= 400 ) { |
| 586 |
return new WP_REST_Response( $response->get_data(), $response->get_status() ); |
| 587 |
} |
| 588 |
$finalized = $this->checkpoint_and_finalize( $m['mutationId'], $id, $response->get_status() ); |
| 589 |
return is_wp_error( $finalized ) ? $finalized : new WP_REST_Response( (object) array(), 200 ); |
| 590 |
} |
| 591 |
|
| 592 |
/** |
| 593 |
* Whether the forwarded wc/v3 order delete would pass its capability gate. |
| 594 |
* |
| 595 |
* Asks the SAME question the forward will, under the same |
| 596 |
* `woocommerce_rest_check_permissions` filter `dispatch_write()` installs, so the |
| 597 |
* pre-flight and the forward can never disagree. Used only to keep the stock |
| 598 |
* pre-restore off a delete that is going to be refused. |
| 599 |
* |
| 600 |
* @param int $id The order id. |
| 601 |
*/ |
| 602 |
private function can_forward_delete( int $id ): bool { |
| 603 |
add_filter( 'woocommerce_rest_check_permissions', array( $this, 'wcpos_check_permissions' ), 10, 4 ); |
| 604 |
try { |
| 605 |
return (bool) wc_rest_check_post_permissions( 'shop_order', 'delete', $id ); |
| 606 |
} finally { |
| 607 |
remove_filter( 'woocommerce_rest_check_permissions', array( $this, 'wcpos_check_permissions' ), 10 ); |
| 608 |
} |
| 609 |
} |
| 610 |
|
| 611 |
private function checkpoint_and_finalize( string $mutation_id, int $remote_id, int $response_status ) { |
| 612 |
if ( ! $this->store->mark_applied( $mutation_id, $remote_id, $response_status ) ) { |
| 613 |
return $this->finalize_error(); |
| 614 |
} |
| 615 |
if ( ! $this->store->finalize( $mutation_id, $remote_id ) ) { |
| 616 |
return $this->finalize_error(); |
| 617 |
} |
| 618 |
return null; |
| 619 |
} |
| 620 |
|
| 621 |
private function finalize_error(): WP_Error { |
| 622 |
return new WP_Error( 'woo_rxdb_sync_finalize_failed', 'Woo write succeeded but mutation finalization failed; retry the same mutationId.', array( 'status' => 500 ) ); |
| 623 |
} |
| 624 |
|
| 625 |
private function replay( array $meta, array $hit ) { |
| 626 |
if ( 'delete' === ( $hit['operation'] ?? '' ) || 0 === (int) ( $hit['remote_id'] ?? 0 ) ) { |
| 627 |
return new WP_REST_Response( (object) array(), 200 ); |
| 628 |
} |
| 629 |
$remote_id = (int) $hit['remote_id']; |
| 630 |
$expected = (string) ( $hit['record_uuid'] ?? '' ); |
| 631 |
// Verify the recorded record still EXISTS and still owns this uuid. We check |
| 632 |
// via the uuid→id resolver (not the wc/v3 response, which omits the protected |
| 633 |
// _woocommerce_pos_uuid meta): if the uuid no longer maps to the recorded id, |
| 634 |
// the record was deleted out-of-band / its id was reused — return 410. |
| 635 |
if ( '' !== $expected ) { |
| 636 |
$resolved = $this->store->resolve_id_by_uuid( $meta['id_type'], $expected, $meta ); |
| 637 |
if ( is_wp_error( $resolved ) ) { |
| 638 |
return $resolved; // ambiguous identity (uuid now on >1 record) — surface 409, not a false 410-orphan |
| 639 |
} |
| 640 |
if ( $resolved !== $remote_id ) { |
| 641 |
return new WP_Error( 'woo_rxdb_sync_orphaned_mutation', 'Recorded mutation no longer matches its record.', array( 'status' => 410 ) ); |
| 642 |
} |
| 643 |
} |
| 644 |
$status = isset( $hit['response_status'] ) |
| 645 |
? (int) $hit['response_status'] |
| 646 |
: ( 'create' === ( $hit['operation'] ?? '' ) ? 201 : null ); |
| 647 |
$writer = $this->writer( $meta ); |
| 648 |
return $this->envelope_document( $this->document_for( $meta, $remote_id ), $expected, $meta, $remote_id, $status, $writer ); |
| 649 |
} |
| 650 |
|
| 651 |
private function retry_identity_stamp( array $meta, array $m, array $hit ) { |
| 652 |
$remote_id = (int) ( $hit['remote_id'] ?? 0 ); |
| 653 |
$record_uuid = (string) ( $hit['record_uuid'] ?? '' ); |
| 654 |
if ( $record_uuid !== $m['recordId'] ) { |
| 655 |
return new WP_Error( 'woo_rxdb_sync_identity_conflict', 'recordId disagrees with the stored mutation identity.', array( 'status' => 422 ) ); |
| 656 |
} |
| 657 |
if ( 'create' !== ( $hit['operation'] ?? '' ) || $remote_id <= 0 ) { |
| 658 |
return new WP_Error( 'woo_rxdb_sync_identity_persistence_failed', 'Created record identity cannot be recovered safely.', array( 'status' => 500 ) ); |
| 659 |
} |
| 660 |
$resolved = $this->store->resolve_id_by_uuid( $meta['id_type'], $record_uuid, $meta ); |
| 661 |
if ( is_wp_error( $resolved ) ) { |
| 662 |
return $resolved; |
| 663 |
} |
| 664 |
if ( $resolved > 0 && $resolved !== $remote_id ) { |
| 665 |
return new WP_Error( 'woo_rxdb_sync_identity_persistence_failed', 'Stored create identity points at a different record.', array( 'status' => 500 ) ); |
| 666 |
} |
| 667 |
if ( ! $this->store->persist_uuid( $meta['id_type'], $remote_id, $record_uuid ) ) { |
| 668 |
return new WP_Error( 'woo_rxdb_sync_identity_persistence_failed', 'Unable to persist created record identity.', array( 'status' => 500 ) ); |
| 669 |
} |
| 670 |
$verified = $this->store->resolve_id_by_uuid( $meta['id_type'], $record_uuid, $meta ); |
| 671 |
if ( is_wp_error( $verified ) ) { |
| 672 |
return $verified; |
| 673 |
} |
| 674 |
if ( $verified !== $remote_id ) { |
| 675 |
return new WP_Error( 'woo_rxdb_sync_identity_persistence_failed', 'Unable to persist created record identity.', array( 'status' => 500 ) ); |
| 676 |
} |
| 677 |
$writer = $this->writer( $meta ); |
| 678 |
$writer->persist( 'create_recovery', $remote_id, $m['payload'] ); |
| 679 |
if ( ! $this->store->finalize_poison( $m['mutationId'], $remote_id ) ) { |
| 680 |
return $this->finalize_error(); |
| 681 |
} |
| 682 |
$status = isset( $hit['response_status'] ) ? (int) $hit['response_status'] : 201; |
| 683 |
return $this->envelope_document( $this->document_for( $meta, $remote_id ), $record_uuid, $meta, $remote_id, $status, $writer ); |
| 684 |
} |
| 685 |
|
| 686 |
/** |
| 687 |
* Validate a client-submitted `tax_ids` payload against the v1 schema. |
| 688 |
* |
| 689 |
* tax_ids is unknown to the stock wc/v3 controllers and is stripped before the forward, |
| 690 |
* so wc/v3 never validates it. The v1 controllers (Orders_Controller::wcpos_get_item_schema, |
| 691 |
* Customers_Controller) exposed a TaxId[] schema (typed enum, string value, nullable |
| 692 |
* country/label) that WordPress enforced on every create/update; reproduce that check here |
| 693 |
* for both orders and customers so malformed or unsupported entries are rejected with a |
| 694 |
* 400 instead of being silently dropped by Tax_Id_Writer. |
| 695 |
* |
| 696 |
* @param array $payload Mutation payload. |
| 697 |
* |
| 698 |
* @return null|WP_Error null when tax_ids is absent or valid; WP_Error (400) otherwise. |
| 699 |
*/ |
| 700 |
private function validate_tax_ids_payload( array $payload ) { |
| 701 |
if ( ! array_key_exists( 'tax_ids', $payload ) ) { |
| 702 |
return null; |
| 703 |
} |
| 704 |
$schema = array( |
| 705 |
'type' => 'array', |
| 706 |
'items' => array( |
| 707 |
'type' => 'object', |
| 708 |
// value/type are required: Tax_Id_Writer silently drops an entry with no value and |
| 709 |
// rewrites a missing type to `other`, so an accepted-but-mutated ack would diverge |
| 710 |
// from the submitted IDs. Require them so the API returns a 400 instead. |
| 711 |
'required' => array( 'value', 'type' ), |
| 712 |
'properties' => array( |
| 713 |
'type' => array( |
| 714 |
'type' => 'string', |
| 715 |
'enum' => Tax_Id_Types::all_types(), |
| 716 |
), |
| 717 |
'value' => array( |
| 718 |
'type' => 'string', |
| 719 |
), |
| 720 |
'country' => array( |
| 721 |
'type' => array( 'string', 'null' ), |
| 722 |
), |
| 723 |
'label' => array( |
| 724 |
'type' => array( 'string', 'null' ), |
| 725 |
), |
| 726 |
), |
| 727 |
), |
| 728 |
); |
| 729 |
$valid = rest_validate_value_from_schema( $payload['tax_ids'], $schema, 'tax_ids' ); |
| 730 |
if ( is_wp_error( $valid ) ) { |
| 731 |
return new WP_Error( 'woocommerce_pos_rest_invalid_tax_ids', $valid->get_error_message(), array( 'status' => 400 ) ); |
| 732 |
} |
| 733 |
return null; |
| 734 |
} |
| 735 |
private function forward( string $method, string $route, $payload ) { |
| 736 |
$request = new WP_REST_Request( $method, $route ); |
| 737 |
if ( is_array( $payload ) ) { |
| 738 |
// The route id (resolved server-side from the uuid) is authoritative — never |
| 739 |
// let a client-supplied body `id` override it or pin a create's id. The |
| 740 |
// v2 header is likewise the only authority for the legacy store param. |
| 741 |
unset( $payload['id'], $payload[ Store_Scope::PARAM ] ); |
| 742 |
$request->set_body_params( $payload ); |
| 743 |
} |
| 744 |
return $this->dispatch_write( $request ); |
| 745 |
} |
| 746 |
|
| 747 |
/** |
| 748 |
* Dispatch one raw WooCommerce mutation with the client-tier grant scoped to it. |
| 749 |
* |
| 750 |
* @return WP_REST_Response |
| 751 |
*/ |
| 752 |
private function dispatch_write( WP_REST_Request $request ) { |
| 753 |
// Stamp here so direct callers (notably deletes) carry the scope too. |
| 754 |
Store_Scope::stamp( $request ); |
| 755 |
add_filter( 'woocommerce_rest_check_permissions', array( $this, 'wcpos_check_permissions' ), 10, 4 ); |
| 756 |
try { |
| 757 |
// Marked as OUR traffic for the duration of the forward, so a consumer |
| 758 |
// keyed on store scope can act on a till write without also claiming |
| 759 |
// every stock wc/v3 product write on the site (pro#425 review). |
| 760 |
return Store_Scope::in_v2_lane( |
| 761 |
static function () use ( $request ) { |
| 762 |
return rest_do_request( $request ); |
| 763 |
} |
| 764 |
); |
| 765 |
} finally { |
| 766 |
remove_filter( 'woocommerce_rest_check_permissions', array( $this, 'wcpos_check_permissions' ), 10 ); |
| 767 |
} |
| 768 |
} |
| 769 |
|
| 770 |
/** |
| 771 |
* Judge a customer edit or delete the way V1\Customers_Controller does. |
| 772 |
* |
| 773 |
* The staff guard runs first and is final. A target it has cleared is then |
| 774 |
* re-judged by WooCommerce with the target's own roles allowed through the |
| 775 |
* shop_manager role-name restriction, so a shop manager can edit a subscriber |
| 776 |
* from a current app exactly as from the legacy route. WooCommerce's |
| 777 |
* credential fence is untouched: it runs in the controller, not here. |
| 778 |
* |
| 779 |
* @param bool $permission WooCommerce's verdict so far. |
| 780 |
* @param string $context 'edit' or 'delete'. |
| 781 |
* @param int $target_id Target user ID. |
| 782 |
*/ |
| 783 |
private function check_user_permission( bool $permission, string $context, int $target_id ): bool { |
| 784 |
if ( $this->rejudging_user_target ) { |
| 785 |
return $permission; |
| 786 |
} |
| 787 |
if ( ! Customer_Account_Guard::can_modify( get_current_user_id(), $target_id ) ) { |
| 788 |
return false; |
| 789 |
} |
| 790 |
if ( $permission ) { |
| 791 |
return true; |
| 792 |
} |
| 793 |
$this->rejudging_user_target = true; |
| 794 |
$restore = Customer_Account_Guard::allow_target_roles( $target_id ); |
| 795 |
try { |
| 796 |
return (bool) wc_rest_check_user_permissions( $context, $target_id ); |
| 797 |
} finally { |
| 798 |
$restore(); |
| 799 |
$this->rejudging_user_target = false; |
| 800 |
} |
| 801 |
} |
| 802 |
|
| 803 |
/** |
| 804 |
* Authorize proxied mutations for POS users while protecting staff accounts. |
| 805 |
* |
| 806 |
* This filter is attached only while a sync push is forwarded to wc/v3, so |
| 807 |
* direct WooCommerce requests keep their normal permission checks. |
| 808 |
* |
| 809 |
* @param bool $permission The current permission. |
| 810 |
* @param string $context The request context. |
| 811 |
* @param int $object_id The object ID. |
| 812 |
* @param string $post_type The object type passed by WooCommerce. |
| 813 |
* |
| 814 |
* @return bool |
| 815 |
*/ |
| 816 |
public function wcpos_check_permissions( $permission, $context, $object_id, $post_type ) { |
| 817 |
// Customer edits/deletes: the staff guard is final, then a cleared target |
| 818 |
// is judged by WooCommerce the same way the v1 controller judges it. |
| 819 |
if ( 'user' === $post_type && (int) $object_id > 0 && \in_array( $context, array( 'edit', 'delete' ), true ) ) { |
| 820 |
return $this->check_user_permission( (bool) $permission, $context, (int) $object_id ); |
| 821 |
} |
| 822 |
|
| 823 |
// Catalog and coupon WRITES require the user's real WooCommerce |
| 824 |
// capabilities — no POS-tier widening. The cashier role is deliberately |
| 825 |
// read-only on catalog (Activator), and a blanket grant here handed |
| 826 |
// every POS user product deletion and coupon minting. Product decision |
| 827 |
// 2026-08-06: strict wc/v3 parity for catalog mutations; only the |
| 828 |
// HPOS placeholder remap below (orders) adjusts anything, and it never |
| 829 |
// grants beyond the user's own role caps. |
| 830 |
|
| 831 |
// Orders: with HPOS enabled (sync off), get_post() yields shop_order_placehold |
| 832 |
// (map_meta_cap = false, no capability_type), so WooCommerce's REST check maps |
| 833 |
// to the generic edit_post/delete_post caps that cashier-tier roles lack — |
| 834 |
// even though they hold the real shop_orders caps. Re-check the capability the |
| 835 |
// mapping SHOULD have produced, mirroring V1\Orders_Controller's |
| 836 |
// update_item_permissions_check fix. No grant beyond the user's own role caps. |
| 837 |
if ( ! $permission && 'shop_order' === $post_type ) { |
| 838 |
$order_caps = array( |
| 839 |
'read' => 'read_private_shop_orders', |
| 840 |
'create' => 'publish_shop_orders', |
| 841 |
'delete' => 'delete_shop_orders', |
| 842 |
); |
| 843 |
$order_cap = $order_caps[ $context ] ?? null; |
| 844 |
// edit and delete are ownership-sensitive: the base *_shop_orders cap only |
| 845 |
// authorizes acting on the user's OWN orders. Touching another user's order |
| 846 |
// additionally requires the *_others_shop_orders cap, mirroring WooCommerce's |
| 847 |
// own meta-cap map. Without this, a cashier with delete_shop_orders (but not |
| 848 |
// delete_others_shop_orders) could delete/void orders they do not own. |
| 849 |
if ( \in_array( $context, array( 'edit', 'delete' ), true ) ) { |
| 850 |
$order_post = get_post( $object_id ); |
| 851 |
if ( $order_post ) { |
| 852 |
$owns_order = get_current_user_id() === (int) $order_post->post_author; |
| 853 |
$order_cap = $owns_order ? "{$context}_shop_orders" : "{$context}_others_shop_orders"; |
| 854 |
} |
| 855 |
} |
| 856 |
if ( $order_cap && current_user_can( $order_cap ) ) { |
| 857 |
$permission = true; |
| 858 |
} |
| 859 |
} |
| 860 |
|
| 861 |
return $permission; |
| 862 |
} |
| 863 |
|
| 864 |
/** |
| 865 |
* Read this collection's document for one record, through its writer. |
| 866 |
* |
| 867 |
* The single place the writer's document step is invoked. Kept as a named |
| 868 |
* method rather than inlined at each call site because it is also the seam |
| 869 |
* Test_Rest_Dispatch_Write_Contract and Test_Sync_Hook_Isolation reach for |
| 870 |
* to pin the variation parent-route and re-read-price behaviours. |
| 871 |
* |
| 872 |
* @param array $meta Collection meta for the record. |
| 873 |
* @param int $id Record id. |
| 874 |
* |
| 875 |
* @return mixed |
| 876 |
*/ |
| 877 |
private function document_for( array $meta, int $id ) { |
| 878 |
return $this->writer( $meta )->document( $meta, $id, \Closure::fromCallable( array( $this, 'default_document_for' ) ) ); |
| 879 |
} |
| 880 |
|
| 881 |
/** Read and normalize a generic wc/v3 response document. */ |
| 882 |
private function default_document_for( array $meta, int $id, array $params = array() ) { |
| 883 |
$request = new WP_REST_Request( 'GET', $meta['route'] . '/' . $id ); |
| 884 |
Store_Scope::stamp( $request ); |
| 885 |
foreach ( $params as $key => $value ) { |
| 886 |
$request->set_param( $key, $value ); |
| 887 |
} |
| 888 |
$response = Store_Scope::in_v2_lane( |
| 889 |
static function () use ( $request ) { |
| 890 |
return rest_do_request( $request ); |
| 891 |
} |
| 892 |
); |
| 893 |
$data = $response->get_data(); |
| 894 |
if ( is_array( $data ) ) { |
| 895 |
$response->set_data( Meta_Normalizer::normalize( $data ) ); |
| 896 |
} |
| 897 |
return $response; |
| 898 |
} |
| 899 |
|
| 900 |
/** Apply generic product augmentation and inject the client UUID. */ |
| 901 |
private function default_response_document( array $bare, string $record_id, array $meta, int $id ): array { |
| 902 |
if ( 'product' === ( $meta['post_type'] ?? '' ) ) { |
| 903 |
$product = wc_get_product( $id ); |
| 904 |
if ( $product ) { |
| 905 |
$bare = Product_Serializer::augment( $bare, $product, new WP_REST_Request( 'GET', $meta['route'] . '/' . $id ) ); |
| 906 |
} |
| 907 |
} |
| 908 |
return Pos_Uuid::ensure_in_payload( $bare, $record_id ); |
| 909 |
} |
| 910 |
|
| 911 |
/** Wrap a collection document in the unchanged mutation response envelope. */ |
| 912 |
private function respond( array $bare, string $record_id, int $status, array $meta, int $id, $writer ) { |
| 913 |
$current_revision = $this->revision_for( $meta, $id, $bare ); |
| 914 |
$document = $writer->build_response_document( |
| 915 |
$bare, |
| 916 |
$record_id, |
| 917 |
$meta, |
| 918 |
$id, |
| 919 |
\Closure::fromCallable( array( $this, 'default_response_document' ) ) |
| 920 |
); |
| 921 |
return new WP_REST_Response( |
| 922 |
array( |
| 923 |
'document' => $document, |
| 924 |
'currentRevision' => $current_revision, |
| 925 |
), |
| 926 |
$status |
| 927 |
); |
| 928 |
} |
| 929 |
|
| 930 |
/** |
| 931 |
* Wrap a collection document in the write-ack envelope. |
| 932 |
* |
| 933 |
* $status and $writer default so the four-argument form still resolves — |
| 934 |
* Test_Sync_Hook_Isolation reaches this method by reflection to pin the |
| 935 |
* variation re-read price behaviour. |
| 936 |
* |
| 937 |
* @param mixed $document Document to envelope. |
| 938 |
* @param string $record_id Client record id. |
| 939 |
* @param array $meta Collection meta for the record. |
| 940 |
* @param int $id Record id. |
| 941 |
* @param int|null $status Status to report, or null to use the document's. |
| 942 |
* @param object|null $writer Writer for the collection, resolved from $meta when null. |
| 943 |
* |
| 944 |
* @return mixed |
| 945 |
*/ |
| 946 |
private function envelope_document( $document, string $record_id, array $meta, int $id, ?int $status = null, $writer = null ) { |
| 947 |
if ( ! ( $document instanceof WP_REST_Response ) || $document->get_status() >= 400 ) { |
| 948 |
return $document; |
| 949 |
} |
| 950 |
$writer = $writer ?? $this->writer( $meta ); |
| 951 |
$bare = $document->get_data(); |
| 952 |
return $this->respond( is_array( $bare ) ? $bare : array(), $record_id, $status ?? $document->get_status(), $meta, $id, $writer ); |
| 953 |
} |
| 954 |
|
| 955 |
private function revision_for( array $meta, int $id, array $bare ): string { |
| 956 |
if ( 'product_variation' === ( $meta['post_type'] ?? '' ) ) { |
| 957 |
/* |
| 958 |
* A variation's revision is its `date_modified_gmt`, deliberately: the client's targeted |
| 959 |
* pull synthesizes exactly that as `sync.revision`, so both sides agree without the |
| 960 |
* variations lane needing a stamped `_rxdb_revision`. |
| 961 |
* |
| 962 |
* Read the date from WHEREVER it is — nested under `payload` in today's |
| 963 |
* `{ id, parent_id, payload }` wrapper, or top level once that wrapper is dropped. |
| 964 |
* |
| 965 |
* This used to read `$bare['payload']['date_modified_gmt']` only, with `$bare['id']` as |
| 966 |
* the fallback. Against a FLAT document that silently degrades to the variation's own |
| 967 |
* ID — a value that never changes again. The failure would be total and invisible: |
| 968 |
* the ack would hand the client the id as `currentRevision`, and from then on every |
| 969 |
* stale baseRevision would equal every recomputed one — the strict revision |
| 970 |
* comparison would pass every queued write. Two tills editing the same |
| 971 |
* variation hours apart would both pass the precondition; the per-record lock would |
| 972 |
* serialize them, so there would be no error — just a lost update, every time. |
| 973 |
* |
| 974 |
* The `$bare['id']` fallback is kept ONLY for a document carrying no date at all, and is |
| 975 |
* now unreachable for any real variation serialization. |
| 976 |
*/ |
| 977 |
$payload = isset( $bare['payload'] ) && is_array( $bare['payload'] ) ? $bare['payload'] : array(); |
| 978 |
$date = $payload['date_modified_gmt'] ?? $bare['date_modified_gmt'] ?? null; |
| 979 |
|
| 980 |
return (string) ( $date ?? $bare['id'] ?? $id ); |
| 981 |
} |
| 982 |
if ( 'order' === ( $meta['id_type'] ?? '' ) && $id > 0 ) { |
| 983 |
return Order_Serializer::canonical_revision( $bare ); |
| 984 |
} |
| 985 |
return Revision::compute( $bare ); |
| 986 |
} |
| 987 |
} |
| 988 |
|