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 => {
|
||||
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!;
|
||||
});
|
||||
|
@ -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[];
|
||||
|
||||
|
@ -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);
|
||||
|
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user