mirror of
https://github.com/appwrite/appwrite.git
synced 2026-05-26 13:51:13 +00:00
format + lil cleanup
This commit is contained in:
@@ -124,4 +124,4 @@ _APP_WEBHOOK_MAX_FAILED_ATTEMPTS=10
|
||||
_APP_PROJECT_REGIONS=default
|
||||
_APP_FUNCTIONS_CREATION_ABUSE_LIMIT=5000
|
||||
_APP_STATS_USAGE_DUAL_WRITING_DBS=database_db_main
|
||||
_APP_AUTH_STRIPE_WEBHOOK_URL=https://12ee42ab449a.ngrok-free.app
|
||||
_APP_AUTH_STRIPE_WEBHOOK_URL=https://b568b886f9db.ngrok-free.app
|
||||
-237
@@ -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]
|
||||
```
|
||||
@@ -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.
|
||||
-1046
File diff suppressed because it is too large
Load Diff
@@ -555,4 +555,122 @@ return [
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
'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],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
|
||||
+175
-159
@@ -1,8 +1,7 @@
|
||||
<?php
|
||||
|
||||
use Appwrite\Auth\Validator\PlanData;
|
||||
use Appwrite\Auth\Subscription\StripeService;
|
||||
use Appwrite\Auth\Subscription\Exception\SubscriptionException;
|
||||
use Appwrite\Auth\Subscription\StripeService;
|
||||
use Appwrite\Extend\Exception;
|
||||
use Appwrite\SDK\AuthType;
|
||||
use Appwrite\SDK\Method;
|
||||
@@ -15,11 +14,11 @@ use Utopia\Database\Helpers\ID;
|
||||
use Utopia\Database\Query;
|
||||
use Utopia\Database\Validator\UID;
|
||||
use Utopia\Validator\ArrayList;
|
||||
use Utopia\Validator\Assoc;
|
||||
use Utopia\Validator\Boolean;
|
||||
use Utopia\Validator\Integer;
|
||||
use Utopia\Validator\Text;
|
||||
use Utopia\Validator\WhiteList;
|
||||
use Utopia\Validator\Assoc;
|
||||
|
||||
App::post('/v1/projects/:projectId/auth/plans')
|
||||
->desc('Create auth plan')
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -10,4 +10,4 @@ class SubscriptionException extends Exception
|
||||
{
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,4 +75,4 @@ class PlanData extends Validator
|
||||
{
|
||||
return self::TYPE_OBJECT;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,4 +55,4 @@ class StripeKey extends Validator
|
||||
{
|
||||
return self::TYPE_STRING;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,4 +55,4 @@ class SubscriptionStatus extends Validator
|
||||
{
|
||||
return self::TYPE_STRING;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
<?php
|
||||
require __DIR__ . '/vendor/autoload.php';
|
||||
$reader = new MaxMind\Db\Reader(__DIR__ . '/app/assets/dbip/dbip-country-lite-2024-09.mmdb');
|
||||
foreach ([
|
||||
'127.0.0.1',
|
||||
'::1',
|
||||
'192.168.1.1',
|
||||
'1.1.1.1',
|
||||
'8.8.8.8',
|
||||
] as $ip) {
|
||||
try {
|
||||
$record = $reader->get($ip);
|
||||
echo $ip, ' => ';
|
||||
var_export($record);
|
||||
echo PHP_EOL;
|
||||
} catch (\Throwable $e) {
|
||||
echo $ip, ' error: ', get_class($e), ' ', $e->getMessage(), PHP_EOL;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user