| @@ -7,8 +7,11 @@ | ||
| 7 | 7 | |
| 8 | 8 | namespace WCPOS\WooCommercePOS\Sync; |
| 9 | 9 | |
| 10 | 10 | use WC_Order_Item_Product; |
| 11 | +use WCPOS\WooCommercePOS\Services\Tax_Id_Reader; | |
| 12 | +use WCPOS\WooCommercePOS\Services\Tax_Id_Writer; | |
| 13 | +use WP_Error; | |
| 11 | 14 | |
| 12 | 15 | /** |
| 13 | 16 | * Shapes a POS order document into the body forwarded to the STOCK wc/v3 orders |
| 14 | 17 | * controller. |
| @@ -17,9 +20,27 @@ | ||
| 17 | 20 | * replay, CAS, checkpoint) and the order-specific payload shaping that makes a |
| 18 | 21 | * POS order document survive wc/v3's strict schema and its remove-and-reapply |
| 19 | 22 | * line semantics. This class is the second half, extracted verbatim from |
| 20 | 23 | * API\V2\Write_Controller so the protocol half stays legible; the shaping rules |
| 21 | - * themselves are unchanged. | |
| 24 | + * themselves are unchanged. Client-date validation and tax-ID persistence are | |
| 25 | + * shared with the v1 lane; the remaining shaping rules are not. | |
| 26 | + * | |
| 27 | + * Lane differences still to be reconciled (V1 = API\V1\Orders_Controller; V2 = API\V2\Writers\Order_Writer): | |
| 28 | + * - Coupons: V1::calculate_coupons vs reconcile_order_coupon_lines: v1 skips empty codes; v2 forwards malformed lines for rejection; v2 reconciles updates only. | |
| 29 | + * - Product identity: V1::get_product_id vs normalize_line_item_product_identity: v1 uses loose zero comparison; v2 requires numeric zero and supplies a misc SKU sentinel. | |
| 30 | + * - Misc SKU: V1::maybe_set_item_meta_data vs normalize_line_item_product_identity: v1 uses isset and the stored product ID; v2 requires posted zero, a string SKU, and trims it. | |
| 31 | + * - Any attributes: V1::maybe_set_item_meta_data vs recover_any_variation_attributes: v1 uses stored identity and updates by meta ID (default ''); v2 uses posted IDs (product default 0) and appends only missing keys. | |
| 32 | + * - Variation dedupe: V1::prepare_line_items vs drop_unchanged_variation_line_identity: v1 prunes duplicate rows after preparation; v2 drops unchanged binding IDs before forwarding. | |
| 33 | + * - Tombstones/omissions: V1 uses WC item deletion; v2 preserves explicit product_id null before identity dedupe and adds deletion markers for omitted items; v1 has no omission pass. | |
| 34 | + * - Item UUIDs: V1 uses WC posted item IDs; reconcile_order_item_ids restores missing IDs from unique UUID matches on v2. | |
| 35 | + * - Billing email: V1::wcpos_validate_billing_email/get_item_schema allow empty values; without_empty_billing_email drops ''/null on v2, whose writer explicitly clears '' on update. | |
| 36 | + * - Display fields: V1::get_item_schema relaxes parent_name; sanitize_order_wc_payload drops null parent_name, image, and display meta fields on v2. | |
| 37 | + * - Client date: V1 create filter reads raw JSON; V2::prepare_create reads the document; both now use validate_client_created_gmt (absent/null/empty means no override). | |
| 38 | + * - Tax IDs: v1 coerces, v2 rejects incomplete entries; V1 refreshes its response after persist_tax_ids; V2::persist uses the same snapshot (absent snapshots on create only; [] clears). | |
| 39 | + * - Audit: V1::wcpos_before_order_object_save/create_item/update_item vs V2 audit phases: v2 also handles reassignment, offline payment assertions, and unpaid provenance updates. | |
| 40 | + * - Reserved stock: V1::save_object uses request params (absent values null); V2::forward_with_reserved_stock uses payload defaults (status/transaction '', paid false); both use around_paid_create. | |
| 41 | + * - Write intent: both lanes declare through Services\Order_Write_Intent (v1 at create_item, v2 at Order_Writer::forward); v1 update and direct wc/v3 rely on the ad-hoc intent from the request. | |
| 42 | + * - HPOS caps: V1 permission overrides retry broad edit/delete order caps; V2 Write_Controller::wcpos_check_permissions remaps read/create and ownership-sensitive edit/delete caps; no payload rule. | |
| 22 | 43 | */ |
| 23 | 44 | final class Order_Write_Payload { |
| 24 | 45 | /** |
| 25 | 46 | * Shape a CREATE payload for the wc/v3 forward. |
| @@ -56,8 +77,63 @@ | ||
| 56 | 77 | $payload = $this->sanitize_order_wc_payload( $payload ); |
| 57 | 78 | // Runs last: it reads the FORWARDED line shape, after normalize_line_item_product_identity |
| 58 | 79 | // has already resolved the posted sku (which outranks the ids in wc/v3's get_product_id). |
| 59 | 80 | return $this->drop_unchanged_variation_line_identity( $order, $payload ); |
| 81 | + } | |
| 82 | + | |
| 83 | + /** | |
| 84 | + * Validate the client creation time; bare GMT values are UTC, not store time. | |
| 85 | + * | |
| 86 | + * @param array $payload Original order document (raw JSON on v1). | |
| 87 | + * @return int|null|WP_Error UTC timestamp, null when absent/empty, or a 400 error. | |
| 88 | + */ | |
| 89 | + public function validate_client_created_gmt( array $payload ) { | |
| 90 | + if ( ! isset( $payload['date_created_gmt'] ) ) { | |
| 91 | + return null; | |
| 92 | + } | |
| 93 | + if ( ! is_scalar( $payload['date_created_gmt'] ) ) { | |
| 94 | + return $this->invalid_created_gmt(); | |
| 95 | + } | |
| 96 | + $value = wc_clean( wp_unslash( (string) $payload['date_created_gmt'] ) ); | |
| 97 | + if ( '' === $value ) { | |
| 98 | + return null; | |
| 99 | + } | |
| 100 | + $timestamp = 1 === preg_match( '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?$/i', $value ) | |
| 101 | + ? rest_parse_date( 'Z' === strtoupper( substr( $value, -1 ) ) ? $value : $value . 'Z', true ) : false; | |
| 102 | + if ( false === $timestamp ) { | |
| 103 | + return $this->invalid_created_gmt(); | |
| 104 | + } | |
| 105 | + return $timestamp > time() + DAY_IN_SECONDS | |
| 106 | + ? new WP_Error( 'woocommerce_pos_rest_future_date_created_gmt', __( 'date_created_gmt cannot be more than 24 hours in the future.', 'woocommerce-pos' ), array( 'status' => 400 ) ) | |
| 107 | + : $timestamp; | |
| 108 | + } | |
| 109 | + | |
| 110 | + /** Build the stable invalid create timestamp error. */ | |
| 111 | + private function invalid_created_gmt(): WP_Error { | |
| 112 | + return new WP_Error( 'woocommerce_pos_rest_invalid_date_created_gmt', __( 'date_created_gmt must be a valid ISO 8601 UTC date.', 'woocommerce-pos' ), array( 'status' => 400 ) ); | |
| 113 | + } | |
| 114 | + | |
| 115 | + /** | |
| 116 | + * Persist explicit tax IDs, or snapshot the customer only on create. | |
| 117 | + * | |
| 118 | + * The v1 controller uses the read-back to refresh its already-built response. | |
| 119 | + * | |
| 120 | + * @param int $id Saved order ID. | |
| 121 | + * @param array $payload Original order document; an empty tax_ids array clears IDs. | |
| 122 | + * @param bool $is_create Whether to snapshot when tax_ids is absent. | |
| 123 | + * @return array|null Stored tax IDs, or null when the order does not exist. | |
| 124 | + */ | |
| 125 | + public function persist_tax_ids( int $id, array $payload, bool $is_create ): ?array { | |
| 126 | + $order = wc_get_order( $id ); | |
| 127 | + if ( ! $order ) { | |
| 128 | + return null; | |
| 129 | + } | |
| 130 | + if ( is_array( $payload['tax_ids'] ?? null ) ) { | |
| 131 | + ( new Tax_Id_Writer() )->write_for_order( $order, $payload['tax_ids'] ); | |
| 132 | + } elseif ( $is_create && $order->get_customer_id() > 0 ) { | |
| 133 | + ( new Tax_Id_Writer() )->snapshot_from_user_to_order( $order, $order->get_customer_id() ); | |
| 134 | + } | |
| 135 | + return ( new Tax_Id_Reader() )->read_for_order( $order ); | |
| 60 | 136 | } |
| 61 | 137 | |
| 62 | 138 | /** |
| 63 | 139 | * WC-strict-schema tolerance for POS order payloads. |