619 lines
21 KiB
PHP
619 lines
21 KiB
PHP
<?php
|
|
|
|
namespace humhub\modules\donations\services\providers;
|
|
|
|
use humhub\modules\donations\models\DonationGoal;
|
|
use humhub\modules\donations\models\DonationProviderConfig;
|
|
use humhub\modules\donations\models\DonationTransaction;
|
|
|
|
class PaymentGatewayService
|
|
{
|
|
public function createCheckout(
|
|
DonationTransaction $transaction,
|
|
DonationGoal $goal,
|
|
DonationProviderConfig $providerConfig,
|
|
string $successUrl,
|
|
string $cancelUrl
|
|
): array {
|
|
if ($transaction->provider === 'stripe') {
|
|
return $this->createStripeCheckout($transaction, $goal, $providerConfig, $successUrl, $cancelUrl);
|
|
}
|
|
|
|
if ($transaction->provider === 'paypal') {
|
|
return $this->createPayPalCheckout($transaction, $goal, $providerConfig, $successUrl, $cancelUrl);
|
|
}
|
|
|
|
return [
|
|
'success' => false,
|
|
'error' => 'Unsupported payment provider.',
|
|
];
|
|
}
|
|
|
|
public function capturePayPalOrder(DonationTransaction $transaction, DonationProviderConfig $providerConfig): array
|
|
{
|
|
if ($transaction->provider !== 'paypal' || $transaction->mode !== 'one_time') {
|
|
return [
|
|
'success' => false,
|
|
'error' => 'PayPal one-time order capture is only valid for one-time PayPal transactions.',
|
|
];
|
|
}
|
|
|
|
$orderId = trim((string)$transaction->provider_checkout_id);
|
|
if ($orderId === '') {
|
|
return [
|
|
'success' => false,
|
|
'error' => 'PayPal order ID is missing on transaction.',
|
|
];
|
|
}
|
|
|
|
$context = $this->getPayPalRequestContext($providerConfig);
|
|
if (!($context['ok'] ?? false)) {
|
|
return [
|
|
'success' => false,
|
|
'error' => (string)($context['error'] ?? 'PayPal auth context is unavailable.'),
|
|
'status' => (int)($context['status'] ?? 0),
|
|
'response' => (string)($context['response'] ?? ''),
|
|
];
|
|
}
|
|
|
|
$baseUrl = (string)$context['base_url'];
|
|
$accessToken = (string)$context['access_token'];
|
|
|
|
$captureResponse = $this->httpRequest(
|
|
'POST',
|
|
$baseUrl . '/v2/checkout/orders/' . rawurlencode($orderId) . '/capture',
|
|
[
|
|
'Authorization: Bearer ' . $accessToken,
|
|
'Content-Type: application/json',
|
|
'Prefer: return=representation',
|
|
],
|
|
'{}'
|
|
);
|
|
|
|
$captureData = json_decode((string)$captureResponse['body'], true);
|
|
$captureId = $this->extractPayPalCaptureId($captureData);
|
|
|
|
if ($captureResponse['ok']) {
|
|
return [
|
|
'success' => true,
|
|
'payment_id' => $captureId,
|
|
'raw' => is_array($captureData) ? $captureData : null,
|
|
];
|
|
}
|
|
|
|
if ((int)$captureResponse['status'] === 422 && stripos((string)$captureResponse['body'], 'ORDER_ALREADY_CAPTURED') !== false) {
|
|
$orderResponse = $this->httpRequest(
|
|
'GET',
|
|
$baseUrl . '/v2/checkout/orders/' . rawurlencode($orderId),
|
|
[
|
|
'Authorization: Bearer ' . $accessToken,
|
|
'Content-Type: application/json',
|
|
],
|
|
''
|
|
);
|
|
|
|
$orderData = json_decode((string)$orderResponse['body'], true);
|
|
if ($orderResponse['ok']) {
|
|
return [
|
|
'success' => true,
|
|
'already_captured' => true,
|
|
'payment_id' => $this->extractPayPalCaptureId($orderData),
|
|
'raw' => is_array($orderData) ? $orderData : null,
|
|
];
|
|
}
|
|
}
|
|
|
|
return [
|
|
'success' => false,
|
|
'error' => 'PayPal order capture failed.',
|
|
'status' => (int)$captureResponse['status'],
|
|
'response' => (string)$captureResponse['body'],
|
|
];
|
|
}
|
|
|
|
private function createStripeCheckout(
|
|
DonationTransaction $transaction,
|
|
DonationGoal $goal,
|
|
DonationProviderConfig $providerConfig,
|
|
string $successUrl,
|
|
string $cancelUrl
|
|
): array {
|
|
$secretKey = trim(ProviderCredentialResolver::resolveStripeSecretKey($providerConfig));
|
|
if ($secretKey === '') {
|
|
return [
|
|
'success' => false,
|
|
'error' => 'Stripe secret key is missing.',
|
|
];
|
|
}
|
|
|
|
$currency = strtolower((string)$transaction->currency);
|
|
$unitAmount = (int)round(((float)$transaction->amount) * 100);
|
|
if ($unitAmount < 1) {
|
|
return [
|
|
'success' => false,
|
|
'error' => 'Invalid donation amount.',
|
|
];
|
|
}
|
|
|
|
$lineItem = [
|
|
'line_items[0][quantity]' => 1,
|
|
'line_items[0][price_data][currency]' => $currency,
|
|
'line_items[0][price_data][product_data][name]' => (string)$goal->title,
|
|
'line_items[0][price_data][unit_amount]' => $unitAmount,
|
|
];
|
|
|
|
if ($transaction->mode === 'recurring') {
|
|
$payload = array_merge($lineItem, [
|
|
'mode' => 'subscription',
|
|
'line_items[0][price_data][recurring][interval]' => 'month',
|
|
]);
|
|
} else {
|
|
$payload = array_merge($lineItem, [
|
|
'mode' => 'payment',
|
|
]);
|
|
}
|
|
|
|
$payload['success_url'] = $successUrl;
|
|
$payload['cancel_url'] = $cancelUrl;
|
|
$payload['metadata[transaction_id]'] = (string)$transaction->id;
|
|
$payload['metadata[goal_id]'] = (string)$transaction->goal_id;
|
|
$payload['metadata[contentcontainer_id]'] = (string)$transaction->contentcontainer_id;
|
|
|
|
$response = $this->httpRequest(
|
|
'POST',
|
|
'https://api.stripe.com/v1/checkout/sessions',
|
|
[
|
|
'Authorization: Bearer ' . $secretKey,
|
|
'Content-Type: application/x-www-form-urlencoded',
|
|
],
|
|
http_build_query($payload, '', '&')
|
|
);
|
|
|
|
if (!$response['ok']) {
|
|
return [
|
|
'success' => false,
|
|
'error' => 'Stripe checkout session creation failed.',
|
|
'status' => $response['status'],
|
|
'response' => $response['body'],
|
|
];
|
|
}
|
|
|
|
$data = json_decode((string)$response['body'], true);
|
|
if (!is_array($data) || empty($data['url']) || empty($data['id'])) {
|
|
return [
|
|
'success' => false,
|
|
'error' => 'Stripe response is invalid.',
|
|
'response' => $response['body'],
|
|
];
|
|
}
|
|
|
|
return [
|
|
'success' => true,
|
|
'redirect_url' => (string)$data['url'],
|
|
'checkout_id' => (string)$data['id'],
|
|
'payment_id' => (string)($data['payment_intent'] ?? ''),
|
|
'subscription_id' => (string)($data['subscription'] ?? ''),
|
|
'customer_id' => (string)($data['customer'] ?? ''),
|
|
'raw' => $data,
|
|
];
|
|
}
|
|
|
|
private function createPayPalCheckout(
|
|
DonationTransaction $transaction,
|
|
DonationGoal $goal,
|
|
DonationProviderConfig $providerConfig,
|
|
string $successUrl,
|
|
string $cancelUrl
|
|
): array {
|
|
$clientId = trim(ProviderCredentialResolver::resolvePayPalClientId($providerConfig));
|
|
$clientSecret = trim(ProviderCredentialResolver::resolvePayPalClientSecret($providerConfig));
|
|
|
|
if ($clientId === '' || $clientSecret === '') {
|
|
return [
|
|
'success' => false,
|
|
'error' => 'PayPal client credentials are missing.',
|
|
];
|
|
}
|
|
|
|
$baseUrl = ProviderCredentialResolver::isSandboxMode($providerConfig)
|
|
? 'https://api-m.sandbox.paypal.com'
|
|
: 'https://api-m.paypal.com';
|
|
|
|
$tokenResponse = $this->httpRequest(
|
|
'POST',
|
|
$baseUrl . '/v1/oauth2/token',
|
|
[
|
|
'Authorization: Basic ' . base64_encode($clientId . ':' . $clientSecret),
|
|
'Content-Type: application/x-www-form-urlencoded',
|
|
],
|
|
'grant_type=client_credentials'
|
|
);
|
|
|
|
if (!$tokenResponse['ok']) {
|
|
return [
|
|
'success' => false,
|
|
'error' => 'PayPal token request failed.',
|
|
'status' => $tokenResponse['status'],
|
|
'response' => $tokenResponse['body'],
|
|
];
|
|
}
|
|
|
|
$accessToken = $this->extractAccessToken($tokenResponse['body']);
|
|
if ($accessToken === '') {
|
|
return [
|
|
'success' => false,
|
|
'error' => 'PayPal access token missing in response.',
|
|
];
|
|
}
|
|
|
|
if ($transaction->mode === 'recurring') {
|
|
return $this->createPayPalRecurringCheckout(
|
|
$transaction,
|
|
$goal,
|
|
$providerConfig,
|
|
$baseUrl,
|
|
$accessToken,
|
|
$successUrl,
|
|
$cancelUrl
|
|
);
|
|
}
|
|
|
|
$orderPayload = [
|
|
'intent' => 'CAPTURE',
|
|
'purchase_units' => [[
|
|
'custom_id' => (string)$transaction->id,
|
|
'description' => (string)$goal->title,
|
|
'amount' => [
|
|
'currency_code' => strtoupper((string)$transaction->currency),
|
|
'value' => number_format((float)$transaction->amount, 2, '.', ''),
|
|
],
|
|
]],
|
|
'application_context' => [
|
|
'return_url' => $successUrl,
|
|
'cancel_url' => $cancelUrl,
|
|
],
|
|
];
|
|
|
|
$orderResponse = $this->httpRequest(
|
|
'POST',
|
|
$baseUrl . '/v2/checkout/orders',
|
|
[
|
|
'Authorization: Bearer ' . $accessToken,
|
|
'Content-Type: application/json',
|
|
],
|
|
json_encode($orderPayload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
|
|
);
|
|
|
|
if (!$orderResponse['ok']) {
|
|
return [
|
|
'success' => false,
|
|
'error' => 'PayPal order creation failed.',
|
|
'status' => $orderResponse['status'],
|
|
'response' => $orderResponse['body'],
|
|
];
|
|
}
|
|
|
|
$orderData = json_decode((string)$orderResponse['body'], true);
|
|
if (!is_array($orderData) || empty($orderData['id'])) {
|
|
return [
|
|
'success' => false,
|
|
'error' => 'PayPal response is invalid.',
|
|
'response' => $orderResponse['body'],
|
|
];
|
|
}
|
|
|
|
$approveUrl = '';
|
|
foreach ((array)($orderData['links'] ?? []) as $link) {
|
|
if (($link['rel'] ?? '') === 'approve') {
|
|
$approveUrl = (string)($link['href'] ?? '');
|
|
break;
|
|
}
|
|
}
|
|
|
|
if ($approveUrl === '') {
|
|
return [
|
|
'success' => false,
|
|
'error' => 'PayPal approve URL is missing.',
|
|
'response' => $orderResponse['body'],
|
|
];
|
|
}
|
|
|
|
return [
|
|
'success' => true,
|
|
'redirect_url' => $approveUrl,
|
|
'checkout_id' => (string)$orderData['id'],
|
|
'raw' => $orderData,
|
|
];
|
|
}
|
|
|
|
private function createPayPalRecurringCheckout(
|
|
DonationTransaction $transaction,
|
|
DonationGoal $goal,
|
|
DonationProviderConfig $providerConfig,
|
|
string $baseUrl,
|
|
string $accessToken,
|
|
string $successUrl,
|
|
string $cancelUrl
|
|
): array {
|
|
$currency = strtoupper((string)$transaction->currency);
|
|
$amount = number_format((float)$transaction->amount, 2, '.', '');
|
|
|
|
$productPayload = [
|
|
'name' => substr((string)$goal->title, 0, 127),
|
|
'description' => substr((string)($goal->description ?: $goal->title), 0, 255),
|
|
'type' => 'SERVICE',
|
|
'category' => 'SOFTWARE',
|
|
];
|
|
|
|
$productResponse = $this->httpRequest(
|
|
'POST',
|
|
$baseUrl . '/v1/catalogs/products',
|
|
[
|
|
'Authorization: Bearer ' . $accessToken,
|
|
'Content-Type: application/json',
|
|
],
|
|
json_encode($productPayload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
|
|
);
|
|
|
|
if (!$productResponse['ok']) {
|
|
return [
|
|
'success' => false,
|
|
'error' => 'PayPal product creation failed.',
|
|
'status' => $productResponse['status'],
|
|
'response' => $productResponse['body'],
|
|
];
|
|
}
|
|
|
|
$productData = json_decode((string)$productResponse['body'], true);
|
|
$productId = (string)($productData['id'] ?? '');
|
|
if ($productId === '') {
|
|
return [
|
|
'success' => false,
|
|
'error' => 'PayPal product response is invalid.',
|
|
'response' => $productResponse['body'],
|
|
];
|
|
}
|
|
|
|
$planPayload = [
|
|
'product_id' => $productId,
|
|
'name' => substr((string)$goal->title . ' Monthly Donation', 0, 127),
|
|
'status' => 'ACTIVE',
|
|
'billing_cycles' => [[
|
|
'frequency' => [
|
|
'interval_unit' => 'MONTH',
|
|
'interval_count' => 1,
|
|
],
|
|
'tenure_type' => 'REGULAR',
|
|
'sequence' => 1,
|
|
'total_cycles' => 0,
|
|
'pricing_scheme' => [
|
|
'fixed_price' => [
|
|
'value' => $amount,
|
|
'currency_code' => $currency,
|
|
],
|
|
],
|
|
]],
|
|
'payment_preferences' => [
|
|
'auto_bill_outstanding' => true,
|
|
'setup_fee_failure_action' => 'CONTINUE',
|
|
'payment_failure_threshold' => 3,
|
|
],
|
|
];
|
|
|
|
$planResponse = $this->httpRequest(
|
|
'POST',
|
|
$baseUrl . '/v1/billing/plans',
|
|
[
|
|
'Authorization: Bearer ' . $accessToken,
|
|
'Content-Type: application/json',
|
|
],
|
|
json_encode($planPayload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
|
|
);
|
|
|
|
if (!$planResponse['ok']) {
|
|
return [
|
|
'success' => false,
|
|
'error' => 'PayPal plan creation failed.',
|
|
'status' => $planResponse['status'],
|
|
'response' => $planResponse['body'],
|
|
];
|
|
}
|
|
|
|
$planData = json_decode((string)$planResponse['body'], true);
|
|
$planId = (string)($planData['id'] ?? '');
|
|
if ($planId === '') {
|
|
return [
|
|
'success' => false,
|
|
'error' => 'PayPal plan response is invalid.',
|
|
'response' => $planResponse['body'],
|
|
];
|
|
}
|
|
|
|
$subscriptionPayload = [
|
|
'plan_id' => $planId,
|
|
'custom_id' => (string)$transaction->id,
|
|
'application_context' => [
|
|
'brand_name' => 'Rescue Hub',
|
|
'user_action' => 'SUBSCRIBE_NOW',
|
|
'return_url' => $successUrl,
|
|
'cancel_url' => $cancelUrl,
|
|
],
|
|
];
|
|
|
|
$subscriptionResponse = $this->httpRequest(
|
|
'POST',
|
|
$baseUrl . '/v1/billing/subscriptions',
|
|
[
|
|
'Authorization: Bearer ' . $accessToken,
|
|
'Content-Type: application/json',
|
|
],
|
|
json_encode($subscriptionPayload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
|
|
);
|
|
|
|
if (!$subscriptionResponse['ok']) {
|
|
return [
|
|
'success' => false,
|
|
'error' => 'PayPal subscription creation failed.',
|
|
'status' => $subscriptionResponse['status'],
|
|
'response' => $subscriptionResponse['body'],
|
|
];
|
|
}
|
|
|
|
$subscriptionData = json_decode((string)$subscriptionResponse['body'], true);
|
|
$subscriptionId = (string)($subscriptionData['id'] ?? '');
|
|
if ($subscriptionId === '') {
|
|
return [
|
|
'success' => false,
|
|
'error' => 'PayPal subscription response is invalid.',
|
|
'response' => $subscriptionResponse['body'],
|
|
];
|
|
}
|
|
|
|
$approveUrl = '';
|
|
foreach ((array)($subscriptionData['links'] ?? []) as $link) {
|
|
if (($link['rel'] ?? '') === 'approve') {
|
|
$approveUrl = (string)($link['href'] ?? '');
|
|
break;
|
|
}
|
|
}
|
|
|
|
if ($approveUrl === '') {
|
|
return [
|
|
'success' => false,
|
|
'error' => 'PayPal subscription approve URL is missing.',
|
|
'response' => $subscriptionResponse['body'],
|
|
];
|
|
}
|
|
|
|
return [
|
|
'success' => true,
|
|
'redirect_url' => $approveUrl,
|
|
'checkout_id' => $subscriptionId,
|
|
'subscription_id' => $subscriptionId,
|
|
'raw' => $subscriptionData,
|
|
];
|
|
}
|
|
|
|
private function extractAccessToken(string $responseBody): string
|
|
{
|
|
$data = json_decode($responseBody, true);
|
|
return (string)($data['access_token'] ?? '');
|
|
}
|
|
|
|
private function getPayPalRequestContext(DonationProviderConfig $providerConfig): array
|
|
{
|
|
$clientId = trim(ProviderCredentialResolver::resolvePayPalClientId($providerConfig));
|
|
$clientSecret = trim(ProviderCredentialResolver::resolvePayPalClientSecret($providerConfig));
|
|
|
|
if ($clientId === '' || $clientSecret === '') {
|
|
return [
|
|
'ok' => false,
|
|
'error' => 'PayPal client credentials are missing.',
|
|
'status' => 0,
|
|
'response' => '',
|
|
];
|
|
}
|
|
|
|
$baseUrl = ProviderCredentialResolver::isSandboxMode($providerConfig)
|
|
? 'https://api-m.sandbox.paypal.com'
|
|
: 'https://api-m.paypal.com';
|
|
|
|
$tokenResponse = $this->httpRequest(
|
|
'POST',
|
|
$baseUrl . '/v1/oauth2/token',
|
|
[
|
|
'Authorization: Basic ' . base64_encode($clientId . ':' . $clientSecret),
|
|
'Content-Type: application/x-www-form-urlencoded',
|
|
],
|
|
'grant_type=client_credentials'
|
|
);
|
|
|
|
if (!$tokenResponse['ok']) {
|
|
return [
|
|
'ok' => false,
|
|
'error' => 'PayPal token request failed.',
|
|
'status' => (int)$tokenResponse['status'],
|
|
'response' => (string)$tokenResponse['body'],
|
|
];
|
|
}
|
|
|
|
$accessToken = $this->extractAccessToken((string)$tokenResponse['body']);
|
|
if ($accessToken === '') {
|
|
return [
|
|
'ok' => false,
|
|
'error' => 'PayPal access token missing in response.',
|
|
'status' => 0,
|
|
'response' => (string)$tokenResponse['body'],
|
|
];
|
|
}
|
|
|
|
return [
|
|
'ok' => true,
|
|
'base_url' => $baseUrl,
|
|
'access_token' => $accessToken,
|
|
];
|
|
}
|
|
|
|
private function extractPayPalCaptureId($orderData): string
|
|
{
|
|
if (!is_array($orderData)) {
|
|
return '';
|
|
}
|
|
|
|
$units = (array)($orderData['purchase_units'] ?? []);
|
|
$firstUnit = $units[0] ?? null;
|
|
if (!is_array($firstUnit)) {
|
|
return '';
|
|
}
|
|
|
|
$captures = (array)($firstUnit['payments']['captures'] ?? []);
|
|
$firstCapture = $captures[0] ?? null;
|
|
if (!is_array($firstCapture)) {
|
|
return '';
|
|
}
|
|
|
|
return (string)($firstCapture['id'] ?? '');
|
|
}
|
|
|
|
private function httpRequest(string $method, string $url, array $headers, string $body): array
|
|
{
|
|
if (!function_exists('curl_init')) {
|
|
return [
|
|
'ok' => false,
|
|
'status' => 0,
|
|
'body' => 'cURL extension is not available.',
|
|
];
|
|
}
|
|
|
|
$ch = curl_init($url);
|
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
|
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, strtoupper($method));
|
|
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
|
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
|
|
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
|
|
|
|
if ($body !== '') {
|
|
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
|
}
|
|
|
|
$raw = curl_exec($ch);
|
|
$err = curl_error($ch);
|
|
$status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
curl_close($ch);
|
|
|
|
if ($raw === false) {
|
|
return [
|
|
'ok' => false,
|
|
'status' => $status,
|
|
'body' => $err,
|
|
];
|
|
}
|
|
|
|
return [
|
|
'ok' => $status >= 200 && $status < 300,
|
|
'status' => $status,
|
|
'body' => (string)$raw,
|
|
];
|
|
}
|
|
}
|