'POS', 'instagram' => 'Instagram', 'whatsapp' => 'WhatsApp', 'facebook' => 'Facebook', 'phone' => 'Phone', 'b2b' => 'B2B', 'other' => 'Other', ); /** The marketplaces whose orders a channel is named after, StoreRuler's own (paid build) or another plugin's (market()). */ public const MARKETS = array( 'trendyol' => 'Trendyol', 'hepsiburada' => 'Hepsiburada', 'n11' => 'n11', 'ciceksepeti' => 'Çiçeksepeti', 'pazarama' => 'Pazarama', 'amazon' => 'Amazon', ); /** The address domains a marketplace gives its customers in the orders it passes on. */ private const MARKET_DOMAINS = array( 'trendyolmail.com' => 'trendyol', ); /** Correct grouping on legacy Turkish orders without rewriting their saved contact details. */ private static function phone_display( string $phone ) : string { if ( (wc_get_base_location()['country'] ?? '') !== 'TR' ) { return $phone; } $digits = (string) preg_replace( '/\\D+/', '', $phone ); $international = str_starts_with( $digits, '90' ) && strlen( $digits ) === 12; $national = ( $international ? substr( $digits, 2 ) : ltrim( $digits, '0' ) ); return ( strlen( $national ) === 10 ? (( $international ? '+90 ' : (( str_starts_with( $digits, '0' ) ? '0' : '' )) )) . substr( $national, 0, 3 ) . ' ' . substr( $national, 3, 3 ) . ' ' . substr( $national, 6, 2 ) . ' ' . substr( $national, 8, 2 ) : $phone ); } /** * The cash-on-delivery rule cod_unreceived() applies (persona fix ORD-2, owner decision OD-3, 9 Oct 2026). The paid build keeps * 0.65.25's rule (paid when its money is recorded); the free build now has one of its own: paid when the delivery is confirmed * (StoreRuler's "Confirm delivery", an in-person handover, or the order completed). Changing the rule changes this name, and * Upgrade::cod_rule_reread() then reads the index's cash-on-delivery rows again once (fix-B02). */ public const COD_RULE = '1.2.12-ord2'; /** An order made at the desk with "E-mail the customer" off. */ public const NO_MAIL = '_storesuit_mail_customer'; /** The customer's status e-mails the desk switch holds back; the ones the team sends by hand (order details, a note) still go. */ private const ORDER_MAILS = array( 'customer_on_hold_order', 'customer_processing_order', 'customer_completed_order', 'customer_refunded_order', 'customer_partially_refunded_order', 'customer_cancelled_order', 'customer_failed_order' ); public static function hooks() : void { // Denetim R3-5: an order made at the desk mailed its customer twice, "on hold" when it was made and "processing" when its // payment came (WooCommerce's Turkish texts give both the same subject). A desk order never sends the "on hold" one: the // team just took the order with the customer; the next status mail is the first. With the desk switch off, none is sent. foreach ( self::ORDER_MAILS as $email ) { add_filter( 'woocommerce_email_enabled_' . $email, static function ( $enabled, $order = null ) use($email) { if ( !$enabled || !$order instanceof \WC_Order || $order->get_created_via() !== 'storesuit' ) { return $enabled; } return $email !== 'customer_on_hold_order' && $order->get_meta( self::NO_MAIL ) !== 'no'; }, 20, 2 ); } add_filter( 'woocommerce_valid_order_statuses_for_payment', static function ( $statuses, $order ) { if ( $order instanceof \WC_Order && $order->get_created_via() === 'storesuit' && $order->get_meta( '_storesuit_payment_mode' ) === 'link' && !$order->get_meta( '_storesuit_payments' ) ) { $statuses[] = 'on-hold'; } return $statuses; }, 10, 2 ); add_filter( 'woocommerce_order_needs_payment', static function ( $needs, $order ) { return ( $order->get_created_via() === 'storesuit' && $order->get_meta( '_storesuit_payments' ) ? false : $needs ); }, 10, 2 ); } /** * The order as the database holds it now, for a check made under its lock: the copies this request keeps (WooCommerce's * order cache, the post and meta caches, its items and refunds) are dropped first, since one loaded before the lock may * predate another request's commit (review 0.58 L11). */ public static function fresh( int $id ) : \WC_Order { if ( class_exists( '\\Automattic\\WooCommerce\\Caches\\OrderCache' ) ) { wc_get_container()->get( \Automattic\WooCommerce\Caches\OrderCache::class )->remove( $id ); } clean_post_cache( $id ); wp_cache_delete( 'order-items-' . $id, 'orders' ); wp_cache_delete( \WC_Data::generate_meta_cache_key( $id, 'orders' ), 'orders' ); foreach ( array('refunds', 'total_refunded') as $key ) { wp_cache_delete( \WC_Cache_Helper::get_cache_prefix( 'orders' ) . $key . $id, 'orders' ); } return self::get( $id ); } public static function get( int $id ) : \WC_Order { $order = wc_get_order( $id ); if ( !$order instanceof \WC_Order || $order instanceof \WC_Order_Refund ) { throw new \InvalidArgumentException(I18n::t( 'Order not found.' )); } return $order; } public static function net_price( \WC_Product $product, float $raw ) : float { if ( wc_prices_include_tax() && $product->is_taxable() ) { return $raw - array_sum( \WC_Tax::calc_tax( $raw, \WC_Tax::get_base_tax_rates( $product->get_tax_class() ), true ) ); } return $raw; } /** 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); } /** The private order notes the new sale saved for the team (ORD-5): their ids, kept so the privacy tools find them. */ public const INTERNAL_NOTES = '_storesuit_internal_notes'; public static function collected( \WC_Order $order ) : float { $payments = $order->get_meta( '_storesuit_payments' ); $gross = ( is_array( $payments ) && $payments ? array_sum( array_column( $payments, 'amount' ) ) : (( self::paid_date( $order ) || self::paid_by_status( $order ) ? (float) $order->get_total() : 0 )) ); $writeoff = (float) $order->get_meta( '_storesuit_refund_writeoff' ); return max( 0, $gross - (float) $order->get_total_refunded() + $writeoff ); } /** * Paid by its status though no paid date is stored (admin audit A2, 28 Oct 2026): WooCommerce stores one when an order moves * to a paid status (wc_get_is_paid_statuses()), but an order another plugin writes straight into processing or completed, or * into a status of its own for a shipped order (shipped_status()), carries none. Cash on delivery stays unpaid until its money * is in (cod_unreceived()); StoreRuler's own sales (its channel or payment record on the order) record their payments. */ private static function paid_by_status( \WC_Order $order ) : bool { if ( self::is_cod( $order ) || $order->get_created_via() === 'storesuit' || $order->get_meta( '_storesuit_channel' ) || $order->get_meta( '_storesuit_payment_mode' ) ) { return false; } return $order->has_status( wc_get_is_paid_statuses() ) || self::shipped_status( $order ); } /** * A status another plugin gives an order handed to the carrier or delivered (admin audit A2: a marketplace sync plugin's * "shipped", not even registered with WooCommerce): read by its name and label, never one of WooCommerce's own statuses nor * one that waits for the shipment ("awaiting shipment"). Filterable for a status named otherwise. */ public static function shipped_status( \WC_Order $order ) : bool { $status = (string) $order->get_status(); if ( in_array( $status, array( 'pending', 'processing', 'on-hold', 'completed', 'cancelled', 'refunded', 'failed', 'checkout-draft', 'trash', 'auto-draft' ), true ) || SaleStatus::quote( $order ) ) { return false; } $name = $status . ' ' . (string) (wc_get_order_statuses()['wc-' . $status] ?? ''); $shipped = (bool) preg_match( '/ship|deliver|dispatch|kargo|teslim|sevk|versand|geliefert|enviad|entregad/iu', $name ) && !preg_match( '/await|wait|ready|prepar|bekl|hazır|hazir|pending/iu', $name ); return (bool) apply_filters( 'storesuit_order_shipped_status', $shipped, $status, $order ); } /** * WooCommerce's paid date as StoreRuler's own figures read it: every StoreRuler reader that decides paid, due or collected * asks here (0.65.25's cash-on-delivery rule, no longer a site-wide woocommerce_order_get_date_paid filter). Cash on * delivery is paid when its money is recorded, not because WooCommerce stored a date: it does on completion, on an admin * move to processing, and imported or sample orders may carry one. WooCommerce's own value is never filtered, so its order * screen, REST API, emails, Analytics and other plugins keep reading the stored date. * * @return \WC_DateTime|null */ public static function paid_date( \WC_Order $order ) { $date = $order->get_date_paid(); if ( self::cod_unreceived( $order ) ) { return null; } // The free build's cash on delivery was paid when its delivery was confirmed: that moment, whatever date WooCommerce stored // at processing, or none for an order on hold (ORD-2). if ( self::cod_paid_on_delivery( $order ) ) { return self::cod_delivered_at( $order ) ?? $date; } return $date; } /** When the free build's cash on delivery was paid (cod_delivered()): the delivery confirmation, the handover, or the completion. */ private static function cod_delivered_at( \WC_Order $order ) : ?\WC_DateTime { $confirmed = $order->get_meta( '_storesuit_delivery_confirmed' ); $at = ( is_array( $confirmed ) ? (string) ($confirmed['at'] ?? '') : '' ); if ( $at === '' && !$order->has_status( 'completed' ) ) { $last = (array) (( $order->get_meta( '_storesuit_shipments' ) ?: array() )); $last = end( $last ); $at = ( is_array( $last ) && ($last['mode'] ?? '') === 'handover' ? (string) ($last['at'] ?? '') : '' ); } if ( $at !== '' ) { try { $date = new \WC_DateTime($at, new \DateTimeZone('UTC')); $date->setTimezone( new \DateTimeZone(wc_timezone_string()) ); return $date; } catch ( \Exception $e ) { unset($e); } } return ( $order->get_date_completed() ?: null ); } /** Paid by cash on delivery: WooCommerce's cod method, or a manual sale of the cod payment type (never a till sale or return). */ public static function is_cod( \WC_Order $order ) : bool { $cod = $order->get_payment_method() === 'cod' || $order->get_meta( '_storesuit_payment_mode' ) === 'cod'; return $cod; } /** * A cash-on-delivery order whose money has not come in yet (COD_RULE). A till sale or till return keeps its money in the till's * own records. * - Paid build: no collection of a positive amount that is not trashed (0.65.25). * - Free build (ORD-2, OD-3): the free build records no collections, and WooCommerce stores a paid date as soon as a cash-on-delivery * checkout moves to processing, so that date is no receipt. The order is unpaid until its delivery is confirmed (cod_delivered()). */ public static function cod_unreceived( \WC_Order $order ) : bool { if ( !self::is_cod( $order ) ) { return false; } $unreceived = !self::cod_delivered( $order ); return $unreceived; } /** * The free build's proof that cash on delivery was paid: the delivery was confirmed in StoreRuler (OrderFlow::advance('delivered')), * every product was handed over in person (the customer paid at the handover), or the order is completed (StoreRuler completes a * cash-on-delivery order only once it is paid; a merchant who completes it in WooCommerce says it is done). */ private static function cod_delivered( \WC_Order $order ) : bool { if ( $order->get_meta( '_storesuit_delivery_confirmed' ) || $order->has_status( 'completed' ) ) { return true; } $shipments = (array) (( $order->get_meta( '_storesuit_shipments' ) ?: array() )); if ( !$shipments || !Operations::all_shipped( $order ) ) { return false; } foreach ( $shipments as $shipment ) { if ( !is_array( $shipment ) || ($shipment['mode'] ?? '') !== 'handover' ) { return false; } } return true; } /** Whether the order's cash on delivery is paid by confirming its delivery (the free build's rule); false in the paid build. */ public static function cod_paid_on_delivery( \WC_Order $order ) : bool { $on_delivery = self::is_cod( $order ); return $on_delivery; } /** * An order paid in advance whose money has not come in (ORD-3): a balance is open and it is no cash on delivery, no order on * account (B2B terms), no till sale and no checkout left before payment. Shipping it waits for a confirmation (Operations). */ public static function unpaid_prepaid( \WC_Order $order ) : bool { if ( self::is_cod( $order ) || $order->get_meta( '_storesuit_payment_mode' ) === 'terms' || self::abandoned_checkout( $order ) ) { return false; } $prepaid = true; return $prepaid && self::outstanding( $order ) > 0; } /** An amount as plain text in the order's currency ("₺1.009,40"), for a message the screen shows with textContent. */ public static function money_text( float $amount, string $currency ) : string { return trim( Store::plain( wp_strip_all_tags( wc_price( $amount, array( 'currency' => $currency, ) ) ) ) ); } /** * Why the free build's "Mark as paid" cannot be used on this order ('' when it can). Owner decision OD-3: an on-hold or pending * order with a balance; cash on delivery is paid by confirming its delivery instead. */ private static function mark_paid_refusal( \WC_Order $order ) : string { if ( SaleStatus::quote( $order ) || !$order->has_status( array('on-hold', 'pending') ) || self::outstanding( $order ) <= 0 ) { return I18n::t( 'Only an on-hold or pending order with an open balance can be marked as paid. Refresh the order.' ); } if ( self::is_cod( $order ) ) { return I18n::t( 'Cash on delivery is paid when you confirm the delivery.' ); } return ''; } /** Whether the order screen offers "Mark as paid": the free build only, for an order mark_paid() accepts. */ public static function can_mark_paid( \WC_Order $order ) : bool { $can = current_user_can( 'storesuit_finance' ) && self::mark_paid_refusal( $order ) === ''; // The paid build records the payment ("I received the payment"). return $can; } /** * The free build's "Mark as paid" (OD-3): WooCommerce's own step for an order whose payment arrived (WC_Order::payment_complete(): * the paid date, Processing, or Completed when every product was already shipped, and WooCommerce's own e-mail). The full amount, * no method, no partial amount, no StoreRuler payment record. A second request for an order already paid changes nothing. */ public static function mark_paid( int $id, array $data ) : array { Store::require_cap( 'storesuit_finance' ); if ( empty( $data['confirmed'] ) ) { throw new \InvalidArgumentException(I18n::t( 'Review and confirm the action.' )); } return Store::atomic( static fn() => Store::lock( 'order-' . $id, static function () use($id) { $order = self::fresh( $id ); if ( self::outstanding( $order ) <= 0 && !$order->has_status( array('on-hold', 'pending') ) ) { return self::summary( $order ); } $why = self::mark_paid_refusal( $order ); if ( $why !== '' ) { throw new \InvalidArgumentException($why); } $amount = self::outstanding( $order ); if ( Operations::all_shipped( $order ) && !Operations::return_open( $order ) ) { // Paid after it was shipped: the order is done, not back to "being prepared" (the paid build's rule, Finance::collect()). $done = static function ( $status, $order_id = 0 ) use($id) { return ( (int) $order_id === $id ? 'completed' : $status ); }; add_filter( 'woocommerce_payment_complete_order_status', $done, PHP_INT_MAX, 2 ); try { $order->payment_complete(); } finally { remove_filter( 'woocommerce_payment_complete_order_status', $done, PHP_INT_MAX ); } } else { $order->payment_complete(); } if ( !$order->get_date_paid() ) { $order->set_date_paid( time() ); $order->save(); } $order->add_order_note( I18n::t( 'StoreRuler: marked as paid in full by hand. No money was charged.' ) ); Store::audit( 'order', $id, 'marked_paid', array( 'amount' => $amount, 'currency' => $order->get_currency(), ) ); OrderIndex::changed( $id ); return self::summary( self::fresh( $id ) ); } ) ); } /** * A phone number as WhatsApp's wa.me link takes it: digits with the country calling code, '' unless that gives 8 to 15 digits * (ORD-8, the rule of Purchasing::whatsapp_number()). A number written with "+" or "00" keeps its own code; another one gets the * calling code of $country (the order's billing country), or the store's. */ public static function whatsapp_number( string $raw, string $country = '' ) : string { $raw = trim( $raw ); $digits = (string) preg_replace( '/\\D+/', '', $raw ); if ( $digits === '' ) { return ''; } if ( str_starts_with( $raw, '+' ) ) { $number = $digits; } elseif ( str_starts_with( $digits, '00' ) ) { $number = substr( $digits, 2 ); } else { $countries = ( function_exists( 'WC' ) && WC() ? WC()->countries : null ); $code = ( $countries ? (string) $countries->get_country_calling_code( ( $country !== '' ? $country : $countries->get_base_country() ) ) : '' ); $code = ltrim( (string) preg_replace( '/\\D+/', '', $code ), '0' ); if ( $code === '' ) { $number = ltrim( $digits, '0' ); } elseif ( str_starts_with( $digits, '0' ) ) { $number = $code . ltrim( $digits, '0' ); } elseif ( strlen( $digits ) > 10 && str_starts_with( $digits, $code ) ) { $number = $digits; } else { $number = ( strlen( $digits ) <= 10 ? $code . $digits : $digits ); } } return ( preg_match( '/^[1-9]\\d{7,14}$/D', $number ) ? $number : '' ); } /** Whether the registered customer said no to WhatsApp messages (the customer profile's contact preferences). */ private static function whatsapp_declined( \WC_Order $order ) : bool { $user = $order->get_customer_id(); $profile = ( $user ? get_user_meta( $user, '_storesuit_customer_profile', true ) : '' ); return is_array( $profile ) && ($profile['contact']['whatsapp']['status'] ?? '') === 'declined'; } /** The order's WhatsApp number for wa.me ('' when there is none, or the registered customer declined WhatsApp contact). */ private static function whatsapp( \WC_Order $order ) : string { return ( self::whatsapp_declined( $order ) ? '' : self::whatsapp_number( (string) $order->get_billing_phone(), (string) $order->get_billing_country() ) ); } /** * The store's bank accounts WooCommerce's bank transfer method shows its customers, for the order summary message of an unpaid * bank transfer (ORD-8, P1-06): bank, account name, IBAN or account number, BIC. None for another order. */ private static function bank_accounts( \WC_Order $order ) : array { if ( $order->get_payment_method() !== 'bacs' || self::outstanding( $order ) <= 0 ) { return array(); } $out = array(); foreach ( (array) get_option( 'woocommerce_bacs_accounts', array() ) as $account ) { if ( !is_array( $account ) ) { continue; } $row = array(); foreach ( array( 'bank_name', 'account_name', 'iban', 'account_number', 'bic' ) as $key ) { $row[$key] = Store::plain( sanitize_text_field( (string) ($account[$key] ?? '') ) ); } if ( $row['iban'] !== '' || $row['account_number'] !== '' ) { $out[] = $row; } } return array_slice( $out, 0, 3 ); } public static function outstanding( \WC_Order $order ) : float { if ( SaleStatus::quote( $order ) ) { return 0.0; } // A quote owes nothing until the customer accepts it (0.62.9). if ( $order->has_status( array('cancelled', 'failed', 'refunded') ) ) { return 0.0; } // The marketplace takes the customer's money and pays the store itself: another plugin's marketplace order owes nothing here (admin audit A2). if ( self::market_import( $order ) ) { return 0.0; } return max( 0, round( (float) $order->get_total() - (float) $order->get_total_refunded() - self::collected( $order ), wc_get_price_decimals() ) ); } /** WooCommerce order-pay charges the stored total; do not share a link that would charge more than the true balance. */ public static function usable_payment_link( \WC_Order $order ) : bool { if ( SaleStatus::quote( $order ) || !$order->needs_payment() ) { return false; } $due = self::outstanding( $order ); return $due > 0 && abs( $due - (float) $order->get_total() ) <= 1.0E-6; } /** * Cancels the order. A paid one (cancel_refund()) is refunded in full with it: $data says how ('refund_way' manual or * gateway) and repeats what the screen showed ('expected_refund', 'expected_gateway'); by hand needs the person's * 'confirm_manual_refund'. Through the provider, the provider is asked outside the store's transaction, under the order's * lock, which stays taken if the request dies while it answers (the order then needs an administrator's look). */ public static function cancel( int $id, array $data = array() ) : array { Store::require_cap( 'storesuit_orders' ); if ( Store::text( $data, 'refund_way' ) === 'gateway' ) { return self::cancel_through_provider( $id, $data ); } // Under the order's lock, like buying a label: a label bought at this moment and the cancellation cannot both win. return Store::atomic( static fn() => Store::lock( 'order-' . $id, static function () use($id, $data) { $order = self::get( $id ); if ( $order->has_status( 'cancelled' ) ) { return self::summary( $order ); } $money = self::cancel_checks( $order, $data, false ); $done = ''; if ( $money ) { if ( Store::text( $data, 'confirm_manual_refund' ) !== '1' ) { throw new \InvalidArgumentException(I18n::format( 'Confirm that you give %s back to the customer yourself.', self::money_text( $money['amount'], $money['currency'] ) )); } $refund = self::refund_whole( $order, $money, false ); if ( is_wp_error( $refund ) ) { throw new \RuntimeException($refund->get_error_message()); } $done = 'manual'; $order = self::get( $id ); } return self::cancel_now( $order, $money, $done ); } ) ); } /** A paid order cancelled with its money sent back through its payment provider (cancel()). */ private static function cancel_through_provider( int $id, array $data ) : array { return Store::lock( 'order-' . $id, static function () use($id, $data) { // Checked as committed now; the provider is not asked inside a transaction (its answer can take a minute). $money = Store::atomic( static fn() => self::cancel_checks( self::get( $id ), $data, true ) ); $order = self::get( $id ); $order->update_meta_data( self::CANCEL_REFUND, array( 'amount' => $money['amount'], 'gateway' => $money['gateway']['id'], 'at' => gmdate( 'c' ), 'by' => get_current_user_id(), ) ); $order->save(); try { $refund = self::refund_whole( $order, $money, true ); } catch ( \Throwable $thrown ) { $refund = new \WP_Error('storesuit_refund', $thrown->getMessage()); } $order = self::get( $id ); if ( is_wp_error( $refund ) || !$refund->get_refunded_payment() ) { // An error past WooCommerce's own handling left its refund saved: whether the money left is not known. if ( (float) $order->get_total_refunded() > 0.0 ) { throw new \RuntimeException(I18n::format( 'It is not known whether %s sent the money back. Check the payment there before you cancel the order.', $money['gateway']['title'] )); } // WooCommerce deletes its refund when the provider refuses: nothing left, so the mark goes and a retry may ask again. $order->delete_meta_data( self::CANCEL_REFUND ); $order->save(); $why = ( is_wp_error( $refund ) ? rtrim( trim( wp_strip_all_tags( $refund->get_error_message() ) ), '.' ) : '' ); throw new \RuntimeException(( $why !== '' ? I18n::format( 'The payment provider did not refund the money (%s). Nothing was changed.', $why ) : I18n::t( 'WooCommerce could not save the refund, so the payment provider was not asked. Nothing was changed.' ) )); } return Store::atomic( static fn() => self::cancel_now( self::get( $id ), $money, 'gateway' ) ); } ); } /** * Refuses a cancellation the order does not allow; returns the refund it makes (cancel_refund()) or null. A refund must be * the one the screen showed, and through the provider ($provider) only when the store sends it that way. */ private static function cancel_checks( \WC_Order $order, array $data, bool $provider ) : ?array { if ( $provider && $order->has_status( 'cancelled' ) ) { throw new \InvalidArgumentException(I18n::t( 'This order is already cancelled.' )); } $id = $order->get_id(); $why = self::cancel_block( $order ); if ( $why !== '' ) { $refused = I18n::t( 'An order with payment, refund, or preparation activity cannot be cancelled directly. Review it in its WooCommerce record.' ); // Returns: paid build. if ( $why === 'marketplace' ) { $refused = I18n::t( 'Handle marketplace orders on the marketplace; StoreRuler follows their status.' ); } elseif ( $why === 'till' ) { $refused = I18n::t( 'A till sale is not cancelled here. Void it or take the products back at the till.' ); } elseif ( $why === 'rights' ) { $refused = I18n::t( 'Only someone who may edit WooCommerce orders can cancel this order.' ); } elseif ( $why === 'refund_rights' ) { $refused = I18n::t( 'This order is paid. Only someone who may refund payments can cancel it.' ); } elseif ( $why === 'provider' ) { $refused = I18n::t( 'The payment provider was asked to refund this order and the outcome is not recorded. Check the payment there, then finish in the WooCommerce record.' ); } elseif ( $why === 'other' || $why === 'closed' ) { $refused = I18n::t( 'This order cannot be cancelled here. Review it in its WooCommerce record.' ); } throw new \InvalidArgumentException($refused); } $money = self::cancel_refund( $order ); if ( !$money ) { if ( $provider ) { throw new \InvalidArgumentException(I18n::t( 'The amounts changed. Refresh the preview.' )); } return null; } // What the screen showed comes back: the amount, and the provider when it sends the money. if ( !isset( $data['expected_refund'] ) || abs( (float) wc_format_decimal( $data['expected_refund'] ) - $money['amount'] ) > 1.0E-6 ) { throw new \InvalidArgumentException(I18n::t( 'The amounts changed. Refresh the preview.' )); } if ( $provider && (!$money['gateway'] || Store::text( $data, 'expected_gateway' ) !== $money['gateway']['id']) ) { throw new \InvalidArgumentException(I18n::t( 'The amounts changed. Refresh the preview.' )); } return $money; } /** Sets the order cancelled (WooCommerce puts its stock back), with a note of who did and how its money went back ($done). */ private static function cancel_now( \WC_Order $order, ?array $money, string $done ) : array { $from = (string) $order->get_status(); $user = wp_get_current_user(); $name = ( $user->exists() ? $user->display_name : '' ); $note = ( $name !== '' ? I18n::format( 'StoreRuler: cancelled by %s; WooCommerce puts the stock back.', $name ) : I18n::t( 'StoreRuler: cancelled by the user; WooCommerce will restore stock.' ) ); $paid = ( $money ? self::money_text( $money['amount'], $money['currency'] ) : '' ); if ( $done === 'manual' ) { $note = I18n::format( 'StoreRuler: cancelled by %1$s; %2$s was refunded by hand outside WooCommerce. WooCommerce puts the stock back.', ( $name ?: '-' ), $paid ); } if ( $done === 'gateway' ) { $note = I18n::format( 'StoreRuler: cancelled by %1$s; %2$s was refunded via %3$s. WooCommerce puts the stock back.', ( $name ?: '-' ), $paid, $money['gateway']['title'] ); } $order->delete_meta_data( self::CANCEL_REFUND ); $order->update_status( 'cancelled', $note ); $audit = array( 'from' => $from, ) + (( $done !== '' ? array( 'refund' => $money['amount'], 'refund_way' => $done, ) + (( $done === 'gateway' ? array( 'gateway' => $money['gateway']['id'], ) : array() )) : array() )); Store::audit( 'order', $order->get_id(), 'cancelled', $audit ); return self::summary( $order ); } /** Whether StoreSuit may cancel the order; $labels = false leaves its shipping labels out (the caller checks them). */ /** * An abandoned checkout payment: the store's own checkout order still waiting for a payment that never came (pending, or * a block-checkout draft). It is no purchase and no money owed. Every other pending order is (Codex CUST-01; second look N3; * 0.62.12): one the team made (manual, register, WordPress admin), an integration's (REST), an accepted quote waiting for * its payment, a plugin's, a sample order. A block-checkout draft never is. */ public static function abandoned_checkout( \WC_Order $order ) : bool { if ( $order->has_status( 'checkout-draft' ) ) { return true; } return $order->has_status( 'pending' ) && in_array( $order->get_created_via(), self::web_checkout_sources(), true ); } /** Where a pending order is a shopper's own checkout attempt (classic and block checkout; filterable). */ public static function web_checkout_sources() : array { return array_values( array_unique( array_filter( array_map( 'sanitize_key', (array) apply_filters( 'storesuit_web_checkout_sources', array('checkout', 'store-api') ) ) ) ) ); } /** * The order's sales channel key (Orders::CHANNELS, or 'web'): StoreRuler's own record of it, else 'web'. The paid build * also recognises its till's sales, which carry no record of their own (0.63: one place for the order screen, the order * index, the customer card and the reporting facts). */ public static function channel( \WC_Order $order ) { return ( $order->get_meta( '_storesuit_channel' ) ?: (( self::till_record( $order ) ? 'pos' : (( self::market( $order ) ?: 'web' )) )) ); } /** * A sale made at a counter (Almira audit SAT-2, 28 Oct 2026: four screens answered this four ways, so 323 sales another * till plugin recorded were "POS" in the Order desk, yet one of them asked to be packed and shipped). StoreRuler's record * of the channel says so, or the order was recorded by a till: StoreRuler's own (paid build) or another one, named by the * order's created_via or _order_source (till_source()). A return made at StoreRuler's till is not a sale. Every screen that * asks "was this sold at a counter" asks this; own_till_sale() is the narrower "is its money in StoreRuler's drawer". */ public static function store_sale( \WC_Order $order ) : bool { $sale = (string) $order->get_meta( '_storesuit_channel' ) === 'pos' || self::till_record( $order ); return (bool) apply_filters( 'storesuit_store_sale', $sale, $order ); } /** A sale StoreRuler's own till recorded, whose money is in its drawer (paid build; never in the free build). */ public static function own_till_sale( \WC_Order $order ) : bool { $own = false; return $own; } /** * The till that recorded an order, from its created_via or _order_source, or ''. A name on the list (stores add theirs * with the storesuit_till_sources filter) or one ending in "-pos" ("almira-pos", "rpsw-pos"); nothing matches part of a * word, so "deposit" or "pos-test-import" are not tills. */ public static function till_source( \WC_Order $order ) : string { $names = self::till_names(); foreach ( array((string) $order->get_created_via(), (string) $order->get_meta( '_order_source' )) as $source ) { $source = sanitize_key( $source ); if ( $source !== '' && (in_array( $source, $names, true ) || preg_match( '/^[a-z0-9_]+-pos$/', $source )) ) { return $source; } } return ''; } /** The till names till_source() knows besides the "-pos" ending. @return string[] */ public static function till_names() : array { return array_values( array_unique( array_filter( array_map( 'sanitize_key', (array) apply_filters( 'storesuit_till_sources', array( 'pos', 'wcpos', 'woocommerce-pos', 'point-of-sale' ) ) ) ) ) ); } /** Recorded by a till (till_source(), or StoreRuler's own in the paid build), returns included. */ private static function till_record( \WC_Order $order ) : bool { $till = self::till_source( $order ) !== ''; return $till; } /** * The marketplace an order came from (a MARKETS key), or '' (admin audit A2, 28 Oct 2026: a store's 85 Trendyol orders, brought * in by a sync plugin, read as web orders). StoreRuler's own record first; else what such a plugin leaves: the source it * names (_order_source, the order's created_via), the address domain the marketplace gives its customers, or its order * number meta ("_trendyol_order_number"). Filterable for a plugin that marks its orders otherwise. */ public static function market( \WC_Order $order ) : string { $own = (string) $order->get_meta( '_storesuit_channel' ); if ( $own !== '' ) { return ( isset( self::MARKETS[$own] ) ? $own : '' ); } $keys = implode( '|', array_keys( self::MARKETS ) ); $found = ''; foreach ( array((string) $order->get_meta( '_order_source' ), (string) $order->get_created_via()) as $hint ) { // "trendyol", "trendyol-sync"; never a payment service named after one ("amazon_pay"). if ( preg_match( '/^(' . $keys . ')(?:$|[\\s_.-])(?!.*pay)/i', trim( $hint ), $m ) ) { $found = strtolower( $m[1] ); break; } } if ( $found === '' ) { $found = self::MARKET_DOMAINS[strtolower( substr( (string) strrchr( (string) $order->get_billing_email(), '@' ), 1 ) )] ?? ''; } if ( $found === '' ) { foreach ( $order->get_meta_data() as $meta ) { if ( preg_match( '/^_(' . $keys . ')_order_(?:id|number)$/', (string) $meta->key, $m ) ) { $found = $m[1]; break; } } } $found = sanitize_key( (string) apply_filters( 'storesuit_order_market', $found, $order ) ); return ( isset( self::MARKETS[$found] ) ? $found : '' ); } /** * An order another plugin brought in from a marketplace (market(), no StoreRuler record of its own): the marketplace, its * order number there and the carrier and tracking number the plugin stored, or null. The marketplace collects the money, * ships through its own carrier contract and handles returns; the order screen says so instead of offering a label. */ public static function market_import( \WC_Order $order ) : ?array { if ( (string) $order->get_meta( '_storesuit_channel' ) !== '' ) { return null; } $market = self::market( $order ); if ( $market === '' ) { return null; } $read = static function ( array $suffixes ) use($order, $market) : string { foreach ( $suffixes as $suffix ) { $value = $order->get_meta( '_' . $market . '_' . $suffix ); if ( is_scalar( $value ) && trim( (string) $value ) !== '' ) { return sanitize_text_field( (string) $value ); } } return ''; }; return array( 'market' => $market, 'name' => self::MARKETS[$market], 'number' => $read( array('order_number', 'order_id') ), 'carrier' => $read( array('cargo_provider', 'cargo_company', 'carrier') ), 'tracking' => $read( array( 'cargo_tracking', 'cargo_tracking_number', 'tracking_number', 'tracking' ) ), ); } /** * Why the order screen does not cancel an order, or '' when it does (Almira, 30 Oct 2026: a web checkout order had no * cancel at all, only the order desk's own orders did). An unpaid order of the desk, of the web checkout or one made in * WooCommerce is cancelled through WooCommerce, which puts its stock back. A web or WooCommerce order paid in full at its * checkout is cancelled with a refund of its whole total (cancel_refund()); another paid one is refunded in the return * flow. A till sale stays the till's, a marketplace order is ended on the marketplace. A web or WooCommerce order needs * someone WooCommerce lets edit it, as changing it does (OrderEditor::may_edit(), security audit S06), and its refund * someone who may refund. Codes: closed, marketplace, till, other, rights, paid, provider, activity, refund_rights. */ private static function cancel_block( \WC_Order $order ) : string { if ( $order->has_status( array( 'cancelled', 'refunded', 'failed', 'trash', 'completed', 'checkout-draft' ) ) ) { return 'closed'; } $via = (string) $order->get_created_via(); if ( $via !== 'storesuit' ) { $market = self::market( $order ) !== ''; if ( $market ) { return 'marketplace'; } $till = self::store_sale( $order ); if ( $till ) { return 'till'; } // StoreRuler's other records (quotes, sample orders) and a status another plugin gave an order on its way. if ( str_starts_with( $via, 'storesuit-' ) || str_starts_with( $via, 'storeruler-' ) || !$order->has_status( array('pending', 'on-hold', 'processing') ) ) { return 'other'; } $type = get_post_type_object( 'shop_order' ); if ( !current_user_can( 'manage_woocommerce' ) && !($type && current_user_can( $type->cap->edit_post, $order->get_id() )) ) { return 'rights'; } } // A cancellation whose provider refund was made but whose cancelling was not saved: nothing is left to give back. $mark = $order->get_meta( self::CANCEL_REFUND ); if ( $mark && self::collected( $order ) > 0.0 ) { return 'provider'; } $refund = ( $mark ? null : self::cancel_refund( $order ) ); if ( !$mark && !$refund && (self::collected( $order ) > 0.0 || (float) $order->get_total_refunded() > 0.0) ) { return 'paid'; } if ( $order->get_meta( '_storesuit_fulfilment' ) || $order->get_meta( '_storesuit_shipments' ) || $order->get_meta( '_storesuit_delivery_confirmed' ) || self::has_blocking_return( $order ) ) { return 'activity'; } if ( $refund && !self::may_refund() ) { return 'refund_rights'; } return ''; } /** * Order meta: a cancellation that asked the payment provider to send the order's money back (Almira, 30 Oct 2026). Written * and saved before the provider is asked, removed with the cancelling: one that stays means the provider was asked and the * order was not cancelled, so it is never asked again; once its refund is recorded the order is cancelled without money. */ private const CANCEL_REFUND = '_storesuit_cancel_refund'; /** * The refund a cancellation makes, or null (Almira, 30 Oct 2026: an order paid with PayTR whose product had run out could * not be cancelled): a web or WooCommerce order paid in full at its checkout, nothing refunded yet, without StoreRuler's * own payment record or a gift card or store credit part, gives its whole total back. Through its payment provider when the * provider can refund it and the store turned that on (Returns::gateway(), paid build), else refunded by hand, which the * person cancelling confirms. 'gateway_off' names a provider that could refund it while the store has that turned off. */ private static function cancel_refund( \WC_Order $order ) : ?array { if ( $order->get_created_via() === 'storesuit' || $order->get_meta( '_storesuit_payments' ) || $order->get_meta( '_storesuit_payment_mode' ) || (float) $order->get_total_refunded() > 0.0 ) { return null; } $amount = round( (float) $order->get_total(), wc_get_price_decimals() ); if ( $amount <= 0.0 || abs( self::collected( $order ) - $amount ) > 1.0E-6 ) { return null; } $gateway = null; $off = ''; return array( 'amount' => $amount, 'currency' => $order->get_currency(), 'method' => trim( wp_strip_all_tags( (string) $order->get_payment_method_title() ) ), 'gateway' => $gateway, 'gateway_off' => $off, ); } /** Who may give an order's money back: a store manager; in the paid build finance and approval, as a return's refund needs. */ private static function may_refund() : bool { $may = current_user_can( 'manage_woocommerce' ); return $may; } /** WooCommerce's refund lines for the whole order: every product (its quantity), shipping and fee, with their taxes. */ private static function cancel_lines( \WC_Order $order ) : array { $lines = array(); foreach ( $order->get_items( array('line_item', 'shipping', 'fee') ) as $item_id => $item ) { $tax = array(); foreach ( (array) ($item->get_taxes()['total'] ?? array()) as $rate => $amount ) { if ( is_numeric( $amount ) ) { $tax[$rate] = (float) $amount; } } $lines[$item_id] = array( 'qty' => ( $item->is_type( 'line_item' ) ? $item->get_quantity() : 0 ), 'refund_total' => (float) $item->get_total(), 'refund_tax' => $tax, ); } return $lines; } /** * WooCommerce's refund of the whole order, its products not restocked (cancelling puts the stock back) and the order's * status left as it is (cancelling sets it, not "Refunded"). WooCommerce e-mails the customer the refund. */ private static function refund_whole( \WC_Order $order, array $money, bool $send ) { $id = $order->get_id(); $keep = static fn( $status, $order_id = 0 ) => ( (int) $order_id === $id ? false : $status ); add_filter( 'woocommerce_order_fully_refunded_status', $keep, 99, 2 ); try { return wc_create_refund( array( 'order_id' => $id, 'amount' => $money['amount'], 'reason' => I18n::t( 'Order cancelled' ), 'refund_payment' => $send, 'restock_items' => false, 'line_items' => self::cancel_lines( $order ), ) ); } finally { remove_filter( 'woocommerce_order_fully_refunded_status', $keep, 99 ); } } private static function can_cancel( \WC_Order $order, bool $labels = true ) : bool { $can = self::cancel_block( $order ) === ''; return $can; } private static function has_blocking_return( \WC_Order $order ) : bool { foreach ( (array) (( $order->get_meta( '_storesuit_returns' ) ?: array() )) as $request ) { // A rejected request had no stock or money effect; every other state // must be resolved in the return flow, not hidden by cancellation. if ( !is_array( $request ) || ($request['status'] ?? '') !== 'rejected' ) { return true; } } return false; } /** * What the sale amount is made of, as WooCommerce's own order screen lists it: the products before discounts, the * discount, shipping and fees (all before tax) and the tax (Almira audit SAT-5, 28 Oct 2026: the payment summary showed the * total alone). They add up to sale_total(): gift cards lower WooCommerce's total, not these. */ private static function breakdown( \WC_Order $order ) : array { $decimals = wc_get_price_decimals(); $fees = 0.0; foreach ( $order->get_fees() as $fee ) { $fees += (float) $fee->get_total(); } return array( 'items' => round( (float) $order->get_subtotal(), $decimals ), 'discount' => round( (float) $order->get_discount_total(), $decimals ), 'shipping' => round( (float) $order->get_shipping_total(), $decimals ), 'fees' => round( $fees, $decimals ), 'tax' => round( (float) $order->get_total_tax(), $decimals ), ); } /** The sale amount: the order total plus what gift cards and store credit paid (WooCommerce's total is what was left to pay). */ private static function sale_total( \WC_Order $order ) : float { // The order's value on the desk: the money part plus every card part, sold or not (display only; the reports leave // a sold card's part out because the card was counted when sold) (0.62.10 review). Gift cards: paid build. $total = round( (float) $order->get_total(), wc_get_price_decimals() ); return $total; } /** The returned full cart value. WooCommerce's refund amount contains only the money part of a gift-card-funded return. */ private static function sale_refunded_total( \WC_Order $order ) : float { $amount = (float) $order->get_total_refunded(); return max( 0.0, min( self::sale_total( $order ), round( $amount, wc_get_price_decimals() ) ) ); } /** * The address the order screen shows under "Delivery address", as plain text with one line to a part: the order's delivery * address, or its billing address when it has no delivery address of its own (WooCommerce's has_shipping_address()), written * the way WooCommerce writes an address of that country (WC_Countries::get_formatted_address(): the country's own order of * street, postcode, city and state, and no country line when it is the store's own country). The name is left out (the screen * shows it above); the company stays. WooCommerce escapes each part for HTML, so the lines are turned back into text here: the * screens set them with textContent. An order with no street, city or postcode has no address (a country alone is none). */ private static function delivery_address( \WC_Order $order ) : string { $parts = $order->get_address( ( $order->has_shipping_address() ? 'shipping' : 'billing' ) ); if ( trim( implode( '', array( $parts['address_1'] ?? '', $parts['address_2'] ?? '', $parts['city'] ?? '', $parts['postcode'] ?? '' ) ) ) === '' ) { return ''; } self::drop_email_country_filter(); $lines = array(); foreach ( explode( "\n", (string) WC()->countries->get_formatted_address( array_merge( $parts, array( 'first_name' => '', 'last_name' => '', ) ), "\n" ) ) as $line ) { $line = trim( html_entity_decode( wp_strip_all_tags( $line ), ENT_QUOTES, 'UTF-8' ) ); if ( $line !== '' ) { $lines[] = $line; } } return implode( "\n", $lines ); } /** * WooCommerce's emails write the store address with the country line forced on: WC_Emails::get_store_address() adds a filter for * that and takes it out again at another priority than it added it, so it stays. In a request that sent an email (an order * completed by "Record delivery", say) every address written after it would show the store's own country too, and the screen's * answer would not match the page it was loaded with. Only that leftover is taken out; a store's own filter stays. */ private static function drop_email_country_filter() : void { global $wp_filter; $hook = $wp_filter['woocommerce_formatted_address_force_country_display'] ?? null; if ( !$hook instanceof \WP_Hook ) { return; } foreach ( $hook->callbacks as $priority => $callbacks ) { foreach ( $callbacks as $callback ) { $function = $callback['function'] ?? null; if ( is_array( $function ) && ($function[0] ?? null) instanceof \WC_Emails && ($function[1] ?? '') === 'get_store_address_force_country_display' ) { remove_filter( 'woocommerce_formatted_address_force_country_display', $function, (int) $priority ); } } } } public static function summary( \WC_Order $order ) : array { $items = array(); $fulfil = ( $order->get_meta( '_storesuit_fulfilment' ) ?: array() ); foreach ( $order->get_items() as $id => $item ) { $product = $item->get_product(); $thumbnail = ( $product && $product->get_image_id() ? wp_get_attachment_image_url( $product->get_image_id(), 'woocommerce_thumbnail' ) : '' ); // Almira, 30 Oct 2026: the thumbnail alone did not say which product it was. Each line opens the product in StoreRuler's // product editor (for whoever may edit it) or its page in the store, and names its options (colour, size) in words. $parent = (int) $item->get_product_id(); $status = ( $parent ? get_post_status( $parent ) : false ); $edit = ( $status && $status !== 'trash' && current_user_can( 'edit_product', $parent ) ? admin_url( 'admin.php?page=storesuit-products&id=' . $parent ) : '' ); $view = ( $product && $status === 'publish' ? (string) $product->get_permalink() : '' ); // An option the name already says in other letters ("… - BEYAZ, 1-2 YAŞ" and "Beden: 1-2 Yaş" on Almira) is left out: // WooCommerce compares the values after the name's last " - " letter for letter. $options = array(); $fold = static fn( string $s ): string => trim( mb_strtolower( str_replace( array('İ', 'I', 'ı'), 'i', $s ), 'UTF-8' ) ); $title = Store::plain( (string) $item->get_name() ); $cut = strrpos( $title, ' - ' ); $named = ( $cut === false ? array() : array_map( $fold, explode( ',', substr( $title, $cut + 3 ) ) ) ); foreach ( $item->get_formatted_meta_data( '_' ) as $meta ) { $value = trim( wp_strip_all_tags( Store::plain( (string) $meta->display_value ) ) ); if ( $value !== '' && count( $options ) < 6 && !in_array( $fold( $value ), $named, true ) ) { $options[] = trim( wp_strip_all_tags( Store::plain( (string) $meta->display_key ) ) ) . ': ' . $value; } } $items[] = array( 'id' => $id, 'product_id' => ( $item->get_variation_id() ?: $item->get_product_id() ), 'name' => $item->get_name(), 'image' => ( $thumbnail ?: '' ), 'product_url' => $edit, 'view_url' => $view, 'options' => $options, 'line_total' => (float) $item->get_total() + (float) $item->get_total_tax(), 'sku' => ( $product ? $product->get_sku() : '' ), 'qty' => $item->get_quantity(), 'refunded_qty' => abs( $order->get_qty_refunded_for_item( $id ) ), 'fulfilment' => $fulfil[$id] ?? array( 'picked' => 0, 'packed' => 0, 'shipped' => 0, ), ); } $mode = $order->get_meta( '_storesuit_payment_mode' ); if ( !$mode ) { $method = $order->get_payment_method(); $mode = ( $method === 'cod' ? 'cod' : (( in_array( $method, array('bacs', 'cheque'), true ) ? 'manual' : '' )) ); } $shipments = (array) (( $order->get_meta( '_storesuit_shipments' ) ?: array() )); foreach ( $shipments as &$shipment ) { if ( isset( $shipment['provider_tracker']['account_fingerprint'] ) ) { unset($shipment['provider_tracker']['account_fingerprint']); } } unset($shipment); // The paid build's parts (merchant quotes, marketplace, cash on delivery, labels, gift cards, payments, invoice, editing) start neutral, // so the order screen reads the same keys, in the same order, in both builds. $merchant_quote = null; $marketplace = null; $cod_blocked = false; $cod_blocked_by = array(); $cod_enabled = false; $labels = array(); $parcel = array(); $cards = array(); $cancel_label = ''; $payments = array(); $invoice = array(); $editor = array(); // The payment WooCommerce recorded that the register does not hold (ORD-7, P2-14b): what collected() counts as paid before // refunds, less the positive register rows, so the history keeps a checkout's own payment beside a later refund. $stored = $order->get_meta( '_storesuit_payments' ); $paid = self::paid_date( $order ); $gross = ( is_array( $stored ) && $stored ? (float) array_sum( array_column( $stored, 'amount' ) ) : (( $paid || self::paid_by_status( $order ) ? (float) $order->get_total() : 0.0 )) ); $registered = 0.0; foreach ( $payments as $row ) { if ( is_array( $row ) && (float) ($row['amount'] ?? 0) > 0 ) { $registered += (float) $row['amount']; } } // Denetim R3-6: a team member without the finance permission reads the total, what was collected and what is still due, and // the payment method, not each payment (who took it, when, how, its note); the order screen then shows the collected amount // as one line. Taken out here, not only on the screen. if ( !current_user_can( 'storesuit_finance' ) ) { $payments = array(); } $extra = array( 'abandoned' => self::abandoned_checkout( $order ), 'payment_method' => (string) $order->get_payment_method(), 'payment_title' => Store::plain( (string) $order->get_payment_method_title() ), 'refunded_total' => (float) $order->get_total_refunded(), 'sale_refunded_total' => self::sale_refunded_total( $order ), 'paid_at' => ( $paid ? $paid->format( DATE_ATOM ) : '' ), 'unrecorded_paid' => max( 0.0, round( $gross - $registered, wc_get_price_decimals() ) ), 'unpaid_prepaid' => self::unpaid_prepaid( $order ), 'cod_on_delivery' => self::cod_paid_on_delivery( $order ), 'can_mark_paid' => self::can_mark_paid( $order ), 'cod_enabled' => $cod_enabled, 'first_name' => Store::plain( (string) $order->get_billing_first_name() ), 'whatsapp' => self::whatsapp( $order ), 'whatsapp_declined' => self::whatsapp_declined( $order ), 'bank_accounts' => self::bank_accounts( $order ), 'breakdown' => self::breakdown( $order ), 'cancel_reason' => $cancel_why = self::cancel_block( $order ), 'cancel_refund' => ( $cancel_why === '' ? self::cancel_refund( $order ) : null ), ); // A marketplace order another plugin brought in: the marketplace took the payment (admin audit A2). It rides on the // marketplace key, marked imported, so the summary keeps its fields. $import = ( $marketplace ? null : self::market_import( $order ) ); if ( $import ) { $marketplace = $import + array( 'title' => $import['name'], 'imported' => true, 'can_report' => false, 'lines' => array(), ); $extra['payment_title'] = I18n::format( 'Collected by %s', $import['name'] ); } // The keys of before, in their order; the new ones (ORD-2/3/7/8) after them, in both builds. return array_merge( array( 'merchant_quote' => $merchant_quote, 'marketplace' => $marketplace, 'flow' => OrderFlow::read( $order ), 'cod_blocked' => $cod_blocked, 'cod_blocked_by' => $cod_blocked_by, 'labels' => $labels, 'parcel' => $parcel, 'delivery' => ( $order->get_meta( '_storesuit_delivery' ) ?: 'shipping' ), 'shipping_name' => ( $order->has_shipping_address() ? trim( $order->get_formatted_shipping_full_name() ) : '' ), 'id' => $order->get_id(), 'number' => $order->get_order_number(), 'created' => ( $order->get_date_created() ? $order->get_date_created()->format( DATE_ATOM ) : '' ), 'email' => $order->get_billing_email(), 'address' => self::delivery_address( $order ), 'note' => $order->get_customer_note(), 'handle' => (string) $order->get_meta( '_storesuit_handle' ), 'channel' => self::channel( $order ), 'name' => trim( $order->get_formatted_billing_full_name() ), 'phone' => $order->get_billing_phone(), 'phone_display' => self::phone_display( (string) $order->get_billing_phone() ), 'status' => $order->get_status(), 'total' => $order->get_total(), 'sale_total' => self::sale_total( $order ), 'cards' => $cards, 'currency' => $order->get_currency(), 'collected' => self::collected( $order ), 'outstanding' => self::outstanding( $order ), 'payment_mode' => $mode, 'can_cancel' => self::can_cancel( $order ), 'cancel_label' => $cancel_label, 'payments' => $payments, 'exchange_parent' => $order->get_meta( '_storesuit_exchange_parent' ), 'payment_url' => ( $mode === 'link' && self::usable_payment_link( $order ) ? $order->get_checkout_payment_url() : '' ), 'edit_url' => $order->get_edit_order_url(), 'due' => $order->get_meta( '_storesuit_due' ), 'invoice' => $invoice, 'editor' => $editor, 'items' => $items, 'returns' => ( $order->get_meta( '_storesuit_returns' ) ?: array() ), 'shipments' => $shipments, 'messages' => OrderMessages::history( $order ), ), $extra ); } }