(core) add appsumo endpoints with stub implementations

Summary:
This adds appsumo /token and /notification endpoints, with some
tests.  The stub implementation is sufficient for AppSumo
activation to succeed (when exposed via port forwarding for testing).
It needs fleshing out:

 * Implement upgrade/downgrade/refund and stripe subscription.
 * Implement custom landing page and flow.

Test Plan: added tests

Reviewers: dsagal, georgegevoian

Reviewed By: dsagal

Subscribers: alexmojaki

Differential Revision: https://phab.getgrist.com/D2864
This commit is contained in:
Paul Fitzpatrick 2021-06-17 16:48:46 -04:00
parent ca57b3c099
commit cc04c6481a
7 changed files with 101 additions and 12 deletions

View File

@ -70,7 +70,10 @@ export function addOrg(
return dbManager.connection.transaction(async manager => {
const user = await manager.findOne(User, userId);
if (!user) { return handleDeletedUser(); }
const query = await dbManager.addOrg(user, props, false, true, manager);
const query = await dbManager.addOrg(user, props, {
setUserAsOwner: false,
useNewPlan: true
}, manager);
if (query.status !== 200) { throw new ApiError(query.errMessage!, query.status); }
return query.data!;
});

View File

@ -12,6 +12,13 @@ interface BillingAccountStatus {
message?: string;
}
// A structure for billing options relevant to an external authority, for sites
// created outside of Grist's regular billing flow.
export interface ExternalBillingOptions {
authority: string; // The name of the external authority.
invoiceId?: string; // An id of an invoice or other external billing context.
}
/**
* This relates organizations to products. It holds any stripe information
* needed to be able to update and pay for the product that applies to the
@ -49,6 +56,12 @@ export class BillingAccount extends BaseEntity {
@Column({name: 'stripe_plan_id', type: String, nullable: true})
public stripePlanId: string | null;
@Column({name: 'external_id', type: String, nullable: true})
public externalId: string | null;
@Column({name: 'external_options', type: nativeValues.jsonEntityType, nullable: true})
public externalOptions: ExternalBillingOptions | null;
@OneToMany(type => BillingAccountManager, manager => manager.billingAccount)
public managers: BillingAccountManager[];

View File

@ -13,7 +13,7 @@ import {ANONYMOUS_USER_EMAIL, DocumentProperties, EVERYONE_EMAIL,
WorkspaceProperties} from "app/common/UserAPI";
import {AclRule, AclRuleDoc, AclRuleOrg, AclRuleWs} from "app/gen-server/entity/AclRule";
import {Alias} from "app/gen-server/entity/Alias";
import {BillingAccount} from "app/gen-server/entity/BillingAccount";
import {BillingAccount, ExternalBillingOptions} from "app/gen-server/entity/BillingAccount";
import {BillingAccountManager} from "app/gen-server/entity/BillingAccountManager";
import {Document} from "app/gen-server/entity/Document";
import {Group} from "app/gen-server/entity/Group";
@ -522,7 +522,10 @@ export class HomeDBManager extends EventEmitter {
// Add a personal organization for this user.
// We don't add a personal org for anonymous/everyone/previewer "users" as it could
// get a bit confusing.
const result = await this.addOrg(user, {name: "Personal"}, true, true, manager);
const result = await this.addOrg(user, {name: "Personal"}, {
setUserAsOwner: true,
useNewPlan: true
}, manager);
if (result.status !== 200) {
throw new Error(result.errMessage);
}
@ -744,6 +747,18 @@ export class HomeDBManager extends EventEmitter {
});
}
/**
* Look up an org by an external id. External IDs are used in integrations, and
* simply offer an alternate way to identify an org.
*/
public async getOrgByExternalId(externalId: string): Promise<Organization|undefined> {
const query = this._orgs()
.leftJoinAndSelect('orgs.billingAccount', 'billing_accounts')
.leftJoinAndSelect('billing_accounts.product', 'products')
.where('external_id = :externalId', {externalId});
return query.getOne();
}
/**
* Returns a QueryResult for an organization with nested workspaces.
*/
@ -1077,7 +1092,10 @@ export class HomeDBManager extends EventEmitter {
*
*/
public async addOrg(user: User, props: Partial<OrganizationProperties>,
setUserAsOwner: boolean, useNewPlan: boolean,
options: { setUserAsOwner: boolean,
useNewPlan: boolean,
externalId?: string,
externalOptions?: ExternalBillingOptions },
transaction?: EntityManager): Promise<QueryResult<number>> {
const notifications: Array<() => void> = [];
const name = props.name;
@ -1102,18 +1120,18 @@ export class HomeDBManager extends EventEmitter {
// Create or find a billing account to associate with this org.
const billingAccountEntities = [];
let billingAccount;
if (useNewPlan) {
if (options.useNewPlan) {
const productNames = getDefaultProductNames();
let productName = setUserAsOwner ? productNames.personal : productNames.teamInitial;
let productName = options.setUserAsOwner ? productNames.personal : productNames.teamInitial;
// A bit fragile: this is called during creation of support@ user, before
// getSupportUserId() is available, but with setUserAsOwner of true.
if (!setUserAsOwner && user.id === this.getSupportUserId()) {
if (!options.setUserAsOwner && user.id === this.getSupportUserId()) {
// 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;
}
billingAccount = new BillingAccount();
billingAccount.individual = setUserAsOwner;
billingAccount.individual = options.setUserAsOwner;
const dbProduct = await manager.findOne(Product, {name: productName});
if (!dbProduct) {
throw new Error('Cannot find product for new organization');
@ -1124,6 +1142,13 @@ export class HomeDBManager extends EventEmitter {
billingAccountManager.user = user;
billingAccountManager.billingAccount = billingAccount;
billingAccountEntities.push(billingAccountManager);
if (options.externalId) {
// save will fail if externalId is a duplicate.
billingAccount.externalId = options.externalId;
}
if (options.externalOptions) {
billingAccount.externalOptions = options.externalOptions;
}
} else {
// Use the billing account from the user's personal org to start with.
billingAccount = await manager.createQueryBuilder()
@ -1132,6 +1157,9 @@ export class HomeDBManager extends EventEmitter {
.leftJoinAndSelect('billing_accounts.orgs', 'orgs')
.where('orgs.owner_id = :userId', {userId: user.id})
.getOne();
if (options.externalId && billingAccount?.externalId !== options.externalId) {
throw new ApiError('Conflicting external identifier', 400);
}
if (!billingAccount) {
throw new ApiError('Cannot find an initial plan for organization', 500);
}
@ -1144,7 +1172,7 @@ export class HomeDBManager extends EventEmitter {
if (domain) {
org.domain = domain;
}
if (setUserAsOwner) {
if (options.setUserAsOwner) {
org.owner = user;
}
// Create the special initial permission groups for the new org.
@ -1180,7 +1208,7 @@ export class HomeDBManager extends EventEmitter {
// count are not checked, this will succeed unconditionally.
await this._doAddWorkspace(savedOrg, {name: 'Home'}, manager);
if (!setUserAsOwner) {
if (!options.setUserAsOwner) {
// This user just made a team site (once this transaction is applied).
// Emit a notification.
notifications.push(this._teamCreatorNotification(user.id));
@ -1248,6 +1276,14 @@ export class HomeDBManager extends EventEmitter {
if (org.owner) {
throw new ApiError('Cannot set a domain for a personal organization', 400);
}
try {
checkSubdomainValidity(props.domain);
} catch (e) {
return {
status: 400,
errMessage: `Domain is not permitted: ${e.message}`
};
}
}
org.updateFromProperties(props);
await manager.save(org);

View File

@ -0,0 +1,24 @@
import { nativeValues } from 'app/gen-server/lib/values';
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
export class ExternalBilling1623871765992 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<any> {
await queryRunner.addColumn('billing_accounts', new TableColumn({
name: 'external_id',
type: 'varchar',
isNullable: true,
isUnique: true,
}));
await queryRunner.addColumn('billing_accounts', new TableColumn({
name: 'external_options',
type: nativeValues.jsonType,
isNullable: true,
}));
}
public async down(queryRunner: QueryRunner): Promise<any> {
await queryRunner.dropColumn('billing_accounts', 'external_id');
await queryRunner.dropColumn('billing_accounts', 'external_options');
}
}

View File

@ -1138,6 +1138,18 @@ export class FlexServer implements GristServer {
return this.getResourceUrl(doc);
}
/**
* Get a url for a team site.
*/
public async getOrgUrl(orgKey: string|number): Promise<string> {
if (!this.dbManager) { throw new Error('database missing'); }
const org = await this.dbManager.getOrg({
userId: this.dbManager.getPreviewerUserId(),
showAll: true
}, orgKey);
return this.getResourceUrl(this.dbManager.unwrapQueryResult(org));
}
/**
* Get a url for an organization, workspace, or document.
*/
@ -1448,7 +1460,7 @@ export class FlexServer implements GristServer {
private _getBilling(): IBilling {
if (!this._billing) {
if (!this.dbManager) { throw new Error("need dbManager"); }
this._billing = this.create.Billing(this.dbManager);
this._billing = this.create.Billing(this.dbManager, this);
}
return this._billing;
}

View File

@ -20,6 +20,7 @@ export interface GristServer {
getHomeUrl(req: express.Request, relPath?: string): string;
getHomeUrlByDocId(docId: string, relPath?: string): Promise<string>;
getDocUrl(docId: string): Promise<string>;
getOrgUrl(orgKey: string|number): Promise<string>;
getResourceUrl(resource: Organization|Workspace|Document): Promise<string>;
getGristConfig(): GristLoadConfig;
getPermitStore(): IPermitStore;

View File

@ -17,7 +17,7 @@ import { PluginManager } from 'app/server/lib/PluginManager';
export interface ICreate {
LoginSession(comm: Comm, sid: string, domain: string, scopeSession: ScopedSession,
instanceManager: IInstanceManager|null): ILoginSession;
Billing(dbManager: HomeDBManager): IBilling;
Billing(dbManager: HomeDBManager, gristConfig: GristServer): IBilling;
Notifier(dbManager: HomeDBManager, gristConfig: GristServer): INotifier;
Shell(): IShell|undefined;