reports = $reports; $this->csv = $csv; $this->orders = $orders; $this->buyers = $buyers; } /** * Register `GET /reports/sales/export` and `GET /reports/orders/export`. * * @since 1.0.0 * * @return void */ public function registerRoutes(): void { register_rest_route( self::API_NAMESPACE, '/reports/sales/export', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'salesExport' ), 'permission_callback' => array( $this, 'reportsPermission' ), 'args' => $this->rangeArgs(), ), ) ); register_rest_route( self::API_NAMESPACE, '/reports/orders/export', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'ordersExport' ), 'permission_callback' => array( $this, 'managePermission' ), 'args' => $this->rangeArgs(), ), ) ); } /** * Build the sales CSV export. * * @since 1.0.0 * * @param WP_REST_Request $request The incoming request. * @return WP_REST_Response|WP_Error The file name and CSV, or an error envelope. */ public function salesExport( WP_REST_Request $request ) { list( $from, $to ) = $this->reports->defaultRange( (string) $request->get_param( 'from' ), (string) $request->get_param( 'to' ) ); try { $report = $this->reports->sales( $from, $to ); } catch ( ReportValidationException $e ) { $errors = $e->getErrors(); return $this->invalidParams( array() === $errors ? array( $e->getMessage() ) : $errors ); } catch ( ReportPersistenceException $e ) { return $this->serverError(); } catch ( Throwable $e ) { return $this->serverError(); } return $this->file( 'releasecart-sales-' . $report['from'] . '-to-' . $report['to'] . '.csv', $this->csv->write( $this->header(), $this->rows( $report ) ) ); } /** * Build the orders CSV export. * * Refuse oversized ranges instead of truncating buyer data. * * @since 1.0.0 * * @param WP_REST_Request $request The incoming request. * @return WP_REST_Response|WP_Error The file name and CSV, or an error envelope. */ public function ordersExport( WP_REST_Request $request ) { list( $from, $to ) = $this->reports->defaultRange( (string) $request->get_param( 'from' ), (string) $request->get_param( 'to' ) ); try { $this->reports->assertValidRange( $from, $to ); } catch ( ReportValidationException $e ) { $errors = $e->getErrors(); return $this->invalidParams( array() === $errors ? array( $e->getMessage() ) : $errors ); } $cap = $this->ordersExportCap(); try { $orders = $this->orders->exportRange( $from, $to, $cap + 1 ); } catch ( Throwable $e ) { return $this->serverError(); } if ( count( $orders ) > $cap ) { return $this->invalidParams( array( sprintf( /* translators: %s: the maximum number of rows an orders export may hold. */ __( 'This range holds more than %s orders. Narrow the date range and export again.', 'releasecart' ), number_format_i18n( $cap ) ), ) ); } return $this->file( 'releasecart-orders-' . $from . '-to-' . $to . '.csv', $this->csv->write( $this->ordersHeader(), $this->orderRows( $orders ) ) ); } /** * The export as REST JSON. * * @since 1.0.0 * * @param string $filename The suggested file name. * @param string $content The CSV text. * @return WP_REST_Response */ private function file( string $filename, string $content ): WP_REST_Response { return new WP_REST_Response( array( 'filename' => sanitize_file_name( $filename ), 'content' => $content, ), 200 ); } /** * Return the sales CSV header. * * @since 1.0.0 * * @return array */ private function header(): array { return array( 'date', 'currency', 'gross_revenue', 'refund_total', 'net_revenue', 'order_count', 'refund_count' ); } /** * Return sales CSV rows. * * @since 1.0.0 * * @param array $report The sales report. * @return array> */ private function rows( array $report ): array { $currency = (string) $report['currency']; $rows = array(); foreach ( $report['series'] as $day ) { $rows[] = array( (string) $day['date'], $currency, Money::toDecimalString( (int) $day['gross_revenue'], $currency ), Money::toDecimalString( (int) $day['refund_total'], $currency ), Money::toDecimalString( (int) $day['net_revenue'], $currency ), (string) (int) $day['order_count'], (string) (int) $day['refund_count'], ); } return $rows; } /** * Return the filterable orders-export limit. * * @since 1.0.0 * * @return int */ private function ordersExportCap(): int { /** * Filter the maximum rows in an orders CSV export. * * @since 1.0.0 * * @param int $cap The row cap. Raising it trades memory and request time for a larger file. */ $cap = (int) apply_filters( 'releasecart_reports_orders_export_max_rows', self::ORDERS_EXPORT_MAX_ROWS ); return max( 1, $cap ); } /** * Return the orders CSV header. * * @since 1.0.0 * * @return array */ private function ordersHeader(): array { return array( 'order_id', 'date', 'customer_name', 'customer_email', 'products', 'status', 'type', 'total', 'currency', 'gateway', 'company_name', 'tax_id', 'billing_address', 'billing_country' ); } /** * Return orders CSV rows. * * Deleted buyers leave an empty name cell. * * @since 1.0.0 * * @param array $orders The orders to write. * @return array> */ private function orderRows( array $orders ): array { $buyers = $this->buyers->forOrders( $orders ); $rows = array(); // One meta query for the whole export, not one per order. update_meta_cache( 'releasecart_order', array_map( static fn ( Order $order ): int => (int) $order->id(), $orders ) ); foreach ( $orders as $order ) { $buyer = $buyers[ (int) $order->customerId() ] ?? null; $currency = (string) $order->currency(); $orderId = (int) $order->id(); $rows[] = array( (string) (int) $order->id(), (string) $order->createdAt(), $buyer instanceof Customer ? (string) $buyer->name() : '', (string) $order->email(), $this->productsCell( $order ), $order->status()->value, $order->type()->value, Money::toDecimalString( (int) $order->total(), $currency ), $currency, null === $order->gateway() ? '' : (string) $order->gateway(), $this->metaCell( $orderId, CheckoutController::COMPANY_META ), $this->metaCell( $orderId, CheckoutController::TAX_ID_META ), $this->addressCell( $orderId ), (string) $order->country(), ); } return $rows; } /** * Return a string order meta value as one CSV cell. * * @since 1.0.0 * * @param int $orderId The order id. * @param string $key The order meta key. * @return string The value, or '' when it is absent. */ private function metaCell( int $orderId, string $key ): string { $value = get_metadata( 'releasecart_order', $orderId, $key, true ); return is_string( $value ) ? $value : ''; } /** * Return the billing address as one CSV cell, in the order the screen shows it. * * @since 1.0.0 * * @param int $orderId The order id. * @return string The filled-in parts joined by commas, or ''. */ private function addressCell( int $orderId ): string { $address = get_metadata( 'releasecart_order', $orderId, CheckoutController::ADDRESS_META, true ); if ( ! is_array( $address ) ) { return ''; } $parts = array(); foreach ( array( 'address_1', 'address_2', 'city', 'state', 'postcode' ) as $key ) { $part = isset( $address[ $key ] ) && is_string( $address[ $key ] ) ? trim( $address[ $key ] ) : ''; if ( '' !== $part ) { $parts[] = $part; } } return implode( ', ', $parts ); } /** * Return order items as one CSV cell. * * @since 1.0.0 * * @param Order $order The order. * @return string */ private function productsCell( Order $order ): string { $parts = array_map( static function ( OrderItem $item ): string { $name = (string) $item->productName(); $tier = (string) $item->priceName(); return '' === $tier ? $name : $name . ' (' . $tier . ')'; }, $order->items() ); return implode( '; ', $parts ); } /** * Return the optional date-range schema. * * @since 1.0.0 * * @return array> */ private function rangeArgs(): array { return array( 'from' => array( 'description' => __( 'Inclusive start date (YYYY-MM-DD, UTC). Defaults to 29 days before "to".', 'releasecart' ), 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => 'rest_validate_request_arg', ), 'to' => array( 'description' => __( 'Inclusive end date (YYYY-MM-DD, UTC). Defaults to the current UTC date.', 'releasecart' ), 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => 'rest_validate_request_arg', ), ); } }