$tolerance ) { return false; } $expected = hash_hmac( 'sha256', $timestamp . '.' . $payload, $secret ); foreach ( $signatures as $candidate ) { if ( hash_equals( $expected, $candidate ) ) { return true; } } return false; } /** * Entry point from RestApi::stripe_webhook(). Returns true on success * (including "verified but this event type isn't one we act on" — * Stripe sends dozens of event types this integration has no opinion * about) or a WP_Error for RestApi to turn into an HTTP error response. * A 4xx response tells Stripe's retry logic the request itself was * rejected (bad signature, malformed payload) — Stripe does NOT retry * on 4xx, only 5xx/timeout, so a permanently-invalid request (e.g. the * secret was rotated and this site's copy is stale) doesn't get * retried forever either; the merchant sees it surface in their own * Stripe Dashboard webhook logs. */ public static function handle( $payload, $signature_header ) { $secret = Settings::get( 'stripe_webhook_secret' ); if ( ! $secret ) { return new \WP_Error( 'chargeforms_webhook_not_configured', __( 'Stripe webhooks are not configured for this site.', 'chargeforms' ), [ 'status' => 400 ] ); } if ( ! self::verify_signature( $payload, $signature_header, $secret ) ) { return new \WP_Error( 'chargeforms_webhook_invalid_signature', __( 'Invalid webhook signature.', 'chargeforms' ), [ 'status' => 400 ] ); } $event = json_decode( $payload, true ); if ( ! is_array( $event ) || empty( $event['type'] ) ) { return new \WP_Error( 'chargeforms_webhook_invalid_payload', __( 'Malformed webhook payload.', 'chargeforms' ), [ 'status' => 400 ] ); } switch ( $event['type'] ) { case 'invoice.payment_succeeded': self::handle_invoice_succeeded( $event['data']['object'] ?? [] ); break; case 'invoice.payment_failed': self::handle_invoice_failed( $event['data']['object'] ?? [] ); break; case 'customer.subscription.updated': self::handle_subscription_updated( $event['data']['object'] ?? [] ); break; case 'customer.subscription.deleted': self::handle_subscription_deleted( $event['data']['object'] ?? [] ); break; default: // Every other event type Stripe might send this endpoint // (charge.dispute.created, payment_intent.*, etc.) — not // acted on today, deliberately not an error. Returning // success for unrecognized-but-validly-signed events is // what lets a merchant safely subscribe this one endpoint // to "all events" in the Stripe Dashboard without every // unrelated event type showing as a failure in their logs. break; } return true; } /** A subscription row must already exist locally (created synchronously during the original submission — see SubmissionHandler::verify_stripe_subscription()) for any webhook to have something to update. If Stripe's webhook races ahead of that (theoretically possible: Stripe can fire customer.subscription.updated within moments of creation, before this site's own request finishes verifying and writing the row), there's nothing to reconcile yet and nothing wrong — the reconciliation cron and/or a later webhook event will still catch the subscription up once the row exists. */ private static function find_local_subscription( $gateway_subscription_id ) { if ( ! $gateway_subscription_id ) { return null; } return Subscription::get_by_gateway_id( 'stripe', $gateway_subscription_id ); } /** * A renewal (or the very first post-trial charge) succeeded — the * subscription is in good standing. `current_period_end` moves * forward to the invoice's own period end (not "now + interval" * computed locally, which could drift from Stripe's actual billing * anchor/proration rules) — pulled off the invoice's line item if * present, falling back to a fresh retrieve_subscription() call for * older API response shapes that don't attach it to the invoice line. */ private static function handle_invoice_succeeded( array $invoice ) { $subscription_id = $invoice['subscription'] ?? null; $row = self::find_local_subscription( $subscription_id ); if ( ! $row ) { return; } $period_end = $invoice['lines']['data'][0]['period']['end'] ?? null; if ( ! $period_end ) { $fresh = Stripe_Gateway::retrieve_subscription( $subscription_id ); $period_end = is_wp_error( $fresh ) ? null : ( $fresh['current_period_end'] ?? null ); } Subscription::update( $row['id'], array_filter( [ 'status' => 'active', 'current_period_end' => $period_end ? gmdate( 'Y-m-d H:i:s', $period_end ) : null, ] ) ); Entry::log_activity( $row['entry_id'], __( 'Subscription payment succeeded — renewed.', 'chargeforms' ) ); Activity_Log::log( 'subscription_renewed', 'subscription', $row['id'], __( 'Subscription renewal succeeded.', 'chargeforms' ) ); } /** * A renewal charge failed. Stripe's own Smart Retries (if enabled on * the account) will keep trying on its own schedule — this site just * needs to reflect `past_due` so the Payments admin page shows it * accurately; `customer.subscription.deleted` is what eventually fires * if every retry exhausts, handled separately below. */ private static function handle_invoice_failed( array $invoice ) { $subscription_id = $invoice['subscription'] ?? null; $row = self::find_local_subscription( $subscription_id ); if ( ! $row ) { return; } Subscription::update( $row['id'], [ 'status' => 'past_due' ] ); Entry::log_activity( $row['entry_id'], __( 'Subscription payment failed.', 'chargeforms' ) ); Activity_Log::log( 'subscription_payment_failed', 'subscription', $row['id'], __( 'Subscription renewal payment failed.', 'chargeforms' ) ); } /** * Fired for almost any subscription state change that isn't a * cancellation (trial ending into an active period, status moving to * `past_due`/`unpaid`, `cancel_at_period_end` being toggled by an * admin from the Stripe Dashboard directly rather than through this * site). Status is copied straight from Stripe's own `status` field — * see Subscription::STATUSES, which mirrors Stripe's own status * strings 1:1 rather than inventing a parallel vocabulary that would * need translating back and forth. */ private static function handle_subscription_updated( array $subscription ) { $row = self::find_local_subscription( $subscription['id'] ?? null ); if ( ! $row ) { return; } $status = $subscription['status'] ?? null; Subscription::update( $row['id'], array_filter( [ 'status' => in_array( $status, Subscription::STATUSES, true ) ? $status : null, 'current_period_end' => isset( $subscription['current_period_end'] ) ? gmdate( 'Y-m-d H:i:s', $subscription['current_period_end'] ) : null, 'trial_end' => isset( $subscription['trial_end'] ) ? gmdate( 'Y-m-d H:i:s', $subscription['trial_end'] ) : null, 'cancel_at_period_end' => isset( $subscription['cancel_at_period_end'] ) ? ( $subscription['cancel_at_period_end'] ? 1 : 0 ) : null, ], fn( $v ) => null !== $v ) ); Entry::log_activity( $row['entry_id'], sprintf( /* translators: %s: new subscription status */ __( 'Subscription status changed to %s.', 'chargeforms' ), $status ?: __( 'unknown', 'chargeforms' ) ) ); } /** * The subscription has permanently ended — either the merchant/ * subscriber canceled it, or (per Stripe's own `cancellation_details. * reason`) it was auto-canceled after exhausting payment retries. * Distinguished so the Payments admin page can show "Expired" (payment * failure) differently from "Canceled" (a deliberate stop) — the same * distinction a subscriber or admin reviewing history would actually * want, not just "it's gone" either way. */ private static function handle_subscription_deleted( array $subscription ) { $row = self::find_local_subscription( $subscription['id'] ?? null ); if ( ! $row ) { return; } $reason = $subscription['cancellation_details']['reason'] ?? null; $status = 'payment_failed' === $reason ? 'expired' : 'canceled'; Subscription::update( $row['id'], [ 'status' => $status ] ); Entry::log_activity( $row['entry_id'], 'expired' === $status ? __( 'Subscription expired after failed payments.', 'chargeforms' ) : __( 'Subscription canceled.', 'chargeforms' ) ); Activity_Log::log( 'subscription_ended', 'subscription', $row['id'], 'expired' === $status ? __( 'Subscription expired.', 'chargeforms' ) : __( 'Subscription canceled.', 'chargeforms' ) ); } }