From 369837eaa370e9f084be2953b27df21632e084ae Mon Sep 17 00:00:00 2001 From: Simon Larsen Date: Thu, 24 Nov 2022 15:10:21 +0000 Subject: [PATCH] fix billing. --- Common/Models/BaseModel.ts | 2 + Common/Types/API/Route.ts | 2 +- Common/Types/Billing/SubscriptionPlan.ts | 8 + .../AllowAccessIfSubscriptionIsUnpaid.ts | 5 + Common/Types/Database/ColumnType.ts | 1 + .../DatabaseCommonInteractionProps.ts | 1 + Common/Types/Permission.ts | 81 ++++++ CommonServer/API/BaseAPI.ts | 4 +- CommonServer/API/BillingInvoiceAPI.ts | 159 +++++++++++ CommonServer/API/BillingPaymentMethodAPI.ts | 3 +- .../Middleware/ProjectAuthorization.ts | 17 -- .../Services/BillingInvoiceService.ts | 92 +++++++ CommonServer/Services/BillingService.ts | 55 ++++ CommonServer/Services/ProjectService.ts | 13 +- CommonServer/Utils/ModelPermission.ts | 4 + CommonUI/src/Components/Icon/Icon.tsx | 14 +- .../src/Components/ModelTable/ModelTable.tsx | 3 - Dashboard/src/Pages/Settings/Invoices.tsx | 169 +++++++++++- Dashboard/src/Pages/Settings/SideMenu.tsx | 6 +- DashboardAPI/Index.ts | 3 + Model/Models/BillingInvoice.ts | 246 ++++++++++++++++++ Model/Models/BillingPaymentMethod.ts | 46 ++-- Model/Models/Index.ts | 2 + Model/Models/Project.ts | 2 + Model/Models/User.ts | 3 + 25 files changed, 876 insertions(+), 65 deletions(-) create mode 100644 Common/Types/Database/AccessControl/AllowAccessIfSubscriptionIsUnpaid.ts create mode 100644 CommonServer/API/BillingInvoiceAPI.ts create mode 100644 CommonServer/Services/BillingInvoiceService.ts create mode 100644 Model/Models/BillingInvoice.ts diff --git a/Common/Models/BaseModel.ts b/Common/Models/BaseModel.ts index 5f27aea773..8572f542e1 100644 --- a/Common/Models/BaseModel.ts +++ b/Common/Models/BaseModel.ts @@ -77,6 +77,8 @@ export default class BaseModel extends BaseEntity { public updateBillingPlan!: PlanSelect | null; public deleteBillingPlan!: PlanSelect | null; + public allowAccessIfSubscriptionIsUnpaid!: boolean; + public currentUserCanAccessColumnBy!: string | null; public labelsColumn!: string | null; diff --git a/Common/Types/API/Route.ts b/Common/Types/API/Route.ts index 2109cad4d8..2fd55c7168 100644 --- a/Common/Types/API/Route.ts +++ b/Common/Types/API/Route.ts @@ -6,7 +6,7 @@ export default class Route { } public set route(v: string) { const matchRouteCharacters: RegExp = - /^[a-zA-Z\d\-!#$&'()*+,./:;=?@[\]]*$/; + /^[a-zA-Z_\d\-!#$&'()*+,./:;=?@[\]]*$/; if (v && !matchRouteCharacters.test(v)) { throw new BadDataException(`Invalid route: ${v}`); } diff --git a/Common/Types/Billing/SubscriptionPlan.ts b/Common/Types/Billing/SubscriptionPlan.ts index 8e091173ca..85059de6e9 100644 --- a/Common/Types/Billing/SubscriptionPlan.ts +++ b/Common/Types/Billing/SubscriptionPlan.ts @@ -177,4 +177,12 @@ export default class SubscriptionPlan { return true; } + + public static isUnpaid(subscriptionStatus: string): boolean { + if (subscriptionStatus === "incomplete" || subscriptionStatus === "incomplete_expired" || subscriptionStatus === "past_due" || subscriptionStatus === "canceled" || subscriptionStatus === "unpaid") { + return true; + } + + return false; + } } diff --git a/Common/Types/Database/AccessControl/AllowAccessIfSubscriptionIsUnpaid.ts b/Common/Types/Database/AccessControl/AllowAccessIfSubscriptionIsUnpaid.ts new file mode 100644 index 0000000000..d6185bb88f --- /dev/null +++ b/Common/Types/Database/AccessControl/AllowAccessIfSubscriptionIsUnpaid.ts @@ -0,0 +1,5 @@ +export default () => { + return (ctr: Function) => { + ctr.prototype.allowAccessIfSubscriptionIsUnpaid = true; + }; +}; diff --git a/Common/Types/Database/ColumnType.ts b/Common/Types/Database/ColumnType.ts index 4ee5ea7334..c74af04d76 100644 --- a/Common/Types/Database/ColumnType.ts +++ b/Common/Types/Database/ColumnType.ts @@ -26,6 +26,7 @@ enum ColumnType { PositiveNumber = 'integer', BigPositiveNumber = 'bigint', SmallNumber = 'smallint', + Decimal = 'decimal', Number = 'integer', BigNumber = 'bigint', Markdown = 'text', diff --git a/Common/Types/Database/DatabaseCommonInteractionProps.ts b/Common/Types/Database/DatabaseCommonInteractionProps.ts index c6bf2fec83..86c7712331 100644 --- a/Common/Types/Database/DatabaseCommonInteractionProps.ts +++ b/Common/Types/Database/DatabaseCommonInteractionProps.ts @@ -19,4 +19,5 @@ export default interface DatabaseCommonInteractionProps { isMultiTenantRequest?: boolean | undefined; ignoreHooks?: boolean | undefined; currentPlan?: PlanSelect | undefined; + isSubscriptionUnpaid?: boolean | undefined; } diff --git a/Common/Types/Permission.ts b/Common/Types/Permission.ts index 4df03fcf44..741e8181b6 100644 --- a/Common/Types/Permission.ts +++ b/Common/Types/Permission.ts @@ -183,6 +183,18 @@ enum Permission { CanDeleteIncidentPublicNote = 'CanDeleteIncidentPublicNote', CanReadIncidentPublicNote = 'CanReadIncidentPublicNote', + + CanCreateInvoices = 'CanCreateInvoices', + CanEditInvoices = 'CanEditInvoices', + CanDeleteInvoices = 'CanDeleteInvoices', + CanReadInvoices = 'CanReadInvoices', + + + CanCreateBillingPaymentMethod = 'CanCreateBillingPaymentMethod', + CanEditBillingPaymentMethod = 'CanEditBillingPaymentMethod', + CanDeleteBillingPaymentMethod = 'CanDeleteBillingPaymentMethod', + CanReadBillingPaymentMethod = 'CanReadBillingPaymentMethod', + CanCreateProjectMonitor = 'CanCreateProjectMonitor', CanEditProjectMonitor = 'CanEditProjectMonitor', CanDeleteProjectMonitor = 'CanDeleteProjectMonitor', @@ -1108,6 +1120,75 @@ export class PermissionHelper { isAccessControlPermission: false, }, + + { + permission: Permission.CanCreateInvoices, + title: 'Can Create Invoices', + description: + 'A user assigned this permission can create Invoices this project.', + isAssignableToTenant: true, + isAccessControlPermission: false, + }, + { + permission: Permission.CanDeleteInvoices, + title: 'Can Delete Invoices', + description: + 'A user assigned this permission can delete Invoices of this project.', + isAssignableToTenant: true, + isAccessControlPermission: false, + }, + { + permission: Permission.CanEditInvoices, + title: 'Can Edit Invoices', + description: + 'A user assigned this permission can edit Invoices of this project.', + isAssignableToTenant: true, + isAccessControlPermission: false, + }, + { + permission: Permission.CanReadInvoices, + title: 'Can Read Invoices', + description: + 'A user assigned this permission can read Invoices of this project.', + isAssignableToTenant: true, + isAccessControlPermission: false, + }, + + + + { + permission: Permission.CanCreateBillingPaymentMethod, + title: 'Can Create Payment Method', + description: + 'A user assigned this permission can create Payment Method this project.', + isAssignableToTenant: true, + isAccessControlPermission: false, + }, + { + permission: Permission.CanDeleteBillingPaymentMethod, + title: 'Can Delete Payment Method', + description: + 'A user assigned this permission can delete Payment Method of this project.', + isAssignableToTenant: true, + isAccessControlPermission: false, + }, + { + permission: Permission.CanEditBillingPaymentMethod, + title: 'Can Edit Payment Method', + description: + 'A user assigned this permission can edit Payment Method of this project.', + isAssignableToTenant: true, + isAccessControlPermission: false, + }, + { + permission: Permission.CanReadBillingPaymentMethod, + title: 'Can Read Payment Method', + description: + 'A user assigned this permission can read Payment Method of this project.', + isAssignableToTenant: true, + isAccessControlPermission: false, + }, + { permission: Permission.CanCreateProjectOnCallDuty, title: 'Can Create On-Call Duty', diff --git a/CommonServer/API/BaseAPI.ts b/CommonServer/API/BaseAPI.ts index 2ab052c811..f47b24f622 100644 --- a/CommonServer/API/BaseAPI.ts +++ b/CommonServer/API/BaseAPI.ts @@ -216,7 +216,9 @@ export default class BaseAPI< } if (IsBillingEnabled && props.tenantId) { - props.currentPlan = await ProjectService.getCurrentPlan(props.tenantId) || undefined; + const plan = await ProjectService.getCurrentPlan(props.tenantId!); + props.currentPlan = plan.plan || undefined; + props.isSubscriptionUnpaid = plan.isSubscriptionUnpaid; } return props; diff --git a/CommonServer/API/BillingInvoiceAPI.ts b/CommonServer/API/BillingInvoiceAPI.ts new file mode 100644 index 0000000000..45353152da --- /dev/null +++ b/CommonServer/API/BillingInvoiceAPI.ts @@ -0,0 +1,159 @@ +import BaseModel from 'Common/Models/BaseModel'; +import BadDataException from 'Common/Types/Exception/BadDataException'; +import { JSONObject } from 'Common/Types/JSON'; +import Permission from 'Common/Types/Permission'; +import BillingInvoice from 'Model/Models/BillingInvoice'; +import Project from 'Model/Models/Project'; +import { IsBillingEnabled } from '../Config'; +import UserMiddleware from '../Middleware/UserAuthorization'; +import BillingInvoiceService, { + Service as BillingInvoiceServiceType, +} from '../Services/BillingInvoiceService'; +import BillingService, { Invoice } from '../Services/BillingService'; +import ProjectService from '../Services/ProjectService'; +import { + ExpressRequest, + ExpressResponse, + NextFunction, +} from '../Utils/Express'; +import Response from '../Utils/Response'; +import BaseAPI from './BaseAPI'; + +export default class UserAPI extends BaseAPI< + BillingInvoice, + BillingInvoiceServiceType +> { + public constructor() { + super(BillingInvoice, BillingInvoiceService); + + this.router.post( + `/${new this.entityType().getCrudApiPath()?.toString()}/pay`, + UserMiddleware.getUserMiddleware, + async ( + req: ExpressRequest, + res: ExpressResponse, + next: NextFunction + ) => { + try { + if (!IsBillingEnabled) { + throw new BadDataException( + 'Billign is not enabled for this server' + ); + } + + if (req.body['projectId']) { + throw new BadDataException( + 'projectId is required in request body' + ); + } + + const userPermissions = (await this.getPermissionsForTenant( + req + )).filter((permission) => { + console.log(permission.permission); + //FIX: Change "Project" + return ( + permission.permission.toString() === Permission.ProjectOwner.toString() || permission.permission.toString() === Permission.CanEditInvoices.toString() + ); + }); + + if (userPermissions.length === 0) { + throw new BadDataException( + `You need ${Permission.ProjectOwner} or ${Permission.CanEditInvoices} permission to pay invoices.` + ); + } + + const project: Project | null = + await ProjectService.findOneById({ + id: this.getTenantId(req)!, + props: { + isRoot: true, + }, + select: { + _id: true, + paymentProviderCustomerId: true, + paymentProviderSubscriptionId: true + }, + }); + + if (!project) { + throw new BadDataException('Project not found'); + } + + if (!project) { + throw new BadDataException('Project not found'); + } + + if (!project.paymentProviderCustomerId) { + throw new BadDataException( + 'Payment Provider customer not found' + ); + } + + if (!project.paymentProviderSubscriptionId) { + throw new BadDataException( + 'Payment Provider subscription not found' + ); + } + + const body: JSONObject = req.body; + + const item: BillingInvoice = BaseModel.fromJSON( + body['data'] as JSONObject, + this.entityType + ) as BillingInvoice; + + + if (!item.paymentProviderInvoiceId) { + throw new BadDataException("Invoice ID not found"); + } + + if (!item.paymentProviderCustomerId) { + throw new BadDataException("Customer ID not found"); + } + + const invoice: Invoice = + await BillingService.payInvoice( + item.paymentProviderCustomerId!, + item.paymentProviderInvoiceId!, + ); + + + // save updated status. + + await this.service.updateOneBy({ + query: { + paymentProviderInvoiceId: invoice.id! + }, + props: { + isRoot: true, + ignoreHooks: true + }, + data: { + status: invoice.status + } + }) + + // refresh subscription status. + const subscriptionState = await BillingService.getSubscriptionStatus(project.paymentProviderSubscriptionId as string); + + await ProjectService.updateOneById({ + id: project.id!, + data: { + paymentProviderSubscriptionStatus: subscriptionState + }, + props: { + isRoot: true, + ignoreHooks: true + } + }); + + return Response.sendEmptyResponse(req, res); + + } catch (err) { + next(err); + } + } + ); + } +} diff --git a/CommonServer/API/BillingPaymentMethodAPI.ts b/CommonServer/API/BillingPaymentMethodAPI.ts index 466b274b6a..d6ee1cc979 100644 --- a/CommonServer/API/BillingPaymentMethodAPI.ts +++ b/CommonServer/API/BillingPaymentMethodAPI.ts @@ -1,4 +1,5 @@ import BadDataException from 'Common/Types/Exception/BadDataException'; +import Permission from 'Common/Types/Permission'; import BillingPaymentMethod from 'Model/Models/BillingPaymentMethod'; import Project from 'Model/Models/Project'; import { IsBillingEnabled } from '../Config'; @@ -50,7 +51,7 @@ export default class UserAPI extends BaseAPI< console.log(permission.permission); //FIX: Change "Project" return ( - permission.permission.toString() === 'ProjectOwner' + permission.permission.toString() === Permission.ProjectOwner.toString() || permission.permission.toString() === Permission.CanCreateBillingPaymentMethod.toString() ); }); diff --git a/CommonServer/Middleware/ProjectAuthorization.ts b/CommonServer/Middleware/ProjectAuthorization.ts index b9d5672808..1649b3576b 100644 --- a/CommonServer/Middleware/ProjectAuthorization.ts +++ b/CommonServer/Middleware/ProjectAuthorization.ts @@ -12,9 +12,6 @@ import ApiKey from 'Model/Models/ApiKey'; import { LessThan } from 'typeorm'; import OneUptimeDate from 'Common/Types/Date'; import UserType from 'Common/Types/UserType'; -import { PlanSelect } from 'Common/Types/Billing/SubscriptionPlan'; -import { IsBillingEnabled } from '../Config'; -import ProjectService from '../Services/ProjectService'; export default class ProjectMiddleware { public static getProjectId(req: ExpressRequest): ObjectID | null { @@ -33,20 +30,6 @@ export default class ProjectMiddleware { return projectId; } - public static async getProjectPlan(req: ExpressRequest): Promise { - if (!IsBillingEnabled) { - return null; - } - - const projectId = this.getProjectId(req); - - if (!projectId) { - return null; - } - - return await ProjectService.getCurrentPlan(projectId); - } - public static getApiKey(req: ExpressRequest): ObjectID | null { let apiKey: ObjectID | null = null; diff --git a/CommonServer/Services/BillingInvoiceService.ts b/CommonServer/Services/BillingInvoiceService.ts new file mode 100644 index 0000000000..9458c10989 --- /dev/null +++ b/CommonServer/Services/BillingInvoiceService.ts @@ -0,0 +1,92 @@ +import PostgresDatabase from '../Infrastructure/PostgresDatabase'; +import Model from 'Model/Models/BillingInvoice'; +import DatabaseService, { OnDelete, OnFind } from './DatabaseService'; +import FindBy from '../Types/Database/FindBy'; +import ProjectService from './ProjectService'; +import BadDataException from 'Common/Types/Exception/BadDataException'; +import Project from 'Model/Models/Project'; +import BillingService from './BillingService'; +import DeleteBy from '../Types/Database/DeleteBy'; +import URL from 'Common/Types/API/URL'; + +export class Service extends DatabaseService { + public constructor(postgresDatabase?: PostgresDatabase) { + super(Model, postgresDatabase); + } + + protected override async onBeforeFind( + findBy: FindBy + ): Promise> { + if (!findBy.props.tenantId) { + throw new BadDataException('ProjectID not found.'); + } + + const project: Project | null = await ProjectService.findOneById({ + id: findBy.props.tenantId!, + props: { + ...findBy.props, + isRoot: true, + ignoreHooks: true, + }, + select: { + _id: true, + paymentProviderCustomerId: true, + }, + }); + + if (!project) { + throw new BadDataException('Project not found'); + } + + if (!project.paymentProviderCustomerId) { + throw new BadDataException( + 'Payment provider customer id not found.' + ); + } + + const invoices = await BillingService.getInvoices( + project.paymentProviderCustomerId + ); + + await this.deleteBy({ + query: { + projectId: findBy.props.tenantId!, + }, + props: { + isRoot: true, + ignoreHooks: true, + }, + }); + + for (const invoice of invoices) { + const billingInvoice = new Model(); + + billingInvoice.projectId = project.id!; + + billingInvoice.amount = invoice.amount; + billingInvoice.downloadableLink = URL.fromString(invoice.downloadableLink); + billingInvoice.currencyCode = invoice.currencyCode; + billingInvoice.paymentProviderCustomerId = invoice.customerId || ''; + billingInvoice.paymentProviderSubscriptionId = invoice.subscriptionId || ''; + billingInvoice.status = invoice.status || ''; + billingInvoice.paymentProviderInvoiceId = invoice.id; + + await this.create({ + data: billingInvoice, + props: { + isRoot: true, + }, + }); + } + + return { findBy, carryForward: invoices }; + } + + protected override async onBeforeDelete( + _deleteBy: DeleteBy + ): Promise> { + throw new BadDataException("Invoice should not be deleted.") + } +} + +export default new Service(); diff --git a/CommonServer/Services/BillingService.ts b/CommonServer/Services/BillingService.ts index 01765d112c..3b63f9555d 100644 --- a/CommonServer/Services/BillingService.ts +++ b/CommonServer/Services/BillingService.ts @@ -13,6 +13,16 @@ export interface PaymentMethod { isDefault: boolean; } +export interface Invoice { + id: string; + amount: number; + currencyCode: string; + subscriptionId?: string | undefined; + status: string; + downloadableLink: string; + customerId: string | undefined; +} + export class BillingService { private static stripe: Stripe = new Stripe(BillingPrivateKey, { apiVersion: '2022-08-01', @@ -335,6 +345,51 @@ export class BillingService { return subscription.status; } + + public static async getInvoices(customerId: string): Promise> { + const invoices = await this.stripe.invoices.list({ + customer: customerId, + limit: 100, + }); + + return invoices.data.map((invoice) => { + return { + id: invoice.id!, + amount: invoice.amount_due, + currencyCode: invoice.currency, + subscriptionId: invoice.subscription?.toString() || undefined, + status: invoice.status?.toString() || 'Unknown', + downloadableLink: invoice.invoice_pdf?.toString() || '', + customerId: invoice.customer?.toString() || '' + } + }); + + } + + public static async payInvoice(customerId: string, invoiceId: string): Promise { + // after the invoice is paid, // please fetch subscription and check the status. + const paymentMethods = await this.getPaymentMethods(customerId); + + if (paymentMethods.length === 0) { + throw new BadDataException("Payment Method not added. Please add a payment method."); + } + + const invoice = await this.stripe.invoices.pay( + invoiceId, { + payment_method: paymentMethods[0]?.id || '' + } + ); + + return { + id: invoice.id!, + amount: invoice.amount_due, + currencyCode: invoice.currency, + subscriptionId: invoice.subscription?.toString() || undefined, + status: invoice.status?.toString() || 'Unknown', + downloadableLink: invoice.invoice_pdf?.toString() || '', + customerId: invoice.customer?.toString() || '' + } + } } export default BillingService; diff --git a/CommonServer/Services/ProjectService.ts b/CommonServer/Services/ProjectService.ts index 3f5e1595f6..2130035e3f 100755 --- a/CommonServer/Services/ProjectService.ts +++ b/CommonServer/Services/ProjectService.ts @@ -146,7 +146,7 @@ export class Service extends DatabaseService { plan, project.paymentProviderSubscriptionSeats as number, plan.getYearlyPlanId() === - updateBy.data.paymentProviderPlanId, + updateBy.data.paymentProviderPlanId, project.trialEndsAt ); } @@ -429,7 +429,7 @@ export class Service extends DatabaseService { let ownerTeam: Team = new Team(); ownerTeam.projectId = createdItem.id!; ownerTeam.name = 'Owners'; - ownerTeam.shouldHaveAtleastOneMember = true; + ownerTeam.shouldHaveAtleastOneMember = true; ownerTeam.isPermissionsEditable = false; ownerTeam.isTeamEditable = false; ownerTeam.isTeamDeleteable = false; @@ -592,15 +592,16 @@ export class Service extends DatabaseService { return onDelete; } - public async getCurrentPlan(projectId: ObjectID): Promise { + public async getCurrentPlan(projectId: ObjectID): Promise<{ plan: PlanSelect | null, isSubscriptionUnpaid: boolean }> { if (!IsBillingEnabled) { - return null; + return { plan: null, isSubscriptionUnpaid: false }; } const project = await this.findOneById({ id: projectId, select: { - paymentProviderPlanId: true + paymentProviderPlanId: true, + paymentProviderSubscriptionStatus: true }, props: { isRoot: true, @@ -616,7 +617,7 @@ export class Service extends DatabaseService { throw new BadDataException("Project does not have any plans"); } - return SubscriptionPlan.getPlanSelect(project.paymentProviderPlanId); + return { plan: SubscriptionPlan.getPlanSelect(project.paymentProviderPlanId), isSubscriptionUnpaid: SubscriptionPlan.isUnpaid(project.paymentProviderSubscriptionStatus || 'active')}; } } diff --git a/CommonServer/Utils/ModelPermission.ts b/CommonServer/Utils/ModelPermission.ts index 021b18aa92..a9a09a5626 100644 --- a/CommonServer/Utils/ModelPermission.ts +++ b/CommonServer/Utils/ModelPermission.ts @@ -964,6 +964,10 @@ export default class ModelPermission { const model = new modelType(); + if (props.isSubscriptionUnpaid && !model.allowAccessIfSubscriptionIsUnpaid) { + throw new PaymentRequiredException("Your current subscription is in an unpaid state. Looks like your payment method failed. Please add a new payment method in Project Settings > Billing to proceed.") + } + if (type === DatabaseRequestType.Create && model.createBillingPlan) { if (!SubscriptionPlan.isFeatureAccessibleOnCurrentPlan(model.createBillingPlan, props.currentPlan)) { throw new PaymentRequiredException("Please upgrade your plan to " + model.createBillingPlan + " to access this feature"); diff --git a/CommonUI/src/Components/Icon/Icon.tsx b/CommonUI/src/Components/Icon/Icon.tsx index 966a2ba938..542afcec64 100644 --- a/CommonUI/src/Components/Icon/Icon.tsx +++ b/CommonUI/src/Components/Icon/Icon.tsx @@ -58,7 +58,8 @@ import { ExternalLink, Layers, Codesandbox, - Star + Star, + ArrowDown } from 'react-feather'; export enum SizeProp { @@ -144,7 +145,8 @@ export enum IconProp { Clock, Invoice, Upgrade, - Star + Star, + Download } export interface ComponentProps { @@ -646,6 +648,14 @@ const Icon: FunctionComponent = ({ color={color ? color.toString() : (undefined as any)} /> )} + + {icon === IconProp.Download && ( + + )} ); }; diff --git a/CommonUI/src/Components/ModelTable/ModelTable.tsx b/CommonUI/src/Components/ModelTable/ModelTable.tsx index fc538a2a1e..174a039bfa 100644 --- a/CommonUI/src/Components/ModelTable/ModelTable.tsx +++ b/CommonUI/src/Components/ModelTable/ModelTable.tsx @@ -338,9 +338,6 @@ const ModelTable: Function = ( }; const fetchItems: Function = async () => { - if (isLoading) { - return; - } setError(''); setIsLoading(true); diff --git a/Dashboard/src/Pages/Settings/Invoices.tsx b/Dashboard/src/Pages/Settings/Invoices.tsx index 69405467a9..1c32571d56 100644 --- a/Dashboard/src/Pages/Settings/Invoices.tsx +++ b/Dashboard/src/Pages/Settings/Invoices.tsx @@ -1,17 +1,74 @@ import Route from 'Common/Types/API/Route'; +import { JSONObject } from 'Common/Types/JSON'; +import Button, { ButtonStyleType } from 'CommonUI/src/Components/Button/Button'; +import { IconProp } from 'CommonUI/src/Components/Icon/Icon'; +import ModelTable from 'CommonUI/src/Components/ModelTable/ModelTable'; import Page from 'CommonUI/src/Components/Page/Page'; -import React, { FunctionComponent, ReactElement } from 'react'; +import Navigation from 'CommonUI/src/Utils/Navigation'; +import React, { + FunctionComponent, + ReactElement, + useState, +} from 'react'; +import Text from 'Common/Types/Text'; import PageMap from '../../Utils/PageMap'; import RouteMap from '../../Utils/RouteMap'; import PageComponentProps from '../PageComponentProps'; import DashboardSideMenu from './SideMenu'; -import Alert, { AlertType } from 'CommonUI/src/Components/Alerts/Alert'; +import BillingInvoice from 'Model/Models/BillingInvoice'; +import FieldType from 'CommonUI/src/Components/Types/FieldType'; +import URL from 'Common/Types/API/URL'; +import Pill from 'CommonUI/src/Components/Pill/Pill'; +import { Green, Yellow } from 'Common/Types/BrandColors'; +import { DASHBOARD_API_URL } from 'CommonUI/src/Config'; +import BaseAPI from 'CommonUI/src/Utils/API/API'; +import ModelAPI from 'CommonUI/src/Utils/ModelAPI/ModelAPI'; +import HTTPErrorResponse from 'Common/Types/API/HTTPErrorResponse'; +import ConfirmModal from 'CommonUI/src/Components/Modal/ConfirmModal'; +import ComponentLoader from 'CommonUI/src/Components/ComponentLoader/ComponentLoader'; -export interface ComponentProps extends PageComponentProps {} +export interface ComponentProps extends PageComponentProps { } const Settings: FunctionComponent = ( - _props: ComponentProps + props: ComponentProps ): ReactElement => { + + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + const payInvoice = async (customerId: string, invoiceId: string) => { + try { + setIsLoading(true); + + + await BaseAPI.post( + URL.fromString(DASHBOARD_API_URL.toString()).addRoute( + `/billing-invoices/pay` + ), + { + data: { + paymentProviderInvoiceId: invoiceId, + paymentProviderCustomerId: customerId, + } + }, + ModelAPI.getCommonHeaders() + ); + + Navigation.reload(); + } catch (err) { + try { + setError( + (err as HTTPErrorResponse).message || + 'Server Error. Please try again' + ); + } catch (e) { + setError('Server Error. Please try again'); + } + setIsLoading(false); + } + }; + + return ( = ( ]} sideMenu={} > - + + + {isLoading ? : <>} + + {!isLoading ? + modelType={BillingInvoice} + id="invoices-table" + isDeleteable={false} + isEditable={false} + isCreateable={false} + isViewable={false} + cardProps={{ + icon: IconProp.File, + title: 'Invoices', + description: + 'Here is a list of invoices for this project.', + }} + noItemsMessage={'No invoices so far.'} + query={{ + projectId: props.currentProject?._id, + }} + showRefreshButton={true} + showFilterButton={false} + selectMoreFields={{ + currencyCode: true, + paymentProviderCustomerId: true + }} + columns={[ + { + field: { + paymentProviderInvoiceId: true, + }, + title: 'Invoice ID', + type: FieldType.Text, + }, + { + field: { + amount: true, + }, + title: 'Amount', + type: FieldType.Text, + isFilterable: true, + getElement: (item: JSONObject) => { + return {`${(item['amount'] as number) / 100} ${item['currencyCode']?.toString().toUpperCase()}`} + } + }, + { + field: { + status: true, + }, + title: 'Invoice Status', + type: FieldType.Text, + isFilterable: true, + getElement: (item: JSONObject) => { + if (item['status'] === "paid") { + return + } else { + return + } + } + }, + { + field: { + downloadableLink: true, + }, + title: 'Actions', + type: FieldType.Text, + isFilterable: true, + getElement: (item: JSONObject) => { + + return ( +
+ {item['downloadableLink'] ?
+ ) + } + }, + ]} + /> : <>} + + + {error ? ( + { + setError(''); + }} + submitButtonType={ButtonStyleType.NORMAL} + /> + ): <>} +
); }; diff --git a/Dashboard/src/Pages/Settings/SideMenu.tsx b/Dashboard/src/Pages/Settings/SideMenu.tsx index e8caa6b65d..8aaefad3e3 100644 --- a/Dashboard/src/Pages/Settings/SideMenu.tsx +++ b/Dashboard/src/Pages/Settings/SideMenu.tsx @@ -172,15 +172,15 @@ const DashboardSideMenu: FunctionComponent = (): ReactElement => { }} icon={IconProp.Billing} /> - {/* */} + icon={IconProp.TextFile} + /> ) : ( <> diff --git a/DashboardAPI/Index.ts b/DashboardAPI/Index.ts index 9b6ce7cc09..073172924e 100755 --- a/DashboardAPI/Index.ts +++ b/DashboardAPI/Index.ts @@ -13,6 +13,8 @@ import UserService, { import BillingPaymentMethodAPI from 'CommonServer/API/BillingPaymentMethodAPI'; +import BillingInvoiceAPI from 'CommonServer/API/BillingInvoiceAPI'; + import Project from 'Model/Models/Project'; import ProjectService, { Service as ProjectServiceType, @@ -390,6 +392,7 @@ app.use( app.use(new StatusPageAPI().getRouter()); app.use(new BillingPaymentMethodAPI().getRouter()); +app.use(new BillingInvoiceAPI().getRouter()); app.use( new BaseAPI< diff --git a/Model/Models/BillingInvoice.ts b/Model/Models/BillingInvoice.ts new file mode 100644 index 0000000000..7982a93c18 --- /dev/null +++ b/Model/Models/BillingInvoice.ts @@ -0,0 +1,246 @@ +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import BaseModel from 'Common/Models/BaseModel'; +import User from './User'; +import Project from './Project'; +import CrudApiEndpoint from 'Common/Types/Database/CrudApiEndpoint'; +import Route from 'Common/Types/API/Route'; +import TableColumnType from 'Common/Types/Database/TableColumnType'; +import TableColumn from 'Common/Types/Database/TableColumn'; +import ColumnType from 'Common/Types/Database/ColumnType'; +import ObjectID from 'Common/Types/ObjectID'; +import ColumnLength from 'Common/Types/Database/ColumnLength'; +import TableAccessControl from 'Common/Types/Database/AccessControl/TableAccessControl'; +import Permission from 'Common/Types/Permission'; +import ColumnAccessControl from 'Common/Types/Database/AccessControl/ColumnAccessControl'; +import TenantColumn from 'Common/Types/Database/TenantColumn'; +import SingularPluralName from 'Common/Types/Database/SingularPluralName'; +import AllowAccessIfSubscriptionIsUnpaid from 'Common/Types/Database/AccessControl/AllowAccessIfSubscriptionIsUnpaid'; +import URL from 'Common/Types/API/URL'; + +@AllowAccessIfSubscriptionIsUnpaid() +@TenantColumn('projectId') +@TableAccessControl({ + create: [], + read: [Permission.ProjectOwner, Permission.CanReadInvoices], + delete: [], + update: [], +}) +@CrudApiEndpoint(new Route('/billing-invoices')) +@SingularPluralName('Invoice', 'Invoices') +@Entity({ + name: 'BillingInvoice', +}) +export default class BillingInvoice extends BaseModel { + @ColumnAccessControl({ + create: [], + read: [Permission.ProjectOwner, Permission.CanReadInvoices], + update: [], + }) + @TableColumn({ + manyToOneRelationColumn: 'projectId', + type: TableColumnType.Entity, + modelType: Project, + }) + @ManyToOne( + (_type: string) => { + return Project; + }, + { + eager: false, + nullable: true, + onDelete: 'CASCADE', + orphanedRowAction: 'nullify', + } + ) + @JoinColumn({ name: 'projectId' }) + public project?: Project = undefined; + + @ColumnAccessControl({ + create: [], + read: [Permission.ProjectOwner, Permission.CanReadInvoices], + update: [], + }) + @Index() + @TableColumn({ + type: TableColumnType.ObjectID, + required: true, + canReadOnPopulate: true, + }) + @Column({ + type: ColumnType.ObjectID, + nullable: false, + transformer: ObjectID.getDatabaseTransformer(), + }) + public projectId?: ObjectID = undefined; + + @ColumnAccessControl({ + create: [], + read: [Permission.ProjectOwner, Permission.CanReadInvoices], + update: [], + }) + @TableColumn({ + manyToOneRelationColumn: 'createdByUserId', + type: TableColumnType.Entity, + modelType: User, + }) + @ManyToOne( + (_type: string) => { + return User; + }, + { + eager: false, + nullable: true, + onDelete: 'CASCADE', + orphanedRowAction: 'nullify', + } + ) + @JoinColumn({ name: 'createdByUserId' }) + public createdByUser?: User = undefined; + + @ColumnAccessControl({ + create: [], + read: [Permission.ProjectOwner, Permission.CanReadInvoices], + update: [], + }) + @TableColumn({ type: TableColumnType.ObjectID }) + @Column({ + type: ColumnType.ObjectID, + nullable: true, + transformer: ObjectID.getDatabaseTransformer(), + }) + public createdByUserId?: ObjectID = undefined; + + @ColumnAccessControl({ + create: [], + read: [Permission.ProjectOwner, Permission.CanReadInvoices], + update: [], + }) + @TableColumn({ + manyToOneRelationColumn: 'deletedByUserId', + type: TableColumnType.ObjectID, + }) + @ManyToOne( + (_type: string) => { + return User; + }, + { + cascade: false, + eager: false, + nullable: true, + onDelete: 'CASCADE', + orphanedRowAction: 'nullify', + } + ) + @JoinColumn({ name: 'deletedByUserId' }) + public deletedByUser?: User = undefined; + + @ColumnAccessControl({ + create: [], + read: [Permission.ProjectOwner, Permission.CanReadInvoices], + update: [], + }) + @TableColumn({ type: TableColumnType.ObjectID }) + @Column({ + type: ColumnType.ObjectID, + nullable: true, + transformer: ObjectID.getDatabaseTransformer(), + }) + public deletedByUserId?: ObjectID = undefined; + + @ColumnAccessControl({ + create: [], + read: [Permission.ProjectOwner, Permission.CanReadInvoices], + update: [], + }) + @TableColumn({ type: TableColumnType.Number }) + @Column({ + type: ColumnType.Decimal, + nullable: false, + unique: false, + }) + public amount?: number = undefined; + + @ColumnAccessControl({ + create: [], + read: [Permission.ProjectOwner, Permission.CanReadInvoices], + update: [], + }) + @TableColumn({ type: TableColumnType.ShortText }) + @Column({ + type: ColumnType.ShortText, + length: ColumnLength.ShortText, + nullable: false, + unique: false, + }) + public currencyCode?: string = undefined; + + @ColumnAccessControl({ + create: [], + read: [Permission.ProjectOwner, Permission.CanReadInvoices], + update: [], + }) + @TableColumn({ type: TableColumnType.LongURL }) + @Column({ + type: ColumnType.LongURL, + nullable: false, + unique: false, + transformer: URL.getDatabaseTransformer(), + }) + public downloadableLink?: URL = undefined; + + @ColumnAccessControl({ + create: [], + read: [Permission.ProjectOwner, Permission.CanReadInvoices], + update: [], + }) + @TableColumn({ type: TableColumnType.ShortText }) + @Column({ + type: ColumnType.ShortText, + length: ColumnLength.ShortText, + nullable: false, + unique: false, + }) + public status?: string = undefined; + + @ColumnAccessControl({ + create: [], + read: [Permission.ProjectOwner, Permission.CanReadInvoices], + update: [], + }) + @TableColumn({ type: TableColumnType.ShortText }) + @Column({ + type: ColumnType.ShortText, + length: ColumnLength.ShortText, + nullable: false, + unique: false, + }) + public paymentProviderCustomerId?: string = undefined; + + @ColumnAccessControl({ + create: [], + read: [Permission.ProjectOwner, Permission.CanReadInvoices], + update: [], + }) + @TableColumn({ type: TableColumnType.ShortText }) + @Column({ + type: ColumnType.ShortText, + length: ColumnLength.ShortText, + nullable: true, + unique: false, + }) + public paymentProviderSubscriptionId?: string = undefined; + + @ColumnAccessControl({ + create: [], + read: [Permission.ProjectOwner, Permission.CanReadInvoices], + update: [], + }) + @TableColumn({ type: TableColumnType.ShortText }) + @Column({ + type: ColumnType.ShortText, + length: ColumnLength.ShortText, + nullable: false, + unique: false, + }) + public paymentProviderInvoiceId?: string = undefined; +} diff --git a/Model/Models/BillingPaymentMethod.ts b/Model/Models/BillingPaymentMethod.ts index b1862a0486..18742d4c2c 100644 --- a/Model/Models/BillingPaymentMethod.ts +++ b/Model/Models/BillingPaymentMethod.ts @@ -14,13 +14,15 @@ import Permission from 'Common/Types/Permission'; import ColumnAccessControl from 'Common/Types/Database/AccessControl/ColumnAccessControl'; import TenantColumn from 'Common/Types/Database/TenantColumn'; import SingularPluralName from 'Common/Types/Database/SingularPluralName'; +import AllowAccessIfSubscriptionIsUnpaid from 'Common/Types/Database/AccessControl/AllowAccessIfSubscriptionIsUnpaid'; +@AllowAccessIfSubscriptionIsUnpaid() @TenantColumn('projectId') @TableAccessControl({ - create: [Permission.ProjectOwner], - read: [Permission.ProjectOwner], - delete: [Permission.ProjectOwner], - update: [Permission.ProjectOwner], + create: [Permission.ProjectOwner, Permission.CanCreateBillingPaymentMethod], + read: [Permission.ProjectOwner, Permission.CanReadBillingPaymentMethod], + delete: [Permission.ProjectOwner, Permission.CanDeleteBillingPaymentMethod], + update: [], }) @CrudApiEndpoint(new Route('/billing-payment-methods')) @SingularPluralName('Payment Method', 'Payment Methods') @@ -29,8 +31,8 @@ import SingularPluralName from 'Common/Types/Database/SingularPluralName'; }) export default class BillingPaymentMethod extends BaseModel { @ColumnAccessControl({ - create: [Permission.ProjectOwner], - read: [Permission.ProjectOwner], + create: [Permission.ProjectOwner, Permission.CanCreateBillingPaymentMethod], + read: [Permission.ProjectOwner, Permission.CanReadBillingPaymentMethod], update: [], }) @TableColumn({ @@ -53,8 +55,8 @@ export default class BillingPaymentMethod extends BaseModel { public project?: Project = undefined; @ColumnAccessControl({ - create: [Permission.ProjectOwner], - read: [Permission.ProjectOwner], + create: [Permission.ProjectOwner, Permission.CanCreateBillingPaymentMethod], + read: [Permission.ProjectOwner, Permission.CanReadBillingPaymentMethod], update: [], }) @Index() @@ -71,8 +73,8 @@ export default class BillingPaymentMethod extends BaseModel { public projectId?: ObjectID = undefined; @ColumnAccessControl({ - create: [Permission.ProjectOwner], - read: [Permission.ProjectOwner], + create: [Permission.ProjectOwner, Permission.CanCreateBillingPaymentMethod], + read: [Permission.ProjectOwner, Permission.CanReadBillingPaymentMethod], update: [], }) @TableColumn({ @@ -95,8 +97,8 @@ export default class BillingPaymentMethod extends BaseModel { public createdByUser?: User = undefined; @ColumnAccessControl({ - create: [Permission.ProjectOwner], - read: [Permission.ProjectOwner], + create: [Permission.ProjectOwner, Permission.CanCreateBillingPaymentMethod], + read: [Permission.ProjectOwner, Permission.CanReadBillingPaymentMethod], update: [], }) @TableColumn({ type: TableColumnType.ObjectID }) @@ -109,7 +111,7 @@ export default class BillingPaymentMethod extends BaseModel { @ColumnAccessControl({ create: [], - read: [Permission.ProjectOwner], + read: [Permission.ProjectOwner, Permission.CanReadBillingPaymentMethod], update: [], }) @TableColumn({ @@ -133,7 +135,7 @@ export default class BillingPaymentMethod extends BaseModel { @ColumnAccessControl({ create: [], - read: [Permission.ProjectOwner], + read: [Permission.ProjectOwner, Permission.CanReadBillingPaymentMethod], update: [], }) @TableColumn({ type: TableColumnType.ObjectID }) @@ -145,8 +147,8 @@ export default class BillingPaymentMethod extends BaseModel { public deletedByUserId?: ObjectID = undefined; @ColumnAccessControl({ - create: [Permission.ProjectOwner], - read: [Permission.ProjectOwner], + create: [Permission.ProjectOwner, Permission.CanCreateBillingPaymentMethod], + read: [Permission.ProjectOwner, Permission.CanReadBillingPaymentMethod], update: [], }) @TableColumn({ type: TableColumnType.ShortText }) @@ -160,7 +162,7 @@ export default class BillingPaymentMethod extends BaseModel { @ColumnAccessControl({ create: [], - read: [Permission.ProjectOwner], + read: [Permission.ProjectOwner, Permission.CanReadBillingPaymentMethod], update: [], }) @TableColumn({ type: TableColumnType.ShortText }) @@ -174,7 +176,7 @@ export default class BillingPaymentMethod extends BaseModel { @ColumnAccessControl({ create: [], - read: [Permission.ProjectOwner], + read: [Permission.ProjectOwner, Permission.CanReadBillingPaymentMethod], update: [], }) @TableColumn({ type: TableColumnType.ShortText }) @@ -187,8 +189,8 @@ export default class BillingPaymentMethod extends BaseModel { public paymentProviderCustomerId?: string = undefined; @ColumnAccessControl({ - create: [Permission.ProjectOwner], - read: [Permission.ProjectOwner], + create: [Permission.ProjectOwner, Permission.CanCreateBillingPaymentMethod], + read: [Permission.ProjectOwner, Permission.CanReadBillingPaymentMethod], update: [], }) @TableColumn({ type: TableColumnType.ShortText }) @@ -201,8 +203,8 @@ export default class BillingPaymentMethod extends BaseModel { public last4Digits?: string = undefined; @ColumnAccessControl({ - create: [Permission.ProjectOwner], - read: [Permission.ProjectOwner], + create: [Permission.ProjectOwner, Permission.CanCreateBillingPaymentMethod], + read: [Permission.ProjectOwner, Permission.CanReadBillingPaymentMethod], update: [], }) @TableColumn({ type: TableColumnType.Boolean }) diff --git a/Model/Models/Index.ts b/Model/Models/Index.ts index f49455f634..f093e512ec 100644 --- a/Model/Models/Index.ts +++ b/Model/Models/Index.ts @@ -57,6 +57,7 @@ import ProjectSmtpConfig from './ProjectSmtpConfig'; import Domain from './Domain'; import File from './File'; +import BillingInvoice from './BillingInvoice'; export default [ User, @@ -100,4 +101,5 @@ export default [ ScheduledMaintenanceInternalNote, BillingPaymentMethods, + BillingInvoice ]; diff --git a/Model/Models/Project.ts b/Model/Models/Project.ts index 72a5ac46a5..4bf3d21e8d 100644 --- a/Model/Models/Project.ts +++ b/Model/Models/Project.ts @@ -11,12 +11,14 @@ import Route from 'Common/Types/API/Route'; import TableColumnType from 'Common/Types/Database/TableColumnType'; import SlugifyColumn from 'Common/Types/Database/SlugifyColumn'; import TableAccessControl from 'Common/Types/Database/AccessControl/TableAccessControl'; +import AllowAccessIfSubscriptionIsUnpaid from 'Common/Types/Database/AccessControl/AllowAccessIfSubscriptionIsUnpaid'; import Permission from 'Common/Types/Permission'; import ColumnAccessControl from 'Common/Types/Database/AccessControl/ColumnAccessControl'; import TenantColumn from 'Common/Types/Database/TenantColumn'; import SingularPluralName from 'Common/Types/Database/SingularPluralName'; import MultiTenentQueryAllowed from 'Common/Types/Database/MultiTenentQueryAllowed'; +@AllowAccessIfSubscriptionIsUnpaid() @MultiTenentQueryAllowed(true) @TableAccessControl({ create: [Permission.User], diff --git a/Model/Models/User.ts b/Model/Models/User.ts index 740cd1d033..beb8ed8407 100644 --- a/Model/Models/User.ts +++ b/Model/Models/User.ts @@ -20,7 +20,10 @@ import Permission from 'Common/Types/Permission'; import ColumnAccessControl from 'Common/Types/Database/AccessControl/ColumnAccessControl'; import CurrentUserCanAccessRecordBy from 'Common/Types/Database/CurrentUserCanAccessRecordBy'; import SingularPluralName from 'Common/Types/Database/SingularPluralName'; +import AllowAccessIfSubscriptionIsUnpaid from 'Common/Types/Database/AccessControl/AllowAccessIfSubscriptionIsUnpaid'; + +@AllowAccessIfSubscriptionIsUnpaid() @TableAccessControl({ create: [Permission.Public], read: [