checkout gets created, yay

This commit is contained in:
Atharva Deosthale
2025-11-13 18:41:32 +05:30
parent 3967b2e338
commit f59cd158ef
7 changed files with 428 additions and 89 deletions
+1
View File
@@ -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'] ],
+224 -53
View File
@@ -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<int|string, mixed> $pricing
* @return array<string,string>
*/
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);
}
@@ -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
@@ -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);
@@ -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();
@@ -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);
@@ -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.',