From 14f7e30e6f203c9095800e03413b8ab3a4db0845 Mon Sep 17 00:00:00 2001 From: Paul Fitzpatrick Date: Fri, 8 Apr 2022 09:52:08 -0400 Subject: [PATCH] (core) add users.options.isConsultant flag, and omit such users from billing Summary: This adds an optional `isConsultant` flag to `users.options`, and an endpoint that allows the support user to turn it on or off. Users marked as consultants are not counted as billable members. Follows the example of existing `allowGoogleLogin` option. Billable members are counted when members are added or removed from a site. Changing the `isConsultant` flag has no immediate or retroactive effect on billing. The number of users in stripe is now set unconditionally, rather than only when it has changed. Notifications to billing managers are not aware of this billing nuance, but continue to report user counts that include consultants. The notifications link users to the billing page. Test Plan: extended test Reviewers: georgegevoian Reviewed By: georgegevoian Subscribers: anaisconce, jarek Differential Revision: https://phab.getgrist.com/D3362 --- app/client/ui/BillingPage.ts | 2 +- app/common/UserAPI.ts | 11 +++++++++++ app/gen-server/ApiServer.ts | 19 +++++++++++++++++++ app/gen-server/lib/HomeDBManager.ts | 2 +- 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/app/client/ui/BillingPage.ts b/app/client/ui/BillingPage.ts index 68bd1c23..225bc0cb 100644 --- a/app/client/ui/BillingPage.ts +++ b/app/client/ui/BillingPage.ts @@ -174,7 +174,7 @@ export class BillingPage extends Disposable { ] : null, moneyPlan.amount ? [ makeSummaryFeature([`Your team site has `, `${sub.userCount}`, - ` member${sub.userCount > 1 ? 's' : ''}`]), + ` member${sub.userCount !== 1 ? 's' : ''}`]), tier ? this.buildAppSumoPlanNotes(discountName!) : null, // Currently the subtotal is misleading and scary when tiers are in effect. // In this case, for now, just report what will be invoiced. diff --git a/app/common/UserAPI.ts b/app/common/UserAPI.ts index 50840d04..00cde1e8 100644 --- a/app/common/UserAPI.ts +++ b/app/common/UserAPI.ts @@ -135,6 +135,9 @@ export interface Document extends DocumentProperties { export interface UserOptions { // Whether signing in with Google is allowed. Defaults to true if unset. allowGoogleLogin?: boolean; + // Whether user is a consultant. Consultant users can be added to sites + // without being counted for billing. Defaults to false if unset. + isConsultant?: boolean; } export interface PermissionDelta { @@ -312,6 +315,7 @@ export interface UserAPI { getUserProfile(): Promise; updateUserName(name: string): Promise; updateAllowGoogleLogin(allowGoogleLogin: boolean): Promise; + updateIsConsultant(userId: number, isConsultant: boolean): Promise; getWorker(key: string): Promise; getWorkerAPI(key: string): Promise; getBillingAPI(): BillingAPI; @@ -608,6 +612,13 @@ export class UserAPIImpl extends BaseAPI implements UserAPI { }); } + public async updateIsConsultant(userId: number, isConsultant: boolean): Promise { + await this.request(`${this._url}/api/profile/isConsultant`, { + method: 'POST', + body: JSON.stringify({userId, isConsultant}) + }); + } + public async getWorker(key: string): Promise { const json = await this.requestJson(`${this._url}/api/worker/${key}`, { method: 'GET', diff --git a/app/gen-server/ApiServer.ts b/app/gen-server/ApiServer.ts index b51253f6..0ad019b5 100644 --- a/app/gen-server/ApiServer.ts +++ b/app/gen-server/ApiServer.ts @@ -379,6 +379,25 @@ export class ApiServer { res.sendStatus(200); })); + this._app.post('/api/profile/isConsultant', expressWrap(async (req, res) => { + const userId = getAuthorizedUserId(req); + if (userId !== this._dbManager.getSupportUserId()) { + throw new ApiError('Only support user can enable/disable isConsultant', 401); + } + const isConsultant: boolean | undefined = req.body.isConsultant; + const targetUserId: number | undefined = req.body.userId; + if (isConsultant === undefined) { + throw new ApiError('Missing body param: isConsultant', 400); + } + if (targetUserId === undefined) { + throw new ApiError('Missing body param: targetUserId', 400); + } + await this._dbManager.updateUserOptions(targetUserId, { + isConsultant + }); + res.sendStatus(200); + })); + // GET /api/profile/apikey // Get user's apiKey this._app.get('/api/profile/apikey', expressWrap(async (req, res) => { diff --git a/app/gen-server/lib/HomeDBManager.ts b/app/gen-server/lib/HomeDBManager.ts index db681d6e..871efbda 100644 --- a/app/gen-server/lib/HomeDBManager.ts +++ b/app/gen-server/lib/HomeDBManager.ts @@ -4185,7 +4185,7 @@ function getFrom(queryBuilder: SelectQueryBuilder): string { } // Flatten a map of users per role into a simple list of users. -function removeRole(usersWithRoles: Map) { +export function removeRole(usersWithRoles: Map) { return flatten([...usersWithRoles.values()]); }