mirror of
https://github.com/appwrite/appwrite.git
synced 2026-05-26 13:51:13 +00:00
progress
This commit is contained in:
@@ -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=
|
||||
_APP_AUTH_STRIPE_WEBHOOK_URL=https://12ee42ab449a.ngrok-free.app
|
||||
@@ -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.
|
||||
+378
-20
@@ -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<object> | 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<object> | 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<string> | 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
|
||||
|
||||
|
||||
@@ -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],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -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());
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
require __DIR__ . '/vendor/autoload.php';
|
||||
$reader = new MaxMind\Db\Reader(__DIR__ . '/app/assets/dbip/dbip-country-lite-2024-09.mmdb');
|
||||
foreach ([
|
||||
'127.0.0.1',
|
||||
'::1',
|
||||
'192.168.1.1',
|
||||
'1.1.1.1',
|
||||
'8.8.8.8',
|
||||
] as $ip) {
|
||||
try {
|
||||
$record = $reader->get($ip);
|
||||
echo $ip, ' => ';
|
||||
var_export($record);
|
||||
echo PHP_EOL;
|
||||
} catch (\Throwable $e) {
|
||||
echo $ip, ' error: ', get_class($e), ' ', $e->getMessage(), PHP_EOL;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user