From 92f9ddfc333e0bb07d7547e2265645f38b79dd54 Mon Sep 17 00:00:00 2001 From: Atharva Deosthale Date: Fri, 21 Nov 2025 16:28:13 +0530 Subject: [PATCH] remove legacy hackathon code --- app/config/collections.php | 3 +- app/config/collections/auth_plans.php | 676 --------- app/config/collections/common.php | 109 -- app/config/collections/platform.php | 66 - app/config/services.php | 13 - app/controllers/api/account.php | 492 ------- app/controllers/api/auth-plans.php | 1019 ------------- app/controllers/api/projects.php | 448 +----- app/controllers/api/users.php | 305 +--- app/controllers/general.php | 62 +- .../Exception/SubscriptionException.php | 13 - .../Auth/Subscription/StripeService.php | 1268 ----------------- src/Appwrite/Auth/Validator/PlanData.php | 78 - src/Appwrite/Auth/Validator/StripeKey.php | 58 - .../Auth/Validator/SubscriptionStatus.php | 58 - src/Appwrite/Utopia/Response/Model/User.php | 14 - 16 files changed, 102 insertions(+), 4580 deletions(-) delete mode 100644 app/config/collections/auth_plans.php delete mode 100644 app/controllers/api/auth-plans.php delete mode 100644 src/Appwrite/Auth/Subscription/Exception/SubscriptionException.php delete mode 100644 src/Appwrite/Auth/Subscription/StripeService.php delete mode 100644 src/Appwrite/Auth/Validator/PlanData.php delete mode 100644 src/Appwrite/Auth/Validator/StripeKey.php delete mode 100644 src/Appwrite/Auth/Validator/SubscriptionStatus.php diff --git a/app/config/collections.php b/app/config/collections.php index c812322c7a..533dee57a8 100644 --- a/app/config/collections.php +++ b/app/config/collections.php @@ -5,7 +5,6 @@ $common = include __DIR__ . '/collections/common.php'; $projects = include __DIR__ . '/collections/projects.php'; $databases = include __DIR__ . '/collections/databases.php'; $platform = include __DIR__ . '/collections/platform.php'; -$authPlans = include __DIR__ . '/collections/auth_plans.php'; $logs = include __DIR__ . '/collections/logs.php'; // see - http.php#245 @@ -28,7 +27,7 @@ $collections = [ 'buckets' => $buckets, 'databases' => $databases, 'projects' => array_merge($projects, $common), - 'console' => array_merge($platform, $authPlans, $common), + 'console' => array_merge($platform, $common), 'logs' => $logs, ]; diff --git a/app/config/collections/auth_plans.php b/app/config/collections/auth_plans.php deleted file mode 100644 index 50bdac1f4c..0000000000 --- a/app/config/collections/auth_plans.php +++ /dev/null @@ -1,676 +0,0 @@ - [ - '$collection' => ID::custom(Database::METADATA), - '$id' => ID::custom('auth_plans'), - 'name' => 'Auth Plans', - 'attributes' => [ - [ - '$id' => ID::custom('projectInternalId'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => true, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('projectId'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => true, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('planId'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => true, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('name'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 128, - 'signed' => true, - 'required' => true, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('description'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 256, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('stripePriceId'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 256, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('stripeMeterId'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 256, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('stripeProductId'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 256, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('price'), - 'type' => Database::VAR_INTEGER, - 'format' => '', - 'size' => 0, - 'signed' => false, - 'required' => true, - 'default' => 0, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('currency'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 3, - 'signed' => true, - 'required' => true, - 'default' => 'usd', - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('interval'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 10, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('features'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 16384, - 'signed' => true, - 'required' => false, - 'default' => [], - 'array' => false, - 'filters' => ['json'], - ], - [ - '$id' => ID::custom('isDefault'), - 'type' => Database::VAR_BOOLEAN, - 'signed' => true, - 'size' => 0, - 'format' => '', - 'filters' => [], - 'required' => false, - 'default' => false, - 'array' => false, - ], - [ - '$id' => ID::custom('isFree'), - 'type' => Database::VAR_BOOLEAN, - 'signed' => true, - 'size' => 0, - 'format' => '', - 'filters' => [], - 'required' => false, - 'default' => false, - 'array' => false, - ], - [ - '$id' => ID::custom('maxUsers'), - 'type' => Database::VAR_INTEGER, - 'format' => '', - 'size' => 0, - 'signed' => false, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('active'), - 'type' => Database::VAR_BOOLEAN, - 'signed' => true, - 'size' => 0, - 'format' => '', - 'filters' => [], - 'required' => false, - 'default' => true, - 'array' => false, - ], - [ - '$id' => ID::custom('search'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 16384, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ] - ], - 'indexes' => [ - [ - '$id' => ID::custom('_key_project'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['projectId'], - 'lengths' => [Database::LENGTH_KEY], - 'orders' => [Database::ORDER_ASC], - ], - [ - '$id' => ID::custom('_key_project_planId'), - 'type' => Database::INDEX_UNIQUE, - 'attributes' => ['projectId', 'planId'], - 'lengths' => [Database::LENGTH_KEY, Database::LENGTH_KEY], - 'orders' => [Database::ORDER_ASC, Database::ORDER_ASC], - ], - [ - '$id' => ID::custom('_key_project_default'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['projectId', 'isDefault'], - 'lengths' => [Database::LENGTH_KEY, 0], - 'orders' => [Database::ORDER_ASC, Database::ORDER_ASC], - ], - [ - '$id' => ID::custom('_key_project_active'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['projectId', 'active'], - 'lengths' => [Database::LENGTH_KEY, 0], - 'orders' => [Database::ORDER_ASC, Database::ORDER_ASC], - ], - [ - '$id' => ID::custom('_fulltext_search'), - 'type' => Database::INDEX_FULLTEXT, - 'attributes' => ['search'], - 'lengths' => [], - 'orders' => [], - ], - ], - ], - 'auth_features' => [ - '$collection' => ID::custom(Database::METADATA), - '$id' => ID::custom('auth_features'), - 'name' => 'Auth Features', - 'attributes' => [ - [ - '$id' => ID::custom('projectInternalId'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => true, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('projectId'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => true, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('featureId'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => true, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('name'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 128, - 'signed' => true, - 'required' => true, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('type'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 16, - 'signed' => true, - 'required' => true, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('description'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 256, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('active'), - 'type' => Database::VAR_BOOLEAN, - 'signed' => true, - 'size' => 0, - 'format' => '', - 'filters' => [], - 'required' => false, - 'default' => true, - 'array' => false, - ], - [ - '$id' => ID::custom('search'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 16384, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - ], - 'indexes' => [ - [ - '$id' => ID::custom('_key_project'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['projectId'], - 'lengths' => [Database::LENGTH_KEY], - 'orders' => [Database::ORDER_ASC], - ], - [ - '$id' => ID::custom('_key_project_featureId'), - 'type' => Database::INDEX_UNIQUE, - 'attributes' => ['projectId', 'featureId'], - 'lengths' => [Database::LENGTH_KEY, Database::LENGTH_KEY], - 'orders' => [Database::ORDER_ASC, Database::ORDER_ASC], - ], - [ - '$id' => ID::custom('_fulltext_search'), - 'type' => Database::INDEX_FULLTEXT, - 'attributes' => ['search'], - 'lengths' => [], - 'orders' => [], - ], - ], - ], - 'auth_plan_features' => [ - '$collection' => ID::custom(Database::METADATA), - '$id' => ID::custom('auth_plan_features'), - 'name' => 'Auth Plan Features', - 'attributes' => [ - [ - '$id' => ID::custom('projectInternalId'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => true, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('projectId'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => true, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('planId'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => true, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('featureId'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => true, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('type'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 16, - 'signed' => true, - 'required' => true, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('enabled'), - 'type' => Database::VAR_BOOLEAN, - 'signed' => true, - 'size' => 0, - 'format' => '', - 'filters' => [], - 'required' => false, - 'default' => false, - 'array' => false, - ], - [ - '$id' => ID::custom('currency'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 3, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('interval'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 10, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('includedUnits'), - 'type' => Database::VAR_INTEGER, - 'format' => '', - 'size' => 0, - 'signed' => false, - 'required' => false, - 'default' => 0, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('tiersMode'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 16, - 'signed' => true, - 'required' => false, - 'default' => 'graduated', - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('tiers'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 16384, - 'signed' => true, - 'required' => false, - 'default' => [], - 'array' => false, - 'filters' => ['json'], - ], - [ - '$id' => ID::custom('usageCap'), - 'type' => Database::VAR_INTEGER, - 'format' => '', - 'size' => 0, - 'signed' => false, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('stripePriceId'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 256, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('stripeMeterId'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 256, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('active'), - 'type' => Database::VAR_BOOLEAN, - 'signed' => true, - 'size' => 0, - 'format' => '', - 'filters' => [], - 'required' => false, - 'default' => true, - 'array' => false, - ], - ], - 'indexes' => [ - [ - '$id' => ID::custom('_key_project'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['projectId'], - 'lengths' => [Database::LENGTH_KEY], - 'orders' => [Database::ORDER_ASC], - ], - [ - '$id' => ID::custom('_key_project_plan'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['projectId', 'planId'], - 'lengths' => [Database::LENGTH_KEY, Database::LENGTH_KEY], - 'orders' => [Database::ORDER_ASC, Database::ORDER_ASC], - ], - [ - '$id' => ID::custom('_key_project_plan_feature'), - 'type' => Database::INDEX_UNIQUE, - 'attributes' => ['projectId', 'planId', 'featureId'], - 'lengths' => [Database::LENGTH_KEY, Database::LENGTH_KEY, Database::LENGTH_KEY], - 'orders' => [Database::ORDER_ASC, Database::ORDER_ASC, Database::ORDER_ASC], - ], - [ - '$id' => ID::custom('_key_active'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['active'], - 'lengths' => [0], - 'orders' => [Database::ORDER_ASC], - ], - ], - ], - 'auth_usage_events' => [ - '$collection' => ID::custom(Database::METADATA), - '$id' => ID::custom('auth_usage_events'), - 'name' => 'Auth Usage Events', - 'attributes' => [ - [ - '$id' => ID::custom('projectInternalId'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => true, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('projectId'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => true, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('userId'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => true, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('planId'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => true, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('featureId'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => true, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('value'), - 'type' => Database::VAR_INTEGER, - 'format' => '', - 'size' => 0, - 'signed' => false, - 'required' => true, - 'default' => 0, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('identifier'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 256, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('timestamp'), - 'type' => Database::VAR_DATETIME, - 'format' => '', - 'size' => 0, - 'signed' => false, - 'required' => true, - 'default' => null, - 'array' => false, - 'filters' => ['datetime'], - ], - ], - 'indexes' => [ - [ - '$id' => ID::custom('_key_project_user_feature_time'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['projectId', 'userId', 'featureId', 'timestamp'], - 'lengths' => [Database::LENGTH_KEY, Database::LENGTH_KEY, Database::LENGTH_KEY, 0], - 'orders' => [Database::ORDER_ASC, Database::ORDER_ASC, Database::ORDER_ASC, Database::ORDER_ASC], - ], - [ - '$id' => ID::custom('_key_project_plan_feature_time'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['projectId', 'planId', 'featureId', 'timestamp'], - 'lengths' => [Database::LENGTH_KEY, Database::LENGTH_KEY, Database::LENGTH_KEY, 0], - 'orders' => [Database::ORDER_ASC, Database::ORDER_ASC, Database::ORDER_ASC, Database::ORDER_ASC], - ], - [ - '$id' => ID::custom('_key_identifier'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['identifier'], - 'lengths' => [256], - 'orders' => [Database::ORDER_ASC], - ], - ], - ], -]; diff --git a/app/config/collections/common.php b/app/config/collections/common.php index 47f68728b3..6de7eb224b 100644 --- a/app/config/collections/common.php +++ b/app/config/collections/common.php @@ -364,94 +364,6 @@ return [ 'array' => false, 'filters' => ['datetime'], ], - [ - '$id' => ID::custom('planId'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('stripeCustomerId'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 256, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('stripeSubscriptionId'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 256, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('subscriptionStatus'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 20, - 'signed' => true, - 'required' => false, - 'default' => 'none', - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('subscriptionCurrentPeriodStart'), - 'type' => Database::VAR_DATETIME, - 'format' => '', - 'size' => 0, - 'signed' => false, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => ['datetime'], - ], - [ - '$id' => ID::custom('subscriptionCurrentPeriodEnd'), - 'type' => Database::VAR_DATETIME, - 'format' => '', - 'size' => 0, - 'signed' => false, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => ['datetime'], - ], - [ - '$id' => ID::custom('subscriptionCancelAtPeriodEnd'), - 'type' => Database::VAR_BOOLEAN, - 'signed' => true, - 'size' => 0, - 'format' => '', - 'filters' => [], - 'required' => false, - 'default' => false, - 'array' => false, - ], - [ - '$id' => ID::custom('subscriptionTrialEnd'), - 'type' => Database::VAR_DATETIME, - 'format' => '', - 'size' => 0, - 'signed' => false, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => ['datetime'], - ], ], 'indexes' => [ [ @@ -524,27 +436,6 @@ return [ 'lengths' => [], 'orders' => [], ], - [ - '$id' => ID::custom('_key_planId'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['planId'], - 'lengths' => [Database::LENGTH_KEY], - 'orders' => [Database::ORDER_ASC], - ], - [ - '$id' => ID::custom('_key_stripeCustomerId'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['stripeCustomerId'], - 'lengths' => [256], - 'orders' => [Database::ORDER_ASC], - ], - [ - '$id' => ID::custom('_key_subscriptionStatus'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['subscriptionStatus'], - 'lengths' => [20], - 'orders' => [Database::ORDER_ASC], - ], ], ], diff --git a/app/config/collections/platform.php b/app/config/collections/platform.php index a5279427a0..fd1c426516 100644 --- a/app/config/collections/platform.php +++ b/app/config/collections/platform.php @@ -342,72 +342,6 @@ return [ 'array' => false, 'filters' => ['datetime'], ], - [ - '$id' => ID::custom('authSubscriptionsEnabled'), - 'type' => Database::VAR_BOOLEAN, - 'signed' => true, - 'size' => 0, - 'format' => '', - 'filters' => [], - 'required' => false, - 'default' => false, - 'array' => false, - ], - [ - '$id' => ID::custom('authStripeSecretKey'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 512, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => ['encrypt'], - ], - [ - '$id' => ID::custom('authStripePublishableKey'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 512, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('authStripeWebhookSecret'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 512, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => ['encrypt'], - ], - [ - '$id' => ID::custom('authStripeWebhookEndpointId'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 512, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('authStripeCurrency'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 3, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ] ], 'indexes' => [ [ diff --git a/app/config/services.php b/app/config/services.php index cbf2a60161..0c3f13cf45 100644 --- a/app/config/services.php +++ b/app/config/services.php @@ -274,19 +274,6 @@ return [ 'optional' => false, 'icon' => '', ], - 'authPlans' => [ - 'key' => 'authPlans', - 'name' => 'Auth Plans', - 'subtitle' => 'The Auth Plans service allows you to manage subscription plans for authentication.', - 'description' => '', - 'controller' => 'api/auth-plans.php', - 'sdk' => false, - 'docs' => false, - 'docsUrl' => '', - 'tests' => false, - 'optional' => true, - 'icon' => '', - ], 'migrations' => [ 'key' => 'migrations', 'name' => 'Migrations', diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index a48ee4ff82..28fc161170 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -5183,495 +5183,3 @@ App::delete('/v1/account/identities/:identityId') return $response->noContent(); }); -App::get('/v1/account/subscription') - ->desc('Get account subscription') - ->groups(['api', 'account']) - ->label('scope', 'account') - ->label('sdk', new Method( - namespace: 'account', - group: 'subscription', - name: 'getSubscription', - description: '/docs/references/account/get-subscription.md', - auth: [AuthType::SESSION, AuthType::JWT], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_ANY, - ) - ] - )) - ->inject('response') - ->inject('user') - ->inject('project') - ->inject('dbForProject') - ->inject('dbForPlatform') - ->action(function (Response $response, Document $user, Document $project, Database $dbForProject, Database $dbForPlatform) { - if (!$project->getAttribute('authSubscriptionsEnabled')) { - throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Subscriptions not enabled for this project'); - } - - $planId = (string) $user->getAttribute('planId', ''); - $plan = null; - - if ($planId !== '') { - $candidate = Authorization::skip(fn () => $dbForPlatform->findOne('auth_plans', [ - Query::equal('projectId', [$project->getId()]), - Query::equal('planId', [$planId]), - Query::equal('active', [true]) - ])); - $plan = ($candidate instanceof \Utopia\Database\Document && !$candidate->isEmpty()) ? $candidate : null; - } - - if (!$plan) { - $candidate = Authorization::skip(fn () => $dbForPlatform->findOne('auth_plans', [ - Query::equal('projectId', [$project->getId()]), - Query::equal('isDefault', [true]), - Query::equal('active', [true]) - ])); - $plan = ($candidate instanceof \Utopia\Database\Document && !$candidate->isEmpty()) ? $candidate : null; - } - - // Final safeguard: return default even if not explicitly marked active - if (!$plan) { - $candidate = Authorization::skip(fn () => $dbForPlatform->findOne('auth_plans', [ - Query::equal('projectId', [$project->getId()]), - Query::equal('isDefault', [true]) - ])); - $plan = ($candidate instanceof \Utopia\Database\Document && !$candidate->isEmpty()) ? $candidate : null; - } - - if ($plan) { - $planId = (string) $plan->getAttribute('planId'); - } - - $features = []; - if ($planId !== '') { - $assigned = Authorization::skip(fn () => $dbForPlatform->find('auth_plan_features', [ - Query::equal('projectId', [$project->getId()]), - Query::equal('planId', [$planId]), - Query::equal('active', [true]) - ])); - $stripeService = new \Appwrite\Auth\Subscription\StripeService( - $project->getAttribute('authStripeSecretKey'), - $dbForProject, - $project, - $dbForPlatform - ); - $customerId = (string) $user->getAttribute('stripeCustomerId', ''); - $periodStart = $user->getAttribute('subscriptionCurrentPeriodStart'); - $periodEnd = $user->getAttribute('subscriptionCurrentPeriodEnd'); - $startTs = $periodStart ? strtotime($periodStart) : 0; - $endTs = $periodEnd ? strtotime($periodEnd) : time(); - foreach ($assigned as $af) { - $featureId = (string) $af->getAttribute('featureId'); - $usage = 0; - if ((string)$af->getAttribute('type') === 'metered') { - $featureStartTs = $startTs; - $featureEndTs = $endTs; - if ($featureStartTs <= 0 || $featureEndTs <= 0) { - $interval = (string) $af->getAttribute('interval', 'month'); - $seconds = match ($interval) { - 'day' => 86400, - 'week' => 86400 * 7, - 'year' => 86400 * 365, - default => 86400 * 30, - }; - $featureEndTs = time(); - $featureStartTs = $featureEndTs - $seconds; - } - try { - $usage = $stripeService->getFeatureUsageTotalLocal($user->getId(), $planId, $featureId, $featureStartTs, $featureEndTs); - } catch (\Throwable $_) { - } - } - $features[] = [ - 'featureId' => $featureId, - 'type' => (string) $af->getAttribute('type'), - 'enabled' => (bool) $af->getAttribute('enabled', true), - 'currency' => $af->getAttribute('currency'), - 'interval' => $af->getAttribute('interval'), - 'includedUnits' => (int) $af->getAttribute('includedUnits', 0), - 'tiersMode' => $af->getAttribute('tiersMode'), - 'tiers' => (array) $af->getAttribute('tiers', []), - 'usage' => $usage, - 'usageCap' => $af->getAttribute('usageCap'), - ]; - } - } - - $response->dynamic(new Document([ - 'planId' => $planId !== '' ? $planId : null, - 'planName' => $plan ? $plan->getAttribute('name') : null, - 'status' => $user->getAttribute('subscriptionStatus', 'none'), - 'currentPeriodStart' => $user->getAttribute('subscriptionCurrentPeriodStart'), - 'currentPeriodEnd' => $user->getAttribute('subscriptionCurrentPeriodEnd'), - 'cancelAtPeriodEnd' => $user->getAttribute('subscriptionCancelAtPeriodEnd', false), - 'trialEnd' => $user->getAttribute('subscriptionTrialEnd'), - 'features' => $features - ]), Response::MODEL_ANY); - }); - -App::post('/v1/account/subscription/checkout') - ->desc('Create checkout session') - ->groups(['api', 'account']) - ->label('scope', 'account') - ->label('sdk', new Method( - namespace: 'account', - group: 'subscription', - name: 'createSubscriptionCheckout', - description: '/docs/references/account/create-checkout.md', - auth: [AuthType::SESSION, AuthType::JWT], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_ANY, - ) - ] - )) - ->param('planId', '', new Text(128), 'Plan ID to subscribe to.') - ->param('successUrl', '', new URL(), 'URL to redirect on success.') - ->param('cancelUrl', '', new URL(), 'URL to redirect on cancel.') - ->inject('response') - ->inject('user') - ->inject('project') - ->inject('dbForProject') - ->inject('dbForPlatform') - ->action(function ( - string $planId, - string $successUrl, - string $cancelUrl, - Response $response, - Document $user, - Document $project, - Database $dbForProject, - Database $dbForPlatform - ) { - if (!$project->getAttribute('authSubscriptionsEnabled')) { - throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Subscriptions not enabled for this project'); - } - - $plan = Authorization::skip(fn () => $dbForPlatform->findOne('auth_plans', [ - Query::equal('projectId', [$project->getId()]), - Query::equal('planId', [$planId]), - Query::equal('active', [true]) - ])); - - if (!$plan) { - throw new Exception(Exception::GENERAL_NOT_FOUND, 'Plan not found'); - } - - if ($plan->getAttribute('isFree')) { - throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Cannot checkout for free plan'); - } - - $stripeService = new \Appwrite\Auth\Subscription\StripeService( - $project->getAttribute('authStripeSecretKey'), - $dbForProject, - $project, - $dbForPlatform - ); - - try { - $additionalItems = $stripeService->buildAdditionalLineItemsForPlan($planId); - - $session = $stripeService->createCheckoutSession( - $user, - $plan->getAttribute('stripePriceId'), - $successUrl, - $cancelUrl, - (string) $plan->getAttribute('planId'), - $additionalItems - ); - - $response->dynamic(new Document([ - 'checkoutUrl' => $session['url'] - ]), Response::MODEL_ANY); - } catch (\Appwrite\Auth\Subscription\Exception\SubscriptionException $e) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, $e->getMessage()); - } - }); - -App::post('/v1/account/subscription/portal') - ->desc('Create customer portal session') - ->groups(['api', 'account']) - ->label('scope', 'account') - ->label('sdk', new Method( - namespace: 'account', - group: 'subscription', - name: 'createSubscriptionPortal', - description: '/docs/references/account/create-portal.md', - auth: [AuthType::SESSION, AuthType::JWT], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_ANY, - ) - ] - )) - ->param('returnUrl', '', new URL(), 'URL to return to.') - ->inject('response') - ->inject('user') - ->inject('project') - ->inject('dbForProject') - ->action(function ( - string $returnUrl, - Response $response, - Document $user, - Document $project, - Database $dbForProject - ) { - if (!$project->getAttribute('authSubscriptionsEnabled')) { - throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Subscriptions not enabled for this project'); - } - - $customerId = $user->getAttribute('stripeCustomerId'); - - if (!$customerId) { - throw new Exception(Exception::GENERAL_BAD_REQUEST, 'No active subscription found'); - } - - $stripeService = new \Appwrite\Auth\Subscription\StripeService( - $project->getAttribute('authStripeSecretKey'), - $dbForProject, - $project - ); - - try { - $session = $stripeService->createPortalSession($customerId, $returnUrl); - - $response->dynamic(new Document([ - 'portalUrl' => $session['url'] - ]), Response::MODEL_ANY); - } catch (\Appwrite\Auth\Subscription\Exception\SubscriptionException $e) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, $e->getMessage()); - } - }); - -App::put('/v1/account/subscription') - ->desc('Update subscription') - ->groups(['api', 'account']) - ->label('scope', 'account') - ->label('sdk', new Method( - namespace: 'account', - group: 'subscription', - name: 'updateSubscription', - description: '/docs/references/account/update-subscription.md', - auth: [AuthType::SESSION, AuthType::JWT], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_ANY, - ) - ] - )) - ->param('planId', '', new Text(128), 'New plan ID.') - ->inject('response') - ->inject('user') - ->inject('project') - ->inject('dbForProject') - ->inject('dbForPlatform') - ->action(function ( - string $planId, - Response $response, - Document $user, - Document $project, - Database $dbForProject, - Database $dbForPlatform - ) { - if (!$project->getAttribute('authSubscriptionsEnabled')) { - throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Subscriptions not enabled for this project'); - } - - $subscriptionId = $user->getAttribute('stripeSubscriptionId'); - - if (!$subscriptionId) { - throw new Exception(Exception::GENERAL_BAD_REQUEST, 'No active subscription found'); - } - - $plan = Authorization::skip(fn () => $dbForPlatform->findOne('auth_plans', [ - Query::equal('projectId', [$project->getId()]), - Query::equal('planId', [$planId]), - Query::equal('active', [true]) - ])); - - if (!$plan) { - throw new Exception(Exception::GENERAL_NOT_FOUND, 'Plan not found'); - } - - $stripeService = new \Appwrite\Auth\Subscription\StripeService( - $project->getAttribute('authStripeSecretKey'), - $dbForProject, - $project - ); - - try { - $subscription = $stripeService->updateSubscription( - $subscriptionId, - $plan->getAttribute('stripePriceId') - ); - - $stripeService->syncSubscriptionStatus($user->getId(), $subscription); - - $response->dynamic(new Document([ - 'success' => true - ]), Response::MODEL_ANY); - } catch (\Appwrite\Auth\Subscription\Exception\SubscriptionException $e) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, $e->getMessage()); - } - }); - -App::delete('/v1/account/subscription') - ->desc('Cancel subscription') - ->groups(['api', 'account']) - ->label('scope', 'account') - ->label('sdk', new Method( - namespace: 'account', - group: 'subscription', - name: 'cancelSubscription', - description: '/docs/references/account/cancel-subscription.md', - auth: [AuthType::SESSION, AuthType::JWT], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_ANY, - ) - ] - )) - ->param('atPeriodEnd', true, new Boolean(), 'Cancel at period end.', true) - ->inject('response') - ->inject('user') - ->inject('project') - ->inject('dbForProject') - ->action(function ( - bool $atPeriodEnd, - Response $response, - Document $user, - Document $project, - Database $dbForProject - ) { - if (!$project->getAttribute('authSubscriptionsEnabled')) { - throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Subscriptions not enabled for this project'); - } - - $subscriptionId = $user->getAttribute('stripeSubscriptionId'); - - if (!$subscriptionId) { - throw new Exception(Exception::GENERAL_BAD_REQUEST, 'No active subscription found'); - } - - $stripeService = new \Appwrite\Auth\Subscription\StripeService( - $project->getAttribute('authStripeSecretKey'), - $dbForProject, - $project - ); - - try { - $subscription = $stripeService->cancelSubscription($subscriptionId, $atPeriodEnd); - - if ($atPeriodEnd) { - $stripeService->syncSubscriptionStatus($user->getId(), $subscription); - } - - $response->dynamic(new Document([ - 'success' => true, - 'cancelAtPeriodEnd' => $atPeriodEnd - ]), Response::MODEL_ANY); - } catch (\Appwrite\Auth\Subscription\Exception\SubscriptionException $e) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, $e->getMessage()); - } - }); - -App::post('/v1/usage/ingest') - ->desc('Ingest metered usage for a feature') - ->groups(['api']) - ->label('scope', 'account') - ->label('sdk', new Method( - namespace: 'account', - group: 'usage', - name: 'ingestUsage', - description: '/docs/references/account/ingest-usage.md', - auth: [AuthType::KEY, AuthType::SESSION, AuthType::JWT], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_ANY, - ) - ] - )) - ->param('planId', '', new Text(128), 'Plan ID to attribute usage to.') - ->param('featureId', '', new Text(128), 'Feature ID to attribute usage to.') - ->param('value', 1, new Integer(), 'Usage value to ingest.') - ->param('userId', '', new UID(), 'User ID to attribute usage to. Ignored for client SDK calls.', true) - ->param('timestamp', null, new Integer(true), 'Unix timestamp for the event.', true) - ->param('identifier', null, new Text(256), 'Idempotency identifier for this event.', true) - ->inject('response') - ->inject('project') - ->inject('user') - ->inject('apiKey') - ->inject('dbForProject') - ->inject('dbForPlatform') - ->action(function ( - string $planId, - string $featureId, - int $value, - string $userId, - ?int $timestamp, - ?string $identifier, - Response $response, - Document $project, - Document $user, - ?Key $apiKey, - Database $dbForProject, - Database $dbForPlatform - ) { - if (!$project->getAttribute('authSubscriptionsEnabled')) { - throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Subscriptions not enabled for this project'); - } - - // Determine effective userId - $effectiveUserId = ''; - if (!empty($apiKey)) { - // Admin/API key: honor provided userId - if (empty($userId)) { - throw new Exception(Exception::GENERAL_BAD_REQUEST, 'userId is required when using API key'); - } - $effectiveUserId = $userId; - } else { - // Client SDK (session/JWT): ignore provided userId, use logged-in user - if ($user->isEmpty()) { - throw new Exception(Exception::USER_UNAUTHORIZED); - } - $effectiveUserId = $user->getId(); - } - - // Validate plan & feature assignment exists and is metered - $assignment = Authorization::skip(fn () => $dbForPlatform->findOne('auth_plan_features', [ - Query::equal('projectId', [$project->getId()]), - Query::equal('planId', [$planId]), - Query::equal('featureId', [$featureId]), - Query::equal('active', [true]) - ])); - if (!$assignment || $assignment->isEmpty()) { - throw new Exception(Exception::GENERAL_NOT_FOUND, 'Feature not assigned to plan'); - } - if ((string)$assignment->getAttribute('type') !== 'metered') { - throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Feature is not metered'); - } - - $stripeService = new \Appwrite\Auth\Subscription\StripeService( - $project->getAttribute('authStripeSecretKey'), - $dbForProject, - $project, - $dbForPlatform - ); - - try { - $result = $stripeService->ingestUsage($value, $effectiveUserId, $planId, $featureId, $timestamp, $identifier); - $response->dynamic(new Document([ - 'success' => true, - 'id' => $result['id'] ?? null - ]), Response::MODEL_ANY); - } catch (\Appwrite\Auth\Subscription\Exception\SubscriptionException $e) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, $e->getMessage()); - } - }); diff --git a/app/controllers/api/auth-plans.php b/app/controllers/api/auth-plans.php deleted file mode 100644 index 8445e95589..0000000000 --- a/app/controllers/api/auth-plans.php +++ /dev/null @@ -1,1019 +0,0 @@ -desc('Create auth plan') - ->groups(['api']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'authPlans', - name: 'createAuthPlan', - description: '/docs/references/projects/create-auth-plan.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_CREATED, - model: Response::MODEL_ANY, - ) - ] - )) - ->param('projectId', '', new UID(), 'Project unique ID.') - ->param('planId', '', new Text(128), 'Plan unique ID.') - ->param('name', '', new Text(128), 'Plan name.') - ->param('price', 0, new Integer(true), 'Price in cents.') - ->param('currency', '', new Text(3), 'Currency code (3 letters).') - ->param('interval', null, new WhiteList(['month', 'year', 'week', 'day'], true), 'Billing interval.', true) - ->param('description', '', new Text(256), 'Plan description.', true) - ->param('features', [], new ArrayList(new Assoc()), 'Plan features list.', true) - ->param('maxUsers', null, new Integer(true), 'Maximum users allowed.', true) - ->param('isDefault', false, new Boolean(), 'Is this the default plan.', true) - ->param('isFree', false, new Boolean(), 'Is this a free plan.', true) - ->inject('response') - ->inject('dbForPlatform') - ->inject('dbForProject') - ->action(function ( - string $projectId, - string $planId, - string $name, - int $price, - string $currency, - ?string $interval, - string $description, - array $features, - ?int $maxUsers, - bool $isDefault, - bool $isFree, - Response $response, - Database $dbForPlatform, - Database $dbForProject - ) { - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - if (!$project->getAttribute('authSubscriptionsEnabled')) { - throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Auth subscriptions not configured for this project'); - } - - $stripeProductId = null; - $stripePriceId = null; - - if (!$isFree) { - try { - $stripeService = new StripeService( - $project->getAttribute('authStripeSecretKey'), - $dbForProject, - $project - ); - - $product = $stripeService->createProduct($name, $description, $planId); - $stripeProductId = $product['id']; - - $priceData = $stripeService->createPrice($stripeProductId, $price, $currency, $interval, $planId); - $stripePriceId = $priceData['id']; - } catch (SubscriptionException $e) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, $e->getMessage()); - } - } - - if ($isDefault) { - $existingDefault = $dbForPlatform->findOne('auth_plans', [ - Query::equal('projectId', [$projectId]), - Query::equal('isDefault', [true]) - ]); - - if ($existingDefault instanceof Document) { - $existingId = $existingDefault->getId(); - if (!empty($existingId)) { - $dbForPlatform->updateDocument('auth_plans', $existingId, $existingDefault - ->setAttribute('isDefault', false)); - } - } - } - - $plan = $dbForPlatform->createDocument('auth_plans', new Document([ - '$id' => ID::unique(), - 'projectInternalId' => $project->getSequence(), - 'projectId' => $projectId, - 'planId' => $planId, - 'name' => $name, - 'description' => $description, - 'stripeProductId' => $stripeProductId, - 'stripePriceId' => $stripePriceId, - 'price' => $price, - 'currency' => $currency, - 'interval' => $interval, - 'features' => $features, - 'isDefault' => $isDefault, - 'isFree' => $isFree, - 'maxUsers' => $maxUsers, - 'active' => true, - 'search' => implode(' ', [$planId, $name, $description]) - ])); - - $response->setStatusCode(Response::STATUS_CODE_CREATED); - $response->dynamic($plan, Response::MODEL_ANY); - }); - -App::get('/v1/projects/:projectId/auth/plans/:planId/features') - ->desc('List features assigned to plan') - ->groups(['api']) - ->label('scope', 'projects.read') - ->label('sdk', new Method( - namespace: 'projects', - group: 'authPlans', - name: 'listPlanFeatures', - description: '/docs/references/projects/list-plan-features.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_DOCUMENT_LIST, - ) - ] - )) - ->param('projectId', '', new UID(), 'Project unique ID.') - ->param('planId', '', new Text(128), 'Plan ID.') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, string $planId, Response $response, Database $dbForPlatform) { - $project = $dbForPlatform->getDocument('projects', $projectId); - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - $docs = $dbForPlatform->find('auth_plan_features', [ - Query::equal('projectId', [$projectId]), - Query::equal('planId', [$planId]), - Query::equal('active', [true]) - ]); - $response->dynamic(new Document([ - 'total' => count($docs), - 'documents' => $docs - ]), Response::MODEL_DOCUMENT_LIST); - }); - -App::post('/v1/projects/:projectId/auth/plans/:planId/features') - ->desc('Assign features to plan') - ->groups(['api']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'authPlans', - name: 'assignPlanFeatures', - description: '/docs/references/projects/assign-plan-features.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_DOCUMENT_LIST, - ) - ] - )) - ->param('projectId', '', new UID(), 'Project unique ID.') - ->param('planId', '', new Text(128), 'Plan ID.') - ->param('features', [], new ArrayList(new Assoc()), 'Array of feature assignments.', true) - ->inject('response') - ->inject('dbForPlatform') - ->inject('dbForProject') - ->action(function ( - string $projectId, - string $planId, - array $features, - Response $response, - Database $dbForPlatform, - Database $dbForProject - ) { - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $plan = $dbForPlatform->findOne('auth_plans', [ - Query::equal('projectId', [$projectId]), - Query::equal('planId', [$planId]) - ]); - - if (!$plan) { - throw new Exception(Exception::GENERAL_NOT_FOUND, 'Plan not found'); - } - - $stripeService = null; - if ($project->getAttribute('authSubscriptionsEnabled')) { - $stripeService = new StripeService( - $project->getAttribute('authStripeSecretKey'), - $dbForProject, - $project, - $dbForPlatform - ); - } - - $created = []; - - foreach ($features as $config) { - $featureId = (string) ($config['featureId'] ?? ''); - $type = (string) ($config['type'] ?? ''); - if ($featureId === '' || ($type !== 'boolean' && $type !== 'metered')) { - continue; - } - - $feature = $dbForPlatform->findOne('auth_features', [ - Query::equal('projectId', [$projectId]), - Query::equal('featureId', [$featureId]), - Query::equal('active', [true]) - ]); - if (!$feature) { - throw new Exception(Exception::GENERAL_NOT_FOUND, 'Feature not found: ' . $featureId); - } - - $existing = $dbForPlatform->findOne('auth_plan_features', [ - Query::equal('projectId', [$projectId]), - Query::equal('planId', [$planId]), - Query::equal('featureId', [$featureId]) - ]); - - $exists = ($existing instanceof Document) && !$existing->isEmpty(); - $docId = $exists ? (string) $existing->getId() : ID::unique(); - $doc = $exists ? $existing : new Document(['$id' => $docId]); - - $doc - ->setAttribute('projectInternalId', $project->getSequence()) - ->setAttribute('projectId', $projectId) - ->setAttribute('planId', $planId) - ->setAttribute('featureId', $featureId) - ->setAttribute('type', $type) - ->setAttribute('active', true); - - if ($type === 'boolean') { - $doc->setAttribute('enabled', (bool) ($config['enabled'] ?? true)); - $doc->setAttribute('currency', null); - $doc->setAttribute('interval', null); - $doc->setAttribute('includedUnits', 0); - $doc->setAttribute('tiersMode', 'graduated'); - $doc->setAttribute('tiers', []); - $doc->setAttribute('usageCap', null); - } else { - $currency = (string) ($config['currency'] ?? $plan->getAttribute('currency')); - $interval = (string) ($config['interval'] ?? $plan->getAttribute('interval')); - $included = (int) ($config['includedUnits'] ?? 0); - $tiersMode = (string) ($config['tiersMode'] ?? 'graduated'); - $tiers = (array) ($config['tiers'] ?? []); - $usageCap = isset($config['usageCap']) ? (int)$config['usageCap'] : null; - - $doc->setAttribute('enabled', true); - $doc->setAttribute('currency', $currency); - $doc->setAttribute('interval', $interval); - $doc->setAttribute('includedUnits', $included); - $doc->setAttribute('tiersMode', $tiersMode); - $doc->setAttribute('tiers', $tiers); - $doc->setAttribute('usageCap', $usageCap); - - if ($stripeService) { - $productId = $plan->getAttribute('stripeProductId'); - if (!$productId) { - $product = $stripeService->createProduct($plan->getAttribute('name'), $plan->getAttribute('description', ''), (string) $plan->getAttribute('planId')); - $productId = $product['id']; - $plan->setAttribute('stripeProductId', $productId); - $dbForPlatform->updateDocument('auth_plans', $plan->getId(), $plan); - } - - $meterId = $stripeService->ensureMeterForFeature( - (string) $plan->getAttribute('planId'), - $featureId, - (string) $feature->getAttribute('name') - ); - if ($meterId) { - $doc->setAttribute('stripeMeterId', $meterId); - } - - $price = $stripeService->createMeteredTieredPrice( - $productId, - $currency, - $interval ?: null, - $included, - $tiersMode, - $tiers, - (string) $plan->getAttribute('planId'), - $featureId, - (string) $feature->getAttribute('name') - ); - $doc->setAttribute('stripePriceId', $price['id'] ?? null); - } - } - - if ($exists) { - $updated = $dbForPlatform->updateDocument('auth_plan_features', $docId, $doc); - $created[] = $updated; - } else { - $created[] = $dbForPlatform->createDocument('auth_plan_features', $doc); - } - } - - $response->dynamic(new Document([ - 'total' => count($created), - 'documents' => $created - ]), Response::MODEL_DOCUMENT_LIST); - }); - -App::get('/v1/projects/:projectId/auth/plans') - ->desc('List auth plans') - ->groups(['api']) - ->label('scope', 'projects.read') - ->label('sdk', new Method( - namespace: 'projects', - group: 'authPlans', - name: 'listAuthPlans', - description: '/docs/references/projects/list-auth-plans.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_DOCUMENT_LIST, - ) - ] - )) - ->param('projectId', '', new UID(), 'Project unique ID.') - ->param('queries', [], new ArrayList(new Text(256)), 'Array of query strings.', true) - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, array $queries, Response $response, Database $dbForPlatform) { - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $queries[] = Query::equal('projectId', [$projectId]); - $queries[] = Query::equal('active', [true]); - - $plans = $dbForPlatform->find('auth_plans', $queries); - foreach ($plans as $plan) { - $pf = $dbForPlatform->find('auth_plan_features', [ - Query::equal('projectId', [$projectId]), - Query::equal('planId', [(string)$plan->getAttribute('planId')]), - Query::equal('active', [true]) - ]); - $features = []; - foreach ($pf as $f) { - $type = (string)$f->getAttribute('type'); - if ($type === 'boolean') { - $features[] = [ - 'featureId' => (string)$f->getAttribute('featureId'), - 'type' => 'boolean', - 'enabled' => (bool)$f->getAttribute('enabled', true) - ]; - } else { - $features[] = [ - 'featureId' => (string)$f->getAttribute('featureId'), - 'type' => 'metered', - 'currency' => $f->getAttribute('currency'), - 'interval' => $f->getAttribute('interval'), - 'includedUnits' => (int)$f->getAttribute('includedUnits', 0), - 'tiersMode' => $f->getAttribute('tiersMode'), - 'tiers' => (array)$f->getAttribute('tiers', []), - 'stripePriceId' => $f->getAttribute('stripePriceId'), - 'usageCap' => $f->getAttribute('usageCap') - ]; - } - } - $plan->setAttribute('features', $features); - } - - $response->dynamic(new Document([ - 'total' => count($plans), - 'documents' => $plans - ]), Response::MODEL_DOCUMENT_LIST); - }); - -App::get('/v1/projects/:projectId/auth/plans/:planId') - ->desc('Get auth plan') - ->groups(['api']) - ->label('scope', 'projects.read') - ->label('sdk', new Method( - namespace: 'projects', - group: 'authPlans', - name: 'getAuthPlan', - description: '/docs/references/projects/get-auth-plan.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_ANY, - ) - ] - )) - ->param('projectId', '', new UID(), 'Project unique ID.') - ->param('planId', '', new Text(128), 'Plan ID.') - ->inject('response') - ->inject('dbForPlatform') - ->inject('dbForProject') - ->action(function (string $projectId, string $planId, Response $response, Database $dbForPlatform, Database $dbForProject) { - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $plan = $dbForPlatform->findOne('auth_plans', [ - Query::equal('projectId', [$projectId]), - Query::equal('planId', [$planId]) - ]); - - if (!$plan || !$plan->getAttribute('active', true)) { - throw new Exception(Exception::GENERAL_NOT_FOUND, 'Plan not found'); - } - - $pf = $dbForPlatform->find('auth_plan_features', [ - Query::equal('projectId', [$projectId]), - Query::equal('planId', [$planId]), - Query::equal('active', [true]) - ]); - $features = []; - foreach ($pf as $f) { - $type = (string)$f->getAttribute('type'); - if ($type === 'boolean') { - $features[] = [ - 'featureId' => (string)$f->getAttribute('featureId'), - 'type' => 'boolean', - 'enabled' => (bool)$f->getAttribute('enabled', true) - ]; - } else { - $features[] = [ - 'featureId' => (string)$f->getAttribute('featureId'), - 'type' => 'metered', - 'currency' => $f->getAttribute('currency'), - 'interval' => $f->getAttribute('interval'), - 'includedUnits' => (int)$f->getAttribute('includedUnits', 0), - 'tiersMode' => $f->getAttribute('tiersMode'), - 'tiers' => (array)$f->getAttribute('tiers', []), - 'stripePriceId' => $f->getAttribute('stripePriceId'), - 'usageCap' => $f->getAttribute('usageCap') - ]; - } - } - $plan->setAttribute('features', $features); - - $response->dynamic($plan, Response::MODEL_ANY); - }); - -App::delete('/v1/projects/:projectId/auth/plans/:planId/features/:featureId') - ->desc('Delete feature assignment from plan') - ->groups(['api']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'authPlans', - name: 'deletePlanFeature', - description: '/docs/references/projects/delete-plan-feature.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_NOCONTENT, - model: Response::MODEL_NONE, - ) - ] - )) - ->param('projectId', '', new UID(), 'Project unique ID.') - ->param('planId', '', new Text(128), 'Plan ID.') - ->param('featureId', '', new Text(128), 'Feature ID.') - ->inject('response') - ->inject('dbForPlatform') - ->inject('dbForProject') - ->action(function ( - string $projectId, - string $planId, - string $featureId, - Response $response, - Database $dbForPlatform, - Database $dbForProject - ) { - $project = $dbForPlatform->getDocument('projects', $projectId); - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $assignment = $dbForPlatform->findOne('auth_plan_features', [ - Query::equal('projectId', [$projectId]), - Query::equal('planId', [$planId]), - Query::equal('featureId', [$featureId]) - ]); - - if (!$assignment) { - throw new Exception(Exception::GENERAL_NOT_FOUND, 'Plan feature not found'); - } - - if ($project->getAttribute('authSubscriptionsEnabled')) { - try { - $stripeService = new StripeService( - $project->getAttribute('authStripeSecretKey'), - $dbForProject, - $project - ); - $priceId = (string) $assignment->getAttribute('stripePriceId', ''); - if ($priceId !== '') { - $stripeService->deactivatePrice($priceId); - } - } catch (SubscriptionException $_) { - } - } - - $assignment->setAttribute('active', false); - $dbForPlatform->updateDocument('auth_plan_features', $assignment->getId(), $assignment); - - $response->noContent(); - }); - -App::delete('/v1/projects/:projectId/auth/plans/:planId/features') - ->desc('Delete multiple feature assignments from plan') - ->groups(['api']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'authPlans', - name: 'deletePlanFeatures', - description: '/docs/references/projects/delete-plan-features.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_DOCUMENT_LIST, - ) - ] - )) - ->param('projectId', '', new UID(), 'Project unique ID.') - ->param('planId', '', new Text(128), 'Plan ID.') - ->param('featureIds', [], new ArrayList(new Text(128)), 'Array of feature IDs to remove.', true) - ->inject('response') - ->inject('dbForPlatform') - ->inject('dbForProject') - ->action(function ( - string $projectId, - string $planId, - array $featureIds, - Response $response, - Database $dbForPlatform, - Database $dbForProject - ) { - $project = $dbForPlatform->getDocument('projects', $projectId); - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $updated = []; - foreach ($featureIds as $fid) { - $assignment = $dbForPlatform->findOne('auth_plan_features', [ - Query::equal('projectId', [$projectId]), - Query::equal('planId', [$planId]), - Query::equal('featureId', [$fid]) - ]); - if (!$assignment) { - continue; - } - - if ($project->getAttribute('authSubscriptionsEnabled')) { - try { - $stripeService = new StripeService( - $project->getAttribute('authStripeSecretKey'), - $dbForProject, - $project - ); - $priceId = (string) $assignment->getAttribute('stripePriceId', ''); - if ($priceId !== '') { - $stripeService->deactivatePrice($priceId); - } - } catch (SubscriptionException $_) { - } - } - - $assignment->setAttribute('active', false); - $updated[] = $dbForPlatform->updateDocument('auth_plan_features', $assignment->getId(), $assignment); - } - - $response->dynamic(new Document([ - 'total' => count($updated), - 'documents' => $updated - ]), Response::MODEL_DOCUMENT_LIST); - }); - -App::put('/v1/projects/:projectId/auth/plans/:planId') - ->desc('Update auth plan') - ->groups(['api']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'authPlans', - name: 'updateAuthPlan', - description: '/docs/references/projects/update-auth-plan.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_ANY, - ) - ] - )) - ->param('projectId', '', new UID(), 'Project unique ID.') - ->param('planId', '', new Text(128), 'Plan ID.') - ->param('name', null, new Text(128), 'Plan name.', true) - ->param('description', null, new Text(256), 'Plan description.', true) - ->param('features', null, new ArrayList(new Assoc()), 'Full list of feature assignments for this plan.') - ->param('maxUsers', null, new Integer(true), 'Maximum users allowed.', true) - ->param('isDefault', null, new Boolean(), 'Is this the default plan.', true) - ->inject('response') - ->inject('dbForPlatform') - ->inject('dbForProject') - ->action(function ( - string $projectId, - string $planId, - ?string $name, - ?string $description, - ?array $features, - ?int $maxUsers, - ?bool $isDefault, - Response $response, - Database $dbForPlatform, - Database $dbForProject - ) { - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $plan = $dbForPlatform->findOne('auth_plans', [ - Query::equal('projectId', [$projectId]), - Query::equal('planId', [$planId]) - ]); - - if (!$plan) { - throw new Exception(Exception::GENERAL_NOT_FOUND, 'Plan not found'); - } - - if ($plan->getAttribute('isDefault', false) === true) { - throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Cannot delete default plan'); - } - - if ($isDefault === true) { - $existingDefault = $dbForPlatform->findOne('auth_plans', [ - Query::equal('projectId', [$projectId]), - Query::equal('isDefault', [true]), - Query::notEqual('$id', [$plan->getId()]) - ]); - - if ($existingDefault) { - $dbForPlatform->updateDocument('auth_plans', $existingDefault->getId(), $existingDefault - ->setAttribute('isDefault', false)); - } - } - - // Prevent removing the only default plan - if ($isDefault === false && $plan->getAttribute('isDefault', false) === true) { - $otherDefault = $dbForPlatform->findOne('auth_plans', [ - Query::equal('projectId', [$projectId]), - Query::equal('isDefault', [true]), - Query::notEqual('$id', [$plan->getId()]) - ]); - if (!$otherDefault) { - throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Cannot unset default plan without another default'); - } - } - - if ($name !== null) { - $plan->setAttribute('name', $name); - } - if ($description !== null) { - $plan->setAttribute('description', $description); - } - if ($features !== null) { - $plan->setAttribute('features', $features); - } - if ($maxUsers !== null) { - $plan->setAttribute('maxUsers', $maxUsers); - } - if ($isDefault !== null) { - $plan->setAttribute('isDefault', $isDefault); - } - - $plan->setAttribute('search', implode(' ', [ - $plan->getAttribute('planId'), - $plan->getAttribute('name'), - $plan->getAttribute('description') - ])); - - $plan = $dbForPlatform->updateDocument('auth_plans', $plan->getId(), $plan); - - if (is_array($features)) { - $existing = $dbForPlatform->find('auth_plan_features', [ - Query::equal('projectId', [$projectId]), - Query::equal('planId', [$planId]) - ]); - $existingById = []; - foreach ($existing as $a) { - $existingById[(string)$a->getAttribute('featureId')] = $a; - } - - $providedIds = []; - foreach ($features as $cfg) { - if (!is_array($cfg)) { - continue; - } - $fid = (string)($cfg['featureId'] ?? ''); - $type = (string)($cfg['type'] ?? ''); - if ($fid === '' || ($type !== 'boolean' && $type !== 'metered')) { - continue; - } - $providedIds[] = $fid; - $assignment = $existingById[$fid] ?? null; - $doc = $assignment ?: new Document(['$id' => ID::unique()]); - $doc - ->setAttribute('projectInternalId', $project->getSequence()) - ->setAttribute('projectId', $projectId) - ->setAttribute('planId', $planId) - ->setAttribute('featureId', $fid) - ->setAttribute('type', $type) - ->setAttribute('active', true); - if ($type === 'boolean') { - $doc->setAttribute('enabled', (bool)($cfg['enabled'] ?? true)); - $doc->setAttribute('currency', null); - $doc->setAttribute('interval', null); - $doc->setAttribute('includedUnits', 0); - $doc->setAttribute('tiersMode', 'graduated'); - $doc->setAttribute('tiers', []); - $doc->setAttribute('usageCap', null); - } else { - $doc->setAttribute('enabled', true); - $doc->setAttribute('currency', (string)($cfg['currency'] ?? $plan->getAttribute('currency'))); - $doc->setAttribute('interval', (string)($cfg['interval'] ?? $plan->getAttribute('interval'))); - $doc->setAttribute('includedUnits', (int)($cfg['includedUnits'] ?? 0)); - $doc->setAttribute('tiersMode', (string)($cfg['tiersMode'] ?? 'graduated')); - $doc->setAttribute('tiers', (array)($cfg['tiers'] ?? [])); - if (array_key_exists('usageCap', $cfg)) { - $doc->setAttribute('usageCap', $cfg['usageCap'] === null ? null : (int)$cfg['usageCap']); - } - } - if ($assignment) { - if ($type === 'metered' && $project->getAttribute('authSubscriptionsEnabled')) { - try { - $ss = new StripeService( - $project->getAttribute('authStripeSecretKey'), - $dbForProject, - $project, - $dbForPlatform - ); - $productId = (string)$plan->getAttribute('stripeProductId'); - if (!$productId) { - $p = $ss->createProduct($plan->getAttribute('name'), $plan->getAttribute('description', ''), (string)$plan->getAttribute('planId')); - $productId = (string)$p['id']; - $plan->setAttribute('stripeProductId', $productId); - $dbForPlatform->updateDocument('auth_plans', $plan->getId(), $plan); - } - $feature = $dbForPlatform->findOne('auth_features', [ - Query::equal('projectId', [$projectId]), - Query::equal('featureId', [$fid]) - ]); - $featureName = $feature ? (string)$feature->getAttribute('name', $fid) : $fid; - $prev = [ - 'currency' => (string)$assignment->getAttribute('currency'), - 'interval' => (string)$assignment->getAttribute('interval'), - 'includedUnits' => (int)$assignment->getAttribute('includedUnits', 0), - 'tiersMode' => (string)$assignment->getAttribute('tiersMode'), - 'tiers' => (array)$assignment->getAttribute('tiers', []) - ]; - $next = [ - 'currency' => (string)$doc->getAttribute('currency'), - 'interval' => (string)$doc->getAttribute('interval'), - 'includedUnits' => (int)$doc->getAttribute('includedUnits', 0), - 'tiersMode' => (string)$doc->getAttribute('tiersMode'), - 'tiers' => (array)$doc->getAttribute('tiers', []) - ]; - $tiersChanged = json_encode(array_values($prev['tiers'])) !== json_encode(array_values($next['tiers'])) || ($prev['tiersMode'] !== $next['tiersMode']); - $metaChanged = $prev['currency'] !== $next['currency'] || $prev['interval'] !== $next['interval'] || $prev['includedUnits'] !== $next['includedUnits']; - $oldPriceId = (string)$assignment->getAttribute('stripePriceId', ''); - // If nothing changed, still verify on Stripe whether the existing price matches the expected shape. - $forceRecreate = false; - if ($oldPriceId !== '') { - try { - $curr = $ss->getPrice($oldPriceId); - $currMode = (string)($curr['tiers_mode'] ?? ''); - $currUsage = (string)($curr['recurring']['usage_type'] ?? ''); - $currCurrency = (string)($curr['currency'] ?? ''); - $currInterval = (string)($curr['recurring']['interval'] ?? ''); - if ($currMode !== $next['tiersMode'] || $currUsage !== 'metered' || $currCurrency !== $next['currency'] || ($next['interval'] && $currInterval !== $next['interval'])) { - $forceRecreate = true; - } - // Compare tier boundaries with expected (including includedUnits free tier) - $currTiers = isset($curr['tiers']) && is_array($curr['tiers']) ? $curr['tiers'] : []; - $currUpTo = []; - foreach ($currTiers as $t) { - $currUpTo[] = isset($t['up_to']) ? (string)$t['up_to'] : ''; - } - $expectedUpTo = []; - $inc = (int)$next['includedUnits']; - if ($inc > 0) { - $expectedUpTo[] = (string)$inc; - } - foreach ((array)$next['tiers'] as $tierX) { - $to = $tierX['to'] ?? 'inf'; - $expectedUpTo[] = $to === 'inf' ? 'inf' : (string)$to; - } - if (json_encode($currUpTo) !== json_encode($expectedUpTo)) { - $forceRecreate = true; - } - // Ensure free tier is truly free if includedUnits > 0 - if ($inc > 0 && isset($currTiers[0]['unit_amount']) && (int)$currTiers[0]['unit_amount'] !== 0) { - $forceRecreate = true; - } - // debug log removed - } catch (SubscriptionException $e) { - $forceRecreate = true; - } - } - if ($oldPriceId === '' || $tiersChanged || $metaChanged || $forceRecreate) { - $meterId = $ss->ensureMeterForFeature((string)$plan->getAttribute('planId'), $fid, $featureName); - if ($meterId) { - $doc->setAttribute('stripeMeterId', $meterId); - } - $newPrice = $ss->createMeteredTieredPrice( - $productId, - $next['currency'], - $next['interval'] ?: null, - $next['includedUnits'], - $next['tiersMode'], - $next['tiers'], - (string)$plan->getAttribute('planId'), - $fid, - $featureName - ); - $doc->setAttribute('stripePriceId', $newPrice['id'] ?? null); - if ($oldPriceId !== '') { - $ss->deactivatePrice($oldPriceId); - } - } - } catch (SubscriptionException $e) { - throw $e; - } - } - $dbForPlatform->updateDocument('auth_plan_features', $assignment->getId(), $doc); - } else { - if ($type === 'metered' && $project->getAttribute('authSubscriptionsEnabled')) { - try { - $ss = new StripeService( - $project->getAttribute('authStripeSecretKey'), - $dbForProject, - $project, - $dbForPlatform - ); - $productId = (string)$plan->getAttribute('stripeProductId'); - if (!$productId) { - $p = $ss->createProduct($plan->getAttribute('name'), $plan->getAttribute('description', ''), (string)$plan->getAttribute('planId')); - $productId = (string)$p['id']; - $plan->setAttribute('stripeProductId', $productId); - $dbForPlatform->updateDocument('auth_plans', $plan->getId(), $plan); - } - $feature = $dbForPlatform->findOne('auth_features', [ - Query::equal('projectId', [$projectId]), - Query::equal('featureId', [$fid]) - ]); - $featureName = $feature ? (string)$feature->getAttribute('name', $fid) : $fid; - $meterId = $ss->ensureMeterForFeature((string)$plan->getAttribute('planId'), $fid, $featureName); - if ($meterId) { - $doc->setAttribute('stripeMeterId', $meterId); - } - $newPrice = $ss->createMeteredTieredPrice( - $productId, - (string)$doc->getAttribute('currency'), - (string)$doc->getAttribute('interval') ?: null, - (int)$doc->getAttribute('includedUnits', 0), - (string)$doc->getAttribute('tiersMode'), - (array)$doc->getAttribute('tiers', []), - (string)$plan->getAttribute('planId'), - $fid, - $featureName - ); - $doc->setAttribute('stripePriceId', $newPrice['id'] ?? null); - } catch (SubscriptionException $e) { - throw $e; - } - } - $dbForPlatform->createDocument('auth_plan_features', $doc); - } - } - - foreach ($existingById as $fid => $assignment) { - if (!in_array($fid, $providedIds, true) && $assignment->getAttribute('active', true)) { - if ($project->getAttribute('authSubscriptionsEnabled')) { - try { - $ss = new StripeService( - $project->getAttribute('authStripeSecretKey'), - $dbForProject, - $project - ); - $pid = (string)$assignment->getAttribute('stripePriceId', ''); - if ($pid !== '') { - $ss->deactivatePrice($pid); - } - } catch (SubscriptionException $_) { - } - } - $assignment->setAttribute('active', false); - $dbForPlatform->updateDocument('auth_plan_features', $assignment->getId(), $assignment); - } - } - } - - $response->dynamic($plan, Response::MODEL_ANY); - }); - -App::delete('/v1/projects/:projectId/auth/plans/:planId') - ->desc('Delete auth plan') - ->groups(['api']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'authPlans', - name: 'deleteAuthPlan', - description: '/docs/references/projects/delete-auth-plan.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_NOCONTENT, - model: Response::MODEL_NONE, - ) - ] - )) - ->param('projectId', '', new UID(), 'Project unique ID.') - ->param('planId', '', new Text(128), 'Plan ID.') - ->inject('response') - ->inject('dbForPlatform') - ->inject('dbForProject') - ->action(function (string $projectId, string $planId, Response $response, Database $dbForPlatform, Database $dbForProject) { - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $plan = $dbForPlatform->findOne('auth_plans', [ - Query::equal('projectId', [$projectId]), - Query::equal('planId', [$planId]) - ]); - - if (!$plan) { - throw new Exception(Exception::GENERAL_NOT_FOUND, 'Plan not found'); - } - - // Archive on Stripe first if non-free and keys configured - if (!$plan->getAttribute('isFree', false) && $project->getAttribute('authSubscriptionsEnabled')) { - try { - $stripeService = new StripeService( - $project->getAttribute('authStripeSecretKey'), - $dbForProject, - $project - ); - $priceId = $plan->getAttribute('stripePriceId'); - $productId = $plan->getAttribute('stripeProductId'); - if ($priceId) { - // Deactivate price - $stripeServicePrivate = new \ReflectionClass(StripeService::class); - $method = $stripeServicePrivate->getMethod('makeRequest'); - $method->setAccessible(true); - $method->invoke($stripeService, 'POST', '/prices/' . $priceId, ['active' => 'false']); - } - if ($productId) { - // Deactivate product - $stripeServicePrivate = new \ReflectionClass(StripeService::class); - $method = $stripeServicePrivate->getMethod('makeRequest'); - $method->setAccessible(true); - $method->invoke($stripeService, 'POST', '/products/' . $productId, ['active' => 'false']); - } - } catch (\Throwable $_) { - // Swallow Stripe errors on delete to ensure local cleanup - } - } - - $dbForPlatform->deleteDocument('auth_plans', $plan->getId()); - - $response->noContent(); - }); diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 1f79493bee..89fd93c5a9 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -2,10 +2,7 @@ use Ahc\Jwt\JWT; use Appwrite\Auth\Auth; -use Appwrite\Auth\Subscription\Exception\SubscriptionException; -use Appwrite\Auth\Subscription\StripeService; use Appwrite\Auth\Validator\MockNumber; -use Appwrite\Auth\Validator\StripeKey; use Appwrite\Event\Delete; use Appwrite\Event\Mail; use Appwrite\Event\Validator\Event; @@ -49,6 +46,7 @@ use Utopia\Validator\Boolean; use Utopia\Validator\Hostname; use Utopia\Validator\Integer; use Utopia\Validator\Multiple; +use Utopia\Validator\Nullable; use Utopia\Validator\Range; use Utopia\Validator\Text; use Utopia\Validator\URL; @@ -63,209 +61,6 @@ App::init() } }); -App::post('/v1/projects/:projectId/auth/features') - ->desc('Create auth feature') - ->groups(['api']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'authFeatures', - name: 'createAuthFeature', - description: '/docs/references/projects/create-auth-feature.md', - auth: [AuthType::ADMIN, AuthType::SESSION], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_CREATED, - model: Response::MODEL_ANY, - ) - ] - )) - ->param('projectId', '', new UID(), 'Project unique ID.') - ->param('featureId', '', new Text(128), 'Feature unique ID.') - ->param('name', '', new Text(128), 'Feature name.') - ->param('type', '', new WhiteList(['boolean', 'metered']), 'Feature type: boolean or metered') - ->param('description', '', new Text(256), 'Feature description.', true) - ->inject('response') - ->inject('dbForPlatform') - ->action(function ( - string $projectId, - string $featureId, - string $name, - string $type, - string $description, - Response $response, - Database $dbForPlatform - ) { - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $existing = $dbForPlatform->findOne('auth_features', [ - Query::equal('projectId', [$projectId]), - Query::equal('featureId', [$featureId]) - ]); - - if ($existing instanceof Document && !$existing->isEmpty()) { - throw new Exception(Exception::RESOURCE_ALREADY_EXISTS, 'Feature already exists'); - } - - $doc = $dbForPlatform->createDocument('auth_features', new Document([ - '$id' => ID::unique(), - 'projectInternalId' => $project->getSequence(), - 'projectId' => $projectId, - 'featureId' => $featureId, - 'name' => $name, - 'type' => $type, - 'description' => $description, - 'active' => true, - 'search' => implode(' ', [$featureId, $name, $description]) - ])); - - $response->setStatusCode(Response::STATUS_CODE_CREATED); - $response->dynamic($doc, Response::MODEL_ANY); - }); - -App::get('/v1/projects/:projectId/auth/features') - ->desc('List auth features') - ->groups(['api']) - ->label('scope', 'projects.read') - ->label('sdk', new Method( - namespace: 'projects', - group: 'authFeatures', - name: 'listAuthFeatures', - description: '/docs/references/projects/list-auth-features.md', - auth: [AuthType::ADMIN, AuthType::SESSION], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_DOCUMENT_LIST, - ) - ] - )) - ->param('projectId', '', new UID(), 'Project unique ID.') - ->param('queries', [], new ArrayList(new Text(256)), 'Array of query strings.', true) - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, array $queries, Response $response, Database $dbForPlatform) { - $project = $dbForPlatform->getDocument('projects', $projectId); - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - $queries[] = Query::equal('projectId', [$projectId]); - $queries[] = Query::equal('active', [true]); - $docs = $dbForPlatform->find('auth_features', $queries); - $response->dynamic(new Document([ - 'total' => count($docs), - 'documents' => $docs - ]), Response::MODEL_DOCUMENT_LIST); - }); - -App::put('/v1/projects/:projectId/auth/features/:featureId') - ->desc('Update auth feature') - ->groups(['api']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'authFeatures', - name: 'updateAuthFeature', - description: '/docs/references/projects/update-auth-feature.md', - auth: [AuthType::ADMIN, AuthType::SESSION], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_ANY, - ) - ] - )) - ->param('projectId', '', new UID(), 'Project unique ID.') - ->param('featureId', '', new Text(128), 'Feature unique ID.') - ->param('name', null, new Text(128), 'Feature name.', true) - ->param('type', null, new WhiteList(['boolean', 'metered'], true), 'Feature type.', true) - ->param('description', null, new Text(256), 'Feature description.', true) - ->param('active', null, new Boolean(), 'Active state.', true) - ->inject('response') - ->inject('dbForPlatform') - ->action(function ( - string $projectId, - string $featureId, - ?string $name, - ?string $type, - ?string $description, - ?bool $active, - Response $response, - Database $dbForPlatform - ) { - $project = $dbForPlatform->getDocument('projects', $projectId); - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - $doc = $dbForPlatform->findOne('auth_features', [ - Query::equal('projectId', [$projectId]), - Query::equal('featureId', [$featureId]) - ]); - if (!$doc) { - throw new Exception(Exception::GENERAL_NOT_FOUND, 'Feature not found'); - } - if ($name !== null) { - $doc->setAttribute('name', $name); - } - if ($type !== null) { - $doc->setAttribute('type', $type); - } - if ($description !== null) { - $doc->setAttribute('description', $description); - } - if ($active !== null) { - $doc->setAttribute('active', $active); - } - $doc->setAttribute('search', implode(' ', [ - $doc->getAttribute('featureId'), - $doc->getAttribute('name'), - $doc->getAttribute('description') - ])); - $doc = $dbForPlatform->updateDocument('auth_features', $doc->getId(), $doc); - $response->dynamic($doc, Response::MODEL_ANY); - }); - -App::delete('/v1/projects/:projectId/auth/features/:featureId') - ->desc('Delete auth feature') - ->groups(['api']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'authFeatures', - name: 'deleteAuthFeature', - description: '/docs/references/projects/delete-auth-feature.md', - auth: [AuthType::ADMIN, AuthType::SESSION], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_NOCONTENT, - model: Response::MODEL_NONE, - ) - ] - )) - ->param('projectId', '', new UID(), 'Project unique ID.') - ->param('featureId', '', new Text(128), 'Feature unique ID.') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, string $featureId, Response $response, Database $dbForPlatform) { - $project = $dbForPlatform->getDocument('projects', $projectId); - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - $doc = $dbForPlatform->findOne('auth_features', [ - Query::equal('projectId', [$projectId]), - Query::equal('featureId', [$featureId]) - ]); - if (!$doc) { - throw new Exception(Exception::GENERAL_NOT_FOUND, 'Feature not found'); - } - $dbForPlatform->deleteDocument('auth_features', $doc->getId()); - $response->noContent(); - }); - App::post('/v1/projects') ->desc('Create project') ->groups(['api', 'projects']) @@ -527,8 +322,7 @@ App::get('/v1/projects/:projectId') ->param('projectId', '', new UID(), 'Project unique ID.') ->inject('response') ->inject('dbForPlatform') - ->inject('dbForProject') - ->action(function (string $projectId, Response $response, Database $dbForPlatform, Database $dbForProject) { + ->action(function (string $projectId, Response $response, Database $dbForPlatform) { $project = $dbForPlatform->getDocument('projects', $projectId); @@ -885,9 +679,9 @@ App::patch('/v1/projects/:projectId/oauth2') )) ->param('projectId', '', new UID(), 'Project unique ID.') ->param('provider', '', new WhiteList(\array_keys(Config::getParam('oAuthProviders')), true), 'Provider Name') - ->param('appId', null, new Text(256), 'Provider app ID. Max length: 256 chars.', true) - ->param('secret', null, new text(512), 'Provider secret key. Max length: 512 chars.', true) - ->param('enabled', null, new Boolean(), 'Provider status. Set to \'false\' to disable new session creation.', true) + ->param('appId', null, new Nullable(new Text(256)), 'Provider app ID. Max length: 256 chars.', true) + ->param('secret', null, new Nullable(new text(512)), 'Provider secret key. Max length: 512 chars.', true) + ->param('enabled', null, new Nullable(new Boolean()), 'Provider status. Set to \'false\' to disable new session creation.', true) ->inject('response') ->inject('dbForPlatform') ->action(function (string $projectId, string $provider, ?string $appId, ?string $secret, ?bool $enabled, Response $response, Database $dbForPlatform) { @@ -1441,10 +1235,10 @@ App::get('/v1/projects/:projectId/webhooks') ] )) ->param('projectId', '', new UID(), 'Project unique ID.') + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) ->inject('response') ->inject('dbForPlatform') - ->inject('dbForProject') - ->action(function (string $projectId, Response $response, Database $dbForPlatform, Database $dbForProject) { + ->action(function (string $projectId, bool $includeTotal, Response $response, Database $dbForPlatform) { $project = $dbForPlatform->getDocument('projects', $projectId); @@ -1459,7 +1253,7 @@ App::get('/v1/projects/:projectId/webhooks') $response->dynamic(new Document([ 'webhooks' => $webhooks, - 'total' => count($webhooks), + 'total' => $includeTotal ? count($webhooks) : 0, ]), Response::MODEL_WEBHOOK_LIST); }); @@ -1683,8 +1477,8 @@ App::post('/v1/projects/:projectId/keys') )) ->param('projectId', '', new UID(), 'Project unique ID.') ->param('name', null, new Text(128), 'Key name. Max length: 128 chars.') - ->param('scopes', null, new ArrayList(new WhiteList(array_keys(Config::getParam('scopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.') - ->param('expire', null, new DatetimeValidator(), 'Expiration time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Use null for unlimited expiration.', true) + ->param('scopes', null, new Nullable(new ArrayList(new WhiteList(array_keys(Config::getParam('scopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.') + ->param('expire', null, new Nullable(new DatetimeValidator()), 'Expiration time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Use null for unlimited expiration.', true) ->inject('response') ->inject('dbForPlatform') ->action(function (string $projectId, string $name, array $scopes, ?string $expire, Response $response, Database $dbForPlatform) { @@ -1739,9 +1533,10 @@ App::get('/v1/projects/:projectId/keys') ] )) ->param('projectId', '', new UID(), 'Project unique ID.') + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) ->inject('response') ->inject('dbForPlatform') - ->action(function (string $projectId, Response $response, Database $dbForPlatform) { + ->action(function (string $projectId, bool $includeTotal, Response $response, Database $dbForPlatform) { $project = $dbForPlatform->getDocument('projects', $projectId); @@ -1756,7 +1551,7 @@ App::get('/v1/projects/:projectId/keys') $response->dynamic(new Document([ 'keys' => $keys, - 'total' => count($keys), + 'total' => $includeTotal ? count($keys) : 0, ]), Response::MODEL_KEY_LIST); }); @@ -1821,8 +1616,8 @@ App::put('/v1/projects/:projectId/keys/:keyId') ->param('projectId', '', new UID(), 'Project unique ID.') ->param('keyId', '', new UID(), 'Key unique ID.') ->param('name', null, new Text(128), 'Key name. Max length: 128 chars.') - ->param('scopes', null, new ArrayList(new WhiteList(array_keys(Config::getParam('scopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' events are allowed.') - ->param('expire', null, new DatetimeValidator(), 'Expiration time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Use null for unlimited expiration.', true) + ->param('scopes', null, new Nullable(new ArrayList(new WhiteList(array_keys(Config::getParam('scopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' events are allowed.') + ->param('expire', null, new Nullable(new DatetimeValidator()), 'Expiration time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Use null for unlimited expiration.', true) ->inject('response') ->inject('dbForPlatform') ->action(function (string $projectId, string $keyId, string $name, array $scopes, ?string $expire, Response $response, Database $dbForPlatform) { @@ -1964,7 +1759,28 @@ App::post('/v1/projects/:projectId/platforms') ] )) ->param('projectId', '', new UID(), 'Project unique ID.') - ->param('type', null, new WhiteList([Platform::TYPE_WEB, Platform::TYPE_FLUTTER_WEB, Platform::TYPE_FLUTTER_IOS, Platform::TYPE_FLUTTER_ANDROID, Platform::TYPE_FLUTTER_LINUX, Platform::TYPE_FLUTTER_MACOS, Platform::TYPE_FLUTTER_WINDOWS, Platform::TYPE_APPLE_IOS, Platform::TYPE_APPLE_MACOS, Platform::TYPE_APPLE_WATCHOS, Platform::TYPE_APPLE_TVOS, Platform::TYPE_ANDROID, Platform::TYPE_UNITY, Platform::TYPE_REACT_NATIVE_IOS, Platform::TYPE_REACT_NATIVE_ANDROID], true), 'Platform type.') + ->param( + 'type', + null, + new WhiteList([ + Platform::TYPE_WEB, + Platform::TYPE_FLUTTER_WEB, + Platform::TYPE_FLUTTER_IOS, + Platform::TYPE_FLUTTER_ANDROID, + Platform::TYPE_FLUTTER_LINUX, + Platform::TYPE_FLUTTER_MACOS, + Platform::TYPE_FLUTTER_WINDOWS, + Platform::TYPE_APPLE_IOS, + Platform::TYPE_APPLE_MACOS, + Platform::TYPE_APPLE_WATCHOS, + Platform::TYPE_APPLE_TVOS, + Platform::TYPE_ANDROID, + Platform::TYPE_UNITY, + Platform::TYPE_REACT_NATIVE_IOS, + Platform::TYPE_REACT_NATIVE_ANDROID, + ], true), + 'Platform type. Possible values are: web, flutter-web, flutter-ios, flutter-android, flutter-linux, flutter-macos, flutter-windows, apple-ios, apple-macos, apple-watchos, apple-tvos, android, unity, react-native-ios, react-native-android.' + ) ->param('name', null, new Text(128), 'Platform name. Max length: 128 chars.') ->param('key', '', new Text(256), 'Package name for Android or bundle ID for iOS or macOS. Max length: 256 chars.', true) ->param('store', '', new Text(256), 'App store or Google Play store ID. Max length: 256 chars.', true) @@ -2021,9 +1837,10 @@ App::get('/v1/projects/:projectId/platforms') ] )) ->param('projectId', '', new UID(), 'Project unique ID.') + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) ->inject('response') ->inject('dbForPlatform') - ->action(function (string $projectId, Response $response, Database $dbForPlatform) { + ->action(function (string $projectId, bool $includeTotal, Response $response, Database $dbForPlatform) { $project = $dbForPlatform->getDocument('projects', $projectId); @@ -2038,7 +1855,7 @@ App::get('/v1/projects/:projectId/platforms') $response->dynamic(new Document([ 'platforms' => $platforms, - 'total' => count($platforms), + 'total' => $includeTotal ? count($platforms) : 0, ]), Response::MODEL_PLATFORM_LIST); }); @@ -2845,187 +2662,4 @@ App::patch('/v1/projects/:projectId/auth/session-invalidation') ->setAttribute('auths', $auths)); $response->dynamic($project, Response::MODEL_PROJECT); - }); - -App::put('/v1/projects/:projectId/auth/subscriptions') - ->desc('Configure auth subscriptions') - ->groups(['api']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'configureSubscriptions', - description: '/docs/references/projects/configure-subscriptions.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', new UID(), 'Project unique ID.') - ->param('stripeSecretKey', '', new StripeKey(), 'Stripe secret key.') - ->inject('response') - ->inject('dbForPlatform') - ->inject('dbForProject') - ->action(function (string $projectId, string $stripeSecretKey, Response $response, Database $dbForPlatform, Database $dbForProject) { - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - try { - $stripeService = new StripeService($stripeSecretKey, $dbForProject, $project); - $config = $stripeService->initializeAccount(); - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project - ->setAttribute('authSubscriptionsEnabled', true) - ->setAttribute('authStripeSecretKey', $stripeSecretKey) - ->setAttribute('authStripePublishableKey', $config['publishableKey']) - ->setAttribute('authStripeWebhookSecret', $config['webhookSecret']) - ->setAttribute('authStripeWebhookEndpointId', $config['webhookEndpointId']) - ->setAttribute('authStripeCurrency', $config['currency'])); - - // Ensure a default free plan exists - $existingDefault = $dbForPlatform->findOne('auth_plans', [ - \Utopia\Database\Query::equal('projectId', [$projectId]), - \Utopia\Database\Query::equal('isDefault', [true]) - ]); - if (!($existingDefault instanceof \Utopia\Database\Document) || $existingDefault->isEmpty()) { - $dbForPlatform->createDocument('auth_plans', new \Utopia\Database\Document([ - '$id' => \Utopia\Database\Helpers\ID::unique(), - 'projectInternalId' => $project->getSequence(), - 'projectId' => $projectId, - 'planId' => 'free', - 'name' => 'Free', - 'description' => 'Default free plan', - 'stripeProductId' => null, - 'stripePriceId' => null, - 'price' => 0, - 'currency' => $config['currency'], - 'interval' => null, - 'features' => [], - 'isDefault' => true, - 'isFree' => true, - 'maxUsers' => null, - 'active' => true, - 'search' => 'free Default free plan' - ])); - } - - $response->dynamic($project, Response::MODEL_PROJECT); - } catch (SubscriptionException $e) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, $e->getMessage()); - } - }); - -App::get('/v1/projects/:projectId/auth/subscriptions') - ->desc('Get auth subscription configuration') - ->groups(['api']) - ->label('scope', 'projects.read') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'getSubscriptionsConfig', - description: '/docs/references/projects/get-subscriptions-config.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', new UID(), 'Project unique ID.') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, Response $response, Database $dbForPlatform) { - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $response->dynamic(new Document([ - 'enabled' => $project->getAttribute('authSubscriptionsEnabled', false), - 'publishableKey' => $project->getAttribute('authStripePublishableKey'), - 'currency' => $project->getAttribute('authStripeCurrency'), - ]), Response::MODEL_ANY); - }); - -App::delete('/v1/projects/:projectId/auth/subscriptions') - ->desc('Remove auth subscription configuration') - ->groups(['api']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'removeSubscriptionsConfig', - description: '/docs/references/projects/remove-subscriptions-config.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', new UID(), 'Project unique ID.') - ->inject('response') - ->inject('dbForPlatform') - ->inject('dbForProject') - ->action(function (string $projectId, Response $response, Database $dbForPlatform, Database $dbForProject) { - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - if ($project->getAttribute('authSubscriptionsEnabled')) { - try { - $stripe = new \Appwrite\Auth\Subscription\StripeService( - $project->getAttribute('authStripeSecretKey'), - $dbForProject, - $project, - $dbForPlatform - ); - $endpointId = $project->getAttribute('authStripeWebhookEndpointId'); - if (!empty($endpointId)) { - $stripe->deleteWebhookEndpoint($endpointId); - } - $plans = $dbForPlatform->find('auth_plans', [ - \Utopia\Database\Query::equal('projectId', [$projectId]) - ]); - foreach ($plans as $plan) { - $priceId = $plan->getAttribute('stripePriceId'); - $productId = $plan->getAttribute('stripeProductId'); - try { - if ($priceId) { - $stripe->deactivatePrice($priceId); - } - } catch (\Throwable $_) { - } - try { - if ($productId) { - $stripe->deactivateProduct($productId); - } - } catch (\Throwable $_) { - } - $dbForPlatform->deleteDocument('auth_plans', $plan->getId()); - } - } catch (\Throwable $_) { - } - } - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project - ->setAttribute('authSubscriptionsEnabled', false) - ->setAttribute('authStripeSecretKey', null) - ->setAttribute('authStripePublishableKey', null) - ->setAttribute('authStripeWebhookSecret', null) - ->setAttribute('authStripeWebhookEndpointId', null) - ->setAttribute('authStripeCurrency', null)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); + }); \ No newline at end of file diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index eb56842f91..abfc4fdf8c 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -16,7 +16,7 @@ use Appwrite\Event\Delete; use Appwrite\Event\Event; use Appwrite\Extend\Exception; use Appwrite\Hooks\Hooks; -use Appwrite\Network\Validator\Email; +use Appwrite\Network\Validator\Email as EmailValidator; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Deprecated; @@ -49,12 +49,14 @@ use Utopia\Database\Validator\Query\Cursor; use Utopia\Database\Validator\Query\Limit; use Utopia\Database\Validator\Query\Offset; use Utopia\Database\Validator\UID; +use Utopia\Emails\Email; use Utopia\Locale\Locale; use Utopia\System\System; use Utopia\Validator\ArrayList; use Utopia\Validator\Assoc; use Utopia\Validator\Boolean; use Utopia\Validator\Integer; +use Utopia\Validator\Nullable; use Utopia\Validator\Range; use Utopia\Validator\Text; use Utopia\Validator\WhiteList; @@ -97,6 +99,12 @@ function createUser(string $hash, mixed $hashOptions, string $userId, ?string $e } } + try { + $emailCanonical = new Email($email); + } catch (Throwable) { + $emailCanonical = null; + } + $password = (!empty($password)) ? ($hash === 'plaintext' ? Auth::passwordHash($password, $hash, $hashOptionsObject) : $password) : null; $user = new Document([ '$id' => $userId, @@ -124,6 +132,11 @@ function createUser(string $hash, mixed $hashOptions, string $userId, ?string $e 'tokens' => null, 'memberships' => null, 'search' => implode(' ', [$userId, $email, $phone, $name]), + 'emailCanonical' => $emailCanonical?->getCanonical(), + 'emailIsCanonical' => $emailCanonical?->isCanonicalSupported(), + 'emailIsCorporate' => $emailCanonical?->isCorporate(), + 'emailIsDisposable' => $emailCanonical?->isDisposable(), + 'emailIsFree' => $emailCanonical?->isFree(), ]); if ($hash === 'plaintext') { @@ -208,8 +221,8 @@ App::post('/v1/users') ] )) ->param('userId', '', new CustomId(), 'User ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.') - ->param('email', null, new Email(), 'User email.', true) - ->param('phone', null, new Phone(), 'Phone number. Format this number with a leading \'+\' and a country code, e.g., +16175551212.', true) + ->param('email', null, new Nullable(new EmailValidator()), 'User email.', true) + ->param('phone', null, new Nullable(new Phone()), 'Phone number. Format this number with a leading \'+\' and a country code, e.g., +16175551212.', true) ->param('password', '', fn ($project, $passwordsDictionary) => new PasswordDictionary($passwordsDictionary, $project->getAttribute('auths', [])['passwordDictionary'] ?? false), 'Plain text user password. Must be at least 8 chars.', true, ['project', 'passwordsDictionary']) ->param('name', '', new Text(128), 'User name. Max length: 128 chars.', true) ->inject('response') @@ -243,7 +256,7 @@ App::post('/v1/users/bcrypt') ] )) ->param('userId', '', new CustomId(), 'User ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.') - ->param('email', '', new Email(), 'User email.') + ->param('email', '', new EmailValidator(), 'User email.') ->param('password', '', new Password(), 'User password hashed using Bcrypt.') ->param('name', '', new Text(128), 'User name. Max length: 128 chars.', true) ->inject('response') @@ -278,7 +291,7 @@ App::post('/v1/users/md5') ] )) ->param('userId', '', new CustomId(), 'User ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.') - ->param('email', '', new Email(), 'User email.') + ->param('email', '', new EmailValidator(), 'User email.') ->param('password', '', new Password(), 'User password hashed using MD5.') ->param('name', '', new Text(128), 'User name. Max length: 128 chars.', true) ->inject('response') @@ -313,7 +326,7 @@ App::post('/v1/users/argon2') ] )) ->param('userId', '', new CustomId(), 'User ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.') - ->param('email', '', new Email(), 'User email.') + ->param('email', '', new EmailValidator(), 'User email.') ->param('password', '', new Password(), 'User password hashed using Argon2.') ->param('name', '', new Text(128), 'User name. Max length: 128 chars.', true) ->inject('response') @@ -348,7 +361,7 @@ App::post('/v1/users/sha') ] )) ->param('userId', '', new CustomId(), 'User ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.') - ->param('email', '', new Email(), 'User email.') + ->param('email', '', new EmailValidator(), 'User email.') ->param('password', '', new Password(), 'User password hashed using SHA.') ->param('passwordVersion', '', new WhiteList(['sha1', 'sha224', 'sha256', 'sha384', 'sha512/224', 'sha512/256', 'sha512', 'sha3-224', 'sha3-256', 'sha3-384', 'sha3-512']), "Optional SHA version used to hash password. Allowed values are: 'sha1', 'sha224', 'sha256', 'sha384', 'sha512/224', 'sha512/256', 'sha512', 'sha3-224', 'sha3-256', 'sha3-384', 'sha3-512'", true) ->param('name', '', new Text(128), 'User name. Max length: 128 chars.', true) @@ -390,7 +403,7 @@ App::post('/v1/users/phpass') ] )) ->param('userId', '', new CustomId(), 'User ID. Choose a custom ID or pass the string `ID.unique()`to auto generate it. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.') - ->param('email', '', new Email(), 'User email.') + ->param('email', '', new EmailValidator(), 'User email.') ->param('password', '', new Password(), 'User password hashed using PHPass.') ->param('name', '', new Text(128), 'User name. Max length: 128 chars.', true) ->inject('response') @@ -425,7 +438,7 @@ App::post('/v1/users/scrypt') ] )) ->param('userId', '', new CustomId(), 'User ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.') - ->param('email', '', new Email(), 'User email.') + ->param('email', '', new EmailValidator(), 'User email.') ->param('password', '', new Password(), 'User password hashed using Scrypt.') ->param('passwordSalt', '', new Text(128), 'Optional salt used to hash password.') ->param('passwordCpu', 8, new Integer(), 'Optional CPU cost used to hash password.') @@ -473,7 +486,7 @@ App::post('/v1/users/scrypt-modified') ] )) ->param('userId', '', new CustomId(), 'User ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.') - ->param('email', '', new Email(), 'User email.') + ->param('email', '', new EmailValidator(), 'User email.') ->param('password', '', new Password(), 'User password hashed using Scrypt Modified.') ->param('passwordSalt', '', new Text(128), 'Salt used to hash password.') ->param('passwordSaltSeparator', '', new Text(128), 'Salt separator used to hash password.') @@ -527,7 +540,7 @@ App::post('/v1/users/:userId/targets') switch ($providerType) { case 'email': - $validator = new Email(); + $validator = new EmailValidator(); if (!$validator->isValid($identifier)) { throw new Exception(Exception::GENERAL_INVALID_EMAIL); } @@ -605,11 +618,10 @@ App::get('/v1/users') )) ->param('queries', [], new Users(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Users::ALLOWED_ATTRIBUTES), true) ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) ->inject('response') ->inject('dbForProject') - ->inject('dbForPlatform') - ->inject('project') - ->action(function (array $queries, string $search, Response $response, Database $dbForProject, Database $dbForPlatform, Document $project) { + ->action(function (array $queries, string $search, bool $includeTotal, Response $response, Database $dbForProject) { try { $queries = Query::parseQueries($queries); @@ -649,10 +661,10 @@ App::get('/v1/users') $users = []; $total = 0; - $dbForProject->skipFilters(function () use ($dbForProject, $queries, &$users, &$total) { + $dbForProject->skipFilters(function () use ($dbForProject, $queries, $includeTotal, &$users, &$total) { try { $users = $dbForProject->find('users', $queries); - $total = $dbForProject->count('users', $queries, APP_LIMIT_COUNT); + $total = $includeTotal ? $dbForProject->count('users', $queries, APP_LIMIT_COUNT) : 0; } catch (OrderException $e) { throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null."); } catch (QueryException $e) { @@ -660,49 +672,6 @@ App::get('/v1/users') } }, ['subQueryAuthenticators', 'subQuerySessions', 'subQueryTokens', 'subQueryChallenges', 'subQueryMemberships']); - $planIds = []; - foreach ($users as $u) { - $pid = (string) $u->getAttribute('planId', ''); - if ($pid !== '') { - $planIds[$pid] = true; - } - } - $planMap = []; - if (!empty($planIds)) { - $plans = $dbForPlatform->find('auth_plans', [ - Query::equal('projectId', [$project->getId()]), - Query::equal('planId', array_keys($planIds)), - Query::equal('active', [true]) - ]); - foreach ($plans as $p) { - $planMap[(string) $p->getAttribute('planId')] = $p; - } - } - $defaultPlan = $dbForPlatform->findOne('auth_plans', [ - Query::equal('projectId', [$project->getId()]), - Query::equal('isDefault', [true]), - Query::equal('active', [true]) - ]); - foreach ($users as $u) { - $pid = (string) $u->getAttribute('planId', ''); - $p = null; - if ($pid !== '' && isset($planMap[$pid])) { - $p = $planMap[$pid]; - } elseif ($defaultPlan) { - $p = $defaultPlan; - } - if ($p) { - $u->setAttribute('plan', [ - 'id' => $p->getAttribute('planId'), - 'name' => $p->getAttribute('name'), - 'price' => $p->getAttribute('price'), - 'currency' => $p->getAttribute('currency'), - 'interval' => $p->getAttribute('interval'), - 'isFree' => $p->getAttribute('isFree', false) - ]); - } - } - $response->dynamic(new Document([ 'users' => $users, 'total' => $total, @@ -729,9 +698,7 @@ App::get('/v1/users/:userId') ->param('userId', '', new UID(), 'User ID.') ->inject('response') ->inject('dbForProject') - ->inject('dbForPlatform') - ->inject('project') - ->action(function (string $userId, Response $response, Database $dbForProject, Database $dbForPlatform, Document $project) { + ->action(function (string $userId, Response $response, Database $dbForProject) { $user = $dbForProject->getDocument('users', $userId); @@ -739,177 +706,6 @@ App::get('/v1/users/:userId') throw new Exception(Exception::USER_NOT_FOUND); } - $pid = (string) $user->getAttribute('planId', ''); - $plan = null; - if ($pid !== '') { - $candidate = $dbForPlatform->findOne('auth_plans', [ - Query::equal('projectId', [$project->getId()]), - Query::equal('planId', [$pid]), - Query::equal('active', [true]) - ]); - $plan = ($candidate instanceof \Utopia\Database\Document && !$candidate->isEmpty()) ? $candidate : null; - } else { - $candidate = $dbForPlatform->findOne('auth_plans', [ - Query::equal('projectId', [$project->getId()]), - Query::equal('isDefault', [true]), - Query::equal('active', [true]) - ]); - $plan = ($candidate instanceof \Utopia\Database\Document && !$candidate->isEmpty()) ? $candidate : null; - } - if ($plan instanceof \Utopia\Database\Document && !$plan->isEmpty()) { - $features = []; - $resolvedPlanId = (string) $plan->getAttribute('planId'); - $assigned = []; - if ($resolvedPlanId !== '') { - $assigned = Authorization::skip(fn () => $dbForPlatform->find('auth_plan_features', [ - Query::equal('projectId', [$project->getId()]), - Query::equal('planId', [$resolvedPlanId]), - Query::equal('active', [true]) - ])); - } - $stripeService = new \Appwrite\Auth\Subscription\StripeService( - $project->getAttribute('authStripeSecretKey'), - $dbForProject, - $project, - $dbForPlatform - ); - $customerId = (string) $user->getAttribute('stripeCustomerId', ''); - $periodStart = $user->getAttribute('subscriptionCurrentPeriodStart'); - $periodEnd = $user->getAttribute('subscriptionCurrentPeriodEnd'); - $startTs = $periodStart ? strtotime($periodStart) : 0; - $endTs = $periodEnd ? strtotime($periodEnd) : time(); - foreach ($assigned as $af) { - $featureId = (string) $af->getAttribute('featureId'); - $usage = 0; - if ((string)$af->getAttribute('type') === 'metered') { - try { - $usage = $stripeService->getFeatureUsageTotalLocal($user->getId(), $resolvedPlanId, $featureId, $startTs, $endTs); - } catch (\Throwable $_) { - } - } - $features[] = [ - 'featureId' => (string) $af->getAttribute('featureId'), - 'type' => (string) $af->getAttribute('type'), - 'enabled' => (bool) $af->getAttribute('enabled', true), - 'currency' => $af->getAttribute('currency'), - 'interval' => $af->getAttribute('interval'), - 'includedUnits' => (int) $af->getAttribute('includedUnits', 0), - 'tiersMode' => $af->getAttribute('tiersMode'), - 'tiers' => (array) $af->getAttribute('tiers', []), - 'usage' => $usage, - 'usageCap' => $af->getAttribute('usageCap'), - ]; - } - $user->setAttribute('plan', [ - 'id' => $plan->getAttribute('planId'), - 'name' => $plan->getAttribute('name'), - 'price' => $plan->getAttribute('price'), - 'currency' => $plan->getAttribute('currency'), - 'interval' => $plan->getAttribute('interval'), - 'isFree' => $plan->getAttribute('isFree', false), - 'features' => $features - ]); - } - - $response->dynamic($user, Response::MODEL_USER); - }); - -App::post('/v1/users/:userId/plan') - ->desc('Assign plan to user (admin)') - ->groups(['api', 'users']) - ->label('scope', 'users.write') - ->label('sdk', new Method( - namespace: 'users', - group: 'users', - name: 'assignPlan', - description: '/docs/references/users/assign-plan.md', - auth: [AuthType::KEY], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_USER, - ) - ] - )) - ->param('userId', '', new UID(), 'User ID.') - ->param('planId', '', new Text(128), 'Plan ID to assign.') - ->param('complimentary', true, new Boolean(), 'If true, create a complimentary Stripe subscription for one billing interval.', true) - ->inject('response') - ->inject('project') - ->inject('dbForProject') - ->inject('dbForPlatform') - ->action(function (string $userId, string $planId, bool $complimentary, Response $response, Document $project, Database $dbForProject, Database $dbForPlatform) { - $user = $dbForProject->getDocument('users', $userId); - if ($user->isEmpty()) { - throw new Exception(Exception::USER_NOT_FOUND); - } - - $plan = $dbForPlatform->findOne('auth_plans', [ - Query::equal('projectId', [$project->getId()]), - Query::equal('planId', [$planId]), - Query::equal('active', [true]) - ]); - if (!$plan) { - throw new Exception(Exception::GENERAL_NOT_FOUND, 'Plan not found'); - } - - $user->setAttribute('planId', $planId); - $now = time(); - $interval = (string) $plan->getAttribute('interval', 'month'); - $seconds = match ($interval) { - 'day' => 86400, - 'week' => 86400 * 7, - 'year' => 86400 * 365, - default => 86400 * 30, - }; - $user->setAttribute('subscriptionStatus', 'active'); - $user->setAttribute('subscriptionCurrentPeriodStart', DateTime::format(new \DateTime('@' . $now))); - $user->setAttribute('subscriptionCurrentPeriodEnd', DateTime::format(new \DateTime('@' . ($now + $seconds)))); - $user->setAttribute('subscriptionCancelAtPeriodEnd', true); - $user->setAttribute('subscriptionTrialEnd', DateTime::format(new \DateTime('@' . ($now + $seconds)))); - - if ($plan->getAttribute('isFree', false) && $project->getAttribute('authSubscriptionsEnabled')) { - $stripe = new \Appwrite\Auth\Subscription\StripeService( - $project->getAttribute('authStripeSecretKey'), - $dbForProject, - $project - ); - $subscriptionId = (string) $user->getAttribute('stripeSubscriptionId', ''); - if ($subscriptionId === '') { - $customerId = (string) $user->getAttribute('stripeCustomerId', ''); - if ($customerId !== '') { - try { - $found = $stripe->findActiveSubscriptionId($customerId); - if (!empty($found)) { - $subscriptionId = (string) $found; - } - } catch (\Throwable $_) { - } - } - } - if ($subscriptionId !== '') { - try { - $stripe->cancelSubscription($subscriptionId, false); - } catch (\Throwable $_) { - } - $user->setAttribute('stripeSubscriptionId', null); - } - } elseif ($complimentary && !$plan->getAttribute('isFree', false) && $project->getAttribute('authSubscriptionsEnabled')) { - $stripe = new \Appwrite\Auth\Subscription\StripeService( - $project->getAttribute('authStripeSecretKey'), - $dbForProject, - $project - ); - $customerId = (string) $user->getAttribute('stripeCustomerId', ''); - if ($customerId === '') { - $customerId = $stripe->ensureCustomer($user); - $user->setAttribute('stripeCustomerId', $customerId); - } - $stripe->createComplimentarySubscription($customerId, (string) $plan->getAttribute('stripePriceId'), $interval, $user->getId()); - } - - $user = $dbForProject->updateDocument('users', $userId, $user); - $response->dynamic($user, Response::MODEL_USER); }); @@ -1002,10 +798,11 @@ App::get('/v1/users/:userId/sessions') ] )) ->param('userId', '', new UID(), 'User ID.') + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) ->inject('response') ->inject('dbForProject') ->inject('locale') - ->action(function (string $userId, Response $response, Database $dbForProject, Locale $locale) { + ->action(function (string $userId, bool $includeTotal, Response $response, Database $dbForProject, Locale $locale) { $user = $dbForProject->getDocument('users', $userId); @@ -1027,7 +824,7 @@ App::get('/v1/users/:userId/sessions') $response->dynamic(new Document([ 'sessions' => $sessions, - 'total' => count($sessions), + 'total' => $includeTotal ? count($sessions) : 0, ]), Response::MODEL_SESSION_LIST); }); @@ -1051,9 +848,10 @@ App::get('/v1/users/:userId/memberships') ->param('userId', '', new UID(), 'User ID.') ->param('queries', [], new Memberships(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Memberships::ALLOWED_ATTRIBUTES), true) ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) ->inject('response') ->inject('dbForProject') - ->action(function (string $userId, array $queries, string $search, Response $response, Database $dbForProject) { + ->action(function (string $userId, array $queries, string $search, bool $includeTotal, Response $response, Database $dbForProject) { $user = $dbForProject->getDocument('users', $userId); @@ -1087,7 +885,7 @@ App::get('/v1/users/:userId/memberships') $response->dynamic(new Document([ 'memberships' => $memberships, - 'total' => count($memberships), + 'total' => $includeTotal ? count($memberships) : 0, ]), Response::MODEL_MEMBERSHIP_LIST); }); @@ -1110,11 +908,12 @@ App::get('/v1/users/:userId/logs') )) ->param('userId', '', new UID(), 'User ID.') ->param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) ->inject('response') ->inject('dbForProject') ->inject('locale') ->inject('geodb') - ->action(function (string $userId, array $queries, Response $response, Database $dbForProject, Locale $locale, Reader $geodb) { + ->action(function (string $userId, array $queries, bool $includeTotal, Response $response, Database $dbForProject, Locale $locale, Reader $geodb) { $user = $dbForProject->getDocument('users', $userId); @@ -1183,7 +982,7 @@ App::get('/v1/users/:userId/logs') } $response->dynamic(new Document([ - 'total' => $audit->countLogsByUser($user->getSequence(), $queries), + 'total' => $includeTotal ? $audit->countLogsByUser($user->getSequence(), $queries) : 0, 'logs' => $output, ]), Response::MODEL_LOG_LIST); }); @@ -1207,9 +1006,10 @@ App::get('/v1/users/:userId/targets') )) ->param('userId', '', new UID(), 'User ID.') ->param('queries', [], new Targets(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Targets::ALLOWED_ATTRIBUTES), true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) ->inject('response') ->inject('dbForProject') - ->action(function (string $userId, array $queries, Response $response, Database $dbForProject) { + ->action(function (string $userId, array $queries, bool $includeTotal, Response $response, Database $dbForProject) { $user = $dbForProject->getDocument('users', $userId); if ($user->isEmpty()) { @@ -1249,7 +1049,7 @@ App::get('/v1/users/:userId/targets') } try { $targets = $dbForProject->find('targets', $queries); - $total = $dbForProject->count('targets', $queries, APP_LIMIT_COUNT); + $total = $includeTotal ? $dbForProject->count('targets', $queries, APP_LIMIT_COUNT) : 0; } catch (OrderException $e) { throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null."); } @@ -1278,9 +1078,10 @@ App::get('/v1/users/identities') )) ->param('queries', [], new Identities(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Identities::ALLOWED_ATTRIBUTES), true) ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) ->inject('response') ->inject('dbForProject') - ->action(function (array $queries, string $search, Response $response, Database $dbForProject) { + ->action(function (array $queries, string $search, bool $includeTotal, Response $response, Database $dbForProject) { try { $queries = Query::parseQueries($queries); @@ -1317,10 +1118,9 @@ App::get('/v1/users/identities') $cursor->setValue($cursorDocument); } - $filterQueries = Query::groupByType($queries)['filters']; try { $identities = $dbForProject->find('identities', $queries); - $total = $dbForProject->count('identities', $filterQueries, APP_LIMIT_COUNT); + $total = $includeTotal ? $dbForProject->count('identities', $queries, APP_LIMIT_COUNT) : 0; } catch (OrderException $e) { throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null."); } @@ -1615,7 +1415,7 @@ App::patch('/v1/users/:userId/email') ] )) ->param('userId', '', new UID(), 'User ID.') - ->param('email', '', new Email(allowEmpty: true), 'User email.') + ->param('email', '', new EmailValidator(allowEmpty: true), 'User email.') ->inject('response') ->inject('dbForProject') ->inject('queueForEvents') @@ -1650,9 +1450,20 @@ App::patch('/v1/users/:userId/email') $oldEmail = $user->getAttribute('email'); + try { + $emailCanonical = new Email($email); + } catch (Throwable) { + $emailCanonical = null; + } + $user ->setAttribute('email', $email) ->setAttribute('emailVerification', false) + ->setAttribute('emailCanonical', $emailCanonical?->getCanonical()) + ->setAttribute('emailIsCanonical', $emailCanonical?->isCanonicalSupported()) + ->setAttribute('emailIsCorporate', $emailCanonical?->isCorporate()) + ->setAttribute('emailIsDisposable', $emailCanonical?->isDisposable()) + ->setAttribute('emailIsFree', $emailCanonical?->isFree()) ; try { @@ -1913,7 +1724,7 @@ App::patch('/v1/users/:userId/targets/:targetId') switch ($providerType) { case 'email': - $validator = new Email(); + $validator = new EmailValidator(); if (!$validator->isValid($identifier)) { throw new Exception(Exception::GENERAL_INVALID_EMAIL); } @@ -2898,4 +2709,4 @@ App::get('/v1/users/usage') 'users' => $usage[$metrics[0]]['data'], 'sessions' => $usage[$metrics[1]]['data'], ]), Response::MODEL_USAGE_USERS); - }); + }); \ No newline at end of file diff --git a/app/controllers/general.php b/app/controllers/general.php index c9a38e8f9b..574c334e0c 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -41,7 +41,6 @@ use Utopia\Database\Document; use Utopia\Database\Helpers\ID; use Utopia\Database\Query; use Utopia\Database\Validator\Authorization; -use Utopia\Database\Validator\UID; use Utopia\Domains\Domain; use Utopia\DSN\DSN; use Utopia\Locale\Locale; @@ -192,7 +191,7 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw $resourceId = $rule->getAttribute('deploymentResourceId', ''); $type = ($resourceType === 'site') ? 'sites' : 'functions'; $exception = new AppwriteException(AppwriteException::DEPLOYMENT_NOT_FOUND, view: $errorView); - $exception->addCTA('View deployments', $url . '/console/project-' . $project->getAttribute('region', 'default') . '-' . $project->getId() . '/' . $type . '/' . $resourceType . '-' . $resourceId); + $exception->addCTA('View deployments', $url . '/console/project-' . $project->getAttribute('region', 'default') . '-' . $projectId . '/' . $type . '/' . $resourceType . '-' . $resourceId); throw $exception; } @@ -1640,61 +1639,4 @@ if (!empty(Method::getErrors())) { // Modules $platform = new Appwrite(); -$platform->init(Service::TYPE_HTTP); - -App::post('/v1/webhooks/stripe/auth/:projectId') - ->desc('Handle Stripe webhook for auth subscriptions') - ->groups(['webhooks']) - ->label('scope', 'public') - ->inject('request') - ->inject('response') - ->inject('dbForPlatform') - ->inject('getProjectDB') - ->param('projectId', '', new UID(), 'Project unique ID.') - ->action(function (string $projectId, $request, $response, $dbForPlatform, callable $getProjectDB) { - $payload = $request->getRawPayload(); - $signature = $request->getHeader('stripe-signature') ?: $request->getHeader('Stripe-Signature') ?: ($_SERVER['HTTP_STRIPE_SIGNATURE'] ?? ''); - - if (empty($signature)) { - throw new AppwriteException(AppwriteException::GENERAL_BAD_REQUEST, 'Missing Stripe signature'); - } - - $event = json_decode($payload, true); - - $project = Authorization::skip(fn () => $dbForPlatform->getDocument('projects', $projectId)); - if ($project->isEmpty()) { - throw new AppwriteException(AppwriteException::PROJECT_NOT_FOUND); - } - $dbForProject = $getProjectDB($project); - - $webhookSecret = $project->getAttribute('authStripeWebhookSecret'); - if (empty($webhookSecret)) { - throw new AppwriteException(AppwriteException::GENERAL_BAD_REQUEST, 'Webhook not configured'); - } - - $tmpService = new \Appwrite\Auth\Subscription\StripeService( - $project->getAttribute('authStripeSecretKey'), - $dbForProject, - $project - ); - if (!$tmpService->verifyWebhookSignature($payload, $signature, $webhookSecret)) { - throw new AppwriteException(AppwriteException::GENERAL_ACCESS_FORBIDDEN, 'Invalid webhook signature'); - } - - $platformDb = $dbForPlatform; - $stripeService = new \Appwrite\Auth\Subscription\StripeService( - $project->getAttribute('authStripeSecretKey'), - $dbForProject, - $project, - $platformDb - ); - - - - try { - $stripeService->handleWebhook($event ?? []); - $response->json(['success' => true]); - } catch (\Appwrite\Auth\Subscription\Exception\SubscriptionException $e) { - throw new AppwriteException(AppwriteException::GENERAL_SERVER_ERROR, $e->getMessage()); - } - }); +$platform->init(Service::TYPE_HTTP); \ No newline at end of file diff --git a/src/Appwrite/Auth/Subscription/Exception/SubscriptionException.php b/src/Appwrite/Auth/Subscription/Exception/SubscriptionException.php deleted file mode 100644 index 3287df0195..0000000000 --- a/src/Appwrite/Auth/Subscription/Exception/SubscriptionException.php +++ /dev/null @@ -1,13 +0,0 @@ -apiKey = $apiKey; - $this->database = $database; - $this->project = $project; - $this->platformDb = $platformDb; - } - - /** - * Initialize Stripe account for the project - * @throws SubscriptionException - */ - public function initializeAccount(): array - { - $account = $this->getAccount(); - - $currency = $account['default_currency'] ?? 'usd'; - - $webhookEndpoint = $this->createWebhookEndpoint(); - - return [ - 'currency' => $currency, - 'webhookEndpointId' => $webhookEndpoint['id'], - 'webhookSecret' => $webhookEndpoint['secret'], - 'publishableKey' => $this->extractPublishableKey() - ]; - } - - /** - * Get Stripe account details - * @throws SubscriptionException - */ - private function getAccount(): array - { - $response = $this->makeRequest('GET', '/account'); - - if (isset($response['error'])) { - throw new SubscriptionException($response['error']['message'] ?? 'Failed to get Stripe account'); - } - - return $response; - } - - /** - * Extract publishable key from secret key - */ - private function extractPublishableKey(): string - { - if (strpos($this->apiKey, 'sk_test_') === 0) { - return 'pk_test_' . substr($this->apiKey, 8); - } elseif (strpos($this->apiKey, 'sk_live_') === 0) { - return 'pk_live_' . substr($this->apiKey, 8); - } - - throw new SubscriptionException('Invalid Stripe secret key format'); - } - - /** - * Create webhook endpoint - * @throws SubscriptionException - */ - private function createWebhookEndpoint(): array - { - $base = getenv('_APP_AUTH_STRIPE_WEBHOOK_URL'); - if (!$base) { - $host = $_SERVER['HTTP_HOST'] ?? ''; - $base = 'https://' . $host; - } - if (strpos($base, '{projectId}') !== false || strpos($base, ':projectId') !== false) { - $webhookUrl = str_replace(['{projectId}', ':projectId'], $this->project->getId(), $base); - } else { - $normalized = rtrim($base, '/'); - if (str_ends_with($normalized, '/v1/webhooks/stripe/auth')) { - $webhookUrl = $normalized . '/' . $this->project->getId(); - } else { - $webhookUrl = $normalized . '/v1/webhooks/stripe/auth/' . $this->project->getId(); - } - } - - $params = [ - 'url' => $webhookUrl, - 'enabled_events' => [ - 'checkout.session.completed', - 'customer.subscription.created', - 'customer.subscription.updated', - 'customer.subscription.deleted', - 'invoice.payment_failed', - 'invoice.payment_succeeded', - 'product.updated', - 'product.deleted', - 'price.updated', - 'price.deleted' - ], - 'description' => 'Appwrite Auth Subscription Webhook for Project ' . $this->project->getId() - ]; - - $response = $this->makeRequest('POST', '/webhook_endpoints', $params); - - if (isset($response['error'])) { - throw new SubscriptionException($response['error']['message'] ?? 'Failed to create webhook endpoint'); - } - - return $response; - } - - public function deleteWebhookEndpoint(string $endpointId): array - { - return $this->makeRequest('DELETE', '/webhook_endpoints/' . $endpointId); - } - - /** - * Create Stripe product - * @throws SubscriptionException - */ - public function createProduct(string $name, string $description = '', ?string $planId = null): array - { - $params = [ - 'name' => $name, - 'description' => $description, - 'metadata' => [ - 'project_id' => $this->project->getId(), - 'plan_id' => $planId, - 'type' => 'auth_plan' - ] - ]; - - $response = $this->makeRequest('POST', '/products', $params); - - if (isset($response['error'])) { - throw new SubscriptionException($response['error']['message'] ?? 'Failed to create product'); - } - - return $response; - } - - /** - * Create Stripe price - * @throws SubscriptionException - */ - public function createPrice(string $productId, int $amount, string $currency, ?string $interval = null, ?string $planId = null): array - { - $params = [ - 'product' => $productId, - 'unit_amount' => $amount, - 'currency' => $currency, - 'metadata' => [ - 'project_id' => $this->project->getId(), - 'plan_id' => $planId, - 'type' => 'auth_plan_price' - ] - ]; - - if ($interval) { - $params['recurring'] = ['interval' => $interval]; - } - - $response = $this->makeRequest('POST', '/prices', $params); - - if (isset($response['error'])) { - throw new SubscriptionException($response['error']['message'] ?? 'Failed to create price'); - } - - return $response; - } - - public function createMeteredTieredPrice( - string $productId, - string $currency, - ?string $interval, - int $includedUnits, - string $tiersMode, - array $tiers, - ?string $planId, - ?string $featureId, - ?string $featureName - ): array { - $meterId = $this->ensureMeterForFeature($planId, $featureId, $featureName); - $stripeTiers = []; - - $remainingIncluded = max(0, $includedUnits); - if ($remainingIncluded > 0) { - $stripeTiers[] = [ - 'up_to' => $remainingIncluded, - 'unit_amount' => 0, - ]; - } - - foreach ($tiers as $tier) { - $to = $tier['to'] ?? null; // integer or 'inf' - $unitAmountMajor = (int) ($tier['unitAmount'] ?? 0); - $unitAmount = $this->toMinorUnits($unitAmountMajor, $currency); - $flatAmount = isset($tier['flatAmount']) ? (int) $tier['flatAmount'] : null; - - $entry = [ - 'up_to' => $to === null ? 'inf' : $to, - ]; - if ($unitAmount > 0) { - $entry['unit_amount'] = $unitAmount; - } else { - $entry['unit_amount'] = 0; - } - if ($flatAmount !== null) { - $entry['flat_amount'] = $this->toMinorUnits($flatAmount, $currency); - } - $stripeTiers[] = $entry; - } - - $params = [ - 'product' => $productId, - 'currency' => $currency, - 'billing_scheme' => 'tiered', - 'tiers_mode' => $tiersMode === 'volume' ? 'volume' : 'graduated', - 'tiers' => $stripeTiers, - 'recurring' => array_filter([ - 'interval' => $interval, - 'usage_type' => 'metered', - 'meter' => $meterId, - ]), - 'nickname' => $featureName ? ('Feature: ' . $featureName) : null, - 'metadata' => array_filter([ - 'project_id' => $this->project->getId(), - 'plan_id' => $planId, - 'feature_id' => $featureId, - 'type' => 'auth_plan_feature_price' - ]) - ]; - - $response = $this->makeRequest('POST', '/prices', $params); - - if (isset($response['error'])) { - throw new SubscriptionException($response['error']['message'] ?? 'Failed to create metered price'); - } - - return $response; - } - - private function toMinorUnits(int $major, string $currency): int - { - $currency = strtolower($currency); - $zeroDecimal = [ - 'bif','clp','djf','gnf','jpy','kmf','krw','mga','pyg','rwf','ugx','vnd','vuv','xaf','xof','xpf' - ]; - if (in_array($currency, $zeroDecimal, true)) { - return $major; - } - return $major * 100; - } - - public function ensureMeterForFeature(?string $planId, ?string $featureId, ?string $featureName): string - { - if ($this->platformDb === null || !$planId || !$featureId) { - return ''; - } - $existing = $this->platformDb->findOne('auth_plan_features', [ - Query::equal('projectId', [$this->project->getId()]), - Query::equal('planId', [$planId]), - Query::equal('featureId', [$featureId]) - ]); - if ($existing && (string)$existing->getAttribute('stripeMeterId', '') !== '') { - return (string)$existing->getAttribute('stripeMeterId'); - } - $eventName = 'appwrite.auth.feature.usage.' . $this->project->getId() . '.' . $planId . '.' . $featureId; - - $list = $this->makeRequest('GET', '/billing/meters', [ 'limit' => 100 ]); - if (isset($list['data']) && is_array($list['data'])) { - foreach ($list['data'] as $m) { - if (($m['event_name'] ?? '') === $eventName) { - $id = (string) ($m['id'] ?? ''); - if ($id !== '' && ($m['active'] ?? true) === false) { - $this->makeRequest('POST', '/billing/meters/' . $id, ['active' => 'true']); - } - return $id; - } - } - } - - $meter = $this->makeRequest('POST', '/billing/meters', [ - 'display_name' => ($featureName ? ($featureName . ' usage') : 'Auth feature usage'), - 'event_name' => $eventName, - 'default_aggregation' => [ 'formula' => 'sum' ], - 'value_settings' => [ 'event_payload_key' => 'value' ], - 'customer_mapping' => [ 'type' => 'by_id', 'event_payload_key' => 'stripe_customer_id' ] - ]); - - if (isset($meter['error'])) { - throw new SubscriptionException($meter['error']['message'] ?? 'Failed to create meter'); - } - - return (string)($meter['id'] ?? ''); - } - - /** - * Deactivate a Stripe price - * @throws SubscriptionException - */ - public function deactivatePrice(string $priceId): array - { - return $this->makeRequest('POST', '/prices/' . $priceId, ['active' => 'false']); - } - - /** - * Get a Stripe price - * @throws SubscriptionException - */ - public function getPrice(string $priceId): array - { - return $this->makeRequest('GET', '/prices/' . $priceId); - } - - /** - * Deactivate a Stripe product - * @throws SubscriptionException - */ - public function deactivateProduct(string $productId): array - { - return $this->makeRequest('POST', '/products/' . $productId, ['active' => 'false']); - } - - /** - * Create checkout session - * @throws SubscriptionException - */ - public function createCheckoutSession(Document $user, string $priceId, string $successUrl, string $cancelUrl, ?string $planId = null, array $additionalItems = []): array - { - $mainItem = [ 'price' => $priceId ]; - try { - $price = $this->getPrice($priceId); - $usageType = (string)($price['recurring']['usage_type'] ?? ''); - if ($usageType !== 'metered') { - $mainItem['quantity'] = 1; - } - } catch (SubscriptionException $e) { - $mainItem['quantity'] = 1; - } - - $lineItems = [ - 'mode' => 'subscription', - 'line_items' => [ $mainItem ], - ]; - - foreach ($additionalItems as $item) { - if (!is_array($item)) { - continue; - } - $entry = []; - if (!empty($item['price'])) { - $entry['price'] = $item['price']; - } - if (!empty($item['quantity'])) { - $entry['quantity'] = (int) $item['quantity']; - } - if (!empty($entry)) { - $lineItems['line_items'][] = $entry; - } - } - - $params = $lineItems + [ - 'success_url' => $successUrl, - 'cancel_url' => $cancelUrl, - 'customer_email' => $user->getAttribute('email'), - 'client_reference_id' => $user->getId(), - 'metadata' => array_filter([ - 'user_id' => $user->getId(), - 'project_id' => $this->project->getId(), - 'plan_id' => $planId, - ]), - 'subscription_data' => [ - 'metadata' => array_filter([ - 'user_id' => $user->getId(), - 'project_id' => $this->project->getId(), - 'plan_id' => $planId, - ]) - ] - ]; - - if ($user->getAttribute('stripeCustomerId')) { - $params['customer'] = $user->getAttribute('stripeCustomerId'); - unset($params['customer_email']); - } - - $response = $this->makeRequest('POST', '/checkout/sessions', $params); - - if (isset($response['error'])) { - throw new SubscriptionException($response['error']['message'] ?? 'Failed to create checkout session'); - } - - return $response; - } - - public function buildAdditionalLineItemsForPlan(string $planId): array - { - if ($this->platformDb === null) { - return []; - } - - $items = []; - $assigned = Authorization::skip(fn () => $this->platformDb->find('auth_plan_features', [ - Query::equal('projectId', [$this->project->getId()]), - Query::equal('planId', [$planId]), - Query::equal('active', [true]) - ])); - - foreach ($assigned as $af) { - $type = (string) $af->getAttribute('type'); - $priceId = (string) $af->getAttribute('stripePriceId'); - if ($priceId === '') { - continue; - } - if ($type === 'metered') { - $items[] = [ 'price' => $priceId ]; - continue; - } - $items[] = [ 'price' => $priceId, 'quantity' => 1 ]; - } - - return $items; - } - - /** - * Create customer portal session - * @throws SubscriptionException - */ - public function createPortalSession(string $customerId, string $returnUrl): array - { - $params = [ - 'customer' => $customerId, - 'return_url' => $returnUrl - ]; - - $response = $this->makeRequest('POST', '/billing_portal/sessions', $params); - - if (isset($response['error'])) { - throw new SubscriptionException($response['error']['message'] ?? 'Failed to create portal session'); - } - - return $response; - } - - /** - * Ensure Stripe customer exists for user, return customer ID - */ - public function ensureCustomer(Document $user): string - { - $customerId = (string) $user->getAttribute('stripeCustomerId', ''); - if ($customerId !== '') { - return $customerId; - } - - $params = [ - 'email' => $user->getAttribute('email'), - 'metadata' => [ - 'project_id' => $this->project->getId(), - 'user_id' => $user->getId() - ] - ]; - $response = $this->makeRequest('POST', '/customers', $params); - if (isset($response['error'])) { - throw new SubscriptionException($response['error']['message'] ?? 'Failed to create customer'); - } - return (string) ($response['id'] ?? ''); - } - - /** - * Ingest usage for a metered feature - * @throws SubscriptionException - */ - public function ingestUsage(int $value, string $userId, string $planId, string $featureId, ?int $timestamp = null, ?string $identifier = null): array - { - // Resolve user and ensure Stripe customer id - $user = Authorization::skip(fn () => $this->database->getDocument('users', $userId)); - if ($user->isEmpty()) { - throw new SubscriptionException('User not found'); - } - - $customerId = (string) $user->getAttribute('stripeCustomerId', ''); - if ($customerId === '') { - // Create customer if missing - $customerId = $this->ensureCustomer($user); - if ($customerId === '') { - throw new SubscriptionException('Failed to ensure Stripe customer for user'); - } - $user = $user->setAttribute('stripeCustomerId', $customerId); - Authorization::skip(fn () => $this->database->updateDocument('users', $userId, $user)); - } - - // Determine window timestamps - $periodStart = $user->getAttribute('subscriptionCurrentPeriodStart'); - $periodEnd = $user->getAttribute('subscriptionCurrentPeriodEnd'); - $startTs = $periodStart ? strtotime((string)$periodStart) : 0; - $endTs = $periodEnd ? strtotime((string)$periodEnd) : time(); - - // Enforce usage caps if defined on the plan feature assignment (prefer local DB total to avoid Stripe latency) - if ($this->platformDb) { - $assignment = Authorization::skip(fn () => $this->platformDb->findOne('auth_plan_features', [ - Query::equal('projectId', [$this->project->getId()]), - Query::equal('planId', [$planId]), - Query::equal('featureId', [$featureId]), - Query::equal('active', [true]) - ])); - if ($assignment && !$assignment->isEmpty()) { - $cap = $assignment->getAttribute('usageCap'); - if ($cap !== null) { - // First, try local DB sum - $current = 0; - try { - $current = $this->getFeatureUsageTotalLocal($user->getId(), $planId, $featureId, $startTs, $endTs); - } catch (\Throwable $_) { - } - // Fallback to Stripe if local gives 0 and we have a customer - if ($current === 0) { - try { - $current = $this->getFeatureUsageTotal($customerId, $planId, $featureId, $startTs, $endTs); - } catch (\Throwable $_) { - } - } - if ($current + $value > (int)$cap) { - throw new SubscriptionException('Usage cap exceeded for feature: ' . $featureId); - } - } - } - } - - // Build event name based on the provisioned meter convention - $eventName = 'appwrite.auth.feature.usage.' . $this->project->getId() . '.' . $planId . '.' . $featureId; - - $params = [ - 'event_name' => $eventName, - 'payload' => [ - 'value' => (string) $value, - 'stripe_customer_id' => $customerId, - ], - ]; - if (!empty($identifier)) { - $params['identifier'] = $identifier; - } - if (!empty($timestamp)) { - $params['timestamp'] = (string) $timestamp; - } - - // Write usage locally for fast reads and idempotency checks - $eventDocument = new Document([ - '$id' => ID::unique(), - 'projectInternalId' => $this->project->getSequence(), - 'projectId' => $this->project->getId(), - 'userId' => $user->getId(), - 'planId' => $planId, - 'featureId' => $featureId, - 'value' => $value, - 'identifier' => $identifier, - 'timestamp' => isset($timestamp) - ? DatabaseDateTime::formatTz(gmdate('c', (int)$timestamp)) - : DatabaseDateTime::formatTz(gmdate('c')), - ]); - try { - Authorization::skip(fn () => $this->platformDb?->createDocument('auth_usage_events', $eventDocument)); - } catch (\Throwable $e) { - // best-effort; do not block ingestion if local write fails - } - - // Send to Stripe (async on Stripe side) - $response = $this->makeRequest('POST', '/billing/meter_events', $params); - - if (isset($response['error'])) { - throw new SubscriptionException($response['error']['message'] ?? 'Failed to ingest usage'); - } - - return $response; - } - - /** - * Get total usage for a feature from local DB within a time window - */ - public function getFeatureUsageTotalLocal(string $userId, string $planId, string $featureId, int $startTime, int $endTime): int - { - if ($this->platformDb === null) { - return 0; - } - // Sum within current billing window; if invalid window, approximate using feature interval (default month) - if ($startTime <= 0 || $endTime <= 0) { - // Approximate: last 30 days - $endTime = time(); - $startTime = $endTime - (86400 * 30); - } - $fromStr = DatabaseDateTime::formatTz(gmdate('c', (int)$startTime)); - $toStr = DatabaseDateTime::formatTz(gmdate('c', (int)$endTime)); - $filters = [ - Query::equal('projectId', [$this->project->getId()]), - Query::equal('userId', [$userId]), - Query::equal('planId', [$planId]), - Query::equal('featureId', [$featureId]), - Query::greaterThanEqual('timestamp', $fromStr), - Query::lessThanEqual('timestamp', $toStr), - Query::limit(100000), - ]; - $events = Authorization::skip(fn () => $this->platformDb->find('auth_usage_events', $filters)); - $total = 0; - foreach ($events as $e) { - $total += (int) $e->getAttribute('value', 0); - } - return $total; - } - - /** - * Get total usage for a feature for a customer within a time window - * Returns integer total (sum) if available, otherwise 0. - * @throws SubscriptionException - */ - public function getFeatureUsageTotal(string $customerId, string $planId, string $featureId, int $startTime, int $endTime): int - { - if ($customerId === '' || $startTime <= 0 || $endTime <= 0) { - return 0; - } - - // Find meter id from platform DB - if ($this->platformDb === null) { - return 0; - } - $assigned = Authorization::skip(fn () => $this->platformDb->findOne('auth_plan_features', [ - Query::equal('projectId', [$this->project->getId()]), - Query::equal('planId', [$planId]), - Query::equal('featureId', [$featureId]), - Query::equal('active', [true]) - ])); - if (!$assigned || $assigned->isEmpty()) { - return 0; - } - $meterId = (string) $assigned->getAttribute('stripeMeterId', ''); - if ($meterId === '') { - return 0; - } - - $params = [ - 'meter' => $meterId, - 'customer' => $customerId, - 'start_time' => (string) $startTime, - 'end_time' => (string) $endTime, - ]; - - // Stripe may expose this endpoint as meter_event_summaries - $summary = $this->makeRequest('GET', '/billing/meter_event_summaries', $params); - if (isset($summary['error'])) { - return 0; - } - // Attempt to parse common shapes - if (isset($summary['data']) && is_array($summary['data'])) { - $total = 0; - foreach ($summary['data'] as $row) { - $val = (int) ($row['value'] ?? 0); - $total += $val; - } - return $total; - } - if (isset($summary['total'])) { - return (int) $summary['total']; - } - if (isset($summary['value'])) { - return (int) $summary['value']; - } - return 0; - } - - /** - * Create a complimentary subscription (trial for one interval, auto-cancel) - */ - public function createComplimentarySubscription(string $customerId, string $priceId, string $interval, ?string $userId = null): array - { - $now = time(); - $seconds = 0; - switch ($interval) { - case 'day': - $seconds = 86400; - break; - case 'week': - $seconds = 86400 * 7; - break; - case 'year': - $seconds = 86400 * 365; - break; - case 'month': - default: - $seconds = 86400 * 30; - break; - } - $trialEnd = $now + $seconds; - - $params = [ - 'customer' => $customerId, - 'items' => [[ 'price' => $priceId ]], - 'trial_end' => $trialEnd, - 'cancel_at' => $trialEnd, - 'payment_behavior' => 'default_incomplete', - 'metadata' => [ - 'project_id' => $this->project->getId(), - 'user_id' => $userId - ] - ]; - - $response = $this->makeRequest('POST', '/subscriptions', $params); - - if (isset($response['error'])) { - throw new SubscriptionException($response['error']['message'] ?? 'Failed to create subscription'); - } - - return $response; - } - - /** - * Cancel active subscription immediately or at period end - */ - public function cancelSubscription(string $subscriptionId, bool $atPeriodEnd = true): array - { - $params = [ 'cancel_at_period_end' => $atPeriodEnd ? 'true' : 'false' ]; - $response = $this->makeRequest('DELETE', '/subscriptions/' . $subscriptionId, $params); - - if (isset($response['error'])) { - throw new SubscriptionException($response['error']['message'] ?? 'Failed to cancel subscription'); - } - - return $response; - } - - /** - * Find an active (or trialing) subscription ID for a customer - */ - public function findActiveSubscriptionId(string $customerId): ?string - { - $params = [ - 'customer' => $customerId, - 'limit' => 100 - ]; - - $response = $this->makeRequest('GET', '/subscriptions', $params); - - if (!isset($response['data']) || !is_array($response['data'])) { - return null; - } - - foreach ($response['data'] as $sub) { - $status = (string) ($sub['status'] ?? ''); - if (in_array($status, ['active', 'trialing', 'past_due', 'unpaid'], true)) { - return (string) ($sub['id'] ?? ''); - } - } - - return null; - } - - /** - * Get subscription details - * @throws SubscriptionException - */ - public function getSubscription(string $subscriptionId): array - { - $response = $this->makeRequest('GET', '/subscriptions/' . $subscriptionId); - - if (isset($response['error'])) { - throw new SubscriptionException($response['error']['message'] ?? 'Failed to get subscription'); - } - - return $response; - } - - // duplicate removed - - /** - * Update subscription (change plan) - * @throws SubscriptionException - */ - public function updateSubscription(string $subscriptionId, string $newPriceId): array - { - $subscription = $this->getSubscription($subscriptionId); - - $params = [ - 'items' => [ - [ - 'id' => $subscription['items']['data'][0]['id'], - 'price' => $newPriceId - ] - ], - 'proration_behavior' => 'create_prorations' - ]; - - $response = $this->makeRequest('POST', '/subscriptions/' . $subscriptionId, $params); - - if (isset($response['error'])) { - throw new SubscriptionException($response['error']['message'] ?? 'Failed to update subscription'); - } - - return $response; - } - - /** - * Verify webhook signature - */ - public function verifyWebhookSignature(string $payload, string $signature, string $secret): bool - { - $timestamp = null; - $v1Signatures = []; - - foreach (explode(',', (string) $signature) as $segment) { - $segment = trim($segment); - $parts = explode('=', $segment, 2); - if (count($parts) !== 2) { - continue; - } - if ($parts[0] === 't') { - $timestamp = $parts[1]; - } - if ($parts[0] === 'v1') { - $v1Signatures[] = $parts[1]; - } - } - - if (!$timestamp || empty($v1Signatures)) { - return false; - } - - $signedPayload = $timestamp . '.' . $payload; - $expected = hash_hmac('sha256', $signedPayload, trim((string) $secret)); - - foreach ($v1Signatures as $sig) { - if (hash_equals($expected, $sig)) { - return true; - } - } - - return false; - } - - /** - * Handle webhook event - * @throws SubscriptionException - */ - public function handleWebhook(array $event): void - { - switch ($event['type']) { - case 'checkout.session.completed': - $this->handleCheckoutCompleted($event['data']['object']); - break; - - case 'customer.subscription.created': - case 'customer.subscription.updated': - $this->handleSubscriptionUpdate($event['data']['object']); - break; - - case 'customer.subscription.deleted': - $this->handleSubscriptionDeleted($event['data']['object']); - break; - - case 'invoice.payment_failed': - $this->handlePaymentFailed($event['data']['object']); - break; - - case 'invoice.payment_succeeded': - $this->handlePaymentSucceeded($event['data']['object']); - break; - - // Keep Appwrite in sync with Stripe-side changes - case 'product.updated': - $this->handleProductUpdated($event['data']['object']); - break; - case 'product.deleted': - $this->handleProductDeleted($event['data']['object']); - break; - case 'price.updated': - $this->handlePriceUpdated($event['data']['object']); - break; - case 'price.deleted': - $this->handlePriceDeleted($event['data']['object']); - break; - } - } - - /** - * Product updated - */ - private function handleProductUpdated(array $product): void - { - if ($this->platformDb === null) { - return; - } - $stripeProductId = $product['id'] ?? ''; - if (empty($stripeProductId)) { - return; - } - $plan = $this->platformDb->findOne('auth_plans', [ - Query::equal('stripeProductId', [$stripeProductId]), - Query::equal('projectId', [$this->project->getId()]) - ]); - if ($plan->isEmpty()) { - return; - } - $plan->setAttribute('name', $product['name'] ?? $plan->getAttribute('name')); - if (isset($product['active']) && $product['active'] === false) { - // If product deactivated, mark plan inactive - $plan->setAttribute('active', false); - } - $this->platformDb->updateDocument('auth_plans', $plan->getId(), $plan); - } - - /** - * Product deleted - */ - private function handleProductDeleted(array $product): void - { - if ($this->platformDb === null) { - return; - } - $stripeProductId = $product['id'] ?? ''; - if (empty($stripeProductId)) { - return; - } - $plan = $this->platformDb->findOne('auth_plans', [ - Query::equal('stripeProductId', [$stripeProductId]), - Query::equal('projectId', [$this->project->getId()]) - ]); - if (!$plan->isEmpty()) { - $this->platformDb->deleteDocument('auth_plans', $plan->getId()); - } - } - - /** - * Price updated - */ - private function handlePriceUpdated(array $price): void - { - if ($this->platformDb === null) { - return; - } - $stripePriceId = $price['id'] ?? ''; - if (empty($stripePriceId)) { - return; - } - $plan = $this->platformDb->findOne('auth_plans', [ - Query::equal('stripePriceId', [$stripePriceId]), - Query::equal('projectId', [$this->project->getId()]) - ]); - if ($plan->isEmpty()) { - return; - } - if (isset($price['active']) && $price['active'] === false) { - $plan->setAttribute('active', false); - } - if (isset($price['unit_amount'])) { - $plan->setAttribute('price', (int)$price['unit_amount']); - } - if (isset($price['currency'])) { - $plan->setAttribute('currency', $price['currency']); - } - if (isset($price['recurring']['interval'])) { - $plan->setAttribute('interval', $price['recurring']['interval']); - } - $this->platformDb->updateDocument('auth_plans', $plan->getId(), $plan); - } - - /** - * Price deleted - */ - private function handlePriceDeleted(array $price): void - { - if ($this->platformDb === null) { - return; - } - $stripePriceId = $price['id'] ?? ''; - if (empty($stripePriceId)) { - return; - } - $plan = $this->platformDb->findOne('auth_plans', [ - Query::equal('stripePriceId', [$stripePriceId]), - Query::equal('projectId', [$this->project->getId()]) - ]); - if (!$plan->isEmpty()) { - $this->platformDb->deleteDocument('auth_plans', $plan->getId()); - } - } - - /** - * Handle checkout completed - */ - private function handleCheckoutCompleted(array $session): void - { - $userId = $session['metadata']['user_id'] ?? null; - if (!$userId) { - return; - } - - $user = Authorization::skip(fn () => $this->database->getDocument('users', $userId)); - if ($user->isEmpty()) { - return; - } - - $updated = $user - ->setAttribute('stripeCustomerId', $session['customer']) - ->setAttribute('stripeSubscriptionId', $session['subscription']); - Authorization::skip(fn () => $this->database->updateDocument('users', $userId, $updated)); - - if ($session['subscription']) { - $subscription = $this->getSubscription($session['subscription']); - $this->syncSubscriptionStatus($userId, $subscription); - } - } - - /** - * Handle subscription update - */ - private function handleSubscriptionUpdate(array $subscription): void - { - $userId = $subscription['metadata']['user_id'] ?? null; - if (!$userId) { - return; - } - - $full = isset($subscription['id']) ? $this->getSubscription($subscription['id']) : $subscription; - $this->syncSubscriptionStatus($userId, $full); - } - - /** - * Handle subscription deleted - */ - private function handleSubscriptionDeleted(array $subscription): void - { - $userId = $subscription['metadata']['user_id'] ?? null; - if (!$userId) { - return; - } - - $user = Authorization::skip(fn () => $this->database->getDocument('users', $userId)); - if ($user->isEmpty()) { - return; - } - - $defaultPlan = null; - if ($this->platformDb) { - $defaultPlan = $this->platformDb->findOne('auth_plans', [ - Query::equal('projectId', [$this->project->getId()]), - Query::equal('isDefault', [true]), - Query::equal('isFree', [true]) - ]); - } - - $updated = $user - ->setAttribute('stripeSubscriptionId', null) - ->setAttribute('subscriptionStatus', 'none') - ->setAttribute('subscriptionCurrentPeriodStart', null) - ->setAttribute('subscriptionCurrentPeriodEnd', null) - ->setAttribute('subscriptionCancelAtPeriodEnd', false) - ->setAttribute('planId', $defaultPlan ? $defaultPlan->getAttribute('planId') : null); - Authorization::skip(fn () => $this->database->updateDocument('users', $userId, $updated)); - } - - /** - * Handle payment failed - */ - private function handlePaymentFailed(array $invoice): void - { - $subscriptionId = $invoice['subscription'] ?? null; - if (!$subscriptionId) { - return; - } - - $subscription = $this->getSubscription($subscriptionId); - $userId = $subscription['metadata']['user_id'] ?? null; - - if ($userId) { - $user = Authorization::skip(fn () => $this->database->getDocument('users', $userId)); - if (!$user->isEmpty()) { - $updated = $user->setAttribute('subscriptionStatus', 'past_due'); - Authorization::skip(fn () => $this->database->updateDocument('users', $userId, $updated)); - } - } - } - - /** - * Handle payment succeeded - */ - private function handlePaymentSucceeded(array $invoice): void - { - $subscriptionId = $invoice['subscription'] ?? null; - if (!$subscriptionId) { - return; - } - - $subscription = $this->getSubscription($subscriptionId); - $userId = $subscription['metadata']['user_id'] ?? null; - - if ($userId) { - $this->syncSubscriptionStatus($userId, $subscription); - } - } - - /** - * Sync subscription status to user - */ - public function syncSubscriptionStatus(string $userId, array $subscription): void - { - $user = Authorization::skip(fn () => $this->database->getDocument('users', $userId)); - if ($user->isEmpty()) { - return; - } - - $priceId = $subscription['items']['data'][0]['price']['id'] ?? null; - $status = (string) ($subscription['status'] ?? ''); - $cancelAtPeriodEnd = (bool) ($subscription['cancel_at_period_end'] ?? false); - $periodStart = isset($subscription['current_period_start']) ? date('c', $subscription['current_period_start']) : null; - $periodEndTs = $subscription['current_period_end'] ?? null; - $periodEnd = $periodEndTs ? date('c', $periodEndTs) : null; - - $plan = null; - if ($priceId && $this->platformDb) { - $plan = Authorization::skip(fn () => $this->platformDb->findOne('auth_plans', [ - Query::equal('stripePriceId', [$priceId]), - Query::equal('projectId', [$this->project->getId()]) - ])); - } - if (!$plan && $this->platformDb) { - $productId = $subscription['items']['data'][0]['price']['product'] ?? null; - if (!empty($productId)) { - $fallback = Authorization::skip(fn () => $this->platformDb->findOne('auth_plans', [ - Query::equal('stripeProductId', [$productId]), - Query::equal('projectId', [$this->project->getId()]) - ])); - if ($fallback) { - $plan = $fallback; - } - } - } - if (!$plan && $this->platformDb) { - $metaPlanId = $subscription['metadata']['plan_id'] ?? null; - if (!empty($metaPlanId)) { - $fallback = Authorization::skip(fn () => $this->platformDb->findOne('auth_plans', [ - Query::equal('planId', [$metaPlanId]), - Query::equal('projectId', [$this->project->getId()]) - ])); - if ($fallback) { - $plan = $fallback; - } - } - } - - - $fallbackToFree = ($status === 'canceled' || $status === 'incomplete_expired' || ($cancelAtPeriodEnd && $periodEndTs && time() >= (int) $periodEndTs)); - $defaultPlan = null; - if ($fallbackToFree && $this->platformDb) { - $defaultPlan = Authorization::skip(fn () => $this->platformDb->findOne('auth_plans', [ - Query::equal('projectId', [$this->project->getId()]), - Query::equal('isDefault', [true]), - Query::equal('isFree', [true]) - ])); - } - - $updated = $user - ->setAttribute('planId', $fallbackToFree && $defaultPlan ? $defaultPlan->getAttribute('planId') : ($plan ? $plan->getAttribute('planId') : null)) - ->setAttribute('subscriptionStatus', $status) - ->setAttribute('subscriptionCurrentPeriodStart', $periodStart) - ->setAttribute('subscriptionCurrentPeriodEnd', $periodEnd) - ->setAttribute('subscriptionCancelAtPeriodEnd', $cancelAtPeriodEnd) - ->setAttribute('subscriptionTrialEnd', isset($subscription['trial_end']) ? date('c', $subscription['trial_end']) : null); - Authorization::skip(fn () => $this->database->updateDocument('users', $userId, $updated)); - } - - /** - * Make request to Stripe API - */ - private function makeRequest(string $method, string $path, array $params = []): array - { - $ch = curl_init(); - - $url = $this->apiUrl . $path; - - - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_HTTPHEADER, [ - 'Authorization: Bearer ' . $this->apiKey, - 'Content-Type: application/x-www-form-urlencoded' - ]); - - switch ($method) { - case 'GET': - if (!empty($params)) { - $url .= '?' . http_build_query($params); - } - break; - - case 'POST': - curl_setopt($ch, CURLOPT_POST, true); - if (!empty($params)) { - curl_setopt($ch, CURLOPT_POSTFIELDS, $this->buildFormData($params)); - } - break; - - case 'DELETE': - curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE'); - break; - } - - curl_setopt($ch, CURLOPT_URL, $url); - - $response = curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - - curl_close($ch); - - if ($response === false) { - throw new SubscriptionException('Failed to connect to Stripe API'); - } - - $data = json_decode($response, true); - - if ($httpCode >= 400) { - if (isset($data['error'])) { - throw new SubscriptionException($data['error']['message'] ?? 'Stripe API error', $httpCode); - } - throw new SubscriptionException('Stripe API request failed', $httpCode); - } - - return $data; - } - - /** - * Build form data for nested arrays - */ - private function buildFormData(array $params, string $prefix = ''): string - { - $data = []; - - foreach ($params as $key => $value) { - $key = $prefix ? "{$prefix}[{$key}]" : $key; - - if (is_array($value)) { - if (isset($value[0])) { - foreach ($value as $index => $item) { - if (is_array($item)) { - $data[] = $this->buildFormData($item, "{$key}[{$index}]"); - } else { - $data[] = urlencode("{$key}[{$index}]") . '=' . urlencode($item); - } - } - } else { - $data[] = $this->buildFormData($value, $key); - } - } else { - $data[] = urlencode($key) . '=' . urlencode($value); - } - } - - return implode('&', $data); - } -} diff --git a/src/Appwrite/Auth/Validator/PlanData.php b/src/Appwrite/Auth/Validator/PlanData.php deleted file mode 100644 index 3286d2c493..0000000000 --- a/src/Appwrite/Auth/Validator/PlanData.php +++ /dev/null @@ -1,78 +0,0 @@ - true, 'example' => [], ]) - ->addRule('plan', [ - 'type' => self::TYPE_JSON, - 'description' => 'User subscription plan details.', - 'required' => false, - 'default' => new \stdClass(), - 'example' => [ - 'id' => 'basic', - 'name' => 'Basic', - 'price' => 999, - 'currency' => 'usd', - 'interval' => 'month', - 'isFree' => false - ], - ]) ->addRule('accessedAt', [ 'type' => self::TYPE_DATETIME, 'description' => 'Most recent access date in ISO 8601 format. This attribute is only updated again after ' . APP_USER_ACCESS / 60 / 60 . ' hours.',