Completed payments per invoice, filled by prime(). */ private static $paid_cache = []; /** @var array Credit-note totals per invoice, filled by prime(). */ private static $credit_cache = []; /** * Load the paid and credited totals for many invoices in two queries. * * The dashboard and the reports call due() for every invoice on the * site; without this each call ran its own payment and credit-note * lookups (two to four queries per invoice, thousands on a busy site). * * @param int[] $invoice_ids */ public static function prime( array $invoice_ids ): void { // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $in is a list of intval()'d ids. global $wpdb; $ids = array_values( array_unique( array_filter( array_map( 'intval', $invoice_ids ) ) ) ); if ( ! $ids ) { return; } foreach ( array_chunk( $ids, 2000 ) as $chunk ) { $in = implode( ',', $chunk ); // Completed payments grouped by invoice. $rows = $wpdb->get_results( "SELECT inv.meta_value AS invoice_id, SUM(CAST(amt.meta_value AS DECIMAL(18,4))) AS paid FROM {$wpdb->posts} p INNER JOIN {$wpdb->postmeta} inv ON inv.post_id = p.ID AND inv.meta_key = '_invoice_id' INNER JOIN {$wpdb->postmeta} st ON st.post_id = p.ID AND st.meta_key = '_status' LEFT JOIN {$wpdb->postmeta} amt ON amt.post_id = p.ID AND amt.meta_key = '_amount' WHERE p.post_type = 'easy_invoice_payment' AND p.post_status = 'publish' AND LOWER(st.meta_value) IN ('completed','complete','paid','success','succeeded') AND inv.meta_value IN ($in) GROUP BY inv.meta_value", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- integer ids. ARRAY_A ); foreach ( $chunk as $id ) { self::$paid_cache[ $id ] = 0.0; self::$credit_cache[ $id ] = 0.0; } foreach ( (array) $rows as $r ) { self::$paid_cache[ (int) $r['invoice_id'] ] = round( (float) $r['paid'], 2 ); } // Credit notes grouped by invoice. $rows = $wpdb->get_results( "SELECT inv.meta_value AS invoice_id, SUM(CAST(tot.meta_value AS DECIMAL(18,4))) AS credited FROM {$wpdb->posts} p INNER JOIN {$wpdb->postmeta} inv ON inv.post_id = p.ID AND inv.meta_key = '_easy_invoice_credited_invoice_id' LEFT JOIN {$wpdb->postmeta} tot ON tot.post_id = p.ID AND tot.meta_key = '_easy_invoice_total' WHERE p.post_type = 'easy_invoice_credit' AND p.post_status = 'publish' AND inv.meta_value IN ($in) GROUP BY inv.meta_value", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- integer ids. ARRAY_A ); foreach ( (array) $rows as $r ) { self::$credit_cache[ (int) $r['invoice_id'] ] = round( (float) $r['credited'], 2 ); } } // phpcs:enable } /** * Forget primed totals (after a payment or credit note is written). * * @param int|null $invoice_id One invoice, or everything when null. */ public static function forget( ?int $invoice_id = null ): void { if ( null === $invoice_id ) { self::$paid_cache = self::$credit_cache = []; return; } unset( self::$paid_cache[ $invoice_id ], self::$credit_cache[ $invoice_id ] ); } /** * Credit notes issued against an invoice. * * @param int $invoice_id Invoice. * @return float */ public static function credited( int $invoice_id ): float { if ( $invoice_id <= 0 ) { return 0.0; } if ( isset( self::$credit_cache[ $invoice_id ] ) ) { return self::$credit_cache[ $invoice_id ]; } return CreditNote::creditedTotal( $invoice_id ); } /** * Total less payments and credits, never below zero. * * @param mixed $invoice Invoice model. * @return float */ public static function due( $invoice ): float { $total = is_callable( [ $invoice, 'getTotal' ] ) ? (float) $invoice->getTotal() : 0.0; $id = is_callable( [ $invoice, 'getId' ] ) ? (int) $invoice->getId() : 0; $due = max( 0.0, round( $total - self::paid( $id ) - self::credited( $id ), 2 ) ); /** * Filter the amount still owed on an invoice — shown in the designs' * header, the payment panel, and charged by the gateways. * * @param float $due Total less payments received and credit notes. * @param mixed $invoice Invoice model. */ return (float) apply_filters( 'easy_invoice_amount_due', $due, $invoice ); } /** * Open invoices still carrying a balance, with the amounts by currency * and the subset past their due date. * * @param array $invoices Invoice models. * @return array{count:int,overdue_count:int,amount:array,overdue_amount:array} */ public static function outstanding( array $invoices ): array { $out = [ 'count' => 0, 'overdue_count' => 0, 'amount' => [], 'overdue_amount' => [] ]; self::prime( array_map( function ( $invoice ) { return is_callable( [ $invoice, 'getId' ] ) ? (int) $invoice->getId() : 0; }, $invoices ) ); $today = gmdate( 'Y-m-d', current_time( 'timestamp' ) ); $site = strtoupper( (string) get_option( 'easy_invoice_currency_code', 'USD' ) ); foreach ( $invoices as $invoice ) { $status = is_callable( [ $invoice, 'getStatus' ] ) ? strtolower( (string) $invoice->getStatus() ) : ''; if ( ! in_array( $status, [ 'available', 'unpaid', 'partial', 'overdue', 'sent', 'pending' ], true ) ) { continue; } $due = self::due( $invoice ); if ( $due <= 0 ) { continue; } $currency = is_callable( [ $invoice, 'getCurrencyCode' ] ) ? strtoupper( (string) $invoice->getCurrencyCode() ) : ''; if ( '' === $currency || 'GLOBAL' === $currency ) { $currency = $site; } $out['count']++; $out['amount'][ $currency ] = ( $out['amount'][ $currency ] ?? 0 ) + $due; $due_date = is_callable( [ $invoice, 'getDueDate' ] ) ? (string) $invoice->getDueDate() : ''; if ( '' !== $due_date && strtotime( $due_date ) && gmdate( 'Y-m-d', strtotime( $due_date ) ) < $today ) { $out['overdue_count']++; $out['overdue_amount'][ $currency ] = ( $out['overdue_amount'][ $currency ] ?? 0 ) + $due; } } return $out; } /** * Whether payments and credits together cover the invoice. * * @param mixed $invoice Invoice model. * @return bool */ public static function isSettled( $invoice ): bool { $total = is_callable( [ $invoice, 'getTotal' ] ) ? (float) $invoice->getTotal() : 0.0; $id = is_callable( [ $invoice, 'getId' ] ) ? (int) $invoice->getId() : 0; return self::paid( $id ) + self::credited( $id ) + 0.005 >= $total; } }