(core) Billing for formula assistant

Summary:
Adding limits for AI calls and connecting those limits with a Stripe Account.

- New table in homedb called `limits`
- All calls to the AI are not routed through DocApi and measured.
- All products now contain a special key `assistantLimit`, with a default value 0
- Limit is reset every time the subscription has changed its period
- The billing page is updated with two new options that describe the AI plan
- There is a new popup that allows the user to upgrade to a higher plan
- Tiers are read directly from the Stripe product with a volume pricing model

Test Plan: Updated and added

Reviewers: georgegevoian, paulfitz

Reviewed By: georgegevoian

Subscribers: dsagal

Differential Revision: https://phab.getgrist.com/D3907
This commit is contained in:
Jarosław Sadziński
2023-07-05 17:36:45 +02:00
parent 75d979abdb
commit d13b9b9019
26 changed files with 501 additions and 106 deletions

View File

@@ -1,5 +1,4 @@
import {ActionGroup} from 'app/common/ActionGroup';
import {AssistanceRequest, AssistanceResponse} from 'app/common/AssistancePrompts';
import {BulkAddRecord, CellValue, TableDataAction, UserAction} from 'app/common/DocActions';
import {FormulaProperties} from 'app/common/GranularAccessClause';
import {UIRowId} from 'app/common/TableData';
@@ -320,11 +319,6 @@ export interface ActiveDocAPI {
*/
getFormulaError(tableId: string, colId: string, rowId: number): Promise<CellValue>;
/**
* Generates a formula code based on the AI suggestions, it also modifies the column and sets it type to a formula.
*/
getAssistance(request: AssistanceRequest): Promise<AssistanceResponse>;
/**
* Fetch content at a url.
*/

View File

@@ -2,15 +2,17 @@
* A tip for fixing an error.
*/
export interface ApiTip {
action: 'add-members' | 'upgrade' |'ask-for-help';
action: 'add-members' | 'upgrade' | 'ask-for-help' | 'manage';
message: string;
}
export type LimitType = 'collaborators' | 'docs' | 'workspaces' | 'assistant';
/**
* Documentation of a limit relevant to an API error.
*/
export interface ApiLimit {
quantity: 'collaborators' | 'docs' | 'workspaces'; // what are we counting
quantity: LimitType; // what are we counting
subquantity?: string; // a nuance to what we are counting
maximum: number; // maximum allowed
value: number; // current value of quantity for user

View File

@@ -43,6 +43,16 @@ export interface IBillingPlan {
active: boolean;
}
export interface ILimitTier {
name?: string;
volume: number;
price: number;
flatFee: number;
type: string;
planId: string;
interval: string; // probably 'month'|'year';
}
// Utility type that requires all properties to be non-nullish.
// type NonNullableProperties<T> = { [P in keyof T]: Required<NonNullable<T[P]>>; };
@@ -69,6 +79,7 @@ export interface IBillingDiscount {
export interface IBillingSubscription {
// All standard plan options.
plans: IBillingPlan[];
tiers: ILimitTier[];
// Index in the plans array of the plan currently in effect.
planIndex: number;
// Index in the plans array of the plan to be in effect after the current period end.
@@ -111,6 +122,14 @@ export interface IBillingSubscription {
lastInvoiceUrl?: string; // URL of the Stripe-hosted page with the last invoice.
lastChargeError?: string; // The last charge error, if any, to show in case of a bad status.
lastChargeTime?: number; // The time of the last charge attempt.
limit?: ILimit|null;
}
export interface ILimit {
limitValue: number;
currentUsage: number;
type: string; // Limit type, for now only assistant is supported.
price: number; // If this is 0, it means it is a free plan.
}
export interface IBillingOrgSettings {
@@ -139,6 +158,7 @@ export interface BillingAPI {
downgradePlan(planName: string): Promise<void>;
renewPlan(): string;
customerPortal(): string;
updateAssistantPlan(tier: number): Promise<void>;
}
export class BillingAPIImpl extends BaseAPI implements BillingAPI {
@@ -230,6 +250,13 @@ export class BillingAPIImpl extends BaseAPI implements BillingAPI {
return `${this._url}/api/billing/renew`;
}
public async updateAssistantPlan(tier: number): Promise<void> {
await this.request(`${this._url}/api/billing/upgrade-assistant`, {
method: 'POST',
body: JSON.stringify({ tier })
});
}
/**
* Checks if current org has active subscription for a Stripe plan.
*/

View File

@@ -58,6 +58,11 @@ export interface Features {
// for attached files in a document
gracePeriodDays?: number; // Duration of the grace period in days, before entering delete-only mode
baseMaxAssistantCalls?: number; // Maximum number of AI assistant calls. Defaults to 0 if not set, use -1 to indicate
// unbound limit. This is total limit, not per month or per day, it is used as a seed
// value for the limits table. To create a per-month limit, there must be a separate
// task that resets the usage in the limits table.
}
// Check whether it is possible to add members at the org level. There's no flag

View File

@@ -1,5 +1,6 @@
import {ActionSummary} from 'app/common/ActionSummary';
import {ApplyUAResult, ForkResult, PermissionDataWithExtraUsers, QueryFilters} from 'app/common/ActiveDocAPI';
import {AssistanceRequest, AssistanceResponse} from 'app/common/AssistancePrompts';
import {BaseAPI, IOptions} from 'app/common/BaseAPI';
import {BillingAPI, BillingAPIImpl} from 'app/common/BillingAPI';
import {BrowserSettings} from 'app/common/BrowserSettings';
@@ -462,6 +463,8 @@ export interface DocAPI {
// Update webhook
updateWebhook(webhook: WebhookUpdate): Promise<void>;
flushWebhooks(): Promise<void>;
getAssistance(params: AssistanceRequest): Promise<AssistanceResponse>;
}
// Operations that are supported by a doc worker.
@@ -1012,6 +1015,13 @@ export class DocAPIImpl extends BaseAPI implements DocAPI {
return response.data[0];
}
public async getAssistance(params: AssistanceRequest): Promise<AssistanceResponse> {
return await this.requestJson(`${this._url}/assistant`, {
method: 'POST',
body: JSON.stringify(params),
});
}
private _getRecords(tableId: string, endpoint: 'data' | 'records', options?: GetRowsParams): Promise<any> {
const url = new URL(`${this._url}/tables/${tableId}/${endpoint}`);
if (options?.filters) {