Compare commits

..
Author SHA1 Message Date
Atharva Deosthale f8f83285c8 fix spec generation 2026-01-07 00:46:44 +05:30
Atharva Deosthale 0a3b879140 fix sdk method names and use new auth methods after auth refactor 2026-01-07 00:36:25 +05:30
Atharva Deosthale c1c53789ae specs 2026-01-07 00:18:30 +05:30
Atharva Deosthale 131bf76848 merge conflicts 2026-01-06 23:59:32 +05:30
Atharva Deosthale e352eb8ced specs 2026-01-06 23:39:49 +05:30
Atharva Deosthale c187efadb4 improve the code a bit 2026-01-06 23:37:15 +05:30
HemachandarandGitHub fc5ea06821 Bump utopia-php/fetch version (#10997)
* Bump utopia-php/fetch version

* fix timeouts
2026-01-06 21:30:25 +05:30
Matej BačoandGitHub aa135d5cd4 Merge pull request #11085 from appwrite/feat-success-abuse-reset
Feat: Abuse reset on success
2026-01-06 15:56:36 +01:00
Matej Bačo 6d85d1567e Update composer.lock 2026-01-06 15:33:21 +01:00
Atharva Deosthale 9786b4f6cd progress, very close to be v0 2026-01-06 19:51:35 +05:30
Matej Bačo 7567639996 grammar fix 2026-01-06 15:18:44 +01:00
Matej Bačo 057bff2140 Merge branch '1.8.x' into feat-success-abuse-reset 2026-01-06 15:18:25 +01:00
Matej Bačo d7bb234072 PR reviews 2026-01-06 15:18:22 +01:00
HemachandarandGitHub 3b5b15d1a6 Remove dual read for keys (#11083)
* Remove dual read for `keys`

* write to mock

* remove dual writes

* Revert "remove dual writes"

This reverts commit ce9a48423b.

* add todo
2026-01-06 17:49:32 +05:30
Shmuel FogelandGitHub 2c5bc32f7e Merge pull request #11072 from appwrite/platform-rules-collections
Sync platform rules + common targets tables
2026-01-06 13:59:41 +02:00
Matej Bačo a37aa2dd68 Generic abuse test group 2026-01-05 22:51:11 +01:00
Matej Bačo b29f70a0af Add new tests 2026-01-05 22:51:03 +01:00
Matej Bačo 2fc5e56a61 WIP: Abuse reset on success 2026-01-05 22:05:00 +01:00
Matej BačoandGitHub b1373c02bc Merge pull request #11056 from appwrite/feat-project-labels
Feat: Project labels
2026-01-05 17:15:30 +01:00
Matej Bačo 1db12e78ef AI code review 2026-01-05 14:42:03 +01:00
Matej Bačo a25c8004e4 Merge branch '1.8.x' into feat-project-labels 2026-01-05 14:32:26 +01:00
Chirag AggarwalandGitHub 547d59d95e Merge pull request #11079 from appwrite/validate-smtp-connection 2026-01-05 17:36:58 +05:30
Chirag Aggarwal 4e0477af32 add timeout to mailer 2026-01-05 17:25:12 +05:30
Luke B. SilverandGitHub 1659bc3ffa Merge pull request #11080 from appwrite/fix-audit-delete
Fix audit delete
2026-01-05 10:53:52 +00:00
fogelito 84e9c8243c fix param order 2026-01-05 09:30:47 +02:00
fogelito 020d7bb801 Merge branch '1.8.x' of https://github.com/appwrite/appwrite into platform-rules-collections 2026-01-04 12:26:49 +02:00
fogelito b6aeaffe8b Remove region index 2026-01-04 12:20:22 +02:00
fogelito 65883122e3 rules changes 2026-01-04 12:16:59 +02:00
DarshanandGitHub d4660a70c3 Merge pull request #11071 from appwrite/fix-missing-dbId 2026-01-04 12:37:54 +05:30
Darshan 16f9c35850 update: tests. 2026-01-04 12:09:11 +05:30
Darshan 49511ebdd9 fix: missing database id in response. 2026-01-04 12:05:55 +05:30
Luke B. SilverandGitHub 63511c56ae Merge pull request #11067 from appwrite/fix-memory-leak
fix: memory leak
2026-01-03 23:02:31 +00:00
loks0n 445ada0226 fix: memory leak 2026-01-03 22:38:17 +00:00
Steven NguyenandGitHub 68b4a49e9d Merge pull request #10896 from appwrite/feat-1.8.1-release-prep
Prepare 1.8.1 release
2026-01-02 15:38:50 -08:00
DarshanandGitHub 28a09d7aaa Merge pull request #11065 from appwrite/add-logging-tests 2026-01-02 17:20:10 +05:30
Darshan ee3b2d75ae add: missing test at console level. 2026-01-02 17:01:03 +05:30
Chirag AggarwalandGitHub ffe1fd89a7 Merge pull request #11064 from appwrite/chore-cookie-console 2026-01-02 16:29:39 +05:30
Chirag Aggarwal 06c4ba81e9 chore: sync specs + allow cookie auth in console platform 2026-01-02 15:20:45 +05:30
Chirag AggarwalandGitHub f2772cea6b Merge pull request #11063 from appwrite/fix-remove-production-attribute 2026-01-02 12:22:42 +05:30
Chirag Aggarwal b76a4bdd2f fix: remove production attribute when releasing sdks 2026-01-02 12:16:50 +05:30
Shmuel FogelandGitHub 1c94381830 Merge pull request #11058 from appwrite/cli-previous-errors
Cli previous errors
2026-01-01 17:36:24 +02:00
Matej Bačo 8d1acef95d Implement project labels 2025-12-31 15:44:18 +01:00
Steven NguyenandGitHub bd288ce650 Merge pull request #10898 from appwrite/copilot/update-changes-md-for-1-8-1
Add CHANGES.md section for version 1.8.1
2025-12-04 17:36:59 -08:00
copilot-swe-agent[bot]andstnguyen90 9f2105b294 Address additional review feedback on CHANGES.md
Co-authored-by: stnguyen90 <1477010+stnguyen90@users.noreply.github.com>
2025-12-05 00:48:50 +00:00
Steven NguyenandGitHub b3944c678c Merge pull request #10903 from appwrite/copilot/add-migration-version-for-1-8-1
Complete V23 migration for 1.8.1: add user email attributes and fix fall-through bug
2025-12-04 16:34:33 -08:00
copilot-swe-agent[bot]andstnguyen90 31c8c09060 Address review feedback on CHANGES.md categorization
Co-authored-by: stnguyen90 <1477010+stnguyen90@users.noreply.github.com>
2025-12-05 00:28:49 +00:00
copilot-swe-agent[bot]andstnguyen90 4283671d49 Add user email attributes migration and fix missing break statement in V23
Co-authored-by: stnguyen90 <1477010+stnguyen90@users.noreply.github.com>
2025-12-04 22:15:50 +00:00
copilot-swe-agent[bot] c3a3717bde Initial plan 2025-12-04 22:02:02 +00:00
copilot-swe-agent[bot]andstnguyen90 5cdb59142f Add CHANGES.md section for version 1.8.1
Co-authored-by: stnguyen90 <1477010+stnguyen90@users.noreply.github.com>
2025-12-03 22:11:54 +00:00
copilot-swe-agent[bot] bf6f784826 Initial plan 2025-12-03 22:03:16 +00:00
Steven NguyenandGitHub 33f7fd259b Merge pull request #10897 from appwrite/chore-bump-console-7.5.7
Bump console to version 7.5.7
2025-12-03 13:53:35 -08:00
Steven Nguyen bc99e04b57 feat: bump console to version 7.5.7 2025-12-03 13:26:06 -08:00
Steven Nguyen 10b8f97e96 chore: bump appwrite version to 1.8.1 2025-12-03 13:22:39 -08:00
Atharva Deosthale c915a00bac change back to traefik, check tests 2025-11-21 21:50:46 +05:30
Atharva Deosthale d03d9e5795 add functionality to cancel immediately rather than period end 2025-11-21 20:12:20 +05:30
Atharva Deosthale 7cde35d7a3 get proper projectDB when passing to adapter in webhook 2025-11-21 18:58:12 +05:30
Atharva Deosthale 27932854c8 format 2025-11-21 18:24:16 +05:30
Atharva Deosthale a01d689ae2 merge conflicts? 2025-11-21 18:21:56 +05:30
Atharva Deosthale f319841fd1 Merge branch '1.8.x' into auth-subscriptions 2025-11-21 16:31:56 +05:30
Atharva Deosthale 92f9ddfc33 remove legacy hackathon code 2025-11-21 16:28:13 +05:30
Atharva Deosthale 86878e3b55 move payments collections to projects db instead of platformDB 2025-11-21 15:12:10 +05:30
Atharva Deosthale df8c696b86 users already subscribed should not be able to do so again 2025-11-21 14:32:31 +05:30
Atharva Deosthale 650da7ba94 ensure customer before any checkout session 2025-11-15 16:10:36 +05:30
Atharva Deosthale efb8ff7bb1 store subscription id while in webhooks 2025-11-15 16:00:23 +05:30
Atharva Deosthale 16f7031799 get webhooks to sync subscription status 2025-11-15 15:42:13 +05:30
Atharva Deosthale 90e5824f9d progress, but dbplatform query always returns empty 2025-11-14 02:21:41 +05:30
Atharva Deosthale d7677a8992 progressing on webhook stuff 2025-11-14 01:21:23 +05:30
Atharva Deosthale 94390f4a65 add route to get team/user subscription 2025-11-13 23:30:37 +05:30
Atharva Deosthale f59cd158ef checkout gets created, yay 2025-11-13 18:41:32 +05:30
Atharva Deosthale 3967b2e338 remove temp files 2025-11-13 01:32:01 +05:30
Atharva Deosthale eb08a3379a very rough progress 2025-11-13 01:29:29 +05:30
Atharva Deosthale fee6ad43d6 progress 2025-11-12 19:09:55 +05:30
Atharva Deosthale 350e651c53 progress 2025-10-03 17:23:28 +05:30
Atharva Deosthale d49bad0de5 revert env 2025-09-30 23:46:44 +05:30
Atharva Deosthale e885cece85 format + lil cleanup 2025-09-30 23:44:14 +05:30
Atharva Deosthale 9b71c70d1a progress 2025-09-30 12:53:48 +05:30
Atharva Deosthale 414692083f remove env 2025-09-29 16:45:04 +05:30
Atharva Deosthale be1e681aa8 init payments 2025-09-29 16:44:11 +05:30
146 changed files with 34024 additions and 13975 deletions
+1 -1
View File
@@ -126,4 +126,4 @@ _APP_WEBHOOK_MAX_FAILED_ATTEMPTS=10
_APP_PROJECT_REGIONS=default
_APP_FUNCTIONS_CREATION_ABUSE_LIMIT=5000
_APP_STATS_USAGE_DUAL_WRITING_DBS=database_db_main
_APP_TRUSTED_HEADERS=x-forwarded-for
_APP_TRUSTED_HEADERS=x-forwarded-for
+11 -11
View File
@@ -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
View File
@@ -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
View File
@@ -87,7 +87,9 @@ RUN chmod +x /usr/local/bin/doctor && \
chmod +x /usr/local/bin/worker-webhooks && \
chmod +x /usr/local/bin/worker-stats-usage && \
chmod +x /usr/local/bin/stats-resources && \
chmod +x /usr/local/bin/worker-stats-resources
chmod +x /usr/local/bin/worker-stats-resources && \
chmod +x /usr/local/bin/worker-payments-usage && \
chmod +x /usr/local/bin/schedule-payments-usage
RUN mkdir -p /etc/letsencrypt/live/ && chmod -Rf 755 /etc/letsencrypt/live/
+3 -3
View File
@@ -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 的本机主机上完成安装后,服务器可能需要几分钟才能启动。
+3 -3
View File
@@ -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.
+4
View File
@@ -5,6 +5,7 @@ require_once __DIR__ . '/init.php';
use Appwrite\Event\Certificate;
use Appwrite\Event\Delete;
use Appwrite\Event\Func;
use Appwrite\Event\PaymentsUsage;
use Appwrite\Event\StatsResources;
use Appwrite\Event\StatsUsage;
use Appwrite\Platform\Appwrite;
@@ -228,6 +229,9 @@ CLI::setResource('queueForDeletes', function (Publisher $publisher) {
CLI::setResource('queueForCertificates', function (Publisher $publisher) {
return new Certificate($publisher);
}, ['publisher']);
CLI::setResource('queueForPaymentsUsage', function (Publisher $publisher) {
return new PaymentsUsage($publisher);
}, ['publisher']);
CLI::setResource('logError', function (Registry $register) {
return function (Throwable $error, string $namespace, string $action) use ($register) {
Console::error('[Error] Timestamp: ' . date('c', time()));
+35 -13
View File
@@ -232,6 +232,17 @@ $platformCollections = [
'array' => false,
'filters' => ['json'],
],
[
'$id' => ID::custom('payments'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => 65536,
'signed' => true,
'required' => false,
'default' => [],
'array' => false,
'filters' => ['json', 'encrypt'],
],
[
'$id' => ID::custom('auths'),
'type' => Database::VAR_STRING,
@@ -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' => [],
],
],
],
+121
View File
@@ -2645,4 +2645,125 @@ return [
],
],
],
'payments_plans' => [
'$collection' => ID::custom(Database::METADATA),
'$id' => ID::custom('payments_plans'),
'name' => 'Payments Plans',
'attributes' => [
[ '$id' => ID::custom('planId'), 'type' => Database::VAR_STRING, 'size' => Database::LENGTH_KEY, 'signed' => true, 'required' => true, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('name'), 'type' => Database::VAR_STRING, 'size' => 2048, 'signed' => true, 'required' => false, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('description'), 'type' => Database::VAR_STRING, 'size' => 8192, 'signed' => true, 'required' => false, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('pricing'), 'type' => Database::VAR_STRING, 'size' => 65536, 'signed' => true, 'required' => false, 'default' => [], 'array' => false, 'filters' => ['json'] ],
[ '$id' => ID::custom('isDefault'), 'type' => Database::VAR_BOOLEAN, 'size' => 0, 'signed' => true, 'required' => false, 'default' => false, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('isFree'), 'type' => Database::VAR_BOOLEAN, 'size' => 0, 'signed' => true, 'required' => false, 'default' => false, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('status'), 'type' => Database::VAR_STRING, 'size' => 32, 'signed' => true, 'required' => false, 'default' => 'active', 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('providers'), 'type' => Database::VAR_STRING, 'size' => 65536, 'signed' => true, 'required' => false, 'default' => [], 'array' => false, 'filters' => ['json'] ],
[ '$id' => ID::custom('features'), 'type' => Database::VAR_STRING, 'size' => 65536, 'signed' => true, 'required' => false, 'default' => [], 'array' => false, 'filters' => ['json'] ],
[ '$id' => ID::custom('search'), 'type' => Database::VAR_STRING, 'size' => 16384, 'signed' => true, 'required' => false, 'default' => null, 'array' => false, 'filters' => [] ],
],
'indexes' => [
[ '$id' => ID::custom('_key_plan'), 'type' => Database::INDEX_UNIQUE, 'attributes' => ['planId'], 'lengths' => [Database::LENGTH_KEY], 'orders' => [Database::ORDER_ASC] ],
[ '$id' => ID::custom('_key_status'), 'type' => Database::INDEX_KEY, 'attributes' => ['status'], 'lengths' => [32], 'orders' => [Database::ORDER_ASC] ],
[ '$id' => ID::custom('_fulltext_search'), 'type' => Database::INDEX_FULLTEXT, 'attributes' => ['search'], 'lengths' => [], 'orders' => [] ],
],
],
'payments_features' => [
'$collection' => ID::custom(Database::METADATA),
'$id' => ID::custom('payments_features'),
'name' => 'Payments Features',
'attributes' => [
[ '$id' => ID::custom('featureId'), 'type' => Database::VAR_STRING, 'size' => Database::LENGTH_KEY, 'signed' => true, 'required' => true, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('name'), 'type' => Database::VAR_STRING, 'size' => 2048, 'signed' => true, 'required' => false, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('type'), 'type' => Database::VAR_STRING, 'size' => 32, 'signed' => true, 'required' => true, 'default' => 'boolean', 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('description'), 'type' => Database::VAR_STRING, 'size' => 8192, 'signed' => true, 'required' => false, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('providers'), 'type' => Database::VAR_STRING, 'size' => 65536, 'signed' => true, 'required' => false, 'default' => [], 'array' => false, 'filters' => ['json'] ],
],
'indexes' => [
[ '$id' => ID::custom('_key_feature'), 'type' => Database::INDEX_UNIQUE, 'attributes' => ['featureId'], 'lengths' => [Database::LENGTH_KEY], 'orders' => [Database::ORDER_ASC] ],
[ '$id' => ID::custom('_key_type'), 'type' => Database::INDEX_KEY, 'attributes' => ['type'], 'lengths' => [32], 'orders' => [Database::ORDER_ASC] ],
],
],
'payments_plan_features' => [
'$collection' => ID::custom(Database::METADATA),
'$id' => ID::custom('payments_plan_features'),
'name' => 'Payments Plan Features',
'attributes' => [
[ '$id' => ID::custom('planId'), 'type' => Database::VAR_STRING, 'size' => Database::LENGTH_KEY, 'signed' => true, 'required' => true, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('featureId'), 'type' => Database::VAR_STRING, 'size' => Database::LENGTH_KEY, 'signed' => true, 'required' => true, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('type'), 'type' => Database::VAR_STRING, 'size' => 32, 'signed' => true, 'required' => true, 'default' => 'boolean', 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('enabled'), 'type' => Database::VAR_BOOLEAN, 'size' => 0, 'signed' => true, 'required' => false, 'default' => true, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('currency'), 'type' => Database::VAR_STRING, 'size' => 8, 'signed' => true, 'required' => false, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('interval'), 'type' => Database::VAR_STRING, 'size' => 16, 'signed' => true, 'required' => false, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('includedUnits'), 'type' => Database::VAR_INTEGER, 'size' => 0, 'signed' => false, 'required' => false, 'default' => 0, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('tiersMode'), 'type' => Database::VAR_STRING, 'size' => 16, 'signed' => true, 'required' => false, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('tiers'), 'type' => Database::VAR_STRING, 'size' => 65536, 'signed' => true, 'required' => false, 'default' => [], 'array' => false, 'filters' => ['json'] ],
[ '$id' => ID::custom('usageCap'), 'type' => Database::VAR_INTEGER, 'size' => 0, 'signed' => false, 'required' => false, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('overagePrice'), 'type' => Database::VAR_INTEGER, 'size' => 0, 'signed' => false, 'required' => false, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('providers'), 'type' => Database::VAR_STRING, 'size' => 65536, 'signed' => true, 'required' => false, 'default' => [], 'array' => false, 'filters' => ['json'] ],
[ '$id' => ID::custom('metadata'), 'type' => Database::VAR_STRING, 'size' => 65536, 'signed' => true, 'required' => false, 'default' => [], 'array' => false, 'filters' => ['json'] ],
],
'indexes' => [
[ '$id' => ID::custom('_key_unique'), 'type' => Database::INDEX_UNIQUE, 'attributes' => ['planId','featureId'], 'lengths' => [Database::LENGTH_KEY, Database::LENGTH_KEY], 'orders' => [Database::ORDER_ASC, Database::ORDER_ASC] ],
[ '$id' => ID::custom('_key_type'), 'type' => Database::INDEX_KEY, 'attributes' => ['type'], 'lengths' => [16], 'orders' => [Database::ORDER_ASC] ],
],
],
'payments_subscriptions' => [
'$collection' => ID::custom(Database::METADATA),
'$id' => ID::custom('payments_subscriptions'),
'name' => 'Payments Subscriptions',
'attributes' => [
[ '$id' => ID::custom('subscriptionId'), 'type' => Database::VAR_STRING, 'size' => Database::LENGTH_KEY, 'signed' => true, 'required' => true, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('providerSubscriptionId'), 'type' => Database::VAR_STRING, 'size' => Database::LENGTH_KEY, 'signed' => true, 'required' => false, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('providerCheckoutId'), 'type' => Database::VAR_STRING, 'size' => Database::LENGTH_KEY, 'signed' => true, 'required' => false, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('actorType'), 'type' => Database::VAR_STRING, 'size' => 16, 'signed' => true, 'required' => true, 'default' => 'user', 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('actorId'), 'type' => Database::VAR_STRING, 'size' => Database::LENGTH_KEY, 'signed' => true, 'required' => true, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('actorInternalId'), 'type' => Database::VAR_STRING, 'size' => Database::LENGTH_KEY, 'signed' => true, 'required' => true, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('planId'), 'type' => Database::VAR_STRING, 'size' => Database::LENGTH_KEY, 'signed' => true, 'required' => true, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('priceId'), 'type' => Database::VAR_STRING, 'size' => Database::LENGTH_KEY, 'signed' => true, 'required' => false, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('status'), 'type' => Database::VAR_STRING, 'size' => 32, 'signed' => true, 'required' => false, 'default' => 'active', 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('trialEndsAt'), 'type' => Database::VAR_DATETIME, 'size' => 0, 'signed' => false, 'required' => false, 'default' => null, 'array' => false, 'filters' => ['datetime'] ],
[ '$id' => ID::custom('currentPeriodStart'), 'type' => Database::VAR_DATETIME, 'size' => 0, 'signed' => false, 'required' => false, 'default' => null, 'array' => false, 'filters' => ['datetime'] ],
[ '$id' => ID::custom('currentPeriodEnd'), 'type' => Database::VAR_DATETIME, 'size' => 0, 'signed' => false, 'required' => false, 'default' => null, 'array' => false, 'filters' => ['datetime'] ],
[ '$id' => ID::custom('cancelAtPeriodEnd'), 'type' => Database::VAR_BOOLEAN, 'size' => 0, 'signed' => true, 'required' => false, 'default' => false, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('canceledAt'), 'type' => Database::VAR_DATETIME, 'size' => 0, 'signed' => false, 'required' => false, 'default' => null, 'array' => false, 'filters' => ['datetime'] ],
[ '$id' => ID::custom('providers'), 'type' => Database::VAR_STRING, 'size' => 65536, 'signed' => true, 'required' => false, 'default' => [], 'array' => false, 'filters' => ['json'] ],
[ '$id' => ID::custom('usageSummary'), 'type' => Database::VAR_STRING, 'size' => 65536, 'signed' => true, 'required' => false, 'default' => [], 'array' => false, 'filters' => ['json'] ],
[ '$id' => ID::custom('tags'), 'type' => Database::VAR_STRING, 'size' => Database::LENGTH_KEY, 'signed' => true, 'required' => false, 'default' => [], 'array' => true, 'filters' => [] ],
[ '$id' => ID::custom('search'), 'type' => Database::VAR_STRING, 'size' => 16384, 'signed' => true, 'required' => false, 'default' => null, 'array' => false, 'filters' => [] ],
],
'indexes' => [
[ '$id' => ID::custom('_key_actor'), 'type' => Database::INDEX_KEY, 'attributes' => ['actorType','actorId'], 'lengths' => [16, Database::LENGTH_KEY], 'orders' => [Database::ORDER_ASC, Database::ORDER_ASC] ],
[ '$id' => ID::custom('_key_status'), 'type' => Database::INDEX_KEY, 'attributes' => ['status'], 'lengths' => [32], 'orders' => [Database::ORDER_ASC] ],
[ '$id' => ID::custom('_key_plan'), 'type' => Database::INDEX_KEY, 'attributes' => ['planId'], 'lengths' => [Database::LENGTH_KEY], 'orders' => [Database::ORDER_ASC] ],
[ '$id' => ID::custom('_key_provider_subscription'), 'type' => Database::INDEX_KEY, 'attributes' => ['providerSubscriptionId'], 'lengths' => [Database::LENGTH_KEY], 'orders' => [Database::ORDER_ASC] ],
[ '$id' => ID::custom('_key_provider_checkout'), 'type' => Database::INDEX_KEY, 'attributes' => ['providerCheckoutId'], 'lengths' => [Database::LENGTH_KEY], 'orders' => [Database::ORDER_ASC] ],
[ '$id' => ID::custom('_fulltext_search'), 'type' => Database::INDEX_FULLTEXT, 'attributes' => ['search'], 'lengths' => [], 'orders' => [] ],
],
],
'payments_usage_events' => [
'$collection' => ID::custom(Database::METADATA),
'$id' => ID::custom('payments_usage_events'),
'name' => 'Payments Usage Events',
'attributes' => [
[ '$id' => ID::custom('subscriptionId'), 'type' => Database::VAR_STRING, 'size' => Database::LENGTH_KEY, 'signed' => true, 'required' => true, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('actorType'), 'type' => Database::VAR_STRING, 'size' => 16, 'signed' => true, 'required' => true, 'default' => 'user', 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('actorId'), 'type' => Database::VAR_STRING, 'size' => Database::LENGTH_KEY, 'signed' => true, 'required' => true, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('planId'), 'type' => Database::VAR_STRING, 'size' => Database::LENGTH_KEY, 'signed' => true, 'required' => true, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('featureId'), 'type' => Database::VAR_STRING, 'size' => Database::LENGTH_KEY, 'signed' => true, 'required' => true, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('quantity'), 'type' => Database::VAR_INTEGER, 'size' => 0, 'signed' => false, 'required' => true, 'default' => 0, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('timestamp'), 'type' => Database::VAR_DATETIME, 'size' => 0, 'signed' => false, 'required' => true, 'default' => null, 'array' => false, 'filters' => ['datetime'] ],
[ '$id' => ID::custom('providerSyncState'), 'type' => Database::VAR_STRING, 'size' => 32, 'signed' => true, 'required' => false, 'default' => 'pending', 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('providerEventId'), 'type' => Database::VAR_STRING, 'size' => 256, 'signed' => true, 'required' => false, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('metadata'), 'type' => Database::VAR_STRING, 'size' => 65536, 'signed' => true, 'required' => false, 'default' => [], 'array' => false, 'filters' => ['json'] ],
],
'indexes' => [
[ '$id' => ID::custom('_key_subscription_feature_time'), 'type' => Database::INDEX_KEY, 'attributes' => ['subscriptionId','featureId','timestamp'], 'lengths' => [Database::LENGTH_KEY, Database::LENGTH_KEY, 0], 'orders' => [Database::ORDER_ASC, Database::ORDER_ASC, Database::ORDER_ASC] ],
[ '$id' => ID::custom('_key_sync_state'), 'type' => Database::INDEX_KEY, 'attributes' => ['providerSyncState'], 'lengths' => [32], 'orders' => [Database::ORDER_ASC] ],
],
],
];
+42
View File
@@ -1329,4 +1329,46 @@ return [
'description' => 'Target has an invalid provider type.',
'code' => 400,
],
/** Payments */
Exception::PAYMENT_PLAN_NOT_FOUND => [
'name' => Exception::PAYMENT_PLAN_NOT_FOUND,
'description' => 'Payment plan with the requested ID could not be found.',
'code' => 404,
],
Exception::PAYMENT_PLAN_ALREADY_EXISTS => [
'name' => Exception::PAYMENT_PLAN_ALREADY_EXISTS,
'description' => 'Payment plan with the requested ID already exists.',
'code' => 409,
],
Exception::PAYMENT_SUBSCRIPTION_NOT_FOUND => [
'name' => Exception::PAYMENT_SUBSCRIPTION_NOT_FOUND,
'description' => 'Payment subscription with the requested ID could not be found.',
'code' => 404,
],
Exception::PAYMENT_SUBSCRIPTION_ALREADY_EXISTS => [
'name' => Exception::PAYMENT_SUBSCRIPTION_ALREADY_EXISTS,
'description' => 'Payment subscription already exists for this actor.',
'code' => 409,
],
Exception::PAYMENT_PROVIDER_NOT_CONFIGURED => [
'name' => Exception::PAYMENT_PROVIDER_NOT_CONFIGURED,
'description' => 'No payment provider has been configured for this project.',
'code' => 400,
],
Exception::PAYMENT_PROVIDER_ALREADY_CONFIGURED => [
'name' => Exception::PAYMENT_PROVIDER_ALREADY_CONFIGURED,
'description' => 'Payment provider is already configured. Disconnect the existing provider before configuring a new one.',
'code' => 409,
],
Exception::PAYMENT_WEBHOOK_FAILED => [
'name' => Exception::PAYMENT_WEBHOOK_FAILED,
'description' => 'Failed to create payment provider webhook.',
'code' => 500,
],
Exception::PAYMENT_FEATURE_NOT_FOUND => [
'name' => Exception::PAYMENT_FEATURE_NOT_FOUND,
'description' => 'Payment feature with the requested ID could not be found.',
'code' => 404,
],
];
+9
View File
@@ -166,4 +166,13 @@ return [ // List of publicly visible scopes
'tokens.write' => [
'description' => 'Access to create, update, and delete your project\'s tokens',
],
'payments.read' => [
'description' => 'Access to read your project\'s payments data (plans, subscriptions, usage)'
],
'payments.write' => [
'description' => 'Access to create, update, and delete your project\'s payments resources'
],
'payments.subscribe' => [
'description' => 'Access to create and manage subscriptions as an actor (user/team)'
],
];
+13
View File
@@ -225,6 +225,19 @@ return [
'icon' => '/images/services/functions.png',
'platforms' => ['client', 'server', 'console'],
],
'payments' => [
'key' => 'payments',
'name' => 'Payments',
'subtitle' => 'Create and manage plans, subscriptions, usage and billing providers.',
'description' => '/docs/services/payments.md',
'controller' => '', // Uses modules
'sdk' => true,
'docs' => true,
'docsUrl' => '',
'tests' => false,
'optional' => true,
'icon' => '/images/services/databases.png',
],
'proxy' => [
'key' => 'proxy',
'name' => 'Proxy',
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
+3
View File
@@ -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')
+1 -1
View File
@@ -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)
+10 -28
View File
@@ -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()) {
+4
View File
@@ -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,
+37 -1
View File
@@ -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
*/
+1
View File
@@ -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
+2 -1
View File
@@ -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';
+2 -7
View File
@@ -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),
]);
}
+366
View File
@@ -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());
+7
View File
@@ -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;
});
+3
View File
@@ -82,6 +82,9 @@ App::setResource('hooks', function ($register) {
}, ['register']);
App::setResource('register', fn () => $register);
App::setResource('registryPayments', function ($register) {
return $register->get('registryPayments');
}, ['register']);
App::setResource('locale', function () {
$locale = new Locale(System::getEnv('_APP_LOCALE', 'en'));
$locale->setFallback(System::getEnv('_APP_LOCALE', 'en'));
+1 -1
View File
@@ -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
+4
View File
@@ -382,6 +382,10 @@ Server::setResource('plan', function (array $plan = []) {
return [];
});
Server::setResource('registryPayments', function (Registry $register) {
return $register->get('registryPayments');
}, ['register']);
Server::setResource('certificates', function () {
$email = System::getEnv('_APP_EMAIL_CERTIFICATES', System::getEnv('_APP_SYSTEM_SECURITY_EMAIL_ADDRESS'));
if (empty($email)) {
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
php /usr/src/code/app/cli.php schedule-payments-usage $@
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
php /usr/src/code/app/worker.php payments-usage-sync $@
+4 -4
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+60 -1
View File
@@ -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
+3
View File
@@ -45,6 +45,9 @@ class Event
public const MIGRATIONS_QUEUE_NAME = 'v1-migrations';
public const MIGRATIONS_CLASS_NAME = 'MigrationsV1';
public const PAYMENTS_USAGE_QUEUE_NAME = 'v1-payments-usage-sync';
public const PAYMENTS_USAGE_CLASS_NAME = 'PaymentsUsageSyncV1';
protected string $queue = '';
protected string $class = '';
protected string $event = '';
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace Appwrite\Event;
use Utopia\Queue\Publisher;
class PaymentsUsage extends Event
{
public function __construct(protected Publisher $publisher)
{
parent::__construct($publisher);
$this
->setQueue(Event::PAYMENTS_USAGE_QUEUE_NAME)
->setClass(Event::PAYMENTS_USAGE_CLASS_NAME);
}
/**
* Prepare the payload for the event
*
* @return array
*/
protected function preparePayload(): array
{
return [
'project' => $this->getProject(),
];
}
}
+11
View File
@@ -62,6 +62,7 @@ class Exception extends \Exception
public const string GENERAL_INVALID_PHONE = 'general_invalid_phone';
public const string GENERAL_REGION_ACCESS_DENIED = 'general_region_access_denied';
public const string GENERAL_BAD_REQUEST = 'general_bad_request';
public const string GENERAL_NOT_FOUND = 'general_not_found';
/** Users */
public const string USER_COUNT_EXCEEDED = 'user_count_exceeded';
@@ -378,6 +379,16 @@ class Exception extends \Exception
public const string TOKEN_EXPIRED = 'token_expired';
public const string TOKEN_RESOURCE_TYPE_INVALID = 'token_resource_type_invalid';
/** Payments */
public const string PAYMENT_PLAN_NOT_FOUND = 'payment_plan_not_found';
public const string PAYMENT_PLAN_ALREADY_EXISTS = 'payment_plan_already_exists';
public const string PAYMENT_SUBSCRIPTION_NOT_FOUND = 'payment_subscription_not_found';
public const string PAYMENT_SUBSCRIPTION_ALREADY_EXISTS = 'payment_subscription_already_exists';
public const string PAYMENT_PROVIDER_NOT_CONFIGURED = 'payment_provider_not_configured';
public const string PAYMENT_PROVIDER_ALREADY_CONFIGURED = 'payment_provider_already_configured';
public const string PAYMENT_WEBHOOK_FAILED = 'payment_webhook_failed';
public const string PAYMENT_FEATURE_NOT_FOUND = 'payment_feature_not_found';
protected string $type = '';
protected array $errors = [];
protected bool $publish;
+1
View File
@@ -90,6 +90,7 @@ abstract class Migration
'1.7.3' => 'V22',
'1.7.4' => 'V22',
'1.8.0' => 'V23',
'1.8.1' => 'V23',
];
/**
+16 -1
View File
@@ -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
-48
View File
@@ -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);
}
}
}
}
+2
View File
@@ -7,6 +7,7 @@ use Appwrite\Platform\Modules\Console;
use Appwrite\Platform\Modules\Core;
use Appwrite\Platform\Modules\Databases;
use Appwrite\Platform\Modules\Functions;
use Appwrite\Platform\Modules\Payments;
use Appwrite\Platform\Modules\Projects;
use Appwrite\Platform\Modules\Proxy;
use Appwrite\Platform\Modules\Sites;
@@ -28,5 +29,6 @@ class Appwrite extends Platform
$this->addModule(new Proxy\Module());
$this->addModule(new Tokens\Module());
$this->addModule(new Storage\Module());
$this->addModule(new Payments\Module());
}
}
@@ -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());
@@ -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