feat(api): Add payments with Polar.sh (#264)

## Description
<!-- Briefly describe the purpose and scope of your changes -->


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Introduced a new subscription API endpoint for managing subscriptions
and products.
- Enhanced subscription management with new entities and
functionalities.
- Added functionality to retrieve current timestamps in both local and
UTC formats.
- Added Polar.sh integration with customer portal and checkout session
creation APIs.

- **Refactor**
- Redesigned team details to now present members and subscription
information instead of a plan type.
  - Enhanced member management by incorporating role assignments.
- Streamlined user data handling and removed legacy subscription event
logic.
  - Simplified error handling in actor functions for better clarity.
  - Updated plan types and UI labels to reflect new subscription tiers.
  - Improved database indexing for Steam user data.

- **Chores**
- Updated the database schema with new tables and fields to support
subscription, team, and member enhancements.
  - Extended identifier prefixes to broaden system integration.
- Added new secrets related to pricing plans in infrastructure
configuration.
  - Configured API and auth routing with new domain and routing rules.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
Wanjohi
2025-04-18 14:24:19 +03:00
committed by GitHub
parent 76d27e4708
commit 47e61599bb
40 changed files with 3304 additions and 425 deletions

View File

@@ -1,69 +1,16 @@
import { z } from "zod";
import { fn } from "../utils";
import { Resource } from "sst";
import { eq, and } from "../drizzle";
import { useTeam } from "../actor";
import { createEvent } from "../event";
// import { polarTable, Standing } from "./polar.sql.ts.test";
import { useTeam, useUserID } from "../actor";
import { Polar as PolarSdk } from "@polar-sh/sdk";
import { useTransaction } from "../drizzle/transaction";
import { validateEvent } from "@polar-sh/sdk/webhooks";
import { PlanType } from "../subscription/subscription.sql";
const polar = new PolarSdk({ accessToken: Resource.PolarSecret.value, server: Resource.App.stage !== "production" ? "sandbox" : "production" });
const planType = z.enum(PlanType)
export namespace Polar {
export const client = polar;
export const Info = z.object({
teamID: z.string(),
subscriptionID: z.string().nullable(),
customerID: z.string(),
subscriptionItemID: z.string().nullable(),
// standing: z.enum(Standing),
});
export type Info = z.infer<typeof Info>;
export const Checkout = z.object({
annual: z.boolean().optional(),
successUrl: z.string(),
cancelUrl: z.string(),
});
export const CheckoutSession = z.object({
url: z.string().nullable(),
});
export const CustomerSubscriptionEventType = [
"created",
"updated",
"deleted",
] as const;
export const Events = {
CustomerSubscriptionEvent: createEvent(
"polar.customer-subscription-event",
z.object({
type: z.enum(CustomerSubscriptionEventType),
status: z.string(),
teamID: z.string().min(1),
customerID: z.string().min(1),
subscriptionID: z.string().min(1),
subscriptionItemID: z.string().min(1),
}),
),
};
// export function get() {
// return useTransaction(async (tx) =>
// tx
// .select()
// .from(polarTable)
// .where(eq(polarTable.teamID, useTeam()))
// .execute()
// .then((rows) => rows.map(serialize).at(0)),
// );
// }
export const fromUserEmail = fn(z.string().min(1), async (email) => {
try {
const customers = await client.customers.list({ email })
@@ -81,89 +28,69 @@ export namespace Polar {
}
})
// export const setCustomerID = fn(Info.shape.customerID, async (customerID) =>
// useTransaction(async (tx) =>
// tx
// .insert(polarTable)
// .values({
// teamID: useTeam(),
// customerID,
// standing: "new",
// })
// .execute(),
// ),
// );
const getProductIDs = (plan: z.infer<typeof planType>) => {
switch (plan) {
case "free":
return [Resource.NestriFreeMonthly.value]
case "pro":
return [Resource.NestriProYearly.value, Resource.NestriProMonthly.value]
case "family":
return [Resource.NestriFamilyYearly.value, Resource.NestriFamilyMonthly.value]
default:
return [Resource.NestriFreeMonthly.value]
}
}
// export const setSubscription = fn(
// Info.pick({
// subscriptionID: true,
// subscriptionItemID: true,
// }),
// (input) =>
// useTransaction(async (tx) =>
// tx
// .update(polarTable)
// .set({
// subscriptionID: input.subscriptionID,
// subscriptionItemID: input.subscriptionItemID,
// })
// .where(eq(polarTable.teamID, useTeam()))
// .returning()
// .execute()
// .then((rows) => rows.map(serialize).at(0)),
// ),
// );
export const createPortal = fn(
z.string(),
async (customerId) => {
const session = await client.customerSessions.create({
customerId
})
// export const removeSubscription = fn(
// z.string().min(1),
// (stripeSubscriptionID) =>
// useTransaction((tx) =>
// tx
// .update(polarTable)
// .set({
// subscriptionItemID: null,
// subscriptionID: null,
// })
// .where(and(eq(polarTable.subscriptionID, stripeSubscriptionID)))
// .execute(),
// ),
// );
return session.customerPortalUrl
}
)
// export const setStanding = fn(
// Info.pick({
// subscriptionID: true,
// standing: true,
// }),
// (input) =>
// useTransaction((tx) =>
// tx
// .update(polarTable)
// .set({ standing: input.standing })
// .where(and(eq(polarTable.subscriptionID, input.subscriptionID!)))
// .execute(),
// ),
// );
//TODO: Implement this
export const handleWebhook = async(payload: ReturnType<typeof validateEvent>) => {
switch (payload.type) {
case "subscription.created":
const teamID = payload.data.metadata.teamID
}
}
// export const fromCustomerID = fn(Info.shape.customerID, (customerID) =>
// useTransaction((tx) =>
// tx
// .select()
// .from(polarTable)
// .where(and(eq(polarTable.customerID, customerID)))
// .execute()
// .then((rows) => rows.map(serialize).at(0)),
// ),
// );
export const createCheckout = fn(
z
.object({
planType: z.enum(PlanType),
customerEmail: z.string(),
successUrl: z.string(),
customerID: z.string(),
allowDiscountCodes: z.boolean(),
teamID: z.string()
})
.partial({
customerEmail: true,
allowDiscountCodes: true,
customerID: true,
teamID: true
}),
async (input) => {
const productIDs = getProductIDs(input.planType)
// function serialize(
// input: typeof polarTable.$inferSelect,
// ): z.infer<typeof Info> {
// return {
// teamID: input.teamID,
// customerID: input.customerID,
// subscriptionID: input.subscriptionID,
// subscriptionItemID: input.subscriptionItemID,
// standing: input.standing,
// };
// }
const checkoutUrl =
await client.checkouts.create({
products: productIDs,
customerEmail: input.customerEmail ?? useUserID(),
successUrl: `${input.successUrl}?checkout={CHECKOUT_ID}`,
allowDiscountCodes: input.allowDiscountCodes ?? false,
customerId: input.customerID,
customerMetadata: {
teamID: input.teamID ?? useTeam()
}
})
return checkoutUrl.url
})
}