Не вакансия

// Финализация платежа после 3DS/redirect: пробуем сделать capture
if ($request == 'finalize_payment') {
yk_ensure_payments_log_table();
$userId = is_array($user) ? ($user['id'] ?? null) : (is_object($user) ? ($user->id ?? null) : null);
if (!$userId) {
echo json_encode(['success' => 0, 'error' => 'Unauthorized']);
exit;
}
$paymentId = isset($data['payment_id']) ? (string)$data['payment_id'] : '';
$amountRaw = isset($data['amount']) ? (string)$data['amount'] : '';
$amount = preg_replace('/[^0-9\.-]/', '', $amountRaw);
if ($amount === '' || !is_numeric($amount)) { $amount = '0'; }
$amount = number_format((float)$amount, 2, '.', '');
if ($paymentId === '') {
echo json_encode(['success' => 0, 'error' => 'payment_id required']);
exit;
}
// В песочнице часто включён автокапчер. Финализация: опросить статус платежа до успеха или таймаута.
$attempts = YK_FINALIZE_ATTEMPTS; // параметризовано
for ($i = 0; $i < $attempts; $i++) {
$http = 0; $obj = null;
yk_curl_with_retries(
'https://api.yookassa.ru/v3/payments/' . $paymentId,
[
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [ 'Content-Type: application/json' ],
CURLOPT_USERPWD => YOOKASSA_SHOP_ID . ':' . YOOKASSA_SECRET_KEY,
],
$http,
$obj
);
if ($http == 200 && isset($obj['status'])) {
if ($obj['status'] === 'succeeded' && !empty($obj['paid'])) {
yk_log('finalize_payment: succeeded', ['payment_id' => $paymentId]);
try {
$row = R::findOne('payments_log', ' payment_id = ? ', [ $paymentId ]);
if (!$row) { $row = R::dispense('payments_log'); $row->payment_id = $paymentId; $row->created_at = date('Y-m-d H:i:s'); }
$row->status = 'succeeded';
$row->meta = json_encode(['stage' => 'finalize_success', 'response' => $obj], JSON_UNESCAPED_UNICODE);
$row->updated_at = date('Y-m-d H:i:s');
R::store($row);
} catch (Throwable $e) {}
echo json_encode([
'success' => 1,
'payment_id' => $paymentId,
'status' => 'succeeded',
'paid' => true,
]);
exit;
}
if ($obj['status'] === 'canceled' || $obj['status'] === 'expired') {
yk_log('finalize_payment: terminal failure', ['status' => $obj['status'], 'payment_id' => $paymentId]);
echo json_encode([
'success' => 0,
'error' => 'payment ' . $obj['status'],
'status' => $obj['status'],
]);
exit;
}
}
usleep(YK_FINALIZE_SLEEP_MS);
}
yk_log('finalize_payment: timeout pending', ['payment_id' => $paymentId]);
echo json_encode([
'success' => 0,
'error' => 'payment not completed yet',
'status' => 'pending',
]);
exit;
}
echo json_encode(['success' => 0, 'error' => 'Неизвестный запрос для YooKassa']);
exit;

Не вакансия

CURLOPT_USERPWD => YOOKASSA_SHOP_ID . ':' . YOOKASSA_SECRET_KEY,
];
$http2 = 0; $body2 = null;
yk_curl_with_retries('https://api.yookassa.ru/v3/payments', $opts2, $http2, $body2);
yk_log('create_sbp_payment_if_saved:fallback_after_invalid_saved', ['http' => $http2, 'body' => $body2, 'user_id' => $userId]);
if ($http2 == 200 && is_array($body2) && isset($body2['id'])) {
$paymentId = (string)$body2['id'];
$confirmUrl = $body2['confirmation']['confirmation_url'] ?? null;
$status = (string)($body2['status'] ?? '');
$paid = !empty($body2['paid']);
echo json_encode([
'success' => 1,
'payment_id' => $paymentId,
'status' => $status,
'paid' => $paid ? 1 : 0,
'url' => $confirmUrl,
]);
exit;
}
echo json_encode(['success' => 0, 'error' => $body2['description'] ?? 'sbp fallback payment failed', 'debug' => [ 'http' => $http2, 'body' => $body2 ]]);
exit;
}
echo json_encode(['success' => 0, 'error' => $body['description'] ?? 'sbp saved payment failed', 'debug' => [ 'http' => $http, 'body' => $body ]]);
exit;
}

// Фолбэк: обычный redirect‑сценарий (как create_sbp_payment)
$bankId = isset($data['bank_id']) ? (string)$data['bank_id'] : '';
$paymentData = [
'amount' => [ 'value' => $amount, 'currency' => 'RUB' ],
'payment_method_data' => [ 'type' => 'sbp' ],
'confirmation' => [ 'type' => 'redirect', 'return_url' => YOOKASSA_RETURN_URL ],
'capture' => true,
'metadata' => [ 'user_id' => (string)$userId, 'action' => 'sbp_pay_fallback' ],
];
if ($bankId !== '') { $paymentData['payment_method_data']['bank_id'] = $bankId; }
$opts2 = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($paymentData),
CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'Idempotence-Key: ' . $idemp ],
CURLOPT_USERPWD => YOOKASSA_SHOP_ID . ':' . YOOKASSA_SECRET_KEY,
];
$http2 = 0; $body2 = null;
yk_curl_with_retries('https://api.yookassa.ru/v3/payments', $opts2, $http2, $body2);
yk_log('create_sbp_payment_if_saved:fallback', ['http' => $http2, 'body' => $body2, 'user_id' => $userId]);
if ($http2 == 200 && is_array($body2) && isset($body2['id'])) {
$paymentId = (string)$body2['id'];
$confirmUrl = $body2['confirmation']['confirmation_url'] ?? null;
$status = (string)($body2['status'] ?? '');
$paid = !empty($body2['paid']);
echo json_encode([
'success' => 1,
'payment_id' => $paymentId,
'status' => $status,
'paid' => $paid ? 1 : 0,
'url' => $confirmUrl,
]);
exit;
}
echo json_encode(['success' => 0, 'error' => $body2['description'] ?? 'sbp fallback payment failed', 'debug' => [ 'http' => $http2, 'body' => $body2 ]]);
exit;
}

Не вакансия

$pmRequested = isset($data['pm_id']) ? trim((string)$data['pm_id']) : '';
$pmIdToUse = '';
if (yk_table_exists('userpaymentmethods')) {
$pm = null;
if ($pmRequested !== '') {
try { $pm = R::findOne('userpaymentmethods', ' clients_id = ? AND provider = ? AND type = ? AND pm_id = ? AND revoked = 0 ', [ (int)$userId, 'yookassa', 'sbp', $pmRequested ]); } catch (Throwable $e) {}
}
if (!$pm) {
try { $pm = R::findOne('userpaymentmethods', ' clients_id = ? AND provider = ? AND type = ? AND revoked = 0 ORDER BY id DESC ', [ (int)$userId, 'yookassa', 'sbp' ]); } catch (Throwable $e) {}
}
if ($pm) { $pmIdToUse = (string)$pm->pm_id; }
}
// Без фолбэков: используем только user_payment_methods
if ($pmIdToUse !== '') {
// Без редиректа
$paymentData = [
'amount' => [ 'value' => $amount, 'currency' => 'RUB' ],
'payment_method_id' => $pmIdToUse,
'capture' => true,
'metadata' => [ 'user_id' => (string)$userId, 'action' => 'sbp_pay_saved' ],
];
$opts = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($paymentData),
CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'Idempotence-Key: ' . $idemp ],
CURLOPT_USERPWD => YOOKASSA_SHOP_ID . ':' . YOOKASSA_SECRET_KEY,
];
$http = 0; $body = null;
yk_curl_with_retries('https://api.yookassa.ru/v3/payments', $opts, $http, $body);
yk_log('create_sbp_payment_if_saved:pm', ['http' => $http, 'body' => $body, 'user_id' => $userId]);
if ($http == 200 && is_array($body) && isset($body['id'])) {
$status = (string)($body['status'] ?? '');
$paid = !empty($body['paid']);
echo json_encode(['success' => 1, 'payment_id' => (string)$body['id'], 'status' => $status, 'paid' => $paid ? 1 : 0]);
exit;
}
// Если метод не сохранён у ЮKassa, выполняем фолбэк с редиректом
$needFallback = false;
if (is_array($body)) {
$code = (string)($body['code'] ?? ($body['body']['code'] ?? ''));
$param = (string)($body['parameter'] ?? ($body['body']['parameter'] ?? ''));
$desc = (string)($body['description'] ?? ($body['body']['description'] ?? ''));
if (strtolower($code) === 'invalid_request' && strtolower($param) === 'payment_method_id') {
$needFallback = true;
} else if (stripos($desc, 'not saved') !== false) {
$needFallback = true;
}
}
if ($needFallback) {
// Пытаемся взять bank_id из запроса или из сохранённой записи привязки
$bankId = isset($data['bank_id']) ? (string)$data['bank_id'] : '';
if ($bankId === '') {
try {
$row = R::findOne('userpaymentmethods', ' clients_id = ? AND provider = ? AND type = ? AND pm_id = ? AND revoked = 0 ', [ (int)$userId, 'yookassa', 'sbp', $pmIdToUse ]);
if ($row && !empty($row->bank_id)) { $bankId = (string)$row->bank_id; }
} catch (Throwable $e) { /* ignore */ }
}
$paymentData = [
'amount' => [ 'value' => $amount, 'currency' => 'RUB' ],
'payment_method_data' => [ 'type' => 'sbp' ],
'confirmation' => [ 'type' => 'redirect', 'return_url' => YOOKASSA_RETURN_URL ],
'capture' => true,
'metadata' => [ 'user_id' => (string)$userId, 'action' => 'sbp_pay_fallback_after_invalid_saved' ],
];
if ($bankId !== '') { $paymentData['payment_method_data']['bank_id'] = $bankId; }
$opts2 = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($paymentData),
CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'Idempotence-Key: ' . $idemp ],
$idemp = isset($data['idempotency_key']) ? (string)$data['idempotency_key'] : null;
if (!$idemp) { $idemp = 'sbp_saved_' . $userId . '_' . $amount . '_' . date('YmdHi'); }

