array( 'tracking' ), 'shipment_tracking_corrected'=>array( 'tracking', 'before' ), 'fulfil_shipped'=>array( 'tracking' ), 'handover'=>array( 'reference' ), 'flow_delivered'=>array( 'reference' ), 'existing_tracker_linked'=>array( 'tracker_id' ) ); private const UNSETTLED = array( 'purchasing', 'needs_review' ); public static function hooks(): void { add_action( 'woocommerce_privacy_remove_order_personal_data', array( __CLASS__, 'erase' ) ); } /** Runs after WooCommerce has anonymised and saved the order; a second run changes nothing. */ public static function erase( $order ): void { if ( ! $order instanceof \WC_Order ) { return; } $changed = false; $shipments = $order->get_meta( '_storesuit_shipments' ); if ( is_array( $shipments ) ) { $clean = array(); foreach ( $shipments as $key => $row ) { if ( is_array( $row ) ) { $tracker = is_array( $row['provider_tracker'] ?? null ) ? (string) ( $row['provider_tracker']['id'] ?? '' ) : ''; if ( $tracker !== '' ) { delete_option( 'storesuit_easypost_tracker_' . hash( 'sha256', $tracker ) ); } unset( $row['provider_tracker'] ); foreach ( array( 'tracking', 'tracking_url' ) as $field ) { if ( array_key_exists( $field, $row ) ) { $row[ $field ] = ''; } } } $clean[ $key ] = $row; } if ( $clean !== $shipments ) { $order->update_meta_data( '_storesuit_shipments', $clean ); $changed = true; } } $confirmed = $order->get_meta( '_storesuit_delivery_confirmed' ); if ( is_array( $confirmed ) && array_key_exists( 'reference', $confirmed ) ) { unset( $confirmed['reference'] ); $order->update_meta_data( '_storesuit_delivery_confirmed', $confirmed ); $changed = true; } $labels = $order->get_meta( '_storesuit_labels' ); if ( is_array( $labels ) ) { $clean = array(); foreach ( $labels as $key => $label ) { if ( is_array( $label ) ) { foreach ( array( 'barcode', 'label_url', 'tracker', 'messages', 'dest' ) as $field ) { unset( $label[ $field ] ); } if ( array_key_exists( 'tracking', $label ) ) { $label['tracking'] = ''; } unset( $label['tracking_url'] ); if ( ! in_array( $label['state'] ?? '', self::UNSETTLED, true ) && array_key_exists( 'ref', $label ) ) { $label['ref'] = ''; } if ( ( $label['state'] ?? '' ) === 'quoted' ) { unset( $label['rates'] ); $label['expires'] = 0; } } $clean[ $key ] = $label; } if ( $clean !== $labels ) { $order->update_meta_data( '_storesuit_labels', $clean ); $changed = true; } } foreach ( array( '_storesuit_automation_messages', '_storesuit_market_tracking' ) as $key ) { if ( $order->meta_exists( $key ) ) { $order->delete_meta_data( $key ); $changed = true; } } // The official integrations' records (0.65): their tracking numbers, barcodes, label files and tracking links, as for // the labels above (review L8). Provider IDs and states stay, so a purchase can still be settled with the provider. foreach ( array( '_storesuit_official_geliver'=>array( 'tracking', 'reference', 'label_url', 'tracking_url' ), '_storesuit_official_sendcloud'=>array( 'tracking_number', 'tracking_url' ), '_storesuit_official_easypost'=>array( 'tracking_code' ) ) as $key => $fields ) { $record = $order->get_meta( $key ); if ( ! is_array( $record ) ) { continue; } $clean = $record; foreach ( $fields as $field ) { if ( isset( $clean[ $field ] ) && $clean[ $field ] !== '' ) { $clean[ $field ] = ''; } } // Sendcloud: the pickup point the customer chose (its name and address, and the customer's own post number of a locker); its ids stay, so a // purchase can still be settled with Sendcloud. The record is marked erased: a refresh writes none of it back (no tracking number, no link, no note). if ( $key === '_storesuit_official_sendcloud' ) { if ( is_array( $clean['point'] ?? null ) ) { foreach ( array( 'name', 'street', 'house_number', 'postal_code', 'city', 'post_number' ) as $field ) { if ( isset( $clean['point'][ $field ] ) && $clean['point'][ $field ] !== '' ) { $clean['point'][ $field ] = ''; } } } // An order that was switched to the customer's address keeps the closed point it replaced: its name and address go the same way. if ( is_array( $clean['home']['point'] ?? null ) ) { foreach ( array( 'name', 'street', 'house_number', 'postal_code', 'city', 'post_number' ) as $field ) { if ( isset( $clean['home']['point'][ $field ] ) && $clean['home']['point'][ $field ] !== '' ) { $clean['home']['point'][ $field ] = ''; } } } if ( empty( $clean['erased'] ) ) { $clean['erased'] = time(); } } if ( $clean !== $record ) { $order->update_meta_data( $key, $clean ); $changed = true; } } // Parcel2Go (1.1): the tracking numbers (a list of rows), the tracking page link, the courier's last status text and the pick-up point chosen near // the recipient are cleared; its ids, stages and states stay, so a purchase can still be settled. The record is marked erased (nothing writes this data // back: a refresh, the hourly check or a webhook) and the order leaves the flat line-id meta that finds it for tracking. $parcel2go = $order->get_meta( '_storesuit_official_parcel2go' ); if ( is_array( $parcel2go ) ) { $clean = self::clean_parcel2go( $parcel2go ); if ( $clean !== $parcel2go ) { $order->update_meta_data( '_storesuit_official_parcel2go', $clean ); $changed = true; } } if ( '' !== (string) $order->get_meta( '_storesuit_p2g_line' ) ) { $order->delete_meta_data( '_storesuit_p2g_line' ); $changed = true; } // Sendcloud's scheduled check finds a label by this flat meta: an order whose data was removed leaves its list (and the record is marked erased: nothing is written back). if ( '' !== (string) $order->get_meta( '_storesuit_sc_poll' ) ) { $order->delete_meta_data( '_storesuit_sc_poll' ); $changed = true; } if ( $changed ) { $order->save(); } self::erase_events( $order->get_id() ); } /** * A Parcel2Go record without the buyer's personal data (the tracking numbers, the tracking link, the courier's last status text and the pick-up point chosen near the * recipient), marked erased; ids, stages and states stay. The eraser uses it, and so does a write that was made from what an operation read before it waited for * Parcel2Go and must not undo an erasure that happened meanwhile ($erased_at is then the time the erasure was marked). */ public static function clean_parcel2go( array $record, int $erased_at = 0 ): array { $clean = $record; if ( is_array( $clean['tracking'] ?? null ) ) { $clean['tracking'] = array_map( static fn( $row ) => is_array( $row ) ? array_merge( $row, array( 'number'=>'' ) ) : $row, $clean['tracking'] ); } if ( ! empty( $clean['tracking_page'] ) ) { $clean['tracking_page'] = ''; } if ( ! empty( $clean['stage_text'] ) ) { $clean['stage_text'] = ''; } if ( is_array( $clean['choice'] ?? null ) ) { if ( ! empty( $clean['choice']['shop_name'] ) ) { $clean['choice']['shop_name'] = ''; } if ( ! empty( $clean['choice']['shop'] ) ) { $clean['choice']['shop'] = null; } } if ( empty( $clean['erased'] ) ) { $clean['erased'] = $erased_at > 0 ? $erased_at : time(); } return $clean; } private static function erase_events( int $order_id ): void { global $wpdb; if ( $order_id <= 0 ) { return; } $table = $wpdb->prefix . 'storesuit_events'; $events = array_keys( self::EVENTS ); $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT id,event,payload FROM %i WHERE object_type=%s AND object_id=%d AND event IN (' . implode( ',', array_fill( 0, count( $events ), '%s' ) ) . ')', array_merge( array( $table, 'order', $order_id ), $events ) ), ARRAY_A ) ?: array(); foreach ( $rows as $row ) { $payload = json_decode( (string) $row['payload'], true ); if ( ! is_array( $payload ) ) { continue; } $clean = array_diff_key( $payload, array_flip( self::EVENTS[ $row['event'] ] ) ); if ( $clean !== $payload ) { $wpdb->update( $table, array( 'payload'=>wp_json_encode( $clean ) ), array( 'id'=>(int) $row['id'] ) ); } } } }