From e885cece8545e0708b538d5a06986ea5dfa316b6 Mon Sep 17 00:00:00 2001 From: Atharva Deosthale Date: Tue, 30 Sep 2025 23:44:14 +0530 Subject: [PATCH] format + lil cleanup --- .env | 2 +- INSTRUCTIONS.md | 237 ---- TEMP_FEATURES.md | 64 - TEMP_SCHEMA.md | 1046 ----------------- app/config/collections/auth_plans.php | 120 +- app/controllers/api/account.php | 24 +- app/controllers/api/auth-plans.php | 334 +++--- app/controllers/api/projects.php | 39 +- app/controllers/api/users.php | 107 +- app/controllers/general.php | 5 +- .../Exception/SubscriptionException.php | 2 +- .../Auth/Subscription/StripeService.php | 142 ++- src/Appwrite/Auth/Validator/PlanData.php | 2 +- src/Appwrite/Auth/Validator/StripeKey.php | 2 +- .../Auth/Validator/SubscriptionStatus.php | 2 +- tmp_check_geodb.php | 19 - 16 files changed, 519 insertions(+), 1628 deletions(-) delete mode 100644 INSTRUCTIONS.md delete mode 100644 TEMP_FEATURES.md delete mode 100644 TEMP_SCHEMA.md delete mode 100644 tmp_check_geodb.php diff --git a/.env b/.env index f195406195..b291aa8629 100644 --- a/.env +++ b/.env @@ -124,4 +124,4 @@ _APP_WEBHOOK_MAX_FAILED_ATTEMPTS=10 _APP_PROJECT_REGIONS=default _APP_FUNCTIONS_CREATION_ABUSE_LIMIT=5000 _APP_STATS_USAGE_DUAL_WRITING_DBS=database_db_main -_APP_AUTH_STRIPE_WEBHOOK_URL=https://12ee42ab449a.ngrok-free.app \ No newline at end of file +_APP_AUTH_STRIPE_WEBHOOK_URL=https://b568b886f9db.ngrok-free.app \ No newline at end of file diff --git a/INSTRUCTIONS.md b/INSTRUCTIONS.md deleted file mode 100644 index 07c22994d9..0000000000 --- a/INSTRUCTIONS.md +++ /dev/null @@ -1,237 +0,0 @@ -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] -``` diff --git a/TEMP_FEATURES.md b/TEMP_FEATURES.md deleted file mode 100644 index 9f2d067a6b..0000000000 --- a/TEMP_FEATURES.md +++ /dev/null @@ -1,64 +0,0 @@ -# Auth Features Structure - -## Collections - -- `auth_features` (project-level catalog) - - - `featureId` (string): unique feature identifier - - `name` (string): display name - - `type` (string): `boolean` or `metered` - - `description` (string) - - `active` (boolean) - -- `auth_plan_features` (assignment of features to plans) - - - `projectId` (string) - - `planId` (string) - - `featureId` (string) - - `type` (string): `boolean` or `metered` - - For `boolean`: - - `enabled` (boolean) - - For `metered`: - - `currency` (string, 3 letters) - - `interval` (string: day|week|month|year) - - `includedUnits` (int) - - `tiersMode` (string: graduated|volume) - - `tiers` (array of objects): each `{ to: number|"inf", unitAmount: number, flatAmount?: number }` - - `stripePriceId` (string): linked Stripe price for this feature - - `active` (boolean) - -- `auth_plans` (unchanged fields plus Stripe product/price for base plan) - -## Endpoints - -- Manage features (project-level): - - - `POST /v1/projects/:projectId/auth/features` - - `GET /v1/projects/:projectId/auth/features` - - `PUT /v1/projects/:projectId/auth/features/:featureId` - - `DELETE /v1/projects/:projectId/auth/features/:featureId` - -- Assign features to a plan: - - `POST /v1/projects/:projectId/auth/plans/:planId/features` - - `GET /v1/projects/:projectId/auth/plans/:planId/features` - -## Stripe Mapping - -- Base plan uses `auth_plans.stripePriceId` for subscription primary item. -- Each metered feature assignment creates a separate Stripe Price with: - - `usage_type = metered`, `billing_scheme = tiered`, `tiers_mode = graduated|volume`, `aggregate_usage = sum` - - Backed by a Stripe Meter; `stripeMeterId` is stored in `auth_plan_features` - - Tiers include a free tier for `includedUnits` with `unit_amount = 0` - - Price `nickname` = `Feature: {feature.name}` - - `metadata`: `project_id`, `plan_id`, `feature_id`, `type = auth_plan_feature_price` - -## Checkout Behavior - -- Checkout adds the plan price item plus one line item per assigned metered feature price. -- Boolean features apply immediately on plan assignment and do not generate additional Stripe items. - -## Evaluation - -- User effective features are derived from plan: - - Booleans: enabled if assignment exists with `enabled=true`. - - Metered: tracked externally via Stripe usage records tied to `stripePriceId`; included units are tiered free usage. diff --git a/TEMP_SCHEMA.md b/TEMP_SCHEMA.md deleted file mode 100644 index e8d07c5625..0000000000 --- a/TEMP_SCHEMA.md +++ /dev/null @@ -1,1046 +0,0 @@ -# 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. Each plan embeds `features` resolved from `auth_plan_features` (including `usageCap` for metered features). - -**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": 2, - "plans": [ - { - "$id": "64a5f8e7c3d2a", - "planId": "free", - "name": "Free Plan", - "price": 0, - "currency": "usd", - "interval": "month", - "isFree": true, - "isDefault": true, - "features": [ - { "featureId": "custom-domains", "type": "boolean", "enabled": true } - ] - }, - { - "$id": "64a5f8e7c3d2b", - "planId": "basic", - "name": "Basic Plan", - "price": 999, - "currency": "usd", - "interval": "month", - "features": [ - { - "featureId": "premium-api-calls", - "type": "metered", - "currency": "usd", - "interval": "month", - "includedUnits": 20, - "tiersMode": "graduated", - "tiers": [ - { "up_to": 20, "unit_amount": 0 }, - { "up_to": "inf", "unit_amount": 100 } - ], - "stripePriceId": "price_123" - } - ] - } - ] -} -``` - -**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. Response embeds `features` identical in shape to the List endpoint (including `usageCap` for metered features). - -**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 with embedded `features` - -**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. - -When `features` is provided, it is treated as the full source of truth for the plan's assignments and the backend reconciles as follows: - -- Upsert: Each provided feature assignment is created or updated in `auth_plan_features` -- Soft-delete: Any existing assignment not present in the payload is marked `active=false` -- Stripe cleanup: For removed metered assignments, the associated Stripe price is deactivated - -**Authentication:** Admin API Key - -**Path Parameters:** -| 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 | Full list of current feature assignments | No | -| maxUsers | integer | Max users allowed | No | -| isDefault | boolean | Set as default plan | No | - -**Response (200 OK):** Updated plan object (features are read from GET/LIST) - -**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 - ---- - -## Auth Features Management Endpoints - -### 8.a Create Auth Feature - -`POST /v1/projects/{projectId}/auth/features` - -Description: Create a reusable feature definition at the project level. Features can be assigned to plans. - -Authentication: Admin API Key - -Path Parameters: -| Parameter | Type | Description | Required | -|-----------|------|-------------|----------| -| projectId | string | Project unique ID | Yes | - -Request Body: -| Field | Type | Description | Required | -|-------|------|-------------|----------| -| featureId | string | Unique feature ID | Yes | -| name | string | Feature display name | Yes | -| type | string | Feature type (`boolean` or `metered`) | Yes | -| description | string | Description | No | - -Response (201 Created): Feature object - -Errors: - -- 401 - Unauthorized -- 404 - Project not found -- 409 - Feature already exists - ---- - -### 8.b List Auth Features - -`GET /v1/projects/{projectId}/auth/features` - -Description: List all active features for the project. - -Authentication: Admin API Key - -Path Parameters: -| Parameter | Type | Description | Required | -|-----------|------|-------------|----------| -| projectId | string | Project unique ID | Yes | - -Response (200 OK): Document list of features - -Errors: - -- 401 - Unauthorized -- 404 - Project not found - ---- - -### 8.c Update Auth Feature - -`PUT /v1/projects/{projectId}/auth/features/{featureId}` - -Description: Update a feature definition. - -Authentication: Admin API Key - -Path Parameters: -| Parameter | Type | Description | Required | -|-----------|------|-------------|----------| -| projectId | string | Project unique ID | Yes | -| featureId | string | Feature unique ID | Yes | - -Request Body: -| Field | Type | Description | Required | -|-------|------|-------------|----------| -| name | string | Feature name | No | -| type | string | `boolean` or `metered` | No | -| description | string | Description | No | -| active | boolean | Active state | No | - -Response (200 OK): Updated feature object - -Errors: - -- 401 - Unauthorized -- 404 - Project or Feature not found - ---- - -### 8.d Delete Auth Feature - -`DELETE /v1/projects/{projectId}/auth/features/{featureId}` - -Description: Delete a feature. - -Authentication: Admin API Key - -Path Parameters: -| Parameter | Type | Description | Required | -|-----------|------|-------------|----------| -| projectId | string | Project unique ID | Yes | -| featureId | string | Feature unique ID | Yes | - -Response (204 No Content): Empty - -Errors: - -- 401 - Unauthorized -- 404 - Project or Feature not found - ---- - -## Plan Feature Assignment Endpoints - -### 8.e Assign Features to Plan - -`POST /v1/projects/{projectId}/auth/plans/{planId}/features` - -Description: Assign features to a plan. Creates Stripe metered tiered prices for metered features and links them. - -Authentication: Admin API Key - -Path Parameters: -| Parameter | Type | Description | Required | -|-----------|------|-------------|----------| -| projectId | string | Project unique ID | Yes | -| planId | string | Plan ID | Yes | - -Request Body: -| Field | Type | Description | Required | -|-------|------|-------------|----------| -| features | array | List of feature assignments | Yes | - -Feature assignment object (two shapes): - -- Boolean feature: - { - "featureId": "custom-domains", - "type": "boolean", - "enabled": true - } - -- Metered feature: - { - "featureId": "seats", - "type": "metered", - "currency": "usd", - "interval": "month", - "includedUnits": 100, - "tiersMode": "graduated", - "tiers": [ - { "to": 500, "unitAmount": 100 }, - { "to": 1000, "unitAmount": 200 }, - { "to": "inf", "unitAmount": 300 } - ] - } - -Notes: - -- includedUnits are applied as a free tier in Stripe (unit_amount=0 up to includedUnits) -- Stripe price uses billing_scheme=tiered, usage_type=metered, tiers_mode=graduated|volume, and references a Stripe Meter (required by Stripe versions >= 2025-03-31.basil) -- Amounts are accepted in major units (e.g., USD dollars); backend converts to minor units (cents), handling zero-decimal currencies -- `tiers` sent to Stripe are in `{ up_to, unit_amount }` with the last tier `up_to = "inf"` -- Price nickname is set to "Feature: {feature.name}" and metadata includes `feature_id`, `plan_id`, `project_id` -- Optional `usageCap` (integer or null) can be provided on metered features to cap ingestion per billing period. Ingestion beyond the cap is rejected and not sent to Stripe. - -Response (200 OK): Document list of created/updated assignments - -Errors: - -- 400 - Invalid feature assignment -- 401 - Unauthorized -- 404 - Project, Plan or Feature not found -- 500 - Stripe error for metered features - ---- - -### 8.f List Plan Features - -`GET /v1/projects/{projectId}/auth/plans/{planId}/features` - -Description: List active features assigned to a plan. - -Authentication: Admin API Key - -Response (200 OK): Document list of assignments - ---- - -### 8.g Delete Plan Feature - -`DELETE /v1/projects/{projectId}/auth/plans/{planId}/features/{featureId}` - -Description: Soft-delete a single feature assignment from a plan. For metered assignments, deactivates the Stripe price. - -Authentication: Admin API Key - -Response (204 No Content) - -Errors: - -- 401 - Unauthorized -- 404 - Project or Plan Feature not found - ---- - -### 8.h Delete Multiple Plan Features - -`DELETE /v1/projects/{projectId}/auth/plans/{planId}/features` - -Description: Bulk remove feature assignments from a plan by IDs. Deactivates Stripe prices for removed metered features. - -Authentication: Admin API Key - -Request Body: -| Field | Type | Description | Required | -|-------|------|-------------|----------| -| featureIds | array | Feature IDs to remove | Yes | - -Response (200 OK): Document list of updated assignments - -Errors: - -- 401 - Unauthorized -- 404 - Project not found - ---- - -## User Subscription Endpoints - -### 9. Get Account Subscription - -`GET /v1/account/subscription` - -**Description:** Get current user's subscription details, including assigned features and current-period usage for metered features. - -**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, - "features": [ - { - "featureId": "requests", - "type": "metered", - "enabled": true, - "currency": "usd", - "interval": "month", - "includedUnits": 100000, - "tiersMode": "graduated", - "tiers": [ - { "to": 100000, "unitAmount": 0 }, - { "to": "inf", "unitAmount": 0.5 } - ], - "usage": 12345, - "usageCap": 200000 - }, - { "featureId": "team-members", "type": "boolean", "enabled": true } - ] -} -``` - -**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..." -} - -Notes: -- Assigned metered feature prices, if any, are added as additional subscription line items in the checkout session. -``` - -**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 - ---- - -### 10.a Ingest Usage - -`POST /v1/usage/ingest` - -**Description:** Ingest usage events for metered features. Sends usage to Stripe Billing Meters. - -**Authentication:** - -- Admin API Key (server-to-server), or -- Session/JWT (client SDK) -- Scope: `account` - -**Behavior:** - -- With API key: `userId` is required and honored. -- With Session/JWT: `userId` is ignored; the logged-in user is used. - -**Request Body:** -| Field | Type | Description | Required | -|-------|------|-------------|----------| -| planId | string | Plan ID to attribute usage to | Yes | -| featureId | string | Feature ID to attribute usage to | Yes | -| value | integer | Usage value to ingest | Yes | -| userId | string | User ID (ignored on client SDK) | No | -| timestamp | integer | Unix timestamp for the usage event | No | -| identifier | string | Idempotency identifier for the event | No | - -**Response (200 OK):** - -```json -{ - "success": true, - "id": "mevt_123" -} -``` - -**Errors:** - -- `400` - Subscriptions not enabled / Feature not metered / Missing userId for API key mode -- `401` - Unauthorized -- `404` - Feature assignment not found -- `500` - Stripe ingestion error - ---- - -### 11. Create Customer Portal Session - -`POST /v1/account/subscription/portal` - -**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 | -| 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 | - -### Auth Features Collection - -| Field | Type | Description | Index | Unique | -| ----------------- | ----------- | -------------------------- | -------- | -------------- | -| $id | string | Document ID | Primary | Yes | -| projectInternalId | string | Internal project reference | Yes | No | -| projectId | string | Project ID | Yes | No | -| featureId | string | Feature identifier | Yes | With projectId | -| name | string(128) | Feature name | No | No | -| type | string(16) | `boolean` or `metered` | Yes | No | -| description | string(256) | Feature description | No | No | -| active | boolean | Active status | Yes | No | -| search | string | Full-text search | Fulltext | No | - -Indexes: - -- \_key_project: projectId -- \_key_project_featureId: projectId + featureId (unique) -- \_fulltext_search: search - -### Auth Plan Features Collection - -| Field | Type | Description | Index | Unique | -| ----------------- | ----------- | -------------------------------- | ------- | ------------------------ | -| $id | string | Document ID | Primary | Yes | -| projectInternalId | string | Internal project reference | Yes | No | -| projectId | string | Project ID | Yes | No | -| planId | string | Plan ID | Yes | With projectId+featureId | -| featureId | string | Feature ID | Yes | With projectId+planId | -| type | string(16) | `boolean` or `metered` | Yes | No | -| enabled | boolean | For boolean features | No | No | -| currency | string(3) | For metered features | No | No | -| interval | string(10) | For metered features | No | No | -| includedUnits | integer | Free included units | No | No | -| tiersMode | string(16) | `graduated` or `volume` | No | No | -| tiers | array | Tier definitions | No | No | -| stripePriceId | string(256) | Stripe price for metered feature | No | No | -| stripeMeterId | string(256) | Stripe meter backing the price | No | No | -| active | boolean | Active status | Yes | No | -| $createdAt | datetime | Creation timestamp | No | No | -| $updatedAt | datetime | Last update timestamp | No | No | - -**Indexes:** - -- `_key_project`: projectId -- `_key_project_plan`: projectId + planId -- `_key_project_plan_feature` (unique): projectId + planId + featureId -- `_key_active`: active - -### 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. **Assign Features**: Admin assigns features → For metered features, backend creates/links Stripe Meter and tiered metered price -4. **User Subscription**: User initiates checkout → Redirected to Stripe; additional line items include metered feature prices -5. **Webhook Processing**: Stripe sends events → User record updated -6. **Management**: User uses portal or API → Subscription modified in Stripe - -### Migration Considerations - -- 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 diff --git a/app/config/collections/auth_plans.php b/app/config/collections/auth_plans.php index 128a4c275d..50bdac1f4c 100644 --- a/app/config/collections/auth_plans.php +++ b/app/config/collections/auth_plans.php @@ -555,4 +555,122 @@ return [ ], ], ], -]; \ No newline at end of file + 'auth_usage_events' => [ + '$collection' => ID::custom(Database::METADATA), + '$id' => ID::custom('auth_usage_events'), + 'name' => 'Auth Usage Events', + '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('userId'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('planId'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('featureId'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('value'), + 'type' => Database::VAR_INTEGER, + 'format' => '', + 'size' => 0, + 'signed' => false, + 'required' => true, + 'default' => 0, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('identifier'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 256, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('timestamp'), + 'type' => Database::VAR_DATETIME, + 'format' => '', + 'size' => 0, + 'signed' => false, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => ['datetime'], + ], + ], + 'indexes' => [ + [ + '$id' => ID::custom('_key_project_user_feature_time'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['projectId', 'userId', 'featureId', 'timestamp'], + 'lengths' => [Database::LENGTH_KEY, Database::LENGTH_KEY, Database::LENGTH_KEY, 0], + 'orders' => [Database::ORDER_ASC, Database::ORDER_ASC, Database::ORDER_ASC, Database::ORDER_ASC], + ], + [ + '$id' => ID::custom('_key_project_plan_feature_time'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['projectId', 'planId', 'featureId', 'timestamp'], + 'lengths' => [Database::LENGTH_KEY, Database::LENGTH_KEY, Database::LENGTH_KEY, 0], + 'orders' => [Database::ORDER_ASC, Database::ORDER_ASC, Database::ORDER_ASC, Database::ORDER_ASC], + ], + [ + '$id' => ID::custom('_key_identifier'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['identifier'], + 'lengths' => [256], + 'orders' => [Database::ORDER_ASC], + ], + ], + ], +]; diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 5e977c1299..a48ee4ff82 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -2,11 +2,11 @@ use Ahc\Jwt\JWT; use Appwrite\Auth\Auth; +use Appwrite\Auth\Key; use Appwrite\Auth\MFA\Challenge; use Appwrite\Auth\MFA\Type; use Appwrite\Auth\MFA\Type\TOTP; use Appwrite\Auth\OAuth2\Exception as OAuth2Exception; -use Appwrite\Auth\Key; use Appwrite\Auth\Phrase; use Appwrite\Auth\Validator\Password; use Appwrite\Auth\Validator\PasswordDictionary; @@ -62,8 +62,8 @@ use Utopia\Locale\Locale; use Utopia\System\System; use Utopia\Validator\ArrayList; use Utopia\Validator\Assoc; -use Utopia\Validator\Integer; use Utopia\Validator\Boolean; +use Utopia\Validator\Integer; use Utopia\Validator\Text; use Utopia\Validator\URL; use Utopia\Validator\WhiteList; @@ -5265,10 +5265,24 @@ App::get('/v1/account/subscription') foreach ($assigned as $af) { $featureId = (string) $af->getAttribute('featureId'); $usage = 0; - if ((string)$af->getAttribute('type') === 'metered' && $customerId !== '' && $startTs > 0) { + if ((string)$af->getAttribute('type') === 'metered') { + $featureStartTs = $startTs; + $featureEndTs = $endTs; + if ($featureStartTs <= 0 || $featureEndTs <= 0) { + $interval = (string) $af->getAttribute('interval', 'month'); + $seconds = match ($interval) { + 'day' => 86400, + 'week' => 86400 * 7, + 'year' => 86400 * 365, + default => 86400 * 30, + }; + $featureEndTs = time(); + $featureStartTs = $featureEndTs - $seconds; + } try { - $usage = $stripeService->getFeatureUsageTotal($customerId, $planId, $featureId, $startTs, $endTs); - } catch (\Throwable $_) {} + $usage = $stripeService->getFeatureUsageTotalLocal($user->getId(), $planId, $featureId, $featureStartTs, $featureEndTs); + } catch (\Throwable $_) { + } } $features[] = [ 'featureId' => $featureId, diff --git a/app/controllers/api/auth-plans.php b/app/controllers/api/auth-plans.php index 8f0d96497c..8445e95589 100644 --- a/app/controllers/api/auth-plans.php +++ b/app/controllers/api/auth-plans.php @@ -1,8 +1,7 @@ desc('Create auth plan') @@ -701,11 +700,21 @@ App::put('/v1/projects/:projectId/auth/plans/:planId') } } - 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); + 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'), @@ -727,10 +736,14 @@ App::put('/v1/projects/:projectId/auth/plans/:planId') $providedIds = []; foreach ($features as $cfg) { - if (!is_array($cfg)) continue; + if (!is_array($cfg)) { + continue; + } $fid = (string)($cfg['featureId'] ?? ''); $type = (string)($cfg['type'] ?? ''); - if ($fid === '' || ($type !== 'boolean' && $type !== 'metered')) continue; + if ($fid === '' || ($type !== 'boolean' && $type !== 'metered')) { + continue; + } $providedIds[] = $fid; $assignment = $existingById[$fid] ?? null; $doc = $assignment ?: new Document(['$id' => ID::unique()]); @@ -760,152 +773,152 @@ App::put('/v1/projects/:projectId/auth/plans/:planId') $doc->setAttribute('usageCap', $cfg['usageCap'] === null ? null : (int)$cfg['usageCap']); } } - if ($assignment) { - if ($type === 'metered' && $project->getAttribute('authSubscriptionsEnabled')) { - try { - $ss = new StripeService( - $project->getAttribute('authStripeSecretKey'), - $dbForProject, - $project, - $dbForPlatform - ); - $productId = (string)$plan->getAttribute('stripeProductId'); - if (!$productId) { - $p = $ss->createProduct($plan->getAttribute('name'), $plan->getAttribute('description', ''), (string)$plan->getAttribute('planId')); - $productId = (string)$p['id']; - $plan->setAttribute('stripeProductId', $productId); - $dbForPlatform->updateDocument('auth_plans', $plan->getId(), $plan); - } - $feature = $dbForPlatform->findOne('auth_features', [ - Query::equal('projectId', [$projectId]), - Query::equal('featureId', [$fid]) - ]); - $featureName = $feature ? (string)$feature->getAttribute('name', $fid) : $fid; - $prev = [ - 'currency' => (string)$assignment->getAttribute('currency'), - 'interval' => (string)$assignment->getAttribute('interval'), - 'includedUnits' => (int)$assignment->getAttribute('includedUnits', 0), - 'tiersMode' => (string)$assignment->getAttribute('tiersMode'), - 'tiers' => (array)$assignment->getAttribute('tiers', []) - ]; - $next = [ - 'currency' => (string)$doc->getAttribute('currency'), - 'interval' => (string)$doc->getAttribute('interval'), - 'includedUnits' => (int)$doc->getAttribute('includedUnits', 0), - 'tiersMode' => (string)$doc->getAttribute('tiersMode'), - 'tiers' => (array)$doc->getAttribute('tiers', []) - ]; - $tiersChanged = json_encode(array_values($prev['tiers'])) !== json_encode(array_values($next['tiers'])) || ($prev['tiersMode'] !== $next['tiersMode']); - $metaChanged = $prev['currency'] !== $next['currency'] || $prev['interval'] !== $next['interval'] || $prev['includedUnits'] !== $next['includedUnits']; - $oldPriceId = (string)$assignment->getAttribute('stripePriceId', ''); - // If nothing changed, still verify on Stripe whether the existing price matches the expected shape. - $forceRecreate = false; - if ($oldPriceId !== '') { - try { - $curr = $ss->getPrice($oldPriceId); - $currMode = (string)($curr['tiers_mode'] ?? ''); - $currUsage = (string)($curr['recurring']['usage_type'] ?? ''); - $currCurrency = (string)($curr['currency'] ?? ''); - $currInterval = (string)($curr['recurring']['interval'] ?? ''); - if ($currMode !== $next['tiersMode'] || $currUsage !== 'metered' || $currCurrency !== $next['currency'] || ($next['interval'] && $currInterval !== $next['interval'])) { - $forceRecreate = true; - } - // Compare tier boundaries with expected (including includedUnits free tier) - $currTiers = isset($curr['tiers']) && is_array($curr['tiers']) ? $curr['tiers'] : []; - $currUpTo = []; - foreach ($currTiers as $t) { - $currUpTo[] = isset($t['up_to']) ? (string)$t['up_to'] : ''; - } - $expectedUpTo = []; - $inc = (int)$next['includedUnits']; - if ($inc > 0) { - $expectedUpTo[] = (string)$inc; - } - foreach ((array)$next['tiers'] as $tierX) { - $to = $tierX['to'] ?? 'inf'; - $expectedUpTo[] = $to === 'inf' ? 'inf' : (string)$to; - } - if (json_encode($currUpTo) !== json_encode($expectedUpTo)) { - $forceRecreate = true; - } - // Ensure free tier is truly free if includedUnits > 0 - if ($inc > 0 && isset($currTiers[0]['unit_amount']) && (int)$currTiers[0]['unit_amount'] !== 0) { - $forceRecreate = true; - } - error_log(json_encode(['plan_put' => 'remote_check', 'featureId' => $fid, 'currUpTo' => $currUpTo, 'expectedUpTo' => $expectedUpTo, 'forceRecreate' => $forceRecreate])); - } catch (SubscriptionException $e) { - $forceRecreate = true; - } - } - if ($oldPriceId === '' || $tiersChanged || $metaChanged || $forceRecreate) { - $meterId = $ss->ensureMeterForFeature((string)$plan->getAttribute('planId'), $fid, $featureName); - if ($meterId) { - $doc->setAttribute('stripeMeterId', $meterId); - } - $newPrice = $ss->createMeteredTieredPrice( - $productId, - $next['currency'], - $next['interval'] ?: null, - $next['includedUnits'], - $next['tiersMode'], - $next['tiers'], - (string)$plan->getAttribute('planId'), - $fid, - $featureName - ); - $doc->setAttribute('stripePriceId', $newPrice['id'] ?? null); - if ($oldPriceId !== '') { - $ss->deactivatePrice($oldPriceId); - } - } - } catch (SubscriptionException $e) { - throw $e; - } - } - $dbForPlatform->updateDocument('auth_plan_features', $assignment->getId(), $doc); - } else { - if ($type === 'metered' && $project->getAttribute('authSubscriptionsEnabled')) { - try { - $ss = new StripeService( - $project->getAttribute('authStripeSecretKey'), - $dbForProject, - $project, - $dbForPlatform - ); - $productId = (string)$plan->getAttribute('stripeProductId'); - if (!$productId) { - $p = $ss->createProduct($plan->getAttribute('name'), $plan->getAttribute('description', ''), (string)$plan->getAttribute('planId')); - $productId = (string)$p['id']; - $plan->setAttribute('stripeProductId', $productId); - $dbForPlatform->updateDocument('auth_plans', $plan->getId(), $plan); - } - $feature = $dbForPlatform->findOne('auth_features', [ - Query::equal('projectId', [$projectId]), - Query::equal('featureId', [$fid]) - ]); - $featureName = $feature ? (string)$feature->getAttribute('name', $fid) : $fid; - $meterId = $ss->ensureMeterForFeature((string)$plan->getAttribute('planId'), $fid, $featureName); - if ($meterId) { - $doc->setAttribute('stripeMeterId', $meterId); - } - $newPrice = $ss->createMeteredTieredPrice( - $productId, - (string)$doc->getAttribute('currency'), - (string)$doc->getAttribute('interval') ?: null, - (int)$doc->getAttribute('includedUnits', 0), - (string)$doc->getAttribute('tiersMode'), - (array)$doc->getAttribute('tiers', []), - (string)$plan->getAttribute('planId'), - $fid, - $featureName - ); - $doc->setAttribute('stripePriceId', $newPrice['id'] ?? null); - } catch (SubscriptionException $e) { - throw $e; - } - } - $dbForPlatform->createDocument('auth_plan_features', $doc); - } + if ($assignment) { + if ($type === 'metered' && $project->getAttribute('authSubscriptionsEnabled')) { + try { + $ss = new StripeService( + $project->getAttribute('authStripeSecretKey'), + $dbForProject, + $project, + $dbForPlatform + ); + $productId = (string)$plan->getAttribute('stripeProductId'); + if (!$productId) { + $p = $ss->createProduct($plan->getAttribute('name'), $plan->getAttribute('description', ''), (string)$plan->getAttribute('planId')); + $productId = (string)$p['id']; + $plan->setAttribute('stripeProductId', $productId); + $dbForPlatform->updateDocument('auth_plans', $plan->getId(), $plan); + } + $feature = $dbForPlatform->findOne('auth_features', [ + Query::equal('projectId', [$projectId]), + Query::equal('featureId', [$fid]) + ]); + $featureName = $feature ? (string)$feature->getAttribute('name', $fid) : $fid; + $prev = [ + 'currency' => (string)$assignment->getAttribute('currency'), + 'interval' => (string)$assignment->getAttribute('interval'), + 'includedUnits' => (int)$assignment->getAttribute('includedUnits', 0), + 'tiersMode' => (string)$assignment->getAttribute('tiersMode'), + 'tiers' => (array)$assignment->getAttribute('tiers', []) + ]; + $next = [ + 'currency' => (string)$doc->getAttribute('currency'), + 'interval' => (string)$doc->getAttribute('interval'), + 'includedUnits' => (int)$doc->getAttribute('includedUnits', 0), + 'tiersMode' => (string)$doc->getAttribute('tiersMode'), + 'tiers' => (array)$doc->getAttribute('tiers', []) + ]; + $tiersChanged = json_encode(array_values($prev['tiers'])) !== json_encode(array_values($next['tiers'])) || ($prev['tiersMode'] !== $next['tiersMode']); + $metaChanged = $prev['currency'] !== $next['currency'] || $prev['interval'] !== $next['interval'] || $prev['includedUnits'] !== $next['includedUnits']; + $oldPriceId = (string)$assignment->getAttribute('stripePriceId', ''); + // If nothing changed, still verify on Stripe whether the existing price matches the expected shape. + $forceRecreate = false; + if ($oldPriceId !== '') { + try { + $curr = $ss->getPrice($oldPriceId); + $currMode = (string)($curr['tiers_mode'] ?? ''); + $currUsage = (string)($curr['recurring']['usage_type'] ?? ''); + $currCurrency = (string)($curr['currency'] ?? ''); + $currInterval = (string)($curr['recurring']['interval'] ?? ''); + if ($currMode !== $next['tiersMode'] || $currUsage !== 'metered' || $currCurrency !== $next['currency'] || ($next['interval'] && $currInterval !== $next['interval'])) { + $forceRecreate = true; + } + // Compare tier boundaries with expected (including includedUnits free tier) + $currTiers = isset($curr['tiers']) && is_array($curr['tiers']) ? $curr['tiers'] : []; + $currUpTo = []; + foreach ($currTiers as $t) { + $currUpTo[] = isset($t['up_to']) ? (string)$t['up_to'] : ''; + } + $expectedUpTo = []; + $inc = (int)$next['includedUnits']; + if ($inc > 0) { + $expectedUpTo[] = (string)$inc; + } + foreach ((array)$next['tiers'] as $tierX) { + $to = $tierX['to'] ?? 'inf'; + $expectedUpTo[] = $to === 'inf' ? 'inf' : (string)$to; + } + if (json_encode($currUpTo) !== json_encode($expectedUpTo)) { + $forceRecreate = true; + } + // Ensure free tier is truly free if includedUnits > 0 + if ($inc > 0 && isset($currTiers[0]['unit_amount']) && (int)$currTiers[0]['unit_amount'] !== 0) { + $forceRecreate = true; + } + // debug log removed + } catch (SubscriptionException $e) { + $forceRecreate = true; + } + } + if ($oldPriceId === '' || $tiersChanged || $metaChanged || $forceRecreate) { + $meterId = $ss->ensureMeterForFeature((string)$plan->getAttribute('planId'), $fid, $featureName); + if ($meterId) { + $doc->setAttribute('stripeMeterId', $meterId); + } + $newPrice = $ss->createMeteredTieredPrice( + $productId, + $next['currency'], + $next['interval'] ?: null, + $next['includedUnits'], + $next['tiersMode'], + $next['tiers'], + (string)$plan->getAttribute('planId'), + $fid, + $featureName + ); + $doc->setAttribute('stripePriceId', $newPrice['id'] ?? null); + if ($oldPriceId !== '') { + $ss->deactivatePrice($oldPriceId); + } + } + } catch (SubscriptionException $e) { + throw $e; + } + } + $dbForPlatform->updateDocument('auth_plan_features', $assignment->getId(), $doc); + } else { + if ($type === 'metered' && $project->getAttribute('authSubscriptionsEnabled')) { + try { + $ss = new StripeService( + $project->getAttribute('authStripeSecretKey'), + $dbForProject, + $project, + $dbForPlatform + ); + $productId = (string)$plan->getAttribute('stripeProductId'); + if (!$productId) { + $p = $ss->createProduct($plan->getAttribute('name'), $plan->getAttribute('description', ''), (string)$plan->getAttribute('planId')); + $productId = (string)$p['id']; + $plan->setAttribute('stripeProductId', $productId); + $dbForPlatform->updateDocument('auth_plans', $plan->getId(), $plan); + } + $feature = $dbForPlatform->findOne('auth_features', [ + Query::equal('projectId', [$projectId]), + Query::equal('featureId', [$fid]) + ]); + $featureName = $feature ? (string)$feature->getAttribute('name', $fid) : $fid; + $meterId = $ss->ensureMeterForFeature((string)$plan->getAttribute('planId'), $fid, $featureName); + if ($meterId) { + $doc->setAttribute('stripeMeterId', $meterId); + } + $newPrice = $ss->createMeteredTieredPrice( + $productId, + (string)$doc->getAttribute('currency'), + (string)$doc->getAttribute('interval') ?: null, + (int)$doc->getAttribute('includedUnits', 0), + (string)$doc->getAttribute('tiersMode'), + (array)$doc->getAttribute('tiers', []), + (string)$plan->getAttribute('planId'), + $fid, + $featureName + ); + $doc->setAttribute('stripePriceId', $newPrice['id'] ?? null); + } catch (SubscriptionException $e) { + throw $e; + } + } + $dbForPlatform->createDocument('auth_plan_features', $doc); + } } foreach ($existingById as $fid => $assignment) { @@ -918,8 +931,11 @@ App::put('/v1/projects/:projectId/auth/plans/:planId') $project ); $pid = (string)$assignment->getAttribute('stripePriceId', ''); - if ($pid !== '') $ss->deactivatePrice($pid); - } catch (SubscriptionException $_) {} + if ($pid !== '') { + $ss->deactivatePrice($pid); + } + } catch (SubscriptionException $_) { + } } $assignment->setAttribute('active', false); $dbForPlatform->updateDocument('auth_plan_features', $assignment->getId(), $assignment); @@ -1000,4 +1016,4 @@ App::delete('/v1/projects/:projectId/auth/plans/:planId') $dbForPlatform->deleteDocument('auth_plans', $plan->getId()); $response->noContent(); - }); \ No newline at end of file + }); diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 0eda32990d..1f79493bee 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -2,11 +2,10 @@ use Ahc\Jwt\JWT; use Appwrite\Auth\Auth; +use Appwrite\Auth\Subscription\Exception\SubscriptionException; +use Appwrite\Auth\Subscription\StripeService; 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; @@ -54,7 +53,6 @@ use Utopia\Validator\Range; use Utopia\Validator\Text; use Utopia\Validator\URL; use Utopia\Validator\WhiteList; -use Utopia\Validator\Assoc; App::init() ->groups(['projects']) @@ -210,10 +208,18 @@ App::put('/v1/projects/:projectId/auth/features/:featureId') if (!$doc) { throw new Exception(Exception::GENERAL_NOT_FOUND, 'Feature not found'); } - if ($name !== null) $doc->setAttribute('name', $name); - if ($type !== null) $doc->setAttribute('type', $type); - if ($description !== null) $doc->setAttribute('description', $description); - if ($active !== null) $doc->setAttribute('active', $active); + if ($name !== null) { + $doc->setAttribute('name', $name); + } + if ($type !== null) { + $doc->setAttribute('type', $type); + } + if ($description !== null) { + $doc->setAttribute('description', $description); + } + if ($active !== null) { + $doc->setAttribute('active', $active); + } $doc->setAttribute('search', implode(' ', [ $doc->getAttribute('featureId'), $doc->getAttribute('name'), @@ -2995,11 +3001,22 @@ App::delete('/v1/projects/:projectId/auth/subscriptions') 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 $_) {} + try { + if ($priceId) { + $stripe->deactivatePrice($priceId); + } + } catch (\Throwable $_) { + } + try { + if ($productId) { + $stripe->deactivateProduct($productId); + } + } catch (\Throwable $_) { + } $dbForPlatform->deleteDocument('auth_plans', $plan->getId()); } - } catch (\Throwable $_) {} + } catch (\Throwable $_) { + } } $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index 3a525afdf4..eb56842f91 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -756,59 +756,60 @@ App::get('/v1/users/:userId') ]); $plan = ($candidate instanceof \Utopia\Database\Document && !$candidate->isEmpty()) ? $candidate : null; } - // if ($plan instanceof \Utopia\Database\Document && !$plan->isEmpty()) { - // $features = []; - // $resolvedPlanId = (string) $plan->getAttribute('planId'); - // $assigned = []; - // if ($resolvedPlanId !== '') { - // $assigned = Authorization::skip(fn () => $dbForPlatform->find('auth_plan_features', [ - // Query::equal('projectId', [$project->getId()]), - // Query::equal('planId', [$resolvedPlanId]), - // Query::equal('active', [true]) - // ])); - // } - // $stripeService = new \Appwrite\Auth\Subscription\StripeService( - // $project->getAttribute('authStripeSecretKey'), - // $dbForProject, - // $project, - // $dbForPlatform - // ); - // $customerId = (string) $user->getAttribute('stripeCustomerId', ''); - // $periodStart = $user->getAttribute('subscriptionCurrentPeriodStart'); - // $periodEnd = $user->getAttribute('subscriptionCurrentPeriodEnd'); - // $startTs = $periodStart ? strtotime($periodStart) : 0; - // $endTs = $periodEnd ? strtotime($periodEnd) : time(); - // foreach ($assigned as $af) { - // $featureId = (string) $af->getAttribute('featureId'); - // $usage = 0; - // if ((string)$af->getAttribute('type') === 'metered' && $customerId !== '' && $startTs > 0) { - // try { - // $usage = $stripeService->getFeatureUsageTotal($customerId, $resolvedPlanId, $featureId, $startTs, $endTs); - // } catch (\Throwable $_) {} - // } - // $features[] = [ - // 'featureId' => (string) $af->getAttribute('featureId'), - // 'type' => (string) $af->getAttribute('type'), - // 'enabled' => (bool) $af->getAttribute('enabled', true), - // 'currency' => $af->getAttribute('currency'), - // 'interval' => $af->getAttribute('interval'), - // 'includedUnits' => (int) $af->getAttribute('includedUnits', 0), - // 'tiersMode' => $af->getAttribute('tiersMode'), - // 'tiers' => (array) $af->getAttribute('tiers', []), - // 'usage' => $usage, - // 'usageCap' => $af->getAttribute('usageCap'), - // ]; - // } - // $user->setAttribute('plan', [ - // 'id' => $plan->getAttribute('planId'), - // 'name' => $plan->getAttribute('name'), - // 'price' => $plan->getAttribute('price'), - // 'currency' => $plan->getAttribute('currency'), - // 'interval' => $plan->getAttribute('interval'), - // 'isFree' => $plan->getAttribute('isFree', false), - // 'features' => $features - // ]); - // } + if ($plan instanceof \Utopia\Database\Document && !$plan->isEmpty()) { + $features = []; + $resolvedPlanId = (string) $plan->getAttribute('planId'); + $assigned = []; + if ($resolvedPlanId !== '') { + $assigned = Authorization::skip(fn () => $dbForPlatform->find('auth_plan_features', [ + Query::equal('projectId', [$project->getId()]), + Query::equal('planId', [$resolvedPlanId]), + Query::equal('active', [true]) + ])); + } + $stripeService = new \Appwrite\Auth\Subscription\StripeService( + $project->getAttribute('authStripeSecretKey'), + $dbForProject, + $project, + $dbForPlatform + ); + $customerId = (string) $user->getAttribute('stripeCustomerId', ''); + $periodStart = $user->getAttribute('subscriptionCurrentPeriodStart'); + $periodEnd = $user->getAttribute('subscriptionCurrentPeriodEnd'); + $startTs = $periodStart ? strtotime($periodStart) : 0; + $endTs = $periodEnd ? strtotime($periodEnd) : time(); + foreach ($assigned as $af) { + $featureId = (string) $af->getAttribute('featureId'); + $usage = 0; + if ((string)$af->getAttribute('type') === 'metered') { + try { + $usage = $stripeService->getFeatureUsageTotalLocal($user->getId(), $resolvedPlanId, $featureId, $startTs, $endTs); + } catch (\Throwable $_) { + } + } + $features[] = [ + 'featureId' => (string) $af->getAttribute('featureId'), + 'type' => (string) $af->getAttribute('type'), + 'enabled' => (bool) $af->getAttribute('enabled', true), + 'currency' => $af->getAttribute('currency'), + 'interval' => $af->getAttribute('interval'), + 'includedUnits' => (int) $af->getAttribute('includedUnits', 0), + 'tiersMode' => $af->getAttribute('tiersMode'), + 'tiers' => (array) $af->getAttribute('tiers', []), + 'usage' => $usage, + 'usageCap' => $af->getAttribute('usageCap'), + ]; + } + $user->setAttribute('plan', [ + 'id' => $plan->getAttribute('planId'), + 'name' => $plan->getAttribute('name'), + 'price' => $plan->getAttribute('price'), + 'currency' => $plan->getAttribute('currency'), + 'interval' => $plan->getAttribute('interval'), + 'isFree' => $plan->getAttribute('isFree', false), + 'features' => $features + ]); + } $response->dynamic($user, Response::MODEL_USER); }); diff --git a/app/controllers/general.php b/app/controllers/general.php index 6d9de60e96..c9a38e8f9b 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -41,6 +41,7 @@ use Utopia\Database\Document; use Utopia\Database\Helpers\ID; use Utopia\Database\Query; use Utopia\Database\Validator\Authorization; +use Utopia\Database\Validator\UID; use Utopia\Domains\Domain; use Utopia\DSN\DSN; use Utopia\Locale\Locale; @@ -51,7 +52,6 @@ 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'); @@ -1689,7 +1689,7 @@ App::post('/v1/webhooks/stripe/auth/:projectId') $platformDb ); - + try { $stripeService->handleWebhook($event ?? []); @@ -1698,4 +1698,3 @@ App::post('/v1/webhooks/stripe/auth/:projectId') throw new AppwriteException(AppwriteException::GENERAL_SERVER_ERROR, $e->getMessage()); } }); - diff --git a/src/Appwrite/Auth/Subscription/Exception/SubscriptionException.php b/src/Appwrite/Auth/Subscription/Exception/SubscriptionException.php index d3a96a73c5..3287df0195 100644 --- a/src/Appwrite/Auth/Subscription/Exception/SubscriptionException.php +++ b/src/Appwrite/Auth/Subscription/Exception/SubscriptionException.php @@ -10,4 +10,4 @@ class SubscriptionException extends Exception { parent::__construct($message, $code, $previous); } -} \ No newline at end of file +} diff --git a/src/Appwrite/Auth/Subscription/StripeService.php b/src/Appwrite/Auth/Subscription/StripeService.php index 00905748a3..929c740e4e 100644 --- a/src/Appwrite/Auth/Subscription/StripeService.php +++ b/src/Appwrite/Auth/Subscription/StripeService.php @@ -4,6 +4,7 @@ namespace Appwrite\Auth\Subscription; use Appwrite\Auth\Subscription\Exception\SubscriptionException; use Utopia\Database\Database; +use Utopia\Database\DateTime as DatabaseDateTime; use Utopia\Database\Document; use Utopia\Database\Helpers\ID; use Utopia\Database\Query; @@ -358,7 +359,9 @@ class StripeService ]; foreach ($additionalItems as $item) { - if (!is_array($item)) continue; + if (!is_array($item)) { + continue; + } $entry = []; if (!empty($item['price'])) { $entry['price'] = $item['price']; @@ -500,7 +503,13 @@ class StripeService Authorization::skip(fn () => $this->database->updateDocument('users', $userId, $user)); } - // Enforce usage caps if defined on the plan feature assignment + // Determine window timestamps + $periodStart = $user->getAttribute('subscriptionCurrentPeriodStart'); + $periodEnd = $user->getAttribute('subscriptionCurrentPeriodEnd'); + $startTs = $periodStart ? strtotime((string)$periodStart) : 0; + $endTs = $periodEnd ? strtotime((string)$periodEnd) : time(); + + // Enforce usage caps if defined on the plan feature assignment (prefer local DB total to avoid Stripe latency) if ($this->platformDb) { $assignment = Authorization::skip(fn () => $this->platformDb->findOne('auth_plan_features', [ Query::equal('projectId', [$this->project->getId()]), @@ -511,14 +520,19 @@ class StripeService if ($assignment && !$assignment->isEmpty()) { $cap = $assignment->getAttribute('usageCap'); if ($cap !== null) { - $periodStart = $user->getAttribute('subscriptionCurrentPeriodStart'); - $periodEnd = $user->getAttribute('subscriptionCurrentPeriodEnd'); - $startTs = $periodStart ? strtotime((string)$periodStart) : 0; - $endTs = $periodEnd ? strtotime((string)$periodEnd) : time(); + // First, try local DB sum $current = 0; try { - $current = $this->getFeatureUsageTotal($customerId, $planId, $featureId, $startTs, $endTs); - } catch (\Throwable $_) {} + $current = $this->getFeatureUsageTotalLocal($user->getId(), $planId, $featureId, $startTs, $endTs); + } catch (\Throwable $_) { + } + // Fallback to Stripe if local gives 0 and we have a customer + if ($current === 0) { + try { + $current = $this->getFeatureUsageTotal($customerId, $planId, $featureId, $startTs, $endTs); + } catch (\Throwable $_) { + } + } if ($current + $value > (int)$cap) { throw new SubscriptionException('Usage cap exceeded for feature: ' . $featureId); } @@ -543,6 +557,27 @@ class StripeService $params['timestamp'] = (string) $timestamp; } + // Write usage locally for fast reads and idempotency checks + $eventDocument = new Document([ + '$id' => ID::unique(), + 'projectInternalId' => $this->project->getSequence(), + 'projectId' => $this->project->getId(), + 'userId' => $user->getId(), + 'planId' => $planId, + 'featureId' => $featureId, + 'value' => $value, + 'identifier' => $identifier, + 'timestamp' => isset($timestamp) + ? DatabaseDateTime::formatTz(gmdate('c', (int)$timestamp)) + : DatabaseDateTime::formatTz(gmdate('c')), + ]); + try { + Authorization::skip(fn () => $this->platformDb?->createDocument('auth_usage_events', $eventDocument)); + } catch (\Throwable $e) { + // best-effort; do not block ingestion if local write fails + } + + // Send to Stripe (async on Stripe side) $response = $this->makeRequest('POST', '/billing/meter_events', $params); if (isset($response['error'])) { @@ -552,6 +587,39 @@ class StripeService return $response; } + /** + * Get total usage for a feature from local DB within a time window + */ + public function getFeatureUsageTotalLocal(string $userId, string $planId, string $featureId, int $startTime, int $endTime): int + { + if ($this->platformDb === null) { + return 0; + } + // Sum within current billing window; if invalid window, approximate using feature interval (default month) + if ($startTime <= 0 || $endTime <= 0) { + // Approximate: last 30 days + $endTime = time(); + $startTime = $endTime - (86400 * 30); + } + $fromStr = DatabaseDateTime::formatTz(gmdate('c', (int)$startTime)); + $toStr = DatabaseDateTime::formatTz(gmdate('c', (int)$endTime)); + $filters = [ + Query::equal('projectId', [$this->project->getId()]), + Query::equal('userId', [$userId]), + Query::equal('planId', [$planId]), + Query::equal('featureId', [$featureId]), + Query::greaterThanEqual('timestamp', $fromStr), + Query::lessThanEqual('timestamp', $toStr), + Query::limit(100000), + ]; + $events = Authorization::skip(fn () => $this->platformDb->find('auth_usage_events', $filters)); + $total = 0; + foreach ($events as $e) { + $total += (int) $e->getAttribute('value', 0); + } + return $total; + } + /** * Get total usage for a feature for a customer within a time window * Returns integer total (sum) if available, otherwise 0. @@ -620,14 +688,18 @@ class StripeService $seconds = 0; switch ($interval) { case 'day': - $seconds = 86400; break; + $seconds = 86400; + break; case 'week': - $seconds = 86400 * 7; break; + $seconds = 86400 * 7; + break; case 'year': - $seconds = 86400 * 365; break; + $seconds = 86400 * 365; + break; case 'month': default: - $seconds = 86400 * 30; break; + $seconds = 86400 * 30; + break; } $trialEnd = $now + $seconds; @@ -803,7 +875,7 @@ class StripeService $this->handlePaymentSucceeded($event['data']['object']); break; - // Keep Appwrite in sync with Stripe-side changes + // Keep Appwrite in sync with Stripe-side changes case 'product.updated': $this->handleProductUpdated($event['data']['object']); break; @@ -824,14 +896,20 @@ class StripeService */ private function handleProductUpdated(array $product): void { - if ($this->platformDb === null) return; + if ($this->platformDb === null) { + return; + } $stripeProductId = $product['id'] ?? ''; - if (empty($stripeProductId)) return; + if (empty($stripeProductId)) { + return; + } $plan = $this->platformDb->findOne('auth_plans', [ Query::equal('stripeProductId', [$stripeProductId]), Query::equal('projectId', [$this->project->getId()]) ]); - if ($plan->isEmpty()) return; + if ($plan->isEmpty()) { + return; + } $plan->setAttribute('name', $product['name'] ?? $plan->getAttribute('name')); if (isset($product['active']) && $product['active'] === false) { // If product deactivated, mark plan inactive @@ -845,9 +923,13 @@ class StripeService */ private function handleProductDeleted(array $product): void { - if ($this->platformDb === null) return; + if ($this->platformDb === null) { + return; + } $stripeProductId = $product['id'] ?? ''; - if (empty($stripeProductId)) return; + if (empty($stripeProductId)) { + return; + } $plan = $this->platformDb->findOne('auth_plans', [ Query::equal('stripeProductId', [$stripeProductId]), Query::equal('projectId', [$this->project->getId()]) @@ -862,14 +944,20 @@ class StripeService */ private function handlePriceUpdated(array $price): void { - if ($this->platformDb === null) return; + if ($this->platformDb === null) { + return; + } $stripePriceId = $price['id'] ?? ''; - if (empty($stripePriceId)) return; + if (empty($stripePriceId)) { + return; + } $plan = $this->platformDb->findOne('auth_plans', [ Query::equal('stripePriceId', [$stripePriceId]), Query::equal('projectId', [$this->project->getId()]) ]); - if ($plan->isEmpty()) return; + if ($plan->isEmpty()) { + return; + } if (isset($price['active']) && $price['active'] === false) { $plan->setAttribute('active', false); } @@ -890,9 +978,13 @@ class StripeService */ private function handlePriceDeleted(array $price): void { - if ($this->platformDb === null) return; + if ($this->platformDb === null) { + return; + } $stripePriceId = $price['id'] ?? ''; - if (empty($stripePriceId)) return; + if (empty($stripePriceId)) { + return; + } $plan = $this->platformDb->findOne('auth_plans', [ Query::equal('stripePriceId', [$stripePriceId]), Query::equal('projectId', [$this->project->getId()]) @@ -1064,7 +1156,7 @@ class StripeService } } } - + $fallbackToFree = ($status === 'canceled' || $status === 'incomplete_expired' || ($cancelAtPeriodEnd && $periodEndTs && time() >= (int) $periodEndTs)); $defaultPlan = null; @@ -1173,4 +1265,4 @@ class StripeService return implode('&', $data); } -} \ No newline at end of file +} diff --git a/src/Appwrite/Auth/Validator/PlanData.php b/src/Appwrite/Auth/Validator/PlanData.php index 19d4cc60b0..3286d2c493 100644 --- a/src/Appwrite/Auth/Validator/PlanData.php +++ b/src/Appwrite/Auth/Validator/PlanData.php @@ -75,4 +75,4 @@ class PlanData extends Validator { return self::TYPE_OBJECT; } -} \ No newline at end of file +} diff --git a/src/Appwrite/Auth/Validator/StripeKey.php b/src/Appwrite/Auth/Validator/StripeKey.php index 7eb50ca3e0..cf8f578494 100644 --- a/src/Appwrite/Auth/Validator/StripeKey.php +++ b/src/Appwrite/Auth/Validator/StripeKey.php @@ -55,4 +55,4 @@ class StripeKey extends Validator { return self::TYPE_STRING; } -} \ No newline at end of file +} diff --git a/src/Appwrite/Auth/Validator/SubscriptionStatus.php b/src/Appwrite/Auth/Validator/SubscriptionStatus.php index 22fec5a218..18be4796a7 100644 --- a/src/Appwrite/Auth/Validator/SubscriptionStatus.php +++ b/src/Appwrite/Auth/Validator/SubscriptionStatus.php @@ -55,4 +55,4 @@ class SubscriptionStatus extends Validator { return self::TYPE_STRING; } -} \ No newline at end of file +} diff --git a/tmp_check_geodb.php b/tmp_check_geodb.php deleted file mode 100644 index ea34cd8432..0000000000 --- a/tmp_check_geodb.php +++ /dev/null @@ -1,19 +0,0 @@ -get($ip); - echo $ip, ' => '; - var_export($record); - echo PHP_EOL; - } catch (\Throwable $e) { - echo $ip, ' error: ', get_class($e), ' ', $e->getMessage(), PHP_EOL; - } -}