Не вакансия

// Возвращает, есть ли у пользователя активная привязка СБП
if ($request == 'has_sbp_binding') {
yk_ensure_user_payment_methods_table();
yk_migrate_sbp_bindings_to_upm((int)$userId);
$userId = is_array($user) ? ($user['id'] ?? null) : (is_object($user) ? ($user->id ?? null) : null);
if (!$userId) { echo json_encode(['success' => 0, 'error' => 'Unauthorized']); exit; }
$pm = R::findOne('userpaymentmethods', ' clients_id = ? AND provider = ? AND type = ? AND revoked = 0 ORDER BY id DESC ', [ (int)$userId, 'yookassa', 'sbp' ]);
if ($pm) {
echo json_encode(['success' => 1, 'has' => 1, 'pm_id' => (string)$pm->pm_id, 'bank_name' => (string)$pm->bank_name, 'bank_id' => (string)$pm->bank_id]);
} else {
echo json_encode(['success' => 1, 'has' => 0]);
}
exit;
}

// Список всех привязанных СБП‑методов пользователя
if ($request == 'list_sbp_bindings') {
yk_ensure_user_payment_methods_table();
yk_migrate_sbp_bindings_to_upm((int)$userId);
$userId = is_array($user) ? ($user['id'] ?? null) : (is_object($user) ? ($user->id ?? null) : null);
if (!$userId) { echo json_encode(['success' => 0, 'error' => 'Unauthorized']); exit; }
$methods = [];
try {
$rows = R::findAll('userpaymentmethods', ' clients_id = ? AND provider = ? AND type = ? AND revoked = 0 ORDER BY id DESC ', [ (int)$userId, 'yookassa', 'sbp' ]);
foreach ($rows as $r) {
$bankName = (string)($r->bank_name ?? '');
$alias = (string)($r->alias ?? '');
$pmid = (string)$r->pm_id;
$tail = strlen($pmid) > 6 ? substr($pmid, -6) : $pmid;
$bnorm = mb_strtolower(trim($bankName), 'UTF-8');
$isGeneric = ($bnorm === '' || in_array($bnorm, ['сбп','sbp','система быстрых платежей'], true));
$display = $alias !== '' ? $alias : ($isGeneric ? ('СБП •••' . $tail) : $bankName);
$methods[] = [
'pm_id' => $pmid,
'bank_name' => $bankName,
'bank_id' => (string)($r->bank_id ?? ''),
'display_name' => $display,
'created_at' => (string)($r->created_at ?? ''),
];
}
} catch (Throwable $e) { /* ignore */ }
echo json_encode(['success' => 1, 'methods' => $methods]);
exit;
}

// Удаление (деактивация) привязки СБП
if ($request == 'delete_sbp_binding') {
$userId = is_array($user) ? ($user['id'] ?? null) : (is_object($user) ? ($user->id ?? null) : null);
if (!$userId) { echo json_encode(['success' => 0, 'error' => 'Unauthorized']); exit; }
$pmId = isset($data['pm_id']) ? trim((string)$data['pm_id']) : '';
if ($pmId === '') { echo json_encode(['success' => 0, 'error' => 'pm_id required']); exit; }
try {
$row = R::findOne('userpaymentmethods', ' clients_id = ? AND provider = ? AND type = ? AND pm_id = ? AND revoked = 0 ', [ (int)$userId, 'yookassa', 'sbp', $pmId ]);
if (!$row) { echo json_encode(['success' => 0, 'error' => 'binding not found']); exit; }
$row->revoked = 1; R::store($row);
echo json_encode(['success' => 1]);
exit;
} catch (Throwable $e) {
echo json_encode(['success' => 0, 'error' => 'delete failed']);
exit;
}
}

// Создать платёж по привязанному СБП-методу без редиректа (если есть), иначе — обычный redirect
if ($request == 'create_sbp_payment_if_saved') {
yk_ensure_user_payment_methods_table();
$userId = is_array($user) ? ($user['id'] ?? null) : (is_object($user) ? ($user->id ?? null) : null);
if (!$userId) { echo json_encode(['success' => 0, 'error' => 'Unauthorized']); exit; }
$amountRaw = isset($data['amount']) ? (string)$data['amount'] : '';
$amount = preg_replace('/[^0-9\.-]/', '', $amountRaw);
if ($amount === '' || !is_numeric($amount)) { $amount = '0'; }
$amount = number_format((float)$amount, 2, '.', '');
if ((float)$amount <= 0) { echo json_encode(['success' => 0, 'error' => 'amount must be > 0']); exit; }
$http = 0; $body = null;
$opts = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [ 'Content-Type: application/json' ],
CURLOPT_USERPWD => YOOKASSA_SHOP_ID . ':' . YOOKASSA_SECRET_KEY,
];
yk_curl_with_retries('https://api.yookassa.ru/v3/payments/' . urlencode($paymentId), $opts, $http, $body, 'GET');
yk_log('finalize_sbp_bind:fetch', ['http' => $http, 'body' => $body, 'user_id' => $userId]);
if ($http != 200 || !is_array($body)) { echo json_encode(['success' => 0, 'error' => 'fetch payment failed']); exit; }

$status = (string)($body['status'] ?? '');
$paid = !empty($body['paid']);
$pm = isset($body['payment_method']) && is_array($body['payment_method']) ? $body['payment_method'] : null;
$pmId = $pm['id'] ?? null;
$pmType = $pm['type'] ?? null;
$bankIdFromPayment = (string)($pm['bank_id'] ?? '');
if ($bankIdFromPayment === '' && isset($body['metadata']['bank_id'])) {
$bankIdFromPayment = (string)$body['metadata']['bank_id'];
}
if ($bankIdFromPayment === '' && $bankIdOverride !== '') {
$bankIdFromPayment = $bankIdOverride;
}
$titleFromPaymentRaw = (string)($pm['title'] ?? '');
$titleFromPaymentNorm = mb_strtolower(trim($titleFromPaymentRaw), 'UTF-8');
$isGenericTitle = in_array($titleFromPaymentNorm, ['сбп','sbp','система быстрых платежей','system for fast payments'], true);
$titleFromPayment = $isGenericTitle ? '' : $titleFromPaymentRaw;
if ($titleFromPayment === '' && $bankHost !== '') {
// Простая эвристика: маппинг домена на человеко‑читаемое название
$map = [
'www.tbank.ru' => 'Т‑Банк', 'tbank.ru' => 'Т‑Банк', 'www.tinkoff.ru' => 'Т‑Банк', 'tinkoff.ru' => 'Т‑Банк',
'www.sberbank.ru' => 'Сбербанк', 'sberbank.ru' => 'Сбербанк',
'www.vtb.ru' => 'ВТБ', 'vtb.ru' => 'ВТБ',
];
if (isset($map[$bankHost])) { $titleFromPayment = $map[$bankHost]; }
}
$resolvedBankName = $titleFromPayment;
if ($resolvedBankName === '' && !empty($bankIdFromPayment)) {
$resolvedBankName = yk_sbp_bank_name_by_id($bankIdFromPayment);
}
// Новый сценарий ЮKassa: привязка может не списывать 1 ₽ (paid=0, status=pending/waiting_for_capture)
// Считаем привязку успешной, если получили payment_method.id типа 'sbp' и статус НЕ canceled
if (!$pmId $pmType !== 'sbp' $status === 'canceled') {
echo json_encode(['success' => 0, 'error' => 'binding not completed']);
exit;
}

