mirror of
https://github.com/appwrite/appwrite.git
synced 2026-05-26 13:51:13 +00:00
init payments
This commit is contained in:
@@ -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
@@ -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
@@ -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
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
|
||||
@@ -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' => [],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -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],
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
|
||||
@@ -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' => [
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
@@ -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());
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.',
|
||||
|
||||
Reference in New Issue
Block a user