From c187efadb4acd53a2cf0a7af0951f0c4966aec7b Mon Sep 17 00:00:00 2001 From: Atharva Deosthale Date: Tue, 6 Jan 2026 23:37:15 +0530 Subject: [PATCH] improve the code a bit --- .../Payments/Http/ActorFeatures/Get.php | 4 +- .../Modules/Payments/Http/Features/Create.php | 4 +- .../Modules/Payments/Http/Features/Delete.php | 4 +- .../Modules/Payments/Http/Features/Get.php | 4 +- .../Modules/Payments/Http/Features/Update.php | 4 +- .../Modules/Payments/Http/Features/XList.php | 67 ++- .../Modules/Payments/Http/Invoices/XList.php | 103 +++-- .../Payments/Http/PlanFeatures/Assign.php | 8 +- .../Payments/Http/PlanFeatures/Remove.php | 4 +- .../Payments/Http/PlanFeatures/XList.php | 77 +++- .../Modules/Payments/Http/Plans/Create.php | 24 +- .../Modules/Payments/Http/Plans/Delete.php | 4 +- .../Modules/Payments/Http/Plans/Get.php | 4 +- .../Modules/Payments/Http/Plans/Update.php | 19 +- .../Modules/Payments/Http/Plans/XList.php | 72 +++- .../Http/Providers/Actions/Test/Create.php | 4 +- .../Modules/Payments/Http/Providers/Get.php | 4 +- .../Payments/Http/Providers/Update.php | 4 +- .../Payments/Http/Subscriptions/Cancel.php | 4 +- .../Payments/Http/Subscriptions/Create.php | 4 +- .../Payments/Http/Subscriptions/Get.php | 4 +- .../Payments/Http/Subscriptions/Portal.php | 5 +- .../Http/Subscriptions/PreviewUpgrade.php | 6 +- .../Payments/Http/Subscriptions/Resume.php | 4 +- .../Payments/Http/Subscriptions/Update.php | 4 +- .../Payments/Http/Subscriptions/XList.php | 72 +++- .../Modules/Payments/Http/Usage/Create.php | 4 +- .../Payments/Http/Usage/Events/XList.php | 83 +++- .../Modules/Payments/Http/Usage/Get.php | 4 +- .../Payments/Http/Usage/Reconcile/Create.php | 4 +- .../Http/Webhooks/Provider/Create.php | 4 +- .../Modules/Payments/Services/Http.php | 4 +- .../Payments/Validator/FeatureTier.php | 97 +++++ .../Payments/Validator/PricingEntry.php | 92 ++++ .../Payments/Validator/ProviderConfig.php | 124 ++++++ .../Platform/Tasks/SchedulePaymentsUsage.php | 1 - .../Validator/Queries/PaymentFeatures.php | 22 + .../Validator/Queries/PaymentPlanFeatures.php | 22 + .../Validator/Queries/PaymentPlans.php | 23 + .../Queries/PaymentSubscriptions.php | 24 ++ .../Validator/Queries/PaymentUsageEvents.php | 21 + src/Appwrite/Utopia/Response.php | 12 + .../Utopia/Response/Model/PaymentInvoice.php | 78 ++++ .../Response/Model/PaymentPlanFeature.php | 102 +++++ .../Response/Model/PaymentUsageEvent.php | 84 ++++ tests/e2e/Services/Payments/PaymentsBase.php | 392 ++++++++++++++++++ .../Payments/PaymentsConsoleClientTest.php | 17 + .../Payments/PaymentsCustomClientTest.php | 17 + .../Payments/PaymentsCustomServerTest.php | 16 + 49 files changed, 1595 insertions(+), 169 deletions(-) create mode 100644 src/Appwrite/Platform/Modules/Payments/Validator/FeatureTier.php create mode 100644 src/Appwrite/Platform/Modules/Payments/Validator/PricingEntry.php create mode 100644 src/Appwrite/Platform/Modules/Payments/Validator/ProviderConfig.php create mode 100644 src/Appwrite/Utopia/Database/Validator/Queries/PaymentFeatures.php create mode 100644 src/Appwrite/Utopia/Database/Validator/Queries/PaymentPlanFeatures.php create mode 100644 src/Appwrite/Utopia/Database/Validator/Queries/PaymentPlans.php create mode 100644 src/Appwrite/Utopia/Database/Validator/Queries/PaymentSubscriptions.php create mode 100644 src/Appwrite/Utopia/Database/Validator/Queries/PaymentUsageEvents.php create mode 100644 src/Appwrite/Utopia/Response/Model/PaymentInvoice.php create mode 100644 src/Appwrite/Utopia/Response/Model/PaymentPlanFeature.php create mode 100644 src/Appwrite/Utopia/Response/Model/PaymentUsageEvent.php create mode 100644 tests/e2e/Services/Payments/PaymentsBase.php create mode 100644 tests/e2e/Services/Payments/PaymentsConsoleClientTest.php create mode 100644 tests/e2e/Services/Payments/PaymentsCustomClientTest.php create mode 100644 tests/e2e/Services/Payments/PaymentsCustomServerTest.php diff --git a/src/Appwrite/Platform/Modules/Payments/Http/ActorFeatures/Get.php b/src/Appwrite/Platform/Modules/Payments/Http/ActorFeatures/Get.php index d2b02f253f..495aa471a8 100644 --- a/src/Appwrite/Platform/Modules/Payments/Http/ActorFeatures/Get.php +++ b/src/Appwrite/Platform/Modules/Payments/Http/ActorFeatures/Get.php @@ -41,7 +41,9 @@ class Get extends Base namespace: 'payments', group: 'actorFeatures', name: 'get', - description: 'Get features available to an actor (user/team) based on their subscription plan', + description: <<param('search', '', new Text(256), 'Search term.', true) + ->param('queries', [], new PaymentFeatures(), '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(', ', PaymentFeatures::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') ->callback($this->action(...)); } public function action( + array $queries, string $search, + bool $includeTotal, Response $response, Database $dbForProject ) { - $filters = []; - if ($search !== '') { - $filters[] = Query::search('name', $search); + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); } - $list = $dbForProject->find('payments_features', $filters); + + if (!empty($search)) { + $queries[] = Query::search('search', $search); + } + + /** + * Get cursor document if there was a cursor query, we use array_filter and reset for reference $cursor to $queries + */ + $cursor = \array_filter($queries, function ($query) { + return \in_array($query->getMethod(), [Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE]); + }); + $cursor = reset($cursor); + if ($cursor) { + /** @var Query $cursor */ + + $validator = new Cursor(); + if (!$validator->isValid($cursor)) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); + } + + $featureId = $cursor->getValue(); + $cursorDocument = $dbForProject->getDocument('payments_features', $featureId); + + if ($cursorDocument->isEmpty()) { + throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Feature '{$featureId}' for the 'cursor' value not found."); + } + + $cursor->setValue($cursorDocument); + } + + $filterQueries = Query::groupByType($queries)['filters']; + + try { + $features = $dbForProject->find('payments_features', $queries); + $total = $includeTotal ? $dbForProject->count('payments_features', $filterQueries, 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."); + } + $response->dynamic(new Document([ - 'total' => count($list), - 'features' => $list + 'features' => $features, + 'total' => $total, ]), Response::MODEL_PAYMENT_FEATURE_LIST); } } diff --git a/src/Appwrite/Platform/Modules/Payments/Http/Invoices/XList.php b/src/Appwrite/Platform/Modules/Payments/Http/Invoices/XList.php index 2bbe2ede15..4faf367584 100644 --- a/src/Appwrite/Platform/Modules/Payments/Http/Invoices/XList.php +++ b/src/Appwrite/Platform/Modules/Payments/Http/Invoices/XList.php @@ -2,9 +2,10 @@ namespace Appwrite\Platform\Modules\Payments\Http\Invoices; -use Appwrite\Payments\Provider\Registry; -use Appwrite\Payments\Provider\StripeAdapter; +use Appwrite\Extend\Exception; +use Appwrite\Payments\Provider\ProviderState; use Appwrite\Payments\Provider\ProviderSubscriptionRef; +use Appwrite\Payments\Provider\Registry; use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; @@ -40,18 +41,20 @@ class XList extends Base namespace: 'payments', group: 'subscriptions', name: 'listInvoices', - description: 'List invoices for a subscription', + description: <<param('subscriptionId', '', new Text(128), 'Subscription ID') - ->param('limit', 25, new Integer(true), 'Maximum number of invoices to return (max 100)', true) - ->param('offset', 0, new Integer(true), 'Offset for pagination', true) + ->param('subscriptionId', '', new Text(128), 'Subscription ID.') + ->param('limit', 25, new Integer(true), 'Maximum number of invoices to return. Max 100.', true) + ->param('offset', 0, new Integer(true), 'Offset for pagination.', true) ->inject('response') ->inject('dbForPlatform') ->inject('dbForProject') @@ -76,23 +79,17 @@ class XList extends Base $projDoc = $dbForPlatform->getDocument('projects', $project->getId()); $paymentsCfg = (array) $projDoc->getAttribute('payments', []); if (isset($paymentsCfg['enabled']) && $paymentsCfg['enabled'] === false) { - $response->setStatusCode(Response::STATUS_CODE_FORBIDDEN); - $response->json(['message' => 'Payments feature is disabled for this project']); - return; + throw new Exception(Exception::GENERAL_ACCESS_FORBIDDEN, 'Payments feature is disabled for this project'); } // Validate limit if ($limit < 1 || $limit > 100) { - $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); - $response->json(['message' => 'Limit must be between 1 and 100']); - return; + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Limit must be between 1 and 100'); } // Validate offset if ($offset < 0) { - $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); - $response->json(['message' => 'Offset must be non-negative']); - return; + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Offset must be non-negative'); } // Get subscription from database @@ -101,9 +98,7 @@ class XList extends Base ]); if ($subscription === null || $subscription->isEmpty()) { - $response->setStatusCode(Response::STATUS_CODE_NOT_FOUND); - $response->json(['message' => 'Subscription not found']); - return; + throw new Exception(Exception::PAYMENT_SUBSCRIPTION_NOT_FOUND); } // Authorization: only enforce for JWT users, API keys have admin access @@ -113,7 +108,7 @@ class XList extends Base if ($actorType === 'user') { if ($user->getId() !== $actorId) { - throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::USER_UNAUTHORIZED, 'Not authorized to access this subscription'); + throw new Exception(Exception::USER_UNAUTHORIZED, 'Not authorized to access this subscription'); } } elseif ($actorType === 'team') { $membership = $dbForProject->findOne('memberships', [ @@ -122,49 +117,50 @@ class XList extends Base ]); if ($membership === null || $membership->isEmpty()) { - throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::USER_UNAUTHORIZED, 'User is not a member of this team'); + throw new Exception(Exception::USER_UNAUTHORIZED, 'User is not a member of this team'); } $roles = (array) $membership->getAttribute('roles', []); if (!in_array('owner', $roles, true) && !in_array('billing', $roles, true)) { - throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::USER_UNAUTHORIZED, 'User must have owner or billing role'); + throw new Exception(Exception::USER_UNAUTHORIZED, 'User must have owner or billing role'); } } } - // Get provider subscription ID - $providerSubscriptionId = (string) $subscription->getAttribute('providerSubscriptionId', ''); + // Get provider from subscription + $subscriptionProviders = (array) $subscription->getAttribute('providers', []); + $providerId = array_key_first($subscriptionProviders); + + if (!$providerId) { + // No provider on subscription, return empty list + $response->dynamic(new Document([ + 'invoices' => [], + 'total' => 0, + ]), Response::MODEL_PAYMENT_INVOICE_LIST); + return; + } + + $providerData = (array) ($subscriptionProviders[$providerId] ?? []); + $providerSubscriptionId = (string) ($providerData['providerSubscriptionId'] ?? ''); if ($providerSubscriptionId === '') { // No provider subscription yet, return empty list - $response->setStatusCode(Response::STATUS_CODE_OK); - $response->json([ + $response->dynamic(new Document([ + 'invoices' => [], 'total' => 0, - 'invoices' => [] - ]); + ]), Response::MODEL_PAYMENT_INVOICE_LIST); return; } - // Get payment provider - $payments = (array) $project->getAttribute('payments', []); - $providers = (array) ($payments['providers'] ?? []); - $primary = array_key_first($providers); - - if (!$primary) { - $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); - $response->json(['message' => 'No payment provider configured for this project']); - return; + // Get payment provider configuration + $providers = (array) ($paymentsCfg['providers'] ?? []); + if (!isset($providers[$providerId])) { + throw new Exception(Exception::PAYMENT_PROVIDER_NOT_CONFIGURED, 'Payment provider not configured for this project'); } - $config = (array) ($providers[$primary] ?? []); - $adapter = $registryPayments->get((string) $primary, $config, $project, $dbForPlatform, $dbForProject); - $state = new \Appwrite\Payments\Provider\ProviderState((string) $primary, $config, (array) ($config['state'] ?? [])); - - if (!$adapter instanceof StripeAdapter) { - $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); - $response->json(['message' => 'Unsupported payment provider: ' . $primary]); - return; - } + $config = (array) ($providers[$providerId] ?? []); + $adapter = $registryPayments->get((string) $providerId, $config, $projDoc, $dbForPlatform, $dbForProject); + $state = new ProviderState((string) $providerId, $config, (array) ($config['state'] ?? [])); // Create provider subscription reference $providerSubRef = new ProviderSubscriptionRef( @@ -174,15 +170,13 @@ class XList extends Base try { $invoices = $adapter->listInvoices($providerSubRef, $state, $limit, $offset); } catch (\Throwable $e) { - $response->setStatusCode(Response::STATUS_CODE_INTERNAL_SERVER_ERROR); - $response->json(['message' => 'Failed to fetch invoices: ' . $e->getMessage()]); - return; + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to fetch invoices: ' . $e->getMessage()); } // Format invoices for response $formattedInvoices = []; foreach ($invoices as $invoice) { - $formattedInvoices[] = [ + $formattedInvoices[] = new Document([ 'invoiceId' => $invoice->invoiceId, 'subscriptionId' => $invoice->subscriptionId, 'amount' => $invoice->amount, @@ -192,13 +186,12 @@ class XList extends Base 'paidAt' => $invoice->paidAt, 'invoiceUrl' => $invoice->invoiceUrl, 'metadata' => $invoice->metadata, - ]; + ]); } - $response->setStatusCode(Response::STATUS_CODE_OK); - $response->json([ + $response->dynamic(new Document([ + 'invoices' => $formattedInvoices, 'total' => count($formattedInvoices), - 'invoices' => $formattedInvoices - ]); + ]), Response::MODEL_PAYMENT_INVOICE_LIST); } } diff --git a/src/Appwrite/Platform/Modules/Payments/Http/PlanFeatures/Assign.php b/src/Appwrite/Platform/Modules/Payments/Http/PlanFeatures/Assign.php index 5db94f83cb..39faf85847 100644 --- a/src/Appwrite/Platform/Modules/Payments/Http/PlanFeatures/Assign.php +++ b/src/Appwrite/Platform/Modules/Payments/Http/PlanFeatures/Assign.php @@ -7,6 +7,7 @@ use Appwrite\Event\Event; use Appwrite\Payments\Provider\ProviderState; use Appwrite\Payments\Provider\Registry; use Appwrite\Platform\Modules\Compute\Base; +use Appwrite\Platform\Modules\Payments\Validator\FeatureTier; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; @@ -17,7 +18,6 @@ use Utopia\Database\Query; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\Validator\ArrayList; -use Utopia\Validator\Assoc; use Utopia\Validator\Integer; use Utopia\Validator\Text; @@ -46,7 +46,9 @@ class Assign extends Base namespace: 'payments', group: 'planFeatures', name: 'assign', - description: 'Assign feature to plan', + description: <<param('interval', '', new Text(16, 0), 'Billing interval', true) ->param('includedUnits', 0, new Integer(), 'Included units', true) ->param('tiersMode', null, new Text(32, 0), 'Tiers mode (graduated or volume)', true) - ->param('tiers', [], new ArrayList(new Assoc(), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Pricing tiers', true) + ->param('tiers', [], new ArrayList(new FeatureTier(), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Pricing tiers', true) ->param('usageCap', null, new Integer(), 'Usage cap', true) ->inject('response') ->inject('dbForPlatform') diff --git a/src/Appwrite/Platform/Modules/Payments/Http/PlanFeatures/Remove.php b/src/Appwrite/Platform/Modules/Payments/Http/PlanFeatures/Remove.php index adeee56ac5..6360fddd25 100644 --- a/src/Appwrite/Platform/Modules/Payments/Http/PlanFeatures/Remove.php +++ b/src/Appwrite/Platform/Modules/Payments/Http/PlanFeatures/Remove.php @@ -42,7 +42,9 @@ class Remove extends Base namespace: 'payments', group: 'planFeatures', name: 'remove', - description: 'Remove feature from plan', + description: <<param('planId', '', new Text(128), 'Plan ID') + ->param('queries', [], new PaymentPlanFeatures(), '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(', ', PaymentPlanFeatures::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') ->callback($this->action(...)); @@ -52,15 +64,62 @@ class XList extends Base public function action( string $planId, + array $queries, + string $search, + bool $includeTotal, Response $response, Database $dbForProject ) { - $items = $dbForProject->find('payments_plan_features', [ - Query::equal('planId', [$planId]) - ]); - $response->json([ - 'total' => count($items), - 'features' => array_map(fn ($d) => $d->getArrayCopy(), $items) - ]); + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + // Always filter by planId + $queries[] = Query::equal('planId', [$planId]); + + if (!empty($search)) { + $queries[] = Query::search('search', $search); + } + + /** + * Get cursor document if there was a cursor query, we use array_filter and reset for reference $cursor to $queries + */ + $cursor = \array_filter($queries, function ($query) { + return \in_array($query->getMethod(), [Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE]); + }); + $cursor = reset($cursor); + if ($cursor) { + /** @var Query $cursor */ + + $validator = new Cursor(); + if (!$validator->isValid($cursor)) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); + } + + $planFeatureId = $cursor->getValue(); + $cursorDocument = $dbForProject->getDocument('payments_plan_features', $planFeatureId); + + if ($cursorDocument->isEmpty()) { + throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Plan feature '{$planFeatureId}' for the 'cursor' value not found."); + } + + $cursor->setValue($cursorDocument); + } + + $filterQueries = Query::groupByType($queries)['filters']; + + try { + $planFeatures = $dbForProject->find('payments_plan_features', $queries); + $total = $includeTotal ? $dbForProject->count('payments_plan_features', $filterQueries, 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."); + } + + $response->dynamic(new Document([ + 'features' => $planFeatures, + 'total' => $total, + ]), Response::MODEL_PAYMENT_PLAN_FEATURE_LIST); } } diff --git a/src/Appwrite/Platform/Modules/Payments/Http/Plans/Create.php b/src/Appwrite/Platform/Modules/Payments/Http/Plans/Create.php index 1aa4be3a96..f6fd3473ae 100644 --- a/src/Appwrite/Platform/Modules/Payments/Http/Plans/Create.php +++ b/src/Appwrite/Platform/Modules/Payments/Http/Plans/Create.php @@ -8,19 +8,19 @@ use Appwrite\Extend\Exception as ExtendException; use Appwrite\Payments\Provider\ProviderState; use Appwrite\Payments\Provider\Registry; use Appwrite\Platform\Modules\Compute\Base; +use Appwrite\Platform\Modules\Payments\Validator\PricingEntry; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Database\Validator\CustomId; use Appwrite\Utopia\Response; -use Exception; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Query; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; +use Utopia\Validator\ArrayList; use Utopia\Validator\Boolean; -use Utopia\Validator\JSON as JSONValidator; use Utopia\Validator\Text; class Create extends Base @@ -48,7 +48,9 @@ class Create extends Base namespace: 'payments', group: 'plans', name: 'create', - description: 'Create a payment plan', + description: <<param('description', '', new Text(8192, 0), 'Plan description.', true) ->param('isDefault', false, new Boolean(), 'Set as default plan for new users.', true) ->param('isFree', false, new Boolean(), 'Is the plan free.', true) - ->param('pricing', [], new JSONValidator(), 'Pricing configuration array [{priceId,amount,currency,interval}]', true) + ->param('pricing', [], new ArrayList(new PricingEntry(), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Pricing configuration array [{priceId,amount,currency,interval}]', true) ->inject('response') ->inject('dbForPlatform') ->inject('dbForProject') @@ -86,31 +88,21 @@ class Create extends Base Event $queueForEvents, Document $project ) { - // Normalize and validate pricing definitions (ensure user-provided priceIds) - $normalizedPricing = []; + // Check for duplicate priceIds $seenPriceIds = []; foreach ($pricing as $entry) { - if (!is_array($entry)) { - throw new AppwriteException(ExtendException::GENERAL_BAD_REQUEST, 'Invalid pricing entry format'); - } $priceId = (string) ($entry['priceId'] ?? ''); - if ($priceId === '') { - throw new AppwriteException(ExtendException::GENERAL_BAD_REQUEST, 'Each pricing entry must include a priceId'); - } if (isset($seenPriceIds[$priceId])) { throw new AppwriteException(ExtendException::GENERAL_BAD_REQUEST, 'Duplicate priceId detected: ' . $priceId); } $seenPriceIds[$priceId] = true; - $normalizedPricing[] = $entry; } - $pricing = $normalizedPricing; - $document = new Document([ 'planId' => $planId, 'name' => $name, 'description' => $description, - 'pricing' => $normalizedPricing, + 'pricing' => $pricing, 'isDefault' => $isDefault, 'isFree' => $isFree, 'status' => 'active', diff --git a/src/Appwrite/Platform/Modules/Payments/Http/Plans/Delete.php b/src/Appwrite/Platform/Modules/Payments/Http/Plans/Delete.php index cf5916c4f4..76820a1191 100644 --- a/src/Appwrite/Platform/Modules/Payments/Http/Plans/Delete.php +++ b/src/Appwrite/Platform/Modules/Payments/Http/Plans/Delete.php @@ -39,7 +39,9 @@ class Delete extends Base namespace: 'payments', group: 'plans', name: 'delete', - description: 'Delete a payment plan', + description: <<param('description', '', new Text(8192, 0), 'Plan description.', true) ->param('isDefault', false, new Boolean(), 'Set as default plan for new users.', true) ->param('isFree', false, new Boolean(), 'Is the plan free.', true) - ->param('pricing', [], new JSONValidator(), 'Pricing configuration array [{priceId,amount,currency,interval}]', true) + ->param('pricing', [], new ArrayList(new PricingEntry(), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Pricing configuration array [{priceId,amount,currency,interval}]', true) ->inject('response') ->inject('dbForPlatform') ->inject('dbForProject') @@ -101,23 +104,15 @@ class Update extends Base $plan->setAttribute('isDefault', $isDefault); $plan->setAttribute('isFree', $isFree); if (!empty($pricing)) { - $normalizedPricing = []; + // Check for duplicate priceIds $seenPriceIds = []; foreach ($pricing as $entry) { - if (!is_array($entry)) { - throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::GENERAL_BAD_REQUEST, 'Invalid pricing entry format'); - } $priceId = (string) ($entry['priceId'] ?? ''); - if ($priceId === '') { - throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::GENERAL_BAD_REQUEST, 'Each pricing entry must include a priceId'); - } if (isset($seenPriceIds[$priceId])) { throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::GENERAL_BAD_REQUEST, 'Duplicate priceId detected: ' . $priceId); } $seenPriceIds[$priceId] = true; - $normalizedPricing[] = $entry; } - $pricing = $normalizedPricing; $plan->setAttribute('pricing', $pricing); } $plan = $dbForProject->updateDocument('payments_plans', $plan->getId(), $plan); diff --git a/src/Appwrite/Platform/Modules/Payments/Http/Plans/XList.php b/src/Appwrite/Platform/Modules/Payments/Http/Plans/XList.php index c8de1a0226..bcec060120 100644 --- a/src/Appwrite/Platform/Modules/Payments/Http/Plans/XList.php +++ b/src/Appwrite/Platform/Modules/Payments/Http/Plans/XList.php @@ -2,16 +2,22 @@ namespace Appwrite\Platform\Modules\Payments\Http\Plans; +use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Utopia\Database\Validator\Queries\PaymentPlans; use Appwrite\Utopia\Response; use Utopia\Database\Database; use Utopia\Database\Document; +use Utopia\Database\Exception\Order as OrderException; +use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Query; +use Utopia\Database\Validator\Query\Cursor; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; +use Utopia\Validator\Boolean; use Utopia\Validator\Text; class XList extends Base @@ -36,7 +42,9 @@ class XList extends Base namespace: 'payments', group: 'plans', name: 'list', - description: 'List payment plans', + description: <<param('search', '', new Text(256), 'Search term.', true) + ->param('queries', [], new PaymentPlans(), '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(', ', PaymentPlans::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') ->callback($this->action(...)); } public function action( + array $queries, string $search, + bool $includeTotal, Response $response, Database $dbForProject ) { - $filters = []; - if ($search !== '') { - $filters[] = Query::search('search', $search); + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); } - $plans = $dbForProject->find('payments_plans', $filters); - $payload = [ - 'total' => count($plans), - 'plans' => array_map(fn ($d) => $d->getArrayCopy(), $plans) - ]; - $response->dynamic(new Document($payload), Response::MODEL_PAYMENT_PLAN_LIST); + + if (!empty($search)) { + $queries[] = Query::search('search', $search); + } + + /** + * Get cursor document if there was a cursor query, we use array_filter and reset for reference $cursor to $queries + */ + $cursor = \array_filter($queries, function ($query) { + return \in_array($query->getMethod(), [Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE]); + }); + $cursor = reset($cursor); + if ($cursor) { + /** @var Query $cursor */ + + $validator = new Cursor(); + if (!$validator->isValid($cursor)) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); + } + + $planId = $cursor->getValue(); + $cursorDocument = $dbForProject->getDocument('payments_plans', $planId); + + if ($cursorDocument->isEmpty()) { + throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Plan '{$planId}' for the 'cursor' value not found."); + } + + $cursor->setValue($cursorDocument); + } + + $filterQueries = Query::groupByType($queries)['filters']; + + try { + $plans = $dbForProject->find('payments_plans', $queries); + $total = $includeTotal ? $dbForProject->count('payments_plans', $filterQueries, 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."); + } + + $response->dynamic(new Document([ + 'plans' => $plans, + 'total' => $total, + ]), Response::MODEL_PAYMENT_PLAN_LIST); } } diff --git a/src/Appwrite/Platform/Modules/Payments/Http/Providers/Actions/Test/Create.php b/src/Appwrite/Platform/Modules/Payments/Http/Providers/Actions/Test/Create.php index 3fab3b4e7e..ce18e75f95 100644 --- a/src/Appwrite/Platform/Modules/Payments/Http/Providers/Actions/Test/Create.php +++ b/src/Appwrite/Platform/Modules/Payments/Http/Providers/Actions/Test/Create.php @@ -35,7 +35,9 @@ class Create extends Base namespace: 'payments', group: 'providers', name: 'test', - description: 'Test payments provider credentials', + description: <<param('actorType', '', new Text(16, 0), 'Filter by actor type', true) - ->param('actorId', '', new Text(128, 0), 'Filter by actor ID', true) - ->param('status', '', new Text(32, 0), 'Filter by status', true) + ->param('queries', [], new PaymentSubscriptions(), '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(', ', PaymentSubscriptions::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') ->callback($this->action(...)); } public function action( - string $actorType, - string $actorId, - string $status, + array $queries, + string $search, + bool $includeTotal, Response $response, Database $dbForProject ) { - $filters = []; - if ($actorType !== '') { - $filters[] = Query::equal('actorType', [$actorType]); + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); } - if ($actorId !== '') { - $filters[] = Query::equal('actorId', [$actorId]); + + if (!empty($search)) { + $queries[] = Query::search('search', $search); } - if ($status !== '') { - $filters[] = Query::equal('status', [$status]); + + /** + * Get cursor document if there was a cursor query, we use array_filter and reset for reference $cursor to $queries + */ + $cursor = \array_filter($queries, function ($query) { + return \in_array($query->getMethod(), [Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE]); + }); + $cursor = reset($cursor); + if ($cursor) { + /** @var Query $cursor */ + + $validator = new Cursor(); + if (!$validator->isValid($cursor)) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); + } + + $subscriptionId = $cursor->getValue(); + $cursorDocument = $dbForProject->getDocument('payments_subscriptions', $subscriptionId); + + if ($cursorDocument->isEmpty()) { + throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Subscription '{$subscriptionId}' for the 'cursor' value not found."); + } + + $cursor->setValue($cursorDocument); + } + + $filterQueries = Query::groupByType($queries)['filters']; + + try { + $subscriptions = $dbForProject->find('payments_subscriptions', $queries); + $total = $includeTotal ? $dbForProject->count('payments_subscriptions', $filterQueries, 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."); } - $subscriptions = $dbForProject->find('payments_subscriptions', $filters); // Collect unique plan IDs and fetch plans $plansById = []; @@ -96,7 +136,7 @@ class XList extends Base $response->dynamic(new Document([ 'subscriptions' => $subscriptions, - 'total' => count($subscriptions), + 'total' => $total, ]), Response::MODEL_PAYMENT_SUBSCRIPTION_LIST); } } diff --git a/src/Appwrite/Platform/Modules/Payments/Http/Usage/Create.php b/src/Appwrite/Platform/Modules/Payments/Http/Usage/Create.php index 27cb798472..c3d3056318 100644 --- a/src/Appwrite/Platform/Modules/Payments/Http/Usage/Create.php +++ b/src/Appwrite/Platform/Modules/Payments/Http/Usage/Create.php @@ -41,7 +41,9 @@ class Create extends Base namespace: 'payments', group: 'usage', name: 'report', - description: 'Report usage for subscription', + description: <<param('subscriptionId', '', new Text(128, 0), 'Filter by subscription ID', true) - ->param('featureId', '', new Text(128, 0), 'Filter by feature ID', true) + ->param('subscriptionId', '', new Text(128, 0), 'Filter by subscription ID.', true) + ->param('featureId', '', new Text(128, 0), 'Filter by feature ID.', true) + ->param('queries', [], new PaymentUsageEvents(), '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(', ', PaymentUsageEvents::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') ->callback($this->action(...)); @@ -54,20 +66,67 @@ class XList extends Base public function action( string $subscriptionId, string $featureId, + array $queries, + string $search, + bool $includeTotal, Response $response, Database $dbForProject ) { - $filters = []; + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + // Add filters from explicit params if ($subscriptionId !== '') { - $filters[] = Query::equal('subscriptionId', [$subscriptionId]); + $queries[] = Query::equal('subscriptionId', [$subscriptionId]); } if ($featureId !== '') { - $filters[] = Query::equal('featureId', [$featureId]); + $queries[] = Query::equal('featureId', [$featureId]); } - $list = $dbForProject->find('payments_usage_events', $filters); - $response->json([ - 'total' => count($list), - 'events' => array_map(fn ($d) => $d->getArrayCopy(), $list) - ]); + + if (!empty($search)) { + $queries[] = Query::search('search', $search); + } + + /** + * Get cursor document if there was a cursor query, we use array_filter and reset for reference $cursor to $queries + */ + $cursor = \array_filter($queries, function ($query) { + return \in_array($query->getMethod(), [Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE]); + }); + $cursor = reset($cursor); + if ($cursor) { + /** @var Query $cursor */ + + $validator = new Cursor(); + if (!$validator->isValid($cursor)) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); + } + + $eventId = $cursor->getValue(); + $cursorDocument = $dbForProject->getDocument('payments_usage_events', $eventId); + + if ($cursorDocument->isEmpty()) { + throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Usage event '{$eventId}' for the 'cursor' value not found."); + } + + $cursor->setValue($cursorDocument); + } + + $filterQueries = Query::groupByType($queries)['filters']; + + try { + $events = $dbForProject->find('payments_usage_events', $queries); + $total = $includeTotal ? $dbForProject->count('payments_usage_events', $filterQueries, 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."); + } + + $response->dynamic(new Document([ + 'events' => $events, + 'total' => $total, + ]), Response::MODEL_PAYMENT_USAGE_EVENT_LIST); } } diff --git a/src/Appwrite/Platform/Modules/Payments/Http/Usage/Get.php b/src/Appwrite/Platform/Modules/Payments/Http/Usage/Get.php index fd2814e310..3066f96d18 100644 --- a/src/Appwrite/Platform/Modules/Payments/Http/Usage/Get.php +++ b/src/Appwrite/Platform/Modules/Payments/Http/Usage/Get.php @@ -38,7 +38,9 @@ class Get extends Base namespace: 'payments', group: 'usage', name: 'get', - description: 'Get usage summary for subscription', + description: <<description; + } + + /** + * Indicates this validator expects an array + * + * @return bool + */ + public function isArray(): bool + { + return true; + } + + /** + * Validate a feature tier entry for metered billing + * + * @param mixed $value + * @return bool + */ + public function isValid($value): bool + { + // Must be array-like + if (!\is_array($value)) { + $this->description = 'Feature tier must be an array'; + return false; + } + + // Validate upTo - should be present (integer or 'inf') + if (\array_key_exists('upTo', $value)) { + if (!\is_int($value['upTo']) && $value['upTo'] !== 'inf' && $value['upTo'] !== null) { + $this->description = "Key 'upTo' must be an integer, 'inf', or null"; + return false; + } + } + + // Validate unitAmount - should be present (numeric) + if (\array_key_exists('unitAmount', $value)) { + if (!\is_int($value['unitAmount']) && !\is_float($value['unitAmount'])) { + $this->description = "Key 'unitAmount' must be a numeric value"; + return false; + } + } + + // Validate flatAmount - optional (numeric) + if (\array_key_exists('flatAmount', $value)) { + if (!\is_int($value['flatAmount']) && !\is_float($value['flatAmount']) && $value['flatAmount'] !== null) { + $this->description = "Key 'flatAmount' must be a numeric value or null"; + return false; + } + } + + // Validate firstUnit - optional (integer) + if (\array_key_exists('firstUnit', $value)) { + if (!\is_int($value['firstUnit'])) { + $this->description = "Key 'firstUnit' must be an integer"; + return false; + } + } + + // Validate lastUnit - optional (integer or null for infinity) + if (\array_key_exists('lastUnit', $value)) { + if (!\is_int($value['lastUnit']) && $value['lastUnit'] !== null) { + $this->description = "Key 'lastUnit' must be an integer or null"; + return false; + } + } + + return true; + } + + /** + * Get the type of this validator + * + * @return string + */ + public function getType(): string + { + return self::TYPE_OBJECT; + } +} diff --git a/src/Appwrite/Platform/Modules/Payments/Validator/PricingEntry.php b/src/Appwrite/Platform/Modules/Payments/Validator/PricingEntry.php new file mode 100644 index 0000000000..7460e018b5 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Payments/Validator/PricingEntry.php @@ -0,0 +1,92 @@ +description; + } + + /** + * Indicates this validator expects an array + * + * @return bool + */ + public function isArray(): bool + { + return true; + } + + /** + * Validate a pricing entry + * + * @param mixed $value + * @return bool + */ + public function isValid($value): bool + { + // Must be array-like + if (!\is_array($value)) { + $this->description = 'Pricing entry must be an array'; + return false; + } + + // Validate priceId - required, non-empty string + if (!\array_key_exists('priceId', $value)) { + $this->description = 'Missing required key: priceId'; + return false; + } + + if (!\is_string($value['priceId']) || \trim($value['priceId']) === '') { + $this->description = "Key 'priceId' must be a non-empty string"; + return false; + } + + // Validate amount - should be present (integer) + if (\array_key_exists('amount', $value)) { + if (!\is_int($value['amount']) && !\is_float($value['amount'])) { + $this->description = "Key 'amount' must be a numeric value"; + return false; + } + } + + // Validate currency - should be present (string) + if (\array_key_exists('currency', $value)) { + if (!\is_string($value['currency'])) { + $this->description = "Key 'currency' must be a string"; + return false; + } + } + + // Validate interval - should be present (string) + if (\array_key_exists('interval', $value)) { + if (!\is_string($value['interval'])) { + $this->description = "Key 'interval' must be a string"; + return false; + } + } + + return true; + } + + /** + * Get the type of this validator + * + * @return string + */ + public function getType(): string + { + return self::TYPE_OBJECT; + } +} diff --git a/src/Appwrite/Platform/Modules/Payments/Validator/ProviderConfig.php b/src/Appwrite/Platform/Modules/Payments/Validator/ProviderConfig.php new file mode 100644 index 0000000000..80155faa08 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Payments/Validator/ProviderConfig.php @@ -0,0 +1,124 @@ +description; + } + + /** + * Indicates this validator expects an array + * + * @return bool + */ + public function isArray(): bool + { + return true; + } + + /** + * Validate a provider configuration object + * + * @param mixed $value + * @return bool + */ + public function isValid($value): bool + { + // Must be array-like + if (!\is_array($value)) { + $this->description = 'Provider configuration must be an array'; + return false; + } + + // Must have 'providers' key + if (!\array_key_exists('providers', $value)) { + $this->description = "Missing required key: providers"; + return false; + } + + if (!\is_array($value['providers'])) { + $this->description = "Key 'providers' must be an array"; + return false; + } + + // Validate each provider entry + foreach ($value['providers'] as $providerId => $providerConfig) { + if (!\is_string($providerId) || \trim($providerId) === '') { + $this->description = 'Provider ID must be a non-empty string'; + return false; + } + + if (!\is_array($providerConfig)) { + $this->description = "Provider '{$providerId}' configuration must be an array"; + return false; + } + + // Validate secretKey - required, non-empty string + if (!\array_key_exists('secretKey', $providerConfig)) { + $this->description = "Missing required key 'secretKey' for provider '{$providerId}'"; + return false; + } + + if (!\is_string($providerConfig['secretKey']) || \trim($providerConfig['secretKey']) === '') { + $this->description = "Key 'secretKey' for provider '{$providerId}' must be a non-empty string"; + return false; + } + + // Validate optional 'enabled' key if present + if (\array_key_exists('enabled', $providerConfig)) { + if (!\is_bool($providerConfig['enabled'])) { + $this->description = "Key 'enabled' for provider '{$providerId}' must be a boolean"; + return false; + } + } + + // Validate optional 'defaults' key if present + if (\array_key_exists('defaults', $providerConfig)) { + if (!\is_array($providerConfig['defaults'])) { + $this->description = "Key 'defaults' for provider '{$providerId}' must be an array"; + return false; + } + } + } + + // Validate optional top-level 'enabled' key if present + if (\array_key_exists('enabled', $value)) { + if (!\is_bool($value['enabled'])) { + $this->description = "Key 'enabled' must be a boolean"; + return false; + } + } + + // Validate optional top-level 'defaults' key if present + if (\array_key_exists('defaults', $value)) { + if (!\is_array($value['defaults'])) { + $this->description = "Key 'defaults' must be an array"; + return false; + } + } + + return true; + } + + /** + * Get the type of this validator + * + * @return string + */ + public function getType(): string + { + return self::TYPE_OBJECT; + } +} diff --git a/src/Appwrite/Platform/Tasks/SchedulePaymentsUsage.php b/src/Appwrite/Platform/Tasks/SchedulePaymentsUsage.php index e7d13952e1..b9dd028020 100644 --- a/src/Appwrite/Platform/Tasks/SchedulePaymentsUsage.php +++ b/src/Appwrite/Platform/Tasks/SchedulePaymentsUsage.php @@ -10,7 +10,6 @@ use Utopia\Database\Document; use Utopia\Database\Query; use Utopia\Database\Validator\Authorization; use Utopia\Platform\Action; -use Utopia\Queue\Publisher; use Utopia\System\System; class SchedulePaymentsUsage extends Action diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/PaymentFeatures.php b/src/Appwrite/Utopia/Database/Validator/Queries/PaymentFeatures.php new file mode 100644 index 0000000000..367923ff53 --- /dev/null +++ b/src/Appwrite/Utopia/Database/Validator/Queries/PaymentFeatures.php @@ -0,0 +1,22 @@ +setModel(new \Appwrite\Utopia\Response\Model\PaymentFeature()) ->setModel(new \Appwrite\Utopia\Response\Model\PaymentSubscription()) ->setModel(new \Appwrite\Utopia\Response\Model\PaymentProviderConfig()) + ->setModel(new \Appwrite\Utopia\Response\Model\PaymentPlanFeature()) + ->setModel(new \Appwrite\Utopia\Response\Model\PaymentInvoice()) + ->setModel(new \Appwrite\Utopia\Response\Model\PaymentUsageEvent()) ->setModel(new BaseList('Payment Subscription List', self::MODEL_PAYMENT_SUBSCRIPTION_LIST, 'subscriptions', self::MODEL_PAYMENT_SUBSCRIPTION, true, false)) ->setModel(new BaseList('Payment Plan List', self::MODEL_PAYMENT_PLAN_LIST, 'plans', self::MODEL_PAYMENT_PLAN, true, false)) ->setModel(new BaseList('Payment Feature List', self::MODEL_PAYMENT_FEATURE_LIST, 'features', self::MODEL_PAYMENT_FEATURE, true, false)) + ->setModel(new BaseList('Payment Plan Feature List', self::MODEL_PAYMENT_PLAN_FEATURE_LIST, 'features', self::MODEL_PAYMENT_PLAN_FEATURE, true, false)) + ->setModel(new BaseList('Payment Invoice List', self::MODEL_PAYMENT_INVOICE_LIST, 'invoices', self::MODEL_PAYMENT_INVOICE, true, false)) + ->setModel(new BaseList('Payment Usage Event List', self::MODEL_PAYMENT_USAGE_EVENT_LIST, 'events', self::MODEL_PAYMENT_USAGE_EVENT, true, false)) ->setModel(new Installation()) ->setModel(new ProviderRepository()) ->setModel(new ProviderRepositoryFramework()) diff --git a/src/Appwrite/Utopia/Response/Model/PaymentInvoice.php b/src/Appwrite/Utopia/Response/Model/PaymentInvoice.php new file mode 100644 index 0000000000..5f28e4e50c --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/PaymentInvoice.php @@ -0,0 +1,78 @@ +addRule('invoiceId', [ + 'type' => self::TYPE_STRING, + 'description' => 'Invoice ID.', + 'default' => '', + 'example' => 'inv_1234567890', + ]) + ->addRule('subscriptionId', [ + 'type' => self::TYPE_STRING, + 'description' => 'Subscription ID.', + 'default' => '', + 'example' => 'sub_1234567890', + ]) + ->addRule('amount', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Invoice amount in cents.', + 'default' => 0, + 'example' => 2999, + ]) + ->addRule('currency', [ + 'type' => self::TYPE_STRING, + 'description' => 'Currency code.', + 'default' => '', + 'example' => 'usd', + ]) + ->addRule('status', [ + 'type' => self::TYPE_STRING, + 'description' => 'Invoice status.', + 'default' => '', + 'example' => 'paid', + ]) + ->addRule('createdAt', [ + 'type' => self::TYPE_DATETIME, + 'description' => 'Invoice creation date.', + 'default' => '', + 'example' => self::TYPE_DATETIME_EXAMPLE, + ]) + ->addRule('paidAt', [ + 'type' => self::TYPE_DATETIME, + 'description' => 'Invoice payment date.', + 'default' => '', + 'example' => self::TYPE_DATETIME_EXAMPLE, + ]) + ->addRule('invoiceUrl', [ + 'type' => self::TYPE_STRING, + 'description' => 'URL to view the invoice.', + 'default' => '', + 'example' => 'https://invoice.stripe.com/i/acct_123/inv_456', + ]) + ->addRule('metadata', [ + 'type' => self::TYPE_JSON, + 'description' => 'Additional metadata.', + 'default' => [], + 'example' => [], + ]); + } + + public function getName(): string + { + return 'PaymentInvoice'; + } + + public function getType(): string + { + return Response::MODEL_PAYMENT_INVOICE; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/PaymentPlanFeature.php b/src/Appwrite/Utopia/Response/Model/PaymentPlanFeature.php new file mode 100644 index 0000000000..7637f5839e --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/PaymentPlanFeature.php @@ -0,0 +1,102 @@ +addRule('planId', [ + 'type' => self::TYPE_STRING, + 'description' => 'Plan ID.', + 'default' => '', + 'example' => 'pro', + ]) + ->addRule('featureId', [ + 'type' => self::TYPE_STRING, + 'description' => 'Feature ID.', + 'default' => '', + 'example' => 'seats', + ]) + ->addRule('type', [ + 'type' => self::TYPE_STRING, + 'description' => 'Feature type (boolean, metered).', + 'default' => 'boolean', + 'example' => 'metered', + ]) + ->addRule('enabled', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether the feature is enabled for this plan.', + 'default' => true, + 'example' => true, + ]) + ->addRule('currency', [ + 'type' => self::TYPE_STRING, + 'description' => 'Currency code for metered features.', + 'default' => '', + 'example' => 'usd', + ]) + ->addRule('interval', [ + 'type' => self::TYPE_STRING, + 'description' => 'Billing interval for metered features.', + 'default' => '', + 'example' => 'month', + ]) + ->addRule('includedUnits', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Number of units included in the plan.', + 'default' => 0, + 'example' => 100, + ]) + ->addRule('tiersMode', [ + 'type' => self::TYPE_STRING, + 'description' => 'Pricing tiers mode (graduated or volume).', + 'default' => '', + 'example' => 'graduated', + ]) + ->addRule('tiers', [ + 'type' => self::TYPE_JSON, + 'description' => 'Pricing tiers configuration.', + 'default' => [], + 'example' => [], + ]) + ->addRule('usageCap', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Maximum usage cap.', + 'default' => null, + 'example' => 1000, + ]) + ->addRule('overagePrice', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Price per unit for overage usage.', + 'default' => null, + 'example' => 100, + ]) + ->addRule('providers', [ + 'type' => self::TYPE_JSON, + 'description' => 'Provider-specific metadata.', + 'default' => [], + 'example' => [], + ]) + ->addRule('metadata', [ + 'type' => self::TYPE_JSON, + 'description' => 'Additional metadata.', + 'default' => [], + 'example' => [], + ]); + } + + public function getName(): string + { + return 'PaymentPlanFeature'; + } + + public function getType(): string + { + return Response::MODEL_PAYMENT_PLAN_FEATURE; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/PaymentUsageEvent.php b/src/Appwrite/Utopia/Response/Model/PaymentUsageEvent.php new file mode 100644 index 0000000000..990b643598 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/PaymentUsageEvent.php @@ -0,0 +1,84 @@ +addRule('subscriptionId', [ + 'type' => self::TYPE_STRING, + 'description' => 'Subscription ID.', + 'default' => '', + 'example' => 'sub_abc123', + ]) + ->addRule('actorType', [ + 'type' => self::TYPE_STRING, + 'description' => 'Actor type (user or team).', + 'default' => 'user', + 'example' => 'user', + ]) + ->addRule('actorId', [ + 'type' => self::TYPE_STRING, + 'description' => 'Actor ID.', + 'default' => '', + 'example' => 'user_123', + ]) + ->addRule('planId', [ + 'type' => self::TYPE_STRING, + 'description' => 'Plan ID.', + 'default' => '', + 'example' => 'pro', + ]) + ->addRule('featureId', [ + 'type' => self::TYPE_STRING, + 'description' => 'Feature ID.', + 'default' => '', + 'example' => 'api_calls', + ]) + ->addRule('quantity', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Usage quantity.', + 'default' => 0, + 'example' => 100, + ]) + ->addRule('timestamp', [ + 'type' => self::TYPE_DATETIME, + 'description' => 'Event timestamp.', + 'default' => '', + 'example' => self::TYPE_DATETIME_EXAMPLE, + ]) + ->addRule('providerSyncState', [ + 'type' => self::TYPE_STRING, + 'description' => 'Provider sync state.', + 'default' => 'pending', + 'example' => 'synced', + ]) + ->addRule('providerEventId', [ + 'type' => self::TYPE_STRING, + 'description' => 'Provider event ID.', + 'default' => '', + 'example' => 'evt_123', + ]) + ->addRule('metadata', [ + 'type' => self::TYPE_JSON, + 'description' => 'Additional metadata.', + 'default' => [], + 'example' => [], + ]); + } + + public function getName(): string + { + return 'PaymentUsageEvent'; + } + + public function getType(): string + { + return Response::MODEL_PAYMENT_USAGE_EVENT; + } +} diff --git a/tests/e2e/Services/Payments/PaymentsBase.php b/tests/e2e/Services/Payments/PaymentsBase.php new file mode 100644 index 0000000000..cf52c8455d --- /dev/null +++ b/tests/e2e/Services/Payments/PaymentsBase.php @@ -0,0 +1,392 @@ + 'test-plan-' . uniqid(), + 'name' => 'Test Plan', + 'description' => 'Test plan description', + 'isDefault' => false, + 'isFree' => false, + 'pricing' => [] + ]; + $params = array_merge($defaults, $params); + + $plan = $this->client->call(Client::METHOD_POST, '/payments/plans', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), $params); + + $this->assertEquals(201, $plan['headers']['status-code'], 'Setup plan failed with status code: ' . $plan['headers']['status-code'] . ' and response: ' . json_encode($plan['body'], JSON_PRETTY_PRINT)); + + return $plan['body']; + } + + protected function cleanupPlan(string $planId): void + { + $plan = $this->client->call(Client::METHOD_DELETE, '/payments/plans/' . $planId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + + $this->assertEquals(204, $plan['headers']['status-code']); + } + + protected function createPlan(array $params = []): array + { + $plan = $this->client->call(Client::METHOD_POST, '/payments/plans', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), $params); + + return $plan; + } + + protected function getPlan(string $planId): array + { + $plan = $this->client->call(Client::METHOD_GET, '/payments/plans/' . $planId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + return $plan; + } + + protected function listPlans(array $params = []): array + { + $plans = $this->client->call(Client::METHOD_GET, '/payments/plans', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), $params); + + return $plans; + } + + protected function updatePlan(string $planId, array $params = []): array + { + $plan = $this->client->call(Client::METHOD_PUT, '/payments/plans/' . $planId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), $params); + + return $plan; + } + + protected function deletePlan(string $planId): array + { + $plan = $this->client->call(Client::METHOD_DELETE, '/payments/plans/' . $planId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + return $plan; + } + + protected function setupFeature(array $params = []): array + { + $defaults = [ + 'featureId' => 'test-feature-' . uniqid(), + 'name' => 'Test Feature', + 'type' => 'boolean', + 'description' => 'Test feature description' + ]; + $params = array_merge($defaults, $params); + + $feature = $this->client->call(Client::METHOD_POST, '/payments/features', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), $params); + + $this->assertEquals(201, $feature['headers']['status-code'], 'Setup feature failed with status code: ' . $feature['headers']['status-code'] . ' and response: ' . json_encode($feature['body'], JSON_PRETTY_PRINT)); + + return $feature['body']; + } + + protected function cleanupFeature(string $featureId): void + { + $feature = $this->client->call(Client::METHOD_DELETE, '/payments/features/' . $featureId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + + $this->assertEquals(204, $feature['headers']['status-code']); + } + + protected function createFeature(array $params = []): array + { + $feature = $this->client->call(Client::METHOD_POST, '/payments/features', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), $params); + + return $feature; + } + + protected function getFeature(string $featureId): array + { + $feature = $this->client->call(Client::METHOD_GET, '/payments/features/' . $featureId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + return $feature; + } + + protected function listFeatures(array $params = []): array + { + $features = $this->client->call(Client::METHOD_GET, '/payments/features', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), $params); + + return $features; + } + + protected function updateFeature(string $featureId, array $params = []): array + { + $feature = $this->client->call(Client::METHOD_PUT, '/payments/features/' . $featureId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), $params); + + return $feature; + } + + protected function deleteFeature(string $featureId): array + { + $feature = $this->client->call(Client::METHOD_DELETE, '/payments/features/' . $featureId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + return $feature; + } + + protected function setupSubscription(string $planId, array $params = []): array + { + $defaults = [ + 'actorType' => 'user', + 'actorId' => $this->getUser()['$id'], + 'planId' => $planId, + 'successUrl' => 'https://example.com/success', + 'cancelUrl' => 'https://example.com/cancel' + ]; + $params = array_merge($defaults, $params); + + $subscription = $this->client->call(Client::METHOD_POST, '/payments/subscriptions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), $params); + + $this->assertEquals(201, $subscription['headers']['status-code'], 'Setup subscription failed with status code: ' . $subscription['headers']['status-code'] . ' and response: ' . json_encode($subscription['body'], JSON_PRETTY_PRINT)); + + return $subscription['body']; + } + + protected function cleanupSubscription(string $subscriptionId): void + { + $subscription = $this->client->call(Client::METHOD_POST, '/payments/subscriptions/' . $subscriptionId . '/cancel', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'endAtPeriodEnd' => false + ]); + + $this->assertEquals(204, $subscription['headers']['status-code']); + } + + protected function createSubscription(array $params = []): array + { + $subscription = $this->client->call(Client::METHOD_POST, '/payments/subscriptions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), $params); + + return $subscription; + } + + protected function getSubscription(string $subscriptionId): array + { + $subscription = $this->client->call(Client::METHOD_GET, '/payments/subscriptions/' . $subscriptionId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + return $subscription; + } + + protected function listSubscriptions(array $params = []): array + { + $subscriptions = $this->client->call(Client::METHOD_GET, '/payments/subscriptions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), $params); + + return $subscriptions; + } + + protected function updateSubscription(string $subscriptionId, array $params = []): array + { + $subscription = $this->client->call(Client::METHOD_PUT, '/payments/subscriptions/' . $subscriptionId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), $params); + + return $subscription; + } + + protected function cancelSubscription(string $subscriptionId, array $params = []): array + { + $subscription = $this->client->call(Client::METHOD_POST, '/payments/subscriptions/' . $subscriptionId . '/cancel', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), $params); + + return $subscription; + } + + protected function resumeSubscription(string $subscriptionId): array + { + $subscription = $this->client->call(Client::METHOD_POST, '/payments/subscriptions/' . $subscriptionId . '/resume', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + return $subscription; + } + + protected function getSubscriptionPortal(string $subscriptionId, array $params = []): array + { + $portal = $this->client->call(Client::METHOD_GET, '/payments/subscriptions/' . $subscriptionId . '/portal', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), $params); + + return $portal; + } + + protected function previewUpgrade(string $subscriptionId, array $params = []): array + { + $preview = $this->client->call(Client::METHOD_GET, '/payments/subscriptions/' . $subscriptionId . '/preview-upgrade', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), $params); + + return $preview; + } + + protected function listInvoices(array $params = []): array + { + $invoices = $this->client->call(Client::METHOD_GET, '/payments/invoices', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), $params); + + return $invoices; + } + + protected function getUsage(array $params = []): array + { + $usage = $this->client->call(Client::METHOD_GET, '/payments/usage', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), $params); + + return $usage; + } + + protected function createUsage(array $params = []): array + { + $usage = $this->client->call(Client::METHOD_POST, '/payments/usage', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), $params); + + return $usage; + } + + protected function listUsageEvents(array $params = []): array + { + $events = $this->client->call(Client::METHOD_GET, '/payments/usage/events', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), $params); + + return $events; + } + + protected function getProvider(string $providerId): array + { + $provider = $this->client->call(Client::METHOD_GET, '/payments/providers/' . $providerId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + return $provider; + } + + protected function updateProvider(string $providerId, array $params = []): array + { + $provider = $this->client->call(Client::METHOD_PUT, '/payments/providers/' . $providerId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), $params); + + return $provider; + } + + protected function assignPlanFeature(string $planId, array $params = []): array + { + $planFeature = $this->client->call(Client::METHOD_POST, '/payments/plans/' . $planId . '/features', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), $params); + + return $planFeature; + } + + protected function listPlanFeatures(string $planId, array $params = []): array + { + $planFeatures = $this->client->call(Client::METHOD_GET, '/payments/plans/' . $planId . '/features', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), $params); + + return $planFeatures; + } + + protected function removePlanFeature(string $planId, string $featureId): array + { + $planFeature = $this->client->call(Client::METHOD_DELETE, '/payments/plans/' . $planId . '/features/' . $featureId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + return $planFeature; + } + + protected function getActorFeatures(array $params = []): array + { + $actorFeatures = $this->client->call(Client::METHOD_GET, '/payments/actor-features', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), $params); + + return $actorFeatures; + } +} diff --git a/tests/e2e/Services/Payments/PaymentsConsoleClientTest.php b/tests/e2e/Services/Payments/PaymentsConsoleClientTest.php new file mode 100644 index 0000000000..2a3a5a2727 --- /dev/null +++ b/tests/e2e/Services/Payments/PaymentsConsoleClientTest.php @@ -0,0 +1,17 @@ +