From 9b71c70d1a8ecbdaa2a30bd1d65dd2987cc87561 Mon Sep 17 00:00:00 2001 From: Atharva Deosthale Date: Tue, 30 Sep 2025 12:53:48 +0530 Subject: [PATCH] progress --- .env | 2 +- TEMP_FEATURES.md | 64 ++ TEMP_SCHEMA.md | 398 ++++++++++- app/config/collections/auth_plans.php | 331 ++++++++++ app/controllers/api/account.php | 154 ++++- app/controllers/api/auth-plans.php | 621 +++++++++++++++++- app/controllers/api/projects.php | 200 +++++- app/controllers/api/users.php | 69 +- .../Auth/Subscription/StripeService.php | 346 +++++++++- tmp_check_geodb.php | 19 + 10 files changed, 2151 insertions(+), 53 deletions(-) create mode 100644 TEMP_FEATURES.md create mode 100644 tmp_check_geodb.php diff --git a/.env b/.env index 4535ff53d3..f195406195 100644 --- a/.env +++ b/.env @@ -124,4 +124,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_AUTH_STRIPE_WEBHOOK_URL= \ No newline at end of file +_APP_AUTH_STRIPE_WEBHOOK_URL=https://12ee42ab449a.ngrok-free.app \ No newline at end of file diff --git a/TEMP_FEATURES.md b/TEMP_FEATURES.md new file mode 100644 index 0000000000..9f2d067a6b --- /dev/null +++ b/TEMP_FEATURES.md @@ -0,0 +1,64 @@ +# Auth Features Structure + +## Collections + +- `auth_features` (project-level catalog) + + - `featureId` (string): unique feature identifier + - `name` (string): display name + - `type` (string): `boolean` or `metered` + - `description` (string) + - `active` (boolean) + +- `auth_plan_features` (assignment of features to plans) + + - `projectId` (string) + - `planId` (string) + - `featureId` (string) + - `type` (string): `boolean` or `metered` + - For `boolean`: + - `enabled` (boolean) + - For `metered`: + - `currency` (string, 3 letters) + - `interval` (string: day|week|month|year) + - `includedUnits` (int) + - `tiersMode` (string: graduated|volume) + - `tiers` (array of objects): each `{ to: number|"inf", unitAmount: number, flatAmount?: number }` + - `stripePriceId` (string): linked Stripe price for this feature + - `active` (boolean) + +- `auth_plans` (unchanged fields plus Stripe product/price for base plan) + +## Endpoints + +- Manage features (project-level): + + - `POST /v1/projects/:projectId/auth/features` + - `GET /v1/projects/:projectId/auth/features` + - `PUT /v1/projects/:projectId/auth/features/:featureId` + - `DELETE /v1/projects/:projectId/auth/features/:featureId` + +- Assign features to a plan: + - `POST /v1/projects/:projectId/auth/plans/:planId/features` + - `GET /v1/projects/:projectId/auth/plans/:planId/features` + +## Stripe Mapping + +- Base plan uses `auth_plans.stripePriceId` for subscription primary item. +- Each metered feature assignment creates a separate Stripe Price with: + - `usage_type = metered`, `billing_scheme = tiered`, `tiers_mode = graduated|volume`, `aggregate_usage = sum` + - Backed by a Stripe Meter; `stripeMeterId` is stored in `auth_plan_features` + - Tiers include a free tier for `includedUnits` with `unit_amount = 0` + - Price `nickname` = `Feature: {feature.name}` + - `metadata`: `project_id`, `plan_id`, `feature_id`, `type = auth_plan_feature_price` + +## Checkout Behavior + +- Checkout adds the plan price item plus one line item per assigned metered feature price. +- Boolean features apply immediately on plan assignment and do not generate additional Stripe items. + +## Evaluation + +- User effective features are derived from plan: + - Booleans: enabled if assignment exists with `enabled=true`. + - Metered: tracked externally via Stripe usage records tied to `stripePriceId`; included units are tiered free usage. diff --git a/TEMP_SCHEMA.md b/TEMP_SCHEMA.md index 98a87b467e..e8d07c5625 100644 --- a/TEMP_SCHEMA.md +++ b/TEMP_SCHEMA.md @@ -170,7 +170,7 @@ `GET /v1/projects/{projectId}/auth/plans` -**Description:** List all subscription plans for the project. +**Description:** List all subscription plans for the project. Each plan embeds `features` resolved from `auth_plan_features` (including `usageCap` for metered features). **Authentication:** Admin API Key @@ -198,23 +198,43 @@ ```json { - "total": 3, + "total": 2, "plans": [ { "$id": "64a5f8e7c3d2a", "planId": "free", "name": "Free Plan", "price": 0, + "currency": "usd", + "interval": "month", "isFree": true, - "isDefault": true - // ... other plan fields + "isDefault": true, + "features": [ + { "featureId": "custom-domains", "type": "boolean", "enabled": true } + ] }, { "$id": "64a5f8e7c3d2b", "planId": "basic", "name": "Basic Plan", - "price": 999 - // ... other plan fields + "price": 999, + "currency": "usd", + "interval": "month", + "features": [ + { + "featureId": "premium-api-calls", + "type": "metered", + "currency": "usd", + "interval": "month", + "includedUnits": 20, + "tiersMode": "graduated", + "tiers": [ + { "up_to": 20, "unit_amount": 0 }, + { "up_to": "inf", "unit_amount": 100 } + ], + "stripePriceId": "price_123" + } + ] } ] } @@ -231,7 +251,7 @@ `GET /v1/projects/{projectId}/auth/plans/{planId}` -**Description:** Get details of a specific subscription plan. +**Description:** Get details of a specific subscription plan. Response embeds `features` identical in shape to the List endpoint (including `usageCap` for metered features). **Authentication:** Admin API Key @@ -241,7 +261,7 @@ | projectId | string | Project unique ID | Yes | | planId | string | Plan ID | Yes | -**Response (200 OK):** Plan object (same as create response) +**Response (200 OK):** Plan object with embedded `features` **Errors:** @@ -257,6 +277,12 @@ **Description:** Update plan metadata. Note: Cannot update price, currency, or interval after creation. +When `features` is provided, it is treated as the full source of truth for the plan's assignments and the backend reconciles as follows: + +- Upsert: Each provided feature assignment is created or updated in `auth_plan_features` +- Soft-delete: Any existing assignment not present in the payload is marked `active=false` +- Stripe cleanup: For removed metered assignments, the associated Stripe price is deactivated + **Authentication:** Admin API Key **Path Parameters:** @@ -270,11 +296,11 @@ |-------|------|-------------|----------| | name | string | Plan display name | No | | description | string | Plan description | No | -| features | array | List of plan features | No | +| features | array | Full list of current feature assignments | No | | maxUsers | integer | Max users allowed | No | | isDefault | boolean | Set as default plan | No | -**Response (200 OK):** Updated plan object +**Response (200 OK):** Updated plan object (features are read from GET/LIST) **Errors:** @@ -308,13 +334,238 @@ --- +## Auth Features Management Endpoints + +### 8.a Create Auth Feature + +`POST /v1/projects/{projectId}/auth/features` + +Description: Create a reusable feature definition at the project level. Features can be assigned to plans. + +Authentication: Admin API Key + +Path Parameters: +| Parameter | Type | Description | Required | +|-----------|------|-------------|----------| +| projectId | string | Project unique ID | Yes | + +Request Body: +| Field | Type | Description | Required | +|-------|------|-------------|----------| +| featureId | string | Unique feature ID | Yes | +| name | string | Feature display name | Yes | +| type | string | Feature type (`boolean` or `metered`) | Yes | +| description | string | Description | No | + +Response (201 Created): Feature object + +Errors: + +- 401 - Unauthorized +- 404 - Project not found +- 409 - Feature already exists + +--- + +### 8.b List Auth Features + +`GET /v1/projects/{projectId}/auth/features` + +Description: List all active features for the project. + +Authentication: Admin API Key + +Path Parameters: +| Parameter | Type | Description | Required | +|-----------|------|-------------|----------| +| projectId | string | Project unique ID | Yes | + +Response (200 OK): Document list of features + +Errors: + +- 401 - Unauthorized +- 404 - Project not found + +--- + +### 8.c Update Auth Feature + +`PUT /v1/projects/{projectId}/auth/features/{featureId}` + +Description: Update a feature definition. + +Authentication: Admin API Key + +Path Parameters: +| Parameter | Type | Description | Required | +|-----------|------|-------------|----------| +| projectId | string | Project unique ID | Yes | +| featureId | string | Feature unique ID | Yes | + +Request Body: +| Field | Type | Description | Required | +|-------|------|-------------|----------| +| name | string | Feature name | No | +| type | string | `boolean` or `metered` | No | +| description | string | Description | No | +| active | boolean | Active state | No | + +Response (200 OK): Updated feature object + +Errors: + +- 401 - Unauthorized +- 404 - Project or Feature not found + +--- + +### 8.d Delete Auth Feature + +`DELETE /v1/projects/{projectId}/auth/features/{featureId}` + +Description: Delete a feature. + +Authentication: Admin API Key + +Path Parameters: +| Parameter | Type | Description | Required | +|-----------|------|-------------|----------| +| projectId | string | Project unique ID | Yes | +| featureId | string | Feature unique ID | Yes | + +Response (204 No Content): Empty + +Errors: + +- 401 - Unauthorized +- 404 - Project or Feature not found + +--- + +## Plan Feature Assignment Endpoints + +### 8.e Assign Features to Plan + +`POST /v1/projects/{projectId}/auth/plans/{planId}/features` + +Description: Assign features to a plan. Creates Stripe metered tiered prices for metered features and links them. + +Authentication: Admin API Key + +Path Parameters: +| Parameter | Type | Description | Required | +|-----------|------|-------------|----------| +| projectId | string | Project unique ID | Yes | +| planId | string | Plan ID | Yes | + +Request Body: +| Field | Type | Description | Required | +|-------|------|-------------|----------| +| features | array | List of feature assignments | Yes | + +Feature assignment object (two shapes): + +- Boolean feature: + { + "featureId": "custom-domains", + "type": "boolean", + "enabled": true + } + +- Metered feature: + { + "featureId": "seats", + "type": "metered", + "currency": "usd", + "interval": "month", + "includedUnits": 100, + "tiersMode": "graduated", + "tiers": [ + { "to": 500, "unitAmount": 100 }, + { "to": 1000, "unitAmount": 200 }, + { "to": "inf", "unitAmount": 300 } + ] + } + +Notes: + +- includedUnits are applied as a free tier in Stripe (unit_amount=0 up to includedUnits) +- Stripe price uses billing_scheme=tiered, usage_type=metered, tiers_mode=graduated|volume, and references a Stripe Meter (required by Stripe versions >= 2025-03-31.basil) +- Amounts are accepted in major units (e.g., USD dollars); backend converts to minor units (cents), handling zero-decimal currencies +- `tiers` sent to Stripe are in `{ up_to, unit_amount }` with the last tier `up_to = "inf"` +- Price nickname is set to "Feature: {feature.name}" and metadata includes `feature_id`, `plan_id`, `project_id` +- Optional `usageCap` (integer or null) can be provided on metered features to cap ingestion per billing period. Ingestion beyond the cap is rejected and not sent to Stripe. + +Response (200 OK): Document list of created/updated assignments + +Errors: + +- 400 - Invalid feature assignment +- 401 - Unauthorized +- 404 - Project, Plan or Feature not found +- 500 - Stripe error for metered features + +--- + +### 8.f List Plan Features + +`GET /v1/projects/{projectId}/auth/plans/{planId}/features` + +Description: List active features assigned to a plan. + +Authentication: Admin API Key + +Response (200 OK): Document list of assignments + +--- + +### 8.g Delete Plan Feature + +`DELETE /v1/projects/{projectId}/auth/plans/{planId}/features/{featureId}` + +Description: Soft-delete a single feature assignment from a plan. For metered assignments, deactivates the Stripe price. + +Authentication: Admin API Key + +Response (204 No Content) + +Errors: + +- 401 - Unauthorized +- 404 - Project or Plan Feature not found + +--- + +### 8.h Delete Multiple Plan Features + +`DELETE /v1/projects/{projectId}/auth/plans/{planId}/features` + +Description: Bulk remove feature assignments from a plan by IDs. Deactivates Stripe prices for removed metered features. + +Authentication: Admin API Key + +Request Body: +| Field | Type | Description | Required | +|-------|------|-------------|----------| +| featureIds | array | Feature IDs to remove | Yes | + +Response (200 OK): Document list of updated assignments + +Errors: + +- 401 - Unauthorized +- 404 - Project not found + +--- + ## User Subscription Endpoints ### 9. Get Account Subscription `GET /v1/account/subscription` -**Description:** Get current user's subscription details. +**Description:** Get current user's subscription details, including assigned features and current-period usage for metered features. **Authentication:** Session or JWT @@ -328,7 +579,25 @@ "currentPeriodStart": "2024-01-01T00:00:00.000+00:00", "currentPeriodEnd": "2024-02-01T00:00:00.000+00:00", "cancelAtPeriodEnd": false, - "trialEnd": null + "trialEnd": null, + "features": [ + { + "featureId": "requests", + "type": "metered", + "enabled": true, + "currency": "usd", + "interval": "month", + "includedUnits": 100000, + "tiersMode": "graduated", + "tiers": [ + { "to": 100000, "unitAmount": 0 }, + { "to": "inf", "unitAmount": 0.5 } + ], + "usage": 12345, + "usageCap": 200000 + }, + { "featureId": "team-members", "type": "boolean", "enabled": true } + ] } ``` @@ -371,6 +640,9 @@ { "checkoutUrl": "https://checkout.stripe.com/c/pay/cs_test_a1b2c3..." } + +Notes: +- Assigned metered feature prices, if any, are added as additional subscription line items in the checkout session. ``` **Errors:** @@ -383,6 +655,51 @@ --- +### 10.a Ingest Usage + +`POST /v1/usage/ingest` + +**Description:** Ingest usage events for metered features. Sends usage to Stripe Billing Meters. + +**Authentication:** + +- Admin API Key (server-to-server), or +- Session/JWT (client SDK) +- Scope: `account` + +**Behavior:** + +- With API key: `userId` is required and honored. +- With Session/JWT: `userId` is ignored; the logged-in user is used. + +**Request Body:** +| Field | Type | Description | Required | +|-------|------|-------------|----------| +| planId | string | Plan ID to attribute usage to | Yes | +| featureId | string | Feature ID to attribute usage to | Yes | +| value | integer | Usage value to ingest | Yes | +| userId | string | User ID (ignored on client SDK) | No | +| timestamp | integer | Unix timestamp for the usage event | No | +| identifier | string | Idempotency identifier for the event | No | + +**Response (200 OK):** + +```json +{ + "success": true, + "id": "mevt_123" +} +``` + +**Errors:** + +- `400` - Subscriptions not enabled / Feature not metered / Missing userId for API key mode +- `401` - Unauthorized +- `404` - Feature assignment not found +- `500` - Stripe ingestion error + +--- + ### 11. Create Customer Portal Session `POST /v1/account/subscription/portal` @@ -597,7 +914,6 @@ | price | integer | Price in cents | No | No | | currency | string(3) | ISO 4217 currency code | No | No | | interval | string(10) | Billing interval | No | No | -| features | array | JSON array of features | No | No | | isDefault | boolean | Default plan flag | Yes | No | | isFree | boolean | Free tier flag | No | No | | maxUsers | integer | User limit (null = unlimited) | No | No | @@ -606,13 +922,54 @@ | $createdAt | datetime | Creation timestamp | No | No | | $updatedAt | datetime | Last update timestamp | No | No | +### Auth Features Collection + +| Field | Type | Description | Index | Unique | +| ----------------- | ----------- | -------------------------- | -------- | -------------- | +| $id | string | Document ID | Primary | Yes | +| projectInternalId | string | Internal project reference | Yes | No | +| projectId | string | Project ID | Yes | No | +| featureId | string | Feature identifier | Yes | With projectId | +| name | string(128) | Feature name | No | No | +| type | string(16) | `boolean` or `metered` | Yes | No | +| description | string(256) | Feature description | No | No | +| active | boolean | Active status | Yes | No | +| search | string | Full-text search | Fulltext | No | + +Indexes: + +- \_key_project: projectId +- \_key_project_featureId: projectId + featureId (unique) +- \_fulltext_search: search + +### Auth Plan Features Collection + +| Field | Type | Description | Index | Unique | +| ----------------- | ----------- | -------------------------------- | ------- | ------------------------ | +| $id | string | Document ID | Primary | Yes | +| projectInternalId | string | Internal project reference | Yes | No | +| projectId | string | Project ID | Yes | No | +| planId | string | Plan ID | Yes | With projectId+featureId | +| featureId | string | Feature ID | Yes | With projectId+planId | +| type | string(16) | `boolean` or `metered` | Yes | No | +| enabled | boolean | For boolean features | No | No | +| currency | string(3) | For metered features | No | No | +| interval | string(10) | For metered features | No | No | +| includedUnits | integer | Free included units | No | No | +| tiersMode | string(16) | `graduated` or `volume` | No | No | +| tiers | array | Tier definitions | No | No | +| stripePriceId | string(256) | Stripe price for metered feature | No | No | +| stripeMeterId | string(256) | Stripe meter backing the price | No | No | +| active | boolean | Active status | Yes | No | +| $createdAt | datetime | Creation timestamp | No | No | +| $updatedAt | datetime | Last update timestamp | No | No | + **Indexes:** - `_key_project`: projectId -- `_key_project_planId`: projectId + planId (unique) -- `_key_project_default`: projectId + isDefault -- `_key_project_active`: projectId + active -- `_fulltext_search`: search +- `_key_project_plan`: projectId + planId +- `_key_project_plan_feature` (unique): projectId + planId + featureId +- `_key_active`: active ### Users Collection Extensions @@ -669,9 +1026,10 @@ All errors follow Appwrite's standard error format: 1. **Setup**: Admin configures Stripe key → Webhook created automatically 2. **Plan Creation**: Admin creates plan → Stripe product/price created -3. **User Subscription**: User initiates checkout → Redirected to Stripe -4. **Webhook Processing**: Stripe sends events → User record updated -5. **Management**: User uses portal or API → Subscription modified in Stripe +3. **Assign Features**: Admin assigns features → For metered features, backend creates/links Stripe Meter and tiered metered price +4. **User Subscription**: User initiates checkout → Redirected to Stripe; additional line items include metered feature prices +5. **Webhook Processing**: Stripe sends events → User record updated +6. **Management**: User uses portal or API → Subscription modified in Stripe ### Migration Considerations diff --git a/app/config/collections/auth_plans.php b/app/config/collections/auth_plans.php index 4d736a21f2..128a4c275d 100644 --- a/app/config/collections/auth_plans.php +++ b/app/config/collections/auth_plans.php @@ -75,6 +75,17 @@ return [ 'array' => false, 'filters' => [], ], + [ + '$id' => ID::custom('stripeMeterId'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 256, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], [ '$id' => ID::custom('stripeProductId'), 'type' => Database::VAR_STRING, @@ -224,4 +235,324 @@ return [ ], ], ], + 'auth_features' => [ + '$collection' => ID::custom(Database::METADATA), + '$id' => ID::custom('auth_features'), + 'name' => 'Auth Features', + 'attributes' => [ + [ + '$id' => ID::custom('projectInternalId'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('projectId'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('featureId'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('name'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 128, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('type'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 16, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('description'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 256, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('active'), + 'type' => Database::VAR_BOOLEAN, + 'signed' => true, + 'size' => 0, + 'format' => '', + 'filters' => [], + 'required' => false, + 'default' => true, + 'array' => false, + ], + [ + '$id' => ID::custom('search'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 16384, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + ], + 'indexes' => [ + [ + '$id' => ID::custom('_key_project'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['projectId'], + 'lengths' => [Database::LENGTH_KEY], + 'orders' => [Database::ORDER_ASC], + ], + [ + '$id' => ID::custom('_key_project_featureId'), + 'type' => Database::INDEX_UNIQUE, + 'attributes' => ['projectId', 'featureId'], + 'lengths' => [Database::LENGTH_KEY, Database::LENGTH_KEY], + 'orders' => [Database::ORDER_ASC, Database::ORDER_ASC], + ], + [ + '$id' => ID::custom('_fulltext_search'), + 'type' => Database::INDEX_FULLTEXT, + 'attributes' => ['search'], + 'lengths' => [], + 'orders' => [], + ], + ], + ], + 'auth_plan_features' => [ + '$collection' => ID::custom(Database::METADATA), + '$id' => ID::custom('auth_plan_features'), + 'name' => 'Auth Plan Features', + 'attributes' => [ + [ + '$id' => ID::custom('projectInternalId'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('projectId'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('planId'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('featureId'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('type'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 16, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('enabled'), + 'type' => Database::VAR_BOOLEAN, + 'signed' => true, + 'size' => 0, + 'format' => '', + 'filters' => [], + 'required' => false, + 'default' => false, + 'array' => false, + ], + [ + '$id' => ID::custom('currency'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 3, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('interval'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 10, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('includedUnits'), + 'type' => Database::VAR_INTEGER, + 'format' => '', + 'size' => 0, + 'signed' => false, + 'required' => false, + 'default' => 0, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('tiersMode'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 16, + 'signed' => true, + 'required' => false, + 'default' => 'graduated', + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('tiers'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 16384, + 'signed' => true, + 'required' => false, + 'default' => [], + 'array' => false, + 'filters' => ['json'], + ], + [ + '$id' => ID::custom('usageCap'), + 'type' => Database::VAR_INTEGER, + 'format' => '', + 'size' => 0, + 'signed' => false, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('stripePriceId'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 256, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('stripeMeterId'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 256, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('active'), + 'type' => Database::VAR_BOOLEAN, + 'signed' => true, + 'size' => 0, + 'format' => '', + 'filters' => [], + 'required' => false, + 'default' => true, + 'array' => false, + ], + ], + 'indexes' => [ + [ + '$id' => ID::custom('_key_project'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['projectId'], + 'lengths' => [Database::LENGTH_KEY], + 'orders' => [Database::ORDER_ASC], + ], + [ + '$id' => ID::custom('_key_project_plan'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['projectId', 'planId'], + 'lengths' => [Database::LENGTH_KEY, Database::LENGTH_KEY], + 'orders' => [Database::ORDER_ASC, Database::ORDER_ASC], + ], + [ + '$id' => ID::custom('_key_project_plan_feature'), + 'type' => Database::INDEX_UNIQUE, + 'attributes' => ['projectId', 'planId', 'featureId'], + 'lengths' => [Database::LENGTH_KEY, Database::LENGTH_KEY, Database::LENGTH_KEY], + 'orders' => [Database::ORDER_ASC, Database::ORDER_ASC, Database::ORDER_ASC], + ], + [ + '$id' => ID::custom('_key_active'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['active'], + 'lengths' => [0], + 'orders' => [Database::ORDER_ASC], + ], + ], + ], ]; \ No newline at end of file diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 7d2f64475d..5e977c1299 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -6,6 +6,7 @@ use Appwrite\Auth\MFA\Challenge; use Appwrite\Auth\MFA\Type; use Appwrite\Auth\MFA\Type\TOTP; use Appwrite\Auth\OAuth2\Exception as OAuth2Exception; +use Appwrite\Auth\Key; use Appwrite\Auth\Phrase; use Appwrite\Auth\Validator\Password; use Appwrite\Auth\Validator\PasswordDictionary; @@ -61,6 +62,7 @@ use Utopia\Locale\Locale; use Utopia\System\System; use Utopia\Validator\ArrayList; use Utopia\Validator\Assoc; +use Utopia\Validator\Integer; use Utopia\Validator\Boolean; use Utopia\Validator\Text; use Utopia\Validator\URL; @@ -5242,6 +5244,46 @@ App::get('/v1/account/subscription') $planId = (string) $plan->getAttribute('planId'); } + $features = []; + if ($planId !== '') { + $assigned = Authorization::skip(fn () => $dbForPlatform->find('auth_plan_features', [ + Query::equal('projectId', [$project->getId()]), + Query::equal('planId', [$planId]), + Query::equal('active', [true]) + ])); + $stripeService = new \Appwrite\Auth\Subscription\StripeService( + $project->getAttribute('authStripeSecretKey'), + $dbForProject, + $project, + $dbForPlatform + ); + $customerId = (string) $user->getAttribute('stripeCustomerId', ''); + $periodStart = $user->getAttribute('subscriptionCurrentPeriodStart'); + $periodEnd = $user->getAttribute('subscriptionCurrentPeriodEnd'); + $startTs = $periodStart ? strtotime($periodStart) : 0; + $endTs = $periodEnd ? strtotime($periodEnd) : time(); + foreach ($assigned as $af) { + $featureId = (string) $af->getAttribute('featureId'); + $usage = 0; + if ((string)$af->getAttribute('type') === 'metered' && $customerId !== '' && $startTs > 0) { + try { + $usage = $stripeService->getFeatureUsageTotal($customerId, $planId, $featureId, $startTs, $endTs); + } catch (\Throwable $_) {} + } + $features[] = [ + 'featureId' => $featureId, + 'type' => (string) $af->getAttribute('type'), + 'enabled' => (bool) $af->getAttribute('enabled', true), + 'currency' => $af->getAttribute('currency'), + 'interval' => $af->getAttribute('interval'), + 'includedUnits' => (int) $af->getAttribute('includedUnits', 0), + 'tiersMode' => $af->getAttribute('tiersMode'), + 'tiers' => (array) $af->getAttribute('tiers', []), + 'usage' => $usage, + 'usageCap' => $af->getAttribute('usageCap'), + ]; + } + } $response->dynamic(new Document([ 'planId' => $planId !== '' ? $planId : null, @@ -5250,7 +5292,8 @@ App::get('/v1/account/subscription') 'currentPeriodStart' => $user->getAttribute('subscriptionCurrentPeriodStart'), 'currentPeriodEnd' => $user->getAttribute('subscriptionCurrentPeriodEnd'), 'cancelAtPeriodEnd' => $user->getAttribute('subscriptionCancelAtPeriodEnd', false), - 'trialEnd' => $user->getAttribute('subscriptionTrialEnd') + 'trialEnd' => $user->getAttribute('subscriptionTrialEnd'), + 'features' => $features ]), Response::MODEL_ANY); }); @@ -5310,11 +5353,21 @@ App::post('/v1/account/subscription/checkout') $stripeService = new \Appwrite\Auth\Subscription\StripeService( $project->getAttribute('authStripeSecretKey'), $dbForProject, - $project + $project, + $dbForPlatform ); try { - $session = $stripeService->createCheckoutSession($user, $plan->getAttribute('stripePriceId'), $successUrl, $cancelUrl, (string) $plan->getAttribute('planId')); + $additionalItems = $stripeService->buildAdditionalLineItemsForPlan($planId); + + $session = $stripeService->createCheckoutSession( + $user, + $plan->getAttribute('stripePriceId'), + $successUrl, + $cancelUrl, + (string) $plan->getAttribute('planId'), + $additionalItems + ); $response->dynamic(new Document([ 'checkoutUrl' => $session['url'] @@ -5513,3 +5566,98 @@ App::delete('/v1/account/subscription') throw new Exception(Exception::GENERAL_SERVER_ERROR, $e->getMessage()); } }); + +App::post('/v1/usage/ingest') + ->desc('Ingest metered usage for a feature') + ->groups(['api']) + ->label('scope', 'account') + ->label('sdk', new Method( + namespace: 'account', + group: 'usage', + name: 'ingestUsage', + description: '/docs/references/account/ingest-usage.md', + auth: [AuthType::KEY, AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_ANY, + ) + ] + )) + ->param('planId', '', new Text(128), 'Plan ID to attribute usage to.') + ->param('featureId', '', new Text(128), 'Feature ID to attribute usage to.') + ->param('value', 1, new Integer(), 'Usage value to ingest.') + ->param('userId', '', new UID(), 'User ID to attribute usage to. Ignored for client SDK calls.', true) + ->param('timestamp', null, new Integer(true), 'Unix timestamp for the event.', true) + ->param('identifier', null, new Text(256), 'Idempotency identifier for this event.', true) + ->inject('response') + ->inject('project') + ->inject('user') + ->inject('apiKey') + ->inject('dbForProject') + ->inject('dbForPlatform') + ->action(function ( + string $planId, + string $featureId, + int $value, + string $userId, + ?int $timestamp, + ?string $identifier, + Response $response, + Document $project, + Document $user, + ?Key $apiKey, + Database $dbForProject, + Database $dbForPlatform + ) { + if (!$project->getAttribute('authSubscriptionsEnabled')) { + throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Subscriptions not enabled for this project'); + } + + // Determine effective userId + $effectiveUserId = ''; + if (!empty($apiKey)) { + // Admin/API key: honor provided userId + if (empty($userId)) { + throw new Exception(Exception::GENERAL_BAD_REQUEST, 'userId is required when using API key'); + } + $effectiveUserId = $userId; + } else { + // Client SDK (session/JWT): ignore provided userId, use logged-in user + if ($user->isEmpty()) { + throw new Exception(Exception::USER_UNAUTHORIZED); + } + $effectiveUserId = $user->getId(); + } + + // Validate plan & feature assignment exists and is metered + $assignment = Authorization::skip(fn () => $dbForPlatform->findOne('auth_plan_features', [ + Query::equal('projectId', [$project->getId()]), + Query::equal('planId', [$planId]), + Query::equal('featureId', [$featureId]), + Query::equal('active', [true]) + ])); + if (!$assignment || $assignment->isEmpty()) { + throw new Exception(Exception::GENERAL_NOT_FOUND, 'Feature not assigned to plan'); + } + if ((string)$assignment->getAttribute('type') !== 'metered') { + throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Feature is not metered'); + } + + $stripeService = new \Appwrite\Auth\Subscription\StripeService( + $project->getAttribute('authStripeSecretKey'), + $dbForProject, + $project, + $dbForPlatform + ); + + try { + $result = $stripeService->ingestUsage($value, $effectiveUserId, $planId, $featureId, $timestamp, $identifier); + $response->dynamic(new Document([ + 'success' => true, + 'id' => $result['id'] ?? null + ]), Response::MODEL_ANY); + } catch (\Appwrite\Auth\Subscription\Exception\SubscriptionException $e) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, $e->getMessage()); + } + }); diff --git a/app/controllers/api/auth-plans.php b/app/controllers/api/auth-plans.php index f31cb64428..8f0d96497c 100644 --- a/app/controllers/api/auth-plans.php +++ b/app/controllers/api/auth-plans.php @@ -19,6 +19,7 @@ use Utopia\Validator\Boolean; use Utopia\Validator\Integer; use Utopia\Validator\Text; use Utopia\Validator\WhiteList; +use Utopia\Validator\Assoc; App::post('/v1/projects/:projectId/auth/plans') ->desc('Create auth plan') @@ -44,7 +45,7 @@ App::post('/v1/projects/:projectId/auth/plans') ->param('currency', '', new Text(3), 'Currency code (3 letters).') ->param('interval', null, new WhiteList(['month', 'year', 'week', 'day'], true), 'Billing interval.', true) ->param('description', '', new Text(256), 'Plan description.', true) - ->param('features', [], new ArrayList(new Text(256)), 'Plan features list.', true) + ->param('features', [], new ArrayList(new Assoc()), 'Plan features list.', true) ->param('maxUsers', null, new Integer(true), 'Maximum users allowed.', true) ->param('isDefault', false, new Boolean(), 'Is this the default plan.', true) ->param('isFree', false, new Boolean(), 'Is this a free plan.', true) @@ -137,6 +138,206 @@ App::post('/v1/projects/:projectId/auth/plans') $response->dynamic($plan, Response::MODEL_ANY); }); +App::get('/v1/projects/:projectId/auth/plans/:planId/features') + ->desc('List features assigned to plan') + ->groups(['api']) + ->label('scope', 'projects.read') + ->label('sdk', new Method( + namespace: 'projects', + group: 'authPlans', + name: 'listPlanFeatures', + description: '/docs/references/projects/list-plan-features.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_DOCUMENT_LIST, + ) + ] + )) + ->param('projectId', '', new UID(), 'Project unique ID.') + ->param('planId', '', new Text(128), 'Plan ID.') + ->inject('response') + ->inject('dbForPlatform') + ->action(function (string $projectId, string $planId, Response $response, Database $dbForPlatform) { + $project = $dbForPlatform->getDocument('projects', $projectId); + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + $docs = $dbForPlatform->find('auth_plan_features', [ + Query::equal('projectId', [$projectId]), + Query::equal('planId', [$planId]), + Query::equal('active', [true]) + ]); + $response->dynamic(new Document([ + 'total' => count($docs), + 'documents' => $docs + ]), Response::MODEL_DOCUMENT_LIST); + }); + +App::post('/v1/projects/:projectId/auth/plans/:planId/features') + ->desc('Assign features to plan') + ->groups(['api']) + ->label('scope', 'projects.write') + ->label('sdk', new Method( + namespace: 'projects', + group: 'authPlans', + name: 'assignPlanFeatures', + description: '/docs/references/projects/assign-plan-features.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_DOCUMENT_LIST, + ) + ] + )) + ->param('projectId', '', new UID(), 'Project unique ID.') + ->param('planId', '', new Text(128), 'Plan ID.') + ->param('features', [], new ArrayList(new Assoc()), 'Array of feature assignments.', true) + ->inject('response') + ->inject('dbForPlatform') + ->inject('dbForProject') + ->action(function ( + string $projectId, + string $planId, + array $features, + Response $response, + Database $dbForPlatform, + Database $dbForProject + ) { + $project = $dbForPlatform->getDocument('projects', $projectId); + + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + $plan = $dbForPlatform->findOne('auth_plans', [ + Query::equal('projectId', [$projectId]), + Query::equal('planId', [$planId]) + ]); + + if (!$plan) { + throw new Exception(Exception::GENERAL_NOT_FOUND, 'Plan not found'); + } + + $stripeService = null; + if ($project->getAttribute('authSubscriptionsEnabled')) { + $stripeService = new StripeService( + $project->getAttribute('authStripeSecretKey'), + $dbForProject, + $project, + $dbForPlatform + ); + } + + $created = []; + + foreach ($features as $config) { + $featureId = (string) ($config['featureId'] ?? ''); + $type = (string) ($config['type'] ?? ''); + if ($featureId === '' || ($type !== 'boolean' && $type !== 'metered')) { + continue; + } + + $feature = $dbForPlatform->findOne('auth_features', [ + Query::equal('projectId', [$projectId]), + Query::equal('featureId', [$featureId]), + Query::equal('active', [true]) + ]); + if (!$feature) { + throw new Exception(Exception::GENERAL_NOT_FOUND, 'Feature not found: ' . $featureId); + } + + $existing = $dbForPlatform->findOne('auth_plan_features', [ + Query::equal('projectId', [$projectId]), + Query::equal('planId', [$planId]), + Query::equal('featureId', [$featureId]) + ]); + + $exists = ($existing instanceof Document) && !$existing->isEmpty(); + $docId = $exists ? (string) $existing->getId() : ID::unique(); + $doc = $exists ? $existing : new Document(['$id' => $docId]); + + $doc + ->setAttribute('projectInternalId', $project->getSequence()) + ->setAttribute('projectId', $projectId) + ->setAttribute('planId', $planId) + ->setAttribute('featureId', $featureId) + ->setAttribute('type', $type) + ->setAttribute('active', true); + + if ($type === 'boolean') { + $doc->setAttribute('enabled', (bool) ($config['enabled'] ?? true)); + $doc->setAttribute('currency', null); + $doc->setAttribute('interval', null); + $doc->setAttribute('includedUnits', 0); + $doc->setAttribute('tiersMode', 'graduated'); + $doc->setAttribute('tiers', []); + $doc->setAttribute('usageCap', null); + } else { + $currency = (string) ($config['currency'] ?? $plan->getAttribute('currency')); + $interval = (string) ($config['interval'] ?? $plan->getAttribute('interval')); + $included = (int) ($config['includedUnits'] ?? 0); + $tiersMode = (string) ($config['tiersMode'] ?? 'graduated'); + $tiers = (array) ($config['tiers'] ?? []); + $usageCap = isset($config['usageCap']) ? (int)$config['usageCap'] : null; + + $doc->setAttribute('enabled', true); + $doc->setAttribute('currency', $currency); + $doc->setAttribute('interval', $interval); + $doc->setAttribute('includedUnits', $included); + $doc->setAttribute('tiersMode', $tiersMode); + $doc->setAttribute('tiers', $tiers); + $doc->setAttribute('usageCap', $usageCap); + + if ($stripeService) { + $productId = $plan->getAttribute('stripeProductId'); + if (!$productId) { + $product = $stripeService->createProduct($plan->getAttribute('name'), $plan->getAttribute('description', ''), (string) $plan->getAttribute('planId')); + $productId = $product['id']; + $plan->setAttribute('stripeProductId', $productId); + $dbForPlatform->updateDocument('auth_plans', $plan->getId(), $plan); + } + + $meterId = $stripeService->ensureMeterForFeature( + (string) $plan->getAttribute('planId'), + $featureId, + (string) $feature->getAttribute('name') + ); + if ($meterId) { + $doc->setAttribute('stripeMeterId', $meterId); + } + + $price = $stripeService->createMeteredTieredPrice( + $productId, + $currency, + $interval ?: null, + $included, + $tiersMode, + $tiers, + (string) $plan->getAttribute('planId'), + $featureId, + (string) $feature->getAttribute('name') + ); + $doc->setAttribute('stripePriceId', $price['id'] ?? null); + } + } + + if ($exists) { + $updated = $dbForPlatform->updateDocument('auth_plan_features', $docId, $doc); + $created[] = $updated; + } else { + $created[] = $dbForPlatform->createDocument('auth_plan_features', $doc); + } + } + + $response->dynamic(new Document([ + 'total' => count($created), + 'documents' => $created + ]), Response::MODEL_DOCUMENT_LIST); + }); + App::get('/v1/projects/:projectId/auth/plans') ->desc('List auth plans') ->groups(['api']) @@ -169,6 +370,37 @@ App::get('/v1/projects/:projectId/auth/plans') $queries[] = Query::equal('active', [true]); $plans = $dbForPlatform->find('auth_plans', $queries); + foreach ($plans as $plan) { + $pf = $dbForPlatform->find('auth_plan_features', [ + Query::equal('projectId', [$projectId]), + Query::equal('planId', [(string)$plan->getAttribute('planId')]), + Query::equal('active', [true]) + ]); + $features = []; + foreach ($pf as $f) { + $type = (string)$f->getAttribute('type'); + if ($type === 'boolean') { + $features[] = [ + 'featureId' => (string)$f->getAttribute('featureId'), + 'type' => 'boolean', + 'enabled' => (bool)$f->getAttribute('enabled', true) + ]; + } else { + $features[] = [ + 'featureId' => (string)$f->getAttribute('featureId'), + 'type' => 'metered', + 'currency' => $f->getAttribute('currency'), + 'interval' => $f->getAttribute('interval'), + 'includedUnits' => (int)$f->getAttribute('includedUnits', 0), + 'tiersMode' => $f->getAttribute('tiersMode'), + 'tiers' => (array)$f->getAttribute('tiers', []), + 'stripePriceId' => $f->getAttribute('stripePriceId'), + 'usageCap' => $f->getAttribute('usageCap') + ]; + } + } + $plan->setAttribute('features', $features); + } $response->dynamic(new Document([ 'total' => count($plans), @@ -214,9 +446,178 @@ App::get('/v1/projects/:projectId/auth/plans/:planId') throw new Exception(Exception::GENERAL_NOT_FOUND, 'Plan not found'); } + $pf = $dbForPlatform->find('auth_plan_features', [ + Query::equal('projectId', [$projectId]), + Query::equal('planId', [$planId]), + Query::equal('active', [true]) + ]); + $features = []; + foreach ($pf as $f) { + $type = (string)$f->getAttribute('type'); + if ($type === 'boolean') { + $features[] = [ + 'featureId' => (string)$f->getAttribute('featureId'), + 'type' => 'boolean', + 'enabled' => (bool)$f->getAttribute('enabled', true) + ]; + } else { + $features[] = [ + 'featureId' => (string)$f->getAttribute('featureId'), + 'type' => 'metered', + 'currency' => $f->getAttribute('currency'), + 'interval' => $f->getAttribute('interval'), + 'includedUnits' => (int)$f->getAttribute('includedUnits', 0), + 'tiersMode' => $f->getAttribute('tiersMode'), + 'tiers' => (array)$f->getAttribute('tiers', []), + 'stripePriceId' => $f->getAttribute('stripePriceId'), + 'usageCap' => $f->getAttribute('usageCap') + ]; + } + } + $plan->setAttribute('features', $features); + $response->dynamic($plan, Response::MODEL_ANY); }); +App::delete('/v1/projects/:projectId/auth/plans/:planId/features/:featureId') + ->desc('Delete feature assignment from plan') + ->groups(['api']) + ->label('scope', 'projects.write') + ->label('sdk', new Method( + namespace: 'projects', + group: 'authPlans', + name: 'deletePlanFeature', + description: '/docs/references/projects/delete-plan-feature.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_NOCONTENT, + model: Response::MODEL_NONE, + ) + ] + )) + ->param('projectId', '', new UID(), 'Project unique ID.') + ->param('planId', '', new Text(128), 'Plan ID.') + ->param('featureId', '', new Text(128), 'Feature ID.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('dbForProject') + ->action(function ( + string $projectId, + string $planId, + string $featureId, + Response $response, + Database $dbForPlatform, + Database $dbForProject + ) { + $project = $dbForPlatform->getDocument('projects', $projectId); + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + $assignment = $dbForPlatform->findOne('auth_plan_features', [ + Query::equal('projectId', [$projectId]), + Query::equal('planId', [$planId]), + Query::equal('featureId', [$featureId]) + ]); + + if (!$assignment) { + throw new Exception(Exception::GENERAL_NOT_FOUND, 'Plan feature not found'); + } + + if ($project->getAttribute('authSubscriptionsEnabled')) { + try { + $stripeService = new StripeService( + $project->getAttribute('authStripeSecretKey'), + $dbForProject, + $project + ); + $priceId = (string) $assignment->getAttribute('stripePriceId', ''); + if ($priceId !== '') { + $stripeService->deactivatePrice($priceId); + } + } catch (SubscriptionException $_) { + } + } + + $assignment->setAttribute('active', false); + $dbForPlatform->updateDocument('auth_plan_features', $assignment->getId(), $assignment); + + $response->noContent(); + }); + +App::delete('/v1/projects/:projectId/auth/plans/:planId/features') + ->desc('Delete multiple feature assignments from plan') + ->groups(['api']) + ->label('scope', 'projects.write') + ->label('sdk', new Method( + namespace: 'projects', + group: 'authPlans', + name: 'deletePlanFeatures', + description: '/docs/references/projects/delete-plan-features.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_DOCUMENT_LIST, + ) + ] + )) + ->param('projectId', '', new UID(), 'Project unique ID.') + ->param('planId', '', new Text(128), 'Plan ID.') + ->param('featureIds', [], new ArrayList(new Text(128)), 'Array of feature IDs to remove.', true) + ->inject('response') + ->inject('dbForPlatform') + ->inject('dbForProject') + ->action(function ( + string $projectId, + string $planId, + array $featureIds, + Response $response, + Database $dbForPlatform, + Database $dbForProject + ) { + $project = $dbForPlatform->getDocument('projects', $projectId); + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + $updated = []; + foreach ($featureIds as $fid) { + $assignment = $dbForPlatform->findOne('auth_plan_features', [ + Query::equal('projectId', [$projectId]), + Query::equal('planId', [$planId]), + Query::equal('featureId', [$fid]) + ]); + if (!$assignment) { + continue; + } + + if ($project->getAttribute('authSubscriptionsEnabled')) { + try { + $stripeService = new StripeService( + $project->getAttribute('authStripeSecretKey'), + $dbForProject, + $project + ); + $priceId = (string) $assignment->getAttribute('stripePriceId', ''); + if ($priceId !== '') { + $stripeService->deactivatePrice($priceId); + } + } catch (SubscriptionException $_) { + } + } + + $assignment->setAttribute('active', false); + $updated[] = $dbForPlatform->updateDocument('auth_plan_features', $assignment->getId(), $assignment); + } + + $response->dynamic(new Document([ + 'total' => count($updated), + 'documents' => $updated + ]), Response::MODEL_DOCUMENT_LIST); + }); + App::put('/v1/projects/:projectId/auth/plans/:planId') ->desc('Update auth plan') ->groups(['api']) @@ -238,11 +639,12 @@ App::put('/v1/projects/:projectId/auth/plans/:planId') ->param('planId', '', new Text(128), 'Plan ID.') ->param('name', null, new Text(128), 'Plan name.', true) ->param('description', null, new Text(256), 'Plan description.', true) - ->param('features', null, new ArrayList(new Text(256)), 'Plan features list.', true) + ->param('features', null, new ArrayList(new Assoc()), 'Full list of feature assignments for this plan.') ->param('maxUsers', null, new Integer(true), 'Maximum users allowed.', true) ->param('isDefault', null, new Boolean(), 'Is this the default plan.', true) ->inject('response') ->inject('dbForPlatform') + ->inject('dbForProject') ->action(function ( string $projectId, string $planId, @@ -252,7 +654,8 @@ App::put('/v1/projects/:projectId/auth/plans/:planId') ?int $maxUsers, ?bool $isDefault, Response $response, - Database $dbForPlatform + Database $dbForPlatform, + Database $dbForProject ) { $project = $dbForPlatform->getDocument('projects', $projectId); @@ -312,6 +715,218 @@ App::put('/v1/projects/:projectId/auth/plans/:planId') $plan = $dbForPlatform->updateDocument('auth_plans', $plan->getId(), $plan); + if (is_array($features)) { + $existing = $dbForPlatform->find('auth_plan_features', [ + Query::equal('projectId', [$projectId]), + Query::equal('planId', [$planId]) + ]); + $existingById = []; + foreach ($existing as $a) { + $existingById[(string)$a->getAttribute('featureId')] = $a; + } + + $providedIds = []; + foreach ($features as $cfg) { + if (!is_array($cfg)) continue; + $fid = (string)($cfg['featureId'] ?? ''); + $type = (string)($cfg['type'] ?? ''); + if ($fid === '' || ($type !== 'boolean' && $type !== 'metered')) continue; + $providedIds[] = $fid; + $assignment = $existingById[$fid] ?? null; + $doc = $assignment ?: new Document(['$id' => ID::unique()]); + $doc + ->setAttribute('projectInternalId', $project->getSequence()) + ->setAttribute('projectId', $projectId) + ->setAttribute('planId', $planId) + ->setAttribute('featureId', $fid) + ->setAttribute('type', $type) + ->setAttribute('active', true); + if ($type === 'boolean') { + $doc->setAttribute('enabled', (bool)($cfg['enabled'] ?? true)); + $doc->setAttribute('currency', null); + $doc->setAttribute('interval', null); + $doc->setAttribute('includedUnits', 0); + $doc->setAttribute('tiersMode', 'graduated'); + $doc->setAttribute('tiers', []); + $doc->setAttribute('usageCap', null); + } else { + $doc->setAttribute('enabled', true); + $doc->setAttribute('currency', (string)($cfg['currency'] ?? $plan->getAttribute('currency'))); + $doc->setAttribute('interval', (string)($cfg['interval'] ?? $plan->getAttribute('interval'))); + $doc->setAttribute('includedUnits', (int)($cfg['includedUnits'] ?? 0)); + $doc->setAttribute('tiersMode', (string)($cfg['tiersMode'] ?? 'graduated')); + $doc->setAttribute('tiers', (array)($cfg['tiers'] ?? [])); + if (array_key_exists('usageCap', $cfg)) { + $doc->setAttribute('usageCap', $cfg['usageCap'] === null ? null : (int)$cfg['usageCap']); + } + } + if ($assignment) { + if ($type === 'metered' && $project->getAttribute('authSubscriptionsEnabled')) { + try { + $ss = new StripeService( + $project->getAttribute('authStripeSecretKey'), + $dbForProject, + $project, + $dbForPlatform + ); + $productId = (string)$plan->getAttribute('stripeProductId'); + if (!$productId) { + $p = $ss->createProduct($plan->getAttribute('name'), $plan->getAttribute('description', ''), (string)$plan->getAttribute('planId')); + $productId = (string)$p['id']; + $plan->setAttribute('stripeProductId', $productId); + $dbForPlatform->updateDocument('auth_plans', $plan->getId(), $plan); + } + $feature = $dbForPlatform->findOne('auth_features', [ + Query::equal('projectId', [$projectId]), + Query::equal('featureId', [$fid]) + ]); + $featureName = $feature ? (string)$feature->getAttribute('name', $fid) : $fid; + $prev = [ + 'currency' => (string)$assignment->getAttribute('currency'), + 'interval' => (string)$assignment->getAttribute('interval'), + 'includedUnits' => (int)$assignment->getAttribute('includedUnits', 0), + 'tiersMode' => (string)$assignment->getAttribute('tiersMode'), + 'tiers' => (array)$assignment->getAttribute('tiers', []) + ]; + $next = [ + 'currency' => (string)$doc->getAttribute('currency'), + 'interval' => (string)$doc->getAttribute('interval'), + 'includedUnits' => (int)$doc->getAttribute('includedUnits', 0), + 'tiersMode' => (string)$doc->getAttribute('tiersMode'), + 'tiers' => (array)$doc->getAttribute('tiers', []) + ]; + $tiersChanged = json_encode(array_values($prev['tiers'])) !== json_encode(array_values($next['tiers'])) || ($prev['tiersMode'] !== $next['tiersMode']); + $metaChanged = $prev['currency'] !== $next['currency'] || $prev['interval'] !== $next['interval'] || $prev['includedUnits'] !== $next['includedUnits']; + $oldPriceId = (string)$assignment->getAttribute('stripePriceId', ''); + // If nothing changed, still verify on Stripe whether the existing price matches the expected shape. + $forceRecreate = false; + if ($oldPriceId !== '') { + try { + $curr = $ss->getPrice($oldPriceId); + $currMode = (string)($curr['tiers_mode'] ?? ''); + $currUsage = (string)($curr['recurring']['usage_type'] ?? ''); + $currCurrency = (string)($curr['currency'] ?? ''); + $currInterval = (string)($curr['recurring']['interval'] ?? ''); + if ($currMode !== $next['tiersMode'] || $currUsage !== 'metered' || $currCurrency !== $next['currency'] || ($next['interval'] && $currInterval !== $next['interval'])) { + $forceRecreate = true; + } + // Compare tier boundaries with expected (including includedUnits free tier) + $currTiers = isset($curr['tiers']) && is_array($curr['tiers']) ? $curr['tiers'] : []; + $currUpTo = []; + foreach ($currTiers as $t) { + $currUpTo[] = isset($t['up_to']) ? (string)$t['up_to'] : ''; + } + $expectedUpTo = []; + $inc = (int)$next['includedUnits']; + if ($inc > 0) { + $expectedUpTo[] = (string)$inc; + } + foreach ((array)$next['tiers'] as $tierX) { + $to = $tierX['to'] ?? 'inf'; + $expectedUpTo[] = $to === 'inf' ? 'inf' : (string)$to; + } + if (json_encode($currUpTo) !== json_encode($expectedUpTo)) { + $forceRecreate = true; + } + // Ensure free tier is truly free if includedUnits > 0 + if ($inc > 0 && isset($currTiers[0]['unit_amount']) && (int)$currTiers[0]['unit_amount'] !== 0) { + $forceRecreate = true; + } + error_log(json_encode(['plan_put' => 'remote_check', 'featureId' => $fid, 'currUpTo' => $currUpTo, 'expectedUpTo' => $expectedUpTo, 'forceRecreate' => $forceRecreate])); + } catch (SubscriptionException $e) { + $forceRecreate = true; + } + } + if ($oldPriceId === '' || $tiersChanged || $metaChanged || $forceRecreate) { + $meterId = $ss->ensureMeterForFeature((string)$plan->getAttribute('planId'), $fid, $featureName); + if ($meterId) { + $doc->setAttribute('stripeMeterId', $meterId); + } + $newPrice = $ss->createMeteredTieredPrice( + $productId, + $next['currency'], + $next['interval'] ?: null, + $next['includedUnits'], + $next['tiersMode'], + $next['tiers'], + (string)$plan->getAttribute('planId'), + $fid, + $featureName + ); + $doc->setAttribute('stripePriceId', $newPrice['id'] ?? null); + if ($oldPriceId !== '') { + $ss->deactivatePrice($oldPriceId); + } + } + } catch (SubscriptionException $e) { + throw $e; + } + } + $dbForPlatform->updateDocument('auth_plan_features', $assignment->getId(), $doc); + } else { + if ($type === 'metered' && $project->getAttribute('authSubscriptionsEnabled')) { + try { + $ss = new StripeService( + $project->getAttribute('authStripeSecretKey'), + $dbForProject, + $project, + $dbForPlatform + ); + $productId = (string)$plan->getAttribute('stripeProductId'); + if (!$productId) { + $p = $ss->createProduct($plan->getAttribute('name'), $plan->getAttribute('description', ''), (string)$plan->getAttribute('planId')); + $productId = (string)$p['id']; + $plan->setAttribute('stripeProductId', $productId); + $dbForPlatform->updateDocument('auth_plans', $plan->getId(), $plan); + } + $feature = $dbForPlatform->findOne('auth_features', [ + Query::equal('projectId', [$projectId]), + Query::equal('featureId', [$fid]) + ]); + $featureName = $feature ? (string)$feature->getAttribute('name', $fid) : $fid; + $meterId = $ss->ensureMeterForFeature((string)$plan->getAttribute('planId'), $fid, $featureName); + if ($meterId) { + $doc->setAttribute('stripeMeterId', $meterId); + } + $newPrice = $ss->createMeteredTieredPrice( + $productId, + (string)$doc->getAttribute('currency'), + (string)$doc->getAttribute('interval') ?: null, + (int)$doc->getAttribute('includedUnits', 0), + (string)$doc->getAttribute('tiersMode'), + (array)$doc->getAttribute('tiers', []), + (string)$plan->getAttribute('planId'), + $fid, + $featureName + ); + $doc->setAttribute('stripePriceId', $newPrice['id'] ?? null); + } catch (SubscriptionException $e) { + throw $e; + } + } + $dbForPlatform->createDocument('auth_plan_features', $doc); + } + } + + foreach ($existingById as $fid => $assignment) { + if (!in_array($fid, $providedIds, true) && $assignment->getAttribute('active', true)) { + if ($project->getAttribute('authSubscriptionsEnabled')) { + try { + $ss = new StripeService( + $project->getAttribute('authStripeSecretKey'), + $dbForProject, + $project + ); + $pid = (string)$assignment->getAttribute('stripePriceId', ''); + if ($pid !== '') $ss->deactivatePrice($pid); + } catch (SubscriptionException $_) {} + } + $assignment->setAttribute('active', false); + $dbForPlatform->updateDocument('auth_plan_features', $assignment->getId(), $assignment); + } + } + } + $response->dynamic($plan, Response::MODEL_ANY); }); diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 4ee15466a5..0eda32990d 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -54,6 +54,7 @@ use Utopia\Validator\Range; use Utopia\Validator\Text; use Utopia\Validator\URL; use Utopia\Validator\WhiteList; +use Utopia\Validator\Assoc; App::init() ->groups(['projects']) @@ -64,6 +65,201 @@ App::init() } }); +App::post('/v1/projects/:projectId/auth/features') + ->desc('Create auth feature') + ->groups(['api']) + ->label('scope', 'projects.write') + ->label('sdk', new Method( + namespace: 'projects', + group: 'authFeatures', + name: 'createAuthFeature', + description: '/docs/references/projects/create-auth-feature.md', + auth: [AuthType::ADMIN, AuthType::SESSION], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_CREATED, + model: Response::MODEL_ANY, + ) + ] + )) + ->param('projectId', '', new UID(), 'Project unique ID.') + ->param('featureId', '', new Text(128), 'Feature unique ID.') + ->param('name', '', new Text(128), 'Feature name.') + ->param('type', '', new WhiteList(['boolean', 'metered']), 'Feature type: boolean or metered') + ->param('description', '', new Text(256), 'Feature description.', true) + ->inject('response') + ->inject('dbForPlatform') + ->action(function ( + string $projectId, + string $featureId, + string $name, + string $type, + string $description, + Response $response, + Database $dbForPlatform + ) { + $project = $dbForPlatform->getDocument('projects', $projectId); + + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + $existing = $dbForPlatform->findOne('auth_features', [ + Query::equal('projectId', [$projectId]), + Query::equal('featureId', [$featureId]) + ]); + + if ($existing instanceof Document && !$existing->isEmpty()) { + throw new Exception(Exception::RESOURCE_ALREADY_EXISTS, 'Feature already exists'); + } + + $doc = $dbForPlatform->createDocument('auth_features', new Document([ + '$id' => ID::unique(), + 'projectInternalId' => $project->getSequence(), + 'projectId' => $projectId, + 'featureId' => $featureId, + 'name' => $name, + 'type' => $type, + 'description' => $description, + 'active' => true, + 'search' => implode(' ', [$featureId, $name, $description]) + ])); + + $response->setStatusCode(Response::STATUS_CODE_CREATED); + $response->dynamic($doc, Response::MODEL_ANY); + }); + +App::get('/v1/projects/:projectId/auth/features') + ->desc('List auth features') + ->groups(['api']) + ->label('scope', 'projects.read') + ->label('sdk', new Method( + namespace: 'projects', + group: 'authFeatures', + name: 'listAuthFeatures', + description: '/docs/references/projects/list-auth-features.md', + auth: [AuthType::ADMIN, AuthType::SESSION], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_DOCUMENT_LIST, + ) + ] + )) + ->param('projectId', '', new UID(), 'Project unique ID.') + ->param('queries', [], new ArrayList(new Text(256)), 'Array of query strings.', true) + ->inject('response') + ->inject('dbForPlatform') + ->action(function (string $projectId, array $queries, Response $response, Database $dbForPlatform) { + $project = $dbForPlatform->getDocument('projects', $projectId); + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + $queries[] = Query::equal('projectId', [$projectId]); + $queries[] = Query::equal('active', [true]); + $docs = $dbForPlatform->find('auth_features', $queries); + $response->dynamic(new Document([ + 'total' => count($docs), + 'documents' => $docs + ]), Response::MODEL_DOCUMENT_LIST); + }); + +App::put('/v1/projects/:projectId/auth/features/:featureId') + ->desc('Update auth feature') + ->groups(['api']) + ->label('scope', 'projects.write') + ->label('sdk', new Method( + namespace: 'projects', + group: 'authFeatures', + name: 'updateAuthFeature', + description: '/docs/references/projects/update-auth-feature.md', + auth: [AuthType::ADMIN, AuthType::SESSION], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_ANY, + ) + ] + )) + ->param('projectId', '', new UID(), 'Project unique ID.') + ->param('featureId', '', new Text(128), 'Feature unique ID.') + ->param('name', null, new Text(128), 'Feature name.', true) + ->param('type', null, new WhiteList(['boolean', 'metered'], true), 'Feature type.', true) + ->param('description', null, new Text(256), 'Feature description.', true) + ->param('active', null, new Boolean(), 'Active state.', true) + ->inject('response') + ->inject('dbForPlatform') + ->action(function ( + string $projectId, + string $featureId, + ?string $name, + ?string $type, + ?string $description, + ?bool $active, + Response $response, + Database $dbForPlatform + ) { + $project = $dbForPlatform->getDocument('projects', $projectId); + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + $doc = $dbForPlatform->findOne('auth_features', [ + Query::equal('projectId', [$projectId]), + Query::equal('featureId', [$featureId]) + ]); + if (!$doc) { + throw new Exception(Exception::GENERAL_NOT_FOUND, 'Feature not found'); + } + if ($name !== null) $doc->setAttribute('name', $name); + if ($type !== null) $doc->setAttribute('type', $type); + if ($description !== null) $doc->setAttribute('description', $description); + if ($active !== null) $doc->setAttribute('active', $active); + $doc->setAttribute('search', implode(' ', [ + $doc->getAttribute('featureId'), + $doc->getAttribute('name'), + $doc->getAttribute('description') + ])); + $doc = $dbForPlatform->updateDocument('auth_features', $doc->getId(), $doc); + $response->dynamic($doc, Response::MODEL_ANY); + }); + +App::delete('/v1/projects/:projectId/auth/features/:featureId') + ->desc('Delete auth feature') + ->groups(['api']) + ->label('scope', 'projects.write') + ->label('sdk', new Method( + namespace: 'projects', + group: 'authFeatures', + name: 'deleteAuthFeature', + description: '/docs/references/projects/delete-auth-feature.md', + auth: [AuthType::ADMIN, AuthType::SESSION], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_NOCONTENT, + model: Response::MODEL_NONE, + ) + ] + )) + ->param('projectId', '', new UID(), 'Project unique ID.') + ->param('featureId', '', new Text(128), 'Feature unique ID.') + ->inject('response') + ->inject('dbForPlatform') + ->action(function (string $projectId, string $featureId, Response $response, Database $dbForPlatform) { + $project = $dbForPlatform->getDocument('projects', $projectId); + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + $doc = $dbForPlatform->findOne('auth_features', [ + Query::equal('projectId', [$projectId]), + Query::equal('featureId', [$featureId]) + ]); + if (!$doc) { + throw new Exception(Exception::GENERAL_NOT_FOUND, 'Feature not found'); + } + $dbForPlatform->deleteDocument('auth_features', $doc->getId()); + $response->noContent(); + }); + App::post('/v1/projects') ->desc('Create project') ->groups(['api', 'projects']) @@ -2691,9 +2887,9 @@ App::put('/v1/projects/:projectId/auth/subscriptions') \Utopia\Database\Query::equal('projectId', [$projectId]), \Utopia\Database\Query::equal('isDefault', [true]) ]); - if (!$existingDefault) { + if (!($existingDefault instanceof \Utopia\Database\Document) || $existingDefault->isEmpty()) { $dbForPlatform->createDocument('auth_plans', new \Utopia\Database\Document([ - '$id' => null, + '$id' => \Utopia\Database\Helpers\ID::unique(), 'projectInternalId' => $project->getSequence(), 'projectId' => $projectId, 'planId' => 'free', diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index c54c4ed186..3a525afdf4 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -742,28 +742,73 @@ App::get('/v1/users/:userId') $pid = (string) $user->getAttribute('planId', ''); $plan = null; if ($pid !== '') { - $plan = $dbForPlatform->findOne('auth_plans', [ + $candidate = $dbForPlatform->findOne('auth_plans', [ Query::equal('projectId', [$project->getId()]), Query::equal('planId', [$pid]), Query::equal('active', [true]) ]); + $plan = ($candidate instanceof \Utopia\Database\Document && !$candidate->isEmpty()) ? $candidate : null; } else { - $plan = $dbForPlatform->findOne('auth_plans', [ + $candidate = $dbForPlatform->findOne('auth_plans', [ Query::equal('projectId', [$project->getId()]), Query::equal('isDefault', [true]), Query::equal('active', [true]) ]); + $plan = ($candidate instanceof \Utopia\Database\Document && !$candidate->isEmpty()) ? $candidate : null; } - if ($plan) { - $user->setAttribute('plan', [ - 'id' => $plan->getAttribute('planId'), - 'name' => $plan->getAttribute('name'), - 'price' => $plan->getAttribute('price'), - 'currency' => $plan->getAttribute('currency'), - 'interval' => $plan->getAttribute('interval'), - 'isFree' => $plan->getAttribute('isFree', false) - ]); - } + // if ($plan instanceof \Utopia\Database\Document && !$plan->isEmpty()) { + // $features = []; + // $resolvedPlanId = (string) $plan->getAttribute('planId'); + // $assigned = []; + // if ($resolvedPlanId !== '') { + // $assigned = Authorization::skip(fn () => $dbForPlatform->find('auth_plan_features', [ + // Query::equal('projectId', [$project->getId()]), + // Query::equal('planId', [$resolvedPlanId]), + // Query::equal('active', [true]) + // ])); + // } + // $stripeService = new \Appwrite\Auth\Subscription\StripeService( + // $project->getAttribute('authStripeSecretKey'), + // $dbForProject, + // $project, + // $dbForPlatform + // ); + // $customerId = (string) $user->getAttribute('stripeCustomerId', ''); + // $periodStart = $user->getAttribute('subscriptionCurrentPeriodStart'); + // $periodEnd = $user->getAttribute('subscriptionCurrentPeriodEnd'); + // $startTs = $periodStart ? strtotime($periodStart) : 0; + // $endTs = $periodEnd ? strtotime($periodEnd) : time(); + // foreach ($assigned as $af) { + // $featureId = (string) $af->getAttribute('featureId'); + // $usage = 0; + // if ((string)$af->getAttribute('type') === 'metered' && $customerId !== '' && $startTs > 0) { + // try { + // $usage = $stripeService->getFeatureUsageTotal($customerId, $resolvedPlanId, $featureId, $startTs, $endTs); + // } catch (\Throwable $_) {} + // } + // $features[] = [ + // 'featureId' => (string) $af->getAttribute('featureId'), + // 'type' => (string) $af->getAttribute('type'), + // 'enabled' => (bool) $af->getAttribute('enabled', true), + // 'currency' => $af->getAttribute('currency'), + // 'interval' => $af->getAttribute('interval'), + // 'includedUnits' => (int) $af->getAttribute('includedUnits', 0), + // 'tiersMode' => $af->getAttribute('tiersMode'), + // 'tiers' => (array) $af->getAttribute('tiers', []), + // 'usage' => $usage, + // 'usageCap' => $af->getAttribute('usageCap'), + // ]; + // } + // $user->setAttribute('plan', [ + // 'id' => $plan->getAttribute('planId'), + // 'name' => $plan->getAttribute('name'), + // 'price' => $plan->getAttribute('price'), + // 'currency' => $plan->getAttribute('currency'), + // 'interval' => $plan->getAttribute('interval'), + // 'isFree' => $plan->getAttribute('isFree', false), + // 'features' => $features + // ]); + // } $response->dynamic($user, Response::MODEL_USER); }); diff --git a/src/Appwrite/Auth/Subscription/StripeService.php b/src/Appwrite/Auth/Subscription/StripeService.php index 8428cf5743..00905748a3 100644 --- a/src/Appwrite/Auth/Subscription/StripeService.php +++ b/src/Appwrite/Auth/Subscription/StripeService.php @@ -182,6 +182,132 @@ class StripeService return $response; } + public function createMeteredTieredPrice( + string $productId, + string $currency, + ?string $interval, + int $includedUnits, + string $tiersMode, + array $tiers, + ?string $planId, + ?string $featureId, + ?string $featureName + ): array { + $meterId = $this->ensureMeterForFeature($planId, $featureId, $featureName); + $stripeTiers = []; + + $remainingIncluded = max(0, $includedUnits); + if ($remainingIncluded > 0) { + $stripeTiers[] = [ + 'up_to' => $remainingIncluded, + 'unit_amount' => 0, + ]; + } + + foreach ($tiers as $tier) { + $to = $tier['to'] ?? null; // integer or 'inf' + $unitAmountMajor = (int) ($tier['unitAmount'] ?? 0); + $unitAmount = $this->toMinorUnits($unitAmountMajor, $currency); + $flatAmount = isset($tier['flatAmount']) ? (int) $tier['flatAmount'] : null; + + $entry = [ + 'up_to' => $to === null ? 'inf' : $to, + ]; + if ($unitAmount > 0) { + $entry['unit_amount'] = $unitAmount; + } else { + $entry['unit_amount'] = 0; + } + if ($flatAmount !== null) { + $entry['flat_amount'] = $this->toMinorUnits($flatAmount, $currency); + } + $stripeTiers[] = $entry; + } + + $params = [ + 'product' => $productId, + 'currency' => $currency, + 'billing_scheme' => 'tiered', + 'tiers_mode' => $tiersMode === 'volume' ? 'volume' : 'graduated', + 'tiers' => $stripeTiers, + 'recurring' => array_filter([ + 'interval' => $interval, + 'usage_type' => 'metered', + 'meter' => $meterId, + ]), + 'nickname' => $featureName ? ('Feature: ' . $featureName) : null, + 'metadata' => array_filter([ + 'project_id' => $this->project->getId(), + 'plan_id' => $planId, + 'feature_id' => $featureId, + 'type' => 'auth_plan_feature_price' + ]) + ]; + + $response = $this->makeRequest('POST', '/prices', $params); + + if (isset($response['error'])) { + throw new SubscriptionException($response['error']['message'] ?? 'Failed to create metered price'); + } + + return $response; + } + + private function toMinorUnits(int $major, string $currency): int + { + $currency = strtolower($currency); + $zeroDecimal = [ + 'bif','clp','djf','gnf','jpy','kmf','krw','mga','pyg','rwf','ugx','vnd','vuv','xaf','xof','xpf' + ]; + if (in_array($currency, $zeroDecimal, true)) { + return $major; + } + return $major * 100; + } + + public function ensureMeterForFeature(?string $planId, ?string $featureId, ?string $featureName): string + { + if ($this->platformDb === null || !$planId || !$featureId) { + return ''; + } + $existing = $this->platformDb->findOne('auth_plan_features', [ + Query::equal('projectId', [$this->project->getId()]), + Query::equal('planId', [$planId]), + Query::equal('featureId', [$featureId]) + ]); + if ($existing && (string)$existing->getAttribute('stripeMeterId', '') !== '') { + return (string)$existing->getAttribute('stripeMeterId'); + } + $eventName = 'appwrite.auth.feature.usage.' . $this->project->getId() . '.' . $planId . '.' . $featureId; + + $list = $this->makeRequest('GET', '/billing/meters', [ 'limit' => 100 ]); + if (isset($list['data']) && is_array($list['data'])) { + foreach ($list['data'] as $m) { + if (($m['event_name'] ?? '') === $eventName) { + $id = (string) ($m['id'] ?? ''); + if ($id !== '' && ($m['active'] ?? true) === false) { + $this->makeRequest('POST', '/billing/meters/' . $id, ['active' => 'true']); + } + return $id; + } + } + } + + $meter = $this->makeRequest('POST', '/billing/meters', [ + 'display_name' => ($featureName ? ($featureName . ' usage') : 'Auth feature usage'), + 'event_name' => $eventName, + 'default_aggregation' => [ 'formula' => 'sum' ], + 'value_settings' => [ 'event_payload_key' => 'value' ], + 'customer_mapping' => [ 'type' => 'by_id', 'event_payload_key' => 'stripe_customer_id' ] + ]); + + if (isset($meter['error'])) { + throw new SubscriptionException($meter['error']['message'] ?? 'Failed to create meter'); + } + + return (string)($meter['id'] ?? ''); + } + /** * Deactivate a Stripe price * @throws SubscriptionException @@ -191,6 +317,15 @@ class StripeService return $this->makeRequest('POST', '/prices/' . $priceId, ['active' => 'false']); } + /** + * Get a Stripe price + * @throws SubscriptionException + */ + public function getPrice(string $priceId): array + { + return $this->makeRequest('GET', '/prices/' . $priceId); + } + /** * Deactivate a Stripe product * @throws SubscriptionException @@ -204,16 +339,39 @@ class StripeService * Create checkout session * @throws SubscriptionException */ - public function createCheckoutSession(Document $user, string $priceId, string $successUrl, string $cancelUrl, ?string $planId = null): array + public function createCheckoutSession(Document $user, string $priceId, string $successUrl, string $cancelUrl, ?string $planId = null, array $additionalItems = []): array { - $params = [ + $mainItem = [ 'price' => $priceId ]; + try { + $price = $this->getPrice($priceId); + $usageType = (string)($price['recurring']['usage_type'] ?? ''); + if ($usageType !== 'metered') { + $mainItem['quantity'] = 1; + } + } catch (SubscriptionException $e) { + $mainItem['quantity'] = 1; + } + + $lineItems = [ 'mode' => 'subscription', - 'line_items' => [ - [ - 'price' => $priceId, - 'quantity' => 1 - ] - ], + 'line_items' => [ $mainItem ], + ]; + + foreach ($additionalItems as $item) { + if (!is_array($item)) continue; + $entry = []; + if (!empty($item['price'])) { + $entry['price'] = $item['price']; + } + if (!empty($item['quantity'])) { + $entry['quantity'] = (int) $item['quantity']; + } + if (!empty($entry)) { + $lineItems['line_items'][] = $entry; + } + } + + $params = $lineItems + [ 'success_url' => $successUrl, 'cancel_url' => $cancelUrl, 'customer_email' => $user->getAttribute('email'), @@ -246,6 +404,35 @@ class StripeService return $response; } + public function buildAdditionalLineItemsForPlan(string $planId): array + { + if ($this->platformDb === null) { + return []; + } + + $items = []; + $assigned = Authorization::skip(fn () => $this->platformDb->find('auth_plan_features', [ + Query::equal('projectId', [$this->project->getId()]), + Query::equal('planId', [$planId]), + Query::equal('active', [true]) + ])); + + foreach ($assigned as $af) { + $type = (string) $af->getAttribute('type'); + $priceId = (string) $af->getAttribute('stripePriceId'); + if ($priceId === '') { + continue; + } + if ($type === 'metered') { + $items[] = [ 'price' => $priceId ]; + continue; + } + $items[] = [ 'price' => $priceId, 'quantity' => 1 ]; + } + + return $items; + } + /** * Create customer portal session * @throws SubscriptionException @@ -290,6 +477,140 @@ class StripeService return (string) ($response['id'] ?? ''); } + /** + * Ingest usage for a metered feature + * @throws SubscriptionException + */ + public function ingestUsage(int $value, string $userId, string $planId, string $featureId, ?int $timestamp = null, ?string $identifier = null): array + { + // Resolve user and ensure Stripe customer id + $user = Authorization::skip(fn () => $this->database->getDocument('users', $userId)); + if ($user->isEmpty()) { + throw new SubscriptionException('User not found'); + } + + $customerId = (string) $user->getAttribute('stripeCustomerId', ''); + if ($customerId === '') { + // Create customer if missing + $customerId = $this->ensureCustomer($user); + if ($customerId === '') { + throw new SubscriptionException('Failed to ensure Stripe customer for user'); + } + $user = $user->setAttribute('stripeCustomerId', $customerId); + Authorization::skip(fn () => $this->database->updateDocument('users', $userId, $user)); + } + + // Enforce usage caps if defined on the plan feature assignment + if ($this->platformDb) { + $assignment = Authorization::skip(fn () => $this->platformDb->findOne('auth_plan_features', [ + Query::equal('projectId', [$this->project->getId()]), + Query::equal('planId', [$planId]), + Query::equal('featureId', [$featureId]), + Query::equal('active', [true]) + ])); + if ($assignment && !$assignment->isEmpty()) { + $cap = $assignment->getAttribute('usageCap'); + if ($cap !== null) { + $periodStart = $user->getAttribute('subscriptionCurrentPeriodStart'); + $periodEnd = $user->getAttribute('subscriptionCurrentPeriodEnd'); + $startTs = $periodStart ? strtotime((string)$periodStart) : 0; + $endTs = $periodEnd ? strtotime((string)$periodEnd) : time(); + $current = 0; + try { + $current = $this->getFeatureUsageTotal($customerId, $planId, $featureId, $startTs, $endTs); + } catch (\Throwable $_) {} + if ($current + $value > (int)$cap) { + throw new SubscriptionException('Usage cap exceeded for feature: ' . $featureId); + } + } + } + } + + // Build event name based on the provisioned meter convention + $eventName = 'appwrite.auth.feature.usage.' . $this->project->getId() . '.' . $planId . '.' . $featureId; + + $params = [ + 'event_name' => $eventName, + 'payload' => [ + 'value' => (string) $value, + 'stripe_customer_id' => $customerId, + ], + ]; + if (!empty($identifier)) { + $params['identifier'] = $identifier; + } + if (!empty($timestamp)) { + $params['timestamp'] = (string) $timestamp; + } + + $response = $this->makeRequest('POST', '/billing/meter_events', $params); + + if (isset($response['error'])) { + throw new SubscriptionException($response['error']['message'] ?? 'Failed to ingest usage'); + } + + return $response; + } + + /** + * Get total usage for a feature for a customer within a time window + * Returns integer total (sum) if available, otherwise 0. + * @throws SubscriptionException + */ + public function getFeatureUsageTotal(string $customerId, string $planId, string $featureId, int $startTime, int $endTime): int + { + if ($customerId === '' || $startTime <= 0 || $endTime <= 0) { + return 0; + } + + // Find meter id from platform DB + if ($this->platformDb === null) { + return 0; + } + $assigned = Authorization::skip(fn () => $this->platformDb->findOne('auth_plan_features', [ + Query::equal('projectId', [$this->project->getId()]), + Query::equal('planId', [$planId]), + Query::equal('featureId', [$featureId]), + Query::equal('active', [true]) + ])); + if (!$assigned || $assigned->isEmpty()) { + return 0; + } + $meterId = (string) $assigned->getAttribute('stripeMeterId', ''); + if ($meterId === '') { + return 0; + } + + $params = [ + 'meter' => $meterId, + 'customer' => $customerId, + 'start_time' => (string) $startTime, + 'end_time' => (string) $endTime, + ]; + + // Stripe may expose this endpoint as meter_event_summaries + $summary = $this->makeRequest('GET', '/billing/meter_event_summaries', $params); + if (isset($summary['error'])) { + return 0; + } + // Attempt to parse common shapes + if (isset($summary['data']) && is_array($summary['data'])) { + $total = 0; + foreach ($summary['data'] as $row) { + $val = (int) ($row['value'] ?? 0); + $total += $val; + } + return $total; + } + if (isset($summary['total'])) { + return (int) $summary['total']; + } + if (isset($summary['value'])) { + return (int) $summary['value']; + } + return 0; + } + /** * Create a complimentary subscription (trial for one interval, auto-cancel) */ @@ -510,7 +831,7 @@ class StripeService Query::equal('stripeProductId', [$stripeProductId]), Query::equal('projectId', [$this->project->getId()]) ]); - if (!$plan) return; + if ($plan->isEmpty()) return; $plan->setAttribute('name', $product['name'] ?? $plan->getAttribute('name')); if (isset($product['active']) && $product['active'] === false) { // If product deactivated, mark plan inactive @@ -531,7 +852,7 @@ class StripeService Query::equal('stripeProductId', [$stripeProductId]), Query::equal('projectId', [$this->project->getId()]) ]); - if ($plan) { + if (!$plan->isEmpty()) { $this->platformDb->deleteDocument('auth_plans', $plan->getId()); } } @@ -548,7 +869,7 @@ class StripeService Query::equal('stripePriceId', [$stripePriceId]), Query::equal('projectId', [$this->project->getId()]) ]); - if (!$plan) return; + if ($plan->isEmpty()) return; if (isset($price['active']) && $price['active'] === false) { $plan->setAttribute('active', false); } @@ -576,7 +897,7 @@ class StripeService Query::equal('stripePriceId', [$stripePriceId]), Query::equal('projectId', [$this->project->getId()]) ]); - if ($plan) { + if (!$plan->isEmpty()) { $this->platformDb->deleteDocument('auth_plans', $plan->getId()); } } @@ -774,6 +1095,7 @@ class StripeService $url = $this->apiUrl . $path; + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Authorization: Bearer ' . $this->apiKey, diff --git a/tmp_check_geodb.php b/tmp_check_geodb.php new file mode 100644 index 0000000000..ea34cd8432 --- /dev/null +++ b/tmp_check_geodb.php @@ -0,0 +1,19 @@ +get($ip); + echo $ip, ' => '; + var_export($record); + echo PHP_EOL; + } catch (\Throwable $e) { + echo $ip, ' error: ', get_class($e), ' ', $e->getMessage(), PHP_EOL; + } +}