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 / InvoiceRetention.php

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

170 lines 6.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Stops issued invoices being destroyed.
4 *
5 * @package Easy_Invoice
6 * @subpackage Services
7 */
8
9 namespace EasyInvoice\Services;
10
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit;
13 }
14
15 /**
16 * Refuses permanent deletion of an invoice that has been issued.
17 *
18 * Why
19 * ---
20 * An issued invoice is not a record of your intentions, it is a record of a
21 * taxable supply, and every tax authority this plugin's users answer to — HMRC,
22 * the EU under the VAT Directive, the ATO, the IRS — requires it to be kept and
23 * to carry an unbroken sequential identifier. Deleting one destroys evidence and
24 * leaves a gap in the numbering that is precisely what an auditor looks for.
25 *
26 * The correct way to undo an invoice is to issue a credit note against it, which
27 * leaves the original standing and records the correction. The correct way to
28 * abandon one before it is sent is to delete the draft, which this still allows.
29 *
30 * What is still permitted
31 * -----------------------
32 * Everything except destroying the record:
33 *
34 * ▸ Deleting a draft — it was never issued, so there is nothing to preserve
35 * ▸ Trashing an issued invoice — reversible, and the existing flow marks it
36 * cancelled on the way, so the document survives and says what happened
37 * ▸ Emptying the trash of drafts
38 *
39 * Only "delete permanently" on an issued invoice is refused, and only for
40 * invoices. A quote is an offer, not a record of a supply, and is left alone.
41 *
42 * Two layers on purpose
43 * ---------------------
44 * `mayDelete()` lets a caller ask first and tell the user something useful.
45 * The `pre_delete_post` filter is the backstop, because deletion can be reached
46 * from the invoice screen, a bulk action, the REST route, the client cascade,
47 * wp-admin's own post list and WP-CLI — and guarding each of those individually
48 * is a list someone will add to without noticing.
49 *
50 * This does not fire during GDPR erasure: the eraser trashes rather than
51 * deletes, deliberately, because the right to erasure does not override a
52 * statutory retention period.
53 */
54 class InvoiceRetention {
55
56 /** Post types this protects. */
57 const PROTECTED_TYPES = [ 'easy_invoice' ];
58
59 /**
60 * Register the backstop.
61 *
62 * @return void
63 */
64 public static function init(): void {
65 add_filter( 'pre_delete_post', [ __CLASS__, 'blockDeletion' ], 10, 3 );
66 }
67
68 /**
69 * Has this invoice been issued?
70 *
71 * Anything that is not a draft has, as far as the customer is concerned,
72 * left the building — it has a number, and it may have been sent, paid or
73 * reported. "Draft" is the one state where nothing is owed to anyone.
74 *
75 * @param int $post_id Invoice ID.
76 * @return bool
77 */
78 public static function isIssued( int $post_id ): bool {
79 $post = get_post( $post_id );
80 if ( ! $post instanceof \WP_Post ) {
81 return false;
82 }
83
84 if ( in_array( $post->post_status, [ 'draft', 'auto-draft' ], true ) ) {
85 return false;
86 }
87
88 $status = (string) get_post_meta( $post_id, '_easy_invoice_status', true );
89
90 return 'draft' !== $status;
91 }
92
93 /**
94 * May this invoice be permanently deleted?
95 *
96 * @param int $post_id Invoice ID.
97 * @return true|\WP_Error True, or an error explaining what to do instead.
98 */
99 public static function mayDelete( int $post_id ) {
100 $post = get_post( $post_id );
101 if ( ! $post instanceof \WP_Post || ! in_array( $post->post_type, self::PROTECTED_TYPES, true ) ) {
102 return true;
103 }
104
105 if ( ! self::isIssued( $post_id ) ) {
106 return true;
107 }
108
109 /**
110 * Filter whether an issued invoice may be permanently deleted.
111 *
112 * The escape hatch for a site that has a genuine reason — a botched
113 * import, a staging copy, a legal instruction. It is deliberately not a
114 * setting: this should be a considered act by someone who can edit code,
115 * not a checkbox someone ticks to make a warning go away.
116 *
117 * @param bool $allowed Whether deletion is allowed.
118 * @param int $post_id Invoice ID.
119 */
120 if ( apply_filters( 'easy_invoice_allow_issued_invoice_deletion', false, $post_id ) ) {
121 return true;
122 }
123
124 $number = (string) get_post_meta( $post_id, '_easy_invoice_number', true );
125
126 return new \WP_Error(
127 'easy_invoice_issued_invoice_protected',
128 $number
129 ? sprintf(
130 /* translators: %s: invoice number. */
131 __( 'Invoice %s has been issued, so it cannot be deleted permanently — it is a tax record, and removing it breaks your numbering sequence. Move it to trash to take it out of your lists, or issue a credit note to cancel it out.', 'easy-invoice' ),
132 $number
133 )
134 : __( 'This invoice has been issued, so it cannot be deleted permanently — it is a tax record, and removing it breaks your numbering sequence. Move it to trash to take it out of your lists, or issue a credit note to cancel it out.', 'easy-invoice' ),
135 [ 'status' => 409 ]
136 );
137 }
138
139 /**
140 * Short-circuit `wp_delete_post()` for a protected invoice.
141 *
142 * @param \WP_Post|false|null $check Short-circuit value.
143 * @param \WP_Post $post Post being deleted.
144 * @param bool $force Whether this bypasses the trash.
145 * @return \WP_Post|false|null
146 */
147 public static function blockDeletion( $check, $post, $force ) {
148 // Without $force this is a move to trash, which is reversible and stays
149 // allowed. WordPress also passes $force = true for post types that have
150 // no trash support, so the type check below still matters.
151 if ( ! $force ) {
152 return $check;
153 }
154
155 if ( ! $post instanceof \WP_Post || ! in_array( $post->post_type, self::PROTECTED_TYPES, true ) ) {
156 return $check;
157 }
158
159 $may = self::mayDelete( (int) $post->ID );
160 if ( is_wp_error( $may ) ) {
161 // false tells WordPress the deletion failed, which is exactly what
162 // happened. Callers that used mayDelete() first will already have
163 // told the user why; the rest at least do not silently succeed.
164 return false;
165 }
166
167 return $check;
168 }
169 }
170