AgentSkillsCN

payment-integration

实施基于角色的访问控制与授权

SKILL.md
--- frontmatter
name: payment-integration
description: Guide for integrating Payme and Click payment providers with webhooks

Payment Integration Skill

This skill covers implementing Payme and Click payment integrations for Golden Touch.

When to Use This Skill

  • Setting up payment providers
  • Creating invoice/checkout flows
  • Implementing webhook handlers
  • Handling payment confirmations

Payment Flow Overview

code
1. Dispatcher creates invoice in admin
2. System calls Payme/Click API to create payment
3. System gets payment URL and reference
4. SMS sent to client with payment link
5. Client pays via Payme/Click
6. Webhook notifies system of payment
7. System updates order: PAID status
8. Telegram notification sent

Configuration

Environment Variables

env
# Payme
PAYME_MERCHANT_ID=your_merchant_id
PAYME_SECRET_KEY=your_secret_key
PAYME_TEST_MODE=true
PAYME_CHECKOUT_URL=https://checkout.paycom.uz

# Click
CLICK_MERCHANT_ID=your_merchant_id
CLICK_SERVICE_ID=your_service_id
CLICK_SECRET_KEY=your_secret_key
CLICK_TEST_MODE=true

Config File

php
// config/payments.php

return [
    'payme' => [
        'merchant_id' => env('PAYME_MERCHANT_ID'),
        'secret_key' => env('PAYME_SECRET_KEY'),
        'test_mode' => env('PAYME_TEST_MODE', true),
        'checkout_url' => env('PAYME_CHECKOUT_URL', 'https://checkout.paycom.uz'),
    ],

    'click' => [
        'merchant_id' => env('CLICK_MERCHANT_ID'),
        'service_id' => env('CLICK_SERVICE_ID'),
        'secret_key' => env('CLICK_SECRET_KEY'),
        'test_mode' => env('CLICK_TEST_MODE', true),
    ],

    'webhook_ips' => [
        'payme' => ['185.8.212.0/24', '195.158.31.0/24'],
        'click' => ['185.8.213.0/24'],
    ],
];

Payment Service

php
// app/Services/PaymentService.php

namespace App\Services;

use App\Models\Order;
use App\Models\PaymentLog;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

class PaymentService
{
    /**
     * Create invoice for order
     */
    public function createInvoice(Order $order, string $provider, int $amount = null): array
    {
        $amount = $amount ?? $order->total_amount;

        return match($provider) {
            'payme' => $this->createPaymeInvoice($order, $amount),
            'click' => $this->createClickInvoice($order, $amount),
            default => throw new \InvalidArgumentException("Unknown provider: {$provider}"),
        };
    }

    /**
     * Create Payme checkout link
     */
    private function createPaymeInvoice(Order $order, int $amount): array
    {
        $merchantId = config('payments.payme.merchant_id');
        $amountTiyin = $amount * 100; // Convert to tiyin (1 UZS = 100 tiyin)

        // Build checkout URL with base64 encoded params
        $params = base64_encode(sprintf(
            'm=%s;ac.order_id=%d;a=%d;c=%s',
            $merchantId,
            $order->id,
            $amountTiyin,
            route('api.payments.webhook', 'payme')
        ));

        $checkoutUrl = config('payments.payme.checkout_url') . '/' . $params;

        // Generate reference for tracking
        $reference = 'PM' . $order->id . '_' . time();

        $this->logPayment($order, 'payme', 'invoice_created', [
            'amount' => $amount,
            'reference' => $reference,
        ]);

        return [
            'url' => $checkoutUrl,
            'reference' => $reference,
            'expires_at' => now()->addMinutes(30),
        ];
    }

