# easy-invoice/2.4.3/includes/Services/ClientLedger.php

Easy Invoice – Invoice Generator, PDF Quotes &amp; Payments, version 2.4.3. 310 lines.

- Page: https://pluginprobe.com/plugins/easy-invoice/2.4.3/code/includes/Services/ClientLedger.php
- Raw: https://pluginprobe.com/plugins/easy-invoice/2.4.3/raw/includes/Services/ClientLedger.php
- Modified: 2026-09-15T12:31:20+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/easy-invoice/2.4.3/code/includes/Services/ClientLedger.php#L10-L20`.

```php
<?php
/**
 * What a client owes, has paid, and has been credited.
 *
 * @package Easy_Invoice
 * @subpackage Services
 */

namespace EasyInvoice\Services;

use EasyInvoice\Constants\PostTypes;

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

/**
 * The canonical answer to "which documents belong to this client".
 *
 * Why it exists
 * -------------
 * Payments do not record a client. They record `_invoice_id`, and the invoice
 * records `_easy_invoice_client_id` — so a client's payments are only reachable
 * through their invoices.
 *
 * Four places in the plugin instead queried `_easy_payment_client_id`, a meta
 * key nothing has ever written. Every one of them silently matched nothing:
 * deleting a client never removed their payments, and the confirmation dialog
 * that said how many would go always said none. An earlier fix aligned two of
 * those sites on the same key as the other two, which made them agree without
 * making them right.
 *
 * Resolving the relationship in one place is the only way it stays true —
 * especially now that a statement of account depends on the same question.
 */
class ClientLedger {

    /** Meta on an invoice: which client it belongs to. */
    const META_INVOICE_CLIENT = '_easy_invoice_client_id';

    /** Meta on a payment: which invoice it settles. */
    const META_PAYMENT_INVOICE = '_invoice_id';

    /**
     * Every invoice belonging to a client.
     *
     * @param int  $client_id       Client (a WordPress user).
     * @param bool $include_drafts  Whether to include drafts.
     * @return int[]
     */
    public static function invoiceIds( int $client_id, bool $include_drafts = true ): array {
        if ( $client_id <= 0 ) {
            return [];
        }

        $ids = get_posts(
            [
                'post_type'        => 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<int,array<string,mixed>>,
     *     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,
        ];
    }
}

```
