mirror of
https://github.com/documenso/documenso.git
synced 2025-11-17 02:01:33 +10:00
feat: add organisations (#1820)
This commit is contained in:
@ -1,13 +1,11 @@
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
||||
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { createTeamFromPendingTeam } from '@documenso/lib/server-only/team/create-team';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { onSubscriptionCreated } from './on-subscription-created';
|
||||
import { onSubscriptionDeleted } from './on-subscription-deleted';
|
||||
import { onSubscriptionUpdated } from './on-subscription-updated';
|
||||
|
||||
@ -65,78 +63,18 @@ export const stripeWebhookHandler = async (req: Request): Promise<Response> => {
|
||||
|
||||
const event = stripe.webhooks.constructEvent(payload, signature, webhookSecret);
|
||||
|
||||
/**
|
||||
* Notes:
|
||||
* - Dropped invoice.payment_succeeded
|
||||
* - Dropped invoice.payment_failed
|
||||
* - Dropped checkout-session.completed
|
||||
*/
|
||||
return await match(event.type)
|
||||
.with('checkout.session.completed', async () => {
|
||||
.with('customer.subscription.created', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const session = event.data.object as Stripe.Checkout.Session;
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
|
||||
const customerId =
|
||||
typeof session.customer === 'string' ? session.customer : session.customer?.id;
|
||||
|
||||
// Attempt to get the user ID from the client reference id.
|
||||
let userId = Number(session.client_reference_id);
|
||||
|
||||
// If the user ID is not found, attempt to get it from the Stripe customer metadata.
|
||||
if (!userId && customerId) {
|
||||
const customer = await stripe.customers.retrieve(customerId);
|
||||
|
||||
if (!customer.deleted) {
|
||||
userId = Number(customer.metadata.userId);
|
||||
}
|
||||
}
|
||||
|
||||
// Finally, attempt to get the user ID from the subscription within the database.
|
||||
if (!userId && customerId) {
|
||||
const result = await prisma.user.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
customerId,
|
||||
},
|
||||
});
|
||||
|
||||
if (result?.id) {
|
||||
userId = result.id;
|
||||
}
|
||||
}
|
||||
|
||||
const subscriptionId =
|
||||
typeof session.subscription === 'string'
|
||||
? session.subscription
|
||||
: session.subscription?.id;
|
||||
|
||||
if (!subscriptionId) {
|
||||
return Response.json(
|
||||
{ success: false, message: 'Invalid session' } satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||
|
||||
// Handle team creation after seat checkout.
|
||||
if (subscription.items.data[0].price.metadata.plan === STRIPE_PLAN_TYPE.TEAM) {
|
||||
await handleTeamSeatCheckout({ subscription });
|
||||
|
||||
return Response.json(
|
||||
{ success: true, message: 'Webhook received' } satisfies StripeWebhookResponse,
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate user ID.
|
||||
if (!userId || Number.isNaN(userId)) {
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Invalid session or missing user ID',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
await onSubscriptionUpdated({ userId, subscription });
|
||||
await onSubscriptionCreated({ subscription });
|
||||
|
||||
return Response.json(
|
||||
{ success: true, message: 'Webhook received' } satisfies StripeWebhookResponse,
|
||||
@ -147,254 +85,14 @@ export const stripeWebhookHandler = async (req: Request): Promise<Response> => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
|
||||
const customerId =
|
||||
typeof subscription.customer === 'string'
|
||||
? subscription.customer
|
||||
: subscription.customer.id;
|
||||
|
||||
if (subscription.items.data[0].price.metadata.plan === STRIPE_PLAN_TYPE.TEAM) {
|
||||
const team = await prisma.team.findFirst({
|
||||
where: {
|
||||
customerId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'No team associated with subscription found',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
await onSubscriptionUpdated({ teamId: team.id, subscription });
|
||||
|
||||
return Response.json(
|
||||
{ success: true, message: 'Webhook received' } satisfies StripeWebhookResponse,
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
const result = await prisma.user.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
customerId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result?.id) {
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'User not found',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
await onSubscriptionUpdated({ userId: result.id, subscription });
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
success: true,
|
||||
message: 'Webhook received',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 200 },
|
||||
);
|
||||
})
|
||||
.with('invoice.payment_succeeded', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const invoice = event.data.object as Stripe.Invoice;
|
||||
const previousAttributes = event.data
|
||||
.previous_attributes as Partial<Stripe.Subscription> | null;
|
||||
|
||||
if (invoice.billing_reason !== 'subscription_cycle') {
|
||||
return Response.json(
|
||||
{
|
||||
success: true,
|
||||
message: 'Webhook received',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
const customerId =
|
||||
typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id;
|
||||
|
||||
const subscriptionId =
|
||||
typeof invoice.subscription === 'string'
|
||||
? invoice.subscription
|
||||
: invoice.subscription?.id;
|
||||
|
||||
if (!customerId || !subscriptionId) {
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Invalid invoice',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||
|
||||
if (subscription.status === 'incomplete_expired') {
|
||||
return Response.json(
|
||||
{
|
||||
success: true,
|
||||
message: 'Webhook received',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
if (subscription.items.data[0].price.metadata.plan === STRIPE_PLAN_TYPE.TEAM) {
|
||||
const team = await prisma.team.findFirst({
|
||||
where: {
|
||||
customerId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'No team associated with subscription found',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
await onSubscriptionUpdated({ teamId: team.id, subscription });
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
success: true,
|
||||
message: 'Webhook received',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
const result = await prisma.user.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
customerId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result?.id) {
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'User not found',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
await onSubscriptionUpdated({ userId: result.id, subscription });
|
||||
await onSubscriptionUpdated({ subscription, previousAttributes });
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
success: true,
|
||||
message: 'Webhook received',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 200 },
|
||||
);
|
||||
})
|
||||
.with('invoice.payment_failed', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const invoice = event.data.object as Stripe.Invoice;
|
||||
|
||||
const customerId =
|
||||
typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id;
|
||||
|
||||
const subscriptionId =
|
||||
typeof invoice.subscription === 'string'
|
||||
? invoice.subscription
|
||||
: invoice.subscription?.id;
|
||||
|
||||
if (!customerId || !subscriptionId) {
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Invalid invoice',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||
|
||||
if (subscription.status === 'incomplete_expired') {
|
||||
return Response.json(
|
||||
{
|
||||
success: true,
|
||||
message: 'Webhook received',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
if (subscription.items.data[0].price.metadata.plan === STRIPE_PLAN_TYPE.TEAM) {
|
||||
const team = await prisma.team.findFirst({
|
||||
where: {
|
||||
customerId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'No team associated with subscription found',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
await onSubscriptionUpdated({ teamId: team.id, subscription });
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
success: true,
|
||||
message: 'Webhook received',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
const result = await prisma.user.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
customerId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result?.id) {
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'User not found',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
await onSubscriptionUpdated({ userId: result.id, subscription });
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
success: true,
|
||||
message: 'Webhook received',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ success: true, message: 'Webhook received' } satisfies StripeWebhookResponse,
|
||||
{ status: 200 },
|
||||
);
|
||||
})
|
||||
@ -424,6 +122,13 @@ export const stripeWebhookHandler = async (req: Request): Promise<Response> => {
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
if (err instanceof Response) {
|
||||
const message = await err.json();
|
||||
console.error(message);
|
||||
|
||||
return err;
|
||||
}
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
@ -433,21 +138,3 @@ export const stripeWebhookHandler = async (req: Request): Promise<Response> => {
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export type HandleTeamSeatCheckoutOptions = {
|
||||
subscription: Stripe.Subscription;
|
||||
};
|
||||
|
||||
const handleTeamSeatCheckout = async ({ subscription }: HandleTeamSeatCheckoutOptions) => {
|
||||
if (subscription.metadata?.pendingTeamId === undefined) {
|
||||
throw new Error('Missing pending team ID');
|
||||
}
|
||||
|
||||
const pendingTeamId = Number(subscription.metadata.pendingTeamId);
|
||||
|
||||
if (Number.isNaN(pendingTeamId)) {
|
||||
throw new Error('Invalid pending team ID');
|
||||
}
|
||||
|
||||
return await createTeamFromPendingTeam({ pendingTeamId, subscription }).then((team) => team.id);
|
||||
};
|
||||
|
||||
@ -0,0 +1,214 @@
|
||||
import { OrganisationType, SubscriptionStatus } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import {
|
||||
createOrganisation,
|
||||
createOrganisationClaimUpsertData,
|
||||
} from '@documenso/lib/server-only/organisation/create-organisation';
|
||||
import { type Stripe } from '@documenso/lib/server-only/stripe';
|
||||
import type {
|
||||
InternalClaim,
|
||||
StripeOrganisationCreateMetadata,
|
||||
} from '@documenso/lib/types/subscription';
|
||||
import {
|
||||
INTERNAL_CLAIM_ID,
|
||||
ZStripeOrganisationCreateMetadataSchema,
|
||||
} from '@documenso/lib/types/subscription';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { extractStripeClaim } from './on-subscription-updated';
|
||||
|
||||
export type OnSubscriptionCreatedOptions = {
|
||||
subscription: Stripe.Subscription;
|
||||
};
|
||||
|
||||
type StripeWebhookResponse = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Todo: We might want to pull this into a job so we can do steps. Since if organisation creation passes but
|
||||
* fails after this would be automatically rerun by Stripe, which means duplicate organisations can be
|
||||
* potentially created.
|
||||
*/
|
||||
export const onSubscriptionCreated = async ({ subscription }: OnSubscriptionCreatedOptions) => {
|
||||
const customerId =
|
||||
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
|
||||
|
||||
// Todo: logging
|
||||
if (subscription.items.data.length !== 1) {
|
||||
console.error('No support for multiple items');
|
||||
|
||||
throw Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'No support for multiple items',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const subscriptionItem = subscription.items.data[0];
|
||||
const claim = await extractStripeClaim(subscriptionItem.price);
|
||||
|
||||
// Todo: logging
|
||||
if (!claim) {
|
||||
console.error(`Subscription claim on ${subscriptionItem.price.id} not found`);
|
||||
|
||||
throw Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: `Subscription claim on ${subscriptionItem.price.id} not found`,
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const organisationCreateData = subscription.metadata?.organisationCreateData;
|
||||
|
||||
// A new subscription can be for an existing organisation or a new one.
|
||||
const organisationId = organisationCreateData
|
||||
? await handleOrganisationCreate({
|
||||
customerId,
|
||||
claim,
|
||||
unknownCreateData: organisationCreateData,
|
||||
})
|
||||
: await handleOrganisationUpdate({
|
||||
customerId,
|
||||
claim,
|
||||
});
|
||||
|
||||
const status = match(subscription.status)
|
||||
.with('active', () => SubscriptionStatus.ACTIVE)
|
||||
.with('past_due', () => SubscriptionStatus.PAST_DUE)
|
||||
.otherwise(() => SubscriptionStatus.INACTIVE);
|
||||
|
||||
await prisma.subscription.create({
|
||||
data: {
|
||||
organisationId,
|
||||
status,
|
||||
customerId,
|
||||
planId: subscription.id,
|
||||
priceId: subscription.items.data[0].price.id,
|
||||
periodEnd: new Date(subscription.current_period_end * 1000),
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
type HandleOrganisationCreateOptions = {
|
||||
customerId: string;
|
||||
claim: InternalClaim;
|
||||
unknownCreateData: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the creation of an organisation.
|
||||
*/
|
||||
const handleOrganisationCreate = async ({
|
||||
customerId,
|
||||
claim,
|
||||
unknownCreateData,
|
||||
}: HandleOrganisationCreateOptions) => {
|
||||
let organisationCreateFlowData: StripeOrganisationCreateMetadata | null = null;
|
||||
|
||||
const parseResult = ZStripeOrganisationCreateMetadataSchema.safeParse(
|
||||
JSON.parse(unknownCreateData),
|
||||
);
|
||||
|
||||
if (!parseResult.success) {
|
||||
console.error('Invalid organisation create flow data');
|
||||
|
||||
throw Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Invalid organisation create flow data',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
organisationCreateFlowData = parseResult.data;
|
||||
|
||||
const createdOrganisation = await createOrganisation({
|
||||
name: organisationCreateFlowData.organisationName,
|
||||
userId: organisationCreateFlowData.userId,
|
||||
type: OrganisationType.ORGANISATION,
|
||||
customerId,
|
||||
claim,
|
||||
});
|
||||
|
||||
return createdOrganisation.id;
|
||||
};
|
||||
|
||||
type HandleOrganisationUpdateOptions = {
|
||||
customerId: string;
|
||||
claim: InternalClaim;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the updating an exist organisation claims.
|
||||
*/
|
||||
const handleOrganisationUpdate = async ({ customerId, claim }: HandleOrganisationUpdateOptions) => {
|
||||
const organisation = await prisma.organisation.findFirst({
|
||||
where: {
|
||||
customerId,
|
||||
},
|
||||
include: {
|
||||
subscription: true,
|
||||
organisationClaim: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organisation) {
|
||||
throw Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: `Organisation not found`,
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
// Todo: logging
|
||||
if (organisation.subscription) {
|
||||
console.error('Organisation already has a subscription');
|
||||
|
||||
// This should never happen
|
||||
throw Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: `Organisation already has a subscription`,
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
let newOrganisationType: OrganisationType = OrganisationType.ORGANISATION;
|
||||
|
||||
// Keep the organisation as personal if the claim is for an individual.
|
||||
if (
|
||||
organisation.type === OrganisationType.PERSONAL &&
|
||||
claim.id === INTERNAL_CLAIM_ID.INDIVIDUAL
|
||||
) {
|
||||
newOrganisationType = OrganisationType.PERSONAL;
|
||||
}
|
||||
|
||||
await prisma.organisation.update({
|
||||
where: {
|
||||
id: organisation.id,
|
||||
},
|
||||
data: {
|
||||
type: newOrganisationType,
|
||||
organisationClaim: {
|
||||
update: {
|
||||
originalSubscriptionClaimId: claim.id,
|
||||
...createOrganisationClaimUpsertData(claim),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return organisation.id;
|
||||
};
|
||||
@ -1,59 +1,163 @@
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { SubscriptionStatus } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { createOrganisationClaimUpsertData } from '@documenso/lib/server-only/organisation/create-organisation';
|
||||
import { type Stripe, stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type OnSubscriptionUpdatedOptions = {
|
||||
userId?: number;
|
||||
teamId?: number;
|
||||
subscription: Stripe.Subscription;
|
||||
previousAttributes: Partial<Stripe.Subscription> | null;
|
||||
};
|
||||
|
||||
type StripeWebhookResponse = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export const onSubscriptionUpdated = async ({
|
||||
userId,
|
||||
teamId,
|
||||
subscription,
|
||||
previousAttributes,
|
||||
}: OnSubscriptionUpdatedOptions) => {
|
||||
await prisma.subscription.upsert(
|
||||
mapStripeSubscriptionToPrismaUpsertAction(subscription, userId, teamId),
|
||||
);
|
||||
};
|
||||
const customerId =
|
||||
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
|
||||
|
||||
export const mapStripeSubscriptionToPrismaUpsertAction = (
|
||||
subscription: Stripe.Subscription,
|
||||
userId?: number,
|
||||
teamId?: number,
|
||||
): Prisma.SubscriptionUpsertArgs => {
|
||||
if ((!userId && !teamId) || (userId && teamId)) {
|
||||
throw new Error('Either userId or teamId must be provided.');
|
||||
// Todo: logging
|
||||
if (subscription.items.data.length !== 1) {
|
||||
console.error('No support for multiple items');
|
||||
|
||||
throw Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'No support for multiple items',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const organisation = await prisma.organisation.findFirst({
|
||||
where: {
|
||||
customerId,
|
||||
},
|
||||
include: {
|
||||
organisationClaim: true,
|
||||
subscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organisation) {
|
||||
throw Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: `Organisation not found`,
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
if (organisation.subscription?.planId !== subscription.id) {
|
||||
console.error('[WARNING]: Organisation has two subscriptions');
|
||||
}
|
||||
|
||||
const previousItem = previousAttributes?.items?.data[0];
|
||||
const updatedItem = subscription.items.data[0];
|
||||
|
||||
const previousSubscriptionClaimId = previousItem
|
||||
? await extractStripeClaimId(previousItem.price)
|
||||
: null;
|
||||
const updatedSubscriptionClaim = await extractStripeClaim(updatedItem.price);
|
||||
|
||||
if (!updatedSubscriptionClaim) {
|
||||
console.error(`Subscription claim on ${updatedItem.price.id} not found`);
|
||||
|
||||
throw Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: `Subscription claim on ${updatedItem.price.id} not found`,
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const newClaimFound = previousSubscriptionClaimId !== updatedSubscriptionClaim.id;
|
||||
|
||||
const status = match(subscription.status)
|
||||
.with('active', () => SubscriptionStatus.ACTIVE)
|
||||
.with('past_due', () => SubscriptionStatus.PAST_DUE)
|
||||
.otherwise(() => SubscriptionStatus.INACTIVE);
|
||||
|
||||
return {
|
||||
where: {
|
||||
planId: subscription.id,
|
||||
},
|
||||
create: {
|
||||
status: status,
|
||||
planId: subscription.id,
|
||||
priceId: subscription.items.data[0].price.id,
|
||||
periodEnd: new Date(subscription.current_period_end * 1000),
|
||||
userId: userId ?? null,
|
||||
teamId: teamId ?? null,
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
},
|
||||
update: {
|
||||
status: status,
|
||||
planId: subscription.id,
|
||||
priceId: subscription.items.data[0].price.id,
|
||||
periodEnd: new Date(subscription.current_period_end * 1000),
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
},
|
||||
};
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.subscription.update({
|
||||
where: {
|
||||
planId: subscription.id,
|
||||
},
|
||||
data: {
|
||||
organisationId: organisation.id,
|
||||
status: status,
|
||||
planId: subscription.id,
|
||||
priceId: subscription.items.data[0].price.id,
|
||||
periodEnd: new Date(subscription.current_period_end * 1000),
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
},
|
||||
});
|
||||
|
||||
// Override current organisation claim if new one is found.
|
||||
if (newClaimFound) {
|
||||
await tx.organisationClaim.update({
|
||||
where: {
|
||||
id: organisation.organisationClaim.id,
|
||||
},
|
||||
data: {
|
||||
originalSubscriptionClaimId: updatedSubscriptionClaim.id,
|
||||
...createOrganisationClaimUpsertData(updatedSubscriptionClaim),
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks the price metadata for a claimId, if it is missing it will fetch
|
||||
* and check the product metadata for a claimId.
|
||||
*
|
||||
* The order of priority is:
|
||||
* 1. Price metadata
|
||||
* 2. Product metadata
|
||||
*
|
||||
* @returns The claimId or null if no claimId is found.
|
||||
*/
|
||||
export const extractStripeClaimId = async (priceId: Stripe.Price) => {
|
||||
if (priceId.metadata.claimId) {
|
||||
return priceId.metadata.claimId;
|
||||
}
|
||||
|
||||
const productId = typeof priceId.product === 'string' ? priceId.product : priceId.product.id;
|
||||
|
||||
const product = await stripe.products.retrieve(productId);
|
||||
|
||||
return product.metadata.claimId || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks the price metadata for a claimId, if it is missing it will fetch
|
||||
* and check the product metadata for a claimId.
|
||||
*
|
||||
*/
|
||||
export const extractStripeClaim = async (priceId: Stripe.Price) => {
|
||||
const claimId = await extractStripeClaimId(priceId);
|
||||
|
||||
if (!claimId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const subscriptionClaim = await prisma.subscriptionClaim.findFirst({
|
||||
where: { id: claimId },
|
||||
});
|
||||
|
||||
if (!subscriptionClaim) {
|
||||
console.error(`Subscription claim ${claimId} not found`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return subscriptionClaim;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user