// Сохраняем метод только в user_payment_methods
// upsert по pm_id — если запись есть и в ней "СБП", а у нас есть более ясное имя, обновляем
$row = R::findOne('userpaymentmethods', ' clients_id = ? AND provider = ? AND type = ? AND pm_id = ? ', [ (int)$userId, 'yookassa', 'sbp', (string)$pmId ]);
if (!$row) { $row = R::dispense('userpaymentmethods'); $row->clients_id = (int)$userId; $row->provider = 'yookassa'; $row->type = 'sbp'; $row->pm_id = (string)$pmId; if (empty($row->created_at)) { $row->created_at = date('Y-m-d H:i:s'); } $row->revoked = 0; }
$newName = $resolvedBankName !== '' ? $resolvedBankName : 'СБП';
$oldNameNorm = isset($row->bank_name) ? mb_strtolower(trim((string)$row->bank_name), 'UTF-8') : '';
$shouldUpdateName = ($oldNameNorm === '' || in_array($oldNameNorm, ['сбп','sbp','система быстрых платежей'], true));
if ($shouldUpdateName) { $row->bank_name = $newName; }
if (!empty($bankIdFromPayment)) { $row->bank_id = $bankIdFromPayment; }
try { R::store($row); } catch (Throwable $e) { /* ignore */ }

// После сохранения пробуем мигрировать возможные фолбэк‑записи в user_payment_methods
yk_migrate_sbp_bindings_to_upm((int)$userId);
echo json_encode(['success' => 1, 'payment_method_id' => $pmId]);
exit;
}

Не вакансия

// Проверка статуса платежа (в т.ч. СБП)
if ($request == 'check_sbp_status') {
$paymentId = isset($data['payment_id']) ? trim((string)$data['payment_id']) : '';
$bankIdOverride = isset($data['bank_id']) ? (string)$data['bank_id'] : '';
$bankHost = isset($data['bank_host']) ? (string)$data['bank_host'] : '';
if ($paymentId === '') {
echo json_encode(['success' => 0, 'error' => 'payment_id required']);
exit;
}
$http = 0; $body = null;
$opts = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [ 'Content-Type: application/json' ],
CURLOPT_USERPWD => YOOKASSA_SHOP_ID . ':' . YOOKASSA_SECRET_KEY,
];
yk_curl_with_retries('https://api.yookassa.ru/v3/payments/' . urlencode($paymentId), $opts, $http, $body, 'GET');
yk_log('check_sbp_status', ['http' => $http, 'body' => $body, 'payment_id' => $paymentId]);
if ($http == 200 && is_array($body)) {
$status = (string)($body['status'] ?? '');
$paid = !empty($body['paid']);
echo json_encode(['success' => 1, 'status' => $status, 'paid' => $paid ? 1 : 0, 'raw' => $body]);
exit;
}
echo json_encode(['success' => 0, 'error' => 'status fetch failed', 'debug' => [ 'http' => $http, 'body' => $body ]]);
exit;
}

// ===================== СБП: ПРИВЯЗКА (1 ₽) =====================
// Инициирует платёж на 1.00 RUB по СБП с сохранением способа оплаты (мандат)
if ($request == 'sbp_bind') {
// Требуется аутентифицированный пользователь
$userId = is_array($user) ? ($user['id'] ?? null) : (is_object($user) ? ($user->id ?? null) : null);
if (!$userId) { echo json_encode(['success' => 0, 'error' => 'Unauthorized']); exit; }

$bankId = isset($data['bank_id']) ? (string)$data['bank_id'] : '';
$idemp = isset($data['idempotency_key']) ? (string)$data['idempotency_key'] : null;
if (!$idemp) {
$minute = date('YmdHi');
$idemp = 'sbp_bind_' . $userId . '_' . $minute;
}

$amount = '1.00';
$paymentData = [
'amount' => [ 'value' => $amount, 'currency' => 'RUB' ],
'payment_method_data' => [ 'type' => 'sbp' ],
'save_payment_method' => true,
'confirmation' => [ 'type' => 'redirect', 'return_url' => YOOKASSA_RETURN_URL ],
'capture' => true,
'description' => 'СБП привязка 1 ₽',
'metadata' => [ 'user_id' => (string)$userId, 'action' => 'sbp_bind', 'bank_id' => (string)$bankId ],
];
if ($bankId !== '') { $paymentData['payment_method_data']['bank_id'] = $bankId; }

$opts = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($paymentData),
CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'Idempotence-Key: ' . $idemp ],
CURLOPT_USERPWD => YOOKASSA_SHOP_ID . ':' . YOOKASSA_SECRET_KEY,
];
$http = 0; $body = null;
yk_curl_with_retries('https://api.yookassa.ru/v3/payments', $opts, $http, $body);
yk_log('sbp_bind:create', ['http' => $http, 'body' => $body, 'user_id' => $userId]);
if ($http == 200 && is_array($body) && isset($body['id'])) {
$pid = (string)$body['id'];
$url = $body['confirmation']['confirmation_url'] ?? null;
echo json_encode(['success' => 1, 'payment_id' => $pid, 'url' => $url]);
exit;
}
echo json_encode(['success' => 0, 'error' => $body['description'] ?? 'sbp_bind failed', 'debug' => [ 'http' => $http, 'body' => $body ]]);
exit;
}

// Завершает привязку: проверяет платёж, сохраняет payment_method.id (type=sbp) у пользователя
if ($request == 'finalize_sbp_bind') {
yk_ensure_user_payment_methods_table();
$userId = is_array($user) ? ($user['id'] ?? null) : (is_object($user) ? ($user->id ?? null) : null);
if (!$userId) { echo json_encode(['success' => 0, 'error' => 'Unauthorized']); exit; }
$paymentId = isset($data['payment_id']) ? trim((string)$data['payment_id']) : '';
if ($paymentId === '') { echo json_encode(['success' => 0, 'error' => 'payment_id required']); exit; }
// Возвращает автообновляемый каталог банков СБП (кеш на 24 часа)
if ($request == 'get_sbp_banks') {
try {
$cacheKey = 'sbp_banks_cache_json';
$cacheTsKey = 'sbp_banks_cache_updated_at';
$now = time();
$maxAge = 24 * 3600;

// Примитивный кеш на базе таблицы options (или аналогичной)
$cached = null; $updatedAt = 0;
if (R::inspect('options')) {
$row = R::findOne('options', ' name = ? ', [ $cacheKey ]);
if ($row) {
$cached = (string)$row->value;
}
$rowTs = R::findOne('options', ' name = ? ', [ $cacheTsKey ]);
if ($rowTs) { $updatedAt = (int)$rowTs->value; }
}
$needRefresh = ($cached === null || ($now - (int)$updatedAt) > $maxAge);

if ($needRefresh) {
// Источник: публичный каталог банков СБП/НСПК
$http = 0; $body = null;
yk_curl_with_retries('https://qr.nspk.ru/proxyapp/c2bmembers.json', [ CURLOPT_RETURNTRANSFER => true ], $http, $body);
if ($http == 200 && is_array($body) && !empty($body)) {
$json = json_encode($body, JSON_UNESCAPED_UNICODE);
if (R::inspect('options')) {
$row = R::findOne('options', ' name = ? ', [ $cacheKey ]);
if (!$row) { $row = R::dispense('options'); $row->name = $cacheKey; }
$row->value = $json; R::store($row);
$rowTs = R::findOne('options', ' name = ? ', [ $cacheTsKey ]);
if (!$rowTs) { $rowTs = R::dispense('options'); $rowTs->name = $cacheTsKey; }
$rowTs->value = (string)$now; R::store($rowTs);
}
$cached = $json;
}
}

$banks = [];
if ($cached) {
$arr = json_decode($cached, true);
if (is_array($arr)) {
// NSPK формат: { "members": [ {"bankName": ..., "schema": {"ios": "scheme"}, "appStore": "...", "bankId": "..."}, ... ] }
$list = isset($arr['members']) && is_array($arr['members']) ? $arr['members'] : (is_array($arr) ? $arr : []);
foreach ($list as $it) {
$name = $it['name'] ?? ($it['bankName'] ?? ($it['bank_name'] ?? null));
if (!$name) continue;
$scheme = '';
if (isset($it['schema'])) {
if (is_array($it['schema'])) {
$scheme = $it['schema']['ios'] ?? $it['schema']['iosScheme'] ?? $it['schema']['scheme'] ?? '';
} else {
$scheme = (string)$it['schema'];
}
} else {
$scheme = $it['ios_scheme'] ?? $it['iosScheme'] ?? $it['deeplink_scheme'] ?? '';
}
$store = $it['appStore'] ?? $it['ios_store'] ?? $it['iosStoreUrl'] ?? ($it['store'] ?? '');
$bankId = $it['bankId'] ?? $it['bank_id'] ?? $it['id'] ?? '';
$banks[] = [ 'name' => (string)$name, 'scheme' => (string)$scheme, 'store' => (string)$store, 'bank_id' => (string)$bankId ];
}
}
}

if (empty($banks)) {
// Фолбэк-минимум
$banks = [
[ 'name' => 'Сбербанк', 'scheme' => 'sberbankonline', 'store' => 'https://apps.apple.com/ru/app/id492617855' ],
[ 'name' => 'Тинькофф', 'scheme' => 'tinkoff', 'store' => 'https://apps.apple.com/ru/app/id490461369' ],
];
}

echo json_encode(['success' => 1, 'banks' => $banks]);
exit;
} catch (Throwable $e) {
echo json_encode(['success' => 0, 'error' => 'sbp banks fetch failed']);
exit;
}
}
// Формируем запрос к YooKassa на СБП-платёж
// confirmation.type=redirect — вернёт confirmation_url (универсальная ссылка NSPK)
$paymentData = [
'amount' => [ 'value' => $amount, 'currency' => 'RUB' ],
'payment_method_data' => [ 'type' => 'sbp' ],
'confirmation' => [ 'type' => 'redirect', 'return_url' => YOOKASSA_RETURN_URL ],
'capture' => true,
'description' => $description,
'metadata' => [
'user_id' => (string)$userId,
'action' => 'sbp_pay',
'address_id' => $addressIdOpt,
'pickup_id' => $pickupIdOpt,
'phone' => $phoneOpt,
'amount' => $amount,
],
];

// Пробрасываем выбранный банк, если пришёл с клиента
if (!empty($data['bank_id'])) {
$paymentData['payment_method_data']['bank_id'] = (string)$data['bank_id'];
}

$opts = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($paymentData),
CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'Idempotence-Key: ' . $idemp ],
CURLOPT_USERPWD => YOOKASSA_SHOP_ID . ':' . YOOKASSA_SECRET_KEY,
];
$httpCode = 0; $result = null;
yk_curl_with_retries('https://api.yookassa.ru/v3/payments', $opts, $httpCode, $result);
yk_log('create_sbp_payment: response', ['http' => $httpCode, 'result' => $result, 'user_id' => $userId, 'amount' => $amount]);

