init payments

This commit is contained in:
Atharva Deosthale
2025-09-29 16:44:11 +05:30
parent f28bb9345b
commit be1e681aa8
27 changed files with 8999 additions and 1623 deletions
+2 -1
View File
@@ -123,4 +123,5 @@ _APP_MESSAGE_PUSH_TEST_DSN=
_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_STATS_USAGE_DUAL_WRITING_DBS=database_db_main
_APP_AUTH_STRIPE_WEBHOOK_URL=https://1f9644c53fc9.ngrok-free.app/v1/webhooks/stripe/auth/:projectId
+237
View File
@@ -0,0 +1,237 @@
This is Appwrite backend. I want to develop a new feature into it.
This feature is inspired by Clerk Billing and BetterAuth Payment Plugins, where your auth is associated with plans. You can add plans and set a price for them. The users can then just purchase the plan.
I want to add such functionality to the Auth product of Appwrite. The setup for a project should be to input a Stripe secret key, which will then set the stripe account up with necessary stuff, like webhooks, etc. Store this key in the DB, I don't know where such stuff is stored about the project, but find it.
Then, setup a webhook using the stripe API and point towards a webhook endpoint that you'll create. Process should be automatic and frictionless.
The the project owner should be able to set plans. Plan should have options like plan name, ID, default (true/false) which should be true for the first one and if default is true, everyone gets the plan if it's the free one. First plan should be the free one.
The products should immediately be created on Stripe. Keep track of product ID in internal DB.
Then user can add more plans. The currency should be auto fetched using the key.
Then add API routes for people to create checkout URLs to subscribe.
Make sure you have CRUD because this needs to later reflect on console. Every route you create, create a schema in a TEMP_SCHEMA.md file.
Write no comments in code.
No summaries at end of message
## IMPLEMENTATION PLAN
### Phase 1: Database Schema & Collections
1. **Extend Projects Collection**
- Add subscription configuration to existing projects collection
- New fields in `app/config/collections/projects.php`:
- `authStripeSecretKey`: Encrypted Stripe secret key
- `authStripePublishableKey`: Stripe publishable key
- `authStripeWebhookSecret`: Webhook endpoint secret
- `authStripeWebhookEndpointId`: Stripe webhook endpoint ID
- `authStripeCurrency`: Default currency (auto-detected from Stripe account)
- `authSubscriptionsEnabled`: Boolean flag
2. **Auth Plans Collection** (`auth_plans`)
- Store plan definitions tied to auth
- Fields:
- `projectId`: Reference to project
- `planId`: Unique plan identifier
- `name`: Plan display name
- `description`: Plan description
- `stripePriceId`: Stripe price ID
- `stripeProductId`: Stripe product ID
- `price`: Amount in cents
- `currency`: Currency code
- `interval`: billing interval (month/year)
- `features`: JSON array of features
- `isDefault`: Boolean (first plan defaults to true)
- `isFree`: Boolean (free tier flag)
- `maxUsers`: User limit (null for unlimited)
- `active`: Boolean status
- `createdAt`, `updatedAt`: Timestamps
3. **Extend Users Collection**
- Add subscription fields to existing users collection
- New fields:
- `planId`: Current plan ID
- `stripeCustomerId`: Stripe customer ID
- `stripeSubscriptionId`: Stripe subscription ID
- `subscriptionStatus`: Status (active/canceled/past_due/trialing/none)
- `subscriptionCurrentPeriodStart`: Period start
- `subscriptionCurrentPeriodEnd`: Period end
- `subscriptionCancelAtPeriodEnd`: Boolean for pending cancellation
- `subscriptionTrialEnd`: Trial end date (if applicable)
### Phase 2: API Routes Structure (Integrated into Auth)
1. **Project Auth Configuration** (extend `/v1/projects/{projectId}`)
- `PUT /auth/subscriptions` - Configure Stripe for auth subscriptions
- `GET /auth/subscriptions` - Get subscription configuration
- `DELETE /auth/subscriptions` - Remove subscription configuration
2. **Auth Plans Management** (`/v1/projects/{projectId}/auth/plans`)
- `POST /` - Create new auth plan (auto-creates Stripe product/price)
- `GET /` - List all auth plans
- `GET /{planId}` - Get specific plan details
- `PUT /{planId}` - Update plan (metadata only)
- `DELETE /{planId}` - Deactivate plan
3. **User Auth Subscription** (extend `/v1/account` and `/v1/users`)
- `GET /subscription` - Get current user's subscription
- `POST /subscription/checkout` - Create Stripe checkout session URL
- `POST /subscription/portal` - Create customer portal session URL
- `PUT /subscription` - Update subscription (change plan)
- `DELETE /subscription` - Cancel subscription
4. **Webhook Handler** (`/v1/webhooks/stripe/auth`)
- `POST /` - Handle Stripe webhook events for auth subscriptions
- Events to handle:
- `checkout.session.completed`
- `customer.subscription.created`
- `customer.subscription.updated`
- `customer.subscription.deleted`
- `invoice.payment_failed`
- `invoice.payment_succeeded`
### Phase 3: Core Implementation Components
1. **Auth Subscription Service** (`src/Appwrite/Auth/Subscription/StripeService.php`)
- Manage Stripe API interactions for auth subscriptions
- Methods:
- `initializeAccount()` - Setup webhook, validate key
- `createProduct()` - Create Stripe product
- `createPrice()` - Create Stripe price
- `createCheckoutSession()` - Generate checkout URL
- `createPortalSession()` - Generate customer portal URL
- `handleWebhook()` - Process webhook events
- `syncSubscriptionStatus()` - Update user subscription state
2. **Auth Subscription Validators**
- `src/Appwrite/Auth/Validator/StripeKey.php` - Validate Stripe keys
- `src/Appwrite/Auth/Validator/PlanData.php` - Validate plan creation
- `src/Appwrite/Auth/Validator/SubscriptionStatus.php` - Validate status updates
3. **Permission & Access Control**
- Plan management: Project owners/admins only
- Subscription viewing: User can view own subscription
- Checkout creation: Authenticated users
- Webhook processing: Stripe signature verification
### Phase 4: Integration Points
1. **User Authentication Flow**
- Check subscription status on login
- Assign default free plan on user registration if no plan exists
- Apply plan-based permissions and limits
- Include plan details in JWT tokens
2. **Account Endpoints Integration**
- Extend `/v1/account` response to include subscription details
- Add subscription info to user sessions
- Plan-based rate limiting
3. **User Management Integration**
- Show subscription status in user details
- Allow admins to view/manage user subscriptions
- Bulk subscription operations for teams
### Phase 5: Security Considerations
1. **Encryption**
- Stripe keys encrypted at rest using project encryption key
- Webhook secrets stored encrypted
- No sensitive data in logs
2. **Validation**
- Webhook signature verification mandatory
- Rate limiting on checkout creation
- CSRF protection on auth subscription endpoints
3. **Permissions**
- Project-level isolation of subscription data
- User can only manage own subscriptions
- Admin override capabilities
### Phase 6: Error Handling & Recovery
1. **Webhook Failures**
- Implement retry mechanism
- Dead letter queue for failed events
- Manual sync capability
2. **Subscription Failures**
- Grace period implementation
- Notification system integration
- Automatic retry logic
3. **Plan Migration**
- Handle upgrades/downgrades
- Proration calculation
- Feature access transitions
### Phase 7: Testing Strategy
1. **Unit Tests**
- Stripe service methods
- Validators
- Database operations
2. **Integration Tests**
- Webhook processing
- Checkout flow
- Subscription lifecycle
3. **End-to-End Tests**
- Complete signup with subscription
- Plan changes
- Cancellation flow
### Implementation Order
1. Database schema creation (collections)
2. Stripe service class implementation
3. Project auth subscription configuration endpoints
4. Plan management endpoints
5. Webhook handler implementation
6. User subscription endpoints
7. Integration with auth system
8. Testing and validation
9. Documentation and examples
### File Structure
```
app/
config/
collections/
(extend projects.php - add auth subscription fields)
auth_plans.php (new - auth plans collection)
controllers/
api/
(extend account.php - add subscription endpoints)
(extend users.php - add subscription management)
(extend projects.php - add auth/plans endpoints)
src/
Appwrite/
Auth/
Subscription/
StripeService.php
Exception/
SubscriptionException.php
Validator/
StripeKey.php
PlanData.php
SubscriptionStatus.php
```
### Environment Variables
```
_APP_AUTH_SUBSCRIPTIONS_ENABLED=true
_APP_AUTH_STRIPE_WEBHOOK_URL=https://[domain]/v1/webhooks/stripe/auth
_APP_ENCRYPTION_KEY=[existing project encryption key]
```
+688
View File
@@ -0,0 +1,688 @@
# Appwrite Auth Subscriptions API Schema
## Project Auth Configuration Endpoints
### 1. Configure Stripe for Auth Subscriptions
`PUT /v1/projects/{projectId}/auth/subscriptions`
**Description:** Initialize Stripe integration for auth subscriptions. Automatically creates webhook endpoint in Stripe and stores configuration.
**Authentication:** Admin API Key
**Path Parameters:**
| Parameter | Type | Description | Required |
|-----------|------|-------------|----------|
| projectId | string | Project unique ID | Yes |
**Request Body:**
| Field | Type | Description | Required | Validation |
|-------|------|-------------|----------|------------|
| stripeSecretKey | string | Stripe secret API key | Yes | Must match pattern: `sk_(test\|live)_[0-9a-zA-Z]{24,}` |
**Response (200 OK):**
```json
{
"$id": "5e5ea5c16897e",
"$createdAt": "2020-10-15T06:38:00.000+00:00",
"$updatedAt": "2020-10-15T06:38:00.000+00:00",
"name": "My Project",
"teamId": "5e5ea5c16897f",
// ... other project fields ...
"authSubscriptionsEnabled": true,
"authStripeSecretKey": "sk_test_...", // encrypted in DB
"authStripePublishableKey": "pk_test_...",
"authStripeWebhookSecret": "whsec_...", // encrypted in DB
"authStripeWebhookEndpointId": "we_1234567890",
"authStripeCurrency": "usd"
}
```
**Errors:**
- `400` - Invalid Stripe key format
- `401` - Unauthorized (invalid API key)
- `404` - Project not found
- `500` - Failed to connect to Stripe or create webhook
---
### 2. Get Auth Subscription Configuration
`GET /v1/projects/{projectId}/auth/subscriptions`
**Description:** Retrieve current auth subscription configuration for the project.
**Authentication:** Admin API Key
**Path Parameters:**
| Parameter | Type | Description | Required |
|-----------|------|-------------|----------|
| projectId | string | Project unique ID | Yes |
**Response (200 OK):**
```json
{
"enabled": true,
"publishableKey": "pk_test_51ABC...",
"currency": "usd"
}
```
**Errors:**
- `401` - Unauthorized
- `404` - Project not found
---
### 3. Remove Auth Subscription Configuration
`DELETE /v1/projects/{projectId}/auth/subscriptions`
**Description:** Remove Stripe configuration and disable auth subscriptions for the project.
**Authentication:** Admin API Key
**Path Parameters:**
| Parameter | Type | Description | Required |
|-----------|------|-------------|----------|
| projectId | string | Project unique ID | Yes |
**Response (200 OK):** Updated project object with null subscription fields
**Errors:**
- `401` - Unauthorized
- `404` - Project not found
---
## Auth Plans Management Endpoints
### 4. Create Auth Plan
`POST /v1/projects/{projectId}/auth/plans`
**Description:** Create a new subscription plan. Automatically creates corresponding product and price in Stripe for paid plans.
**Authentication:** Admin API Key
**Path Parameters:**
| Parameter | Type | Description | Required |
|-----------|------|-------------|----------|
| projectId | string | Project unique ID | Yes |
**Request Body:**
| Field | Type | Description | Required | Default | Validation |
|-------|------|-------------|----------|---------|------------|
| planId | string | Unique plan identifier | Yes | - | Max 128 chars, alphanumeric + hyphen/underscore |
| name | string | Plan display name | Yes | - | Max 128 chars |
| price | integer | Price in cents (e.g., 999 = $9.99) | Yes | 0 | Min 0 |
| currency | string | ISO 4217 currency code | Yes | - | 3 letter code (e.g., usd, eur) |
| interval | string | Billing interval | No | null | `month`, `year`, `week`, `day` |
| description | string | Plan description | No | "" | Max 256 chars |
| features | array | List of plan features | No | [] | Array of strings, max 256 chars each |
| maxUsers | integer | Max users allowed on plan | No | null | Min 1, null = unlimited |
| isDefault | boolean | Set as default plan for new users | No | false | Only one plan can be default |
| isFree | boolean | Mark as free tier | No | false | Free plans don't create Stripe products |
**Response (201 Created):**
```json
{
"$id": "64a5f8e7c3d2a",
"$createdAt": "2024-01-15T10:30:00.000+00:00",
"$updatedAt": "2024-01-15T10:30:00.000+00:00",
"projectInternalId": "64a5f8e7c3d2b",
"projectId": "my-project",
"planId": "premium",
"name": "Premium Plan",
"description": "Our best plan with all features",
"stripeProductId": "prod_ABC123",
"stripePriceId": "price_DEF456",
"price": 2999,
"currency": "usd",
"interval": "month",
"features": ["Unlimited users", "Priority support", "Advanced analytics"],
"isDefault": false,
"isFree": false,
"maxUsers": null,
"active": true,
"search": "premium Premium Plan Our best plan with all features"
}
```
**Errors:**
- `400` - Auth subscriptions not configured for project
- `400` - Invalid plan data
- `401` - Unauthorized
- `404` - Project not found
- `409` - Plan ID already exists
- `500` - Failed to create Stripe product/price
---
### 5. List Auth Plans
`GET /v1/projects/{projectId}/auth/plans`
**Description:** List all subscription plans for the project.
**Authentication:** Admin API Key
**Path Parameters:**
| Parameter | Type | Description | Required |
|-----------|------|-------------|----------|
| projectId | string | Project unique ID | Yes |
**Query Parameters:**
| Parameter | Type | Description | Required | Default |
|-----------|------|-------------|----------|---------|
| queries[] | array | Query filters | No | [] |
**Available Query Filters:**
- `Query::equal('active', [true])` - Only active plans
- `Query::equal('isFree', [true])` - Only free plans
- `Query::equal('isDefault', [true])` - Only default plan
- `Query::orderAsc('price')` - Sort by price ascending
- `Query::orderDesc('price')` - Sort by price descending
- `Query::limit(25)` - Limit results
- `Query::offset(0)` - Pagination offset
**Response (200 OK):**
```json
{
"total": 3,
"plans": [
{
"$id": "64a5f8e7c3d2a",
"planId": "free",
"name": "Free Plan",
"price": 0,
"isFree": true,
"isDefault": true
// ... other plan fields
},
{
"$id": "64a5f8e7c3d2b",
"planId": "basic",
"name": "Basic Plan",
"price": 999
// ... other plan fields
}
]
}
```
**Errors:**
- `401` - Unauthorized
- `404` - Project not found
---
### 6. Get Auth Plan
`GET /v1/projects/{projectId}/auth/plans/{planId}`
**Description:** Get details of a specific subscription plan.
**Authentication:** Admin API Key
**Path Parameters:**
| Parameter | Type | Description | Required |
|-----------|------|-------------|----------|
| projectId | string | Project unique ID | Yes |
| planId | string | Plan ID | Yes |
**Response (200 OK):** Plan object (same as create response)
**Errors:**
- `401` - Unauthorized
- `404` - Project not found
- `404` - Plan not found
---
### 7. Update Auth Plan
`PUT /v1/projects/{projectId}/auth/plans/{planId}`
**Description:** Update plan metadata. Note: Cannot update price, currency, or interval after creation.
**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 |
|-------|------|-------------|----------|
| name | string | Plan display name | No |
| description | string | Plan description | No |
| features | array | List of plan features | No |
| maxUsers | integer | Max users allowed | No |
| isDefault | boolean | Set as default plan | No |
**Response (200 OK):** Updated plan object
**Errors:**
- `401` - Unauthorized
- `404` - Project not found
- `404` - Plan not found
---
### 8. Delete Auth Plan
`DELETE /v1/projects/{projectId}/auth/plans/{planId}`
**Description:** Soft delete a plan (sets active=false). Users on this plan keep it but new users can't subscribe.
**Authentication:** Admin API Key
**Path Parameters:**
| Parameter | Type | Description | Required |
|-----------|------|-------------|----------|
| projectId | string | Project unique ID | Yes |
| planId | string | Plan ID | Yes |
**Response (204 No Content):** Empty response
**Errors:**
- `401` - Unauthorized
- `404` - Project not found
- `404` - Plan not found
---
## User Subscription Endpoints
### 9. Get Account Subscription
`GET /v1/account/subscription`
**Description:** Get current user's subscription details.
**Authentication:** Session or JWT
**Response (200 OK):**
```json
{
"planId": "premium",
"planName": "Premium Plan",
"status": "active",
"currentPeriodStart": "2024-01-01T00:00:00.000+00:00",
"currentPeriodEnd": "2024-02-01T00:00:00.000+00:00",
"cancelAtPeriodEnd": false,
"trialEnd": null
}
```
**Status Values:**
- `none` - No subscription
- `active` - Subscription is active
- `canceled` - Subscription canceled (may still be active until period end)
- `incomplete` - First payment pending
- `incomplete_expired` - First payment failed
- `past_due` - Payment failed, retrying
- `trialing` - In trial period
- `unpaid` - Subscription suspended due to non-payment
- `paused` - Subscription paused
**Errors:**
- `401` - Unauthorized
---
### 10. Create Checkout Session
`POST /v1/account/subscription/checkout`
**Description:** Create a Stripe Checkout session to subscribe to a plan.
**Authentication:** Session or JWT
**Request Body:**
| Field | Type | Description | Required | Validation |
|-------|------|-------------|----------|------------|
| planId | string | Plan to subscribe to | Yes | Must be active plan |
| successUrl | string | URL to redirect after success | Yes | Valid URL |
| cancelUrl | string | URL to redirect on cancel | Yes | Valid URL |
**Response (200 OK):**
```json
{
"checkoutUrl": "https://checkout.stripe.com/c/pay/cs_test_a1b2c3..."
}
```
**Errors:**
- `400` - Subscriptions not enabled for project
- `400` - Cannot checkout for free plan
- `401` - Unauthorized
- `404` - Plan not found
- `500` - Failed to create checkout session
---
### 11. Create Customer Portal Session
`POST /v1/account/subscription/portal`
**Description:** Create a Stripe Customer Portal session for billing management.
**Authentication:** Session or JWT
**Request Body:**
| Field | Type | Description | Required |
|-------|------|-------------|----------|
| returnUrl | string | URL to return to after portal | Yes |
**Response (200 OK):**
```json
{
"portalUrl": "https://billing.stripe.com/p/session/test_YWNjdF8..."
}
```
**Errors:**
- `400` - Subscriptions not enabled
- `400` - No active subscription found
- `401` - Unauthorized
- `500` - Failed to create portal session
---
### 11.a Assign Plan to User (Admin)
`POST /v1/users/{userId}/plan`
**Description:** Manually assign a plan to a user from the admin panel. Optionally creates a complimentary Stripe subscription for one billing interval and auto-cancels at period end. The plan is applied immediately without payment.
**Authentication:** Admin API Key
**Path Parameters:**
| Parameter | Type | Description | Required |
|-----------|------|-------------|----------|
| userId | string | User ID | Yes |
**Request Body:**
| Field | Type | Description | Required | Default |
|-------|------|-------------|----------|---------|
| planId | string | Plan ID to assign | Yes | - |
| complimentary | boolean | Create complimentary Stripe subscription for one billing interval | No | true |
**Behavior:**
- Sets `user.planId` and subscription fields immediately:
- `subscriptionStatus: 'active'`
- `subscriptionCurrentPeriodStart`: now
- `subscriptionCurrentPeriodEnd`: now + plan interval
- `subscriptionCancelAtPeriodEnd: true`
- `subscriptionTrialEnd`: end of the complimentary period
- If `complimentary=true`, plan is paid, and subscriptions are enabled:
- Ensures Stripe customer exists
- Creates trial subscription for one interval and schedules cancel at period end
**Response (200 OK):** Updated user object (includes embedded `plan`)
**Errors:**
- `400` - Subscriptions not enabled (only for paid complimentary)
- `401` - Unauthorized
|- `404` - User or Plan not found
- `500` - Stripe error while creating complimentary subscription
---
### 12. Update Subscription
`PUT /v1/account/subscription`
**Description:** Change subscription plan (upgrade/downgrade).
**Authentication:** Session or JWT
**Request Body:**
| Field | Type | Description | Required |
|-------|------|-------------|----------|
| planId | string | New plan ID | Yes |
**Response (200 OK):**
```json
{
"success": true
}
```
**Notes:**
- Upgrades happen immediately with proration
- Downgrades happen at period end by default
- Cannot change to/from free plans via this endpoint
**Errors:**
- `400` - Subscriptions not enabled
- `400` - No active subscription
- `401` - Unauthorized
- `404` - Plan not found
- `500` - Failed to update subscription
---
### 13. Cancel Subscription
`DELETE /v1/account/subscription`
**Description:** Cancel current subscription.
**Authentication:** Session or JWT
**Query Parameters:**
| Parameter | Type | Description | Required | Default |
|-----------|------|-------------|----------|---------|
| atPeriodEnd | boolean | Cancel at period end vs immediately | No | true |
**Response (200 OK):**
```json
{
"success": true,
"cancelAtPeriodEnd": true
}
```
**Errors:**
- `400` - Subscriptions not enabled
- `400` - No active subscription
- `401` - Unauthorized
- `500` - Failed to cancel subscription
---
## Webhook Handler
### 14. Stripe Webhook Handler
`POST /v1/webhooks/stripe/auth`
**Description:** Handle Stripe webhook events for subscription lifecycle.
**Authentication:** Stripe webhook signature
**Headers:**
| Header | Description | Required |
|--------|-------------|----------|
| stripe-signature | Stripe webhook signature for verification | Yes |
**Request Body:** Raw Stripe event JSON
**Response (200 OK):**
```json
{
"success": true
}
```
**Handled Events:**
| Event | Description | Actions |
| ------------------------------- | ------------------------ | -------------------------------------------------- |
| `checkout.session.completed` | Checkout successful | Create customer, update user with subscription IDs |
| `customer.subscription.created` | New subscription created | Sync subscription status to user |
| `customer.subscription.updated` | Subscription changed | Update user's plan and status |
| `customer.subscription.deleted` | Subscription canceled | Remove subscription, assign default free plan |
| `invoice.payment_failed` | Payment failed | Update status to `past_due` |
| `invoice.payment_succeeded` | Payment successful | Update status to `active` |
**Errors:**
- `400` - Missing Stripe signature
- `400` - Invalid webhook payload
- `403` - Invalid signature
- `404` - Project not found
- `500` - Processing error
---
## Database Schema
### Projects Collection Extensions
| Field | Type | Description | Encrypted |
| --------------------------- | ------- | ------------------------------------ | --------- |
| authSubscriptionsEnabled | boolean | Whether subscriptions are enabled | No |
| authStripeSecretKey | string | Stripe secret API key | Yes |
| authStripePublishableKey | string | Stripe publishable key | No |
| authStripeWebhookSecret | string | Webhook endpoint secret | Yes |
| authStripeWebhookEndpointId | string | Stripe webhook endpoint ID | No |
| authStripeCurrency | string | Default currency from Stripe account | No |
### Auth Plans 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 identifier | Yes | With projectId |
| name | string(128) | Plan display name | No | No |
| description | string(256) | Plan description | No | No |
| stripeProductId | string(256) | Stripe product ID | No | No |
| stripePriceId | string(256) | Stripe price ID | No | No |
| 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 |
| active | boolean | Active status | Yes | No |
| search | string | Full-text search field | Fulltext | 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
### Users Collection Extensions
| Field | Type | Description | Index |
| ------------------------------ | ----------- | ---------------------------- | ----- |
| planId | string | Current plan ID | Yes |
| stripeCustomerId | string(256) | Stripe customer ID | Yes |
| stripeSubscriptionId | string(256) | Stripe subscription ID | No |
| subscriptionStatus | string(20) | Subscription status | Yes |
| subscriptionCurrentPeriodStart | datetime | Current billing period start | No |
| subscriptionCurrentPeriodEnd | datetime | Current billing period end | No |
| subscriptionCancelAtPeriodEnd | boolean | Pending cancellation flag | No |
| subscriptionTrialEnd | datetime | Trial end date | No |
**Indexes:**
- `_key_planId`: planId
- `_key_stripeCustomerId`: stripeCustomerId
- `_key_subscriptionStatus`: subscriptionStatus
---
## Implementation Notes
### Security Considerations
1. **API Key Storage**: Stripe secret keys are encrypted using project encryption key
2. **Webhook Verification**: All webhook requests verified using Stripe signature
3. **Permission Checks**:
- Project endpoints require admin API key
- User endpoints require authenticated session
- Webhook endpoint validates signature only
### Rate Limiting
- Checkout session creation: 10 per minute per user
- Portal session creation: 5 per minute per user
- Subscription updates: 10 per hour per user
### Error Handling
All errors follow Appwrite's standard error format:
```json
{
"message": "Human readable error message",
"code": 400,
"type": "general_bad_request",
"version": "1.5.0"
}
```
### Stripe Integration Flow
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
### Migration Considerations
- Existing users get `subscriptionStatus: 'none'` by default
- First plan marked as default automatically assigns to new users
- Free plan recommended as first/default plan
### Testing
Test mode keys (`sk_test_*`) recommended for development:
- Use Stripe test cards for checkout
- Webhook events can be simulated via Stripe CLI
- Test and live keys stored separately
+2 -1
View File
@@ -5,6 +5,7 @@ $common = include __DIR__ . '/collections/common.php';
$projects = include __DIR__ . '/collections/projects.php';
$databases = include __DIR__ . '/collections/databases.php';
$platform = include __DIR__ . '/collections/platform.php';
$authPlans = include __DIR__ . '/collections/auth_plans.php';
$logs = include __DIR__ . '/collections/logs.php';
// see - http.php#245
@@ -27,7 +28,7 @@ $collections = [
'buckets' => $buckets,
'databases' => $databases,
'projects' => array_merge($projects, $common),
'console' => array_merge($platform, $common),
'console' => array_merge($platform, $authPlans, $common),
'logs' => $logs,
];
+227
View File
@@ -0,0 +1,227 @@
<?php
use Utopia\Database\Database;
use Utopia\Database\Helpers\ID;
return [
'auth_plans' => [
'$collection' => ID::custom(Database::METADATA),
'$id' => ID::custom('auth_plans'),
'name' => 'Auth Plans',
'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('name'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => 128,
'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('stripePriceId'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => 256,
'signed' => true,
'required' => false,
'default' => null,
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('stripeProductId'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => 256,
'signed' => true,
'required' => false,
'default' => null,
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('price'),
'type' => Database::VAR_INTEGER,
'format' => '',
'size' => 0,
'signed' => false,
'required' => true,
'default' => 0,
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('currency'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => 3,
'signed' => true,
'required' => true,
'default' => 'usd',
'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('features'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => 16384,
'signed' => true,
'required' => false,
'default' => [],
'array' => false,
'filters' => ['json'],
],
[
'$id' => ID::custom('isDefault'),
'type' => Database::VAR_BOOLEAN,
'signed' => true,
'size' => 0,
'format' => '',
'filters' => [],
'required' => false,
'default' => false,
'array' => false,
],
[
'$id' => ID::custom('isFree'),
'type' => Database::VAR_BOOLEAN,
'signed' => true,
'size' => 0,
'format' => '',
'filters' => [],
'required' => false,
'default' => false,
'array' => false,
],
[
'$id' => ID::custom('maxUsers'),
'type' => Database::VAR_INTEGER,
'format' => '',
'size' => 0,
'signed' => false,
'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_planId'),
'type' => Database::INDEX_UNIQUE,
'attributes' => ['projectId', 'planId'],
'lengths' => [Database::LENGTH_KEY, Database::LENGTH_KEY],
'orders' => [Database::ORDER_ASC, Database::ORDER_ASC],
],
[
'$id' => ID::custom('_key_project_default'),
'type' => Database::INDEX_KEY,
'attributes' => ['projectId', 'isDefault'],
'lengths' => [Database::LENGTH_KEY, 0],
'orders' => [Database::ORDER_ASC, Database::ORDER_ASC],
],
[
'$id' => ID::custom('_key_project_active'),
'type' => Database::INDEX_KEY,
'attributes' => ['projectId', 'active'],
'lengths' => [Database::LENGTH_KEY, 0],
'orders' => [Database::ORDER_ASC, Database::ORDER_ASC],
],
[
'$id' => ID::custom('_fulltext_search'),
'type' => Database::INDEX_FULLTEXT,
'attributes' => ['search'],
'lengths' => [],
'orders' => [],
],
],
],
];
+109
View File
@@ -364,6 +364,94 @@ return [
'array' => false,
'filters' => ['datetime'],
],
[
'$id' => ID::custom('planId'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => Database::LENGTH_KEY,
'signed' => true,
'required' => false,
'default' => null,
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('stripeCustomerId'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => 256,
'signed' => true,
'required' => false,
'default' => null,
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('stripeSubscriptionId'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => 256,
'signed' => true,
'required' => false,
'default' => null,
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('subscriptionStatus'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => 20,
'signed' => true,
'required' => false,
'default' => 'none',
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('subscriptionCurrentPeriodStart'),
'type' => Database::VAR_DATETIME,
'format' => '',
'size' => 0,
'signed' => false,
'required' => false,
'default' => null,
'array' => false,
'filters' => ['datetime'],
],
[
'$id' => ID::custom('subscriptionCurrentPeriodEnd'),
'type' => Database::VAR_DATETIME,
'format' => '',
'size' => 0,
'signed' => false,
'required' => false,
'default' => null,
'array' => false,
'filters' => ['datetime'],
],
[
'$id' => ID::custom('subscriptionCancelAtPeriodEnd'),
'type' => Database::VAR_BOOLEAN,
'signed' => true,
'size' => 0,
'format' => '',
'filters' => [],
'required' => false,
'default' => false,
'array' => false,
],
[
'$id' => ID::custom('subscriptionTrialEnd'),
'type' => Database::VAR_DATETIME,
'format' => '',
'size' => 0,
'signed' => false,
'required' => false,
'default' => null,
'array' => false,
'filters' => ['datetime'],
],
],
'indexes' => [
[
@@ -436,6 +524,27 @@ return [
'lengths' => [],
'orders' => [],
],
[
'$id' => ID::custom('_key_planId'),
'type' => Database::INDEX_KEY,
'attributes' => ['planId'],
'lengths' => [Database::LENGTH_KEY],
'orders' => [Database::ORDER_ASC],
],
[
'$id' => ID::custom('_key_stripeCustomerId'),
'type' => Database::INDEX_KEY,
'attributes' => ['stripeCustomerId'],
'lengths' => [256],
'orders' => [Database::ORDER_ASC],
],
[
'$id' => ID::custom('_key_subscriptionStatus'),
'type' => Database::INDEX_KEY,
'attributes' => ['subscriptionStatus'],
'lengths' => [20],
'orders' => [Database::ORDER_ASC],
],
],
],
+66
View File
@@ -330,6 +330,72 @@ return [
'default' => null,
'array' => false,
'filters' => ['datetime'],
],
[
'$id' => ID::custom('authSubscriptionsEnabled'),
'type' => Database::VAR_BOOLEAN,
'signed' => true,
'size' => 0,
'format' => '',
'filters' => [],
'required' => false,
'default' => false,
'array' => false,
],
[
'$id' => ID::custom('authStripeSecretKey'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => 512,
'signed' => true,
'required' => false,
'default' => null,
'array' => false,
'filters' => ['encrypt'],
],
[
'$id' => ID::custom('authStripePublishableKey'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => 512,
'signed' => true,
'required' => false,
'default' => null,
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('authStripeWebhookSecret'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => 512,
'signed' => true,
'required' => false,
'default' => null,
'array' => false,
'filters' => ['encrypt'],
],
[
'$id' => ID::custom('authStripeWebhookEndpointId'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => 512,
'signed' => true,
'required' => false,
'default' => null,
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('authStripeCurrency'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => 3,
'signed' => true,
'required' => false,
'default' => null,
'array' => false,
'filters' => [],
]
],
'indexes' => [
+13
View File
@@ -261,6 +261,19 @@ return [
'optional' => false,
'icon' => '',
],
'authPlans' => [
'key' => 'authPlans',
'name' => 'Auth Plans',
'subtitle' => 'The Auth Plans service allows you to manage subscription plans for authentication.',
'description' => '',
'controller' => 'api/auth-plans.php',
'sdk' => false,
'docs' => false,
'docsUrl' => '',
'tests' => false,
'optional' => true,
'icon' => '',
],
'migrations' => [
'key' => 'migrations',
'name' => 'Migrations',
+416 -59
View File
@@ -2872,6 +2872,342 @@
]
}
},
"\/account\/subscription": {
"get": {
"summary": "Get account subscription",
"operationId": "accountGetSubscription",
"tags": [
"account"
],
"description": false,
"responses": {
"200": {
"description": "Any",
"content": {
"application\/json": {
"schema": {
"$ref": "#\/components\/schemas\/any"
}
}
}
}
},
"deprecated": false,
"x-appwrite": {
"method": "getSubscription",
"group": "subscription",
"weight": 60,
"cookies": false,
"type": "",
"demo": "account\/get-subscription.md",
"edit": "https:\/\/github.com\/appwrite\/appwrite\/edit\/master\/docs\/references\/account\/get-subscription.md",
"rate-limit": 0,
"rate-time": 3600,
"rate-key": "url:{url},ip:{ip}",
"scope": "account",
"platforms": [
"client",
"server"
],
"packaging": false,
"auth": {
"Project": []
}
},
"security": [
{
"Project": [],
"Session": [],
"JWT": []
}
]
},
"put": {
"summary": "Update subscription",
"operationId": "accountUpdateSubscription",
"tags": [
"account"
],
"description": false,
"responses": {
"200": {
"description": "Any",
"content": {
"application\/json": {
"schema": {
"$ref": "#\/components\/schemas\/any"
}
}
}
}
},
"deprecated": false,
"x-appwrite": {
"method": "updateSubscription",
"group": "subscription",
"weight": 63,
"cookies": false,
"type": "",
"demo": "account\/update-subscription.md",
"edit": "https:\/\/github.com\/appwrite\/appwrite\/edit\/master\/docs\/references\/account\/update-subscription.md",
"rate-limit": 0,
"rate-time": 3600,
"rate-key": "url:{url},ip:{ip}",
"scope": "account",
"platforms": [
"client",
"server"
],
"packaging": false,
"auth": {
"Project": []
}
},
"security": [
{
"Project": [],
"Session": [],
"JWT": []
}
],
"requestBody": {
"content": {
"application\/json": {
"schema": {
"type": "object",
"properties": {
"planId": {
"type": "string",
"description": "New plan ID.",
"x-example": "<PLAN_ID>"
}
},
"required": [
"planId"
]
}
}
}
}
},
"delete": {
"summary": "Cancel subscription",
"operationId": "accountCancelSubscription",
"tags": [
"account"
],
"description": false,
"responses": {
"200": {
"description": "Any",
"content": {
"application\/json": {
"schema": {
"$ref": "#\/components\/schemas\/any"
}
}
}
}
},
"deprecated": false,
"x-appwrite": {
"method": "cancelSubscription",
"group": "subscription",
"weight": 64,
"cookies": false,
"type": "",
"demo": "account\/cancel-subscription.md",
"edit": "https:\/\/github.com\/appwrite\/appwrite\/edit\/master\/docs\/references\/account\/cancel-subscription.md",
"rate-limit": 0,
"rate-time": 3600,
"rate-key": "url:{url},ip:{ip}",
"scope": "account",
"platforms": [
"client",
"server"
],
"packaging": false,
"auth": {
"Project": []
}
},
"security": [
{
"Project": [],
"Session": [],
"JWT": []
}
],
"requestBody": {
"content": {
"application\/json": {
"schema": {
"type": "object",
"properties": {
"atPeriodEnd": {
"type": "boolean",
"description": "Cancel at period end.",
"x-example": false
}
}
}
}
}
}
}
},
"\/account\/subscription\/checkout": {
"post": {
"summary": "Create checkout session",
"operationId": "accountCreateSubscriptionCheckout",
"tags": [
"account"
],
"description": false,
"responses": {
"200": {
"description": "Any",
"content": {
"application\/json": {
"schema": {
"$ref": "#\/components\/schemas\/any"
}
}
}
}
},
"deprecated": false,
"x-appwrite": {
"method": "createSubscriptionCheckout",
"group": "subscription",
"weight": 61,
"cookies": false,
"type": "",
"demo": "account\/create-subscription-checkout.md",
"edit": "https:\/\/github.com\/appwrite\/appwrite\/edit\/master\/docs\/references\/account\/create-checkout.md",
"rate-limit": 0,
"rate-time": 3600,
"rate-key": "url:{url},ip:{ip}",
"scope": "account",
"platforms": [
"client",
"server"
],
"packaging": false,
"auth": {
"Project": []
}
},
"security": [
{
"Project": [],
"Session": [],
"JWT": []
}
],
"requestBody": {
"content": {
"application\/json": {
"schema": {
"type": "object",
"properties": {
"planId": {
"type": "string",
"description": "Plan ID to subscribe to.",
"x-example": "<PLAN_ID>"
},
"successUrl": {
"type": "string",
"description": "URL to redirect on success.",
"x-example": "https:\/\/example.com"
},
"cancelUrl": {
"type": "string",
"description": "URL to redirect on cancel.",
"x-example": "https:\/\/example.com"
}
},
"required": [
"planId",
"successUrl",
"cancelUrl"
]
}
}
}
}
}
},
"\/account\/subscription\/portal": {
"post": {
"summary": "Create customer portal session",
"operationId": "accountCreateSubscriptionPortal",
"tags": [
"account"
],
"description": false,
"responses": {
"200": {
"description": "Any",
"content": {
"application\/json": {
"schema": {
"$ref": "#\/components\/schemas\/any"
}
}
}
}
},
"deprecated": false,
"x-appwrite": {
"method": "createSubscriptionPortal",
"group": "subscription",
"weight": 62,
"cookies": false,
"type": "",
"demo": "account\/create-subscription-portal.md",
"edit": "https:\/\/github.com\/appwrite\/appwrite\/edit\/master\/docs\/references\/account\/create-portal.md",
"rate-limit": 0,
"rate-time": 3600,
"rate-key": "url:{url},ip:{ip}",
"scope": "account",
"platforms": [
"client",
"server"
],
"packaging": false,
"auth": {
"Project": []
}
},
"security": [
{
"Project": [],
"Session": [],
"JWT": []
}
],
"requestBody": {
"content": {
"application\/json": {
"schema": {
"type": "object",
"properties": {
"returnUrl": {
"type": "string",
"description": "URL to return to.",
"x-example": "https:\/\/example.com"
}
},
"required": [
"returnUrl"
]
}
}
}
}
}
},
"\/account\/targets\/push": {
"post": {
"summary": "Create push target",
@@ -3753,7 +4089,7 @@
"x-appwrite": {
"method": "getBrowser",
"group": null,
"weight": 61,
"weight": 66,
"cookies": false,
"type": "location",
"demo": "avatars\/get-browser.md",
@@ -3879,7 +4215,7 @@
"x-appwrite": {
"method": "getCreditCard",
"group": null,
"weight": 60,
"weight": 65,
"cookies": false,
"type": "location",
"demo": "avatars\/get-credit-card.md",
@@ -4011,7 +4347,7 @@
"x-appwrite": {
"method": "getFavicon",
"group": null,
"weight": 64,
"weight": 69,
"cookies": false,
"type": "location",
"demo": "avatars\/get-favicon.md",
@@ -4069,7 +4405,7 @@
"x-appwrite": {
"method": "getFlag",
"group": null,
"weight": 62,
"weight": 67,
"cookies": false,
"type": "location",
"demo": "avatars\/get-flag.md",
@@ -4557,7 +4893,7 @@
"x-appwrite": {
"method": "getImage",
"group": null,
"weight": 63,
"weight": 68,
"cookies": false,
"type": "location",
"demo": "avatars\/get-image.md",
@@ -4639,7 +4975,7 @@
"x-appwrite": {
"method": "getInitials",
"group": null,
"weight": 66,
"weight": 71,
"cookies": false,
"type": "location",
"demo": "avatars\/get-initials.md",
@@ -4731,7 +5067,7 @@
"x-appwrite": {
"method": "getQR",
"group": null,
"weight": 65,
"weight": 70,
"cookies": false,
"type": "location",
"demo": "avatars\/get-qr.md",
@@ -4830,7 +5166,7 @@
"x-appwrite": {
"method": "listDocuments",
"group": "documents",
"weight": 335,
"weight": 349,
"cookies": false,
"type": "",
"demo": "databases\/list-documents.md",
@@ -4919,7 +5255,7 @@
"x-appwrite": {
"method": "createDocument",
"group": "documents",
"weight": 327,
"weight": 341,
"cookies": false,
"type": "",
"demo": "databases\/create-document.md",
@@ -5069,7 +5405,7 @@
"x-appwrite": {
"method": "getDocument",
"group": "documents",
"weight": 328,
"weight": 342,
"cookies": false,
"type": "",
"demo": "databases\/get-document.md",
@@ -5168,7 +5504,7 @@
"x-appwrite": {
"method": "upsertDocument",
"group": "documents",
"weight": 331,
"weight": 345,
"cookies": false,
"type": "",
"demo": "databases\/upsert-document.md",
@@ -5316,7 +5652,7 @@
"x-appwrite": {
"method": "updateDocument",
"group": "documents",
"weight": 329,
"weight": 343,
"cookies": false,
"type": "",
"demo": "databases\/update-document.md",
@@ -5419,7 +5755,7 @@
"x-appwrite": {
"method": "deleteDocument",
"group": "documents",
"weight": 333,
"weight": 347,
"cookies": false,
"type": "",
"demo": "databases\/delete-document.md",
@@ -5507,7 +5843,7 @@
"x-appwrite": {
"method": "decrementDocumentAttribute",
"group": "documents",
"weight": 338,
"weight": 352,
"cookies": false,
"type": "",
"demo": "databases\/decrement-document-attribute.md",
@@ -5626,7 +5962,7 @@
"x-appwrite": {
"method": "incrementDocumentAttribute",
"group": "documents",
"weight": 337,
"weight": 351,
"cookies": false,
"type": "",
"demo": "databases\/increment-document-attribute.md",
@@ -5745,7 +6081,7 @@
"x-appwrite": {
"method": "listExecutions",
"group": "executions",
"weight": 456,
"weight": 470,
"cookies": false,
"type": "",
"demo": "functions\/list-executions.md",
@@ -5820,7 +6156,7 @@
"x-appwrite": {
"method": "createExecution",
"group": "executions",
"weight": 454,
"weight": 468,
"cookies": false,
"type": "",
"demo": "functions\/create-execution.md",
@@ -5936,7 +6272,7 @@
"x-appwrite": {
"method": "getExecution",
"group": "executions",
"weight": 455,
"weight": 469,
"cookies": false,
"type": "",
"demo": "functions\/get-execution.md",
@@ -6010,7 +6346,7 @@
"x-appwrite": {
"method": "query",
"group": "graphql",
"weight": 250,
"weight": 259,
"cookies": false,
"type": "graphql",
"demo": "graphql\/query.md",
@@ -6062,7 +6398,7 @@
"x-appwrite": {
"method": "mutation",
"group": "graphql",
"weight": 249,
"weight": 258,
"cookies": false,
"type": "graphql",
"demo": "graphql\/mutation.md",
@@ -6114,7 +6450,7 @@
"x-appwrite": {
"method": "get",
"group": null,
"weight": 70,
"weight": 75,
"cookies": false,
"type": "",
"demo": "locale\/get.md",
@@ -6166,7 +6502,7 @@
"x-appwrite": {
"method": "listCodes",
"group": null,
"weight": 71,
"weight": 76,
"cookies": false,
"type": "",
"demo": "locale\/list-codes.md",
@@ -6218,7 +6554,7 @@
"x-appwrite": {
"method": "listContinents",
"group": null,
"weight": 75,
"weight": 80,
"cookies": false,
"type": "",
"demo": "locale\/list-continents.md",
@@ -6270,7 +6606,7 @@
"x-appwrite": {
"method": "listCountries",
"group": null,
"weight": 72,
"weight": 77,
"cookies": false,
"type": "",
"demo": "locale\/list-countries.md",
@@ -6322,7 +6658,7 @@
"x-appwrite": {
"method": "listCountriesEU",
"group": null,
"weight": 73,
"weight": 78,
"cookies": false,
"type": "",
"demo": "locale\/list-countries-eu.md",
@@ -6374,7 +6710,7 @@
"x-appwrite": {
"method": "listCountriesPhones",
"group": null,
"weight": 74,
"weight": 79,
"cookies": false,
"type": "",
"demo": "locale\/list-countries-phones.md",
@@ -6426,7 +6762,7 @@
"x-appwrite": {
"method": "listCurrencies",
"group": null,
"weight": 76,
"weight": 81,
"cookies": false,
"type": "",
"demo": "locale\/list-currencies.md",
@@ -6478,7 +6814,7 @@
"x-appwrite": {
"method": "listLanguages",
"group": null,
"weight": 77,
"weight": 82,
"cookies": false,
"type": "",
"demo": "locale\/list-languages.md",
@@ -6530,7 +6866,7 @@
"x-appwrite": {
"method": "createSubscriber",
"group": "subscribers",
"weight": 296,
"weight": 310,
"cookies": false,
"type": "",
"demo": "messaging\/create-subscriber.md",
@@ -6613,7 +6949,7 @@
"x-appwrite": {
"method": "deleteSubscriber",
"group": "subscribers",
"weight": 300,
"weight": 314,
"cookies": false,
"type": "",
"demo": "messaging\/delete-subscriber.md",
@@ -6688,7 +7024,7 @@
"x-appwrite": {
"method": "listFiles",
"group": "files",
"weight": 160,
"weight": 168,
"cookies": false,
"type": "",
"demo": "storage\/list-files.md",
@@ -6774,7 +7110,7 @@
"x-appwrite": {
"method": "createFile",
"group": "files",
"weight": 159,
"weight": 167,
"cookies": false,
"type": "upload",
"demo": "storage\/create-file.md",
@@ -6872,7 +7208,7 @@
"x-appwrite": {
"method": "getFile",
"group": "files",
"weight": 161,
"weight": 169,
"cookies": false,
"type": "",
"demo": "storage\/get-file.md",
@@ -6944,7 +7280,7 @@
"x-appwrite": {
"method": "updateFile",
"group": "files",
"weight": 166,
"weight": 174,
"cookies": false,
"type": "",
"demo": "storage\/update-file.md",
@@ -7033,7 +7369,7 @@
"x-appwrite": {
"method": "deleteFile",
"group": "files",
"weight": 167,
"weight": 175,
"cookies": false,
"type": "",
"demo": "storage\/delete-file.md",
@@ -7100,7 +7436,7 @@
"x-appwrite": {
"method": "getFileDownload",
"group": "files",
"weight": 163,
"weight": 171,
"cookies": false,
"type": "location",
"demo": "storage\/get-file-download.md",
@@ -7178,7 +7514,7 @@
"x-appwrite": {
"method": "getFilePreview",
"group": "files",
"weight": 162,
"weight": 170,
"cookies": false,
"type": "location",
"demo": "storage\/get-file-preview.md",
@@ -7406,7 +7742,7 @@
"x-appwrite": {
"method": "getFileView",
"group": "files",
"weight": 164,
"weight": 172,
"cookies": false,
"type": "location",
"demo": "storage\/get-file-view.md",
@@ -7491,7 +7827,7 @@
"x-appwrite": {
"method": "listRows",
"group": "rows",
"weight": 427,
"weight": 441,
"cookies": false,
"type": "",
"demo": "tablesdb\/list-rows.md",
@@ -7579,7 +7915,7 @@
"x-appwrite": {
"method": "createRow",
"group": "rows",
"weight": 419,
"weight": 433,
"cookies": false,
"type": "",
"demo": "tablesdb\/create-row.md",
@@ -7724,7 +8060,7 @@
"x-appwrite": {
"method": "getRow",
"group": "rows",
"weight": 420,
"weight": 434,
"cookies": false,
"type": "",
"demo": "tablesdb\/get-row.md",
@@ -7822,7 +8158,7 @@
"x-appwrite": {
"method": "upsertRow",
"group": "rows",
"weight": 423,
"weight": 437,
"cookies": false,
"type": "",
"demo": "tablesdb\/upsert-row.md",
@@ -7961,7 +8297,7 @@
"x-appwrite": {
"method": "updateRow",
"group": "rows",
"weight": 421,
"weight": 435,
"cookies": false,
"type": "",
"demo": "tablesdb\/update-row.md",
@@ -8063,7 +8399,7 @@
"x-appwrite": {
"method": "deleteRow",
"group": "rows",
"weight": 425,
"weight": 439,
"cookies": false,
"type": "",
"demo": "tablesdb\/delete-row.md",
@@ -8150,7 +8486,7 @@
"x-appwrite": {
"method": "decrementRowColumn",
"group": "rows",
"weight": 430,
"weight": 444,
"cookies": false,
"type": "",
"demo": "tablesdb\/decrement-row-column.md",
@@ -8268,7 +8604,7 @@
"x-appwrite": {
"method": "incrementRowColumn",
"group": "rows",
"weight": 429,
"weight": 443,
"cookies": false,
"type": "",
"demo": "tablesdb\/increment-row-column.md",
@@ -8386,7 +8722,7 @@
"x-appwrite": {
"method": "list",
"group": "teams",
"weight": 171,
"weight": 179,
"cookies": false,
"type": "",
"demo": "teams\/list.md",
@@ -8462,7 +8798,7 @@
"x-appwrite": {
"method": "create",
"group": "teams",
"weight": 170,
"weight": 178,
"cookies": false,
"type": "",
"demo": "teams\/create.md",
@@ -8547,7 +8883,7 @@
"x-appwrite": {
"method": "get",
"group": "teams",
"weight": 172,
"weight": 180,
"cookies": false,
"type": "",
"demo": "teams\/get.md",
@@ -8609,7 +8945,7 @@
"x-appwrite": {
"method": "updateName",
"group": "teams",
"weight": 174,
"weight": 182,
"cookies": false,
"type": "",
"demo": "teams\/update-name.md",
@@ -8683,7 +9019,7 @@
"x-appwrite": {
"method": "delete",
"group": "teams",
"weight": 176,
"weight": 184,
"cookies": false,
"type": "",
"demo": "teams\/delete.md",
@@ -8747,7 +9083,7 @@
"x-appwrite": {
"method": "listMemberships",
"group": "memberships",
"weight": 178,
"weight": 186,
"cookies": false,
"type": "",
"demo": "teams\/list-memberships.md",
@@ -8833,7 +9169,7 @@
"x-appwrite": {
"method": "createMembership",
"group": "memberships",
"weight": 177,
"weight": 185,
"cookies": false,
"type": "",
"demo": "teams\/create-membership.md",
@@ -8944,7 +9280,7 @@
"x-appwrite": {
"method": "getMembership",
"group": "memberships",
"weight": 179,
"weight": 187,
"cookies": false,
"type": "",
"demo": "teams\/get-membership.md",
@@ -9016,7 +9352,7 @@
"x-appwrite": {
"method": "updateMembership",
"group": "memberships",
"weight": 180,
"weight": 188,
"cookies": false,
"type": "",
"demo": "teams\/update-membership.md",
@@ -9103,7 +9439,7 @@
"x-appwrite": {
"method": "deleteMembership",
"group": "memberships",
"weight": 182,
"weight": 190,
"cookies": false,
"type": "",
"demo": "teams\/delete-membership.md",
@@ -9177,7 +9513,7 @@
"x-appwrite": {
"method": "updateMembershipStatus",
"group": "memberships",
"weight": 181,
"weight": 189,
"cookies": false,
"type": "",
"demo": "teams\/update-membership-status.md",
@@ -9275,7 +9611,7 @@
"x-appwrite": {
"method": "getPrefs",
"group": "teams",
"weight": 173,
"weight": 181,
"cookies": false,
"type": "",
"demo": "teams\/get-prefs.md",
@@ -9336,7 +9672,7 @@
"x-appwrite": {
"method": "updatePrefs",
"group": "teams",
"weight": 175,
"weight": 183,
"cookies": false,
"type": "",
"demo": "teams\/update-prefs.md",
@@ -10375,6 +10711,19 @@
},
"x-example": []
},
"plan": {
"type": "object",
"description": "User subscription plan details.",
"x-example": {
"id": "basic",
"name": "Basic",
"price": 999,
"currency": "usd",
"interval": "month",
"isFree": false
},
"nullable": true
},
"accessedAt": {
"type": "string",
"description": "Most recent access date in ISO 8601 format. This attribute is only updated again after 24 hours.",
@@ -10423,6 +10772,14 @@
"timezone": "UTC"
},
"targets": [],
"plan": {
"id": "basic",
"name": "Basic",
"price": 999,
"currency": "usd",
"interval": "month",
"isFree": false
},
"accessedAt": "2020-10-15T06:38:00.000+00:00"
}
},
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+430 -59
View File
@@ -2989,6 +2989,356 @@
]
}
},
"\/account\/subscription": {
"get": {
"summary": "Get account subscription",
"operationId": "accountGetSubscription",
"consumes": [],
"produces": [
"application\/json"
],
"tags": [
"account"
],
"description": false,
"responses": {
"200": {
"description": "Any",
"schema": {
"$ref": "#\/definitions\/any"
}
}
},
"deprecated": false,
"x-appwrite": {
"method": "getSubscription",
"group": "subscription",
"weight": 60,
"cookies": false,
"type": "",
"demo": "account\/get-subscription.md",
"edit": "https:\/\/github.com\/appwrite\/appwrite\/edit\/master\/docs\/references\/account\/get-subscription.md",
"rate-limit": 0,
"rate-time": 3600,
"rate-key": "url:{url},ip:{ip}",
"scope": "account",
"platforms": [
"client",
"server"
],
"packaging": false,
"auth": {
"Project": []
}
},
"security": [
{
"Project": [],
"Session": [],
"JWT": []
}
]
},
"put": {
"summary": "Update subscription",
"operationId": "accountUpdateSubscription",
"consumes": [
"application\/json"
],
"produces": [
"application\/json"
],
"tags": [
"account"
],
"description": false,
"responses": {
"200": {
"description": "Any",
"schema": {
"$ref": "#\/definitions\/any"
}
}
},
"deprecated": false,
"x-appwrite": {
"method": "updateSubscription",
"group": "subscription",
"weight": 63,
"cookies": false,
"type": "",
"demo": "account\/update-subscription.md",
"edit": "https:\/\/github.com\/appwrite\/appwrite\/edit\/master\/docs\/references\/account\/update-subscription.md",
"rate-limit": 0,
"rate-time": 3600,
"rate-key": "url:{url},ip:{ip}",
"scope": "account",
"platforms": [
"client",
"server"
],
"packaging": false,
"auth": {
"Project": []
}
},
"security": [
{
"Project": [],
"Session": [],
"JWT": []
}
],
"parameters": [
{
"name": "payload",
"in": "body",
"schema": {
"type": "object",
"properties": {
"planId": {
"type": "string",
"description": "New plan ID.",
"default": null,
"x-example": "<PLAN_ID>"
}
},
"required": [
"planId"
]
}
}
]
},
"delete": {
"summary": "Cancel subscription",
"operationId": "accountCancelSubscription",
"consumes": [
"application\/json"
],
"produces": [
"application\/json"
],
"tags": [
"account"
],
"description": false,
"responses": {
"200": {
"description": "Any",
"schema": {
"$ref": "#\/definitions\/any"
}
}
},
"deprecated": false,
"x-appwrite": {
"method": "cancelSubscription",
"group": "subscription",
"weight": 64,
"cookies": false,
"type": "",
"demo": "account\/cancel-subscription.md",
"edit": "https:\/\/github.com\/appwrite\/appwrite\/edit\/master\/docs\/references\/account\/cancel-subscription.md",
"rate-limit": 0,
"rate-time": 3600,
"rate-key": "url:{url},ip:{ip}",
"scope": "account",
"platforms": [
"client",
"server"
],
"packaging": false,
"auth": {
"Project": []
}
},
"security": [
{
"Project": [],
"Session": [],
"JWT": []
}
],
"parameters": [
{
"name": "payload",
"in": "body",
"schema": {
"type": "object",
"properties": {
"atPeriodEnd": {
"type": "boolean",
"description": "Cancel at period end.",
"default": true,
"x-example": false
}
}
}
}
]
}
},
"\/account\/subscription\/checkout": {
"post": {
"summary": "Create checkout session",
"operationId": "accountCreateSubscriptionCheckout",
"consumes": [
"application\/json"
],
"produces": [
"application\/json"
],
"tags": [
"account"
],
"description": false,
"responses": {
"200": {
"description": "Any",
"schema": {
"$ref": "#\/definitions\/any"
}
}
},
"deprecated": false,
"x-appwrite": {
"method": "createSubscriptionCheckout",
"group": "subscription",
"weight": 61,
"cookies": false,
"type": "",
"demo": "account\/create-subscription-checkout.md",
"edit": "https:\/\/github.com\/appwrite\/appwrite\/edit\/master\/docs\/references\/account\/create-checkout.md",
"rate-limit": 0,
"rate-time": 3600,
"rate-key": "url:{url},ip:{ip}",
"scope": "account",
"platforms": [
"client",
"server"
],
"packaging": false,
"auth": {
"Project": []
}
},
"security": [
{
"Project": [],
"Session": [],
"JWT": []
}
],
"parameters": [
{
"name": "payload",
"in": "body",
"schema": {
"type": "object",
"properties": {
"planId": {
"type": "string",
"description": "Plan ID to subscribe to.",
"default": null,
"x-example": "<PLAN_ID>"
},
"successUrl": {
"type": "string",
"description": "URL to redirect on success.",
"default": null,
"x-example": "https:\/\/example.com"
},
"cancelUrl": {
"type": "string",
"description": "URL to redirect on cancel.",
"default": null,
"x-example": "https:\/\/example.com"
}
},
"required": [
"planId",
"successUrl",
"cancelUrl"
]
}
}
]
}
},
"\/account\/subscription\/portal": {
"post": {
"summary": "Create customer portal session",
"operationId": "accountCreateSubscriptionPortal",
"consumes": [
"application\/json"
],
"produces": [
"application\/json"
],
"tags": [
"account"
],
"description": false,
"responses": {
"200": {
"description": "Any",
"schema": {
"$ref": "#\/definitions\/any"
}
}
},
"deprecated": false,
"x-appwrite": {
"method": "createSubscriptionPortal",
"group": "subscription",
"weight": 62,
"cookies": false,
"type": "",
"demo": "account\/create-subscription-portal.md",
"edit": "https:\/\/github.com\/appwrite\/appwrite\/edit\/master\/docs\/references\/account\/create-portal.md",
"rate-limit": 0,
"rate-time": 3600,
"rate-key": "url:{url},ip:{ip}",
"scope": "account",
"platforms": [
"client",
"server"
],
"packaging": false,
"auth": {
"Project": []
}
},
"security": [
{
"Project": [],
"Session": [],
"JWT": []
}
],
"parameters": [
{
"name": "payload",
"in": "body",
"schema": {
"type": "object",
"properties": {
"returnUrl": {
"type": "string",
"description": "URL to return to.",
"default": null,
"x-example": "https:\/\/example.com"
}
},
"required": [
"returnUrl"
]
}
}
]
}
},
"\/account\/targets\/push": {
"post": {
"summary": "Create push target",
@@ -3908,7 +4258,7 @@
"x-appwrite": {
"method": "getBrowser",
"group": null,
"weight": 61,
"weight": 66,
"cookies": false,
"type": "location",
"demo": "avatars\/get-browser.md",
@@ -4032,7 +4382,7 @@
"x-appwrite": {
"method": "getCreditCard",
"group": null,
"weight": 60,
"weight": 65,
"cookies": false,
"type": "location",
"demo": "avatars\/get-credit-card.md",
@@ -4162,7 +4512,7 @@
"x-appwrite": {
"method": "getFavicon",
"group": null,
"weight": 64,
"weight": 69,
"cookies": false,
"type": "location",
"demo": "avatars\/get-favicon.md",
@@ -4224,7 +4574,7 @@
"x-appwrite": {
"method": "getFlag",
"group": null,
"weight": 62,
"weight": 67,
"cookies": false,
"type": "location",
"demo": "avatars\/get-flag.md",
@@ -4710,7 +5060,7 @@
"x-appwrite": {
"method": "getImage",
"group": null,
"weight": 63,
"weight": 68,
"cookies": false,
"type": "location",
"demo": "avatars\/get-image.md",
@@ -4792,7 +5142,7 @@
"x-appwrite": {
"method": "getInitials",
"group": null,
"weight": 66,
"weight": 71,
"cookies": false,
"type": "location",
"demo": "avatars\/get-initials.md",
@@ -4882,7 +5232,7 @@
"x-appwrite": {
"method": "getQR",
"group": null,
"weight": 65,
"weight": 70,
"cookies": false,
"type": "location",
"demo": "avatars\/get-qr.md",
@@ -4972,7 +5322,7 @@
"x-appwrite": {
"method": "listDocuments",
"group": "documents",
"weight": 335,
"weight": 349,
"cookies": false,
"type": "",
"demo": "databases\/list-documents.md",
@@ -5057,7 +5407,7 @@
"x-appwrite": {
"method": "createDocument",
"group": "documents",
"weight": 327,
"weight": 341,
"cookies": false,
"type": "",
"demo": "databases\/create-document.md",
@@ -5204,7 +5554,7 @@
"x-appwrite": {
"method": "getDocument",
"group": "documents",
"weight": 328,
"weight": 342,
"cookies": false,
"type": "",
"demo": "databases\/get-document.md",
@@ -5297,7 +5647,7 @@
"x-appwrite": {
"method": "upsertDocument",
"group": "documents",
"weight": 331,
"weight": 345,
"cookies": false,
"type": "",
"demo": "databases\/upsert-document.md",
@@ -5440,7 +5790,7 @@
"x-appwrite": {
"method": "updateDocument",
"group": "documents",
"weight": 329,
"weight": 343,
"cookies": false,
"type": "",
"demo": "databases\/update-document.md",
@@ -5540,7 +5890,7 @@
"x-appwrite": {
"method": "deleteDocument",
"group": "documents",
"weight": 333,
"weight": 347,
"cookies": false,
"type": "",
"demo": "databases\/delete-document.md",
@@ -5623,7 +5973,7 @@
"x-appwrite": {
"method": "decrementDocumentAttribute",
"group": "documents",
"weight": 338,
"weight": 352,
"cookies": false,
"type": "",
"demo": "databases\/decrement-document-attribute.md",
@@ -5735,7 +6085,7 @@
"x-appwrite": {
"method": "incrementDocumentAttribute",
"group": "documents",
"weight": 337,
"weight": 351,
"cookies": false,
"type": "",
"demo": "databases\/increment-document-attribute.md",
@@ -5845,7 +6195,7 @@
"x-appwrite": {
"method": "listExecutions",
"group": "executions",
"weight": 456,
"weight": 470,
"cookies": false,
"type": "",
"demo": "functions\/list-executions.md",
@@ -5918,7 +6268,7 @@
"x-appwrite": {
"method": "createExecution",
"group": "executions",
"weight": 454,
"weight": 468,
"cookies": false,
"type": "",
"demo": "functions\/create-execution.md",
@@ -6035,7 +6385,7 @@
"x-appwrite": {
"method": "getExecution",
"group": "executions",
"weight": 455,
"weight": 469,
"cookies": false,
"type": "",
"demo": "functions\/get-execution.md",
@@ -6106,7 +6456,7 @@
"x-appwrite": {
"method": "query",
"group": "graphql",
"weight": 250,
"weight": 259,
"cookies": false,
"type": "graphql",
"demo": "graphql\/query.md",
@@ -6179,7 +6529,7 @@
"x-appwrite": {
"method": "mutation",
"group": "graphql",
"weight": 249,
"weight": 258,
"cookies": false,
"type": "graphql",
"demo": "graphql\/mutation.md",
@@ -6250,7 +6600,7 @@
"x-appwrite": {
"method": "get",
"group": null,
"weight": 70,
"weight": 75,
"cookies": false,
"type": "",
"demo": "locale\/get.md",
@@ -6301,7 +6651,7 @@
"x-appwrite": {
"method": "listCodes",
"group": null,
"weight": 71,
"weight": 76,
"cookies": false,
"type": "",
"demo": "locale\/list-codes.md",
@@ -6352,7 +6702,7 @@
"x-appwrite": {
"method": "listContinents",
"group": null,
"weight": 75,
"weight": 80,
"cookies": false,
"type": "",
"demo": "locale\/list-continents.md",
@@ -6403,7 +6753,7 @@
"x-appwrite": {
"method": "listCountries",
"group": null,
"weight": 72,
"weight": 77,
"cookies": false,
"type": "",
"demo": "locale\/list-countries.md",
@@ -6454,7 +6804,7 @@
"x-appwrite": {
"method": "listCountriesEU",
"group": null,
"weight": 73,
"weight": 78,
"cookies": false,
"type": "",
"demo": "locale\/list-countries-eu.md",
@@ -6505,7 +6855,7 @@
"x-appwrite": {
"method": "listCountriesPhones",
"group": null,
"weight": 74,
"weight": 79,
"cookies": false,
"type": "",
"demo": "locale\/list-countries-phones.md",
@@ -6556,7 +6906,7 @@
"x-appwrite": {
"method": "listCurrencies",
"group": null,
"weight": 76,
"weight": 81,
"cookies": false,
"type": "",
"demo": "locale\/list-currencies.md",
@@ -6607,7 +6957,7 @@
"x-appwrite": {
"method": "listLanguages",
"group": null,
"weight": 77,
"weight": 82,
"cookies": false,
"type": "",
"demo": "locale\/list-languages.md",
@@ -6660,7 +7010,7 @@
"x-appwrite": {
"method": "createSubscriber",
"group": "subscribers",
"weight": 296,
"weight": 310,
"cookies": false,
"type": "",
"demo": "messaging\/create-subscriber.md",
@@ -6744,7 +7094,7 @@
"x-appwrite": {
"method": "deleteSubscriber",
"group": "subscribers",
"weight": 300,
"weight": 314,
"cookies": false,
"type": "",
"demo": "messaging\/delete-subscriber.md",
@@ -6814,7 +7164,7 @@
"x-appwrite": {
"method": "listFiles",
"group": "files",
"weight": 160,
"weight": 168,
"cookies": false,
"type": "",
"demo": "storage\/list-files.md",
@@ -6896,7 +7246,7 @@
"x-appwrite": {
"method": "createFile",
"group": "files",
"weight": 159,
"weight": 167,
"cookies": false,
"type": "upload",
"demo": "storage\/create-file.md",
@@ -6985,7 +7335,7 @@
"x-appwrite": {
"method": "getFile",
"group": "files",
"weight": 161,
"weight": 169,
"cookies": false,
"type": "",
"demo": "storage\/get-file.md",
@@ -7054,7 +7404,7 @@
"x-appwrite": {
"method": "updateFile",
"group": "files",
"weight": 166,
"weight": 174,
"cookies": false,
"type": "",
"demo": "storage\/update-file.md",
@@ -7142,7 +7492,7 @@
"x-appwrite": {
"method": "deleteFile",
"group": "files",
"weight": 167,
"weight": 175,
"cookies": false,
"type": "",
"demo": "storage\/delete-file.md",
@@ -7211,7 +7561,7 @@
"x-appwrite": {
"method": "getFileDownload",
"group": "files",
"weight": 163,
"weight": 171,
"cookies": false,
"type": "location",
"demo": "storage\/get-file-download.md",
@@ -7289,7 +7639,7 @@
"x-appwrite": {
"method": "getFilePreview",
"group": "files",
"weight": 162,
"weight": 170,
"cookies": false,
"type": "location",
"demo": "storage\/get-file-preview.md",
@@ -7495,7 +7845,7 @@
"x-appwrite": {
"method": "getFileView",
"group": "files",
"weight": 164,
"weight": 172,
"cookies": false,
"type": "location",
"demo": "storage\/get-file-view.md",
@@ -7573,7 +7923,7 @@
"x-appwrite": {
"method": "listRows",
"group": "rows",
"weight": 427,
"weight": 441,
"cookies": false,
"type": "",
"demo": "tablesdb\/list-rows.md",
@@ -7657,7 +8007,7 @@
"x-appwrite": {
"method": "createRow",
"group": "rows",
"weight": 419,
"weight": 433,
"cookies": false,
"type": "",
"demo": "tablesdb\/create-row.md",
@@ -7799,7 +8149,7 @@
"x-appwrite": {
"method": "getRow",
"group": "rows",
"weight": 420,
"weight": 434,
"cookies": false,
"type": "",
"demo": "tablesdb\/get-row.md",
@@ -7891,7 +8241,7 @@
"x-appwrite": {
"method": "upsertRow",
"group": "rows",
"weight": 423,
"weight": 437,
"cookies": false,
"type": "",
"demo": "tablesdb\/upsert-row.md",
@@ -8025,7 +8375,7 @@
"x-appwrite": {
"method": "updateRow",
"group": "rows",
"weight": 421,
"weight": 435,
"cookies": false,
"type": "",
"demo": "tablesdb\/update-row.md",
@@ -8124,7 +8474,7 @@
"x-appwrite": {
"method": "deleteRow",
"group": "rows",
"weight": 425,
"weight": 439,
"cookies": false,
"type": "",
"demo": "tablesdb\/delete-row.md",
@@ -8206,7 +8556,7 @@
"x-appwrite": {
"method": "decrementRowColumn",
"group": "rows",
"weight": 430,
"weight": 444,
"cookies": false,
"type": "",
"demo": "tablesdb\/decrement-row-column.md",
@@ -8317,7 +8667,7 @@
"x-appwrite": {
"method": "incrementRowColumn",
"group": "rows",
"weight": 429,
"weight": 443,
"cookies": false,
"type": "",
"demo": "tablesdb\/increment-row-column.md",
@@ -8426,7 +8776,7 @@
"x-appwrite": {
"method": "list",
"group": "teams",
"weight": 171,
"weight": 179,
"cookies": false,
"type": "",
"demo": "teams\/list.md",
@@ -8500,7 +8850,7 @@
"x-appwrite": {
"method": "create",
"group": "teams",
"weight": 170,
"weight": 178,
"cookies": false,
"type": "",
"demo": "teams\/create.md",
@@ -8589,7 +8939,7 @@
"x-appwrite": {
"method": "get",
"group": "teams",
"weight": 172,
"weight": 180,
"cookies": false,
"type": "",
"demo": "teams\/get.md",
@@ -8650,7 +9000,7 @@
"x-appwrite": {
"method": "updateName",
"group": "teams",
"weight": 174,
"weight": 182,
"cookies": false,
"type": "",
"demo": "teams\/update-name.md",
@@ -8724,7 +9074,7 @@
"x-appwrite": {
"method": "delete",
"group": "teams",
"weight": 176,
"weight": 184,
"cookies": false,
"type": "",
"demo": "teams\/delete.md",
@@ -8785,7 +9135,7 @@
"x-appwrite": {
"method": "listMemberships",
"group": "memberships",
"weight": 178,
"weight": 186,
"cookies": false,
"type": "",
"demo": "teams\/list-memberships.md",
@@ -8867,7 +9217,7 @@
"x-appwrite": {
"method": "createMembership",
"group": "memberships",
"weight": 177,
"weight": 185,
"cookies": false,
"type": "",
"demo": "teams\/create-membership.md",
@@ -8979,7 +9329,7 @@
"x-appwrite": {
"method": "getMembership",
"group": "memberships",
"weight": 179,
"weight": 187,
"cookies": false,
"type": "",
"demo": "teams\/get-membership.md",
@@ -9048,7 +9398,7 @@
"x-appwrite": {
"method": "updateMembership",
"group": "memberships",
"weight": 180,
"weight": 188,
"cookies": false,
"type": "",
"demo": "teams\/update-membership.md",
@@ -9133,7 +9483,7 @@
"x-appwrite": {
"method": "deleteMembership",
"group": "memberships",
"weight": 182,
"weight": 190,
"cookies": false,
"type": "",
"demo": "teams\/delete-membership.md",
@@ -9204,7 +9554,7 @@
"x-appwrite": {
"method": "updateMembershipStatus",
"group": "memberships",
"weight": 181,
"weight": 189,
"cookies": false,
"type": "",
"demo": "teams\/update-membership-status.md",
@@ -9298,7 +9648,7 @@
"x-appwrite": {
"method": "getPrefs",
"group": "teams",
"weight": 173,
"weight": 181,
"cookies": false,
"type": "",
"demo": "teams\/get-prefs.md",
@@ -9359,7 +9709,7 @@
"x-appwrite": {
"method": "updatePrefs",
"group": "teams",
"weight": 175,
"weight": 183,
"cookies": false,
"type": "",
"demo": "teams\/update-prefs.md",
@@ -10373,6 +10723,19 @@
},
"x-example": []
},
"plan": {
"type": "object",
"additionalProperties": true,
"description": "User subscription plan details.",
"x-example": {
"id": "basic",
"name": "Basic",
"price": 999,
"currency": "usd",
"interval": "month",
"isFree": false
}
},
"accessedAt": {
"type": "string",
"description": "Most recent access date in ISO 8601 format. This attribute is only updated again after 24 hours.",
@@ -10421,6 +10784,14 @@
"timezone": "UTC"
},
"targets": [],
"plan": {
"id": "basic",
"name": "Basic",
"price": 999,
"currency": "usd",
"interval": "month",
"isFree": false
},
"accessedAt": "2020-10-15T06:38:00.000+00:00"
}
},
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+333
View File
@@ -5180,3 +5180,336 @@ App::delete('/v1/account/identities/:identityId')
return $response->noContent();
});
App::get('/v1/account/subscription')
->desc('Get account subscription')
->groups(['api', 'account'])
->label('scope', 'account')
->label('sdk', new Method(
namespace: 'account',
group: 'subscription',
name: 'getSubscription',
description: '/docs/references/account/get-subscription.md',
auth: [AuthType::SESSION, AuthType::JWT],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_ANY,
)
]
))
->inject('response')
->inject('user')
->inject('project')
->inject('dbForProject')
->inject('dbForPlatform')
->action(function (Response $response, Document $user, Document $project, Database $dbForProject, Database $dbForPlatform) {
if (!$project->getAttribute('authSubscriptionsEnabled')) {
throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Subscriptions not enabled for this project');
}
$planId = (string) $user->getAttribute('planId', '');
$plan = null;
if ($planId !== '') {
$candidate = Authorization::skip(fn () => $dbForPlatform->findOne('auth_plans', [
Query::equal('projectId', [$project->getId()]),
Query::equal('planId', [$planId]),
Query::equal('active', [true])
]));
$plan = ($candidate instanceof \Utopia\Database\Document && !$candidate->isEmpty()) ? $candidate : null;
}
if (!$plan) {
$candidate = Authorization::skip(fn () => $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;
}
// Final safeguard: return default even if not explicitly marked active
if (!$plan) {
$candidate = Authorization::skip(fn () => $dbForPlatform->findOne('auth_plans', [
Query::equal('projectId', [$project->getId()]),
Query::equal('isDefault', [true])
]));
$plan = ($candidate instanceof \Utopia\Database\Document && !$candidate->isEmpty()) ? $candidate : null;
}
if ($plan) {
$planId = (string) $plan->getAttribute('planId');
}
$response->dynamic(new Document([
'planId' => $planId !== '' ? $planId : null,
'planName' => $plan ? $plan->getAttribute('name') : null,
'status' => $user->getAttribute('subscriptionStatus', 'none'),
'currentPeriodStart' => $user->getAttribute('subscriptionCurrentPeriodStart'),
'currentPeriodEnd' => $user->getAttribute('subscriptionCurrentPeriodEnd'),
'cancelAtPeriodEnd' => $user->getAttribute('subscriptionCancelAtPeriodEnd', false),
'trialEnd' => $user->getAttribute('subscriptionTrialEnd')
]), Response::MODEL_ANY);
});
App::post('/v1/account/subscription/checkout')
->desc('Create checkout session')
->groups(['api', 'account'])
->label('scope', 'account')
->label('sdk', new Method(
namespace: 'account',
group: 'subscription',
name: 'createSubscriptionCheckout',
description: '/docs/references/account/create-checkout.md',
auth: [AuthType::SESSION, AuthType::JWT],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_ANY,
)
]
))
->param('planId', '', new Text(128), 'Plan ID to subscribe to.')
->param('successUrl', '', new URL(), 'URL to redirect on success.')
->param('cancelUrl', '', new URL(), 'URL to redirect on cancel.')
->inject('response')
->inject('user')
->inject('project')
->inject('dbForProject')
->inject('dbForPlatform')
->action(function (
string $planId,
string $successUrl,
string $cancelUrl,
Response $response,
Document $user,
Document $project,
Database $dbForProject,
Database $dbForPlatform
) {
if (!$project->getAttribute('authSubscriptionsEnabled')) {
throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Subscriptions not enabled for this project');
}
$plan = Authorization::skip(fn () => $dbForPlatform->findOne('auth_plans', [
Query::equal('projectId', [$project->getId()]),
Query::equal('planId', [$planId]),
Query::equal('active', [true])
]));
if (!$plan) {
throw new Exception(Exception::GENERAL_NOT_FOUND, 'Plan not found');
}
if ($plan->getAttribute('isFree')) {
throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Cannot checkout for free plan');
}
$stripeService = new \Appwrite\Auth\Subscription\StripeService(
$project->getAttribute('authStripeSecretKey'),
$dbForProject,
$project
);
try {
$session = $stripeService->createCheckoutSession($user, $plan->getAttribute('stripePriceId'), $successUrl, $cancelUrl, (string) $plan->getAttribute('planId'));
$response->dynamic(new Document([
'checkoutUrl' => $session['url']
]), Response::MODEL_ANY);
} catch (\Appwrite\Auth\Subscription\Exception\SubscriptionException $e) {
throw new Exception(Exception::GENERAL_SERVER_ERROR, $e->getMessage());
}
});
App::post('/v1/account/subscription/portal')
->desc('Create customer portal session')
->groups(['api', 'account'])
->label('scope', 'account')
->label('sdk', new Method(
namespace: 'account',
group: 'subscription',
name: 'createSubscriptionPortal',
description: '/docs/references/account/create-portal.md',
auth: [AuthType::SESSION, AuthType::JWT],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_ANY,
)
]
))
->param('returnUrl', '', new URL(), 'URL to return to.')
->inject('response')
->inject('user')
->inject('project')
->inject('dbForProject')
->action(function (
string $returnUrl,
Response $response,
Document $user,
Document $project,
Database $dbForProject
) {
if (!$project->getAttribute('authSubscriptionsEnabled')) {
throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Subscriptions not enabled for this project');
}
$customerId = $user->getAttribute('stripeCustomerId');
if (!$customerId) {
throw new Exception(Exception::GENERAL_BAD_REQUEST, 'No active subscription found');
}
$stripeService = new \Appwrite\Auth\Subscription\StripeService(
$project->getAttribute('authStripeSecretKey'),
$dbForProject,
$project
);
try {
$session = $stripeService->createPortalSession($customerId, $returnUrl);
$response->dynamic(new Document([
'portalUrl' => $session['url']
]), Response::MODEL_ANY);
} catch (\Appwrite\Auth\Subscription\Exception\SubscriptionException $e) {
throw new Exception(Exception::GENERAL_SERVER_ERROR, $e->getMessage());
}
});
App::put('/v1/account/subscription')
->desc('Update subscription')
->groups(['api', 'account'])
->label('scope', 'account')
->label('sdk', new Method(
namespace: 'account',
group: 'subscription',
name: 'updateSubscription',
description: '/docs/references/account/update-subscription.md',
auth: [AuthType::SESSION, AuthType::JWT],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_ANY,
)
]
))
->param('planId', '', new Text(128), 'New plan ID.')
->inject('response')
->inject('user')
->inject('project')
->inject('dbForProject')
->inject('dbForPlatform')
->action(function (
string $planId,
Response $response,
Document $user,
Document $project,
Database $dbForProject,
Database $dbForPlatform
) {
if (!$project->getAttribute('authSubscriptionsEnabled')) {
throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Subscriptions not enabled for this project');
}
$subscriptionId = $user->getAttribute('stripeSubscriptionId');
if (!$subscriptionId) {
throw new Exception(Exception::GENERAL_BAD_REQUEST, 'No active subscription found');
}
$plan = Authorization::skip(fn () => $dbForPlatform->findOne('auth_plans', [
Query::equal('projectId', [$project->getId()]),
Query::equal('planId', [$planId]),
Query::equal('active', [true])
]));
if (!$plan) {
throw new Exception(Exception::GENERAL_NOT_FOUND, 'Plan not found');
}
$stripeService = new \Appwrite\Auth\Subscription\StripeService(
$project->getAttribute('authStripeSecretKey'),
$dbForProject,
$project
);
try {
$subscription = $stripeService->updateSubscription(
$subscriptionId,
$plan->getAttribute('stripePriceId')
);
$stripeService->syncSubscriptionStatus($user->getId(), $subscription);
$response->dynamic(new Document([
'success' => true
]), Response::MODEL_ANY);
} catch (\Appwrite\Auth\Subscription\Exception\SubscriptionException $e) {
throw new Exception(Exception::GENERAL_SERVER_ERROR, $e->getMessage());
}
});
App::delete('/v1/account/subscription')
->desc('Cancel subscription')
->groups(['api', 'account'])
->label('scope', 'account')
->label('sdk', new Method(
namespace: 'account',
group: 'subscription',
name: 'cancelSubscription',
description: '/docs/references/account/cancel-subscription.md',
auth: [AuthType::SESSION, AuthType::JWT],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_ANY,
)
]
))
->param('atPeriodEnd', true, new Boolean(), 'Cancel at period end.', true)
->inject('response')
->inject('user')
->inject('project')
->inject('dbForProject')
->action(function (
bool $atPeriodEnd,
Response $response,
Document $user,
Document $project,
Database $dbForProject
) {
if (!$project->getAttribute('authSubscriptionsEnabled')) {
throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Subscriptions not enabled for this project');
}
$subscriptionId = $user->getAttribute('stripeSubscriptionId');
if (!$subscriptionId) {
throw new Exception(Exception::GENERAL_BAD_REQUEST, 'No active subscription found');
}
$stripeService = new \Appwrite\Auth\Subscription\StripeService(
$project->getAttribute('authStripeSecretKey'),
$dbForProject,
$project
);
try {
$subscription = $stripeService->cancelSubscription($subscriptionId, $atPeriodEnd);
if ($atPeriodEnd) {
$stripeService->syncSubscriptionStatus($user->getId(), $subscription);
}
$response->dynamic(new Document([
'success' => true,
'cancelAtPeriodEnd' => $atPeriodEnd
]), Response::MODEL_ANY);
} catch (\Appwrite\Auth\Subscription\Exception\SubscriptionException $e) {
throw new Exception(Exception::GENERAL_SERVER_ERROR, $e->getMessage());
}
});
+388
View File
@@ -0,0 +1,388 @@
<?php
use Appwrite\Auth\Validator\PlanData;
use Appwrite\Auth\Subscription\StripeService;
use Appwrite\Auth\Subscription\Exception\SubscriptionException;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\App;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Query;
use Utopia\Database\Validator\UID;
use Utopia\Validator\ArrayList;
use Utopia\Validator\Boolean;
use Utopia\Validator\Integer;
use Utopia\Validator\Text;
use Utopia\Validator\WhiteList;
App::post('/v1/projects/:projectId/auth/plans')
->desc('Create auth plan')
->groups(['api'])
->label('scope', 'projects.write')
->label('sdk', new Method(
namespace: 'projects',
group: 'authPlans',
name: 'createAuthPlan',
description: '/docs/references/projects/create-auth-plan.md',
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_CREATED,
model: Response::MODEL_ANY,
)
]
))
->param('projectId', '', new UID(), 'Project unique ID.')
->param('planId', '', new Text(128), 'Plan unique ID.')
->param('name', '', new Text(128), 'Plan name.')
->param('price', 0, new Integer(true), 'Price in cents.')
->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('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)
->inject('response')
->inject('dbForPlatform')
->inject('dbForProject')
->action(function (
string $projectId,
string $planId,
string $name,
int $price,
string $currency,
?string $interval,
string $description,
array $features,
?int $maxUsers,
bool $isDefault,
bool $isFree,
Response $response,
Database $dbForPlatform,
Database $dbForProject
) {
$project = $dbForPlatform->getDocument('projects', $projectId);
if ($project->isEmpty()) {
throw new Exception(Exception::PROJECT_NOT_FOUND);
}
if (!$project->getAttribute('authSubscriptionsEnabled')) {
throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Auth subscriptions not configured for this project');
}
$stripeProductId = null;
$stripePriceId = null;
if (!$isFree) {
try {
$stripeService = new StripeService(
$project->getAttribute('authStripeSecretKey'),
$dbForProject,
$project
);
$product = $stripeService->createProduct($name, $description, $planId);
$stripeProductId = $product['id'];
$priceData = $stripeService->createPrice($stripeProductId, $price, $currency, $interval, $planId);
$stripePriceId = $priceData['id'];
} catch (SubscriptionException $e) {
throw new Exception(Exception::GENERAL_SERVER_ERROR, $e->getMessage());
}
}
if ($isDefault) {
$existingDefault = $dbForPlatform->findOne('auth_plans', [
Query::equal('projectId', [$projectId]),
Query::equal('isDefault', [true])
]);
if ($existingDefault instanceof Document) {
$existingId = $existingDefault->getId();
if (!empty($existingId)) {
$dbForPlatform->updateDocument('auth_plans', $existingId, $existingDefault
->setAttribute('isDefault', false));
}
}
}
$plan = $dbForPlatform->createDocument('auth_plans', new Document([
'$id' => ID::unique(),
'projectInternalId' => $project->getSequence(),
'projectId' => $projectId,
'planId' => $planId,
'name' => $name,
'description' => $description,
'stripeProductId' => $stripeProductId,
'stripePriceId' => $stripePriceId,
'price' => $price,
'currency' => $currency,
'interval' => $interval,
'features' => $features,
'isDefault' => $isDefault,
'isFree' => $isFree,
'maxUsers' => $maxUsers,
'active' => true,
'search' => implode(' ', [$planId, $name, $description])
]));
$response->setStatusCode(Response::STATUS_CODE_CREATED);
$response->dynamic($plan, Response::MODEL_ANY);
});
App::get('/v1/projects/:projectId/auth/plans')
->desc('List auth plans')
->groups(['api'])
->label('scope', 'projects.read')
->label('sdk', new Method(
namespace: 'projects',
group: 'authPlans',
name: 'listAuthPlans',
description: '/docs/references/projects/list-auth-plans.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('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]);
$plans = $dbForPlatform->find('auth_plans', $queries);
$response->dynamic(new Document([
'total' => count($plans),
'documents' => $plans
]), Response::MODEL_DOCUMENT_LIST);
});
App::get('/v1/projects/:projectId/auth/plans/:planId')
->desc('Get auth plan')
->groups(['api'])
->label('scope', 'projects.read')
->label('sdk', new Method(
namespace: 'projects',
group: 'authPlans',
name: 'getAuthPlan',
description: '/docs/references/projects/get-auth-plan.md',
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_ANY,
)
]
))
->param('projectId', '', new UID(), 'Project unique ID.')
->param('planId', '', new Text(128), 'Plan ID.')
->inject('response')
->inject('dbForPlatform')
->inject('dbForProject')
->action(function (string $projectId, string $planId, 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 || !$plan->getAttribute('active', true)) {
throw new Exception(Exception::GENERAL_NOT_FOUND, 'Plan not found');
}
$response->dynamic($plan, Response::MODEL_ANY);
});
App::put('/v1/projects/:projectId/auth/plans/:planId')
->desc('Update auth plan')
->groups(['api'])
->label('scope', 'projects.write')
->label('sdk', new Method(
namespace: 'projects',
group: 'authPlans',
name: 'updateAuthPlan',
description: '/docs/references/projects/update-auth-plan.md',
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_ANY,
)
]
))
->param('projectId', '', new UID(), 'Project unique ID.')
->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('maxUsers', null, new Integer(true), 'Maximum users allowed.', true)
->param('isDefault', null, new Boolean(), 'Is this the default plan.', true)
->inject('response')
->inject('dbForPlatform')
->action(function (
string $projectId,
string $planId,
?string $name,
?string $description,
?array $features,
?int $maxUsers,
?bool $isDefault,
Response $response,
Database $dbForPlatform
) {
$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');
}
if ($plan->getAttribute('isDefault', false) === true) {
throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Cannot delete default plan');
}
if ($isDefault === true) {
$existingDefault = $dbForPlatform->findOne('auth_plans', [
Query::equal('projectId', [$projectId]),
Query::equal('isDefault', [true]),
Query::notEqual('$id', [$plan->getId()])
]);
if ($existingDefault) {
$dbForPlatform->updateDocument('auth_plans', $existingDefault->getId(), $existingDefault
->setAttribute('isDefault', false));
}
}
// Prevent removing the only default plan
if ($isDefault === false && $plan->getAttribute('isDefault', false) === true) {
$otherDefault = $dbForPlatform->findOne('auth_plans', [
Query::equal('projectId', [$projectId]),
Query::equal('isDefault', [true]),
Query::notEqual('$id', [$plan->getId()])
]);
if (!$otherDefault) {
throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Cannot unset default plan without another default');
}
}
if ($name !== null) $plan->setAttribute('name', $name);
if ($description !== null) $plan->setAttribute('description', $description);
if ($features !== null) $plan->setAttribute('features', $features);
if ($maxUsers !== null) $plan->setAttribute('maxUsers', $maxUsers);
if ($isDefault !== null) $plan->setAttribute('isDefault', $isDefault);
$plan->setAttribute('search', implode(' ', [
$plan->getAttribute('planId'),
$plan->getAttribute('name'),
$plan->getAttribute('description')
]));
$plan = $dbForPlatform->updateDocument('auth_plans', $plan->getId(), $plan);
$response->dynamic($plan, Response::MODEL_ANY);
});
App::delete('/v1/projects/:projectId/auth/plans/:planId')
->desc('Delete auth plan')
->groups(['api'])
->label('scope', 'projects.write')
->label('sdk', new Method(
namespace: 'projects',
group: 'authPlans',
name: 'deleteAuthPlan',
description: '/docs/references/projects/delete-auth-plan.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.')
->inject('response')
->inject('dbForPlatform')
->inject('dbForProject')
->action(function (string $projectId, string $planId, 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');
}
// Archive on Stripe first if non-free and keys configured
if (!$plan->getAttribute('isFree', false) && $project->getAttribute('authSubscriptionsEnabled')) {
try {
$stripeService = new StripeService(
$project->getAttribute('authStripeSecretKey'),
$dbForProject,
$project
);
$priceId = $plan->getAttribute('stripePriceId');
$productId = $plan->getAttribute('stripeProductId');
if ($priceId) {
// Deactivate price
$stripeServicePrivate = new \ReflectionClass(StripeService::class);
$method = $stripeServicePrivate->getMethod('makeRequest');
$method->setAccessible(true);
$method->invoke($stripeService, 'POST', '/prices/' . $priceId, ['active' => 'false']);
}
if ($productId) {
// Deactivate product
$stripeServicePrivate = new \ReflectionClass(StripeService::class);
$method = $stripeServicePrivate->getMethod('makeRequest');
$method->setAccessible(true);
$method->invoke($stripeService, 'POST', '/products/' . $productId, ['active' => 'false']);
}
} catch (\Throwable $_) {
// Swallow Stripe errors on delete to ensure local cleanup
}
}
$dbForPlatform->deleteDocument('auth_plans', $plan->getId());
$response->noContent();
});
+180 -2
View File
@@ -3,6 +3,10 @@
use Ahc\Jwt\JWT;
use Appwrite\Auth\Auth;
use Appwrite\Auth\Validator\MockNumber;
use Appwrite\Auth\Validator\StripeKey;
use Appwrite\Auth\Validator\PlanData;
use Appwrite\Auth\Subscription\StripeService;
use Appwrite\Auth\Subscription\Exception\SubscriptionException;
use Appwrite\Event\Delete;
use Appwrite\Event\Mail;
use Appwrite\Event\Validator\Event;
@@ -321,7 +325,8 @@ App::get('/v1/projects/:projectId')
->param('projectId', '', new UID(), 'Project unique ID.')
->inject('response')
->inject('dbForPlatform')
->action(function (string $projectId, Response $response, Database $dbForPlatform) {
->inject('dbForProject')
->action(function (string $projectId, Response $response, Database $dbForPlatform, Database $dbForProject) {
$project = $dbForPlatform->getDocument('projects', $projectId);
@@ -1236,7 +1241,8 @@ App::get('/v1/projects/:projectId/webhooks')
->param('projectId', '', new UID(), 'Project unique ID.')
->inject('response')
->inject('dbForPlatform')
->action(function (string $projectId, Response $response, Database $dbForPlatform) {
->inject('dbForProject')
->action(function (string $projectId, Response $response, Database $dbForPlatform, Database $dbForProject) {
$project = $dbForPlatform->getDocument('projects', $projectId);
@@ -2638,3 +2644,175 @@ App::patch('/v1/projects/:projectId/auth/session-invalidation')
$response->dynamic($project, Response::MODEL_PROJECT);
});
App::put('/v1/projects/:projectId/auth/subscriptions')
->desc('Configure auth subscriptions')
->groups(['api'])
->label('scope', 'projects.write')
->label('sdk', new Method(
namespace: 'projects',
group: 'auth',
name: 'configureSubscriptions',
description: '/docs/references/projects/configure-subscriptions.md',
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_PROJECT,
)
]
))
->param('projectId', '', new UID(), 'Project unique ID.')
->param('stripeSecretKey', '', new StripeKey(), 'Stripe secret key.')
->inject('response')
->inject('dbForPlatform')
->inject('dbForProject')
->action(function (string $projectId, string $stripeSecretKey, Response $response, Database $dbForPlatform, Database $dbForProject) {
$project = $dbForPlatform->getDocument('projects', $projectId);
if ($project->isEmpty()) {
throw new Exception(Exception::PROJECT_NOT_FOUND);
}
try {
$stripeService = new StripeService($stripeSecretKey, $dbForProject, $project);
$config = $stripeService->initializeAccount();
$project = $dbForPlatform->updateDocument('projects', $project->getId(), $project
->setAttribute('authSubscriptionsEnabled', true)
->setAttribute('authStripeSecretKey', $stripeSecretKey)
->setAttribute('authStripePublishableKey', $config['publishableKey'])
->setAttribute('authStripeWebhookSecret', $config['webhookSecret'])
->setAttribute('authStripeWebhookEndpointId', $config['webhookEndpointId'])
->setAttribute('authStripeCurrency', $config['currency']));
// Ensure a default free plan exists
$existingDefault = $dbForPlatform->findOne('auth_plans', [
\Utopia\Database\Query::equal('projectId', [$projectId]),
\Utopia\Database\Query::equal('isDefault', [true])
]);
if (!$existingDefault) {
$dbForPlatform->createDocument('auth_plans', new \Utopia\Database\Document([
'$id' => null,
'projectInternalId' => $project->getSequence(),
'projectId' => $projectId,
'planId' => 'free',
'name' => 'Free',
'description' => 'Default free plan',
'stripeProductId' => null,
'stripePriceId' => null,
'price' => 0,
'currency' => $config['currency'],
'interval' => null,
'features' => [],
'isDefault' => true,
'isFree' => true,
'maxUsers' => null,
'active' => true,
'search' => 'free Default free plan'
]));
}
$response->dynamic($project, Response::MODEL_PROJECT);
} catch (SubscriptionException $e) {
throw new Exception(Exception::GENERAL_SERVER_ERROR, $e->getMessage());
}
});
App::get('/v1/projects/:projectId/auth/subscriptions')
->desc('Get auth subscription configuration')
->groups(['api'])
->label('scope', 'projects.read')
->label('sdk', new Method(
namespace: 'projects',
group: 'auth',
name: 'getSubscriptionsConfig',
description: '/docs/references/projects/get-subscriptions-config.md',
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_PROJECT,
)
]
))
->param('projectId', '', new UID(), 'Project unique ID.')
->inject('response')
->inject('dbForPlatform')
->action(function (string $projectId, Response $response, Database $dbForPlatform) {
$project = $dbForPlatform->getDocument('projects', $projectId);
if ($project->isEmpty()) {
throw new Exception(Exception::PROJECT_NOT_FOUND);
}
$response->dynamic(new Document([
'enabled' => $project->getAttribute('authSubscriptionsEnabled', false),
'publishableKey' => $project->getAttribute('authStripePublishableKey'),
'currency' => $project->getAttribute('authStripeCurrency'),
]), Response::MODEL_ANY);
});
App::delete('/v1/projects/:projectId/auth/subscriptions')
->desc('Remove auth subscription configuration')
->groups(['api'])
->label('scope', 'projects.write')
->label('sdk', new Method(
namespace: 'projects',
group: 'auth',
name: 'removeSubscriptionsConfig',
description: '/docs/references/projects/remove-subscriptions-config.md',
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_PROJECT,
)
]
))
->param('projectId', '', new UID(), 'Project unique ID.')
->inject('response')
->inject('dbForPlatform')
->inject('dbForProject')
->action(function (string $projectId, Response $response, Database $dbForPlatform, Database $dbForProject) {
$project = $dbForPlatform->getDocument('projects', $projectId);
if ($project->isEmpty()) {
throw new Exception(Exception::PROJECT_NOT_FOUND);
}
if ($project->getAttribute('authSubscriptionsEnabled')) {
try {
$stripe = new \Appwrite\Auth\Subscription\StripeService(
$project->getAttribute('authStripeSecretKey'),
$dbForProject,
$project,
$dbForPlatform
);
$endpointId = $project->getAttribute('authStripeWebhookEndpointId');
if (!empty($endpointId)) {
$stripe->deleteWebhookEndpoint($endpointId);
}
$plans = $dbForPlatform->find('auth_plans', [
\Utopia\Database\Query::equal('projectId', [$projectId])
]);
foreach ($plans as $plan) {
$priceId = $plan->getAttribute('stripePriceId');
$productId = $plan->getAttribute('stripeProductId');
try { if ($priceId) { $stripe->deactivatePrice($priceId); } } catch (\Throwable $_) {}
try { if ($productId) { $stripe->deactivateProduct($productId); } } catch (\Throwable $_) {}
$dbForPlatform->deleteDocument('auth_plans', $plan->getId());
}
} catch (\Throwable $_) {}
}
$project = $dbForPlatform->updateDocument('projects', $project->getId(), $project
->setAttribute('authSubscriptionsEnabled', false)
->setAttribute('authStripeSecretKey', null)
->setAttribute('authStripePublishableKey', null)
->setAttribute('authStripeWebhookSecret', null)
->setAttribute('authStripeWebhookEndpointId', null)
->setAttribute('authStripeCurrency', null));
$response->dynamic($project, Response::MODEL_PROJECT);
});
+174 -2
View File
@@ -607,7 +607,9 @@ App::get('/v1/users')
->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true)
->inject('response')
->inject('dbForProject')
->action(function (array $queries, string $search, Response $response, Database $dbForProject) {
->inject('dbForPlatform')
->inject('project')
->action(function (array $queries, string $search, Response $response, Database $dbForProject, Database $dbForPlatform, Document $project) {
try {
$queries = Query::parseQueries($queries);
@@ -658,6 +660,49 @@ App::get('/v1/users')
}
}, ['subQueryAuthenticators', 'subQuerySessions', 'subQueryTokens', 'subQueryChallenges', 'subQueryMemberships']);
$planIds = [];
foreach ($users as $u) {
$pid = (string) $u->getAttribute('planId', '');
if ($pid !== '') {
$planIds[$pid] = true;
}
}
$planMap = [];
if (!empty($planIds)) {
$plans = $dbForPlatform->find('auth_plans', [
Query::equal('projectId', [$project->getId()]),
Query::equal('planId', array_keys($planIds)),
Query::equal('active', [true])
]);
foreach ($plans as $p) {
$planMap[(string) $p->getAttribute('planId')] = $p;
}
}
$defaultPlan = $dbForPlatform->findOne('auth_plans', [
Query::equal('projectId', [$project->getId()]),
Query::equal('isDefault', [true]),
Query::equal('active', [true])
]);
foreach ($users as $u) {
$pid = (string) $u->getAttribute('planId', '');
$p = null;
if ($pid !== '' && isset($planMap[$pid])) {
$p = $planMap[$pid];
} elseif ($defaultPlan) {
$p = $defaultPlan;
}
if ($p) {
$u->setAttribute('plan', [
'id' => $p->getAttribute('planId'),
'name' => $p->getAttribute('name'),
'price' => $p->getAttribute('price'),
'currency' => $p->getAttribute('currency'),
'interval' => $p->getAttribute('interval'),
'isFree' => $p->getAttribute('isFree', false)
]);
}
}
$response->dynamic(new Document([
'users' => $users,
'total' => $total,
@@ -684,7 +729,9 @@ App::get('/v1/users/:userId')
->param('userId', '', new UID(), 'User ID.')
->inject('response')
->inject('dbForProject')
->action(function (string $userId, Response $response, Database $dbForProject) {
->inject('dbForPlatform')
->inject('project')
->action(function (string $userId, Response $response, Database $dbForProject, Database $dbForPlatform, Document $project) {
$user = $dbForProject->getDocument('users', $userId);
@@ -692,6 +739,131 @@ App::get('/v1/users/:userId')
throw new Exception(Exception::USER_NOT_FOUND);
}
$pid = (string) $user->getAttribute('planId', '');
$plan = null;
if ($pid !== '') {
$plan = $dbForPlatform->findOne('auth_plans', [
Query::equal('projectId', [$project->getId()]),
Query::equal('planId', [$pid]),
Query::equal('active', [true])
]);
} else {
$plan = $dbForPlatform->findOne('auth_plans', [
Query::equal('projectId', [$project->getId()]),
Query::equal('isDefault', [true]),
Query::equal('active', [true])
]);
}
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)
]);
}
$response->dynamic($user, Response::MODEL_USER);
});
App::post('/v1/users/:userId/plan')
->desc('Assign plan to user (admin)')
->groups(['api', 'users'])
->label('scope', 'users.write')
->label('sdk', new Method(
namespace: 'users',
group: 'users',
name: 'assignPlan',
description: '/docs/references/users/assign-plan.md',
auth: [AuthType::KEY],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_USER,
)
]
))
->param('userId', '', new UID(), 'User ID.')
->param('planId', '', new Text(128), 'Plan ID to assign.')
->param('complimentary', true, new Boolean(), 'If true, create a complimentary Stripe subscription for one billing interval.', true)
->inject('response')
->inject('project')
->inject('dbForProject')
->inject('dbForPlatform')
->action(function (string $userId, string $planId, bool $complimentary, Response $response, Document $project, Database $dbForProject, Database $dbForPlatform) {
$user = $dbForProject->getDocument('users', $userId);
if ($user->isEmpty()) {
throw new Exception(Exception::USER_NOT_FOUND);
}
$plan = $dbForPlatform->findOne('auth_plans', [
Query::equal('projectId', [$project->getId()]),
Query::equal('planId', [$planId]),
Query::equal('active', [true])
]);
if (!$plan) {
throw new Exception(Exception::GENERAL_NOT_FOUND, 'Plan not found');
}
$user->setAttribute('planId', $planId);
$now = time();
$interval = (string) $plan->getAttribute('interval', 'month');
$seconds = match ($interval) {
'day' => 86400,
'week' => 86400 * 7,
'year' => 86400 * 365,
default => 86400 * 30,
};
$user->setAttribute('subscriptionStatus', 'active');
$user->setAttribute('subscriptionCurrentPeriodStart', DateTime::format(new \DateTime('@' . $now)));
$user->setAttribute('subscriptionCurrentPeriodEnd', DateTime::format(new \DateTime('@' . ($now + $seconds))));
$user->setAttribute('subscriptionCancelAtPeriodEnd', true);
$user->setAttribute('subscriptionTrialEnd', DateTime::format(new \DateTime('@' . ($now + $seconds))));
if ($plan->getAttribute('isFree', false) && $project->getAttribute('authSubscriptionsEnabled')) {
$stripe = new \Appwrite\Auth\Subscription\StripeService(
$project->getAttribute('authStripeSecretKey'),
$dbForProject,
$project
);
$subscriptionId = (string) $user->getAttribute('stripeSubscriptionId', '');
if ($subscriptionId === '') {
$customerId = (string) $user->getAttribute('stripeCustomerId', '');
if ($customerId !== '') {
try {
$found = $stripe->findActiveSubscriptionId($customerId);
if (!empty($found)) {
$subscriptionId = (string) $found;
}
} catch (\Throwable $_) {
}
}
}
if ($subscriptionId !== '') {
try {
$stripe->cancelSubscription($subscriptionId, false);
} catch (\Throwable $_) {
}
$user->setAttribute('stripeSubscriptionId', null);
}
} elseif ($complimentary && !$plan->getAttribute('isFree', false) && $project->getAttribute('authSubscriptionsEnabled')) {
$stripe = new \Appwrite\Auth\Subscription\StripeService(
$project->getAttribute('authStripeSecretKey'),
$dbForProject,
$project
);
$customerId = (string) $user->getAttribute('stripeCustomerId', '');
if ($customerId === '') {
$customerId = $stripe->ensureCustomer($user);
$user->setAttribute('stripeCustomerId', $customerId);
}
$stripe->createComplimentarySubscription($customerId, (string) $plan->getAttribute('stripePriceId'), $interval, $user->getId());
}
$user = $dbForProject->updateDocument('users', $userId, $user);
$response->dynamic($user, Response::MODEL_USER);
});
+60 -1
View File
@@ -51,6 +51,7 @@ use Utopia\Logger\Logger;
use Utopia\Platform\Service;
use Utopia\System\System;
use Utopia\Validator\Text;
use Utopia\Database\Validator\UID;
Config::setParam('domainVerification', false);
Config::setParam('cookieDomain', 'localhost');
@@ -191,7 +192,7 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw
$resourceId = $rule->getAttribute('deploymentResourceId', '');
$type = ($resourceType === 'site') ? 'sites' : 'functions';
$exception = new AppwriteException(AppwriteException::DEPLOYMENT_NOT_FOUND, view: $errorView);
$exception->addCTA('View deployments', $url . '/console/project-' . $project->getAttribute('region', 'default') . '-' . $projectId . '/' . $type . '/' . $resourceType . '-' . $resourceId);
$exception->addCTA('View deployments', $url . '/console/project-' . $project->getAttribute('region', 'default') . '-' . $project->getId() . '/' . $type . '/' . $resourceType . '-' . $resourceId);
throw $exception;
}
@@ -1640,3 +1641,61 @@ if (!empty(Method::getErrors())) {
$platform = new Appwrite();
$platform->init(Service::TYPE_HTTP);
App::post('/v1/webhooks/stripe/auth/:projectId')
->desc('Handle Stripe webhook for auth subscriptions')
->groups(['webhooks'])
->label('scope', 'public')
->inject('request')
->inject('response')
->inject('dbForPlatform')
->inject('getProjectDB')
->param('projectId', '', new UID(), 'Project unique ID.')
->action(function (string $projectId, $request, $response, $dbForPlatform, callable $getProjectDB) {
$payload = $request->getRawPayload();
$signature = $request->getHeader('stripe-signature') ?: $request->getHeader('Stripe-Signature') ?: ($_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '');
if (empty($signature)) {
throw new AppwriteException(AppwriteException::GENERAL_BAD_REQUEST, 'Missing Stripe signature');
}
$event = json_decode($payload, true);
$project = Authorization::skip(fn () => $dbForPlatform->getDocument('projects', $projectId));
if ($project->isEmpty()) {
throw new AppwriteException(AppwriteException::PROJECT_NOT_FOUND);
}
$dbForProject = $getProjectDB($project);
$webhookSecret = $project->getAttribute('authStripeWebhookSecret');
if (empty($webhookSecret)) {
throw new AppwriteException(AppwriteException::GENERAL_BAD_REQUEST, 'Webhook not configured');
}
$tmpService = new \Appwrite\Auth\Subscription\StripeService(
$project->getAttribute('authStripeSecretKey'),
$dbForProject,
$project
);
if (!$tmpService->verifyWebhookSignature($payload, $signature, $webhookSecret)) {
throw new AppwriteException(AppwriteException::GENERAL_ACCESS_FORBIDDEN, 'Invalid webhook signature');
}
$platformDb = $dbForPlatform;
$stripeService = new \Appwrite\Auth\Subscription\StripeService(
$project->getAttribute('authStripeSecretKey'),
$dbForProject,
$project,
$platformDb
);
try {
$stripeService->handleWebhook($event ?? []);
$response->json(['success' => true]);
} catch (\Appwrite\Auth\Subscription\Exception\SubscriptionException $e) {
throw new AppwriteException(AppwriteException::GENERAL_SERVER_ERROR, $e->getMessage());
}
});
+1
View File
@@ -213,6 +213,7 @@ services:
- _APP_DATABASE_SHARED_NAMESPACE
- _APP_FUNCTIONS_CREATION_ABUSE_LIMIT
- _APP_CUSTOM_DOMAIN_DENY_LIST
- _APP_AUTH_STRIPE_WEBHOOK_URL
extra_hosts:
- "host.docker.internal:host-gateway"
@@ -0,0 +1,13 @@
<?php
namespace Appwrite\Auth\Subscription\Exception;
use Exception;
class SubscriptionException extends Exception
{
public function __construct(string $message = '', int $code = 0, Exception $previous = null)
{
parent::__construct($message, $code, $previous);
}
}
@@ -0,0 +1,854 @@
<?php
namespace Appwrite\Auth\Subscription;
use Appwrite\Auth\Subscription\Exception\SubscriptionException;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Query;
use Utopia\Database\Validator\Authorization;
class StripeService
{
private string $apiKey;
private string $apiUrl = 'https://api.stripe.com/v1';
private Database $database; // Project DB
private ?Database $platformDb = null; // Platform (console) DB for cross-project collections
private Document $project;
public function __construct(string $apiKey, Database $database, Document $project, ?Database $platformDb = null)
{
$this->apiKey = $apiKey;
$this->database = $database;
$this->project = $project;
$this->platformDb = $platformDb;
}
/**
* Initialize Stripe account for the project
* @throws SubscriptionException
*/
public function initializeAccount(): array
{
$account = $this->getAccount();
$currency = $account['default_currency'] ?? 'usd';
$webhookEndpoint = $this->createWebhookEndpoint();
return [
'currency' => $currency,
'webhookEndpointId' => $webhookEndpoint['id'],
'webhookSecret' => $webhookEndpoint['secret'],
'publishableKey' => $this->extractPublishableKey()
];
}
/**
* Get Stripe account details
* @throws SubscriptionException
*/
private function getAccount(): array
{
$response = $this->makeRequest('GET', '/account');
if (isset($response['error'])) {
throw new SubscriptionException($response['error']['message'] ?? 'Failed to get Stripe account');
}
return $response;
}
/**
* Extract publishable key from secret key
*/
private function extractPublishableKey(): string
{
if (strpos($this->apiKey, 'sk_test_') === 0) {
return 'pk_test_' . substr($this->apiKey, 8);
} elseif (strpos($this->apiKey, 'sk_live_') === 0) {
return 'pk_live_' . substr($this->apiKey, 8);
}
throw new SubscriptionException('Invalid Stripe secret key format');
}
/**
* Create webhook endpoint
* @throws SubscriptionException
*/
private function createWebhookEndpoint(): array
{
$base = getenv('_APP_AUTH_STRIPE_WEBHOOK_URL');
if (!$base) {
$host = $_SERVER['HTTP_HOST'] ?? '';
$base = 'https://' . $host;
}
if (strpos($base, '{projectId}') !== false || strpos($base, ':projectId') !== false) {
$webhookUrl = str_replace(['{projectId}', ':projectId'], $this->project->getId(), $base);
} else {
$normalized = rtrim($base, '/');
if (str_ends_with($normalized, '/v1/webhooks/stripe/auth')) {
$webhookUrl = $normalized . '/' . $this->project->getId();
} else {
$webhookUrl = $normalized . '/v1/webhooks/stripe/auth/' . $this->project->getId();
}
}
$params = [
'url' => $webhookUrl,
'enabled_events' => [
'checkout.session.completed',
'customer.subscription.created',
'customer.subscription.updated',
'customer.subscription.deleted',
'invoice.payment_failed',
'invoice.payment_succeeded',
'product.updated',
'product.deleted',
'price.updated',
'price.deleted'
],
'description' => 'Appwrite Auth Subscription Webhook for Project ' . $this->project->getId()
];
$response = $this->makeRequest('POST', '/webhook_endpoints', $params);
if (isset($response['error'])) {
throw new SubscriptionException($response['error']['message'] ?? 'Failed to create webhook endpoint');
}
return $response;
}
public function deleteWebhookEndpoint(string $endpointId): array
{
return $this->makeRequest('DELETE', '/webhook_endpoints/' . $endpointId);
}
/**
* Create Stripe product
* @throws SubscriptionException
*/
public function createProduct(string $name, string $description = '', ?string $planId = null): array
{
$params = [
'name' => $name,
'description' => $description,
'metadata' => [
'project_id' => $this->project->getId(),
'plan_id' => $planId,
'type' => 'auth_plan'
]
];
$response = $this->makeRequest('POST', '/products', $params);
if (isset($response['error'])) {
throw new SubscriptionException($response['error']['message'] ?? 'Failed to create product');
}
return $response;
}
/**
* Create Stripe price
* @throws SubscriptionException
*/
public function createPrice(string $productId, int $amount, string $currency, ?string $interval = null, ?string $planId = null): array
{
$params = [
'product' => $productId,
'unit_amount' => $amount,
'currency' => $currency,
'metadata' => [
'project_id' => $this->project->getId(),
'plan_id' => $planId,
'type' => 'auth_plan_price'
]
];
if ($interval) {
$params['recurring'] = ['interval' => $interval];
}
$response = $this->makeRequest('POST', '/prices', $params);
if (isset($response['error'])) {
throw new SubscriptionException($response['error']['message'] ?? 'Failed to create price');
}
return $response;
}
/**
* Deactivate a Stripe price
* @throws SubscriptionException
*/
public function deactivatePrice(string $priceId): array
{
return $this->makeRequest('POST', '/prices/' . $priceId, ['active' => 'false']);
}
/**
* Deactivate a Stripe product
* @throws SubscriptionException
*/
public function deactivateProduct(string $productId): array
{
return $this->makeRequest('POST', '/products/' . $productId, ['active' => 'false']);
}
/**
* Create checkout session
* @throws SubscriptionException
*/
public function createCheckoutSession(Document $user, string $priceId, string $successUrl, string $cancelUrl, ?string $planId = null): array
{
$params = [
'mode' => 'subscription',
'line_items' => [
[
'price' => $priceId,
'quantity' => 1
]
],
'success_url' => $successUrl,
'cancel_url' => $cancelUrl,
'customer_email' => $user->getAttribute('email'),
'client_reference_id' => $user->getId(),
'metadata' => array_filter([
'user_id' => $user->getId(),
'project_id' => $this->project->getId(),
'plan_id' => $planId,
]),
'subscription_data' => [
'metadata' => array_filter([
'user_id' => $user->getId(),
'project_id' => $this->project->getId(),
'plan_id' => $planId,
])
]
];
if ($user->getAttribute('stripeCustomerId')) {
$params['customer'] = $user->getAttribute('stripeCustomerId');
unset($params['customer_email']);
}
$response = $this->makeRequest('POST', '/checkout/sessions', $params);
if (isset($response['error'])) {
throw new SubscriptionException($response['error']['message'] ?? 'Failed to create checkout session');
}
return $response;
}
/**
* Create customer portal session
* @throws SubscriptionException
*/
public function createPortalSession(string $customerId, string $returnUrl): array
{
$params = [
'customer' => $customerId,
'return_url' => $returnUrl
];
$response = $this->makeRequest('POST', '/billing_portal/sessions', $params);
if (isset($response['error'])) {
throw new SubscriptionException($response['error']['message'] ?? 'Failed to create portal session');
}
return $response;
}
/**
* Ensure Stripe customer exists for user, return customer ID
*/
public function ensureCustomer(Document $user): string
{
$customerId = (string) $user->getAttribute('stripeCustomerId', '');
if ($customerId !== '') {
return $customerId;
}
$params = [
'email' => $user->getAttribute('email'),
'metadata' => [
'project_id' => $this->project->getId(),
'user_id' => $user->getId()
]
];
$response = $this->makeRequest('POST', '/customers', $params);
if (isset($response['error'])) {
throw new SubscriptionException($response['error']['message'] ?? 'Failed to create customer');
}
return (string) ($response['id'] ?? '');
}
/**
* Create a complimentary subscription (trial for one interval, auto-cancel)
*/
public function createComplimentarySubscription(string $customerId, string $priceId, string $interval, ?string $userId = null): array
{
$now = time();
$seconds = 0;
switch ($interval) {
case 'day':
$seconds = 86400; break;
case 'week':
$seconds = 86400 * 7; break;
case 'year':
$seconds = 86400 * 365; break;
case 'month':
default:
$seconds = 86400 * 30; break;
}
$trialEnd = $now + $seconds;
$params = [
'customer' => $customerId,
'items' => [[ 'price' => $priceId ]],
'trial_end' => $trialEnd,
'cancel_at' => $trialEnd,
'payment_behavior' => 'default_incomplete',
'metadata' => [
'project_id' => $this->project->getId(),
'user_id' => $userId
]
];
$response = $this->makeRequest('POST', '/subscriptions', $params);
if (isset($response['error'])) {
throw new SubscriptionException($response['error']['message'] ?? 'Failed to create subscription');
}
return $response;
}
/**
* Cancel active subscription immediately or at period end
*/
public function cancelSubscription(string $subscriptionId, bool $atPeriodEnd = true): array
{
$params = [ 'cancel_at_period_end' => $atPeriodEnd ? 'true' : 'false' ];
$response = $this->makeRequest('DELETE', '/subscriptions/' . $subscriptionId, $params);
if (isset($response['error'])) {
throw new SubscriptionException($response['error']['message'] ?? 'Failed to cancel subscription');
}
return $response;
}
/**
* Find an active (or trialing) subscription ID for a customer
*/
public function findActiveSubscriptionId(string $customerId): ?string
{
$params = [
'customer' => $customerId,
'limit' => 100
];
$response = $this->makeRequest('GET', '/subscriptions', $params);
if (!isset($response['data']) || !is_array($response['data'])) {
return null;
}
foreach ($response['data'] as $sub) {
$status = (string) ($sub['status'] ?? '');
if (in_array($status, ['active', 'trialing', 'past_due', 'unpaid'], true)) {
return (string) ($sub['id'] ?? '');
}
}
return null;
}
/**
* Get subscription details
* @throws SubscriptionException
*/
public function getSubscription(string $subscriptionId): array
{
$response = $this->makeRequest('GET', '/subscriptions/' . $subscriptionId);
if (isset($response['error'])) {
throw new SubscriptionException($response['error']['message'] ?? 'Failed to get subscription');
}
return $response;
}
// duplicate removed
/**
* Update subscription (change plan)
* @throws SubscriptionException
*/
public function updateSubscription(string $subscriptionId, string $newPriceId): array
{
$subscription = $this->getSubscription($subscriptionId);
$params = [
'items' => [
[
'id' => $subscription['items']['data'][0]['id'],
'price' => $newPriceId
]
],
'proration_behavior' => 'create_prorations'
];
$response = $this->makeRequest('POST', '/subscriptions/' . $subscriptionId, $params);
if (isset($response['error'])) {
throw new SubscriptionException($response['error']['message'] ?? 'Failed to update subscription');
}
return $response;
}
/**
* Verify webhook signature
*/
public function verifyWebhookSignature(string $payload, string $signature, string $secret): bool
{
$timestamp = null;
$v1Signatures = [];
foreach (explode(',', (string) $signature) as $segment) {
$segment = trim($segment);
$parts = explode('=', $segment, 2);
if (count($parts) !== 2) {
continue;
}
if ($parts[0] === 't') {
$timestamp = $parts[1];
}
if ($parts[0] === 'v1') {
$v1Signatures[] = $parts[1];
}
}
if (!$timestamp || empty($v1Signatures)) {
return false;
}
$signedPayload = $timestamp . '.' . $payload;
$expected = hash_hmac('sha256', $signedPayload, trim((string) $secret));
foreach ($v1Signatures as $sig) {
if (hash_equals($expected, $sig)) {
return true;
}
}
return false;
}
/**
* Handle webhook event
* @throws SubscriptionException
*/
public function handleWebhook(array $event): void
{
switch ($event['type']) {
case 'checkout.session.completed':
$this->handleCheckoutCompleted($event['data']['object']);
break;
case 'customer.subscription.created':
case 'customer.subscription.updated':
$this->handleSubscriptionUpdate($event['data']['object']);
break;
case 'customer.subscription.deleted':
$this->handleSubscriptionDeleted($event['data']['object']);
break;
case 'invoice.payment_failed':
$this->handlePaymentFailed($event['data']['object']);
break;
case 'invoice.payment_succeeded':
$this->handlePaymentSucceeded($event['data']['object']);
break;
// Keep Appwrite in sync with Stripe-side changes
case 'product.updated':
$this->handleProductUpdated($event['data']['object']);
break;
case 'product.deleted':
$this->handleProductDeleted($event['data']['object']);
break;
case 'price.updated':
$this->handlePriceUpdated($event['data']['object']);
break;
case 'price.deleted':
$this->handlePriceDeleted($event['data']['object']);
break;
}
}
/**
* Product updated
*/
private function handleProductUpdated(array $product): void
{
if ($this->platformDb === null) return;
$stripeProductId = $product['id'] ?? '';
if (empty($stripeProductId)) return;
$plan = $this->platformDb->findOne('auth_plans', [
Query::equal('stripeProductId', [$stripeProductId]),
Query::equal('projectId', [$this->project->getId()])
]);
if (!$plan) return;
$plan->setAttribute('name', $product['name'] ?? $plan->getAttribute('name'));
if (isset($product['active']) && $product['active'] === false) {
// If product deactivated, mark plan inactive
$plan->setAttribute('active', false);
}
$this->platformDb->updateDocument('auth_plans', $plan->getId(), $plan);
}
/**
* Product deleted
*/
private function handleProductDeleted(array $product): void
{
if ($this->platformDb === null) return;
$stripeProductId = $product['id'] ?? '';
if (empty($stripeProductId)) return;
$plan = $this->platformDb->findOne('auth_plans', [
Query::equal('stripeProductId', [$stripeProductId]),
Query::equal('projectId', [$this->project->getId()])
]);
if ($plan) {
$this->platformDb->deleteDocument('auth_plans', $plan->getId());
}
}
/**
* Price updated
*/
private function handlePriceUpdated(array $price): void
{
if ($this->platformDb === null) return;
$stripePriceId = $price['id'] ?? '';
if (empty($stripePriceId)) return;
$plan = $this->platformDb->findOne('auth_plans', [
Query::equal('stripePriceId', [$stripePriceId]),
Query::equal('projectId', [$this->project->getId()])
]);
if (!$plan) return;
if (isset($price['active']) && $price['active'] === false) {
$plan->setAttribute('active', false);
}
if (isset($price['unit_amount'])) {
$plan->setAttribute('price', (int)$price['unit_amount']);
}
if (isset($price['currency'])) {
$plan->setAttribute('currency', $price['currency']);
}
if (isset($price['recurring']['interval'])) {
$plan->setAttribute('interval', $price['recurring']['interval']);
}
$this->platformDb->updateDocument('auth_plans', $plan->getId(), $plan);
}
/**
* Price deleted
*/
private function handlePriceDeleted(array $price): void
{
if ($this->platformDb === null) return;
$stripePriceId = $price['id'] ?? '';
if (empty($stripePriceId)) return;
$plan = $this->platformDb->findOne('auth_plans', [
Query::equal('stripePriceId', [$stripePriceId]),
Query::equal('projectId', [$this->project->getId()])
]);
if ($plan) {
$this->platformDb->deleteDocument('auth_plans', $plan->getId());
}
}
/**
* Handle checkout completed
*/
private function handleCheckoutCompleted(array $session): void
{
$userId = $session['metadata']['user_id'] ?? null;
if (!$userId) {
return;
}
$user = Authorization::skip(fn () => $this->database->getDocument('users', $userId));
if ($user->isEmpty()) {
return;
}
$updated = $user
->setAttribute('stripeCustomerId', $session['customer'])
->setAttribute('stripeSubscriptionId', $session['subscription']);
Authorization::skip(fn () => $this->database->updateDocument('users', $userId, $updated));
if ($session['subscription']) {
$subscription = $this->getSubscription($session['subscription']);
$this->syncSubscriptionStatus($userId, $subscription);
}
}
/**
* Handle subscription update
*/
private function handleSubscriptionUpdate(array $subscription): void
{
$userId = $subscription['metadata']['user_id'] ?? null;
if (!$userId) {
return;
}
$full = isset($subscription['id']) ? $this->getSubscription($subscription['id']) : $subscription;
$this->syncSubscriptionStatus($userId, $full);
}
/**
* Handle subscription deleted
*/
private function handleSubscriptionDeleted(array $subscription): void
{
$userId = $subscription['metadata']['user_id'] ?? null;
if (!$userId) {
return;
}
$user = Authorization::skip(fn () => $this->database->getDocument('users', $userId));
if ($user->isEmpty()) {
return;
}
$defaultPlan = null;
if ($this->platformDb) {
$defaultPlan = $this->platformDb->findOne('auth_plans', [
Query::equal('projectId', [$this->project->getId()]),
Query::equal('isDefault', [true]),
Query::equal('isFree', [true])
]);
}
$updated = $user
->setAttribute('stripeSubscriptionId', null)
->setAttribute('subscriptionStatus', 'none')
->setAttribute('subscriptionCurrentPeriodStart', null)
->setAttribute('subscriptionCurrentPeriodEnd', null)
->setAttribute('subscriptionCancelAtPeriodEnd', false)
->setAttribute('planId', $defaultPlan ? $defaultPlan->getAttribute('planId') : null);
Authorization::skip(fn () => $this->database->updateDocument('users', $userId, $updated));
}
/**
* Handle payment failed
*/
private function handlePaymentFailed(array $invoice): void
{
$subscriptionId = $invoice['subscription'] ?? null;
if (!$subscriptionId) {
return;
}
$subscription = $this->getSubscription($subscriptionId);
$userId = $subscription['metadata']['user_id'] ?? null;
if ($userId) {
$user = Authorization::skip(fn () => $this->database->getDocument('users', $userId));
if (!$user->isEmpty()) {
$updated = $user->setAttribute('subscriptionStatus', 'past_due');
Authorization::skip(fn () => $this->database->updateDocument('users', $userId, $updated));
}
}
}
/**
* Handle payment succeeded
*/
private function handlePaymentSucceeded(array $invoice): void
{
$subscriptionId = $invoice['subscription'] ?? null;
if (!$subscriptionId) {
return;
}
$subscription = $this->getSubscription($subscriptionId);
$userId = $subscription['metadata']['user_id'] ?? null;
if ($userId) {
$this->syncSubscriptionStatus($userId, $subscription);
}
}
/**
* Sync subscription status to user
*/
public function syncSubscriptionStatus(string $userId, array $subscription): void
{
$user = Authorization::skip(fn () => $this->database->getDocument('users', $userId));
if ($user->isEmpty()) {
return;
}
$priceId = $subscription['items']['data'][0]['price']['id'] ?? null;
$status = (string) ($subscription['status'] ?? '');
$cancelAtPeriodEnd = (bool) ($subscription['cancel_at_period_end'] ?? false);
$periodStart = isset($subscription['current_period_start']) ? date('c', $subscription['current_period_start']) : null;
$periodEndTs = $subscription['current_period_end'] ?? null;
$periodEnd = $periodEndTs ? date('c', $periodEndTs) : null;
$plan = null;
if ($priceId && $this->platformDb) {
$plan = Authorization::skip(fn () => $this->platformDb->findOne('auth_plans', [
Query::equal('stripePriceId', [$priceId]),
Query::equal('projectId', [$this->project->getId()])
]));
}
if (!$plan && $this->platformDb) {
$productId = $subscription['items']['data'][0]['price']['product'] ?? null;
if (!empty($productId)) {
$fallback = Authorization::skip(fn () => $this->platformDb->findOne('auth_plans', [
Query::equal('stripeProductId', [$productId]),
Query::equal('projectId', [$this->project->getId()])
]));
if ($fallback) {
$plan = $fallback;
}
}
}
if (!$plan && $this->platformDb) {
$metaPlanId = $subscription['metadata']['plan_id'] ?? null;
if (!empty($metaPlanId)) {
$fallback = Authorization::skip(fn () => $this->platformDb->findOne('auth_plans', [
Query::equal('planId', [$metaPlanId]),
Query::equal('projectId', [$this->project->getId()])
]));
if ($fallback) {
$plan = $fallback;
}
}
}
$fallbackToFree = ($status === 'canceled' || $status === 'incomplete_expired' || ($cancelAtPeriodEnd && $periodEndTs && time() >= (int) $periodEndTs));
$defaultPlan = null;
if ($fallbackToFree && $this->platformDb) {
$defaultPlan = Authorization::skip(fn () => $this->platformDb->findOne('auth_plans', [
Query::equal('projectId', [$this->project->getId()]),
Query::equal('isDefault', [true]),
Query::equal('isFree', [true])
]));
}
$updated = $user
->setAttribute('planId', $fallbackToFree && $defaultPlan ? $defaultPlan->getAttribute('planId') : ($plan ? $plan->getAttribute('planId') : null))
->setAttribute('subscriptionStatus', $status)
->setAttribute('subscriptionCurrentPeriodStart', $periodStart)
->setAttribute('subscriptionCurrentPeriodEnd', $periodEnd)
->setAttribute('subscriptionCancelAtPeriodEnd', $cancelAtPeriodEnd)
->setAttribute('subscriptionTrialEnd', isset($subscription['trial_end']) ? date('c', $subscription['trial_end']) : null);
Authorization::skip(fn () => $this->database->updateDocument('users', $userId, $updated));
}
/**
* Make request to Stripe API
*/
private function makeRequest(string $method, string $path, array $params = []): array
{
$ch = curl_init();
$url = $this->apiUrl . $path;
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer ' . $this->apiKey,
'Content-Type: application/x-www-form-urlencoded'
]);
switch ($method) {
case 'GET':
if (!empty($params)) {
$url .= '?' . http_build_query($params);
}
break;
case 'POST':
curl_setopt($ch, CURLOPT_POST, true);
if (!empty($params)) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $this->buildFormData($params));
}
break;
case 'DELETE':
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
break;
}
curl_setopt($ch, CURLOPT_URL, $url);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($response === false) {
throw new SubscriptionException('Failed to connect to Stripe API');
}
$data = json_decode($response, true);
if ($httpCode >= 400) {
if (isset($data['error'])) {
throw new SubscriptionException($data['error']['message'] ?? 'Stripe API error', $httpCode);
}
throw new SubscriptionException('Stripe API request failed', $httpCode);
}
return $data;
}
/**
* Build form data for nested arrays
*/
private function buildFormData(array $params, string $prefix = ''): string
{
$data = [];
foreach ($params as $key => $value) {
$key = $prefix ? "{$prefix}[{$key}]" : $key;
if (is_array($value)) {
if (isset($value[0])) {
foreach ($value as $index => $item) {
if (is_array($item)) {
$data[] = $this->buildFormData($item, "{$key}[{$index}]");
} else {
$data[] = urlencode("{$key}[{$index}]") . '=' . urlencode($item);
}
}
} else {
$data[] = $this->buildFormData($value, $key);
}
} else {
$data[] = urlencode($key) . '=' . urlencode($value);
}
}
return implode('&', $data);
}
}
+78
View File
@@ -0,0 +1,78 @@
<?php
namespace Appwrite\Auth\Validator;
use Utopia\Validator;
class PlanData extends Validator
{
/**
* Get Description
*/
public function getDescription(): string
{
return 'Value must be a valid plan data structure';
}
/**
* Is valid
*
* @param mixed $value
*/
public function isValid($value): bool
{
if (!is_array($value)) {
return false;
}
if (!isset($value['name']) || empty($value['name'])) {
return false;
}
if (!isset($value['planId']) || empty($value['planId'])) {
return false;
}
if (!preg_match('/^[a-zA-Z0-9_-]+$/', $value['planId'])) {
return false;
}
if (isset($value['price']) && (!is_int($value['price']) || $value['price'] < 0)) {
return false;
}
if (isset($value['currency']) && !preg_match('/^[a-z]{3}$/', $value['currency'])) {
return false;
}
if (isset($value['interval']) && !in_array($value['interval'], ['month', 'year', 'week', 'day'])) {
return false;
}
if (isset($value['maxUsers']) && (!is_int($value['maxUsers']) || $value['maxUsers'] < 0)) {
return false;
}
if (isset($value['features']) && !is_array($value['features'])) {
return false;
}
return true;
}
/**
* Is array
*/
public function isArray(): bool
{
return false;
}
/**
* Get Type
*/
public function getType(): string
{
return self::TYPE_OBJECT;
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace Appwrite\Auth\Validator;
use Utopia\Validator;
class StripeKey extends Validator
{
/**
* Get Description
*/
public function getDescription(): string
{
return 'Value must be a valid Stripe API key';
}
/**
* Is valid
*
* @param mixed $value
*/
public function isValid($value): bool
{
if (!is_string($value)) {
return false;
}
if (empty($value)) {
return false;
}
if (preg_match('/^sk_(test|live)_[0-9a-zA-Z]{24,}$/', $value)) {
return true;
}
if (preg_match('/^rk_(test|live)_[0-9a-zA-Z]{24,}$/', $value)) {
return true;
}
return false;
}
/**
* Is array
*/
public function isArray(): bool
{
return false;
}
/**
* Get Type
*/
public function getType(): string
{
return self::TYPE_STRING;
}
}
@@ -0,0 +1,58 @@
<?php
namespace Appwrite\Auth\Validator;
use Utopia\Validator;
class SubscriptionStatus extends Validator
{
private const VALID_STATUSES = [
'none',
'active',
'canceled',
'incomplete',
'incomplete_expired',
'past_due',
'trialing',
'unpaid',
'paused'
];
/**
* Get Description
*/
public function getDescription(): string
{
return 'Value must be a valid subscription status';
}
/**
* Is valid
*
* @param mixed $value
*/
public function isValid($value): bool
{
if (!is_string($value)) {
return false;
}
return in_array($value, self::VALID_STATUSES);
}
/**
* Is array
*/
public function isArray(): bool
{
return false;
}
/**
* Get Type
*/
public function getType(): string
{
return self::TYPE_STRING;
}
}
+1
View File
@@ -62,6 +62,7 @@ class Exception extends \Exception
public const GENERAL_INVALID_PHONE = 'general_invalid_phone';
public const GENERAL_REGION_ACCESS_DENIED = 'general_region_access_denied';
public const GENERAL_BAD_REQUEST = 'general_bad_request';
public const GENERAL_NOT_FOUND = 'general_not_found';
/** Users */
public const USER_COUNT_EXCEEDED = 'user_count_exceeded';
@@ -133,6 +133,20 @@ class User extends Model
'array' => true,
'example' => [],
])
->addRule('plan', [
'type' => self::TYPE_JSON,
'description' => 'User subscription plan details.',
'required' => false,
'default' => new \stdClass(),
'example' => [
'id' => 'basic',
'name' => 'Basic',
'price' => 999,
'currency' => 'usd',
'interval' => 'month',
'isFree' => false
],
])
->addRule('accessedAt', [
'type' => self::TYPE_DATETIME,
'description' => 'Most recent access date in ISO 8601 format. This attribute is only updated again after ' . APP_USER_ACCESS / 60 / 60 . ' hours.',