mirror of
https://github.com/appwrite/appwrite.git
synced 2026-05-26 13:51:13 +00:00
checkout gets created, yay
This commit is contained in:
@@ -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'] ],
|
||||
|
||||
@@ -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.',
|
||||
|
||||
Reference in New Issue
Block a user