if ($httpCode == 200 && is_array($result) && isset($result['id'])) {
$paymentId = (string)$result['id'];
$status = $result['status'] ?? null;
$paid = !empty($result['paid']);
$confirmUrl = $result['confirmation']['confirmation_url'] ?? null;

// Лог в БД
try {
$row = R::findOne('payments_log', ' payment_id = ? ', [ $paymentId ]);
if (!$row) { $row = R::dispense('payments_log'); $row->payment_id = $paymentId; $row->created_at = date('Y-m-d H:i:s'); }
$row->clients_id = (int)$userId;
$row->amount = (float)$amount;
$row->status = (string)$status;
$row->meta = json_encode(['stage' => 'create_sbp_payment', 'response' => $result], JSON_UNESCAPED_UNICODE);
$row->updated_at = date('Y-m-d H:i:s');
R::store($row);
} catch (Throwable $e) { /* ignore */ }

// Для СБП нормален статус pending + confirmation_url
if (!empty($confirmUrl)) {
echo json_encode([
'success' => 1,
'payment_id' => $paymentId,
'status' => $status,
'paid' => $paid ? 1 : 0,
'url' => $confirmUrl,
]);
exit;
}

// Если вдруг сразу оплачен (завершён без редиректа)
if ($paid && $status === 'succeeded') {
echo json_encode([
'success' => 1,
'payment_id' => $paymentId,
'status' => $status,
'paid' => 1,
]);
exit;
}

echo json_encode([
'success' => 0,
'error' => 'confirmation url missing',
'status' => $status,
]);
exit;
}

// Специальная обработка: если СБП не подключён у мерчанта
if ($httpCode == 400 && is_array($result) && isset($result['code']) && ($result['code'] === 'invalid_request')) {
$desc = (string)($result['description'] ?? '');
if (stripos($desc, 'Payment method is not available') !== false) {
echo json_encode([
'success' => 0,
'error_code' => 'sbp_unavailable',
'error' => 'Payment method is not available',
'debug' => [ 'http' => $httpCode, 'body' => $result ],
]);
exit;
}
}
echo json_encode([
'success' => 0,
'error' => $result['description'] ?? 'sbp payment failed',
'debug' => [ 'http' => $httpCode, 'body' => $result ],
]);
exit;
}
yk_log('create_payment: http error or invalid response', ['http' => $httpCode, 'result' => $result]);
echo json_encode([
'success' => 0,
'error' => $result['description'] ?? ($err ?? 'payment failed'),
'debug' => [ 'http' => $httpCode, 'body' => $result ],
]);
exit;
}

// Создание платежа по СБП через YooKassa: возвращает confirmation_url (sbp_uri)
if ($request == 'create_sbp_payment') {
// Стоп по глобальному флагу приёма заказов
try {
$cityIdForFlag = null;
$locationIdForFlag = null;
$addressIdOpt = isset($data['address_id']) ? (int)$data['address_id'] : null;
$pickupIdOpt = isset($data['pickup_id']) ? (int)$data['pickup_id'] : null;
if ($addressIdOpt) {
$ab = R::findOne('addresses', ' id = ? ', [ $addressIdOpt ]);
if ($ab && isset($ab->city_id)) { $cityIdForFlag = (int)$ab->city_id; }
}
if ($pickupIdOpt) { $locationIdForFlag = (int)$pickupIdOpt; }
if (function_exists('app_on_off')) {
$flag = app_on_off($cityIdForFlag, $locationIdForFlag);
if (is_array($flag) && !empty($flag['orders_disabled'])) {
echo json_encode([
'success' => 0,
'error_code' => 'orders_disabled',
'message' => $flag['orders_disabled_reason'] ?? 'Приём заказов временно недоступен',
'orders_disabled' => 1,
'orders_disabled_reason' => $flag['orders_disabled_reason'] ?? null,
'orders_disabled_resume_at' => $flag['resume_at'] ?? null,
]);
exit;
}
}
} catch (Throwable $e) { /* ignore */ }

yk_ensure_payments_log_table();
// Требуется аутентифицированный пользователь
$userId = is_array($user) ? ($user['id'] ?? null) : (is_object($user) ? ($user->id ?? null) : null);
if (!$userId) {
echo json_encode(['success' => 0, 'error' => 'Unauthorized']);
exit;
}

$amountRaw = isset($data['amount']) ? (string)$data['amount'] : '';
$phoneOpt = isset($data['phone']) ? trim((string)$data['phone']) : '';
// Стабильный идемпотентный ключ: пользователь + сумма + дата (минутная гранулярность)
$idemp = isset($data['idempotency_key']) ? (string)$data['idempotency_key'] : null;
if (!$idemp) {
$minute = date('YmdHi');
$idemp = 'sbp_' . $userId . '_' . $amount . '_' . $minute;
}
$addressIdOpt = isset($data['address_id']) ? (int)$data['address_id'] : null;
$pickupIdOpt = isset($data['pickup_id']) ? (int)$data['pickup_id'] : null;

// Нормализуем сумму
$amount = preg_replace('/[^0-9\.-]/', '', $amountRaw);
if ($amount === '' || !is_numeric($amount)) { $amount = '0'; }
$amount = number_format((float)$amount, 2, '.', '');
if ((float)$amount <= 0) {
echo json_encode(['success' => 0, 'error' => 'amount must be > 0']);
exit;
}

// Описание платежа
$descParts = [ 'Оплата по СБП: ' . $amount . ' ₽' ];
if ($addressIdOpt) {
$addr = R::findOne('addresses', ' id = ? ', [ $addressIdOpt ]);
if ($addr) {
$street = (string)($addr->street ?? '');
$house = (string)($addr->house ?? '');
$ap = (string)($addr->apartment ?? '');
$adrText = trim($street . (strlen($house)?(', д.' . $house):'') . (strlen($ap)?(', кв.' . $ap):''));
if ($adrText !== '') $descParts[] = 'Доставка: ' . $adrText;
}
} elseif ($pickupIdOpt) {
$loc = R::findOne('locations', ' id = ? ', [ $pickupIdOpt ]);
if ($loc) {
$locName = (string)($loc->name ?? '');
if ($locName !== '') $descParts[] = 'Самовывоз: ' . $locName;
}
}
if ($phoneOpt !== '') { $descParts[] = 'Тел: ' . $phoneOpt; }
$description = implode(' | ', $descParts);
// Если статус "waiting_for_capture" — пробуем выполнить capture сразу
if ($status === 'waiting_for_capture') {
$capData = [ 'amount' => [ 'value' => $amount, 'currency' => 'RUB' ] ];
$capKey = uniqid('cap_', true);
$ch2 = curl_init('https://api.yookassa.ru/v3/payments/' . $paymentId . '/capture');
curl_setopt($ch2, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch2, CURLOPT_POST, true);
curl_setopt($ch2, CURLOPT_POSTFIELDS, json_encode($capData));
curl_setopt($ch2, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'Idempotence-Key: ' . $capKey,
]);
curl_setopt($ch2, CURLOPT_USERPWD, YOOKASSA_SHOP_ID . ':' . YOOKASSA_SECRET_KEY);
$capResp = curl_exec($ch2);
$capHttp = curl_getinfo($ch2, CURLINFO_HTTP_CODE);
curl_close($ch2);
$cap = json_decode($capResp, true);
if ($capHttp == 200 && isset($cap['status']) && $cap['status'] === 'succeeded' && !empty($cap['paid'])) {
yk_log('create_payment: capture succeeded', ['payment_id' => $paymentId]);
try {
$row = R::findOne('payments_log', ' payment_id = ? ', [ $paymentId ]);
if (!$row) { $row = R::dispense('payments_log'); $row->payment_id = $paymentId; $row->created_at = date('Y-m-d H:i:s'); }
$row->clients_id = (int)$userId;
$row->amount = (float)$amount;
$row->status = 'succeeded';
$row->meta = json_encode(['stage' => 'capture', 'response' => $cap], JSON_UNESCAPED_UNICODE);
$row->updated_at = date('Y-m-d H:i:s');
R::store($row);
} catch (Throwable $e) {}
echo json_encode([
'success' => 1,
'payment_id' => $paymentId,
'status' => $cap['status'],
'paid' => true,
]);
exit;
}
yk_log('create_payment: capture failed', ['http' => $capHttp, 'cap' => $cap]);
echo json_encode([
'success' => 0,
'error' => $cap['description'] ?? 'capture failed',
'status' => $cap['status'] ?? null,
'debug' => [ 'http' => $capHttp, 'body' => $cap ],
]);
exit;
}