    /**
     * Create Click checkout link
     */
    private function createClickInvoice(Order $order, int $amount): array
    {
        $merchantId = config('payments.click.merchant_id');
        $serviceId = config('payments.click.service_id');

        // Build Click checkout URL
        $params = http_build_query([
            'merchant_id' => $merchantId,
            'service_id' => $serviceId,
            'amount' => $amount,
            'transaction_param' => $order->id,
            'return_url' => route('cabinet.orders.show', $order->public_token),
        ]);

        $checkoutUrl = 'https://my.click.uz/services/pay?' . $params;

        $reference = 'CL' . $order->id . '_' . time();

        $this->logPayment($order, 'click', 'invoice_created', [
            'amount' => $amount,
            'reference' => $reference,
        ]);

        return [
            'url' => $checkoutUrl,
            'reference' => $reference,
            'expires_at' => now()->addMinutes(30),
        ];
    }

    /**
     * Handle webhook from payment provider
     */
    public function handleWebhook(string $provider, array $payload, string $signature = null): array
    {
        // Log incoming webhook
        $this->logWebhook($provider, $payload);

        // Verify signature
        if (!$this->verifySignature($provider, $payload, $signature)) {
            Log::warning("Payment webhook signature verification failed", [
                'provider' => $provider,
                'payload' => $payload,
            ]);
            return ['error' => 'Invalid signature'];
        }

        return match($provider) {
            'payme' => $this->handlePaymeWebhook($payload),
            'click' => $this->handleClickWebhook($payload),
            default => ['error' => 'Unknown provider'],
        };
    }

    /**
     * Handle Payme webhook
     */
    private function handlePaymeWebhook(array $payload): array
    {
        $method = $payload['method'] ?? null;

        return match($method) {
            'CheckPerformTransaction' => $this->paymeCheckPerform($payload),
            'CreateTransaction' => $this->paymeCreateTransaction($payload),
            'PerformTransaction' => $this->paymePerformTransaction($payload),
            'CancelTransaction' => $this->paymeCancelTransaction($payload),
            'CheckTransaction' => $this->paymeCheckTransaction($payload),
            default => ['error' => ['code' => -32601, 'message' => 'Method not found']],
        };
    }

    private function paymeCheckPerform(array $payload): array
    {
        $orderId = $payload['params']['account']['order_id'] ?? null;
        $amount = $payload['params']['amount'] ?? 0;

        $order = Order::find($orderId);

        if (!$order) {
            return ['error' => ['code' => -31050, 'message' => 'Order not found']];
        }

        if ($order->payment_status === Order::PAY_PAID) {
            return ['error' => ['code' => -31051, 'message' => 'Already paid']];
        }

        $expectedAmount = $order->total_amount * 100; // tiyin
        if ($amount !== $expectedAmount) {
            return ['error' => ['code' => -31001, 'message' => 'Invalid amount']];
        }

        return ['result' => ['allow' => true]];
    }

    private function paymePerformTransaction(array $payload): array
    {
        $transactionId = $payload['params']['id'] ?? null;
        $orderId = $payload['params']['account']['order_id'] ?? null;

        $order = Order::find($orderId);

        if (!$order) {
            return ['error' => ['code' => -31050, 'message' => 'Order not found']];
        }

        // Check idempotency
        if ($order->payment_status === Order::PAY_PAID &&
            $order->pay_reference === $transactionId) {
            return ['result' => [
                'transaction' => $transactionId,
                'perform_time' => $order->paid_at->timestamp * 1000,
                'state' => 2,
            ]];
        }

        // Confirm payment
        $this->confirmPayment($order, 'payme', [
            'transaction_id' => $transactionId,
            'payload' => $payload,
        ]);

        return ['result' => [
            'transaction' => $transactionId,
            'perform_time' => now()->timestamp * 1000,
            'state' => 2,
        ]];
    }

    /**
     * Handle Click webhook
     */
    private function handleClickWebhook(array $payload): array
    {
        $action = $payload['action'] ?? null;

        if ($action === 0) {
            // Prepare - check if order exists
            return $this->clickPrepare($payload);
        }

        if ($action === 1) {
            // Complete - confirm payment
            return $this->clickComplete($payload);
        }

        return ['error' => -9, 'error_note' => 'Unknown action'];
    }

