Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

gergo/web 1968 add features list #3332

Merged
merged 29 commits into from
Oct 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
f8053c6
feat(gatekeeper): add gatekeeper module feature flag
gjedlicska Oct 9, 2024
eb32874
feat(gatekeeper): add workspace pricing table domain
gjedlicska Oct 11, 2024
0a9e134
feat(gatekeeper): add checkout session creation
gjedlicska Oct 11, 2024
43c57c4
feat(gatekeeper): verify stripe signature
gjedlicska Oct 11, 2024
ed543c5
wip(gatekeeper): checkout callbacks
gjedlicska Oct 14, 2024
76a4fa8
feat(gatekeeper): add unlimited and academia plan types
gjedlicska Oct 15, 2024
88bc01f
refactor(envHelper): getStringFromEnv helper
gjedlicska Oct 16, 2024
8559dfb
chore(gatekeeper): add future todos
gjedlicska Oct 17, 2024
5a80cfb
feat(gatekeeper): add productId to the subscription domain
gjedlicska Oct 17, 2024
403c99c
feat(gatekeeper): add in memory repositories
gjedlicska Oct 17, 2024
d307a3d
feat(gatekeeper): add more errors
gjedlicska Oct 17, 2024
d7d9bce
feat(gatekeeper): complete checkout session service
gjedlicska Oct 17, 2024
6ae4b5d
feat(gatekeeper): add stripe client implementation
gjedlicska Oct 17, 2024
e7bfa38
feat(gatekeeper): add checkout session completion webhook callback path
gjedlicska Oct 17, 2024
1ceca73
feat(gendo): fix not needing env vars if gendo module is not enabled
gjedlicska Oct 17, 2024
4912819
feat(gatekeeper): require a license for billing
gjedlicska Oct 17, 2024
2ba5e75
chore(gatekeeper): cleanup before testing
gjedlicska Oct 17, 2024
9118f1a
feat(gatekeeper): subscriptionData parsing model
gjedlicska Oct 17, 2024
96e127c
ci: add billing integration and gatekeeper modules to test config
gjedlicska Oct 17, 2024
7bb99df
test(gatekeeper): add checkout service tests
gjedlicska Oct 18, 2024
81d09dd
feat(gatekeeper): make completeCheckout callback idempotent properly
gjedlicska Oct 18, 2024
cf5cf4b
feat(gatekeeper): move to knex based repositories
gjedlicska Oct 19, 2024
4770aaf
test(gatekeeper): billing repository tests
gjedlicska Oct 19, 2024
8e92369
feat(gatekeeper): add yearly billing cycle toggle
gjedlicska Oct 19, 2024
e5ad82d
feat(ci): add stripe integration context to test job
gjedlicska Oct 19, 2024
d7c35c9
feat(billingPage): conditionally render the checkout CTAs
gjedlicska Oct 19, 2024
ecfb7a1
fix(gatekeeper): remove flaky test condition
gjedlicska Oct 19, 2024
984258f
feat(helm): add billing integration feature flag
gjedlicska Oct 19, 2024
d24b6a9
Merge branch 'main' of github.com:specklesystems/speckle-server into …
gjedlicska Oct 20, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ workflows:
- test-server:
context:
- speckle-server-licensing
- stripe-integration
filters: &filters-allow-all
tags:
# run tests for any commit on any branch, including any tags
Expand Down Expand Up @@ -464,6 +465,7 @@ jobs:
REDIS_URL: 'redis://127.0.0.1:6379'
S3_REGION: '' # optional, defaults to 'us-east-1'
AUTOMATE_ENCRYPTION_KEYS_PATH: 'test/assets/automate/encryptionKeys.json'
FF_BILLING_INTEGRATION_ENABLED: 'true'
steps:
- checkout
- restore_cache:
Expand Down Expand Up @@ -550,6 +552,8 @@ jobs:
FF_WORKSPACES_SSO_ENABLED: 'false'
FF_MULTIPLE_EMAILS_MODULE_ENABLED: 'false'
FF_GENDOAI_MODULE_ENABLED: 'false'
FF_GATEKEEPER_MODULE_ENABLED: 'false'
FF_BILLING_INTEGRATION_ENABLED: 'false'

test-frontend-2:
docker: &docker-node-browsers-image
Expand Down
38 changes: 38 additions & 0 deletions packages/frontend-2/components/settings/workspaces/Billing.vue
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,25 @@
</FormButton>
</div>
</div>

