PostTypes::EASY_INVOICE_POST_TYPE, 'post_status' => [ 'publish', 'draft', 'pending', 'private' ], 'numberposts' => -1, 'fields' => 'ids', 'no_found_rows' => true, 'suppress_filters' => true, 'meta_query' => [ [ 'key' => self::META_INVOICE_CLIENT, 'value' => $client_id, ], ], ] ); if ( $include_drafts ) { return $ids; } return array_values( array_filter( $ids, static function ( $id ) { return 'draft' !== (string) get_post_meta( $id, '_easy_invoice_status', true ); } ) ); } /** * Every payment belonging to a client, reached through their invoices. * * @param int $client_id Client ID. * @return int[] */ public static function paymentIds( int $client_id ): array { $invoice_ids = self::invoiceIds( $client_id ); if ( empty( $invoice_ids ) ) { return []; } return get_posts( [ 'post_type' => PostTypes::EASY_INVOICE_PAYMENT_POST_TYPE, 'post_status' => [ 'publish', 'draft', 'pending', 'private' ], 'numberposts' => -1, 'fields' => 'ids', 'no_found_rows' => true, 'suppress_filters' => true, 'meta_query' => [ [ 'key' => self::META_PAYMENT_INVOICE, 'value' => array_map( 'strval', $invoice_ids ), 'compare' => 'IN', ], ], ] ); } /** * Every credit note issued against a client's invoices. * * @param int $client_id Client ID. * @return int[] */ public static function creditNoteIds( int $client_id ): array { $ids = []; if ( ! class_exists( '\EasyInvoice\Services\CreditNote' ) ) { return $ids; } // One query for all of the client's invoices rather than one per // invoice (a client with thousands of invoices made thousands of // round-trips here). $invoice_ids = array_map( 'intval', self::invoiceIds( $client_id ) ); if ( ! $invoice_ids ) { return $ids; } $ids = get_posts( [ 'post_type' => 'easy_invoice_credit', 'post_status' => 'publish', 'numberposts' => -1, 'fields' => 'ids', 'suppress_filters' => false, 'meta_query' => [ [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query 'key' => CreditNote::META_CREDITED_INVOICE, 'value' => $invoice_ids, 'compare' => 'IN', ] ], ] ); return array_map( 'intval', (array) $ids ); } /** * A statement of account for a client. * * Every document that moved the balance, in date order, with a running * total — which is what a client asks for when they say "what do I owe * you?" and what a bookkeeper reconciles against. * * @param int $client_id Client ID. * @param string $from Start date (Y-m-d), or empty for everything. * @param string $to End date (Y-m-d), or empty for today. * @return array{ * rows:array>, * invoiced:float, paid:float, credited:float, balance:float, * opening:float, from:string, to:string * } */ public static function statement( int $client_id, string $from = '', string $to = '' ): array { $from_ts = '' !== $from ? strtotime( $from . ' 00:00:00' ) : 0; $to_ts = '' !== $to ? strtotime( $to . ' 23:59:59' ) : PHP_INT_MAX; $entries = []; // Warm the post and meta caches in two queries rather than one // round-trip per invoice while the ledger is assembled. $invoice_ids = self::invoiceIds( $client_id, false ); if ( $invoice_ids ) { _prime_post_caches( $invoice_ids, false, false ); update_meta_cache( 'post', $invoice_ids ); } foreach ( $invoice_ids as $invoice_id ) { $invoice = new \EasyInvoice\Models\Invoice( get_post( $invoice_id ) ); $entries[] = [ 'type' => 'invoice', 'label' => __( 'Invoice', 'easy-invoice' ), 'id' => (int) $invoice_id, 'number' => (string) $invoice->getNumber(), 'date' => (string) $invoice->getIssueDate(), 'ts' => strtotime( (string) $invoice->getIssueDate() ) ?: 0, // An invoice increases what is owed. 'charge' => round( (float) $invoice->getTotal(), 2 ), 'credit' => 0.0, ]; } $payment_ids = self::paymentIds( $client_id ); if ( $payment_ids ) { update_meta_cache( 'post', $payment_ids ); } foreach ( $payment_ids as $payment_id ) { // Only money actually received moves the balance. A pending or // failed payment is an intention, not a settlement. $status = strtolower( (string) get_post_meta( $payment_id, '_status', true ) ); if ( ! in_array( $status, [ 'completed', 'complete', 'paid', 'success', 'succeeded' ], true ) ) { continue; } $date = (string) get_post_meta( $payment_id, '_payment_date', true ); if ( '' === $date ) { $date = get_the_date( 'Y-m-d', $payment_id ) ?: ''; } // Dates are shown day-precise; a stored datetime is trimmed. if ( strtotime( $date ) ) { $date = gmdate( 'Y-m-d', strtotime( $date ) ); } // Describe the payment by how it was made ("bank transfer") — an // internal transaction id means nothing to the person reading. $method = str_replace( '_', ' ', (string) get_post_meta( $payment_id, '_payment_method', true ) ); $method = 'manual' === $method ? __( 'recorded', 'easy-invoice' ) : $method; $ref = (string) get_post_meta( $payment_id, '_transaction_id', true ); $ref = ( 0 === strpos( $ref, 'MANUAL-' ) || 0 === strpos( $ref, 'PAYMENT-' ) ) ? '' : $ref; $entries[] = [ 'type' => 'payment', 'label' => __( 'Payment', 'easy-invoice' ), 'id' => (int) $payment_id, 'number' => trim( ( '' !== $method ? ucfirst( $method ) : '' ) . ( '' !== $ref ? ' · ' . $ref : '' ) ), 'date' => $date, 'ts' => strtotime( $date ) ?: 0, 'charge' => 0.0, 'credit' => round( (float) get_post_meta( $payment_id, '_amount', true ), 2 ), ]; } foreach ( self::creditNoteIds( $client_id ) as $credit_id ) { $note = new \EasyInvoice\Models\Invoice( get_post( $credit_id ) ); $date = (string) $note->getIssueDate(); $entries[] = [ 'type' => 'credit_note', 'label' => __( 'Credit note', 'easy-invoice' ), 'id' => (int) $credit_id, 'number' => (string) $note->getNumber(), 'date' => $date, 'ts' => strtotime( $date ) ?: 0, 'charge' => 0.0, 'credit' => round( (float) $note->getTotal(), 2 ), ]; } usort( $entries, static function ( $a, $b ) { return $a['ts'] === $b['ts'] ? ( $a['id'] <=> $b['id'] ) : ( $a['ts'] <=> $b['ts'] ); } ); // Anything before the window is not listed but still counts — a // statement that starts from zero mid-relationship is misleading. $opening = 0.0; $rows = []; $running = 0.0; $invoiced = 0.0; $paid = 0.0; $credited = 0.0; foreach ( $entries as $entry ) { $delta = $entry['charge'] - $entry['credit']; if ( $entry['ts'] < $from_ts ) { $opening += $delta; continue; } if ( $entry['ts'] > $to_ts ) { continue; } if ( empty( $rows ) ) { $running = $opening; } $running += $delta; $entry['balance'] = round( $running, 2 ); $rows[] = $entry; $invoiced += $entry['charge']; if ( 'payment' === $entry['type'] ) { $paid += $entry['credit']; } else { $credited += $entry['credit']; } } return [ 'rows' => $rows, 'opening' => round( $opening, 2 ), 'invoiced' => round( $invoiced, 2 ), 'paid' => round( $paid, 2 ), 'credited' => round( $credited, 2 ), 'balance' => round( empty( $rows ) ? $opening : $running, 2 ), 'from' => $from, 'to' => $to, ]; } }