PluginProbe
Easy Invoice – Invoice Generator, PDF Quotes & Payments / 2.4.1
Easy Invoice – Invoice Generator, PDF Quotes & Payments v2.4.1
2.4.0 2.4.1 2.3.8 2.3.7 2.3.6 2.3.5 2.3.4 2.3.3 2.3.2 2.3.1 2.2.0 2.1.21 2.1.20 2.1.19 2.1.18 2.1.0 2.1.1 2.1.10 2.1.11 2.1.12 2.1.13 2.1.14 2.1.15 2.1.16 2.1.2 All 57 releases
easy-invoice / includes / Services / ClientLedger.php

ClientLedger.php in Easy Invoice – Invoice Generator, PDF Quotes & Payments 2.4.1, at includes/Services/ClientLedger.php

310 lines 11.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * What a client owes, has paid, and has been credited.
4 *
5 * @package Easy_Invoice
6 * @subpackage Services
7 */
8
9 namespace EasyInvoice\Services;
10
11 use EasyInvoice\Constants\PostTypes;
12
13 if ( ! defined( 'ABSPATH' ) ) {
14 exit;
15 }
16
17 /**
18 * The canonical answer to "which documents belong to this client".
19 *
20 * Why it exists
21 * -------------
22 * Payments do not record a client. They record `_invoice_id`, and the invoice
23 * records `_easy_invoice_client_id` — so a client's payments are only reachable
24 * through their invoices.
25 *
26 * Four places in the plugin instead queried `_easy_payment_client_id`, a meta
27 * key nothing has ever written. Every one of them silently matched nothing:
28 * deleting a client never removed their payments, and the confirmation dialog
29 * that said how many would go always said none. An earlier fix aligned two of
30 * those sites on the same key as the other two, which made them agree without
31 * making them right.
32 *
33 * Resolving the relationship in one place is the only way it stays true —
34 * especially now that a statement of account depends on the same question.
35 */
36 class ClientLedger {
37
38 /** Meta on an invoice: which client it belongs to. */
39 const META_INVOICE_CLIENT = '_easy_invoice_client_id';
40
41 /** Meta on a payment: which invoice it settles. */
42 const META_PAYMENT_INVOICE = '_invoice_id';
43
44 /**
45 * Every invoice belonging to a client.
46 *
47 * @param int $client_id Client (a WordPress user).
48 * @param bool $include_drafts Whether to include drafts.
49 * @return int[]
50 */
51 public static function invoiceIds( int $client_id, bool $include_drafts = true ): array {
52 if ( $client_id <= 0 ) {
53 return [];
54 }
55
56 $ids = get_posts(
57 [
58 'post_type' => PostTypes::EASY_INVOICE_POST_TYPE,
59 'post_status' => [ 'publish', 'draft', 'pending', 'private' ],
60 'numberposts' => -1,
61 'fields' => 'ids',
62 'no_found_rows' => true,
63 'suppress_filters' => true,
64 'meta_query' => [
65 [
66 'key' => self::META_INVOICE_CLIENT,
67 'value' => $client_id,
68 ],
69 ],
70 ]
71 );
72
73 if ( $include_drafts ) {
74 return $ids;
75 }
76
77 return array_values(
78 array_filter(
79 $ids,
80 static function ( $id ) {
81 return 'draft' !== (string) get_post_meta( $id, '_easy_invoice_status', true );
82 }
83 )
84 );
85 }
86
87 /**
88 * Every payment belonging to a client, reached through their invoices.
89 *
90 * @param int $client_id Client ID.
91 * @return int[]
92 */
93 public static function paymentIds( int $client_id ): array {
94 $invoice_ids = self::invoiceIds( $client_id );
95 if ( empty( $invoice_ids ) ) {
96 return [];
97 }
98
99 return get_posts(
100 [
101 'post_type' => PostTypes::EASY_INVOICE_PAYMENT_POST_TYPE,
102 'post_status' => [ 'publish', 'draft', 'pending', 'private' ],
103 'numberposts' => -1,
104 'fields' => 'ids',
105 'no_found_rows' => true,
106 'suppress_filters' => true,
107 'meta_query' => [
108 [
109 'key' => self::META_PAYMENT_INVOICE,
110 'value' => array_map( 'strval', $invoice_ids ),
111 'compare' => 'IN',
112 ],
113 ],
114 ]
115 );
116 }
117
118 /**
119 * Every credit note issued against a client's invoices.
120 *
121 * @param int $client_id Client ID.
122 * @return int[]
123 */
124 public static function creditNoteIds( int $client_id ): array {
125 $ids = [];
126
127 if ( ! class_exists( '\EasyInvoice\Services\CreditNote' ) ) {
128 return $ids;
129 }
130
131 // One query for all of the client's invoices rather than one per
132 // invoice (a client with thousands of invoices made thousands of
133 // round-trips here).
134 $invoice_ids = array_map( 'intval', self::invoiceIds( $client_id ) );
135 if ( ! $invoice_ids ) {
136 return $ids;
137 }
138 $ids = get_posts( [
139 'post_type' => 'easy_invoice_credit',
140 'post_status' => 'publish',
141 'numberposts' => -1,
142 'fields' => 'ids',
143 'suppress_filters' => false,
144 'meta_query' => [ [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
145 'key' => CreditNote::META_CREDITED_INVOICE,
146 'value' => $invoice_ids,
147 'compare' => 'IN',
148 ] ],
149 ] );
150
151 return array_map( 'intval', (array) $ids );
152 }
153
154 /**
155 * A statement of account for a client.
156 *
157 * Every document that moved the balance, in date order, with a running
158 * total — which is what a client asks for when they say "what do I owe
159 * you?" and what a bookkeeper reconciles against.
160 *
161 * @param int $client_id Client ID.
162 * @param string $from Start date (Y-m-d), or empty for everything.
163 * @param string $to End date (Y-m-d), or empty for today.
164 * @return array{
165 * rows:array<int,array<string,mixed>>,
166 * invoiced:float, paid:float, credited:float, balance:float,
167 * opening:float, from:string, to:string
168 * }
169 */
170 public static function statement( int $client_id, string $from = '', string $to = '' ): array {
171 $from_ts = '' !== $from ? strtotime( $from . ' 00:00:00' ) : 0;
172 $to_ts = '' !== $to ? strtotime( $to . ' 23:59:59' ) : PHP_INT_MAX;
173
174 $entries = [];
175
176 // Warm the post and meta caches in two queries rather than one
177 // round-trip per invoice while the ledger is assembled.
178 $invoice_ids = self::invoiceIds( $client_id, false );
179 if ( $invoice_ids ) {
180 _prime_post_caches( $invoice_ids, false, false );
181 update_meta_cache( 'post', $invoice_ids );
182 }
183 foreach ( $invoice_ids as $invoice_id ) {
184 $invoice = new \EasyInvoice\Models\Invoice( get_post( $invoice_id ) );
185 $entries[] = [
186 'type' => 'invoice',
187 'label' => __( 'Invoice', 'easy-invoice' ),
188 'id' => (int) $invoice_id,
189 'number' => (string) $invoice->getNumber(),
190 'date' => (string) $invoice->getIssueDate(),
191 'ts' => strtotime( (string) $invoice->getIssueDate() ) ?: 0,
192 // An invoice increases what is owed.
193 'charge' => round( (float) $invoice->getTotal(), 2 ),
194 'credit' => 0.0,
195 ];
196 }
197
198 $payment_ids = self::paymentIds( $client_id );
199 if ( $payment_ids ) {
200 update_meta_cache( 'post', $payment_ids );
201 }
202 foreach ( $payment_ids as $payment_id ) {
203 // Only money actually received moves the balance. A pending or
204 // failed payment is an intention, not a settlement.
205 $status = strtolower( (string) get_post_meta( $payment_id, '_status', true ) );
206 if ( ! in_array( $status, [ 'completed', 'complete', 'paid', 'success', 'succeeded' ], true ) ) {
207 continue;
208 }
209
210 $date = (string) get_post_meta( $payment_id, '_payment_date', true );
211 if ( '' === $date ) {
212 $date = get_the_date( 'Y-m-d', $payment_id ) ?: '';
213 }
214 // Dates are shown day-precise; a stored datetime is trimmed.
215 if ( strtotime( $date ) ) {
216 $date = gmdate( 'Y-m-d', strtotime( $date ) );
217 }
218
219 // Describe the payment by how it was made ("bank transfer") — an
220 // internal transaction id means nothing to the person reading.
221 $method = str_replace( '_', ' ', (string) get_post_meta( $payment_id, '_payment_method', true ) );
222 $method = 'manual' === $method ? __( 'recorded', 'easy-invoice' ) : $method;
223 $ref = (string) get_post_meta( $payment_id, '_transaction_id', true );
224 $ref = ( 0 === strpos( $ref, 'MANUAL-' ) || 0 === strpos( $ref, 'PAYMENT-' ) ) ? '' : $ref;
225
226 $entries[] = [
227 'type' => 'payment',
228 'label' => __( 'Payment', 'easy-invoice' ),
229 'id' => (int) $payment_id,
230 'number' => trim( ( '' !== $method ? ucfirst( $method ) : '' ) . ( '' !== $ref ? ' · ' . $ref : '' ) ),
231 'date' => $date,
232 'ts' => strtotime( $date ) ?: 0,
233 'charge' => 0.0,
234 'credit' => round( (float) get_post_meta( $payment_id, '_amount', true ), 2 ),
235 ];
236 }
237
238 foreach ( self::creditNoteIds( $client_id ) as $credit_id ) {
239 $note = new \EasyInvoice\Models\Invoice( get_post( $credit_id ) );
240 $date = (string) $note->getIssueDate();
241 $entries[] = [
242 'type' => 'credit_note',
243 'label' => __( 'Credit note', 'easy-invoice' ),
244 'id' => (int) $credit_id,
245 'number' => (string) $note->getNumber(),
246 'date' => $date,
247 'ts' => strtotime( $date ) ?: 0,
248 'charge' => 0.0,
249 'credit' => round( (float) $note->getTotal(), 2 ),
250 ];
251 }
252
253 usort(
254 $entries,
255 static function ( $a, $b ) {
256 return $a['ts'] === $b['ts'] ? ( $a['id'] <=> $b['id'] ) : ( $a['ts'] <=> $b['ts'] );
257 }
258 );
259
260 // Anything before the window is not listed but still counts — a
261 // statement that starts from zero mid-relationship is misleading.
262 $opening = 0.0;
263 $rows = [];
264 $running = 0.0;
265 $invoiced = 0.0;
266 $paid = 0.0;
267 $credited = 0.0;
268
269 foreach ( $entries as $entry ) {
270 $delta = $entry['charge'] - $entry['credit'];
271
272 if ( $entry['ts'] < $from_ts ) {
273 $opening += $delta;
274 continue;
275 }
276
277 if ( $entry['ts'] > $to_ts ) {
278 continue;
279 }
280
281 if ( empty( $rows ) ) {
282 $running = $opening;
283 }
284
285 $running += $delta;
286
287 $entry['balance'] = round( $running, 2 );
288 $rows[] = $entry;
289
290 $invoiced += $entry['charge'];
291 if ( 'payment' === $entry['type'] ) {
292 $paid += $entry['credit'];
293 } else {
294 $credited += $entry['credit'];
295 }
296 }
297
298 return [
299 'rows' => $rows,
300 'opening' => round( $opening, 2 ),
301 'invoiced' => round( $invoiced, 2 ),
302 'paid' => round( $paid, 2 ),
303 'credited' => round( $credited, 2 ),
304 'balance' => round( empty( $rows ) ? $opening : $running, 2 ),
305 'from' => $from,
306 'to' => $to,
307 ];
308 }
309 }
310