mirror of
https://github.com/appwrite/appwrite.git
synced 2026-05-26 13:51:13 +00:00
Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8f83285c8 | ||
|
|
0a3b879140 | ||
|
|
c1c53789ae | ||
|
|
131bf76848 | ||
|
|
e352eb8ced | ||
|
|
c187efadb4 | ||
|
|
9786b4f6cd | ||
|
|
c915a00bac | ||
|
|
d03d9e5795 | ||
|
|
7cde35d7a3 | ||
|
|
27932854c8 | ||
|
|
a01d689ae2 | ||
|
|
f319841fd1 | ||
|
|
92f9ddfc33 | ||
|
|
86878e3b55 | ||
|
|
df8c696b86 | ||
|
|
650da7ba94 | ||
|
|
efb8ff7bb1 | ||
|
|
16f7031799 | ||
|
|
90e5824f9d | ||
|
|
d7677a8992 | ||
|
|
94390f4a65 | ||
|
|
f59cd158ef | ||
|
|
3967b2e338 | ||
|
|
eb08a3379a | ||
|
|
fee6ad43d6 | ||
|
|
350e651c53 | ||
|
|
d49bad0de5 | ||
|
|
e885cece85 | ||
|
|
9b71c70d1a | ||
|
|
414692083f | ||
|
|
be1e681aa8 |
@@ -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
@@ -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/
|
||||
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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] ],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
];
|
||||
|
||||
@@ -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)'
|
||||
],
|
||||
];
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
@@ -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';
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
|
||||
php /usr/src/code/app/cli.php schedule-payments-usage $@
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
|
||||
php /usr/src/code/app/worker.php payments-usage-sync $@
|
||||
Generated
+8148
-8960
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -45,6 +45,9 @@ class Event
|
||||
public const MIGRATIONS_QUEUE_NAME = 'v1-migrations';
|
||||
public const MIGRATIONS_CLASS_NAME = 'MigrationsV1';
|
||||
|
||||
public const PAYMENTS_USAGE_QUEUE_NAME = 'v1-payments-usage-sync';
|
||||
public const PAYMENTS_USAGE_CLASS_NAME = 'PaymentsUsageSyncV1';
|
||||
|
||||
protected string $queue = '';
|
||||
protected string $class = '';
|
||||
protected string $event = '';
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Event;
|
||||
|
||||
use Utopia\Queue\Publisher;
|
||||
|
||||
class PaymentsUsage extends Event
|
||||
{
|
||||
public function __construct(protected Publisher $publisher)
|
||||
{
|
||||
parent::__construct($publisher);
|
||||
|
||||
$this
|
||||
->setQueue(Event::PAYMENTS_USAGE_QUEUE_NAME)
|
||||
->setClass(Event::PAYMENTS_USAGE_CLASS_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the payload for the event
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function preparePayload(): array
|
||||
{
|
||||
return [
|
||||
'project' => $this->getProject(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user