if ($paid && $status === 'succeeded') {
yk_log('create_payment: succeeded without capture', ['payment_id' => $paymentId]);
try {
$row = R::findOne('payments_log', ' payment_id = ? ', [ $paymentId ]);
if (!$row) { $row = R::dispense('payments_log'); $row->payment_id = $paymentId; $row->created_at = date('Y-m-d H:i:s'); }
$row->clients_id = (int)$userId;
$row->amount = (float)$amount;
$row->status = 'succeeded';
$row->meta = json_encode(['stage' => 'create_success', 'response' => $result], JSON_UNESCAPED_UNICODE);
$row->updated_at = date('Y-m-d H:i:s');
R::store($row);
} catch (Throwable $e) {}
echo json_encode([
'success' => 1,
'payment_id' => $paymentId,
'status' => $status,
'paid' => true,
]);
exit;
}

// Любой другой статус считаем неуспехом для бесшовного сценария
yk_log('create_payment: not completed', ['status' => $status, 'paid' => $paid, 'payment_id' => $paymentId]);
echo json_encode([
'success' => 0,
'error' => 'payment not completed',
'status' => $status,
'paid' => $paid,
'payment_id' => $paymentId,
]);
exit;
}
// Описание платежа: сумма + адрес/самовывоз + телефон
$descParts = [ 'Оплата заказа: ' . $amount . ' ₽' ];
if ($addressIdOpt) {
$addr = R::findOne('addresses', ' id = ? ', [ $addressIdOpt ]);
if ($addr) {
$street = (string)($addr->street ?? '');
$house = (string)($addr->house ?? '');
$ap = (string)($addr->apartment ?? '');
$adrText = trim($street . (strlen($house)?(', д.' . $house):'') . (strlen($ap)?(', кв.' . $ap):''));
if ($adrText !== '') $descParts[] = 'Доставка: ' . $adrText;
}
} elseif ($pickupIdOpt) {
$loc = R::findOne('locations', ' id = ? ', [ $pickupIdOpt ]);
if ($loc) {
$locName = (string)($loc->name ?? '');
if ($locName !== '') $descParts[] = 'Самовывоз: ' . $locName;
}
}
if ($phoneOpt !== '') { $descParts[] = 'Тел: ' . $phoneOpt; }
$description = implode(' | ', $descParts);

// Формируем запрос в YooKassa для списания с сохранённой карты
$paymentData = [
'amount' => [
'value' => $amount,
'currency' => 'RUB',
],
'payment_method_id' => (string)$paymentMethodId,
'capture' => $capture ? true : false,
'description' => $description,
'metadata' => [
'user_id' => (string)$userId,
'card_id' => (string)$cardId,
'action' => 'pay',
'address_id' => $addressIdOpt,
'pickup_id' => $pickupIdOpt,
'phone' => $phoneOpt,
'amount' => $amount,
],
];

$opts = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($paymentData),
CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'Idempotence-Key: ' . $idemp ],
CURLOPT_USERPWD => YOOKASSA_SHOP_ID . ':' . YOOKASSA_SECRET_KEY,
];
$err = null; $httpCode = 0; $result = null;
yk_curl_with_retries('https://api.yookassa.ru/v3/payments', $opts, $httpCode, $result);
yk_log('create_payment: response', ['http' => $httpCode, 'result' => $result, 'user_id' => $userId, 'card_id' => $cardId, 'amount' => $amount]);

// Если требуется подтверждение (3DS) — YooKassa вернёт confirmation_url. Для бесшовной оплаты это ошибка.
if (isset($result['confirmation']['confirmation_url'])) {
yk_log('create_payment: requires_action (3DS)', ['url' => $result['confirmation']['confirmation_url']]);
echo json_encode([
'success' => 0,
'requires_action' => 1,
'url' => $result['confirmation']['confirmation_url'],
'status' => $result['status'] ?? null,
'payment_id' => $result['id'] ?? null,
'error' => 'confirmation required',
]);
exit;
}

