# easy-invoice/2.4.2/includes/Services/OfflinePayments.php

Easy Invoice – Invoice Generator, PDF Quotes &amp; Payments, version 2.4.2. 593 lines.

- Page: https://pluginprobe.com/plugins/easy-invoice/2.4.2/code/includes/Services/OfflinePayments.php
- Raw: https://pluginprobe.com/plugins/easy-invoice/2.4.2/raw/includes/Services/OfflinePayments.php
- Modified: 2026-09-29T05:21:04+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.2/code/includes/Services/OfflinePayments.php#L10-L20`.

```php
<?php
/**
 * Offline payment methods: bank transfer, cheque, cash and any method the
 * merchant adds (ACH, wire, mobile money, …).
 *
 * One place knows which methods exist, what each one asks the client for
 * (a reference, a receipt), where a receipt is kept and who may open it.
 * The gateway objects (OfflineGateway) are thin wrappers over this.
 *
 * @package EasyInvoice
 */

namespace EasyInvoice\Services;

use EasyInvoice\Helpers\UploadGuard;

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

class OfflinePayments {

	/** Option holding the merchant's own methods: [ [ 'id' => '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<string, array{id:string,title:string,description:string,icon:string}>
	 */
	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<string, array{id:string,title:string,description:string,icon:string}>
	 */
	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<string, 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<string,string> 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"
			. "<IfModule mod_authz_core.c>\n    Require all denied\n</IfModule>\n"
			. "<IfModule !mod_authz_core.c>\n    Order deny,allow\n    Deny from all\n</IfModule>\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, '_' );
	}
}

```
