$request_id, ], $success); // Ensure the receipt page can resolve the order. // - Always include ToDownload_order (public id) // - Include a receipt token when we have a valid buyer email (supports guest access) $success = add_query_arg([ 'ToDownload_order' => $order_public_id, ], $success); $receipt_email = $email; if ($receipt_email === '' || !is_email($receipt_email)) { $customer = Orders::get_customer((int) ($order['customer_id'] ?? 0)); $receipt_email = $customer ? sanitize_email((string) ($customer['email'] ?? '')) : ''; } if ($order_public_id !== '' && $receipt_email !== '' && is_email($receipt_email)) { $success = add_query_arg([ 'ToDownload_receipt_token' => Token::receipt_token($order_public_id, (string) $receipt_email), ], $success); } $currency = strtoupper(sanitize_text_field((string) ($order['currency'] ?? 'USD'))); $subtotal = (float) ($order['subtotal'] ?? 0); $tax = (float) ($order['tax'] ?? 0); // Preferred mode: pass a real cart (order items) so hosted can apply product-targeted/cart rules. // Fallback: if tax is present (or cart is unavailable), send an aggregated pseudo-product like before. $cart_items = []; $tax_minor = (int) round(max(0.0, $tax) * 100); if ($tax_minor === 0) { $order_items = Orders::get_order_items((int) $order_id); if (is_array($order_items) && !empty($order_items)) { $seen = []; foreach ($order_items as $it) { $download_id = (int) ($it['download_id'] ?? 0); $price = (float) ($it['price'] ?? 0); $amount_minor_it = (int) round(max(0.0, $price) * 100); if ($download_id <= 0 || $amount_minor_it <= 0) { continue; } if (isset($seen[$download_id])) { // No quantity support in hosted cart yet; fallback to legacy aggregated mode. $cart_items = []; break; } $seen[$download_id] = true; $title = get_the_title($download_id); $name = is_string($title) && trim($title) !== '' ? trim($title) : ('WP Download #' . (string) $download_id); $hosted_product_public_id = trim((string) get_post_meta($download_id, HostedProductsImporter::META_HOSTED_PRODUCT_PUBLIC_ID, true)); if ($hosted_product_public_id !== '') { $cart_items[] = [ 'product_public_id' => $hosted_product_public_id, 'quantity' => 1, ]; } else { $cart_items[] = [ 'product_external_id' => 'wp_download_' . (string) $download_id, 'product_name' => $name, 'currency' => strtolower($currency), 'amount_minor' => (int) $amount_minor_it, 'quantity' => 1, ]; } } } } $amount_pre_discount = max(0.0, $subtotal + $tax); $amount_minor = (int) round($amount_pre_discount * 100); if ($amount_minor <= 0) { // Free order (100% discount). Mark complete immediately. Orders::update_status($order_id, 'complete', 'free_' . $order_id); do_action('ToDownload_after_order_complete', $order_id); return $success; } // Ensure the success URL includes the {CHECKOUT_SESSION_ID} token. // (This can still be your page URL; we don't rely on it for fulfillment.) if (strpos($success, '{CHECKOUT_SESSION_ID}') === false) { $sep = (strpos($success, '?') !== false) ? '&' : '?'; $success .= $sep . 'session_id={CHECKOUT_SESSION_ID}'; } $store_domain = (string) wp_parse_url(home_url('/'), PHP_URL_HOST); $store_domain = strtolower(trim($store_domain)); self::log_structured('info', 'checkout_create_start', [ 'request_id' => $request_id, 'order_id' => (int) $order_id, 'order_public_id' => $order_public_id, 'mode' => 'payment', 'store_domain' => $store_domain, 'amount_minor' => (int) $amount_minor, 'currency' => strtolower($currency), ]); $seller_user_id = (int) ($order['seller_user_id'] ?? 0); $hosted = Sellers::hosted_credentials($seller_user_id); $hosted_base = (string) ($hosted['hosted_base'] ?? ((string) ($settings['hosted_api_base_url'] ?? 'https://2dl.app'))); $hosted_base = $hosted_base !== '' ? rtrim($hosted_base, '/') : 'https://2dl.app'; $hosted_store = trim((string) ($hosted['store_public_id'] ?? '')); $hosted_key = trim((string) ($hosted['api_key'] ?? '')); if ($hosted_store === '' || $hosted_key === '') { Orders::append_order_note($order_id, 'ToDownload_hosted_platform_error: missing hosted store credentials'); return $this->with_plan_error($cancel, __('Hosted checkout is not configured for this seller. Please have the seller complete onboarding (connect store) before accepting payments.', '2download-connector')); } $payload = [ 'store_public_id' => $hosted_store, 'success_url' => $success, 'cancel_url' => $cancel, 'buyer_email' => ($email !== '' && is_email($email)) ? $email : null, 'order_external_id' => $order_public_id !== '' ? $order_public_id : (string) (int) $order_id, 'client_reference_id' => $order_public_id !== '' ? $order_public_id : (string) (int) $order_id, ]; if (!empty($cart_items)) { $payload['cart_items'] = $cart_items; } else { // Fallback mode: send an aggregated pseudo-product (legacy behavior). $product_external_id = 'wp_order_' . ($order_public_id !== '' ? $order_public_id : (string) (int) $order_id); $order_ref = $order_public_id !== '' ? substr($order_public_id, -8) : ''; /* translators: %s = order reference (last 8 characters of the public id) */ $product_name = ($order_ref !== '' ? sprintf(__('2DL Order %s', '2download-connector'), $order_ref) : __('2DL Order', '2download-connector')); $payload['product_external_id'] = $product_external_id; $payload['product_name'] = $product_name; $payload['currency'] = strtolower($currency); $payload['amount_minor'] = (int) $amount_minor; } // If a discount code exists on the order notes, pass it through so hosted can price correctly. $notes = is_string($order['notes'] ?? null) ? (string) $order['notes'] : ''; if ($notes !== '' && preg_match('/ToDownload_discount_code:\s*([A-Za-z0-9_-]{1,64})/', $notes, $m)) { $discount_code = strtoupper(trim((string) ($m[1] ?? ''))); if ($discount_code !== '') { $payload['discount_code'] = $discount_code; } } $res = HostedClient::post_json($hosted_base, '/api/v1/checkout/sessions', $payload, $hosted_key, $request_id); if (empty($res['ok'])) { $err = isset($res['error']) && is_array($res['error']) ? (array) $res['error'] : []; $code = isset($err['code']) ? (string) $err['code'] : ''; $status = isset($res['status']) ? (int) $res['status'] : 0; $msg = (string) ($res['message'] ?? ($err['message'] ?? 'unknown')); Orders::append_order_note($order_id, 'ToDownload_hosted_platform_error: ' . $msg); self::log_structured('error', 'hosted_checkout_create_failed', [ 'request_id' => $request_id, 'order_id' => (int) $order_id, 'order_public_id' => $order_public_id, 'store_domain' => $store_domain, 'error' => $msg, 'status' => $status, 'code' => $code, ]); if ($status === 401 || $status === 403 || $code === 'API_KEY_INVALID') { return $this->with_plan_error($cancel, __('Hosted connection is not authorized. Please check Store API key.', '2download-connector')); } if ( $status >= 500 || $status === 0 || $code === 'HOSTED_TIMEOUT' || $code === 'HOSTED_BAD_RESPONSE' || $code === 'HOSTED_REQUEST_FAILED' ) { return $this->with_plan_error($cancel, __('2dl.app is temporarily unavailable. Please try again.', '2download-connector')); } return $this->with_plan_error($cancel, __('Hosted checkout failed: ', '2download-connector') . $msg); } $data = is_array($res['data'] ?? null) ? (array) $res['data'] : []; $session_id = isset($data['session_id']) ? (string) $data['session_id'] : ''; $url = isset($data['checkout_url']) ? (string) $data['checkout_url'] : ''; if ($session_id !== '') { Orders::update_status($order_id, 'pending', $session_id); Orders::append_order_note($order_id, 'ToDownload_checkout_session_id: ' . $session_id); Orders::append_order_note($order_id, 'ToDownload_request_id: ' . $request_id); self::log_structured('info', 'hosted_checkout_create_ok', [ 'request_id' => $request_id, 'order_id' => (int) $order_id, 'order_public_id' => $order_public_id, 'store_domain' => $store_domain, 'session_id' => $session_id, ]); } return $url !== '' ? $url : $cancel; } public function handle_webhook(): void { // Webhooks are handled centrally by 2dl.app. status_header(410); echo 'gone'; exit; } /** * Best-effort status sync using the hosted platform (2dl.app). * * @return array{ok:bool,message?:string} */ public function sync_order_from_checkout_session(string $session_id, int $order_id): array { $session_id = trim((string) $session_id); $order_id = (int) $order_id; if ($order_id <= 0) { return ['ok' => false, 'message' => 'missing order_id']; } if ($session_id === '' || strpos($session_id, 'cs_') !== 0) { return ['ok' => false, 'message' => 'invalid session id']; } $order = Orders::get_order($order_id); if (!$order) { return ['ok' => false, 'message' => 'order not found']; } $seller_user_id = (int) ($order['seller_user_id'] ?? 0); $status_now = (string) ($order['status'] ?? ''); if ($status_now === 'complete') { return ['ok' => true]; } $hosted = Sellers::hosted_credentials($seller_user_id); $hosted_base = trim((string) ($hosted['hosted_base'] ?? 'https://2dl.app')); $hosted_base = $hosted_base !== '' ? rtrim($hosted_base, '/') : 'https://2dl.app'; $hosted_store = trim((string) ($hosted['store_public_id'] ?? '')); $hosted_key = trim((string) ($hosted['api_key'] ?? '')); if (empty($hosted['connected']) || $hosted_store === '' || $hosted_key === '') { return ['ok' => false, 'message' => 'hosted platform not configured']; } $request_id = self::get_request_id(); $res = HostedClient::post_json( $hosted_base, '/api/v1/entitlements/by-session', [ 'store_public_id' => $hosted_store, 'session_id' => $session_id, ], $hosted_key, $request_id ); if (empty($res['ok']) || empty($res['data']) || !is_array($res['data'])) { $err = isset($res['error']) && is_array($res['error']) ? (array) $res['error'] : []; $msg = isset($err['message']) ? (string) $err['message'] : ''; if ($msg === '' && !empty($res['message'])) { $msg = (string) $res['message']; } return ['ok' => false, 'message' => $msg !== '' ? $msg : 'hosted lookup failed']; } $data = (array) $res['data']; $hosted_status = isset($data['status']) ? (string) $data['status'] : ''; if ($hosted_status === 'paid') { Orders::update_status($order_id, 'complete', $session_id); Orders::append_order_note($order_id, 'ToDownload_hosted_sync: entitlements/by-session paid'); do_action('ToDownload_after_order_complete', $order_id); return ['ok' => true]; } if ($hosted_status === 'refunded') { Orders::update_status($order_id, 'refunded'); Orders::append_order_note($order_id, 'ToDownload_hosted_sync: entitlements/by-session refunded'); return ['ok' => true]; } if ($hosted_status === 'disputed') { Orders::update_status($order_id, 'disputed'); Orders::append_order_note($order_id, 'ToDownload_hosted_sync: entitlements/by-session disputed'); return ['ok' => true]; } if ($hosted_status === 'failed') { Orders::update_status($order_id, 'failed'); Orders::append_order_note($order_id, 'ToDownload_hosted_sync: entitlements/by-session failed'); return ['ok' => true]; } // Pending/failed/etc: no local status change. return ['ok' => true]; } private static function get_request_id(): string { $request_id = ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only diagnostic/correlation id. if (isset($_GET['ToDownload_request_id'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only diagnostic/correlation id. $request_id = sanitize_text_field((string) wp_unslash($_GET['ToDownload_request_id'])); } $request_id = trim((string) $request_id); if ($request_id !== '') { return $request_id; } return function_exists('wp_generate_uuid4') ? (string) wp_generate_uuid4() : (string) time(); } /** * @param array $fields */ private static function log_structured(string $level, string $event, array $fields): void { $level = strtolower(trim((string) $level)); $event = trim((string) $event); if ($event === '') { return; } } private function with_plan_error(string $url, string $message): string { $url = (string) $url; $message = trim((string) $message); if ($url === '' || $message === '') { return $url; } // Normalize to our dashboard params (safe even for non-dashboard URLs). $url = remove_query_arg(['ToDownload_plan_notice', 'ToDownload_plan_error'], $url); return (string) add_query_arg([ 'ToDownload_plan_error' => rawurlencode($message), ], $url); } }