get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE processed_gmt IS NULL AND created_gmt < %s', Install::table(), self::overdue_cutoff() ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery } /** * A refund was created in WooCommerce: open statements of the order count as processed. * * @param int $order_id Order ID. * @param int $refund_id Refund ID. */ public static function on_refund( $order_id, $refund_id ) { $order = Verifier::get_order( $order_id ); if ( ! $order ) { return; } foreach ( Submission::for_order( $order_id ) as $s ) { if ( $s->processed_gmt ) { continue; } $s->set_processed( 'refund' ); /* translators: 1: statement number, 2: refund ID */ $order->add_order_note( sprintf( __( 'Withdrawal %1$s marked as processed (refund #%2$d).', 'otkazpro' ), $s->number, (int) $refund_id ) ); } } /** * Daily: one email to the store with open statements whose refund deadline is near or past. * Every statement is included at most once. */ public static function send_reminders() { global $wpdb; // Refund due by the end of the day (site timezone) that is reminder_days() from today. $until = ( new \DateTimeImmutable( 'now', wp_timezone() ) )->setTime( 23, 59, 59 )->modify( '+' . self::reminder_days() . ' days' ); $cutoff = gmdate( 'Y-m-d H:i:s', $until->getTimestamp() - self::days() * DAY_IN_SECONDS ); $rows =$wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE processed_gmt IS NULL AND reminder_sent_gmt IS NULL AND created_gmt <= %s ORDER BY created_gmt ASC LIMIT %d', Install::table(), $cutoff, self::REMINDER_LIMIT ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery if ( ! $rows ) { return; } $submissions = array_map( array( Submission::class, 'from_row' ), $rows ); $emails = WC()->mailer()->get_emails(); $email = isset( $emails['PY_Otkaz_Admin_Refund_Reminder'] ) ? $emails['PY_Otkaz_Admin_Refund_Reminder'] : null; if ( ! $email || ! $email->is_enabled() || ! $email->get_recipient() ) { return; } if ( ! $email->trigger( $submissions ) ) { wc_get_logger()->error( 'Refund reminder could not be sent.', array( 'source' => 'otkazpro' ) ); return; } $ids = array_map( 'intval', wp_list_pluck( $submissions, 'id' ) ); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery $wpdb->query( $wpdb->prepare( 'UPDATE %i SET reminder_sent_gmt = %s WHERE id IN (' . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ')', array_merge( array( Install::table(), current_time( 'mysql', true ) ), $ids ) ) ); // phpcs:enable } /** * Upgrade from DB 1.0.0: statements of orders that were already refunded after the * statement count as processed; no reminder for statements already past the deadline. */ public static function migrate_existing() { global $wpdb; $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE processed_gmt IS NULL', Install::table() ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery foreach ( (array) $rows as $row ) { $s = Submission::from_row( $row ); $order = Verifier::get_order( $s->order_id ); if ( $order && self::refunded_since( $order, strtotime( $s->created_gmt . ' UTC' ) ) ) { $s->set_processed( 'refund' ); continue; } if ( time() > $s->refund_due() ) { $wpdb->update( Install::table(), array( 'reminder_sent_gmt' => current_time( 'mysql', true ) ), array( 'id' => $s->id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery } } } /** * Does the order have a refund created at or after the timestamp? * * @param \WC_Order $order Order. * @param int $since Timestamp. * @return bool */ private static function refunded_since( $order, $since ) { foreach ( $order->get_refunds() as $refund ) { $date = $refund->get_date_created(); if ( $date && $date->getTimestamp() >= $since ) { return true; } } return false; } /* --------------------------------------------------------------------- * Admin * ------------------------------------------------------------------ */ /** * Refund state badge: "Refunded", "Processed", "N days left" or "Overdue by N days". * * @param Submission $s Submission. * @return string */ public static function badge( $s ) { $state = $s->refund_state(); if ( 'processed' === $state ) { $label = 'refund' === $s->processed_by ? __( 'Refunded', 'otkazpro' ) : __( 'Processed', 'otkazpro' ); return '' . esc_html( $label ) . ''; } $days = $s->refund_days_left(); $due = wp_date( get_option( 'date_format' ), $s->refund_due() ); if ( 'overdue' === $state ) { $days = max( 1, -$days ); /* translators: %d: number of days */ $label = sprintf( _n( 'Overdue by %d day', 'Overdue by %d days', $days, 'otkazpro' ), $days ); $class = 'error'; } else { $label = 0 === $days ? __( 'Refund due today', 'otkazpro' ) /* translators: %d: number of days */ : sprintf( _n( '%d day left', '%d days left', $days, 'otkazpro' ), $days ); $class = 'soon' === $state ? 'warn' : ''; } /* translators: %s: date */ $title = sprintf( __( 'Refund due by %s', 'otkazpro' ), $due ); return '' . esc_html( $label ) . ''; } /** * "Mark as processed" / "Reopen" button. * * @param Submission $s Submission. * @return string */ public static function processed_button( $s ) { $url = wp_nonce_url( add_query_arg( array( 'action' => 'otkazpro_processed', 'id' => $s->id, 'state' => $s->processed_gmt ? 0 : 1, ), admin_url( 'admin-post.php' ) ), 'py_otkaz_processed_' . $s->id ); $label = $s->processed_gmt ? __( 'Reopen', 'otkazpro' ) : __( 'Mark as processed', 'otkazpro' ); return '' . esc_html( $label ) . ''; } /** * "Prepare refund" button: opens the order with the WooCommerce refund form pre-filled * with the products and quantities of the statement. The refund itself stays manual. * * @param Submission $s Submission. * @param \WC_Order|null $order Order. * @return string '' when the order cannot be refunded. */ public static function prepare_button( $s, $order = null ) { $order = $order ? $order : Verifier::get_order( $s->order_id ); if ( ! $order || $order->get_remaining_refund_amount() <= 0 ) { return ''; } $items = array(); foreach ( $s->items as $item ) { if ( ! empty( $item['item_id'] ) && $order->get_item( (int) $item['item_id'] ) ) { $items[ (int) $item['item_id'] ] = (int) $item['qty']; } } if ( ! $items ) { return ''; } $url = add_query_arg( 'otkazpro_refund', $s->id, $order->get_edit_order_url() ) . '#woocommerce-order-items'; return sprintf( '%5$s', esc_url( $url ), (int) $s->id, esc_attr( (string) wp_json_encode( (object) $items ) ), /* translators: %s: statement number */ esc_attr( sprintf( __( 'Withdrawal %s', 'otkazpro' ), $s->number ) ), esc_html__( 'Prepare refund', 'otkazpro' ) ); } /** * Handle "Mark as processed" / "Reopen". */ public static function handle_processed() { $id = isset( $_GET['id'] ) ? absint( $_GET['id'] ) : 0; check_admin_referer( 'py_otkaz_processed_' . $id ); if ( ! current_user_can( Admin::CAP ) ) { wp_die( esc_html__( 'You do not have permission to do this.', 'otkazpro' ) ); } $s = Submission::get( $id ); if ( $s ) { $done = ! empty( $_GET['state'] ); $s->set_processed( $done ? 'manual' : '' ); $order = Verifier::get_order( $s->order_id ); if ( $order ) { $order->add_order_note( $done /* translators: %s: statement number */ ? sprintf( __( 'Withdrawal %s marked as processed.', 'otkazpro' ), $s->number ) /* translators: %s: statement number */ : sprintf( __( 'Withdrawal %s reopened.', 'otkazpro' ), $s->number ), false, true ); } } $back = wp_get_referer(); wp_safe_redirect( $back ? $back : Admin::url() ); exit; } /** * Script that pre-fills the refund form, on the order edit screen only. */ public static function order_screen_script() { $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; $screens = array( 'shop_order' ); if ( function_exists( 'wc_get_page_screen_id' ) ) { $screens[] = wc_get_page_screen_id( 'shop-order' ); } if ( ! $screen || ! in_array( $screen->id, $screens, true ) ) { return; } wp_enqueue_script( 'otkazpro-admin-refund', PY_OTKAZ_URL . 'assets/js/admin-refund.js', array( 'jquery', 'wc-admin-order-meta-boxes' ), PY_OTKAZ_VERSION, true ); } }