    private function clickPrepare(array $payload): array
    {
        $orderId = $payload['merchant_trans_id'] ?? null;
        $amount = $payload['amount'] ?? 0;

        $order = Order::find($orderId);

        if (!$order) {
            return ['error' => -5, 'error_note' => 'Order not found'];
        }

        if ($order->payment_status === Order::PAY_PAID) {
            return ['error' => -4, 'error_note' => 'Already paid'];
        }

        if ((int)$amount !== $order->total_amount) {
            return ['error' => -2, 'error_note' => 'Invalid amount'];
        }

        return [
            'error' => 0,
            'error_note' => 'Success',
            'click_trans_id' => $payload['click_trans_id'],
            'merchant_trans_id' => $orderId,
            'merchant_prepare_id' => $orderId,
        ];
    }

    private function clickComplete(array $payload): array
    {
        $orderId = $payload['merchant_trans_id'] ?? null;
        $transactionId = $payload['click_trans_id'] ?? null;
        $error = $payload['error'] ?? 0;

        $order = Order::find($orderId);

        if (!$order) {
            return ['error' => -5, 'error_note' => 'Order not found'];
        }

        // Check for payment error
        if ($error < 0) {
            $this->logPayment($order, 'click', 'payment_failed', $payload);
            $order->update(['payment_status' => Order::PAY_FAILED]);
            return [
                'error' => $error,
                'error_note' => 'Payment failed',
                'click_trans_id' => $transactionId,
                'merchant_trans_id' => $orderId,
            ];
        }

        // Check idempotency
        if ($order->payment_status === Order::PAY_PAID) {
            return [
                'error' => 0,
                'error_note' => 'Success',
                'click_trans_id' => $transactionId,
                'merchant_trans_id' => $orderId,
                'merchant_confirm_id' => $orderId,
            ];
        }

        // Confirm payment
        $this->confirmPayment($order, 'click', [
            'transaction_id' => $transactionId,
            'payload' => $payload,
        ]);

        return [
            'error' => 0,
            'error_note' => 'Success',
            'click_trans_id' => $transactionId,
            'merchant_trans_id' => $orderId,
            'merchant_confirm_id' => $orderId,
        ];
    }

    /**
     * Confirm payment and update order
     */
    private function confirmPayment(Order $order, string $provider, array $data): void
    {
        \DB::transaction(function () use ($order, $provider, $data) {
            $order->update([
                'payment_status' => Order::PAY_PAID,
                'paid_at' => now(),
                'pay_reference' => $data['transaction_id'],
            ]);

            $order->logEvent('paid', [
                'provider' => $provider,
                'transaction_id' => $data['transaction_id'],
            ]);

            $this->logPayment($order, $provider, 'payment_confirmed', $data);

            // Send Telegram notification
            app(TelegramService::class)->sendPaid($order);

            // Auto-reserve if confirmation is complete
            if ($order->confirmation?->call_outcome === 'CONFIRMED' &&
                $order->slot->status === 'PENDING') {
                app(SlotService::class)->markReserved($order->slot, $order);
                $order->update(['status' => Order::STATUS_RESERVED]);

                // Check and send READY notification
                app(OrderService::class)->sendReadyIfEligible($order->fresh());
            }
        });
    }

    /**
     * Verify webhook signature
     */
    private function verifySignature(string $provider, array $payload, ?string $signature): bool
    {
        if (config('payments.' . $provider . '.test_mode')) {
            return true; // Skip verification in test mode
        }

        return match($provider) {
            'payme' => $this->verifyPaymeSignature($payload, $signature),
            'click' => $this->verifyClickSignature($payload),
            default => false,
        };
    }

    private function verifyPaymeSignature(array $payload, ?string $signature): bool
    {
        // Payme uses Basic auth header
        $expectedAuth = base64_encode(
            'Paycom:' . config('payments.payme.secret_key')
        );

        return $signature === $expectedAuth;
    }

