id = self::ID; $this->method_title = __( 'Canada Post (Maple Parcel)', 'maple-parcel' ); $this->method_description = __( 'Live Canada Post rates at checkout, using your own app from Canada Post\'s Developer Portal.', 'maple-parcel' ); $this->supports = array( 'shipping-zones', 'instance-settings', 'instance-settings-modal', 'settings' ); $this->form_fields = Fields::global(); $this->instance_form_fields = Fields::instance(); $this->init_settings(); $this->title = (string) $this->get_option( 'title', $this->method_title ); $this->tax_status = (string) $this->get_option( 'tax_status', 'taxable' ); add_action( 'woocommerce_update_options_shipping_' . $this->id, array( $this, 'saveSettings' ) ); } /** * The settings page, then bbmmp_settings_after_form on the store-wide page * only (not a zone's modal). The free build's "What Pro adds" panel uses it. */ public function admin_options(): void { parent::admin_options(); if ( 0 === (int) $this->instance_id ) { do_action( 'bbmmp_settings_after_form' ); } } /** Saves the store-wide form. process_admin_options() returns a bool an action has no use for. */ public function saveSettings(): void { $this->process_admin_options(); } /** * Keeps the ship-from postal code in one tidy form, and refuses one that * is not Canadian rather than saving something every quote would fail on. */ public function validate_origin_postal_code_field( string $key, mixed $value ): string { $raw = is_string( $value ) ? trim( stripslashes( $value ) ) : ''; if ( '' === $raw ) { return ''; } $postcode = Destination::canadianPostcode( $raw ); if ( null === $postcode ) { \WC_Admin_Settings::add_error( __( 'The ship-from postal code was not saved: it should look like V2S 6X1.', 'maple-parcel' ) ); return (string) $this->get_option( $key ); } return substr( $postcode, 0, 3 ) . ' ' . substr( $postcode, 3 ); } /** Digits only, at most ten, as Canada Post requires. */ public function validate_customer_number_field( string $key, mixed $value ): string { $number = is_string( $value ) ? (string) preg_replace( '/\D/', '', $value ) : ''; if ( strlen( $number ) > 10 ) { \WC_Admin_Settings::add_error( __( 'The customer number was not saved: it is at most 10 digits.', 'maple-parcel' ) ); return (string) $this->get_option( $key ); } return $number; } /** * Offers one rate per service. Canada Post failures never reach the * cart: they are logged, counted, and replaced by the fallback rate. * * @param array $package * @throws ApiException Never escapes: thrown for a missing origin and caught below. */ public function calculate_shipping( $package = array() ): void { $settings = Settings::load(); $logger = new Logger( $settings->debug() ); $destination = Destination::fromPackage( is_array( $package['destination'] ?? null ) ? $package['destination'] : array() ); if ( null === $destination ) { return; } $choices = Services::choices( $this->get_option( 'services' ) ); /** * The service codes to ask Canada Post for in this package. Return an * empty array to offer no Canada Post rates, and no fallback rate. * * @param list $services Codes the zone offers for this address. * @param array $package The WooCommerce package. * @param Method $method */ $services = apply_filters( 'bbmmp_services', Services::enabledFor( $destination->group, $choices ), $package, $this ); $services = is_array( $services ) ? array_values( array_filter( $services, 'is_string' ) ) : array(); if ( array() === $services ) { return; } $builder = new ParcelBuilder( $settings->packing(), $settings->defaultWeight(), $settings->defaultSides() ); /** * The parcels to rate for this package. Return an empty array to offer * no Canada Post rates. * * @param list $parcels * @param array $package The WooCommerce package. * @param Method $method */ $parcels = apply_filters( 'bbmmp_parcels', $builder->build( is_array( $package['contents'] ?? null ) ? $package['contents'] : array() ), $package, $this ); if ( ! is_array( $parcels ) || array() === $parcels ) { return; } foreach ( $parcels as $parcel ) { if ( ! $parcel instanceof Parcel ) { return; } if ( $parcel->exceedsLimits() ) { $logger->error( sprintf( 'No Canada Post rates: a %s kg parcel of %s x %s x %s cm is over Canada Post\'s limits (30 kg, 200 cm a side, 300 cm length plus girth).', $parcel->weight, $parcel->length, $parcel->width, $parcel->height ) ); return; } } try { if ( '' === $settings->originPostalCode() ) { throw new ApiException( __( 'The ship-from postal code is missing. Add it in WooCommerce > Settings > Shipping > Canada Post (Maple Parcel).', 'maple-parcel' ) ); } $quotes = $this->rater( $settings, $logger )->rate( $destination, array_values( $parcels ), $services, 'yes' === $this->get_option( 'coverage' ), 'yes' === $this->get_option( 'signature' ) ); } catch ( ApiException $e ) { $logger->error( 'Canada Post rates failed: ' . $e->getMessage() ); Health::recordFailure( $e->getMessage() ); $this->addFallback( $package ); return; } Health::recordSuccess(); Health::noteFirstRate(); foreach ( $this->rateArgs( $quotes, $services, $choices, $settings->taxMode(), $package ) as $args ) { $this->add_rate( $args ); $rate = $this->rates[ $args['id'] ] ?? null; if ( $rate instanceof \WC_Shipping_Rate && '' !== $args['delivery_time'] ) { $rate->set_delivery_time( $args['delivery_time'] ); } } } /** * The add_rate() arguments for each quote, cheapest first, with free * shipping applied and the delivery estimate attached. * * @param list $quotes * @param list $services Codes the zone offers here. * @param array $choices * @param array $package * @return list> */ public function rateArgs( array $quotes, array $services, array $choices, string $taxMode, array $package ): array { $quotes = array_values( array_filter( $quotes, static fn ( Quote $quote ): bool => in_array( $quote->serviceCode, $services, true ) ) ); usort( $quotes, static fn ( Quote $a, Quote $b ): int => $a->price( $taxMode ) <=> $b->price( $taxMode ) ); $minimum = (string) $this->get_option( 'free_shipping_min' ); $free = is_numeric( $minimum ) && (float) ( $package['contents_cost'] ?? 0 ) >= (float) $minimum; $show = 'yes' === $this->get_option( 'delivery_time', 'yes' ); $rates = array(); foreach ( $quotes as $i => $quote ) { $rates[] = array( 'id' => $this->get_rate_id( $quote->serviceCode ), 'label' => Services::label( $quote->serviceCode, $quote->serviceName, $choices ), 'cost' => $free && 0 === $i ? 0 : round( $quote->price( $taxMode ), 2 ), // Canada Post's tax-included price already carries the tax. 'taxes' => 'due' === $taxMode ? false : '', 'package' => $package, 'meta_data' => array( self::META_SERVICE => $quote->serviceCode ), 'delivery_time' => $show ? self::deliveryText( $quote ) : '', ); } /** * The rates about to be offered, as add_rate() arguments. * * @param list> $rates * @param list $quotes * @param array $package * @param Method $method */ $rates = apply_filters( 'bbmmp_rates', $rates, $quotes, $package, $this ); return is_array( $rates ) ? array_values( $rates ) : array(); } /** * "Estimated delivery Oct 14", or "Delivered in 3 business days", or ''. * * A date before today is ignored (risk 11: the Test app sends a fixed * 2025 date), so the customer sees the business days instead. */ public static function deliveryText( Quote $quote ): string { if ( null !== $quote->deliveryDate ) { $date = \DateTimeImmutable::createFromFormat( '!Y-m-d', $quote->deliveryDate, wp_timezone() ); $today = ( new \DateTimeImmutable( 'now', wp_timezone() ) )->setTime( 0, 0 ); if ( false !== $date && $date >= $today ) { /* translators: %s: a date, e.g. Oct 14. */ return sprintf( __( 'Estimated delivery %s', 'maple-parcel' ), wp_date( _x( 'M j', 'delivery estimate date format', 'maple-parcel' ), $date->getTimestamp() + 12 * HOUR_IN_SECONDS ) ); } } if ( null !== $quote->transitDays && $quote->transitDays > 0 ) { /* translators: %d: number of business days. */ return sprintf( _n( 'Delivered in %d business day', 'Delivered in %d business days', $quote->transitDays, 'maple-parcel' ), $quote->transitDays ); } return ''; } /** * Shows the delivery estimate under our rates in the classic cart and * checkout. The Cart and Checkout blocks read it from the rate on their own. */ public static function showDeliveryTime( mixed $rate ): void { if ( ! $rate instanceof \WC_Shipping_Rate || self::ID !== $rate->get_method_id() ) { return; } $text = (string) $rate->get_delivery_time(); if ( '' !== $text ) { printf( '
%s', esc_html( $text ) ); } } /** @param array $package */ private function addFallback( array $package ): void { $cost = (string) $this->get_option( 'fallback_cost' ); if ( ! is_numeric( $cost ) ) { return; } $label = trim( (string) $this->get_option( 'fallback_label' ) ); $this->add_rate( array( 'id' => $this->get_rate_id( 'fallback' ), 'label' => '' !== $label ? $label : __( 'Shipping', 'maple-parcel' ), 'cost' => (float) $cost, 'package' => $package, ) ); } private function rater( Settings $settings, Logger $logger ): Rater { $credentials = $settings->credentials(); $tokens = new TokenProvider( $credentials, $logger ); $client = new RatingClient( new Client( $tokens, $logger, $settings->language() ) ); // Service names come back in the request's language, so it is part of the key. return new Rater( $client, new RateCache( $settings->cacheMinutes(), $credentials->fingerprint() . '|' . $settings->language() ), $settings ); } }