[
'name' => 'Noliki Orders',
'singular_name' => 'Noliki Order',
],
'public' => false,
'show_ui' => true,
'supports' => ['title'],
'capability_type' => 'post',
'menu_position' => 25,
'menu_icon' => 'dashicons-cart',
]);
}
public static function register_routes() {
register_rest_route('noliki/v1', '/create-payment', [
'methods' => 'POST',
'callback' => [__CLASS__, 'create_payment'],
'permission_callback' => '__return_true',
]);
register_rest_route('noliki/v1', '/order-status', [
'methods' => 'GET',
'callback' => [__CLASS__, 'order_status'],
'permission_callback' => '__return_true',
'args' => [
'order_id' => ['required' => true],
]
]);
register_rest_route('noliki/v1', '/webhook', [
'methods' => 'POST',
'callback' => [__CLASS__, 'webhook'],
'permission_callback' => '__return_true',
]);
}
private static function cfg($name) {
$const = 'NOLIKI_YOOKASSA_' . strtoupper($name);
return defined($const) ? constant($const) : null;
}
private static function ensure_config_or_error() {
$shop = self::cfg('SHOP_ID');
$key = self::cfg('SECRET_KEY');
if (!$shop || !$key) {
return new WP_Error(
'noliki_no_config',
'YooKassa config missing. Define NOLIKI_YOOKASSA_SHOP_ID and NOLIKI_YOOKASSA_SECRET_KEY in wp-config.php.',
['status' => 500]
);
}
return true;
}
private static function get_client_ip() {
$keys = ['HTTP_CF_CONNECTING_IP','HTTP_X_FORWARDED_FOR','REMOTE_ADDR'];
foreach ($keys as $k) {
if (!empty($_SERVER[$k])) {
$ip = $_SERVER[$k];
if ($k === 'HTTP_X_FORWARDED_FOR') {
$parts = explode(',', $ip);
$ip = trim($parts[0]);
}
return sanitize_text_field($ip);
}
}
return '0.0.0.0';
}
private static function rate_limit_ok() {
$ip = self::get_client_ip();
$bucket = 'noliki_rl_' . md5($ip);
$count = (int) get_transient($bucket);
if ($count >= 25) return false;
set_transient($bucket, $count + 1, 10 * MINUTE_IN_SECONDS);
return true;
}
private static function money_value($number) {
// YooKassa expects string with 2 decimals for amount.value
$v = (float) $number;
return number_format($v, 2, '.', '');
}
private static function clean_phone($phone) {
$p = preg_replace('/\D+/', '', (string) $phone);
// keep leading 7/8 etc as user typed; YooKassa receipts accept phone in +7... often, but we store digits.
return $p;
}
private static function normalize_email($email) {
$e = sanitize_email((string)$email);
return is_email($e) ? $e : '';
}
public static function create_payment(WP_REST_Request $req) {
$cfg_ok = self::ensure_config_or_error();
if (is_wp_error($cfg_ok)) return $cfg_ok;
if (!self::rate_limit_ok()) {
return new WP_Error('noliki_rate_limited', 'Too many attempts. Please try later.', ['status' => 429]);
}
$body = $req->get_json_params();
if (!is_array($body)) $body = [];
$product_name = isset($body['product_name']) ? sanitize_text_field($body['product_name']) : 'Нолики — деревянная игра';
$unit_price = isset($body['unit_price']) ? (float) $body['unit_price'] : 0.0;
$qty = isset($body['qty']) ? (int) $body['qty'] : 1;
$customer_name = isset($body['name']) ? sanitize_text_field($body['name']) : '';
$customer_phone = isset($body['phone']) ? self::clean_phone($body['phone']) : '';
$customer_email = isset($body['email']) ? self::normalize_email($body['email']) : '';
$city = isset($body['city']) ? sanitize_text_field($body['city']) : '';
$address = isset($body['address']) ? sanitize_text_field($body['address']) : '';
$delivery = isset($body['delivery']) ? sanitize_text_field($body['delivery']) : 'not_set';
$agree = !empty($body['agree']);
if (!$agree) {
return new WP_Error('noliki_agree_required', 'Consent checkbox is required.', ['status' => 400]);
}
if ($unit_price <= 0) {
return new WP_Error('noliki_bad_price', 'Invalid unit price.', ['status' => 400]);
}
$qty = max(1, min(99, $qty));
if ($customer_name === '' || $customer_phone === '' || $city === '' || $address === '') {
return new WP_Error('noliki_missing_fields', 'Name, phone, city and address are required.', ['status' => 400]);
}
// For receipts (54-FZ): email or phone is typically needed for sending receipt.
// We'll accept missing email, but recommend collecting it.
// (You can enforce email by changing the condition below.)
// if ($customer_email === '') return new WP_Error('noliki_email_required', 'Email is required.', ['status' => 400]);
$order_id = wp_generate_uuid4();
$amount_total = $unit_price * $qty;
// Create order post
$post_id = wp_insert_post([
'post_type' => self::CPT,
'post_status' => 'publish',
'post_title' => 'Order ' . $order_id,
], true);
if (is_wp_error($post_id)) {
return new WP_Error('noliki_order_create_failed', 'Failed to create order.', ['status' => 500]);
}
update_post_meta($post_id, 'noliki_order_id', $order_id);
update_post_meta($post_id, 'status', 'created');
update_post_meta($post_id, 'product_name', $product_name);
update_post_meta($post_id, 'unit_price', $unit_price);
update_post_meta($post_id, 'qty', $qty);
update_post_meta($post_id, 'amount_total', $amount_total);
update_post_meta($post_id, 'customer_name', $customer_name);
update_post_meta($post_id, 'customer_phone', $customer_phone);
update_post_meta($post_id, 'customer_email', $customer_email);
update_post_meta($post_id, 'city', $city);
update_post_meta($post_id, 'address', $address);
update_post_meta($post_id, 'delivery', $delivery);
// Return URL (user returns here after payment)
$return_url = isset($body['return_url']) ? esc_url_raw($body['return_url']) : '';
if ($return_url === '') {
// fallback: homepage with params
$return_url = add_query_arg(['order_id' => rawurlencode($order_id)], home_url('/thank-you/'));
} else {
$return_url = add_query_arg(['order_id' => rawurlencode($order_id)], $return_url);
}
$idempotence_key = wp_generate_uuid4();
$payload = [
'amount' => [
'value' => self::money_value($amount_total),
'currency' => 'RUB',
],
'capture' => true,
'confirmation' => [
'type' => 'redirect',
'return_url' => $return_url,
],
'description' => sprintf('%s — заказ %s', $product_name, $order_id),
'metadata' => [
'order_id' => $order_id,
'site' => home_url(),
],
];
// Optional: send receipt data (54-FZ). If you enable "receipts" in YooKassa, you typically must pass receipt.
// Configure in wp-config.php:
// define('NOLIKI_YOOKASSA_SEND_RECEIPT', true);
// define('NOLIKI_YOOKASSA_TAX_SYSTEM_CODE', 1);
// define('NOLIKI_YOOKASSA_VAT_CODE', 1);
$send_receipt = defined('NOLIKI_YOOKASSA_SEND_RECEIPT') ? (bool) NOLIKI_YOOKASSA_SEND_RECEIPT : false;
if ($send_receipt) {
$tax_system_code = defined('NOLIKI_YOOKASSA_TAX_SYSTEM_CODE') ? (int) NOLIKI_YOOKASSA_TAX_SYSTEM_CODE : 1;
$vat_code = defined('NOLIKI_YOOKASSA_VAT_CODE') ? (int) NOLIKI_YOOKASSA_VAT_CODE : 1;
$customer = [];
if ($customer_email !== '') $customer['email'] = $customer_email;
if ($customer_phone !== '') $customer['phone'] = $customer_phone;
$payload['receipt'] = [
'customer' => $customer,
'tax_system_code' => $tax_system_code,
'items' => [[
'description' => mb_substr($product_name, 0, 128),
'quantity' => (string) $qty,
'amount' => [
'value' => self::money_value($unit_price),
'currency' => 'RUB',
],
'vat_code' => $vat_code,
]],
];
}
$shop_id = self::cfg('SHOP_ID');
$secret = self::cfg('SECRET_KEY');
$resp = wp_remote_post('https://api.yookassa.ru/v3/payments', [
'timeout' => 30,
'headers' => [
'Content-Type' => 'application/json',
'Idempotence-Key' => $idempotence_key,
'Authorization' => 'Basic ' . base64_encode($shop_id . ':' . $secret),
],
'body' => wp_json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
]);
if (is_wp_error($resp)) {
update_post_meta($post_id, 'status', 'create_payment_failed');
update_post_meta($post_id, 'error', $resp->get_error_message());
return new WP_Error('noliki_yk_request_failed', 'YooKassa request failed.', ['status' => 502]);
}
$code = (int) wp_remote_retrieve_response_code($resp);
$raw = wp_remote_retrieve_body($resp);
$data = json_decode($raw, true);
update_post_meta($post_id, 'yookassa_http_code', $code);
update_post_meta($post_id, 'yookassa_raw', $raw);
if ($code < 200 || $code >= 300 || !is_array($data)) {
update_post_meta($post_id, 'status', 'create_payment_failed');
return new WP_Error('noliki_yk_bad_response', 'YooKassa returned an error.', ['status' => 502, 'details' => $data]);
}
$payment_id = isset($data['id']) ? sanitize_text_field($data['id']) : '';
$status = isset($data['status']) ? sanitize_text_field($data['status']) : '';
$confirmation_url = isset($data['confirmation']['confirmation_url']) ? esc_url_raw($data['confirmation']['confirmation_url']) : '';
update_post_meta($post_id, 'payment_id', $payment_id);
update_post_meta($post_id, 'payment_status', $status);
update_post_meta($post_id, 'status', 'payment_created');
if ($confirmation_url === '') {
return new WP_Error('noliki_no_confirmation_url', 'No confirmation_url in response.', ['status' => 502]);
}
return [
'order_id' => $order_id,
'confirmation_url' => $confirmation_url,
'return_url' => $return_url,
];
}
public static function order_status(WP_REST_Request $req) {
$order_id = sanitize_text_field((string)$req->get_param('order_id'));
if ($order_id === '') {
return new WP_Error('noliki_no_order_id', 'order_id is required.', ['status' => 400]);
}
$q = new WP_Query([
'post_type' => self::CPT,
'posts_per_page' => 1,
'meta_key' => 'noliki_order_id',
'meta_value' => $order_id,
'fields' => 'ids',
]);
if (empty($q->posts)) {
return new WP_Error('noliki_order_not_found', 'Order not found.', ['status' => 404]);
}
$post_id = (int) $q->posts[0];
$status = (string) get_post_meta($post_id, 'status', true);
$payment_status = (string) get_post_meta($post_id, 'payment_status', true);
return [
'order_id' => $order_id,
'status' => $status,
'payment_status' => $payment_status,
];
}
public static function webhook(WP_REST_Request $req) {
$cfg_ok = self::ensure_config_or_error();
if (is_wp_error($cfg_ok)) return $cfg_ok;
$payload = $req->get_json_params();
if (!is_array($payload)) $payload = [];
// YooKassa webhooks include "event" and "object" with payment data
$event = isset($payload['event']) ? sanitize_text_field($payload['event']) : '';
$obj = isset($payload['object']) && is_array($payload['object']) ? $payload['object'] : [];
$payment_id = isset($obj['id']) ? sanitize_text_field($obj['id']) : '';
$payment_status = isset($obj['status']) ? sanitize_text_field($obj['status']) : '';
$meta = isset($obj['metadata']) && is_array($obj['metadata']) ? $obj['metadata'] : [];
$order_id = isset($meta['order_id']) ? sanitize_text_field((string)$meta['order_id']) : '';
if ($order_id === '' && $payment_id !== '') {
// try to find by payment_id
$q = new WP_Query([
'post_type' => self::CPT,
'posts_per_page' => 1,
'meta_key' => 'payment_id',
'meta_value' => $payment_id,
'fields' => 'ids',
]);
if (!empty($q->posts)) {
$post_id = (int)$q->posts[0];
$order_id = (string) get_post_meta($post_id, 'noliki_order_id', true);
}
}
if ($order_id === '') {
return ['ok' => true]; // ignore unknown
}
$q2 = new WP_Query([
'post_type' => self::CPT,
'posts_per_page' => 1,
'meta_key' => 'noliki_order_id',
'meta_value' => $order_id,
'fields' => 'ids',
]);
if (empty($q2->posts)) {
return ['ok' => true];
}
$post_id = (int)$q2->posts[0];
update_post_meta($post_id, 'webhook_event', $event);
update_post_meta($post_id, 'payment_status', $payment_status);
update_post_meta($post_id, 'webhook_received_at', gmdate('c'));
// Mark final states
if ($payment_status === 'succeeded') {
update_post_meta($post_id, 'status', 'paid');
$email = (string) get_post_meta($post_id, 'customer_email', true);
$name = (string) get_post_meta($post_id, 'customer_name', true);
$phone = (string) get_post_meta($post_id, 'customer_phone', true);
$amount = (string) get_post_meta($post_id, 'amount_total', true);
$product = (string) get_post_meta($post_id, 'product_name', true);
$admin_to = get_option('admin_email');
$subject_admin = 'Оплачен заказ Noliki: ' . $order_id;
$msg_admin = "Заказ: {$order_id}\nСтатус: PAID\nТовар: {$product}\nСумма: {$amount} RUB\nИмя: {$name}\nТелефон: {$phone}\nEmail: {$email}\n\nАдминка: " . admin_url('post.php?post=' . $post_id . '&action=edit');
wp_mail($admin_to, $subject_admin, $msg_admin);
if ($email && is_email($email)) {
$subject_user = 'Оплата получена — заказ ' . $order_id;
$msg_user = "Спасибо! Мы получили оплату.\n\nЗаказ: {$order_id}\nТовар: {$product}\nДальше: мы свяжемся с вами по телефону для уточнения доставки.\n\nЕсли вы ошиблись в данных — просто ответьте на это письмо.";
wp_mail($email, $subject_user, $msg_user);
}
} elseif ($payment_status === 'canceled') {
update_post_meta($post_id, 'status', 'canceled');
} else {
update_post_meta($post_id, 'status', 'pending');
}
return ['ok' => true];
}
}
Noliki_YooKassa_Landing::init();