mirror of
https://github.com/appwrite/appwrite.git
synced 2026-05-26 13:51:13 +00:00
progress, very close to be v0
This commit is contained in:
+3
-1
@@ -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/
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
];
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
|
||||
php /usr/src/code/app/cli.php schedule-payments-usage $@
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
|
||||
php /usr/src/code/app/worker.php payments-usage-sync $@
|
||||
@@ -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
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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 = [],
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user