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

@@ -5,6 +5,12 @@ import {
sendSlackNotification,
slackInstaller,
} from '@openpanel/integrations/src/slack';
import {
PolarWebhookVerificationError,
getProduct,
validatePolarEvent,
} from '@openpanel/payments';
import { publishEvent } from '@openpanel/redis';
import { zSlackAuthResponse } from '@openpanel/validation';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
@@ -102,3 +108,123 @@ export async function slackWebhook(
return reply.status(500).header('Content-Type', 'text/html').send(html);
}
}
export async function polarWebhook(
request: FastifyRequest<{
Querystring: unknown;
}>,
reply: FastifyReply,
) {
try {
const event = validatePolarEvent(
request.rawBody!,
request.headers as Record<string, string>,
process.env.POLAR_WEBHOOK_SECRET ?? '',
);
switch (event.type) {
case 'order.created': {
const metadata = z
.object({
organizationId: z.string(),
})
.parse(event.data.metadata);
if (event.data.billingReason === 'subscription_cycle') {
await db.organization.update({
where: {
id: metadata.organizationId,
},
data: {
subscriptionPeriodEventsCount: 0,
},
});
}
break;
}
case 'subscription.updated': {
const metadata = z
.object({
organizationId: z.string(),
userId: z.string(),
})
.parse(event.data.metadata);
const product = await getProduct(event.data.productId);
const eventsLimit = product.metadata?.eventsLimit;
const subscriptionPeriodEventsLimit =
typeof eventsLimit === 'number' ? eventsLimit : undefined;
if (!subscriptionPeriodEventsLimit) {
request.log.warn('No events limit found for product', { product });
}
// If we get a cancel event and we cant find it we should ignore it
// Since we only have one subscription per organization but you can have several in polar
// we dont want to override the existing subscription with a canceled one
// TODO: might be other events that we should handle like this?!
if (event.data.status === 'canceled') {
const orgSubscription = await db.organization.findFirst({
where: {
subscriptionCustomerId: event.data.customer.id,
subscriptionId: event.data.id,
subscriptionStatus: 'active',
},
});
if (!orgSubscription) {
return reply.status(202).send('OK');
}
}
await db.organization.update({
where: {
id: metadata.organizationId,
},
data: {
subscriptionId: event.data.id,
subscriptionCustomerId: event.data.customer.id,
subscriptionPriceId: event.data.priceId,
subscriptionProductId: event.data.productId,
subscriptionStatus: event.data.status,
subscriptionStartsAt: event.data.currentPeriodStart,
subscriptionCanceledAt: event.data.canceledAt,
subscriptionEndsAt:
event.data.status === 'canceled'
? event.data.cancelAtPeriodEnd
? event.data.currentPeriodEnd
: event.data.canceledAt
: event.data.currentPeriodEnd,
subscriptionCreatedByUserId: metadata.userId,
subscriptionInterval: event.data.recurringInterval,
subscriptionPeriodEventsLimit,
},
});
await publishEvent('organization', 'subscription_updated', {
organizationId: metadata.organizationId,
});
break;
}
}
reply.status(202).send('OK');
} catch (error) {
if (error instanceof PolarWebhookVerificationError) {
request.log.error('Polar webhook error', { error });
reply.status(403).send('');
}
throw error;
}
}
function isToday(date: Date) {
const today = new Date();
return (
date.getDate() === today.getDate() &&
date.getMonth() === today.getMonth() &&
date.getFullYear() === today.getFullYear()
);
}