2022-07-26 17:49:35 +00:00
|
|
|
import {Features, Product as IProduct, PERSONAL_FREE_PLAN, PERSONAL_LEGACY_PLAN, TEAM_FREE_PLAN,
|
|
|
|
TEAM_PLAN} from 'app/common/Features';
|
2020-07-21 13:20:51 +00:00
|
|
|
import {nativeValues} from 'app/gen-server/lib/values';
|
|
|
|
import * as assert from 'assert';
|
2022-03-15 10:45:20 +00:00
|
|
|
import {BillingAccount} from 'app/gen-server/entity/BillingAccount';
|
|
|
|
import {BaseEntity, Column, Connection, Entity, OneToMany, PrimaryGeneratedColumn} from 'typeorm';
|
2020-07-21 13:20:51 +00:00
|
|
|
|
|
|
|
/**
|
2022-07-26 17:49:35 +00:00
|
|
|
* A summary of features available in legacy personal sites.
|
2020-07-21 13:20:51 +00:00
|
|
|
*/
|
2022-07-26 17:49:35 +00:00
|
|
|
export const personalLegacyFeatures: Features = {
|
2020-07-21 13:20:51 +00:00
|
|
|
workspaces: true,
|
|
|
|
// no vanity domain
|
|
|
|
maxDocsPerOrg: 10,
|
|
|
|
maxSharesPerDoc: 2,
|
|
|
|
maxWorkspacesPerOrg: 1
|
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* A summary of features used in 'team' plans.
|
|
|
|
*/
|
|
|
|
export const teamFeatures: Features = {
|
|
|
|
workspaces: true,
|
|
|
|
vanityDomain: true,
|
|
|
|
maxSharesPerWorkspace: 0, // all workspace shares need to be org members.
|
|
|
|
maxSharesPerDoc: 2
|
|
|
|
};
|
|
|
|
|
2022-02-02 19:30:50 +00:00
|
|
|
/**
|
|
|
|
* A summary of features available in free team sites.
|
|
|
|
*/
|
|
|
|
export const teamFreeFeatures: Features = {
|
|
|
|
workspaces: true,
|
|
|
|
vanityDomain: true,
|
|
|
|
maxSharesPerWorkspace: 0, // all workspace shares need to be org members.
|
|
|
|
maxSharesPerDoc: 2,
|
2022-07-26 17:49:35 +00:00
|
|
|
snapshotWindow: { count: 30, unit: 'days' },
|
|
|
|
baseMaxRowsPerDocument: 5000,
|
|
|
|
baseMaxApiUnitsPerDocumentPerDay: 5000,
|
|
|
|
baseMaxDataSizePerDocument: 5000 * 2 * 1024, // 2KB per row
|
|
|
|
baseMaxAttachmentsBytesPerDocument: 1 * 1024 * 1024 * 1024, // 1GB
|
|
|
|
gracePeriodDays: 14,
|
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* A summary of features available in free personal sites.
|
|
|
|
*/
|
|
|
|
export const personalFreeFeatures: Features = {
|
|
|
|
workspaces: true,
|
|
|
|
maxSharesPerWorkspace: 0, // workspace sharing is disabled.
|
|
|
|
maxSharesPerDoc: 2,
|
2022-03-30 11:45:37 +00:00
|
|
|
snapshotWindow: { count: 30, unit: 'days' },
|
2022-02-02 19:30:50 +00:00
|
|
|
baseMaxRowsPerDocument: 5000,
|
(core) Grace period and delete-only mode when exceeding row limit
Summary:
Builds upon https://phab.getgrist.com/D3328
- Add HomeDB column `Document.gracePeriodStart`
- When the row count moves above the limit, set it to the current date. When it moves below, set it to null.
- Add DataLimitStatus type indicating if the document is approaching the limit, is in a grace period, or is in delete only mode if the grace period started at least 14 days ago. Compute it in ActiveDoc and send it to client when opening.
- Only allow certain user actions when in delete-only mode.
Follow-up tasks related to this diff:
- When DataLimitStatus in the client is non-empty, show a banner to the appropriate users.
- Only send DataLimitStatus to users with the appropriate access. There's no risk landing this now since real users will only see null until free team sites are released.
- Update DataLimitStatus immediately in the client when it changes, e.g. when user actions are applied or the product is changed. Right now it's only sent when the document loads.
- Update row limit, grace period start, and data limit status in ActiveDoc when the product changes, i.e. the user upgrades/downgrades.
- Account for data size when computing data limit status, not just row counts.
See also the tasks mentioned in https://phab.getgrist.com/D3331
Test Plan: Extended FreeTeam nbrowser test, testing the 4 statuses.
Reviewers: georgegevoian
Reviewed By: georgegevoian
Differential Revision: https://phab.getgrist.com/D3331
2022-03-24 12:05:51 +00:00
|
|
|
baseMaxApiUnitsPerDocumentPerDay: 5000,
|
2022-03-30 11:45:37 +00:00
|
|
|
baseMaxDataSizePerDocument: 5000 * 2 * 1024, // 2KB per row
|
2022-04-14 12:19:36 +00:00
|
|
|
baseMaxAttachmentsBytesPerDocument: 1 * 1024 * 1024 * 1024, // 1GB
|
(core) Grace period and delete-only mode when exceeding row limit
Summary:
Builds upon https://phab.getgrist.com/D3328
- Add HomeDB column `Document.gracePeriodStart`
- When the row count moves above the limit, set it to the current date. When it moves below, set it to null.
- Add DataLimitStatus type indicating if the document is approaching the limit, is in a grace period, or is in delete only mode if the grace period started at least 14 days ago. Compute it in ActiveDoc and send it to client when opening.
- Only allow certain user actions when in delete-only mode.
Follow-up tasks related to this diff:
- When DataLimitStatus in the client is non-empty, show a banner to the appropriate users.
- Only send DataLimitStatus to users with the appropriate access. There's no risk landing this now since real users will only see null until free team sites are released.
- Update DataLimitStatus immediately in the client when it changes, e.g. when user actions are applied or the product is changed. Right now it's only sent when the document loads.
- Update row limit, grace period start, and data limit status in ActiveDoc when the product changes, i.e. the user upgrades/downgrades.
- Account for data size when computing data limit status, not just row counts.
See also the tasks mentioned in https://phab.getgrist.com/D3331
Test Plan: Extended FreeTeam nbrowser test, testing the 4 statuses.
Reviewers: georgegevoian
Reviewed By: georgegevoian
Differential Revision: https://phab.getgrist.com/D3331
2022-03-24 12:05:51 +00:00
|
|
|
gracePeriodDays: 14,
|
2022-02-02 19:30:50 +00:00
|
|
|
};
|
|
|
|
|
2022-04-28 11:51:55 +00:00
|
|
|
export const testDailyApiLimitFeatures = {
|
|
|
|
...teamFreeFeatures,
|
|
|
|
baseMaxApiUnitsPerDocumentPerDay: 3,
|
|
|
|
};
|
|
|
|
|
2020-07-21 13:20:51 +00:00
|
|
|
/**
|
|
|
|
* A summary of features used in unrestricted grandfathered accounts, and also
|
|
|
|
* in some test settings.
|
|
|
|
*/
|
|
|
|
export const grandfatherFeatures: Features = {
|
|
|
|
workspaces: true,
|
|
|
|
vanityDomain: true,
|
|
|
|
};
|
|
|
|
|
|
|
|
export const suspendedFeatures: Features = {
|
|
|
|
workspaces: true,
|
|
|
|
vanityDomain: true,
|
|
|
|
readOnlyDocs: true,
|
|
|
|
// clamp down on new docs/workspaces/shares
|
|
|
|
maxDocsPerOrg: 0,
|
|
|
|
maxSharesPerDoc: 0,
|
|
|
|
maxWorkspacesPerOrg: 0,
|
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
*
|
|
|
|
* 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.
|
|
|
|
*
|
|
|
|
*/
|
2022-04-28 11:51:55 +00:00
|
|
|
export const PRODUCTS: IProduct[] = [
|
2020-07-21 13:20:51 +00:00
|
|
|
// This is a product for grandfathered accounts/orgs.
|
|
|
|
{
|
|
|
|
name: 'Free',
|
|
|
|
features: grandfatherFeatures,
|
|
|
|
},
|
|
|
|
|
|
|
|
// This is a product for newly created accounts/orgs.
|
|
|
|
{
|
|
|
|
name: 'stub',
|
|
|
|
features: {},
|
|
|
|
},
|
2022-07-26 17:49:35 +00:00
|
|
|
// This is a product for legacy personal accounts/orgs.
|
2020-07-21 13:20:51 +00:00
|
|
|
{
|
2022-07-26 17:49:35 +00:00
|
|
|
name: PERSONAL_LEGACY_PLAN,
|
|
|
|
features: personalLegacyFeatures,
|
2020-07-21 13:20:51 +00:00
|
|
|
},
|
|
|
|
{
|
|
|
|
name: 'professional', // deprecated, can be removed once no longer referred to in stripe.
|
|
|
|
features: teamFeatures,
|
|
|
|
},
|
|
|
|
{
|
2022-06-29 10:19:20 +00:00
|
|
|
name: TEAM_PLAN,
|
2022-06-08 17:54:00 +00:00
|
|
|
features: teamFeatures
|
2020-07-21 13:20:51 +00:00
|
|
|
},
|
|
|
|
|
|
|
|
// 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',
|
2022-06-08 17:54:00 +00:00
|
|
|
features: suspendedFeatures
|
2020-07-21 13:20:51 +00:00
|
|
|
},
|
2022-02-02 19:30:50 +00:00
|
|
|
{
|
2022-06-29 10:19:20 +00:00
|
|
|
name: TEAM_FREE_PLAN,
|
2022-06-08 17:54:00 +00:00
|
|
|
features: teamFreeFeatures
|
2022-02-02 19:30:50 +00:00
|
|
|
},
|
2022-07-26 17:49:35 +00:00
|
|
|
{
|
|
|
|
name: PERSONAL_FREE_PLAN,
|
|
|
|
features: personalFreeFeatures,
|
|
|
|
},
|
2020-07-21 13:20:51 +00:00
|
|
|
];
|
|
|
|
|
2022-06-08 17:54:00 +00:00
|
|
|
|
2020-07-21 13:20:51 +00:00
|
|
|
/**
|
|
|
|
* Get names of products for different situations.
|
|
|
|
*/
|
|
|
|
export function getDefaultProductNames() {
|
2022-02-08 18:40:48 +00:00
|
|
|
const defaultProduct = process.env.GRIST_DEFAULT_PRODUCT;
|
2022-07-26 17:49:35 +00:00
|
|
|
// TODO: can be removed once new deal is released.
|
2022-08-09 14:49:51 +00:00
|
|
|
const personalFreePlan = PERSONAL_FREE_PLAN;
|
2020-07-21 13:20:51 +00:00
|
|
|
return {
|
2022-07-26 17:49:35 +00:00
|
|
|
// Personal site start off on a functional plan.
|
|
|
|
personal: defaultProduct || personalFreePlan,
|
|
|
|
// Team site starts off on a limited plan, requiring subscription.
|
|
|
|
teamInitial: defaultProduct || 'stub',
|
|
|
|
// Team site that has been 'turned off'.
|
|
|
|
teamCancel: 'suspended',
|
|
|
|
// Functional team site.
|
|
|
|
team: defaultProduct || TEAM_PLAN,
|
2022-06-29 10:19:20 +00:00
|
|
|
teamFree: defaultProduct || TEAM_FREE_PLAN,
|
2022-02-02 19:30:50 +00:00
|
|
|
};
|
2020-07-21 13:20:51 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* A Grist product. Corresponds to a set of enabled features and a choice of limits.
|
|
|
|
*/
|
|
|
|
@Entity({name: 'products'})
|
|
|
|
export class Product extends BaseEntity {
|
|
|
|
@PrimaryGeneratedColumn()
|
|
|
|
public id: number;
|
|
|
|
|
|
|
|
@Column()
|
|
|
|
public name: string;
|
|
|
|
|
|
|
|
@Column({type: nativeValues.jsonEntityType})
|
|
|
|
public features: Features;
|
2022-03-15 10:45:20 +00:00
|
|
|
|
|
|
|
@OneToMany(type => BillingAccount, account => account.product)
|
|
|
|
public accounts: BillingAccount[];
|
2020-07-21 13:20:51 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Make sure the products defined for the current stripe setup are
|
|
|
|
* in the database and up to date. Other products in the database
|
|
|
|
* are untouched.
|
|
|
|
*
|
|
|
|
* If `apply` is set, the products are changed in the db, otherwise
|
|
|
|
* the are left unchanged. A summary of affected products is returned.
|
|
|
|
*/
|
2022-04-28 11:51:55 +00:00
|
|
|
export async function synchronizeProducts(
|
|
|
|
connection: Connection, apply: boolean, products = PRODUCTS
|
|
|
|
): Promise<string[]> {
|
2020-07-21 13:20:51 +00:00
|
|
|
try {
|
|
|
|
await connection.query('select name, features, stripe_product_id from products limit 1');
|
|
|
|
} catch (e) {
|
|
|
|
// No usable products table, do not try to synchronize.
|
|
|
|
return [];
|
|
|
|
}
|
|
|
|
const changingProducts: string[] = [];
|
|
|
|
await connection.transaction(async transaction => {
|
2022-04-28 11:51:55 +00:00
|
|
|
const desiredProducts = new Map(products.map(p => [p.name, p]));
|
2020-07-21 13:20:51 +00:00
|
|
|
const existingProducts = new Map((await transaction.find(Product))
|
|
|
|
.map(p => [p.name, p]));
|
|
|
|
for (const product of desiredProducts.values()) {
|
|
|
|
if (existingProducts.has(product.name)) {
|
|
|
|
const p = existingProducts.get(product.name)!;
|
|
|
|
try {
|
|
|
|
assert.deepStrictEqual(p.features, product.features);
|
|
|
|
} catch (e) {
|
|
|
|
if (apply) {
|
|
|
|
p.features = product.features;
|
|
|
|
await transaction.save(p);
|
|
|
|
}
|
|
|
|
changingProducts.push(p.name);
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
if (apply) {
|
|
|
|
const p = new Product();
|
|
|
|
p.name = product.name;
|
|
|
|
p.features = product.features;
|
|
|
|
await transaction.save(p);
|
|
|
|
}
|
|
|
|
changingProducts.push(product.name);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
});
|
|
|
|
return changingProducts;
|
|
|
|
}
|