PluginProbe
Easy Invoice – Invoice Generator, PDF Quotes & Payments / 2.4.3
Easy Invoice – Invoice Generator, PDF Quotes & Payments v2.4.3
2.4.3 2.4.4 2.4.2 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 All 60 releases
easy-invoice / includes / Services / OfflinePayments.php

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

593 lines 20.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Offline payment methods: bank transfer, cheque, cash and any method the
4 * merchant adds (ACH, wire, mobile money, …).
5 *
6 * One place knows which methods exist, what each one asks the client for
7 * (a reference, a receipt), where a receipt is kept and who may open it.
8 * The gateway objects (OfflineGateway) are thin wrappers over this.
9 *
10 * @package EasyInvoice
11 */
12
13 namespace EasyInvoice\Services;
14
15 use EasyInvoice\Helpers\UploadGuard;
16
17 if ( ! defined( 'ABSPATH' ) ) {
18 exit;
19 }
20
21 class OfflinePayments {
22
23 /** Option holding the merchant's own methods: [ [ 'id' => 'ach', 'title' => 'ACH' ], … ]. */
24 const OPTION_CUSTOM = 'easy_invoice_offline_methods';
25
26 /** Payment meta written by an offline submission. */
27 const META_REFERENCE = '_payment_reference';
28 const META_PROOF_FILE = '_payment_proof_file';
29 const META_PROOF_NAME = '_payment_proof_name';
30 const META_PROOF_URL = '_payment_proof';
31
32 /** admin-post action that streams a receipt to a signed-in member of staff. */
33 const PROOF_ACTION = 'easy_invoice_payment_proof';
34
35 /** Folder under uploads/ that receipts are written to. */
36 const PROOF_SUBDIR = 'easy-invoice/payment-proofs';
37
38 /**
39 * The three methods every install has. Ids match the ones Pro's gateways
40 * used, so settings, filters and per-invoice method choices carry over.
41 *
42 * @return array<string, array{id:string,title:string,description:string,icon:string}>
43 */
44 public static function builtin(): array {
45 return [
46 'bank_transfer' => [
47 'id' => 'bank_transfer',
48 'title' => __( 'Bank Transfer', 'easy-invoice' ),
49 'description' => __( 'Pay from your bank account using the details shown.', 'easy-invoice' ),
50 'icon' => 'bank',
51 ],
52 'cheque' => [
53 'id' => 'cheque',
54 'title' => __( 'Cheque', 'easy-invoice' ),
55 'description' => __( 'Post a cheque to the address shown.', 'easy-invoice' ),
56 'icon' => 'cheque',
57 ],
58 'cash' => [
59 'id' => 'cash',
60 'title' => __( 'Cash', 'easy-invoice' ),
61 'description' => __( 'Pay in cash in person.', 'easy-invoice' ),
62 'icon' => 'cash',
63 ],
64 ];
65 }
66
67 /**
68 * Methods the merchant added under Settings → Payment Methods.
69 *
70 * @return array<string, array{id:string,title:string,description:string,icon:string}>
71 */
72 public static function custom(): array {
73 $stored = get_option( self::OPTION_CUSTOM, [] );
74 $out = [];
75 if ( ! is_array( $stored ) ) {
76 return $out;
77 }
78 foreach ( $stored as $row ) {
79 if ( ! is_array( $row ) || empty( $row['id'] ) ) {
80 continue;
81 }
82 $id = self::sanitizeId( (string) $row['id'] );
83 if ( '' === $id || isset( self::builtin()[ $id ] ) ) {
84 continue;
85 }
86 $out[ $id ] = [
87 'id' => $id,
88 'title' => sanitize_text_field( (string) ( $row['title'] ?? $id ) ),
89 'description' => sanitize_text_field( (string) ( $row['description'] ?? '' ) ),
90 'icon' => 'bank',
91 ];
92 }
93 return $out;
94 }
95
96 /**
97 * Every offline method, built-in first.
98 *
99 * @return array<string, array>
100 */
101 public static function methods(): array {
102 /**
103 * Filter the offline payment methods the plugin offers.
104 *
105 * @param array $methods id => [ id, title, description, icon ].
106 */
107 return (array) apply_filters( 'easy_invoice_offline_methods', self::builtin() + self::custom() );
108 }
109
110 /**
111 * Ids of every offline method — what Partial Payments, reports and the
112 * legacy 'manual' alias mean by "offline".
113 *
114 * @return string[]
115 */
116 public static function ids(): array {
117 return array_keys( self::methods() );
118 }
119
120 /**
121 * Is this gateway id one of the offline methods (or the legacy alias)?
122 *
123 * @param string $id Gateway id.
124 */
125 public static function isOffline( string $id ): bool {
126 return 'manual' === $id || isset( self::methods()[ $id ] );
127 }
128
129 /**
130 * Add a method. Returns its id, or a WP_Error when the name is unusable.
131 *
132 * @param string $title Display name, e.g. "ACH".
133 * @return string|\WP_Error
134 */
135 public static function addCustom( string $title ) {
136 $title = trim( sanitize_text_field( $title ) );
137 if ( '' === $title ) {
138 return new \WP_Error( 'empty', __( 'Give the payment method a name.', 'easy-invoice' ) );
139 }
140 $base = self::sanitizeId( sanitize_title( $title ) );
141 if ( '' === $base ) {
142 $base = 'method';
143 }
144 // Never collide with a gateway that exists or may exist (Pro registers
145 // card gateways after us and would silently replace a custom method
146 // with the same id).
147 $reserved = [ 'manual', 'paypal', 'stripe', 'square', 'authorizenet', 'authorize_net', 'mollie', 'paystack', 'moneris', 'check', 'card', 'offline' ];
148 if ( class_exists( '\\EasyInvoice\\EasyInvoice' ) ) {
149 try {
150 $reserved = array_merge( $reserved, array_keys( \EasyInvoice\EasyInvoice::getInstance()->getGatewayManager()->getGateways() ) );
151 } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement
152 // Manager not built yet: the static list still applies.
153 }
154 }
155 $existing = self::methods();
156 $id = $base;
157 $n = 2;
158 while ( isset( $existing[ $id ] ) || in_array( $id, $reserved, true ) ) {
159 $id = $base . '_' . $n;
160 ++$n;
161 }
162 $stored = get_option( self::OPTION_CUSTOM, [] );
163 $stored = is_array( $stored ) ? $stored : [];
164 $stored[] = [
165 'id' => $id,
166 'title' => $title,
167 ];
168 update_option( self::OPTION_CUSTOM, array_values( $stored ), false );
169
170 // A new method starts enabled: the merchant added it to use it.
171 $enabled = get_option( 'easy_invoice_payment_methods', [] );
172 $enabled = is_array( $enabled ) ? $enabled : [];
173 if ( ! in_array( $id, $enabled, true ) ) {
174 $enabled[] = $id;
175 update_option( 'easy_invoice_payment_methods', $enabled );
176 }
177 return $id;
178 }
179
180 /**
181 * Remove a merchant-added method. Payments already recorded against it
182 * keep their method id; only the option to offer it goes.
183 *
184 * @param string $id Method id.
185 */
186 public static function removeCustom( string $id ): bool {
187 $id = self::sanitizeId( $id );
188 if ( '' === $id || isset( self::builtin()[ $id ] ) ) {
189 return false;
190 }
191 $stored = get_option( self::OPTION_CUSTOM, [] );
192 $stored = is_array( $stored ) ? $stored : [];
193 $kept = array_values(
194 array_filter(
195 $stored,
196 static function ( $row ) use ( $id ) {
197 return ! is_array( $row ) || self::sanitizeId( (string) ( $row['id'] ?? '' ) ) !== $id;
198 }
199 )
200 );
201 if ( count( $kept ) === count( $stored ) ) {
202 return false;
203 }
204 update_option( self::OPTION_CUSTOM, $kept, false );
205
206 $enabled = get_option( 'easy_invoice_payment_methods', [] );
207 if ( is_array( $enabled ) ) {
208 update_option( 'easy_invoice_payment_methods', array_values( array_diff( $enabled, [ $id ] ) ) );
209 }
210 foreach ( [ 'instructions', 'reference', 'proof' ] as $field ) {
211 delete_option( self::optionKey( $id, $field ) );
212 }
213 delete_option( 'easy_invoice_gateway_display_name_' . $id );
214 return true;
215 }
216
217 /**
218 * Option key for a method's own setting.
219 *
220 * @param string $id Method id.
221 * @param string $field instructions | reference | proof.
222 */
223 public static function optionKey( string $id, string $field ): string {
224 return 'easy_invoice_offline_' . $id . '_' . $field;
225 }
226
227 /**
228 * What the method shows and asks for.
229 *
230 * @param string $id Method id.
231 * @return array{instructions:string,reference:bool,proof:string,proof_required:bool}
232 */
233 public static function settings( string $id ): array {
234 $instructions = (string) get_option( self::optionKey( $id, 'instructions' ), '' );
235 if ( '' === trim( $instructions ) ) {
236 // Text saved by earlier versions (and by Pro's own gateways) under their keys.
237 $legacy = [
238 'bank_transfer' => 'easy_invoice_bank_details',
239 'cash' => 'easy_invoice_cash_instructions',
240 ];
241 if ( isset( $legacy[ $id ] ) ) {
242 $instructions = (string) get_option( $legacy[ $id ], '' );
243 }
244 }
245 $proof = (string) get_option( self::optionKey( $id, 'proof' ), 'cash' === $id ? 'off' : 'optional' );
246 if ( ! in_array( $proof, [ 'off', 'optional', 'required' ], true ) ) {
247 $proof = 'optional';
248 }
249 $reference_default = 'cash' === $id ? 'no' : 'yes';
250 $reference = (string) get_option( self::optionKey( $id, 'reference' ), $reference_default );
251
252 return [
253 'instructions' => $instructions,
254 'reference' => in_array( $reference, [ 'yes', '1', 'on', 'true' ], true ),
255 'proof' => $proof,
256 'proof_required' => 'required' === $proof,
257 ];
258 }
259
260 /**
261 * The structured details a built-in method prints above its instructions.
262 * Empty rows are skipped, so a merchant fills in what applies.
263 *
264 * @param string $id Method id.
265 * @return array<string,string> label => value
266 */
267 public static function details( string $id ): array {
268 $rows = [];
269 if ( 'bank_transfer' === $id ) {
270 $map = [
271 'easy_invoice_manual_bank_name' => __( 'Bank', 'easy-invoice' ),
272 'easy_invoice_manual_account_name' => __( 'Account name', 'easy-invoice' ),
273 'easy_invoice_manual_account_number' => __( 'Account number', 'easy-invoice' ),
274 'easy_invoice_manual_routing_number' => __( 'Routing / sort code', 'easy-invoice' ),
275 'easy_invoice_manual_swift_code' => __( 'SWIFT / BIC', 'easy-invoice' ),
276 'easy_invoice_manual_iban' => __( 'IBAN', 'easy-invoice' ),
277 'easy_invoice_manual_bank_address' => __( 'Bank address', 'easy-invoice' ),
278 ];
279 } elseif ( 'cheque' === $id ) {
280 $map = [
281 'easy_invoice_cheque_payable_to' => __( 'Payable to', 'easy-invoice' ),
282 'easy_invoice_cheque_mailing_address' => __( 'Mail to', 'easy-invoice' ),
283 ];
284 } else {
285 $map = [];
286 }
287 foreach ( $map as $option => $label ) {
288 $value = trim( (string) get_option( $option, '' ) );
289 if ( '' !== $value ) {
290 $rows[ $label ] = $value;
291 }
292 }
293 return $rows;
294 }
295
296 /* ------------------------------------------------------------------ */
297 /* Receipts */
298 /* ------------------------------------------------------------------ */
299
300 /**
301 * Absolute path of the receipts folder, created and guarded.
302 */
303 public static function proofDir(): string {
304 $upload = wp_upload_dir();
305 $dir = trailingslashit( $upload['basedir'] ) . self::PROOF_SUBDIR;
306 UploadGuard::protectDirectory( $dir );
307 self::denyDirectAccess( $dir );
308 return $dir;
309 }
310
311 /**
312 * Receipts carry bank details and are only ever meant for staff, so on
313 * Apache the folder refuses direct requests outright (UploadGuard only
314 * switches listings off). Nginx ignores .htaccess; there the random,
315 * unlisted file names are the protection, and the admin link goes through
316 * PHP either way.
317 *
318 * @param string $dir Folder.
319 */
320 private static function denyDirectAccess( string $dir ): void {
321 $htaccess = trailingslashit( $dir ) . '.htaccess';
322 $rule = "# Easy Invoice: receipts are served through the plugin, never directly.\n"
323 . "<IfModule mod_authz_core.c>\n Require all denied\n</IfModule>\n"
324 . "<IfModule !mod_authz_core.c>\n Order deny,allow\n Deny from all\n</IfModule>\n"
325 . "Options -Indexes\n";
326 $current = file_exists( $htaccess ) ? (string) file_get_contents( $htaccess ) : ''; // phpcs:ignore WordPress.WP.AlternativeFunctions
327 if ( false === strpos( $current, 'Require all denied' ) ) {
328 @file_put_contents( $htaccess, $rule ); // phpcs:ignore WordPress.WP.AlternativeFunctions,WordPress.PHP.NoSilencedErrors
329 }
330 }
331
332 /**
333 * Validate and store an uploaded receipt.
334 *
335 * @param array $file One entry of $_FILES.
336 * @return array{file:string,name:string}|\WP_Error Path relative to uploads/ and the client's file name.
337 */
338 public static function storeProof( array $file ) {
339 if ( empty( $file['tmp_name'] ) || ! is_uploaded_file( $file['tmp_name'] ) ) {
340 return new \WP_Error( 'upload', __( 'The receipt could not be read. Please try again.', 'easy-invoice' ) );
341 }
342 if ( ! empty( $file['error'] ) ) {
343 return new \WP_Error( 'upload', __( 'The receipt could not be uploaded. Please try again.', 'easy-invoice' ) );
344 }
345 /**
346 * Filter the largest receipt accepted, in bytes.
347 *
348 * @param int $bytes Default 5 MB.
349 */
350 $max = (int) apply_filters( 'easy_invoice_payment_proof_max_bytes', 5 * 1024 * 1024 );
351 if ( (int) $file['size'] > $max ) {
352 /* translators: %s: size such as "5 MB". */
353 return new \WP_Error( 'size', sprintf( __( 'The receipt must be smaller than %s.', 'easy-invoice' ), size_format( $max ) ) );
354 }
355 $allowed = [
356 'jpg|jpeg|jpe' => 'image/jpeg',
357 'png' => 'image/png',
358 'gif' => 'image/gif',
359 'webp' => 'image/webp',
360 'pdf' => 'application/pdf',
361 ];
362 $checked = wp_check_filetype_and_ext( $file['tmp_name'], (string) $file['name'], $allowed );
363 if ( empty( $checked['ext'] ) || empty( $checked['type'] ) || ! in_array( $checked['type'], $allowed, true ) ) {
364 return new \WP_Error( 'type', __( 'Receipts can be a JPG, PNG, GIF, WebP or PDF file.', 'easy-invoice' ) );
365 }
366 $dir = self::proofDir();
367 $name = 'receipt-' . wp_generate_password( 32, false, false ) . '.' . $checked['ext'];
368 $dest = trailingslashit( $dir ) . $name;
369 if ( ! is_dir( $dir ) || ! @move_uploaded_file( $file['tmp_name'], $dest ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors
370 return new \WP_Error( 'move', __( 'The receipt could not be saved. Please try again or contact us.', 'easy-invoice' ) );
371 }
372 @chmod( $dest, 0640 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions
373 return [
374 'file' => self::PROOF_SUBDIR . '/' . $name,
375 'name' => sanitize_file_name( (string) $file['name'] ),
376 ];
377 }
378
379 /**
380 * Absolute path of the receipt attached to a payment, if any.
381 *
382 * Understands the three ways earlier versions recorded it: a path
383 * relative to uploads/ (2.4.2+), a full URL on the payment (2.4.0/2.4.1)
384 * and a full URL on the invoice (Pro's bank/cheque gateways).
385 *
386 * @param int $payment_id Payment record.
387 */
388 public static function proofPath( int $payment_id ): string {
389 $upload = wp_upload_dir();
390 $base = trailingslashit( $upload['basedir'] );
391 $rel = (string) get_post_meta( $payment_id, self::META_PROOF_FILE, true );
392 if ( '' !== $rel ) {
393 $abs = $base . ltrim( $rel, '/' );
394 return file_exists( $abs ) ? $abs : '';
395 }
396 $url = (string) get_post_meta( $payment_id, self::META_PROOF_URL, true );
397 if ( '' === $url ) {
398 $invoice_id = (int) get_post_meta( $payment_id, '_invoice_id', true );
399 foreach ( [ '_bank_payment_proof', '_cheque_image', '_manual_payment_proof' ] as $legacy ) {
400 $url = (string) get_post_meta( $invoice_id, $legacy, true );
401 if ( '' !== $url ) {
402 break;
403 }
404 }
405 }
406 if ( '' === $url ) {
407 return '';
408 }
409 // Only ever serve out of our own receipts folder, whatever the stored URL says.
410 $name = basename( (string) wp_parse_url( $url, PHP_URL_PATH ) );
411 if ( '' === $name || $name !== sanitize_file_name( $name ) ) {
412 return '';
413 }
414 foreach ( [ self::PROOF_SUBDIR, 'easy-invoice-proofs', 'easy-invoice/payment-proofs' ] as $folder ) {
415 $abs = $base . $folder . '/' . $name;
416 if ( file_exists( $abs ) ) {
417 return $abs;
418 }
419 }
420 return '';
421 }
422
423 /**
424 * Does the payment carry a receipt staff can open?
425 *
426 * @param int $payment_id Payment record.
427 */
428 public static function hasProof( int $payment_id ): bool {
429 return '' !== self::proofPath( $payment_id );
430 }
431
432 /**
433 * Signed admin link that streams the receipt.
434 *
435 * @param int $payment_id Payment record.
436 */
437 public static function proofUrl( int $payment_id ): string {
438 // Signed per payment rather than nonced: the link is emailed to staff
439 // and must still open days later, for whoever is signed in — the
440 // capability check is the authorisation, the signature just keeps the
441 // endpoint from being enumerated. Not HTML-escaped; escape at output.
442 return add_query_arg(
443 [
444 'action' => self::PROOF_ACTION,
445 'payment' => $payment_id,
446 'sig' => self::proofSignature( $payment_id ),
447 ],
448 admin_url( 'admin-post.php' )
449 );
450 }
451
452 /**
453 * Stable signature for a payment's receipt link.
454 *
455 * @param int $payment_id Payment record.
456 */
457 private static function proofSignature( int $payment_id ): string {
458 return substr( wp_hash( 'easy_invoice_payment_proof|' . $payment_id, 'nonce' ), 0, 20 );
459 }
460
461 /**
462 * admin-post handler: stream the receipt to someone who may see invoices.
463 */
464 public static function serveProof(): void {
465 // phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only download; authorised by capability, addressed by signature.
466 $payment_id = isset( $_GET['payment'] ) ? absint( $_GET['payment'] ) : 0;
467 $sig = isset( $_GET['sig'] ) ? sanitize_text_field( wp_unslash( $_GET['sig'] ) ) : '';
468 // phpcs:enable
469 if ( ! is_user_logged_in() ) {
470 auth_redirect();
471 }
472 if ( ! easy_invoice_user_can( 'ei_view_invoices' ) ) {
473 wp_die( esc_html__( 'You do not have permission to view payment receipts.', 'easy-invoice' ), 403 );
474 }
475 if ( ! $payment_id || '' === $sig || ! hash_equals( self::proofSignature( $payment_id ), $sig ) ) {
476 wp_die( esc_html__( 'That receipt link is not valid. Open the payment from the Payments screen.', 'easy-invoice' ), 403 );
477 }
478 $post = get_post( $payment_id );
479 if ( ! $post || 'easy_invoice_payment' !== $post->post_type ) {
480 wp_die( esc_html__( 'Payment not found.', 'easy-invoice' ), 404 );
481 }
482 $path = self::proofPath( $payment_id );
483 if ( '' === $path ) {
484 wp_die( esc_html__( 'No receipt is attached to this payment.', 'easy-invoice' ), 404 );
485 }
486 $type = wp_check_filetype( $path );
487 $mime = $type['type'] ?: 'application/octet-stream';
488 $name = (string) get_post_meta( $payment_id, self::META_PROOF_NAME, true );
489 if ( '' === $name ) {
490 $name = 'receipt-' . $payment_id . '.' . ( $type['ext'] ?: 'bin' );
491 }
492 nocache_headers();
493 header( 'Content-Type: ' . $mime );
494 header( 'Content-Length: ' . (string) filesize( $path ) );
495 header( 'Content-Disposition: inline; filename="' . rawurlencode( $name ) . '"' );
496 header( 'X-Content-Type-Options: nosniff' );
497 readfile( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions
498 exit;
499 }
500
501 /**
502 * before_delete_post: a receipt goes with its payment record.
503 *
504 * @param int $post_id Post being deleted.
505 * @param \WP_Post|null $post The post.
506 */
507 public static function deleteProofWithPayment( $post_id, $post = null ): void {
508 $post = $post ?: get_post( $post_id );
509 if ( ! $post || 'easy_invoice_payment' !== $post->post_type ) {
510 return;
511 }
512 $path = self::proofPath( (int) $post_id );
513 if ( '' !== $path && file_exists( $path ) ) {
514 wp_delete_file( $path );
515 }
516 }
517
518 /* ------------------------------------------------------------------ */
519 /* Pending submissions */
520 /* ------------------------------------------------------------------ */
521
522 /** Statuses a payment record can carry while it waits for staff. */
523 public static function pendingStatuses(): array {
524 return [ 'pending', 'pending-bank', 'pending-cheque', 'pending_verification' ];
525 }
526
527 /**
528 * Offline payments a client has told us about that staff have not yet
529 * confirmed or rejected, newest first.
530 *
531 * @param int $invoice_id Invoice.
532 * @return \WP_Post[]
533 */
534 public static function pendingForInvoice( int $invoice_id ): array {
535 if ( $invoice_id <= 0 ) {
536 return [];
537 }
538 return get_posts(
539 [
540 'post_type' => 'easy_invoice_payment',
541 'post_status' => 'any',
542 'posts_per_page' => 20,
543 'orderby' => 'date',
544 'order' => 'DESC',
545 'meta_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
546 [
547 'key' => '_invoice_id',
548 'value' => $invoice_id,
549 ],
550 [
551 'key' => '_status',
552 'value' => self::pendingStatuses(),
553 'compare' => 'IN',
554 ],
555 ],
556 ]
557 );
558 }
559
560 /**
561 * Human name for a method id, honouring the display name from Settings.
562 *
563 * @param string $id Method or gateway id.
564 */
565 public static function label( string $id ): string {
566 if ( function_exists( 'easy_invoice_get_payment_method_label' ) ) {
567 return (string) easy_invoice_get_payment_method_label( $id );
568 }
569 $custom = (string) get_option( 'easy_invoice_gateway_display_name_' . $id, '' );
570 if ( '' !== $custom ) {
571 return $custom;
572 }
573 $methods = self::methods();
574 if ( isset( $methods[ $id ] ) ) {
575 return (string) $methods[ $id ]['title'];
576 }
577 if ( 'manual' === $id ) {
578 return __( 'Manual payment', 'easy-invoice' );
579 }
580 return ucwords( str_replace( [ '_', '-' ], ' ', $id ) );
581 }
582
583 /**
584 * Lower-case id: letters, digits and underscores.
585 *
586 * @param string $id Raw id.
587 */
588 public static function sanitizeId( string $id ): string {
589 $id = strtolower( preg_replace( '/[^a-z0-9_]+/i', '_', $id ) );
590 return trim( $id, '_' );
591 }
592 }
593