<div v-if="isBillingIntegrationEnabled" class="flex flex-col space-y-10">
<SettingsSectionHeader title="Start payment" class="pt-10" subheading />
<div class="flex items-center">
<div class="flex-1 flex-col pr-6 gap-y-1">
<p class="text-body-xs font-medium text-foreground">Billing cycle</p>
<p class="text-body-xs text-foreground-2 leading-5 max-w-md">
Choose an annual billing cycle for 20% off
</p>
</div>
<FormSwitch v-model="isYearlyPlan" :show-label="true" name="annual billing" />
</div>
<div class="text-lg">Add the pricing table here</div>
<div class="flex justify-between">
<FormButton @click="teamCheckout">Team plan</FormButton>
<FormButton @click="proCheckout">Pro plan</FormButton>
<FormButton @click="businessCheckout">Business plan</FormButton>
</div>
</div>
</div>
</section>
</template>
Expand All @@ -65,6 +84,7 @@
import { graphql } from '~/lib/common/generated/gql'
import { useQuery } from '@vue/apollo-composable'
import { settingsWorkspaceBillingQuery } from '~/lib/settings/graphql/queries'
import { useIsBillingIntegrationEnabled } from '~/composables/globals'

graphql(`
fragment SettingsWorkspacesBilling_Workspace on Workspace {
Expand All @@ -86,6 +106,24 @@ const props = defineProps<{
workspaceId: string
}>()

const isBillingIntegrationEnabled = useIsBillingIntegrationEnabled()
const isYearlyPlan = ref(false)

const checkoutUrl = (plan: string) =>
`/api/v1/billing/workspaces/${
props.workspaceId
}/checkout-session/${plan}/${billingCycle()}`
const billingCycle = () => (isYearlyPlan.value ? 'yearly' : 'monthly')
const teamCheckout = () => {
window.location.href = checkoutUrl('team')
}
const proCheckout = () => {
window.location.href = checkoutUrl('pro')
}
const businessCheckout = () => {
window.location.href = checkoutUrl('business')
}

const { result } = useQuery(settingsWorkspaceBillingQuery, () => ({
workspaceId: props.workspaceId
}))
Expand Down
7 changes: 7 additions & 0 deletions packages/frontend-2/composables/globals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,11 @@ export const useIsGendoModuleEnabled = () => {
return ref(FF_GENDOAI_MODULE_ENABLED)
}

export const useIsBillingIntegrationEnabled = () => {
const {
public: { FF_BILLING_INTEGRATION_ENABLED }
} = useRuntimeConfig()
return ref(FF_BILLING_INTEGRATION_ENABLED)
}

export { useGlobalToast, useActiveUser, usePageQueryStandardFetchPolicy }
2 changes: 2 additions & 0 deletions packages/frontend-2/lib/common/generated/gql/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2501,6 +2501,7 @@ export type Query = {
* Either token or workspaceId must be specified, or both
*/
workspaceInvite?: Maybe<PendingWorkspaceCollaborator>;
workspacePricingPlans: Scalars['JSONObject']['output'];
};


Expand Down Expand Up @@ -6936,6 +6937,7 @@ export type QueryFieldArgs = {
workspace: QueryWorkspaceArgs,
workspaceBySlug: QueryWorkspaceBySlugArgs,
workspaceInvite: QueryWorkspaceInviteArgs,
workspacePricingPlans: {},
}
export type ResourceIdentifierFieldArgs = {
resourceId: {},
Expand Down
10 changes: 9 additions & 1 deletion packages/server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,15 @@ export async function init() {
}

app.use(corsMiddleware())
app.use(express.json({ limit: '100mb' }))
// there are some paths, that need the raw body
app.use((req, res, next) => {
const rawPaths = ['/api/v1/billing/webhooks']
if (rawPaths.includes(req.path)) {
express.raw({ type: 'application/json' })(req, res, next)
} else {
express.json({ limit: '100mb' })(req, res, next)
}
})
app.use(express.urlencoded({ limit: `${getFileSizeLimitMB()}mb`, extended: false }))

// Trust X-Forwarded-* headers (for https protocol detection)
Expand Down
3 changes: 3 additions & 0 deletions packages/server/assets/gatekeeper/typedefs/gatekeeper.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
extend type Query {
workspacePricingPlans: JSONObject!
}
2 changes: 2 additions & 0 deletions packages/server/modules/core/graph/generated/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2520,6 +2520,7 @@ export type Query = {
* Either token or workspaceId must be specified, or both
*/
workspaceInvite?: Maybe<PendingWorkspaceCollaborator>;
workspacePricingPlans: Scalars['JSONObject']['output'];
};


Expand Down Expand Up @@ -5702,6 +5703,7 @@ export type QueryResolvers<ContextType = GraphQLContext, ParentType extends Reso
workspace?: Resolver<ResolversTypes['Workspace'], ParentType, ContextType, RequireFields<QueryWorkspaceArgs, 'id'>>;
workspaceBySlug?: Resolver<ResolversTypes['Workspace'], ParentType, ContextType, RequireFields<QueryWorkspaceBySlugArgs, 'slug'>>;
workspaceInvite?: Resolver<Maybe<ResolversTypes['PendingWorkspaceCollaborator']>, ParentType, ContextType, Partial<QueryWorkspaceInviteArgs>>;
workspacePricingPlans?: Resolver<ResolversTypes['JSONObject'], ParentType, ContextType>;
};

export type ResourceIdentifierResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['ResourceIdentifier'] = ResolversParentTypes['ResourceIdentifier']> = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2504,6 +2504,7 @@ export type Query = {
* Either token or workspaceId must be specified, or both
*/
workspaceInvite?: Maybe<PendingWorkspaceCollaborator>;
workspacePricingPlans: Scalars['JSONObject']['output'];
};


Expand Down
150 changes: 150 additions & 0 deletions packages/server/modules/gatekeeper/clients/stripe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/* eslint-disable camelcase */
import {
CreateCheckoutSession,
GetSubscriptionData,
WorkspaceSubscription
} from '@/modules/gatekeeper/domain/billing'
import {
WorkspacePlanBillingIntervals,
WorkspacePricingPlans
} from '@/modules/gatekeeper/domain/workspacePricing'
import { Stripe } from 'stripe'

type GetWorkspacePlanPrice = (args: {
workspacePlan: WorkspacePricingPlans
billingInterval: WorkspacePlanBillingIntervals
}) => string

export const createCheckoutSessionFactory =
({
stripe,
frontendOrigin,
getWorkspacePlanPrice
}: {
stripe: Stripe
frontendOrigin: string
getWorkspacePlanPrice: GetWorkspacePlanPrice
}): CreateCheckoutSession =>
async ({
seatCount,
guestCount,
workspacePlan,
billingInterval,
workspaceSlug,
workspaceId
}) => {
//?settings=workspace/security&
const resultUrl = new URL(
`${frontendOrigin}/workspaces/${workspaceSlug}?workspace=${workspaceId}&settings=workspace/billing`
)

const price = getWorkspacePlanPrice({ billingInterval, workspacePlan })
const costLineItems: Stripe.Checkout.SessionCreateParams.LineItem[] = [
{ price, quantity: seatCount }
]
if (guestCount > 0)
costLineItems.push({
price: getWorkspacePlanPrice({
workspacePlan: 'guest',
billingInterval
}),
quantity: guestCount
})

const session = await stripe.checkout.sessions.create({
mode: 'subscription',

line_items: costLineItems,

success_url: `${resultUrl.toString()}&payment_status=success&session_id={CHECKOUT_SESSION_ID}`,

cancel_url: `${resultUrl.toString()}&payment_status=cancelled&session_id={CHECKOUT_SESSION_ID}`
})

if (!session.url) throw new Error('Failed to create an active checkout session')
return {
id: session.id,
url: session.url,
billingInterval,
workspacePlan,
workspaceId,
createdAt: new Date(),
updatedAt: new Date(),
paymentStatus: 'unpaid'
}
}

export const getSubscriptionDataFactory =
({
stripe
}: // getWorkspacePlanPrice
{
stripe: Stripe
// getWorkspacePlanPrice: GetWorkspacePlanPrice
}): GetSubscriptionData =>
async ({ subscriptionId }) => {
const stripeSubscription = await stripe.subscriptions.retrieve(subscriptionId)

return {
customerId:
typeof stripeSubscription.customer === 'string'
? stripeSubscription.customer
: stripeSubscription.customer.id,
subscriptionId,
products: stripeSubscription.items.data.map((subscriptionItem) => {
const productId =
typeof subscriptionItem.price.product === 'string'
? subscriptionItem.price.product
: subscriptionItem.price.product.id
const quantity = subscriptionItem.quantity
if (!quantity)
throw new Error(
'invalid subscription, we do not support products without quantities'
)
return {
priceId: subscriptionItem.price.id,
productId,
quantity,
subscriptionItemId: subscriptionItem.id
}
})
}
}

// this should be a reconcile subscriptions, we keep an accurate state in the DB
// on each change, we're reconciling that state to stripe
export const reconcileWorkspaceSubscriptionFactory =
({ stripe }: { stripe: Stripe }) =>
async ({
workspaceSubscription,
applyProrotation
}: {
workspaceSubscription: WorkspaceSubscription
applyProrotation: boolean
}) => {
const existingSubscriptionState = await getSubscriptionDataFactory({ stripe })({
subscriptionId: workspaceSubscription.subscriptionData.subscriptionId
})
const items: Stripe.SubscriptionUpdateParams.Item[] = []
for (const product of workspaceSubscription.subscriptionData.products) {
const existingProduct = existingSubscriptionState.products.find(
(p) => p.productId === product.productId
)
// we're adding a new product to the sub
if (!existingProduct) {
items.push({ quantity: product.quantity, price: product.priceId })
// we're moving a product to a new price for ie upgrading to a yearly plan
} else if (existingProduct.priceId !== product.priceId) {
items.push({ quantity: product.quantity, price: product.priceId })
items.push({ id: product.subscriptionItemId, deleted: true })
} else {
items.push({ quantity: product.quantity, id: product.subscriptionItemId })
}
}
// workspaceSubscription.subscriptionData.products.
// const item = workspaceSubscription.subscriptionData.products.find(p => p.)
await stripe.subscriptions.update(
workspaceSubscription.subscriptionData.subscriptionId,
{ items, proration_behavior: applyProrotation ? 'create_prorations' : 'none' }
)
}
Loading
Loading