'ach', 'title' => 'ACH' ], … ]. */ const OPTION_CUSTOM = 'easy_invoice_offline_methods'; /** Payment meta written by an offline submission. */ const META_REFERENCE = '_payment_reference'; const META_PROOF_FILE = '_payment_proof_file'; const META_PROOF_NAME = '_payment_proof_name'; const META_PROOF_URL = '_payment_proof'; /** admin-post action that streams a receipt to a signed-in member of staff. */ const PROOF_ACTION = 'easy_invoice_payment_proof'; /** Folder under uploads/ that receipts are written to. */ const PROOF_SUBDIR = 'easy-invoice/payment-proofs'; /** * The three methods every install has. Ids match the ones Pro's gateways * used, so settings, filters and per-invoice method choices carry over. * * @return array */ public static function builtin(): array { return [ 'bank_transfer' => [ 'id' => 'bank_transfer', 'title' => __( 'Bank Transfer', 'easy-invoice' ), 'description' => __( 'Pay from your bank account using the details shown.', 'easy-invoice' ), 'icon' => 'bank', ], 'cheque' => [ 'id' => 'cheque', 'title' => __( 'Cheque', 'easy-invoice' ), 'description' => __( 'Post a cheque to the address shown.', 'easy-invoice' ), 'icon' => 'cheque', ], 'cash' => [ 'id' => 'cash', 'title' => __( 'Cash', 'easy-invoice' ), 'description' => __( 'Pay in cash in person.', 'easy-invoice' ), 'icon' => 'cash', ], ]; } /** * Methods the merchant added under Settings → Payment Methods. * * @return array */ public static function custom(): array { $stored = get_option( self::OPTION_CUSTOM, [] ); $out = []; if ( ! is_array( $stored ) ) { return $out; } foreach ( $stored as $row ) { if ( ! is_array( $row ) || empty( $row['id'] ) ) { continue; } $id = self::sanitizeId( (string) $row['id'] ); if ( '' === $id || isset( self::builtin()[ $id ] ) ) { continue; } $out[ $id ] = [ 'id' => $id, 'title' => sanitize_text_field( (string) ( $row['title'] ?? $id ) ), 'description' => sanitize_text_field( (string) ( $row['description'] ?? '' ) ), 'icon' => 'bank', ]; } return $out; } /** * Every offline method, built-in first. * * @return array */ public static function methods(): array { /** * Filter the offline payment methods the plugin offers. * * @param array $methods id => [ id, title, description, icon ]. */ return (array) apply_filters( 'easy_invoice_offline_methods', self::builtin() + self::custom() ); } /** * Ids of every offline method — what Partial Payments, reports and the * legacy 'manual' alias mean by "offline". * * @return string[] */ public static function ids(): array { return array_keys( self::methods() ); } /** * Is this gateway id one of the offline methods (or the legacy alias)? * * @param string $id Gateway id. */ public static function isOffline( string $id ): bool { return 'manual' === $id || isset( self::methods()[ $id ] ); } /** * Add a method. Returns its id, or a WP_Error when the name is unusable. * * @param string $title Display name, e.g. "ACH". * @return string|\WP_Error */ public static function addCustom( string $title ) { $title = trim( sanitize_text_field( $title ) ); if ( '' === $title ) { return new \WP_Error( 'empty', __( 'Give the payment method a name.', 'easy-invoice' ) ); } $base = self::sanitizeId( sanitize_title( $title ) ); if ( '' === $base ) { $base = 'method'; } // Never collide with a gateway that exists or may exist (Pro registers // card gateways after us and would silently replace a custom method // with the same id). $reserved = [ 'manual', 'paypal', 'stripe', 'square', 'authorizenet', 'authorize_net', 'mollie', 'paystack', 'moneris', 'check', 'card', 'offline' ]; if ( class_exists( '\\EasyInvoice\\EasyInvoice' ) ) { try { $reserved = array_merge( $reserved, array_keys( \EasyInvoice\EasyInvoice::getInstance()->getGatewayManager()->getGateways() ) ); } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement // Manager not built yet: the static list still applies. } } $existing = self::methods(); $id = $base; $n = 2; while ( isset( $existing[ $id ] ) || in_array( $id, $reserved, true ) ) { $id = $base . '_' . $n; ++$n; } $stored = get_option( self::OPTION_CUSTOM, [] ); $stored = is_array( $stored ) ? $stored : []; $stored[] = [ 'id' => $id, 'title' => $title, ]; update_option( self::OPTION_CUSTOM, array_values( $stored ), false ); // A new method starts enabled: the merchant added it to use it. $enabled = get_option( 'easy_invoice_payment_methods', [] ); $enabled = is_array( $enabled ) ? $enabled : []; if ( ! in_array( $id, $enabled, true ) ) { $enabled[] = $id; update_option( 'easy_invoice_payment_methods', $enabled ); } return $id; } /** * Remove a merchant-added method. Payments already recorded against it * keep their method id; only the option to offer it goes. * * @param string $id Method id. */ public static function removeCustom( string $id ): bool { $id = self::sanitizeId( $id ); if ( '' === $id || isset( self::builtin()[ $id ] ) ) { return false; } $stored = get_option( self::OPTION_CUSTOM, [] ); $stored = is_array( $stored ) ? $stored : []; $kept = array_values( array_filter( $stored, static function ( $row ) use ( $id ) { return ! is_array( $row ) || self::sanitizeId( (string) ( $row['id'] ?? '' ) ) !== $id; } ) ); if ( count( $kept ) === count( $stored ) ) { return false; } update_option( self::OPTION_CUSTOM, $kept, false ); $enabled = get_option( 'easy_invoice_payment_methods', [] ); if ( is_array( $enabled ) ) { update_option( 'easy_invoice_payment_methods', array_values( array_diff( $enabled, [ $id ] ) ) ); } foreach ( [ 'instructions', 'reference', 'proof' ] as $field ) { delete_option( self::optionKey( $id, $field ) ); } delete_option( 'easy_invoice_gateway_display_name_' . $id ); return true; } /** * Option key for a method's own setting. * * @param string $id Method id. * @param string $field instructions | reference | proof. */ public static function optionKey( string $id, string $field ): string { return 'easy_invoice_offline_' . $id . '_' . $field; } /** * What the method shows and asks for. * * @param string $id Method id. * @return array{instructions:string,reference:bool,proof:string,proof_required:bool} */ public static function settings( string $id ): array { $instructions = (string) get_option( self::optionKey( $id, 'instructions' ), '' ); if ( '' === trim( $instructions ) ) { // Text saved by earlier versions (and by Pro's own gateways) under their keys. $legacy = [ 'bank_transfer' => 'easy_invoice_bank_details', 'cash' => 'easy_invoice_cash_instructions', ]; if ( isset( $legacy[ $id ] ) ) { $instructions = (string) get_option( $legacy[ $id ], '' ); } } $proof = (string) get_option( self::optionKey( $id, 'proof' ), 'cash' === $id ? 'off' : 'optional' ); if ( ! in_array( $proof, [ 'off', 'optional', 'required' ], true ) ) { $proof = 'optional'; } $reference_default = 'cash' === $id ? 'no' : 'yes'; $reference = (string) get_option( self::optionKey( $id, 'reference' ), $reference_default ); return [ 'instructions' => $instructions, 'reference' => in_array( $reference, [ 'yes', '1', 'on', 'true' ], true ), 'proof' => $proof, 'proof_required' => 'required' === $proof, ]; } /** * The structured details a built-in method prints above its instructions. * Empty rows are skipped, so a merchant fills in what applies. * * @param string $id Method id. * @return array label => value */ public static function details( string $id ): array { $rows = []; if ( 'bank_transfer' === $id ) { $map = [ 'easy_invoice_manual_bank_name' => __( 'Bank', 'easy-invoice' ), 'easy_invoice_manual_account_name' => __( 'Account name', 'easy-invoice' ), 'easy_invoice_manual_account_number' => __( 'Account number', 'easy-invoice' ), 'easy_invoice_manual_routing_number' => __( 'Routing / sort code', 'easy-invoice' ), 'easy_invoice_manual_swift_code' => __( 'SWIFT / BIC', 'easy-invoice' ), 'easy_invoice_manual_iban' => __( 'IBAN', 'easy-invoice' ), 'easy_invoice_manual_bank_address' => __( 'Bank address', 'easy-invoice' ), ]; } elseif ( 'cheque' === $id ) { $map = [ 'easy_invoice_cheque_payable_to' => __( 'Payable to', 'easy-invoice' ), 'easy_invoice_cheque_mailing_address' => __( 'Mail to', 'easy-invoice' ), ]; } else { $map = []; } foreach ( $map as $option => $label ) { $value = trim( (string) get_option( $option, '' ) ); if ( '' !== $value ) { $rows[ $label ] = $value; } } return $rows; } /* ------------------------------------------------------------------ */ /* Receipts */ /* ------------------------------------------------------------------ */ /** * Absolute path of the receipts folder, created and guarded. */ public static function proofDir(): string { $upload = wp_upload_dir(); $dir = trailingslashit( $upload['basedir'] ) . self::PROOF_SUBDIR; UploadGuard::protectDirectory( $dir ); self::denyDirectAccess( $dir ); return $dir; } /** * Receipts carry bank details and are only ever meant for staff, so on * Apache the folder refuses direct requests outright (UploadGuard only * switches listings off). Nginx ignores .htaccess; there the random, * unlisted file names are the protection, and the admin link goes through * PHP either way. * * @param string $dir Folder. */ private static function denyDirectAccess( string $dir ): void { $htaccess = trailingslashit( $dir ) . '.htaccess'; $rule = "# Easy Invoice: receipts are served through the plugin, never directly.\n" . "\n Require all denied\n\n" . "\n Order deny,allow\n Deny from all\n\n" . "Options -Indexes\n"; $current = file_exists( $htaccess ) ? (string) file_get_contents( $htaccess ) : ''; // phpcs:ignore WordPress.WP.AlternativeFunctions if ( false === strpos( $current, 'Require all denied' ) ) { @file_put_contents( $htaccess, $rule ); // phpcs:ignore WordPress.WP.AlternativeFunctions,WordPress.PHP.NoSilencedErrors } } /** * Validate and store an uploaded receipt. * * @param array $file One entry of $_FILES. * @return array{file:string,name:string}|\WP_Error Path relative to uploads/ and the client's file name. */ public static function storeProof( array $file ) { if ( empty( $file['tmp_name'] ) || ! is_uploaded_file( $file['tmp_name'] ) ) { return new \WP_Error( 'upload', __( 'The receipt could not be read. Please try again.', 'easy-invoice' ) ); } if ( ! empty( $file['error'] ) ) { return new \WP_Error( 'upload', __( 'The receipt could not be uploaded. Please try again.', 'easy-invoice' ) ); } /** * Filter the largest receipt accepted, in bytes. * * @param int $bytes Default 5 MB. */ $max = (int) apply_filters( 'easy_invoice_payment_proof_max_bytes', 5 * 1024 * 1024 ); if ( (int) $file['size'] > $max ) { /* translators: %s: size such as "5 MB". */ return new \WP_Error( 'size', sprintf( __( 'The receipt must be smaller than %s.', 'easy-invoice' ), size_format( $max ) ) ); } $allowed = [ 'jpg|jpeg|jpe' => 'image/jpeg', 'png' => 'image/png', 'gif' => 'image/gif', 'webp' => 'image/webp', 'pdf' => 'application/pdf', ]; $checked = wp_check_filetype_and_ext( $file['tmp_name'], (string) $file['name'], $allowed ); if ( empty( $checked['ext'] ) || empty( $checked['type'] ) || ! in_array( $checked['type'], $allowed, true ) ) { return new \WP_Error( 'type', __( 'Receipts can be a JPG, PNG, GIF, WebP or PDF file.', 'easy-invoice' ) ); } $dir = self::proofDir(); $name = 'receipt-' . wp_generate_password( 32, false, false ) . '.' . $checked['ext']; $dest = trailingslashit( $dir ) . $name; if ( ! is_dir( $dir ) || ! @move_uploaded_file( $file['tmp_name'], $dest ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors return new \WP_Error( 'move', __( 'The receipt could not be saved. Please try again or contact us.', 'easy-invoice' ) ); } @chmod( $dest, 0640 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions return [ 'file' => self::PROOF_SUBDIR . '/' . $name, 'name' => sanitize_file_name( (string) $file['name'] ), ]; } /** * Absolute path of the receipt attached to a payment, if any. * * Understands the three ways earlier versions recorded it: a path * relative to uploads/ (2.4.2+), a full URL on the payment (2.4.0/2.4.1) * and a full URL on the invoice (Pro's bank/cheque gateways). * * @param int $payment_id Payment record. */ public static function proofPath( int $payment_id ): string { $upload = wp_upload_dir(); $base = trailingslashit( $upload['basedir'] ); $rel = (string) get_post_meta( $payment_id, self::META_PROOF_FILE, true ); if ( '' !== $rel ) { $abs = $base . ltrim( $rel, '/' ); return file_exists( $abs ) ? $abs : ''; } $url = (string) get_post_meta( $payment_id, self::META_PROOF_URL, true ); if ( '' === $url ) { $invoice_id = (int) get_post_meta( $payment_id, '_invoice_id', true ); foreach ( [ '_bank_payment_proof', '_cheque_image', '_manual_payment_proof' ] as $legacy ) { $url = (string) get_post_meta( $invoice_id, $legacy, true ); if ( '' !== $url ) { break; } } } if ( '' === $url ) { return ''; } // Only ever serve out of our own receipts folder, whatever the stored URL says. $name = basename( (string) wp_parse_url( $url, PHP_URL_PATH ) ); if ( '' === $name || $name !== sanitize_file_name( $name ) ) { return ''; } foreach ( [ self::PROOF_SUBDIR, 'easy-invoice-proofs', 'easy-invoice/payment-proofs' ] as $folder ) { $abs = $base . $folder . '/' . $name; if ( file_exists( $abs ) ) { return $abs; } } return ''; } /** * Does the payment carry a receipt staff can open? * * @param int $payment_id Payment record. */ public static function hasProof( int $payment_id ): bool { return '' !== self::proofPath( $payment_id ); } /** * Signed admin link that streams the receipt. * * @param int $payment_id Payment record. */ public static function proofUrl( int $payment_id ): string { // Signed per payment rather than nonced: the link is emailed to staff // and must still open days later, for whoever is signed in — the // capability check is the authorisation, the signature just keeps the // endpoint from being enumerated. Not HTML-escaped; escape at output. return add_query_arg( [ 'action' => self::PROOF_ACTION, 'payment' => $payment_id, 'sig' => self::proofSignature( $payment_id ), ], admin_url( 'admin-post.php' ) ); } /** * Stable signature for a payment's receipt link. * * @param int $payment_id Payment record. */ private static function proofSignature( int $payment_id ): string { return substr( wp_hash( 'easy_invoice_payment_proof|' . $payment_id, 'nonce' ), 0, 20 ); } /** * admin-post handler: stream the receipt to someone who may see invoices. */ public static function serveProof(): void { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only download; authorised by capability, addressed by signature. $payment_id = isset( $_GET['payment'] ) ? absint( $_GET['payment'] ) : 0; $sig = isset( $_GET['sig'] ) ? sanitize_text_field( wp_unslash( $_GET['sig'] ) ) : ''; // phpcs:enable if ( ! is_user_logged_in() ) { auth_redirect(); } if ( ! easy_invoice_user_can( 'ei_view_invoices' ) ) { wp_die( esc_html__( 'You do not have permission to view payment receipts.', 'easy-invoice' ), 403 ); } if ( ! $payment_id || '' === $sig || ! hash_equals( self::proofSignature( $payment_id ), $sig ) ) { wp_die( esc_html__( 'That receipt link is not valid. Open the payment from the Payments screen.', 'easy-invoice' ), 403 ); } $post = get_post( $payment_id ); if ( ! $post || 'easy_invoice_payment' !== $post->post_type ) { wp_die( esc_html__( 'Payment not found.', 'easy-invoice' ), 404 ); } $path = self::proofPath( $payment_id ); if ( '' === $path ) { wp_die( esc_html__( 'No receipt is attached to this payment.', 'easy-invoice' ), 404 ); } $type = wp_check_filetype( $path ); $mime = $type['type'] ?: 'application/octet-stream'; $name = (string) get_post_meta( $payment_id, self::META_PROOF_NAME, true ); if ( '' === $name ) { $name = 'receipt-' . $payment_id . '.' . ( $type['ext'] ?: 'bin' ); } nocache_headers(); header( 'Content-Type: ' . $mime ); header( 'Content-Length: ' . (string) filesize( $path ) ); header( 'Content-Disposition: inline; filename="' . rawurlencode( $name ) . '"' ); header( 'X-Content-Type-Options: nosniff' ); readfile( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions exit; } /** * before_delete_post: a receipt goes with its payment record. * * @param int $post_id Post being deleted. * @param \WP_Post|null $post The post. */ public static function deleteProofWithPayment( $post_id, $post = null ): void { $post = $post ?: get_post( $post_id ); if ( ! $post || 'easy_invoice_payment' !== $post->post_type ) { return; } $path = self::proofPath( (int) $post_id ); if ( '' !== $path && file_exists( $path ) ) { wp_delete_file( $path ); } } /* ------------------------------------------------------------------ */ /* Pending submissions */ /* ------------------------------------------------------------------ */ /** Statuses a payment record can carry while it waits for staff. */ public static function pendingStatuses(): array { return [ 'pending', 'pending-bank', 'pending-cheque', 'pending_verification' ]; } /** * Offline payments a client has told us about that staff have not yet * confirmed or rejected, newest first. * * @param int $invoice_id Invoice. * @return \WP_Post[] */ public static function pendingForInvoice( int $invoice_id ): array { if ( $invoice_id <= 0 ) { return []; } return get_posts( [ 'post_type' => 'easy_invoice_payment', 'post_status' => 'any', 'posts_per_page' => 20, 'orderby' => 'date', 'order' => 'DESC', 'meta_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query [ 'key' => '_invoice_id', 'value' => $invoice_id, ], [ 'key' => '_status', 'value' => self::pendingStatuses(), 'compare' => 'IN', ], ], ] ); } /** * Human name for a method id, honouring the display name from Settings. * * @param string $id Method or gateway id. */ public static function label( string $id ): string { if ( function_exists( 'easy_invoice_get_payment_method_label' ) ) { return (string) easy_invoice_get_payment_method_label( $id ); } $custom = (string) get_option( 'easy_invoice_gateway_display_name_' . $id, '' ); if ( '' !== $custom ) { return $custom; } $methods = self::methods(); if ( isset( $methods[ $id ] ) ) { return (string) $methods[ $id ]['title']; } if ( 'manual' === $id ) { return __( 'Manual payment', 'easy-invoice' ); } return ucwords( str_replace( [ '_', '-' ], ' ', $id ) ); } /** * Lower-case id: letters, digits and underscores. * * @param string $id Raw id. */ public static function sanitizeId( string $id ): string { $id = strtolower( preg_replace( '/[^a-z0-9_]+/i', '_', $id ) ); return trim( $id, '_' ); } }