Compare commits

...
Author SHA1 Message Date
Atharva Deosthale f8f83285c8 fix spec generation 2026-01-07 00:46:44 +05:30
Atharva Deosthale 0a3b879140 fix sdk method names and use new auth methods after auth refactor 2026-01-07 00:36:25 +05:30
Atharva Deosthale c1c53789ae specs 2026-01-07 00:18:30 +05:30
Atharva Deosthale 131bf76848 merge conflicts 2026-01-06 23:59:32 +05:30
Atharva Deosthale e352eb8ced specs 2026-01-06 23:39:49 +05:30
Atharva Deosthale c187efadb4 improve the code a bit 2026-01-06 23:37:15 +05:30
Atharva Deosthale 9786b4f6cd progress, very close to be v0 2026-01-06 19:51:35 +05:30
Atharva Deosthale c915a00bac change back to traefik, check tests 2025-11-21 21:50:46 +05:30
Atharva Deosthale d03d9e5795 add functionality to cancel immediately rather than period end 2025-11-21 20:12:20 +05:30
Atharva Deosthale 7cde35d7a3 get proper projectDB when passing to adapter in webhook 2025-11-21 18:58:12 +05:30
Atharva Deosthale 27932854c8 format 2025-11-21 18:24:16 +05:30
Atharva Deosthale a01d689ae2 merge conflicts? 2025-11-21 18:21:56 +05:30
Atharva Deosthale f319841fd1 Merge branch '1.8.x' into auth-subscriptions 2025-11-21 16:31:56 +05:30
Atharva Deosthale 92f9ddfc33 remove legacy hackathon code 2025-11-21 16:28:13 +05:30
Atharva Deosthale 86878e3b55 move payments collections to projects db instead of platformDB 2025-11-21 15:12:10 +05:30
Atharva Deosthale df8c696b86 users already subscribed should not be able to do so again 2025-11-21 14:32:31 +05:30
Atharva Deosthale 650da7ba94 ensure customer before any checkout session 2025-11-15 16:10:36 +05:30
Atharva Deosthale efb8ff7bb1 store subscription id while in webhooks 2025-11-15 16:00:23 +05:30
Atharva Deosthale 16f7031799 get webhooks to sync subscription status 2025-11-15 15:42:13 +05:30
Atharva Deosthale 90e5824f9d progress, but dbplatform query always returns empty 2025-11-14 02:21:41 +05:30
Atharva Deosthale d7677a8992 progressing on webhook stuff 2025-11-14 01:21:23 +05:30
Atharva Deosthale 94390f4a65 add route to get team/user subscription 2025-11-13 23:30:37 +05:30
Atharva Deosthale f59cd158ef checkout gets created, yay 2025-11-13 18:41:32 +05:30
Atharva Deosthale 3967b2e338 remove temp files 2025-11-13 01:32:01 +05:30
Atharva Deosthale eb08a3379a very rough progress 2025-11-13 01:29:29 +05:30
Atharva Deosthale fee6ad43d6 progress 2025-11-12 19:09:55 +05:30
Atharva Deosthale 350e651c53 progress 2025-10-03 17:23:28 +05:30
Atharva Deosthale d49bad0de5 revert env 2025-09-30 23:46:44 +05:30
Atharva Deosthale e885cece85 format + lil cleanup 2025-09-30 23:44:14 +05:30
Atharva Deosthale 9b71c70d1a progress 2025-09-30 12:53:48 +05:30
Atharva Deosthale 414692083f remove env 2025-09-29 16:45:04 +05:30
Atharva Deosthale be1e681aa8 init payments 2025-09-29 16:44:11 +05:30
98 changed files with 28875 additions and 9679 deletions
+1 -1
View File
@@ -126,4 +126,4 @@ _APP_WEBHOOK_MAX_FAILED_ATTEMPTS=10
_APP_PROJECT_REGIONS=default
_APP_FUNCTIONS_CREATION_ABUSE_LIMIT=5000
_APP_STATS_USAGE_DUAL_WRITING_DBS=database_db_main
_APP_TRUSTED_HEADERS=x-forwarded-for
_APP_TRUSTED_HEADERS=x-forwarded-for
+3 -1
View File
@@ -87,7 +87,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
RUN mkdir -p /etc/letsencrypt/live/ && chmod -Rf 755 /etc/letsencrypt/live/
+4
View File
@@ -5,6 +5,7 @@ require_once __DIR__ . '/init.php';
use Appwrite\Event\Certificate;
use Appwrite\Event\Delete;
use Appwrite\Event\Func;
use Appwrite\Event\PaymentsUsage;
use Appwrite\Event\StatsResources;
use Appwrite\Event\StatsUsage;
use Appwrite\Platform\Appwrite;
@@ -228,6 +229,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()));
+11
View File
@@ -232,6 +232,17 @@ $platformCollections = [
'array' => false,
'filters' => ['json'],
],
[
'$id' => ID::custom('payments'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => 65536,
'signed' => true,
'required' => false,
'default' => [],
'array' => false,
'filters' => ['json', 'encrypt'],
],
[
'$id' => ID::custom('auths'),
'type' => Database::VAR_STRING,
+121
View File
@@ -2645,4 +2645,125 @@ return [
],
],
],
'payments_plans' => [
'$collection' => ID::custom(Database::METADATA),
'$id' => ID::custom('payments_plans'),
'name' => 'Payments Plans',
'attributes' => [
[ '$id' => ID::custom('planId'), 'type' => Database::VAR_STRING, 'size' => Database::LENGTH_KEY, 'signed' => true, 'required' => true, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('name'), 'type' => Database::VAR_STRING, 'size' => 2048, 'signed' => true, 'required' => false, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('description'), 'type' => Database::VAR_STRING, 'size' => 8192, 'signed' => true, 'required' => false, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('pricing'), 'type' => Database::VAR_STRING, 'size' => 65536, 'signed' => true, 'required' => false, 'default' => [], 'array' => false, 'filters' => ['json'] ],
[ '$id' => ID::custom('isDefault'), 'type' => Database::VAR_BOOLEAN, 'size' => 0, 'signed' => true, 'required' => false, 'default' => false, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('isFree'), 'type' => Database::VAR_BOOLEAN, 'size' => 0, 'signed' => true, 'required' => false, 'default' => false, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('status'), 'type' => Database::VAR_STRING, 'size' => 32, 'signed' => true, 'required' => false, 'default' => 'active', 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('providers'), 'type' => Database::VAR_STRING, 'size' => 65536, 'signed' => true, 'required' => false, 'default' => [], 'array' => false, 'filters' => ['json'] ],
[ '$id' => ID::custom('features'), 'type' => Database::VAR_STRING, 'size' => 65536, 'signed' => true, 'required' => false, 'default' => [], 'array' => false, 'filters' => ['json'] ],
[ '$id' => ID::custom('search'), 'type' => Database::VAR_STRING, 'size' => 16384, 'signed' => true, 'required' => false, 'default' => null, 'array' => false, 'filters' => [] ],
],
'indexes' => [
[ '$id' => ID::custom('_key_plan'), 'type' => Database::INDEX_UNIQUE, 'attributes' => ['planId'], 'lengths' => [Database::LENGTH_KEY], 'orders' => [Database::ORDER_ASC] ],
[ '$id' => ID::custom('_key_status'), 'type' => Database::INDEX_KEY, 'attributes' => ['status'], 'lengths' => [32], 'orders' => [Database::ORDER_ASC] ],
[ '$id' => ID::custom('_fulltext_search'), 'type' => Database::INDEX_FULLTEXT, 'attributes' => ['search'], 'lengths' => [], 'orders' => [] ],
],
],
'payments_features' => [
'$collection' => ID::custom(Database::METADATA),
'$id' => ID::custom('payments_features'),
'name' => 'Payments Features',
'attributes' => [
[ '$id' => ID::custom('featureId'), 'type' => Database::VAR_STRING, 'size' => Database::LENGTH_KEY, 'signed' => true, 'required' => true, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('name'), 'type' => Database::VAR_STRING, 'size' => 2048, 'signed' => true, 'required' => false, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('type'), 'type' => Database::VAR_STRING, 'size' => 32, 'signed' => true, 'required' => true, 'default' => 'boolean', 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('description'), 'type' => Database::VAR_STRING, 'size' => 8192, 'signed' => true, 'required' => false, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('providers'), 'type' => Database::VAR_STRING, 'size' => 65536, 'signed' => true, 'required' => false, 'default' => [], 'array' => false, 'filters' => ['json'] ],
],
'indexes' => [
[ '$id' => ID::custom('_key_feature'), 'type' => Database::INDEX_UNIQUE, 'attributes' => ['featureId'], 'lengths' => [Database::LENGTH_KEY], 'orders' => [Database::ORDER_ASC] ],
[ '$id' => ID::custom('_key_type'), 'type' => Database::INDEX_KEY, 'attributes' => ['type'], 'lengths' => [32], 'orders' => [Database::ORDER_ASC] ],
],
],
'payments_plan_features' => [
'$collection' => ID::custom(Database::METADATA),
'$id' => ID::custom('payments_plan_features'),
'name' => 'Payments Plan Features',
'attributes' => [
[ '$id' => ID::custom('planId'), 'type' => Database::VAR_STRING, 'size' => Database::LENGTH_KEY, 'signed' => true, 'required' => true, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('featureId'), 'type' => Database::VAR_STRING, 'size' => Database::LENGTH_KEY, 'signed' => true, 'required' => true, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('type'), 'type' => Database::VAR_STRING, 'size' => 32, 'signed' => true, 'required' => true, 'default' => 'boolean', 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('enabled'), 'type' => Database::VAR_BOOLEAN, 'size' => 0, 'signed' => true, 'required' => false, 'default' => true, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('currency'), 'type' => Database::VAR_STRING, 'size' => 8, 'signed' => true, 'required' => false, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('interval'), 'type' => Database::VAR_STRING, 'size' => 16, 'signed' => true, 'required' => false, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('includedUnits'), 'type' => Database::VAR_INTEGER, 'size' => 0, 'signed' => false, 'required' => false, 'default' => 0, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('tiersMode'), 'type' => Database::VAR_STRING, 'size' => 16, 'signed' => true, 'required' => false, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('tiers'), 'type' => Database::VAR_STRING, 'size' => 65536, 'signed' => true, 'required' => false, 'default' => [], 'array' => false, 'filters' => ['json'] ],
[ '$id' => ID::custom('usageCap'), 'type' => Database::VAR_INTEGER, 'size' => 0, 'signed' => false, 'required' => false, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('overagePrice'), 'type' => Database::VAR_INTEGER, 'size' => 0, 'signed' => false, 'required' => false, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('providers'), 'type' => Database::VAR_STRING, 'size' => 65536, 'signed' => true, 'required' => false, 'default' => [], 'array' => false, 'filters' => ['json'] ],
[ '$id' => ID::custom('metadata'), 'type' => Database::VAR_STRING, 'size' => 65536, 'signed' => true, 'required' => false, 'default' => [], 'array' => false, 'filters' => ['json'] ],
],
'indexes' => [
[ '$id' => ID::custom('_key_unique'), 'type' => Database::INDEX_UNIQUE, 'attributes' => ['planId','featureId'], 'lengths' => [Database::LENGTH_KEY, Database::LENGTH_KEY], 'orders' => [Database::ORDER_ASC, Database::ORDER_ASC] ],
[ '$id' => ID::custom('_key_type'), 'type' => Database::INDEX_KEY, 'attributes' => ['type'], 'lengths' => [16], 'orders' => [Database::ORDER_ASC] ],
],
],
'payments_subscriptions' => [
'$collection' => ID::custom(Database::METADATA),
'$id' => ID::custom('payments_subscriptions'),
'name' => 'Payments Subscriptions',
'attributes' => [
[ '$id' => ID::custom('subscriptionId'), 'type' => Database::VAR_STRING, 'size' => Database::LENGTH_KEY, 'signed' => true, 'required' => true, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('providerSubscriptionId'), 'type' => Database::VAR_STRING, 'size' => Database::LENGTH_KEY, 'signed' => true, 'required' => false, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('providerCheckoutId'), 'type' => Database::VAR_STRING, 'size' => Database::LENGTH_KEY, 'signed' => true, 'required' => false, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('actorType'), 'type' => Database::VAR_STRING, 'size' => 16, 'signed' => true, 'required' => true, 'default' => 'user', 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('actorId'), 'type' => Database::VAR_STRING, 'size' => Database::LENGTH_KEY, 'signed' => true, 'required' => true, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('actorInternalId'), 'type' => Database::VAR_STRING, 'size' => Database::LENGTH_KEY, 'signed' => true, 'required' => true, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('planId'), 'type' => Database::VAR_STRING, 'size' => Database::LENGTH_KEY, 'signed' => true, 'required' => true, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('priceId'), 'type' => Database::VAR_STRING, 'size' => Database::LENGTH_KEY, 'signed' => true, 'required' => false, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('status'), 'type' => Database::VAR_STRING, 'size' => 32, 'signed' => true, 'required' => false, 'default' => 'active', 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('trialEndsAt'), 'type' => Database::VAR_DATETIME, 'size' => 0, 'signed' => false, 'required' => false, 'default' => null, 'array' => false, 'filters' => ['datetime'] ],
[ '$id' => ID::custom('currentPeriodStart'), 'type' => Database::VAR_DATETIME, 'size' => 0, 'signed' => false, 'required' => false, 'default' => null, 'array' => false, 'filters' => ['datetime'] ],
[ '$id' => ID::custom('currentPeriodEnd'), 'type' => Database::VAR_DATETIME, 'size' => 0, 'signed' => false, 'required' => false, 'default' => null, 'array' => false, 'filters' => ['datetime'] ],
[ '$id' => ID::custom('cancelAtPeriodEnd'), 'type' => Database::VAR_BOOLEAN, 'size' => 0, 'signed' => true, 'required' => false, 'default' => false, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('canceledAt'), 'type' => Database::VAR_DATETIME, 'size' => 0, 'signed' => false, 'required' => false, 'default' => null, 'array' => false, 'filters' => ['datetime'] ],
[ '$id' => ID::custom('providers'), 'type' => Database::VAR_STRING, 'size' => 65536, 'signed' => true, 'required' => false, 'default' => [], 'array' => false, 'filters' => ['json'] ],
[ '$id' => ID::custom('usageSummary'), 'type' => Database::VAR_STRING, 'size' => 65536, 'signed' => true, 'required' => false, 'default' => [], 'array' => false, 'filters' => ['json'] ],
[ '$id' => ID::custom('tags'), 'type' => Database::VAR_STRING, 'size' => Database::LENGTH_KEY, 'signed' => true, 'required' => false, 'default' => [], 'array' => true, 'filters' => [] ],
[ '$id' => ID::custom('search'), 'type' => Database::VAR_STRING, 'size' => 16384, 'signed' => true, 'required' => false, 'default' => null, 'array' => false, 'filters' => [] ],
],
'indexes' => [
[ '$id' => ID::custom('_key_actor'), 'type' => Database::INDEX_KEY, 'attributes' => ['actorType','actorId'], 'lengths' => [16, Database::LENGTH_KEY], 'orders' => [Database::ORDER_ASC, Database::ORDER_ASC] ],
[ '$id' => ID::custom('_key_status'), 'type' => Database::INDEX_KEY, 'attributes' => ['status'], 'lengths' => [32], 'orders' => [Database::ORDER_ASC] ],
[ '$id' => ID::custom('_key_plan'), 'type' => Database::INDEX_KEY, 'attributes' => ['planId'], 'lengths' => [Database::LENGTH_KEY], 'orders' => [Database::ORDER_ASC] ],
[ '$id' => ID::custom('_key_provider_subscription'), 'type' => Database::INDEX_KEY, 'attributes' => ['providerSubscriptionId'], 'lengths' => [Database::LENGTH_KEY], 'orders' => [Database::ORDER_ASC] ],
[ '$id' => ID::custom('_key_provider_checkout'), 'type' => Database::INDEX_KEY, 'attributes' => ['providerCheckoutId'], 'lengths' => [Database::LENGTH_KEY], 'orders' => [Database::ORDER_ASC] ],
[ '$id' => ID::custom('_fulltext_search'), 'type' => Database::INDEX_FULLTEXT, 'attributes' => ['search'], 'lengths' => [], 'orders' => [] ],
],
],
'payments_usage_events' => [
'$collection' => ID::custom(Database::METADATA),
'$id' => ID::custom('payments_usage_events'),
'name' => 'Payments Usage Events',
'attributes' => [
[ '$id' => ID::custom('subscriptionId'), 'type' => Database::VAR_STRING, 'size' => Database::LENGTH_KEY, 'signed' => true, 'required' => true, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('actorType'), 'type' => Database::VAR_STRING, 'size' => 16, 'signed' => true, 'required' => true, 'default' => 'user', 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('actorId'), 'type' => Database::VAR_STRING, 'size' => Database::LENGTH_KEY, 'signed' => true, 'required' => true, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('planId'), 'type' => Database::VAR_STRING, 'size' => Database::LENGTH_KEY, 'signed' => true, 'required' => true, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('featureId'), 'type' => Database::VAR_STRING, 'size' => Database::LENGTH_KEY, 'signed' => true, 'required' => true, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('quantity'), 'type' => Database::VAR_INTEGER, 'size' => 0, 'signed' => false, 'required' => true, 'default' => 0, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('timestamp'), 'type' => Database::VAR_DATETIME, 'size' => 0, 'signed' => false, 'required' => true, 'default' => null, 'array' => false, 'filters' => ['datetime'] ],
[ '$id' => ID::custom('providerSyncState'), 'type' => Database::VAR_STRING, 'size' => 32, 'signed' => true, 'required' => false, 'default' => 'pending', 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('providerEventId'), 'type' => Database::VAR_STRING, 'size' => 256, 'signed' => true, 'required' => false, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('metadata'), 'type' => Database::VAR_STRING, 'size' => 65536, 'signed' => true, 'required' => false, 'default' => [], 'array' => false, 'filters' => ['json'] ],
],
'indexes' => [
[ '$id' => ID::custom('_key_subscription_feature_time'), 'type' => Database::INDEX_KEY, 'attributes' => ['subscriptionId','featureId','timestamp'], 'lengths' => [Database::LENGTH_KEY, Database::LENGTH_KEY, 0], 'orders' => [Database::ORDER_ASC, Database::ORDER_ASC, Database::ORDER_ASC] ],
[ '$id' => ID::custom('_key_sync_state'), 'type' => Database::INDEX_KEY, 'attributes' => ['providerSyncState'], 'lengths' => [32], 'orders' => [Database::ORDER_ASC] ],
],
],
];
+42
View File
@@ -1329,4 +1329,46 @@ return [
'description' => 'Target has an invalid provider type.',
'code' => 400,
],
/** Payments */
Exception::PAYMENT_PLAN_NOT_FOUND => [
'name' => Exception::PAYMENT_PLAN_NOT_FOUND,
'description' => 'Payment plan with the requested ID could not be found.',
'code' => 404,
],
Exception::PAYMENT_PLAN_ALREADY_EXISTS => [
'name' => Exception::PAYMENT_PLAN_ALREADY_EXISTS,
'description' => 'Payment plan with the requested ID already exists.',
'code' => 409,
],
Exception::PAYMENT_SUBSCRIPTION_NOT_FOUND => [
'name' => Exception::PAYMENT_SUBSCRIPTION_NOT_FOUND,
'description' => 'Payment subscription with the requested ID could not be found.',
'code' => 404,
],
Exception::PAYMENT_SUBSCRIPTION_ALREADY_EXISTS => [
'name' => Exception::PAYMENT_SUBSCRIPTION_ALREADY_EXISTS,
'description' => 'Payment subscription already exists for this actor.',
'code' => 409,
],
Exception::PAYMENT_PROVIDER_NOT_CONFIGURED => [
'name' => Exception::PAYMENT_PROVIDER_NOT_CONFIGURED,
'description' => 'No payment provider has been configured for this project.',
'code' => 400,
],
Exception::PAYMENT_PROVIDER_ALREADY_CONFIGURED => [
'name' => Exception::PAYMENT_PROVIDER_ALREADY_CONFIGURED,
'description' => 'Payment provider is already configured. Disconnect the existing provider before configuring a new one.',
'code' => 409,
],
Exception::PAYMENT_WEBHOOK_FAILED => [
'name' => Exception::PAYMENT_WEBHOOK_FAILED,
'description' => 'Failed to create payment provider webhook.',
'code' => 500,
],
Exception::PAYMENT_FEATURE_NOT_FOUND => [
'name' => Exception::PAYMENT_FEATURE_NOT_FOUND,
'description' => 'Payment feature with the requested ID could not be found.',
'code' => 404,
],
];
+9
View File
@@ -166,4 +166,13 @@ return [ // List of publicly visible scopes
'tokens.write' => [
'description' => 'Access to create, update, and delete your project\'s tokens',
],
'payments.read' => [
'description' => 'Access to read your project\'s payments data (plans, subscriptions, usage)'
],
'payments.write' => [
'description' => 'Access to create, update, and delete your project\'s payments resources'
],
'payments.subscribe' => [
'description' => 'Access to create and manage subscriptions as an actor (user/team)'
],
];
+13
View File
@@ -225,6 +225,19 @@ return [
'icon' => '/images/services/functions.png',
'platforms' => ['client', 'server', 'console'],
],
'payments' => [
'key' => 'payments',
'name' => 'Payments',
'subtitle' => 'Create and manage plans, subscriptions, usage and billing providers.',
'description' => '/docs/services/payments.md',
'controller' => '', // Uses modules
'sdk' => true,
'docs' => true,
'docsUrl' => '',
'tests' => false,
'optional' => true,
'icon' => '/images/services/databases.png',
],
'proxy' => [
'key' => 'proxy',
'name' => 'Proxy',
+12 -12
View File
@@ -1,7 +1,7 @@
{
"openapi": "3.0.0",
"info": {
"version": "1.8.0",
"version": "1.8.1",
"title": "Appwrite",
"description": "Appwrite backend as a service cuts up to 70% of the time and costs required for building a modern application. We abstract and simplify common development tasks behind a REST APIs, to help you develop your app in a fast and secure way. For full API documentation and tutorials go to [https:\/\/appwrite.io\/docs](https:\/\/appwrite.io\/docs)",
"termsOfService": "https:\/\/appwrite.io\/policy\/terms",
@@ -7318,7 +7318,7 @@
"x-appwrite": {
"method": "listExecutions",
"group": "executions",
"weight": 454,
"weight": 455,
"cookies": false,
"type": "",
"demo": "functions\/list-executions.md",
@@ -7405,7 +7405,7 @@
"x-appwrite": {
"method": "createExecution",
"group": "executions",
"weight": 452,
"weight": 453,
"cookies": false,
"type": "",
"demo": "functions\/create-execution.md",
@@ -7523,7 +7523,7 @@
"x-appwrite": {
"method": "getExecution",
"group": "executions",
"weight": 453,
"weight": 454,
"cookies": false,
"type": "",
"demo": "functions\/get-execution.md",
@@ -8298,7 +8298,7 @@
"x-appwrite": {
"method": "listFiles",
"group": "files",
"weight": 525,
"weight": 526,
"cookies": false,
"type": "",
"demo": "storage\/list-files.md",
@@ -8397,7 +8397,7 @@
"x-appwrite": {
"method": "createFile",
"group": "files",
"weight": 523,
"weight": 524,
"cookies": false,
"type": "upload",
"demo": "storage\/create-file.md",
@@ -8498,7 +8498,7 @@
"x-appwrite": {
"method": "getFile",
"group": "files",
"weight": 524,
"weight": 525,
"cookies": false,
"type": "",
"demo": "storage\/get-file.md",
@@ -8572,7 +8572,7 @@
"x-appwrite": {
"method": "updateFile",
"group": "files",
"weight": 526,
"weight": 527,
"cookies": false,
"type": "",
"demo": "storage\/update-file.md",
@@ -8664,7 +8664,7 @@
"x-appwrite": {
"method": "deleteFile",
"group": "files",
"weight": 527,
"weight": 528,
"cookies": false,
"type": "",
"demo": "storage\/delete-file.md",
@@ -8733,7 +8733,7 @@
"x-appwrite": {
"method": "getFileDownload",
"group": "files",
"weight": 529,
"weight": 530,
"cookies": false,
"type": "location",
"demo": "storage\/get-file-download.md",
@@ -8813,7 +8813,7 @@
"x-appwrite": {
"method": "getFilePreview",
"group": "files",
"weight": 528,
"weight": 529,
"cookies": false,
"type": "location",
"demo": "storage\/get-file-preview.md",
@@ -9043,7 +9043,7 @@
"x-appwrite": {
"method": "getFileView",
"group": "files",
"weight": 530,
"weight": 531,
"cookies": false,
"type": "location",
"demo": "storage\/get-file-view.md",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+12 -12
View File
@@ -1,7 +1,7 @@
{
"swagger": "2.0",
"info": {
"version": "1.8.0",
"version": "1.8.1",
"title": "Appwrite",
"description": "Appwrite backend as a service cuts up to 70% of the time and costs required for building a modern application. We abstract and simplify common development tasks behind a REST APIs, to help you develop your app in a fast and secure way. For full API documentation and tutorials go to [https:\/\/appwrite.io\/docs](https:\/\/appwrite.io\/docs)",
"termsOfService": "https:\/\/appwrite.io\/policy\/terms",
@@ -7375,7 +7375,7 @@
"x-appwrite": {
"method": "listExecutions",
"group": "executions",
"weight": 454,
"weight": 455,
"cookies": false,
"type": "",
"demo": "functions\/list-executions.md",
@@ -7458,7 +7458,7 @@
"x-appwrite": {
"method": "createExecution",
"group": "executions",
"weight": 452,
"weight": 453,
"cookies": false,
"type": "",
"demo": "functions\/create-execution.md",
@@ -7577,7 +7577,7 @@
"x-appwrite": {
"method": "getExecution",
"group": "executions",
"weight": 453,
"weight": 454,
"cookies": false,
"type": "",
"demo": "functions\/get-execution.md",
@@ -8379,7 +8379,7 @@
"x-appwrite": {
"method": "listFiles",
"group": "files",
"weight": 525,
"weight": 526,
"cookies": false,
"type": "",
"demo": "storage\/list-files.md",
@@ -8472,7 +8472,7 @@
"x-appwrite": {
"method": "createFile",
"group": "files",
"weight": 523,
"weight": 524,
"cookies": false,
"type": "upload",
"demo": "storage\/create-file.md",
@@ -8563,7 +8563,7 @@
"x-appwrite": {
"method": "getFile",
"group": "files",
"weight": 524,
"weight": 525,
"cookies": false,
"type": "",
"demo": "storage\/get-file.md",
@@ -8634,7 +8634,7 @@
"x-appwrite": {
"method": "updateFile",
"group": "files",
"weight": 526,
"weight": 527,
"cookies": false,
"type": "",
"demo": "storage\/update-file.md",
@@ -8725,7 +8725,7 @@
"x-appwrite": {
"method": "deleteFile",
"group": "files",
"weight": 527,
"weight": 528,
"cookies": false,
"type": "",
"demo": "storage\/delete-file.md",
@@ -8796,7 +8796,7 @@
"x-appwrite": {
"method": "getFileDownload",
"group": "files",
"weight": 529,
"weight": 530,
"cookies": false,
"type": "location",
"demo": "storage\/get-file-download.md",
@@ -8876,7 +8876,7 @@
"x-appwrite": {
"method": "getFilePreview",
"group": "files",
"weight": 528,
"weight": 529,
"cookies": false,
"type": "location",
"demo": "storage\/get-file-preview.md",
@@ -9084,7 +9084,7 @@
"x-appwrite": {
"method": "getFileView",
"group": "files",
"weight": 530,
"weight": 531,
"cookies": false,
"type": "location",
"demo": "storage\/get-file-view.md",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1
View File
@@ -360,6 +360,7 @@ const RESOURCE_TYPE_TOPICS = 'topics';
const RESOURCE_TYPE_SUBSCRIBERS = 'subscribers';
const RESOURCE_TYPE_MESSAGES = 'messages';
const RESOURCE_TYPE_EXECUTIONS = 'executions';
const RESOURCE_TYPE_PAYMENTS = 'payments';
// Resource types for Tokens
const TOKENS_RESOURCE_TYPE_FILES = 'files';
+22
View File
@@ -94,6 +94,13 @@ use Appwrite\Utopia\Response\Model\MigrationReport;
use Appwrite\Utopia\Response\Model\Mock;
use Appwrite\Utopia\Response\Model\MockNumber;
use Appwrite\Utopia\Response\Model\None;
use Appwrite\Utopia\Response\Model\PaymentFeature;
use Appwrite\Utopia\Response\Model\PaymentInvoice;
use Appwrite\Utopia\Response\Model\PaymentPlan;
use Appwrite\Utopia\Response\Model\PaymentPlanFeature;
use Appwrite\Utopia\Response\Model\PaymentProviderConfig;
use Appwrite\Utopia\Response\Model\PaymentSubscription;
use Appwrite\Utopia\Response\Model\PaymentUsageEvent;
use Appwrite\Utopia\Response\Model\Phone;
use Appwrite\Utopia\Response\Model\Platform;
use Appwrite\Utopia\Response\Model\Preferences;
@@ -340,5 +347,20 @@ Response::setModel(new Migration());
Response::setModel(new MigrationReport());
Response::setModel(new MigrationFirebaseProject());
// Payments
Response::setModel(new PaymentPlan());
Response::setModel(new PaymentFeature());
Response::setModel(new PaymentSubscription());
Response::setModel(new PaymentProviderConfig());
Response::setModel(new PaymentPlanFeature());
Response::setModel(new PaymentInvoice());
Response::setModel(new PaymentUsageEvent());
Response::setModel(new BaseList('Payment Subscription List', Response::MODEL_PAYMENT_SUBSCRIPTION_LIST, 'subscriptions', Response::MODEL_PAYMENT_SUBSCRIPTION, true, true));
Response::setModel(new BaseList('Payment Plan List', Response::MODEL_PAYMENT_PLAN_LIST, 'plans', Response::MODEL_PAYMENT_PLAN, true, true));
Response::setModel(new BaseList('Payment Feature List', Response::MODEL_PAYMENT_FEATURE_LIST, 'features', Response::MODEL_PAYMENT_FEATURE, true, true));
Response::setModel(new BaseList('Payment Plan Feature List', Response::MODEL_PAYMENT_PLAN_FEATURE_LIST, 'features', Response::MODEL_PAYMENT_PLAN_FEATURE, true, true));
Response::setModel(new BaseList('Payment Invoice List', Response::MODEL_PAYMENT_INVOICE_LIST, 'invoices', Response::MODEL_PAYMENT_INVOICE, true, true));
Response::setModel(new BaseList('Payment Usage Event List', Response::MODEL_PAYMENT_USAGE_EVENT_LIST, 'events', Response::MODEL_PAYMENT_USAGE_EVENT, true, true));
// Tests (keep last)
Response::setModel(new Mock());
+5
View File
@@ -388,3 +388,8 @@ $register->set('promiseAdapter', function () {
$register->set('hooks', function () {
return new Hooks();
});
$register->set('registryPayments', function () {
$registry = new \Appwrite\Payments\Provider\Registry();
$registry->register('stripe', \Appwrite\Payments\Provider\StripeAdapter::class);
return $registry;
});
+3
View File
@@ -82,6 +82,9 @@ App::setResource('hooks', function ($register) {
}, ['register']);
App::setResource('register', fn () => $register);
App::setResource('registryPayments', function ($register) {
return $register->get('registryPayments');
}, ['register']);
App::setResource('locale', function () {
$locale = new Locale(System::getEnv('_APP_LOCALE', 'en'));
$locale->setFallback(System::getEnv('_APP_LOCALE', 'en'));
+4
View File
@@ -382,6 +382,10 @@ Server::setResource('plan', function (array $plan = []) {
return [];
});
Server::setResource('registryPayments', function (Registry $register) {
return $register->get('registryPayments');
}, ['register']);
Server::setResource('certificates', function () {
$email = System::getEnv('_APP_EMAIL_CERTIFICATES', System::getEnv('_APP_SYSTEM_SECURITY_EMAIL_ADDRESS'));
if (empty($email)) {
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
php /usr/src/code/app/cli.php schedule-payments-usage $@
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
php /usr/src/code/app/worker.php payments-usage-sync $@
Generated
+8148 -8960
View File
File diff suppressed because it is too large Load Diff
+59
View File
@@ -917,6 +917,65 @@ services:
- _APP_USAGE_AGGREGATION_INTERVAL
- _APP_DATABASE_SHARED_TABLES
appwrite-worker-payments-usage:
entrypoint: worker-payments-usage
<<: *x-logging
container_name: appwrite-worker-payments-usage
image: appwrite-dev
networks:
- appwrite
volumes:
- ./app:/usr/src/code/app
- ./src:/usr/src/code/src
depends_on:
- redis
- mariadb
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
- _APP_OPENSSL_KEY_V1
- _APP_DB_HOST
- _APP_DB_PORT
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_REDIS_USER
- _APP_REDIS_PASS
- _APP_LOGGING_CONFIG
- _APP_DATABASE_SHARED_TABLES
appwrite-task-scheduler-payments-usage:
entrypoint: schedule-payments-usage
<<: *x-logging
container_name: appwrite-task-scheduler-payments-usage
image: appwrite-dev
networks:
- appwrite
volumes:
- ./app:/usr/src/code/app
- ./src:/usr/src/code/src
depends_on:
- mariadb
- redis
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
- _APP_OPENSSL_KEY_V1
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_REDIS_USER
- _APP_REDIS_PASS
- _APP_DB_HOST
- _APP_DB_PORT
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_LOGGING_CONFIG
- _APP_DATABASE_SHARED_TABLES
- _APP_PAYMENTS_USAGE_SYNC_INTERVAL
appwrite-task-scheduler-functions:
entrypoint: schedule-functions
<<: *x-logging
+3
View File
@@ -45,6 +45,9 @@ class Event
public const MIGRATIONS_QUEUE_NAME = 'v1-migrations';
public const MIGRATIONS_CLASS_NAME = 'MigrationsV1';
public const PAYMENTS_USAGE_QUEUE_NAME = 'v1-payments-usage-sync';
public const PAYMENTS_USAGE_CLASS_NAME = 'PaymentsUsageSyncV1';
protected string $queue = '';
protected string $class = '';
protected string $event = '';
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace Appwrite\Event;
use Utopia\Queue\Publisher;
class PaymentsUsage extends Event
{
public function __construct(protected Publisher $publisher)
{
parent::__construct($publisher);
$this
->setQueue(Event::PAYMENTS_USAGE_QUEUE_NAME)
->setClass(Event::PAYMENTS_USAGE_CLASS_NAME);
}
/**
* Prepare the payload for the event
*
* @return array
*/
protected function preparePayload(): array
{
return [
'project' => $this->getProject(),
];
}
}
+11
View File
@@ -62,6 +62,7 @@ class Exception extends \Exception
public const string GENERAL_INVALID_PHONE = 'general_invalid_phone';
public const string GENERAL_REGION_ACCESS_DENIED = 'general_region_access_denied';
public const string GENERAL_BAD_REQUEST = 'general_bad_request';
public const string GENERAL_NOT_FOUND = 'general_not_found';
/** Users */
public const string USER_COUNT_EXCEEDED = 'user_count_exceeded';
@@ -378,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;
@@ -0,0 +1,47 @@
<?php
namespace Appwrite\Payments\Provider;
use Utopia\Database\Document;
interface Adapter
{
public function getIdentifier(): string;
public function configure(array $config, Document $project): ProviderState;
public function ensurePlan(array $planData, ProviderState $state): ProviderPlanRef;
public function updatePlan(array $planData, ProviderPlanRef $reference, ProviderState $state): ProviderPlanRef;
public function deletePlan(ProviderPlanRef $reference, ProviderState $state): void;
public function ensureFeature(array $featureData, ProviderPlanRef $plan, ProviderState $state): ProviderFeatureRef;
public function deleteFeature(ProviderFeatureRef $feature, ProviderPlanRef $plan, ProviderState $state): void;
public function updateSubscription(ProviderSubscriptionRef $subscription, array $changes, ProviderState $state): ProviderSubscriptionRef;
public function cancelSubscription(ProviderSubscriptionRef $subscription, bool $atPeriodEnd, ProviderState $state): ProviderSubscriptionRef;
public function resumeSubscription(ProviderSubscriptionRef $subscription, ProviderState $state): ProviderSubscriptionRef;
public function createCheckoutSession(Document $actor, array $planContext, ProviderState $state, array $options = []): ProviderCheckoutSession;
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;
public function handleWebhook(array $payload, ProviderState $state): ProviderWebhookResult;
public function testConnection(array $config): ProviderTestResult;
}
@@ -0,0 +1,12 @@
<?php
namespace Appwrite\Payments\Provider;
class ProviderCheckoutSession
{
public function __construct(
public readonly string $url,
public readonly array $metadata = [],
) {
}
}
@@ -0,0 +1,12 @@
<?php
namespace Appwrite\Payments\Provider;
class ProviderFeatureRef
{
public function __construct(
public readonly string $externalFeatureId,
public readonly array $metadata = [],
) {
}
}
@@ -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,12 @@
<?php
namespace Appwrite\Payments\Provider;
class ProviderPlanRef
{
public function __construct(
public readonly string $externalPlanId,
public readonly array $metadata = [],
) {
}
}
@@ -0,0 +1,12 @@
<?php
namespace Appwrite\Payments\Provider;
class ProviderPortalSession
{
public function __construct(
public readonly string $url,
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 = [],
) {
}
}
@@ -0,0 +1,13 @@
<?php
namespace Appwrite\Payments\Provider;
class ProviderState
{
public function __construct(
public readonly string $providerId,
public readonly array $config = [],
public readonly array $metadata = [],
) {
}
}
@@ -0,0 +1,12 @@
<?php
namespace Appwrite\Payments\Provider;
class ProviderSubscriptionRef
{
public function __construct(
public readonly string $externalSubscriptionId,
public readonly array $metadata = [],
) {
}
}
@@ -0,0 +1,13 @@
<?php
namespace Appwrite\Payments\Provider;
class ProviderTestResult
{
public function __construct(
public readonly bool $success,
public readonly string $message = '',
public readonly array $metadata = [],
) {
}
}
@@ -0,0 +1,12 @@
<?php
namespace Appwrite\Payments\Provider;
class ProviderUsageReport
{
public function __construct(
public readonly array $totals = [],
public readonly array $details = [],
) {
}
}
@@ -0,0 +1,12 @@
<?php
namespace Appwrite\Payments\Provider;
class ProviderWebhookResult
{
public function __construct(
public readonly string $status,
public readonly array $changes = [],
) {
}
}
@@ -0,0 +1,45 @@
<?php
namespace Appwrite\Payments\Provider;
use Utopia\Database\Document;
class Registry
{
/**
* @var array<string, class-string<Adapter>>
*/
private array $map = [];
/**
* @var array<string, Adapter>
*/
private array $cache = [];
public function __construct()
{
// Map provider identifiers to adapter classes
// e.g., $this->map['stripe'] = \Appwrite\Payments\Provider\StripeAdapter::class;
}
public function register(string $identifier, string $adapterClass): void
{
$this->map[$identifier] = $adapterClass;
}
public function get(string $identifier, array $config, Document $project, \Utopia\Database\Database $dbForPlatform, \Utopia\Database\Database $dbForProject): Adapter
{
$cacheKey = $identifier . ':' . $project->getId();
if (isset($this->cache[$cacheKey])) {
return $this->cache[$cacheKey];
}
if (!isset($this->map[$identifier])) {
throw new \RuntimeException('Unknown payments provider: ' . $identifier);
}
$class = $this->map[$identifier];
/** @var Adapter $adapter */
$adapter = new $class($config, $project, $dbForProject, $dbForPlatform);
$this->cache[$cacheKey] = $adapter;
return $adapter;
}
}
File diff suppressed because it is too large Load Diff
+2
View File
@@ -7,6 +7,7 @@ use Appwrite\Platform\Modules\Console;
use Appwrite\Platform\Modules\Core;
use Appwrite\Platform\Modules\Databases;
use Appwrite\Platform\Modules\Functions;
use Appwrite\Platform\Modules\Payments;
use Appwrite\Platform\Modules\Projects;
use Appwrite\Platform\Modules\Proxy;
use Appwrite\Platform\Modules\Sites;
@@ -28,5 +29,6 @@ class Appwrite extends Platform
$this->addModule(new Proxy\Module());
$this->addModule(new Tokens\Module());
$this->addModule(new Storage\Module());
$this->addModule(new Payments\Module());
}
}
@@ -0,0 +1,233 @@
<?php
namespace Appwrite\Platform\Modules\Payments\Http\ActorFeatures;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Database\Documents\User;
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: 'getActorFeatures',
description: <<<EOT
Get the features available to a specific actor (user or team) based on their active subscription.
EOT,
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')
->param('actorId', 'me', new Text(128), 'Actor ID. Use "me" or "current" for the logged-in user.')
->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 = User::isApp($roles);
$isPrivileged = User::isPrivileged($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);
}
}
@@ -0,0 +1,101 @@
<?php
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;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\Text;
class Create extends Base
{
use HTTP;
public static function getName()
{
return 'createPaymentFeature';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
->setHttpPath('/v1/payments/features')
->groups(['api', 'payments'])
->desc('Create payment feature')
->label('scope', 'payments.write')
->label('resourceType', RESOURCE_TYPE_PAYMENTS)
->label('event', 'payments.features.[featureId].create')
->label('audits.event', 'payments.feature.create')
->label('audits.resource', 'payments/feature/{request.featureId}')
->label('sdk', new Method(
namespace: 'payments',
group: 'features',
name: 'createFeature',
description: <<<EOT
Create a new feature that can be assigned to plans. Features can be boolean (on/off) or metered (usage-based).
EOT,
auth: [AuthType::KEY, AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_CREATED,
model: Response::MODEL_PAYMENT_FEATURE,
)
]
))
->param('featureId', '', new Text(128), 'Feature ID')
->param('name', '', new Text(2048), 'Feature name')
->param('type', 'boolean', new Text(32), 'Feature type')
->param('description', '', new Text(8192, 0), 'Description', true)
->inject('response')
->inject('dbForPlatform')
->inject('dbForProject')
->inject('project')
->inject('queueForEvents')
->callback($this->action(...));
}
public function action(
string $featureId,
string $name,
string $type,
string $description,
Response $response,
Database $dbForPlatform,
Database $dbForProject,
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) {
throw new AppwriteException(ExtendException::GENERAL_ACCESS_FORBIDDEN, 'Payments feature is disabled for this project');
}
$doc = new Document([
'featureId' => $featureId,
'name' => $name,
'type' => $type,
'description' => $description,
'providers' => []
]);
$created = $dbForProject->createDocument('payments_features', $doc);
$queueForEvents
->setParam('featureId', $featureId)
->setPayload($created->getArrayCopy());
$response->setStatusCode(Response::STATUS_CODE_CREATED);
$response->dynamic($created, Response::MODEL_PAYMENT_FEATURE);
}
}
@@ -0,0 +1,95 @@
<?php
namespace Appwrite\Platform\Modules\Payments\Http\Features;
use Appwrite\Event\Event;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
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 Delete extends Base
{
use HTTP;
public static function getName()
{
return 'deletePaymentFeature';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE)
->setHttpPath('/v1/payments/features/:featureId')
->groups(['api', 'payments'])
->desc('Delete payment feature')
->label('scope', 'payments.write')
->label('resourceType', RESOURCE_TYPE_PAYMENTS)
->label('event', 'payments.features.[featureId].delete')
->label('audits.event', 'payments.feature.delete')
->label('audits.resource', 'payments/feature/{request.featureId}')
->label('sdk', new Method(
namespace: 'payments',
group: 'features',
name: 'deleteFeature',
description: <<<EOT
Delete a feature by its unique ID.
EOT,
auth: [AuthType::KEY, AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_NOCONTENT,
model: Response::MODEL_NONE,
)
],
contentType: ContentType::NONE
))
->param('featureId', '', new Text(128), 'Feature ID')
->inject('response')
->inject('dbForPlatform')
->inject('dbForProject')
->inject('project')
->inject('queueForEvents')
->callback($this->action(...));
}
public function action(
string $featureId,
Response $response,
Database $dbForPlatform,
Database $dbForProject,
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) {
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()) {
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,70 @@
<?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: 'getFeature',
description: <<<EOT
Get a feature by its unique ID.
EOT,
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);
}
}
@@ -0,0 +1,99 @@
<?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\Document;
use Utopia\Database\Query;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\Text;
class Update extends Base
{
use HTTP;
public static function getName()
{
return 'updatePaymentFeature';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT)
->setHttpPath('/v1/payments/features/:featureId')
->groups(['api', 'payments'])
->desc('Update payment feature')
->label('scope', 'payments.write')
->label('resourceType', RESOURCE_TYPE_PAYMENTS)
->label('event', 'payments.features.[featureId].update')
->label('audits.event', 'payments.feature.update')
->label('audits.resource', 'payments/feature/{request.featureId}')
->label('sdk', new Method(
namespace: 'payments',
group: 'features',
name: 'updateFeature',
description: <<<EOT
Update a feature by its unique ID.
EOT,
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')
->param('name', '', new Text(2048), 'Feature name', true)
->param('type', 'boolean', new Text(32), 'Feature type', true)
->param('description', '', new Text(8192, 0), 'Description', true)
->inject('response')
->inject('dbForPlatform')
->inject('dbForProject')
->inject('project')
->callback($this->action(...));
}
public function action(
string $featureId,
string $name,
string $type,
string $description,
Response $response,
Database $dbForPlatform,
Database $dbForProject,
Document $project
) {
// 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) {
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()) {
throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::PAYMENT_FEATURE_NOT_FOUND);
}
if ($name !== '') {
$feature->setAttribute('name', $name);
}
if ($type !== '') {
$feature->setAttribute('type', $type);
}
if ($description !== '') {
$feature->setAttribute('description', $description);
}
$feature = $dbForProject->updateDocument('payments_features', $feature->getId(), $feature);
$response->dynamic($feature, Response::MODEL_PAYMENT_FEATURE);
}
}
@@ -0,0 +1,120 @@
<?php
namespace Appwrite\Platform\Modules\Payments\Http\Features;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Database\Validator\Queries\PaymentFeatures;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Exception\Order as OrderException;
use Utopia\Database\Exception\Query as QueryException;
use Utopia\Database\Query;
use Utopia\Database\Validator\Query\Cursor;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\Boolean;
use Utopia\Validator\Text;
class XList extends Base
{
use HTTP;
public static function getName()
{
return 'listPaymentFeatures';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/payments/features')
->groups(['api', 'payments'])
->desc('List payment features')
->label('scope', 'payments.read')
->label('resourceType', RESOURCE_TYPE_PAYMENTS)
->label('sdk', new Method(
namespace: 'payments',
group: 'features',
name: 'listFeatures',
description: <<<EOT
Get a list of all features configured for the project.
EOT,
auth: [AuthType::KEY, AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_PAYMENT_FEATURE_LIST,
)
]
))
->param('queries', [], new PaymentFeatures(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', PaymentFeatures::ALLOWED_ATTRIBUTES), true)
->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true)
->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
->inject('response')
->inject('dbForProject')
->callback($this->action(...));
}
public function action(
array $queries,
string $search,
bool $includeTotal,
Response $response,
Database $dbForProject
) {
try {
$queries = Query::parseQueries($queries);
} catch (QueryException $e) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
}
if (!empty($search)) {
$queries[] = Query::search('search', $search);
}
/**
* Get cursor document if there was a cursor query, we use array_filter and reset for reference $cursor to $queries
*/
$cursor = \array_filter($queries, function ($query) {
return \in_array($query->getMethod(), [Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE]);
});
$cursor = reset($cursor);
if ($cursor) {
/** @var Query $cursor */
$validator = new Cursor();
if (!$validator->isValid($cursor)) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription());
}
$featureId = $cursor->getValue();
$cursorDocument = $dbForProject->getDocument('payments_features', $featureId);
if ($cursorDocument->isEmpty()) {
throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Feature '{$featureId}' for the 'cursor' value not found.");
}
$cursor->setValue($cursorDocument);
}
$filterQueries = Query::groupByType($queries)['filters'];
try {
$features = $dbForProject->find('payments_features', $queries);
$total = $includeTotal ? $dbForProject->count('payments_features', $filterQueries, APP_LIMIT_COUNT) : 0;
} catch (OrderException $e) {
throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null.");
}
$response->dynamic(new Document([
'features' => $features,
'total' => $total,
]), Response::MODEL_PAYMENT_FEATURE_LIST);
}
}
@@ -0,0 +1,197 @@
<?php
namespace Appwrite\Platform\Modules\Payments\Http\Invoices;
use Appwrite\Extend\Exception;
use Appwrite\Payments\Provider\ProviderState;
use Appwrite\Payments\Provider\ProviderSubscriptionRef;
use Appwrite\Payments\Provider\Registry;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
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: <<<EOT
Get a list of invoices for a subscription from the payment provider.
EOT,
auth: [AuthType::KEY, AuthType::ADMIN, AuthType::JWT],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_PAYMENT_INVOICE_LIST,
)
]
))
->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) {
throw new Exception(Exception::GENERAL_ACCESS_FORBIDDEN, 'Payments feature is disabled for this project');
}
// Validate limit
if ($limit < 1 || $limit > 100) {
throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Limit must be between 1 and 100');
}
// Validate offset
if ($offset < 0) {
throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Offset must be non-negative');
}
// 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 from subscription
$subscriptionProviders = (array) $subscription->getAttribute('providers', []);
$providerId = array_key_first($subscriptionProviders);
if (!$providerId) {
// No provider on subscription, return empty list
$response->dynamic(new Document([
'invoices' => [],
'total' => 0,
]), Response::MODEL_PAYMENT_INVOICE_LIST);
return;
}
$providerData = (array) ($subscriptionProviders[$providerId] ?? []);
$providerSubscriptionId = (string) ($providerData['providerSubscriptionId'] ?? '');
if ($providerSubscriptionId === '') {
// No provider subscription yet, return empty list
$response->dynamic(new Document([
'invoices' => [],
'total' => 0,
]), Response::MODEL_PAYMENT_INVOICE_LIST);
return;
}
// Get payment provider configuration
$providers = (array) ($paymentsCfg['providers'] ?? []);
if (!isset($providers[$providerId])) {
throw new Exception(Exception::PAYMENT_PROVIDER_NOT_CONFIGURED, 'Payment provider not configured for this project');
}
$config = (array) ($providers[$providerId] ?? []);
$adapter = $registryPayments->get((string) $providerId, $config, $projDoc, $dbForPlatform, $dbForProject);
$state = new ProviderState((string) $providerId, $config, (array) ($config['state'] ?? []));
// Create provider subscription reference
$providerSubRef = new ProviderSubscriptionRef(
externalSubscriptionId: $providerSubscriptionId
);
try {
$invoices = $adapter->listInvoices($providerSubRef, $state, $limit, $offset);
} catch (\Throwable $e) {
throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to fetch invoices: ' . $e->getMessage());
}
// Format invoices for response
$formattedInvoices = [];
foreach ($invoices as $invoice) {
$formattedInvoices[] = new Document([
'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->dynamic(new Document([
'invoices' => $formattedInvoices,
'total' => count($formattedInvoices),
]), Response::MODEL_PAYMENT_INVOICE_LIST);
}
}
@@ -0,0 +1,179 @@
<?php
namespace Appwrite\Platform\Modules\Payments\Http\PlanFeatures;
use Appwrite\Event\Audit;
use Appwrite\Event\Event;
use Appwrite\Payments\Provider\ProviderState;
use Appwrite\Payments\Provider\Registry;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\Platform\Modules\Payments\Validator\FeatureTier;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
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\ArrayList;
use Utopia\Validator\Integer;
use Utopia\Validator\Text;
class Assign extends Base
{
use HTTP;
public static function getName()
{
return 'assignPaymentPlanFeature';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
->setHttpPath('/v1/payments/plans/:planId/features')
->groups(['api', 'payments'])
->desc('Assign feature to plan')
->label('scope', 'payments.write')
->label('resourceType', RESOURCE_TYPE_PAYMENTS)
->label('event', 'payments.plans.[planId].features.[featureId].assign')
->label('audits.event', 'payments.plan.feature.assign')
->label('audits.resource', 'payments/plan/{request.planId}/feature/{request.featureId}')
->label('sdk', new Method(
namespace: 'payments',
group: 'planFeatures',
name: 'assignPlanFeature',
description: <<<EOT
Assign a feature to a plan with optional usage limits and pricing tiers for metered features.
EOT,
auth: [AuthType::KEY, AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_CREATED,
model: Response::MODEL_ANY,
)
]
))
->param('planId', '', new Text(128), 'Plan ID')
->param('featureId', '', new Text(128), 'Feature ID')
->param('currency', '', new Text(8, 0), 'Currency code', true)
->param('interval', '', new Text(16, 0), 'Billing interval', true)
->param('includedUnits', 0, new Integer(), 'Included units', true)
->param('tiersMode', null, new Text(32, 0), 'Tiers mode (graduated or volume)', true)
->param('tiers', [], new ArrayList(new FeatureTier(), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Pricing tiers', true)
->param('usageCap', null, new Integer(), 'Usage cap', true)
->inject('response')
->inject('dbForPlatform')
->inject('dbForProject')
->inject('registryPayments')
->inject('project')
->inject('queueForEvents')
->inject('queueForAudits')
->callback($this->action(...));
}
public function action(
string $planId,
string $featureId,
string $currency,
string $interval,
int $includedUnits,
?string $tiersMode,
array $tiers,
?int $usageCap,
Response $response,
Database $dbForPlatform,
Database $dbForProject,
Registry $registryPayments,
Document $project,
Event $queueForEvents,
Audit $queueForAudits
) {
// 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) {
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()) {
throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::PAYMENT_PLAN_NOT_FOUND);
}
// Infer feature type from existing feature document
$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);
}
$type = (string) strtolower((string) $feature->getAttribute('type', 'boolean'));
$doc = new Document([
'planId' => $planId,
'featureId' => $featureId,
'type' => $type,
'enabled' => true,
'currency' => $currency,
'interval' => $interval,
'includedUnits' => $includedUnits,
'tiersMode' => $tiersMode,
'tiers' => $tiers,
'usageCap' => $usageCap,
'overagePrice' => null,
'providers' => [],
'metadata' => []
]);
$created = $dbForProject->createDocument('payments_plan_features', $doc);
// Provision feature with each provider and persist refs (metered only)
$planProviders = (array) $plan->getAttribute('providers', []);
$payments = (array) $project->getAttribute('payments', []);
$providerConfigs = (array) ($payments['providers'] ?? []);
$providersMeta = [];
if ($type === 'metered') {
foreach ($providerConfigs as $providerId => $providerConfig) {
$state = new ProviderState((string) $providerId, (array) $providerConfig, (array) ($providerConfig['state'] ?? []));
$adapter = $registryPayments->get((string) $providerId, (array) $providerConfig, $project, $dbForPlatform, $dbForProject);
$planRef = new \Appwrite\Payments\Provider\ProviderPlanRef((string) ($planProviders[$providerId]['externalId'] ?? ''), (array) ($planProviders[$providerId]['metadata'] ?? []));
$featRef = $adapter->ensureFeature([
'featureId' => $featureId,
'name' => $featureId,
'currency' => $currency,
'interval' => $interval,
'includedUnits' => $includedUnits,
'planId' => $planId,
'type' => $type,
'tiersMode' => $tiersMode,
'tiers' => $tiers,
'usageCap' => $usageCap,
], $planRef, $state);
$providersMeta[$providerId] = [ 'priceId' => $featRef->metadata['priceId'] ?? null, 'meterId' => $featRef->metadata['meterId'] ?? null ];
}
}
if (!empty($providersMeta)) {
$created->setAttribute('providers', $providersMeta);
$created = $dbForProject->updateDocument('payments_plan_features', $created->getId(), $created);
}
$queueForEvents
->setProject($project)
->setEvent('payments.[planId].features.[featureId].assign')
->setParam('planId', $planId)
->setParam('featureId', $featureId)
->setPayload($created->getArrayCopy());
$queueForAudits
->setProject($project)
->setPayload($created->getArrayCopy());
$response->setStatusCode(Response::STATUS_CODE_CREATED);
$response->json($created->getArrayCopy());
}
}
@@ -0,0 +1,128 @@
<?php
namespace Appwrite\Platform\Modules\Payments\Http\PlanFeatures;
use Appwrite\Event\Audit;
use Appwrite\Event\Event;
use Appwrite\Payments\Provider\ProviderState;
use Appwrite\Payments\Provider\Registry;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
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 Remove extends Base
{
use HTTP;
public static function getName()
{
return 'removePaymentPlanFeature';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE)
->setHttpPath('/v1/payments/plans/:planId/features/:featureId')
->groups(['api', 'payments'])
->desc('Remove feature from plan')
->label('scope', 'payments.write')
->label('resourceType', RESOURCE_TYPE_PAYMENTS)
->label('event', 'payments.plans.[planId].features.[featureId].remove')
->label('audits.event', 'payments.plan.feature.remove')
->label('audits.resource', 'payments/plan/{request.planId}/feature/{request.featureId}')
->label('sdk', new Method(
namespace: 'payments',
group: 'planFeatures',
name: 'removePlanFeature',
description: <<<EOT
Remove a feature from a plan.
EOT,
auth: [AuthType::KEY, AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_NOCONTENT,
model: Response::MODEL_NONE,
)
],
contentType: ContentType::NONE
))
->param('planId', '', new Text(128), 'Plan ID')
->param('featureId', '', new Text(128), 'Feature ID')
->inject('response')
->inject('dbForPlatform')
->inject('dbForProject')
->inject('registryPayments')
->inject('project')
->inject('queueForEvents')
->inject('queueForAudits')
->callback($this->action(...));
}
public function action(
string $planId,
string $featureId,
Response $response,
Database $dbForPlatform,
Database $dbForProject,
Registry $registryPayments,
Document $project,
Event $queueForEvents,
Audit $queueForAudits
) {
// 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) {
throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::GENERAL_ACCESS_FORBIDDEN, 'Payments feature is disabled for this project');
}
$assignment = $dbForProject->findOne('payments_plan_features', [
Query::equal('planId', [$planId]),
Query::equal('featureId', [$featureId])
]);
if ($assignment === null || $assignment->isEmpty()) {
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', [
Query::equal('planId', [$planId])
]);
$planProviders = (array) ($plan?->getAttribute('providers', []) ?? []);
$providersMeta = (array) $assignment->getAttribute('providers', []);
$payments = (array) $project->getAttribute('payments', []);
$providerConfigs = (array) ($payments['providers'] ?? []);
foreach ($providerConfigs as $providerId => $providerConfig) {
$state = new ProviderState((string) $providerId, (array) $providerConfig, (array) ($providerConfig['state'] ?? []));
$adapter = $registryPayments->get((string) $providerId, (array) $providerConfig, $project, $dbForPlatform, $dbForProject);
$providerPlan = (array) ($planProviders[$providerId] ?? []);
$providerAssignment = (array) ($providersMeta[$providerId] ?? []);
$planRef = new \Appwrite\Payments\Provider\ProviderPlanRef((string) ($providerPlan['externalId'] ?? ''), (array) ($providerPlan['metadata'] ?? []));
$featRef = new \Appwrite\Payments\Provider\ProviderFeatureRef((string) ($providerAssignment['priceId'] ?? ''), (array) $providerAssignment);
$adapter->deleteFeature($featRef, $planRef, $state);
}
$dbForProject->deleteDocument('payments_plan_features', $assignment->getId());
$queueForEvents
->setProject($project)
->setEvent('payments.[planId].features.[featureId].remove')
->setParam('planId', $planId)
->setParam('featureId', $featureId)
->setPayload(['planId' => $planId, 'featureId' => $featureId]);
$queueForAudits
->setProject($project)
->setPayload(['planId' => $planId, 'featureId' => $featureId]);
$response->noContent();
}
}
@@ -0,0 +1,125 @@
<?php
namespace Appwrite\Platform\Modules\Payments\Http\PlanFeatures;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Database\Validator\Queries\PaymentPlanFeatures;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Exception\Order as OrderException;
use Utopia\Database\Exception\Query as QueryException;
use Utopia\Database\Query;
use Utopia\Database\Validator\Query\Cursor;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\Boolean;
use Utopia\Validator\Text;
class XList extends Base
{
use HTTP;
public static function getName()
{
return 'listPaymentPlanFeatures';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/payments/plans/:planId/features')
->groups(['api', 'payments'])
->desc('List plan features')
->label('scope', 'payments.read')
->label('resourceType', RESOURCE_TYPE_PAYMENTS)
->label('sdk', new Method(
namespace: 'payments',
group: 'planFeatures',
name: 'listPlanFeatures',
description: <<<EOT
Get a list of all features assigned to a specific plan.
EOT,
auth: [AuthType::KEY, AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_PAYMENT_PLAN_FEATURE_LIST,
)
]
))
->param('planId', '', new Text(128), 'Plan ID')
->param('queries', [], new PaymentPlanFeatures(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', PaymentPlanFeatures::ALLOWED_ATTRIBUTES), true)
->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true)
->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
->inject('response')
->inject('dbForProject')
->callback($this->action(...));
}
public function action(
string $planId,
array $queries,
string $search,
bool $includeTotal,
Response $response,
Database $dbForProject
) {
try {
$queries = Query::parseQueries($queries);
} catch (QueryException $e) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
}
// Always filter by planId
$queries[] = Query::equal('planId', [$planId]);
if (!empty($search)) {
$queries[] = Query::search('search', $search);
}
/**
* Get cursor document if there was a cursor query, we use array_filter and reset for reference $cursor to $queries
*/
$cursor = \array_filter($queries, function ($query) {
return \in_array($query->getMethod(), [Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE]);
});
$cursor = reset($cursor);
if ($cursor) {
/** @var Query $cursor */
$validator = new Cursor();
if (!$validator->isValid($cursor)) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription());
}
$planFeatureId = $cursor->getValue();
$cursorDocument = $dbForProject->getDocument('payments_plan_features', $planFeatureId);
if ($cursorDocument->isEmpty()) {
throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Plan feature '{$planFeatureId}' for the 'cursor' value not found.");
}
$cursor->setValue($cursorDocument);
}
$filterQueries = Query::groupByType($queries)['filters'];
try {
$planFeatures = $dbForProject->find('payments_plan_features', $queries);
$total = $includeTotal ? $dbForProject->count('payments_plan_features', $filterQueries, APP_LIMIT_COUNT) : 0;
} catch (OrderException $e) {
throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null.");
}
$response->dynamic(new Document([
'features' => $planFeatures,
'total' => $total,
]), Response::MODEL_PAYMENT_PLAN_FEATURE_LIST);
}
}
@@ -0,0 +1,166 @@
<?php
namespace Appwrite\Platform\Modules\Payments\Http\Plans;
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;
use Appwrite\Platform\Modules\Payments\Validator\PricingEntry;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Database\Validator\CustomId;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Query;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\ArrayList;
use Utopia\Validator\Boolean;
use Utopia\Validator\Text;
class Create extends Base
{
use HTTP;
public static function getName()
{
return 'createPaymentPlan';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
->setHttpPath('/v1/payments/plans')
->groups(['api', 'payments'])
->desc('Create payment plan')
->label('scope', 'payments.write')
->label('resourceType', RESOURCE_TYPE_PAYMENTS)
->label('event', 'payments.plan.[planId].create')
->label('audits.event', 'payments.plan.create')
->label('audits.resource', 'payments/plan/{request.planId}')
->label('sdk', new Method(
namespace: 'payments',
group: 'plans',
name: 'createPlan',
description: <<<EOT
Create a new payment plan for your project. Plans define pricing tiers and can be associated with features for subscription management.
EOT,
auth: [AuthType::KEY, AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_CREATED,
model: Response::MODEL_PAYMENT_PLAN,
)
]
))
->param('planId', '', new CustomId(), 'Plan ID. Choose a custom ID or generate a random ID with `ID.unique()`.')
->param('name', '', new Text(2048), 'Plan name.')
->param('description', '', new Text(8192, 0), 'Plan description.', true)
->param('isDefault', false, new Boolean(), 'Set as default plan for new users.', true)
->param('isFree', false, new Boolean(), 'Is the plan free.', true)
->param('pricing', [], new ArrayList(new PricingEntry(), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Pricing configuration array [{priceId,amount,currency,interval}]', true)
->inject('response')
->inject('dbForPlatform')
->inject('dbForProject')
->inject('registryPayments')
->inject('queueForEvents')
->inject('project')
->callback($this->action(...));
}
public function action(
string $planId,
string $name,
string $description,
bool $isDefault,
bool $isFree,
array $pricing,
Response $response,
Database $dbForPlatform,
Database $dbForProject,
Registry $registryPayments,
Event $queueForEvents,
Document $project
) {
// Check for duplicate priceIds
$seenPriceIds = [];
foreach ($pricing as $entry) {
$priceId = (string) ($entry['priceId'] ?? '');
if (isset($seenPriceIds[$priceId])) {
throw new AppwriteException(ExtendException::GENERAL_BAD_REQUEST, 'Duplicate priceId detected: ' . $priceId);
}
$seenPriceIds[$priceId] = true;
}
$document = new Document([
'planId' => $planId,
'name' => $name,
'description' => $description,
'pricing' => $pricing,
'isDefault' => $isDefault,
'isFree' => $isFree,
'status' => 'active',
'providers' => [],
'features' => [],
'search' => implode(' ', [$planId, $name])
]);
// 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) {
throw new AppwriteException(ExtendException::GENERAL_ACCESS_FORBIDDEN, 'Payments feature is disabled for this project');
}
// Check if plan already exists
$existingPlan = $dbForProject->findOne('payments_plans', [
Query::equal('planId', [$planId])
]);
if ($existingPlan !== null && !$existingPlan->isEmpty()) {
throw new AppwriteException(ExtendException::PAYMENT_PLAN_ALREADY_EXISTS);
}
$payments = (array) $project->getAttribute('payments', []);
$providerConfigs = (array) ($payments['providers'] ?? []);
if (empty($providerConfigs)) {
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);
$queueForEvents->setParam('planId', $planId);
// Provision on configured providers
$providersMeta = [];
foreach ($providerConfigs as $providerId => $providerConfig) {
$state = new ProviderState((string) $providerId, (array) $providerConfig, (array) ($providerConfig['state'] ?? []));
$adapter = $registryPayments->get((string) $providerId, (array) $providerConfig, $project, $dbForPlatform, $dbForProject);
$ref = $adapter->ensurePlan([
'planId' => $planId,
'name' => $name,
'description' => $description,
'pricing' => $pricing,
], $state);
$meta = $ref->metadata;
$providersMeta[$providerId] = [
'externalId' => $ref->externalPlanId,
'metadata' => $meta,
'prices' => (array) ($meta['prices'] ?? [])
];
}
if (!empty($providersMeta)) {
$created->setAttribute('providers', $providersMeta);
$created = $dbForProject->updateDocument('payments_plans', $created->getId(), $created);
}
$response->setStatusCode(Response::STATUS_CODE_CREATED);
$response->dynamic($created, Response::MODEL_PAYMENT_PLAN);
}
}
@@ -0,0 +1,95 @@
<?php
namespace Appwrite\Platform\Modules\Payments\Http\Plans;
use Appwrite\Event\Event;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
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 Delete extends Base
{
use HTTP;
public static function getName()
{
return 'deletePaymentPlan';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE)
->setHttpPath('/v1/payments/plans/:planId')
->groups(['api', 'payments'])
->desc('Delete payment plan')
->label('scope', 'payments.write')
->label('resourceType', RESOURCE_TYPE_PAYMENTS)
->label('event', 'payments.plans.[planId].delete')
->label('audits.event', 'payments.plan.delete')
->label('audits.resource', 'payments/plan/{request.planId}')
->label('sdk', new Method(
namespace: 'payments',
group: 'plans',
name: 'deletePlan',
description: <<<EOT
Delete a payment plan by its unique ID. This action cannot be undone.
EOT,
auth: [AuthType::KEY, AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_NOCONTENT,
model: Response::MODEL_NONE,
)
],
contentType: ContentType::NONE
))
->param('planId', '', new Text(128), 'Plan ID')
->inject('response')
->inject('dbForPlatform')
->inject('dbForProject')
->inject('project')
->inject('queueForEvents')
->callback($this->action(...));
}
public function action(
string $planId,
Response $response,
Database $dbForPlatform,
Database $dbForProject,
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) {
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()) {
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();
}
}
@@ -0,0 +1,70 @@
<?php
namespace Appwrite\Platform\Modules\Payments\Http\Plans;
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 'getPaymentPlan';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/payments/plans/:planId')
->groups(['api', 'payments'])
->desc('Get payment plan')
->label('scope', 'payments.read')
->label('resourceType', RESOURCE_TYPE_PAYMENTS)
->label('sdk', new Method(
namespace: 'payments',
group: 'plans',
name: 'getPlan',
description: <<<EOT
Get a payment plan by its unique ID.
EOT,
auth: [AuthType::KEY, AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_PAYMENT_PLAN,
)
]
))
->param('planId', '', new Text(128), 'Plan ID')
->inject('response')
->inject('dbForProject')
->callback($this->action(...));
}
public function action(
string $planId,
Response $response,
Database $dbForProject
) {
$plan = $dbForProject->findOne('payments_plans', [
Query::equal('planId', [$planId])
]);
if ($plan === null || $plan->isEmpty()) {
throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::PAYMENT_PLAN_NOT_FOUND);
}
$response->dynamic($plan, Response::MODEL_PAYMENT_PLAN);
}
}
@@ -0,0 +1,148 @@
<?php
namespace Appwrite\Platform\Modules\Payments\Http\Plans;
use Appwrite\Payments\Provider\ProviderState;
use Appwrite\Payments\Provider\Registry;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\Platform\Modules\Payments\Validator\PricingEntry;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\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\ArrayList;
use Utopia\Validator\Boolean;
use Utopia\Validator\Text;
class Update extends Base
{
use HTTP;
public static function getName()
{
return 'updatePaymentPlan';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT)
->setHttpPath('/v1/payments/plans/:planId')
->groups(['api', 'payments'])
->desc('Update payment plan')
->label('scope', 'payments.write')
->label('resourceType', RESOURCE_TYPE_PAYMENTS)
->label('event', 'payments.plans.[planId].update')
->label('audits.event', 'payments.plan.update')
->label('audits.resource', 'payments/plan/{request.planId}')
->label('sdk', new Method(
namespace: 'payments',
group: 'plans',
name: 'updatePlan',
description: <<<EOT
Update a payment plan by its unique ID. You can update the plan name, description, and pricing configuration.
EOT,
auth: [AuthType::KEY, AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_PAYMENT_PLAN,
)
]
))
->param('planId', '', new Text(128), 'Plan ID')
->param('name', '', new Text(2048), 'Plan name.', true)
->param('description', '', new Text(8192, 0), 'Plan description.', true)
->param('isDefault', false, new Boolean(), 'Set as default plan for new users.', true)
->param('isFree', false, new Boolean(), 'Is the plan free.', true)
->param('pricing', [], new ArrayList(new PricingEntry(), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Pricing configuration array [{priceId,amount,currency,interval}]', true)
->inject('response')
->inject('dbForPlatform')
->inject('dbForProject')
->inject('registryPayments')
->inject('project')
->callback($this->action(...));
}
public function action(
string $planId,
string $name,
string $description,
bool $isDefault,
bool $isFree,
array $pricing,
Response $response,
Database $dbForPlatform,
Database $dbForProject,
Registry $registryPayments,
Document $project
) {
// 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) {
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()) {
throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::PAYMENT_PLAN_NOT_FOUND);
}
if ($name !== '') {
$plan->setAttribute('name', $name);
}
if ($description !== '') {
$plan->setAttribute('description', $description);
}
$plan->setAttribute('isDefault', $isDefault);
$plan->setAttribute('isFree', $isFree);
if (!empty($pricing)) {
// Check for duplicate priceIds
$seenPriceIds = [];
foreach ($pricing as $entry) {
$priceId = (string) ($entry['priceId'] ?? '');
if (isset($seenPriceIds[$priceId])) {
throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::GENERAL_BAD_REQUEST, 'Duplicate priceId detected: ' . $priceId);
}
$seenPriceIds[$priceId] = true;
}
$plan->setAttribute('pricing', $pricing);
}
$plan = $dbForProject->updateDocument('payments_plans', $plan->getId(), $plan);
// Update on providers if pricing changed or name/desc changed
$payments = (array) $project->getAttribute('payments', []);
$providerConfigs = (array) ($payments['providers'] ?? []);
$providersMeta = (array) $plan->getAttribute('providers', []);
foreach ($providerConfigs as $providerId => $providerConfig) {
$state = new ProviderState((string) $providerId, (array) $providerConfig, (array) ($providerConfig['state'] ?? []));
$adapter = $registryPayments->get((string) $providerId, (array) $providerConfig, $project, $dbForPlatform, $dbForProject);
$existing = $providersMeta[$providerId] ?? [];
$ref = new \Appwrite\Payments\Provider\ProviderPlanRef((string) ($existing['externalId'] ?? ''), (array) ($existing['metadata'] ?? []));
$newRef = $adapter->updatePlan([
'planId' => $planId,
'name' => $name,
'description' => $description,
'pricing' => !empty($pricing) ? $pricing : ($plan->getAttribute('pricing') ?? [])
], $ref, $state);
$meta = $newRef->metadata;
$providersMeta[$providerId] = [
'externalId' => $newRef->externalPlanId,
'metadata' => $meta,
'prices' => (array) ($meta['prices'] ?? [])
];
}
if (!empty($providersMeta)) {
$plan->setAttribute('providers', $providersMeta);
$plan = $dbForProject->updateDocument('payments_plans', $plan->getId(), $plan);
}
$response->dynamic($plan, Response::MODEL_PAYMENT_PLAN);
}
}
@@ -0,0 +1,120 @@
<?php
namespace Appwrite\Platform\Modules\Payments\Http\Plans;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Database\Validator\Queries\PaymentPlans;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Exception\Order as OrderException;
use Utopia\Database\Exception\Query as QueryException;
use Utopia\Database\Query;
use Utopia\Database\Validator\Query\Cursor;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\Boolean;
use Utopia\Validator\Text;
class XList extends Base
{
use HTTP;
public static function getName()
{
return 'listPaymentPlans';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/payments/plans')
->groups(['api', 'payments'])
->desc('List payment plans')
->label('scope', 'payments.read')
->label('resourceType', RESOURCE_TYPE_PAYMENTS)
->label('sdk', new Method(
namespace: 'payments',
group: 'plans',
name: 'listPlans',
description: <<<EOT
Get a list of all payment plans configured for the project. You can use the query params to filter your results.
EOT,
auth: [AuthType::KEY, AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_PAYMENT_PLAN_LIST,
)
]
))
->param('queries', [], new PaymentPlans(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', PaymentPlans::ALLOWED_ATTRIBUTES), true)
->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true)
->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
->inject('response')
->inject('dbForProject')
->callback($this->action(...));
}
public function action(
array $queries,
string $search,
bool $includeTotal,
Response $response,
Database $dbForProject
) {
try {
$queries = Query::parseQueries($queries);
} catch (QueryException $e) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
}
if (!empty($search)) {
$queries[] = Query::search('search', $search);
}
/**
* Get cursor document if there was a cursor query, we use array_filter and reset for reference $cursor to $queries
*/
$cursor = \array_filter($queries, function ($query) {
return \in_array($query->getMethod(), [Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE]);
});
$cursor = reset($cursor);
if ($cursor) {
/** @var Query $cursor */
$validator = new Cursor();
if (!$validator->isValid($cursor)) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription());
}
$planId = $cursor->getValue();
$cursorDocument = $dbForProject->getDocument('payments_plans', $planId);
if ($cursorDocument->isEmpty()) {
throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Plan '{$planId}' for the 'cursor' value not found.");
}
$cursor->setValue($cursorDocument);
}
$filterQueries = Query::groupByType($queries)['filters'];
try {
$plans = $dbForProject->find('payments_plans', $queries);
$total = $includeTotal ? $dbForProject->count('payments_plans', $filterQueries, APP_LIMIT_COUNT) : 0;
} catch (OrderException $e) {
throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null.");
}
$response->dynamic(new Document([
'plans' => $plans,
'total' => $total,
]), Response::MODEL_PAYMENT_PLAN_LIST);
}
}
@@ -0,0 +1,67 @@
<?php
namespace Appwrite\Platform\Modules\Payments\Http\Providers\Actions\Test;
use Appwrite\Payments\Provider\Registry;
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\Document;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
class Create extends Base
{
use HTTP;
public static function getName()
{
return 'testPaymentProvider';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
->setHttpPath('/v1/payments/providers/:providerId/actions/test')
->groups(['api', 'payments'])
->desc('Test payments provider credentials')
->label('scope', 'payments.write')
->label('resourceType', RESOURCE_TYPE_PAYMENTS)
->label('sdk', new Method(
namespace: 'payments',
group: 'providers',
name: 'test',
description: <<<EOT
Test the connection to a configured payment provider.
EOT,
auth: [AuthType::KEY, AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_ANY,
)
]
))
->param('providerId', '', new UID(), 'Provider ID')
->inject('response')
->inject('registryPayments')
->inject('dbForPlatform')
->inject('dbForProject')
->inject('project')
->callback($this->action(...));
}
public function action(string $providerId, Response $response, Registry $registryPayments, \Utopia\Database\Database $dbForPlatform, \Utopia\Database\Database $dbForProject, Document $project)
{
// Load provider config from project.payments
$payments = (array) $project->getAttribute('payments', []);
$providers = (array) ($payments['providers'] ?? []);
$config = (array) ($providers[$providerId] ?? []);
$result = $registryPayments->get($providerId, $config, $project, $dbForPlatform, $dbForProject)->testConnection($config);
$response->json(['success' => $result->success, 'message' => $result->message]);
}
}
@@ -0,0 +1,70 @@
<?php
namespace Appwrite\Platform\Modules\Payments\Http\Providers;
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\Platform\Action;
use Utopia\Platform\Scope\HTTP;
class Get extends Base
{
use HTTP;
public static function getName()
{
return 'getPaymentProviders';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/payments/providers')
->groups(['api', 'payments'])
->desc('Get payments providers config')
->label('scope', 'payments.read')
->label('resourceType', RESOURCE_TYPE_PAYMENTS)
->label('sdk', new Method(
namespace: 'payments',
group: 'providers',
name: 'getProvider',
description: <<<EOT
Get the current payment provider configuration for the project.
EOT,
auth: [AuthType::KEY, AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_PAYMENT_PROVIDER_CONFIG,
)
]
))
->inject('response')
->inject('dbForPlatform')
->inject('project')
->callback($this->action(...));
}
public function action(Response $response, Database $dbForPlatform, Document $project)
{
$projectDoc = $dbForPlatform->getDocument('projects', $project->getId());
$payments = (array) $projectDoc->getAttribute('payments', []);
$providers = (array) ($payments['providers'] ?? []);
foreach ($providers as $pid => &$cfg) {
if (isset($cfg['secretKey'])) {
$cfg['secretKey'] = '***';
}
if (isset($cfg['webhookSecret'])) {
$cfg['webhookSecret'] = '***';
}
}
$payments['providers'] = $providers;
$response->dynamic(new Document($payments), Response::MODEL_PAYMENT_PROVIDER_CONFIG);
}
}
@@ -0,0 +1,247 @@
<?php
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;
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\JSON as JSONValidator;
class Update extends Base
{
use HTTP;
public static function getName()
{
return 'updatePaymentProviders';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT)
->setHttpPath('/v1/payments/providers')
->groups(['api', 'payments'])
->desc('Configure payments providers')
->label('scope', 'payments.write')
->label('resourceType', RESOURCE_TYPE_PAYMENTS)
->label('event', 'payments.providers.update')
->label('audits.event', 'payments.providers.update')
->label('audits.resource', 'payments/providers')
->label('sdk', new Method(
namespace: 'payments',
group: 'providers',
name: 'updateProvider',
description: <<<EOT
Configure payment providers for the project. Currently supports Stripe.
EOT,
auth: [AuthType::KEY, AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_PAYMENT_PROVIDER_CONFIG,
)
]
))
->param('config', [], new JSONValidator(), 'Payments configuration JSON', false)
->inject('response')
->inject('dbForPlatform')
->inject('dbForProject')
->inject('registryPayments')
->inject('project')
->inject('queueForEvents')
->callback($this->action(...));
}
public function action(array $config, Response $response, Database $dbForPlatform, Database $dbForProject, Registry $registryPayments, Document $project, Event $queueForEvents)
{
$projectDoc = $dbForPlatform->getDocument('projects', $project->getId());
$existing = (array) $projectDoc->getAttribute('payments', []);
$existingProviders = (array) ($existing['providers'] ?? []);
$providers = (array) ($config['providers'] ?? []);
$providerKeys = \array_keys($providers);
$queueForEvents->setParam('providers', empty($providerKeys) ? 'providers' : \implode(',', $providerKeys));
// Basic validation + test connection for known providers
foreach ($providers as $providerId => $providerConfig) {
if ($providerId === 'stripe') {
$secret = (string) ($providerConfig['secretKey'] ?? '');
if ($secret === '') {
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) {
$adapter = $registryPayments->get($providerId, (array) $providerConfig, $project, $dbForPlatform, $dbForProject);
$test = $adapter->testConnection((array) $providerConfig);
if (!$test->success) {
\error_log("[Payments/Update] provider={$providerId} test failed: {$test->message}");
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, [
'state' => $state->metadata,
]);
}
// Feature flag gating
$enabled = (bool) ($config['enabled'] ?? true);
$merged = [
'providers' => array_merge((array) ($existing['providers'] ?? []), $providers),
'defaults' => $config['defaults'] ?? ($existing['defaults'] ?? []),
'enabled' => $enabled,
];
$projectDoc->setAttribute('payments', $merged);
$updated = $dbForPlatform->updateDocument('projects', $projectDoc->getId(), $projectDoc);
$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 &$cfg) {
if (isset($cfg['secretKey'])) {
$cfg['secretKey'] = '***';
}
if (isset($cfg['webhookSecret'])) {
$cfg['webhookSecret'] = '***';
}
}
$out['providers'] = $prov;
$response->dynamic(new Document($out), Response::MODEL_PAYMENT_PROVIDER_CONFIG);
}
}
@@ -0,0 +1,150 @@
<?php
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;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
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\Boolean;
use Utopia\Validator\Text;
class Cancel extends Base
{
use HTTP;
public static function getName()
{
return 'cancelPaymentSubscription';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
->setHttpPath('/v1/payments/subscriptions/:subscriptionId/cancel')
->groups(['api', 'payments'])
->desc('Cancel subscription')
->label('scope', 'payments.subscribe')
->label('resourceType', RESOURCE_TYPE_PAYMENTS)
->label('event', 'payments.subscription.[subscriptionId].cancel')
->label('audits.event', 'payments.subscription.cancel')
->label('audits.resource', 'payments/subscription/{request.subscriptionId}')
->label('sdk', new Method(
namespace: 'payments',
group: 'subscriptions',
name: 'cancel',
description: <<<EOT
Cancel a subscription. By default, cancellation takes effect at the end of the current billing period.
EOT,
auth: [AuthType::KEY, AuthType::ADMIN, AuthType::JWT],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_NOCONTENT,
model: Response::MODEL_NONE,
)
],
contentType: ContentType::NONE
))
->param('subscriptionId', '', new Text(128), 'Subscription ID')
->param('endAtPeriodEnd', true, new Boolean(true), 'End at period end', true)
->inject('response')
->inject('dbForPlatform')
->inject('dbForProject')
->inject('user')
->inject('registryPayments')
->inject('project')
->inject('queueForEvents')
->callback($this->action(...));
}
public function action(
string $subscriptionId,
bool $endAtPeriodEnd,
Response $response,
Database $dbForPlatform,
Database $dbForProject,
Document $user,
Registry $registryPayments,
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) {
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()) {
throw new AppwriteException(ExtendException::PAYMENT_SUBSCRIPTION_NOT_FOUND);
}
// Authorization for JWT user: must be owner/billing on team or owner (self) on user
if (!$user->isEmpty()) {
$actorType = (string) $sub->getAttribute('actorType', 'user');
$actorId = (string) $sub->getAttribute('actorId', '');
if ($actorType === 'user') {
if ($user->getId() !== $actorId) {
throw new AppwriteException(ExtendException::USER_UNAUTHORIZED, 'Not allowed to cancel 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 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)) {
throw new AppwriteException(ExtendException::USER_UNAUTHORIZED, 'Requires owner or billing role');
}
}
}
// Provider cancel
$providers = (array) ($paymentsCfg['providers'] ?? []);
$primary = array_key_first($providers);
if ($primary) {
$config = (array) ($providers[$primary] ?? []);
$provMap = (array) $sub->getAttribute('providers', []);
$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, $projDoc, $dbForPlatform, $dbForProject);
$adapter->cancelSubscription(new \Appwrite\Payments\Provider\ProviderSubscriptionRef($subscriptionRef), $endAtPeriodEnd, $state);
}
}
if ($endAtPeriodEnd) {
$sub->setAttribute('cancelAtPeriodEnd', true);
$sub->setAttribute('canceledAt', null);
} else {
$sub->setAttribute('status', 'canceled');
$sub->setAttribute('cancelAtPeriodEnd', false);
$sub->setAttribute('canceledAt', date('c'));
}
$dbForProject->updateDocument('payments_subscriptions', $sub->getId(), $sub);
$queueForEvents
->setParam('subscriptionId', $subscriptionId)
->setPayload($sub->getArrayCopy());
$response->noContent();
}
}
@@ -0,0 +1,334 @@
<?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\Helpers\ID;
use Utopia\Database\Query;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\Text;
class Create extends Base
{
use HTTP;
public static function getName()
{
return 'createPaymentSubscription';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
->setHttpPath('/v1/payments/subscriptions')
->groups(['api', 'payments'])
->desc('Create subscription')
->label('scope', 'payments.subscribe')
->label('resourceType', RESOURCE_TYPE_PAYMENTS)
->label('event', 'payments.subscription.[subscriptionId].create')
->label('audits.event', 'payments.subscription.create')
->label('audits.resource', 'payments/subscription/{response.subscriptionId}')
->label('sdk', new Method(
namespace: 'payments',
group: 'subscriptions',
name: 'createSubscription',
description: <<<EOT
Create a new subscription for a user or team. This initiates a checkout session with the configured payment provider and returns a checkout URL for payment completion.
EOT,
auth: [AuthType::KEY, AuthType::ADMIN, AuthType::JWT],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_CREATED,
model: Response::MODEL_PAYMENT_SUBSCRIPTION,
)
]
))
->param('actorType', 'user', new Text(16), 'Actor type: user or team')
->param('actorId', '', new Text(128), 'Actor ID')
->param('planId', '', new Text(128), 'Plan ID')
->param('priceId', '', new Text(128), 'Price ID', true)
->param('payerUserId', '', new Text(128, 0), 'Payer user ID (for team subscriptions)', true)
->param('successUrl', '', new Text(2048), 'Success redirect URL')
->param('cancelUrl', '', new Text(2048), 'Cancel redirect URL')
->inject('response')
->inject('dbForPlatform')
->inject('dbForProject')
->inject('registryPayments')
->inject('project')
->inject('queueForEvents')
->callback($this->action(...));
}
public function action(
string $actorType,
string $actorId,
string $planId,
string $priceId,
string $payerUserId,
string $successUrl,
string $cancelUrl,
Response $response,
Database $dbForPlatform,
Database $dbForProject,
Registry $registryPayments,
Document $project,
Event $queueForEvents
) {
// 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 \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()) {
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 === '') {
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()) {
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
$membership = $dbForProject->findOne('memberships', [
Query::equal('teamId', [$actorId]),
Query::equal('userId', [$payerId])
]);
if ($membership === null || $membership->isEmpty()) {
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)) {
throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::USER_UNAUTHORIZED, 'Payer must have owner or billing role');
}
}
// Fetch actor document to get internal ID
$actor = $actorType === 'user'
? $dbForProject->getDocument('users', $actorId)
: $dbForProject->getDocument('teams', $actorId);
if ($actor->isEmpty()) {
$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
$existingSubscriptions = $dbForProject->find('payments_subscriptions', [
Query::equal('actorType', [$actorType]),
Query::equal('actorId', [$actorId]),
Query::equal('status', ['active', 'trialing', 'paused']),
Query::limit(1),
]);
if (!empty($existingSubscriptions)) {
$existingSubscription = $existingSubscriptions[0];
if ($existingSubscription instanceof Document && !$existingSubscription->isEmpty()) {
throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::PAYMENT_SUBSCRIPTION_ALREADY_EXISTS, 'Actor already has an active subscription');
}
}
// Get payment provider and create checkout session
$payments = (array) $project->getAttribute('payments', []);
$providers = (array) ($payments['providers'] ?? []);
$primary = array_key_first($providers);
$initialStatus = 'pending';
$providerData = [];
$providerSubscriptionId = null;
$providerCheckoutId = null;
$checkoutUrl = null;
$selectedPriceId = $priceId !== '' ? $priceId : null;
$providerPlanPriceId = '';
if ($primary) {
$config = (array) ($providers[$primary] ?? []);
$planProviders = (array) $plan->getAttribute('providers', []);
$adapter = $registryPayments->get((string) $primary, $config, $project, $dbForPlatform, $dbForProject);
$state = new \Appwrite\Payments\Provider\ProviderState((string) $primary, $config, (array) ($config['state'] ?? []));
$planPricing = array_values((array) ($plan->getAttribute('pricing') ?? []));
$providerEntry = (array) ($planProviders[$primary] ?? []);
$rawProviderPrices = (array) ($providerEntry['prices'] ?? []);
$providerPriceMap = [];
if (!empty($rawProviderPrices)) {
if (!\array_is_list($rawProviderPrices)) {
foreach ($rawProviderPrices as $internalId => $providerPrice) {
$internalKey = (string) $internalId;
$providerValue = (string) $providerPrice;
if ($internalKey !== '' && $providerValue !== '') {
$providerPriceMap[$internalKey] = $providerValue;
}
}
} else {
foreach ($planPricing as $index => $pricingEntry) {
$internalId = (string) ($pricingEntry['priceId'] ?? '');
$providerValue = (string) ($rawProviderPrices[$index] ?? '');
if ($internalId !== '' && $providerValue !== '') {
$providerPriceMap[$internalId] = $providerValue;
}
}
}
}
if (empty($providerPriceMap)) {
$metaPrices = (array) (($providerEntry['metadata']['prices'] ?? []) ?: []);
foreach ($metaPrices as $internalId => $providerPrice) {
$internalKey = (string) $internalId;
$providerValue = (string) $providerPrice;
if ($internalKey !== '' && $providerValue !== '') {
$providerPriceMap[$internalKey] = $providerValue;
}
}
}
if ($selectedPriceId !== null) {
if (!isset($providerPriceMap[$selectedPriceId])) {
throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::GENERAL_BAD_REQUEST, 'Price ID not configured for provider: ' . $selectedPriceId);
}
$providerPlanPriceId = (string) $providerPriceMap[$selectedPriceId];
} elseif (!empty($providerPriceMap)) {
$selectedPriceId = (string) array_key_first($providerPriceMap);
$providerPlanPriceId = (string) $providerPriceMap[$selectedPriceId];
}
if ($providerPlanPriceId === '') {
// Legacy fallback: use first available provider price ID even if internal mapping missing
$legacyList = (array) ($providerEntry['prices'] ?? []);
if (\array_is_list($legacyList)) {
foreach ($legacyList as $legacyPriceId) {
if (!empty($legacyPriceId)) {
$providerPlanPriceId = (string) $legacyPriceId;
break;
}
}
}
}
if ($providerPlanPriceId === '') {
throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::GENERAL_BAD_REQUEST, 'Plan has no prices configured for provider: ' . $primary);
}
$providerKey = (string) $primary;
$providerEntryData = [];
$providerSubscriptionId = null;
$providerCheckoutId = null;
if (!$adapter instanceof StripeAdapter) {
throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::GENERAL_BAD_REQUEST, 'Unsupported payment provider: ' . $providerKey);
}
if ($successUrl === '' || $cancelUrl === '') {
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,
'meteredPriceIds' => $meteredPriceIds,
], $state, [
'successUrl' => $successUrl,
'cancelUrl' => $cancelUrl
]);
} catch (\Throwable $e) {
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 === '') {
throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::GENERAL_SERVER_ERROR, 'Checkout session did not return an id');
}
$providerCustomerId = (string) ($checkoutSession->metadata['customerId'] ?? '');
$providerEntryData = [
'priceId' => (string) ($selectedPriceId ?? ''),
'providerPriceId' => (string) $providerPlanPriceId,
'providerCheckoutId' => $providerCheckoutId,
];
if ($providerCustomerId !== '') {
$providerEntryData['providerCustomerId'] = $providerCustomerId;
}
$initialStatus = 'pending';
$providerData[$providerKey] = $providerEntryData;
} else {
throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::PAYMENT_PROVIDER_NOT_CONFIGURED);
}
$resolvedPriceId = (string) ($selectedPriceId ?? '');
$subscription = new Document([
'subscriptionId' => ID::unique(),
'providerSubscriptionId' => $providerSubscriptionId,
'providerCheckoutId' => $providerCheckoutId,
'actorType' => $actorType,
'actorId' => $actorId,
'actorInternalId' => $actor->getSequence(),
'planId' => $planId,
'priceId' => $resolvedPriceId,
'status' => $initialStatus,
'trialEndsAt' => null,
'currentPeriodStart' => null,
'currentPeriodEnd' => null,
'cancelAtPeriodEnd' => false,
'canceledAt' => null,
'providers' => $providerData,
'usageSummary' => [],
'tags' => [],
'search' => implode(' ', [$actorType, $actorId, $planId])
]);
$created = $dbForProject->createDocument('payments_subscriptions', $subscription);
$queueForEvents
->setProject($project)
->setParam('subscriptionId', $created->getAttribute('subscriptionId'))
->setEvent('payments.subscription.[subscriptionId].create')
->setPayload($created->getArrayCopy());
// Add checkoutUrl to the response document
if ($checkoutUrl) {
$created->setAttribute('checkoutUrl', $checkoutUrl);
}
$response->setStatusCode(Response::STATUS_CODE_CREATED);
$response->dynamic($created, Response::MODEL_PAYMENT_SUBSCRIPTION);
}
}
@@ -0,0 +1,205 @@
<?php
namespace Appwrite\Platform\Modules\Payments\Http\Subscriptions;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Database\Documents\User;
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 'getPaymentSubscription';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/payments/subscriptions/:actorType/:actorId')
->httpAlias('/v1/payments/subscriptions')
->httpAlias('/v1/payments/subscriptions/current')
->httpAlias('/v1/payments/subscriptions/me')
->groups(['api', 'payments'])
->desc('Get subscription')
->label('scope', 'payments.read')
->label('resourceType', RESOURCE_TYPE_PAYMENTS)
->label('sdk', new Method(
namespace: 'payments',
group: 'subscriptions',
name: 'getSubscription',
description: <<<EOT
Get a subscription by its unique ID.
EOT,
auth: [AuthType::KEY, AuthType::ADMIN, AuthType::JWT],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_PAYMENT_SUBSCRIPTION,
)
]
))
->param('actorType', 'user', new Text(16), 'Actor type: user or team')
->param('actorId', 'me', new Text(128), 'Actor ID. Use "me" or "current" for the logged-in user.')
->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 = User::isApp($roles);
$isPrivileged = User::isPrivileged($roles);
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) {
if ($user->isEmpty() || $user->getId() !== $actorId) {
throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::USER_UNAUTHORIZED, 'Not allowed to access this subscription');
}
}
}
if ($actorType === 'team') {
if ($actorId === '') {
throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::GENERAL_BAD_REQUEST, 'actorId required for team subscriptions');
}
if (!$isAPIKey && !$isPrivileged) {
if ($user->isEmpty()) {
throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::USER_UNAUTHORIZED, 'Login required');
}
$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');
}
}
}
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);
}
}
$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;
$activeSubscription = null;
if ($subscription instanceof Document && !$subscription->isEmpty()) {
$status = strtolower((string) $subscription->getAttribute('status', ''));
if ($status == 'active' || $status == 'trialing' || $status == 'paused') {
$activeSubscription = $subscription;
}
}
$planId = '';
if ($activeSubscription instanceof Document) {
$planId = (string) $activeSubscription->getAttribute('planId', '');
}
if ($planId === '') {
$planId = 'free';
}
$planData = null;
$features = [];
$plan = $dbForProject->findOne('payments_plans', [
Query::equal('planId', [$planId])
]);
if ($plan && !$plan->isEmpty()) {
$planData = $plan->getArrayCopy();
$planFeatures = $dbForProject->find('payments_plan_features', [
Query::equal('planId', [$planId]),
Query::equal('enabled', [true]),
]);
foreach ($planFeatures as $featureDoc) {
if (!$featureDoc instanceof Document) {
continue;
}
$featureId = (string) $featureDoc->getAttribute('featureId', '');
$featureDetails = $dbForProject->findOne('payments_features', [
Query::equal('featureId', [$featureId])
]);
$features[] = [
'featureId' => $featureId,
'name' => $featureDetails?->getAttribute('name'),
'description' => $featureDetails?->getAttribute('description'),
'type' => $featureDoc->getAttribute('type'),
'includedUnits' => $featureDoc->getAttribute('includedUnits'),
'usageCap' => $featureDoc->getAttribute('usageCap'),
'tiersMode' => $featureDoc->getAttribute('tiersMode'),
'tiers' => $featureDoc->getAttribute('tiers'),
'currency' => $featureDoc->getAttribute('currency'),
'interval' => $featureDoc->getAttribute('interval'),
];
}
}
$subscriptionDoc = $activeSubscription instanceof Document ? $activeSubscription : new Document([]);
$response->dynamic($subscriptionDoc, Response::MODEL_PAYMENT_SUBSCRIPTION);
}
}
@@ -0,0 +1,169 @@
<?php
namespace Appwrite\Platform\Modules\Payments\Http\Subscriptions;
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: <<<EOT
Get a URL to the payment provider's customer portal where users can manage their subscription and payment methods.
EOT,
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,193 @@
<?php
namespace Appwrite\Platform\Modules\Payments\Http\Subscriptions;
use Appwrite\Extend\Exception;
use Appwrite\Payments\Provider\ProviderSubscriptionRef;
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 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: <<<EOT
Preview the cost of upgrading or downgrading a subscription to a different plan.
EOT,
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,
]);
}
}
@@ -0,0 +1,141 @@
<?php
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;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
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 Resume extends Base
{
use HTTP;
public static function getName()
{
return 'resumePaymentSubscription';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
->setHttpPath('/v1/payments/subscriptions/:subscriptionId/resume')
->groups(['api', 'payments'])
->desc('Resume subscription')
->label('scope', 'payments.subscribe')
->label('resourceType', RESOURCE_TYPE_PAYMENTS)
->label('event', 'payments.subscription.[subscriptionId].resume')
->label('audits.event', 'payments.subscription.resume')
->label('audits.resource', 'payments/subscription/{request.subscriptionId}')
->label('sdk', new Method(
namespace: 'payments',
group: 'subscriptions',
name: 'resume',
description: <<<EOT
Resume a previously canceled subscription if it hasn't expired yet.
EOT,
auth: [AuthType::KEY, AuthType::ADMIN, AuthType::JWT],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_NOCONTENT,
model: Response::MODEL_NONE,
)
],
contentType: ContentType::NONE
))
->param('subscriptionId', '', new Text(128), 'Subscription ID')
->inject('response')
->inject('dbForPlatform')
->inject('dbForProject')
->inject('user')
->inject('registryPayments')
->inject('project')
->inject('queueForEvents')
->callback($this->action(...));
}
public function action(
string $subscriptionId,
Response $response,
Database $dbForPlatform,
Database $dbForProject,
Document $user,
Registry $registryPayments,
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) {
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()) {
throw new AppwriteException(ExtendException::PAYMENT_SUBSCRIPTION_NOT_FOUND);
}
if (!$user->isEmpty()) {
$actorType = (string) $sub->getAttribute('actorType', 'user');
$actorId = (string) $sub->getAttribute('actorId', '');
if ($actorType === 'user') {
if ($user->getId() !== $actorId) {
throw new AppwriteException(ExtendException::USER_UNAUTHORIZED, 'Not allowed to resume 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 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)) {
throw new AppwriteException(ExtendException::USER_UNAUTHORIZED, 'Requires owner or billing role');
}
}
}
// Provider resume
$payments = (array) $project->getAttribute('payments', []);
$providers = (array) ($payments['providers'] ?? []);
$primary = array_key_first($providers);
if ($primary) {
$config = (array) ($providers[$primary] ?? []);
$provMap = (array) $sub->getAttribute('providers', []);
$subscriptionRef = (string) ((array) ($provMap[(string) $primary] ?? []))['subscriptionId'] ?? '';
if ($subscriptionRef !== '') {
$state = new ProviderState((string) $primary, $config, (array) ($config['state'] ?? []));
$adapter = $registryPayments->get((string) $primary, $config, $project, $dbForPlatform, $dbForProject);
$adapter->resumeSubscription(new \Appwrite\Payments\Provider\ProviderSubscriptionRef($subscriptionRef), $state);
}
}
$sub->setAttribute('status', 'active');
$sub->setAttribute('canceledAt', null);
$sub->setAttribute('cancelAtPeriodEnd', false);
$dbForProject->updateDocument('payments_subscriptions', $sub->getId(), $sub);
$queueForEvents
->setParam('subscriptionId', $subscriptionId)
->setPayload($sub->getArrayCopy());
$response->noContent();
}
}
@@ -0,0 +1,219 @@
<?php
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;
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\Boolean;
use Utopia\Validator\Text;
class Update extends Base
{
use HTTP;
public static function getName()
{
return 'updatePaymentSubscription';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
->setHttpPath('/v1/payments/subscriptions/:subscriptionId')
->groups(['api', 'payments'])
->desc('Update subscription')
->label('scope', 'payments.subscribe')
->label('resourceType', RESOURCE_TYPE_PAYMENTS)
->label('event', 'payments.subscription.[subscriptionId].update')
->label('audits.event', 'payments.subscription.update')
->label('audits.resource', 'payments/subscription/{request.subscriptionId}')
->label('sdk', new Method(
namespace: 'payments',
group: 'subscriptions',
name: 'updateSubscription',
description: <<<EOT
Update a subscription to change the associated plan. This may trigger prorated charges or credits depending on the provider configuration.
EOT,
auth: [AuthType::KEY, AuthType::ADMIN, AuthType::JWT],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_PAYMENT_SUBSCRIPTION,
)
]
))
->param('subscriptionId', '', new Text(128), 'Subscription ID')
->param('planId', '', new Text(128), 'New plan ID', true)
->param('priceId', '', new Text(128), 'New price ID', true)
->param('cancelAtPeriodEnd', false, new Boolean(), 'Cancel at period end', true)
->inject('response')
->inject('dbForPlatform')
->inject('dbForProject')
->inject('user')
->inject('registryPayments')
->inject('project')
->inject('queueForEvents')
->callback($this->action(...));
}
public function action(
string $subscriptionId,
string $planId,
string $priceId,
bool $cancelAtPeriodEnd,
Response $response,
Database $dbForPlatform,
Database $dbForProject,
Document $user,
Registry $registryPayments,
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) {
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()) {
throw new AppwriteException(ExtendException::PAYMENT_SUBSCRIPTION_NOT_FOUND);
}
// Authorization: if acting as user (JWT), enforce actor ownership or team membership roles
if (!$user->isEmpty()) {
$actorType = (string) $sub->getAttribute('actorType', 'user');
$actorId = (string) $sub->getAttribute('actorId', '');
if ($actorType === 'user') {
if ($user->getId() !== $actorId) {
throw new AppwriteException(ExtendException::USER_UNAUTHORIZED, 'Not allowed to modify 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 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)) {
throw new AppwriteException(ExtendException::USER_UNAUTHORIZED, 'Requires owner or billing role');
}
}
}
// Provider update
$payments = (array) $project->getAttribute('payments', []);
$providers = (array) ($payments['providers'] ?? []);
$primary = array_key_first($providers);
if ($primary) {
$config = (array) ($providers[$primary] ?? []);
$provMap = (array) $sub->getAttribute('providers', []);
$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);
if ($planId !== '' || $priceId !== '') {
$currentPlanId = (string) $sub->getAttribute('planId', '');
$targetPlanId = $planId !== '' ? $planId : $currentPlanId;
$plan = $dbForProject->findOne('payments_plans', [
Query::equal('planId', [$targetPlanId])
]);
if ($plan === null || $plan->isEmpty()) {
throw new AppwriteException(ExtendException::PAYMENT_PLAN_NOT_FOUND);
}
$planProviders = (array) $plan->getAttribute('providers', []);
$planPricing = array_values((array) ($plan->getAttribute('pricing') ?? []));
$providerEntry = (array) ($planProviders[(string) $primary] ?? []);
$rawProviderPrices = (array) ($providerEntry['prices'] ?? []);
$providerPriceMap = [];
if (!empty($rawProviderPrices)) {
if (!\array_is_list($rawProviderPrices)) {
foreach ($rawProviderPrices as $internalId => $providerPrice) {
$internalKey = (string) $internalId;
$providerValue = (string) $providerPrice;
if ($internalKey !== '' && $providerValue !== '') {
$providerPriceMap[$internalKey] = $providerValue;
}
}
} else {
foreach ($planPricing as $index => $pricingEntry) {
$internalId = (string) ($pricingEntry['priceId'] ?? '');
$providerValue = (string) ($rawProviderPrices[$index] ?? '');
if ($internalId !== '' && $providerValue !== '') {
$providerPriceMap[$internalId] = $providerValue;
}
}
}
}
if (empty($providerPriceMap)) {
$metaPrices = (array) (($providerEntry['metadata']['prices'] ?? []) ?: []);
foreach ($metaPrices as $internalId => $providerPrice) {
$internalKey = (string) $internalId;
$providerValue = (string) $providerPrice;
if ($internalKey !== '' && $providerValue !== '') {
$providerPriceMap[$internalKey] = $providerValue;
}
}
}
$selectedPriceId = $priceId !== '' ? $priceId : (string) $sub->getAttribute('priceId', '');
if ($selectedPriceId === '' && !empty($providerPriceMap)) {
$selectedPriceId = (string) array_key_first($providerPriceMap);
}
if ($selectedPriceId === '' || !isset($providerPriceMap[$selectedPriceId])) {
throw new AppwriteException(ExtendException::GENERAL_BAD_REQUEST, 'Price ID not configured for provider');
}
$providerPriceId = (string) $providerPriceMap[$selectedPriceId];
$adapter->updateSubscription(
new \Appwrite\Payments\Provider\ProviderSubscriptionRef($subscriptionRef),
['priceId' => $providerPriceId],
$state
);
$sub->setAttribute('planId', $targetPlanId);
$sub->setAttribute('priceId', $selectedPriceId);
$provEntry = (array) ($provMap[(string) $primary] ?? []);
$provEntry['priceId'] = $selectedPriceId;
$provEntry['providerPriceId'] = $providerPriceId;
$provMap[(string) $primary] = $provEntry;
$sub->setAttribute('providers', $provMap);
}
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);
$queueForEvents
->setParam('subscriptionId', $subscriptionId)
->setPayload($sub->getArrayCopy());
$response->dynamic($sub, Response::MODEL_PAYMENT_SUBSCRIPTION);
}
}
@@ -0,0 +1,142 @@
<?php
namespace Appwrite\Platform\Modules\Payments\Http\Subscriptions;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Database\Validator\Queries\PaymentSubscriptions;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Exception\Order as OrderException;
use Utopia\Database\Exception\Query as QueryException;
use Utopia\Database\Query;
use Utopia\Database\Validator\Query\Cursor;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\Boolean;
use Utopia\Validator\Text;
class XList extends Base
{
use HTTP;
public static function getName()
{
return 'listPaymentSubscriptions';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/payments/subscriptions/search')
->groups(['api', 'payments'])
->desc('List subscriptions')
->label('scope', 'payments.read')
->label('resourceType', RESOURCE_TYPE_PAYMENTS)
->label('sdk', new Method(
namespace: 'payments',
group: 'subscriptions',
name: 'listSubscriptions',
description: <<<EOT
Get a list of all the project's subscriptions. You can use the query params to filter your results.
EOT,
auth: [AuthType::KEY, AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_PAYMENT_SUBSCRIPTION_LIST,
)
]
))
->param('queries', [], new PaymentSubscriptions(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', PaymentSubscriptions::ALLOWED_ATTRIBUTES), true)
->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true)
->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
->inject('response')
->inject('dbForProject')
->callback($this->action(...));
}
public function action(
array $queries,
string $search,
bool $includeTotal,
Response $response,
Database $dbForProject
) {
try {
$queries = Query::parseQueries($queries);
} catch (QueryException $e) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
}
if (!empty($search)) {
$queries[] = Query::search('search', $search);
}
/**
* Get cursor document if there was a cursor query, we use array_filter and reset for reference $cursor to $queries
*/
$cursor = \array_filter($queries, function ($query) {
return \in_array($query->getMethod(), [Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE]);
});
$cursor = reset($cursor);
if ($cursor) {
/** @var Query $cursor */
$validator = new Cursor();
if (!$validator->isValid($cursor)) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription());
}
$subscriptionId = $cursor->getValue();
$cursorDocument = $dbForProject->getDocument('payments_subscriptions', $subscriptionId);
if ($cursorDocument->isEmpty()) {
throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Subscription '{$subscriptionId}' for the 'cursor' value not found.");
}
$cursor->setValue($cursorDocument);
}
$filterQueries = Query::groupByType($queries)['filters'];
try {
$subscriptions = $dbForProject->find('payments_subscriptions', $queries);
$total = $includeTotal ? $dbForProject->count('payments_subscriptions', $filterQueries, APP_LIMIT_COUNT) : 0;
} catch (OrderException $e) {
throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null.");
}
// Collect unique plan IDs and fetch plans
$plansById = [];
foreach ($subscriptions as $sub) {
$planId = (string) $sub->getAttribute('planId', '');
if ($planId !== '' && !isset($plansById[$planId])) {
$plan = $dbForProject->findOne('payments_plans', [
Query::equal('planId', [$planId])
]);
if ($plan) {
$plansById[$planId] = $plan;
}
}
}
// Enrich subscriptions with plan Documents
foreach ($subscriptions as $sub) {
$planId = (string) $sub->getAttribute('planId', '');
if ($planId !== '' && isset($plansById[$planId])) {
$sub->setAttribute('plan', $plansById[$planId]);
}
}
$response->dynamic(new Document([
'subscriptions' => $subscriptions,
'total' => $total,
]), Response::MODEL_PAYMENT_SUBSCRIPTION_LIST);
}
}
@@ -0,0 +1,104 @@
<?php
namespace Appwrite\Platform\Modules\Payments\Http\Usage;
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\Helpers\ID;
use Utopia\Database\Query;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\Integer as IntValidator;
use Utopia\Validator\Text;
class Create extends Base
{
use HTTP;
public static function getName()
{
return 'reportPaymentSubscriptionUsage';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
->setHttpPath('/v1/payments/subscriptions/:subscriptionId/usage')
->groups(['api', 'payments'])
->desc('Report usage for subscription')
->label('scope', 'payments.subscribe')
->label('resourceType', RESOURCE_TYPE_PAYMENTS)
->label('event', 'payments.subscriptions.usage.report')
->label('audits.event', 'payments.usage.report')
->label('audits.resource', 'payments/subscription/{request.subscriptionId}')
->label('sdk', new Method(
namespace: 'payments',
group: 'usage',
name: 'report',
description: <<<EOT
Report usage for a metered feature on a subscription.
EOT,
auth: [AuthType::KEY],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_CREATED,
model: Response::MODEL_ANY,
)
]
))
->param('subscriptionId', '', new Text(128), 'Subscription ID')
->param('featureId', '', new Text(128), 'Feature ID')
->param('quantity', 0, new IntValidator(), 'Usage quantity')
->inject('response')
->inject('dbForPlatform')
->inject('dbForProject')
->inject('project')
->callback($this->action(...));
}
public function action(
string $subscriptionId,
string $featureId,
int $quantity,
Response $response,
Database $dbForPlatform,
Database $dbForProject,
Document $project
) {
// 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) {
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()) {
throw new \Appwrite\AppwriteException(\Appwrite\Extend\Exception::PAYMENT_SUBSCRIPTION_NOT_FOUND);
}
$event = new Document([
'$id' => ID::unique(),
'subscriptionId' => $subscriptionId,
'actorType' => $sub->getAttribute('actorType'),
'actorId' => $sub->getAttribute('actorId'),
'planId' => $sub->getAttribute('planId'),
'featureId' => $featureId,
'quantity' => $quantity,
'timestamp' => date('c'),
'providerSyncState' => 'pending',
'providerEventId' => null,
'metadata' => []
]);
$created = $dbForProject->createDocument('payments_usage_events', $event);
$response->setStatusCode(Response::STATUS_CODE_CREATED);
$response->json($created->getArrayCopy());
}
}
@@ -0,0 +1,132 @@
<?php
namespace Appwrite\Platform\Modules\Payments\Http\Usage\Events;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Database\Validator\Queries\PaymentUsageEvents;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Exception\Order as OrderException;
use Utopia\Database\Exception\Query as QueryException;
use Utopia\Database\Query;
use Utopia\Database\Validator\Query\Cursor;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\Boolean;
use Utopia\Validator\Text;
class XList extends Base
{
use HTTP;
public static function getName()
{
return 'listPaymentUsageEvents';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/payments/usage/events')
->groups(['api', 'payments'])
->desc('List usage events')
->label('scope', 'payments.read')
->label('resourceType', RESOURCE_TYPE_PAYMENTS)
->label('sdk', new Method(
namespace: 'payments',
group: 'usage',
name: 'listEvents',
description: <<<EOT
Get a list of usage events for a subscription.
EOT,
auth: [AuthType::KEY, AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_PAYMENT_USAGE_EVENT_LIST,
)
]
))
->param('subscriptionId', '', new Text(128, 0), 'Filter by subscription ID.', true)
->param('featureId', '', new Text(128, 0), 'Filter by feature ID.', true)
->param('queries', [], new PaymentUsageEvents(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', PaymentUsageEvents::ALLOWED_ATTRIBUTES), true)
->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true)
->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
->inject('response')
->inject('dbForProject')
->callback($this->action(...));
}
public function action(
string $subscriptionId,
string $featureId,
array $queries,
string $search,
bool $includeTotal,
Response $response,
Database $dbForProject
) {
try {
$queries = Query::parseQueries($queries);
} catch (QueryException $e) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
}
// Add filters from explicit params
if ($subscriptionId !== '') {
$queries[] = Query::equal('subscriptionId', [$subscriptionId]);
}
if ($featureId !== '') {
$queries[] = Query::equal('featureId', [$featureId]);
}
if (!empty($search)) {
$queries[] = Query::search('search', $search);
}
/**
* Get cursor document if there was a cursor query, we use array_filter and reset for reference $cursor to $queries
*/
$cursor = \array_filter($queries, function ($query) {
return \in_array($query->getMethod(), [Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE]);
});
$cursor = reset($cursor);
if ($cursor) {
/** @var Query $cursor */
$validator = new Cursor();
if (!$validator->isValid($cursor)) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription());
}
$eventId = $cursor->getValue();
$cursorDocument = $dbForProject->getDocument('payments_usage_events', $eventId);
if ($cursorDocument->isEmpty()) {
throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Usage event '{$eventId}' for the 'cursor' value not found.");
}
$cursor->setValue($cursorDocument);
}
$filterQueries = Query::groupByType($queries)['filters'];
try {
$events = $dbForProject->find('payments_usage_events', $queries);
$total = $includeTotal ? $dbForProject->count('payments_usage_events', $filterQueries, APP_LIMIT_COUNT) : 0;
} catch (OrderException $e) {
throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null.");
}
$response->dynamic(new Document([
'events' => $events,
'total' => $total,
]), Response::MODEL_PAYMENT_USAGE_EVENT_LIST);
}
}
@@ -0,0 +1,76 @@
<?php
namespace Appwrite\Platform\Modules\Payments\Http\Usage;
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 'getPaymentSubscriptionUsage';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/payments/subscriptions/:subscriptionId/usage')
->groups(['api', 'payments'])
->desc('Get usage for subscription')
->label('scope', 'payments.read')
->label('resourceType', RESOURCE_TYPE_PAYMENTS)
->label('event', 'payments.subscriptions.usage.get')
->label('audits.event', 'payments.usage.get')
->label('audits.resource', 'payments/subscription/{request.subscriptionId}')
->label('sdk', new Method(
namespace: 'payments',
group: 'usage',
name: 'getUsage',
description: <<<EOT
Get usage summary for a subscription.
EOT,
auth: [AuthType::KEY, AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_ANY,
)
]
))
->param('subscriptionId', '', new Text(128), 'Subscription ID')
->inject('response')
->inject('dbForProject')
->callback($this->action(...));
}
public function action(
string $subscriptionId,
Response $response,
Database $dbForProject
) {
$events = $dbForProject->find('payments_usage_events', [
Query::equal('subscriptionId', [$subscriptionId])
]);
$total = 0;
foreach ($events as $e) {
$total += (int) $e->getAttribute('quantity', 0);
}
$response->json([
'subscriptionId' => $subscriptionId,
'total' => $total,
'events' => array_map(fn ($d) => $d->getArrayCopy(), $events)
]);
}
}
@@ -0,0 +1,105 @@
<?php
namespace Appwrite\Platform\Modules\Payments\Http\Usage\Reconcile;
use Appwrite\Payments\Provider\Registry as ProviderRegistry;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
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;
class Create extends Base
{
use HTTP;
public static function getName()
{
return 'createPaymentUsageReconcile';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
->setHttpPath('/v1/payments/usage/reconcile')
->groups(['api', 'payments'])
->desc('Trigger usage reconciliation')
->label('scope', 'payments.write')
->label('resourceType', RESOURCE_TYPE_PAYMENTS)
->label('event', 'payments.usage.reconcile')
->label('audits.event', 'payments.usage.reconcile')
->label('audits.resource', 'payments/usage/reconcile')
->label('sdk', new Method(
namespace: 'payments',
group: 'usage',
name: 'reconcile',
description: <<<EOT
Reconcile usage events with the payment provider.
EOT,
auth: [AuthType::KEY, AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_NOCONTENT,
model: Response::MODEL_NONE,
)
],
contentType: ContentType::NONE
))
->inject('response')
->inject('dbForPlatform')
->inject('dbForProject')
->inject('registryPayments')
->inject('project')
->callback($this->action(...));
}
public function action(Response $response, Database $dbForPlatform, Database $dbForProject, ProviderRegistry $registryPayments, Document $project)
{
// 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;
}
$payments = (array) $project->getAttribute('payments', []);
$providers = (array) ($payments['providers'] ?? []);
$primary = array_key_first($providers);
$config = $primary ? (array) ($providers[$primary] ?? []) : [];
$subs = $dbForProject->find('payments_subscriptions', []);
foreach ($subs as $sub) {
// Aggregate internal usage summary
$events = $dbForProject->find('payments_usage_events', [
Query::equal('subscriptionId', [$sub->getAttribute('subscriptionId')])
]);
$totals = [];
foreach ($events as $e) {
$fid = (string) $e->getAttribute('featureId', '');
$totals[$fid] = ($totals[$fid] ?? 0) + (int) $e->getAttribute('quantity', 0);
}
$sub->setAttribute('usageSummary', $totals);
$dbForProject->updateDocument('payments_subscriptions', $sub->getId(), $sub);
// Optionally invoke provider sync for parity
if ($primary) {
$provMap = (array) $sub->getAttribute('providers', []);
$subscriptionRef = (string) ((array) ($provMap[(string) $primary] ?? []))['subscriptionId'] ?? '';
if ($subscriptionRef !== '') {
$state = new \Appwrite\Payments\Provider\ProviderState((string) $primary, $config, (array) ($config['state'] ?? []));
$adapter = $registryPayments->get((string) $primary, $config, $project, $dbForPlatform, $dbForProject);
$adapter->syncUsage(new \Appwrite\Payments\Provider\ProviderSubscriptionRef($subscriptionRef), $state);
}
}
}
$response->noContent();
}
}
@@ -0,0 +1,113 @@
<?php
namespace Appwrite\Platform\Modules\Payments\Http\Webhooks\Provider;
use Appwrite\Event\Event;
use Appwrite\Payments\Provider\Registry;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Swoole\Request;
class Create extends Base
{
use HTTP;
public static function getName()
{
return 'createPaymentProviderWebhook';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
->setHttpPath('/v1/payments/webhooks/:providerId/:projectId')
->groups(['api', 'payments'])
->desc('Handle provider webhook')
->label('scope', 'public')
->label('resourceType', RESOURCE_TYPE_PAYMENTS)
->label('sdk', new Method(
namespace: 'payments',
group: 'providers',
name: 'webhook',
description: <<<EOT
Handle incoming webhooks from payment providers. This endpoint is called by the provider to notify of subscription and payment events.
EOT,
auth: [],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_NOCONTENT,
model: Response::MODEL_NONE,
)
],
contentType: ContentType::NONE
))
->label('event', 'payments.providers.[providerId].webhook')
->label('audits.event', 'payments.webhook')
->label('audits.resource', 'provider/{request.providerId}')
->param('providerId', '', new UID(), 'Provider ID')
->param('projectId', '', new UID(), 'Project ID')
->inject('response')
->inject('request')
->inject('registryPayments')
->inject('dbForPlatform')
->inject('getProjectDB')
->inject('queueForEvents')
->callback($this->action(...));
}
public function action(
string $providerId,
string $projectId,
Response $response,
Request $request,
Registry $registryPayments,
Database $dbForPlatform,
callable $getProjectDB,
Event $queueForEvents
) {
$project = Authorization::skip(fn () => $dbForPlatform->getDocument('projects', $projectId));
if ($project === null || $project->isEmpty()) {
$response->setStatusCode(Response::STATUS_CODE_NOT_FOUND);
$response->json(['message' => 'Project not found']);
return;
}
$dbForProject = $getProjectDB($project);
// Feature flag: ignore if disabled
$paymentsCfg = (array) $project->getAttribute('payments', []);
if (isset($paymentsCfg['enabled']) && $paymentsCfg['enabled'] === false) {
$response->noContent();
return;
}
$payments = (array) $project->getAttribute('payments', []);
$providers = (array) ($payments['providers'] ?? []);
$config = (array) ($providers[$providerId] ?? []);
// Read raw payload and signature
$payload = $request->getRawPayload();
$signature = (string) ($request->getHeader('stripe-signature') ?? '');
$json = [];
try {
$json = \json_decode($payload, true) ?: [];
} catch (\Throwable $e) {
$json = [];
}
$json['_signature'] = $signature;
$json['_raw'] = $payload;
$registryPayments->get($providerId, $config, $project, $dbForPlatform, $dbForProject)->handleWebhook($json, new \Appwrite\Payments\Provider\ProviderState($providerId, $config, (array) ($config['state'] ?? [])));
$queueForEvents
->setParam('providers', $providerId)
->setParam('providerId', $providerId);
$response->noContent();
}
}
@@ -0,0 +1,16 @@
<?php
namespace Appwrite\Platform\Modules\Payments;
use Appwrite\Platform\Modules\Payments\Services\Http;
use Appwrite\Platform\Modules\Payments\Services\Workers;
use Utopia\Platform;
class Module extends Platform\Module
{
public function __construct()
{
$this->addService('http', new Http());
$this->addService('workers', new Workers());
}
}
@@ -0,0 +1,93 @@
<?php
namespace Appwrite\Platform\Modules\Payments\Services;
use Appwrite\Platform\Modules\Payments\Http\ActorFeatures\Get as ActorFeaturesGet;
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\Invoices\XList as InvoicesList;
use Appwrite\Platform\Modules\Payments\Http\PlanFeatures\Assign as PlanFeaturesAssign;
use Appwrite\Platform\Modules\Payments\Http\PlanFeatures\Remove as PlanFeaturesRemove;
use Appwrite\Platform\Modules\Payments\Http\PlanFeatures\XList as PlanFeaturesList;
use Appwrite\Platform\Modules\Payments\Http\Plans\Create as PlansCreate;
use Appwrite\Platform\Modules\Payments\Http\Plans\Delete as PlansDelete;
use Appwrite\Platform\Modules\Payments\Http\Plans\Get as PlansGet;
use Appwrite\Platform\Modules\Payments\Http\Plans\Update as PlansUpdate;
use Appwrite\Platform\Modules\Payments\Http\Plans\XList as PlansList;
use Appwrite\Platform\Modules\Payments\Http\Providers\Actions\Test\Create as ProvidersTest;
use Appwrite\Platform\Modules\Payments\Http\Providers\Get as ProvidersGet;
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\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 Utopia\Platform\Service;
class Http extends Service
{
public function __construct()
{
$this->type = Service::TYPE_HTTP;
// Plans
$this->addAction(PlansCreate::getName(), new PlansCreate());
$this->addAction(PlansGet::getName(), new PlansGet());
$this->addAction(PlansList::getName(), new PlansList());
$this->addAction(PlansUpdate::getName(), new PlansUpdate());
$this->addAction(PlansDelete::getName(), new PlansDelete());
// 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());
// Plan Features
$this->addAction(PlanFeaturesAssign::getName(), new PlanFeaturesAssign());
$this->addAction(PlanFeaturesList::getName(), new PlanFeaturesList());
$this->addAction(PlanFeaturesRemove::getName(), new PlanFeaturesRemove());
// Subscriptions
$this->addAction(SubscriptionsCreate::getName(), new SubscriptionsCreate());
$this->addAction(SubscriptionsList::getName(), new SubscriptionsList());
$this->addAction(SubscriptionsGet::getName(), new SubscriptionsGet());
$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());
$this->addAction(UsageCreate::getName(), new UsageCreate());
$this->addAction(UsageEventsList::getName(), new UsageEventsList());
$this->addAction(UsageReconcile::getName(), new UsageReconcile());
// Providers
$this->addAction(ProvidersGet::getName(), new ProvidersGet());
$this->addAction(ProvidersUpdate::getName(), new ProvidersUpdate());
$this->addAction(ProvidersTest::getName(), new ProvidersTest());
// Webhooks
$this->addAction(WebhookProviderCreate::getName(), new WebhookProviderCreate());
// Actor Features
$this->addAction(ActorFeaturesGet::getName(), new ActorFeaturesGet());
}
}
@@ -0,0 +1,15 @@
<?php
namespace Appwrite\Platform\Modules\Payments\Services;
use Appwrite\Platform\Modules\Payments\Workers\UsageSync;
use Utopia\Platform\Service;
class Workers extends Service
{
public function __construct()
{
$this->type = Service::TYPE_WORKER;
$this->addAction(UsageSync::getName(), new UsageSync());
}
}
@@ -0,0 +1,97 @@
<?php
namespace Appwrite\Platform\Modules\Payments\Validator;
use Utopia\Validator;
class FeatureTier extends Validator
{
private string $description = '';
/**
* Get description of the validation error
*
* @return string
*/
public function getDescription(): string
{
return $this->description;
}
/**
* Indicates this validator expects an array
*
* @return bool
*/
public function isArray(): bool
{
return true;
}
/**
* Validate a feature tier entry for metered billing
*
* @param mixed $value
* @return bool
*/
public function isValid($value): bool
{
// Must be array-like
if (!\is_array($value)) {
$this->description = 'Feature tier must be an array';
return false;
}
// Validate upTo - should be present (integer or 'inf')
if (\array_key_exists('upTo', $value)) {
if (!\is_int($value['upTo']) && $value['upTo'] !== 'inf' && $value['upTo'] !== null) {
$this->description = "Key 'upTo' must be an integer, 'inf', or null";
return false;
}
}
// Validate unitAmount - should be present (numeric)
if (\array_key_exists('unitAmount', $value)) {
if (!\is_int($value['unitAmount']) && !\is_float($value['unitAmount'])) {
$this->description = "Key 'unitAmount' must be a numeric value";
return false;
}
}
// Validate flatAmount - optional (numeric)
if (\array_key_exists('flatAmount', $value)) {
if (!\is_int($value['flatAmount']) && !\is_float($value['flatAmount']) && $value['flatAmount'] !== null) {
$this->description = "Key 'flatAmount' must be a numeric value or null";
return false;
}
}
// Validate firstUnit - optional (integer)
if (\array_key_exists('firstUnit', $value)) {
if (!\is_int($value['firstUnit'])) {
$this->description = "Key 'firstUnit' must be an integer";
return false;
}
}
// Validate lastUnit - optional (integer or null for infinity)
if (\array_key_exists('lastUnit', $value)) {
if (!\is_int($value['lastUnit']) && $value['lastUnit'] !== null) {
$this->description = "Key 'lastUnit' must be an integer or null";
return false;
}
}
return true;
}
/**
* Get the type of this validator
*
* @return string
*/
public function getType(): string
{
return self::TYPE_OBJECT;
}
}
@@ -0,0 +1,92 @@
<?php
namespace Appwrite\Platform\Modules\Payments\Validator;
use Utopia\Validator;
class PricingEntry extends Validator
{
private string $description = '';
/**
* Get description of the validation error
*
* @return string
*/
public function getDescription(): string
{
return $this->description;
}
/**
* Indicates this validator expects an array
*
* @return bool
*/
public function isArray(): bool
{
return true;
}
/**
* Validate a pricing entry
*
* @param mixed $value
* @return bool
*/
public function isValid($value): bool
{
// Must be array-like
if (!\is_array($value)) {
$this->description = 'Pricing entry must be an array';
return false;
}
// Validate priceId - required, non-empty string
if (!\array_key_exists('priceId', $value)) {
$this->description = 'Missing required key: priceId';
return false;
}
if (!\is_string($value['priceId']) || \trim($value['priceId']) === '') {
$this->description = "Key 'priceId' must be a non-empty string";
return false;
}
// Validate amount - should be present (integer)
if (\array_key_exists('amount', $value)) {
if (!\is_int($value['amount']) && !\is_float($value['amount'])) {
$this->description = "Key 'amount' must be a numeric value";
return false;
}
}
// Validate currency - should be present (string)
if (\array_key_exists('currency', $value)) {
if (!\is_string($value['currency'])) {
$this->description = "Key 'currency' must be a string";
return false;
}
}
// Validate interval - should be present (string)
if (\array_key_exists('interval', $value)) {
if (!\is_string($value['interval'])) {
$this->description = "Key 'interval' must be a string";
return false;
}
}
return true;
}
/**
* Get the type of this validator
*
* @return string
*/
public function getType(): string
{
return self::TYPE_OBJECT;
}
}
@@ -0,0 +1,124 @@
<?php
namespace Appwrite\Platform\Modules\Payments\Validator;
use Utopia\Validator;
class ProviderConfig extends Validator
{
private string $description = '';
/**
* Get description of the validation error
*
* @return string
*/
public function getDescription(): string
{
return $this->description;
}
/**
* Indicates this validator expects an array
*
* @return bool
*/
public function isArray(): bool
{
return true;
}
/**
* Validate a provider configuration object
*
* @param mixed $value
* @return bool
*/
public function isValid($value): bool
{
// Must be array-like
if (!\is_array($value)) {
$this->description = 'Provider configuration must be an array';
return false;
}
// Must have 'providers' key
if (!\array_key_exists('providers', $value)) {
$this->description = "Missing required key: providers";
return false;
}
if (!\is_array($value['providers'])) {
$this->description = "Key 'providers' must be an array";
return false;
}
// Validate each provider entry
foreach ($value['providers'] as $providerId => $providerConfig) {
if (!\is_string($providerId) || \trim($providerId) === '') {
$this->description = 'Provider ID must be a non-empty string';
return false;
}
if (!\is_array($providerConfig)) {
$this->description = "Provider '{$providerId}' configuration must be an array";
return false;
}
// Validate secretKey - required, non-empty string
if (!\array_key_exists('secretKey', $providerConfig)) {
$this->description = "Missing required key 'secretKey' for provider '{$providerId}'";
return false;
}
if (!\is_string($providerConfig['secretKey']) || \trim($providerConfig['secretKey']) === '') {
$this->description = "Key 'secretKey' for provider '{$providerId}' must be a non-empty string";
return false;
}
// Validate optional 'enabled' key if present
if (\array_key_exists('enabled', $providerConfig)) {
if (!\is_bool($providerConfig['enabled'])) {
$this->description = "Key 'enabled' for provider '{$providerId}' must be a boolean";
return false;
}
}
// Validate optional 'defaults' key if present
if (\array_key_exists('defaults', $providerConfig)) {
if (!\is_array($providerConfig['defaults'])) {
$this->description = "Key 'defaults' for provider '{$providerId}' must be an array";
return false;
}
}
}
// Validate optional top-level 'enabled' key if present
if (\array_key_exists('enabled', $value)) {
if (!\is_bool($value['enabled'])) {
$this->description = "Key 'enabled' must be a boolean";
return false;
}
}
// Validate optional top-level 'defaults' key if present
if (\array_key_exists('defaults', $value)) {
if (!\is_array($value['defaults'])) {
$this->description = "Key 'defaults' must be an array";
return false;
}
}
return true;
}
/**
* Get the type of this validator
*
* @return string
*/
public function getType(): string
{
return self::TYPE_OBJECT;
}
}
@@ -0,0 +1,138 @@
<?php
namespace Appwrite\Platform\Modules\Payments\Workers;
use Appwrite\Payments\Provider\ProviderState;
use Appwrite\Payments\Provider\ProviderSubscriptionRef;
use Appwrite\Payments\Provider\Registry as ProviderRegistry;
use Appwrite\Platform\Action;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Query;
class UsageSync extends Action
{
public static function getName(): string
{
return 'payments-usage-sync';
}
public function __construct()
{
$this
->desc('Sync payments usage events to providers')
->inject('dbForPlatform')
->inject('dbForProject')
->inject('project')
->inject('registryPayments')
->callback($this->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);
if (!$primary) {
return;
}
$config = (array) ($providers[$primary] ?? []);
$stateBase = (array) ($config['state'] ?? []);
$adapter = $registryPayments->get((string) $primary, $config, $project, $dbForPlatform, $dbForProject);
foreach ($allEvents as $event) {
try {
$subscriptionId = (string) $event->getAttribute('subscriptionId', '');
$featureId = (string) $event->getAttribute('featureId', '');
$quantity = (int) $event->getAttribute('quantity', 0);
$timestampStr = (string) $event->getAttribute('timestamp', date('c'));
$timestamp = new \DateTimeImmutable($timestampStr);
$sub = $dbForProject->findOne('payments_subscriptions', [
Query::equal('subscriptionId', [$subscriptionId])
]);
if ($sub === null || $sub->isEmpty()) {
// 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_permanent');
$dbForProject->updateDocument('payments_usage_events', $event->getId(), $event);
continue;
}
$provMap = (array) $sub->getAttribute('providers', []);
$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_permanent');
$dbForProject->updateDocument('payments_usage_events', $event->getId(), $event);
continue;
}
// Enrich state with planId for meter event name compatibility
$enrichedState = $stateBase;
$enrichedState['planId'] = (string) $sub->getAttribute('planId', '');
$state = new ProviderState((string) $primary, $config, $enrichedState);
$adapter->reportUsage(new ProviderSubscriptionRef($providerSubId), $featureId, $quantity, $timestamp, $state);
$event->setAttribute('providerSyncState', 'synced');
$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'] = $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);
// 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
View File
@@ -11,6 +11,7 @@ use Appwrite\Platform\Tasks\QueueRetry;
use Appwrite\Platform\Tasks\ScheduleExecutions;
use Appwrite\Platform\Tasks\ScheduleFunctions;
use Appwrite\Platform\Tasks\ScheduleMessages;
use Appwrite\Platform\Tasks\SchedulePaymentsUsage;
use Appwrite\Platform\Tasks\Screenshot;
use Appwrite\Platform\Tasks\SDKs;
use Appwrite\Platform\Tasks\Specs;
@@ -44,6 +45,7 @@ class Tasks extends Service
->addAction(Vars::getName(), new Vars())
->addAction(Version::getName(), new Version())
->addAction(StatsResources::getName(), new StatsResources())
->addAction(SchedulePaymentsUsage::getName(), new SchedulePaymentsUsage())
;
}
}
@@ -0,0 +1,66 @@
<?php
namespace Appwrite\Platform\Tasks;
use Appwrite\Event\PaymentsUsage;
use Utopia\CLI\Console;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
use Utopia\Database\Query;
use Utopia\Database\Validator\Authorization;
use Utopia\Platform\Action;
use Utopia\System\System;
class SchedulePaymentsUsage extends Action
{
public static function getName()
{
return 'schedule-payments-usage';
}
public function __construct()
{
$this
->desc('Schedules payments usage sync for active projects')
->inject('dbForPlatform')
->inject('queueForPaymentsUsage')
->callback($this->action(...));
}
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, $queueForPaymentsUsage) {
Authorization::disable();
Authorization::setDefaultStatus(false);
$last24Hours = (new \DateTime())->sub(\DateInterval::createFromDateString('24 hours'));
$projects = $dbForPlatform->find('projects', [
Query::greaterThanEqual('accessedAt', DateTime::format($last24Hours)),
Query::equal('region', [System::getEnv('_APP_REGION', 'default')])
]);
foreach ($projects as $project) {
/** @var Document $project */
$payments = (array) $project->getAttribute('payments', []);
if ((bool) ($payments['enabled'] ?? true) === false) {
continue; // skip disabled projects
}
$queueForPaymentsUsage
->setProject($project)
->trigger();
Console::success('Queued payments usage sync for project: ' . $project->getId());
}
Authorization::reset();
Authorization::setDefaultStatus(true);
}, $interval);
}
}
@@ -0,0 +1,22 @@
<?php
namespace Appwrite\Utopia\Database\Validator\Queries;
class PaymentFeatures extends Base
{
public const ALLOWED_ATTRIBUTES = [
'featureId',
'name',
'type',
'status'
];
/**
* Expression constructor
*
*/
public function __construct()
{
parent::__construct('payments_features', self::ALLOWED_ATTRIBUTES);
}
}
@@ -0,0 +1,22 @@
<?php
namespace Appwrite\Utopia\Database\Validator\Queries;
class PaymentPlanFeatures extends Base
{
public const ALLOWED_ATTRIBUTES = [
'planId',
'featureId',
'enabled',
'type'
];
/**
* Expression constructor
*
*/
public function __construct()
{
parent::__construct('payments_plan_features', self::ALLOWED_ATTRIBUTES);
}
}
@@ -0,0 +1,23 @@
<?php
namespace Appwrite\Utopia\Database\Validator\Queries;
class PaymentPlans extends Base
{
public const ALLOWED_ATTRIBUTES = [
'planId',
'name',
'status',
'isDefault',
'isFree'
];
/**
* Expression constructor
*
*/
public function __construct()
{
parent::__construct('payments_plans', self::ALLOWED_ATTRIBUTES);
}
}
@@ -0,0 +1,24 @@
<?php
namespace Appwrite\Utopia\Database\Validator\Queries;
class PaymentSubscriptions extends Base
{
public const ALLOWED_ATTRIBUTES = [
'subscriptionId',
'actorType',
'actorId',
'planId',
'status',
'cancelAtPeriodEnd'
];
/**
* Expression constructor
*
*/
public function __construct()
{
parent::__construct('payments_subscriptions', self::ALLOWED_ATTRIBUTES);
}
}
@@ -0,0 +1,21 @@
<?php
namespace Appwrite\Utopia\Database\Validator\Queries;
class PaymentUsageEvents extends Base
{
public const ALLOWED_ATTRIBUTES = [
'subscriptionId',
'featureId',
'quantity'
];
/**
* Expression constructor
*
*/
public function __construct()
{
parent::__construct('payments_usage_events', self::ALLOWED_ATTRIBUTES);
}
}
+14
View File
@@ -225,6 +225,20 @@ class Response extends SwooleResponse
// Project
public const MODEL_PROJECT = 'project';
public const MODEL_PROJECT_LIST = 'projectList';
// Payments
public const MODEL_PAYMENT_PLAN = 'paymentPlan';
public const MODEL_PAYMENT_FEATURE = 'paymentFeature';
public const MODEL_PAYMENT_SUBSCRIPTION = 'paymentSubscription';
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_PLAN_FEATURE = 'paymentPlanFeature';
public const MODEL_PAYMENT_PLAN_FEATURE_LIST = 'paymentPlanFeatureList';
public const MODEL_PAYMENT_INVOICE = 'paymentInvoice';
public const MODEL_PAYMENT_INVOICE_LIST = 'paymentInvoiceList';
public const MODEL_PAYMENT_USAGE_EVENT = 'paymentUsageEvent';
public const MODEL_PAYMENT_USAGE_EVENT_LIST = 'paymentUsageEventList';
public const MODEL_PAYMENT_PROVIDER_CONFIG = 'paymentProviderConfig';
public const MODEL_WEBHOOK = 'webhook';
public const MODEL_WEBHOOK_LIST = 'webhookList';
public const MODEL_KEY = 'key';
@@ -0,0 +1,48 @@
<?php
namespace Appwrite\Utopia\Response\Model;
use Appwrite\Utopia\Response;
use Appwrite\Utopia\Response\Model;
class PaymentFeature extends Model
{
public function __construct()
{
$this
->addRule('featureId', [
'type' => self::TYPE_STRING,
'description' => 'Feature ID.',
'default' => '',
'example' => 'seats',
])
->addRule('name', [
'type' => self::TYPE_STRING,
'description' => 'Feature name.',
'default' => '',
'example' => 'Seats',
])
->addRule('type', [
'type' => self::TYPE_STRING,
'description' => 'Feature type (boolean, metered).',
'default' => 'boolean',
'example' => 'metered',
])
->addRule('description', [
'type' => self::TYPE_STRING,
'description' => 'Feature description.',
'default' => '',
'example' => 'Number of seat licenses',
]);
}
public function getName(): string
{
return 'PaymentFeature';
}
public function getType(): string
{
return Response::MODEL_PAYMENT_FEATURE;
}
}
@@ -0,0 +1,78 @@
<?php
namespace Appwrite\Utopia\Response\Model;
use Appwrite\Utopia\Response;
use Appwrite\Utopia\Response\Model;
class PaymentInvoice extends Model
{
public function __construct()
{
$this
->addRule('invoiceId', [
'type' => self::TYPE_STRING,
'description' => 'Invoice ID.',
'default' => '',
'example' => 'inv_1234567890',
])
->addRule('subscriptionId', [
'type' => self::TYPE_STRING,
'description' => 'Subscription ID.',
'default' => '',
'example' => 'sub_1234567890',
])
->addRule('amount', [
'type' => self::TYPE_INTEGER,
'description' => 'Invoice amount in cents.',
'default' => 0,
'example' => 2999,
])
->addRule('currency', [
'type' => self::TYPE_STRING,
'description' => 'Currency code.',
'default' => '',
'example' => 'usd',
])
->addRule('status', [
'type' => self::TYPE_STRING,
'description' => 'Invoice status.',
'default' => '',
'example' => 'paid',
])
->addRule('createdAt', [
'type' => self::TYPE_DATETIME,
'description' => 'Invoice creation date.',
'default' => '',
'example' => self::TYPE_DATETIME_EXAMPLE,
])
->addRule('paidAt', [
'type' => self::TYPE_DATETIME,
'description' => 'Invoice payment date.',
'default' => '',
'example' => self::TYPE_DATETIME_EXAMPLE,
])
->addRule('invoiceUrl', [
'type' => self::TYPE_STRING,
'description' => 'URL to view the invoice.',
'default' => '',
'example' => 'https://invoice.stripe.com/i/acct_123/inv_456',
])
->addRule('metadata', [
'type' => self::TYPE_JSON,
'description' => 'Additional metadata.',
'default' => [],
'example' => [],
]);
}
public function getName(): string
{
return 'PaymentInvoice';
}
public function getType(): string
{
return Response::MODEL_PAYMENT_INVOICE;
}
}
@@ -0,0 +1,32 @@
<?php
namespace Appwrite\Utopia\Response\Model;
use Appwrite\Utopia\Response;
use Appwrite\Utopia\Response\Model;
class PaymentPlan extends Model
{
public function __construct()
{
$this
->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('features', [ 'type' => self::TYPE_JSON, 'description' => 'Features summary', 'default' => [], 'example' => [] ]);
}
public function getName(): string
{
return 'PaymentPlan';
}
public function getType(): string
{
return Response::MODEL_PAYMENT_PLAN;
}
}
@@ -0,0 +1,102 @@
<?php
namespace Appwrite\Utopia\Response\Model;
use Appwrite\Utopia\Response;
use Appwrite\Utopia\Response\Model;
class PaymentPlanFeature extends Model
{
public function __construct()
{
$this
->addRule('planId', [
'type' => self::TYPE_STRING,
'description' => 'Plan ID.',
'default' => '',
'example' => 'pro',
])
->addRule('featureId', [
'type' => self::TYPE_STRING,
'description' => 'Feature ID.',
'default' => '',
'example' => 'seats',
])
->addRule('type', [
'type' => self::TYPE_STRING,
'description' => 'Feature type (boolean, metered).',
'default' => 'boolean',
'example' => 'metered',
])
->addRule('enabled', [
'type' => self::TYPE_BOOLEAN,
'description' => 'Whether the feature is enabled for this plan.',
'default' => true,
'example' => true,
])
->addRule('currency', [
'type' => self::TYPE_STRING,
'description' => 'Currency code for metered features.',
'default' => '',
'example' => 'usd',
])
->addRule('interval', [
'type' => self::TYPE_STRING,
'description' => 'Billing interval for metered features.',
'default' => '',
'example' => 'month',
])
->addRule('includedUnits', [
'type' => self::TYPE_INTEGER,
'description' => 'Number of units included in the plan.',
'default' => 0,
'example' => 100,
])
->addRule('tiersMode', [
'type' => self::TYPE_STRING,
'description' => 'Pricing tiers mode (graduated or volume).',
'default' => '',
'example' => 'graduated',
])
->addRule('tiers', [
'type' => self::TYPE_JSON,
'description' => 'Pricing tiers configuration.',
'default' => [],
'example' => [],
])
->addRule('usageCap', [
'type' => self::TYPE_INTEGER,
'description' => 'Maximum usage cap.',
'default' => null,
'example' => 1000,
])
->addRule('overagePrice', [
'type' => self::TYPE_INTEGER,
'description' => 'Price per unit for overage usage.',
'default' => null,
'example' => 100,
])
->addRule('providers', [
'type' => self::TYPE_JSON,
'description' => 'Provider-specific metadata.',
'default' => [],
'example' => [],
])
->addRule('metadata', [
'type' => self::TYPE_JSON,
'description' => 'Additional metadata.',
'default' => [],
'example' => [],
]);
}
public function getName(): string
{
return 'PaymentPlanFeature';
}
public function getType(): string
{
return Response::MODEL_PAYMENT_PLAN_FEATURE;
}
}
@@ -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;
}
}
@@ -0,0 +1,109 @@
<?php
namespace Appwrite\Utopia\Response\Model;
use Appwrite\Utopia\Response;
use Appwrite\Utopia\Response\Model;
class PaymentSubscription extends Model
{
public function __construct()
{
$this
->addRule('subscriptionId', [
'type' => self::TYPE_STRING,
'description' => 'Subscription ID.',
'default' => '',
'example' => 'sub_123',
])
->addRule('actorType', [
'type' => self::TYPE_STRING,
'description' => 'Actor type.',
'default' => '',
'example' => 'team',
])
->addRule('actorId', [
'type' => self::TYPE_STRING,
'description' => 'Actor ID.',
'default' => '',
'example' => 'team_abc',
])
->addRule('planId', [
'type' => self::TYPE_STRING,
'description' => 'Plan ID.',
'default' => '',
'example' => 'pro',
])
->addRule('priceId', [
'type' => self::TYPE_STRING,
'description' => 'Selected price ID.',
'default' => '',
'example' => 'pro-monthly',
])
->addRule('status', [
'type' => self::TYPE_STRING,
'description' => 'Subscription status.',
'default' => 'active',
'example' => 'active',
])
->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' => Response::MODEL_PAYMENT_PLAN,
'description' => 'Embedded plan model.',
'default' => null,
'example' => [],
])
->addRule('features', [
'type' => Response::MODEL_PAYMENT_FEATURE,
'description' => 'Feature quotas for the subscribed plan.',
'default' => [],
'example' => [],
'array' => true,
]);
}
public function getName(): string
{
return 'PaymentSubscription';
}
public function getType(): string
{
return Response::MODEL_PAYMENT_SUBSCRIPTION;
}
}
@@ -0,0 +1,84 @@
<?php
namespace Appwrite\Utopia\Response\Model;
use Appwrite\Utopia\Response;
use Appwrite\Utopia\Response\Model;
class PaymentUsageEvent extends Model
{
public function __construct()
{
$this
->addRule('subscriptionId', [
'type' => self::TYPE_STRING,
'description' => 'Subscription ID.',
'default' => '',
'example' => 'sub_abc123',
])
->addRule('actorType', [
'type' => self::TYPE_STRING,
'description' => 'Actor type (user or team).',
'default' => 'user',
'example' => 'user',
])
->addRule('actorId', [
'type' => self::TYPE_STRING,
'description' => 'Actor ID.',
'default' => '',
'example' => 'user_123',
])
->addRule('planId', [
'type' => self::TYPE_STRING,
'description' => 'Plan ID.',
'default' => '',
'example' => 'pro',
])
->addRule('featureId', [
'type' => self::TYPE_STRING,
'description' => 'Feature ID.',
'default' => '',
'example' => 'api_calls',
])
->addRule('quantity', [
'type' => self::TYPE_INTEGER,
'description' => 'Usage quantity.',
'default' => 0,
'example' => 100,
])
->addRule('timestamp', [
'type' => self::TYPE_DATETIME,
'description' => 'Event timestamp.',
'default' => '',
'example' => self::TYPE_DATETIME_EXAMPLE,
])
->addRule('providerSyncState', [
'type' => self::TYPE_STRING,
'description' => 'Provider sync state.',
'default' => 'pending',
'example' => 'synced',
])
->addRule('providerEventId', [
'type' => self::TYPE_STRING,
'description' => 'Provider event ID.',
'default' => '',
'example' => 'evt_123',
])
->addRule('metadata', [
'type' => self::TYPE_JSON,
'description' => 'Additional metadata.',
'default' => [],
'example' => [],
]);
}
public function getName(): string
{
return 'PaymentUsageEvent';
}
public function getType(): string
{
return Response::MODEL_PAYMENT_USAGE_EVENT;
}
}
@@ -0,0 +1,392 @@
<?php
namespace Tests\E2E\Services\Payments;
use Appwrite\Tests\Async;
use Tests\E2E\Client;
trait PaymentsBase
{
use Async;
protected function setupPlan(array $params = []): array
{
$defaults = [
'planId' => 'test-plan-' . uniqid(),
'name' => 'Test Plan',
'description' => 'Test plan description',
'isDefault' => false,
'isFree' => false,
'pricing' => []
];
$params = array_merge($defaults, $params);
$plan = $this->client->call(Client::METHOD_POST, '/payments/plans', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
]), $params);
$this->assertEquals(201, $plan['headers']['status-code'], 'Setup plan failed with status code: ' . $plan['headers']['status-code'] . ' and response: ' . json_encode($plan['body'], JSON_PRETTY_PRINT));
return $plan['body'];
}
protected function cleanupPlan(string $planId): void
{
$plan = $this->client->call(Client::METHOD_DELETE, '/payments/plans/' . $planId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
]));
$this->assertEquals(204, $plan['headers']['status-code']);
}
protected function createPlan(array $params = []): array
{
$plan = $this->client->call(Client::METHOD_POST, '/payments/plans', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), $params);
return $plan;
}
protected function getPlan(string $planId): array
{
$plan = $this->client->call(Client::METHOD_GET, '/payments/plans/' . $planId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
return $plan;
}
protected function listPlans(array $params = []): array
{
$plans = $this->client->call(Client::METHOD_GET, '/payments/plans', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), $params);
return $plans;
}
protected function updatePlan(string $planId, array $params = []): array
{
$plan = $this->client->call(Client::METHOD_PUT, '/payments/plans/' . $planId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), $params);
return $plan;
}
protected function deletePlan(string $planId): array
{
$plan = $this->client->call(Client::METHOD_DELETE, '/payments/plans/' . $planId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
return $plan;
}
protected function setupFeature(array $params = []): array
{
$defaults = [
'featureId' => 'test-feature-' . uniqid(),
'name' => 'Test Feature',
'type' => 'boolean',
'description' => 'Test feature description'
];
$params = array_merge($defaults, $params);
$feature = $this->client->call(Client::METHOD_POST, '/payments/features', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
]), $params);
$this->assertEquals(201, $feature['headers']['status-code'], 'Setup feature failed with status code: ' . $feature['headers']['status-code'] . ' and response: ' . json_encode($feature['body'], JSON_PRETTY_PRINT));
return $feature['body'];
}
protected function cleanupFeature(string $featureId): void
{
$feature = $this->client->call(Client::METHOD_DELETE, '/payments/features/' . $featureId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
]));
$this->assertEquals(204, $feature['headers']['status-code']);
}
protected function createFeature(array $params = []): array
{
$feature = $this->client->call(Client::METHOD_POST, '/payments/features', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), $params);
return $feature;
}
protected function getFeature(string $featureId): array
{
$feature = $this->client->call(Client::METHOD_GET, '/payments/features/' . $featureId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
return $feature;
}
protected function listFeatures(array $params = []): array
{
$features = $this->client->call(Client::METHOD_GET, '/payments/features', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), $params);
return $features;
}
protected function updateFeature(string $featureId, array $params = []): array
{
$feature = $this->client->call(Client::METHOD_PUT, '/payments/features/' . $featureId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), $params);
return $feature;
}
protected function deleteFeature(string $featureId): array
{
$feature = $this->client->call(Client::METHOD_DELETE, '/payments/features/' . $featureId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
return $feature;
}
protected function setupSubscription(string $planId, array $params = []): array
{
$defaults = [
'actorType' => 'user',
'actorId' => $this->getUser()['$id'],
'planId' => $planId,
'successUrl' => 'https://example.com/success',
'cancelUrl' => 'https://example.com/cancel'
];
$params = array_merge($defaults, $params);
$subscription = $this->client->call(Client::METHOD_POST, '/payments/subscriptions', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
]), $params);
$this->assertEquals(201, $subscription['headers']['status-code'], 'Setup subscription failed with status code: ' . $subscription['headers']['status-code'] . ' and response: ' . json_encode($subscription['body'], JSON_PRETTY_PRINT));
return $subscription['body'];
}
protected function cleanupSubscription(string $subscriptionId): void
{
$subscription = $this->client->call(Client::METHOD_POST, '/payments/subscriptions/' . $subscriptionId . '/cancel', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
]), [
'endAtPeriodEnd' => false
]);
$this->assertEquals(204, $subscription['headers']['status-code']);
}
protected function createSubscription(array $params = []): array
{
$subscription = $this->client->call(Client::METHOD_POST, '/payments/subscriptions', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), $params);
return $subscription;
}
protected function getSubscription(string $subscriptionId): array
{
$subscription = $this->client->call(Client::METHOD_GET, '/payments/subscriptions/' . $subscriptionId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
return $subscription;
}
protected function listSubscriptions(array $params = []): array
{
$subscriptions = $this->client->call(Client::METHOD_GET, '/payments/subscriptions', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), $params);
return $subscriptions;
}
protected function updateSubscription(string $subscriptionId, array $params = []): array
{
$subscription = $this->client->call(Client::METHOD_PUT, '/payments/subscriptions/' . $subscriptionId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), $params);
return $subscription;
}
protected function cancelSubscription(string $subscriptionId, array $params = []): array
{
$subscription = $this->client->call(Client::METHOD_POST, '/payments/subscriptions/' . $subscriptionId . '/cancel', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), $params);
return $subscription;
}
protected function resumeSubscription(string $subscriptionId): array
{
$subscription = $this->client->call(Client::METHOD_POST, '/payments/subscriptions/' . $subscriptionId . '/resume', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
return $subscription;
}
protected function getSubscriptionPortal(string $subscriptionId, array $params = []): array
{
$portal = $this->client->call(Client::METHOD_GET, '/payments/subscriptions/' . $subscriptionId . '/portal', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), $params);
return $portal;
}
protected function previewUpgrade(string $subscriptionId, array $params = []): array
{
$preview = $this->client->call(Client::METHOD_GET, '/payments/subscriptions/' . $subscriptionId . '/preview-upgrade', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), $params);
return $preview;
}
protected function listInvoices(array $params = []): array
{
$invoices = $this->client->call(Client::METHOD_GET, '/payments/invoices', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), $params);
return $invoices;
}
protected function getUsage(array $params = []): array
{
$usage = $this->client->call(Client::METHOD_GET, '/payments/usage', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), $params);
return $usage;
}
protected function createUsage(array $params = []): array
{
$usage = $this->client->call(Client::METHOD_POST, '/payments/usage', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), $params);
return $usage;
}
protected function listUsageEvents(array $params = []): array
{
$events = $this->client->call(Client::METHOD_GET, '/payments/usage/events', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), $params);
return $events;
}
protected function getProvider(string $providerId): array
{
$provider = $this->client->call(Client::METHOD_GET, '/payments/providers/' . $providerId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
return $provider;
}
protected function updateProvider(string $providerId, array $params = []): array
{
$provider = $this->client->call(Client::METHOD_PUT, '/payments/providers/' . $providerId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), $params);
return $provider;
}
protected function assignPlanFeature(string $planId, array $params = []): array
{
$planFeature = $this->client->call(Client::METHOD_POST, '/payments/plans/' . $planId . '/features', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), $params);
return $planFeature;
}
protected function listPlanFeatures(string $planId, array $params = []): array
{
$planFeatures = $this->client->call(Client::METHOD_GET, '/payments/plans/' . $planId . '/features', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), $params);
return $planFeatures;
}
protected function removePlanFeature(string $planId, string $featureId): array
{
$planFeature = $this->client->call(Client::METHOD_DELETE, '/payments/plans/' . $planId . '/features/' . $featureId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
return $planFeature;
}
protected function getActorFeatures(array $params = []): array
{
$actorFeatures = $this->client->call(Client::METHOD_GET, '/payments/actor-features', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), $params);
return $actorFeatures;
}
}
@@ -0,0 +1,17 @@
<?php
namespace Tests\E2E\Services\Payments;
use Tests\E2E\Client;
use Tests\E2E\Scopes\ProjectCustom;
use Tests\E2E\Scopes\Scope;
use Tests\E2E\Scopes\SideConsole;
class PaymentsConsoleClientTest extends Scope
{
use PaymentsBase;
use ProjectCustom;
use SideConsole;
// Add test methods for console client tests
}
@@ -0,0 +1,17 @@
<?php
namespace Tests\E2E\Services\Payments;
use Tests\E2E\Client;
use Tests\E2E\Scopes\ProjectCustom;
use Tests\E2E\Scopes\Scope;
use Tests\E2E\Scopes\SideClient;
class PaymentsCustomClientTest extends Scope
{
use PaymentsBase;
use ProjectCustom;
use SideClient;
// Add test methods for custom client tests
}
@@ -0,0 +1,16 @@
<?php
namespace Tests\E2E\Services\Payments;
use Tests\E2E\Scopes\ProjectCustom;
use Tests\E2E\Scopes\Scope;
use Tests\E2E\Scopes\SideServer;
class PaymentsCustomServerTest extends Scope
{
use PaymentsBase;
use ProjectCustom;
use SideServer;
// Add test methods for server-side tests
}