feat(subscriptions): added polar as payment provider for subscriptions

* feature(dashboard): add polar / subscription

* wip(payments): manage subscription

* wip(payments): add free product, faq and some other improvements

* fix(root): change node to bundler in tsconfig

* wip(payments): display current subscription

* feat(dashboard): schedule project for deletion

* wip(payments): support custom products/subscriptions

* wip(payments): fix polar scripts

* wip(payments): add json package to dockerfiles
This commit is contained in:
Carl-Gerhard Lindesvärd
2025-02-26 11:24:00 +01:00
committed by GitHub
parent 86bf9dd064
commit 168ebc3430
105 changed files with 3395 additions and 463 deletions

View File

@@ -0,0 +1,170 @@
import {
db,
getOrganizationBillingEventsCountSerieCached,
getOrganizationBySlug,
} from '@openpanel/db';
import {
cancelSubscription,
changeSubscription,
createCheckout,
createPortal,
getProduct,
getProducts,
reactivateSubscription,
} from '@openpanel/payments';
import { zCheckout } from '@openpanel/validation';
import { getCache } from '@openpanel/redis';
import { subDays } from 'date-fns';
import { z } from 'zod';
import { TRPCBadRequestError } from '../errors';
import { createTRPCRouter, protectedProcedure } from '../trpc';
export const subscriptionRouter = createTRPCRouter({
getCurrent: protectedProcedure
.input(z.object({ organizationId: z.string() }))
.query(async ({ input }) => {
const organization = await getOrganizationBySlug(input.organizationId);
if (!organization.subscriptionProductId) {
return null;
}
return getProduct(organization.subscriptionProductId);
}),
checkout: protectedProcedure
.input(zCheckout)
.mutation(async ({ input, ctx }) => {
const [user, organization] = await Promise.all([
db.user.findFirstOrThrow({
where: {
id: ctx.session.user.id,
},
}),
db.organization.findFirstOrThrow({
where: {
id: input.organizationId,
},
}),
]);
if (
organization.subscriptionId &&
organization.subscriptionStatus === 'active'
) {
if (organization.subscriptionCanceledAt) {
await reactivateSubscription(organization.subscriptionId);
} else {
await changeSubscription(
organization.subscriptionId,
input.productId,
);
}
return null;
}
const checkout = await createCheckout({
priceId: input.productPriceId,
organizationId: input.organizationId,
projectId: input.projectId ?? undefined,
user,
ipAddress: ctx.req.ip,
});
return {
url: checkout.url,
};
}),
products: protectedProcedure
.input(z.object({ organizationId: z.string() }))
.query(async ({ input }) => {
const organization = await db.organization.findUniqueOrThrow({
where: {
id: input.organizationId,
},
select: {
subscriptionPeriodEventsCount: true,
},
});
return (
await getCache('polar:products', 60 * 60 * 24, () => getProducts())
).map((product) => {
const eventsLimit = product.metadata.eventsLimit;
return {
...product,
disabled:
typeof eventsLimit === 'number' &&
organization.subscriptionPeriodEventsCount >= eventsLimit
? 'This product is not applicable since you have exceeded the limits for this subscription.'
: null,
};
});
}),
usage: protectedProcedure
.input(
z.object({
organizationId: z.string(),
}),
)
.query(async ({ input }) => {
const organization = await db.organization.findUniqueOrThrow({
where: {
id: input.organizationId,
},
include: {
projects: { select: { id: true } },
},
});
if (
organization.hasSubscription &&
organization.subscriptionStartsAt &&
organization.subscriptionEndsAt
) {
return getOrganizationBillingEventsCountSerieCached(organization, {
startDate: organization.subscriptionStartsAt,
endDate: organization.subscriptionEndsAt,
});
}
return getOrganizationBillingEventsCountSerieCached(organization, {
startDate: subDays(new Date(), 30),
endDate: new Date(),
});
}),
cancelSubscription: protectedProcedure
.input(z.object({ organizationId: z.string() }))
.mutation(async ({ input }) => {
const organization = await getOrganizationBySlug(input.organizationId);
if (!organization.subscriptionId) {
throw TRPCBadRequestError('Organization has no subscription');
}
const res = await cancelSubscription(organization.subscriptionId);
return res;
}),
portal: protectedProcedure
.input(z.object({ organizationId: z.string() }))
.mutation(async ({ input }) => {
const organization = await getOrganizationBySlug(input.organizationId);
if (!organization.subscriptionCustomerId) {
throw TRPCBadRequestError('Organization has no subscription');
}
const portal = await createPortal({
customerId: organization.subscriptionCustomerId,
});
return {
url: portal.customerPortalUrl,
};
}),
});