,v1=.")>. */ public static function sign( string $secret, string $body, int $timestamp ): string { return 't=' . $timestamp . ',v1=' . hash_hmac( 'sha256', $timestamp . '.' . $body, $secret ); } /** * Converts a decimal amount string to minor units without float rounding errors. */ public static function to_minor( $amount, int $decimals = 2 ): ?int { $amount = trim( (string) $amount ); if ( '' === $amount || ! preg_match( '/^-?\d+(\.\d+)?$/', $amount ) ) { return null; } $negative = '-' === $amount[0]; $amount = ltrim( $amount, '-' ); $parts = explode( '.', $amount, 2 ); $frac = substr( str_pad( $parts[1] ?? '', $decimals, '0' ), 0, $decimals ); $minor = (int) ( $parts[0] . $frac ); return $negative ? null : $minor; } /** * Stable ID for a site's object so events from two sites never collide. */ public static function external_id( string $site_key, string $kind, $id ): string { return substr( $site_key, 0, 12 ) . ':' . $kind . ':' . $id; } /** * Event for a WooCommerce order status change, or null when the status isn't one RootCue acts on. * * @param array $order {id, number, status, total, currency, decimals, customer_id, first_name, last_name, company, phone, email, created_at, items}. */ public static function order_event( array $order, string $site_key, string $site_name ): ?array { $map = array( 'pending' => 'payment.pending', 'on-hold' => 'payment.pending', 'processing' => 'customer.updated', 'completed' => 'service.completed', ); $status = (string) ( $order['status'] ?? '' ); if ( ! isset( $map[ $status ] ) ) { return null; } $customer = self::customer( $order, $site_key ); if ( null === $customer ) { return null; } $type = $map[ $status ]; $data = array( 'customer' => $customer, 'reference' => 'Order #' . ( $order['number'] ?? $order['id'] ), 'sourceSystem' => self::source( $site_name ), ); $minor = self::to_minor( $order['total'] ?? '', (int) ( $order['decimals'] ?? 2 ) ); if ( null !== $minor && $minor > 0 ) { $data['amountMinor'] = $minor; $data['currency'] = strtoupper( substr( (string) ( $order['currency'] ?? 'INR' ), 0, 3 ) ); } if ( ! empty( $order['items'] ) ) { $data['title'] = self::truncate( implode( ', ', array_slice( (array) $order['items'], 0, 3 ) ), 160 ); } return array( 'id' => self::external_id( $site_key, 'order', $order['id'] ) . ':' . $status, 'type' => $type, 'occurredAt' => self::iso( $order['modified_at'] ?? $order['created_at'] ?? null ), 'origin' => 'wordpress', 'externalVersion' => (string) ( $order['modified_at'] ?? '' ), 'data' => $data, ); } /** * Event for a contact-form submission. * * @param array $lead {form_id, entry_id, name, phone, email, message, submitted_at}. */ public static function lead_event( array $lead, string $site_key, string $site_name ): ?array { $phone = self::clean( $lead['phone'] ?? '', 32 ); $email = self::clean( $lead['email'] ?? '', 254 ); if ( '' === $phone && '' === $email ) { return null; // Nothing RootCue could follow up on. } $key = '' !== $email ? strtolower( $email ) : preg_replace( '/\D+/', '', $phone ); $customer = array_filter( array( 'externalId' => self::external_id( $site_key, 'contact', substr( hash( 'sha256', $key ), 0, 24 ) ), 'name' => self::clean( $lead['name'] ?? '', 120 ), 'phone' => $phone, 'email' => $email, 'tags' => array( 'website' ), ) ); if ( empty( $customer['name'] ) ) { $customer['name'] = '' !== $email ? $email : $phone; } $data = array( 'customer' => $customer, 'title' => 'New enquiry from your website', 'sourceSystem' => self::source( $site_name ), ); $message = self::clean( $lead['message'] ?? '', 2000 ); if ( '' !== $message ) { $data['note'] = $message; } return array( 'id' => self::external_id( $site_key, 'form', ( $lead['form_id'] ?? 'form' ) . '-' . ( $lead['entry_id'] ?? hash( 'sha256', $key . ( $lead['submitted_at'] ?? '' ) ) ) ), 'type' => 'lead.created', 'occurredAt' => self::iso( $lead['submitted_at'] ?? null ), 'origin' => 'wordpress', 'data' => $data, ); } private static function customer( array $o, string $site_key ): ?array { $name = self::clean( trim( ( $o['first_name'] ?? '' ) . ' ' . ( $o['last_name'] ?? '' ) ), 120 ); $phone = self::clean( $o['phone'] ?? '', 32 ); $email = self::clean( $o['email'] ?? '', 254 ); if ( '' === $phone && '' === $email ) { return null; } // Registered customers keep one identity across orders; guests are keyed by email/phone. $id = ! empty( $o['customer_id'] ) ? self::external_id( $site_key, 'customer', (int) $o['customer_id'] ) : self::external_id( $site_key, 'guest', substr( hash( 'sha256', '' !== $email ? strtolower( $email ) : preg_replace( '/\D+/', '', $phone ) ), 0, 24 ) ); return array_filter( array( 'externalId' => $id, 'name' => '' !== $name ? $name : ( '' !== $email ? $email : $phone ), 'phone' => $phone, 'email' => $email, 'organizationName' => self::clean( $o['company'] ?? '', 160 ), 'tags' => array( 'woocommerce' ), ) ); } private static function source( string $site_name ): string { return self::truncate( '' !== trim( $site_name ) ? trim( $site_name ) : 'WordPress', 60 ); } private static function iso( $value ): string { $ts = is_numeric( $value ) ? (int) $value : ( is_string( $value ) && '' !== $value ? strtotime( $value ) : false ); return gmdate( 'Y-m-d\TH:i:s\Z', false === $ts ? time() : $ts ); } private static function clean( $value, int $max ): string { $value = preg_replace( '/[\x00-\x1F\x7F]+/u', ' ', (string) $value ); return self::truncate( trim( (string) $value ), $max ); } private static function truncate( string $value, int $max ): string { return function_exists( 'mb_substr' ) ? mb_substr( $value, 0, $max ) : substr( $value, 0, $max ); } }