'Basic ' . base64_encode(...)` lines before subscriptions added four more call sites. Returns null (rather than throwing) when unconfigured so every caller keeps its own explicit, user-facing "not configured" error rather than a generic exception. */ private static function headers() { $secret = Settings::get( 'stripe_secret_key' ); if ( ! $secret ) { return null; } return [ 'Authorization' => 'Basic ' . base64_encode( $secret . ':' ), 'Stripe-Version' => self::API_VERSION, ]; } private static function not_configured_error() { return new \WP_Error( 'chargeforms_stripe_not_configured', __( 'Payments are not configured for this site.', 'chargeforms' ) ); } public static function create_intent( $amount_decimal, $currency, array $metadata = [] ) { $headers = self::headers(); if ( ! $headers ) { return self::not_configured_error(); } $body = [ 'amount' => (int) round( $amount_decimal * 100 ), 'currency' => strtolower( $currency ), ]; foreach ( $metadata as $key => $value ) { $body[ 'metadata[' . $key . ']' ] = $value; } $response = wp_remote_post( self::API_BASE . '/payment_intents', [ 'headers' => $headers, 'body' => $body, 'timeout' => 15, ] ); return self::parse_response( $response ); } public static function retrieve_intent( $intent_id ) { $headers = self::headers(); if ( ! $headers ) { return self::not_configured_error(); } $response = wp_remote_get( self::API_BASE . '/payment_intents/' . rawurlencode( $intent_id ), [ 'headers' => $headers, 'timeout' => 15, ] ); return self::parse_response( $response ); } /** * Always creates a fresh Customer rather than searching for an existing * one by email — Stripe allows multiple customers sharing an email * address, and matching an incoming submitter to a past customer by * email alone would be a spoofable identity assumption (anyone who * knows a real customer's email could otherwise attach a new * subscription's billing to their record). Simpler and safer; nothing * in this feature needs "the same visitor's second subscription * shares a customer" today. */ public static function get_or_create_customer( $email, array $metadata = [] ) { $headers = self::headers(); if ( ! $headers ) { return self::not_configured_error(); } $body = []; if ( $email ) { $body['email'] = $email; } foreach ( $metadata as $key => $value ) { $body[ 'metadata[' . $key . ']' ] = $value; } $response = wp_remote_post( self::API_BASE . '/customers', [ 'headers' => $headers, 'body' => $body, 'timeout' => 15, ] ); $customer = self::parse_response( $response ); if ( is_wp_error( $customer ) ) { return $customer; } if ( empty( $customer['id'] ) ) { return new \WP_Error( 'chargeforms_stripe_error', __( 'Unexpected response from Stripe.', 'chargeforms' ) ); } return $customer['id']; } /** * Creates a Stripe-hosted Billing Portal session — a fresh one every * time, on demand, never generated in advance and emailed as a static * link (Stripe's own sessions are short-lived by design; see * Subscription_Portal::handle(), the one caller, which asks for a * session at the moment the payer actually clicks "Manage my * subscription," not when the receipt email was sent). */ public static function create_portal_session( $customer_id, $return_url ) { $headers = self::headers(); if ( ! $headers ) { return self::not_configured_error(); } $response = wp_remote_post( self::API_BASE . '/billing_portal/sessions', [ 'headers' => $headers, 'body' => [ 'customer' => $customer_id, 'return_url' => $return_url, ], 'timeout' => 15, ] ); $session = self::parse_response( $response ); if ( is_wp_error( $session ) ) { return $session; } if ( empty( $session['url'] ) ) { return new \WP_Error( 'chargeforms_stripe_error', __( 'Unexpected response from Stripe.', 'chargeforms' ) ); } return $session['url']; } /** * `payment_behavior=default_incomplete` is the load-bearing setting * here: it creates the subscription in an `incomplete` state with an * open invoice instead of immediately trying (and failing) to charge a * payment method that doesn't exist yet — the client then confirms * that invoice's PaymentIntent with the card details exactly the same * way the one-time-payment flow already confirms a PaymentIntent (see * assets/js/stripe-payment.js), which is what actually attaches a * payment method to the subscription. `save_default_payment_method: * on_subscription` is what makes that confirmed payment method stick * around for the NEXT billing cycle's automatic charge, not just this * one. * * A trial (`$trial_days` > 0) means the first invoice is $0 — Stripe * then returns a `pending_setup_intent` instead of a payment-bearing * invoice, since there's nothing to charge yet but a payment method * still needs to be collected and saved for when the trial ends. The * caller (RestApi::create_subscription()) inspects which one came back * and tells the client whether to call confirmCardPayment() or * confirmCardSetup() — see that method's doc comment. * * Price is defined inline via `price_data` rather than a pre-created * Stripe Price object — ChargeForms's amounts are computed per-submission * from a form's own field config (Commerce_Calculator), not fixed * catalog prices, so there is no stable Price id to reuse across * submissions in the first place. */ public static function create_subscription( $customer_id, $amount, $currency, $interval, $trial_days, array $metadata = [] ) { $headers = self::headers(); if ( ! $headers ) { return self::not_configured_error(); } if ( ! in_array( $interval, [ 'month', 'year' ], true ) ) { return new \WP_Error( 'chargeforms_invalid_interval', __( 'Invalid billing interval.', 'chargeforms' ) ); } $body = [ 'customer' => $customer_id, 'payment_behavior' => 'default_incomplete', 'payment_settings' => [ 'save_default_payment_method' => 'on_subscription' ], 'items' => [ [ 'price_data' => [ 'currency' => strtolower( $currency ), 'unit_amount' => (int) round( $amount * 100 ), 'recurring' => [ 'interval' => $interval ], 'product_data' => [ 'name' => get_bloginfo( 'name' ) ?: 'Subscription' ], ], ], ], 'expand' => [ 'latest_invoice.payment_intent', 'pending_setup_intent' ], ]; if ( $trial_days > 0 ) { $body['trial_period_days'] = (int) $trial_days; } foreach ( $metadata as $key => $value ) { $body['metadata'][ $key ] = $value; } $response = wp_remote_post( self::API_BASE . '/subscriptions', [ 'headers' => $headers, 'body' => $body, 'timeout' => 15, ] ); return self::parse_response( $response ); } /** * `$at_period_end` true issues an update (`cancel_at_period_end=true`) * so the subscriber keeps access through what they already paid for; * false issues an immediate DELETE, ending access right away. Either * way this is the only place that actually talks to Stripe to cancel — * RestApi's cancel endpoint and the Payments admin page's "Cancel" * action both call through here rather than each building their own * request. */ public static function cancel_subscription( $gateway_subscription_id, $at_period_end = false ) { $headers = self::headers(); if ( ! $headers ) { return self::not_configured_error(); } $args = [ 'headers' => $headers, 'timeout' => 15, ]; if ( $at_period_end ) { $args['method'] = 'POST'; $args['body'] = [ 'cancel_at_period_end' => 'true' ]; $response = wp_remote_post( self::API_BASE . '/subscriptions/' . rawurlencode( $gateway_subscription_id ), $args ); } else { $args['method'] = 'DELETE'; $response = wp_remote_request( self::API_BASE . '/subscriptions/' . rawurlencode( $gateway_subscription_id ), $args ); } return self::parse_response( $response ); } public static function retrieve_subscription( $gateway_subscription_id ) { $headers = self::headers(); if ( ! $headers ) { return self::not_configured_error(); } $response = wp_remote_get( self::API_BASE . '/subscriptions/' . rawurlencode( $gateway_subscription_id ) . '?expand[]=latest_invoice.payment_intent', [ 'headers' => $headers, 'timeout' => 15, ] ); return self::parse_response( $response ); } private static function parse_response( $response ) { if ( is_wp_error( $response ) ) { return new \WP_Error( 'chargeforms_stripe_unreachable', __( 'Could not reach Stripe. Please try again.', 'chargeforms' ) ); } $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( ! empty( $body['error'] ) ) { return new \WP_Error( 'chargeforms_stripe_error', $body['error']['message'] ?? __( 'Stripe rejected the request.', 'chargeforms' ) ); } return is_array( $body ) ? $body : new \WP_Error( 'chargeforms_stripe_error', __( 'Unexpected response from Stripe.', 'chargeforms' ) ); } /** * Thin delegate onto Commerce_Calculator (extracted out when PayPal * became a second gateway — both need the exact same total-recomputation * authority, not one implementation per gateway). Kept here, not removed, * so every existing caller (RestApi, SubmissionHandler) keeps working * unchanged. */ public static function calculate_total( array $form, array $submitted_values ) { return Commerce_Calculator::calculate_total( $form, $submitted_values ); } }