From cc04c6481af099179cad3ebec812aa5aec43ae5c Mon Sep 17 00:00:00 2001 From: Paul Fitzpatrick Date: Thu, 17 Jun 2021 16:48:46 -0400 Subject: [PATCH] (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 --- app/gen-server/ApiServer.ts | 5 +- app/gen-server/entity/BillingAccount.ts | 13 +++++ app/gen-server/lib/HomeDBManager.ts | 54 +++++++++++++++---- .../1623871765992-ExternalBilling.ts | 24 +++++++++ app/server/lib/FlexServer.ts | 14 ++++- app/server/lib/GristServer.ts | 1 + app/server/lib/ICreate.ts | 2 +- 7 files changed, 101 insertions(+), 12 deletions(-) create mode 100644 app/gen-server/migration/1623871765992-ExternalBilling.ts diff --git a/app/gen-server/ApiServer.ts b/app/gen-server/ApiServer.ts index 67eab7e7..394c5f39 100644 --- a/app/gen-server/ApiServer.ts +++ b/app/gen-server/ApiServer.ts @@ -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!; }); diff --git a/app/gen-server/entity/BillingAccount.ts b/app/gen-server/entity/BillingAccount.ts index 6ad07714..a4a5ac83 100644 --- a/app/gen-server/entity/BillingAccount.ts +++ b/app/gen-server/entity/BillingAccount.ts @@ -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[]; diff --git a/app/gen-server/lib/HomeDBManager.ts b/app/gen-server/lib/HomeDBManager.ts index 3d02bd2b..2ffee5c4 100644 --- a/app/gen-server/lib/HomeDBManager.ts +++ b/app/gen-server/lib/HomeDBManager.ts @@ -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 { + 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, - setUserAsOwner: boolean, useNewPlan: boolean, + options: { setUserAsOwner: boolean, + useNewPlan: boolean, + externalId?: string, + externalOptions?: ExternalBillingOptions }, transaction?: EntityManager): Promise> { 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); diff --git a/app/gen-server/migration/1623871765992-ExternalBilling.ts b/app/gen-server/migration/1623871765992-ExternalBilling.ts new file mode 100644 index 00000000..d82103f7 --- /dev/null +++ b/app/gen-server/migration/1623871765992-ExternalBilling.ts @@ -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 { + 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 { + await queryRunner.dropColumn('billing_accounts', 'external_id'); + await queryRunner.dropColumn('billing_accounts', 'external_options'); + } +} diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts index 8f6c9ed5..10dd06a8 100644 --- a/app/server/lib/FlexServer.ts +++ b/app/server/lib/FlexServer.ts @@ -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 { + 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; } diff --git a/app/server/lib/GristServer.ts b/app/server/lib/GristServer.ts index 50b6c3a7..81bb8c12 100644 --- a/app/server/lib/GristServer.ts +++ b/app/server/lib/GristServer.ts @@ -20,6 +20,7 @@ export interface GristServer { getHomeUrl(req: express.Request, relPath?: string): string; getHomeUrlByDocId(docId: string, relPath?: string): Promise; getDocUrl(docId: string): Promise; + getOrgUrl(orgKey: string|number): Promise; getResourceUrl(resource: Organization|Workspace|Document): Promise; getGristConfig(): GristLoadConfig; getPermitStore(): IPermitStore; diff --git a/app/server/lib/ICreate.ts b/app/server/lib/ICreate.ts index 3e16a767..8782610c 100644 --- a/app/server/lib/ICreate.ts +++ b/app/server/lib/ICreate.ts @@ -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;