    private function verifyClickSignature(array $payload): bool
    {
        $signString = $payload['click_trans_id'] .
                     $payload['service_id'] .
                     config('payments.click.secret_key') .
                     $payload['merchant_trans_id'] .
                     ($payload['merchant_prepare_id'] ?? '') .
                     $payload['amount'] .
                     $payload['action'] .
                     $payload['sign_time'];

        $expectedSign = md5($signString);

        return $payload['sign_string'] === $expectedSign;
    }

    /**
     * Log payment event
     */
    private function logPayment(Order $order, string $provider, string $event, array $data): void
    {
        PaymentLog::create([
            'order_id' => $order->id,
            'provider' => $provider,
            'event' => $event,
            'data' => $data,
        ]);
    }

    /**
     * Log incoming webhook
     */
    private function logWebhook(string $provider, array $payload): void
    {
        Log::channel('payments')->info("Webhook received: {$provider}", [
            'payload' => $payload,
            'ip' => request()->ip(),
        ]);
    }
}

Webhook Controller

php
// app/Http/Controllers/Api/PaymentWebhookController.php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Services\PaymentService;
use Illuminate\Http\Request;

class PaymentWebhookController extends Controller
{
    public function __construct(private PaymentService $paymentService) {}

    public function handle(Request $request, string $provider)
    {
        // Verify IP whitelist
        if (!$this->isAllowedIp($provider, $request->ip())) {
            abort(403, 'IP not allowed');
        }

        $payload = $request->all();

        // Get signature from header
        $signature = match($provider) {
            'payme' => str_replace('Basic ', '', $request->header('Authorization')),
            'click' => null, // Click uses sign_string in payload
            default => null,
        };

        $result = $this->paymentService->handleWebhook($provider, $payload, $signature);

        return response()->json($result);
    }

    private function isAllowedIp(string $provider, string $ip): bool
    {
        if (config('payments.' . $provider . '.test_mode')) {
            return true;
        }

        $allowedRanges = config('payments.webhook_ips.' . $provider, []);

        foreach ($allowedRanges as $range) {
            if ($this->ipInRange($ip, $range)) {
                return true;
            }
        }

        return false;
    }

    private function ipInRange(string $ip, string $range): bool
    {
        if (strpos($range, '/') === false) {
            return $ip === $range;
        }

        [$subnet, $bits] = explode('/', $range);
        $ip = ip2long($ip);
        $subnet = ip2long($subnet);
        $mask = -1 << (32 - $bits);

        return ($ip & $mask) === ($subnet & $mask);
    }
}

Routes

php
// routes/api.php

Route::post('/payments/webhook/{provider}', [PaymentWebhookController::class, 'handle'])
    ->name('api.payments.webhook')
    ->withoutMiddleware(['csrf', 'throttle']);

PaymentLog Model

php
// app/Models/PaymentLog.php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class PaymentLog extends Model
{
    protected $fillable = [
        'order_id',
        'provider',
        'event',
        'data',
    ];

    protected $casts = [
        'data' => 'array',
    ];

    public function order()
    {
        return $this->belongsTo(Order::class);
    }
}

// Migration
Schema::create('payment_logs', function (Blueprint $table) {
    $table->id();
    $table->foreignId('order_id')->constrained()->onDelete('cascade');
    $table->string('provider'); // payme, click
    $table->string('event'); // invoice_created, payment_confirmed, etc.
    $table->json('data')->nullable();
    $table->timestamps();

    $table->index(['order_id', 'provider']);
});

Admin Invoice UI

vue
<!-- resources/js/Components/Admin/PaymentBlock.vue -->

<script setup>
import { ref } from 'vue'
import { router } from '@inertiajs/vue3'

const props = defineProps({
    order: Object,
})

const selectedProvider = ref('payme')
const amount = ref(props.order.total_amount)
const loading = ref(false)

const createInvoice = () => {
    loading.value = true
    router.post(route('admin.orders.invoice', props.order.id), {
        provider: selectedProvider.value,
        amount: amount.value,
    }, {
        onFinish: () => loading.value = false,
    })
}

