mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(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:
parent
ca57b3c099
commit
cc04c6481a
@ -70,7 +70,10 @@ export function addOrg(
|
|||||||
return dbManager.connection.transaction(async manager => {
|
return dbManager.connection.transaction(async manager => {
|
||||||
const user = await manager.findOne(User, userId);
|
const user = await manager.findOne(User, userId);
|
||||||
if (!user) { return handleDeletedUser(); }
|
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); }
|
if (query.status !== 200) { throw new ApiError(query.errMessage!, query.status); }
|
||||||
return query.data!;
|
return query.data!;
|
||||||
});
|
});
|
||||||
|
@ -12,6 +12,13 @@ interface BillingAccountStatus {
|
|||||||
message?: string;
|
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
|
* 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
|
* 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})
|
@Column({name: 'stripe_plan_id', type: String, nullable: true})
|
||||||
public stripePlanId: string | null;
|
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)
|
@OneToMany(type => BillingAccountManager, manager => manager.billingAccount)
|
||||||
public managers: BillingAccountManager[];
|
public managers: BillingAccountManager[];
|
||||||
|
|
||||||
|
@ -13,7 +13,7 @@ import {ANONYMOUS_USER_EMAIL, DocumentProperties, EVERYONE_EMAIL,
|
|||||||
WorkspaceProperties} from "app/common/UserAPI";
|
WorkspaceProperties} from "app/common/UserAPI";
|
||||||
import {AclRule, AclRuleDoc, AclRuleOrg, AclRuleWs} from "app/gen-server/entity/AclRule";
|
import {AclRule, AclRuleDoc, AclRuleOrg, AclRuleWs} from "app/gen-server/entity/AclRule";
|
||||||
import {Alias} from "app/gen-server/entity/Alias";
|
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 {BillingAccountManager} from "app/gen-server/entity/BillingAccountManager";
|
||||||
import {Document} from "app/gen-server/entity/Document";
|
import {Document} from "app/gen-server/entity/Document";
|
||||||
import {Group} from "app/gen-server/entity/Group";
|
import {Group} from "app/gen-server/entity/Group";
|
||||||
@ -522,7 +522,10 @@ export class HomeDBManager extends EventEmitter {
|
|||||||
// Add a personal organization for this user.
|
// Add a personal organization for this user.
|
||||||
// We don't add a personal org for anonymous/everyone/previewer "users" as it could
|
// We don't add a personal org for anonymous/everyone/previewer "users" as it could
|
||||||
// get a bit confusing.
|
// 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) {
|
if (result.status !== 200) {
|
||||||
throw new Error(result.errMessage);
|
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.
|
* 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>,
|
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>> {
|
transaction?: EntityManager): Promise<QueryResult<number>> {
|
||||||
const notifications: Array<() => void> = [];
|
const notifications: Array<() => void> = [];
|
||||||
const name = props.name;
|
const name = props.name;
|
||||||
@ -1102,18 +1120,18 @@ export class HomeDBManager extends EventEmitter {
|
|||||||
// Create or find a billing account to associate with this org.
|
// Create or find a billing account to associate with this org.
|
||||||
const billingAccountEntities = [];
|
const billingAccountEntities = [];
|
||||||
let billingAccount;
|
let billingAccount;
|
||||||
if (useNewPlan) {
|
if (options.useNewPlan) {
|
||||||
const productNames = getDefaultProductNames();
|
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
|
// A bit fragile: this is called during creation of support@ user, before
|
||||||
// getSupportUserId() is available, but with setUserAsOwner of true.
|
// 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
|
// For teams created by support@getgrist.com, set the product to something
|
||||||
// good so payment not needed. This is useful for testing.
|
// good so payment not needed. This is useful for testing.
|
||||||
productName = productNames.team;
|
productName = productNames.team;
|
||||||
}
|
}
|
||||||
billingAccount = new BillingAccount();
|
billingAccount = new BillingAccount();
|
||||||
billingAccount.individual = setUserAsOwner;
|
billingAccount.individual = options.setUserAsOwner;
|
||||||
const dbProduct = await manager.findOne(Product, {name: productName});
|
const dbProduct = await manager.findOne(Product, {name: productName});
|
||||||
if (!dbProduct) {
|
if (!dbProduct) {
|
||||||
throw new Error('Cannot find product for new organization');
|
throw new Error('Cannot find product for new organization');
|
||||||
@ -1124,6 +1142,13 @@ export class HomeDBManager extends EventEmitter {
|
|||||||
billingAccountManager.user = user;
|
billingAccountManager.user = user;
|
||||||
billingAccountManager.billingAccount = billingAccount;
|
billingAccountManager.billingAccount = billingAccount;
|
||||||
billingAccountEntities.push(billingAccountManager);
|
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 {
|
} else {
|
||||||
// Use the billing account from the user's personal org to start with.
|
// Use the billing account from the user's personal org to start with.
|
||||||
billingAccount = await manager.createQueryBuilder()
|
billingAccount = await manager.createQueryBuilder()
|
||||||
@ -1132,6 +1157,9 @@ export class HomeDBManager extends EventEmitter {
|
|||||||
.leftJoinAndSelect('billing_accounts.orgs', 'orgs')
|
.leftJoinAndSelect('billing_accounts.orgs', 'orgs')
|
||||||
.where('orgs.owner_id = :userId', {userId: user.id})
|
.where('orgs.owner_id = :userId', {userId: user.id})
|
||||||
.getOne();
|
.getOne();
|
||||||
|
if (options.externalId && billingAccount?.externalId !== options.externalId) {
|
||||||
|
throw new ApiError('Conflicting external identifier', 400);
|
||||||
|
}
|
||||||
if (!billingAccount) {
|
if (!billingAccount) {
|
||||||
throw new ApiError('Cannot find an initial plan for organization', 500);
|
throw new ApiError('Cannot find an initial plan for organization', 500);
|
||||||
}
|
}
|
||||||
@ -1144,7 +1172,7 @@ export class HomeDBManager extends EventEmitter {
|
|||||||
if (domain) {
|
if (domain) {
|
||||||
org.domain = domain;
|
org.domain = domain;
|
||||||
}
|
}
|
||||||
if (setUserAsOwner) {
|
if (options.setUserAsOwner) {
|
||||||
org.owner = user;
|
org.owner = user;
|
||||||
}
|
}
|
||||||
// Create the special initial permission groups for the new org.
|
// 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.
|
// count are not checked, this will succeed unconditionally.
|
||||||
await this._doAddWorkspace(savedOrg, {name: 'Home'}, manager);
|
await this._doAddWorkspace(savedOrg, {name: 'Home'}, manager);
|
||||||
|
|
||||||
if (!setUserAsOwner) {
|
if (!options.setUserAsOwner) {
|
||||||
// This user just made a team site (once this transaction is applied).
|
// This user just made a team site (once this transaction is applied).
|
||||||
// Emit a notification.
|
// Emit a notification.
|
||||||
notifications.push(this._teamCreatorNotification(user.id));
|
notifications.push(this._teamCreatorNotification(user.id));
|
||||||
@ -1248,6 +1276,14 @@ export class HomeDBManager extends EventEmitter {
|
|||||||
if (org.owner) {
|
if (org.owner) {
|
||||||
throw new ApiError('Cannot set a domain for a personal organization', 400);
|
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);
|
org.updateFromProperties(props);
|
||||||
await manager.save(org);
|
await manager.save(org);
|
||||||
|
24
app/gen-server/migration/1623871765992-ExternalBilling.ts
Normal file
24
app/gen-server/migration/1623871765992-ExternalBilling.ts
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
@ -1138,6 +1138,18 @@ export class FlexServer implements GristServer {
|
|||||||
return this.getResourceUrl(doc);
|
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.
|
* Get a url for an organization, workspace, or document.
|
||||||
*/
|
*/
|
||||||
@ -1448,7 +1460,7 @@ export class FlexServer implements GristServer {
|
|||||||
private _getBilling(): IBilling {
|
private _getBilling(): IBilling {
|
||||||
if (!this._billing) {
|
if (!this._billing) {
|
||||||
if (!this.dbManager) { throw new Error("need dbManager"); }
|
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;
|
return this._billing;
|
||||||
}
|
}
|
||||||
|
@ -20,6 +20,7 @@ export interface GristServer {
|
|||||||
getHomeUrl(req: express.Request, relPath?: string): string;
|
getHomeUrl(req: express.Request, relPath?: string): string;
|
||||||
getHomeUrlByDocId(docId: string, relPath?: string): Promise<string>;
|
getHomeUrlByDocId(docId: string, relPath?: string): Promise<string>;
|
||||||
getDocUrl(docId: string): Promise<string>;
|
getDocUrl(docId: string): Promise<string>;
|
||||||
|
getOrgUrl(orgKey: string|number): Promise<string>;
|
||||||
getResourceUrl(resource: Organization|Workspace|Document): Promise<string>;
|
getResourceUrl(resource: Organization|Workspace|Document): Promise<string>;
|
||||||
getGristConfig(): GristLoadConfig;
|
getGristConfig(): GristLoadConfig;
|
||||||
getPermitStore(): IPermitStore;
|
getPermitStore(): IPermitStore;
|
||||||
|
@ -17,7 +17,7 @@ import { PluginManager } from 'app/server/lib/PluginManager';
|
|||||||
export interface ICreate {
|
export interface ICreate {
|
||||||
LoginSession(comm: Comm, sid: string, domain: string, scopeSession: ScopedSession,
|
LoginSession(comm: Comm, sid: string, domain: string, scopeSession: ScopedSession,
|
||||||
instanceManager: IInstanceManager|null): ILoginSession;
|
instanceManager: IInstanceManager|null): ILoginSession;
|
||||||
Billing(dbManager: HomeDBManager): IBilling;
|
Billing(dbManager: HomeDBManager, gristConfig: GristServer): IBilling;
|
||||||
Notifier(dbManager: HomeDBManager, gristConfig: GristServer): INotifier;
|
Notifier(dbManager: HomeDBManager, gristConfig: GristServer): INotifier;
|
||||||
Shell(): IShell|undefined;
|
Shell(): IShell|undefined;
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user