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:
@@ -10,6 +10,7 @@ import {Organization} from 'app/gen-server/entity/Organization';
|
||||
import {Product} from 'app/gen-server/entity/Product';
|
||||
import {HomeDBManager, UserChange} from 'app/gen-server/lib/HomeDBManager';
|
||||
import {TestServer} from 'test/gen-server/apiUtils';
|
||||
import {TEAM_FREE_PLAN} from 'app/common/Features';
|
||||
|
||||
const assert = chai.assert;
|
||||
|
||||
@@ -920,6 +921,9 @@ describe('ApiServer', function() {
|
||||
status: null,
|
||||
externalId: null,
|
||||
externalOptions: null,
|
||||
features: null,
|
||||
stripePlanId: null,
|
||||
paymentLink: null,
|
||||
},
|
||||
});
|
||||
assert.isNotNull(org.updatedAt);
|
||||
@@ -2151,7 +2155,7 @@ describe('ApiServer', function() {
|
||||
'best-friends-squad', false);
|
||||
await dbManager.connection.query(
|
||||
'update billing_accounts set product_id = (select id from products where name = $1) where id = $2',
|
||||
['teamFree', prevAccount.id]
|
||||
[TEAM_FREE_PLAN, prevAccount.id]
|
||||
);
|
||||
|
||||
const resp = await axios.post(`${homeUrl}/api/orgs/${freeTeamOrgId}/workspaces`, {
|
||||
|
||||
@@ -64,8 +64,8 @@ describe('ApiSession', function() {
|
||||
'createdAt', 'updatedAt', 'host']);
|
||||
assert.deepEqual(resp.data.org.billingAccount,
|
||||
{ id: 1, individual: false, inGoodStanding: true, status: null,
|
||||
externalId: null, externalOptions: null,
|
||||
isManager: true, paid: false,
|
||||
externalId: null, externalOptions: null, paymentLink: null,
|
||||
isManager: true, paid: false, features: null, stripePlanId: null,
|
||||
product: { id: 1, name: 'Free', features: {workspaces: true, vanityDomain: true} } });
|
||||
|
||||
// Check that internally we have access to stripe ids.
|
||||
@@ -74,7 +74,7 @@ describe('ApiSession', function() {
|
||||
assert.hasAllKeys(org2.data!.billingAccount,
|
||||
['id', 'individual', 'inGoodStanding', 'status', 'stripeCustomerId',
|
||||
'stripeSubscriptionId', 'stripePlanId', 'product', 'paid', 'isManager',
|
||||
'externalId', 'externalOptions']);
|
||||
'externalId', 'externalOptions', 'features', 'paymentLink']);
|
||||
});
|
||||
|
||||
it('GET /api/session/access/active returns orgErr when org is forbidden', async function() {
|
||||
|
||||
@@ -41,6 +41,7 @@ import {ForkIndexes1678737195050 as ForkIndexes} from 'app/gen-server/migration/
|
||||
import {ActivationPrefs1682636695021 as ActivationPrefs} from 'app/gen-server/migration/1682636695021-ActivationPrefs';
|
||||
import {AssistantLimit1685343047786 as AssistantLimit} from 'app/gen-server/migration/1685343047786-AssistantLimit';
|
||||
import {Shares1701557445716 as Shares} from 'app/gen-server/migration/1701557445716-Shares';
|
||||
import {Billing1711557445716 as BillingFeatures} from 'app/gen-server/migration/1711557445716-Billing';
|
||||
|
||||
const home: HomeDBManager = new HomeDBManager();
|
||||
|
||||
@@ -49,7 +50,7 @@ const migrations = [Initial, Login, PinDocs, UserPicture, DisplayEmail, DisplayE
|
||||
CustomerIndex, ExtraIndexes, OrgHost, DocRemovedAt, Prefs,
|
||||
ExternalBilling, DocOptions, Secret, UserOptions, GracePeriodStart,
|
||||
DocumentUsage, Activations, UserConnectId, UserUUID, UserUniqueRefUUID,
|
||||
Forks, ForkIndexes, ActivationPrefs, AssistantLimit, Shares];
|
||||
Forks, ForkIndexes, ActivationPrefs, AssistantLimit, Shares, BillingFeatures];
|
||||
|
||||
// Assert that the "members" acl rule and group exist (or not).
|
||||
function assertMembersGroup(org: Organization, exists: boolean) {
|
||||
|
||||
@@ -37,7 +37,7 @@ import {Document} from "app/gen-server/entity/Document";
|
||||
import {Group} from "app/gen-server/entity/Group";
|
||||
import {Login} from "app/gen-server/entity/Login";
|
||||
import {Organization} from "app/gen-server/entity/Organization";
|
||||
import {Product, PRODUCTS, synchronizeProducts, testDailyApiLimitFeatures} from "app/gen-server/entity/Product";
|
||||
import {Product, PRODUCTS, synchronizeProducts, teamFreeFeatures} from "app/gen-server/entity/Product";
|
||||
import {User} from "app/gen-server/entity/User";
|
||||
import {Workspace} from "app/gen-server/entity/Workspace";
|
||||
import {EXAMPLE_WORKSPACE_NAME} from 'app/gen-server/lib/HomeDBManager';
|
||||
@@ -48,6 +48,13 @@ import * as fse from 'fs-extra';
|
||||
|
||||
const ACCESS_GROUPS = ['owners', 'editors', 'viewers', 'guests', 'members'];
|
||||
|
||||
|
||||
export const testDailyApiLimitFeatures = {
|
||||
...teamFreeFeatures,
|
||||
baseMaxApiUnitsPerDocumentPerDay: 3,
|
||||
};
|
||||
|
||||
|
||||
const testProducts = [
|
||||
...PRODUCTS,
|
||||
{
|
||||
@@ -428,7 +435,11 @@ class Seed {
|
||||
const ba = new BillingAccount();
|
||||
ba.individual = false;
|
||||
const productName = org.product || 'Free';
|
||||
ba.product = (await Product.findOne({where: {name: productName}}))!;
|
||||
const product = await Product.findOne({where: {name: productName}});
|
||||
if (!product) {
|
||||
throw new Error(`Product not found: ${productName}`);
|
||||
}
|
||||
ba.product = product;
|
||||
o.billingAccount = ba;
|
||||
if (org.domain) { o.domain = org.domain; }
|
||||
if (org.host) { o.host = org.host; }
|
||||
|
||||
@@ -236,7 +236,7 @@ export async function getDocWorkerUrl(): Promise<string> {
|
||||
}
|
||||
|
||||
export async function waitForUrl(pattern: RegExp|string, waitMs: number = 2000) {
|
||||
await driver.wait(() => testCurrentUrl(pattern), waitMs);
|
||||
await driver.wait(() => testCurrentUrl(pattern), waitMs, `waiting for url ${pattern}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -1823,6 +1823,34 @@ export async function editOrgAcls(): Promise<void> {
|
||||
await driver.findWait('.test-um-members', 3000);
|
||||
}
|
||||
|
||||
export async function addUser(email: string|string[], role?: 'Owner'|'Viewer'|'Editor'): Promise<void> {
|
||||
await driver.findWait('.test-user-icon', 5000).click();
|
||||
await driver.find('.test-dm-org-access').click();
|
||||
await driver.findWait('.test-um-members', 500);
|
||||
const orgInput = await driver.find('.test-um-member-new input');
|
||||
|
||||
const emails = Array.isArray(email) ? email : [email];
|
||||
for(const e of emails) {
|
||||
await orgInput.sendKeys(e, Key.ENTER);
|
||||
if (role && role !== 'Viewer') {
|
||||
await driver.findContentWait('.test-um-member', e, 1000).find('.test-um-member-role').click();
|
||||
await driver.findContent('.test-um-role-option', role ?? 'Viewer').click();
|
||||
}
|
||||
}
|
||||
await driver.find('.test-um-confirm').click();
|
||||
await driver.wait(async () => !await driver.find('.test-um-members').isPresent(), 500);
|
||||
}
|
||||
|
||||
export async function removeUser(email: string): Promise<void> {
|
||||
await driver.findWait('.test-user-icon', 5000).click();
|
||||
await driver.find('.test-dm-org-access').click();
|
||||
await driver.findWait('.test-um-members', 500);
|
||||
const kiwiRow = await driver.findContent('.test-um-member', email);
|
||||
await kiwiRow.find('.test-um-member-delete').click();
|
||||
await driver.find('.test-um-confirm').click();
|
||||
await driver.wait(async () => !await driver.find('.test-um-members').isPresent(), 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click confirm on a user manager dialog. If clickRemove is set, then
|
||||
* any extra modal that pops up will be accepted. Returns true unless
|
||||
@@ -3746,6 +3774,23 @@ export function findValue(selector: string, value: string|RegExp) {
|
||||
return new WebElementPromise(driver, inner());
|
||||
}
|
||||
|
||||
export async function switchUser(email: string) {
|
||||
await driver.findWait('.test-user-icon', 1000).click();
|
||||
await driver.findContentWait('.test-usermenu-other-email', exactMatch(email), 1000).click();
|
||||
await waitForServer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the toast message with the given text to appear.
|
||||
*/
|
||||
export async function waitForAccessDenied() {
|
||||
await waitToPass(async () => {
|
||||
assert.equal(
|
||||
await driver.findWait('.test-notifier-toast-message', 1000).getText(),
|
||||
'access denied');
|
||||
});
|
||||
}
|
||||
|
||||
} // end of namespace gristUtils
|
||||
|
||||
stackWrapOwnMethods(gristUtils);
|
||||
|
||||
@@ -5,7 +5,6 @@ import {SHARE_KEY_PREFIX} from 'app/common/gristUrls';
|
||||
import {arrayRepeat} from 'app/common/gutil';
|
||||
import {WebhookSummary} from 'app/common/Triggers';
|
||||
import {DocAPI, DocState, UserAPIImpl} from 'app/common/UserAPI';
|
||||
import {testDailyApiLimitFeatures} from 'app/gen-server/entity/Product';
|
||||
import {AddOrUpdateRecord, Record as ApiRecord, ColumnsPut, RecordWithStringId} from 'app/plugin/DocApiTypes';
|
||||
import {CellValue, GristObjCode} from 'app/plugin/GristData';
|
||||
import {
|
||||
@@ -41,6 +40,7 @@ import {waitForIt} from 'test/server/wait';
|
||||
import defaultsDeep = require('lodash/defaultsDeep');
|
||||
import pick = require('lodash/pick');
|
||||
import { getDatabase } from 'test/testUtils';
|
||||
import {testDailyApiLimitFeatures} from 'test/gen-server/seed';
|
||||
|
||||
const chimpy = configForUser('Chimpy');
|
||||
const kiwi = configForUser('Kiwi');
|
||||
|
||||
Reference in New Issue
Block a user