mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) Customizable stripe plans.
Summary: - Reading plans from Stripe, and allowing Stripe to define custom plans. - Storing product features (aka limits) in Stripe, that override those in db. - Adding hierarchical data in Stripe. All features are defined at Product level but can be overwritten on Price levels. - New options for Support user to -- Override product for team site (if he is added as a billing manager) -- Override subscription and customer id for a team site -- Attach an "offer", an custom plan configured in stripe that a team site can use -- Enabling wire transfer for subscription by allowing subscription to be created without a payment method (which is customizable) Test Plan: Updated and new. Reviewers: georgegevoian Reviewed By: georgegevoian Differential Revision: https://phab.getgrist.com/D4201
This commit is contained in:
@@ -72,7 +72,7 @@ export function addOrg(
|
||||
userId: number,
|
||||
props: Partial<OrganizationProperties>,
|
||||
options?: {
|
||||
planType?: string,
|
||||
product?: string,
|
||||
billing?: BillingOptions,
|
||||
}
|
||||
): Promise<number> {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {Organization} from 'app/gen-server/entity/Organization';
|
||||
import {Product} from 'app/gen-server/entity/Product';
|
||||
import {nativeValues} from 'app/gen-server/lib/values';
|
||||
import {Limit} from 'app/gen-server/entity/Limit';
|
||||
import {Features, mergedFeatures} from 'app/common/Features';
|
||||
|
||||
// This type is for billing account status information. Intended for stuff
|
||||
// like "free trial running out in N days".
|
||||
@@ -35,6 +36,9 @@ export class BillingAccount extends BaseEntity {
|
||||
@JoinColumn({name: 'product_id'})
|
||||
public product: Product;
|
||||
|
||||
@Column({type: nativeValues.jsonEntityType, nullable: true})
|
||||
public features: Features|null;
|
||||
|
||||
@Column({type: Boolean})
|
||||
public individual: boolean;
|
||||
|
||||
@@ -57,6 +61,9 @@ export class BillingAccount extends BaseEntity {
|
||||
@Column({name: 'stripe_plan_id', type: String, nullable: true})
|
||||
public stripePlanId: string | null;
|
||||
|
||||
@Column({name: 'payment_link', type: String, nullable: true})
|
||||
public paymentLink: string | null;
|
||||
|
||||
@Column({name: 'external_id', type: String, nullable: true})
|
||||
public externalId: string | null;
|
||||
|
||||
@@ -66,6 +73,7 @@ export class BillingAccount extends BaseEntity {
|
||||
@OneToMany(type => BillingAccountManager, manager => manager.billingAccount)
|
||||
public managers: BillingAccountManager[];
|
||||
|
||||
// Only one billing account per organization.
|
||||
@OneToMany(type => Organization, org => org.billingAccount)
|
||||
public orgs: Organization[];
|
||||
|
||||
@@ -79,4 +87,8 @@ export class BillingAccount extends BaseEntity {
|
||||
// A calculated column summarizing whether active user is a manager of the billing account.
|
||||
// (No @Column needed since calculation is done in javascript not sql)
|
||||
public isManager?: boolean;
|
||||
|
||||
public getFeatures(): Features {
|
||||
return mergedFeatures(this.features, this.product.features) ?? {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import {Features, Product as IProduct, PERSONAL_FREE_PLAN, PERSONAL_LEGACY_PLAN, TEAM_FREE_PLAN,
|
||||
import {Features, FREE_PLAN,
|
||||
Product as IProduct,
|
||||
isManagedPlan,
|
||||
PERSONAL_FREE_PLAN,
|
||||
PERSONAL_LEGACY_PLAN,
|
||||
STUB_PLAN,
|
||||
SUSPENDED_PLAN,
|
||||
TEAM_FREE_PLAN,
|
||||
TEAM_PLAN} from 'app/common/Features';
|
||||
import {nativeValues} from 'app/gen-server/lib/values';
|
||||
import * as assert from 'assert';
|
||||
@@ -21,7 +28,8 @@ export const personalLegacyFeatures: Features = {
|
||||
};
|
||||
|
||||
/**
|
||||
* A summary of features used in 'team' plans.
|
||||
* A summary of features used in 'team' plans. Grist ensures that this plan exists in the database, but it
|
||||
* is treated as an external plan that came from Stripe, and is not modified by Grist.
|
||||
*/
|
||||
export const teamFeatures: Features = {
|
||||
workspaces: true,
|
||||
@@ -71,16 +79,11 @@ export const teamFreeFeatures: Features = {
|
||||
baseMaxAssistantCalls: 100,
|
||||
};
|
||||
|
||||
export const testDailyApiLimitFeatures = {
|
||||
...teamFreeFeatures,
|
||||
baseMaxApiUnitsPerDocumentPerDay: 3,
|
||||
};
|
||||
|
||||
/**
|
||||
* A summary of features used in unrestricted grandfathered accounts, and also
|
||||
* in some test settings.
|
||||
*/
|
||||
export const grandfatherFeatures: Features = {
|
||||
export const freeAllFeatures: Features = {
|
||||
workspaces: true,
|
||||
vanityDomain: true,
|
||||
};
|
||||
@@ -98,61 +101,44 @@ export const suspendedFeatures: Features = {
|
||||
|
||||
/**
|
||||
*
|
||||
* Products are a bundle of enabled features. Most products in
|
||||
* Grist correspond to products in stripe. The correspondence is
|
||||
* established by a gristProduct metadata field on stripe plans.
|
||||
*
|
||||
* In addition, there are the following products in Grist that don't
|
||||
* exist in stripe:
|
||||
* - The product named 'Free'. This is a product used for organizations
|
||||
* created prior to the billing system being set up.
|
||||
* - The product named 'stub'. This is product assigned to new
|
||||
* organizations that should not be usable until a paid plan
|
||||
* is set up for them.
|
||||
*
|
||||
* TODO: change capitalization of name of grandfather product.
|
||||
*
|
||||
* Products are a bundle of enabled features. Grist knows only
|
||||
* about free products and creates them by default. Other products
|
||||
* are created by the billing system (Stripe) and synchronized when used
|
||||
* or via webhooks.
|
||||
*/
|
||||
export const PRODUCTS: IProduct[] = [
|
||||
// This is a product for grandfathered accounts/orgs.
|
||||
{
|
||||
name: 'Free',
|
||||
features: grandfatherFeatures,
|
||||
},
|
||||
|
||||
// This is a product for newly created accounts/orgs.
|
||||
{
|
||||
name: 'stub',
|
||||
features: {},
|
||||
},
|
||||
// This is a product for legacy personal accounts/orgs.
|
||||
{
|
||||
name: PERSONAL_LEGACY_PLAN,
|
||||
features: personalLegacyFeatures,
|
||||
},
|
||||
{
|
||||
name: 'professional', // deprecated, can be removed once no longer referred to in stripe.
|
||||
features: teamFeatures,
|
||||
name: PERSONAL_FREE_PLAN,
|
||||
features: personalFreeFeatures, // those features are read from database, here are only as a reference.
|
||||
},
|
||||
{
|
||||
name: TEAM_FREE_PLAN,
|
||||
features: teamFreeFeatures,
|
||||
},
|
||||
// This is a product for a team site (used in tests mostly, as the real team plan is managed by Stripe).
|
||||
{
|
||||
name: TEAM_PLAN,
|
||||
features: teamFeatures
|
||||
},
|
||||
|
||||
// This is a product for a team site that is no longer in good standing, but isn't yet
|
||||
// to be removed / deactivated entirely.
|
||||
{
|
||||
name: 'suspended',
|
||||
features: suspendedFeatures
|
||||
name: SUSPENDED_PLAN,
|
||||
features: suspendedFeatures,
|
||||
},
|
||||
{
|
||||
name: TEAM_FREE_PLAN,
|
||||
features: teamFreeFeatures
|
||||
name: FREE_PLAN,
|
||||
features: freeAllFeatures,
|
||||
},
|
||||
// This is a product for newly created accounts/orgs.
|
||||
{
|
||||
name: PERSONAL_FREE_PLAN,
|
||||
features: personalFreeFeatures,
|
||||
},
|
||||
name: STUB_PLAN,
|
||||
features: {},
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -161,7 +147,6 @@ export const PRODUCTS: IProduct[] = [
|
||||
*/
|
||||
export function getDefaultProductNames() {
|
||||
const defaultProduct = process.env.GRIST_DEFAULT_PRODUCT;
|
||||
// TODO: can be removed once new deal is released.
|
||||
const personalFreePlan = PERSONAL_FREE_PLAN;
|
||||
return {
|
||||
// Personal site start off on a functional plan.
|
||||
@@ -218,6 +203,12 @@ export async function synchronizeProducts(
|
||||
.map(p => [p.name, p]));
|
||||
for (const product of desiredProducts.values()) {
|
||||
if (existingProducts.has(product.name)) {
|
||||
|
||||
// Synchronize features only of known plans (team plan is not known).
|
||||
if (!isManagedPlan(product.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const p = existingProducts.get(product.name)!;
|
||||
try {
|
||||
assert.deepStrictEqual(p.features, product.features);
|
||||
|
||||
@@ -4,7 +4,7 @@ import {mapGetOrSet, mapSetOrClear, MapWithTTL} from 'app/common/AsyncCreate';
|
||||
import {getDataLimitStatus} from 'app/common/DocLimits';
|
||||
import {createEmptyOrgUsageSummary, DocumentUsage, OrgUsageSummary} from 'app/common/DocUsage';
|
||||
import {normalizeEmail} from 'app/common/emails';
|
||||
import {canAddOrgMembers, Features} from 'app/common/Features';
|
||||
import {ANONYMOUS_PLAN, canAddOrgMembers, Features, PERSONAL_FREE_PLAN} from 'app/common/Features';
|
||||
import {buildUrlId, MIN_URLID_PREFIX_LENGTH, parseUrlId} from 'app/common/gristUrls';
|
||||
import {FullUser, UserProfile} from 'app/common/LoginSessionAPI';
|
||||
import {checkSubdomainValidity} from 'app/common/orgNameUtils';
|
||||
@@ -72,6 +72,7 @@ import {
|
||||
import uuidv4 from "uuid/v4";
|
||||
import flatten = require('lodash/flatten');
|
||||
import pick = require('lodash/pick');
|
||||
import defaultsDeep = require('lodash/defaultsDeep');
|
||||
|
||||
// Support transactions in Sqlite in async code. This is a monkey patch, affecting
|
||||
// the prototypes of various TypeORM classes.
|
||||
@@ -264,16 +265,18 @@ interface CreateWorkspaceOptions {
|
||||
|
||||
/**
|
||||
* Available options for creating a new org with a new billing account.
|
||||
* It serves only as a way to remove all foreign keys from the entity.
|
||||
*/
|
||||
export type BillingOptions = Partial<Pick<BillingAccount,
|
||||
'product' |
|
||||
'stripeCustomerId' |
|
||||
'stripeSubscriptionId' |
|
||||
'stripePlanId' |
|
||||
'externalId' |
|
||||
'externalOptions' |
|
||||
'inGoodStanding' |
|
||||
'status'
|
||||
'status' |
|
||||
'paymentLink' |
|
||||
'features'
|
||||
>>;
|
||||
|
||||
/**
|
||||
@@ -748,7 +751,8 @@ export class HomeDBManager extends EventEmitter {
|
||||
// get a bit confusing.
|
||||
const result = await this.addOrg(user, {name: "Personal"}, {
|
||||
setUserAsOwner: true,
|
||||
useNewPlan: true
|
||||
useNewPlan: true,
|
||||
product: PERSONAL_FREE_PLAN,
|
||||
}, manager);
|
||||
if (result.status !== 200) {
|
||||
throw new Error(result.errMessage);
|
||||
@@ -808,22 +812,17 @@ export class HomeDBManager extends EventEmitter {
|
||||
* and orgs.acl_rules.group.memberUsers should be included.
|
||||
*/
|
||||
public async getOrgMemberCount(org: string|number|Organization): Promise<number> {
|
||||
if (!(org instanceof Organization)) {
|
||||
const orgQuery = this._org(null, false, org, {
|
||||
needRealOrg: true
|
||||
})
|
||||
// Join the org's ACL rules (with 1st level groups/users listed).
|
||||
.leftJoinAndSelect('orgs.aclRules', 'acl_rules')
|
||||
.leftJoinAndSelect('acl_rules.group', 'org_groups')
|
||||
.leftJoinAndSelect('org_groups.memberUsers', 'org_member_users');
|
||||
const result = await orgQuery.getRawAndEntities();
|
||||
if (result.entities.length === 0) {
|
||||
// If the query for the org failed, return the failure result.
|
||||
throw new ApiError('org not found', 404);
|
||||
}
|
||||
org = result.entities[0];
|
||||
}
|
||||
return getResourceUsers(org, this.defaultNonGuestGroupNames).length;
|
||||
return (await this._getOrgMembers(org)).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of billable users in the given org.
|
||||
*/
|
||||
public async getOrgBillableMemberCount(org: string|number|Organization): Promise<number> {
|
||||
return (await this._getOrgMembers(org))
|
||||
.filter(u => !u.options?.isConsultant) // remove consultants.
|
||||
.filter(u => !this.getExcludedUserIds().includes(u.id)) // remove support user and other
|
||||
.length;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -892,11 +891,13 @@ export class HomeDBManager extends EventEmitter {
|
||||
id: 0,
|
||||
individual: true,
|
||||
product: {
|
||||
name: 'anonymous',
|
||||
name: ANONYMOUS_PLAN,
|
||||
features: personalFreeFeatures,
|
||||
},
|
||||
stripePlanId: '',
|
||||
isManager: false,
|
||||
inGoodStanding: true,
|
||||
features: {},
|
||||
},
|
||||
host: null
|
||||
};
|
||||
@@ -1080,7 +1081,7 @@ export class HomeDBManager extends EventEmitter {
|
||||
orgQuery = this._addFeatures(orgQuery);
|
||||
const orgQueryResult = await verifyEntity(orgQuery);
|
||||
const org: Organization = this.unwrapQueryResult(orgQueryResult);
|
||||
const productFeatures = org.billingAccount.product.features;
|
||||
const productFeatures = org.billingAccount.getFeatures();
|
||||
|
||||
// Grab all the non-removed documents in the org.
|
||||
let docsQuery = this._docs()
|
||||
@@ -1273,7 +1274,7 @@ export class HomeDBManager extends EventEmitter {
|
||||
if (docs.length === 0) { throw new ApiError('document not found', 404); }
|
||||
if (docs.length > 1) { throw new ApiError('ambiguous document request', 400); }
|
||||
doc = docs[0];
|
||||
const features = doc.workspace.org.billingAccount.product.features;
|
||||
const features = doc.workspace.org.billingAccount.getFeatures();
|
||||
if (features.readOnlyDocs || this._restrictedMode) {
|
||||
// Don't allow any access to docs that is stronger than "viewers".
|
||||
doc.access = roles.getWeakestRole('viewers', doc.access);
|
||||
@@ -1399,14 +1400,14 @@ export class HomeDBManager extends EventEmitter {
|
||||
* user's personal org will be used for all other orgs they create. Set useNewPlan
|
||||
* to force a distinct non-individual billing account to be used for this org.
|
||||
* NOTE: Currently it is always a true - billing account is one to one with org.
|
||||
* @param planType: if set, controls the type of plan used for the org. Only
|
||||
* @param product: if set, controls the type of plan used for the org. Only
|
||||
* meaningful for team sites currently.
|
||||
* @param billing: if set, controls the billing account settings for the org.
|
||||
*/
|
||||
public async addOrg(user: User, props: Partial<OrganizationProperties>,
|
||||
options: { setUserAsOwner: boolean,
|
||||
useNewPlan: boolean,
|
||||
planType?: string,
|
||||
product?: string, // Default to PERSONAL_FREE_PLAN or TEAM_FREE_PLAN env variable.
|
||||
billing?: BillingOptions},
|
||||
transaction?: EntityManager): Promise<QueryResult<number>> {
|
||||
const notifications: Array<() => void> = [];
|
||||
@@ -1434,20 +1435,21 @@ export class HomeDBManager extends EventEmitter {
|
||||
let billingAccount;
|
||||
if (options.useNewPlan) { // use separate billing account (currently yes)
|
||||
const productNames = getDefaultProductNames();
|
||||
let productName = options.setUserAsOwner ? productNames.personal :
|
||||
options.planType === productNames.teamFree ? productNames.teamFree : productNames.teamInitial;
|
||||
// A bit fragile: this is called during creation of support@ user, before
|
||||
// getSupportUserId() is available, but with setUserAsOwner of true.
|
||||
if (!options.setUserAsOwner
|
||||
&& user.id === this.getSupportUserId()
|
||||
&& options.planType !== productNames.teamFree) {
|
||||
// For teams created by support@getgrist.com, set the product to something
|
||||
// good so payment not needed. This is useful for testing.
|
||||
productName = productNames.team;
|
||||
}
|
||||
const product =
|
||||
// For personal site use personal product always (ignoring options.product)
|
||||
options.setUserAsOwner ? productNames.personal :
|
||||
// For team site use the product from options if given
|
||||
options.product ? options.product :
|
||||
// If we are support user, use team product
|
||||
// A bit fragile: this is called during creation of support@ user, before
|
||||
// getSupportUserId() is available, but with setUserAsOwner of true.
|
||||
user.id === this.getSupportUserId() ? productNames.team :
|
||||
// Otherwise use teamInitial product (a stub).
|
||||
productNames.teamInitial;
|
||||
|
||||
billingAccount = new BillingAccount();
|
||||
billingAccount.individual = options.setUserAsOwner;
|
||||
const dbProduct = await manager.findOne(Product, {where: {name: productName}});
|
||||
const dbProduct = await manager.findOne(Product, {where: {name: product}});
|
||||
if (!dbProduct) {
|
||||
throw new Error('Cannot find product for new organization');
|
||||
}
|
||||
@@ -1460,16 +1462,21 @@ export class HomeDBManager extends EventEmitter {
|
||||
// Apply billing settings if requested, but not all of them.
|
||||
if (options.billing) {
|
||||
const billing = options.billing;
|
||||
// If we have features but it is empty object, just remove it
|
||||
if (billing.features && typeof billing.features === 'object' && Object.keys(billing.features).length === 0) {
|
||||
delete billing.features;
|
||||
}
|
||||
const allowedKeys: Array<keyof BillingOptions> = [
|
||||
'product',
|
||||
'stripeCustomerId',
|
||||
'stripeSubscriptionId',
|
||||
'stripePlanId',
|
||||
'features',
|
||||
// save will fail if externalId is a duplicate.
|
||||
'externalId',
|
||||
'externalOptions',
|
||||
'inGoodStanding',
|
||||
'status'
|
||||
'status',
|
||||
'paymentLink'
|
||||
];
|
||||
Object.keys(billing).forEach(key => {
|
||||
if (!allowedKeys.includes(key as any)) {
|
||||
@@ -1721,7 +1728,7 @@ export class HomeDBManager extends EventEmitter {
|
||||
return queryResult;
|
||||
}
|
||||
const org: Organization = queryResult.data;
|
||||
const features = org.billingAccount.product.features;
|
||||
const features = org.billingAccount.getFeatures();
|
||||
if (features.maxWorkspacesPerOrg !== undefined) {
|
||||
// we need to count how many workspaces are in the current org, and if we
|
||||
// are already at or above the limit, then fail.
|
||||
@@ -2131,7 +2138,7 @@ export class HomeDBManager extends EventEmitter {
|
||||
// of other information.
|
||||
const updated = pick(billingAccountCopy, 'inGoodStanding', 'status', 'stripeCustomerId',
|
||||
'stripeSubscriptionId', 'stripePlanId', 'product', 'externalId',
|
||||
'externalOptions');
|
||||
'externalOptions', 'paymentLink');
|
||||
billingAccount.paid = undefined; // workaround for a typeorm bug fixed upstream in
|
||||
// https://github.com/typeorm/typeorm/pull/4035
|
||||
await transaction.save(Object.assign(billingAccount, updated));
|
||||
@@ -2313,7 +2320,7 @@ export class HomeDBManager extends EventEmitter {
|
||||
await this._updateUserPermissions(groups, userIdDelta, manager);
|
||||
this._checkUserChangeAllowed(userId, groups);
|
||||
const nonOrgMembersAfter = this._getUserDifference(groups, orgGroups);
|
||||
const features = ws.org.billingAccount.product.features;
|
||||
const features = ws.org.billingAccount.getFeatures();
|
||||
const limit = features.maxSharesPerWorkspace;
|
||||
if (limit !== undefined) {
|
||||
this._restrictShares(null, limit, removeRole(nonOrgMembersBefore),
|
||||
@@ -2367,7 +2374,7 @@ export class HomeDBManager extends EventEmitter {
|
||||
await this._updateUserPermissions(groups, userIdDelta, manager);
|
||||
this._checkUserChangeAllowed(userId, groups);
|
||||
const nonOrgMembersAfter = this._getUserDifference(groups, orgGroups);
|
||||
const features = org.billingAccount.product.features;
|
||||
const features = org.billingAccount.getFeatures();
|
||||
this._restrictAllDocShares(features, nonOrgMembersBefore, nonOrgMembersAfter);
|
||||
}
|
||||
await manager.save(groups);
|
||||
@@ -2629,7 +2636,7 @@ export class HomeDBManager extends EventEmitter {
|
||||
const destOrgGroups = getNonGuestGroups(destOrg);
|
||||
const nonOrgMembersBefore = this._getUserDifference(docGroups, sourceOrgGroups);
|
||||
const nonOrgMembersAfter = this._getUserDifference(docGroups, destOrgGroups);
|
||||
const features = destOrg.billingAccount.product.features;
|
||||
const features = destOrg.billingAccount.getFeatures();
|
||||
this._restrictAllDocShares(features, nonOrgMembersBefore, nonOrgMembersAfter, false);
|
||||
}
|
||||
}
|
||||
@@ -2768,6 +2775,32 @@ export class HomeDBManager extends EventEmitter {
|
||||
.execute();
|
||||
}
|
||||
|
||||
public async getProduct(name: string): Promise<Product | undefined> {
|
||||
return await this._connection.createQueryBuilder()
|
||||
.select('product')
|
||||
.from(Product, 'product')
|
||||
.where('name = :name', {name})
|
||||
.getOne() || undefined;
|
||||
}
|
||||
|
||||
public async getDocFeatures(docId: string): Promise<Features | undefined> {
|
||||
const billingAccount = await this._connection.createQueryBuilder()
|
||||
.select('account')
|
||||
.from(BillingAccount, 'account')
|
||||
.leftJoinAndSelect('account.product', 'product')
|
||||
.leftJoinAndSelect('account.orgs', 'org')
|
||||
.leftJoinAndSelect('org.workspaces', 'workspace')
|
||||
.leftJoinAndSelect('workspace.docs', 'doc')
|
||||
.where('doc.id = :docId', {docId})
|
||||
.getOne() || undefined;
|
||||
|
||||
if (!billingAccount) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return defaultsDeep(billingAccount.features, billingAccount.product.features);
|
||||
}
|
||||
|
||||
public async getDocProduct(docId: string): Promise<Product | undefined> {
|
||||
return await this._connection.createQueryBuilder()
|
||||
.select('product')
|
||||
@@ -3011,11 +3044,11 @@ export class HomeDBManager extends EventEmitter {
|
||||
}
|
||||
let existing = org?.billingAccount?.limits?.[0];
|
||||
if (!existing) {
|
||||
const product = org?.billingAccount?.product;
|
||||
if (!product) {
|
||||
const features = org?.billingAccount?.getFeatures();
|
||||
if (!features) {
|
||||
throw new ApiError(`getLimit: no product found for org`, 500);
|
||||
}
|
||||
if (product.features.baseMaxAssistantCalls === undefined) {
|
||||
if (features.baseMaxAssistantCalls === undefined) {
|
||||
// If the product has no assistantLimit, then it is not billable yet, and we don't need to
|
||||
// track usage as it is basically unlimited.
|
||||
return null;
|
||||
@@ -3023,7 +3056,7 @@ export class HomeDBManager extends EventEmitter {
|
||||
existing = new Limit();
|
||||
existing.billingAccountId = org.billingAccountId;
|
||||
existing.type = limitType;
|
||||
existing.limit = product.features.baseMaxAssistantCalls ?? 0;
|
||||
existing.limit = features.baseMaxAssistantCalls ?? 0;
|
||||
existing.usage = 0;
|
||||
}
|
||||
const limitLess = existing.limit === -1; // -1 means no limit, it is not possible to do in stripe.
|
||||
@@ -3112,6 +3145,25 @@ export class HomeDBManager extends EventEmitter {
|
||||
.getOne();
|
||||
}
|
||||
|
||||
private async _getOrgMembers(org: string|number|Organization) {
|
||||
if (!(org instanceof Organization)) {
|
||||
const orgQuery = this._org(null, false, org, {
|
||||
needRealOrg: true
|
||||
})
|
||||
// Join the org's ACL rules (with 1st level groups/users listed).
|
||||
.leftJoinAndSelect('orgs.aclRules', 'acl_rules')
|
||||
.leftJoinAndSelect('acl_rules.group', 'org_groups')
|
||||
.leftJoinAndSelect('org_groups.memberUsers', 'org_member_users');
|
||||
const result = await orgQuery.getRawAndEntities();
|
||||
if (result.entities.length === 0) {
|
||||
// If the query for the org failed, return the failure result.
|
||||
throw new ApiError('org not found', 404);
|
||||
}
|
||||
org = result.entities[0];
|
||||
}
|
||||
return getResourceUsers(org, this.defaultNonGuestGroupNames);
|
||||
}
|
||||
|
||||
private async _getOrCreateLimit(accountId: number, limitType: LimitType, force: boolean): Promise<Limit|null> {
|
||||
if (accountId === 0) {
|
||||
throw new Error(`getLimit: called for not existing account`);
|
||||
@@ -4196,7 +4248,7 @@ export class HomeDBManager extends EventEmitter {
|
||||
if (value.billingAccount) {
|
||||
// This is an organization with billing account information available. Check limits.
|
||||
const org = value as Organization;
|
||||
const features = org.billingAccount.product.features;
|
||||
const features = org.billingAccount.getFeatures();
|
||||
if (!features.vanityDomain) {
|
||||
// Vanity domain not allowed for this org.
|
||||
options = {...options, suppressDomain: true};
|
||||
@@ -4625,7 +4677,7 @@ export class HomeDBManager extends EventEmitter {
|
||||
|
||||
// Throw an error if there's no room for adding another document.
|
||||
private async _checkRoomForAnotherDoc(workspace: Workspace, manager: EntityManager) {
|
||||
const features = workspace.org.billingAccount.product.features;
|
||||
const features = workspace.org.billingAccount.getFeatures();
|
||||
if (features.maxDocsPerOrg !== undefined) {
|
||||
// we need to count how many docs are in the current org, and if we
|
||||
// are already at or above the limit, then fail.
|
||||
|
||||
23
app/gen-server/migration/1711557445716-Billing.ts
Normal file
23
app/gen-server/migration/1711557445716-Billing.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import {nativeValues} from 'app/gen-server/lib/values';
|
||||
import {MigrationInterface, QueryRunner, TableColumn} from 'typeorm';
|
||||
|
||||
export class Billing1711557445716 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.addColumn('billing_accounts', new TableColumn({
|
||||
name: 'features',
|
||||
type: nativeValues.jsonType,
|
||||
isNullable: true,
|
||||
}));
|
||||
|
||||
await queryRunner.addColumn('billing_accounts', new TableColumn({
|
||||
name: 'payment_link',
|
||||
type: nativeValues.jsonType,
|
||||
isNullable: true,
|
||||
}));
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.dropColumn('billing_accounts', 'features');
|
||||
await queryRunner.dropColumn('billing_accounts', 'payment_link');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user