diff --git a/app/config/collections/payments.php b/app/config/collections/payments.php index a9f78e0c31..42d3897c51 100644 --- a/app/config/collections/payments.php +++ b/app/config/collections/payments.php @@ -87,6 +87,7 @@ return [ [ '$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' => [] ], [ '$id' => ID::custom('planId'), 'type' => Database::VAR_STRING, 'size' => Database::LENGTH_KEY, 'signed' => true, 'required' => true, 'default' => null, 'array' => false, 'filters' => [] ], + [ '$id' => ID::custom('priceId'), 'type' => Database::VAR_STRING, 'size' => Database::LENGTH_KEY, 'signed' => true, 'required' => false, 'default' => null, 'array' => false, 'filters' => [] ], [ '$id' => ID::custom('status'), 'type' => Database::VAR_STRING, 'size' => 32, 'signed' => true, 'required' => false, 'default' => 'active', 'array' => false, 'filters' => [] ], [ '$id' => ID::custom('trialEndsAt'), 'type' => Database::VAR_DATETIME, 'size' => 0, 'signed' => false, 'required' => false, 'default' => null, 'array' => false, 'filters' => ['datetime'] ], [ '$id' => ID::custom('currentPeriodStart'), 'type' => Database::VAR_DATETIME, 'size' => 0, 'signed' => false, 'required' => false, 'default' => null, 'array' => false, 'filters' => ['datetime'] ], diff --git a/src/Appwrite/Payments/Provider/StripeAdapter.php b/src/Appwrite/Payments/Provider/StripeAdapter.php index 06a596d4cb..ae7908eb05 100644 --- a/src/Appwrite/Payments/Provider/StripeAdapter.php +++ b/src/Appwrite/Payments/Provider/StripeAdapter.php @@ -70,6 +70,7 @@ class StripeAdapter implements Adapter $apiKey = (string) ($state->config['secretKey'] ?? ''); $name = (string) ($planData['name'] ?? ''); $description = (string) ($planData['description'] ?? ''); + $pricing = (array) ($planData['pricing'] ?? []); $providerPlanRaw = $planData['providers']['stripe']['planId'] ?? ''; $providerPlanDecoded = \is_string($providerPlanRaw) ? \json_decode($providerPlanRaw, true) : $providerPlanRaw; @@ -84,7 +85,19 @@ class StripeAdapter implements Adapter $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']]); + $productId = (string) ($data['id'] ?? ''); + $priceMap = $this->mapStripePricesForPlan( + $apiKey, + $productId, + $pricing + ); + return new ProviderPlanRef( + externalPlanId: $productId, + metadata: [ + 'productId' => $productId, + 'prices' => $priceMap, + ] + ); } } @@ -98,9 +111,15 @@ class StripeAdapter implements Adapter ]); $productData = $this->decodeResponse($product); $productId = (string) ($productData['id'] ?? ''); - $refs = ['productId' => $productId, 'prices' => []]; - $pricing = (array) ($planData['pricing'] ?? []); + $priceMap = []; foreach ($pricing as $price) { + if (!is_array($price)) { + continue; + } + $internalPriceId = (string) ($price['priceId'] ?? ''); + if ($internalPriceId === '') { + continue; + } $amount = (int) ($price['amount'] ?? 0); $currency = (string) ($price['currency'] ?? ($state->metadata['currency'] ?? 'usd')); $interval = (string) ($price['interval'] ?? 'month'); @@ -111,13 +130,109 @@ class StripeAdapter implements Adapter 'recurring' => [ 'interval' => $interval ], 'metadata' => [ 'plan_id' => (string) ($planData['planId'] ?? ''), - 'type' => 'payments_plan_price' + 'type' => 'payments_plan_price', + 'internal_price_id' => $internalPriceId, ] ]); $resData = $this->decodeResponse($res); - $refs['prices'][] = $resData['id'] ?? null; + $providerPriceId = (string) ($resData['id'] ?? ''); + if ($providerPriceId !== '') { + $priceMap[$internalPriceId] = $providerPriceId; + } } - return new ProviderPlanRef(externalPlanId: $productId, metadata: $refs); + return new ProviderPlanRef( + externalPlanId: $productId, + metadata: [ + 'productId' => $productId, + 'prices' => $priceMap, + ] + ); + } + + /** + * @param array $pricing + * @return array + */ + private function mapStripePricesForPlan(string $apiKey, string $productId, array $pricing): array + { + if ($productId === '') { + return []; + } + + $pricingIndex = []; + foreach ($pricing as $entry) { + if (!is_array($entry)) { + continue; + } + $internalId = (string) ($entry['priceId'] ?? ''); + if ($internalId === '') { + continue; + } + $pricingIndex[$internalId] = [ + 'amount' => (int) ($entry['amount'] ?? 0), + 'currency' => \strtolower((string) ($entry['currency'] ?? '')), + 'interval' => (string) ($entry['interval'] ?? ''), + ]; + } + + $priceMap = []; + try { + $response = $this->request($apiKey, 'GET', '/prices', [ + 'product' => $productId, + 'limit' => 100, + ]); + $data = $this->decodeResponse($response); + } catch (\Throwable $_) { + return []; + } + + $assigned = []; + if (isset($data['data']) && \is_array($data['data'])) { + foreach ($data['data'] as $price) { + if (!\is_array($price)) { + continue; + } + $providerPriceId = (string) ($price['id'] ?? ''); + if ($providerPriceId === '') { + continue; + } + $internalId = (string) ($price['metadata']['internal_price_id'] ?? ''); + if ($internalId !== '') { + $priceMap[$internalId] = $providerPriceId; + $assigned[$internalId] = true; + } + } + + foreach ($data['data'] as $price) { + if (!\is_array($price)) { + continue; + } + $providerPriceId = (string) ($price['id'] ?? ''); + if ($providerPriceId === '') { + continue; + } + $internalId = (string) ($price['metadata']['internal_price_id'] ?? ''); + if ($internalId !== '') { + continue; + } + $amount = (int) ($price['unit_amount'] ?? 0); + $currency = \strtolower((string) ($price['currency'] ?? '')); + $interval = (string) ($price['recurring']['interval'] ?? ''); + + foreach ($pricingIndex as $candidateId => $details) { + if (isset($assigned[$candidateId])) { + continue; + } + if ($details['amount'] === $amount && $details['currency'] === $currency && $details['interval'] === $interval) { + $priceMap[$candidateId] = $providerPriceId; + $assigned[$candidateId] = true; + break; + } + } + } + } + + return $priceMap; } public function updatePlan(array $planData, ProviderPlanRef $reference, ProviderState $state): ProviderPlanRef @@ -138,63 +253,94 @@ class StripeAdapter implements Adapter // Reconcile prices: create new prices for provided pricing entries; deactivate orphaned $newPricing = (array) ($planData['pricing'] ?? []); + $productId = $reference->externalPlanId !== '' ? $reference->externalPlanId : (string) ($reference->metadata['productId'] ?? ''); $existingPrices = (array) ($reference->metadata['prices'] ?? []); - - // Fetch details for existing price ids - $existingMap = []; // key => priceId - foreach ($existingPrices as $pid) { - if (!$pid) { - continue; - } - $price = $this->request($apiKey, 'GET', '/prices/' . $pid); - $priceData = $this->decodeResponse($price); - $currency = (string) ($priceData['currency'] ?? ''); - $interval = (string) ($priceData['recurring']['interval'] ?? ''); - $amount = (int) ($priceData['unit_amount'] ?? 0); - $key = $currency . ':' . $interval . ':' . $amount; - $existingMap[$key] = (string) $pid; + if (empty($existingPrices) && $productId !== '') { + $existingPrices = $this->mapStripePricesForPlan($apiKey, $productId, $newPricing); } - $keptPriceIds = []; - $desiredKeys = []; - foreach ($newPricing as $entry) { - $amount = (int) ($entry['amount'] ?? 0); - $currency = (string) ($entry['currency'] ?? ($state->metadata['currency'] ?? 'usd')); - $interval = (string) ($entry['interval'] ?? 'month'); - $key = $currency . ':' . $interval . ':' . $amount; - $desiredKeys[$key] = true; + $sanitizedExisting = []; + $existingDetails = []; + foreach ($existingPrices as $internalId => $providerPriceIdRaw) { + $providerPriceId = \is_array($providerPriceIdRaw) ? (string) ($providerPriceIdRaw['id'] ?? '') : (string) $providerPriceIdRaw; + if ($providerPriceId === '') { + continue; + } + $sanitizedExisting[$internalId] = $providerPriceId; + try { + $price = $this->request($apiKey, 'GET', '/prices/' . $providerPriceId); + $existingDetails[$internalId] = $this->decodeResponse($price); + } catch (\Throwable $_) { + $existingDetails[$internalId] = null; + } + } - if (isset($existingMap[$key])) { - // keep current price - $keptPriceIds[] = $existingMap[$key]; + $remainingExisting = $sanitizedExisting; + $newMap = []; + foreach ($newPricing as $entry) { + if (!\is_array($entry)) { + continue; + } + $internalPriceId = (string) ($entry['priceId'] ?? ''); + if ($internalPriceId === '') { + continue; + } + $amount = (int) ($entry['amount'] ?? 0); + $currency = \strtolower((string) ($entry['currency'] ?? ($state->metadata['currency'] ?? 'usd'))); + $interval = (string) ($entry['interval'] ?? 'month'); + + $reuseExisting = false; + if (isset($existingDetails[$internalPriceId]) && \is_array($existingDetails[$internalPriceId])) { + $current = $existingDetails[$internalPriceId]; + $currentAmount = (int) ($current['unit_amount'] ?? 0); + $currentCurrency = \strtolower((string) ($current['currency'] ?? '')); + $currentInterval = (string) ($current['recurring']['interval'] ?? ''); + if ($currentAmount === $amount && $currentCurrency === $currency && $currentInterval === $interval) { + $newMap[$internalPriceId] = $remainingExisting[$internalPriceId]; + unset($remainingExisting[$internalPriceId]); + $reuseExisting = true; + } + } + + if ($reuseExisting) { continue; } - // create new price + if ($productId === '') { + throw new \RuntimeException('Stripe product ID missing for plan update'); + } + $res = $this->request($apiKey, 'POST', '/prices', [ - 'product' => $reference->externalPlanId, + 'product' => $productId, 'unit_amount' => $amount, 'currency' => $currency, 'recurring' => [ 'interval' => $interval ], 'metadata' => [ 'plan_id' => (string) ($planData['planId'] ?? ''), - 'type' => 'payments_plan_price' + 'type' => 'payments_plan_price', + 'internal_price_id' => $internalPriceId, ] ]); $resData = $this->decodeResponse($res); - $keptPriceIds[] = (string) ($resData['id'] ?? ''); + $providerPriceId = (string) ($resData['id'] ?? ''); + if ($providerPriceId !== '') { + $newMap[$internalPriceId] = $providerPriceId; + } } // Deactivate any existing price not in desired set - foreach ($existingMap as $key => $pid) { - if (!isset($desiredKeys[$key])) { - $this->request($apiKey, 'POST', '/prices/' . $pid, ['active' => 'false']); + foreach ($remainingExisting as $providerPriceId) { + if ($providerPriceId !== '') { + $this->request($apiKey, 'POST', '/prices/' . $providerPriceId, ['active' => 'false']); } } return new ProviderPlanRef( - externalPlanId: $reference->externalPlanId, - metadata: ['prices' => array_values(array_filter($keptPriceIds))] + externalPlanId: $productId, + metadata: [ + 'productId' => $productId, + 'prices' => $newMap, + ] ); } @@ -202,11 +348,11 @@ class StripeAdapter implements Adapter { $apiKey = (string) ($state->config['secretKey'] ?? ''); $meta = $reference->metadata; - if (!empty($meta['prices'])) { - foreach ($meta['prices'] as $priceId) { - if ($priceId) { - $this->request($apiKey, 'POST', '/prices/' . $priceId, ['active' => 'false']); - } + $priceRefs = (array) ($meta['prices'] ?? []); + foreach ($priceRefs as $entry) { + $priceId = \is_array($entry) ? (string) ($entry['id'] ?? '') : (string) $entry; + if ($priceId !== '') { + $this->request($apiKey, 'POST', '/prices/' . $priceId, ['active' => 'false']); } } if (!empty($reference->externalPlanId)) { @@ -278,16 +424,41 @@ class StripeAdapter implements Adapter $apiKey = (string) ($state->config['secretKey'] ?? ''); $customerId = $this->ensureCustomer($apiKey, $actor); $planRefs = (array) ($subscriptionData['planProviders'] ?? []); - $priceId = ''; - if (!empty($planRefs['stripe']['prices'][0] ?? null)) { - $priceId = (string) $planRefs['stripe']['prices'][0]; + $providerEntry = (array) ($planRefs['stripe'] ?? []); + $priceMap = (array) ($providerEntry['prices'] ?? []); + $desiredInternalPriceId = (string) ($subscriptionData['priceId'] ?? ''); + + $providerPriceId = ''; + if ($desiredInternalPriceId !== '' && isset($priceMap[$desiredInternalPriceId])) { + $providerPriceId = (string) $priceMap[$desiredInternalPriceId]; } - if ($priceId === '') { + + 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' => $priceId ] ], + 'items' => [ [ 'price' => $providerPriceId ] ], 'payment_behavior' => 'default_incomplete', 'metadata' => [ 'project_id' => $this->project->getId(), 'actor_id' => $actor->getId() ] ]); @@ -353,7 +524,7 @@ class StripeAdapter implements Adapter $priceId = (string) ($planContext['priceId'] ?? ''); $params = [ 'mode' => 'subscription', - 'line_items' => [ [ 'price' => $priceId ] ], + 'line_items' => [ [ 'price' => $priceId, 'quantity' => 1 ] ], 'success_url' => $successUrl, 'cancel_url' => $cancelUrl, 'client_reference_id' => $actor->getId(), @@ -622,8 +793,8 @@ class StripeAdapter implements Adapter if ($statusCode >= 400) { $responseData = json_decode($responseBody, true); - $message = is_array($responseData) && isset($responseData['error']['message']) - ? (string) $responseData['error']['message'] + $message = is_array($responseData) && isset($responseData['error']['message']) + ? (string) $responseData['error']['message'] : 'Stripe API error'; throw new \RuntimeException($message, $statusCode); } diff --git a/src/Appwrite/Platform/Modules/Payments/Http/Plans/Create.php b/src/Appwrite/Platform/Modules/Payments/Http/Plans/Create.php index 60783339b0..5568dbe8e5 100644 --- a/src/Appwrite/Platform/Modules/Payments/Http/Plans/Create.php +++ b/src/Appwrite/Platform/Modules/Payments/Http/Plans/Create.php @@ -62,7 +62,7 @@ class Create extends Base ->param('description', '', new Text(8192, 0), 'Plan description.', true) ->param('isDefault', false, new Boolean(), 'Set as default plan for new users.', true) ->param('isFree', false, new Boolean(), 'Is the plan free.', true) - ->param('pricing', [], new JSONValidator(), 'Pricing configuration array [{amount,currency,interval}]', true) + ->param('pricing', [], new JSONValidator(), 'Pricing configuration array [{priceId,amount,currency,interval}]', true) ->inject('response') ->inject('dbForPlatform') ->inject('dbForProject') @@ -86,13 +86,39 @@ class Create extends Base Event $queueForEvents, Document $project ) { + // Normalize and validate pricing definitions (ensure user-provided priceIds) + $normalizedPricing = []; + $seenPriceIds = []; + foreach ($pricing as $entry) { + if (!is_array($entry)) { + $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); + $response->json(['message' => 'Invalid pricing entry format']); + return; + } + $priceId = (string) ($entry['priceId'] ?? ''); + if ($priceId === '') { + $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); + $response->json(['message' => 'Each pricing entry must include a priceId']); + return; + } + if (isset($seenPriceIds[$priceId])) { + $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); + $response->json(['message' => 'Duplicate priceId detected: ' . $priceId]); + return; + } + $seenPriceIds[$priceId] = true; + $normalizedPricing[] = $entry; + } + + $pricing = $normalizedPricing; + $document = new Document([ 'projectId' => $project->getId(), 'projectInternalId' => $project->getSequence(), 'planId' => $planId, 'name' => $name, 'description' => $description, - 'pricing' => $pricing, + 'pricing' => $normalizedPricing, 'isDefault' => $isDefault, 'isFree' => $isFree, 'status' => 'active', @@ -121,7 +147,7 @@ class Create extends Base } $created = $dbForPlatform->createDocument('payments_plans', $document); - + $queueForEvents->setParam('planId', $planId); // Provision on configured providers diff --git a/src/Appwrite/Platform/Modules/Payments/Http/Plans/Update.php b/src/Appwrite/Platform/Modules/Payments/Http/Plans/Update.php index 1797e8ee56..b5fbc6d24c 100644 --- a/src/Appwrite/Platform/Modules/Payments/Http/Plans/Update.php +++ b/src/Appwrite/Platform/Modules/Payments/Http/Plans/Update.php @@ -57,7 +57,7 @@ class Update extends Base ->param('description', '', new Text(8192, 0), 'Plan description.', true) ->param('isDefault', false, new Boolean(), 'Set as default plan for new users.', true) ->param('isFree', false, new Boolean(), 'Is the plan free.', true) - ->param('pricing', [], new JSONValidator(), 'Pricing configuration array [{amount,currency,interval}]', true) + ->param('pricing', [], new JSONValidator(), 'Pricing configuration array [{priceId,amount,currency,interval}]', true) ->inject('response') ->inject('dbForPlatform') ->inject('dbForProject') @@ -106,6 +106,29 @@ class Update extends Base $plan->setAttribute('isDefault', $isDefault); $plan->setAttribute('isFree', $isFree); if (!empty($pricing)) { + $normalizedPricing = []; + $seenPriceIds = []; + foreach ($pricing as $entry) { + if (!is_array($entry)) { + $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); + $response->json(['message' => 'Invalid pricing entry format']); + return; + } + $priceId = (string) ($entry['priceId'] ?? ''); + if ($priceId === '') { + $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); + $response->json(['message' => 'Each pricing entry must include a priceId']); + return; + } + if (isset($seenPriceIds[$priceId])) { + $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); + $response->json(['message' => 'Duplicate priceId detected: ' . $priceId]); + return; + } + $seenPriceIds[$priceId] = true; + $normalizedPricing[] = $entry; + } + $pricing = $normalizedPricing; $plan->setAttribute('pricing', $pricing); } $plan = $dbForPlatform->updateDocument('payments_plans', $plan->getId(), $plan); diff --git a/src/Appwrite/Platform/Modules/Payments/Http/Subscriptions/Create.php b/src/Appwrite/Platform/Modules/Payments/Http/Subscriptions/Create.php index 3d8e3da2f2..04ae4e5933 100644 --- a/src/Appwrite/Platform/Modules/Payments/Http/Subscriptions/Create.php +++ b/src/Appwrite/Platform/Modules/Payments/Http/Subscriptions/Create.php @@ -54,6 +54,7 @@ class Create extends Base ->param('actorType', 'user', new Text(16), 'Actor type: user or team') ->param('actorId', '', new Text(128), 'Actor ID') ->param('planId', '', new Text(128), 'Plan ID') + ->param('priceId', '', new Text(128), 'Price ID', true) ->param('payerUserId', '', new Text(128, 0), 'Payer user ID (for team subscriptions)', true) ->param('successUrl', '', new Text(2048), 'Success redirect URL') ->param('cancelUrl', '', new Text(2048), 'Cancel redirect URL') @@ -70,6 +71,7 @@ class Create extends Base string $actorType, string $actorId, string $planId, + string $priceId, string $payerUserId, string $successUrl, string $cancelUrl, @@ -149,6 +151,8 @@ class Create extends Base $initialStatus = 'pending'; $providerData = []; $checkoutUrl = null; + $selectedPriceId = $priceId !== '' ? $priceId : null; + $providerPlanPriceId = ''; if ($primary) { $config = (array) ($providers[$primary] ?? []); @@ -156,43 +160,82 @@ class Create extends Base $adapter = $registryPayments->get((string) $primary, $config, $project, $dbForPlatform, $dbForProject); $state = new \Appwrite\Payments\Provider\ProviderState((string) $primary, $config, (array) ($config['state'] ?? [])); - // Find the fixed plan price (not metered features) - // Plan prices are stored first in the prices array, followed by feature prices - $priceIds = (array) ($planProviders[$primary]['prices'] ?? []); + $planPricing = array_values((array) ($plan->getAttribute('pricing') ?? [])); + $providerEntry = (array) ($planProviders[$primary] ?? []); + $rawProviderPrices = (array) ($providerEntry['prices'] ?? []); + $providerPriceMap = []; + if (!empty($rawProviderPrices)) { + if (!\array_is_list($rawProviderPrices)) { + foreach ($rawProviderPrices as $internalId => $providerPrice) { + $internalKey = (string) $internalId; + $providerValue = (string) $providerPrice; + if ($internalKey !== '' && $providerValue !== '') { + $providerPriceMap[$internalKey] = $providerValue; + } + } + } else { + foreach ($planPricing as $index => $pricingEntry) { + $internalId = (string) ($pricingEntry['priceId'] ?? ''); + $providerValue = (string) ($rawProviderPrices[$index] ?? ''); + if ($internalId !== '' && $providerValue !== '') { + $providerPriceMap[$internalId] = $providerValue; + } + } + } + } + if (empty($providerPriceMap)) { + $metaPrices = (array) (($providerEntry['metadata']['prices'] ?? []) ?: []); + foreach ($metaPrices as $internalId => $providerPrice) { + $internalKey = (string) $internalId; + $providerValue = (string) $providerPrice; + if ($internalKey !== '' && $providerValue !== '') { + $providerPriceMap[$internalKey] = $providerValue; + } + } + } - // If no prices in plan providers, return error - if (empty($priceIds)) { + if ($selectedPriceId !== null) { + if (!isset($providerPriceMap[$selectedPriceId])) { + $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); + $response->json(['message' => 'Price ID not configured for provider: ' . $selectedPriceId]); + return; + } + $providerPlanPriceId = (string) $providerPriceMap[$selectedPriceId]; + } elseif (!empty($providerPriceMap)) { + $selectedPriceId = (string) array_key_first($providerPriceMap); + $providerPlanPriceId = (string) $providerPriceMap[$selectedPriceId]; + } + + if ($providerPlanPriceId === '') { + // Legacy fallback: use first available provider price ID even if internal mapping missing + $legacyList = (array) ($providerEntry['prices'] ?? []); + if (\array_is_list($legacyList)) { + foreach ($legacyList as $legacyPriceId) { + if (!empty($legacyPriceId)) { + $providerPlanPriceId = (string) $legacyPriceId; + break; + } + } + } + } + + if ($providerPlanPriceId === '') { $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); $response->json(['message' => 'Plan has no prices configured for provider: ' . $primary]); return; } - // Use the first price ID as the plan price - // Plan prices are created first and stored first in the array (see StripeAdapter::ensurePlan) - $planPriceId = null; - foreach ($priceIds as $priceId) { - if (!empty($priceId)) { - $planPriceId = $priceId; - break; - } - } - - if ($planPriceId === null) { - $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); - $response->json(['message' => 'Plan has no valid prices configured for provider: ' . $primary]); - return; - } - // Create checkout session if we have a price ID and URLs - if ($planPriceId && $successUrl !== '' && $cancelUrl !== '') { + if ($providerPlanPriceId && $successUrl !== '' && $cancelUrl !== '') { try { $checkoutSession = $adapter->createCheckoutSession($payer, [ - 'priceId' => $planPriceId + '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 @@ -203,9 +246,12 @@ class Create extends Base try { $subRef = $adapter->ensureSubscription($payer, [ 'planId' => $planId, - 'planProviders' => $planProviders + 'planProviders' => $planProviders, + 'priceId' => (string) ($selectedPriceId ?? '') ], $state); $providerData = [ (string) $primary => [ 'subscriptionId' => $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) { @@ -219,6 +265,8 @@ class Create extends Base return; } + $resolvedPriceId = (string) ($selectedPriceId ?? ''); + $subscription = new Document([ 'subscriptionId' => ID::unique(), 'projectId' => $project->getId(), @@ -227,6 +275,7 @@ class Create extends Base 'actorId' => $actorId, 'actorInternalId' => $actor->getSequence(), 'planId' => $planId, + 'priceId' => $resolvedPriceId, 'status' => $initialStatus, 'trialEndsAt' => null, 'currentPeriodStart' => null, @@ -243,8 +292,8 @@ class Create extends Base $queueForEvents ->setProject($project) - ->setEvent('payments.subscription.[subscriptionId].create') ->setParam('subscriptionId', $created->getAttribute('subscriptionId')) + ->setEvent('payments.subscription.[subscriptionId].create') ->setPayload($created->getArrayCopy()); $responseData = $created->getArrayCopy(); diff --git a/src/Appwrite/Platform/Modules/Payments/Http/Subscriptions/Update.php b/src/Appwrite/Platform/Modules/Payments/Http/Subscriptions/Update.php index 1994960a44..cda1dcbdc5 100644 --- a/src/Appwrite/Platform/Modules/Payments/Http/Subscriptions/Update.php +++ b/src/Appwrite/Platform/Modules/Payments/Http/Subscriptions/Update.php @@ -53,6 +53,7 @@ class Update extends Base )) ->param('subscriptionId', '', new Text(128), 'Subscription ID') ->param('planId', '', new Text(128), 'New plan ID', true) + ->param('priceId', '', new Text(128), 'New price ID', true) ->param('cancelAtPeriodEnd', false, new Boolean(), 'Cancel at period end', true) ->inject('response') ->inject('dbForPlatform') @@ -66,6 +67,7 @@ class Update extends Base public function action( string $subscriptionId, string $planId, + string $priceId, bool $cancelAtPeriodEnd, Response $response, Database $dbForPlatform, @@ -132,16 +134,77 @@ class Update extends Base if ($subscriptionRef !== '') { $state = new ProviderState((string) $primary, $config, (array) ($config['state'] ?? [])); $adapter = $registryPayments->get((string) $primary, $config, $project, $dbForPlatform, $dbForProject); - if ($planId !== '') { - // map planId -> priceId + if ($planId !== '' || $priceId !== '') { + $currentPlanId = (string) $sub->getAttribute('planId', ''); + $targetPlanId = $planId !== '' ? $planId : $currentPlanId; $plan = $dbForPlatform->findOne('payments_plans', [ Query::equal('projectId', [$project->getId()]), - Query::equal('planId', [$planId]) + Query::equal('planId', [$targetPlanId]) ]); - $planProviders = (array) ($plan?->getAttribute('providers', []) ?? []); - $priceId = (string) (($planProviders[(string) $primary]['metadata']['prices'][0] ?? '') ?: ''); - $adapter->updateSubscription(new \Appwrite\Payments\Provider\ProviderSubscriptionRef($subscriptionRef), ['priceId' => $priceId], $state); - $sub->setAttribute('planId', $planId); + if ($plan === null || $plan->isEmpty()) { + $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); + $response->json(['message' => 'Target plan not found']); + return; + } + $planProviders = (array) $plan->getAttribute('providers', []); + $planPricing = array_values((array) ($plan->getAttribute('pricing') ?? [])); + $providerEntry = (array) ($planProviders[(string) $primary] ?? []); + $rawProviderPrices = (array) ($providerEntry['prices'] ?? []); + $providerPriceMap = []; + if (!empty($rawProviderPrices)) { + if (!\array_is_list($rawProviderPrices)) { + foreach ($rawProviderPrices as $internalId => $providerPrice) { + $internalKey = (string) $internalId; + $providerValue = (string) $providerPrice; + if ($internalKey !== '' && $providerValue !== '') { + $providerPriceMap[$internalKey] = $providerValue; + } + } + } else { + foreach ($planPricing as $index => $pricingEntry) { + $internalId = (string) ($pricingEntry['priceId'] ?? ''); + $providerValue = (string) ($rawProviderPrices[$index] ?? ''); + if ($internalId !== '' && $providerValue !== '') { + $providerPriceMap[$internalId] = $providerValue; + } + } + } + } + if (empty($providerPriceMap)) { + $metaPrices = (array) (($providerEntry['metadata']['prices'] ?? []) ?: []); + foreach ($metaPrices as $internalId => $providerPrice) { + $internalKey = (string) $internalId; + $providerValue = (string) $providerPrice; + if ($internalKey !== '' && $providerValue !== '') { + $providerPriceMap[$internalKey] = $providerValue; + } + } + } + + $selectedPriceId = $priceId !== '' ? $priceId : (string) $sub->getAttribute('priceId', ''); + if ($selectedPriceId === '' && !empty($providerPriceMap)) { + $selectedPriceId = (string) array_key_first($providerPriceMap); + } + + if ($selectedPriceId === '' || !isset($providerPriceMap[$selectedPriceId])) { + $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); + $response->json(['message' => 'Price ID not configured for provider']); + return; + } + + $providerPriceId = (string) $providerPriceMap[$selectedPriceId]; + $adapter->updateSubscription( + new \Appwrite\Payments\Provider\ProviderSubscriptionRef($subscriptionRef), + ['priceId' => $providerPriceId], + $state + ); + $sub->setAttribute('planId', $targetPlanId); + $sub->setAttribute('priceId', $selectedPriceId); + $provEntry = (array) ($provMap[(string) $primary] ?? []); + $provEntry['priceId'] = $selectedPriceId; + $provEntry['providerPriceId'] = $providerPriceId; + $provMap[(string) $primary] = $provEntry; + $sub->setAttribute('providers', $provMap); } if ($cancelAtPeriodEnd) { $adapter->cancelSubscription(new \Appwrite\Payments\Provider\ProviderSubscriptionRef($subscriptionRef), true, $state); diff --git a/src/Appwrite/Utopia/Response/Model/PaymentSubscription.php b/src/Appwrite/Utopia/Response/Model/PaymentSubscription.php index cf3f748327..d47fc73a67 100644 --- a/src/Appwrite/Utopia/Response/Model/PaymentSubscription.php +++ b/src/Appwrite/Utopia/Response/Model/PaymentSubscription.php @@ -34,6 +34,12 @@ class PaymentSubscription extends Model 'default' => '', 'example' => 'pro', ]) + ->addRule('priceId', [ + 'type' => self::TYPE_STRING, + 'description' => 'Selected price ID.', + 'default' => '', + 'example' => 'pro-monthly', + ]) ->addRule('status', [ 'type' => self::TYPE_STRING, 'description' => 'Subscription status.',