From 06232f3bf8bb8fa5d20deea0f972bcac1c1177c2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 12 Aug 2024 16:08:54 -0700 Subject: [PATCH] chore(release): Test v7.43.0 (#10101) Signed-off-by: Matt Krick Signed-off-by: dependabot[bot] Co-authored-by: Matt Krick Co-authored-by: Dale Bumblis <135627447+dbumblis-parabol@users.noreply.github.com> Co-authored-by: Georg Bremer Co-authored-by: Rafa <101704572+rafaelromcar-parabol@users.noreply.github.com> Co-authored-by: parabol-release-bot[bot] <150284312+parabol-release-bot[bot]@users.noreply.github.com> Co-authored-by: Nick O'Ferrall Co-authored-by: Bruce Tian Co-authored-by: Bartosz Jarocki Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: snyk-bot Co-authored-by: GitHub Action Co-authored-by: Jordan Husney Co-authored-by: Terry Acker Co-authored-by: Rafael Romero Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: github-actions --- .release-please-manifest.json | 2 +- CHANGELOG.md | 13 + LICENSE | 5 +- .../us-department-of-defense/LICENSE | 5 +- package.json | 2 +- packages/chronos/package.json | 4 +- packages/client/components/PrivateRoutes.tsx | 4 - .../components/EmailFooter/EmailFooter.tsx | 6 +- .../WholeMeetingSummaryResult.tsx | 37 +- .../invoice/components/Invoice/Invoice.tsx | 301 ------------- .../components/Invoice/InvoiceFailedStamp.tsx | 37 -- .../invoice/components/Invoice/InvoiceTag.tsx | 46 -- .../InvoiceFooter/InvoiceFooter.tsx | 70 --- .../InvoiceHeader/InvoiceHeader.tsx | 96 ----- .../InvoiceLineItem/InvoiceLineItem.tsx | 50 --- .../InvoiceLineItemContent.tsx | 53 --- .../InvoiceLineItemDetails.tsx | 112 ----- .../NextPeriodChargesLineItem.tsx | 43 -- .../invoice/containers/InvoiceRoot.tsx | 18 - .../components/InvoiceRow/InvoiceRow.tsx | 55 +-- .../components/OrgBilling/BillingForm.tsx | 9 +- .../OrgBilling/OrgBillingInvoices.tsx | 38 +- .../OrgBilling/OrgPlansAndBilling.tsx | 14 +- .../upgradeToTeamTierSuccessUpdater.ts | 12 - packages/client/package.json | 3 +- packages/client/styles/theme/global.css | 7 +- .../subscriptions/OrganizationSubscription.ts | 5 +- packages/client/types/constEnums.ts | 1 - packages/client/ui/Button/Button.tsx | 4 +- .../utils/features/isTeamHealthAvailable.ts | 2 +- packages/client/utils/sortByTier.ts | 2 +- packages/embedder/package.json | 2 +- packages/gql-executor/package.json | 6 +- packages/integration-tests/package.json | 2 +- .../server/billing/helpers/fetchAllLines.ts | 22 - .../server/billing/helpers/generateInvoice.ts | 405 ------------------ .../helpers/generateUpcomingInvoice.ts | 19 - .../server/billing/stripeWebhookHandler.ts | 17 - packages/server/database/rethinkDriver.ts | 33 -- packages/server/database/types/Coupon.ts | 21 - packages/server/database/types/CreditCard.ts | 17 - packages/server/database/types/Invoice.ts | 99 ----- .../server/database/types/InvoiceItemHook.ts | 68 --- .../server/database/types/InvoiceLineItem.ts | 36 -- .../database/types/InvoiceLineItemDetail.ts | 27 -- .../types/InvoiceLineItemOtherAdjustments.ts | 15 - .../database/types/NextPeriodCharges.ts | 24 -- .../server/database/types/Organization.ts | 4 +- .../database/types/QuantityChangeLineItem.ts | 5 - packages/server/database/types/Team.ts | 2 +- packages/server/database/types/User.ts | 2 +- .../server/dataloader/customLoaderMakers.ts | 35 +- .../dataloader/primaryKeyLoaderMakers.ts | 5 + .../rethinkPrimaryKeyLoaderMakers.ts | 1 - .../mutations/helpers/createTeamAndLeader.ts | 6 + .../mutations/helpers/getCCFromCustomer.ts | 2 +- .../mutations/helpers/stripeCardToDBCard.ts | 2 +- .../graphql/mutations/removePokerTemplate.ts | 15 +- .../mutations/removeReflectTemplate.ts | 16 +- .../graphql/mutations/selectTemplate.ts | 9 +- .../graphql/mutations/updateRetroMaxVotes.ts | 10 + .../private/mutations/changeEmailDomain.ts | 21 +- .../private/mutations/stripeCreateInvoice.ts | 15 +- .../private/mutations/stripeFailPayment.ts | 1 - .../mutations/stripeInvoiceFinalized.ts | 56 --- .../private/mutations/stripeInvoicePaid.ts | 22 +- .../private/mutations/stripeSucceedPayment.ts | 22 +- .../mutations/stripeUpdateInvoiceItem.ts | 101 ----- .../graphql/private/typeDefs/Mutation.graphql | 20 - .../server/graphql/public/fields/invoices.ts | 75 ++++ .../public/mutations/setMeetingSettings.ts | 49 ++- .../public/mutations/startRetrospective.ts | 10 +- .../graphql/public/typeDefs/Coupon.graphql | 24 -- .../graphql/public/typeDefs/Invoice.graphql | 81 +--- .../public/typeDefs/InvoiceLineItem.graphql | 34 -- .../typeDefs/InvoiceLineItemDetails.graphql | 34 -- .../typeDefs/InvoiceLineItemEnum.graphql | 9 - .../public/typeDefs/NextPeriodCharges.graphql | 29 -- .../graphql/public/typeDefs/User.graphql | 8 +- packages/server/graphql/public/types/User.ts | 104 +---- .../queries/helpers/makeUpcomingInvoice.ts | 78 ---- .../helpers/resolveSelectedTemplate.ts | 7 + packages/server/graphql/types/Coupon.ts | 27 -- packages/server/graphql/types/CreditCard.ts | 23 - packages/server/graphql/types/Invoice.ts | 137 ------ .../server/graphql/types/InvoiceLineItem.ts | 48 --- .../graphql/types/InvoiceLineItemDetails.ts | 40 -- .../graphql/types/InvoiceLineItemEnum.ts | 14 - .../server/graphql/types/NextPeriodCharges.ts | 34 -- .../graphql/types/helpers/getFeatureTier.ts | 2 +- packages/server/package.json | 4 +- .../server/postgres/helpers/toCreditCard.ts | 2 +- .../1723061869934_MeetingSettings-phase1.ts | 73 ++++ packages/server/postgres/select.ts | 46 +- packages/server/postgres/types/index.d.ts | 3 + .../isRequestToJoinDomainAllowed.test.ts | 2 +- packages/server/utils/analytics/analytics.ts | 2 +- packages/server/utils/getUpcomingInvoiceId.ts | 3 - packages/server/utils/isPhaseAvailable.ts | 2 +- packages/server/utils/stripe/StripeManager.ts | 3 + yarn.lock | 5 + 101 files changed, 474 insertions(+), 2779 deletions(-) delete mode 100644 packages/client/modules/invoice/components/Invoice/Invoice.tsx delete mode 100644 packages/client/modules/invoice/components/Invoice/InvoiceFailedStamp.tsx delete mode 100644 packages/client/modules/invoice/components/Invoice/InvoiceTag.tsx delete mode 100644 packages/client/modules/invoice/components/InvoiceFooter/InvoiceFooter.tsx delete mode 100644 packages/client/modules/invoice/components/InvoiceHeader/InvoiceHeader.tsx delete mode 100644 packages/client/modules/invoice/components/InvoiceLineItem/InvoiceLineItem.tsx delete mode 100644 packages/client/modules/invoice/components/InvoiceLineItem/InvoiceLineItemContent.tsx delete mode 100644 packages/client/modules/invoice/components/InvoiceLineItem/InvoiceLineItemDetails.tsx delete mode 100644 packages/client/modules/invoice/components/InvoiceLineItem/NextPeriodChargesLineItem.tsx delete mode 100644 packages/client/modules/invoice/containers/InvoiceRoot.tsx delete mode 100644 packages/client/mutations/handlers/upgradeToTeamTierSuccessUpdater.ts delete mode 100644 packages/server/billing/helpers/fetchAllLines.ts delete mode 100644 packages/server/billing/helpers/generateInvoice.ts delete mode 100644 packages/server/billing/helpers/generateUpcomingInvoice.ts delete mode 100644 packages/server/database/types/Coupon.ts delete mode 100644 packages/server/database/types/CreditCard.ts delete mode 100644 packages/server/database/types/Invoice.ts delete mode 100644 packages/server/database/types/InvoiceItemHook.ts delete mode 100644 packages/server/database/types/InvoiceLineItem.ts delete mode 100644 packages/server/database/types/InvoiceLineItemDetail.ts delete mode 100644 packages/server/database/types/InvoiceLineItemOtherAdjustments.ts delete mode 100644 packages/server/database/types/NextPeriodCharges.ts delete mode 100644 packages/server/database/types/QuantityChangeLineItem.ts delete mode 100644 packages/server/graphql/private/mutations/stripeInvoiceFinalized.ts delete mode 100644 packages/server/graphql/private/mutations/stripeUpdateInvoiceItem.ts create mode 100644 packages/server/graphql/public/fields/invoices.ts delete mode 100644 packages/server/graphql/public/typeDefs/Coupon.graphql delete mode 100644 packages/server/graphql/public/typeDefs/InvoiceLineItem.graphql delete mode 100644 packages/server/graphql/public/typeDefs/InvoiceLineItemDetails.graphql delete mode 100644 packages/server/graphql/public/typeDefs/InvoiceLineItemEnum.graphql delete mode 100644 packages/server/graphql/public/typeDefs/NextPeriodCharges.graphql delete mode 100644 packages/server/graphql/queries/helpers/makeUpcomingInvoice.ts delete mode 100644 packages/server/graphql/types/Coupon.ts delete mode 100644 packages/server/graphql/types/CreditCard.ts delete mode 100644 packages/server/graphql/types/Invoice.ts delete mode 100644 packages/server/graphql/types/InvoiceLineItem.ts delete mode 100644 packages/server/graphql/types/InvoiceLineItemDetails.ts delete mode 100644 packages/server/graphql/types/InvoiceLineItemEnum.ts delete mode 100644 packages/server/graphql/types/NextPeriodCharges.ts create mode 100644 packages/server/postgres/migrations/1723061869934_MeetingSettings-phase1.ts delete mode 100644 packages/server/utils/getUpcomingInvoiceId.ts diff --git a/.release-please-manifest.json b/.release-please-manifest.json index f005d1c8ce6..aef4662aaf9 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "7.42.2" + ".": "7.43.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index c97007c47bc..12ec5079e21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ This project adheres to [Semantic Versioning](http://semver.org/). This CHANGELOG follows conventions [outlined here](http://keepachangelog.com/). +## [7.43.0](https://github.com/ParabolInc/parabol/compare/v7.42.2...v7.43.0) (2024-08-12) + + +### Added + +* update meeting summary UI ([#10081](https://github.com/ParabolInc/parabol/issues/10081)) ([bf1851e](https://github.com/ParabolInc/parabol/commit/bf1851e9ebfe88aa470952b1daf053a83c04c865)) + + +### Changed + +* **rethinkdb:** Invoice: Remove ([#10086](https://github.com/ParabolInc/parabol/issues/10086)) ([10164a8](https://github.com/ParabolInc/parabol/commit/10164a8a43850c9ca80042a151b4da6020ad37d6)) +* **rethinkdb:** MeetingSettings: Phase 1 ([#10088](https://github.com/ParabolInc/parabol/issues/10088)) ([40d8c8c](https://github.com/ParabolInc/parabol/commit/40d8c8c15b9804a5ee2ef49649893cdc5dfdd665)) + ## [7.42.2](https://github.com/ParabolInc/parabol/compare/v7.42.1...v7.42.2) (2024-08-08) diff --git a/LICENSE b/LICENSE index 685e387e99c..fc22ab5e610 100644 --- a/LICENSE +++ b/LICENSE @@ -24,6 +24,7 @@ If you have any questions regarding our licensing policy, please contact us: love@parabol.co (via email) Parabol - 8605 Santa Monica Blvd - West Hollywood, CA 90069-4109 + 1111 6th Ave., Ste 550 + PMB 73201 + San Diego, CA 92101 USA diff --git a/docs/alternative-licenses/us-department-of-defense/LICENSE b/docs/alternative-licenses/us-department-of-defense/LICENSE index 711afbcddab..9907b77fa21 100644 --- a/docs/alternative-licenses/us-department-of-defense/LICENSE +++ b/docs/alternative-licenses/us-department-of-defense/LICENSE @@ -18,6 +18,7 @@ If you have any questions regarding our licensing policy, please contact us: support@parabol.com (via email) Parabol - 8605 Santa Monica Blvd - West Hollywood, CA 90069-4109 + 1111 6th Ave., Ste 550 + PMB 73201 + San Diego, CA 92101 USA diff --git a/package.json b/package.json index af680d38988..af77b461503 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "description": "An open-source app for building smarter, more agile teams.", "author": "Parabol Inc. (http://github.com/ParabolInc)", "license": "AGPL-3.0", - "version": "7.42.2", + "version": "7.43.0", "repository": { "type": "git", "url": "https://github.com/ParabolInc/parabol" diff --git a/packages/chronos/package.json b/packages/chronos/package.json index 59e597f5a46..a7849afd4ad 100644 --- a/packages/chronos/package.json +++ b/packages/chronos/package.json @@ -1,6 +1,6 @@ { "name": "chronos", - "version": "7.42.2", + "version": "7.43.0", "description": "A cron job scheduler", "author": "Matt Krick ", "homepage": "https://github.com/ParabolInc/parabol/tree/master/packages/chronos#readme", @@ -25,6 +25,6 @@ }, "dependencies": { "cron": "^2.3.1", - "parabol-server": "7.42.2" + "parabol-server": "7.43.0" } } diff --git a/packages/client/components/PrivateRoutes.tsx b/packages/client/components/PrivateRoutes.tsx index 178742810e2..0d5f18fb0ab 100644 --- a/packages/client/components/PrivateRoutes.tsx +++ b/packages/client/components/PrivateRoutes.tsx @@ -4,9 +4,6 @@ import {Redirect, Route, Switch, useLocation} from 'react-router' import useAuthRoute from '../hooks/useAuthRoute' import useNoIndex from '../hooks/useNoIndex' -const Invoice = lazy( - () => import(/* webpackChunkName: 'InvoiceRoot' */ '../modules/invoice/containers/InvoiceRoot') -) const NewMeetingSummary = lazy( () => import( @@ -71,7 +68,6 @@ const PrivateRoutes = () => { - diff --git a/packages/client/modules/email/components/EmailFooter/EmailFooter.tsx b/packages/client/modules/email/components/EmailFooter/EmailFooter.tsx index 26c563a6b5f..2d8b0263590 100644 --- a/packages/client/modules/email/components/EmailFooter/EmailFooter.tsx +++ b/packages/client/modules/email/components/EmailFooter/EmailFooter.tsx @@ -35,9 +35,11 @@ const EmailFooter = () => {
{'Parabol, Inc.'}
- {'8605 Santa Monica Blvd'} + {'1111 6th Ave., Ste 550'}
- {'West Hollywood, CA 90069-4109'} + {'PMB 73201'} +
+ {'San Diego, CA 92101'}
{'United States'}
diff --git a/packages/client/modules/email/components/SummaryEmail/MeetingSummaryEmail/WholeMeetingSummaryResult.tsx b/packages/client/modules/email/components/SummaryEmail/MeetingSummaryEmail/WholeMeetingSummaryResult.tsx index 56e30fea03b..c9e45cad738 100644 --- a/packages/client/modules/email/components/SummaryEmail/MeetingSummaryEmail/WholeMeetingSummaryResult.tsx +++ b/packages/client/modules/email/components/SummaryEmail/MeetingSummaryEmail/WholeMeetingSummaryResult.tsx @@ -1,10 +1,12 @@ import graphql from 'babel-plugin-relay/macro' -import {WholeMeetingSummaryResult_meeting$key} from 'parabol-client/__generated__/WholeMeetingSummaryResult_meeting.graphql' -import {PALETTE} from 'parabol-client/styles/paletteV3' -import {FONT_FAMILY} from 'parabol-client/styles/typographyV2' +import DOMPurify from 'dompurify' +import {marked} from 'marked' import React, {useEffect} from 'react' import {useFragment} from 'react-relay' +import {WholeMeetingSummaryResult_meeting$key} from '../../../../../__generated__/WholeMeetingSummaryResult_meeting.graphql' import useAtmosphere from '../../../../../hooks/useAtmosphere' +import {PALETTE} from '../../../../../styles/paletteV3' +import {FONT_FAMILY} from '../../../../../styles/typographyV2' import {AIExplainer} from '../../../../../types/constEnums' import SendClientSideEvent from '../../../../../utils/SendClientSideEvent' import EmailBorderBottom from './EmailBorderBottom' @@ -39,8 +41,9 @@ interface Props { meetingRef: WholeMeetingSummaryResult_meeting$key } -const WholeMeetingSummaryResult = (props: Props) => { - const {meetingRef} = props +const WholeMeetingSummaryResult = ({meetingRef}: Props) => { + const atmosphere = useAtmosphere() + const meeting = useFragment( graphql` fragment WholeMeetingSummaryResult_meeting on NewMeeting { @@ -55,16 +58,25 @@ const WholeMeetingSummaryResult = (props: Props) => { `, meetingRef ) - const atmosphere = useAtmosphere() - const {summary: wholeMeetingSummary, team} = meeting - const explainerText = team?.tier === 'starter' ? AIExplainer.STARTER : AIExplainer.PREMIUM_MEETING useEffect(() => { SendClientSideEvent(atmosphere, 'AI Summary Viewed', { source: 'Meeting Summary', tier: meeting.team.billingTier, meetingId: meeting.id }) - }, []) + }, [atmosphere, meeting.id, meeting.team.billingTier]) + + const {summary: wholeMeetingSummary, team} = meeting + + if (!wholeMeetingSummary) return null + const renderedSummary = marked(wholeMeetingSummary, { + gfm: true, + breaks: true + }) as string + const sanitizedSummary = DOMPurify.sanitize(renderedSummary) + + const explainerText = team?.tier === 'starter' ? AIExplainer.STARTER : AIExplainer.PREMIUM_MEETING + return ( <> @@ -78,7 +90,12 @@ const WholeMeetingSummaryResult = (props: Props) => { - {wholeMeetingSummary} + diff --git a/packages/client/modules/invoice/components/Invoice/Invoice.tsx b/packages/client/modules/invoice/components/Invoice/Invoice.tsx deleted file mode 100644 index a50d9543b35..00000000000 --- a/packages/client/modules/invoice/components/Invoice/Invoice.tsx +++ /dev/null @@ -1,301 +0,0 @@ -import styled from '@emotion/styled' -import graphql from 'babel-plugin-relay/macro' -import React from 'react' -import {PreloadedQuery, usePreloadedQuery} from 'react-relay' -import {InvoiceQuery, InvoiceStatusEnum} from '../../../../__generated__/InvoiceQuery.graphql' -import EmphasisTag from '../../../../components/Tag/EmphasisTag' -import useDocumentTitle from '../../../../hooks/useDocumentTitle' -import {Elevation} from '../../../../styles/elevation' -import {PALETTE} from '../../../../styles/paletteV3' -import {Breakpoint} from '../../../../types/constEnums' -import makeDateString from '../../../../utils/makeDateString' -import makeMonthString from '../../../../utils/makeMonthString' -import invoiceLineFormat from '../../helpers/invoiceLineFormat' -import InvoiceFooter from '../InvoiceFooter/InvoiceFooter' -import InvoiceHeader from '../InvoiceHeader/InvoiceHeader' -import InvoiceLineItem from '../InvoiceLineItem/InvoiceLineItem' -import InvoiceLineItemContent from '../InvoiceLineItem/InvoiceLineItemContent' -import NextPeriodChargesLineItem from '../InvoiceLineItem/NextPeriodChargesLineItem' -import InvoiceFailedStamp from './InvoiceFailedStamp' -import InvoiceTag from './InvoiceTag' - -const chargeStatus = { - PAID: 'Charged', - FAILED: 'Failed charge', - PENDING: 'Pending charge', - UPCOMING: 'Will be charged' -} as Record - -const AmountSection = styled('div')({ - borderTop: `1px solid ${PALETTE.SLATE_300}`, - marginTop: 1, - paddingTop: 16, - paddingRight: 12, - - [`@media (min-width: ${Breakpoint.INVOICE}px)`]: { - paddingTop: 32, - paddingRight: 20 - } -}) - -const AmountLine = styled('div')({ - display: 'flex', - justifyContent: 'space-between', - width: '100%', - fontSize: 24, - fontWeight: 600, - lineHeight: '34px' -}) - -const AmountLineSub = styled('div')({ - display: 'flex', - justifyContent: 'space-between', - width: '100%', - fontSize: 18, - lineHeight: '28px' -}) - -const Wrap = styled('div')({ - backgroundColor: PALETTE.SLATE_200, - overflow: 'hidden' -}) - -const InvoiceStyles = styled('div')({ - backgroundColor: 'white', - boxShadow: Elevation.SHEET, - color: PALETTE.SLATE_700, - margin: '0 auto', - maxWidth: 600, - padding: 16, - - [`@media (min-width: ${Breakpoint.INVOICE}px)`]: { - margin: '32px auto 48px', - padding: 32 - } -}) - -const Panel = styled('div')({ - backgroundColor: '#FFFFFF', - border: `1px solid ${PALETTE.SLATE_500}`, - borderRadius: 8, - margin: `16px 0`, - padding: `12px 0 12px 12px`, - position: 'relative', - - [`@media (min-width: ${Breakpoint.INVOICE}px)`]: { - margin: `32px 0`, - padding: `32px 0 32px 32px` - } -}) - -const Label = styled('div')({ - color: PALETTE.SLATE_500, - fontSize: 14, - fontWeight: 600, - textTransform: 'uppercase' -}) - -const Subject = styled('div')({ - fontSize: 32, - [`@media (min-width: ${Breakpoint.INVOICE}px)`]: { - fontSize: 34 - } -}) - -const SectionHeader = styled('div')({ - borderBottom: `1px solid ${PALETTE.SLATE_300}`, - marginTop: 12, - paddingBottom: '.75rem', - - [`@media (min-width: ${Breakpoint.INVOICE}px)`]: { - marginTop: 20 - } -}) - -const Heading = styled('div')({ - alignItems: 'center', - display: 'flex', - fontSize: 18, - fontWeight: 600, - justifyContent: 'space-between', - lineHeight: '24px', - paddingBottom: 8, - paddingRight: 12, - [`@media (min-width: ${Breakpoint.INVOICE}px)`]: { - fontSize: 24, - justifyContent: 'flex-start', - paddingRight: 20 - } -}) - -const Meta = styled('div')<{isError?: boolean}>(({isError}) => ({ - color: isError ? PALETTE.TOMATO_500 : undefined, - fontSize: 14 -})) - -const PayURLText = styled('a')({ - display: 'flex', - fontSize: 12, - justifyContent: 'space-between', - paddingTop: 8, - width: '100%' -}) - -const CouponEmphasis = styled('span')({ - color: PALETTE.ROSE_500, - fontWeight: 600 -}) - -interface Props { - queryRef: PreloadedQuery -} - -const query = graphql` - query InvoiceQuery($invoiceId: ID!) { - viewer { - invoiceDetails(invoiceId: $invoiceId) { - ...InvoiceHeader_invoice - id - amountDue - creditCard { - brand - last4 - } - coupon { - amountOff - name - percentOff - } - endAt - lines { - ...InvoiceLineItem_item - id - } - nextPeriodCharges { - ...NextPeriodChargesLineItem_item - nextPeriodEnd - interval - amount - } - payUrl - startingBalance - startAt - status - tier - total - } - } - } -` - -const Invoice = (props: Props) => { - const {queryRef} = props - const data = usePreloadedQuery(query, queryRef) - const {viewer} = data - const {invoiceDetails} = viewer - const endAt = invoiceDetails && invoiceDetails.endAt - const subject = makeMonthString(endAt) - useDocumentTitle(`Invoice | ${subject}`, 'Invoices') - if (!invoiceDetails) return null - - const { - amountDue, - total, - creditCard, - coupon, - lines, - nextPeriodCharges, - payUrl, - startAt, - startingBalance, - tier - } = invoiceDetails - const status = invoiceDetails.status as InvoiceStatusEnum - const {amount, interval, nextPeriodEnd} = nextPeriodCharges! - const chargeDates = `${makeDateString(startAt)} to ${makeDateString(endAt)}` - const nextChargesDates = `${makeDateString(endAt)} to ${makeDateString(nextPeriodEnd)}` - const amountOff = coupon && (coupon.amountOff || (coupon.percentOff! / 100) * amount) - const discountedAmount = amountOff && invoiceLineFormat(-amountOff) - return ( - - - - - - - - {subject} - - - {`Next ${interval}’s usage`} - {nextChargesDates} - - - - {/* - Re: coupon - Percent-off amounts are based on the nextPeriodCharges, - so the line item makes more sense close to the starting amount. - Also, coupons are not part of “Last month’s adjustments” - */} - {coupon && ( - {`Coupon: “${coupon.name}”`}} - amount={{discountedAmount}} - /> - )} - - {lines.length > 0 && ( - <> - - - {'Last month’s adjustments'} - {'Prorated'} - - {chargeDates} - - {lines.map((item) => ( - - ))} - - )} - - {startingBalance !== 0 && ( -
- -
{'Total'}
-
{invoiceLineFormat(total)}
-
- -
{'Previous Balance'}
-
{invoiceLineFormat(startingBalance)}
-
-
- )} - -
{'Amount due'}
-
{invoiceLineFormat(amountDue)}
-
- {creditCard && ( - - {chargeStatus[status]} - {' to '} - {creditCard.brand} {'ending in '} - {creditCard.last4} - - )} - {status === 'PENDING' && payUrl && ( - - PAY NOW - {payUrl} - - )} -
-
- -
-
- ) -} - -export default Invoice diff --git a/packages/client/modules/invoice/components/Invoice/InvoiceFailedStamp.tsx b/packages/client/modules/invoice/components/Invoice/InvoiceFailedStamp.tsx deleted file mode 100644 index 6f035b53959..00000000000 --- a/packages/client/modules/invoice/components/Invoice/InvoiceFailedStamp.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import styled from '@emotion/styled' -import React from 'react' -import {InvoiceStatusEnum} from '~/__generated__/InvoiceRow_invoice.graphql' -import {PALETTE} from '../../../../styles/paletteV3' -import {Breakpoint} from '../../../../types/constEnums' - -const FailedStamp = styled('div')({ - color: PALETTE.TOMATO_600, - fontSize: 40, - fontWeight: 600, - left: '50%', - opacity: 0.5, - // don't let it cover up any buttons - pointerEvents: 'none', - position: 'absolute', - textAlign: 'center', - textTransform: 'uppercase', - top: '50%', - transform: 'translate3d(-50%, -50%, 0) rotate(-30deg)', - width: '100%', - - [`@media (min-width: ${Breakpoint.INVOICE}px)`]: { - fontSize: 48 - } -}) - -interface Props { - status: InvoiceStatusEnum -} -const InvoiceFailedStamp = (props: Props) => { - const {status} = props - if (status !== 'FAILED') return null - - return {'Payment Failed'} -} - -export default InvoiceFailedStamp diff --git a/packages/client/modules/invoice/components/Invoice/InvoiceTag.tsx b/packages/client/modules/invoice/components/Invoice/InvoiceTag.tsx deleted file mode 100644 index a4f2f7604e3..00000000000 --- a/packages/client/modules/invoice/components/Invoice/InvoiceTag.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import styled from '@emotion/styled' -import React from 'react' -import {InvoiceStatusEnum} from '~/__generated__/InvoiceRow_invoice.graphql' -import BaseTag from '../../../../components/Tag/BaseTag' -import {PALETTE} from '../../../../styles/paletteV3' -import {Breakpoint} from '../../../../types/constEnums' - -const TagBlock = styled('div')({ - position: 'absolute', - right: 12, - top: 12, - [`@media (min-width: ${Breakpoint.INVOICE}px)`]: { - right: 20, - top: 20 - } -}) - -const StyledBaseTag = styled(BaseTag)({ - backgroundColor: PALETTE.SLATE_200, - color: PALETTE.SLATE_700 -}) - -const lookup = { - PENDING: { - label: 'Payment Processing' - }, - UPCOMING: { - label: 'Current Estimation' - } -} as const - -interface Props { - status: InvoiceStatusEnum -} -const InvoiceTag = (props: Props) => { - const {status} = props - if (status !== 'UPCOMING' && status !== 'PENDING') return null - const {label} = lookup[status] - return ( - - {label} - - ) -} - -export default InvoiceTag diff --git a/packages/client/modules/invoice/components/InvoiceFooter/InvoiceFooter.tsx b/packages/client/modules/invoice/components/InvoiceFooter/InvoiceFooter.tsx deleted file mode 100644 index f510e217226..00000000000 --- a/packages/client/modules/invoice/components/InvoiceFooter/InvoiceFooter.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import styled from '@emotion/styled' -import React from 'react' -import parabolMark from '../../../../styles/theme/images/brand/mark-color.svg' -import {ContactInfo} from '../../../../types/constEnums' - -const Footer = styled('div')({ - textAlign: 'center' -}) - -const Heading = styled('div')({ - fontSize: 20, - fontWeight: 600, - lineHeight: '24px' -}) - -const Copy = styled('div')({ - fontSize: 14, - lineHeight: '20px' -}) - -const Lockup = styled('img')({ - display: 'block', - margin: '48px auto 16px', - width: 64 -}) - -const FinePrint = styled('div')({ - fontSize: 12, - lineHeight: '1.5', - margin: '16px auto 0' -}) - -const InvoiceFooter = () => { - return ( - - ) -} - -export default InvoiceFooter diff --git a/packages/client/modules/invoice/components/InvoiceHeader/InvoiceHeader.tsx b/packages/client/modules/invoice/components/InvoiceHeader/InvoiceHeader.tsx deleted file mode 100644 index 0e8611f1263..00000000000 --- a/packages/client/modules/invoice/components/InvoiceHeader/InvoiceHeader.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import styled from '@emotion/styled' -import graphql from 'babel-plugin-relay/macro' -import React from 'react' -import {useFragment} from 'react-relay' -import {InvoiceHeader_invoice$key} from '~/__generated__/InvoiceHeader_invoice.graphql' -import TierTag from '../../../../components/Tag/TierTag' -import {PALETTE} from '../../../../styles/paletteV3' -import defaultOrgAvatar from '../../../../styles/theme/images/avatar-organization.svg' -import {Breakpoint} from '../../../../types/constEnums' - -const Header = styled('div')({ - alignItems: 'center', - display: 'flex', - fontWeight: 600 -}) - -const LogoPanel = styled('div')({ - backgroundColor: '#FFFFFF', - border: `1px solid ${PALETTE.SLATE_500}`, - borderRadius: 8, - height: 64, - padding: 8, - width: 64, - - [`@media (min-width: ${Breakpoint.INVOICE}px)`]: { - height: 96, - width: 96 - } -}) - -const Picture = styled('img')({ - height: 'auto', - width: '100%' -}) - -const Info = styled('div')({ - justifyContent: 'flex-start', - display: 'flex', - flex: 1, - flexDirection: 'column', - marginLeft: 20 -}) - -const OrgName = styled('div')({ - fontSize: 20, - lineHeight: '1.5', - - [`@media (min-width: ${Breakpoint.INVOICE}px)`]: { - fontSize: 24 - } -}) - -const Email = styled('div')({ - fontSize: 14, - lineHeight: '20px' -}) - -const StyledTierTag = styled(TierTag)({ - margin: '0 auto 0 0' -}) - -interface Props { - invoice: InvoiceHeader_invoice$key -} - -const InvoiceHeader = (props: Props) => { - const {invoice: invoiceRef} = props - const invoice = useFragment( - graphql` - fragment InvoiceHeader_invoice on Invoice { - orgName - billingLeaderEmails - picture - tier - } - `, - invoiceRef - ) - const {orgName, billingLeaderEmails, picture, tier} = invoice - return ( -
- - - - - {orgName} - {tier !== 'starter' && } - {billingLeaderEmails.map((email) => ( - {email} - ))} - -
- ) -} - -export default InvoiceHeader diff --git a/packages/client/modules/invoice/components/InvoiceLineItem/InvoiceLineItem.tsx b/packages/client/modules/invoice/components/InvoiceLineItem/InvoiceLineItem.tsx deleted file mode 100644 index b27fdadfcbd..00000000000 --- a/packages/client/modules/invoice/components/InvoiceLineItem/InvoiceLineItem.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import graphql from 'babel-plugin-relay/macro' -import React from 'react' -import {useFragment} from 'react-relay' -import { - InvoiceLineItemEnum, - InvoiceLineItem_item$key -} from '~/__generated__/InvoiceLineItem_item.graphql' -import plural from '../../../../utils/plural' -import invoiceLineFormat from '../../helpers/invoiceLineFormat' -import InvoiceLineItemContent from './InvoiceLineItemContent' -import InvoiceLineItemDetails from './InvoiceLineItemDetails' - -const descriptionMaker = { - ADDED_USERS: (quantity: number) => `${quantity} new ${plural(quantity, 'user')} added`, - REMOVED_USERS: (quantity: number) => `${quantity} ${plural(quantity, 'user')} removed`, - INACTIVITY_ADJUSTMENTS: () => 'Adjustments for paused users' -} as const - -interface Props { - item: InvoiceLineItem_item$key -} - -const InvoiceLineItem = (props: Props) => { - const {item: itemRef} = props - const item = useFragment( - graphql` - fragment InvoiceLineItem_item on InvoiceLineItem { - amount - description - details { - ...InvoiceLineItemDetails_details - } - quantity - type - } - `, - itemRef - ) - const {quantity, details} = item - const type = item.type as Exclude - const amount = invoiceLineFormat(item.amount) - const description = item.description || descriptionMaker[type](quantity!) - return ( - - - - ) -} - -export default InvoiceLineItem diff --git a/packages/client/modules/invoice/components/InvoiceLineItem/InvoiceLineItemContent.tsx b/packages/client/modules/invoice/components/InvoiceLineItem/InvoiceLineItemContent.tsx deleted file mode 100644 index 7bc5f7b47d6..00000000000 --- a/packages/client/modules/invoice/components/InvoiceLineItem/InvoiceLineItemContent.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import styled from '@emotion/styled' -import React, {ReactNode} from 'react' -import {PALETTE} from '../../../../styles/paletteV3' -import {Breakpoint} from '../../../../types/constEnums' - -const Item = styled('div')({ - borderBottom: `1px solid ${PALETTE.SLATE_300}`, - display: 'block', - paddingBottom: 10, - paddingTop: 10 -}) - -const ItemContent = styled('div')({ - display: 'flex', - fontSize: 16, - justifyContent: 'space-between', - lineHeight: '24px', - paddingRight: 12, - width: '100%', - - [`@media (min-width: ${Breakpoint.INVOICE}px)`]: { - paddingRight: 20 - } -}) - -const Fill = styled('div')({ - flex: 1, - paddingRight: 16 -}) - -const Amount = styled('div')({ - fontVariantNumeric: 'tabular-nums' -}) - -interface Props { - description: ReactNode - amount: ReactNode - children?: ReactNode -} - -const InvoiceLineItemContent = (props: Props) => { - const {description, amount, children} = props - return ( - - - {description} - {amount} - - {children} - - ) -} -export default InvoiceLineItemContent diff --git a/packages/client/modules/invoice/components/InvoiceLineItem/InvoiceLineItemDetails.tsx b/packages/client/modules/invoice/components/InvoiceLineItem/InvoiceLineItemDetails.tsx deleted file mode 100644 index e92025814e1..00000000000 --- a/packages/client/modules/invoice/components/InvoiceLineItem/InvoiceLineItemDetails.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import styled from '@emotion/styled' -import graphql from 'babel-plugin-relay/macro' -import React, {useState} from 'react' -import {useFragment} from 'react-relay' -import { - InvoiceLineItemDetails_details$data, - InvoiceLineItemDetails_details$key -} from '~/__generated__/InvoiceLineItemDetails_details.graphql' -import {InvoiceLineItemEnum} from '~/__generated__/InvoiceLineItem_item.graphql' -import {PALETTE} from '../../../../styles/paletteV3' -import {Breakpoint} from '../../../../types/constEnums' -import makeDateString from '../../../../utils/makeDateString' -import invoiceLineFormat from '../../helpers/invoiceLineFormat' - -const detailDescriptionMaker = { - ADDED_USERS: (detail: InvoiceLineItemDetails_details$data[0]) => - `${detail.email} joined ${makeDateString(detail.startAt)}`, - REMOVED_USERS: (detail: InvoiceLineItemDetails_details$data[0]) => - `${detail.email} left ${makeDateString(detail.startAt)}`, - INACTIVITY_ADJUSTMENTS: (detail: InvoiceLineItemDetails_details$data[0]) => { - if (!detail.endAt) { - return `${detail.email} has been paused since ${makeDateString(detail.startAt)}` - } else if (!detail.startAt) { - return `${detail.email} was paused until ${makeDateString(detail.endAt)}` - } - return `${detail.email} was paused from ${makeDateString(detail.startAt)} to ${makeDateString( - detail.endAt - )}` - } -} as const - -const Details = styled('div')({ - display: 'block' -}) - -const DetailsToggle = styled('div')({ - color: PALETTE.SKY_500, - cursor: 'pointer', - fontSize: 13, - fontWeight: 600, - lineHeight: '20px', - textTransform: 'uppercase' -}) - -const DetailsInner = styled('div')<{isOpen: boolean}>(({isOpen}) => ({ - display: isOpen ? 'block' : 'none' -})) - -const DetailsItem = styled('div')({ - display: 'flex', - justifyContent: 'space-between', - width: '100%', - fontSize: 13, - lineHeight: '22px', - paddingRight: 12, - - [`@media (min-width: ${Breakpoint.INVOICE}px)`]: { - paddingRight: 20 - } -}) - -const DetailsFill = styled('div')({ - flex: 1, - paddingRight: 16 -}) - -interface Props { - details: InvoiceLineItemDetails_details$key | null - type: Exclude -} - -const InvoiceLineItemDetails = (props: Props) => { - const {details: detailsRef, type} = props - const details = useFragment( - graphql` - fragment InvoiceLineItemDetails_details on InvoiceLineItemDetails @relay(plural: true) { - id - amount - email - endAt - startAt - } - `, - detailsRef - ) - const [isOpen, setIsOpen] = useState(false) - const toggleDetails = () => { - setIsOpen(!isOpen) - } - if (!details) return null - return ( -
- - {isOpen ? 'Hide Details' : 'View Details'} - - - {details.map((d) => { - const amount = invoiceLineFormat(d.amount) - const description = detailDescriptionMaker[type](d) - return ( - - {description} -
{amount}
-
- ) - })} -
-
- ) -} - -export default InvoiceLineItemDetails diff --git a/packages/client/modules/invoice/components/InvoiceLineItem/NextPeriodChargesLineItem.tsx b/packages/client/modules/invoice/components/InvoiceLineItem/NextPeriodChargesLineItem.tsx deleted file mode 100644 index ccbe9e7bb42..00000000000 --- a/packages/client/modules/invoice/components/InvoiceLineItem/NextPeriodChargesLineItem.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import graphql from 'babel-plugin-relay/macro' -import React from 'react' -import {useFragment} from 'react-relay' -import {NextPeriodChargesLineItem_item$key} from '~/__generated__/NextPeriodChargesLineItem_item.graphql' -import {TierEnum} from '../../../../__generated__/DowngradeToStarterMutation.graphql' -import plural from '../../../../utils/plural' -import invoiceLineFormat from '../../helpers/invoiceLineFormat' -import InvoiceLineItemContent from './InvoiceLineItemContent' - -interface Props { - item: NextPeriodChargesLineItem_item$key - tier: TierEnum -} - -const NextPeriodChargesLineItem = (props: Props) => { - const {item: itemRef, tier} = props - const item = useFragment( - graphql` - fragment NextPeriodChargesLineItem_item on NextPeriodCharges { - amount - quantity - unitPrice - } - `, - itemRef - ) - const {unitPrice, quantity} = item - const amount = invoiceLineFormat(item.amount) - if (tier === 'enterprise') { - return ( - - ) - } - const unitPriceString = (unitPrice! / 100).toLocaleString('en-US', { - style: 'currency', - currency: 'USD', - minimumFractionDigits: 0 - }) - const description = `${quantity} active ${plural(quantity, 'user')} (${unitPriceString} each)` - return -} - -export default NextPeriodChargesLineItem diff --git a/packages/client/modules/invoice/containers/InvoiceRoot.tsx b/packages/client/modules/invoice/containers/InvoiceRoot.tsx deleted file mode 100644 index b0db1125ae9..00000000000 --- a/packages/client/modules/invoice/containers/InvoiceRoot.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import React, {Suspense} from 'react' -import {RouteComponentProps} from 'react-router' -import invoiceQuery, {InvoiceQuery} from '../../../__generated__/InvoiceQuery.graphql' -import useQueryLoaderNow from '../../../hooks/useQueryLoaderNow' -import Invoice from '../components/Invoice/Invoice' - -interface Props extends RouteComponentProps<{invoiceId: string}> {} - -const InvoiceRoot = ({ - match: { - params: {invoiceId} - } -}: Props) => { - const queryRef = useQueryLoaderNow(invoiceQuery, {invoiceId}) - return {queryRef && } -} - -export default InvoiceRoot diff --git a/packages/client/modules/userDashboard/components/InvoiceRow/InvoiceRow.tsx b/packages/client/modules/userDashboard/components/InvoiceRow/InvoiceRow.tsx index 4148969d317..292094e8b97 100644 --- a/packages/client/modules/userDashboard/components/InvoiceRow/InvoiceRow.tsx +++ b/packages/client/modules/userDashboard/components/InvoiceRow/InvoiceRow.tsx @@ -64,38 +64,21 @@ const InvoiceRow = (props: Props) => { graphql` fragment InvoiceRow_invoice on Invoice { id - amountDue - creditCard { - brand - } - endAt - nextPeriodCharges { - nextPeriodEnd - } - paidAt + dueAt + total payUrl status } `, invoiceRef ) - const { - id: invoiceId, - amountDue, - creditCard, - endAt, - nextPeriodCharges, - paidAt, - payUrl, - status - } = invoice - const {nextPeriodEnd} = nextPeriodCharges + const {dueAt, total, payUrl, status} = invoice const isEstimate = status === 'UPCOMING' return ( { {status === 'UPCOMING' - ? `Due on ${makeDateString(endAt)}` - : `${makeDateString(endAt)} to ${makeDateString(nextPeriodEnd)}`} + ? `Due on ${makeDateString(dueAt)}` + : `${makeDateString(dueAt)}`} {isEstimate && '*'} - {invoiceLineFormat(amountDue)} + {invoiceLineFormat(total)} {status === 'UPCOMING' && ( - - {isEstimate && '*Current estimate. '} - {creditCard - ? `Card will be charged on ${makeDateString(endAt)}` - : `Make sure to add billing info before ${makeDateString(endAt)}!`} - - )} - {status === 'PAID' && ( - - {'Paid on '} - {makeDateString(paidAt)} - + {isEstimate && '*Current estimate. '} )} + {status === 'PAID' && {'Paid'}} {status !== 'PAID' && status !== 'UPCOMING' && ( - {payUrl ? ( - - {'PAY NOW'} - - ) : ( - `Status: ${status}` - )} + + {'PAY NOW'} + )} diff --git a/packages/client/modules/userDashboard/components/OrgBilling/BillingForm.tsx b/packages/client/modules/userDashboard/components/OrgBilling/BillingForm.tsx index e86bb080242..626009930b6 100644 --- a/packages/client/modules/userDashboard/components/OrgBilling/BillingForm.tsx +++ b/packages/client/modules/userDashboard/components/OrgBilling/BillingForm.tsx @@ -20,10 +20,8 @@ import StyledError from '../../../../components/StyledError' import useAtmosphere from '../../../../hooks/useAtmosphere' import useMutationProps from '../../../../hooks/useMutationProps' import CreateStripeSubscriptionMutation from '../../../../mutations/CreateStripeSubscriptionMutation' -import upgradeToTeamTierSuccessUpdater from '../../../../mutations/handlers/upgradeToTeamTierSuccessUpdater' import {PALETTE} from '../../../../styles/paletteV3' import SendClientSideEvent from '../../../../utils/SendClientSideEvent' -import createProxyRecord from '../../../../utils/relay/createProxyRecord' const ButtonBlock = styled('div')({ display: 'flex', @@ -130,9 +128,10 @@ const BillingForm = (props: Props) => { return } commitLocalUpdate(atmosphere, (store) => { - const payload = createProxyRecord(store, 'payload', {}) - payload.setLinkedRecord(store.get(orgId)!, 'organization') - upgradeToTeamTierSuccessUpdater(payload) + const organization = store.get(orgId) + if (!organization) return + organization.setValue(true, 'showConfetti') + organization.setValue(true, 'showDrawer') }) onCompleted() } diff --git a/packages/client/modules/userDashboard/components/OrgBilling/OrgBillingInvoices.tsx b/packages/client/modules/userDashboard/components/OrgBilling/OrgBillingInvoices.tsx index cc13cdee052..de77f5392a5 100644 --- a/packages/client/modules/userDashboard/components/OrgBilling/OrgBillingInvoices.tsx +++ b/packages/client/modules/userDashboard/components/OrgBilling/OrgBillingInvoices.tsx @@ -5,22 +5,14 @@ import {usePaginationFragment} from 'react-relay' import {OrgBillingInvoices_query$key} from '~/__generated__/OrgBillingInvoices_query.graphql' import {OrgBillingInvoicesPaginationQuery} from '../../../../__generated__/OrgBillingInvoicesPaginationQuery.graphql' import Panel from '../../../../components/Panel/Panel' -import SecondaryButton from '../../../../components/SecondaryButton' -import {ElementWidth, Layout} from '../../../../types/constEnums' +import {ElementWidth} from '../../../../types/constEnums' +import {Button} from '../../../../ui/Button/Button' import InvoiceRow from '../InvoiceRow/InvoiceRow' const StyledPanel = styled(Panel)<{isWide: boolean}>(({isWide}) => ({ maxWidth: isWide ? ElementWidth.PANEL_WIDTH : 'inherit' })) -const MoreGutter = styled('div')({ - paddingBottom: Layout.ROW_GUTTER -}) - -const LoadMoreButton = styled(SecondaryButton)({ - margin: '0 auto' -}) - interface Props { queryRef: OrgBillingInvoices_query$key isWide?: boolean @@ -43,11 +35,11 @@ const OrgBillingInvoices = (props: Props) => { node { ...InvoiceRow_invoice id + payUrl } } pageInfo { hasNextPage - endCursor } } } @@ -55,25 +47,25 @@ const OrgBillingInvoices = (props: Props) => { `, queryRef ) - const {data, hasNext, isLoadingNext, loadNext} = paginationRes + const {data} = paginationRes const {viewer} = data const {invoices} = viewer - const loadMore = () => { - if (!hasNext || isLoadingNext) return - loadNext(5) - } - if (!invoices || !invoices.edges.length) return null + const {edges} = invoices + if (!edges.length) return null + const portalUrl = edges[0]?.node.payUrl ?? '' return ( ) diff --git a/packages/client/modules/userDashboard/components/OrgBilling/OrgPlansAndBilling.tsx b/packages/client/modules/userDashboard/components/OrgBilling/OrgPlansAndBilling.tsx index 834b06f7447..72a921ee61c 100644 --- a/packages/client/modules/userDashboard/components/OrgBilling/OrgPlansAndBilling.tsx +++ b/packages/client/modules/userDashboard/components/OrgBilling/OrgPlansAndBilling.tsx @@ -1,6 +1,6 @@ import {StripeCardNumberElement} from '@stripe/stripe-js' import graphql from 'babel-plugin-relay/macro' -import React, {Suspense, useRef, useState} from 'react' +import React, {Suspense, useEffect, useRef, useState} from 'react' import {PreloadedQuery, useFragment, usePreloadedQuery, useRefetchableFragment} from 'react-relay' import {OrgPlansAndBillingQuery} from '../../../../__generated__/OrgPlansAndBillingQuery.graphql' import {OrgPlansAndBillingRefetchQuery} from '../../../../__generated__/OrgPlansAndBillingRefetchQuery.graphql' @@ -29,7 +29,7 @@ const OrgPlansAndBilling = (props: Props) => { `, queryRef ) - const [queryData] = useRefetchableFragment< + const [queryData, refetchInvoices] = useRefetchableFragment< OrgPlansAndBillingRefetchQuery, OrgPlansAndBilling_query$key >( @@ -50,6 +50,7 @@ const OrgPlansAndBilling = (props: Props) => { ...BillingLeaders_organization ...PaymentDetails_organization ...OrgPlanDrawer_organization + id billingTier isBillingLeader } @@ -57,13 +58,18 @@ const OrgPlansAndBilling = (props: Props) => { organizationRef ) const [hasSelectedTeamPlan, setHasSelectedTeamPlan] = useState(false) - const {billingTier, isBillingLeader} = organization + const {id: orgId, billingTier, isBillingLeader} = organization const cardNumberRef = useRef(null) const handleSelectTeamPlan = () => { setHasSelectedTeamPlan(true) cardNumberRef.current?.focus() } - + const prevTierRef = useRef(billingTier) + useEffect(() => { + if (billingTier === prevTierRef.current) return + prevTierRef.current = billingTier + refetchInvoices({orgId, first: 3}, {fetchPolicy: 'network-only'}) + }, [billingTier]) if (billingTier === 'starter') { return ( diff --git a/packages/client/mutations/handlers/upgradeToTeamTierSuccessUpdater.ts b/packages/client/mutations/handlers/upgradeToTeamTierSuccessUpdater.ts deleted file mode 100644 index f47a9bbb626..00000000000 --- a/packages/client/mutations/handlers/upgradeToTeamTierSuccessUpdater.ts +++ /dev/null @@ -1,12 +0,0 @@ -import {RecordProxy} from 'relay-runtime' - -const upgradeToTeamTierSuccessUpdater = (payload: RecordProxy) => { - const organization = payload.getLinkedRecord('organization') - if (organization) { - organization.setValue(true, 'showConfetti') - organization.setValue(true, 'showDrawer') - organization.setValue('team', 'billingTier') - organization.setValue('team', 'tier') - } -} -export default upgradeToTeamTierSuccessUpdater diff --git a/packages/client/package.json b/packages/client/package.json index 6be831cb4d2..2235e8e1440 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -3,7 +3,7 @@ "description": "An open-source app for building smarter, more agile teams.", "author": "Parabol Inc. (http://github.com/ParabolInc)", "license": "AGPL-3.0", - "version": "7.42.2", + "version": "7.43.0", "repository": { "type": "git", "url": "https://github.com/ParabolInc/parabol" @@ -116,6 +116,7 @@ "json2csv": "5.0.7", "jwt-decode": "^2.1.0", "linkify-it": "^2.0.3", + "marked": "^13.0.3", "mousetrap": "^1.6.3", "ms": "^2.0.0", "react": "^17.0.2", diff --git a/packages/client/styles/theme/global.css b/packages/client/styles/theme/global.css index 8bfbfb9f72e..b1a76203c76 100644 --- a/packages/client/styles/theme/global.css +++ b/packages/client/styles/theme/global.css @@ -65,7 +65,7 @@ 2) prevent a horizontal scrollbar from causing a vertical scrollbar due to the 100vh */ #root { - @apply w-full h-screen p-0 m-0 bg-slate-200; + @apply m-0 h-screen w-full bg-slate-200 p-0; } *, @@ -187,3 +187,8 @@ .draft-codeblock { @apply m-0 rounded-[1px] border-l-2 border-solid border-l-slate-500 bg-slate-200 py-0 px-[8px] font-mono font-[13px] leading-normal; } + +.summary-link-style a { + @apply text-sky-500; + text-decoration: underline; +} diff --git a/packages/client/subscriptions/OrganizationSubscription.ts b/packages/client/subscriptions/OrganizationSubscription.ts index 8877fcb3011..cc9fb3bae44 100644 --- a/packages/client/subscriptions/OrganizationSubscription.ts +++ b/packages/client/subscriptions/OrganizationSubscription.ts @@ -20,7 +20,6 @@ import { setOrgUserRoleAddedOrganizationUpdater } from '../mutations/SetOrgUserRoleMutation' import {updateTemplateScopeOrganizationUpdater} from '../mutations/UpdateReflectTemplateScopeMutation' -import upgradeToTeamTierSuccessUpdater from '../mutations/handlers/upgradeToTeamTierSuccessUpdater' import subscriptionOnNext from './subscriptionOnNext' import subscriptionUpdater from './subscriptionUpdater' @@ -40,7 +39,6 @@ const subscription = graphql` OldUpdateCreditCardPayload { ...OldUpdateCreditCardMutation_organization @relay(mask: false) } - UpdateOrgPayload { ...UpdateOrgMutation_organization @relay(mask: false) } @@ -77,8 +75,7 @@ const updateHandlers = { ArchiveOrganizationPayload: archiveOrganizationOrganizationUpdater, SetOrgUserRoleSuccess: setOrgUserRoleAddedOrganizationUpdater, RemoveOrgUserPayload: removeOrgUserOrganizationUpdater, - UpdateTemplateScopeSuccess: updateTemplateScopeOrganizationUpdater, - UpgradeToTeamTierSuccess: upgradeToTeamTierSuccessUpdater + UpdateTemplateScopeSuccess: updateTemplateScopeOrganizationUpdater } as const const OrganizationSubscription = ( diff --git a/packages/client/types/constEnums.ts b/packages/client/types/constEnums.ts index ce41369e4a9..9397a89a34e 100644 --- a/packages/client/types/constEnums.ts +++ b/packages/client/types/constEnums.ts @@ -34,7 +34,6 @@ export const enum BezierCurve { } export const enum Breakpoint { - INVOICE = 512, SIDEBAR_LEFT = 1024, NEW_MEETING_GRID = 1112, NEW_MEETING_SELECTOR = 500, diff --git a/packages/client/ui/Button/Button.tsx b/packages/client/ui/Button/Button.tsx index fcfb00ec8eb..a0e3bc1e373 100644 --- a/packages/client/ui/Button/Button.tsx +++ b/packages/client/ui/Button/Button.tsx @@ -41,7 +41,7 @@ export interface ButtonProps extends React.ButtonHTMLAttributes( +export const Button = React.forwardRef( ({className, variant, size = 'default', shape = 'default', asChild = false, ...props}, ref) => { const Comp = asChild ? Slot : 'button' return ( @@ -63,5 +63,3 @@ const Button = React.forwardRef( ) Button.displayName = 'Button' - -export {Button} diff --git a/packages/client/utils/features/isTeamHealthAvailable.ts b/packages/client/utils/features/isTeamHealthAvailable.ts index fbe6bcfd56c..5e91e68bf6a 100644 --- a/packages/client/utils/features/isTeamHealthAvailable.ts +++ b/packages/client/utils/features/isTeamHealthAvailable.ts @@ -1,4 +1,4 @@ -import {TierEnum} from '~/../server/database/types/Invoice' +import {TierEnum} from '../../__generated__/DowngradeToStarterMutation.graphql' function isTeamHealthAvailable(tier: TierEnum) { return tier !== 'starter' diff --git a/packages/client/utils/sortByTier.ts b/packages/client/utils/sortByTier.ts index eb353512927..77747880657 100644 --- a/packages/client/utils/sortByTier.ts +++ b/packages/client/utils/sortByTier.ts @@ -1,4 +1,4 @@ -import {TierEnum} from '../__generated__/InvoiceHeader_invoice.graphql' +import {TierEnum} from '../__generated__/AddOrgMutation_organization.graphql' const sortByTier = ( teamsOrOrgs: T diff --git a/packages/embedder/package.json b/packages/embedder/package.json index 7331557996b..10e25af40f1 100644 --- a/packages/embedder/package.json +++ b/packages/embedder/package.json @@ -1,6 +1,6 @@ { "name": "parabol-embedder", - "version": "7.42.2", + "version": "7.43.0", "description": "A service that computes embedding vectors from Parabol objects", "author": "Jordan Husney ", "homepage": "https://github.com/ParabolInc/parabol/tree/master/packages/embedder#readme", diff --git a/packages/gql-executor/package.json b/packages/gql-executor/package.json index 161a7a3a181..23cf3bcce89 100644 --- a/packages/gql-executor/package.json +++ b/packages/gql-executor/package.json @@ -1,6 +1,6 @@ { "name": "gql-executor", - "version": "7.42.2", + "version": "7.43.0", "description": "A Stateless GraphQL Executor", "author": "Matt Krick ", "homepage": "https://github.com/ParabolInc/parabol/tree/master/packages/gqlExecutor#readme", @@ -27,8 +27,8 @@ }, "dependencies": { "dd-trace": "^4.2.0", - "parabol-client": "7.42.2", - "parabol-server": "7.42.2", + "parabol-client": "7.43.0", + "parabol-server": "7.43.0", "undici": "^5.26.2" } } diff --git a/packages/integration-tests/package.json b/packages/integration-tests/package.json index 107f835a451..f032f8fd418 100644 --- a/packages/integration-tests/package.json +++ b/packages/integration-tests/package.json @@ -2,7 +2,7 @@ "name": "integration-tests", "author": "Parabol Inc. (http://github.com/ParabolInc)", "license": "AGPL-3.0", - "version": "7.42.2", + "version": "7.43.0", "description": "", "main": "index.js", "scripts": { diff --git a/packages/server/billing/helpers/fetchAllLines.ts b/packages/server/billing/helpers/fetchAllLines.ts deleted file mode 100644 index 7f8c30d0e4b..00000000000 --- a/packages/server/billing/helpers/fetchAllLines.ts +++ /dev/null @@ -1,22 +0,0 @@ -import Stripe from 'stripe' -import {getStripeManager} from '../../utils/stripe' - -export default async function fetchAllLines(invoiceId: string, customerId?: string | null) { - const stripeLineItems = [] as Stripe.InvoiceLineItem[] - const options = {limit: 100} as Stripe.InvoiceLineItemListParams & {customer: string} - // used for upcoming invoices - if (customerId) { - options.customer = customerId - } - const manager = getStripeManager() - for (let i = 0; i < 100; i++) { - if (i > 0) { - options.starting_after = stripeLineItems[stripeLineItems.length - 1]!.id - } - - const invoiceLines = await manager.listLineItems(invoiceId, options) // eslint-disable-line no-await-in-loop - stripeLineItems.push(...invoiceLines.data) - if (!invoiceLines.has_more) break - } - return stripeLineItems -} diff --git a/packages/server/billing/helpers/generateInvoice.ts b/packages/server/billing/helpers/generateInvoice.ts deleted file mode 100644 index b9cc7abb80b..00000000000 --- a/packages/server/billing/helpers/generateInvoice.ts +++ /dev/null @@ -1,405 +0,0 @@ -import {InvoiceItemType} from 'parabol-client/types/constEnums' -import Stripe from 'stripe' -import getRethink from '../../database/rethinkDriver' -import Coupon from '../../database/types/Coupon' -import Invoice, {InvoiceStatusEnum} from '../../database/types/Invoice' -import {InvoiceLineItemEnum} from '../../database/types/InvoiceLineItem' -import InvoiceLineItemDetail from '../../database/types/InvoiceLineItemDetail' -import InvoiceLineItemOtherAdjustments from '../../database/types/InvoiceLineItemOtherAdjustments' -import NextPeriodCharges from '../../database/types/NextPeriodCharges' -import QuantityChangeLineItem from '../../database/types/QuantityChangeLineItem' -import generateUID from '../../generateUID' -import {DataLoaderWorker} from '../../graphql/graphql' -import isValid from '../../graphql/isValid' -import {fromEpochSeconds} from '../../utils/epochTime' -import sendToSentry from '../../utils/sendToSentry' -import {getStripeManager} from '../../utils/stripe' - -interface InvoicesByStartTime { - [start: string]: { - unusedTime?: Stripe.InvoiceLineItem - remainingTime?: Stripe.InvoiceLineItem - } -} - -interface TypesDict { - pauseUser: InvoicesByStartTime - unpauseUser: InvoicesByStartTime - addUser: InvoicesByStartTime - removeUser: InvoicesByStartTime -} - -interface ItemDict { - [userId: string]: TypesDict -} - -interface EmailLookup { - [userId: string]: string -} - -interface ReducedItemBase { - id: string - amount: number - email: string -} - -interface ReducedUnpausePartial extends ReducedItemBase { - endAt: Date -} - -interface ReducedStandardPartial extends ReducedItemBase { - startAt: Date -} - -interface ReducedItem extends ReducedItemBase { - startAt?: Date | null - endAt?: Date | null -} - -interface ReducedItemsByType { - addUser: ReducedStandardPartial[] - removeUser: ReducedStandardPartial[] - pauseUser: ReducedStandardPartial[] - unpauseUser: ReducedUnpausePartial[] -} - -interface DetailedLineItemDict { - ADDED_USERS: ReducedStandardPartial[] - REMOVED_USERS: ReducedStandardPartial[] - INACTIVITY_ADJUSTMENTS: ReducedItem[] -} - -const getEmailLookup = async (userIds: string[], dataLoader: DataLoaderWorker) => { - const usersAndEmails = (await dataLoader.get('users').loadMany(userIds)).filter(isValid) - return usersAndEmails.reduce( - (dict, doc) => { - if (doc) { - dict[doc.id] = doc.email - } - return dict - }, - {} as {[key: string]: string} - ) as EmailLookup -} - -const reduceItemsByType = (typesDict: TypesDict, email: string) => { - const userTypes = Object.keys(typesDict) as (keyof TypesDict)[] - const reducedItemsByType: ReducedItemsByType = { - addUser: [] as ReducedStandardPartial[], - removeUser: [] as ReducedStandardPartial[], - pauseUser: [] as ReducedStandardPartial[], - unpauseUser: [] as ReducedUnpausePartial[] - } - for (let j = 0; j < userTypes.length; j++) { - // for each type - const type = userTypes[j]! - const reducedItems = reducedItemsByType[type] - const startTimeDict = typesDict[type] - const startTimes = Object.keys(startTimeDict) - // unpausing someone ends a period, we'll use this later - const dateField = type === InvoiceItemType.UNPAUSE_USER ? 'endAt' : 'startAt' - for (let k = 0; k < startTimes.length; k++) { - // for each time period - const startTime = startTimes[k]! - const lineItems = startTimeDict[startTime]! - // combine unusedTime with remainingTime to create a single activity - const {unusedTime, remainingTime} = lineItems - const unusedTimeAmount = unusedTime ? unusedTime.amount : 0 - const remainingTimeAmount = remainingTime ? remainingTime.amount : 0 - reducedItems[k] = { - id: generateUID(), - amount: unusedTimeAmount + remainingTimeAmount, - email, - [dateField]: fromEpochSeconds(startTime) - } as unknown as ReducedUnpausePartial | ReducedStandardPartial - } - } - return reducedItemsByType -} - -const makeDetailedPauseEvents = ( - pausedItems: ReducedStandardPartial[], - unpausedItems: ReducedUnpausePartial[] -) => { - const inactivityDetails: ReducedItem[] = [] - // if an unpause happened before a pause, we know they came into this period paused, so we don't want a start date - // really this should be an if clause, but there are some errors cases where multiple unpause events were sent for the same user - while ( - unpausedItems.length > 0 && - (pausedItems.length === 0 || unpausedItems[0]!.endAt < pausedItems[0]!.startAt) - ) { - // mutative - const firstUnpausedItem = unpausedItems.shift()! - inactivityDetails.push(firstUnpausedItem) - } - // match up every pause with an unpause so it's clear that Foo was paused for 5 days - for (let j = 0; j < unpausedItems.length; j++) { - const unpausedItem = unpausedItems[j]! - const pausedItem = pausedItems[j]! - inactivityDetails.push({ - ...pausedItem, - amount: unpausedItem.amount + pausedItem.amount, - endAt: unpausedItem.endAt - }) - } - - // if there is an extra pause, then it's because they are still on pause while we're invoicing. - if (pausedItems.length > unpausedItems.length) { - const lastPausedItem = pausedItems[pausedItems.length - 1]! - inactivityDetails.push(lastPausedItem) - } - return inactivityDetails -} - -const makeQuantityChangeLineItems = (detailedLineItems: DetailedLineItemDict) => { - const quantityChangeLineItems: QuantityChangeLineItem[] = [] - const lineItemTypes = Object.keys(detailedLineItems) as (keyof DetailedLineItemDict)[] - for (let i = 0; i < lineItemTypes.length; i++) { - const lineItemType = lineItemTypes[i]! - const details = detailedLineItems[lineItemType] as ReducedItem[] - if (details.length > 0) { - const id = generateUID() - quantityChangeLineItems.push( - new QuantityChangeLineItem({ - id, - amount: details.reduce((sum, detail) => sum + detail.amount, 0), - details: details.map((doc) => new InvoiceLineItemDetail({...doc, parentId: id})), - quantity: details.length, - type: lineItemType as InvoiceLineItemEnum - }) - ) - } - } - return quantityChangeLineItems -} - -const makeDetailedLineItems = async (itemDict: ItemDict, dataLoader: DataLoaderWorker) => { - // Make lookup table to get user Emails - const userIds = Object.keys(itemDict) - const emailLookup = await getEmailLookup(userIds, dataLoader) - const detailedLineItems = { - ADDED_USERS: [] as ReducedStandardPartial[], - REMOVED_USERS: [] as ReducedStandardPartial[], - INACTIVITY_ADJUSTMENTS: [] as ReducedItem[] - } as DetailedLineItemDict - - for (let i = 0; i < userIds.length; i++) { - // for each userId - const userId = userIds[i]! - const email = emailLookup[userId]! - const typesDict = itemDict[userId]! - const reducedItemsByType = reduceItemsByType(typesDict, email) - const pausedItems = reducedItemsByType.pauseUser - const unpausedItems = reducedItemsByType.unpauseUser - detailedLineItems.ADDED_USERS.push(...reducedItemsByType.addUser) - detailedLineItems.REMOVED_USERS.push(...reducedItemsByType.removeUser) - detailedLineItems.INACTIVITY_ADJUSTMENTS.push( - ...makeDetailedPauseEvents(pausedItems, unpausedItems) - ) - } - return detailedLineItems -} - -const addToDict = (itemDict: ItemDict, lineItem: Stripe.InvoiceLineItem) => { - const { - metadata, - period: {start} - } = lineItem - const userId = metadata.userId! - const type = metadata.type as InvoiceItemType - const safeType = type === InvoiceItemType.AUTO_PAUSE_USER ? InvoiceItemType.PAUSE_USER : type - itemDict[userId] = itemDict[userId] || ({} as TypesDict) - const userItemDict = itemDict[userId]! - userItemDict[safeType] = userItemDict[safeType] || {} - userItemDict[safeType][start] = userItemDict[safeType][start] || {} - const startTimeItems = userItemDict[safeType][start] as InvoicesByStartTime['start'] - const bucket = lineItem.amount < 0 ? 'unusedTime' : 'remainingTime' - // an identical line item may already exist in the bucket, e.g. a user was removed & prorated to the exact same timestamp (subscription start) - // since the start time is the same, we know the amount will be the same, so we do this to avoid a duplicate line item on the invoice - startTimeItems[bucket] = lineItem -} - -const makeItemDict = (stripeLineItems: Stripe.InvoiceLineItem[]) => { - const itemDict = {} as ItemDict - const unknownLineItems = [] as Stripe.InvoiceLineItem[] - let nextPeriodCharges!: NextPeriodCharges - for (let i = 0; i < stripeLineItems.length; i++) { - const lineItem = stripeLineItems[i]! - const { - amount, - metadata, - period: {end}, - proration, - quantity - } = lineItem - const lineItemQuantity = quantity ?? 0 - if (proration === false) { - if (!nextPeriodCharges) { - // this must be the next month's charge - nextPeriodCharges = new NextPeriodCharges({ - amount, - quantity: lineItemQuantity, - nextPeriodEnd: fromEpochSeconds(end), - unitPrice: lineItem.plan?.amount || undefined, - interval: lineItem.plan?.interval || 'month' - }) - } else { - //merge the quantity & price line for enterprise - nextPeriodCharges.amount = nextPeriodCharges.amount || amount - nextPeriodCharges.quantity = nextPeriodCharges.quantity || lineItemQuantity - } - } else if (!metadata.type) { - unknownLineItems.push(lineItem) - } else { - // at this point, we don't care whether it's an auto pause or manual - addToDict(itemDict, lineItem) - } - } - return {itemDict, nextPeriodCharges, unknownLineItems} -} - -const maybeReduceUnknowns = async ( - unknownLineItems: Stripe.InvoiceLineItem[], - itemDict: ItemDict, - stripeSubscriptionId: string -) => { - const r = await getRethink() - const unknowns = [] as Stripe.InvoiceLineItem[] - const manager = getStripeManager() - for (let i = 0; i < unknownLineItems.length; i++) { - const unknownLineItem = unknownLineItems[i]! - // this could be inefficient but if all goes as planned, we'll never use this function - - const hook = await r - .table('InvoiceItemHook') // eslint-disable-line no-await-in-loop - .getAll(unknownLineItem.period.start, {index: 'prorationDate'}) - .filter({stripeSubscriptionId}) - .nth(0) - .default(null) - .run() - if (hook) { - const {id: hookId, type, userId} = hook - // push it back to stripe for posterity - if (unknownLineItem.invoice_item) { - manager.updateInvoiceItem(unknownLineItem.invoice_item, type, userId, hookId).catch() - } - // mutate the original line item - unknownLineItem.metadata = { - type, - userId - } - addToDict(itemDict, unknownLineItem) - } else { - unknowns.push(unknownLineItem) - } - } - return unknowns -} - -export default async function generateInvoice( - invoice: Stripe.Invoice, - stripeLineItems: Stripe.InvoiceLineItem[], - orgId: string, - invoiceId: string, - dataLoader: DataLoaderWorker -) { - const r = await getRethink() - const now = new Date() - - const {itemDict, nextPeriodCharges, unknownLineItems} = makeItemDict(stripeLineItems) - // technically, invoice.created could be called before invoiceitem.created is if there is a network hiccup. mutates itemDict! - const unknownInvoiceLines = await maybeReduceUnknowns( - unknownLineItems, - itemDict, - invoice.subscription as string - ) - const detailedLineItems = await makeDetailedLineItems(itemDict, dataLoader) - const quantityChangeLineItems = makeQuantityChangeLineItems(detailedLineItems) - const invoiceLineItems = [ - ...unknownInvoiceLines.map( - (item) => - new InvoiceLineItemOtherAdjustments({ - amount: item.amount, - description: item.description, - quantity: item.quantity ?? 0 - }) - ), - ...quantityChangeLineItems - ].sort((a, b) => (a.type > b.type ? 1 : -1)) - - // sanity check - const calculatedTotal = - invoiceLineItems.reduce((sum, {amount}) => sum + amount, 0) + nextPeriodCharges.amount - if (calculatedTotal !== invoice.total) { - sendToSentry(new Error('Calculated invoice does not match stripe invoice'), { - tags: {invoiceId, calculatedTotal, invoiceTotal: invoice.total} - }) - } - - const [type] = invoiceId.split('_') - const isUpcoming = type === 'upcoming' - - const statusLookup = { - paid: 'PAID', - draft: 'PENDING', - open: 'PENDING' - } as Record - - const status: InvoiceStatusEnum = isUpcoming - ? 'UPCOMING' - : statusLookup[invoice.status!] || 'FAILED' - const paidAt = - invoice.status === 'paid' && invoice.status_transitions.paid_at - ? fromEpochSeconds(invoice.status_transitions.paid_at) - : undefined - const [organization, orgUsers] = await Promise.all([ - dataLoader.get('organizations').loadNonNull(orgId), - dataLoader.get('organizationUsersByOrgId').load(orgId) - ]) - const billingLeaderIds = orgUsers - .filter(({role}) => role && ['BILLING_LEADER', 'ORG_ADMIN'].includes(role)) - .map(({userId}) => userId) - - const billingLeaders = (await dataLoader.get('users').loadMany(billingLeaderIds)).filter(isValid) - const billingLeaderEmails = billingLeaders.map((user) => user.email) - - const couponDetails = (invoice.discount && invoice.discount.coupon) || null - const coupon = - (couponDetails?.name && - new Coupon({ - id: couponDetails.id, - amountOff: couponDetails.amount_off ?? 0, - name: couponDetails.name, - percentOff: couponDetails.percent_off ?? 0 - })) || - null - - const {creditCard} = organization - const dbInvoice = new Invoice({ - id: invoiceId, - amountDue: invoice.amount_due, - createdAt: now, - coupon, - total: invoice.total, - billingLeaderEmails, - creditCard: creditCard ? {...creditCard, last4: String(creditCard.last4)} : undefined, - endAt: fromEpochSeconds(invoice.period_end), - invoiceDate: fromEpochSeconds(invoice.due_date!), - lines: invoiceLineItems, - nextPeriodCharges, - orgId, - orgName: organization.name, - paidAt, - payUrl: invoice.hosted_invoice_url, - picture: organization.picture, - startAt: fromEpochSeconds(invoice.period_start), - startingBalance: invoice.starting_balance, - status, - tier: nextPeriodCharges.interval === 'year' ? 'enterprise' : 'team' - }) - - return r - .table('Invoice') - .insert(dbInvoice, {conflict: 'replace', returnChanges: true})('changes')(0)('new_val') - .run() -} diff --git a/packages/server/billing/helpers/generateUpcomingInvoice.ts b/packages/server/billing/helpers/generateUpcomingInvoice.ts deleted file mode 100644 index ddc4b1b3a12..00000000000 --- a/packages/server/billing/helpers/generateUpcomingInvoice.ts +++ /dev/null @@ -1,19 +0,0 @@ -import {DataLoaderWorker} from '../../graphql/graphql' -import getUpcomingInvoiceId from '../../utils/getUpcomingInvoiceId' -import {getStripeManager} from '../../utils/stripe' -import fetchAllLines from './fetchAllLines' -import generateInvoice from './generateInvoice' - -const generateUpcomingInvoice = async (orgId: string, dataLoader: DataLoaderWorker) => { - const invoiceId = getUpcomingInvoiceId(orgId) - const organization = await dataLoader.get('organizations').loadNonNull(orgId) - const {stripeId} = organization - const manager = getStripeManager() - const [stripeLineItems, upcomingInvoice] = await Promise.all([ - fetchAllLines('upcoming', stripeId), - manager.retrieveUpcomingInvoice(stripeId!) - ]) - return generateInvoice(upcomingInvoice, stripeLineItems, orgId, invoiceId, dataLoader) -} - -export default generateUpcomingInvoice diff --git a/packages/server/billing/stripeWebhookHandler.ts b/packages/server/billing/stripeWebhookHandler.ts index 5233f4ebc1f..e022d6c80ad 100644 --- a/packages/server/billing/stripeWebhookHandler.ts +++ b/packages/server/billing/stripeWebhookHandler.ts @@ -51,23 +51,6 @@ const eventLookup = { stripeInvoicePaid(invoiceId: $invoiceId) } ` - }, - finalized: { - getVars: ({id: invoiceId}: InvoiceEventCallBackArg) => ({invoiceId}), - query: ` - mutation StripeInvoiceFinalized($invoiceId: ID!) { - stripeInvoiceFinalized(invoiceId: $invoiceId) - }` - } - }, - invoiceitem: { - created: { - getVars: ({id: invoiceItemId}: {id: string}) => ({invoiceItemId}), - query: ` - mutation StripeUpdateInvoiceItem($invoiceItemId: ID!) { - stripeUpdateInvoiceItem(invoiceItemId: $invoiceItemId) - } - ` } }, customer: { diff --git a/packages/server/database/rethinkDriver.ts b/packages/server/database/rethinkDriver.ts index 38e4268d917..03dc30c72c3 100644 --- a/packages/server/database/rethinkDriver.ts +++ b/packages/server/database/rethinkDriver.ts @@ -3,15 +3,10 @@ import SlackAuth from '../database/types/SlackAuth' import SlackNotification from '../database/types/SlackNotification' import TeamInvitation from '../database/types/TeamInvitation' import {AnyMeeting, AnyMeetingSettings, AnyMeetingTeamMember} from '../postgres/types/Meeting' -import {ScheduledJobUnion} from '../types/custom' import getRethinkConfig from './getRethinkConfig' import {R} from './stricterR' import AgendaItem from './types/AgendaItem' -import AtlassianAuth from './types/AtlassianAuth' import Comment from './types/Comment' -import FailedAuthRequest from './types/FailedAuthRequest' -import Invoice from './types/Invoice' -import InvoiceItemHook from './types/InvoiceItemHook' import MassInvitation from './types/MassInvitation' import NotificationKickedOut from './types/NotificationKickedOut' import NotificationMeetingStageTimeLimitEnd from './types/NotificationMeetingStageTimeLimitEnd' @@ -33,10 +28,6 @@ export type RethinkSchema = { type: AgendaItem index: 'teamId' | 'meetingId' } - AtlassianAuth: { - type: AtlassianAuth - index: 'atlassianUserId' | 'userId' | 'teamId' - } Comment: { type: Comment index: 'discussionId' @@ -45,26 +36,6 @@ export type RethinkSchema = { type: RetrospectivePrompt index: 'teamId' | 'templateId' } - EmailVerification: { - type: any - index: 'email' | 'token' - } - FailedAuthRequest: { - type: FailedAuthRequest - index: 'email' | 'ip' - } - GQLRequest: { - type: any - index: 'id' - } - Invoice: { - type: Invoice - index: 'orgIdStartAt' - } - InvoiceItemHook: { - type: InvoiceItemHook - index: 'prorationDate' | 'stripeSubscriptionId' - } MassInvitation: { type: MassInvitation index: 'teamMemberId' @@ -112,10 +83,6 @@ export type RethinkSchema = { type: PushInvitation index: 'userId' } - ScheduledJob: { - type: ScheduledJobUnion - index: 'runAt' | 'type' - } SlackAuth: { type: SlackAuth index: 'teamId' | 'userId' diff --git a/packages/server/database/types/Coupon.ts b/packages/server/database/types/Coupon.ts deleted file mode 100644 index c82aff62005..00000000000 --- a/packages/server/database/types/Coupon.ts +++ /dev/null @@ -1,21 +0,0 @@ -interface Input { - id: string - amountOff?: number - name: string - percentOff?: number -} - -export default class Coupon { - id: string - amountOff?: number - name: string - percentOff?: number - - constructor(input: Input) { - const {id, amountOff, name, percentOff} = input - this.id = id - this.amountOff = amountOff - this.percentOff = percentOff - this.name = name - } -} diff --git a/packages/server/database/types/CreditCard.ts b/packages/server/database/types/CreditCard.ts deleted file mode 100644 index 180bff6ea62..00000000000 --- a/packages/server/database/types/CreditCard.ts +++ /dev/null @@ -1,17 +0,0 @@ -interface Input { - brand?: string - expiry?: string - last4?: string | number -} - -export default class CreditCard { - brand: string - expiry: string - last4: string - constructor(input: Input = {}) { - const {brand, expiry, last4} = input - this.brand = brand || 'Unknown brand' - this.expiry = expiry || 'Unknown expiration' - this.last4 = String(last4) || '0000' - } -} diff --git a/packages/server/database/types/Invoice.ts b/packages/server/database/types/Invoice.ts deleted file mode 100644 index ee34077a5de..00000000000 --- a/packages/server/database/types/Invoice.ts +++ /dev/null @@ -1,99 +0,0 @@ -import Coupon from './Coupon' -import CreditCard from './CreditCard' -import InvoiceLineItem from './InvoiceLineItem' -import NextPeriodCharges from './NextPeriodCharges' - -export type InvoiceStatusEnum = 'FAILED' | 'PAID' | 'PENDING' | 'UPCOMING' -export type TierEnum = 'enterprise' | 'starter' | 'team' - -interface Input { - id: string - amountDue: number - createdAt?: Date - coupon?: Coupon | null - total: number - billingLeaderEmails: string[] - creditCard?: CreditCard | null - endAt: Date - invoiceDate: Date - lines: InvoiceLineItem[] - nextPeriodCharges: NextPeriodCharges - orgId: string - orgName?: string | null - paidAt?: Date | null - payUrl?: string | null - picture?: string | null - startAt: Date - startingBalance: number - status: InvoiceStatusEnum - tier: TierEnum -} - -export default class Invoice { - id: string - amountDue: number - createdAt: Date - coupon?: Coupon | null - total: number - billingLeaderEmails: string[] - creditCard?: CreditCard | null - endAt: Date - invoiceDate: Date - lines: InvoiceLineItem[] - nextPeriodCharges: NextPeriodCharges - orgId: string - orgName: string - paidAt: Date | null - payUrl?: string | null - picture: string | null - startAt: Date - startingBalance: number - status: InvoiceStatusEnum - tier: TierEnum - updatedAt?: Date - - constructor(input: Input) { - const { - id, - amountDue, - createdAt, - coupon, - billingLeaderEmails, - creditCard, - endAt, - invoiceDate, - lines, - nextPeriodCharges, - orgId, - orgName, - paidAt, - payUrl, - picture, - startAt, - startingBalance, - status, - total, - tier - } = input - this.id = id - this.amountDue = amountDue - this.createdAt = createdAt || new Date() - this.coupon = coupon - this.total = total - this.billingLeaderEmails = billingLeaderEmails - this.creditCard = creditCard - this.endAt = endAt - this.invoiceDate = invoiceDate - this.lines = lines - this.nextPeriodCharges = nextPeriodCharges - this.orgId = orgId - this.orgName = orgName || 'Unknown Org' - this.paidAt = paidAt || null - this.payUrl = payUrl || undefined - this.picture = picture || null - this.startAt = startAt - this.startingBalance = startingBalance - this.status = status - this.tier = tier - } -} diff --git a/packages/server/database/types/InvoiceItemHook.ts b/packages/server/database/types/InvoiceItemHook.ts deleted file mode 100644 index 39fef3fa112..00000000000 --- a/packages/server/database/types/InvoiceItemHook.ts +++ /dev/null @@ -1,68 +0,0 @@ -import {InvoiceItemType} from 'parabol-client/types/constEnums' -import generateUID from '../../generateUID' - -interface Input { - id?: string - stripeSubscriptionId: string - orgId: string - createdAt?: Date - // true if the hook has not yet been sent to Stripe - isPending?: boolean - // true if not an enterprise plan - isProrated: boolean - // the fixed prorationDate, can be empty if isProrated is false or isPending is true & we want to prorate when processed - prorationDate?: number - previousQuantity?: number - quantity?: number - previousInvoiceItemId?: string - invoiceItemId?: string - type: InvoiceItemType - userId: string -} - -export default class InvoiceItemHook { - id: string - createdAt: Date - invoiceItemId?: string - isPending: boolean - isProrated: boolean - orgId: string - previousInvoiceItemId?: string - previousQuantity?: number - prorationDate?: number - quantity?: number - stripeSubscriptionId: string - type: InvoiceItemType - userId: string - - constructor(input: Input) { - const { - id, - createdAt, - invoiceItemId, - previousInvoiceItemId, - isPending, - isProrated, - orgId, - previousQuantity, - prorationDate, - quantity, - stripeSubscriptionId, - type, - userId - } = input - this.id = id || generateUID() - this.createdAt = createdAt || new Date() - this.invoiceItemId = invoiceItemId - this.previousInvoiceItemId = previousInvoiceItemId - this.isPending = isPending ?? true - this.isProrated = isProrated - this.orgId = orgId - this.previousQuantity = previousQuantity - this.prorationDate = prorationDate - this.quantity = quantity - this.stripeSubscriptionId = stripeSubscriptionId - this.type = type - this.userId = userId - } -} diff --git a/packages/server/database/types/InvoiceLineItem.ts b/packages/server/database/types/InvoiceLineItem.ts deleted file mode 100644 index b4756cdacb3..00000000000 --- a/packages/server/database/types/InvoiceLineItem.ts +++ /dev/null @@ -1,36 +0,0 @@ -import generateUID from '../../generateUID' -import InvoiceLineItemDetail from './InvoiceLineItemDetail' - -export type InvoiceLineItemEnum = - | 'ADDED_USERS' - | 'INACTIVITY_ADJUSTMENTS' - | 'OTHER_ADJUSTMENTS' - | 'REMOVED_USERS' - -interface Input { - id?: string - amount: number - description?: string | null - details?: InvoiceLineItemDetail[] - quantity: number - type: InvoiceLineItemEnum -} - -export default class InvoiceLineItem { - id: string - amount: number - description: string | null - details: InvoiceLineItemDetail[] - quantity: number - type: InvoiceLineItemEnum - - constructor(input: Input) { - const {quantity, amount, id, description, details, type} = input - this.id = id || generateUID() - this.amount = amount - this.description = description || null - this.details = details || [] - this.quantity = quantity - this.type = type - } -} diff --git a/packages/server/database/types/InvoiceLineItemDetail.ts b/packages/server/database/types/InvoiceLineItemDetail.ts deleted file mode 100644 index 20eddb11750..00000000000 --- a/packages/server/database/types/InvoiceLineItemDetail.ts +++ /dev/null @@ -1,27 +0,0 @@ -interface Input { - id: string - amount: number - email: string - endAt?: Date | null - parentId: string - startAt?: Date | null -} - -export default class InvoiceLineItemDetail { - id: string - amount: number - email: string - endAt: Date | null - parentId: string - startAt: Date | null - - constructor(input: Input) { - const {amount, id, startAt, endAt, email, parentId} = input - this.id = id - this.amount = amount - this.startAt = startAt || null - this.email = email - this.endAt = endAt || null - this.parentId = parentId - } -} diff --git a/packages/server/database/types/InvoiceLineItemOtherAdjustments.ts b/packages/server/database/types/InvoiceLineItemOtherAdjustments.ts deleted file mode 100644 index 82fbe09d22b..00000000000 --- a/packages/server/database/types/InvoiceLineItemOtherAdjustments.ts +++ /dev/null @@ -1,15 +0,0 @@ -import InvoiceLineItem, {InvoiceLineItemEnum} from './InvoiceLineItem' - -interface Input { - amount: number - description?: string | null - quantity: number -} - -export default class InvoiceLineItemOtherAdjustments extends InvoiceLineItem { - details = [] - type = 'OTHER_ADJUSTMENTS' as InvoiceLineItemEnum - constructor(input: Input) { - super({...input, type: 'OTHER_ADJUSTMENTS'}) - } -} diff --git a/packages/server/database/types/NextPeriodCharges.ts b/packages/server/database/types/NextPeriodCharges.ts deleted file mode 100644 index fa566feb26e..00000000000 --- a/packages/server/database/types/NextPeriodCharges.ts +++ /dev/null @@ -1,24 +0,0 @@ -type IntervalUnit = 'day' | 'week' | 'month' | 'year' -interface Input { - amount: number - quantity: number - nextPeriodEnd: Date - unitPrice?: number - interval: IntervalUnit -} - -export default class NextPeriodCharges { - amount: number - quantity: number - nextPeriodEnd: Date - unitPrice?: number - interval: IntervalUnit - constructor(input: Input) { - const {amount, quantity, unitPrice, nextPeriodEnd, interval} = input - this.amount = amount - this.quantity = quantity - this.nextPeriodEnd = nextPeriodEnd - this.unitPrice = unitPrice - this.interval = interval - } -} diff --git a/packages/server/database/types/Organization.ts b/packages/server/database/types/Organization.ts index 25e6e3bea63..423ecf139e5 100644 --- a/packages/server/database/types/Organization.ts +++ b/packages/server/database/types/Organization.ts @@ -1,7 +1,7 @@ import generateUID from '../../generateUID' +import {TierEnum} from '../../graphql/public/resolverTypes' +import {CreditCard} from '../../postgres/select' import {defaultTier} from '../../utils/defaultTier' -import CreditCard from './CreditCard' -import {TierEnum} from './Invoice' interface Input { id?: string diff --git a/packages/server/database/types/QuantityChangeLineItem.ts b/packages/server/database/types/QuantityChangeLineItem.ts deleted file mode 100644 index 3c6f90277c5..00000000000 --- a/packages/server/database/types/QuantityChangeLineItem.ts +++ /dev/null @@ -1,5 +0,0 @@ -import InvoiceLineItem from './InvoiceLineItem' - -export default class QuantityChangeLineItem extends InvoiceLineItem { - description = null -} diff --git a/packages/server/database/types/Team.ts b/packages/server/database/types/Team.ts index b5bf6434a84..50ba379daaa 100644 --- a/packages/server/database/types/Team.ts +++ b/packages/server/database/types/Team.ts @@ -1,7 +1,7 @@ import generateUID from '../../generateUID' +import {TierEnum} from '../../graphql/public/resolverTypes' import {TEAM_NAME_LIMIT} from '../../postgres/constants' import {MeetingTypeEnum} from '../../postgres/types/Meeting' -import {TierEnum} from './Invoice' interface Input { id?: string diff --git a/packages/server/database/types/User.ts b/packages/server/database/types/User.ts index 05431d88791..e04364e7747 100644 --- a/packages/server/database/types/User.ts +++ b/packages/server/database/types/User.ts @@ -1,9 +1,9 @@ import {AuthTokenRole} from 'parabol-client/types/constEnums' import generateUID from '../../generateUID' +import {TierEnum} from '../../graphql/public/resolverTypes' import {USER_PREFERRED_NAME_LIMIT} from '../../postgres/constants' import {defaultTier} from '../../utils/defaultTier' import AuthIdentity from './AuthIdentity' -import {TierEnum} from './Invoice' interface Input { id?: string diff --git a/packages/server/dataloader/customLoaderMakers.ts b/packages/server/dataloader/customLoaderMakers.ts index 38e013297ef..6dd43e5141e 100644 --- a/packages/server/dataloader/customLoaderMakers.ts +++ b/packages/server/dataloader/customLoaderMakers.ts @@ -2,7 +2,7 @@ import DataLoader from 'dataloader' import tracer from 'dd-trace' import {Selectable, SqlBool, sql} from 'kysely' import {PARABOL_AI_USER_ID} from '../../client/utils/constants' -import getRethink, {RethinkSchema} from '../database/rethinkDriver' +import getRethink from '../database/rethinkDriver' import {RDatum} from '../database/stricterR' import MeetingSettingsTeamPrompt from '../database/types/MeetingSettingsTeamPrompt' import MeetingTemplate from '../database/types/MeetingTemplate' @@ -26,8 +26,8 @@ import getLatestTaskEstimates from '../postgres/queries/getLatestTaskEstimates' import getMeetingTaskEstimates, { MeetingTaskEstimatesResult } from '../postgres/queries/getMeetingTaskEstimates' -import {selectTeams} from '../postgres/select' -import {OrganizationUser, Team} from '../postgres/types' +import {selectMeetingSettings, selectTeams} from '../postgres/select' +import {MeetingSettings, OrganizationUser, Team} from '../postgres/types' import {AnyMeeting, MeetingTypeEnum} from '../postgres/types/Meeting' import {Logger} from '../utils/Logger' import getRedis from '../utils/getRedis' @@ -288,7 +288,7 @@ export const githubDimensionFieldMaps = (parent: RootDataLoader) => { export const meetingSettingsByType = (parent: RootDataLoader, dependsOn: RegisterDependsOn) => { dependsOn('meetingSettings') - return new DataLoader( + return new DataLoader( async (keys) => { const r = await getRethink() const types = {} as Record @@ -313,7 +313,7 @@ export const meetingSettingsByType = (parent: RootDataLoader, dependsOn: Registe const {teamId, meetingType} = key // until we decide the final shape of the team prompt settings, let's return a temporary hardcoded value if (meetingType === 'teamPrompt') { - return new MeetingSettingsTeamPrompt({teamId}) + return new MeetingSettingsTeamPrompt({teamId}) as any } return docs.find((doc) => doc.teamId === teamId && doc.meetingType === meetingType)! }) @@ -325,6 +325,31 @@ export const meetingSettingsByType = (parent: RootDataLoader, dependsOn: Registe ) } +export const _PGmeetingSettingsByType = (parent: RootDataLoader, dependsOn: RegisterDependsOn) => { + dependsOn('meetingSettings') + return new DataLoader( + async (keys) => { + const res = await selectMeetingSettings() + .where(({eb, refTuple, tuple}) => + eb( + refTuple('teamId', 'meetingType'), + 'in', + keys.map((key) => tuple(key.teamId, key.meetingType)) + ) + ) + .execute() + return keys.map( + (key) => + res.find((doc) => doc.teamId === key.teamId && doc.meetingType === key.meetingType)! + ) + }, + { + ...parent.dataLoaderOptions, + cacheKeyFn: (key) => `${key.teamId}:${key.meetingType}` + } + ) +} + export const organizationApprovedDomainsByOrgId = (parent: RootDataLoader) => { return new DataLoader( async (orgIds) => { diff --git a/packages/server/dataloader/primaryKeyLoaderMakers.ts b/packages/server/dataloader/primaryKeyLoaderMakers.ts index 155e7f34489..a5e46c9e45b 100644 --- a/packages/server/dataloader/primaryKeyLoaderMakers.ts +++ b/packages/server/dataloader/primaryKeyLoaderMakers.ts @@ -6,6 +6,7 @@ import getMeetingTemplatesByIds from '../postgres/queries/getMeetingTemplatesByI import getTemplateRefsByIds from '../postgres/queries/getTemplateRefsByIds' import {getUsersByIds} from '../postgres/queries/getUsersByIds' import { + selectMeetingSettings, selectOrganizations, selectRetroReflections, selectSuggestedAction, @@ -85,3 +86,7 @@ export const templateDimensions = primaryKeyLoaderMaker((ids: readonly string[]) export const suggestedActions = primaryKeyLoaderMaker((ids: readonly string[]) => { return selectSuggestedAction().where('id', 'in', ids).execute() }) + +export const _PGmeetingSettings = primaryKeyLoaderMaker((ids: readonly string[]) => { + return selectMeetingSettings().where('id', 'in', ids).execute() +}) diff --git a/packages/server/dataloader/rethinkPrimaryKeyLoaderMakers.ts b/packages/server/dataloader/rethinkPrimaryKeyLoaderMakers.ts index 5d015a2042a..a0fa13be86a 100644 --- a/packages/server/dataloader/rethinkPrimaryKeyLoaderMakers.ts +++ b/packages/server/dataloader/rethinkPrimaryKeyLoaderMakers.ts @@ -4,7 +4,6 @@ import RethinkPrimaryKeyLoaderMaker from './RethinkPrimaryKeyLoaderMaker' * all rethink dataloader types which also must exist in {@link rethinkDriver/RethinkSchema} */ export const agendaItems = new RethinkPrimaryKeyLoaderMaker('AgendaItem') -export const atlassianAuths = new RethinkPrimaryKeyLoaderMaker('AtlassianAuth') export const comments = new RethinkPrimaryKeyLoaderMaker('Comment') export const reflectPrompts = new RethinkPrimaryKeyLoaderMaker('ReflectPrompt') export const massInvitations = new RethinkPrimaryKeyLoaderMaker('MassInvitation') diff --git a/packages/server/graphql/mutations/helpers/createTeamAndLeader.ts b/packages/server/graphql/mutations/helpers/createTeamAndLeader.ts index f9c9bdf6d73..c7e672d1c69 100644 --- a/packages/server/graphql/mutations/helpers/createTeamAndLeader.ts +++ b/packages/server/graphql/mutations/helpers/createTeamAndLeader.ts @@ -30,6 +30,7 @@ export default async function createTeamAndLeader( const organization = await dataLoader.get('organizations').loadNonNull(orgId) const {tier, trialStartDate} = organization const verifiedTeam = new Team({...newTeam, createdBy: userId, tier, trialStartDate}) + const meetingSettings = [ new MeetingSettingsRetrospective({teamId}), new MeetingSettingsAction({teamId}), @@ -77,6 +78,11 @@ export default async function createTeamAndLeader( .values(suggestedAction) .onConflict((oc) => oc.columns(['userId', 'type']).doNothing()) ) + .with('MeetingSettingsInsert', (qc) => + qc + .insertInto('MeetingSettings') + .values(meetingSettings.map((s) => ({...s, jiraSearchQueries: null}))) + ) .insertInto('TimelineEvent') .values(timelineEvent) .execute(), diff --git a/packages/server/graphql/mutations/helpers/getCCFromCustomer.ts b/packages/server/graphql/mutations/helpers/getCCFromCustomer.ts index 7db3ccbce4e..ec9c082a226 100644 --- a/packages/server/graphql/mutations/helpers/getCCFromCustomer.ts +++ b/packages/server/graphql/mutations/helpers/getCCFromCustomer.ts @@ -38,7 +38,7 @@ export default async function getCCFromCustomer( const expiry = `${expMonth.toString().padStart(2, '0')}/${String(expYear).substr(2)}` return { brand, - last4, + last4: Number(last4), expiry } } diff --git a/packages/server/graphql/mutations/helpers/stripeCardToDBCard.ts b/packages/server/graphql/mutations/helpers/stripeCardToDBCard.ts index f5b433a7974..5a9cd1bb113 100644 --- a/packages/server/graphql/mutations/helpers/stripeCardToDBCard.ts +++ b/packages/server/graphql/mutations/helpers/stripeCardToDBCard.ts @@ -9,6 +9,6 @@ export const stripeCardToDBCard = (card: Stripe.PaymentMethod.Card) => { return { brand: formattedBrand, expiry, - last4 + last4: Number(last4) } } diff --git a/packages/server/graphql/mutations/removePokerTemplate.ts b/packages/server/graphql/mutations/removePokerTemplate.ts index 660b5622633..7d1b9f399a8 100644 --- a/packages/server/graphql/mutations/removePokerTemplate.ts +++ b/packages/server/graphql/mutations/removePokerTemplate.ts @@ -42,12 +42,9 @@ const removePokerTemplate = { const {teamId} = template const [templates, settings] = await Promise.all([ dataLoader.get('meetingTemplatesByType').load({meetingType: 'poker', teamId}), - r - .table('MeetingSettings') - .getAll(teamId, {index: 'teamId'}) - .filter({meetingType: 'poker'}) - .nth(0) - .run() as unknown as MeetingSettingsPoker + dataLoader + .get('meetingSettingsByType') + .load({meetingType: 'poker', teamId}) as any as MeetingSettingsPoker ]) // RESOLUTION @@ -66,6 +63,11 @@ const removePokerTemplate = { if (settings.selectedTemplateId === templateId) { const nextTemplate = templates.find((template) => template.id !== templateId) const nextTemplateId = nextTemplate?.id ?? SprintPokerDefaults.DEFAULT_TEMPLATE_ID + await getKysely() + .updateTable('MeetingSettings') + .set({selectedTemplateId: nextTemplateId}) + .where('id', '=', settingsId) + .execute() await r .table('MeetingSettings') .get(settingsId) @@ -73,6 +75,7 @@ const removePokerTemplate = { selectedTemplateId: nextTemplateId }) .run() + dataLoader.clearAll('meetingSettings') } const data = {templateId, settingsId} diff --git a/packages/server/graphql/mutations/removeReflectTemplate.ts b/packages/server/graphql/mutations/removeReflectTemplate.ts index 2a7f9bc9896..251f0f631a2 100644 --- a/packages/server/graphql/mutations/removeReflectTemplate.ts +++ b/packages/server/graphql/mutations/removeReflectTemplate.ts @@ -2,6 +2,7 @@ import {GraphQLID, GraphQLNonNull} from 'graphql' import {SubscriptionChannel} from 'parabol-client/types/constEnums' import getRethink from '../../database/rethinkDriver' import MeetingSettingsRetrospective from '../../database/types/MeetingSettingsRetrospective' +import getKysely from '../../postgres/getKysely' import removeMeetingTemplate from '../../postgres/queries/removeMeetingTemplate' import {getUserId, isTeamMember} from '../../utils/authorization' import publish from '../../utils/publish' @@ -41,12 +42,9 @@ const removeReflectTemplate = { const {teamId} = template const [templates, settings] = await Promise.all([ dataLoader.get('meetingTemplatesByType').load({meetingType: 'retrospective', teamId}), - r - .table('MeetingSettings') - .getAll(teamId, {index: 'teamId'}) - .filter({meetingType: 'retrospective'}) - .nth(0) - .run() as unknown as MeetingSettingsRetrospective + dataLoader + .get('meetingSettingsByType') + .load({meetingType: 'retrospective', teamId}) as any as MeetingSettingsRetrospective ]) // RESOLUTION @@ -69,6 +67,11 @@ const removeReflectTemplate = { if (settings.selectedTemplateId === templateId) { const nextTemplate = templates.find((template) => template.id !== templateId) const nextTemplateId = nextTemplate?.id ?? 'workingStuckTemplate' + await getKysely() + .updateTable('MeetingSettings') + .set({selectedTemplateId: nextTemplateId}) + .where('id', '=', settingsId) + .execute() await r .table('MeetingSettings') .get(settingsId) @@ -76,6 +79,7 @@ const removeReflectTemplate = { selectedTemplateId: nextTemplateId }) .run() + dataLoader.clearAll('meetingSettings') } const data = {templateId, settingsId} diff --git a/packages/server/graphql/mutations/selectTemplate.ts b/packages/server/graphql/mutations/selectTemplate.ts index 5e0855e6912..861f9a37545 100644 --- a/packages/server/graphql/mutations/selectTemplate.ts +++ b/packages/server/graphql/mutations/selectTemplate.ts @@ -2,6 +2,7 @@ import {GraphQLID, GraphQLNonNull} from 'graphql' import {SubscriptionChannel} from 'parabol-client/types/constEnums' import getRethink from '../../database/rethinkDriver' import MeetingTemplate from '../../database/types/MeetingTemplate' +import getKysely from '../../postgres/getKysely' import {Logger} from '../../utils/Logger' import {getUserId, isTeamMember} from '../../utils/authorization' import publish from '../../utils/publish' @@ -67,7 +68,13 @@ const selectTemplate = { )('changes')(0)('old_val')('id') .default(null) .run() - + await getKysely() + .updateTable('MeetingSettings') + .set({selectedTemplateId}) + .where('teamId', '=', teamId) + .where('meetingType', '=', template.type) + .returning('id') + .executeTakeFirst() // No need to check if a non-null 'meetingSettingsId' was returned - the Activity Library client // will always attempt to update the template, even if it's already selected, and we don't need // to return a 'meetingSettingsId' if no updates took place. diff --git a/packages/server/graphql/mutations/updateRetroMaxVotes.ts b/packages/server/graphql/mutations/updateRetroMaxVotes.ts index 3ec7c8d1f2c..e687a9b9bb8 100644 --- a/packages/server/graphql/mutations/updateRetroMaxVotes.ts +++ b/packages/server/graphql/mutations/updateRetroMaxVotes.ts @@ -5,6 +5,7 @@ import mode from 'parabol-client/utils/mode' import getRethink from '../../database/rethinkDriver' import {RValue} from '../../database/stricterR' import MeetingRetrospective from '../../database/types/MeetingRetrospective' +import getKysely from '../../postgres/getKysely' import {getUserId, isTeamMember} from '../../utils/authorization' import publish from '../../utils/publish' import standardError from '../../utils/standardError' @@ -136,6 +137,15 @@ const updateRetroMaxVotes = { // RESOLUTION await Promise.all([ + getKysely() + .updateTable('MeetingSettings') + .set({ + totalVotes, + maxVotesPerGroup + }) + .where('teamId', '=', teamId) + .where('meetingType', '=', 'retrospective') + .execute(), r .table('MeetingSettings') .getAll(teamId, {index: 'teamId'}) diff --git a/packages/server/graphql/private/mutations/changeEmailDomain.ts b/packages/server/graphql/private/mutations/changeEmailDomain.ts index fc0a3c384e9..5d76d328a40 100644 --- a/packages/server/graphql/private/mutations/changeEmailDomain.ts +++ b/packages/server/graphql/private/mutations/changeEmailDomain.ts @@ -1,6 +1,4 @@ import {sql} from 'kysely' -import {r} from 'rethinkdb-ts' -import {RDatum, RValue} from '../../../database/stricterR' import getKysely from '../../../postgres/getKysely' import getUsersbyDomain from '../../../postgres/queries/getUsersByDomain' import {MutationResolvers} from '../../private/resolverTypes' @@ -81,24 +79,7 @@ const changeEmailDomain: MutationResolvers['changeEmailDomain'] = async ( }) .where('id', 'in', userIdsToUpdate) .returning('id') - .execute(), - r - .table('Invoice') - .filter((row: RDatum) => - row('billingLeaderEmails').contains((email: RValue) => - email.split('@').nth(1).eq(normalizedOldDomain) - ) - ) - .update((row: RDatum) => ({ - billingLeaderEmails: row('billingLeaderEmails').map((email: RValue) => - r.branch( - email.split('@').nth(1).eq(normalizedOldDomain), - email.split('@').nth(0).add(`@${normalizedNewDomain}`), - email - ) - ) - })) - .run() + .execute() ]) const usersUpdatedIds = updatedUserRes.map(({id}) => id) diff --git a/packages/server/graphql/private/mutations/stripeCreateInvoice.ts b/packages/server/graphql/private/mutations/stripeCreateInvoice.ts index 44f2abe9329..bd9721aa55b 100644 --- a/packages/server/graphql/private/mutations/stripeCreateInvoice.ts +++ b/packages/server/graphql/private/mutations/stripeCreateInvoice.ts @@ -1,5 +1,3 @@ -import fetchAllLines from '../../../billing/helpers/fetchAllLines' -import generateInvoice from '../../../billing/helpers/generateInvoice' import updateSubscriptionQuantity from '../../../billing/helpers/updateSubscriptionQuantity' import {isSuperUser} from '../../../utils/authorization' import {getStripeManager} from '../../../utils/stripe' @@ -8,7 +6,7 @@ import {MutationResolvers} from '../resolverTypes' const stripeCreateInvoice: MutationResolvers['stripeCreateInvoice'] = async ( _source, {invoiceId}, - {authToken, dataLoader} + {authToken} ) => { // AUTH if (!isSuperUser(authToken)) { @@ -17,10 +15,7 @@ const stripeCreateInvoice: MutationResolvers['stripeCreateInvoice'] = async ( // RESOLUTION const manager = getStripeManager() - const [stripeLineItems, invoice] = await Promise.all([ - fetchAllLines(invoiceId), - manager.retrieveInvoice(invoiceId) - ]) + const invoice = await manager.retrieveInvoice(invoiceId) const stripeCustomer = await manager.retrieveCustomer(invoice.customer as string) if (stripeCustomer.deleted) { throw new Error('Customer was deleted') @@ -31,11 +26,7 @@ const stripeCreateInvoice: MutationResolvers['stripeCreateInvoice'] = async ( if (!orgId) throw new Error(`orgId not found on metadata for invoice ${invoiceId}`) await updateSubscriptionQuantity(orgId, true) - - await Promise.all([ - generateInvoice(invoice, stripeLineItems, orgId, invoiceId, dataLoader), - manager.updateInvoice(invoiceId, orgId) - ]) + await manager.updateInvoice(invoiceId, orgId) return true } diff --git a/packages/server/graphql/private/mutations/stripeFailPayment.ts b/packages/server/graphql/private/mutations/stripeFailPayment.ts index 023a6037369..4281862845c 100644 --- a/packages/server/graphql/private/mutations/stripeFailPayment.ts +++ b/packages/server/graphql/private/mutations/stripeFailPayment.ts @@ -101,7 +101,6 @@ const stripeFailPayment: MutationResolvers['stripeFailPayment'] = async ( ) await r({ - update: r.table('Invoice').get(invoiceId).update({status: 'FAILED'}), insert: r.table('Notification').insert(notifications) }).run() diff --git a/packages/server/graphql/private/mutations/stripeInvoiceFinalized.ts b/packages/server/graphql/private/mutations/stripeInvoiceFinalized.ts deleted file mode 100644 index 6450f9dc57f..00000000000 --- a/packages/server/graphql/private/mutations/stripeInvoiceFinalized.ts +++ /dev/null @@ -1,56 +0,0 @@ -import getRethink from '../../../database/rethinkDriver' -import {isSuperUser} from '../../../utils/authorization' -import {getStripeManager} from '../../../utils/stripe' -import {MutationResolvers} from '../resolverTypes' - -const stripeInvoiceFinalized: MutationResolvers['stripeInvoiceFinalized'] = async ( - _source, - {invoiceId}, - {authToken, dataLoader} -) => { - const r = await getRethink() - const now = new Date() - - // AUTH - if (!isSuperUser(authToken)) { - throw new Error('Don’t be rude.') - } - - // VALIDATION - const manager = getStripeManager() - const invoice = await manager.retrieveInvoice(invoiceId) - const customerId = invoice.customer as string - - const customer = await manager.retrieveCustomer(customerId) - if (customer.deleted) { - return false - } - const { - livemode, - metadata: {orgId} - } = customer - const org = await dataLoader.get('organizations').load(orgId!) - if (!org) { - if (livemode) { - throw new Error( - `Payment sent cannot be handled. Org ${orgId} does not exist for invoice ${invoiceId}` - ) - } - return false - } - - const {collection_method, hosted_invoice_url} = invoice - if (collection_method !== 'send_invoice') return false - // RESOLUTION - await r - .table('Invoice') - .get(invoiceId) - .update({ - payUrl: hosted_invoice_url, - updatedAt: now - }) - .run() - return true -} - -export default stripeInvoiceFinalized diff --git a/packages/server/graphql/private/mutations/stripeInvoicePaid.ts b/packages/server/graphql/private/mutations/stripeInvoicePaid.ts index 3d533b39b40..6cef500b289 100644 --- a/packages/server/graphql/private/mutations/stripeInvoicePaid.ts +++ b/packages/server/graphql/private/mutations/stripeInvoicePaid.ts @@ -1,4 +1,3 @@ -import getRethink from '../../../database/rethinkDriver' import updateTeamByOrgId from '../../../postgres/queries/updateTeamByOrgId' import {isSuperUser} from '../../../utils/authorization' import {getStripeManager} from '../../../utils/stripe' @@ -9,7 +8,6 @@ const stripeInvoicePaid: MutationResolvers['stripeInvoicePaid'] = async ( {invoiceId}, {authToken, dataLoader} ) => { - const r = await getRethink() const now = new Date() // AUTH @@ -42,31 +40,13 @@ const stripeInvoicePaid: MutationResolvers['stripeInvoicePaid'] = async ( } return false } - const {creditCard} = org // RESOLUTION const teamUpdates = { isPaid: true, updatedAt: now } - await Promise.all([ - r({ - invoice: r - .table('Invoice') - .get(invoiceId) - .update({ - creditCard: creditCard - ? { - ...creditCard, - last4: String(creditCard) - } - : undefined, - paidAt: now, - status: 'PAID' - }) - }).run(), - updateTeamByOrgId(teamUpdates, orgId) - ]) + await Promise.all([updateTeamByOrgId(teamUpdates, orgId)]) return true } diff --git a/packages/server/graphql/private/mutations/stripeSucceedPayment.ts b/packages/server/graphql/private/mutations/stripeSucceedPayment.ts index 76c93a60856..40153959a2a 100644 --- a/packages/server/graphql/private/mutations/stripeSucceedPayment.ts +++ b/packages/server/graphql/private/mutations/stripeSucceedPayment.ts @@ -1,4 +1,3 @@ -import getRethink from '../../../database/rethinkDriver' import updateTeamByOrgId from '../../../postgres/queries/updateTeamByOrgId' import {isSuperUser} from '../../../utils/authorization' import {getStripeManager} from '../../../utils/stripe' @@ -9,7 +8,6 @@ const stripeSucceedPayment: MutationResolvers['stripeSucceedPayment'] = async ( {invoiceId}, {authToken, dataLoader} ) => { - const r = await getRethink() const now = new Date() // AUTH @@ -42,31 +40,13 @@ const stripeSucceedPayment: MutationResolvers['stripeSucceedPayment'] = async ( } return false } - const {creditCard} = org // RESOLUTION const teamUpdates = { isPaid: true, updatedAt: now } - await Promise.all([ - r({ - invoice: r - .table('Invoice') - .get(invoiceId) - .update({ - creditCard: creditCard - ? { - ...creditCard, - last4: String(creditCard.last4) - } - : undefined, - paidAt: now, - status: 'PAID' - }) - }).run(), - updateTeamByOrgId(teamUpdates, orgId) - ]) + await Promise.all([updateTeamByOrgId(teamUpdates, orgId)]) return true } diff --git a/packages/server/graphql/private/mutations/stripeUpdateInvoiceItem.ts b/packages/server/graphql/private/mutations/stripeUpdateInvoiceItem.ts deleted file mode 100644 index 463e98f1ba1..00000000000 --- a/packages/server/graphql/private/mutations/stripeUpdateInvoiceItem.ts +++ /dev/null @@ -1,101 +0,0 @@ -import Stripe from 'stripe' -import getRethink from '../../../database/rethinkDriver' -import {RValue} from '../../../database/stricterR' -import InvoiceItemHook from '../../../database/types/InvoiceItemHook' -import {isSuperUser} from '../../../utils/authorization' -import sendToSentry from '../../../utils/sendToSentry' -import {getStripeManager} from '../../../utils/stripe' -import {MutationResolvers} from '../resolverTypes' - -const MAX_STRIPE_DELAY = 3 // seconds - -const getPossibleHooks = async (invoiceItem: Stripe.InvoiceItem) => { - const r = await getRethink() - const { - subscription, - period: {start}, - amount, - quantity - } = invoiceItem - const isRefund = amount < 0 - const invoiceItemName = isRefund ? 'previousInvoiceItemId' : 'invoiceItemId' - const quantityName = isRefund ? 'previousQuantity' : 'quantity' - - const proratedHooks = await r - .table('InvoiceItemHook') - .between(start - MAX_STRIPE_DELAY, start, {index: 'prorationDate', rightBound: 'closed'}) - .filter({ - [quantityName]: quantity, - stripeSubscriptionId: subscription as string - }) - .filter((row: RValue) => row(invoiceItemName).default(null).eq(null)) - .orderBy(r.desc('prorationDate')) - .run() - if (proratedHooks.length) return proratedHooks - return r - .table('InvoiceItemHook') - .getAll(subscription as string, {index: 'stripeSubscriptionId'}) - .filter({[quantityName]: quantity, isProrated: false}) - .filter((row: RValue) => row(invoiceItemName).default(null).eq(null)) - .orderBy(r.desc('createdAt')) - .run() -} - -const getBestHook = (possibleHooks: InvoiceItemHook[]) => { - if (possibleHooks.length === 1) return possibleHooks[0]! - const firstHook = possibleHooks[possibleHooks.length - 1]! - const {id: hookId} = firstHook - sendToSentry(new Error('Imperfect invoice item hook selected'), {tags: {hookId}}) - return firstHook -} - -const tagInvoiceItemWithHook = async (invoiceItem: Stripe.InvoiceItem): Promise => { - const r = await getRethink() - const {id: invoiceItemId, amount} = invoiceItem - const isRefund = amount < 0 - const invoiceItemName = isRefund ? 'previousInvoiceItemId' : 'invoiceItemId' - const possibleHooks = await getPossibleHooks(invoiceItem) - if (possibleHooks.length === 0) { - sendToSentry(new Error('No hooks found invoice item'), {tags: {invoiceItemId}}) - return false - } - - const hook = getBestHook(possibleHooks) - const {id: hookId, type, userId} = hook - const updatedRecord = await r - .table('InvoiceItemHook') - .get(hookId) - .update( - (row: RValue) => ({ - [invoiceItemName]: row(invoiceItemName).default(invoiceItemId) - }), - {returnChanges: true} - )('changes')(0)('new_val') - .default(null) - .run() - - if (!updatedRecord) { - // another webhook already picked this one, try again - return tagInvoiceItemWithHook(invoiceItem) - } - - const manager = getStripeManager() - await manager.updateInvoiceItem(invoiceItemId, type, userId, hookId) - return true -} - -const stripeUpdateInvoiceItem: MutationResolvers['stripeUpdateInvoiceItem'] = async ( - _source, - {invoiceItemId}, - {authToken} -) => { - // AUTH - if (!isSuperUser(authToken)) { - throw new Error('Don’t be rude.') - } - const manager = getStripeManager() - const invoiceItem = await manager.retrieveInvoiceItem(invoiceItemId) - return tagInvoiceItemWithHook(invoiceItem) -} - -export default stripeUpdateInvoiceItem diff --git a/packages/server/graphql/private/typeDefs/Mutation.graphql b/packages/server/graphql/private/typeDefs/Mutation.graphql index 0c96e5e2c37..8aad93abd57 100644 --- a/packages/server/graphql/private/typeDefs/Mutation.graphql +++ b/packages/server/graphql/private/typeDefs/Mutation.graphql @@ -304,26 +304,6 @@ type Mutation { subscriptionId: ID! ): Boolean - """ - When a new invoiceitem is sent from stripe, tag it with metadata - """ - stripeUpdateInvoiceItem( - """ - The stripe invoice ID - """ - invoiceItemId: ID! - ): Boolean - - """ - An invice has been sent from stripe, meaning it is finalized - """ - stripeInvoiceFinalized( - """ - The stripe invoice ID - """ - invoiceId: ID! - ): Boolean - """ add/remove user(s) to/from the watchlist so that we start/stop recording their sessions """ diff --git a/packages/server/graphql/public/fields/invoices.ts b/packages/server/graphql/public/fields/invoices.ts new file mode 100644 index 00000000000..9bb4050f4f3 --- /dev/null +++ b/packages/server/graphql/public/fields/invoices.ts @@ -0,0 +1,75 @@ +import makeAppURL from '../../../../client/utils/makeAppURL' +import appOrigin from '../../../appOrigin' +import {getUserId, isUserBillingLeader} from '../../../utils/authorization' +import {fromEpochSeconds} from '../../../utils/epochTime' +import {getStripeManager} from '../../../utils/stripe' +import {Invoice, InvoiceStatusEnum, UserResolvers} from '../resolverTypes' + +export const invoices: NonNullable = async ( + _source, + {orgId}, + {authToken, dataLoader} +) => { + // AUTH + const viewerId = getUserId(authToken) + if (!(await isUserBillingLeader(viewerId, orgId, dataLoader))) { + return { + edges: [], + pageInfo: { + hasNextPage: false, + hasPreviousPage: false + } + } + } + + // RESOLUTION + const org = await dataLoader.get('organizations').loadNonNull(orgId) + const {stripeId, stripeSubscriptionId} = org + if (!stripeId || !stripeSubscriptionId) + // the subscription is necessary because if they downgraded we don't want to fetch invoices + return {edges: [], pageInfo: {hasNextPage: false, hasPreviousPage: false}} + const manager = getStripeManager() + + const [session, upcomingInvoice, invoices] = await Promise.all([ + manager.stripe.billingPortal.sessions.create({ + customer: stripeId, + return_url: makeAppURL(appOrigin, `me/organizations/${orgId}/billing`) + }), + manager.retrieveUpcomingInvoice(stripeId), + manager.listInvoices(stripeId) + ]) + const parabolUpcomingInvoice: Invoice = { + id: `upcoming_${orgId}`, + dueAt: fromEpochSeconds(upcomingInvoice.due_date!), + total: upcomingInvoice.total, + payUrl: session.url, + status: 'UPCOMING' + } + + const parabolPastInvoices: Invoice[] = invoices.data.map((stripeInvoice) => { + const {id, due_date, total, status: stripeStatus} = stripeInvoice + const status: InvoiceStatusEnum = + stripeStatus === 'uncollectible' ? 'FAILED' : stripeStatus === 'paid' ? 'PAID' : 'PENDING' + return { + id, + dueAt: fromEpochSeconds(due_date!), + total, + payUrl: session.url, + status + } + }) + const edges = [parabolUpcomingInvoice, ...parabolPastInvoices].map((node) => ({ + cursor: node.dueAt, + node + })) + const firstEdge = edges[0] + return { + edges, + pageInfo: { + startCursor: firstEdge && firstEdge.cursor, + endCursor: firstEdge && edges[edges.length - 1]!.cursor, + hasNextPage: invoices.has_more, + hasPreviousPage: false + } + } +} diff --git a/packages/server/graphql/public/mutations/setMeetingSettings.ts b/packages/server/graphql/public/mutations/setMeetingSettings.ts index 0b0cc44a899..92c339d1b41 100644 --- a/packages/server/graphql/public/mutations/setMeetingSettings.ts +++ b/packages/server/graphql/public/mutations/setMeetingSettings.ts @@ -2,11 +2,16 @@ import {SubscriptionChannel} from 'parabol-client/types/constEnums' import {isNotNull} from 'parabol-client/utils/predicates' import getRethink from '../../../database/rethinkDriver' import {RValue} from '../../../database/stricterR' -import {analytics, MeetingSettings} from '../../../utils/analytics/analytics' +import getKysely from '../../../postgres/getKysely' +import {MeetingSettings} from '../../../postgres/types' +import { + analytics, + MeetingSettings as MeetingSettingsAnalytics +} from '../../../utils/analytics/analytics' import {getUserId} from '../../../utils/authorization' import publish from '../../../utils/publish' import standardError from '../../../utils/standardError' -import {MutationResolvers} from '../resolverTypes' +import {MutationResolvers, NewMeetingPhaseTypeEnum} from '../resolverTypes' const setMeetingSettings: MutationResolvers['setMeetingSettings'] = async ( _source, @@ -19,13 +24,13 @@ const setMeetingSettings: MutationResolvers['setMeetingSettings'] = async ( // AUTH const viewerId = getUserId(authToken) - const settings = await r.table('MeetingSettings').get(settingsId).run() + const settings = (await dataLoader.get('meetingSettings').load(settingsId)) as MeetingSettings if (!settings) { return standardError(new Error('Settings not found'), {userId: viewerId}) } // RESOLUTION - const {teamId, meetingType} = settings + const {teamId, meetingType, phaseTypes} = settings const [team, viewer] = await Promise.all([ dataLoader.get('teams').loadNonNull(teamId), dataLoader.get('users').loadNonNull(viewerId) @@ -34,7 +39,27 @@ const setMeetingSettings: MutationResolvers['setMeetingSettings'] = async ( const {featureFlags} = organization const hasTranscriptFlag = featureFlags?.includes('zoomTranscription') - const meetingSettings = {} as MeetingSettings + const meetingSettings = {} as MeetingSettingsAnalytics + const firstPhases: NewMeetingPhaseTypeEnum[] = [] + if (checkinEnabled || (checkinEnabled !== false && phaseTypes.includes('checkin'))) { + firstPhases.push('checkin') + } + if (teamHealthEnabled || (teamHealthEnabled !== false && phaseTypes.includes('TEAM_HEALTH'))) { + firstPhases.push('TEAM_HEALTH') + } + const nextSettings = { + phaseTypes: [ + ...firstPhases, + ...phaseTypes.filter((phase) => phase !== 'checkin' && phase !== 'TEAM_HEALTH') + ], + disableAnonymity: isNotNull(disableAnonymity) ? disableAnonymity : settings.disableAnonymity, + videoMeetingURL: hasTranscriptFlag + ? isNotNull(videoMeetingURL) + ? videoMeetingURL + : settings.videoMeetingURL + : null + } + await r .table('MeetingSettings') .get(settingsId) @@ -74,8 +99,20 @@ const setMeetingSettings: MutationResolvers['setMeetingSettings'] = async ( }) .run() + await getKysely() + .updateTable('MeetingSettings') + .set(nextSettings) + .where('id', '=', settings.id) + .execute() + dataLoader.clearAll('meetingSettings') + const data = {settingsId} - analytics.meetingSettingsChanged(viewer, teamId, meetingType, meetingSettings) + analytics.meetingSettingsChanged(viewer, teamId, meetingType, { + disableAnonymity: nextSettings.disableAnonymity, + videoMeetingURL: nextSettings.videoMeetingURL, + hasIcebreaker: nextSettings.phaseTypes.includes('checkin'), + hasTeamHealth: nextSettings.phaseTypes.includes('TEAM_HEALTH') + }) publish(SubscriptionChannel.TEAM, teamId, 'SetMeetingSettingsPayload', data, subOptions) return data } diff --git a/packages/server/graphql/public/mutations/startRetrospective.ts b/packages/server/graphql/public/mutations/startRetrospective.ts index b4c7ba3ddad..81b5a500f0d 100644 --- a/packages/server/graphql/public/mutations/startRetrospective.ts +++ b/packages/server/graphql/public/mutations/startRetrospective.ts @@ -24,6 +24,7 @@ const startRetrospective: MutationResolvers['startRetrospective'] = async ( {authToken, socketId: mutatorId, dataLoader} ) => { const r = await getRethink() + const pg = getKysely() const operationId = dataLoader.share() const subOptions = {mutatorId, operationId} const DUPLICATE_THRESHOLD = 3000 @@ -113,7 +114,13 @@ const startRetrospective: MutationResolvers['startRetrospective'] = async ( .update({ videoMeetingURL: null }) - .run() + .run(), + videoMeetingURL && + pg + .updateTable('MeetingSettings') + .set({videoMeetingURL: null}) + .where('id', '=', meetingSettingsId) + .execute() ]) if (meetingSeries) { // meeting was modified if a new meeting series was created @@ -133,7 +140,6 @@ const startRetrospective: MutationResolvers['startRetrospective'] = async ( dataLoader }) if (meetingSeries && gcalSeriesId) { - const pg = getKysely() await pg .updateTable('MeetingSeries') .set({gcalSeriesId}) diff --git a/packages/server/graphql/public/typeDefs/Coupon.graphql b/packages/server/graphql/public/typeDefs/Coupon.graphql deleted file mode 100644 index 4e32dd49e01..00000000000 --- a/packages/server/graphql/public/typeDefs/Coupon.graphql +++ /dev/null @@ -1,24 +0,0 @@ -""" -The discount coupon from Stripe, if any -""" -type Coupon { - """ - The ID of the discount coupon from Stripe - """ - id: String! - - """ - The amount off the invoice, if any - """ - amountOff: Int - - """ - The name of the discount coupon from Stripe - """ - name: String! - - """ - The percent off the invoice, if any - """ - percentOff: Int -} diff --git a/packages/server/graphql/public/typeDefs/Invoice.graphql b/packages/server/graphql/public/typeDefs/Invoice.graphql index 9d046fffc4d..65b47d98b34 100644 --- a/packages/server/graphql/public/typeDefs/Invoice.graphql +++ b/packages/server/graphql/public/typeDefs/Invoice.graphql @@ -8,94 +8,19 @@ type Invoice { id: ID! """ - The tier this invoice pays for + The datetime the invoice is due """ - tier: TierEnum! - - """ - The amount the card will be charged (total + startingBalance with a min value of 0) - """ - amountDue: Float! - - """ - The datetime the invoice was first generated - """ - createdAt: DateTime! - - """ - The discount coupon information from Stripe, if any discount applied - """ - coupon: Coupon + dueAt: DateTime! """ The total amount for the invoice (in USD) """ total: Float! - """ - The emails the invoice was sent to - """ - billingLeaderEmails: [Email!]! - - """ - the card used to pay the invoice - """ - creditCard: CreditCard - - """ - The timestamp for the end of the billing cycle - """ - endAt: DateTime! - - """ - The date the invoice was created - """ - invoiceDate: DateTime! - - """ - An invoice line item for previous month adjustments - """ - lines: [InvoiceLineItem!]! - - """ - The details that comprise the charges for next month - """ - nextPeriodCharges: NextPeriodCharges! - - """ - *The organization id to charge - """ - orgId: ID! - - """ - The persisted name of the org as it was when invoiced - """ - orgName: String! - - """ - the datetime the invoice was successfully paid - """ - paidAt: DateTime - """ The URL to pay via stripe if payment was not collected in app """ - payUrl: String - - """ - The picture of the organization - """ - picture: URL - - """ - The timestamp for the beginning of the billing cycle - """ - startAt: DateTime! - - """ - The balance on the customer account (in cents) - """ - startingBalance: Float! + payUrl: String! """ the status of the invoice. starts as pending, moves to paid or unpaid depending on if the payment succeeded diff --git a/packages/server/graphql/public/typeDefs/InvoiceLineItem.graphql b/packages/server/graphql/public/typeDefs/InvoiceLineItem.graphql deleted file mode 100644 index c8b84bbed0c..00000000000 --- a/packages/server/graphql/public/typeDefs/InvoiceLineItem.graphql +++ /dev/null @@ -1,34 +0,0 @@ -""" -A single line item charge on the invoice -""" -type InvoiceLineItem { - """ - The unique line item id - """ - id: ID! - - """ - The amount for the line item (in USD) - """ - amount: Float! - - """ - A description of the charge. Only present if we have no idea what the charge is - """ - description: String - - """ - Array of user activity line items that roll up to total activity (add/leave/pause/unpause) - """ - details: [InvoiceLineItemDetails!]! - - """ - The total number of days that all org users have been inactive during the billing cycle - """ - quantity: Int - - """ - The line item type for a monthly billing invoice - """ - type: InvoiceLineItemEnum! -} diff --git a/packages/server/graphql/public/typeDefs/InvoiceLineItemDetails.graphql b/packages/server/graphql/public/typeDefs/InvoiceLineItemDetails.graphql deleted file mode 100644 index 4dea9f99a7d..00000000000 --- a/packages/server/graphql/public/typeDefs/InvoiceLineItemDetails.graphql +++ /dev/null @@ -1,34 +0,0 @@ -""" -The per-user-action line item details, -""" -type InvoiceLineItemDetails { - """ - The unique detailed line item id - """ - id: ID! - - """ - The amount for the line item (in USD) - """ - amount: Float! - - """ - The email affected by this line item change - """ - email: Email! - - """ - End of the event. Only present if a pause action gets matched up with an unpause action - """ - endAt: DateTime - - """ - The parent line item id - """ - parentId: ID! - - """ - The timestamp for the beginning of the period of no charge - """ - startAt: DateTime -} diff --git a/packages/server/graphql/public/typeDefs/InvoiceLineItemEnum.graphql b/packages/server/graphql/public/typeDefs/InvoiceLineItemEnum.graphql deleted file mode 100644 index 70402ffab9e..00000000000 --- a/packages/server/graphql/public/typeDefs/InvoiceLineItemEnum.graphql +++ /dev/null @@ -1,9 +0,0 @@ -""" -A big picture line item -""" -enum InvoiceLineItemEnum { - ADDED_USERS - INACTIVITY_ADJUSTMENTS - OTHER_ADJUSTMENTS - REMOVED_USERS -} diff --git a/packages/server/graphql/public/typeDefs/NextPeriodCharges.graphql b/packages/server/graphql/public/typeDefs/NextPeriodCharges.graphql deleted file mode 100644 index 2a288677fff..00000000000 --- a/packages/server/graphql/public/typeDefs/NextPeriodCharges.graphql +++ /dev/null @@ -1,29 +0,0 @@ -""" -A single line item for the charges for next month -""" -type NextPeriodCharges { - """ - The amount for the line item (in USD) - """ - amount: Float! - - """ - The datetime the next period will end - """ - nextPeriodEnd: DateTime! - - """ - The total number of days that all org users have been inactive during the billing cycle - """ - quantity: Int! - - """ - The per-seat monthly price of the subscription (in dollars), null if invoice is not per-seat - """ - unitPrice: Float - - """ - "year" if enterprise, else "month" for pro - """ - interval: String -} diff --git a/packages/server/graphql/public/typeDefs/User.graphql b/packages/server/graphql/public/typeDefs/User.graphql index 6acd37dfc59..65469d0d6c8 100644 --- a/packages/server/graphql/public/typeDefs/User.graphql +++ b/packages/server/graphql/public/typeDefs/User.graphql @@ -63,7 +63,7 @@ type User { The id of the organization """ orgId: ID! - ): InvoiceConnection + ): InvoiceConnection! """ true if the user is a billing leader on any organization, else false @@ -397,12 +397,6 @@ type User { Any super power given to the user via a super user """ featureFlags: UserFeatureFlags! - invoiceDetails( - """ - The id of the invoice - """ - invoiceId: ID! - ): Invoice """ url of user’s profile picture diff --git a/packages/server/graphql/public/types/User.ts b/packages/server/graphql/public/types/User.ts index 468f032903b..d1f63613754 100644 --- a/packages/server/graphql/public/types/User.ts +++ b/packages/server/graphql/public/types/User.ts @@ -5,43 +5,32 @@ import MeetingMemberId from 'parabol-client/shared/gqlIds/MeetingMemberId' import isTaskPrivate from 'parabol-client/utils/isTaskPrivate' import {isNotNull} from 'parabol-client/utils/predicates' import toTeamMemberId from 'parabol-client/utils/relay/toTeamMemberId' -import {Threshold} from '../../../../client/types/constEnums' import { AUTO_GROUPING_THRESHOLD, MAX_REDUCTION_PERCENTAGE, MAX_RESULT_GROUP_SIZE } from '../../../../client/utils/constants' import groupReflections from '../../../../client/utils/smartGroup/groupReflections' -import fetchAllLines from '../../../billing/helpers/fetchAllLines' -import generateInvoice from '../../../billing/helpers/generateInvoice' -import generateUpcomingInvoice from '../../../billing/helpers/generateUpcomingInvoice' import getRethink from '../../../database/rethinkDriver' import {RDatum, RValue} from '../../../database/stricterR' -import Invoice from '../../../database/types/Invoice' import MeetingMemberType from '../../../database/types/MeetingMember' import MeetingTemplate from '../../../database/types/MeetingTemplate' import Task from '../../../database/types/Task' import getKysely from '../../../postgres/getKysely' -import { - getUserId, - isSuperUser, - isTeamMember, - isUserBillingLeader -} from '../../../utils/authorization' +import {getUserId, isSuperUser, isTeamMember} from '../../../utils/authorization' import getDomainFromEmail from '../../../utils/getDomainFromEmail' import getMonthlyStreak from '../../../utils/getMonthlyStreak' import getRedis from '../../../utils/getRedis' import {getSSOMetadataFromURL} from '../../../utils/getSSOMetadataFromURL' import sendToSentry from '../../../utils/sendToSentry' import standardError from '../../../utils/standardError' -import {getStripeManager} from '../../../utils/stripe' import errorFilter from '../../errorFilter' import {DataLoaderWorker} from '../../graphql' import isValid from '../../isValid' import connectionFromTasks from '../../queries/helpers/connectionFromTasks' import connectionFromTemplateArray from '../../queries/helpers/connectionFromTemplateArray' -import makeUpcomingInvoice from '../../queries/helpers/makeUpcomingInvoice' import {getFeatureTier} from '../../types/helpers/getFeatureTier' +import {invoices} from '../fields/invoices' import getSignOnURL from '../mutations/helpers/SAMLHelpers/getSignOnURL' import {ReqResolvers} from './ReqResolvers' @@ -92,68 +81,7 @@ const User: ReqResolvers<'User'> = { if (!isSuperUser(authToken) && !viewerOrganizationUser) return null return organization }, - invoices: async (_source, {orgId, first, after}, {authToken, dataLoader}) => { - const r = await getRethink() - - // AUTH - const viewerId = getUserId(authToken) - if (!(await isUserBillingLeader(viewerId, orgId, dataLoader))) { - // standardError(new Error('Not organization lead'), {userId: viewerId}) - return { - edges: [], - pageInfo: { - hasNextPage: false, - hasPreviousPage: false - } - } - } - - // RESOLUTION - const {stripeId} = await dataLoader.get('organizations').loadNonNull(orgId) - const dbAfter = after ? new Date(after) : r.maxval - const [tooManyInvoices, orgUsers] = await Promise.all([ - r - .table('Invoice') - .between([orgId, r.minval], [orgId, dbAfter], { - index: 'orgIdStartAt', - leftBound: 'open', - rightBound: 'closed' - }) - .filter((invoice: RDatum) => invoice('status').ne('UPCOMING').and(invoice('total').ne(0))) - // it's possible that stripe gives the same startAt to 2 invoices (the first $5 charge & the next) - // break ties based on when created. In the future, we might want to consider using the created_at provided by stripe instead of our own - .orderBy(r.desc('startAt'), r.desc('createdAt')) - .limit(first + 1) - .run(), - dataLoader.get('organizationUsersByOrgId').load(orgId) - ]) - const activeOrgUsers = orgUsers.filter(({inactive}) => !inactive) - const orgUserCount = activeOrgUsers.length - const org = await dataLoader.get('organizations').loadNonNull(orgId) - const upcomingInvoice = after - ? undefined - : await makeUpcomingInvoice(org, orgUserCount, stripeId) - const extraInvoices: Invoice[] = tooManyInvoices || [] - const paginatedInvoices = after ? extraInvoices.slice(1) : extraInvoices - const allInvoices = upcomingInvoice - ? [upcomingInvoice, ...paginatedInvoices] - : paginatedInvoices - const nodes = allInvoices.slice(0, first) - const edges = nodes.map((node) => ({ - cursor: node.startAt, - node - })) - const firstEdge = edges[0] - return { - edges, - pageInfo: { - startCursor: firstEdge && firstEdge.cursor, - endCursor: firstEdge && edges[edges.length - 1]!.cursor, - hasNextPage: extraInvoices.length + (upcomingInvoice ? 1 : 0) > first, - hasPreviousPage: false - } - } - }, + invoices, archivedTasks: async (_source, {first, after, teamId}, {authToken}) => { const r = await getRethink() @@ -755,32 +683,6 @@ const User: ReqResolvers<'User'> = { featureFlags: ({featureFlags}) => { return Object.fromEntries(featureFlags.map((flag) => [flag as any, true])) }, - invoiceDetails: async (_source, {invoiceId}, {authToken, dataLoader}) => { - const r = await getRethink() - const now = new Date() - - const viewerId = getUserId(authToken) - const isUpcoming = invoiceId.startsWith('upcoming_') - const currentInvoice = await r.table('Invoice').get(invoiceId).default(null).run() - const orgId = (currentInvoice && currentInvoice.orgId) || invoiceId.substring(9) // remove 'upcoming_' - if (!(await isUserBillingLeader(viewerId, orgId, dataLoader))) { - standardError(new Error('Not organization lead'), {userId: viewerId}) - return null - } - if (currentInvoice) { - const invalidAt = new Date( - currentInvoice.createdAt.getTime() + Threshold.UPCOMING_INVOICE_TIME_VALID - ) - if (invalidAt > now) return currentInvoice - } - if (isUpcoming) { - return generateUpcomingInvoice(orgId, dataLoader) - } - const manager = getStripeManager() - const stripeLineItems = await fetchAllLines(invoiceId) - const invoice = await manager.retrieveInvoice(invoiceId) - return generateInvoice(invoice, stripeLineItems, orgId, invoiceId, dataLoader) - }, availableTemplates: async ({id: userId}, {first, after, type}, {authToken, dataLoader}) => { const viewerId = getUserId(authToken) const user = await dataLoader.get('users').loadNonNull(userId) diff --git a/packages/server/graphql/queries/helpers/makeUpcomingInvoice.ts b/packages/server/graphql/queries/helpers/makeUpcomingInvoice.ts deleted file mode 100644 index 891abebfa89..00000000000 --- a/packages/server/graphql/queries/helpers/makeUpcomingInvoice.ts +++ /dev/null @@ -1,78 +0,0 @@ -import dayjs from 'dayjs' -import Stripe from 'stripe' -import Invoice from '../../../database/types/Invoice' -import {Organization} from '../../../postgres/types' -import {fromEpochSeconds} from '../../../utils/epochTime' -import getUpcomingInvoiceId from '../../../utils/getUpcomingInvoiceId' -import {getStripeManager} from '../../../utils/stripe' -import StripeManager from '../../../utils/stripe/StripeManager' - -export default async function makeUpcomingInvoice( - org: Organization, - quantity: number, - stripeId?: string | null -): Promise { - if (!stripeId) return undefined - const manager = getStripeManager() - let stripeInvoice: Stripe.Invoice - let sources: Stripe.Response> - try { - ;[stripeInvoice, sources] = await Promise.all([ - manager.retrieveUpcomingInvoice(stripeId), - manager.listSources(stripeId) - ]) - } catch (e) { - // useful for debugging prod accounts in dev - return undefined - } - const cardSource = sources.data.find((source): source is Stripe.Card => source.object === 'card') - const creditCard = cardSource - ? { - brand: cardSource.brand, - last4: cardSource.last4, - expiry: dayjs(`${cardSource.exp_year}-${cardSource.exp_month}-01`).format('MM/YY') - } - : undefined - - const subscription = stripeInvoice.lines.data.find( - ({plan}) => plan?.id === StripeManager.TEAM_PRICE_APP_ID - ) - if (subscription && subscription.quantity !== quantity) { - const {subscription_item: lineitemId} = subscription - await manager.updateSubscriptionItemQuantity(lineitemId!, quantity) - stripeInvoice = await manager.retrieveUpcomingInvoice(stripeId) - } - - const {id: orgId, tier, name: orgName, picture} = org - const unitPrice = subscription?.plan?.amount ?? 0 - const amount = unitPrice * quantity - - return { - id: getUpcomingInvoiceId(orgId), - amountDue: stripeInvoice.amount_due, - creditCard, - total: stripeInvoice.total, - endAt: fromEpochSeconds(stripeInvoice.period_end), - invoiceDate: fromEpochSeconds(stripeInvoice.due_date!), - orgId, - startAt: fromEpochSeconds(stripeInvoice.period_start), - startingBalance: stripeInvoice.starting_balance, - status: 'UPCOMING', - createdAt: fromEpochSeconds(stripeInvoice.period_start), - billingLeaderEmails: [], - lines: [], - orgName, - tier, - paidAt: null, - picture: picture ?? null, - nextPeriodCharges: { - amount, - quantity, - nextPeriodEnd: fromEpochSeconds( - stripeInvoice.period_end - stripeInvoice.period_start + stripeInvoice.period_end - ), - unitPrice, - interval: subscription?.plan?.interval ?? 'month' - } - } -} diff --git a/packages/server/graphql/queries/helpers/resolveSelectedTemplate.ts b/packages/server/graphql/queries/helpers/resolveSelectedTemplate.ts index 4f09128912e..5d556ffdf3b 100644 --- a/packages/server/graphql/queries/helpers/resolveSelectedTemplate.ts +++ b/packages/server/graphql/queries/helpers/resolveSelectedTemplate.ts @@ -1,6 +1,7 @@ import getRethink from '../../../database/rethinkDriver' import MeetingSettingsPoker from '../../../database/types/MeetingSettingsPoker' import MeetingSettingsRetrospective from '../../../database/types/MeetingSettingsRetrospective' +import getKysely from '../../../postgres/getKysely' import {GQLContext} from '../../graphql' const resolveSelectedTemplate = @@ -23,6 +24,12 @@ const resolveSelectedTemplate = .get(settingsId) .update({selectedTemplateId: fallbackTemplateId}) .run() + await getKysely() + .updateTable('MeetingSettings') + .set({selectedTemplateId: fallbackTemplateId}) + .where('id', '=', settingsId) + .execute() + return dataLoader.get('meetingTemplates').loadNonNull(fallbackTemplateId) } diff --git a/packages/server/graphql/types/Coupon.ts b/packages/server/graphql/types/Coupon.ts deleted file mode 100644 index 9da325d34bf..00000000000 --- a/packages/server/graphql/types/Coupon.ts +++ /dev/null @@ -1,27 +0,0 @@ -import {GraphQLInt, GraphQLNonNull, GraphQLObjectType, GraphQLString} from 'graphql' -import {GQLContext} from '../graphql' - -const Coupon = new GraphQLObjectType({ - name: 'Coupon', - description: 'The discount coupon from Stripe, if any', - fields: () => ({ - id: { - type: new GraphQLNonNull(GraphQLString), - description: 'The ID of the discount coupon from Stripe' - }, - amountOff: { - type: GraphQLInt, - description: 'The amount off the invoice, if any' - }, - name: { - type: new GraphQLNonNull(GraphQLString), - description: 'The name of the discount coupon from Stripe' - }, - percentOff: { - type: GraphQLInt, - description: 'The percent off the invoice, if any' - } - }) -}) - -export default Coupon diff --git a/packages/server/graphql/types/CreditCard.ts b/packages/server/graphql/types/CreditCard.ts deleted file mode 100644 index 5964113a47c..00000000000 --- a/packages/server/graphql/types/CreditCard.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {GraphQLNonNull, GraphQLObjectType, GraphQLString} from 'graphql' -import {GQLContext} from '../graphql' - -const CreditCard = new GraphQLObjectType({ - name: 'CreditCard', - description: 'A credit card', - fields: () => ({ - brand: { - type: new GraphQLNonNull(GraphQLString), - description: 'The brand of the credit card, as provided by stripe' - }, - expiry: { - type: new GraphQLNonNull(GraphQLString), - description: 'The MM/YY string of the expiration date' - }, - last4: { - type: new GraphQLNonNull(GraphQLString), - description: 'The last 4 digits of a credit card' - } - }) -}) - -export default CreditCard diff --git a/packages/server/graphql/types/Invoice.ts b/packages/server/graphql/types/Invoice.ts deleted file mode 100644 index eaf0a636176..00000000000 --- a/packages/server/graphql/types/Invoice.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { - GraphQLFloat, - GraphQLID, - GraphQLList, - GraphQLNonNull, - GraphQLObjectType, - GraphQLString -} from 'graphql' -import connectionDefinitions from '../connectionDefinitions' -import {GQLContext} from '../graphql' -import Coupon from './Coupon' -import CreditCard from './CreditCard' -import GraphQLEmailType from './GraphQLEmailType' -import GraphQLISO8601Type from './GraphQLISO8601Type' -import GraphQLURLType from './GraphQLURLType' -import InvoiceLineItem from './InvoiceLineItem' -import InvoiceStatusEnum from './InvoiceStatusEnum' -import NextPeriodCharges from './NextPeriodCharges' -import PageInfoDateCursor from './PageInfoDateCursor' -import TierEnum from './TierEnum' - -/* Each invoice has 3 levels. - * L1 is a the invoice itself: how much to pay. - * L2 is line items (next month charges, added users, removed users, inactivity credits, previousBalance) with a quantity - * L3 is a detailed line item & is a breakdown of the L2 quantity (eg a user with the pause/unpause dates) - */ - -const Invoice = new GraphQLObjectType({ - name: 'Invoice', - description: 'A monthly billing invoice for an organization', - fields: () => ({ - id: { - type: new GraphQLNonNull(GraphQLID), - description: 'A shortid for the invoice' - }, - tier: { - type: new GraphQLNonNull(TierEnum), - description: 'The tier this invoice pays for' - }, - amountDue: { - type: new GraphQLNonNull(GraphQLFloat), - description: - 'The amount the card will be charged (total + startingBalance with a min value of 0)' - }, - createdAt: { - type: new GraphQLNonNull(GraphQLISO8601Type), - description: 'The datetime the invoice was first generated' - }, - coupon: { - type: Coupon, - description: 'The discount coupon information from Stripe, if any discount applied' - }, - total: { - type: new GraphQLNonNull(GraphQLFloat), - description: 'The total amount for the invoice (in USD)' - }, - billingLeaderEmails: { - type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(GraphQLEmailType))), - description: 'The emails the invoice was sent to' - }, - creditCard: { - type: CreditCard, - description: 'the card used to pay the invoice' - }, - endAt: { - type: new GraphQLNonNull(GraphQLISO8601Type), - description: 'The timestamp for the end of the billing cycle' - }, - invoiceDate: { - type: new GraphQLNonNull(GraphQLISO8601Type), - description: 'The date the invoice was created' - }, - lines: { - type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(InvoiceLineItem))), - description: 'An invoice line item for previous month adjustments' - }, - nextPeriodCharges: { - type: new GraphQLNonNull(NextPeriodCharges), - description: 'The details that comprise the charges for next month' - }, - orgId: { - type: new GraphQLNonNull(GraphQLID), - description: '*The organization id to charge' - }, - orgName: { - type: new GraphQLNonNull(GraphQLString), - description: 'The persisted name of the org as it was when invoiced' - }, - paidAt: { - type: GraphQLISO8601Type, - description: 'the datetime the invoice was successfully paid' - }, - payUrl: { - type: GraphQLString, - description: 'The URL to pay via stripe if payment was not collected in app' - }, - picture: { - type: GraphQLURLType, - description: 'The picture of the organization', - resolve: async ({picture}, _args, {dataLoader}) => { - return dataLoader.get('fileStoreAsset').load(picture) - } - }, - startAt: { - type: new GraphQLNonNull(GraphQLISO8601Type), - description: 'The timestamp for the beginning of the billing cycle' - }, - startingBalance: { - type: new GraphQLNonNull(GraphQLFloat), - description: 'The balance on the customer account (in cents)' - }, - status: { - type: new GraphQLNonNull(InvoiceStatusEnum), - description: - 'the status of the invoice. starts as pending, moves to paid or unpaid depending on if the payment succeeded' - } - }) -}) - -const {connectionType, edgeType} = connectionDefinitions({ - nodeType: Invoice, - edgeFields: () => ({ - cursor: { - type: GraphQLISO8601Type - } - }), - connectionFields: () => ({ - pageInfo: { - type: PageInfoDateCursor, - description: 'Page info with cursors coerced to ISO8601 dates' - } - }) -}) - -export const InvoiceConnection = connectionType -export const InvoiceEdge = edgeType -export default Invoice diff --git a/packages/server/graphql/types/InvoiceLineItem.ts b/packages/server/graphql/types/InvoiceLineItem.ts deleted file mode 100644 index 168f37f99d1..00000000000 --- a/packages/server/graphql/types/InvoiceLineItem.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { - GraphQLFloat, - GraphQLID, - GraphQLInt, - GraphQLList, - GraphQLNonNull, - GraphQLObjectType, - GraphQLString -} from 'graphql' -import {GQLContext} from '../graphql' -import InvoiceLineItemDetails from './InvoiceLineItemDetails' -import InvoiceLineItemEnum from './InvoiceLineItemEnum' - -const InvoiceLineItem = new GraphQLObjectType({ - name: 'InvoiceLineItem', - description: 'A single line item charge on the invoice', - fields: () => ({ - id: { - type: new GraphQLNonNull(GraphQLID), - description: 'The unique line item id' - }, - amount: { - type: new GraphQLNonNull(GraphQLFloat), - description: 'The amount for the line item (in USD)' - }, - description: { - type: GraphQLString, - description: 'A description of the charge. Only present if we have no idea what the charge is' - }, - details: { - type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(InvoiceLineItemDetails))), - description: - 'Array of user activity line items that roll up to total activity (add/leave/pause/unpause)', - resolve: ({details}) => details || [] - }, - quantity: { - type: GraphQLInt, - description: - 'The total number of days that all org users have been inactive during the billing cycle' - }, - type: { - type: new GraphQLNonNull(InvoiceLineItemEnum), - description: 'The line item type for a monthly billing invoice' - } - }) -}) - -export default InvoiceLineItem diff --git a/packages/server/graphql/types/InvoiceLineItemDetails.ts b/packages/server/graphql/types/InvoiceLineItemDetails.ts deleted file mode 100644 index 794c3d5b336..00000000000 --- a/packages/server/graphql/types/InvoiceLineItemDetails.ts +++ /dev/null @@ -1,40 +0,0 @@ -import {GraphQLFloat, GraphQLID, GraphQLNonNull, GraphQLObjectType} from 'graphql' -import {GQLContext} from '../graphql' -import GraphQLEmailType from './GraphQLEmailType' -import GraphQLISO8601Type from './GraphQLISO8601Type' - -const InvoiceLineItemDetails = new GraphQLObjectType({ - name: 'InvoiceLineItemDetails', - description: 'The per-user-action line item details,', - fields: () => ({ - id: { - type: new GraphQLNonNull(GraphQLID), - description: 'The unique detailed line item id' - }, - amount: { - type: new GraphQLNonNull(GraphQLFloat), - description: 'The amount for the line item (in USD)' - }, - email: { - type: new GraphQLNonNull(GraphQLEmailType), - description: 'The email affected by this line item change', - // the request could come before the hook fires - resolve: ({email}) => email || '*New User*' - }, - endAt: { - type: GraphQLISO8601Type, - description: - 'End of the event. Only present if a pause action gets matched up with an unpause action' - }, - parentId: { - type: new GraphQLNonNull(GraphQLID), - description: 'The parent line item id' - }, - startAt: { - type: GraphQLISO8601Type, - description: 'The timestamp for the beginning of the period of no charge' - } - }) -}) - -export default InvoiceLineItemDetails diff --git a/packages/server/graphql/types/InvoiceLineItemEnum.ts b/packages/server/graphql/types/InvoiceLineItemEnum.ts deleted file mode 100644 index 8b125a7852e..00000000000 --- a/packages/server/graphql/types/InvoiceLineItemEnum.ts +++ /dev/null @@ -1,14 +0,0 @@ -import {GraphQLEnumType} from 'graphql' - -const InvoiceLineItemEnum = new GraphQLEnumType({ - name: 'InvoiceLineItemEnum', - description: 'A big picture line item', - values: { - ADDED_USERS: {}, - INACTIVITY_ADJUSTMENTS: {}, - OTHER_ADJUSTMENTS: {}, - REMOVED_USERS: {} - } -}) - -export default InvoiceLineItemEnum diff --git a/packages/server/graphql/types/NextPeriodCharges.ts b/packages/server/graphql/types/NextPeriodCharges.ts deleted file mode 100644 index f3d23f0e144..00000000000 --- a/packages/server/graphql/types/NextPeriodCharges.ts +++ /dev/null @@ -1,34 +0,0 @@ -import {GraphQLFloat, GraphQLInt, GraphQLNonNull, GraphQLObjectType, GraphQLString} from 'graphql' -import {GQLContext} from '../graphql' -import GraphQLISO8601Type from './GraphQLISO8601Type' - -const NextPeriodCharges = new GraphQLObjectType({ - name: 'NextPeriodCharges', - description: 'A single line item for the charges for next month', - fields: () => ({ - amount: { - type: new GraphQLNonNull(GraphQLFloat), - description: 'The amount for the line item (in USD)' - }, - nextPeriodEnd: { - type: new GraphQLNonNull(GraphQLISO8601Type), - description: 'The datetime the next period will end' - }, - quantity: { - type: new GraphQLNonNull(GraphQLInt), - description: - 'The total number of days that all org users have been inactive during the billing cycle' - }, - unitPrice: { - type: GraphQLFloat, - description: - 'The per-seat monthly price of the subscription (in dollars), null if invoice is not per-seat' - }, - interval: { - type: GraphQLString, - description: '"year" if enterprise, else "month" for team' - } - }) -}) - -export default NextPeriodCharges diff --git a/packages/server/graphql/types/helpers/getFeatureTier.ts b/packages/server/graphql/types/helpers/getFeatureTier.ts index 46a09dda45e..8306fc49a22 100644 --- a/packages/server/graphql/types/helpers/getFeatureTier.ts +++ b/packages/server/graphql/types/helpers/getFeatureTier.ts @@ -1,4 +1,4 @@ -import {TierEnum} from '../../../database/types/Invoice' +import {TierEnum} from '../../public/resolverTypes' export const getFeatureTier = ({ tier, diff --git a/packages/server/package.json b/packages/server/package.json index 4f5d153f0d2..1a7b9e32630 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -3,7 +3,7 @@ "description": "An open-source app for building smarter, more agile teams.", "author": "Parabol Inc. (http://github.com/ParabolInc)", "license": "AGPL-3.0", - "version": "7.42.2", + "version": "7.43.0", "repository": { "type": "git", "url": "https://github.com/ParabolInc/parabol" @@ -123,7 +123,7 @@ "openai": "^4.53.0", "openapi-fetch": "^0.9.7", "oy-vey": "^0.12.1", - "parabol-client": "7.42.2", + "parabol-client": "7.43.0", "pg": "^8.5.1", "react": "^17.0.2", "react-dom": "^17.0.2", diff --git a/packages/server/postgres/helpers/toCreditCard.ts b/packages/server/postgres/helpers/toCreditCard.ts index 2f4e4c02b42..8b9dd97cdd7 100644 --- a/packages/server/postgres/helpers/toCreditCard.ts +++ b/packages/server/postgres/helpers/toCreditCard.ts @@ -1,5 +1,5 @@ import {sql} from 'kysely' -import CreditCard from '../../database/types/CreditCard' +import {CreditCard} from '../select' export const toCreditCard = (creditCard: CreditCard | undefined | null) => { if (!creditCard) return null return sql`(select json_populate_record(null::"CreditCard", ${JSON.stringify(creditCard)}))` diff --git a/packages/server/postgres/migrations/1723061869934_MeetingSettings-phase1.ts b/packages/server/postgres/migrations/1723061869934_MeetingSettings-phase1.ts new file mode 100644 index 00000000000..d8f1741cd7d --- /dev/null +++ b/packages/server/postgres/migrations/1723061869934_MeetingSettings-phase1.ts @@ -0,0 +1,73 @@ +import {Client} from 'pg' +import getPgConfig from '../getPgConfig' + +export async function up() { + const client = new Client(getPgConfig()) + await client.connect() + await client.query(` + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'NewMeetingPhaseTypeEnum') THEN + CREATE TYPE "NewMeetingPhaseTypeEnum" AS ENUM ( + 'ESTIMATE', + 'SCOPE', + 'SUMMARY', + 'agendaitems', + 'checkin', + 'TEAM_HEALTH', + 'discuss', + 'firstcall', + 'group', + 'lastcall', + 'lobby', + 'reflect', + 'updates', + 'vote', + 'RESPONSES' + ); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'MeetingTypeEnum') THEN + CREATE TYPE "MeetingTypeEnum" AS ENUM ( + 'action', + 'retrospective', + 'poker', + 'teamPrompt' + ); + END IF; + CREATE TABLE IF NOT EXISTS "MeetingSettings" ( + "id" VARCHAR(100) PRIMARY KEY, + "phaseTypes" "NewMeetingPhaseTypeEnum"[] NOT NULL, + "meetingType" "MeetingTypeEnum" NOT NULL, + "teamId" VARCHAR(100) NOT NULL, + "selectedTemplateId" VARCHAR(100), + "jiraSearchQueries" JSONB, + "maxVotesPerGroup" SMALLINT, + "totalVotes" SMALLINT, + "disableAnonymity" BOOLEAN, + "videoMeetingURL" VARCHAR(2056), + UNIQUE("teamId", "meetingType"), + CONSTRAINT "fk_teamId" + FOREIGN KEY("teamId") + REFERENCES "Team"("id") + ON DELETE CASCADE, + CONSTRAINT "fk_selectedTemplateId" + FOREIGN KEY("selectedTemplateId") + REFERENCES "MeetingTemplate"("id") + ON DELETE SET NULL + ); + CREATE INDEX IF NOT EXISTS "idx_MeetingSettings_teamId" ON "MeetingSettings"("teamId"); + END $$; +`) + await client.end() +} + +export async function down() { + const client = new Client(getPgConfig()) + await client.connect() + await client.query(` + DROP TABLE "MeetingSettings"; + DROP TYPE "NewMeetingPhaseTypeEnum"; + DROP TYPE "MeetingTypeEnum"; + ` /* Do undo magic */) + await client.end() +} diff --git a/packages/server/postgres/select.ts b/packages/server/postgres/select.ts index 53a2052c047..15bd911c327 100644 --- a/packages/server/postgres/select.ts +++ b/packages/server/postgres/select.ts @@ -1,5 +1,6 @@ import type {JSONContent} from '@tiptap/core' import {NotNull, sql} from 'kysely' +import {NewMeetingPhaseTypeEnum} from '../graphql/public/resolverTypes' import getKysely from './getKysely' export const selectTimelineEvent = () => { @@ -123,6 +124,7 @@ export const selectRetroReflections = () => fn('to_json', ['reactjis']).as('reactjis') ]) +export type CreditCard = {brand: string; expiry: string; last4: number} export const selectOrganizations = () => getKysely() .selectFrom('Organization') @@ -148,11 +150,7 @@ export const selectOrganizations = () => 'updatedAt', 'featureFlags' ]) - .select(({fn}) => [ - fn<{brand: string; expiry: string; last4: number} | null>('to_json', ['creditCard']).as( - 'creditCard' - ) - ]) + .select(({fn}) => [fn('to_json', ['creditCard']).as('creditCard')]) export const selectTeamPromptResponses = () => getKysely() @@ -169,3 +167,41 @@ export const selectTeamPromptResponses = () => ]) .$narrowType<{content: JSONContent}>() .select(({fn}) => [fn('to_json', ['reactjis']).as('reactjis')]) + +export type JiraSearchQuery = { + id: string + queryString: string + isJQL: boolean + projectKeyFilters?: string[] + lastUsedAt: Date +} + +export const selectMeetingSettings = () => + getKysely() + .selectFrom('MeetingSettings') + .select([ + 'id', + 'phaseTypes', + 'meetingType', + 'teamId', + 'selectedTemplateId', + 'jiraSearchQueries', + 'maxVotesPerGroup', + 'totalVotes', + 'disableAnonymity', + 'videoMeetingURL' + ]) + .$narrowType< + // NewMeeetingPhaseTypeEnum[] should be inferred from kysely-codegen, but it's not + | {meetingType: NotNull; phaseTypes: NewMeetingPhaseTypeEnum[]} + | { + meetingType: 'retrospective' + phaseTypes: NewMeetingPhaseTypeEnum[] + maxVotesPerGroup: NotNull + totalVotes: NotNull + disableAnonymity: NotNull + } + >() + .select(({fn}) => [ + fn('to_json', ['jiraSearchQueries']).as('jiraSearchQueries') + ]) diff --git a/packages/server/postgres/types/index.d.ts b/packages/server/postgres/types/index.d.ts index af90f4583a8..f5260cea94f 100644 --- a/packages/server/postgres/types/index.d.ts +++ b/packages/server/postgres/types/index.d.ts @@ -6,6 +6,7 @@ import { TeamMember as TeamMemberPG } from '../pg.d' import { + selectMeetingSettings, selectOrganizations, selectRetroReflections, selectSuggestedAction, @@ -39,3 +40,5 @@ export type TemplateScale = ExtractTypeFromQueryBuilderSelect + +export type MeetingSettings = ExtractTypeFromQueryBuilderSelect diff --git a/packages/server/utils/__tests__/isRequestToJoinDomainAllowed.test.ts b/packages/server/utils/__tests__/isRequestToJoinDomainAllowed.test.ts index dbe8589d6d8..543515656c3 100644 --- a/packages/server/utils/__tests__/isRequestToJoinDomainAllowed.test.ts +++ b/packages/server/utils/__tests__/isRequestToJoinDomainAllowed.test.ts @@ -1,9 +1,9 @@ /* eslint-env jest */ import {Insertable} from 'kysely' import {createPGTables, truncatePGTables} from '../../__tests__/common' -import {TierEnum} from '../../database/types/Invoice' import RootDataLoader from '../../dataloader/RootDataLoader' import generateUID from '../../generateUID' +import {TierEnum} from '../../graphql/public/resolverTypes' import getKysely from '../../postgres/getKysely' import {User} from '../../postgres/pg' import {OrganizationUser} from '../../postgres/types' diff --git a/packages/server/utils/analytics/analytics.ts b/packages/server/utils/analytics/analytics.ts index 6da67df7976..9424b16a716 100644 --- a/packages/server/utils/analytics/analytics.ts +++ b/packages/server/utils/analytics/analytics.ts @@ -91,7 +91,7 @@ export type TaskEstimateProperties = { export type MeetingSettings = { hasIcebreaker?: boolean hasTeamHealth?: boolean - disableAnonymity?: boolean + disableAnonymity?: boolean | null videoMeetingURL?: string | null } diff --git a/packages/server/utils/getUpcomingInvoiceId.ts b/packages/server/utils/getUpcomingInvoiceId.ts deleted file mode 100644 index a5d7daa2d86..00000000000 --- a/packages/server/utils/getUpcomingInvoiceId.ts +++ /dev/null @@ -1,3 +0,0 @@ -const getUpcomingInvoiceId = (orgId: string) => `upcoming_${orgId}` - -export default getUpcomingInvoiceId diff --git a/packages/server/utils/isPhaseAvailable.ts b/packages/server/utils/isPhaseAvailable.ts index dd81f068140..b581223b3eb 100644 --- a/packages/server/utils/isPhaseAvailable.ts +++ b/packages/server/utils/isPhaseAvailable.ts @@ -1,6 +1,6 @@ import isTeamHealthAvailable from 'parabol-client/utils/features/isTeamHealthAvailable' import {NewMeetingPhaseTypeEnum} from '../database/types/GenericMeetingPhase' -import {TierEnum} from '../database/types/Invoice' +import {TierEnum} from '../graphql/public/resolverTypes' const isPhaseAvailable = (tier: TierEnum) => (phaseType: NewMeetingPhaseTypeEnum) => { if (phaseType === 'TEAM_HEALTH') { diff --git a/packages/server/utils/stripe/StripeManager.ts b/packages/server/utils/stripe/StripeManager.ts index 06594ed064a..322323a1e96 100644 --- a/packages/server/utils/stripe/StripeManager.ts +++ b/packages/server/utils/stripe/StripeManager.ts @@ -194,6 +194,9 @@ export default class StripeManager { return this.stripe.customers.list({email}) } + async listInvoices(stripeId: string, startingAfter?: string) { + return this.stripe.invoices.list({customer: stripeId, starting_after: startingAfter}) + } async getSubscriptionItem(subscriptionId: string) { const allSubscriptionItems = await this.stripe.subscriptionItems.list({ subscription: subscriptionId diff --git a/yarn.lock b/yarn.lock index 3a072fdc2ea..267fb2c403b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16916,6 +16916,11 @@ markdown-it@^13.0.1: mdurl "^1.0.1" uc.micro "^1.0.5" +marked@^13.0.3: + version "13.0.3" + resolved "https://registry.yarnpkg.com/marked/-/marked-13.0.3.tgz#5c5b4a5d0198060c7c9bc6ef9420a7fed30f822d" + integrity sha512-rqRix3/TWzE9rIoFGIn8JmsVfhiuC8VIQ8IdX5TfzmeBucdY05/0UlzKaw0eVtpcN/OdVFpBk7CjKGo9iHJ/zA== + marked@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/marked/-/marked-4.3.0.tgz#796362821b019f734054582038b116481b456cf3"