mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-25 14:26:39 +00:00
Merge branch 'main' into dev
This commit is contained in:
35
.github/workflows/saas.yml
vendored
35
.github/workflows/saas.yml
vendored
@@ -56,6 +56,41 @@ jobs:
|
|||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
|
|
||||||
|
- name: Download MaxMind GeoLite2 databases
|
||||||
|
env:
|
||||||
|
MAXMIND_LICENSE_KEY: ${{ secrets.MAXMIND_LICENSE_KEY }}
|
||||||
|
run: |
|
||||||
|
echo "Downloading MaxMind GeoLite2 databases..."
|
||||||
|
|
||||||
|
# Download GeoLite2-Country
|
||||||
|
curl -L "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-Country&license_key=${MAXMIND_LICENSE_KEY}&suffix=tar.gz" \
|
||||||
|
-o GeoLite2-Country.tar.gz
|
||||||
|
|
||||||
|
# Download GeoLite2-ASN
|
||||||
|
curl -L "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-ASN&license_key=${MAXMIND_LICENSE_KEY}&suffix=tar.gz" \
|
||||||
|
-o GeoLite2-ASN.tar.gz
|
||||||
|
|
||||||
|
# Extract the .mmdb files
|
||||||
|
tar -xzf GeoLite2-Country.tar.gz --strip-components=1 --wildcards '*.mmdb'
|
||||||
|
tar -xzf GeoLite2-ASN.tar.gz --strip-components=1 --wildcards '*.mmdb'
|
||||||
|
|
||||||
|
# Verify files exist
|
||||||
|
if [ ! -f "GeoLite2-Country.mmdb" ]; then
|
||||||
|
echo "ERROR: Failed to download GeoLite2-Country.mmdb"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "GeoLite2-ASN.mmdb" ]; then
|
||||||
|
echo "ERROR: Failed to download GeoLite2-ASN.mmdb"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Clean up tar files
|
||||||
|
rm -f GeoLite2-Country.tar.gz GeoLite2-ASN.tar.gz
|
||||||
|
|
||||||
|
echo "MaxMind databases downloaded successfully"
|
||||||
|
ls -lh GeoLite2-*.mmdb
|
||||||
|
|
||||||
- name: Monitor storage space
|
- name: Monitor storage space
|
||||||
run: |
|
run: |
|
||||||
THRESHOLD=75
|
THRESHOLD=75
|
||||||
|
|||||||
@@ -49,6 +49,14 @@ COPY server/db/ios_models.json ./dist/ios_models.json
|
|||||||
COPY server/db/mac_models.json ./dist/mac_models.json
|
COPY server/db/mac_models.json ./dist/mac_models.json
|
||||||
COPY public ./public
|
COPY public ./public
|
||||||
|
|
||||||
|
# Copy MaxMind databases for SaaS builds
|
||||||
|
ARG BUILD=oss
|
||||||
|
RUN mkdir -p ./maxmind
|
||||||
|
|
||||||
|
# This is only for saas
|
||||||
|
COPY --from=builder-dev /app/GeoLite2-Country.mmdb ./maxmind/GeoLite2-Country.mmdb
|
||||||
|
COPY --from=builder-dev /app/GeoLite2-ASN.mmdb ./maxmind/GeoLite2-ASN.mmdb
|
||||||
|
|
||||||
# OCI Image Labels - Build Args for dynamic values
|
# OCI Image Labels - Build Args for dynamic values
|
||||||
ARG VERSION="dev"
|
ARG VERSION="dev"
|
||||||
ARG REVISION=""
|
ARG REVISION=""
|
||||||
|
|||||||
@@ -1572,6 +1572,16 @@
|
|||||||
"billingFeatureLossWarning": "Feature Availability Notice",
|
"billingFeatureLossWarning": "Feature Availability Notice",
|
||||||
"billingFeatureLossDescription": "By downgrading, features not available in the new plan will be automatically disabled. Some settings and configurations may be lost. Please review the pricing matrix to understand which features will no longer be available.",
|
"billingFeatureLossDescription": "By downgrading, features not available in the new plan will be automatically disabled. Some settings and configurations may be lost. Please review the pricing matrix to understand which features will no longer be available.",
|
||||||
"billingUsageExceedsLimit": "Current usage ({current}) exceeds limit ({limit})",
|
"billingUsageExceedsLimit": "Current usage ({current}) exceeds limit ({limit})",
|
||||||
|
"billingPastDueTitle": "Payment Past Due",
|
||||||
|
"billingPastDueDescription": "Your payment is past due. Please update your payment method to continue using your current plan features. If not resolved, your subscription will be canceled and you'll be reverted to the free tier.",
|
||||||
|
"billingUnpaidTitle": "Subscription Unpaid",
|
||||||
|
"billingUnpaidDescription": "Your subscription is unpaid and you have been reverted to the free tier. Please update your payment method to restore your subscription.",
|
||||||
|
"billingIncompleteTitle": "Payment Incomplete",
|
||||||
|
"billingIncompleteDescription": "Your payment is incomplete. Please complete the payment process to activate your subscription.",
|
||||||
|
"billingIncompleteExpiredTitle": "Payment Expired",
|
||||||
|
"billingIncompleteExpiredDescription": "Your payment was never completed and has expired. You have been reverted to the free tier. Please subscribe again to restore access to paid features.",
|
||||||
|
"billingManageSubscription": "Manage your subscription",
|
||||||
|
"billingResolvePaymentIssue": "Please resolve your payment issue before upgrading or downgrading",
|
||||||
"signUpTerms": {
|
"signUpTerms": {
|
||||||
"IAgreeToThe": "I agree to the",
|
"IAgreeToThe": "I agree to the",
|
||||||
"termsOfService": "terms of service",
|
"termsOfService": "terms of service",
|
||||||
|
|||||||
@@ -46,8 +46,6 @@ export class UsageService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let orgIdToUse = await this.getBillingOrg(orgId, transaction);
|
|
||||||
|
|
||||||
// Truncate value to 11 decimal places
|
// Truncate value to 11 decimal places
|
||||||
value = this.truncateValue(value);
|
value = this.truncateValue(value);
|
||||||
|
|
||||||
@@ -59,6 +57,7 @@ export class UsageService {
|
|||||||
try {
|
try {
|
||||||
let usage;
|
let usage;
|
||||||
if (transaction) {
|
if (transaction) {
|
||||||
|
const orgIdToUse = await this.getBillingOrg(orgId, transaction);
|
||||||
usage = await this.internalAddUsage(
|
usage = await this.internalAddUsage(
|
||||||
orgIdToUse,
|
orgIdToUse,
|
||||||
featureId,
|
featureId,
|
||||||
@@ -67,6 +66,7 @@ export class UsageService {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
|
const orgIdToUse = await this.getBillingOrg(orgId, trx);
|
||||||
usage = await this.internalAddUsage(
|
usage = await this.internalAddUsage(
|
||||||
orgIdToUse,
|
orgIdToUse,
|
||||||
featureId,
|
featureId,
|
||||||
@@ -92,7 +92,7 @@ export class UsageService {
|
|||||||
const delay = baseDelay + jitter;
|
const delay = baseDelay + jitter;
|
||||||
|
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`Deadlock detected for ${orgIdToUse}/${featureId}, retrying attempt ${attempt}/${maxRetries} after ${delay.toFixed(0)}ms`
|
`Deadlock detected for ${orgId}/${featureId}, retrying attempt ${attempt}/${maxRetries} after ${delay.toFixed(0)}ms`
|
||||||
);
|
);
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
@@ -100,7 +100,7 @@ export class UsageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.error(
|
logger.error(
|
||||||
`Failed to add usage for ${orgIdToUse}/${featureId} after ${attempt} attempts:`,
|
`Failed to add usage for ${orgId}/${featureId} after ${attempt} attempts:`,
|
||||||
error
|
error
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
@@ -169,7 +169,7 @@ export class UsageService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let orgIdToUse = await this.getBillingOrg(orgId);
|
const orgIdToUse = await this.getBillingOrg(orgId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Truncate value to 11 decimal places if provided
|
// Truncate value to 11 decimal places if provided
|
||||||
@@ -227,7 +227,7 @@ export class UsageService {
|
|||||||
orgId: string,
|
orgId: string,
|
||||||
featureId: FeatureId
|
featureId: FeatureId
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
let orgIdToUse = await this.getBillingOrg(orgId);
|
const orgIdToUse = await this.getBillingOrg(orgId);
|
||||||
|
|
||||||
const cacheKey = `customer_${orgIdToUse}_${featureId}`;
|
const cacheKey = `customer_${orgIdToUse}_${featureId}`;
|
||||||
const cached = cache.get<string>(cacheKey);
|
const cached = cache.get<string>(cacheKey);
|
||||||
@@ -274,7 +274,7 @@ export class UsageService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let orgIdToUse = await this.getBillingOrg(orgId, trx);
|
const orgIdToUse = await this.getBillingOrg(orgId, trx);
|
||||||
|
|
||||||
const usageId = `${orgIdToUse}-${featureId}`;
|
const usageId = `${orgIdToUse}-${featureId}`;
|
||||||
|
|
||||||
@@ -382,7 +382,7 @@ export class UsageService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
let orgIdToUse = await this.getBillingOrg(orgId, trx);
|
const orgIdToUse = await this.getBillingOrg(orgId, trx);
|
||||||
|
|
||||||
// This method should check the current usage against the limits set for the organization
|
// This method should check the current usage against the limits set for the organization
|
||||||
// and kick out all of the sites on the org
|
// and kick out all of the sites on the org
|
||||||
|
|||||||
@@ -78,7 +78,8 @@ export async function getOrgTierData(
|
|||||||
if (
|
if (
|
||||||
subscription.type === "tier1" ||
|
subscription.type === "tier1" ||
|
||||||
subscription.type === "tier2" ||
|
subscription.type === "tier2" ||
|
||||||
subscription.type === "tier3"
|
subscription.type === "tier3" ||
|
||||||
|
subscription.type === "enterprise"
|
||||||
) {
|
) {
|
||||||
tier = subscription.type;
|
tier = subscription.type;
|
||||||
active = true;
|
active = true;
|
||||||
|
|||||||
@@ -14,6 +14,9 @@
|
|||||||
import { config } from "@server/lib/config";
|
import { config } from "@server/lib/config";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { redis } from "#private/lib/redis";
|
import { redis } from "#private/lib/redis";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
|
const instanceId = uuidv4();
|
||||||
|
|
||||||
export class LockManager {
|
export class LockManager {
|
||||||
/**
|
/**
|
||||||
@@ -33,7 +36,7 @@ export class LockManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const lockValue = `${
|
const lockValue = `${
|
||||||
config.getRawConfig().gerbil.exit_node_name
|
instanceId
|
||||||
}:${Date.now()}`;
|
}:${Date.now()}`;
|
||||||
const redisKey = `lock:${lockKey}`;
|
const redisKey = `lock:${lockKey}`;
|
||||||
|
|
||||||
@@ -52,7 +55,7 @@ export class LockManager {
|
|||||||
if (result === "OK") {
|
if (result === "OK") {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Lock acquired: ${lockKey} by ${
|
`Lock acquired: ${lockKey} by ${
|
||||||
config.getRawConfig().gerbil.exit_node_name
|
instanceId
|
||||||
}`
|
}`
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
@@ -63,14 +66,14 @@ export class LockManager {
|
|||||||
if (
|
if (
|
||||||
existingValue &&
|
existingValue &&
|
||||||
existingValue.startsWith(
|
existingValue.startsWith(
|
||||||
`${config.getRawConfig().gerbil.exit_node_name}:`
|
`${instanceId}:`
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
// Extend the lock TTL since it's the same worker
|
// Extend the lock TTL since it's the same worker
|
||||||
await redis.pexpire(redisKey, ttlMs);
|
await redis.pexpire(redisKey, ttlMs);
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Lock extended: ${lockKey} by ${
|
`Lock extended: ${lockKey} by ${
|
||||||
config.getRawConfig().gerbil.exit_node_name
|
instanceId
|
||||||
}`
|
}`
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
@@ -129,19 +132,19 @@ export class LockManager {
|
|||||||
luaScript,
|
luaScript,
|
||||||
1,
|
1,
|
||||||
redisKey,
|
redisKey,
|
||||||
`${config.getRawConfig().gerbil.exit_node_name}:`
|
`${instanceId}:`
|
||||||
)) as number;
|
)) as number;
|
||||||
|
|
||||||
if (result === 1) {
|
if (result === 1) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Lock released: ${lockKey} by ${
|
`Lock released: ${lockKey} by ${
|
||||||
config.getRawConfig().gerbil.exit_node_name
|
instanceId
|
||||||
}`
|
}`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`Lock not released - not owned by worker: ${lockKey} by ${
|
`Lock not released - not owned by worker: ${lockKey} by ${
|
||||||
config.getRawConfig().gerbil.exit_node_name
|
instanceId
|
||||||
}`
|
}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -198,7 +201,7 @@ export class LockManager {
|
|||||||
const ownedByMe =
|
const ownedByMe =
|
||||||
exists &&
|
exists &&
|
||||||
value!.startsWith(
|
value!.startsWith(
|
||||||
`${config.getRawConfig().gerbil.exit_node_name}:`
|
`${instanceId}:`
|
||||||
);
|
);
|
||||||
const owner = exists ? value!.split(":")[0] : undefined;
|
const owner = exists ? value!.split(":")[0] : undefined;
|
||||||
|
|
||||||
@@ -246,14 +249,14 @@ export class LockManager {
|
|||||||
luaScript,
|
luaScript,
|
||||||
1,
|
1,
|
||||||
redisKey,
|
redisKey,
|
||||||
`${config.getRawConfig().gerbil.exit_node_name}:`,
|
`${instanceId}:`,
|
||||||
ttlMs.toString()
|
ttlMs.toString()
|
||||||
)) as number;
|
)) as number;
|
||||||
|
|
||||||
if (result === 1) {
|
if (result === 1) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Lock extended: ${lockKey} by ${
|
`Lock extended: ${lockKey} by ${
|
||||||
config.getRawConfig().gerbil.exit_node_name
|
instanceId
|
||||||
} for ${ttlMs}ms`
|
} for ${ttlMs}ms`
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
@@ -356,7 +359,7 @@ export class LockManager {
|
|||||||
(value) =>
|
(value) =>
|
||||||
value &&
|
value &&
|
||||||
value.startsWith(
|
value.startsWith(
|
||||||
`${config.getRawConfig().gerbil.exit_node_name}:`
|
`${instanceId}:`
|
||||||
)
|
)
|
||||||
).length;
|
).length;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,15 +72,15 @@ export const privateConfigSchema = z.object({
|
|||||||
db: z.int().nonnegative().optional().default(0)
|
db: z.int().nonnegative().optional().default(0)
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
.optional(),
|
||||||
|
tls: z
|
||||||
|
.object({
|
||||||
|
rejectUnauthorized: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.default(true)
|
||||||
|
})
|
||||||
.optional()
|
.optional()
|
||||||
// tls: z
|
|
||||||
// .object({
|
|
||||||
// reject_unauthorized: z
|
|
||||||
// .boolean()
|
|
||||||
// .optional()
|
|
||||||
// .default(true)
|
|
||||||
// })
|
|
||||||
// .optional()
|
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
gerbil: z
|
gerbil: z
|
||||||
|
|||||||
@@ -108,11 +108,15 @@ class RedisManager {
|
|||||||
port: redisConfig.port!,
|
port: redisConfig.port!,
|
||||||
password: redisConfig.password,
|
password: redisConfig.password,
|
||||||
db: redisConfig.db
|
db: redisConfig.db
|
||||||
// tls: {
|
|
||||||
// rejectUnauthorized:
|
|
||||||
// redisConfig.tls?.reject_unauthorized || false
|
|
||||||
// }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Enable TLS if configured (required for AWS ElastiCache in-transit encryption)
|
||||||
|
if (redisConfig.tls) {
|
||||||
|
opts.tls = {
|
||||||
|
rejectUnauthorized: redisConfig.tls.rejectUnauthorized ?? true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return opts;
|
return opts;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,11 +134,15 @@ class RedisManager {
|
|||||||
port: replica.port!,
|
port: replica.port!,
|
||||||
password: replica.password,
|
password: replica.password,
|
||||||
db: replica.db || redisConfig.db
|
db: replica.db || redisConfig.db
|
||||||
// tls: {
|
|
||||||
// rejectUnauthorized:
|
|
||||||
// replica.tls?.reject_unauthorized || false
|
|
||||||
// }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Enable TLS if configured (required for AWS ElastiCache in-transit encryption)
|
||||||
|
if (redisConfig.tls) {
|
||||||
|
opts.tls = {
|
||||||
|
rejectUnauthorized: redisConfig.tls.rejectUnauthorized ?? true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return opts;
|
return opts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -197,7 +197,6 @@ export async function updateSiteBandwidth(
|
|||||||
usageService
|
usageService
|
||||||
.checkLimitSet(
|
.checkLimitSet(
|
||||||
orgId,
|
orgId,
|
||||||
|
|
||||||
FeatureId.EGRESS_DATA_MB,
|
FeatureId.EGRESS_DATA_MB,
|
||||||
bandwidthUsage
|
bandwidthUsage
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,9 +2,13 @@ import { db, dnsRecords } from "@server/db";
|
|||||||
import { domains, exitNodes, orgDomains, orgs, resources } from "@server/db";
|
import { domains, exitNodes, orgDomains, orgs, resources } from "@server/db";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { eq, ne } from "drizzle-orm";
|
import { eq, ne } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import { build } from "@server/build";
|
||||||
|
|
||||||
export async function copyInConfig() {
|
export async function copyInConfig() {
|
||||||
|
if (build == "saas") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const endpoint = config.getRawConfig().gerbil.base_endpoint;
|
const endpoint = config.getRawConfig().gerbil.base_endpoint;
|
||||||
const listenPort = config.getRawConfig().gerbil.start_port;
|
const listenPort = config.getRawConfig().gerbil.start_port;
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import m11 from "./scriptsPg/1.14.0";
|
|||||||
import m12 from "./scriptsPg/1.15.0";
|
import m12 from "./scriptsPg/1.15.0";
|
||||||
import m13 from "./scriptsPg/1.15.3";
|
import m13 from "./scriptsPg/1.15.3";
|
||||||
import m14 from "./scriptsPg/1.15.4";
|
import m14 from "./scriptsPg/1.15.4";
|
||||||
|
import { build } from "@server/build";
|
||||||
|
|
||||||
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
||||||
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
||||||
@@ -53,6 +54,10 @@ async function run() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function runMigrations() {
|
export async function runMigrations() {
|
||||||
|
if (build == "saas") {
|
||||||
|
console.log("Running in SaaS mode, skipping migrations...");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (process.env.DISABLE_MIGRATIONS) {
|
if (process.env.DISABLE_MIGRATIONS) {
|
||||||
console.log("Migrations are disabled. Skipping...");
|
console.log("Migrations are disabled. Skipping...");
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import m32 from "./scriptsSqlite/1.14.0";
|
|||||||
import m33 from "./scriptsSqlite/1.15.0";
|
import m33 from "./scriptsSqlite/1.15.0";
|
||||||
import m34 from "./scriptsSqlite/1.15.3";
|
import m34 from "./scriptsSqlite/1.15.3";
|
||||||
import m35 from "./scriptsSqlite/1.15.4";
|
import m35 from "./scriptsSqlite/1.15.4";
|
||||||
|
import { build } from "@server/build";
|
||||||
|
|
||||||
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
||||||
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
||||||
@@ -105,6 +106,10 @@ function backupDb() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function runMigrations() {
|
export async function runMigrations() {
|
||||||
|
if (build == "saas") {
|
||||||
|
console.log("Running in SaaS mode, skipping migrations...");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (process.env.DISABLE_MIGRATIONS) {
|
if (process.env.DISABLE_MIGRATIONS) {
|
||||||
console.log("Migrations are disabled. Skipping...");
|
console.log("Migrations are disabled. Skipping...");
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -445,6 +445,54 @@ export default function BillingPage() {
|
|||||||
|
|
||||||
const currentPlanId = getCurrentPlanId();
|
const currentPlanId = getCurrentPlanId();
|
||||||
|
|
||||||
|
// Check if subscription is in a problematic state that requires attention
|
||||||
|
const hasProblematicSubscription = (): boolean => {
|
||||||
|
if (!tierSubscription?.subscription) return false;
|
||||||
|
const status = tierSubscription.subscription.status;
|
||||||
|
return (
|
||||||
|
status === "past_due" ||
|
||||||
|
status === "unpaid" ||
|
||||||
|
status === "incomplete" ||
|
||||||
|
status === "incomplete_expired"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isProblematicState = hasProblematicSubscription();
|
||||||
|
|
||||||
|
// Get user-friendly subscription status message
|
||||||
|
const getSubscriptionStatusMessage = (): { title: string; description: string } | null => {
|
||||||
|
if (!tierSubscription?.subscription || !isProblematicState) return null;
|
||||||
|
|
||||||
|
const status = tierSubscription.subscription.status;
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case "past_due":
|
||||||
|
return {
|
||||||
|
title: t("billingPastDueTitle") || "Payment Past Due",
|
||||||
|
description: t("billingPastDueDescription") || "Your payment is past due. Please update your payment method to continue using your current plan features. If not resolved, your subscription will be canceled and you'll be reverted to the free tier."
|
||||||
|
};
|
||||||
|
case "unpaid":
|
||||||
|
return {
|
||||||
|
title: t("billingUnpaidTitle") || "Subscription Unpaid",
|
||||||
|
description: t("billingUnpaidDescription") || "Your subscription is unpaid and you have been reverted to the free tier. Please update your payment method to restore your subscription."
|
||||||
|
};
|
||||||
|
case "incomplete":
|
||||||
|
return {
|
||||||
|
title: t("billingIncompleteTitle") || "Payment Incomplete",
|
||||||
|
description: t("billingIncompleteDescription") || "Your payment is incomplete. Please complete the payment process to activate your subscription."
|
||||||
|
};
|
||||||
|
case "incomplete_expired":
|
||||||
|
return {
|
||||||
|
title: t("billingIncompleteExpiredTitle") || "Payment Expired",
|
||||||
|
description: t("billingIncompleteExpiredDescription") || "Your payment was never completed and has expired. You have been reverted to the free tier. Please subscribe again to restore access to paid features."
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusMessage = getSubscriptionStatusMessage();
|
||||||
|
|
||||||
// Get button label and action for each plan
|
// Get button label and action for each plan
|
||||||
const getPlanAction = (plan: PlanOption) => {
|
const getPlanAction = (plan: PlanOption) => {
|
||||||
if (plan.id === "enterprise") {
|
if (plan.id === "enterprise") {
|
||||||
@@ -458,7 +506,7 @@ export default function BillingPage() {
|
|||||||
|
|
||||||
if (plan.id === currentPlanId) {
|
if (plan.id === currentPlanId) {
|
||||||
// If it's the basic plan (basic with no subscription), show as current but disabled
|
// If it's the basic plan (basic with no subscription), show as current but disabled
|
||||||
if (plan.id === "basic" && !hasSubscription) {
|
if (plan.id === "basic" && !hasSubscription && !isProblematicState) {
|
||||||
return {
|
return {
|
||||||
label: "Current Plan",
|
label: "Current Plan",
|
||||||
action: () => {},
|
action: () => {},
|
||||||
@@ -466,8 +514,17 @@ export default function BillingPage() {
|
|||||||
disabled: true
|
disabled: true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
// If on free tier but has a problematic subscription, allow them to manage it
|
||||||
|
if (plan.id === "basic" && isProblematicState) {
|
||||||
|
return {
|
||||||
|
label: "Manage Subscription",
|
||||||
|
action: handleModifySubscription,
|
||||||
|
variant: "default" as const,
|
||||||
|
disabled: false
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
label: "Modify Current Plan",
|
label: "Manage Current Plan",
|
||||||
action: handleModifySubscription,
|
action: handleModifySubscription,
|
||||||
variant: "default" as const,
|
variant: "default" as const,
|
||||||
disabled: false
|
disabled: false
|
||||||
@@ -503,7 +560,7 @@ export default function BillingPage() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
variant: "outline" as const,
|
variant: "outline" as const,
|
||||||
disabled: false
|
disabled: isProblematicState
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -522,7 +579,7 @@ export default function BillingPage() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
variant: "outline" as const,
|
variant: "outline" as const,
|
||||||
disabled: false
|
disabled: isProblematicState
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -648,6 +705,26 @@ export default function BillingPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsContainer>
|
<SettingsContainer>
|
||||||
|
{/* Subscription Status Alert */}
|
||||||
|
{isProblematicState && statusMessage && (
|
||||||
|
<Alert variant="destructive" className="mb-6">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertTitle>
|
||||||
|
{statusMessage.title}
|
||||||
|
</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{statusMessage.description}
|
||||||
|
{" "}
|
||||||
|
<button
|
||||||
|
onClick={handleModifySubscription}
|
||||||
|
className="underline font-semibold hover:no-underline"
|
||||||
|
>
|
||||||
|
{t("billingManageSubscription") || "Manage your subscription"}
|
||||||
|
</button>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Your Plan Section */}
|
{/* Your Plan Section */}
|
||||||
<SettingsSection>
|
<SettingsSection>
|
||||||
<SettingsSectionHeader>
|
<SettingsSectionHeader>
|
||||||
@@ -692,22 +769,50 @@ export default function BillingPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<Button
|
{isProblematicState && planAction.disabled && !isCurrentPlan && plan.id !== "enterprise" ? (
|
||||||
variant={
|
<Tooltip>
|
||||||
isCurrentPlan
|
<TooltipTrigger asChild>
|
||||||
? "default"
|
<div>
|
||||||
: "outline"
|
<Button
|
||||||
}
|
variant={
|
||||||
size="sm"
|
isCurrentPlan
|
||||||
className="w-full"
|
? "default"
|
||||||
onClick={planAction.action}
|
: "outline"
|
||||||
disabled={
|
}
|
||||||
isLoading || planAction.disabled
|
size="sm"
|
||||||
}
|
className="w-full"
|
||||||
loading={isLoading && isCurrentPlan}
|
onClick={planAction.action}
|
||||||
>
|
disabled={
|
||||||
{planAction.label}
|
isLoading || planAction.disabled
|
||||||
</Button>
|
}
|
||||||
|
loading={isLoading && isCurrentPlan}
|
||||||
|
>
|
||||||
|
{planAction.label}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{t("billingResolvePaymentIssue") || "Please resolve your payment issue before upgrading or downgrading"}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant={
|
||||||
|
isCurrentPlan
|
||||||
|
? "default"
|
||||||
|
: "outline"
|
||||||
|
}
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
onClick={planAction.action}
|
||||||
|
disabled={
|
||||||
|
isLoading || planAction.disabled
|
||||||
|
}
|
||||||
|
loading={isLoading && isCurrentPlan}
|
||||||
|
>
|
||||||
|
{planAction.label}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -84,13 +84,13 @@ export default async function RootLayout({
|
|||||||
<body className={`${fontClassName} h-screen-safe overflow-hidden`}>
|
<body className={`${fontClassName} h-screen-safe overflow-hidden`}>
|
||||||
<StoreInternalRedirect />
|
<StoreInternalRedirect />
|
||||||
<TopLoader />
|
<TopLoader />
|
||||||
{build === "saas" && (
|
{/* build === "saas" && (
|
||||||
<Script
|
<Script
|
||||||
src="https://rybbit.fossorial.io/api/script.js"
|
src="https://rybbit.fossorial.io/api/script.js"
|
||||||
data-site-id="fe1ff2a33287"
|
data-site-id="fe1ff2a33287"
|
||||||
strategy="afterInteractive"
|
strategy="afterInteractive"
|
||||||
/>
|
/>
|
||||||
)}
|
)*/}
|
||||||
<ViewportHeightFix />
|
<ViewportHeightFix />
|
||||||
<NextIntlClientProvider>
|
<NextIntlClientProvider>
|
||||||
<ThemeProvider
|
<ThemeProvider
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export const isOrgSubscribed = cache(async (orgId: string) => {
|
|||||||
try {
|
try {
|
||||||
const subRes = await getCachedSubscription(orgId);
|
const subRes = await getCachedSubscription(orgId);
|
||||||
subscribed =
|
subscribed =
|
||||||
(subRes.data.data.tier == "tier1" || subRes.data.data.tier == "tier2" || subRes.data.data.tier == "tier3") &&
|
(subRes.data.data.tier == "tier1" || subRes.data.data.tier == "tier2" || subRes.data.data.tier == "tier3" || subRes.data.data.tier == "enterprise") &&
|
||||||
subRes.data.data.active;
|
subRes.data.data.active;
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ export function SubscriptionStatusProvider({
|
|||||||
if (
|
if (
|
||||||
subscription.type == "tier1" ||
|
subscription.type == "tier1" ||
|
||||||
subscription.type == "tier2" ||
|
subscription.type == "tier2" ||
|
||||||
subscription.type == "tier3"
|
subscription.type == "tier3" ||
|
||||||
|
subscription.type == "enterprise"
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
tier: subscription.type,
|
tier: subscription.type,
|
||||||
@@ -61,7 +62,7 @@ export function SubscriptionStatusProvider({
|
|||||||
const isSubscribed = () => {
|
const isSubscribed = () => {
|
||||||
const { tier, active } = getTier();
|
const { tier, active } = getTier();
|
||||||
return (
|
return (
|
||||||
(tier == "tier1" || tier == "tier2" || tier == "tier3") &&
|
(tier == "tier1" || tier == "tier2" || tier == "tier3" || tier == "enterprise") &&
|
||||||
active
|
active
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user