format + lil cleanup

This commit is contained in:
Atharva Deosthale
2025-09-30 23:44:14 +05:30
parent 9b71c70d1a
commit e885cece85
16 changed files with 519 additions and 1628 deletions
+1 -1
View File
@@ -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
View File
@@ -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]
```
-64
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+119 -1
View File
@@ -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],
],
],
],
];
+19 -5
View File
@@ -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
View File
@@ -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();
});
});
+28 -11
View File
@@ -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
+54 -53
View File
@@ -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);
});
+2 -3
View File
@@ -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);
}
}
}
+117 -25
View File
@@ -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);
}
}
}
+1 -1
View File
@@ -75,4 +75,4 @@ class PlanData extends Validator
{
return self::TYPE_OBJECT;
}
}
}
+1 -1
View File
@@ -55,4 +55,4 @@ class StripeKey extends Validator
{
return self::TYPE_STRING;
}
}
}
@@ -55,4 +55,4 @@ class SubscriptionStatus extends Validator
{
return self::TYPE_STRING;
}
}
}
-19
View File
@@ -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;
}
}