// Успех только если уже оплачено
if ($httpCode == 200 && isset($result['id'])) {
$status = $result['status'] ?? null;
$paid = !empty($result['paid']);
$paymentId = $result['id'];

// Пишем/обновляем лог
try {
$row = R::findOne('payments_log', ' payment_id = ? ', [ $paymentId ]);
if (!$row) { $row = R::dispense('payments_log'); $row->payment_id = $paymentId; $row->created_at = date('Y-m-d H:i:s'); }
$row->clients_id = (int)$userId;
$row->amount = (float)$amount;
$row->status = (string)$status;
$row->meta = json_encode(['stage' => 'create_payment', 'response' => $result], JSON_UNESCAPED_UNICODE);
$row->updated_at = date('Y-m-d H:i:s');
R::store($row);
} catch (Throwable $e) { /* ignore */ }

Не вакансия

if ($request == 'deleteCard') {
// Удаление карты пользователя
$userId = is_array($user) ? ($user['id'] ?? null) : (is_object($user) ? ($user->id ?? null) : null);
if (!$userId) {
echo json_encode(['success' => 0, 'error' => 'Unauthorized']);
exit;
}
$cardId = isset($data['card_id']) ? (int)$data['card_id'] : 0;
if ($cardId <= 0) {
echo json_encode(['success' => 0, 'error' => 'card_id required']);
exit;
}
try {
$card = R::findOne('usercards', ' id = ? AND clients_id = ? AND paysistem = ? ', [ $cardId, $userId, 'yookassa' ]);
if (!$card) {
echo json_encode(['success' => 0, 'error' => 'card not found']);
exit;
}
R::trash($card);
echo json_encode(['success' => 1]);
exit;
} catch (Throwable $e) {
echo json_encode(['success' => 0, 'error' => 'delete failed']);
exit;
}
}

if ($request == 'create_payment') {
// Перед оплатой — быстрый стоп по глобальному флагу (всегда включен сейчас)
try {
$cityIdForFlag = null;
$locationIdForFlag = null;
if ($addressIdOpt) {
$ab = R::findOne('addresses', ' id = ? ', [ $addressIdOpt ]);
if ($ab && isset($ab->city_id)) { $cityIdForFlag = (int)$ab->city_id; }
}
if ($pickupIdOpt) { $locationIdForFlag = (int)$pickupIdOpt; }
if (function_exists('app_on_off')) {
$flag = app_on_off($cityIdForFlag, $locationIdForFlag);
if (is_array($flag) && !empty($flag['orders_disabled'])) {
echo json_encode([
'success' => 0,
'error_code' => 'orders_disabled',
'message' => $flag['orders_disabled_reason'] ?? 'Приём заказов временно недоступен',
'orders_disabled' => 1,
'orders_disabled_reason' => $flag['orders_disabled_reason'] ?? null,
'orders_disabled_resume_at' => $flag['resume_at'] ?? null,
]);
exit;
}
}
} catch (Throwable $e) { /* ignore */ }
yk_ensure_payments_log_table();
// Требуется аутентифицированный пользователь и paysistem = yookassa
$userId = is_array($user) ? ($user['id'] ?? null) : (is_object($user) ? ($user->id ?? null) : null);
if (!$userId) {
echo json_encode(['success' => 0, 'error' => 'Unauthorized']);
exit;
}

// Параметры: amount (строка/число), card_id (ID в usercards), capture (0/1), idempotency_key (строка)
$amountRaw = isset($data['amount']) ? (string)$data['amount'] : '';
$cardId = isset($data['card_id']) ? (int)$data['card_id'] : 0;
$capture = isset($data['capture']) ? (int)$data['capture'] : 1;
$idemp = isset($data['idempotency_key']) ? (string)$data['idempotency_key'] : uniqid('pay_', true);
$addressIdOpt = isset($data['address_id']) ? (int)$data['address_id'] : null;
$pickupIdOpt = isset($data['pickup_id']) ? (int)$data['pickup_id'] : null;
$phoneOpt = isset($data['phone']) ? trim((string)$data['phone']) : '';

// Нормализуем сумму в формате 0.00
$amount = preg_replace('/[^0-9\.-]/', '', $amountRaw);
if ($amount === '' || !is_numeric($amount)) { $amount = '0'; }
$amount = number_format((float)$amount, 2, '.', '');

if ($cardId <= 0) {
echo json_encode(['success' => 0, 'error' => 'card_id required']);
exit;
}
if ((float)$amount <= 0) {
echo json_encode(['success' => 0, 'error' => 'amount must be > 0']);
exit;
}

// Ищем сохранённую карту пользователя
$card = R::findOne('usercards', ' id = ? AND clients_id = ? AND paysistem = ? ', [ $cardId, $userId, 'yookassa' ]);
if (!$card) {
echo json_encode(['success' => 0, 'error' => 'card not found']);
exit;
}
$paymentMethodId = $card->payment_method_id ?? '';
if (!$paymentMethodId) {
echo json_encode(['success' => 0, 'error' => 'payment_method_id missing for card']);
exit;
}

Не вакансия

// Дедупликация по «отпечатку» и сохранение карты
$card = R::dispense('usercards');
$card->clients = R::load('clients', $userId );
$card->paysistem = 'yookassa';
$card->payment_method_id = $pmId;
$card->last4 = $pm['card']['last4'] ?? '';
$card->exp_month = $pm['card']['expiry_month'] ?? '';
$card->exp_year = $pm['card']['expiry_year'] ?? '';
$card->card_type = $pm['card']['card_type'] ?? '';
$card->card_title = $pm['title'] ?? '';
$card->card_first6 = $pm['card']['first6'] ?? '';
$card->card_status = $pm['status'] ?? '';
$card->card_saved = $pm['saved'] ?? false;
$card->card_issuer_country = $pm['card']['issuer_country'] ?? '';
$card->card_product_code = $pm['card']['card_product']['code'] ?? '';
$card->payment_id = $p['id'] ?? '';
$card->created_at = $p['created_at'] ?? '';
$card->paid = $p['paid'] ?? false;
$card->test = $p['test'] ?? false;
// Проверка на дубликат по отпечатку (без учёта first6, т.к. старые записи могли быть без него)
$dup = R::findOne(
'usercards',
' clients_id = ? AND paysistem = ? AND last4 = ? AND exp_month = ? AND exp_year = ? AND card_type = ? ',
[ $userId, 'yookassa', $card->last4, $card->exp_month, $card->exp_year, $card->card_type ]
);
if ($dup) {
// Дополняем first6 у существующей карты, если он пустой
try {
$needUpdate = false;
if (empty($dup->card_first6) && !empty($card->card_first6)) { $dup->card_first6 = $card->card_first6; $needUpdate = true; }
if (empty($dup->card_title) && !empty($card->card_title)) { $dup->card_title = $card->card_title; $needUpdate = true; }
if ($needUpdate) { R::store($dup); }
} catch (Throwable $e) {}
yk_log('finalize_bind: duplicate by soft fingerprint', ['pm_id' => $pmId]);
echo json_encode(['success' => 1, 'stored' => 0, 'message' => 'duplicate fingerprint']);
exit;
}
yk_ensure_usercards_unique();
try {
R::store($card);
} catch (Throwable $e) {
// Нарушение уникального индекса — считаем успехом без дубликата
yk_log('finalize_bind: unique index prevents duplicate', ['pm_id' => $pmId]);
echo json_encode(['success' => 1, 'stored' => 0, 'message' => 'already exists']);
exit;
}
yk_log('finalize_bind: card stored', ['pm_id' => $pmId]);
echo json_encode(['success' => 1, 'stored' => 1]);
exit;
}
}
yk_log('finalize_bind: bind payment not found');
echo json_encode(['success' => 0, 'error' => 'bind payment not found']);
} catch (Throwable $e) {
yk_log('finalize_bind: exception', ['error' => $e->getMessage()]);
echo json_encode(['success' => 0, 'error' => 'finalize failed']);
}
exit;
}

Не вакансия

try { if (empty($dup->card_first6) && !empty($card->card_first6)) { $dup->card_first6 = $card->card_first6; R::store($dup); } } catch (Throwable $e) {}
echo json_encode(['success' => 1, 'stored' => 0, 'message' => 'duplicate fingerprint']);
exit;
}
yk_ensure_usercards_unique();
try { R::store($card); } catch (Throwable $e) { echo json_encode(['success' => 1, 'stored' => 0, 'message' => 'already exists']); exit; }
echo json_encode(['success' => 1, 'stored' => 1]);
exit;
}
}
}
}
}
echo json_encode(['success' => 0, 'error' => 'bind payment not found or not succeeded']);
exit;
}
// Запрашиваем последние платежи и ищем bind-платёж текущего пользователя
$query = http_build_query([
'limit' => 10,
// Дополнительно можно добавить created_at_gte
]);
$ch = curl_init('https://api.yookassa.ru/v3/payments?' . $query);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Content-Type: application/json' ]);
curl_setopt($ch, CURLOPT_USERPWD, YOOKASSA_SHOP_ID . ':' . YOOKASSA_SECRET_KEY);
$resp = curl_exec($ch);
$http = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$list = json_decode($resp, true);

