--- a/sprout-invoices/Sprout_Invoices.class.php
+++ b/sprout-invoices/Sprout_Invoices.class.php
@@ -35,7 +35,7 @@
* Current version. Should match sprout-invoices.php plugin version.
*/
- const SI_VERSION = '20.8.8';
+ const SI_VERSION = '20.8.9';
/**
* DB Version
--- a/sprout-invoices/controllers/_Controller.php
+++ b/sprout-invoices/controllers/_Controller.php
@@ -24,7 +24,6 @@
public static function init() {
if ( is_admin() ) {
-
// On Activation
add_action( 'si_plugin_activation_hook', array( __CLASS__, 'sprout_invoices_activated' ) );
@@ -114,15 +113,18 @@
if ( is_admin() ) {
return $query;
}
+
if ( $query->is_single() ) {
return $query;
}
+
if ( $query->is_main_query() ) {
$type = $query->get( 'post_type' );
if ( in_array( $type, array( SI_Invoice::POST_TYPE, SI_Estimate::POST_TYPE ) ) ) {
$query->set( 'post_type', 'post' );
}
}
+
return $query;
}
@@ -141,12 +143,14 @@
*/
public static function sprout_invoices_activated() {
add_option( 'si_do_activation_redirect', true );
+
// Get the previous version number
$si_version = get_option( 'si_current_version', self::SI_VERSION );
if ( version_compare( $si_version, self::SI_VERSION, '<' ) ) { // If an upgrade create some hooks
do_action( 'si_version_upgrade', $si_version );
do_action( 'si_version_upgrade_'.$si_version );
}
+
// Set the new version number
update_option( 'si_current_version', self::SI_VERSION );
}
@@ -205,19 +209,24 @@
'locale_standard' => str_replace( '_', '-', get_locale() ),
'inline_spinner' => '<span class="spinner si_inline_spinner" style="visibility:visible;display:inline-block;"></span>',
);
+
if ( is_single() && ( get_post_type( get_the_ID() ) === SI_Invoice::POST_TYPE ) ) {
$si_js_object += array(
'invoice_id' => get_the_ID(),
'invoice_amount' => si_get_invoice_calculated_total(),
'invoice_balance' => si_get_invoice_balance(),
+ 'doc_hash' => SI_Upgrades::ensure_doc_hash( get_the_ID() ),
);
}
+
if ( is_single() && ( get_post_type( get_the_ID() ) === SI_Estimate::POST_TYPE ) ) {
$si_js_object += array(
'estimate_id' => get_the_ID(),
'estimate_total' => si_get_estimate_total(),
+ 'doc_hash' => SI_Upgrades::ensure_doc_hash( get_the_ID() ),
);
}
+
return apply_filters( 'si_sprout_doc_scripts_localization', $si_js_object );
}
@@ -228,9 +237,7 @@
$screen = get_current_screen();
$screen_post_type = str_replace( 'edit-', '', $screen->id );
if ( in_array( $screen_post_type, array( SI_Estimate::POST_TYPE, SI_Invoice::POST_TYPE ) ) ) {
-
if ( ! SI_FREE_TEST && file_exists( SI_PATH.'/resources/admin/plugins/redactor/redactor.min.js' ) ) {
-
$add_to_js_object['redactor'] = true;
wp_enqueue_script( 'redactor' );
@@ -250,14 +257,12 @@
}
if ( SI_Client::POST_TYPE === $screen_post_type ) {
-
wp_enqueue_script( 'si_admin_est_and_invoices' );
self::enqueue_general_scripts_styles();
}
if ( SI_Project::POST_TYPE === $screen_post_type ) {
-
wp_enqueue_script( 'si_admin_est_and_invoices' );
self::enqueue_general_scripts_styles();
@@ -265,7 +270,6 @@
if ( self::is_si_admin() ) {
if ( ! SI_FREE_TEST && file_exists( SI_PATH.'/resources/admin/plugins/redactor/redactor.min.js' ) ) {
-
$add_to_js_object['redactor'] = true;
wp_enqueue_script( 'redactor' );
@@ -292,7 +296,6 @@
}
public static function enqueue_general_scripts_styles( $scripts = array() ) {
-
// Defaults
if ( empty( $scripts ) ) {
$scripts = array( 'dropdown', 'select2', 'qtip' );
@@ -348,10 +351,12 @@
if ( self::DEBUG ) {
wp_clear_scheduled_hook( self::CRON_HOOK );
}
+
if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
$interval = apply_filters( 'si_set_schedule', 'quarterhour' );
wp_schedule_event( time(), $interval, self::CRON_HOOK );
}
+
if ( ! wp_next_scheduled( self::DAILY_CRON_HOOK ) ) {
wp_schedule_event( time(), 'daily', self::DAILY_CRON_HOOK );
}
@@ -395,7 +400,10 @@
$file = apply_filters( 'sprout_invoice_template_'.$view, $file );
$args = apply_filters( 'load_view_args_'.$view, $args, $allow_theme_override );
- if ( ! empty( $args ) ) { extract( $args ); }
+ if ( ! empty( $args ) ) {
+ extract( $args );
+ }
+
include $file;
}
@@ -430,6 +438,7 @@
foreach ( $possibilities as $p ) {
$theme_overrides[] = self::get_template_path().'/'.$p;
}
+
if ( $found = locate_template( $theme_overrides, false ) ) {
return $found;
}
@@ -508,10 +517,12 @@
if ( ! isset( self::$messages ) ) {
self::load_messages();
}
+
$message = __( $message, 'sprout-invoices' );
if ( ! isset( self::$messages[ $status ] ) ) {
self::$messages[ $status ] = array();
}
+
self::$messages[ $status ][] = $message;
if ( $save ) {
self::save_messages();
@@ -531,6 +542,7 @@
set_transient( 'si_messaging_for_'.self::get_user_ip(), self::$messages, 300 );
}
}
+
update_user_meta( $user_id, $blog_id.'_'.self::MESSAGE_META_KEY, self::$messages );
}
@@ -538,6 +550,7 @@
if ( ! isset( self::$messages ) ) {
self::load_messages();
}
+
return self::$messages;
}
@@ -552,6 +565,7 @@
global $blog_id;
$messages = get_user_meta( $user_id, $blog_id.'_'.self::MESSAGE_META_KEY, true );
}
+
if ( $messages ) {
self::$messages = $messages;
} else {
@@ -566,6 +580,7 @@
if ( isset( self::$messages[ self::MESSAGE_STATUS_INFO ] ) ) {
$statuses[] = self::MESSAGE_STATUS_INFO;
}
+
if ( isset( self::$messages[ self::MESSAGE_STATUS_ERROR ] ) ) {
$statuses[] = self::MESSAGE_STATUS_ERROR;
}
@@ -576,6 +591,7 @@
if ( ! isset( self::$messages ) ) {
self::load_messages();
}
+
foreach ( $statuses as $status ) {
foreach ( self::$messages[ $status ] as $message ) {
self::load_view( 'templates/messages', array(
@@ -585,6 +601,7 @@
}
self::$messages[ $status ] = array();
}
+
self::save_messages();
if ( defined( 'DOING_AJAX' ) ) {
exit();
@@ -600,9 +617,11 @@
$redirect = urlencode( add_query_arg( $_REQUEST, $redirect ) );
}
}
+
wp_redirect( wp_login_url( $redirect ) );
exit();
}
+
return true; // explicit return value, for the benefit of the router plugin
}
@@ -613,10 +632,12 @@
global $blog_id;
if ( empty( $blog_id ) || ! is_multisite() ) {
- $url = get_option( 'home' ); } else {
- $url = get_blog_option( $blog_id, 'home' ); }
+ $url = get_option( 'home' );
+ } else {
+ $url = get_blog_option( $blog_id, 'home' );
+ }
- return apply_filters( 'si_get_home_url_option', esc_url( $url ) );
+ return apply_filters( 'si_get_home_url_option', esc_url( $url ) );
}
/**
@@ -624,11 +645,13 @@
*/
public static function sort_by_weight( $a, $b ) {
if ( ! isset( $a['weight'] ) || ! isset( $b['weight'] ) ) {
- return 0; }
+ return 0;
+ }
if ( $a['weight'] == $b['weight'] ) {
return 0;
}
+
return ( $a['weight'] < $b['weight'] ) ? -1 : 1;
}
@@ -637,11 +660,13 @@
*/
public static function sort_by_date( $a, $b ) {
if ( ! isset( $a['date'] ) || ! isset( $b['date'] ) ) {
- return 0; }
+ return 0;
+ }
if ( $a['date'] == $b['date'] ) {
return 0;
}
+
return ( $a['date'] < $b['date'] ) ? -1 : 1;
}
@@ -660,6 +685,7 @@
if ( ! defined( 'DONOTCACHEPAGE' ) ) {
define( 'DONOTCACHEPAGE', true );
}
+
nocache_headers();
}
@@ -672,15 +698,11 @@
public static function clear_post_cache( $post_id ) {
if ( function_exists( 'wp_cache_post_change' ) ) {
// WP Super Cache
-
$GLOBALS['super_cache_enabled'] = 1;
wp_cache_post_change( $post_id );
-
} elseif ( function_exists( 'w3tc_pgcache_flush_post' ) ) {
// W3 Total Cache
-
w3tc_pgcache_flush_post( $post_id );
-
}
}
@@ -694,7 +716,6 @@
$post = get_post( $post_id );
$new_post_id = 0;
if ( isset( $post ) && $post != null ) {
-
if ( $new_post_type == '' ) {
$new_post_type = $post->post_type;
}
@@ -761,6 +782,7 @@
$date = current_time( 'timestamp' ) + ( DAY_IN_SECONDS * $days );
$invoice->set_due_date( $date );
}
+
// estimate clean up.
if ( SI_Estimate::POST_TYPE === $post->post_type ) {
// set dates.
@@ -773,6 +795,7 @@
}
}
}
+
// end
do_action( 'si_cloned_post', $new_post_id, $post_id, $new_post_type, $new_post_status );
return $new_post_id;
@@ -803,6 +826,7 @@
if ( $new_post_type != '' ) {
$url = add_query_arg( array( 'post_type' => $new_post_type ), esc_url_raw( $url ) );
}
+
return apply_filters( 'si_get_clone_post_url', esc_url_raw( $url ), $post_id, $new_post_type );
}
@@ -824,8 +848,10 @@
if ( is_a( $client, 'SI_Client' ) ) {
$address = $client->get_address();
}
+
$user = get_userdata( $user_id );
}
+
$fields = array();
$fields['first_name'] = array(
'weight' => 50,
@@ -908,7 +934,6 @@
////////////////////
public static function ajax_number_formatter() {
-
if ( ! isset( $_REQUEST['number'] ) ) {
self::ajax_fail( 'Forget something?' );
}
@@ -925,25 +950,50 @@
'int' => (int) si_get_number_format( $number ),
);
header( 'Content-type: application/json' );
- if ( self::DEBUG ) { header( 'Access-Control-Allow-Origin: *' ); }
+ if ( self::DEBUG ) {
+ header( 'Access-Control-Allow-Origin: *' );
+ }
+
echo wp_json_encode( $currency );
exit();
}
public static function maybe_create_private_note() {
-
if ( ! isset( $_REQUEST['private_note_nonce'] ) ) {
self::ajax_fail( 'Forget something?' );
}
if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_REQUEST['private_note_nonce'] ) ), SI_Internal_Records::NONCE ) ) {
- self::ajax_fail( 'We couldn’t process that request. Please contact system adminstrator.' ); }
+ self::ajax_fail( 'We could not process that request. Please contact system administrator.' );
+ }
- if ( ! current_user_can( 'edit_sprout_invoices' ) ) {
- return; }
- $notes = isset( $_REQUEST['notes'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['notes'] ) ) : '';
$associated_id = isset( $_REQUEST['associated_id'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['associated_id'] ) ) : 0;
+
+ // Authorization check: Either logged in with permission OR has the secret access hash
+ if ( is_user_logged_in() ) {
+ // Authenticated users need edit_sprout_invoices capability
+ if ( ! current_user_can( 'edit_sprout_invoices' ) ) {
+ self::ajax_fail( 'You do not have permission to create notes.' );
+ }
+ } else {
+ // Unauthenticated users must provide the correct access hash for THIS specific document
+ if ( ! isset( $_REQUEST['doc_hash'] ) ) {
+ self::ajax_fail( 'Missing document access key.' );
+ }
+
+ $provided_hash = sanitize_text_field( wp_unslash( $_REQUEST['doc_hash'] ) );
+
+ // Ensure hash exists (generates on-the-fly if missing)
+ $actual_hash = SI_Upgrades::ensure_doc_hash( $associated_id );
+
+ // Verify the provided hash matches this specific document's hash
+ if ( empty( $actual_hash ) || ! hash_equals( $actual_hash, $provided_hash ) ) {
+ self::ajax_fail( 'Invalid document access key.' );
+ }
+ }
+
+ $notes = isset( $_REQUEST['notes'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['notes'] ) ) : '';
$record_id = SI_Internal_Records::new_record( $notes, SI_Controller::PRIVATE_NOTES_TYPE, sanitize_text_field( wp_unslash( $_REQUEST['associated_id'] ) ), '', 0, false );
$error = ( $record_id ) ? '' : esc_html__( 'Private note failed to save, try again.', 'sprout-invoices' );
$data = array(
@@ -955,7 +1005,10 @@
);
header( 'Content-type: application/json' );
- if ( self::DEBUG ) { header( 'Access-Control-Allow-Origin: *' ); }
+ if ( self::DEBUG ) {
+ header( 'Access-Control-Allow-Origin: *' );
+ }
+
echo wp_json_encode( $data );
exit();
@@ -964,27 +1017,58 @@
/**
* Maybe Change Status
*
- * Note: There is no need to check if the user can edit the post
- * because this system is designed in a way that anyone with the
- * link to the invoice or estimate can change the status. This is
- * because the link to the invoice or estimate is a private hashed
- * link. Nonce is used to prevent CSRF attacks.
+ * Allows users to change invoice/estimate status via AJAX.
+ *
+ * Security model:
+ * - Authenticated users: Must have edit_post capability for the document
+ * - Unauthenticated users: Must provide the correct post hash for the specific document
+ *
+ * The post hash acts as a secret token - only those with the full URL can change status.
+ * This enables client self-service without requiring WordPress accounts.
*/
public static function maybe_change_status() {
if ( ! isset( $_REQUEST['change_status_nonce'] ) ) {
- self::ajax_fail( 'Forget something?' ); }
+ self::ajax_fail( 'Forget something?' );
+ }
if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_REQUEST['change_status_nonce'] ) ) , self::NONCE ) ) {
- self::ajax_fail( 'We couldn’t process that request. Please contact system adminstrator.' ); }
+ self::ajax_fail( 'We could not process that request. Please contact system administrator.' );
+ }
if ( ! isset( $_REQUEST['id'] ) ) {
- self::ajax_fail( 'Forget something?' ); }
+ self::ajax_fail( 'Forget something?' );
+ }
if ( ! isset( $_REQUEST['status'] ) ) {
- self::ajax_fail( 'Forget something?' ); }
+ self::ajax_fail( 'Forget something?' );
+ }
- $view = '';
$doc_id = sanitize_text_field( wp_unslash( $_REQUEST['id'] ) );
+
+ // Authorization check: Either logged in with permission OR has the secret access hash
+ if ( is_user_logged_in() ) {
+ // Authenticated users need edit permission
+ if ( ! current_user_can( 'edit_post', $doc_id ) ) {
+ self::ajax_fail( 'You do not have permission to change this status.' );
+ }
+ } else {
+ // Unauthenticated users must provide the correct access hash for THIS specific document
+ if ( ! isset( $_REQUEST['doc_hash'] ) ) {
+ self::ajax_fail( 'Missing document access key.' );
+ }
+
+ $provided_hash = sanitize_text_field( wp_unslash( $_REQUEST['doc_hash'] ) );
+
+ // Ensure hash exists (generates on-the-fly if missing)
+ $actual_hash = SI_Upgrades::ensure_doc_hash( $doc_id );
+
+ // Verify the provided hash matches this specific document's hash
+ if ( empty( $actual_hash ) || ! hash_equals( $actual_hash, $provided_hash ) ) {
+ self::ajax_fail( 'Invalid document access key.' );
+ }
+ }
+
+ $view = '';
$new_status = sanitize_text_field( wp_unslash( $_REQUEST['status'] ) );
switch ( get_post_type( $doc_id ) ) {
case SI_Invoice::POST_TYPE:
@@ -1025,7 +1109,11 @@
do_action( 'doc_status_changed', $doc, $_REQUEST );
header( 'Content-type: application/json' );
- if ( self::DEBUG ) { header( 'Access-Control-Allow-Origin: *' ); }
+
+ if ( self::DEBUG ) {
+ header( 'Access-Control-Allow-Origin: *' );
+ }
+
echo wp_json_encode( array( 'new_button' => $view ) );
exit();
@@ -1093,22 +1181,33 @@
if ( $message == '' ) {
$message = __( 'Something failed.', 'sprout-invoices' );
}
- if ( $json ) { header( 'Content-Type: application/json; charset=' . get_option( 'blog_charset' ) ); }
- if ( self::DEBUG ) { header( 'Access-Control-Allow-Origin: *' ); }
+
+ if ( $json ) {
+ header( 'Content-Type: application/json; charset=' . get_option( 'blog_charset' ) );
+ }
+
+ if ( self::DEBUG ) {
+ header( 'Access-Control-Allow-Origin: *' );
+ }
+
if ( $json ) {
echo wp_json_encode( array( 'error' => 1, 'response' => esc_html( $message ) ) );
} else {
wp_send_json( $message );
}
+
exit();
}
public static function get_user_ip() {
- if ( isset( $_SERVER['HTTP_USER_AGENT'] ) && preg_match( '/bot|crawl|slurp|spider|mediapartners/i', sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) )
+ if (
+ isset( $_SERVER['HTTP_USER_AGENT'] )
+ && preg_match( '/bot|crawl|slurp|spider|mediapartners/i', sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) )
) {
return false;
}
+
$client = isset( $_SERVER['HTTP_CLIENT_IP'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_CLIENT_IP'] ) ) : '';
$forward = isset( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) : '';
$remote = isset( $_SERVER['REMOTE_ADDR']) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
@@ -1121,6 +1220,7 @@
} elseif ( filter_var( $remote, FILTER_VALIDATE_IP ) ) {
$ip = $remote;
}
+
return apply_filters( 'si_get_user_ip', $ip );
}
@@ -1131,15 +1231,18 @@
if ( ! is_numeric( $number ) ) {
return $number;
}
+
if ( ! $number ) {
return 'zero';
}
+
$ends = array( 'th','st','nd','rd','th','th','th','th','th','th' );
if ( ($number % 100) >= 11 && ($number % 100) <= 13 ) {
$abbreviation = $number. 'th';
} else {
$abbreviation = $number. $ends[ $number % 10 ];
}
+
return $abbreviation;
}
--- a/sprout-invoices/controllers/admin/Upgrades.php
+++ b/sprout-invoices/controllers/admin/Upgrades.php
@@ -0,0 +1,182 @@
+<?php
+/**
+ * Upgrade/Migration Controller
+ *
+ * Handles automatic database migrations and upgrades when plugin is updated
+ *
+ * @package Sprout_Invoice
+ * @subpackage Admin
+ */
+class SI_Upgrades extends SI_Controller {
+ const OPTION_KEY = 'si_db_version';
+ const HASH_MIGRATION_VERSION = '20.8.9'; // Version when hash authentication was added
+
+ public static function init() {
+ // Run on plugin activation
+ add_action( 'si_plugin_activation_hook', array( __CLASS__, 'maybe_run_upgrades' ), 20 );
+
+ // Check on init to ensure migration runs regardless of whether admin visits site
+ // This prevents race condition where clients access invoices before migration completes
+ add_action( 'init', array( __CLASS__, 'maybe_run_upgrades' ), 5 );
+ }
+
+ /**
+ * Check if upgrades need to run and execute them
+ */
+ public static function maybe_run_upgrades() {
+ static $did_run = false;
+
+ // Prevent multiple executions within the same request.
+ if ( $did_run ) {
+ return;
+ }
+
+ $did_run = true;
+
+ $current_db_version = get_option( self::OPTION_KEY, '0' );
+ $plugin_version = Sprout_Invoices::SI_VERSION;
+
+ // If this is a fresh install or upgrade is needed
+ if ( version_compare( $current_db_version, $plugin_version, '<' ) ) {
+ self::run_upgrades( $current_db_version, $plugin_version );
+
+ // Update stored version
+ update_option( self::OPTION_KEY, $plugin_version );
+ }
+ }
+
+ /**
+ * Run necessary upgrade routines based on version
+ *
+ * @param string $from_version Version upgrading from
+ * @param string $to_version Version upgrading to
+ */
+ private static function run_upgrades( $from_version, $to_version ) {
+ // Hash authentication migration (added in 20.8.9)
+ if ( version_compare( $from_version, self::HASH_MIGRATION_VERSION, '<' ) ) {
+ self::upgrade_add_access_hashes();
+ }
+
+ // Future upgrades can be added here
+ // Example:
+ // if ( version_compare( $from_version, '21.0.0', '<' ) ) {
+ // self::upgrade_some_new_feature();
+ // }
+
+ do_action( 'si_after_upgrades', $from_version, $to_version );
+ }
+
+ /**
+ * Upgrade: Generate access hashes for existing documents
+ *
+ * This migration generates secure access hashes for all existing invoices
+ * and estimates to support the hash-based authentication system.
+ */
+ private static function upgrade_add_access_hashes() {
+ // Prevent timeout on large databases
+ set_time_limit( 300 ); // 5 minutes
+
+ // Generate hashes for invoices
+ $invoices = get_posts( array(
+ 'post_type' => SI_Invoice::POST_TYPE,
+ 'posts_per_page' => -1,
+ 'post_status' => 'any',
+ 'fields' => 'ids',
+ ) );
+
+ $invoice_count = 0;
+ foreach ( $invoices as $invoice_id ) {
+ $existing_hash = get_post_meta( $invoice_id, '_invoice_access_hash', true );
+ if ( empty( $existing_hash ) ) {
+ $hash = bin2hex( random_bytes( 32 ) );
+ update_post_meta( $invoice_id, '_invoice_access_hash', $hash );
+ $invoice_count++;
+ }
+ }
+
+ // Generate hashes for estimates
+ $estimates = get_posts( array(
+ 'post_type' => SI_Estimate::POST_TYPE,
+ 'posts_per_page' => -1,
+ 'post_status' => 'any',
+ 'fields' => 'ids',
+ ) );
+
+ $estimate_count = 0;
+ foreach ( $estimates as $estimate_id ) {
+ $existing_hash = get_post_meta( $estimate_id, '_estimate_access_hash', true );
+ if ( empty( $existing_hash ) ) {
+ $hash = bin2hex( random_bytes( 32 ) );
+ update_post_meta( $estimate_id, '_estimate_access_hash', $hash );
+ $estimate_count++;
+ }
+ }
+
+ // Log the migration
+ do_action( 'si_log', 'Hash Migration Complete', sprintf(
+ 'Generated hashes for %d invoices and %d estimates',
+ $invoice_count,
+ $estimate_count
+ ) );
+
+ // Store flag that this specific migration has run
+ update_option( 'si_hash_migration_completed', true );
+ }
+
+ /**
+ * Check if hash migration has been completed
+ *
+ * @return bool
+ */
+ public static function is_hash_migration_complete() {
+ return (bool) get_option( 'si_hash_migration_completed', false );
+ }
+
+ /**
+ * Get current database version
+ *
+ * @return string
+ */
+ public static function get_db_version() {
+ return get_option( self::OPTION_KEY, '0' );
+ }
+
+ /**
+ * Ensure a document has an access hash, generating one if missing
+ *
+ * This provides a fallback for the race condition where clients access
+ * documents before the migration runs on admin_init.
+ *
+ * @param int $post_id The document ID
+ * @return string The access hash
+ */
+ public static function ensure_doc_hash( $post_id ) {
+ $post_type = get_post_type( $post_id );
+ $hash_meta_key = '';
+
+ if ( $post_type === SI_Invoice::POST_TYPE ) {
+ $hash_meta_key = '_invoice_access_hash';
+ } elseif ( $post_type === SI_Estimate::POST_TYPE ) {
+ $hash_meta_key = '_estimate_access_hash';
+ } else {
+ return '';
+ }
+
+ $hash = get_post_meta( $post_id, $hash_meta_key, true );
+
+ // If hash doesn't exist, generate it now
+ if ( empty( $hash ) ) {
+ $hash = bin2hex( random_bytes( 32 ) );
+ update_post_meta( $post_id, $hash_meta_key, $hash );
+
+ // Log that we had to generate a hash on-the-fly
+ do_action( 'si_log', 'Generated Hash On-Demand', sprintf(
+ 'Generated access hash for %s ID %d (migration may not have run yet)',
+ $post_type,
+ $post_id
+ ) );
+ }
+
+ return $hash;
+ }
+}
--- a/sprout-invoices/controllers/notifications/Notifications.php
+++ b/sprout-invoices/controllers/notifications/Notifications.php
@@ -963,6 +963,12 @@
if ( isset( $data['invoice'] ) && is_a( $data['invoice'], 'SI_Invoice' ) ) {
$invoice_id = $data['invoice']->get_id();
$url = get_permalink( $invoice_id );
+
+ // Append access hash for client authentication (generates on-the-fly if missing)
+ $hash = SI_Upgrades::ensure_doc_hash( $invoice_id );
+ if ( ! empty( $hash ) ) {
+ $url = add_query_arg( 'hash', $hash, $url );
+ }
}
return apply_filters( 'shortcode_invoice_url', esc_url_raw( $url ), $data );
}
@@ -1463,6 +1469,12 @@
if ( isset( $data['estimate'] ) && is_a( $data['estimate'], 'SI_Estimate' ) ) {
$estimate_id = $data['estimate']->get_id();
$url = get_permalink( $estimate_id );
+
+ // Append access hash for client authentication (generates on-the-fly if missing)
+ $hash = SI_Upgrades::ensure_doc_hash( $estimate_id );
+ if ( ! empty( $hash ) ) {
+ $url = add_query_arg( 'hash', $hash, $url );
+ }
}
return apply_filters( 'shortcode_estimate_url', esc_url_raw( $url ), $data );
}
--- a/sprout-invoices/importers/Importer.php
+++ b/sprout-invoices/importers/Importer.php
@@ -210,6 +210,11 @@
self::ajax_fail( 'Not going to fall for it!' );
}
+ // Check if user is logged in and has import permissions
+ if ( ! current_user_can( 'manage_sprout_invoices_importer' ) ) {
+ self::ajax_fail( 'You do not have permission to import data.' );
+ }
+
$class = isset( $_REQUEST['importer'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['importer'] ) ) : '';
$method = isset( $_REQUEST['method'] ) ? 'import_' . sanitize_text_field( wp_unslash( $_REQUEST['method'] ) ) : '';
if ( method_exists( $class, $method ) ) {
--- a/sprout-invoices/load.php
+++ b/sprout-invoices/load.php
@@ -67,6 +67,9 @@
require_once SI_PATH.'/controllers/admin/Help.php';
+ // upgrades and migrations
+ require_once SI_PATH.'/controllers/admin/Upgrades.php';
+
// json api
// require_once SI_PATH.'/controllers/api/JSON_API.php';
@@ -247,6 +250,9 @@
SI_Admin_Capabilities::init();
+ // upgrades and migrations
+ SI_Upgrades::init();
+
// updates
if ( ! SI_FREE_TEST && class_exists( 'SI_Updates' ) ) {
SI_Updates::init();
--- a/sprout-invoices/models/Estimate.php
+++ b/sprout-invoices/models/Estimate.php
@@ -26,6 +26,7 @@
// meta fields with a prefixed key of _doc are transferable (when cloned) to invoices
private static $meta_keys = array(
+ 'access_hash' => '_estimate_access_hash', // string - unique hash for client access
'client_id' => '_client_id', // int
'currency' => '_doc_currency', // string
'discount' => '_doc_discount', // int
@@ -66,6 +67,9 @@
);
self::register_post_type( self::POST_TYPE, 'Estimate', 'Estimates', $post_type_args );
+ // Generate access hash on estimate save
+ add_action( 'save_post_' . self::POST_TYPE, array( __CLASS__, 'maybe_generate_access_hash' ), 10, 1 );
+
// register category taxonomy
// TODO remove since it's now deprecated
$singular = 'Task';
@@ -79,6 +83,23 @@
self::register_post_statuses();
}
+ /**
+ * Generate a unique access hash for this estimate if one doesn't exist
+ *
+ * @param int $post_id The post ID
+ * @return void
+ */
+ public static function maybe_generate_access_hash( $post_id ) {
+ // Check if hash already exists
+ $existing_hash = get_post_meta( $post_id, '_estimate_access_hash', true );
+
+ if ( empty( $existing_hash ) ) {
+ // Generate a cryptographically secure random hash
+ $hash = bin2hex( random_bytes( 32 ) ); // 64 character hex string
+ update_post_meta( $post_id, '_estimate_access_hash', $hash );
+ }
+ }
+
public static function get_statuses() {
$statuses = array(
self::STATUS_TEMP => __( 'Draft', 'sprout-invoices' ),
--- a/sprout-invoices/models/Invoice.php
+++ b/sprout-invoices/models/Invoice.php
@@ -34,6 +34,7 @@
*/
private static $meta_keys = array(
// migrated/match of estimates
+ 'access_hash' => '_invoice_access_hash', // string - unique hash for client access
'client_id' => '_client_id', // int
'currency' => '_doc_currency', // string
'deposit' => '_deposit', // float
@@ -133,6 +134,26 @@
self::register_post_type( self::POST_TYPE, 'Invoice', 'Invoices', $post_type_args );
self::register_post_statuses();
+
+ // Generate access hash on invoice save
+ add_action( 'save_post_' . self::POST_TYPE, array( __CLASS__, 'maybe_generate_access_hash' ), 10, 1 );
+ }
+
+ /**
+ * Generate a unique access hash for this invoice if one doesn't exist
+ *
+ * @param int $post_id The post ID
+ * @return void
+ */
+ public static function maybe_generate_access_hash( $post_id ) {
+ // Check if hash already exists
+ $existing_hash = get_post_meta( $post_id, '_invoice_access_hash', true );
+
+ if ( empty( $existing_hash ) ) {
+ // Generate a cryptographically secure random hash
+ $hash = bin2hex( random_bytes( 32 ) ); // 64 character hex string
+ update_post_meta( $post_id, '_invoice_access_hash', $hash );
+ }
}
public static function get_statuses() {
--- a/sprout-invoices/sprout-invoices.php
+++ b/sprout-invoices/sprout-invoices.php
@@ -3,7 +3,7 @@
* Plugin Name: Sprout Invoices
* Plugin URI: https://sproutinvoices.com
* Description: Easily accept estimates, create invoices, and receive invoice payments on your WordPress site. Learn more at <a href="https://sproutinvoices.com">sproutinvoices.com</a>.
- * Version: 20.8.8
+ * Version: 20.8.9
* Requires at least: 5.1
* Requires PHP: 7.2.5
* Author: BoldGrid
--- a/sprout-invoices/views/admin/dashboards/premium/balances-chart.php
+++ b/sprout-invoices/views/admin/dashboards/premium/balances-chart.php
@@ -0,0 +1,95 @@
+<div class="dashboard_widget inside">
+
+ <div id="balance_totals" class="chart_filter">
+ <span class="spinner si_inline_spinner"></span>
+ <input type="text" name="balance_totals_chart_segment_span" value="6" id="balance_totals_chart_segment_span" class="small-input"/>
+ <select id="balance_totals_chart_segment_select" name="balance_totals_chart_segment" class="chart_segment_select">
+ <option value="weeks"><?php esc_html_e( 'Weeks', 'sprout-invoices' ) ?></option>
+ <option value="months"><?php esc_html_e( 'Months', 'sprout-invoices' ) ?></option>
+ </select>
+ <button id="balance_totals_chart_filter" class="button" disabled="disabled"><?php esc_html_e( 'Show', 'sprout-invoices' ) ?></button>
+ </div>
+
+ <div class="main">
+ <canvas id="balance_totals_chart" min-height="300" max-height="500"></canvas>
+ <script type="text/javascript" charset="utf-8">
+ var balance_data = {};
+ var balance_total_chart = null;
+ var balance_totals_button = jQuery('#balance_totals_chart_filter');
+
+ function load_balance_totals_chart() {
+ var can = jQuery('#balance_totals_chart');
+ var ctx = can.get(0).getContext("2d");
+ // destroy current chart
+ if ( balance_total_chart !== null ) {
+ balance_total_chart.destroy();
+ };
+ balance_total_chart = new Chart(ctx).Line( balance_data );
+ }
+
+ var balance_chart_data = function () {
+ var segment = jQuery('#balance_totals_chart_segment_select').val(),
+ span = jQuery('#balance_totals_chart_segment_span').val();
+
+ balance_totals_button.prop('disabled', 'disabled');
+ jQuery('#balance_totals .spinner').css('visibility','visible');
+
+ jQuery.post( ajaxurl, {
+ action: '<?php echo esc_attr( SI_Reporting::AJAX_ACTION ) ?>',
+ data: 'balance_invoiced',
+ segment: segment,
+ span: span,
+ refresh_cache: si_js_object.reports_refresh_cache,
+ security: '<?php echo esc_attr( wp_create_nonce( SI_Reporting::AJAX_NONCE ) ) ?>'
+ },
+ function( response ) {
+ if ( response.error ) {
+ balance_totals_button.after('<span class="inline_error_message">' + response.response + '</span>');
+ return;
+ };
+ balance_data = {
+ labels: response.data.labels,
+ datasets: [
+ {
+ label: "<?php esc_html_e( 'Invoice Balances', 'sprout-invoices' ) ?>",
+ fillColor: "rgba(255,90,94,0.2)",
+ strokeColor: "rgba(255,90,94,1)",
+ pointColor: "rgba(255,90,94,1)",
+ pointStrokeColor: "#fff",
+ pointHighlightFill: "#fff",
+ pointHighlightStroke: "rgba(255,90,94,1)",
+ data: response.data.balances
+ },
+ {
+ label: "<?php esc_html_e( 'Payments', 'sprout-invoices' ) ?>",
+ fillColor: "rgba(38,41,44,0.2)",
+ strokeColor: "rgba(38,41,44,1)",
+ pointColor: "rgba(38,41,44,1)",
+ pointStrokeColor: "#fff",
+ pointHighlightFill: "#fff",
+ pointHighlightStroke: "rgba(38,41,44,1)",
+ data: response.data.payments
+ }
+ ]
+ }
+ load_balance_totals_chart();
+ // enable select
+ balance_totals_button.prop('disabled', false);
+ jQuery('#balance_totals .spinner').css('visibility','hidden');
+ }
+ );
+ };
+
+ jQuery(document).ready(function($) {
+ // load chart from the start
+ balance_chart_data();
+ // change data if select changes
+ balance_totals_button.on( 'click', function( e ) {
+ // load chart
+ balance_chart_data();
+ } );
+ });
+ </script>
+ <p class="description"><?php esc_html_e( 'Shows total outstanding balance and payments by week', 'sprout-invoices' ) ?></p>
+ </div>
+</div>
--- a/sprout-invoices/views/admin/dashboards/premium/estimates-invoices-chart.php
+++ b/sprout-invoices/views/admin/dashboards/premium/estimates-invoices-chart.php
@@ -0,0 +1,97 @@
+<div class="dashboard_widget inside">
+
+ <div id="est_inv_totals" class="chart_filter">
+ <span class="spinner si_inline_spinner"></span>
+ <input type="text" name="est_inv_totals_chart_segment_span" value="6" id="est_inv_totals_chart_segment_span" class="small-input"/>
+ <select id="est_inv_totals_chart_segment_select" name="est_inv_totals_chart_segment" class="chart_segment_select">
+ <option value="weeks"><?php esc_html_e( 'Weeks', 'sprout-invoices' ) ?></option>
+ <option value="months"><?php esc_html_e( 'Months', 'sprout-invoices' ) ?></option>
+ </select>
+ <button id="est_inv_totals_chart_filter" class="button" disabled="disabled"><?php esc_html_e( 'Show', 'sprout-invoices' ) ?></button>
+ </div>
+
+ <div class="main">
+ <canvas id="est_invoices_chart" min-height="300" max-height="500"></canvas>
+ <script type="text/javascript" charset="utf-8">
+ var est_inv_totals_data = {};
+ var est_invoice_chart = null;
+ var est_inv_totals_button = jQuery('#est_inv_totals_chart_filter');
+
+ function est_invoices_chart() {
+ var can = jQuery('#est_invoices_chart');
+ var ctx = can.get(0).getContext("2d");
+ // destroy current chart
+ if ( est_invoice_chart !== null ) {
+ est_invoice_chart.destroy();
+ };
+ est_invoice_chart = new Chart(ctx).Bar( est_inv_totals_data, {
+ multiTooltipTemplate: "<%= value %>",
+ } );
+ }
+
+ var get_est_inv_totals_data = function () {
+ var segment = jQuery('#est_inv_totals_chart_segment_select').val(),
+ span = jQuery('#est_inv_totals_chart_segment_span').val();
+
+ est_inv_totals_button.prop('disabled', 'disabled');
+ jQuery('#est_inv_totals .spinner').css('visibility','visible');
+
+ jQuery.post( ajaxurl, {
+ action: '<?php echo esc_attr( SI_Reporting::AJAX_ACTION ) ?>',
+ data: 'est_invoice_totals',
+ segment: segment,
+ span: span,
+ refresh_cache: si_js_object.reports_refresh_cache,
+ security: '<?php echo esc_attr( wp_create_nonce( SI_Reporting::AJAX_NONCE ) ) ?>'
+ },
+ function( response ) {
+ if ( response.error ) {
+ est_inv_totals_button.after('<span class="inline_error_message">' + response.response + '</span>');
+ return;
+ };
+ est_inv_totals_data = {
+ labels: response.data.labels,
+ datasets: [
+ {
+ label: "<?php esc_html_e( 'Estimates', 'sprout-invoices' ) ?>",
+ fillColor: "rgba(255,165,0,0.2)",
+ strokeColor: "rgba(255,165,0,1)",
+ pointColor: "rgba(255,165,0,1)",
+ pointStrokeColor: "#fff",
+ pointHighlightFill: "#fff",
+ pointHighlightStroke: "rgba(255,165,0,1)",
+ data: response.data.estimates
+ },
+ {
+ label: "<?php esc_html_e( 'Invoices', 'sprout-invoices' ) ?>",
+ fillColor: "rgba(134,189,72,0.2)",
+ strokeColor: "rgba(134,189,72,1)",
+ pointColor: "rgba(134,189,72,1)",
+ pointStrokeColor: "#fff",
+ pointHighlightFill: "#fff",
+ pointHighlightStroke: "rgba(134,189,72)",
+ data: response.data.invoices
+ }
+ ]
+ }
+ est_invoices_chart();
+ // enable select
+ est_inv_totals_button.prop('disabled', false);
+ jQuery('#est_inv_totals .spinner').css('visibility','hidden');
+ }
+ );
+ };
+
+ jQuery(document).ready(function($) {
+ // load chart from the start
+ get_est_inv_totals_data();
+ // change data if select changes
+ est_inv_totals_button.on( 'click', function( e ) {
+ // load chart
+ get_est_inv_totals_data();
+ } );
+ });
+ </script>
+ <p class="description"><?php esc_html_e( 'Shows total estimates and invoices by week.', 'sprout-invoices' ) ?></p>
+ </div>
+</div>
No newline at end of file
--- a/sprout-invoices/views/admin/dashboards/premium/estimates-status-chart.php
+++ b/sprout-invoices/views/admin/dashboards/premium/estimates-status-chart.php
@@ -0,0 +1,64 @@
+<div class="dashboard_widget inside">
+ <div class="main">
+ <canvas id="estimate_status_chart" min-height="300" max-height="500"></canvas>
+ <script type="text/javascript" charset="utf-8">
+
+ var estimate_status_data = {};
+
+ function estimate_status_chart() {
+ var can = jQuery('#estimate_status_chart');
+ var ctx = can.get(0).getContext("2d");
+ var chart = new Chart(ctx).Doughnut(estimate_status_data, {
+ responsive: true,
+ maintainAspectRatio: true
+ });
+ }
+
+ var estimate_status_data = function () {
+ jQuery.post( ajaxurl, {
+ action: '<?php echo esc_attr( SI_Reporting::AJAX_ACTION ) ?>',
+ data: 'estimates_statuses',
+ segment: 'weeks',
+ refresh_cache: si_js_object.reports_refresh_cache,
+ span: 6,
+ security: '<?php echo esc_attr( wp_create_nonce( SI_Reporting::AJAX_NONCE ) ) ?>'
+ },
+ function( data ) {
+ estimate_status_data = [
+ {
+ value: data.status_request,
+ color:"rgba(85,181,232,1)",
+ highlight: "rgba(85,181,232,.8)",
+ label: "<?php esc_html_e( 'Request', 'sprout-invoices' ) ?>"
+ },
+ {
+ value: data.status_pending,
+ color:"rgba(255,165,0,1)",
+ highlight: "rgba(255,165,0,.8)",
+ label: "<?php esc_html_e( 'Pending', 'sprout-invoices' ) ?>"
+ },
+ {
+ value: data.status_approved,
+ color: "rgba(134,189,72,1)",
+ highlight: "rgba(134,189,72,.8)",
+ label: "<?php esc_html_e( 'Approved', 'sprout-invoices' ) ?>"
+ },
+ {
+ value: data.status_declined,
+ color:"rgba(38,41,44,1)",
+ highlight: "rgba(38,41,44,.8)",
+ label: "<?php esc_html_e( 'Declined', 'sprout-invoices' ) ?>"
+ }
+ ];
+ estimate_status_chart();
+ }
+ );
+ };
+
+ jQuery(document).ready(function($) {
+ estimate_status_data();
+ });
+ </script>
+ <p class="description"><?php esc_html_e( 'Statuses from estimates from the last 3 weeks', 'sprout-invoices' ) ?></p>
+ </div>
+</div>
No newline at end of file
--- a/sprout-invoices/views/admin/dashboards/premium/estimates.php
+++ b/sprout-invoices/views/admin/dashboards/premium/estimates.php
@@ -0,0 +1,115 @@
+<div class="reports_widget inside">
+ <div class="main">
+ <?php
+ $args = array(
+ 'orderby' => 'modified',
+ 'post_type' => SI_Estimate::POST_TYPE,
+ 'post_status' => 'any', // Not Written-off?
+ 'posts_per_page' => 3,
+ 'fields' => 'ids',
+ );
+ $estimates = new WP_Query( $args ); ?>
+
+ <?php if ( ! empty( $estimates->posts ) ) : ?>
+ <b><?php esc_html_e( 'Latest Updates', 'sprout-invoices' ) ?></b>
+ <ul>
+ <?php foreach ( $estimates->posts as $estimate_id ) : ?>
+ <li><a href="<?php echo esc_attr( get_edit_post_link( $estimate_id ) ) ?>"><?php echo esc_html( get_the_title( $estimate_id ) ) ?></a> — <?php echo esc_html( date( get_option( 'date_format' ), get_post_modified_time( 'U', false, $estimate_id ) ) ) ?></li>
+ <?php endforeach ?>
+ </ul>
+ <?php else : ?>
+ <p>
+ <b><?php esc_html_e( 'Latest Updates', 'sprout-invoices' ) ?></b><br/>
+ <?php esc_html_e( 'No recent estimates found.', 'sprout-invoices' ) ?>
+ </p>
+ <?php endif ?>
+
+ <?php
+ $args = array(
+ 'post_type' => SI_Estimate::POST_TYPE,
+ 'post_status' => array( SI_Estimate::STATUS_REQUEST ),
+ 'posts_per_page' => 3,
+ 'fields' => 'ids',
+ );
+ $estimates = new WP_Query( $args ); ?>
+
+ <?php if ( ! empty( $estimates->posts ) ) : ?>
+ <b><?php esc_html_e( 'Recent Requests', 'sprout-invoices' ) ?></b>
+ <ul>
+ <?php foreach ( $estimates->posts as $estimate_id ) : ?>
+ <li><a href="<?php echo esc_attr( get_edit_post_link( $estimate_id ) ) ?>"><?php echo esc_html( get_the_title( $estimate_id ) )?></a> — <?php echo esc_html( date( get_option( 'date_format' ), get_post_time( 'U', false, $estimate_id ) ) )?></li>
+ <?php endforeach ?>
+ </ul>
+ <?php else : ?>
+ <p>
+ <b><?php esc_html_e( 'Recent Requests', 'sprout-invoices' ) ?></b><br/>
+ <?php esc_html_e( 'No recently requested estimates.', 'sprout-invoices' ) ?>
+ </p>
+ <?php endif ?>
+
+ <?php
+ $args = array(
+ 'orderby' => 'modified',
+ 'post_type' => SI_Estimate::POST_TYPE,
+ 'post_status' => array( SI_Estimate::STATUS_DECLINED ),
+ 'posts_per_page' => 3,
+ 'fields' => 'ids',
+ );
+ $estimates = new WP_Query( $args ); ?>
+
+ <?php if ( ! empty( $estimates->posts ) ) : ?>
+ <b><?php esc_html_e( 'Recent Declined', 'sprout-invoices' ) ?></b>
+ <ul>
+ <?php foreach ( $estimates->posts as $estimate_id ) : ?>
+ <li><a href="<?php echo esc_attr( get_edit_post_link( $estimate_id ) ) ?>"><?php echo esc_html( get_the_title( $estimate_id ) ) ?></a> — <?php echo esc_html( date( get_option( 'date_format' ), get_post_time( 'U', false, $estimate_id ) ) )?></li>
+ <?php endforeach ?>
+ </ul>
+ <?php else : ?>
+ <p>
+ <b><?php esc_html_e( 'Recent Declined', 'sprout-invoices' ) ?></b><br/>
+ <?php esc_html_e( 'No recently declined estimates.', 'sprout-invoices' ) ?>
+ </p>
+ <?php endif ?>
+
+ <?php
+ $args = array(
+ 'post_type' => SI_Estimate::POST_TYPE,
+ 'post_status' => array( SI_Estimate::STATUS_PENDING ),
+ 'posts_per_page' => 3,
+ 'fields' => 'ids',
+ 'meta_query' => array(
+ array(
+ 'meta_key' => '_expiration_date',
+ 'value' => array( 0, current_time( 'timestamp' ) ),
+ 'compare' => 'BETWEEN',
+ ),
+ ),
+ );
+ $estimates = new WP_Query( $args ); ?>
+
+ <?php if ( ! empty( $estimates->posts ) ) : ?>
+ <b><?php esc_html_e( 'Expired & Pending', 'sprout-invoices' ) ?></b>
+ <ul>
+ <?php foreach ( $estimates->posts as $estimate_id ) : ?>
+ <li><a href="<?php echo esc_attr( get_edit_post_link( $estimate_id ) ) ?>"><?php
+ $expired_or_pending = ( si_get_estimate_expiration_date( $estimate_id ) > current_time( 'timestamp' ) ) ? __( 'Expired', 'sprout-invoices' ) : __( 'Pending', 'sprout-invoices' );
+ echo esc_html( get_the_title( $estimate_id ) ) ?></a> —
+ <?php
+ printf(
+ // translators: 1: estimate status, 2: estimate expiration date.
+ esc_html( '%1$s: %2$s'),
+ esc_html( $expired_or_pending ),
+ esc_html( date_i18n( get_option( 'date_format' ), si_get_estimate_expiration_date( $estimate_id ) ) )
+ )
+ ?>
+ </li>
+ <?php endforeach ?>
+ </ul>
+ <?php else : ?>
+ <p>
+ <b><?php esc_html_e( 'Expired & Pending', 'sprout-invoices' ) ?></b><br/>
+ <?php esc_html_e( 'No recently expired or pending estimates.', 'sprout-invoices' ) ?>
+ </p>
+ <?php endif ?>
+ </div>
+</div>
--- a/sprout-invoices/views/admin/dashboards/premium/invoice-payments-chart.php
+++ b/sprout-invoices/views/admin/dashboards/premium/invoice-payments-chart.php
@@ -0,0 +1,96 @@
+<div class="dashboard_widget inside">
+
+ <div id="invoice_payments" class="chart_filter">
+ <span class="spinner si_inline_spinner"></span>
+ <input type="text" name="invoice_payments_chart_segment_span" value="6" id="invoice_payments_chart_segment_span" class="small-input"/>
+ <select id="invoice_payments_chart_segment_select" name="invoice_payments_chart_segment" class="chart_segment_select">
+ <option value="weeks"><?php esc_html_e( 'Weeks', 'sprout-invoices' ) ?></option>
+ <option value="months"><?php esc_html_e( 'Months', 'sprout-invoices' ) ?></option>
+ </select>
+ <button id="invoice_payments_chart_filter" class="button" disabled="disabled"><?php esc_html_e( 'Show', 'sprout-invoices' ) ?></button>
+ </div>
+
+ <div class="main">
+ <canvas id="invoice_payments_chart" min-height="300" max-height="500"></canvas>
+ <script type="text/javascript" charset="utf-8">
+ var inv_data = {};
+ var invoice_payments_chart = null;
+ var invoice_payments_button = jQuery('#invoice_payments_chart_filter');
+
+ function load_invoice_payments_chart() {
+ var can = jQuery('#invoice_payments_chart');
+ var ctx = can.get(0).getContext("2d");
+ // destroy current chart
+ if ( invoice_payments_chart !== null ) {
+ invoice_payments_chart.destroy();
+ };
+ invoice_payments_chart = new Chart(ctx).Line( inv_data );
+ }
+
+ var inv_chart_data = function () {
+ var segment = jQuery('#invoice_payments_chart_segment_select').val(),
+ span = jQuery('#invoice_payments_chart_segment_span').val();
+
+ invoice_payments_button.prop('disabled', 'disabled');
+ jQuery('#invoice_payments .spinner').css('visibility','visible');
+
+ jQuery.post( ajaxurl, {
+ action: '<?php echo esc_attr( SI_Reporting::AJAX_ACTION ) ?>',
+ data: 'invoice_payments',
+ segment: segment,
+ refresh_cache: si_js_object.reports_refresh_cache,
+ span: span,
+ security: '<?php echo esc_attr( wp_create_nonce( SI_Reporting::AJAX_NONCE ) ) ?>'
+ },
+ function( response ) {
+ if ( response.error ) {
+ invoice_payments_button.after('<span class="inline_error_message">' + response.response + '</span>');
+ return;
+ };
+ inv_data = {
+ labels: response.data.labels,
+ datasets: [
+ {
+ label: "<?php esc_html_e( 'Invoiced', 'sprout-invoices' ) ?>",
+ fillColor: "rgba(134,189,72,0.2)",
+ strokeColor: "rgba(134,189,72,1)",
+ pointColor: "rgba(134,189,72,1)",
+ pointStrokeColor: "#fff",
+ pointHighlightFill: "#fff",
+ pointHighlightStroke: "rgba(134,189,72)",
+ data: response.data.invoices
+ },
+ {
+ label: "<?php esc_html_e( 'Payments', 'sprout-invoices' ) ?>",
+ fillColor: "rgba(38,41,44,0.2)",
+ strokeColor: "rgba(38,41,44,1)",
+ pointColor: "rgba(38,41,44,1)",
+ pointStrokeColor: "#fff",
+ pointHighlightFill: "#fff",
+ pointHighlightStroke: "rgba(38,41,44,1)",
+ data: response.data.payments
+ }
+ ]
+ }
+ load_invoice_payments_chart();
+ // enable select
+ invoice_payments_button.prop('disabled', false);
+ jQuery('#invoice_payments .spinner').css('visibility','hidden');
+ }
+ );
+ };
+
+ // add chart after page loads
+ jQuery(document).ready(function($) {
+ // load chart from the start
+ inv_chart_data();
+ // change data if select changes
+ invoice_payments_button.on( 'click', function( e ) {
+ // load chart
+ inv_chart_data();
+ } );
+ });
+ </script>
+ <p class="description"><?php esc_html_e( 'Compares total invoiced and the total payments.', 'sprout-invoices' ) ?></p>
+ </div>
+</div>
--- a/sprout-invoices/views/admin/dashboards/premium/invoices-status-chart.php
+++ b/sprout-invoices/views/admin/dashboards/premium/invoices-status-chart.php
@@ -0,0 +1,67 @@
+<div class="dashboard_widget inside">
+ <div class="main">
+ <canvas id="invoice_status_chart" min-height="300" max-height="500"></canvas>
+ <script type="text/javascript" charset="utf-8">
+
+ var invoice_status_data = {};
+
+ function invoice_status_chart() {
+ var can = jQuery('#invoice_status_chart');
+ var ctx = can.get(0).getContext("2d");
+ var chart = new Chart(ctx).Doughnut(invoice_status_data );
+ }
+
+ var invoice_status_data = function () {
+ jQuery.post( ajaxurl, {
+ action: '<?php echo esc_attr( SI_Reporting::AJAX_ACTION ) ?>',
+ data: 'invoice_statuses',
+ segment: 'weeks',
+ refresh_cache: si_js_object.reports_refresh_cache,
+ span: 6,
+ security: '<?php echo esc_attr( wp_create_nonce( SI_Reporting::AJAX_NONCE ) ) ?>'
+ },
+ function( data ) {
+ invoice_status_data = [
+ {
+ value: data.status_temp,
+ color:"rgba(85,181,232,1)",
+ highlight: "rgba(85,181,232,.8)",
+ label: "<?php esc_html_e( 'Temp', 'sprout-invoices' ) ?>"
+ },
+ {
+ value: data.status_pending,
+ color:"rgba(255,165,0,1)",
+ highlight: "rgba(255,165,0,.8)",
+ label: "<?php esc_html_e( 'Pending', 'sprout-invoices' ) ?>"
+ },
+ {
+ value: data.status_partial,
+ color:"rgba(38,41,44,1)",
+ highlight: "rgba(38,41,44,.8)",
+ label: "<?php esc_html_e( 'Partial', 'sprout-invoices' ) ?>"
+ },
+ {
+ value: data.status_complete,
+ color: "rgba(134,189,72,1)",
+ highlight: "rgba(134,189,72,.8)",
+ label: "<?php esc_html_e( 'Complete', 'sprout-invoices' ) ?>"
+ },
+ {
+ value: data.status_writeoff,
+ color:"rgba(38,41,44,1)",
+ highlight: "rgba(38,41,44,.8)",
+ label: "<?php esc_html_e( 'Written Off', 'sprout-invoices' ) ?>"
+ }
+ ];
+ invoice_status_chart();
+ }
+ );
+ };
+
+ jQuery(document).ready(function($) {
+ invoice_status_data();
+ });
+ </script>
+ <p class="description"><?php esc_html_e( 'Statuses from invoices from the last 3 weeks', 'sprout-invoices' ) ?></p>
+ </div>
+</div>
No newline at end of file
--- a/sprout-invoices/views/admin/dashboards/premium/invoices.php
+++ b/sprout-invoices/views/admin/dashboards/premium/invoices.php
@@ -0,0 +1,88 @@
+<div class="dashboard_widget inside">
+ <div class="main">
+ <?php
+ $invoice_data = SI_Reporting::total_invoice_data();
+
+ $week_payment_data = SI_Reporting::total_payment_data( 'week' );
+
+ $last_week_payment_data = SI_Reporting::total_payment_data( 'lastweek' );
+ $month_payment_data = SI_Reporting::total_payment_data( 'month' );
+ $last_month_payment_data = SI_Reporting::total_payment_data( 'lastmonth' );
+ $year_payment_data = SI_Reporting::total_payment_data( 'year' );
+ $last_year_payment_data = SI_Reporting::total_payment_data( 'lastyear' ); ?>
+
+ <dl>
+ <dt><?php esc_html_e( 'Outstanding', 'sprout-invoices' ) ?></dt>
+ <dd><?php sa_formatted_money( $invoice_data['balance'] ) ?></dd>
+
+ <dt><?php esc_html_e( 'Paid (this week)', 'sprout-invoices' ) ?></dt>
+ <dd><?php sa_formatted_money( $week_payment_data['totals'] ) ?></dd>
+
+ <dt><?php esc_html_e( 'Paid (last week)', 'sprout-invoices' ) ?></dt>
+ <dd><?php sa_formatted_money( $last_week_payment_data['totals'] ) ?></dd>
+
+ <dt><?php esc_html_e( 'Paid (month to date)', 'sprout-invoices' ) ?></dt>
+ <dd><?php sa_formatted_money( $month_payment_data['totals'] ) ?></dd>
+
+ <dt><?php esc_html_e( 'Paid (last month)', 'sprout-invoices' ) ?></dt>
+ <dd><?php sa_formatted_money( $last_month_payment_data['totals'] ) ?></dd>
+
+ <dt><?php esc_html_e( 'Paid (year to date)', 'sprout-invoices' ) ?></dt>
+ <dd><?php sa_formatted_money( $year_payment_data['totals'] ) ?></dd>
+
+ <dt><?php esc_html_e( 'Paid (last year)', 'sprout-invoices' ) ?></dt>
+ <dd><?php sa_formatted_money( $last_year_payment_data['totals'] ) ?></dd>
+ </dl>
+
+ <?php
+ $args = array(
+ 'orderby' => 'modified',
+ 'post_type' => SI_Invoice::POST_TYPE,
+ 'post_status' => array_keys( SI_Invoice::get_statuses() ),
+ 'posts_per_page' => 5,
+ 'fields' => 'ids',
+ );
+ $invoices = new WP_Query( $args ); ?>
+
+ <?php if ( ! empty( $invoices->posts ) ) : ?>
+ <b><?php esc_html_e( 'Latest Updates', 'sprout-invoices' ) ?></b>
+ <ul>
+ <?php foreach ( $invoices->posts as $invoice_id ) : ?>
+ <li><a href="<?php echo esc_attr( get_edit_post_link( $invoice_id ) ) ?>"><?php echo esc_html( get_the_title( $invoice_id ) ) ?></a> — <?php echo esc_html( date( get_option( 'date_format' ), get_post_modified_time( 'U', false, $invoice_id ) ) ) ?></li>
+ <?php endforeach ?>
+ </ul>
+ <?php else : ?>
+ <p>
+ <b><?php esc_html_e( 'Latest Updates', 'sprout-invoices' ) ?></b><br/>
+ <?php esc_html_e( 'No invoices found.', 'sprout-invoices' ) ?>
+ </p>
+ <?php endif ?>
+
+ <?php
+ $invoices = SI_Invoice::get_overdue_invoices( apply_filters( 'si_dashboard_get_overdue_invoices_from', current_time( 'timestamp' ) - ( DAY_IN_SECONDS * 14 ) ), apply_filters( 'si_dashboard_get_overdue_invoices_to', current_time( 'timestamp' ) ) ); ?>
+
+ <?php if ( ! empty( $invoices ) ) : ?>
+ <b><?php esc_html_e( 'Recently Overdue & Unpaid', 'sprout-invoices' ) ?></b>
+ <ul>
+ <?php foreach ( $invoices as $invoice_id ) : ?>
+ <li>
+ <a href="<?php echo esc_attr( get_edit_post_link( $invoice_id ) ) ?>"><?php echo esc_html( get_the_title( $invoice_id ) )?></a>
+ —
+ <?php
+ printf(
+ // translators: 1: invoice due date.
+ esc_html__( 'Due: %1$s', 'sprout-invoices' ),
+ esc_html( date_i18n( get_option( 'date_format' ), si_get_invoice_due_date( $invoice_id ) ) )
+ );
+ ?>
+ </li>
+ <?php endforeach ?>
+ </ul>
+ <?php else : ?>
+ <p>
+ <b><?php esc_html_e( 'Overdue & Unpaid', 'sprout-invoices' ) ?></b><br/>
+ <?php esc_html_e( 'No overdue or unpaid invoices.', 'sprout-invoices' ) ?>
+ </p>
+ <?php endif ?>
+ </div>
+</div>
--- a/sprout-invoices/views/admin/dashboards/premium/payments-chart.php
+++ b/sprout-invoices/views/admin/dashboards/premium/payments-chart.php
@@ -0,0 +1,96 @@
+<div class="dashboard_widget inside">
+
+ <div id="payments" class="chart_filter">
+ <span class="spinner si_inline_spinner"></span>
+ <input type="text" name="payments_chart_segment_span" value="6" id="payments_chart_segment_span" class="small-input"/>
+ <select id="payments_chart_segment_select" name="payments_chart_segment" class="chart_segment_select">
+ <option value="weeks"><?php esc_html_e( 'Weeks', 'sprout-invoices' ) ?></option>
+ <option value="months"><?php esc_html_e( 'Months', 'sprout-invoices' ) ?></option>
+ </select>
+ <button id="payments_chart_filter" class="button" disabled="disabled"><?php esc_html_e( 'Show', 'sprout-invoices' ) ?></button>
+ </div>
+
+ <div class="main">
+ <canvas id="payments_chart" min-height="300" max-height="500"></canvas>
+ <script type="text/javascript" charset="utf-8">
+ var payments_data = {};
+ var payments_chart = null;
+ var payments_button = jQuery('#payments_chart_filter');
+
+ function load_payments_chart() {
+ var can = jQuery('#payments_chart');
+ var ctx = can.get(0).getContext("2d");
+ // destroy current chart
+ if ( payments_chart !== null ) {
+ payments_chart.destroy();
+ };
+ payments_chart = new Chart(ctx).Line( payments_data );
+ }
+
+ var payments_chart_data = function () {
+ var segment = jQuery('#payments_chart_segment_select').val(),
+ span = jQuery('#payments_chart_segment_span').val();
+
+ payments_button.prop('disabled', 'disabled');
+ jQuery('#payments .spinner').css('visibility','visible');
+
+ jQuery.post( ajaxurl, {
+ action: '<?php echo esc_attr( SI_Reporting::AJAX_ACTION )?>',
+ data: 'payments',
+ segment: segment,
+ refresh_cache: si_js_object.reports_refresh_cache,
+ span: span,
+ security: '<?php echo esc_attr( wp_create_nonce( SI_Reporting::AJAX_NONCE ) ) ?>'
+ },
+ function( response ) {
+ if ( response.error ) {
+ payments_button.after('<span class="inline_error_message">' + response.response + '</span>');
+ return;
+ };
+ payments_data = {
+ labels: response.data.labels,
+ datasets: [
+ {
+ label: "<?php esc_html_e( 'Totals', 'sprout-invoices' ) ?>",
+ fillColor: "rgba(134,189,72,0.2)",
+ strokeColor: "rgba(134,189,72,1)",
+ pointColor: "rgba(134,189,72,1)",
+ pointStrokeColor: "#fff",
+ pointHighlightFill: "#fff",
+ pointHighlightStroke: "rgba(134,189,72)",
+ data: response.data.totals
+ },
+ {
+ label: "<?php esc_html_e( 'Payments', 'sprout-invoices' ) ?>",
+ fillColor: "rgba(38,41,44,0.2)",
+ strokeColor: "rgba(38,41,44,1)",
+ pointColor: "rgba(38,41,44,1)",
+ pointStrokeColor: "#fff",
+ pointHighlightFill: "#fff",
+ pointHighlightStroke: "rgba(38,41,44,1)",
+ data: response.data.payments
+ }
+ ]
+ }
+ load_payments_chart();
+ // enable select
+ payments_button.prop('disabled', false);
+ jQuery('#payments .spinner').css('visibility','hidden');
+ }
+ );
+ };
+
+ // add chart after page loads
+ jQuery(document).ready(function($) {
+ // load chart from the start
+ payments_chart_data();
+ // change data if select changes
+ payments_button.on( 'click', function( e ) {
+ // load chart
+ payments_chart_data();
+ } );
+ });
+ </script>
+ <p class="description"><?php esc_html_e( 'Compares payment totals and the total payments.', 'sprout-invoices' ) ?></p>
+ </div>
+</div>
--- a/sprout-invoices/views/admin/dashboards/premium/payments-status-chart.php
+++ b/sprout-invoices/views/admin/dashboards/premium/payments-status-chart.php
@@ -0,0 +1,55 @@
+<div class="dashboard_widget inside">
+ <div class="main">
+ <canvas id="payments_status_chart" min-height="300" max-height="500"></canvas>
+ <script type="text/javascript" charset="utf-8">
+
+ var payment_status_data = {};
+
+ function payments_status_chart() {
+ var can = jQuery('#payments_status_chart');
+ var ctx = can.get(0).getContext("2d");
+ var chart = new Chart(ctx).Doughnut(payment_status_data );
+ }
+
+ var payments_status_chart_data = function () {
+ jQuery.post( ajaxurl, {
+ action: '<?php echo esc_attr( SI_Reporting::AJAX_ACTION ) ?>',
+ data: 'payment_statuses',
+ segment: 'weeks',
+ refresh_cache: si_js_object.reports_refresh_cache,
+ span: 6,
+ security: '<?php echo esc_attr( wp_create_nonce( SI_Reporting::AJAX_NONCE ) ) ?>'
+ },
+ function( data ) {
+ payment_status_data = [
+ {
+ value: data.status_pending,
+ color:"rgba(255,165,0,1)",
+ highlight: "rgba(255,165,0,.8)",
+ label: "Pending"
+ },
+ {
+ value: data.status_complete,
+ color: "rgba(134,189,72,1)",
+ highlight: "rgba(134,189,72,.8)",
+ label: "Paid"
+ },
+ {
+ value: data.status_void,
+ color:"rgba(38,41,44,1)",
+ highlight: "rgba(38,41,44,.8)",
+ label: "Void"
+ }
+ ];
+ payments_status_chart();
+ }
+ );
+ };
+
+ jQuery(document).ready(function($) {
+ payments_status_chart_data();
+ });
+ </script>
+ <p class="description"><?php esc_html_e( 'Statuses from payments from the last 3 weeks', 'sprout-invoices' ) ?></p>
+ </div>
+</div>
No newline at end of file
--- a/sprout-invoices/views/admin/dashboards/premium/requests-converted-chart.php
+++ b/sprout-invoices/views/admin/dashboards/premium/requests-converted-chart.php
@@ -0,0 +1,95 @@
+<div class="dashboard_widget inside">
+
+ <div id="req_est_totals" class="chart_filter">
+ <span class="spinner si_inline_spinner"></span>
+ <input type="text" name="req_est_totals_chart_segment_span" value="6" id="req_est_totals_chart_segment_span" class="small-input"/>
+ <select id="req_est_totals_chart_segment_select" name="req_est_totals_chart_segment" class="chart_segment_select">
+ <option value="weeks"><?php esc_html_e( 'Weeks', 'sprout-invoices' ) ?></option>
+ <option value="months"><?php esc_html_e( 'Months', 'sprout-invoices' ) ?></option>
+ </select>
+ <button id="req_est_totals_chart_filter" class="button" disabled="disabled"><?php esc_html_e( 'Show', 'sprout-invoices' ) ?></button>
+ </div>
+
+ <div class="main">
+ <canvas id="req_estimates_chart" min-height="300" max-height="500"></canvas>
+ <script type="text/javascript" charset="utf-8">
+ var req_est_totals_data = {};
+ var req_estimate_chart = null;
+ var req_est_totals_button = jQuery('#req_est_totals_chart_filter');
+
+ function req_est