very rough progress

This commit is contained in:
Atharva Deosthale
2025-11-13 01:29:29 +05:30
parent fee6ad43d6
commit eb08a3379a
4 changed files with 165 additions and 66 deletions
+137 -61
View File
@@ -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<string,mixed>
*/
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
@@ -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'] ?? [])
];
}
@@ -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);
@@ -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) {