const copyPaymentLink = () => {
    navigator.clipboard.writeText(props.order.pay_url)
    // Show toast notification
}
</script>

<template>
    <div class="payment-block">
        <h3>Payment</h3>

        <!-- Not Invoiced -->
        <template v-if="order.payment_status === 'NOT_INVOICED'">
            <div class="form-group">
                <label>Amount (UZS)</label>
                <input type="number" v-model="amount" min="1000" step="1000" />
            </div>

            <div class="form-group">
                <label>Provider</label>
                <div class="provider-buttons">
                    <button
                        :class="{ active: selectedProvider === 'payme' }"
                        @click="selectedProvider = 'payme'"
                    >
                        Payme
                    </button>
                    <button
                        :class="{ active: selectedProvider === 'click' }"
                        @click="selectedProvider = 'click'"
                    >
                        Click
                    </button>
                </div>
            </div>

            <button
                @click="createInvoice"
                :disabled="loading"
                class="btn-primary"
            >
                {{ loading ? 'Creating...' : 'Create Invoice' }}
            </button>
        </template>

        <!-- Invoiced -->
        <template v-else-if="order.payment_status === 'INVOICED'">
            <div class="status-badge warning">Waiting for payment</div>

            <div class="payment-info">
                <p><strong>Provider:</strong> {{ order.pay_provider }}</p>
                <p><strong>Amount:</strong> {{ order.formatted_amount }}</p>
                <p v-if="order.pay_expires_at">
                    <strong>Expires:</strong> {{ order.pay_expires_at }}
                </p>
            </div>

            <div class="payment-link">
                <input :value="order.pay_url" readonly />
                <button @click="copyPaymentLink">Copy</button>
            </div>

            <button class="btn-secondary" disabled>
                Waiting for webhook...
            </button>
        </template>

        <!-- Paid -->
        <template v-else-if="order.payment_status === 'PAID'">
            <div class="status-badge success">Payment Confirmed</div>

            <div class="payment-info">
                <p><strong>Provider:</strong> {{ order.pay_provider }}</p>
                <p><strong>Amount:</strong> {{ order.formatted_amount }}</p>
                <p><strong>Paid at:</strong> {{ order.paid_at }}</p>
                <p><strong>Reference:</strong> {{ order.pay_reference }}</p>
            </div>
        </template>

        <!-- Failed -->
        <template v-else-if="order.payment_status === 'FAILED'">
            <div class="status-badge error">Payment Failed</div>
            <button @click="order.payment_status = 'NOT_INVOICED'">
                Try Again
            </button>
        </template>
    </div>
</template>

Testing Webhooks

Payme Test

bash
curl -X POST http://localhost/api/payments/webhook/payme \
  -H "Content-Type: application/json" \
  -H "Authorization: Basic cGF5Y29tOnlvdXJfc2VjcmV0X2tleQ==" \
  -d '{
    "method": "PerformTransaction",
    "params": {
      "id": "test_transaction_123",
      "account": {
        "order_id": 1
      },
      "amount": 50000000
    }
  }'

Click Test

bash
curl -X POST http://localhost/api/payments/webhook/click \
  -H "Content-Type: application/json" \
  -d '{
    "click_trans_id": "123456",
    "service_id": "your_service_id",
    "merchant_trans_id": "1",
    "amount": "500000",
    "action": 1,
    "error": 0,
    "sign_time": "2024-01-01 12:00:00",
    "sign_string": "calculated_md5_hash"
  }'

Checklist

  • Config file with Payme/Click credentials
  • PaymentService with createInvoice method
  • Payme checkout URL generation
  • Click checkout URL generation
  • Webhook controller with IP verification
  • Payme webhook handler (CheckPerform, Perform, Cancel)
  • Click webhook handler (Prepare, Complete)
  • Signature verification
  • PaymentLog model for audit trail
  • Idempotency handling (prevent duplicate processing)
  • Auto-reserve slot on successful payment
  • Telegram notification on payment
  • Admin UI for invoice creation
  • Test mode support