| 1 |
<?php |
| 2 |
/** |
| 3 |
* POS order audit-meta authority. |
| 4 |
* |
| 5 |
* Single source of truth for the `_pos_*` audit keys (who rang up the sale, |
| 6 |
* which store, the cash amounts) shared by the wcpos/v1 orders controller and |
| 7 |
* the wcpos/v2 write controller. The server is the sole authoritative writer |
| 8 |
* of this trail: `_pos_user` and `_woocommerce_pos_version` are always |
| 9 |
* server-derived, and the till-sourced values (`_pos_store`, cash-tender) are |
| 10 |
* accepted from the client payload only at create and only when valid. |
| 11 |
* |
| 12 |
* @package WCPOS\WooCommercePOS\Services |
| 13 |
*/ |
| 14 |
|
| 15 |
namespace WCPOS\WooCommercePOS\Services; |
| 16 |
|
| 17 |
use WCPOS\WooCommercePOS\Sync\Meta_Entry; |
| 18 |
|
| 19 |
/** |
| 20 |
* Pos_Order_Audit service. |
| 21 |
*/ |
| 22 |
final class Pos_Order_Audit { |
| 23 |
/** |
| 24 |
* Audit keys the SERVER derives — a client-supplied value is never accepted. |
| 25 |
* |
| 26 |
* @var string[] |
| 27 |
*/ |
| 28 |
private const SERVER_META_KEYS = array( '_pos_user', '_pos_user_created', '_pos_payment_asserted', '_woocommerce_pos_version' ); |
| 29 |
|
| 30 |
/** |
| 31 |
* Audit keys sourced from the till at the sale (client-supplied, validated, |
| 32 |
* write-once). |
| 33 |
* |
| 34 |
* @var string[] |
| 35 |
*/ |
| 36 |
private const TILL_META_KEYS = array( '_pos_store', '_pos_cash_amount_tendered', '_pos_cash_change', '_pos_card_cashback' ); |
| 37 |
|
| 38 |
/** |
| 39 |
* The subset of till keys that are monetary AMOUNTS (unsigned plain decimals); |
| 40 |
* `_pos_store` is an identifier, not an amount. |
| 41 |
* |
| 42 |
* @var string[] |
| 43 |
*/ |
| 44 |
private const CASH_META_KEYS = array( '_pos_cash_amount_tendered', '_pos_cash_change', '_pos_card_cashback' ); |
| 45 |
|
| 46 |
/** |
| 47 |
* The monetary till keys, for callers that stamp cash amounts |
| 48 |
* (Write_Controller::stamp_order_till_meta on the v2 update path). |
| 49 |
* |
| 50 |
* @return string[] |
| 51 |
*/ |
| 52 |
public static function cash_meta_keys(): array { |
| 53 |
return self::CASH_META_KEYS; |
| 54 |
} |
| 55 |
|
| 56 |
/** |
| 57 |
* Every audit key: the server-derived and the till-sourced set. |
| 58 |
* |
| 59 |
* @return string[] |
| 60 |
*/ |
| 61 |
public static function audit_meta_keys(): array { |
| 62 |
return array_merge( self::SERVER_META_KEYS, self::TILL_META_KEYS ); |
| 63 |
} |
| 64 |
|
| 65 |
/** |
| 66 |
* A `meta_data` array safe to apply on order CREATE: the server-derived keys |
| 67 |
* are removed (the write path re-stamps them from the authenticated request), |
| 68 |
* and till entries use the same last-entry-wins parser as the v2 create path. |
| 69 |
* |
| 70 |
* @param array $meta_data REST `meta_data` entries (arrays or objects with key/value). |
| 71 |
* |
| 72 |
* @return array |
| 73 |
*/ |
| 74 |
public static function sanitize_create_meta( array $meta_data ): array { |
| 75 |
$sanitized = self::strip_audit_meta( $meta_data ); |
| 76 |
foreach ( self::till_meta_from_payload( $meta_data ) as $key => $value ) { |
| 77 |
$sanitized[] = array( |
| 78 |
'key' => $key, |
| 79 |
'value' => $value, |
| 80 |
); |
| 81 |
} |
| 82 |
|
| 83 |
return $sanitized; |
| 84 |
} |
| 85 |
|
| 86 |
/** |
| 87 |
* A `meta_data` array with every audit key removed — used by the v1 update |
| 88 |
* path and both v2 forwards. The audit trail is write-once at the sale: an |
| 89 |
* edit under a different cashier/store (or a forged payload) must not |
| 90 |
* rewrite it. (The one sanctioned exception is v2's explicit reassignment |
| 91 |
* flow in Write_Controller, which re-stamps `_pos_user`/`_pos_store` with |
| 92 |
* its own authorization checks and an order note.) |
| 93 |
* |
| 94 |
* WooCommerce resolves an entry by its `id` BEFORE its `key` and overwrites |
| 95 |
* both the row's key and value — so an id-addressed entry under a harmless |
| 96 |
* key would rename an audit row away. Pass the order's audit-row ids as |
| 97 |
* `$protected_meta_ids` and such entries are dropped too. |
| 98 |
* |
| 99 |
* @param array $meta_data REST `meta_data` entries (arrays or objects with key/value). |
| 100 |
* @param int[] $protected_meta_ids Existing audit-row meta ids on the target order (see audit_meta_ids()). |
| 101 |
* |
| 102 |
* @return array |
| 103 |
*/ |
| 104 |
public static function strip_audit_meta( array $meta_data, array $protected_meta_ids = array() ): array { |
| 105 |
$strip = self::audit_meta_keys(); |
| 106 |
|
| 107 |
return array_values( |
| 108 |
array_filter( |
| 109 |
$meta_data, |
| 110 |
static function ( $entry ) use ( $strip, $protected_meta_ids ) { |
| 111 |
if ( \in_array( self::entry_key( $entry ), $strip, true ) ) { |
| 112 |
return false; |
| 113 |
} |
| 114 |
$id = \is_array( $entry ) ? ( $entry['id'] ?? null ) : ( \is_object( $entry ) ? ( $entry->id ?? null ) : null ); |
| 115 |
|
| 116 |
return ! ( is_numeric( $id ) && \in_array( (int) $id, $protected_meta_ids, true ) ); |
| 117 |
} |
| 118 |
) |
| 119 |
); |
| 120 |
} |
| 121 |
|
| 122 |
/** |
| 123 |
* Meta ids of the order's existing audit rows — the rows an id-addressed |
| 124 |
* `meta_data` entry could target (see strip_audit_meta()). |
| 125 |
* |
| 126 |
* @param mixed $order A WC_Order (or false/null when lookup failed — safe no-op). |
| 127 |
* |
| 128 |
* @return int[] |
| 129 |
*/ |
| 130 |
public static function audit_meta_ids( $order ): array { |
| 131 |
if ( ! \is_object( $order ) || ! method_exists( $order, 'get_meta_data' ) ) { |
| 132 |
return array(); |
| 133 |
} |
| 134 |
$keys = self::audit_meta_keys(); |
| 135 |
$ids = array(); |
| 136 |
foreach ( $order->get_meta_data() as $meta ) { |
| 137 |
$data = \is_object( $meta ) && method_exists( $meta, 'get_data' ) ? $meta->get_data() : array(); |
| 138 |
if ( isset( $data['id'], $data['key'] ) && \in_array( (string) $data['key'], $keys, true ) ) { |
| 139 |
$ids[] = (int) $data['id']; |
| 140 |
} |
| 141 |
} |
| 142 |
|
| 143 |
return $ids; |
| 144 |
} |
| 145 |
|
| 146 |
/** |
| 147 |
* The validated till key⇒value map from a client `meta_data` array (last |
| 148 |
* entry wins for a repeated key, invalid values dropped). This is the one |
| 149 |
* parser for till values — callers must not rebuild the extraction loop. |
| 150 |
* |
| 151 |
* @param array $meta_data REST `meta_data` entries (arrays or objects with key/value). |
| 152 |
* |
| 153 |
* @return array<string, string> |
| 154 |
*/ |
| 155 |
public static function till_meta_from_payload( array $meta_data ): array { |
| 156 |
$client = array(); |
| 157 |
foreach ( $meta_data as $entry ) { |
| 158 |
$key = self::entry_key( $entry ); |
| 159 |
if ( null !== $key ) { |
| 160 |
$client[ $key ] = self::entry_value( $entry ); |
| 161 |
} |
| 162 |
} |
| 163 |
$till = array(); |
| 164 |
foreach ( self::TILL_META_KEYS as $key ) { |
| 165 |
if ( array_key_exists( $key, $client ) && self::is_valid_till_value( $key, $client[ $key ] ) ) { |
| 166 |
$till[ $key ] = (string) $client[ $key ]; |
| 167 |
} |
| 168 |
} |
| 169 |
|
| 170 |
return $till; |
| 171 |
} |
| 172 |
|
| 173 |
/** |
| 174 |
* Whether a till value may persist: never an empty/non-scalar value, and the |
| 175 |
* cash AMOUNTS must be unsigned plain decimals (a malformed amount would break |
| 176 |
* Pro analytics aggregations). `_pos_store` is an identifier — the store-scope |
| 177 |
* model allows numeric ids, uuids, or slugs — so any non-empty scalar is kept. |
| 178 |
* |
| 179 |
* @param string $key The till meta key. |
| 180 |
* @param mixed $value The client-supplied value. |
| 181 |
* |
| 182 |
* @return bool |
| 183 |
*/ |
| 184 |
public static function is_valid_till_value( string $key, $value ): bool { |
| 185 |
if ( ! \is_scalar( $value ) || '' === (string) $value ) { |
| 186 |
return false; |
| 187 |
} |
| 188 |
if ( \in_array( $key, self::CASH_META_KEYS, true ) && 1 !== preg_match( '/^\d+(?:\.\d+)?$/', (string) $value ) ) { |
| 189 |
return false; |
| 190 |
} |
| 191 |
|
| 192 |
return true; |
| 193 |
} |
| 194 |
|
| 195 |
/** |
| 196 |
* The entry's meta key, or null for a malformed entry. A `key` that is an |
| 197 |
* array/object must not be used for comparisons (PHP "Illegal offset type" |
| 198 |
* territory) — treat it as unrecognized rather than crash the write. |
| 199 |
* |
| 200 |
* @param mixed $entry A REST `meta_data` entry. |
| 201 |
* |
| 202 |
* @return string|null |
| 203 |
*/ |
| 204 |
private static function entry_key( $entry ) { |
| 205 |
$key = Meta_Entry::key( $entry ); |
| 206 |
|
| 207 |
return \is_scalar( $key ) ? (string) $key : null; |
| 208 |
} |
| 209 |
|
| 210 |
/** |
| 211 |
* The entry's value ('' when absent or malformed). |
| 212 |
* |
| 213 |
* @param mixed $entry A REST `meta_data` entry. |
| 214 |
* |
| 215 |
* @return mixed |
| 216 |
*/ |
| 217 |
private static function entry_value( $entry ) { |
| 218 |
return Meta_Entry::value( $entry ) ?? ''; |
| 219 |
} |
| 220 |
} |
| 221 |
|