diff --git a/Dockerfile b/Dockerfile index d097edf0ca..0531bbb5d6 100755 --- a/Dockerfile +++ b/Dockerfile @@ -88,7 +88,9 @@ RUN chmod +x /usr/local/bin/doctor && \ chmod +x /usr/local/bin/worker-webhooks && \ chmod +x /usr/local/bin/worker-stats-usage && \ chmod +x /usr/local/bin/stats-resources && \ - chmod +x /usr/local/bin/worker-stats-resources + chmod +x /usr/local/bin/worker-stats-resources && \ + chmod +x /usr/local/bin/worker-payments-usage && \ + chmod +x /usr/local/bin/schedule-payments-usage # Letsencrypt Permissions RUN mkdir -p /etc/letsencrypt/live/ && chmod -Rf 755 /etc/letsencrypt/live/ diff --git a/app/cli.php b/app/cli.php index 71b6464cb9..c34310fcdb 100644 --- a/app/cli.php +++ b/app/cli.php @@ -5,6 +5,7 @@ require_once __DIR__ . '/init.php'; use Appwrite\Event\Certificate; use Appwrite\Event\Delete; use Appwrite\Event\Func; +use Appwrite\Event\PaymentsUsage; use Appwrite\Event\StatsResources; use Appwrite\Event\StatsUsage; use Appwrite\Platform\Appwrite; @@ -226,6 +227,9 @@ CLI::setResource('queueForDeletes', function (Publisher $publisher) { CLI::setResource('queueForCertificates', function (Publisher $publisher) { return new Certificate($publisher); }, ['publisher']); +CLI::setResource('queueForPaymentsUsage', function (Publisher $publisher) { + return new PaymentsUsage($publisher); +}, ['publisher']); CLI::setResource('logError', function (Registry $register) { return function (Throwable $error, string $namespace, string $action) use ($register) { Console::error('[Error] Timestamp: ' . date('c', time())); diff --git a/app/config/errors.php b/app/config/errors.php index e9c3894f53..2467729a4f 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -1329,4 +1329,46 @@ return [ 'description' => 'Target has an invalid provider type.', 'code' => 400, ], + + /** Payments */ + Exception::PAYMENT_PLAN_NOT_FOUND => [ + 'name' => Exception::PAYMENT_PLAN_NOT_FOUND, + 'description' => 'Payment plan with the requested ID could not be found.', + 'code' => 404, + ], + Exception::PAYMENT_PLAN_ALREADY_EXISTS => [ + 'name' => Exception::PAYMENT_PLAN_ALREADY_EXISTS, + 'description' => 'Payment plan with the requested ID already exists.', + 'code' => 409, + ], + Exception::PAYMENT_SUBSCRIPTION_NOT_FOUND => [ + 'name' => Exception::PAYMENT_SUBSCRIPTION_NOT_FOUND, + 'description' => 'Payment subscription with the requested ID could not be found.', + 'code' => 404, + ], + Exception::PAYMENT_SUBSCRIPTION_ALREADY_EXISTS => [ + 'name' => Exception::PAYMENT_SUBSCRIPTION_ALREADY_EXISTS, + 'description' => 'Payment subscription already exists for this actor.', + 'code' => 409, + ], + Exception::PAYMENT_PROVIDER_NOT_CONFIGURED => [ + 'name' => Exception::PAYMENT_PROVIDER_NOT_CONFIGURED, + 'description' => 'No payment provider has been configured for this project.', + 'code' => 400, + ], + Exception::PAYMENT_PROVIDER_ALREADY_CONFIGURED => [ + 'name' => Exception::PAYMENT_PROVIDER_ALREADY_CONFIGURED, + 'description' => 'Payment provider is already configured. Disconnect the existing provider before configuring a new one.', + 'code' => 409, + ], + Exception::PAYMENT_WEBHOOK_FAILED => [ + 'name' => Exception::PAYMENT_WEBHOOK_FAILED, + 'description' => 'Failed to create payment provider webhook.', + 'code' => 500, + ], + Exception::PAYMENT_FEATURE_NOT_FOUND => [ + 'name' => Exception::PAYMENT_FEATURE_NOT_FOUND, + 'description' => 'Payment feature with the requested ID could not be found.', + 'code' => 404, + ], ]; diff --git a/app/worker.php b/app/worker.php index 60f44ab33f..0e8c71f22b 100644 --- a/app/worker.php +++ b/app/worker.php @@ -378,6 +378,10 @@ Server::setResource('plan', function (array $plan = []) { return []; }); +Server::setResource('registryPayments', function (Registry $register) { + return $register->get('registryPayments'); +}, ['register']); + Server::setResource('certificates', function () { $email = System::getEnv('_APP_EMAIL_CERTIFICATES', System::getEnv('_APP_SYSTEM_SECURITY_EMAIL_ADDRESS')); if (empty($email)) { diff --git a/bin/schedule-payments-usage b/bin/schedule-payments-usage new file mode 100755 index 0000000000..15ec2f774a --- /dev/null +++ b/bin/schedule-payments-usage @@ -0,0 +1,3 @@ +#!/bin/sh + +php /usr/src/code/app/cli.php schedule-payments-usage $@ diff --git a/bin/worker-payments-usage b/bin/worker-payments-usage new file mode 100755 index 0000000000..cf2392e1d0 --- /dev/null +++ b/bin/worker-payments-usage @@ -0,0 +1,3 @@ +#!/bin/sh + +php /usr/src/code/app/worker.php payments-usage-sync $@ diff --git a/docker-compose.yml b/docker-compose.yml index 9247124004..491fde0b38 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -866,6 +866,65 @@ services: - _APP_USAGE_AGGREGATION_INTERVAL - _APP_DATABASE_SHARED_TABLES + appwrite-worker-payments-usage: + entrypoint: worker-payments-usage + <<: *x-logging + container_name: appwrite-worker-payments-usage + image: appwrite-dev + networks: + - appwrite + volumes: + - ./app:/usr/src/code/app + - ./src:/usr/src/code/src + depends_on: + - redis + - mariadb + environment: + - _APP_ENV + - _APP_WORKER_PER_CORE + - _APP_OPENSSL_KEY_V1 + - _APP_DB_HOST + - _APP_DB_PORT + - _APP_DB_SCHEMA + - _APP_DB_USER + - _APP_DB_PASS + - _APP_REDIS_HOST + - _APP_REDIS_PORT + - _APP_REDIS_USER + - _APP_REDIS_PASS + - _APP_LOGGING_CONFIG + - _APP_DATABASE_SHARED_TABLES + + appwrite-task-scheduler-payments-usage: + entrypoint: schedule-payments-usage + <<: *x-logging + container_name: appwrite-task-scheduler-payments-usage + image: appwrite-dev + networks: + - appwrite + volumes: + - ./app:/usr/src/code/app + - ./src:/usr/src/code/src + depends_on: + - mariadb + - redis + environment: + - _APP_ENV + - _APP_WORKER_PER_CORE + - _APP_OPENSSL_KEY_V1 + - _APP_REDIS_HOST + - _APP_REDIS_PORT + - _APP_REDIS_USER + - _APP_REDIS_PASS + - _APP_DB_HOST + - _APP_DB_PORT + - _APP_DB_SCHEMA + - _APP_DB_USER + - _APP_DB_PASS + - _APP_LOGGING_CONFIG + - _APP_DATABASE_SHARED_TABLES + - _APP_PAYMENTS_USAGE_SYNC_INTERVAL + appwrite-task-scheduler-functions: entrypoint: schedule-functions <<: *x-logging diff --git a/src/Appwrite/Event/Event.php b/src/Appwrite/Event/Event.php index 16fe76bf8a..eeb530f007 100644 --- a/src/Appwrite/Event/Event.php +++ b/src/Appwrite/Event/Event.php @@ -45,6 +45,9 @@ class Event public const MIGRATIONS_QUEUE_NAME = 'v1-migrations'; public const MIGRATIONS_CLASS_NAME = 'MigrationsV1'; + public const PAYMENTS_USAGE_QUEUE_NAME = 'v1-payments-usage-sync'; + public const PAYMENTS_USAGE_CLASS_NAME = 'PaymentsUsageSyncV1'; + protected string $queue = ''; protected string $class = ''; protected string $event = ''; diff --git a/src/Appwrite/Event/PaymentsUsage.php b/src/Appwrite/Event/PaymentsUsage.php new file mode 100644 index 0000000000..3ea0bfd101 --- /dev/null +++ b/src/Appwrite/Event/PaymentsUsage.php @@ -0,0 +1,29 @@ +setQueue(Event::PAYMENTS_USAGE_QUEUE_NAME) + ->setClass(Event::PAYMENTS_USAGE_CLASS_NAME); + } + + /** + * Prepare the payload for the event + * + * @return array + */ + protected function preparePayload(): array + { + return [ + 'project' => $this->getProject(), + ]; + } +} diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index 5b44a623df..ad87fb4836 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -379,6 +379,16 @@ class Exception extends \Exception public const string TOKEN_EXPIRED = 'token_expired'; public const string TOKEN_RESOURCE_TYPE_INVALID = 'token_resource_type_invalid'; + /** Payments */ + public const string PAYMENT_PLAN_NOT_FOUND = 'payment_plan_not_found'; + public const string PAYMENT_PLAN_ALREADY_EXISTS = 'payment_plan_already_exists'; + public const string PAYMENT_SUBSCRIPTION_NOT_FOUND = 'payment_subscription_not_found'; + public const string PAYMENT_SUBSCRIPTION_ALREADY_EXISTS = 'payment_subscription_already_exists'; + public const string PAYMENT_PROVIDER_NOT_CONFIGURED = 'payment_provider_not_configured'; + public const string PAYMENT_PROVIDER_ALREADY_CONFIGURED = 'payment_provider_already_configured'; + public const string PAYMENT_WEBHOOK_FAILED = 'payment_webhook_failed'; + public const string PAYMENT_FEATURE_NOT_FOUND = 'payment_feature_not_found'; + protected string $type = ''; protected array $errors = []; protected bool $publish; @@ -387,9 +397,9 @@ class Exception extends \Exception public function __construct( string $type = Exception::GENERAL_UNKNOWN, - string $message = null, - int|string $code = null, - \Throwable $previous = null, + ?string $message = null, + int|string|null $code = null, + ?\Throwable $previous = null, ?string $view = null ) { $this->errors = Config::getParam('errors'); diff --git a/src/Appwrite/Payments/Provider/Adapter.php b/src/Appwrite/Payments/Provider/Adapter.php index f3b30d3c19..d2bd3d85c4 100644 --- a/src/Appwrite/Payments/Provider/Adapter.php +++ b/src/Appwrite/Payments/Provider/Adapter.php @@ -30,6 +30,13 @@ interface Adapter public function createPortalSession(Document $actor, ProviderState $state, array $options = []): ProviderPortalSession; + /** + * @return ProviderInvoice[] + */ + public function listInvoices(ProviderSubscriptionRef $subscription, ProviderState $state, int $limit = 25, int $offset = 0): array; + + public function previewProration(ProviderSubscriptionRef $subscription, string $newPriceId, ProviderState $state): ProviderProrationPreview; + public function reportUsage(ProviderSubscriptionRef $subscription, string $featureId, int $quantity, \DateTimeInterface $timestamp, ProviderState $state): void; public function syncUsage(ProviderSubscriptionRef $subscription, ProviderState $state): ProviderUsageReport; diff --git a/src/Appwrite/Payments/Provider/ProviderInvoice.php b/src/Appwrite/Payments/Provider/ProviderInvoice.php new file mode 100644 index 0000000000..8d93eef7c6 --- /dev/null +++ b/src/Appwrite/Payments/Provider/ProviderInvoice.php @@ -0,0 +1,19 @@ +getId(); - $endpointResponse = $this->request($apiKey, 'POST', '/webhook_endpoints', [ - '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 Payments Webhook for Project ' . $project->getId() - ]); + + try { + $endpointResponse = $this->request($apiKey, 'POST', '/webhook_endpoints', [ + '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 Payments Webhook for Project ' . $project->getId() + ]); + } catch (\Throwable $e) { + throw new Exception(Exception::PAYMENT_WEBHOOK_FAILED, 'Failed to create Stripe webhook: ' . $e->getMessage()); + } $endpointData = $this->decodeResponse($endpointResponse); + $webhookEndpointId = (string) ($endpointData['id'] ?? ''); + $webhookSecret = (string) ($endpointData['secret'] ?? ''); + + if ($webhookEndpointId === '' || $webhookSecret === '') { + throw new Exception(Exception::PAYMENT_WEBHOOK_FAILED, 'Stripe webhook creation failed: missing endpoint ID or secret'); + } + $meta = [ 'currency' => $account['default_currency'] ?? 'usd', - 'webhookEndpointId' => $endpointData['id'] ?? null, - 'webhookSecret' => $endpointData['secret'] ?? null, + 'webhookEndpointId' => $webhookEndpointId, + 'webhookSecret' => $webhookSecret, ]; return new ProviderState($this->getIdentifier(), $config, $meta); } @@ -474,14 +487,28 @@ class StripeAdapter implements Adapter $successUrl = (string) ($options['successUrl'] ?? ''); $cancelUrl = (string) ($options['cancelUrl'] ?? ''); $priceId = (string) ($planContext['priceId'] ?? ''); + $meteredPriceIds = (array) ($planContext['meteredPriceIds'] ?? []); $customerId = $this->ensureCustomer($apiKey, $actor); + + // Build line items: base subscription price + metered feature prices + $lineItems = [ + ['price' => $priceId, 'quantity' => 1] + ]; + + // Add metered prices (no quantity for metered/usage-based prices) + foreach ($meteredPriceIds as $meteredPriceId) { + if (!empty($meteredPriceId)) { + $lineItems[] = ['price' => (string) $meteredPriceId]; + } + } + $params = [ 'mode' => 'subscription', - 'line_items' => [ [ 'price' => $priceId, 'quantity' => 1 ] ], + 'line_items' => $lineItems, 'success_url' => $successUrl, 'cancel_url' => $cancelUrl, 'client_reference_id' => $actor->getId(), - 'metadata' => [ 'project_id' => $this->project->getId(), 'actor_id' => $actor->getId() ] + 'metadata' => ['project_id' => $this->project->getId(), 'actor_id' => $actor->getId()] ]; if ($customerId !== '') { $params['customer'] = $customerId; @@ -509,9 +536,164 @@ class StripeAdapter implements Adapter return new ProviderPortalSession(url: (string) ($sessionData['url'] ?? '')); } + /** + * @return ProviderInvoice[] + */ + public function listInvoices(ProviderSubscriptionRef $subscription, ProviderState $state, int $limit = 25, int $offset = 0): array + { + $apiKey = (string) ($state->config['secretKey'] ?? ''); + $stripeSubId = (string) $subscription->externalSubscriptionId; + + if ($stripeSubId === '') { + return []; + } + + // Fetch invoices from Stripe + $params = [ + 'subscription' => $stripeSubId, + 'limit' => min($limit, 100), // Stripe max is 100 + ]; + + // Handle offset by using starting_after cursor + if ($offset > 0) { + try { + // First fetch to get the invoice to start after + $offsetResponse = $this->request($apiKey, 'GET', '/invoices', [ + 'subscription' => $stripeSubId, + 'limit' => $offset, + ]); + $offsetData = $this->decodeResponse($offsetResponse); + if (isset($offsetData['data']) && is_array($offsetData['data']) && !empty($offsetData['data'])) { + $lastInvoice = end($offsetData['data']); + $params['starting_after'] = (string) ($lastInvoice['id'] ?? ''); + } + } catch (\Throwable $_) { + // If offset fetch fails, ignore and proceed without offset + } + } + + try { + $response = $this->request($apiKey, 'GET', '/invoices', $params); + $data = $this->decodeResponse($response); + } catch (\Throwable $e) { + throw new \RuntimeException('Failed to fetch invoices: ' . $e->getMessage()); + } + + $invoices = []; + if (isset($data['data']) && is_array($data['data'])) { + foreach ($data['data'] as $invoice) { + if (!is_array($invoice)) { + continue; + } + + $invoices[] = new ProviderInvoice( + invoiceId: (string) ($invoice['id'] ?? ''), + subscriptionId: (string) ($invoice['subscription'] ?? ''), + amount: (int) ($invoice['amount_due'] ?? 0), + currency: (string) ($invoice['currency'] ?? ''), + status: (string) ($invoice['status'] ?? ''), + createdAt: isset($invoice['created']) ? (int) $invoice['created'] : null, + paidAt: isset($invoice['status_transitions']['paid_at']) ? (int) $invoice['status_transitions']['paid_at'] : null, + invoiceUrl: (string) ($invoice['hosted_invoice_url'] ?? ''), + metadata: [ + 'number' => (string) ($invoice['number'] ?? ''), + 'period_start' => isset($invoice['period_start']) ? (int) $invoice['period_start'] : null, + 'period_end' => isset($invoice['period_end']) ? (int) $invoice['period_end'] : null, + ] + ); + } + } + + return $invoices; + } + + public function previewProration(ProviderSubscriptionRef $subscription, string $newPriceId, ProviderState $state): ProviderProrationPreview + { + $apiKey = (string) ($state->config['secretKey'] ?? ''); + $stripeSubId = (string) $subscription->externalSubscriptionId; + + if ($stripeSubId === '') { + throw new \RuntimeException('Subscription has no provider subscription ID'); + } + + // First, get the current subscription to find the subscription item ID + try { + $subResponse = $this->request($apiKey, 'GET', '/subscriptions/' . $stripeSubId); + $subData = $this->decodeResponse($subResponse); + } catch (\Throwable $e) { + throw new \RuntimeException('Failed to fetch subscription: ' . $e->getMessage()); + } + + $items = (array) ($subData['items']['data'] ?? []); + if (empty($items)) { + throw new \RuntimeException('Subscription has no items'); + } + + $itemId = (string) ($items[0]['id'] ?? ''); + if ($itemId === '') { + throw new \RuntimeException('Subscription item has no ID'); + } + + // Use new POST /invoices/create_preview endpoint (replaces deprecated GET /invoices/upcoming) + try { + $params = [ + 'subscription' => $stripeSubId, + 'subscription_details' => [ + 'items' => [ + [ + 'id' => $itemId, + 'price' => $newPriceId, + ] + ], + ], + ]; + + $response = $this->request($apiKey, 'POST', '/invoices/create_preview', $params); + $data = $this->decodeResponse($response); + } catch (\Throwable $e) { + throw new \RuntimeException('Failed to preview proration: ' . $e->getMessage()); + } + + // Calculate proration amount from line items + $prorationAmount = 0; + $lines = (array) ($data['lines']['data'] ?? []); + foreach ($lines as $line) { + if (!is_array($line)) { + continue; + } + // Check for proration in parent.subscription_item_details.proration (new API) + $parent = (array) ($line['parent'] ?? []); + $subItemDetails = (array) ($parent['subscription_item_details'] ?? []); + $isProration = (bool) ($subItemDetails['proration'] ?? false); + // Fallback to legacy proration field + if (!$isProration && isset($line['proration'])) { + $isProration = (bool) $line['proration']; + } + if ($isProration) { + $prorationAmount += (int) ($line['amount'] ?? 0); + } + } + + return new ProviderProrationPreview( + amountDue: (int) ($data['amount_due'] ?? 0), + prorationAmount: $prorationAmount, + currency: (string) ($data['currency'] ?? ''), + nextBillingDate: isset($data['period_end']) ? (int) $data['period_end'] : null, + metadata: [ + 'subtotal' => (int) ($data['subtotal'] ?? 0), + 'total' => (int) ($data['total'] ?? 0), + 'period_start' => isset($data['period_start']) ? (int) $data['period_start'] : null, + 'period_end' => isset($data['period_end']) ? (int) $data['period_end'] : null, + ] + ); + } + public function reportUsage(ProviderSubscriptionRef $subscription, string $featureId, int $quantity, \DateTimeInterface $timestamp, ProviderState $state): void { $apiKey = (string) ($state->config['secretKey'] ?? ''); + if ($apiKey === '') { + throw new \RuntimeException('Stripe API key is missing from provider config'); + } $eventName = 'appwrite.payments.feature.usage.' . $this->project->getId() . '.' . ($state->metadata['planId'] ?? '') . '.' . $featureId; $stripeSubId = (string) $subscription->externalSubscriptionId; $customerId = ''; @@ -524,12 +706,15 @@ class StripeAdapter implements Adapter $customerId = ''; } } + if ($customerId === '') { + throw new \RuntimeException('Could not resolve Stripe customer ID for subscription ' . $stripeSubId); + } $params = [ 'event_name' => $eventName, - 'payload' => array_filter([ + 'payload' => [ 'value' => (string) $quantity, 'stripe_customer_id' => $customerId, - ]), + ], 'timestamp' => (string) $timestamp->getTimestamp(), ]; $this->request($apiKey, 'POST', '/billing/meter_events', $params); @@ -537,15 +722,107 @@ class StripeAdapter implements Adapter public function syncUsage(ProviderSubscriptionRef $subscription, ProviderState $state): ProviderUsageReport { - // Not implemented in full due to Stripe API specifics; return empty aggregate - return new ProviderUsageReport(totals: []); + $apiKey = (string) ($state->config['secretKey'] ?? ''); + $stripeSubId = (string) $subscription->externalSubscriptionId; + + // Retrieve customer ID from subscription + $customerId = ''; + if ($stripeSubId !== '') { + try { + $sub = $this->request($apiKey, 'GET', '/subscriptions/' . $stripeSubId); + $subData = $this->decodeResponse($sub); + $customerId = (string) ($subData['customer'] ?? ''); + } catch (\Throwable $_) { + return new ProviderUsageReport(totals: []); + } + } + + if ($customerId === '') { + return new ProviderUsageReport(totals: []); + } + + // Fetch all billing meters to get usage summaries + $totals = []; + $details = []; + + try { + $metersList = $this->request($apiKey, 'GET', '/billing/meters', ['limit' => 100]); + $metersData = $this->decodeResponse($metersList); + + if (!isset($metersData['data']) || !is_array($metersData['data'])) { + return new ProviderUsageReport(totals: []); + } + + // For each meter, fetch event summaries + foreach ($metersData['data'] as $meter) { + if (!is_array($meter)) { + continue; + } + + $meterId = (string) ($meter['id'] ?? ''); + $eventName = (string) ($meter['event_name'] ?? ''); + + if ($meterId === '' || $eventName === '') { + continue; + } + + // Extract feature ID from event name (format: appwrite.payments.feature.usage.{projectId}.{planId}.{featureId}) + $parts = explode('.', $eventName); + if (count($parts) < 7) { + continue; + } + $featureId = $parts[6] ?? ''; + + if ($featureId === '') { + continue; + } + + // Fetch event summaries for this meter and customer + try { + // Get current billing period + $now = time(); + $startTime = strtotime('first day of this month 00:00:00'); + $endTime = $now; + + $summaryResponse = $this->request($apiKey, 'GET', '/billing/meters/' . $meterId . '/event_summaries', [ + 'customer' => $customerId, + 'start_time' => (string) $startTime, + 'end_time' => (string) $endTime, + ]); + $summaryData = $this->decodeResponse($summaryResponse); + + if (isset($summaryData['data']) && is_array($summaryData['data']) && !empty($summaryData['data'])) { + $summary = $summaryData['data'][0] ?? []; + $aggregatedValue = (int) ($summary['aggregated_value'] ?? 0); + + $totals[$featureId] = ($totals[$featureId] ?? 0) + $aggregatedValue; + + $details[$featureId] = [ + 'meterId' => $meterId, + 'eventName' => $eventName, + 'aggregatedValue' => $aggregatedValue, + 'startTime' => $startTime, + 'endTime' => $endTime, + ]; + } + } catch (\Throwable $_) { + // Skip this meter if we can't fetch summaries + continue; + } + } + } catch (\Throwable $_) { + // Return empty report if we can't fetch meters + return new ProviderUsageReport(totals: []); + } + + return new ProviderUsageReport(totals: $totals, details: $details); } public function handleWebhook(array $payload, ProviderState $state): ProviderWebhookResult { $signature = (string) ($payload['_signature'] ?? ''); $raw = (string) ($payload['_raw'] ?? ''); - $secret = (string) ($state->metadata['webhookSecret'] ?? ''); + $secret = (string) ($state->config['webhookSecret'] ?? ''); if ($secret !== '' && $signature !== '' && $raw !== '') { $parts = []; foreach (explode(',', $signature) as $part) { diff --git a/src/Appwrite/Platform/Modules/Payments/Http/ActorFeatures/Get.php b/src/Appwrite/Platform/Modules/Payments/Http/ActorFeatures/Get.php new file mode 100644 index 0000000000..d2b02f253f --- /dev/null +++ b/src/Appwrite/Platform/Modules/Payments/Http/ActorFeatures/Get.php @@ -0,0 +1,231 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/payments/actors/:actorType/:actorId/features') + ->httpAlias('/v1/payments/actors/features') + ->httpAlias('/v1/payments/actors/current/features') + ->httpAlias('/v1/payments/actors/me/features') + ->groups(['api', 'payments']) + ->desc('Get features for an actor') + ->label('scope', 'payments.read') + ->label('resourceType', RESOURCE_TYPE_PAYMENTS) + ->label('sdk', new Method( + namespace: 'payments', + group: 'actorFeatures', + name: 'get', + description: 'Get features available to an actor (user/team) based on their subscription plan', + auth: [AuthType::KEY, AuthType::ADMIN, AuthType::JWT], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_ANY, + ) + ] + )) + ->param('actorType', 'user', new Text(16), 'Actor type: user or team', true) + ->param('actorId', '', new Text(128), 'Actor ID', true) + ->inject('response') + ->inject('dbForProject') + ->inject('user') + ->callback($this->action(...)); + } + + public function action( + string $actorType, + string $actorId, + Response $response, + Database $dbForProject, + Document $user + ) { + // Handle case where path parameters might not be extracted (e.g., from aliases) + if ($actorType === '' || $actorType === ':actorType') { + $actorType = 'user'; + } + if ($actorId === '' || $actorId === ':actorId') { + $actorId = ''; + } + + $actorType = strtolower($actorType); + + if (!\in_array($actorType, ['user', 'team'], true)) { + throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::GENERAL_BAD_REQUEST, 'actorType must be "user" or "team"'); + } + + $roles = Authorization::getRoles(); + $isAPIKey = Auth::isAppUser($roles); + $isPrivileged = Auth::isPrivilegedUser($roles); + + // Authorization: Handle user actor type + if ($actorType === 'user') { + if ($actorId === '' || $actorId === 'current' || $actorId === 'me') { + if ($user->isEmpty()) { + if ($isAPIKey || $isPrivileged) { + throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::GENERAL_BAD_REQUEST, 'actorId is required when using API keys or privileged access'); + } else { + throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::USER_UNAUTHORIZED, 'Login required'); + } + } + $actorId = $user->getId(); + } elseif (!$isAPIKey && !$isPrivileged) { + // Non-privileged users can only access their own features + if ($user->isEmpty() || $user->getId() !== $actorId) { + throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::USER_UNAUTHORIZED, 'Not allowed to access this actor\'s features'); + } + } + } + + // Authorization: Handle team actor type + if ($actorType === 'team') { + if ($actorId === '') { + throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::GENERAL_BAD_REQUEST, 'actorId required for team features'); + } + + if (!$isAPIKey && !$isPrivileged) { + if ($user->isEmpty()) { + throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::USER_UNAUTHORIZED, 'Login required'); + } + + // Verify user is a member of the team + $membership = $dbForProject->findOne('memberships', [ + Query::equal('teamId', [$actorId]), + Query::equal('userId', [$user->getId()]) + ]); + + if ($membership === null || $membership->isEmpty()) { + throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::USER_UNAUTHORIZED, 'User is not a member of this team'); + } + } + } + + // Verify the actor exists + if ($actorId !== '') { + $collection = $actorType === 'team' ? 'teams' : 'users'; + $actor = $dbForProject->getDocument($collection, $actorId); + + if ($actor->isEmpty()) { + $exceptionType = $actorType === 'user' ? \Appwrite\Extend\Exception::USER_NOT_FOUND : \Appwrite\Extend\Exception::TEAM_NOT_FOUND; + throw new \Appwrite\AppwriteException($exceptionType); + } + } + + // Get the actor's subscription + $queries = [ + Query::equal('actorType', [$actorType]), + Query::equal('actorId', [$actorId]), + Query::notEqual('status', 'pending'), + Query::orderDesc('$createdAt'), + Query::limit(1), + ]; + + $subscriptions = $dbForProject->find('payments_subscriptions', $queries); + $subscription = $subscriptions[0] ?? null; + + // Determine active subscription + $activeSubscription = null; + if ($subscription instanceof Document && !$subscription->isEmpty()) { + $status = strtolower((string) $subscription->getAttribute('status', '')); + if ($status == 'active' || $status == 'trialing' || $status == 'paused') { + $activeSubscription = $subscription; + } + } + + // Get the plan ID (default to 'free' if no active subscription) + $planId = ''; + if ($activeSubscription instanceof Document) { + $planId = (string) $activeSubscription->getAttribute('planId', ''); + } + if ($planId === '') { + $planId = 'free'; + } + + // Fetch plan features + $features = []; + $planFeatures = $dbForProject->find('payments_plan_features', [ + Query::equal('planId', [$planId]), + Query::equal('enabled', [true]), + ]); + + foreach ($planFeatures as $planFeatureDoc) { + if (!$planFeatureDoc instanceof Document) { + continue; + } + + $featureId = (string) $planFeatureDoc->getAttribute('featureId', ''); + + // Get feature details from payments_features collection + $featureDetails = $dbForProject->findOne('payments_features', [ + Query::equal('featureId', [$featureId]) + ]); + + if (!$featureDetails || $featureDetails->isEmpty()) { + continue; + } + + // Build comprehensive feature information + $feature = [ + 'featureId' => $featureId, + 'name' => $featureDetails->getAttribute('name', ''), + 'description' => $featureDetails->getAttribute('description', ''), + 'type' => $planFeatureDoc->getAttribute('type', 'boolean'), + 'enabled' => true, + ]; + + // Add metered feature details if applicable + if ($planFeatureDoc->getAttribute('type') === 'metered') { + $feature['includedUnits'] = $planFeatureDoc->getAttribute('includedUnits', 0); + $feature['usageCap'] = $planFeatureDoc->getAttribute('usageCap', null); + $feature['tiersMode'] = $planFeatureDoc->getAttribute('tiersMode', ''); + $feature['tiers'] = $planFeatureDoc->getAttribute('tiers', []); + $feature['currency'] = $planFeatureDoc->getAttribute('currency', ''); + $feature['interval'] = $planFeatureDoc->getAttribute('interval', ''); + } + + $features[] = $feature; + } + + // Sanitize features to ensure proper JSON encoding + $featuresSanitized = []; + foreach ($features as $feature) { + $sanitized = json_decode(json_encode($feature), false); + $featuresSanitized[] = $sanitized ?? new \stdClass(); + } + + $payload = [ + 'actorType' => $actorType, + 'actorId' => $actorId, + 'planId' => $planId, + 'total' => count($featuresSanitized), + 'features' => $featuresSanitized, + ]; + + $response->json($payload); + } +} diff --git a/src/Appwrite/Platform/Modules/Payments/Http/Features/Create.php b/src/Appwrite/Platform/Modules/Payments/Http/Features/Create.php index 403ccfc83c..7d5c85de31 100644 --- a/src/Appwrite/Platform/Modules/Payments/Http/Features/Create.php +++ b/src/Appwrite/Platform/Modules/Payments/Http/Features/Create.php @@ -2,6 +2,9 @@ namespace Appwrite\Platform\Modules\Payments\Http\Features; +use Appwrite\AppwriteException; +use Appwrite\Event\Event; +use Appwrite\Extend\Exception as ExtendException; use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; @@ -43,7 +46,7 @@ class Create extends Base responses: [ new SDKResponse( code: Response::STATUS_CODE_CREATED, - model: Response::MODEL_ANY, + model: Response::MODEL_PAYMENT_FEATURE, ) ] )) @@ -55,6 +58,7 @@ class Create extends Base ->inject('dbForPlatform') ->inject('dbForProject') ->inject('project') + ->inject('queueForEvents') ->callback($this->action(...)); } @@ -66,15 +70,14 @@ class Create extends Base Response $response, Database $dbForPlatform, Database $dbForProject, - Document $project + Document $project, + Event $queueForEvents ) { // Feature flag: block if payments disabled for project $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 AppwriteException(ExtendException::GENERAL_ACCESS_FORBIDDEN, 'Payments feature is disabled for this project'); } $doc = new Document([ @@ -85,7 +88,12 @@ class Create extends Base 'providers' => [] ]); $created = $dbForProject->createDocument('payments_features', $doc); + + $queueForEvents + ->setParam('featureId', $featureId) + ->setPayload($created->getArrayCopy()); + $response->setStatusCode(Response::STATUS_CODE_CREATED); - $response->json($created->getArrayCopy()); + $response->dynamic($created, Response::MODEL_PAYMENT_FEATURE); } } diff --git a/src/Appwrite/Platform/Modules/Payments/Http/Features/Delete.php b/src/Appwrite/Platform/Modules/Payments/Http/Features/Delete.php index e0cd69a99e..7c28ae9d92 100644 --- a/src/Appwrite/Platform/Modules/Payments/Http/Features/Delete.php +++ b/src/Appwrite/Platform/Modules/Payments/Http/Features/Delete.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Payments\Http\Features; +use Appwrite\Event\Event; use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; @@ -47,6 +48,7 @@ class Delete extends Base ->inject('dbForPlatform') ->inject('dbForProject') ->inject('project') + ->inject('queueForEvents') ->callback($this->action(...)); } @@ -55,26 +57,29 @@ class Delete extends Base Response $response, Database $dbForPlatform, Database $dbForProject, - Document $project + Document $project, + Event $queueForEvents ) { // Feature flag: block if payments disabled for project $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 \Appwrite\AppwriteException(\Appwrite\Extend\Exception::GENERAL_ACCESS_FORBIDDEN, 'Payments feature is disabled for this project'); } $feature = $dbForProject->findOne('payments_features', [ Query::equal('featureId', [$featureId]) ]); if ($feature === null || $feature->isEmpty()) { - $response->setStatusCode(Response::STATUS_CODE_NOT_FOUND); - $response->json(['message' => 'Feature not found']); - return; + throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::PAYMENT_FEATURE_NOT_FOUND); } $dbForProject->deleteDocument('payments_features', $feature->getId()); + + $queueForEvents + ->setEvent('payments.[featureId].delete') + ->setParam('featureId', $featureId) + ->setPayload(['featureId' => $featureId]); + $response->noContent(); } } diff --git a/src/Appwrite/Platform/Modules/Payments/Http/Features/Get.php b/src/Appwrite/Platform/Modules/Payments/Http/Features/Get.php new file mode 100644 index 0000000000..5619247bbe --- /dev/null +++ b/src/Appwrite/Platform/Modules/Payments/Http/Features/Get.php @@ -0,0 +1,68 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/payments/features/:featureId') + ->groups(['api', 'payments']) + ->desc('Get payment feature') + ->label('scope', 'payments.read') + ->label('resourceType', RESOURCE_TYPE_PAYMENTS) + ->label('sdk', new Method( + namespace: 'payments', + group: 'features', + name: 'get', + description: 'Get a payment feature', + auth: [AuthType::KEY, AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_PAYMENT_FEATURE, + ) + ] + )) + ->param('featureId', '', new Text(128), 'Feature ID') + ->inject('response') + ->inject('dbForProject') + ->callback($this->action(...)); + } + + public function action( + string $featureId, + Response $response, + Database $dbForProject + ) { + $feature = $dbForProject->findOne('payments_features', [ + Query::equal('featureId', [$featureId]) + ]); + + if ($feature === null || $feature->isEmpty()) { + throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::PAYMENT_FEATURE_NOT_FOUND); + } + + $response->dynamic($feature, Response::MODEL_PAYMENT_FEATURE); + } +} diff --git a/src/Appwrite/Platform/Modules/Payments/Http/Features/Update.php b/src/Appwrite/Platform/Modules/Payments/Http/Features/Update.php index 0053fff4ab..8f6a86eb62 100644 --- a/src/Appwrite/Platform/Modules/Payments/Http/Features/Update.php +++ b/src/Appwrite/Platform/Modules/Payments/Http/Features/Update.php @@ -44,7 +44,7 @@ class Update extends Base responses: [ new SDKResponse( code: Response::STATUS_CODE_OK, - model: Response::MODEL_ANY, + model: Response::MODEL_PAYMENT_FEATURE, ) ] )) @@ -73,18 +73,14 @@ class Update 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 \Appwrite\AppwriteException(\Appwrite\Extend\Exception::GENERAL_ACCESS_FORBIDDEN, 'Payments feature is disabled for this project'); } $feature = $dbForProject->findOne('payments_features', [ Query::equal('featureId', [$featureId]) ]); if ($feature === null || $feature->isEmpty()) { - $response->setStatusCode(Response::STATUS_CODE_NOT_FOUND); - $response->json(['message' => 'Feature not found']); - return; + throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::PAYMENT_FEATURE_NOT_FOUND); } if ($name !== '') { $feature->setAttribute('name', $name); @@ -96,6 +92,6 @@ class Update extends Base $feature->setAttribute('description', $description); } $feature = $dbForProject->updateDocument('payments_features', $feature->getId(), $feature); - $response->json($feature->getArrayCopy()); + $response->dynamic($feature, Response::MODEL_PAYMENT_FEATURE); } } diff --git a/src/Appwrite/Platform/Modules/Payments/Http/Features/XList.php b/src/Appwrite/Platform/Modules/Payments/Http/Features/XList.php index f7bf059e2c..9dc9e77e83 100644 --- a/src/Appwrite/Platform/Modules/Payments/Http/Features/XList.php +++ b/src/Appwrite/Platform/Modules/Payments/Http/Features/XList.php @@ -8,6 +8,7 @@ use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response; use Utopia\Database\Database; +use Utopia\Database\Document; use Utopia\Database\Query; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; @@ -40,7 +41,7 @@ class XList extends Base responses: [ new SDKResponse( code: Response::STATUS_CODE_OK, - model: Response::MODEL_ANY, + model: Response::MODEL_PAYMENT_FEATURE_LIST, ) ] )) @@ -60,9 +61,9 @@ class XList extends Base $filters[] = Query::search('name', $search); } $list = $dbForProject->find('payments_features', $filters); - $response->json([ + $response->dynamic(new Document([ 'total' => count($list), - 'features' => array_map(fn ($d) => $d->getArrayCopy(), $list) - ]); + 'features' => $list + ]), 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 new file mode 100644 index 0000000000..2bbe2ede15 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Payments/Http/Invoices/XList.php @@ -0,0 +1,204 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/payments/subscriptions/:subscriptionId/invoices') + ->groups(['api', 'payments']) + ->desc('List subscription invoices') + ->label('scope', 'payments.read') + ->label('resourceType', RESOURCE_TYPE_PAYMENTS) + ->label('sdk', new Method( + namespace: 'payments', + group: 'subscriptions', + name: 'listInvoices', + description: 'List invoices for a subscription', + auth: [AuthType::KEY, AuthType::ADMIN, AuthType::JWT], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_ANY, + ) + ] + )) + ->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') + ->inject('registryPayments') + ->inject('project') + ->inject('user') + ->callback($this->action(...)); + } + + public function action( + string $subscriptionId, + int $limit, + int $offset, + Response $response, + Database $dbForPlatform, + Database $dbForProject, + Registry $registryPayments, + Document $project, + Document $user + ) { + // Feature flag: block if payments disabled + $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; + } + + // 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; + } + + // Validate offset + if ($offset < 0) { + $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); + $response->json(['message' => 'Offset must be non-negative']); + return; + } + + // Get subscription from database + $subscription = $dbForProject->findOne('payments_subscriptions', [ + Query::equal('subscriptionId', [$subscriptionId]) + ]); + + if ($subscription === null || $subscription->isEmpty()) { + $response->setStatusCode(Response::STATUS_CODE_NOT_FOUND); + $response->json(['message' => 'Subscription not found']); + return; + } + + // Authorization: only enforce for JWT users, API keys have admin access + if (!$user->isEmpty()) { + $actorType = (string) $subscription->getAttribute('actorType', ''); + $actorId = (string) $subscription->getAttribute('actorId', ''); + + if ($actorType === 'user') { + if ($user->getId() !== $actorId) { + throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::USER_UNAUTHORIZED, 'Not authorized to access this subscription'); + } + } elseif ($actorType === 'team') { + $membership = $dbForProject->findOne('memberships', [ + Query::equal('teamId', [$actorId]), + Query::equal('userId', [$user->getId()]) + ]); + + if ($membership === null || $membership->isEmpty()) { + throw new \Appwrite\AppwriteException(\Appwrite\Extend\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'); + } + } + } + + // Get provider subscription ID + $providerSubscriptionId = (string) $subscription->getAttribute('providerSubscriptionId', ''); + + if ($providerSubscriptionId === '') { + // No provider subscription yet, return empty list + $response->setStatusCode(Response::STATUS_CODE_OK); + $response->json([ + 'total' => 0, + 'invoices' => [] + ]); + 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; + } + + $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; + } + + // Create provider subscription reference + $providerSubRef = new ProviderSubscriptionRef( + externalSubscriptionId: $providerSubscriptionId + ); + + 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; + } + + // Format invoices for response + $formattedInvoices = []; + foreach ($invoices as $invoice) { + $formattedInvoices[] = [ + 'invoiceId' => $invoice->invoiceId, + 'subscriptionId' => $invoice->subscriptionId, + 'amount' => $invoice->amount, + 'currency' => $invoice->currency, + 'status' => $invoice->status, + 'createdAt' => $invoice->createdAt, + 'paidAt' => $invoice->paidAt, + 'invoiceUrl' => $invoice->invoiceUrl, + 'metadata' => $invoice->metadata, + ]; + } + + $response->setStatusCode(Response::STATUS_CODE_OK); + $response->json([ + 'total' => count($formattedInvoices), + 'invoices' => $formattedInvoices + ]); + } +} diff --git a/src/Appwrite/Platform/Modules/Payments/Http/PlanFeatures/Assign.php b/src/Appwrite/Platform/Modules/Payments/Http/PlanFeatures/Assign.php index 53bf45a09c..5db94f83cb 100644 --- a/src/Appwrite/Platform/Modules/Payments/Http/PlanFeatures/Assign.php +++ b/src/Appwrite/Platform/Modules/Payments/Http/PlanFeatures/Assign.php @@ -94,18 +94,14 @@ class Assign 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 \Appwrite\AppwriteException(\Appwrite\Extend\Exception::GENERAL_ACCESS_FORBIDDEN, 'Payments feature is disabled for this project'); } $plan = $dbForProject->findOne('payments_plans', [ Query::equal('planId', [$planId]) ]); if ($plan === null || $plan->isEmpty()) { - $response->setStatusCode(Response::STATUS_CODE_NOT_FOUND); - $response->json(['message' => 'Plan not found']); - return; + throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::PAYMENT_PLAN_NOT_FOUND); } // Infer feature type from existing feature document @@ -113,9 +109,7 @@ class Assign extends Base Query::equal('featureId', [$featureId]) ]); if ($feature === null || $feature->isEmpty()) { - $response->setStatusCode(Response::STATUS_CODE_NOT_FOUND); - $response->json(['message' => 'Feature not found']); - return; + throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::PAYMENT_FEATURE_NOT_FOUND); } $type = (string) strtolower((string) $feature->getAttribute('type', 'boolean')); diff --git a/src/Appwrite/Platform/Modules/Payments/Http/PlanFeatures/Remove.php b/src/Appwrite/Platform/Modules/Payments/Http/PlanFeatures/Remove.php index 4c7c001af9..adeee56ac5 100644 --- a/src/Appwrite/Platform/Modules/Payments/Http/PlanFeatures/Remove.php +++ b/src/Appwrite/Platform/Modules/Payments/Http/PlanFeatures/Remove.php @@ -73,9 +73,7 @@ class Remove 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 \Appwrite\AppwriteException(\Appwrite\Extend\Exception::GENERAL_ACCESS_FORBIDDEN, 'Payments feature is disabled for this project'); } $assignment = $dbForProject->findOne('payments_plan_features', [ @@ -83,9 +81,7 @@ class Remove extends Base Query::equal('featureId', [$featureId]) ]); if ($assignment === null || $assignment->isEmpty()) { - $response->setStatusCode(Response::STATUS_CODE_NOT_FOUND); - $response->json(['message' => 'Assignment not found']); - return; + throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::GENERAL_NOT_FOUND, 'Assignment not found'); } // Attempt deprovision: deactivate provider price if tracked $plan = $dbForProject->findOne('payments_plans', [ diff --git a/src/Appwrite/Platform/Modules/Payments/Http/PlanFeatures/XList.php b/src/Appwrite/Platform/Modules/Payments/Http/PlanFeatures/XList.php index 51673b75f8..15299c2b54 100644 --- a/src/Appwrite/Platform/Modules/Payments/Http/PlanFeatures/XList.php +++ b/src/Appwrite/Platform/Modules/Payments/Http/PlanFeatures/XList.php @@ -60,7 +60,7 @@ class XList extends Base ]); $response->json([ 'total' => count($items), - 'assignments' => array_map(fn ($d) => $d->getArrayCopy(), $items) + 'features' => array_map(fn ($d) => $d->getArrayCopy(), $items) ]); } } diff --git a/src/Appwrite/Platform/Modules/Payments/Http/Plans/Create.php b/src/Appwrite/Platform/Modules/Payments/Http/Plans/Create.php index a9d3c73f6c..1aa4be3a96 100644 --- a/src/Appwrite/Platform/Modules/Payments/Http/Plans/Create.php +++ b/src/Appwrite/Platform/Modules/Payments/Http/Plans/Create.php @@ -41,7 +41,7 @@ class Create extends Base ->desc('Create payment plan') ->label('scope', 'payments.write') ->label('resourceType', RESOURCE_TYPE_PAYMENTS) - ->label('event', 'plans.[planId].create') + ->label('event', 'payments.plan.[planId].create') ->label('audits.event', 'payments.plan.create') ->label('audits.resource', 'payments/plan/{request.planId}') ->label('sdk', new Method( @@ -53,7 +53,7 @@ class Create extends Base responses: [ new SDKResponse( code: Response::STATUS_CODE_CREATED, - model: Response::MODEL_ANY, + model: Response::MODEL_PAYMENT_PLAN, ) ] )) @@ -91,20 +91,14 @@ class Create extends Base $seenPriceIds = []; foreach ($pricing as $entry) { if (!is_array($entry)) { - $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); - $response->json(['message' => 'Invalid pricing entry format']); - return; + throw new AppwriteException(ExtendException::GENERAL_BAD_REQUEST, 'Invalid pricing entry format'); } $priceId = (string) ($entry['priceId'] ?? ''); if ($priceId === '') { - $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); - $response->json(['message' => 'Each pricing entry must include a priceId']); - return; + throw new AppwriteException(ExtendException::GENERAL_BAD_REQUEST, 'Each pricing entry must include a priceId'); } if (isset($seenPriceIds[$priceId])) { - $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); - $response->json(['message' => 'Duplicate priceId detected: ' . $priceId]); - return; + throw new AppwriteException(ExtendException::GENERAL_BAD_REQUEST, 'Duplicate priceId detected: ' . $priceId); } $seenPriceIds[$priceId] = true; $normalizedPricing[] = $entry; @@ -129,9 +123,7 @@ class Create 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 AppwriteException(ExtendException::GENERAL_ACCESS_FORBIDDEN, 'Payments feature is disabled for this project'); } // Check if plan already exists @@ -139,16 +131,13 @@ class Create extends Base Query::equal('planId', [$planId]) ]); if ($existingPlan !== null && !$existingPlan->isEmpty()) { - // TODO: create a custom exception for this - return new AppwriteException(ExtendException::RESOURCE_ALREADY_EXISTS); + throw new AppwriteException(ExtendException::PAYMENT_PLAN_ALREADY_EXISTS); } $payments = (array) $project->getAttribute('payments', []); $providerConfigs = (array) ($payments['providers'] ?? []); if (empty($providerConfigs)) { - $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); - $response->json(['message' => 'At least one payment provider must be configured before creating plans']); - return; + throw new AppwriteException(ExtendException::PAYMENT_PROVIDER_NOT_CONFIGURED, 'At least one payment provider must be configured before creating plans'); } $created = $dbForProject->createDocument('payments_plans', $document); @@ -180,6 +169,6 @@ class Create extends Base } $response->setStatusCode(Response::STATUS_CODE_CREATED); - $response->json($created->getArrayCopy()); + $response->dynamic($created, Response::MODEL_PAYMENT_PLAN); } } diff --git a/src/Appwrite/Platform/Modules/Payments/Http/Plans/Delete.php b/src/Appwrite/Platform/Modules/Payments/Http/Plans/Delete.php index d97c97ba5d..cf5916c4f4 100644 --- a/src/Appwrite/Platform/Modules/Payments/Http/Plans/Delete.php +++ b/src/Appwrite/Platform/Modules/Payments/Http/Plans/Delete.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Payments\Http\Plans; +use Appwrite\Event\Event; use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; @@ -47,6 +48,7 @@ class Delete extends Base ->inject('dbForPlatform') ->inject('dbForProject') ->inject('project') + ->inject('queueForEvents') ->callback($this->action(...)); } @@ -55,26 +57,29 @@ class Delete extends Base Response $response, Database $dbForPlatform, Database $dbForProject, - Document $project + Document $project, + Event $queueForEvents ) { // Feature flag: block if payments disabled for project $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 \Appwrite\AppwriteException(\Appwrite\Extend\Exception::GENERAL_ACCESS_FORBIDDEN, 'Payments feature is disabled for this project'); } $plan = $dbForProject->findOne('payments_plans', [ Query::equal('planId', [$planId]) ]); if ($plan === null || $plan->isEmpty()) { - $response->setStatusCode(Response::STATUS_CODE_NOT_FOUND); - $response->json(['message' => 'Plan not found']); - return; + throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::PAYMENT_PLAN_NOT_FOUND); } $dbForProject->deleteDocument('payments_plans', $plan->getId()); + + $queueForEvents + ->setEvent('payments.[planId].delete') + ->setParam('planId', $planId) + ->setPayload(['planId' => $planId]); + $response->noContent(); } } diff --git a/src/Appwrite/Platform/Modules/Payments/Http/Plans/Get.php b/src/Appwrite/Platform/Modules/Payments/Http/Plans/Get.php index 18a4449511..278fae9bd7 100644 --- a/src/Appwrite/Platform/Modules/Payments/Http/Plans/Get.php +++ b/src/Appwrite/Platform/Modules/Payments/Http/Plans/Get.php @@ -60,9 +60,7 @@ class Get extends Base ]); if ($plan === null || $plan->isEmpty()) { - $response->setStatusCode(Response::STATUS_CODE_NOT_FOUND); - $response->json(['message' => 'Plan not found']); - return; + throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::PAYMENT_PLAN_NOT_FOUND); } $response->dynamic($plan, Response::MODEL_PAYMENT_PLAN); diff --git a/src/Appwrite/Platform/Modules/Payments/Http/Plans/Update.php b/src/Appwrite/Platform/Modules/Payments/Http/Plans/Update.php index d4ddf72d22..86a1f86114 100644 --- a/src/Appwrite/Platform/Modules/Payments/Http/Plans/Update.php +++ b/src/Appwrite/Platform/Modules/Payments/Http/Plans/Update.php @@ -48,7 +48,7 @@ class Update extends Base responses: [ new SDKResponse( code: Response::STATUS_CODE_OK, - model: Response::MODEL_ANY, + model: Response::MODEL_PAYMENT_PLAN, ) ] )) @@ -83,18 +83,14 @@ class Update 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 \Appwrite\AppwriteException(\Appwrite\Extend\Exception::GENERAL_ACCESS_FORBIDDEN, 'Payments feature is disabled for this project'); } $plan = $dbForProject->findOne('payments_plans', [ Query::equal('planId', [$planId]) ]); if ($plan === null || $plan->isEmpty()) { - $response->setStatusCode(Response::STATUS_CODE_NOT_FOUND); - $response->json(['message' => 'Plan not found']); - return; + throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::PAYMENT_PLAN_NOT_FOUND); } if ($name !== '') { $plan->setAttribute('name', $name); @@ -109,20 +105,14 @@ class Update extends Base $seenPriceIds = []; foreach ($pricing as $entry) { if (!is_array($entry)) { - $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); - $response->json(['message' => 'Invalid pricing entry format']); - return; + throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::GENERAL_BAD_REQUEST, 'Invalid pricing entry format'); } $priceId = (string) ($entry['priceId'] ?? ''); if ($priceId === '') { - $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); - $response->json(['message' => 'Each pricing entry must include a priceId']); - return; + throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::GENERAL_BAD_REQUEST, 'Each pricing entry must include a priceId'); } if (isset($seenPriceIds[$priceId])) { - $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); - $response->json(['message' => 'Duplicate priceId detected: ' . $priceId]); - return; + throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::GENERAL_BAD_REQUEST, 'Duplicate priceId detected: ' . $priceId); } $seenPriceIds[$priceId] = true; $normalizedPricing[] = $entry; @@ -158,6 +148,6 @@ class Update extends Base $plan->setAttribute('providers', $providersMeta); $plan = $dbForProject->updateDocument('payments_plans', $plan->getId(), $plan); } - $response->json($plan->getArrayCopy()); + $response->dynamic($plan, Response::MODEL_PAYMENT_PLAN); } } diff --git a/src/Appwrite/Platform/Modules/Payments/Http/Providers/Get.php b/src/Appwrite/Platform/Modules/Payments/Http/Providers/Get.php index 0161ee0605..3b187c298e 100644 --- a/src/Appwrite/Platform/Modules/Payments/Http/Providers/Get.php +++ b/src/Appwrite/Platform/Modules/Payments/Http/Providers/Get.php @@ -39,7 +39,7 @@ class Get extends Base responses: [ new SDKResponse( code: Response::STATUS_CODE_OK, - model: Response::MODEL_ANY, + model: Response::MODEL_PAYMENT_PROVIDER_CONFIG, ) ] )) @@ -63,6 +63,6 @@ class Get extends Base } } $payments['providers'] = $providers; - $response->json(['payments' => $payments]); + $response->dynamic(new Document($payments), Response::MODEL_PAYMENT_PROVIDER_CONFIG); } } diff --git a/src/Appwrite/Platform/Modules/Payments/Http/Providers/Update.php b/src/Appwrite/Platform/Modules/Payments/Http/Providers/Update.php index ceb81f8b62..d4bcebe97c 100644 --- a/src/Appwrite/Platform/Modules/Payments/Http/Providers/Update.php +++ b/src/Appwrite/Platform/Modules/Payments/Http/Providers/Update.php @@ -3,6 +3,8 @@ namespace Appwrite\Platform\Modules\Payments\Http\Providers; use Appwrite\Event\Event; +use Appwrite\Payments\Provider\ProviderPlanRef; +use Appwrite\Payments\Provider\ProviderState; use Appwrite\Payments\Provider\Registry; use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; @@ -11,6 +13,7 @@ use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response; use Utopia\Database\Database; use Utopia\Database\Document; +use Utopia\Database\Query; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\Validator\JSON as JSONValidator; @@ -45,7 +48,7 @@ class Update extends Base responses: [ new SDKResponse( code: Response::STATUS_CODE_OK, - model: Response::MODEL_ANY, + model: Response::MODEL_PAYMENT_PROVIDER_CONFIG, ) ] )) @@ -63,6 +66,7 @@ class Update extends Base { $projectDoc = $dbForPlatform->getDocument('projects', $project->getId()); $existing = (array) $projectDoc->getAttribute('payments', []); + $existingProviders = (array) ($existing['providers'] ?? []); $providers = (array) ($config['providers'] ?? []); $providerKeys = \array_keys($providers); @@ -73,11 +77,37 @@ class Update extends Base if ($providerId === 'stripe') { $secret = (string) ($providerConfig['secretKey'] ?? ''); if ($secret === '') { - $response->setStatusCode(400); - $response->json(['message' => 'Stripe secretKey is required']); - return; + throw new \Appwrite\Extend\Exception(\Appwrite\Extend\Exception::GENERAL_BAD_REQUEST, 'Stripe secretKey is required'); } } + + // Check if provider is already configured - prevent re-setup without disconnect + if (isset($existingProviders[$providerId])) { + $existingState = (array) ($existingProviders[$providerId]['state'] ?? []); + $existingWebhookId = (string) ($existingState['webhookEndpointId'] ?? ''); + if ($existingWebhookId !== '') { + throw new \Appwrite\Extend\Exception( + \Appwrite\Extend\Exception::PAYMENT_PROVIDER_ALREADY_CONFIGURED, + "Provider '{$providerId}' is already configured. Disconnect it first before reconfiguring." + ); + } + } + } + + // Identify newly added and removed providers + $newProviders = []; + $removedProviders = []; + + foreach ($providers as $providerId => $providerConfig) { + if (!isset($existingProviders[$providerId])) { + $newProviders[] = $providerId; + } + } + + foreach ($existingProviders as $providerId => $providerConfig) { + if (!isset($providers[$providerId])) { + $removedProviders[] = $providerId; + } } foreach ($providers as $providerId => $providerConfig) { @@ -85,9 +115,7 @@ class Update extends Base $test = $adapter->testConnection((array) $providerConfig); if (!$test->success) { \error_log("[Payments/Update] provider={$providerId} test failed: {$test->message}"); - $response->setStatusCode(400); - $response->json(['message' => 'Provider test failed: ' . $test->message]); - return; + throw new \Appwrite\Extend\Exception(\Appwrite\Extend\Exception::GENERAL_BAD_REQUEST, 'Provider test failed: ' . $test->message); } $state = $adapter->configure((array) $providerConfig, $project); $providers[$providerId] = array_merge((array) $providerConfig, [ @@ -108,9 +136,102 @@ class Update extends Base $mergedProviderKeys = \array_keys((array) ($merged['providers'] ?? [])); $queueForEvents->setParam('providers', empty($mergedProviderKeys) ? 'providers' : \implode(',', $mergedProviderKeys)); + // Sync existing plans to newly added providers + if (!empty($newProviders)) { + $allPlans = $dbForProject->find('payments_plans', [ + Query::limit(1000) + ]); + + foreach ($allPlans as $plan) { + $planId = (string) $plan->getAttribute('planId', ''); + $planName = (string) $plan->getAttribute('name', ''); + $planDescription = (string) $plan->getAttribute('description', ''); + $pricing = (array) $plan->getAttribute('pricing', []); + $providersMeta = (array) $plan->getAttribute('providers', []); + + foreach ($newProviders as $providerId) { + if (isset($providersMeta[$providerId])) { + continue; // Skip if plan already exists for this provider + } + + $providerConfig = (array) ($providers[$providerId] ?? []); + $state = new ProviderState((string) $providerId, (array) $providerConfig, (array) ($providerConfig['state'] ?? [])); + $adapter = $registryPayments->get((string) $providerId, (array) $providerConfig, $project, $dbForPlatform, $dbForProject); + + try { + $ref = $adapter->ensurePlan([ + 'planId' => $planId, + 'name' => $planName, + 'description' => $planDescription, + 'pricing' => $pricing, + ], $state); + + $meta = $ref->metadata; + $providersMeta[$providerId] = [ + 'externalId' => $ref->externalPlanId, + 'metadata' => $meta, + 'prices' => (array) ($meta['prices'] ?? []) + ]; + } catch (\Throwable $e) { + \error_log("[Payments/Update] Failed to sync plan {$planId} to provider {$providerId}: {$e->getMessage()}"); + // Continue with other plans even if one fails + } + } + + if (!empty($providersMeta)) { + $plan->setAttribute('providers', $providersMeta); + $dbForProject->updateDocument('payments_plans', $plan->getId(), $plan); + } + } + } + + // Remove plans from de-configured providers + if (!empty($removedProviders)) { + $allPlans = $dbForProject->find('payments_plans', [ + Query::limit(1000) + ]); + + foreach ($allPlans as $plan) { + $providersMeta = (array) $plan->getAttribute('providers', []); + $planUpdated = false; + + foreach ($removedProviders as $providerId) { + if (!isset($providersMeta[$providerId])) { + continue; // Skip if plan doesn't exist for this provider + } + + $providerConfig = (array) ($existingProviders[$providerId] ?? []); + $state = new ProviderState((string) $providerId, (array) $providerConfig, (array) ($providerConfig['state'] ?? [])); + $adapter = $registryPayments->get((string) $providerId, (array) $providerConfig, $project, $dbForPlatform, $dbForProject); + + $providerMeta = (array) $providersMeta[$providerId]; + $ref = new ProviderPlanRef( + (string) ($providerMeta['externalId'] ?? ''), + (array) ($providerMeta['metadata'] ?? []) + ); + + try { + $adapter->deletePlan($ref, $state); + } catch (\Throwable $e) { + \error_log("[Payments/Update] Failed to delete plan from provider {$providerId}: {$e->getMessage()}"); + // Continue with deletion from metadata even if provider deletion fails + } + + // Remove provider entry from plan metadata + unset($providersMeta[$providerId]); + $planUpdated = true; + } + + if ($planUpdated) { + $plan->setAttribute('providers', $providersMeta); + $dbForProject->updateDocument('payments_plans', $plan->getId(), $plan); + } + } + } + $out = (array) $updated->getAttribute('payments', []); $prov = (array) ($out['providers'] ?? []); - foreach ($prov as $pid => &$cfg) { + foreach ($prov as &$cfg) { if (isset($cfg['secretKey'])) { $cfg['secretKey'] = '***'; } @@ -119,6 +240,6 @@ class Update extends Base } } $out['providers'] = $prov; - $response->json(['payments' => $out]); + $response->dynamic(new Document($out), Response::MODEL_PAYMENT_PROVIDER_CONFIG); } } diff --git a/src/Appwrite/Platform/Modules/Payments/Http/Subscriptions/Cancel.php b/src/Appwrite/Platform/Modules/Payments/Http/Subscriptions/Cancel.php index f981803ce5..5c63333278 100644 --- a/src/Appwrite/Platform/Modules/Payments/Http/Subscriptions/Cancel.php +++ b/src/Appwrite/Platform/Modules/Payments/Http/Subscriptions/Cancel.php @@ -2,6 +2,9 @@ namespace Appwrite\Platform\Modules\Payments\Http\Subscriptions; +use Appwrite\AppwriteException; +use Appwrite\Event\Event; +use Appwrite\Extend\Exception as ExtendException; use Appwrite\Payments\Provider\ProviderState; use Appwrite\Payments\Provider\Registry; use Appwrite\Platform\Modules\Compute\Base; @@ -34,7 +37,7 @@ class Cancel extends Base ->desc('Cancel subscription') ->label('scope', 'payments.subscribe') ->label('resourceType', RESOURCE_TYPE_PAYMENTS) - ->label('event', 'payments.subscriptions.cancel') + ->label('event', 'payments.subscription.[subscriptionId].cancel') ->label('audits.event', 'payments.subscription.cancel') ->label('audits.resource', 'payments/subscription/{request.subscriptionId}') ->label('sdk', new Method( @@ -53,6 +56,7 @@ class Cancel extends Base ->inject('user') ->inject('registryPayments') ->inject('project') + ->inject('queueForEvents') ->callback($this->action(...)); } @@ -64,24 +68,21 @@ class Cancel extends Base Database $dbForProject, Document $user, Registry $registryPayments, - Document $project + Document $project, + Event $queueForEvents ) { // Feature flag: block if payments disabled for project $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 AppwriteException(ExtendException::GENERAL_ACCESS_FORBIDDEN, 'Payments feature is disabled for this project'); } $sub = $dbForProject->findOne('payments_subscriptions', [ Query::equal('subscriptionId', [$subscriptionId]) ]); if ($sub === null || $sub->isEmpty()) { - $response->setStatusCode(Response::STATUS_CODE_NOT_FOUND); - $response->json(['message' => 'Subscription not found']); - return; + throw new AppwriteException(ExtendException::PAYMENT_SUBSCRIPTION_NOT_FOUND); } // Authorization for JWT user: must be owner/billing on team or owner (self) on user @@ -90,9 +91,7 @@ class Cancel extends Base $actorId = (string) $sub->getAttribute('actorId', ''); if ($actorType === 'user') { if ($user->getId() !== $actorId) { - $response->setStatusCode(Response::STATUS_CODE_FORBIDDEN); - $response->json(['message' => 'Not allowed to cancel this subscription']); - return; + throw new AppwriteException(ExtendException::USER_UNAUTHORIZED, 'Not allowed to cancel this subscription'); } } elseif ($actorType === 'team') { $membership = $dbForProject->findOne('memberships', [ @@ -100,29 +99,25 @@ class Cancel extends Base Query::equal('userId', [$user->getId()]) ]); if ($membership === null || $membership->isEmpty()) { - $response->setStatusCode(Response::STATUS_CODE_FORBIDDEN); - $response->json(['message' => 'Not a member of the team']); - return; + throw new AppwriteException(ExtendException::USER_UNAUTHORIZED, 'Not a member of the team'); } $roles = (array) $membership->getAttribute('roles', []); if (!in_array('owner', $roles, true) && !in_array('billing', $roles, true)) { - $response->setStatusCode(Response::STATUS_CODE_FORBIDDEN); - $response->json(['message' => 'Requires owner or billing role']); - return; + throw new AppwriteException(ExtendException::USER_UNAUTHORIZED, 'Requires owner or billing role'); } } } // Provider cancel - $payments = (array) $project->getAttribute('payments', []); - $providers = (array) ($payments['providers'] ?? []); + $providers = (array) ($paymentsCfg['providers'] ?? []); $primary = array_key_first($providers); if ($primary) { $config = (array) ($providers[$primary] ?? []); $provMap = (array) $sub->getAttribute('providers', []); - $subscriptionRef = (string) ((array) ($provMap[(string) $primary] ?? []))['providerSubscriptionId'] ?? ''; + $providerData = (array) ($provMap[(string) $primary] ?? []); + $subscriptionRef = (string) ($providerData['providerSubscriptionId'] ?? ''); if ($subscriptionRef !== '') { $state = new ProviderState((string) $primary, $config, (array) ($config['state'] ?? [])); - $adapter = $registryPayments->get((string) $primary, $config, $project, $dbForPlatform, $dbForProject); + $adapter = $registryPayments->get((string) $primary, $config, $projDoc, $dbForPlatform, $dbForProject); $adapter->cancelSubscription(new \Appwrite\Payments\Provider\ProviderSubscriptionRef($subscriptionRef), $endAtPeriodEnd, $state); } } @@ -135,6 +130,11 @@ class Cancel extends Base $sub->setAttribute('canceledAt', date('c')); } $dbForProject->updateDocument('payments_subscriptions', $sub->getId(), $sub); + + $queueForEvents + ->setParam('subscriptionId', $subscriptionId) + ->setPayload($sub->getArrayCopy()); + $response->noContent(); } } diff --git a/src/Appwrite/Platform/Modules/Payments/Http/Subscriptions/Create.php b/src/Appwrite/Platform/Modules/Payments/Http/Subscriptions/Create.php index 108eb670c8..7ca94c4b6c 100644 --- a/src/Appwrite/Platform/Modules/Payments/Http/Subscriptions/Create.php +++ b/src/Appwrite/Platform/Modules/Payments/Http/Subscriptions/Create.php @@ -48,7 +48,7 @@ class Create extends Base responses: [ new SDKResponse( code: Response::STATUS_CODE_CREATED, - model: Response::MODEL_ANY, + model: Response::MODEL_PAYMENT_SUBSCRIPTION, ) ] )) @@ -87,32 +87,24 @@ class Create 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 \Appwrite\AppwriteException(\Appwrite\Extend\Exception::GENERAL_ACCESS_FORBIDDEN, 'Payments feature is disabled for this project'); } $plan = $dbForProject->findOne('payments_plans', [ Query::equal('planId', [$planId]) ]); if ($plan === null || $plan->isEmpty()) { - $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); - $response->json(['message' => 'Invalid planId']); - return; + throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::PAYMENT_PLAN_NOT_FOUND); } // Resolve payer (user who owns payment method). For teams, use payerUserId; else actorId if ($actorType === 'team' && $payerUserId === '') { - $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); - $response->json(['message' => 'payerUserId required for team subscriptions']); - return; + throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::GENERAL_BAD_REQUEST, 'payerUserId required for team subscriptions'); } $payerId = $actorType === 'team' ? $payerUserId : $actorId; $payer = $dbForProject->getDocument('users', $payerId); if ($payer->isEmpty()) { - $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); - $response->json(['message' => 'Payer user not found']); - return; + throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::USER_NOT_FOUND, 'Payer user not found'); } if ($actorType === 'team') { // Ensure payer is a member with billing/owner role @@ -121,15 +113,11 @@ class Create extends Base Query::equal('userId', [$payerId]) ]); if ($membership === null || $membership->isEmpty()) { - $response->setStatusCode(Response::STATUS_CODE_FORBIDDEN); - $response->json(['message' => 'Payer is not a member of the team']); - return; + throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::USER_UNAUTHORIZED, 'Payer is not a member of the team'); } $roles = (array) $membership->getAttribute('roles', []); if (!in_array('owner', $roles, true) && !in_array('billing', $roles, true)) { - $response->setStatusCode(Response::STATUS_CODE_FORBIDDEN); - $response->json(['message' => 'Payer must have owner or billing role']); - return; + throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::USER_UNAUTHORIZED, 'Payer must have owner or billing role'); } } @@ -139,9 +127,8 @@ class Create extends Base : $dbForProject->getDocument('teams', $actorId); if ($actor->isEmpty()) { - $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); - $response->json(['message' => 'Actor not found']); - return; + $exceptionType = $actorType === 'user' ? \Appwrite\Extend\Exception::USER_NOT_FOUND : \Appwrite\Extend\Exception::TEAM_NOT_FOUND; + throw new \Appwrite\AppwriteException($exceptionType); } // Check if actor already has an active subscription @@ -155,12 +142,7 @@ class Create extends Base if (!empty($existingSubscriptions)) { $existingSubscription = $existingSubscriptions[0]; if ($existingSubscription instanceof Document && !$existingSubscription->isEmpty()) { - $response->setStatusCode(Response::STATUS_CODE_CONFLICT); - $response->json([ - 'message' => 'Actor already has an active subscription', - 'subscriptionId' => $existingSubscription->getAttribute('subscriptionId') - ]); - return; + throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::PAYMENT_SUBSCRIPTION_ALREADY_EXISTS, 'Actor already has an active subscription'); } } @@ -218,9 +200,7 @@ class Create extends Base if ($selectedPriceId !== null) { if (!isset($providerPriceMap[$selectedPriceId])) { - $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); - $response->json(['message' => 'Price ID not configured for provider: ' . $selectedPriceId]); - return; + throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::GENERAL_BAD_REQUEST, 'Price ID not configured for provider: ' . $selectedPriceId); } $providerPlanPriceId = (string) $providerPriceMap[$selectedPriceId]; } elseif (!empty($providerPriceMap)) { @@ -242,9 +222,7 @@ class Create extends Base } if ($providerPlanPriceId === '') { - $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); - $response->json(['message' => 'Plan has no prices configured for provider: ' . $primary]); - return; + throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::GENERAL_BAD_REQUEST, 'Plan has no prices configured for provider: ' . $primary); } $providerKey = (string) $primary; @@ -253,36 +231,48 @@ class Create extends Base $providerCheckoutId = null; if (!$adapter instanceof StripeAdapter) { - $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); - $response->json(['message' => 'Unsupported payment provider: ' . $providerKey]); - return; + throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::GENERAL_BAD_REQUEST, 'Unsupported payment provider: ' . $providerKey); } if ($successUrl === '' || $cancelUrl === '') { - $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); - $response->json(['message' => 'successUrl and cancelUrl are required.']); - return; + throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::GENERAL_BAD_REQUEST, 'successUrl and cancelUrl are required.'); + } + + // Fetch metered feature prices for this plan + $meteredPriceIds = []; + $planFeatures = $dbForProject->find('payments_plan_features', [ + Query::equal('planId', [$planId]), + Query::equal('enabled', [true]), + ]); + foreach ($planFeatures as $planFeature) { + $featureType = (string) $planFeature->getAttribute('type', ''); + if ($featureType !== 'metered') { + continue; + } + $featureProviders = (array) $planFeature->getAttribute('providers', []); + $featureProviderData = (array) ($featureProviders[$primary] ?? []); + $featurePriceId = (string) ($featureProviderData['priceId'] ?? ''); + if ($featurePriceId !== '') { + $meteredPriceIds[] = $featurePriceId; + } } try { $checkoutSession = $adapter->createCheckoutSession($payer, [ - 'priceId' => $providerPlanPriceId + 'priceId' => $providerPlanPriceId, + 'meteredPriceIds' => $meteredPriceIds, ], $state, [ 'successUrl' => $successUrl, 'cancelUrl' => $cancelUrl ]); } catch (\Throwable $e) { - $response->setStatusCode(Response::STATUS_CODE_INTERNAL_SERVER_ERROR); - $response->json(['message' => 'Failed to create checkout session: ' . $e->getMessage()]); - return; + throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::GENERAL_SERVER_ERROR, 'Failed to create checkout session: ' . $e->getMessage()); } $checkoutUrl = $checkoutSession->url; $providerCheckoutId = (string) ($checkoutSession->metadata['id'] ?? ''); if ($providerCheckoutId === '') { - $response->setStatusCode(Response::STATUS_CODE_INTERNAL_SERVER_ERROR); - $response->json(['message' => 'Checkout session did not return an id']); - return; + throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::GENERAL_SERVER_ERROR, 'Checkout session did not return an id'); } $providerCustomerId = (string) ($checkoutSession->metadata['customerId'] ?? ''); $providerEntryData = [ @@ -297,9 +287,7 @@ class Create extends Base $providerData[$providerKey] = $providerEntryData; } else { - $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); - $response->json(['message' => 'No payment provider configured for this project']); - return; + throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::PAYMENT_PROVIDER_NOT_CONFIGURED); } $resolvedPriceId = (string) ($selectedPriceId ?? ''); @@ -333,12 +321,12 @@ class Create extends Base ->setEvent('payments.subscription.[subscriptionId].create') ->setPayload($created->getArrayCopy()); - $responseData = $created->getArrayCopy(); + // Add checkoutUrl to the response document if ($checkoutUrl) { - $responseData['checkoutUrl'] = $checkoutUrl; + $created->setAttribute('checkoutUrl', $checkoutUrl); } $response->setStatusCode(Response::STATUS_CODE_CREATED); - $response->json($responseData); + $response->dynamic($created, Response::MODEL_PAYMENT_SUBSCRIPTION); } } diff --git a/src/Appwrite/Platform/Modules/Payments/Http/Subscriptions/Get.php b/src/Appwrite/Platform/Modules/Payments/Http/Subscriptions/Get.php index 9d47922889..628a659a6d 100644 --- a/src/Appwrite/Platform/Modules/Payments/Http/Subscriptions/Get.php +++ b/src/Appwrite/Platform/Modules/Payments/Http/Subscriptions/Get.php @@ -76,9 +76,7 @@ class Get extends Base $actorType = strtolower($actorType); if (!\in_array($actorType, ['user', 'team'], true)) { - $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); - $response->json(['message' => 'actorType must be "user" or "team"']); - return; + throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::GENERAL_BAD_REQUEST, 'actorType must be "user" or "team"'); } $roles = Authorization::getRoles(); @@ -89,36 +87,27 @@ class Get extends Base if ($actorId === '' || $actorId === 'current' || $actorId === 'me') { if ($user->isEmpty()) { if ($isAPIKey || $isPrivileged) { - $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); - $response->json(['message' => 'actorId is required when using API keys or privileged access']); + throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::GENERAL_BAD_REQUEST, 'actorId is required when using API keys or privileged access'); } else { - $response->setStatusCode(Response::STATUS_CODE_UNAUTHORIZED); - $response->json(['message' => 'Login required']); + throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::USER_UNAUTHORIZED, 'Login required'); } - return; } $actorId = $user->getId(); } elseif (!$isAPIKey && !$isPrivileged) { if ($user->isEmpty() || $user->getId() !== $actorId) { - $response->setStatusCode(Response::STATUS_CODE_UNAUTHORIZED); - $response->json(['message' => 'Not allowed to access this subscription']); - return; + throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::USER_UNAUTHORIZED, 'Not allowed to access this subscription'); } } } if ($actorType === 'team') { if ($actorId === '') { - $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); - $response->json(['message' => 'actorId required for team subscriptions']); - return; + throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::GENERAL_BAD_REQUEST, 'actorId required for team subscriptions'); } if (!$isAPIKey && !$isPrivileged) { if ($user->isEmpty()) { - $response->setStatusCode(Response::STATUS_CODE_UNAUTHORIZED); - $response->json(['message' => 'Login required']); - return; + throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::USER_UNAUTHORIZED, 'Login required'); } $membership = $dbForProject->findOne('memberships', [ @@ -127,9 +116,7 @@ class Get extends Base ]); if ($membership === null || $membership->isEmpty()) { - $response->setStatusCode(Response::STATUS_CODE_UNAUTHORIZED); - $response->json(['message' => 'User is not a member of this team']); - return; + throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::USER_UNAUTHORIZED, 'User is not a member of this team'); } } } @@ -139,9 +126,8 @@ class Get extends Base $actor = $dbForProject->getDocument($collection, $actorId); if ($actor->isEmpty()) { - $response->setStatusCode(Response::STATUS_CODE_NOT_FOUND); - $response->json(['message' => 'Actor not found']); - return; + $exceptionType = $actorType === 'user' ? \Appwrite\Extend\Exception::USER_NOT_FOUND : \Appwrite\Extend\Exception::TEAM_NOT_FOUND; + throw new \Appwrite\AppwriteException($exceptionType); } } @@ -210,65 +196,8 @@ class Get extends Base } } - $featuresSanitized = []; - foreach ($features as $feature) { - $sanitized = json_decode(json_encode($feature), false); - $featuresSanitized[] = $sanitized ?? new \stdClass(); - } - $subscriptionDoc = $activeSubscription instanceof Document ? $activeSubscription : new Document([]); - // Convert planData to plain array/object - $planDataObj = null; - if ($planData !== null) { - if ($planData instanceof Document) { - $planDataObj = json_decode(json_encode($planData->getArrayCopy()), false); - } elseif (is_array($planData)) { - $planDataObj = json_decode(json_encode($planData), false); - } else { - $planDataObj = $planData; - } - } - if ($planDataObj === null) { - $planDataObj = new \stdClass(); - } - - // Convert subscription data to plain array/object - $subscriptionData = new \stdClass(); - if (!$subscriptionDoc->isEmpty()) { - $subscriptionData = (object) [ - 'subscriptionId' => (string) $subscriptionDoc->getAttribute('subscriptionId', ''), - 'status' => (string) $subscriptionDoc->getAttribute('status', ''), - 'priceId' => (string) $subscriptionDoc->getAttribute('priceId', ''), - 'trialEndsAt' => $subscriptionDoc->getAttribute('trialEndsAt'), - 'currentPeriodStart' => $subscriptionDoc->getAttribute('currentPeriodStart'), - 'currentPeriodEnd' => $subscriptionDoc->getAttribute('currentPeriodEnd'), - 'cancelAtPeriodEnd' => (bool) $subscriptionDoc->getAttribute('cancelAtPeriodEnd', false), - ]; - } - - $providersRaw = $subscriptionDoc->getAttribute('providers', []) ?: new \stdClass(); - if ($providersRaw instanceof Document) { - $providersRaw = $providersRaw->getArrayCopy(); - } - $providersSanitized = json_decode(json_encode($providersRaw), false); - if ($providersSanitized === null) { - $providersSanitized = new \stdClass(); - } - - $payload = new Document([ - 'subscriptionId' => (string) $subscriptionDoc->getAttribute('subscriptionId', ''), - 'actorType' => $actorType, - 'actorId' => $actorId, - 'planId' => $planId, - 'priceId' => (string) $subscriptionDoc->getAttribute('priceId', ''), - 'status' => (string) $subscriptionDoc->getAttribute('status', ''), - 'providers' => $providersSanitized, - 'plan' => $planDataObj, - 'features' => $featuresSanitized, - 'subscription' => $subscriptionData, - ]); - - $response->dynamic($payload, Response::MODEL_PAYMENT_SUBSCRIPTION); + $response->dynamic($subscriptionDoc, Response::MODEL_PAYMENT_SUBSCRIPTION); } } diff --git a/src/Appwrite/Platform/Modules/Payments/Http/Subscriptions/Portal.php b/src/Appwrite/Platform/Modules/Payments/Http/Subscriptions/Portal.php new file mode 100644 index 0000000000..e17634934d --- /dev/null +++ b/src/Appwrite/Platform/Modules/Payments/Http/Subscriptions/Portal.php @@ -0,0 +1,168 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/payments/subscriptions/:subscriptionId/portal') + ->groups(['api', 'payments']) + ->desc('Create billing portal session') + ->label('scope', 'payments.subscribe') + ->label('resourceType', RESOURCE_TYPE_PAYMENTS) + ->label('sdk', new Method( + namespace: 'payments', + group: 'subscriptions', + name: 'createPortal', + description: 'Create a billing portal session', + auth: [AuthType::KEY, AuthType::ADMIN, AuthType::JWT], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_ANY, + ) + ] + )) + ->param('subscriptionId', '', new Text(128), 'Subscription ID') + ->param('returnUrl', '', new Text(2048), 'Return URL after portal session', true) + ->inject('response') + ->inject('dbForPlatform') + ->inject('dbForProject') + ->inject('registryPayments') + ->inject('project') + ->inject('user') + ->callback($this->action(...)); + } + + public function action( + string $subscriptionId, + string $returnUrl, + Response $response, + Database $dbForPlatform, + Database $dbForProject, + Registry $registryPayments, + Document $project, + Document $user + ) { + // Feature flag: block if payments disabled + $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; + } + + // Get subscription from database + $subscription = $dbForProject->findOne('payments_subscriptions', [ + Query::equal('subscriptionId', [$subscriptionId]) + ]); + + if ($subscription === null || $subscription->isEmpty()) { + $response->setStatusCode(Response::STATUS_CODE_NOT_FOUND); + $response->json(['message' => 'Subscription not found']); + return; + } + + // Authorization: only enforce for JWT users, API keys have admin access + if (!$user->isEmpty()) { + $actorType = (string) $subscription->getAttribute('actorType', ''); + $actorId = (string) $subscription->getAttribute('actorId', ''); + + if ($actorType === 'user') { + if ($user->getId() !== $actorId) { + throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::USER_UNAUTHORIZED, 'Not authorized to access this subscription'); + } + } elseif ($actorType === 'team') { + $membership = $dbForProject->findOne('memberships', [ + Query::equal('teamId', [$actorId]), + Query::equal('userId', [$user->getId()]) + ]); + + if ($membership === null || $membership->isEmpty()) { + throw new \Appwrite\AppwriteException(\Appwrite\Extend\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'); + } + } + } + + $actorType = (string) $subscription->getAttribute('actorType', ''); + $actorId = (string) $subscription->getAttribute('actorId', ''); + + // Get actor document + $actor = $actorType === 'user' + ? $dbForProject->getDocument('users', $actorId) + : $dbForProject->getDocument('teams', $actorId); + + if ($actor->isEmpty()) { + $response->setStatusCode(Response::STATUS_CODE_NOT_FOUND); + $response->json(['message' => 'Actor not found']); + return; + } + + // Get payment provider and create portal session + $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; + } + + $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; + } + + try { + $portalSession = $adapter->createPortalSession($actor, $state, [ + 'returnUrl' => $returnUrl + ]); + } catch (\Throwable $e) { + $response->setStatusCode(Response::STATUS_CODE_INTERNAL_SERVER_ERROR); + $response->json(['message' => 'Failed to create portal session: ' . $e->getMessage()]); + return; + } + + $response->setStatusCode(Response::STATUS_CODE_OK); + $response->json([ + 'url' => $portalSession->url + ]); + } +} diff --git a/src/Appwrite/Platform/Modules/Payments/Http/Subscriptions/PreviewUpgrade.php b/src/Appwrite/Platform/Modules/Payments/Http/Subscriptions/PreviewUpgrade.php new file mode 100644 index 0000000000..8023274c97 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Payments/Http/Subscriptions/PreviewUpgrade.php @@ -0,0 +1,191 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/payments/subscriptions/:subscriptionId/preview') + ->groups(['api', 'payments']) + ->desc('Preview subscription upgrade proration') + ->label('scope', 'payments.read') + ->label('resourceType', RESOURCE_TYPE_PAYMENTS) + ->label('sdk', new Method( + namespace: 'payments', + group: 'subscriptions', + name: 'previewUpgrade', + description: 'Preview a subscription upgrade with proration details', + auth: [AuthType::KEY, AuthType::ADMIN, AuthType::JWT], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_ANY, + ) + ] + )) + ->param('subscriptionId', '', new Text(128), 'Subscription ID') + ->param('newPlanId', '', new Text(128), 'New plan ID to switch to') + ->inject('response') + ->inject('dbForPlatform') + ->inject('dbForProject') + ->inject('registryPayments') + ->inject('project') + ->inject('user') + ->callback($this->action(...)); + } + + public function action( + string $subscriptionId, + string $newPlanId, + Response $response, + Database $dbForPlatform, + Database $dbForProject, + Registry $registryPayments, + Document $project, + Document $user + ) { + // Feature flag: block if payments disabled + $projDoc = $dbForPlatform->getDocument('projects', $project->getId()); + $paymentsCfg = (array) $projDoc->getAttribute('payments', []); + if (isset($paymentsCfg['enabled']) && $paymentsCfg['enabled'] === false) { + throw new Exception(Exception::GENERAL_ACCESS_FORBIDDEN, 'Payments feature is disabled for this project'); + } + + // Get subscription from database + $subscription = $dbForProject->findOne('payments_subscriptions', [ + Query::equal('subscriptionId', [$subscriptionId]) + ]); + + if ($subscription === null || $subscription->isEmpty()) { + throw new Exception(Exception::PAYMENT_SUBSCRIPTION_NOT_FOUND); + } + + // Authorization: only enforce for JWT users, API keys have admin access + if (!$user->isEmpty()) { + $actorType = (string) $subscription->getAttribute('actorType', ''); + $actorId = (string) $subscription->getAttribute('actorId', ''); + + if ($actorType === 'user') { + if ($user->getId() !== $actorId) { + throw new Exception(Exception::USER_UNAUTHORIZED, 'Not authorized to access this subscription'); + } + } elseif ($actorType === 'team') { + $membership = $dbForProject->findOne('memberships', [ + Query::equal('teamId', [$actorId]), + Query::equal('userId', [$user->getId()]) + ]); + + if ($membership === null || $membership->isEmpty()) { + 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 Exception(Exception::USER_UNAUTHORIZED, 'User must have owner or billing role'); + } + } + } + + // Get provider subscription ID from providers map + $payments = (array) $project->getAttribute('payments', []); + $providers = (array) ($payments['providers'] ?? []); + $primary = array_key_first($providers); + + if (!$primary) { + throw new Exception(Exception::PAYMENT_PROVIDER_NOT_CONFIGURED, 'No payment provider configured'); + } + + $subProviders = (array) $subscription->getAttribute('providers', []); + $subProviderData = (array) ($subProviders[$primary] ?? []); + $providerSubscriptionId = (string) ($subProviderData['providerSubscriptionId'] ?? ''); + + if ($providerSubscriptionId === '') { + throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Subscription has no provider subscription ID'); + } + + $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) { + throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Unsupported payment provider: ' . $primary); + } + + // Get the NEW plan to find the provider price + $newPlan = $dbForProject->findOne('payments_plans', [ + Query::equal('planId', [$newPlanId]) + ]); + + if ($newPlan === null || $newPlan->isEmpty()) { + throw new Exception(Exception::PAYMENT_PLAN_NOT_FOUND); + } + + // Get provider price from the new plan + $planProviders = (array) $newPlan->getAttribute('providers', []); + $providerEntry = (array) ($planProviders[$primary] ?? []); + $providerPrices = (array) ($providerEntry['prices'] ?? []); + + // Try metadata prices as fallback + if (empty($providerPrices)) { + $providerPrices = (array) (($providerEntry['metadata']['prices'] ?? []) ?: []); + } + + if (empty($providerPrices)) { + throw new Exception(Exception::GENERAL_BAD_REQUEST, 'New plan has no provider prices configured'); + } + + // Get the first available provider price + $providerPriceId = (string) reset($providerPrices); + + if ($providerPriceId === '') { + throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Could not determine provider price for new plan'); + } + + // Create provider subscription reference + $providerSubRef = new ProviderSubscriptionRef( + externalSubscriptionId: $providerSubscriptionId + ); + + try { + $preview = $adapter->previewProration($providerSubRef, $providerPriceId, $state); + } catch (\Throwable $e) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to preview proration: ' . $e->getMessage()); + } + + $response->setStatusCode(Response::STATUS_CODE_OK); + $response->json([ + 'planId' => $newPlanId, + 'amountDue' => $preview->amountDue, + 'prorationAmount' => $preview->prorationAmount, + 'currency' => $preview->currency, + 'nextBillingDate' => $preview->nextBillingDate, + 'metadata' => $preview->metadata, + ]); + } +} diff --git a/src/Appwrite/Platform/Modules/Payments/Http/Subscriptions/Resume.php b/src/Appwrite/Platform/Modules/Payments/Http/Subscriptions/Resume.php index e244c30a8c..baad5b37ff 100644 --- a/src/Appwrite/Platform/Modules/Payments/Http/Subscriptions/Resume.php +++ b/src/Appwrite/Platform/Modules/Payments/Http/Subscriptions/Resume.php @@ -2,6 +2,9 @@ namespace Appwrite\Platform\Modules\Payments\Http\Subscriptions; +use Appwrite\AppwriteException; +use Appwrite\Event\Event; +use Appwrite\Extend\Exception as ExtendException; use Appwrite\Payments\Provider\ProviderState; use Appwrite\Payments\Provider\Registry; use Appwrite\Platform\Modules\Compute\Base; @@ -33,7 +36,7 @@ class Resume extends Base ->desc('Resume subscription') ->label('scope', 'payments.subscribe') ->label('resourceType', RESOURCE_TYPE_PAYMENTS) - ->label('event', 'payments.subscriptions.resume') + ->label('event', 'payments.subscription.[subscriptionId].resume') ->label('audits.event', 'payments.subscription.resume') ->label('audits.resource', 'payments/subscription/{request.subscriptionId}') ->label('sdk', new Method( @@ -51,6 +54,7 @@ class Resume extends Base ->inject('user') ->inject('registryPayments') ->inject('project') + ->inject('queueForEvents') ->callback($this->action(...)); } @@ -61,24 +65,21 @@ class Resume extends Base Database $dbForProject, Document $user, Registry $registryPayments, - Document $project + Document $project, + Event $queueForEvents ) { // Feature flag: block if payments disabled for project $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 AppwriteException(ExtendException::GENERAL_ACCESS_FORBIDDEN, 'Payments feature is disabled for this project'); } $sub = $dbForProject->findOne('payments_subscriptions', [ Query::equal('subscriptionId', [$subscriptionId]) ]); if ($sub === null || $sub->isEmpty()) { - $response->setStatusCode(Response::STATUS_CODE_NOT_FOUND); - $response->json(['message' => 'Subscription not found']); - return; + throw new AppwriteException(ExtendException::PAYMENT_SUBSCRIPTION_NOT_FOUND); } if (!$user->isEmpty()) { @@ -86,9 +87,7 @@ class Resume extends Base $actorId = (string) $sub->getAttribute('actorId', ''); if ($actorType === 'user') { if ($user->getId() !== $actorId) { - $response->setStatusCode(Response::STATUS_CODE_FORBIDDEN); - $response->json(['message' => 'Not allowed to resume this subscription']); - return; + throw new AppwriteException(ExtendException::USER_UNAUTHORIZED, 'Not allowed to resume this subscription'); } } elseif ($actorType === 'team') { $membership = $dbForProject->findOne('memberships', [ @@ -96,15 +95,11 @@ class Resume extends Base Query::equal('userId', [$user->getId()]) ]); if ($membership === null || $membership->isEmpty()) { - $response->setStatusCode(Response::STATUS_CODE_FORBIDDEN); - $response->json(['message' => 'Not a member of the team']); - return; + throw new AppwriteException(ExtendException::USER_UNAUTHORIZED, 'Not a member of the team'); } $roles = (array) $membership->getAttribute('roles', []); if (!in_array('owner', $roles, true) && !in_array('billing', $roles, true)) { - $response->setStatusCode(Response::STATUS_CODE_FORBIDDEN); - $response->json(['message' => 'Requires owner or billing role']); - return; + throw new AppwriteException(ExtendException::USER_UNAUTHORIZED, 'Requires owner or billing role'); } } } @@ -126,6 +121,11 @@ class Resume extends Base $sub->setAttribute('canceledAt', null); $sub->setAttribute('cancelAtPeriodEnd', false); $dbForProject->updateDocument('payments_subscriptions', $sub->getId(), $sub); + + $queueForEvents + ->setParam('subscriptionId', $subscriptionId) + ->setPayload($sub->getArrayCopy()); + $response->noContent(); } } diff --git a/src/Appwrite/Platform/Modules/Payments/Http/Subscriptions/Update.php b/src/Appwrite/Platform/Modules/Payments/Http/Subscriptions/Update.php index ef1b15b8b4..589509b5a4 100644 --- a/src/Appwrite/Platform/Modules/Payments/Http/Subscriptions/Update.php +++ b/src/Appwrite/Platform/Modules/Payments/Http/Subscriptions/Update.php @@ -2,6 +2,9 @@ namespace Appwrite\Platform\Modules\Payments\Http\Subscriptions; +use Appwrite\AppwriteException; +use Appwrite\Event\Event; +use Appwrite\Extend\Exception as ExtendException; use Appwrite\Payments\Provider\ProviderState; use Appwrite\Payments\Provider\Registry; use Appwrite\Platform\Modules\Compute\Base; @@ -35,7 +38,7 @@ class Update extends Base ->desc('Update subscription') ->label('scope', 'payments.subscribe') ->label('resourceType', RESOURCE_TYPE_PAYMENTS) - ->label('event', 'payments.subscriptions.update') + ->label('event', 'payments.subscription.[subscriptionId].update') ->label('audits.event', 'payments.subscription.update') ->label('audits.resource', 'payments/subscription/{request.subscriptionId}') ->label('sdk', new Method( @@ -47,7 +50,7 @@ class Update extends Base responses: [ new SDKResponse( code: Response::STATUS_CODE_OK, - model: Response::MODEL_ANY, + model: Response::MODEL_PAYMENT_SUBSCRIPTION, ) ] )) @@ -61,6 +64,7 @@ class Update extends Base ->inject('user') ->inject('registryPayments') ->inject('project') + ->inject('queueForEvents') ->callback($this->action(...)); } @@ -74,24 +78,21 @@ class Update extends Base Database $dbForProject, Document $user, Registry $registryPayments, - Document $project + Document $project, + Event $queueForEvents ) { // Feature flag: block if payments disabled for project $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 AppwriteException(ExtendException::GENERAL_ACCESS_FORBIDDEN, 'Payments feature is disabled for this project'); } $sub = $dbForProject->findOne('payments_subscriptions', [ Query::equal('subscriptionId', [$subscriptionId]) ]); if ($sub === null || $sub->isEmpty()) { - $response->setStatusCode(Response::STATUS_CODE_NOT_FOUND); - $response->json(['message' => 'Subscription not found']); - return; + throw new AppwriteException(ExtendException::PAYMENT_SUBSCRIPTION_NOT_FOUND); } // Authorization: if acting as user (JWT), enforce actor ownership or team membership roles @@ -100,9 +101,7 @@ class Update extends Base $actorId = (string) $sub->getAttribute('actorId', ''); if ($actorType === 'user') { if ($user->getId() !== $actorId) { - $response->setStatusCode(Response::STATUS_CODE_FORBIDDEN); - $response->json(['message' => 'Not allowed to modify this subscription']); - return; + throw new AppwriteException(ExtendException::USER_UNAUTHORIZED, 'Not allowed to modify this subscription'); } } elseif ($actorType === 'team') { $membership = $dbForProject->findOne('memberships', [ @@ -110,15 +109,11 @@ class Update extends Base Query::equal('userId', [$user->getId()]) ]); if ($membership === null || $membership->isEmpty()) { - $response->setStatusCode(Response::STATUS_CODE_FORBIDDEN); - $response->json(['message' => 'Not a member of the team']); - return; + throw new AppwriteException(ExtendException::USER_UNAUTHORIZED, 'Not a member of the team'); } $roles = (array) $membership->getAttribute('roles', []); if (!in_array('owner', $roles, true) && !in_array('billing', $roles, true)) { - $response->setStatusCode(Response::STATUS_CODE_FORBIDDEN); - $response->json(['message' => 'Requires owner or billing role']); - return; + throw new AppwriteException(ExtendException::USER_UNAUTHORIZED, 'Requires owner or billing role'); } } } @@ -129,7 +124,8 @@ class Update extends Base if ($primary) { $config = (array) ($providers[$primary] ?? []); $provMap = (array) $sub->getAttribute('providers', []); - $subscriptionRef = (string) ((array) ($provMap[(string) $primary] ?? []))['subscriptionId'] ?? ''; + $providerData = (array) ($provMap[(string) $primary] ?? []); + $subscriptionRef = (string) ($providerData['providerSubscriptionId'] ?? ''); if ($subscriptionRef !== '') { $state = new ProviderState((string) $primary, $config, (array) ($config['state'] ?? [])); $adapter = $registryPayments->get((string) $primary, $config, $project, $dbForPlatform, $dbForProject); @@ -140,9 +136,7 @@ class Update extends Base Query::equal('planId', [$targetPlanId]) ]); if ($plan === null || $plan->isEmpty()) { - $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); - $response->json(['message' => 'Target plan not found']); - return; + throw new AppwriteException(ExtendException::PAYMENT_PLAN_NOT_FOUND); } $planProviders = (array) $plan->getAttribute('providers', []); $planPricing = array_values((array) ($plan->getAttribute('pricing') ?? [])); @@ -185,9 +179,7 @@ class Update extends Base } if ($selectedPriceId === '' || !isset($providerPriceMap[$selectedPriceId])) { - $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); - $response->json(['message' => 'Price ID not configured for provider']); - return; + throw new AppwriteException(ExtendException::GENERAL_BAD_REQUEST, 'Price ID not configured for provider'); } $providerPriceId = (string) $providerPriceMap[$selectedPriceId]; @@ -207,10 +199,19 @@ class Update extends Base if ($cancelAtPeriodEnd) { $adapter->cancelSubscription(new \Appwrite\Payments\Provider\ProviderSubscriptionRef($subscriptionRef), true, $state); $sub->setAttribute('cancelAtPeriodEnd', true); + } elseif (!$cancelAtPeriodEnd && $sub->getAttribute('cancelAtPeriodEnd', false) === true) { + // Resume if explicitly setting to false while subscription is scheduled to cancel + $adapter->resumeSubscription(new \Appwrite\Payments\Provider\ProviderSubscriptionRef($subscriptionRef), $state); + $sub->setAttribute('cancelAtPeriodEnd', false); } } } $sub = $dbForProject->updateDocument('payments_subscriptions', $sub->getId(), $sub); - $response->json($sub->getArrayCopy()); + + $queueForEvents + ->setParam('subscriptionId', $subscriptionId) + ->setPayload($sub->getArrayCopy()); + + $response->dynamic($sub, Response::MODEL_PAYMENT_SUBSCRIPTION); } } diff --git a/src/Appwrite/Platform/Modules/Payments/Http/Subscriptions/XList.php b/src/Appwrite/Platform/Modules/Payments/Http/Subscriptions/XList.php index 71aa3ea066..3c0e0464d1 100644 --- a/src/Appwrite/Platform/Modules/Payments/Http/Subscriptions/XList.php +++ b/src/Appwrite/Platform/Modules/Payments/Http/Subscriptions/XList.php @@ -70,9 +70,11 @@ class XList extends Base if ($status !== '') { $filters[] = Query::equal('status', [$status]); } - $list = $dbForProject->find('payments_subscriptions', $filters); + $subscriptions = $dbForProject->find('payments_subscriptions', $filters); + + // Collect unique plan IDs and fetch plans $plansById = []; - foreach ($list as $sub) { + foreach ($subscriptions as $sub) { $planId = (string) $sub->getAttribute('planId', ''); if ($planId !== '' && !isset($plansById[$planId])) { $plan = $dbForProject->findOne('payments_plans', [ @@ -83,15 +85,18 @@ class XList extends Base } } } - $subs = []; - foreach ($list as $sub) { - $arr = $sub->getArrayCopy(); - $planId = (string) ($arr['planId'] ?? ''); + + // Enrich subscriptions with plan Documents + foreach ($subscriptions as $sub) { + $planId = (string) $sub->getAttribute('planId', ''); if ($planId !== '' && isset($plansById[$planId])) { - $arr['plan'] = $plansById[$planId]->getArrayCopy(); + $sub->setAttribute('plan', $plansById[$planId]); } - $subs[] = $arr; } - $response->dynamic(new Document(['total' => count($subs), 'subscriptions' => $subs]), Response::MODEL_PAYMENT_SUBSCRIPTION_LIST); + + $response->dynamic(new Document([ + 'subscriptions' => $subscriptions, + 'total' => count($subscriptions), + ]), 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 a88a0fd3a1..27cb798472 100644 --- a/src/Appwrite/Platform/Modules/Payments/Http/Usage/Create.php +++ b/src/Appwrite/Platform/Modules/Payments/Http/Usage/Create.php @@ -73,18 +73,14 @@ class Create 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 \Appwrite\AppwriteException(\Appwrite\Extend\Exception::GENERAL_ACCESS_FORBIDDEN, 'Payments feature is disabled for this project'); } $sub = $dbForProject->findOne('payments_subscriptions', [ Query::equal('subscriptionId', [$subscriptionId]) ]); if ($sub === null || $sub->isEmpty()) { - $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); - $response->json(['message' => 'Invalid subscriptionId']); - return; + throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::PAYMENT_SUBSCRIPTION_NOT_FOUND); } $event = new Document([ '$id' => ID::unique(), diff --git a/src/Appwrite/Platform/Modules/Payments/Http/Usage/Events/XList.php b/src/Appwrite/Platform/Modules/Payments/Http/Usage/Events/XList.php index 594a67511d..508a149d32 100644 --- a/src/Appwrite/Platform/Modules/Payments/Http/Usage/Events/XList.php +++ b/src/Appwrite/Platform/Modules/Payments/Http/Usage/Events/XList.php @@ -31,9 +31,6 @@ class XList extends Base ->desc('List usage events') ->label('scope', 'payments.read') ->label('resourceType', RESOURCE_TYPE_PAYMENTS) - ->label('event', 'payments.usage.events.list') - ->label('audits.event', 'payments.usage.events.list') - ->label('audits.resource', 'payments/usage/events') ->label('sdk', new Method( namespace: 'payments', group: 'usage', diff --git a/src/Appwrite/Platform/Modules/Payments/Services/Http.php b/src/Appwrite/Platform/Modules/Payments/Services/Http.php index 616225532b..2497802e52 100644 --- a/src/Appwrite/Platform/Modules/Payments/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Payments/Services/Http.php @@ -4,6 +4,7 @@ namespace Appwrite\Platform\Modules\Payments\Services; use Appwrite\Platform\Modules\Payments\Http\Features\Create as FeaturesCreate; use Appwrite\Platform\Modules\Payments\Http\Features\Delete as FeaturesDelete; +use Appwrite\Platform\Modules\Payments\Http\Features\Get as FeaturesGet; use Appwrite\Platform\Modules\Payments\Http\Features\Update as FeaturesUpdate; use Appwrite\Platform\Modules\Payments\Http\Features\XList as FeaturesList; use Appwrite\Platform\Modules\Payments\Http\PlanFeatures\Assign as PlanFeaturesAssign; @@ -20,14 +21,18 @@ use Appwrite\Platform\Modules\Payments\Http\Providers\Update as ProvidersUpdate; use Appwrite\Platform\Modules\Payments\Http\Subscriptions\Cancel as SubscriptionsCancel; use Appwrite\Platform\Modules\Payments\Http\Subscriptions\Create as SubscriptionsCreate; use Appwrite\Platform\Modules\Payments\Http\Subscriptions\Get as SubscriptionsGet; +use Appwrite\Platform\Modules\Payments\Http\Subscriptions\Portal as SubscriptionsPortal; +use Appwrite\Platform\Modules\Payments\Http\Subscriptions\PreviewUpgrade as SubscriptionsPreviewUpgrade; use Appwrite\Platform\Modules\Payments\Http\Subscriptions\Resume as SubscriptionsResume; use Appwrite\Platform\Modules\Payments\Http\Subscriptions\Update as SubscriptionsUpdate; use Appwrite\Platform\Modules\Payments\Http\Subscriptions\XList as SubscriptionsList; +use Appwrite\Platform\Modules\Payments\Http\Invoices\XList as InvoicesList; use Appwrite\Platform\Modules\Payments\Http\Usage\Create as UsageCreate; use Appwrite\Platform\Modules\Payments\Http\Usage\Events\XList as UsageEventsList; use Appwrite\Platform\Modules\Payments\Http\Usage\Get as UsageGet; use Appwrite\Platform\Modules\Payments\Http\Usage\Reconcile\Create as UsageReconcile; use Appwrite\Platform\Modules\Payments\Http\Webhooks\Provider\Create as WebhookProviderCreate; +use Appwrite\Platform\Modules\Payments\Http\ActorFeatures\Get as ActorFeaturesGet; use Utopia\Platform\Service; class Http extends Service @@ -45,6 +50,7 @@ class Http extends Service // Features $this->addAction(FeaturesCreate::getName(), new FeaturesCreate()); + $this->addAction(FeaturesGet::getName(), new FeaturesGet()); $this->addAction(FeaturesList::getName(), new FeaturesList()); $this->addAction(FeaturesUpdate::getName(), new FeaturesUpdate()); $this->addAction(FeaturesDelete::getName(), new FeaturesDelete()); @@ -61,6 +67,11 @@ class Http extends Service $this->addAction(SubscriptionsUpdate::getName(), new SubscriptionsUpdate()); $this->addAction(SubscriptionsCancel::getName(), new SubscriptionsCancel()); $this->addAction(SubscriptionsResume::getName(), new SubscriptionsResume()); + $this->addAction(SubscriptionsPortal::getName(), new SubscriptionsPortal()); + $this->addAction(SubscriptionsPreviewUpgrade::getName(), new SubscriptionsPreviewUpgrade()); + + // Invoices + $this->addAction(InvoicesList::getName(), new InvoicesList()); // Usage $this->addAction(UsageGet::getName(), new UsageGet()); @@ -75,5 +86,8 @@ class Http extends Service // Webhooks $this->addAction(WebhookProviderCreate::getName(), new WebhookProviderCreate()); + + // Actor Features + $this->addAction(ActorFeaturesGet::getName(), new ActorFeaturesGet()); } } diff --git a/src/Appwrite/Platform/Modules/Payments/Workers/UsageSync.php b/src/Appwrite/Platform/Modules/Payments/Workers/UsageSync.php index 03ac554778..022d202a5e 100644 --- a/src/Appwrite/Platform/Modules/Payments/Workers/UsageSync.php +++ b/src/Appwrite/Platform/Modules/Payments/Workers/UsageSync.php @@ -30,11 +30,32 @@ class UsageSync extends Action public function action(Database $dbForPlatform, Database $dbForProject, Document $project, ProviderRegistry $registryPayments): void { + // Query for both pending and retry_pending events $pending = $dbForProject->find('payments_usage_events', [ Query::equal('providerSyncState', ['pending']), Query::limit(APP_LIMIT_SUBQUERY), ]); + // Query for retry_pending events that are ready to be retried + $now = time(); + $retryPending = $dbForProject->find('payments_usage_events', [ + Query::equal('providerSyncState', ['retry_pending']), + Query::limit(APP_LIMIT_SUBQUERY), + ]); + + // Filter retry_pending events to only include those whose nextRetryAt has passed + $retryReady = []; + foreach ($retryPending as $event) { + $meta = (array) $event->getAttribute('metadata', []); + $nextRetryAt = (int) ($meta['nextRetryAt'] ?? 0); + if ($nextRetryAt <= $now) { + $retryReady[] = $event; + } + } + + // Merge pending and retry-ready events + $allEvents = array_merge($pending, $retryReady); + $payments = (array) $project->getAttribute('payments', []); $providers = (array) ($payments['providers'] ?? []); $primary = array_key_first($providers); @@ -46,7 +67,7 @@ class UsageSync extends Action $adapter = $registryPayments->get((string) $primary, $config, $project, $dbForPlatform, $dbForProject); - foreach ($pending as $event) { + foreach ($allEvents as $event) { try { $subscriptionId = (string) $event->getAttribute('subscriptionId', ''); $featureId = (string) $event->getAttribute('featureId', ''); @@ -58,21 +79,22 @@ class UsageSync extends Action Query::equal('subscriptionId', [$subscriptionId]) ]); if ($sub === null || $sub->isEmpty()) { - // Mark failed with reason + // Mark as failed_permanent for non-retryable errors $meta = (array) $event->getAttribute('metadata', []); $meta['error'] = 'Subscription not found'; $event->setAttribute('metadata', $meta); - $event->setAttribute('providerSyncState', 'failed'); + $event->setAttribute('providerSyncState', 'failed_permanent'); $dbForProject->updateDocument('payments_usage_events', $event->getId(), $event); continue; } $provMap = (array) $sub->getAttribute('providers', []); - $providerSubId = (string) ((array) ($provMap[(string) $primary] ?? []))['subscriptionId'] ?? ''; + $providerData = (array) ($provMap[(string) $primary] ?? []); + $providerSubId = (string) ($providerData['providerSubscriptionId'] ?? ''); if ($providerSubId === '') { $meta = (array) $event->getAttribute('metadata', []); $meta['error'] = 'Provider subscription missing'; $event->setAttribute('metadata', $meta); - $event->setAttribute('providerSyncState', 'failed'); + $event->setAttribute('providerSyncState', 'failed_permanent'); $dbForProject->updateDocument('payments_usage_events', $event->getId(), $event); continue; } @@ -87,11 +109,28 @@ class UsageSync extends Action $dbForProject->updateDocument('payments_usage_events', $event->getId(), $event); } catch (\Throwable $e) { $meta = (array) $event->getAttribute('metadata', []); + $retries = (int) ($meta['retries'] ?? 0); + $retries++; + $meta['error'] = $e->getMessage(); $meta['lastTriedAt'] = date('c'); - $meta['retries'] = (int) ($meta['retries'] ?? 0) + 1; + $meta['retries'] = $retries; + + // Implement exponential backoff: min(pow(2, retries) * 60, 3600) seconds + $backoffSeconds = min(pow(2, $retries) * 60, 3600); + $meta['nextRetryAt'] = $now + (int) $backoffSeconds; + $event->setAttribute('metadata', $meta); - $event->setAttribute('providerSyncState', 'failed'); + + // Check if max retries (5) exceeded + if ($retries >= 5) { + // Mark as failed_permanent after max retries + $event->setAttribute('providerSyncState', 'failed_permanent'); + } else { + // Mark as retry_pending for future retry + $event->setAttribute('providerSyncState', 'retry_pending'); + } + $dbForProject->updateDocument('payments_usage_events', $event->getId(), $event); } } diff --git a/src/Appwrite/Platform/Tasks/SchedulePaymentsUsage.php b/src/Appwrite/Platform/Tasks/SchedulePaymentsUsage.php index 9ec028c6f2..e7d13952e1 100644 --- a/src/Appwrite/Platform/Tasks/SchedulePaymentsUsage.php +++ b/src/Appwrite/Platform/Tasks/SchedulePaymentsUsage.php @@ -2,7 +2,7 @@ namespace Appwrite\Platform\Tasks; -use Appwrite\Event\Event as AppwriteEvent; +use Appwrite\Event\PaymentsUsage; use Utopia\CLI\Console; use Utopia\Database\Database; use Utopia\Database\DateTime; @@ -10,7 +10,7 @@ use Utopia\Database\Document; use Utopia\Database\Query; use Utopia\Database\Validator\Authorization; use Utopia\Platform\Action; -use Utopia\Queue\Broker\Pool as BrokerPool; +use Utopia\Queue\Publisher; use Utopia\System\System; class SchedulePaymentsUsage extends Action @@ -25,18 +25,18 @@ class SchedulePaymentsUsage extends Action $this ->desc('Schedules payments usage sync for active projects') ->inject('dbForPlatform') - ->inject('publisher') + ->inject('queueForPaymentsUsage') ->callback($this->action(...)); } - public function action(Database $dbForPlatform, BrokerPool $publisher): void + public function action(Database $dbForPlatform, PaymentsUsage $queueForPaymentsUsage): void { Console::title('Payments usage scheduler V1'); Console::success('Payments usage scheduler started'); $interval = (int) System::getEnv('_APP_PAYMENTS_USAGE_SYNC_INTERVAL', '300'); // 5 minutes - Console::loop(function () use ($dbForPlatform, $publisher) { + Console::loop(function () use ($dbForPlatform, $queueForPaymentsUsage) { Authorization::disable(); Authorization::setDefaultStatus(false); @@ -53,16 +53,15 @@ class SchedulePaymentsUsage extends Action continue; // skip disabled projects } - // Enqueue a payments-usage-sync event for this project - $event = new AppwriteEvent($publisher); - $event - ->setQueue('v1-payments-usage-sync') + $queueForPaymentsUsage ->setProject($project) - ->setEvent('payments.usage.sync') ->trigger(); Console::success('Queued payments usage sync for project: ' . $project->getId()); } + + Authorization::reset(); + Authorization::setDefaultStatus(true); }, $interval); } } diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index e78d8f4bbb..69e264cf19 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -371,6 +371,7 @@ class Response extends SwooleResponse public const MODEL_PAYMENT_SUBSCRIPTION_LIST = 'paymentSubscriptionList'; public const MODEL_PAYMENT_PLAN_LIST = 'paymentPlanList'; public const MODEL_PAYMENT_FEATURE_LIST = 'paymentFeatureList'; + public const MODEL_PAYMENT_PROVIDER_CONFIG = 'paymentProviderConfig'; public const MODEL_WEBHOOK = 'webhook'; public const MODEL_WEBHOOK_LIST = 'webhookList'; public const MODEL_KEY = 'key'; @@ -570,6 +571,7 @@ class Response extends SwooleResponse ->setModel(new \Appwrite\Utopia\Response\Model\PaymentPlan()) ->setModel(new \Appwrite\Utopia\Response\Model\PaymentFeature()) ->setModel(new \Appwrite\Utopia\Response\Model\PaymentSubscription()) + ->setModel(new \Appwrite\Utopia\Response\Model\PaymentProviderConfig()) ->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)) diff --git a/src/Appwrite/Utopia/Response/Model/PaymentFeature.php b/src/Appwrite/Utopia/Response/Model/PaymentFeature.php index e68461cdc4..eb1a6ff8aa 100644 --- a/src/Appwrite/Utopia/Response/Model/PaymentFeature.php +++ b/src/Appwrite/Utopia/Response/Model/PaymentFeature.php @@ -33,12 +33,6 @@ class PaymentFeature extends Model 'description' => 'Feature description.', 'default' => '', 'example' => 'Number of seat licenses', - ]) - ->addRule('providers', [ - 'type' => self::TYPE_JSON, - 'description' => 'Provider-specific metadata.', - 'default' => new \stdClass(), - 'example' => new \stdClass(), ]); } diff --git a/src/Appwrite/Utopia/Response/Model/PaymentPlan.php b/src/Appwrite/Utopia/Response/Model/PaymentPlan.php index 57e6d8b78a..cbdb27dc05 100644 --- a/src/Appwrite/Utopia/Response/Model/PaymentPlan.php +++ b/src/Appwrite/Utopia/Response/Model/PaymentPlan.php @@ -10,15 +10,13 @@ class PaymentPlan extends Model public function __construct() { $this - ->addRule('$id', [ 'type' => self::TYPE_STRING, 'description' => 'Internal document ID', 'default' => '', 'example' => '5e5ea5c16897e' ]) - ->addRule('planId', [ 'type' => self::TYPE_STRING, 'description' => 'Public plan ID', 'default' => '', 'example' => 'pro' ]) + ->addRule('planId', [ 'type' => self::TYPE_STRING, 'description' => 'Plan ID', 'default' => '', 'example' => 'pro' ]) ->addRule('name', [ 'type' => self::TYPE_STRING, 'description' => 'Plan name', 'default' => '', 'example' => 'Pro' ]) ->addRule('description', [ 'type' => self::TYPE_STRING, 'description' => 'Plan description', 'default' => '', 'example' => 'Pro plan' ]) ->addRule('pricing', [ 'type' => self::TYPE_JSON, 'description' => 'Pricing options', 'default' => [], 'example' => [] ]) ->addRule('isDefault', [ 'type' => self::TYPE_BOOLEAN, 'description' => 'Default plan', 'default' => false, 'example' => true ]) ->addRule('isFree', [ 'type' => self::TYPE_BOOLEAN, 'description' => 'Is plan free', 'default' => false, 'example' => false ]) ->addRule('status', [ 'type' => self::TYPE_STRING, 'description' => 'Plan status', 'default' => 'active', 'example' => 'active' ]) - ->addRule('providers', [ 'type' => self::TYPE_JSON, 'description' => 'Provider mapping', 'default' => [], 'example' => [] ]) ->addRule('features', [ 'type' => self::TYPE_JSON, 'description' => 'Features summary', 'default' => [], 'example' => [] ]); } diff --git a/src/Appwrite/Utopia/Response/Model/PaymentProviderConfig.php b/src/Appwrite/Utopia/Response/Model/PaymentProviderConfig.php new file mode 100644 index 0000000000..bf1cc9cf00 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/PaymentProviderConfig.php @@ -0,0 +1,42 @@ +addRule('providers', [ + 'type' => self::TYPE_JSON, + 'description' => 'Payment providers configuration', + 'default' => [], + 'example' => ['stripe' => ['enabled' => true]] + ]) + ->addRule('defaults', [ + 'type' => self::TYPE_JSON, + 'description' => 'Default payment settings', + 'default' => [], + 'example' => [] + ]) + ->addRule('enabled', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether payments feature is enabled', + 'default' => true, + 'example' => true + ]); + } + + public function getName(): string + { + return 'PaymentProviderConfig'; + } + + public function getType(): string + { + return Response::MODEL_PAYMENT_PROVIDER_CONFIG; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/PaymentSubscription.php b/src/Appwrite/Utopia/Response/Model/PaymentSubscription.php index dac8f848b7..6f4f01f67d 100644 --- a/src/Appwrite/Utopia/Response/Model/PaymentSubscription.php +++ b/src/Appwrite/Utopia/Response/Model/PaymentSubscription.php @@ -46,29 +46,54 @@ class PaymentSubscription extends Model 'default' => 'active', 'example' => 'active', ]) - ->addRule('providers', [ - 'type' => self::TYPE_JSON, - 'description' => 'Provider refs.', - 'default' => new \stdClass(), - 'example' => new \stdClass(), + ->addRule('trialEndsAt', [ + 'type' => self::TYPE_STRING, + 'description' => 'Trial end date.', + 'default' => '', + 'example' => '2023-12-31T23:59:59.000Z', + ]) + ->addRule('currentPeriodStart', [ + 'type' => self::TYPE_STRING, + 'description' => 'Current billing period start date.', + 'default' => '', + 'example' => '2023-12-01T00:00:00.000Z', + ]) + ->addRule('currentPeriodEnd', [ + 'type' => self::TYPE_STRING, + 'description' => 'Current billing period end date.', + 'default' => '', + 'example' => '2023-12-31T23:59:59.000Z', + ]) + ->addRule('cancelAtPeriodEnd', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether subscription will cancel at period end.', + 'default' => false, + 'example' => false, + ]) + ->addRule('canceledAt', [ + 'type' => self::TYPE_STRING, + 'description' => 'Cancellation date.', + 'default' => '', + 'example' => '2023-12-31T23:59:59.000Z', + ]) + ->addRule('checkoutUrl', [ + 'type' => self::TYPE_STRING, + 'description' => 'Checkout URL for completing subscription payment (only returned on creation).', + 'default' => '', + 'example' => 'https://checkout.stripe.com/c/pay/cs_test_...', ]) ->addRule('plan', [ - 'type' => self::TYPE_JSON, + 'type' => Response::MODEL_PAYMENT_PLAN, 'description' => 'Embedded plan model.', - 'default' => new \stdClass(), - 'example' => new \stdClass(), + 'default' => null, + 'example' => [], ]) ->addRule('features', [ - 'type' => self::TYPE_JSON, + 'type' => Response::MODEL_PAYMENT_FEATURE, 'description' => 'Feature quotas for the subscribed plan.', 'default' => [], 'example' => [], - ]) - ->addRule('subscription', [ - 'type' => self::TYPE_JSON, - 'description' => 'Raw subscription document data.', - 'default' => new \stdClass(), - 'example' => new \stdClass(), + 'array' => true, ]); }