(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:
Jarosław Sadziński
2024-05-17 21:14:34 +02:00
parent ed9514bae0
commit 60423edc17
40 changed files with 720 additions and 248 deletions

View File

@@ -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`, {

View File

@@ -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() {

View File

@@ -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) {

View File

@@ -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; }

View File

@@ -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);

View File

@@ -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');