diff --git a/app/config/collections/payments.php b/app/config/collections/payments.php index c56b8e8441..40f1908124 100644 --- a/app/config/collections/payments.php +++ b/app/config/collections/payments.php @@ -84,6 +84,7 @@ return [ [ '$id' => ID::custom('projectInternalId'), 'type' => Database::VAR_STRING, 'size' => Database::LENGTH_KEY, 'signed' => true, 'required' => true, 'default' => null, 'array' => false, 'filters' => [] ], [ '$id' => ID::custom('subscriptionId'), 'type' => Database::VAR_STRING, 'size' => Database::LENGTH_KEY, 'signed' => true, 'required' => true, 'default' => null, 'array' => false, 'filters' => [] ], [ '$id' => ID::custom('providerSubscriptionId'), 'type' => Database::VAR_STRING, 'size' => Database::LENGTH_KEY, 'signed' => true, 'required' => false, 'default' => null, 'array' => false, 'filters' => [] ], + [ '$id' => ID::custom('providerCheckoutId'), 'type' => Database::VAR_STRING, 'size' => Database::LENGTH_KEY, 'signed' => true, 'required' => false, 'default' => null, 'array' => false, 'filters' => [] ], [ '$id' => ID::custom('actorType'), 'type' => Database::VAR_STRING, 'size' => 16, 'signed' => true, 'required' => true, 'default' => 'user', 'array' => false, 'filters' => [] ], [ '$id' => ID::custom('actorId'), 'type' => Database::VAR_STRING, 'size' => Database::LENGTH_KEY, 'signed' => true, 'required' => true, 'default' => null, 'array' => false, 'filters' => [] ], [ '$id' => ID::custom('actorInternalId'), 'type' => Database::VAR_STRING, 'size' => Database::LENGTH_KEY, 'signed' => true, 'required' => true, 'default' => null, 'array' => false, 'filters' => [] ], @@ -105,6 +106,7 @@ return [ [ '$id' => ID::custom('_key_status'), 'type' => Database::INDEX_KEY, 'attributes' => ['status'], 'lengths' => [32], 'orders' => [Database::ORDER_ASC] ], [ '$id' => ID::custom('_key_plan'), 'type' => Database::INDEX_KEY, 'attributes' => ['planId'], 'lengths' => [Database::LENGTH_KEY], 'orders' => [Database::ORDER_ASC] ], [ '$id' => ID::custom('_key_provider_subscription'), 'type' => Database::INDEX_KEY, 'attributes' => ['providerSubscriptionId'], 'lengths' => [Database::LENGTH_KEY], 'orders' => [Database::ORDER_ASC] ], + [ '$id' => ID::custom('_key_provider_checkout'), 'type' => Database::INDEX_KEY, 'attributes' => ['providerCheckoutId'], 'lengths' => [Database::LENGTH_KEY], 'orders' => [Database::ORDER_ASC] ], [ '$id' => ID::custom('_fulltext_search'), 'type' => Database::INDEX_FULLTEXT, 'attributes' => ['search'], 'lengths' => [], 'orders' => [] ], ], ], diff --git a/src/Appwrite/Payments/Provider/Adapter.php b/src/Appwrite/Payments/Provider/Adapter.php index d5a3440cd9..f3b30d3c19 100644 --- a/src/Appwrite/Payments/Provider/Adapter.php +++ b/src/Appwrite/Payments/Provider/Adapter.php @@ -20,8 +20,6 @@ interface Adapter public function deleteFeature(ProviderFeatureRef $feature, ProviderPlanRef $plan, ProviderState $state): void; - public function ensureSubscription(Document $actor, array $subscriptionData, ProviderState $state): ProviderSubscriptionRef; - public function updateSubscription(ProviderSubscriptionRef $subscription, array $changes, ProviderState $state): ProviderSubscriptionRef; public function cancelSubscription(ProviderSubscriptionRef $subscription, bool $atPeriodEnd, ProviderState $state): ProviderSubscriptionRef; diff --git a/src/Appwrite/Payments/Provider/StripeAdapter.php b/src/Appwrite/Payments/Provider/StripeAdapter.php index b2ab14eea4..b8ec279ea6 100644 --- a/src/Appwrite/Payments/Provider/StripeAdapter.php +++ b/src/Appwrite/Payments/Provider/StripeAdapter.php @@ -420,71 +420,6 @@ class StripeAdapter implements Adapter // Stripe meters typically continue to exist; no deletion API needed for now } - public function ensureSubscription(Document $actor, array $subscriptionData, ProviderState $state): ProviderSubscriptionRef - { - $apiKey = (string) ($state->config['secretKey'] ?? ''); - $customerId = $this->ensureCustomer($apiKey, $actor); - $planRefs = (array) ($subscriptionData['planProviders'] ?? []); - $providerEntry = (array) ($planRefs['stripe'] ?? []); - $priceMap = (array) ($providerEntry['prices'] ?? []); - $desiredInternalPriceId = (string) ($subscriptionData['priceId'] ?? ''); - - $providerPriceId = ''; - if ($desiredInternalPriceId !== '' && isset($priceMap[$desiredInternalPriceId])) { - $providerPriceId = (string) $priceMap[$desiredInternalPriceId]; - } - - if ($providerPriceId === '') { - $metadataPrices = (array) (($providerEntry['metadata']['prices'] ?? []) ?: []); - if ($desiredInternalPriceId !== '' && isset($metadataPrices[$desiredInternalPriceId])) { - $providerPriceId = (string) $metadataPrices[$desiredInternalPriceId]; - } elseif (!empty($priceMap)) { - $first = reset($priceMap); - $providerPriceId = (string) $first; - } elseif (!empty($metadataPrices)) { - $first = reset($metadataPrices); - $providerPriceId = (string) $first; - } - } - - if ($providerPriceId === '' && !empty($providerEntry['prices'])) { - $legacy = (array) $providerEntry['prices']; - if (\array_is_list($legacy) && isset($legacy[0])) { - $providerPriceId = (string) $legacy[0]; - } - } - - if ($providerPriceId === '') { - return new ProviderSubscriptionRef(externalSubscriptionId: ''); - } - $resp = $this->request($apiKey, 'POST', '/subscriptions', [ - 'customer' => $customerId, - 'items' => [ [ 'price' => $providerPriceId ] ], - 'payment_behavior' => 'default_incomplete', - 'metadata' => [ 'project_id' => $this->project->getId(), 'actor_id' => $actor->getId() ] - ]); - $respData = $this->decodeResponse($resp); - - // Map Stripe status to internal status - $stripeStatus = (string) ($respData['status'] ?? 'incomplete'); - $statusMap = [ - 'active' => 'active', - 'trialing' => 'trialing', - 'canceled' => 'canceled', - 'unpaid' => 'past_due', - 'past_due' => 'past_due', - 'incomplete' => 'pending', - 'incomplete_expired' => 'canceled', - 'paused' => 'paused', - ]; - $internalStatus = $statusMap[$stripeStatus] ?? 'pending'; - - return new ProviderSubscriptionRef( - externalSubscriptionId: (string) ($respData['id'] ?? ''), - metadata: ['status' => $internalStatus] - ); - } - public function updateSubscription(ProviderSubscriptionRef $subscription, array $changes, ProviderState $state): ProviderSubscriptionRef { $apiKey = (string) ($state->config['secretKey'] ?? ''); @@ -533,7 +468,14 @@ class StripeAdapter implements Adapter ]; $sessionResponse = $this->request($apiKey, 'POST', '/checkout/sessions', $params); $sessionData = $this->decodeResponse($sessionResponse); - return new ProviderCheckoutSession(url: (string) ($sessionData['url'] ?? '')); + return new ProviderCheckoutSession( + url: (string) ($sessionData['url'] ?? ''), + metadata: [ + 'id' => (string) ($sessionData['id'] ?? ''), + 'subscriptionId' => (string) ($sessionData['subscription'] ?? ''), + 'customerId' => (string) ($sessionData['customer'] ?? ''), + ] + ); } public function createPortalSession(Document $actor, ProviderState $state, array $options = []): ProviderPortalSession @@ -605,98 +547,81 @@ class StripeAdapter implements Adapter $type = (string) ($payload['type'] ?? ''); $changes = []; + $apiKey = (string) ($state->config['secretKey'] ?? ''); - // error_log(print_r($state, true)); - error_log(print_r($this->config, true)); - $apiKey = $state->config['secretKey']; - - error_log(print_r($type, true)); if (str_starts_with($type, 'customer.subscription.')) { - // /** @var array $obj */ - // $obj = (array) ($payload['data']['object'] ?? []); - // $stripeSubId = (string) ($obj['id'] ?? ''); - // $stripeStatus = (string) ($obj['status'] ?? ''); - // $periodStart = isset($obj['current_period_start']) ? date('c', (int) $obj['current_period_start']) : null; - // $periodEnd = isset($obj['current_period_end']) ? date('c', (int) $obj['current_period_end']) : null; - - // $statusMap = [ - // 'active' => 'active', - // 'trialing' => 'trialing', - // 'canceled' => 'canceled', - // 'unpaid' => 'past_due', - // 'past_due' => 'past_due', - // 'incomplete' => 'pending', - // 'incomplete_expired' => 'canceled', - // 'paused' => 'paused', - // ]; - // $internalStatus = $statusMap[$stripeStatus] ?? 'active'; - - // $subs = $this->dbForPlatform->find('payments_subscriptions', [ - // Query::equal('projectId', [$this->project->getId()]) - // ]); - // foreach ($subs as $sub) { - // /** @var Document $sub */ - // $providerMap = (array) $sub->getAttribute('providers', []); - // $prov = (array) ($providerMap['stripe'] ?? []); - // if ((string) ($prov['subscriptionId'] ?? '') === $stripeSubId) { - // $sub->setAttribute('status', $internalStatus); - // if ($periodStart) { - // $sub->setAttribute('currentPeriodStart', $periodStart); - // } - // if ($periodEnd) { - // $sub->setAttribute('currentPeriodEnd', $periodEnd); - // } - // $this->dbForPlatform->updateDocument('payments_subscriptions', $sub->getId(), $sub); - // $changes['subscription'] = $sub->getId(); - // $changes['status'] = $internalStatus; - // break; - // } - // } - // error_log(print_r($payload, true)); $stripeSubId = (string) ($payload['data']['object']['id'] ?? ''); - $response = $this->request($apiKey, 'GET', '/subscriptions/' . $stripeSubId); - $subData = $this->decodeResponse($response); - error_log("subData: " . print_r($subData, true)); + if ($stripeSubId === '') { + return new ProviderWebhookResult(status: 'ignored', changes: []); + } - error_log("stripeSubId: " . $stripeSubId); + try { + $response = $this->request($apiKey, 'GET', '/subscriptions/' . $stripeSubId); + $subData = $this->decodeResponse($response); + } catch (\Throwable $e) { + return new ProviderWebhookResult(status: 'error', changes: ['error' => $e->getMessage()]); + } - // PROBLEM: Always returns null, figure out why. - $sub = Authorization::skip(fn () => $this->dbForPlatform->findOne('payments_subscriptions', [ - Query::equal("providerSubscriptionId", [$stripeSubId]), + /** @var Document|null $subscription */ + $subscription = Authorization::skip(fn () => $this->dbForPlatform->findOne('payments_subscriptions', [ + Query::equal('providerSubscriptionId', [$stripeSubId]), ])); - - error_log("sub final: " . print_r($sub, true)); - - // TODO: Move subscriptionId to a different column called providerSubscriptionId to be able to proceed with this. - $sub['status'] = $subData['status']; - - error_log(print_r($sub, true)); - - // error_log(print_r($sub, true)); - Authorization::skip(fn () => $this->dbForPlatform->updateDocument('payments_subscriptions', $sub['$id'], $sub)); - - return new ProviderWebhookResult(status: 'ok', changes: $changes); - } - if ($type === 'invoice.payment_succeeded' || $type === 'invoice.payment_failed') { - /** @var array $obj */ - $obj = (array) ($payload['data']['object'] ?? []); - $stripeSubId = (string) ($obj['subscription'] ?? ''); - $internalStatus = $type === 'invoice.payment_succeeded' ? 'active' : 'past_due'; - $subs = $this->dbForPlatform->find('payments_subscriptions', [ - Query::equal('projectId', [$this->project->getId()]) - ]); - foreach ($subs as $sub) { - /** @var Document $sub */ - $prov = (array) ((array) $sub->getAttribute('providers', []))['stripe'] ?? []; - if ((string) ($prov['subscriptionId'] ?? '') === $stripeSubId) { - $sub->setAttribute('status', $internalStatus); - $this->dbForPlatform->updateDocument('payments_subscriptions', $sub->getId(), $sub); - $changes['subscription'] = $sub->getId(); - $changes['status'] = $internalStatus; - break; + if ((!$subscription instanceof Document || $subscription->isEmpty()) && $type === 'customer.subscription.created') { + try { + $sessionsResponse = $this->request($apiKey, 'GET', '/checkout/sessions', [ + 'subscription' => $stripeSubId, + 'limit' => 1, + ]); + $sessionsData = $this->decodeResponse($sessionsResponse); + $sessionId = (string) ($sessionsData['data'][0]['id'] ?? ''); + if ($sessionId !== '') { + $subscription = Authorization::skip(fn () => $this->dbForPlatform->findOne('payments_subscriptions', [ + Query::equal('providerCheckoutId', [$sessionId]), + ])); + } + } catch (\Throwable $_) { + $subscription = null; } } + + if (!$subscription instanceof Document || $subscription->isEmpty()) { + return new ProviderWebhookResult(status: 'not_found', changes: []); + } + + $stripeStatus = strtolower((string) ($subData['status'] ?? '')); + $statusMap = [ + 'active' => 'active', + 'trialing' => 'trialing', + 'canceled' => 'canceled', + 'unpaid' => 'past_due', + 'past_due' => 'past_due', + 'incomplete' => 'pending', + 'incomplete_expired' => 'canceled', + 'paused' => 'paused', + ]; + $internalStatus = $statusMap[$stripeStatus] ?? 'active'; + $periodStart = isset($subData['current_period_start']) ? date('c', (int) $subData['current_period_start']) : null; + $periodEnd = isset($subData['current_period_end']) ? date('c', (int) $subData['current_period_end']) : null; + + $providers = (array) $subscription->getAttribute('providers', []); + $providerEntry = (array) ($providers['stripe'] ?? []); + $providerEntry['providerSubscriptionId'] = $stripeSubId; + $providers['stripe'] = $providerEntry; + + $subscription->setAttribute('status', $internalStatus); + if ($periodStart) { + $subscription->setAttribute('currentPeriodStart', $periodStart); + } + if ($periodEnd) { + $subscription->setAttribute('currentPeriodEnd', $periodEnd); + } + $subscription->setAttribute('providers', $providers); + + Authorization::skip(fn () => $this->dbForPlatform->updateDocument('payments_subscriptions', $subscription->getId(), $subscription)); + $changes['subscription'] = $subscription->getId(); + $changes['status'] = $internalStatus; + return new ProviderWebhookResult(status: 'ok', changes: $changes); } return new ProviderWebhookResult(status: 'ok', changes: $changes); diff --git a/src/Appwrite/Platform/Modules/Payments/Http/Subscriptions/Create.php b/src/Appwrite/Platform/Modules/Payments/Http/Subscriptions/Create.php index e3f902ee7d..1b8642a9b2 100644 --- a/src/Appwrite/Platform/Modules/Payments/Http/Subscriptions/Create.php +++ b/src/Appwrite/Platform/Modules/Payments/Http/Subscriptions/Create.php @@ -4,6 +4,7 @@ namespace Appwrite\Platform\Modules\Payments\Http\Subscriptions; use Appwrite\Event\Event; use Appwrite\Payments\Provider\Registry; +use Appwrite\Payments\Provider\StripeAdapter; use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; @@ -150,6 +151,8 @@ class Create extends Base $primary = array_key_first($providers); $initialStatus = 'pending'; $providerData = []; + $providerSubscriptionId = null; + $providerCheckoutId = null; $checkoutUrl = null; $selectedPriceId = $priceId !== '' ? $priceId : null; $providerPlanPriceId = ''; @@ -225,41 +228,51 @@ class Create extends Base return; } - // Create checkout session if we have a price ID and URLs - if ($providerPlanPriceId && $successUrl !== '' && $cancelUrl !== '') { - try { - $checkoutSession = $adapter->createCheckoutSession($payer, [ - 'priceId' => $providerPlanPriceId - ], $state, [ - 'successUrl' => $successUrl, - 'cancelUrl' => $cancelUrl - ]); - $checkoutUrl = $checkoutSession->url; - error_log('Checkout URL: ' . $checkoutUrl); - } catch (\Throwable $e) { - // Log error but continue with subscription creation - // The subscription can be created without checkout URL for manual payment flows - } - } + $providerKey = (string) $primary; + $providerEntryData = []; + $providerSubscriptionId = null; + $providerCheckoutId = null; - // Create or ensure subscription exists in provider - $subRef = null; - try { - $subRef = $adapter->ensureSubscription($payer, [ - 'planId' => $planId, - 'planProviders' => $planProviders, - 'priceId' => (string) ($selectedPriceId ?? '') - ], $state); - $providerData = [ (string) $primary => [ 'providerSubscriptionId' => $subRef->externalSubscriptionId ] ]; - $providerData[(string) $primary]['priceId'] = (string) ($selectedPriceId ?? ''); - $providerData[(string) $primary]['providerPriceId'] = (string) $providerPlanPriceId; - // Use status from provider if available - $initialStatus = (string) ($subRef->metadata['status'] ?? 'pending'); - } catch (\Throwable $e) { - $response->setStatusCode(Response::STATUS_CODE_INTERNAL_SERVER_ERROR); - $response->json(['message' => 'Failed to create subscription: ' . $e->getMessage()]); + if (!$adapter instanceof StripeAdapter) { + $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); + $response->json(['message' => 'Unsupported payment provider: ' . $providerKey]); return; } + + if ($successUrl === '' || $cancelUrl === '') { + $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); + $response->json(['message' => 'successUrl and cancelUrl are required.']); + return; + } + + try { + $checkoutSession = $adapter->createCheckoutSession($payer, [ + 'priceId' => $providerPlanPriceId + ], $state, [ + 'successUrl' => $successUrl, + 'cancelUrl' => $cancelUrl + ]); + } catch (\Throwable $e) { + $response->setStatusCode(Response::STATUS_CODE_INTERNAL_SERVER_ERROR); + $response->json(['message' => 'Failed to create checkout session: ' . $e->getMessage()]); + return; + } + + $checkoutUrl = $checkoutSession->url; + $providerCheckoutId = (string) ($checkoutSession->metadata['id'] ?? ''); + if ($providerCheckoutId === '') { + $response->setStatusCode(Response::STATUS_CODE_INTERNAL_SERVER_ERROR); + $response->json(['message' => 'Checkout session did not return an id']); + return; + } + $providerEntryData = [ + 'priceId' => (string) ($selectedPriceId ?? ''), + 'providerPriceId' => (string) $providerPlanPriceId, + 'providerCheckoutId' => $providerCheckoutId, + ]; + $initialStatus = 'pending'; + + $providerData[$providerKey] = $providerEntryData; } else { $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); $response->json(['message' => 'No payment provider configured for this project']); @@ -270,7 +283,8 @@ class Create extends Base $subscription = new Document([ 'subscriptionId' => ID::unique(), - 'providerSubscriptionId' => $subRef->externalSubscriptionId, + 'providerSubscriptionId' => $providerSubscriptionId, + 'providerCheckoutId' => $providerCheckoutId, 'projectId' => $project->getId(), 'projectInternalId' => $project->getSequence(), 'actorType' => $actorType,