mirror of
https://github.com/nestriness/nestri.git
synced 2025-12-12 08:45:38 +02:00
⭐ 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:
@@ -42,11 +42,6 @@ export const auth: MiddlewareHandler = async (c, next) => {
|
||||
"Invalid bearer token",
|
||||
);
|
||||
}
|
||||
|
||||
if (result.subject.type === "machine") {
|
||||
console.log("machine detected")
|
||||
return withActor(result.subject, next);
|
||||
}
|
||||
|
||||
if (result.subject.type === "user") {
|
||||
const teamID = c.req.header("x-nestri-team");
|
||||
@@ -58,12 +53,11 @@ export const auth: MiddlewareHandler = async (c, next) => {
|
||||
teamID,
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
return withActor(
|
||||
async () =>
|
||||
withActor(
|
||||
result.subject,
|
||||
next,
|
||||
);
|
||||
},
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -3,7 +3,7 @@ import { Hono } from "hono";
|
||||
import { auth } from "./auth";
|
||||
import { cors } from "hono/cors";
|
||||
import { TeamApi } from "./team";
|
||||
import { SteamApi } from "./steam";
|
||||
import { PolarApi } from "./polar";
|
||||
import { logger } from "hono/logger";
|
||||
import { Realtime } from "./realtime";
|
||||
import { AccountApi } from "./account";
|
||||
@@ -27,7 +27,7 @@ const routes = app
|
||||
.get("/", (c) => c.text("Hello World!"))
|
||||
.route("/realtime", Realtime.route)
|
||||
.route("/team", TeamApi.route)
|
||||
.route("/steam", SteamApi.route)
|
||||
.route("/polar", PolarApi.route)
|
||||
.route("/account", AccountApi.route)
|
||||
.route("/machine", MachineApi.route)
|
||||
.onError((error, c) => {
|
||||
|
||||
174
packages/functions/src/api/polar.ts
Normal file
174
packages/functions/src/api/polar.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { z } from "zod";
|
||||
import { Hono } from "hono";
|
||||
import { Resource } from "sst";
|
||||
import { notPublic } from "./auth";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { User } from "@nestri/core/user/index";
|
||||
import { assertActor } from "@nestri/core/actor";
|
||||
import { Polar } from "@nestri/core/polar/index";
|
||||
import { Examples } from "@nestri/core/examples";
|
||||
import { ErrorResponses, Result, validator } from "./common";
|
||||
import { ErrorCodes, VisibleError } from "@nestri/core/error";
|
||||
import { PlanType } from "@nestri/core/subscription/subscription.sql";
|
||||
import { WebhookVerificationError, validateEvent } from "@polar-sh/sdk/webhooks";
|
||||
|
||||
export namespace PolarApi {
|
||||
export const route = new Hono()
|
||||
.use(notPublic)
|
||||
.get("/",
|
||||
describeRoute({
|
||||
tags: ["Polar"],
|
||||
summary: "Create a Polar.sh customer portal",
|
||||
description: "Creates Polar.sh's customer portal url where the user can manage their payments",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
z.object({
|
||||
portalUrl: z.string()
|
||||
}).openapi({
|
||||
description: "The customer portal url",
|
||||
example: { portalUrl: "https://polar.sh/portal/39393jdie09292" }
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "customer portal url"
|
||||
},
|
||||
400: ErrorResponses[400],
|
||||
404: ErrorResponses[404],
|
||||
429: ErrorResponses[429],
|
||||
}
|
||||
}),
|
||||
async (c) => {
|
||||
const actor = assertActor("user");
|
||||
|
||||
const user = await User.fromID(actor.properties.userID);
|
||||
|
||||
if (!user)
|
||||
throw new VisibleError(
|
||||
"not_found",
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
"User not found",
|
||||
);
|
||||
|
||||
if (!user.polarCustomerID)
|
||||
throw new VisibleError(
|
||||
"not_found",
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
"User does not contain Polar customer ID"
|
||||
)
|
||||
|
||||
const portalUrl = await Polar.createPortal(user.polarCustomerID)
|
||||
|
||||
return c.json({
|
||||
data: {
|
||||
portalUrl
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
.post("/checkout",
|
||||
describeRoute({
|
||||
tags: ["Polar"],
|
||||
summary: "Create a checkout url",
|
||||
description: "Creates a Polar.sh's checkout url for the user to pay a subscription for this team",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
z.object({
|
||||
checkoutUrl: z.string()
|
||||
}).openapi({
|
||||
description: "The checkout url",
|
||||
example: { checkoutUrl: "https://polar.sh/portal/39393jdie09292" }
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "checkout url"
|
||||
},
|
||||
400: ErrorResponses[400],
|
||||
404: ErrorResponses[404],
|
||||
429: ErrorResponses[429],
|
||||
}
|
||||
}),
|
||||
validator(
|
||||
"json",
|
||||
z
|
||||
.object({
|
||||
planType: z.enum(PlanType),
|
||||
successUrl: z.string().url("Success url must be a valid url")
|
||||
})
|
||||
.openapi({
|
||||
description: "Details of the team to create",
|
||||
example: {
|
||||
planType: Examples.Subscription.planType,
|
||||
successUrl: "https://your-url.io/thanks"
|
||||
},
|
||||
})
|
||||
),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json");
|
||||
const actor = assertActor("user");
|
||||
|
||||
const user = await User.fromID(actor.properties.userID);
|
||||
|
||||
if (!user)
|
||||
throw new VisibleError(
|
||||
"not_found",
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
"User not found",
|
||||
);
|
||||
|
||||
if (!user.polarCustomerID)
|
||||
throw new VisibleError(
|
||||
"not_found",
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
"User does not contain Polar customer ID"
|
||||
)
|
||||
|
||||
const checkoutUrl = await Polar.createCheckout({ customerID: user.polarCustomerID, planType: body.planType, successUrl: body.successUrl })
|
||||
|
||||
return c.json({
|
||||
data: {
|
||||
checkoutUrl,
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
.post("/webhook",
|
||||
async (c) => {
|
||||
const requestBody = await c.req.text();
|
||||
|
||||
const webhookSecret = Resource.PolarWebhookSecret.value
|
||||
|
||||
const webhookHeaders = {
|
||||
"webhook-id": c.req.header("webhook-id") ?? "",
|
||||
"webhook-timestamp": c.req.header("webhook-timestamp") ?? "",
|
||||
"webhook-signature": c.req.header("webhook-signature") ?? "",
|
||||
};
|
||||
|
||||
let webhookPayload: ReturnType<typeof validateEvent>;
|
||||
try {
|
||||
webhookPayload = validateEvent(
|
||||
requestBody,
|
||||
webhookHeaders,
|
||||
webhookSecret,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof WebhookVerificationError) {
|
||||
return c.json({ received: false }, { status: 403 });
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
await Polar.handleWebhook(webhookPayload)
|
||||
|
||||
return c.json({ received: true });
|
||||
}
|
||||
)
|
||||
}
|
||||
46
packages/functions/src/api/subscription.ts
Normal file
46
packages/functions/src/api/subscription.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Hono } from "hono";
|
||||
import { notPublic } from "./auth";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { Examples } from "@nestri/core/examples";
|
||||
import { assertActor } from "@nestri/core/actor";
|
||||
import { ErrorResponses, Result } from "./common";
|
||||
import { Subscription } from "@nestri/core/subscription/index";
|
||||
|
||||
export namespace SubscriptionApi {
|
||||
export const route = new Hono()
|
||||
.use(notPublic)
|
||||
.get("/",
|
||||
describeRoute({
|
||||
tags: ["Subscription"],
|
||||
summary: "Get user subscriptions",
|
||||
description: "Get all user subscriptions",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Subscription.Info.array().openapi({
|
||||
description: "All the subscriptions this user has",
|
||||
example: [Examples.Subscription]
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "All user subscriptions"
|
||||
},
|
||||
400: ErrorResponses[400],
|
||||
404: ErrorResponses[404],
|
||||
429: ErrorResponses[429],
|
||||
}
|
||||
}),
|
||||
async (c) => {
|
||||
const actor = assertActor("user")
|
||||
|
||||
const subscriptions = await Subscription.fromUserID(actor.properties.userID)
|
||||
|
||||
return c.json({
|
||||
data: subscriptions
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -5,9 +5,12 @@ import { describeRoute } from "hono-openapi";
|
||||
import { User } from "@nestri/core/user/index";
|
||||
import { Team } from "@nestri/core/team/index";
|
||||
import { Examples } from "@nestri/core/examples";
|
||||
import { Polar } from "@nestri/core/polar/index";
|
||||
import { Member } from "@nestri/core/member/index";
|
||||
import { assertActor, withActor } from "@nestri/core/actor";
|
||||
import { ErrorResponses, Result, validator } from "./common";
|
||||
import { Subscription } from "@nestri/core/subscription/index";
|
||||
import { PlanType } from "@nestri/core/subscription/subscription.sql";
|
||||
|
||||
export namespace TeamApi {
|
||||
export const route = new Hono()
|
||||
@@ -49,7 +52,12 @@ export namespace TeamApi {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
z.literal("ok")
|
||||
z.object({
|
||||
checkoutUrl: z.string().openapi({
|
||||
description: "The checkout url to confirm subscription for this team",
|
||||
example: "https://polar.sh/checkout/2903038439320298377"
|
||||
})
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -63,17 +71,24 @@ export namespace TeamApi {
|
||||
}),
|
||||
validator(
|
||||
"json",
|
||||
Team.create.schema.omit({ id: true }).openapi({
|
||||
description: "Details of the team to create",
|
||||
//@ts-expect-error
|
||||
example: { ...Examples.Team, id: undefined }
|
||||
})
|
||||
Team.create.schema
|
||||
.pick({ slug: true, name: true })
|
||||
.extend({ planType: z.enum(PlanType), successUrl: z.string().url("Success url must be a valid url") })
|
||||
.openapi({
|
||||
description: "Details of the team to create",
|
||||
example: {
|
||||
slug: Examples.Team.slug,
|
||||
name: Examples.Team.name,
|
||||
planType: Examples.Subscription.planType,
|
||||
successUrl: "https://your-url.io/thanks"
|
||||
},
|
||||
})
|
||||
),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
const actor = assertActor("user");
|
||||
|
||||
const teamID = await Team.create(body);
|
||||
const teamID = await Team.create({ name: body.name, slug: body.slug });
|
||||
|
||||
await withActor(
|
||||
{
|
||||
@@ -82,14 +97,28 @@ export namespace TeamApi {
|
||||
teamID,
|
||||
},
|
||||
},
|
||||
() =>
|
||||
Member.create({
|
||||
async () => {
|
||||
await Member.create({
|
||||
first: true,
|
||||
email: actor.properties.email,
|
||||
}),
|
||||
});
|
||||
|
||||
await Subscription.create({
|
||||
planType: body.planType,
|
||||
userID: actor.properties.userID,
|
||||
// FIXME: Make this make sense
|
||||
tokens: body.planType === "free" ? 100 : body.planType === "pro" ? 1000 : body.planType === "family" ? 10000 : 0,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
return c.json({ data: "ok" })
|
||||
const checkoutUrl = await Polar.createCheckout({ planType: body.planType, successUrl: body.successUrl, teamID })
|
||||
|
||||
return c.json({
|
||||
data: {
|
||||
checkoutUrl,
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
24
packages/functions/sst-env.d.ts
vendored
24
packages/functions/sst-env.d.ts
vendored
@@ -57,10 +57,34 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"NestriFamilyMonthly": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"NestriFamilyYearly": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"NestriFreeMonthly": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"NestriProMonthly": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"NestriProYearly": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"PolarSecret": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"PolarWebhookSecret": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"Realtime": {
|
||||
"authorizer": string
|
||||
"endpoint": string
|
||||
|
||||
Reference in New Issue
Block a user