'POST', 'callback' => [$this, 'handle'], 'permission_callback' => '__return_true', // auth = signature verification ]); }); } public function handle(\WP_REST_Request $request): \WP_REST_Response { $payload = $request->get_body(); $sigHeader = (string) $request->get_header('stripe_signature'); $secret = $this->webhookSecret(); if ($secret === '' || !$this->verify($payload, $sigHeader, $secret)) { return new \WP_REST_Response(['error' => 'invalid-signature'], 400); } $event = json_decode($payload, true); if (!is_array($event) || !isset($event['type'])) { return new \WP_REST_Response(['error' => 'bad-payload'], 400); } $object = $event['data']['object'] ?? []; switch ($event['type']) { case 'payment_intent.succeeded': if ($id = $this->subIdFrom($object)) { RetryPolicy::resolveSuccess($id); } break; case 'payment_intent.payment_failed': if ($id = $this->subIdFrom($object)) { $reason = (string) ($object['last_payment_error']['message'] ?? 'sepa-failed'); RetryPolicy::resolveFailure($id, $reason); } break; case 'setup_intent.succeeded': $this->captureMandate($object); break; // charge.refunded / charge.dispute.created: TODO(build) — reconciliation. } return new \WP_REST_Response(['received' => true], 200); } /** @param array $object */ private function subIdFrom(array $object): int { return (int) ($object['metadata']['abovault_subscription_id'] ?? 0); } /** * setup_intent.succeeded → attach "cus|pm" onto every subscription of the order * so renewals can charge. Mandate is per-order (one SetupIntent covers all its subs). * @param array $si */ private function captureMandate(array $si): void { global $wpdb; $orderId = (int) ($si['metadata']['abovault_order_id'] ?? 0); $customer = (string) ($si['customer'] ?? ''); $pm = (string) ($si['payment_method'] ?? ''); if ($orderId <= 0 || $customer === '' || $pm === '') { return; } // Attach the mandate without touching status (a trial must stay trialing). $wpdb->query($wpdb->prepare( 'UPDATE ' . Subscription::table() . " SET gateway = 'stripe_sepa', gateway_ref = %s WHERE order_id = %d", $customer . '|' . $pm, $orderId )); do_action('abovault_mandate_captured', $orderId, $customer . '|' . $pm); } /** Constant-time Stripe signature verification. */ private function verify(string $payload, string $sigHeader, string $secret): bool { $parts = []; foreach (explode(',', $sigHeader) as $kv) { [$k, $v] = array_pad(explode('=', $kv, 2), 2, ''); $parts[$k] = $v; } $t = $parts['t'] ?? ''; $v1 = $parts['v1'] ?? ''; if ($t === '' || $v1 === '') { return false; } if (abs(time() - (int) $t) > self::TOLERANCE) { return false; } $expected = hash_hmac('sha256', $t . '.' . $payload, $secret); return hash_equals($expected, $v1); } private function webhookSecret(): string { $settings = get_option('woocommerce_abovault_sepa_settings', []); return is_array($settings) ? (string) ($settings['webhook_secret'] ?? '') : ''; } }