mirror of
https://github.com/appwrite/appwrite.git
synced 2026-05-26 13:51:13 +00:00
Compare commits
78
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8f83285c8 | ||
|
|
0a3b879140 | ||
|
|
c1c53789ae | ||
|
|
131bf76848 | ||
|
|
e352eb8ced | ||
|
|
c187efadb4 | ||
|
|
fc5ea06821 | ||
|
|
aa135d5cd4 | ||
|
|
6d85d1567e | ||
|
|
9786b4f6cd | ||
|
|
7567639996 | ||
|
|
057bff2140 | ||
|
|
d7bb234072 | ||
|
|
3b5b15d1a6 | ||
|
|
2c5bc32f7e | ||
|
|
a37aa2dd68 | ||
|
|
b29f70a0af | ||
|
|
2fc5e56a61 | ||
|
|
b1373c02bc | ||
|
|
1db12e78ef | ||
|
|
a25c8004e4 | ||
|
|
547d59d95e | ||
|
|
4e0477af32 | ||
|
|
1659bc3ffa | ||
|
|
84e9c8243c | ||
|
|
020d7bb801 | ||
|
|
b6aeaffe8b | ||
|
|
65883122e3 | ||
|
|
d4660a70c3 | ||
|
|
16f9c35850 | ||
|
|
49511ebdd9 | ||
|
|
63511c56ae | ||
|
|
445ada0226 | ||
|
|
68b4a49e9d | ||
|
|
28a09d7aaa | ||
|
|
ee3b2d75ae | ||
|
|
ffe1fd89a7 | ||
|
|
06c4ba81e9 | ||
|
|
f2772cea6b | ||
|
|
b76a4bdd2f | ||
|
|
1c94381830 | ||
|
|
8d1acef95d | ||
|
|
bd288ce650 | ||
|
|
9f2105b294 | ||
|
|
b3944c678c | ||
|
|
31c8c09060 | ||
|
|
4283671d49 | ||
|
|
c3a3717bde | ||
|
|
5cdb59142f | ||
|
|
bf6f784826 | ||
|
|
33f7fd259b | ||
|
|
bc99e04b57 | ||
|
|
10b8f97e96 | ||
|
|
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
|
||||
|
||||
+11
-11
@@ -223,7 +223,7 @@ jobs:
|
||||
-e _APP_DATABASE_SHARED_TABLES \
|
||||
-e _APP_DATABASE_SHARED_TABLES_V1 \
|
||||
-e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" \
|
||||
appwrite test /usr/src/code/tests/e2e/Services/${{ matrix.service }} --debug --exclude-group devKeys,screenshots
|
||||
appwrite test /usr/src/code/tests/e2e/Services/${{ matrix.service }} --debug --exclude-group abuseEnabled,screenshots
|
||||
|
||||
- name: Failure Logs
|
||||
if: failure()
|
||||
@@ -312,7 +312,7 @@ jobs:
|
||||
-e _APP_DATABASE_SHARED_TABLES \
|
||||
-e _APP_DATABASE_SHARED_TABLES_V1 \
|
||||
-e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" \
|
||||
appwrite test /usr/src/code/tests/e2e/Services/${{ matrix.service }} --debug --exclude-group devKeys,screenshots
|
||||
appwrite test /usr/src/code/tests/e2e/Services/${{ matrix.service }} --debug --exclude-group abuseEnabled,screenshots
|
||||
|
||||
- name: Failure Logs
|
||||
if: failure()
|
||||
@@ -322,8 +322,8 @@ jobs:
|
||||
echo "=== OpenRuntimes Executor Logs ==="
|
||||
docker compose logs openruntimes-executor
|
||||
|
||||
e2e_dev_keys:
|
||||
name: E2E Service Test (Dev Keys)
|
||||
e2e_abuse_enabled:
|
||||
name: E2E Service Test (Abuse enabled)
|
||||
runs-on: ubuntu-latest
|
||||
needs: setup
|
||||
steps:
|
||||
@@ -344,7 +344,7 @@ jobs:
|
||||
docker compose up -d
|
||||
sleep 30
|
||||
|
||||
- name: Run Projects tests with dev keys in dedicated table mode
|
||||
- name: Run Projects tests in dedicated table mode
|
||||
run: |
|
||||
echo "Using project tables"
|
||||
export _APP_DATABASE_SHARED_TABLES=
|
||||
@@ -354,7 +354,7 @@ jobs:
|
||||
-e _APP_DATABASE_SHARED_TABLES \
|
||||
-e _APP_DATABASE_SHARED_TABLES_V1 \
|
||||
-e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" \
|
||||
appwrite test /usr/src/code/tests/e2e/Services/Projects --debug --group=devKeys
|
||||
appwrite test /usr/src/code/tests/e2e/Services/Projects --debug --group=abuseEnabled
|
||||
|
||||
- name: Failure Logs
|
||||
if: failure()
|
||||
@@ -364,8 +364,8 @@ jobs:
|
||||
echo "=== OpenRuntimes Executor Logs ==="
|
||||
docker compose logs openruntimes-executor
|
||||
|
||||
e2e_dev_keys_shared_mode:
|
||||
name: E2E Shared Mode Service Test (Dev Keys)
|
||||
e2e_abuse_enabled_shared_mode:
|
||||
name: E2E Shared Mode Service Test (Abuse enabled)
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ setup, check_database_changes ]
|
||||
if: needs.check_database_changes.outputs.database_changed == 'true'
|
||||
@@ -394,7 +394,7 @@ jobs:
|
||||
docker compose up -d
|
||||
sleep 30
|
||||
|
||||
- name: Run Projects tests with dev keys in ${{ matrix.tables-mode }} table mode
|
||||
- name: Run Projects tests in ${{ matrix.tables-mode }} table mode
|
||||
run: |
|
||||
if [ "${{ matrix.tables-mode }}" == "Shared V1" ]; then
|
||||
echo "Using shared tables V1"
|
||||
@@ -410,7 +410,7 @@ jobs:
|
||||
-e _APP_DATABASE_SHARED_TABLES \
|
||||
-e _APP_DATABASE_SHARED_TABLES_V1 \
|
||||
-e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" \
|
||||
appwrite test /usr/src/code/tests/e2e/Services/Projects --debug --group=devKeys
|
||||
appwrite test /usr/src/code/tests/e2e/Services/Projects --debug --group=abuseEnabled
|
||||
|
||||
- name: Failure Logs
|
||||
if: failure()
|
||||
@@ -420,7 +420,7 @@ jobs:
|
||||
echo "=== OpenRuntimes Executor Logs ==="
|
||||
docker compose logs openruntimes-executor
|
||||
|
||||
e2e_screenshots_keys:
|
||||
e2e_screenshots:
|
||||
name: E2E Service Test (Site Screenshots)
|
||||
runs-on: ubuntu-latest
|
||||
needs: setup
|
||||
|
||||
+98
@@ -1,3 +1,101 @@
|
||||
# Version 1.8.1
|
||||
|
||||
## What's Changed
|
||||
|
||||
### Notable changes
|
||||
|
||||
* Add branch deployments support in [#10486](https://github.com/appwrite/appwrite/pull/10486)
|
||||
* Add TanStack Start sites support in [#10681](https://github.com/appwrite/appwrite/pull/10681)
|
||||
* Add Next.js standalone support in [#10747](https://github.com/appwrite/appwrite/pull/10747)
|
||||
* Add Resend integration in [#10690](https://github.com/appwrite/appwrite/pull/10690)
|
||||
* Add option to enable/disable image transformations per-bucket in [#10722](https://github.com/appwrite/appwrite/pull/10722)
|
||||
* Add operators support in [#10735](https://github.com/appwrite/appwrite/pull/10735) and [#10800](https://github.com/appwrite/appwrite/pull/10800)
|
||||
* Add function and sites stats in [#10786](https://github.com/appwrite/appwrite/pull/10786)
|
||||
* Add disable count feature in [#10668](https://github.com/appwrite/appwrite/pull/10668)
|
||||
* Add ElevenLabs site template in [#10782](https://github.com/appwrite/appwrite/pull/10782)
|
||||
* Add suggested environment variables in [#10795](https://github.com/appwrite/appwrite/pull/10795)
|
||||
* Update GeoDB database in [#10890](https://github.com/appwrite/appwrite/pull/10890)
|
||||
* Update Flutter default build runtime in [#10807](https://github.com/appwrite/appwrite/pull/10807)
|
||||
* Upgrade runtimes in [#10804](https://github.com/appwrite/appwrite/pull/10804)
|
||||
|
||||
### Fixes
|
||||
|
||||
* Fix duplicate document error while creating file in [#10891](https://github.com/appwrite/appwrite/pull/10891)
|
||||
* Fix "Update external deployment (authorize)" throwing 500 error due to invalid query in [#10888](https://github.com/appwrite/appwrite/pull/10888)
|
||||
* Fix error setting user password in [#10889](https://github.com/appwrite/appwrite/pull/10889)
|
||||
* Fix error generating email MFA challenges in [#10884](https://github.com/appwrite/appwrite/pull/10884)
|
||||
* Fix file token expiry in [#10877](https://github.com/appwrite/appwrite/pull/10877)
|
||||
* Fix TanStack Nitro default in [#10860](https://github.com/appwrite/appwrite/pull/10860)
|
||||
* Fix TanStack builds in [#10767](https://github.com/appwrite/appwrite/pull/10767)
|
||||
* Fix nullable validation in [#10819](https://github.com/appwrite/appwrite/pull/10819) and [#10778](https://github.com/appwrite/appwrite/pull/10778)
|
||||
* Fix WebP library in [#10738](https://github.com/appwrite/appwrite/pull/10738)
|
||||
* Fix batch writes in [#10812](https://github.com/appwrite/appwrite/pull/10812)
|
||||
* Fix error handler error in [#10719](https://github.com/appwrite/appwrite/pull/10719)
|
||||
* Fix Next 16 compatibility in [#10713](https://github.com/appwrite/appwrite/pull/10713)
|
||||
* Fix stats usage memory leak in [#10683](https://github.com/appwrite/appwrite/pull/10683)
|
||||
* Fix author URL in template deployments in [#10535](https://github.com/appwrite/appwrite/pull/10535)
|
||||
* Fix VCS lock deletion in [#10691](https://github.com/appwrite/appwrite/pull/10691)
|
||||
|
||||
### Miscellaneous
|
||||
|
||||
* Add CSV export functionality in [#10546](https://github.com/appwrite/appwrite/pull/10546), [#10750](https://github.com/appwrite/appwrite/pull/10750), [#10813](https://github.com/appwrite/appwrite/pull/10813), and [#10847](https://github.com/appwrite/appwrite/pull/10847)
|
||||
* Add JWT disposition in [#10867](https://github.com/appwrite/appwrite/pull/10867)
|
||||
* Add screenshots endpoint in [#10675](https://github.com/appwrite/appwrite/pull/10675)
|
||||
* Add screenshot endpoint stats in [#10706](https://github.com/appwrite/appwrite/pull/10706)
|
||||
* Add users attributes in [#10688](https://github.com/appwrite/appwrite/pull/10688)
|
||||
* Add max build duration environment variable in [#10674](https://github.com/appwrite/appwrite/pull/10674)
|
||||
* Add custom realtime logger in [#10871](https://github.com/appwrite/appwrite/pull/10871)
|
||||
* Add logs in [#10869](https://github.com/appwrite/appwrite/pull/10869)
|
||||
* Improve MFA docs endpoint order in [#10793](https://github.com/appwrite/appwrite/pull/10793)
|
||||
* Auth refactor in [#10758](https://github.com/appwrite/appwrite/pull/10758), [#10837](https://github.com/appwrite/appwrite/pull/10837), [#10682](https://github.com/appwrite/appwrite/pull/10682), and [#10667](https://github.com/appwrite/appwrite/pull/10667)
|
||||
* Bump assistant to 0.8.4 in [#10887](https://github.com/appwrite/appwrite/pull/10887)
|
||||
* Bump database to 3.1.5 in [#10766](https://github.com/appwrite/appwrite/pull/10766)
|
||||
* Bump Utopia DNS in [#10761](https://github.com/appwrite/appwrite/pull/10761)
|
||||
* Update domains to 0.8.3 in [#10658](https://github.com/appwrite/appwrite/pull/10658)
|
||||
* Update domains to 0.9.1 in [#10678](https://github.com/appwrite/appwrite/pull/10678)
|
||||
* Update Apple Swift to 13.3.0 in [#10679](https://github.com/appwrite/appwrite/pull/10679)
|
||||
* Update Apple Swift in [#10663](https://github.com/appwrite/appwrite/pull/10663)
|
||||
* Update CLI to 10.2.2 in [#10672](https://github.com/appwrite/appwrite/pull/10672)
|
||||
* Update to CLI 12.0.0 in [#10853](https://github.com/appwrite/appwrite/pull/10853)
|
||||
* Update docs examples to use Permission class in [#10707](https://github.com/appwrite/appwrite/pull/10707)
|
||||
* Update SDK examples docs in [#10855](https://github.com/appwrite/appwrite/pull/10855)
|
||||
* Release Python SDK in [#10762](https://github.com/appwrite/appwrite/pull/10762)
|
||||
* Release Flutter 20.3.2 in [#10838](https://github.com/appwrite/appwrite/pull/10838)
|
||||
* Release Flutter/Dart add screenshot examples in [#10811](https://github.com/appwrite/appwrite/pull/10811)
|
||||
* Release PHP CLI in [#10791](https://github.com/appwrite/appwrite/pull/10791)
|
||||
* Release SDKs in [#10817](https://github.com/appwrite/appwrite/pull/10817)
|
||||
* Update SDKs in [#10694](https://github.com/appwrite/appwrite/pull/10694), [#10729](https://github.com/appwrite/appwrite/pull/10729), and [#10744](https://github.com/appwrite/appwrite/pull/10744)
|
||||
* Update SDK generator in [#10743](https://github.com/appwrite/appwrite/pull/10743)
|
||||
* Update database in [#10664](https://github.com/appwrite/appwrite/pull/10664)
|
||||
* Update README file in [#10763](https://github.com/appwrite/appwrite/pull/10763)
|
||||
* SDK release documentation in [#10745](https://github.com/appwrite/appwrite/pull/10745)
|
||||
* SDK release runtime config in [#10765](https://github.com/appwrite/appwrite/pull/10765)
|
||||
* Sync specs in [#10789](https://github.com/appwrite/appwrite/pull/10789)
|
||||
* Sync 1.8.0 in [#10677](https://github.com/appwrite/appwrite/pull/10677)
|
||||
* Add workflow for issue triage in [#10718](https://github.com/appwrite/appwrite/pull/10718)
|
||||
* Add issue auto-labeler in [#10700](https://github.com/appwrite/appwrite/pull/10700)
|
||||
* Add AI moderator repo in [#10717](https://github.com/appwrite/appwrite/pull/10717)
|
||||
* Browser bump in [#10850](https://github.com/appwrite/appwrite/pull/10850)
|
||||
* Template type enum override in [#10848](https://github.com/appwrite/appwrite/pull/10848)
|
||||
* VCS reference type in [#10852](https://github.com/appwrite/appwrite/pull/10852)
|
||||
* Index scope description in [#10851](https://github.com/appwrite/appwrite/pull/10851)
|
||||
* Config for environment in [#10833](https://github.com/appwrite/appwrite/pull/10833)
|
||||
* Format instance in [#10830](https://github.com/appwrite/appwrite/pull/10830)
|
||||
* Replace sleep in webhooks service in [#10656](https://github.com/appwrite/appwrite/pull/10656)
|
||||
* Update email composer in [#10720](https://github.com/appwrite/appwrite/pull/10720)
|
||||
* Update facts on GitHub sites and functions in [#10593](https://github.com/appwrite/appwrite/pull/10593) and [#10771](https://github.com/appwrite/appwrite/pull/10771)
|
||||
* Fix wrong user type in [#10875](https://github.com/appwrite/appwrite/pull/10875)
|
||||
* Fix limit and offset computation in [#10880](https://github.com/appwrite/appwrite/pull/10880)
|
||||
* Fix enum examples in [#10828](https://github.com/appwrite/appwrite/pull/10828)
|
||||
* Fix response models multi-methods in [#10815](https://github.com/appwrite/appwrite/pull/10815)
|
||||
* Fix undefined variable in [#10654](https://github.com/appwrite/appwrite/pull/10654)
|
||||
* Fix undefined sequence in [#10652](https://github.com/appwrite/appwrite/pull/10652)
|
||||
* Fix description in [#10702](https://github.com/appwrite/appwrite/pull/10702)
|
||||
* Fix warning in builds worker in [#10705](https://github.com/appwrite/appwrite/pull/10705)
|
||||
* Fix sites create deployment docs in [#10566](https://github.com/appwrite/appwrite/pull/10566)
|
||||
* Fix test dependencies projects in [#10655](https://github.com/appwrite/appwrite/pull/10655)
|
||||
* Fix list sites test in [#10726](https://github.com/appwrite/appwrite/pull/10726)
|
||||
|
||||
# Version 1.8.0
|
||||
|
||||
## What's Changed
|
||||
|
||||
+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/
|
||||
|
||||
|
||||
+3
-3
@@ -72,7 +72,7 @@ docker run -it --rm \
|
||||
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||
--volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \
|
||||
--entrypoint="install" \
|
||||
appwrite/appwrite:1.8.0
|
||||
appwrite/appwrite:1.8.1
|
||||
```
|
||||
|
||||
### Windows
|
||||
@@ -84,7 +84,7 @@ docker run -it --rm ^
|
||||
--volume //var/run/docker.sock:/var/run/docker.sock ^
|
||||
--volume "%cd%"/appwrite:/usr/src/code/appwrite:rw ^
|
||||
--entrypoint="install" ^
|
||||
appwrite/appwrite:1.8.0
|
||||
appwrite/appwrite:1.8.1
|
||||
```
|
||||
|
||||
#### PowerShell
|
||||
@@ -94,7 +94,7 @@ docker run -it --rm `
|
||||
--volume /var/run/docker.sock:/var/run/docker.sock `
|
||||
--volume ${pwd}/appwrite:/usr/src/code/appwrite:rw `
|
||||
--entrypoint="install" `
|
||||
appwrite/appwrite:1.8.0
|
||||
appwrite/appwrite:1.8.1
|
||||
```
|
||||
|
||||
运行后,可以在浏览器上访问 http://localhost 找到 Appwrite 控制台。在非 Linux 的本机主机上完成安装后,服务器可能需要几分钟才能启动。
|
||||
|
||||
@@ -75,7 +75,7 @@ docker run -it --rm \
|
||||
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||
--volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \
|
||||
--entrypoint="install" \
|
||||
appwrite/appwrite:1.8.0
|
||||
appwrite/appwrite:1.8.1
|
||||
```
|
||||
|
||||
### Windows
|
||||
@@ -87,7 +87,7 @@ docker run -it --rm ^
|
||||
--volume //var/run/docker.sock:/var/run/docker.sock ^
|
||||
--volume "%cd%"/appwrite:/usr/src/code/appwrite:rw ^
|
||||
--entrypoint="install" ^
|
||||
appwrite/appwrite:1.8.0
|
||||
appwrite/appwrite:1.8.1
|
||||
```
|
||||
|
||||
#### PowerShell
|
||||
@@ -97,7 +97,7 @@ docker run -it --rm `
|
||||
--volume /var/run/docker.sock:/var/run/docker.sock `
|
||||
--volume ${pwd}/appwrite:/usr/src/code/appwrite:rw `
|
||||
--entrypoint="install" `
|
||||
appwrite/appwrite:1.8.0
|
||||
appwrite/appwrite:1.8.1
|
||||
```
|
||||
|
||||
Once the Docker installation is complete, go to http://localhost to access the Appwrite console from your browser. Please note that on non-Linux native hosts, the server might take a few minutes to start after completing the installation.
|
||||
|
||||
@@ -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,
|
||||
@@ -330,7 +341,18 @@ $platformCollections = [
|
||||
'default' => null,
|
||||
'array' => false,
|
||||
'filters' => ['datetime'],
|
||||
]
|
||||
],
|
||||
[
|
||||
'$id' => ID::custom('labels'),
|
||||
'type' => Database::VAR_STRING,
|
||||
'format' => '',
|
||||
'size' => 128,
|
||||
'signed' => true,
|
||||
'required' => false,
|
||||
'default' => [],
|
||||
'array' => true,
|
||||
'filters' => [],
|
||||
],
|
||||
],
|
||||
'indexes' => [
|
||||
[
|
||||
@@ -1402,21 +1424,21 @@ $platformCollections = [
|
||||
'$id' => '_key_type',
|
||||
'type' => Database::INDEX_KEY,
|
||||
'attributes' => ['type'],
|
||||
'lengths' => [32],
|
||||
'lengths' => [],
|
||||
'orders' => [Database::ORDER_ASC],
|
||||
],
|
||||
[
|
||||
'$id' => '_key_trigger',
|
||||
'type' => Database::INDEX_KEY,
|
||||
'attributes' => ['trigger'],
|
||||
'lengths' => [32],
|
||||
'lengths' => [],
|
||||
'orders' => [Database::ORDER_ASC],
|
||||
],
|
||||
[
|
||||
'$id' => '_key_deploymentResourceType',
|
||||
'type' => Database::INDEX_KEY,
|
||||
'attributes' => ['deploymentResourceType'],
|
||||
'lengths' => [32],
|
||||
'lengths' => [],
|
||||
'orders' => [Database::ORDER_ASC],
|
||||
],
|
||||
[
|
||||
@@ -1458,23 +1480,23 @@ $platformCollections = [
|
||||
'$id' => ID::custom('_key_owner'),
|
||||
'type' => Database::INDEX_KEY,
|
||||
'attributes' => ['owner'],
|
||||
'lengths' => [16],
|
||||
'lengths' => [],
|
||||
'orders' => [Database::ORDER_ASC],
|
||||
],
|
||||
[
|
||||
'$id' => ID::custom('_key_region'),
|
||||
'type' => Database::INDEX_KEY,
|
||||
'attributes' => ['region'],
|
||||
'lengths' => [16],
|
||||
'orders' => [Database::ORDER_ASC],
|
||||
],
|
||||
[
|
||||
'$id' => ID::custom('_key_piid_riid_rt'),
|
||||
'$id' => ID::custom('_key_piid_diid_drt'),
|
||||
'type' => Database::INDEX_KEY,
|
||||
'attributes' => ['projectInternalId', 'deploymentInternalId', 'deploymentResourceType'],
|
||||
'lengths' => [],
|
||||
'orders' => [],
|
||||
],
|
||||
[
|
||||
'$id' => '_key_region_status_createdAt',
|
||||
'type' => Database::INDEX_KEY,
|
||||
'attributes' => ['region', 'status', '$createdAt'],
|
||||
'lengths' => [],
|
||||
'orders' => [],
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -959,6 +959,7 @@ App::post('/v1/account/sessions/email')
|
||||
))
|
||||
->label('abuse-limit', 10)
|
||||
->label('abuse-key', 'url:{url},email:{param-email}')
|
||||
->label('abuse-reset', [201])
|
||||
->param('email', '', new EmailValidator(), 'User email.')
|
||||
->param('password', '', new Password(), 'User password. Must be at least 8 chars.')
|
||||
->inject('request')
|
||||
@@ -1257,6 +1258,7 @@ App::post('/v1/account/sessions/token')
|
||||
))
|
||||
->label('abuse-limit', 10)
|
||||
->label('abuse-key', 'ip:{ip},userId:{param-userId}')
|
||||
->label('abuse-reset', [201])
|
||||
->param('userId', '', new CustomId(), 'User ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.')
|
||||
->param('secret', '', new Text(256), 'Secret of a token generated by login methods. For example, the `createMagicURLToken` or `createPhoneToken` methods.')
|
||||
->inject('request')
|
||||
@@ -2645,6 +2647,7 @@ App::put('/v1/account/sessions/magic-url')
|
||||
))
|
||||
->label('abuse-limit', 10)
|
||||
->label('abuse-key', 'ip:{ip},userId:{param-userId}')
|
||||
->label('abuse-reset', [201])
|
||||
->param('userId', '', new CustomId(), 'User ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.')
|
||||
->param('secret', '', new Text(256), 'Valid verification token.')
|
||||
->inject('request')
|
||||
|
||||
@@ -697,7 +697,7 @@ App::get('/v1/avatars/screenshots')
|
||||
}
|
||||
|
||||
$client = new Client();
|
||||
$client->setTimeout(30);
|
||||
$client->setTimeout(30 * 1000); // 30 seconds
|
||||
$client->addHeader('content-type', Client::CONTENT_TYPE_APPLICATION_JSON);
|
||||
|
||||
// Convert indexed array to empty array (should not happen due to Assoc validator)
|
||||
|
||||
@@ -204,6 +204,7 @@ App::post('/v1/projects')
|
||||
'accessedAt' => DateTime::now(),
|
||||
'search' => implode(' ', [$projectId, $name]),
|
||||
'database' => $dsn,
|
||||
'labels' => [],
|
||||
]));
|
||||
} catch (Duplicate) {
|
||||
throw new Exception(Exception::PROJECT_ALREADY_EXISTS);
|
||||
@@ -1500,6 +1501,7 @@ App::post('/v1/projects/:projectId/keys')
|
||||
Permission::update(Role::any()),
|
||||
Permission::delete(Role::any()),
|
||||
],
|
||||
// TODO: @hmacr Remove `projectInternalId` and `projectId` column writes before deleting the column.
|
||||
'projectInternalId' => $project->getSequence(),
|
||||
'projectId' => $project->getId(),
|
||||
'resourceInternalId' => $project->getSequence(),
|
||||
@@ -1552,13 +1554,8 @@ App::get('/v1/projects/:projectId/keys')
|
||||
}
|
||||
|
||||
$keys = $dbForPlatform->find('keys', [
|
||||
Query::or([
|
||||
Query::equal('projectInternalId', [$project->getSequence()]),
|
||||
Query::and([
|
||||
Query::equal('resourceType', ['projects']),
|
||||
Query::equal('resourceInternalId', [$project->getSequence()]),
|
||||
])
|
||||
]),
|
||||
Query::equal('resourceType', ['projects']),
|
||||
Query::equal('resourceInternalId', [$project->getSequence()]),
|
||||
Query::limit(5000),
|
||||
]);
|
||||
|
||||
@@ -1599,13 +1596,8 @@ App::get('/v1/projects/:projectId/keys/:keyId')
|
||||
|
||||
$key = $dbForPlatform->findOne('keys', [
|
||||
Query::equal('$id', [$keyId]),
|
||||
Query::or([
|
||||
Query::equal('projectInternalId', [$project->getSequence()]),
|
||||
Query::and([
|
||||
Query::equal('resourceType', ['projects']),
|
||||
Query::equal('resourceInternalId', [$project->getSequence()]),
|
||||
])
|
||||
])
|
||||
Query::equal('resourceType', ['projects']),
|
||||
Query::equal('resourceInternalId', [$project->getSequence()]),
|
||||
]);
|
||||
|
||||
if ($key->isEmpty()) {
|
||||
@@ -1649,13 +1641,8 @@ App::put('/v1/projects/:projectId/keys/:keyId')
|
||||
|
||||
$key = $dbForPlatform->findOne('keys', [
|
||||
Query::equal('$id', [$keyId]),
|
||||
Query::or([
|
||||
Query::equal('projectInternalId', [$project->getSequence()]),
|
||||
Query::and([
|
||||
Query::equal('resourceType', ['projects']),
|
||||
Query::equal('resourceInternalId', [$project->getSequence()]),
|
||||
])
|
||||
])
|
||||
Query::equal('resourceType', ['projects']),
|
||||
Query::equal('resourceInternalId', [$project->getSequence()]),
|
||||
]);
|
||||
|
||||
if ($key->isEmpty()) {
|
||||
@@ -1706,13 +1693,8 @@ App::delete('/v1/projects/:projectId/keys/:keyId')
|
||||
|
||||
$key = $dbForPlatform->findOne('keys', [
|
||||
Query::equal('$id', [$keyId]),
|
||||
Query::or([
|
||||
Query::equal('projectInternalId', [$project->getSequence()]),
|
||||
Query::and([
|
||||
Query::equal('resourceType', ['projects']),
|
||||
Query::equal('resourceInternalId', [$project->getSequence()]),
|
||||
])
|
||||
])
|
||||
Query::equal('resourceType', ['projects']),
|
||||
Query::equal('resourceInternalId', [$project->getSequence()]),
|
||||
]);
|
||||
|
||||
if ($key->isEmpty()) {
|
||||
|
||||
@@ -200,8 +200,12 @@ App::post('/v1/mock/api-key-unprefixed')
|
||||
Permission::update(Role::any()),
|
||||
Permission::delete(Role::any()),
|
||||
],
|
||||
// TODO: @hmacr Remove `projectInternalId` and `projectId` column writes before deleting the column.
|
||||
'projectInternalId' => $project->getSequence(),
|
||||
'projectId' => $project->getId(),
|
||||
'resourceInternalId' => $project->getSequence(),
|
||||
'resourceId' => $project->getId(),
|
||||
'resourceType' => 'projects',
|
||||
'name' => 'Outdated key',
|
||||
'scopes' => $scopes,
|
||||
'expire' => null,
|
||||
|
||||
@@ -814,7 +814,8 @@ App::shutdown()
|
||||
->inject('queueForWebhooks')
|
||||
->inject('queueForRealtime')
|
||||
->inject('dbForProject')
|
||||
->action(function (App $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, Audit $queueForAudits, StatsUsage $queueForStatsUsage, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Messaging $queueForMessaging, Func $queueForFunctions, Event $queueForWebhooks, Realtime $queueForRealtime, Database $dbForProject) use ($parseLabel) {
|
||||
->inject('timelimit')
|
||||
->action(function (App $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, Audit $queueForAudits, StatsUsage $queueForStatsUsage, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Messaging $queueForMessaging, Func $queueForFunctions, Event $queueForWebhooks, Realtime $queueForRealtime, Database $dbForProject, callable $timelimit) use ($parseLabel) {
|
||||
|
||||
$responsePayload = $response->getPayload();
|
||||
|
||||
@@ -848,6 +849,41 @@ App::shutdown()
|
||||
$route = $utopia->getRoute();
|
||||
$requestParams = $route->getParamsValues();
|
||||
|
||||
/**
|
||||
* Abuse labels
|
||||
*/
|
||||
$abuseEnabled = System::getEnv('_APP_OPTIONS_ABUSE', 'enabled') !== 'disabled';
|
||||
$abuseResetCode = $route->getLabel('abuse-reset', []);
|
||||
$abuseResetCode = \is_array($abuseResetCode) ? $abuseResetCode : [$abuseResetCode];
|
||||
|
||||
if ($abuseEnabled && \count($abuseResetCode) > 0 && \in_array($response->getStatusCode(), $abuseResetCode)) {
|
||||
$abuseKeyLabel = $route->getLabel('abuse-key', 'url:{url},ip:{ip}');
|
||||
$abuseKeyLabel = (!is_array($abuseKeyLabel)) ? [$abuseKeyLabel] : $abuseKeyLabel;
|
||||
|
||||
foreach ($abuseKeyLabel as $abuseKey) {
|
||||
$start = $request->getContentRangeStart();
|
||||
$end = $request->getContentRangeEnd();
|
||||
$timeLimit = $timelimit($abuseKey, $route->getLabel('abuse-limit', 0), $route->getLabel('abuse-time', 3600));
|
||||
$timeLimit
|
||||
->setParam('{projectId}', $project->getId())
|
||||
->setParam('{userId}', $user->getId())
|
||||
->setParam('{userAgent}', $request->getUserAgent(''))
|
||||
->setParam('{ip}', $request->getIP())
|
||||
->setParam('{url}', $request->getHostname() . $route->getPath())
|
||||
->setParam('{method}', $request->getMethod())
|
||||
->setParam('{chunkId}', (int)($start / ($end + 1 - $start)));
|
||||
|
||||
foreach ($request->getParams() as $key => $value) { // Set request params as potential abuse keys
|
||||
if (!empty($value)) {
|
||||
$timeLimit->setParam('{param-' . $key . '}', (\is_array($value)) ? \json_encode($value) : $value);
|
||||
}
|
||||
}
|
||||
|
||||
$abuse = new Abuse($timeLimit);
|
||||
$abuse->reset();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Audit labels
|
||||
*/
|
||||
|
||||
@@ -26,6 +26,7 @@ require_once __DIR__ . '/init/database/filters.php';
|
||||
require_once __DIR__ . '/init/database/formats.php';
|
||||
require_once __DIR__ . '/init/locales.php';
|
||||
require_once __DIR__ . '/init/registers.php';
|
||||
require_once __DIR__ . '/init/models.php';
|
||||
require_once __DIR__ . '/init/resources.php';
|
||||
|
||||
\stream_context_set_default([ // Set global user agent and http settings
|
||||
|
||||
@@ -44,7 +44,7 @@ const APP_RESOURCE_TOKEN_ACCESS = 24 * 60 * 60; // 24 hours
|
||||
const APP_FILE_ACCESS = 24 * 60 * 60; // 24 hours
|
||||
const APP_CACHE_UPDATE = 24 * 60 * 60; // 24 hours
|
||||
const APP_CACHE_BUSTER = 4321;
|
||||
const APP_VERSION_STABLE = '1.8.0';
|
||||
const APP_VERSION_STABLE = '1.8.1';
|
||||
const APP_DATABASE_ATTRIBUTE_EMAIL = 'email';
|
||||
const APP_DATABASE_ATTRIBUTE_ENUM = 'enum';
|
||||
const APP_DATABASE_ATTRIBUTE_IP = 'ip';
|
||||
@@ -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';
|
||||
|
||||
@@ -136,13 +136,8 @@ Database::addFilter(
|
||||
function (mixed $value, Document $document, Database $database) {
|
||||
return $database
|
||||
->find('keys', [
|
||||
Query::or([
|
||||
Query::equal('projectInternalId', [$document->getSequence()]),
|
||||
Query::and([
|
||||
Query::equal('resourceType', ['projects']),
|
||||
Query::equal('resourceInternalId', [$document->getSequence()]),
|
||||
])
|
||||
]),
|
||||
Query::equal('resourceType', ['projects']),
|
||||
Query::equal('resourceInternalId', [$document->getSequence()]),
|
||||
Query::limit(APP_LIMIT_SUBQUERY),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
<?php
|
||||
|
||||
use Appwrite\Utopia\Response;
|
||||
use Appwrite\Utopia\Response\Model\Account;
|
||||
use Appwrite\Utopia\Response\Model\AlgoArgon2;
|
||||
use Appwrite\Utopia\Response\Model\AlgoBcrypt;
|
||||
use Appwrite\Utopia\Response\Model\AlgoMd5;
|
||||
use Appwrite\Utopia\Response\Model\AlgoPhpass;
|
||||
use Appwrite\Utopia\Response\Model\AlgoScrypt;
|
||||
use Appwrite\Utopia\Response\Model\AlgoScryptModified;
|
||||
use Appwrite\Utopia\Response\Model\AlgoSha;
|
||||
use Appwrite\Utopia\Response\Model\Any;
|
||||
use Appwrite\Utopia\Response\Model\Attribute;
|
||||
use Appwrite\Utopia\Response\Model\AttributeBoolean;
|
||||
use Appwrite\Utopia\Response\Model\AttributeDatetime;
|
||||
use Appwrite\Utopia\Response\Model\AttributeEmail;
|
||||
use Appwrite\Utopia\Response\Model\AttributeEnum;
|
||||
use Appwrite\Utopia\Response\Model\AttributeFloat;
|
||||
use Appwrite\Utopia\Response\Model\AttributeInteger;
|
||||
use Appwrite\Utopia\Response\Model\AttributeIP;
|
||||
use Appwrite\Utopia\Response\Model\AttributeLine;
|
||||
use Appwrite\Utopia\Response\Model\AttributeList;
|
||||
use Appwrite\Utopia\Response\Model\AttributePoint;
|
||||
use Appwrite\Utopia\Response\Model\AttributePolygon;
|
||||
use Appwrite\Utopia\Response\Model\AttributeRelationship;
|
||||
use Appwrite\Utopia\Response\Model\AttributeString;
|
||||
use Appwrite\Utopia\Response\Model\AttributeURL;
|
||||
use Appwrite\Utopia\Response\Model\AuthProvider;
|
||||
use Appwrite\Utopia\Response\Model\BaseList;
|
||||
use Appwrite\Utopia\Response\Model\Branch;
|
||||
use Appwrite\Utopia\Response\Model\Bucket;
|
||||
use Appwrite\Utopia\Response\Model\Collection;
|
||||
use Appwrite\Utopia\Response\Model\Column;
|
||||
use Appwrite\Utopia\Response\Model\ColumnBoolean;
|
||||
use Appwrite\Utopia\Response\Model\ColumnDatetime;
|
||||
use Appwrite\Utopia\Response\Model\ColumnEmail;
|
||||
use Appwrite\Utopia\Response\Model\ColumnEnum;
|
||||
use Appwrite\Utopia\Response\Model\ColumnFloat;
|
||||
use Appwrite\Utopia\Response\Model\ColumnIndex;
|
||||
use Appwrite\Utopia\Response\Model\ColumnInteger;
|
||||
use Appwrite\Utopia\Response\Model\ColumnIP;
|
||||
use Appwrite\Utopia\Response\Model\ColumnLine;
|
||||
use Appwrite\Utopia\Response\Model\ColumnList;
|
||||
use Appwrite\Utopia\Response\Model\ColumnPoint;
|
||||
use Appwrite\Utopia\Response\Model\ColumnPolygon;
|
||||
use Appwrite\Utopia\Response\Model\ColumnRelationship;
|
||||
use Appwrite\Utopia\Response\Model\ColumnString;
|
||||
use Appwrite\Utopia\Response\Model\ColumnURL;
|
||||
use Appwrite\Utopia\Response\Model\ConsoleVariables;
|
||||
use Appwrite\Utopia\Response\Model\Continent;
|
||||
use Appwrite\Utopia\Response\Model\Country;
|
||||
use Appwrite\Utopia\Response\Model\Currency;
|
||||
use Appwrite\Utopia\Response\Model\Database;
|
||||
use Appwrite\Utopia\Response\Model\Deployment;
|
||||
use Appwrite\Utopia\Response\Model\DetectionFramework;
|
||||
use Appwrite\Utopia\Response\Model\DetectionRuntime;
|
||||
use Appwrite\Utopia\Response\Model\DetectionVariable;
|
||||
use Appwrite\Utopia\Response\Model\DevKey;
|
||||
use Appwrite\Utopia\Response\Model\Document as ModelDocument;
|
||||
use Appwrite\Utopia\Response\Model\Error;
|
||||
use Appwrite\Utopia\Response\Model\ErrorDev;
|
||||
use Appwrite\Utopia\Response\Model\Execution;
|
||||
use Appwrite\Utopia\Response\Model\File;
|
||||
use Appwrite\Utopia\Response\Model\Framework;
|
||||
use Appwrite\Utopia\Response\Model\FrameworkAdapter;
|
||||
use Appwrite\Utopia\Response\Model\Func;
|
||||
use Appwrite\Utopia\Response\Model\Headers;
|
||||
use Appwrite\Utopia\Response\Model\HealthAntivirus;
|
||||
use Appwrite\Utopia\Response\Model\HealthCertificate;
|
||||
use Appwrite\Utopia\Response\Model\HealthQueue;
|
||||
use Appwrite\Utopia\Response\Model\HealthStatus;
|
||||
use Appwrite\Utopia\Response\Model\HealthTime;
|
||||
use Appwrite\Utopia\Response\Model\HealthVersion;
|
||||
use Appwrite\Utopia\Response\Model\Identity;
|
||||
use Appwrite\Utopia\Response\Model\Index;
|
||||
use Appwrite\Utopia\Response\Model\Installation;
|
||||
use Appwrite\Utopia\Response\Model\JWT;
|
||||
use Appwrite\Utopia\Response\Model\Key;
|
||||
use Appwrite\Utopia\Response\Model\Language;
|
||||
use Appwrite\Utopia\Response\Model\Locale;
|
||||
use Appwrite\Utopia\Response\Model\LocaleCode;
|
||||
use Appwrite\Utopia\Response\Model\Log;
|
||||
use Appwrite\Utopia\Response\Model\Membership;
|
||||
use Appwrite\Utopia\Response\Model\Message;
|
||||
use Appwrite\Utopia\Response\Model\Metric;
|
||||
use Appwrite\Utopia\Response\Model\MetricBreakdown;
|
||||
use Appwrite\Utopia\Response\Model\MFAChallenge;
|
||||
use Appwrite\Utopia\Response\Model\MFAFactors;
|
||||
use Appwrite\Utopia\Response\Model\MFARecoveryCodes;
|
||||
use Appwrite\Utopia\Response\Model\MFAType;
|
||||
use Appwrite\Utopia\Response\Model\Migration;
|
||||
use Appwrite\Utopia\Response\Model\MigrationFirebaseProject;
|
||||
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;
|
||||
use Appwrite\Utopia\Response\Model\Project;
|
||||
use Appwrite\Utopia\Response\Model\Provider;
|
||||
use Appwrite\Utopia\Response\Model\ProviderRepository;
|
||||
use Appwrite\Utopia\Response\Model\ProviderRepositoryFramework;
|
||||
use Appwrite\Utopia\Response\Model\ProviderRepositoryRuntime;
|
||||
use Appwrite\Utopia\Response\Model\ResourceToken;
|
||||
use Appwrite\Utopia\Response\Model\Row;
|
||||
use Appwrite\Utopia\Response\Model\Rule;
|
||||
use Appwrite\Utopia\Response\Model\Runtime;
|
||||
use Appwrite\Utopia\Response\Model\Session;
|
||||
use Appwrite\Utopia\Response\Model\Site;
|
||||
use Appwrite\Utopia\Response\Model\Specification;
|
||||
use Appwrite\Utopia\Response\Model\Subscriber;
|
||||
use Appwrite\Utopia\Response\Model\Table;
|
||||
use Appwrite\Utopia\Response\Model\Target;
|
||||
use Appwrite\Utopia\Response\Model\Team;
|
||||
use Appwrite\Utopia\Response\Model\TemplateEmail;
|
||||
use Appwrite\Utopia\Response\Model\TemplateFramework;
|
||||
use Appwrite\Utopia\Response\Model\TemplateFunction;
|
||||
use Appwrite\Utopia\Response\Model\TemplateRuntime;
|
||||
use Appwrite\Utopia\Response\Model\TemplateSite;
|
||||
use Appwrite\Utopia\Response\Model\TemplateSMS;
|
||||
use Appwrite\Utopia\Response\Model\TemplateVariable;
|
||||
use Appwrite\Utopia\Response\Model\Token;
|
||||
use Appwrite\Utopia\Response\Model\Topic;
|
||||
use Appwrite\Utopia\Response\Model\Transaction;
|
||||
use Appwrite\Utopia\Response\Model\UsageBuckets;
|
||||
use Appwrite\Utopia\Response\Model\UsageCollection;
|
||||
use Appwrite\Utopia\Response\Model\UsageDatabase;
|
||||
use Appwrite\Utopia\Response\Model\UsageDatabases;
|
||||
use Appwrite\Utopia\Response\Model\UsageFunction;
|
||||
use Appwrite\Utopia\Response\Model\UsageFunctions;
|
||||
use Appwrite\Utopia\Response\Model\UsageProject;
|
||||
use Appwrite\Utopia\Response\Model\UsageSite;
|
||||
use Appwrite\Utopia\Response\Model\UsageSites;
|
||||
use Appwrite\Utopia\Response\Model\UsageStorage;
|
||||
use Appwrite\Utopia\Response\Model\UsageTable;
|
||||
use Appwrite\Utopia\Response\Model\UsageUsers;
|
||||
use Appwrite\Utopia\Response\Model\User;
|
||||
use Appwrite\Utopia\Response\Model\Variable;
|
||||
use Appwrite\Utopia\Response\Model\VcsContent;
|
||||
use Appwrite\Utopia\Response\Model\Webhook;
|
||||
|
||||
// General
|
||||
Response::setModel(new None());
|
||||
Response::setModel(new Any());
|
||||
Response::setModel(new Error());
|
||||
Response::setModel(new ErrorDev());
|
||||
|
||||
// Lists
|
||||
Response::setModel(new BaseList('Rows List', Response::MODEL_ROW_LIST, 'rows', Response::MODEL_ROW));
|
||||
Response::setModel(new BaseList('Documents List', Response::MODEL_DOCUMENT_LIST, 'documents', Response::MODEL_DOCUMENT));
|
||||
Response::setModel(new BaseList('Tables List', Response::MODEL_TABLE_LIST, 'tables', Response::MODEL_TABLE));
|
||||
Response::setModel(new BaseList('Collections List', Response::MODEL_COLLECTION_LIST, 'collections', Response::MODEL_COLLECTION));
|
||||
Response::setModel(new BaseList('Databases List', Response::MODEL_DATABASE_LIST, 'databases', Response::MODEL_DATABASE));
|
||||
Response::setModel(new BaseList('Indexes List', Response::MODEL_INDEX_LIST, 'indexes', Response::MODEL_INDEX));
|
||||
Response::setModel(new BaseList('Column Indexes List', Response::MODEL_COLUMN_INDEX_LIST, 'indexes', Response::MODEL_COLUMN_INDEX));
|
||||
Response::setModel(new BaseList('Users List', Response::MODEL_USER_LIST, 'users', Response::MODEL_USER));
|
||||
Response::setModel(new BaseList('Sessions List', Response::MODEL_SESSION_LIST, 'sessions', Response::MODEL_SESSION));
|
||||
Response::setModel(new BaseList('Identities List', Response::MODEL_IDENTITY_LIST, 'identities', Response::MODEL_IDENTITY));
|
||||
Response::setModel(new BaseList('Logs List', Response::MODEL_LOG_LIST, 'logs', Response::MODEL_LOG));
|
||||
Response::setModel(new BaseList('Files List', Response::MODEL_FILE_LIST, 'files', Response::MODEL_FILE));
|
||||
Response::setModel(new BaseList('Buckets List', Response::MODEL_BUCKET_LIST, 'buckets', Response::MODEL_BUCKET));
|
||||
Response::setModel(new BaseList('Resource Tokens List', Response::MODEL_RESOURCE_TOKEN_LIST, 'tokens', Response::MODEL_RESOURCE_TOKEN));
|
||||
Response::setModel(new BaseList('Teams List', Response::MODEL_TEAM_LIST, 'teams', Response::MODEL_TEAM));
|
||||
Response::setModel(new BaseList('Memberships List', Response::MODEL_MEMBERSHIP_LIST, 'memberships', Response::MODEL_MEMBERSHIP));
|
||||
Response::setModel(new BaseList('Sites List', Response::MODEL_SITE_LIST, 'sites', Response::MODEL_SITE));
|
||||
Response::setModel(new BaseList('Site Templates List', Response::MODEL_TEMPLATE_SITE_LIST, 'templates', Response::MODEL_TEMPLATE_SITE));
|
||||
Response::setModel(new BaseList('Functions List', Response::MODEL_FUNCTION_LIST, 'functions', Response::MODEL_FUNCTION));
|
||||
Response::setModel(new BaseList('Function Templates List', Response::MODEL_TEMPLATE_FUNCTION_LIST, 'templates', Response::MODEL_TEMPLATE_FUNCTION));
|
||||
Response::setModel(new BaseList('Installations List', Response::MODEL_INSTALLATION_LIST, 'installations', Response::MODEL_INSTALLATION));
|
||||
Response::setModel(new BaseList('Framework Provider Repositories List', Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK_LIST, 'frameworkProviderRepositories', Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK));
|
||||
Response::setModel(new BaseList('Runtime Provider Repositories List', Response::MODEL_PROVIDER_REPOSITORY_RUNTIME_LIST, 'runtimeProviderRepositories', Response::MODEL_PROVIDER_REPOSITORY_RUNTIME));
|
||||
Response::setModel(new BaseList('Branches List', Response::MODEL_BRANCH_LIST, 'branches', Response::MODEL_BRANCH));
|
||||
Response::setModel(new BaseList('Frameworks List', Response::MODEL_FRAMEWORK_LIST, 'frameworks', Response::MODEL_FRAMEWORK));
|
||||
Response::setModel(new BaseList('Runtimes List', Response::MODEL_RUNTIME_LIST, 'runtimes', Response::MODEL_RUNTIME));
|
||||
Response::setModel(new BaseList('Deployments List', Response::MODEL_DEPLOYMENT_LIST, 'deployments', Response::MODEL_DEPLOYMENT));
|
||||
Response::setModel(new BaseList('Executions List', Response::MODEL_EXECUTION_LIST, 'executions', Response::MODEL_EXECUTION));
|
||||
Response::setModel(new BaseList('Projects List', Response::MODEL_PROJECT_LIST, 'projects', Response::MODEL_PROJECT, true, false));
|
||||
Response::setModel(new BaseList('Webhooks List', Response::MODEL_WEBHOOK_LIST, 'webhooks', Response::MODEL_WEBHOOK, true, false));
|
||||
Response::setModel(new BaseList('API Keys List', Response::MODEL_KEY_LIST, 'keys', Response::MODEL_KEY, true, false));
|
||||
Response::setModel(new BaseList('Dev Keys List', Response::MODEL_DEV_KEY_LIST, 'devKeys', Response::MODEL_DEV_KEY, true, false));
|
||||
Response::setModel(new BaseList('Auth Providers List', Response::MODEL_AUTH_PROVIDER_LIST, 'platforms', Response::MODEL_AUTH_PROVIDER, true, false));
|
||||
Response::setModel(new BaseList('Platforms List', Response::MODEL_PLATFORM_LIST, 'platforms', Response::MODEL_PLATFORM, true, false));
|
||||
Response::setModel(new BaseList('Countries List', Response::MODEL_COUNTRY_LIST, 'countries', Response::MODEL_COUNTRY));
|
||||
Response::setModel(new BaseList('Continents List', Response::MODEL_CONTINENT_LIST, 'continents', Response::MODEL_CONTINENT));
|
||||
Response::setModel(new BaseList('Languages List', Response::MODEL_LANGUAGE_LIST, 'languages', Response::MODEL_LANGUAGE));
|
||||
Response::setModel(new BaseList('Currencies List', Response::MODEL_CURRENCY_LIST, 'currencies', Response::MODEL_CURRENCY));
|
||||
Response::setModel(new BaseList('Phones List', Response::MODEL_PHONE_LIST, 'phones', Response::MODEL_PHONE));
|
||||
Response::setModel(new BaseList('Metric List', Response::MODEL_METRIC_LIST, 'metrics', Response::MODEL_METRIC, true, false));
|
||||
Response::setModel(new BaseList('Variables List', Response::MODEL_VARIABLE_LIST, 'variables', Response::MODEL_VARIABLE));
|
||||
Response::setModel(new BaseList('Status List', Response::MODEL_HEALTH_STATUS_LIST, 'statuses', Response::MODEL_HEALTH_STATUS));
|
||||
Response::setModel(new BaseList('Rule List', Response::MODEL_PROXY_RULE_LIST, 'rules', Response::MODEL_PROXY_RULE));
|
||||
Response::setModel(new BaseList('Locale codes list', Response::MODEL_LOCALE_CODE_LIST, 'localeCodes', Response::MODEL_LOCALE_CODE));
|
||||
Response::setModel(new BaseList('Provider list', Response::MODEL_PROVIDER_LIST, 'providers', Response::MODEL_PROVIDER));
|
||||
Response::setModel(new BaseList('Message list', Response::MODEL_MESSAGE_LIST, 'messages', Response::MODEL_MESSAGE));
|
||||
Response::setModel(new BaseList('Topic list', Response::MODEL_TOPIC_LIST, 'topics', Response::MODEL_TOPIC));
|
||||
Response::setModel(new BaseList('Subscriber list', Response::MODEL_SUBSCRIBER_LIST, 'subscribers', Response::MODEL_SUBSCRIBER));
|
||||
Response::setModel(new BaseList('Target list', Response::MODEL_TARGET_LIST, 'targets', Response::MODEL_TARGET));
|
||||
Response::setModel(new BaseList('Transaction List', Response::MODEL_TRANSACTION_LIST, 'transactions', Response::MODEL_TRANSACTION));
|
||||
Response::setModel(new BaseList('Migrations List', Response::MODEL_MIGRATION_LIST, 'migrations', Response::MODEL_MIGRATION));
|
||||
Response::setModel(new BaseList('Migrations Firebase Projects List', Response::MODEL_MIGRATION_FIREBASE_PROJECT_LIST, 'projects', Response::MODEL_MIGRATION_FIREBASE_PROJECT));
|
||||
Response::setModel(new BaseList('Specifications List', Response::MODEL_SPECIFICATION_LIST, 'specifications', Response::MODEL_SPECIFICATION));
|
||||
Response::setModel(new BaseList('VCS Content List', Response::MODEL_VCS_CONTENT_LIST, 'contents', Response::MODEL_VCS_CONTENT));
|
||||
|
||||
// Entities
|
||||
Response::setModel(new Database());
|
||||
|
||||
// Collection API Models
|
||||
Response::setModel(new Collection());
|
||||
Response::setModel(new Attribute());
|
||||
Response::setModel(new AttributeList());
|
||||
Response::setModel(new AttributeString());
|
||||
Response::setModel(new AttributeInteger());
|
||||
Response::setModel(new AttributeFloat());
|
||||
Response::setModel(new AttributeBoolean());
|
||||
Response::setModel(new AttributeEmail());
|
||||
Response::setModel(new AttributeEnum());
|
||||
Response::setModel(new AttributeIP());
|
||||
Response::setModel(new AttributeURL());
|
||||
Response::setModel(new AttributeDatetime());
|
||||
Response::setModel(new AttributeRelationship());
|
||||
Response::setModel(new AttributePoint());
|
||||
Response::setModel(new AttributeLine());
|
||||
Response::setModel(new AttributePolygon());
|
||||
|
||||
// Table API Models
|
||||
Response::setModel(new Table());
|
||||
Response::setModel(new Column());
|
||||
Response::setModel(new ColumnList());
|
||||
Response::setModel(new ColumnString());
|
||||
Response::setModel(new ColumnInteger());
|
||||
Response::setModel(new ColumnFloat());
|
||||
Response::setModel(new ColumnBoolean());
|
||||
Response::setModel(new ColumnEmail());
|
||||
Response::setModel(new ColumnEnum());
|
||||
Response::setModel(new ColumnIP());
|
||||
Response::setModel(new ColumnURL());
|
||||
Response::setModel(new ColumnDatetime());
|
||||
Response::setModel(new ColumnRelationship());
|
||||
Response::setModel(new ColumnPoint());
|
||||
Response::setModel(new ColumnLine());
|
||||
Response::setModel(new ColumnPolygon());
|
||||
Response::setModel(new Index());
|
||||
Response::setModel(new ColumnIndex());
|
||||
Response::setModel(new Row());
|
||||
Response::setModel(new ModelDocument());
|
||||
Response::setModel(new Log());
|
||||
Response::setModel(new User());
|
||||
Response::setModel(new AlgoMd5());
|
||||
Response::setModel(new AlgoSha());
|
||||
Response::setModel(new AlgoPhpass());
|
||||
Response::setModel(new AlgoBcrypt());
|
||||
Response::setModel(new AlgoScrypt());
|
||||
Response::setModel(new AlgoScryptModified());
|
||||
Response::setModel(new AlgoArgon2());
|
||||
Response::setModel(new Account());
|
||||
Response::setModel(new Preferences());
|
||||
Response::setModel(new Session());
|
||||
Response::setModel(new Identity());
|
||||
Response::setModel(new Token());
|
||||
Response::setModel(new JWT());
|
||||
Response::setModel(new Locale());
|
||||
Response::setModel(new LocaleCode());
|
||||
Response::setModel(new File());
|
||||
Response::setModel(new Bucket());
|
||||
Response::setModel(new ResourceToken());
|
||||
Response::setModel(new Team());
|
||||
Response::setModel(new Membership());
|
||||
Response::setModel(new Site());
|
||||
Response::setModel(new TemplateSite());
|
||||
Response::setModel(new TemplateFramework());
|
||||
Response::setModel(new Func());
|
||||
Response::setModel(new TemplateFunction());
|
||||
Response::setModel(new TemplateRuntime());
|
||||
Response::setModel(new TemplateVariable());
|
||||
Response::setModel(new Installation());
|
||||
Response::setModel(new ProviderRepository());
|
||||
Response::setModel(new ProviderRepositoryFramework());
|
||||
Response::setModel(new ProviderRepositoryRuntime());
|
||||
Response::setModel(new DetectionFramework());
|
||||
Response::setModel(new DetectionRuntime());
|
||||
Response::setModel(new DetectionVariable());
|
||||
Response::setModel(new VcsContent());
|
||||
Response::setModel(new Branch());
|
||||
Response::setModel(new Runtime());
|
||||
Response::setModel(new Framework());
|
||||
Response::setModel(new FrameworkAdapter());
|
||||
Response::setModel(new Deployment());
|
||||
Response::setModel(new Execution());
|
||||
Response::setModel(new Project());
|
||||
Response::setModel(new Webhook());
|
||||
Response::setModel(new Key());
|
||||
Response::setModel(new DevKey());
|
||||
Response::setModel(new MockNumber());
|
||||
Response::setModel(new AuthProvider());
|
||||
Response::setModel(new Platform());
|
||||
Response::setModel(new Variable());
|
||||
Response::setModel(new Country());
|
||||
Response::setModel(new Continent());
|
||||
Response::setModel(new Language());
|
||||
Response::setModel(new Currency());
|
||||
Response::setModel(new Phone());
|
||||
Response::setModel(new HealthAntivirus());
|
||||
Response::setModel(new HealthQueue());
|
||||
Response::setModel(new HealthStatus());
|
||||
Response::setModel(new HealthCertificate());
|
||||
Response::setModel(new HealthTime());
|
||||
Response::setModel(new HealthVersion());
|
||||
Response::setModel(new Metric());
|
||||
Response::setModel(new MetricBreakdown());
|
||||
Response::setModel(new UsageDatabases());
|
||||
Response::setModel(new UsageDatabase());
|
||||
Response::setModel(new UsageTable());
|
||||
Response::setModel(new UsageCollection());
|
||||
Response::setModel(new UsageUsers());
|
||||
Response::setModel(new UsageStorage());
|
||||
Response::setModel(new UsageBuckets());
|
||||
Response::setModel(new UsageFunctions());
|
||||
Response::setModel(new UsageFunction());
|
||||
Response::setModel(new UsageSites());
|
||||
Response::setModel(new UsageSite());
|
||||
Response::setModel(new UsageProject());
|
||||
Response::setModel(new Headers());
|
||||
Response::setModel(new Specification());
|
||||
Response::setModel(new Rule());
|
||||
Response::setModel(new TemplateSMS());
|
||||
Response::setModel(new TemplateEmail());
|
||||
Response::setModel(new ConsoleVariables());
|
||||
Response::setModel(new MFAChallenge());
|
||||
Response::setModel(new MFARecoveryCodes());
|
||||
Response::setModel(new MFAType());
|
||||
Response::setModel(new MFAFactors());
|
||||
Response::setModel(new Provider());
|
||||
Response::setModel(new Message());
|
||||
Response::setModel(new Topic());
|
||||
Response::setModel(new Transaction());
|
||||
Response::setModel(new Subscriber());
|
||||
Response::setModel(new Target());
|
||||
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());
|
||||
@@ -360,6 +360,8 @@ $register->set('smtp', function () {
|
||||
$mail->SMTPSecure = System::getEnv('_APP_SMTP_SECURE', '');
|
||||
$mail->SMTPAutoTLS = false;
|
||||
$mail->CharSet = 'UTF-8';
|
||||
$mail->Timeout = 10; /* Connection timeout */
|
||||
$mail->getSMTPInstance()->Timelimit = 30; /* Timeout for each individual SMTP command (e.g. HELO, EHLO, etc.) */
|
||||
|
||||
$from = \urldecode(System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'));
|
||||
$email = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM);
|
||||
@@ -386,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'));
|
||||
|
||||
@@ -180,7 +180,7 @@ $enableAssistant = $this->getParam('enableAssistant', false);
|
||||
appwrite-console:
|
||||
<<: *x-logging
|
||||
container_name: appwrite-console
|
||||
image: <?php echo $organization; ?>/console:7.4.7
|
||||
image: <?php echo $organization; ?>/console:7.5.7
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- appwrite
|
||||
|
||||
@@ -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 $@
|
||||
+4
-4
@@ -45,9 +45,9 @@
|
||||
"ext-sockets": "*",
|
||||
"appwrite/php-runtimes": "0.19.*",
|
||||
"appwrite/php-clamav": "2.0.*",
|
||||
"utopia-php/abuse": "1.*",
|
||||
"utopia-php/abuse": "1.*.*",
|
||||
"utopia-php/analytics": "0.10.*",
|
||||
"utopia-php/audit": "2.0.2-rc1",
|
||||
"utopia-php/audit": "2.0.2-rc3",
|
||||
"utopia-php/auth": "0.5.*",
|
||||
"utopia-php/cache": "0.13.*",
|
||||
"utopia-php/cli": "0.15.*",
|
||||
@@ -59,12 +59,12 @@
|
||||
"utopia-php/dns": "1.4.*",
|
||||
"utopia-php/dsn": "0.2.1",
|
||||
"utopia-php/framework": "0.33.*",
|
||||
"utopia-php/fetch": "0.4.*",
|
||||
"utopia-php/fetch": "0.5.*",
|
||||
"utopia-php/image": "0.8.*",
|
||||
"utopia-php/locale": "0.8.*",
|
||||
"utopia-php/logger": "0.6.*",
|
||||
"utopia-php/messaging": "0.20.*",
|
||||
"utopia-php/migration": "1.3.*",
|
||||
"utopia-php/migration": "1.*.*",
|
||||
"utopia-php/orchestration": "0.9.*",
|
||||
"utopia-php/platform": "0.7.*",
|
||||
"utopia-php/pools": "0.8.*",
|
||||
|
||||
Generated
+8148
-8960
File diff suppressed because it is too large
Load Diff
+60
-1
@@ -230,7 +230,7 @@ services:
|
||||
appwrite-console:
|
||||
<<: *x-logging
|
||||
container_name: appwrite-console
|
||||
image: appwrite/console:7.4.11
|
||||
image: appwrite/console:7.5.7
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- appwrite
|
||||
@@ -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;
|
||||
|
||||
@@ -90,6 +90,7 @@ abstract class Migration
|
||||
'1.7.3' => 'V22',
|
||||
'1.7.4' => 'V22',
|
||||
'1.8.0' => 'V23',
|
||||
'1.8.1' => 'V23',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -139,7 +139,7 @@ class V23 extends Migration
|
||||
} catch (\Throwable $th) {
|
||||
Console::warning("Failed to migration error attribute size in collection {$id}: {$th->getMessage()}");
|
||||
}
|
||||
|
||||
break;
|
||||
case 'buckets':
|
||||
try {
|
||||
$this->createAttributeFromCollection($this->dbForProject, $id, 'transformations');
|
||||
@@ -148,6 +148,21 @@ class V23 extends Migration
|
||||
}
|
||||
$this->dbForProject->purgeCachedCollection($id);
|
||||
break;
|
||||
case 'users':
|
||||
$attributes = [
|
||||
'emailCanonical',
|
||||
'emailIsFree',
|
||||
'emailIsDisposable',
|
||||
'emailIsCorporate',
|
||||
'emailIsCanonical',
|
||||
];
|
||||
try {
|
||||
$this->createAttributesFromCollection($this->dbForProject, $id, $attributes);
|
||||
} catch (\Throwable $th) {
|
||||
Console::warning('Failed to create attributes "' . \implode(', ', $attributes) . "\" in collection {$id}: {$th->getMessage()}");
|
||||
}
|
||||
$this->dbForProject->purgeCachedCollection($id);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Payments\Provider;
|
||||
|
||||
use Utopia\Database\Document;
|
||||
|
||||
interface Adapter
|
||||
{
|
||||
public function getIdentifier(): string;
|
||||
|
||||
public function configure(array $config, Document $project): ProviderState;
|
||||
|
||||
public function ensurePlan(array $planData, ProviderState $state): ProviderPlanRef;
|
||||
|
||||
public function updatePlan(array $planData, ProviderPlanRef $reference, ProviderState $state): ProviderPlanRef;
|
||||
|
||||
public function deletePlan(ProviderPlanRef $reference, ProviderState $state): void;
|
||||
|
||||
public function ensureFeature(array $featureData, ProviderPlanRef $plan, ProviderState $state): ProviderFeatureRef;
|
||||
|
||||
public function deleteFeature(ProviderFeatureRef $feature, ProviderPlanRef $plan, ProviderState $state): void;
|
||||
|
||||
public function updateSubscription(ProviderSubscriptionRef $subscription, array $changes, ProviderState $state): ProviderSubscriptionRef;
|
||||
|
||||
public function cancelSubscription(ProviderSubscriptionRef $subscription, bool $atPeriodEnd, ProviderState $state): ProviderSubscriptionRef;
|
||||
|
||||
public function resumeSubscription(ProviderSubscriptionRef $subscription, ProviderState $state): ProviderSubscriptionRef;
|
||||
|
||||
public function createCheckoutSession(Document $actor, array $planContext, ProviderState $state, array $options = []): ProviderCheckoutSession;
|
||||
|
||||
public function createPortalSession(Document $actor, ProviderState $state, array $options = []): ProviderPortalSession;
|
||||
|
||||
/**
|
||||
* @return ProviderInvoice[]
|
||||
*/
|
||||
public function listInvoices(ProviderSubscriptionRef $subscription, ProviderState $state, int $limit = 25, int $offset = 0): array;
|
||||
|
||||
public function previewProration(ProviderSubscriptionRef $subscription, string $newPriceId, ProviderState $state): ProviderProrationPreview;
|
||||
|
||||
public function reportUsage(ProviderSubscriptionRef $subscription, string $featureId, int $quantity, \DateTimeInterface $timestamp, ProviderState $state): void;
|
||||
|
||||
public function syncUsage(ProviderSubscriptionRef $subscription, ProviderState $state): ProviderUsageReport;
|
||||
|
||||
public function handleWebhook(array $payload, ProviderState $state): ProviderWebhookResult;
|
||||
|
||||
public function testConnection(array $config): ProviderTestResult;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Payments\Provider;
|
||||
|
||||
class ProviderCheckoutSession
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $url,
|
||||
public readonly array $metadata = [],
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Payments\Provider;
|
||||
|
||||
class ProviderFeatureRef
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $externalFeatureId,
|
||||
public readonly array $metadata = [],
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Payments\Provider;
|
||||
|
||||
class ProviderInvoice
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $invoiceId,
|
||||
public readonly string $subscriptionId,
|
||||
public readonly int $amount,
|
||||
public readonly string $currency,
|
||||
public readonly string $status,
|
||||
public readonly ?int $createdAt = null,
|
||||
public readonly ?int $paidAt = null,
|
||||
public readonly ?string $invoiceUrl = null,
|
||||
public readonly array $metadata = [],
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Payments\Provider;
|
||||
|
||||
class ProviderPlanRef
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $externalPlanId,
|
||||
public readonly array $metadata = [],
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Payments\Provider;
|
||||
|
||||
class ProviderPortalSession
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $url,
|
||||
public readonly array $metadata = [],
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Payments\Provider;
|
||||
|
||||
class ProviderProrationPreview
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $amountDue,
|
||||
public readonly int $prorationAmount,
|
||||
public readonly string $currency,
|
||||
public readonly ?int $nextBillingDate = null,
|
||||
public readonly array $metadata = [],
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Payments\Provider;
|
||||
|
||||
class ProviderState
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $providerId,
|
||||
public readonly array $config = [],
|
||||
public readonly array $metadata = [],
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Payments\Provider;
|
||||
|
||||
class ProviderSubscriptionRef
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $externalSubscriptionId,
|
||||
public readonly array $metadata = [],
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Payments\Provider;
|
||||
|
||||
class ProviderTestResult
|
||||
{
|
||||
public function __construct(
|
||||
public readonly bool $success,
|
||||
public readonly string $message = '',
|
||||
public readonly array $metadata = [],
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Payments\Provider;
|
||||
|
||||
class ProviderUsageReport
|
||||
{
|
||||
public function __construct(
|
||||
public readonly array $totals = [],
|
||||
public readonly array $details = [],
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Payments\Provider;
|
||||
|
||||
class ProviderWebhookResult
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $status,
|
||||
public readonly array $changes = [],
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Payments\Provider;
|
||||
|
||||
use Utopia\Database\Document;
|
||||
|
||||
class Registry
|
||||
{
|
||||
/**
|
||||
* @var array<string, class-string<Adapter>>
|
||||
*/
|
||||
private array $map = [];
|
||||
|
||||
/**
|
||||
* @var array<string, Adapter>
|
||||
*/
|
||||
private array $cache = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// Map provider identifiers to adapter classes
|
||||
// e.g., $this->map['stripe'] = \Appwrite\Payments\Provider\StripeAdapter::class;
|
||||
}
|
||||
|
||||
public function register(string $identifier, string $adapterClass): void
|
||||
{
|
||||
$this->map[$identifier] = $adapterClass;
|
||||
}
|
||||
|
||||
public function get(string $identifier, array $config, Document $project, \Utopia\Database\Database $dbForPlatform, \Utopia\Database\Database $dbForProject): Adapter
|
||||
{
|
||||
$cacheKey = $identifier . ':' . $project->getId();
|
||||
if (isset($this->cache[$cacheKey])) {
|
||||
return $this->cache[$cacheKey];
|
||||
}
|
||||
if (!isset($this->map[$identifier])) {
|
||||
throw new \RuntimeException('Unknown payments provider: ' . $identifier);
|
||||
}
|
||||
$class = $this->map[$identifier];
|
||||
/** @var Adapter $adapter */
|
||||
$adapter = new $class($config, $project, $dbForProject, $dbForPlatform);
|
||||
$this->cache[$cacheKey] = $adapter;
|
||||
return $adapter;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,8 +2,6 @@
|
||||
|
||||
namespace Appwrite\Platform;
|
||||
|
||||
use Appwrite\Utopia\Request;
|
||||
use Appwrite\Utopia\Response;
|
||||
use Swoole\Coroutine as Co;
|
||||
use Utopia\CLI\Console;
|
||||
use Utopia\Database\Database;
|
||||
@@ -161,50 +159,4 @@ class Action extends UtopiaAction
|
||||
Console::info("[" . DateTime::now() . "] " . $method . ' ' . $type . ' ' . $project->getSequence() . ' ' . $project->getId() . ' ' . $collectionId . ' ' . $log);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Helper to apply (request) select queries to response model.
|
||||
*
|
||||
* This prevents default values of rules to be presnet for not-selected attributes
|
||||
*
|
||||
* @param Request $request
|
||||
* @param Document $document
|
||||
* @return void
|
||||
*/
|
||||
public function applySelectQueries(Request $request, Response $response, string $model): void
|
||||
{
|
||||
$queries = $request->getParam('queries', []);
|
||||
|
||||
$queries = Query::parseQueries($queries);
|
||||
$selectQueries = Query::groupByType($queries)['selections'] ?? [];
|
||||
|
||||
// No select queries means no filtering out
|
||||
if (empty($selectQueries)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$attributes = [];
|
||||
foreach ($selectQueries as $query) {
|
||||
foreach ($query->getValues() as $attribute) {
|
||||
$attributes[] = $attribute;
|
||||
}
|
||||
}
|
||||
|
||||
// found a wildcard, return!
|
||||
if (\in_array('*', $attributes)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$responseModel = $response->getModel($model);
|
||||
foreach ($responseModel->getRules() as $ruleName => $rule) {
|
||||
if (\str_starts_with($ruleName, '$')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!\in_array($ruleName, $attributes)) {
|
||||
$responseModel->removeRule($ruleName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -177,6 +177,7 @@ class Decrement extends Action
|
||||
value: $value,
|
||||
min: $min
|
||||
);
|
||||
$document->setAttribute('$databaseId', $database->getId());
|
||||
$document->setAttribute('$' . $this->getCollectionsEventsContext() . 'Id', $collectionId);
|
||||
} catch (ConflictException) {
|
||||
throw new Exception($this->getConflictException());
|
||||
|
||||
+1
@@ -177,6 +177,7 @@ class Increment extends Action
|
||||
value: $value,
|
||||
max: $max
|
||||
);
|
||||
$document->setAttribute('$databaseId', $database->getId());
|
||||
$document->setAttribute('$' . $this->getCollectionsEventsContext() . 'Id', $collectionId);
|
||||
} catch (ConflictException) {
|
||||
throw new Exception($this->getConflictException());
|
||||
|
||||
@@ -10,6 +10,7 @@ use Appwrite\SDK\Response as SDKResponse;
|
||||
use Appwrite\Utopia\Database\Validator\Queries\Deployments;
|
||||
use Appwrite\Utopia\Request;
|
||||
use Appwrite\Utopia\Response;
|
||||
use Appwrite\Utopia\Response\Filters\ListSelection;
|
||||
use Utopia\Database\Database;
|
||||
use Utopia\Database\Document;
|
||||
use Utopia\Database\Exception\Order as OrderException;
|
||||
@@ -119,7 +120,9 @@ class XList extends Base
|
||||
$cursor->setValue($cursorDocument);
|
||||
}
|
||||
|
||||
$filterQueries = Query::groupByType($queries)['filters'];
|
||||
$grouped = Query::groupByType($queries);
|
||||
$filterQueries = $grouped['filters'];
|
||||
$selectQueries = $grouped['selections'] ?? [];
|
||||
|
||||
try {
|
||||
$results = $dbForProject->find('deployments', $queries);
|
||||
@@ -128,7 +131,8 @@ class XList extends Base
|
||||
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.");
|
||||
}
|
||||
|
||||
$this->applySelectQueries($request, $response, Response::MODEL_DEPLOYMENT);
|
||||
$response->addFilter(new ListSelection($selectQueries, 'deployments'));
|
||||
|
||||
$response->dynamic(new Document([
|
||||
'deployments' => $results,
|
||||
'total' => $total,
|
||||
|
||||
@@ -939,7 +939,7 @@ class Builds extends Action
|
||||
}
|
||||
|
||||
$client = new FetchClient();
|
||||
$client->setTimeout(\intval($resource->getAttribute('timeout', '15')));
|
||||
$client->setTimeout(\intval($resource->getAttribute('timeout', '15')) * 1000);
|
||||
$client->addHeader('content-type', FetchClient::CONTENT_TYPE_APPLICATION_JSON);
|
||||
|
||||
$bucket = Authorization::skip(fn () => $dbForPlatform->getDocument('buckets', 'screenshots'));
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user