id = $id; } public static function register_post_type(): void { register_post_type( self::POST_TYPE, [ 'labels' => [ 'name' => __( 'Cryptoway payments', 'cryptoway-button' ), 'singular_name' => __( 'Cryptoway payment', 'cryptoway-button' ), ], 'public' => false, 'publicly_queryable' => false, 'exclude_from_search' => true, 'show_ui' => false, 'show_in_menu' => false, 'show_in_rest' => false, 'show_in_nav_menus' => false, 'query_var' => false, 'rewrite' => false, 'has_archive' => false, 'hierarchical' => false, 'supports' => [ 'title' ], 'can_export' => false, 'capability_type' => 'post', 'map_meta_cap' => true, ] ); } /** * Start a payment record. The invoice is requested afterwards; until then the status is "new". * * @param array $args title, amount, currency, mode, after, message, redirect, access, button, source_post, payer, symbol. */ public static function create( array $args ): ?self { $title = sanitize_text_field( (string) ( $args['title'] ?? '' ) ); $id = wp_insert_post( [ 'post_type' => self::POST_TYPE, 'post_status' => self::POST_STATUS, 'post_title' => '' !== $title ? $title : __( 'Payment', 'cryptoway-button' ), 'post_content' => '', 'post_author' => 0, ], true ); if ( is_wp_error( $id ) || ! is_int( $id ) || $id <= 0 ) { return null; } $payment = new self( $id ); $external_id = $id . '-' . bin2hex( random_bytes( 8 ) ); // The external_id doubles as the slug: unique, indexed, and found without a meta query. wp_update_post( [ 'ID' => $id, 'post_name' => $external_id ] ); $payment->set_meta( self::META_EXTERNAL_ID, $external_id ); $payment->set_meta( self::META_VIEW_KEY, bin2hex( random_bytes( 16 ) ) ); $payment->set_meta( self::META_RETURN_KEY, bin2hex( random_bytes( 16 ) ) ); $payment->set_meta( self::META_STATUS, self::STATUS_NEW ); $payment->set_meta( self::META_AMOUNT, (string) ( $args['amount'] ?? '' ) ); $payment->set_meta( self::META_CURRENCY, strtoupper( (string) ( $args['currency'] ?? '' ) ) ); $payment->set_meta( self::META_MODE, (string) ( $args['mode'] ?? Button::PAY_PAGE ) ); $payment->set_meta( self::META_AFTER, (string) ( $args['after'] ?? Button::AFTER_MESSAGE ) ); $payment->set_meta( self::META_MESSAGE, (string) ( $args['message'] ?? '' ) ); $payment->set_meta( self::META_REDIRECT, (string) ( $args['redirect'] ?? '' ) ); $payment->set_meta( self::META_ACCESS, (string) (int) ( $args['access'] ?? 0 ) ); $payment->set_meta( self::META_BUTTON, (string) ( $args['button'] ?? '' ) ); $payment->set_meta( self::META_SOURCE_POST, (string) (int) ( $args['source_post'] ?? 0 ) ); if ( ! empty( $args['symbol'] ) ) { $payment->set_meta( self::META_REQUESTED_SYMBOL, (string) $args['symbol'] ); } if ( ! empty( $args['payer'] ) && is_array( $args['payer'] ) ) { $payment->set_meta( self::META_PAYER, $args['payer'] ); // The e-mail on its own, lower-cased: "Already paid?" looks payments up by it. $email = (string) ( $args['payer']['email'] ?? '' ); if ( is_email( $email ) ) { $payment->set_meta( self::META_PAYER_EMAIL, strtolower( $email ) ); } } return $payment; } /** * Payments of a button on a page made with an e-mail, newest first: for mailing the access * link again. A handful at most; the caller picks the one whose access still runs (paid, or * opened by hand). * * @param string[] $buttons The button's keys (Button::keys()). * @return self[] */ public static function find_by_payer_email( array $buttons, int $source_post, string $email ): array { $buttons = array_values( array_filter( array_map( 'strval', $buttons ) ) ); if ( ! $buttons || ! is_email( $email ) ) { return []; } $query = new \WP_Query( [ 'post_type' => self::POST_TYPE, 'post_status' => [ self::POST_STATUS ], 'posts_per_page' => 10, 'fields' => 'ids', 'no_found_rows' => true, 'update_post_term_cache' => false, 'orderby' => 'ID', 'order' => 'DESC', 'meta_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- exact matches on a few rows. 'relation' => 'AND', [ 'key' => self::META_BUTTON, 'value' => $buttons, 'compare' => 'IN' ], [ 'key' => self::META_SOURCE_POST, 'value' => (string) $source_post ], [ 'key' => self::META_PAYER_EMAIL, 'value' => strtolower( $email ) ], ], ] ); return array_map( static fn( $id ): self => new self( (int) $id ), $query->posts ); } /** * Payments made with an e-mail, for the personal data exporter and eraser. * * @return self[] */ public static function find_by_email( string $email, int $page = 1, int $per_page = 50 ): array { if ( ! is_email( $email ) ) { return []; } $query = new \WP_Query( [ 'post_type' => self::POST_TYPE, 'post_status' => [ self::POST_STATUS ], 'posts_per_page' => $per_page, 'paged' => max( 1, $page ), 'fields' => 'ids', 'no_found_rows' => true, 'update_post_term_cache' => false, 'orderby' => 'ID', 'order' => 'ASC', 'meta_key' => self::META_PAYER_EMAIL, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- a privacy request, rare. 'meta_value' => strtolower( $email ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value ] ); return array_map( static fn( $id ): self => new self( (int) $id ), $query->posts ); } public static function get( int $id ): ?self { if ( $id <= 0 ) { return null; } $post = get_post( $id ); if ( ! $post instanceof \WP_Post || self::POST_TYPE !== $post->post_type ) { return null; } return new self( $id ); } /** * The payment a Cryptoway invoice belongs to. * * @throws \RuntimeException When the database could not be asked: the notification must be re-sent, not answered 404. */ public static function find_by_invoice_id( string $invoice_id ): ?self { if ( '' === $invoice_id ) { return null; } return self::find_one( [ 'meta_key' => self::META_INVOICE_ID, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- the invoice id is the only handle a notification carries. 'meta_value' => $invoice_id, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value ] ); } /** * The payment with this external_id ("-"), which is also its slug. * * @throws \RuntimeException When the database could not be asked. */ public static function find_by_external_id( string $external_id ): ?self { if ( '' === $external_id || 1 !== preg_match( '/^\d+-[0-9a-f]{16}$/', $external_id ) ) { return null; } $payment = self::find_one( [ 'name' => $external_id ] ); if ( $payment && $payment->external_id() !== $external_id ) { return null; } return $payment; } /** * @throws \RuntimeException When the query failed. */ private static function find_one( array $args ): ?self { global $wpdb; $query = new \WP_Query( array_merge( [ 'post_type' => self::POST_TYPE, 'post_status' => [ self::POST_STATUS ], 'posts_per_page' => 1, 'fields' => 'ids', 'no_found_rows' => true, 'update_post_meta_cache' => false, 'update_post_term_cache' => false, 'orderby' => 'ID', 'order' => 'DESC', ], $args ) ); if ( '' !== (string) $wpdb->last_error ) { // Caught by the webhook handler and written to the log only; escaped all the same. throw new \RuntimeException( esc_html( 'database error: ' . $wpdb->last_error ) ); } $ids = $query->posts; return $ids ? new self( (int) $ids[0] ) : null; } /** * Ids of payments the scheduled check should look at: open statuses from the last two * weeks, the ones checked longest ago first, so a busy site still reaches every one. * * @return int[] */ public static function open_ids( int $limit ): array { $query = new \WP_Query( [ 'post_type' => self::POST_TYPE, 'post_status' => [ self::POST_STATUS ], 'posts_per_page' => 500, 'no_found_rows' => true, 'update_post_term_cache' => false, 'orderby' => 'ID', 'order' => 'DESC', 'date_query' => [ [ 'after' => '14 days ago' ] ], 'meta_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- open payments of two weeks, once per ten minutes. [ 'key' => self::META_STATUS, 'value' => self::OPEN, 'compare' => 'IN' ], ], ] ); // Whole posts with their meta in the cache: the caller looks at each one; the least recently checked first. $ids = array_map( static fn( $post ): int => (int) $post->ID, (array) $query->posts ); usort( $ids, static fn( int $a, int $b ): int => (int) get_post_meta( $a, self::META_LAST_SYNC, true ) <=> (int) get_post_meta( $b, self::META_LAST_SYNC, true ) ?: $b <=> $a ); return array_slice( $ids, 0, $limit ); } public function id(): int { return $this->id; } public function meta( string $key ): string { $value = get_post_meta( $this->id, $key, true ); return is_scalar( $value ) ? (string) $value : ''; } public function meta_array( string $key ): array { $value = get_post_meta( $this->id, $key, true ); return is_array( $value ) ? $value : []; } public function set_meta( string $key, mixed $value ): void { update_post_meta( $this->id, $key, $value ); } public function delete_meta( string $key ): void { delete_post_meta( $this->id, $key ); } /** Drop this payment's meta from the object cache: the next read comes from the database. */ public function forget_cached_meta(): void { wp_cache_delete( $this->id, 'post_meta' ); } /** The lock that guards read-modify-write of this payment's lists (history, events). */ public static function meta_lock_key( int $id ): string { return 'cryptoway_button_lock_p' . $id; } /** Remove the payment and its meta for good (the admin's "Delete" on a closed payment). */ public function delete(): bool { wp_clear_scheduled_hook( Cron::HOOK_DEADLINE, [ $this->id ] ); return (bool) wp_delete_post( $this->id, true ); } public function title(): string { $post = get_post( $this->id ); return $post instanceof \WP_Post ? (string) $post->post_title : ''; } /** When the payment was started, as a Unix timestamp. */ public function created_at(): int { $post = get_post( $this->id ); return $post instanceof \WP_Post ? (int) get_post_timestamp( $post ) : 0; } public function external_id(): string { return $this->meta( self::META_EXTERNAL_ID ); } public function invoice_id(): string { return $this->meta( self::META_INVOICE_ID ); } public function invoice_status(): string { return $this->meta( self::META_INVOICE_STATUS ); } public function pay_url(): string { return $this->meta( self::META_PAY_URL ); } public function amount(): string { return $this->meta( self::META_AMOUNT ); } public function currency(): string { return $this->meta( self::META_CURRENCY ); } public function symbol(): string { $symbol = $this->meta( self::META_SYMBOL ); return '' !== $symbol ? $symbol : $this->meta( self::META_REQUESTED_SYMBOL ); } public function address(): string { return $this->meta( self::META_ADDRESS ); } public function tx_hash(): string { return $this->meta( self::META_TX_HASH ); } public function deadline(): string { return $this->meta( self::META_DEADLINE ); } public function view_key(): string { return $this->meta( self::META_VIEW_KEY ); } public function return_key(): string { return $this->meta( self::META_RETURN_KEY ); } public function mode(): string { return $this->meta( self::META_MODE ); } public function after(): string { return $this->meta( self::META_AFTER ); } public function button(): string { return $this->meta( self::META_BUTTON ); } public function source_post(): int { return (int) $this->meta( self::META_SOURCE_POST ); } /** Name, e-mail and message the payer typed, when the button asked for them. */ public function payer(): array { $payer = $this->meta_array( self::META_PAYER ); $out = []; foreach ( [ 'name', 'email', 'message' ] as $field ) { if ( isset( $payer[ $field ] ) && is_scalar( $payer[ $field ] ) && '' !== trim( (string) $payer[ $field ] ) ) { $out[ $field ] = (string) $payer[ $field ]; } } return $out; } public function status(): string { return $this->meta( self::META_STATUS ); } /** The status as the database has it now, bypassing the object cache. */ public function fresh_status(): string { wp_cache_delete( $this->id, 'post_meta' ); return $this->status(); } public function set_status( string $status ): void { $this->set_meta( self::META_STATUS, $status ); if ( in_array( $status, self::REVIEW, true ) ) { $this->set_meta( self::META_REVIEW, 'yes' ); } elseif ( in_array( $status, self::SETTLED, true ) ) { $this->delete_meta( self::META_REVIEW ); } } public function is_settled(): bool { return in_array( $this->status(), self::SETTLED, true ); } public function needs_review(): bool { return 'yes' === $this->meta( self::META_REVIEW ); } /** Whether the time to pay has run out; false when no deadline is known. */ public function deadline_has_passed(): bool { $deadline = $this->deadline(); if ( '' === $deadline ) { return false; } $at = strtotime( $deadline ); return false !== $at && $at < time(); } /** Seconds left to pay; 0 when the deadline passed or is unknown. */ public function seconds_left(): int { $at = strtotime( $this->deadline() ); return false === $at ? 0 : max( 0, $at - time() ); } /** * Whether the saved pay link or address can still be used: the payment is payable and * more than a minute is left before the deadline. */ public function link_is_alive(): bool { if ( ! in_array( $this->status(), self::PAYABLE, true ) ) { return false; } if ( ! in_array( $this->invoice_status(), [ '', 'active', 'pending' ], true ) ) { return false; } $deadline = $this->deadline(); if ( '' === $deadline ) { return true; } $expires_at = strtotime( $deadline ); return false === $expires_at || $expires_at > ( time() + MINUTE_IN_SECONDS ); } /** * The history: what happened to the payment, newest last. * * @return array */ public function history(): array { return array_values( array_filter( $this->meta_array( self::META_HISTORY ), 'is_array' ) ); } public function add_history( string $text ): void { // Read and written under the payment's own lock: two notifications at once keep both lines. Lock::run( self::meta_lock_key( $this->id ), function () use ( $text ): void { $this->forget_cached_meta(); $history = $this->history(); $history[] = [ 't' => time(), 'm' => $text ]; $this->set_meta( self::META_HISTORY, array_slice( $history, -self::HISTORY_MAX ) ); } ); } /** The screen of this payment in the admin area. */ public function admin_url(): string { return add_query_arg( [ 'page' => Admin::PAGE, 'payment' => $this->id ], admin_url( 'admin.php' ) ); } /** The visible labels of the statuses. */ public static function status_labels(): array { return [ self::STATUS_NEW => __( 'Starting', 'cryptoway-button' ), self::STATUS_FAILED => __( 'Not started', 'cryptoway-button' ), self::STATUS_PENDING => __( 'Waiting for payment', 'cryptoway-button' ), self::STATUS_CONFIRMING => __( 'Confirming', 'cryptoway-button' ), self::STATUS_PAID => __( 'Paid', 'cryptoway-button' ), self::STATUS_UNDERPAID => __( 'Underpaid', 'cryptoway-button' ), self::STATUS_EXPIRED => __( 'Expired', 'cryptoway-button' ), self::STATUS_LATE => __( 'Paid after the time ran out', 'cryptoway-button' ), self::STATUS_AML => __( 'Security check', 'cryptoway-button' ), self::STATUS_CANCELLED => __( 'Cancelled', 'cryptoway-button' ), self::STATUS_HOLD => __( 'Needs review', 'cryptoway-button' ), ]; } public static function status_label( string $status ): string { return self::status_labels()[ $status ] ?? __( 'Unknown', 'cryptoway-button' ); } public function status_label_now(): string { return self::status_label( $this->status() ); } }