get_meta( '_order_withdrawal_requested' ) === 'yes' && !$order->get_meta( '_sr_withdrawal_reviewed' ) && !$order->has_status( array( 'cancelled', 'failed', 'refunded', 'trash' ) ); } private static function require_no_open_return( \WC_Order $order ) : void { if ( self::pending_withdrawal( $order ) ) { throw new \InvalidArgumentException(I18n::t( 'Resolve the open return request first.' )); } foreach ( (array) (( $order->get_meta( '_storesuit_returns' ) ?: array() )) as $return ) { if ( in_array( $return['status'] ?? '', array('requested', 'approved'), true ) ) { throw new \InvalidArgumentException(I18n::t( 'Resolve the open return request first.' )); } } } public static function fulfil( int $id, array $data ) : array { return Store::atomic( static function () use($id, $data) { return self::fulfil_native( $id, $data ); } ); } /** Pro-only methods live under premium-suffixed names; old callers keep their names (paid build only). */ public static function __callStatic( string $name, array $args ) { throw new \BadMethodCallException(self::class . '::' . $name); } /** Whether every line, less refunds, was handed to the carrier or to the customer (0.62.4). */ public static function all_shipped( \WC_Order $order ) : bool { // At least one line must have quantity left: an order refunded line by line was never shipped. $all = (array) (( $order->get_meta( '_storesuit_fulfilment' ) ?: array() )); $has = false; foreach ( $order->get_items() as $iid => $item ) { $left = (float) $item->get_quantity() - abs( $order->get_qty_refunded_for_item( $iid ) ); if ( $left <= 0 ) { continue; } $has = true; if ( (float) ($all[$iid]['shipped'] ?? 0) < $left ) { return false; } } return $has; } /** * The most a line may hold at a stage (FUL-LONG-02): picking stops at the quantity less refunds, packing and shipping also at * what the stage before holds. Units refunded after they were packed are never shipped: the order's Ship, a line's step and * the order screen (order-desk.js shippable()) read this one rule. */ private static function stage_cap( \WC_Order $order, int $item_id, \WC_Order_Item $item, array $line, string $stage ) : float { $net = max( 0, (float) $item->get_quantity() - abs( $order->get_qty_refunded_for_item( $item_id ) ) ); return ( 'picked' === $stage ? $net : min( $net, (float) ($line[( 'packed' === $stage ? 'picked' : 'packed' )] ?? 0) ) ); } /** Whether a return of this order is requested or approved and not settled yet. */ public static function return_open( \WC_Order $order ) : bool { if ( self::pending_withdrawal( $order ) ) { return true; } foreach ( (array) (( $order->get_meta( '_storesuit_returns' ) ?: array() )) as $r ) { if ( in_array( $r['status'] ?? '', array('requested', 'approved'), true ) ) { return true; } } return false; } /** An earlier shipment of this order with the same carrier and tracking number already has its note. */ private static function noted_before( array $shipments, array $shipment ) : bool { foreach ( array_slice( $shipments, 0, -1 ) as $s ) { if ( (string) ($s['carrier_label'] ?? '') === (string) ($shipment['carrier_label'] ?? '') && (string) ($s['tracking'] ?? '') === (string) ($shipment['tracking'] ?? '') ) { return true; } } return false; } /** * An order paid in advance whose money has not come in is shipped or handed over only when the person says so (ORD-3, P1-04): * the request carries ship_unpaid, and the activity log keeps who did it and what was open. Cash on delivery and orders on * account (B2B terms) ship as before. Checked under the order's lock, before anything is written. */ private static function require_payment_or_consent( \WC_Order $order, array $data ) : float { if ( !Orders::unpaid_prepaid( $order ) ) { return 0.0; } $open = Orders::outstanding( $order ); $money = Orders::money_text( $open, $order->get_currency() ); // A store-pickup order is handed over, not shipped (FUL-LONG-01). if ( empty( $data['ship_unpaid'] ) ) { throw new \InvalidArgumentException(( (( $order->get_meta( '_storesuit_delivery' ) ?: 'shipping' )) === 'pickup' ? I18n::format( 'Payment not received yet (%s open). Confirm that you hand it over without waiting for payment.', $money ) : I18n::format( 'Payment not received yet (%s open). Confirm that you ship without waiting for payment.', $money ) )); } return $open; } /** The carrier this person used last (ORD-8, P3-08): the shipment screens preselect it while it is still offered. */ public const LAST_CARRIER = '_storesuit_last_carrier'; /** The error code of a shipment that already has another tracking number (shipment_tracking()); the router names it tracking_saved. */ public const TRACKING_SAVED = 4032; private static function remember_carrier( array $shipment ) : void { $user = get_current_user_id(); $key = (string) ($shipment['carrier'] ?? ''); if ( !$user || $key === '' ) { return; } update_user_meta( $user, self::LAST_CARRIER, array( 'key' => $key, 'label' => ( $key === 'other' ? mb_substr( (string) ($shipment['carrier_label'] ?? ''), 0, 80 ) : '' ), ) ); } /** A private note, so WooCommerce's own order screen shows the shipment too (0.62.4, Woo parity). */ private static function shipment_note( array $shipment ) : string { $carrier = ( (string) ($shipment['carrier_label'] ?? '') ?: I18n::t( 'carrier' ) ); $tracking = (string) ($shipment['tracking'] ?? ''); return ( $tracking !== '' ? I18n::format( 'StoreRuler: shipped with %1$s, tracking number %2$s.', $carrier, $tracking ) : I18n::format( 'StoreRuler: shipped with %s; no tracking number yet.', $carrier ) ); } public static function shipment_tracking( int $id, array $data ) : array { Store::require_cap( 'storesuit_fulfil' ); return Store::lock( 'order-' . $id, static function () use($id, $data) { $order = Orders::get( $id ); $shipment_id = Store::text( $data, 'shipment_id' ); $tracking = Store::text( $data, 'tracking' ); $key = Store::text( $data, 'request_key' ); if ( '' === $shipment_id || '' === $tracking || strlen( $tracking ) > 100 || !preg_match( '/^[a-zA-Z0-9_-]{16,80}$/', $key ) ) { throw new \InvalidArgumentException(I18n::t( 'A shipment and valid tracking number are required.' )); } $history = (array) (( $order->get_meta( '_storesuit_tracking_keys' ) ?: array() )); if ( in_array( $key, $history, true ) ) { return Orders::summary( $order ); } $shipments = (array) (( $order->get_meta( '_storesuit_shipments' ) ?: array() )); $found = false; $matched = null; $had = false; $previous = ''; foreach ( $shipments as &$shipment ) { if ( (string) ($shipment['id'] ?? '') !== $shipment_id || ($shipment['mode'] ?? '') === 'handover' ) { continue; } // Denetim R3-4: a mistyped number is corrected when the request says so (the screen asks first); the order's history // keeps both. A shipment whose EasyPost tracking follows the old number is unlinked first. if ( !empty( $shipment['tracking'] ) && (string) $shipment['tracking'] !== $tracking ) { if ( empty( $data['correct'] ) ) { throw new \InvalidArgumentException(I18n::t( 'A tracking number has already been saved for this shipment.' ), self::TRACKING_SAVED); } if ( !empty( $shipment['provider_tracker'] ) ) { throw new \InvalidArgumentException(I18n::t( 'EasyPost follows this shipment under its old tracking number. Remove the EasyPost link first, then correct the number.' )); } $previous = (string) $shipment['tracking']; } // The bought label's own tracking page reaches the customer here too (8.30 review I5). $had = !empty( $shipment['tracking'] ); $shipment['tracking'] = $tracking; $shipment = OperationalLinks::shipment( $order, $shipment ); $found = true; $matched = $shipment; break; } unset($shipment); if ( !$found ) { throw new \InvalidArgumentException(I18n::t( 'No shipment was found for this tracking number.' )); } $history[] = $key; $order->update_meta_data( '_storesuit_tracking_keys', array_slice( $history, -100 ) ); $order->update_meta_data( '_storesuit_shipments', $shipments ); $order->save(); if ( $matched && !$had ) { $order->add_order_note( I18n::format( 'StoreRuler: tracking number %1$s added to the shipment with %2$s.', $tracking, ( (string) ($matched['carrier_label'] ?? '') ?: I18n::t( 'carrier' ) ) ) ); } if ( $matched && $previous !== '' ) { $order->add_order_note( I18n::format( 'StoreRuler: the tracking number of the shipment with %1$s was corrected from %2$s to %3$s.', ( (string) ($matched['carrier_label'] ?? '') ?: I18n::t( 'carrier' ) ), $previous, $tracking ) ); Store::audit( 'order', $id, 'shipment_tracking_corrected', array( 'shipment' => $shipment_id, 'before' => $previous, 'tracking' => $tracking, ) ); return Orders::summary( $order ); } Store::audit( 'order', $id, 'shipment_tracking_added', array( 'shipment' => $shipment_id, 'tracking' => $tracking, ) ); return Orders::summary( $order ); } ); } private static function fulfil_native( int $id, array $data ) : array { Store::require_cap( 'storesuit_fulfil' ); return Store::lock( 'order-' . $id, static function () use($id, $data) { $order = Orders::get( $id ); SaleStatus::refuse_quote( $order ); // A line id names one of this order's lines, before anything else (FUL-LONG-03): WooCommerce loads any order's line by its id. $lines = $order->get_items(); $item_id = absint( $data['item_id'] ?? 0 ); if ( (!empty( $data['item_id'] ) || !in_array( Store::text( $data, 'stage' ), array('handover', 'ship_order'), true )) && !($lines[$item_id] ?? null) instanceof \WC_Order_Item_Product ) { throw new \InvalidArgumentException(I18n::t( 'Order line not found.' )); } if ( Store::text( $data, 'stage' ) === 'handover' ) { return self::handover( $order, $data ); } if ( $order->has_status( array( 'cancelled', 'failed', 'trash', 'refunded', 'pending', 'checkout-draft' ) ) ) { throw new \InvalidArgumentException(I18n::t( 'The order is not eligible for preparation.' )); } if ( Store::text( $data, 'stage' ) === 'ship_order' ) { return self::ship_order( $order, $data ); } $item = $lines[$item_id]; $action = Store::text( $data, 'stage' ); $qty = (float) wc_stock_amount( $data['qty'] ?? 0 ); if ( !in_array( $action, array('picked', 'packed', 'shipped'), true ) || $qty <= 0 || !is_finite( $qty ) ) { throw new \InvalidArgumentException(I18n::t( 'Invalid stage or quantity.' )); } $key = Store::text( $data, 'request_key' ); if ( !preg_match( '/^[a-zA-Z0-9_-]{16,80}$/', $key ) ) { throw new \InvalidArgumentException(I18n::t( 'The action key is missing.' )); } $history = ( $order->get_meta( '_storesuit_fulfil_keys' ) ?: array() ); if ( in_array( $key, $history, true ) ) { return Orders::summary( $order ); } self::require_no_open_return( $order ); $all = ( $order->get_meta( '_storesuit_fulfilment' ) ?: array() ); $line = $all[$item_id] ?? array( 'picked' => 0, 'packed' => 0, 'shipped' => 0, ); if ( $line[$action] + $qty > self::stage_cap( $order, $item_id, $item, $line, $action ) ) { throw new \InvalidArgumentException(I18n::t( 'The quantity exceeds the order or the amount completed in the previous stage.' )); } // A line handed over or shipped asks for the same payment consent as the order's Ship and handover (FUL-LONG-01). $unpaid = ( 'shipped' === $action ? self::require_payment_or_consent( $order, $data ) : 0.0 ); if ( 'picked' === $action ) { $product = $item->get_product(); $barcode = Store::text( $data, 'barcode' ); $gtin = ( $product ? ( method_exists( $product, 'get_global_unique_id' ) ? $product->get_global_unique_id() : $product->get_meta( '_global_unique_id' ) ) : '' ); $codes = ( $product ? array_filter( array( $product->get_sku(), $gtin, $product->get_meta( '_barcode' ), $product->get_meta( '_ean' ) ) ) : array() ); // A product with no code at all is picked by hand (0.62: it could not be picked); one with a code is scanned. if ( $codes && (!$barcode || !in_array( $barcode, $codes, true )) ) { throw new \InvalidArgumentException(I18n::t( 'Barcode or SKU did not match. Add an SKU or GTIN to the product.' )); } } if ( 'shipped' === $action ) { // A store-pickup line is handed over in person: no carrier and no shipping email. $shipment = ( (( $order->get_meta( '_storesuit_delivery' ) ?: 'shipping' )) === 'pickup' ? array( 'id' => wp_generate_uuid4(), 'mode' => 'handover', 'tracking' => mb_substr( Store::text( $data, 'tracking' ), 0, 100 ), ) : OperationalLinks::shipment( $order, Shipping::details( $data ) ) ); $shipments = ( $order->get_meta( '_storesuit_shipments' ) ?: array() ); $shipments[] = array_merge( $shipment, array( 'item_id' => $item_id, 'qty' => $qty, 'at' => gmdate( 'c' ), 'actor' => get_current_user_id(), ) ); $order->update_meta_data( '_storesuit_shipments', $shipments ); } $line[$action] += $qty; $all[$item_id] = $line; $history[] = $key; $order->update_meta_data( '_storesuit_fulfilment', $all ); $order->update_meta_data( '_storesuit_fulfil_keys', $history ); $order->save(); if ( 'shipped' === $action ) { if ( ($shipment['mode'] ?? '') !== 'handover' && !self::noted_before( $shipments, $shipment ) ) { $order->add_order_note( self::shipment_note( $shipment ) ); } if ( ($shipment['mode'] ?? '') !== 'handover' ) { self::remember_carrier( $shipment ); } } $complete = true; foreach ( $order->get_items() as $iid => $i ) { if ( ($all[$iid]['shipped'] ?? 0) < $i->get_quantity() - abs( $order->get_qty_refunded_for_item( $iid ) ) ) { $complete = false; break; } } // A COD shipment is not proof of payment. Preserve its debt/status. if ( $complete && Orders::outstanding( $order ) <= 0 ) { $order->update_status( 'completed', I18n::t( 'StoreRuler: all lines completed the delivery stage.' ) ); } Store::audit( 'order', $id, 'fulfil_' . $action, array( 'item' => $item_id, 'qty' => $qty, ) ); if ( $unpaid > 0 ) { Store::audit( 'order', $id, 'shipped_unpaid', array( 'open' => $unpaid, 'currency' => $order->get_currency(), ) ); } return Orders::summary( $order ); } ); } private static function ship_order( \WC_Order $order, array $data ) : array { if ( (( $order->get_meta( '_storesuit_delivery' ) ?: 'shipping' )) === 'pickup' ) { throw new \InvalidArgumentException(I18n::t( 'Use the in-person handover step for a store-pickup order.' )); } $key = Store::text( $data, 'request_key' ); if ( !preg_match( '/^[a-zA-Z0-9_-]{16,80}$/', $key ) ) { throw new \InvalidArgumentException(I18n::t( 'Review and confirm the shipment and packed products.' )); } // Saving the shipment is the confirmation; no separate handover tick (0.62.5, owner). $history = (array) (( $order->get_meta( '_storesuit_fulfil_keys' ) ?: array() )); if ( in_array( $key, $history, true ) ) { return Orders::summary( $order ); } self::require_no_open_return( $order ); $unpaid = self::require_payment_or_consent( $order, $data ); $all = (array) (( $order->get_meta( '_storesuit_fulfilment' ) ?: array() )); $lines = array(); $total = 0.0; foreach ( $order->get_items() as $item_id => $item ) { $line = $all[$item_id] ?? array( 'picked' => 0, 'packed' => 0, 'shipped' => 0, ); $remaining = max( 0, self::stage_cap( $order, (int) $item_id, $item, $line, 'shipped' ) - (float) $line['shipped'] ); if ( $remaining <= 0 ) { continue; } $line['shipped'] += $remaining; $all[$item_id] = $line; $total += $remaining; $lines[] = array( 'item_id' => (int) $item_id, 'qty' => $remaining, ); } if ( !$lines ) { throw new \InvalidArgumentException(I18n::t( 'There are no packed products to ship. Prepare the products first.' )); } // The order-level dialog pre-fills the bought label's carrier and tracking number: its tracking page goes with it (8.30 review I5). $shipment = OperationalLinks::shipment( $order, Shipping::details( $data ) ); $shipments = (array) (( $order->get_meta( '_storesuit_shipments' ) ?: array() )); $shipments[] = array_merge( $shipment, array( 'items' => $lines, 'qty' => $total, 'at' => gmdate( 'c' ), 'actor' => get_current_user_id(), ) ); $history[] = $key; $order->update_meta_data( '_storesuit_fulfilment', $all ); $order->update_meta_data( '_storesuit_fulfil_keys', $history ); $order->update_meta_data( '_storesuit_shipments', $shipments ); $order->save(); if ( !self::noted_before( $shipments, $shipment ) ) { $order->add_order_note( self::shipment_note( $shipment ) ); } $complete = true; foreach ( $order->get_items() as $item_id => $item ) { if ( (float) ($all[$item_id]['shipped'] ?? 0) < (float) $item->get_quantity() - abs( $order->get_qty_refunded_for_item( $item_id ) ) ) { $complete = false; break; } } if ( $complete && Orders::outstanding( $order ) <= 0 ) { $order->update_status( 'completed', I18n::t( 'StoreRuler: all products were handed to the carrier.' ) ); } Store::audit( 'order', $order->get_id(), 'fulfil_shipped', array( 'items' => $lines, 'qty' => $total, 'carrier' => $shipment['carrier'], 'tracking' => $shipment['tracking'], ) ); if ( $unpaid > 0 ) { Store::audit( 'order', $order->get_id(), 'shipped_unpaid', array( 'open' => $unpaid, 'currency' => $order->get_currency(), ) ); } self::remember_carrier( $shipment ); return Orders::summary( $order ); } private static function handover( \WC_Order $order, array $data ) : array { $key = Store::text( $data, 'request_key' ); $reference = Store::text( $data, 'tracking' ); if ( !preg_match( '/^[a-zA-Z0-9_-]{16,80}$/', $key ) || $reference === '' || empty( $data['confirm_handover'] ) ) { throw new \InvalidArgumentException(I18n::t( 'The recipient or reference and physical handover confirmation are required.' )); } $keys = (array) (( $order->get_meta( '_storesuit_fulfil_keys' ) ?: array() )); if ( in_array( $key, $keys, true ) ) { return Orders::summary( $order ); } SaleStatus::refuse_quote( $order ); if ( $order->has_status( array( 'cancelled', 'failed', 'refunded', 'pending', 'trash', 'checkout-draft' ) ) ) { throw new \InvalidArgumentException(I18n::t( 'The order is not eligible for handover.' )); } self::require_no_open_return( $order ); $unpaid = self::require_payment_or_consent( $order, $data ); $all = (array) (( $order->get_meta( '_storesuit_fulfilment' ) ?: array() )); $shipments = (array) (( $order->get_meta( '_storesuit_shipments' ) ?: array() )); $changed = false; foreach ( $order->get_items() as $iid => $item ) { $qty = max( 0, (float) $item->get_quantity() - abs( $order->get_qty_refunded_for_item( $iid ) ) ); $remaining = $qty - (float) ($all[$iid]['shipped'] ?? 0); if ( $remaining <= 0 ) { continue; } $all[$iid] = array( 'picked' => $qty, 'packed' => $qty, 'shipped' => $qty, ); $shipments[] = array( 'item_id' => $iid, 'qty' => $remaining, 'tracking' => $reference, 'mode' => 'handover', 'at' => gmdate( 'c' ), 'actor' => get_current_user_id(), ); $changed = true; } if ( !$changed ) { throw new \InvalidArgumentException(I18n::t( 'There are no remaining products to hand over.' )); } $keys[] = $key; $order->update_meta_data( '_storesuit_fulfil_keys', $keys ); $order->update_meta_data( '_storesuit_fulfilment', $all ); $order->update_meta_data( '_storesuit_shipments', $shipments ); $order->save(); if ( Orders::outstanding( $order ) <= 0 ) { $order->update_status( 'completed', I18n::t( 'StoreRuler: handed over in person at the store.' ) ); } Store::audit( 'order', $order->get_id(), 'handover', array( 'reference' => $reference, ) ); if ( $unpaid > 0 ) { Store::audit( 'order', $order->get_id(), 'shipped_unpaid', array( 'open' => $unpaid, 'currency' => $order->get_currency(), ) ); } return Orders::summary( $order ); } }