enabledFor( (string) $to ); if ( [] === $events ) { // Nothing configured for this status: no meta write, no query, no job. return; } /** * Filters whether this particular transition may notify. * * Lets the admin order screen suppress a send when a shop manager changes * status by hand and does not want the customer texted. * * @param bool $send Whether to send. * @param WC_Order $order The order. * @param string $to New status. * @param string $from Previous status. */ if ( ! apply_filters( 'rahkar_wp_send_order_notification', true, $order, $to, $from ) ) { return; } $queued = false; foreach ( $events as $event ) { $queued = $this->queueOnce( $order, $event ) || $queued; } // One save for the whole transition, not one per notification. Each // `WC_Order::save()` on HPOS writes several tables, and a status that // notifies both the customer and the shop was paying for two of them on a // path the customer is waiting behind. if ( $queued ) { $order->save(); } } /** * Queue one notification, at most once per order and status. * * The flag is written *before* the job is queued, and deliberately so. A * status can be re-entered, and several hooks can fire in one request; the * trade-off is between occasionally losing a send after a transient failure — * which staff can re-fire — and texting a customer five times when a provider * recovers. The second is far worse, and on a metered line it also costs money. * * @param WC_Order $order The order. * @param EventDefinition $event The notification. * * @return bool Whether a flag was set and a job queued. */ private function queueOnce( WC_Order $order, EventDefinition $event ): bool { $metaKey = '_rahkar_sent_' . sanitize_key( $event->settingsKey() ); if ( '1' === (string) $order->get_meta( $metaKey, true ) ) { return false; } // Staged only — the caller saves once for the whole transition. $order->update_meta_data( $metaKey, '1' ); Queue::push( self::JOB_HOOK, [ 'order' => $order->get_id(), 'event' => $event->id, ], 0, 10, // Grouped per order and notification, so a duplicate transition inside // the same request cannot enqueue the same message twice. 'order:' . $order->get_id() . ':' . $event->settingsKey() ); return true; } /** * Perform a queued notification. * * @param array $payload Job payload. * * @internal Hooked callback. */ public function handleJob( array $payload ): void { $orderId = (int) ( $payload['order'] ?? 0 ); $eventId = (string) ( $payload['event'] ?? '' ); $order = $orderId > 0 && function_exists( 'wc_get_order' ) ? wc_get_order( $orderId ) : null; $event = OrderEvents::all()[ $eventId ] ?? null; if ( ! $order instanceof WC_Order || null === $event ) { // The order was deleted, or the status it belonged to no longer exists. // Neither is worth retrying. return; } $config = EventSettings::for( $event ); if ( ! $config['enabled'] ) { // Switched off between queueing and running. return; } $values = self::orderVariables( $order ); $body = TemplateRenderer::render( $config['text'], $values ); $params = '' !== $config['pattern'] ? self::patternVariables( $config['pattern_map'], $values ) : $values; foreach ( $this->recipientsFor( $order, $event, $config ) as $recipient ) { Plugin::make( Notifier::class )->queue( new Message( $event->id, $recipient, $body, $params, $event->label ) ); } } /** * Where this notification should go. * * @param WC_Order $order The order. * @param EventDefinition $event The notification. * @param array{enabled:bool, channel:string, pattern:string, pattern_map:array, text:string, recipients:string} $config Its configuration. * * @return list */ private function recipientsFor( WC_Order $order, EventDefinition $event, array $config ): array { if ( $event->isForAdmin() ) { $numbers = '' !== $config['recipients'] ? $config['recipients'] : (string) Settings::get( NotificationsModule::SLUG . '.admin_phones', '' ); $recipients = []; foreach ( preg_split( '/[,\s،]+/', $numbers ) ?: [] as $raw ) { $phone = PhoneNumber::parse( (string) $raw ); if ( null !== $phone ) { $recipients[] = $phone->canonical(); } } return array_values( array_unique( $recipients ) ); } // A customer's verified login number is more reliable than the billing // field, which they may have typed with a digit missing. $verified = (string) get_user_meta( $order->get_customer_id(), 'rahkar_phone', true ); $phone = PhoneNumber::parse( '' !== $verified ? $verified : (string) $order->get_billing_phone() ); if ( null === $phone ) { Logger::warning( 'notify', 'Order has no usable phone number.', [ 'order' => $order->get_id(), 'event' => $event->id ] ); return []; } return [ $phone->canonical() ]; } /** * Notifications configured for a status. * * @param string $status Status slug. * * @return list */ private function enabledFor( string $status ): array { $enabled = []; foreach ( OrderEvents::all() as $event ) { if ( str_starts_with( $event->id, 'order.' . $status . '.' ) && EventSettings::isEnabled( $event ) ) { $enabled[] = $event; } } return $enabled; } /** * Placeholder values for one order. * * @param WC_Order $order The order. * * @return array */ public static function orderVariables( WC_Order $order ): array { return [ 'order_number' => $order->get_order_number(), // WooCommerce returns the formatted total as HTML, and the currency // symbol as an entity — the Rial sign arrives as `﷼`. Stripping // tags alone is not enough: the first version of this shipped // `﷼20.000` into the message body, which is what a customer // would have read. 'order_total' => self::plainText( $order->get_formatted_order_total() ), 'order_status' => function_exists( 'wc_get_order_status_name' ) ? wc_get_order_status_name( $order->get_status() ) : $order->get_status(), 'first_name' => $order->get_billing_first_name(), 'last_name' => $order->get_billing_last_name(), 'full_name' => trim( $order->get_billing_first_name() . ' ' . $order->get_billing_last_name() ), 'items_count' => (string) $order->get_item_count(), 'items' => self::itemList( $order ), 'payment_method' => $order->get_payment_method_title(), 'shipping_city' => $order->get_shipping_city() ?: $order->get_billing_city(), 'customer_phone' => (string) $order->get_billing_phone(), 'site_name' => get_bloginfo( 'name' ), 'site_url' => home_url( '/' ), 'order_url' => $order->get_view_order_url(), ]; } /** * The order's products as one line: "name ×2، name". * * Capped, because a pattern variable has a length limit at the provider and * an order of thirty lines would be rejected outright rather than cut short. * * @param WC_Order $order The order. */ private static function itemList( WC_Order $order ): string { $parts = []; foreach ( $order->get_items() as $item ) { $qty = (int) $item->get_quantity(); $parts[] = $item->get_name() . ( $qty > 1 ? ' ×' . $qty : '' ); } $list = implode( '، ', $parts ); return mb_strlen( $list ) > 150 ? mb_substr( $list, 0, 149 ) . '…' : $list; } /** * The variables a pattern send carries. * * With a mapping saved for the event's pattern, each pattern variable gets * its own small template rendered against the order — `{first_name}`, a fixed * `aramahome.com`, or a mix. Without one, the plugin's own names are sent as * they are, which works only when the pattern happens to use the same names: * how every event configured before mappings existed keeps working. * * @param array $map Pattern variable => template. * @param array $variables The order's values. * * @return array */ public static function patternVariables( array $map, array $variables ): array { if ( [] === $map ) { return $variables; } $out = []; foreach ( $map as $name => $template ) { $value = TemplateRenderer::render( $template, $variables ); // A provider rejects the whole send over one empty parameter; a dash // in its place still gets the message to the customer. $out[ $name ] = '' !== $value ? $value : '-'; } return $out; } /** * Reduce a WooCommerce-formatted value to something an SMS can carry. * * Tags out, then entities decoded. Both are needed: WooCommerce wraps prices * in markup *and* emits the currency symbol as a numeric entity. * * @param string $value Formatted HTML value. */ private static function plainText( string $value ): string { $plain = html_entity_decode( wp_strip_all_tags( $value ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ); // Non-breaking spaces come along with the price formatting and read as // stray characters on some handsets. $plain = str_replace( "\u{00A0}", ' ', $plain ); return trim( (string) preg_replace( '/\s+/u', ' ', $plain ) ); } }