getMessage() ); return false; } } /** * Try one queued row. * * @param int $id Row id. * @return bool */ public static function send_one( $id ) { try { $row = Affiliatant_Queue::row( $id ); if ( null === $row || 'queued' !== $row['status'] ) { return false; } if ( ! Affiliatant_Settings::configured() ) { Affiliatant_Queue::mark_failure( $row, 'The plugin is not configured.', false ); return false; } $payload = json_decode( (string) $row['payload'], true ); if ( ! is_array( $payload ) ) { Affiliatant_Queue::mark_failure( $row, 'The stored payload could not be read.', false ); return false; } $client = Affiliatant_Settings::client(); $amount = 0; if ( 'reverse' === $row['action'] ) { $result = self::send_reversal( $client, $payload, (string) $row['event_id'], (int) $row['order_id'] ); $response = $result['response']; $amount = $result['amount_minor']; } else { $response = $client->createConversion( $payload, (string) $row['event_id'] ); } $remote = ''; if ( is_array( $response ) ) { if ( isset( $response['id'] ) ) { $remote = (string) $response['id']; } elseif ( isset( $response['conversion_id'] ) ) { $remote = (string) $response['conversion_id']; } } Affiliatant_Queue::mark_sent( (int) $row['id'], $remote, $amount ); return true; } catch ( AffiliatantException $e ) { self::fail( $id, $e->getMessage(), $e->isRetryable() ); return false; } catch ( \Throwable $e ) { self::fail( $id, $e->getMessage(), true ); return false; } } /** * Send a reversal, working the commission proportion out at send time. * * The reverse endpoint takes COMMISSION minor units. The shop knows what * it refunded of the ORDER. The proportion can only be computed once the * commissions can be read back, which is why it happens here and not * where the refund hook fired. * * @param AffiliatantClient $client Client. * @param array $payload Queued payload. * @param string $event_id Idempotency key. * @param int $order_id Order id. * @return array{response:array,amount_minor:int} * @throws AffiliatantApiException When there is no conversion to reverse. */ private static function send_reversal( $client, array $payload, $event_id, $order_id ) { $found = $client->findConversion( $payload['external_order_id'] ); $conversion_id = null; if ( isset( $found['data'][0]['id'] ) ) { $conversion_id = $found['data'][0]['id']; } elseif ( isset( $found['data']['id'] ) ) { $conversion_id = $found['data']['id']; } if ( null === $conversion_id ) { // esc_html on the order number because an uncaught exception's // message can reach a screen, and an order number is merchant // data rather than something this plugin wrote. // WordPress.Security.EscapeOutput.ExceptionNotEscaped, and the // sniff is right: it is the only interpolation in a throw here. throw new AffiliatantApiException( 'No conversion at Affiliatant for order ' . esc_html( (string) $payload['external_order_id'] ) . ', so there is nothing to reverse.', 404, 'not_found' ); } $body = array( 'reason' => $payload['reason'] ); if ( ! empty( $payload['is_test'] ) ) { $body['is_test'] = 1; } $amount = 0; if ( ! empty( $payload['refund_minor'] ) && ! empty( $payload['order_minor'] ) ) { $commission = Affiliatant_Reversal::commission_total( self::commission_rows( $client, $conversion_id ) ); $share = Affiliatant_Reversal::share( $commission, (int) $payload['refund_minor'], (int) $payload['order_minor'], Affiliatant_Queue::reversed_so_far( $order_id ) ); // null means "reverse the lot", which is what sending no amount // does. A zero share means a previous partial reversal already // covered this refund, and there is nothing left to claw back. if ( null !== $share ) { $body['amount_minor'] = $share; $body['currency'] = isset( $payload['currency'] ) ? $payload['currency'] : ''; $amount = $share; } } return array( 'response' => $client->reverseConversion( $conversion_id, $body, $event_id ), 'amount_minor' => $amount, ); } /** * What Affiliatant has already allowed on this conversion. * * An unreadable answer returns an empty list, which makes the proportion * uncomputable, which makes the caller send no amount and reverse the * whole commission. Over-reversing is visible and recoverable; silently * under-reversing leaves the merchant out of pocket. * * @param AffiliatantClient $client Client. * @param int|string $conversion_id Conversion id. * @return array */ private static function commission_rows( $client, $conversion_id ) { try { $result = $client->listCommissions( array( 'conversion_id' => $conversion_id ) ); } catch ( \Throwable $e ) { self::log( 'Commission lookup for conversion ' . $conversion_id . ' failed: ' . $e->getMessage() ); return array(); } return isset( $result['data'] ) && is_array( $result['data'] ) ? $result['data'] : array(); } /** * Drain whatever is due. * * @param int $limit How many rows at most. * @param int $budget Seconds of wall clock. * @return array{sent:int,failed:int} */ public static function drain( $limit = 25, $budget = 25 ) { $result = array( 'sent' => 0, 'failed' => 0, ); try { if ( ! Affiliatant_Settings::active() ) { return $result; } $started = time(); foreach ( Affiliatant_Queue::due( (int) $limit ) as $row ) { if ( ( time() - $started ) >= $budget ) { break; } if ( self::send_one( (int) $row['id'] ) ) { $result['sent']++; } else { $result['failed']++; } } Affiliatant_Queue::prune(); } catch ( \Throwable $e ) { self::log( $e->getMessage() ); } return $result; } /** * Record a failure against the row and in the status panel. * * @param int $id Row id. * @param string $message Error text. * @param bool $retryable Worth retrying. * @return void */ private static function fail( $id, $message, $retryable ) { $row = Affiliatant_Queue::row( $id ); if ( null !== $row ) { Affiliatant_Queue::mark_failure( $row, $message, $retryable ); Affiliatant_Settings::note_error( $message, (int) $row['order_id'] ); } else { Affiliatant_Settings::note_error( $message, 0 ); } self::log( $message ); } /** * WooCommerce's own logger, under its own source, so a merchant finds it * where they already look: WooCommerce, Status, Logs. * * Nothing personal is logged. The API key never appears: the SDK keeps it * out of its exception messages and out of every URL. * * @param string $message Text. * @return void */ public static function log( $message ) { if ( ! function_exists( 'wc_get_logger' ) ) { return; } $logger = wc_get_logger(); if ( is_object( $logger ) ) { $logger->warning( (string) $message, array( 'source' => 'affiliatant' ) ); } } }