Payments — "Transactions", a dedicated top-level screen for * every captured payment (Stripe or PayPal), separate from Global Settings * > Payment Methods (which only holds gateway API keys — linked from the * "Payment Settings" button here). Every transaction row is really just an * entry with payment meta attached by SubmissionHandler::verify_payment() * at submission time — this page is a read-only view over that, not a * second source of truth. * * The filter row's Payment Methods dropdown is real now that a second * gateway exists; Status/Types still only ever have one real value each * (Succeeded / One-time) since ChargeForms doesn't yet track failed attempts * or subscriptions for either gateway — those two stay honestly disabled * rather than offering options that would never do anything. */ class Payments_Page { public function __construct() { add_action( 'admin_menu', [ $this, 'register' ] ); add_action( 'admin_post_chargeforms_bulk_delete_payments', [ $this, 'handle_bulk_delete' ] ); add_action( 'admin_post_chargeforms_cancel_subscription', [ $this, 'handle_cancel_subscription' ] ); } /** * The one admin-facing way to cancel a subscription (satisfies the * feature's cancellation requirement) — deliberately minimal: a single * confirm-and-cancel link, immediate cancellation only (not "at period * end"; an admin wanting that finer control can offer their own * subscriber-facing self-service flow against the REST endpoint, * RestApi::cancel_subscription(), which does support it — out of * scope for this admin screen per "no unnecessary UI polish"). Cancels * at Stripe FIRST and only updates the local row if that succeeds — * same ordering RestApi::cancel_subscription() uses and for the same * reason (a local-only cancellation the gateway never heard about * would keep billing the subscriber while this site thinks it's done). */ public function handle_cancel_subscription() { $id = isset( $_GET['subscription_id'] ) ? (int) $_GET['subscription_id'] : 0; if ( ! current_user_can( 'chargeforms_manage_forms' ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ?? '' ) ), 'chargeforms_cancel_subscription_' . $id ) ) { wp_die( esc_html__( 'Not allowed.', 'chargeforms' ), 403 ); } $row = Subscription::get( $id ); if ( $row && in_array( $row['status'], Subscription::CANCELABLE_STATUSES, true ) && Gateway_Manager::supports_subscriptions( $row['gateway'] ) ) { $gateway_class = Gateway_Manager::get( $row['gateway'] ); $result = $gateway_class::cancel_subscription( $row['gateway_subscription_id'], false ); if ( ! is_wp_error( $result ) ) { Subscription::update( $id, [ 'status' => 'canceled' ] ); Entry::log_activity( $row['entry_id'], __( 'Subscription canceled immediately by an admin.', 'chargeforms' ) ); Activity_Log::log( 'subscription_canceled', 'subscription', $id, __( 'Subscription canceled from the Payments screen.', 'chargeforms' ) ); } } wp_safe_redirect( admin_url( 'admin.php?page=chargeforms-payments&chargeforms_notice=subscription_canceled' ) ); exit; } /** * A transaction row IS an entry (see this class's own doc comment) — * deleting a payment record means deleting the entry it's attached to, * same as the row-checkbox column on Entries. Previously these * checkboxes were wired up (see the "check all" toggle below) with no * action behind them at all. */ public function handle_bulk_delete() { $payment_ids = isset( $_POST['payment_ids'] ) ? array_map( 'intval', (array) $_POST['payment_ids'] ) : []; if ( ! current_user_can( 'chargeforms_manage_forms' ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ?? '' ) ), 'chargeforms_bulk_delete_payments' ) ) { wp_die( esc_html__( 'Not allowed.', 'chargeforms' ), 403 ); } foreach ( $payment_ids as $entry_id ) { if ( $entry_id > 0 ) { Entry::delete( $entry_id ); } } wp_safe_redirect( admin_url( 'admin.php?page=chargeforms-payments&chargeforms_notice=payments_deleted' ) ); exit; } public function register() { add_submenu_page( 'chargeforms', __( 'Payments', 'chargeforms' ), __( 'Payments', 'chargeforms' ), 'chargeforms_manage_forms', 'chargeforms-payments', [ $this, 'render' ] ); } /** Best-effort "who paid" — the first Name/Email field value found on the entry, matching how notification emails already identify a submitter. */ private function customer_label( array $form, array $values ) { $name = ''; $email = ''; foreach ( $form['fields'] ?? [] as $field ) { if ( 'name' === $field['type'] && '' === $name && ! empty( $values[ $field['id'] ] ) ) { $name = $values[ $field['id'] ]; } if ( 'email' === $field['type'] && '' === $email && ! empty( $values[ $field['id'] ] ) ) { $email = $values[ $field['id'] ]; } } if ( $name && $email ) { return $name . ' (' . $email . ')'; } return $name ?: ( $email ?: __( '—', 'chargeforms' ) ); } public function render() { $currencies = [ 'stripe' => strtoupper( Settings::get( 'stripe_currency', 'usd' ) ), 'paypal' => strtoupper( Settings::get( 'paypal_currency', 'usd' ) ), ]; $page = isset( $_GET['paged'] ) ? max( 1, (int) $_GET['paged'] ) : 1; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $per_page = isset( $_GET['per_page'] ) ? min( 200, max( 1, (int) $_GET['per_page'] ) ) : 20; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $form_id = isset( $_GET['form_id'] ) ? (int) $_GET['form_id'] : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $gateway = isset( $_GET['gateway'] ) ? sanitize_key( $_GET['gateway'] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $forms = Form::all(); $forms_by_id = []; foreach ( $forms as $form ) { $forms_by_id[ $form['id'] ] = $form; } $result = Entry::payment_transactions( array_filter( [ 'page' => $page, 'per_page' => $per_page, 'gateway' => $gateway, ] ) ); if ( $form_id ) { $result['items'] = array_values( array_filter( $result['items'], fn( $row ) => (int) $row['form_id'] === $form_id ) ); } // Stat-card row (design_handoff_chargeforms's Payments screen: a // 4-tile KPI row under the header). Stripe/PayPal revenue are kept // as SEPARATE tiles rather than summed — Entry::payment_total()'s // own doc comment warns against combining different-currency // totals into one meaningless figure. $month_start = gmdate( 'Y-m-01' ); $month_count = Entry::payment_transactions( [ 'date_from' => $month_start, 'per_page' => 1 ] )['total']; $stripe_total = Entry::payment_total( '', '', 'stripe' ); $paypal_total = Entry::payment_total( '', '', 'paypal' ); $payment_stats = [ [ 'label' => __( 'Total Transactions', 'chargeforms' ), 'value' => number_format_i18n( $result['total'] ), 'sub' => __( 'All time', 'chargeforms' ) ], [ 'label' => __( 'This Month', 'chargeforms' ), 'value' => number_format_i18n( $month_count ), 'sub' => __( 'Since the 1st', 'chargeforms' ) ], [ 'label' => __( 'Stripe Revenue', 'chargeforms' ), 'value' => $stripe_total > 0 ? $currencies['stripe'] . ' ' . number_format_i18n( $stripe_total, 2 ) : '—', 'sub' => __( 'Captured, all time', 'chargeforms' ) ], [ 'label' => __( 'PayPal Revenue', 'chargeforms' ), 'value' => $paypal_total > 0 ? $currencies['paypal'] . ' ' . number_format_i18n( $paypal_total, 2 ) : '—', 'sub' => __( 'Captured, all time', 'chargeforms' ) ], ]; ?>

Global Settings > Payment Methods to start accepting payments.', 'chargeforms' ) ), esc_url( admin_url( 'admin.php?page=chargeforms-settings§ion=payment-methods' ) ) ); ?>

# customer_label( $form, $values ) : '—' ); ?>
// Matches the numbered pager (with jumper + per-page selector) already // shipped for Entries — the reference product's own real pagination pattern // (total/sizes/prev/pager/next/jumper), see the plugin's internal design-fidelity research doc. // Payments' own $result already comes from Entry::payment_transactions() // with a fixed per_page=20 (line ~145 above); the per-page selector is // still offered here since the helper wires it through the normal // 'per_page' query arg, same as every other paginated screen. $pagination_base_args = array_filter( [ 'page' => 'chargeforms-payments', 'form_id' => $form_id ?: null, 'gateway' => $gateway ?: null, 'per_page' => $result['per_page'], ] ); \ChargeForms\Includes\Pagination_Helper::render( $result, $pagination_base_args, [ 'jumper_id' => 'chargeforms-payments-jumper' ] ); ?>