if ($http >= 200 && $http < 300 && isset($list['items']) && is_array($list['items'])) {
yk_log('finalize_bind: payments list received', ['count' => count($list['items'])]);
foreach ($list['items'] as $p) {
$md = $p['metadata'] ?? [];
if (($md['action'] ?? '') !== 'bind') continue;
if ((string)($md['user_id'] ?? '') !== (string)$userId) continue;
$pm = $p['payment_method'] ?? [];
if (($pm['type'] ?? '') !== 'bank_card') continue;
if (empty($pm['saved'])) continue;
$pmId = $pm['id'] ?? '';
if ($pmId === '') continue;
// Для привязки допускаем succeeded и waiting_for_capture (песочница часто так возвращает)
$status = (string)($p['status'] ?? '');
$paid = !empty($p['paid']);
yk_log('finalize_bind: candidate', [
'status' => $status,
'paid' => $paid,
'cancellation' => $p['cancellation_details'] ?? null,
'pm' => [
'id' => $pmId,
'first6' => $pm['card']['first6'] ?? '',
'last4' => $pm['card']['last4'] ?? '',
'exp_month' => $pm['card']['expiry_month'] ?? '',
'exp_year' => $pm['card']['expiry_year'] ?? '',
'card_type' => $pm['card']['card_type'] ?? ''
]
]);
$allowed = in_array($status, ['succeeded', 'waiting_for_capture'], true);
if (!$allowed) continue;
if (!empty($p['cancellation_details'])) continue;

// Если карта уже есть — успех
$existing = R::findOne('usercards', 'clients_id = ? AND paysistem = ? AND payment_method_id = ?', [ $userId, 'yookassa', $pmId ]);
if ($existing) {
yk_log('finalize_bind: card already exists by payment_method_id', ['pm_id' => $pmId]);
echo json_encode(['success' => 1, 'stored' => 0, 'message' => 'already exists']);
exit;
}
// Явная финализация привязки карты (на случай, если вебхук ещё не отработал)
if ($request == 'finalize_bind') {
$userId = is_array($user) ? ($user['id'] ?? null) : (is_object($user) ? ($user->id ?? null) : null);
if (!$userId) {
echo json_encode(['success' => 0, 'error' => 'Unauthorized']);
exit;
}
try {
// Мгновенная финализация по конкретному платежу, если передан payment_id
$paymentIdOpt = isset($data['payment_id']) ? (string)$data['payment_id'] : '';
if ($paymentIdOpt !== '') {
$http = 0; $p = null;
yk_curl_with_retries(
'https://api.yookassa.ru/v3/payments/' . $paymentIdOpt,
[
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [ 'Content-Type: application/json' ],
CURLOPT_USERPWD => YOOKASSA_SHOP_ID . ':' . YOOKASSA_SECRET_KEY,
],
$http,
$p
);
if ($http >= 200 && $http < 300 && is_array($p)) {
$md = $p['metadata'] ?? [];
if (($md['action'] ?? '') === 'bind' && (string)($md['user_id'] ?? '') === (string)$userId) {
$pm = $p['payment_method'] ?? [];
if (($pm['type'] ?? '') === 'bank_card' && !empty($pm['saved'])) {
$pmId = $pm['id'] ?? '';
if ($pmId !== '') {
$status = (string)($p['status'] ?? '');
$paid = !empty($p['paid']);
if ($status === 'succeeded' && $paid && empty($p['cancellation_details'])) {
// Если карта уже есть — успех без дубликата
$existing = R::findOne('usercards', 'clients_id = ? AND paysistem = ? AND payment_method_id = ?', [ $userId, 'yookassa', $pmId ]);
if ($existing) {
echo json_encode(['success' => 1, 'stored' => 0, 'message' => 'already exists']);
exit;
}
// Сохраняем карту
$card = R::dispense('usercards');
$card->clients = R::load('clients', $userId );
$card->paysistem = 'yookassa';
$card->payment_method_id = $pmId;
$card->last4 = $pm['card']['last4'] ?? '';
$card->exp_month = $pm['card']['expiry_month'] ?? '';
$card->exp_year = $pm['card']['expiry_year'] ?? '';
$card->card_type = $pm['card']['card_type'] ?? '';
$card->card_title = $pm['title'] ?? '';
$card->card_first6 = $pm['card']['first6'] ?? '';
$card->card_status = $pm['status'] ?? '';
$card->card_saved = $pm['saved'] ?? false;
$card->card_issuer_country = $pm['card']['issuer_country'] ?? '';
$card->card_product_code = $pm['card']['card_product']['code'] ?? '';
$card->payment_id = $p['id'] ?? '';
$card->created_at = $p['created_at'] ?? '';
$card->paid = $p['paid'] ?? false;
$card->test = $p['test'] ?? false;
// Мягкая дедупликация
$dup = R::findOne(
'usercards',
' clients_id = ? AND paysistem = ? AND last4 = ? AND exp_month = ? AND exp_year = ? AND card_type = ? ',
[ $userId, 'yookassa', $card->last4, $card->exp_month, $card->exp_year, $card->card_type ]
);
if ($dup) {
$paymentData = [
'amount' => [
'value' => '1.00',
'currency' => 'RUB'
],
'payment_method_data' => [
'type' => 'bank_card'
],
// Для стабильности открытия в WebView используем embedded-форму при поддержке
'confirmation' => [
'type' => 'redirect',
'return_url' => YOOKASSA_RETURN_URL
],
'capture' => true,
'save_payment_method' => true,
'description' => 'Привязка карты для автоплатежей',
'metadata' => [
'user_id' => $userId,
'action' => 'bind'
],
];

// Поле 'test' не отправляем на прод. Оставляем только для песочницы (по префиксу ключа)
if (strpos(YOOKASSA_SECRET_KEY, 'test_') === 0) {
$paymentData['test'] = true;
}

$idempotenceKey = uniqid('bind_', true);

$ch = curl_init('https://api.yookassa.ru/v3/payments');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($paymentData));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'Idempotence-Key: ' . $idempotenceKey
]);
curl_setopt($ch, CURLOPT_USERPWD, YOOKASSA_SHOP_ID . ':' . YOOKASSA_SECRET_KEY);

$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

$result = json_decode($response, true);
yk_log('get_card_bind_url: response', ['http' => $httpCode, 'result' => $result]);

if (($httpCode == 200 || $httpCode == 201) && isset($result['confirmation']['confirmation_url'])) {
yk_log('get_card_bind_url: confirmation_url issued', ['url' => $result['confirmation']['confirmation_url']]);
$flag = function_exists('app_on_off') ? app_on_off(null, null) : ['orders_disabled'=>0];
echo json_encode([
'success' => 1,
'url' => $result['confirmation']['confirmation_url'],
'orders_disabled' => isset($flag['orders_disabled']) ? (int)$flag['orders_disabled'] : 0,
'orders_disabled_reason' => $flag['orders_disabled_reason'] ?? null,
'orders_disabled_resume_at' => $flag['resume_at'] ?? null,
]);
} else {
yk_log('get_card_bind_url: error creating bind payment', ['http' => $httpCode, 'result' => $result]);
$flag = function_exists('app_on_off') ? app_on_off(null, null) : ['orders_disabled'=>0];
echo json_encode([
'success' => 0,
'error' => $result['description'] ?? 'Ошибка при создании платежа',
'debug' => $result,
'orders_disabled' => isset($flag['orders_disabled']) ? (int)$flag['orders_disabled'] : 0,
'orders_disabled_reason' => $flag['orders_disabled_reason'] ?? null,
'orders_disabled_resume_at' => $flag['resume_at'] ?? null,
]);
}
exit;
}
function yk_migrate_sbp_bindings_to_upm(int $userId) {
if (!yk_table_exists('userpaymentmethods')) return;
try {
$has = R::count('userpaymentmethods', ' clients_id = ? AND provider = ? AND type = ? ', [ $userId, 'yookassa', 'sbp' ]);
if ($has > 0) return;
} catch (Throwable $e) { /* ignore */ }
try {
$rows = R::findAll('usercards', ' clients_id = ? AND paysistem = ? AND card_type = ? ', [ $userId, 'yookassa', 'SBP' ]);
foreach ($rows as $uc) {
$row = R::dispense('userpaymentmethods');
$row->clients_id = (int)$userId;
$row->provider = 'yookassa';
$row->type = 'sbp';
$row->pm_id = (string)$uc->payment_method_id;
$row->bank_name = (string)($uc->card_title ?? 'СБП');
$row->bank_id = '';
$row->revoked = 0;
$row->created_at = date('Y-m-d H:i:s');
try { R::store($row); } catch (Throwable $e) { /* ignore duplicates */ }
try { R::trash($uc); } catch (Throwable $e) { /* keep if cannot delete */ }
}
} catch (Throwable $e) { /* ignore */ }
}

function yk_card_fingerprint(array $pm): array {
return [
'first6' => (string)($pm['card']['first6'] ?? ''),
'last4' => (string)($pm['card']['last4'] ?? ''),
'exp_month' => (string)($pm['card']['expiry_month'] ?? ''),
'exp_year' => (string)($pm['card']['expiry_year'] ?? ''),
'card_type' => (string)($pm['card']['card_type'] ?? ($pm['card_type'] ?? ($pm['type'] ?? ''))),
];
}
// server_yookassa.php
// ===================== АРХИТЕКТУРНАЯ ПАМЯТКА =====================
// Все запросы, связанные с эквайрингом YooKassa, должны приходить с paysistem=yookassa
// В server_code.php и других файлах не должно быть логики по YooKassa
// ================================================================

// ===================== YOOKASSA ГЛОБАЛЬНЫЕ ПАРАМЕТРЫ =====================
// ВНИМАНИЕ: Никогда не коммитьте реальные ключи в публичный репозиторий!
const YOOKASSA_SHOP_ID = '1190553'; // production shopId
const YOOKASSA_SECRET_KEY = 'live_YHEOhKkYOVHB4MXzXlBHu_Wr1--zDn314qO1pqYJQ6g'; // production secretKey
const YOOKASSA_RETURN_URL = 'https://api.deely.ru/yookassa_return'; // <-- Заменено на новый return_url
// ========================================================================

// Статус онлайн‑кассы YooKassa: временно всегда включена
if ($request == 'get_yookassa_online_status') {
echo json_encode(['success' => 1, 'enabled' => 1]);
exit;
}

// verbose debug removed (kept fatal guard only)

if ($request == 'getCards') {
$userId = is_array($user) ? ($user['id'] ?? null) : (is_object($user) ? ($user->id ?? null) : null);
if (!$userId) {
echo json_encode(['success' => 0, 'error' => 'user_id required']);
exit;
}
$cards = R::findAll('usercards', 'clients_id = ? AND paysistem = ?', [ $userId, 'yookassa' ]);
$result = [];
foreach ($cards as $card) {
$result[] = [
'id' => $card->id,
'masked' => '**** ** ** ' . $card->last4,
'type' => $card->card_type,
'exp' => $card->exp_month . '/' . $card->exp_year,
// можно добавить другие нужные поля
];
}
$flag = function_exists('app_on_off') ? app_on_off(null, null) : ['orders_disabled'=>0];
echo json_encode([
'success' => 1,
'cards' => $result,
'orders_disabled' => isset($flag['orders_disabled']) ? (int)$flag['orders_disabled'] : 0,
'orders_disabled_reason' => $flag['orders_disabled_reason'] ?? null,
'orders_disabled_resume_at' => $flag['resume_at'] ?? null,
]);
exit;
}

if ($request == 'get_card_bind_url') {
// Получаем id пользователя (например, из JWT или сессии)
$userId = is_array($user) ? ($user['id'] ?? null) : (is_object($user) ? ($user->id ?? null) : null);
if (!$userId) { $userId = uniqid('testuser_'); }

Не вакансия

function yk_ensure_user_payment_methods_table() {
try {
R::exec("CREATE TABLE IF NOT EXISTS userpaymentmethods (
id INT NOT NULL AUTO_INCREMENT,
clients_id INT NOT NULL,
provider VARCHAR(32) NOT NULL,
type VARCHAR(32) NOT NULL,
pm_id VARCHAR(128) NOT NULL,
bank_name VARCHAR(255) NULL,
bank_id VARCHAR(64) NULL,
alias VARCHAR(128) NULL,
revoked TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME NULL,
PRIMARY KEY (`id`),
UNIQUE KEY ux_client_provider_type_pm (`clients_id`,`provider`,`type`,`pm_id`),
KEY ix_client_provider_type (`clients_id`,`provider`,`type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
} catch (Throwable $e) { /* ignore */ }
// Бесшумно добавим недостающие поля при апгрейде схемы
try { R::exec("ALTER TABLE userpaymentmethods ADD COLUMN alias VARCHAR(128) NULL"); } catch (Throwable $e) { /* exists */ }
}

function yk_table_exists($name) {
try {
$cols = R::inspect($name);
return is_array($cols) && !empty($cols);
} catch (Throwable $e) { return false; }
}

function yk_sbp_bank_name_by_id($bankId): string {
if (!$bankId) return '';
try {
$cacheKey = 'sbp_banks_cache_json';
$cacheTsKey = 'sbp_banks_cache_updated_at';
$json = null; $updatedAt = 0; $now = time();
if (R::inspect('options')) {
$row = R::findOne('options', ' name = ? ', [ $cacheKey ]);
if ($row) { $json = (string)$row->value; }
$rowTs = R::findOne('options', ' name = ? ', [ $cacheTsKey ]);
if ($rowTs) { $updatedAt = (int)$rowTs->value; }
}
if ($json === null || ($now - $updatedAt) > 24*3600) {
$http = 0; $body = null;
yk_curl_with_retries('https://qr.nspk.ru/proxyapp/c2bmembers.json', [ CURLOPT_RETURNTRANSFER => true ], $http, $body);
if ($http == 200 && is_array($body)) {
$json = json_encode($body, JSON_UNESCAPED_UNICODE);
if (R::inspect('options')) {
$row = R::findOne('options', ' name = ? ', [ $cacheKey ]);
if (!$row) { $row = R::dispense('options'); $row->name = $cacheKey; }
$row->value = $json; R::store($row);
$rowTs = R::findOne('options', ' name = ? ', [ $cacheTsKey ]);
if (!$rowTs) { $rowTs = R::dispense('options'); $rowTs->name = $cacheTsKey; }
$rowTs->value = (string)$now; R::store($rowTs);
}
}
}
if ($json) {
$arr = json_decode($json, true);
$list = isset($arr['members']) && is_array($arr['members']) ? $arr['members'] : (is_array($arr) ? $arr : []);
foreach ($list as $it) {
$id = $it['bankId'] ?? $it['bank_id'] ?? $it['id'] ?? '';
if ((string)$id === $bankId) {
$name = $it['name'] ?? ($it['bankName'] ?? ($it['bank_name'] ?? ''));
return (string)$name;
}
}
}
} catch (Throwable $e) {}
return '';
}
<?php
// Crash guard: always return JSON instead of empty body on fatal errors
if (!function_exists('yk_shutdown_guard')) {
function yk_shutdown_guard() {
$e = error_get_last();
if ($e && in_array($e['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
@header('Content-Type: application/json');
@file_put_contents(__DIR__.'/yookassa_debug.log', date('c')." - FATAL: ".json_encode($e, JSON_UNESCAPED_UNICODE)."\n", FILE_APPEND);
echo json_encode([
'success' => 0,
'error' => 'internal server error',
'fatal' => [ 'type' => $e['type'], 'message' => $e['message'], 'file' => $e['file'], 'line' => $e['line'] ],
]);
}
}
register_shutdown_function('yk_shutdown_guard');
}
// Простой файловый логгер для отладки YooKassa
if (!function_exists('yk_log')) {
function yk_log(string $message, $data = null) {
try {
$line = date('c') . ' - ' . $message;
if ($data !== null) {
$line .= ' ' . json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
@file_put_contents(__DIR__ . '/yookassa_api_debug.log', $line . "\n", FILE_APPEND);
} catch (Throwable $e) { /* ignore */ }
}
}
// Параметры повторов и опросов
const YK_RETRY_ATTEMPTS = 2; // количество повторов на 429/5xx
const YK_RETRY_SLEEP_MS = 350000; // 350 мс между повторами
const YK_FINALIZE_ATTEMPTS = 8; // попыток опроса статуса (итого ~4с)
const YK_FINALIZE_SLEEP_MS = 500000; // 500 мс между опросами

function yk_curl_with_retries(string $url, array $opts, &$outHttp, &$outBody)
{
$attempts = YK_RETRY_ATTEMPTS + 1;
for ($i = 0; $i < $attempts; $i++) {
$ch = curl_init($url);
foreach ($opts as $opt => $val) {
curl_setopt($ch, $opt, $val);
}
$resp = curl_exec($ch);
$http = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$outHttp = $http;
$outBody = json_decode($resp, true);
// Успех или клиентская ошибка — выходим
if ($http < 500 && $http !== 429) {
return;
}
// 429/5xx — спим и повторяем
usleep(YK_RETRY_SLEEP_MS);
}
}

function yk_ensure_payments_log_table() {
try {
R::exec("CREATE TABLE IF NOT EXISTS payments_log (
id INT NOT NULL AUTO_INCREMENT,
payment_id VARCHAR(64) NOT NULL,
clients_id INT NULL,
amount DECIMAL(12,2) NULL,
status VARCHAR(32) NULL,
orders_id INT NULL,
meta TEXT NULL,
created_at DATETIME NULL,
updated_at DATETIME NULL,
PRIMARY KEY (`id`),
UNIQUE KEY ux_payment (`payment_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
} catch (Throwable $e) { /* ignore */ }
}

function yk_ensure_usercards_unique() {
try {
// Уникальность карты на пользователя и платёжную систему по payment_method_id
R::exec("CREATE UNIQUE INDEX IF NOT EXISTS ux_usercards_unique ON usercards (clients_id, paysistem, payment_method_id)");
} catch (Throwable $e) { /* ignore */ }
}
https://t.me/+sj7ac1WTWMhiMGM1

Not_specified

Краткое описание вакансии не предоставлено

Почему стоит искать работу на JobFinder?

  • Актуальная база вакансий от проверенных работодателей.
  • Удобный и быстрый поиск с множеством фильтров.
  • Возможность сохранять вакансии и подписываться на рассылки.
  • Полезные статьи и советы по трудоустройству.