self::PAGE ), $args ), admin_url( 'admin.php' ) );
}
/**
* Submenu under WooCommerce.
*/
public static function menu() {
// Needs attention: failed confirmations and refunds past the deadline.
$count = Submission::count_failed() + Refunds::count_overdue();
$title = __( 'Withdrawals', 'otkazpro' );
$menu = $title;
if ( $count ) {
$menu .= ' ' . (int) $count . '';
}
add_submenu_page( 'woocommerce', $title, $menu, self::CAP, self::PAGE, array( __CLASS__, 'render' ) );
}
/**
* Page router.
*/
public static function render() {
if ( ! current_user_can( self::CAP ) ) {
wp_die( esc_html__( 'You do not have permission to view this page.', 'otkazpro' ) );
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$view = isset( $_GET['view'] ) ? absint( $_GET['view'] ) : 0;
if ( $view ) {
self::render_detail( $view );
return;
}
$table = new Admin_List_Table();
$table->prepare_items();
?>
' . esc_html__( 'Withdrawal not found.', 'otkazpro' ) . '
';
return;
}
$order = Verifier::get_order( $s->order_id );
$rows = array(
__( 'Statement number', 'otkazpro' ) => esc_html( $s->number ),
__( 'Submitted on', 'otkazpro' ) => esc_html( Util::format_gmt( $s->created_gmt, 'd.m.Y H:i:s' ) . ' (' . wp_timezone_string() . ') / ' . $s->created_gmt . ' UTC' ),
__( 'Order', 'otkazpro' ) => $order ? '#' . esc_html( $s->order_number ) . '' : esc_html( '#' . $s->order_number ),
__( 'Name', 'otkazpro' ) => esc_html( $s->name ),
__( 'Email', 'otkazpro' ) => '' . esc_html( $s->email ) . '',
__( 'Deadline', 'otkazpro' ) => self::deadline_badge( $s ) . ( $s->deadline_gmt ? ' ' . esc_html( Util::format_gmt( $s->deadline_gmt, 'date' ) ) : '' ),
__( 'Products', 'otkazpro' ) => self::items_html( $s ),
__( 'Refund', 'otkazpro' ) => self::refund_html( $s, $order ),
__( 'Text of the statement', 'otkazpro' ) => '' . esc_html( $s->statement ) . '
',
__( 'Reason (optional)', 'otkazpro' ) => '' !== $s->reason ? nl2br( esc_html( $s->reason ) ) : '—',
__( 'Source', 'otkazpro' ) => esc_html( self::source_label( $s->source ) ),
__( 'Language', 'otkazpro' ) => esc_html( $s->language ? $s->language : '—' ),
__( 'IP address', 'otkazpro' ) => esc_html( $s->ip_address ),
__( 'User agent', 'otkazpro' ) => '' . esc_html( $s->user_agent ) . '',
__( 'Customer confirmation', 'otkazpro' ) => self::email_badge( $s ) . ( $s->email_sent_gmt ? ' ' . esc_html( Util::format_gmt( $s->email_sent_gmt, 'datetime' ) ) : '' ) . ' ' . self::resend_button( $s ),
__( 'Store notification', 'otkazpro' ) => esc_html( self::status_label( $s->admin_email_status ) ),
);
?>
processed_gmt ) {
$out = Refunds::badge( $s ) . ' ' . esc_html( Util::format_gmt( $s->processed_gmt, 'datetime' ) );
} else {
/* translators: %s: date */
$out = Refunds::badge( $s ) . ' ' . esc_html( sprintf( __( 'Refund due by %s', 'otkazpro' ), wp_date( get_option( 'date_format' ), $s->refund_due() ) ) );
}
$actions = ( $s->processed_gmt ? '' : Refunds::prepare_button( $s, $order ) . ' ' ) . Refunds::processed_button( $s );
return $out . '' . $actions . '
';
}
/**
* Items list markup.
*
* @param Submission $s Submission.
* @return string
*/
public static function items_html( $s ) {
$out = '';
foreach ( $s->items as $item ) {
$out .= '- ' . esc_html( $item['name'] ) . ' × ' . (int) $item['qty'];
if ( ! empty( $item['ordered_qty'] ) ) {
/* translators: %d: purchased quantity */
$out .= ' (' . esc_html( sprintf( __( 'of %d', 'otkazpro' ), (int) $item['ordered_qty'] ) ) . ')';
}
if ( ! empty( $item['excluded'] ) ) {
$out .= ' ' . esc_html__( 'Excluded product', 'otkazpro' ) . '';
}
$out .= '
';
}
return $out . '
';
}
/**
* Deadline badge.
*
* @param Submission $s Submission.
* @return string
*/
public static function deadline_badge( $s ) {
if ( true === $s->within_deadline ) {
return '' . esc_html__( 'In time', 'otkazpro' ) . '';
}
if ( false === $s->within_deadline ) {
return '' . esc_html__( 'After the deadline', 'otkazpro' ) . '';
}
return '' . esc_html__( 'Not started', 'otkazpro' ) . '';
}
/**
* Email status badge.
*
* @param Submission $s Submission.
* @return string
*/
public static function email_badge( $s ) {
$class = 'sent' === $s->email_status ? 'ok' : ( 'failed' === $s->email_status ? 'error' : '' );
$label = 'failed' === $s->email_status ? __( 'Email not sent', 'otkazpro' ) : self::status_label( $s->email_status );
return '' . esc_html( $label ) . '';
}
/**
* Status label.
*
* @param string $status Status.
* @return string
*/
public static function status_label( $status ) {
$labels = array(
'sent' => __( 'Sent', 'otkazpro' ),
'failed' => __( 'Failed', 'otkazpro' ),
'pending' => __( 'Pending', 'otkazpro' ),
'disabled' => __( 'Disabled', 'otkazpro' ),
);
return isset( $labels[ $status ] ) ? $labels[ $status ] : $status;
}
/**
* Source label.
*
* @param string $source Source.
* @return string
*/
public static function source_label( $source ) {
$labels = array(
'lookup' => __( 'Order number + email', 'otkazpro' ),
'link' => __( 'Link from email', 'otkazpro' ),
'account' => __( 'My account', 'otkazpro' ),
);
return isset( $labels[ $source ] ) ? $labels[ $source ] : (string) $source;
}
/**
* Resend form button.
*
* @param Submission $s Submission.
* @return string
*/
public static function resend_button( $s ) {
$url = wp_nonce_url(
add_query_arg(
array(
'action' => 'otkazpro_resend',
'id' => $s->id,
),
admin_url( 'admin-post.php' )
),
'py_otkaz_resend_' . $s->id
);
return '' . esc_html( 'sent' === $s->email_status ? __( 'Send again', 'otkazpro' ) : __( 'Resend', 'otkazpro' ) ) . '';
}
/**
* Handle resend.
*/
public static function resend() {
$id = isset( $_GET['id'] ) ? absint( $_GET['id'] ) : 0;
check_admin_referer( 'py_otkaz_resend_' . $id );
if ( ! current_user_can( self::CAP ) ) {
wp_die( esc_html__( 'You do not have permission to do this.', 'otkazpro' ) );
}
$s = Submission::get( $id );
$ok = $s ? Emails::send_customer( $s ) : false;
if ( $s && $ok ) {
$order = Verifier::get_order( $s->order_id );
if ( $order ) {
/* translators: 1: statement number, 2: email */
$order->add_order_note( sprintf( __( 'Withdrawal confirmation %1$s resent to %2$s.', 'otkazpro' ), $s->number, $s->email ) );
}
}
$back = wp_get_referer();
wp_safe_redirect( add_query_arg( 'otkazpro_msg', $ok ? 'resent' : 'resend_failed', $back ? $back : self::url() ) );
exit;
}
/**
* CSV export of the current filters (all columns).
*/
public static function export_csv() {
check_admin_referer( 'otkazpro_export' );
if ( ! current_user_can( self::CAP ) ) {
wp_die( esc_html__( 'You do not have permission to do this.', 'otkazpro' ) );
}
$args = Admin_List_Table::filters_from_request();
$args['per_page'] = 0;
$result = Submission::query( $args );
nocache_headers();
header( 'Content-Type: text/csv; charset=utf-8' );
header( 'Content-Disposition: attachment; filename=otkazi-' . gmdate( 'Y-m-d' ) . '.csv' );
$out = fopen( 'php://output', 'w' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
fwrite( $out, "\xEF\xBB\xBF" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- BOM for Excel.
$header = array(
__( 'Statement number', 'otkazpro' ),
__( 'Submitted (local)', 'otkazpro' ),
__( 'Submitted (UTC)', 'otkazpro' ),
__( 'Order ID', 'otkazpro' ),
__( 'Order number', 'otkazpro' ),
__( 'Customer ID', 'otkazpro' ),
__( 'Name', 'otkazpro' ),
__( 'Email', 'otkazpro' ),
__( 'Products', 'otkazpro' ),
__( 'Total quantity', 'otkazpro' ),
__( 'Excluded products', 'otkazpro' ),
__( 'Deadline', 'otkazpro' ),
__( 'Indicative deadline', 'otkazpro' ),
__( 'Refund due by', 'otkazpro' ),
__( 'Processed (UTC)', 'otkazpro' ),
__( 'Text of the statement', 'otkazpro' ),
__( 'Reason (optional)', 'otkazpro' ),
__( 'Source', 'otkazpro' ),
__( 'Language', 'otkazpro' ),
__( 'IP address', 'otkazpro' ),
__( 'User agent', 'otkazpro' ),
__( 'Customer confirmation', 'otkazpro' ),
__( 'Confirmation sent (UTC)', 'otkazpro' ),
__( 'Send attempts', 'otkazpro' ),
__( 'Store notification', 'otkazpro' ),
);
fputcsv( $out, $header, ',', '"', '' );
foreach ( $result['items'] as $s ) {
$items = array();
foreach ( $s->items as $item ) {
$items[] = $item['name'] . ( ! empty( $item['sku'] ) ? ' [' . $item['sku'] . ']' : '' ) . ' x ' . (int) $item['qty'] . ( ! empty( $item['excluded'] ) ? ' (' . $item['excluded_reason'] . ')' : '' );
}
if ( true === $s->within_deadline ) {
$deadline = __( 'In time', 'otkazpro' );
} elseif ( false === $s->within_deadline ) {
$deadline = __( 'After the deadline', 'otkazpro' );
} else {
$deadline = __( 'Not started', 'otkazpro' );
}
$row = array(
$s->number,
$s->created_local,
$s->created_gmt,
$s->order_id,
$s->order_number,
$s->customer_id,
$s->name,
$s->email,
implode( '; ', $items ),
$s->total_qty(),
$s->has_excluded ? __( 'Yes', 'otkazpro' ) : __( 'No', 'otkazpro' ),
$deadline,
$s->deadline_gmt ? Util::format_gmt( $s->deadline_gmt, 'Y-m-d' ) : '',
wp_date( 'Y-m-d', $s->refund_due() ),
(string) $s->processed_gmt,
$s->statement,
$s->reason,
self::source_label( $s->source ),
$s->language,
$s->ip_address,
$s->user_agent,
self::status_label( $s->email_status ),
$s->email_sent_gmt,
$s->email_attempts,
self::status_label( $s->admin_email_status ),
);
fputcsv( $out, array_map( array( Util::class, 'csv_cell' ), $row ), ',', '"', '' );
}
fclose( $out ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
exit;
}
/**
* Admin notices.
*/
public static function notices() {
if ( get_transient( 'py_otkaz_activated' ) && current_user_can( self::CAP ) && ! get_option( Install::CONFLICT_OPTION ) ) {
delete_transient( 'py_otkaz_activated' );
printf(
'',
esc_html__( 'OtkazPro is active. A “Withdrawal from contract” page was created – review the settings and add the link to your footer.', 'otkazpro' ),
esc_url( Settings::url() ),
esc_html__( 'Open settings', 'otkazpro' )
);
}
$taken = get_transient( 'py_otkaz_took_over' );
if ( $taken && current_user_can( 'activate_plugins' ) ) {
delete_transient( 'py_otkaz_took_over' );
echo '' . esc_html__( 'OtkazPro took over the settings and withdrawal records of the previous edition (WPOtkaz) and deactivated it. You can now delete WPOtkaz from the Plugins screen – the data stays.', 'otkazpro' ) . '
';
}
if ( get_transient( 'py_otkaz_settings_restored' ) && current_user_can( self::CAP ) ) {
delete_transient( 'py_otkaz_settings_restored' );
echo '' . esc_html__( 'OtkazPro restored its settings, which were removed when the previous edition was deleted.', 'otkazpro' ) . '
';
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$msg = isset( $_GET['otkazpro_msg'] ) ? sanitize_key( wp_unslash( $_GET['otkazpro_msg'] ) ) : '';
if ( 'resent' === $msg ) {
echo '' . esc_html__( 'The confirmation was sent to the customer.', 'otkazpro' ) . '
';
} elseif ( 'cache_checked' === $msg ) {
$level = Cache_Check::level( Cache_Check::result() );
$class = 'good' === $level ? 'notice-success' : ( 'critical' === $level ? 'notice-error' : 'notice-warning' );
echo '' . wp_kses_post( Cache_Check::describe( Cache_Check::result() ) ) . '
';
} elseif ( 'page_ok' === $msg ) {
echo '' . esc_html__( 'The withdrawal form page is set up. Check it and add the link to your footer.', 'otkazpro' ) . '
';
} elseif ( 'page_failed' === $msg ) {
echo '' . esc_html__( 'The page could not be updated. Add [otkazpro] to the page manually and select it in the settings.', 'otkazpro' ) . '
';
} elseif ( 'resend_failed' === $msg ) {
echo '' . esc_html__( 'The email could not be sent. Check your SMTP settings and WooCommerce → Status → Logs (source “otkazpro”).', 'otkazpro' ) . '
';
}
// Persistent warnings are limited to the OtkazPro screens (critical ones also to the Plugins screen).
$on_ours = self::is_own_screen();
if ( ! $on_ours && ! self::is_screen( 'plugins' ) ) {
return;
}
$cache_level = Cache_Check::level( Cache_Check::result() );
if ( 'cache_checked' !== $msg && current_user_can( self::CAP ) && ( 'critical' === $cache_level || ( 'recommended' === $cache_level && $on_ours ) ) ) {
printf(
'',
'critical' === $cache_level ? 'notice-error' : 'notice-warning',
esc_html__( 'OtkazPro:', 'otkazpro' ),
wp_kses_post( Cache_Check::describe( Cache_Check::result() ) ),
esc_url( Cache_Check::button_url() ),
esc_html__( 'Check cache now', 'otkazpro' )
);
}
$conflict = (int) get_option( Install::CONFLICT_OPTION );
if ( $conflict && current_user_can( self::CAP ) && current_user_can( 'edit_pages' ) ) {
self::conflict_notice( $conflict );
return;
}
$page_id = (int) Settings::get( 'page_id' );
if ( current_user_can( self::CAP ) && ! Form::is_valid_page( $page_id ) ) {
printf(
'',
esc_html__( 'OtkazPro:', 'otkazpro' ),
esc_html__( 'The withdrawal form page is missing, not published or no longer contains the form. Customers are sent to a fallback page so the function keeps working – select a page with [otkazpro] in the settings.', 'otkazpro' ),
esc_url( Settings::url() ),
esc_html__( 'Open settings', 'otkazpro' )
);
}
}
/**
* Is the current admin screen one of the OtkazPro screens (register or its settings tab)?
*
* @return bool
*/
private static function is_own_screen() {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : '';
return self::is_screen( 'woocommerce_page_' . self::PAGE ) || ( self::is_screen( 'woocommerce_page_wc-settings' ) && Settings::TAB === $tab );
}
/**
* Is the current admin screen the given one?
*
* @param string $id Screen ID.
* @return bool
*/
private static function is_screen( $id ) {
$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
return $screen && $id === $screen->id;
}
/**
* "A page with this slug already exists" notice with two choices.
*
* @param int $page_id Existing page ID.
*/
private static function conflict_notice( $page_id ) {
$post = get_post( $page_id );
if ( ! $post ) {
delete_option( Install::CONFLICT_OPTION );
return;
}
$url = static function ( $choice ) use ( $page_id ) {
return wp_nonce_url( add_query_arg( array( 'action' => 'otkazpro_page', 'choice' => $choice, 'page_id' => $page_id ), admin_url( 'admin-post.php' ) ), 'otkazpro_page' );
};
?>
' . esc_html( get_the_title( $post ) ) . '',
esc_html( get_post_status_object( $post->post_status ) ? get_post_status_object( $post->post_status )->label : $post->post_status )
)
);
?>
Project Yordanov'
);
}
return $meta;
}
/**
* Discreet credit in the admin footer, only on OtkazPro screens.
*
* @param string $text Footer text.
* @return string
*/
public static function footer_credit( $text ) {
if ( ! self::is_own_screen() ) {
return $text;
}
return sprintf(
/* translators: 1: plugin name, 2: version, 3: agency name with link */
esc_html__( '%1$s %2$s · developed by %3$s', 'otkazpro' ),
'OtkazPro',
esc_html( PY_OTKAZ_VERSION ),
'Project Yordanov'
);
}
/**
* Small admin CSS for badges.
*/
public static function admin_css() {
wp_register_style( 'otkazpro-admin', false, array(), PY_OTKAZ_VERSION );
wp_enqueue_style( 'otkazpro-admin' );
wp_add_inline_style(
'otkazpro-admin',
'.otkazpro-badge{display:inline-block;padding:2px 8px;border-radius:10px;background:#f0f0f1;color:#50575e;font-size:12px;line-height:1.6;white-space:nowrap}'
. '.otkazpro-badge--ok{background:#edfaef;color:#00691c}'
. '.otkazpro-badge--warn{background:#fcf9e8;color:#8a4b00}'
. '.otkazpro-badge--error{background:#fcf0f1;color:#a00}'
. '.otkazpro-admin-items{margin:0}'
. '.otkazpro-admin-items li{margin:0 0 2px}'
. '.otkazpro-actions{margin:6px 0 0}'
. '.column-refund .button{margin:4px 4px 0 0}'
. '.column-py_otkaz{width:90px}'
);
}
/**
* Orders list column.
*
* @param array $columns Columns.
* @return array
*/
public static function orders_column( $columns ) {
$new = array();
foreach ( $columns as $key => $label ) {
$new[ $key ] = $label;
if ( 'order_status' === $key ) {
$new['py_otkaz'] = __( 'Withdrawal', 'otkazpro' );
}
}
if ( ! isset( $new['py_otkaz'] ) ) {
$new['py_otkaz'] = __( 'Withdrawal', 'otkazpro' );
}
return $new;
}
/**
* Orders list column content.
*
* @param string $column Column.
* @param int|\WC_Order $order Order or post ID.
*/
public static function orders_column_content( $column, $order ) {
if ( 'py_otkaz' !== $column ) {
return;
}
$order = $order instanceof \WC_Order ? $order : Verifier::get_order( (int) $order );
if ( ! $order ) {
return;
}
$history = Order_Data::history( $order );
if ( ! $history ) {
return;
}
$numbers = wp_list_pluck( $history, 'number' );
printf(
'%s',
esc_url( self::url( array( 's' => $order->get_order_number() ) ) ),
esc_attr( implode( ', ', $numbers ) ),
esc_html(
count( $history ) > 1
/* translators: %d: count */
? sprintf( __( 'Withdrawal ×%d', 'otkazpro' ), count( $history ) )
: __( 'Withdrawal', 'otkazpro' )
)
);
}
/**
* Meta box on the order edit screen.
*
* @param string $screen_id Screen ID.
* @param \WP_Post|object $object Post or order.
*/
public static function meta_box( $screen_id, $object = null ) {
$screens = array( 'shop_order' );
if ( function_exists( 'wc_get_page_screen_id' ) ) {
$screens[] = wc_get_page_screen_id( 'shop-order' );
}
if ( ! in_array( $screen_id, $screens, true ) ) {
return;
}
$order = $object instanceof \WP_Post ? Verifier::get_order( $object->ID ) : ( $object instanceof \WC_Order ? $object : null );
if ( ! $order || ! Order_Data::history( $order ) ) {
return;
}
add_meta_box( 'otkazpro-order', __( 'Withdrawal from contract', 'otkazpro' ), array( __CLASS__, 'meta_box_content' ), $screen_id, 'side', 'high' );
}
/**
* Meta box content.
*
* @param \WP_Post|\WC_Order $object Post or order.
*/
public static function meta_box_content( $object ) {
$order = $object instanceof \WP_Post ? Verifier::get_order( $object->ID ) : $object;
if ( ! $order ) {
return;
}
$deadline = Order_Data::deadline( $order );
foreach ( Submission::for_order( $order->get_id() ) as $s ) {
echo '';
echo '
' . esc_html( $s->number ) . ' · ' . esc_html( Util::format_gmt( $s->created_gmt, 'datetime' ) ) . '
';
echo wp_kses_post( self::items_html( $s ) );
echo '
' . wp_kses_post( self::deadline_badge( $s ) . ' ' . self::email_badge( $s ) ) . '
';
echo wp_kses_post( '
' . self::refund_html( $s, $order ) . '
' );
echo '
';
}
if ( $deadline ) {
/* translators: %s: date */
echo '' . esc_html( sprintf( __( 'Indicative deadline: %s', 'otkazpro' ), wp_date( get_option( 'date_format' ), $deadline->getTimestamp() ) ) ) . '
';
}
echo '' . esc_html__( '“Prepare refund” fills in the WooCommerce refund form of this order; check the amount and confirm it. The plugin never refunds or restocks by itself.', 'otkazpro' ) . '
';
}
}