progress, very close to be v0

This commit is contained in:
Atharva Deosthale
2026-01-06 19:51:35 +05:30
parent c915a00bac
commit 9786b4f6cd
48 changed files with 1857 additions and 386 deletions
+3 -1
View File
@@ -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/
+4
View File
@@ -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()));
+42
View File
@@ -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,
],
];
+4
View File
@@ -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)) {
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
php /usr/src/code/app/cli.php schedule-payments-usage $@
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
php /usr/src/code/app/worker.php payments-usage-sync $@
+59
View File
@@ -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
+3
View File
@@ -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 = '';
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace Appwrite\Event;
use Utopia\Queue\Publisher;
class PaymentsUsage extends Event
{
public function __construct(protected Publisher $publisher)
{
parent::__construct($publisher);
$this
->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(),
];
}
}
+13 -3
View File
@@ -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');
@@ -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;
@@ -0,0 +1,19 @@
<?php
namespace Appwrite\Payments\Provider;
class ProviderInvoice
{
public function __construct(
public readonly string $invoiceId,
public readonly string $subscriptionId,
public readonly int $amount,
public readonly string $currency,
public readonly string $status,
public readonly ?int $createdAt = null,
public readonly ?int $paidAt = null,
public readonly ?string $invoiceUrl = null,
public readonly array $metadata = [],
) {
}
}
@@ -0,0 +1,15 @@
<?php
namespace Appwrite\Payments\Provider;
class ProviderProrationPreview
{
public function __construct(
public readonly int $amountDue,
public readonly int $prorationAmount,
public readonly string $currency,
public readonly ?int $nextBillingDate = null,
public readonly array $metadata = [],
) {
}
}
+303 -26
View File
@@ -2,6 +2,7 @@
namespace Appwrite\Payments\Provider;
use Appwrite\Extend\Exception;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Query;
@@ -31,7 +32,7 @@ class StripeAdapter implements Adapter
$rawDomain = (string) System::getEnv('_APP_DOMAIN', '');
$domain = \trim($rawDomain);
if ($domain === '' || \in_array(\strtolower($domain), ['localhost', '127.0.0.1', 'traefik'], true)) {
throw new \RuntimeException('Appwrite domain is not configured. Set _APP_DOMAIN to a publicly accessible hostname before configuring Stripe.');
throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Appwrite domain is not configured. Set _APP_DOMAIN to a publicly accessible hostname before configuring Stripe.');
}
$domain = (string) \preg_replace('/^\s*https?:\/\//i', '', $domain);
$domain = \ltrim($domain, '/');
@@ -39,29 +40,41 @@ class StripeAdapter implements Adapter
$forceHttps = System::getEnv('_APP_OPTIONS_FORCE_HTTPS', 'enabled') !== 'disabled';
$scheme = $forceHttps ? 'https' : 'http';
$webhookUrl = $scheme . '://' . $domain . '/v1/payments/webhooks/stripe/' . $project->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) {
@@ -0,0 +1,231 @@
<?php
namespace Appwrite\Platform\Modules\Payments\Http\ActorFeatures;
use Appwrite\Auth\Auth;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
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\Database\Validator\Authorization;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\Text;
class Get extends Base
{
use HTTP;
public static function getName()
{
return 'getActorFeatures';
}
public function __construct()
{
$this
->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);
}
}
@@ -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);
}
}
@@ -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();
}
}
@@ -0,0 +1,68 @@
<?php
namespace Appwrite\Platform\Modules\Payments\Http\Features;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Query;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\Text;
class Get extends Base
{
use HTTP;
public static function getName()
{
return 'getPaymentFeature';
}
public function __construct()
{
$this
->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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -0,0 +1,204 @@
<?php
namespace Appwrite\Platform\Modules\Payments\Http\Invoices;
use Appwrite\Payments\Provider\Registry;
use Appwrite\Payments\Provider\StripeAdapter;
use Appwrite\Payments\Provider\ProviderSubscriptionRef;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
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;
use Utopia\Validator\Integer;
use Utopia\Validator\Text;
class XList extends Base
{
use HTTP;
public static function getName()
{
return 'listPaymentInvoices';
}
public function __construct()
{
$this
->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
]);
}
}
@@ -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'));
@@ -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', [
@@ -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)
]);
}
}
@@ -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);
}
}
@@ -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();
}
}
@@ -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);
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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();
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -0,0 +1,168 @@
<?php
namespace Appwrite\Platform\Modules\Payments\Http\Subscriptions;
use Appwrite\Event\Event;
use Appwrite\Payments\Provider\Registry;
use Appwrite\Payments\Provider\StripeAdapter;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
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;
use Utopia\Validator\Text;
class Portal extends Base
{
use HTTP;
public static function getName()
{
return 'createPaymentSubscriptionPortal';
}
public function __construct()
{
$this
->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
]);
}
}
@@ -0,0 +1,191 @@
<?php
namespace Appwrite\Platform\Modules\Payments\Http\Subscriptions;
use Appwrite\Extend\Exception;
use Appwrite\Payments\Provider\Registry;
use Appwrite\Payments\Provider\StripeAdapter;
use Appwrite\Payments\Provider\ProviderSubscriptionRef;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
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;
use Utopia\Validator\Text;
class PreviewUpgrade extends Base
{
use HTTP;
public static function getName()
{
return 'previewPaymentSubscriptionUpgrade';
}
public function __construct()
{
$this
->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,
]);
}
}
@@ -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();
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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(),
@@ -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',
@@ -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());
}
}
@@ -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);
}
}
@@ -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);
}
}
+2
View File
@@ -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))
@@ -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(),
]);
}
@@ -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' => [] ]);
}
@@ -0,0 +1,42 @@
<?php
namespace Appwrite\Utopia\Response\Model;
use Appwrite\Utopia\Response;
use Appwrite\Utopia\Response\Model;
class PaymentProviderConfig extends Model
{
public function __construct()
{
$this
->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;
}
}
@@ -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,
]);
}