From eb08a3379a1239f848134a54b2027dbfa2c56f80 Mon Sep 17 00:00:00 2001 From: Atharva Deosthale Date: Thu, 13 Nov 2025 01:29:29 +0530 Subject: [PATCH] very rough progress --- .../Payments/Provider/StripeAdapter.php | 198 ++++++++++++------ .../Modules/Payments/Http/Plans/Create.php | 13 +- .../Modules/Payments/Http/Plans/Update.php | 7 +- .../Payments/Http/Providers/Update.php | 13 +- 4 files changed, 165 insertions(+), 66 deletions(-) diff --git a/src/Appwrite/Payments/Provider/StripeAdapter.php b/src/Appwrite/Payments/Provider/StripeAdapter.php index ac65239b19..06a596d4cb 100644 --- a/src/Appwrite/Payments/Provider/StripeAdapter.php +++ b/src/Appwrite/Payments/Provider/StripeAdapter.php @@ -2,14 +2,11 @@ namespace Appwrite\Payments\Provider; -use Swoole\Coroutine\Http\ClientProxy; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Query; use Utopia\System\System; -use function Swoole\Coroutine\Http\request; - class StripeAdapter implements Adapter { public function __construct( @@ -28,11 +25,20 @@ class StripeAdapter implements Adapter public function configure(array $config, Document $project): ProviderState { $apiKey = (string) ($config['secretKey'] ?? ''); - $account = $this->request($apiKey, 'GET', '/account'); - $domain = System::getEnv('_APP_DOMAIN', 'localhost'); - $scheme = System::getEnv('_APP_OPTIONS_FORCE_HTTPS', 'enabled') === 'disabled' ? 'http' : 'https'; + $accountResponse = $this->request($apiKey, 'GET', '/account'); + $account = $this->decodeResponse($accountResponse); + $rawDomain = (string) System::getEnv('_APP_DOMAIN', ''); + $domain = \trim($rawDomain); + if ($domain === '' || \in_array(\strtolower($domain), ['localhost', '127.0.0.1'], true)) { + throw new \RuntimeException('Appwrite domain is not configured. Set _APP_DOMAIN to a publicly accessible hostname before configuring Stripe.'); + } + $domain = (string) \preg_replace('/^\s*https?:\/\//i', '', $domain); + $domain = \ltrim($domain, '/'); + $domain = \rtrim($domain, '/'); + $forceHttps = System::getEnv('_APP_OPTIONS_FORCE_HTTPS', 'enabled') !== 'disabled'; + $scheme = $forceHttps ? 'https' : 'http'; $webhookUrl = $scheme . '://' . $domain . '/v1/payments/webhooks/stripe/' . $project->getId(); - $endpoint = $this->request($apiKey, 'POST', '/webhook_endpoints', [ + $endpointResponse = $this->request($apiKey, 'POST', '/webhook_endpoints', [ 'url' => $webhookUrl, 'enabled_events' => [ 'checkout.session.completed', @@ -49,7 +55,7 @@ class StripeAdapter implements Adapter 'description' => 'Appwrite Payments Webhook for Project ' . $project->getId() ]); - $endpointData = json_decode($endpoint->getBody(), true); + $endpointData = $this->decodeResponse($endpointResponse); $meta = [ 'currency' => $account['default_currency'] ?? 'usd', @@ -65,14 +71,21 @@ class StripeAdapter implements Adapter $name = (string) ($planData['name'] ?? ''); $description = (string) ($planData['description'] ?? ''); - $providerPlanId = json_decode($planData['providers']['stripe']['planId'] ?? '', true); + $providerPlanRaw = $planData['providers']['stripe']['planId'] ?? ''; + $providerPlanDecoded = \is_string($providerPlanRaw) ? \json_decode($providerPlanRaw, true) : $providerPlanRaw; + $providerPlanId = ''; + if (\is_array($providerPlanDecoded)) { + $providerPlanId = (string) ($providerPlanDecoded['id'] ?? ''); + } elseif (\is_string($providerPlanDecoded)) { + $providerPlanId = $providerPlanDecoded; + } - // Check if product already exists - $existingProduct = $this->request($apiKey, 'GET', '/products/' . $providerPlanId); - - if ($existingProduct->getStatusCode() === 200) { - $data = json_decode($existingProduct->getBody(), true); - return new ProviderPlanRef(externalPlanId: $data['id'], metadata: ['productId' => $data['id']]); + if ($providerPlanId !== '') { + $existingProduct = $this->request($apiKey, 'GET', '/products/' . $providerPlanId); + if (($existingProduct['status'] ?? 0) === 200) { + $data = $this->decodeResponse($existingProduct); + return new ProviderPlanRef(externalPlanId: $data['id'], metadata: ['productId' => $data['id']]); + } } $product = $this->request($apiKey, 'POST', '/products', [ @@ -83,7 +96,7 @@ class StripeAdapter implements Adapter 'plan_id' => (string) ($planData['planId'] ?? '') ] ]); - $productData = json_decode($product->getBody(), true); + $productData = $this->decodeResponse($product); $productId = (string) ($productData['id'] ?? ''); $refs = ['productId' => $productId, 'prices' => []]; $pricing = (array) ($planData['pricing'] ?? []); @@ -101,7 +114,8 @@ class StripeAdapter implements Adapter 'type' => 'payments_plan_price' ] ]); - $refs['prices'][] = $res['id'] ?? null; + $resData = $this->decodeResponse($res); + $refs['prices'][] = $resData['id'] ?? null; } return new ProviderPlanRef(externalPlanId: $productId, metadata: $refs); } @@ -133,7 +147,7 @@ class StripeAdapter implements Adapter continue; } $price = $this->request($apiKey, 'GET', '/prices/' . $pid); - $priceData = json_decode($price->getBody(), true); + $priceData = $this->decodeResponse($price); $currency = (string) ($priceData['currency'] ?? ''); $interval = (string) ($priceData['recurring']['interval'] ?? ''); $amount = (int) ($priceData['unit_amount'] ?? 0); @@ -167,7 +181,7 @@ class StripeAdapter implements Adapter 'type' => 'payments_plan_price' ] ]); - $resData = json_decode($res->getBody(), true); + $resData = $this->decodeResponse($res); $keptPriceIds[] = (string) ($resData['id'] ?? ''); } @@ -239,7 +253,14 @@ class StripeAdapter implements Adapter } $price = $this->request($apiKey, 'POST', '/prices', $priceParams); - return new ProviderFeatureRef(externalFeatureId: (string) ($price['id'] ?? ''), metadata: ['priceId' => $price['id'] ?? null, 'meterId' => $meter]); + $priceData = $this->decodeResponse($price); + return new ProviderFeatureRef( + externalFeatureId: (string) ($priceData['id'] ?? ''), + metadata: [ + 'priceId' => $priceData['id'] ?? null, + 'meterId' => $meter, + ] + ); } public function deleteFeature(ProviderFeatureRef $feature, ProviderPlanRef $plan, ProviderState $state): void @@ -270,8 +291,7 @@ class StripeAdapter implements Adapter 'payment_behavior' => 'default_incomplete', 'metadata' => [ 'project_id' => $this->project->getId(), 'actor_id' => $actor->getId() ] ]); - - $respData = json_decode($resp->getBody(), true); + $respData = $this->decodeResponse($resp); // Map Stripe status to internal status $stripeStatus = (string) ($respData['status'] ?? 'incomplete'); @@ -299,7 +319,7 @@ class StripeAdapter implements Adapter $newPriceId = (string) ($changes['priceId'] ?? ''); if ($newPriceId !== '') { $sub = $this->request($apiKey, 'GET', '/subscriptions/' . $subscription->externalSubscriptionId); - $subData = json_decode($sub->getBody(), true); + $subData = $this->decodeResponse($sub); $itemId = $subData['items']['data'][0]['id'] ?? ''; if ($itemId !== '') { $this->request($apiKey, 'POST', '/subscriptions/' . $subscription->externalSubscriptionId, [ @@ -340,7 +360,7 @@ class StripeAdapter implements Adapter 'metadata' => [ 'project_id' => $this->project->getId(), 'actor_id' => $actor->getId() ] ]; $sessionResponse = $this->request($apiKey, 'POST', '/checkout/sessions', $params); - $sessionData = json_decode($sessionResponse->getBody(), true); + $sessionData = $this->decodeResponse($sessionResponse); return new ProviderCheckoutSession(url: (string) ($sessionData['url'] ?? '')); } @@ -350,7 +370,7 @@ class StripeAdapter implements Adapter $returnUrl = (string) ($options['returnUrl'] ?? ''); $customerId = $this->ensureCustomer($apiKey, $actor); $sessionResponse = $this->request($apiKey, 'POST', '/billing_portal/sessions', [ 'customer' => $customerId, 'return_url' => $returnUrl ]); - $sessionData = json_decode($sessionResponse->getBody(), true); + $sessionData = $this->decodeResponse($sessionResponse); return new ProviderPortalSession(url: (string) ($sessionData['url'] ?? '')); } @@ -363,7 +383,7 @@ class StripeAdapter implements Adapter if ($stripeSubId !== '') { try { $sub = $this->request($apiKey, 'GET', '/subscriptions/' . $stripeSubId); - $subData = json_decode($sub->getBody(), true); + $subData = $this->decodeResponse($sub); $customerId = (string) ($subData['customer'] ?? ''); } catch (\Throwable $_) { $customerId = ''; @@ -494,7 +514,7 @@ class StripeAdapter implements Adapter { $eventName = 'appwrite.payments.feature.usage.' . $projectId . '.' . $planId . '.' . $featureId; $list = $this->request($apiKey, 'GET', '/billing/meters', ['limit' => 100]); - $listData = json_decode($list->getBody(), true); + $listData = $this->decodeResponse($list); if (isset($listData['data']) && is_array($listData['data'])) { foreach ($listData['data'] as $m) { if ((string) ($m['event_name'] ?? '') === $eventName) { @@ -513,7 +533,7 @@ class StripeAdapter implements Adapter 'value_settings' => [ 'event_payload_key' => 'value' ], 'customer_mapping' => [ 'type' => 'by_id', 'event_payload_key' => 'stripe_customer_id' ], ]); - $meterData = json_decode($meter->getBody(), true); + $meterData = $this->decodeResponse($meter); return (string) ($meterData['id'] ?? ''); } @@ -528,7 +548,7 @@ class StripeAdapter implements Adapter 'email' => $actor->getAttribute('email', ''), 'metadata' => [ 'project_id' => $this->project->getId(), 'actor_id' => $actor->getId() ] ]); - $customerData = json_decode($customer->getBody(), true); + $customerData = $this->decodeResponse($customer); $customerId = (string) ($customerData['id'] ?? ''); try { $actor->setAttribute('stripeCustomerId', $customerId); @@ -540,39 +560,95 @@ class StripeAdapter implements Adapter return $customerId; } - public function request(string $apiKey, string $method, string $path, array $params = []): ClientProxy + /** + * @return array{status:int,body:string,decoded:mixed} + */ + public function request(string $apiKey, string $method, string $path, array $params = []): array { - // $ch = \curl_init(); - // $url = 'https://api.stripe.com/v1' . $path; - // $headers = [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/x-www-form-urlencoded' ]; - // \curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - // \curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); - // switch ($method) { - // case 'GET': - // if (!empty($params)) { $url .= '?' . http_build_query($params); } - // break; - // case 'POST': - // \curl_setopt($ch, CURLOPT_POST, true); - // if (!empty($params)) { \curl_setopt($ch, CURLOPT_POSTFIELDS, $this->buildFormData($params)); } - // break; - // case 'DELETE': - // \curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE'); - // if (!empty($params)) { \curl_setopt($ch, CURLOPT_POSTFIELDS, $this->buildFormData($params)); } - // break; - // } - // \curl_setopt($ch, CURLOPT_URL, $url); - // $response = \curl_exec($ch); - // $httpCode = (int) \curl_getinfo($ch, CURLINFO_HTTP_CODE); - // \curl_close($ch); - // if ($response === false) { throw new \RuntimeException('Stripe API request failed'); } - // $data = \json_decode($response, true); - // if ($httpCode >= 400) { - // $message = is_array($data) && isset($data['error']['message']) ? (string) $data['error']['message'] : 'Stripe API error'; - // throw new \RuntimeException($message, $httpCode); - // } - // return is_array($data) ? $data : []; - $response = request($method, $path, $params, ['headers' => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/x-www-form-urlencoded' ]]); - return $response; + // Construct full Stripe API URL + $baseUrl = 'https://api.stripe.com/v1'; + $url = $baseUrl . $path; + + // Build headers + $headers = [ + 'Authorization: Bearer ' . $apiKey, + 'Content-Type: application/x-www-form-urlencoded' + ]; + + // Handle GET requests - append params as query string + if ($method === 'GET' && !empty($params)) { + $url .= '?' . http_build_query($params); + } + + // Build form data for POST/DELETE requests + $body = ''; + if (!empty($params) && ($method === 'POST' || $method === 'DELETE')) { + $body = $this->buildFormData($params); + } + + $ch = \curl_init($url); + if ($ch === false) { + throw new \RuntimeException('Unable to initialise Stripe curl handle'); + } + + $curlOptions = [ + \CURLOPT_RETURNTRANSFER => true, + \CURLOPT_CUSTOMREQUEST => $method, + \CURLOPT_HTTPHEADER => $headers, + \CURLOPT_TIMEOUT => 30, + \CURLOPT_SSL_VERIFYPEER => true, + \CURLOPT_SSL_VERIFYHOST => 2, + ]; + + if ($body !== '') { + $curlOptions[\CURLOPT_POSTFIELDS] = $body; + } + + if (!\curl_setopt_array($ch, $curlOptions)) { + $error = \curl_error($ch); + \curl_close($ch); + throw new \RuntimeException('Failed to configure Stripe curl request: ' . $error); + } + + $responseBody = \curl_exec($ch); + if ($responseBody === false) { + $error = \curl_error($ch); + \curl_close($ch); + throw new \RuntimeException('Stripe curl request failed: ' . $error); + } + + $statusCode = (int) \curl_getinfo($ch, \CURLINFO_HTTP_CODE); + \curl_close($ch); + + if ($statusCode >= 400) { + $responseData = json_decode($responseBody, true); + $message = is_array($responseData) && isset($responseData['error']['message']) + ? (string) $responseData['error']['message'] + : 'Stripe API error'; + throw new \RuntimeException($message, $statusCode); + } + return [ + 'status' => $statusCode, + 'body' => $responseBody, + 'decoded' => \json_decode($responseBody, true), + ]; + } + + /** + * Safely extract decoded JSON from a Stripe response array. + * + * @param array{status:int,body:string,decoded:mixed} $response + * @return array + */ + private function decodeResponse(array $response): array + { + $decoded = $response['decoded'] ?? null; + if (\is_array($decoded)) { + return $decoded; + } + + $decoded = \json_decode($response['body'] ?? '', true); + return \is_array($decoded) ? $decoded : []; } private function buildFormData(array $params, string $prefix = ''): string diff --git a/src/Appwrite/Platform/Modules/Payments/Http/Plans/Create.php b/src/Appwrite/Platform/Modules/Payments/Http/Plans/Create.php index ccbfb1f428..60783339b0 100644 --- a/src/Appwrite/Platform/Modules/Payments/Http/Plans/Create.php +++ b/src/Appwrite/Platform/Modules/Payments/Http/Plans/Create.php @@ -3,11 +3,11 @@ namespace Appwrite\Platform\Modules\Payments\Http\Plans; use Appwrite\AppwriteException; +use Appwrite\Event\Event; use Appwrite\Extend\Exception as ExtendException; use Appwrite\Payments\Provider\ProviderState; use Appwrite\Payments\Provider\Registry; use Appwrite\Platform\Modules\Compute\Base; -use Appwrite\Query; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; @@ -16,6 +16,7 @@ use Appwrite\Utopia\Response; use Exception; use Utopia\Database\Database; use Utopia\Database\Document; +use Utopia\Database\Query; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\Validator\Boolean; @@ -40,7 +41,7 @@ class Create extends Base ->desc('Create payment plan') ->label('scope', 'payments.write') ->label('resourceType', RESOURCE_TYPE_PAYMENTS) - ->label('event', 'payments.plans.[planId].create') + ->label('event', 'plans.[planId].create') ->label('audits.event', 'payments.plan.create') ->label('audits.resource', 'payments/plan/{request.planId}') ->label('sdk', new Method( @@ -66,6 +67,7 @@ class Create extends Base ->inject('dbForPlatform') ->inject('dbForProject') ->inject('registryPayments') + ->inject('queueForEvents') ->inject('project') ->callback($this->action(...)); } @@ -81,6 +83,7 @@ class Create extends Base Database $dbForPlatform, Database $dbForProject, Registry $registryPayments, + Event $queueForEvents, Document $project ) { $document = new Document([ @@ -118,6 +121,8 @@ class Create extends Base } $created = $dbForPlatform->createDocument('payments_plans', $document); + + $queueForEvents->setParam('planId', $planId); // Provision on configured providers $payments = (array) $project->getAttribute('payments', []); @@ -132,9 +137,11 @@ class Create extends Base 'description' => $description, 'pricing' => $pricing, ], $state); + $meta = $ref->metadata; $providersMeta[$providerId] = [ 'externalId' => $ref->externalPlanId, - 'metadata' => $ref->metadata + 'metadata' => $meta, + 'prices' => (array) ($meta['prices'] ?? []) ]; } diff --git a/src/Appwrite/Platform/Modules/Payments/Http/Plans/Update.php b/src/Appwrite/Platform/Modules/Payments/Http/Plans/Update.php index cafbb9d38b..1797e8ee56 100644 --- a/src/Appwrite/Platform/Modules/Payments/Http/Plans/Update.php +++ b/src/Appwrite/Platform/Modules/Payments/Http/Plans/Update.php @@ -125,7 +125,12 @@ class Update extends Base 'description' => $description, 'pricing' => !empty($pricing) ? $pricing : ($plan->getAttribute('pricing') ?? []) ], $ref, $state); - $providersMeta[$providerId] = [ 'externalId' => $newRef->externalPlanId, 'metadata' => $newRef->metadata ]; + $meta = $newRef->metadata; + $providersMeta[$providerId] = [ + 'externalId' => $newRef->externalPlanId, + 'metadata' => $meta, + 'prices' => (array) ($meta['prices'] ?? []) + ]; } if (!empty($providersMeta)) { $plan->setAttribute('providers', $providersMeta); diff --git a/src/Appwrite/Platform/Modules/Payments/Http/Providers/Update.php b/src/Appwrite/Platform/Modules/Payments/Http/Providers/Update.php index fdfae7e014..2fbd84f7c9 100644 --- a/src/Appwrite/Platform/Modules/Payments/Http/Providers/Update.php +++ b/src/Appwrite/Platform/Modules/Payments/Http/Providers/Update.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Payments\Http\Providers; +use Appwrite\Event\Event; use Appwrite\Payments\Provider\Registry; use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; @@ -54,15 +55,19 @@ class Update extends Base ->inject('dbForProject') ->inject('registryPayments') ->inject('project') + ->inject('queueForEvents') ->callback($this->action(...)); } - public function action(array $config, Response $response, Database $dbForPlatform, Database $dbForProject, Registry $registryPayments, Document $project) + public function action(array $config, Response $response, Database $dbForPlatform, Database $dbForProject, Registry $registryPayments, Document $project, Event $queueForEvents) { $projectDoc = $dbForPlatform->getDocument('projects', $project->getId()); $existing = (array) $projectDoc->getAttribute('payments', []); $providers = (array) ($config['providers'] ?? []); + $providerKeys = \array_keys($providers); + $queueForEvents->setParam('providers', empty($providerKeys) ? 'providers' : \implode(',', $providerKeys)); + // Basic validation + test connection for known providers foreach ($providers as $providerId => $providerConfig) { if ($providerId === 'stripe') { @@ -77,12 +82,15 @@ class Update extends Base foreach ($providers as $providerId => $providerConfig) { $adapter = $registryPayments->get($providerId, (array) $providerConfig, $project, $dbForPlatform, $dbForProject); + \error_log("[Payments/Update] testing provider={$providerId}"); $test = $adapter->testConnection((array) $providerConfig); if (!$test->success) { + \error_log("[Payments/Update] provider={$providerId} test failed: {$test->message}"); $response->setStatusCode(400); $response->json(['message' => 'Provider test failed: ' . $test->message]); return; } + \error_log("[Payments/Update] provider={$providerId} test ok"); $state = $adapter->configure((array) $providerConfig, $project); $providers[$providerId] = array_merge((array) $providerConfig, [ 'state' => $state->metadata, @@ -99,6 +107,9 @@ class Update extends Base $projectDoc->setAttribute('payments', $merged); $updated = $dbForPlatform->updateDocument('projects', $projectDoc->getId(), $projectDoc); + $mergedProviderKeys = \array_keys((array) ($merged['providers'] ?? [])); + $queueForEvents->setParam('providers', empty($mergedProviderKeys) ? 'providers' : \implode(',', $mergedProviderKeys)); + $out = (array) $updated->getAttribute('payments', []); $prov = (array) ($out['providers'] ?? []); foreach ($prov as $pid => &$cfg) {